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.
|
||||
|
||||
|
||||
+47
-19
@@ -41,15 +41,26 @@ pub(crate) enum Action {
|
||||
/// Append a styled line to the game log.
|
||||
Log(LogLine),
|
||||
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
|
||||
SetTag { target: ObjectId, tag: String, present: bool },
|
||||
SetTag {
|
||||
target: ObjectId,
|
||||
tag: String,
|
||||
present: bool,
|
||||
},
|
||||
/// Display a speech bubble above the source object for `duration` seconds.
|
||||
Say(String, f64),
|
||||
/// Pause draining this object's queue for `secs` seconds. Never reaches the board queue.
|
||||
Delay(f64),
|
||||
/// Set the source object's foreground and/or background color.
|
||||
SetColor { fg: Option<Rgba8>, bg: Option<Rgba8> },
|
||||
SetColor {
|
||||
fg: Option<Rgba8>,
|
||||
bg: Option<Rgba8>,
|
||||
},
|
||||
/// Call `fn_name` on `target` object, optionally passing `arg`.
|
||||
Send { target: ObjectId, fn_name: String, arg: Option<ScriptArg> },
|
||||
Send {
|
||||
target: ObjectId,
|
||||
fn_name: String,
|
||||
arg: Option<ScriptArg>,
|
||||
},
|
||||
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice
|
||||
/// selection dispatches [`Send`](Action::Send) to the source object.
|
||||
Scroll(Vec<ScrollLine>),
|
||||
@@ -65,8 +76,8 @@ fn dir_to_str(d: Direction) -> &'static str {
|
||||
match d {
|
||||
Direction::North => "North",
|
||||
Direction::South => "South",
|
||||
Direction::East => "East",
|
||||
Direction::West => "West",
|
||||
Direction::East => "East",
|
||||
Direction::West => "West",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,26 +94,35 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
|
||||
match action {
|
||||
Action::Move(dir) => {
|
||||
m.insert("type".into(), ds("Move"));
|
||||
m.insert("dir".into(), ds(dir_to_str(*dir)));
|
||||
m.insert("dir".into(), ds(dir_to_str(*dir)));
|
||||
}
|
||||
Action::SetTile(n) => {
|
||||
m.insert("type".into(), ds("SetTile"));
|
||||
m.insert("tile".into(), Dynamic::from(*n as i64));
|
||||
}
|
||||
Action::Log(line) => {
|
||||
let text = line.spans.first().map(|s| s.text.as_str()).unwrap_or("").to_string();
|
||||
let text = line
|
||||
.spans
|
||||
.first()
|
||||
.map(|s| s.text.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
m.insert("type".into(), ds("Log"));
|
||||
m.insert("msg".into(), Dynamic::from(text));
|
||||
m.insert("msg".into(), Dynamic::from(text));
|
||||
}
|
||||
Action::SetTag { target, tag, present } => {
|
||||
m.insert("type".into(), ds("SetTag"));
|
||||
m.insert("target".into(), Dynamic::from(*target as i64));
|
||||
m.insert("tag".into(), Dynamic::from(tag.clone()));
|
||||
Action::SetTag {
|
||||
target,
|
||||
tag,
|
||||
present,
|
||||
} => {
|
||||
m.insert("type".into(), ds("SetTag"));
|
||||
m.insert("target".into(), Dynamic::from(*target as i64));
|
||||
m.insert("tag".into(), Dynamic::from(tag.clone()));
|
||||
m.insert("present".into(), Dynamic::from(*present));
|
||||
}
|
||||
Action::Say(text, dur) => {
|
||||
m.insert("type".into(), ds("Say"));
|
||||
m.insert("msg".into(), Dynamic::from(text.clone()));
|
||||
m.insert("type".into(), ds("Say"));
|
||||
m.insert("msg".into(), Dynamic::from(text.clone()));
|
||||
m.insert("duration".into(), Dynamic::from(*dur));
|
||||
}
|
||||
Action::Delay(secs) => {
|
||||
@@ -111,13 +131,21 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
|
||||
}
|
||||
Action::SetColor { fg, bg } => {
|
||||
m.insert("type".into(), ds("SetColor"));
|
||||
if let Some(c) = fg { m.insert("fg".into(), Dynamic::from(color_to_hex(*c))); }
|
||||
if let Some(c) = bg { m.insert("bg".into(), Dynamic::from(color_to_hex(*c))); }
|
||||
if let Some(c) = fg {
|
||||
m.insert("fg".into(), Dynamic::from(color_to_hex(*c)));
|
||||
}
|
||||
if let Some(c) = bg {
|
||||
m.insert("bg".into(), Dynamic::from(color_to_hex(*c)));
|
||||
}
|
||||
}
|
||||
Action::Send { target, fn_name, arg } => {
|
||||
m.insert("type".into(), ds("Send"));
|
||||
Action::Send {
|
||||
target,
|
||||
fn_name,
|
||||
arg,
|
||||
} => {
|
||||
m.insert("type".into(), ds("Send"));
|
||||
m.insert("target".into(), Dynamic::from(*target as i64));
|
||||
m.insert("name".into(), Dynamic::from(fn_name.clone()));
|
||||
m.insert("name".into(), Dynamic::from(fn_name.clone()));
|
||||
if let Some(a) = arg {
|
||||
let dyn_arg = match a {
|
||||
ScriptArg::Str(s) => Dynamic::from(s.clone()),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use color::Rgba8;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::utils::{Behavior, Direction, Pushable};
|
||||
use color::Rgba8;
|
||||
|
||||
/// A class of board cell, encoding its default behavior and appearance.
|
||||
///
|
||||
@@ -95,8 +95,8 @@ impl Archetype {
|
||||
Archetype::VCrate => "vcrate",
|
||||
Archetype::Pusher(Direction::North) => "pusher_north",
|
||||
Archetype::Pusher(Direction::South) => "pusher_south",
|
||||
Archetype::Pusher(Direction::East) => "pusher_east",
|
||||
Archetype::Pusher(Direction::West) => "pusher_west",
|
||||
Archetype::Pusher(Direction::East) => "pusher_east",
|
||||
Archetype::Pusher(Direction::West) => "pusher_west",
|
||||
Archetype::ErrorBlock => "error_block",
|
||||
}
|
||||
}
|
||||
@@ -174,8 +174,8 @@ impl TryFrom<&str> for Archetype {
|
||||
// entries in map files. They live in [[objects]] with their own glyph.
|
||||
"pusher_north" => Ok(Archetype::Pusher(Direction::North)),
|
||||
"pusher_south" => Ok(Archetype::Pusher(Direction::South)),
|
||||
"pusher_east" => Ok(Archetype::Pusher(Direction::East)),
|
||||
"pusher_west" => Ok(Archetype::Pusher(Direction::West)),
|
||||
"pusher_east" => Ok(Archetype::Pusher(Direction::East)),
|
||||
"pusher_west" => Ok(Archetype::Pusher(Direction::West)),
|
||||
_ => Err(format!("unknown archetype: {name}")),
|
||||
}
|
||||
}
|
||||
|
||||
+216
-111
@@ -1,11 +1,11 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use crate::archetype::Archetype;
|
||||
use crate::archetype::Archetype::Empty;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::layer::Layer;
|
||||
use crate::log::LogLine;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::Direction;
|
||||
use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// The complete state of one game board (a single room or screen).
|
||||
///
|
||||
@@ -31,19 +31,12 @@ pub struct Board {
|
||||
pub width: usize,
|
||||
/// Height of the board in cells.
|
||||
pub height: usize,
|
||||
/// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to
|
||||
/// access by `(x, y)` coordinates.
|
||||
pub(crate) cells: Vec<(Glyph, Archetype)>,
|
||||
/// Row-major cache of the visual floor layer: the glyph drawn for a cell when
|
||||
/// it would otherwise render as [`Archetype::Empty`]. Computed once at load
|
||||
/// from [`floor_spec`](Board::floor_spec) (see [`crate::floor::build_floor`]).
|
||||
/// When a map declares no `[floor]`, every entry is black-on-black space (the
|
||||
/// historical look of an empty cell). Read it via [`Board::glyph_at`].
|
||||
pub(crate) floor: Vec<Glyph>,
|
||||
/// The raw `[floor]` declaration, kept so [`crate::map_file::save`] can
|
||||
/// round-trip it. `None` when the map declared no floor. (Generators
|
||||
/// re-randomize on reload; literal glyph grids are preserved exactly.)
|
||||
pub(crate) floor_spec: Option<crate::floor::FloorSpec>,
|
||||
/// Ordered draw stack of [`Layer`]s, bottom (index 0) to top. Each layer holds
|
||||
/// a row-major grid of `(Glyph, Archetype)` cells; a transparent cell lets the
|
||||
/// layer beneath show through. Drawing ([`Board::glyph_at`]) walks the stack
|
||||
/// top-down; solidity ([`Board::solid_at`]) scans every layer. Access a single
|
||||
/// cell with [`Board::get`]/[`Board::get_mut`] by `(z, x, y)`.
|
||||
pub(crate) layers: Vec<Layer>,
|
||||
/// Current player position. See [`Player`] for caveats about its future.
|
||||
pub player: Player,
|
||||
/// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap`
|
||||
@@ -75,61 +68,85 @@ pub struct Board {
|
||||
}
|
||||
|
||||
impl Board {
|
||||
/// Returns a reference to the cell at `(x, y)`.
|
||||
/// Number of draw layers on this board (≥ 1 for a loaded board).
|
||||
pub fn layer_count(&self) -> usize {
|
||||
self.layers.len()
|
||||
}
|
||||
|
||||
/// Returns a reference to the cell at `(x, y)` on layer `z`.
|
||||
///
|
||||
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `x` or `y` are
|
||||
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are
|
||||
/// out of bounds.
|
||||
pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) {
|
||||
&self.cells[y * self.width + x]
|
||||
pub fn get(&self, z: usize, x: usize, y: usize) -> &(Glyph, Archetype) {
|
||||
&self.layers[z].cells[y * self.width + x]
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the cell at `(x, y)`.
|
||||
/// Returns a mutable reference to the cell at `(x, y)` on layer `z`.
|
||||
///
|
||||
/// Panics if `x` or `y` are out of bounds.
|
||||
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
|
||||
&mut self.cells[y * self.width + x]
|
||||
/// Panics if `z`, `x`, or `y` are out of bounds.
|
||||
pub fn get_mut(&mut self, z: usize, x: usize, y: usize) -> &mut (Glyph, Archetype) {
|
||||
let w = self.width;
|
||||
&mut self.layers[z].cells[y * w + x]
|
||||
}
|
||||
|
||||
/// Returns the glyph we should display for a given coordinate:
|
||||
/// Returns the glyph to display at `(x, y)`, honoring layer draw order.
|
||||
///
|
||||
/// - If the coord contains a [`Solid`], use the appropriate glyph for that
|
||||
/// - If it contains at least one non-solid object with a glyph other than 0, use one (arbitrarily)
|
||||
/// - If it contains any non-Empty archetype, use that glyph
|
||||
/// - Fall back to the [`floor`](Board::floor) glyph.
|
||||
/// The player is always drawn on top (it is not part of the layer stack yet).
|
||||
/// Otherwise the layers are walked **top-down**; the first thing that draws on
|
||||
/// a layer wins:
|
||||
///
|
||||
/// An `Empty` cell's *own* glyph is intentionally ignored — the floor layer
|
||||
/// supersedes it (so a cell vacated by a pushed crate reveals the floor, not
|
||||
/// black). Panics if out of bounds.
|
||||
/// 1. an object on that layer (a solid object always; otherwise a non-solid
|
||||
/// object whose glyph is not transparent, i.e. `tile != 0`),
|
||||
/// 2. a portal on that layer,
|
||||
/// 3. the layer's terrain cell — a solid always draws, and a non-solid draws
|
||||
/// only when not transparent (`tile != 0`).
|
||||
///
|
||||
/// If no layer contributes anything, the canonical black `Empty` glyph is used.
|
||||
/// Panics if out of bounds.
|
||||
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
|
||||
if let Some(solid) = self.solid_at(x, y) {
|
||||
// Use the solid
|
||||
return match solid {
|
||||
Solid::Player => Glyph::player(),
|
||||
Solid::Cell(_) => self.get(x, y).0,
|
||||
Solid::Object(id) => self.objects[&id].glyph
|
||||
// The player is rendered above the whole stack (see the `Player` notes).
|
||||
if self.player.x == x as i32 && self.player.y == y as i32 {
|
||||
return Glyph::player();
|
||||
}
|
||||
|
||||
for z in (0..self.layers.len()).rev() {
|
||||
// Objects on this layer: a solid object always draws; otherwise the
|
||||
// first non-transparent non-solid object (lets invisible objects exist).
|
||||
let mut nonsolid: Option<Glyph> = None;
|
||||
for o in self
|
||||
.objects
|
||||
.values()
|
||||
.filter(|o| o.x == x && o.y == y && o.z == z)
|
||||
{
|
||||
if o.solid {
|
||||
return o.glyph;
|
||||
}
|
||||
if nonsolid.is_none() && o.glyph.tile != 0 {
|
||||
nonsolid = Some(o.glyph);
|
||||
}
|
||||
}
|
||||
if let Some(g) = nonsolid {
|
||||
return g;
|
||||
}
|
||||
|
||||
// A portal on this layer draws above its (transparent) terrain cell.
|
||||
if self
|
||||
.portals
|
||||
.iter()
|
||||
.any(|p| p.x == x && p.y == y && p.z == z)
|
||||
{
|
||||
return PortalDef::default_glyph();
|
||||
}
|
||||
|
||||
// The terrain cell: a solid always draws; a non-solid only if visible.
|
||||
let (glyph, arch) = self.get(z, x, y);
|
||||
if arch.behavior().solid || glyph.tile != 0 {
|
||||
return *glyph;
|
||||
}
|
||||
}
|
||||
|
||||
let (glyph, arch) = *self.get(x, y);
|
||||
let ids = self.object_ids_at(x, y); // IDs of non-solid (ethereal?) objects
|
||||
|
||||
// Objects with nonzero (nonblank) glyphs take precedence. This allows us to use invisible
|
||||
// nonsolid objects on the board.
|
||||
if let Some(glyph) = ids.iter().find(|id| self.objects[id].glyph.tile != 0 ).map(|id| self.objects[id].glyph) {
|
||||
return glyph
|
||||
}
|
||||
|
||||
// Is there a normal archetype?
|
||||
if arch != Empty {
|
||||
return glyph
|
||||
}
|
||||
|
||||
// Portal: passable and non-opaque, but visible above the floor.
|
||||
if self.portals.iter().any(|p| p.x == x && p.y == y) {
|
||||
return PortalDef::default_glyph();
|
||||
}
|
||||
|
||||
self.floor[y * self.width + x]
|
||||
// Nothing on any layer: the canonical black empty cell.
|
||||
Archetype::Empty.default_glyph()
|
||||
}
|
||||
|
||||
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
|
||||
@@ -153,7 +170,7 @@ impl Board {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.load_errors.is_empty()
|
||||
}
|
||||
|
||||
|
||||
/// Returns the single solid entity occupying `(x, y)`, if any.
|
||||
///
|
||||
/// Checks player first, then objects, then the grid archetype. Because at most one solid
|
||||
@@ -165,19 +182,38 @@ impl Board {
|
||||
if self.player.x == x as i32 && self.player.y == y as i32 {
|
||||
return Some(Solid::Player);
|
||||
}
|
||||
// A solid object shadows the grid cell it sits on.
|
||||
if let Some(id) = self.solid_object_id_at(x, y)
|
||||
{
|
||||
// A solid object shadows the cell it sits on.
|
||||
if let Some(id) = self.solid_object_id_at(x, y) {
|
||||
return Some(Solid::Object(id));
|
||||
}
|
||||
// Otherwise the grid archetype itself may be solid (e.g. a wall).
|
||||
let arch = self.get(x, y).1;
|
||||
if arch.behavior().solid {
|
||||
return Some(Solid::Cell(arch));
|
||||
// Otherwise some layer's terrain archetype may be solid (e.g. a wall).
|
||||
if let Some(z) = self.solid_cell_layer(x, y) {
|
||||
return Some(Solid::Cell(self.get(z, x, y).1));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the index of the layer whose terrain cell at `(x, y)` is solid, if
|
||||
/// any. By the one-solid-per-cell invariant there is at most one such layer.
|
||||
fn solid_cell_layer(&self, x: usize, y: usize) -> Option<usize> {
|
||||
(0..self.layers.len()).find(|&z| self.get(z, x, y).1.behavior().solid)
|
||||
}
|
||||
|
||||
/// Returns the coordinates of every [`Archetype::Pusher`] terrain cell on the
|
||||
/// board (scanning all layers). Used by the pusher heartbeat in
|
||||
/// [`GameState::tick`](crate::game::GameState::tick).
|
||||
pub fn pusher_cells(&self) -> Vec<(usize, usize)> {
|
||||
let mut cells = Vec::new();
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
if matches!(self.solid_at(x, y), Some(Solid::Cell(Archetype::Pusher(_)))) {
|
||||
cells.push((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it.
|
||||
///
|
||||
/// Convenience inverse of [`solid_at`](Board::solid_at).
|
||||
@@ -261,7 +297,12 @@ impl Board {
|
||||
/// - If the target has a pushable chain: pushes it first, then slides in.
|
||||
/// - If blocked or out of bounds: no-op, returns `false`.
|
||||
pub(crate) fn advance_pusher(&mut self, x: usize, y: usize) -> bool {
|
||||
let Archetype::Pusher(dir) = self.get(x, y).1 else { return false; };
|
||||
let Some(z) = self.solid_cell_layer(x, y) else {
|
||||
return false;
|
||||
};
|
||||
let Archetype::Pusher(dir) = self.get(z, x, y).1 else {
|
||||
return false;
|
||||
};
|
||||
let (dx, dy): (i32, i32) = dir.into();
|
||||
let tx = x as i32 + dx;
|
||||
let ty = y as i32 + dy;
|
||||
@@ -283,24 +324,24 @@ impl Board {
|
||||
|
||||
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
|
||||
///
|
||||
/// A solid object is relocated; otherwise the grid archetype (a crate) is
|
||||
/// moved, leaving `Empty` floor behind (the grid has no separate floor layer).
|
||||
/// A solid object is relocated (keeping its layer); otherwise the solid
|
||||
/// terrain archetype (a crate) is moved within its own layer, leaving a
|
||||
/// transparent cell behind so the layer beneath (e.g. floor) shows through.
|
||||
/// The caller guarantees the destination is already clear.
|
||||
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
|
||||
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
|
||||
// The player owns its cell, so move it before considering objects/grid.
|
||||
// The player owns its cell, so move it before considering objects/terrain.
|
||||
if self.player.x == x as i32 && self.player.y == y as i32 {
|
||||
self.player.x = tx as i32;
|
||||
self.player.y = ty as i32;
|
||||
} else if let Some(id) = self.solid_object_id_at(x, y)
|
||||
{
|
||||
} else if let Some(id) = self.solid_object_id_at(x, y) {
|
||||
let obj = self.objects.get_mut(&id).expect("id from object_id_at");
|
||||
obj.x = tx;
|
||||
obj.y = ty;
|
||||
} else {
|
||||
let moved = *self.get(x, y);
|
||||
*self.get_mut(tx, ty) = moved;
|
||||
*self.get_mut(x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty);
|
||||
} else if let Some(z) = self.solid_cell_layer(x, y) {
|
||||
let moved = *self.get(z, x, y);
|
||||
*self.get_mut(z, tx, ty) = moved;
|
||||
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,20 +350,19 @@ impl Board {
|
||||
self.objects
|
||||
.iter()
|
||||
.filter(|(_, o)| o.x == x && o.y == y)
|
||||
.map(|(&id, _)| id).collect()
|
||||
.map(|(&id, _)| id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns a borrow of the actual object at `(x, y)` if any
|
||||
pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option<ObjectId> {
|
||||
self.objects
|
||||
.iter()
|
||||
.find_map(|(&id, o)| {
|
||||
if o.x == x && o.y == y && o.solid {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
self.objects.iter().find_map(|(&id, o)| {
|
||||
if o.x == x && o.y == y && o.solid {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id.
|
||||
@@ -340,17 +380,22 @@ impl Board {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use color::Rgba8;
|
||||
use super::Board;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::layer::Layer;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::Direction;
|
||||
use crate::utils::{ObjectId, Player, Solid};
|
||||
use super::Board;
|
||||
use color::Rgba8;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// Builds an all-empty `w×h` board with the given player position and objects.
|
||||
/// Assigns sequential ids (1..=n) to objects.
|
||||
/// Builds an all-empty `w×h` single-layer board with the given player position
|
||||
/// and objects (all on layer 0). Assigns sequential ids (1..=n) to objects.
|
||||
///
|
||||
/// The single layer is fully transparent, so terrain stamped via [`crate_at`]
|
||||
/// etc. always lands on the board's top layer. Use [`add_floor`] to slip a
|
||||
/// visible floor layer underneath.
|
||||
pub(crate) fn open_board(
|
||||
w: usize,
|
||||
h: usize,
|
||||
@@ -368,10 +413,13 @@ pub(crate) mod tests {
|
||||
name: "test".into(),
|
||||
width: w,
|
||||
height: h,
|
||||
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
|
||||
floor: vec![Archetype::Empty.default_glyph(); w * h],
|
||||
floor_spec: None,
|
||||
player: Player { x: player.0, y: player.1 },
|
||||
layers: vec![Layer {
|
||||
cells: vec![(Glyph::transparent(), Archetype::Empty); w * h],
|
||||
}],
|
||||
player: Player {
|
||||
x: player.0,
|
||||
y: player.1,
|
||||
},
|
||||
objects: object_map,
|
||||
next_object_id,
|
||||
portals: Vec::new(),
|
||||
@@ -381,25 +429,48 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamps a crate cell onto a board.
|
||||
/// Inserts a visible floor layer (filled with `glyph`) below everything,
|
||||
/// bumping existing terrain and objects up one layer.
|
||||
pub(crate) fn add_floor(board: &mut Board, glyph: Glyph) {
|
||||
let count = board.width * board.height;
|
||||
board.layers.insert(
|
||||
0,
|
||||
Layer {
|
||||
cells: vec![(glyph, Archetype::Empty); count],
|
||||
},
|
||||
);
|
||||
for o in board.objects.values_mut() {
|
||||
o.z += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of the board's top (terrain) layer, where stamps are written.
|
||||
fn top(board: &Board) -> usize {
|
||||
board.layers.len() - 1
|
||||
}
|
||||
|
||||
/// Stamps a crate cell onto the board's top layer.
|
||||
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
|
||||
*board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
|
||||
let z = top(board);
|
||||
*board.get_mut(z, x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
|
||||
}
|
||||
|
||||
/// Stamps a wall cell onto a board.
|
||||
/// Stamps a wall cell onto the board's top layer.
|
||||
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
|
||||
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
||||
let z = top(board);
|
||||
*board.get_mut(z, x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
||||
}
|
||||
|
||||
/// Stamps an arbitrary archetype cell onto a board.
|
||||
/// Stamps an arbitrary archetype cell onto the board's top layer.
|
||||
pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
|
||||
*board.get_mut(x, y) = (arch.default_glyph(), arch);
|
||||
let z = top(board);
|
||||
*board.get_mut(z, x, y) = (arch.default_glyph(), arch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_at_reports_wall_object_and_empty() {
|
||||
let mut board = open_board(4, 1, (3, 0), vec![]);
|
||||
board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
||||
wall_at(&mut board, 1, 0);
|
||||
board.add_object(ObjectDef::new(2, 0));
|
||||
|
||||
assert!(board.solid_at(0, 0).is_none());
|
||||
@@ -412,7 +483,9 @@ pub(crate) mod tests {
|
||||
assert!(!board.is_passable(1, 0));
|
||||
|
||||
match board.solid_at(2, 0) {
|
||||
Some(Solid::Object(id)) => assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0)),
|
||||
Some(Solid::Object(id)) => {
|
||||
assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0))
|
||||
}
|
||||
_ => panic!("expected Solid::Object"),
|
||||
}
|
||||
assert!(!board.is_passable(2, 0));
|
||||
@@ -453,8 +526,8 @@ pub(crate) mod tests {
|
||||
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);
|
||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // no mutation
|
||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Empty);
|
||||
|
||||
let mut board = open_board(3, 1, (0, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
@@ -469,8 +542,8 @@ pub(crate) mod tests {
|
||||
crate_at(&mut board, 1, 0);
|
||||
assert!(board.can_push(1, 0, Direction::East));
|
||||
board.push(1, 0, Direction::East);
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
|
||||
assert_eq!((board.player.x, board.player.y), (3, 0));
|
||||
}
|
||||
|
||||
@@ -482,7 +555,7 @@ pub(crate) mod tests {
|
||||
wall_at(&mut board, 3, 0);
|
||||
assert!(!board.can_push(1, 0, Direction::East));
|
||||
board.push(1, 0, Direction::East); // no-op
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Crate);
|
||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
|
||||
assert_eq!((board.player.x, board.player.y), (2, 0));
|
||||
}
|
||||
|
||||
@@ -492,15 +565,47 @@ pub(crate) mod tests {
|
||||
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 },
|
||||
bg: Rgba8 { r: 1, g: 2, b: 3, a: 255 },
|
||||
fg: Rgba8 {
|
||||
r: 10,
|
||||
g: 20,
|
||||
b: 30,
|
||||
a: 255,
|
||||
},
|
||||
bg: Rgba8 {
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3,
|
||||
a: 255,
|
||||
},
|
||||
};
|
||||
board.floor = vec![floor_glyph; 3];
|
||||
// Floor on a lower layer, a wall on the top (terrain) layer at (0,0).
|
||||
add_floor(&mut board, floor_glyph);
|
||||
wall_at(&mut board, 0, 0);
|
||||
// The wall (solid) draws over the floor; the empty cell reveals the floor.
|
||||
assert_eq!(board.glyph_at(0, 0), Archetype::Wall.default_glyph());
|
||||
assert_eq!(board.glyph_at(1, 0), floor_glyph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_at_draws_higher_layer_over_lower() {
|
||||
// A non-solid object on an upper layer renders above a wall on a lower one.
|
||||
let mut board = open_board(2, 1, (1, 0), vec![]);
|
||||
wall_at(&mut board, 0, 0); // wall on layer 0
|
||||
// Add an upper layer holding a visible, non-solid object at (0,0).
|
||||
board.layers.push(Layer {
|
||||
cells: vec![(Glyph::transparent(), Archetype::Empty); 2],
|
||||
});
|
||||
let mut obj = ObjectDef::new(0, 0);
|
||||
obj.z = 1;
|
||||
obj.solid = false;
|
||||
obj.glyph = Glyph {
|
||||
tile: '*' as u32,
|
||||
..Glyph::transparent()
|
||||
};
|
||||
board.add_object(obj);
|
||||
assert_eq!(board.glyph_at(0, 0).tile, '*' as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_board_is_valid_and_reports_errors() {
|
||||
let mut board = open_board(1, 1, (0, 0), vec![]);
|
||||
@@ -509,4 +614,4 @@ pub(crate) mod tests {
|
||||
assert!(!board.is_valid());
|
||||
assert_eq!(board.load_errors.len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+38
-266
@@ -1,29 +1,24 @@
|
||||
//! The floor layer: a per-cell visual background drawn wherever a board cell
|
||||
//! would otherwise render as [`Archetype::Empty`](crate::archetype::Archetype::Empty).
|
||||
//! Procedural floor generators.
|
||||
//!
|
||||
//! The floor is purely cosmetic — it carries no behavior. It exists to give
|
||||
//! boards some non-distracting visual flavor (textured ground) with little
|
||||
//! A "floor" is a purely cosmetic, non-solid visual placed on a board layer (a
|
||||
//! palette entry with `kind = "floor"`). It carries no behavior — it exists to
|
||||
//! give boards some non-distracting visual flavor (textured ground) with little
|
||||
//! authoring effort, including randomly-generated "grass" / "dirt" / "stone".
|
||||
//!
|
||||
//! A board's floor is computed once at load time ([`build_floor`]) and cached as
|
||||
//! a `Vec<Glyph>` on the [`Board`](crate::board::Board); generators are run during
|
||||
//! that single pass and their results stored, so there is no per-frame cost and
|
||||
//! no tile flicker. The raw [`FloorSpec`] is also kept on the board so a save
|
||||
//! round-trips the declaration (generators re-randomize on reload; literal glyph
|
||||
//! grids are preserved exactly).
|
||||
//! The actual placement / per-cell expansion happens in [`crate::layer`] during
|
||||
//! map load: a floor palette entry either names one of these generators (a fresh
|
||||
//! glyph is rolled per grid cell) or gives a fixed glyph. This module only owns
|
||||
//! the generators themselves.
|
||||
|
||||
use crate::archetype::Archetype;
|
||||
use crate::log::LogLine;
|
||||
use crate::map_file::{parse_color, TileIndex};
|
||||
use color::Rgba8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tinyrand::{Probability, Rand, Seeded, StdRand};
|
||||
use crate::glyph::Glyph;
|
||||
use color::Rgba8;
|
||||
use tinyrand::{Probability, Rand, StdRand};
|
||||
|
||||
/// Fixed seed for the floor PRNG, so a board's generated floor is deterministic
|
||||
/// for a given spec (stable across reloads within a run, and testable).
|
||||
const FLOOR_SEED: u64 = 0x_C0FF_EE15_F100_0001;
|
||||
/// for a given map (stable across reloads within a run, and testable). The layer
|
||||
/// builder seeds one [`StdRand`] with this and threads it through every
|
||||
/// generator call so the result depends only on the map content.
|
||||
pub(crate) const FLOOR_SEED: u64 = 0x_C0FF_EE15_F100_0001;
|
||||
|
||||
/// A procedural floor texture. Each generator differs only in its color scheme
|
||||
/// and the probability/character set of its scattered "texture" glyphs; the
|
||||
@@ -56,7 +51,7 @@ impl FloorGenerator {
|
||||
/// Picks a background ground color within the generator's scheme, then with
|
||||
/// the generator's probability scatters a lighter "texture" char on top
|
||||
/// (otherwise a blank space, whose fg is irrelevant).
|
||||
fn generate(&self, rng: &mut StdRand) -> Glyph {
|
||||
pub(crate) fn generate(&self, rng: &mut StdRand) -> Glyph {
|
||||
// (background color ranges, texture probability, texture chars).
|
||||
let (bg, chars, prob) = match self {
|
||||
// Green → greenish-yellow: g dominant, a touch of r for the yellow tilt.
|
||||
@@ -131,221 +126,41 @@ fn lighten(c: Rgba8, amt: u8) -> Rgba8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed floor glyph declared in a map file: visual only, no archetype.
|
||||
///
|
||||
/// Reuses [`TileIndex`] (so `tile` accepts a char or an int) and `"#RRGGBB"`
|
||||
/// color strings, exactly like a `[palette]` entry minus the archetype.
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct FloorGlyph {
|
||||
/// Tile index into the board's font. Accepts an integer or single-char string.
|
||||
pub tile: TileIndex,
|
||||
/// Foreground color as an `"#RRGGBB"` hex string.
|
||||
pub fg: String,
|
||||
/// Background color as an `"#RRGGBB"` hex string.
|
||||
pub bg: String,
|
||||
}
|
||||
|
||||
impl FloorGlyph {
|
||||
/// Converts this declaration into a concrete [`Glyph`].
|
||||
fn to_glyph(&self) -> Glyph {
|
||||
Glyph {
|
||||
tile: self.tile.into_u32(),
|
||||
fg: parse_color(&self.fg),
|
||||
bg: parse_color(&self.bg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in a floor grid's palette: either a generator name or a fixed glyph.
|
||||
///
|
||||
/// `#[serde(untagged)]`: a TOML string parses as [`FloorTile::Generator`]; a TOML
|
||||
/// table parses as [`FloorTile::Glyph`].
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum FloorTile {
|
||||
/// A generator name, e.g. `"grass"`.
|
||||
Generator(String),
|
||||
/// A fixed glyph definition.
|
||||
Glyph(FloorGlyph),
|
||||
}
|
||||
|
||||
/// The optional `[floor]` declaration in a map file, in one of four shapes.
|
||||
///
|
||||
/// `#[serde(untagged)]`, so the variants are distinguished by TOML shape — order
|
||||
/// matters: a string hits [`FloorSpec::Generator`]; a table with a `tile` key hits
|
||||
/// [`FloorSpec::Single`]; a table with a `content` key hits [`FloorSpec::Grid`].
|
||||
/// (Absence of a `[floor]` section is represented by `Option::None` on the map
|
||||
/// file, not a variant here, and yields a default black-on-black floor.)
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum FloorSpec {
|
||||
/// A single generator name covering the whole board, e.g. `floor = "grass"`.
|
||||
Generator(String),
|
||||
/// A single fixed glyph filling every floor cell.
|
||||
Single(FloorGlyph),
|
||||
/// A grid (like `[grid]`) with its own palette of generators and/or glyphs.
|
||||
Grid {
|
||||
/// Multi-line grid string; one char per cell, looked up in `palette`.
|
||||
content: String,
|
||||
/// Char → [`FloorTile`] lookup for the grid.
|
||||
palette: HashMap<String, FloorTile>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The canonical "empty" floor glyph: a black-on-black space. This is the default
|
||||
/// floor (when a map declares no `[floor]`) and the fallback for any cell that
|
||||
/// can't be resolved.
|
||||
fn empty_glyph() -> Glyph {
|
||||
Archetype::Empty.default_glyph()
|
||||
}
|
||||
|
||||
/// Builds the cached per-cell floor `Vec<Glyph>` (row-major, `width * height`)
|
||||
/// from an optional [`FloorSpec`].
|
||||
///
|
||||
/// Best-effort and nonfatal: any problem (unknown generator/char, grid-dimension
|
||||
/// mismatch) is recorded on `errors` and that cell falls back to [`empty_glyph`],
|
||||
/// so the board still loads. With no spec, the floor is all black-on-black space
|
||||
/// (the previous behavior, where `Empty` cells rendered as nothing).
|
||||
pub fn build_floor(
|
||||
spec: &Option<FloorSpec>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
errors: &mut Vec<LogLine>,
|
||||
) -> Vec<Glyph> {
|
||||
let mut rng = StdRand::seed(FLOOR_SEED);
|
||||
let count = width * height;
|
||||
match spec {
|
||||
// No declaration: the historical default of black-on-black whitespace.
|
||||
None => vec![empty_glyph(); count],
|
||||
// One generator for the whole board: run it once per cell.
|
||||
Some(FloorSpec::Generator(name)) => match FloorGenerator::from_name(name) {
|
||||
Some(generator) => (0..count).map(|_| generator.generate(&mut rng)).collect(),
|
||||
None => {
|
||||
errors.push(LogLine::error(format!("unknown floor generator '{name}'")));
|
||||
vec![empty_glyph(); count]
|
||||
}
|
||||
},
|
||||
// One fixed glyph: parse once, fill every cell.
|
||||
Some(FloorSpec::Single(glyph)) => vec![glyph.to_glyph(); count],
|
||||
// A grid with its own palette of generators and/or fixed glyphs.
|
||||
Some(FloorSpec::Grid { content, palette }) => {
|
||||
build_grid_floor(content, palette, width, height, &mut rng, errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the floor for a [`FloorSpec::Grid`]: each grid character is looked up in
|
||||
/// `palette` and resolved to a glyph (generators run per-cell). Dimension
|
||||
/// mismatches and unknown chars/generators are reported once and fall back to the
|
||||
/// empty glyph.
|
||||
fn build_grid_floor(
|
||||
content: &str,
|
||||
palette: &HashMap<String, FloorTile>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
rng: &mut StdRand,
|
||||
errors: &mut Vec<LogLine>,
|
||||
) -> Vec<Glyph> {
|
||||
// Key the palette by char for O(1) per-cell lookup.
|
||||
let by_char: HashMap<char, &FloorTile> = palette
|
||||
.iter()
|
||||
.filter_map(|(k, v)| k.chars().next().map(|c| (c, v)))
|
||||
.collect();
|
||||
|
||||
let rows: Vec<&str> = content.lines().collect();
|
||||
if rows.len() != height {
|
||||
errors.push(LogLine::error(format!(
|
||||
"floor grid has {} rows but the board is {height} tall; missing cells use empty floor",
|
||||
rows.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Report each unknown char / bad generator only once, not per occurrence.
|
||||
let mut reported: HashSet<char> = HashSet::new();
|
||||
let mut floor = Vec::with_capacity(width * height);
|
||||
for y in 0..height {
|
||||
// `chars().nth(x)` is O(x) but floors are small; this keeps the lookup
|
||||
// correct for multi-byte chars (a byte index would not).
|
||||
let row: Vec<char> = rows.get(y).map(|r| r.chars().collect()).unwrap_or_default();
|
||||
for x in 0..width {
|
||||
let glyph = match row.get(x) {
|
||||
Some(&ch) => match by_char.get(&ch) {
|
||||
Some(FloorTile::Glyph(g)) => g.to_glyph(),
|
||||
Some(FloorTile::Generator(name)) => match FloorGenerator::from_name(name) {
|
||||
Some(generator) => generator.generate(rng),
|
||||
None => {
|
||||
if reported.insert(ch) {
|
||||
errors.push(LogLine::error(format!(
|
||||
"floor palette '{ch}' names unknown generator '{name}'; using empty floor"
|
||||
)));
|
||||
}
|
||||
empty_glyph()
|
||||
}
|
||||
},
|
||||
None => {
|
||||
if reported.insert(ch) {
|
||||
errors.push(LogLine::error(format!(
|
||||
"unknown floor grid character '{ch}'; using empty floor"
|
||||
)));
|
||||
}
|
||||
empty_glyph()
|
||||
}
|
||||
},
|
||||
// Row missing or too short: padded with empty floor (already reported above).
|
||||
None => empty_glyph(),
|
||||
};
|
||||
floor.push(glyph);
|
||||
}
|
||||
}
|
||||
floor
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tinyrand::Seeded;
|
||||
|
||||
/// Parses a `FloorSpec` from a standalone TOML snippet of the form
|
||||
/// `floor = ...` / `[floor]\n...`, returning the parsed spec.
|
||||
fn parse_spec(toml: &str) -> FloorSpec {
|
||||
#[derive(Deserialize)]
|
||||
struct Wrap {
|
||||
floor: FloorSpec,
|
||||
}
|
||||
toml::from_str::<Wrap>(toml)
|
||||
.expect("parse floor spec")
|
||||
.floor
|
||||
/// Rolls `count` glyphs from `generator` against a freshly-seeded RNG, the
|
||||
/// same way the layer builder does.
|
||||
fn roll(generator: FloorGenerator, count: usize) -> Vec<Glyph> {
|
||||
let mut rng = StdRand::seed(FLOOR_SEED);
|
||||
(0..count).map(|_| generator.generate(&mut rng)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_spec_yields_all_empty_floor() {
|
||||
let mut errs = Vec::new();
|
||||
let floor = build_floor(&None, 3, 2, &mut errs);
|
||||
assert_eq!(floor.len(), 6);
|
||||
assert!(floor.iter().all(|g| *g == empty_glyph()));
|
||||
assert!(errs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_glyph_fills_every_cell() {
|
||||
let spec = parse_spec("[floor]\ntile = \".\"\nfg = \"#112233\"\nbg = \"#445566\"\n");
|
||||
let mut errs = Vec::new();
|
||||
let floor = build_floor(&Some(spec), 2, 2, &mut errs);
|
||||
let expected = Glyph {
|
||||
tile: '.' as u32,
|
||||
fg: parse_color("#112233"),
|
||||
bg: parse_color("#445566"),
|
||||
};
|
||||
assert_eq!(floor.len(), 4);
|
||||
assert!(floor.iter().all(|g| *g == expected));
|
||||
fn from_name_parses_known_generators() {
|
||||
assert_eq!(
|
||||
FloorGenerator::from_name("grass"),
|
||||
Some(FloorGenerator::Grass)
|
||||
);
|
||||
assert_eq!(
|
||||
FloorGenerator::from_name("dirt"),
|
||||
Some(FloorGenerator::Dirt)
|
||||
);
|
||||
assert_eq!(
|
||||
FloorGenerator::from_name("stone"),
|
||||
Some(FloorGenerator::Stone)
|
||||
);
|
||||
assert_eq!(FloorGenerator::from_name("lava"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grass_generator_stays_in_scheme_and_is_deterministic() {
|
||||
let mut errs = Vec::new();
|
||||
let a = build_floor(&Some(FloorSpec::Generator("grass".into())), 8, 8, &mut errs);
|
||||
let b = build_floor(&Some(FloorSpec::Generator("grass".into())), 8, 8, &mut errs);
|
||||
let a = roll(FloorGenerator::Grass, 64);
|
||||
let b = roll(FloorGenerator::Grass, 64);
|
||||
assert_eq!(a.len(), 64);
|
||||
// Same seed → identical floors across builds.
|
||||
// Same seed → identical rolls across builds.
|
||||
assert!(a.iter().zip(&b).all(|(x, y)| x == y));
|
||||
// Every cell's ground (bg) sits inside grass's channel ranges.
|
||||
for g in &a {
|
||||
@@ -353,48 +168,5 @@ mod tests {
|
||||
assert!((45..=85).contains(&g.bg.g), "g out of range: {}", g.bg.g);
|
||||
assert!((20..=35).contains(&g.bg.b), "b out of range: {}", g.bg.b);
|
||||
}
|
||||
assert!(errs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_generator_falls_back_and_reports() {
|
||||
let mut errs = Vec::new();
|
||||
let floor = build_floor(&Some(FloorSpec::Generator("lava".into())), 2, 1, &mut errs);
|
||||
assert!(floor.iter().all(|g| *g == empty_glyph()));
|
||||
assert_eq!(errs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_resolves_glyph_and_generator_entries() {
|
||||
let spec = parse_spec(
|
||||
"[floor]\ncontent = \"\"\"\ng.\n.g\n\"\"\"\n[floor.palette]\n\"g\" = \"grass\"\n\".\" = { tile = \"#\", fg = \"#010203\", bg = \"#040506\" }\n",
|
||||
);
|
||||
let mut errs = Vec::new();
|
||||
let floor = build_floor(&Some(spec), 2, 2, &mut errs);
|
||||
assert!(errs.is_empty());
|
||||
let fixed = Glyph {
|
||||
tile: '#' as u32,
|
||||
fg: parse_color("#010203"),
|
||||
bg: parse_color("#040506"),
|
||||
};
|
||||
// (0,0) and (1,1) are grass; (1,0) and (0,1) are the fixed glyph.
|
||||
assert_eq!(floor[1], fixed);
|
||||
assert_eq!(floor[2], fixed);
|
||||
assert!((20..=55).contains(&floor[0].bg.r)); // grass ground
|
||||
assert!((20..=55).contains(&floor[3].bg.r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_dimension_mismatch_is_nonfatal() {
|
||||
// Declares 1 row for a 2-tall board: missing row is padded with empty floor.
|
||||
let spec = parse_spec(
|
||||
"[floor]\ncontent = \"\"\"\ngg\n\"\"\"\n[floor.palette]\n\"g\" = \"grass\"\n",
|
||||
);
|
||||
let mut errs = Vec::new();
|
||||
let floor = build_floor(&Some(spec), 2, 2, &mut errs);
|
||||
assert_eq!(floor.len(), 4);
|
||||
assert_eq!(errs.len(), 1); // the row-count mismatch was reported
|
||||
assert_eq!(floor[2], empty_glyph()); // padded second row
|
||||
assert_eq!(floor[3], empty_glyph());
|
||||
}
|
||||
}
|
||||
|
||||
+56
-33
@@ -1,12 +1,11 @@
|
||||
use crate::action::Action;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::log::LogLine;
|
||||
use crate::script::ScriptHost;
|
||||
use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid};
|
||||
use crate::world::World;
|
||||
use std::cell::{Ref, RefMut};
|
||||
use std::time::Duration;
|
||||
use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid};
|
||||
|
||||
/// How long a `say()` speech bubble stays on screen, in seconds.
|
||||
pub const SAY_DURATION: f64 = 3.0;
|
||||
@@ -85,7 +84,10 @@ impl GameState {
|
||||
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
|
||||
.boards
|
||||
.get(&name)
|
||||
.expect("world::load guarantees start board exists"),
|
||||
&world.scripts,
|
||||
);
|
||||
let mut state = Self {
|
||||
@@ -177,7 +179,10 @@ impl GameState {
|
||||
self.scripts.run_tick(secs);
|
||||
// Expire speech bubbles before resolving new actions so a fresh say()
|
||||
// this frame isn't immediately culled.
|
||||
self.speech_bubbles.retain_mut(|b| { b.remaining -= secs; b.remaining > 0.0 });
|
||||
self.speech_bubbles.retain_mut(|b| {
|
||||
b.remaining -= secs;
|
||||
b.remaining > 0.0
|
||||
});
|
||||
self.tick_pushers(secs);
|
||||
self.resolve();
|
||||
}
|
||||
@@ -193,18 +198,7 @@ impl GameState {
|
||||
return;
|
||||
}
|
||||
self.pusher_timer += PUSHER_INTERVAL;
|
||||
let pushers: Vec<(usize, usize)> = {
|
||||
let board = self.board();
|
||||
let mut v = Vec::new();
|
||||
for y in 0..board.height {
|
||||
for x in 0..board.width {
|
||||
if matches!(board.get(x, y).1, Archetype::Pusher(_)) {
|
||||
v.push((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
let pushers: Vec<(usize, usize)> = self.board().pusher_cells();
|
||||
let mut board = self.board_mut();
|
||||
for (x, y) in pushers {
|
||||
board.advance_pusher(x, y);
|
||||
@@ -251,9 +245,17 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
Action::Log(line) => logs.push(line),
|
||||
Action::SetTag { target, tag, present } => {
|
||||
Action::SetTag {
|
||||
target,
|
||||
tag,
|
||||
present,
|
||||
} => {
|
||||
if let Some(obj) = board.objects.get_mut(&target) {
|
||||
if present { obj.tags.insert(tag); } else { obj.tags.remove(&tag); }
|
||||
if present {
|
||||
obj.tags.insert(tag);
|
||||
} else {
|
||||
obj.tags.remove(&tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Replace any existing bubble from this object so repeated say() calls
|
||||
@@ -267,26 +269,38 @@ impl GameState {
|
||||
Action::Delay(_) => {}
|
||||
Action::SetColor { fg, bg } => {
|
||||
if let Some(obj) = board.objects.get_mut(&ba.source) {
|
||||
if let Some(c) = fg { obj.glyph.fg = c; }
|
||||
if let Some(c) = bg { obj.glyph.bg = c; }
|
||||
if let Some(c) = fg {
|
||||
obj.glyph.fg = c;
|
||||
}
|
||||
if let Some(c) = bg {
|
||||
obj.glyph.bg = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Collected and fired after the board borrow drops, like bumps.
|
||||
Action::Send { target, fn_name, arg } => {
|
||||
Action::Send {
|
||||
target,
|
||||
fn_name,
|
||||
arg,
|
||||
} => {
|
||||
sends.push((target, fn_name, arg));
|
||||
}
|
||||
// Later scrolls overwrite earlier ones from the same tick.
|
||||
Action::Scroll(lines) => {
|
||||
new_scroll = Some(Scroll { source: ba.source, lines, choice: None });
|
||||
new_scroll = Some(Scroll {
|
||||
source: ba.source,
|
||||
lines,
|
||||
choice: None,
|
||||
});
|
||||
}
|
||||
Action::Teleport { x, y } => {
|
||||
if !board.in_bounds((x, y)) {
|
||||
logs.push(LogLine::error(format!(
|
||||
"teleport({x},{y}): out of bounds"
|
||||
)));
|
||||
logs.push(LogLine::error(format!("teleport({x},{y}): out of bounds")));
|
||||
} else {
|
||||
let (ux, uy) = (x as usize, y as usize);
|
||||
let source_solid = board.objects.get(&ba.source)
|
||||
let source_solid = board
|
||||
.objects
|
||||
.get(&ba.source)
|
||||
.map(|o| o.solid)
|
||||
.unwrap_or(false);
|
||||
if source_solid && board.solid_at(ux, uy).is_some() {
|
||||
@@ -305,7 +319,8 @@ impl GameState {
|
||||
self.log.extend(logs);
|
||||
for bubble in new_bubbles {
|
||||
// One bubble per object: replace the existing one if present.
|
||||
self.speech_bubbles.retain(|b| b.object_id != bubble.object_id);
|
||||
self.speech_bubbles
|
||||
.retain(|b| b.object_id != bubble.object_id);
|
||||
self.speech_bubbles.push(bubble);
|
||||
}
|
||||
if let Some(scroll) = new_scroll {
|
||||
@@ -344,12 +359,16 @@ impl GameState {
|
||||
/// to play a visual effect during the switch.
|
||||
pub fn enter_board(&mut self, target_map: &str, target_entry: &str) {
|
||||
if !self.world.boards.contains_key(target_map) {
|
||||
self.log.push(LogLine::error(format!("portal target board {target_map:?} not found")));
|
||||
self.log.push(LogLine::error(format!(
|
||||
"portal target board {target_map:?} not found"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
// Find the named arrival portal on the target board (borrow then release).
|
||||
let arrival = self.world.boards[target_map].borrow()
|
||||
.portals.iter()
|
||||
let arrival = self.world.boards[target_map]
|
||||
.borrow()
|
||||
.portals
|
||||
.iter()
|
||||
.find(|p| p.name == target_entry)
|
||||
.map(|p| (p.x, p.y));
|
||||
let (ax, ay) = match arrival {
|
||||
@@ -366,7 +385,10 @@ impl GameState {
|
||||
self.active_scroll = None;
|
||||
// Switch to the new board and place the player at the arrival portal.
|
||||
self.current_board_name = target_map.to_string();
|
||||
self.board_mut().player = Player { x: ax as i32, y: ay as i32 };
|
||||
self.board_mut().player = Player {
|
||||
x: ax as i32,
|
||||
y: ay as i32,
|
||||
};
|
||||
// Rebuild the script host for the new board's objects.
|
||||
self.scripts = ScriptHost::new(
|
||||
&self.world.boards[&self.current_board_name],
|
||||
@@ -405,7 +427,9 @@ impl GameState {
|
||||
board.player.x = nx as i32;
|
||||
board.player.y = ny as i32;
|
||||
// Check for a portal at the new position; clone strings to release the borrow.
|
||||
portal_target = board.portals.iter()
|
||||
portal_target = board
|
||||
.portals
|
||||
.iter()
|
||||
.find(|p| p.x == nx && p.y == ny)
|
||||
.map(|p| (p.target_map.clone(), p.target_entry.clone()));
|
||||
} else {
|
||||
@@ -453,4 +477,3 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
|
||||
}
|
||||
bumped
|
||||
}
|
||||
|
||||
|
||||
+16
-2
@@ -1,5 +1,5 @@
|
||||
use std::hash::{Hash, Hasher};
|
||||
use color::Rgba8;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
/// The visual representation of a single board cell.
|
||||
///
|
||||
@@ -46,4 +46,18 @@ impl Glyph {
|
||||
bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fully transparent glyph: tile `0` (the see-through sentinel) on black.
|
||||
///
|
||||
/// A layer cell holding this glyph contributes nothing to drawing, so a lower
|
||||
/// layer shows through. It is what an `empty` palette entry resolves to, and
|
||||
/// what is left behind when a solid is pushed/moved off a cell.
|
||||
#[rustfmt::skip]
|
||||
pub const fn transparent() -> Self {
|
||||
Self {
|
||||
tile: 0,
|
||||
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
||||
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Palette layers: the unit of the map-file format and the board's draw stack.
|
||||
//!
|
||||
//! A board is an ordered list of **layers**, drawn bottom (index 0) to top. Each
|
||||
//! layer is a character grid plus a palette mapping each character to one *kind*
|
||||
//! of thing — an archetype (terrain), a floor glyph, a scripted object, a portal,
|
||||
//! or the player. "Put two things on one cell" simply means "use two layers".
|
||||
//!
|
||||
//! This module owns the per-layer serde types ([`LayerData`], [`PaletteEntry`]),
|
||||
//! the runtime [`Layer`] (a grid of `(Glyph, Archetype)` cells, where a cell with
|
||||
//! a transparent glyph lets lower layers show through), and the load-time
|
||||
//! conversion ([`build_layer`]) that turns one `LayerData` into a `Layer` plus a
|
||||
//! list of [`Placement`]s (objects/portals/player) for the map loader to resolve
|
||||
//! across layers. Cross-layer validation (one solid per cell, unique names, the
|
||||
//! player winning its cell) lives in [`crate::map_file`].
|
||||
|
||||
use crate::archetype::Archetype;
|
||||
use crate::floor::FloorGenerator;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::log::LogLine;
|
||||
use crate::map_file::{TileIndex, parse_color};
|
||||
use crate::object_def::ObjectDef;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tinyrand::StdRand;
|
||||
|
||||
/// A single draw layer: a row-major grid of `(Glyph, Archetype)` cells.
|
||||
///
|
||||
/// A cell whose `glyph.tile == 0` (see [`Glyph::transparent`]) is transparent —
|
||||
/// it draws nothing and lets the layer beneath show through. Solidity comes from
|
||||
/// the archetype and is independent of transparency. The grid is `width * height`
|
||||
/// long; index it via the owning [`Board`](crate::board::Board)'s dimensions.
|
||||
pub(crate) struct Layer {
|
||||
/// Row-major `(Glyph, Archetype)` cells for this layer.
|
||||
pub(crate) cells: Vec<(Glyph, Archetype)>,
|
||||
}
|
||||
|
||||
/// Serde representation of one `[[layers]]` entry: a grid string and its palette.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(crate) struct LayerData {
|
||||
/// Multi-line grid string; one char per cell, looked up in `palette`.
|
||||
pub content: String,
|
||||
/// Char (as a one-character string key) → palette entry.
|
||||
#[serde(default)]
|
||||
pub palette: HashMap<String, PaletteEntry>,
|
||||
}
|
||||
|
||||
/// One palette entry, discriminated by [`kind`](PaletteEntry::kind).
|
||||
///
|
||||
/// A single flat struct (rather than an enum) because `kind` is open-ended: it is
|
||||
/// any archetype name (`"wall"`, `"crate"`, `"pusher_east"`, …) *or* one of the
|
||||
/// meta-kinds `empty`, `floor`, `object`, `portal`, `player`. Only the fields
|
||||
/// relevant to a given kind are read; the rest stay `None`. See [`resolve_entry`].
|
||||
#[derive(Deserialize, Serialize, Default, Clone)]
|
||||
pub(crate) struct PaletteEntry {
|
||||
/// What this entry is: an archetype name, or `empty`/`floor`/`object`/`portal`/`player`.
|
||||
pub kind: String,
|
||||
/// Tile index (int or single-char string). Used by archetype/floor/object kinds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tile: Option<TileIndex>,
|
||||
/// Foreground `"#RRGGBB"`. Used by archetype/floor/object kinds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fg: Option<String>,
|
||||
/// Background `"#RRGGBB"`. Used by archetype/floor/object kinds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bg: Option<String>,
|
||||
/// Floor generator name (`"grass"`/`"dirt"`/`"stone"`). Only for `kind = "floor"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generator: Option<String>,
|
||||
/// Object solidity (defaults `true`). Only for `kind = "object"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub solid: Option<bool>,
|
||||
/// Object opacity (defaults `true`). Only for `kind = "object"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opaque: Option<bool>,
|
||||
/// Object pushability (defaults `false`). Only for `kind = "object"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pushable: Option<bool>,
|
||||
/// Rhai script name. Only for `kind = "object"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub script_name: Option<String>,
|
||||
/// Open-ended labels. Only for `kind = "object"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// Unique name. For `kind = "object"` (optional) or `kind = "portal"` (required).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Target board key. Required for `kind = "portal"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_map: Option<String>,
|
||||
/// Target portal name on the destination board. Required for `kind = "portal"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_entry: Option<String>,
|
||||
}
|
||||
|
||||
/// A non-terrain thing a layer places at a grid cell, resolved by the map loader.
|
||||
///
|
||||
/// Terrain/floor go straight into [`Layer::cells`]; these need cross-layer
|
||||
/// handling (ids, name uniqueness, the single player), so [`build_layer`] returns
|
||||
/// them separately with their `(x, y)` for [`crate::map_file`] to finish.
|
||||
pub(crate) enum Placement {
|
||||
/// A scripted object to spawn at `(x, y)`.
|
||||
Object(ObjectTemplate, usize, usize),
|
||||
/// A portal at `(x, y)`.
|
||||
Portal(PortalTemplate, usize, usize),
|
||||
/// The player's start cell `(x, y)`.
|
||||
Player(usize, usize),
|
||||
}
|
||||
|
||||
/// A resolved object definition minus board-assigned fields (`id`, `z`).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ObjectTemplate {
|
||||
pub glyph: Glyph,
|
||||
pub solid: bool,
|
||||
pub opaque: bool,
|
||||
pub pushable: bool,
|
||||
pub script_name: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// A resolved portal definition minus `(x, y, z)`.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PortalTemplate {
|
||||
pub name: String,
|
||||
pub target_map: String,
|
||||
pub target_entry: String,
|
||||
}
|
||||
|
||||
/// What a palette character resolves to during the grid walk.
|
||||
#[derive(Clone)]
|
||||
enum Resolved {
|
||||
/// A fixed cell written straight into the layer (terrain, fixed floor, empty, error block).
|
||||
Cell(Glyph, Archetype),
|
||||
/// A procedural floor: roll a fresh glyph per occurrence.
|
||||
FloorGen(FloorGenerator),
|
||||
/// A scripted object placed per occurrence.
|
||||
Object(ObjectTemplate),
|
||||
/// A portal placed per occurrence.
|
||||
Portal(PortalTemplate),
|
||||
/// The player's start cell.
|
||||
Player,
|
||||
}
|
||||
|
||||
/// Resolves one palette entry to a [`Resolved`], recording any nonfatal problem.
|
||||
///
|
||||
/// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `floor`
|
||||
/// with an unknown generator or a `portal` missing required fields falls back to a
|
||||
/// transparent cell (the grid char is still consumed). Object/floor/archetype
|
||||
/// glyphs fall back to the relevant default for any absent visual field.
|
||||
fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resolved {
|
||||
// Builds a glyph from the entry's tile/fg/bg, each falling back to `default`.
|
||||
let glyph_with_default = |default: Glyph| Glyph {
|
||||
tile: e.tile.map(TileIndex::into_u32).unwrap_or(default.tile),
|
||||
fg: e.fg.as_deref().map(parse_color).unwrap_or(default.fg),
|
||||
bg: e.bg.as_deref().map(parse_color).unwrap_or(default.bg),
|
||||
};
|
||||
|
||||
match e.kind.as_str() {
|
||||
// Transparent: lower layers show through here.
|
||||
"empty" => Resolved::Cell(Glyph::transparent(), Archetype::Empty),
|
||||
"floor" => match &e.generator {
|
||||
Some(name) => match FloorGenerator::from_name(name) {
|
||||
Some(g) => Resolved::FloorGen(g),
|
||||
None => {
|
||||
errors.push(LogLine::error(format!(
|
||||
"floor palette '{ch}' names unknown generator '{name}'; using empty floor"
|
||||
)));
|
||||
Resolved::Cell(Glyph::transparent(), Archetype::Empty)
|
||||
}
|
||||
},
|
||||
// A fixed floor glyph: a visual-only cell (no archetype behavior).
|
||||
None => Resolved::Cell(
|
||||
glyph_with_default(Glyph {
|
||||
tile: 32,
|
||||
..Glyph::transparent()
|
||||
}),
|
||||
Archetype::Empty,
|
||||
),
|
||||
},
|
||||
"object" => Resolved::Object(ObjectTemplate {
|
||||
glyph: glyph_with_default(ObjectDef::default_glyph()),
|
||||
solid: e.solid.unwrap_or(true),
|
||||
opaque: e.opaque.unwrap_or(true),
|
||||
pushable: e.pushable.unwrap_or(false),
|
||||
script_name: e.script_name.clone(),
|
||||
tags: e.tags.clone().unwrap_or_default(),
|
||||
name: e.name.clone(),
|
||||
}),
|
||||
"portal" => match (e.name.clone(), e.target_map.clone(), e.target_entry.clone()) {
|
||||
(Some(name), Some(target_map), Some(target_entry)) => {
|
||||
Resolved::Portal(PortalTemplate {
|
||||
name,
|
||||
target_map,
|
||||
target_entry,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
errors.push(LogLine::error(format!(
|
||||
"portal palette '{ch}' needs name, target_map and target_entry; skipping"
|
||||
)));
|
||||
Resolved::Cell(Glyph::transparent(), Archetype::Empty)
|
||||
}
|
||||
},
|
||||
"player" => Resolved::Player,
|
||||
// Any other kind must be an archetype name.
|
||||
other => match Archetype::try_from(other) {
|
||||
Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a),
|
||||
Err(msg) => {
|
||||
errors.push(LogLine::error(format!(
|
||||
"palette '{ch}': {msg}; using error block"
|
||||
)));
|
||||
Resolved::Cell(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds one [`Layer`] from its [`LayerData`], plus the non-terrain placements it
|
||||
/// contains (with their `(x, y)`).
|
||||
///
|
||||
/// `rng` is the shared, deterministically-seeded floor PRNG (one per board build),
|
||||
/// threaded through so procedural floors depend only on map content. Returns
|
||||
/// `Err` only on a grid-dimension mismatch (the single hard error, matching the
|
||||
/// pre-layers loader); every other problem is recorded on `errors`.
|
||||
pub(crate) fn build_layer(
|
||||
data: &LayerData,
|
||||
width: usize,
|
||||
height: usize,
|
||||
rng: &mut StdRand,
|
||||
errors: &mut Vec<LogLine>,
|
||||
) -> Result<(Layer, Vec<Placement>), String> {
|
||||
// Resolve each palette entry once (per-cell work like generators happens below).
|
||||
let resolved: HashMap<char, Resolved> = data
|
||||
.palette
|
||||
.iter()
|
||||
.map(|(key, entry)| {
|
||||
let ch = key.chars().next().unwrap_or(' ');
|
||||
(ch, resolve_entry(ch, entry, errors))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Validate dimensions before walking — the only fatal error.
|
||||
let rows: Vec<&str> = data.content.lines().collect();
|
||||
if rows.len() != height {
|
||||
return Err(format!(
|
||||
"layer grid has {} rows but the board is {height} tall",
|
||||
rows.len()
|
||||
));
|
||||
}
|
||||
for (i, line) in rows.iter().enumerate() {
|
||||
let cols = line.chars().count();
|
||||
if cols != width {
|
||||
return Err(format!(
|
||||
"layer grid row {i} has {cols} characters but the board is {width} wide"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the grid, filling cells and collecting placements.
|
||||
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height);
|
||||
let mut placements: Vec<Placement> = Vec::new();
|
||||
for (y, line) in rows.iter().enumerate() {
|
||||
for (x, ch) in line.chars().enumerate() {
|
||||
match resolved.get(&ch) {
|
||||
Some(Resolved::Cell(g, a)) => cells.push((*g, *a)),
|
||||
Some(Resolved::FloorGen(g)) => cells.push((g.generate(rng), Archetype::Empty)),
|
||||
Some(Resolved::Object(t)) => {
|
||||
cells.push((Glyph::transparent(), Archetype::Empty));
|
||||
placements.push(Placement::Object(t.clone(), x, y));
|
||||
}
|
||||
Some(Resolved::Portal(t)) => {
|
||||
cells.push((Glyph::transparent(), Archetype::Empty));
|
||||
placements.push(Placement::Portal(t.clone(), x, y));
|
||||
}
|
||||
Some(Resolved::Player) => {
|
||||
cells.push((Glyph::transparent(), Archetype::Empty));
|
||||
placements.push(Placement::Player(x, y));
|
||||
}
|
||||
None => {
|
||||
errors.push(LogLine::error(format!(
|
||||
"unknown grid character '{ch}' at ({x}, {y}); using error block"
|
||||
)));
|
||||
cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((Layer { cells }, placements))
|
||||
}
|
||||
+10
-9
@@ -1,24 +1,25 @@
|
||||
/// The optional per-cell visual floor layer ([`floor::FloorSpec`], [`floor::build_floor`]).
|
||||
mod action;
|
||||
mod archetype;
|
||||
mod board;
|
||||
/// Procedural floor generators ([`floor::FloorGenerator`]).
|
||||
pub mod floor;
|
||||
/// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc.
|
||||
pub mod game;
|
||||
pub mod glyph;
|
||||
mod layer;
|
||||
/// Styled log messages ([`log::LogLine`]) for the in-game message feed.
|
||||
pub mod log;
|
||||
/// Map file loading and saving (`.toml` format).
|
||||
pub mod map_file;
|
||||
/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]).
|
||||
pub mod world;
|
||||
mod object_def;
|
||||
/// Rhai scripting runtime for board objects ([`script::ScriptHost`]).
|
||||
pub mod script;
|
||||
pub mod glyph;
|
||||
mod action;
|
||||
mod utils;
|
||||
mod archetype;
|
||||
mod object_def;
|
||||
mod board;
|
||||
/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]).
|
||||
pub mod world;
|
||||
|
||||
pub use board::Board;
|
||||
pub use utils::Direction;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests;
|
||||
|
||||
+291
-694
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
use color::Rgba8;
|
||||
use std::collections::HashSet;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::utils::ObjectId;
|
||||
use color::Rgba8;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A scripted object placed on the board, loaded from a map file.
|
||||
///
|
||||
@@ -32,6 +32,10 @@ pub struct ObjectDef {
|
||||
pub x: usize,
|
||||
/// Row of this object on the board (0-indexed).
|
||||
pub y: usize,
|
||||
/// Index of the layer this object belongs to (0 = bottom). Determines its
|
||||
/// draw order: objects on higher layers render above lower-layer terrain and
|
||||
/// objects. Set at load from the layer the object's palette char appeared in.
|
||||
pub z: usize,
|
||||
/// Visual representation of this object. Owned by the object (not derived
|
||||
/// from the grid cell), so scripts can change tile, fg, and bg at runtime.
|
||||
pub glyph: Glyph,
|
||||
@@ -78,6 +82,7 @@ impl ObjectDef {
|
||||
id: 0,
|
||||
x,
|
||||
y,
|
||||
z: 0,
|
||||
glyph: Self::default_glyph(),
|
||||
solid: true,
|
||||
opaque: true,
|
||||
@@ -87,4 +92,4 @@ impl ObjectDef {
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+352
-162
@@ -53,13 +53,16 @@
|
||||
//!
|
||||
//! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()`
|
||||
|
||||
use crate::action::{action_to_map, Action, ScrollLine, MOVE_COST};
|
||||
use crate::game::SAY_DURATION;
|
||||
use crate::action::{Action, MOVE_COST, ScrollLine, action_to_map};
|
||||
use crate::board::Board;
|
||||
use crate::game::SAY_DURATION;
|
||||
use crate::log::LogLine;
|
||||
use crate::map_file::{color_to_hex, parse_color};
|
||||
use crate::utils::{Direction, ObjectId, RegistryValue, ScriptArg};
|
||||
use rhai::{Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module, NativeCallContext, Scope, AST};
|
||||
use rhai::{
|
||||
AST, Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module,
|
||||
NativeCallContext, Scope,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::rc::Rc;
|
||||
@@ -206,20 +209,28 @@ impl ScriptHost {
|
||||
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
|
||||
let mut failed: HashSet<String> = HashSet::new();
|
||||
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; }
|
||||
let Some(name) = obj.script_name.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if scripts.contains_key(name) || failed.contains(name) {
|
||||
continue;
|
||||
}
|
||||
match script_sources.get(name) {
|
||||
Some(src) => match engine.compile(src) {
|
||||
Ok(ast) => {
|
||||
let defines = |n: &str, params: usize| {
|
||||
ast.iter_functions().any(|f| f.name == n && f.params.len() == params)
|
||||
ast.iter_functions()
|
||||
.any(|f| f.name == n && f.params.len() == params)
|
||||
};
|
||||
scripts.insert(name.clone(), CompiledScript {
|
||||
has_init: defines("init", 0),
|
||||
has_tick: defines("tick", 1),
|
||||
has_bump: defines("bump", 1),
|
||||
ast,
|
||||
});
|
||||
scripts.insert(
|
||||
name.clone(),
|
||||
CompiledScript {
|
||||
has_init: defines("init", 0),
|
||||
has_tick: defines("tick", 1),
|
||||
has_bump: defines("bump", 1),
|
||||
ast,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
failed.insert(name.clone());
|
||||
@@ -240,8 +251,12 @@ impl ScriptHost {
|
||||
// One runtime per object whose script compiled.
|
||||
let mut objects = Vec::new();
|
||||
for (&id, obj) in board.objects.iter() {
|
||||
let Some(name) = obj.script_name.as_ref() else { continue; };
|
||||
if !scripts.contains_key(name) { continue; }
|
||||
let Some(name) = obj.script_name.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if !scripts.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new()));
|
||||
queues.borrow_mut().insert(id, queue.clone());
|
||||
objects.push(ObjectRuntime {
|
||||
@@ -253,7 +268,13 @@ impl ScriptHost {
|
||||
}
|
||||
|
||||
drop(board);
|
||||
Self { engine, scripts, objects, board_queue, errors }
|
||||
Self {
|
||||
engine,
|
||||
scripts,
|
||||
objects,
|
||||
board_queue,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls `init()` on every scripted object that defines it and drains each queue.
|
||||
@@ -273,16 +294,31 @@ impl ScriptHost {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let Self { engine, scripts, objects, errors, .. } = self;
|
||||
let Self {
|
||||
engine,
|
||||
scripts,
|
||||
objects,
|
||||
errors,
|
||||
..
|
||||
} = self;
|
||||
let obj = &mut objects[i];
|
||||
let Some(compiled) = scripts.get(&obj.script_name) else { return; };
|
||||
if !compiled.has_bump { return; }
|
||||
let Some(compiled) = scripts.get(&obj.script_name) else {
|
||||
return;
|
||||
};
|
||||
if !compiled.has_bump {
|
||||
return;
|
||||
}
|
||||
let options = CallFnOptions::default().with_tag(object_id as i64);
|
||||
if let Err(err) = engine.call_fn_with_options::<()>(
|
||||
options, &mut obj.scope, &compiled.ast, "bump", (bumper,),
|
||||
options,
|
||||
&mut obj.scope,
|
||||
&compiled.ast,
|
||||
"bump",
|
||||
(bumper,),
|
||||
) {
|
||||
errors.borrow_mut().push(LogLine::error(format!(
|
||||
"script '{}' bump error: {err}", obj.script_name
|
||||
"script '{}' bump error: {err}",
|
||||
obj.script_name
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -300,13 +336,25 @@ impl ScriptHost {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let Self { engine, scripts, objects, errors, .. } = self;
|
||||
let Self {
|
||||
engine,
|
||||
scripts,
|
||||
objects,
|
||||
errors,
|
||||
..
|
||||
} = self;
|
||||
let obj = &mut objects[i];
|
||||
let Some(compiled) = scripts.get(&obj.script_name) else { return; };
|
||||
let Some(compiled) = scripts.get(&obj.script_name) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let has_1 = compiled.ast.iter_functions()
|
||||
let has_1 = compiled
|
||||
.ast
|
||||
.iter_functions()
|
||||
.any(|f| f.name == fn_name && f.params.len() == 1);
|
||||
let has_0 = compiled.ast.iter_functions()
|
||||
let has_0 = compiled
|
||||
.ast
|
||||
.iter_functions()
|
||||
.any(|f| f.name == fn_name && f.params.is_empty());
|
||||
|
||||
let options = CallFnOptions::default().with_tag(obj.object_id as i64);
|
||||
@@ -318,11 +366,19 @@ impl ScriptHost {
|
||||
None => Dynamic::UNIT,
|
||||
};
|
||||
engine.call_fn_with_options::<()>(
|
||||
options, &mut obj.scope, &compiled.ast, fn_name, (dyn_arg,),
|
||||
options,
|
||||
&mut obj.scope,
|
||||
&compiled.ast,
|
||||
fn_name,
|
||||
(dyn_arg,),
|
||||
)
|
||||
} else if has_0 {
|
||||
engine.call_fn_with_options::<()>(
|
||||
options, &mut obj.scope, &compiled.ast, fn_name, (),
|
||||
options,
|
||||
&mut obj.scope,
|
||||
&compiled.ast,
|
||||
fn_name,
|
||||
(),
|
||||
)
|
||||
} else {
|
||||
return; // Function not defined on this object
|
||||
@@ -330,7 +386,8 @@ impl ScriptHost {
|
||||
|
||||
if let Err(err) = result {
|
||||
errors.borrow_mut().push(LogLine::error(format!(
|
||||
"script '{}' send('{}') error: {err}", obj.script_name, fn_name
|
||||
"script '{}' send('{}') error: {err}",
|
||||
obj.script_name, fn_name
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -357,7 +414,9 @@ impl ScriptHost {
|
||||
if is_delay {
|
||||
let expired = {
|
||||
let mut q = queue.borrow_mut();
|
||||
let Some(Action::Delay(r)) = q.front_mut() else { break };
|
||||
let Some(Action::Delay(r)) = q.front_mut() else {
|
||||
break;
|
||||
};
|
||||
*r -= dt;
|
||||
dt = 0.0;
|
||||
*r <= 0.0
|
||||
@@ -368,8 +427,13 @@ impl ScriptHost {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
let Some(action) = queue.borrow_mut().pop_front() else { break };
|
||||
self.board_queue.borrow_mut().push(BoardAction { source: oid, action });
|
||||
let Some(action) = queue.borrow_mut().pop_front() else {
|
||||
break;
|
||||
};
|
||||
self.board_queue.borrow_mut().push(BoardAction {
|
||||
source: oid,
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,17 +448,28 @@ impl ScriptHost {
|
||||
) {
|
||||
for i in 0..self.objects.len() {
|
||||
{
|
||||
let Self { engine, scripts, objects, errors, .. } = self;
|
||||
let Self {
|
||||
engine,
|
||||
scripts,
|
||||
objects,
|
||||
errors,
|
||||
..
|
||||
} = self;
|
||||
let obj = &mut objects[i];
|
||||
if let Some(compiled) = scripts.get(&obj.script_name)
|
||||
&& defined(compiled)
|
||||
{
|
||||
let options = CallFnOptions::default().with_tag(obj.object_id as i64);
|
||||
if let Err(err) = engine.call_fn_with_options::<()>(
|
||||
options, &mut obj.scope, &compiled.ast, hook, args,
|
||||
options,
|
||||
&mut obj.scope,
|
||||
&compiled.ast,
|
||||
hook,
|
||||
args,
|
||||
) {
|
||||
errors.borrow_mut().push(LogLine::error(format!(
|
||||
"script '{}' {hook} error: {err}", obj.script_name
|
||||
"script '{}' {hook} error: {err}",
|
||||
obj.script_name
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -412,36 +487,46 @@ fn register_me_type(engine: &mut Engine) {
|
||||
engine.register_get("id", |me: &mut Me| me.object_id as i64);
|
||||
|
||||
engine.register_get("name", |me: &mut Me| {
|
||||
me.board.borrow()
|
||||
.objects.get(&me.object_id)
|
||||
me.board
|
||||
.borrow()
|
||||
.objects
|
||||
.get(&me.object_id)
|
||||
.and_then(|o| o.name.as_deref())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
engine.register_get("x", |me: &mut Me| {
|
||||
me.board.borrow()
|
||||
.objects.get(&me.object_id)
|
||||
me.board
|
||||
.borrow()
|
||||
.objects
|
||||
.get(&me.object_id)
|
||||
.map(|o| o.x as i64)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
engine.register_get("y", |me: &mut Me| {
|
||||
me.board.borrow()
|
||||
.objects.get(&me.object_id)
|
||||
me.board
|
||||
.borrow()
|
||||
.objects
|
||||
.get(&me.object_id)
|
||||
.map(|o| o.y as i64)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
engine.register_fn("has_tag", |me: &mut Me, tag: ImmutableString| {
|
||||
me.board.borrow()
|
||||
.objects.get(&me.object_id)
|
||||
me.board
|
||||
.borrow()
|
||||
.objects
|
||||
.get(&me.object_id)
|
||||
.is_some_and(|o| o.tags.contains(tag.as_str()))
|
||||
});
|
||||
|
||||
engine.register_fn("tags", |me: &mut Me| -> rhai::Array {
|
||||
me.board.borrow()
|
||||
.objects.get(&me.object_id)
|
||||
me.board
|
||||
.borrow()
|
||||
.objects
|
||||
.get(&me.object_id)
|
||||
.map(|o| o.tags.iter().map(|t| Dynamic::from(t.clone())).collect())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
@@ -455,7 +540,11 @@ fn register_me_type(engine: &mut Engine) {
|
||||
bg: color_to_hex(obj.glyph.bg),
|
||||
}
|
||||
} else {
|
||||
RhaiGlyph { tile: 0, fg: "#000000".to_string(), bg: "#000000".to_string() }
|
||||
RhaiGlyph {
|
||||
tile: 0,
|
||||
fg: "#000000".to_string(),
|
||||
bg: "#000000".to_string(),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -467,7 +556,8 @@ fn register_registry_type(engine: &mut Engine) {
|
||||
|
||||
// Registry.get(key) -> Dynamic — returns () if the key is absent.
|
||||
engine.register_fn("get", |r: &mut Registry, key: ImmutableString| -> Dynamic {
|
||||
r.board.borrow()
|
||||
r.board
|
||||
.borrow()
|
||||
.registry
|
||||
.get(key.as_str())
|
||||
.map(|v| v.clone().into())
|
||||
@@ -476,25 +566,39 @@ fn register_registry_type(engine: &mut Engine) {
|
||||
|
||||
// Registry.set(key, value) — stores a primitive value; () removes the key;
|
||||
// unsupported types (closures, custom objects) are silently ignored.
|
||||
engine.register_fn("set", |r: &mut Registry, key: ImmutableString, value: Dynamic| {
|
||||
match RegistryValue::try_from(value.clone()) {
|
||||
Ok(rv) => { r.board.borrow_mut().registry.insert(key.to_string(), rv); }
|
||||
Err(()) if value.is_unit() => { r.board.borrow_mut().registry.remove(key.as_str()); }
|
||||
Err(()) => {} // unsupported type — silently ignore
|
||||
}
|
||||
});
|
||||
engine.register_fn(
|
||||
"set",
|
||||
|r: &mut Registry, key: ImmutableString, value: Dynamic| {
|
||||
match RegistryValue::try_from(value.clone()) {
|
||||
Ok(rv) => {
|
||||
r.board.borrow_mut().registry.insert(key.to_string(), rv);
|
||||
}
|
||||
Err(()) if value.is_unit() => {
|
||||
r.board.borrow_mut().registry.remove(key.as_str());
|
||||
}
|
||||
Err(()) => {} // unsupported type — silently ignore
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Registry.get_or(key, default) — returns the stored value when present and the
|
||||
// same Rhai type as `default`; falls back to `default` otherwise.
|
||||
engine.register_fn("get_or", |r: &mut Registry, key: ImmutableString, default: Dynamic| -> Dynamic {
|
||||
if let Some(stored) = r.board.borrow().registry.get(key.as_str()).cloned() {
|
||||
let candidate: Dynamic = stored.into();
|
||||
// Only return the stored value if it matches the type of the default.
|
||||
if candidate.type_id() == default.type_id() { candidate } else { default }
|
||||
} else {
|
||||
default
|
||||
}
|
||||
});
|
||||
engine.register_fn(
|
||||
"get_or",
|
||||
|r: &mut Registry, key: ImmutableString, default: Dynamic| -> Dynamic {
|
||||
if let Some(stored) = r.board.borrow().registry.get(key.as_str()).cloned() {
|
||||
let candidate: Dynamic = stored.into();
|
||||
// Only return the stored value if it matches the type of the default.
|
||||
if candidate.type_id() == default.type_id() {
|
||||
candidate
|
||||
} else {
|
||||
default
|
||||
}
|
||||
} else {
|
||||
default
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Register ObjectInfo type ──────────────────────────────────────────────────
|
||||
@@ -502,9 +606,9 @@ fn register_registry_type(engine: &mut Engine) {
|
||||
fn register_object_info_type(engine: &mut Engine) {
|
||||
engine.register_type_with_name::<ObjectInfo>("ObjectInfo");
|
||||
|
||||
engine.register_get("id", |o: &mut ObjectInfo| o.id);
|
||||
engine.register_get("x", |o: &mut ObjectInfo| o.x);
|
||||
engine.register_get("y", |o: &mut ObjectInfo| o.y);
|
||||
engine.register_get("id", |o: &mut ObjectInfo| o.id);
|
||||
engine.register_get("x", |o: &mut ObjectInfo| o.x);
|
||||
engine.register_get("y", |o: &mut ObjectInfo| o.y);
|
||||
|
||||
engine.register_get("name", |o: &mut ObjectInfo| {
|
||||
o.name.as_deref().unwrap_or("").to_string()
|
||||
@@ -520,8 +624,8 @@ fn register_object_info_type(engine: &mut Engine) {
|
||||
fn register_glyph_type(engine: &mut Engine) {
|
||||
engine.register_type_with_name::<RhaiGlyph>("Glyph");
|
||||
engine.register_get("tile", |g: &mut RhaiGlyph| g.tile);
|
||||
engine.register_get("fg", |g: &mut RhaiGlyph| g.fg.clone());
|
||||
engine.register_get("bg", |g: &mut RhaiGlyph| g.bg.clone());
|
||||
engine.register_get("fg", |g: &mut RhaiGlyph| g.fg.clone());
|
||||
engine.register_get("bg", |g: &mut RhaiGlyph| g.bg.clone());
|
||||
}
|
||||
|
||||
// ── Board read API ────────────────────────────────────────────────────────────
|
||||
@@ -535,8 +639,8 @@ fn register_read_api(
|
||||
engine.register_type_with_name::<BoardRef>("Board");
|
||||
engine.register_get("player_x", |b: &mut BoardRef| b.borrow().player.x as i64);
|
||||
engine.register_get("player_y", |b: &mut BoardRef| b.borrow().player.y as i64);
|
||||
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
|
||||
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
|
||||
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
|
||||
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
|
||||
|
||||
// blocked(dir) — true if moving in dir would be impossible for the calling object.
|
||||
let b = board.clone();
|
||||
@@ -547,25 +651,35 @@ fn register_read_api(
|
||||
|
||||
// Board.tagged(tag) -> Array[ObjectInfo]
|
||||
let b = board.clone();
|
||||
engine.register_fn("tagged", move |_board: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
|
||||
let board = b.borrow();
|
||||
board.objects.iter()
|
||||
.filter(|(_, o)| o.tags.contains(tag.as_str()))
|
||||
.filter_map(|(&id, _)| object_info_from(id, &board))
|
||||
.map(Dynamic::from)
|
||||
.collect()
|
||||
});
|
||||
engine.register_fn(
|
||||
"tagged",
|
||||
move |_board: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
|
||||
let board = b.borrow();
|
||||
board
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|(_, o)| o.tags.contains(tag.as_str()))
|
||||
.filter_map(|(&id, _)| object_info_from(id, &board))
|
||||
.map(Dynamic::from)
|
||||
.collect()
|
||||
},
|
||||
);
|
||||
|
||||
// Board.named(name) -> ObjectInfo | ()
|
||||
let b = board.clone();
|
||||
engine.register_fn("named", move |_board: &mut BoardRef, name: ImmutableString| -> Dynamic {
|
||||
let board = b.borrow();
|
||||
board.objects.iter()
|
||||
.find(|(_, o)| o.name.as_deref() == Some(name.as_str()))
|
||||
.and_then(|(&id, _)| object_info_from(id, &board))
|
||||
.map(Dynamic::from)
|
||||
.unwrap_or(Dynamic::UNIT)
|
||||
});
|
||||
engine.register_fn(
|
||||
"named",
|
||||
move |_board: &mut BoardRef, name: ImmutableString| -> Dynamic {
|
||||
let board = b.borrow();
|
||||
board
|
||||
.objects
|
||||
.iter()
|
||||
.find(|(_, o)| o.name.as_deref() == Some(name.as_str()))
|
||||
.and_then(|(&id, _)| object_info_from(id, &board))
|
||||
.map(Dynamic::from)
|
||||
.unwrap_or(Dynamic::UNIT)
|
||||
},
|
||||
);
|
||||
|
||||
// Board.get(id) -> ObjectInfo | () (-1 returns player; unknown id logs error)
|
||||
let b = board.clone();
|
||||
@@ -576,13 +690,15 @@ fn register_read_api(
|
||||
return Dynamic::from(object_info_player(&board));
|
||||
}
|
||||
if id <= 0 {
|
||||
errs.borrow_mut().push(LogLine::error(format!("Board.get: invalid id {id}")));
|
||||
errs.borrow_mut()
|
||||
.push(LogLine::error(format!("Board.get: invalid id {id}")));
|
||||
return Dynamic::UNIT;
|
||||
}
|
||||
match object_info_from(id as ObjectId, &board) {
|
||||
Some(info) => Dynamic::from(info),
|
||||
None => {
|
||||
errs.borrow_mut().push(LogLine::error(format!("Board.get: no object with id {id}")));
|
||||
errs.borrow_mut()
|
||||
.push(LogLine::error(format!("Board.get: no object with id {id}")));
|
||||
Dynamic::UNIT
|
||||
}
|
||||
}
|
||||
@@ -597,12 +713,18 @@ fn is_blocked(
|
||||
dir: Direction,
|
||||
) -> bool {
|
||||
let board = board.borrow();
|
||||
let Some(obj) = board.objects.get(&source) else { return true; };
|
||||
let Some(obj) = board.objects.get(&source) else {
|
||||
return true;
|
||||
};
|
||||
let (dx, dy): (i32, i32) = dir.into();
|
||||
let target = (obj.x as i32 + dx, obj.y as i32 + dy);
|
||||
if !board.in_bounds(target) { return true; }
|
||||
if !board.in_bounds(target) {
|
||||
return true;
|
||||
}
|
||||
let (tx, ty) = (target.0 as usize, target.1 as usize);
|
||||
if board.solid_at(tx, ty).is_some() { return true; }
|
||||
if board.solid_at(tx, ty).is_some() {
|
||||
return true;
|
||||
}
|
||||
board_queue.borrow().iter().any(|ba| {
|
||||
if let Action::Move(d) = &ba.action
|
||||
&& let Some(mover) = board.objects.get(&ba.source)
|
||||
@@ -663,37 +785,57 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||
});
|
||||
|
||||
let q = queues.clone();
|
||||
engine.register_fn("log", move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::Log(LogLine::raw(msg.to_string())));
|
||||
});
|
||||
engine.register_fn(
|
||||
"log",
|
||||
move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||
emit(
|
||||
&q,
|
||||
source_of(&ctx),
|
||||
Action::Log(LogLine::raw(msg.to_string())),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
let q = queues.clone();
|
||||
engine.register_fn("say", move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::Say(msg.to_string(), SAY_DURATION));
|
||||
});
|
||||
engine.register_fn(
|
||||
"say",
|
||||
move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||
emit(
|
||||
&q,
|
||||
source_of(&ctx),
|
||||
Action::Say(msg.to_string(), SAY_DURATION),
|
||||
);
|
||||
},
|
||||
);
|
||||
let q = queues.clone();
|
||||
engine.register_fn("say", move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| {
|
||||
emit(&q, source_of(&ctx), Action::Say(msg.to_string(), dur));
|
||||
});
|
||||
engine.register_fn(
|
||||
"say",
|
||||
move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| {
|
||||
emit(&q, source_of(&ctx), Action::Say(msg.to_string(), dur));
|
||||
},
|
||||
);
|
||||
|
||||
// scroll(lines): open a full-screen scrollable overlay. Each element of `lines`
|
||||
// is either a plain string (text) or a 2-element array [choice_key, display_text].
|
||||
let q = queues.clone();
|
||||
engine.register_fn("scroll", move |ctx: NativeCallContext, arr: Array| {
|
||||
let lines = arr.into_iter().filter_map(|item| {
|
||||
if let Ok(s) = item.clone().into_string() {
|
||||
Some(ScrollLine::Text(s))
|
||||
} else {
|
||||
let pair: Vec<Dynamic> = item.into_array().ok()?;
|
||||
if pair.len() == 2 {
|
||||
let choice = pair[0].clone().into_string().ok()?;
|
||||
let display = pair[1].clone().into_string().ok()?;
|
||||
Some(ScrollLine::Choice { choice, display })
|
||||
let lines = arr
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
if let Ok(s) = item.clone().into_string() {
|
||||
Some(ScrollLine::Text(s))
|
||||
} else {
|
||||
None
|
||||
let pair: Vec<Dynamic> = item.into_array().ok()?;
|
||||
if pair.len() == 2 {
|
||||
let choice = pair[0].clone().into_string().ok()?;
|
||||
let display = pair[1].clone().into_string().ok()?;
|
||||
Some(ScrollLine::Choice { choice, display })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
emit(&q, source_of(&ctx), Action::Scroll(lines));
|
||||
});
|
||||
|
||||
@@ -701,41 +843,63 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||
engine.register_fn(
|
||||
"set_tag",
|
||||
move |ctx: NativeCallContext, target: i64, tag: ImmutableString, present: bool| {
|
||||
emit(&q, source_of(&ctx), Action::SetTag {
|
||||
target: target as ObjectId,
|
||||
tag: tag.to_string(),
|
||||
present,
|
||||
});
|
||||
emit(
|
||||
&q,
|
||||
source_of(&ctx),
|
||||
Action::SetTag {
|
||||
target: target as ObjectId,
|
||||
tag: tag.to_string(),
|
||||
present,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// set_fg(fg): change foreground color only.
|
||||
let q = queues.clone();
|
||||
engine.register_fn("set_fg", move |ctx: NativeCallContext, fg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::SetColor {
|
||||
fg: Some(parse_color(fg.as_str())),
|
||||
bg: None,
|
||||
});
|
||||
});
|
||||
engine.register_fn(
|
||||
"set_fg",
|
||||
move |ctx: NativeCallContext, fg: ImmutableString| {
|
||||
emit(
|
||||
&q,
|
||||
source_of(&ctx),
|
||||
Action::SetColor {
|
||||
fg: Some(parse_color(fg.as_str())),
|
||||
bg: None,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// set_bg(bg): change background color only.
|
||||
let q = queues.clone();
|
||||
engine.register_fn("set_bg", move |ctx: NativeCallContext, bg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::SetColor {
|
||||
fg: None,
|
||||
bg: Some(parse_color(bg.as_str())),
|
||||
});
|
||||
});
|
||||
engine.register_fn(
|
||||
"set_bg",
|
||||
move |ctx: NativeCallContext, bg: ImmutableString| {
|
||||
emit(
|
||||
&q,
|
||||
source_of(&ctx),
|
||||
Action::SetColor {
|
||||
fg: None,
|
||||
bg: Some(parse_color(bg.as_str())),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// set_color(fg, bg): change both colors.
|
||||
let q = queues.clone();
|
||||
engine.register_fn(
|
||||
"set_color",
|
||||
move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::SetColor {
|
||||
fg: Some(parse_color(fg.as_str())),
|
||||
bg: Some(parse_color(bg.as_str())),
|
||||
});
|
||||
emit(
|
||||
&q,
|
||||
source_of(&ctx),
|
||||
Action::SetColor {
|
||||
fg: Some(parse_color(fg.as_str())),
|
||||
bg: Some(parse_color(bg.as_str())),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -744,11 +908,15 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||
engine.register_fn(
|
||||
"send",
|
||||
move |ctx: NativeCallContext, target: i64, name: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::Send {
|
||||
target: target as ObjectId,
|
||||
fn_name: name.to_string(),
|
||||
arg: None,
|
||||
});
|
||||
emit(
|
||||
&q,
|
||||
source_of(&ctx),
|
||||
Action::Send {
|
||||
target: target as ObjectId,
|
||||
fn_name: name.to_string(),
|
||||
arg: None,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -766,11 +934,15 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
emit(&q, source_of(&ctx), Action::Send {
|
||||
target: target as ObjectId,
|
||||
fn_name: name.to_string(),
|
||||
arg: script_arg,
|
||||
});
|
||||
emit(
|
||||
&q,
|
||||
source_of(&ctx),
|
||||
Action::Send {
|
||||
target: target as ObjectId,
|
||||
fn_name: name.to_string(),
|
||||
arg: script_arg,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -779,7 +951,14 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||
// destination already has a solid occupant.
|
||||
let q = queues.clone();
|
||||
engine.register_fn("teleport", move |ctx: NativeCallContext, x: i64, y: i64| {
|
||||
emit(&q, source_of(&ctx), Action::Teleport { x: x as i32, y: y as i32 });
|
||||
emit(
|
||||
&q,
|
||||
source_of(&ctx),
|
||||
Action::Teleport {
|
||||
x: x as i32,
|
||||
y: y as i32,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -788,7 +967,7 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||
fn register_queue_api(engine: &mut Engine) {
|
||||
engine.register_type_with_name::<ObjQueue>("Queue");
|
||||
engine.register_fn("length", |q: &mut ObjQueue| q.borrow().len() as i64);
|
||||
engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear());
|
||||
engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear());
|
||||
|
||||
// peek(): front action as a Rhai map, or () if empty.
|
||||
engine.register_fn("peek", |q: &mut ObjQueue| -> Dynamic {
|
||||
@@ -817,25 +996,25 @@ fn register_global_constants(engine: &mut Engine) {
|
||||
let mut m = Module::new();
|
||||
m.set_var("North", Direction::North);
|
||||
m.set_var("South", Direction::South);
|
||||
m.set_var("East", Direction::East);
|
||||
m.set_var("West", Direction::West);
|
||||
m.set_var("East", Direction::East);
|
||||
m.set_var("West", Direction::West);
|
||||
// 16 EGA/VGA color constants as "#RRGGBB" strings.
|
||||
m.set_var("Black", "#000000");
|
||||
m.set_var("Blue", "#0000AA");
|
||||
m.set_var("Green", "#00AA00");
|
||||
m.set_var("Cyan", "#00AAAA");
|
||||
m.set_var("Red", "#AA0000");
|
||||
m.set_var("Magenta", "#AA00AA");
|
||||
m.set_var("Brown", "#AA5500");
|
||||
m.set_var("LightGray", "#AAAAAA");
|
||||
m.set_var("DarkGray", "#555555");
|
||||
m.set_var("BrightBlue", "#5555FF");
|
||||
m.set_var("BrightGreen", "#55FF55");
|
||||
m.set_var("BrightCyan", "#55FFFF");
|
||||
m.set_var("BrightRed", "#FF5555");
|
||||
m.set_var("Black", "#000000");
|
||||
m.set_var("Blue", "#0000AA");
|
||||
m.set_var("Green", "#00AA00");
|
||||
m.set_var("Cyan", "#00AAAA");
|
||||
m.set_var("Red", "#AA0000");
|
||||
m.set_var("Magenta", "#AA00AA");
|
||||
m.set_var("Brown", "#AA5500");
|
||||
m.set_var("LightGray", "#AAAAAA");
|
||||
m.set_var("DarkGray", "#555555");
|
||||
m.set_var("BrightBlue", "#5555FF");
|
||||
m.set_var("BrightGreen", "#55FF55");
|
||||
m.set_var("BrightCyan", "#55FFFF");
|
||||
m.set_var("BrightRed", "#FF5555");
|
||||
m.set_var("BrightMagenta", "#FF55FF");
|
||||
m.set_var("Yellow", "#FFFF55");
|
||||
m.set_var("White", "#FFFFFF");
|
||||
m.set_var("Yellow", "#FFFF55");
|
||||
m.set_var("White", "#FFFFFF");
|
||||
engine.register_global_module(m.into());
|
||||
}
|
||||
|
||||
@@ -845,8 +1024,19 @@ fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) ->
|
||||
let mut scope = Scope::new();
|
||||
scope.push_constant("Board", board.clone());
|
||||
scope.push_constant("Queue", queue.clone());
|
||||
scope.push_constant("Me", Me { object_id, board: board.clone() });
|
||||
scope.push_constant("Registry", Registry { board: board.clone() });
|
||||
scope.push_constant(
|
||||
"Me",
|
||||
Me {
|
||||
object_id,
|
||||
board: board.clone(),
|
||||
},
|
||||
);
|
||||
scope.push_constant(
|
||||
"Registry",
|
||||
Registry {
|
||||
board: board.clone(),
|
||||
},
|
||||
);
|
||||
scope
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use std::time::Duration;
|
||||
use super::{log_texts, scripted_object, scripts_from};
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::tests::{crate_at, open_board, wall_at};
|
||||
use crate::game::GameState;
|
||||
use super::{scripted_object, log_texts, scripts_from};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn move_command_relocates_the_source_object() {
|
||||
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); }")]));
|
||||
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).
|
||||
@@ -18,7 +19,8 @@ fn move_command_relocates_the_source_object() {
|
||||
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")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")]));
|
||||
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);
|
||||
}
|
||||
@@ -26,7 +28,8 @@ fn move_into_a_wall_or_edge_is_a_noop() {
|
||||
#[test]
|
||||
fn set_tile_command_changes_the_source_glyph() {
|
||||
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); }")]));
|
||||
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);
|
||||
}
|
||||
@@ -36,19 +39,21 @@ 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")]);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
|
||||
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));
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(3, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[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")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
|
||||
let mut game =
|
||||
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
|
||||
game.run_init();
|
||||
{
|
||||
let b = game.board();
|
||||
@@ -59,7 +64,8 @@ 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")]);
|
||||
wall_at(&mut board, 3, 0);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
|
||||
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));
|
||||
@@ -71,7 +77,10 @@ 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")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(East); }")]));
|
||||
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)
|
||||
|
||||
@@ -91,19 +100,31 @@ fn inline_delay_paces_subsequent_moves() {
|
||||
// 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")]);
|
||||
wall_at(&mut board, 2, 1);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(South); }")]));
|
||||
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));
|
||||
assert_eq!(
|
||||
(game.board().objects[&1].x, game.board().objects[&1].y),
|
||||
(1, 1)
|
||||
);
|
||||
|
||||
// The Delay(250 ms) appended by move(East) is still live, so South is pending.
|
||||
game.tick(Duration::from_millis(100));
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1));
|
||||
assert_eq!(
|
||||
(game.board().objects[&1].x, game.board().objects[&1].y),
|
||||
(1, 1)
|
||||
);
|
||||
|
||||
// Past 250 ms the queued South move resolves.
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 2));
|
||||
assert_eq!(
|
||||
(game.board().objects[&1].x, game.board().objects[&1].y),
|
||||
(1, 2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -111,10 +132,13 @@ fn queue_length_reports_pending_actions() {
|
||||
// Two zero-cost actions are queued before the length is read, then all three
|
||||
// (incl. the log) drain in one pump.
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "q")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[(
|
||||
"q",
|
||||
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }",
|
||||
)]));
|
||||
let mut game = GameState::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
|
||||
@@ -124,7 +148,10 @@ fn queue_length_reports_pending_actions() {
|
||||
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")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")]));
|
||||
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
|
||||
@@ -135,19 +162,25 @@ 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")]);
|
||||
wall_at(&mut board, 2, 0);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[(
|
||||
"b",
|
||||
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)]));
|
||||
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")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[(
|
||||
"b",
|
||||
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)]));
|
||||
let mut game = GameState::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);
|
||||
}
|
||||
@@ -165,10 +198,16 @@ fn blocked_sees_earlier_objects_pending_move() {
|
||||
scripted_object(3, 1, "checker"),
|
||||
],
|
||||
);
|
||||
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); } }"),
|
||||
]));
|
||||
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 super::{log_texts, scripted_object, scripts_from};
|
||||
use crate::board::tests::open_board;
|
||||
use crate::game::GameState;
|
||||
use super::{scripted_object, log_texts, scripts_from};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn collision_priority_resolves_in_array_order_and_bumps() {
|
||||
@@ -13,10 +13,19 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
|
||||
(0, 1), // player off the contested row
|
||||
vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")],
|
||||
);
|
||||
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}`); }"),
|
||||
]));
|
||||
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.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::rc::Rc;
|
||||
use std::cell::RefCell;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::game::GameState;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::layer::Layer;
|
||||
use crate::utils::{Direction, Player, PortalDef};
|
||||
use crate::world::World;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Builds a 3×3 board with the player at `(px, py)` and the given portals.
|
||||
fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board {
|
||||
@@ -13,9 +15,9 @@ fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board {
|
||||
name: "test".into(),
|
||||
width: 3,
|
||||
height: 3,
|
||||
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); 9],
|
||||
floor: vec![Archetype::Empty.default_glyph(); 9],
|
||||
floor_spec: None,
|
||||
layers: vec![Layer {
|
||||
cells: vec![(Glyph::transparent(), Archetype::Empty); 9],
|
||||
}],
|
||||
player: Player { x: px, y: py },
|
||||
objects: BTreeMap::new(),
|
||||
next_object_id: 1,
|
||||
@@ -31,12 +33,30 @@ fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board {
|
||||
/// - `"b1"`: player at `(0, 0)`, portal `"to_b2"` at `(2, 0)` → `"b2"` / `"from_b1"`
|
||||
/// - `"b2"`: player at `(0, 0)`, portal `"from_b1"` at `(1, 1)` → `"b1"` / `"to_b2"`
|
||||
fn two_board_world() -> World {
|
||||
let b1 = make_board(0, 0, vec![
|
||||
PortalDef { name: "to_b2".into(), x: 2, y: 0, target_map: "b2".into(), target_entry: "from_b1".into() },
|
||||
]);
|
||||
let b2 = make_board(0, 0, vec![
|
||||
PortalDef { name: "from_b1".into(), x: 1, y: 1, target_map: "b1".into(), target_entry: "to_b2".into() },
|
||||
]);
|
||||
let b1 = make_board(
|
||||
0,
|
||||
0,
|
||||
vec![PortalDef {
|
||||
name: "to_b2".into(),
|
||||
x: 2,
|
||||
y: 0,
|
||||
z: 0,
|
||||
target_map: "b2".into(),
|
||||
target_entry: "from_b1".into(),
|
||||
}],
|
||||
);
|
||||
let b2 = make_board(
|
||||
0,
|
||||
0,
|
||||
vec![PortalDef {
|
||||
name: "from_b1".into(),
|
||||
x: 1,
|
||||
y: 1,
|
||||
z: 0,
|
||||
target_map: "b1".into(),
|
||||
target_entry: "to_b2".into(),
|
||||
}],
|
||||
);
|
||||
World {
|
||||
name: "test".into(),
|
||||
start: "b1".into(),
|
||||
|
||||
@@ -1,53 +1,59 @@
|
||||
use super::{layer, load_board, map};
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::map_file::MapFile;
|
||||
use super::minimal_toml;
|
||||
|
||||
#[test]
|
||||
fn grid_wrong_row_count_returns_error() {
|
||||
// height = 3 but only 2 rows in the grid
|
||||
let toml = minimal_toml(3, 3, &["...", "..."]);
|
||||
// height = 3 but only 2 rows in the layer grid.
|
||||
let toml = map(3, 3, &[layer("...\n...", &[(".", "kind = \"empty\"")])]);
|
||||
let mf: MapFile = toml::from_str(&toml).unwrap();
|
||||
let result = Board::try_from(mf);
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap();
|
||||
assert!(msg.contains("2 rows"), "expected row count in error, got: {msg}");
|
||||
assert!(msg.contains("height = 3"), "expected declared height in error, got: {msg}");
|
||||
assert!(
|
||||
msg.contains("2 rows"),
|
||||
"expected row count in error, got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("3 tall"),
|
||||
"expected declared height in error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_wrong_row_width_returns_error() {
|
||||
// width = 4 but second row is only 3 characters
|
||||
let toml = minimal_toml(4, 2, &["....", "..."]);
|
||||
// width = 4 but second row is only 3 characters.
|
||||
let toml = map(4, 2, &[layer("....\n...", &[(".", "kind = \"empty\"")])]);
|
||||
let mf: MapFile = toml::from_str(&toml).unwrap();
|
||||
let result = Board::try_from(mf);
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap();
|
||||
assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}");
|
||||
assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}");
|
||||
assert!(
|
||||
msg.contains("3 characters"),
|
||||
"expected col count in error, got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("4 wide"),
|
||||
"expected declared width in error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_archetype_in_palette_produces_error_block() {
|
||||
// "object" is no longer a valid palette archetype; it should produce ErrorBlock.
|
||||
// Player is placed away from the O cell so it isn't cleared.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 2
|
||||
height = 1
|
||||
player_start = [1, 0]
|
||||
|
||||
[palette]
|
||||
"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" }
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
O.
|
||||
"""
|
||||
"##;
|
||||
let mf: MapFile = toml::from_str(toml).unwrap();
|
||||
let board = Board::try_from(mf).unwrap();
|
||||
assert_eq!(*board.get(0, 0), (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock));
|
||||
fn unknown_kind_produces_error_block() {
|
||||
// A palette `kind` that is neither a meta-kind nor a known archetype name
|
||||
// becomes a visible ErrorBlock. Player placed away from the X cell.
|
||||
let toml = map(
|
||||
2,
|
||||
1,
|
||||
&[layer(
|
||||
"X@",
|
||||
&[("X", "kind = \"frobnicate\""), ("@", "kind = \"player\"")],
|
||||
)],
|
||||
);
|
||||
let board = load_board(&toml);
|
||||
assert_eq!(
|
||||
*board.get(0, 0, 0),
|
||||
(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,79 +12,53 @@ fn load_board(toml: &str) -> Board {
|
||||
Board::try_from(mf).expect("convert to board")
|
||||
}
|
||||
|
||||
/// A 3×1 map (`empty` palette `.`) whose grid is `grid`, plus the given
|
||||
/// `[[objects]]` TOML appended. Keeps placement tests compact.
|
||||
fn map_3x1(grid: &str, objects: &str) -> String {
|
||||
format!(
|
||||
r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 3
|
||||
height = 1
|
||||
player_start = [2, 0]
|
||||
|
||||
[palette]
|
||||
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
|
||||
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
{grid}
|
||||
"""
|
||||
{objects}
|
||||
"##
|
||||
)
|
||||
/// Builds one `[[layers]]` block: a triple-quoted `content` grid plus an inline
|
||||
/// `palette` table. Each palette entry is `(char_key, inline-body)` where the
|
||||
/// body is the inside of the entry's `{ ... }` (e.g. `kind = "wall"`).
|
||||
fn layer(content: &str, palette: &[(&str, &str)]) -> String {
|
||||
let pal = palette
|
||||
.iter()
|
||||
.map(|(k, body)| format!("\"{k}\" = {{ {body} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("\n[[layers]]\ncontent = \"\"\"\n{content}\n\"\"\"\npalette = {{ {pal} }}\n")
|
||||
}
|
||||
|
||||
/// A standard `[[objects]]` block placed by palette char `ch`.
|
||||
fn obj_palette(ch: &str, extra: &str) -> String {
|
||||
format!(
|
||||
"[[objects]]\npalette = \"{ch}\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n{extra}"
|
||||
)
|
||||
/// Wraps a `[map]` header of the given size around the supplied `[[layers]]`
|
||||
/// blocks (each produced by [`layer`]).
|
||||
fn map(width: usize, height: usize, layers: &[String]) -> String {
|
||||
let mut s = format!("[map]\nname = \"Test\"\nwidth = {width}\nheight = {height}\n");
|
||||
for l in layers {
|
||||
s.push_str(l);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A 1-row map of the given `width` with a raw `player_start` TOML value
|
||||
/// (`start`, e.g. `[1, 0]` or `"@"`), grid, and objects block.
|
||||
fn player_map(width: usize, start: &str, grid: &str, objects: &str) -> String {
|
||||
format!(
|
||||
r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = {width}
|
||||
height = 1
|
||||
player_start = {start}
|
||||
|
||||
[palette]
|
||||
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
|
||||
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
{grid}
|
||||
"""
|
||||
{objects}
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal valid TOML with a configurable grid, used as a base for dimension tests.
|
||||
fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String {
|
||||
let content = grid_rows.join("\n");
|
||||
format!(
|
||||
r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = {width}
|
||||
height = {height}
|
||||
player_start = [0, 0]
|
||||
|
||||
[palette]
|
||||
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
{content}
|
||||
"""
|
||||
"##
|
||||
/// A 3×1 single-layer map: an `empty`/`wall` palette plus one `object` entry
|
||||
/// placed by char `ch` (cyan `@` glyph), with `extra` appended to its body. The
|
||||
/// player is placed at the far-right cell via a second char where room allows;
|
||||
/// callers that need the player elsewhere build the map directly.
|
||||
fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String {
|
||||
let body = if extra.is_empty() {
|
||||
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"".to_string()
|
||||
} else {
|
||||
format!("kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\", {extra}")
|
||||
};
|
||||
map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
grid,
|
||||
&[
|
||||
(" ", "kind = \"empty\""),
|
||||
(".", "kind = \"empty\""),
|
||||
(
|
||||
"#",
|
||||
"kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"",
|
||||
),
|
||||
("@", "kind = \"player\""),
|
||||
(ch, &body),
|
||||
],
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
use super::{layer, load_board, map, map_3x1_object};
|
||||
use crate::archetype::Archetype;
|
||||
use super::{load_board, map_3x1, obj_palette};
|
||||
|
||||
/// Palette shorthands.
|
||||
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
|
||||
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
|
||||
const WALL: (&str, &str) = (
|
||||
"#",
|
||||
"kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"",
|
||||
);
|
||||
|
||||
/// An object palette entry body with the given `extra` fields appended.
|
||||
fn obj(extra: &str) -> String {
|
||||
let base = "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"";
|
||||
if extra.is_empty() {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}, {extra}")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_name_clears_second_but_keeps_both_objects() {
|
||||
// Two objects share the name "gate": first keeps it, second is cleared.
|
||||
// Both objects still appear on the board; the map is flagged invalid.
|
||||
let objs = format!(
|
||||
"{}\n{}",
|
||||
"[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\nname = \"gate\"\n",
|
||||
"[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\nname = \"gate\"\nsolid = false\n",
|
||||
);
|
||||
let board = load_board(&map_3x1("...", &objs));
|
||||
// Two object entries share the name "gate": first keeps it, second is cleared.
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
"GH@",
|
||||
&[
|
||||
("G", &obj("name = \"gate\"")),
|
||||
("H", &obj("name = \"gate\", solid = false")),
|
||||
PLAYER,
|
||||
],
|
||||
)],
|
||||
));
|
||||
assert_eq!(board.objects.len(), 2, "both objects survive");
|
||||
// First object (id 1) keeps the name; second (id 2) is cleared.
|
||||
assert_eq!(board.objects[&1].name.as_deref(), Some("gate"));
|
||||
assert_eq!(board.objects[&2].name, None);
|
||||
assert!(!board.is_valid(), "duplicate name is a nonfatal load error");
|
||||
@@ -20,80 +42,76 @@ fn duplicate_name_clears_second_but_keeps_both_objects() {
|
||||
|
||||
#[test]
|
||||
fn palette_placement_puts_object_on_empty_floor() {
|
||||
// `G` appears once, isn't a palette key → object lands there, cell is Empty.
|
||||
let board = load_board(&map_3x1("G..", &obj_palette("G", "")));
|
||||
// `G` lands an object; its cell stays Empty (transparent).
|
||||
let board = load_board(&map_3x1_object("G.@", "G", ""));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_absent_from_grid_drops_object() {
|
||||
let board = load_board(&map_3x1("...", &obj_palette("G", "")));
|
||||
assert!(board.objects.is_empty());
|
||||
assert!(!board.is_valid());
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_appearing_twice_spawns_two_objects() {
|
||||
// `G` appears at (0,0) and (1,0); player is at (2,0) so neither G conflicts.
|
||||
// Two independent ObjectDefs are spawned.
|
||||
let board = load_board(&map_3x1("GG.", &obj_palette("G", "")));
|
||||
// `G` appears at (0,0) and (1,0); two independent ObjectDefs are spawned.
|
||||
let board = load_board(&map_3x1_object("GG@", "G", "solid = false"));
|
||||
assert_eq!(board.objects.len(), 2);
|
||||
// IDs are assigned in reading order (left-to-right).
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
|
||||
assert_eq!((board.objects[&2].x, board.objects[&2].y), (1, 0));
|
||||
// Both cells load as Empty in the grid.
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Empty);
|
||||
assert!(board.is_valid(), "multiple occurrences are not an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_multi_occurrence_only_first_keeps_name() {
|
||||
// Two occurrences of a named entry (solid = false to avoid player conflict at (2,0)).
|
||||
// First instance gets the name; second is anonymous.
|
||||
let entry = obj_palette("G", "solid = false\nname = \"guard\"\n");
|
||||
let board = load_board(&map_3x1("GGG", &entry));
|
||||
// Three occurrences of a named entry: the first keeps the name, the rest don't.
|
||||
let board = load_board(&map(
|
||||
4,
|
||||
1,
|
||||
&[layer(
|
||||
"GGG@",
|
||||
&[("G", &obj("solid = false, name = \"guard\"")), PLAYER],
|
||||
)],
|
||||
));
|
||||
assert_eq!(board.objects.len(), 3);
|
||||
assert_eq!(board.objects[&1].name.as_deref(), Some("guard"));
|
||||
assert_eq!(board.objects[&2].name, None);
|
||||
assert_eq!(board.objects[&3].name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_reused_by_two_objects_keeps_first() {
|
||||
// Two objects claim `G`: the first keeps it, the later one is dropped.
|
||||
let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", ""));
|
||||
let board = load_board(&map_3x1("G..", &two));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert!(!board.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_colliding_with_palette_key_drops_object() {
|
||||
// `#` is a terrain palette key, so it can't be an object placeholder.
|
||||
let board = load_board(&map_3x1("#..", &obj_palette("#", "")));
|
||||
assert!(board.objects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() {
|
||||
// Solid object at the wall cell (0,0): dropped for conflicting with the wall.
|
||||
let solid = "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let board = load_board(&map_3x1("#..", solid));
|
||||
assert!(board.objects.is_empty());
|
||||
// Solid object stacked on a wall (different layer): dropped for the conflict.
|
||||
let solid = map(
|
||||
3,
|
||||
1,
|
||||
&[
|
||||
layer("#.@", &[WALL, EMPTY, PLAYER]),
|
||||
layer("O..", &[("O", &obj("")), EMPTY]),
|
||||
],
|
||||
);
|
||||
assert!(load_board(&solid).objects.is_empty());
|
||||
|
||||
// Same placement but non-solid: kept (it doesn't claim the cell's solidity).
|
||||
let nonsolid = format!("{solid}solid = false\n");
|
||||
let board = load_board(&map_3x1("#..", &nonsolid));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
let nonsolid = map(
|
||||
3,
|
||||
1,
|
||||
&[
|
||||
layer("#.@", &[WALL, EMPTY, PLAYER]),
|
||||
layer("O..", &[("O", &obj("solid = false")), EMPTY]),
|
||||
],
|
||||
);
|
||||
assert_eq!(load_board(&nonsolid).objects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_solid_object_on_a_cell_is_dropped() {
|
||||
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let board = load_board(&map_3x1("...", &format!("{obj}{obj}")));
|
||||
// Two solid objects on the same cell across layers: the upper one is dropped.
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[
|
||||
layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY]),
|
||||
layer(".O.", &[EMPTY, ("O", &obj(""))]),
|
||||
],
|
||||
));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert!(!board.is_valid());
|
||||
}
|
||||
|
||||
@@ -1,70 +1,81 @@
|
||||
use super::{layer, load_board, map};
|
||||
use crate::archetype::Archetype;
|
||||
use super::{load_board, player_map};
|
||||
|
||||
#[test]
|
||||
fn player_coords_place_the_player() {
|
||||
let b = load_board(&player_map(3, "[1, 0]", "...", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
/// Palette shorthands shared by these tests.
|
||||
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
|
||||
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
|
||||
const WALL: (&str, &str) = (
|
||||
"#",
|
||||
"kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"",
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn player_char_places_on_empty_floor() {
|
||||
let b = load_board(&player_map(3, "\"@\"", ".@.", ""));
|
||||
let b = load_board(&map(3, 1, &[layer(".@.", &[EMPTY, PLAYER])]));
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_wins_solid_terrain_silently() {
|
||||
// Player coords land on a wall: the wall is cleared, no error reported.
|
||||
let b = load_board(&player_map(3, "[0, 0]", "#..", ""));
|
||||
// Wall on layer 0, player on layer 1 at the same cell: the wall is cleared,
|
||||
// no error reported.
|
||||
let b = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer("#..", &[WALL, EMPTY]), layer("@..", &[PLAYER, EMPTY])],
|
||||
));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(0, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 0, 0).1, Archetype::Empty); // wall cleared on layer 0
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_wins_against_a_solid_object_silently() {
|
||||
// A solid object on the player's cell is dropped, silently (player wins).
|
||||
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let b = load_board(&player_map(3, "[1, 0]", "...", obj));
|
||||
// A solid object on the player's cell (different layer) is dropped silently.
|
||||
let b = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[
|
||||
layer(
|
||||
".O.",
|
||||
&[
|
||||
EMPTY,
|
||||
(
|
||||
"O",
|
||||
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"",
|
||||
),
|
||||
],
|
||||
),
|
||||
layer(".@.", &[EMPTY, PLAYER]),
|
||||
],
|
||||
));
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert!(b.objects.is_empty());
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_is_reserved_from_objects() {
|
||||
// An object trying to use '@' is dropped; the player is still placed.
|
||||
let obj = "[[objects]]\npalette = \"@\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let b = load_board(&player_map(3, "\"@\"", "@..", obj));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(b.objects.is_empty());
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_appearing_twice_uses_first() {
|
||||
let b = load_board(&player_map(3, "\"@\"", "@.@", ""));
|
||||
let b = load_board(&map(3, 1, &[layer("@.@", &[EMPTY, PLAYER])]));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_missing_falls_back_to_origin() {
|
||||
let b = load_board(&player_map(3, "\"@\"", "...", ""));
|
||||
let b = load_board(&map(3, 1, &[layer("...", &[EMPTY])]));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_fallback_to_origin_clears_solid_terrain() {
|
||||
// The '@' char is absent, so the player falls back to (0, 0) — which holds a
|
||||
// wall. The player wins its cell: the wall is cleared so one-solid-per-cell
|
||||
// holds. The fallback is still reported.
|
||||
let b = load_board(&player_map(3, "\"@\"", "#..", ""));
|
||||
// No player cell, so the player falls back to (0, 0) — which holds a wall. The
|
||||
// player wins its cell: the wall is cleared. The fallback is still reported.
|
||||
let b = load_board(&map(3, 1, &[layer("#..", &[WALL, EMPTY])]));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(0, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 0, 0).1, Archetype::Empty);
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
@@ -1,47 +1,56 @@
|
||||
use super::{load_board, map_3x1};
|
||||
use super::{layer, load_board, map};
|
||||
|
||||
fn portal_toml(portals: &str) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
map_3x1("...", ""),
|
||||
portals
|
||||
)
|
||||
}
|
||||
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
|
||||
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
|
||||
|
||||
#[test]
|
||||
fn portal_duplicate_name_drops_second() {
|
||||
let toml = portal_toml(
|
||||
"[[portals]]\nname = \"a\"\nx = 0\ny = 0\ntarget_map = \"x\"\ntarget_entry = \"b\"\n\
|
||||
[[portals]]\nname = \"a\"\nx = 1\ny = 0\ntarget_map = \"x\"\ntarget_entry = \"c\"\n",
|
||||
// Two portal cells share the name "a": the second is dropped.
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
"12@",
|
||||
&[
|
||||
(
|
||||
"1",
|
||||
"kind = \"portal\", name = \"a\", target_map = \"x\", target_entry = \"b\"",
|
||||
),
|
||||
(
|
||||
"2",
|
||||
"kind = \"portal\", name = \"a\", target_map = \"x\", target_entry = \"c\"",
|
||||
),
|
||||
PLAYER,
|
||||
],
|
||||
)],
|
||||
));
|
||||
assert_eq!(
|
||||
board.portals.len(),
|
||||
1,
|
||||
"second portal with duplicate name should be dropped"
|
||||
);
|
||||
let board = load_board(&toml);
|
||||
assert_eq!(board.portals.len(), 1, "second portal with duplicate name should be dropped");
|
||||
assert_eq!(board.portals[0].x, 0);
|
||||
assert!(!board.is_valid(), "duplicate name should be a load error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_palette_char_places_portal_at_grid_position() {
|
||||
let toml = format!(
|
||||
"{}{}",
|
||||
map_3x1("1..", ""),
|
||||
"[[portals]]\nname = \"p\"\npalette = \"1\"\ntarget_map = \"x\"\ntarget_entry = \"e\"\n"
|
||||
);
|
||||
let board = load_board(&toml);
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
"1.@",
|
||||
&[
|
||||
(
|
||||
"1",
|
||||
"kind = \"portal\", name = \"p\", target_map = \"x\", target_entry = \"e\"",
|
||||
),
|
||||
EMPTY,
|
||||
PLAYER,
|
||||
],
|
||||
)],
|
||||
));
|
||||
assert_eq!(board.portals.len(), 1);
|
||||
assert_eq!(board.portals[0].x, 0);
|
||||
assert_eq!(board.portals[0].y, 0);
|
||||
assert_eq!((board.portals[0].x, board.portals[0].y), (0, 0));
|
||||
assert_eq!(board.portals[0].name, "p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_palette_char_colliding_with_object_is_dropped() {
|
||||
let toml = format!(
|
||||
"{}{}{}",
|
||||
map_3x1("1..", ""),
|
||||
"[[objects]]\npalette = \"1\"\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\n",
|
||||
"[[portals]]\nname = \"p\"\npalette = \"1\"\ntarget_map = \"x\"\ntarget_entry = \"e\"\n"
|
||||
);
|
||||
let board = load_board(&toml);
|
||||
assert_eq!(board.portals.len(), 0, "portal whose palette char is already an object char should be dropped");
|
||||
}
|
||||
|
||||
@@ -1,203 +1,147 @@
|
||||
use color::Rgba8;
|
||||
use crate::board::Board;
|
||||
use super::{layer, load_board, map};
|
||||
use crate::glyph::Glyph;
|
||||
use crate::map_file::{MapFile, parse_color};
|
||||
use super::{load_board, map_3x1, obj_palette};
|
||||
use color::Rgba8;
|
||||
|
||||
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
|
||||
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
|
||||
|
||||
/// An object palette entry body with the given `extra` fields appended.
|
||||
fn obj(extra: &str) -> String {
|
||||
let base = "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"";
|
||||
if extra.is_empty() {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}, {extra}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Round-trips a board through save→load and returns the reloaded board.
|
||||
fn round_trip(toml: &str) -> crate::board::Board {
|
||||
let board = load_board(toml);
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
load_board(&toml_out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_glyph_round_trips_through_toml() {
|
||||
// Objects must survive a save→load cycle with their glyph intact.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 3
|
||||
height = 3
|
||||
player_start = [0, 0]
|
||||
|
||||
[palette]
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
...
|
||||
...
|
||||
...
|
||||
"""
|
||||
|
||||
[[objects]]
|
||||
x = 1
|
||||
y = 1
|
||||
tile = 64
|
||||
fg = "#00FFFF"
|
||||
bg = "#000000"
|
||||
"##;
|
||||
let mf: MapFile = toml::from_str(toml).unwrap();
|
||||
let board = Board::try_from(mf).unwrap();
|
||||
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
|
||||
let board = load_board(&toml);
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
let obj = &board.objects[&1];
|
||||
assert_eq!(obj.x, 1);
|
||||
assert_eq!(obj.y, 1);
|
||||
assert_eq!(obj.glyph.tile, 64);
|
||||
assert_eq!(obj.glyph.fg, Rgba8 { r: 0x00, g: 0xFF, b: 0xFF, a: 255 });
|
||||
assert_eq!(obj.glyph.bg, Rgba8 { r: 0, g: 0, b: 0, a: 255 });
|
||||
let obj0 = &board.objects[&1];
|
||||
assert_eq!((obj0.x, obj0.y), (1, 0));
|
||||
assert_eq!(obj0.glyph.tile, 64);
|
||||
assert_eq!(
|
||||
obj0.glyph.fg,
|
||||
Rgba8 {
|
||||
r: 0x00,
|
||||
g: 0xFF,
|
||||
b: 0xFF,
|
||||
a: 255
|
||||
}
|
||||
);
|
||||
|
||||
// Save back to TOML and reload; glyph must survive.
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
let mf2: MapFile = toml::from_str(&toml_out).unwrap();
|
||||
let board2 = Board::try_from(mf2).unwrap();
|
||||
let board2 = round_trip(&toml);
|
||||
let obj2 = &board2.objects[&1];
|
||||
assert_eq!(obj2.glyph.tile, obj.glyph.tile);
|
||||
assert_eq!(obj2.glyph.fg, obj.glyph.fg);
|
||||
assert_eq!(obj2.glyph.bg, obj.glyph.bg);
|
||||
assert_eq!(obj2.glyph.tile, obj0.glyph.tile);
|
||||
assert_eq!(obj2.glyph.fg, obj0.glyph.fg);
|
||||
assert_eq!(obj2.glyph.bg, obj0.glyph.bg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_tags_round_trip_through_toml() {
|
||||
// Tags declared in the map file must survive a save→load cycle, sorted.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 3
|
||||
height = 3
|
||||
player_start = [0, 0]
|
||||
let toml = map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
"@O.",
|
||||
&[PLAYER, ("O", &obj("tags = [\"enemy\", \"boss\"]")), EMPTY],
|
||||
)],
|
||||
);
|
||||
let board = load_board(&toml);
|
||||
let obj0 = &board.objects[&1];
|
||||
assert!(obj0.tags.contains("enemy") && obj0.tags.contains("boss"));
|
||||
assert_eq!(obj0.tags.len(), 2);
|
||||
|
||||
[palette]
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
...
|
||||
...
|
||||
...
|
||||
"""
|
||||
|
||||
[[objects]]
|
||||
x = 1
|
||||
y = 1
|
||||
tile = 64
|
||||
fg = "#ffffff"
|
||||
bg = "#000000"
|
||||
tags = ["enemy", "boss"]
|
||||
"##;
|
||||
let mf: MapFile = toml::from_str(toml).unwrap();
|
||||
let board = Board::try_from(mf).unwrap();
|
||||
let obj = &board.objects[&1];
|
||||
assert!(obj.tags.contains("enemy"));
|
||||
assert!(obj.tags.contains("boss"));
|
||||
assert_eq!(obj.tags.len(), 2);
|
||||
|
||||
// Save and reload; tags must survive, and be sorted alphabetically.
|
||||
// Saved tags must be sorted alphabetically (boss before enemy).
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
// Both tags must appear; "boss" must come before "enemy" (sorted).
|
||||
let boss_pos = toml_out.find("\"boss\"").expect("boss in TOML");
|
||||
let enemy_pos = toml_out.find("\"enemy\"").expect("enemy in TOML");
|
||||
assert!(boss_pos < enemy_pos, "tags must be sorted: boss before enemy");
|
||||
let mf2: MapFile = toml::from_str(&toml_out).unwrap();
|
||||
let board2 = Board::try_from(mf2).unwrap();
|
||||
let obj2 = &board2.objects[&1];
|
||||
assert_eq!(obj2.tags, obj.tags);
|
||||
let boss = toml_out.find("\"boss\"").expect("boss in TOML");
|
||||
let enemy = toml_out.find("\"enemy\"").expect("enemy in TOML");
|
||||
assert!(boss < enemy, "tags must be sorted: boss before enemy");
|
||||
|
||||
let board2 = load_board(&toml_out);
|
||||
assert_eq!(board2.objects[&1].tags, obj0.tags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_empty_tags_omitted_from_toml() {
|
||||
// An object with no tags must not emit a `tags` key in TOML.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 1
|
||||
height = 1
|
||||
player_start = [0, 0]
|
||||
|
||||
[palette]
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
.
|
||||
"""
|
||||
|
||||
[[objects]]
|
||||
x = 0
|
||||
y = 0
|
||||
tile = 64
|
||||
fg = "#ffffff"
|
||||
bg = "#000000"
|
||||
"##;
|
||||
let board = load_board(toml);
|
||||
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
|
||||
let board = load_board(&toml);
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
assert!(!toml_out.contains("tags"), "empty tags must not appear in TOML output");
|
||||
assert!(
|
||||
!toml_out.contains("tags"),
|
||||
"empty tags must not appear in TOML output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_name_round_trips_through_toml() {
|
||||
// A named object must survive a save→load cycle with its name intact.
|
||||
let board = load_board(&map_3x1("G..", &obj_palette("G", "name = \"beacon\"\n")));
|
||||
let toml = map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
"@O.",
|
||||
&[PLAYER, ("O", &obj("name = \"beacon\"")), EMPTY],
|
||||
)],
|
||||
);
|
||||
let board = load_board(&toml);
|
||||
assert_eq!(board.objects[&1].name.as_deref(), Some("beacon"));
|
||||
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
assert!(toml_out.contains("\"beacon\""), "name must appear in saved TOML");
|
||||
assert!(
|
||||
toml_out.contains("\"beacon\""),
|
||||
"name must appear in saved TOML"
|
||||
);
|
||||
let board2 = load_board(&toml_out);
|
||||
assert_eq!(board2.objects[&1].name.as_deref(), Some("beacon"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_object_omits_name_from_toml() {
|
||||
// An unnamed object must not emit a `name` key in TOML; check via the parsed
|
||||
// value so the map header's `name = "Test"` doesn't produce a false positive.
|
||||
let board = load_board(&map_3x1("G..", &obj_palette("G", "")));
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
let val: toml::Value = toml_out.parse().unwrap();
|
||||
let objects = val["objects"].as_array().unwrap();
|
||||
assert!(
|
||||
!objects[0].as_table().unwrap().contains_key("name"),
|
||||
"unnamed object must not emit name key"
|
||||
fn unnamed_object_name_stays_none_through_toml() {
|
||||
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
|
||||
let board2 = round_trip(&toml);
|
||||
assert_eq!(
|
||||
board2.objects[&1].name, None,
|
||||
"unnamed object must round-trip as None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_spec_round_trips_through_toml() {
|
||||
// A 2×2 board with a grid floor (a generator and a fixed glyph) must survive
|
||||
// save→load with its `[floor]` declaration intact, and the reloaded board's
|
||||
// computed floor must place the fixed glyph back at its declared cell.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 2
|
||||
height = 2
|
||||
player_start = [0, 0]
|
||||
|
||||
[palette]
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
..
|
||||
..
|
||||
"""
|
||||
|
||||
[floor]
|
||||
content = """
|
||||
g.
|
||||
.g
|
||||
"""
|
||||
|
||||
[floor.palette]
|
||||
"g" = "grass"
|
||||
"." = { tile = "#", fg = "#010203", bg = "#040506" }
|
||||
"##;
|
||||
let board = load_board(toml);
|
||||
assert!(board.floor_spec.is_some());
|
||||
fn fixed_floor_glyph_round_trips_through_toml() {
|
||||
// A fixed floor glyph (visual-only Empty cell) must survive save→load.
|
||||
let toml = map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
"@F.",
|
||||
&[
|
||||
PLAYER,
|
||||
(
|
||||
"F",
|
||||
"kind = \"floor\", tile = \"#\", fg = \"#010203\", bg = \"#040506\"",
|
||||
),
|
||||
EMPTY,
|
||||
],
|
||||
)],
|
||||
);
|
||||
let fixed = Glyph {
|
||||
tile: '#' as u32,
|
||||
fg: parse_color("#010203"),
|
||||
bg: parse_color("#040506"),
|
||||
};
|
||||
// (1,0) is the fixed `.` floor glyph (the cell archetype is Empty).
|
||||
let board = load_board(&toml);
|
||||
assert_eq!(board.glyph_at(1, 0), fixed);
|
||||
|
||||
// Save back to TOML and reload; the floor declaration must be preserved.
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
let board2 = load_board(&toml_out);
|
||||
assert!(board2.floor_spec.is_some());
|
||||
let board2 = round_trip(&toml);
|
||||
assert_eq!(board2.glyph_at(1, 0), fixed);
|
||||
}
|
||||
|
||||
@@ -5,18 +5,23 @@ mod map_file;
|
||||
mod movement;
|
||||
mod scripting;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::game::GameState;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::layer::Layer;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::Player;
|
||||
use crate::game::GameState;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// 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>) {
|
||||
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);
|
||||
@@ -24,9 +29,9 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> (
|
||||
name: "test".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)],
|
||||
floor: vec![Archetype::Empty.default_glyph()],
|
||||
floor_spec: None,
|
||||
layers: vec![Layer {
|
||||
cells: vec![(Glyph::transparent(), Archetype::Empty)],
|
||||
}],
|
||||
player: Player { x: 0, y: 0 },
|
||||
objects: BTreeMap::from([(1, object)]),
|
||||
next_object_id: 2,
|
||||
@@ -41,7 +46,10 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> (
|
||||
/// 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()
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns an `ObjectDef` at `(x, y)` bound to the named script.
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
use color::Rgba8;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::tests::{crate_at, open_board, stamp, wall_at};
|
||||
use crate::board::tests::{add_floor, crate_at, open_board, stamp, wall_at};
|
||||
use crate::game::GameState;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::Direction;
|
||||
use color::Rgba8;
|
||||
|
||||
#[test]
|
||||
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.
|
||||
// The cell a crate is pushed off of becomes transparent and glyph_at shows the
|
||||
// floor on the layer beneath. After the push the player is at (1,0); check the
|
||||
// cell the player vacated (0,0) — it must reveal the floor glyph, not black.
|
||||
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 },
|
||||
bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 },
|
||||
fg: Rgba8 {
|
||||
r: 40,
|
||||
g: 60,
|
||||
b: 40,
|
||||
a: 255,
|
||||
},
|
||||
bg: Rgba8 {
|
||||
r: 5,
|
||||
g: 10,
|
||||
b: 5,
|
||||
a: 255,
|
||||
},
|
||||
};
|
||||
board.floor = vec![floor_glyph; 4];
|
||||
add_floor(&mut board, floor_glyph); // floor below; terrain becomes layer 1
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(1, 2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(1, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.glyph_at(0, 0), floor_glyph);
|
||||
}
|
||||
|
||||
@@ -35,8 +45,8 @@ fn player_pushes_single_crate() {
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -48,8 +58,8 @@ fn push_blocked_by_wall_moves_nothing() {
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Wall);
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Wall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -61,7 +71,7 @@ fn push_blocked_by_edge_moves_nothing() {
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -73,9 +83,9 @@ fn cascade_pushes_two_crates() {
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(3, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -88,8 +98,8 @@ fn cascade_blocked_by_wall_moves_nothing() {
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -114,8 +124,8 @@ fn hcrate_pushes_east_but_not_north() {
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::HCrate);
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::HCrate);
|
||||
drop(b);
|
||||
|
||||
// Pushing north into an HCrate: blocked, nothing moves.
|
||||
@@ -125,7 +135,7 @@ fn hcrate_pushes_east_but_not_north() {
|
||||
game.try_move(Direction::North);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 3));
|
||||
assert_eq!(b.get(0, 2).1, Archetype::HCrate);
|
||||
assert_eq!(b.get(0, 0, 2).1, Archetype::HCrate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -137,8 +147,8 @@ fn vcrate_pushes_north_but_not_east() {
|
||||
game.try_move(Direction::North);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 2));
|
||||
assert_eq!(b.get(0, 2).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 1).1, Archetype::VCrate);
|
||||
assert_eq!(b.get(0, 0, 2).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 0, 1).1, Archetype::VCrate);
|
||||
drop(b);
|
||||
|
||||
// Pushing east into a VCrate: blocked, nothing moves.
|
||||
@@ -148,5 +158,5 @@ fn vcrate_pushes_north_but_not_east() {
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::VCrate);
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::VCrate);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::time::Duration;
|
||||
use super::{board_with_object, log_texts, scripted_object, scripts_from};
|
||||
use crate::board::tests::open_board;
|
||||
use crate::game::{GameState, ScrollLine};
|
||||
use crate::utils::Direction;
|
||||
use super::{board_with_object, scripted_object, log_texts, scripts_from};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn init_runs_only_on_run_init_not_at_construction() {
|
||||
@@ -44,10 +44,7 @@ fn missing_hooks_and_no_script_are_noops() {
|
||||
assert!(game.log.is_empty());
|
||||
|
||||
// Script defines neither init nor tick: also a no-op.
|
||||
let (board, scripts) = board_with_object(
|
||||
Some("e"),
|
||||
&[("e", "fn other() { log(\"x\"); }")],
|
||||
);
|
||||
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));
|
||||
@@ -69,13 +66,11 @@ fn compile_and_unknown_script_errors_are_logged() {
|
||||
|
||||
#[test]
|
||||
fn script_reads_board_through_view() {
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(3, 1),
|
||||
vec![scripted_object(2, 1, "r")],
|
||||
let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")]),
|
||||
);
|
||||
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"]);
|
||||
}
|
||||
@@ -90,10 +85,13 @@ fn commands_are_routed_to_their_own_source_object() {
|
||||
(0, 0),
|
||||
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
|
||||
);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[
|
||||
("e", "fn init() { move(East); }"),
|
||||
("w", "fn init() { move(West); }"),
|
||||
]));
|
||||
let mut game = GameState::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
|
||||
@@ -112,7 +110,9 @@ fn start_map_greeter_runs_init() {
|
||||
let mut game = GameState::from_world(world);
|
||||
game.run_init();
|
||||
assert!(
|
||||
log_texts(&game).iter().any(|t| t.contains("hello from object")),
|
||||
log_texts(&game)
|
||||
.iter()
|
||||
.any(|t| t.contains("hello from object")),
|
||||
"greeter init should log a greeting"
|
||||
);
|
||||
assert!(
|
||||
@@ -139,7 +139,12 @@ fn set_tag_adds_and_removes_via_my_id() {
|
||||
&[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)],
|
||||
);
|
||||
// Seed the tag before construction.
|
||||
board2.objects.get_mut(&1).unwrap().tags.insert("active".to_string());
|
||||
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"));
|
||||
@@ -172,37 +177,39 @@ fn objects_with_tag_returns_matching_ids() {
|
||||
// obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not.
|
||||
obj2.tags.insert("enemy".to_string());
|
||||
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[
|
||||
("q", r#"fn init() {
|
||||
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", ""),
|
||||
]));
|
||||
}"#,
|
||||
),
|
||||
("none", ""),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
let texts = log_texts(&game);
|
||||
assert_eq!(texts[0], "1"); // exactly one match
|
||||
assert_eq!(texts[1], "2"); // obj2 is id 2
|
||||
assert_eq!(texts[0], "1"); // exactly one match
|
||||
assert_eq!(texts[1], "2"); // obj2 is id 2
|
||||
}
|
||||
|
||||
#[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, scripts) = board_with_object(
|
||||
Some("n"),
|
||||
&[("n", r#"fn init() { log(Me.name); }"#)],
|
||||
);
|
||||
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::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
assert_eq!(log_texts(&game), vec!["beacon"]);
|
||||
|
||||
// An unnamed object should get an empty string.
|
||||
let (board2, scripts2) = board_with_object(
|
||||
Some("n"),
|
||||
&[("n", r#"fn init() { log(Me.name); }"#)],
|
||||
);
|
||||
let (board2, scripts2) =
|
||||
board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]);
|
||||
let mut game2 = GameState::with_scripts(board2, scripts2);
|
||||
game2.run_init();
|
||||
assert_eq!(log_texts(&game2), vec![""]);
|
||||
@@ -216,18 +223,24 @@ fn object_id_for_name_finds_by_name() {
|
||||
let mut obj2 = scripted_object(1, 0, "none");
|
||||
obj2.name = Some("target".to_string());
|
||||
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[
|
||||
("q", r#"fn init() {
|
||||
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", ""),
|
||||
]));
|
||||
}"#,
|
||||
),
|
||||
("none", ""),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
let texts = log_texts(&game);
|
||||
assert_eq!(texts[0], "2"); // obj2 is id 2
|
||||
assert_eq!(texts[1], "not_found"); // Board.named returns () when no match
|
||||
assert_eq!(texts[0], "2"); // obj2 is id 2
|
||||
assert_eq!(texts[1], "not_found"); // Board.named returns () when no match
|
||||
}
|
||||
|
||||
// Ensure try_move from the player side also triggers scripted bump
|
||||
@@ -236,7 +249,10 @@ 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")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")]));
|
||||
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
|
||||
@@ -251,22 +267,36 @@ fn scroll_opens_on_player_bump() {
|
||||
// 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")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#)]));
|
||||
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));
|
||||
|
||||
let scroll = game.active_scroll.as_ref().expect("scroll should be active after bump");
|
||||
let scroll = game
|
||||
.active_scroll
|
||||
.as_ref()
|
||||
.expect("scroll should be active after bump");
|
||||
assert_eq!(scroll.lines.len(), 2);
|
||||
assert!(matches!(&scroll.lines[0], ScrollLine::Text(t) if t == "Hello world"));
|
||||
assert!(matches!(&scroll.lines[1], ScrollLine::Choice { choice, display }
|
||||
if choice == "eat" && display == "Eat it"));
|
||||
assert!(
|
||||
matches!(&scroll.lines[1], ScrollLine::Choice { choice, display }
|
||||
if choice == "eat" && display == "Eat it")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_scroll_without_choice_clears_it() {
|
||||
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"]); }"#)]));
|
||||
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));
|
||||
@@ -282,10 +312,16 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
|
||||
// Setting choice on the scroll before a tick fires send() on the source object;
|
||||
// fn eat() logging "eaten" confirms the dispatch arrived.
|
||||
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#"
|
||||
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));
|
||||
@@ -295,5 +331,8 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
|
||||
game.active_scroll.as_mut().unwrap().choice = Some("eat".to_string());
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(game.active_scroll.is_none());
|
||||
assert!(log_texts(&game).iter().any(|t| t == "eaten"), "eat() should have logged");
|
||||
assert!(
|
||||
log_texts(&game).iter().any(|t| t == "eaten"),
|
||||
"eat() should have logged"
|
||||
);
|
||||
}
|
||||
|
||||
+38
-12
@@ -1,6 +1,6 @@
|
||||
use color::Rgba8;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::glyph::Glyph;
|
||||
use color::Rgba8;
|
||||
use rhai::Dynamic;
|
||||
|
||||
/// Which directions a solid may be pushed in.
|
||||
@@ -98,6 +98,9 @@ pub struct PortalDef {
|
||||
pub x: usize,
|
||||
/// Row of this portal on the board (0-indexed).
|
||||
pub y: usize,
|
||||
/// Index of the layer this portal belongs to (0 = bottom). Determines its
|
||||
/// draw order relative to terrain/objects on other layers.
|
||||
pub z: usize,
|
||||
/// Key of the target board in `World::boards`.
|
||||
pub target_map: String,
|
||||
/// Name of the arrival portal on the target board.
|
||||
@@ -109,8 +112,18 @@ impl PortalDef {
|
||||
pub fn default_glyph() -> Glyph {
|
||||
Glyph {
|
||||
tile: 240,
|
||||
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
||||
bg: Rgba8 { r: 255, g: 255, b: 255, a: 255 },
|
||||
fg: Rgba8 {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 255,
|
||||
},
|
||||
bg: Rgba8 {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: 255,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,12 +146,17 @@ pub struct Player {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::utils::Direction;
|
||||
use super::Pushable;
|
||||
use crate::utils::Direction;
|
||||
|
||||
#[test]
|
||||
fn pushable_allows_only_its_axis() {
|
||||
for d in [Direction::North, Direction::South, Direction::East, Direction::West] {
|
||||
for d in [
|
||||
Direction::North,
|
||||
Direction::South,
|
||||
Direction::East,
|
||||
Direction::West,
|
||||
] {
|
||||
assert!(!Pushable::No.allows(d));
|
||||
assert!(Pushable::Any.allows(d));
|
||||
}
|
||||
@@ -187,10 +205,18 @@ impl TryFrom<Dynamic> for RegistryValue {
|
||||
type Error = ();
|
||||
/// Returns `Err(())` for `()` (unit) and all unsupported types.
|
||||
fn try_from(value: Dynamic) -> Result<Self, ()> {
|
||||
if value.is_bool() { return Ok(RegistryValue::Bool(value.as_bool().unwrap())); }
|
||||
if value.is_int() { return Ok(RegistryValue::Int(value.as_int().unwrap())); }
|
||||
if value.is_float() { return Ok(RegistryValue::Float(value.as_float().unwrap())); }
|
||||
if value.is_string() { return Ok(RegistryValue::Str(value.into_string().unwrap())); }
|
||||
if value.is_bool() {
|
||||
return Ok(RegistryValue::Bool(value.as_bool().unwrap()));
|
||||
}
|
||||
if value.is_int() {
|
||||
return Ok(RegistryValue::Int(value.as_int().unwrap()));
|
||||
}
|
||||
if value.is_float() {
|
||||
return Ok(RegistryValue::Float(value.as_float().unwrap()));
|
||||
}
|
||||
if value.is_string() {
|
||||
return Ok(RegistryValue::Str(value.into_string().unwrap()));
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
@@ -202,9 +228,9 @@ impl Into<Dynamic> for RegistryValue {
|
||||
fn into(self) -> Dynamic {
|
||||
match self {
|
||||
RegistryValue::Bool(b) => Dynamic::from(b),
|
||||
RegistryValue::Int(n) => Dynamic::from(n),
|
||||
RegistryValue::Int(n) => Dynamic::from(n),
|
||||
RegistryValue::Float(f) => Dynamic::from(f),
|
||||
RegistryValue::Str(s) => Dynamic::from(s),
|
||||
RegistryValue::Str(s) => Dynamic::from(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,4 +245,4 @@ impl From<Direction> for (i32, i32) {
|
||||
Direction::West => (-1, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! 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;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A named world containing one or more boards, loaded from a `.toml` file.
|
||||
///
|
||||
@@ -71,8 +71,7 @@ pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> {
|
||||
// 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))?;
|
||||
let board = Board::try_from(map_file).map_err(|e| format!("board '{}': {}", key, e))?;
|
||||
boards.insert(key, Rc::new(RefCell::new(board)));
|
||||
}
|
||||
|
||||
|
||||
+22
-14
@@ -143,21 +143,29 @@ fn run(
|
||||
continue;
|
||||
}
|
||||
let size = terminal.size()?;
|
||||
match current_input_mode(mode, ui) {
|
||||
InputMode::Board | InputMode::Frozen => {
|
||||
if let Mode::Play(game) = mode {
|
||||
handle_board_input(event, ui.log.open, size.height, game, ui);
|
||||
}
|
||||
// Each Mode only produces a subset of input modes (Editor only in Edit;
|
||||
// Board/Scroll/Frozen only in Play; Menu/Ignore in either). Match on Mode
|
||||
// first so each handler gets the right `game`/`ed` without re-checking,
|
||||
// and assert the impossible combinations away with `unreachable!`.
|
||||
let input_mode = current_input_mode(mode, ui);
|
||||
if matches!(input_mode, InputMode::Menu) {
|
||||
handle_menu_input(event, ui)
|
||||
} else if !matches!(input_mode, InputMode::Ignore) {
|
||||
// If input_mode is ignore, an animation is running and claiming no input mode — discard all events.
|
||||
match mode {
|
||||
Mode::Play(game) => match input_mode {
|
||||
InputMode::Board | InputMode::Frozen => {
|
||||
handle_board_input(event, ui.log.open, size.height, game, ui);
|
||||
}
|
||||
InputMode::Scroll => handle_scroll_input(event, game, ui),
|
||||
_ => unreachable!("Menu and Ignore handled above; Editor input mode only occurs in Edit mode"),
|
||||
},
|
||||
Mode::Edit(ed) => match input_mode {
|
||||
InputMode::Editor => handle_editor_input(event, size.width, ed, ui),
|
||||
_ => unreachable!("Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode")
|
||||
},
|
||||
}
|
||||
InputMode::Menu => handle_menu_input(event, ui),
|
||||
InputMode::Scroll => {
|
||||
if let Mode::Play(game) = mode { handle_scroll_input(event, game, ui); }
|
||||
}
|
||||
InputMode::Editor => {
|
||||
if let Mode::Edit(ed) = mode { handle_editor_input(event, size.width, ed, ui); }
|
||||
}
|
||||
// Animation running and claiming no input mode — discard all events.
|
||||
InputMode::Ignore => {}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-5
@@ -107,13 +107,21 @@ impl Ui {
|
||||
return; // no mode tick during any animation (even when just ended)
|
||||
}
|
||||
|
||||
// The game only advances during normal play (InputMode::Board). While a
|
||||
// scroll or menu overlay is open the input mode is Scroll/Menu, so the game
|
||||
// is paused — otherwise `game.tick` would call `handle_scroll`, clearing the
|
||||
// scroll the instant its open animation finished.
|
||||
let input_mode = crate::input::current_input_mode(mode, self);
|
||||
match mode {
|
||||
// Play: tick the game, then check whether a scroll overlay appeared.
|
||||
// Play: tick the game (only in Board mode), then check whether a scroll
|
||||
// overlay appeared as a result of this tick.
|
||||
Mode::Play(game) => {
|
||||
game.tick(dt);
|
||||
if let Some(scroll) = &game.active_scroll {
|
||||
let lines = scroll.lines.clone();
|
||||
self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines)));
|
||||
if matches!(input_mode, crate::input::InputMode::Board) {
|
||||
game.tick(dt);
|
||||
if let Some(scroll) = &game.active_scroll {
|
||||
let lines = scroll.lines.clone();
|
||||
self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Edit: only the cursor blink advances; the game does not exist here.
|
||||
|
||||
+102
-120
@@ -171,24 +171,10 @@ fn tick(dt) {
|
||||
name = "Starting Room"
|
||||
width = 60
|
||||
height = 25
|
||||
player_start = "@" # placed by the '@' grid char (convention)
|
||||
|
||||
[boards.start.palette]
|
||||
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
|
||||
"#" = { archetype = "wall", tile = "#", fg = "#808080", bg = "#606060" }
|
||||
"+" = { archetype = "banana", tile = "#", fg = "#808080", bg = "#606060" }
|
||||
"o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
|
||||
"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" }
|
||||
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" }
|
||||
">" = { archetype = "pusher_east" }
|
||||
"^" = { archetype = "pusher_north" }
|
||||
|
||||
# Optional visual floor layer, drawn under every empty cell (and revealed when a
|
||||
# crate is pushed away). This is a grid form with its own palette: `g`/`d`/`s`
|
||||
# name the built-in generators (random dark, low-saturation texture cached at
|
||||
# load); a palette entry could also be a fixed glyph like
|
||||
# `{ tile = ".", fg = "#222", bg = "#000" }`.
|
||||
[boards.start.floor]
|
||||
# Layer 0 (bottom): the visual floor. Generators g/d/s roll a fresh textured
|
||||
# glyph per cell; the space char is a fixed water glyph.
|
||||
[[boards.start.layers]]
|
||||
content = """
|
||||
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
@@ -216,14 +202,15 @@ gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
|
||||
"""
|
||||
[boards.start.layers.palette]
|
||||
"g" = { kind = "floor", generator = "grass" }
|
||||
"d" = { kind = "floor", generator = "dirt" }
|
||||
"s" = { kind = "floor", generator = "stone" }
|
||||
" " = { kind = "floor", tile = "~", fg = "#6666ff", bg = "#000066" }
|
||||
|
||||
[boards.start.floor.palette]
|
||||
"g" = "grass"
|
||||
"d" = "dirt"
|
||||
"s" = "stone"
|
||||
" " = { tile = "~", fg = "#6666ff", bg = "#000066" }
|
||||
|
||||
[boards.start.grid]
|
||||
# Layer 1: terrain, the player, objects and the portal. A space here is
|
||||
# transparent (kind = "empty"), so the floor below shows through.
|
||||
[[boards.start.layers]]
|
||||
content = """
|
||||
############################################################
|
||||
# #
|
||||
@@ -237,7 +224,7 @@ content = """
|
||||
# # #
|
||||
# | #
|
||||
# #
|
||||
# oo G - @ #
|
||||
# V oo G - @ #
|
||||
# #
|
||||
# #
|
||||
# #
|
||||
@@ -251,69 +238,95 @@ content = """
|
||||
# #
|
||||
############################################################
|
||||
"""
|
||||
[boards.start.layers.palette]
|
||||
" " = { kind = "empty" }
|
||||
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" }
|
||||
"+" = { kind = "banana", tile = "#", fg = "#808080", bg = "#606060" } # unknown kind -> ErrorBlock demo
|
||||
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
|
||||
"-" = { kind = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" }
|
||||
"|" = { kind = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" }
|
||||
">" = { kind = "pusher_east" }
|
||||
"^" = { kind = "pusher_north" }
|
||||
"@" = { kind = "player" }
|
||||
"1" = { kind = "portal", name = "to_house", target_map = "house", target_entry = "from_start" }
|
||||
"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", solid = false, script_name = "greeter" }
|
||||
"V" = { kind = "object", tile = 1, fg = "#33aa33", bg = "#000000", script_name = "mover" }
|
||||
"M" = { kind = "object", tile = 15, fg = "#ffdd88", bg = "#000000", solid = true, name = "muffin", script_name = "muffin" }
|
||||
"B" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "noticeboard", script_name = "noticeboard" }
|
||||
|
||||
# Placed by its grid character `G` (uppercase-letter convention for objects)
|
||||
# rather than x/y; the cell under it loads as Empty.
|
||||
[[boards.start.objects]]
|
||||
fg = "#aa3333"
|
||||
bg = "#000000"
|
||||
palette = "G"
|
||||
tile = "#"
|
||||
solid = false
|
||||
script_name = "greeter"
|
||||
|
||||
# A solid object placed by x/y that paces east on its own. Demonstrates the
|
||||
# rate-limited move(), the blocked() collision check, the Queue object, and the
|
||||
# bump() hook (walk the player into it to trigger a bump).
|
||||
[[boards.start.objects]]
|
||||
x = 10
|
||||
y = 12
|
||||
fg = "#33aa33"
|
||||
bg = "#000000"
|
||||
tile = 1
|
||||
script_name = "mover"
|
||||
|
||||
[[boards.start.objects]]
|
||||
palette = "M"
|
||||
tile = 15
|
||||
fg = "#ffdd88"
|
||||
bg = "#000000"
|
||||
solid = true
|
||||
name = "muffin"
|
||||
script_name = "muffin"
|
||||
|
||||
# Notice board — long scroll to test overflow and scrolling.
|
||||
[[boards.start.objects]]
|
||||
palette = "B"
|
||||
tile = 240
|
||||
fg = "#cc9944"
|
||||
bg = "#000000"
|
||||
solid = true
|
||||
name = "noticeboard"
|
||||
script_name = "noticeboard"
|
||||
|
||||
[[boards.start.portals]]
|
||||
name = "to_house"
|
||||
palette = "1"
|
||||
target_map = "house"
|
||||
target_entry = "from_start"
|
||||
# Layer 2: a purely decorative, non-solid object drawn *above* the solid crate
|
||||
# beneath it — only possible because draw order follows layer order.
|
||||
[[boards.start.layers]]
|
||||
content = """
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"""
|
||||
[boards.start.layers.palette]
|
||||
" " = { kind = "empty" }
|
||||
"*" = { kind = "object", tile = 15, fg = "#ffff66", bg = "#000000", solid = false }
|
||||
|
||||
[boards.house.map]
|
||||
name = "The House"
|
||||
width = 60
|
||||
height = 25
|
||||
player_start = "@"
|
||||
|
||||
[boards.house.palette]
|
||||
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
|
||||
"#" = { archetype = "wall", tile = 178, fg = "#888888", bg = "#555555" }
|
||||
# Layer 0: a single fixed floor glyph across the whole board.
|
||||
[[boards.house.layers]]
|
||||
content = """
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
"""
|
||||
[boards.house.layers.palette]
|
||||
"f" = { kind = "floor", tile = 176, fg = "#888888", bg = "#444444" }
|
||||
|
||||
[boards.house.floor]
|
||||
tile = 176
|
||||
fg = "#888888"
|
||||
bg = "#444444"
|
||||
|
||||
[boards.house.grid]
|
||||
# Layer 1: terrain, the player, objects and the return portal.
|
||||
[[boards.house.layers]]
|
||||
content = """
|
||||
############################################################
|
||||
# #
|
||||
@@ -341,44 +354,13 @@ content = """
|
||||
# #
|
||||
############################################################
|
||||
"""
|
||||
[boards.house.layers.palette]
|
||||
" " = { kind = "empty" }
|
||||
"#" = { kind = "wall", tile = 178, fg = "#888888", bg = "#555555" }
|
||||
"@" = { kind = "player" }
|
||||
"1" = { kind = "portal", name = "from_start", target_map = "start", target_entry = "to_house" }
|
||||
"K" = { kind = "object", tile = 240, fg = "#aa7733", bg = "#3d1c00", solid = true, name = "bookshelf", script_name = "bookshelf" }
|
||||
"F" = { kind = "object", tile = 15, fg = "#ff8800", bg = "#220000", solid = true, name = "fireplace", script_name = "fireplace" }
|
||||
"C" = { kind = "object", tile = 240, fg = "#ccaa44", bg = "#222200", solid = true, name = "chest", script_name = "chest" }
|
||||
"T" = { kind = "object", tile = 1, fg = "#99ff44", bg = "#000000", solid = true, script_name = "yammerer" }
|
||||
|
||||
[[boards.house.objects]]
|
||||
palette = "K"
|
||||
tile = 240
|
||||
fg = "#aa7733"
|
||||
bg = "#3d1c00"
|
||||
solid = true
|
||||
name = "bookshelf"
|
||||
script_name = "bookshelf"
|
||||
|
||||
[[boards.house.objects]]
|
||||
palette = "F"
|
||||
tile = 15
|
||||
fg = "#ff8800"
|
||||
bg = "#220000"
|
||||
solid = true
|
||||
name = "fireplace"
|
||||
script_name = "fireplace"
|
||||
|
||||
[[boards.house.objects]]
|
||||
palette = "C"
|
||||
tile = 240
|
||||
fg = "#ccaa44"
|
||||
bg = "#222200"
|
||||
solid = true
|
||||
name = "chest"
|
||||
script_name = "chest"
|
||||
|
||||
[[boards.house.objects]]
|
||||
palette = "T"
|
||||
tile = 1
|
||||
fg = "#99ff44"
|
||||
bg = "#000000"
|
||||
solid = true
|
||||
script_name = "yammerer"
|
||||
|
||||
[[boards.house.portals]]
|
||||
name = "from_start"
|
||||
palette = "1"
|
||||
target_map = "start"
|
||||
target_entry = "to_house"
|
||||
|
||||
Reference in New Issue
Block a user