basic scripting
This commit is contained in:
+108
-80
@@ -1,10 +1,10 @@
|
||||
use crate::log::LogLine;
|
||||
use crate::script::ScriptHost;
|
||||
use color::Rgba8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tinyrand::{Rand, Seeded, StdRand};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The visual representation of a single board cell.
|
||||
///
|
||||
@@ -205,11 +205,11 @@ pub const ALL_ARCHETYPES: &[Archetype] = &[Archetype::Empty, Archetype::Wall];
|
||||
///
|
||||
/// Script text lives in [`Board::scripts`]; this struct holds only the name
|
||||
/// used to look it up. Two `ObjectDef`s with the same `script_name` share
|
||||
/// source text but will run with independent Rhai scopes when the scripting
|
||||
/// runtime is wired.
|
||||
/// source text but run with independent Rhai scopes (see [`crate::script`]).
|
||||
///
|
||||
/// **Not yet runtime-wired.** Objects are loaded and stored but their scripts
|
||||
/// are not yet executed. Scripting dispatch is a future feature.
|
||||
/// Scripts are executed by [`crate::script::ScriptHost`]: an object's optional
|
||||
/// `init()` and `tick(dt)` functions are called via [`GameState::run_init`] and
|
||||
/// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work.
|
||||
pub struct ObjectDef {
|
||||
/// Column of this object on the board (0-indexed).
|
||||
pub x: usize,
|
||||
@@ -389,37 +389,35 @@ impl Board {
|
||||
/// Holds the active game board and provides game-logic operations.
|
||||
///
|
||||
/// `GameState` is the boundary between the engine (rendering, input) and the
|
||||
/// game data ([`Board`]). It owns the current board and exposes methods for
|
||||
/// actions that involve game rules — currently just player movement.
|
||||
///
|
||||
/// As scripting and event dispatch are added, `GameState` will grow to handle
|
||||
/// routing input events to the appropriate Rhai scripts.
|
||||
/// 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).
|
||||
pub struct GameState {
|
||||
/// The currently active board.
|
||||
pub board: Board,
|
||||
/// The in-game message log, oldest first (newest pushed at the end).
|
||||
pub log: Vec<LogLine>,
|
||||
/// Elapsed real time not yet consumed by [`GameState::tick`], used to fire
|
||||
/// once-per-second events regardless of the actual frame rate.
|
||||
tick_accum: Duration,
|
||||
/// The engine's random-number generator (currently used for tick colors).
|
||||
rng: StdRand,
|
||||
/// The Rhai scripting runtime driving this board's scripted objects.
|
||||
scripts: ScriptHost,
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
/// Creates a `GameState` from a pre-loaded [`Board`].
|
||||
///
|
||||
/// Compiles the board's object scripts but does **not** run any of them;
|
||||
/// 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 {
|
||||
// Seed the RNG from the wall clock so colors differ run-to-run.
|
||||
let seed = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
Self {
|
||||
let scripts = ScriptHost::new(&board);
|
||||
let mut state = Self {
|
||||
board,
|
||||
log: Vec::new(),
|
||||
tick_accum: Duration::ZERO,
|
||||
rng: StdRand::seed(seed),
|
||||
}
|
||||
scripts,
|
||||
};
|
||||
// Surface any compile-time script errors collected during ScriptHost::new.
|
||||
state.flush_script_log();
|
||||
state
|
||||
}
|
||||
|
||||
/// Appends a styled message to the log.
|
||||
@@ -427,27 +425,26 @@ impl GameState {
|
||||
self.log.push(line);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// For now this is a demo: it logs the word "tick" once per elapsed second,
|
||||
/// each in a random bright color. A `while` loop (rather than `if`) keeps the
|
||||
/// count correct even if a frame ran long enough to span multiple seconds.
|
||||
pub fn tick(&mut self, dt: Duration) {
|
||||
self.tick_accum += dt;
|
||||
while self.tick_accum >= Duration::from_secs(1) {
|
||||
self.tick_accum -= Duration::from_secs(1);
|
||||
let color = self.random_color();
|
||||
self.log(LogLine::new().push("tick", Some(color), None));
|
||||
}
|
||||
/// 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.
|
||||
pub fn run_init(&mut self) {
|
||||
self.scripts.run_init();
|
||||
self.flush_script_log();
|
||||
}
|
||||
|
||||
/// Returns a random fully-saturated, full-value color by picking a random
|
||||
/// hue — bright enough to stay readable against dark backgrounds.
|
||||
fn random_color(&mut self) -> Rgba8 {
|
||||
let hue = self.rng.next_lim_u32(360) as f32;
|
||||
let (r, g, b) = hue_to_rgb(hue);
|
||||
Rgba8 { r, g, b, a: 255 }
|
||||
/// 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.
|
||||
pub fn tick(&mut self, dt: Duration) {
|
||||
self.scripts.run_tick(dt.as_secs_f64());
|
||||
self.flush_script_log();
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
/// Attempts to move the player by `(dx, dy)` cells.
|
||||
@@ -468,63 +465,94 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a hue in degrees to an RGB triple at full saturation and value.
|
||||
///
|
||||
/// Standard 6-segment HSV→RGB conversion (S = V = 1). Kept here rather than
|
||||
/// shared with the front-end because kiln-core does not depend on any UI crate;
|
||||
/// `kiln-tui` has its own copy for the terminal-status badge.
|
||||
fn hue_to_rgb(hue: f32) -> (u8, u8, u8) {
|
||||
let h = (hue % 360.0) / 60.0;
|
||||
// `x` ramps the secondary channel up/down within each 60° segment.
|
||||
let x = 1.0 - (h % 2.0 - 1.0).abs();
|
||||
let (r, g, b) = match h as u32 {
|
||||
0 => (1.0, x, 0.0),
|
||||
1 => (x, 1.0, 0.0),
|
||||
2 => (0.0, 1.0, x),
|
||||
3 => (0.0, x, 1.0),
|
||||
4 => (x, 0.0, 1.0),
|
||||
_ => (1.0, 0.0, x),
|
||||
};
|
||||
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal 1×1 empty board for exercising `GameState` logic.
|
||||
fn empty_board() -> Board {
|
||||
/// Builds a 1×1 board with a single object that optionally references a
|
||||
/// script, plus the given `(name, source)` script table entries.
|
||||
fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> Board {
|
||||
let mut object = ObjectDef::new(0, 0);
|
||||
object.script_name = object_script.map(str::to_string);
|
||||
Board {
|
||||
name: "test".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)],
|
||||
player: Player { x: 0, y: 0 },
|
||||
objects: Vec::new(),
|
||||
objects: vec![object],
|
||||
portals: Vec::new(),
|
||||
font: None,
|
||||
zoom: 1,
|
||||
scripts: HashMap::new(),
|
||||
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
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|s| s.text.as_str()).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_logs_one_message_per_elapsed_second() {
|
||||
let mut game = GameState::new(empty_board());
|
||||
assert_eq!(game.log.len(), 0);
|
||||
fn init_runs_only_on_run_init_not_at_construction() {
|
||||
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());
|
||||
|
||||
game.run_init();
|
||||
assert_eq!(log_texts(&game), vec!["hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_calls_script_tick_with_elapsed_seconds() {
|
||||
let board = board_with_object(
|
||||
Some("t"),
|
||||
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
// No tick hook runs until tick() is called.
|
||||
game.run_init();
|
||||
assert!(game.log.is_empty());
|
||||
|
||||
// Under a second of accumulated time logs nothing yet.
|
||||
game.tick(Duration::from_millis(500));
|
||||
assert_eq!(game.log.len(), 0);
|
||||
|
||||
// Crossing the one-second mark logs exactly one "tick".
|
||||
game.tick(Duration::from_millis(600));
|
||||
assert_eq!(game.log.len(), 1);
|
||||
assert_eq!(game.log[0].spans[0].text, "tick");
|
||||
// The dt argument reached the script as ~0.5 seconds.
|
||||
let logged: f64 = log_texts(&game)[0].parse().unwrap();
|
||||
assert!((logged - 0.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
// A single long frame logs one message per whole second it spans.
|
||||
game.tick(Duration::from_secs(3));
|
||||
assert_eq!(game.log.len(), 4);
|
||||
#[test]
|
||||
fn missing_hooks_and_no_script_are_noops() {
|
||||
// Object with no script: nothing happens.
|
||||
let mut game = GameState::new(board_with_object(None, &[]));
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(33));
|
||||
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\"); }")]));
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(33));
|
||||
assert!(game.log.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_and_unknown_script_errors_are_logged() {
|
||||
// A reference to a script name that isn't in the table.
|
||||
let game = GameState::new(board_with_object(Some("ghost"), &[]));
|
||||
assert!(log_texts(&game)[0].contains("unknown script 'ghost'"));
|
||||
|
||||
// A script that fails to compile is reported at construction time.
|
||||
let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")]));
|
||||
assert!(log_texts(&game)[0].contains("failed to compile"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,5 @@ pub mod game;
|
||||
pub mod log;
|
||||
/// Map file loading and saving (`.toml` format).
|
||||
pub mod map_file;
|
||||
/// Rhai scripting runtime for board objects ([`script::ScriptHost`]).
|
||||
pub mod script;
|
||||
|
||||
@@ -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