more scripting
This commit is contained in:
+217
-54
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user