basic scripting
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
//! 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
|
||||
//! 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`]).
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use crate::game::Board;
|
||||
use crate::log::LogLine;
|
||||
use rhai::{Engine, FuncArgs, ImmutableString, Scope, AST};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Shared buffer the Rhai `log` function appends to, drained into the game log.
|
||||
type LogSink = 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).
|
||||
ast: AST,
|
||||
/// Whether the script defines `fn init()` (zero parameters).
|
||||
has_init: bool,
|
||||
/// Whether the script defines `fn tick(dt)` (one parameter).
|
||||
has_tick: bool,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
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.
|
||||
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.
|
||||
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,
|
||||
}
|
||||
|
||||
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()));
|
||||
|
||||
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()));
|
||||
});
|
||||
}
|
||||
|
||||
// Compile each script that an object actually references, once.
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
match board.scripts.get(name) {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(err) => log_sink.borrow_mut().push(LogLine::raw(format!(
|
||||
"script '{name}' failed to compile: {err}"
|
||||
))),
|
||||
},
|
||||
None => log_sink
|
||||
.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, o)| {
|
||||
let name = o.script_name.as_ref()?;
|
||||
scripts.contains_key(name).then(|| ObjectRuntime {
|
||||
object_index: i,
|
||||
script_name: name.clone(),
|
||||
scope: Scope::new(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
engine,
|
||||
scripts,
|
||||
objects,
|
||||
log_sink,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls `init()` on every scripted object that defines it.
|
||||
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.
|
||||
pub fn run_tick(&mut self, dt: f64) {
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
let Self {
|
||||
engine,
|
||||
scripts,
|
||||
objects,
|
||||
log_sink,
|
||||
} = self;
|
||||
for obj in objects.iter_mut() {
|
||||
let Some(compiled) = scripts.get(&obj.script_name) else {
|
||||
continue;
|
||||
};
|
||||
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
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user