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`.
|
- `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()`).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
/// 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
@@ -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,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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
|||||||
@@ -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