diff --git a/CLAUDE.md b/CLAUDE.md index 6fccdd2..2aab6c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ fn init(me, state) { # optional, run once at startup fn tick(me, state, dt) { if !me.waiting && !me.blocked(North) { move(North); } } -fn bump(me, 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 @@ -130,7 +130,7 @@ script_name = "tripwire" Palette `kind` values: the meta-kinds `empty` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `transporter_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). (`floor` is **no longer** a grid kind — it is the board-level `floor` attribute.) For archetype/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). -Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** in the grid (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). The grid's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, `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>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(me.id, "visited", true)`) or the per-board `Registry` (which also persists across transitions). diff --git a/docs/script-api.md b/docs/script-api.md index b0c00e7..c8f67b5 100644 --- a/docs/script-api.md +++ b/docs/script-api.md @@ -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 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 the bumper occupies the cell at `(me.x + dir.dx, me.y + dir.dy)`. - Compare it against the `North`/`South`/`East`/`West` constants. -- The hook can no longer tell *who* did the bumping (there is no id) — a crate bumps just like the player. +- `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 -fn bump(me, dir) { - if dir == West { say("Something shoved me from the west."); } - else { log(`bumped from ${dir}`); } +fn bump(me, dir, by_player) { + if by_player { say("Don't shove me, I'm fragile!"); } + 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`). ```rhai -fn bump(me, dir) { +fn bump(me, dir, _by_player) { scroll([ "The muffin looks delicious.", "", @@ -385,7 +386,7 @@ fn tick(me, state, dt) { 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(); } ``` diff --git a/kiln-core/CLAUDE.md b/kiln-core/CLAUDE.md index 8227ed6..1265878 100644 --- a/kiln-core/CLAUDE.md +++ b/kiln-core/CLAUDE.md @@ -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). - `Registerable` trait — `fn register(engine, log_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`. - `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap`), one persistent `Scope` per scripted object (`HashMap`), and the shared `LogSink`. (There is no shared board queue: each hook call drains into a local `Vec` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`/`has_enter`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the `LogSink`; runs nothing. -- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), `enter(me, dir)` (the non-solid counterpart to `bump`: fired on a non-solid object when a solid *relocates onto* its cell — a player/object step, a pushed crate, a `teleport`, or a `shift` — with `dir` the side the entrant came from, best-effort for teleport/shift), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump and enter are arity 2). Run one object at a time (the per-object loop lives in `GameState`): `run_tick_on(id, dt)` / `run_init_on(id)` / `run_bump(id, dir)` / `run_enter(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec` they drained** for `GameState` to apply. `run_hook_on_one` builds a fresh `ObjectInfo`, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. 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` 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. - **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore. - **Write API** (`register_write_api`) — free functions that (mostly) enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. **`log(s)` is the exception** — it is *not* an `Action`; it writes a line straight to the shared `LogSink`, so it surfaces immediately regardless of the object's queued delays (the write closure takes a `LogSink` clone). `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error immediately to the `LogSink`) and rotates that ring of cells via `Board::apply_shift`. diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index cec40c4..2fc5c5c 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -428,8 +428,11 @@ impl GameState { } }; - // Call the target's bump hook and resolve whatever it did - let actions = self.scripts.run_bump(id, dir.opposite()); + // Call the target's bump hook and resolve whatever it did. `by_player` + // 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); // 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 /// caller does not need to check). If a solid object lies in the path — directly /// or at the end of a chain of crates the player is shoving — its `bump` hook - /// fires with the direction the bump came from (whether or not the player moves). + /// 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) { let (dx, dy): (i64, i64) = dir.into(); let player_loc = self.board().player_pos(); diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 1d91deb..2c3b96e 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -6,8 +6,9 @@ //! //! - `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`]). -//! - `bump(me, dir)` — run when a solid (the player, an object, or a pushed crate) presses into this -//! object's cell, with the [`Direction`] the bump came *from*; see [`ScriptHost::run_bump`]. +//! - `bump(me, dir, by_player)` — run when a solid (the player, an object, or a pushed crate) presses +//! into this object's cell, with the [`Direction`] the bump came *from* 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* //! this **non-solid** object's cell, with the [`Direction`] the entrant came *from* (best-effort for //! teleport/shift); see [`ScriptHost::run_enter`]. @@ -151,7 +152,7 @@ impl ScriptHost { CompiledScript { has_init: defines("init", 1), has_tick: defines("tick", 2), - has_bump: defines("bump", 2), + has_bump: defines("bump", 3), has_enter: defines("enter", 2), ast, }, @@ -201,18 +202,21 @@ impl ScriptHost { /// 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 { - let arg = match hook { - Hook::Tick => Some(Dynamic::from(dt)), - _ => None, + let extra = match hook { + Hook::Tick => vec![Dynamic::from(dt)], + _ => 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 /// 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, dt: f64) -> Vec { + /// + /// `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, dt: f64) -> Vec { let mut actions = Vec::new(); if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) { if let Some(script) = self.scripts.get(&info.script_key) @@ -225,7 +229,7 @@ impl ScriptHost { // fires regardless of queued actions. if script.has(hook) && (hook != Hook::Tick || info.queue.is_empty()) { 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 // that to find what queue to emit events to @@ -249,11 +253,13 @@ impl ScriptHost { actions } - /// Calls `bump(dir)` on the object with [`ObjectId`] `id`, if it defines the - /// 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 { - self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0) + /// Calls `bump(dir, by_player)` on the object with [`ObjectId`] `id`, if it + /// defines the hook, and returns the actions it drained. `dir` is the + /// [`Direction`] the bump came *from* (it points from the bumped object toward + /// the bumper); `by_player` is `true` when the thing pressing into this object + /// 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 { + 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 @@ -261,7 +267,7 @@ impl ScriptHost { /// object, or a pushed crate) relocates onto this non-solid object's cell; `dir` /// is the [`Direction`] the entrant came *from* (best-effort for teleport/shift). pub(crate) fn run_enter(&mut self, id: ObjectId, dir: Direction) -> Vec { - self.run_hook_on_one(Hook::Enter, id, Some(Dynamic::from(dir)), 0.0) + 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`. @@ -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("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(); engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { - let src = source_of(&ctx); - if let Some(scr) = b.borrow_mut().scripting_mut(src) { - scr.queue.act(Action::Move(dir)); - scr.queue.delay(MOVE_COST); - } + implement_move(ctx, &b, dir, true) }); let b = board.clone(); @@ -690,3 +698,13 @@ fn source_of(ctx: &NativeCallContext) -> ObjectId { .and_then(|n| ObjectId::try_from(n).ok()) .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); + } + } +} \ No newline at end of file diff --git a/kiln-core/src/scripts/crate.rhai b/kiln-core/src/scripts/crate.rhai index 032f322..d80b912 100644 --- a/kiln-core/src/scripts/crate.rhai +++ b/kiln-core/src/scripts/crate.rhai @@ -1,8 +1,4 @@ // Built-in script for the `crate` archetype (see kiln-core/src/builtin.rs). -fn bump(me, dir) { - 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); +fn bump(me, dir, _by_player) { + move(dir.opposite, false); } diff --git a/kiln-core/src/scripts/gem.rhai b/kiln-core/src/scripts/gem.rhai index 4556bcd..2f57497 100644 --- a/kiln-core/src/scripts/gem.rhai +++ b/kiln-core/src/scripts/gem.rhai @@ -3,7 +3,11 @@ // 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 // bumped us completes and the player ends up standing where the gem was. -fn bump(me, _dir) { - alter_gems(1); - die(); +fn bump(me, dir, by_player) { + if by_player { + alter_gems(1); + die(); + } else { + move(dir.opposite, false) + } } diff --git a/kiln-core/src/scripts/hcrate.rhai b/kiln-core/src/scripts/hcrate.rhai index de0ba7c..19cae6c 100644 --- a/kiln-core/src/scripts/hcrate.rhai +++ b/kiln-core/src/scripts/hcrate.rhai @@ -1,10 +1,5 @@ -fn bump(me, dir) { - if dir == North || dir == South { - return; +fn bump(me, dir, _by_player) { + if dir == East || dir == West { + 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); } diff --git a/kiln-core/src/scripts/heart.rhai b/kiln-core/src/scripts/heart.rhai index e59600f..bb5d4ab 100644 --- a/kiln-core/src/scripts/heart.rhai +++ b/kiln-core/src/scripts/heart.rhai @@ -2,7 +2,11 @@ // // 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. -fn bump(me, _dir) { - alter_health(1); - die(); +fn bump(me, dir, by_player) { + if by_player { + alter_health(1); + die(); + } else { + move(dir.opposite, false); + } } diff --git a/kiln-core/src/scripts/key.rhai b/kiln-core/src/scripts/key.rhai index dac0996..b413110 100644 --- a/kiln-core/src/scripts/key.rhai +++ b/kiln-core/src/scripts/key.rhai @@ -3,7 +3,7 @@ // A key is a collectible: walking into it fires `bump()`. We read our own colour // off the `BUILTIN_key_` tag, add it to the player's keyring, and `die()` // so the cell clears for the bumper to move into. -fn bump(me, _dir) { +fn bump(me, _dir, _by_player) { let colors = [ "red", "orange", diff --git a/kiln-core/src/scripts/transporter.rhai b/kiln-core/src/scripts/transporter.rhai index f5ae8aa..bdb0b30 100644 --- a/kiln-core/src/scripts/transporter.rhai +++ b/kiln-core/src/scripts/transporter.rhai @@ -54,7 +54,7 @@ fn tick(me, dt) { me.delay(0.15); } -fn bump(me, dir) { +fn bump(me, dir, _by_player) { let d = facing(me); // Only transport things that hit us from the front. `dir` is the side the bump diff --git a/kiln-core/src/scripts/vcrate.rhai b/kiln-core/src/scripts/vcrate.rhai index 6d6326a..a4e36bf 100644 --- a/kiln-core/src/scripts/vcrate.rhai +++ b/kiln-core/src/scripts/vcrate.rhai @@ -1,10 +1,5 @@ -fn bump(me, dir) { - if dir == East || dir == West { - return; +fn bump(me, dir, _by_player) { + if dir == North || dir == South { + 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); } diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index d17ce27..0daa55f 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -1,6 +1,6 @@ use super::{log_texts, scripts_from}; 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::utils::{Direction, ObjectId, Point}; use std::collections::HashMap; @@ -297,8 +297,9 @@ fn object_id_for_name_finds_by_name() { } // ── bump ──────────────────────────────────────────────────────────────────── -// `bump(me, dir)` fires when the player presses into a solid object — the only -// remaining bump trigger, since object movement no longer dispatches hooks. +// `bump(me, dir, by_player)` fires when a solid presses into a scripted object. +// `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] 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); let mut game = GameState::with_scripts( 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.try_move(Direction::East); @@ -328,7 +329,7 @@ fn bump_direction_supports_comparison_and_offset() { "b", // Player bumps from the West, so `dir == West` and the bumper sits at // (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(); @@ -336,6 +337,40 @@ fn bump_direction_supports_comparison_and_offset() { 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 ────────────────────────────────────────────────────────────────── #[test] @@ -348,7 +383,7 @@ fn scroll_opens_on_player_bump() { board, scripts_from(&[( "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(); @@ -372,7 +407,7 @@ fn handle_scroll_without_choice_clears_it() { object_at(&mut board, 1, 0, "s", false); let mut game = GameState::with_scripts( 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.try_move(Direction::East); @@ -394,7 +429,7 @@ fn handle_scroll_with_choice_dispatches_send_to_source() { scripts_from(&[( "s", 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"); } "#, )]), diff --git a/maps/dark.toml b/maps/dark.toml index aff6bd4..438af6e 100644 --- a/maps/dark.toml +++ b/maps/dark.toml @@ -22,7 +22,7 @@ lantern = """ fn init(me) { say("A warm light\\nflickers here."); } -fn bump(me, _dir) { +fn bump(me, _dir, _by_player) { say("The lantern\\nglows steadily."); } """ diff --git a/maps/start.toml b/maps/start.toml index 8e30a97..d05a9bb 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -54,15 +54,17 @@ fn tick(me, dt) { me.delay(0.5); } // Fires when a solid (the player, an object, or a pushed crate) presses into this -// object; dir is the direction the bump came from. -fn bump(me, dir) { - log(`mover bumped from ${dir}`); +// object; dir is the direction the bump came from, by_player whether the bumper +// was the player. +fn bump(me, dir, by_player) { + log(`mover bumped from ${dir} by player ${by_player}`); say("Ow!"); } """ muffin = """ -fn bump(me, _dir) { +fn bump(me, _dir, by_player) { + if !by_player { return; } scroll([ "You find a small muffin on the ground, your favorite kind.", "It smells of cinnamon and warm mornings.", @@ -81,7 +83,8 @@ fn ignore() { """ noticeboard = """ -fn bump(me, _dir) { +fn bump(me, _dir, by_player) { + if !by_player { return; } scroll([ " TOWN NOTICE BOARD", " ", @@ -114,7 +117,8 @@ fn bump(me, _dir) { """ bookshelf = """ -fn bump(me, _dir) { +fn bump(me, _dir, by_player) { + if !by_player { return; } scroll([ " THE BOOKSHELF", " ", @@ -133,13 +137,15 @@ fn bump(me, _dir) { """ fireplace = """ -fn bump(me, _dir) { +fn bump(me, _dir, by_player) { + if !by_player { return; } say("The fire crackles warmly.\\nYou feel at ease."); } """ chest = """ -fn bump(me, _dir) { +fn bump(me, _dir, by_player) { + if !by_player { return; } scroll([ " THE CHEST", " ",