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:
2026-09-20 20:25:26 +02:00
co-authored by Claude Opus 5
parent 335962800f
commit 8bf086b06a
13 changed files with 555 additions and 83 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -5,6 +5,7 @@ mod assets;
mod font;
mod game;
mod input;
mod sprites;
mod ui;
fn main() {
+145
View File
@@ -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());
}
}