From 6464e477479746192b054e00b8e9137bbc482229 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Tue, 16 Jun 2026 14:34:42 -0500 Subject: [PATCH] builtin scripts --- CLAUDE.md | 13 ++- kiln-core/src/archetype.rs | 8 +- kiln-core/src/board.rs | 47 --------- kiln-core/src/builtin_scripts.rs | 46 +++++++++ kiln-core/src/game.rs | 26 ----- kiln-core/src/layer.rs | 21 +++- kiln-core/src/lib.rs | 1 + kiln-core/src/map_file.rs | 14 +++ kiln-core/src/object_def.rs | 6 ++ kiln-core/src/script.rs | 97 ++++++++++------- kiln-core/src/scripts/pusher.rhai | 24 +++++ kiln-core/src/tests/map_file/mod.rs | 1 + kiln-core/src/tests/map_file/pushers.rs | 132 ++++++++++++++++++++++++ 13 files changed, 317 insertions(+), 119 deletions(-) create mode 100644 kiln-core/src/builtin_scripts.rs create mode 100644 kiln-core/src/scripts/pusher.rhai create mode 100644 kiln-core/src/tests/map_file/pushers.rs diff --git a/CLAUDE.md b/CLAUDE.md index 1dff940..1333306 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,11 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `Glyph` (`Copy`, `Eq`, `Hash`) — `tile: u32` (tilesheet index), `fg/bg: Rgba8`. `Glyph::player()` is a `const fn` (tile 64 `@`, white on dark blue); all other glyphs come from map files. `Hash` is hand-implemented via packed `u32` representations so it stays in sync with `Eq`. **`kiln-core/src/archetype.rs`** — element taxonomy: -- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `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>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`). +- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Pusher(Direction)`, `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. `Pusher(dir)` (`pusher_north|south|east|west`) is a **map-file keyword only**: at load it expands into a scripted object (see [`builtin_scripts`]), never a terrain cell. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`). + +**`kiln-core/src/builtin_scripts.rs`** — archetypes that are really scripted objects (`pub(crate)`): +- `archetype_script(arch) -> Option<&'static str>` — the dispatch (one arm per script-backed archetype) returning the embedded Rhai source (`include_str!`); currently `Pusher(_) -> scripts/pusher.rhai`. When `resolve_entry` (in `layer.rs`) hits such an archetype it emits an `ObjectDef` carrying that source in `builtin_script` plus a `BUILTIN_` tag instead of a terrain cell. +- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option` — the tag convention. The script reads its tag for per-instance params (the pusher reads it for its direction, since `Me` isn't visible inside Rhai helper functions); `map_file::save` uses it to collapse the object back into its archetype keyword so maps round-trip. `scripts/pusher.rhai` self-propels via `move(dir)` (which already shoves chains via `step_object`/`push`), pacing itself with `delay` to the old ~0.5 s cadence. **`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`): - `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`. @@ -63,7 +67,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired. **`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`): -- `ObjectDef` — a scripted tile: `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option`, `tags: HashSet`, `name: Option`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black. +- `ObjectDef` — a scripted tile: `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; takes precedence over `script_name`; not serialized — see [`builtin_scripts`]), `tags: HashSet`, `name: Option`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black. **`kiln-core/src/layer.rs`** — palette layers (the map-file/draw-stack unit): - `Layer { cells: Vec<(Glyph, Archetype)> }` (`pub(crate)`) — one row-major draw layer. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the layer beneath shows through; solidity comes from the archetype, independent of transparency. @@ -78,8 +82,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `solid_at(x, y) -> Option` — 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? -- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). Crates/pushers move within their own layer (found via `solid_cell_layer`). -- `pusher_cells() -> Vec<(usize, usize)>` — coordinates of every `Pusher` terrain cell across all layers (used by the pusher heartbeat). +- `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`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object queries. - `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord. - `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface. @@ -104,7 +107,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `LogSpan { text, fg: Option, bg: Option }` and `LogLine { spans: Vec }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a `LogLine` to its own styled text at render time. **`kiln-core/src/script.rs`** — Rhai scripting runtime: -- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool (scripts are compiled from here, not from the board itself). Reports compile/unknown-script failures onto the error sink; runs nothing. +- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects, a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: a named script keys by its pool name; an object's embedded `builtin_script` keys by its source text (so identical built-ins, e.g. all pushers, share one AST). Reports compile/unknown-script failures onto the error sink; runs nothing. - Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), and `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)`; runtime errors go to the error sink (drained to the log), not fatal. - **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`. - **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`; `MOVE_COST = 0.25` s for `Move`, else 0) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`). diff --git a/kiln-core/src/archetype.rs b/kiln-core/src/archetype.rs index 41aeeeb..6f1c29a 100644 --- a/kiln-core/src/archetype.rs +++ b/kiln-core/src/archetype.rs @@ -32,9 +32,11 @@ pub enum Archetype { HCrate, /// A crate that can only be pushed north/south (vertical axis). Glyph ↕. VCrate, - /// A directional pusher — solid, opaque, non-pushable. Driven by a global - /// 500 ms heartbeat in [`GameState::tick`]: each fire shoves the occupant of - /// the adjacent cell in the facing direction one step. + /// A directional pusher — solid, opaque, non-pushable. This is a map-file + /// *keyword* only: at load it expands into a scripted [`ObjectDef`] carrying the + /// embedded `pusher.rhai` and a `BUILTIN_pusher_` tag (see + /// [`crate::builtin_scripts`]). The script self-propels it one cell at a time in + /// the facing direction, shoving any pushable chain ahead — no engine special-case. Pusher(Direction), /// Sentinel for map files that reference an unknown archetype name. /// Renders as a yellow `?` on red to make the error visible in-game. diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 4554d7c..a6f773e 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -199,21 +199,6 @@ impl Board { (0..self.layers.len()).find(|&z| self.get(z, x, y).1.behavior().solid) } - /// Returns the coordinates of every [`Archetype::Pusher`] terrain cell on the - /// board (scanning all layers). Used by the pusher heartbeat in - /// [`GameState::tick`](crate::game::GameState::tick). - pub fn pusher_cells(&self) -> Vec<(usize, usize)> { - let mut cells = Vec::new(); - for y in 0..self.height { - for x in 0..self.width { - if matches!(self.solid_at(x, y), Some(Solid::Cell(Archetype::Pusher(_)))) { - cells.push((x, y)); - } - } - } - cells - } - /// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it. /// /// Convenience inverse of [`solid_at`](Board::solid_at). @@ -290,38 +275,6 @@ impl Board { } } - /// Advances a [`Archetype::Pusher`] cell at `(x, y)` one step in its facing - /// direction. Reads the direction from the cell itself. - /// - /// - If the target is passable: slides the pusher in. - /// - If the target has a pushable chain: pushes it first, then slides in. - /// - If blocked or out of bounds: no-op, returns `false`. - pub(crate) fn advance_pusher(&mut self, x: usize, y: usize) -> bool { - let Some(z) = self.solid_cell_layer(x, y) else { - return false; - }; - let Archetype::Pusher(dir) = self.get(z, x, y).1 else { - return false; - }; - let (dx, dy): (i32, i32) = dir.into(); - let tx = x as i32 + dx; - let ty = y as i32 + dy; - if !self.in_bounds((tx, ty)) { - return false; - } - let (tx, ty) = (tx as usize, ty as usize); - if self.is_passable(tx, ty) { - self.shift_solid(x, y, dx, dy); - true - } else if self.can_push(tx, ty, dir) { - self.push(tx, ty, dir); // clear the chain first - self.shift_solid(x, y, dx, dy); - true - } else { - false - } - } - /// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`. /// /// A solid object is relocated (keeping its layer); otherwise the solid diff --git a/kiln-core/src/builtin_scripts.rs b/kiln-core/src/builtin_scripts.rs new file mode 100644 index 0000000..5702969 --- /dev/null +++ b/kiln-core/src/builtin_scripts.rs @@ -0,0 +1,46 @@ +//! Built-in scripts: archetypes that are really scripted objects. +//! +//! Some archetypes (currently the `pusher_*` family) have behavior that an +//! ordinary scripted object can express exactly. Rather than special-case them in +//! the engine, the map loader *expands* such an archetype into an [`ObjectDef`] +//! carrying an embedded Rhai script (see [`ObjectDef::builtin_script`]) plus a +//! `BUILTIN_` tag. The script reads that tag for any per-instance +//! parameter (the pusher reads it for its direction), and [`crate::map_file`]'s +//! save path uses the same tag to collapse the object back into its archetype +//! keyword so maps round-trip. +//! +//! This module is the single place to register a new script-backed archetype: +//! add an arm to [`archetype_script`]. + +use crate::archetype::Archetype; + +/// Prefix for the tag that marks an object as an expanded built-in archetype and +/// names which archetype it came from (e.g. `"BUILTIN_pusher_east"`). +pub(crate) const BUILTIN_TAG_PREFIX: &str = "BUILTIN_"; + +/// The pusher behavior, embedded into the binary. Shared by every direction — +/// direction comes from the object's tag, so all pushers share one compiled AST. +const PUSHER: &str = include_str!("scripts/pusher.rhai"); + +/// The tag identifying an object as the expanded form of `arch`, e.g. +/// `"BUILTIN_pusher_east"`. +pub(crate) fn builtin_tag(arch: Archetype) -> String { + format!("{BUILTIN_TAG_PREFIX}{}", arch.name()) +} + +/// Recovers the archetype a `BUILTIN_*` tag came from, or `None` if `tag` is not a +/// built-in tag naming a known archetype. Used by the save path to round-trip. +pub(crate) fn archetype_from_builtin_tag(tag: &str) -> Option { + let name = tag.strip_prefix(BUILTIN_TAG_PREFIX)?; + Archetype::try_from(name).ok() +} + +/// Returns the embedded script implementing `arch` as an object, or `None` if the +/// archetype is a plain terrain cell. The one place script-backed archetypes are +/// declared. +pub(crate) fn archetype_script(arch: Archetype) -> Option<&'static str> { + match arch { + Archetype::Pusher(_) => Some(PUSHER), + _ => None, + } +} diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index a2038a2..faf28ed 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -10,9 +10,6 @@ use std::time::Duration; /// How long a `say()` speech bubble stays on screen, in seconds. pub const SAY_DURATION: f64 = 3.0; -/// How often pusher archetypes fire, in seconds. -const PUSHER_INTERVAL: f64 = 0.5; - // Re-export ScrollLine so kiln-tui can pattern-match scroll content without // accessing the private `action` module directly. pub use crate::action::ScrollLine; @@ -69,9 +66,6 @@ pub struct GameState { /// by [`enter_board`](GameState::enter_board). Front-ends tick this down and /// may block input or show a visual effect while it is `Some(t)` where `t > 0`. pub board_transition: Option, - /// Countdown to the next global pusher heartbeat. When it reaches zero all - /// [`Archetype::Pusher`] cells fire and the timer resets to [`PUSHER_INTERVAL`]. - pusher_timer: f64, } impl GameState { @@ -98,7 +92,6 @@ impl GameState { speech_bubbles: Vec::new(), active_scroll: None, board_transition: None, - pusher_timer: PUSHER_INTERVAL, }; state.drain_errors(); state @@ -183,28 +176,9 @@ impl GameState { b.remaining -= secs; b.remaining > 0.0 }); - self.tick_pushers(secs); self.resolve(); } - /// Fires the global pusher heartbeat: every [`PUSHER_INTERVAL`] seconds each - /// [`Archetype::Pusher`] cell shoves the occupant directly in front of it. - /// - /// Two-phase to avoid holding the board borrow while calling `push()` (which - /// needs `&mut Board`): phase A collects target coordinates, phase B applies them. - fn tick_pushers(&mut self, secs: f64) { - self.pusher_timer -= secs; - if self.pusher_timer > 0.0 { - return; - } - self.pusher_timer += PUSHER_INTERVAL; - let pushers: Vec<(usize, usize)> = self.board().pusher_cells(); - let mut board = self.board_mut(); - for (x, y) in pushers { - board.advance_pusher(x, y); - } - } - /// Drains errors collected by the script host into the game log. fn drain_errors(&mut self) { // TODO: errors are only logged for now. This is the place to halt execution / diff --git a/kiln-core/src/layer.rs b/kiln-core/src/layer.rs index 4e609ed..4c89af2 100644 --- a/kiln-core/src/layer.rs +++ b/kiln-core/src/layer.rs @@ -143,6 +143,7 @@ pub(crate) struct ObjectTemplate { pub opaque: bool, pub pushable: bool, pub script_name: Option, + pub builtin_script: Option<&'static str>, pub tags: Vec, pub name: Option, } @@ -212,6 +213,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec) -> Resol opaque: e.opaque.unwrap_or(true), pushable: e.pushable.unwrap_or(false), script_name: e.script_name.clone(), + builtin_script: None, tags: e.tags.clone().unwrap_or_default(), name: e.name.clone(), }), @@ -233,7 +235,24 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec) -> Resol "player" => Resolved::Player, // Any other kind must be an archetype name. other => match Archetype::try_from(other) { - Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a), + // Script-backed archetypes (e.g. pushers) expand into an object carrying + // the embedded script plus a `BUILTIN_` tag the script reads. + Ok(a) => match crate::builtin_scripts::archetype_script(a) { + Some(src) => { + let b = a.behavior(); + Resolved::Object(ObjectTemplate { + glyph: glyph_with_default(a.default_glyph()), + solid: b.solid, + opaque: b.opaque, + pushable: false, + script_name: None, + builtin_script: Some(src), + tags: vec![crate::builtin_scripts::builtin_tag(a)], + name: None, + }) + } + None => Resolved::Cell(glyph_with_default(a.default_glyph()), a), + }, Err(msg) => { errors.push(LogLine::error(format!( "palette '{ch}': {msg}; using error block" diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index 9de9469..66d0e13 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -1,6 +1,7 @@ mod action; mod archetype; mod board; +mod builtin_scripts; /// Procedural floor generators ([`floor::FloorGenerator`]). pub mod floor; /// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc. diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 2137a6f..7755d83 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -20,6 +20,7 @@ use tinyrand::{Seeded, StdRand}; use crate::archetype::Archetype; use crate::board::Board; +use crate::builtin_scripts::archetype_from_builtin_tag; use crate::floor::FLOOR_SEED; use crate::glyph::Glyph; use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer}; @@ -237,6 +238,7 @@ impl TryFrom for Board { opaque: t.opaque, pushable: t.pushable, script_name: t.script_name, + builtin_script: t.builtin_script, tags: t.tags.into_iter().collect(), name, }, @@ -350,6 +352,18 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData { // Objects on this layer overwrite their (transparent) cell with an object char. for o in board.objects.values().filter(|o| o.z == z) { + // A built-in archetype object (e.g. a pusher) round-trips back to its + // archetype keyword: emit it as a terrain cell (deduped with real terrain), + // using the object's current glyph, rather than a `kind = "object"` entry. + if let Some(arch) = o.tags.iter().find_map(|t| archetype_from_builtin_tag(t)) { + let ch = *cell_to_key.entry((o.glyph, arch)).or_insert_with(|| { + let ch = pool.next().expect("ran out of palette characters"); + palette.insert(ch.to_string(), cell_entry(o.glyph, arch)); + ch + }); + grid[o.y][o.x] = ch; + continue; + } let ch = pool.next().expect("ran out of palette characters"); let mut tags: Vec = o.tags.iter().cloned().collect(); tags.sort(); diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs index 0360a99..0548d53 100644 --- a/kiln-core/src/object_def.rs +++ b/kiln-core/src/object_def.rs @@ -51,6 +51,11 @@ pub struct ObjectDef { /// Name of the Rhai script in [`Board::scripts`] that drives this object. /// `None` means this object has no script yet. pub script_name: Option, + /// Embedded built-in script source, set when a script-backed archetype (e.g. a + /// `pusher_*`) is expanded into an object at load time (see + /// [`crate::builtin_scripts`]). Takes precedence over [`script_name`](ObjectDef::script_name) + /// and is not part of the map file (it is regenerated from the archetype on load). + pub builtin_script: Option<&'static str>, /// Open-ended string labels for this object. Serialized as a TOML array; /// not subject to any rate limit — mutations take effect immediately after /// the frame's action queue is drained. @@ -88,6 +93,7 @@ impl ObjectDef { opaque: true, pushable: false, script_name: None, + builtin_script: None, tags: HashSet::new(), name: None, } diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index f557f8d..fba068c 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -58,6 +58,7 @@ use crate::board::Board; use crate::game::SAY_DURATION; use crate::log::LogLine; use crate::map_file::{color_to_hex, parse_color}; +use crate::object_def::ObjectDef; use crate::utils::{Direction, ObjectId, RegistryValue, ScriptArg}; use rhai::{ AST, Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module, @@ -102,11 +103,24 @@ struct CompiledScript { /// The runtime state of one scripted object. struct ObjectRuntime { object_id: ObjectId, - script_name: String, + /// Key into [`ScriptHost::scripts`] for this object's compiled script — the + /// pool name for a named script, or the source text for an embedded built-in. + script_key: String, scope: Scope<'static>, output_queue: ObjQueue, } +/// The compile-key for an object's script: the embedded source itself for a +/// built-in (so identical built-ins share one compiled AST), or the world-pool +/// name for a named script. `None` if the object has no script. +fn script_key(obj: &ObjectDef) -> Option { + if let Some(src) = obj.builtin_script { + Some(src.to_string()) + } else { + obj.script_name.clone() + } +} + // ── Rhai types exposed to scripts ──────────────────────────────────────────── /// A snapshot of one board object, returned by `Board.tagged`, `Board.named`, @@ -205,44 +219,53 @@ impl ScriptHost { let board = board_cell.borrow(); - // Compile each referenced script once. + // Compile each referenced script once, keyed by `script_key` (pool name for + // named scripts, source text for embedded built-ins so identical ones share + // one AST). let mut scripts: HashMap = HashMap::new(); let mut failed: HashSet = HashSet::new(); for obj in board.objects.values() { - let Some(name) = obj.script_name.as_ref() else { + let Some(key) = script_key(obj) else { continue; }; - if scripts.contains_key(name) || failed.contains(name) { + if scripts.contains_key(&key) || failed.contains(&key) { continue; } - match script_sources.get(name) { - Some(src) => match engine.compile(src) { - Ok(ast) => { - let defines = |n: &str, params: usize| { - ast.iter_functions() - .any(|f| f.name == n && f.params.len() == params) - }; - scripts.insert( - name.clone(), - CompiledScript { - has_init: defines("init", 0), - has_tick: defines("tick", 1), - has_bump: defines("bump", 1), - ast, - }, - ); - } - Err(err) => { - failed.insert(name.clone()); + // Source: the embedded built-in, or a lookup in the world script pool. + let source: &str = if let Some(src) = obj.builtin_script { + src + } else { + match script_sources.get(&key) { + Some(src) => src, + None => { + failed.insert(key.clone()); errors.borrow_mut().push(LogLine::error(format!( - "script '{name}' failed to compile: {err}" + "object references unknown script '{key}'" ))); + continue; } - }, - None => { - failed.insert(name.clone()); + } + }; + match engine.compile(source) { + Ok(ast) => { + let defines = |n: &str, params: usize| { + ast.iter_functions() + .any(|f| f.name == n && f.params.len() == params) + }; + scripts.insert( + key, + CompiledScript { + has_init: defines("init", 0), + has_tick: defines("tick", 1), + has_bump: defines("bump", 1), + ast, + }, + ); + } + Err(err) => { + failed.insert(key.clone()); errors.borrow_mut().push(LogLine::error(format!( - "object references unknown script '{name}'" + "script '{key}' failed to compile: {err}" ))); } } @@ -251,17 +274,17 @@ impl ScriptHost { // One runtime per object whose script compiled. let mut objects = Vec::new(); for (&id, obj) in board.objects.iter() { - let Some(name) = obj.script_name.as_ref() else { + let Some(key) = script_key(obj) else { continue; }; - if !scripts.contains_key(name) { + if !scripts.contains_key(&key) { continue; } let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new())); queues.borrow_mut().insert(id, queue.clone()); objects.push(ObjectRuntime { object_id: id, - script_name: name.clone(), + script_key: key, scope: new_object_scope(board_cell, &queue, id), output_queue: queue, }); @@ -302,7 +325,7 @@ impl ScriptHost { .. } = self; let obj = &mut objects[i]; - let Some(compiled) = scripts.get(&obj.script_name) else { + let Some(compiled) = scripts.get(&obj.script_key) else { return; }; if !compiled.has_bump { @@ -318,7 +341,7 @@ impl ScriptHost { ) { errors.borrow_mut().push(LogLine::error(format!( "script '{}' bump error: {err}", - obj.script_name + obj.script_key ))); } } @@ -344,7 +367,7 @@ impl ScriptHost { .. } = self; let obj = &mut objects[i]; - let Some(compiled) = scripts.get(&obj.script_name) else { + let Some(compiled) = scripts.get(&obj.script_key) else { return; }; @@ -387,7 +410,7 @@ impl ScriptHost { if let Err(err) = result { errors.borrow_mut().push(LogLine::error(format!( "script '{}' send('{}') error: {err}", - obj.script_name, fn_name + obj.script_key, fn_name ))); } } @@ -456,7 +479,7 @@ impl ScriptHost { .. } = self; let obj = &mut objects[i]; - if let Some(compiled) = scripts.get(&obj.script_name) + if let Some(compiled) = scripts.get(&obj.script_key) && defined(compiled) { let options = CallFnOptions::default().with_tag(obj.object_id as i64); @@ -469,7 +492,7 @@ impl ScriptHost { ) { errors.borrow_mut().push(LogLine::error(format!( "script '{}' {hook} error: {err}", - obj.script_name + obj.script_key ))); } } diff --git a/kiln-core/src/scripts/pusher.rhai b/kiln-core/src/scripts/pusher.rhai new file mode 100644 index 0000000..9824db5 --- /dev/null +++ b/kiln-core/src/scripts/pusher.rhai @@ -0,0 +1,24 @@ +// Built-in script for the `pusher_*` archetypes (see kiln-core/src/builtin_scripts.rs). +// +// A pusher is a self-propelled solid that advances one cell in its facing +// direction every ~0.5s, shoving any pushable chain (including the player) ahead +// and stopping when blocked. The direction is not baked into this source — every +// pusher shares one compiled copy and learns its direction from the +// `BUILTIN_pusher_` tag the map loader attached to it. + +fn tick(dt) { + // Only queue a step when idle. `move` shoves the chain ahead via step_object + // and is a no-op when blocked; the delay pads each step out to ~0.5s (move + // itself costs 0.25s), matching the old global pusher heartbeat. + // + // The direction is read here, at the top level of the hook, because `Me` is a + // per-object scope constant and is not visible inside other script functions. + if Queue.length() == 0 { + let dir = if Me.has_tag("BUILTIN_pusher_north") { North } + else if Me.has_tag("BUILTIN_pusher_south") { South } + else if Me.has_tag("BUILTIN_pusher_east") { East } + else { West }; + move(dir); + delay(0.25); + } +} diff --git a/kiln-core/src/tests/map_file/mod.rs b/kiln-core/src/tests/map_file/mod.rs index af02426..7483773 100644 --- a/kiln-core/src/tests/map_file/mod.rs +++ b/kiln-core/src/tests/map_file/mod.rs @@ -3,6 +3,7 @@ mod grid_errors; mod object_placement; mod player_placement; mod portal_placement; +mod pushers; mod round_trip; use crate::board::Board; diff --git a/kiln-core/src/tests/map_file/pushers.rs b/kiln-core/src/tests/map_file/pushers.rs new file mode 100644 index 0000000..3546648 --- /dev/null +++ b/kiln-core/src/tests/map_file/pushers.rs @@ -0,0 +1,132 @@ +//! Pushers are now scripted objects (the `pusher_*` archetypes expand into objects +//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_` tag). + +use super::{layer, load_board, map}; +use crate::archetype::Archetype; +use crate::game::GameState; +use crate::map_file::MapFile; +use crate::object_def::ObjectDef; +use std::time::Duration; + +/// Finds the pusher object on a board (by its built-in tag). +fn pusher<'a>(board: &'a crate::board::Board, id: &mut u32) -> &'a ObjectDef { + let (&oid, obj) = board + .objects + .iter() + .find(|(_, o)| o.tags.contains("BUILTIN_pusher_east")) + .expect("pusher object"); + *id = oid; + obj +} + +#[test] +fn pusher_loads_as_a_tagged_scripted_solid_object() { + let board = load_board(&map( + 3, + 1, + &[layer( + "P @", + &[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")], + )], + )); + let mut id = 0; + let p = pusher(&board, &mut id); + assert_eq!((p.x, p.y), (0, 0)); + assert!( + p.builtin_script.is_some(), + "carries the embedded pusher script" + ); + assert!(p.script_name.is_none()); + assert!(!board.is_passable(0, 0), "pusher is solid"); +} + +#[test] +fn pusher_advances_and_shoves_a_crate() { + // Pusher at (0,0), crate at (1,0), the player parked at the east edge. + let board = load_board(&map( + 5, + 1, + &[layer( + "Po @", + &[ + ("P", "kind = \"pusher_east\""), + ("o", "kind = \"crate\""), + ("@", "kind = \"player\""), + ], + )], + )); + let mut pid = 0; + pusher(&board, &mut pid); + + let mut game = GameState::new(board); + game.run_init(); + for _ in 0..12 { + game.tick(Duration::from_secs_f64(0.3)); + } + + let b = game.board(); + assert!(b.objects[&pid].x > 0, "pusher advanced east"); + assert_eq!( + b.get(0, 1, 0).1, + Archetype::Empty, + "crate left its start cell" + ); + assert!( + (2..b.width).any(|x| b.get(0, x, 0).1 == Archetype::Crate), + "crate was shoved east" + ); +} + +#[test] +fn pusher_blocked_by_wall_stays_put() { + let board = load_board(&map( + 4, + 1, + &[layer( + "P# @", + &[ + ("P", "kind = \"pusher_east\""), + ("#", "kind = \"wall\""), + ("@", "kind = \"player\""), + ], + )], + )); + let mut pid = 0; + pusher(&board, &mut pid); + + let mut game = GameState::new(board); + game.run_init(); + for _ in 0..12 { + game.tick(Duration::from_secs_f64(0.3)); + } + assert_eq!( + game.board().objects[&pid].x, + 0, + "a wall-blocked pusher does not move" + ); +} + +#[test] +fn pusher_round_trips_to_its_keyword() { + let board = load_board(&map( + 3, + 1, + &[layer( + "P @", + &[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")], + )], + )); + // Save collapses the expanded object back into the `pusher_east` keyword. + let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); + assert!( + toml_out.contains("pusher_east"), + "pusher should save as its archetype keyword, got:\n{toml_out}" + ); + + // Reload: it is a working pusher object again. + let board2 = load_board(&toml_out); + let mut id = 0; + let p = pusher(&board2, &mut id); + assert_eq!((p.x, p.y), (0, 0)); + assert!(p.builtin_script.is_some()); +}