removed boardstate

This commit is contained in:
2026-06-05 00:12:57 -05:00
parent 4f21f7fa8a
commit 6cd34ebb4e
4 changed files with 28 additions and 48 deletions
+4 -4
View File
@@ -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):**
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.
**`BoardState` / `GameState`**
`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.
**`GameState`**
Owns the board behind an `Rc<RefCell<Board>>`, alongside the message `log` and a `ScriptHost`. Keeping the board 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,11 @@ An earlier design had `GameMap { board: Board, player: Player, ... }`. This was
### `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 **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`.
`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<Board>>)`, 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 become `Error` commands, not fatal.
**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.
**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 `Board`. 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.
+3 -4
View File
@@ -53,16 +53,15 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
- `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`).
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
- `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.
- `GameState`holds `board: Rc<RefCell<Board>>`, the message `log: Vec<LogLine>`, and a `ScriptHost`. The board sits behind `Rc<RefCell<…>>` as a sibling of the `ScriptHost` so script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. 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:
- `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:
- `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).
- `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<Board>>)` (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 become `Error` commands, not fatal.
- **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`.
- **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 `Board`.
- **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.
+7 -19
View File
@@ -393,22 +393,10 @@ impl Board {
}
}
/// 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
/// game data. It owns the world ([`BoardState`], behind a shared `Rc<RefCell>`),
/// game data. It owns the board (behind a shared `Rc<RefCell<Board>>`),
/// the message log, and the [`ScriptHost`] driving object scripts. Front-ends and
/// internal logic reach the board through [`board`](GameState::board) /
/// [`board_mut`](GameState::board_mut). Scripts read the board directly and
@@ -416,7 +404,7 @@ pub struct BoardState {
/// after each script batch.
pub struct GameState {
/// The scriptable world, shared with the script host's read getters.
board_state: Rc<RefCell<BoardState>>,
board: Rc<RefCell<Board>>,
/// The in-game message log, oldest first (newest pushed at the end).
pub log: Vec<LogLine>,
/// The Rhai scripting runtime driving this board's scripted objects.
@@ -430,10 +418,10 @@ impl GameState {
/// 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 {
let board_state = Rc::new(RefCell::new(BoardState { board }));
let scripts = ScriptHost::new(&board_state);
let board = Rc::new(RefCell::new(board));
let scripts = ScriptHost::new(&board);
let mut state = Self {
board_state,
board,
log: Vec::new(),
scripts,
};
@@ -444,12 +432,12 @@ impl GameState {
/// 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)
self.board.borrow()
}
/// Borrows the active board for mutation.
pub fn board_mut(&self) -> RefMut<'_, Board> {
RefMut::map(self.board_state.borrow_mut(), |bs| &mut bs.board)
self.board.borrow_mut()
}
/// Appends a styled message to the log.
+14 -21
View File
@@ -11,7 +11,7 @@
//!
//! Scripts **read** the world directly through a read-only [`BoardView`] (getters
//! only) pushed into each scope as `board`, e.g. `board.player_x`. The view holds
//! an `Rc<RefCell<BoardState>>` and borrows it briefly per getter.
//! an `Rc<RefCell<Board>>` 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
@@ -23,7 +23,7 @@
//!
//! [`GameState`]: crate::game::GameState
use crate::game::BoardState;
use crate::game::Board;
use crate::log::LogLine;
use rhai::{AST, CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope};
use std::cell::RefCell;
@@ -81,10 +81,10 @@ impl From<Direction> for (i32, i32) {
}
/// 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
/// reference to the [`Board`]; only getters are registered, so it is
/// read-only by construction.
#[derive(Clone)]
struct BoardView(Rc<RefCell<BoardState>>);
struct BoardView(Rc<RefCell<Board>>);
/// A compiled script plus which lifecycle hooks it defines.
struct CompiledScript {
@@ -128,15 +128,14 @@ impl ScriptHost {
/// fresh scope per scripted object. Compile errors and references to unknown
/// scripts are queued as [`GameCommand::Error`] (retrievable via
/// [`Self::take_commands`]); no script is run here.
pub fn new(state: &Rc<RefCell<BoardState>>) -> Self {
pub fn new(board_cell: &Rc<RefCell<Board>>) -> Self {
let commands: CommandQueue = Rc::new(RefCell::new(Vec::new()));
let mut engine = Engine::new();
register_read_api(&mut engine);
register_write_api(&mut engine, &commands);
let board_state = state.borrow();
let board = &board_state.board;
let board = board_cell.borrow();
// Compile each referenced script once; attribute failures to the first
// object that references the script.
@@ -200,12 +199,12 @@ impl ScriptHost {
scripts.contains_key(name).then(|| ObjectRuntime {
object_index: i,
script_name: name.clone(),
scope: new_object_scope(state),
scope: new_object_scope(board_cell),
})
})
.collect();
drop(board_state);
drop(board);
Self {
engine,
@@ -280,16 +279,10 @@ impl ScriptHost {
/// 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
});
engine.register_get("player_x", |b: &mut BoardView| b.0.borrow().player.x as i64);
engine.register_get("player_y", |b: &mut BoardView| b.0.borrow().player.y as i64);
engine.register_get("width", |b: &mut BoardView| b.0.borrow().width as i64);
engine.register_get("height", |b: &mut BoardView| b.0.borrow().height as i64);
}
/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log`
@@ -335,9 +328,9 @@ fn source_of(ctx: &NativeCallContext) -> usize {
/// 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> {
fn new_object_scope(board: &Rc<RefCell<Board>>) -> Scope<'static> {
let mut scope = Scope::new();
scope.push_constant("board", BoardView(state.clone()));
scope.push_constant("board", BoardView(board.clone()));
scope.push_constant("north", Direction::North);
scope.push_constant("south", Direction::South);
scope.push_constant("east", Direction::East);