Add the entities.png manifest, paste-in template and sprite loader
The sheet becomes a fixed grid (one row per entity, one column slot per animation state) described by a sidecar entities.toml that only maps names to rows; the loader reads present states and frame counts off the pixels, so assembling the sheet by hand needs no bookkeeping. tools/mktemplate.py renders the labelled template with low-alpha slot guides. The CGA atlas is kept for larger UI text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,3 +7,5 @@ edition = "2024"
|
||||
sim = { path = "../sim" }
|
||||
pbio = { git = "https://git.bnd.wtf/irrlicht/pbio.git", branch = "truecolor16" }
|
||||
png = "0.18.1"
|
||||
toml = "0.9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
+18
-13
@@ -41,25 +41,30 @@ impl Image {
|
||||
Image { width: info.width, height: info.height, pixels, opaque }
|
||||
}
|
||||
|
||||
/// The 8×8 tile whose top-left pixel is `(x, y)`.
|
||||
pub fn tile_at(&self, x: usize, y: usize) -> Tile {
|
||||
let w = self.width as usize;
|
||||
let mut tile = Tile { px: [0; 64], mask: 0 };
|
||||
for tr in 0..8 {
|
||||
for tc in 0..8 {
|
||||
let i = w * (y + tr) + x + tc;
|
||||
tile.px[tr * 8 + tc] = self.pixels[i];
|
||||
if self.opaque[i] {
|
||||
tile.mask |= 1 << (tr * 8 + tc);
|
||||
}
|
||||
}
|
||||
}
|
||||
tile
|
||||
}
|
||||
|
||||
/// Split this image into 8×8 tiles, row-major. Tile ID = flat index into the Vec.
|
||||
pub fn to_tileset(&self) -> Vec<Tile> {
|
||||
let w = self.width as usize;
|
||||
let cols = w / 8;
|
||||
let cols = self.width as usize / 8;
|
||||
let rows = self.height as usize / 8;
|
||||
let mut tiles = Vec::with_capacity(rows * cols);
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let mut tile = Tile { px: [0; 64], mask: 0 };
|
||||
for tr in 0..8 {
|
||||
for tc in 0..8 {
|
||||
let i = w * (row * 8 + tr) + col * 8 + tc;
|
||||
tile.px[tr * 8 + tc] = self.pixels[i];
|
||||
if self.opaque[i] {
|
||||
tile.mask |= 1 << (tr * 8 + tc);
|
||||
}
|
||||
}
|
||||
}
|
||||
tiles.push(tile);
|
||||
tiles.push(self.tile_at(col * 8, row * 8));
|
||||
}
|
||||
}
|
||||
tiles
|
||||
|
||||
+26
-24
@@ -4,6 +4,7 @@ use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use crate::assets::{Image, Tile};
|
||||
use crate::font::Font;
|
||||
use crate::sprites::{Sprites, State};
|
||||
use crate::ui;
|
||||
use crate::input::{GameAction, InputState};
|
||||
use sim::entity::{Brain, Entity, EntityKind, Prey};
|
||||
@@ -49,11 +50,6 @@ const CAM_SNAP_PX: f32 = 96.0;
|
||||
const HEN_COUNT: usize = 150;
|
||||
const FOX_COUNT: usize = 6;
|
||||
|
||||
/// Sprite indices in `entities.png`.
|
||||
const SPRITE_PLAYER: u16 = 0;
|
||||
const SPRITE_HEN: u16 = 1;
|
||||
const SPRITE_FOX: u16 = 2;
|
||||
|
||||
/// HUD panel: the 80 px strip right of the viewport.
|
||||
const HUD_X: i32 = VIEW_PX;
|
||||
const HUD_W: i32 = 320 - VIEW_PX;
|
||||
@@ -80,8 +76,11 @@ struct EntityLerp {
|
||||
|
||||
pub struct Game {
|
||||
tileset: Vec<Tile>,
|
||||
entity_tileset: Vec<Tile>,
|
||||
sprites: Sprites,
|
||||
font: Font,
|
||||
/// Sprite ids (= entity `type_id`) resolved from the manifest by name.
|
||||
id_hen: u16,
|
||||
id_fox: u16,
|
||||
sim: Sim,
|
||||
player_id: u32,
|
||||
/// Frame time not yet consumed by whole ticks.
|
||||
@@ -107,7 +106,8 @@ pub struct Game {
|
||||
impl Game {
|
||||
pub fn start(map_path: &str) -> Self {
|
||||
let tileset = Image::from_png("assets/tilesets/overworld.png").to_tileset();
|
||||
let entity_tileset = Image::from_png("assets/tilesets/entities.png").to_tileset();
|
||||
let sprites = Sprites::load("assets/tilesets/entities.png", "assets/tilesets/entities.toml");
|
||||
let (id_player, id_hen, id_fox) = (sprites.id("player"), sprites.id("hen"), sprites.id("fox"));
|
||||
let font = Font::from_png("assets/fonts/micro-chat-8x9.png", 8, 9);
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
@@ -117,7 +117,7 @@ impl Game {
|
||||
map.width, map.height, t0.elapsed().as_secs_f64() * 1e3);
|
||||
let mut sim = Sim::new(world);
|
||||
let player_id = sim.world.spawn_entity(
|
||||
EntityKind::Player, SPRITE_PLAYER, Brain::Idle, (0, 0), 100, 3);
|
||||
EntityKind::Player, id_player, Brain::Idle, (0, 0), 100, 3);
|
||||
|
||||
// Scatter animals over free tiles. A cheap LCG is plenty for placement.
|
||||
let mut seed: u32 = 12345;
|
||||
@@ -133,13 +133,15 @@ impl Game {
|
||||
}
|
||||
}
|
||||
};
|
||||
scatter(&mut sim, HEN_COUNT, SPRITE_HEN, Brain::Wander, 5, 1);
|
||||
scatter(&mut sim, FOX_COUNT, SPRITE_FOX, Brain::Hunt(Prey::Kind(SPRITE_HEN)), 8, 2);
|
||||
scatter(&mut sim, HEN_COUNT, id_hen, Brain::Wander, 5, 1);
|
||||
scatter(&mut sim, FOX_COUNT, id_fox, Brain::Hunt(Prey::Kind(id_hen)), 8, 2);
|
||||
|
||||
let mut game = Game {
|
||||
tileset,
|
||||
entity_tileset,
|
||||
sprites,
|
||||
font,
|
||||
id_hen,
|
||||
id_fox,
|
||||
sim,
|
||||
player_id,
|
||||
tick_accum_ms: 0.0,
|
||||
@@ -209,7 +211,15 @@ impl Game {
|
||||
|
||||
/// How an entity is referred to in the log. Ids only carry meaning for debugging.
|
||||
fn name(&self, id: u32, type_id: u16) -> &'static str {
|
||||
if id == self.player_id { "you" } else { species(type_id) }
|
||||
if id == self.player_id { "you" } else { self.species(type_id) }
|
||||
}
|
||||
|
||||
fn species(&self, type_id: u16) -> &'static str {
|
||||
match type_id {
|
||||
t if t == self.id_hen => "hen",
|
||||
t if t == self.id_fox => "fox",
|
||||
_ => "something",
|
||||
}
|
||||
}
|
||||
|
||||
/// React to what the sim reported for the last window. Returns `true` once the
|
||||
@@ -232,7 +242,7 @@ impl Game {
|
||||
}
|
||||
Event::Died { id, .. } if id == self.player_id => dead = true,
|
||||
Event::Died { type_id, by, .. } if by == self.player_id => {
|
||||
self.push_log(format!("you kill the {}", species(type_id)));
|
||||
self.push_log(format!("you kill the {}", self.species(type_id)));
|
||||
}
|
||||
Event::Attacked { .. } | Event::Died { .. } => {}
|
||||
}
|
||||
@@ -493,7 +503,7 @@ impl Game {
|
||||
let px = ex - cx;
|
||||
let py = ey - cy;
|
||||
if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; }
|
||||
if let Some(tile) = self.entity_tileset.get(e.type_id as usize) {
|
||||
if let Some(tile) = self.sprites.frame(e.type_id, State::Idle, 0) {
|
||||
pixelhelper::blit_tile(frame, W, px, py, tile);
|
||||
}
|
||||
}
|
||||
@@ -507,14 +517,6 @@ impl Game {
|
||||
}
|
||||
}
|
||||
|
||||
fn species(type_id: u16) -> &'static str {
|
||||
match type_id {
|
||||
SPRITE_HEN => "hen",
|
||||
SPRITE_FOX => "fox",
|
||||
_ => "something",
|
||||
}
|
||||
}
|
||||
|
||||
impl Game {
|
||||
fn render_hud(&self, frame: &mut [u16]) {
|
||||
const W: usize = 320;
|
||||
@@ -535,7 +537,7 @@ impl Game {
|
||||
self.font.draw(frame, W, x, y, HUD_DIM, &hp);
|
||||
y += LINE_H;
|
||||
|
||||
for (label, sprite) in [("hens", SPRITE_HEN), ("foxes", SPRITE_FOX)] {
|
||||
for (label, sprite) in [("hens", self.id_hen), ("foxes", self.id_fox)] {
|
||||
let n = self.count(sprite).to_string();
|
||||
self.font.draw(frame, W, x, y, HUD_DIM, label);
|
||||
self.font.draw(frame, W, x + inner_w - self.font.text_width(&n), y, HUD_DIM, &n);
|
||||
@@ -574,7 +576,7 @@ mod snapshot {
|
||||
for _ in 0..secs * 60 {
|
||||
g.update(&mut frame, 16, &input);
|
||||
}
|
||||
println!("after {secs}s: {} hens, {} foxes", g.count(super::SPRITE_HEN), g.count(super::SPRITE_FOX));
|
||||
println!("after {secs}s: {} hens, {} foxes", g.count(g.id_hen), g.count(g.id_fox));
|
||||
}
|
||||
|
||||
/// Renders a frame headlessly to the PNG named by `$SNAPSHOT` — a way to eyeball
|
||||
|
||||
@@ -5,6 +5,7 @@ mod assets;
|
||||
mod font;
|
||||
mod game;
|
||||
mod input;
|
||||
mod sprites;
|
||||
mod ui;
|
||||
|
||||
fn main() {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Entity sprite sheet with its sidecar manifest (`entities.toml`).
|
||||
//!
|
||||
//! The sheet is a grid of 8×8 cells, one entity per row, one fixed column slot per
|
||||
//! animation state. The manifest gives the layout and `name → row`; which states a
|
||||
//! row has and how many frames each holds is read off the pixels: frames are the
|
||||
//! leading non-empty cells of a slot, an empty slot means "no such state".
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::assets::{Image, Tile};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum State {
|
||||
Idle,
|
||||
Sit,
|
||||
Walk,
|
||||
Action1,
|
||||
Action2,
|
||||
Hit,
|
||||
Dead,
|
||||
}
|
||||
|
||||
const STATES: [(State, &str); 7] = [
|
||||
(State::Idle, "idle"), (State::Sit, "sit"), (State::Walk, "walk"),
|
||||
(State::Action1, "action1"), (State::Action2, "action2"),
|
||||
(State::Hit, "hit"), (State::Dead, "dead"),
|
||||
];
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Manifest {
|
||||
layout: Layout,
|
||||
entities: HashMap<String, u16>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Layout {
|
||||
cell: u32,
|
||||
origin: (u32, u32),
|
||||
states: HashMap<String, (u16, u8)>,
|
||||
}
|
||||
|
||||
/// One state's frames on a row: first cell index into `Sprites::tiles` and count.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Frames {
|
||||
start: usize,
|
||||
count: u8,
|
||||
}
|
||||
|
||||
pub struct Sprites {
|
||||
tiles: Vec<Tile>,
|
||||
/// Per row, per `STATES` index.
|
||||
rows: Vec<[Option<Frames>; 7]>,
|
||||
ids: HashMap<String, u16>,
|
||||
}
|
||||
|
||||
impl Sprites {
|
||||
pub fn load(sheet: &str, manifest: &str) -> Sprites {
|
||||
let text = std::fs::read_to_string(manifest)
|
||||
.unwrap_or_else(|e| panic!("failed to read {manifest}: {e}"));
|
||||
let m: Manifest = toml::from_str(&text)
|
||||
.unwrap_or_else(|e| panic!("bad manifest {manifest}: {e}"));
|
||||
assert_eq!(m.layout.cell, 8, "{manifest}: only 8 px cells are supported");
|
||||
|
||||
let slots: Vec<(u16, u8)> = STATES.iter()
|
||||
.map(|(_, name)| *m.layout.states.get(*name)
|
||||
.unwrap_or_else(|| panic!("{manifest}: layout.states.{name} missing")))
|
||||
.collect();
|
||||
let cols = slots.iter().map(|&(c, n)| c as usize + n as usize).max().unwrap_or(0);
|
||||
let row_count = m.entities.values().map(|&r| r as usize + 1).max().unwrap_or(0);
|
||||
|
||||
let img = Image::from_png(sheet);
|
||||
let (ox, oy) = (m.layout.origin.0 as usize, m.layout.origin.1 as usize);
|
||||
assert!(img.width as usize >= ox + cols * 8 && img.height as usize >= oy + row_count * 8,
|
||||
"{sheet}: {}×{} is too small for the manifest's {cols}×{row_count} cells",
|
||||
img.width, img.height);
|
||||
|
||||
let mut tiles = Vec::with_capacity(cols * row_count);
|
||||
for row in 0..row_count {
|
||||
for col in 0..cols {
|
||||
tiles.push(img.tile_at(ox + col * 8, oy + row * 8));
|
||||
}
|
||||
}
|
||||
|
||||
let rows = (0..row_count).map(|row| {
|
||||
let mut states = [None; 7];
|
||||
for (i, &(col, max)) in slots.iter().enumerate() {
|
||||
let start = row * cols + col as usize;
|
||||
let count = (0..max as usize)
|
||||
.take_while(|&f| tiles[start + f].mask != 0)
|
||||
.count() as u8;
|
||||
if count > 0 {
|
||||
states[i] = Some(Frames { start, count });
|
||||
}
|
||||
}
|
||||
states
|
||||
}).collect();
|
||||
|
||||
Sprites { tiles, rows, ids: m.entities }
|
||||
}
|
||||
|
||||
/// The sprite id (row) for a manifest name. Panics on unknown names — a typo in
|
||||
/// game code, not a runtime condition.
|
||||
pub fn id(&self, name: &str) -> u16 {
|
||||
*self.ids.get(name).unwrap_or_else(|| panic!("no sprite named {name:?} in the manifest"))
|
||||
}
|
||||
|
||||
/// Frame `i` (wrapping) of `state`, falling back to `Idle` for a missing state.
|
||||
/// `None` only for a sprite that has no idle frame either — an empty row.
|
||||
pub fn frame(&self, sprite: u16, state: State, i: usize) -> Option<&Tile> {
|
||||
let row = self.rows.get(sprite as usize)?;
|
||||
let f = row[state as usize].or(row[State::Idle as usize])?;
|
||||
Some(&self.tiles[f.start + i % f.count as usize])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sprites() -> Sprites {
|
||||
let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/tilesets/");
|
||||
Sprites::load(&format!("{root}entities.png"), &format!("{root}entities.toml"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_names_resolve_and_bootstrap_sprites_have_idle() {
|
||||
let s = sprites();
|
||||
for name in ["player", "hen", "fox"] {
|
||||
let id = s.id(name);
|
||||
let idle = s.frame(id, State::Idle, 0).unwrap();
|
||||
assert_ne!(idle.mask, 0, "{name}");
|
||||
// Only one idle frame so far: index 1 wraps to it. No walk yet: falls back.
|
||||
assert_eq!(s.frame(id, State::Idle, 1).unwrap().px, idle.px);
|
||||
assert_eq!(s.frame(id, State::Walk, 0).unwrap().px, idle.px, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_rows_have_nothing() {
|
||||
let s = sprites();
|
||||
assert!(s.frame(s.id("butterfly4"), State::Idle, 0).is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user