more scripting
This commit is contained in:
+17
-11
@@ -92,8 +92,8 @@ An earlier design had `cells: Vec<(Glyph, usize)>` where the `usize` indexed a p
|
|||||||
**Why `Board` is the complete unit (no wrapper struct):**
|
**Why `Board` is the complete unit (no wrapper struct):**
|
||||||
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.
|
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`**
|
**`BoardState` / `GameState`**
|
||||||
Owns the `Board`, the message `log: Vec<LogLine>`, 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`.
|
`BoardState` is the scriptable world — currently just `{ board: Board }`, with room for runtime-only state (e.g. a shared variable blackboard). `GameState` holds it behind an `Rc<RefCell<BoardState>>`, alongside the message `log` and a `ScriptHost`. Keeping the world a **sibling** of the script host (rather than owned by it) is the key ownership move: host functions capture a clone of that `Rc` — a `'static` handle to a *different* `RefCell` than the running engine — so they can read/queue writes without aliasing the borrow that's executing scripts. Front-ends and internal logic reach the board only through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (no `pub board`). `try_move` moves the player; `run_init()` / `tick(dt)` run object hooks; `apply_commands()` drains the script command queue *after* each batch — the deferred apply is when `&mut` is finally free of any outstanding borrow.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,11 +103,13 @@ Owns the `Board`, the message `log: Vec<LogLine>`, and a `ScriptHost`. It keeps
|
|||||||
|
|
||||||
### `script.rs` — Rhai scripting runtime
|
### `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.
|
`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 **command queue**. Built with `ScriptHost::new(&Rc<RefCell<BoardState>>)`, which registers the API, compiles scripts, and records each object's available hooks (detected with `AST::iter_functions()` by name/arity) but **runs nothing** — compile/unknown-script failures are queued as `GameCommand::Error`.
|
||||||
|
|
||||||
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.
|
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 become `Error` commands, 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<RefCell<Vec<LogLine>>>` 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.
|
**Reads vs writes.** Scripts **read** the world directly: a read-only `BoardView` (a registered type with getters only — read-only by construction) is pushed into each scope as `board`, so a script writes `board.player_x`; the getter briefly borrows the shared `BoardState`. Scripts **write** by enqueuing `Command { source, kind }` where `kind: GameCommand` is `Move(Direction)` / `SetTile(u32)` / `Log(LogLine)` / `Error(String)`. The host fns `move`/`set_tile`/`log` push into a shared `Rc<RefCell<Vec<Command>>>` cloned into each closure (the same `'static`-closure trick the old log sink used, generalized); `GameState::apply_commands` drains and applies them after the batch. Reads-are-live, writes-are-deferred gives frame-coherent semantics and keeps script execution free of any `&mut GameState` borrow.
|
||||||
|
|
||||||
|
**Sender identity** (which object issued a command) rides Rhai's per-call **tag**: `run` calls `call_fn_with_options(...with_tag(object_index)...)` and the host fns read it via `NativeCallContext::tag()`, so scripts write `move(north)` without a `this` argument (`north`/`south`/`east`/`west` are `Direction` constants in scope; `impl From<Direction> for (i32,i32)` yields the delta). The object's identity is currently its index into `Board::objects` — a stopgap (a `// TODO` flags replacing it with stable unique ids that survive spawn/destroy). Default `call_fn` rewinds the scope per call, so script-local persistence across ticks is still future work; the per-object `Scope` is the seam for that. `Engine`/`Scope` are not `Send` — fine for the single-threaded kiln-tui.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -306,8 +308,11 @@ target_entry = "west_door"
|
|||||||
|
|
||||||
[scripts]
|
[scripts]
|
||||||
greeter = """
|
greeter = """
|
||||||
fn init() { log("hello world"); }
|
fn init() {
|
||||||
fn tick(dt) { }
|
log(`player at ${board.player_x}, ${board.player_y}`); # read
|
||||||
|
set_tile(2); # write
|
||||||
|
}
|
||||||
|
fn tick(dt) { move(north); } # dt = elapsed seconds; dir ∈ north/south/east/west
|
||||||
"""
|
"""
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -331,11 +336,12 @@ A direct mapping from palette character → `(Glyph, Archetype)` means the map f
|
|||||||
|
|
||||||
## What's not yet implemented
|
## What's not yet implemented
|
||||||
|
|
||||||
**Object scripting** — the runtime exists (`script.rs`): objects run optional `init()`/`tick(dt)` hooks and can call `log(s)`. Still to come:
|
**Object scripting** — the runtime exists (`script.rs`): objects run optional `init()`/`tick(dt)` hooks, **read** the world via `board.*`, and **write** via queued commands (`move`, `set_tile`, `log`). Still to come:
|
||||||
1. More event hooks beyond lifecycle — e.g. fire `on_touch` when the player moves onto an object's cell
|
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
|
2. Grow the command vocabulary and read API (`send_message`, randomness, board queries, more glyph setters)
|
||||||
3. Persist script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call)
|
3. **Stable object ids** — `Command.source` / `ObjectRuntime.object_index` are array indices into `Board::objects`, which break under spawn/destroy/reorder; replace with monotonically-increasing unique ids and key objects by them (a `// TODO` marks this in code)
|
||||||
4. `PortalDef` is still parsed-only — no multi-board navigation yet
|
4. Persist script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call)
|
||||||
|
5. `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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -53,16 +53,20 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
|
|||||||
- `Player` — `x: i32, y: i32`
|
- `Player` — `x: i32, y: i32`
|
||||||
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `passable: bool`, `opaque: bool`, `script_name: Option<String>`. `passable` defaults `false` and `opaque` defaults `true` in map files. Its `init()`/`tick(dt)` hooks are run by the scripting runtime (see `script.rs`).
|
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `passable: bool`, `opaque: bool`, `script_name: Option<String>`. `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
|
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
|
||||||
- `GameState` — holds `board: Board`, the message `log: Vec<LogLine>`, 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.
|
- `BoardState` — the scriptable world: currently just `board: Board` (room to grow, e.g. a shared variable blackboard). Held by `GameState` behind `Rc<RefCell<BoardState>>` so script host functions can share a handle to it without aliasing the running engine.
|
||||||
|
- `GameState` — holds `board_state: Rc<RefCell<BoardState>>`, the message `log: Vec<LogLine>`, and a `ScriptHost`. Front-ends/logic reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (there is no `pub board` field). `try_move` moves the player; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` runs object `tick(dt)` hooks every frame. After each script batch, `apply_commands()` drains the script command queue: `Log`/`Error` → `log`, `SetTile` → the source object's glyph, `Move(dir)` → `move_object` (bounds + `is_passable`). This deferred apply (writes after the batch) is what keeps script execution borrow-safe.
|
||||||
|
|
||||||
**`kiln-core/src/log.rs`** — styled log messages:
|
**`kiln-core/src/log.rs`** — styled log messages:
|
||||||
- `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — 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.
|
- `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — 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:
|
**`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).
|
- `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 command queue. Built with `ScriptHost::new(&Rc<RefCell<BoardState>>)` (registers the API, compiles scripts, reports compile/unknown-script failures as `GameCommand::Error`; 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.
|
- 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 become `Error` commands, not fatal.
|
||||||
- Host function: `log(s)` appends an uncolored `LogLine` to the shared sink, drained by `GameState` via `take_pending()`. The sink (an `Rc<RefCell<Vec<LogLine>>>`) is how scripts write to the log without a mutable borrow of `GameState`.
|
- **Reads (direct):** scripts read through `BoardView` — a read-only handle (getters only, so read-only by construction) pushed into each scope as `board`, e.g. `board.player_x`, `board.player_y`, `board.width`, `board.height`. Each getter briefly borrows the shared `BoardState`.
|
||||||
|
- **Writes (command queue):** host fns `move(dir)`, `set_tile(n)`, `log(s)` push a `Command { source, kind: GameCommand }` (`GameCommand` = `Move(Direction)` / `SetTile(u32)` / `Log(LogLine)` / `Error(String)`) into a shared `Rc<RefCell<Vec<Command>>>`, drained by `GameState::take_commands` and applied after the batch. This is how scripts mutate without a `&mut GameState` borrow.
|
||||||
|
- **Sender identity:** `source` (which object issued a command) rides the per-call **tag** — `run` calls `call_fn_with_options(...with_tag(object_index)...)` and the host fns read it via `NativeCallContext::tag()`. Scripts write `move(north)` without naming themselves; `north`/`south`/`east`/`west` are `Direction` constants in scope, and `impl From<Direction> for (i32,i32)` gives the delta.
|
||||||
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
|
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
|
||||||
|
- **TODO in-code:** `Command.source` (and `ObjectRuntime.object_index`) are array indices into `Board::objects` — a stopgap that breaks under object spawn/destroy/reorder; should become a monotonically-increasing unique object id with objects keyed by it.
|
||||||
|
|
||||||
**`kiln-core/src/map_file.rs`** — map file loading:
|
**`kiln-core/src/map_file.rs`** — map file loading:
|
||||||
- `MapFile` and friends — serde `Deserialize` types for TOML map files
|
- `MapFile` and friends — serde `Deserialize` types for TOML map files
|
||||||
@@ -196,12 +200,15 @@ target_entry = "west_door"
|
|||||||
# Named Rhai scripts, referenced by objects via `script_name`.
|
# Named Rhai scripts, referenced by objects via `script_name`.
|
||||||
[scripts]
|
[scripts]
|
||||||
greeter = """
|
greeter = """
|
||||||
fn init() { log("hello world"); } # optional, run once at startup
|
fn init() { # optional, run once at startup
|
||||||
fn tick(dt) { } # optional, run every frame with elapsed seconds
|
log(`player at ${board.player_x}, ${board.player_y}`); # read the world
|
||||||
|
set_tile(2); # write: change my glyph
|
||||||
|
}
|
||||||
|
fn tick(dt) { move(north); } # optional, run every frame; dt = 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. 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)`.
|
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; they **read** the world through `board.*` (e.g. `board.player_x`) and **write** via host functions `move(dir)` (`dir` ∈ `north`/`south`/`east`/`west`), `set_tile(n)`, and `log(s)`.
|
||||||
|
|
||||||
### Key design decisions
|
### Key design decisions
|
||||||
|
|
||||||
|
|||||||
+245
-30
@@ -1,9 +1,11 @@
|
|||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::script::ScriptHost;
|
use crate::script::{Direction, GameCommand, ScriptHost};
|
||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::cell::{Ref, RefCell, RefMut};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// The visual representation of a single board cell.
|
/// The visual representation of a single board cell.
|
||||||
@@ -43,6 +45,7 @@ impl Glyph {
|
|||||||
/// This is the only hardcoded glyph; all other glyphs come from the map
|
/// This is the only hardcoded glyph; all other glyphs come from the map
|
||||||
/// file palette. It will be removed once the player becomes a scripted
|
/// file palette. It will be removed once the player becomes a scripted
|
||||||
/// object with its own palette entry.
|
/// object with its own palette entry.
|
||||||
|
#[rustfmt::skip]
|
||||||
pub const fn player() -> Self {
|
pub const fn player() -> Self {
|
||||||
Self {
|
Self {
|
||||||
tile: 64,
|
tile: 64,
|
||||||
@@ -145,6 +148,7 @@ impl Archetype {
|
|||||||
///
|
///
|
||||||
/// This glyph is used only for new cells created in the editor; existing
|
/// This glyph is used only for new cells created in the editor; existing
|
||||||
/// cells retain their own per-cell glyph.
|
/// cells retain their own per-cell glyph.
|
||||||
|
#[rustfmt::skip]
|
||||||
pub fn default_glyph(&self) -> Glyph {
|
pub fn default_glyph(&self) -> Glyph {
|
||||||
match self {
|
match self {
|
||||||
Archetype::Empty => Glyph {
|
Archetype::Empty => Glyph {
|
||||||
@@ -229,6 +233,7 @@ pub struct ObjectDef {
|
|||||||
|
|
||||||
impl ObjectDef {
|
impl ObjectDef {
|
||||||
/// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black.
|
/// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black.
|
||||||
|
#[rustfmt::skip]
|
||||||
pub fn default_glyph() -> Glyph {
|
pub fn default_glyph() -> Glyph {
|
||||||
Glyph {
|
Glyph {
|
||||||
tile: 63,
|
tile: 63,
|
||||||
@@ -368,7 +373,9 @@ impl Board {
|
|||||||
/// Panics if `x` or `y` are out of bounds.
|
/// Panics if `x` or `y` are out of bounds.
|
||||||
pub fn is_passable(&self, x: usize, y: usize) -> bool {
|
pub fn is_passable(&self, x: usize, y: usize) -> bool {
|
||||||
// An object on this cell blocks if it's unpassable
|
// An object on this cell blocks if it's unpassable
|
||||||
if let Some(obj) = self.object_at(x, y) && !obj.passable {
|
if let Some(obj) = self.object_at(x, y)
|
||||||
|
&& !obj.passable
|
||||||
|
{
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
self.get(x, y).1.behavior().passable
|
self.get(x, y).1.behavior().passable
|
||||||
@@ -386,16 +393,30 @@ impl Board {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Holds the active game board and provides game-logic operations.
|
/// The scriptable world: the current [`Board`] plus (in the future) runtime-only
|
||||||
|
/// state such as a shared variable blackboard.
|
||||||
|
///
|
||||||
|
/// `BoardState` is a sibling of the [`ScriptHost`] inside [`GameState`] and is
|
||||||
|
/// held behind an `Rc<RefCell<…>>`. Keeping it separate from the script engine is
|
||||||
|
/// what lets host functions hold a shared handle to the world without aliasing the
|
||||||
|
/// borrow that is currently running the engine.
|
||||||
|
pub struct BoardState {
|
||||||
|
/// The active game board.
|
||||||
|
pub board: Board,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the active game world and provides game-logic operations.
|
||||||
///
|
///
|
||||||
/// `GameState` is the boundary between the engine (rendering, input) and the
|
/// `GameState` is the boundary between the engine (rendering, input) and the
|
||||||
/// game data ([`Board`]). It owns the current board, the message log, and the
|
/// game data. It owns the world ([`BoardState`], behind a shared `Rc<RefCell>`),
|
||||||
/// [`ScriptHost`] driving the board's object scripts, and exposes methods for
|
/// the message log, and the [`ScriptHost`] driving object scripts. Front-ends and
|
||||||
/// actions that involve game rules: player movement, the per-frame
|
/// internal logic reach the board through [`board`](GameState::board) /
|
||||||
/// [`tick`](GameState::tick), and the one-time [`run_init`](GameState::run_init).
|
/// [`board_mut`](GameState::board_mut). Scripts read the board directly and
|
||||||
|
/// request mutations as commands, applied by [`apply_commands`](GameState::apply_commands)
|
||||||
|
/// after each script batch.
|
||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
/// The currently active board.
|
/// The scriptable world, shared with the script host's read getters.
|
||||||
pub board: Board,
|
board_state: Rc<RefCell<BoardState>>,
|
||||||
/// The in-game message log, oldest first (newest pushed at the end).
|
/// The in-game message log, oldest first (newest pushed at the end).
|
||||||
pub log: Vec<LogLine>,
|
pub log: Vec<LogLine>,
|
||||||
/// The Rhai scripting runtime driving this board's scripted objects.
|
/// The Rhai scripting runtime driving this board's scripted objects.
|
||||||
@@ -409,42 +430,89 @@ impl GameState {
|
|||||||
/// call [`GameState::run_init`] once the game is ready to start. Any script
|
/// call [`GameState::run_init`] once the game is ready to start. Any script
|
||||||
/// compile errors are surfaced into the log here.
|
/// compile errors are surfaced into the log here.
|
||||||
pub fn new(board: Board) -> Self {
|
pub fn new(board: Board) -> Self {
|
||||||
let scripts = ScriptHost::new(&board);
|
let board_state = Rc::new(RefCell::new(BoardState { board }));
|
||||||
|
let scripts = ScriptHost::new(&board_state);
|
||||||
let mut state = Self {
|
let mut state = Self {
|
||||||
board,
|
board_state,
|
||||||
log: Vec::new(),
|
log: Vec::new(),
|
||||||
scripts,
|
scripts,
|
||||||
};
|
};
|
||||||
// Surface any compile-time script errors collected during ScriptHost::new.
|
// Surface any compile-time script errors collected during ScriptHost::new.
|
||||||
state.flush_script_log();
|
state.apply_commands();
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borrows the active board for reading (e.g. by a front-end renderer).
|
||||||
|
pub fn board(&self) -> Ref<'_, Board> {
|
||||||
|
Ref::map(self.board_state.borrow(), |bs| &bs.board)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrows the active board for mutation.
|
||||||
|
pub fn board_mut(&self) -> RefMut<'_, Board> {
|
||||||
|
RefMut::map(self.board_state.borrow_mut(), |bs| &mut bs.board)
|
||||||
|
}
|
||||||
|
|
||||||
/// Appends a styled message to the log.
|
/// Appends a styled message to the log.
|
||||||
pub fn log(&mut self, line: LogLine) {
|
pub fn log(&mut self, line: LogLine) {
|
||||||
self.log.push(line);
|
self.log.push(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the `init()` hook of every scripted object. Call once, after the
|
/// Runs the `init()` hook of every scripted object, then applies the commands
|
||||||
/// whole map is loaded and the game is about to start — never during map
|
/// they queued. Call once, after the whole map is loaded and the game is about
|
||||||
/// deserialization, since a script may inspect the board.
|
/// to start — never during map deserialization, since a script may inspect the
|
||||||
|
/// board.
|
||||||
pub fn run_init(&mut self) {
|
pub fn run_init(&mut self) {
|
||||||
self.scripts.run_init();
|
self.scripts.run_init();
|
||||||
self.flush_script_log();
|
self.apply_commands();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advances real-time game state by `dt` (the elapsed time since the last
|
/// 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
|
/// tick). Called once per frame by the front-end's game loop; drives every
|
||||||
/// scripted object's `tick(dt)` hook.
|
/// scripted object's `tick(dt)` hook, then applies the commands they queued.
|
||||||
pub fn tick(&mut self, dt: Duration) {
|
pub fn tick(&mut self, dt: Duration) {
|
||||||
self.scripts.run_tick(dt.as_secs_f64());
|
self.scripts.run_tick(dt.as_secs_f64());
|
||||||
self.flush_script_log();
|
self.apply_commands();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves any messages queued by scripts (via the `log` host function) into
|
/// Drains the script command queue and applies each command. Runs *after* a
|
||||||
/// the real game log.
|
/// script batch, when nothing holds a borrow of the board — so the mutations
|
||||||
fn flush_script_log(&mut self) {
|
/// here can't conflict with the read getters scripts use during execution.
|
||||||
self.log.extend(self.scripts.take_pending());
|
fn apply_commands(&mut self) {
|
||||||
|
for cmd in self.scripts.take_commands() {
|
||||||
|
match cmd.kind {
|
||||||
|
GameCommand::Log(line) => self.log.push(line),
|
||||||
|
// TODO: errors are only logged for now. This is the place to halt
|
||||||
|
// execution / set an error state when a script faults.
|
||||||
|
GameCommand::Error(msg) => self.log.push(LogLine::raw(msg)),
|
||||||
|
GameCommand::SetTile(tile) => {
|
||||||
|
if let Some(obj) = self.board_mut().objects.get_mut(cmd.source) {
|
||||||
|
obj.glyph.tile = tile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GameCommand::Move(dir) => self.move_object(cmd.source, dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves object `idx` one cell in `dir`, if the target is in bounds and
|
||||||
|
/// passable. This is where movement rules live (a no-op when blocked).
|
||||||
|
fn move_object(&mut self, idx: usize, dir: Direction) {
|
||||||
|
let (dx, dy): (i32, i32) = dir.into();
|
||||||
|
let mut board = self.board_mut();
|
||||||
|
let Some((ox, oy)) = board.objects.get(idx).map(|o| (o.x, o.y)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let nx = ox as i32 + dx;
|
||||||
|
let ny = oy as i32 + dy;
|
||||||
|
if nx < 0 || ny < 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (nx, ny) = (nx as usize, ny as usize);
|
||||||
|
if nx < board.width && ny < board.height && board.is_passable(nx, ny) {
|
||||||
|
let obj = &mut board.objects[idx];
|
||||||
|
obj.x = nx;
|
||||||
|
obj.y = ny;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to move the player by `(dx, dy)` cells.
|
/// Attempts to move the player by `(dx, dy)` cells.
|
||||||
@@ -452,14 +520,15 @@ impl GameState {
|
|||||||
/// The move is ignored if the target cell is out of bounds or its behavior
|
/// The move is ignored if the target cell is out of bounds or its behavior
|
||||||
/// is not passable. No-ops silently (the caller does not need to check).
|
/// is not passable. No-ops silently (the caller does not need to check).
|
||||||
pub fn try_move(&mut self, dx: i32, dy: i32) {
|
pub fn try_move(&mut self, dx: i32, dy: i32) {
|
||||||
let nx = self.board.player.x + dx;
|
let mut board = self.board_mut();
|
||||||
let ny = self.board.player.y + dy;
|
let nx = board.player.x + dx;
|
||||||
|
let ny = board.player.y + dy;
|
||||||
if nx >= 0 && ny >= 0 {
|
if nx >= 0 && ny >= 0 {
|
||||||
let nx = nx as usize;
|
let nx = nx as usize;
|
||||||
let ny = ny as usize;
|
let ny = ny as usize;
|
||||||
if nx < self.board.width && ny < self.board.height && self.board.is_passable(nx, ny) {
|
if nx < board.width && ny < board.height && board.is_passable(nx, ny) {
|
||||||
self.board.player.x = nx as i32;
|
board.player.x = nx as i32;
|
||||||
self.board.player.y = ny as i32;
|
board.player.y = ny as i32;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,6 +561,44 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An `ObjectDef` at `(x, y)` bound to the named script.
|
||||||
|
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
|
||||||
|
let mut o = ObjectDef::new(x, y);
|
||||||
|
o.script_name = Some(script.to_string());
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an all-empty (passable) `w×h` board with the given player position,
|
||||||
|
/// objects, and `(name, source)` scripts — room for movement, unlike the 1×1
|
||||||
|
/// `board_with_object`.
|
||||||
|
fn open_board(
|
||||||
|
w: usize,
|
||||||
|
h: usize,
|
||||||
|
player: (i32, i32),
|
||||||
|
objects: Vec<ObjectDef>,
|
||||||
|
scripts: &[(&str, &str)],
|
||||||
|
) -> Board {
|
||||||
|
Board {
|
||||||
|
name: "test".into(),
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
|
||||||
|
player: Player {
|
||||||
|
x: player.0,
|
||||||
|
y: player.1,
|
||||||
|
},
|
||||||
|
objects,
|
||||||
|
portals: Vec::new(),
|
||||||
|
font: None,
|
||||||
|
zoom: 1,
|
||||||
|
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.
|
/// Flattens each log line into a single string for easy assertions.
|
||||||
fn log_texts(game: &GameState) -> Vec<String> {
|
fn log_texts(game: &GameState) -> Vec<String> {
|
||||||
game.log
|
game.log
|
||||||
@@ -502,7 +609,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn init_runs_only_on_run_init_not_at_construction() {
|
fn init_runs_only_on_run_init_not_at_construction() {
|
||||||
let board = board_with_object(Some("greet"), &[("greet", r#"fn init() { log("hello"); }"#)]);
|
let board = board_with_object(
|
||||||
|
Some("greet"),
|
||||||
|
&[("greet", r#"fn init() { log("hello"); }"#)],
|
||||||
|
);
|
||||||
let mut game = GameState::new(board);
|
let mut game = GameState::new(board);
|
||||||
// init must not fire during construction / deserialization.
|
// init must not fire during construction / deserialization.
|
||||||
assert!(game.log.is_empty());
|
assert!(game.log.is_empty());
|
||||||
@@ -538,8 +648,10 @@ mod tests {
|
|||||||
assert!(game.log.is_empty());
|
assert!(game.log.is_empty());
|
||||||
|
|
||||||
// Script defines neither init nor tick: also a no-op.
|
// Script defines neither init nor tick: also a no-op.
|
||||||
let mut game =
|
let mut game = GameState::new(board_with_object(
|
||||||
GameState::new(board_with_object(Some("e"), &[("e", "fn other() { log(\"x\"); }")]));
|
Some("e"),
|
||||||
|
&[("e", "fn other() { log(\"x\"); }")],
|
||||||
|
));
|
||||||
game.run_init();
|
game.run_init();
|
||||||
game.tick(Duration::from_millis(33));
|
game.tick(Duration::from_millis(33));
|
||||||
assert!(game.log.is_empty());
|
assert!(game.log.is_empty());
|
||||||
@@ -555,4 +667,107 @@ mod tests {
|
|||||||
let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")]));
|
let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")]));
|
||||||
assert!(log_texts(&game)[0].contains("failed to compile"));
|
assert!(log_texts(&game)[0].contains("failed to compile"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn script_reads_board_through_view() {
|
||||||
|
let board = open_board(
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
(3, 1),
|
||||||
|
vec![scripted_object(2, 1, "r")],
|
||||||
|
&[("r", "fn init() { log(board.player_x.to_string()); }")],
|
||||||
|
);
|
||||||
|
let mut game = GameState::new(board);
|
||||||
|
game.run_init();
|
||||||
|
// The view reported the player's x (3).
|
||||||
|
assert_eq!(log_texts(&game), vec!["3"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_command_relocates_the_source_object() {
|
||||||
|
let board = open_board(
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
(0, 0),
|
||||||
|
vec![scripted_object(2, 1, "m")],
|
||||||
|
&[("m", "fn init() { move(east); }")],
|
||||||
|
);
|
||||||
|
let mut game = GameState::new(board);
|
||||||
|
game.run_init();
|
||||||
|
let b = game.board();
|
||||||
|
// East increments x by one; the object started at (2, 1).
|
||||||
|
assert_eq!((b.objects[0].x, b.objects[0].y), (3, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_into_a_wall_or_edge_is_a_noop() {
|
||||||
|
// Object at the west edge moving west: out of bounds, ignored.
|
||||||
|
let board = open_board(
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
(0, 0),
|
||||||
|
vec![scripted_object(0, 1, "m")],
|
||||||
|
&[("m", "fn init() { move(west); }")],
|
||||||
|
);
|
||||||
|
let mut game = GameState::new(board);
|
||||||
|
game.run_init();
|
||||||
|
assert_eq!(game.board().objects[0].x, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_tile_command_changes_the_source_glyph() {
|
||||||
|
let board = open_board(
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
(0, 0),
|
||||||
|
vec![scripted_object(2, 1, "s")],
|
||||||
|
&[("s", "fn init() { set_tile(7); }")],
|
||||||
|
);
|
||||||
|
let mut game = GameState::new(board);
|
||||||
|
game.run_init();
|
||||||
|
assert_eq!(game.board().objects[0].glyph.tile, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_map_greeter_runs_init() {
|
||||||
|
// End-to-end against the shipped example map: load it, run init, and
|
||||||
|
// confirm the greeter read the board (interpolated message) and wrote to
|
||||||
|
// itself (set_tile). Also guards the example from drifting out of sync.
|
||||||
|
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml");
|
||||||
|
let board = crate::map_file::load(path).expect("load start.toml");
|
||||||
|
let mut game = GameState::new(board);
|
||||||
|
game.run_init();
|
||||||
|
assert!(
|
||||||
|
log_texts(&game)
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.contains("hello from object")),
|
||||||
|
"greeter init should log a greeting"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
game.board().objects.iter().any(|o| o.glyph.tile == 2),
|
||||||
|
"greeter set_tile(2) should change its glyph"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commands_are_routed_to_their_own_source_object() {
|
||||||
|
// Two objects with different scripts move in opposite directions; each
|
||||||
|
// must affect only itself (the per-call tag routes the command source).
|
||||||
|
// Targets are kept apart so the impassable objects don't block each other.
|
||||||
|
let board = open_board(
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
(0, 0),
|
||||||
|
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
|
||||||
|
&[
|
||||||
|
("e", "fn init() { move(east); }"),
|
||||||
|
("w", "fn init() { move(west); }"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let mut game = GameState::new(board);
|
||||||
|
game.run_init();
|
||||||
|
let b = game.board();
|
||||||
|
assert_eq!(b.objects[0].x, 1); // moved east from 0
|
||||||
|
assert_eq!(b.objects[1].x, 3); // moved west from 4
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-10
@@ -152,16 +152,27 @@ pub(crate) struct MapFileObjectEntry {
|
|||||||
script_name: Option<String>,
|
script_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool { true }
|
fn default_true() -> bool {
|
||||||
fn default_zoom() -> u32 { 1 }
|
true
|
||||||
fn is_zoom_default(z: &u32) -> bool { *z == 1 }
|
}
|
||||||
|
fn default_zoom() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn is_zoom_default(z: &u32) -> bool {
|
||||||
|
*z == 1
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`].
|
/// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`].
|
||||||
/// Returns opaque black on any parse failure.
|
/// Returns opaque black on any parse failure.
|
||||||
fn parse_color(hex: &str) -> Rgba8 {
|
fn parse_color(hex: &str) -> Rgba8 {
|
||||||
let hex = hex.trim_start_matches('#');
|
let hex = hex.trim_start_matches('#');
|
||||||
if hex.len() != 6 {
|
if hex.len() != 6 {
|
||||||
return Rgba8 { r: 0, g: 0, b: 0, a: 255 };
|
return Rgba8 {
|
||||||
|
r: 0,
|
||||||
|
g: 0,
|
||||||
|
b: 0,
|
||||||
|
a: 255,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||||||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||||||
@@ -475,8 +486,14 @@ content = """
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
// Use .err().unwrap() instead of .unwrap_err() to avoid requiring Board: Debug.
|
// Use .err().unwrap() instead of .unwrap_err() to avoid requiring Board: Debug.
|
||||||
let msg = result.err().unwrap();
|
let msg = result.err().unwrap();
|
||||||
assert!(msg.contains("2 rows"), "expected row count in error, got: {msg}");
|
assert!(
|
||||||
assert!(msg.contains("height = 3"), "expected declared height in error, got: {msg}");
|
msg.contains("2 rows"),
|
||||||
|
"expected row count in error, got: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("height = 3"),
|
||||||
|
"expected declared height in error, got: {msg}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -487,8 +504,14 @@ content = """
|
|||||||
let result = Board::try_from(mf);
|
let result = Board::try_from(mf);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let msg = result.err().unwrap();
|
let msg = result.err().unwrap();
|
||||||
assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}");
|
assert!(
|
||||||
assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}");
|
msg.contains("3 characters"),
|
||||||
|
"expected col count in error, got: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("width = 4"),
|
||||||
|
"expected declared width in error, got: {msg}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -549,8 +572,24 @@ bg = "#000000"
|
|||||||
assert_eq!(obj.x, 1);
|
assert_eq!(obj.x, 1);
|
||||||
assert_eq!(obj.y, 1);
|
assert_eq!(obj.y, 1);
|
||||||
assert_eq!(obj.glyph.tile, 64);
|
assert_eq!(obj.glyph.tile, 64);
|
||||||
assert_eq!(obj.glyph.fg, Rgba8 { r: 0x00, g: 0xFF, b: 0xFF, a: 255 });
|
assert_eq!(
|
||||||
assert_eq!(obj.glyph.bg, Rgba8 { r: 0, g: 0, b: 0, a: 255 });
|
obj.glyph.fg,
|
||||||
|
Rgba8 {
|
||||||
|
r: 0x00,
|
||||||
|
g: 0xFF,
|
||||||
|
b: 0xFF,
|
||||||
|
a: 255
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
obj.glyph.bg,
|
||||||
|
Rgba8 {
|
||||||
|
r: 0,
|
||||||
|
g: 0,
|
||||||
|
b: 0,
|
||||||
|
a: 255
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Save back to TOML and reload; glyph must survive.
|
// Save back to TOML and reload; glyph must survive.
|
||||||
let map_file = MapFile::from(&board);
|
let map_file = MapFile::from(&board);
|
||||||
|
|||||||
+217
-54
@@ -7,21 +7,84 @@
|
|||||||
//! - `init()` — run once after the whole map is loaded (see [`ScriptHost::run_init`]).
|
//! - `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`]).
|
//! - `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
|
//! ## Reads vs. writes
|
||||||
//! game log. Because Rhai's registered closures must be `'static`, `log` writes
|
//!
|
||||||
//! into a shared [`LogSink`] that [`ScriptHost::take_pending`] drains; the caller
|
//! Scripts **read** the world directly through a read-only [`BoardView`] (getters
|
||||||
//! ([`crate::game::GameState`]) then moves those lines into the real log. This
|
//! only) pushed into each scope as `board`, e.g. `board.player_x`. The view holds
|
||||||
//! keeps script execution from needing a mutable borrow of the game state.
|
//! an `Rc<RefCell<BoardState>>` and borrows it briefly per getter.
|
||||||
|
//!
|
||||||
|
//! Scripts **write** by enqueuing [`Command`]s: host functions (`move`,
|
||||||
|
//! `set_tile`, `log`) push into a shared queue that [`GameState`] drains and
|
||||||
|
//! applies *after* each batch ([`ScriptHost::take_commands`]). This keeps script
|
||||||
|
//! execution from ever needing a mutable borrow of the game state, and makes
|
||||||
|
//! structural/ordering effects deterministic. Which object issued a command is
|
||||||
|
//! read from the per-call **tag** (set to the object index in [`ScriptHost::run`]),
|
||||||
|
//! so scripts write `move(north)` without naming themselves.
|
||||||
|
//!
|
||||||
|
//! [`GameState`]: crate::game::GameState
|
||||||
|
|
||||||
use crate::game::Board;
|
use crate::game::BoardState;
|
||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use rhai::{Engine, FuncArgs, ImmutableString, Scope, AST};
|
use rhai::{AST, CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
/// Shared buffer the Rhai `log` function appends to, drained into the game log.
|
/// Shared queue the write host functions push into; drained by [`ScriptHost::take_commands`].
|
||||||
type LogSink = Rc<RefCell<Vec<LogLine>>>;
|
type CommandQueue = Rc<RefCell<Vec<Command>>>;
|
||||||
|
|
||||||
|
/// A mutation requested by a script, applied by [`crate::game::GameState`] after
|
||||||
|
/// the script batch finishes.
|
||||||
|
pub struct Command {
|
||||||
|
/// The object that issued the command.
|
||||||
|
// TODO: `source` is an index into `Board::objects`, which is a stopgap — it
|
||||||
|
// breaks under object spawn/destroy/reorder. Replace with a monotonically
|
||||||
|
// increasing unique object id, with objects stored keyed by it (e.g.
|
||||||
|
// `BTreeMap<ObjectId, ObjectDef>`). Mirrored on `ObjectRuntime::object_index`.
|
||||||
|
pub source: usize,
|
||||||
|
/// What the command does.
|
||||||
|
pub kind: GameCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kinds of mutation a script can request.
|
||||||
|
pub enum GameCommand {
|
||||||
|
/// Move the source object one cell in a direction (subject to passability).
|
||||||
|
Move(Direction),
|
||||||
|
/// Set the source object's glyph tile index.
|
||||||
|
SetTile(u32),
|
||||||
|
/// Append a plain (uncolored) line to the game log.
|
||||||
|
Log(LogLine),
|
||||||
|
/// A script/engine failure. Applying it logs the message for now; kept a
|
||||||
|
/// distinct variant so execution can later be halted on error.
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cardinal movement direction.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum Direction {
|
||||||
|
North,
|
||||||
|
South,
|
||||||
|
East,
|
||||||
|
West,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Direction> for (i32, i32) {
|
||||||
|
/// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down).
|
||||||
|
fn from(d: Direction) -> Self {
|
||||||
|
match d {
|
||||||
|
Direction::North => (0, -1),
|
||||||
|
Direction::South => (0, 1),
|
||||||
|
Direction::East => (1, 0),
|
||||||
|
Direction::West => (-1, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read-only handle to the world, exposed to scripts as `board`. Holds a shared
|
||||||
|
/// reference to the [`BoardState`]; only getters are registered, so it is
|
||||||
|
/// read-only by construction.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct BoardView(Rc<RefCell<BoardState>>);
|
||||||
|
|
||||||
/// A compiled script plus which lifecycle hooks it defines.
|
/// A compiled script plus which lifecycle hooks it defines.
|
||||||
struct CompiledScript {
|
struct CompiledScript {
|
||||||
@@ -36,49 +99,54 @@ struct CompiledScript {
|
|||||||
/// The runtime state of one scripted object: which script it runs and its own
|
/// The runtime state of one scripted object: which script it runs and its own
|
||||||
/// persistent Rhai scope.
|
/// persistent Rhai scope.
|
||||||
struct ObjectRuntime {
|
struct ObjectRuntime {
|
||||||
/// Index into [`Board::objects`]. Unused for now; kept as the seam for
|
/// Index into [`crate::game::Board::objects`]; passed to scripts as the
|
||||||
/// giving scripts access to their own object.
|
/// per-call tag so host functions know which object issued a command.
|
||||||
#[allow(dead_code)]
|
// TODO: see [`Command::source`] — array indices should become stable ids.
|
||||||
object_index: usize,
|
object_index: usize,
|
||||||
/// Key into [`ScriptHost::scripts`] naming this object's compiled script.
|
/// Key into [`ScriptHost::scripts`] naming this object's compiled script.
|
||||||
script_name: String,
|
script_name: String,
|
||||||
/// Per-object scope, so injected/object-local state can persist across calls.
|
/// Per-object scope (holds the `board` view + direction constants, and can
|
||||||
|
/// hold object-local state in the future). Persists across calls.
|
||||||
scope: Scope<'static>,
|
scope: Scope<'static>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns the Rhai engine and per-object script state for a board.
|
/// Owns the Rhai engine and per-object script state for a board.
|
||||||
pub struct ScriptHost {
|
pub struct ScriptHost {
|
||||||
/// The Rhai engine, with host functions (`log`) registered.
|
/// The Rhai engine, with the read getters and write host functions registered.
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
/// Compiled scripts, keyed by script name; compiled once per referenced name.
|
/// Compiled scripts, keyed by script name; compiled once per referenced name.
|
||||||
scripts: HashMap<String, CompiledScript>,
|
scripts: HashMap<String, CompiledScript>,
|
||||||
/// One entry per object that has a successfully compiled script.
|
/// One entry per object that has a successfully compiled script.
|
||||||
objects: Vec<ObjectRuntime>,
|
objects: Vec<ObjectRuntime>,
|
||||||
/// Buffer the `log` host function writes to; drained by [`Self::take_pending`].
|
/// Queue the write host functions push into; drained by [`Self::take_commands`].
|
||||||
log_sink: LogSink,
|
commands: CommandQueue,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScriptHost {
|
impl ScriptHost {
|
||||||
/// Builds a host for `board`: registers host functions, compiles every script
|
/// Builds a host for the board behind `state`: registers the read/write API,
|
||||||
/// referenced by an object (once each), and creates a fresh scope per scripted
|
/// compiles every script referenced by an object (once each), and creates a
|
||||||
/// object. Compile errors and references to unknown scripts are reported as
|
/// fresh scope per scripted object. Compile errors and references to unknown
|
||||||
/// log lines (retrievable via [`Self::take_pending`]); no script is run here.
|
/// scripts are queued as [`GameCommand::Error`] (retrievable via
|
||||||
pub fn new(board: &Board) -> Self {
|
/// [`Self::take_commands`]); no script is run here.
|
||||||
let log_sink: LogSink = Rc::new(RefCell::new(Vec::new()));
|
pub fn new(state: &Rc<RefCell<BoardState>>) -> Self {
|
||||||
|
let commands: CommandQueue = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
|
||||||
let mut engine = Engine::new();
|
let mut engine = Engine::new();
|
||||||
// `log(s)` — append a plain (uncolored) message to the game log.
|
register_read_api(&mut engine);
|
||||||
{
|
register_write_api(&mut engine, &commands);
|
||||||
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 board_state = state.borrow();
|
||||||
|
let board = &board_state.board;
|
||||||
|
|
||||||
|
// Compile each referenced script once; attribute failures to the first
|
||||||
|
// object that references the script.
|
||||||
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
|
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
|
||||||
for name in board.objects.iter().filter_map(|o| o.script_name.as_ref()) {
|
let mut failed: HashSet<String> = HashSet::new();
|
||||||
if scripts.contains_key(name) {
|
for (i, obj) in board.objects.iter().enumerate() {
|
||||||
|
let Some(name) = obj.script_name.as_ref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if scripts.contains_key(name) || failed.contains(name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match board.scripts.get(name) {
|
match board.scripts.get(name) {
|
||||||
@@ -100,13 +168,25 @@ impl ScriptHost {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(err) => log_sink.borrow_mut().push(LogLine::raw(format!(
|
Err(err) => {
|
||||||
"script '{name}' failed to compile: {err}"
|
failed.insert(name.clone());
|
||||||
))),
|
commands.borrow_mut().push(Command {
|
||||||
|
source: i,
|
||||||
|
kind: GameCommand::Error(format!(
|
||||||
|
"script '{name}' failed to compile: {err}"
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None => log_sink
|
None => {
|
||||||
.borrow_mut()
|
failed.insert(name.clone());
|
||||||
.push(LogLine::raw(format!("object references unknown script '{name}'"))),
|
commands.borrow_mut().push(Command {
|
||||||
|
source: i,
|
||||||
|
kind: GameCommand::Error(format!(
|
||||||
|
"object references unknown script '{name}'"
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,21 +195,23 @@ impl ScriptHost {
|
|||||||
.objects
|
.objects
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, o)| {
|
.filter_map(|(i, obj)| {
|
||||||
let name = o.script_name.as_ref()?;
|
let name = obj.script_name.as_ref()?;
|
||||||
scripts.contains_key(name).then(|| ObjectRuntime {
|
scripts.contains_key(name).then(|| ObjectRuntime {
|
||||||
object_index: i,
|
object_index: i,
|
||||||
script_name: name.clone(),
|
script_name: name.clone(),
|
||||||
scope: Scope::new(),
|
scope: new_object_scope(state),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
drop(board_state);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
engine,
|
engine,
|
||||||
scripts,
|
scripts,
|
||||||
objects,
|
objects,
|
||||||
log_sink,
|
commands,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,14 +226,15 @@ impl ScriptHost {
|
|||||||
self.run("tick", |c| c.has_tick, (dt,));
|
self.run("tick", |c| c.has_tick, (dt,));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes and returns the messages queued by `log` since the last drain.
|
/// Removes and returns the commands queued by scripts since the last drain.
|
||||||
pub fn take_pending(&mut self) -> Vec<LogLine> {
|
pub fn take_commands(&mut self) -> Vec<Command> {
|
||||||
std::mem::take(&mut *self.log_sink.borrow_mut())
|
std::mem::take(&mut self.commands.borrow_mut())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared driver for the lifecycle hooks: for each object whose script
|
/// Shared driver for the lifecycle hooks: for each object whose script
|
||||||
/// `defined` reports the hook, call it with `args`. Runtime errors are
|
/// `defined` reports the hook, call it with `args`, tagging the call with the
|
||||||
/// captured into the log sink rather than aborting the batch.
|
/// object index so host functions know the command source. Runtime errors are
|
||||||
|
/// captured as [`GameCommand::Error`] rather than aborting the batch.
|
||||||
fn run<A: FuncArgs + Copy>(
|
fn run<A: FuncArgs + Copy>(
|
||||||
&mut self,
|
&mut self,
|
||||||
hook: &str,
|
hook: &str,
|
||||||
@@ -163,7 +246,7 @@ impl ScriptHost {
|
|||||||
engine,
|
engine,
|
||||||
scripts,
|
scripts,
|
||||||
objects,
|
objects,
|
||||||
log_sink,
|
commands,
|
||||||
} = self;
|
} = self;
|
||||||
for obj in objects.iter_mut() {
|
for obj in objects.iter_mut() {
|
||||||
let Some(compiled) = scripts.get(&obj.script_name) else {
|
let Some(compiled) = scripts.get(&obj.script_name) else {
|
||||||
@@ -172,12 +255,92 @@ impl ScriptHost {
|
|||||||
if !defined(compiled) {
|
if !defined(compiled) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Err(err) = engine.call_fn::<()>(&mut obj.scope, &compiled.ast, hook, args) {
|
// The tag carries this object's index to the host functions.
|
||||||
log_sink.borrow_mut().push(LogLine::raw(format!(
|
let options = CallFnOptions::default().with_tag(obj.object_index as i64);
|
||||||
"script '{}' {hook} error: {err}",
|
if let Err(err) = engine.call_fn_with_options::<()>(
|
||||||
obj.script_name
|
options,
|
||||||
)));
|
&mut obj.scope,
|
||||||
|
&compiled.ast,
|
||||||
|
hook,
|
||||||
|
args,
|
||||||
|
) {
|
||||||
|
commands.borrow_mut().push(Command {
|
||||||
|
source: obj.object_index,
|
||||||
|
kind: GameCommand::Error(format!(
|
||||||
|
"script '{}' {hook} error: {err}",
|
||||||
|
obj.script_name
|
||||||
|
)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers the read-only `board` API: a [`BoardView`] type with getters that
|
||||||
|
/// borrow the shared state briefly.
|
||||||
|
fn register_read_api(engine: &mut Engine) {
|
||||||
|
engine.register_type_with_name::<BoardView>("BoardView");
|
||||||
|
engine.register_get("player_x", |b: &mut BoardView| {
|
||||||
|
b.0.borrow().board.player.x as i64
|
||||||
|
});
|
||||||
|
engine.register_get("player_y", |b: &mut BoardView| {
|
||||||
|
b.0.borrow().board.player.y as i64
|
||||||
|
});
|
||||||
|
engine.register_get("width", |b: &mut BoardView| b.0.borrow().board.width as i64);
|
||||||
|
engine.register_get("height", |b: &mut BoardView| {
|
||||||
|
b.0.borrow().board.height as i64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log`
|
||||||
|
/// host functions, each enqueuing a [`Command`] tagged with its source object.
|
||||||
|
fn register_write_api(engine: &mut Engine, commands: &CommandQueue) {
|
||||||
|
engine.register_type_with_name::<Direction>("Direction");
|
||||||
|
|
||||||
|
let q = commands.clone();
|
||||||
|
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| {
|
||||||
|
q.borrow_mut().push(Command {
|
||||||
|
source: source_of(&ctx),
|
||||||
|
kind: GameCommand::Move(dir),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let q = commands.clone();
|
||||||
|
engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| {
|
||||||
|
q.borrow_mut().push(Command {
|
||||||
|
source: source_of(&ctx),
|
||||||
|
kind: GameCommand::SetTile(tile as u32),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let q = commands.clone();
|
||||||
|
engine.register_fn(
|
||||||
|
"log",
|
||||||
|
move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||||
|
q.borrow_mut().push(Command {
|
||||||
|
source: source_of(&ctx),
|
||||||
|
kind: GameCommand::Log(LogLine::raw(msg.to_string())),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the issuing object's index from the call tag (set in [`ScriptHost::run`]).
|
||||||
|
fn source_of(ctx: &NativeCallContext) -> usize {
|
||||||
|
ctx.tag()
|
||||||
|
.and_then(|t| t.as_int().ok())
|
||||||
|
.and_then(|n| usize::try_from(n).ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a fresh per-object scope, seeded with the read-only `board` view and
|
||||||
|
/// the four direction constants (`north`/`south`/`east`/`west`).
|
||||||
|
fn new_object_scope(state: &Rc<RefCell<BoardState>>) -> Scope<'static> {
|
||||||
|
let mut scope = Scope::new();
|
||||||
|
scope.push_constant("board", BoardView(state.clone()));
|
||||||
|
scope.push_constant("north", Direction::North);
|
||||||
|
scope.push_constant("south", Direction::South);
|
||||||
|
scope.push_constant("east", Direction::East);
|
||||||
|
scope.push_constant("west", Direction::West);
|
||||||
|
scope
|
||||||
|
}
|
||||||
|
|||||||
+12
-6
@@ -21,13 +21,13 @@ use ratatui::crossterm::event::{
|
|||||||
use ratatui::crossterm::execute;
|
use ratatui::crossterm::execute;
|
||||||
use ratatui::layout::{Constraint, Layout, Spacing};
|
use ratatui::layout::{Constraint, Layout, Spacing};
|
||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::{Color, Style};
|
||||||
|
use ratatui::symbols::merge::MergeStrategy;
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||||
use render::{BoardWidget, logline_to_line};
|
use render::{BoardWidget, logline_to_line};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use ratatui::symbols::merge::MergeStrategy;
|
|
||||||
use term::TerminalCaps;
|
use term::TerminalCaps;
|
||||||
|
|
||||||
/// View-only UI state for the log panel. This is presentation state, so it lives
|
/// View-only UI state for the log panel. This is presentation state, so it lives
|
||||||
@@ -197,7 +197,9 @@ fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) {
|
|||||||
/// panel across the bottom. When the panel is closed the latest log message is
|
/// panel across the bottom. When the panel is closed the latest log message is
|
||||||
/// previewed in the board's bottom-left border after a `[l]og:` label.
|
/// previewed in the board's bottom-left border after a `[l]og:` label.
|
||||||
fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
|
fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
|
||||||
let board = &game.board;
|
// Borrow the board for the duration of the frame (no script runs during draw).
|
||||||
|
let board = game.board();
|
||||||
|
let board = &*board;
|
||||||
|
|
||||||
if ui.log_open {
|
if ui.log_open {
|
||||||
// Split the screen: board on top, log panel of `log_height` at the bottom.
|
// Split the screen: board on top, log panel of `log_height` at the bottom.
|
||||||
@@ -206,14 +208,19 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
|
|||||||
.spacing(Spacing::Overlap(1))
|
.spacing(Spacing::Overlap(1))
|
||||||
.areas(frame.area());
|
.areas(frame.area());
|
||||||
|
|
||||||
let block = Block::bordered().title(format!(" {} ", board.name)).merge_borders(MergeStrategy::Exact);
|
let block = Block::bordered()
|
||||||
|
.title(format!(" {} ", board.name))
|
||||||
|
.merge_borders(MergeStrategy::Exact);
|
||||||
let inner = block.inner(board_area);
|
let inner = block.inner(board_area);
|
||||||
frame.render_widget(block, board_area);
|
frame.render_widget(block, board_area);
|
||||||
frame.render_widget(BoardWidget::new(board), inner);
|
frame.render_widget(BoardWidget::new(board), inner);
|
||||||
|
|
||||||
// Newest message on top: reverse the log into ratatui lines.
|
// Newest message on top: reverse the log into ratatui lines.
|
||||||
let log_block = Block::bordered()
|
let log_block = Block::bordered()
|
||||||
.title(Span::styled(" [l]og: ", Style::default().fg(Color::DarkGray)))
|
.title(Span::styled(
|
||||||
|
" [l]og: ",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
))
|
||||||
.merge_borders(MergeStrategy::Exact);
|
.merge_borders(MergeStrategy::Exact);
|
||||||
let lines: Vec<Line> = game.log.iter().rev().map(logline_to_line).collect();
|
let lines: Vec<Line> = game.log.iter().rev().map(logline_to_line).collect();
|
||||||
let paragraph = Paragraph::new(lines)
|
let paragraph = Paragraph::new(lines)
|
||||||
@@ -223,8 +230,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
|
|||||||
frame.render_widget(paragraph, log_area);
|
frame.render_widget(paragraph, log_area);
|
||||||
} else {
|
} else {
|
||||||
// Panel closed: board fills the screen, latest message in the border.
|
// Panel closed: board fills the screen, latest message in the border.
|
||||||
let mut block =
|
let mut block = Block::bordered().title(format!(" {} ", board.name));
|
||||||
Block::bordered().title(format!(" {} ", board.name));
|
|
||||||
block = block.title_bottom(log_preview(game).left_aligned());
|
block = block.title_bottom(log_preview(game).left_aligned());
|
||||||
let inner = block.inner(frame.area());
|
let inner = block.inner(frame.area());
|
||||||
frame.render_widget(block, frame.area());
|
frame.render_widget(block, frame.area());
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
//! available. The detected [`TerminalCaps`] are intended to be handed to scripts
|
//! available. The detected [`TerminalCaps`] are intended to be handed to scripts
|
||||||
//! so they can pick key bindings that actually work on this terminal.
|
//! so they can pick key bindings that actually work on this terminal.
|
||||||
|
|
||||||
|
use color::Rgba8;
|
||||||
|
use kiln_core::log::LogLine;
|
||||||
use ratatui::crossterm::event::{
|
use ratatui::crossterm::event::{
|
||||||
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||||
};
|
};
|
||||||
use color::Rgba8;
|
|
||||||
use kiln_core::log::LogLine;
|
|
||||||
use ratatui::crossterm::execute;
|
use ratatui::crossterm::execute;
|
||||||
use ratatui::crossterm::terminal::supports_keyboard_enhancement;
|
use ratatui::crossterm::terminal::supports_keyboard_enhancement;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|||||||
+2
-1
@@ -50,6 +50,7 @@ script_name = "greeter"
|
|||||||
[scripts]
|
[scripts]
|
||||||
greeter = """
|
greeter = """
|
||||||
fn init() {
|
fn init() {
|
||||||
log("hello world");
|
log(`hello from object — player at ${board.player_x}, ${board.player_y}`);
|
||||||
|
set_tile(2); // change my glyph to ☻ — proves the write path
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
Reference in New Issue
Block a user