1081 lines
38 KiB
Rust
1081 lines
38 KiB
Rust
//! 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 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`].
|
|
//!
|
|
//! ## Script constants
|
|
//!
|
|
//! Object-specific handles live in the per-object scope (visible to the
|
|
//! top-level hook Rust calls):
|
|
//!
|
|
//! | Name | Type | Description |
|
|
//! |---|---|---|
|
|
//! | `Board` | `Board` | Read-only world handle. |
|
|
//! | `Queue` | `Queue` | This object's pending-action queue. |
|
|
//! | `Me` | `Me` | Self-reference (id, name, x, y, tags, glyph). |
|
|
//! | `Registry` | `Registry` | Board-scoped key→value store persisting across board transitions. |
|
|
//!
|
|
//! Direction and color constants are registered as a global Rhai module and
|
|
//! are visible to all functions at any call depth, including Rhai-to-Rhai calls:
|
|
//!
|
|
//! | Name | Type | Description |
|
|
//! |---|---|---|
|
|
//! | `North`/`South`/`East`/`West` | `Direction` | Cardinal directions. |
|
|
//! | `Black`..`White` | `String` | 16 EGA/VGA color hex strings. |
|
|
//!
|
|
//! ## Board read API (`Board.*`)
|
|
//!
|
|
//! - `Board.player_x / player_y / width / height` — world properties
|
|
//! - `Board.tagged(tag) -> Array[ObjectInfo]` — objects carrying a tag
|
|
//! - `Board.named(name) -> ObjectInfo | ()` — object with that name (or unit)
|
|
//! - `Board.get(id) -> ObjectInfo | ()` — look up by id; `-1` returns player info
|
|
//!
|
|
//! ## Self-reference API (`Me.*`)
|
|
//!
|
|
//! - `Me.id / name / x / y` — getters
|
|
//! - `Me.has_tag(s) -> bool`
|
|
//! - `Me.tags() -> Array`
|
|
//! - `Me.glyph() -> Glyph` (`Glyph.tile`, `Glyph.fg`, `Glyph.bg`)
|
|
//!
|
|
//! ## Write functions
|
|
//!
|
|
//! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`,
|
|
//! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`,
|
|
//! `send(target_id, fn_name [, arg])`, `teleport(x, y)`
|
|
//!
|
|
//! ## Queue API
|
|
//!
|
|
//! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()`
|
|
|
|
use crate::action::{Action, MOVE_COST, ScrollLine, action_to_map};
|
|
use crate::board::Board;
|
|
use crate::game::SAY_DURATION;
|
|
use crate::log::LogLine;
|
|
use crate::map_file::{color_to_hex, parse_color};
|
|
use crate::object_def::ObjectDef;
|
|
use crate::utils::{Direction, ObjectId, RegistryValue, ScriptArg};
|
|
use rhai::{
|
|
AST, Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module,
|
|
NativeCallContext, Scope,
|
|
};
|
|
use std::cell::RefCell;
|
|
use std::collections::{HashMap, HashSet, VecDeque};
|
|
use std::rc::Rc;
|
|
|
|
/// An action promoted from an object's output queue onto the board queue, tagged
|
|
/// with the object that issued it.
|
|
pub(crate) struct BoardAction {
|
|
/// The stable [`ObjectId`] of the object that issued the action.
|
|
pub(crate) source: ObjectId,
|
|
/// The action to apply.
|
|
pub(crate) action: Action,
|
|
}
|
|
|
|
/// A read-only handle to the world, exposed to scripts as `Board`.
|
|
type BoardRef = Rc<RefCell<Board>>;
|
|
|
|
/// A single object's output queue.
|
|
type ObjQueue = Rc<RefCell<VecDeque<Action>>>;
|
|
|
|
/// The board queue: actions promoted from object queues, awaiting resolution.
|
|
type BoardQueue = Rc<RefCell<Vec<BoardAction>>>;
|
|
|
|
/// Maps an [`ObjectId`] to its output queue.
|
|
type QueueMap = Rc<RefCell<HashMap<ObjectId, ObjQueue>>>;
|
|
|
|
/// Channel for engine/compile/runtime errors.
|
|
type ErrorSink = Rc<RefCell<Vec<LogLine>>>;
|
|
|
|
/// A compiled script plus which lifecycle hooks it defines.
|
|
struct CompiledScript {
|
|
ast: AST,
|
|
has_init: bool,
|
|
has_tick: bool,
|
|
has_bump: bool,
|
|
}
|
|
|
|
/// The runtime state of one scripted object.
|
|
struct ObjectRuntime {
|
|
object_id: ObjectId,
|
|
/// Key into [`ScriptHost::scripts`] for this object's compiled script — the
|
|
/// pool name for a named script, or the source text for an embedded built-in.
|
|
script_key: String,
|
|
scope: Scope<'static>,
|
|
output_queue: ObjQueue,
|
|
}
|
|
|
|
/// The compile-key for an object's script: the embedded source itself for a
|
|
/// built-in (so identical built-ins share one compiled AST), or the world-pool
|
|
/// name for a named script. `None` if the object has no script.
|
|
fn script_key(obj: &ObjectDef) -> Option<String> {
|
|
if let Some(src) = obj.builtin_script {
|
|
Some(src.to_string())
|
|
} else {
|
|
obj.script_name.clone()
|
|
}
|
|
}
|
|
|
|
// ── Rhai types exposed to scripts ────────────────────────────────────────────
|
|
|
|
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
|
|
/// and `Board.get`. Passed by value — scripts read fields, not a live reference.
|
|
#[derive(Clone)]
|
|
struct ObjectInfo {
|
|
id: i64,
|
|
x: i64,
|
|
y: i64,
|
|
name: Option<String>,
|
|
tags: Vec<String>,
|
|
}
|
|
|
|
/// The glyph (tile + colors) of a board object, returned by `Me.glyph()`.
|
|
/// Colors are `"#RRGGBB"` hex strings matching the map-file format.
|
|
#[derive(Clone)]
|
|
struct RhaiGlyph {
|
|
tile: i64,
|
|
fg: String,
|
|
bg: String,
|
|
}
|
|
|
|
/// A script's read-only reference to itself, pushed into scope as the constant `Me`.
|
|
/// Holds the object id and a shared board handle so getters can look up live values.
|
|
#[derive(Clone)]
|
|
struct Me {
|
|
object_id: ObjectId,
|
|
board: BoardRef,
|
|
}
|
|
|
|
/// The board's script registry, pushed into scope as the constant `Registry`.
|
|
/// `get`/`set`/`get_or` methods let scripts read and write per-board key→value pairs
|
|
/// that persist across board transitions. Mutation goes through the inner
|
|
/// `Rc<RefCell<Board>>`, so the struct itself can be shared as a scope constant.
|
|
#[derive(Clone)]
|
|
struct Registry {
|
|
board: BoardRef,
|
|
}
|
|
|
|
// ── ObjectInfo helper ─────────────────────────────────────────────────────────
|
|
|
|
fn object_info_from(id: ObjectId, board: &Board) -> Option<ObjectInfo> {
|
|
let obj = board.objects.get(&id)?;
|
|
Some(ObjectInfo {
|
|
id: id as i64,
|
|
x: obj.x as i64,
|
|
y: obj.y as i64,
|
|
name: obj.name.clone(),
|
|
tags: obj.tags.iter().cloned().collect(),
|
|
})
|
|
}
|
|
|
|
fn object_info_player(board: &Board) -> ObjectInfo {
|
|
ObjectInfo {
|
|
id: -1,
|
|
x: board.player.x as i64,
|
|
y: board.player.y as i64,
|
|
name: None,
|
|
tags: Vec::new(),
|
|
}
|
|
}
|
|
|
|
// ── ScriptHost ────────────────────────────────────────────────────────────────
|
|
|
|
/// Owns the Rhai engine and per-object script state for a board.
|
|
pub struct ScriptHost {
|
|
engine: Engine,
|
|
scripts: HashMap<String, CompiledScript>,
|
|
objects: Vec<ObjectRuntime>,
|
|
board_queue: BoardQueue,
|
|
errors: ErrorSink,
|
|
}
|
|
|
|
impl ScriptHost {
|
|
/// Builds a host for the board behind `board_cell`: registers the full script 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; no script is run here.
|
|
///
|
|
/// `scripts` is the world-level script pool (script name → Rhai source); it is
|
|
/// read only during construction and not retained afterward.
|
|
pub fn new(board_cell: &BoardRef, script_sources: &HashMap<String, String>) -> Self {
|
|
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, board_cell, &board_queue, &errors);
|
|
register_write_api(&mut engine, &queues);
|
|
register_queue_api(&mut engine);
|
|
register_me_type(&mut engine);
|
|
register_object_info_type(&mut engine);
|
|
register_glyph_type(&mut engine);
|
|
register_registry_type(&mut engine);
|
|
register_global_constants(&mut engine);
|
|
|
|
let board = board_cell.borrow();
|
|
|
|
// Compile each referenced script once, keyed by `script_key` (pool name for
|
|
// named scripts, source text for embedded built-ins so identical ones share
|
|
// one AST).
|
|
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
|
|
let mut failed: HashSet<String> = HashSet::new();
|
|
for obj in board.objects.values() {
|
|
let Some(key) = script_key(obj) else {
|
|
continue;
|
|
};
|
|
if scripts.contains_key(&key) || failed.contains(&key) {
|
|
continue;
|
|
}
|
|
// Source: the embedded built-in, or a lookup in the world script pool.
|
|
let source: &str = if let Some(src) = obj.builtin_script {
|
|
src
|
|
} else {
|
|
match script_sources.get(&key) {
|
|
Some(src) => src,
|
|
None => {
|
|
failed.insert(key.clone());
|
|
errors.borrow_mut().push(LogLine::error(format!(
|
|
"object references unknown script '{key}'"
|
|
)));
|
|
continue;
|
|
}
|
|
}
|
|
};
|
|
match engine.compile(source) {
|
|
Ok(ast) => {
|
|
let defines = |n: &str, params: usize| {
|
|
ast.iter_functions()
|
|
.any(|f| f.name == n && f.params.len() == params)
|
|
};
|
|
scripts.insert(
|
|
key,
|
|
CompiledScript {
|
|
has_init: defines("init", 0),
|
|
has_tick: defines("tick", 1),
|
|
has_bump: defines("bump", 1),
|
|
ast,
|
|
},
|
|
);
|
|
}
|
|
Err(err) => {
|
|
failed.insert(key.clone());
|
|
errors.borrow_mut().push(LogLine::error(format!(
|
|
"script '{key}' failed to compile: {err}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// One runtime per object whose script compiled.
|
|
let mut objects = Vec::new();
|
|
for (&id, obj) in board.objects.iter() {
|
|
let Some(key) = script_key(obj) else {
|
|
continue;
|
|
};
|
|
if !scripts.contains_key(&key) {
|
|
continue;
|
|
}
|
|
let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new()));
|
|
queues.borrow_mut().insert(id, queue.clone());
|
|
objects.push(ObjectRuntime {
|
|
object_id: id,
|
|
script_key: key,
|
|
scope: new_object_scope(board_cell, &queue, id),
|
|
output_queue: queue,
|
|
});
|
|
}
|
|
|
|
drop(board);
|
|
Self {
|
|
engine,
|
|
scripts,
|
|
objects,
|
|
board_queue,
|
|
errors,
|
|
}
|
|
}
|
|
|
|
/// Calls `init()` on every scripted object that defines it and drains each queue.
|
|
pub fn run_init(&mut self) {
|
|
self.run("init", |c| c.has_init, (), 0.0);
|
|
}
|
|
|
|
/// Calls `tick(dt)` on every scripted object that defines it, then drains queues.
|
|
pub fn run_tick(&mut self, dt: f64) {
|
|
self.run("tick", |c| c.has_tick, (dt,), dt);
|
|
}
|
|
|
|
/// Calls `bump(id)` on the object with [`ObjectId`] `object_id`, if it defines
|
|
/// the hook. After the hook, drains the object's queue with `dt = 0`.
|
|
pub fn run_bump(&mut self, object_id: ObjectId, bumper: i64) {
|
|
let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else {
|
|
return;
|
|
};
|
|
{
|
|
let Self {
|
|
engine,
|
|
scripts,
|
|
objects,
|
|
errors,
|
|
..
|
|
} = self;
|
|
let obj = &mut objects[i];
|
|
let Some(compiled) = scripts.get(&obj.script_key) else {
|
|
return;
|
|
};
|
|
if !compiled.has_bump {
|
|
return;
|
|
}
|
|
let options = CallFnOptions::default().with_tag(object_id as i64);
|
|
if let Err(err) = engine.call_fn_with_options::<()>(
|
|
options,
|
|
&mut obj.scope,
|
|
&compiled.ast,
|
|
"bump",
|
|
(bumper,),
|
|
) {
|
|
errors.borrow_mut().push(LogLine::error(format!(
|
|
"script '{}' bump error: {err}",
|
|
obj.script_key
|
|
)));
|
|
}
|
|
}
|
|
self.drain(i, 0.0);
|
|
}
|
|
|
|
/// Calls the named function on the object with [`ObjectId`] `target_id`.
|
|
///
|
|
/// If `arg` is `Some` and the function accepts one parameter, the arg is passed.
|
|
/// If the function accepts zero parameters (or arg is `None`), it is called with
|
|
/// no args. If neither arity exists, the call is silently skipped.
|
|
/// After the call, drains the object's queue with `dt = 0`.
|
|
pub(crate) fn run_send(&mut self, target_id: ObjectId, fn_name: &str, arg: Option<ScriptArg>) {
|
|
let Some(i) = self.objects.iter().position(|o| o.object_id == target_id) else {
|
|
return;
|
|
};
|
|
{
|
|
let Self {
|
|
engine,
|
|
scripts,
|
|
objects,
|
|
errors,
|
|
..
|
|
} = self;
|
|
let obj = &mut objects[i];
|
|
let Some(compiled) = scripts.get(&obj.script_key) else {
|
|
return;
|
|
};
|
|
|
|
let has_1 = compiled
|
|
.ast
|
|
.iter_functions()
|
|
.any(|f| f.name == fn_name && f.params.len() == 1);
|
|
let has_0 = compiled
|
|
.ast
|
|
.iter_functions()
|
|
.any(|f| f.name == fn_name && f.params.is_empty());
|
|
|
|
let options = CallFnOptions::default().with_tag(obj.object_id as i64);
|
|
let result = if has_1 {
|
|
// Call with arg (or unit if arg is absent).
|
|
let dyn_arg: Dynamic = match &arg {
|
|
Some(ScriptArg::Str(s)) => Dynamic::from(s.clone()),
|
|
Some(ScriptArg::Num(n)) => Dynamic::from(*n),
|
|
None => Dynamic::UNIT,
|
|
};
|
|
engine.call_fn_with_options::<()>(
|
|
options,
|
|
&mut obj.scope,
|
|
&compiled.ast,
|
|
fn_name,
|
|
(dyn_arg,),
|
|
)
|
|
} else if has_0 {
|
|
engine.call_fn_with_options::<()>(
|
|
options,
|
|
&mut obj.scope,
|
|
&compiled.ast,
|
|
fn_name,
|
|
(),
|
|
)
|
|
} else {
|
|
return; // Function not defined on this object
|
|
};
|
|
|
|
if let Err(err) = result {
|
|
errors.borrow_mut().push(LogLine::error(format!(
|
|
"script '{}' send('{}') error: {err}",
|
|
obj.script_key, fn_name
|
|
)));
|
|
}
|
|
}
|
|
self.drain(i, 0.0);
|
|
}
|
|
|
|
/// Removes and returns the actions promoted onto the board queue.
|
|
pub(crate) 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())
|
|
}
|
|
|
|
/// Drains object `i`'s output queue onto the board queue, advancing inline
|
|
/// [`Action::Delay`]s by `dt`.
|
|
fn drain(&mut self, i: usize, mut dt: f64) {
|
|
let oid = self.objects[i].object_id;
|
|
loop {
|
|
let queue = self.objects[i].output_queue.clone();
|
|
let is_delay = matches!(queue.borrow().front(), Some(Action::Delay(_)));
|
|
if is_delay {
|
|
let expired = {
|
|
let mut q = queue.borrow_mut();
|
|
let Some(Action::Delay(r)) = q.front_mut() else {
|
|
break;
|
|
};
|
|
*r -= dt;
|
|
dt = 0.0;
|
|
*r <= 0.0
|
|
};
|
|
if expired {
|
|
queue.borrow_mut().pop_front();
|
|
} else {
|
|
break;
|
|
}
|
|
} else {
|
|
let Some(action) = queue.borrow_mut().pop_front() else {
|
|
break;
|
|
};
|
|
self.board_queue.borrow_mut().push(BoardAction {
|
|
source: oid,
|
|
action,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Shared driver for `init`/`tick`.
|
|
fn run<A: FuncArgs + Copy>(
|
|
&mut self,
|
|
hook: &str,
|
|
defined: fn(&CompiledScript) -> bool,
|
|
args: A,
|
|
drain_dt: f64,
|
|
) {
|
|
for i in 0..self.objects.len() {
|
|
{
|
|
let Self {
|
|
engine,
|
|
scripts,
|
|
objects,
|
|
errors,
|
|
..
|
|
} = self;
|
|
let obj = &mut objects[i];
|
|
if let Some(compiled) = scripts.get(&obj.script_key)
|
|
&& defined(compiled)
|
|
{
|
|
let options = CallFnOptions::default().with_tag(obj.object_id as i64);
|
|
if let Err(err) = engine.call_fn_with_options::<()>(
|
|
options,
|
|
&mut obj.scope,
|
|
&compiled.ast,
|
|
hook,
|
|
args,
|
|
) {
|
|
errors.borrow_mut().push(LogLine::error(format!(
|
|
"script '{}' {hook} error: {err}",
|
|
obj.script_key
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
self.drain(i, drain_dt);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Register Me type ──────────────────────────────────────────────────────────
|
|
|
|
fn register_me_type(engine: &mut Engine) {
|
|
engine.register_type_with_name::<Me>("Me");
|
|
|
|
engine.register_get("id", |me: &mut Me| me.object_id as i64);
|
|
|
|
engine.register_get("name", |me: &mut Me| {
|
|
me.board
|
|
.borrow()
|
|
.objects
|
|
.get(&me.object_id)
|
|
.and_then(|o| o.name.as_deref())
|
|
.unwrap_or("")
|
|
.to_string()
|
|
});
|
|
|
|
engine.register_get("x", |me: &mut Me| {
|
|
me.board
|
|
.borrow()
|
|
.objects
|
|
.get(&me.object_id)
|
|
.map(|o| o.x as i64)
|
|
.unwrap_or(0)
|
|
});
|
|
|
|
engine.register_get("y", |me: &mut Me| {
|
|
me.board
|
|
.borrow()
|
|
.objects
|
|
.get(&me.object_id)
|
|
.map(|o| o.y as i64)
|
|
.unwrap_or(0)
|
|
});
|
|
|
|
engine.register_fn("has_tag", |me: &mut Me, tag: ImmutableString| {
|
|
me.board
|
|
.borrow()
|
|
.objects
|
|
.get(&me.object_id)
|
|
.is_some_and(|o| o.tags.contains(tag.as_str()))
|
|
});
|
|
|
|
engine.register_fn("tags", |me: &mut Me| -> rhai::Array {
|
|
me.board
|
|
.borrow()
|
|
.objects
|
|
.get(&me.object_id)
|
|
.map(|o| o.tags.iter().map(|t| Dynamic::from(t.clone())).collect())
|
|
.unwrap_or_default()
|
|
});
|
|
|
|
engine.register_fn("glyph", |me: &mut Me| -> RhaiGlyph {
|
|
let board = me.board.borrow();
|
|
if let Some(obj) = board.objects.get(&me.object_id) {
|
|
RhaiGlyph {
|
|
tile: obj.glyph.tile as i64,
|
|
fg: color_to_hex(obj.glyph.fg),
|
|
bg: color_to_hex(obj.glyph.bg),
|
|
}
|
|
} else {
|
|
RhaiGlyph {
|
|
tile: 0,
|
|
fg: "#000000".to_string(),
|
|
bg: "#000000".to_string(),
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Register Registry type ────────────────────────────────────────────────────
|
|
|
|
fn register_registry_type(engine: &mut Engine) {
|
|
engine.register_type_with_name::<Registry>("Registry");
|
|
|
|
// Registry.get(key) -> Dynamic — returns () if the key is absent.
|
|
engine.register_fn("get", |r: &mut Registry, key: ImmutableString| -> Dynamic {
|
|
r.board
|
|
.borrow()
|
|
.registry
|
|
.get(key.as_str())
|
|
.map(|v| v.clone().into())
|
|
.unwrap_or(Dynamic::UNIT)
|
|
});
|
|
|
|
// Registry.set(key, value) — stores a primitive value; () removes the key;
|
|
// unsupported types (closures, custom objects) are silently ignored.
|
|
engine.register_fn(
|
|
"set",
|
|
|r: &mut Registry, key: ImmutableString, value: Dynamic| {
|
|
match RegistryValue::try_from(value.clone()) {
|
|
Ok(rv) => {
|
|
r.board.borrow_mut().registry.insert(key.to_string(), rv);
|
|
}
|
|
Err(()) if value.is_unit() => {
|
|
r.board.borrow_mut().registry.remove(key.as_str());
|
|
}
|
|
Err(()) => {} // unsupported type — silently ignore
|
|
}
|
|
},
|
|
);
|
|
|
|
// Registry.get_or(key, default) — returns the stored value when present and the
|
|
// same Rhai type as `default`; falls back to `default` otherwise.
|
|
engine.register_fn(
|
|
"get_or",
|
|
|r: &mut Registry, key: ImmutableString, default: Dynamic| -> Dynamic {
|
|
if let Some(stored) = r.board.borrow().registry.get(key.as_str()).cloned() {
|
|
let candidate: Dynamic = stored.into();
|
|
// Only return the stored value if it matches the type of the default.
|
|
if candidate.type_id() == default.type_id() {
|
|
candidate
|
|
} else {
|
|
default
|
|
}
|
|
} else {
|
|
default
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Register ObjectInfo type ──────────────────────────────────────────────────
|
|
|
|
fn register_object_info_type(engine: &mut Engine) {
|
|
engine.register_type_with_name::<ObjectInfo>("ObjectInfo");
|
|
|
|
engine.register_get("id", |o: &mut ObjectInfo| o.id);
|
|
engine.register_get("x", |o: &mut ObjectInfo| o.x);
|
|
engine.register_get("y", |o: &mut ObjectInfo| o.y);
|
|
|
|
engine.register_get("name", |o: &mut ObjectInfo| {
|
|
o.name.as_deref().unwrap_or("").to_string()
|
|
});
|
|
|
|
engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array {
|
|
o.tags.iter().map(|t| Dynamic::from(t.clone())).collect()
|
|
});
|
|
}
|
|
|
|
// ── Register RhaiGlyph type ───────────────────────────────────────────────────
|
|
|
|
fn register_glyph_type(engine: &mut Engine) {
|
|
engine.register_type_with_name::<RhaiGlyph>("Glyph");
|
|
engine.register_get("tile", |g: &mut RhaiGlyph| g.tile);
|
|
engine.register_get("fg", |g: &mut RhaiGlyph| g.fg.clone());
|
|
engine.register_get("bg", |g: &mut RhaiGlyph| g.bg.clone());
|
|
}
|
|
|
|
// ── Board read API ────────────────────────────────────────────────────────────
|
|
|
|
fn register_read_api(
|
|
engine: &mut Engine,
|
|
board: &BoardRef,
|
|
board_queue: &BoardQueue,
|
|
errors: &ErrorSink,
|
|
) {
|
|
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);
|
|
|
|
// blocked(dir) — true if moving in dir would be impossible for the calling object.
|
|
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)
|
|
});
|
|
|
|
// Board.tagged(tag) -> Array[ObjectInfo]
|
|
let b = board.clone();
|
|
engine.register_fn(
|
|
"tagged",
|
|
move |_board: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
|
|
let board = b.borrow();
|
|
board
|
|
.objects
|
|
.iter()
|
|
.filter(|(_, o)| o.tags.contains(tag.as_str()))
|
|
.filter_map(|(&id, _)| object_info_from(id, &board))
|
|
.map(Dynamic::from)
|
|
.collect()
|
|
},
|
|
);
|
|
|
|
// Board.named(name) -> ObjectInfo | ()
|
|
let b = board.clone();
|
|
engine.register_fn(
|
|
"named",
|
|
move |_board: &mut BoardRef, name: ImmutableString| -> Dynamic {
|
|
let board = b.borrow();
|
|
board
|
|
.objects
|
|
.iter()
|
|
.find(|(_, o)| o.name.as_deref() == Some(name.as_str()))
|
|
.and_then(|(&id, _)| object_info_from(id, &board))
|
|
.map(Dynamic::from)
|
|
.unwrap_or(Dynamic::UNIT)
|
|
},
|
|
);
|
|
|
|
// Board.get(id) -> ObjectInfo | () (-1 returns player; unknown id logs error)
|
|
let b = board.clone();
|
|
let errs = errors.clone();
|
|
engine.register_fn("get", move |_board: &mut BoardRef, id: i64| -> Dynamic {
|
|
let board = b.borrow();
|
|
if id == -1 {
|
|
return Dynamic::from(object_info_player(&board));
|
|
}
|
|
if id <= 0 {
|
|
errs.borrow_mut()
|
|
.push(LogLine::error(format!("Board.get: invalid id {id}")));
|
|
return Dynamic::UNIT;
|
|
}
|
|
match object_info_from(id as ObjectId, &board) {
|
|
Some(info) => Dynamic::from(info),
|
|
None => {
|
|
errs.borrow_mut()
|
|
.push(LogLine::error(format!("Board.get: no object with id {id}")));
|
|
Dynamic::UNIT
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Whether the object at `source` would be blocked moving in `dir`.
|
|
fn is_blocked(
|
|
board: &BoardRef,
|
|
board_queue: &BoardQueue,
|
|
source: ObjectId,
|
|
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;
|
|
}
|
|
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
|
|
})
|
|
}
|
|
|
|
// ── Write API ─────────────────────────────────────────────────────────────────
|
|
|
|
fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
|
engine.register_type_with_name::<Direction>("Direction");
|
|
|
|
// move(dir): enqueue a Move followed by a rate-limiting Delay.
|
|
let q = queues.clone();
|
|
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| {
|
|
let src = source_of(&ctx);
|
|
if let Some(queue) = q.borrow().get(&src) {
|
|
queue.borrow_mut().push_back(Action::Move(dir));
|
|
push_delay(queue, MOVE_COST);
|
|
}
|
|
});
|
|
|
|
// delay(secs): insert an explicit pause. Two overloads so integer literals work.
|
|
let q = queues.clone();
|
|
engine.register_fn("delay", move |ctx: NativeCallContext, secs: f64| {
|
|
let src = source_of(&ctx);
|
|
if let Some(queue) = q.borrow().get(&src) {
|
|
push_delay(queue, secs.max(0.0));
|
|
}
|
|
});
|
|
let q = queues.clone();
|
|
engine.register_fn("delay", move |ctx: NativeCallContext, secs: i64| {
|
|
let src = source_of(&ctx);
|
|
if let Some(queue) = q.borrow().get(&src) {
|
|
push_delay(queue, (secs.max(0)) as f64);
|
|
}
|
|
});
|
|
|
|
// now(): promote the most recently enqueued action to the front.
|
|
let q = queues.clone();
|
|
engine.register_fn("now", move |ctx: NativeCallContext| {
|
|
let src = source_of(&ctx);
|
|
if let Some(queue) = q.borrow().get(&src) {
|
|
let mut q = queue.borrow_mut();
|
|
if let Some(back) = q.pop_back() {
|
|
q.push_front(back);
|
|
}
|
|
}
|
|
});
|
|
|
|
let q = queues.clone();
|
|
engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| {
|
|
emit(&q, source_of(&ctx), Action::SetTile(tile as u32));
|
|
});
|
|
|
|
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())),
|
|
);
|
|
},
|
|
);
|
|
|
|
let q = queues.clone();
|
|
engine.register_fn(
|
|
"say",
|
|
move |ctx: NativeCallContext, msg: ImmutableString| {
|
|
emit(
|
|
&q,
|
|
source_of(&ctx),
|
|
Action::Say(msg.to_string(), SAY_DURATION),
|
|
);
|
|
},
|
|
);
|
|
let q = queues.clone();
|
|
engine.register_fn(
|
|
"say",
|
|
move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| {
|
|
emit(&q, source_of(&ctx), Action::Say(msg.to_string(), dur));
|
|
},
|
|
);
|
|
|
|
// scroll(lines): open a full-screen scrollable overlay. Each element of `lines`
|
|
// is either a plain string (text) or a 2-element array [choice_key, display_text].
|
|
let q = queues.clone();
|
|
engine.register_fn("scroll", move |ctx: NativeCallContext, arr: Array| {
|
|
let lines = arr
|
|
.into_iter()
|
|
.filter_map(|item| {
|
|
if let Ok(s) = item.clone().into_string() {
|
|
Some(ScrollLine::Text(s))
|
|
} else {
|
|
let pair: Vec<Dynamic> = item.into_array().ok()?;
|
|
if pair.len() == 2 {
|
|
let choice = pair[0].clone().into_string().ok()?;
|
|
let display = pair[1].clone().into_string().ok()?;
|
|
Some(ScrollLine::Choice { choice, display })
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
})
|
|
.collect();
|
|
emit(&q, source_of(&ctx), Action::Scroll(lines));
|
|
});
|
|
|
|
let q = queues.clone();
|
|
engine.register_fn(
|
|
"set_tag",
|
|
move |ctx: NativeCallContext, target: i64, tag: ImmutableString, present: bool| {
|
|
emit(
|
|
&q,
|
|
source_of(&ctx),
|
|
Action::SetTag {
|
|
target: target as ObjectId,
|
|
tag: tag.to_string(),
|
|
present,
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
// set_fg(fg): change foreground color only.
|
|
let q = queues.clone();
|
|
engine.register_fn(
|
|
"set_fg",
|
|
move |ctx: NativeCallContext, fg: ImmutableString| {
|
|
emit(
|
|
&q,
|
|
source_of(&ctx),
|
|
Action::SetColor {
|
|
fg: Some(parse_color(fg.as_str())),
|
|
bg: None,
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
// set_bg(bg): change background color only.
|
|
let q = queues.clone();
|
|
engine.register_fn(
|
|
"set_bg",
|
|
move |ctx: NativeCallContext, bg: ImmutableString| {
|
|
emit(
|
|
&q,
|
|
source_of(&ctx),
|
|
Action::SetColor {
|
|
fg: None,
|
|
bg: Some(parse_color(bg.as_str())),
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
// set_color(fg, bg): change both colors.
|
|
let q = queues.clone();
|
|
engine.register_fn(
|
|
"set_color",
|
|
move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| {
|
|
emit(
|
|
&q,
|
|
source_of(&ctx),
|
|
Action::SetColor {
|
|
fg: Some(parse_color(fg.as_str())),
|
|
bg: Some(parse_color(bg.as_str())),
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
// send(target, fn_name): call a named function on another object.
|
|
let q = queues.clone();
|
|
engine.register_fn(
|
|
"send",
|
|
move |ctx: NativeCallContext, target: i64, name: ImmutableString| {
|
|
emit(
|
|
&q,
|
|
source_of(&ctx),
|
|
Action::Send {
|
|
target: target as ObjectId,
|
|
fn_name: name.to_string(),
|
|
arg: None,
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
// send(target, fn_name, arg): same, with an optional string or number argument.
|
|
let q = queues.clone();
|
|
engine.register_fn(
|
|
"send",
|
|
move |ctx: NativeCallContext, target: i64, name: ImmutableString, arg: Dynamic| {
|
|
let script_arg = if let Ok(n) = arg.clone().as_int() {
|
|
Some(ScriptArg::Num(n as f64))
|
|
} else if let Ok(f) = arg.clone().as_float() {
|
|
Some(ScriptArg::Num(f))
|
|
} else if let Ok(s) = arg.into_string() {
|
|
Some(ScriptArg::Str(s))
|
|
} else {
|
|
None
|
|
};
|
|
emit(
|
|
&q,
|
|
source_of(&ctx),
|
|
Action::Send {
|
|
target: target as ObjectId,
|
|
fn_name: name.to_string(),
|
|
arg: script_arg,
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
// teleport(x, y): move the calling object to an arbitrary cell. Zero time cost.
|
|
// Blocked (with an error logged at resolve time) if the object is solid and the
|
|
// destination already has a solid occupant.
|
|
let q = queues.clone();
|
|
engine.register_fn("teleport", move |ctx: NativeCallContext, x: i64, y: i64| {
|
|
emit(
|
|
&q,
|
|
source_of(&ctx),
|
|
Action::Teleport {
|
|
x: x as i32,
|
|
y: y as i32,
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
// ── Queue API ─────────────────────────────────────────────────────────────────
|
|
|
|
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());
|
|
|
|
// peek(): front action as a Rhai map, or () if empty.
|
|
engine.register_fn("peek", |q: &mut ObjQueue| -> Dynamic {
|
|
q.borrow()
|
|
.front()
|
|
.map(|a| Dynamic::from(action_to_map(a)))
|
|
.unwrap_or(Dynamic::UNIT)
|
|
});
|
|
|
|
// pop(): same, but removes the front.
|
|
engine.register_fn("pop", |q: &mut ObjQueue| -> Dynamic {
|
|
q.borrow_mut()
|
|
.pop_front()
|
|
.as_ref()
|
|
.map(|a| Dynamic::from(action_to_map(a)))
|
|
.unwrap_or(Dynamic::UNIT)
|
|
});
|
|
}
|
|
|
|
// ── Scope construction & global constants ─────────────────────────────────────
|
|
|
|
/// Registers direction and color constants as a global Rhai module so they are
|
|
/// visible to every function at any call depth, including Rhai-to-Rhai calls.
|
|
/// (Scope-level constants are only visible to the top-level function Rust calls.)
|
|
fn register_global_constants(engine: &mut Engine) {
|
|
let mut m = Module::new();
|
|
m.set_var("North", Direction::North);
|
|
m.set_var("South", Direction::South);
|
|
m.set_var("East", Direction::East);
|
|
m.set_var("West", Direction::West);
|
|
// 16 EGA/VGA color constants as "#RRGGBB" strings, from the shared palette table
|
|
// (the single source of truth, also used by the editor's color picker).
|
|
for (name, c) in crate::colors::NAMED_COLORS {
|
|
m.set_var(name, format!("#{:02X}{:02X}{:02X}", c.r, c.g, c.b));
|
|
}
|
|
engine.register_global_module(m.into());
|
|
}
|
|
|
|
/// Builds a fresh per-object scope containing only the object-specific handles:
|
|
/// the Board view, the object's Queue, and the Me self-reference.
|
|
fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) -> Scope<'static> {
|
|
let mut scope = Scope::new();
|
|
scope.push_constant("Board", board.clone());
|
|
scope.push_constant("Queue", queue.clone());
|
|
scope.push_constant(
|
|
"Me",
|
|
Me {
|
|
object_id,
|
|
board: board.clone(),
|
|
},
|
|
);
|
|
scope.push_constant(
|
|
"Registry",
|
|
Registry {
|
|
board: board.clone(),
|
|
},
|
|
);
|
|
scope
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
/// Appends a [`Action::Delay`] to the back of `queue`, merging with an existing
|
|
/// trailing delay to prevent adjacent delays from accumulating.
|
|
fn push_delay(queue: &ObjQueue, secs: f64) {
|
|
let mut q = queue.borrow_mut();
|
|
if let Some(Action::Delay(r)) = q.back_mut() {
|
|
*r += secs;
|
|
} else {
|
|
q.push_back(Action::Delay(secs));
|
|
}
|
|
}
|
|
|
|
/// Appends `action` to the output queue of the object identified by `source`.
|
|
fn emit(queues: &QueueMap, source: ObjectId, action: Action) {
|
|
if let Some(queue) = queues.borrow().get(&source) {
|
|
queue.borrow_mut().push_back(action);
|
|
}
|
|
}
|
|
|
|
/// Reads the issuing object's [`ObjectId`] from the call tag.
|
|
fn source_of(ctx: &NativeCallContext) -> ObjectId {
|
|
ctx.tag()
|
|
.and_then(|t| t.as_int().ok())
|
|
.and_then(|n| ObjectId::try_from(n).ok())
|
|
.unwrap_or(0)
|
|
}
|