diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eb3d8c2..d43eb25 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -29,6 +29,8 @@ Cargo **workspace**; root `members = ["kiln-core", "kiln-tui"]`. `kiln-egui` sta ``` kiln-core/src/ — the engine (UI-agnostic) game.rs — core data types and game logic + log.rs — styled, UI-agnostic log messages (LogLine/LogSpan) + script.rs — Rhai scripting runtime (ScriptHost); object init/tick hooks map_file.rs — TOML deserialization, Board load/save kiln-tui/src/ — terminal player (no editor) main.rs — CLI arg + play loop; TerminalCaps detection; Kitty flags @@ -76,8 +78,9 @@ In ZZT, each board tile had both a visual (character + color pair) and an elemen The complete unit of a game world — one "room" or "screen" in ZZT terminology. Holds: - `cells: Vec<(Glyph, Archetype)>` — row-major grid; each cell directly owns its visual and behavioral class - `player: Player` — current player position on this board -- `objects: Vec` — scripted objects (parsed; scripts not yet runtime-wired, but `passable`/`opaque` are live and affect collision) +- `objects: Vec` — scripted objects; their `init()`/`tick(dt)` hooks run via the scripting runtime, and `passable`/`opaque` affect collision - `portals: Vec` — exits to other boards (parsed, not yet runtime-wired) +- `scripts: HashMap` — named Rhai source, referenced by objects via `script_name` - `font: Option` — per-board font override; `None` means use the app default **`ObjectDef`** @@ -90,7 +93,21 @@ An earlier design had `cells: Vec<(Glyph, usize)>` where the `usize` indexed a p An earlier design had `GameMap { board: Board, player: Player, ... }`. This was eliminated because the split was artificial: there's no meaningful use of a `Board` without a player position, and no meaningful use of a player without a `Board`. ZZT itself treats a board as containing everything — the grid, the objects, and the player entry point. Collapsing to a single struct matches the domain model. **`GameState`** -Currently a thin wrapper around `Board` that provides game-logic methods (`try_move`). It exists to keep mutation logic (collision checking, movement) separate from the data. As the game grows, event processing and scripting dispatch will live here. +Owns the `Board`, the message `log: Vec`, and a `ScriptHost`. It keeps mutation logic (collision checking, movement) separate from the data and is where event processing/scripting dispatch lives: `try_move` for movement, `run_init()` to run object `init()` hooks once at startup, and `tick(dt)` to run object `tick(dt)` hooks every frame. After each script batch it flushes script-emitted log lines into `log`. + +--- + +### `log.rs` — styled messages + +`LogLine { spans: Vec }`, where `LogSpan { text, fg: Option, bg: Option }`, is a **UI-agnostic** styled message: colors are core `Rgba8`, not a front-end type, so the engine stays UI-free. `LogLine::raw()`, a chainable `push()`, and `append()` build messages; each front-end converts a `LogLine` to its own styled text at render time (kiln-tui's `render::logline_to_line`). The game log lives on `GameState`; both the engine (scripts) and front-ends append to it. + +### `script.rs` — Rhai scripting runtime + +`ScriptHost` owns the Rhai `Engine`, the scripts referenced by a board's objects (compiled once per name via `Engine::compile`), a persistent per-object `Scope`, and a shared **log sink**. Built with `ScriptHost::new(&board)`, which compiles scripts and records each object's available hooks (detected with `AST::iter_functions()` by name/arity) but **runs nothing** — compile errors and unknown-script references are reported into the log. + +Two optional lifecycle hooks per object: `init()` (zero-arg, run once) and `tick(dt)` (elapsed seconds as `f64`, run per frame), driven by `run_init()` / `run_tick(dt)`. Runtime errors are logged, not fatal. + +The one host function so far is `log(s)`, which appends an uncolored `LogLine`. The borrow problem — Rhai's registered closures must be `'static`, yet `log` must reach the game log — is solved by writing into an `Rc>>` sink cloned into the closure; `GameState` drains it with `take_pending()` after each batch, so running scripts never needs a mutable borrow of `GameState`. (Default `call_fn` rewinds the scope per call, so script-local persistence across ticks is future work; the per-object `Scope` is the seam for that and for injecting object state.) `Engine`/`Scope` are not `Send` — fine for the single-threaded kiln-tui. --- @@ -107,11 +124,11 @@ A `ratatui::widgets::Widget` that draws the board into the frame buffer. The `ax **`term.rs` — capability detection + Kitty protocol.** `TerminalCaps { keyboard_enhancement, truecolor }` is detected once at startup: `keyboard_enhancement` via crossterm's `supports_keyboard_enhancement()` (a real query/response with the terminal), `truecolor` from `$COLORTERM`. When keyboard enhancement is supported, kiln-tui pushes the Kitty keyboard-protocol flags (`DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS | REPORT_ALL_KEYS_AS_ESCAPE_CODES`) after `ratatui::init()` and pops them before `restore()`. This unlocks richer input — modifier-only keypresses, key release/repeat events, unambiguous Ctrl combos — which the engine intends to hand to scripts so they can choose bindings the terminal actually supports. -**Input loop.** -Because `REPORT_EVENT_TYPES` makes the terminal emit Release (and Repeat) events, the loop acts on `Press` **and** `Repeat` — so holding a key moves the player continuously at the terminal's own repeat cadence — and skips `Release`, which would otherwise fire a second, spurious move per keypress. In legacy terminals no Repeat events arrive, so held-key movement falls back to terminal auto-repeat; same behavior, no special-casing. +**Real-time loop.** +The loop runs at ~30 FPS: it `event::poll`s only until the next frame deadline, so input stays responsive, and otherwise wakes to advance the game by the real elapsed `dt` via `GameState::tick` (which drives object `tick(dt)` hooks). Because `REPORT_EVENT_TYPES` makes the terminal emit Release (and Repeat) events, it acts on `Press` **and** `Repeat` — so holding a key moves continuously at the terminal's own repeat cadence — and skips `Release`, which would otherwise fire a second, spurious move. Mouse capture is enabled so the log panel can be scrolled (wheel) and resized (drag the divider). `game.run_init()` runs object `init()` hooks after the board loads and before the loop. -**Capability badge.** -`TerminalCaps::summary_line()` renders a one-line badge in the bottom border. When truecolor is available it shows off by drawing the word "truecolor" with each letter a different rainbow color (`hue_to_rgb`, a full-saturation HSV→RGB helper); otherwise it shows a plain "256-color", followed by the input descriptor ("enhanced input" / "legacy input") in the default color. +**Log panel + capability line.** +The bottom-left border previews the latest log line after a `[l]og:` label; pressing `l` opens a bottom panel listing the log newest-first (`render::logline_to_line` converts each `LogLine`). `TerminalCaps::status_logline()` returns a `LogLine` (not a border badge) seeded into the log at startup: when truecolor is available it draws "truecolor" with each letter a different rainbow color (`hue_to_rgb`, a full-saturation HSV→RGB helper), else a plain "256-color", followed by the input descriptor. **Why a capability struct, not a terminal name.** Scripts should branch on *what works* (truecolor, keyboard enhancement), not on a brittle terminal brand string that is often unset over SSH or inside tmux. `TerminalCaps` deliberately carries no program-identity field. @@ -275,15 +292,23 @@ content = """ [[objects]] x = 10 y = 5 -script = """ - on_touch(|| { send_message("open"); }); -""" +tile = "#" +fg = "#aa3333" +bg = "#000000" +passable = true +script_name = "greeter" [[portals]] x = 59 y = 12 target_map = "cave" target_entry = "west_door" + +[scripts] +greeter = """ +fn init() { log("hello world"); } +fn tick(dt) { } +""" ``` **Why TOML over a custom format:** @@ -306,9 +331,11 @@ A direct mapping from palette character → `(Glyph, Archetype)` means the map f ## What's not yet implemented -**Object scripting** — `ObjectDef` and `PortalDef` are parsed from map files and stored on `Board`, but they have no runtime effect yet. The next step here is: -1. Wire `ObjectDef` scripts to Rhai: when the player moves to an object's cell, fire its `on_touch` handler -2. Define the Rhai API surface (what functions scripts can call: `send_message`, movement, board queries) +**Object scripting** — the runtime exists (`script.rs`): objects run optional `init()`/`tick(dt)` hooks and can call `log(s)`. Still to come: +1. More event hooks beyond lifecycle — e.g. fire `on_touch` when the player moves onto an object's cell +2. Grow the Rhai API surface (movement, board queries, `send_message`, randomness) and give a script a handle to *its own* object +3. Persist script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call) +4. `PortalDef` is still parsed-only — no multi-board navigation yet **Object behavior from scripts** — `ObjectDef.passable` and `ObjectDef.opaque` are live and editable in the editor, but they are static author-set values. Eventually they should be overridable by the object's Rhai script at runtime (e.g. a door script that flips `passable` when opened). `Board::is_passable` already has the right shape for this; the script runtime just needs to be wired in. diff --git a/CLAUDE.md b/CLAUDE.md index 2c96868..0b91d6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,9 +51,18 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`. - `ALL_ARCHETYPES: &[Archetype]` — ordered list of valid editor choices (excludes `ErrorBlock`). - `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec`, `portals: Vec`, `font: Option`. `cells` is `pub(crate)`. `is_passable(x, y)` checks objects before the grid cell: an impassable object blocks even over a passable floor tile. - `Player` — `x: i32, y: i32` -- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `passable: bool`, `opaque: bool`, `script_name: Option`. `passable` defaults `false` and `opaque` defaults `true` in map files. Scripts not yet runtime-wired. +- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `passable: bool`, `opaque: bool`, `script_name: Option`. `passable` defaults `false` and `opaque` defaults `true` in map files. Its `init()`/`tick(dt)` hooks are run by the scripting runtime (see `script.rs`). - `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired -- `GameState` — holds `board: Board`; `try_move(dx, dy)` checks passability before moving the player +- `GameState` — holds `board: Board`, the message `log: Vec`, and a `ScriptHost`. `try_move(dx, dy)` checks passability before moving the player; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` runs object `tick(dt)` hooks every frame. Script-emitted log lines are flushed into `log` after each call. + +**`kiln-core/src/log.rs`** — styled log messages: +- `LogSpan { text, fg: Option, bg: Option }` and `LogLine { spans: Vec }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, and `append()` build messages; each front-end converts a `LogLine` to its own styled text at render time. + +**`kiln-core/src/script.rs`** — Rhai scripting runtime: +- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent `Scope`, and a shared log sink. Built with `ScriptHost::new(&board)` (compiles scripts, reports compile/unknown-script errors to the log; runs nothing). +- Lifecycle hooks per object: `init()` (zero-arg) and `tick(dt)` (elapsed seconds as `f64`), both optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)`; runtime errors are logged, not fatal. +- Host function: `log(s)` appends an uncolored `LogLine` to the shared sink, drained by `GameState` via `take_pending()`. The sink (an `Rc>>`) is how scripts write to the log without a mutable borrow of `GameState`. +- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui. **`kiln-core/src/map_file.rs`** — map file loading: - `MapFile` and friends — serde `Deserialize` types for TOML map files @@ -70,8 +79,9 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint **`kiln-tui/src/main.rs`** — entry point and play loop: - Parses a single positional arg (the map path); prints usage and exits non-zero if missing. Loads the board via `kiln_core::map_file::load` *before* touching the terminal so load errors print to a normal screen. - `ratatui::init()` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `ratatui::restore()`. -- `run` blocks on `event::read()`; it acts on `Press` **and** `Repeat` key events (so holding a key moves continuously) and ignores `Release` (which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys or `hjkl` move the player via `GameState::try_move`; `q`/`Esc` quit. -- `draw` wraps the board in a bordered `Block` titled with the board name and controls; `caps.summary_line()` is shown right-aligned in the bottom border. +- `run` is a ~30 FPS real-time loop: it `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance the game by the real elapsed `dt` via `GameState::tick`. It acts on `Press` **and** `Repeat` key events (so holding a key moves continuously) and ignores `Release` (which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys move the player via `GameState::try_move`; `l` toggles the log panel; `q`/`Esc` quit. Mouse capture is enabled so the log panel scrolls (wheel) and resizes (drag the divider). `game.run_init()` runs object `init()` hooks after the board is loaded and before the loop. +- `draw` wraps the board in a bordered `Block` titled with the board name and controls. When the log panel is closed, the latest log line is previewed in the bottom-left border after a `[l]og:` label; when open, a bottom panel lists the log newest-first (`render::logline_to_line` converts each `LogLine`). +- `Ui { log_open, log_height, scroll }` — view-only panel state kept in the front-end (not on `GameState`). **`kiln-tui/src/cp437.rs`** — CP437 → Unicode mapping: - `CP437: [char; 256]` — full code-page-437 table (graphic glyphs for 0x00–0x1F, box-drawing for 0xB0–0xDF, etc.). @@ -168,21 +178,30 @@ content = """ ############################################################ """ -[[objects]] # optional; parsed but not yet runtime-wired +[[objects]] # optional x = 10 y = 5 -script = """ - on_touch(|| { send_message("open"); }); -""" +tile = "#" +fg = "#aa3333" +bg = "#000000" +passable = true +script_name = "greeter" # references a key in [scripts] [[portals]] # optional; parsed but not yet runtime-wired x = 59 y = 12 target_map = "cave" target_entry = "west_door" + +# Named Rhai scripts, referenced by objects via `script_name`. +[scripts] +greeter = """ +fn init() { log("hello world"); } # optional, run once at startup +fn tick(dt) { } # optional, run every frame with elapsed seconds +""" ``` -Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning. +Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`/`tick(dt)` functions and call the host function `log(s)`. ### Key design decisions diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 91675e7..4160c94 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -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, - /// 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 { + 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")); } } diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index 2cf956e..eff07f6 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -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; diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs new file mode 100644 index 0000000..1aa8ce4 --- /dev/null +++ b/kiln-core/src/script.rs @@ -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>>; + +/// 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, + /// One entry per object that has a successfully compiled script. + objects: Vec, + /// 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 = 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 { + 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( + &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 + ))); + } + } + } +} diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index c8f288d..5d539bf 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -92,6 +92,10 @@ fn main() -> ExitCode { game.log(LogLine::raw("Press 'q' to exit.")); game.log(LogLine::raw("Welcome to kiln - ").append(caps.status_logline())); + // Now that the board is fully loaded and the terminal is ready, run each + // scripted object's `init()` hook. + game.run_init(); + let mut ui = Ui::default(); let result = run(&mut terminal, &mut game, &mut ui); diff --git a/maps/start.toml b/maps/start.toml index e0819ae..ce51ffb 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -44,4 +44,12 @@ bg = "#000000" x = 10 y = 10 tile = "#" -passable = true \ No newline at end of file +passable = true +script_name = "greeter" + +[scripts] +greeter = """ +fn init() { + log("hello world"); +} +""" \ No newline at end of file