diff --git a/CLAUDE.md b/CLAUDE.md index efc223c..0ec56d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ fn init(me, state) { # optional, run once at startup fn tick(me, state, dt) { if !me.waiting && !me.blocked(North) { move(North); } } -fn bump(me, state, id) { log(`bumped by ${id}`); say("Ouch!"); } +fn bump(me, dir) { log(`bumped from ${dir}`); say("Ouch!"); } # dir: the side it came from """ # Each board is a named subtable under [boards]. The board key ("room1") is @@ -130,7 +130,7 @@ content = """ Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `transporter_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph. -Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, state, id)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`) and `state` (the world). They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately: 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`. The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md). +Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`). `bump`'s `dir` is the `Direction` the bump came *from* (pointing toward the bumper), fired when *any* solid — the player, another object, or a pushed crate — presses into this object. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately: 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` with the direction the bump came from. The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md). **Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(me.id, "visited", true)`) or the per-board `Registry` (which also persists across transitions). diff --git a/docs/script-api.md b/docs/script-api.md index 18c9b3e..626174a 100644 --- a/docs/script-api.md +++ b/docs/script-api.md @@ -48,17 +48,20 @@ fn tick(me, state, dt) { } ``` -### `fn bump(me, state, id)` +### `fn bump(me, dir)` -Called when another entity walks into this object's cell. +Called when a solid presses into this object's cell — the player, another object, or a **crate** being +pushed into it (the push chain is walked through to reach the object it presses against). -- `id` is the bumper's `ObjectId` (an `i64`). -- `id == -1` means the **player** bumped this object. +- `dir` is the [`Direction`] the bump **came from**: it points from this object toward the bumper, so + the bumper occupies the cell at `(me.x + dir.dx, me.y + dir.dy)`. +- Compare it against the `North`/`South`/`East`/`West` constants. +- The hook can no longer tell *who* did the bumping (there is no id) — a crate bumps just like the player. ```rhai -fn bump(me, state, id) { - if id == -1 { say("Hey! Watch it."); } - else { log(`object ${id} bumped me`); } +fn bump(me, dir) { + if dir == West { say("Something shoved me from the west."); } + else { log(`bumped from ${dir}`); } } ``` @@ -297,7 +300,7 @@ function call named `choice_key` (see [custom handler functions](#custom-handler arity rules — a choice handler is called with no `arg`). ```rhai -fn bump(me, state, id) { +fn bump(me, dir) { scroll([ "The muffin looks delicious.", "", @@ -358,7 +361,7 @@ crash the game. A script that throws during `tick` logs the error and resumes on ## Full example ```rhai -// A guard that patrols east/west and greets the player when bumped. +// A guard that patrols east/west and greets whatever bumps into it. fn init(me, state) { set_tile(2); @@ -371,9 +374,8 @@ fn tick(me, state, dt) { else if !me.blocked(West) { move(West); } } -fn bump(me, state, id) { - if id == -1 { say("Halt! Who goes there?"); now(); } - else { say("Watch it!"); now(); } +fn bump(me, dir) { + say(`Halt! Who approaches from the ${dir}?`); now(); } ``` diff --git a/kiln-core/CLAUDE.md b/kiln-core/CLAUDE.md index f7f16e2..b7e578f 100644 --- a/kiln-core/CLAUDE.md +++ b/kiln-core/CLAUDE.md @@ -20,7 +20,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc **`kiln-core/src/archetype.rs`** — element taxonomy: - `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Builtin(Builtin, &'static str)`, `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. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` checks the `Builtin` registry first (via `Builtin::from_name`), then the hard-coded terrain names; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`). - `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Heart`, `Pusher`, `Spinner`, `Key`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_` tag on the expanded object, and the compile-cache key. -- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable), `Transporter` (`"transporter_north|south|east|west"` → 94/118/41/40 `^`/`v`/`)`/`(`; cyan, solid + **see-through** (`opaque: false`) + unpushable — animates a 4-frame loop and teleports whatever bumps it from the front, either past it or out of a paired opposite-facing transporter). All aliases in a family share one Rhai script; the script reads `me.has_tag("BUILTIN_")` to determine per-instance direction/chirality. +- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable), `Transporter` (`"transporter_north|south|east|west"` → 94/118/41/40 `^`/`v`/`)`/`(`; cyan, solid + **see-through** (`opaque: false`) + unpushable — animates a 4-frame loop and, on a front bump, moves whatever solid presses into its entrance — the player, an object, or a **pushed crate** — via a coordinate `shift`, either onto the cell just past it or, if that is blocked, out the far side of the nearest paired opposite-facing transporter). All aliases in a family share one Rhai script; the script reads `me.has_tag("BUILTIN_")` to determine per-instance direction/chirality. **`kiln-core/src/builtin_scripts.rs`** — tag helpers for script-backed archetypes (`pub(crate)`): - `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_` (e.g. `"BUILTIN_pusher_east"`) and also uses this string as the object's `script_name` compile-cache key (so each alias gets its own cached AST, though the Rhai source is the same for all aliases in a family). The script reads its tag to determine per-instance direction. `map_file::save` uses `archetype_from_builtin_tag` to collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live in `archetype.rs` via the `builtins!` macro (via `include_str!` of `scripts/*.rhai`); this file has only the three tag helpers. @@ -49,6 +49,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `solid_at(x, y) -> Option` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`). - `is_passable(x, y)` — convenience inverse of `solid_at`. - `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space? +- `bump_target(x, y, dir) -> Option` — the object a move **into** `(x, y)` heading `dir` bumps: the solid object in the target cell, or the one at the end of a chain of pushed crates (walked through in `dir`). Open space, the player, or a wall yield `None`. This is what lets a plain crate — not just an object or the player — trigger a `bump`. Used by `GameState::try_move` / `step_object`; the caller supplies the came-from direction as `dir.opposite()`. - `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The **player** counts as a blocker for this check. A Rust-side companion read to `apply_shift`; not itself exposed to scripts. - `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`). - `add_object(obj) -> ObjectId`, `remove_object(id) -> Option`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries. @@ -70,7 +71,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one). - `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds). - `Scroll { source: ObjectId, lines: Vec }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`. -- `GameState` — owns `world: World` (all boards as `Rc>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, `pub active_scroll: Option`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `alter_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a `ScriptState` bundle — a `Copy` snapshot of `player` plus a shared handle to the active board — passed to the hook as its `state` parameter (Rhai type `State`, read via `state.player`/`state.board`; see `api::state`). Front-ends reach the active board through `board() -> Ref` / `board_mut() -> RefMut` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-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()`/`alter_gems()`/`alter_health()` apply before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` 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`/`SetColor` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player.gems`, `AlterHealth(dh)` → `player.health` clamped to `[0, max_health]`, `SetKey` → `player.keys`, `Shift` → `apply_shift`, `Push`/`Teleport` → board moves, `Send` → `run_send`, `Die` → `remove_object(source)`). 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>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, `pub active_scroll: Option`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `alter_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a `ScriptState` bundle — a `Copy` snapshot of `player` plus a shared handle to the active board — passed to the hook as its `state` parameter (Rhai type `State`, read via `state.player`/`state.board`; see `api::state`). Front-ends reach the active board through `board() -> Ref` / `board_mut() -> RefMut` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(dir_from)` on the solid object it presses into — directly or at the end of a chain of crates it shoves (see `Board::bump_target`), where `dir_from` is the side the bump came from; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` + an immediate `resolve()` so its `die()`/`alter_gems()`/`alter_health()` apply before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` 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`/`SetColor` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player.gems`, `AlterHealth(dh)` → `player.health` clamped to `[0, max_health]`, `SetKey` → `player.keys`, `Shift` → `apply_shift`, `Push`/`Teleport` → board moves, `Send` → `run_send`, `Die` → `remove_object(source)`). Resolution is two-phase: mutate the board collecting `(bumped, dir_from)` pairs (the bumped object and the direction the bump came from), 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 supporting types: - `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s). @@ -89,7 +90,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc **`kiln-core/src/script.rs`** — Rhai scripting **host** (the script-facing types live in `kiln-core/src/api/`, below). The script-author's reference is [`docs/script-api.md`](../docs/script-api.md). - `Registerable` trait — `fn register(engine, error_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`. - `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap`), one persistent `Scope` per scripted object (`HashMap`), the shared board queue, and the error sink. Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the error sink; runs nothing. -- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, state, id)` (the bumper's `ObjectId`, or `-1` for the player), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (2/3/3/2). Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(state, id, bumper)` / `run_grab(state, id)`, all funneling through `run_hook_on_one`: it builds a fresh `ObjectInfo` for the object, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal. +- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(id, dir)` / `run_grab(state, id)`, all funneling through `run_hook_on_one`: it builds a fresh `ObjectInfo` for the object, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal. - `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists. - **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore. - **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`. diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 87f24fe..cdbda60 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -252,6 +252,41 @@ impl Board { .is_some_and(|s| s.pushable().allows(dir)) } + /// The object that a move **into** `(x, y)` heading `dir` bumps, if any. + /// + /// Walks the chain of pushable crates from the target cell in `dir` until it + /// reaches something that stops it, and reports the object it presses against: + /// - a solid object in the target cell (or at the end of a crate chain) is the + /// bumped object, + /// - open space or the player means nothing is bumped, + /// - a non-pushable terrain cell (a wall) blocks the chain with no object to bump. + /// + /// This is what lets *any* solid — the player, another object, or a pushed + /// crate — trigger a `bump`: the bumper need not be an object, since we only + /// return the *bumped* object's id (the direction it came from is supplied by + /// the caller from its move direction). + pub fn bump_target(&self, x: usize, y: usize, dir: Direction) -> Option { + let (dx, dy): (i64, i64) = dir.into(); + let (mut cx, mut cy) = (x, y); + loop { + match self.solid_at(cx, cy) { + None => return None, // open space: nothing bumped + Some(s) if s.object_id().is_some() => return s.object_id(), // hit an object + Some(s) if s.player() => return None, // the player is never "bumped" + // A terrain cell: a pushable crate is walked through; a wall stops us. + Some(_) if self.is_pushable(cx, cy, dir) => { + let next = (cx as i64 + dx, cy as i64 + dy); + if !self.in_bounds(next) { + return None; // crate chain runs off the board + } + cx = next.0 as usize; + cy = next.1 as usize; + } + Some(_) => return None, // non-pushable terrain (wall): no object behind it here + } + } + } + /// Whether the chain of pushable solids starting at `(x, y)` can be shoved one /// step in `dir` — i.e. the chain ends at a passable cell rather than the board /// edge or a non-pushable solid. diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 140b726..6c75284 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -208,7 +208,7 @@ impl GameState { // Logs are collected here rather than pushed inline, since the board borrow // below also borrows `self`. let mut logs: Vec = Vec::new(); - let mut bumps: Vec<(ObjectId, i64)> = Vec::new(); + let mut bumps: Vec<(ObjectId, Direction)> = Vec::new(); // Net change to player stats from AddGems / AlterHealth actions; applied // to `self` after the board borrow drops. let mut gem_delta: i64 = 0; @@ -225,7 +225,9 @@ impl GameState { match ba.action { Action::Move(dir) => { if let Some(bumped) = step_object(&mut board, ba.source, dir) { - bumps.push((bumped, ba.source as i64)); + // The bump "comes from" the side the mover advanced from, + // i.e. the opposite of its travel direction. + bumps.push((bumped, dir.opposite())); } } Action::SetTile(tile) => { @@ -382,8 +384,8 @@ impl GameState { self.log.push(LogLine::error(format!("set_key: unknown color {color:?}"))); } } - for (bumped, bumper) in bumps { - self.scripts.run_bump(bumped, bumper); + for (bumped, dir) in bumps { + self.scripts.run_bump(bumped, dir); } for (target, fn_name, arg) in sends { self.scripts.run_send(target, &fn_name, arg); @@ -462,8 +464,9 @@ impl GameState { /// /// The move is ignored if the target cell is out of bounds, or it is neither /// passable nor a pushable solid that can be shoved aside. No-ops silently (the - /// caller does not need to check). If the target cell holds a solid object, that - /// object's `bump(-1)` hook fires (whether or not the player ends up moving). + /// caller does not need to check). If a solid object lies in the path — directly + /// or at the end of a chain of crates the player is shoving — its `bump` hook + /// fires with the direction the bump came from (whether or not the player moves). pub fn try_move(&mut self, dir: Direction) { let bumped; let grabbed; @@ -479,12 +482,13 @@ impl GameState { // 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 = board - .solid_at(nx, ny) - .filter(|_| grabbed.is_none()) - .and_then(|s| s.object_id()); + // A solid object in the way is bumped by the player — possibly through a + // chain of crates the player is shoving (see `bump_target`) — but a grab + // thing fires grab() instead of bump(), so don't also bump it. + bumped = grabbed + .is_none() + .then(|| board.bump_target(nx, ny, dir)) + .flatten(); if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) { // 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). @@ -515,7 +519,8 @@ impl GameState { self.resolve(); } if let Some(idx) = bumped { - self.scripts.run_bump(idx, -1); + // The player advanced in `dir`, so the bump arrives from the opposite side. + self.scripts.run_bump(idx, dir.opposite()); self.drain_errors(); } } @@ -526,9 +531,10 @@ impl GameState { /// /// 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 -/// 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 -/// crates carry no script, so only solid objects yield a bump. +/// bump is recorded for the solid object the move presses into — directly, or at the +/// end of a chain of crates being shoved (see [`Board::bump_target`]) — whether it +/// gets pushed aside or blocks the move. Walls and crates carry no script, so only +/// solid objects yield a bump. fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option { let (dx, dy): (i64, i64) = dir.into(); let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?; @@ -538,7 +544,8 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option("Direction"); + // Rhai does not auto-derive comparison for custom types, so register `==`/`!=` + // to let scripts test the `bump` direction (e.g. `if dir == West { … }`). + engine.register_fn("==", |a: Direction, b: Direction| a == b); + engine.register_fn("!=", |a: Direction, b: Direction| a != b); + // Let a Direction interpolate/print as its name (e.g. in `log(`from ${dir}`)`). + let dir_name = |d: Direction| match d { + Direction::North => "North", + Direction::South => "South", + Direction::East => "East", + Direction::West => "West", + }; + engine.register_fn("to_string", dir_name); + engine.register_fn("to_debug", dir_name); + // Expose the unit-step components so scripts can turn a direction into a cell + // offset (e.g. the bumper's cell is `(me.x + dir.dx, me.y + dir.dy)`). + engine.register_get("dx", |d: &mut Direction| d.dx()); + engine.register_get("dy", |d: &mut Direction| d.dy()); // move(dir): enqueue a Move followed by a rate-limiting Delay. let b = board.clone(); diff --git a/kiln-core/src/scripts/transporter.rhai b/kiln-core/src/scripts/transporter.rhai index 19cca17..8fa6a09 100644 --- a/kiln-core/src/scripts/transporter.rhai +++ b/kiln-core/src/scripts/transporter.rhai @@ -8,8 +8,9 @@ // // The direction is not baked into this source: every transporter shares one // compiled copy and reads its `BUILTIN_transporter_` tag. The world is -// reached through the global `Board`/`Player` constants; `teleport(id, x, y)` -// moves an arbitrary entity (`-1` is the player). +// reached through the global `Board`/`Player` constants. The bumper is moved by +// coordinate via `shift([[from], [to]])`, which relocates whatever solid sits at +// the entrance — player, object, or a pushed crate — onto the empty destination. // This transporter's facing unit vector, from its direction tag. fn facing(me) { @@ -27,6 +28,16 @@ fn opposite_tag(me) { else { "BUILTIN_transporter_east" } } +// The direction a front bump arrives from — the side opposite our facing (a +// north-facing transporter is entered from the south, etc.). `bump` reacts only +// when the reported direction matches this. +fn entrance_dir(me) { + if me.has_tag("BUILTIN_transporter_north") { South } + else if me.has_tag("BUILTIN_transporter_south") { North } + else if me.has_tag("BUILTIN_transporter_east") { West } + else { East } +} + // The 4-frame animation loop for this direction (CP437 tile codes). fn frames(me) { if me.has_tag("BUILTIN_transporter_north") { [94, 45, 94, 126] } // ^ - ^ ~ @@ -47,38 +58,41 @@ fn tick(me, dt) { me.delay(0.15); } -fn bump(me, id) { +// Move whatever solid sits at (sx, sy) onto the empty cell (tx, ty) immediately. +// A two-cell `shift` relocates the source solid — player, object, or crate — and +// leaves its old cell empty (the destination is checked empty by the caller). Kept +// as a helper so the nested-array literal stays at a shallow expression depth. +fn transport(sx, sy, tx, ty) { + shift([[sx, sy], [tx, ty]]); now(); +} + +fn bump(me, dir) { + // Only transport things that hit us from the front — the entrance side. `dir` + // is the direction the bump came from; a bump from any other side does nothing. + if dir != entrance_dir(me) { return; } + let d = facing(me); let dx = d[0]; let dy = d[1]; - // Only transport things that hit us from the front — the entrance cell at - // (me - d). A bump from any other side does nothing. - let bx; - let by; - if id == -1 { - bx = Player.x; - by = Player.y; - } else { - let o = Board.get(id); - if o == () { return; } - bx = o.x; - by = o.y; - } - if bx != me.x - dx || by != me.y - dy { return; } + // Whatever solid is pressed against our entrance cell (me - d) gets moved: + // the player, another object, or a pushed crate — all handled uniformly by + // shifting the solid at that coordinate onto an empty destination. + let ex = me.x - dx; + let ey = me.y - dy; // 1. The cell just past us (the opposite side): if nothing solid is there, // drop the bumper onto it. let fx = me.x + dx; let fy = me.y + dy; if Board.passable(fx, fy) { - teleport(id, fx, fy); now(); + transport(ex, ey, fx, fy); return; } // 2. Otherwise scan along our axis for the nearest opposite-facing transporter - // whose entrance (the cell just before it) is free, and drop the bumper - // there. A blocked pair is skipped; give up at the board edge. + // whose far side (the cell just past it) is free, and drop the bumper there. + // A blocked pair is skipped; give up at the board edge. let opp = Board.tagged(opposite_tag(me)); let cx = fx; let cy = fy; @@ -94,10 +108,12 @@ fn bump(me, id) { if o.x == cx && o.y == cy { here = true; } } if here { - let ex = cx + dx; - let ey = cy + dy; - if Board.passable(ex, ey) { - teleport(id, ex, ey); now(); + // Emerge on the paired transporter's far side — the cell just past it + // along our scan (cx + d), out its back. + let tx = cx + dx; + let ty = cy + dy; + if Board.passable(tx, ty) { + transport(ex, ey, tx, ty); return; } } diff --git a/kiln-core/src/tests/collision.rs b/kiln-core/src/tests/collision.rs index 20de094..00f8eeb 100644 --- a/kiln-core/src/tests/collision.rs +++ b/kiln-core/src/tests/collision.rs @@ -18,11 +18,11 @@ fn collision_priority_resolves_in_array_order_and_bumps() { scripts_from(&[ ( "e", - "fn init(m) { move(East); } fn bump(m,id) { log(`o0 by ${id}`); }", + "fn init(m) { move(East); } fn bump(m,dir) { log(`o0 from ${dir}`); }", ), ( "w", - "fn init(m) { move(West); } fn bump(m,id) { log(`o1 by ${id}`); }", + "fn init(m) { move(West); } fn bump(m,dir) { log(`o1 from ${dir}`); }", ), ]), ); @@ -36,6 +36,7 @@ fn collision_priority_resolves_in_array_order_and_bumps() { assert_eq!((b.objects[&2].x, b.objects[&2].y), (2, 0)); // obj1 blocked } let logs = log_texts(&game); - assert!(logs.iter().any(|t| t == "o0 by 2")); // obj0 bumped by obj1 - assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped + // obj1 moved West into obj0, so the bump arrives from the East side of obj0. + assert!(logs.iter().any(|t| t == "o0 from East")); // obj0 bumped by obj1 + assert!(!logs.iter().any(|t| t.starts_with("o1 from"))); // obj1 not bumped } diff --git a/kiln-core/src/tests/map_file/transporters.rs b/kiln-core/src/tests/map_file/transporters.rs index fd989f1..3621b54 100644 --- a/kiln-core/src/tests/map_file/transporters.rs +++ b/kiln-core/src/tests/map_file/transporters.rs @@ -56,9 +56,9 @@ fn bumping_a_transporter_drops_you_on_its_far_side() { #[test] fn a_blocked_far_side_transports_out_of_the_paired_transporter() { - // Player, east transporter, a wall blocking its far side, the free entrance - // cell, then the paired west transporter. The player should pop out at the - // west transporter's entrance (the cell just before it), across the wall. + // Player, east transporter, a wall blocking its far side, a gap, the paired + // west transporter, then a free cell. The player should pop out on the paired + // transporter's far side (the cell just past it), across the wall. let board = load_board(&map( 6, 1, @@ -81,11 +81,49 @@ fn a_blocked_far_side_transports_out_of_the_paired_transporter() { let b = game.board(); assert_eq!( (b.player.x, b.player.y), - (3, 0), - "transported to the paired transporter's entrance", + (5, 0), + "transported past the paired transporter", ); } +#[test] +fn a_pushed_crate_is_transported_through() { + // Player, a crate, an east transporter, then a free cell. Pushing the crate + // east into the transporter bumps it (through the crate chain); the transporter + // moves the crate — a plain terrain solid, no object id — out its far side. + let board = load_board(&map( + 4, + 1, + &[layer( + "@oT ", + &[ + ("@", "kind = \"player\""), + ("o", "kind = \"crate\""), + ("T", "kind = \"transporter_east\""), + ], + )], + )); + let mut game = GameState::new(board); + game.run_init(); + + // Push east into the crate: the crate can't move (the transporter blocks it), + // but the transporter is bumped from the West and teleports the crate to (3,0). + game.try_move(Direction::East); + game.tick(Duration::from_secs_f64(0.1)); + { + let b = game.board(); + assert!(b.solid_at(3, 0).is_some(), "crate transported to the far side"); + assert!(b.is_passable(1, 0), "crate's old cell is now empty"); + assert_eq!((b.player.x, b.player.y), (0, 0), "player didn't move yet"); + } + + // With the crate gone, a second push walks the player onto the vacated cell. + game.try_move(Direction::East); + game.tick(Duration::from_secs_f64(0.1)); + let b = game.board(); + assert_eq!((b.player.x, b.player.y), (1, 0), "player follows into the gap"); +} + #[test] fn transporter_round_trips_to_its_keyword() { let board = load_board(&map( diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 72f3443..055a8fe 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -245,20 +245,40 @@ fn object_id_for_name_finds_by_name() { // Ensure try_move from the player side also triggers scripted bump #[test] -fn player_bump_fires_with_negative_one() { - // The player walks into a solid scripted object; the object is bumped with -1 - // and the player does not move onto it. +fn player_bump_reports_the_direction_it_came_from() { + // The player walks East into a solid scripted object; the object is bumped from + // the West side (opposite the player's travel) and the player does not move onto it. let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); let mut game = GameState::with_scripts( board, - scripts_from(&[("b", "fn bump(m,id) { log(`bumped by ${id}`); }")]), + scripts_from(&[("b", "fn bump(m,dir) { log(`bumped from ${dir}`); }")]), ); game.run_init(); game.try_move(Direction::East); assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked // bump's log is emitted into the object's queue; a tick flushes it. game.tick(Duration::from_millis(16)); - assert!(log_texts(&game).iter().any(|t| t == "bumped by -1")); + assert!(log_texts(&game).iter().any(|t| t == "bumped from West")); +} + +// A bump handler can compare the direction and read its `dx`/`dy` offset to +// locate the bumper's cell. +#[test] +fn bump_direction_supports_comparison_and_offset() { + let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); + let mut game = GameState::with_scripts( + board, + scripts_from(&[( + "b", + // Player bumps from the West, so `dir == West` and the bumper sits at + // (me.x + dir.dx) = 1 + (-1) = 0. + "fn bump(m,dir) { if dir == West { log(`bumper at ${m.x + dir.dx}`); } }", + )]), + ); + game.run_init(); + game.try_move(Direction::East); + game.tick(Duration::from_millis(16)); + assert!(log_texts(&game).iter().any(|t| t == "bumper at 0")); } #[test] @@ -271,7 +291,7 @@ fn scroll_opens_on_player_bump() { board, scripts_from(&[( "s", - r#"fn bump(m,id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#, + r#"fn bump(m,dir) { scroll(["Hello world", ["eat", "Eat it"]]); }"#, )]), ); game.run_init(); @@ -295,7 +315,7 @@ fn handle_scroll_without_choice_clears_it() { let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); let mut game = GameState::with_scripts( board, - scripts_from(&[("s", r#"fn bump(m,id) { scroll(["Hello"]); }"#)]), + scripts_from(&[("s", r#"fn bump(m,dir) { scroll(["Hello"]); }"#)]), ); game.run_init(); game.try_move(Direction::East); @@ -317,7 +337,7 @@ fn handle_scroll_with_choice_dispatches_send_to_source() { scripts_from(&[( "s", r#" - fn bump(m,id) { scroll(["Muffin?", ["eat", "Eat it"]]); } + fn bump(m,dir) { scroll(["Muffin?", ["eat", "Eat it"]]); } fn eat() { log("eaten"); } "#, )]), diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index 0fdc11e..ca11224 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -367,6 +367,20 @@ impl Direction { Direction::North => -1, } } + + /// The direction pointing the opposite way. + /// + /// Used to turn a mover's travel direction into the "came-from" direction + /// reported to a bumped object's `bump` hook (a bumper heading East arrives + /// from the West side of the thing it hits). + pub fn opposite(self) -> Direction { + match self { + Direction::North => Direction::South, + Direction::South => Direction::North, + Direction::East => Direction::West, + Direction::West => Direction::East, + } + } } /// A value that can be stored in a board's script registry across board transitions. diff --git a/maps/start.toml b/maps/start.toml index 1cff463..92d20d0 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -56,16 +56,16 @@ fn dance(me) { say("Ho!", 0.5); me.delay(0.5); } -// Fires when something steps into this object; id is the bumper's object id, -// or -1 for the player. -fn bump(me, id) { - log(`mover bumped by ${id}`); +// Fires when a solid (the player, an object, or a pushed crate) presses into this +// object; dir is the direction the bump came from. +fn bump(me, dir) { + log(`mover bumped from ${dir}`); say("Ow!"); } """ muffin = """ -fn bump(me, _id) { +fn bump(me, _dir) { scroll([ "You find a small muffin on the ground, your favorite kind.", "It smells of cinnamon and warm mornings.", @@ -84,7 +84,7 @@ fn ignore() { """ noticeboard = """ -fn bump(me, id) { +fn bump(me, _dir) { scroll([ " TOWN NOTICE BOARD", " ", @@ -117,7 +117,7 @@ fn bump(me, id) { """ bookshelf = """ -fn bump(me, id) { +fn bump(me, _dir) { scroll([ " THE BOOKSHELF", " ", @@ -136,13 +136,13 @@ fn bump(me, id) { """ fireplace = """ -fn bump(me, id) { +fn bump(me, _dir) { say("The fire crackles warmly.\\nYou feel at ease."); } """ chest = """ -fn bump(me, id) { +fn bump(me, _dir) { scroll([ " THE CHEST", " ",