From f407b5d9a6aa261e6dbfe3a8903783d025fee0b2 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Wed, 8 Jul 2026 10:06:25 -0500 Subject: [PATCH] transporters --- CLAUDE.md | 4 +- docs/script-api.md | 4 +- kiln-api.md | 2 +- kiln-core/CLAUDE.md | 4 +- kiln-core/src/action.rs | 9 +- kiln-core/src/archetype.rs | 11 ++ kiln-core/src/game.rs | 58 +++++++--- kiln-core/src/script.rs | 13 ++- kiln-core/src/scripts/transporter.rhai | 105 +++++++++++++++++ kiln-core/src/tests/map_file/mod.rs | 1 + kiln-core/src/tests/map_file/transporters.rs | 115 +++++++++++++++++++ kiln-tui/CLAUDE.md | 2 +- kiln-tui/src/editor_menu.rs | 20 ++++ maps/start.toml | 9 +- 14 files changed, 323 insertions(+), 34 deletions(-) create mode 100644 kiln-core/src/scripts/transporter.rhai create mode 100644 kiln-core/src/tests/map_file/transporters.rs diff --git a/CLAUDE.md b/CLAUDE.md index 25b7787..efc223c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, 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(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`. The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md). **Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(me.id, "visited", true)`) or the per-board `Registry` (which also persists across transitions). diff --git a/docs/script-api.md b/docs/script-api.md index 73f1738..18c9b3e 100644 --- a/docs/script-api.md +++ b/docs/script-api.md @@ -262,7 +262,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). | @@ -402,7 +402,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 diff --git a/kiln-api.md b/kiln-api.md index 3ad785a..8435a2d 100644 --- a/kiln-api.md +++ b/kiln-api.md @@ -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 diff --git a/kiln-core/CLAUDE.md b/kiln-core/CLAUDE.md index b878ea9..f7f16e2 100644 --- a/kiln-core/CLAUDE.md +++ b/kiln-core/CLAUDE.md @@ -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_` 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/.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_")` 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/.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_")` 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` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_` (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. @@ -92,7 +92,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - 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. - `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`): 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`, 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. diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index 49ea3ea..b619412 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -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), - /// 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 }, diff --git a/kiln-core/src/archetype.rs b/kiln-core/src/archetype.rs index becd5f2..cb472af 100644 --- a/kiln-core/src/archetype.rs +++ b/kiln-core/src/archetype.rs @@ -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(), diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index f69bb15..140b726 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -282,24 +282,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. diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 2b6c27d..28cd73a 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -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}; @@ -554,12 +554,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 diff --git a/kiln-core/src/scripts/transporter.rhai b/kiln-core/src/scripts/transporter.rhai new file mode 100644 index 0000000..19cca17 --- /dev/null +++ b/kiln-core/src/scripts/transporter.rhai @@ -0,0 +1,105 @@ +// 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_` tag. The world is +// reached through the global `Board`/`Player` constants; `teleport(id, x, y)` +// moves an arbitrary entity (`-1` is the player). + +// 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); +} + +fn bump(me, id) { + 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; } + + // 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(); + 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. + 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 { + let ex = cx + dx; + let ey = cy + dy; + if Board.passable(ex, ey) { + teleport(id, ex, ey); now(); + return; + } + } + } +} diff --git a/kiln-core/src/tests/map_file/mod.rs b/kiln-core/src/tests/map_file/mod.rs index c198ac8..e7fc4da 100644 --- a/kiln-core/src/tests/map_file/mod.rs +++ b/kiln-core/src/tests/map_file/mod.rs @@ -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; diff --git a/kiln-core/src/tests/map_file/transporters.rs b/kiln-core/src/tests/map_file/transporters.rs new file mode 100644 index 0000000..fd989f1 --- /dev/null +++ b/kiln-core/src/tests/map_file/transporters.rs @@ -0,0 +1,115 @@ +//! Transporters are scripted objects (the `transporter_*` archetypes expand into +//! objects carrying the embedded `transporter.rhai` plus a +//! `BUILTIN_transporter_` 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, 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. + 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), + (3, 0), + "transported to the paired transporter's entrance", + ); +} + +#[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", + ); +} diff --git a/kiln-tui/CLAUDE.md b/kiln-tui/CLAUDE.md index f6201b0..e757439 100644 --- a/kiln-tui/CLAUDE.md +++ b/kiln-tui/CLAUDE.md @@ -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` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option>`, `code_editor: Option` (the open script editor; replaces the board view), `glyph_dialog: Option>` (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` (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` (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` (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. diff --git a/kiln-tui/src/editor_menu.rs b/kiln-tui/src/editor_menu.rs index b3f23f4..37b23cd 100644 --- a/kiln-tui/src/editor_menu.rs +++ b/kiln-tui/src/editor_menu.rs @@ -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 diff --git a/maps/start.toml b/maps/start.toml index f82ea2a..1cff463 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -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); @@ -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.