by_player flag on bump and delay flag on move

This commit is contained in:
2026-08-16 01:17:30 -05:00
parent 1fa47f6418
commit d9408a9012
15 changed files with 139 additions and 81 deletions
+2 -2
View File
@@ -86,7 +86,7 @@ fn init(me, state) { # optional, run once at startup
fn tick(me, state, dt) { fn tick(me, state, dt) {
if !me.waiting && !me.blocked(North) { move(North); } if !me.waiting && !me.blocked(North) { move(North); }
} }
fn bump(me, dir) { log(`bumped from ${dir}`); say("Ouch!"); } # dir: the side it came from fn bump(me, dir, by_player) { log(`bumped from ${dir} by player ${by_player}`); say("Ouch!"); } # dir: the side it came from
""" """
# Each board is a named subtable under [boards]. The board key ("room1") is # Each board is a named subtable under [boards]. The board key ("room1") is
@@ -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). 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)`, `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). `tick` is invoked **only when the object's output queue is empty** — while actions from a prior tick are still pending (including a pacing `Delay`), the engine just drains the queue by `dt` without re-running `tick`, so a plain `move()` paces itself one step per drain and no `if me.queue.length == 0` guard is needed. (Every other hook — `bump`/`enter`/`grab`/`send`/`init` — fires regardless of queued actions.) 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). 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, by_player)`, `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, and `by_player` is `true` when the bumper is the player itself (a crate shoved into this object reports `false`, since the crate is the immediate bumper). `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). `tick` is invoked **only when the object's output queue is empty** — while actions from a prior tick are still pending (including a pacing `Delay`), the engine just drains the queue by `dt` without re-running `tick`, so a plain `move()` paces itself one step per drain and no `if me.queue.length == 0` guard is needed. (Every other hook — `bump`/`enter`/`grab`/`send`/`init` — fires regardless of queued actions.) 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<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). **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).
+8 -7
View File
@@ -53,7 +53,7 @@ fn tick(me, state, dt) {
} }
``` ```
### `fn bump(me, dir)` ### `fn bump(me, dir, by_player)`
Called when a solid presses into this object's cell — the player, another object, or a **crate** being Called when a solid presses into this object's cell — the player, another object, or a **crate** being
pushed into it (the push chain is walked through to reach the object it presses against). pushed into it (the push chain is walked through to reach the object it presses against).
@@ -61,12 +61,13 @@ pushed into it (the push chain is walked through to reach the object it presses
- `dir` is the [`Direction`] the bump **came from**: it points from this object toward the bumper, so - `dir` is the [`Direction`] the bump **came from**: it points from this object toward the bumper, so
the bumper occupies the cell at `(me.x + dir.dx, me.y + dir.dy)`. the bumper occupies the cell at `(me.x + dir.dx, me.y + dir.dy)`.
- Compare it against the `North`/`South`/`East`/`West` constants. - Compare it against the `North`/`South`/`East`/`West` constants.
- The hook can no longer tell *who* did the bumping (there is no id) — a crate bumps just like the player. - `by_player` is `true` when the bumper **is the player** — a crate the player shoves into this object
(or another object moving in) reports `false`, since the crate is the immediate bumper.
```rhai ```rhai
fn bump(me, dir) { fn bump(me, dir, by_player) {
if dir == West { say("Something shoved me from the west."); } if by_player { say("Don't shove me, I'm fragile!"); }
else { log(`bumped from ${dir}`); } else { log(`shoved from ${dir} by something other than the player`); }
} }
``` ```
@@ -311,7 +312,7 @@ function call named `choice_key` (see [custom handler functions](#custom-handler
arity rules — a choice handler is called with no `arg`). arity rules — a choice handler is called with no `arg`).
```rhai ```rhai
fn bump(me, dir) { fn bump(me, dir, _by_player) {
scroll([ scroll([
"The muffin looks delicious.", "The muffin looks delicious.",
"", "",
@@ -385,7 +386,7 @@ fn tick(me, state, dt) {
else if !me.blocked(West) { move(West); } else if !me.blocked(West) { move(West); }
} }
fn bump(me, dir) { fn bump(me, dir, by_player) {
say(`Halt! Who approaches from the ${dir}?`); now(); say(`Halt! Who approaches from the ${dir}?`); now();
} }
``` ```
+1 -1
View File
@@ -92,7 +92,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/script.rs`** — Rhai scripting **host** (the script-facing types live in `kiln-core/src/api/`, below). The script-author's reference is [`docs/script-api.md`](../docs/script-api.md). **`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`. - `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<String, CompiledScript>`), one persistent `Scope` per scripted object (`HashMap<ObjectId, Scope>`), and the shared `LogSink`. (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`/`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. - `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap<String, CompiledScript>`), one persistent `Scope` per scripted object (`HashMap<ObjectId, Scope>`), and the shared `LogSink`. (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`/`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<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. The `Tick` hook is **gated on an empty queue** (`hook != Hook::Tick || info.queue.is_empty()`), so an object with pending actions/`Delay` from an earlier tick isn't re-ticked; all other hooks fire whenever defined. After the call — **whether or not the hook was called** — it always drains the object's queue, so a delay still advances on an object that has no `tick` (or whose `tick` was skipped this frame). Runtime errors go to the shared `LogSink` (drained to the log), not fatal. - Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir, by_player)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object; `by_player` is `true` when the immediate bumper is the player — a pushed crate reports `false`), `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 is arity 3, enter 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, by_player)` / `run_enter(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. The `Tick` hook is **gated on an empty queue** (`hook != Hook::Tick || info.queue.is_empty()`), so an object with pending actions/`Delay` from an earlier tick isn't re-ticked; all other hooks fire whenever defined. After the call — **whether or not the hook was called** — it always drains the object's queue, so a delay still advances on an object that has no `tick` (or whose `tick` was skipped this frame). 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. - `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. - **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`. - **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`.
+7 -3
View File
@@ -428,8 +428,11 @@ impl GameState {
} }
}; };
// Call the target's bump hook and resolve whatever it did // Call the target's bump hook and resolve whatever it did. `by_player`
let actions = self.scripts.run_bump(id, dir.opposite()); // is true only when the immediate bumper is the player — a crate shoved
// into this object (or another object moving in) is not the player.
let by_player = from == self.board().player_pos();
let actions = self.scripts.run_bump(id, dir.opposite(), by_player);
self.apply_actions(actions); self.apply_actions(actions);
// Now, check again if the target cell is empty. If it is, put the mover there. Also // Now, check again if the target cell is empty. If it is, put the mover there. Also
@@ -450,7 +453,8 @@ impl GameState {
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the /// passable nor a pushable solid that can be shoved aside. No-ops silently (the
/// caller does not need to check). If a solid object lies in the path — directly /// caller does not need to check). If a solid object lies in the path — directly
/// or at the end of a chain of crates the player is shoving — its `bump` hook /// or at the end of a chain of crates the player is shoving — its `bump` hook
/// fires with the direction the bump came from (whether or not the player moves). /// fires with the direction the bump came from and a bool reporting whether the
/// bumper was the player (whether or not the player moves).
pub fn try_move(&mut self, dir: Direction) { pub fn try_move(&mut self, dir: Direction) {
let (dx, dy): (i64, i64) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
let player_loc = self.board().player_pos(); let player_loc = self.board().player_pos();
+39 -21
View File
@@ -6,8 +6,9 @@
//! //!
//! - `init(me, state)` — run once after the whole map is loaded (see [`ScriptHost::run_init`]). //! - `init(me, state)` — run once after the whole map is loaded (see [`ScriptHost::run_init`]).
//! - `tick(me, state, dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]). //! - `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 //! - `bump(me, dir, by_player)` — run when a solid (the player, an object, or a pushed crate) presses
//! object's cell, with the [`Direction`] the bump came *from*; see [`ScriptHost::run_bump`]. //! into this object's cell, with the [`Direction`] the bump came *from* and whether the bumper was
//! the player; see [`ScriptHost::run_bump`].
//! - `enter(me, dir)` — run when a solid (the player, an object, or a pushed crate) relocates *onto* //! - `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 //! this **non-solid** object's cell, with the [`Direction`] the entrant came *from* (best-effort for
//! teleport/shift); see [`ScriptHost::run_enter`]. //! teleport/shift); see [`ScriptHost::run_enter`].
@@ -151,7 +152,7 @@ impl ScriptHost {
CompiledScript { CompiledScript {
has_init: defines("init", 1), has_init: defines("init", 1),
has_tick: defines("tick", 2), has_tick: defines("tick", 2),
has_bump: defines("bump", 2), has_bump: defines("bump", 3),
has_enter: defines("enter", 2), has_enter: defines("enter", 2),
ast, ast,
}, },
@@ -201,18 +202,21 @@ impl ScriptHost {
/// The `dt` arg is only meaningful for `Tick` (it becomes the hook's `dt` /// 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. /// 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> { pub(crate) fn run_hook_on(&mut self, hook: Hook, id: ObjectId, dt: f64) -> Vec<BoardAction> {
let arg = match hook { let extra = match hook {
Hook::Tick => Some(Dynamic::from(dt)), Hook::Tick => vec![Dynamic::from(dt)],
_ => None, _ => vec![],
}; };
self.run_hook_on_one(hook, id, arg, dt) self.run_hook_on_one(hook, id, extra, dt)
} }
/// Calls one lifecycle `hook` on object `id` (if the script defines it), then /// 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 — /// drains that object's ready actions into a fresh `Vec` and returns them —
/// [`GameState`](crate::game::GameState) applies them immediately, before the /// [`GameState`](crate::game::GameState) applies them immediately, before the
/// next object runs, so each object sees the board state its actions run against. /// 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> { ///
/// `extra` holds the hook-specific arguments beyond `me` (`[dt]` for `tick`,
/// `[dir]` for `enter`, `[dir, by_player]` for `bump`, empty otherwise).
fn run_hook_on_one(&mut self, hook: Hook, id: ObjectId, extra: Vec<Dynamic>, dt: f64) -> Vec<BoardAction> {
let mut actions = Vec::new(); let mut actions = Vec::new();
if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) { if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
if let Some(script) = self.scripts.get(&info.script_key) if let Some(script) = self.scripts.get(&info.script_key)
@@ -225,7 +229,7 @@ impl ScriptHost {
// fires regardless of queued actions. // fires regardless of queued actions.
if script.has(hook) && (hook != Hook::Tick || info.queue.is_empty()) { if script.has(hook) && (hook != Hook::Tick || info.queue.is_empty()) {
let mut args = vec![Dynamic::from(info.clone())]; let mut args = vec![Dynamic::from(info.clone())];
if let Some(d) = arg { args.push(d) } args.extend(extra);
// Call this with opts tagging this call as our id. The write API will read // Call this with opts tagging this call as our id. The write API will read
// that to find what queue to emit events to // that to find what queue to emit events to
@@ -249,11 +253,13 @@ impl ScriptHost {
actions actions
} }
/// Calls `bump(dir)` on the object with [`ObjectId`] `id`, if it defines the /// Calls `bump(dir, by_player)` on the object with [`ObjectId`] `id`, if it
/// hook, and returns the actions it drained. `dir` is the [`Direction`] the /// defines the hook, and returns the actions it drained. `dir` is the
/// bump came *from* (it points from the bumped object toward the bumper). /// [`Direction`] the bump came *from* (it points from the bumped object toward
pub(crate) fn run_bump(&mut self, id: ObjectId, dir: Direction) -> Vec<BoardAction> { /// the bumper); `by_player` is `true` when the thing pressing into this object
self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0) /// is the player (not a pushed crate or another object).
pub(crate) fn run_bump(&mut self, id: ObjectId, dir: Direction, by_player: bool) -> Vec<BoardAction> {
self.run_hook_on_one(Hook::Bump, id, vec![Dynamic::from(dir), Dynamic::from(by_player)], 0.0)
} }
/// Calls `enter(dir)` on the object with [`ObjectId`] `id`, if it defines the /// Calls `enter(dir)` on the object with [`ObjectId`] `id`, if it defines the
@@ -261,7 +267,7 @@ impl ScriptHost {
/// object, or a pushed crate) relocates onto this non-solid object's cell; `dir` /// 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). /// is the [`Direction`] the entrant came *from* (best-effort for teleport/shift).
pub(crate) fn run_enter(&mut self, id: ObjectId, dir: Direction) -> Vec<BoardAction> { pub(crate) fn run_enter(&mut self, id: ObjectId, dir: Direction) -> Vec<BoardAction> {
self.run_hook_on_one(Hook::Enter, id, Some(Dynamic::from(dir)), 0.0) self.run_hook_on_one(Hook::Enter, id, vec![Dynamic::from(dir)], 0.0)
} }
/// Calls the named function on the object with [`ObjectId`] `target_id`. /// Calls the named function on the object with [`ObjectId`] `target_id`.
@@ -359,14 +365,16 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
engine.register_get("dx", |d: &mut Direction| d.dx()); engine.register_get("dx", |d: &mut Direction| d.dx());
engine.register_get("dy", |d: &mut Direction| d.dy()); engine.register_get("dy", |d: &mut Direction| d.dy());
// move(dir): enqueue a Move followed by a rate-limiting Delay. // move(dir): enqueue a Move followed by an optional rate-limiting Delay.
let b = board.clone();
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction, delay: bool| {
implement_move(ctx, &b, dir, delay)
});
// If not specified the delay defaults to true:
let b = board.clone(); let b = board.clone();
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| {
let src = source_of(&ctx); implement_move(ctx, &b, dir, true)
if let Some(scr) = b.borrow_mut().scripting_mut(src) {
scr.queue.act(Action::Move(dir));
scr.queue.delay(MOVE_COST);
}
}); });
let b = board.clone(); let b = board.clone();
@@ -690,3 +698,13 @@ fn source_of(ctx: &NativeCallContext) -> ObjectId {
.and_then(|n| ObjectId::try_from(n).ok()) .and_then(|n| ObjectId::try_from(n).ok())
.unwrap_or(0) .unwrap_or(0)
} }
fn implement_move(ctx: NativeCallContext, board: &BoardRef, dir: Direction, delay: bool) {
let src = source_of(&ctx);
if let Some(scr) = board.borrow_mut().scripting_mut(src) {
scr.queue.act(Action::Move(dir));
if delay {
scr.queue.delay(MOVE_COST);
}
}
}
+2 -6
View File
@@ -1,8 +1,4 @@
// Built-in script for the `crate` archetype (see kiln-core/src/builtin.rs). // Built-in script for the `crate` archetype (see kiln-core/src/builtin.rs).
fn bump(me, dir) { fn bump(me, dir, _by_player) {
push(me.x, me.y, dir.opposite); // Try to clear our target cell move(dir.opposite, false);
let tgt = dir.opposite.from_point(me.x, me.y);
// This will silently-nop if tgt is occupied, and it will evaluate that after the push
// (queue both right now, evaluate in that order after)
teleport(me.id, tgt.x, tgt.y);
} }
+7 -3
View File
@@ -3,7 +3,11 @@
// A gem is a collectible: it is solid, so walking into it fires this `bump()` // A gem is a collectible: it is solid, so walking into it fires this `bump()`
// hook. We bank the gem and `die()`, which clears the cell — so the move that // hook. We bank the gem and `die()`, which clears the cell — so the move that
// bumped us completes and the player ends up standing where the gem was. // bumped us completes and the player ends up standing where the gem was.
fn bump(me, _dir) { fn bump(me, dir, by_player) {
alter_gems(1); if by_player {
die(); alter_gems(1);
die();
} else {
move(dir.opposite, false)
}
} }
+3 -8
View File
@@ -1,10 +1,5 @@
fn bump(me, dir) { fn bump(me, dir, _by_player) {
if dir == North || dir == South { if dir == East || dir == West {
return; move(dir.opposite, false);
} }
push(me.x, me.y, dir.opposite); // Try to clear our target cell
let tgt = dir.opposite.from_point(me.x, me.y);
// This will silently-nop if tgt is occupied, and it will evaluate that after the push
// (queue both right now, evaluate in that order after)
teleport(me.id, tgt.x, tgt.y);
} }
+7 -3
View File
@@ -2,7 +2,11 @@
// //
// A heart is a collectible: walking into it fires `bump()`, which restores 1 // A heart is a collectible: walking into it fires `bump()`, which restores 1
// health and removes the heart, clearing the cell for the bumper to move into. // health and removes the heart, clearing the cell for the bumper to move into.
fn bump(me, _dir) { fn bump(me, dir, by_player) {
alter_health(1); if by_player {
die(); alter_health(1);
die();
} else {
move(dir.opposite, false);
}
} }
+1 -1
View File
@@ -3,7 +3,7 @@
// A key is a collectible: walking into it fires `bump()`. We read our own colour // A key is a collectible: walking into it fires `bump()`. We read our own colour
// off the `BUILTIN_key_<colour>` tag, add it to the player's keyring, and `die()` // off the `BUILTIN_key_<colour>` tag, add it to the player's keyring, and `die()`
// so the cell clears for the bumper to move into. // so the cell clears for the bumper to move into.
fn bump(me, _dir) { fn bump(me, _dir, _by_player) {
let colors = [ let colors = [
"red", "red",
"orange", "orange",
+1 -1
View File
@@ -54,7 +54,7 @@ fn tick(me, dt) {
me.delay(0.15); me.delay(0.15);
} }
fn bump(me, dir) { fn bump(me, dir, _by_player) {
let d = facing(me); let d = facing(me);
// Only transport things that hit us from the front. `dir` is the side the bump // Only transport things that hit us from the front. `dir` is the side the bump
+3 -8
View File
@@ -1,10 +1,5 @@
fn bump(me, dir) { fn bump(me, dir, _by_player) {
if dir == East || dir == West { if dir == North || dir == South {
return; move(dir.opposite, false);
} }
push(me.x, me.y, dir.opposite); // Try to clear our target cell
let tgt = dir.opposite.from_point(me.x, me.y);
// This will silently-nop if tgt is occupied, and it will evaluate that after the push
// (queue both right now, evaluate in that order after)
teleport(me.id, tgt.x, tgt.y);
} }
+43 -8
View File
@@ -1,6 +1,6 @@
use super::{log_texts, scripts_from}; use super::{log_texts, scripts_from};
use crate::board::Board; use crate::board::Board;
use crate::board::tests::{object_at, open_board, plain_object_at, sensor_at}; use crate::board::tests::{crate_at, object_at, open_board, plain_object_at, sensor_at};
use crate::game::{GameState, ScrollLine}; use crate::game::{GameState, ScrollLine};
use crate::utils::{Direction, ObjectId, Point}; use crate::utils::{Direction, ObjectId, Point};
use std::collections::HashMap; use std::collections::HashMap;
@@ -297,8 +297,9 @@ fn object_id_for_name_finds_by_name() {
} }
// ── bump ──────────────────────────────────────────────────────────────────── // ── bump ────────────────────────────────────────────────────────────────────
// `bump(me, dir)` fires when the player presses into a solid object — the only // `bump(me, dir, by_player)` fires when a solid presses into a scripted object.
// remaining bump trigger, since object movement no longer dispatches hooks. // `dir` is the side the bump came from; `by_player` is true when the bumper is
// the player itself (not a pushed crate or another object).
#[test] #[test]
fn player_bump_reports_the_direction_it_came_from() { fn player_bump_reports_the_direction_it_came_from() {
@@ -308,7 +309,7 @@ fn player_bump_reports_the_direction_it_came_from() {
object_at(&mut board, 1, 0, "b", false); object_at(&mut board, 1, 0, "b", false);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("b", "fn bump(me, dir) { log(`bumped from ${dir}`); }")]), scripts_from(&[("b", "fn bump(me, dir, _by_player) { log(`bumped from ${dir}`); }")]),
); );
game.run_init(); game.run_init();
game.try_move(Direction::East); game.try_move(Direction::East);
@@ -328,7 +329,7 @@ fn bump_direction_supports_comparison_and_offset() {
"b", "b",
// Player bumps from the West, so `dir == West` and the bumper sits at // Player bumps from the West, so `dir == West` and the bumper sits at
// (me.x + dir.dx) = 1 + (-1) = 0. // (me.x + dir.dx) = 1 + (-1) = 0.
"fn bump(me, dir) { if dir == West { log(`bumper at ${me.x + dir.dx}`); } }", "fn bump(me, dir, _by_player) { if dir == West { log(`bumper at ${me.x + dir.dx}`); } }",
)]), )]),
); );
game.run_init(); game.run_init();
@@ -336,6 +337,40 @@ fn bump_direction_supports_comparison_and_offset() {
assert!(log_texts(&game).iter().any(|t| t == "bumper at 0")); assert!(log_texts(&game).iter().any(|t| t == "bumper at 0"));
} }
#[test]
fn bump_reports_true_when_bumped_by_the_player() {
// Walking straight into a scripted solid: the player is the bumper.
let mut board = open_board(3, 1, (0, 0));
object_at(&mut board, 1, 0, "b", false);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("b", "fn bump(me, _dir, by_player) { log(`bumped by ${by_player}`); }")]),
);
game.run_init();
game.try_move(Direction::East);
assert!(log_texts(&game).iter().any(|t| t == "bumped by true"));
}
#[test]
fn bump_reports_false_when_bumped_by_a_pushed_crate() {
// The player shoves the crate at (1,0) into a scripted solid at (2,0). The
// crate's own bump sees the player, but the object behind it is bumped by the
// crate — so `by_player` is false there.
let mut board = open_board(4, 1, (0, 0));
crate_at(&mut board, 1, 0);
object_at(&mut board, 2, 0, "o", false);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("o", "fn bump(me, _dir, by_player) { log(`bumped by ${by_player}`); }")]),
);
game.run_init();
game.try_move(Direction::East);
assert!(
log_texts(&game).iter().any(|t| t == "bumped by false"),
"the object was bumped by the crate, not the player"
);
}
// ── scroll ────────────────────────────────────────────────────────────────── // ── scroll ──────────────────────────────────────────────────────────────────
#[test] #[test]
@@ -348,7 +383,7 @@ fn scroll_opens_on_player_bump() {
board, board,
scripts_from(&[( scripts_from(&[(
"s", "s",
r#"fn bump(me, dir) { scroll(["Hello world", ["eat", "Eat it"]]); }"#, r#"fn bump(me, dir, _by_player) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
)]), )]),
); );
game.run_init(); game.run_init();
@@ -372,7 +407,7 @@ fn handle_scroll_without_choice_clears_it() {
object_at(&mut board, 1, 0, "s", false); object_at(&mut board, 1, 0, "s", false);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("s", r#"fn bump(me, dir) { scroll(["Hello"]); }"#)]), scripts_from(&[("s", r#"fn bump(me, dir, _by_player) { scroll(["Hello"]); }"#)]),
); );
game.run_init(); game.run_init();
game.try_move(Direction::East); game.try_move(Direction::East);
@@ -394,7 +429,7 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
scripts_from(&[( scripts_from(&[(
"s", "s",
r#" r#"
fn bump(me, dir) { scroll(["Muffin?", ["eat", "Eat it"]]); } fn bump(me, dir, _by_player) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); } fn eat() { log("eaten"); }
"#, "#,
)]), )]),
+1 -1
View File
@@ -22,7 +22,7 @@ lantern = """
fn init(me) { fn init(me) {
say("A warm light\\nflickers here."); say("A warm light\\nflickers here.");
} }
fn bump(me, _dir) { fn bump(me, _dir, _by_player) {
say("The lantern\\nglows steadily."); say("The lantern\\nglows steadily.");
} }
""" """
+14 -8
View File
@@ -54,15 +54,17 @@ fn tick(me, dt) {
me.delay(0.5); me.delay(0.5);
} }
// Fires when a solid (the player, an object, or a pushed crate) presses into this // Fires when a solid (the player, an object, or a pushed crate) presses into this
// object; dir is the direction the bump came from. // object; dir is the direction the bump came from, by_player whether the bumper
fn bump(me, dir) { // was the player.
log(`mover bumped from ${dir}`); fn bump(me, dir, by_player) {
log(`mover bumped from ${dir} by player ${by_player}`);
say("Ow!"); say("Ow!");
} }
""" """
muffin = """ muffin = """
fn bump(me, _dir) { fn bump(me, _dir, by_player) {
if !by_player { return; }
scroll([ scroll([
"You find a small muffin on the ground, your favorite kind.", "You find a small muffin on the ground, your favorite kind.",
"It smells of cinnamon and warm mornings.", "It smells of cinnamon and warm mornings.",
@@ -81,7 +83,8 @@ fn ignore() {
""" """
noticeboard = """ noticeboard = """
fn bump(me, _dir) { fn bump(me, _dir, by_player) {
if !by_player { return; }
scroll([ scroll([
" TOWN NOTICE BOARD", " TOWN NOTICE BOARD",
" ", " ",
@@ -114,7 +117,8 @@ fn bump(me, _dir) {
""" """
bookshelf = """ bookshelf = """
fn bump(me, _dir) { fn bump(me, _dir, by_player) {
if !by_player { return; }
scroll([ scroll([
" THE BOOKSHELF", " THE BOOKSHELF",
" ", " ",
@@ -133,13 +137,15 @@ fn bump(me, _dir) {
""" """
fireplace = """ fireplace = """
fn bump(me, _dir) { fn bump(me, _dir, by_player) {
if !by_player { return; }
say("The fire crackles warmly.\\nYou feel at ease."); say("The fire crackles warmly.\\nYou feel at ease.");
} }
""" """
chest = """ chest = """
fn bump(me, _dir) { fn bump(me, _dir, by_player) {
if !by_player { return; }
scroll([ scroll([
" THE CHEST", " THE CHEST",
" ", " ",