tools/realign_sheet.py strips the blank spacer rows from a documented Animals/Monsters sheet, drops the dark-box preview copies and moves the light frames into the manifest's slot columns, so a sheet pastes into the template as one block. Both sheets are pasted; the manifest gains the fifth foal/horse pair the Animals sheet actually has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
5.4 KiB
Rust
158 lines
5.4 KiB
Rust
//! 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 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);
|
||
}
|
||
}
|