builtin scripts
This commit is contained in:
@@ -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_<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)`):
|
||||
- `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<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):
|
||||
- `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`).
|
||||
- `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<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:
|
||||
- `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.
|
||||
- **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()`).
|
||||
|
||||
@@ -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_<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),
|
||||
/// Sentinel for map files that reference an unknown archetype name.
|
||||
/// Renders as a yellow `?` on red to make the error visible in-game.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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<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 {
|
||||
@@ -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 /
|
||||
|
||||
+20
-1
@@ -143,6 +143,7 @@ pub(crate) struct ObjectTemplate {
|
||||
pub opaque: bool,
|
||||
pub pushable: bool,
|
||||
pub script_name: Option<String>,
|
||||
pub builtin_script: Option<&'static str>,
|
||||
pub tags: Vec<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),
|
||||
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<LogLine>) -> 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_<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) => {
|
||||
errors.push(LogLine::error(format!(
|
||||
"palette '{ch}': {msg}; using error block"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<MapFile> 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<String> = o.tags.iter().cloned().collect();
|
||||
tags.sort();
|
||||
|
||||
@@ -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<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;
|
||||
/// 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,
|
||||
}
|
||||
|
||||
+60
-37
@@ -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<String> {
|
||||
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<String, CompiledScript> = HashMap::new();
|
||||
let mut failed: HashSet<String> = 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
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user