diff --git a/Cargo.lock b/Cargo.lock index 4db68d3..9d6edb4 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/assets/fonts/cga-8x8.png b/assets/fonts/cga-8x8.png new file mode 100644 index 0000000..0ebc4f0 Binary files /dev/null and b/assets/fonts/cga-8x8.png differ diff --git a/assets/packs/Mini-Medieval-Documented-8x8/.zip b/assets/packs/Mini-Medieval-Documented-8x8/.zip new file mode 100644 index 0000000..9b579c0 Binary files /dev/null and b/assets/packs/Mini-Medieval-Documented-8x8/.zip differ diff --git a/assets/tilesets/entities-template.png b/assets/tilesets/entities-template.png new file mode 100644 index 0000000..b020768 Binary files /dev/null and b/assets/tilesets/entities-template.png differ diff --git a/assets/tilesets/entities.png b/assets/tilesets/entities.png index eb59285..b020768 100644 Binary files a/assets/tilesets/entities.png and b/assets/tilesets/entities.png differ diff --git a/assets/tilesets/entities.toml b/assets/tilesets/entities.toml new file mode 100644 index 0000000..e10f717 --- /dev/null +++ b/assets/tilesets/entities.toml @@ -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 diff --git a/game/Cargo.toml b/game/Cargo.toml index 9274d10..c306414 100755 --- a/game/Cargo.toml +++ b/game/Cargo.toml @@ -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"] } diff --git a/game/src/assets.rs b/game/src/assets.rs index 5e8487a..95d4738 100755 --- a/game/src/assets.rs +++ b/game/src/assets.rs @@ -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 { - 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 diff --git a/game/src/game.rs b/game/src/game.rs index 314f7b1..e97bb1e 100755 --- a/game/src/game.rs +++ b/game/src/game.rs @@ -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, - entity_tileset: Vec, + 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 diff --git a/game/src/main.rs b/game/src/main.rs index 114cdaf..4d684d1 100755 --- a/game/src/main.rs +++ b/game/src/main.rs @@ -5,6 +5,7 @@ mod assets; mod font; mod game; mod input; +mod sprites; mod ui; fn main() { diff --git a/game/src/sprites.rs b/game/src/sprites.rs new file mode 100644 index 0000000..a6c7ef7 --- /dev/null +++ b/game/src/sprites.rs @@ -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, +} + +#[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 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()); + } +} diff --git a/notes/roadmap.md b/notes/roadmap.md index bef5491..4abfa00 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -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`), diff --git a/tools/mktemplate.py b/tools/mktemplate.py new file mode 100644 index 0000000..d47b3b2 --- /dev/null +++ b/tools/mktemplate.py @@ -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()