more scripting

This commit is contained in:
2026-06-04 23:52:33 -05:00
parent e0477a12b8
commit 4f21f7fa8a
8 changed files with 558 additions and 121 deletions
+245 -30
View File
@@ -1,9 +1,11 @@
use crate::log::LogLine;
use crate::script::ScriptHost;
use crate::script::{Direction, GameCommand, ScriptHost};
use color::Rgba8;
use serde::{Deserialize, Serialize};
use std::cell::{Ref, RefCell, RefMut};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::rc::Rc;
use std::time::Duration;
/// The visual representation of a single board cell.
@@ -43,6 +45,7 @@ impl Glyph {
/// This is the only hardcoded glyph; all other glyphs come from the map
/// file palette. It will be removed once the player becomes a scripted
/// object with its own palette entry.
#[rustfmt::skip]
pub const fn player() -> Self {
Self {
tile: 64,
@@ -145,6 +148,7 @@ impl Archetype {
///
/// This glyph is used only for new cells created in the editor; existing
/// cells retain their own per-cell glyph.
#[rustfmt::skip]
pub fn default_glyph(&self) -> Glyph {
match self {
Archetype::Empty => Glyph {
@@ -229,6 +233,7 @@ pub struct ObjectDef {
impl ObjectDef {
/// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black.
#[rustfmt::skip]
pub fn default_glyph() -> Glyph {
Glyph {
tile: 63,
@@ -368,7 +373,9 @@ impl Board {
/// Panics if `x` or `y` are out of bounds.
pub fn is_passable(&self, x: usize, y: usize) -> bool {
// An object on this cell blocks if it's unpassable
if let Some(obj) = self.object_at(x, y) && !obj.passable {
if let Some(obj) = self.object_at(x, y)
&& !obj.passable
{
false
} else {
self.get(x, y).1.behavior().passable
@@ -386,16 +393,30 @@ impl Board {
}
}
/// Holds the active game board and provides game-logic operations.
/// The scriptable world: the current [`Board`] plus (in the future) runtime-only
/// state such as a shared variable blackboard.
///
/// `BoardState` is a sibling of the [`ScriptHost`] inside [`GameState`] and is
/// held behind an `Rc<RefCell<…>>`. Keeping it separate from the script engine is
/// what lets host functions hold a shared handle to the world without aliasing the
/// borrow that is currently running the engine.
pub struct BoardState {
/// The active game board.
pub board: Board,
}
/// Holds the active game world and provides game-logic operations.
///
/// `GameState` is the boundary between the engine (rendering, input) and the
/// game data ([`Board`]). It owns the current board, the message log, and the
/// [`ScriptHost`] driving the board's object scripts, and exposes methods for
/// actions that involve game rules: player movement, the per-frame
/// [`tick`](GameState::tick), and the one-time [`run_init`](GameState::run_init).
/// game data. It owns the world ([`BoardState`], behind a shared `Rc<RefCell>`),
/// the message log, and the [`ScriptHost`] driving object scripts. Front-ends and
/// internal logic reach the board through [`board`](GameState::board) /
/// [`board_mut`](GameState::board_mut). Scripts read the board directly and
/// request mutations as commands, applied by [`apply_commands`](GameState::apply_commands)
/// after each script batch.
pub struct GameState {
/// The currently active board.
pub board: Board,
/// The scriptable world, shared with the script host's read getters.
board_state: Rc<RefCell<BoardState>>,
/// The in-game message log, oldest first (newest pushed at the end).
pub log: Vec<LogLine>,
/// The Rhai scripting runtime driving this board's scripted objects.
@@ -409,42 +430,89 @@ impl GameState {
/// call [`GameState::run_init`] once the game is ready to start. Any script
/// compile errors are surfaced into the log here.
pub fn new(board: Board) -> Self {
let scripts = ScriptHost::new(&board);
let board_state = Rc::new(RefCell::new(BoardState { board }));
let scripts = ScriptHost::new(&board_state);
let mut state = Self {
board,
board_state,
log: Vec::new(),
scripts,
};
// Surface any compile-time script errors collected during ScriptHost::new.
state.flush_script_log();
state.apply_commands();
state
}
/// Borrows the active board for reading (e.g. by a front-end renderer).
pub fn board(&self) -> Ref<'_, Board> {
Ref::map(self.board_state.borrow(), |bs| &bs.board)
}
/// Borrows the active board for mutation.
pub fn board_mut(&self) -> RefMut<'_, Board> {
RefMut::map(self.board_state.borrow_mut(), |bs| &mut bs.board)
}
/// Appends a styled message to the log.
pub fn log(&mut self, line: LogLine) {
self.log.push(line);
}
/// Runs the `init()` hook of every scripted object. 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, 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.
pub fn run_init(&mut self) {
self.scripts.run_init();
self.flush_script_log();
self.apply_commands();
}
/// 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.
/// scripted object's `tick(dt)` hook, then applies the commands they queued.
pub fn tick(&mut self, dt: Duration) {
self.scripts.run_tick(dt.as_secs_f64());
self.flush_script_log();
self.apply_commands();
}
/// Moves any messages queued by scripts (via the `log` host function) into
/// the real game log.
fn flush_script_log(&mut self) {
self.log.extend(self.scripts.take_pending());
/// 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;
}
}
GameCommand::Move(dir) => self.move_object(cmd.source, dir),
}
}
}
/// Moves object `idx` one cell in `dir`, if the target is in bounds and
/// passable. This is where movement rules live (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 nx = ox as i32 + dx;
let ny = oy as i32 + dy;
if nx < 0 || ny < 0 {
return;
}
let (nx, ny) = (nx as usize, ny as usize);
if nx < board.width && ny < board.height && board.is_passable(nx, ny) {
let obj = &mut board.objects[idx];
obj.x = nx;
obj.y = ny;
}
}
/// Attempts to move the player by `(dx, dy)` cells.
@@ -452,14 +520,15 @@ impl GameState {
/// The move is ignored if the target cell is out of bounds or its behavior
/// is not passable. No-ops silently (the caller does not need to check).
pub fn try_move(&mut self, dx: i32, dy: i32) {
let nx = self.board.player.x + dx;
let ny = self.board.player.y + dy;
let mut board = self.board_mut();
let nx = board.player.x + dx;
let ny = board.player.y + dy;
if nx >= 0 && ny >= 0 {
let nx = nx as usize;
let ny = ny as usize;
if nx < self.board.width && ny < self.board.height && self.board.is_passable(nx, ny) {
self.board.player.x = nx as i32;
self.board.player.y = ny as i32;
if nx < board.width && ny < board.height && board.is_passable(nx, ny) {
board.player.x = nx as i32;
board.player.y = ny as i32;
}
}
}
@@ -492,6 +561,44 @@ mod tests {
}
}
/// An `ObjectDef` at `(x, y)` bound to the named script.
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
let mut o = ObjectDef::new(x, y);
o.script_name = Some(script.to_string());
o
}
/// Builds an all-empty (passable) `w×h` board with the given player position,
/// objects, and `(name, source)` scripts — room for movement, unlike the 1×1
/// `board_with_object`.
fn open_board(
w: usize,
h: usize,
player: (i32, i32),
objects: Vec<ObjectDef>,
scripts: &[(&str, &str)],
) -> Board {
Board {
name: "test".into(),
width: w,
height: h,
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
player: Player {
x: player.0,
y: player.1,
},
objects,
portals: Vec::new(),
font: None,
zoom: 1,
scripts: scripts
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
board_script_name: None,
}
}
/// Flattens each log line into a single string for easy assertions.
fn log_texts(game: &GameState) -> Vec<String> {
game.log
@@ -502,7 +609,10 @@ mod tests {
#[test]
fn init_runs_only_on_run_init_not_at_construction() {
let board = board_with_object(Some("greet"), &[("greet", r#"fn init() { log("hello"); }"#)]);
let board = board_with_object(
Some("greet"),
&[("greet", r#"fn init() { log("hello"); }"#)],
);
let mut game = GameState::new(board);
// init must not fire during construction / deserialization.
assert!(game.log.is_empty());
@@ -538,8 +648,10 @@ mod tests {
assert!(game.log.is_empty());
// Script defines neither init nor tick: also a no-op.
let mut game =
GameState::new(board_with_object(Some("e"), &[("e", "fn other() { log(\"x\"); }")]));
let mut game = GameState::new(board_with_object(
Some("e"),
&[("e", "fn other() { log(\"x\"); }")],
));
game.run_init();
game.tick(Duration::from_millis(33));
assert!(game.log.is_empty());
@@ -555,4 +667,107 @@ mod tests {
let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")]));
assert!(log_texts(&game)[0].contains("failed to compile"));
}
#[test]
fn script_reads_board_through_view() {
let board = open_board(
5,
3,
(3, 1),
vec![scripted_object(2, 1, "r")],
&[("r", "fn init() { log(board.player_x.to_string()); }")],
);
let mut game = GameState::new(board);
game.run_init();
// The view reported the player's x (3).
assert_eq!(log_texts(&game), vec!["3"]);
}
#[test]
fn move_command_relocates_the_source_object() {
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(2, 1, "m")],
&[("m", "fn init() { move(east); }")],
);
let mut game = GameState::new(board);
game.run_init();
let b = game.board();
// East increments x by one; the object started at (2, 1).
assert_eq!((b.objects[0].x, b.objects[0].y), (3, 1));
}
#[test]
fn move_into_a_wall_or_edge_is_a_noop() {
// Object at the west edge moving west: out of bounds, ignored.
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(0, 1, "m")],
&[("m", "fn init() { move(west); }")],
);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].x, 0);
}
#[test]
fn set_tile_command_changes_the_source_glyph() {
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(2, 1, "s")],
&[("s", "fn init() { set_tile(7); }")],
);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].glyph.tile, 7);
}
#[test]
fn start_map_greeter_runs_init() {
// End-to-end against the shipped example map: load it, run init, and
// confirm the greeter read the board (interpolated message) and wrote to
// itself (set_tile). Also guards the example from drifting out of sync.
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml");
let board = crate::map_file::load(path).expect("load start.toml");
let mut game = GameState::new(board);
game.run_init();
assert!(
log_texts(&game)
.iter()
.any(|t| t.contains("hello from object")),
"greeter init should log a greeting"
);
assert!(
game.board().objects.iter().any(|o| o.glyph.tile == 2),
"greeter set_tile(2) should change its glyph"
);
}
#[test]
fn commands_are_routed_to_their_own_source_object() {
// Two objects with different scripts move in opposite directions; each
// must affect only itself (the per-call tag routes the command source).
// Targets are kept apart so the impassable objects don't block each other.
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
&[
("e", "fn init() { move(east); }"),
("w", "fn init() { move(west); }"),
],
);
let mut game = GameState::new(board);
game.run_init();
let b = game.board();
assert_eq!(b.objects[0].x, 1); // moved east from 0
assert_eq!(b.objects[1].x, 3); // moved west from 4
}
}
+49 -10
View File
@@ -152,16 +152,27 @@ pub(crate) struct MapFileObjectEntry {
script_name: Option<String>,
}
fn default_true() -> bool { true }
fn default_zoom() -> u32 { 1 }
fn is_zoom_default(z: &u32) -> bool { *z == 1 }
fn default_true() -> bool {
true
}
fn default_zoom() -> u32 {
1
}
fn is_zoom_default(z: &u32) -> bool {
*z == 1
}
/// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`].
/// Returns opaque black on any parse failure.
fn parse_color(hex: &str) -> Rgba8 {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Rgba8 { r: 0, g: 0, b: 0, a: 255 };
return Rgba8 {
r: 0,
g: 0,
b: 0,
a: 255,
};
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
@@ -475,8 +486,14 @@ content = """
assert!(result.is_err());
// Use .err().unwrap() instead of .unwrap_err() to avoid requiring Board: Debug.
let msg = result.err().unwrap();
assert!(msg.contains("2 rows"), "expected row count in error, got: {msg}");
assert!(msg.contains("height = 3"), "expected declared height in error, got: {msg}");
assert!(
msg.contains("2 rows"),
"expected row count in error, got: {msg}"
);
assert!(
msg.contains("height = 3"),
"expected declared height in error, got: {msg}"
);
}
#[test]
@@ -487,8 +504,14 @@ content = """
let result = Board::try_from(mf);
assert!(result.is_err());
let msg = result.err().unwrap();
assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}");
assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}");
assert!(
msg.contains("3 characters"),
"expected col count in error, got: {msg}"
);
assert!(
msg.contains("width = 4"),
"expected declared width in error, got: {msg}"
);
}
#[test]
@@ -549,8 +572,24 @@ bg = "#000000"
assert_eq!(obj.x, 1);
assert_eq!(obj.y, 1);
assert_eq!(obj.glyph.tile, 64);
assert_eq!(obj.glyph.fg, Rgba8 { r: 0x00, g: 0xFF, b: 0xFF, a: 255 });
assert_eq!(obj.glyph.bg, Rgba8 { r: 0, g: 0, b: 0, a: 255 });
assert_eq!(
obj.glyph.fg,
Rgba8 {
r: 0x00,
g: 0xFF,
b: 0xFF,
a: 255
}
);
assert_eq!(
obj.glyph.bg,
Rgba8 {
r: 0,
g: 0,
b: 0,
a: 255
}
);
// Save back to TOML and reload; glyph must survive.
let map_file = MapFile::from(&board);
+217 -54
View File
@@ -7,21 +7,84 @@
//! - `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`]).
//!
//! Scripts have one host function for now: `log(s)`, which appends `s` to the
//! game log. Because Rhai's registered closures must be `'static`, `log` writes
//! into a shared [`LogSink`] that [`ScriptHost::take_pending`] drains; the caller
//! ([`crate::game::GameState`]) then moves those lines into the real log. This
//! keeps script execution from needing a mutable borrow of the game state.
//! ## Reads vs. writes
//!
//! Scripts **read** the world directly through a read-only [`BoardView`] (getters
//! only) pushed into each scope as `board`, e.g. `board.player_x`. The view holds
//! an `Rc<RefCell<BoardState>>` and borrows it briefly per getter.
//!
//! 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.
//!
//! [`GameState`]: crate::game::GameState
use crate::game::Board;
use crate::game::BoardState;
use crate::log::LogLine;
use rhai::{Engine, FuncArgs, ImmutableString, Scope, AST};
use rhai::{AST, CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope};
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
/// Shared buffer the Rhai `log` function appends to, drained into the game log.
type LogSink = Rc<RefCell<Vec<LogLine>>>;
/// Shared queue the write host functions push into; drained by [`ScriptHost::take_commands`].
type CommandQueue = Rc<RefCell<Vec<Command>>>;
/// 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 {
/// 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),
}
/// A cardinal movement direction.
#[derive(Clone, Copy)]
pub enum Direction {
North,
South,
East,
West,
}
impl From<Direction> for (i32, i32) {
/// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down).
fn from(d: Direction) -> Self {
match d {
Direction::North => (0, -1),
Direction::South => (0, 1),
Direction::East => (1, 0),
Direction::West => (-1, 0),
}
}
}
/// A read-only handle to the world, exposed to scripts as `board`. Holds a shared
/// reference to the [`BoardState`]; only getters are registered, so it is
/// read-only by construction.
#[derive(Clone)]
struct BoardView(Rc<RefCell<BoardState>>);
/// A compiled script plus which lifecycle hooks it defines.
struct CompiledScript {
@@ -36,49 +99,54 @@ struct CompiledScript {
/// The runtime state of one scripted object: which script it runs and its own
/// persistent Rhai scope.
struct ObjectRuntime {
/// Index into [`Board::objects`]. Unused for now; kept as the seam for
/// giving scripts access to their own object.
#[allow(dead_code)]
/// 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.
object_index: usize,
/// Key into [`ScriptHost::scripts`] naming this object's compiled script.
script_name: String,
/// Per-object scope, so injected/object-local state can persist across calls.
/// Per-object scope (holds the `board` view + direction constants, and can
/// hold object-local state in the future). Persists across calls.
scope: Scope<'static>,
}
/// Owns the Rhai engine and per-object script state for a board.
pub struct ScriptHost {
/// The Rhai engine, with host functions (`log`) registered.
/// The Rhai engine, with the read getters and write host functions registered.
engine: Engine,
/// Compiled scripts, keyed by script name; compiled once per referenced name.
scripts: HashMap<String, CompiledScript>,
/// One entry per object that has a successfully compiled script.
objects: Vec<ObjectRuntime>,
/// Buffer the `log` host function writes to; drained by [`Self::take_pending`].
log_sink: LogSink,
/// Queue the write host functions push into; drained by [`Self::take_commands`].
commands: CommandQueue,
}
impl ScriptHost {
/// Builds a host for `board`: registers host functions, 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 reported as
/// log lines (retrievable via [`Self::take_pending`]); no script is run here.
pub fn new(board: &Board) -> Self {
let log_sink: LogSink = Rc::new(RefCell::new(Vec::new()));
/// 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.
pub fn new(state: &Rc<RefCell<BoardState>>) -> Self {
let commands: CommandQueue = Rc::new(RefCell::new(Vec::new()));
let mut engine = Engine::new();
// `log(s)` — append a plain (uncolored) message to the game log.
{
let sink = log_sink.clone();
engine.register_fn("log", move |msg: ImmutableString| {
sink.borrow_mut().push(LogLine::raw(msg.to_string()));
});
}
register_read_api(&mut engine);
register_write_api(&mut engine, &commands);
// Compile each script that an object actually references, once.
let board_state = state.borrow();
let board = &board_state.board;
// Compile each referenced script once; attribute failures to the first
// object that references the script.
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
for name in board.objects.iter().filter_map(|o| o.script_name.as_ref()) {
if scripts.contains_key(name) {
let mut failed: HashSet<String> = HashSet::new();
for (i, obj) in board.objects.iter().enumerate() {
let Some(name) = obj.script_name.as_ref() else {
continue;
};
if scripts.contains_key(name) || failed.contains(name) {
continue;
}
match board.scripts.get(name) {
@@ -100,13 +168,25 @@ impl ScriptHost {
},
);
}
Err(err) => log_sink.borrow_mut().push(LogLine::raw(format!(
"script '{name}' failed to compile: {err}"
))),
Err(err) => {
failed.insert(name.clone());
commands.borrow_mut().push(Command {
source: i,
kind: GameCommand::Error(format!(
"script '{name}' failed to compile: {err}"
)),
});
}
},
None => log_sink
.borrow_mut()
.push(LogLine::raw(format!("object references unknown script '{name}'"))),
None => {
failed.insert(name.clone());
commands.borrow_mut().push(Command {
source: i,
kind: GameCommand::Error(format!(
"object references unknown script '{name}'"
)),
});
}
}
}
@@ -115,21 +195,23 @@ impl ScriptHost {
.objects
.iter()
.enumerate()
.filter_map(|(i, o)| {
let name = o.script_name.as_ref()?;
.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: Scope::new(),
scope: new_object_scope(state),
})
})
.collect();
drop(board_state);
Self {
engine,
scripts,
objects,
log_sink,
commands,
}
}
@@ -144,14 +226,15 @@ impl ScriptHost {
self.run("tick", |c| c.has_tick, (dt,));
}
/// Removes and returns the messages queued by `log` since the last drain.
pub fn take_pending(&mut self) -> Vec<LogLine> {
std::mem::take(&mut *self.log_sink.borrow_mut())
/// 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`. Runtime errors are
/// captured into the log sink rather than aborting the batch.
/// `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,
@@ -163,7 +246,7 @@ impl ScriptHost {
engine,
scripts,
objects,
log_sink,
commands,
} = self;
for obj in objects.iter_mut() {
let Some(compiled) = scripts.get(&obj.script_name) else {
@@ -172,12 +255,92 @@ impl ScriptHost {
if !defined(compiled) {
continue;
}
if let Err(err) = engine.call_fn::<()>(&mut obj.scope, &compiled.ast, hook, args) {
log_sink.borrow_mut().push(LogLine::raw(format!(
"script '{}' {hook} error: {err}",
obj.script_name
)));
// 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
)),
});
}
}
}
}
/// Registers the read-only `board` API: a [`BoardView`] type with getters that
/// borrow the shared state briefly.
fn register_read_api(engine: &mut Engine) {
engine.register_type_with_name::<BoardView>("BoardView");
engine.register_get("player_x", |b: &mut BoardView| {
b.0.borrow().board.player.x as i64
});
engine.register_get("player_y", |b: &mut BoardView| {
b.0.borrow().board.player.y as i64
});
engine.register_get("width", |b: &mut BoardView| b.0.borrow().board.width as i64);
engine.register_get("height", |b: &mut BoardView| {
b.0.borrow().board.height as i64
});
}
/// 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) {
engine.register_type_with_name::<Direction>("Direction");
let q = commands.clone();
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| {
q.borrow_mut().push(Command {
source: source_of(&ctx),
kind: GameCommand::Move(dir),
});
});
let q = commands.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),
});
});
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())),
});
},
);
}
/// Reads the issuing object's index from the call tag (set in [`ScriptHost::run`]).
fn source_of(ctx: &NativeCallContext) -> usize {
ctx.tag()
.and_then(|t| t.as_int().ok())
.and_then(|n| usize::try_from(n).ok())
.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(state: &Rc<RefCell<BoardState>>) -> Scope<'static> {
let mut scope = Scope::new();
scope.push_constant("board", BoardView(state.clone()));
scope.push_constant("north", Direction::North);
scope.push_constant("south", Direction::South);
scope.push_constant("east", Direction::East);
scope.push_constant("west", Direction::West);
scope
}