From 01cd73ca3bd75ee2a47fc2062e489cc312872a52 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Mon, 15 Jun 2026 23:35:18 -0500 Subject: [PATCH] Board layers --- CLAUDE.md | 136 ++- kiln-core/src/action.rs | 66 +- kiln-core/src/archetype.rs | 10 +- kiln-core/src/board.rs | 327 ++++-- kiln-core/src/floor.rs | 304 +----- kiln-core/src/game.rs | 89 +- kiln-core/src/glyph.rs | 18 +- kiln-core/src/layer.rs | 290 ++++++ kiln-core/src/lib.rs | 19 +- kiln-core/src/map_file.rs | 985 ++++++------------ kiln-core/src/object_def.rs | 11 +- kiln-core/src/script.rs | 514 ++++++--- kiln-core/src/tests/actions.rs | 103 +- kiln-core/src/tests/collision.rs | 21 +- kiln-core/src/tests/game_portals.rs | 44 +- kiln-core/src/tests/map_file/grid_errors.rs | 68 +- kiln-core/src/tests/map_file/mod.rs | 114 +- .../src/tests/map_file/object_placement.rs | 130 ++- .../src/tests/map_file/player_placement.rs | 75 +- .../src/tests/map_file/portal_placement.rs | 75 +- kiln-core/src/tests/map_file/round_trip.rs | 262 ++--- kiln-core/src/tests/mod.rs | 22 +- kiln-core/src/tests/movement.rs | 62 +- kiln-core/src/tests/scripting.rs | 137 ++- kiln-core/src/utils.rs | 50 +- kiln-core/src/world.rs | 9 +- kiln-tui/src/main.rs | 36 +- kiln-tui/src/ui.rs | 18 +- maps/start.toml | 222 ++-- 29 files changed, 2166 insertions(+), 2051 deletions(-) create mode 100644 kiln-core/src/layer.rs diff --git a/CLAUDE.md b/CLAUDE.md index 2bd3ae1..5c26ac1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,16 +67,23 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `FontSpec { path: String, tile_w: u32, tile_h: u32 }` — optional per-board bitmap font. When `None`, the app default is used. **`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`): -- `ObjectDef` — a scripted tile: `x`, `y`, `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option`, `tags: HashSet`, `name: Option`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults. `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black. +- `ObjectDef` — a scripted tile: `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option`, `tags: HashSet`, `name: Option`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black. + +**`kiln-core/src/layer.rs`** — palette layers (the map-file/draw-stack unit): +- `Layer { cells: Vec<(Glyph, Archetype)> }` (`pub(crate)`) — one row-major draw layer. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the layer beneath shows through; solidity comes from the archetype, independent of transparency. +- `LayerData { content, palette: HashMap }` — serde for one `[[layers]]` entry. `PaletteEntry` is a single flat struct with a `kind: String` discriminator plus all-optional fields (`tile`, `fg`, `bg`, `generator`, `solid`, `opaque`, `pushable`, `script_name`, `tags`, `name`, `target_map`, `target_entry`); only the fields relevant to the kind are read. (One flat struct, not an enum, because `kind` is open-ended — any archetype name *or* a meta-kind.) +- `build_layer(data, w, h, &mut StdRand, &mut Vec) -> Result<(Layer, Vec), String>` — resolves the palette (via `resolve_entry`), validates the grid dims (the only hard `Err`), and walks the grid building the `Layer` plus a `Vec` (`Object(ObjectTemplate, x, y)` / `Portal(PortalTemplate, x, y)` / `Player(x, y)`) for the map loader to resolve across layers. Procedural floors roll a fresh glyph per cell from the shared seeded `StdRand`. +- `resolve_entry` maps `kind` → `Resolved`: `empty` → transparent cell; `floor` → a generator (per-cell roll) or a fixed visual-only `Empty` glyph; `object`/`portal`/`player` → a placement; any other string → `Archetype::try_from` (`Ok` → terrain cell; `Err` → visible `ErrorBlock` + logged error). A `portal` missing `name`/`target_map`/`target_entry` is dropped to a transparent cell with an error. **`kiln-core/src/board.rs`** — the board data type: -- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; `pub(crate)`), `floor: Vec` + `floor_spec: Option` (visual floor layer; `pub(crate)`), `player: Player`, `objects: BTreeMap`, `next_object_id: ObjectId`, `portals: Vec`, `font: Option`, `zoom: u32` (integer tile scale factor), `board_script_name: Option` (name of a board-level script in the world script pool, if any), `load_errors: Vec` (`pub(crate)`). Scripts are **not** stored on `Board` — they live in [`World::scripts`]. -- `get(x, y) -> &(Glyph, Archetype)` / `get_mut` — direct cell access (panics OOB). -- `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`. Priority: player > solid object > non-solid object with nonzero tile > non-Empty grid archetype > floor. **Includes the player** — `glyph_at` at the player's cell returns `Glyph::player()`. An `Empty` cell's own glyph is always ignored (floor supersedes it). Front-ends call this per-cell instead of managing a separate player overlay. -- `solid_at(x, y) -> Option` — the cell's single solid occupant (player checked first, then objects, then grid archetype). +- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `layers: Vec` (bottom→top draw stack; `pub(crate)`), `player: Player`, `objects: BTreeMap`, `next_object_id: ObjectId`, `portals: Vec`, `board_script_name: Option` (name of a board-level script in the world script pool, if any), `load_errors: Vec` (`pub(crate)`), `registry: HashMap`. Scripts are **not** stored on `Board` — they live in [`World::scripts`]. The visual floor is no longer a separate field: it is just `Empty` cells with a visible glyph on a lower layer. +- `layer_count() -> usize`; `get(z, x, y) -> &(Glyph, Archetype)` / `get_mut(z, x, y)` — per-layer cell access (panics OOB). +- `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`. The player draws on top (returns `Glyph::player()` at its cell); otherwise layers are walked **top-down** and the first thing that draws wins: a solid object on that layer (always) or a visible non-solid object, else a portal on that layer, else the layer's terrain cell (a solid always draws; a non-solid only if `tile != 0`). Nothing anywhere → the canonical black `Empty` glyph. Front-ends call this per-cell instead of managing a separate player overlay. +- `solid_at(x, y) -> Option` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`). - `is_passable(x, y)` — convenience inverse of `solid_at`. - `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space? -- `push(x, y, dir)` — mutating: shoves the chain one step, leaving `Empty` behind. +- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). Crates/pushers move within their own layer (found via `solid_cell_layer`). +- `pusher_cells() -> Vec<(usize, usize)>` — coordinates of every `Pusher` terrain cell across all layers (used by the pusher heartbeat). - `add_object(obj) -> ObjectId`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object queries. - `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord. - `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface. @@ -93,11 +100,9 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `Action` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String)`, `Delay(f64)` (never reaches the board queue — consumed by `ScriptHost::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec)`. - `action_to_map(action) -> rhai::Map` — converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. The map always has a `"type"` key; other keys carry the payload. `Scroll` emits `type` only (lines are not inspectable via the map API). `Log` is flattened to its first span's text. -**`kiln-core/src/floor.rs`** — the visual floor layer: -- The floor is a cosmetic per-cell background drawn wherever a cell would render as `Archetype::Empty` (it is *how* `Empty` is drawn; the cell's own `Empty` glyph is ignored). Computed once at load and cached on `Board::floor`, so there is no per-frame cost / flicker; the raw `FloorSpec` is also kept (`Board::floor_spec`) so `map_file::save` round-trips it. -- `FloorGenerator` (`Grass`/`Dirt`/`Stone`) — procedural textures differing only in color scheme and texture-char probability. `from_name(&str)` parses the map-file name; `generate(&mut StdRand)` picks a dark, low-saturation ground color (green/brown/gray) and, with the generator's probability, scatters a lighter texture char (grass `, . \` '`; dirt `. : , ;`; stone `. ,`). Uses `tinyrand` (already a workspace dep, WASM-safe) seeded with a fixed `FLOOR_SEED` for deterministic, testable output. -- `FloorSpec` — `#[serde(untagged)]` enum for the `[floor]` declaration: `Generator(String)` (whole board), `Single(FloorGlyph)` (one fixed glyph everywhere), or `Grid { content, palette: HashMap }` (a grid with its own palette). Untagged ordering matters: a string → `Generator`; a table with `tile` → `Single`; a table with `content` → `Grid`. `FloorTile` (untagged) is a grid-palette entry: a generator name (string) or a `FloorGlyph` (table). `FloorGlyph { tile: TileIndex, fg, bg }` reuses `map_file::TileIndex` (now `pub`) and `parse_color`. -- `build_floor(&Option, w, h, &mut Vec) -> Vec` — best-effort/nonfatal (like the map loader): unknown generators/chars and grid-dimension mismatches are recorded on the error vec and that cell falls back to the canonical black-on-black space; `None` → an all-empty floor (the historical look). +**`kiln-core/src/floor.rs`** — procedural floor generators: +- A "floor" is just a non-solid, visible `Empty` cell on a layer (a palette entry with `kind = "floor"`). This module only owns the generators; the actual per-cell placement happens in `layer.rs` during load. +- `FloorGenerator` (`Grass`/`Dirt`/`Stone`) — procedural textures differing only in color scheme and texture-char probability. `from_name(&str)` parses the map-file name; `generate(&mut StdRand)` picks a dark, low-saturation ground color (green/brown/gray) and, with the generator's probability, scatters a lighter texture char (grass `, . \` '`; dirt `. : , ;`; stone `. ,`). Uses `tinyrand` (already a workspace dep, WASM-safe). `FLOOR_SEED` (`pub(crate)`) seeds one `StdRand` per board build (in `map_file`), threaded through every generator call, so floors are deterministic/testable and depend only on map content. **`kiln-core/src/log.rs`** — styled log messages: - `LogSpan { text, fg: Option, bg: Option }` and `LogLine { spans: Vec }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a `LogLine` to its own styled text at render time. @@ -112,14 +117,13 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui. - **Sender identity uses stable ids:** `BoardAction.source`, `ObjectRuntime.object_id`, and the `QueueMap` keys are all the object's `ObjectId` (matching `Board::objects`' `BTreeMap` keys), so they survive object spawn/destroy/reorder. The per-call tag carries the id as an `i64` (id 0 — never valid — is the no-source fallback). `ScriptHost::objects` is still a `Vec` built by iterating the board map, so it stays in ascending-id order. -**`kiln-core/src/map_file.rs`** — per-board serde types and single-board load/save: -- `MapFile` and friends — serde `Deserialize`/`Serialize` types for the per-board portion of a `.toml` file. Does **not** include scripts (those live in `World::scripts`, not on `Board`). -- `TileIndex` — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably -- `PlayerStart` — `#[serde(untagged)]` enum (`Coord([i32;2])` | `Char(char)`); `player_start = [x, y]` or `player_start = "@"` (locate that char in the grid). Same dual-form trick as `TileIndex` -- `FontHeader` — deserializes the optional `[font]` section (`path`, `tile_w`, `tile_h`) -- `impl TryFrom for Board` — the single load conversion. **Best-effort/nonfatal**: only a grid-dimension mismatch returns `Err`; every other problem (unknown archetype/grid char → `ErrorBlock`; object/player placement-char issues; one-solid-per-cell conflicts) is recovered and recorded on `Board::load_errors` (see `Board::is_valid`). Resolves the player (coord or `@` char, first-occurrence, falling back to `(0,0)`) and lets it *win* its cell; places objects by `x`/`y` or `palette` char -- `pub fn load(path: &str) -> Result>` — reads a single-board `.toml` file (legacy format). Production code uses `world::load` instead. -- `pub fn save(board: &Board, path: &Path) -> Result<(), Box>` — serializes `Board` back to a single-board TOML file +**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in `layer.rs`): +- `MapFile { map: MapHeader, layers: Vec }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `board_script_name`; **no** `player_start` — the player is a `kind = "player"` palette char) plus the `[[layers]]` stack. Does **not** include scripts (those live in `World::scripts`). +- `TileIndex` (`pub`) — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably. `parse_color`/`color_to_hex` (`pub(crate)`) are shared with `layer.rs`. +- `impl TryFrom for Board` — the load conversion: builds each layer via `layer::build_layer` (collecting object/portal/player placements with their layer `z`), then runs the cross-layer validations. **Best-effort/nonfatal**: only a layer grid-dimension mismatch returns `Err`; everything else is recorded on `Board::load_errors` (see `Board::is_valid`) — the player must appear exactly once (missing → `(0,0)`; multiple → first) and *wins* its cell; one solid per cell across all layers (a stacked solid is dropped); object names board-unique (a dup is cleared), portal names unique (a dup is dropped). Object ids are assigned in layer-then-reading order. +- `impl From<&Board> for MapFile` — save: emits one `[[layers]]` per board layer (deduping each unique `(Glyph, Archetype)` to a palette char), with objects/portals grouped by `z` and the player written onto the top layer. Generators baked to literal glyphs at load are saved as fixed `floor` glyphs (the generator name is not recovered). +- `pub fn load(path: &str) -> Result` — reads a single-board `.toml` file. Production code uses `world::load` instead. +- `pub fn save(board: &Board, path: &Path) -> Result<(), …>` — serializes `Board` back to a single-board TOML file. **`kiln-core/src/world.rs`** — world type and world-file loading: - `World` — the runtime representation of a `.toml` world file: `name: String`, `start: String` (key of the starting board), `scripts: HashMap` (named Rhai source, shared across all boards), `boards: HashMap>>` (all boards, always present). Every board is wrapped in `Rc>` so multiple shared refs can coexist — `GameState` clones the active board's `Rc` for its `ScriptHost`, and all boards remain in `world.boards` at all times (no board is ever "removed" when active). @@ -238,6 +242,8 @@ Each `.toml` file is a **world**: a named collection of boards plus a shared scr **Keep this example in sync with `world.rs` / `map_file.rs` whenever the format changes.** +A board is a `[map]` header plus an **ordered array of `[[layers]]`** (bottom→top). Each layer is a `content` grid and a `palette` mapping each char to one *kind* of thing. To put more than one thing on a cell (e.g. a non-solid object above a wall), put them on different layers — drawing follows layer order. + ```toml [world] name = "My World" @@ -259,85 +265,59 @@ fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); } # Each board is a named subtable under [boards]. The board key ("room1") is # used for cross-board references (e.g. portals) and becomes current_board_name. +# NOTE: there is no `player_start` — the player is placed by a kind="player" char. [boards.room1.map] name = "Room One" width = 60 height = 25 -player_start = [30, 12] # OR a grid char: player_start = "@" -# Optional: override the default font for this board. -[boards.room1.font] -path = "assets/my_font.png" -tile_w = 8 -tile_h = 16 - -[boards.room1.palette] -" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" } -"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" } -"o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } # solid + pushable (any dir) -"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } # pushable east/west only -"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # pushable north/south only - -# Optional visual floor layer. Four forms: -# boards.room1.floor = "grass" # 1. one generator for the whole board -# [boards.room1.floor] # 2. one fixed glyph everywhere -# tile = "." fg = "#1c3a1c" bg = "#0a1a0a" -# [boards.room1.floor] # 3. a grid with its own palette -# content = """...""" -# [boards.room1.floor.palette] -# "g" = "grass" # generator: "grass" | "dirt" | "stone" -# "." = { tile = ".", fg = "#222", bg = "#000" } -[boards.room1.floor] +# Layer 0 (bottom): the visual floor. Generators roll a fresh textured glyph per +# cell; a fixed-glyph form (tile/fg/bg, no generator) is also allowed. +[[boards.room1.layers]] content = """ -gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg -gggggggggggggggddddddddddddddddddddddddddddddggggggggggggggg -gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg +...60×25 grid of 'g'/'d'... """ -[boards.room1.floor.palette] -"g" = "grass" -"d" = "dirt" +[boards.room1.layers.palette] +"g" = { kind = "floor", generator = "grass" } # "grass" | "dirt" | "stone" +"d" = { kind = "floor", generator = "dirt" } +"." = { kind = "floor", tile = ".", fg = "#222", bg = "#000" } # fixed floor glyph -[boards.room1.grid] +# Layer 1: terrain, the player, objects and portals. A space here resolves to +# kind="empty" (transparent), so the floor below shows through. +[[boards.room1.layers]] content = """ ############################################################ -# G # +# G @ 1 # ############################################################ """ - -[[boards.room1.objects]] # optional; array of tables -# Placement: either `x`/`y`, OR a `palette` char that appears in the grid. -# Convention: object palette chars are uppercase letters (here `G`). The char -# may appear multiple times — one ObjectDef is spawned per occurrence in reading -# order. Only one [[objects]] entry may use a given char, and it must not be a -# [palette] key. Each matching cell loads as Empty. If the entry has a `name`, -# only the first spawned instance keeps it (names must be board-unique). -palette = "G" -tile = "#" -fg = "#aa3333" -bg = "#000000" -solid = false # blocks movement? defaults true. (pushable defaults false) -name = "greeter" # optional unique name; readable via Me.name / Board.named() -script_name = "greeter" # references a key in the world-level [scripts] table - -[[boards.room1.portals]] # optional; parsed but not yet runtime-wired -x = 59 -y = 12 -target_map = "room2" -target_entry = "west_door" +[boards.room1.layers.palette] +" " = { kind = "empty" } # transparent +"#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" } +"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } # solid + pushable (any dir) +"-" = { kind = "hcrate" } # pushable east/west only +"|" = { kind = "vcrate" } # pushable north/south only +">" = { kind = "pusher_east" } +"@" = { kind = "player" } # exactly one across all layers +# An object: spawned once per occurrence of its char (uppercase by convention). +# If it has a `name`, only the first instance keeps it (names are board-unique). +"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", + solid = false, name = "greeter", script_name = "greeter" } +# A portal: its char must appear exactly once. +"1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" } ``` -The optional `[boards.NAME.floor]` section declares the visual floor layer (drawn under every empty cell, revealed when a crate is pushed away); see `floor.rs`. Forms: omitted (black-on-black, the default), a generator name string, a single fixed glyph (table with `tile`/`fg`/`bg`), or a grid with its own palette. The three built-in generators are `grass`/`dirt`/`stone`. Floor parsing is best-effort: unknown generators/chars and dimension mismatches fall back to the default glyph. +Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph. -Colors are `"#RRGGBB"` hex strings. `player_start` accepts `[x, y]` coordinates or a grid char (convention `"@"`; that cell loads as `Empty`). `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). The grid multi-line string's leading newline is trimmed by TOML. Unknown archetype names produce an `ErrorBlock` and a logged warning. Objects may be placed by `x`/`y` or a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, and `scroll(lines)`. Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`. +Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error). An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, and `scroll(lines)`. Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`. **Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(Me.id, "visited", true)`). ### Key design decisions - **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`solid`, `opaque`, `pushable`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site. -- **The floor layer is how `Empty` is drawn** — `Board::glyph_at` returns the per-cell `floor` glyph for any `Empty` cell, so an `Empty` cell's *own* glyph is ignored (and a cell vacated by a pushed crate automatically shows the floor with no movement-code changes). The floor is computed once at load and cached (`Board::floor`); the raw `FloorSpec` is kept (`Board::floor_spec`) only so saves round-trip. One consequence: a map that colored an `Empty` cell's bg no longer shows it — empties always come from the floor layer (default black-on-black). See `floor.rs`. -- **One solid per cell** — at most one solid entity (the player, a solid grid archetype, *or* a solid object) may occupy a cell. The player is a first-class solid: `Board::solid_at` reports `Solid::Player` for the player's cell (checked first), it blocks movers, and it is **pushable in any direction** — a push chain that reaches the player slides the player along (and is rejected if the player has nowhere to go, e.g. against a wall). The map loader enforces the invariant: a solid object landing on an already-solid cell is dropped (recorded via `report_error`); the player *wins* its cell — its resolved position (including the `(0, 0)` fallback when `player_start` is unresolvable) silently clears any solid terrain under it and drops a conflicting object. `Board::solid_at` relies on this invariant. -- **`cells: Vec<(Glyph, Archetype)>`** — each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient. +- **Draw order is layer order** — `Board::glyph_at` walks `Board::layers` top-down and returns the first thing that draws (a solid object always; a visible non-solid object; a portal; a solid terrain cell; a non-transparent terrain cell). There is no separate floor field: a "floor" is an `Empty` cell with a visible glyph on a lower layer, so a non-solid object on a higher layer can render *above* a solid one. A cell vacated by a pushed crate is left transparent (`Glyph::transparent()`, tile 0), revealing the layer beneath with no movement-code changes. See `board.rs` / `layer.rs`. +- **One solid per cell (across all layers)** — at most one solid entity (the player, a solid terrain archetype on any layer, *or* a solid object) may occupy a cell. The player is a first-class solid: `Board::solid_at` reports `Solid::Player` for the player's cell (checked first), it blocks movers, and it is **pushable in any direction** — a push chain that reaches the player slides the player along (and is rejected if the player has nowhere to go, e.g. against a wall). The map loader enforces the invariant: a second solid stacked on a cell is dropped (recorded as a load error); the player *wins* its cell — its resolved position (including the `(0, 0)` fallback when no `player` char resolves) silently clears any solid terrain under it (any layer) and drops a conflicting object. `Board::solid_at` relies on this invariant. +- **Each layer is `Vec<(Glyph, Archetype)>`** — every cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient. A transparent cell (glyph tile 0) draws nothing; solidity is read from the archetype regardless of transparency. - **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games. Adding a variant: the `match`es in `behavior()`/`name()`/`default_glyph()` are exhaustive (the compiler flags them), but **`TryFrom<&str>` and `ALL_ARCHETYPES` are not** — forget the `TryFrom` arm and the archetype silently loads as `ErrorBlock`. Also update the map-format example. - **`Board` is the complete unit** — grid, player, objects, and portals all live on `Board`, matching how ZZT treats a "board". No separate wrapper struct. - **Glyph (visual) and Archetype (behavior) are decoupled** — each cell has its own `Glyph` (so colors can vary per-cell, e.g. fire flickering) while sharing an `Archetype` with other cells of the same type. @@ -351,7 +331,7 @@ The long-term goal is for the "player" to be a board *object* that happens to re Today's baked-in assumptions that will need to change (don't make `Board.player` *more* central in the meantime — e.g. don't add methods that assume player presence): - `Board.player: Player` is required and non-optional; a player-less board can't be represented (should become `Option`, or move the player out of `Board` to the engine layer). -- `player_start` is effectively required; player spawning should eventually move into the object/script system. +- A `kind = "player"` cell is effectively required (exactly one); player spawning should eventually move into the object/script system rather than being a special palette kind. - `GameState::try_move` mutates `board.player` directly; once the player is script-driven, movement should go through event dispatch (e.g. `dispatch_event(ArrowKey(dir))`) rather than a dedicated method. - The player is rendered as a hardcoded overlay (`Glyph::player()`); it should become part of the normal object layer. diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index c9c6caf..705dbc5 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -41,15 +41,26 @@ pub(crate) enum Action { /// Append a styled line to the game log. Log(LogLine), /// Add (`present = true`) or remove (`present = false`) `tag` on `target`. - SetTag { target: ObjectId, tag: String, present: bool }, + SetTag { + target: ObjectId, + tag: String, + present: bool, + }, /// Display a speech bubble above the source object for `duration` seconds. Say(String, f64), /// Pause draining this object's queue for `secs` seconds. Never reaches the board queue. Delay(f64), /// Set the source object's foreground and/or background color. - SetColor { fg: Option, bg: Option }, + SetColor { + fg: Option, + bg: Option, + }, /// Call `fn_name` on `target` object, optionally passing `arg`. - Send { target: ObjectId, fn_name: String, arg: Option }, + Send { + target: ObjectId, + fn_name: String, + arg: Option, + }, /// Open a scrollable text overlay. Pauses game ticks while shown; a choice /// selection dispatches [`Send`](Action::Send) to the source object. Scroll(Vec), @@ -65,8 +76,8 @@ fn dir_to_str(d: Direction) -> &'static str { match d { Direction::North => "North", Direction::South => "South", - Direction::East => "East", - Direction::West => "West", + Direction::East => "East", + Direction::West => "West", } } @@ -83,26 +94,35 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map { match action { Action::Move(dir) => { m.insert("type".into(), ds("Move")); - m.insert("dir".into(), ds(dir_to_str(*dir))); + m.insert("dir".into(), ds(dir_to_str(*dir))); } Action::SetTile(n) => { m.insert("type".into(), ds("SetTile")); m.insert("tile".into(), Dynamic::from(*n as i64)); } Action::Log(line) => { - let text = line.spans.first().map(|s| s.text.as_str()).unwrap_or("").to_string(); + let text = line + .spans + .first() + .map(|s| s.text.as_str()) + .unwrap_or("") + .to_string(); m.insert("type".into(), ds("Log")); - m.insert("msg".into(), Dynamic::from(text)); + m.insert("msg".into(), Dynamic::from(text)); } - Action::SetTag { target, tag, present } => { - m.insert("type".into(), ds("SetTag")); - m.insert("target".into(), Dynamic::from(*target as i64)); - m.insert("tag".into(), Dynamic::from(tag.clone())); + Action::SetTag { + target, + tag, + present, + } => { + m.insert("type".into(), ds("SetTag")); + m.insert("target".into(), Dynamic::from(*target as i64)); + m.insert("tag".into(), Dynamic::from(tag.clone())); m.insert("present".into(), Dynamic::from(*present)); } Action::Say(text, dur) => { - m.insert("type".into(), ds("Say")); - m.insert("msg".into(), Dynamic::from(text.clone())); + m.insert("type".into(), ds("Say")); + m.insert("msg".into(), Dynamic::from(text.clone())); m.insert("duration".into(), Dynamic::from(*dur)); } Action::Delay(secs) => { @@ -111,13 +131,21 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map { } Action::SetColor { fg, bg } => { m.insert("type".into(), ds("SetColor")); - if let Some(c) = fg { m.insert("fg".into(), Dynamic::from(color_to_hex(*c))); } - if let Some(c) = bg { m.insert("bg".into(), Dynamic::from(color_to_hex(*c))); } + if let Some(c) = fg { + m.insert("fg".into(), Dynamic::from(color_to_hex(*c))); + } + if let Some(c) = bg { + m.insert("bg".into(), Dynamic::from(color_to_hex(*c))); + } } - Action::Send { target, fn_name, arg } => { - m.insert("type".into(), ds("Send")); + Action::Send { + target, + fn_name, + arg, + } => { + m.insert("type".into(), ds("Send")); m.insert("target".into(), Dynamic::from(*target as i64)); - m.insert("name".into(), Dynamic::from(fn_name.clone())); + m.insert("name".into(), Dynamic::from(fn_name.clone())); if let Some(a) = arg { let dyn_arg = match a { ScriptArg::Str(s) => Dynamic::from(s.clone()), diff --git a/kiln-core/src/archetype.rs b/kiln-core/src/archetype.rs index 4efcbec..41aeeeb 100644 --- a/kiln-core/src/archetype.rs +++ b/kiln-core/src/archetype.rs @@ -1,6 +1,6 @@ -use color::Rgba8; use crate::glyph::Glyph; use crate::utils::{Behavior, Direction, Pushable}; +use color::Rgba8; /// A class of board cell, encoding its default behavior and appearance. /// @@ -95,8 +95,8 @@ impl Archetype { Archetype::VCrate => "vcrate", Archetype::Pusher(Direction::North) => "pusher_north", Archetype::Pusher(Direction::South) => "pusher_south", - Archetype::Pusher(Direction::East) => "pusher_east", - Archetype::Pusher(Direction::West) => "pusher_west", + Archetype::Pusher(Direction::East) => "pusher_east", + Archetype::Pusher(Direction::West) => "pusher_west", Archetype::ErrorBlock => "error_block", } } @@ -174,8 +174,8 @@ impl TryFrom<&str> for Archetype { // entries in map files. They live in [[objects]] with their own glyph. "pusher_north" => Ok(Archetype::Pusher(Direction::North)), "pusher_south" => Ok(Archetype::Pusher(Direction::South)), - "pusher_east" => Ok(Archetype::Pusher(Direction::East)), - "pusher_west" => Ok(Archetype::Pusher(Direction::West)), + "pusher_east" => Ok(Archetype::Pusher(Direction::East)), + "pusher_west" => Ok(Archetype::Pusher(Direction::West)), _ => Err(format!("unknown archetype: {name}")), } } diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 361750d..4554d7c 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -1,11 +1,11 @@ -use std::collections::{BTreeMap, HashMap}; use crate::archetype::Archetype; -use crate::archetype::Archetype::Empty; use crate::glyph::Glyph; +use crate::layer::Layer; use crate::log::LogLine; use crate::object_def::ObjectDef; use crate::utils::Direction; use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid}; +use std::collections::{BTreeMap, HashMap}; /// The complete state of one game board (a single room or screen). /// @@ -31,19 +31,12 @@ pub struct Board { pub width: usize, /// Height of the board in cells. pub height: usize, - /// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to - /// access by `(x, y)` coordinates. - pub(crate) cells: Vec<(Glyph, Archetype)>, - /// Row-major cache of the visual floor layer: the glyph drawn for a cell when - /// it would otherwise render as [`Archetype::Empty`]. Computed once at load - /// from [`floor_spec`](Board::floor_spec) (see [`crate::floor::build_floor`]). - /// When a map declares no `[floor]`, every entry is black-on-black space (the - /// historical look of an empty cell). Read it via [`Board::glyph_at`]. - pub(crate) floor: Vec, - /// The raw `[floor]` declaration, kept so [`crate::map_file::save`] can - /// round-trip it. `None` when the map declared no floor. (Generators - /// re-randomize on reload; literal glyph grids are preserved exactly.) - pub(crate) floor_spec: Option, + /// Ordered draw stack of [`Layer`]s, bottom (index 0) to top. Each layer holds + /// a row-major grid of `(Glyph, Archetype)` cells; a transparent cell lets the + /// layer beneath show through. Drawing ([`Board::glyph_at`]) walks the stack + /// top-down; solidity ([`Board::solid_at`]) scans every layer. Access a single + /// cell with [`Board::get`]/[`Board::get_mut`] by `(z, x, y)`. + pub(crate) layers: Vec, /// Current player position. See [`Player`] for caveats about its future. pub player: Player, /// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap` @@ -75,61 +68,85 @@ pub struct Board { } impl Board { - /// Returns a reference to the cell at `(x, y)`. + /// Number of draw layers on this board (≥ 1 for a loaded board). + pub fn layer_count(&self) -> usize { + self.layers.len() + } + + /// Returns a reference to the cell at `(x, y)` on layer `z`. /// - /// The cell is a `(Glyph, Archetype)` tuple. Panics if `x` or `y` are + /// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are /// out of bounds. - pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) { - &self.cells[y * self.width + x] + pub fn get(&self, z: usize, x: usize, y: usize) -> &(Glyph, Archetype) { + &self.layers[z].cells[y * self.width + x] } - /// Returns a mutable reference to the cell at `(x, y)`. + /// Returns a mutable reference to the cell at `(x, y)` on layer `z`. /// - /// Panics if `x` or `y` are out of bounds. - pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) { - &mut self.cells[y * self.width + x] + /// Panics if `z`, `x`, or `y` are out of bounds. + pub fn get_mut(&mut self, z: usize, x: usize, y: usize) -> &mut (Glyph, Archetype) { + let w = self.width; + &mut self.layers[z].cells[y * w + x] } - /// Returns the glyph we should display for a given coordinate: + /// Returns the glyph to display at `(x, y)`, honoring layer draw order. /// - /// - If the coord contains a [`Solid`], use the appropriate glyph for that - /// - If it contains at least one non-solid object with a glyph other than 0, use one (arbitrarily) - /// - If it contains any non-Empty archetype, use that glyph - /// - Fall back to the [`floor`](Board::floor) glyph. + /// The player is always drawn on top (it is not part of the layer stack yet). + /// Otherwise the layers are walked **top-down**; the first thing that draws on + /// a layer wins: /// - /// An `Empty` cell's *own* glyph is intentionally ignored — the floor layer - /// supersedes it (so a cell vacated by a pushed crate reveals the floor, not - /// black). Panics if out of bounds. + /// 1. an object on that layer (a solid object always; otherwise a non-solid + /// object whose glyph is not transparent, i.e. `tile != 0`), + /// 2. a portal on that layer, + /// 3. the layer's terrain cell — a solid always draws, and a non-solid draws + /// only when not transparent (`tile != 0`). + /// + /// If no layer contributes anything, the canonical black `Empty` glyph is used. + /// Panics if out of bounds. pub fn glyph_at(&self, x: usize, y: usize) -> Glyph { - if let Some(solid) = self.solid_at(x, y) { - // Use the solid - return match solid { - Solid::Player => Glyph::player(), - Solid::Cell(_) => self.get(x, y).0, - Solid::Object(id) => self.objects[&id].glyph + // The player is rendered above the whole stack (see the `Player` notes). + if self.player.x == x as i32 && self.player.y == y as i32 { + return Glyph::player(); + } + + for z in (0..self.layers.len()).rev() { + // Objects on this layer: a solid object always draws; otherwise the + // first non-transparent non-solid object (lets invisible objects exist). + let mut nonsolid: Option = None; + for o in self + .objects + .values() + .filter(|o| o.x == x && o.y == y && o.z == z) + { + if o.solid { + return o.glyph; + } + if nonsolid.is_none() && o.glyph.tile != 0 { + nonsolid = Some(o.glyph); + } + } + if let Some(g) = nonsolid { + return g; + } + + // A portal on this layer draws above its (transparent) terrain cell. + if self + .portals + .iter() + .any(|p| p.x == x && p.y == y && p.z == z) + { + return PortalDef::default_glyph(); + } + + // The terrain cell: a solid always draws; a non-solid only if visible. + let (glyph, arch) = self.get(z, x, y); + if arch.behavior().solid || glyph.tile != 0 { + return *glyph; } } - let (glyph, arch) = *self.get(x, y); - let ids = self.object_ids_at(x, y); // IDs of non-solid (ethereal?) objects - - // Objects with nonzero (nonblank) glyphs take precedence. This allows us to use invisible - // nonsolid objects on the board. - if let Some(glyph) = ids.iter().find(|id| self.objects[id].glyph.tile != 0 ).map(|id| self.objects[id].glyph) { - return glyph - } - - // Is there a normal archetype? - if arch != Empty { - return glyph - } - - // Portal: passable and non-opaque, but visible above the floor. - if self.portals.iter().any(|p| p.x == x && p.y == y) { - return PortalDef::default_glyph(); - } - - self.floor[y * self.width + x] + // Nothing on any layer: the canonical black empty cell. + Archetype::Empty.default_glyph() } /// Returns `true` if `(x, y)` is a valid cell coordinate on this board. @@ -153,7 +170,7 @@ impl Board { pub fn is_valid(&self) -> bool { self.load_errors.is_empty() } - + /// Returns the single solid entity occupying `(x, y)`, if any. /// /// Checks player first, then objects, then the grid archetype. Because at most one solid @@ -165,19 +182,38 @@ impl Board { if self.player.x == x as i32 && self.player.y == y as i32 { return Some(Solid::Player); } - // A solid object shadows the grid cell it sits on. - if let Some(id) = self.solid_object_id_at(x, y) - { + // A solid object shadows the cell it sits on. + if let Some(id) = self.solid_object_id_at(x, y) { return Some(Solid::Object(id)); } - // Otherwise the grid archetype itself may be solid (e.g. a wall). - let arch = self.get(x, y).1; - if arch.behavior().solid { - return Some(Solid::Cell(arch)); + // Otherwise some layer's terrain archetype may be solid (e.g. a wall). + if let Some(z) = self.solid_cell_layer(x, y) { + return Some(Solid::Cell(self.get(z, x, y).1)); } None } + /// Returns the index of the layer whose terrain cell at `(x, y)` is solid, if + /// any. By the one-solid-per-cell invariant there is at most one such layer. + fn solid_cell_layer(&self, x: usize, y: usize) -> Option { + (0..self.layers.len()).find(|&z| self.get(z, x, y).1.behavior().solid) + } + + /// Returns the coordinates of every [`Archetype::Pusher`] terrain cell on the + /// board (scanning all layers). Used by the pusher heartbeat in + /// [`GameState::tick`](crate::game::GameState::tick). + pub fn pusher_cells(&self) -> Vec<(usize, usize)> { + let mut cells = Vec::new(); + for y in 0..self.height { + for x in 0..self.width { + if matches!(self.solid_at(x, y), Some(Solid::Cell(Archetype::Pusher(_)))) { + cells.push((x, y)); + } + } + } + cells + } + /// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it. /// /// Convenience inverse of [`solid_at`](Board::solid_at). @@ -261,7 +297,12 @@ impl Board { /// - If the target has a pushable chain: pushes it first, then slides in. /// - If blocked or out of bounds: no-op, returns `false`. pub(crate) fn advance_pusher(&mut self, x: usize, y: usize) -> bool { - let Archetype::Pusher(dir) = self.get(x, y).1 else { return false; }; + let Some(z) = self.solid_cell_layer(x, y) else { + return false; + }; + let Archetype::Pusher(dir) = self.get(z, x, y).1 else { + return false; + }; let (dx, dy): (i32, i32) = dir.into(); let tx = x as i32 + dx; let ty = y as i32 + dy; @@ -283,24 +324,24 @@ impl Board { /// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`. /// - /// A solid object is relocated; otherwise the grid archetype (a crate) is - /// moved, leaving `Empty` floor behind (the grid has no separate floor layer). + /// A solid object is relocated (keeping its layer); otherwise the solid + /// terrain archetype (a crate) is moved within its own layer, leaving a + /// transparent cell behind so the layer beneath (e.g. floor) shows through. /// The caller guarantees the destination is already clear. fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) { let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize); - // The player owns its cell, so move it before considering objects/grid. + // The player owns its cell, so move it before considering objects/terrain. if self.player.x == x as i32 && self.player.y == y as i32 { self.player.x = tx as i32; self.player.y = ty as i32; - } else if let Some(id) = self.solid_object_id_at(x, y) - { + } else if let Some(id) = self.solid_object_id_at(x, y) { let obj = self.objects.get_mut(&id).expect("id from object_id_at"); obj.x = tx; obj.y = ty; - } else { - let moved = *self.get(x, y); - *self.get_mut(tx, ty) = moved; - *self.get_mut(x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty); + } else if let Some(z) = self.solid_cell_layer(x, y) { + let moved = *self.get(z, x, y); + *self.get_mut(z, tx, ty) = moved; + *self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty); } } @@ -309,20 +350,19 @@ impl Board { self.objects .iter() .filter(|(_, o)| o.x == x && o.y == y) - .map(|(&id, _)| id).collect() + .map(|(&id, _)| id) + .collect() } /// Returns a borrow of the actual object at `(x, y)` if any pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option { - self.objects - .iter() - .find_map(|(&id, o)| { - if o.x == x && o.y == y && o.solid { - Some(id) - } else { - None - } - }) + self.objects.iter().find_map(|(&id, o)| { + if o.x == x && o.y == y && o.solid { + Some(id) + } else { + None + } + }) } /// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id. @@ -340,17 +380,22 @@ impl Board { #[cfg(test)] pub(crate) mod tests { - use std::collections::{BTreeMap, HashMap}; - use color::Rgba8; + use super::Board; use crate::archetype::Archetype; use crate::glyph::Glyph; + use crate::layer::Layer; use crate::object_def::ObjectDef; use crate::utils::Direction; use crate::utils::{ObjectId, Player, Solid}; - use super::Board; + use color::Rgba8; + use std::collections::{BTreeMap, HashMap}; - /// Builds an all-empty `w×h` board with the given player position and objects. - /// Assigns sequential ids (1..=n) to objects. + /// Builds an all-empty `w×h` single-layer board with the given player position + /// and objects (all on layer 0). Assigns sequential ids (1..=n) to objects. + /// + /// The single layer is fully transparent, so terrain stamped via [`crate_at`] + /// etc. always lands on the board's top layer. Use [`add_floor`] to slip a + /// visible floor layer underneath. pub(crate) fn open_board( w: usize, h: usize, @@ -368,10 +413,13 @@ pub(crate) mod tests { name: "test".into(), width: w, height: h, - cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h], - floor: vec![Archetype::Empty.default_glyph(); w * h], - floor_spec: None, - player: Player { x: player.0, y: player.1 }, + layers: vec![Layer { + cells: vec![(Glyph::transparent(), Archetype::Empty); w * h], + }], + player: Player { + x: player.0, + y: player.1, + }, objects: object_map, next_object_id, portals: Vec::new(), @@ -381,25 +429,48 @@ pub(crate) mod tests { } } - /// Stamps a crate cell onto a board. + /// Inserts a visible floor layer (filled with `glyph`) below everything, + /// bumping existing terrain and objects up one layer. + pub(crate) fn add_floor(board: &mut Board, glyph: Glyph) { + let count = board.width * board.height; + board.layers.insert( + 0, + Layer { + cells: vec![(glyph, Archetype::Empty); count], + }, + ); + for o in board.objects.values_mut() { + o.z += 1; + } + } + + /// The index of the board's top (terrain) layer, where stamps are written. + fn top(board: &Board) -> usize { + board.layers.len() - 1 + } + + /// Stamps a crate cell onto the board's top layer. pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) { - *board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate); + let z = top(board); + *board.get_mut(z, x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate); } - /// Stamps a wall cell onto a board. + /// Stamps a wall cell onto the board's top layer. pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) { - *board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall); + let z = top(board); + *board.get_mut(z, x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall); } - /// Stamps an arbitrary archetype cell onto a board. + /// Stamps an arbitrary archetype cell onto the board's top layer. pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) { - *board.get_mut(x, y) = (arch.default_glyph(), arch); + let z = top(board); + *board.get_mut(z, x, y) = (arch.default_glyph(), arch); } #[test] fn solid_at_reports_wall_object_and_empty() { let mut board = open_board(4, 1, (3, 0), vec![]); - board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall); + wall_at(&mut board, 1, 0); board.add_object(ObjectDef::new(2, 0)); assert!(board.solid_at(0, 0).is_none()); @@ -412,7 +483,9 @@ pub(crate) mod tests { assert!(!board.is_passable(1, 0)); match board.solid_at(2, 0) { - Some(Solid::Object(id)) => assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0)), + Some(Solid::Object(id)) => { + assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0)) + } _ => panic!("expected Solid::Object"), } assert!(!board.is_passable(2, 0)); @@ -453,8 +526,8 @@ pub(crate) mod tests { let mut board = open_board(3, 1, (0, 0), vec![]); crate_at(&mut board, 1, 0); assert!(board.can_push(1, 0, Direction::East)); - assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation - assert_eq!(board.get(2, 0).1, Archetype::Empty); + assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // no mutation + assert_eq!(board.get(0, 2, 0).1, Archetype::Empty); let mut board = open_board(3, 1, (0, 0), vec![]); crate_at(&mut board, 1, 0); @@ -469,8 +542,8 @@ pub(crate) mod tests { crate_at(&mut board, 1, 0); assert!(board.can_push(1, 0, Direction::East)); board.push(1, 0, Direction::East); - assert_eq!(board.get(1, 0).1, Archetype::Empty); - assert_eq!(board.get(2, 0).1, Archetype::Crate); + assert_eq!(board.get(0, 1, 0).1, Archetype::Empty); + assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); assert_eq!((board.player.x, board.player.y), (3, 0)); } @@ -482,7 +555,7 @@ pub(crate) mod tests { wall_at(&mut board, 3, 0); assert!(!board.can_push(1, 0, Direction::East)); board.push(1, 0, Direction::East); // no-op - assert_eq!(board.get(1, 0).1, Archetype::Crate); + assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); assert_eq!((board.player.x, board.player.y), (2, 0)); } @@ -492,15 +565,47 @@ pub(crate) mod tests { let mut board = open_board(3, 1, (2, 0), vec![]); let floor_glyph = Glyph { tile: '.' as u32, - fg: Rgba8 { r: 10, g: 20, b: 30, a: 255 }, - bg: Rgba8 { r: 1, g: 2, b: 3, a: 255 }, + fg: Rgba8 { + r: 10, + g: 20, + b: 30, + a: 255, + }, + bg: Rgba8 { + r: 1, + g: 2, + b: 3, + a: 255, + }, }; - board.floor = vec![floor_glyph; 3]; + // Floor on a lower layer, a wall on the top (terrain) layer at (0,0). + add_floor(&mut board, floor_glyph); wall_at(&mut board, 0, 0); + // The wall (solid) draws over the floor; the empty cell reveals the floor. assert_eq!(board.glyph_at(0, 0), Archetype::Wall.default_glyph()); assert_eq!(board.glyph_at(1, 0), floor_glyph); } + #[test] + fn glyph_at_draws_higher_layer_over_lower() { + // A non-solid object on an upper layer renders above a wall on a lower one. + let mut board = open_board(2, 1, (1, 0), vec![]); + wall_at(&mut board, 0, 0); // wall on layer 0 + // Add an upper layer holding a visible, non-solid object at (0,0). + board.layers.push(Layer { + cells: vec![(Glyph::transparent(), Archetype::Empty); 2], + }); + let mut obj = ObjectDef::new(0, 0); + obj.z = 1; + obj.solid = false; + obj.glyph = Glyph { + tile: '*' as u32, + ..Glyph::transparent() + }; + board.add_object(obj); + assert_eq!(board.glyph_at(0, 0).tile, '*' as u32); + } + #[test] fn fresh_board_is_valid_and_reports_errors() { let mut board = open_board(1, 1, (0, 0), vec![]); @@ -509,4 +614,4 @@ pub(crate) mod tests { assert!(!board.is_valid()); assert_eq!(board.load_errors.len(), 1); } -} \ No newline at end of file +} diff --git a/kiln-core/src/floor.rs b/kiln-core/src/floor.rs index 4f1af9b..777bfd5 100644 --- a/kiln-core/src/floor.rs +++ b/kiln-core/src/floor.rs @@ -1,29 +1,24 @@ -//! The floor layer: a per-cell visual background drawn wherever a board cell -//! would otherwise render as [`Archetype::Empty`](crate::archetype::Archetype::Empty). +//! Procedural floor generators. //! -//! The floor is purely cosmetic — it carries no behavior. It exists to give -//! boards some non-distracting visual flavor (textured ground) with little +//! A "floor" is a purely cosmetic, non-solid visual placed on a board layer (a +//! palette entry with `kind = "floor"`). It carries no behavior — it exists to +//! give boards some non-distracting visual flavor (textured ground) with little //! authoring effort, including randomly-generated "grass" / "dirt" / "stone". //! -//! A board's floor is computed once at load time ([`build_floor`]) and cached as -//! a `Vec` on the [`Board`](crate::board::Board); generators are run during -//! that single pass and their results stored, so there is no per-frame cost and -//! no tile flicker. The raw [`FloorSpec`] is also kept on the board so a save -//! round-trips the declaration (generators re-randomize on reload; literal glyph -//! grids are preserved exactly). +//! The actual placement / per-cell expansion happens in [`crate::layer`] during +//! map load: a floor palette entry either names one of these generators (a fresh +//! glyph is rolled per grid cell) or gives a fixed glyph. This module only owns +//! the generators themselves. -use crate::archetype::Archetype; -use crate::log::LogLine; -use crate::map_file::{parse_color, TileIndex}; -use color::Rgba8; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; -use tinyrand::{Probability, Rand, Seeded, StdRand}; use crate::glyph::Glyph; +use color::Rgba8; +use tinyrand::{Probability, Rand, StdRand}; /// Fixed seed for the floor PRNG, so a board's generated floor is deterministic -/// for a given spec (stable across reloads within a run, and testable). -const FLOOR_SEED: u64 = 0x_C0FF_EE15_F100_0001; +/// for a given map (stable across reloads within a run, and testable). The layer +/// builder seeds one [`StdRand`] with this and threads it through every +/// generator call so the result depends only on the map content. +pub(crate) const FLOOR_SEED: u64 = 0x_C0FF_EE15_F100_0001; /// A procedural floor texture. Each generator differs only in its color scheme /// and the probability/character set of its scattered "texture" glyphs; the @@ -56,7 +51,7 @@ impl FloorGenerator { /// Picks a background ground color within the generator's scheme, then with /// the generator's probability scatters a lighter "texture" char on top /// (otherwise a blank space, whose fg is irrelevant). - fn generate(&self, rng: &mut StdRand) -> Glyph { + pub(crate) fn generate(&self, rng: &mut StdRand) -> Glyph { // (background color ranges, texture probability, texture chars). let (bg, chars, prob) = match self { // Green → greenish-yellow: g dominant, a touch of r for the yellow tilt. @@ -131,221 +126,41 @@ fn lighten(c: Rgba8, amt: u8) -> Rgba8 { } } -/// A fixed floor glyph declared in a map file: visual only, no archetype. -/// -/// Reuses [`TileIndex`] (so `tile` accepts a char or an int) and `"#RRGGBB"` -/// color strings, exactly like a `[palette]` entry minus the archetype. -#[derive(Deserialize, Serialize, Clone)] -pub struct FloorGlyph { - /// Tile index into the board's font. Accepts an integer or single-char string. - pub tile: TileIndex, - /// Foreground color as an `"#RRGGBB"` hex string. - pub fg: String, - /// Background color as an `"#RRGGBB"` hex string. - pub bg: String, -} - -impl FloorGlyph { - /// Converts this declaration into a concrete [`Glyph`]. - fn to_glyph(&self) -> Glyph { - Glyph { - tile: self.tile.into_u32(), - fg: parse_color(&self.fg), - bg: parse_color(&self.bg), - } - } -} - -/// One entry in a floor grid's palette: either a generator name or a fixed glyph. -/// -/// `#[serde(untagged)]`: a TOML string parses as [`FloorTile::Generator`]; a TOML -/// table parses as [`FloorTile::Glyph`]. -#[derive(Deserialize, Serialize, Clone)] -#[serde(untagged)] -pub enum FloorTile { - /// A generator name, e.g. `"grass"`. - Generator(String), - /// A fixed glyph definition. - Glyph(FloorGlyph), -} - -/// The optional `[floor]` declaration in a map file, in one of four shapes. -/// -/// `#[serde(untagged)]`, so the variants are distinguished by TOML shape — order -/// matters: a string hits [`FloorSpec::Generator`]; a table with a `tile` key hits -/// [`FloorSpec::Single`]; a table with a `content` key hits [`FloorSpec::Grid`]. -/// (Absence of a `[floor]` section is represented by `Option::None` on the map -/// file, not a variant here, and yields a default black-on-black floor.) -#[derive(Deserialize, Serialize, Clone)] -#[serde(untagged)] -pub enum FloorSpec { - /// A single generator name covering the whole board, e.g. `floor = "grass"`. - Generator(String), - /// A single fixed glyph filling every floor cell. - Single(FloorGlyph), - /// A grid (like `[grid]`) with its own palette of generators and/or glyphs. - Grid { - /// Multi-line grid string; one char per cell, looked up in `palette`. - content: String, - /// Char → [`FloorTile`] lookup for the grid. - palette: HashMap, - }, -} - -/// The canonical "empty" floor glyph: a black-on-black space. This is the default -/// floor (when a map declares no `[floor]`) and the fallback for any cell that -/// can't be resolved. -fn empty_glyph() -> Glyph { - Archetype::Empty.default_glyph() -} - -/// Builds the cached per-cell floor `Vec` (row-major, `width * height`) -/// from an optional [`FloorSpec`]. -/// -/// Best-effort and nonfatal: any problem (unknown generator/char, grid-dimension -/// mismatch) is recorded on `errors` and that cell falls back to [`empty_glyph`], -/// so the board still loads. With no spec, the floor is all black-on-black space -/// (the previous behavior, where `Empty` cells rendered as nothing). -pub fn build_floor( - spec: &Option, - width: usize, - height: usize, - errors: &mut Vec, -) -> Vec { - let mut rng = StdRand::seed(FLOOR_SEED); - let count = width * height; - match spec { - // No declaration: the historical default of black-on-black whitespace. - None => vec![empty_glyph(); count], - // One generator for the whole board: run it once per cell. - Some(FloorSpec::Generator(name)) => match FloorGenerator::from_name(name) { - Some(generator) => (0..count).map(|_| generator.generate(&mut rng)).collect(), - None => { - errors.push(LogLine::error(format!("unknown floor generator '{name}'"))); - vec![empty_glyph(); count] - } - }, - // One fixed glyph: parse once, fill every cell. - Some(FloorSpec::Single(glyph)) => vec![glyph.to_glyph(); count], - // A grid with its own palette of generators and/or fixed glyphs. - Some(FloorSpec::Grid { content, palette }) => { - build_grid_floor(content, palette, width, height, &mut rng, errors) - } - } -} - -/// Builds the floor for a [`FloorSpec::Grid`]: each grid character is looked up in -/// `palette` and resolved to a glyph (generators run per-cell). Dimension -/// mismatches and unknown chars/generators are reported once and fall back to the -/// empty glyph. -fn build_grid_floor( - content: &str, - palette: &HashMap, - width: usize, - height: usize, - rng: &mut StdRand, - errors: &mut Vec, -) -> Vec { - // Key the palette by char for O(1) per-cell lookup. - let by_char: HashMap = palette - .iter() - .filter_map(|(k, v)| k.chars().next().map(|c| (c, v))) - .collect(); - - let rows: Vec<&str> = content.lines().collect(); - if rows.len() != height { - errors.push(LogLine::error(format!( - "floor grid has {} rows but the board is {height} tall; missing cells use empty floor", - rows.len() - ))); - } - - // Report each unknown char / bad generator only once, not per occurrence. - let mut reported: HashSet = HashSet::new(); - let mut floor = Vec::with_capacity(width * height); - for y in 0..height { - // `chars().nth(x)` is O(x) but floors are small; this keeps the lookup - // correct for multi-byte chars (a byte index would not). - let row: Vec = rows.get(y).map(|r| r.chars().collect()).unwrap_or_default(); - for x in 0..width { - let glyph = match row.get(x) { - Some(&ch) => match by_char.get(&ch) { - Some(FloorTile::Glyph(g)) => g.to_glyph(), - Some(FloorTile::Generator(name)) => match FloorGenerator::from_name(name) { - Some(generator) => generator.generate(rng), - None => { - if reported.insert(ch) { - errors.push(LogLine::error(format!( - "floor palette '{ch}' names unknown generator '{name}'; using empty floor" - ))); - } - empty_glyph() - } - }, - None => { - if reported.insert(ch) { - errors.push(LogLine::error(format!( - "unknown floor grid character '{ch}'; using empty floor" - ))); - } - empty_glyph() - } - }, - // Row missing or too short: padded with empty floor (already reported above). - None => empty_glyph(), - }; - floor.push(glyph); - } - } - floor -} - #[cfg(test)] mod tests { use super::*; + use tinyrand::Seeded; - /// Parses a `FloorSpec` from a standalone TOML snippet of the form - /// `floor = ...` / `[floor]\n...`, returning the parsed spec. - fn parse_spec(toml: &str) -> FloorSpec { - #[derive(Deserialize)] - struct Wrap { - floor: FloorSpec, - } - toml::from_str::(toml) - .expect("parse floor spec") - .floor + /// Rolls `count` glyphs from `generator` against a freshly-seeded RNG, the + /// same way the layer builder does. + fn roll(generator: FloorGenerator, count: usize) -> Vec { + let mut rng = StdRand::seed(FLOOR_SEED); + (0..count).map(|_| generator.generate(&mut rng)).collect() } #[test] - fn no_spec_yields_all_empty_floor() { - let mut errs = Vec::new(); - let floor = build_floor(&None, 3, 2, &mut errs); - assert_eq!(floor.len(), 6); - assert!(floor.iter().all(|g| *g == empty_glyph())); - assert!(errs.is_empty()); - } - - #[test] - fn single_glyph_fills_every_cell() { - let spec = parse_spec("[floor]\ntile = \".\"\nfg = \"#112233\"\nbg = \"#445566\"\n"); - let mut errs = Vec::new(); - let floor = build_floor(&Some(spec), 2, 2, &mut errs); - let expected = Glyph { - tile: '.' as u32, - fg: parse_color("#112233"), - bg: parse_color("#445566"), - }; - assert_eq!(floor.len(), 4); - assert!(floor.iter().all(|g| *g == expected)); + fn from_name_parses_known_generators() { + assert_eq!( + FloorGenerator::from_name("grass"), + Some(FloorGenerator::Grass) + ); + assert_eq!( + FloorGenerator::from_name("dirt"), + Some(FloorGenerator::Dirt) + ); + assert_eq!( + FloorGenerator::from_name("stone"), + Some(FloorGenerator::Stone) + ); + assert_eq!(FloorGenerator::from_name("lava"), None); } #[test] fn grass_generator_stays_in_scheme_and_is_deterministic() { - let mut errs = Vec::new(); - let a = build_floor(&Some(FloorSpec::Generator("grass".into())), 8, 8, &mut errs); - let b = build_floor(&Some(FloorSpec::Generator("grass".into())), 8, 8, &mut errs); + let a = roll(FloorGenerator::Grass, 64); + let b = roll(FloorGenerator::Grass, 64); assert_eq!(a.len(), 64); - // Same seed → identical floors across builds. + // Same seed → identical rolls across builds. assert!(a.iter().zip(&b).all(|(x, y)| x == y)); // Every cell's ground (bg) sits inside grass's channel ranges. for g in &a { @@ -353,48 +168,5 @@ mod tests { assert!((45..=85).contains(&g.bg.g), "g out of range: {}", g.bg.g); assert!((20..=35).contains(&g.bg.b), "b out of range: {}", g.bg.b); } - assert!(errs.is_empty()); - } - - #[test] - fn unknown_generator_falls_back_and_reports() { - let mut errs = Vec::new(); - let floor = build_floor(&Some(FloorSpec::Generator("lava".into())), 2, 1, &mut errs); - assert!(floor.iter().all(|g| *g == empty_glyph())); - assert_eq!(errs.len(), 1); - } - - #[test] - fn grid_resolves_glyph_and_generator_entries() { - let spec = parse_spec( - "[floor]\ncontent = \"\"\"\ng.\n.g\n\"\"\"\n[floor.palette]\n\"g\" = \"grass\"\n\".\" = { tile = \"#\", fg = \"#010203\", bg = \"#040506\" }\n", - ); - let mut errs = Vec::new(); - let floor = build_floor(&Some(spec), 2, 2, &mut errs); - assert!(errs.is_empty()); - let fixed = Glyph { - tile: '#' as u32, - fg: parse_color("#010203"), - bg: parse_color("#040506"), - }; - // (0,0) and (1,1) are grass; (1,0) and (0,1) are the fixed glyph. - assert_eq!(floor[1], fixed); - assert_eq!(floor[2], fixed); - assert!((20..=55).contains(&floor[0].bg.r)); // grass ground - assert!((20..=55).contains(&floor[3].bg.r)); - } - - #[test] - fn grid_dimension_mismatch_is_nonfatal() { - // Declares 1 row for a 2-tall board: missing row is padded with empty floor. - let spec = parse_spec( - "[floor]\ncontent = \"\"\"\ngg\n\"\"\"\n[floor.palette]\n\"g\" = \"grass\"\n", - ); - let mut errs = Vec::new(); - let floor = build_floor(&Some(spec), 2, 2, &mut errs); - assert_eq!(floor.len(), 4); - assert_eq!(errs.len(), 1); // the row-count mismatch was reported - assert_eq!(floor[2], empty_glyph()); // padded second row - assert_eq!(floor[3], empty_glyph()); } } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 9362d0c..a2038a2 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,12 +1,11 @@ use crate::action::Action; -use crate::archetype::Archetype; use crate::board::Board; use crate::log::LogLine; use crate::script::ScriptHost; +use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid}; use crate::world::World; use std::cell::{Ref, RefMut}; use std::time::Duration; -use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid}; /// How long a `say()` speech bubble stays on screen, in seconds. pub const SAY_DURATION: f64 = 3.0; @@ -85,7 +84,10 @@ impl GameState { pub fn from_world(world: World) -> Self { let name = world.start.clone(); let host = ScriptHost::new( - world.boards.get(&name).expect("world::load guarantees start board exists"), + world + .boards + .get(&name) + .expect("world::load guarantees start board exists"), &world.scripts, ); let mut state = Self { @@ -177,7 +179,10 @@ impl GameState { self.scripts.run_tick(secs); // Expire speech bubbles before resolving new actions so a fresh say() // this frame isn't immediately culled. - self.speech_bubbles.retain_mut(|b| { b.remaining -= secs; b.remaining > 0.0 }); + self.speech_bubbles.retain_mut(|b| { + b.remaining -= secs; + b.remaining > 0.0 + }); self.tick_pushers(secs); self.resolve(); } @@ -193,18 +198,7 @@ impl GameState { return; } self.pusher_timer += PUSHER_INTERVAL; - let pushers: Vec<(usize, usize)> = { - let board = self.board(); - let mut v = Vec::new(); - for y in 0..board.height { - for x in 0..board.width { - if matches!(board.get(x, y).1, Archetype::Pusher(_)) { - v.push((x, y)); - } - } - } - v - }; + let pushers: Vec<(usize, usize)> = self.board().pusher_cells(); let mut board = self.board_mut(); for (x, y) in pushers { board.advance_pusher(x, y); @@ -251,9 +245,17 @@ impl GameState { } } Action::Log(line) => logs.push(line), - Action::SetTag { target, tag, present } => { + Action::SetTag { + target, + tag, + present, + } => { if let Some(obj) = board.objects.get_mut(&target) { - if present { obj.tags.insert(tag); } else { obj.tags.remove(&tag); } + if present { + obj.tags.insert(tag); + } else { + obj.tags.remove(&tag); + } } } // Replace any existing bubble from this object so repeated say() calls @@ -267,26 +269,38 @@ impl GameState { Action::Delay(_) => {} Action::SetColor { fg, bg } => { if let Some(obj) = board.objects.get_mut(&ba.source) { - if let Some(c) = fg { obj.glyph.fg = c; } - if let Some(c) = bg { obj.glyph.bg = c; } + if let Some(c) = fg { + obj.glyph.fg = c; + } + if let Some(c) = bg { + obj.glyph.bg = c; + } } } // Collected and fired after the board borrow drops, like bumps. - Action::Send { target, fn_name, arg } => { + Action::Send { + target, + fn_name, + arg, + } => { sends.push((target, fn_name, arg)); } // Later scrolls overwrite earlier ones from the same tick. Action::Scroll(lines) => { - new_scroll = Some(Scroll { source: ba.source, lines, choice: None }); + new_scroll = Some(Scroll { + source: ba.source, + lines, + choice: None, + }); } Action::Teleport { x, y } => { if !board.in_bounds((x, y)) { - logs.push(LogLine::error(format!( - "teleport({x},{y}): out of bounds" - ))); + logs.push(LogLine::error(format!("teleport({x},{y}): out of bounds"))); } else { let (ux, uy) = (x as usize, y as usize); - let source_solid = board.objects.get(&ba.source) + let source_solid = board + .objects + .get(&ba.source) .map(|o| o.solid) .unwrap_or(false); if source_solid && board.solid_at(ux, uy).is_some() { @@ -305,7 +319,8 @@ impl GameState { self.log.extend(logs); for bubble in new_bubbles { // One bubble per object: replace the existing one if present. - self.speech_bubbles.retain(|b| b.object_id != bubble.object_id); + self.speech_bubbles + .retain(|b| b.object_id != bubble.object_id); self.speech_bubbles.push(bubble); } if let Some(scroll) = new_scroll { @@ -344,12 +359,16 @@ impl GameState { /// to play a visual effect during the switch. pub fn enter_board(&mut self, target_map: &str, target_entry: &str) { if !self.world.boards.contains_key(target_map) { - self.log.push(LogLine::error(format!("portal target board {target_map:?} not found"))); + self.log.push(LogLine::error(format!( + "portal target board {target_map:?} not found" + ))); return; } // Find the named arrival portal on the target board (borrow then release). - let arrival = self.world.boards[target_map].borrow() - .portals.iter() + let arrival = self.world.boards[target_map] + .borrow() + .portals + .iter() .find(|p| p.name == target_entry) .map(|p| (p.x, p.y)); let (ax, ay) = match arrival { @@ -366,7 +385,10 @@ impl GameState { self.active_scroll = None; // Switch to the new board and place the player at the arrival portal. self.current_board_name = target_map.to_string(); - self.board_mut().player = Player { x: ax as i32, y: ay as i32 }; + self.board_mut().player = Player { + x: ax as i32, + y: ay as i32, + }; // Rebuild the script host for the new board's objects. self.scripts = ScriptHost::new( &self.world.boards[&self.current_board_name], @@ -405,7 +427,9 @@ impl GameState { board.player.x = nx as i32; board.player.y = ny as i32; // Check for a portal at the new position; clone strings to release the borrow. - portal_target = board.portals.iter() + portal_target = board + .portals + .iter() .find(|p| p.x == nx && p.y == ny) .map(|p| (p.target_map.clone(), p.target_entry.clone())); } else { @@ -453,4 +477,3 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option Self { + Self { + tile: 0, + fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, + bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, + } + } +} diff --git a/kiln-core/src/layer.rs b/kiln-core/src/layer.rs new file mode 100644 index 0000000..379de6d --- /dev/null +++ b/kiln-core/src/layer.rs @@ -0,0 +1,290 @@ +//! Palette layers: the unit of the map-file format and the board's draw stack. +//! +//! A board is an ordered list of **layers**, drawn bottom (index 0) to top. Each +//! layer is a character grid plus a palette mapping each character to one *kind* +//! of thing — an archetype (terrain), a floor glyph, a scripted object, a portal, +//! or the player. "Put two things on one cell" simply means "use two layers". +//! +//! This module owns the per-layer serde types ([`LayerData`], [`PaletteEntry`]), +//! the runtime [`Layer`] (a grid of `(Glyph, Archetype)` cells, where a cell with +//! a transparent glyph lets lower layers show through), and the load-time +//! conversion ([`build_layer`]) that turns one `LayerData` into a `Layer` plus a +//! list of [`Placement`]s (objects/portals/player) for the map loader to resolve +//! across layers. Cross-layer validation (one solid per cell, unique names, the +//! player winning its cell) lives in [`crate::map_file`]. + +use crate::archetype::Archetype; +use crate::floor::FloorGenerator; +use crate::glyph::Glyph; +use crate::log::LogLine; +use crate::map_file::{TileIndex, parse_color}; +use crate::object_def::ObjectDef; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tinyrand::StdRand; + +/// A single draw layer: a row-major grid of `(Glyph, Archetype)` cells. +/// +/// A cell whose `glyph.tile == 0` (see [`Glyph::transparent`]) is transparent — +/// it draws nothing and lets the layer beneath show through. Solidity comes from +/// the archetype and is independent of transparency. The grid is `width * height` +/// long; index it via the owning [`Board`](crate::board::Board)'s dimensions. +pub(crate) struct Layer { + /// Row-major `(Glyph, Archetype)` cells for this layer. + pub(crate) cells: Vec<(Glyph, Archetype)>, +} + +/// Serde representation of one `[[layers]]` entry: a grid string and its palette. +#[derive(Deserialize, Serialize)] +pub(crate) struct LayerData { + /// Multi-line grid string; one char per cell, looked up in `palette`. + pub content: String, + /// Char (as a one-character string key) → palette entry. + #[serde(default)] + pub palette: HashMap, +} + +/// One palette entry, discriminated by [`kind`](PaletteEntry::kind). +/// +/// A single flat struct (rather than an enum) because `kind` is open-ended: it is +/// any archetype name (`"wall"`, `"crate"`, `"pusher_east"`, …) *or* one of the +/// meta-kinds `empty`, `floor`, `object`, `portal`, `player`. Only the fields +/// relevant to a given kind are read; the rest stay `None`. See [`resolve_entry`]. +#[derive(Deserialize, Serialize, Default, Clone)] +pub(crate) struct PaletteEntry { + /// What this entry is: an archetype name, or `empty`/`floor`/`object`/`portal`/`player`. + pub kind: String, + /// Tile index (int or single-char string). Used by archetype/floor/object kinds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tile: Option, + /// Foreground `"#RRGGBB"`. Used by archetype/floor/object kinds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fg: Option, + /// Background `"#RRGGBB"`. Used by archetype/floor/object kinds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bg: Option, + /// Floor generator name (`"grass"`/`"dirt"`/`"stone"`). Only for `kind = "floor"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generator: Option, + /// Object solidity (defaults `true`). Only for `kind = "object"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub solid: Option, + /// Object opacity (defaults `true`). Only for `kind = "object"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opaque: Option, + /// Object pushability (defaults `false`). Only for `kind = "object"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pushable: Option, + /// Rhai script name. Only for `kind = "object"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script_name: Option, + /// Open-ended labels. Only for `kind = "object"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + /// Unique name. For `kind = "object"` (optional) or `kind = "portal"` (required). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Target board key. Required for `kind = "portal"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_map: Option, + /// Target portal name on the destination board. Required for `kind = "portal"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_entry: Option, +} + +/// A non-terrain thing a layer places at a grid cell, resolved by the map loader. +/// +/// Terrain/floor go straight into [`Layer::cells`]; these need cross-layer +/// handling (ids, name uniqueness, the single player), so [`build_layer`] returns +/// them separately with their `(x, y)` for [`crate::map_file`] to finish. +pub(crate) enum Placement { + /// A scripted object to spawn at `(x, y)`. + Object(ObjectTemplate, usize, usize), + /// A portal at `(x, y)`. + Portal(PortalTemplate, usize, usize), + /// The player's start cell `(x, y)`. + Player(usize, usize), +} + +/// A resolved object definition minus board-assigned fields (`id`, `z`). +#[derive(Clone)] +pub(crate) struct ObjectTemplate { + pub glyph: Glyph, + pub solid: bool, + pub opaque: bool, + pub pushable: bool, + pub script_name: Option, + pub tags: Vec, + pub name: Option, +} + +/// A resolved portal definition minus `(x, y, z)`. +#[derive(Clone)] +pub(crate) struct PortalTemplate { + pub name: String, + pub target_map: String, + pub target_entry: String, +} + +/// What a palette character resolves to during the grid walk. +#[derive(Clone)] +enum Resolved { + /// A fixed cell written straight into the layer (terrain, fixed floor, empty, error block). + Cell(Glyph, Archetype), + /// A procedural floor: roll a fresh glyph per occurrence. + FloorGen(FloorGenerator), + /// A scripted object placed per occurrence. + Object(ObjectTemplate), + /// A portal placed per occurrence. + Portal(PortalTemplate), + /// The player's start cell. + Player, +} + +/// Resolves one palette entry to a [`Resolved`], recording any nonfatal problem. +/// +/// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `floor` +/// with an unknown generator or a `portal` missing required fields falls back to a +/// transparent cell (the grid char is still consumed). Object/floor/archetype +/// glyphs fall back to the relevant default for any absent visual field. +fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec) -> Resolved { + // Builds a glyph from the entry's tile/fg/bg, each falling back to `default`. + let glyph_with_default = |default: Glyph| Glyph { + tile: e.tile.map(TileIndex::into_u32).unwrap_or(default.tile), + fg: e.fg.as_deref().map(parse_color).unwrap_or(default.fg), + bg: e.bg.as_deref().map(parse_color).unwrap_or(default.bg), + }; + + match e.kind.as_str() { + // Transparent: lower layers show through here. + "empty" => Resolved::Cell(Glyph::transparent(), Archetype::Empty), + "floor" => match &e.generator { + Some(name) => match FloorGenerator::from_name(name) { + Some(g) => Resolved::FloorGen(g), + None => { + errors.push(LogLine::error(format!( + "floor palette '{ch}' names unknown generator '{name}'; using empty floor" + ))); + Resolved::Cell(Glyph::transparent(), Archetype::Empty) + } + }, + // A fixed floor glyph: a visual-only cell (no archetype behavior). + None => Resolved::Cell( + glyph_with_default(Glyph { + tile: 32, + ..Glyph::transparent() + }), + Archetype::Empty, + ), + }, + "object" => Resolved::Object(ObjectTemplate { + glyph: glyph_with_default(ObjectDef::default_glyph()), + solid: e.solid.unwrap_or(true), + opaque: e.opaque.unwrap_or(true), + pushable: e.pushable.unwrap_or(false), + script_name: e.script_name.clone(), + tags: e.tags.clone().unwrap_or_default(), + name: e.name.clone(), + }), + "portal" => match (e.name.clone(), e.target_map.clone(), e.target_entry.clone()) { + (Some(name), Some(target_map), Some(target_entry)) => { + Resolved::Portal(PortalTemplate { + name, + target_map, + target_entry, + }) + } + _ => { + errors.push(LogLine::error(format!( + "portal palette '{ch}' needs name, target_map and target_entry; skipping" + ))); + Resolved::Cell(Glyph::transparent(), Archetype::Empty) + } + }, + "player" => Resolved::Player, + // Any other kind must be an archetype name. + other => match Archetype::try_from(other) { + Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a), + Err(msg) => { + errors.push(LogLine::error(format!( + "palette '{ch}': {msg}; using error block" + ))); + Resolved::Cell(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock) + } + }, + } +} + +/// Builds one [`Layer`] from its [`LayerData`], plus the non-terrain placements it +/// contains (with their `(x, y)`). +/// +/// `rng` is the shared, deterministically-seeded floor PRNG (one per board build), +/// threaded through so procedural floors depend only on map content. Returns +/// `Err` only on a grid-dimension mismatch (the single hard error, matching the +/// pre-layers loader); every other problem is recorded on `errors`. +pub(crate) fn build_layer( + data: &LayerData, + width: usize, + height: usize, + rng: &mut StdRand, + errors: &mut Vec, +) -> Result<(Layer, Vec), String> { + // Resolve each palette entry once (per-cell work like generators happens below). + let resolved: HashMap = data + .palette + .iter() + .map(|(key, entry)| { + let ch = key.chars().next().unwrap_or(' '); + (ch, resolve_entry(ch, entry, errors)) + }) + .collect(); + + // Validate dimensions before walking — the only fatal error. + let rows: Vec<&str> = data.content.lines().collect(); + if rows.len() != height { + return Err(format!( + "layer grid has {} rows but the board is {height} tall", + rows.len() + )); + } + for (i, line) in rows.iter().enumerate() { + let cols = line.chars().count(); + if cols != width { + return Err(format!( + "layer grid row {i} has {cols} characters but the board is {width} wide" + )); + } + } + + // Walk the grid, filling cells and collecting placements. + let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height); + let mut placements: Vec = Vec::new(); + for (y, line) in rows.iter().enumerate() { + for (x, ch) in line.chars().enumerate() { + match resolved.get(&ch) { + Some(Resolved::Cell(g, a)) => cells.push((*g, *a)), + Some(Resolved::FloorGen(g)) => cells.push((g.generate(rng), Archetype::Empty)), + Some(Resolved::Object(t)) => { + cells.push((Glyph::transparent(), Archetype::Empty)); + placements.push(Placement::Object(t.clone(), x, y)); + } + Some(Resolved::Portal(t)) => { + cells.push((Glyph::transparent(), Archetype::Empty)); + placements.push(Placement::Portal(t.clone(), x, y)); + } + Some(Resolved::Player) => { + cells.push((Glyph::transparent(), Archetype::Empty)); + placements.push(Placement::Player(x, y)); + } + None => { + errors.push(LogLine::error(format!( + "unknown grid character '{ch}' at ({x}, {y}); using error block" + ))); + cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)); + } + } + } + } + + Ok((Layer { cells }, placements)) +} diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index 17369cf..9de9469 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -1,24 +1,25 @@ -/// The optional per-cell visual floor layer ([`floor::FloorSpec`], [`floor::build_floor`]). +mod action; +mod archetype; +mod board; +/// Procedural floor generators ([`floor::FloorGenerator`]). pub mod floor; /// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc. pub mod game; +pub mod glyph; +mod layer; /// Styled log messages ([`log::LogLine`]) for the in-game message feed. pub mod log; /// Map file loading and saving (`.toml` format). pub mod map_file; -/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]). -pub mod world; +mod object_def; /// Rhai scripting runtime for board objects ([`script::ScriptHost`]). pub mod script; -pub mod glyph; -mod action; mod utils; -mod archetype; -mod object_def; -mod board; +/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]). +pub mod world; pub use board::Board; pub use utils::Direction; #[cfg(test)] -mod tests; \ No newline at end of file +mod tests; diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 7f0ee95..1c01de9 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -1,57 +1,55 @@ -use crate::floor::{build_floor, FloorSpec}; -use crate::board::Board; -use crate::log::LogLine; +//! Per-board map-file load/save and the small serde types that orchestrate it. +//! +//! A board in a `.toml` world file is a `[map]` header plus an ordered array of +//! `[[layers]]` (see [`crate::layer`]). This module owns the [`MapFile`]/ +//! [`MapHeader`] serde shells and the conversions to and from a runtime +//! [`Board`]; the per-layer grid/palette work lives in [`crate::layer`]. +//! +//! Loading is **best-effort/nonfatal**: only a layer grid-dimension mismatch is a +//! hard error. Every other problem (unknown archetype/char → `ErrorBlock`, missing +//! or duplicate player cell, two solids stacked on a cell, duplicate object/portal +//! names) is recovered and recorded on [`Board::load_errors`]. + use color::Rgba8; use serde::{Deserialize, Serialize}; use std::collections::hash_map::Entry; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::convert::TryFrom; use std::path::Path; +use tinyrand::{Seeded, StdRand}; + use crate::archetype::Archetype; +use crate::board::Board; +use crate::floor::FLOOR_SEED; use crate::glyph::Glyph; +use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer}; +use crate::log::LogLine; use crate::object_def::ObjectDef; use crate::utils::{ObjectId, Player, PortalDef}; -/// The top-level serialization/deserialization type for a `.toml` map file. +/// The serde shell for one board in a `.toml` file: a header and its layers. /// -/// This struct mirrors the TOML file structure exactly. On load it is -/// immediately converted into a [`Board`] via [`TryFrom`] and then -/// discarded. On save a [`Board`] is converted back into this struct via -/// [`From<&Board>`] before serialization. -/// -/// See `maps/start.toml` for a complete example of the file format. +/// On load this is converted into a [`Board`] via [`TryFrom`] and discarded; on +/// save a [`Board`] is converted back via [`From<&Board>`]. See `maps/start.toml` +/// for a complete example of the format. #[derive(Deserialize, Serialize)] pub struct MapFile { - /// The `[map]` section: name, dimensions, player start position. + /// The `[map]` header: name, dimensions, optional board script. pub map: MapHeader, - /// The `[palette]` section: maps single-character keys to tile definitions. - pub palette: HashMap, - /// The `[grid]` section: the multi-line string that defines cell layout. - pub grid: GridData, - /// Optional `[floor]` declaration: the visual floor layer (a generator name, - /// a single glyph, or a grid with its own palette). See [`FloorSpec`]. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub floor: Option, - /// Any `[[objects]]` entries. Optional; defaults to empty. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) objects: Vec, - /// Any `[[portals]]` entries. Optional; defaults to empty. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) portals: Vec, + /// The ordered `[[layers]]` stack, bottom (index 0) to top. + #[serde(default)] + pub(crate) layers: Vec, } -/// The `[map]` header section of a map file. +/// The `[map]` header section of a board. #[derive(Deserialize, Serialize)] pub struct MapHeader { /// Human-readable name for this board, e.g. `"Opening Room"`. pub name: String, - /// Width of the board in cells. Must match the length of every row in `[grid] content`. + /// Width of the board in cells. Must match every layer row's length. pub width: usize, - /// Height of the board in cells. Must match the number of rows in `[grid] content`. + /// Height of the board in cells. Must match every layer's row count. pub height: usize, - /// Where the player starts: either explicit `[x, y]` coordinates or a single - /// grid character to locate (e.g. `"@"`). See [`PlayerStart`]. - pub player_start: PlayerStart, /// Name of the board-level script in the `[scripts]` table, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub board_script_name: Option, @@ -59,10 +57,8 @@ pub struct MapHeader { /// A tile index in a palette entry: either a plain integer or a character literal. /// -/// Accepting both forms lets map files use `tile = 32` for new files or -/// `tile = " "` for convenience (the char is converted to its Unicode scalar). -/// This means existing maps that used `ch = "#"` can be migrated by renaming -/// the key to `tile`; the char form keeps working. +/// Accepting both forms lets map files write `tile = 35` or `tile = "#"` (the char +/// is converted to its Unicode scalar) interchangeably. #[derive(Deserialize, Serialize, Clone, Copy)] #[serde(untagged)] pub enum TileIndex { @@ -73,6 +69,7 @@ pub enum TileIndex { } impl TileIndex { + /// Returns the tile index as a `u32`, converting a char to its scalar value. pub(crate) fn into_u32(self) -> u32 { match self { TileIndex::Num(n) => n, @@ -81,197 +78,6 @@ impl TileIndex { } } -/// Where the player starts in a map file: explicit coordinates or a single grid -/// character to locate. -/// -/// Untagged like [`TileIndex`], so `player_start = [30, 12]` parses as -/// [`PlayerStart::Coord`] and `player_start = "@"` as [`PlayerStart::Char`] -/// (`Coord` is listed first, so a TOML array matches it and a string falls -/// through to `Char`). -#[derive(Deserialize, Serialize)] -#[serde(untagged)] -pub enum PlayerStart { - /// Explicit `[x, y]` coordinates (0-indexed, origin top-left). - Coord([i32; 2]), - /// A single grid character marking the player's cell (its cell loads `Empty`). - Char(char), -} - -/// One entry in the `[palette]` table. -/// -/// Each entry is keyed by a single character (e.g. `"#"` or `" "`). -/// That character is used in the `[grid]` content string to place tiles. -/// The entry defines which [`Archetype`] the cell uses for behavior; `tile`, -/// `fg`, and `bg` are optional and fall back to [`Archetype::default_glyph`] -/// when absent. On save, fields that match the archetype default are omitted. -/// -/// `tile` accepts either an integer (`tile = 35`) or a char (`tile = "#"`). -#[derive(Deserialize, Serialize)] -pub struct PaletteEntry { - /// The archetype name for this tile, e.g. `"wall"` or `"empty"`. - pub archetype: String, - /// Tile index into the board's bitmap font. Omit to use the archetype default. - /// Accepts either an integer or a single-character string. - #[serde(default, skip_serializing_if = "Option::is_none")] - tile: Option, - /// Foreground color as an `"#RRGGBB"` hex string. Omit to use the archetype default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fg: Option, - /// Background color as an `"#RRGGBB"` hex string. Omit to use the archetype default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bg: Option, -} - -/// The `[grid]` section of a map file. -/// -/// `content` is a TOML multi-line string. Each line is one row of the board; -/// each character in a line is looked up in the palette to determine the -/// tile for that cell. -#[derive(Deserialize, Serialize)] -pub struct GridData { - /// The raw grid content. Split by [`str::lines`] during loading. - pub content: String, -} - -/// One `[[objects]]` entry in a map file. -/// -/// Used only for TOML serialization/deserialization. Converted to/from -/// [`ObjectDef`] (which holds a [`Glyph`] directly) in the [`TryFrom`] / -/// [`From`] impls below. -#[derive(Deserialize, Serialize)] -pub(crate) struct MapFileObjectEntry { - /// Column of this object on the board (0-indexed). Mutually exclusive with - /// `palette`: provide either `x`/`y` or `palette`, not both. - #[serde(default, skip_serializing_if = "Option::is_none")] - x: Option, - /// Row of this object on the board (0-indexed). See `x`. - #[serde(default, skip_serializing_if = "Option::is_none")] - y: Option, - /// Single grid character marking where this object sits, as an alternative to - /// `x`/`y`. By convention an uppercase letter. The char may appear multiple - /// times in the grid — one `ObjectDef` is spawned per occurrence in reading - /// order. Only one `[[objects]]` entry may use a given char, and it must not - /// collide with a `[palette]` key. Each cell under the char loads as `Empty`. - /// If the entry has a `name`, only the first spawned instance keeps it. - #[serde(default, skip_serializing_if = "Option::is_none")] - palette: Option, - /// Tile index into the board's bitmap font. Accepts integer or single-char string. - tile: TileIndex, - /// Foreground color as an `"#RRGGBB"` hex string. - fg: String, - /// Background color as an `"#RRGGBB"` hex string. - bg: String, - /// Whether the object blocks / participates in movement. Defaults to `true` - /// (blocking). Inverse of the old `passable` field. - #[serde(default = "default_true")] - solid: bool, - /// Whether the object blocks player line of sight / FOV - #[serde(default = "default_true")] - opaque: bool, - /// Whether the object can be shoved by a mover. Unused for now; defaults `false`. - #[serde(default)] - pushable: bool, - /// Name of the Rhai script in the `[scripts]` table, if any. - #[serde(default, skip_serializing_if = "Option::is_none")] - script_name: Option, - /// Open-ended string labels. Serialized as a TOML array; omitted when empty. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - tags: Vec, - /// Optional unique human-readable name. Omitted from TOML when absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - name: Option, -} - -/// One `[[portals]]` entry in a map file. -/// -/// Used only for TOML serialization/deserialization. Converted to [`PortalDef`] -/// in the [`TryFrom`] impl. On save, always written with concrete `x`/`y` -/// (the `palette` form is a load-time convenience only). -/// -/// By convention, portal palette chars are digits (`'1'`–`'9'`), parallel to the -/// uppercase-letter convention for objects. -#[derive(Deserialize, Serialize)] -pub(crate) struct MapFilePortalEntry { - /// Board-unique name; also used as `target_entry` by the other end. - pub name: String, - /// Column of this portal (0-indexed). Mutually exclusive with `palette`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub x: Option, - /// Row of this portal (0-indexed). See `x`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub y: Option, - /// Single grid character (conventionally a digit). Alternative to `x`/`y`. - /// A portal's char may appear only once in the grid. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub palette: Option, - /// Key of the target board in `World::boards`. - pub target_map: String, - /// Name of the arrival portal on the target board. - pub target_entry: String, -} - -fn default_true() -> bool { - true -} - -/// Constructs a [`Glyph`] from the three raw TOML-sourced fields. -fn make_glyph(tile: u32, fg: &str, bg: &str) -> Glyph { - Glyph { tile, fg: parse_color(fg), bg: parse_color(bg) } -} - -/// Constructs a serializable [`PaletteEntry`] from core board types. -/// -/// Fields that match the archetype's [`default_glyph`](Archetype::default_glyph) -/// are omitted (`None`) so saved files stay minimal and round-trip cleanly. -fn make_palette_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry { - let default = arch.default_glyph(); - PaletteEntry { - archetype: arch.name().to_string(), - tile: (glyph.tile != default.tile).then_some(TileIndex::Num(glyph.tile)), - fg: (glyph.fg != default.fg ).then_some(color_to_hex(glyph.fg)), - bg: (glyph.bg != default.bg ).then_some(color_to_hex(glyph.bg)), - } -} - -/// Resolves the player's starting cell from `player_start`, falling back to `(0, 0)` on any error. -fn resolve_player_start( - start: &PlayerStart, - w: usize, - h: usize, - player_grid_pos: Option<(usize, usize)>, - player_grid_count: usize, - errors: &mut Vec, -) -> (usize, usize) { - match start { - PlayerStart::Coord([x, y]) - if *x >= 0 && *y >= 0 && (*x as usize) < w && (*y as usize) < h => - { - (*x as usize, *y as usize) - } - PlayerStart::Coord([x, y]) => { - errors.push(LogLine::error(format!( - "player_start ({x}, {y}) is out of bounds; placing player at (0, 0)" - ))); - (0, 0) - } - PlayerStart::Char(c) => match (player_grid_pos, player_grid_count) { - (Some(pos), 1) => pos, - (Some(pos), n) => { - errors.push(LogLine::error(format!( - "player char '{c}' appears {n} times in the grid; using the first" - ))); - pos - } - _ => { - errors.push(LogLine::error(format!( - "player char '{c}' not found in the grid; placing player at (0, 0)" - ))); - (0, 0) - } - }, - } -} - /// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`]. /// Returns opaque black on any parse failure. pub(crate) fn parse_color(hex: &str) -> Rgba8 { @@ -297,424 +103,173 @@ pub(crate) fn color_to_hex(color: Rgba8) -> String { /// Converts a parsed map file into a runtime [`Board`]. /// -/// Returns `Err(String)` if the grid dimensions do not match the header: -/// - wrong number of rows (actual row count ≠ `height`) -/// - any row whose character count ≠ `width` +/// Builds each layer (collecting its object/portal/player placements), then runs +/// the cross-layer validations that span the whole board: +/// - the player must appear exactly once (missing → `(0, 0)`; multiple → the first), and wins its cell; +/// - at most one solid may occupy a cell across all layers (a stacked solid is dropped); +/// - object names must be board-unique (a duplicate is cleared) and portal names unique (a duplicate is dropped). /// -/// The conversion proceeds in two passes: -/// -/// 1. **Palette pass** — each entry is parsed into a `(Glyph, Archetype)` pair -/// and stored in a temporary `HashMap` keyed by the palette character. -/// Unknown archetype names produce an [`Archetype::ErrorBlock`] and a logged -/// warning so malformed maps are visible in-game. -/// -/// 2. **Grid pass** — `grid.content` is split into lines; each character is -/// looked up in the palette map and pushed directly into `cells`. +/// Returns `Err` only when a layer's grid dimensions disagree with the header. impl TryFrom for Board { type Error = String; fn try_from(mf: MapFile) -> Result { let w = mf.map.width; let h = mf.map.height; - - // Nonfatal problems collected as we load; surfaced on the Board so callers - // can read `Board::is_valid`. Only grid-dimension mismatch (below) is fatal. let mut load_errors: Vec = Vec::new(); - // If the player is placed by a grid char, this is that char (e.g. '@'). - let player_char = match mf.map.player_start { - PlayerStart::Char(c) => Some(c), - PlayerStart::Coord(_) => None, - }; + // One PRNG for the whole board so generated floors depend only on content. + let mut rng = StdRand::seed(FLOOR_SEED); - // Pass 1: build a char → (Glyph, Archetype) lookup from the palette. - let mut palette: HashMap = HashMap::new(); - - for (key, entry) in mf.palette { - let key_char = key.chars().next().unwrap_or(' '); - match Archetype::try_from(entry.archetype.as_str()) { - Ok(archetype) => { - // Fall back to default_glyph() for any absent visual field. - let default = archetype.default_glyph(); - let tile = entry.tile.map(|t| t.into_u32()).unwrap_or(default.tile); - let fg = entry.fg.as_deref().map(parse_color).unwrap_or(default.fg); - let bg = entry.bg.as_deref().map(parse_color).unwrap_or(default.bg); - let glyph = Glyph { tile, fg, bg }; - palette.insert(key_char, (glyph, archetype)); - } - Err(e) => { - load_errors.push(LogLine::error(e)); - palette.insert( - key_char, - (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock), - ); + // Build every layer, collecting non-terrain placements with their layer z. + let mut layers: Vec = Vec::with_capacity(mf.layers.len()); + let mut object_specs: Vec<(crate::layer::ObjectTemplate, usize, usize, usize)> = Vec::new(); + let mut portal_specs: Vec<(crate::layer::PortalTemplate, usize, usize, usize)> = Vec::new(); + let mut player_positions: Vec<(usize, usize)> = Vec::new(); + for (z, data) in mf.layers.iter().enumerate() { + let (layer, placements) = build_layer(data, w, h, &mut rng, &mut load_errors)?; + for p in placements { + match p { + Placement::Object(t, x, y) => object_specs.push((t, x, y, z)), + Placement::Portal(t, x, y) => portal_specs.push((t, x, y, z)), + Placement::Player(x, y) => player_positions.push((x, y)), } } + layers.push(layer); + } + if layers.is_empty() { + return Err("map has no [[layers]]".into()); } - // Validate grid dimensions before walking cells. - // Collect lines eagerly so we can check the count and reuse them. - let rows: Vec<&str> = mf.grid.content.lines().collect(); - if rows.len() != h { - return Err(format!( - "grid has {} rows but map header declares height = {}", - rows.len(), - h - )); - } - for (i, line) in rows.iter().enumerate() { - let col_count = line.chars().count(); - if col_count != w { - return Err(format!( - "grid row {} has {} characters but map header declares width = {}", - i, col_count, w + // The player must be placed exactly once across all layers. + let (px, py) = match player_positions.len() { + 1 => player_positions[0], + 0 => { + load_errors.push(LogLine::error( + "no player cell (kind = \"player\") found; placing player at (0, 0)", )); + (0, 0) } - } - - // Validate object `palette` references before the grid pass, since the - // grid pass needs to know which chars are object placeholders. - // `skip[i]` drops object `i`; `palette_char_owner` maps a valid object - // palette char to the single object that owns it. - let mut skip = vec![false; mf.objects.len()]; - let mut palette_char_owner: HashMap = HashMap::new(); - for (i, e) in mf.objects.iter().enumerate() { - let Some(p) = e.palette.as_deref() else { - continue; - }; - // `palette` is mutually exclusive with explicit `x`/`y`. - if e.x.is_some() || e.y.is_some() { + n => { load_errors.push(LogLine::error(format!( - "object {i}: specify either x/y or palette, not both; skipping object" + "player cell appears {n} times across layers; using the first" ))); - skip[i] = true; - continue; + player_positions[0] } - // It must be exactly one character. - let mut chs = p.chars(); - let (Some(ch), None) = (chs.next(), chs.next()) else { - load_errors.push(LogLine::error(format!( - "object {i}: palette must be a single character (got {p:?}); skipping object" - ))); - skip[i] = true; - continue; - }; - // The player's char (e.g. '@') is reserved. - if Some(ch) == player_char { - load_errors.push(LogLine::error(format!( - "object {i}: palette char '{ch}' is reserved for the player; skipping object" - ))); - skip[i] = true; - continue; - } - // It can't reuse a terrain palette key. - if palette.contains_key(&ch) { - load_errors.push(LogLine::error(format!( - "object {i}: palette char '{ch}' is already a [palette] key; skipping object" - ))); - skip[i] = true; - continue; - } - // It must be unique across objects — keep the first claimant, drop later ones. - match palette_char_owner.entry(ch) { - Entry::Occupied(_) => { - load_errors.push(LogLine::error(format!( - "object {i}: palette char '{ch}' is already used by another object; skipping object" - ))); - skip[i] = true; - } - Entry::Vacant(v) => { - v.insert(i); - } - } - } - - // Validate portal `palette` references — same rules as objects, plus portals - // may not claim chars already taken by objects (and vice versa). - // `portal_skip[i]` drops portal `i`; `portal_palette_char_owner` maps char → portal index. - let mut portal_skip = vec![false; mf.portals.len()]; - let mut portal_palette_char_owner: HashMap = HashMap::new(); - for (i, e) in mf.portals.iter().enumerate() { - let Some(p) = e.palette.as_deref() else { continue; }; - if e.x.is_some() || e.y.is_some() { - load_errors.push(LogLine::error(format!( - "portal {i} ({:?}): specify either x/y or palette, not both; skipping portal", e.name - ))); - portal_skip[i] = true; - continue; - } - let mut chs = p.chars(); - let (Some(ch), None) = (chs.next(), chs.next()) else { - load_errors.push(LogLine::error(format!( - "portal {i} ({:?}): palette must be a single character (got {p:?}); skipping portal", e.name - ))); - portal_skip[i] = true; - continue; - }; - if Some(ch) == player_char { - load_errors.push(LogLine::error(format!( - "portal {i} ({:?}): palette char '{ch}' is reserved for the player; skipping portal", e.name - ))); - portal_skip[i] = true; - continue; - } - if palette.contains_key(&ch) { - load_errors.push(LogLine::error(format!( - "portal {i} ({:?}): palette char '{ch}' is already a [palette] key; skipping portal", e.name - ))); - portal_skip[i] = true; - continue; - } - if palette_char_owner.contains_key(&ch) { - load_errors.push(LogLine::error(format!( - "portal {i} ({:?}): palette char '{ch}' is already used by an object; skipping portal", e.name - ))); - portal_skip[i] = true; - continue; - } - match portal_palette_char_owner.entry(ch) { - Entry::Occupied(_) => { - load_errors.push(LogLine::error(format!( - "portal {i} ({:?}): palette char '{ch}' is already used by another portal; skipping portal", e.name - ))); - portal_skip[i] = true; - } - Entry::Vacant(v) => { v.insert(i); } - } - } - - // Pass 2: walk the validated grid and build cells. Object/portal palette chars - // become `Empty` floor (with their positions remembered); truly unknown - // chars become a visible `ErrorBlock` so the cell count stays correct. - let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty); - let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h); - // All grid positions (in reading order) at which each object palette char appears. - let mut obj_palette_positions: HashMap> = HashMap::new(); - // The single grid position at which each portal palette char appears (first sighting). - let mut portal_palette_positions: HashMap> = HashMap::new(); - // Where the player's char was first seen, and how many times (for recovery). - let mut player_grid_pos: Option<(usize, usize)> = None; - let mut player_grid_count: usize = 0; - for (y, line) in rows.iter().enumerate() { - for (x, ch) in line.chars().enumerate() { - if let Some(&cell) = palette.get(&ch) { - cells.push(cell); - } else if Some(ch) == player_char { - // Player placeholder: floor underneath; keep the first sighting. - cells.push(canonical_empty); - player_grid_pos.get_or_insert((x, y)); - player_grid_count += 1; - } else if palette_char_owner.contains_key(&ch) { - cells.push(canonical_empty); - obj_palette_positions.entry(ch).or_default().push((x, y)); - } else if portal_palette_char_owner.contains_key(&ch) { - cells.push(canonical_empty); - portal_palette_positions.entry(ch).or_default().push((x, y)); - } else { - load_errors.push(LogLine::error(format!( - "unknown grid character '{ch}' at ({x}, {y}); using error block" - ))); - cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)); - } - } - } - - // Resolve each surviving object palette char: missing → drop the entry. - // Multiple occurrences are valid — each spawns its own ObjectDef below. - for (&ch, &owner) in &palette_char_owner { - if skip[owner] { - continue; - } - if obj_palette_positions.get(&ch).is_none_or(Vec::is_empty) { - load_errors.push(LogLine::error(format!( - "object palette char '{ch}' not found in the grid; skipping object" - ))); - skip[owner] = true; - } - } - - // Resolve portal palette chars: must appear exactly once (warn + use first if multiple). - for (&ch, &owner) in &portal_palette_char_owner { - if portal_skip[owner] { continue; } - let positions = portal_palette_positions.get(&ch).map(Vec::as_slice).unwrap_or(&[]); - match positions.len() { - 0 => { - load_errors.push(LogLine::error(format!( - "portal palette char '{ch}' not found in the grid; skipping portal" - ))); - portal_skip[owner] = true; - } - n if n > 1 => { - load_errors.push(LogLine::error(format!( - "portal palette char '{ch}' appears {n} times in the grid; using the first" - ))); - } - _ => {} - } - } - - // Build the cached floor layer from the (optional) `[floor]` declaration. - // Best-effort: any problem is recorded on `load_errors`, not fatal. The - // raw spec is kept on the board so a save can round-trip it. - let floor_spec = mf.floor; - let floor = build_floor(&floor_spec, w, h, &mut load_errors); - - // Convert MapFileObjectEntry → ObjectDef, enforcing one solid per cell. - // `solid_occupied` is seeded from the grid (solid archetypes); each solid - // object then claims its cell, and a second solid on a cell is dropped. - let mut solid_occupied: Vec = cells.iter().map(|(_, a)| a.behavior().solid).collect(); - - // Resolve the player's cell (nonfatal; fall back to (0, 0) on any problem), - // then let the player *win* its cell: clear solid terrain under it and claim - // the cell so a solid object placed here is dropped by the loop below. - let (px, py) = resolve_player_start( - &mf.map.player_start, - w, - h, - player_grid_pos, - player_grid_count, - &mut load_errors, - ); + }; let pidx = py * w + px; + + // Enforce one solid per cell across all layers: the first solid seen at a + // cell claims it; a later (upper) solid stacked on it is dropped. + let mut solid_occupied = vec![false; w * h]; + for (z, layer) in layers.iter_mut().enumerate() { + for (idx, cell) in layer.cells.iter_mut().enumerate() { + if !cell.1.behavior().solid { + continue; + } + if solid_occupied[idx] { + let (x, y) = (idx % w, idx / w); + load_errors.push(LogLine::error(format!( + "two solids stacked at ({x}, {y}) on layer {z}; dropping the upper one" + ))); + *cell = (Glyph::transparent(), Archetype::Empty); + } else { + solid_occupied[idx] = true; + } + } + } + + // The player wins its cell: clear any solid terrain under it (any layer) + // and claim the cell so a solid object placed here is dropped below. if solid_occupied[pidx] { - cells[pidx] = canonical_empty; + for layer in &mut layers { + if layer.cells[pidx].1.behavior().solid { + layer.cells[pidx] = (Glyph::transparent(), Archetype::Empty); + } + } } solid_occupied[pidx] = true; - // Objects get sequential ids in load order (so BTreeMap iteration order == - // load order, preserving the documented "lowest id wins a collision" rule). + // Spawn objects in layer-then-reading order, so ids are deterministic and + // "lowest id wins a collision" / "first claimant keeps the name" hold. let mut objects: BTreeMap = BTreeMap::new(); let mut next_object_id: ObjectId = 1; - // Track claimed names so duplicates can be caught and cleared (nonfatal). - let mut seen_names: HashMap = HashMap::new(); - for (i, e) in mf.objects.into_iter().enumerate() { - if skip[i] { + let mut seen_names: HashMap = HashMap::new(); + for (t, x, y, z) in object_specs { + let idx = y * w + x; + // A solid object may not share a cell with another solid. + if t.solid && solid_occupied[idx] { + // The player silently wins its cell; any other conflict is reported. + if idx != pidx { + load_errors.push(LogLine::error(format!( + "solid object at ({x}, {y}) conflicts with an existing solid; skipping object" + ))); + } continue; } - // Collect all grid positions for this entry. Palette entries produce one - // position per occurrence (in reading order); x/y entries produce exactly one. - let positions: Vec<(usize, usize)> = if let Some(p) = e.palette.as_deref() { - let ch = p.chars().next().unwrap(); - obj_palette_positions.get(&ch).cloned().unwrap_or_default() - } else { - match (e.x, e.y) { - (Some(x), Some(y)) if x < w && y < h => vec![(x, y)], - (Some(x), Some(y)) => { - load_errors.push(LogLine::error(format!( - "object {i}: x/y ({x}, {y}) out of bounds; skipping object" - ))); - continue; - } - _ => { - load_errors.push(LogLine::error(format!( - "object {i}: must specify both x and y (or a palette char); skipping object" - ))); - continue; - } + let id = next_object_id; + // Name uniqueness: first claimant keeps it; later duplicates are cleared. + let name = t.name.and_then(|n| match seen_names.entry(n.clone()) { + Entry::Vacant(v) => { + v.insert(id); + Some(n) } - }; - - // Spawn one ObjectDef per position. Only the first instance may claim the - // entry's name; subsequent copies are anonymous (names must be board-unique). - for (instance_idx, &(x, y)) in positions.iter().enumerate() { - let name = if instance_idx == 0 { - // Validate name uniqueness: first claimant keeps the name, later ones - // have their name cleared and a nonfatal error is recorded. - e.name.as_ref().and_then(|n| { - match seen_names.entry(n.clone()) { - Entry::Vacant(v) => { v.insert(i); Some(n.clone()) } - Entry::Occupied(o) => { - load_errors.push(LogLine::error(format!( - "object {i}: name {n:?} already used by object {}; clearing name", - o.get() - ))); - None - } - } - }) - } else { + Entry::Occupied(o) => { + load_errors.push(LogLine::error(format!( + "object name {n:?} already used by object {}; clearing name", + o.get() + ))); None - }; - - let obj = ObjectDef { - id: 0, // stamped by Board::add_object + } + }); + if t.solid { + solid_occupied[idx] = true; + } + objects.insert( + id, + ObjectDef { + id, x, y, - glyph: make_glyph(e.tile.into_u32(), &e.fg, &e.bg), - solid: e.solid, - opaque: e.opaque, - pushable: e.pushable, - script_name: e.script_name.clone(), - tags: e.tags.iter().cloned().collect(), + z, + glyph: t.glyph, + solid: t.solid, + opaque: t.opaque, + pushable: t.pushable, + script_name: t.script_name, + tags: t.tags.into_iter().collect(), name, - }; - - // A solid object may not share a cell with another solid. - if obj.solid { - let idx = y * w + x; - if solid_occupied[idx] { - // The player silently wins its own cell (by design); any other - // solid collision is a reportable mistake. - if idx != pidx { - load_errors.push(LogLine::error(format!( - "object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object" - ))); - } - continue; - } - solid_occupied[idx] = true; - } - - objects.insert(next_object_id, obj); - next_object_id += 1; - } + }, + ); + next_object_id += 1; } - // Build the validated portal list, resolving palette chars to concrete (x, y). - let mut seen_portal_names: HashMap = HashMap::new(); + // Build the portal list, dropping duplicate names (first claimant wins). + let mut seen_portal_names: HashSet = HashSet::new(); let mut portals: Vec = Vec::new(); - for (i, e) in mf.portals.into_iter().enumerate() { - if portal_skip[i] { continue; } - let (x, y) = if let Some(p) = e.palette.as_deref() { - let ch = p.chars().next().unwrap(); - let positions = portal_palette_positions.get(&ch).map(Vec::as_slice).unwrap_or(&[]); - if positions.is_empty() { continue; } - positions[0] - } else { - match (e.x, e.y) { - (Some(x), Some(y)) if x < w && y < h => (x, y), - (Some(x), Some(y)) => { - load_errors.push(LogLine::error(format!( - "portal {i} ({:?}): x/y ({x}, {y}) out of bounds; skipping portal", e.name - ))); - continue; - } - _ => { - load_errors.push(LogLine::error(format!( - "portal {i} ({:?}): must specify both x and y (or a palette char); skipping portal", e.name - ))); - continue; - } - } - }; - // Duplicate names: first claimant keeps the name, later ones are dropped. - if seen_portal_names.contains_key(&e.name) { + for (t, x, y, z) in portal_specs { + if !seen_portal_names.insert(t.name.clone()) { load_errors.push(LogLine::error(format!( - "portal {i}: name {:?} already used by another portal; skipping portal", e.name + "portal name {:?} already used by another portal; skipping portal", + t.name ))); continue; } - seen_portal_names.insert(e.name.clone(), ()); - portals.push(PortalDef { name: e.name, x, y, target_map: e.target_map, target_entry: e.target_entry }); + portals.push(PortalDef { + name: t.name, + x, + y, + z, + target_map: t.target_map, + target_entry: t.target_entry, + }); } Ok(Board { name: mf.map.name, width: w, height: h, - cells, - floor, - floor_spec, + layers, player: Player { x: px as i32, y: py as i32, @@ -724,120 +279,165 @@ impl TryFrom for Board { portals, board_script_name: mf.map.board_script_name, load_errors, - registry: std::collections::HashMap::new(), + registry: HashMap::new(), }) } } +/// Pool of palette characters for save: printable ASCII (plus a leading space for +/// the common transparent-empty cell), excluding `"` and `\` which would need +/// escaping inside a TOML string. +fn char_pool() -> Vec { + let mut pool = vec![' ']; + pool.extend( + (33u8..=126u8) + .filter(|&b| b != b'"' && b != b'\\') + .map(|b| b as char), + ); + pool +} + +/// Builds the [`PaletteEntry`] for a terrain/floor cell `(glyph, arch)`. +fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry { + if arch == Archetype::Empty { + if glyph.tile == 0 { + // Transparent: lower layers show through. + PaletteEntry { + kind: "empty".into(), + ..Default::default() + } + } else { + // A fixed floor glyph (generators bake to literal glyphs on load). + PaletteEntry { + kind: "floor".into(), + tile: Some(TileIndex::Num(glyph.tile)), + fg: Some(color_to_hex(glyph.fg)), + bg: Some(color_to_hex(glyph.bg)), + ..Default::default() + } + } + } else { + PaletteEntry { + kind: arch.name().into(), + tile: Some(TileIndex::Num(glyph.tile)), + fg: Some(color_to_hex(glyph.fg)), + bg: Some(color_to_hex(glyph.bg)), + ..Default::default() + } + } +} + +/// Serializes one layer `z` of `board` to a [`LayerData`], optionally writing the +/// player cell into this layer (done on the top layer only). +fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData { + let (w, h) = (board.width, board.height); + let mut pool = char_pool().into_iter(); + let mut palette: HashMap = HashMap::new(); + let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new(); + let mut grid: Vec> = vec![vec![' '; w]; h]; + + // Terrain/floor cells: dedup each unique (glyph, archetype) to one palette char. + for (y, row) in grid.iter_mut().enumerate() { + for (x, slot) in row.iter_mut().enumerate() { + let (glyph, arch) = *board.get(z, x, y); + *slot = *cell_to_key.entry((glyph, arch)).or_insert_with(|| { + let ch = pool.next().expect("ran out of palette characters"); + palette.insert(ch.to_string(), cell_entry(glyph, arch)); + ch + }); + } + } + + // Objects on this layer overwrite their (transparent) cell with an object char. + for o in board.objects.values().filter(|o| o.z == z) { + let ch = pool.next().expect("ran out of palette characters"); + let mut tags: Vec = o.tags.iter().cloned().collect(); + tags.sort(); + palette.insert( + ch.to_string(), + PaletteEntry { + kind: "object".into(), + tile: Some(TileIndex::Num(o.glyph.tile)), + fg: Some(color_to_hex(o.glyph.fg)), + bg: Some(color_to_hex(o.glyph.bg)), + solid: Some(o.solid), + opaque: Some(o.opaque), + pushable: Some(o.pushable), + script_name: o.script_name.clone(), + tags: (!tags.is_empty()).then_some(tags), + name: o.name.clone(), + ..Default::default() + }, + ); + grid[o.y][o.x] = ch; + } + + // Portals on this layer. + for p in board.portals.iter().filter(|p| p.z == z) { + let ch = pool.next().expect("ran out of palette characters"); + palette.insert( + ch.to_string(), + PaletteEntry { + kind: "portal".into(), + name: Some(p.name.clone()), + target_map: Some(p.target_map.clone()), + target_entry: Some(p.target_entry.clone()), + ..Default::default() + }, + ); + grid[p.y][p.x] = ch; + } + + // The player is written onto the top layer. + if place_player { + let ch = pool.next().expect("ran out of palette characters"); + palette.insert( + ch.to_string(), + PaletteEntry { + kind: "player".into(), + ..Default::default() + }, + ); + grid[board.player.y as usize][board.player.x as usize] = ch; + } + + let content = grid + .into_iter() + .map(|row| row.into_iter().collect::()) + .collect::>() + .join("\n") + + "\n"; + LayerData { content, palette } +} + /// Converts a runtime [`Board`] back into a serializable [`MapFile`]. /// -/// The palette is reconstructed by enumerating unique `(Glyph, Archetype)` -/// combinations in the board's cells and assigning each a single printable -/// ASCII character key. The grid is rebuilt by looking up each cell in the -/// resulting palette map. +/// Emits one `[[layers]]` entry per board layer; the player is written onto the +/// top layer. Procedural floors were baked to literal glyphs at load, so they are +/// saved as fixed `floor` glyphs (the generator declaration is not recovered). impl From<&Board> for MapFile { fn from(board: &Board) -> Self { - // Pool for non-empty archetypes: printable ASCII 33-126 ('!' onward), - // excluding `"` and `\` which would require escaping inside TOML strings. - // Space (32) is reserved exclusively for Archetype::Empty. - let pool: Vec = (33u8..=126u8) - .filter(|&b| b != b'"' && b != b'\\') - .map(|b| b as char) + let top = board.layer_count() - 1; + let layers = (0..board.layer_count()) + .map(|z| layer_to_data(board, z, z == top)) .collect(); - let mut pool_iter = pool.iter(); - - // The canonical empty cell (tile 32, black/black) is always serialized as ' '. - // Every other unique (Glyph, Archetype) pair draws from the pool above. - let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty); - let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new(); - let mut palette: HashMap = HashMap::new(); - - for y in 0..board.height { - for x in 0..board.width { - let (glyph, arch) = board.get(x, y); - let key = (*glyph, *arch); - if let Entry::Vacant(e) = cell_to_key.entry(key) { - let ch = if key == canonical_empty { - ' ' - } else { - *pool_iter.next().expect("board has more than 92 unique non-canonical cell types") - }; - e.insert(ch); - palette.insert(ch.to_string(), make_palette_entry(*glyph, *arch)); - } - } - } - - // Pass 2: reconstruct the grid string row by row. - let mut rows: Vec = Vec::with_capacity(board.height); - for y in 0..board.height { - let mut row = String::with_capacity(board.width); - for x in 0..board.width { - let (glyph, arch) = board.get(x, y); - row.push(cell_to_key[&(*glyph, *arch)]); - } - rows.push(row); - } - // Trailing newline ensures the last row is complete; str::lines handles it correctly. - let content = rows.join("\n") + "\n"; - - // Convert ObjectDef → MapFileObjectEntry, encoding the glyph as TOML-compatible fields. - let objects: Vec = board - .objects - .values() - .map(|o| MapFileObjectEntry { - // Saving always emits concrete coordinates; the palette form is a - // load-time convenience only. - x: Some(o.x), - y: Some(o.y), - palette: None, - tile: TileIndex::Num(o.glyph.tile), - fg: color_to_hex(o.glyph.fg), - bg: color_to_hex(o.glyph.bg), - solid: o.solid, - opaque: o.opaque, - pushable: o.pushable, - script_name: o.script_name.clone(), - // Sort for stable TOML output; HashSet iteration order is non-deterministic. - tags: { let mut v: Vec = o.tags.iter().cloned().collect(); v.sort(); v }, - name: o.name.clone(), - }) - .collect(); - - // Always save portals with concrete x/y; the palette form is load-time only. - let portals: Vec = board.portals.iter().map(|p| MapFilePortalEntry { - name: p.name.clone(), - x: Some(p.x), - y: Some(p.y), - palette: None, - target_map: p.target_map.clone(), - target_entry: p.target_entry.clone(), - }).collect(); - MapFile { map: MapHeader { name: board.name.clone(), width: board.width, height: board.height, - player_start: PlayerStart::Coord([board.player.x, board.player.y]), board_script_name: board.board_script_name.clone(), }, - palette, - grid: GridData { content }, - // Round-trip the original floor declaration verbatim (generators - // re-randomize on the next load; literal glyph grids are exact). - floor: board.floor_spec.clone(), - objects, - portals, + layers, } } } /// Loads a map file from disk and returns a ready-to-use [`Board`]. /// -/// Reads the file at `path`, deserializes it as TOML into a [`MapFile`], -/// then converts it via [`TryFrom`]. Propagates I/O errors, TOML -/// parse errors, and grid dimension mismatches through the `Box` return. +/// Reads the file at `path`, deserializes it as a single-board [`MapFile`], then +/// converts it via [`TryFrom`]. Production code uses [`crate::world::load`] for +/// multi-board world files instead. pub fn load(path: &str) -> Result> { let content = std::fs::read_to_string(path)?; let map_file: MapFile = toml::from_str(&content)?; @@ -845,9 +445,6 @@ pub fn load(path: &str) -> Result> { } /// Serializes a [`Board`] to a `.toml` map file at `path`. -/// -/// Converts the board to a [`MapFile`] via [`From<&Board>`], then serializes -/// it with `toml::to_string_pretty`. Propagates I/O and serialization errors. pub fn save(board: &Board, path: &Path) -> Result<(), Box> { let map_file = MapFile::from(board); let toml_str = toml::to_string_pretty(&map_file)?; diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs index beb41a7..0360a99 100644 --- a/kiln-core/src/object_def.rs +++ b/kiln-core/src/object_def.rs @@ -1,7 +1,7 @@ -use color::Rgba8; -use std::collections::HashSet; use crate::glyph::Glyph; use crate::utils::ObjectId; +use color::Rgba8; +use std::collections::HashSet; /// A scripted object placed on the board, loaded from a map file. /// @@ -32,6 +32,10 @@ pub struct ObjectDef { pub x: usize, /// Row of this object on the board (0-indexed). pub y: usize, + /// Index of the layer this object belongs to (0 = bottom). Determines its + /// draw order: objects on higher layers render above lower-layer terrain and + /// objects. Set at load from the layer the object's palette char appeared in. + pub z: usize, /// Visual representation of this object. Owned by the object (not derived /// from the grid cell), so scripts can change tile, fg, and bg at runtime. pub glyph: Glyph, @@ -78,6 +82,7 @@ impl ObjectDef { id: 0, x, y, + z: 0, glyph: Self::default_glyph(), solid: true, opaque: true, @@ -87,4 +92,4 @@ impl ObjectDef { name: None, } } -} \ No newline at end of file +} diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 301c47d..f557f8d 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -53,13 +53,16 @@ //! //! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()` -use crate::action::{action_to_map, Action, ScrollLine, MOVE_COST}; -use crate::game::SAY_DURATION; +use crate::action::{Action, MOVE_COST, ScrollLine, action_to_map}; use crate::board::Board; +use crate::game::SAY_DURATION; use crate::log::LogLine; use crate::map_file::{color_to_hex, parse_color}; use crate::utils::{Direction, ObjectId, RegistryValue, ScriptArg}; -use rhai::{Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module, NativeCallContext, Scope, AST}; +use rhai::{ + AST, Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module, + NativeCallContext, Scope, +}; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; @@ -206,20 +209,28 @@ impl ScriptHost { let mut scripts: HashMap = HashMap::new(); let mut failed: HashSet = HashSet::new(); for obj in board.objects.values() { - let Some(name) = obj.script_name.as_ref() else { continue; }; - if scripts.contains_key(name) || failed.contains(name) { continue; } + let Some(name) = obj.script_name.as_ref() else { + continue; + }; + if scripts.contains_key(name) || failed.contains(name) { + continue; + } match script_sources.get(name) { Some(src) => match engine.compile(src) { Ok(ast) => { let defines = |n: &str, params: usize| { - ast.iter_functions().any(|f| f.name == n && f.params.len() == params) + ast.iter_functions() + .any(|f| f.name == n && f.params.len() == params) }; - scripts.insert(name.clone(), CompiledScript { - has_init: defines("init", 0), - has_tick: defines("tick", 1), - has_bump: defines("bump", 1), - ast, - }); + scripts.insert( + name.clone(), + CompiledScript { + has_init: defines("init", 0), + has_tick: defines("tick", 1), + has_bump: defines("bump", 1), + ast, + }, + ); } Err(err) => { failed.insert(name.clone()); @@ -240,8 +251,12 @@ impl ScriptHost { // One runtime per object whose script compiled. let mut objects = Vec::new(); for (&id, obj) in board.objects.iter() { - let Some(name) = obj.script_name.as_ref() else { continue; }; - if !scripts.contains_key(name) { continue; } + let Some(name) = obj.script_name.as_ref() else { + continue; + }; + if !scripts.contains_key(name) { + continue; + } let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new())); queues.borrow_mut().insert(id, queue.clone()); objects.push(ObjectRuntime { @@ -253,7 +268,13 @@ impl ScriptHost { } drop(board); - Self { engine, scripts, objects, board_queue, errors } + Self { + engine, + scripts, + objects, + board_queue, + errors, + } } /// Calls `init()` on every scripted object that defines it and drains each queue. @@ -273,16 +294,31 @@ impl ScriptHost { return; }; { - let Self { engine, scripts, objects, errors, .. } = self; + let Self { + engine, + scripts, + objects, + errors, + .. + } = self; let obj = &mut objects[i]; - let Some(compiled) = scripts.get(&obj.script_name) else { return; }; - if !compiled.has_bump { return; } + let Some(compiled) = scripts.get(&obj.script_name) else { + return; + }; + if !compiled.has_bump { + return; + } let options = CallFnOptions::default().with_tag(object_id as i64); if let Err(err) = engine.call_fn_with_options::<()>( - options, &mut obj.scope, &compiled.ast, "bump", (bumper,), + options, + &mut obj.scope, + &compiled.ast, + "bump", + (bumper,), ) { errors.borrow_mut().push(LogLine::error(format!( - "script '{}' bump error: {err}", obj.script_name + "script '{}' bump error: {err}", + obj.script_name ))); } } @@ -300,13 +336,25 @@ impl ScriptHost { return; }; { - let Self { engine, scripts, objects, errors, .. } = self; + let Self { + engine, + scripts, + objects, + errors, + .. + } = self; let obj = &mut objects[i]; - let Some(compiled) = scripts.get(&obj.script_name) else { return; }; + let Some(compiled) = scripts.get(&obj.script_name) else { + return; + }; - let has_1 = compiled.ast.iter_functions() + let has_1 = compiled + .ast + .iter_functions() .any(|f| f.name == fn_name && f.params.len() == 1); - let has_0 = compiled.ast.iter_functions() + let has_0 = compiled + .ast + .iter_functions() .any(|f| f.name == fn_name && f.params.is_empty()); let options = CallFnOptions::default().with_tag(obj.object_id as i64); @@ -318,11 +366,19 @@ impl ScriptHost { None => Dynamic::UNIT, }; engine.call_fn_with_options::<()>( - options, &mut obj.scope, &compiled.ast, fn_name, (dyn_arg,), + options, + &mut obj.scope, + &compiled.ast, + fn_name, + (dyn_arg,), ) } else if has_0 { engine.call_fn_with_options::<()>( - options, &mut obj.scope, &compiled.ast, fn_name, (), + options, + &mut obj.scope, + &compiled.ast, + fn_name, + (), ) } else { return; // Function not defined on this object @@ -330,7 +386,8 @@ impl ScriptHost { if let Err(err) = result { errors.borrow_mut().push(LogLine::error(format!( - "script '{}' send('{}') error: {err}", obj.script_name, fn_name + "script '{}' send('{}') error: {err}", + obj.script_name, fn_name ))); } } @@ -357,7 +414,9 @@ impl ScriptHost { if is_delay { let expired = { let mut q = queue.borrow_mut(); - let Some(Action::Delay(r)) = q.front_mut() else { break }; + let Some(Action::Delay(r)) = q.front_mut() else { + break; + }; *r -= dt; dt = 0.0; *r <= 0.0 @@ -368,8 +427,13 @@ impl ScriptHost { break; } } else { - let Some(action) = queue.borrow_mut().pop_front() else { break }; - self.board_queue.borrow_mut().push(BoardAction { source: oid, action }); + let Some(action) = queue.borrow_mut().pop_front() else { + break; + }; + self.board_queue.borrow_mut().push(BoardAction { + source: oid, + action, + }); } } } @@ -384,17 +448,28 @@ impl ScriptHost { ) { for i in 0..self.objects.len() { { - let Self { engine, scripts, objects, errors, .. } = self; + let Self { + engine, + scripts, + objects, + errors, + .. + } = self; let obj = &mut objects[i]; if let Some(compiled) = scripts.get(&obj.script_name) && defined(compiled) { let options = CallFnOptions::default().with_tag(obj.object_id as i64); if let Err(err) = engine.call_fn_with_options::<()>( - options, &mut obj.scope, &compiled.ast, hook, args, + options, + &mut obj.scope, + &compiled.ast, + hook, + args, ) { errors.borrow_mut().push(LogLine::error(format!( - "script '{}' {hook} error: {err}", obj.script_name + "script '{}' {hook} error: {err}", + obj.script_name ))); } } @@ -412,36 +487,46 @@ fn register_me_type(engine: &mut Engine) { engine.register_get("id", |me: &mut Me| me.object_id as i64); engine.register_get("name", |me: &mut Me| { - me.board.borrow() - .objects.get(&me.object_id) + me.board + .borrow() + .objects + .get(&me.object_id) .and_then(|o| o.name.as_deref()) .unwrap_or("") .to_string() }); engine.register_get("x", |me: &mut Me| { - me.board.borrow() - .objects.get(&me.object_id) + me.board + .borrow() + .objects + .get(&me.object_id) .map(|o| o.x as i64) .unwrap_or(0) }); engine.register_get("y", |me: &mut Me| { - me.board.borrow() - .objects.get(&me.object_id) + me.board + .borrow() + .objects + .get(&me.object_id) .map(|o| o.y as i64) .unwrap_or(0) }); engine.register_fn("has_tag", |me: &mut Me, tag: ImmutableString| { - me.board.borrow() - .objects.get(&me.object_id) + me.board + .borrow() + .objects + .get(&me.object_id) .is_some_and(|o| o.tags.contains(tag.as_str())) }); engine.register_fn("tags", |me: &mut Me| -> rhai::Array { - me.board.borrow() - .objects.get(&me.object_id) + me.board + .borrow() + .objects + .get(&me.object_id) .map(|o| o.tags.iter().map(|t| Dynamic::from(t.clone())).collect()) .unwrap_or_default() }); @@ -455,7 +540,11 @@ fn register_me_type(engine: &mut Engine) { bg: color_to_hex(obj.glyph.bg), } } else { - RhaiGlyph { tile: 0, fg: "#000000".to_string(), bg: "#000000".to_string() } + RhaiGlyph { + tile: 0, + fg: "#000000".to_string(), + bg: "#000000".to_string(), + } } }); } @@ -467,7 +556,8 @@ fn register_registry_type(engine: &mut Engine) { // Registry.get(key) -> Dynamic — returns () if the key is absent. engine.register_fn("get", |r: &mut Registry, key: ImmutableString| -> Dynamic { - r.board.borrow() + r.board + .borrow() .registry .get(key.as_str()) .map(|v| v.clone().into()) @@ -476,25 +566,39 @@ fn register_registry_type(engine: &mut Engine) { // Registry.set(key, value) — stores a primitive value; () removes the key; // unsupported types (closures, custom objects) are silently ignored. - engine.register_fn("set", |r: &mut Registry, key: ImmutableString, value: Dynamic| { - match RegistryValue::try_from(value.clone()) { - Ok(rv) => { r.board.borrow_mut().registry.insert(key.to_string(), rv); } - Err(()) if value.is_unit() => { r.board.borrow_mut().registry.remove(key.as_str()); } - Err(()) => {} // unsupported type — silently ignore - } - }); + engine.register_fn( + "set", + |r: &mut Registry, key: ImmutableString, value: Dynamic| { + match RegistryValue::try_from(value.clone()) { + Ok(rv) => { + r.board.borrow_mut().registry.insert(key.to_string(), rv); + } + Err(()) if value.is_unit() => { + r.board.borrow_mut().registry.remove(key.as_str()); + } + Err(()) => {} // unsupported type — silently ignore + } + }, + ); // Registry.get_or(key, default) — returns the stored value when present and the // same Rhai type as `default`; falls back to `default` otherwise. - engine.register_fn("get_or", |r: &mut Registry, key: ImmutableString, default: Dynamic| -> Dynamic { - if let Some(stored) = r.board.borrow().registry.get(key.as_str()).cloned() { - let candidate: Dynamic = stored.into(); - // Only return the stored value if it matches the type of the default. - if candidate.type_id() == default.type_id() { candidate } else { default } - } else { - default - } - }); + engine.register_fn( + "get_or", + |r: &mut Registry, key: ImmutableString, default: Dynamic| -> Dynamic { + if let Some(stored) = r.board.borrow().registry.get(key.as_str()).cloned() { + let candidate: Dynamic = stored.into(); + // Only return the stored value if it matches the type of the default. + if candidate.type_id() == default.type_id() { + candidate + } else { + default + } + } else { + default + } + }, + ); } // ── Register ObjectInfo type ────────────────────────────────────────────────── @@ -502,9 +606,9 @@ fn register_registry_type(engine: &mut Engine) { fn register_object_info_type(engine: &mut Engine) { engine.register_type_with_name::("ObjectInfo"); - engine.register_get("id", |o: &mut ObjectInfo| o.id); - engine.register_get("x", |o: &mut ObjectInfo| o.x); - engine.register_get("y", |o: &mut ObjectInfo| o.y); + engine.register_get("id", |o: &mut ObjectInfo| o.id); + engine.register_get("x", |o: &mut ObjectInfo| o.x); + engine.register_get("y", |o: &mut ObjectInfo| o.y); engine.register_get("name", |o: &mut ObjectInfo| { o.name.as_deref().unwrap_or("").to_string() @@ -520,8 +624,8 @@ fn register_object_info_type(engine: &mut Engine) { fn register_glyph_type(engine: &mut Engine) { engine.register_type_with_name::("Glyph"); engine.register_get("tile", |g: &mut RhaiGlyph| g.tile); - engine.register_get("fg", |g: &mut RhaiGlyph| g.fg.clone()); - engine.register_get("bg", |g: &mut RhaiGlyph| g.bg.clone()); + engine.register_get("fg", |g: &mut RhaiGlyph| g.fg.clone()); + engine.register_get("bg", |g: &mut RhaiGlyph| g.bg.clone()); } // ── Board read API ──────────────────────────────────────────────────────────── @@ -535,8 +639,8 @@ fn register_read_api( engine.register_type_with_name::("Board"); engine.register_get("player_x", |b: &mut BoardRef| b.borrow().player.x as i64); engine.register_get("player_y", |b: &mut BoardRef| b.borrow().player.y as i64); - engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64); - engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64); + engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64); + engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64); // blocked(dir) — true if moving in dir would be impossible for the calling object. let b = board.clone(); @@ -547,25 +651,35 @@ fn register_read_api( // Board.tagged(tag) -> Array[ObjectInfo] let b = board.clone(); - engine.register_fn("tagged", move |_board: &mut BoardRef, tag: ImmutableString| -> rhai::Array { - let board = b.borrow(); - board.objects.iter() - .filter(|(_, o)| o.tags.contains(tag.as_str())) - .filter_map(|(&id, _)| object_info_from(id, &board)) - .map(Dynamic::from) - .collect() - }); + engine.register_fn( + "tagged", + move |_board: &mut BoardRef, tag: ImmutableString| -> rhai::Array { + let board = b.borrow(); + board + .objects + .iter() + .filter(|(_, o)| o.tags.contains(tag.as_str())) + .filter_map(|(&id, _)| object_info_from(id, &board)) + .map(Dynamic::from) + .collect() + }, + ); // Board.named(name) -> ObjectInfo | () let b = board.clone(); - engine.register_fn("named", move |_board: &mut BoardRef, name: ImmutableString| -> Dynamic { - let board = b.borrow(); - board.objects.iter() - .find(|(_, o)| o.name.as_deref() == Some(name.as_str())) - .and_then(|(&id, _)| object_info_from(id, &board)) - .map(Dynamic::from) - .unwrap_or(Dynamic::UNIT) - }); + engine.register_fn( + "named", + move |_board: &mut BoardRef, name: ImmutableString| -> Dynamic { + let board = b.borrow(); + board + .objects + .iter() + .find(|(_, o)| o.name.as_deref() == Some(name.as_str())) + .and_then(|(&id, _)| object_info_from(id, &board)) + .map(Dynamic::from) + .unwrap_or(Dynamic::UNIT) + }, + ); // Board.get(id) -> ObjectInfo | () (-1 returns player; unknown id logs error) let b = board.clone(); @@ -576,13 +690,15 @@ fn register_read_api( return Dynamic::from(object_info_player(&board)); } if id <= 0 { - errs.borrow_mut().push(LogLine::error(format!("Board.get: invalid id {id}"))); + errs.borrow_mut() + .push(LogLine::error(format!("Board.get: invalid id {id}"))); return Dynamic::UNIT; } match object_info_from(id as ObjectId, &board) { Some(info) => Dynamic::from(info), None => { - errs.borrow_mut().push(LogLine::error(format!("Board.get: no object with id {id}"))); + errs.borrow_mut() + .push(LogLine::error(format!("Board.get: no object with id {id}"))); Dynamic::UNIT } } @@ -597,12 +713,18 @@ fn is_blocked( dir: Direction, ) -> bool { let board = board.borrow(); - let Some(obj) = board.objects.get(&source) else { return true; }; + let Some(obj) = board.objects.get(&source) else { + return true; + }; let (dx, dy): (i32, i32) = dir.into(); let target = (obj.x as i32 + dx, obj.y as i32 + dy); - if !board.in_bounds(target) { return true; } + if !board.in_bounds(target) { + return true; + } let (tx, ty) = (target.0 as usize, target.1 as usize); - if board.solid_at(tx, ty).is_some() { return true; } + if board.solid_at(tx, ty).is_some() { + return true; + } board_queue.borrow().iter().any(|ba| { if let Action::Move(d) = &ba.action && let Some(mover) = board.objects.get(&ba.source) @@ -663,37 +785,57 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { }); let q = queues.clone(); - engine.register_fn("log", move |ctx: NativeCallContext, msg: ImmutableString| { - emit(&q, source_of(&ctx), Action::Log(LogLine::raw(msg.to_string()))); - }); + engine.register_fn( + "log", + move |ctx: NativeCallContext, msg: ImmutableString| { + emit( + &q, + source_of(&ctx), + Action::Log(LogLine::raw(msg.to_string())), + ); + }, + ); let q = queues.clone(); - engine.register_fn("say", move |ctx: NativeCallContext, msg: ImmutableString| { - emit(&q, source_of(&ctx), Action::Say(msg.to_string(), SAY_DURATION)); - }); + engine.register_fn( + "say", + move |ctx: NativeCallContext, msg: ImmutableString| { + emit( + &q, + source_of(&ctx), + Action::Say(msg.to_string(), SAY_DURATION), + ); + }, + ); let q = queues.clone(); - engine.register_fn("say", move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| { - emit(&q, source_of(&ctx), Action::Say(msg.to_string(), dur)); - }); + engine.register_fn( + "say", + move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| { + emit(&q, source_of(&ctx), Action::Say(msg.to_string(), dur)); + }, + ); // scroll(lines): open a full-screen scrollable overlay. Each element of `lines` // is either a plain string (text) or a 2-element array [choice_key, display_text]. let q = queues.clone(); engine.register_fn("scroll", move |ctx: NativeCallContext, arr: Array| { - let lines = arr.into_iter().filter_map(|item| { - if let Ok(s) = item.clone().into_string() { - Some(ScrollLine::Text(s)) - } else { - let pair: Vec = item.into_array().ok()?; - if pair.len() == 2 { - let choice = pair[0].clone().into_string().ok()?; - let display = pair[1].clone().into_string().ok()?; - Some(ScrollLine::Choice { choice, display }) + let lines = arr + .into_iter() + .filter_map(|item| { + if let Ok(s) = item.clone().into_string() { + Some(ScrollLine::Text(s)) } else { - None + let pair: Vec = item.into_array().ok()?; + if pair.len() == 2 { + let choice = pair[0].clone().into_string().ok()?; + let display = pair[1].clone().into_string().ok()?; + Some(ScrollLine::Choice { choice, display }) + } else { + None + } } - } - }).collect(); + }) + .collect(); emit(&q, source_of(&ctx), Action::Scroll(lines)); }); @@ -701,41 +843,63 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { engine.register_fn( "set_tag", move |ctx: NativeCallContext, target: i64, tag: ImmutableString, present: bool| { - emit(&q, source_of(&ctx), Action::SetTag { - target: target as ObjectId, - tag: tag.to_string(), - present, - }); + emit( + &q, + source_of(&ctx), + Action::SetTag { + target: target as ObjectId, + tag: tag.to_string(), + present, + }, + ); }, ); // set_fg(fg): change foreground color only. let q = queues.clone(); - engine.register_fn("set_fg", move |ctx: NativeCallContext, fg: ImmutableString| { - emit(&q, source_of(&ctx), Action::SetColor { - fg: Some(parse_color(fg.as_str())), - bg: None, - }); - }); + engine.register_fn( + "set_fg", + move |ctx: NativeCallContext, fg: ImmutableString| { + emit( + &q, + source_of(&ctx), + Action::SetColor { + fg: Some(parse_color(fg.as_str())), + bg: None, + }, + ); + }, + ); // set_bg(bg): change background color only. let q = queues.clone(); - engine.register_fn("set_bg", move |ctx: NativeCallContext, bg: ImmutableString| { - emit(&q, source_of(&ctx), Action::SetColor { - fg: None, - bg: Some(parse_color(bg.as_str())), - }); - }); + engine.register_fn( + "set_bg", + move |ctx: NativeCallContext, bg: ImmutableString| { + emit( + &q, + source_of(&ctx), + Action::SetColor { + fg: None, + bg: Some(parse_color(bg.as_str())), + }, + ); + }, + ); // set_color(fg, bg): change both colors. let q = queues.clone(); engine.register_fn( "set_color", move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| { - emit(&q, source_of(&ctx), Action::SetColor { - fg: Some(parse_color(fg.as_str())), - bg: Some(parse_color(bg.as_str())), - }); + emit( + &q, + source_of(&ctx), + Action::SetColor { + fg: Some(parse_color(fg.as_str())), + bg: Some(parse_color(bg.as_str())), + }, + ); }, ); @@ -744,11 +908,15 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { engine.register_fn( "send", move |ctx: NativeCallContext, target: i64, name: ImmutableString| { - emit(&q, source_of(&ctx), Action::Send { - target: target as ObjectId, - fn_name: name.to_string(), - arg: None, - }); + emit( + &q, + source_of(&ctx), + Action::Send { + target: target as ObjectId, + fn_name: name.to_string(), + arg: None, + }, + ); }, ); @@ -766,11 +934,15 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { } else { None }; - emit(&q, source_of(&ctx), Action::Send { - target: target as ObjectId, - fn_name: name.to_string(), - arg: script_arg, - }); + emit( + &q, + source_of(&ctx), + Action::Send { + target: target as ObjectId, + fn_name: name.to_string(), + arg: script_arg, + }, + ); }, ); @@ -779,7 +951,14 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { // destination already has a solid occupant. let q = queues.clone(); engine.register_fn("teleport", move |ctx: NativeCallContext, x: i64, y: i64| { - emit(&q, source_of(&ctx), Action::Teleport { x: x as i32, y: y as i32 }); + emit( + &q, + source_of(&ctx), + Action::Teleport { + x: x as i32, + y: y as i32, + }, + ); }); } @@ -788,7 +967,7 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { fn register_queue_api(engine: &mut Engine) { engine.register_type_with_name::("Queue"); engine.register_fn("length", |q: &mut ObjQueue| q.borrow().len() as i64); - engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear()); + engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear()); // peek(): front action as a Rhai map, or () if empty. engine.register_fn("peek", |q: &mut ObjQueue| -> Dynamic { @@ -817,25 +996,25 @@ fn register_global_constants(engine: &mut Engine) { let mut m = Module::new(); m.set_var("North", Direction::North); m.set_var("South", Direction::South); - m.set_var("East", Direction::East); - m.set_var("West", Direction::West); + m.set_var("East", Direction::East); + m.set_var("West", Direction::West); // 16 EGA/VGA color constants as "#RRGGBB" strings. - m.set_var("Black", "#000000"); - m.set_var("Blue", "#0000AA"); - m.set_var("Green", "#00AA00"); - m.set_var("Cyan", "#00AAAA"); - m.set_var("Red", "#AA0000"); - m.set_var("Magenta", "#AA00AA"); - m.set_var("Brown", "#AA5500"); - m.set_var("LightGray", "#AAAAAA"); - m.set_var("DarkGray", "#555555"); - m.set_var("BrightBlue", "#5555FF"); - m.set_var("BrightGreen", "#55FF55"); - m.set_var("BrightCyan", "#55FFFF"); - m.set_var("BrightRed", "#FF5555"); + m.set_var("Black", "#000000"); + m.set_var("Blue", "#0000AA"); + m.set_var("Green", "#00AA00"); + m.set_var("Cyan", "#00AAAA"); + m.set_var("Red", "#AA0000"); + m.set_var("Magenta", "#AA00AA"); + m.set_var("Brown", "#AA5500"); + m.set_var("LightGray", "#AAAAAA"); + m.set_var("DarkGray", "#555555"); + m.set_var("BrightBlue", "#5555FF"); + m.set_var("BrightGreen", "#55FF55"); + m.set_var("BrightCyan", "#55FFFF"); + m.set_var("BrightRed", "#FF5555"); m.set_var("BrightMagenta", "#FF55FF"); - m.set_var("Yellow", "#FFFF55"); - m.set_var("White", "#FFFFFF"); + m.set_var("Yellow", "#FFFF55"); + m.set_var("White", "#FFFFFF"); engine.register_global_module(m.into()); } @@ -845,8 +1024,19 @@ fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) -> let mut scope = Scope::new(); scope.push_constant("Board", board.clone()); scope.push_constant("Queue", queue.clone()); - scope.push_constant("Me", Me { object_id, board: board.clone() }); - scope.push_constant("Registry", Registry { board: board.clone() }); + scope.push_constant( + "Me", + Me { + object_id, + board: board.clone(), + }, + ); + scope.push_constant( + "Registry", + Registry { + board: board.clone(), + }, + ); scope } diff --git a/kiln-core/src/tests/actions.rs b/kiln-core/src/tests/actions.rs index 7127db3..1e5aa6d 100644 --- a/kiln-core/src/tests/actions.rs +++ b/kiln-core/src/tests/actions.rs @@ -1,13 +1,14 @@ -use std::time::Duration; +use super::{log_texts, scripted_object, scripts_from}; use crate::archetype::Archetype; use crate::board::tests::{crate_at, open_board, wall_at}; use crate::game::GameState; -use super::{scripted_object, log_texts, scripts_from}; +use std::time::Duration; #[test] fn move_command_relocates_the_source_object() { let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]); - let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); + let mut game = + GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); game.run_init(); let b = game.board(); // East increments x by one; the object started at (2, 1). @@ -18,7 +19,8 @@ fn move_command_relocates_the_source_object() { fn move_into_a_wall_or_edge_is_a_noop() { // Object at the west edge moving west: out of bounds, ignored. let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]); - let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")])); + let mut game = + GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")])); game.run_init(); assert_eq!(game.board().objects[&1].x, 0); } @@ -26,7 +28,8 @@ fn move_into_a_wall_or_edge_is_a_noop() { #[test] fn set_tile_command_changes_the_source_glyph() { let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]); - let mut game = GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")])); + let mut game = + GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")])); game.run_init(); assert_eq!(game.board().objects[&1].glyph.tile, 7); } @@ -36,19 +39,21 @@ fn object_pushes_crate_on_init() { // A scripted object moving east into a crate shoves it (step_object path). let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]); crate_at(&mut board, 2, 0); - let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); + let mut game = + GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); game.run_init(); let b = game.board(); assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); - assert_eq!(b.get(2, 0).1, Archetype::Empty); - assert_eq!(b.get(3, 0).1, Archetype::Crate); + assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); + assert_eq!(b.get(0, 3, 0).1, Archetype::Crate); } #[test] fn object_push_into_player() { // A scripted object moving into the player pushes the player when there's room. let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]); - let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); + let mut game = + GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); game.run_init(); { let b = game.board(); @@ -59,7 +64,8 @@ fn object_push_into_player() { // With a wall behind the player, the object is blocked and nothing moves. let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]); wall_at(&mut board, 3, 0); - let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); + let mut game = + GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); game.run_init(); let b = game.board(); assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); @@ -71,7 +77,10 @@ fn move_cost_rate_limits_repeated_moves() { // init queues two moves; only the first resolves immediately, the second must // wait the full 250 ms cooldown. let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]); - let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(East); }")])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[("m", "fn init() { move(East); move(East); }")]), + ); game.run_init(); assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2) @@ -91,19 +100,31 @@ fn inline_delay_paces_subsequent_moves() { // is held back by that delay even if the first move was blocked by a wall. let mut board = open_board(3, 3, (0, 0), vec![scripted_object(1, 1, "m")]); wall_at(&mut board, 2, 1); - let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(South); }")])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[("m", "fn init() { move(East); move(South); }")]), + ); game.run_init(); // First (eastward) move is blocked by the wall: object hasn't moved. - assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1)); + assert_eq!( + (game.board().objects[&1].x, game.board().objects[&1].y), + (1, 1) + ); // The Delay(250 ms) appended by move(East) is still live, so South is pending. game.tick(Duration::from_millis(100)); game.tick(Duration::from_millis(100)); - assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1)); + assert_eq!( + (game.board().objects[&1].x, game.board().objects[&1].y), + (1, 1) + ); // Past 250 ms the queued South move resolves. game.tick(Duration::from_millis(100)); - assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 2)); + assert_eq!( + (game.board().objects[&1].x, game.board().objects[&1].y), + (1, 2) + ); } #[test] @@ -111,10 +132,13 @@ fn queue_length_reports_pending_actions() { // Two zero-cost actions are queued before the length is read, then all three // (incl. the log) drain in one pump. let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "q")]); - let mut game = GameState::with_scripts(board, scripts_from(&[( - "q", - "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", - )])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[( + "q", + "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", + )]), + ); game.run_init(); assert!(log_texts(&game).iter().any(|t| t == "len=2")); assert_eq!(game.board().objects[&1].glyph.tile, 6); // last set_tile won @@ -124,7 +148,10 @@ fn queue_length_reports_pending_actions() { fn queue_clear_drops_pending_actions() { // clear() empties the output queue mid-script, so the queued moves never run. let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "c")]); - let mut game = GameState::with_scripts(board, scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")]), + ); game.run_init(); game.tick(Duration::from_millis(300)); assert_eq!(game.board().objects[&1].x, 1); // never moved @@ -135,19 +162,25 @@ fn blocked_reports_solid_and_clear() { // Solid ahead (a wall): blocked() is true. let mut board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); wall_at(&mut board, 2, 0); - let mut game = GameState::with_scripts(board, scripts_from(&[( - "b", - "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", - )])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[( + "b", + "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", + )]), + ); game.run_init(); assert_eq!(game.board().objects[&1].glyph.tile, 9); // Open ahead, nothing pending: blocked() is false. let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); - let mut game = GameState::with_scripts(board, scripts_from(&[( - "b", - "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", - )])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[( + "b", + "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", + )]), + ); game.run_init(); assert_eq!(game.board().objects[&1].glyph.tile, 7); } @@ -165,10 +198,16 @@ fn blocked_sees_earlier_objects_pending_move() { scripted_object(3, 1, "checker"), ], ); - let mut game = GameState::with_scripts(board, scripts_from(&[ - ("mover", "fn tick(dt) { move(East); }"), - ("checker", "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }"), - ])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ("mover", "fn tick(dt) { move(East); }"), + ( + "checker", + "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }", + ), + ]), + ); game.tick(Duration::from_millis(16)); let b = game.board(); assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1)); diff --git a/kiln-core/src/tests/collision.rs b/kiln-core/src/tests/collision.rs index d3e15d0..d174746 100644 --- a/kiln-core/src/tests/collision.rs +++ b/kiln-core/src/tests/collision.rs @@ -1,7 +1,7 @@ -use std::time::Duration; +use super::{log_texts, scripted_object, scripts_from}; use crate::board::tests::open_board; use crate::game::GameState; -use super::{scripted_object, log_texts, scripts_from}; +use std::time::Duration; #[test] fn collision_priority_resolves_in_array_order_and_bumps() { @@ -13,10 +13,19 @@ fn collision_priority_resolves_in_array_order_and_bumps() { (0, 1), // player off the contested row vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")], ); - let mut game = GameState::with_scripts(board, scripts_from(&[ - ("e", "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }"), - ("w", "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }"), - ])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ( + "e", + "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }", + ), + ( + "w", + "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }", + ), + ]), + ); game.run_init(); // The bump fires during resolution but its log is emitted into obj0's queue; // a later tick (past both objects' move cooldown) flushes it to the game log. diff --git a/kiln-core/src/tests/game_portals.rs b/kiln-core/src/tests/game_portals.rs index 9113e1a..201706b 100644 --- a/kiln-core/src/tests/game_portals.rs +++ b/kiln-core/src/tests/game_portals.rs @@ -1,11 +1,13 @@ -use std::collections::{BTreeMap, HashMap}; -use std::rc::Rc; -use std::cell::RefCell; use crate::archetype::Archetype; use crate::board::Board; use crate::game::GameState; +use crate::glyph::Glyph; +use crate::layer::Layer; use crate::utils::{Direction, Player, PortalDef}; use crate::world::World; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; +use std::rc::Rc; /// Builds a 3×3 board with the player at `(px, py)` and the given portals. fn make_board(px: i32, py: i32, portals: Vec) -> Board { @@ -13,9 +15,9 @@ fn make_board(px: i32, py: i32, portals: Vec) -> Board { name: "test".into(), width: 3, height: 3, - cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); 9], - floor: vec![Archetype::Empty.default_glyph(); 9], - floor_spec: None, + layers: vec![Layer { + cells: vec![(Glyph::transparent(), Archetype::Empty); 9], + }], player: Player { x: px, y: py }, objects: BTreeMap::new(), next_object_id: 1, @@ -31,12 +33,30 @@ fn make_board(px: i32, py: i32, portals: Vec) -> Board { /// - `"b1"`: player at `(0, 0)`, portal `"to_b2"` at `(2, 0)` → `"b2"` / `"from_b1"` /// - `"b2"`: player at `(0, 0)`, portal `"from_b1"` at `(1, 1)` → `"b1"` / `"to_b2"` fn two_board_world() -> World { - let b1 = make_board(0, 0, vec![ - PortalDef { name: "to_b2".into(), x: 2, y: 0, target_map: "b2".into(), target_entry: "from_b1".into() }, - ]); - let b2 = make_board(0, 0, vec![ - PortalDef { name: "from_b1".into(), x: 1, y: 1, target_map: "b1".into(), target_entry: "to_b2".into() }, - ]); + let b1 = make_board( + 0, + 0, + vec![PortalDef { + name: "to_b2".into(), + x: 2, + y: 0, + z: 0, + target_map: "b2".into(), + target_entry: "from_b1".into(), + }], + ); + let b2 = make_board( + 0, + 0, + vec![PortalDef { + name: "from_b1".into(), + x: 1, + y: 1, + z: 0, + target_map: "b1".into(), + target_entry: "to_b2".into(), + }], + ); World { name: "test".into(), start: "b1".into(), diff --git a/kiln-core/src/tests/map_file/grid_errors.rs b/kiln-core/src/tests/map_file/grid_errors.rs index 0848b64..11a6951 100644 --- a/kiln-core/src/tests/map_file/grid_errors.rs +++ b/kiln-core/src/tests/map_file/grid_errors.rs @@ -1,53 +1,59 @@ +use super::{layer, load_board, map}; use crate::archetype::Archetype; use crate::board::Board; use crate::map_file::MapFile; -use super::minimal_toml; #[test] fn grid_wrong_row_count_returns_error() { - // height = 3 but only 2 rows in the grid - let toml = minimal_toml(3, 3, &["...", "..."]); + // height = 3 but only 2 rows in the layer grid. + let toml = map(3, 3, &[layer("...\n...", &[(".", "kind = \"empty\"")])]); let mf: MapFile = toml::from_str(&toml).unwrap(); let result = Board::try_from(mf); assert!(result.is_err()); let msg = result.err().unwrap(); - assert!(msg.contains("2 rows"), "expected row count in error, got: {msg}"); - assert!(msg.contains("height = 3"), "expected declared height in error, got: {msg}"); + assert!( + msg.contains("2 rows"), + "expected row count in error, got: {msg}" + ); + assert!( + msg.contains("3 tall"), + "expected declared height in error, got: {msg}" + ); } #[test] fn grid_wrong_row_width_returns_error() { - // width = 4 but second row is only 3 characters - let toml = minimal_toml(4, 2, &["....", "..."]); + // width = 4 but second row is only 3 characters. + let toml = map(4, 2, &[layer("....\n...", &[(".", "kind = \"empty\"")])]); let mf: MapFile = toml::from_str(&toml).unwrap(); let result = Board::try_from(mf); assert!(result.is_err()); let msg = result.err().unwrap(); - assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}"); - assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}"); + assert!( + msg.contains("3 characters"), + "expected col count in error, got: {msg}" + ); + assert!( + msg.contains("4 wide"), + "expected declared width in error, got: {msg}" + ); } #[test] -fn object_archetype_in_palette_produces_error_block() { - // "object" is no longer a valid palette archetype; it should produce ErrorBlock. - // Player is placed away from the O cell so it isn't cleared. - let toml = r##" -[map] -name = "Test" -width = 2 -height = 1 -player_start = [1, 0] - -[palette] -"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } -"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } - -[grid] -content = """ -O. -""" -"##; - let mf: MapFile = toml::from_str(toml).unwrap(); - let board = Board::try_from(mf).unwrap(); - assert_eq!(*board.get(0, 0), (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)); +fn unknown_kind_produces_error_block() { + // A palette `kind` that is neither a meta-kind nor a known archetype name + // becomes a visible ErrorBlock. Player placed away from the X cell. + let toml = map( + 2, + 1, + &[layer( + "X@", + &[("X", "kind = \"frobnicate\""), ("@", "kind = \"player\"")], + )], + ); + let board = load_board(&toml); + assert_eq!( + *board.get(0, 0, 0), + (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock) + ); } diff --git a/kiln-core/src/tests/map_file/mod.rs b/kiln-core/src/tests/map_file/mod.rs index 0f36f4f..cd5064c 100644 --- a/kiln-core/src/tests/map_file/mod.rs +++ b/kiln-core/src/tests/map_file/mod.rs @@ -12,79 +12,53 @@ fn load_board(toml: &str) -> Board { Board::try_from(mf).expect("convert to board") } -/// A 3×1 map (`empty` palette `.`) whose grid is `grid`, plus the given -/// `[[objects]]` TOML appended. Keeps placement tests compact. -fn map_3x1(grid: &str, objects: &str) -> String { - format!( - r##" -[map] -name = "Test" -width = 3 -height = 1 -player_start = [2, 0] - -[palette] -"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} -"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }} - -[grid] -content = """ -{grid} -""" -{objects} -"## - ) +/// Builds one `[[layers]]` block: a triple-quoted `content` grid plus an inline +/// `palette` table. Each palette entry is `(char_key, inline-body)` where the +/// body is the inside of the entry's `{ ... }` (e.g. `kind = "wall"`). +fn layer(content: &str, palette: &[(&str, &str)]) -> String { + let pal = palette + .iter() + .map(|(k, body)| format!("\"{k}\" = {{ {body} }}")) + .collect::>() + .join(", "); + format!("\n[[layers]]\ncontent = \"\"\"\n{content}\n\"\"\"\npalette = {{ {pal} }}\n") } -/// A standard `[[objects]]` block placed by palette char `ch`. -fn obj_palette(ch: &str, extra: &str) -> String { - format!( - "[[objects]]\npalette = \"{ch}\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n{extra}" - ) +/// Wraps a `[map]` header of the given size around the supplied `[[layers]]` +/// blocks (each produced by [`layer`]). +fn map(width: usize, height: usize, layers: &[String]) -> String { + let mut s = format!("[map]\nname = \"Test\"\nwidth = {width}\nheight = {height}\n"); + for l in layers { + s.push_str(l); + } + s } -/// A 1-row map of the given `width` with a raw `player_start` TOML value -/// (`start`, e.g. `[1, 0]` or `"@"`), grid, and objects block. -fn player_map(width: usize, start: &str, grid: &str, objects: &str) -> String { - format!( - r##" -[map] -name = "Test" -width = {width} -height = 1 -player_start = {start} - -[palette] -"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} -"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }} - -[grid] -content = """ -{grid} -""" -{objects} -"## - ) -} - -/// Minimal valid TOML with a configurable grid, used as a base for dimension tests. -fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String { - let content = grid_rows.join("\n"); - format!( - r##" -[map] -name = "Test" -width = {width} -height = {height} -player_start = [0, 0] - -[palette] -"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} - -[grid] -content = """ -{content} -""" -"## +/// A 3×1 single-layer map: an `empty`/`wall` palette plus one `object` entry +/// placed by char `ch` (cyan `@` glyph), with `extra` appended to its body. The +/// player is placed at the far-right cell via a second char where room allows; +/// callers that need the player elsewhere build the map directly. +fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String { + let body = if extra.is_empty() { + "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"".to_string() + } else { + format!("kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\", {extra}") + }; + map( + 3, + 1, + &[layer( + grid, + &[ + (" ", "kind = \"empty\""), + (".", "kind = \"empty\""), + ( + "#", + "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"", + ), + ("@", "kind = \"player\""), + (ch, &body), + ], + )], ) } diff --git a/kiln-core/src/tests/map_file/object_placement.rs b/kiln-core/src/tests/map_file/object_placement.rs index eb346f7..3fa6824 100644 --- a/kiln-core/src/tests/map_file/object_placement.rs +++ b/kiln-core/src/tests/map_file/object_placement.rs @@ -1,18 +1,40 @@ +use super::{layer, load_board, map, map_3x1_object}; use crate::archetype::Archetype; -use super::{load_board, map_3x1, obj_palette}; + +/// Palette shorthands. +const EMPTY: (&str, &str) = (".", "kind = \"empty\""); +const PLAYER: (&str, &str) = ("@", "kind = \"player\""); +const WALL: (&str, &str) = ( + "#", + "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"", +); + +/// An object palette entry body with the given `extra` fields appended. +fn obj(extra: &str) -> String { + let base = "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\""; + if extra.is_empty() { + base.to_string() + } else { + format!("{base}, {extra}") + } +} #[test] fn duplicate_name_clears_second_but_keeps_both_objects() { - // Two objects share the name "gate": first keeps it, second is cleared. - // Both objects still appear on the board; the map is flagged invalid. - let objs = format!( - "{}\n{}", - "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\nname = \"gate\"\n", - "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\nname = \"gate\"\nsolid = false\n", - ); - let board = load_board(&map_3x1("...", &objs)); + // Two object entries share the name "gate": first keeps it, second is cleared. + let board = load_board(&map( + 3, + 1, + &[layer( + "GH@", + &[ + ("G", &obj("name = \"gate\"")), + ("H", &obj("name = \"gate\", solid = false")), + PLAYER, + ], + )], + )); assert_eq!(board.objects.len(), 2, "both objects survive"); - // First object (id 1) keeps the name; second (id 2) is cleared. assert_eq!(board.objects[&1].name.as_deref(), Some("gate")); assert_eq!(board.objects[&2].name, None); assert!(!board.is_valid(), "duplicate name is a nonfatal load error"); @@ -20,80 +42,76 @@ fn duplicate_name_clears_second_but_keeps_both_objects() { #[test] fn palette_placement_puts_object_on_empty_floor() { - // `G` appears once, isn't a palette key → object lands there, cell is Empty. - let board = load_board(&map_3x1("G..", &obj_palette("G", ""))); + // `G` lands an object; its cell stays Empty (transparent). + let board = load_board(&map_3x1_object("G.@", "G", "")); assert_eq!(board.objects.len(), 1); assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); - assert_eq!(board.get(0, 0).1, Archetype::Empty); -} - -#[test] -fn palette_char_absent_from_grid_drops_object() { - let board = load_board(&map_3x1("...", &obj_palette("G", ""))); - assert!(board.objects.is_empty()); - assert!(!board.is_valid()); + assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); } #[test] fn palette_char_appearing_twice_spawns_two_objects() { - // `G` appears at (0,0) and (1,0); player is at (2,0) so neither G conflicts. - // Two independent ObjectDefs are spawned. - let board = load_board(&map_3x1("GG.", &obj_palette("G", ""))); + // `G` appears at (0,0) and (1,0); two independent ObjectDefs are spawned. + let board = load_board(&map_3x1_object("GG@", "G", "solid = false")); assert_eq!(board.objects.len(), 2); - // IDs are assigned in reading order (left-to-right). assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); assert_eq!((board.objects[&2].x, board.objects[&2].y), (1, 0)); - // Both cells load as Empty in the grid. - assert_eq!(board.get(0, 0).1, Archetype::Empty); - assert_eq!(board.get(1, 0).1, Archetype::Empty); assert!(board.is_valid(), "multiple occurrences are not an error"); } #[test] fn palette_char_multi_occurrence_only_first_keeps_name() { - // Two occurrences of a named entry (solid = false to avoid player conflict at (2,0)). - // First instance gets the name; second is anonymous. - let entry = obj_palette("G", "solid = false\nname = \"guard\"\n"); - let board = load_board(&map_3x1("GGG", &entry)); + // Three occurrences of a named entry: the first keeps the name, the rest don't. + let board = load_board(&map( + 4, + 1, + &[layer( + "GGG@", + &[("G", &obj("solid = false, name = \"guard\"")), PLAYER], + )], + )); assert_eq!(board.objects.len(), 3); assert_eq!(board.objects[&1].name.as_deref(), Some("guard")); assert_eq!(board.objects[&2].name, None); assert_eq!(board.objects[&3].name, None); } -#[test] -fn palette_char_reused_by_two_objects_keeps_first() { - // Two objects claim `G`: the first keeps it, the later one is dropped. - let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", "")); - let board = load_board(&map_3x1("G..", &two)); - assert_eq!(board.objects.len(), 1); - assert!(!board.is_valid()); -} - -#[test] -fn palette_char_colliding_with_palette_key_drops_object() { - // `#` is a terrain palette key, so it can't be an object placeholder. - let board = load_board(&map_3x1("#..", &obj_palette("#", ""))); - assert!(board.objects.is_empty()); -} - #[test] fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() { - // Solid object at the wall cell (0,0): dropped for conflicting with the wall. - let solid = "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; - let board = load_board(&map_3x1("#..", solid)); - assert!(board.objects.is_empty()); + // Solid object stacked on a wall (different layer): dropped for the conflict. + let solid = map( + 3, + 1, + &[ + layer("#.@", &[WALL, EMPTY, PLAYER]), + layer("O..", &[("O", &obj("")), EMPTY]), + ], + ); + assert!(load_board(&solid).objects.is_empty()); // Same placement but non-solid: kept (it doesn't claim the cell's solidity). - let nonsolid = format!("{solid}solid = false\n"); - let board = load_board(&map_3x1("#..", &nonsolid)); - assert_eq!(board.objects.len(), 1); + let nonsolid = map( + 3, + 1, + &[ + layer("#.@", &[WALL, EMPTY, PLAYER]), + layer("O..", &[("O", &obj("solid = false")), EMPTY]), + ], + ); + assert_eq!(load_board(&nonsolid).objects.len(), 1); } #[test] fn second_solid_object_on_a_cell_is_dropped() { - let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; - let board = load_board(&map_3x1("...", &format!("{obj}{obj}"))); + // Two solid objects on the same cell across layers: the upper one is dropped. + let board = load_board(&map( + 3, + 1, + &[ + layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY]), + layer(".O.", &[EMPTY, ("O", &obj(""))]), + ], + )); assert_eq!(board.objects.len(), 1); assert!(!board.is_valid()); } diff --git a/kiln-core/src/tests/map_file/player_placement.rs b/kiln-core/src/tests/map_file/player_placement.rs index 4ed5874..4223b98 100644 --- a/kiln-core/src/tests/map_file/player_placement.rs +++ b/kiln-core/src/tests/map_file/player_placement.rs @@ -1,70 +1,81 @@ +use super::{layer, load_board, map}; use crate::archetype::Archetype; -use super::{load_board, player_map}; -#[test] -fn player_coords_place_the_player() { - let b = load_board(&player_map(3, "[1, 0]", "...", "")); - assert_eq!((b.player.x, b.player.y), (1, 0)); - assert!(b.is_valid()); -} +/// Palette shorthands shared by these tests. +const EMPTY: (&str, &str) = (".", "kind = \"empty\""); +const PLAYER: (&str, &str) = ("@", "kind = \"player\""); +const WALL: (&str, &str) = ( + "#", + "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"", +); #[test] fn player_char_places_on_empty_floor() { - let b = load_board(&player_map(3, "\"@\"", ".@.", "")); + let b = load_board(&map(3, 1, &[layer(".@.", &[EMPTY, PLAYER])])); assert_eq!((b.player.x, b.player.y), (1, 0)); - assert_eq!(b.get(1, 0).1, Archetype::Empty); + assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); assert!(b.is_valid()); } #[test] fn player_wins_solid_terrain_silently() { - // Player coords land on a wall: the wall is cleared, no error reported. - let b = load_board(&player_map(3, "[0, 0]", "#..", "")); + // Wall on layer 0, player on layer 1 at the same cell: the wall is cleared, + // no error reported. + let b = load_board(&map( + 3, + 1, + &[layer("#..", &[WALL, EMPTY]), layer("@..", &[PLAYER, EMPTY])], + )); assert_eq!((b.player.x, b.player.y), (0, 0)); - assert_eq!(b.get(0, 0).1, Archetype::Empty); + assert_eq!(b.get(0, 0, 0).1, Archetype::Empty); // wall cleared on layer 0 assert!(b.is_valid()); } #[test] fn player_wins_against_a_solid_object_silently() { - // A solid object on the player's cell is dropped, silently (player wins). - let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; - let b = load_board(&player_map(3, "[1, 0]", "...", obj)); + // A solid object on the player's cell (different layer) is dropped silently. + let b = load_board(&map( + 3, + 1, + &[ + layer( + ".O.", + &[ + EMPTY, + ( + "O", + "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"", + ), + ], + ), + layer(".@.", &[EMPTY, PLAYER]), + ], + )); + assert_eq!((b.player.x, b.player.y), (1, 0)); assert!(b.objects.is_empty()); assert!(b.is_valid()); } -#[test] -fn player_char_is_reserved_from_objects() { - // An object trying to use '@' is dropped; the player is still placed. - let obj = "[[objects]]\npalette = \"@\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; - let b = load_board(&player_map(3, "\"@\"", "@..", obj)); - assert_eq!((b.player.x, b.player.y), (0, 0)); - assert!(b.objects.is_empty()); - assert!(!b.is_valid()); -} - #[test] fn player_char_appearing_twice_uses_first() { - let b = load_board(&player_map(3, "\"@\"", "@.@", "")); + let b = load_board(&map(3, 1, &[layer("@.@", &[EMPTY, PLAYER])])); assert_eq!((b.player.x, b.player.y), (0, 0)); assert!(!b.is_valid()); } #[test] fn player_char_missing_falls_back_to_origin() { - let b = load_board(&player_map(3, "\"@\"", "...", "")); + let b = load_board(&map(3, 1, &[layer("...", &[EMPTY])])); assert_eq!((b.player.x, b.player.y), (0, 0)); assert!(!b.is_valid()); } #[test] fn player_fallback_to_origin_clears_solid_terrain() { - // The '@' char is absent, so the player falls back to (0, 0) — which holds a - // wall. The player wins its cell: the wall is cleared so one-solid-per-cell - // holds. The fallback is still reported. - let b = load_board(&player_map(3, "\"@\"", "#..", "")); + // No player cell, so the player falls back to (0, 0) — which holds a wall. The + // player wins its cell: the wall is cleared. The fallback is still reported. + let b = load_board(&map(3, 1, &[layer("#..", &[WALL, EMPTY])])); assert_eq!((b.player.x, b.player.y), (0, 0)); - assert_eq!(b.get(0, 0).1, Archetype::Empty); + assert_eq!(b.get(0, 0, 0).1, Archetype::Empty); assert!(!b.is_valid()); } diff --git a/kiln-core/src/tests/map_file/portal_placement.rs b/kiln-core/src/tests/map_file/portal_placement.rs index b13b32c..de56790 100644 --- a/kiln-core/src/tests/map_file/portal_placement.rs +++ b/kiln-core/src/tests/map_file/portal_placement.rs @@ -1,47 +1,56 @@ -use super::{load_board, map_3x1}; +use super::{layer, load_board, map}; -fn portal_toml(portals: &str) -> String { - format!( - "{}{}", - map_3x1("...", ""), - portals - ) -} +const EMPTY: (&str, &str) = (".", "kind = \"empty\""); +const PLAYER: (&str, &str) = ("@", "kind = \"player\""); #[test] fn portal_duplicate_name_drops_second() { - let toml = portal_toml( - "[[portals]]\nname = \"a\"\nx = 0\ny = 0\ntarget_map = \"x\"\ntarget_entry = \"b\"\n\ - [[portals]]\nname = \"a\"\nx = 1\ny = 0\ntarget_map = \"x\"\ntarget_entry = \"c\"\n", + // Two portal cells share the name "a": the second is dropped. + let board = load_board(&map( + 3, + 1, + &[layer( + "12@", + &[ + ( + "1", + "kind = \"portal\", name = \"a\", target_map = \"x\", target_entry = \"b\"", + ), + ( + "2", + "kind = \"portal\", name = \"a\", target_map = \"x\", target_entry = \"c\"", + ), + PLAYER, + ], + )], + )); + assert_eq!( + board.portals.len(), + 1, + "second portal with duplicate name should be dropped" ); - let board = load_board(&toml); - assert_eq!(board.portals.len(), 1, "second portal with duplicate name should be dropped"); assert_eq!(board.portals[0].x, 0); assert!(!board.is_valid(), "duplicate name should be a load error"); } #[test] fn portal_palette_char_places_portal_at_grid_position() { - let toml = format!( - "{}{}", - map_3x1("1..", ""), - "[[portals]]\nname = \"p\"\npalette = \"1\"\ntarget_map = \"x\"\ntarget_entry = \"e\"\n" - ); - let board = load_board(&toml); + let board = load_board(&map( + 3, + 1, + &[layer( + "1.@", + &[ + ( + "1", + "kind = \"portal\", name = \"p\", target_map = \"x\", target_entry = \"e\"", + ), + EMPTY, + PLAYER, + ], + )], + )); assert_eq!(board.portals.len(), 1); - assert_eq!(board.portals[0].x, 0); - assert_eq!(board.portals[0].y, 0); + assert_eq!((board.portals[0].x, board.portals[0].y), (0, 0)); assert_eq!(board.portals[0].name, "p"); } - -#[test] -fn portal_palette_char_colliding_with_object_is_dropped() { - let toml = format!( - "{}{}{}", - map_3x1("1..", ""), - "[[objects]]\npalette = \"1\"\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\n", - "[[portals]]\nname = \"p\"\npalette = \"1\"\ntarget_map = \"x\"\ntarget_entry = \"e\"\n" - ); - let board = load_board(&toml); - assert_eq!(board.portals.len(), 0, "portal whose palette char is already an object char should be dropped"); -} diff --git a/kiln-core/src/tests/map_file/round_trip.rs b/kiln-core/src/tests/map_file/round_trip.rs index 386985b..18d107d 100644 --- a/kiln-core/src/tests/map_file/round_trip.rs +++ b/kiln-core/src/tests/map_file/round_trip.rs @@ -1,203 +1,147 @@ -use color::Rgba8; -use crate::board::Board; +use super::{layer, load_board, map}; use crate::glyph::Glyph; use crate::map_file::{MapFile, parse_color}; -use super::{load_board, map_3x1, obj_palette}; +use color::Rgba8; + +const EMPTY: (&str, &str) = (".", "kind = \"empty\""); +const PLAYER: (&str, &str) = ("@", "kind = \"player\""); + +/// An object palette entry body with the given `extra` fields appended. +fn obj(extra: &str) -> String { + let base = "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\""; + if extra.is_empty() { + base.to_string() + } else { + format!("{base}, {extra}") + } +} + +/// Round-trips a board through save→load and returns the reloaded board. +fn round_trip(toml: &str) -> crate::board::Board { + let board = load_board(toml); + let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); + load_board(&toml_out) +} #[test] fn object_glyph_round_trips_through_toml() { - // Objects must survive a save→load cycle with their glyph intact. - let toml = r##" -[map] -name = "Test" -width = 3 -height = 3 -player_start = [0, 0] - -[palette] -"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } - -[grid] -content = """ -... -... -... -""" - -[[objects]] -x = 1 -y = 1 -tile = 64 -fg = "#00FFFF" -bg = "#000000" -"##; - let mf: MapFile = toml::from_str(toml).unwrap(); - let board = Board::try_from(mf).unwrap(); + let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]); + let board = load_board(&toml); assert_eq!(board.objects.len(), 1); - let obj = &board.objects[&1]; - assert_eq!(obj.x, 1); - assert_eq!(obj.y, 1); - assert_eq!(obj.glyph.tile, 64); - assert_eq!(obj.glyph.fg, Rgba8 { r: 0x00, g: 0xFF, b: 0xFF, a: 255 }); - assert_eq!(obj.glyph.bg, Rgba8 { r: 0, g: 0, b: 0, a: 255 }); + let obj0 = &board.objects[&1]; + assert_eq!((obj0.x, obj0.y), (1, 0)); + assert_eq!(obj0.glyph.tile, 64); + assert_eq!( + obj0.glyph.fg, + Rgba8 { + r: 0x00, + g: 0xFF, + b: 0xFF, + a: 255 + } + ); - // Save back to TOML and reload; glyph must survive. - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - let mf2: MapFile = toml::from_str(&toml_out).unwrap(); - let board2 = Board::try_from(mf2).unwrap(); + let board2 = round_trip(&toml); let obj2 = &board2.objects[&1]; - assert_eq!(obj2.glyph.tile, obj.glyph.tile); - assert_eq!(obj2.glyph.fg, obj.glyph.fg); - assert_eq!(obj2.glyph.bg, obj.glyph.bg); + assert_eq!(obj2.glyph.tile, obj0.glyph.tile); + assert_eq!(obj2.glyph.fg, obj0.glyph.fg); + assert_eq!(obj2.glyph.bg, obj0.glyph.bg); } #[test] fn object_tags_round_trip_through_toml() { - // Tags declared in the map file must survive a save→load cycle, sorted. - let toml = r##" -[map] -name = "Test" -width = 3 -height = 3 -player_start = [0, 0] + let toml = map( + 3, + 1, + &[layer( + "@O.", + &[PLAYER, ("O", &obj("tags = [\"enemy\", \"boss\"]")), EMPTY], + )], + ); + let board = load_board(&toml); + let obj0 = &board.objects[&1]; + assert!(obj0.tags.contains("enemy") && obj0.tags.contains("boss")); + assert_eq!(obj0.tags.len(), 2); -[palette] -"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } - -[grid] -content = """ -... -... -... -""" - -[[objects]] -x = 1 -y = 1 -tile = 64 -fg = "#ffffff" -bg = "#000000" -tags = ["enemy", "boss"] -"##; - let mf: MapFile = toml::from_str(toml).unwrap(); - let board = Board::try_from(mf).unwrap(); - let obj = &board.objects[&1]; - assert!(obj.tags.contains("enemy")); - assert!(obj.tags.contains("boss")); - assert_eq!(obj.tags.len(), 2); - - // Save and reload; tags must survive, and be sorted alphabetically. + // Saved tags must be sorted alphabetically (boss before enemy). let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - // Both tags must appear; "boss" must come before "enemy" (sorted). - let boss_pos = toml_out.find("\"boss\"").expect("boss in TOML"); - let enemy_pos = toml_out.find("\"enemy\"").expect("enemy in TOML"); - assert!(boss_pos < enemy_pos, "tags must be sorted: boss before enemy"); - let mf2: MapFile = toml::from_str(&toml_out).unwrap(); - let board2 = Board::try_from(mf2).unwrap(); - let obj2 = &board2.objects[&1]; - assert_eq!(obj2.tags, obj.tags); + let boss = toml_out.find("\"boss\"").expect("boss in TOML"); + let enemy = toml_out.find("\"enemy\"").expect("enemy in TOML"); + assert!(boss < enemy, "tags must be sorted: boss before enemy"); + + let board2 = load_board(&toml_out); + assert_eq!(board2.objects[&1].tags, obj0.tags); } #[test] fn object_empty_tags_omitted_from_toml() { - // An object with no tags must not emit a `tags` key in TOML. - let toml = r##" -[map] -name = "Test" -width = 1 -height = 1 -player_start = [0, 0] - -[palette] -"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } - -[grid] -content = """ -. -""" - -[[objects]] -x = 0 -y = 0 -tile = 64 -fg = "#ffffff" -bg = "#000000" -"##; - let board = load_board(toml); + let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]); + let board = load_board(&toml); let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - assert!(!toml_out.contains("tags"), "empty tags must not appear in TOML output"); + assert!( + !toml_out.contains("tags"), + "empty tags must not appear in TOML output" + ); } #[test] fn object_name_round_trips_through_toml() { - // A named object must survive a save→load cycle with its name intact. - let board = load_board(&map_3x1("G..", &obj_palette("G", "name = \"beacon\"\n"))); + let toml = map( + 3, + 1, + &[layer( + "@O.", + &[PLAYER, ("O", &obj("name = \"beacon\"")), EMPTY], + )], + ); + let board = load_board(&toml); assert_eq!(board.objects[&1].name.as_deref(), Some("beacon")); let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - assert!(toml_out.contains("\"beacon\""), "name must appear in saved TOML"); + assert!( + toml_out.contains("\"beacon\""), + "name must appear in saved TOML" + ); let board2 = load_board(&toml_out); assert_eq!(board2.objects[&1].name.as_deref(), Some("beacon")); } #[test] -fn unnamed_object_omits_name_from_toml() { - // An unnamed object must not emit a `name` key in TOML; check via the parsed - // value so the map header's `name = "Test"` doesn't produce a false positive. - let board = load_board(&map_3x1("G..", &obj_palette("G", ""))); - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - let val: toml::Value = toml_out.parse().unwrap(); - let objects = val["objects"].as_array().unwrap(); - assert!( - !objects[0].as_table().unwrap().contains_key("name"), - "unnamed object must not emit name key" +fn unnamed_object_name_stays_none_through_toml() { + let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]); + let board2 = round_trip(&toml); + assert_eq!( + board2.objects[&1].name, None, + "unnamed object must round-trip as None" ); } #[test] -fn floor_spec_round_trips_through_toml() { - // A 2×2 board with a grid floor (a generator and a fixed glyph) must survive - // save→load with its `[floor]` declaration intact, and the reloaded board's - // computed floor must place the fixed glyph back at its declared cell. - let toml = r##" -[map] -name = "Test" -width = 2 -height = 2 -player_start = [0, 0] - -[palette] -"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } - -[grid] -content = """ -.. -.. -""" - -[floor] -content = """ -g. -.g -""" - -[floor.palette] -"g" = "grass" -"." = { tile = "#", fg = "#010203", bg = "#040506" } -"##; - let board = load_board(toml); - assert!(board.floor_spec.is_some()); +fn fixed_floor_glyph_round_trips_through_toml() { + // A fixed floor glyph (visual-only Empty cell) must survive save→load. + let toml = map( + 3, + 1, + &[layer( + "@F.", + &[ + PLAYER, + ( + "F", + "kind = \"floor\", tile = \"#\", fg = \"#010203\", bg = \"#040506\"", + ), + EMPTY, + ], + )], + ); let fixed = Glyph { tile: '#' as u32, fg: parse_color("#010203"), bg: parse_color("#040506"), }; - // (1,0) is the fixed `.` floor glyph (the cell archetype is Empty). + let board = load_board(&toml); assert_eq!(board.glyph_at(1, 0), fixed); - // Save back to TOML and reload; the floor declaration must be preserved. - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - let board2 = load_board(&toml_out); - assert!(board2.floor_spec.is_some()); + let board2 = round_trip(&toml); assert_eq!(board2.glyph_at(1, 0), fixed); } diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index 34b49d6..ffdae08 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -5,18 +5,23 @@ mod map_file; mod movement; mod scripting; -use std::collections::{BTreeMap, HashMap}; use crate::archetype::Archetype; use crate::board::Board; +use crate::game::GameState; +use crate::glyph::Glyph; +use crate::layer::Layer; use crate::object_def::ObjectDef; use crate::utils::Player; -use crate::game::GameState; +use std::collections::{BTreeMap, HashMap}; /// Builds a 1×1 board with a single object that optionally references a script, /// and returns both the board and the `(name, source)` entries as a script map. /// /// Pass the returned scripts to [`GameState::with_scripts`] so they are compiled. -fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> (Board, HashMap) { +fn board_with_object( + object_script: Option<&str>, + scripts: &[(&str, &str)], +) -> (Board, HashMap) { let mut object = ObjectDef::new(0, 0); object.id = 1; object.script_name = object_script.map(str::to_string); @@ -24,9 +29,9 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> ( name: "test".into(), width: 1, height: 1, - cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)], - floor: vec![Archetype::Empty.default_glyph()], - floor_spec: None, + layers: vec![Layer { + cells: vec![(Glyph::transparent(), Archetype::Empty)], + }], player: Player { x: 0, y: 0 }, objects: BTreeMap::from([(1, object)]), next_object_id: 2, @@ -41,7 +46,10 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> ( /// Converts a `(name, source)` slice into a `HashMap` suitable for /// [`GameState::with_scripts`]. fn scripts_from(pairs: &[(&str, &str)]) -> HashMap { - pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() } /// Returns an `ObjectDef` at `(x, y)` bound to the named script. diff --git a/kiln-core/src/tests/movement.rs b/kiln-core/src/tests/movement.rs index 2740b0e..5320497 100644 --- a/kiln-core/src/tests/movement.rs +++ b/kiln-core/src/tests/movement.rs @@ -1,29 +1,39 @@ -use color::Rgba8; use crate::archetype::Archetype; -use crate::board::tests::{crate_at, open_board, stamp, wall_at}; +use crate::board::tests::{add_floor, crate_at, open_board, stamp, wall_at}; use crate::game::GameState; use crate::glyph::Glyph; use crate::object_def::ObjectDef; use crate::utils::Direction; +use color::Rgba8; #[test] fn pushing_a_crate_reveals_the_floor_underneath() { - // The cell a crate is pushed off of becomes Empty and glyph_at shows floor. - // After the push the player is at (1,0); check the cell the player vacated - // (0,0) — it is Empty and must reveal the floor glyph, not black-on-black. + // The cell a crate is pushed off of becomes transparent and glyph_at shows the + // floor on the layer beneath. After the push the player is at (1,0); check the + // cell the player vacated (0,0) — it must reveal the floor glyph, not black. let mut board = open_board(4, 1, (0, 0), vec![]); let floor_glyph = Glyph { tile: ',' as u32, - fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 }, - bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 }, + fg: Rgba8 { + r: 40, + g: 60, + b: 40, + a: 255, + }, + bg: Rgba8 { + r: 5, + g: 10, + b: 5, + a: 255, + }, }; - board.floor = vec![floor_glyph; 4]; + add_floor(&mut board, floor_glyph); // floor below; terrain becomes layer 1 crate_at(&mut board, 1, 0); let mut game = GameState::new(board); game.try_move(Direction::East); let b = game.board(); - assert_eq!(b.get(2, 0).1, Archetype::Crate); - assert_eq!(b.get(1, 0).1, Archetype::Empty); + assert_eq!(b.get(1, 2, 0).1, Archetype::Crate); + assert_eq!(b.get(1, 1, 0).1, Archetype::Empty); assert_eq!(b.glyph_at(0, 0), floor_glyph); } @@ -35,8 +45,8 @@ fn player_pushes_single_crate() { game.try_move(Direction::East); let b = game.board(); assert_eq!((b.player.x, b.player.y), (1, 0)); - assert_eq!(b.get(1, 0).1, Archetype::Empty); - assert_eq!(b.get(2, 0).1, Archetype::Crate); + assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); + assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); } #[test] @@ -48,8 +58,8 @@ fn push_blocked_by_wall_moves_nothing() { game.try_move(Direction::East); let b = game.board(); assert_eq!((b.player.x, b.player.y), (0, 0)); - assert_eq!(b.get(1, 0).1, Archetype::Crate); - assert_eq!(b.get(2, 0).1, Archetype::Wall); + assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); + assert_eq!(b.get(0, 2, 0).1, Archetype::Wall); } #[test] @@ -61,7 +71,7 @@ fn push_blocked_by_edge_moves_nothing() { game.try_move(Direction::East); let b = game.board(); assert_eq!((b.player.x, b.player.y), (0, 0)); - assert_eq!(b.get(1, 0).1, Archetype::Crate); + assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); } #[test] @@ -73,9 +83,9 @@ fn cascade_pushes_two_crates() { game.try_move(Direction::East); let b = game.board(); assert_eq!((b.player.x, b.player.y), (1, 0)); - assert_eq!(b.get(1, 0).1, Archetype::Empty); - assert_eq!(b.get(2, 0).1, Archetype::Crate); - assert_eq!(b.get(3, 0).1, Archetype::Crate); + assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); + assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); + assert_eq!(b.get(0, 3, 0).1, Archetype::Crate); } #[test] @@ -88,8 +98,8 @@ fn cascade_blocked_by_wall_moves_nothing() { game.try_move(Direction::East); let b = game.board(); assert_eq!((b.player.x, b.player.y), (0, 0)); - assert_eq!(b.get(1, 0).1, Archetype::Crate); - assert_eq!(b.get(2, 0).1, Archetype::Crate); + assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); + assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); } #[test] @@ -114,8 +124,8 @@ fn hcrate_pushes_east_but_not_north() { game.try_move(Direction::East); let b = game.board(); assert_eq!((b.player.x, b.player.y), (1, 0)); - assert_eq!(b.get(1, 0).1, Archetype::Empty); - assert_eq!(b.get(2, 0).1, Archetype::HCrate); + assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); + assert_eq!(b.get(0, 2, 0).1, Archetype::HCrate); drop(b); // Pushing north into an HCrate: blocked, nothing moves. @@ -125,7 +135,7 @@ fn hcrate_pushes_east_but_not_north() { game.try_move(Direction::North); let b = game.board(); assert_eq!((b.player.x, b.player.y), (0, 3)); - assert_eq!(b.get(0, 2).1, Archetype::HCrate); + assert_eq!(b.get(0, 0, 2).1, Archetype::HCrate); } #[test] @@ -137,8 +147,8 @@ fn vcrate_pushes_north_but_not_east() { game.try_move(Direction::North); let b = game.board(); assert_eq!((b.player.x, b.player.y), (0, 2)); - assert_eq!(b.get(0, 2).1, Archetype::Empty); - assert_eq!(b.get(0, 1).1, Archetype::VCrate); + assert_eq!(b.get(0, 0, 2).1, Archetype::Empty); + assert_eq!(b.get(0, 0, 1).1, Archetype::VCrate); drop(b); // Pushing east into a VCrate: blocked, nothing moves. @@ -148,5 +158,5 @@ fn vcrate_pushes_north_but_not_east() { game.try_move(Direction::East); let b = game.board(); assert_eq!((b.player.x, b.player.y), (0, 0)); - assert_eq!(b.get(1, 0).1, Archetype::VCrate); + assert_eq!(b.get(0, 1, 0).1, Archetype::VCrate); } diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 950d367..0016972 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -1,8 +1,8 @@ -use std::time::Duration; +use super::{board_with_object, log_texts, scripted_object, scripts_from}; use crate::board::tests::open_board; use crate::game::{GameState, ScrollLine}; use crate::utils::Direction; -use super::{board_with_object, scripted_object, log_texts, scripts_from}; +use std::time::Duration; #[test] fn init_runs_only_on_run_init_not_at_construction() { @@ -44,10 +44,7 @@ fn missing_hooks_and_no_script_are_noops() { assert!(game.log.is_empty()); // Script defines neither init nor tick: also a no-op. - let (board, scripts) = board_with_object( - Some("e"), - &[("e", "fn other() { log(\"x\"); }")], - ); + let (board, scripts) = board_with_object(Some("e"), &[("e", "fn other() { log(\"x\"); }")]); let mut game = GameState::with_scripts(board, scripts); game.run_init(); game.tick(Duration::from_millis(33)); @@ -69,13 +66,11 @@ fn compile_and_unknown_script_errors_are_logged() { #[test] fn script_reads_board_through_view() { - let board = open_board( - 5, - 3, - (3, 1), - vec![scripted_object(2, 1, "r")], + let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]); + let mut game = GameState::with_scripts( + board, + scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")]), ); - let mut game = GameState::with_scripts(board, scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")])); game.run_init(); assert_eq!(log_texts(&game), vec!["3"]); } @@ -90,10 +85,13 @@ fn commands_are_routed_to_their_own_source_object() { (0, 0), vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")], ); - let mut game = GameState::with_scripts(board, scripts_from(&[ - ("e", "fn init() { move(East); }"), - ("w", "fn init() { move(West); }"), - ])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ("e", "fn init() { move(East); }"), + ("w", "fn init() { move(West); }"), + ]), + ); game.run_init(); let b = game.board(); assert_eq!(b.objects[&1].x, 1); // moved east from 0 @@ -112,7 +110,9 @@ fn start_map_greeter_runs_init() { let mut game = GameState::from_world(world); game.run_init(); assert!( - log_texts(&game).iter().any(|t| t.contains("hello from object")), + log_texts(&game) + .iter() + .any(|t| t.contains("hello from object")), "greeter init should log a greeting" ); assert!( @@ -139,7 +139,12 @@ fn set_tag_adds_and_removes_via_my_id() { &[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)], ); // Seed the tag before construction. - board2.objects.get_mut(&1).unwrap().tags.insert("active".to_string()); + board2 + .objects + .get_mut(&1) + .unwrap() + .tags + .insert("active".to_string()); let mut game2 = GameState::with_scripts(board2, scripts2); game2.run_init(); assert!(!game2.board().objects[&1].tags.contains("active")); @@ -172,37 +177,39 @@ fn objects_with_tag_returns_matching_ids() { // obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not. obj2.tags.insert("enemy".to_string()); let board = open_board(5, 1, (4, 0), vec![obj1, obj2]); - let mut game = GameState::with_scripts(board, scripts_from(&[ - ("q", r#"fn init() { + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ( + "q", + r#"fn init() { let infos = Board.tagged("enemy"); log(infos.len().to_string()); log(infos[0].id.to_string()); - }"#), - ("none", ""), - ])); + }"#, + ), + ("none", ""), + ]), + ); game.run_init(); let texts = log_texts(&game); - assert_eq!(texts[0], "1"); // exactly one match - assert_eq!(texts[1], "2"); // obj2 is id 2 + assert_eq!(texts[0], "1"); // exactly one match + assert_eq!(texts[1], "2"); // obj2 is id 2 } #[test] fn my_name_returns_name_or_empty_string() { // An object with a name set on its ObjectDef should see it via my_name(). - let (mut board, scripts) = board_with_object( - Some("n"), - &[("n", r#"fn init() { log(Me.name); }"#)], - ); + let (mut board, scripts) = + board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]); board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string()); let mut game = GameState::with_scripts(board, scripts); game.run_init(); assert_eq!(log_texts(&game), vec!["beacon"]); // An unnamed object should get an empty string. - let (board2, scripts2) = board_with_object( - Some("n"), - &[("n", r#"fn init() { log(Me.name); }"#)], - ); + let (board2, scripts2) = + board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]); let mut game2 = GameState::with_scripts(board2, scripts2); game2.run_init(); assert_eq!(log_texts(&game2), vec![""]); @@ -216,18 +223,24 @@ fn object_id_for_name_finds_by_name() { let mut obj2 = scripted_object(1, 0, "none"); obj2.name = Some("target".to_string()); let board = open_board(5, 1, (4, 0), vec![obj1, obj2]); - let mut game = GameState::with_scripts(board, scripts_from(&[ - ("q", r#"fn init() { + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ( + "q", + r#"fn init() { log(Board.named("target").id.to_string()); let miss = Board.named("missing"); log(if miss == () { "not_found" } else { miss.id.to_string() }); - }"#), - ("none", ""), - ])); + }"#, + ), + ("none", ""), + ]), + ); game.run_init(); let texts = log_texts(&game); - assert_eq!(texts[0], "2"); // obj2 is id 2 - assert_eq!(texts[1], "not_found"); // Board.named returns () when no match + assert_eq!(texts[0], "2"); // obj2 is id 2 + assert_eq!(texts[1], "not_found"); // Board.named returns () when no match } // Ensure try_move from the player side also triggers scripted bump @@ -236,7 +249,10 @@ fn player_bump_fires_with_negative_one() { // The player walks into a solid scripted object; the object is bumped with -1 // and the player does not move onto it. let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); - let mut game = GameState::with_scripts(board, scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")]), + ); game.run_init(); game.try_move(Direction::East); assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked @@ -251,22 +267,36 @@ fn scroll_opens_on_player_bump() { // player bumps it and a tick resolves the queued action, active_scroll is set // with the correct ScrollLine variants. let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); - let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#)])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[( + "s", + r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#, + )]), + ); game.run_init(); game.try_move(Direction::East); game.tick(Duration::from_millis(16)); - let scroll = game.active_scroll.as_ref().expect("scroll should be active after bump"); + let scroll = game + .active_scroll + .as_ref() + .expect("scroll should be active after bump"); assert_eq!(scroll.lines.len(), 2); assert!(matches!(&scroll.lines[0], ScrollLine::Text(t) if t == "Hello world")); - assert!(matches!(&scroll.lines[1], ScrollLine::Choice { choice, display } - if choice == "eat" && display == "Eat it")); + assert!( + matches!(&scroll.lines[1], ScrollLine::Choice { choice, display } + if choice == "eat" && display == "Eat it") + ); } #[test] fn handle_scroll_without_choice_clears_it() { let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); - let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)])); + let mut game = GameState::with_scripts( + board, + scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)]), + ); game.run_init(); game.try_move(Direction::East); game.tick(Duration::from_millis(16)); @@ -282,10 +312,16 @@ fn handle_scroll_with_choice_dispatches_send_to_source() { // Setting choice on the scroll before a tick fires send() on the source object; // fn eat() logging "eaten" confirms the dispatch arrived. let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); - let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#" + let mut game = GameState::with_scripts( + board, + scripts_from(&[( + "s", + r#" fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); } fn eat() { log("eaten"); } - "#)])); + "#, + )]), + ); game.run_init(); game.try_move(Direction::East); game.tick(Duration::from_millis(16)); @@ -295,5 +331,8 @@ fn handle_scroll_with_choice_dispatches_send_to_source() { game.active_scroll.as_mut().unwrap().choice = Some("eat".to_string()); game.tick(Duration::from_millis(16)); assert!(game.active_scroll.is_none()); - assert!(log_texts(&game).iter().any(|t| t == "eaten"), "eat() should have logged"); + assert!( + log_texts(&game).iter().any(|t| t == "eaten"), + "eat() should have logged" + ); } diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index a8ba989..3a1385b 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -1,6 +1,6 @@ -use color::Rgba8; use crate::archetype::Archetype; use crate::glyph::Glyph; +use color::Rgba8; use rhai::Dynamic; /// Which directions a solid may be pushed in. @@ -98,6 +98,9 @@ pub struct PortalDef { pub x: usize, /// Row of this portal on the board (0-indexed). pub y: usize, + /// Index of the layer this portal belongs to (0 = bottom). Determines its + /// draw order relative to terrain/objects on other layers. + pub z: usize, /// Key of the target board in `World::boards`. pub target_map: String, /// Name of the arrival portal on the target board. @@ -109,8 +112,18 @@ impl PortalDef { pub fn default_glyph() -> Glyph { Glyph { tile: 240, - fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, - bg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, + fg: Rgba8 { + r: 0, + g: 0, + b: 0, + a: 255, + }, + bg: Rgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, } } } @@ -133,12 +146,17 @@ pub struct Player { #[cfg(test)] mod tests { - use crate::utils::Direction; use super::Pushable; + use crate::utils::Direction; #[test] fn pushable_allows_only_its_axis() { - for d in [Direction::North, Direction::South, Direction::East, Direction::West] { + for d in [ + Direction::North, + Direction::South, + Direction::East, + Direction::West, + ] { assert!(!Pushable::No.allows(d)); assert!(Pushable::Any.allows(d)); } @@ -187,10 +205,18 @@ impl TryFrom for RegistryValue { type Error = (); /// Returns `Err(())` for `()` (unit) and all unsupported types. fn try_from(value: Dynamic) -> Result { - if value.is_bool() { return Ok(RegistryValue::Bool(value.as_bool().unwrap())); } - if value.is_int() { return Ok(RegistryValue::Int(value.as_int().unwrap())); } - if value.is_float() { return Ok(RegistryValue::Float(value.as_float().unwrap())); } - if value.is_string() { return Ok(RegistryValue::Str(value.into_string().unwrap())); } + if value.is_bool() { + return Ok(RegistryValue::Bool(value.as_bool().unwrap())); + } + if value.is_int() { + return Ok(RegistryValue::Int(value.as_int().unwrap())); + } + if value.is_float() { + return Ok(RegistryValue::Float(value.as_float().unwrap())); + } + if value.is_string() { + return Ok(RegistryValue::Str(value.into_string().unwrap())); + } Err(()) } } @@ -202,9 +228,9 @@ impl Into for RegistryValue { fn into(self) -> Dynamic { match self { RegistryValue::Bool(b) => Dynamic::from(b), - RegistryValue::Int(n) => Dynamic::from(n), + RegistryValue::Int(n) => Dynamic::from(n), RegistryValue::Float(f) => Dynamic::from(f), - RegistryValue::Str(s) => Dynamic::from(s), + RegistryValue::Str(s) => Dynamic::from(s), } } } @@ -219,4 +245,4 @@ impl From for (i32, i32) { Direction::West => (-1, 0), } } -} \ No newline at end of file +} diff --git a/kiln-core/src/world.rs b/kiln-core/src/world.rs index ef35f16..1262bd2 100644 --- a/kiln-core/src/world.rs +++ b/kiln-core/src/world.rs @@ -1,11 +1,11 @@ //! World type: a named collection of boards loaded from a single `.toml` file. -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; use crate::board::Board; use crate::map_file::MapFile; use serde::Deserialize; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; /// A named world containing one or more boards, loaded from a `.toml` file. /// @@ -71,8 +71,7 @@ pub fn load(path: &str) -> Result> { // hard errors; everything else is nonfatal and lands on Board::load_errors. let mut boards = HashMap::new(); for (key, map_file) in wf.boards { - let board = Board::try_from(map_file) - .map_err(|e| format!("board '{}': {}", key, e))?; + let board = Board::try_from(map_file).map_err(|e| format!("board '{}': {}", key, e))?; boards.insert(key, Rc::new(RefCell::new(board))); } diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index aa70406..8562506 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -143,21 +143,29 @@ fn run( continue; } let size = terminal.size()?; - match current_input_mode(mode, ui) { - InputMode::Board | InputMode::Frozen => { - if let Mode::Play(game) = mode { - handle_board_input(event, ui.log.open, size.height, game, ui); - } + // Each Mode only produces a subset of input modes (Editor only in Edit; + // Board/Scroll/Frozen only in Play; Menu/Ignore in either). Match on Mode + // first so each handler gets the right `game`/`ed` without re-checking, + // and assert the impossible combinations away with `unreachable!`. + let input_mode = current_input_mode(mode, ui); + if matches!(input_mode, InputMode::Menu) { + handle_menu_input(event, ui) + } else if !matches!(input_mode, InputMode::Ignore) { + // If input_mode is ignore, an animation is running and claiming no input mode — discard all events. + match mode { + Mode::Play(game) => match input_mode { + InputMode::Board | InputMode::Frozen => { + handle_board_input(event, ui.log.open, size.height, game, ui); + } + InputMode::Scroll => handle_scroll_input(event, game, ui), + _ => unreachable!("Menu and Ignore handled above; Editor input mode only occurs in Edit mode"), + }, + Mode::Edit(ed) => match input_mode { + InputMode::Editor => handle_editor_input(event, size.width, ed, ui), + _ => unreachable!("Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode") + }, } - InputMode::Menu => handle_menu_input(event, ui), - InputMode::Scroll => { - if let Mode::Play(game) = mode { handle_scroll_input(event, game, ui); } - } - InputMode::Editor => { - if let Mode::Edit(ed) = mode { handle_editor_input(event, size.width, ed, ui); } - } - // Animation running and claiming no input mode — discard all events. - InputMode::Ignore => {} + } } diff --git a/kiln-tui/src/ui.rs b/kiln-tui/src/ui.rs index 265f8c7..8f830f7 100644 --- a/kiln-tui/src/ui.rs +++ b/kiln-tui/src/ui.rs @@ -107,13 +107,21 @@ impl Ui { return; // no mode tick during any animation (even when just ended) } + // The game only advances during normal play (InputMode::Board). While a + // scroll or menu overlay is open the input mode is Scroll/Menu, so the game + // is paused — otherwise `game.tick` would call `handle_scroll`, clearing the + // scroll the instant its open animation finished. + let input_mode = crate::input::current_input_mode(mode, self); match mode { - // Play: tick the game, then check whether a scroll overlay appeared. + // Play: tick the game (only in Board mode), then check whether a scroll + // overlay appeared as a result of this tick. Mode::Play(game) => { - game.tick(dt); - if let Some(scroll) = &game.active_scroll { - let lines = scroll.lines.clone(); - self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines))); + if matches!(input_mode, crate::input::InputMode::Board) { + game.tick(dt); + if let Some(scroll) = &game.active_scroll { + let lines = scroll.lines.clone(); + self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines))); + } } } // Edit: only the cursor blink advances; the game does not exist here. diff --git a/maps/start.toml b/maps/start.toml index c661998..d422a1f 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -171,24 +171,10 @@ fn tick(dt) { name = "Starting Room" width = 60 height = 25 -player_start = "@" # placed by the '@' grid char (convention) -[boards.start.palette] -" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" } -"#" = { archetype = "wall", tile = "#", fg = "#808080", bg = "#606060" } -"+" = { archetype = "banana", tile = "#", fg = "#808080", bg = "#606060" } -"o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } -"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } -"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } -">" = { archetype = "pusher_east" } -"^" = { archetype = "pusher_north" } - -# Optional visual floor layer, drawn under every empty cell (and revealed when a -# crate is pushed away). This is a grid form with its own palette: `g`/`d`/`s` -# name the built-in generators (random dark, low-saturation texture cached at -# load); a palette entry could also be a fixed glyph like -# `{ tile = ".", fg = "#222", bg = "#000" }`. -[boards.start.floor] +# Layer 0 (bottom): the visual floor. Generators g/d/s roll a fresh textured +# glyph per cell; the space char is a fixed water glyph. +[[boards.start.layers]] content = """ gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg @@ -216,14 +202,15 @@ gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg """ +[boards.start.layers.palette] +"g" = { kind = "floor", generator = "grass" } +"d" = { kind = "floor", generator = "dirt" } +"s" = { kind = "floor", generator = "stone" } +" " = { kind = "floor", tile = "~", fg = "#6666ff", bg = "#000066" } -[boards.start.floor.palette] -"g" = "grass" -"d" = "dirt" -"s" = "stone" -" " = { tile = "~", fg = "#6666ff", bg = "#000066" } - -[boards.start.grid] +# Layer 1: terrain, the player, objects and the portal. A space here is +# transparent (kind = "empty"), so the floor below shows through. +[[boards.start.layers]] content = """ ############################################################ # # @@ -237,7 +224,7 @@ content = """ # # # # | # # # -# oo G - @ # +# V oo G - @ # # # # # # # @@ -251,69 +238,95 @@ content = """ # # ############################################################ """ +[boards.start.layers.palette] +" " = { kind = "empty" } +"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" } +"+" = { kind = "banana", tile = "#", fg = "#808080", bg = "#606060" } # unknown kind -> ErrorBlock demo +"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } +"-" = { kind = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } +"|" = { kind = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } +">" = { kind = "pusher_east" } +"^" = { kind = "pusher_north" } +"@" = { kind = "player" } +"1" = { kind = "portal", name = "to_house", target_map = "house", target_entry = "from_start" } +"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", solid = false, script_name = "greeter" } +"V" = { kind = "object", tile = 1, fg = "#33aa33", bg = "#000000", script_name = "mover" } +"M" = { kind = "object", tile = 15, fg = "#ffdd88", bg = "#000000", solid = true, name = "muffin", script_name = "muffin" } +"B" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "noticeboard", script_name = "noticeboard" } -# Placed by its grid character `G` (uppercase-letter convention for objects) -# rather than x/y; the cell under it loads as Empty. -[[boards.start.objects]] -fg = "#aa3333" -bg = "#000000" -palette = "G" -tile = "#" -solid = false -script_name = "greeter" - -# A solid object placed by x/y that paces east on its own. Demonstrates the -# rate-limited move(), the blocked() collision check, the Queue object, and the -# bump() hook (walk the player into it to trigger a bump). -[[boards.start.objects]] -x = 10 -y = 12 -fg = "#33aa33" -bg = "#000000" -tile = 1 -script_name = "mover" - -[[boards.start.objects]] -palette = "M" -tile = 15 -fg = "#ffdd88" -bg = "#000000" -solid = true -name = "muffin" -script_name = "muffin" - -# Notice board — long scroll to test overflow and scrolling. -[[boards.start.objects]] -palette = "B" -tile = 240 -fg = "#cc9944" -bg = "#000000" -solid = true -name = "noticeboard" -script_name = "noticeboard" - -[[boards.start.portals]] -name = "to_house" -palette = "1" -target_map = "house" -target_entry = "from_start" +# Layer 2: a purely decorative, non-solid object drawn *above* the solid crate +# beneath it — only possible because draw order follows layer order. +[[boards.start.layers]] +content = """ + + + + + + + + + + + + + * + + + + + + + + + + + + +""" +[boards.start.layers.palette] +" " = { kind = "empty" } +"*" = { kind = "object", tile = 15, fg = "#ffff66", bg = "#000000", solid = false } [boards.house.map] name = "The House" width = 60 height = 25 -player_start = "@" -[boards.house.palette] -" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" } -"#" = { archetype = "wall", tile = 178, fg = "#888888", bg = "#555555" } +# Layer 0: a single fixed floor glyph across the whole board. +[[boards.house.layers]] +content = """ +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +""" +[boards.house.layers.palette] +"f" = { kind = "floor", tile = 176, fg = "#888888", bg = "#444444" } -[boards.house.floor] -tile = 176 -fg = "#888888" -bg = "#444444" - -[boards.house.grid] +# Layer 1: terrain, the player, objects and the return portal. +[[boards.house.layers]] content = """ ############################################################ # # @@ -341,44 +354,13 @@ content = """ # # ############################################################ """ +[boards.house.layers.palette] +" " = { kind = "empty" } +"#" = { kind = "wall", tile = 178, fg = "#888888", bg = "#555555" } +"@" = { kind = "player" } +"1" = { kind = "portal", name = "from_start", target_map = "start", target_entry = "to_house" } +"K" = { kind = "object", tile = 240, fg = "#aa7733", bg = "#3d1c00", solid = true, name = "bookshelf", script_name = "bookshelf" } +"F" = { kind = "object", tile = 15, fg = "#ff8800", bg = "#220000", solid = true, name = "fireplace", script_name = "fireplace" } +"C" = { kind = "object", tile = 240, fg = "#ccaa44", bg = "#222200", solid = true, name = "chest", script_name = "chest" } +"T" = { kind = "object", tile = 1, fg = "#99ff44", bg = "#000000", solid = true, script_name = "yammerer" } -[[boards.house.objects]] -palette = "K" -tile = 240 -fg = "#aa7733" -bg = "#3d1c00" -solid = true -name = "bookshelf" -script_name = "bookshelf" - -[[boards.house.objects]] -palette = "F" -tile = 15 -fg = "#ff8800" -bg = "#220000" -solid = true -name = "fireplace" -script_name = "fireplace" - -[[boards.house.objects]] -palette = "C" -tile = 240 -fg = "#ccaa44" -bg = "#222200" -solid = true -name = "chest" -script_name = "chest" - -[[boards.house.objects]] -palette = "T" -tile = 1 -fg = "#99ff44" -bg = "#000000" -solid = true -script_name = "yammerer" - -[[boards.house.portals]] -name = "from_start" -palette = "1" -target_map = "start" -target_entry = "to_house"