spinners and gems

This commit is contained in:
2026-06-21 18:27:45 -05:00
parent 9b53552f4a
commit d4b9278838
17 changed files with 626 additions and 56 deletions
+2 -2
View File
@@ -128,9 +128,9 @@ content = """
"1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" } "1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" }
``` ```
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`, `spinner_cw|spinner_ccw`). 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` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph.
Colors are `"#RRGGBB"` hex strings. 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()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, `can_push(x, y, dir) -> bool`, `can_shift(x, y, dir) -> bool`, and `passable(x, y) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)` (shove a chain at arbitrary coords), and `swap([[src_x, src_y, dst_x, dst_y], …])` (move several solids simultaneously). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`. Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); 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()`, `tick(dt)`, `bump(id)`, and `grab()` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, `can_push(x, y, dir) -> bool`, `can_shift(x, y, dir) -> bool`, and `passable(x, y) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)` (shove a chain at arbitrary coords), `swap([[src_x, src_y, dst_x, dst_y], …])` (move several solids simultaneously), `add_gems(n)` (change the player's gem count), and `die()` (remove the calling object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`.
**Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` 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)`). **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<RefCell<Board>>` 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)`).
+13 -12
View File
@@ -18,22 +18,22 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `NAMED_COLORS: [(&str, Rgba8); 16]` — the 16 EGA/VGA palette colors as `(name, color)` pairs in palette order (`Black`, `Blue`, …, `White`). The **single source of truth**: `script::register_global_constants` builds the Rhai `Black`/`Blue`/… `"#RRGGBB"` constants from it, and kiln-ui's glyph picker builds its named-swatch strips from it, so the two never drift. - `NAMED_COLORS: [(&str, Rgba8); 16]` — the 16 EGA/VGA palette colors as `(name, color)` pairs in palette order (`Black`, `Blue`, …, `White`). The **single source of truth**: `script::register_global_constants` builds the Rhai `Black`/`Blue`/… `"#RRGGBB"` constants from it, and kiln-ui's glyph picker builds its named-swatch strips from it, so the two never drift.
**`kiln-core/src/archetype.rs`** — element taxonomy: **`kiln-core/src/archetype.rs`** — element taxonomy:
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Pusher(Direction)`, `Spinner(SpinDirection)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `Pusher(dir)` (`pusher_north|south|east|west`) is a **map-file keyword only**: at load it expands into a scripted object (see [`builtin_scripts`]), never a terrain cell. `Spinner(SpinDirection)` (`SpinDirection::{Clockwise,CounterClockwise}`; keywords `spinner_cw|spinner_ccw`) is the same kind of script-backed keyword: it expands into a `spinner.rhai` object that rotates its 8 neighbours each 0.5 s and animates its glyph (default glyph is the first frame, `/` CW / `\` CCW, gray on black). `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`). - `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Pusher(Direction)`, `Spinner(SpinDirection)`, `Gem`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `Pusher(dir)` (`pusher_north|south|east|west`) is a **map-file keyword only**: at load it expands into a scripted object (see [`builtin_scripts`]), never a terrain cell. `Spinner(SpinDirection)` (`SpinDirection::{Clockwise,CounterClockwise}`; keywords `spinner_cw|spinner_ccw`) is the same kind of script-backed keyword: it expands into a `spinner.rhai` object that rotates its 8 neighbours each 0.5 s and animates its glyph (default glyph is the first frame, `/` CW / `\` CCW, gray on black). `Gem` (keyword `gem`) is also a script-backed keyword: solid + non-opaque + pushable + **grab** (the only archetype with `grab: true`), it expands into a `gem.rhai` object (default glyph blue ♦, CP437 tile 4) whose `grab()` hook adds a gem to the player and `die()`s. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
**`kiln-core/src/builtin_scripts.rs`** — archetypes that are really scripted objects (`pub(crate)`): **`kiln-core/src/builtin_scripts.rs`** — archetypes that are really scripted objects (`pub(crate)`):
- `archetype_script(arch) -> Option<&'static str>` — the dispatch (one arm per script-backed archetype) returning the embedded Rhai source (`include_str!`); currently `Pusher(_) -> scripts/pusher.rhai` and `Spinner(_) -> scripts/spinner.rhai`. Such an archetype loads as a plain terrain cell (`layer::resolve_entry`); `Board::expand_builtin_archetypes` (called from `TryFrom<MapFile>` at load and again before a playtest) is the **single** place that turns it into an `ObjectDef` carrying that source in `builtin_script` plus a `BUILTIN_<archetype>` tag. The spinner reads its `BUILTIN_spinner_cw`/`_ccw` tag for spin direction (defaulting clockwise) and stores per-instance animation frame state in the board `Registry`. - `archetype_script(arch) -> Option<&'static str>` — the dispatch (one arm per script-backed archetype) returning the embedded Rhai source (`include_str!`); currently `Pusher(_) -> scripts/pusher.rhai`, `Spinner(_) -> scripts/spinner.rhai`, and `Gem -> scripts/gem.rhai` (just `fn grab() { add_gems(1); die(); }`). Such an archetype loads as a plain terrain cell (`layer::resolve_entry`); `Board::expand_builtin_archetypes` (called from `TryFrom<MapFile>` at load and again before a playtest) is the **single** place that turns it into an `ObjectDef` carrying that source in `builtin_script` plus a `BUILTIN_<archetype>` tag. The spinner reads its `BUILTIN_spinner_cw`/`_ccw` tag for spin direction (defaulting clockwise) and stores per-instance animation frame state in the board `Registry`.
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. The script reads its tag for per-instance params (the pusher reads it for its direction, since `Me` isn't visible inside Rhai helper functions); `map_file::save` uses it to collapse the object back into its archetype keyword so maps round-trip. `scripts/pusher.rhai` self-propels via `move(dir)` (which already shoves chains via `step_object`/`push`), pacing itself with `delay` to the old ~0.5 s cadence. - `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. The script reads its tag for per-instance params (the pusher reads it for its direction, since `Me` isn't visible inside Rhai helper functions); `map_file::save` uses it to collapse the object back into its archetype keyword so maps round-trip. `scripts/pusher.rhai` self-propels via `move(dir)` (which already shoves chains via `step_object`/`push`), pacing itself with `delay` to the old ~0.5 s cadence.
**`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`): **`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`):
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`. - `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`. - `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`, `grab: bool` (walking into a `solid + grab` thing isn't blocked — the player moves onto it and its `grab()` hook fires; only `Gem` sets it).
- `ObjectId = u32` — stable identifier for board objects. - `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`, `Cell(Archetype)`, or `Object(ObjectId)`.
- `Player { x: i32, y: i32 }` — current player position. - `Player { x: i32, y: i32 }` — current player position.
- `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired. - `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired.
**`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`): **`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
- `ObjectDef` — a scripted tile: `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; takes precedence over `script_name`; not serialized — see [`builtin_scripts`]), `tags: HashSet<String>`, `name: Option<String>`. 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: `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `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<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; takes precedence over `script_name`; not serialized — see [`builtin_scripts`]), `tags: HashSet<String>`, `name: Option<String>`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
**`kiln-core/src/layer.rs`** — palette layers (the map-file/draw-stack unit): **`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. - `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.
@@ -48,12 +48,13 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`). - `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`).
- `is_passable(x, y)` — convenience inverse of `solid_at`. - `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? - `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
- `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 right gate for a simultaneous shift/rotation via `apply_swap`. - `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 right gate for a simultaneous shift/rotation via `apply_swap`. Shifting onto the **player** counts only when the source is a `grab` thing (it gets grabbed, the player isn't moved); any other solid treats the player as a blocker.
- `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 a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`).
- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries. (`remove_object` only edits the `objects` map; a live `ScriptHost` keeps a stale `ObjectRuntime` whose later host-fn calls resolve to a missing id and no-op — benign.) - `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries.
- `apply_swap(pairs: &[(i32,i32,i32,i32)]) -> Vec<LogLine>` — applies a batch of one-way solid moves **simultaneously** (reads every source's solid occupant — player/object/terrain — into a private `SolidSnapshot` before writing any destination, so cycles and two-cell swaps resolve). A source with no solid moves an "empty", removing the destination's solid (terrain cleared; a displaced object despawned via `remove_object`). The **player is never destroyed** — a write that would overwrite it without relocating it is skipped + logged. Out-of-bounds entries are skipped + logged. Backs the script `swap()` fn. - `grab_object_at(x, y) -> Option<ObjectId>` (a solid `grab` object on the cell — the player-walks-onto-it case) and `pushed_grab_into_player(x, y, dir) -> Option<ObjectId>` (walk the pushable chain from `(x,y)`; if a chain cell is a `grab` object whose forward neighbour is the player, return it — the grab-thing-shoved-into-the-player case). `GameState` uses both to decide when to fire `grab()` instead of pushing/sliding the player. (`remove_object` only edits the `objects` map; a live `ScriptHost` keeps a stale `ObjectRuntime` whose later host-fn calls resolve to a missing id and no-op — benign.)
- `apply_swap(pairs: &[(i32,i32,i32,i32)]) -> (Vec<LogLine>, Vec<ObjectId>)` — applies a batch of one-way solid moves **simultaneously** (reads every source's solid occupant — player/object/terrain — into a private `SolidSnapshot` before writing any destination, so cycles and two-cell swaps resolve). A source with no solid moves an "empty", removing the destination's solid (terrain cleared; a displaced object despawned via `remove_object`). The **player is never destroyed** — a write that would overwrite it without relocating it is skipped + logged. A **grab** object whose destination is the player isn't written onto it — it is left in place and its id is returned (the second tuple element) so `GameState` fires its `grab()` hook (collected into the same `grabs` list as the push path). Because a grabbed object stays put (and its `grab()` may not despawn it), a final **overlap sweep** over the swapped cells keeps one solid per cell — preferring a grabbed object so its `grab()` can still fire — and deletes the rest (never the player), logging an error per deletion. Out-of-bounds entries are skipped + logged. Backs the script `swap()` fn.
- `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`. - `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 script-backed terrain cell (e.g. `Spinner`/`Pusher`, those with an `archetype_script`) with the scripted object it expands to (embedded script + `BUILTIN_*` tag + behavior, copying the cell's glyph so palette overrides survive). Idempotent (cells already loaded as objects are skipped). Called by `TryFrom<MapFile> for Board` after cross-layer validation (so disk loads get working machines) 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 — the playtest skips the reload path). Because it runs after explicit-object placement, builtin objects get ids after hand-placed ones (still layer-then-reading order among themselves). - `expand_builtin_archetypes()` — the **single** expansion point: replaces every script-backed terrain cell (e.g. `Spinner`/`Pusher`/`Gem`, those with an `archetype_script`) with the scripted object it expands to (embedded script + `BUILTIN_*` tag + behavior, copying the cell's glyph so palette overrides survive). The object's `pushable`/`grab` flags are taken from the archetype's `Behavior` (so a gem becomes pushable + grabbable; pushers/spinners stay unpushable). Idempotent (cells already loaded as objects are skipped). Called by `TryFrom<MapFile> for Board` after cross-layer validation (so disk loads get working machines) 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 — the playtest skips the reload path). Because it runs after explicit-object placement, builtin objects get ids after hand-placed ones (still layer-then-reading order among themselves).
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord. - `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface. - `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
@@ -61,12 +62,12 @@ 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). - `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). - `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds).
- `Scroll { source: ObjectId, lines: Vec<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; 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`. - `Scroll { source: ObjectId, lines: Vec<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; 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<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, `pub player_health: u32` (starts 5) and `pub player_gems: u32` (starts 0) — game-global player stats that persist across board transitions (display-only for now, no runtime mutation path). Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (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<RefCell<Board>>` 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(-1)` on any solid object walked into. `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log``log`, `SetTile` → source glyph, `Move(dir)``step_object`, `Say``speech_bubbles`, `Scroll``active_scroll`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `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<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, `pub player_health: u32` (starts 5) and `pub player_gems: u32` (starts 0) — game-global player stats that persist across board transitions. `player_gems` is changed at runtime by the `add_gems(n)` script fn (e.g. grabbing a gem); `player_health` is still display-only. Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (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<RefCell<Board>>` 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(-1)` on any solid object walked into; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` + an immediate `resolve()` so its `die()`/`add_gems()` apply before the call returns (no player+object overlap). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log``log`, `SetTile` → source glyph, `Move(dir)``step_object`, `Say``speech_bubbles`, `Scroll``active_scroll`, `AddGems(n)``player_gems`, `Die``remove_object(source)`). `step_object` and `Action::Push` route a grab-thing-shoved-into-the-player (via `Board::pushed_grab_into_player`) into a deferred `grab()` instead of sliding the player, collected and fired in phase B alongside `bump`s. Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `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.
**`kiln-core/src/action.rs`** — the `Action` enum and its Rhai-facing conversion: **`kiln-core/src/action.rs`** — the `Action` enum and its Rhai-facing conversion:
- `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s). - `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). - `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).
- `Action` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String)`, `Delay(f64)` (never reaches the board queue — consumed by `ScriptHost::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Swap(Vec<(i32,i32,i32,i32)>)` (batch of simultaneous one-way solid moves; zero time cost). - `Action` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String)`, `Delay(f64)` (never reaches the board queue — consumed by `ScriptHost::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Swap(Vec<(i32,i32,i32,i32)>)` (batch of simultaneous one-way solid moves; zero time cost), `AddGems(i64)` (change `GameState::player_gems`, clamped at 0; zero cost), `Die` (remove the source object; zero cost).
- `action_to_map(action) -> rhai::Map` — converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. The map always has a `"type"` key; other keys carry the payload. `Scroll`/`Swap` emit `type` only (their payloads are not inspectable via the map API). `Log` is flattened to its first span's text. - `action_to_map(action) -> rhai::Map` — converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. The map always has a `"type"` key; other keys carry the payload. `Scroll`/`Swap` emit `type` only (their payloads are not inspectable via the map API). `Log` is flattened to its first span's text.
**`kiln-core/src/floor.rs`** — procedural floor generators: **`kiln-core/src/floor.rs`** — procedural floor generators:
@@ -78,9 +79,9 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/script.rs`** — Rhai scripting runtime: **`kiln-core/src/script.rs`** — Rhai scripting runtime:
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects, a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: a named script keys by its pool name; an object's embedded `builtin_script` keys by its source text (so identical built-ins, e.g. all pushers, share one AST). Reports compile/unknown-script failures onto the error sink; runs nothing. - `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects, a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: a named script keys by its pool name; an object's embedded `builtin_script` keys by its source text (so identical built-ins, e.g. all pushers, share one AST). Reports compile/unknown-script failures onto the error sink; runs nothing.
- Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), and `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)`; runtime errors go to the error sink (drained to the log), not fatal. - Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), and `grab()` (zero-arg; fired when the player walks onto / pushes a `grab` thing into themselves), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)` / `run_grab(object_id)`; runtime errors go to the error sink (drained to the log), not fatal.
- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `can_push(x, y, dir) -> bool` is a read fn mirroring `Board::can_push`: true if `(x, y)` holds a pushable whose chain can be shoved in `dir` (false off-board). `can_shift(x, y, dir) -> bool` mirrors `Board::can_shift`: like `can_push` but only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation via `swap`. `passable(x, y) -> bool` mirrors `Board::is_passable`: true if `(x, y)` is on-board and holds no solid (off-board → false), letting a script tell a hole apart from a blocked solid (which `can_shift`/`can_push` alone cannot). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`. - **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `can_push(x, y, dir) -> bool` is a read fn mirroring `Board::can_push`: true if `(x, y)` holds a pushable whose chain can be shoved in `dir` (false off-board). `can_shift(x, y, dir) -> bool` mirrors `Board::can_shift`: like `can_push` but only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation via `swap`. `passable(x, y) -> bool` mirrors `Board::is_passable`: true if `(x, y)` is on-board and holds no solid (off-board → false), letting a script tell a hole apart from a blocked solid (which `can_shift`/`can_push` alone cannot). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`.
- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)`, `swap(pairs)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`/`Push`/`Swap`; `MOVE_COST = 0.25` s for `Move`, else 0 — `push`/`swap` add no delay) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `push(x, y, dir)` shoves the pushable chain at arbitrary coords `(x, y)`. `swap(pairs)` takes an array of four-int `[src_x, src_y, dst_x, dst_y]` arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see `Board::apply_swap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`). - **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)`, `swap(pairs)`, `add_gems(n)` (adjust the player's gem count), `die()` (remove the calling object) append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`/`Push`/`Swap`; `MOVE_COST = 0.25` s for `Move`, else 0 — `push`/`swap` add no delay) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `push(x, y, dir)` shoves the pushable chain at arbitrary coords `(x, y)`. `swap(pairs)` takes an array of four-int `[src_x, src_y, dst_x, dst_y]` arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see `Board::apply_swap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`).
- The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`). - The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`).
- **Sender identity:** `source` (which object issued an action) rides the per-call **tag**`run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From<Direction> for (i32,i32)` gives the delta. - **Sender identity:** `source` (which object issued an action) rides the per-call **tag**`run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From<Direction> for (i32,i32)` gives the delta.
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui. - `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
+13
View File
@@ -75,6 +75,12 @@ pub(crate) enum Action {
/// write-all, so cycles/swaps work). Each tuple is `(src_x, src_y, dst_x, /// write-all, so cycles/swaps work). Each tuple is `(src_x, src_y, dst_x,
/// dst_y)`. Zero time cost. /// dst_y)`. Zero time cost.
Swap(Vec<(i32, i32, i32, i32)>), Swap(Vec<(i32, i32, i32, i32)>),
/// Add `n` to the player's gem count (negative subtracts; the count is
/// clamped at 0). Zero time cost. Applied to `GameState::player_gems`.
AddGems(i64),
/// Remove the source object from the board. Zero time cost. Used by grab
/// things (e.g. gems) to despawn themselves from their `grab()` hook.
Die,
} }
// ── Direction helper ────────────────────────────────────────────────────────── // ── Direction helper ──────────────────────────────────────────────────────────
@@ -180,6 +186,13 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
Action::Swap(_) => { Action::Swap(_) => {
m.insert("type".into(), ds("Swap")); m.insert("type".into(), ds("Swap"));
} }
Action::AddGems(n) => {
m.insert("type".into(), ds("AddGems"));
m.insert("n".into(), Dynamic::from(*n));
}
Action::Die => {
m.insert("type".into(), ds("Die"));
}
} }
m m
} }
+29
View File
@@ -45,6 +45,13 @@ pub enum Archetype {
/// direction, rotates the 8 neighbouring cells one step each 0.5 s, and animates /// direction, rotates the 8 neighbouring cells one step each 0.5 s, and animates
/// its own glyph through a spinning line `/ ─ \ │` (slash-swapped for counter-clockwise). /// its own glyph through a spinning line `/ ─ \ │` (slash-swapped for counter-clockwise).
Spinner(SpinDirection), Spinner(SpinDirection),
/// A collectible gem — solid, non-opaque, pushable, and **grab**bable. Like
/// [`Pusher`](Archetype::Pusher)/[`Spinner`](Archetype::Spinner) this is a
/// map-file *keyword* only: at load it expands into a scripted [`ObjectDef`]
/// carrying the embedded `gem.rhai` (see [`crate::builtin_scripts`]). Walking
/// onto a gem (or pushing one into the player) fires its `grab()` hook, which
/// increments the player's gem count and removes the gem. Glyph: blue ♦.
Gem,
/// Sentinel for map files that reference an unknown archetype name. /// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game. /// Renders as a yellow `?` on red to make the error visible in-game.
ErrorBlock, ErrorBlock,
@@ -69,41 +76,56 @@ impl Archetype {
solid: false, solid: false,
opaque: false, opaque: false,
pushable: Pushable::No, pushable: Pushable::No,
grab: false,
}, },
Archetype::Wall => Behavior { Archetype::Wall => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
pushable: Pushable::No, pushable: Pushable::No,
grab: false,
}, },
Archetype::Crate => Behavior { Archetype::Crate => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
pushable: Pushable::Any, pushable: Pushable::Any,
grab: false,
}, },
Archetype::HCrate => Behavior { Archetype::HCrate => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
pushable: Pushable::Horizontal, pushable: Pushable::Horizontal,
grab: false,
}, },
Archetype::VCrate => Behavior { Archetype::VCrate => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
pushable: Pushable::Vertical, pushable: Pushable::Vertical,
grab: false,
}, },
Archetype::Pusher(_) => Behavior { Archetype::Pusher(_) => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
pushable: Pushable::No, pushable: Pushable::No,
grab: false,
}, },
Archetype::Spinner(_) => Behavior { Archetype::Spinner(_) => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
pushable: Pushable::No, pushable: Pushable::No,
grab: false,
},
// A gem doesn't block sight, is shovable, and is grabbed on contact.
Archetype::Gem => Behavior {
solid: true,
opaque: false,
pushable: Pushable::Any,
grab: true,
}, },
Archetype::ErrorBlock => Behavior { Archetype::ErrorBlock => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
pushable: Pushable::No, pushable: Pushable::No,
grab: false,
}, },
} }
} }
@@ -122,6 +144,7 @@ impl Archetype {
Archetype::Pusher(Direction::West) => "pusher_west", Archetype::Pusher(Direction::West) => "pusher_west",
Archetype::Spinner(SpinDirection::Clockwise) => "spinner_cw", Archetype::Spinner(SpinDirection::Clockwise) => "spinner_cw",
Archetype::Spinner(SpinDirection::CounterClockwise) => "spinner_ccw", Archetype::Spinner(SpinDirection::CounterClockwise) => "spinner_ccw",
Archetype::Gem => "gem",
Archetype::ErrorBlock => "error_block", Archetype::ErrorBlock => "error_block",
} }
} }
@@ -183,6 +206,11 @@ impl Archetype {
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
} }
} }
Archetype::Gem => Glyph {
tile: 4, // CP437 ♦ (diamond)
fg: Rgba8 { r: 0x50, g: 0x50, b: 0xFF, a: 255 }, // blue on black
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
// Visually distinct so malformed map files are immediately obvious. // Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph { Archetype::ErrorBlock => Glyph {
tile: 63, tile: 63,
@@ -215,6 +243,7 @@ impl TryFrom<&str> for Archetype {
"pusher_west" => Ok(Archetype::Pusher(Direction::West)), "pusher_west" => Ok(Archetype::Pusher(Direction::West)),
"spinner_cw" => Ok(Archetype::Spinner(SpinDirection::Clockwise)), "spinner_cw" => Ok(Archetype::Spinner(SpinDirection::Clockwise)),
"spinner_ccw" => Ok(Archetype::Spinner(SpinDirection::CounterClockwise)), "spinner_ccw" => Ok(Archetype::Spinner(SpinDirection::CounterClockwise)),
"gem" => Ok(Archetype::Gem),
_ => Err(format!("unknown archetype: {name}")), _ => Err(format!("unknown archetype: {name}")),
} }
} }
+241 -10
View File
@@ -6,6 +6,7 @@ use crate::object_def::ObjectDef;
use crate::utils::Direction; use crate::utils::Direction;
use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid}; use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid};
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use crate::builtin_scripts::archetype_script_key;
/// A captured solid occupant of a cell, used by [`Board::apply_swap`] to read /// A captured solid occupant of a cell, used by [`Board::apply_swap`] to read
/// every source cell before writing any destination (so cyclic moves work). /// every source cell before writing any destination (so cyclic moves work).
@@ -234,6 +235,38 @@ impl Board {
self.solid_at(x, y).is_none() self.solid_at(x, y).is_none()
} }
/// Returns `true` if the mover as `(x1, y1)` can enter `(x2, y2)` — i.e. it would not
/// break the rules of "one solid per cell, except for player + grab".
///
/// Convenience inverse of [`solid_at`](Board::solid_at).
/// Panics if `x` or `y` are out of bounds.
pub fn is_combinable(&self, x1: usize, y1: usize, x2: usize, y2: usize) -> bool {
// Are either out of bounds?
if !self.in_bounds((x1 as i32, y1 as i32)) || !self.in_bounds((x2 as i32, y2 as i32)) {
return false
}
// Grab the solids
let solid1 = self.solid_at(x1, y1);
let solid2 = self.solid_at(x2, y2);
// Is one cell empty?
if solid1.is_none() || solid2.is_none() { return true }
// They're both present, unwrap them:
let solid1 = solid1.unwrap();
let solid2 = solid2.unwrap();
// This is probably disallowed then, but let's check for a player coexisting with a grab:
if solid1.player() && solid2.grab(self) ||
solid2.player() && solid1.grab(self) {
return true
}
// Nope, two solids that can't coexist:
false
}
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`. /// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
/// ///
/// Non-solid things are never pushable: `pushable` only matters for solids. /// Non-solid things are never pushable: `pushable` only matters for solids.
@@ -287,6 +320,12 @@ impl Board {
/// [`apply_swap`](Board::apply_swap)), where a destination is occupied by /// [`apply_swap`](Board::apply_swap)), where a destination is occupied by
/// another pushable that is itself moving the same frame. Returns `false` if /// another pushable that is itself moving the same frame. Returns `false` if
/// `(x, y)` holds no pushable, or the cell ahead runs off the board. /// `(x, y)` holds no pushable, or the cell ahead runs off the board.
///
/// Shifting onto the **player** is allowed only when the source is a
/// [`grab`](crate::object_def::ObjectDef::grab) thing: it isn't shoving the
/// player aside (a shift can't relocate the player, and `apply_swap` would
/// refuse to overwrite it), it's being grabbed — the player stays put and the
/// thing despawns. Any other solid treats the player as a blocker.
pub fn can_shift(&self, x: usize, y: usize, dir: Direction) -> bool { pub fn can_shift(&self, x: usize, y: usize, dir: Direction) -> bool {
// The source must hold a solid pushable in `dir`. // The source must hold a solid pushable in `dir`.
if !self.is_pushable(x, y, dir) { if !self.is_pushable(x, y, dir) {
@@ -298,6 +337,11 @@ impl Board {
return false; // nothing to shift into off the board return false; // nothing to shift into off the board
} }
let (nx, ny) = (next.0 as usize, next.1 as usize); let (nx, ny) = (next.0 as usize, next.1 as usize);
// Shifting onto the player is only legitimate for a grab thing (it gets
// grabbed, the player isn't moved); otherwise the player is a blocker.
if matches!(self.solid_at(nx, ny), Some(Solid::Player)) {
return self.grab_object_at(x, y).is_some();
}
// The cell ahead is acceptable if it is empty or another pushable solid. // The cell ahead is acceptable if it is empty or another pushable solid.
self.is_passable(nx, ny) || self.is_pushable(nx, ny, dir) self.is_passable(nx, ny) || self.is_pushable(nx, ny, dir)
} }
@@ -370,6 +414,50 @@ impl Board {
}) })
} }
/// Returns the [`ObjectId`] of a solid, **grab**bable object at `(x, y)`, if any.
///
/// Used by [`GameState::try_move`](crate::game::GameState::try_move) to detect
/// the player walking onto a grab thing (e.g. a gem): the move isn't blocked,
/// the object's `grab()` hook fires instead.
pub fn grab_object_at(&self, x: usize, y: usize) -> Option<ObjectId> {
self.objects.iter().find_map(|(&id, o)| {
if o.x == x && o.y == y && o.solid && o.grab {
Some(id)
} else {
None
}
})
}
/// Returns the [`ObjectId`] of a **grab** object that a push at `(x, y)` in
/// `dir` would shove into the player, if any.
///
/// Walks the pushable chain from `(x, y)`; if a chain cell holds a grab object
/// whose immediate forward neighbour is the player, that object is returned.
/// This is the "grab thing pushed into the player" case: rather than sliding
/// the player along, the caller grabs the thing (see
/// [`GameState`](crate::game::GameState)). Returns `None` for an ordinary push.
pub fn pushed_grab_into_player(&self, x: usize, y: usize, dir: Direction) -> Option<ObjectId> {
let (dx, dy): (i32, i32) = dir.into();
let (mut cx, mut cy) = (x, y);
// Advance through the contiguous run of pushable solids.
while self.is_pushable(cx, cy, dir) {
let next = (cx as i32 + dx, cy as i32 + dy);
if !self.in_bounds(next) {
return None;
}
let (nx, ny) = (next.0 as usize, next.1 as usize);
// If the next cell is the player and this cell is a grab object, the
// push would drive that grab thing into the player: grab it instead.
if matches!(self.solid_at(nx, ny), Some(Solid::Player)) {
return self.grab_object_at(cx, cy);
}
cx = nx;
cy = ny;
}
None
}
/// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id. /// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id.
/// ///
/// Ids start at 1 and increase monotonically; an id is never reused, so it /// Ids start at 1 and increase monotonically; an id is never reused, so it
@@ -467,8 +555,13 @@ impl Board {
obj.glyph = glyph; obj.glyph = glyph;
obj.solid = b.solid; obj.solid = b.solid;
obj.opaque = b.opaque; obj.opaque = b.opaque;
obj.pushable = false; // Carry the archetype's pushability/grab onto the object (pushers and
// spinners are Pushable::No, so they stay unpushable; gems are pushable
// and grabbable).
obj.pushable = b.pushable != crate::utils::Pushable::No;
obj.grab = b.grab;
obj.builtin_script = archetype_script(arch); obj.builtin_script = archetype_script(arch);
obj.script_name = archetype_script_key(arch).map(|s| s.to_owned());
obj.tags.insert(builtin_tag(arch)); obj.tags.insert(builtin_tag(arch));
self.add_object(obj); self.add_object(obj);
} }
@@ -489,8 +582,21 @@ impl Board {
/// [`remove_object`](Board::remove_object)). The **player is never destroyed** — /// [`remove_object`](Board::remove_object)). The **player is never destroyed** —
/// a write that would overwrite the player without relocating it is skipped and /// a write that would overwrite the player without relocating it is skipped and
/// logged (the player wins its cell, per the one-solid-per-cell invariant). /// logged (the player wins its cell, per the one-solid-per-cell invariant).
pub fn apply_swap(&mut self, pairs: &[(i32, i32, i32, i32)]) -> Vec<LogLine> { ///
/// **Grab:** a [`grab`](crate::object_def::ObjectDef::grab) object whose
/// destination is the player isn't written onto the player — it is left where it
/// is and its id is returned in the second tuple element so the caller can fire
/// its `grab()` hook (it gets grabbed, the player isn't moved). Because a
/// grabbed object stays put (and its `grab()` may not despawn it), it can end up
/// sharing a cell with a solid that moved in; a final sweep over the swapped
/// cells resolves any such **overlap** — it keeps one solid (preferring a
/// grabbed object so its `grab()` can still fire), deletes the rest (never the
/// player), and logs an error per deletion.
pub fn apply_swap(&mut self, pairs: &[(i32, i32, i32, i32)]) -> (Vec<LogLine>, Vec<ObjectId>) {
let mut errors = Vec::new(); let mut errors = Vec::new();
// Grab objects whose destination was the player: left in place here, their
// grab() hooks fired by the caller (GameState).
let mut grabbed: Vec<ObjectId> = Vec::new();
// 1. Validate: keep only entries whose source and destination are in bounds. // 1. Validate: keep only entries whose source and destination are in bounds.
let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new(); let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new();
@@ -572,6 +678,16 @@ impl Board {
// The player wins its cell: never overwrite player_final with anything // The player wins its cell: never overwrite player_final with anything
// other than the player itself. // other than the player itself.
if (cx, cy) == player_final && !matches!(snap, SolidSnapshot::Player) { if (cx, cy) == player_final && !matches!(snap, SolidSnapshot::Player) {
// A grab object shoved onto the player is grabbed, not blocked: leave
// it where it is (don't install onto the player) and report it so the
// caller fires its grab() hook. The overlap sweep below cleans up if
// its source cell is now also someone else's destination.
if let SolidSnapshot::Object(id) = snap
&& self.objects.get(id).is_some_and(|o| o.grab)
{
grabbed.push(*id);
continue;
}
errors.push(LogLine::error(format!( errors.push(LogLine::error(format!(
"swap: cannot overwrite the player at ({cx},{cy})" "swap: cannot overwrite the player at ({cx},{cy})"
))); )));
@@ -595,7 +711,52 @@ impl Board {
} }
} }
errors // 5. Overlap sweep: a grabbed object left in place (above) may now share a
// cell with a solid that moved in. For any swapped cell holding more than
// one solid, keep a single occupant — a grabbed object if present (so its
// grab() can still fire), otherwise whatever remains — and delete the rest
// (never the player). Logs an error per deletion.
for &(cx, cy) in &affected {
let (ux, uy) = (cx as usize, cy as usize);
let player_here = self.player.x == cx && self.player.y == cy;
// Solid objects on this cell, with grabbed ones first so we keep one.
let mut objs: Vec<ObjectId> = self
.objects
.iter()
.filter(|(_, o)| o.x == ux && o.y == uy && o.solid)
.map(|(&id, _)| id)
.collect();
objs.sort_by_key(|id| !grabbed.contains(id));
let has_terrain = self.solid_cell_layer(ux, uy).is_some();
// How many solids share the cell (player + solid objects + solid terrain).
let total = player_here as usize + objs.len() + has_terrain as usize;
if total <= 1 {
continue;
}
errors.push(LogLine::error(format!(
"swap: {total} solids overlap at ({cx},{cy}); deleting extras"
)));
// Pick the survivor (priority: player > grabbed/first object > terrain)
// and delete every other solid. The player is only ever a survivor, so
// it is never deleted. `keep_obj` is the object we keep, if any.
let keep_obj = (!player_here).then(|| objs.first().copied()).flatten();
for id in &objs {
if Some(*id) != keep_obj {
self.remove_object(*id);
}
}
// Clear terrain unless it's the sole survivor (no player, no kept object).
if has_terrain
&& (player_here || keep_obj.is_some())
&& let Some(z) = self.solid_cell_layer(ux, uy)
{
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
}
}
(errors, grabbed)
} }
/// Reads the solid occupant of `(x, y)` into a [`SolidSnapshot`] (for /// Reads the solid occupant of `(x, y)` into a [`SolidSnapshot`] (for
@@ -740,6 +901,27 @@ pub(crate) mod tests {
assert!(!board.is_passable(2, 0)); assert!(!board.is_passable(2, 0));
} }
#[test]
fn grab_helpers_detect_a_gem() {
// A grabbable gem object at (1,0); the player at (2,0).
let mut gem = ObjectDef::new(1, 0);
gem.grab = true;
gem.pushable = true;
let board = open_board(3, 1, (2, 0), vec![gem]);
// grab_object_at finds the gem on its own cell, nowhere else.
assert_eq!(board.grab_object_at(1, 0), Some(1));
assert_eq!(board.grab_object_at(0, 0), None);
// Pushing the gem east shoves it into the player → reported as a grab.
assert_eq!(
board.pushed_grab_into_player(1, 0, Direction::East),
Some(1)
);
// Pushing it west (away from the player) is an ordinary push.
assert_eq!(board.pushed_grab_into_player(1, 0, Direction::West), None);
}
#[test] #[test]
fn non_solid_object_does_not_block() { fn non_solid_object_does_not_block() {
let mut obj = ObjectDef::new(1, 0); let mut obj = ObjectDef::new(1, 0);
@@ -816,6 +998,22 @@ pub(crate) mod tests {
assert!(!board.can_shift(1, 0, Direction::East)); assert!(!board.can_shift(1, 0, Direction::East));
} }
#[test]
fn can_shift_into_player_only_for_a_grab_thing() {
// A grab gem at (0,0) may shift east onto the player at (1,0): it gets
// grabbed rather than blocked.
let mut gem = ObjectDef::new(0, 0);
gem.grab = true;
gem.pushable = true;
let board = open_board(2, 1, (1, 0), vec![gem]);
assert!(board.can_shift(0, 0, Direction::East));
// A plain crate may not shift onto the player — the player blocks it.
let mut board = open_board(2, 1, (1, 0), vec![]);
crate_at(&mut board, 0, 0);
assert!(!board.can_shift(0, 0, Direction::East));
}
#[test] #[test]
fn push_into_player_pushes_player() { fn push_into_player_pushes_player() {
// Crate shoved east into the player slides the player along into open space. // Crate shoved east into the player slides the player along into open space.
@@ -992,7 +1190,7 @@ pub(crate) mod tests {
let mut board = open_board(3, 1, (1, 0), vec![]); let mut board = open_board(3, 1, (1, 0), vec![]);
crate_at(&mut board, 0, 0); crate_at(&mut board, 0, 0);
wall_at(&mut board, 2, 0); wall_at(&mut board, 2, 0);
let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]); let (errs, _) = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
assert!(errs.is_empty()); assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Wall); assert_eq!(board.get(0, 0, 0).1, Archetype::Wall);
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
@@ -1006,7 +1204,7 @@ pub(crate) mod tests {
// a = (0,0) empty, b = (1,0) crate, c = (2,0) wall. // a = (0,0) empty, b = (1,0) crate, c = (2,0) wall.
crate_at(&mut board, 1, 0); crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0); wall_at(&mut board, 2, 0);
let errs = board.apply_swap(&[(0, 0, 1, 0), (1, 0, 2, 0)]); let (errs, _) = board.apply_swap(&[(0, 0, 1, 0), (1, 0, 2, 0)]);
assert!(errs.is_empty()); assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty); assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
@@ -1017,7 +1215,7 @@ pub(crate) mod tests {
fn apply_swap_empty_onto_object_removes_it() { fn apply_swap_empty_onto_object_removes_it() {
// Moving an empty source onto an object despawns the object. // Moving an empty source onto an object despawns the object.
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]); let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
let errs = board.apply_swap(&[(0, 0, 1, 0)]); let (errs, _) = board.apply_swap(&[(0, 0, 1, 0)]);
assert!(errs.is_empty()); assert!(errs.is_empty());
assert!(board.solid_object_id_at(1, 0).is_none()); assert!(board.solid_object_id_at(1, 0).is_none());
assert!(board.objects.is_empty()); assert!(board.objects.is_empty());
@@ -1029,7 +1227,7 @@ pub(crate) mod tests {
let mut board = open_board(3, 1, (2, 0), vec![]); let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0); crate_at(&mut board, 0, 0);
wall_at(&mut board, 1, 0); wall_at(&mut board, 1, 0);
let errs = board.apply_swap(&[(0, 0, 1, 0)]); let (errs, _) = board.apply_swap(&[(0, 0, 1, 0)]);
assert!(errs.is_empty()); assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
@@ -1039,7 +1237,7 @@ pub(crate) mod tests {
fn apply_swap_moves_object_and_player() { fn apply_swap_moves_object_and_player() {
// An object and the player relocate (swap places) in one batch. // An object and the player relocate (swap places) in one batch.
let mut board = open_board(3, 1, (0, 0), vec![ObjectDef::new(2, 0)]); let mut board = open_board(3, 1, (0, 0), vec![ObjectDef::new(2, 0)]);
let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]); let (errs, _) = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
assert!(errs.is_empty()); assert!(errs.is_empty());
assert_eq!((board.player.x, board.player.y), (2, 0)); assert_eq!((board.player.x, board.player.y), (2, 0));
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
@@ -1049,7 +1247,7 @@ pub(crate) mod tests {
fn apply_swap_out_of_bounds_skips_and_logs() { fn apply_swap_out_of_bounds_skips_and_logs() {
let mut board = open_board(3, 1, (2, 0), vec![]); let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0); crate_at(&mut board, 0, 0);
let errs = board.apply_swap(&[(0, 0, 9, 0)]); let (errs, _) = board.apply_swap(&[(0, 0, 9, 0)]);
assert_eq!(errs.len(), 1); assert_eq!(errs.len(), 1);
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged
} }
@@ -1059,9 +1257,42 @@ pub(crate) mod tests {
// A crate moved onto the (non-relocating) player is rejected and logged. // A crate moved onto the (non-relocating) player is rejected and logged.
let mut board = open_board(3, 1, (1, 0), vec![]); let mut board = open_board(3, 1, (1, 0), vec![]);
crate_at(&mut board, 0, 0); crate_at(&mut board, 0, 0);
let errs = board.apply_swap(&[(0, 0, 1, 0)]); let (errs, _) = board.apply_swap(&[(0, 0, 1, 0)]);
assert_eq!(errs.len(), 1); assert_eq!(errs.len(), 1);
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // source still vacated assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // source still vacated
} }
#[test]
fn apply_swap_grab_onto_player_is_reported_not_moved() {
// A grab gem swapped onto the player is reported (for its grab() hook) and
// left at its source rather than overwriting / sliding the player.
let mut gem = ObjectDef::new(0, 0);
gem.grab = true;
gem.pushable = true;
let mut board = open_board(2, 1, (1, 0), vec![gem]);
let (errs, grabbed) = board.apply_swap(&[(0, 0, 1, 0)]);
assert!(errs.is_empty());
assert_eq!(grabbed, vec![1]);
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // gem stayed
}
#[test]
fn apply_swap_overlap_after_grab_deletes_the_other_solid() {
// gem (0,0)→player (1,0) [grabbed, stays at (0,0)] while a crate (2,0)→(0,0)
// moves into the gem's cell. The sweep keeps the grabbed gem and deletes the
// crate, logging the overlap.
let mut gem = ObjectDef::new(0, 0);
gem.grab = true;
gem.pushable = true;
let mut board = open_board(3, 1, (1, 0), vec![gem]);
crate_at(&mut board, 2, 0);
let (errs, grabbed) = board.apply_swap(&[(0, 0, 1, 0), (2, 0, 0, 0)]);
assert_eq!(grabbed, vec![1]);
assert_eq!(errs.len(), 1); // the overlap was logged
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // gem kept
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // crate deleted
assert_eq!((board.player.x, board.player.y), (1, 0)); // player untouched
}
} }
+17
View File
@@ -26,6 +26,10 @@ const PUSHER: &str = include_str!("scripts/pusher.rhai");
/// direction comes from the object's tag, so all spinners share one compiled AST. /// direction comes from the object's tag, so all spinners share one compiled AST.
const SPINNER: &str = include_str!("scripts/spinner.rhai"); const SPINNER: &str = include_str!("scripts/spinner.rhai");
/// The gem behavior, embedded into the binary: a `grab()` hook that adds a gem to
/// the player and removes the object.
const GEM: &str = include_str!("scripts/gem.rhai");
/// The tag identifying an object as the expanded form of `arch`, e.g. /// The tag identifying an object as the expanded form of `arch`, e.g.
/// `"BUILTIN_pusher_east"`. /// `"BUILTIN_pusher_east"`.
pub(crate) fn builtin_tag(arch: Archetype) -> String { pub(crate) fn builtin_tag(arch: Archetype) -> String {
@@ -46,6 +50,19 @@ pub(crate) fn archetype_script(arch: Archetype) -> Option<&'static str> {
match arch { match arch {
Archetype::Pusher(_) => Some(PUSHER), Archetype::Pusher(_) => Some(PUSHER),
Archetype::Spinner(_) => Some(SPINNER), Archetype::Spinner(_) => Some(SPINNER),
Archetype::Gem => Some(GEM),
_ => None,
}
}
/// Returns the embedded script implementing `arch` as an object, or `None` if the
/// archetype is a plain terrain cell. The one place script-backed archetypes are
/// declared.
pub(crate) fn archetype_script_key(arch: Archetype) -> Option<&'static str> {
match arch {
Archetype::Pusher(_) => Some("BUILTIN_pusher"),
Archetype::Spinner(_) => Some("BUILTIN_spinner"),
Archetype::Gem => Some("BUILTIN_gem"),
_ => None, _ => None,
} }
} }
+168 -16
View File
@@ -210,6 +210,12 @@ impl GameState {
// below also borrows `self`. // below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new(); let mut logs: Vec<LogLine> = Vec::new();
let mut bumps: Vec<(ObjectId, i64)> = Vec::new(); let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
// Grab hooks to fire (player walked into / pushed a grab thing); fired after
// the board borrow drops, like bumps.
let mut grabs: Vec<ObjectId> = Vec::new();
// Net change to the player's gem count from AddGems actions; applied to
// `self` after the board borrow drops.
let mut gem_delta: i64 = 0;
let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new(); let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new(); let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll. // Collected outside the board borrow so we can assign to self.active_scroll.
@@ -219,9 +225,13 @@ impl GameState {
for ba in actions { for ba in actions {
match ba.action { match ba.action {
Action::Move(dir) => { Action::Move(dir) => {
if let Some(bumped) = step_object(&mut board, ba.source, dir) { let outcome = step_object(&mut board, ba.source, dir);
if let Some(bumped) = outcome.bumped {
bumps.push((bumped, ba.source as i64)); bumps.push((bumped, ba.source as i64));
} }
if let Some(grabbed) = outcome.grabbed {
grabs.push(grabbed);
}
} }
Action::SetTile(tile) => { Action::SetTile(tile) => {
if let Some(obj) = board.objects.get_mut(&ba.source) { if let Some(obj) = board.objects.get_mut(&ba.source) {
@@ -299,15 +309,32 @@ impl GameState {
} }
// push() self-checks can_push, so an in-bounds guard is all we add. // push() self-checks can_push, so an in-bounds guard is all we add.
Action::Push { x, y, dir } => { Action::Push { x, y, dir } => {
if board.in_bounds((x, y)) { if !board.in_bounds((x, y)) {
board.push(x as usize, y as usize, dir);
} else {
logs.push(LogLine::error(format!("push({x},{y}): out of bounds"))); logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
} else if let Some(grabbed) =
board.pushed_grab_into_player(x as usize, y as usize, dir)
{
// A grab thing shoved into the player is grabbed, not pushed.
grabs.push(grabbed);
} else {
board.push(x as usize, y as usize, dir);
} }
} }
// apply_swap reads all sources then writes all destinations, so a // apply_swap reads all sources then writes all destinations, so a
// whole batch of moves resolves simultaneously (cycles included). // whole batch of moves resolves simultaneously (cycles included).
Action::Swap(pairs) => logs.extend(board.apply_swap(&pairs)), // A grab thing swapped onto the player is reported back for its
// grab() hook (fired in phase B with the other grabs).
Action::Swap(pairs) => {
let (errs, grabbed) = board.apply_swap(&pairs);
logs.extend(errs);
grabs.extend(grabbed);
}
// Accumulated and applied to `self.player_gems` after the borrow drops.
Action::AddGems(n) => gem_delta += n,
// A grab thing despawns itself from its grab() hook.
Action::Die => {
board.remove_object(ba.source);
}
} }
} }
} }
@@ -321,9 +348,18 @@ impl GameState {
if let Some(scroll) = new_scroll { if let Some(scroll) = new_scroll {
self.active_scroll = Some(scroll); self.active_scroll = Some(scroll);
} }
// Apply the net gem change (clamped at 0, since the count is unsigned).
if gem_delta != 0 {
self.player_gems = (self.player_gems as i64 + gem_delta).max(0) as u32;
}
for (bumped, bumper) in bumps { for (bumped, bumper) in bumps {
self.scripts.run_bump(bumped, bumper); self.scripts.run_bump(bumped, bumper);
} }
// Fire grab() hooks (player walked into / pushed a grab thing). The hook
// typically adds a stat and calls die(); those actions resolve next pass.
for grabbed in grabs {
self.scripts.run_grab(grabbed);
}
for (target, fn_name, arg) in sends { for (target, fn_name, arg) in sends {
self.scripts.run_send(target, &fn_name, arg); self.scripts.run_send(target, &fn_name, arg);
} }
@@ -403,6 +439,7 @@ impl GameState {
/// object's `bump(-1)` hook fires (whether or not the player ends up moving). /// object's `bump(-1)` hook fires (whether or not the player ends up moving).
pub fn try_move(&mut self, dir: Direction) { pub fn try_move(&mut self, dir: Direction) {
let bumped; let bumped;
let grabbed;
let portal_target; let portal_target;
{ {
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i32, i32) = dir.into();
@@ -412,13 +449,21 @@ impl GameState {
return; return;
} }
let (nx, ny) = (target.0 as usize, target.1 as usize); let (nx, ny) = (target.0 as usize, target.1 as usize);
// A solid object in the way is bumped by the player (id -1). // Walking onto a grab thing (e.g. a gem) is never blocked: the player
// moves onto it and its grab() hook fires (the thing despawns itself).
grabbed = board.grab_object_at(nx, ny);
// A solid object in the way is bumped by the player (id -1) — but a
// grab thing fires grab() instead of bump(), so don't also bump it.
bumped = match board.solid_at(nx, ny) { bumped = match board.solid_at(nx, ny) {
Some(Solid::Object(_)) => board.solid_object_id_at(nx, ny), Some(Solid::Object(_)) if grabbed.is_none() => board.solid_object_id_at(nx, ny),
_ => None, _ => None,
}; };
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) { if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise // Don't push a grab thing aside — walk onto it. Otherwise shove any
// pushable chain out of the way (no-op when there's nothing to push).
if grabbed.is_none() {
board.push(nx, ny, dir);
}
board.player.x = nx as i32; board.player.x = nx as i32;
board.player.y = ny as i32; board.player.y = ny as i32;
// Check for a portal at the new position; clone strings to release the borrow. // Check for a portal at the new position; clone strings to release the borrow.
@@ -436,6 +481,12 @@ impl GameState {
self.enter_board(&target_map, &target_entry); self.enter_board(&target_map, &target_entry);
return; return;
} }
// Fire the grab hook and resolve it immediately so the grabbed thing's
// die()/add_gems() apply now — no player+object overlap survives this call.
if let Some(id) = grabbed {
self.scripts.run_grab(id);
self.resolve();
}
if let Some(idx) = bumped { if let Some(idx) = bumped {
self.scripts.run_bump(idx, -1); self.scripts.run_bump(idx, -1);
self.drain_errors(); self.drain_errors();
@@ -443,22 +494,46 @@ impl GameState {
} }
} }
/// Moves object `id` one cell in `dir` on `board`, returning the [`ObjectId`] of a /// The side effects of a [`step_object`] call that the caller resolves after the
/// solid object it bumped (its target cell was occupied by that object), if any. /// board borrow drops: a `bump`ed object and/or a `grab`bed object.
#[derive(Default)]
struct StepOutcome {
/// A solid object the mover collided with (target cell was occupied by it).
bumped: Option<ObjectId>,
/// A grab object the move shoved into the player (its `grab()` hook should fire).
grabbed: Option<ObjectId>,
}
/// Moves object `id` one cell in `dir` on `board`, returning the objects it bumped
/// and/or grabbed (see [`StepOutcome`]).
/// ///
/// The move itself proceeds only if the target is in bounds and either passable or a /// The move itself proceeds only if the target is in bounds and either passable or a
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The /// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
/// bump is recorded whenever a solid object occupies the target — whether it gets /// bump is recorded whenever a solid object occupies the target — whether it gets
/// pushed aside or blocks the move — since something tried to move into it. Walls and /// pushed aside or blocks the move — since something tried to move into it. Walls and
/// crates carry no script, so only solid objects yield a bump. /// crates carry no script, so only solid objects yield a bump.
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> { ///
/// If the push would shove a **grab** thing into the player (see
/// [`Board::pushed_grab_into_player`]), the push is skipped and the grab thing is
/// reported instead of sliding the player along.
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome {
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i32, i32) = dir.into();
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?; let Some((ox, oy)) = board.objects.get(&id).map(|o| (o.x, o.y)) else {
return StepOutcome::default();
};
let target = (ox as i32 + dx, oy as i32 + dy); let target = (ox as i32 + dx, oy as i32 + dy);
if !board.in_bounds(target) { if !board.in_bounds(target) {
return None; return StepOutcome::default();
} }
let (nx, ny) = (target.0 as usize, target.1 as usize); let (nx, ny) = (target.0 as usize, target.1 as usize);
// A push that would drive a grab thing into the player grabs it instead of
// sliding the player; skip the move entirely (the grab thing despawns itself).
if let Some(grabbed) = board.pushed_grab_into_player(nx, ny, dir) {
return StepOutcome {
bumped: None,
grabbed: Some(grabbed),
};
}
// Capture the bumped object before any push relocates it (its id is stable). // Capture the bumped object before any push relocates it (its id is stable).
let bumped = match board.solid_at(nx, ny) { let bumped = match board.solid_at(nx, ny) {
Some(Solid::Object(_)) => board.solid_object_id_at(nx, ny), Some(Solid::Object(_)) => board.solid_object_id_at(nx, ny),
@@ -470,18 +545,95 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
obj.x = nx; obj.x = nx;
obj.y = ny; obj.y = ny;
} }
bumped StepOutcome {
bumped,
grabbed: None,
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::GameState; use super::GameState;
use crate::Direction;
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at}; use crate::board::tests::{crate_at, open_board, stamp, wall_at};
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
#[test]
fn walking_onto_a_gem_grabs_it() {
// A gem terrain cell at (1,0); expanding turns it into the builtin gem
// object running scripts/gem.rhai. The player starts at (0,0).
let mut board = open_board(3, 1, (0, 0), vec![]);
stamp(&mut board, 1, 0, Archetype::Gem);
board.expand_builtin_archetypes();
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
// The gem was grabbed: gem count up, gem object gone, player on its cell.
assert_eq!(game.player_gems, 1);
assert!(game.board().objects.is_empty());
assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
}
#[test]
fn pushing_a_gem_into_the_player_grabs_it() {
// A non-solid script object at (0,0) pushes the gem at (1,0) east into the
// player at (2,0); the gem is grabbed rather than sliding the player.
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let mut board = open_board(3, 1, (2, 0), vec![sobj]);
stamp(&mut board, 1, 0, Archetype::Gem);
board.expand_builtin_archetypes();
let scripts = HashMap::from([(
"s".to_string(),
"fn tick(dt) { if Queue.length() == 0 { push(1, 0, East); } }".to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
// First tick: the push resolves into a grab (gem's grab() is queued).
// Second tick: the gem's add_gems()/die() resolve.
game.tick(Duration::from_millis(16));
game.tick(Duration::from_millis(16));
assert_eq!(game.player_gems, 1);
// The gem (the only non-script object) is gone; the player never moved.
assert!(!game.board().objects.values().any(|o| o.grab));
assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
}
#[test]
fn swapping_a_gem_into_the_player_grabs_it() {
// A non-solid script object swaps the gem at (1,0) onto the player at (2,0).
// apply_swap reports the grab; the gem's grab() collects it and die()s.
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let mut board = open_board(3, 1, (2, 0), vec![sobj]);
stamp(&mut board, 1, 0, Archetype::Gem);
board.expand_builtin_archetypes();
let scripts = HashMap::from([(
"s".to_string(),
"fn tick(dt) { if Queue.length() == 0 { swap([[1, 0, 2, 0]]); } }".to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
// First tick: the swap reports the grab (gem's grab() is queued).
// Second tick: the gem's add_gems()/die() resolve.
game.tick(Duration::from_millis(16));
game.tick(Duration::from_millis(16));
assert_eq!(game.player_gems, 1);
assert!(!game.board().objects.values().any(|o| o.grab));
assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
}
/// Builds a `GameState` with one non-solid object running `src` as its script. /// Builds a `GameState` with one non-solid object running `src` as its script.
fn game_with_object_script(board_w: usize, src: &str) -> GameState { fn game_with_object_script(board_w: usize, src: &str) -> GameState {
let mut obj = ObjectDef::new(0, 0); let mut obj = ObjectDef::new(0, 0);
+3
View File
@@ -237,6 +237,9 @@ impl TryFrom<MapFile> for Board {
solid: t.solid, solid: t.solid,
opaque: t.opaque, opaque: t.opaque,
pushable: t.pushable, pushable: t.pushable,
// Hand-placed objects are never grab targets; only expanded
// grab archetypes (e.g. gems) set this (see expand_builtin_archetypes).
grab: false,
script_name: t.script_name, script_name: t.script_name,
// Script-backed archetypes are expanded after the board is built // Script-backed archetypes are expanded after the board is built
// (see `expand_builtin_archetypes` below), not via templates. // (see `expand_builtin_archetypes` below), not via templates.
+6
View File
@@ -49,6 +49,11 @@ pub struct ObjectDef {
/// Whether the object can be shoved by a mover. Unused for now (no push /// Whether the object can be shoved by a mover. Unused for now (no push
/// mechanic yet); defaults to `false`. /// mechanic yet); defaults to `false`.
pub pushable: bool, pub pushable: bool,
/// Whether walking into this object **grabs** it: the player moves onto the
/// cell and the object's `grab()` hook fires (the object is expected to
/// remove itself via `die()`). Set when a grab archetype (e.g. a gem) is
/// expanded into an object. Defaults to `false`.
pub grab: bool,
/// Name of the Rhai script in [`Board::scripts`] that drives this object. /// Name of the Rhai script in [`Board::scripts`] that drives this object.
/// `None` means this object has no script yet. /// `None` means this object has no script yet.
pub script_name: Option<String>, pub script_name: Option<String>,
@@ -93,6 +98,7 @@ impl ObjectDef {
solid: true, solid: true,
opaque: true, opaque: true,
pushable: false, pushable: false,
grab: false,
script_name: None, script_name: None,
builtin_script: None, builtin_script: None,
tags: HashSet::new(), tags: HashSet::new(),
+72 -8
View File
@@ -8,6 +8,8 @@
//! - `tick(dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]). //! - `tick(dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]).
//! - `bump(id)` — run when another mover steps into this object's cell, with the //! - `bump(id)` — run when another mover steps into this object's cell, with the
//! bumper's object index (`-1` for the player); see [`ScriptHost::run_bump`]. //! bumper's object index (`-1` for the player); see [`ScriptHost::run_bump`].
//! - `grab()` — run when the player walks onto (or pushes into themselves) a
//! `grab` object; see [`ScriptHost::run_grab`]. Typically adds a stat + `die()`s.
//! //!
//! ## Script constants //! ## Script constants
//! //!
@@ -55,7 +57,7 @@
//! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`, //! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`,
//! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`, //! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`,
//! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`, //! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`,
//! `swap([[src_x, src_y, dst_x, dst_y], …])` //! `swap([[src_x, src_y, dst_x, dst_y], …])`, `add_gems(n)`, `die()`
//! //!
//! ## Queue API //! ## Queue API
//! //!
@@ -106,6 +108,7 @@ struct CompiledScript {
has_init: bool, has_init: bool,
has_tick: bool, has_tick: bool,
has_bump: bool, has_bump: bool,
has_grab: bool,
} }
/// The runtime state of one scripted object. /// The runtime state of one scripted object.
@@ -122,11 +125,7 @@ struct ObjectRuntime {
/// built-in (so identical built-ins share one compiled AST), or the world-pool /// built-in (so identical built-ins share one compiled AST), or the world-pool
/// name for a named script. `None` if the object has no script. /// name for a named script. `None` if the object has no script.
fn script_key(obj: &ObjectDef) -> Option<String> { fn script_key(obj: &ObjectDef) -> Option<String> {
if let Some(src) = obj.builtin_script { obj.script_name.clone()
Some(src.to_string())
} else {
obj.script_name.clone()
}
} }
// ── Rhai types exposed to scripts ──────────────────────────────────────────── // ── Rhai types exposed to scripts ────────────────────────────────────────────
@@ -266,6 +265,7 @@ impl ScriptHost {
has_init: defines("init", 0), has_init: defines("init", 0),
has_tick: defines("tick", 1), has_tick: defines("tick", 1),
has_bump: defines("bump", 1), has_bump: defines("bump", 1),
has_grab: defines("grab", 0),
ast, ast,
}, },
); );
@@ -356,6 +356,48 @@ impl ScriptHost {
self.drain(i, 0.0); self.drain(i, 0.0);
} }
/// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the
/// hook, then drains its queue with `dt = 0`.
///
/// Fired when the player walks onto a grab object or a grab object is pushed
/// into the player (see [`GameState`](crate::game::GameState)). The hook
/// typically increments a player stat and removes the object via `die()`.
pub fn run_grab(&mut self, object_id: ObjectId) {
let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else {
return;
};
{
let Self {
engine,
scripts,
objects,
errors,
..
} = self;
let obj = &mut objects[i];
let Some(compiled) = scripts.get(&obj.script_key) else {
return;
};
if !compiled.has_grab {
return;
}
let options = CallFnOptions::default().with_tag(object_id as i64);
if let Err(err) = engine.call_fn_with_options::<()>(
options,
&mut obj.scope,
&compiled.ast,
"grab",
(),
) {
errors.borrow_mut().push(LogLine::error(format!(
"script '{}' grab error: {err}",
obj.script_key
)));
}
}
self.drain(i, 0.0);
}
/// Calls the named function on the object with [`ObjectId`] `target_id`. /// Calls the named function on the object with [`ObjectId`] `target_id`.
/// ///
/// If `arg` is `Some` and the function accepts one parameter, the arg is passed. /// If `arg` is `Some` and the function accepts one parameter, the arg is passed.
@@ -689,8 +731,9 @@ fn register_read_api(
}); });
// can_shift(x, y, dir) — like can_push but inspects only the cell ahead: (x, y) // can_shift(x, y, dir) — like can_push but inspects only the cell ahead: (x, y)
// holds a pushable and the next cell is empty or another pushable. False // holds a pushable and the next cell is empty or another pushable. Shifting
// off-board. The right gate for a simultaneous shift/rotation via swap(). // onto the player counts only when the source is a grab thing (it gets
// grabbed). False off-board. The right gate for a shift/rotation via swap().
let b = board.clone(); let b = board.clone();
engine.register_fn("can_shift", move |x: i64, y: i64, dir: Direction| -> bool { engine.register_fn("can_shift", move |x: i64, y: i64, dir: Direction| -> bool {
let board = b.borrow(); let board = b.borrow();
@@ -706,6 +749,15 @@ fn register_read_api(
board.in_bounds((x as i32, y as i32)) && board.is_passable(x as usize, y as usize) board.in_bounds((x as i32, y as i32)) && board.is_passable(x as usize, y as usize)
}); });
// combinable(x1, y1, x2, y2) — true if the solids at (x1, y1) and (x2, y2) can coexist in a cell
// (meaning, either one contains no solid, or one contains a grab solid and the other contains the
// player). Off-board is not combinable with anything.
let b = board.clone();
engine.register_fn("combinable", move |x1: i64, y1: i64, x2: i64, y2: i64| -> bool {
let board = b.borrow();
board.is_combinable(x1 as usize, y1 as usize, x2 as usize, y2 as usize)
});
// Board.tagged(tag) -> Array[ObjectInfo] // Board.tagged(tag) -> Array[ObjectInfo]
let b = board.clone(); let b = board.clone();
engine.register_fn( engine.register_fn(
@@ -841,6 +893,18 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
emit(&q, source_of(&ctx), Action::SetTile(tile as u32)); emit(&q, source_of(&ctx), Action::SetTile(tile as u32));
}); });
// add_gems(n): change the player's gem count (negative subtracts; clamped at 0).
let q = queues.clone();
engine.register_fn("add_gems", move |ctx: NativeCallContext, n: i64| {
emit(&q, source_of(&ctx), Action::AddGems(n));
});
// die(): remove the calling object from the board.
let q = queues.clone();
engine.register_fn("die", move |ctx: NativeCallContext| {
emit(&q, source_of(&ctx), Action::Die);
});
let q = queues.clone(); let q = queues.clone();
engine.register_fn( engine.register_fn(
"log", "log",
+9
View File
@@ -0,0 +1,9 @@
// Built-in script for the `gem` archetype (see kiln-core/src/builtin_scripts.rs).
//
// A gem is a grabbable collectible: walking onto it (or pushing it into the
// player) fires this `grab()` hook instead of blocking. We bump the player's gem
// count and remove ourselves from the board.
fn grab() {
add_gems(1);
die();
}
+7 -1
View File
@@ -68,9 +68,15 @@ fn tick(dt) {
let changed = false; let changed = false;
for i in 0..8 { for i in 0..8 {
let n = (i + step) % 8; let n = (i + step) % 8;
let pn = (i + step - 1) % 8;
let dx = ox + ring[n][0]; let dx = ox + ring[n][0];
let dy = oy + ring[n][1]; let dy = oy + ring[n][1];
if moves[i] && !passable(dx, dy) && !moves[n] { let sx = ox + ring[pn][0];
let sy = oy + ring[pn][1];
let blocked = !passable(dx, dy) && !combinable(sx, sy, dx, dy);
if moves[i] && blocked && !moves[n] {
moves[i] = false; moves[i] = false;
changed = true; changed = true;
} }
+22
View File
@@ -2,6 +2,7 @@ use crate::archetype::Archetype;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use color::Rgba8; use color::Rgba8;
use rhai::Dynamic; use rhai::Dynamic;
use crate::Board;
/// Which directions a solid may be pushed in. /// Which directions a solid may be pushed in.
/// ///
@@ -51,6 +52,11 @@ pub struct Behavior {
pub opaque: bool, pub opaque: bool,
/// Which directions a mover can shove this cell in (only meaningful when `solid`). /// Which directions a mover can shove this cell in (only meaningful when `solid`).
pub pushable: Pushable, pub pushable: Pushable,
/// Whether walking into this solid **grabs** it instead of being blocked: the
/// player passes onto the cell and the thing's `grab()` script hook fires (and
/// the thing is expected to remove itself via `die()`). Only meaningful when
/// `solid`. The same grab fires if the thing is pushed into the player.
pub grab: bool,
} }
/// A stable, unique identifier for a board object. /// A stable, unique identifier for a board object.
@@ -77,6 +83,22 @@ pub enum Solid {
Object(ObjectId), Object(ObjectId),
} }
impl Solid {
pub fn player(&self) -> bool {
matches!(self, Solid::Player)
}
pub fn grab(&self, board: &Board) -> bool {
match self {
Solid::Player => false,
Solid::Cell(arch) => arch.behavior().grab,
Solid::Object(id) => {
board.objects.get(id).map_or(false, |obj| obj.grab)
}
}
}
}
/// A portal that teleports the player to a named entry point on another board. /// A portal that teleports the player to a named entry point on another board.
/// ///
/// Portals are loaded from `[[portals]]` entries in `.toml` map files and /// Portals are loaded from `[[portals]]` entries in `.toml` map files and
+2 -2
View File
@@ -22,7 +22,7 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact
**`kiln-tui/src/editor.rs`** — world editor mode: **`kiln-tui/src/editor.rs`** — world editor mode:
- `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`). - `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`).
- `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`) — a sidebar menu level; `title()` (breadcrumb segment) and `entries() -> Vec<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main``World`/`Floor`/`Terrain`/`Machines`; `Machines``Pushers`), **sets the drawing tool** (`Terrain`/`Pushers``set_current(arch)`, plus `Machines``[s]` CW / `[c]` CCW spinner), or **launches a playtest** (`Main``[p] Play board``playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor. - `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`/`Items`) — a sidebar menu level; (`Main``[i] Items``[g] ♦ Gem`, which `set_current(Archetype::Gem)`); `title()` (breadcrumb segment) and `entries() -> Vec<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main``World`/`Floor`/`Terrain`/`Machines`; `Machines``Pushers`), **sets the drawing tool** (`Terrain`/`Pushers``set_current(arch)`, plus `Machines``[s]` CW / `[c]` CCW spinner), or **launches a playtest** (`Main``[p] Play board``playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor.
- Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, **expands script-backed archetypes** in every board via `Board::expand_builtin_archetypes` so editor-stamped spinners/pushers run, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs). - Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, **expands script-backed archetypes** in every board via `Board::expand_builtin_archetypes` so editor-stamped spinners/pushers run, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs).
- Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`. - Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`.
- `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel. - `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel.
@@ -61,7 +61,7 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact
- `board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if off-screen. Used by bubble placement. - `board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if off-screen. Used by bubble placement.
**`kiln-tui/src/status.rs`** — play-mode status sidebar widget: **`kiln-tui/src/status.rs`** — play-mode status sidebar widget:
- `StatusSidebarWidget` — a `ratatui::widgets::Widget` built with `new(health, gems)` that draws the player's stats in a bordered `Block` titled `Status`: a `Health:` label above a row of `MAX_HEARTS` (5) `♥` glyphs (first `health` bright red, rest dark red) and a `Gems: ♦ N` line (gem glyph blue). Purely presentational — reads the values handed in, never mutates game state. Drawn by `draw_play` when `ui.show_status` is set; `i` toggles it. - `StatusSidebarWidget` — a `ratatui::widgets::Widget` built with `new(health, gems)` that draws the player's stats in a bordered `Block` titled `Status`: a `Health:` label above a row of `MAX_HEARTS` (5) `♥` glyphs (first `health` bright red, rest dark red) and a `Gems: ♦ N` line. The gem indicator is rendered from `kiln_core::Archetype::Gem.default_glyph()` (char via `cp437::tile_to_char`, color via `rgba8_to_color`) so it matches gems on the board — not a hardcoded glyph/color. Purely presentational — reads the values handed in, never mutates game state. Drawn by `draw_play` when `ui.show_status` is set; `Tab` toggles it.
**`kiln-tui/src/log.rs`** — log panel widget: **`kiln-tui/src/log.rs`** — log panel widget:
- `LogState { open, height, scroll }` — panel visibility, height in rows, and scroll offset. `toggle()`, `scroll_by(delta, log_len)`, `resize(target, total)`. - `LogState { open, height, scroll }` — panel visibility, height in rows, and scroll offset. `toggle()`, `scroll_by(delta, log_len)`, `resize(target, total)`.
+8
View File
@@ -26,6 +26,8 @@ pub(crate) enum MenuLevel {
Machines, Machines,
/// Various directions of pushers /// Various directions of pushers
Pushers, Pushers,
/// Collectible items the player picks up (e.g. gems)
Items,
} }
impl MenuLevel { impl MenuLevel {
@@ -38,6 +40,7 @@ impl MenuLevel {
MenuLevel::Terrain => "Terrain", MenuLevel::Terrain => "Terrain",
MenuLevel::Machines => "Machines", MenuLevel::Machines => "Machines",
MenuLevel::Pushers => "Pushers", MenuLevel::Pushers => "Pushers",
MenuLevel::Items => "Items",
} }
} }
@@ -54,6 +57,7 @@ impl MenuLevel {
MenuEntry::item('f', "Floor", |ed| ed.menu.push(MenuLevel::Floor)), MenuEntry::item('f', "Floor", |ed| ed.menu.push(MenuLevel::Floor)),
MenuEntry::item('t', "Terrain", |ed| ed.menu.push(MenuLevel::Terrain)), MenuEntry::item('t', "Terrain", |ed| ed.menu.push(MenuLevel::Terrain)),
MenuEntry::item('m', "Machines", |ed| ed.menu.push(MenuLevel::Machines)), MenuEntry::item('m', "Machines", |ed| ed.menu.push(MenuLevel::Machines)),
MenuEntry::item('i', "Items", |ed| ed.menu.push(MenuLevel::Items)),
], ],
MenuLevel::World => vec![ MenuLevel::World => vec![
MenuEntry::item('n', "Name", |ed| ed.open_name_dialog()), MenuEntry::item('n', "Name", |ed| ed.open_name_dialog()),
@@ -96,6 +100,10 @@ impl MenuLevel {
ed.set_current(Archetype::Pusher(Direction::West)) ed.set_current(Archetype::Pusher(Direction::West))
}), }),
], ],
MenuLevel::Items => vec![MenuEntry::item('t', "♦ Gem", |ed| {
// gem and glyph start with the same letter
ed.set_current(Archetype::Gem)
})],
} }
} }
+10 -3
View File
@@ -5,6 +5,9 @@
//! presentational: it reads `health`/`gems` values handed in at construction and //! presentational: it reads `health`/`gems` values handed in at construction and
//! never mutates game state. //! never mutates game state.
use crate::utils::rgba8_to_color;
use kiln_core::Archetype;
use kiln_core::cp437::tile_to_char;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Style};
@@ -18,8 +21,6 @@ const MAX_HEARTS: u32 = 5;
const HEART_FULL: Color = Color::Rgb(255, 0, 0); const HEART_FULL: Color = Color::Rgb(255, 0, 0);
/// A depleted heart: dark red. /// A depleted heart: dark red.
const HEART_EMPTY: Color = Color::Rgb(90, 0, 0); const HEART_EMPTY: Color = Color::Rgb(90, 0, 0);
/// The gem glyph color: blue.
const GEM: Color = Color::Rgb(80, 80, 255);
/// A ratatui widget that renders the play-mode status sidebar. /// A ratatui widget that renders the play-mode status sidebar.
/// ///
@@ -51,13 +52,19 @@ impl Widget for StatusSidebarWidget {
}) })
.collect(); .collect();
// Draw the gem indicator from the gem archetype's default glyph, so the
// sidebar matches what gems look like on the board (no hardcoded ♦/color).
let gem_glyph = Archetype::Gem.default_glyph();
let gem_char = tile_to_char(gem_glyph.tile).to_string();
let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg));
let lines = vec![ let lines = vec![
Line::from("Health:"), Line::from("Health:"),
Line::from(hearts), Line::from(hearts),
Line::from(""), Line::from(""),
Line::from(vec![ Line::from(vec![
Span::raw("Gems: "), Span::raw("Gems: "),
Span::styled("", Style::default().fg(GEM)), Span::styled(gem_char, gem_style),
Span::raw(format!(" {}", self.gems)), Span::raw(format!(" {}", self.gems)),
]), ]),
]; ];
+4 -2
View File
@@ -225,8 +225,8 @@ content = """
# | # # | #
# # # #
# V oo G - @ # # V oo G - @ #
# # # /# #
# # # t #
# # # #
# >| # # >| #
# # # #
@@ -252,6 +252,8 @@ content = """
"V" = { kind = "object", tile = 1, fg = "#33aa33", bg = "#000000", script_name = "mover" } "V" = { kind = "object", tile = 1, fg = "#33aa33", bg = "#000000", script_name = "mover" }
"M" = { kind = "object", tile = 15, fg = "#ffdd88", bg = "#000000", solid = true, name = "muffin", script_name = "muffin" } "M" = { kind = "object", tile = 15, fg = "#ffdd88", bg = "#000000", solid = true, name = "muffin", script_name = "muffin" }
"B" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "noticeboard", script_name = "noticeboard" } "B" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "noticeboard", script_name = "noticeboard" }
"/" = { kind = "spinner_cw" }
"t" = { kind = "gem" }
# Layer 2: a purely decorative, non-solid object drawn *above* the solid crate # Layer 2: a purely decorative, non-solid object drawn *above* the solid crate
# beneath it — only possible because draw order follows layer order. # beneath it — only possible because draw order follows layer order.