diff --git a/CLAUDE.md b/CLAUDE.md index 14b47cb..5d146bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ script_name = "tripwire" Palette `kind` values: the meta-kinds `empty` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `transporter_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). (`floor` is **no longer** a grid kind — it is the board-level `floor` attribute.) For archetype/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). -Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** in the grid (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). The grid's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`). `bump`'s `dir` is the `Direction` the bump came *from* (pointing toward the bumper), fired when *any* solid — the player, another object, or a pushed crate — presses into this object. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately within a hook: each is queued, and **at most one `move` resolves per 250 ms** per object. The exception is **`log(s)`**, which is *not* queued — it writes to the game log the instant it is called, so a diagnostic line surfaces even when it sits behind a pending `move`/`delay`. But each object's ready actions are **applied the moment its hook returns**, before the next object runs — objects are processed in **ascending id order** each tick, so a later object observes the board *after* every lower-id object has already moved (the one exception is actions still stuck behind a `Delay`). When two objects contend for the same cell, **lowest id wins**, and the loser — whose hook ran later — can already see the winner there. The bumped object receives `bump` with the direction the bump came from; `bump`/`send` reactions fire in a follow-up pass that settles fully within the same tick/`try_move`, bounded by a per-invocation guard that fires each `(object, hook, args)` at most once (so a bump/send cycle can't loop forever). The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md). +Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** in the grid (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). The grid's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, `enter(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. `enter` is the **non-solid** counterpart: it fires on a non-solid object when a solid *relocates onto* its cell (a player/object step, a pushed crate, a `teleport`, or a `shift`), with `dir` the side the entrant came from — exact (`dir.opposite()`) for a one-cell move/push, best-effort (dominant axis, via `Direction::from_delta`) for a `teleport`/`shift` that jumps an arbitrary distance. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately within a hook: each is queued, and **at most one `move` resolves per 250 ms** per object. The exception is **`log(s)`**, which is *not* queued — it writes to the game log the instant it is called, so a diagnostic line surfaces even when it sits behind a pending `move`/`delay`. But each object's ready actions are **applied the moment its hook returns**, before the next object runs — objects are processed in **ascending id order** each tick, so a later object observes the board *after* every lower-id object has already moved (the one exception is actions still stuck behind a `Delay`). When two objects contend for the same cell, **lowest id wins**, and the loser — whose hook ran later — can already see the winner there. The bumped object receives `bump` with the direction the bump came from; `bump`/`enter`/`send` reactions fire in a follow-up pass that settles fully within the same tick/`try_move`, bounded by a per-invocation guard that fires each `(object, hook, args)` at most once (so a bump/enter/send cycle can't loop forever). The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md). **Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(me.id, "visited", true)`) or the per-board `Registry` (which also persists across transitions). diff --git a/docs/script-api.md b/docs/script-api.md index f2898f8..f409924 100644 --- a/docs/script-api.md +++ b/docs/script-api.md @@ -65,6 +65,27 @@ fn bump(me, dir) { } ``` +### `fn enter(me, dir)` + +The **non-solid** counterpart to `bump`. Called when a solid — the player, another object, or a +**crate** being pushed/shifted — relocates *onto* this object's cell. Only makes sense on a non-solid +object (a trigger, or a decorative `solid = false` object); a solid object blocks the entrant instead +of being entered, so its `bump` fires rather than `enter`. + +- `dir` is the [`Direction`] the entrant **came from** — the same convention as `bump`. For a one-cell + move or push it is exactly the opposite of the entrant's travel direction. For a `teleport` or `shift` + that jumps a solid an arbitrary distance it is **best-effort**: the dominant axis of the displacement + (a longer horizontal jump reads as East/West, a longer vertical one as North/South). +- Fires for *every* solid that lands on the cell: a player/object step, and each crate a push or shift + moved onto it. + +```rhai +fn enter(me, dir) { + say("Who goes there?"); + log(`something stepped on me from the ${dir}`); +} +``` + ### `fn grab(me, state)` Called when the **player walks onto** this object and the object is a *grab* thing (gems, hearts — diff --git a/kiln-core/CLAUDE.md b/kiln-core/CLAUDE.md index 8565403..8a07367 100644 --- a/kiln-core/CLAUDE.md +++ b/kiln-core/CLAUDE.md @@ -53,10 +53,10 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `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 the floor is revealed). A pushed solid moves within the grid. -- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries. +- `push(x, y, dir) -> Vec<(usize, usize)>` — mutating: shoves the chain one step, leaving a transparent cell behind (so the floor is revealed). A pushed solid moves within the grid. **Returns the destination cells** each shoved solid moved into (empty when nothing moved), so callers can fire `enter` on any non-solid a pushed solid landed on. +- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)`, `non_solid_object_ids_at(x, y)` (the non-solid objects on a cell — the `enter`-hook targets) — object add/remove + queries. - `grab_object_at(x, y) -> Option` — a solid `grab` object on the cell (the player-walks-onto-it case). `GameState::try_move` uses it to fire `grab()` instead of bumping when the player steps onto a grab thing. Grab is **only** a player-movement event: a grab thing pushed/shifted into the player is treated as an ordinary solid (no special-case). (`remove_object` deletes the object and its `ObjQueue` from the `objects` map; a live `ScriptHost` keeps an unused `Scope` for that id until it is rebuilt — harmless, since hook dispatch iterates the board's *current* ids.) -- `clear_solid(x, y)` — replaces the grid cell's solid terrain (if any) with a transparent `Empty`, revealing the floor. `apply_shift(cells: &[(i32, i32)]) -> Vec` — rotates the solids occupying a ring of cells one step: each cell's solid moves to the next coordinate in the list, and the last wraps back to the first. Backs the script `shift()` fn. A cell is **immobile** if its solid is unpushable, or is an `HCrate`/`VCrate` asked to move along its forbidden axis; immobility **cascades backward** along the ring, so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is `place`d at its target (a displaced object is despawned via `remove_object`). Any out-of-bounds coordinate **rejects the whole shift**. The player rotates like any other solid. +- `clear_solid(x, y)` — replaces the grid cell's solid terrain (if any) with a transparent `Empty`, revealing the floor. `apply_shift(cells: &[(i32, i32)]) -> ShiftOutcome` — rotates the solids occupying a ring of cells one step: each cell's solid moves to the next coordinate in the list, and the last wraps back to the first. Backs the script `shift()` fn. A cell is **immobile** if its solid is unpushable, or is an `HCrate`/`VCrate` asked to move along its forbidden axis; immobility **cascades backward** along the ring, so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is `place`d at its target (a displaced object is despawned via `remove_object`). Any out-of-bounds coordinate **rejects the whole shift**. The player rotates like any other solid. Returns a `ShiftOutcome { errors: Vec, moves: Vec<((i64,i64),(i64,i64))> }` — the error lines to log plus each `(from, to)` relocation performed, so the caller can fire `enter` at each destination (direction best-effort via `Direction::from_delta`, since a ring may rotate non-adjacent cells). - `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving the floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the grid cell; `Empty` erases — removes every object plus the grid cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`. - `expand_builtin_archetypes()` — the **single** expansion point: replaces every `Archetype::Builtin(b, alias)` grid cell with the scripted object it expands to. For each such cell: vacates the grid cell (→ transparent `Empty`), calls `b.script()` for the embedded source, uses `builtin_tag(arch)` as both the `BUILTIN_` tag and the `script_name` compile-cache key, and copies the cell's glyph (so palette overrides survive). The object's `pushable`/`grab` flags come from `b.behavior()`. Idempotent (vacated cells become `Archetype::Empty`, so a second pass finds nothing). Called by `TryFrom for Board` after cross-cell validation (disk loads) and again by the editor's `playtest()` on the `World::deep_clone`. Builtin objects get ids after hand-placed ones (reading order among themselves). - `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord. @@ -73,7 +73,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(dir_from)` on the solid object it presses into — directly or at the end of a chain of crates it shoves (see `Board::bump_target`), where `dir_from` is the side the bump came from; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` — applied immediately — so its `die()`/`alter_gems()`/`alter_health()` take effect before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` expires speech bubbles, then runs each object's `tick(dt)` hook **in ascending id order, applying that object's drained actions before the next object runs** — so a later object sees the board an earlier one just changed (the requested fix; the exception is actions still behind a `Delay`). The workhorse is `apply_actions(Vec) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` pairs, drop the borrow, then apply the player-stat / bubble / scroll changes), applying each `Action` (`SetTile`/`SetColor` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player.gems`, `AlterHealth(dh)` → `player.health` clamped to `[0, max_health]`, `SetKey` → `player.keys`, `Shift` → `apply_shift`, `Push`/`Teleport` → board moves, `Die` → `remove_object(source)`). It **returns** the `bump`/`send` reactions rather than firing them; after all object hooks, `settle(events, called)` runs them (and any they cascade into) until quiescent, applying each hook's actions as it goes. `called: CalledSet` (a `HashSet<(ObjectId, fn/hook name, arg repr)>`, fresh per `tick`/`try_move`/`run_init`) records every reaction fired and skips repeats, so a bump/send cycle terminates. `drain_log()` moves the script host's collected log lines — script `log()` output and errors — into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object. +- `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; it also fires `enter(dir_from)` on any **non-solid** object under the player's new cell or under a cell a shoved crate landed on; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` — applied immediately — so its `die()`/`alter_gems()`/`alter_health()` take effect before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` expires speech bubbles, then runs each object's `tick(dt)` hook **in ascending id order, applying that object's drained actions before the next object runs** — so a later object sees the board an earlier one just changed (the requested fix; the exception is actions still behind a `Delay`). The workhorse is `apply_actions(Vec) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` and `(entered, dir_from)` pairs, drop the borrow, then apply the player-stat / bubble / scroll changes), applying each `Action` (`SetTile`/`SetColor` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player.gems`, `AlterHealth(dh)` → `player.health` clamped to `[0, max_health]`, `SetKey` → `player.keys`, `Shift` → `apply_shift`, `Push`/`Teleport` → board moves, `Die` → `remove_object(source)`). It **returns** the `bump`/`enter`/`send` reactions rather than firing them; after all object hooks, `settle(events, called)` runs them (and any they cascade into) until quiescent, applying each hook's actions as it goes. `Events` carries three reaction lists — `bumps`, `enters`, `sends`; the `Move`/`Push`/`Teleport`/`Shift` arms populate `enters` for every non-solid a solid relocated onto (via `Board::non_solid_object_ids_at`), the direction exact for a one-cell move/push and best-effort (`Direction::from_delta`) for a teleport/shift. `step_object` returns a `StepOutcome { bumped, entered }` (a non-solid mover contributes no own-cell `enter`; pushed crates always do). `called: CalledSet` (a `HashSet<(ObjectId, fn/hook name, arg repr)>`, fresh per `tick`/`try_move`/`run_init`) records every reaction fired and skips repeats, so a bump/enter/send cycle terminates. `drain_log()` moves the script host's collected log lines — script `log()` output and errors — into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object. **`kiln-core/src/action.rs`** — the `Action` enum and its supporting types: - `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s). @@ -91,8 +91,8 @@ 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, log_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`. -- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap`), one persistent `Scope` per scripted object (`HashMap`), and the shared `LogSink`. (There is no shared board queue: each hook call drains into a local `Vec` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the `LogSink`; runs nothing. -- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Run one object at a time (the per-object loop lives in `GameState`): `run_tick_on(id, dt)` / `run_init_on(id)` / `run_bump(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec` they drained** for `GameState` to apply. `run_hook_on_one` builds a fresh `ObjectInfo`, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the shared `LogSink` (drained to the log), not fatal. +- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap`), one persistent `Scope` per scripted object (`HashMap`), and the shared `LogSink`. (There is no shared board queue: each hook call drains into a local `Vec` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`/`has_enter`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the `LogSink`; runs nothing. +- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), `enter(me, dir)` (the non-solid counterpart to `bump`: fired on a non-solid object when a solid *relocates onto* its cell — a player/object step, a pushed crate, a `teleport`, or a `shift` — with `dir` the side the entrant came from, best-effort for teleport/shift), 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 and enter are arity 2). Run one object at a time (the per-object loop lives in `GameState`): `run_tick_on(id, dt)` / `run_init_on(id)` / `run_bump(id, dir)` / `run_enter(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec` they drained** for `GameState` to apply. `run_hook_on_one` builds a fresh `ObjectInfo`, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the shared `LogSink` (drained to the log), not fatal. - `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists. - **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore. - **Write API** (`register_write_api`) — free functions that (mostly) enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. **`log(s)` is the exception** — it is *not* an `Action`; it writes a line straight to the shared `LogSink`, so it surfaces immediately regardless of the object's queued delays (the write closure takes a `LogSink` clone). `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error immediately to the `LogSink`) and rotates that ring of cells via `Board::apply_shift`. diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 4f31201..1233a56 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -7,6 +7,19 @@ use crate::utils::Direction; use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid}; use std::collections::{BTreeMap, HashMap, HashSet}; +/// The result of [`Board::apply_shift`]: any error lines to log, plus the +/// `(from, to)` cell relocations the shift actually performed. +/// +/// The `moves` let the caller fire an `enter` hook on any non-solid object each +/// shifted solid landed on; the direction is derived best-effort from `to - from` +/// (a shift can rotate cells that aren't cardinally adjacent). +pub struct ShiftOutcome { + /// Error lines (e.g. an out-of-bounds cell) for the caller to log. + pub errors: Vec, + /// Each `(from, to)` relocation a non-blocked solid underwent. + pub moves: Vec<((i64, i64), (i64, i64))>, +} + /// A non-solid `(glyph, archetype)` placed at a board coordinate, **outside** the /// main grid, drawn only when the grid cell at `(x, y)` is empty. /// @@ -373,9 +386,12 @@ impl Board { /// /// No-op when the chain can't move (it self-checks via [`can_push`](Board::can_push)), /// so it is safe to call unconditionally. - pub fn push(&mut self, x: usize, y: usize, dir: Direction) { + /// Returns the cells the shoved solids moved **into** (each chain cell stepped + /// one cell in `dir`), so the caller can fire `enter` on any non-solid object a + /// pushed solid landed on. Empty when nothing moved. + pub fn push(&mut self, x: usize, y: usize, dir: Direction) -> Vec<(usize, usize)> { if !self.can_push(x, y, dir) { - return; + return Vec::new(); } let (dx, dy): (i64, i64) = dir.into(); // can_push guaranteed the chain ends at an in-bounds passable cell, so @@ -391,6 +407,12 @@ impl Board { for &(px, py) in chain.iter().rev() { self.shift_solid(px, py, dx, dy); } + // Each solid ended up one step along `dir`; those destination cells are + // where an `enter` may need to fire. + chain + .iter() + .map(|&(px, py)| ((px as i64 + dx) as usize, (py as i64 + dy) as usize)) + .collect() } /// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`. @@ -421,6 +443,19 @@ impl Board { .collect() } + /// Returns the [`ObjectId`]s of the **non-solid** objects at `(x, y)`. + /// + /// These are the targets of an `enter` hook when a solid relocates onto the + /// cell (terrain is always solid, so only objects can be non-solid). Mirrors + /// [`object_ids_at`](Board::object_ids_at) / [`solid_object_id_at`](Board::solid_object_id_at). + pub fn non_solid_object_ids_at(&self, x: usize, y: usize) -> Vec { + self.objects + .iter() + .filter(|(_, o)| o.x == x && o.y == y && !o.solid) + .map(|(&id, _)| id) + .collect() + } + /// Returns a borrow of the actual object at `(x, y)` if any pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option { self.objects.iter().find_map(|(&id, o)| { @@ -552,11 +587,16 @@ impl Board { } /// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script - /// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log. - pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> Vec { + /// `shift()` fn. Returns a [`ShiftOutcome`] carrying any error [`LogLine`]s for + /// the caller to log plus the `(from, to)` relocations it performed (so the + /// caller can fire `enter` on non-solids each moved solid landed on). + pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> ShiftOutcome { // Validate all the cells are in bounds, error if not: if cells.iter().any(|&c| !self.in_bounds(c)) { - return vec![LogLine::error("Called shift() with a cell out of bounds")] + return ShiftOutcome { + errors: vec![LogLine::error("Called shift() with a cell out of bounds")], + moves: Vec::new(), + }; } // Get all the Solids at these cells: @@ -609,15 +649,19 @@ impl Board { } } - // Now, move anything that we've decided is not blocked: + // Now, move anything that we've decided is not blocked, recording each + // relocation so the caller can fire `enter` at every destination. + let mut moves = Vec::new(); for (curr_idx, curr) in solids.iter().enumerate() { if let Some(solid) = curr && !blocked.contains(&curr_idx) { + let origin = cells[curr_idx]; let target = cells[(curr_idx + 1) % cells.len()]; solid.place(self, target.0 as usize, target.1 as usize); + moves.push((origin, target)); } } - vec![] + ShiftOutcome { errors: Vec::new(), moves } } /// Clear the queues of all objects on this board: called when entering a board, objects @@ -975,7 +1019,7 @@ pub(crate) mod tests { let mut board = open_board(3, 1, (2, 0), vec![]); crate_at(&mut board, 0, 0); let errs = board.apply_shift(&[(0, 0), (9, 0)]); - assert_eq!(errs.len(), 1); + assert_eq!(errs.errors.len(), 1); assert_eq!(board.get(0, 0).1, Archetype::Crate); // unchanged } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index b871c49..5286baf 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -17,6 +17,9 @@ use std::time::Duration; struct Events { /// `(bumped object, direction the bump came from)` for each triggered `bump`. bumps: Vec<(ObjectId, Direction)>, + /// `(entered non-solid object, direction the entrant came from)` for each + /// `enter` — a solid relocating onto a non-solid object's cell. + enters: Vec<(ObjectId, Direction)>, /// `(target object, function name, argument)` for each `send`. sends: Vec<(ObjectId, String, SendArg)>, } @@ -25,12 +28,13 @@ impl Events { /// Appends `other`'s reactions onto `self`. fn merge(&mut self, other: Events) { self.bumps.extend(other.bumps); + self.enters.extend(other.enters); self.sends.extend(other.sends); } /// Whether there is anything left to fire. fn is_empty(&self) -> bool { - self.bumps.is_empty() && self.sends.is_empty() + self.bumps.is_empty() && self.enters.is_empty() && self.sends.is_empty() } } @@ -267,6 +271,8 @@ impl GameState { // also borrows `self`) is held. let log_sink = self.scripts.log_sink().clone(); let mut bumps: Vec<(ObjectId, Direction)> = Vec::new(); + // `enter` reactions: a solid relocating onto a non-solid object's cell. + let mut enters: 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; @@ -282,11 +288,16 @@ impl GameState { for ba in actions { match ba.action { Action::Move(dir) => { - if let Some(bumped) = step_object(&mut board, ba.source, dir) { - // The bump "comes from" the side the mover advanced from, - // i.e. the opposite of its travel direction. + let StepOutcome { bumped, entered } = + step_object(&mut board, ba.source, dir); + // The bump / enter "comes from" the side the mover advanced + // from, i.e. the opposite of its travel direction. + if let Some(bumped) = bumped { bumps.push((bumped, dir.opposite())); } + for id in entered { + enters.push((id, dir.opposite())); + } } Action::SetTile(tile) => { if let Some(obj) = board.objects.get_mut(&ba.source) { @@ -359,8 +370,19 @@ impl GameState { "teleport(player,{x},{y}): destination is solid" )); } else { + let (old_x, old_y) = (board.player.x, board.player.y); board.player.x = ux as i64; board.player.y = uy as i64; + // The player is solid, so it may land on non-solids: + // fire `enter` with a best-effort came-from direction + // (the jump is arbitrary, so it has no exact cardinal). + if let Some(from) = + Direction::from_delta(old_x - ux as i64, old_y - uy as i64) + { + for id in board.non_solid_object_ids_at(ux, uy) { + enters.push((id, from)); + } + } } } else if let Ok(tid) = ObjectId::try_from(target) { // Move object `tid` to (x, y). A solid destination blocks @@ -372,6 +394,7 @@ impl GameState { )), Some(obj) => { let source_solid = obj.solid; + let (old_x, old_y) = (obj.x as i64, obj.y as i64); let blocked = source_solid && matches!( board.solid_at(ux, uy), @@ -381,9 +404,23 @@ impl GameState { log_sink.error(format!( "teleport({target},{x},{y}): destination is solid" )); - } else if let Some(obj) = board.objects.get_mut(&tid) { - obj.x = ux; - obj.y = uy; + } else { + if let Some(obj) = board.objects.get_mut(&tid) { + obj.x = ux; + obj.y = uy; + } + // Only a solid mover triggers `enter`; the + // direction is best-effort (arbitrary jump). + if source_solid + && let Some(from) = Direction::from_delta( + old_x - ux as i64, + old_y - uy as i64, + ) + { + for id in board.non_solid_object_ids_at(ux, uy) { + enters.push((id, from)); + } + } } } } @@ -398,14 +435,35 @@ impl GameState { if !board.in_bounds((x, y)) { log_sink.error(format!("push({x},{y}): out of bounds")); } else { - board.push(x as usize, y as usize, dir); + // Each pushed solid stepped one cell in `dir`; fire `enter` + // on any non-solid it landed on (came-from `dir.opposite()`). + for (cx, cy) in board.push(x as usize, y as usize, dir) { + for id in board.non_solid_object_ids_at(cx, cy) { + enters.push((id, dir.opposite())); + } + } } } - // apply_shift moves the named cells, returning any error lines. + // apply_shift moves the named cells, returning error lines plus the + // relocations it performed (for `enter` at each destination). Action::Shift(cells) => { - for line in board.apply_shift(&cells) { + let outcome = board.apply_shift(&cells); + for line in outcome.errors { log_sink.line(line); } + for (from, to) in outcome.moves { + // A shift can rotate non-adjacent cells, so the came-from + // direction is best-effort (dominant axis of the jump). + if let Some(from_dir) = + Direction::from_delta(from.0 - to.0, from.1 - to.1) + { + for id in + board.non_solid_object_ids_at(to.0 as usize, to.1 as usize) + { + enters.push((id, from_dir)); + } + } + } } // Accumulated and applied to `self.player.gems` after the borrow drops. Action::AddGems(n) => gem_delta += n, @@ -443,7 +501,11 @@ impl GameState { } } // Return the reactions for the caller's settle pass rather than firing them here. - Events { bumps, sends } + Events { + bumps, + enters, + sends, + } } /// Fires the `bump` / `send` reactions in `events` (and any they cascade into) @@ -465,6 +527,14 @@ impl GameState { let actions = self.scripts.run_bump(id, dir); next.merge(self.apply_actions(actions)); } + for (id, dir) in std::mem::take(&mut pending.enters) { + // Skip an enter already fired this pass (dedup key includes the direction). + if !called.insert((id, "enter".to_string(), format!("{dir:?}"))) { + continue; + } + let actions = self.scripts.run_enter(id, dir); + next.merge(self.apply_actions(actions)); + } for (id, fn_name, arg) in std::mem::take(&mut pending.sends) { // Dedup key: target + function name + argument. if !called.insert((id, fn_name.clone(), format!("{arg:?}"))) { @@ -560,6 +630,9 @@ impl GameState { let bumped; let grabbed; let portal_target; + // Non-solid objects the player (or the crates it shoved) landed on this move, + // collected while the board is borrowed and fired as `enter` after the borrow. + let mut entered: Vec = Vec::new(); { let (dx, dy): (i64, i64) = dir.into(); let mut board = self.board_mut(); @@ -583,10 +656,15 @@ impl GameState { // 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); + // Each pushed solid lands on a cell that may hold a non-solid. + for (cx, cy) in board.push(nx, ny, dir) { + entered.extend(board.non_solid_object_ids_at(cx, cy)); + } } board.player.x = nx as i64; board.player.y = ny as i64; + // The player is solid, so any non-solid on its new cell gets `enter`. + entered.extend(board.non_solid_object_ids_at(nx, ny)); // Check for a portal at the new position; clone strings to release the borrow. portal_target = board .portals @@ -597,7 +675,7 @@ impl GameState { portal_target = None; } } - // A portal takes priority: board transitions skip the bump hook. + // A portal takes priority: board transitions skip the bump/enter hooks. if let Some((target_map, target_entry)) = portal_target { self.enter_board(&target_map, &target_entry); return; @@ -613,6 +691,10 @@ impl GameState { // The player advanced in `dir`, so the bump arrives from the opposite side. ev.bumps.push((idx, dir.opposite())); } + for id in entered { + // The player advanced in `dir`, so it entered from the opposite side. + ev.enters.push((id, dir.opposite())); + } // Settle the grab/bump reactions (and any they cascade into) before returning. let mut called = CalledSet::new(); self.settle(ev, &mut called); @@ -620,33 +702,60 @@ impl GameState { } } -/// Moves object `id` one cell in `dir` on `board`, returning the object it bumped -/// (if any) for the caller to resolve after the board borrow drops. +/// What a [`step_object`] call produced, for the caller to resolve after the board +/// borrow drops. +struct StepOutcome { + /// The solid object the move pressed into (directly or at a crate-chain's end), + /// which receives a `bump`. + bumped: Option, + /// Non-solid objects a solid landed on this move — the mover itself (only if it + /// is solid) and every crate it shoved — each of which receives an `enter`. + entered: Vec, +} + +/// Moves object `id` one cell in `dir` on `board`, reporting the `bump`/`enter` +/// reactions for the caller to resolve after the board borrow drops. /// /// 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 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 { +/// solid objects yield a bump. An `enter` is recorded for every non-solid object a +/// solid lands on: the mover's destination cell (only when the mover is itself solid) +/// and each cell a pushed crate moved into (crates are always solid entrants). +fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome { + let mut out = StepOutcome { + bumped: None, + entered: Vec::new(), + }; let (dx, dy): (i64, i64) = dir.into(); - let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?; + let Some((ox, oy, solid)) = board.objects.get(&id).map(|o| (o.x, o.y, o.solid)) else { + return out; + }; let target = (ox as i64 + dx, oy as i64 + dy); if !board.in_bounds(target) { - return None; + return out; } let (nx, ny) = (target.0 as usize, target.1 as usize); // Capture the bumped object before any push relocates it (its id is stable). // Walks through a pushed crate chain to the object it presses against. - let bumped = board.bump_target(nx, ny, dir); + out.bumped = board.bump_target(nx, ny, dir); if 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 + // Shove a crate/object out of the way (no-op otherwise); each pushed solid + // may land on a non-solid, which gets `enter` regardless of the mover. + for (cx, cy) in board.push(nx, ny, dir) { + out.entered.extend(board.non_solid_object_ids_at(cx, cy)); + } let obj = board.objects.get_mut(&id).expect("id checked above"); obj.x = nx; obj.y = ny; + // Only a solid mover triggers `enter` on non-solids under its own new cell. + if solid { + out.entered.extend(board.non_solid_object_ids_at(nx, ny)); + } } - bumped + out } #[cfg(test)] diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 56cbf59..274566b 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -8,6 +8,9 @@ //! - `tick(me, state, dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]). //! - `bump(me, dir)` — run when a solid (the player, an object, or a pushed crate) presses into this //! object's cell, with the [`Direction`] the bump came *from*; see [`ScriptHost::run_bump`]. +//! - `enter(me, dir)` — run when a solid (the player, an object, or a pushed crate) relocates *onto* +//! this **non-solid** object's cell, with the [`Direction`] the entrant came *from* (best-effort for +//! teleport/shift); see [`ScriptHost::run_enter`]. //! - `grab(me, state)` — run when the player walks onto a `grab` object; see [`ScriptHost::run_grab`]. //! Typically adds a stat + `die()`s. //! @@ -63,6 +66,7 @@ struct CompiledScript { has_tick: bool, has_bump: bool, has_grab: bool, + has_enter: bool, } impl CompiledScript { @@ -71,7 +75,8 @@ impl CompiledScript { Hook::Init => self.has_init, Hook::Tick => self.has_tick, Hook::Grab => self.has_grab, - Hook::Bump => self.has_bump + Hook::Bump => self.has_bump, + Hook::Enter => self.has_enter } } } @@ -163,6 +168,7 @@ impl ScriptHost { has_tick: defines("tick", 2), has_bump: defines("bump", 2), has_grab: defines("grab", 1), + has_enter: defines("enter", 2), ast, }, ); @@ -262,6 +268,14 @@ impl ScriptHost { self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0) } + /// Calls `enter(dir)` on the object with [`ObjectId`] `id`, if it defines the + /// hook, and returns the actions it drained. Fired when a solid (the player, an + /// object, or a pushed crate) relocates onto this non-solid object's cell; `dir` + /// is the [`Direction`] the entrant came *from* (best-effort for teleport/shift). + pub(crate) fn run_enter(&mut self, id: ObjectId, dir: Direction) -> Vec { + self.run_hook_on_one(Hook::Enter, id, Some(Dynamic::from(dir)), 0.0) + } + /// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the /// hook, and returns the actions it drained. /// diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index 7ff2a06..16f9ac0 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -59,6 +59,14 @@ fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef { o } +/// Returns a **non-solid** `ObjectDef` at `(x, y)` bound to the named script — the +/// kind that receives an `enter` hook when a solid relocates onto its cell. +fn nonsolid_object(x: usize, y: usize, script: &str) -> ObjectDef { + let mut o = scripted_object(x, y, script); + o.solid = false; + o +} + /// Flattens each log line into a single string for easy assertions. fn log_texts(game: &GameState) -> Vec { game.log diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 2bc443a..1bc5934 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -1,5 +1,5 @@ -use super::{board_with_object, log_texts, scripted_object, scripts_from}; -use crate::board::tests::open_board; +use super::{board_with_object, log_texts, nonsolid_object, scripted_object, scripts_from}; +use crate::board::tests::{crate_at, open_board}; use crate::game::{GameState, ScrollLine}; use crate::utils::Direction; use std::time::Duration; @@ -406,3 +406,112 @@ fn a_send_cycle_terminates_via_the_called_guard() { // A.poke's re-send to B.poke is a repeat key and is skipped, so the cascade stops. assert_eq!(log_texts(&game), vec!["B", "A"]); } + +// ── enter hook ────────────────────────────────────────────────────────────── +// `enter(me, dir)` fires on a non-solid object when a solid relocates onto its +// cell. `dir` is the side the entrant came from (opposite the travel direction +// for a cardinal move/push; best-effort for teleport/shift). + +#[test] +fn player_walking_onto_a_nonsolid_fires_enter_from_the_travel_side() { + // The player walks East onto a non-solid trigger; it entered from the West. + let board = open_board(3, 1, (0, 0), vec![nonsolid_object(1, 0, "e")]); + let mut game = GameState::with_scripts( + board, + scripts_from(&[("e", "fn enter(m, dir) { log(`entered from ${dir}`); }")]), + ); + game.run_init(); + game.try_move(Direction::East); + // The player is not blocked by a non-solid — it moves onto the cell. + assert_eq!((game.board().player.x, game.board().player.y), (1, 0)); + assert!(log_texts(&game).iter().any(|t| t == "entered from West")); +} + +#[test] +fn object_moving_onto_a_nonsolid_fires_enter() { + // A solid mover ticks East onto a non-solid trigger sharing the destination. + let mover = scripted_object(0, 0, "m"); + let trigger = nonsolid_object(1, 0, "e"); + let board = open_board(3, 1, (2, 0), vec![mover, trigger]); + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ("m", "fn tick(m, dt) { if m.queue.length == 0 { move(East); } }"), + ("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"), + ]), + ); + game.run_init(); + game.tick(Duration::from_millis(16)); + assert!(log_texts(&game).iter().any(|t| t == "entered from West")); +} + +#[test] +fn pushing_a_crate_onto_a_nonsolid_fires_enter() { + // Player pushes a crate East onto a non-solid trigger's cell; the crate is the + // solid entrant, so `enter` fires (from West). + let mut board = open_board(4, 1, (0, 0), vec![nonsolid_object(2, 0, "e")]); + crate_at(&mut board, 1, 0); + let mut game = GameState::with_scripts( + board, + scripts_from(&[("e", "fn enter(m, dir) { log(`entered from ${dir}`); }")]), + ); + game.run_init(); + game.try_move(Direction::East); + assert!(log_texts(&game).iter().any(|t| t == "entered from West")); +} + +#[test] +fn teleporting_onto_a_nonsolid_fires_enter() { + // A solid object teleports itself from (0,0) onto the non-solid trigger at (1,0). + let mover = scripted_object(0, 0, "t"); + let trigger = nonsolid_object(1, 0, "e"); + let board = open_board(3, 1, (2, 0), vec![mover, trigger]); + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ("t", "fn init(m) { teleport(m.id, 1, 0); }"), + ("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"), + ]), + ); + game.run_init(); + // Best-effort direction: the jump was one cell east, so it entered from West. + assert!(log_texts(&game).iter().any(|t| t == "entered from West")); +} + +#[test] +fn shifting_a_crate_onto_a_nonsolid_fires_enter() { + // An object shifts the ring [(1,0),(2,0)]: the crate at (1,0) rotates onto the + // non-solid trigger at (2,0), firing `enter` (best-effort West). + let shifter = nonsolid_object(0, 0, "s"); + let trigger = nonsolid_object(2, 0, "e"); + let mut board = open_board(4, 1, (3, 0), vec![shifter, trigger]); + crate_at(&mut board, 1, 0); + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ("s", "fn init(m) { shift([[1, 0], [2, 0]]); }"), + ("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"), + ]), + ); + game.run_init(); + assert!(log_texts(&game).iter().any(|t| t == "entered from West")); +} + +#[test] +fn a_nonsolid_mover_onto_a_nonsolid_does_not_fire_enter() { + // Only *solid* entrants trigger enter: a non-solid mover walking onto another + // non-solid must NOT fire it. + let mover = nonsolid_object(0, 0, "m"); + let trigger = nonsolid_object(1, 0, "e"); + let board = open_board(3, 1, (2, 0), vec![mover, trigger]); + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ("m", "fn tick(m, dt) { if m.queue.length == 0 { move(East); } }"), + ("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"), + ]), + ); + game.run_init(); + game.tick(Duration::from_millis(16)); + assert!(!log_texts(&game).iter().any(|t| t.starts_with("entered"))); +} diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index 142101b..f6e3a22 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -376,6 +376,28 @@ impl Direction { Direction::West => Direction::East, } } + + /// The cardinal direction of the dominant axis of a `(dx, dy)` displacement, + /// or `None` when the displacement is zero. + /// + /// A one-cell cardinal move maps to its obvious direction; for an arbitrary + /// relocation (e.g. a `teleport` or `shift` that jumps a solid several cells + /// or diagonally) it picks whichever axis moved farther — a **best-effort** + /// direction used to fill in the `enter` hook's `dir` when there is no exact + /// cardinal travel direction. Ties (|dx| == |dy|, dx != 0) resolve to the + /// horizontal axis. + pub fn from_delta(dx: i64, dy: i64) -> Option { + if dx == 0 && dy == 0 { + return None; + } + // Compare magnitudes so a longer horizontal jump reads as East/West and a + // longer vertical jump as North/South; the horizontal axis wins ties. + if dx.abs() >= dy.abs() { + Some(if dx > 0 { Direction::East } else { Direction::West }) + } else { + Some(if dy > 0 { Direction::South } else { Direction::North }) + } + } } /// A value that can be stored in a board's script registry across board transitions. @@ -439,7 +461,7 @@ impl From for (i64, i64) { #[derive(Copy,Clone,Debug, PartialEq)] pub enum Hook { - Init, Tick, Bump, Grab + Init, Tick, Bump, Grab, Enter } impl Hook { @@ -448,7 +470,8 @@ impl Hook { Hook::Init => "init", Hook::Bump => "bump", Hook::Grab => "grab", - Hook::Tick => "tick" + Hook::Tick => "tick", + Hook::Enter => "enter" } } } diff --git a/maps/start.toml b/maps/start.toml index 2cf0c7a..8b2f82b 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -13,7 +13,7 @@ fn init(me) { log(`Player health: ${Player.health}`); } -fn tick(me, dt) { +fn enter(me, dir) { if Player.x == me.x && Player.y == me.y && !me.waiting { say("Hey! Get offa me!"); me.delay(5.0);