Compare commits

..

3 Commits

Author SHA1 Message Date
randrews 386967e936 simplify 2026-07-09 00:18:40 -05:00
randrews e545395ac6 redoing bump 2026-07-08 13:21:27 -05:00
randrews f407b5d9a6 transporters 2026-07-08 10:06:25 -05:00
18 changed files with 530 additions and 93 deletions
+3 -3
View File
@@ -82,7 +82,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, state, id) { log(`bumped by ${id}`); say("Ouch!"); }
fn bump(me, dir) { log(`bumped from ${dir}`); say("Ouch!"); } # dir: the side it came from
"""
# Each board is a named subtable under [boards]. The board key ("room1") is
@@ -128,9 +128,9 @@ content = """
"1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" }
```
Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph.
Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `transporter_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph.
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, state, id)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`) and `state` (the world). 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(x, y)`, `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump`. The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md).
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`). `bump`'s `dir` is the `Direction` the bump came *from* (pointing toward the bumper), fired when *any* solid — the player, another object, or a pushed crate — presses into this object. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump` with the direction the bump came from. The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md).
**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).
+16 -14
View File
@@ -48,17 +48,20 @@ fn tick(me, state, dt) {
}
```
### `fn bump(me, state, id)`
### `fn bump(me, dir)`
Called when another entity walks into this object's cell.
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).
- `id` is the bumper's `ObjectId` (an `i64`).
- `id == -1` means the **player** bumped this object.
- `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.
```rhai
fn bump(me, state, id) {
if id == -1 { say("Hey! Watch it."); }
else { log(`object ${id} bumped me`); }
fn bump(me, dir) {
if dir == West { say("Something shoved me from the west."); }
else { log(`bumped from ${dir}`); }
}
```
@@ -262,7 +265,7 @@ engine after the batch. The *subject* of a write is implicit and varies by funct
| Call | Subject | Cost | Description |
|------|---------|------|-------------|
| `move(dir)` | self | 0.25 s | Enqueue a one-cell move, plus a rate-limiting delay (~4/s). A blocked move still costs the cooldown. |
| `teleport(x, y)` | self | 0 | Jump to an arbitrary cell. Refused (error logged at resolve) if self is solid and the destination is occupied. |
| `teleport(id, x, y)` | arbitrary entity | 0 | Jump entity `id` to an arbitrary cell (pass `me.id` for self; `id == -1` is the player). Refused (error logged at resolve) if that entity is solid and the destination already holds a *different* solid. |
| `push(x, y, dir)` | arbitrary cell | 0 | Shove the pushable chain at `(x, y)` one step in `dir`. |
| `shift([[x, y], …])` | arbitrary cells | 0 | Rotate cell contents: each listed cell moves to the next coordinate, last→first. Skips non-pushables and won't move into a non-vacated occupied cell. |
| `set_tile(n)` | self | 0 | Set glyph tile index (CP437 code point in the default font). |
@@ -297,7 +300,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, state, id) {
fn bump(me, dir) {
scroll([
"The muffin looks delicious.",
"",
@@ -358,7 +361,7 @@ crash the game. A script that throws during `tick` logs the error and resumes on
## Full example
```rhai
// A guard that patrols east/west and greets the player when bumped.
// A guard that patrols east/west and greets whatever bumps into it.
fn init(me, state) {
set_tile(2);
@@ -371,9 +374,8 @@ fn tick(me, state, dt) {
else if !me.blocked(West) { move(West); }
}
fn bump(me, state, id) {
if id == -1 { say("Halt! Who goes there?"); now(); }
else { say("Watch it!"); now(); }
fn bump(me, dir) {
say(`Halt! Who approaches from the ${dir}?`); now();
}
```
@@ -402,7 +404,7 @@ they are shape problems.
delta" operations use *different* verbs: `alter_gems` vs `alter_health`.
- **The implicit subject of a write is unpredictable.** `set_tile`/`set_fg`/`say`/`die` act on
**self**; `alter_gems`/`alter_health`/`set_key` act on the **player**; `set_tag`/`send` act on an
**arbitrary target**; `push`/`shift`/`teleport` act on **cells**. The `set_*` prefix in particular
**arbitrary target**; `push`/`shift` act on **cells** and `teleport` on an **arbitrary entity**. The `set_*` prefix in particular
means three different subjects.
- **Reads are methods/getters on handles, but writes are free functions.** `me.blocked(dir)` reads,
but `move(dir)` (the matching write) is a bare global that acts on `me` implicitly — the symmetry is
+1 -1
View File
@@ -218,7 +218,7 @@ are zero-cost and may all resolve in the same frame.
| `move(dir)` | Step one cell in `dir`, shoving any pushable chain (including the player) ahead; a no-op if blocked. **Costs 0.25 s** before this object can move again. |
| `delay(secs)` | Insert an explicit pause in this object's queue (accepts int or float). Adjacent delays merge. |
| `now()` | Promote the most-recently-enqueued action to the **front** of the queue (jump the delay). |
| `teleport(x, y)` | Jump to an arbitrary cell. Zero cost. Refused (with a logged error at resolve time) if this object is solid and the destination already holds a solid. |
| `teleport(id, x, y)` | Jump entity `id` to an arbitrary cell (pass `me.id` for self; `id == -1` is the player). Zero cost. Refused (with a logged error at resolve time) if that entity is solid and the destination already holds a *different* solid. |
### Appearance
+5 -4
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). 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,10 +90,10 @@ 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(x, y)`, `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`.
- **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`.
- **Queue draining (two-tier):** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(board_queue, dt)` promotes them onto the shared **board queue** (`Vec<BoardAction>`): it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. `GameState` takes the board queue with `take_board_queue()` and applies it after the batch, so scripts mutate without a `&mut GameState` borrow. Errors bypass the queues via the error sink (`take_errors()`).
- **Constants in scope:** only `Registry` is pushed into each object's scope (the `me`/`state` views are hook *parameters* now). `register_global_constants` registers the `North`/`South`/`East`/`West` `Direction` values and the 16 named colors as a **global module**, visible at any call depth (including Rhai→Rhai calls and helper functions) — unlike scope constants such as `Registry`.
- `Registry` (a `Board`-handle wrapper) — `Registry.get(key)` / `set(key, value)` / `get_or(key, default)` read and write the board's `registry: HashMap<String, RegistryValue>`, which **persists across board transitions**. `set(key, ())` removes a key; unsupported value types are ignored; `get_or` only returns the stored value when it matches the default's Rhai type.
+5 -4
View File
@@ -99,10 +99,11 @@ pub enum Action {
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// selection dispatches [`Send`](Action::Send) to the source object.
Scroll(Vec<ScrollLine>),
/// Teleport the source object to `(x, y)`. Blocked (with a logged error) if
/// the source is solid and the destination already has a solid occupant.
/// Zero time cost — multiple teleports may fire in one frame.
Teleport { x: i64, y: i64 },
/// Teleport object `target` to `(x, y)` (`target == -1` is the player).
/// Blocked (with a logged error) if that entity is solid and the destination
/// already has a *different* solid occupant. Zero time cost — multiple
/// teleports may fire in one frame.
Teleport { target: i64, x: i64, y: i64 },
/// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on
/// arbitrary cells, not the source object). Zero time cost.
Push { x: i64, y: i64, dir: Direction },
+11
View File
@@ -123,6 +123,17 @@ builtins! {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false },
script: include_str!("scripts/spinner.rhai"),
},
// Solid, see-through (opaque: false), unpushable teleporters. Each direction's
// default glyph is the first frame of its animation loop (see transporter.rhai).
Transporter => [
"transporter_north" => g(94, 0x55, 0xFF, 0xFF), // '^'
"transporter_south" => g(118, 0x55, 0xFF, 0xFF), // 'v'
"transporter_east" => g(41, 0x55, 0xFF, 0xFF), // ')'
"transporter_west" => g(40, 0x55, 0xFF, 0xFF), // '('
] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::No, grab: false },
script: include_str!("scripts/transporter.rhai"),
},
Key => [ // TODO these should refer to the key colors in Keyring
"key_blue" => KeyType::Blue.glyph(),
"key_green" => KeyType::Green.glyph(),
+38
View File
@@ -252,6 +252,44 @@ 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 {
// Open space ends the walk with nothing bumped.
let solid = self.solid_at(cx, cy)?;
// A solid object — directly, or at the end of a crate chain — is the target.
if let Some(id) = solid.object_id() {
return Some(id);
}
// The player is never itself "bumped"; a wall (non-pushable terrain) stops
// the chain with no object behind it. Only a pushable crate is walked
// through — reuse the already-fetched `solid` rather than re-scanning.
if solid.player() || !solid.pushable().allows(dir) {
return None;
}
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;
}
}
/// 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.
+70 -30
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) => {
@@ -282,24 +284,56 @@ impl GameState {
choice: None,
});
}
Action::Teleport { x, y } => {
Action::Teleport { target, x, y } => {
if !board.in_bounds((x, y)) {
logs.push(LogLine::error(format!("teleport({x},{y}): out of bounds")));
} else {
logs.push(LogLine::error(format!(
"teleport({target},{x},{y}): out of bounds"
)));
} else if target == -1 {
// Move the player. A solid *other than the player itself*
// blocks the destination.
let (ux, uy) = (x as usize, y as usize);
let source_solid = board
.objects
.get(&ba.source)
.map(|o| o.solid)
.unwrap_or(false);
if source_solid && board.solid_at(ux, uy).is_some() {
let blocked = matches!(
board.solid_at(ux, uy),
Some(s) if !s.player()
);
if blocked {
logs.push(LogLine::error(format!(
"teleport({x},{y}): destination is solid"
"teleport(player,{x},{y}): destination is solid"
)));
} else if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.x = ux;
obj.y = uy;
} else {
board.player.x = ux as i64;
board.player.y = uy as i64;
}
} else if let Ok(tid) = ObjectId::try_from(target) {
// Move object `tid` to (x, y). A solid destination blocks
// a solid mover, unless the occupant is that same object.
let (ux, uy) = (x as usize, y as usize);
match board.objects.get(&tid) {
None => logs.push(LogLine::error(format!(
"teleport({target},{x},{y}): no such object"
))),
Some(obj) => {
let source_solid = obj.solid;
let blocked = source_solid
&& matches!(
board.solid_at(ux, uy),
Some(s) if s.object_id() != Some(tid)
);
if blocked {
logs.push(LogLine::error(format!(
"teleport({target},{x},{y}): destination is solid"
)));
} else if let Some(obj) = board.objects.get_mut(&tid) {
obj.x = ux;
obj.y = uy;
}
}
}
} else {
logs.push(LogLine::error(format!(
"teleport({target},{x},{y}): invalid target id"
)));
}
}
// push() self-checks can_push, so an in-bounds guard is all we add.
@@ -350,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);
@@ -430,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;
@@ -447,12 +482,14 @@ 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 = if grabbed.is_none() {
board.bump_target(nx, ny, dir)
} else {
None
};
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).
@@ -483,7 +520,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();
}
}
@@ -494,9 +532,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))?;
@@ -506,7 +545,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");
+32 -12
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.
//!
@@ -27,7 +27,7 @@
//!
//! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`,
//! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`,
//! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`,
//! `send(target_id, fn_name [, arg])`, `teleport(id, x, y)`, `push(x, y, dir)`,
//! `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`
use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST};
@@ -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();
@@ -554,12 +573,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) {
},
);
// teleport(x, y): move the calling object to an arbitrary cell. Zero time cost.
// Blocked (with an error logged at resolve time) if the object is solid and the
// destination already has a solid occupant.
// teleport(target, x, y): move object `target` to an arbitrary cell (pass your
// own `me.id` to move yourself; `target == -1` moves the player). Zero time
// cost. Blocked (with an error logged at resolve time) if that entity is solid
// and the destination already holds a different solid occupant.
let b = board.clone();
engine.register_fn("teleport", move |ctx: NativeCallContext, x: i64, y: i64| {
emit(&b, source_of(&ctx), Action::Teleport { x, y });
engine.register_fn("teleport", move |ctx: NativeCallContext, target: i64, x: i64, y: i64| {
emit(&b, source_of(&ctx), Action::Teleport { target, x, y });
});
// push(x, y, dir): shove the pushable chain at (x, y) one step in dir. Acts on
+112
View File
@@ -0,0 +1,112 @@
// Built-in script for the `transporter_*` archetypes (see archetype.rs).
//
// A transporter is a solid, see-through, unpushable machine that teleports
// whatever bumps into it from its facing side. It animates through a 4-frame
// loop (500 ms/frame) and, on a front bump, moves the bumper either to the cell
// just past it or — if that's blocked — out of the nearest opposite-facing
// transporter further along its axis (skipping any whose entrance is blocked).
//
// 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. 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) {
if me.has_tag("BUILTIN_transporter_north") { [0, -1] }
else if me.has_tag("BUILTIN_transporter_south") { [0, 1] }
else if me.has_tag("BUILTIN_transporter_east") { [1, 0] }
else { [-1, 0] }
}
// The tag of the opposite-facing transporter this one pairs with.
fn opposite_tag(me) {
if me.has_tag("BUILTIN_transporter_north") { "BUILTIN_transporter_south" }
else if me.has_tag("BUILTIN_transporter_south") { "BUILTIN_transporter_north" }
else if me.has_tag("BUILTIN_transporter_east") { "BUILTIN_transporter_west" }
else { "BUILTIN_transporter_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] } // ^ - ^ ~
else if me.has_tag("BUILTIN_transporter_south") { [118, 95, 118, 45] } // v _ v -
else if me.has_tag("BUILTIN_transporter_east") { [41, 124, 41, 62] } // ) | ) >
else { [40, 124, 40, 60] } // ( | ( <
}
fn tick(me, dt) {
// Advance one animation frame every 0.5s, like the spinner: frame state lives
// in the board Registry (script scope resets each tick), keyed per instance.
if me.waiting { return; }
let fs = frames(me);
let fkey = `xport_${me.id}`;
let f = Board.registry.get_or(fkey, 0);
set_tile(fs[f % 4]);
Board.registry.set(fkey, (f + 1) % 4);
me.delay(0.15);
}
// 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) {
let d = facing(me);
let dx = d[0];
let dy = d[1];
// Only transport things that hit us from the front — the entrance side (-facing).
// `dir` is the side the bump came from, so its offset must be the opposite of our
// facing; a bump from any other side does nothing.
if dir.dx != -dx || dir.dy != -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) {
transport(ex, ey, fx, fy);
return;
}
// 2. Otherwise scan along our axis for the nearest opposite-facing transporter
// 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;
loop {
cx += dx;
cy += dy;
if cx < 0 || cy < 0 || cx >= Board.width || cy >= Board.height {
return;
}
// Is one of the opposite transporters sitting at (cx, cy)?
let here = false;
for o in opp {
if o.x == cx && o.y == cy { here = true; }
}
if here {
// 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
}
+1
View File
@@ -6,6 +6,7 @@ mod portal_placement;
mod pushers;
mod round_trip;
mod spinners;
mod transporters;
use crate::board::Board;
use crate::map_file::MapFile;
@@ -0,0 +1,153 @@
//! Transporters are scripted objects (the `transporter_*` archetypes expand into
//! objects carrying the embedded `transporter.rhai` plus a
//! `BUILTIN_transporter_<dir>` tag). Bumping one from its facing side teleports
//! the bumper past it, or out of a paired opposite-facing transporter.
use super::{layer, load_board, map};
use crate::game::GameState;
use crate::map_file::MapFile;
use crate::utils::Direction;
use std::time::Duration;
#[test]
fn transporter_loads_as_a_tagged_scripted_solid_object() {
let board = load_board(&map(
3,
1,
&[layer(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
)],
));
let (_, obj) = board
.objects
.iter()
.find(|(_, o)| o.tags.contains("BUILTIN_transporter_east"))
.expect("transporter object");
assert_eq!((obj.x, obj.y), (1, 0));
assert!(obj.builtin_script.is_some(), "carries the embedded script");
assert!(obj.solid, "transporter is solid");
assert!(!obj.opaque, "transporter is see-through");
assert!(!obj.pushable, "transporter cannot be pushed");
}
#[test]
fn bumping_a_transporter_drops_you_on_its_far_side() {
// Player at (0,0), an east transporter at (1,0), and a free cell at (2,0).
let board = load_board(&map(
3,
1,
&[layer(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
)],
));
let mut game = GameState::new(board);
game.run_init();
// Walk east into the transporter: it's solid so the player doesn't step onto
// it, but the bump queues a teleport that the next tick applies.
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), (2, 0), "transported past the transporter");
}
#[test]
fn a_blocked_far_side_transports_out_of_the_paired_transporter() {
// 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,
&[layer(
"@T# W ",
&[
("@", "kind = \"player\""),
("T", "kind = \"transporter_east\""),
("#", "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\""),
("W", "kind = \"transporter_west\""),
],
)],
));
let mut game = GameState::new(board);
game.run_init();
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),
(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(
3,
1,
&[layer(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
)],
));
// Save collapses the expanded object back into the `transporter_east` keyword.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
assert!(
toml_out.contains("transporter_east"),
"transporter should save as its archetype keyword, got:\n{toml_out}"
);
// Reload: it is a working transporter object again.
let board2 = load_board(&toml_out);
assert!(
board2
.objects
.values()
.any(|o| o.tags.contains("BUILTIN_transporter_east") && o.builtin_script.is_some()),
"reloads as a tagged transporter object",
);
}
+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.
+1 -1
View File
@@ -22,7 +22,7 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact
**`kiln-tui/src/editor.rs`** — world editor mode:
- `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`).
- `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`/`Items`) — a sidebar menu level; (`Main``[i] Items``[t] ♦ Gem` / `[h] ♥ Heart`, which call `set_current(Archetype::Builtin(Builtin::Gem, "gem"))` / `set_current(Archetype::Builtin(Builtin::Heart, "heart"))` respectively); `title()` (breadcrumb segment) and `entries() -> Vec<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main``World`/`Floor`/`Terrain`/`Machines`; `Machines``Pushers`), **sets the drawing tool** (`Terrain`/`Pushers``set_current(arch)`, plus `Machines``[s]` CW / `[c]` CCW spinner, all using `Archetype::Builtin(Builtin::…, "alias")`), or **launches a playtest** (`Main``[p] Play board``playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor.
- `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`/`Transporters`/`Items`) — a sidebar menu level; (`Main``[i] Items``[t] ♦ Gem` / `[h] ♥ Heart`, which call `set_current(Archetype::Builtin(Builtin::Gem, "gem"))` / `set_current(Archetype::Builtin(Builtin::Heart, "heart"))` respectively); `title()` (breadcrumb segment) and `entries() -> Vec<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main``World`/`Floor`/`Terrain`/`Machines`; `Machines``Pushers`/`Transporters`), **sets the drawing tool** (`Terrain`/`Pushers`/`Transporters``set_current(arch)`, plus `Machines``[s]` CW / `[c]` CCW spinner, all using `Archetype::Builtin(Builtin::…, "alias")`), or **launches a playtest** (`Main``[p] Play board``playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor.
- Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, **expands script-backed archetypes** in every board via `Board::expand_builtin_archetypes` so editor-stamped spinners/pushers run, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs).
- Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`.
- `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel.
+20
View File
@@ -28,6 +28,8 @@ pub(crate) enum MenuLevel {
Machines,
/// Various directions of pushers
Pushers,
/// Various directions of transporters
Transporters,
/// Collectible items the player picks up (e.g. gems)
Items,
}
@@ -42,6 +44,7 @@ impl MenuLevel {
MenuLevel::Terrain => "Terrain",
MenuLevel::Machines => "Machines",
MenuLevel::Pushers => "Pushers",
MenuLevel::Transporters => "Transporters",
MenuLevel::Items => "Items",
}
}
@@ -81,6 +84,9 @@ impl MenuLevel {
],
MenuLevel::Machines => vec![
MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)),
MenuEntry::item('t', "Transporters...", |ed| {
ed.menu.push(MenuLevel::Transporters)
}),
MenuEntry::item('s', "/ CW spinner", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Spinner, "spinner_cw"))
}),
@@ -102,6 +108,20 @@ impl MenuLevel {
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west"))
}),
],
MenuLevel::Transporters => vec![
MenuEntry::item('n', "▲ Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_north"))
}),
MenuEntry::item('s', "▼ Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_south"))
}),
MenuEntry::item('e', "► Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_east"))
}),
MenuEntry::item('w', "◄ Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_west"))
}),
],
MenuLevel::Items => vec![
MenuEntry::glyph('t', Archetype::Builtin(Builtin::Gem, "gem").default_glyph(), "Gem", |ed| {
// 't' for 'treasure' — 'g' conflicts with glyph picker
+15 -12
View File
@@ -28,7 +28,7 @@ fn init(me) {
let new_x = Board.registry.get("dancer_x");
let new_y = Board.registry.get("dancer_y");
if me.x != new_x || me.y != new_y {
teleport(new_x, new_y);
teleport(me.id, new_x, new_y);
}
} else {
Board.registry.set("dancer_x", me.x);
@@ -56,16 +56,16 @@ fn dance(me) {
say("Ho!", 0.5);
me.delay(0.5);
}
// Fires when something steps into this object; id is the bumper's object id,
// or -1 for the player.
fn bump(me, id) {
log(`mover bumped by ${id}`);
// 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}`);
say("Ow!");
}
"""
muffin = """
fn bump(me, _id) {
fn bump(me, _dir) {
scroll([
"You find a small muffin on the ground, your favorite kind.",
"It smells of cinnamon and warm mornings.",
@@ -84,7 +84,7 @@ fn ignore() {
"""
noticeboard = """
fn bump(me, id) {
fn bump(me, _dir) {
scroll([
" TOWN NOTICE BOARD",
" ",
@@ -117,7 +117,7 @@ fn bump(me, id) {
"""
bookshelf = """
fn bump(me, id) {
fn bump(me, _dir) {
scroll([
" THE BOOKSHELF",
" ",
@@ -136,13 +136,13 @@ fn bump(me, id) {
"""
fireplace = """
fn bump(me, id) {
fn bump(me, _dir) {
say("The fire crackles warmly.\\nYou feel at ease.");
}
"""
chest = """
fn bump(me, id) {
fn bump(me, _dir) {
scroll([
" THE CHEST",
" ",
@@ -231,9 +231,9 @@ content = """
# oS #
# #
# 1 #
# ###### B #
# )######( B #
# # #
# +++ # #
# +++ # v #
# # #
# | #
# #
@@ -268,6 +268,9 @@ content = """
"S" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "shifter", script_name = "shifter" }
"/" = { kind = "spinner_cw" }
"t" = { kind = "gem" }
"v" = { kind = "transporter_south" }
")" = { kind = "transporter_east" }
"(" = { kind = "transporter_west" }
# Layer 2: a purely decorative, non-solid object drawn *above* the solid crate
# beneath it — only possible because draw order follows layer order.