multi board

This commit is contained in:
2026-06-13 01:25:58 -05:00
parent e5c6affc41
commit 73c8a9bd3e
13 changed files with 456 additions and 445 deletions
+61 -55
View File
@@ -70,7 +70,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `ObjectDef` — a scripted tile: `x`, `y`, `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option<String>`, `tags: HashSet<String>`, `name: Option<String>`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults. `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
**`kiln-core/src/board.rs`** — the board data type:
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; `pub(crate)`), `floor: Vec<Glyph>` + `floor_spec: Option<FloorSpec>` (visual floor layer; `pub(crate)`), `player: Player`, `objects: BTreeMap<ObjectId, ObjectDef>`, `next_object_id: ObjectId`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`, `zoom: u32` (integer tile scale factor), `scripts: HashMap<String, String>` (named Rhai source text; script name → source), `board_script_name: Option<String>` (name of a board-level script, if any), `load_errors: Vec<LogLine>` (`pub(crate)`).
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; `pub(crate)`), `floor: Vec<Glyph>` + `floor_spec: Option<FloorSpec>` (visual floor layer; `pub(crate)`), `player: Player`, `objects: BTreeMap<ObjectId, ObjectDef>`, `next_object_id: ObjectId`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`, `zoom: u32` (integer tile scale factor), `board_script_name: Option<String>` (name of a board-level script in the world script pool, if any), `load_errors: Vec<LogLine>` (`pub(crate)`). Scripts are **not** stored on `Board` — they live in [`World::scripts`].
- `get(x, y) -> &(Glyph, Archetype)` / `get_mut` — direct cell access (panics OOB).
- `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`. Priority: player > solid object > non-solid object with nonzero tile > non-Empty grid archetype > floor. **Includes the player**`glyph_at` at the player's cell returns `Glyph::player()`. An `Empty` cell's own glyph is always ignored (floor supersedes it). Front-ends call this per-cell instead of managing a separate player overlay.
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then grid archetype).
@@ -85,7 +85,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one).
- `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds).
- `Scroll { source: ObjectId, lines: Vec<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`.
- `GameState`holds `board: Rc<RefCell<Board>>`, `log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, and `pub active_scroll: Option<Scroll>`. Front-ends reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into. `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log``log`, `SetTile` → source glyph, `Move(dir)``step_object`, `Say``speech_bubbles`, `Scroll``active_scroll`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
- `GameState`owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`. Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into. `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log``log`, `SetTile` → source glyph, `Move(dir)``step_object`, `Say``speech_bubbles`, `Scroll``active_scroll`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
**`kiln-core/src/floor.rs`** — the visual floor layer:
- The floor is a cosmetic per-cell background drawn wherever a cell would render as `Archetype::Empty` (it is *how* `Empty` is drawn; the cell's own `Empty` glyph is ignored). Computed once at load and cached on `Board::floor`, so there is no per-frame cost / flicker; the raw `FloorSpec` is also kept (`Board::floor_spec`) so `map_file::save` round-trips it.
@@ -97,7 +97,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `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()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) 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` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>)` (registers the API, compiles scripts, reports compile/unknown-script failures onto the error sink; 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` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool (scripts are compiled from here, not from the board itself). Reports compile/unknown-script failures onto the error sink; runs nothing.
- Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), and `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)`; runtime errors go to the error sink (drained to the log), not fatal.
- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`.
- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`; `MOVE_COST = 0.25` s for `Move`, else 0) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`).
@@ -106,14 +106,18 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
- **Sender identity uses stable ids:** `BoardAction.source`, `ObjectRuntime.object_id`, and the `QueueMap` keys are all the object's `ObjectId` (matching `Board::objects`' `BTreeMap` keys), so they survive object spawn/destroy/reorder. The per-call tag carries the id as an `i64` (id 0 — never valid — is the no-source fallback). `ScriptHost::objects` is still a `Vec<ObjectRuntime>` built by iterating the board map, so it stays in ascending-id order.
**`kiln-core/src/map_file.rs`** — map file loading:
- `MapFile` and friends — serde `Deserialize` types for TOML map files
**`kiln-core/src/map_file.rs`** — per-board serde types and single-board load/save:
- `MapFile` and friends — serde `Deserialize`/`Serialize` types for the per-board portion of a `.toml` file. Does **not** include scripts (those live in `World::scripts`, not on `Board`).
- `TileIndex``#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably
- `PlayerStart``#[serde(untagged)]` enum (`Coord([i32;2])` | `Char(char)`); `player_start = [x, y]` or `player_start = "@"` (locate that char in the grid). Same dual-form trick as `TileIndex`
- `FontHeader` — deserializes the optional `[font]` section (`path`, `tile_w`, `tile_h`)
- `impl TryFrom<MapFile> for Board` — the single load conversion. **Best-effort/nonfatal**: only a grid-dimension mismatch returns `Err`; every other problem (unknown archetype/grid char → `ErrorBlock`; object/player placement-char issues; one-solid-per-cell conflicts) is recovered and recorded on `Board::load_errors` (see `Board::is_valid`). Resolves the player (coord or `@` char, first-occurrence, falling back to `(0,0)`) and lets it *win* its cell; places objects by `x`/`y` or `palette` char
- `pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>` — reads and converts a `.toml` map file
- `pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>>` — serializes `Board` back to TOML and writes to disk
- `pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>` — reads a single-board `.toml` file (legacy format). Production code uses `world::load` instead.
- `pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>>` — serializes `Board` back to a single-board TOML file
**`kiln-core/src/world.rs`** — world type and world-file loading:
- `World` — the runtime representation of a `.toml` world file: `name: String`, `start: String` (key of the starting board), `scripts: HashMap<String, String>` (named Rhai source, shared across all boards), `boards: HashMap<String, Rc<RefCell<Board>>>` (all boards, always present). Every board is wrapped in `Rc<RefCell<Board>>` so multiple shared refs can coexist — `GameState` clones the active board's `Rc` for its `ScriptHost`, and all boards remain in `world.boards` at all times (no board is ever "removed" when active).
- `pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>>` — reads a world `.toml` file, converts each `[boards.NAME.*]` subtable via `TryFrom<MapFile> for Board`, wraps each in `Rc::new(RefCell::new(...))`, and validates that `world.start` matches a board key.
### kiln-tui modules
@@ -122,7 +126,7 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint
**Drawing rule: prefer ratatui primitives.** Before writing manual cell-by-cell buffer code, check whether a `Block`, `Paragraph`, `Layout`, `Line::centered()`, or other ratatui widget/method achieves the same result. If a small design change (e.g. fixed vs. content-driven sizing) would unlock a ratatui-native approach, raise it as a question rather than writing manual code. The documented exceptions are: the background dimming pass in `ScrollOverlayWidget` (no ratatui primitive for "tint the whole buffer"), and the tail characters in `SpeechBubblesWidget` (`speech.rs`): the tail-join `┬` on the bottom border and the `│` column drawn below the box down to the source object.
**`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.
- Parses a single positional arg (the world file path); prints usage and exits non-zero if missing. Loads the world via `kiln_core::world::load` *before* touching the terminal so load errors print to a normal screen, then calls `GameState::from_world(world)` to start on the world's designated start board.
- `ratatui::init()` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `ratatui::restore()`.
- `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. When `game.active_scroll` is `Some`, all input is redirected to scroll navigation (Up/Down to scroll, letter keys for choices, Esc to dismiss if no choices); `game.tick()` is skipped until the overlay is closed.
- `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`). The scroll overlay is drawn last (above everything) by `render::draw_scroll_overlay`.
@@ -222,60 +226,79 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint
- `show(ctx, open, glyph, board_cells, font)` — takes `open: &mut bool` and `glyph: &mut Glyph` directly; no dependency on `EditorState`
- Three sections: board palette (unique glyphs deduped via `HashSet<Glyph>`, click to select), FG/BG color pickers, scrollable tile grid (all tiles in the active font at `tile_w × tile_h` per cell)
### Map file format (`maps/*.toml`)
### World file format (`maps/*.toml`)
XPM-inspired: a `[palette]` maps single characters to `(Glyph, Archetype)` definitions; `[grid] content` is a TOML multi-line string where each character indexes the palette.
Each `.toml` file is a **world**: a named collection of boards plus a shared script pool. The `[world]` header names the world and designates the starting board. Scripts live once at the top level and are referenced by name from any board's objects. Each board lives under `[boards.NAME.*]` where `NAME` is the board's identifier string.
**Keep this example in sync with `map_file.rs` whenever the format changes.**
**Keep this example in sync with `world.rs` / `map_file.rs` whenever the format changes.**
```toml
[map]
name = "Room Name"
[world]
name = "My World"
start = "room1" # key of the starting board
# Named Rhai scripts — shared across all boards. Objects reference a script
# by script_name = "key". Scripts do NOT live on individual boards.
[scripts]
greeter = """
fn init() { # optional, run once at startup
log(`player at ${Board.player_x}, ${Board.player_y}`);
set_tile(2); # write: change my glyph
}
fn tick(dt) {
if Queue.length() == 0 && !blocked(North) { move(North); }
}
fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); }
"""
# Each board is a named subtable under [boards]. The board key ("room1") is
# used for cross-board references (e.g. portals) and becomes current_board_name.
[boards.room1.map]
name = "Room One"
width = 60
height = 25
player_start = [30, 12] # OR a grid char: player_start = "@"
# Optional: override the default font for this board.
[font]
[boards.room1.font]
path = "assets/my_font.png"
tile_w = 8
tile_h = 16
[palette]
[boards.room1.palette]
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
"o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } # solid + pushable (any dir)
"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } # crate, pushable east/west only
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # crate, pushable north/south only
"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } # pushable east/west only
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # pushable north/south only
# Optional visual floor layer (drawn under every empty cell). Four forms:
# floor = "grass" # 1. one generator for the whole board
# [floor] # 2. one fixed glyph everywhere
# Optional visual floor layer. Four forms:
# boards.room1.floor = "grass" # 1. one generator for the whole board
# [boards.room1.floor] # 2. one fixed glyph everywhere
# tile = "." fg = "#1c3a1c" bg = "#0a1a0a"
# [floor] # 3. a grid with its own palette
# content = """...""" # (must match width/height)
# [floor.palette] # entries are generator names OR glyphs
# [boards.room1.floor] # 3. a grid with its own palette
# content = """..."""
# [boards.room1.floor.palette]
# "g" = "grass" # generator: "grass" | "dirt" | "stone"
# "." = { tile = ".", fg = "#222", bg = "#000" }
# (Omitting [floor] entirely = black-on-black space, the default.)
[floor]
[boards.room1.floor]
content = """
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
gggggggggggggggddddddddddddddddddddddddddddddggggggggggggggg
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
"""
[floor.palette]
[boards.room1.floor.palette]
"g" = "grass"
"d" = "dirt"
[grid]
[boards.room1.grid]
content = """
############################################################
# G #
############################################################
"""
[[objects]] # optional
[[boards.room1.objects]] # optional; array of tables
# Placement: either `x`/`y`, OR a `palette` char that appears in the grid.
# Convention: object palette chars are uppercase letters (here `G`). The char
# must appear exactly once in the grid, be unique across objects, and not be a
@@ -285,38 +308,21 @@ tile = "#"
fg = "#aa3333"
bg = "#000000"
solid = false # blocks movement? defaults true. (pushable defaults false)
name = "greeter" # optional unique name; readable via my_name() / object_id_for_name()
script_name = "greeter" # references a key in [scripts]
name = "greeter" # optional unique name; readable via Me.name / Board.named()
script_name = "greeter" # references a key in the world-level [scripts] table
[[portals]] # optional; parsed but not yet runtime-wired
[[boards.room1.portals]] # optional; parsed but not yet runtime-wired
x = 59
y = 12
target_map = "cave"
target_map = "room2"
target_entry = "west_door"
# Named Rhai scripts, referenced by objects via `script_name`.
[scripts]
greeter = """
fn init() { # optional, run once at startup
log(`player at ${Board.player_x}, ${Board.player_y}`); # read the world
set_tile(2); # write: change my glyph
}
# optional, run every frame; dt = elapsed seconds. move() costs 250 ms, so the
# object steps ~4 cells/sec regardless of frame rate. blocked(dir) avoids walking
# into a solid (or another object's already-queued move); Queue.length()/Queue.clear()
# inspect/empty this object's own pending-action queue.
fn tick(dt) {
if Queue.length() == 0 && !blocked(North) { move(North); }
}
# optional, fires when something steps into this object; id = bumper's object id,
# or -1 for the player.
fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); }
"""
```
The optional `[floor]` section declares the visual floor layer (drawn under every empty cell, and revealed when a crate/object is pushed away); see `floor.rs`. It takes one of four forms: omitted (black-on-black space, the default), a generator name (`floor = "grass"`), a single fixed glyph (`[floor]` with `tile`/`fg`/`bg`), or a grid (`[floor] content` + `[floor.palette]`, whose entries are generator names or fixed glyphs). The three built-in generators are `grass`/`dirt`/`stone`. Floor parsing is best-effort: an unknown generator/char or a grid-dimension mismatch is recorded on `Board::load_errors` and those cells fall back to the default empty glyph.
The optional `[boards.NAME.floor]` section declares the visual floor layer (drawn under every empty cell, revealed when a crate is pushed away); see `floor.rs`. Forms: omitted (black-on-black, the default), a generator name string, a single fixed glyph (table with `tile`/`fg`/`bg`), or a grid with its own palette. The three built-in generators are `grass`/`dirt`/`stone`. Floor parsing is best-effort: unknown generators/chars and dimension mismatches fall back to the default glyph.
Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). 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, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)` (`dir``North`/`South`/`East`/`West`), `set_tile(n)`, `log(s)`, `say(s)` (displays a speech bubble above the object for 3 s, replacing any existing bubble from that object), and `scroll(lines)` (opens a full-screen scrollable overlay; pauses ticks until dismissed; if the lines include choice entries the player must select one, which fires a named function on the source object via `send`). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object (a blocked move still costs the cooldown), so objects can't act infinitely fast. When two objects move into the same cell on one tick, **lowest id wins** (= load order; the earlier-loaded object lands, the later is pushed or blocked) and the object that was moved into receives `bump(id)`.
Colors are `"#RRGGBB"` hex strings. `player_start` accepts `[x, y]` coordinates or a grid char (convention `"@"`; that cell loads as `Empty`). `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). The grid multi-line string's leading newline is trimmed by TOML. Unknown archetype names produce an `ErrorBlock` and a logged warning. Objects may be placed by `x`/`y` or a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, and `scroll(lines)`. Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`.
**Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(Me.id, "visited", true)`).
### Key design decisions
@@ -327,7 +333,7 @@ Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coord
- **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games. Adding a variant: the `match`es in `behavior()`/`name()`/`default_glyph()` are exhaustive (the compiler flags them), but **`TryFrom<&str>` and `ALL_ARCHETYPES` are not** — forget the `TryFrom` arm and the archetype silently loads as `ErrorBlock`. Also update the map-format example.
- **`Board` is the complete unit** — grid, player, objects, and portals all live on `Board`, matching how ZZT treats a "board". No separate wrapper struct.
- **Glyph (visual) and Archetype (behavior) are decoupled** — each cell has its own `Glyph` (so colors can vary per-cell, e.g. fire flickering) while sharing an `Archetype` with other cells of the same type.
- **File loading happens in `main()`** before the window is created, so board dimensions are available for window sizing.
- **World loading happens in `main()`** before the terminal is initialized, so load errors print to a normal screen and board dimensions are known before the game loop starts.
eframe runs on its own event loop thread; do not assume single-threaded execution.
@@ -345,5 +351,5 @@ Today's baked-in assumptions that will need to change (don't make `Board.player`
- **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call).
- **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates), so references survive reordering. What's still missing: a `remove_object`, and wiring live spawn/destroy into the `ScriptHost` (it builds one `ObjectRuntime` per object once in `GameState::new`, so an object added after that has no script runtime until the host is rebuilt).
- **Portals & multi-board** — `PortalDef` is parsed but not runtime-wired; there is no multi-board world/loading yet (the engine loads a single map).
- **Portals & multi-board** — `PortalDef` is parsed but not runtime-wired. Multi-board world loading **is done**: `world::load` loads all boards as `Rc<RefCell<Board>>`, `GameState` owns the whole `World` and tracks `current_board_name`. What's still missing: a `GameState::enter_board(name)` method that updates `current_board_name` and rebuilds the `ScriptHost` for the new board's objects.
- **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).
+17 -24
View File
@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap};
use std::collections::BTreeMap;
use crate::archetype::Archetype;
use crate::archetype::Archetype::Empty;
use crate::font::FontSpec;
@@ -61,16 +61,11 @@ pub struct Board {
pub font: Option<FontSpec>,
/// Integer scale factor applied to tiles when rendering this board. 1 = natural size.
pub zoom: u32,
/// Named Rhai scripts available on this board: script name → source text.
///
/// All script source lives here; [`ObjectDef`]s and [`Board::board_script_name`]
/// reference entries by name. This means multiple objects can share the same
/// source while each running instance gets its own Rhai scope at runtime.
pub scripts: HashMap<String, String>,
/// Name of the board-level script in [`Board::scripts`], if any.
/// Name of the board-level script in the world script pool, if any.
///
/// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`)
/// rather than being tied to a specific object cell.
/// rather than being tied to a specific object cell. Scripts live in
/// [`World::scripts`](crate::world::World) and are looked up by this name.
pub board_script_name: Option<String>,
/// Nonfatal problems collected while loading this map (e.g. unknown
/// archetypes, dropped objects, recovered placement chars), as red-on-black
@@ -313,7 +308,7 @@ impl Board {
#[cfg(test)]
pub(crate) mod tests {
use std::collections::{BTreeMap, HashMap};
use std::collections::BTreeMap;
use color::Rgba8;
use crate::archetype::Archetype;
use crate::glyph::Glyph;
@@ -322,14 +317,13 @@ pub(crate) mod tests {
use crate::utils::{ObjectId, Player, Solid};
use super::Board;
/// Builds an all-empty `w×h` board with the given player position, objects,
/// and `(name, source)` scripts. Assigns sequential ids (1..=n) to objects.
/// Builds an all-empty `w×h` board with the given player position and objects.
/// Assigns sequential ids (1..=n) to objects.
pub(crate) fn open_board(
w: usize,
h: usize,
player: (i32, i32),
objects: Vec<ObjectDef>,
scripts: &[(&str, &str)],
) -> Board {
let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
let mut next_object_id: ObjectId = 1;
@@ -351,7 +345,6 @@ pub(crate) mod tests {
portals: Vec::new(),
font: None,
zoom: 1,
scripts: scripts.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<HashMap<_,_>>(),
board_script_name: None,
load_errors: Vec::new(),
}
@@ -374,7 +367,7 @@ pub(crate) mod tests {
#[test]
fn solid_at_reports_wall_object_and_empty() {
let mut board = open_board(4, 1, (3, 0), vec![], &[]);
let mut board = open_board(4, 1, (3, 0), vec![]);
board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall);
board.add_object(ObjectDef::new(2, 0));
@@ -398,14 +391,14 @@ pub(crate) mod tests {
fn non_solid_object_does_not_block() {
let mut obj = ObjectDef::new(1, 0);
obj.solid = false;
let board = open_board(3, 1, (0, 0), vec![obj], &[]);
let board = open_board(3, 1, (0, 0), vec![obj]);
assert!(board.solid_at(1, 0).is_none());
assert!(board.is_passable(1, 0));
}
#[test]
fn solid_at_reports_player() {
let board = open_board(3, 1, (1, 0), vec![], &[]);
let board = open_board(3, 1, (1, 0), vec![]);
match board.solid_at(1, 0) {
Some(Solid::Player) => {}
_ => panic!("expected Solid::Player at the player's cell"),
@@ -415,7 +408,7 @@ pub(crate) mod tests {
#[test]
fn in_bounds_checks_grid_boundaries() {
let board = open_board(3, 2, (0, 0), vec![], &[]);
let board = open_board(3, 2, (0, 0), vec![]);
assert!(board.in_bounds((0, 0)));
assert!(board.in_bounds((2, 1)));
assert!(!board.in_bounds((-1, 0)));
@@ -426,13 +419,13 @@ pub(crate) mod tests {
#[test]
fn can_push_is_read_only_and_correct() {
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
let mut board = open_board(3, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation
assert_eq!(board.get(2, 0).1, Archetype::Empty);
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
let mut board = open_board(3, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
assert!(!board.can_push(1, 0, Direction::East));
@@ -441,7 +434,7 @@ pub(crate) mod tests {
#[test]
fn push_into_player_pushes_player() {
// Crate shoved east into the player slides the player along into open space.
let mut board = open_board(4, 1, (2, 0), vec![], &[]);
let mut board = open_board(4, 1, (2, 0), vec![]);
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
board.push(1, 0, Direction::East);
@@ -453,7 +446,7 @@ pub(crate) mod tests {
#[test]
fn push_into_player_blocked_by_wall() {
// Player backed against a wall: push has nowhere to go, nothing moves.
let mut board = open_board(4, 1, (2, 0), vec![], &[]);
let mut board = open_board(4, 1, (2, 0), vec![]);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 3, 0);
assert!(!board.can_push(1, 0, Direction::East));
@@ -465,7 +458,7 @@ pub(crate) mod tests {
#[test]
fn glyph_at_uses_floor_for_empty_and_grid_for_solid() {
// Player parked at (2,0) so it doesn't overlap either asserted cell.
let mut board = open_board(3, 1, (2, 0), vec![], &[]);
let mut board = open_board(3, 1, (2, 0), vec![]);
let floor_glyph = Glyph {
tile: '.' as u32,
fg: Rgba8 { r: 10, g: 20, b: 30, a: 255 },
@@ -479,7 +472,7 @@ pub(crate) mod tests {
#[test]
fn fresh_board_is_valid_and_reports_errors() {
let mut board = open_board(1, 1, (0, 0), vec![], &[]);
let mut board = open_board(1, 1, (0, 0), vec![]);
assert!(board.is_valid());
board.report_error("something went wrong");
assert!(!board.is_valid());
+58 -24
View File
@@ -1,10 +1,10 @@
use crate::action::Action;
use crate::board::Board;
use crate::log::LogLine;
use crate::script::ScriptHost;
use std::cell::{Ref, RefCell, RefMut};
use std::rc::Rc;
use crate::world::World;
use std::cell::{Ref, RefMut};
use std::time::Duration;
use crate::board::Board;
use crate::utils::{Direction, ObjectId, ScriptArg, Solid};
/// How long a `say()` speech bubble stays on screen, in seconds.
@@ -38,18 +38,19 @@ pub struct SpeechBubble {
/// 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 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
/// request mutations as commands, applied by [`apply_commands`](GameState::apply_commands)
/// after each script batch.
/// game data. It owns a [`World`] (all boards + world scripts) and tracks which
/// board is currently active. Front-ends reach the active board through
/// [`board`](GameState::board) / [`board_mut`](GameState::board_mut). Scripts
/// read the board and queue mutations via commands applied by `resolve` after
/// each script batch.
pub struct GameState {
/// The scriptable world, shared with the script host's read getters.
board: Rc<RefCell<Board>>,
/// All boards and world-level scripts.
world: World,
/// Key of the currently active board in [`world.boards`](World::boards).
current_board_name: String,
/// 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.
/// The Rhai scripting runtime for the active board's objects.
scripts: ScriptHost,
/// Active speech bubbles from `say()` calls, displayed by the front-end over the board.
pub speech_bubbles: Vec<SpeechBubble>,
@@ -59,34 +60,67 @@ pub struct GameState {
}
impl GameState {
/// Creates a `GameState` from a pre-loaded [`Board`].
/// Creates a [`GameState`] from a loaded [`World`], starting on `world.start`.
///
/// 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 {
let board = Rc::new(RefCell::new(board));
let scripts = ScriptHost::new(&board);
/// The starting board's objects are compiled and registered with the
/// [`ScriptHost`] using `world.scripts`. No lifecycle hooks are run here;
/// call [`run_init`](GameState::run_init) once the game is ready to start.
/// Compile errors are surfaced into the log immediately.
pub fn from_world(world: World) -> Self {
let name = world.start.clone();
let host = ScriptHost::new(
world.boards.get(&name).expect("world::load guarantees start board exists"),
&world.scripts,
);
let mut state = Self {
board,
world,
current_board_name: name,
log: Vec::new(),
scripts,
scripts: host,
speech_bubbles: Vec::new(),
active_scroll: None,
};
// Surface any compile-time script errors collected during ScriptHost::new.
state.drain_errors();
state
}
/// Creates a `GameState` from a single [`Board`] with no scripts.
///
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
#[cfg(test)]
pub fn new(board: Board) -> Self {
Self::with_scripts(board, std::collections::HashMap::new())
}
/// Creates a `GameState` from a single [`Board`] and an explicit script pool.
///
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
#[cfg(test)]
pub fn with_scripts(board: Board, scripts: std::collections::HashMap<String, String>) -> Self {
// Wrap the bare board in Rc<RefCell<Board>> to match World::boards storage.
let board_ref = std::rc::Rc::new(std::cell::RefCell::new(board));
let world = World {
name: String::new(),
start: "board".to_string(),
scripts,
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
};
Self::from_world(world)
}
/// Borrows the active board for reading (e.g. by a front-end renderer).
pub fn board(&self) -> Ref<'_, Board> {
self.board.borrow()
self.world.boards[&self.current_board_name].borrow()
}
/// Borrows the active board for mutation.
pub fn board_mut(&self) -> RefMut<'_, Board> {
self.board.borrow_mut()
self.world.boards[&self.current_board_name].borrow_mut()
}
/// The name (key) of the currently active board within the world.
pub fn current_board_name(&self) -> &str {
&self.current_board_name
}
/// Appends a styled message to the log.
-6
View File
@@ -41,10 +41,6 @@ pub struct MapFile {
/// Any `[[portals]]` entries. Optional; defaults to empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub portals: Vec<PortalDef>,
/// The `[scripts]` table: maps script names to Rhai source text.
/// Optional; defaults to empty.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub scripts: HashMap<String, String>,
}
/// The `[map]` header section of a map file.
@@ -570,7 +566,6 @@ impl TryFrom<MapFile> for Board {
portals: mf.portals,
font,
zoom: mf.map.zoom,
scripts: mf.scripts,
board_script_name: mf.map.board_script_name,
load_errors,
})
@@ -705,7 +700,6 @@ impl From<&Board> for MapFile {
floor: board.floor_spec.clone(),
objects,
portals,
scripts: board.scripts.clone(),
}
}
}
+5 -2
View File
@@ -164,7 +164,10 @@ impl ScriptHost {
/// compiles every script referenced by an object (once each), and creates a fresh
/// scope and output queue per scripted object. Compile errors and references to
/// unknown scripts are queued onto the error sink; no script is run here.
pub fn new(board_cell: &BoardRef) -> Self {
///
/// `scripts` is the world-level script pool (script name → Rhai source); it is
/// read only during construction and not retained afterward.
pub fn new(board_cell: &BoardRef, script_sources: &HashMap<String, String>) -> Self {
let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new()));
let queues: QueueMap = Rc::new(RefCell::new(HashMap::new()));
let errors: ErrorSink = Rc::new(RefCell::new(Vec::new()));
@@ -186,7 +189,7 @@ impl ScriptHost {
for obj in board.objects.values() {
let Some(name) = obj.script_name.as_ref() else { continue; };
if scripts.contains_key(name) || failed.contains(name) { continue; }
match board.scripts.get(name) {
match script_sources.get(name) {
Some(src) => match engine.compile(src) {
Ok(ast) => {
let defines = |n: &str, params: usize| {
+33 -109
View File
@@ -2,18 +2,12 @@ use std::time::Duration;
use crate::archetype::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at};
use crate::game::GameState;
use super::{scripted_object, log_texts};
use super::{scripted_object, log_texts, scripts_from};
#[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);
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
game.run_init();
let b = game.board();
// East increments x by one; the object started at (2, 1).
@@ -23,28 +17,16 @@ fn move_command_relocates_the_source_object() {
#[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);
let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")]));
game.run_init();
assert_eq!(game.board().objects[&1].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);
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")]));
game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7);
}
@@ -52,15 +34,9 @@ fn set_tile_command_changes_the_source_glyph() {
#[test]
fn object_pushes_crate_on_init() {
// A scripted object moving east into a crate shoves it (step_object path).
let mut board = open_board(
4,
1,
(0, 0),
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); }")],
);
let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
crate_at(&mut board, 2, 0);
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
game.run_init();
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
@@ -71,14 +47,8 @@ fn object_pushes_crate_on_init() {
#[test]
fn object_push_into_player() {
// A scripted object moving into the player pushes the player when there's room.
let board = open_board(
5,
1,
(2, 0),
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); }")],
);
let mut game = GameState::new(board);
let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
game.run_init();
{
let b = game.board();
@@ -87,15 +57,9 @@ fn object_push_into_player() {
}
// With a wall behind the player, the object is blocked and nothing moves.
let mut board = open_board(
4,
1,
(2, 0),
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); }")],
);
let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
wall_at(&mut board, 3, 0);
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
game.run_init();
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0));
@@ -106,14 +70,8 @@ fn object_push_into_player() {
fn move_cost_rate_limits_repeated_moves() {
// init queues two moves; only the first resolves immediately, the second must
// wait the full 250 ms cooldown.
let board = open_board(
5,
1,
(0, 0),
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); move(East); }")],
);
let mut game = GameState::new(board);
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(East); }")]));
game.run_init();
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
@@ -131,15 +89,9 @@ fn move_cost_rate_limits_repeated_moves() {
fn inline_delay_paces_subsequent_moves() {
// move() always appends a Delay(250 ms) to the queue, so a second queued move
// is held back by that delay even if the first move was blocked by a wall.
let mut board = open_board(
3,
3,
(0, 0),
vec![scripted_object(1, 1, "m")],
&[("m", "fn init() { move(East); move(South); }")],
);
let mut board = open_board(3, 3, (0, 0), vec![scripted_object(1, 1, "m")]);
wall_at(&mut board, 2, 1);
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(South); }")]));
game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved.
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1));
@@ -158,17 +110,11 @@ fn inline_delay_paces_subsequent_moves() {
fn queue_length_reports_pending_actions() {
// Two zero-cost actions are queued before the length is read, then all three
// (incl. the log) drain in one pump.
let board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "q")],
&[(
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "q")]);
let mut game = GameState::with_scripts(board, scripts_from(&[(
"q",
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }",
)],
);
let mut game = GameState::new(board);
)]));
game.run_init();
assert!(log_texts(&game).iter().any(|t| t == "len=2"));
assert_eq!(game.board().objects[&1].glyph.tile, 6); // last set_tile won
@@ -177,14 +123,8 @@ fn queue_length_reports_pending_actions() {
#[test]
fn queue_clear_drops_pending_actions() {
// clear() empties the output queue mid-script, so the queued moves never run.
let board = open_board(
5,
1,
(0, 0),
vec![scripted_object(1, 0, "c")],
&[("c", "fn init() { move(East); move(East); Queue.clear(); }")],
);
let mut game = GameState::new(board);
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "c")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")]));
game.run_init();
game.tick(Duration::from_millis(300));
assert_eq!(game.board().objects[&1].x, 1); // never moved
@@ -193,33 +133,21 @@ fn queue_clear_drops_pending_actions() {
#[test]
fn blocked_reports_solid_and_clear() {
// Solid ahead (a wall): blocked() is true.
let mut board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "b")],
&[(
let mut board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
wall_at(&mut board, 2, 0);
let mut game = GameState::with_scripts(board, scripts_from(&[(
"b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
)],
);
wall_at(&mut board, 2, 0);
let mut game = GameState::new(board);
)]));
game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 9);
// Open ahead, nothing pending: blocked() is false.
let board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "b")],
&[(
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts(board, scripts_from(&[(
"b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
)],
);
let mut game = GameState::new(board);
)]));
game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7);
}
@@ -236,15 +164,11 @@ fn blocked_sees_earlier_objects_pending_move() {
scripted_object(1, 1, "mover"),
scripted_object(3, 1, "checker"),
],
&[
("mover", "fn tick(dt) { move(East); }"),
(
"checker",
"fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }",
),
],
);
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts_from(&[
("mover", "fn tick(dt) { move(East); }"),
("checker", "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }"),
]));
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1));
+5 -12
View File
@@ -1,7 +1,7 @@
use std::time::Duration;
use crate::board::tests::open_board;
use crate::game::GameState;
use super::{scripted_object, log_texts};
use super::{scripted_object, log_texts, scripts_from};
#[test]
fn collision_priority_resolves_in_array_order_and_bumps() {
@@ -12,18 +12,11 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
2,
(0, 1), // player off the contested row
vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")],
&[
(
"e",
"fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }",
),
(
"w",
"fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }",
),
],
);
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts_from(&[
("e", "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }"),
("w", "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }"),
]));
game.run_init();
// The bump fires during resolution but its log is emitted into obj0's queue;
// a later tick (past both objects' move cooldown) flushes it to the game log.
+15 -7
View File
@@ -4,20 +4,22 @@ mod map_file;
mod movement;
mod scripting;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use crate::archetype::Archetype;
use crate::board::Board;
use crate::object_def::ObjectDef;
use crate::utils::Player;
use crate::game::GameState;
/// 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 {
/// Builds a 1×1 board with a single object that optionally references a script,
/// and returns both the board and the `(name, source)` entries as a script map.
///
/// Pass the returned scripts to [`GameState::with_scripts`] so they are compiled.
fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> (Board, HashMap<String, String>) {
let mut object = ObjectDef::new(0, 0);
object.id = 1;
object.script_name = object_script.map(str::to_string);
Board {
let board = Board {
name: "test".into(),
width: 1,
height: 1,
@@ -30,10 +32,16 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> B
portals: Vec::new(),
font: None,
zoom: 1,
scripts: scripts.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
board_script_name: None,
load_errors: Vec::new(),
}
};
(board, scripts_from(scripts))
}
/// Converts a `(name, source)` slice into a `HashMap` suitable for
/// [`GameState::with_scripts`].
fn scripts_from(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
/// Returns an `ObjectDef` at `(x, y)` bound to the named script.
+11 -11
View File
@@ -11,7 +11,7 @@ fn pushing_a_crate_reveals_the_floor_underneath() {
// The cell a crate is pushed off of becomes Empty and glyph_at shows floor.
// After the push the player is at (1,0); check the cell the player vacated
// (0,0) — it is Empty and must reveal the floor glyph, not black-on-black.
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
let mut board = open_board(4, 1, (0, 0), vec![]);
let floor_glyph = Glyph {
tile: ',' as u32,
fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 },
@@ -29,7 +29,7 @@ fn pushing_a_crate_reveals_the_floor_underneath() {
#[test]
fn player_pushes_single_crate() {
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
let mut board = open_board(4, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
@@ -41,7 +41,7 @@ fn player_pushes_single_crate() {
#[test]
fn push_blocked_by_wall_moves_nothing() {
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
let mut board = open_board(4, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
let mut game = GameState::new(board);
@@ -55,7 +55,7 @@ fn push_blocked_by_wall_moves_nothing() {
#[test]
fn push_blocked_by_edge_moves_nothing() {
// Crate on the last column; player pushes it toward the board edge.
let mut board = open_board(2, 1, (0, 0), vec![], &[]);
let mut board = open_board(2, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
@@ -66,7 +66,7 @@ fn push_blocked_by_edge_moves_nothing() {
#[test]
fn cascade_pushes_two_crates() {
let mut board = open_board(5, 1, (0, 0), vec![], &[]);
let mut board = open_board(5, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
crate_at(&mut board, 2, 0);
let mut game = GameState::new(board);
@@ -80,7 +80,7 @@ fn cascade_pushes_two_crates() {
#[test]
fn cascade_blocked_by_wall_moves_nothing() {
let mut board = open_board(5, 1, (0, 0), vec![], &[]);
let mut board = open_board(5, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
crate_at(&mut board, 2, 0);
wall_at(&mut board, 3, 0);
@@ -97,7 +97,7 @@ fn player_pushes_pushable_solid_object() {
// A solid, pushable object is shoved exactly like a crate.
let mut obj = ObjectDef::new(1, 0);
obj.pushable = true;
let board = open_board(4, 1, (0, 0), vec![obj], &[]);
let board = open_board(4, 1, (0, 0), vec![obj]);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
@@ -108,7 +108,7 @@ fn player_pushes_pushable_solid_object() {
#[test]
fn hcrate_pushes_east_but_not_north() {
// Pushing east: the HCrate slides.
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
let mut board = open_board(4, 1, (0, 0), vec![]);
stamp(&mut board, 1, 0, Archetype::HCrate);
let mut game = GameState::new(board);
game.try_move(Direction::East);
@@ -119,7 +119,7 @@ fn hcrate_pushes_east_but_not_north() {
drop(b);
// Pushing north into an HCrate: blocked, nothing moves.
let mut board = open_board(1, 4, (0, 3), vec![], &[]);
let mut board = open_board(1, 4, (0, 3), vec![]);
stamp(&mut board, 0, 2, Archetype::HCrate);
let mut game = GameState::new(board);
game.try_move(Direction::North);
@@ -131,7 +131,7 @@ fn hcrate_pushes_east_but_not_north() {
#[test]
fn vcrate_pushes_north_but_not_east() {
// Pushing north: the VCrate slides.
let mut board = open_board(1, 4, (0, 3), vec![], &[]);
let mut board = open_board(1, 4, (0, 3), vec![]);
stamp(&mut board, 0, 2, Archetype::VCrate);
let mut game = GameState::new(board);
game.try_move(Direction::North);
@@ -142,7 +142,7 @@ fn vcrate_pushes_north_but_not_east() {
drop(b);
// Pushing east into a VCrate: blocked, nothing moves.
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
let mut board = open_board(4, 1, (0, 0), vec![]);
stamp(&mut board, 1, 0, Archetype::VCrate);
let mut game = GameState::new(board);
game.try_move(Direction::East);
+46 -78
View File
@@ -2,15 +2,15 @@ use std::time::Duration;
use crate::board::tests::open_board;
use crate::game::{GameState, ScrollLine};
use crate::utils::Direction;
use super::{board_with_object, scripted_object, log_texts};
use super::{board_with_object, scripted_object, log_texts, scripts_from};
#[test]
fn init_runs_only_on_run_init_not_at_construction() {
let board = board_with_object(
let (board, scripts) = board_with_object(
Some("greet"),
&[("greet", r#"fn init() { log("hello"); }"#)],
);
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts);
// init must not fire during construction / deserialization.
assert!(game.log.is_empty());
@@ -20,11 +20,11 @@ fn init_runs_only_on_run_init_not_at_construction() {
#[test]
fn tick_calls_script_tick_with_elapsed_seconds() {
let board = board_with_object(
let (board, scripts) = board_with_object(
Some("t"),
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)],
);
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(game.log.is_empty());
@@ -37,16 +37,18 @@ fn tick_calls_script_tick_with_elapsed_seconds() {
#[test]
fn missing_hooks_and_no_script_are_noops() {
// Object with no script: nothing happens.
let mut game = GameState::new(board_with_object(None, &[]));
let (board, scripts) = board_with_object(None, &[]);
let mut game = GameState::with_scripts(board, scripts);
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(
let (board, scripts) = board_with_object(
Some("e"),
&[("e", "fn other() { log(\"x\"); }")],
));
);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game.tick(Duration::from_millis(33));
assert!(game.log.is_empty());
@@ -55,11 +57,13 @@ fn missing_hooks_and_no_script_are_noops() {
#[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"), &[]));
let (board, scripts) = board_with_object(Some("ghost"), &[]);
let game = GameState::with_scripts(board, scripts);
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( {")]));
let (board, scripts) = board_with_object(Some("bad"), &[("bad", "fn init( {")]);
let game = GameState::with_scripts(board, scripts);
assert!(log_texts(&game)[0].contains("failed to compile"));
}
@@ -70,9 +74,8 @@ fn script_reads_board_through_view() {
3,
(3, 1),
vec![scripted_object(2, 1, "r")],
&[("r", "fn init() { log(Board.player_x.to_string()); }")],
);
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")]));
game.run_init();
assert_eq!(log_texts(&game), vec!["3"]);
}
@@ -86,12 +89,11 @@ fn commands_are_routed_to_their_own_source_object() {
3,
(0, 0),
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
&[
);
let mut game = GameState::with_scripts(board, scripts_from(&[
("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[&1].x, 1); // moved east from 0
@@ -104,9 +106,8 @@ fn start_map_greeter_runs_init() {
// 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 mut world = crate::world::load(path).expect("load start.toml");
let board = world.boards.remove(&world.start).expect("start board");
let mut game = GameState::new(board);
let world = crate::world::load(path).expect("load start.toml");
let mut game = GameState::from_world(world);
game.run_init();
assert!(
log_texts(&game).iter().any(|t| t.contains("hello from object")),
@@ -122,25 +123,22 @@ fn start_map_greeter_runs_init() {
fn set_tag_adds_and_removes_via_my_id() {
// A script calls set_tag(Me.id, "active", true) in init; the tag must be
// present on the object afterward.
let board = board_with_object(
let (board, scripts) = board_with_object(
Some("t"),
&[("t", r#"fn init() { set_tag(Me.id, "active", true); }"#)],
);
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(game.board().objects[&1].tags.contains("active"));
// A script removes a pre-existing tag.
let board2 = board_with_object(
let (mut board2, scripts2) = board_with_object(
Some("t2"),
&[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)],
);
// Seed the tag before construction.
let mut game2 = GameState::new({
let mut b = board2; // need mut to insert tag
b.objects.get_mut(&1).unwrap().tags.insert("active".to_string());
b
});
board2.objects.get_mut(&1).unwrap().tags.insert("active".to_string());
let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init();
assert!(!game2.board().objects[&1].tags.contains("active"));
}
@@ -149,7 +147,7 @@ fn set_tag_adds_and_removes_via_my_id() {
fn has_tag_reads_own_tags() {
// set_tag queues an action; has_tag reads the board state. So set_tag in
// init() takes effect after init returns; a subsequent tick sees it.
let board = board_with_object(
let (board, scripts) = board_with_object(
Some("t"),
&[(
"t",
@@ -157,7 +155,7 @@ fn has_tag_reads_own_tags() {
fn tick(dt) { log(Me.has_tag("active").to_string()); }"#,
)],
);
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "true"));
@@ -171,21 +169,15 @@ fn objects_with_tag_returns_matching_ids() {
let mut obj2 = scripted_object(1, 0, "none");
// obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not.
obj2.tags.insert("enemy".to_string());
let board = open_board(
5,
1,
(4, 0),
vec![obj1, obj2],
&[
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
let mut game = GameState::with_scripts(board, scripts_from(&[
("q", r#"fn init() {
let infos = Board.tagged("enemy");
log(infos.len().to_string());
log(infos[0].id.to_string());
}"#),
("none", ""),
],
);
let mut game = GameState::new(board);
]));
game.run_init();
let texts = log_texts(&game);
assert_eq!(texts[0], "1"); // exactly one match
@@ -195,21 +187,21 @@ fn objects_with_tag_returns_matching_ids() {
#[test]
fn my_name_returns_name_or_empty_string() {
// An object with a name set on its ObjectDef should see it via my_name().
let mut board = board_with_object(
let (mut board, scripts) = board_with_object(
Some("n"),
&[("n", r#"fn init() { log(Me.name); }"#)],
);
board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string());
let mut game = GameState::new(board);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert_eq!(log_texts(&game), vec!["beacon"]);
// An unnamed object should get an empty string.
let board2 = board_with_object(
let (board2, scripts2) = board_with_object(
Some("n"),
&[("n", r#"fn init() { log(Me.name); }"#)],
);
let mut game2 = GameState::new(board2);
let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init();
assert_eq!(log_texts(&game2), vec![""]);
}
@@ -221,21 +213,15 @@ fn object_id_for_name_finds_by_name() {
let obj1 = scripted_object(0, 0, "q");
let mut obj2 = scripted_object(1, 0, "none");
obj2.name = Some("target".to_string());
let board = open_board(
5,
1,
(4, 0),
vec![obj1, obj2],
&[
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
let mut game = GameState::with_scripts(board, scripts_from(&[
("q", r#"fn init() {
log(Board.named("target").id.to_string());
let miss = Board.named("missing");
log(if miss == () { "not_found" } else { miss.id.to_string() });
}"#),
("none", ""),
],
);
let mut game = GameState::new(board);
]));
game.run_init();
let texts = log_texts(&game);
assert_eq!(texts[0], "2"); // obj2 is id 2
@@ -247,14 +233,8 @@ fn object_id_for_name_finds_by_name() {
fn player_bump_fires_with_negative_one() {
// The player walks into a solid scripted object; the object is bumped with -1
// and the player does not move onto it.
let board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "b")],
&[("b", "fn bump(id) { log(`bumped by ${id}`); }")],
);
let mut game = GameState::new(board);
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")]));
game.run_init();
game.try_move(Direction::East);
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
@@ -268,12 +248,8 @@ fn scroll_opens_on_player_bump() {
// A solid object's bump() calls scroll([text, [choice, display]]). After the
// player bumps it and a tick resolves the queued action, active_scroll is set
// with the correct ScrollLine variants.
let board = open_board(
3, 1, (0, 0),
vec![scripted_object(1, 0, "s")],
&[("s", r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#)],
);
let mut game = GameState::new(board);
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#)]));
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
@@ -287,12 +263,8 @@ fn scroll_opens_on_player_bump() {
#[test]
fn close_scroll_without_choice_clears_it() {
let board = open_board(
3, 1, (0, 0),
vec![scripted_object(1, 0, "s")],
&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)],
);
let mut game = GameState::new(board);
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)]));
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
@@ -306,15 +278,11 @@ fn close_scroll_without_choice_clears_it() {
fn close_scroll_with_choice_dispatches_send_to_source() {
// Closing with a choice string fires send() on the object that opened the
// scroll; fn eat() logging "eaten" confirms the dispatch arrived.
let board = open_board(
3, 1, (0, 0),
vec![scripted_object(1, 0, "s")],
&[("s", r#"
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); }
"#)],
);
let mut game = GameState::new(board);
"#)]));
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
+94
View File
@@ -0,0 +1,94 @@
//! World type: a named collection of boards loaded from a single `.toml` file.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use crate::board::Board;
use crate::map_file::MapFile;
use serde::Deserialize;
/// A named world containing one or more boards, loaded from a `.toml` file.
///
/// The file groups all boards under a `[boards]` table. Each board key maps
/// to a full board definition (the same structure as a single-board map file,
/// but nested under `[boards.<key>.*]`). [`World::start`] names the board the
/// player enters first.
///
/// Scripts live at the world level ([`World::scripts`]) rather than on each
/// board, so multiple boards can reference the same script name without
/// duplicating source text.
pub struct World {
/// Human-readable name for this world, from the `[world]` header.
pub name: String,
/// Key of the starting board; matches a key in [`World::boards`].
pub start: String,
/// Named Rhai scripts shared across all boards: script name → source text.
///
/// Objects on any board reference a script by name via
/// [`ObjectDef::script_name`](crate::object_def::ObjectDef). The script pool is
/// passed to [`GameState::from_world`](crate::game::GameState::from_world) at startup.
pub scripts: HashMap<String, String>,
/// All boards in this world, keyed by their identifier string.
///
/// Every board is always present here as a shared `Rc<RefCell<Board>>` — no board
/// is ever "removed" when active. [`GameState`](crate::game::GameState) holds a
/// clone of the active board's `Rc` and borrows through it.
pub boards: HashMap<String, Rc<RefCell<Board>>>,
}
/// Serde target for the top-level `.toml` world file.
#[derive(Deserialize)]
struct WorldFile {
world: WorldHeader,
/// The `[scripts]` table: script name → Rhai source. Shared across all boards.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
scripts: HashMap<String, String>,
/// Each value deserializes as a [`MapFile`], reusing the existing per-board
/// serde types from `map_file.rs`.
boards: HashMap<String, MapFile>,
}
/// The `[world]` header section of a world file.
#[derive(Deserialize)]
struct WorldHeader {
/// Human-readable name for this world.
name: String,
/// Key of the board to start on; must match a key in `[boards]`.
start: String,
}
/// Load a world from a `.toml` world file at `path`.
///
/// Returns an error if the file cannot be read or parsed, if any board fails
/// the hard-error conversion (only a grid-dimension mismatch qualifies — all
/// other problems are recorded on [`Board::load_errors`]), or if [`World::start`]
/// does not match any board key in the file.
pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?;
let wf: WorldFile = toml::from_str(&text)?;
// Convert each MapFile into a Board. Grid-dimension mismatches are the only
// hard errors; everything else is nonfatal and lands on Board::load_errors.
let mut boards = HashMap::new();
for (key, map_file) in wf.boards {
let board = Board::try_from(map_file)
.map_err(|e| format!("board '{}': {}", key, e))?;
boards.insert(key, Rc::new(RefCell::new(board)));
}
// Guard: the start key must actually exist in the boards map.
if !boards.contains_key(&wf.world.start) {
return Err(format!(
"start board '{}' not found in world '{}'",
wf.world.start, wf.world.name
)
.into());
}
Ok(World {
name: wf.world.name,
start: wf.world.start,
scripts: wf.scripts,
boards,
})
}
+2 -10
View File
@@ -49,22 +49,14 @@ fn main() -> ExitCode {
};
// Load before touching the terminal so load errors print to a normal screen.
let board = match world::load(&path) {
Ok(mut w) => match w.boards.remove(&w.start) {
Some(b) => b,
None => {
eprintln!("error: start board '{}' not found", w.start);
return ExitCode::FAILURE;
}
},
let mut game = match world::load(&path) {
Ok(world) => GameState::from_world(world),
Err(e) => {
eprintln!("error loading {path}: {e}");
return ExitCode::FAILURE;
}
};
let mut game = GameState::new(board);
// `init` enters the alternate screen and raw mode; `restore` always undoes it.
let mut terminal = ratatui::init();
+88 -86
View File
@@ -2,6 +2,94 @@
name = "Kiln Demo"
start = "start"
# Named Rhai scripts shared across all boards in this world.
# Objects reference a script by name via `script_name`.
[scripts]
greeter = """
fn init() {
log(`hello from object player at ${Board.player_x}, ${Board.player_y}`);
set_tile(2); // change my glyph to proves the write path
say("Hello there,\\ntraveller!");
}
fn tick(dt) {
//log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`);
if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 {
say("Hey! Get offa me!");
delay(5.0);
}
}
"""
mover = """
// move() costs 250 ms, so the object steps ~4 cells/sec no matter how often
// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking
// into a wall (or another object's already-queued move this tick).
fn tick(dt) {
if Queue.length() == 0 {
move(East);
}
}
// Fires when something steps into this object; id is the bumper's object id,
// or -1 for the player.
fn bump(id) {
log(`mover bumped by ${id}`);
say("Ow!");
}
"""
muffin = """
fn bump(id) {
scroll([
"You find a small muffin on the ground, your favorite kind.",
"It smells of cinnamon and warm mornings.",
["eat", "Pick up the muffin and eat it."],
["ignore", "This is obviously a trap — ignore it."]
]);
}
fn eat() {
log("You ate the muffin. Delicious.");
say("Mmm!");
}
fn ignore() {
log("You walk away from the muffin. Wise.");
say("Suspicious...");
}
"""
noticeboard = """
fn bump(id) {
scroll([
" TOWN NOTICE BOARD",
" ",
"ROAD CLOSURE — The eastern road through Miller's Crossing remains closed",
"following flood damage to the main bridge. Repairs are underway but travellers",
"should expect delays of at least three weeks. The north fork via Ashwell is",
"passable, though it adds half a day to the journey.",
" ",
"LOST ANIMAL — One grey mule, answers to the name Pepper. Last seen grazing",
"near the old mill on the morning of the 14th. The animal has a notched left",
"ear and wears a blue rope halter. Any information to the innkeeper; a reward",
"of two silver pieces is offered for her safe return.",
" ",
"POSITION AVAILABLE — Captain Aldric of the town garrison seeks an experienced",
"wilderness guide for an expedition into the Darkwood. Duration: ten to fourteen",
"days. Pay is good and provisions will be supplied. Candidates must present",
"themselves at the barracks before the evening bell. Some risk is involved and",
"the captain asks that only those in sound health and good standing apply.",
" ",
"HARVEST FESTIVAL — The annual Harvest Festival will be held on the last",
"Saturday of the month. The market square will be closed to carts from dawn.",
"Stall permits must be obtained from the clerk's office no later than Thursday.",
"There will be music, a pie competition, and the traditional lantern parade at",
"dusk. All residents are warmly encouraged to attend.",
" ",
" Posted by order of the Town Council.",
" Unauthorised notices will be removed.",
]);
}
"""
[boards.start.map]
name = "Starting Room"
width = 60
@@ -124,89 +212,3 @@ bg = "#000000"
solid = true
name = "noticeboard"
script_name = "noticeboard"
[boards.start.scripts]
greeter = """
fn init() {
log(`hello from object player at ${Board.player_x}, ${Board.player_y}`);
set_tile(2); // change my glyph to proves the write path
say("Hello there,\\ntraveller!");
}
fn tick(dt) {
//log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`);
if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 {
say("Hey! Get offa me!");
delay(5.0);
}
}
"""
mover = """
// move() costs 250 ms, so the object steps ~4 cells/sec no matter how often
// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking
// into a wall (or another object's already-queued move this tick).
fn tick(dt) {
if Queue.length() == 0 {
move(East);
}
}
// Fires when something steps into this object; id is the bumper's object id,
// or -1 for the player.
fn bump(id) {
log(`mover bumped by ${id}`);
say("Ow!");
}
"""
muffin = """
fn bump(id) {
scroll([
"You find a small muffin on the ground, your favorite kind.",
"It smells of cinnamon and warm mornings.",
["eat", "Pick up the muffin and eat it."],
["ignore", "This is obviously a trap — ignore it."]
]);
}
fn eat() {
log("You ate the muffin. Delicious.");
say("Mmm!");
}
fn ignore() {
log("You walk away from the muffin. Wise.");
say("Suspicious...");
}
"""
noticeboard = """
fn bump(id) {
scroll([
" TOWN NOTICE BOARD",
" ",
"ROAD CLOSURE — The eastern road through Miller's Crossing remains closed",
"following flood damage to the main bridge. Repairs are underway but travellers",
"should expect delays of at least three weeks. The north fork via Ashwell is",
"passable, though it adds half a day to the journey.",
" ",
"LOST ANIMAL — One grey mule, answers to the name Pepper. Last seen grazing",
"near the old mill on the morning of the 14th. The animal has a notched left",
"ear and wears a blue rope halter. Any information to the innkeeper; a reward",
"of two silver pieces is offered for her safe return.",
" ",
"POSITION AVAILABLE — Captain Aldric of the town garrison seeks an experienced",
"wilderness guide for an expedition into the Darkwood. Duration: ten to fourteen",
"days. Pay is good and provisions will be supplied. Candidates must present",
"themselves at the barracks before the evening bell. Some risk is involved and",
"the captain asks that only those in sound health and good standing apply.",
" ",
"HARVEST FESTIVAL — The annual Harvest Festival will be held on the last",
"Saturday of the month. The market square will be closed to carts from dawn.",
"Stall permits must be obtained from the clerk's office no later than Thursday.",
"There will be music, a pie competition, and the traditional lantern parade at",
"dusk. All residents are warmly encouraged to attend.",
" ",
" Posted by order of the Town Council.",
" Unauthorised notices will be removed.",
]);
}
"""