//! 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, } #[derive(Deserialize)] struct Layout { cell: u32, origin: (u32, u32), states: HashMap, } /// 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, /// Per row, per `STATES` index. rows: Vec<[Option; 7]>, ids: HashMap, } 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 frames_are_read_off_the_sheet() { let s = sprites(); let hen = s.id("hen"); let idle0 = s.frame(hen, State::Idle, 0).unwrap(); let idle1 = s.frame(hen, State::Idle, 1).unwrap(); assert_ne!(idle0.mask, 0); assert_ne!(idle0.px, idle1.px, "hen has two distinct idle frames"); assert_eq!(s.frame(hen, State::Idle, 2).unwrap().px, idle0.px, "wraps"); // The hen walks with 4 frames; index 4 wraps back to the first. let walk0 = s.frame(hen, State::Walk, 0).unwrap(); assert_eq!(s.frame(hen, State::Walk, 4).unwrap().px, walk0.px); assert_ne!(s.frame(hen, State::Walk, 3).unwrap().px, walk0.px); } #[test] fn missing_state_falls_back_to_idle() { let s = sprites(); let ghost = s.id("ghost1"); // ghosts never sit assert_eq!(s.frame(ghost, State::Sit, 0).unwrap().px, s.frame(ghost, State::Idle, 0).unwrap().px); } #[test] fn player_row_has_only_a_bootstrap_idle() { let s = sprites(); let p = s.id("player"); assert!(s.frame(p, State::Idle, 0).is_some()); assert_eq!(s.frame(p, State::Walk, 0).unwrap().px, s.frame(p, State::Idle, 0).unwrap().px); } }