Files
forgotten_caves/game/src/sprites.rs
T
irrlichtandClaude Opus 5 4b4e779b3e Realign pack sheets into the entities layout and fill the sheet
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>
2026-09-20 20:46:02 +02:00

158 lines
5.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}