basic scripting

This commit is contained in:
2026-06-04 20:11:55 -05:00
parent f0bcc90480
commit e0477a12b8
7 changed files with 373 additions and 102 deletions
+108 -80
View File
@@ -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"));
}
}