more scripting, objects moving

This commit is contained in:
2026-06-06 17:36:00 -05:00
parent 8ac6da184c
commit a5f60e22e1
5 changed files with 781 additions and 240 deletions
+392 -65
View File
@@ -1,5 +1,5 @@
use crate::log::LogLine;
use crate::script::{Direction, GameCommand, ScriptHost};
use crate::script::{Action, Direction, ScriptHost};
use color::Rgba8;
use serde::{Deserialize, Serialize};
use std::cell::{Ref, RefCell, RefMut};
@@ -352,10 +352,13 @@ impl ObjectDef {
/// The single solid occupant of a board cell, returned by [`Board::solid_at`].
///
/// At most one solid — a grid [`Archetype`] *or* an [`ObjectDef`] — may occupy a
/// cell (the invariant enforced at load time), so this represents the one thing a
/// mover would collide with there.
/// At most one solid — the player, a grid [`Archetype`], *or* an [`ObjectDef`] — may
/// occupy a cell (the invariant enforced at load time), so this represents the one
/// thing a mover would collide with there.
pub enum Solid<'a> {
/// The player occupies the cell. The player is solid (it blocks movers) and
/// pushable in any direction (see [`Board::is_pushable`]).
Player,
/// The cell's grid archetype is itself solid (e.g. [`Archetype::Wall`]).
Cell(Archetype),
/// A solid [`ObjectDef`] occupies the cell.
@@ -510,6 +513,10 @@ impl Board {
/// [`crate::map_file`]), this returns that one occupant or `None`.
/// Panics if `x` or `y` are out of bounds.
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid<'_>> {
// The player wins its cell (load-time invariant), so it is the solid there.
if self.player.x == x as i32 && self.player.y == y as i32 {
return Some(Solid::Player);
}
// A solid object shadows the grid cell it sits on.
if let Some(obj) = self.object_at(x, y)
&& obj.solid
@@ -539,6 +546,7 @@ impl Board {
/// can be shoved in any direction.
fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool {
match self.solid_at(x, y) {
Some(Solid::Player) => true, // the player is pushable in any direction
Some(Solid::Cell(a)) => a.behavior().pushable.allows(dir),
Some(Solid::Object(o)) => o.pushable,
None => false,
@@ -606,7 +614,11 @@ impl Board {
/// The caller guarantees the destination is already clear.
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
if let Some(idx) = self.object_index_at(x, y)
// The player owns its cell, so move it before considering objects/grid.
if self.player.x == x as i32 && self.player.y == y as i32 {
self.player.x = tx as i32;
self.player.y = ty as i32;
} else if let Some(idx) = self.object_index_at(x, y)
&& self.objects[idx].solid
{
self.objects[idx].x = tx;
@@ -662,7 +674,7 @@ impl GameState {
scripts,
};
// Surface any compile-time script errors collected during ScriptHost::new.
state.apply_commands();
state.drain_errors();
state
}
@@ -681,86 +693,135 @@ impl GameState {
self.log.push(line);
}
/// Runs the `init()` hook of every scripted object, then applies the commands
/// they queued. Call once, after the whole map is loaded and the game is about
/// to start — never during map deserialization, since a script may inspect the
/// board.
/// Runs the `init()` hook of every scripted object, pumps their queues, and
/// resolves the resulting actions. Call once, after the whole map is loaded and
/// the game is about to start — never during map deserialization, since a script
/// may inspect the board.
pub fn run_init(&mut self) {
self.scripts.run_init();
self.apply_commands();
self.resolve();
}
/// Advances real-time game state by `dt` (the elapsed time since the last
/// tick). Called once per frame by the front-end's game loop; drives every
/// scripted object's `tick(dt)` hook, then applies the commands they queued.
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
/// Called once per frame by the front-end's game loop. Counts down object
/// cooldowns, drives every scripted object's `tick(dt)` hook (pumping each queue),
/// then resolves the actions that were promoted onto the board queue.
pub fn tick(&mut self, dt: Duration) {
self.scripts.run_tick(dt.as_secs_f64());
self.apply_commands();
let secs = dt.as_secs_f64();
self.scripts.advance_timers(secs);
self.scripts.run_tick(secs);
self.resolve();
}
/// Drains the script command queue and applies each command. Runs *after* a
/// script batch, when nothing holds a borrow of the board — so the mutations
/// here can't conflict with the read getters scripts use during execution.
fn apply_commands(&mut self) {
for cmd in self.scripts.take_commands() {
match cmd.kind {
GameCommand::Log(line) => self.log.push(line),
// TODO: errors are only logged for now. This is the place to halt
// execution / set an error state when a script faults.
GameCommand::Error(msg) => self.log.push(LogLine::raw(msg)),
GameCommand::SetTile(tile) => {
if let Some(obj) = self.board_mut().objects.get_mut(cmd.source) {
obj.glyph.tile = tile;
/// 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 /
// set an error state when a script faults.
let errors = self.scripts.take_errors();
self.log.extend(errors);
}
/// Resolves the board queue: applies each promoted action to the board, then fires
/// the `bump` hooks the moves triggered.
///
/// Done in two phases so no `board_mut` borrow is held while `bump` scripts run
/// (they read the board through its getters): phase A mutates the board and records
/// `(bumped_object, bumper)` pairs; phase B fires the bumps after the borrow drops.
fn resolve(&mut self) {
let actions = self.scripts.take_board_queue();
// Logs are collected here rather than pushed inline, since the board borrow
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
let mut bumps: Vec<(usize, i64)> = Vec::new();
{
let mut board = self.board_mut();
for ba in actions {
match ba.action {
Action::Move(dir) => {
if let Some(bumped) = step_object(&mut board, ba.source, dir) {
bumps.push((bumped, ba.source as i64));
}
}
Action::SetTile(tile) => {
if let Some(obj) = board.objects.get_mut(ba.source) {
obj.glyph.tile = tile;
}
}
Action::Log(line) => logs.push(line),
}
GameCommand::Move(dir) => self.move_object(cmd.source, dir),
}
}
}
/// Moves object `idx` one cell in `dir`. The move proceeds if the target is in
/// bounds and either passable or a pushable solid the object can shove out of
/// the way (see [`Board::can_push`]). A no-op when blocked.
fn move_object(&mut self, idx: usize, dir: Direction) {
let (dx, dy): (i32, i32) = dir.into();
let mut board = self.board_mut();
let Some((ox, oy)) = board.objects.get(idx).map(|o| (o.x, o.y)) else {
return;
};
let target = (ox as i32 + dx, oy as i32 + dy);
if !board.in_bounds(target) {
return;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
let obj = &mut board.objects[idx];
obj.x = nx;
obj.y = ny;
self.log.extend(logs);
for (bumped, bumper) in bumps {
self.scripts.run_bump(bumped, bumper);
}
self.drain_errors();
}
/// Attempts to move the player one cell in `dir`.
///
/// The move is ignored if the target cell is out of bounds, or it is neither
/// passable nor a pushable solid that can be shoved aside. No-ops silently
/// (the caller does not need to check).
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the
/// caller does not need to check). If the target cell holds a solid object, that
/// object's `bump(-1)` hook fires (whether or not the player ends up moving).
pub fn try_move(&mut self, dir: Direction) {
let (dx, dy): (i32, i32) = dir.into();
let mut board = self.board_mut();
let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) {
return;
let bumped;
{
let (dx, dy): (i32, i32) = dir.into();
let mut board = self.board_mut();
let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) {
return;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// A solid object in the way is bumped by the player (id -1).
bumped = match board.solid_at(nx, ny) {
Some(Solid::Object(_)) => board.object_index_at(nx, ny),
_ => None,
};
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
board.player.x = nx as i32;
board.player.y = ny as i32;
}
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
board.player.x = nx as i32;
board.player.y = ny as i32;
if let Some(idx) = bumped {
self.scripts.run_bump(idx, -1);
self.drain_errors();
}
}
}
/// Moves object `idx` one cell in `dir` on `board`, returning the index of a solid
/// object it bumped (its target cell was occupied by that object), if any.
///
/// The move itself proceeds only if the target is in bounds and either passable or a
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
/// bump is recorded whenever a solid object occupies the target — whether it gets
/// pushed aside or blocks the move — since something tried to move into it. Walls and
/// crates carry no script, so only solid objects yield a bump.
fn step_object(board: &mut Board, idx: usize, dir: Direction) -> Option<usize> {
let (dx, dy): (i32, i32) = dir.into();
let (ox, oy) = board.objects.get(idx).map(|o| (o.x, o.y))?;
let target = (ox as i32 + dx, oy as i32 + dy);
if !board.in_bounds(target) {
return None;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// Capture the bumped object before any push relocates it (its index is stable).
let bumped = match board.solid_at(nx, ny) {
Some(Solid::Object(_)) => board.object_index_at(nx, ny),
_ => None,
};
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
let obj = &mut board.objects[idx];
obj.x = nx;
obj.y = ny;
}
bumped
}
#[cfg(test)]
mod tests {
use super::*;
@@ -838,8 +899,9 @@ mod tests {
#[test]
fn solid_at_reports_wall_object_and_empty() {
// A 3×1 board: empty floor, a wall, and an empty cell holding one object.
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
// A 4×1 board: empty floor, a wall, an object, and the player parked at (3,0)
// (kept off the asserted cells, since the player is itself solid).
let mut board = open_board(4, 1, (3, 0), vec![], &[]);
board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall);
// A solid object on the otherwise-empty cell (2, 0).
board.objects.push(ObjectDef::new(2, 0)); // solid by default
@@ -1249,4 +1311,269 @@ mod tests {
assert_eq!(b.objects[0].x, 1); // moved east from 0
assert_eq!(b.objects[1].x, 3); // moved west from 4
}
#[test]
fn move_cost_rate_limits_repeated_moves() {
// init queues two moves; only the first resolves immediately, the second must
// wait the full 250 ms cooldown.
let board = open_board(
5,
1,
(0, 0), // player to the west, out of the object's path
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); move(East); }")],
);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].x, 2); // first move applied (1 -> 2)
// 200 ms of ticks: still inside the cooldown, no further movement.
game.tick(Duration::from_millis(100));
game.tick(Duration::from_millis(100));
assert_eq!(game.board().objects[0].x, 2);
// Crossing the 250 ms mark releases the queued second move.
game.tick(Duration::from_millis(100));
assert_eq!(game.board().objects[0].x, 3);
}
#[test]
fn blocked_move_still_costs_cooldown() {
// A move into a wall fails but still charges the 250 ms cooldown, so a second
// queued move (into open space) is delayed just as a successful move would be.
let mut board = open_board(
3,
3,
(0, 0),
vec![scripted_object(1, 1, "m")],
&[("m", "fn init() { move(East); move(South); }")],
);
wall_at(&mut board, 2, 1); // blocks the eastward move
let mut game = GameState::new(board);
game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved.
assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 1));
// The blocked move charged the cooldown, so South is still pending here.
game.tick(Duration::from_millis(100));
game.tick(Duration::from_millis(100));
assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 1));
// Past 250 ms the queued South move resolves.
game.tick(Duration::from_millis(100));
assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 2));
}
#[test]
fn queue_length_reports_pending_actions() {
// Two zero-cost actions are queued before the length is read, then all three
// (incl. the log) drain in one pump.
let board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "q")],
&[("q", "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }")],
);
let mut game = GameState::new(board);
game.run_init();
assert!(log_texts(&game).iter().any(|t| t == "len=2"));
assert_eq!(game.board().objects[0].glyph.tile, 6); // last set_tile won
}
#[test]
fn queue_clear_drops_pending_actions() {
// clear() empties the output queue mid-script, so the queued moves never run.
let board = open_board(
5,
1,
(0, 0),
vec![scripted_object(1, 0, "c")],
&[("c", "fn init() { move(East); move(East); Queue.clear(); }")],
);
let mut game = GameState::new(board);
game.run_init();
game.tick(Duration::from_millis(300));
assert_eq!(game.board().objects[0].x, 1); // never moved
}
#[test]
fn blocked_reports_solid_and_clear() {
// Solid ahead (a wall): blocked() is true.
let mut board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "b")],
&[("b", "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }")],
);
wall_at(&mut board, 2, 0);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].glyph.tile, 9);
// Open ahead, nothing pending: blocked() is false.
let board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "b")],
&[("b", "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }")],
);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].glyph.tile, 7);
}
#[test]
fn blocked_sees_earlier_objects_pending_move() {
// obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked()
// toward that same cell and must see the pending move.
let board = open_board(
5,
3,
(0, 0),
vec![
scripted_object(1, 1, "mover"),
scripted_object(3, 1, "checker"),
],
&[
("mover", "fn tick(dt) { move(East); }"),
(
"checker",
"fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }",
),
],
);
let mut game = GameState::new(board);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 1)); // mover advanced
assert_eq!(b.objects[1].glyph.tile, 1); // checker saw the pending move
}
#[test]
fn collision_priority_resolves_in_array_order_and_bumps() {
// Two objects move into the same empty cell (1,0). obj0 (earlier) wins it;
// obj1 is blocked and bumps obj0. obj1 itself receives no bump.
let board = open_board(
3,
2,
(0, 1), // player off the contested row
vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")],
&[
(
"e",
"fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }",
),
(
"w",
"fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }",
),
],
);
let mut game = GameState::new(board);
game.run_init();
// The bump fires during resolution but its log is emitted into obj0's queue;
// a later tick (past both objects' move cooldown) flushes it to the game log.
game.tick(Duration::from_millis(300));
{
let b = game.board();
assert_eq!((b.objects[0].x, b.objects[0].y), (1, 0)); // obj0 won the cell
assert_eq!((b.objects[1].x, b.objects[1].y), (2, 0)); // obj1 blocked
}
let logs = log_texts(&game);
assert!(logs.iter().any(|t| t == "o0 by 1")); // obj0 bumped by obj1
assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped
}
#[test]
fn player_bump_fires_with_negative_one() {
// The player walks into a solid scripted object; the object is bumped with -1
// and the player does not move onto it.
let board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "b")],
&[("b", "fn bump(id) { log(`bumped by ${id}`); }")],
);
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
// bump's log is emitted into the object's queue; a tick flushes it.
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "bumped by -1"));
}
#[test]
fn solid_at_reports_player() {
// The player's own cell is solid (and thus impassable to movers).
let board = open_board(3, 1, (1, 0), vec![], &[]);
match board.solid_at(1, 0) {
Some(Solid::Player) => {}
_ => panic!("expected Solid::Player at the player's cell"),
}
assert!(!board.is_passable(1, 0));
}
#[test]
fn push_into_player_pushes_player() {
// Crate shoved east into the player slides the player along into open space.
let mut board = open_board(4, 1, (2, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
board.push(1, 0, Direction::East);
assert_eq!(board.get(1, 0).1, Archetype::Empty); // crate left its cell
assert_eq!(board.get(2, 0).1, Archetype::Crate); // crate took the player's old cell
assert_eq!((board.player.x, board.player.y), (3, 0)); // player shoved east
}
#[test]
fn push_into_player_blocked_by_wall() {
// Player backed against a wall: the push has nowhere to go, so nothing moves
// and the player is never overlapped.
let mut board = open_board(4, 1, (2, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 3, 0);
assert!(!board.can_push(1, 0, Direction::East));
board.push(1, 0, Direction::East); // no-op
assert_eq!(board.get(1, 0).1, Archetype::Crate);
assert_eq!((board.player.x, board.player.y), (2, 0));
}
#[test]
fn object_push_into_player() {
// A scripted object moving into the player pushes the player when there's room.
let board = open_board(
5,
1,
(2, 0),
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); }")],
);
let mut game = GameState::new(board);
game.run_init();
{
let b = game.board();
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 0)); // object took player's cell
assert_eq!((b.player.x, b.player.y), (3, 0)); // player shoved east
}
// With a wall behind the player, the object is blocked and nothing moves.
let mut board = open_board(
4,
1,
(2, 0),
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); }")],
);
wall_at(&mut board, 3, 0);
let mut game = GameState::new(board);
game.run_init();
let b = game.board();
assert_eq!((b.objects[0].x, b.objects[0].y), (1, 0)); // object blocked
assert_eq!((b.player.x, b.player.y), (2, 0)); // player not pushed
}
}
+11
View File
@@ -866,6 +866,17 @@ content = """
assert!(!b.is_valid());
}
#[test]
fn player_fallback_to_origin_clears_solid_terrain() {
// The '@' char is absent, so the player falls back to (0, 0) — which holds a
// wall. The player wins its cell: the wall is cleared so one-solid-per-cell
// holds (the player is itself solid). The fallback is still reported.
let b = load_board(&player_map(3, "\"@\"", "#..", ""));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 0).1, Archetype::Empty); // wall cleared under the player
assert!(!b.is_valid());
}
/// Minimal valid TOML with a configurable grid, used as a base for dimension tests.
fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String {
let content = grid_rows.join("\n");
+327 -161
View File
@@ -1,62 +1,81 @@
//! Rhai scripting runtime for board objects.
//!
//! [`ScriptHost`] owns the Rhai [`Engine`], the compiled scripts referenced by a
//! board's objects, and a persistent per-object [`Scope`]. It drives two optional
//! board's objects, and a persistent per-object [`Scope`]. It drives the optional
//! lifecycle hooks on each scripted object:
//!
//! - `init()` — run once after the whole map is loaded (see [`ScriptHost::run_init`]).
//! - `tick(dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]).
//! - `bump(id)` — run when another mover steps into this object's cell, with the
//! bumper's object index (`-1` for the player); see [`ScriptHost::run_bump`].
//!
//! ## Reads vs. writes
//! ## Actions, queues, and rate limiting
//!
//! Scripts **read** the world directly through a read-only `Rc<RefCell<Board>>` (type aliased
//! as [`BoardRef`], registering getters only) pushed into each scope as `Board`, e.g.
//! `Board.player_x`. Functions borrow it briefly per getter.
//! Scripts don't mutate the world directly. Each write host function (`move`,
//! `set_tile`, `log`) appends an [`Action`] to the issuing object's **output
//! queue** (a per-object FIFO). Actions leave that queue one batch at a time via
//! [`ScriptHost::pump`], which promotes them onto the shared **board queue**:
//!
//! Scripts **write** by enqueuing [`Command`]s: host functions (`move`,
//! `set_tile`, `log`) push into a shared queue that [`GameState`] drains and
//! applies *after* each batch ([`ScriptHost::take_commands`]). This keeps script
//! execution from ever needing a mutable borrow of the game state, and makes
//! structural/ordering effects deterministic. Which object issued a command is
//! read from the per-call **tag** (set to the object index in [`ScriptHost::run`]),
//! so scripts write `move(North)` without naming themselves.
//! - A [`pump`](ScriptHost::pump) pulls the leading run of zero-cost actions plus at
//! most one action with a time cost (currently only [`Action::Move`], 250 ms).
//! - Pulling a timed action arms the object's **ready timer**; while it is running no
//! further actions are pulled (this is what caps an object's speed). [`tick`] ticks
//! the timer down via [`advance_timers`](ScriptHost::advance_timers).
//!
//! [`crate::game::GameState`] drains the board queue ([`take_board_queue`]) and applies
//! each action *after* the script batch, preserving the borrow discipline (scripts only
//! ever read the board during execution). Which object issued an action is read from the
//! per-call **tag** (set to the object index in [`ScriptHost::run`]).
//!
//! [`tick`]: ScriptHost::run_tick
//! [`take_board_queue`]: ScriptHost::take_board_queue
//! [`GameState`]: crate::game::GameState
use crate::game::Board;
use crate::log::LogLine;
use rhai::{AST, CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
/// Shared queue the write host functions push into; drained by [`ScriptHost::take_commands`].
type CommandQueue = Rc<RefCell<Vec<Command>>>;
/// How long a move occupies an object before it can act again, in seconds.
pub const MOVE_COST: f64 = 0.25;
/// A mutation requested by a script, applied by [`crate::game::GameState`] after
/// the script batch finishes.
pub struct Command {
/// The object that issued the command.
// TODO: `source` is an index into `Board::objects`, which is a stopgap — it
// breaks under object spawn/destroy/reorder. Replace with a monotonically
// increasing unique object id, with objects stored keyed by it (e.g.
// `BTreeMap<ObjectId, ObjectDef>`). Mirrored on `ObjectRuntime::object_index`.
pub source: usize,
/// What the command does.
pub kind: GameCommand,
}
/// The kinds of mutation a script can request.
pub enum GameCommand {
/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`]
/// after it is promoted onto the board queue.
pub enum Action {
/// Move the source object one cell in a direction (subject to passability).
Move(Direction),
/// Set the source object's glyph tile index.
SetTile(u32),
/// Append a plain (uncolored) line to the game log.
Log(LogLine),
/// A script/engine failure. Applying it logs the message for now; kept a
/// distinct variant so execution can later be halted on error.
Error(String),
}
impl Action {
/// How long performing this action keeps the object busy, in seconds.
///
/// Only [`Action::Move`] has a cost ([`MOVE_COST`]); zero-cost actions can be
/// drained several-per-pump and never arm the ready timer.
pub fn time_cost(&self) -> f64 {
match self {
Action::Move(_) => MOVE_COST,
Action::SetTile(_) | Action::Log(_) => 0.0,
}
}
}
/// An action promoted from an object's output queue onto the board queue, tagged
/// with the object that issued it.
pub struct BoardAction {
/// The object that issued the action.
// TODO: `source` is an index into `Board::objects`, which is a stopgap — it
// breaks under object spawn/destroy/reorder. Replace with a monotonically
// increasing unique object id, with objects stored keyed by it (e.g.
// `BTreeMap<ObjectId, ObjectDef>`). Mirrored on `ObjectRuntime::object_index`.
pub source: usize,
/// The action to apply.
pub action: Action,
}
/// A cardinal movement direction.
@@ -85,6 +104,22 @@ impl From<Direction> for (i32, i32) {
/// read-only by construction.
type BoardRef = Rc<RefCell<Board>>;
/// A single object's output queue, shared between the host (which pumps it between
/// script calls) and the script (which reads/mutates it through the `Queue` object).
type ObjQueue = Rc<RefCell<VecDeque<Action>>>;
/// The board queue: actions promoted from object queues, awaiting resolution. Shared
/// so the `blocked` host function can scan pending moves during script execution.
type BoardQueue = Rc<RefCell<Vec<BoardAction>>>;
/// Maps an object index to its output queue, so the global write host functions can
/// route an emitted action to the right object via the per-call tag.
type QueueMap = Rc<RefCell<HashMap<usize, ObjQueue>>>;
/// Channel for engine/compile/runtime errors, surfaced straight to the game log
/// (errors never go through the action queues).
type ErrorSink = Rc<RefCell<Vec<LogLine>>>;
/// A compiled script plus which lifecycle hooks it defines.
struct CompiledScript {
/// The compiled Rhai AST (function definitions plus any top-level code).
@@ -93,20 +128,27 @@ struct CompiledScript {
has_init: bool,
/// Whether the script defines `fn tick(dt)` (one parameter).
has_tick: bool,
/// Whether the script defines `fn bump(id)` (one parameter).
has_bump: bool,
}
/// The runtime state of one scripted object: which script it runs and its own
/// persistent Rhai scope.
/// The runtime state of one scripted object: which script it runs, its persistent
/// Rhai scope, its pending actions, and its cooldown timer.
struct ObjectRuntime {
/// Index into [`crate::game::Board::objects`]; passed to scripts as the
/// per-call tag so host functions know which object issued a command.
// TODO: see [`Command::source`] — array indices should become stable ids.
/// per-call tag so host functions know which object issued an action.
// TODO: see [`BoardAction::source`] — array indices should become stable ids.
object_index: usize,
/// Key into [`ScriptHost::scripts`] naming this object's compiled script.
script_name: String,
/// Per-object scope (holds the `board` view + direction constants, and can
/// hold object-local state in the future). Persists across calls.
/// Per-object scope (holds the `Board`/`Queue` views + direction constants, and
/// can hold object-local state in the future). Persists across calls.
scope: Scope<'static>,
/// This object's output queue (shared with its scope's `Queue` object).
output_queue: ObjQueue,
/// Seconds remaining before this object may pull another action. While `> 0`
/// nothing is pulled from [`output_queue`](Self::output_queue).
ready_timer: f64,
}
/// Owns the Rhai engine and per-object script state for a board.
@@ -117,22 +159,27 @@ pub struct ScriptHost {
scripts: HashMap<String, CompiledScript>,
/// One entry per object that has a successfully compiled script.
objects: Vec<ObjectRuntime>,
/// Queue the write host functions push into; drained by [`Self::take_commands`].
commands: CommandQueue,
/// Actions promoted from object queues, drained by [`Self::take_board_queue`].
board_queue: BoardQueue,
/// Errors collected during compile/run, drained by [`Self::take_errors`].
errors: ErrorSink,
}
impl ScriptHost {
/// Builds a host for the board behind `state`: registers the read/write API,
/// compiles every script referenced by an object (once each), and creates a
/// fresh scope per scripted object. Compile errors and references to unknown
/// scripts are queued as [`GameCommand::Error`] (retrievable via
/// [`Self::take_commands`]); no script is run here.
/// Builds a host for the board behind `board_cell`: registers the read/write API,
/// compiles every script referenced by an object (once each), and creates a fresh
/// scope and output queue per scripted object. Compile errors and references to
/// unknown scripts are queued onto the error sink (retrievable via
/// [`Self::take_errors`]); no script is run here.
pub fn new(board_cell: &BoardRef) -> Self {
let commands: CommandQueue = Rc::new(RefCell::new(Vec::new()));
let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new()));
let queues: QueueMap = Rc::new(RefCell::new(HashMap::new()));
let errors: ErrorSink = Rc::new(RefCell::new(Vec::new()));
let mut engine = Engine::new();
register_read_api(&mut engine);
register_write_api(&mut engine, &commands);
register_read_api(&mut engine, board_cell, &board_queue);
register_write_api(&mut engine, &queues);
register_queue_api(&mut engine);
let board = board_cell.borrow();
@@ -140,7 +187,7 @@ impl ScriptHost {
// object that references the script.
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
let mut failed: HashSet<String> = HashSet::new();
for (i, obj) in board.objects.iter().enumerate() {
for obj in board.objects.iter() {
let Some(name) = obj.script_name.as_ref() else {
continue;
};
@@ -151,57 +198,55 @@ impl ScriptHost {
Some(src) => match engine.compile(src) {
Ok(ast) => {
// Detect the optional hooks by name and arity.
let has_init = ast
.iter_functions()
.any(|f| f.name == "init" && f.params.is_empty());
let has_tick = ast
.iter_functions()
.any(|f| f.name == "tick" && f.params.len() == 1);
scripts.insert(
name.clone(),
CompiledScript {
ast,
has_init,
has_tick,
},
);
let defines = |n: &str, params: usize| {
ast.iter_functions()
.any(|f| f.name == n && f.params.len() == params)
};
let compiled = CompiledScript {
has_init: defines("init", 0),
has_tick: defines("tick", 1),
has_bump: defines("bump", 1),
ast,
};
scripts.insert(name.clone(), compiled);
}
Err(err) => {
failed.insert(name.clone());
commands.borrow_mut().push(Command {
source: i,
kind: GameCommand::Error(format!(
"script '{name}' failed to compile: {err}"
)),
});
errors
.borrow_mut()
.push(LogLine::raw(format!("script '{name}' failed to compile: {err}")));
}
},
None => {
failed.insert(name.clone());
commands.borrow_mut().push(Command {
source: i,
kind: GameCommand::Error(format!(
"object references unknown script '{name}'"
)),
});
errors
.borrow_mut()
.push(LogLine::raw(format!("object references unknown script '{name}'")));
}
}
}
// One runtime (independent scope) per object whose script compiled.
let objects = board
.objects
.iter()
.enumerate()
.filter_map(|(i, obj)| {
let name = obj.script_name.as_ref()?;
scripts.contains_key(name).then(|| ObjectRuntime {
object_index: i,
script_name: name.clone(),
scope: new_object_scope(board_cell),
})
})
.collect();
// One runtime (independent scope + output queue) per object whose script
// compiled. The queue is registered in `queues` so host functions can find
// it by the object's index, and shared into the scope as the `Queue` object.
let mut objects = Vec::new();
for (i, obj) in board.objects.iter().enumerate() {
let Some(name) = obj.script_name.as_ref() else {
continue;
};
if !scripts.contains_key(name) {
continue;
}
let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new()));
queues.borrow_mut().insert(i, queue.clone());
objects.push(ObjectRuntime {
object_index: i,
script_name: name.clone(),
scope: new_object_scope(board_cell, &queue),
output_queue: queue,
ready_timer: 0.0,
});
}
drop(board);
@@ -209,112 +254,232 @@ impl ScriptHost {
engine,
scripts,
objects,
commands,
board_queue,
errors,
}
}
/// Calls `init()` on every scripted object that defines it.
/// Calls `init()` on every scripted object that defines it, pumping each object's
/// queue afterward.
pub fn run_init(&mut self) {
self.run("init", |c| c.has_init, ());
}
/// Calls `tick(dt)` on every scripted object that defines it, passing the
/// elapsed seconds since the last tick.
/// Calls `tick(dt)` on every scripted object that defines it, passing the elapsed
/// seconds, then pumps each object's queue.
pub fn run_tick(&mut self, dt: f64) {
self.run("tick", |c| c.has_tick, (dt,));
}
/// Removes and returns the commands queued by scripts since the last drain.
pub fn take_commands(&mut self) -> Vec<Command> {
std::mem::take(&mut self.commands.borrow_mut())
}
/// Shared driver for the lifecycle hooks: for each object whose script
/// `defined` reports the hook, call it with `args`, tagging the call with the
/// object index so host functions know the command source. Runtime errors are
/// captured as [`GameCommand::Error`] rather than aborting the batch.
fn run<A: FuncArgs + Copy>(
&mut self,
hook: &str,
defined: fn(&CompiledScript) -> bool,
args: A,
) {
// Split the borrow so we can call the engine while mutating each scope.
/// Calls `bump(id)` on the object at `object_index`, if it defines the hook.
///
/// `bumper` is the index of the object that moved into it, or `-1` for the player.
/// Does **not** pump: any actions the hook emits wait for the next tick's pump, so
/// a bump can't cascade within the same resolution pass.
pub fn run_bump(&mut self, object_index: usize, bumper: i64) {
let Self {
engine,
scripts,
objects,
commands,
errors,
..
} = self;
for obj in objects.iter_mut() {
let Some(compiled) = scripts.get(&obj.script_name) else {
continue;
let Some(obj) = objects.iter_mut().find(|o| o.object_index == object_index) else {
return;
};
let Some(compiled) = scripts.get(&obj.script_name) else {
return;
};
if !compiled.has_bump {
return;
}
let options = CallFnOptions::default().with_tag(object_index as i64);
if let Err(err) =
engine.call_fn_with_options::<()>(options, &mut obj.scope, &compiled.ast, "bump", (bumper,))
{
errors
.borrow_mut()
.push(LogLine::raw(format!("script '{}' bump error: {err}", obj.script_name)));
}
}
/// Counts every object's ready timer down by `dt` (floored at zero). Call once per
/// frame before [`run_tick`](Self::run_tick).
pub fn advance_timers(&mut self, dt: f64) {
for obj in &mut self.objects {
obj.ready_timer = (obj.ready_timer - dt).max(0.0);
}
}
/// Removes and returns the actions promoted onto the board queue.
pub fn take_board_queue(&mut self) -> Vec<BoardAction> {
std::mem::take(&mut self.board_queue.borrow_mut())
}
/// Removes and returns the errors collected since the last drain.
pub fn take_errors(&mut self) -> Vec<LogLine> {
std::mem::take(&mut self.errors.borrow_mut())
}
/// Promotes ready actions from object `i`'s output queue onto the board queue.
///
/// While the object's ready timer is running, nothing is pulled. Otherwise the
/// leading run of zero-cost actions is promoted, followed by at most one timed
/// action (which arms the timer and ends the pump). The board-queue guard keeps a
/// second pump in the same tick from double-promoting.
fn pump(&mut self, i: usize) {
let oid = self.objects[i].object_index;
// Don't re-promote if this object already has actions awaiting resolution.
if self.board_queue.borrow().iter().any(|ba| ba.source == oid) {
return;
}
loop {
// A running cooldown blocks all further pulls (timed or not).
if self.objects[i].ready_timer > 0.0 {
break;
}
let queue = self.objects[i].output_queue.clone();
let cost = match queue.borrow().front() {
Some(action) => action.time_cost(),
None => break, // queue drained
};
if !defined(compiled) {
continue;
let action = queue.borrow_mut().pop_front().expect("front checked above");
if cost > 0.0 {
self.objects[i].ready_timer = cost;
}
// The tag carries this object's index to the host functions.
let options = CallFnOptions::default().with_tag(obj.object_index as i64);
if let Err(err) = engine.call_fn_with_options::<()>(
options,
&mut obj.scope,
&compiled.ast,
hook,
args,
) {
commands.borrow_mut().push(Command {
source: obj.object_index,
kind: GameCommand::Error(format!(
"script '{}' {hook} error: {err}",
obj.script_name
)),
});
self.board_queue.borrow_mut().push(BoardAction { source: oid, action });
// A timed action ends the pump; zero-cost ones keep draining.
if cost > 0.0 {
break;
}
}
}
/// Shared driver for `init`/`tick`: for each object, call the hook if its script
/// `defined` it (tagging the call with the object index so host functions know the
/// source), then pump that object so backlogged actions drain and later objects'
/// `blocked` checks can see earlier movers. Runtime errors are captured rather than
/// aborting the batch.
fn run<A: FuncArgs + Copy>(&mut self, hook: &str, defined: fn(&CompiledScript) -> bool, args: A) {
for i in 0..self.objects.len() {
// Scope the split borrow so it ends before the `pump` reborrow below.
{
let Self {
engine,
scripts,
objects,
errors,
..
} = self;
let obj = &mut objects[i];
if let Some(compiled) = scripts.get(&obj.script_name)
&& defined(compiled)
{
// The tag carries this object's index to the host functions.
let options = CallFnOptions::default().with_tag(obj.object_index as i64);
if let Err(err) =
engine.call_fn_with_options::<()>(options, &mut obj.scope, &compiled.ast, hook, args)
{
errors.borrow_mut().push(LogLine::raw(format!(
"script '{}' {hook} error: {err}",
obj.script_name
)));
}
}
}
self.pump(i);
}
}
}
/// Registers the read-only `Board` API: a [`BoardRef`] type with getters that
/// borrow the shared state briefly.
fn register_read_api(engine: &mut Engine) {
/// Registers the read-only `Board` API (getters plus the `blocked` query).
///
/// The getters operate on the `Board` value in scope; `blocked` instead captures a
/// clone of the world and the board queue, so it can resolve the caller's position and
/// scan pending moves.
fn register_read_api(engine: &mut Engine, board: &BoardRef, board_queue: &BoardQueue) {
engine.register_type_with_name::<BoardRef>("Board");
engine.register_get("player_x", |b: &mut BoardRef| b.borrow().player.x as i64);
engine.register_get("player_y", |b: &mut BoardRef| b.borrow().player.y as i64);
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
let b = board.clone();
let bq = board_queue.clone();
engine.register_fn("blocked", move |ctx: NativeCallContext, dir: Direction| {
is_blocked(&b, &bq, source_of(&ctx), dir)
});
}
/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log`
/// host functions, each enqueuing a [`Command`] tagged with its source object.
fn register_write_api(engine: &mut Engine, commands: &CommandQueue) {
/// Whether the object at `source` would bump something by moving in `dir`.
///
/// Returns `true` if the target cell is off the board, already holds a solid, or is
/// the destination of any pending move on the board queue. The caller's own move is
/// never on the queue yet (its pump runs after its script), so it can't block itself.
///
/// Pending moves are matched by their *immediate* target cell only; a queued move that
/// would push a chain of solids into the cell is not accounted for (an approximation).
fn is_blocked(board: &BoardRef, board_queue: &BoardQueue, source: usize, dir: Direction) -> bool {
let board = board.borrow();
let Some(obj) = board.objects.get(source) else {
return true;
};
let (dx, dy): (i32, i32) = dir.into();
let target = (obj.x as i32 + dx, obj.y as i32 + dy);
if !board.in_bounds(target) {
return true;
}
let (tx, ty) = (target.0 as usize, target.1 as usize);
if board.solid_at(tx, ty).is_some() {
return true;
}
// Any pending move whose immediate target is this cell would put a solid here.
board_queue.borrow().iter().any(|ba| {
if let Action::Move(d) = &ba.action
&& let Some(mover) = board.objects.get(ba.source)
{
let (mdx, mdy): (i32, i32) = (*d).into();
return (mover.x as i32 + mdx, mover.y as i32 + mdy) == target;
}
false
})
}
/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log` host
/// functions, each appending an [`Action`] to the issuing object's output queue.
fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
engine.register_type_with_name::<Direction>("Direction");
let q = commands.clone();
let q = queues.clone();
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| {
q.borrow_mut().push(Command {
source: source_of(&ctx),
kind: GameCommand::Move(dir),
});
emit(&q, source_of(&ctx), Action::Move(dir));
});
let q = commands.clone();
let q = queues.clone();
engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| {
q.borrow_mut().push(Command {
source: source_of(&ctx),
kind: GameCommand::SetTile(tile as u32),
});
emit(&q, source_of(&ctx), Action::SetTile(tile as u32));
});
let q = commands.clone();
engine.register_fn(
"log",
move |ctx: NativeCallContext, msg: ImmutableString| {
q.borrow_mut().push(Command {
source: source_of(&ctx),
kind: GameCommand::Log(LogLine::raw(msg.to_string())),
});
},
);
let q = queues.clone();
engine.register_fn("log", move |ctx: NativeCallContext, msg: ImmutableString| {
emit(&q, source_of(&ctx), Action::Log(LogLine::raw(msg.to_string())));
});
}
/// Registers the `Queue` object: a handle to an object's output queue with `length()`
/// and `clear()`. Safe to mutate from a script because the host only touches these
/// queues between script calls (during [`ScriptHost::pump`]).
fn register_queue_api(engine: &mut Engine) {
engine.register_type_with_name::<ObjQueue>("Queue");
engine.register_fn("length", |q: &mut ObjQueue| q.borrow().len() as i64);
engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear());
}
/// Appends `action` to the output queue of the object identified by `source`.
fn emit(queues: &QueueMap, source: usize, action: Action) {
if let Some(queue) = queues.borrow().get(&source) {
queue.borrow_mut().push_back(action);
}
}
/// Reads the issuing object's index from the call tag (set in [`ScriptHost::run`]).
@@ -325,11 +490,12 @@ fn source_of(ctx: &NativeCallContext) -> usize {
.unwrap_or(0)
}
/// Builds a fresh per-object scope, seeded with the read-only `board` view and
/// the four direction constants (`north`/`south`/`east`/`west`).
fn new_object_scope(board: &BoardRef) -> Scope<'static> {
/// Builds a fresh per-object scope, seeded with the read-only `Board` view, this
/// object's `Queue`, and the four direction constants (`North`/`South`/`East`/`West`).
fn new_object_scope(board: &BoardRef, queue: &ObjQueue) -> Scope<'static> {
let mut scope = Scope::new();
scope.push_constant("Board", board.clone());
scope.push_constant("Queue", queue.clone());
scope.push_constant("North", Direction::North);
scope.push_constant("South", Direction::South);
scope.push_constant("East", Direction::East);