multi board
This commit is contained in:
@@ -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
|
||||
# "g" = "grass" # generator: "grass" | "dirt" | "stone"
|
||||
# [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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
+38
-114
@@ -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")],
|
||||
&[(
|
||||
"q",
|
||||
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }",
|
||||
)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
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()}`); }",
|
||||
)]));
|
||||
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")],
|
||||
&[(
|
||||
"b",
|
||||
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)],
|
||||
);
|
||||
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::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[(
|
||||
"b",
|
||||
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)]));
|
||||
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")],
|
||||
&[(
|
||||
"b",
|
||||
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)],
|
||||
);
|
||||
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 init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)]));
|
||||
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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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")],
|
||||
&[
|
||||
("e", "fn init() { move(East); }"),
|
||||
("w", "fn init() { move(West); }"),
|
||||
],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[
|
||||
("e", "fn init() { move(East); }"),
|
||||
("w", "fn init() { move(West); }"),
|
||||
]));
|
||||
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],
|
||||
&[
|
||||
("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);
|
||||
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", ""),
|
||||
]));
|
||||
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],
|
||||
&[
|
||||
("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);
|
||||
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", ""),
|
||||
]));
|
||||
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#"
|
||||
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
|
||||
fn eat() { log("eaten"); }
|
||||
"#)],
|
||||
);
|
||||
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(["Muffin?", ["eat", "Eat it"]]); }
|
||||
fn eat() { log("eaten"); }
|
||||
"#)]));
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
game.tick(Duration::from_millis(16));
|
||||
|
||||
@@ -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
@@ -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
@@ -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.",
|
||||
]);
|
||||
}
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user