builtin scripts

This commit is contained in:
2026-06-16 14:34:42 -05:00
parent 7608fc959f
commit 6464e47747
13 changed files with 317 additions and 119 deletions
+8 -5
View File
@@ -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`. - `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: **`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_<archetype>` tag instead of a terrain cell.
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — 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)`): **`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`. - `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. - `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)`): **`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<String>`, `tags: HashSet<String>`, `name: Option<String>`. 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<String>`, `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<String>`, `name: Option<String>`. 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): **`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. - `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<Solid>` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`). - `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`. - `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? - `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`). - `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`).
- `pusher_cells() -> Vec<(usize, usize)>` — coordinates of every `Pusher` terrain cell across all layers (used by the pusher heartbeat).
- `add_object(obj) -> ObjectId`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object queries. - `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. - `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface. - `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<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — 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. - `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — 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: **`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<RefCell<Board>>, &HashMap<String, String>)`: 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<RefCell<Board>>, &HashMap<String, String>)`: 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. - 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<RefCell<Board>>` (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`. - **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (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<usize, ObjQueue>`). `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<BoardAction { source, action }>`): 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()`). - **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<usize, ObjQueue>`). `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<BoardAction { source, action }>`): 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()`).
+5 -3
View File
@@ -32,9 +32,11 @@ pub enum Archetype {
HCrate, HCrate,
/// A crate that can only be pushed north/south (vertical axis). Glyph ↕. /// A crate that can only be pushed north/south (vertical axis). Glyph ↕.
VCrate, VCrate,
/// A directional pusher — solid, opaque, non-pushable. Driven by a global /// A directional pusher — solid, opaque, non-pushable. This is a map-file
/// 500 ms heartbeat in [`GameState::tick`]: each fire shoves the occupant of /// *keyword* only: at load it expands into a scripted [`ObjectDef`] carrying the
/// the adjacent cell in the facing direction one step. /// embedded `pusher.rhai` and a `BUILTIN_pusher_<dir>` 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), Pusher(Direction),
/// Sentinel for map files that reference an unknown archetype name. /// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game. /// Renders as a yellow `?` on red to make the error visible in-game.
-47
View File
@@ -199,21 +199,6 @@ impl Board {
(0..self.layers.len()).find(|&z| self.get(z, x, y).1.behavior().solid) (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. /// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it.
/// ///
/// Convenience inverse of [`solid_at`](Board::solid_at). /// 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)`. /// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
/// ///
/// A solid object is relocated (keeping its layer); otherwise the solid /// A solid object is relocated (keeping its layer); otherwise the solid
+46
View File
@@ -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_<archetype>` 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<Archetype> {
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,
}
}
-26
View File
@@ -10,9 +10,6 @@ use std::time::Duration;
/// How long a `say()` speech bubble stays on screen, in seconds. /// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0; 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 // Re-export ScrollLine so kiln-tui can pattern-match scroll content without
// accessing the private `action` module directly. // accessing the private `action` module directly.
pub use crate::action::ScrollLine; pub use crate::action::ScrollLine;
@@ -69,9 +66,6 @@ pub struct GameState {
/// by [`enter_board`](GameState::enter_board). Front-ends tick this down and /// 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`. /// may block input or show a visual effect while it is `Some(t)` where `t > 0`.
pub board_transition: Option<f64>, pub board_transition: Option<f64>,
/// 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 { impl GameState {
@@ -98,7 +92,6 @@ impl GameState {
speech_bubbles: Vec::new(), speech_bubbles: Vec::new(),
active_scroll: None, active_scroll: None,
board_transition: None, board_transition: None,
pusher_timer: PUSHER_INTERVAL,
}; };
state.drain_errors(); state.drain_errors();
state state
@@ -183,28 +176,9 @@ impl GameState {
b.remaining -= secs; b.remaining -= secs;
b.remaining > 0.0 b.remaining > 0.0
}); });
self.tick_pushers(secs);
self.resolve(); 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. /// Drains errors collected by the script host into the game log.
fn drain_errors(&mut self) { fn drain_errors(&mut self) {
// TODO: errors are only logged for now. This is the place to halt execution / // TODO: errors are only logged for now. This is the place to halt execution /
+20 -1
View File
@@ -143,6 +143,7 @@ pub(crate) struct ObjectTemplate {
pub opaque: bool, pub opaque: bool,
pub pushable: bool, pub pushable: bool,
pub script_name: Option<String>, pub script_name: Option<String>,
pub builtin_script: Option<&'static str>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub name: Option<String>, pub name: Option<String>,
} }
@@ -212,6 +213,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
opaque: e.opaque.unwrap_or(true), opaque: e.opaque.unwrap_or(true),
pushable: e.pushable.unwrap_or(false), pushable: e.pushable.unwrap_or(false),
script_name: e.script_name.clone(), script_name: e.script_name.clone(),
builtin_script: None,
tags: e.tags.clone().unwrap_or_default(), tags: e.tags.clone().unwrap_or_default(),
name: e.name.clone(), name: e.name.clone(),
}), }),
@@ -233,7 +235,24 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
"player" => Resolved::Player, "player" => Resolved::Player,
// Any other kind must be an archetype name. // Any other kind must be an archetype name.
other => match Archetype::try_from(other) { 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_<archetype>` 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) => { Err(msg) => {
errors.push(LogLine::error(format!( errors.push(LogLine::error(format!(
"palette '{ch}': {msg}; using error block" "palette '{ch}': {msg}; using error block"
+1
View File
@@ -1,6 +1,7 @@
mod action; mod action;
mod archetype; mod archetype;
mod board; mod board;
mod builtin_scripts;
/// Procedural floor generators ([`floor::FloorGenerator`]). /// Procedural floor generators ([`floor::FloorGenerator`]).
pub mod floor; pub mod floor;
/// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc. /// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc.
+14
View File
@@ -20,6 +20,7 @@ use tinyrand::{Seeded, StdRand};
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::board::Board; use crate::board::Board;
use crate::builtin_scripts::archetype_from_builtin_tag;
use crate::floor::FLOOR_SEED; use crate::floor::FLOOR_SEED;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer}; use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer};
@@ -237,6 +238,7 @@ impl TryFrom<MapFile> for Board {
opaque: t.opaque, opaque: t.opaque,
pushable: t.pushable, pushable: t.pushable,
script_name: t.script_name, script_name: t.script_name,
builtin_script: t.builtin_script,
tags: t.tags.into_iter().collect(), tags: t.tags.into_iter().collect(),
name, 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. // Objects on this layer overwrite their (transparent) cell with an object char.
for o in board.objects.values().filter(|o| o.z == z) { 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 ch = pool.next().expect("ran out of palette characters");
let mut tags: Vec<String> = o.tags.iter().cloned().collect(); let mut tags: Vec<String> = o.tags.iter().cloned().collect();
tags.sort(); tags.sort();
+6
View File
@@ -51,6 +51,11 @@ pub struct ObjectDef {
/// Name of the Rhai script in [`Board::scripts`] that drives this object. /// Name of the Rhai script in [`Board::scripts`] that drives this object.
/// `None` means this object has no script yet. /// `None` means this object has no script yet.
pub script_name: Option<String>, pub script_name: Option<String>,
/// 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; /// Open-ended string labels for this object. Serialized as a TOML array;
/// not subject to any rate limit — mutations take effect immediately after /// not subject to any rate limit — mutations take effect immediately after
/// the frame's action queue is drained. /// the frame's action queue is drained.
@@ -88,6 +93,7 @@ impl ObjectDef {
opaque: true, opaque: true,
pushable: false, pushable: false,
script_name: None, script_name: None,
builtin_script: None,
tags: HashSet::new(), tags: HashSet::new(),
name: None, name: None,
} }
+60 -37
View File
@@ -58,6 +58,7 @@ use crate::board::Board;
use crate::game::SAY_DURATION; use crate::game::SAY_DURATION;
use crate::log::LogLine; use crate::log::LogLine;
use crate::map_file::{color_to_hex, parse_color}; use crate::map_file::{color_to_hex, parse_color};
use crate::object_def::ObjectDef;
use crate::utils::{Direction, ObjectId, RegistryValue, ScriptArg}; use crate::utils::{Direction, ObjectId, RegistryValue, ScriptArg};
use rhai::{ use rhai::{
AST, Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module, AST, Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module,
@@ -102,11 +103,24 @@ struct CompiledScript {
/// The runtime state of one scripted object. /// The runtime state of one scripted object.
struct ObjectRuntime { struct ObjectRuntime {
object_id: ObjectId, 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>, scope: Scope<'static>,
output_queue: ObjQueue, 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<String> {
if let Some(src) = obj.builtin_script {
Some(src.to_string())
} else {
obj.script_name.clone()
}
}
// ── Rhai types exposed to scripts ──────────────────────────────────────────── // ── Rhai types exposed to scripts ────────────────────────────────────────────
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`, /// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
@@ -205,44 +219,53 @@ impl ScriptHost {
let board = board_cell.borrow(); 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<String, CompiledScript> = HashMap::new(); let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
let mut failed: HashSet<String> = HashSet::new(); let mut failed: HashSet<String> = HashSet::new();
for obj in board.objects.values() { for obj in board.objects.values() {
let Some(name) = obj.script_name.as_ref() else { let Some(key) = script_key(obj) else {
continue; continue;
}; };
if scripts.contains_key(name) || failed.contains(name) { if scripts.contains_key(&key) || failed.contains(&key) {
continue; continue;
} }
match script_sources.get(name) { // Source: the embedded built-in, or a lookup in the world script pool.
Some(src) => match engine.compile(src) { let source: &str = if let Some(src) = obj.builtin_script {
Ok(ast) => { src
let defines = |n: &str, params: usize| { } else {
ast.iter_functions() match script_sources.get(&key) {
.any(|f| f.name == n && f.params.len() == params) Some(src) => src,
}; None => {
scripts.insert( failed.insert(key.clone());
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());
errors.borrow_mut().push(LogLine::error(format!( 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!( 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. // One runtime per object whose script compiled.
let mut objects = Vec::new(); let mut objects = Vec::new();
for (&id, obj) in board.objects.iter() { for (&id, obj) in board.objects.iter() {
let Some(name) = obj.script_name.as_ref() else { let Some(key) = script_key(obj) else {
continue; continue;
}; };
if !scripts.contains_key(name) { if !scripts.contains_key(&key) {
continue; continue;
} }
let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new())); let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new()));
queues.borrow_mut().insert(id, queue.clone()); queues.borrow_mut().insert(id, queue.clone());
objects.push(ObjectRuntime { objects.push(ObjectRuntime {
object_id: id, object_id: id,
script_name: name.clone(), script_key: key,
scope: new_object_scope(board_cell, &queue, id), scope: new_object_scope(board_cell, &queue, id),
output_queue: queue, output_queue: queue,
}); });
@@ -302,7 +325,7 @@ impl ScriptHost {
.. ..
} = self; } = self;
let obj = &mut objects[i]; let obj = &mut objects[i];
let Some(compiled) = scripts.get(&obj.script_name) else { let Some(compiled) = scripts.get(&obj.script_key) else {
return; return;
}; };
if !compiled.has_bump { if !compiled.has_bump {
@@ -318,7 +341,7 @@ impl ScriptHost {
) { ) {
errors.borrow_mut().push(LogLine::error(format!( errors.borrow_mut().push(LogLine::error(format!(
"script '{}' bump error: {err}", "script '{}' bump error: {err}",
obj.script_name obj.script_key
))); )));
} }
} }
@@ -344,7 +367,7 @@ impl ScriptHost {
.. ..
} = self; } = self;
let obj = &mut objects[i]; let obj = &mut objects[i];
let Some(compiled) = scripts.get(&obj.script_name) else { let Some(compiled) = scripts.get(&obj.script_key) else {
return; return;
}; };
@@ -387,7 +410,7 @@ impl ScriptHost {
if let Err(err) = result { if let Err(err) = result {
errors.borrow_mut().push(LogLine::error(format!( errors.borrow_mut().push(LogLine::error(format!(
"script '{}' send('{}') error: {err}", "script '{}' send('{}') error: {err}",
obj.script_name, fn_name obj.script_key, fn_name
))); )));
} }
} }
@@ -456,7 +479,7 @@ impl ScriptHost {
.. ..
} = self; } = self;
let obj = &mut objects[i]; 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) && defined(compiled)
{ {
let options = CallFnOptions::default().with_tag(obj.object_id as i64); let options = CallFnOptions::default().with_tag(obj.object_id as i64);
@@ -469,7 +492,7 @@ impl ScriptHost {
) { ) {
errors.borrow_mut().push(LogLine::error(format!( errors.borrow_mut().push(LogLine::error(format!(
"script '{}' {hook} error: {err}", "script '{}' {hook} error: {err}",
obj.script_name obj.script_key
))); )));
} }
} }
+24
View File
@@ -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_<dir>` 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);
}
}
+1
View File
@@ -3,6 +3,7 @@ mod grid_errors;
mod object_placement; mod object_placement;
mod player_placement; mod player_placement;
mod portal_placement; mod portal_placement;
mod pushers;
mod round_trip; mod round_trip;
use crate::board::Board; use crate::board::Board;
+132
View File
@@ -0,0 +1,132 @@
//! Pushers are now scripted objects (the `pusher_*` archetypes expand into objects
//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_<dir>` 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());
}