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
Generated
+113 -42
View File
@@ -154,9 +154,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.19.0"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
@@ -180,9 +180,9 @@ dependencies = [
[[package]]
name = "bytes"
version = "1.11.1"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]]
name = "calloop"
@@ -469,13 +469,39 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "game"
version = "0.1.0"
dependencies = [
"pbio",
"png",
"serde",
"sim",
"toml",
]
[[package]]
@@ -702,11 +728,12 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.82"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"once_cell",
"cfg-if",
"futures-util",
"wasm-bindgen",
]
@@ -921,19 +948,18 @@ dependencies = [
[[package]]
name = "num_enum"
version = "0.7.6"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
checksum = "683751d591e6d81200c39fb0d1032608b77724f34114db54f571ff1317b337c0"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
checksum = "6c11e44798ad209ccdd91fc192f0526a369a01234f7373e1b141c96d7cee4f0e"
dependencies = [
"proc-macro-crate",
"proc-macro2",
@@ -1306,9 +1332,9 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "portable-atomic"
version = "1.11.1"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]]
name = "portable-atomic-util"
@@ -1327,10 +1353,11 @@ checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
dependencies = [
"toml_datetime 0.6.3",
"toml_edit",
]
@@ -1535,6 +1562,15 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1729,24 +1765,44 @@ dependencies = [
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.6+spec-1.1.0"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0db3bae107c9522f86d361697dee1d7386a2ddcf659d5aea5159819a21a3c4a7"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
[[package]]
@@ -1755,9 +1811,15 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
"winnow 1.0.3",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -1825,9 +1887,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.105"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
@@ -1838,22 +1900,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.55"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.105"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1861,9 +1920,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.105"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -1874,9 +1933,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.105"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
@@ -1992,9 +2051,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.82"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -2385,13 +2444,25 @@ dependencies = [
[[package]]
name = "winnow"
version = "1.0.3"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
[[package]]
name = "wit-bindgen"
version = "0.46.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 7.4 KiB

+116
View File
@@ -0,0 +1,116 @@
# Sidecar manifest for entities.png.
#
# The sheet is a grid of 8×8 cells: one entity per row, one fixed column slot per
# state. States are Idle, Sit, Walk, Action 1, Action 2, Hit, Dead; a slot holds up to
# `max` frames, left to right. Frames present in a slot are detected from the PNG
# (a fully transparent cell ends the animation, an empty slot means the entity has no
# such state and Idle is shown instead) — so this file only says WHERE things are,
# never how many frames exist.
#
# `assets/tilesets/entities-template.png` (from `tools/mktemplate.py`) shows the
# same layout with labels and slot guides: paste sprites into it, save it as
# entities.png. The label column and header are outside `origin` and never loaded.
[layout]
cell = 8
origin = [64, 16] # x, y of the first sprite cell (right of the labels, below the header)
# state = [first column (cells, relative to origin), max frames]
[layout.states]
idle = [0, 2]
sit = [2, 2]
walk = [4, 4]
action1 = [8, 4]
action2 = [12, 2]
hit = [14, 2]
dead = [16, 2]
# name = row (0-based). Order is free, rows must be unique.
[entities]
player = 0
ghost1 = 1
ghost2 = 2
mutant_rat = 3
slime1 = 4
slime2 = 5
slime3 = 6
ent = 7
shroom = 8
floating_eye = 9
chick = 10
hen = 11
rooster = 12
fox_kit = 13
fox = 14
rabbit_kit = 15
rabbit = 16
mouse_pup = 17
mouse = 18
kitten1 = 19
cat1 = 20
kitten2 = 21
cat2 = 22
puppy = 23
dog = 24
foal1 = 25
horse1 = 26
foal2 = 27
horse2 = 28
foal3 = 29
horse3 = 30
foal4 = 31
horse4 = 32
baby_bird1 = 33
bird1 = 34
baby_bird2 = 35
bird2 = 36
cardinal_chick = 37
cardinal = 38
baby_eagle = 39
eagle = 40
baby_crow = 41
crow = 42
piglet = 43
pig = 44
calf = 45
cattle = 46
kid = 47
goat = 48
lamb = 49
sheep = 50
duckling = 51
duck = 52
wolf_pup = 53
wolf = 54
doe = 55
buck = 56
boar_piglet = 57
boar = 58
bear_cub = 59
bear = 60
squirrel_kit = 61
squirrel = 62
turkey_chick = 63
turkey = 64
opossum_joey = 65
opossum = 66
skunk_kit = 67
skunk = 68
raccoon_cub = 69
raccoon = 70
fish1 = 71
fish2 = 72
fish3 = 73
fish4 = 74
bees = 75
bugs1 = 76
bugs2 = 77
butterfly1 = 78
butterfly2 = 79
butterfly3 = 80
butterfly4 = 81
+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());
}
}
+19 -4
View File
@@ -10,9 +10,21 @@ branch `truecolor16`),
not in this repo.
Assets: `assets/packs/` holds the source packs untouched (Mini Medieval family by
VEXED, CC-BY); `assets/tilesets/` is what the game actually loads — sheets assembled
from the packs (`entities.png`: player, hen, fox); `assets/fonts/` the font sources and
the repacked atlas; `assets/maps/` the Tiled CSV maps.
VEXED, CC-BY — "documented" sheets: labels and state columns are pixel text in the
PNGs, readable by eye, not by script); `assets/tilesets/` is what the game actually
loads; `assets/fonts/` the font sources and repacked atlases (Micro Chat for the HUD,
CGA 8×8 kept for menus/titles); `assets/maps/` the Tiled CSV maps.
Entity sprites: `tilesets/entities.png` is assembled by hand from the packs into a
fixed grid described by the sidecar `entities.toml` — one row per entity, one column
slot per state (idle 2, sit 2, walk 4, action1 4, action2 2, hit 2, dead 2 frames
max). The manifest only maps `name → row` and the slot layout; which states and how
many frames a row has, the loader (`game/src/sprites.rs`) reads off the pixels
(leading non-empty cells; empty slot → falls back to idle). `tools/mktemplate.py`
renders the paste-in template `entities-template.png` (labels, header, slot guides at
alpha 48 — invisible to the loader) and carries existing sprites over, so adding rows
is: edit the manifest, rerun the tool, paste, save as `entities.png`. Game code refers
to sprites by name (`sprites.id("hen")`); the id doubles as the entity's `type_id`.
History: this fork started from a client/server multiplayer architecture (authoritative UDP
server, thin client with prediction). The scope was cut to single player in September 2026;
@@ -37,7 +49,10 @@ crate's `tick`/`set_action` interface is the seam a server could be wrapped arou
dimensions), `load_world` slices it into chunks, invisible solid border at the map rim.
See `notes/07-map-loader.md`
- [x] Tile rendering: chunk-based world rendering straight from `World` (`render_viewport`)
- [x] Sprite-based entity rendering: entity `type_id` → tile in `entities.png`, blitted with transparency
- [x] Sprite-based entity rendering: entity `type_id` → sprite row in `entities.png`
(via the `entities.toml` manifest, see Assets above), idle frame blitted with
transparency. Animation (walk while the lerp runs, hit/dead from events) is the
next step — the sheet layout and loader already carry the frames.
- [x] Hybrid tick loop: 24 Hz base tick, movement in `entity_tick` every 4th tick (6 Hz);
the game loop drives the sim with a fixed-timestep accumulator. See `notes/06-hybrid-tick-loop.md`
- [x] Intent model: one scheduled action per entity per movement window (`Sim::set_action`),
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Draw the paste-in template for entities.png from its manifest.
Reads assets/tilesets/entities.toml and writes entities-template.png: a label
column and header (outside the loaded area), and per-state slot guides tinted at
low alpha — below the loader's opacity threshold, so guides left under a sprite are
harmless. If entities.png already exists in this layout, its sprites are carried
over, so the template can be regenerated after adding rows to the manifest.
python3 tools/mktemplate.py
"""
import tomllib
from PIL import Image
MANIFEST = "assets/tilesets/entities.toml"
SHEET = "assets/tilesets/entities.png"
OUT = "assets/tilesets/entities-template.png"
FONT = "assets/fonts/micro-chat-8x9.png"
GUIDE_ALPHA = 48 # < 128: transparent for the game
GUIDE_TINTS = [(90, 160, 255), (255, 200, 90), (120, 230, 120), (255, 120, 120),
(200, 120, 255), (255, 160, 60), (160, 160, 160)]
LABEL_COLOR = (0, 0, 0, 255)
GRID_COLOR = (0, 0, 0, 90)
class Font:
"""Minimal reader for the CP437 atlas in font.rs's layout (32 columns)."""
def __init__(self, path, gw, gh):
self.img = Image.open(path).convert("L")
self.gw, self.gh = gw, gh
self.left, self.width = {}, {}
for code in range(256):
cx, cy = (code % 32) * gw, (code // 32) * gh
xs = [x for y in range(gh) for x in range(gw) if self.img.getpixel((cx + x, cy + y)) > 128]
if xs:
self.left[code], self.width[code] = min(xs), max(xs) - min(xs) + 1
def draw(self, dst, x, y, text, color):
for ch in text:
code = ord(ch) if ord(ch) < 128 else ord("?")
w = self.width.get(code, 0)
if w:
cx, cy = (code % 32) * self.gw + self.left[code], (code // 32) * self.gh
for gy in range(self.gh):
for gx in range(w):
if self.img.getpixel((cx + gx, cy + gy)) > 128 \
and x + gx < dst.size[0] and y + gy < dst.size[1]:
dst.putpixel((x + gx, y + gy), color)
x += w + 1 if w else self.gw // 2
return x
def main():
m = tomllib.load(open(MANIFEST, "rb"))
cell = m["layout"]["cell"]
ox, oy = m["layout"]["origin"]
states = list(m["layout"]["states"].items())
entities = sorted(m["entities"].items(), key=lambda kv: kv[1])
rows = max(r for _, r in entities) + 1
cols = max(col + n for _, (col, n) in states)
w, h = ox + cols * cell, oy + rows * cell
# A little margin right of the sprite area so the last header label fits.
out = Image.new("RGBA", (w + 8, h), (0, 0, 0, 0))
font = Font(FONT, 8, 9)
# Slot guides: one tint per state, alternating rows slightly for readability.
for i, (name, (col, n)) in enumerate(states):
r, g, b = GUIDE_TINTS[i % len(GUIDE_TINTS)]
for row in range(rows):
a = GUIDE_ALPHA if row % 2 == 0 else GUIDE_ALPHA * 2 // 3
x0, y0 = ox + col * cell, oy + row * cell
for y in range(y0, y0 + cell):
for x in range(x0, x0 + n * cell):
out.putpixel((x, y), (r, g, b, a))
# Header label, staggered over two lines so narrow slots don't collide, and a
# separator line at the slot's left edge.
label = {"action1": "act1", "action2": "act2"}.get(name, name)
font.draw(out, ox + col * cell + 1, 1 if i % 2 == 0 else 8, label, LABEL_COLOR)
for y in range(0, h):
out.putpixel((ox + col * cell, y), GRID_COLOR)
for y in range(0, h):
out.putpixel((w, y), GRID_COLOR)
# Cell grid lines (every cell, faint) inside the sprite area.
for x in range(ox, w, cell):
for y in range(oy, h):
if out.getpixel((x, y))[3] < GRID_COLOR[3]:
out.putpixel((x, y), GRID_COLOR)
for y in range(oy, h, cell):
for x in range(ox, w):
out.putpixel((x, y), GRID_COLOR)
# Row labels.
for name, row in entities:
font.draw(out, 1, oy + row * cell, name.replace("_", " "), LABEL_COLOR)
# Carry over sprites from an existing sheet in this layout.
try:
old = Image.open(SHEET).convert("RGBA")
if old.size[0] >= w and old.size[1] >= oy:
region = old.crop((ox, oy, w, min(h, old.size[1])))
out.paste(region, (ox, oy), region)
print(f"carried over sprites from {SHEET}")
except FileNotFoundError:
pass
out.save(OUT)
print(f"wrote {OUT}: {w}×{h}, {rows} rows, {cols} columns")
if __name__ == "__main__":
main()