From ad95a9cd8d0983d7abbbf8e4c336bb87fa067ca9 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Fri, 10 Jul 2026 23:16:28 -0500 Subject: [PATCH] new layer model --- CLAUDE.md | 56 +-- docs/script-api.md | 5 +- kiln-core/CLAUDE.md | 63 +-- kiln-core/src/action.rs | 4 - kiln-core/src/api/board.rs | 8 +- kiln-core/src/api/object_info.rs | 4 +- kiln-core/src/api/player.rs | 4 +- kiln-core/src/api/queue.rs | 6 +- kiln-core/src/api/registry.rs | 4 +- kiln-core/src/board.rs | 383 +++++++-------- kiln-core/src/floor.rs | 68 ++- kiln-core/src/game.rs | 107 +++-- kiln-core/src/glyph.rs | 4 +- kiln-core/src/keys.rs | 4 +- kiln-core/src/layer.rs | 149 +++--- kiln-core/src/map_file.rs | 445 +++++++++++++----- kiln-core/src/object_def.rs | 5 - kiln-core/src/script.rs | 71 +-- kiln-core/src/tests/actions.rs | 4 +- kiln-core/src/tests/game_portals.rs | 10 +- kiln-core/src/tests/map_file/fill_sparse.rs | 50 +- kiln-core/src/tests/map_file/grid_errors.rs | 12 +- kiln-core/src/tests/map_file/mod.rs | 26 +- .../src/tests/map_file/object_placement.rs | 61 +-- .../src/tests/map_file/player_placement.rs | 75 ++- .../src/tests/map_file/portal_placement.rs | 10 +- kiln-core/src/tests/map_file/pushers.rs | 22 +- kiln-core/src/tests/map_file/round_trip.rs | 64 +-- kiln-core/src/tests/map_file/spinners.rs | 10 +- kiln-core/src/tests/map_file/transporters.rs | 22 +- kiln-core/src/tests/mod.rs | 8 +- kiln-core/src/tests/movement.rs | 36 +- kiln-core/src/utils.rs | 30 +- kiln-core/src/world.rs | 4 +- maps/start.toml | 84 ++-- maps/tiny.toml | 19 +- 36 files changed, 1040 insertions(+), 897 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c91cd7e..3636fea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,15 +53,19 @@ 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 has a `palette` mapping each char to one *kind* of thing, and a grid given in one of three ways: `content` (a multi-line grid string), `fill = "x"` (the whole grid filled with one char — e.g. a uniform floor), or `sparse = [{ x, y, ch }, …]` (an all-spaces grid with just those cells set — e.g. a layer of a few objects). 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. +A board is a `[map]` header plus a **single `[grid]`**. The grid has a `palette` mapping each char to one *kind* of thing, and a char map given in one of three ways: `content` (a multi-line grid string), `fill = "x"` (the whole grid filled with one char), or `sparse = [{ x, y, ch }, …]` (an all-spaces grid with just those cells set). The grid holds every solid and most non-solids; each cell holds at most one authored thing. Alongside the grid, a board has three lightweight attributes: + +- **`floor`** — an optional `[map]` attribute (not a layer): `floor = { generator = "grass" }` (a procedural biome), `floor = { tile = 176, fg = "#888", bg = "#444" }` (one fixed glyph), or omitted (a blank/black floor). Drawn beneath everything, shown wherever the grid cell is empty. +- **`[[triggers]]`** — invisible, non-solid, script-only objects: `{ x, y, script_name, name?, tags? }`. No glyph, never solid or pushable; they exist only to run a script. At runtime they are ordinary objects in `Board::objects`. +- **`[[decorations]]`** — non-solid `(glyph, archetype)` cells placed off the grid, drawn only where the grid cell is empty: `{ x, y, kind, tile?, fg?, bg? }`. The editor does not author these; they exist so a save file can represent a non-solid that ended up sharing a cell with a runtime solid. A solid archetype is rejected. ```toml -# Two terser layer forms, equivalent to a full `content` grid: -[[boards.room1.layers]] -fill = "g" # whole layer is grass floor -palette = { "g" = { kind = "floor", generator = "grass" } } +# Two terser grid forms, equivalent to a full `content` grid: +[boards.room1.grid] +fill = "#" # whole grid is wall +palette = { "#" = { kind = "wall" } } -[[boards.room1.layers]] +[boards.room1.grid] sparse = [ { x = 5, y = 3, ch = "C" }, { x = 9, y = 7, ch = "C" } ] # two chests palette = { "C" = { kind = "object", script_name = "chest" } } # spaces are implicitly empty ``` @@ -92,54 +96,50 @@ fn bump(me, dir) { log(`bumped from ${dir}`); say("Ouch!"); } # dir: the side i name = "Room One" width = 60 height = 25 +floor = { generator = "grass" } # "grass" | "dirt" | "stone", or a fixed glyph, or omit -# 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 = """ -...60×25 grid of 'g'/'d'... -""" -[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 - -# Layer 1: terrain, the player, objects and portals. A space is always a -# transparent empty cell, so the floor below shows through. -[[boards.room1.layers]] +# The single grid: terrain, the player, objects and portals. A space is always a +# transparent empty cell, so the floor shows through. +[boards.room1.grid] content = """ ############################################################ # G @ 1 # ############################################################ """ -[boards.room1.layers.palette] +[boards.room1.grid.palette] # A space is always a transparent empty cell — it is never a palette key. "#" = { 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 +"@" = { kind = "player" } # exactly one across the grid # 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" } + +# Invisible script-only triggers (no glyph, never solid). +[[boards.room1.triggers]] +x = 40 +y = 0 +script_name = "tripwire" ``` -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`, `transporter_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). 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. +Palette `kind` values: the meta-kinds `empty` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `transporter_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). (`floor` is **no longer** a grid kind — it is the board-level `floor` attribute.) For archetype/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). -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); the `fill`/`sparse` forms are always exactly sized. 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(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`). `bump`'s `dir` is the `Direction` the bump came *from* (pointing toward the bumper), fired when *any* solid — the player, another object, or a pushed crate — presses into this object. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately within a hook: each is queued, and **at most one `move` resolves per 250 ms** per object. But each object's ready actions are **applied the moment its hook returns**, before the next object runs — objects are processed in **ascending id order** each tick, so a later object observes the board *after* every lower-id object has already moved (the one exception is actions still stuck behind a `Delay`). When two objects contend for the same cell, **lowest id wins**, and the loser — whose hook ran later — can already see the winner there. The bumped object receives `bump` with the direction the bump came from; `bump`/`send` reactions fire in a follow-up pass that settles fully within the same tick/`try_move`, bounded by a per-invocation guard that fires each `(object, hook, args)` at most once (so a bump/send cycle can't loop forever). The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md). +Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** in the grid (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). The grid's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. 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 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(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`). `bump`'s `dir` is the `Direction` the bump came *from* (pointing toward the bumper), fired when *any* solid — the player, another object, or a pushed crate — presses into this object. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately within a hook: each is queued, and **at most one `move` resolves per 250 ms** per object. The exception is **`log(s)`**, which is *not* queued — it writes to the game log the instant it is called, so a diagnostic line surfaces even when it sits behind a pending `move`/`delay`. But each object's ready actions are **applied the moment its hook returns**, before the next object runs — objects are processed in **ascending id order** each tick, so a later object observes the board *after* every lower-id object has already moved (the one exception is actions still stuck behind a `Delay`). When two objects contend for the same cell, **lowest id wins**, and the loser — whose hook ran later — can already see the winner there. The bumped object receives `bump` with the direction the bump came from; `bump`/`send` reactions fire in a follow-up pass that settles fully within the same tick/`try_move`, bounded by a per-invocation guard that fires each `(object, hook, args)` at most once (so a bump/send cycle can't loop forever). The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md). **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)`) or the per-board `Registry` (which also persists across transitions). ### 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. -- **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. +- **Draw order is a fixed precedence** — `Board::glyph_at` at `(x, y)` returns the first that draws: the player; then an object on the cell (a solid object always, else a visible non-solid); then the grid cell (a solid always, else a non-transparent glyph); then a portal; then a `decoration` (only reachable because the grid cell was empty); then the `floor` glyph; then the canonical black `Empty`. There is one grid, plus the board-level `floor` attribute drawn beneath everything. A cell vacated by a pushed crate is left transparent (`Glyph::transparent()`, tile 0), revealing the floor with no movement-code changes. See `board.rs` / `layer.rs` / `floor.rs`. +- **One solid per cell** — at most one solid entity (the player, a solid terrain archetype, *or* a solid object) may occupy a grid 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 single grid holds at most one authored char per cell, so a stacked solid is unrepresentable by construction; 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 and drops a conflicting object. `Board::solid_at` relies on this invariant. +- **The grid 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 variants can be reordered or extended without breaking saved games. **Terrain archetypes** (`Wall`, `Crate`, etc.): the `match`es in `behavior()`/`name()`/`default_glyph()` are exhaustive (the compiler flags them), but `TryFrom<&str>` is not — forget its arm and the new name silently loads as `ErrorBlock`. **Script-backed archetypes** (`Builtin`): adding one requires only (1) an entry in the `builtins!` macro invocation in `archetype.rs` and (2) the `.rhai` script file — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, and the expansion pass all derive from the macro output automatically. In both cases, 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. @@ -153,7 +153,7 @@ Today's baked-in assumptions that will need to change (don't make `Board.player` - `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). - 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. +- The player is rendered as a hardcoded overlay (`Glyph::player()`); it should become a normal object drawn from `Board::objects`. ### Other not-yet-implemented threads diff --git a/docs/script-api.md b/docs/script-api.md index 175839e..f2898f8 100644 --- a/docs/script-api.md +++ b/docs/script-api.md @@ -261,7 +261,8 @@ The Registry is shared by the whole board, so namespace per-instance keys by `me Scripts never mutate the world directly. Each write appends an `Action` to the calling object's **output queue**; the moment the hook returns, that object's ready actions are drained and applied by the engine — before the next object's hook runs. The *subject* of a write is implicit and varies -by function (see the table). +by function (see the table). The one exception is `log(msg)`: it bypasses the queue and writes to the +game log immediately, so a diagnostic line is never paced by (or stuck behind) the object's delays. | Call | Subject | Cost | Description | |------|---------|------|-------------| @@ -275,7 +276,7 @@ by function (see the table). | `set_color(fg, bg)` | self | 0 | Set both colors. | | `set_tag(target_id, tag, present)` | any object | 0 | Add (`true`) / remove (`false`) a tag. Use `me.id` for self. | | `say(msg)` / `say(msg, secs)` | self | 0 | Speech bubble above this object; default 3 s, or `secs`. Replaces any current bubble. | -| `log(msg)` | log | 0 | Append a plain-text line to the game log. | +| `log(msg)` | log | 0 | Append a plain-text line to the game log. **Immediate** — unlike the other writes it is *not* queued, so it appears the instant it's called, even behind a pending `move`/`delay`. | | `scroll(lines)` | UI | 0 | Open a full-screen overlay (see below). | | `send(target_id, fn_name [, arg])` | any object | 0 | Call a handler function on another object; optional string/number arg. | | `delay(secs)` | self | — | Insert an explicit pause; integer and float overloads. Adjacent delays merge. | diff --git a/kiln-core/CLAUDE.md b/kiln-core/CLAUDE.md index cc87ec9..8565403 100644 --- a/kiln-core/CLAUDE.md +++ b/kiln-core/CLAUDE.md @@ -29,34 +29,36 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`. - `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`, `grab: bool` (the player walking onto a `solid + grab` thing isn't blocked — it moves onto it and the thing's `grab()` hook fires; this is the *only* grab trigger; `Gem` and `Heart` both set the flag). - `ObjectId = u32` — stable identifier for board objects. -- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Cell(Archetype)`, or `Object(ObjectId)`. +- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Terrain { glyph, arch }` (a grid cell), or `Object(ObjectId)`. `place(board, x, y)` relocates it (terrain rewrites the destination grid cell). No layer index — the board is one grid. - `PlayerPos { x: i32, y: i32 }` — the player's current position on a board (held as `Board::player`). The broader player *state* (health, gems, keys) lives in [`player::Player`], not here. -- `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired. +- `PortalDef { name, x, y, target_map, target_entry }` — parsed from map files; runtime-wired (portals switch boards). No `z`. +- `LogSink(Rc>>)` — the shared, immediate log channel handed to every `Registerable::register` and the write API. `line(LogLine)` / `error(String)` push straight onto it during hook execution (so script `log()` output and errors are *not* paced by the object's action queue); `take()` drains it. `GameState::drain_log` empties it into `GameState::log` each frame. **`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`): -- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained into a batch of actions that `GameState` applies immediately after the object's hook — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `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. +- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained into a batch of actions that `GameState` applies immediately after the object's hook — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet`, `name: Option`. A **trigger** is just an `ObjectDef` that is non-solid, non-opaque, non-pushable, glyphless (transparent glyph) and scripted (authored via `[[triggers]]`). 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::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, fill, sparse, palette: HashMap }` — serde for one `[[layers]]` entry. The grid comes from exactly one of three optional fields (precedence `content` → `fill` → `sparse`; none ⇒ all spaces): `content` (a multi-line grid string), `fill` (one char filling the whole grid — handy for a uniform floor layer), or `sparse` (a `Vec` of `{ x, y, ch }` over an otherwise all-spaces grid — handy for a layer of just a few objects). Only `content` can mismatch the board dims (hard error); `fill`/`sparse` are always exactly sized (a bad `fill`/`ch` or out-of-bounds `sparse` cell is a nonfatal error). `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/layer.rs`** — the board grid (palette + char map load unit; file/type name kept for history): +- `GridCell = (Glyph, Archetype)` (`pub(crate)`) — one grid cell: its visual and its behavioral class. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the floor/decoration beneath shows through; solidity comes from the archetype, independent of transparency. (There is no longer a `Layer` type — the board is a single `Vec`.) +- `GridData { content, fill, sparse, palette: HashMap }` — serde for the single `[grid]` block. The char map comes from exactly one of three optional fields (precedence `content` → `fill` → `sparse`; none ⇒ all spaces): `content` (a multi-line grid string), `fill` (one char filling the whole grid), or `sparse` (a `Vec` of `{ x, y, ch }` over an otherwise all-spaces grid). Only `content` can mismatch the board dims (hard error); `fill`/`sparse` are always exactly sized (a bad `fill`/`ch` or out-of-bounds `sparse` cell is a nonfatal error). `PaletteEntry` is a single flat struct with a `kind: String` discriminator plus all-optional fields (`tile`, `fg`, `bg`, `solid`, `opaque`, `pushable`, `script_name`, `tags`, `name`, `target_map`, `target_entry`); only the fields relevant to the kind are read. (`floor` is **not** a grid kind — the floor is a board attribute; see `floor.rs`/`map_file.rs`.) +- `build_grid(data, w, h, &mut Vec) -> Result<(Vec, Vec), String>` — resolves the palette (via `resolve_entry`), validates the grid dims (the only hard `Err`), and walks the grid building the cell vec plus a `Vec` (`Object(ObjectTemplate, x, y)` / `Portal(PortalTemplate, x, y)` / `Player(x, y)`) for the map loader to resolve. +- `resolve_entry` maps `kind` → `Resolved`: `empty` → transparent cell; `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`, `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`). +- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `grid: Vec<(Glyph, Archetype)>` (the single row-major cell grid; `pub(crate)`), `floor: Floor` (the cosmetic floor attribute — blank / fixed glyph / biome; `pub(crate)`), `decorations: Vec` (`pub(crate)`), `player: Player`, `objects: BTreeMap`, `next_object_id: ObjectId`, `portals: Vec`, `board_script_name: Option`, `load_errors: Vec` (`pub(crate)`), `registry: HashMap`. Scripts are **not** stored on `Board` — they live in [`World::scripts`]. +- `Decoration { x, y, glyph, archetype }` (`pub`) — a non-solid `(glyph, archetype)` placed off the grid, drawn only where the grid cell is empty. Normally empty; populated by save files (a runtime solid can end up over a non-solid, which is recorded here). A solid archetype is rejected at load. +- `get(x, y) -> &(Glyph, Archetype)` / `get_mut(x, y)` — grid cell access (panics OOB). (No `z` / `layer_count` — the board is one grid.) +- `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`, by fixed precedence: the player (`Glyph::player()`); then an object on the cell (a solid object always, else the first visible non-solid); then the grid cell (a solid always, else a non-transparent glyph); then a portal; then a `decoration` (only reached because the grid cell was empty); then `floor.glyph_at(x, y, width)`; then 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 a solid object, then the grid cell's archetype if solid). - `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? - `bump_target(x, y, dir) -> Option` — the object a move **into** `(x, y)` heading `dir` bumps: the solid object in the target cell, or the one at the end of a chain of pushed crates (walked through in `dir`). Open space, the player, or a wall yield `None`. This is what lets a plain crate — not just an object or the player — trigger a `bump`. Used by `GameState::try_move` / `step_object`; the caller supplies the came-from direction as `dir.opposite()`. - `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The **player** counts as a blocker for this check. A Rust-side companion read to `apply_shift`; not itself exposed to scripts. -- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`). +- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so the floor is revealed). A pushed solid moves within the grid. - `add_object(obj) -> ObjectId`, `remove_object(id) -> Option`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries. - `grab_object_at(x, y) -> Option` — a solid `grab` object on the cell (the player-walks-onto-it case). `GameState::try_move` uses it to fire `grab()` instead of bumping when the player steps onto a grab thing. Grab is **only** a player-movement event: a grab thing pushed/shifted into the player is treated as an ordinary solid (no special-case). (`remove_object` deletes the object and its `ObjQueue` from the `objects` map; a live `ScriptHost` keeps an unused `Scope` for that id until it is rebuilt — harmless, since hook dispatch iterates the board's *current* ids.) -- `apply_shift(cells: &[(i32, i32)]) -> Vec` — rotates the solids occupying a ring of cells one step: each cell's solid moves to the next coordinate in the list, and the last wraps back to the first. Backs the script `shift()` fn. A cell is **immobile** if its solid is unpushable, or is an `HCrate`/`VCrate` asked to move along its forbidden axis; immobility **cascades backward** along the ring (the cell that would feed an immobile cell is also held), so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is `place`d at its target (a displaced object is despawned via `remove_object`). Any out-of-bounds coordinate **rejects the whole shift** (returned as a `LogLine`). The player rotates like any other solid. -- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving any floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the existing terrain layer (`terrain_layer_at`, the single non-`Empty` cell) or else the top layer; `Empty` erases — removes every object plus the terrain cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`. -- `expand_builtin_archetypes()` — the **single** expansion point: replaces every `Archetype::Builtin(b, alias)` terrain cell with the scripted object it expands to. For each such cell: vacates the terrain slot (→ transparent `Empty`), calls `b.script()` for the embedded source, uses `builtin_tag(arch)` as both the `BUILTIN_` tag and the `script_name` compile-cache key, and copies the cell's glyph (so palette overrides survive). The object's `pushable`/`grab` flags come from `b.behavior()`. Idempotent (vacated cells become `Archetype::Empty`, so a second pass finds nothing). Called by `TryFrom for Board` after cross-layer validation (disk loads) and again by the editor's `playtest()` on the `World::deep_clone` (so editor-stamped machines, which `place_archetype` writes as inert terrain, also run). Builtin objects get ids after hand-placed ones (layer-then-reading order among themselves). +- `clear_solid(x, y)` — replaces the grid cell's solid terrain (if any) with a transparent `Empty`, revealing the floor. `apply_shift(cells: &[(i32, i32)]) -> Vec` — rotates the solids occupying a ring of cells one step: each cell's solid moves to the next coordinate in the list, and the last wraps back to the first. Backs the script `shift()` fn. A cell is **immobile** if its solid is unpushable, or is an `HCrate`/`VCrate` asked to move along its forbidden axis; immobility **cascades backward** along the ring, so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is `place`d at its target (a displaced object is despawned via `remove_object`). Any out-of-bounds coordinate **rejects the whole shift**. The player rotates like any other solid. +- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving the floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the grid cell; `Empty` erases — removes every object plus the grid cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`. +- `expand_builtin_archetypes()` — the **single** expansion point: replaces every `Archetype::Builtin(b, alias)` grid cell with the scripted object it expands to. For each such cell: vacates the grid cell (→ transparent `Empty`), calls `b.script()` for the embedded source, uses `builtin_tag(arch)` as both the `BUILTIN_` tag and the `script_name` compile-cache key, and copies the cell's glyph (so palette overrides survive). The object's `pushable`/`grab` flags come from `b.behavior()`. Idempotent (vacated cells become `Archetype::Empty`, so a second pass finds nothing). Called by `TryFrom for Board` after cross-cell validation (disk loads) and again by the editor's `playtest()` on the `World::deep_clone`. Builtin objects get ids after hand-placed ones (reading order among themselves). - `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord. - `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface. @@ -71,30 +73,30 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one). - `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds). - `Scroll { source: ObjectId, lines: Vec }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`. -- `GameState` — owns `world: World` (all boards as `Rc>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, `pub active_scroll: Option`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `alter_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a `ScriptState` bundle — a `Copy` snapshot of `player` plus a shared handle to the active board — passed to the hook as its `state` parameter (Rhai type `State`, read via `state.player`/`state.board`; see `api::state`). Front-ends reach the active board through `board() -> Ref` / `board_mut() -> RefMut` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(dir_from)` on the solid object it presses into — directly or at the end of a chain of crates it shoves (see `Board::bump_target`), where `dir_from` is the side the bump came from; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` — applied immediately — so its `die()`/`alter_gems()`/`alter_health()` take effect before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` expires speech bubbles, then runs each object's `tick(dt)` hook **in ascending id order, applying that object's drained actions before the next object runs** — so a later object sees the board an earlier one just changed (the requested fix; the exception is actions still behind a `Delay`). The workhorse is `apply_actions(Vec) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` pairs, drop the borrow, then apply the player-stat / bubble / scroll changes), applying each `Action` (`Log` → `log`, `SetTile`/`SetColor` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player.gems`, `AlterHealth(dh)` → `player.health` clamped to `[0, max_health]`, `SetKey` → `player.keys`, `Shift` → `apply_shift`, `Push`/`Teleport` → board moves, `Die` → `remove_object(source)`). It **returns** the `bump`/`send` reactions rather than firing them; after all object hooks, `settle(events, called)` runs them (and any they cascade into) until quiescent, applying each hook's actions as it goes. `called: CalledSet` (a `HashSet<(ObjectId, fn/hook name, arg repr)>`, fresh per `tick`/`try_move`/`run_init`) records every reaction fired and skips repeats, so a bump/send cycle terminates. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object. +- `GameState` — owns `world: World` (all boards as `Rc>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, `pub active_scroll: Option`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `alter_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a `ScriptState` bundle — a `Copy` snapshot of `player` plus a shared handle to the active board — passed to the hook as its `state` parameter (Rhai type `State`, read via `state.player`/`state.board`; see `api::state`). Front-ends reach the active board through `board() -> Ref` / `board_mut() -> RefMut` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(dir_from)` on the solid object it presses into — directly or at the end of a chain of crates it shoves (see `Board::bump_target`), where `dir_from` is the side the bump came from; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` — applied immediately — so its `die()`/`alter_gems()`/`alter_health()` take effect before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` expires speech bubbles, then runs each object's `tick(dt)` hook **in ascending id order, applying that object's drained actions before the next object runs** — so a later object sees the board an earlier one just changed (the requested fix; the exception is actions still behind a `Delay`). The workhorse is `apply_actions(Vec) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` pairs, drop the borrow, then apply the player-stat / bubble / scroll changes), applying each `Action` (`SetTile`/`SetColor` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player.gems`, `AlterHealth(dh)` → `player.health` clamped to `[0, max_health]`, `SetKey` → `player.keys`, `Shift` → `apply_shift`, `Push`/`Teleport` → board moves, `Die` → `remove_object(source)`). It **returns** the `bump`/`send` reactions rather than firing them; after all object hooks, `settle(events, called)` runs them (and any they cascade into) until quiescent, applying each hook's actions as it goes. `called: CalledSet` (a `HashSet<(ObjectId, fn/hook name, arg repr)>`, fresh per `tick`/`try_move`/`run_init`) records every reaction fired and skips repeats, so a bump/send cycle terminates. `drain_log()` moves the script host's collected log lines — script `log()` output and errors — into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object. **`kiln-core/src/action.rs`** — the `Action` enum and its supporting types: - `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s). - `ScrollLine` (`pub`) — one line of content in a `Scroll` action: `Text(String)` (plain, word-wrapped) or `Choice { choice, display }` (selectable; `choice` is sent back to the source object when the player picks it). - `SendArg` — the optional argument carried by a `Send` action / `send()` call: `None`/`Int`/`Float`/`String`, with `From`/`Into` conversions. -- `Action` (`pub(crate)`) — deferred mutation emitted by a script, drained from the issuing object's queue and applied by `GameState::apply_actions`: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never drained as an action — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost). +- `Action` (`pub(crate)`) — deferred mutation emitted by a script, drained from the issuing object's queue and applied by `GameState::apply_actions`: `Move(Direction)`, `SetTile(u32)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never drained as an action — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost). - `BoardAction { source, action }` — an `Action` drained from an object's queue, tagged with the `ObjectId` that issued it; `GameState` applies a `Vec` per object. -**`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/floor.rs`** — the board floor attribute + procedural generators: +- `Floor` (`pub`) — a board's cosmetic floor, drawn beneath everything (replaces the old floor *layer*): `Blank` (the canonical black empty shows), `Fixed(Glyph)` (one glyph tiled across the board), or `Biome { generator, glyphs }` (a procedural texture — keeps its `generator` so save re-emits the name, plus a per-cell `glyphs` buffer pre-rolled once at load from `FLOOR_SEED`). `Floor::biome(gen, w, h)` builds and pre-rolls a biome; `Floor::glyph_at(x, y, width) -> Option` is the per-cell lookup used by `Board::glyph_at`; `Default` is `Blank`. Resolved from the map-file `floor = { … }` attribute in `map_file::FloorSpec::resolve`. +- `FloorGenerator` (`Grass`/`Dirt`/`Stone`) — procedural textures differing only in color scheme and texture-char probability. `from_name(&str)` parses the map-file name and `name()` is its inverse (for save); `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 biome build, so floors are deterministic/testable and depend only on generator + dimensions. **`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. **`kiln-core/src/script.rs`** — Rhai scripting **host** (the script-facing types live in `kiln-core/src/api/`, below). The script-author's reference is [`docs/script-api.md`](../docs/script-api.md). -- `Registerable` trait — `fn register(engine, error_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`. -- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap`), one persistent `Scope` per scripted object (`HashMap`), and the error sink. (There is no shared board queue: each hook call drains into a local `Vec` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the error sink; runs nothing. -- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Run one object at a time (the per-object loop lives in `GameState`): `run_tick_on(id, dt)` / `run_init_on(id)` / `run_bump(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec` they drained** for `GameState` to apply. `run_hook_on_one` builds a fresh `ObjectInfo`, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal. +- `Registerable` trait — `fn register(engine, log_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`. +- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap`), one persistent `Scope` per scripted object (`HashMap`), and the shared `LogSink`. (There is no shared board queue: each hook call drains into a local `Vec` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the `LogSink`; runs nothing. +- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Run one object at a time (the per-object loop lives in `GameState`): `run_tick_on(id, dt)` / `run_init_on(id)` / `run_bump(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec` they drained** for `GameState` to apply. `run_hook_on_one` builds a fresh `ObjectInfo`, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the shared `LogSink` (drained to the log), not fatal. - `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists. - **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore. -- **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`. -- **Queue draining:** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(&mut Vec, dt)` moves its ready run into a caller-supplied `Vec`: it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. The hook runner returns that `Vec` and `GameState` applies it immediately (per object), so scripts mutate without a `&mut GameState` borrow and each object sees the prior object's effects. Errors bypass the queue via the error sink (`take_errors()`). +- **Write API** (`register_write_api`) — free functions that (mostly) enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. **`log(s)` is the exception** — it is *not* an `Action`; it writes a line straight to the shared `LogSink`, so it surfaces immediately regardless of the object's queued delays (the write closure takes a `LogSink` clone). `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error immediately to the `LogSink`) and rotates that ring of cells via `Board::apply_shift`. +- **Queue draining:** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(&mut Vec, dt)` moves its ready run into a caller-supplied `Vec`: it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. The hook runner returns that `Vec` and `GameState` applies it immediately (per object), so scripts mutate without a `&mut GameState` borrow and each object sees the prior object's effects. `log()` output and errors bypass the queue entirely via the shared `LogSink` (`take_logs()`), so they are never paced by a `Delay`. - **Constants in scope:** only `Registry` is pushed into each object's scope (the `me`/`state` views are hook *parameters* now). `register_global_constants` registers the `North`/`South`/`East`/`West` `Direction` values and the 16 named colors as a **global module**, visible at any call depth (including Rhai→Rhai calls and helper functions) — unlike scope constants such as `Registry`. - `Registry` (a `Board`-handle wrapper) — `Registry.get(key)` / `set(key, value)` / `get_or(key, default)` read and write the board's `registry: HashMap`, which **persists across board transitions**. `set(key, ())` removes a key; unsupported value types are ignored; `get_or` only returns the stored value when it matches the default's Rhai type. - **Sender identity uses stable ids:** the issuing `ObjectId` rides the per-call **tag** — `run_*` calls `call_fn_with_options(...with_tag(id)...)` and the write fns read it via `NativeCallContext::tag()` (decoded by `source_of`; id 0, never valid, is the no-source fallback). It matches `Board::objects`' `BTreeMap` keys, so it survives object spawn/destroy/reorder. @@ -107,11 +109,12 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `api::object_info` — `ObjectInfo` (a snapshot of one object: `id`, `x`, `y`, a board handle, `script_name`, and a clone of the object's `ObjQueue`), used as both the `me` parameter and the return type of the `Board` lookups (so self and other objects are the **same** type). Getters `x`/`y`/`id`/`name`/`tags`/`glyph`/`waiting`/`queue`; methods `has_tag(tag)`, `delay(secs)`, `can_push(dir)`, `blocked(dir)`. `from_id`/`from_def` build it; `drain(&mut Vec, dt)` moves the underlying object's ready queued actions into the target `Vec`. - `api::queue` — `ObjQueue(Rc>>)` (Rhai type **`Queue`**), one object's output queue, owned by its `ObjectDef` and reachable as `me.queue`. Rhai surface: getter `length`, methods `clear()`, `delay(secs)`. Rust surface used by the host: `act`, `delay` (merges a trailing delay), `now` (promote the last-enqueued action to the front), `drain` (eat leading delays by `dt`, then move the ready actions into a `&mut Vec`), `waiting` (front is a `Delay`). -**`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`). +**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the grid work lives in `layer.rs`): +- `MapFile { map: MapHeader, grid: GridData, triggers: Vec, decorations: Vec }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `floor: FloorSpec` and `board_script_name`; **no** `player_start` — the player is a `kind = "player"` grid char), the single `[grid]`, and the off-grid `[[triggers]]`/`[[decorations]]` lists. Does **not** include scripts (those live in `World::scripts`). +- `FloorSpec { generator?, tile?, fg?, bg? }` → `Floor` via `resolve` (generator → biome + pre-roll; any of tile/fg/bg → fixed glyph; empty → blank; unknown generator → blank + error). `TriggerSpec { x, y, script_name, name?, tags? }` → a non-solid glyphless scripted `ObjectDef`. `DecorationSpec { x, y, kind, tile?, fg?, bg? }` → a `Decoration` (a solid archetype is rejected). - `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. Finally it calls `Board::expand_builtin_archetypes` to turn script-backed archetype cells (pushers/spinners) into their scripted objects (these get ids after the hand-placed objects). -- `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). +- `impl TryFrom for Board` — the load conversion: builds the single grid via `layer::build_grid` (collecting object/portal/player placements), resolves the floor, then runs the cross-cell validations. **Best-effort/nonfatal**: only a 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 (a conflicting solid object is dropped); object names board-unique (a dup is cleared, via `claim_name`), portal names unique (a dup is dropped). Then `[[triggers]]` load as non-solid glyphless objects (after the grid objects) and `[[decorations]]` as off-grid non-solids. Finally it calls `Board::expand_builtin_archetypes` to turn script-backed archetype cells into their scripted objects. +- `impl From<&Board> for MapFile` — save: emits the single `[grid]` (deduping each unique `(Glyph, Archetype)` to a palette char; objects to `kind = "object"`, portals, player), **except** trigger objects (`is_trigger`: non-solid + transparent glyph + scripted + non-builtin) which go to `[[triggers]]`, and decorations to `[[decorations]]`. The floor is written back via `floor_to_spec` — a biome re-emits its `generator` name (procedural floors now round-trip), a fixed glyph its tile/fg/bg. - `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. diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index cbd9623..759a8a8 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -6,7 +6,6 @@ //! object that issued it). use std::fmt::Debug; -use crate::log::LogLine; use crate::utils::{Direction, ObjectId}; use color::Rgba8; use rhai::Dynamic; @@ -73,8 +72,6 @@ pub enum Action { Move(Direction), /// Set the source object's glyph tile index. SetTile(u32), - /// Append a styled line to the game log. - Log(LogLine), /// Add (`present = true`) or remove (`present = false`) `tag` on `target`. SetTag { target: ObjectId, @@ -134,7 +131,6 @@ impl Debug for Action { match self { Action::Move(dir) => write!(f, "Move({:?})", dir), Action::SetTile(i) => write!(f, "SetTile({i})"), - Action::Log(msg) => write!(f, "Log({:?})", msg), Action::SetTag { .. } => write!(f, "SetTag"), Action::Say(_, _) => write!(f, "Say"), Action::Delay(t) => write!(f, "Delay({t})"), diff --git a/kiln-core/src/api/board.rs b/kiln-core/src/api/board.rs index 0932c99..8b161a3 100644 --- a/kiln-core/src/api/board.rs +++ b/kiln-core/src/api/board.rs @@ -19,13 +19,13 @@ use crate::{Board, Direction}; use crate::api::object_info::ObjectInfo; use crate::api::registry::Registry; use crate::script::Registerable; -use crate::utils::{ErrorSink, ObjectId}; +use crate::utils::{LogSink, ObjectId}; /// A read-only handle to the world, exposed to scripts as `Board`. pub type BoardRef = Rc>; impl Registerable for BoardRef { - fn register(engine: &mut Engine, error_sink: ErrorSink) { + fn register(engine: &mut Engine, log_sink: LogSink) { engine.register_type_with_name::("Board"); engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64); engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64); @@ -48,14 +48,14 @@ impl Registerable for BoardRef { // Board.get(id) -> ObjectInfo | () (unknown id logs error) engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic { if id <= 0 { - error_sink.error(format!("Board.get: invalid id {id}")); + log_sink.error(format!("Board.get: invalid id {id}")); return Dynamic::UNIT; } if let Some(obj) = ObjectInfo::from_id(id as ObjectId, board.clone()) { Dynamic::from(obj) } else { - error_sink.error(format!("Board.get: no object with id {id}")); + log_sink.error(format!("Board.get: no object with id {id}")); Dynamic::UNIT } }); diff --git a/kiln-core/src/api/object_info.rs b/kiln-core/src/api/object_info.rs index 658f198..59fc683 100644 --- a/kiln-core/src/api/object_info.rs +++ b/kiln-core/src/api/object_info.rs @@ -28,7 +28,7 @@ use crate::Direction; use crate::action::BoardAction; use crate::object_def::ObjectDef; use crate::script::Registerable; -use crate::utils::{ErrorSink, ObjectId}; +use crate::utils::{LogSink, ObjectId}; /// A snapshot of one board object, returned by `Board.tagged`, `Board.named`, /// and `Board.get`. Passed by value — scripts read fields, not a live reference. @@ -79,7 +79,7 @@ impl ObjectInfo { } impl Registerable for ObjectInfo { - fn register(engine: &mut Engine, _error_sink: ErrorSink) { + fn register(engine: &mut Engine, _log_sink: LogSink) { engine.register_type_with_name::("ObjectInfo") .register_get("x", |obj: &mut ObjectInfo| obj.x) .register_get("y", |obj: &mut ObjectInfo| obj.y) diff --git a/kiln-core/src/api/player.rs b/kiln-core/src/api/player.rs index 29423e3..fee13df 100644 --- a/kiln-core/src/api/player.rs +++ b/kiln-core/src/api/player.rs @@ -2,7 +2,7 @@ use rhai::Engine; use crate::api::board::BoardRef; use crate::player::PlayerRef; use crate::script::Registerable; -use crate::utils::ErrorSink; +use crate::utils::LogSink; /// GameState stores player state but Board stores its position, and we want one /// object to register with Rhai @@ -10,7 +10,7 @@ use crate::utils::ErrorSink; pub struct PlayerWithPos(pub PlayerRef, pub BoardRef); impl Registerable for PlayerWithPos { - fn register(engine: &mut Engine, _error_sink: ErrorSink) { + fn register(engine: &mut Engine, _log_sink: LogSink) { engine.register_type_with_name::("Player") .register_get("gems", |player: &mut PlayerWithPos| player.0.borrow().gems) .register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health) diff --git a/kiln-core/src/api/queue.rs b/kiln-core/src/api/queue.rs index c09613b..2e60d28 100644 --- a/kiln-core/src/api/queue.rs +++ b/kiln-core/src/api/queue.rs @@ -8,7 +8,7 @@ use std::rc::Rc; use rhai::{Dynamic, Engine}; use crate::action::{Action, BoardAction}; use crate::script::Registerable; -use crate::utils::{ErrorSink, ObjectId}; +use crate::utils::{LogSink, ObjectId}; /// A single object's output queue. #[derive(Clone)] @@ -81,7 +81,7 @@ impl ObjQueue { } impl Registerable for ObjQueue { - fn register(engine: &mut Engine, error_sink: ErrorSink) { + fn register(engine: &mut Engine, log_sink: LogSink) { engine.register_type_with_name::("Queue"); engine.register_get("length", |q: &mut ObjQueue| q.0.borrow().len() as i64); engine.register_fn("clear", ObjQueue::clear); @@ -99,7 +99,7 @@ impl Registerable for ObjQueue { match secs { Ok(secs) => q.delay(secs), - Err(msg) => error_sink.error(msg.to_string()) + Err(msg) => log_sink.error(msg.to_string()) } }); } diff --git a/kiln-core/src/api/registry.rs b/kiln-core/src/api/registry.rs index 3a6b86d..6142f22 100644 --- a/kiln-core/src/api/registry.rs +++ b/kiln-core/src/api/registry.rs @@ -1,7 +1,7 @@ use rhai::{Dynamic, Engine, ImmutableString}; use crate::api::board::BoardRef; use crate::script::Registerable; -use crate::utils::{ErrorSink, RegistryValue}; +use crate::utils::{LogSink, RegistryValue}; /// The board's script registry, pushed into scope as the constant `Registry`. /// `get`/`set`/`get_or` methods let scripts read and write per-board key→value pairs @@ -11,7 +11,7 @@ use crate::utils::{ErrorSink, RegistryValue}; pub struct Registry(pub BoardRef); impl Registerable for Registry { - fn register(engine: &mut Engine, _error_sink: ErrorSink) { + fn register(engine: &mut Engine, _log_sink: LogSink) { engine.register_type_with_name::("Registry"); // Registry.get(key) -> Dynamic — returns () if the key is absent. diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 86cead6..4f31201 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -1,12 +1,33 @@ use crate::archetype::Archetype; +use crate::floor::Floor; use crate::glyph::Glyph; -use crate::layer::Layer; use crate::log::LogLine; use crate::object_def::ObjectDef; use crate::utils::Direction; use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid}; use std::collections::{BTreeMap, HashMap, HashSet}; +/// A non-solid `(glyph, archetype)` placed at a board coordinate, **outside** the +/// main grid, drawn only when the grid cell at `(x, y)` is empty. +/// +/// Decorations exist so a single file format can serve both world files and save +/// files: at authoring time every cell holds at most one thing, but during play a +/// runtime solid can end up sharing a cell with a non-solid that was already there. +/// The non-solid is recorded here (the grid keeps the solid). The editor does not +/// place decorations; they are a save/runtime concern. A decoration's archetype is +/// always non-solid (a solid one is rejected at load). +#[derive(Clone)] +pub struct Decoration { + /// Column (0-indexed). + pub x: usize, + /// Row (0-indexed). + pub y: usize, + /// The decoration's visual. + pub glyph: Glyph, + /// The decoration's (non-solid) archetype. + pub archetype: Archetype, +} + /// The complete state of one game board (a single room or screen). /// /// `Board` is the central data structure of the engine, equivalent to a @@ -37,12 +58,18 @@ pub struct Board { pub width: usize, /// Height of the board in cells. pub height: usize, - /// 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, + /// The single row-major grid of `(Glyph, Archetype)` cells (`width * height`), + /// holding every solid and most non-solids. A transparent cell (glyph tile 0) + /// draws nothing, revealing a [`decoration`](Board::decorations) or the + /// [`floor`](Board::floor) beneath. Access a cell with [`Board::get`]/ + /// [`Board::get_mut`] by `(x, y)`. + pub(crate) grid: Vec<(Glyph, Archetype)>, + /// The board's cosmetic floor (blank / one fixed glyph / a biome), drawn beneath + /// everything. Replaces the old dedicated floor layer. + pub(crate) floor: Floor, + /// Non-solid things placed off the main grid, drawn only where the grid cell is + /// empty (see [`Decoration`]). Normally empty; populated by save files. + pub(crate) decorations: Vec, /// Current player position on this board. See [`PlayerPos`] for caveats /// about its future. Game-global player *stats* live in [`crate::player::Player`]. pub player: PlayerPos, @@ -75,98 +102,91 @@ pub struct Board { } impl Board { - /// Number of draw layers on this board (≥ 1 for a loaded board). - pub fn layer_count(&self) -> usize { - self.layers.len() - } - /// Return a list of all `ObjectId`s currently on the board. pub fn all_ids(&self) -> Vec { self.objects.keys().cloned().collect() } - /// Returns a reference to the cell at `(x, y)` on layer `z`. + /// Returns a reference to the `(Glyph, Archetype)` cell at `(x, y)`. /// - /// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are - /// out of bounds. - pub fn get(&self, z: usize, x: usize, y: usize) -> &(Glyph, Archetype) { - &self.layers[z].cells[y * self.width + x] + /// Panics if `x` or `y` are out of bounds. + pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) { + &self.grid[y * self.width + x] } - /// Returns a mutable reference to the cell at `(x, y)` on layer `z`. + /// Returns a mutable reference to the cell at `(x, y)`. /// - /// 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) { + /// Panics if `x` or `y` are out of bounds. + pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) { let w = self.width; - &mut self.layers[z].cells[y * w + x] + &mut self.grid[y * w + x] } - /// Replace the solid (if any) at `(x, y)` with `Empty` + /// Replace the solid terrain (if any) at `(x, y)` with a transparent `Empty` + /// cell, revealing the floor beneath. pub fn clear_solid(&mut self, x: usize, y: usize) { - if self.in_bounds((x as i64, y as i64)) - && let Some(z) = self.solid_cell_layer(x, y) { - *self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty) - } + if self.in_bounds((x as i64, y as i64)) && self.get(x, y).1.behavior().solid { + *self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty); + } } - /// Returns the glyph to display at `(x, y)`, honoring layer draw order. + /// Returns the glyph to display at `(x, y)`. /// - /// 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: + /// With a single grid the draw order is a fixed precedence (no layer walk): /// - /// 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`). + /// 1. the player (drawn on top; not part of the grid yet); + /// 2. an object at the cell — a solid object always, otherwise the first + /// non-transparent non-solid object (`tile != 0`, so invisible objects exist); + /// 3. the grid cell `(glyph, arch)` — a solid always draws, a non-solid only when + /// visible (`tile != 0`); + /// 4. a portal at the cell (portals sit on a transparent grid cell); + /// 5. a [`decoration`](Board::decorations) at the cell (reached only because the + /// grid cell was empty); + /// 6. the [`floor`](Board::floor) glyph, if any; + /// 7. the canonical black `Empty` glyph. /// - /// 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 { - // The player is rendered above the whole stack (see the `Player` notes). + // The player is rendered above everything (see the `Player` notes). if self.player.x == x as i64 && self.player.y == y as i64 { 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); - } + // Objects: a solid object always draws; otherwise the first non-transparent + // non-solid object (lets invisible trigger objects exist). + let mut nonsolid: Option = None; + for o in self.objects.values().filter(|o| o.x == x && o.y == y) { + if o.solid { + return 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; + if nonsolid.is_none() && o.glyph.tile != 0 { + nonsolid = Some(o.glyph); } } + if let Some(g) = nonsolid { + return g; + } - // Nothing on any layer: the canonical black empty cell. - Archetype::Empty.default_glyph() + // The grid cell: a solid always draws; a non-solid only if visible. + let (glyph, arch) = self.get(x, y); + if arch.behavior().solid || glyph.tile != 0 { + return *glyph; + } + + // A portal sits on its (transparent) grid cell. + if self.portals.iter().any(|p| p.x == x && p.y == y) { + return PortalDef::default_glyph(); + } + + // The grid cell was empty: a decoration may show here. + if let Some(d) = self.decorations.iter().find(|d| d.x == x && d.y == y) { + return d.glyph; + } + + // Then the floor, else the canonical black empty cell. + self.floor + .glyph_at(x, y, self.width) + .unwrap_or_else(|| Archetype::Empty.default_glyph()) } /// Returns `true` if `(x, y)` is a valid cell coordinate on this board. @@ -218,20 +238,14 @@ impl Board { }; return Some(Solid::object_at(x, y, id, behavior)); } - // Otherwise some layer's terrain archetype may be solid (e.g. a wall). - if let Some(z) = self.solid_cell_layer(x, y) { - let (glyph, arch) = *self.get(z, x, y); - return Some(Solid::terrain_at(x, y, z, glyph, arch)); + // Otherwise the grid cell's terrain archetype may be solid (e.g. a wall). + let (glyph, arch) = *self.get(x, y); + if arch.behavior().solid { + return Some(Solid::terrain_at(x, y, glyph, arch)); } 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 `true` if a mover can enter `(x, y)` — i.e. no solid occupies it. /// /// Convenience inverse of [`solid_at`](Board::solid_at). @@ -381,10 +395,9 @@ impl Board { /// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`. /// - /// 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. + /// A solid object is relocated; otherwise the solid terrain archetype (a crate) + /// is moved, leaving a transparent cell behind so the floor shows through. The + /// caller guarantees the destination is already clear. fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) { let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize); let Some(solid) = self.solid_at(x, y) else { @@ -393,8 +406,8 @@ impl Board { // A terrain cell leaves a transparent cell behind (revealing any floor); the // player and objects carry no grid cell, so there is nothing to vacate. `place` // captured the glyph/arch, so clearing the source first is safe. - if let Some(z) = self.solid_cell_layer(x, y) { - *self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty); + if self.get(x, y).1.behavior().solid { + *self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty); } solid.place(self, tx, ty); } @@ -465,23 +478,19 @@ impl Board { /// drawing tools cannot place, remove, or alter a floor): /// /// - **Terrain** (`arch != Empty`, always solid today): removes any solid object - /// already in the cell, then writes `(glyph, arch)` into the cell's terrain. - /// - **Erase** (`arch == Empty`): removes the cell's terrain *and* every object in - /// it, leaving the floor (a visible `Empty` cell on a lower layer) in place. + /// already in the cell, then writes `(glyph, arch)` into the grid cell. + /// - **Erase** (`arch == Empty`): removes the grid cell's terrain *and* every + /// object in it, leaving the floor beneath in place. /// - /// Terrain is written to the cell's existing terrain layer (the single non-`Empty` - /// archetype across layers, if any) or else the top layer; a vacated terrain cell - /// becomes a transparent `Empty` so a lower floor shows through. Panics if `(x, y)` - /// is out of bounds. + /// A vacated grid cell becomes a transparent `Empty` so the floor shows through. + /// Panics if `(x, y)` is out of bounds. pub fn place_archetype(&mut self, x: usize, y: usize, arch: Archetype, glyph: Glyph) { if arch == Archetype::Empty { - // Erase: drop every object in the cell and clear its terrain (keep floor). + // Erase: drop every object in the cell and clear its grid cell (keep floor). for id in self.object_ids_at(x, y) { self.objects.remove(&id); } - if let Some(z) = self.terrain_layer_at(x, y) { - *self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty); - } + *self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty); return; } @@ -489,10 +498,7 @@ impl Board { if let Some(id) = self.solid_object_id_at(x, y) { self.objects.remove(&id); } - // Reuse the existing terrain layer if the cell already has terrain, else the - // top layer (so the new wall draws above any floor on a lower layer). - let z = self.terrain_layer_at(x, y).unwrap_or(self.layers.len() - 1); - *self.get_mut(z, x, y) = (glyph, arch); + *self.get_mut(x, y) = (glyph, arch); } /// Replaces every terrain cell whose archetype is script-backed (e.g. a @@ -509,26 +515,23 @@ impl Board { /// already loaded as objects are untouched, so it is safe to call more than once. pub fn expand_builtin_archetypes(&mut self) { use crate::builtin_scripts::builtin_tag; - // Collect first: the loop below mutates both layers and the object map. - let mut found: Vec<(usize, usize, usize, Glyph, Archetype)> = Vec::new(); - for z in 0..self.layers.len() { - for y in 0..self.height { - for x in 0..self.width { - let (glyph, arch) = *self.get(z, x, y); - if matches!(arch, Archetype::Builtin(_, _)) { - found.push((z, x, y, glyph, arch)); - } + // Collect first: the loop below mutates both the grid and the object map. + let mut found: Vec<(usize, usize, Glyph, Archetype)> = Vec::new(); + for y in 0..self.height { + for x in 0..self.width { + let (glyph, arch) = *self.get(x, y); + if matches!(arch, Archetype::Builtin(_, _)) { + found.push((x, y, glyph, arch)); } } } - for (z, x, y, glyph, arch) in found { - // Vacate the terrain cell (revealing any floor beneath), then spawn the + for (x, y, glyph, arch) in found { + // Vacate the grid cell (revealing any floor beneath), then spawn the // object — mirroring `resolve_entry`'s object template. - *self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty); + *self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty); let Archetype::Builtin(b, _alias) = arch else { continue }; let beh = b.behavior(); let mut obj = ObjectDef::new(x, y); - obj.z = z; obj.glyph = glyph; obj.solid = beh.solid; obj.opaque = beh.opaque; @@ -617,13 +620,6 @@ impl Board { vec![] } - /// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty` - /// (the cell's single terrain archetype, if any). By the one-solid-per-cell - /// invariant there is at most one such layer. - fn terrain_layer_at(&self, x: usize, y: usize) -> Option { - (0..self.layers.len()).find(|&z| self.get(z, x, y).1 != Archetype::Empty) - } - /// Clear the queues of all objects on this board: called when entering a board, objects /// don't retain their state across board visits (they get initialized again, but can /// store things in the board registry) @@ -638,20 +634,20 @@ impl Board { pub(crate) mod tests { use super::Board; use crate::archetype::{Archetype, Builtin}; + use crate::floor::Floor; use crate::glyph::Glyph; - use crate::layer::Layer; use crate::object_def::ObjectDef; use crate::utils::Direction; use crate::utils::{ObjectId, PlayerPos}; use color::Rgba8; use std::collections::{BTreeMap, HashMap}; - /// 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. + /// Builds an all-empty `w×h` board with the given player position and objects. + /// 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. + /// The grid is fully transparent (a blank floor), so terrain stamped via + /// [`crate_at`] etc. lands on the single grid. Use [`add_floor`] to give the + /// board a visible fixed floor underneath. pub(crate) fn open_board( w: usize, h: usize, @@ -669,9 +665,9 @@ pub(crate) mod tests { name: "test".into(), width: w, height: h, - layers: vec![Layer { - cells: vec![(Glyph::transparent(), Archetype::Empty); w * h], - }], + grid: vec![(Glyph::transparent(), Archetype::Empty); w * h], + floor: Floor::Blank, + decorations: Vec::new(), player: PlayerPos { x: player.0, y: player.1, @@ -685,42 +681,25 @@ pub(crate) mod tests { } } - /// Inserts a visible floor layer (filled with `glyph`) below everything, - /// bumping existing terrain and objects up one layer. + /// Gives the board a uniform fixed floor glyph (the single-grid replacement for + /// the old separate floor 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; - } + board.floor = Floor::Fixed(glyph); } - /// 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. + /// Stamps a crate cell onto the grid. pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) { - let z = top(board); - *board.get_mut(z, x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate); + *board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate); } - /// Stamps a wall cell onto the board's top layer. + /// Stamps a wall cell onto the grid. pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) { - let z = top(board); - *board.get_mut(z, x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall); + *board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall); } - /// Stamps an arbitrary archetype cell onto the board's top layer. + /// Stamps an arbitrary archetype cell onto the grid. pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) { - let z = top(board); - *board.get_mut(z, x, y) = (arch.default_glyph(), arch); + *board.get_mut(x, y) = (arch.default_glyph(), arch); } #[test] @@ -790,8 +769,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(0, 1, 0).1, Archetype::Crate); // no mutation - assert_eq!(board.get(0, 2, 0).1, Archetype::Empty); + assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation + assert_eq!(board.get(2, 0).1, Archetype::Empty); let mut board = open_board(3, 1, (0, 0), vec![]); crate_at(&mut board, 1, 0); @@ -808,7 +787,7 @@ pub(crate) mod tests { // Crate with open space ahead: shiftable. crate_at(&mut board, 0, 0); assert!(board.can_shift(0, 0, Direction::East)); - assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // read-only + assert_eq!(board.get(0, 0).1, Archetype::Crate); // read-only // Crate with another pushable crate ahead: still shiftable (unlike can_push, // which would follow the chain to the wall and fail). @@ -854,8 +833,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(0, 1, 0).1, Archetype::Empty); - assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); + assert_eq!(board.get(1, 0).1, Archetype::Empty); + assert_eq!(board.get(2, 0).1, Archetype::Crate); assert_eq!((board.player.x, board.player.y), (3, 0)); } @@ -867,7 +846,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(0, 1, 0).1, Archetype::Crate); + assert_eq!(board.get(1, 0).1, Archetype::Crate); assert_eq!((board.player.x, board.player.y), (2, 0)); } @@ -890,7 +869,7 @@ pub(crate) mod tests { a: 255, }, }; - // Floor on a lower layer, a wall on the top (terrain) layer at (0,0). + // A fixed floor attribute, a wall on the grid 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. @@ -898,29 +877,9 @@ pub(crate) mod tests { 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 place_wall_keeps_floor_and_removes_solid_object() { - // Floor on layer 0, terrain layer on top; a solid object sits at (1,0). + // A fixed floor attribute; a solid object sits on the grid at (1,0). let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]); let floor = Glyph { tile: '.' as u32, @@ -931,9 +890,8 @@ pub(crate) mod tests { let wall = Archetype::Wall.default_glyph(); board.place_archetype(1, 0, Archetype::Wall, wall); - // The wall landed on the terrain (top) layer; the floor below is untouched. - assert_eq!(board.get(1, 1, 0), &(wall, Archetype::Wall)); - assert_eq!(board.get(0, 1, 0).0, floor); + // The wall landed on the grid; the floor attribute is untouched. + assert_eq!(board.get(1, 0), &(wall, Archetype::Wall)); // The solid object that was there is gone. assert!(board.object_ids_at(1, 0).is_empty()); assert_eq!(board.glyph_at(1, 0), wall); @@ -941,17 +899,17 @@ pub(crate) mod tests { #[test] fn place_wall_overwrites_existing_terrain_in_place() { - // A crate already occupies the top layer at (1,0). + // A crate already occupies the grid at (1,0). let mut board = open_board(3, 1, (2, 0), vec![]); crate_at(&mut board, 1, 0); let wall = Archetype::Wall.default_glyph(); board.place_archetype(1, 0, Archetype::Wall, wall); - assert_eq!(board.get(0, 1, 0), &(wall, Archetype::Wall)); + assert_eq!(board.get(1, 0), &(wall, Archetype::Wall)); } #[test] fn erase_removes_terrain_and_objects_but_keeps_floor() { - // Floor, a wall on the terrain layer, and a (non-solid) object all at (1,0). + // A fixed floor attribute, a wall on the grid, and a (non-solid) object at (1,0). let mut obj = ObjectDef::new(1, 0); obj.solid = false; let mut board = open_board(3, 1, (2, 0), vec![obj]); @@ -964,13 +922,12 @@ pub(crate) mod tests { board.place_archetype(1, 0, Archetype::Empty, Glyph::transparent()); - // Terrain cleared to transparent Empty; object removed; floor still there. + // Grid cell cleared to transparent Empty; object removed; floor still there. assert_eq!( - board.get(1, 1, 0), + board.get(1, 0), &(Glyph::transparent(), Archetype::Empty) ); assert!(board.object_ids_at(1, 0).is_empty()); - assert_eq!(board.get(0, 1, 0).0, floor); assert_eq!(board.glyph_at(1, 0), floor); } @@ -990,7 +947,7 @@ pub(crate) mod tests { board.expand_builtin_archetypes(); // The terrain cell is vacated and a scripted object takes its place. - assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); + assert_eq!(board.get(0, 0).1, Archetype::Empty); let obj = board.objects.values().next().expect("spinner object"); assert_eq!((obj.x, obj.y), (0, 0)); assert!(obj.solid); @@ -1019,7 +976,7 @@ pub(crate) mod tests { crate_at(&mut board, 0, 0); let errs = board.apply_shift(&[(0, 0), (9, 0)]); assert_eq!(errs.len(), 1); - assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged + assert_eq!(board.get(0, 0).1, Archetype::Crate); // unchanged } #[test] @@ -1043,11 +1000,11 @@ pub(crate) mod tests { // Crate(3,0)→(4,0), Crate(4,0)→(0,0) wrap let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]); - assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // wrapped from (4,0) - assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // moved from (0,0) - assert_eq!(board.get(0, 2, 0).1, Archetype::Wall); // blocked, immobile - assert_eq!(board.get(0, 3, 0).1, Archetype::Empty); // cleared, crate moved - assert_eq!(board.get(0, 4, 0).1, Archetype::Crate); // moved from (3,0) + assert_eq!(board.get(0, 0).1, Archetype::Crate); // wrapped from (4,0) + assert_eq!(board.get(1, 0).1, Archetype::Crate); // moved from (0,0) + assert_eq!(board.get(2, 0).1, Archetype::Wall); // blocked, immobile + assert_eq!(board.get(3, 0).1, Archetype::Empty); // cleared, crate moved + assert_eq!(board.get(4, 0).1, Archetype::Crate); // moved from (3,0) } #[test] @@ -1066,9 +1023,9 @@ pub(crate) mod tests { let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]); - assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // HCrate moved out - assert_eq!(board.get(0, 1, 0).1, Archetype::HCrate); // moved from (0,0) - assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); // moved from (1,0) + assert_eq!(board.get(0, 0).1, Archetype::Empty); // HCrate moved out + assert_eq!(board.get(1, 0).1, Archetype::HCrate); // moved from (0,0) + assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0) } // Subcase B: VCrate stays; Crate behind it still moves @@ -1084,9 +1041,9 @@ pub(crate) mod tests { // Crate at (1,0) is NOT in blocked, so it moves. let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]); - assert_eq!(board.get(0, 0, 0).1, Archetype::VCrate); // immobile - assert_eq!(board.get(0, 1, 0).1, Archetype::Empty); // crate moved out - assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); // moved from (1,0) + assert_eq!(board.get(0, 0).1, Archetype::VCrate); // immobile + assert_eq!(board.get(1, 0).1, Archetype::Empty); // crate moved out + assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0) } } @@ -1105,9 +1062,9 @@ pub(crate) mod tests { let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]); - assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // VCrate moved out - assert_eq!(board.get(0, 0, 1).1, Archetype::VCrate); // moved from (0,0) - assert_eq!(board.get(0, 0, 2).1, Archetype::Crate); // moved from (0,1) + assert_eq!(board.get(0, 0).1, Archetype::Empty); // VCrate moved out + assert_eq!(board.get(0, 1).1, Archetype::VCrate); // moved from (0,0) + assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1) } // Subcase B: HCrate stays; Crate behind it still moves @@ -1123,9 +1080,9 @@ pub(crate) mod tests { // Crate at (0,1) is NOT in blocked, so it moves. let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]); - assert_eq!(board.get(0, 0, 0).1, Archetype::HCrate); // immobile - assert_eq!(board.get(0, 0, 1).1, Archetype::Empty); // crate moved out - assert_eq!(board.get(0, 0, 2).1, Archetype::Crate); // moved from (0,1) + assert_eq!(board.get(0, 0).1, Archetype::HCrate); // immobile + assert_eq!(board.get(0, 1).1, Archetype::Empty); // crate moved out + assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1) } } diff --git a/kiln-core/src/floor.rs b/kiln-core/src/floor.rs index 777bfd5..29ef175 100644 --- a/kiln-core/src/floor.rs +++ b/kiln-core/src/floor.rs @@ -12,7 +12,63 @@ use crate::glyph::Glyph; use color::Rgba8; -use tinyrand::{Probability, Rand, StdRand}; +use tinyrand::{Probability, Rand, Seeded, StdRand}; + +/// A board's floor: the cosmetic backdrop drawn beneath everything, replacing the +/// old dedicated floor *layer*. A board has exactly one [`Floor`] (see +/// [`Board::floor`](crate::board::Board)), given in the map file's `[map]` header +/// as an optional `floor = { … }` attribute. +/// +/// Three forms: [`Blank`](Floor::Blank) (the canonical empty/black cell shows +/// through), [`Fixed`](Floor::Fixed) (one glyph tiled across the whole board), or +/// [`Biome`](Floor::Biome) (a procedural [`FloorGenerator`] texture). A biome keeps +/// its generator (so save re-emits the generator name) alongside a per-cell glyph +/// buffer pre-rolled once at load from [`FLOOR_SEED`] — deterministic, and the +/// direct replacement for the old per-cell floor-layer rolling. +#[derive(Clone)] +pub enum Floor { + /// No floor: the canonical black empty cell shows. + Blank, + /// A single fixed glyph tiled across the whole board. + Fixed(Glyph), + /// A procedural biome floor: the `generator` (retained for save) plus a + /// `glyphs` buffer holding one pre-rolled glyph per cell (row-major). + Biome { + /// The generator this biome was built from; re-emitted on save. + generator: FloorGenerator, + /// One pre-rolled glyph per cell (`width * height`, row-major). + glyphs: Vec, + }, +} + +impl Default for Floor { + /// A board with no declared floor is [`Floor::Blank`]. + fn default() -> Self { + Floor::Blank + } +} + +impl Floor { + /// Builds a [`Floor::Biome`] for a `width × height` board, pre-rolling one glyph + /// per cell from a [`FLOOR_SEED`]-seeded PRNG (so the result is deterministic and + /// depends only on the board dimensions + generator). + pub(crate) fn biome(generator: FloorGenerator, width: usize, height: usize) -> Floor { + let mut rng = StdRand::seed(FLOOR_SEED); + let glyphs = (0..width * height).map(|_| generator.generate(&mut rng)).collect(); + Floor::Biome { generator, glyphs } + } + + /// The floor glyph to draw at `(x, y)`, or `None` for [`Floor::Blank`]. + /// + /// `width` is the board width, needed to index a biome's row-major buffer. + pub(crate) fn glyph_at(&self, x: usize, y: usize, width: usize) -> Option { + match self { + Floor::Blank => None, + Floor::Fixed(g) => Some(*g), + Floor::Biome { glyphs, .. } => glyphs.get(y * width + x).copied(), + } + } +} /// Fixed seed for the floor PRNG, so a board's generated floor is deterministic /// for a given map (stable across reloads within a run, and testable). The layer @@ -46,6 +102,16 @@ impl FloorGenerator { } } + /// The generator's map-file name (inverse of [`from_name`](FloorGenerator::from_name)), + /// re-emitted on save so a biome floor round-trips. + pub fn name(&self) -> &'static str { + match self { + FloorGenerator::Grass => "grass", + FloorGenerator::Dirt => "dirt", + FloorGenerator::Stone => "stone", + } + } + /// Generates one random floor [`Glyph`] for this generator. /// /// Picks a background ground color within the generator's scheme, then with diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index b25fe7a..b871c49 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -135,7 +135,7 @@ impl GameState { board_transition: None, player }; - state.drain_errors(); + state.drain_log(); state } @@ -208,7 +208,7 @@ impl GameState { // Fire any bump/send reactions, then flush errors. let mut called = CalledSet::new(); self.settle(ev, &mut called); - self.drain_errors(); + self.drain_log(); } /// Advances real-time game state by `dt` (the elapsed time since the last tick). @@ -238,15 +238,16 @@ impl GameState { // Fire the bump/send reactions those ticks triggered, then flush errors. let mut called = CalledSet::new(); self.settle(ev, &mut called); - self.drain_errors(); + self.drain_log(); } - /// Drains errors collected by the script host into the game log. - fn drain_errors(&mut self) { + /// Drains the log lines collected by the script host — script `log()` output + /// plus engine/runtime errors — into the game log. + fn drain_log(&mut self) { // TODO: errors are only logged for now. This is the place to halt execution / // set an error state when a script faults. - let errors = self.scripts.take_errors(); - self.log.extend(errors); + let lines = self.scripts.take_logs(); + self.log.extend(lines); } /// Applies one object's drained `actions` to the board and returns the `bump` @@ -259,9 +260,12 @@ impl GameState { /// reactions are returned rather than fired here, so the caller can run them once /// all object hooks in this pass have applied. fn apply_actions(&mut self, actions: Vec) -> Events { - // Logs are collected here rather than pushed inline, since the board borrow - // below also borrows `self`. - let mut logs: Vec = Vec::new(); + // Application-time errors (teleport/push/shift failures) go straight onto + // the shared LogSink — the same immediate channel as script `log()` output, + // so everything lands in the log in one emission order and is flushed by + // `drain_log`. A cheap Rc clone lets us push while the board borrow (which + // also borrows `self`) is held. + let log_sink = self.scripts.log_sink().clone(); let mut bumps: Vec<(ObjectId, Direction)> = Vec::new(); // Net change to player stats from AddGems / AlterHealth actions; applied // to `self` after the board borrow drops. @@ -289,7 +293,6 @@ impl GameState { obj.glyph.tile = tile; } } - Action::Log(line) => logs.push(line), Action::SetTag { target, tag, @@ -340,9 +343,9 @@ impl GameState { } Action::Teleport { target, x, y } => { if !board.in_bounds((x, y)) { - logs.push(LogLine::error(format!( + log_sink.error(format!( "teleport({target},{x},{y}): out of bounds" - ))); + )); } else if target == -1 { // Move the player. A solid *other than the player itself* // blocks the destination. @@ -352,9 +355,9 @@ impl GameState { Some(s) if !s.player() ); if blocked { - logs.push(LogLine::error(format!( + log_sink.error(format!( "teleport(player,{x},{y}): destination is solid" - ))); + )); } else { board.player.x = ux as i64; board.player.y = uy as i64; @@ -364,9 +367,9 @@ impl GameState { // a solid mover, unless the occupant is that same object. let (ux, uy) = (x as usize, y as usize); match board.objects.get(&tid) { - None => logs.push(LogLine::error(format!( + None => log_sink.error(format!( "teleport({target},{x},{y}): no such object" - ))), + )), Some(obj) => { let source_solid = obj.solid; let blocked = source_solid @@ -375,9 +378,9 @@ impl GameState { Some(s) if s.object_id() != Some(tid) ); if blocked { - logs.push(LogLine::error(format!( + log_sink.error(format!( "teleport({target},{x},{y}): destination is solid" - ))); + )); } else if let Some(obj) = board.objects.get_mut(&tid) { obj.x = ux; obj.y = uy; @@ -385,22 +388,24 @@ impl GameState { } } } else { - logs.push(LogLine::error(format!( + log_sink.error(format!( "teleport({target},{x},{y}): invalid target id" - ))); + )); } } // push() self-checks can_push, so an in-bounds guard is all we add. Action::Push { x, y, dir } => { if !board.in_bounds((x, y)) { - logs.push(LogLine::error(format!("push({x},{y}): out of bounds"))); + log_sink.error(format!("push({x},{y}): out of bounds")); } else { board.push(x as usize, y as usize, dir); } } - // apply_shift moves the named cells. + // apply_shift moves the named cells, returning any error lines. Action::Shift(cells) => { - logs.extend(board.apply_shift(&cells)); + for line in board.apply_shift(&cells) { + log_sink.line(line); + } } // Accumulated and applied to `self.player.gems` after the borrow drops. Action::AddGems(n) => gem_delta += n, @@ -415,7 +420,6 @@ impl GameState { } } } - self.log.extend(logs); for bubble in new_bubbles { // One bubble per object: replace the existing one if present. self.speech_bubbles @@ -435,7 +439,7 @@ impl GameState { } for (color, present) in key_changes { if !self.player.borrow_mut().keys.set_by_name(&color, present) { - self.log.push(LogLine::error(format!("set_key: unknown color {color:?}"))); + log_sink.error(format!("set_key: unknown color {color:?}")); } } // Return the reactions for the caller's settle pass rather than firing them here. @@ -490,7 +494,7 @@ impl GameState { let ev = self.apply_actions(actions); let mut called = CalledSet::new(); self.settle(ev, &mut called); - self.drain_errors(); + self.drain_log(); } } @@ -612,7 +616,7 @@ impl GameState { // Settle the grab/bump reactions (and any they cascade into) before returning. let mut called = CalledSet::new(); self.settle(ev, &mut called); - self.drain_errors(); + self.drain_log(); } } @@ -724,8 +728,8 @@ mod tests { ); game.tick(Duration::from_millis(16)); let b = game.board(); - assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); - assert_eq!(b.get(0, 3, 0).1, Archetype::Crate); + assert_eq!(b.get(2, 0).1, Archetype::Empty); + assert_eq!(b.get(3, 0).1, Archetype::Crate); } #[test] @@ -754,6 +758,29 @@ mod tests { assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]); } + #[test] + fn log_is_immediate_and_not_paced_by_the_queue() { + // `delay(5.0)` parks the object's action queue for 5 seconds, then `log()` + // fires. Because logging bypasses the queue entirely, the line must appear + // right after run_init (dt = 0) — under the old queued-Action behavior it + // would have been stuck behind the delay and absent here. + let mut obj = ObjectDef::new(0, 0); + obj.solid = false; + obj.script_name = Some("s".to_string()); + let board = open_board(4, 1, (3, 0), vec![obj]); + let src = "fn init(m) { delay(5.0); log(\"immediate\"); }"; + let scripts = HashMap::from([("s".to_string(), src.to_string())]); + let mut game = GameState::with_scripts(board, scripts); + game.run_init(); + + assert!( + game.log + .iter() + .any(|l| l.spans.iter().any(|s| s.text == "immediate")), + "log() should hit the game log immediately, even behind a delay" + ); + } + /// Builds a 3×3 board with a non-solid clockwise spinner object (running the /// real `scripts/spinner.rhai`) at the centre and the player parked on it. fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState { @@ -797,9 +824,9 @@ mod tests { let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]); game.tick(Duration::from_millis(16)); let b = game.board(); - assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N kept - assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed) - assert_eq!(b.get(0, 2, 1).1, Archetype::Wall); // wall kept + assert_eq!(b.get(1, 0).1, Archetype::Crate); // N kept + assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE kept (not destroyed) + assert_eq!(b.get(2, 1).1, Archetype::Wall); // wall kept } #[test] @@ -809,10 +836,10 @@ mod tests { let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]); game.tick(Duration::from_millis(16)); let b = game.board(); - assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE: filled by N - assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW - assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W - assert_eq!(b.get(0, 0, 1).1, Archetype::Empty); // W: vacated (hole moved here) + assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE: filled by N + assert_eq!(b.get(1, 0).1, Archetype::Crate); // N: filled by NW + assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: filled by W + assert_eq!(b.get(0, 1).1, Archetype::Empty); // W: vacated (hole moved here) } #[test] @@ -822,9 +849,9 @@ mod tests { let mut game = spinner_board_dir(&[(1, 0)], &[], true); game.tick(Duration::from_millis(16)); let b = game.board(); - assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise - assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched - assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated + assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise + assert_eq!(b.get(2, 0).1, Archetype::Empty); // NE: untouched + assert_eq!(b.get(1, 0).1, Archetype::Empty); // N: vacated } #[test] diff --git a/kiln-core/src/glyph.rs b/kiln-core/src/glyph.rs index 4b80604..8e81f14 100644 --- a/kiln-core/src/glyph.rs +++ b/kiln-core/src/glyph.rs @@ -2,7 +2,7 @@ use color::Rgba8; use std::hash::{Hash, Hasher}; use rhai::Engine; use crate::script::Registerable; -use crate::utils::ErrorSink; +use crate::utils::LogSink; /// The visual representation of a single board cell. /// @@ -36,7 +36,7 @@ impl Hash for Glyph { } impl Registerable for Glyph { - fn register(engine: &mut Engine, _error_sink: ErrorSink) { + fn register(engine: &mut Engine, _log_sink: LogSink) { engine.register_type_with_name::("Glyph"); engine.register_get("tile", |g: &mut Glyph| g.tile); engine.register_get("fg", |g: &mut Glyph| { diff --git a/kiln-core/src/keys.rs b/kiln-core/src/keys.rs index 08fab12..f19495f 100644 --- a/kiln-core/src/keys.rs +++ b/kiln-core/src/keys.rs @@ -2,7 +2,7 @@ use color::Rgba8; use rhai::Engine; use crate::glyph::Glyph; use crate::script::Registerable; -use crate::utils::ErrorSink; +use crate::utils::LogSink; #[derive(Copy, Clone, Debug)] pub enum KeyType { @@ -88,7 +88,7 @@ impl Keyring { } impl Registerable for Keyring { - fn register(engine: &mut Engine, _error_sink: ErrorSink) { + fn register(engine: &mut Engine, _log_sink: LogSink) { engine.register_type_with_name::("Keyring") .register_get("red", |keyring: &mut Keyring| keyring.red) .register_get("orange", |keyring: &mut Keyring| keyring.orange) diff --git a/kiln-core/src/layer.rs b/kiln-core/src/layer.rs index eb7bd4e..22790d6 100644 --- a/kiln-core/src/layer.rs +++ b/kiln-core/src/layer.rs @@ -1,54 +1,37 @@ -//! Palette layers: the unit of the map-file format and the board's draw stack. +//! The board grid: the palette+char map-file unit and its load-time conversion. //! -//! 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". +//! A board is a single **grid** — a character grid plus a palette mapping each +//! character to one *kind* of thing: an archetype (terrain), a scripted object, a +//! portal, or the player. (The board's cosmetic floor is a separate `[map]` +//! attribute, not a grid cell; see [`crate::floor`].) //! -//! 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`]. +//! This module owns the grid serde type ([`GridData`], [`PaletteEntry`]) and the +//! load-time conversion ([`build_grid`]) that turns one `GridData` into the board's +//! `Vec<(Glyph, Archetype)>` cells plus a list of [`Placement`]s (objects/portals/ +//! player) for the map loader to resolve. Cross-cell 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. -#[derive(Clone)] -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 plus its palette. +/// Serde representation of the board `[grid]`: a char grid plus its palette. /// /// The grid is given in exactly one of three ways (precedence: `content`, then /// `fill`, then `sparse`; none of them ⇒ an all-spaces grid): /// - `content` — a multi-line grid string, one char per cell (`width × height`). -/// - `fill` — a single character; the whole grid is filled with it (handy for a -/// layer of identical floor). +/// - `fill` — a single character; the whole grid is filled with it. /// - `sparse` — a list of `{ x, y, ch }` cells over an otherwise all-spaces grid -/// (handy for a layer holding just a few objects). +/// (handy for a grid holding just a few things). /// /// A space (`' '`) is always a transparent empty cell and is never a palette key /// (any `" "` entry in `palette` is ignored). -#[derive(Deserialize, Serialize)] -pub(crate) struct LayerData { +#[derive(Deserialize, Serialize, Default)] +pub(crate) struct GridData { /// Multi-line grid string; one char per cell, looked up in `palette`. #[serde(default, skip_serializing_if = "Option::is_none")] pub content: Option, @@ -63,7 +46,7 @@ pub(crate) struct LayerData { pub palette: HashMap, } -/// One cell in a [`LayerData::sparse`] list: a single character at `(x, y)`. +/// One cell in a [`GridData::sparse`] list: a single character at `(x, y)`. #[derive(Deserialize, Serialize, Clone)] pub(crate) struct SparseCell { /// Column (0-indexed). @@ -78,24 +61,21 @@ pub(crate) struct SparseCell { /// /// 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`]. +/// meta-kinds `empty`, `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`. + /// What this entry is: an archetype name, or `empty`/`object`/`portal`/`player`. pub kind: String, - /// Tile index (int or single-char string). Used by archetype/floor/object kinds. + /// Tile index (int or single-char string). Used by archetype/object kinds. #[serde(default, skip_serializing_if = "Option::is_none")] pub tile: Option, - /// Foreground `"#RRGGBB"`. Used by archetype/floor/object kinds. + /// Foreground `"#RRGGBB"`. Used by archetype/object kinds. #[serde(default, skip_serializing_if = "Option::is_none")] pub fg: Option, - /// Background `"#RRGGBB"`. Used by archetype/floor/object kinds. + /// Background `"#RRGGBB"`. Used by archetype/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, @@ -122,11 +102,14 @@ pub(crate) struct PaletteEntry { pub target_entry: Option, } -/// A non-terrain thing a layer places at a grid cell, resolved by the map loader. +/// A single grid cell: its visual and its behavioral class. +pub(crate) type GridCell = (Glyph, Archetype); + +/// A non-terrain thing the grid places at a 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. +/// Terrain goes straight into the grid cells; these need cross-cell handling (ids, +/// name uniqueness, the single player), so [`build_grid`] 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), @@ -136,7 +119,7 @@ pub(crate) enum Placement { Player(usize, usize), } -/// A resolved object definition minus board-assigned fields (`id`, `z`). +/// A resolved object definition minus board-assigned fields (`id`). #[derive(Clone)] pub(crate) struct ObjectTemplate { pub glyph: Glyph, @@ -148,7 +131,7 @@ pub(crate) struct ObjectTemplate { pub name: Option, } -/// A resolved portal definition minus `(x, y, z)`. +/// A resolved portal definition minus `(x, y)`. #[derive(Clone)] pub(crate) struct PortalTemplate { pub name: String, @@ -159,10 +142,8 @@ pub(crate) struct PortalTemplate { /// 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). + /// A fixed cell written straight into the grid (terrain, 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. @@ -173,10 +154,10 @@ enum Resolved { /// 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. +/// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `portal` +/// missing required fields falls back to a transparent cell (the grid char is +/// still consumed). Object/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 { @@ -186,27 +167,8 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec) -> Resol }; match e.kind.as_str() { - // Transparent: lower layers show through here. + // Transparent: the floor / a lower thing shows 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), @@ -248,7 +210,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec) -> Resol } } -/// Resolves a layer's grid to a `height × width` matrix of chars from whichever of +/// Resolves the grid to a `height × width` matrix of chars from whichever of /// `content` / `fill` / `sparse` is supplied (in that precedence; none ⇒ all spaces). /// /// Only an explicit `content` can mismatch the board dimensions — the single hard @@ -256,7 +218,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec) -> Resol /// `fill`/`ch` or an out-of-bounds `sparse` cell is recorded on `errors` and the /// offending cell falls back to (or stays) a space. fn grid_chars( - data: &LayerData, + data: &GridData, width: usize, height: usize, errors: &mut Vec, @@ -279,7 +241,7 @@ fn grid_chars( let rows: Vec<&str> = content.lines().collect(); if rows.len() != height { return Err(format!( - "layer grid has {} rows but the board is {height} tall", + "grid has {} rows but the board is {height} tall", rows.len() )); } @@ -288,7 +250,7 @@ fn grid_chars( let row: Vec = line.chars().collect(); if row.len() != width { return Err(format!( - "layer grid row {i} has {} characters but the board is {width} wide", + "grid row {i} has {} characters but the board is {width} wide", row.len() )); } @@ -301,7 +263,7 @@ fn grid_chars( // A whole grid of one character. let ch = single_char( fill, - format!("layer fill must be a single character (got {fill:?}); using a space"), + format!("grid fill must be a single character (got {fill:?}); using a space"), errors, ) .unwrap_or(' '); @@ -333,23 +295,19 @@ fn grid_chars( Ok(grid) } -/// Builds one [`Layer`] from its [`LayerData`], plus the non-terrain placements it -/// contains (with their `(x, y)`). +/// Builds the board's grid cells from its [`GridData`], 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, +/// Returns `Err` only on a grid-dimension mismatch (the single hard error); +/// every other problem is recorded on `errors`. +pub(crate) fn build_grid( + data: &GridData, 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). - // Space is always a transparent empty cell, so it is never a palette key — any - // `" "` entry is ignored. +) -> Result<(Vec, Vec), String> { + // Resolve each palette entry once. Space is always a transparent empty cell, so + // it is never a palette key — any `" "` entry is ignored. let resolved: HashMap = data .palette .iter() @@ -363,7 +321,7 @@ pub(crate) fn build_layer( let grid = grid_chars(data, width, height, errors)?; // Walk the grid, filling cells and collecting placements. - let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height); + let mut cells: Vec = Vec::with_capacity(width * height); let mut placements: Vec = Vec::new(); for (y, row) in grid.iter().enumerate() { for (x, &ch) in row.iter().enumerate() { @@ -374,7 +332,6 @@ pub(crate) fn build_layer( } 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)); @@ -397,5 +354,5 @@ pub(crate) fn build_layer( } } - Ok((Layer { cells }, placements)) + Ok((cells, placements)) } diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 0f9bffc..a38df8f 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -16,30 +16,37 @@ use std::collections::hash_map::Entry; use std::collections::{BTreeMap, HashMap, HashSet}; use std::convert::TryFrom; use std::path::Path; -use tinyrand::{Seeded, StdRand}; use crate::api::queue::ObjQueue; use crate::archetype::Archetype; -use crate::board::Board; +use crate::board::{Board, Decoration}; use crate::builtin_scripts::archetype_from_builtin_tag; -use crate::floor::FLOOR_SEED; +use crate::floor::{Floor, FloorGenerator}; use crate::glyph::Glyph; -use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer}; +use crate::layer::{GridData, PaletteEntry, Placement, build_grid}; use crate::log::LogLine; use crate::object_def::ObjectDef; use crate::utils::{ObjectId, PlayerPos, PortalDef}; -/// The serde shell for one board in a `.toml` file: a header and its layers. +/// The serde shell for one board in a `.toml` file: a header, the single grid, and +/// the off-grid trigger/decoration lists. /// /// 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]` header: name, dimensions, optional board script. + /// The `[map]` header: name, dimensions, floor, optional board script. pub map: MapHeader, - /// The ordered `[[layers]]` stack, bottom (index 0) to top. + /// The single `[grid]`: palette + char map for all solids and most non-solids. #[serde(default)] - pub(crate) layers: Vec, + pub(crate) grid: GridData, + /// Invisible, non-solid, script-only objects (`[[triggers]]`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) triggers: Vec, + /// Non-solid `(glyph, archetype)` cells drawn only where the grid is empty + /// (`[[decorations]]`). Normally absent; used by save files. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) decorations: Vec, } /// The `[map]` header section of a board. @@ -47,15 +54,107 @@ pub struct MapFile { pub struct MapHeader { /// Human-readable name for this board, e.g. `"Opening Room"`. pub name: String, - /// Width of the board in cells. Must match every layer row's length. + /// Width of the board in cells. Must match the grid row length. pub width: usize, - /// Height of the board in cells. Must match every layer's row count. + /// Height of the board in cells. Must match the grid row count. pub height: usize, + /// The board's optional cosmetic floor. Absent ⇒ blank; see [`FloorSpec`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub floor: Option, /// 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, } +/// Serde form of the board floor attribute (`floor = { … }` in `[map]`). +/// +/// Resolves (in [`FloorSpec::resolve`]) to a [`Floor`]: a `generator` name gives a +/// biome, otherwise any of `tile`/`fg`/`bg` gives a single fixed glyph, and an +/// empty spec is blank. +#[derive(Deserialize, Serialize, Clone)] +pub struct FloorSpec { + /// Procedural biome name (`"grass"`/`"dirt"`/`"stone"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generator: Option, + /// Fixed-glyph tile index (int or single-char string). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tile: Option, + /// Fixed-glyph foreground `"#RRGGBB"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fg: Option, + /// Fixed-glyph background `"#RRGGBB"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bg: Option, +} + +impl FloorSpec { + /// Resolves this spec to a [`Floor`] for a `width × height` board, recording a + /// nonfatal error (and falling back to [`Floor::Blank`]) for an unknown generator. + fn resolve(&self, width: usize, height: usize, errors: &mut Vec) -> Floor { + if let Some(name) = &self.generator { + return match FloorGenerator::from_name(name) { + Some(g) => Floor::biome(g, width, height), + None => { + errors.push(LogLine::error(format!( + "floor names unknown generator '{name}'; using blank floor" + ))); + Floor::Blank + } + }; + } + // A fixed glyph if any visual field is given, else a blank floor. + if self.tile.is_some() || self.fg.is_some() || self.bg.is_some() { + Floor::Fixed(Glyph { + tile: self.tile.map(TileIndex::into_u32).unwrap_or(32), + fg: self.fg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }), + bg: self.bg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }), + }) + } else { + Floor::Blank + } + } +} + +/// Serde form of one `[[triggers]]` entry: an invisible, non-solid, script-only +/// object at `(x, y)`. Triggers are folded into [`Board::objects`] at load; they +/// are re-emitted here on save (recognised as scripted, non-solid, glyphless). +#[derive(Deserialize, Serialize, Clone)] +pub(crate) struct TriggerSpec { + /// Column (0-indexed). + pub x: usize, + /// Row (0-indexed). + pub y: usize, + /// Name of the Rhai script (in `[scripts]`) this trigger runs. + pub script_name: String, + /// Optional board-unique name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Optional open-ended labels. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} + +/// Serde form of one `[[decorations]]` entry: a non-solid `(glyph, archetype)` at +/// `(x, y)`, drawn only where the grid cell is empty. A solid archetype is rejected. +#[derive(Deserialize, Serialize, Clone)] +pub(crate) struct DecorationSpec { + /// Column (0-indexed). + pub x: usize, + /// Row (0-indexed). + pub y: usize, + /// Archetype name (or `"empty"`); must be non-solid. + pub kind: String, + /// Tile index (int or single-char string). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tile: Option, + /// Foreground `"#RRGGBB"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fg: Option, + /// Background `"#RRGGBB"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bg: Option, +} + /// A tile index in a palette entry: either a plain integer or a character literal. /// /// Accepting both forms lets map files write `tile = 35` or `tile = "#"` (the char @@ -104,13 +203,16 @@ pub(crate) fn color_to_hex(color: Rgba8) -> String { /// Converts a parsed map file into a runtime [`Board`]. /// -/// Builds each layer (collecting its object/portal/player placements), then runs -/// the cross-layer validations that span the whole board: +/// Builds the single grid (collecting object/portal/player placements), resolves +/// the floor, then runs the cross-cell 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); +/// - at most one solid may occupy a cell (a conflicting solid object is dropped); /// - object names must be board-unique (a duplicate is cleared) and portal names unique (a duplicate is dropped). /// -/// Returns `Err` only when a layer's grid dimensions disagree with the header. +/// Finally the `[[triggers]]` load as non-solid glyphless objects and the +/// `[[decorations]]` as off-grid non-solid cells. +/// +/// Returns `Err` only when the grid dimensions disagree with the header. impl TryFrom for Board { type Error = String; @@ -119,30 +221,28 @@ impl TryFrom for Board { let h = mf.map.height; let mut load_errors: Vec = Vec::new(); - // One PRNG for the whole board so generated floors depend only on content. - let mut rng = StdRand::seed(FLOOR_SEED); - - // 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(); + // Build the single grid, collecting non-terrain placements. + let (mut grid, placements) = build_grid(&mf.grid, w, h, &mut load_errors)?; + let mut object_specs: Vec<(crate::layer::ObjectTemplate, usize, usize)> = Vec::new(); + let mut portal_specs: Vec<(crate::layer::PortalTemplate, 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)), - } + for p in placements { + match p { + Placement::Object(t, x, y) => object_specs.push((t, x, y)), + Placement::Portal(t, x, y) => portal_specs.push((t, x, y)), + Placement::Player(x, y) => player_positions.push((x, y)), } - layers.push(layer); - } - if layers.is_empty() { - return Err("map has no [[layers]]".into()); } - // The player must be placed exactly once across all layers. + // Resolve the cosmetic floor attribute (blank / fixed glyph / biome). + let floor = mf + .map + .floor + .as_ref() + .map(|f| f.resolve(w, h, &mut load_errors)) + .unwrap_or(Floor::Blank); + + // The player must be placed exactly once. let (px, py) = match player_positions.len() { 1 => player_positions[0], 0 => { @@ -153,50 +253,34 @@ impl TryFrom for Board { } n => { load_errors.push(LogLine::error(format!( - "player cell appears {n} times across layers; using the first" + "player cell appears {n} times; using the first" ))); player_positions[0] } }; 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. + // Track which cells hold a solid (the grid's own solids seed the map). 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; - } + for (idx, cell) in grid.iter().enumerate() { + if cell.1.behavior().solid { + 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] { - for layer in &mut layers { - if layer.cells[pidx].1.behavior().solid { - layer.cells[pidx] = (Glyph::transparent(), Archetype::Empty); - } - } + // The player wins its cell: clear any solid terrain under it and claim the + // cell so a solid object placed here is dropped below. + if grid[pidx].1.behavior().solid { + grid[pidx] = (Glyph::transparent(), Archetype::Empty); } solid_occupied[pidx] = true; - // Spawn objects in layer-then-reading order, so ids are deterministic and - // "lowest id wins a collision" / "first claimant keeps the name" hold. + // Spawn objects in 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; let mut seen_names: HashMap = HashMap::new(); - for (t, x, y, z) in object_specs { + for (t, x, y) 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] { @@ -210,19 +294,7 @@ impl TryFrom for Board { } 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) - } - Entry::Occupied(o) => { - load_errors.push(LogLine::error(format!( - "object name {n:?} already used by object {}; clearing name", - o.get() - ))); - None - } - }); + let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors)); if t.solid { solid_occupied[idx] = true; } @@ -232,7 +304,6 @@ impl TryFrom for Board { id, x, y, - z, glyph: t.glyph, solid: t.solid, opaque: t.opaque, @@ -252,10 +323,44 @@ impl TryFrom for Board { next_object_id += 1; } + // Triggers: invisible, non-solid, script-only objects. They join the same + // `objects` map (so ids/name-uniqueness/script dispatch all apply), after the + // hand-placed grid objects. + for t in mf.triggers { + if !t.x.lt(&w) || !t.y.lt(&h) { + load_errors.push(LogLine::error(format!( + "trigger at ({}, {}) is out of bounds; skipping", + t.x, t.y + ))); + continue; + } + let id = next_object_id; + let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors)); + objects.insert( + id, + ObjectDef { + id, + x: t.x, + y: t.y, + glyph: Glyph::transparent(), + solid: false, + opaque: false, + pushable: false, + grab: false, + script_name: Some(t.script_name), + builtin_script: None, + tags: t.tags.unwrap_or_default().into_iter().collect(), + queue: ObjQueue::new(), + name, + }, + ); + next_object_id += 1; + } + // 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 (t, x, y, z) in portal_specs { + for (t, x, y) in portal_specs { if !seen_portal_names.insert(t.name.clone()) { load_errors.push(LogLine::error(format!( "portal name {:?} already used by another portal; skipping portal", @@ -267,17 +372,27 @@ impl TryFrom for Board { name: t.name, x, y, - z, target_map: t.target_map, target_entry: t.target_entry, }); } + // Decorations: non-solid off-grid cells (a solid archetype is rejected). + let mut decorations: Vec = Vec::new(); + for d in mf.decorations { + match resolve_decoration(&d) { + Ok(dec) => decorations.push(dec), + Err(msg) => load_errors.push(LogLine::error(msg)), + } + } + let mut board = Board { name: mf.map.name, width: w, height: h, - layers, + grid, + floor, + decorations, player: PlayerPos { x: px as i64, y: py as i64, @@ -290,13 +405,64 @@ impl TryFrom for Board { registry: HashMap::new(), }; // Turn script-backed archetype cells (pushers/spinners) into their scripted - // objects. Runs after the cross-layer validation above, so board invariants + // objects. Runs after the cross-cell validation above, so board invariants // hold; the same call also fixes editor-placed machines before a playtest. board.expand_builtin_archetypes(); Ok(board) } } +/// Claims `name` for object `id` in `seen_names`, returning `Some(name)` for the +/// first claimant and `None` (with a logged error) for any later duplicate. +fn claim_name( + n: String, + id: ObjectId, + seen_names: &mut HashMap, + errors: &mut Vec, +) -> Option { + match seen_names.entry(n.clone()) { + Entry::Vacant(v) => { + v.insert(id); + Some(n) + } + Entry::Occupied(o) => { + errors.push(LogLine::error(format!( + "object name {n:?} already used by object {}; clearing name", + o.get() + ))); + None + } + } +} + +/// Resolves a [`DecorationSpec`] into a [`Decoration`], erroring if its archetype is +/// unknown or solid (decorations must be non-solid). +fn resolve_decoration(d: &DecorationSpec) -> Result { + let arch = if d.kind == "empty" { + Archetype::Empty + } else { + Archetype::try_from(d.kind.as_str()) + .map_err(|msg| format!("decoration at ({}, {}): {msg}; skipping", d.x, d.y))? + }; + if arch.behavior().solid { + return Err(format!( + "decoration at ({}, {}) has solid archetype {:?}; skipping", + d.x, d.y, d.kind + )); + } + let default = arch.default_glyph(); + Ok(Decoration { + x: d.x, + y: d.y, + glyph: Glyph { + tile: d.tile.map(TileIndex::into_u32).unwrap_or(default.tile), + fg: d.fg.as_deref().map(parse_color).unwrap_or(default.fg), + bg: d.bg.as_deref().map(parse_color).unwrap_or(default.bg), + }, + archetype: arch, + }) +} + /// 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. @@ -310,24 +476,15 @@ fn char_pool() -> Vec { pool } -/// Builds the [`PaletteEntry`] for a terrain/floor cell `(glyph, arch)`. +/// Builds the [`PaletteEntry`] for a grid terrain cell `(glyph, arch)`. +/// +/// A grid `Empty` cell is always transparent now (floors are a board attribute), +/// so it maps to `kind = "empty"`; anything else is its archetype keyword. 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() - } + PaletteEntry { + kind: "empty".into(), + ..Default::default() } } else { PaletteEntry { @@ -340,19 +497,25 @@ fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry { } } -/// 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 { +/// Whether an object is a **trigger** — an invisible, non-solid, script-only object +/// authored/serialized in `[[triggers]]` rather than the grid palette. +fn is_trigger(o: &ObjectDef) -> bool { + !o.solid && o.glyph.tile == 0 && o.script_name.is_some() && o.builtin_script.is_none() +} + +/// Serializes `board`'s single grid (terrain + non-trigger objects + portals + +/// player) into a [`GridData`]. +fn grid_to_data(board: &Board) -> GridData { 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. + // Terrain 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); + let (glyph, arch) = *board.get(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)); @@ -361,8 +524,9 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData { } } - // Objects on this layer overwrite their (transparent) cell with an object char. - for o in board.objects.values().filter(|o| o.z == z) { + // Objects overwrite their (transparent) grid cell with an object char. Triggers + // are written to `[[triggers]]` instead (see `From<&Board>`), so skip them here. + for o in board.objects.values().filter(|o| !is_trigger(o)) { // A built-in archetype object (e.g. a pusher) round-trips back to its // archetype keyword: emit it as a terrain cell (deduped with real terrain), // using the object's current glyph, rather than a `kind = "object"` entry. @@ -397,8 +561,8 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData { grid[o.y][o.x] = ch; } - // Portals on this layer. - for p in board.portals.iter().filter(|p| p.z == z) { + // Portals. + for p in board.portals.iter() { let ch = pool.next().expect("ran out of palette characters"); palette.insert( ch.to_string(), @@ -413,8 +577,8 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData { grid[p.y][p.x] = ch; } - // The player is written onto the top layer. - if place_player { + // The player. + { let ch = pool.next().expect("ran out of palette characters"); palette.insert( ch.to_string(), @@ -433,7 +597,7 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData { .join("\n") + "\n"; // Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences. - LayerData { + GridData { content: Some(content), fill: None, sparse: None, @@ -441,25 +605,76 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData { } } +/// Builds the [`FloorSpec`] for `board`'s floor, or `None` for a blank floor. A +/// biome re-emits its generator name (so the procedural floor round-trips); a fixed +/// glyph re-emits its tile/fg/bg. +fn floor_to_spec(floor: &Floor) -> Option { + match floor { + Floor::Blank => None, + Floor::Fixed(g) => Some(FloorSpec { + generator: None, + tile: Some(TileIndex::Num(g.tile)), + fg: Some(color_to_hex(g.fg)), + bg: Some(color_to_hex(g.bg)), + }), + Floor::Biome { generator, .. } => Some(FloorSpec { + generator: Some(generator.name().into()), + tile: None, + fg: None, + bg: None, + }), + } +} + /// Converts a runtime [`Board`] back into a serializable [`MapFile`]. /// -/// 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). +/// Emits the single `[grid]`, the `[[triggers]]` (invisible script objects) and +/// `[[decorations]]` lists, and the `floor` attribute (a biome re-emits its +/// generator name, so procedural floors round-trip). impl From<&Board> for MapFile { fn from(board: &Board) -> Self { - let top = board.layer_count() - 1; - let layers = (0..board.layer_count()) - .map(|z| layer_to_data(board, z, z == top)) + let grid = grid_to_data(board); + // Trigger objects → `[[triggers]]`. + let triggers = board + .objects + .values() + .filter(|o| is_trigger(o)) + .map(|o| { + let mut tags: Vec = o.tags.iter().cloned().collect(); + tags.sort(); + TriggerSpec { + x: o.x, + y: o.y, + script_name: o.script_name.clone().unwrap_or_default(), + name: o.name.clone(), + tags: (!tags.is_empty()).then_some(tags), + } + }) + .collect(); + // Decorations → `[[decorations]]`. + let decorations = board + .decorations + .iter() + .map(|d| DecorationSpec { + x: d.x, + y: d.y, + kind: d.archetype.name().into(), + tile: Some(TileIndex::Num(d.glyph.tile)), + fg: Some(color_to_hex(d.glyph.fg)), + bg: Some(color_to_hex(d.glyph.bg)), + }) .collect(); MapFile { map: MapHeader { name: board.name.clone(), width: board.width, height: board.height, + floor: floor_to_spec(&board.floor), board_script_name: board.board_script_name.clone(), }, - layers, + grid, + triggers, + decorations, } } } diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs index de8c540..f6b1795 100644 --- a/kiln-core/src/object_def.rs +++ b/kiln-core/src/object_def.rs @@ -34,10 +34,6 @@ 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, @@ -101,7 +97,6 @@ impl ObjectDef { id: 0, x, y, - z: 0, glyph: Self::default_glyph(), solid: true, opaque: true, diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index b14f023..56cbf59 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -35,7 +35,7 @@ use crate::game::SAY_DURATION; use crate::log::LogLine; use crate::map_file::parse_color; use crate::object_def::ObjectDef; -use crate::utils::{Direction, ErrorSink, Hook, ObjectId}; +use crate::utils::{Direction, LogSink, Hook, ObjectId}; use rhai::{ Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext, Scope, AST, @@ -53,7 +53,7 @@ use crate::player::PlayerRef; /// Types which can be registered to be sent to Rhai pub trait Registerable { /// Register this type and relevant getters / setters with a Rhai engine - fn register(engine: &mut Engine, error_sink: ErrorSink); + fn register(engine: &mut Engine, log_sink: LogSink); } /// A compiled script plus which lifecycle hooks it defines. @@ -93,7 +93,7 @@ pub struct ScriptHost { engine: Engine, scripts: HashMap, scopes: HashMap>, - errors: ErrorSink, + log_sink: LogSink, board: BoardRef } @@ -106,20 +106,20 @@ impl ScriptHost { /// `scripts` is the world-level script pool (script name → Rhai source); it is /// read only during construction and not retained afterward. pub fn new(board_ref: BoardRef, player: PlayerRef, script_sources: &HashMap) -> Self { - let errors = ErrorSink::new(); + let log_sink = LogSink::new(); let mut scopes = HashMap::new(); let mut engine = Engine::new(); - PlayerWithPos::register(&mut engine, errors.clone()); - Keyring::register(&mut engine, errors.clone()); - BoardRef::register(&mut engine, errors.clone()); - ObjectInfo::register(&mut engine, errors.clone()); - Glyph::register(&mut engine, errors.clone()); - ObjQueue::register(&mut engine, errors.clone()); - Registry::register(&mut engine, errors.clone()); + PlayerWithPos::register(&mut engine, log_sink.clone()); + Keyring::register(&mut engine, log_sink.clone()); + BoardRef::register(&mut engine, log_sink.clone()); + ObjectInfo::register(&mut engine, log_sink.clone()); + Glyph::register(&mut engine, log_sink.clone()); + ObjQueue::register(&mut engine, log_sink.clone()); + Registry::register(&mut engine, log_sink.clone()); - register_write_api(&mut engine, board_ref.clone()); + register_write_api(&mut engine, board_ref.clone(), log_sink.clone()); register_global_constants(&mut engine, board_ref.clone(), player.clone()); let board = board_ref.borrow(); @@ -145,7 +145,7 @@ impl ScriptHost { Some(src) => src, None => { failed.insert(key.clone()); - errors.error(format!("object references unknown script '{key}'")); + log_sink.error(format!("object references unknown script '{key}'")); continue; } } @@ -169,7 +169,7 @@ impl ScriptHost { } Err(err) => { failed.insert(key.clone()); - errors.error(format!("script '{key}' failed to compile: {err}")); + log_sink.error(format!("script '{key}' failed to compile: {err}")); } } } @@ -189,7 +189,7 @@ impl ScriptHost { Self { engine, scripts, - errors, + log_sink, scopes, board: board_ref } @@ -242,7 +242,7 @@ impl ScriptHost { hook.to_str(), args, ) { - self.errors.error(format!("script '{}' {} error: {err}", script_key, hook)); + self.log_sink.error(format!("script '{}' {} error: {err}", script_key, hook)); } } // Run the drain regardless of if we have the hook, otherwise @@ -299,7 +299,7 @@ impl ScriptHost { // If it's not there at all, just bail: if arities.is_empty() { - self.errors.error(format!("script '{}' send({}) error: function not found", script_key, fn_name)); + self.log_sink.error(format!("script '{}' send({}) error: function not found", script_key, fn_name)); return actions; } @@ -321,7 +321,7 @@ impl ScriptHost { fn_name, args, ) { - self.errors.error(format!("script '{}' send({}) error: {err}", script_key, fn_name)); + self.log_sink.error(format!("script '{}' send({}) error: {err}", script_key, fn_name)); } info.drain(&mut actions, 0.0) } @@ -331,15 +331,23 @@ impl ScriptHost { actions } - /// Removes and returns the errors collected since the last drain. - pub fn take_errors(&mut self) -> Vec { - self.errors.take() + /// Removes and returns the log lines (script `log()` output and errors) + /// collected since the last drain. + pub fn take_logs(&mut self) -> Vec { + self.log_sink.take() + } + + /// The shared, immediate log channel. Exposed so `GameState::apply_actions` + /// can push its application-time errors (teleport/push/shift failures) onto + /// the same ordered channel as script `log()` output, instead of a side vec. + pub(crate) fn log_sink(&self) -> &LogSink { + &self.log_sink } } // ── Write API ───────────────────────────────────────────────────────────────── -fn register_write_api(engine: &mut Engine, board: BoardRef) { +fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) { engine.register_type_with_name::("Direction"); // Rhai does not auto-derive comparison for custom types, so register `==`/`!=` // to let scripts test the `bump` direction (e.g. `if dir == West { … }`). @@ -418,14 +426,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) { emit(&b, source_of(&ctx), Action::Die); }); - let b = board.clone(); - engine.register_fn( - "log", - move |ctx: NativeCallContext, msg: ImmutableString| { - let id = source_of(&ctx); - emit(&b, id, Action::Log(LogLine::raw(msg.to_string()))); - }, - ); + // log(msg): write to the game log immediately, bypassing the object's action + // queue — so a `log()` is not paced by a pending move/delay and surfaces the + // moment the hook runs. GameState flushes the shared LogSink into its log. + let sink = log_sink.clone(); + engine.register_fn("log", move |msg: ImmutableString| { + sink.line(LogLine::raw(msg.to_string())); + }); let b = board.clone(); engine.register_fn( @@ -591,11 +598,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) { // a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable, // and won't move anything into a cell that's not vacant (or vacated by this shift). let b = board.clone(); + let sink = log_sink.clone(); engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| { let src = source_of(&ctx); match read_coord_array(&arr) { Ok(pairs) => emit(&b, src, Action::Shift(pairs)), - Err(_) => emit(&b, src, Action::Log(LogLine::error("shift: each entry must be [x, y]".to_string()))) + // Malformed args are caught at call time, so log the error immediately. + Err(_) => sink.error("shift: each entry must be [x, y]".to_string()), } }); } diff --git a/kiln-core/src/tests/actions.rs b/kiln-core/src/tests/actions.rs index 3a41e1b..3cf01cd 100644 --- a/kiln-core/src/tests/actions.rs +++ b/kiln-core/src/tests/actions.rs @@ -44,8 +44,8 @@ fn object_pushes_crate_on_init() { game.run_init(); let b = game.board(); assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); - assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); - assert_eq!(b.get(0, 3, 0).1, Archetype::Crate); + assert_eq!(b.get(2, 0).1, Archetype::Empty); + assert_eq!(b.get(3, 0).1, Archetype::Crate); } #[test] diff --git a/kiln-core/src/tests/game_portals.rs b/kiln-core/src/tests/game_portals.rs index 0561de0..f38664c 100644 --- a/kiln-core/src/tests/game_portals.rs +++ b/kiln-core/src/tests/game_portals.rs @@ -1,8 +1,8 @@ use crate::archetype::Archetype; use crate::board::Board; +use crate::floor::Floor; use crate::game::GameState; use crate::glyph::Glyph; -use crate::layer::Layer; use crate::utils::{Direction, PlayerPos, PortalDef}; use crate::world::World; use std::cell::RefCell; @@ -15,9 +15,9 @@ fn make_board(px: i64, py: i64, portals: Vec) -> Board { name: "test".into(), width: 3, height: 3, - layers: vec![Layer { - cells: vec![(Glyph::transparent(), Archetype::Empty); 9], - }], + grid: vec![(Glyph::transparent(), Archetype::Empty); 9], + floor: Floor::Blank, + decorations: Vec::new(), player: PlayerPos { x: px, y: py }, objects: BTreeMap::new(), next_object_id: 1, @@ -40,7 +40,6 @@ fn two_board_world() -> World { name: "to_b2".into(), x: 2, y: 0, - z: 0, target_map: "b2".into(), target_entry: "from_b1".into(), }], @@ -52,7 +51,6 @@ fn two_board_world() -> World { name: "from_b1".into(), x: 1, y: 1, - z: 0, target_map: "b1".into(), target_entry: "to_b2".into(), }], diff --git a/kiln-core/src/tests/map_file/fill_sparse.rs b/kiln-core/src/tests/map_file/fill_sparse.rs index c3146fc..9247507 100644 --- a/kiln-core/src/tests/map_file/fill_sparse.rs +++ b/kiln-core/src/tests/map_file/fill_sparse.rs @@ -1,36 +1,30 @@ use super::load_board; use crate::archetype::Archetype; -use crate::glyph::Glyph; -use crate::map_file::parse_color; #[test] fn fill_builds_a_full_grid_of_one_char() { - // Layer 0 is a solid fill of a fixed floor glyph; a later sparse layer places - // the player. Every cell of layer 0 must be that floor glyph. + // `fill` fills the whole single grid with one palette char. No player char is + // possible in a filled grid, so the player falls back to (0, 0) and wins (clears) + // that cell; every *other* cell is the filled archetype. let toml = r##" [map] name = "Test" width = 3 height = 2 -[[layers]] -fill = "f" -palette = { "f" = { kind = "floor", tile = ".", fg = "#112233", bg = "#445566" } } - -[[layers]] -sparse = [ { x = 0, y = 0, ch = "@" } ] -palette = { " " = { kind = "empty" }, "@" = { kind = "player" } } +[grid] +fill = "#" +palette = { "#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" } } "##; let board = load_board(toml); - assert!(board.is_valid()); - let floor = Glyph { - tile: '.' as u32, - fg: parse_color("#112233"), - bg: parse_color("#445566"), - }; for y in 0..2 { for x in 0..3 { - assert_eq!(*board.get(0, x, y), (floor, Archetype::Empty)); + let expected = if (x, y) == (0, 0) { + Archetype::Empty // player won its fallback cell + } else { + Archetype::Wall + }; + assert_eq!(board.get(x, y).1, expected, "cell ({x}, {y})"); } } assert_eq!((board.player.x, board.player.y), (0, 0)); @@ -38,18 +32,14 @@ palette = { " " = { kind = "empty" }, "@" = { kind = "player" } } #[test] fn sparse_places_only_listed_cells() { - // A grass floor underneath; a sparse layer holding just the player and one object. + // A sparse grid holding just the player and one object; every other cell empty. let toml = r##" [map] name = "Test" width = 4 height = 1 -[[layers]] -fill = "g" -palette = { "g" = { kind = "floor", generator = "grass" } } - -[[layers]] +[grid] sparse = [ { x = 0, y = 0, ch = "@" }, { x = 2, y = 0, ch = "O" } ] palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } } "##; @@ -59,7 +49,7 @@ palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = assert_eq!(board.objects.len(), 1); assert_eq!((board.objects[&1].x, board.objects[&1].y), (2, 0)); // An unlisted sparse cell is a transparent empty. - assert_eq!(board.get(1, 1, 0).1, Archetype::Empty); + assert_eq!(board.get(1, 0).1, Archetype::Empty); } #[test] @@ -70,7 +60,7 @@ name = "Test" width = 2 height = 1 -[[layers]] +[grid] sparse = [ { x = 5, y = 0, ch = "O" }, { x = 0, y = 0, ch = "@" } ] palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } } "##; @@ -95,18 +85,14 @@ name = "Test" width = 2 height = 1 -[[layers]] +[grid] fill = "xy" palette = { " " = { kind = "empty" } } - -[[layers]] -sparse = [ { x = 0, y = 0, ch = "@" } ] -palette = { " " = { kind = "empty" }, "@" = { kind = "player" } } "##; let board = load_board(toml); assert!( !board.is_valid(), "a non-single-char fill is a nonfatal error" ); - assert_eq!(board.get(0, 1, 0).1, Archetype::Empty); + assert_eq!(board.get(1, 0).1, Archetype::Empty); } diff --git a/kiln-core/src/tests/map_file/grid_errors.rs b/kiln-core/src/tests/map_file/grid_errors.rs index 11a6951..289f1d0 100644 --- a/kiln-core/src/tests/map_file/grid_errors.rs +++ b/kiln-core/src/tests/map_file/grid_errors.rs @@ -1,4 +1,4 @@ -use super::{layer, load_board, map}; +use super::{grid, load_board, map}; use crate::archetype::Archetype; use crate::board::Board; use crate::map_file::MapFile; @@ -6,7 +6,7 @@ use crate::map_file::MapFile; #[test] fn grid_wrong_row_count_returns_error() { // height = 3 but only 2 rows in the layer grid. - let toml = map(3, 3, &[layer("...\n...", &[(".", "kind = \"empty\"")])]); + let toml = map(3, 3, &grid("...\n...", &[(".", "kind = \"empty\"")])); let mf: MapFile = toml::from_str(&toml).unwrap(); let result = Board::try_from(mf); assert!(result.is_err()); @@ -24,7 +24,7 @@ fn grid_wrong_row_count_returns_error() { #[test] fn grid_wrong_row_width_returns_error() { // width = 4 but second row is only 3 characters. - let toml = map(4, 2, &[layer("....\n...", &[(".", "kind = \"empty\"")])]); + let toml = map(4, 2, &grid("....\n...", &[(".", "kind = \"empty\"")])); let mf: MapFile = toml::from_str(&toml).unwrap(); let result = Board::try_from(mf); assert!(result.is_err()); @@ -46,14 +46,14 @@ fn unknown_kind_produces_error_block() { let toml = map( 2, 1, - &[layer( + &grid( "X@", &[("X", "kind = \"frobnicate\""), ("@", "kind = \"player\"")], - )], + ), ); let board = load_board(&toml); assert_eq!( - *board.get(0, 0, 0), + *board.get(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 e7fc4da..bf5a3be 100644 --- a/kiln-core/src/tests/map_file/mod.rs +++ b/kiln-core/src/tests/map_file/mod.rs @@ -16,33 +16,29 @@ fn load_board(toml: &str) -> Board { Board::try_from(mf).expect("convert to board") } -/// Builds one `[[layers]]` block: a triple-quoted `content` grid plus an inline +/// Builds the `[grid]` 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 { +fn grid(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") + format!("\n[grid]\ncontent = \"\"\"\n{content}\n\"\"\"\npalette = {{ {pal} }}\n") } -/// 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 +/// Wraps a `[map]` header of the given size around the single `[grid]` block +/// (produced by [`grid`]). +fn map(width: usize, height: usize, grid_block: &str) -> String { + format!("[map]\nname = \"Test\"\nwidth = {width}\nheight = {height}\n{grid_block}") } /// 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 { +fn map_3x1_object(grid_str: &str, ch: &str, extra: &str) -> String { let body = if extra.is_empty() { "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"".to_string() } else { @@ -51,8 +47,8 @@ fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String { map( 3, 1, - &[layer( - grid, + &grid( + grid_str, &[ (" ", "kind = \"empty\""), (".", "kind = \"empty\""), @@ -63,6 +59,6 @@ fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String { ("@", "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 3fa6824..2ca4208 100644 --- a/kiln-core/src/tests/map_file/object_placement.rs +++ b/kiln-core/src/tests/map_file/object_placement.rs @@ -1,13 +1,8 @@ -use super::{layer, load_board, map, map_3x1_object}; +use super::{grid, load_board, map, map_3x1_object}; use crate::archetype::Archetype; -/// Palette shorthands. -const EMPTY: (&str, &str) = (".", "kind = \"empty\""); +/// Palette shorthand. 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 { @@ -25,14 +20,14 @@ fn duplicate_name_clears_second_but_keeps_both_objects() { let board = load_board(&map( 3, 1, - &[layer( + &grid( "GH@", &[ ("G", &obj("name = \"gate\"")), ("H", &obj("name = \"gate\", solid = false")), PLAYER, ], - )], + ), )); assert_eq!(board.objects.len(), 2, "both objects survive"); assert_eq!(board.objects[&1].name.as_deref(), Some("gate")); @@ -46,7 +41,7 @@ fn palette_placement_puts_object_on_empty_floor() { 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, 0).1, Archetype::Empty); + assert_eq!(board.get(0, 0).1, Archetype::Empty); } #[test] @@ -65,10 +60,10 @@ fn palette_char_multi_occurrence_only_first_keeps_name() { let board = load_board(&map( 4, 1, - &[layer( + &grid( "GGG@", &[("G", &obj("solid = false, name = \"guard\"")), PLAYER], - )], + ), )); assert_eq!(board.objects.len(), 3); assert_eq!(board.objects[&1].name.as_deref(), Some("guard")); @@ -77,41 +72,11 @@ fn palette_char_multi_occurrence_only_first_keeps_name() { } #[test] -fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() { - // 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 = 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() { - // 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(""))]), - ], - )); +fn non_solid_object_and_wall_coexist_in_separate_cells() { + // With one grid each cell holds a single palette char, so a solid object can + // never be authored onto a wall cell. A wall and a (separate-cell) non-solid + // object both load fine. + let board = load_board(&map_3x1_object("#G@", "G", "solid = false")); assert_eq!(board.objects.len(), 1); - assert!(!board.is_valid()); + 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 4223b98..f16f262 100644 --- a/kiln-core/src/tests/map_file/player_placement.rs +++ b/kiln-core/src/tests/map_file/player_placement.rs @@ -1,4 +1,4 @@ -use super::{layer, load_board, map}; +use super::{grid, load_board, map}; use crate::archetype::Archetype; /// Palette shorthands shared by these tests. @@ -11,61 +11,22 @@ const WALL: (&str, &str) = ( #[test] fn player_char_places_on_empty_floor() { - let b = load_board(&map(3, 1, &[layer(".@.", &[EMPTY, PLAYER])])); + let b = load_board(&map(3, 1, &grid(".@.", &[EMPTY, PLAYER]))); assert_eq!((b.player.x, b.player.y), (1, 0)); - assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); - assert!(b.is_valid()); -} - -#[test] -fn player_wins_solid_terrain_silently() { - // 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, 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 (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_eq!(b.get(1, 0).1, Archetype::Empty); assert!(b.is_valid()); } #[test] fn player_char_appearing_twice_uses_first() { - let b = load_board(&map(3, 1, &[layer("@.@", &[EMPTY, PLAYER])])); + let b = load_board(&map(3, 1, &grid("@.@", &[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(&map(3, 1, &[layer("...", &[EMPTY])])); + let b = load_board(&map(3, 1, &grid("...", &[EMPTY]))); assert_eq!((b.player.x, b.player.y), (0, 0)); assert!(!b.is_valid()); } @@ -74,8 +35,30 @@ fn player_char_missing_falls_back_to_origin() { fn player_fallback_to_origin_clears_solid_terrain() { // 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])])); + let b = load_board(&map(3, 1, &grid("#..", &[WALL, EMPTY]))); assert_eq!((b.player.x, b.player.y), (0, 0)); - assert_eq!(b.get(0, 0, 0).1, Archetype::Empty); + assert_eq!(b.get(0, 0).1, Archetype::Empty); assert!(!b.is_valid()); } + +#[test] +fn player_fallback_wins_against_a_solid_object() { + // No player cell → player falls back to (0, 0), which holds a solid object. The + // player wins its cell and the object is dropped. + let b = load_board(&map( + 3, + 1, + &grid( + "O..", + &[ + EMPTY, + ( + "O", + "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"", + ), + ], + ), + )); + assert_eq!((b.player.x, b.player.y), (0, 0)); + assert!(b.objects.is_empty()); +} diff --git a/kiln-core/src/tests/map_file/portal_placement.rs b/kiln-core/src/tests/map_file/portal_placement.rs index de56790..d9d5325 100644 --- a/kiln-core/src/tests/map_file/portal_placement.rs +++ b/kiln-core/src/tests/map_file/portal_placement.rs @@ -1,4 +1,4 @@ -use super::{layer, load_board, map}; +use super::{grid, load_board, map}; const EMPTY: (&str, &str) = (".", "kind = \"empty\""); const PLAYER: (&str, &str) = ("@", "kind = \"player\""); @@ -9,7 +9,7 @@ fn portal_duplicate_name_drops_second() { let board = load_board(&map( 3, 1, - &[layer( + &grid( "12@", &[ ( @@ -22,7 +22,7 @@ fn portal_duplicate_name_drops_second() { ), PLAYER, ], - )], + ), )); assert_eq!( board.portals.len(), @@ -38,7 +38,7 @@ fn portal_palette_char_places_portal_at_grid_position() { let board = load_board(&map( 3, 1, - &[layer( + &grid( "1.@", &[ ( @@ -48,7 +48,7 @@ fn portal_palette_char_places_portal_at_grid_position() { EMPTY, PLAYER, ], - )], + ), )); assert_eq!(board.portals.len(), 1); assert_eq!((board.portals[0].x, board.portals[0].y), (0, 0)); diff --git a/kiln-core/src/tests/map_file/pushers.rs b/kiln-core/src/tests/map_file/pushers.rs index b9ebf9a..75a2a80 100644 --- a/kiln-core/src/tests/map_file/pushers.rs +++ b/kiln-core/src/tests/map_file/pushers.rs @@ -1,7 +1,7 @@ //! Pushers are now scripted objects (the `pusher_*` archetypes expand into objects //! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_` tag). -use super::{layer, load_board, map}; +use super::{grid, load_board, map}; use crate::archetype::Archetype; use crate::game::GameState; use crate::map_file::MapFile; @@ -24,10 +24,10 @@ fn pusher_loads_as_a_tagged_scripted_solid_object() { let board = load_board(&map( 3, 1, - &[layer( + &grid( "P @", &[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")], - )], + ), )); let mut id = 0; let p = pusher(&board, &mut id); @@ -48,14 +48,14 @@ fn pusher_advances_and_shoves_a_crate() { let board = load_board(&map( 5, 1, - &[layer( + &grid( "Po @", &[ ("P", "kind = \"pusher_east\""), ("o", "kind = \"crate\""), ("@", "kind = \"player\""), ], - )], + ), )); let mut pid = 0; pusher(&board, &mut pid); @@ -69,12 +69,12 @@ fn pusher_advances_and_shoves_a_crate() { let b = game.board(); assert!(b.objects[&pid].x > 0, "pusher advanced east"); assert_eq!( - b.get(0, 1, 0).1, + b.get(1, 0).1, Archetype::Empty, "crate left its start cell" ); assert!( - (2..b.width).any(|x| b.get(0, x, 0).1 == Archetype::Crate), + (2..b.width).any(|x| b.get(x, 0).1 == Archetype::Crate), "crate was shoved east" ); } @@ -84,14 +84,14 @@ fn pusher_blocked_by_wall_stays_put() { let board = load_board(&map( 4, 1, - &[layer( + &grid( "P# @", &[ ("P", "kind = \"pusher_east\""), ("#", "kind = \"wall\""), ("@", "kind = \"player\""), ], - )], + ), )); let mut pid = 0; pusher(&board, &mut pid); @@ -113,10 +113,10 @@ fn pusher_round_trips_to_its_keyword() { let board = load_board(&map( 3, 1, - &[layer( + &grid( "P @", &[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")], - )], + ), )); // Save collapses the expanded object back into the `pusher_east` keyword. let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); diff --git a/kiln-core/src/tests/map_file/round_trip.rs b/kiln-core/src/tests/map_file/round_trip.rs index 18d107d..6b9335b 100644 --- a/kiln-core/src/tests/map_file/round_trip.rs +++ b/kiln-core/src/tests/map_file/round_trip.rs @@ -1,4 +1,4 @@ -use super::{layer, load_board, map}; +use super::{grid, load_board, map}; use crate::glyph::Glyph; use crate::map_file::{MapFile, parse_color}; use color::Rgba8; @@ -25,7 +25,7 @@ fn round_trip(toml: &str) -> crate::board::Board { #[test] fn object_glyph_round_trips_through_toml() { - let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]); + let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY])); let board = load_board(&toml); assert_eq!(board.objects.len(), 1); let obj0 = &board.objects[&1]; @@ -53,10 +53,10 @@ fn object_tags_round_trip_through_toml() { let toml = map( 3, 1, - &[layer( + &grid( "@O.", &[PLAYER, ("O", &obj("tags = [\"enemy\", \"boss\"]")), EMPTY], - )], + ), ); let board = load_board(&toml); let obj0 = &board.objects[&1]; @@ -75,7 +75,7 @@ fn object_tags_round_trip_through_toml() { #[test] fn object_empty_tags_omitted_from_toml() { - let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]); + let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY])); let board = load_board(&toml); let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); assert!( @@ -89,10 +89,10 @@ fn object_name_round_trips_through_toml() { let toml = map( 3, 1, - &[layer( + &grid( "@O.", &[PLAYER, ("O", &obj("name = \"beacon\"")), EMPTY], - )], + ), ); let board = load_board(&toml); assert_eq!(board.objects[&1].name.as_deref(), Some("beacon")); @@ -108,7 +108,7 @@ fn object_name_round_trips_through_toml() { #[test] fn unnamed_object_name_stays_none_through_toml() { - let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]); + let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY])); let board2 = round_trip(&toml); assert_eq!( board2.objects[&1].name, None, @@ -117,31 +117,41 @@ fn unnamed_object_name_stays_none_through_toml() { } #[test] -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, - ], - )], - ); +fn fixed_floor_attribute_round_trips_through_toml() { + // A fixed floor glyph is now a board attribute (`floor = { … }`), not a grid + // cell. It must survive save→load and show through empty grid cells. + let toml = "[map]\nname = \"Test\"\nwidth = 3\nheight = 1\n\ + floor = { tile = \"#\", fg = \"#010203\", bg = \"#040506\" }\n\ + [grid]\ncontent = \"\"\"\n@..\n\"\"\"\n\ + palette = { \"@\" = { kind = \"player\" }, \".\" = { kind = \"empty\" } }\n"; let fixed = Glyph { tile: '#' as u32, fg: parse_color("#010203"), bg: parse_color("#040506"), }; - let board = load_board(&toml); + let board = load_board(toml); + // An empty grid cell reveals the fixed floor. assert_eq!(board.glyph_at(1, 0), fixed); - let board2 = round_trip(&toml); + let board2 = round_trip(toml); assert_eq!(board2.glyph_at(1, 0), fixed); } + +#[test] +fn biome_floor_round_trips_generator_name() { + // A biome floor re-emits its generator name (not baked glyphs), so the save + // stays compact and the reloaded board is identical. + let toml = "[map]\nname = \"Test\"\nwidth = 3\nheight = 1\n\ + floor = { generator = \"grass\" }\n\ + [grid]\ncontent = \"\"\"\n@..\n\"\"\"\n\ + palette = { \"@\" = { kind = \"player\" }, \".\" = { kind = \"empty\" } }\n"; + let board = load_board(toml); + let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); + assert!( + toml_out.contains("generator = \"grass\""), + "biome floor must re-emit its generator name, got: {toml_out}" + ); + // Reloaded floor glyphs match (deterministic seed). + let board2 = load_board(&toml_out); + assert_eq!(board2.glyph_at(1, 0), board.glyph_at(1, 0)); +} diff --git a/kiln-core/src/tests/map_file/spinners.rs b/kiln-core/src/tests/map_file/spinners.rs index d4322ad..ddd5f6d 100644 --- a/kiln-core/src/tests/map_file/spinners.rs +++ b/kiln-core/src/tests/map_file/spinners.rs @@ -2,7 +2,7 @@ //! into objects carrying the embedded `spinner.rhai` plus a `BUILTIN_spinner_` //! tag, and collapse back to the keyword on save (just like pushers). -use super::{layer, load_board, map}; +use super::{grid, load_board, map}; use crate::map_file::MapFile; #[test] @@ -10,10 +10,10 @@ fn spinner_loads_as_a_tagged_scripted_solid_object() { let board = load_board(&map( 3, 1, - &[layer( + &grid( "S @", &[("S", "kind = \"spinner_cw\""), ("@", "kind = \"player\"")], - )], + ), )); let (_, obj) = board .objects @@ -36,10 +36,10 @@ fn spinner_round_trips_to_its_keyword() { let board = load_board(&map( 3, 1, - &[layer( + &grid( "S @", &[("S", "kind = \"spinner_ccw\""), ("@", "kind = \"player\"")], - )], + ), )); // Save collapses the expanded object back into the `spinner_ccw` keyword. let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); diff --git a/kiln-core/src/tests/map_file/transporters.rs b/kiln-core/src/tests/map_file/transporters.rs index 46c664f..14ad5c8 100644 --- a/kiln-core/src/tests/map_file/transporters.rs +++ b/kiln-core/src/tests/map_file/transporters.rs @@ -3,7 +3,7 @@ //! `BUILTIN_transporter_` tag). Bumping one from its facing side teleports //! the bumper past it, or out of a paired opposite-facing transporter. -use super::{layer, load_board, map}; +use super::{grid, load_board, map}; use crate::game::GameState; use crate::map_file::MapFile; use crate::utils::Direction; @@ -14,10 +14,10 @@ fn transporter_loads_as_a_tagged_scripted_solid_object() { let board = load_board(&map( 3, 1, - &[layer( + &grid( "@T ", &[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")], - )], + ), )); let (_, obj) = board .objects @@ -37,10 +37,10 @@ fn bumping_a_transporter_drops_you_on_its_far_side() { let board = load_board(&map( 3, 1, - &[layer( + &grid( "@T ", &[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")], - )], + ), )); let mut game = GameState::new(board); game.run_init(); @@ -63,7 +63,7 @@ fn a_blocked_far_side_transports_out_of_the_paired_transporter() { let board = load_board(&map( 6, 1, - &[layer( + &grid( "@T# W ", &[ ("@", "kind = \"player\""), @@ -71,7 +71,7 @@ fn a_blocked_far_side_transports_out_of_the_paired_transporter() { ("#", "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\""), ("W", "kind = \"transporter_west\""), ], - )], + ), )); let mut game = GameState::new(board); game.run_init(); @@ -95,14 +95,14 @@ fn a_pushed_crate_is_transported_through() { let board = load_board(&map( 4, 1, - &[layer( + &grid( "@oT ", &[ ("@", "kind = \"player\""), ("o", "kind = \"crate\""), ("T", "kind = \"transporter_east\""), ], - )], + ), )); let mut game = GameState::new(board); game.run_init(); @@ -130,10 +130,10 @@ fn transporter_round_trips_to_its_keyword() { let board = load_board(&map( 3, 1, - &[layer( + &grid( "@T ", &[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")], - )], + ), )); // Save collapses the expanded object back into the `transporter_east` keyword. let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index 6f47c80..7ff2a06 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -7,9 +7,9 @@ mod scripting; use crate::archetype::Archetype; use crate::board::Board; +use crate::floor::Floor; use crate::game::GameState; use crate::glyph::Glyph; -use crate::layer::Layer; use crate::object_def::ObjectDef; use crate::utils::PlayerPos; use std::collections::{BTreeMap, HashMap}; @@ -29,9 +29,9 @@ fn board_with_object( name: "test".into(), width: 1, height: 1, - layers: vec![Layer { - cells: vec![(Glyph::transparent(), Archetype::Empty)], - }], + grid: vec![(Glyph::transparent(), Archetype::Empty)], + floor: Floor::Blank, + decorations: Vec::new(), player: PlayerPos { x: 0, y: 0 }, objects: BTreeMap::from([(1, object)]), next_object_id: 2, diff --git a/kiln-core/src/tests/movement.rs b/kiln-core/src/tests/movement.rs index 5320497..a8aa99d 100644 --- a/kiln-core/src/tests/movement.rs +++ b/kiln-core/src/tests/movement.rs @@ -32,8 +32,8 @@ fn pushing_a_crate_reveals_the_floor_underneath() { let mut game = GameState::new(board); game.try_move(Direction::East); let b = game.board(); - assert_eq!(b.get(1, 2, 0).1, Archetype::Crate); - assert_eq!(b.get(1, 1, 0).1, Archetype::Empty); + assert_eq!(b.get(2, 0).1, Archetype::Crate); + assert_eq!(b.get(1, 0).1, Archetype::Empty); assert_eq!(b.glyph_at(0, 0), floor_glyph); } @@ -45,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(0, 1, 0).1, Archetype::Empty); - assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); + assert_eq!(b.get(1, 0).1, Archetype::Empty); + assert_eq!(b.get(2, 0).1, Archetype::Crate); } #[test] @@ -58,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(0, 1, 0).1, Archetype::Crate); - assert_eq!(b.get(0, 2, 0).1, Archetype::Wall); + assert_eq!(b.get(1, 0).1, Archetype::Crate); + assert_eq!(b.get(2, 0).1, Archetype::Wall); } #[test] @@ -71,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(0, 1, 0).1, Archetype::Crate); + assert_eq!(b.get(1, 0).1, Archetype::Crate); } #[test] @@ -83,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(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); + 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); } #[test] @@ -98,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(0, 1, 0).1, Archetype::Crate); - assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); + assert_eq!(b.get(1, 0).1, Archetype::Crate); + assert_eq!(b.get(2, 0).1, Archetype::Crate); } #[test] @@ -124,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(0, 1, 0).1, Archetype::Empty); - assert_eq!(b.get(0, 2, 0).1, Archetype::HCrate); + assert_eq!(b.get(1, 0).1, Archetype::Empty); + assert_eq!(b.get(2, 0).1, Archetype::HCrate); drop(b); // Pushing north into an HCrate: blocked, nothing moves. @@ -135,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, 0, 2).1, Archetype::HCrate); + assert_eq!(b.get(0, 2).1, Archetype::HCrate); } #[test] @@ -147,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, 0, 2).1, Archetype::Empty); - assert_eq!(b.get(0, 0, 1).1, Archetype::VCrate); + assert_eq!(b.get(0, 2).1, Archetype::Empty); + assert_eq!(b.get(0, 1).1, Archetype::VCrate); drop(b); // Pushing east into a VCrate: blocked, nothing moves. @@ -158,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(0, 1, 0).1, Archetype::VCrate); + assert_eq!(b.get(1, 0).1, Archetype::VCrate); } diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index ca11224..142101b 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -85,8 +85,6 @@ enum SolidKind { Object(ObjectId), /// A solid terrain cell, with everything needed to rewrite it elsewhere. Terrain { - /// Layer the terrain lives on. - z: usize, /// The cell's glyph. glyph: Glyph, /// The cell's archetype. @@ -149,12 +147,12 @@ impl Solid { } } - /// Builds a solid terrain cell at `(x, y)` on layer `z`. - pub(crate) fn terrain_at(x: usize, y: usize, z: usize, glyph: Glyph, arch: Archetype) -> Solid { + /// Builds a solid terrain cell at `(x, y)`. + pub(crate) fn terrain_at(x: usize, y: usize, glyph: Glyph, arch: Archetype) -> Solid { Solid { x, y, - kind: SolidKind::Terrain { z, glyph, arch }, + kind: SolidKind::Terrain { glyph, arch }, behavior: arch.behavior(), } } @@ -228,8 +226,8 @@ impl Solid { obj.y = y; } } - SolidKind::Terrain { z, glyph, arch } => { - *board.get_mut(z, x, y) = (glyph, arch); + SolidKind::Terrain { glyph, arch } => { + *board.get_mut(x, y) = (glyph, arch); } } } @@ -256,9 +254,6 @@ 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. @@ -464,15 +459,24 @@ impl Display for Hook { } } -/// Channel for engine/compile/runtime errors. +/// Shared, immediate channel for log output: script `log()` lines plus +/// engine/compile/runtime errors. A script host pushes to it *during* hook +/// execution (so a `log()` is not paced by the object's action queue), and +/// [`GameState`](crate::game::GameState) flushes it into its log each frame. #[derive(Clone)] -pub struct ErrorSink(Rc>>); +pub struct LogSink(Rc>>); -impl ErrorSink { +impl LogSink { pub fn new() -> Self { Self(Rc::new(RefCell::new(Vec::new()))) } + /// Pushes a pre-built [`LogLine`] onto the channel immediately. + pub fn line(&self, line: LogLine) { + self.0.borrow_mut().push(line); + } + + /// Pushes a red-on-black error line onto the channel immediately. pub fn error(&self, msg: String) { self.0.borrow_mut().push(LogLine::error(msg)); } diff --git a/kiln-core/src/world.rs b/kiln-core/src/world.rs index 6aa4653..5da572c 100644 --- a/kiln-core/src/world.rs +++ b/kiln-core/src/world.rs @@ -149,11 +149,11 @@ mod tests { // The copy changed; the original is still empty at that cell. assert_eq!( - copy.boards["start"].borrow().get(0, 1, 0).1, + copy.boards["start"].borrow().get(1, 0).1, Archetype::Wall ); assert_eq!( - world.boards["start"].borrow().get(0, 1, 0).1, + world.boards["start"].borrow().get(1, 0).1, Archetype::Empty ); // And they are genuinely different allocations. diff --git a/maps/start.toml b/maps/start.toml index ca3dca8..2cf0c7a 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -180,50 +180,28 @@ fn tick(me, dt) { } """ +# A trigger script: no glyph, not solid — it just watches for the player stepping +# onto its cell and logs. Triggers are placed via [[boards.NAME.triggers]]. +tripwire = """ +fn tick(me, dt) { + if Player.x == me.x && Player.y == me.y && !me.waiting { + log("tripwire: the player crossed here"); + me.delay(2.0); + } +} +""" + [boards.start.map] name = "Starting Room" width = 60 height = 25 +# A single procedural grass floor across the whole board (the old mixed +# grass/dirt/stone/water floor is not representable with one floor attribute). +floor = { generator = "grass" } -# 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 -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -ggggggwwwwwwgggggggggggggsggggdddddddddddddddddddddggggggggg -ggggggwwwwwwgggggggggggggsggggdddddddddddddddddddddggggggggg -ggggggwwwwwwgggggggggggggsggggdddddddddddddddddddddggggggggg -ggggggwwwwwwgggggggggggggsggggdddddddddddddddddddddggggggggg -gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg -gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg -gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg -gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg -""" -[boards.start.layers.palette] -"g" = { kind = "floor", generator = "grass" } -"d" = { kind = "floor", generator = "dirt" } -"s" = { kind = "floor", generator = "stone" } -"w" = { kind = "floor", tile = "~", fg = "#6666ff", bg = "#000066" } - -# Layer 1: terrain, the player, objects and the portal. A space is always a -# transparent empty cell, so the floor below shows through. -[[boards.start.layers]] +# The single grid: all solids and most non-solids (terrain, the player, objects and +# the portal). A space is always a transparent empty cell, so the floor shows through. +[boards.start.grid] content = """ ############################################################ # # @@ -251,7 +229,7 @@ content = """ # # ############################################################ """ -[boards.start.layers.palette] +[boards.start.grid.palette] "#" = { 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" } @@ -272,26 +250,22 @@ content = """ ")" = { kind = "transporter_east" } "(" = { kind = "transporter_west" } -# 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]] -sparse = [{ x = 30, y = 12, ch = "*" }] -[boards.start.layers.palette] -"*" = { kind = "object", tile = 15, fg = "#ffff66", bg = "#000000", solid = false } +# Triggers: invisible, non-solid, script-only objects placed off the grid. This one +# watches for the player and logs when they reach its cell. +[[boards.start.triggers]] +x = 40 +y = 12 +script_name = "tripwire" [boards.house.map] name = "The House" width = 60 height = 25 +# A single fixed floor glyph across the whole board. +floor = { tile = 176, fg = "#888888", bg = "#444444" } -# Layer 0: a single fixed floor glyph across the whole board. -[[boards.house.layers]] -fill = "f" -[boards.house.layers.palette] -"f" = { kind = "floor", tile = 176, fg = "#888888", bg = "#444444" } - -# Layer 1: terrain, the player, objects and the return portal. -[[boards.house.layers]] +# The single grid: terrain, the player, objects and the return portal. +[boards.house.grid] content = """ ############################################################ # # @@ -319,7 +293,7 @@ content = """ # # ############################################################ """ -[boards.house.layers.palette] +[boards.house.grid.palette] "#" = { kind = "wall", tile = 178, fg = "#888888", bg = "#555555" } "@" = { kind = "player" } "1" = { kind = "portal", name = "from_start", target_map = "start", target_entry = "to_house" } diff --git a/maps/tiny.toml b/maps/tiny.toml index 2aecb5d..836df4f 100644 --- a/maps/tiny.toml +++ b/maps/tiny.toml @@ -6,15 +6,15 @@ start = "start" # Objects reference a script by name via `script_name`. [scripts] greeter = """ -fn init(me, state) { - log(`hello from object — player at ${state.player.x}, ${state.player.y}`); +fn init(me) { + log(`hello from object — player at ${Player.x}, ${Player.y}`); set_tile(2); // change my glyph to ☻ — proves the write path //say("Hello there,\\ntraveller!"); - log(`Player health: ${state.player.health}`); + log(`Player health: ${Player.health}`); } -fn tick(me, state, dt) { - if state.player.x == me.x && state.player.y == me.y && !me.waiting { +fn tick(me, dt) { + if Player.x == me.x && Player.y == me.y && !me.waiting { say("Hey! Get offa me!"); me.queue.delay(5.0); } @@ -25,10 +25,11 @@ fn tick(me, state, dt) { name = "Starting Room" width = 21 height = 6 +# No floor attribute → a blank (black) floor. -# Layer 1: terrain, the player, objects and the portal. A space is always a -# transparent empty cell, so the floor below shows through. -[[boards.start.layers]] +# The single grid: all solids and most non-solids. A space is always a transparent +# empty cell, so the floor shows through. +[boards.start.grid] content = """ ##################### # # @@ -37,7 +38,7 @@ content = """ # # ##################### """ -[boards.start.layers.palette] +[boards.start.grid.palette] "#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" } "o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } "@" = { kind = "player" }