Board layers
This commit is contained in:
@@ -67,16 +67,23 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
||||
- `FontSpec { path: String, tile_w: u32, tile_h: u32 }` — optional per-board bitmap font. When `None`, the app default is used.
|
||||
|
||||
**`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
|
||||
- `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.
|
||||
- `ObjectDef` — a scripted tile: `x`, `y`, `z: usize` (layer index, drives draw order), `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 (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
|
||||
|
||||
**`kiln-core/src/layer.rs`** — palette layers (the map-file/draw-stack unit):
|
||||
- `Layer { cells: Vec<(Glyph, Archetype)> }` (`pub(crate)`) — one row-major draw layer. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the layer beneath shows through; solidity comes from the archetype, independent of transparency.
|
||||
- `LayerData { content, palette: HashMap<String, PaletteEntry> }` — serde for one `[[layers]]` entry. `PaletteEntry` is a single flat struct with a `kind: String` discriminator plus all-optional fields (`tile`, `fg`, `bg`, `generator`, `solid`, `opaque`, `pushable`, `script_name`, `tags`, `name`, `target_map`, `target_entry`); only the fields relevant to the kind are read. (One flat struct, not an enum, because `kind` is open-ended — any archetype name *or* a meta-kind.)
|
||||
- `build_layer(data, w, h, &mut StdRand, &mut Vec<LogLine>) -> Result<(Layer, Vec<Placement>), String>` — resolves the palette (via `resolve_entry`), validates the grid dims (the only hard `Err`), and walks the grid building the `Layer` plus a `Vec<Placement>` (`Object(ObjectTemplate, x, y)` / `Portal(PortalTemplate, x, y)` / `Player(x, y)`) for the map loader to resolve across layers. Procedural floors roll a fresh glyph per cell from the shared seeded `StdRand`.
|
||||
- `resolve_entry` maps `kind` → `Resolved`: `empty` → transparent cell; `floor` → a generator (per-cell roll) or a fixed visual-only `Empty` glyph; `object`/`portal`/`player` → a placement; any other string → `Archetype::try_from` (`Ok` → terrain cell; `Err` → visible `ErrorBlock` + logged error). A `portal` missing `name`/`target_map`/`target_entry` is dropped to a transparent cell with an error.
|
||||
|
||||
**`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), `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).
|
||||
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `layers: Vec<Layer>` (bottom→top draw stack; `pub(crate)`), `player: Player`, `objects: BTreeMap<ObjectId, ObjectDef>`, `next_object_id: ObjectId`, `portals: Vec<PortalDef>`, `board_script_name: Option<String>` (name of a board-level script in the world script pool, if any), `load_errors: Vec<LogLine>` (`pub(crate)`), `registry: HashMap<String, RegistryValue>`. Scripts are **not** stored on `Board` — they live in [`World::scripts`]. The visual floor is no longer a separate field: it is just `Empty` cells with a visible glyph on a lower layer.
|
||||
- `layer_count() -> usize`; `get(z, x, y) -> &(Glyph, Archetype)` / `get_mut(z, x, y)` — per-layer cell access (panics OOB).
|
||||
- `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`. The player draws on top (returns `Glyph::player()` at its cell); otherwise layers are walked **top-down** and the first thing that draws wins: a solid object on that layer (always) or a visible non-solid object, else a portal on that layer, else the layer's terrain cell (a solid always draws; a non-solid only if `tile != 0`). Nothing anywhere → the canonical black `Empty` glyph. 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 a solid terrain archetype on *any* layer via `solid_cell_layer`).
|
||||
- `is_passable(x, y)` — convenience inverse of `solid_at`.
|
||||
- `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
|
||||
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving `Empty` behind.
|
||||
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). Crates/pushers move within their own layer (found via `solid_cell_layer`).
|
||||
- `pusher_cells() -> Vec<(usize, usize)>` — coordinates of every `Pusher` terrain cell across all layers (used by the pusher heartbeat).
|
||||
- `add_object(obj) -> ObjectId`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object queries.
|
||||
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
|
||||
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
|
||||
@@ -93,11 +100,9 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
||||
- `Action` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String)`, `Delay(f64)` (never reaches the board queue — consumed by `ScriptHost::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`.
|
||||
- `action_to_map(action) -> rhai::Map` — converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. The map always has a `"type"` key; other keys carry the payload. `Scroll` emits `type` only (lines are not inspectable via the map API). `Log` is flattened to its first span's text.
|
||||
|
||||
**`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.
|
||||
- `FloorGenerator` (`Grass`/`Dirt`/`Stone`) — procedural textures differing only in color scheme and texture-char probability. `from_name(&str)` parses the map-file name; `generate(&mut StdRand)` picks a dark, low-saturation ground color (green/brown/gray) and, with the generator's probability, scatters a lighter texture char (grass `, . \` '`; dirt `. : , ;`; stone `. ,`). Uses `tinyrand` (already a workspace dep, WASM-safe) seeded with a fixed `FLOOR_SEED` for deterministic, testable output.
|
||||
- `FloorSpec` — `#[serde(untagged)]` enum for the `[floor]` declaration: `Generator(String)` (whole board), `Single(FloorGlyph)` (one fixed glyph everywhere), or `Grid { content, palette: HashMap<String, FloorTile> }` (a grid with its own palette). Untagged ordering matters: a string → `Generator`; a table with `tile` → `Single`; a table with `content` → `Grid`. `FloorTile` (untagged) is a grid-palette entry: a generator name (string) or a `FloorGlyph` (table). `FloorGlyph { tile: TileIndex, fg, bg }` reuses `map_file::TileIndex` (now `pub`) and `parse_color`.
|
||||
- `build_floor(&Option<FloorSpec>, w, h, &mut Vec<LogLine>) -> Vec<Glyph>` — best-effort/nonfatal (like the map loader): unknown generators/chars and grid-dimension mismatches are recorded on the error vec and that cell falls back to the canonical black-on-black space; `None` → an all-empty floor (the historical look).
|
||||
**`kiln-core/src/floor.rs`** — procedural floor generators:
|
||||
- A "floor" is just a non-solid, visible `Empty` cell on a layer (a palette entry with `kind = "floor"`). This module only owns the generators; the actual per-cell placement happens in `layer.rs` during load.
|
||||
- `FloorGenerator` (`Grass`/`Dirt`/`Stone`) — procedural textures differing only in color scheme and texture-char probability. `from_name(&str)` parses the map-file name; `generate(&mut StdRand)` picks a dark, low-saturation ground color (green/brown/gray) and, with the generator's probability, scatters a lighter texture char (grass `, . \` '`; dirt `. : , ;`; stone `. ,`). Uses `tinyrand` (already a workspace dep, WASM-safe). `FLOOR_SEED` (`pub(crate)`) seeds one `StdRand` per board build (in `map_file`), threaded through every generator call, so floors are deterministic/testable and depend only on map content.
|
||||
|
||||
**`kiln-core/src/log.rs`** — styled log messages:
|
||||
- `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, `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.
|
||||
@@ -112,14 +117,13 @@ 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`** — 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 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/map_file.rs`** — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in `layer.rs`):
|
||||
- `MapFile { map: MapHeader, layers: Vec<LayerData> }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `board_script_name`; **no** `player_start` — the player is a `kind = "player"` palette char) plus the `[[layers]]` stack. Does **not** include scripts (those live in `World::scripts`).
|
||||
- `TileIndex` (`pub`) — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably. `parse_color`/`color_to_hex` (`pub(crate)`) are shared with `layer.rs`.
|
||||
- `impl TryFrom<MapFile> for Board` — the load conversion: builds each layer via `layer::build_layer` (collecting object/portal/player placements with their layer `z`), then runs the cross-layer validations. **Best-effort/nonfatal**: only a layer grid-dimension mismatch returns `Err`; everything else is recorded on `Board::load_errors` (see `Board::is_valid`) — the player must appear exactly once (missing → `(0,0)`; multiple → first) and *wins* its cell; one solid per cell across all layers (a stacked solid is dropped); object names board-unique (a dup is cleared), portal names unique (a dup is dropped). Object ids are assigned in layer-then-reading order.
|
||||
- `impl From<&Board> for MapFile` — save: emits one `[[layers]]` per board layer (deduping each unique `(Glyph, Archetype)` to a palette char), with objects/portals grouped by `z` and the player written onto the top layer. Generators baked to literal glyphs at load are saved as fixed `floor` glyphs (the generator name is not recovered).
|
||||
- `pub fn load(path: &str) -> Result<Board, …>` — reads a single-board `.toml` file. Production code uses `world::load` instead.
|
||||
- `pub fn save(board: &Board, path: &Path) -> Result<(), …>` — 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).
|
||||
@@ -238,6 +242,8 @@ Each `.toml` file is a **world**: a named collection of boards plus a shared scr
|
||||
|
||||
**Keep this example in sync with `world.rs` / `map_file.rs` whenever the format changes.**
|
||||
|
||||
A board is a `[map]` header plus an **ordered array of `[[layers]]`** (bottom→top). Each layer is a `content` grid and a `palette` mapping each char to one *kind* of thing. To put more than one thing on a cell (e.g. a non-solid object above a wall), put them on different layers — drawing follows layer order.
|
||||
|
||||
```toml
|
||||
[world]
|
||||
name = "My World"
|
||||
@@ -259,85 +265,59 @@ 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.
|
||||
# NOTE: there is no `player_start` — the player is placed by a kind="player" char.
|
||||
[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.
|
||||
[boards.room1.font]
|
||||
path = "assets/my_font.png"
|
||||
tile_w = 8
|
||||
tile_h = 16
|
||||
|
||||
[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" } # pushable east/west only
|
||||
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # pushable north/south only
|
||||
|
||||
# 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"
|
||||
# [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" }
|
||||
[boards.room1.floor]
|
||||
# Layer 0 (bottom): the visual floor. Generators roll a fresh textured glyph per
|
||||
# cell; a fixed-glyph form (tile/fg/bg, no generator) is also allowed.
|
||||
[[boards.room1.layers]]
|
||||
content = """
|
||||
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggddddddddddddddddddddddddddddddggggggggggggggg
|
||||
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
|
||||
...60×25 grid of 'g'/'d'...
|
||||
"""
|
||||
[boards.room1.floor.palette]
|
||||
"g" = "grass"
|
||||
"d" = "dirt"
|
||||
[boards.room1.layers.palette]
|
||||
"g" = { kind = "floor", generator = "grass" } # "grass" | "dirt" | "stone"
|
||||
"d" = { kind = "floor", generator = "dirt" }
|
||||
"." = { kind = "floor", tile = ".", fg = "#222", bg = "#000" } # fixed floor glyph
|
||||
|
||||
[boards.room1.grid]
|
||||
# Layer 1: terrain, the player, objects and portals. A space here resolves to
|
||||
# kind="empty" (transparent), so the floor below shows through.
|
||||
[[boards.room1.layers]]
|
||||
content = """
|
||||
############################################################
|
||||
# G #
|
||||
# G @ 1 #
|
||||
############################################################
|
||||
"""
|
||||
|
||||
[[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
|
||||
# may appear multiple times — one ObjectDef is spawned per occurrence in reading
|
||||
# order. Only one [[objects]] entry may use a given char, and it must not be a
|
||||
# [palette] key. Each matching cell loads as Empty. If the entry has a `name`,
|
||||
# only the first spawned instance keeps it (names must be board-unique).
|
||||
palette = "G"
|
||||
tile = "#"
|
||||
fg = "#aa3333"
|
||||
bg = "#000000"
|
||||
solid = false # blocks movement? defaults true. (pushable defaults false)
|
||||
name = "greeter" # optional unique name; readable via Me.name / Board.named()
|
||||
script_name = "greeter" # references a key in the world-level [scripts] table
|
||||
|
||||
[[boards.room1.portals]] # optional; parsed but not yet runtime-wired
|
||||
x = 59
|
||||
y = 12
|
||||
target_map = "room2"
|
||||
target_entry = "west_door"
|
||||
[boards.room1.layers.palette]
|
||||
" " = { kind = "empty" } # transparent
|
||||
"#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" }
|
||||
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } # solid + pushable (any dir)
|
||||
"-" = { kind = "hcrate" } # pushable east/west only
|
||||
"|" = { kind = "vcrate" } # pushable north/south only
|
||||
">" = { kind = "pusher_east" }
|
||||
"@" = { kind = "player" } # exactly one across all layers
|
||||
# An object: spawned once per occurrence of its char (uppercase by convention).
|
||||
# If it has a `name`, only the first instance keeps it (names are board-unique).
|
||||
"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000",
|
||||
solid = false, name = "greeter", script_name = "greeter" }
|
||||
# A portal: its char must appear exactly once.
|
||||
"1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" }
|
||||
```
|
||||
|
||||
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.
|
||||
Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph.
|
||||
|
||||
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)`.
|
||||
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error). An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer 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 (on any layer) 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
|
||||
|
||||
- **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`solid`, `opaque`, `pushable`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site.
|
||||
- **The floor layer is how `Empty` is drawn** — `Board::glyph_at` returns the per-cell `floor` glyph for any `Empty` cell, so an `Empty` cell's *own* glyph is ignored (and a cell vacated by a pushed crate automatically shows the floor with no movement-code changes). The floor is computed once at load and cached (`Board::floor`); the raw `FloorSpec` is kept (`Board::floor_spec`) only so saves round-trip. One consequence: a map that colored an `Empty` cell's bg no longer shows it — empties always come from the floor layer (default black-on-black). See `floor.rs`.
|
||||
- **One solid per cell** — at most one solid entity (the player, a solid grid archetype, *or* a solid object) may occupy a cell. The player is a first-class solid: `Board::solid_at` reports `Solid::Player` for the player's cell (checked first), it blocks movers, and it is **pushable in any direction** — a push chain that reaches the player slides the player along (and is rejected if the player has nowhere to go, e.g. against a wall). The map loader enforces the invariant: a solid object landing on an already-solid cell is dropped (recorded via `report_error`); the player *wins* its cell — its resolved position (including the `(0, 0)` fallback when `player_start` is unresolvable) silently clears any solid terrain under it and drops a conflicting object. `Board::solid_at` relies on this invariant.
|
||||
- **`cells: Vec<(Glyph, Archetype)>`** — each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient.
|
||||
- **Draw order is layer order** — `Board::glyph_at` walks `Board::layers` top-down and returns the first thing that draws (a solid object always; a visible non-solid object; a portal; a solid terrain cell; a non-transparent terrain cell). There is no separate floor field: a "floor" is an `Empty` cell with a visible glyph on a lower layer, so a non-solid object on a higher layer can render *above* a solid one. A cell vacated by a pushed crate is left transparent (`Glyph::transparent()`, tile 0), revealing the layer beneath with no movement-code changes. See `board.rs` / `layer.rs`.
|
||||
- **One solid per cell (across all layers)** — at most one solid entity (the player, a solid terrain archetype on any layer, *or* a solid object) may occupy a cell. The player is a first-class solid: `Board::solid_at` reports `Solid::Player` for the player's cell (checked first), it blocks movers, and it is **pushable in any direction** — a push chain that reaches the player slides the player along (and is rejected if the player has nowhere to go, e.g. against a wall). The map loader enforces the invariant: a second solid stacked on a cell is dropped (recorded as a load error); the player *wins* its cell — its resolved position (including the `(0, 0)` fallback when no `player` char resolves) silently clears any solid terrain under it (any layer) and drops a conflicting object. `Board::solid_at` relies on this invariant.
|
||||
- **Each layer is `Vec<(Glyph, Archetype)>`** — every cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient. A transparent cell (glyph tile 0) draws nothing; solidity is read from the archetype regardless of transparency.
|
||||
- **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.
|
||||
@@ -351,7 +331,7 @@ The long-term goal is for the "player" to be a board *object* that happens to re
|
||||
|
||||
Today's baked-in assumptions that will need to change (don't make `Board.player` *more* central in the meantime — e.g. don't add methods that assume player presence):
|
||||
- `Board.player: Player` is required and non-optional; a player-less board can't be represented (should become `Option<Player>`, or move the player out of `Board` to the engine layer).
|
||||
- `player_start` is effectively required; player spawning should eventually move into the object/script system.
|
||||
- A `kind = "player"` cell is effectively required (exactly one); player spawning should eventually move into the object/script system rather than being a special palette kind.
|
||||
- `GameState::try_move` mutates `board.player` directly; once the player is script-driven, movement should go through event dispatch (e.g. `dispatch_event(ArrowKey(dir))`) rather than a dedicated method.
|
||||
- The player is rendered as a hardcoded overlay (`Glyph::player()`); it should become part of the normal object layer.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user