redoing bump

This commit is contained in:
2026-07-08 13:21:27 -05:00
parent f407b5d9a6
commit e545395ac6
12 changed files with 243 additions and 90 deletions
+4 -3
View File
@@ -20,7 +20,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/archetype.rs`** — element taxonomy:
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Builtin(Builtin, &'static str)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` checks the `Builtin` registry first (via `Builtin::from_name`), then the hard-coded terrain names; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
- `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Heart`, `Pusher`, `Spinner`, `Key`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_<alias>` tag on the expanded object, and the compile-cache key.
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable), `Transporter` (`"transporter_north|south|east|west"` → 94/118/41/40 `^`/`v`/`)`/`(`; cyan, solid + **see-through** (`opaque: false`) + unpushable — animates a 4-frame loop and teleports whatever bumps it from the front, either past it or out of a paired opposite-facing transporter). All aliases in a family share one Rhai script; the script reads `me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable), `Transporter` (`"transporter_north|south|east|west"` → 94/118/41/40 `^`/`v`/`)`/`(`; cyan, solid + **see-through** (`opaque: false`) + unpushable — animates a 4-frame loop and, on a front bump, moves whatever solid presses into its entrance — the player, an object, or a **pushed crate** — via a coordinate `shift`, either onto the cell just past it or, if that is blocked, out the far side of the nearest paired opposite-facing transporter). All aliases in a family share one Rhai script; the script reads `me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
**`kiln-core/src/builtin_scripts.rs`** — tag helpers for script-backed archetypes (`pub(crate)`):
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_<alias>` (e.g. `"BUILTIN_pusher_east"`) and also uses this string as the object's `script_name` compile-cache key (so each alias gets its own cached AST, though the Rhai source is the same for all aliases in a family). The script reads its tag to determine per-instance direction. `map_file::save` uses `archetype_from_builtin_tag` to collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live in `archetype.rs` via the `builtins!` macro (via `include_str!` of `scripts/*.rhai`); this file has only the three tag helpers.
@@ -49,6 +49,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`).
- `is_passable(x, y)` — convenience inverse of `solid_at`.
- `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
- `bump_target(x, y, dir) -> Option<ObjectId>` — the object a move **into** `(x, y)` heading `dir` bumps: the solid object in the target cell, or the one at the end of a chain of pushed crates (walked through in `dir`). Open space, the player, or a wall yield `None`. This is what lets a plain crate — not just an object or the player — trigger a `bump`. Used by `GameState::try_move` / `step_object`; the caller supplies the came-from direction as `dir.opposite()`.
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The **player** counts as a blocker for this check. A Rust-side companion read to `apply_shift`; not itself exposed to scripts.
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`).
- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries.
@@ -70,7 +71,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one).
- `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds).
- `Scroll { source: ObjectId, lines: Vec<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(-1)` on any solid object walked into; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` + an immediate `resolve()` so its `die()`/`alter_gems()`/`alter_health()` apply before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log``log`, `SetTile`/`SetColor` → source glyph, `Move(dir)``step_object`, `Say``speech_bubbles`, `Scroll``active_scroll`, `AddGems(n)``player.gems`, `AlterHealth(dh)``player.health` clamped to `[0, max_health]`, `SetKey``player.keys`, `Shift``apply_shift`, `Push`/`Teleport` → board moves, `Send``run_send`, `Die``remove_object(source)`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
- `GameState` — owns `world: World` (all boards as `Rc<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.
**`kiln-core/src/action.rs`** — the `Action` enum and its supporting types:
- `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s).
@@ -89,7 +90,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/script.rs`** — Rhai scripting **host** (the script-facing types live in `kiln-core/src/api/`, below). The script-author's reference is [`docs/script-api.md`](../docs/script-api.md).
- `Registerable` trait — `fn register(engine, error_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`.
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap<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, state, id)` (the bumper's `ObjectId`, or `-1` for the player), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (2/3/3/2). Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(state, id, bumper)` / `run_grab(state, id)`, all funneling through `run_hook_on_one`: it builds a fresh `ObjectInfo` for the object, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal.
- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(id, dir)` / `run_grab(state, id)`, all funneling through `run_hook_on_one`: it builds a fresh `ObjectInfo` for the object, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal.
- `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists.
- **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore.
- **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`.
+35
View File
@@ -252,6 +252,41 @@ impl Board {
.is_some_and(|s| s.pushable().allows(dir))
}
/// The object that a move **into** `(x, y)` heading `dir` bumps, if any.
///
/// Walks the chain of pushable crates from the target cell in `dir` until it
/// reaches something that stops it, and reports the object it presses against:
/// - a solid object in the target cell (or at the end of a crate chain) is the
/// bumped object,
/// - open space or the player means nothing is bumped,
/// - a non-pushable terrain cell (a wall) blocks the chain with no object to bump.
///
/// This is what lets *any* solid — the player, another object, or a pushed
/// crate — trigger a `bump`: the bumper need not be an object, since we only
/// return the *bumped* object's id (the direction it came from is supplied by
/// the caller from its move direction).
pub fn bump_target(&self, x: usize, y: usize, dir: Direction) -> Option<ObjectId> {
let (dx, dy): (i64, i64) = dir.into();
let (mut cx, mut cy) = (x, y);
loop {
match self.solid_at(cx, cy) {
None => return None, // open space: nothing bumped
Some(s) if s.object_id().is_some() => return s.object_id(), // hit an object
Some(s) if s.player() => return None, // the player is never "bumped"
// A terrain cell: a pushable crate is walked through; a wall stops us.
Some(_) if self.is_pushable(cx, cy, dir) => {
let next = (cx as i64 + dx, cy as i64 + dy);
if !self.in_bounds(next) {
return None; // crate chain runs off the board
}
cx = next.0 as usize;
cy = next.1 as usize;
}
Some(_) => return None, // non-pushable terrain (wall): no object behind it here
}
}
}
/// Whether the chain of pushable solids starting at `(x, y)` can be shoved one
/// step in `dir` — i.e. the chain ends at a passable cell rather than the board
/// edge or a non-pushable solid.
+24 -17
View File
@@ -208,7 +208,7 @@ impl GameState {
// Logs are collected here rather than pushed inline, since the board borrow
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
let mut bumps: Vec<(ObjectId, Direction)> = Vec::new();
// Net change to player stats from AddGems / AlterHealth actions; applied
// to `self` after the board borrow drops.
let mut gem_delta: i64 = 0;
@@ -225,7 +225,9 @@ impl GameState {
match ba.action {
Action::Move(dir) => {
if let Some(bumped) = step_object(&mut board, ba.source, dir) {
bumps.push((bumped, ba.source as i64));
// The bump "comes from" the side the mover advanced from,
// i.e. the opposite of its travel direction.
bumps.push((bumped, dir.opposite()));
}
}
Action::SetTile(tile) => {
@@ -382,8 +384,8 @@ impl GameState {
self.log.push(LogLine::error(format!("set_key: unknown color {color:?}")));
}
}
for (bumped, bumper) in bumps {
self.scripts.run_bump(bumped, bumper);
for (bumped, dir) in bumps {
self.scripts.run_bump(bumped, dir);
}
for (target, fn_name, arg) in sends {
self.scripts.run_send(target, &fn_name, arg);
@@ -462,8 +464,9 @@ impl GameState {
///
/// The move is ignored if the target cell is out of bounds, or it is neither
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the
/// caller does not need to check). If the target cell holds a solid object, that
/// object's `bump(-1)` hook fires (whether or not the player ends up moving).
/// caller does not need to check). If a solid object lies in the path — directly
/// or at the end of a chain of crates the player is shoving — its `bump` hook
/// fires with the direction the bump came from (whether or not the player moves).
pub fn try_move(&mut self, dir: Direction) {
let bumped;
let grabbed;
@@ -479,12 +482,13 @@ impl GameState {
// Walking onto a grab thing (e.g. a gem) is never blocked: the player
// moves onto it and its grab() hook fires (the thing despawns itself).
grabbed = board.grab_object_at(nx, ny);
// A solid object in the way is bumped by the player (id -1) — but a
// grab thing fires grab() instead of bump(), so don't also bump it.
bumped = board
.solid_at(nx, ny)
.filter(|_| grabbed.is_none())
.and_then(|s| s.object_id());
// A solid object in the way is bumped by the player — possibly through a
// chain of crates the player is shoving (see `bump_target`) — but a grab
// thing fires grab() instead of bump(), so don't also bump it.
bumped = grabbed
.is_none()
.then(|| board.bump_target(nx, ny, dir))
.flatten();
if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
// Don't push a grab thing aside — walk onto it. Otherwise shove any
// pushable chain out of the way (no-op when there's nothing to push).
@@ -515,7 +519,8 @@ impl GameState {
self.resolve();
}
if let Some(idx) = bumped {
self.scripts.run_bump(idx, -1);
// The player advanced in `dir`, so the bump arrives from the opposite side.
self.scripts.run_bump(idx, dir.opposite());
self.drain_errors();
}
}
@@ -526,9 +531,10 @@ impl GameState {
///
/// The move itself proceeds only if the target is in bounds and either passable or a
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
/// bump is recorded whenever a solid object occupies the target — whether it gets
/// pushed aside or blocks the move — since something tried to move into it. Walls and
/// crates carry no script, so only solid objects yield a bump.
/// bump is recorded for the solid object the move presses into — directly, or at the
/// end of a chain of crates being shoved (see [`Board::bump_target`]) — whether it
/// gets pushed aside or blocks the move. Walls and crates carry no script, so only
/// solid objects yield a bump.
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> {
let (dx, dy): (i64, i64) = dir.into();
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?;
@@ -538,7 +544,8 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// Capture the bumped object before any push relocates it (its id is stable).
let bumped = board.solid_at(nx, ny).and_then(|s| s.object_id());
// Walks through a pushed crate chain to the object it presses against.
let bumped = board.bump_target(nx, ny, dir);
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
let obj = board.objects.get_mut(&id).expect("id checked above");
+25 -6
View File
@@ -6,8 +6,8 @@
//!
//! - `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, state, id)` — run when another mover steps into this object's cell, with the
//! bumper's object index (`-1` for the player); see [`ScriptHost::run_bump`].
//! - `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`].
//! - `grab(me, state)` — run when the player walks onto a `grab` object; see [`ScriptHost::run_grab`].
//! Typically adds a stat + `die()`s.
//!
@@ -257,10 +257,12 @@ impl ScriptHost {
}
}
/// Calls `bump(id)` on the object with [`ObjectId`] `object_id`, if it defines
/// the hook. After the hook, drains the object's queue with `dt = 0`.
pub fn run_bump(&mut self, id: ObjectId, bumper: i64) {
self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(bumper)), 0.0);
/// 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);
}
/// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the
@@ -344,6 +346,23 @@ impl ScriptHost {
fn register_write_api(engine: &mut Engine, board: BoardRef) {
engine.register_type_with_name::<Direction>("Direction");
// Rhai does not auto-derive comparison for custom types, so register `==`/`!=`
// to let scripts test the `bump` direction (e.g. `if dir == West { … }`).
engine.register_fn("==", |a: Direction, b: Direction| a == b);
engine.register_fn("!=", |a: Direction, b: Direction| a != b);
// Let a Direction interpolate/print as its name (e.g. in `log(`from ${dir}`)`).
let dir_name = |d: Direction| match d {
Direction::North => "North",
Direction::South => "South",
Direction::East => "East",
Direction::West => "West",
};
engine.register_fn("to_string", dir_name);
engine.register_fn("to_debug", dir_name);
// Expose the unit-step components so scripts can turn a direction into a cell
// offset (e.g. the bumper's cell is `(me.x + dir.dx, me.y + dir.dy)`).
engine.register_get("dx", |d: &mut Direction| d.dx());
engine.register_get("dy", |d: &mut Direction| d.dy());
// move(dir): enqueue a Move followed by a rate-limiting Delay.
let b = board.clone();
+40 -24
View File
@@ -8,8 +8,9 @@
//
// The direction is not baked into this source: every transporter shares one
// compiled copy and reads its `BUILTIN_transporter_<dir>` tag. The world is
// reached through the global `Board`/`Player` constants; `teleport(id, x, y)`
// moves an arbitrary entity (`-1` is the player).
// reached through the global `Board`/`Player` constants. The bumper is moved by
// coordinate via `shift([[from], [to]])`, which relocates whatever solid sits at
// the entrance — player, object, or a pushed crate — onto the empty destination.
// This transporter's facing unit vector, from its direction tag.
fn facing(me) {
@@ -27,6 +28,16 @@ fn opposite_tag(me) {
else { "BUILTIN_transporter_east" }
}
// The direction a front bump arrives from — the side opposite our facing (a
// north-facing transporter is entered from the south, etc.). `bump` reacts only
// when the reported direction matches this.
fn entrance_dir(me) {
if me.has_tag("BUILTIN_transporter_north") { South }
else if me.has_tag("BUILTIN_transporter_south") { North }
else if me.has_tag("BUILTIN_transporter_east") { West }
else { East }
}
// The 4-frame animation loop for this direction (CP437 tile codes).
fn frames(me) {
if me.has_tag("BUILTIN_transporter_north") { [94, 45, 94, 126] } // ^ - ^ ~
@@ -47,38 +58,41 @@ fn tick(me, dt) {
me.delay(0.15);
}
fn bump(me, id) {
// Move whatever solid sits at (sx, sy) onto the empty cell (tx, ty) immediately.
// A two-cell `shift` relocates the source solid — player, object, or crate — and
// leaves its old cell empty (the destination is checked empty by the caller). Kept
// as a helper so the nested-array literal stays at a shallow expression depth.
fn transport(sx, sy, tx, ty) {
shift([[sx, sy], [tx, ty]]); now();
}
fn bump(me, dir) {
// Only transport things that hit us from the front — the entrance side. `dir`
// is the direction the bump came from; a bump from any other side does nothing.
if dir != entrance_dir(me) { return; }
let d = facing(me);
let dx = d[0];
let dy = d[1];
// Only transport things that hit us from the front — the entrance cell at
// (me - d). A bump from any other side does nothing.
let bx;
let by;
if id == -1 {
bx = Player.x;
by = Player.y;
} else {
let o = Board.get(id);
if o == () { return; }
bx = o.x;
by = o.y;
}
if bx != me.x - dx || by != me.y - dy { return; }
// Whatever solid is pressed against our entrance cell (me - d) gets moved:
// the player, another object, or a pushed crate — all handled uniformly by
// shifting the solid at that coordinate onto an empty destination.
let ex = me.x - dx;
let ey = me.y - dy;
// 1. The cell just past us (the opposite side): if nothing solid is there,
// drop the bumper onto it.
let fx = me.x + dx;
let fy = me.y + dy;
if Board.passable(fx, fy) {
teleport(id, fx, fy); now();
transport(ex, ey, fx, fy);
return;
}
// 2. Otherwise scan along our axis for the nearest opposite-facing transporter
// whose entrance (the cell just before it) is free, and drop the bumper
// there. A blocked pair is skipped; give up at the board edge.
// whose far side (the cell just past it) is free, and drop the bumper there.
// A blocked pair is skipped; give up at the board edge.
let opp = Board.tagged(opposite_tag(me));
let cx = fx;
let cy = fy;
@@ -94,10 +108,12 @@ fn bump(me, id) {
if o.x == cx && o.y == cy { here = true; }
}
if here {
let ex = cx + dx;
let ey = cy + dy;
if Board.passable(ex, ey) {
teleport(id, ex, ey); now();
// Emerge on the paired transporter's far side — the cell just past it
// along our scan (cx + d), out its back.
let tx = cx + dx;
let ty = cy + dy;
if Board.passable(tx, ty) {
transport(ex, ey, tx, ty);
return;
}
}
+5 -4
View File
@@ -18,11 +18,11 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
scripts_from(&[
(
"e",
"fn init(m) { move(East); } fn bump(m,id) { log(`o0 by ${id}`); }",
"fn init(m) { move(East); } fn bump(m,dir) { log(`o0 from ${dir}`); }",
),
(
"w",
"fn init(m) { move(West); } fn bump(m,id) { log(`o1 by ${id}`); }",
"fn init(m) { move(West); } fn bump(m,dir) { log(`o1 from ${dir}`); }",
),
]),
);
@@ -36,6 +36,7 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
assert_eq!((b.objects[&2].x, b.objects[&2].y), (2, 0)); // obj1 blocked
}
let logs = log_texts(&game);
assert!(logs.iter().any(|t| t == "o0 by 2")); // obj0 bumped by obj1
assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped
// obj1 moved West into obj0, so the bump arrives from the East side of obj0.
assert!(logs.iter().any(|t| t == "o0 from East")); // obj0 bumped by obj1
assert!(!logs.iter().any(|t| t.starts_with("o1 from"))); // obj1 not bumped
}
+43 -5
View File
@@ -56,9 +56,9 @@ fn bumping_a_transporter_drops_you_on_its_far_side() {
#[test]
fn a_blocked_far_side_transports_out_of_the_paired_transporter() {
// Player, east transporter, a wall blocking its far side, the free entrance
// cell, then the paired west transporter. The player should pop out at the
// west transporter's entrance (the cell just before it), across the wall.
// Player, east transporter, a wall blocking its far side, a gap, the paired
// west transporter, then a free cell. The player should pop out on the paired
// transporter's far side (the cell just past it), across the wall.
let board = load_board(&map(
6,
1,
@@ -81,11 +81,49 @@ fn a_blocked_far_side_transports_out_of_the_paired_transporter() {
let b = game.board();
assert_eq!(
(b.player.x, b.player.y),
(3, 0),
"transported to the paired transporter's entrance",
(5, 0),
"transported past the paired transporter",
);
}
#[test]
fn a_pushed_crate_is_transported_through() {
// Player, a crate, an east transporter, then a free cell. Pushing the crate
// east into the transporter bumps it (through the crate chain); the transporter
// moves the crate — a plain terrain solid, no object id — out its far side.
let board = load_board(&map(
4,
1,
&[layer(
"@oT ",
&[
("@", "kind = \"player\""),
("o", "kind = \"crate\""),
("T", "kind = \"transporter_east\""),
],
)],
));
let mut game = GameState::new(board);
game.run_init();
// Push east into the crate: the crate can't move (the transporter blocks it),
// but the transporter is bumped from the West and teleports the crate to (3,0).
game.try_move(Direction::East);
game.tick(Duration::from_secs_f64(0.1));
{
let b = game.board();
assert!(b.solid_at(3, 0).is_some(), "crate transported to the far side");
assert!(b.is_passable(1, 0), "crate's old cell is now empty");
assert_eq!((b.player.x, b.player.y), (0, 0), "player didn't move yet");
}
// With the crate gone, a second push walks the player onto the vacated cell.
game.try_move(Direction::East);
game.tick(Duration::from_secs_f64(0.1));
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0), "player follows into the gap");
}
#[test]
fn transporter_round_trips_to_its_keyword() {
let board = load_board(&map(
+28 -8
View File
@@ -245,20 +245,40 @@ fn object_id_for_name_finds_by_name() {
// Ensure try_move from the player side also triggers scripted bump
#[test]
fn player_bump_fires_with_negative_one() {
// The player walks into a solid scripted object; the object is bumped with -1
// and the player does not move onto it.
fn player_bump_reports_the_direction_it_came_from() {
// The player walks East into a solid scripted object; the object is bumped from
// the West side (opposite the player's travel) and the player does not move onto it.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("b", "fn bump(m,id) { log(`bumped by ${id}`); }")]),
scripts_from(&[("b", "fn bump(m,dir) { log(`bumped from ${dir}`); }")]),
);
game.run_init();
game.try_move(Direction::East);
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
// bump's log is emitted into the object's queue; a tick flushes it.
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "bumped by -1"));
assert!(log_texts(&game).iter().any(|t| t == "bumped from West"));
}
// A bump handler can compare the direction and read its `dx`/`dy` offset to
// locate the bumper's cell.
#[test]
fn bump_direction_supports_comparison_and_offset() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"b",
// Player bumps from the West, so `dir == West` and the bumper sits at
// (me.x + dir.dx) = 1 + (-1) = 0.
"fn bump(m,dir) { if dir == West { log(`bumper at ${m.x + dir.dx}`); } }",
)]),
);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "bumper at 0"));
}
#[test]
@@ -271,7 +291,7 @@ fn scroll_opens_on_player_bump() {
board,
scripts_from(&[(
"s",
r#"fn bump(m,id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
r#"fn bump(m,dir) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
)]),
);
game.run_init();
@@ -295,7 +315,7 @@ fn handle_scroll_without_choice_clears_it() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("s", r#"fn bump(m,id) { scroll(["Hello"]); }"#)]),
scripts_from(&[("s", r#"fn bump(m,dir) { scroll(["Hello"]); }"#)]),
);
game.run_init();
game.try_move(Direction::East);
@@ -317,7 +337,7 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
scripts_from(&[(
"s",
r#"
fn bump(m,id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn bump(m,dir) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); }
"#,
)]),
+14
View File
@@ -367,6 +367,20 @@ impl Direction {
Direction::North => -1,
}
}
/// The direction pointing the opposite way.
///
/// Used to turn a mover's travel direction into the "came-from" direction
/// reported to a bumped object's `bump` hook (a bumper heading East arrives
/// from the West side of the thing it hits).
pub fn opposite(self) -> Direction {
match self {
Direction::North => Direction::South,
Direction::South => Direction::North,
Direction::East => Direction::West,
Direction::West => Direction::East,
}
}
}
/// A value that can be stored in a board's script registry across board transitions.