drain object queues after each object

This commit is contained in:
2026-07-10 01:20:26 -05:00
parent 386967e936
commit 325d2c27dd
13 changed files with 259 additions and 106 deletions
+1 -1
View File
@@ -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, 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).
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`). `bump`'s `dir` is the `Direction` the bump came *from* (pointing toward the bumper), fired when *any* solid — the player, another object, or a pushed crate — presses into this object. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately within a hook: each is queued, and **at most one `move` resolves per 250 ms** per object. But each object's ready actions are **applied the moment its hook returns**, before the next object runs — objects are processed in **ascending id order** each tick, so a later object observes the board *after* every lower-id object has already moved (the one exception is actions still stuck behind a `Delay`). When two objects contend for the same cell, **lowest id wins**, and the loser — whose hook ran later — can already see the winner there. The bumped object receives `bump` with the direction the bump came from; `bump`/`send` reactions fire in a follow-up pass that settles fully within the same tick/`try_move`, bounded by a per-invocation guard that fires each `(object, hook, args)` at most once (so a bump/send cycle can't loop forever). The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md).
**Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(me.id, "visited", true)`) or the per-board `Registry` (which also persists across transitions).
+6 -5
View File
@@ -259,8 +259,9 @@ The Registry is shared by the whole board, so namespace per-instance keys by `me
## Write functions
Scripts never mutate the world directly. Each write appends an `Action` to the calling object's
**output queue**; actions drain to a shared board queue between script calls and are resolved by the
engine after the batch. The *subject* of a write is implicit and varies by function (see the table).
**output queue**; the moment the hook returns, that object's ready actions are drained and applied
by the engine — before the next object's hook runs. The *subject* of a write is implicit and varies
by function (see the table).
| Call | Subject | Cost | Description |
|------|---------|------|-------------|
@@ -421,10 +422,10 @@ they are shape problems.
### Unwieldy — machinery that leaks
- **The two-tier queue + `now()`/`delay()` model is subtle.** `now()` reorders by *enqueue
- **The output-queue + `now()`/`delay()` model is subtle.** `now()` reorders by *enqueue
recency* ("most recently enqueued action to the front"), which is position-dependent and easy to
get wrong; the object-queue → board-queue promotion plus per-object delay draining is a lot of
implicit state to reason about for "do X, wait, do Y".
get wrong; the per-object delay draining (leading delays eaten by `dt`, then the ready run applied)
is still a fair amount of implicit state to reason about for "do X, wait, do Y".
- **Two persistence mechanisms.** Tags and the `Registry` are separate stores with separate APIs;
cross-board state must pick one.
- **Stringly-typed dispatch.** `send` and scroll choices route by function *name*; a scroll choice
+9 -9
View File
@@ -34,7 +34,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired.
**`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained onto the board queue between script calls — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet<String>`, `name: Option<String>`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained into a batch of actions that `GameState` applies immediately after the object's hook — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet<String>`, `name: Option<String>`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
**`kiln-core/src/layer.rs`** — palette layers (the map-file/draw-stack unit):
- `Layer { cells: Vec<(Glyph, Archetype)> }` (`pub(crate)`) — one row-major draw layer. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the layer beneath shows through; solidity comes from the archetype, independent of transparency.
@@ -71,14 +71,14 @@ 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<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`.
- `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, 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>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(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.
- `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, 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>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(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<BoardAction>) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` pairs, drop the borrow, then apply the player-stat / bubble / scroll changes), applying each `Action` (`Log``log`, `SetTile`/`SetColor` → source glyph, `Move(dir)``step_object`, `Say``speech_bubbles`, `Scroll``active_scroll`, `AddGems(n)``player.gems`, `AlterHealth(dh)``player.health` clamped to `[0, max_health]`, `SetKey``player.keys`, `Shift``apply_shift`, `Push`/`Teleport` → board moves, `Die``remove_object(source)`). It **returns** the `bump`/`send` reactions rather than firing them; after all object hooks, `settle(events, called)` runs them (and any they cascade into) until quiescent, applying each hook's actions as it goes. `called: CalledSet` (a `HashSet<(ObjectId, fn/hook name, arg repr)>`, fresh per `tick`/`try_move`/`run_init`) records every reaction fired and skips repeats, so a bump/send cycle terminates. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
**`kiln-core/src/action.rs`** — the `Action` enum and its supporting types:
- `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s).
- `ScrollLine` (`pub`) — one line of content in a `Scroll` action: `Text(String)` (plain, word-wrapped) or `Choice { choice, display }` (selectable; `choice` is sent back to the source object when the player picks it).
- `SendArg` — the optional argument carried by a `Send` action / `send()` call: `None`/`Int`/`Float`/`String`, with `From<Dynamic>`/`Into<Dynamic>` conversions.
- `Action` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never reaches the board queue — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost).
- `BoardAction { source, action }` — an `Action` promoted onto the board queue, tagged with the `ObjectId` that issued it.
- `Action` (`pub(crate)`) — deferred mutation emitted by a script, drained from the issuing object's queue and applied by `GameState::apply_actions`: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never drained as an action — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost).
- `BoardAction { source, action }` — an `Action` drained from an object's queue, tagged with the `ObjectId` that issued it; `GameState` applies a `Vec<BoardAction>` per object.
**`kiln-core/src/floor.rs`** — procedural floor generators:
- A "floor" is just a non-solid, visible `Empty` cell on a layer (a palette entry with `kind = "floor"`). This module only owns the generators; the actual per-cell placement happens in `layer.rs` during load.
@@ -89,12 +89,12 @@ 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<String, CompiledScript>`), one persistent `Scope` per scripted object (`HashMap<ObjectId, Scope>`), the shared board queue, and the error sink. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the error sink; runs nothing.
- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). 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.
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap<String, CompiledScript>`), one persistent `Scope` per scripted object (`HashMap<ObjectId, Scope>`), and the error sink. (There is no shared board queue: each hook call drains into a local `Vec<BoardAction>` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the error sink; runs nothing.
- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Run one object at a time (the per-object loop lives in `GameState`): `run_tick_on(id, dt)` / `run_init_on(id)` / `run_bump(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec<BoardAction>` they drained** for `GameState` to apply. `run_hook_on_one` builds a fresh `ObjectInfo`, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal.
- `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists.
- **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore.
- **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`.
- **Queue draining (two-tier):** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(board_queue, dt)` promotes them onto the shared **board queue** (`Vec<BoardAction>`): it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. `GameState` takes the board queue with `take_board_queue()` and applies it after the batch, so scripts mutate without a `&mut GameState` borrow. Errors bypass the queues via the error sink (`take_errors()`).
- **Queue draining:** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(&mut Vec<BoardAction>, dt)` moves its ready run into a caller-supplied `Vec`: it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. The hook runner returns that `Vec` and `GameState` applies it immediately (per object), so scripts mutate without a `&mut GameState` borrow and each object sees the prior object's effects. Errors bypass the queue via the error sink (`take_errors()`).
- **Constants in scope:** only `Registry` is pushed into each object's scope (the `me`/`state` views are hook *parameters* now). `register_global_constants` registers the `North`/`South`/`East`/`West` `Direction` values and the 16 named colors as a **global module**, visible at any call depth (including Rhai→Rhai calls and helper functions) — unlike scope constants such as `Registry`.
- `Registry` (a `Board`-handle wrapper) — `Registry.get(key)` / `set(key, value)` / `get_or(key, default)` read and write the board's `registry: HashMap<String, RegistryValue>`, which **persists across board transitions**. `set(key, ())` removes a key; unsupported value types are ignored; `get_or` only returns the stored value when it matches the default's Rhai type.
- **Sender identity uses stable ids:** the issuing `ObjectId` rides the per-call **tag**`run_*` calls `call_fn_with_options(...with_tag(id)...)` and the write fns read it via `NativeCallContext::tag()` (decoded by `source_of`; id 0, never valid, is the no-source fallback). It matches `Board::objects`' `BTreeMap` keys, so it survives object spawn/destroy/reorder.
@@ -104,8 +104,8 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `api::state``ScriptState { player: PlayerWithPos, board: BoardRef }` (Rhai type **`State`**), the `state` parameter of every hook. `from_game_state` snapshots the player and clones the board handle; getters `state.player` / `state.board`.
- `api::player``PlayerWithPos(Player, PlayerPos)` (Rhai type **`Player`**), a `Copy` bundle of the game-global `Player` stats and the per-board `PlayerPos` so they register as one Rhai object. Getters `gems`, `health`, `max_health`, `keys` (a `Keyring`, exposing one `bool` getter per color), `x`, `y`.
- `api::board``BoardRef = Rc<RefCell<Board>>` (Rhai type **`Board`**), a read-only handle. Getters `width`/`height`; methods `can_push(x, y, dir)`, `passable(x, y)`, `get(id)` (→ `ObjectInfo` or `()`; `id <= 0` logs an error), `named(name)` (→ `ObjectInfo` or `()`), `tagged(tag)` (→ array of `ObjectInfo`).
- `api::object_info``ObjectInfo` (a snapshot of one object: `id`, `x`, `y`, a board handle, `script_name`, and a clone of the object's `ObjQueue`), used as both the `me` parameter and the return type of the `Board` lookups (so self and other objects are the **same** type). Getters `x`/`y`/`id`/`name`/`tags`/`glyph`/`waiting`/`queue`; methods `has_tag(tag)`, `delay(secs)`, `can_push(dir)`, `blocked(dir)`. `from_id`/`from_def` build it; `drain(target, dt)` promotes the underlying object's queued actions onto the board queue.
- `api::queue``ObjQueue(Rc<RefCell<VecDeque<Action>>>)` (Rhai type **`Queue`**), one object's output queue, owned by its `ObjectDef` and reachable as `me.queue`. Rhai surface: getter `length`, methods `clear()`, `delay(secs)`. Rust surface used by the host: `act`, `delay` (merges a trailing delay), `now` (promote the last-enqueued action to the front), `drain` (eat leading delays by `dt`, then promote ready actions), `waiting` (front is a `Delay`).
- `api::object_info``ObjectInfo` (a snapshot of one object: `id`, `x`, `y`, a board handle, `script_name`, and a clone of the object's `ObjQueue`), used as both the `me` parameter and the return type of the `Board` lookups (so self and other objects are the **same** type). Getters `x`/`y`/`id`/`name`/`tags`/`glyph`/`waiting`/`queue`; methods `has_tag(tag)`, `delay(secs)`, `can_push(dir)`, `blocked(dir)`. `from_id`/`from_def` build it; `drain(&mut Vec<BoardAction>, dt)` moves the underlying object's ready queued actions into the target `Vec`.
- `api::queue``ObjQueue(Rc<RefCell<VecDeque<Action>>>)` (Rhai type **`Queue`**), one object's output queue, owned by its `ObjectDef` and reachable as `me.queue`. Rhai surface: getter `length`, methods `clear()`, `delay(secs)`. Rust surface used by the host: `act`, `delay` (merges a trailing delay), `now` (promote the last-enqueued action to the front), `drain` (eat leading delays by `dt`, then move the ready actions into a `&mut Vec<BoardAction>`), `waiting` (front is a `Delay`).
**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in `layer.rs`):
- `MapFile { map: MapHeader, layers: Vec<LayerData> }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `board_script_name`; **no** `player_start` — the player is a `kind = "player"` palette char) plus the `[[layers]]` stack. Does **not** include scripts (those live in `World::scripts`).
+1 -1
View File
@@ -28,7 +28,7 @@ pub enum ScrollLine {
Choice { choice: String, display: String },
}
#[derive(Clone, PartialEq)]
#[derive(Clone, PartialEq, Debug)]
pub enum SendArg {
None,
Int(i64),
+3 -2
View File
@@ -25,8 +25,9 @@ use rhai::{Dynamic, Engine};
use crate::api::board::BoardRef;
use crate::api::queue::ObjQueue;
use crate::Direction;
use crate::action::BoardAction;
use crate::object_def::ObjectDef;
use crate::script::{BoardQueue, Registerable};
use crate::script::Registerable;
use crate::utils::{ErrorSink, ObjectId};
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
@@ -69,7 +70,7 @@ impl ObjectInfo {
}
}
pub fn drain(&mut self, target: BoardQueue, dt: f64) {
pub fn drain(&mut self, target: &mut Vec<BoardAction>, dt: f64) {
let mut b = self.board.borrow_mut();
if let Some(def) = b.objects.get_mut(&self.id) {
def.queue.drain(self.id, target, dt)
+7 -6
View File
@@ -7,7 +7,7 @@ use std::collections::VecDeque;
use std::rc::Rc;
use rhai::{Dynamic, Engine};
use crate::action::{Action, BoardAction};
use crate::script::{BoardQueue, Registerable};
use crate::script::Registerable;
use crate::utils::{ErrorSink, ObjectId};
/// A single object's output queue.
@@ -44,10 +44,11 @@ impl ObjQueue {
}
}
/// Drains object `i`'s output queue onto a target queue: first advances
/// leading `Delay` actions by dt, then drains actions into the target
/// queue until we run out (or hit another delay)
pub fn drain(&mut self, source: ObjectId, target: BoardQueue, mut dt: f64) {
/// Drains object `i`'s output queue into `target`: first advances leading
/// `Delay` actions by dt, then moves the run of ready actions into `target`
/// until we run out (or hit another delay). Each ready action is tagged with
/// `source` so [`GameState`](crate::game::GameState) knows who issued it.
pub fn drain(&mut self, source: ObjectId, target: &mut Vec<BoardAction>, mut dt: f64) {
let mut queue = self.0.borrow_mut();
loop {
match queue.front_mut() {
@@ -63,7 +64,7 @@ impl ObjQueue {
}
Some(_) => {
let action = queue.pop_front().unwrap();
target.borrow_mut().push(BoardAction { source, action });
target.push(BoardAction { source, action });
}
}
}
+113 -24
View File
@@ -1,12 +1,45 @@
use crate::action::{Action, SendArg};
use crate::action::{Action, BoardAction, SendArg};
use crate::board::Board;
use crate::log::LogLine;
use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, PlayerPos};
use crate::world::World;
use std::cell::{Ref, RefMut};
use std::collections::HashSet;
use std::time::Duration;
/// The bump and send reactions produced while applying a batch of actions.
///
/// [`GameState::apply_actions`] collects these but does not fire them; the
/// follow-up [`GameState::settle`] pass runs them after all object hooks, so a
/// bumped object reacts to the fully-updated board.
#[derive(Default)]
struct Events {
/// `(bumped object, direction the bump came from)` for each triggered `bump`.
bumps: Vec<(ObjectId, Direction)>,
/// `(target object, function name, argument)` for each `send`.
sends: Vec<(ObjectId, String, SendArg)>,
}
impl Events {
/// Appends `other`'s reactions onto `self`.
fn merge(&mut self, other: Events) {
self.bumps.extend(other.bumps);
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()
}
}
/// Records which `(object, hook/fn, args)` reactions have already fired during a
/// single `tick` / `try_move` / `run_init`. [`GameState::settle`] refuses to fire
/// a key twice, so a bump/send cascade always terminates — even if two objects
/// bump each other in a cycle, each side fires at most once. Reset per invocation.
type CalledSet = HashSet<(ObjectId, String, String)>;
/// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0;
@@ -164,8 +197,18 @@ impl GameState {
/// the game is about to start — never during map deserialization, since a script
/// may inspect the board.
pub fn run_init(&mut self) {
self.scripts.run_init();
self.resolve();
// Run each object's init hook in ascending id order, applying its actions
// immediately so a later object's init sees what an earlier one did.
let mut ev = Events::default();
let ids = self.board().all_ids();
for id in ids {
let actions = self.scripts.run_init_on(id);
ev.merge(self.apply_actions(actions));
}
// Fire any bump/send reactions, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
}
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
@@ -178,14 +221,24 @@ impl GameState {
// this runs exactly once per player interaction with a scroll.
self.handle_scroll();
let secs = dt.as_secs_f64();
self.scripts.run_tick(secs);
// Expire speech bubbles before resolving new actions so a fresh say()
// this frame isn't immediately culled.
// Expire speech bubbles once per frame, before applying new actions so a
// fresh say() this frame isn't immediately culled.
self.speech_bubbles.retain_mut(|b| {
b.remaining -= secs;
b.remaining > 0.0
});
self.resolve();
// Run each object's tick in ascending id order, applying its drained
// actions immediately so the next object sees the updated board.
let mut ev = Events::default();
let ids = self.board().all_ids();
for id in ids {
let actions = self.scripts.run_tick_on(id, secs);
ev.merge(self.apply_actions(actions));
}
// Fire the bump/send reactions those ticks triggered, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
}
/// Drains errors collected by the script host into the game log.
@@ -196,15 +249,16 @@ impl GameState {
self.log.extend(errors);
}
/// Resolves the board queue: applies each promoted action to the board, then fires
/// the `bump` and `send` hooks the moves triggered.
/// Applies one object's drained `actions` to the board and returns the `bump`
/// and `send` reactions they triggered (fired later by [`settle`](GameState::settle)).
///
/// Done in two phases so no `board_mut` borrow is held while scripts run
/// (they read the board through its getters): phase A mutates the board and records
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B fires the
/// hooks after the borrow drops.
fn resolve(&mut self) {
let actions = self.scripts.take_board_queue();
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B applies the
/// player-stat / bubble / scroll changes after the borrow drops. The collected
/// reactions are returned rather than fired here, so the caller can run them once
/// all object hooks in this pass have applied.
fn apply_actions(&mut self, actions: Vec<BoardAction>) -> Events {
// Logs are collected here rather than pushed inline, since the board borrow
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
@@ -384,13 +438,39 @@ impl GameState {
self.log.push(LogLine::error(format!("set_key: unknown color {color:?}")));
}
}
for (bumped, dir) in bumps {
self.scripts.run_bump(bumped, dir);
// Return the reactions for the caller's settle pass rather than firing them here.
Events { bumps, sends }
}
for (target, fn_name, arg) in sends {
self.scripts.run_send(target, &fn_name, arg);
/// Fires the `bump` / `send` reactions in `events` (and any they cascade into)
/// until the board is quiescent, applying each hook's actions as it runs.
///
/// `called` records every `(object, hook/fn, args)` already fired this
/// invocation; a reaction whose key is already present is skipped. Since each
/// key fires at most once, the loop is finite even when objects bump each
/// other in a cycle — the guard is what makes bump-loops impossible.
fn settle(&mut self, initial: Events, called: &mut CalledSet) {
let mut pending = initial;
while !pending.is_empty() {
let mut next = Events::default();
for (id, dir) in std::mem::take(&mut pending.bumps) {
// Skip a bump already fired this pass (dedup key includes the direction).
if !called.insert((id, "bump".to_string(), format!("{dir:?}"))) {
continue;
}
let actions = self.scripts.run_bump(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:?}"))) {
continue;
}
let actions = self.scripts.run_send(id, &fn_name, arg);
next.merge(self.apply_actions(actions));
}
pending = next;
}
self.drain_errors();
}
/// Consumes the active scroll, dispatching the player's choice (if any) back
@@ -404,7 +484,12 @@ impl GameState {
if let Some(scroll) = self.active_scroll.take()
&& let Some(choice) = scroll.choice
{
self.scripts.run_send(scroll.source, &choice, SendArg::None);
// Dispatch the choice back to the source object and apply whatever it
// queues (plus any bump/send cascade), the same as a tick.
let actions = self.scripts.run_send(scroll.source, &choice, SendArg::None);
let ev = self.apply_actions(actions);
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
}
}
@@ -513,17 +598,21 @@ impl GameState {
self.enter_board(&target_map, &target_entry);
return;
}
// Fire the grab hook and resolve it immediately so the grabbed thing's
let mut ev = Events::default();
// Fire the grab hook and apply it immediately so the grabbed thing's
// die()/alter_gems() apply now — no player+object overlap survives this call.
if let Some(id) = grabbed {
self.scripts.run_grab(id);
self.resolve();
let actions = self.scripts.run_grab(id);
ev.merge(self.apply_actions(actions));
}
if let Some(idx) = bumped {
// The player advanced in `dir`, so the bump arrives from the opposite side.
self.scripts.run_bump(idx, dir.opposite());
self.drain_errors();
ev.bumps.push((idx, dir.opposite()));
}
// Settle the grab/bump reactions (and any they cascade into) before returning.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
}
}
+38 -43
View File
@@ -40,9 +40,7 @@ use rhai::{
Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext,
Scope, AST,
};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use crate::api::board::BoardRef;
use crate::api::object_info::ObjectInfo;
use crate::api::player::PlayerWithPos;
@@ -58,9 +56,6 @@ pub trait Registerable {
fn register(engine: &mut Engine, error_sink: ErrorSink);
}
/// The board queue: actions promoted from object queues, awaiting resolution.
pub type BoardQueue = Rc<RefCell<Vec<BoardAction>>>;
/// A compiled script plus which lifecycle hooks it defines.
struct CompiledScript {
ast: AST,
@@ -98,7 +93,6 @@ pub struct ScriptHost {
engine: Engine,
scripts: HashMap<String, CompiledScript>,
scopes: HashMap<ObjectId, Scope<'static>>,
board_queue: BoardQueue,
errors: ErrorSink,
board: BoardRef
}
@@ -112,7 +106,6 @@ impl ScriptHost {
/// `scripts` is the world-level script pool (script name → Rhai source); it is
/// read only during construction and not retained afterward.
pub fn new(board_ref: BoardRef, player: PlayerRef, script_sources: &HashMap<String, String>) -> Self {
let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new()));
let errors = ErrorSink::new();
let mut scopes = HashMap::new();
@@ -196,37 +189,41 @@ impl ScriptHost {
Self {
engine,
scripts,
board_queue,
errors,
scopes,
board: board_ref
}
}
/// Calls `tick(dt)` on every scripted object that defines it, then drains queues.
pub fn run_tick(&mut self, dt: f64) {
self.run_hook_on_all(Hook::Tick, dt);
/// Runs the `tick(me, dt)` hook on one object and returns the actions it
/// drained (see [`run_hook_on_one`](ScriptHost::run_hook_on_one)).
pub(crate) fn run_tick_on(&mut self, id: ObjectId, dt: f64) -> Vec<BoardAction> {
self.run_hook_on(Hook::Tick, id, dt)
}
pub fn run_init(&mut self) {
self.run_hook_on_all(Hook::Init, 0.0)
/// Runs the `init(me)` hook on one object and returns the actions it drained.
pub(crate) fn run_init_on(&mut self, id: ObjectId) -> Vec<BoardAction> {
self.run_hook_on(Hook::Init, id, 0.0)
}
/// Run the given hook on every object that defines it. For hooks other than
/// `Tick`, dt should just be 0.0
fn run_hook_on_all(&mut self, hook: Hook, dt: f64) {
let all_ids = self.board.borrow().all_ids();
/// Runs `hook` on the single object `id` and returns the actions it drained.
///
/// The `dt` arg is only meaningful for `Tick` (it becomes the hook's `dt`
/// parameter and paces the object's delay draining); pass `0.0` otherwise.
pub(crate) fn run_hook_on(&mut self, hook: Hook, id: ObjectId, dt: f64) -> Vec<BoardAction> {
let arg = match hook {
Hook::Tick => Some(Dynamic::from(dt)),
_ => None,
};
for id in all_ids {
self.run_hook_on_one(hook, id, arg.clone(), dt)
}
self.run_hook_on_one(hook, id, arg, dt)
}
fn run_hook_on_one(&mut self, hook: Hook, id: ObjectId, arg: Option<Dynamic>, dt: f64) {
/// Calls one lifecycle `hook` on object `id` (if the script defines it), then
/// drains that object's ready actions into a fresh `Vec` and returns them —
/// [`GameState`](crate::game::GameState) applies them immediately, before the
/// next object runs, so each object sees the board state its actions run against.
fn run_hook_on_one(&mut self, hook: Hook, id: ObjectId, arg: Option<Dynamic>, dt: f64) -> Vec<BoardAction> {
let mut actions = Vec::new();
if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
if let Some(script_key) = info.script_name.as_ref()
&& let Some(script) = self.scripts.get(script_key)
@@ -250,29 +247,29 @@ impl ScriptHost {
}
// Run the drain regardless of if we have the hook, otherwise
// things with no tick will never advance past a delay
info.drain(self.board_queue.clone(), dt)
info.drain(&mut actions, dt)
}
} else {
unreachable!("Object not found");
}
actions
}
/// Calls `bump(dir)` on the object with [`ObjectId`] `id`, if it defines the
/// hook. `dir` is the [`Direction`] the bump came *from* (it points from the
/// bumped object toward the bumper). After the hook, drains the object's queue
/// with `dt = 0`.
pub fn run_bump(&mut self, id: ObjectId, dir: Direction) {
self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0);
/// hook, and returns the actions it drained. `dir` is the [`Direction`] the
/// bump came *from* (it points from the bumped object toward the bumper).
pub(crate) fn run_bump(&mut self, id: ObjectId, dir: Direction) -> Vec<BoardAction> {
self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0)
}
/// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the
/// hook, then drains its queue with `dt = 0`.
/// hook, and returns the actions it drained.
///
/// Fired when the player walks onto a grab object or a grab object is pushed
/// into the player (see [`GameState`](crate::game::GameState)). The hook
/// typically increments a player stat and removes the object via `die()`.
pub fn run_grab(&mut self, object_id: ObjectId) {
self.run_hook_on_one(Hook::Grab, object_id, None, 0.0);
/// Fired when the player walks onto a grab object (see
/// [`GameState`](crate::game::GameState)). The hook typically increments a
/// player stat and removes the object via `die()`.
pub(crate) fn run_grab(&mut self, object_id: ObjectId) -> Vec<BoardAction> {
self.run_hook_on_one(Hook::Grab, object_id, None, 0.0)
}
/// Calls the named function on the object with [`ObjectId`] `target_id`.
@@ -282,8 +279,10 @@ impl ScriptHost {
/// - If it has arity 1, we pass the ObjectInfo alone
/// - If it has arity 0, we pass nothing
///
/// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`
pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) {
/// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`.
/// Returns the actions the call drained.
pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) -> Vec<BoardAction> {
let mut actions = Vec::new();
if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
if let Some(script_key) = info.script_name.as_ref()
&& let Some(script) = self.scripts.get(script_key)
@@ -301,7 +300,7 @@ impl ScriptHost {
// If it's not there at all, just bail:
if arities.is_empty() {
self.errors.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
return
return actions;
}
// Assemble the args
@@ -324,16 +323,12 @@ impl ScriptHost {
) {
self.errors.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
}
info.drain(self.board_queue.clone(), 0.0)
info.drain(&mut actions, 0.0)
}
} else {
unreachable!("Object id not found, tried to send");
}
}
/// Removes and returns the actions promoted onto the board queue.
pub(crate) fn take_board_queue(&mut self) -> Vec<BoardAction> {
std::mem::take(&mut self.board_queue.borrow_mut())
actions
}
/// Removes and returns the errors collected since the last drain.
+2 -1
View File
@@ -46,7 +46,8 @@ fn bumping_a_transporter_drops_you_on_its_far_side() {
game.run_init();
// Walk east into the transporter: it's solid so the player doesn't step onto
// it, but the bump queues a teleport that the next tick applies.
// it, but the bump fires a teleport that resolves within this try_move. The
// trailing tick just advances the transporter's idle animation.
game.try_move(Direction::East);
game.tick(Duration::from_secs_f64(0.1));
+54 -4
View File
@@ -295,8 +295,8 @@ fn scroll_opens_on_player_bump() {
)]),
);
game.run_init();
// The bump resolves within try_move now, so the scroll is open immediately.
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
let scroll = game
.active_scroll
@@ -318,11 +318,11 @@ fn handle_scroll_without_choice_clears_it() {
scripts_from(&[("s", r#"fn bump(m,dir) { scroll(["Hello"]); }"#)]),
);
game.run_init();
// The bump resolves within try_move, so the scroll is open right away.
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_some());
// No choice set — next tick clears the scroll without dispatching.
// No choice set — a tick clears the scroll without dispatching.
game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_none());
}
@@ -343,8 +343,8 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
)]),
);
game.run_init();
// The bump resolves within try_move, so the scroll is open right away.
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_some());
// Set the choice, then tick — handle_scroll dispatches "eat" and resolve picks up the log.
@@ -356,3 +356,53 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
"eat() should have logged"
);
}
#[test]
fn a_later_object_sees_an_earlier_objects_move_this_tick() {
// The core of the epic: each object's queued actions apply immediately, before
// the next (higher-id) object runs its hook. Object A (id 1) at (0,0) moves East
// onto (1,0); object B (id 2) at (1,1) then checks the cell to its North (1,0).
// Because A already moved there this tick, B observes it as blocked. Under the
// old collect-all-then-apply model B would have seen (1,0) still empty.
let a = scripted_object(0, 0, "a");
let b = scripted_object(1, 1, "b");
let board = open_board(3, 2, (2, 1), vec![a, b]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("a", "fn tick(m,dt) { if m.queue.length == 0 { move(East); } }"),
("b", r#"fn tick(m,dt) { log(if m.blocked(North) { "blocked" } else { "clear" }); }"#),
]),
);
game.run_init();
game.tick(Duration::from_millis(16));
// A moved onto (1,0), and B saw it there the same tick.
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 0));
assert_eq!(log_texts(&game), vec!["blocked"]);
}
#[test]
fn a_send_cycle_terminates_via_the_called_guard() {
// Two objects send "go" to each other in a cycle. Without the per-invocation
// "already-called" guard this would recurse forever; with it, each (object, fn,
// args) fires at most once, so the cascade settles after one round-trip. That the
// call returns at all — and logs exactly one "B" then one "A" — proves it.
let a = scripted_object(0, 0, "a");
let b = scripted_object(1, 0, "b");
let board = open_board(3, 1, (2, 0), vec![a, b]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("a", r#"fn init(m) { send(2, "poke"); } fn poke(m) { log("A"); send(2, "poke"); }"#),
("b", r#"fn poke(m) { log("B"); send(1, "poke"); }"#),
]),
);
game.run_init();
// B.poke fires once (from A.init's send), then A.poke once (from B.poke's send);
// 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"]);
}
+19 -1
View File
@@ -36,7 +36,7 @@ use crate::input::{
use crate::log::{LogWidget, log_preview_line};
use crate::menu::MenuWidget;
use crate::mode::{Mode, PendingMode};
use crate::scroll_overlay::ScrollOverlayWidget;
use crate::scroll_overlay::{ScrollOpenAnimation, ScrollOverlayWidget};
use crate::speech::SpeechBubblesWidget;
use crate::status::StatusSidebarWidget;
use crate::ui::Ui;
@@ -142,6 +142,11 @@ fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) ->
// only changed cells are actually written to the terminal.
terminal.draw(|frame| draw(frame, mode, ui))?;
// Remember whether a scroll overlay was already open, so we can detect one
// that opens this frame — from a player bump during input *or* from a tick —
// and play its open animation exactly once (see after the tick below).
let scroll_active_before = matches!(mode, Mode::Play(g) if g.active_scroll.is_some());
// Wait for input only up to the next frame deadline so the loop wakes to
// tick even with no keypresses.
let timeout = FRAME.saturating_sub(last_tick.elapsed());
@@ -194,6 +199,19 @@ fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) ->
ui.tick(dt, mode);
}
// A scroll can be opened either by a script tick or by a player bump during
// input handling (bump reactions now resolve within try_move). Start its open
// animation once, whichever opened it — detected by the scroll appearing since
// the top of this frame while no animation is running.
if ui.active_animation.is_none()
&& !scroll_active_before
&& let Mode::Play(game) = &*mode
&& let Some(scroll) = &game.active_scroll
{
let lines = scroll.lines.clone();
ui.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines)));
}
// Apply any Play↔Edit switch a menu action requested this frame.
apply_pending_mode(mode, ui);
+4 -7
View File
@@ -3,7 +3,7 @@ use crate::editor::EditorState;
use crate::log::LogState;
use crate::menu::{MenuCloseAnimation, MenuItem, MenuOpenAnimation, MenuState};
use crate::mode::{Mode, PendingMode};
use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState};
use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOverlayState};
use crate::transition::TransitionAnimation;
use kiln_core::Board;
use kiln_core::game::GameState;
@@ -137,15 +137,12 @@ impl Ui {
// scroll the instant its open animation finished.
let input_mode = crate::input::current_input_mode(mode, self);
match mode {
// Play: tick the game (only in Board mode), then check whether a scroll
// overlay appeared as a result of this tick.
// Play: tick the game only in Board mode (a scroll/menu overlay pauses
// it). The run loop starts the scroll open animation when one appears,
// whether from this tick or from a player bump during input.
Mode::Play(game) => {
if matches!(input_mode, crate::input::InputMode::Board) {
game.tick(dt);
if let Some(scroll) = &game.active_scroll {
let lines = scroll.lines.clone();
self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines)));
}
}
}
// Edit: only the cursor blink advances; the game does not exist here.
+1 -1
View File
@@ -39,7 +39,7 @@ fn init(me) {
// move() costs 250 ms, so the object steps ~4 cells/sec no matter how often
// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking
// into a wall (or another object's already-queued move this tick).
fn tick(me, state, dt) {
fn tick(me, dt) {
if !me.waiting { dance(me); }
}
fn dance(me) {