new layer model
This commit is contained in:
@@ -53,15 +53,19 @@ 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 has a `palette` mapping each char to one *kind* of thing, and a grid given in one of three ways: `content` (a multi-line grid string), `fill = "x"` (the whole grid filled with one char — e.g. a uniform floor), or `sparse = [{ x, y, ch }, …]` (an all-spaces grid with just those cells set — e.g. a layer of a few objects). 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.
|
||||
A board is a `[map]` header plus a **single `[grid]`**. The grid has a `palette` mapping each char to one *kind* of thing, and a char map given in one of three ways: `content` (a multi-line grid string), `fill = "x"` (the whole grid filled with one char), or `sparse = [{ x, y, ch }, …]` (an all-spaces grid with just those cells set). The grid holds every solid and most non-solids; each cell holds at most one authored thing. Alongside the grid, a board has three lightweight attributes:
|
||||
|
||||
- **`floor`** — an optional `[map]` attribute (not a layer): `floor = { generator = "grass" }` (a procedural biome), `floor = { tile = 176, fg = "#888", bg = "#444" }` (one fixed glyph), or omitted (a blank/black floor). Drawn beneath everything, shown wherever the grid cell is empty.
|
||||
- **`[[triggers]]`** — invisible, non-solid, script-only objects: `{ x, y, script_name, name?, tags? }`. No glyph, never solid or pushable; they exist only to run a script. At runtime they are ordinary objects in `Board::objects`.
|
||||
- **`[[decorations]]`** — non-solid `(glyph, archetype)` cells placed off the grid, drawn only where the grid cell is empty: `{ x, y, kind, tile?, fg?, bg? }`. The editor does not author these; they exist so a save file can represent a non-solid that ended up sharing a cell with a runtime solid. A solid archetype is rejected.
|
||||
|
||||
```toml
|
||||
# Two terser layer forms, equivalent to a full `content` grid:
|
||||
[[boards.room1.layers]]
|
||||
fill = "g" # whole layer is grass floor
|
||||
palette = { "g" = { kind = "floor", generator = "grass" } }
|
||||
# Two terser grid forms, equivalent to a full `content` grid:
|
||||
[boards.room1.grid]
|
||||
fill = "#" # whole grid is wall
|
||||
palette = { "#" = { kind = "wall" } }
|
||||
|
||||
[[boards.room1.layers]]
|
||||
[boards.room1.grid]
|
||||
sparse = [ { x = 5, y = 3, ch = "C" }, { x = 9, y = 7, ch = "C" } ] # two chests
|
||||
palette = { "C" = { kind = "object", script_name = "chest" } } # spaces are implicitly empty
|
||||
```
|
||||
@@ -92,54 +96,50 @@ fn bump(me, dir) { log(`bumped from ${dir}`); say("Ouch!"); } # dir: the side i
|
||||
name = "Room One"
|
||||
width = 60
|
||||
height = 25
|
||||
floor = { generator = "grass" } # "grass" | "dirt" | "stone", or a fixed glyph, or omit
|
||||
|
||||
# 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 = """
|
||||
...60×25 grid of 'g'/'d'...
|
||||
"""
|
||||
[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
|
||||
|
||||
# Layer 1: terrain, the player, objects and portals. A space is always a
|
||||
# transparent empty cell, so the floor below shows through.
|
||||
[[boards.room1.layers]]
|
||||
# The single grid: terrain, the player, objects and portals. A space is always a
|
||||
# transparent empty cell, so the floor shows through.
|
||||
[boards.room1.grid]
|
||||
content = """
|
||||
############################################################
|
||||
# G @ 1 #
|
||||
############################################################
|
||||
"""
|
||||
[boards.room1.layers.palette]
|
||||
[boards.room1.grid.palette]
|
||||
# A space is always a transparent empty cell — it is never a palette key.
|
||||
"#" = { 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
|
||||
"@" = { kind = "player" } # exactly one across the grid
|
||||
# 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" }
|
||||
|
||||
# Invisible script-only triggers (no glyph, never solid).
|
||||
[[boards.room1.triggers]]
|
||||
x = 40
|
||||
y = 0
|
||||
script_name = "tripwire"
|
||||
```
|
||||
|
||||
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`, `transporter_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). 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.
|
||||
Palette `kind` values: the meta-kinds `empty` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `transporter_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). (`floor` is **no longer** a grid kind — it is the board-level `floor` attribute.) For archetype/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged).
|
||||
|
||||
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); the `fill`/`sparse` forms are always exactly sized. 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(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`). `bump`'s `dir` is the `Direction` the bump came *from* (pointing toward the bumper), fired when *any* solid — the player, another object, or a pushed crate — presses into this object. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately within a hook: each is queued, and **at most one `move` resolves per 250 ms** per object. But each object's ready actions are **applied the moment its hook returns**, before the next object runs — objects are processed in **ascending id order** each tick, so a later object observes the board *after* every lower-id object has already moved (the one exception is actions still stuck behind a `Delay`). When two objects contend for the same cell, **lowest id wins**, and the loser — whose hook ran later — can already see the winner there. The bumped object receives `bump` with the direction the bump came from; `bump`/`send` reactions fire in a follow-up pass that settles fully within the same tick/`try_move`, bounded by a per-invocation guard that fires each `(object, hook, args)` at most once (so a bump/send cycle can't loop forever). The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md).
|
||||
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** in the grid (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). The grid's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. 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 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(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`). `bump`'s `dir` is the `Direction` the bump came *from* (pointing toward the bumper), fired when *any* solid — the player, another object, or a pushed crate — presses into this object. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately within a hook: each is queued, and **at most one `move` resolves per 250 ms** per object. The exception is **`log(s)`**, which is *not* queued — it writes to the game log the instant it is called, so a diagnostic line surfaces even when it sits behind a pending `move`/`delay`. But each object's ready actions are **applied the moment its hook returns**, before the next object runs — objects are processed in **ascending id order** each tick, so a later object observes the board *after* every lower-id object has already moved (the one exception is actions still stuck behind a `Delay`). When two objects contend for the same cell, **lowest id wins**, and the loser — whose hook ran later — can already see the winner there. The bumped object receives `bump` with the direction the bump came from; `bump`/`send` reactions fire in a follow-up pass that settles fully within the same tick/`try_move`, bounded by a per-invocation guard that fires each `(object, hook, args)` at most once (so a bump/send cycle can't loop forever). The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md).
|
||||
|
||||
**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)`) or the per-board `Registry` (which also persists across transitions).
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- **Draw order is a fixed precedence** — `Board::glyph_at` at `(x, y)` returns the first that draws: the player; then an object on the cell (a solid object always, else a visible non-solid); then the grid cell (a solid always, else a non-transparent glyph); then a portal; then a `decoration` (only reachable because the grid cell was empty); then the `floor` glyph; then the canonical black `Empty`. There is one grid, plus the board-level `floor` attribute drawn beneath everything. A cell vacated by a pushed crate is left transparent (`Glyph::transparent()`, tile 0), revealing the floor with no movement-code changes. See `board.rs` / `layer.rs` / `floor.rs`.
|
||||
- **One solid per cell** — at most one solid entity (the player, a solid terrain archetype, *or* a solid object) may occupy a grid 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 single grid holds at most one authored char per cell, so a stacked solid is unrepresentable by construction; 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 and drops a conflicting object. `Board::solid_at` relies on this invariant.
|
||||
- **The grid 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 variants can be reordered or extended without breaking saved games. **Terrain archetypes** (`Wall`, `Crate`, etc.): the `match`es in `behavior()`/`name()`/`default_glyph()` are exhaustive (the compiler flags them), but `TryFrom<&str>` is not — forget its arm and the new name silently loads as `ErrorBlock`. **Script-backed archetypes** (`Builtin`): adding one requires only (1) an entry in the `builtins!` macro invocation in `archetype.rs` and (2) the `.rhai` script file — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, and the expansion pass all derive from the macro output automatically. In both cases, 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.
|
||||
@@ -153,7 +153,7 @@ Today's baked-in assumptions that will need to change (don't make `Board.player`
|
||||
- `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).
|
||||
- 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.
|
||||
- The player is rendered as a hardcoded overlay (`Glyph::player()`); it should become a normal object drawn from `Board::objects`.
|
||||
|
||||
### Other not-yet-implemented threads
|
||||
|
||||
|
||||
+3
-2
@@ -261,7 +261,8 @@ The Registry is shared by the whole board, so namespace per-instance keys by `me
|
||||
Scripts never mutate the world directly. Each write appends an `Action` to the calling object's
|
||||
**output queue**; the moment the hook returns, that object's ready actions are drained and applied
|
||||
by the engine — before the next object's hook runs. The *subject* of a write is implicit and varies
|
||||
by function (see the table).
|
||||
by function (see the table). The one exception is `log(msg)`: it bypasses the queue and writes to the
|
||||
game log immediately, so a diagnostic line is never paced by (or stuck behind) the object's delays.
|
||||
|
||||
| Call | Subject | Cost | Description |
|
||||
|------|---------|------|-------------|
|
||||
@@ -275,7 +276,7 @@ by function (see the table).
|
||||
| `set_color(fg, bg)` | self | 0 | Set both colors. |
|
||||
| `set_tag(target_id, tag, present)` | any object | 0 | Add (`true`) / remove (`false`) a tag. Use `me.id` for self. |
|
||||
| `say(msg)` / `say(msg, secs)` | self | 0 | Speech bubble above this object; default 3 s, or `secs`. Replaces any current bubble. |
|
||||
| `log(msg)` | log | 0 | Append a plain-text line to the game log. |
|
||||
| `log(msg)` | log | 0 | Append a plain-text line to the game log. **Immediate** — unlike the other writes it is *not* queued, so it appears the instant it's called, even behind a pending `move`/`delay`. |
|
||||
| `scroll(lines)` | UI | 0 | Open a full-screen overlay (see below). |
|
||||
| `send(target_id, fn_name [, arg])` | any object | 0 | Call a handler function on another object; optional string/number arg. |
|
||||
| `delay(secs)` | self | — | Insert an explicit pause; integer and float overloads. Adjacent delays merge. |
|
||||
|
||||
+33
-30
@@ -29,34 +29,36 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
||||
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`.
|
||||
- `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`, `grab: bool` (the player walking onto a `solid + grab` thing isn't blocked — it moves onto it and the thing's `grab()` hook fires; this is the *only* grab trigger; `Gem` and `Heart` both set the flag).
|
||||
- `ObjectId = u32` — stable identifier for board objects.
|
||||
- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Cell(Archetype)`, or `Object(ObjectId)`.
|
||||
- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Terrain { glyph, arch }` (a grid cell), or `Object(ObjectId)`. `place(board, x, y)` relocates it (terrain rewrites the destination grid cell). No layer index — the board is one grid.
|
||||
- `PlayerPos { x: i32, y: i32 }` — the player's current position on a board (held as `Board::player`). The broader player *state* (health, gems, keys) lives in [`player::Player`], not here.
|
||||
- `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired.
|
||||
- `PortalDef { name, x, y, target_map, target_entry }` — parsed from map files; runtime-wired (portals switch boards). No `z`.
|
||||
- `LogSink(Rc<RefCell<Vec<LogLine>>>)` — the shared, immediate log channel handed to every `Registerable::register` and the write API. `line(LogLine)` / `error(String)` push straight onto it during hook execution (so script `log()` output and errors are *not* paced by the object's action queue); `take()` drains it. `GameState::drain_log` empties it into `GameState::log` each frame.
|
||||
|
||||
**`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
|
||||
- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained into a batch of actions that `GameState` applies immediately after the object's hook — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `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.
|
||||
- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained into a batch of actions that `GameState` applies immediately after the object's hook — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet<String>`, `name: Option<String>`. A **trigger** is just an `ObjectDef` that is non-solid, non-opaque, non-pushable, glyphless (transparent glyph) and scripted (authored via `[[triggers]]`). 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::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, fill, sparse, palette: HashMap<String, PaletteEntry> }` — serde for one `[[layers]]` entry. The grid comes from exactly one of three optional fields (precedence `content` → `fill` → `sparse`; none ⇒ all spaces): `content` (a multi-line grid string), `fill` (one char filling the whole grid — handy for a uniform floor layer), or `sparse` (a `Vec<SparseCell>` of `{ x, y, ch }` over an otherwise all-spaces grid — handy for a layer of just a few objects). Only `content` can mismatch the board dims (hard error); `fill`/`sparse` are always exactly sized (a bad `fill`/`ch` or out-of-bounds `sparse` cell is a nonfatal error). `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/layer.rs`** — the board grid (palette + char map load unit; file/type name kept for history):
|
||||
- `GridCell = (Glyph, Archetype)` (`pub(crate)`) — one grid cell: its visual and its behavioral class. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the floor/decoration beneath shows through; solidity comes from the archetype, independent of transparency. (There is no longer a `Layer` type — the board is a single `Vec<GridCell>`.)
|
||||
- `GridData { content, fill, sparse, palette: HashMap<String, PaletteEntry> }` — serde for the single `[grid]` block. The char map comes from exactly one of three optional fields (precedence `content` → `fill` → `sparse`; none ⇒ all spaces): `content` (a multi-line grid string), `fill` (one char filling the whole grid), or `sparse` (a `Vec<SparseCell>` of `{ x, y, ch }` over an otherwise all-spaces grid). Only `content` can mismatch the board dims (hard error); `fill`/`sparse` are always exactly sized (a bad `fill`/`ch` or out-of-bounds `sparse` cell is a nonfatal error). `PaletteEntry` is a single flat struct with a `kind: String` discriminator plus all-optional fields (`tile`, `fg`, `bg`, `solid`, `opaque`, `pushable`, `script_name`, `tags`, `name`, `target_map`, `target_entry`); only the fields relevant to the kind are read. (`floor` is **not** a grid kind — the floor is a board attribute; see `floor.rs`/`map_file.rs`.)
|
||||
- `build_grid(data, w, h, &mut Vec<LogLine>) -> Result<(Vec<GridCell>, Vec<Placement>), String>` — resolves the palette (via `resolve_entry`), validates the grid dims (the only hard `Err`), and walks the grid building the cell vec plus a `Vec<Placement>` (`Object(ObjectTemplate, x, y)` / `Portal(PortalTemplate, x, y)` / `Player(x, y)`) for the map loader to resolve.
|
||||
- `resolve_entry` maps `kind` → `Resolved`: `empty` → transparent cell; `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`, `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`).
|
||||
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `grid: Vec<(Glyph, Archetype)>` (the single row-major cell grid; `pub(crate)`), `floor: Floor` (the cosmetic floor attribute — blank / fixed glyph / biome; `pub(crate)`), `decorations: Vec<Decoration>` (`pub(crate)`), `player: Player`, `objects: BTreeMap<ObjectId, ObjectDef>`, `next_object_id: ObjectId`, `portals: Vec<PortalDef>`, `board_script_name: Option<String>`, `load_errors: Vec<LogLine>` (`pub(crate)`), `registry: HashMap<String, RegistryValue>`. Scripts are **not** stored on `Board` — they live in [`World::scripts`].
|
||||
- `Decoration { x, y, glyph, archetype }` (`pub`) — a non-solid `(glyph, archetype)` placed off the grid, drawn only where the grid cell is empty. Normally empty; populated by save files (a runtime solid can end up over a non-solid, which is recorded here). A solid archetype is rejected at load.
|
||||
- `get(x, y) -> &(Glyph, Archetype)` / `get_mut(x, y)` — grid cell access (panics OOB). (No `z` / `layer_count` — the board is one grid.)
|
||||
- `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`, by fixed precedence: the player (`Glyph::player()`); then an object on the cell (a solid object always, else the first visible non-solid); then the grid cell (a solid always, else a non-transparent glyph); then a portal; then a `decoration` (only reached because the grid cell was empty); then `floor.glyph_at(x, y, width)`; then 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 a solid object, then the grid cell's archetype if solid).
|
||||
- `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?
|
||||
- `bump_target(x, y, dir) -> Option<ObjectId>` — the object a move **into** `(x, y)` heading `dir` bumps: the solid object in the target cell, or the one at the end of a chain of pushed crates (walked through in `dir`). Open space, the player, or a wall yield `None`. This is what lets a plain crate — not just an object or the player — trigger a `bump`. Used by `GameState::try_move` / `step_object`; the caller supplies the came-from direction as `dir.opposite()`.
|
||||
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The **player** counts as a blocker for this check. A Rust-side companion read to `apply_shift`; not itself exposed to scripts.
|
||||
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`).
|
||||
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so the floor is revealed). A pushed solid moves within the grid.
|
||||
- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries.
|
||||
- `grab_object_at(x, y) -> Option<ObjectId>` — a solid `grab` object on the cell (the player-walks-onto-it case). `GameState::try_move` uses it to fire `grab()` instead of bumping when the player steps onto a grab thing. Grab is **only** a player-movement event: a grab thing pushed/shifted into the player is treated as an ordinary solid (no special-case). (`remove_object` deletes the object and its `ObjQueue` from the `objects` map; a live `ScriptHost` keeps an unused `Scope` for that id until it is rebuilt — harmless, since hook dispatch iterates the board's *current* ids.)
|
||||
- `apply_shift(cells: &[(i32, i32)]) -> Vec<LogLine>` — rotates the solids occupying a ring of cells one step: each cell's solid moves to the next coordinate in the list, and the last wraps back to the first. Backs the script `shift()` fn. A cell is **immobile** if its solid is unpushable, or is an `HCrate`/`VCrate` asked to move along its forbidden axis; immobility **cascades backward** along the ring (the cell that would feed an immobile cell is also held), so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is `place`d at its target (a displaced object is despawned via `remove_object`). Any out-of-bounds coordinate **rejects the whole shift** (returned as a `LogLine`). The player rotates like any other solid.
|
||||
- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving any floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the existing terrain layer (`terrain_layer_at`, the single non-`Empty` cell) or else the top layer; `Empty` erases — removes every object plus the terrain cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`.
|
||||
- `expand_builtin_archetypes()` — the **single** expansion point: replaces every `Archetype::Builtin(b, alias)` terrain cell with the scripted object it expands to. For each such cell: vacates the terrain slot (→ transparent `Empty`), calls `b.script()` for the embedded source, uses `builtin_tag(arch)` as both the `BUILTIN_<alias>` tag and the `script_name` compile-cache key, and copies the cell's glyph (so palette overrides survive). The object's `pushable`/`grab` flags come from `b.behavior()`. Idempotent (vacated cells become `Archetype::Empty`, so a second pass finds nothing). Called by `TryFrom<MapFile> for Board` after cross-layer validation (disk loads) and again by the editor's `playtest()` on the `World::deep_clone` (so editor-stamped machines, which `place_archetype` writes as inert terrain, also run). Builtin objects get ids after hand-placed ones (layer-then-reading order among themselves).
|
||||
- `clear_solid(x, y)` — replaces the grid cell's solid terrain (if any) with a transparent `Empty`, revealing the floor. `apply_shift(cells: &[(i32, i32)]) -> Vec<LogLine>` — rotates the solids occupying a ring of cells one step: each cell's solid moves to the next coordinate in the list, and the last wraps back to the first. Backs the script `shift()` fn. A cell is **immobile** if its solid is unpushable, or is an `HCrate`/`VCrate` asked to move along its forbidden axis; immobility **cascades backward** along the ring, so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is `place`d at its target (a displaced object is despawned via `remove_object`). Any out-of-bounds coordinate **rejects the whole shift**. The player rotates like any other solid.
|
||||
- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving the floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the grid cell; `Empty` erases — removes every object plus the grid cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`.
|
||||
- `expand_builtin_archetypes()` — the **single** expansion point: replaces every `Archetype::Builtin(b, alias)` grid cell with the scripted object it expands to. For each such cell: vacates the grid cell (→ transparent `Empty`), calls `b.script()` for the embedded source, uses `builtin_tag(arch)` as both the `BUILTIN_<alias>` tag and the `script_name` compile-cache key, and copies the cell's glyph (so palette overrides survive). The object's `pushable`/`grab` flags come from `b.behavior()`. Idempotent (vacated cells become `Archetype::Empty`, so a second pass finds nothing). Called by `TryFrom<MapFile> for Board` after cross-cell validation (disk loads) and again by the editor's `playtest()` on the `World::deep_clone`. Builtin objects get ids after hand-placed ones (reading order among themselves).
|
||||
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
|
||||
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
|
||||
|
||||
@@ -71,30 +73,30 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
||||
- `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one).
|
||||
- `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds).
|
||||
- `Scroll { source: ObjectId, lines: Vec<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`.
|
||||
- `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `alter_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a `ScriptState` bundle — a `Copy` snapshot of `player` plus a shared handle to the active board — passed to the hook as its `state` parameter (Rhai type `State`, read via `state.player`/`state.board`; see `api::state`). Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(dir_from)` on the solid object it presses into — directly or at the end of a chain of crates it shoves (see `Board::bump_target`), where `dir_from` is the side the bump came from; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` — applied immediately — so its `die()`/`alter_gems()`/`alter_health()` take effect before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` expires speech bubbles, then runs each object's `tick(dt)` hook **in ascending id order, applying that object's drained actions before the next object runs** — so a later object sees the board an earlier one just changed (the requested fix; the exception is actions still behind a `Delay`). The workhorse is `apply_actions(Vec<BoardAction>) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` pairs, drop the borrow, then apply the player-stat / bubble / scroll changes), applying each `Action` (`Log` → `log`, `SetTile`/`SetColor` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player.gems`, `AlterHealth(dh)` → `player.health` clamped to `[0, max_health]`, `SetKey` → `player.keys`, `Shift` → `apply_shift`, `Push`/`Teleport` → board moves, `Die` → `remove_object(source)`). It **returns** the `bump`/`send` reactions rather than firing them; after all object hooks, `settle(events, called)` runs them (and any they cascade into) until quiescent, applying each hook's actions as it goes. `called: CalledSet` (a `HashSet<(ObjectId, fn/hook name, arg repr)>`, fresh per `tick`/`try_move`/`run_init`) records every reaction fired and skips repeats, so a bump/send cycle terminates. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
|
||||
- `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `alter_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a `ScriptState` bundle — a `Copy` snapshot of `player` plus a shared handle to the active board — passed to the hook as its `state` parameter (Rhai type `State`, read via `state.player`/`state.board`; see `api::state`). Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(dir_from)` on the solid object it presses into — directly or at the end of a chain of crates it shoves (see `Board::bump_target`), where `dir_from` is the side the bump came from; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` — applied immediately — so its `die()`/`alter_gems()`/`alter_health()` take effect before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` expires speech bubbles, then runs each object's `tick(dt)` hook **in ascending id order, applying that object's drained actions before the next object runs** — so a later object sees the board an earlier one just changed (the requested fix; the exception is actions still behind a `Delay`). The workhorse is `apply_actions(Vec<BoardAction>) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` pairs, drop the borrow, then apply the player-stat / bubble / scroll changes), applying each `Action` (`SetTile`/`SetColor` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player.gems`, `AlterHealth(dh)` → `player.health` clamped to `[0, max_health]`, `SetKey` → `player.keys`, `Shift` → `apply_shift`, `Push`/`Teleport` → board moves, `Die` → `remove_object(source)`). It **returns** the `bump`/`send` reactions rather than firing them; after all object hooks, `settle(events, called)` runs them (and any they cascade into) until quiescent, applying each hook's actions as it goes. `called: CalledSet` (a `HashSet<(ObjectId, fn/hook name, arg repr)>`, fresh per `tick`/`try_move`/`run_init`) records every reaction fired and skips repeats, so a bump/send cycle terminates. `drain_log()` moves the script host's collected log lines — script `log()` output and errors — into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
|
||||
|
||||
**`kiln-core/src/action.rs`** — the `Action` enum and its supporting types:
|
||||
- `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s).
|
||||
- `ScrollLine` (`pub`) — one line of content in a `Scroll` action: `Text(String)` (plain, word-wrapped) or `Choice { choice, display }` (selectable; `choice` is sent back to the source object when the player picks it).
|
||||
- `SendArg` — the optional argument carried by a `Send` action / `send()` call: `None`/`Int`/`Float`/`String`, with `From<Dynamic>`/`Into<Dynamic>` conversions.
|
||||
- `Action` (`pub(crate)`) — deferred mutation emitted by a script, drained from the issuing object's queue and applied by `GameState::apply_actions`: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never drained as an action — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost).
|
||||
- `Action` (`pub(crate)`) — deferred mutation emitted by a script, drained from the issuing object's queue and applied by `GameState::apply_actions`: `Move(Direction)`, `SetTile(u32)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never drained as an action — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost).
|
||||
- `BoardAction { source, action }` — an `Action` drained from an object's queue, tagged with the `ObjectId` that issued it; `GameState` applies a `Vec<BoardAction>` per object.
|
||||
|
||||
**`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/floor.rs`** — the board floor attribute + procedural generators:
|
||||
- `Floor` (`pub`) — a board's cosmetic floor, drawn beneath everything (replaces the old floor *layer*): `Blank` (the canonical black empty shows), `Fixed(Glyph)` (one glyph tiled across the board), or `Biome { generator, glyphs }` (a procedural texture — keeps its `generator` so save re-emits the name, plus a per-cell `glyphs` buffer pre-rolled once at load from `FLOOR_SEED`). `Floor::biome(gen, w, h)` builds and pre-rolls a biome; `Floor::glyph_at(x, y, width) -> Option<Glyph>` is the per-cell lookup used by `Board::glyph_at`; `Default` is `Blank`. Resolved from the map-file `floor = { … }` attribute in `map_file::FloorSpec::resolve`.
|
||||
- `FloorGenerator` (`Grass`/`Dirt`/`Stone`) — procedural textures differing only in color scheme and texture-char probability. `from_name(&str)` parses the map-file name and `name()` is its inverse (for save); `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 biome build, so floors are deterministic/testable and depend only on generator + dimensions.
|
||||
|
||||
**`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.
|
||||
|
||||
**`kiln-core/src/script.rs`** — Rhai scripting **host** (the script-facing types live in `kiln-core/src/api/`, below). The script-author's reference is [`docs/script-api.md`](../docs/script-api.md).
|
||||
- `Registerable` trait — `fn register(engine, error_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`.
|
||||
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap<String, CompiledScript>`), one persistent `Scope` per scripted object (`HashMap<ObjectId, Scope>`), and the error sink. (There is no shared board queue: each hook call drains into a local `Vec<BoardAction>` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the error sink; runs nothing.
|
||||
- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Run one object at a time (the per-object loop lives in `GameState`): `run_tick_on(id, dt)` / `run_init_on(id)` / `run_bump(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec<BoardAction>` they drained** for `GameState` to apply. `run_hook_on_one` builds a fresh `ObjectInfo`, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal.
|
||||
- `Registerable` trait — `fn register(engine, log_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`.
|
||||
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap<String, CompiledScript>`), one persistent `Scope` per scripted object (`HashMap<ObjectId, Scope>`), and the shared `LogSink`. (There is no shared board queue: each hook call drains into a local `Vec<BoardAction>` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the `LogSink`; runs nothing.
|
||||
- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Run one object at a time (the per-object loop lives in `GameState`): `run_tick_on(id, dt)` / `run_init_on(id)` / `run_bump(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec<BoardAction>` they drained** for `GameState` to apply. `run_hook_on_one` builds a fresh `ObjectInfo`, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the shared `LogSink` (drained to the log), not fatal.
|
||||
- `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists.
|
||||
- **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore.
|
||||
- **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`.
|
||||
- **Queue draining:** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(&mut Vec<BoardAction>, dt)` moves its ready run into a caller-supplied `Vec`: it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. The hook runner returns that `Vec` and `GameState` applies it immediately (per object), so scripts mutate without a `&mut GameState` borrow and each object sees the prior object's effects. Errors bypass the queue via the error sink (`take_errors()`).
|
||||
- **Write API** (`register_write_api`) — free functions that (mostly) enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. **`log(s)` is the exception** — it is *not* an `Action`; it writes a line straight to the shared `LogSink`, so it surfaces immediately regardless of the object's queued delays (the write closure takes a `LogSink` clone). `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error immediately to the `LogSink`) and rotates that ring of cells via `Board::apply_shift`.
|
||||
- **Queue draining:** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(&mut Vec<BoardAction>, dt)` moves its ready run into a caller-supplied `Vec`: it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. The hook runner returns that `Vec` and `GameState` applies it immediately (per object), so scripts mutate without a `&mut GameState` borrow and each object sees the prior object's effects. `log()` output and errors bypass the queue entirely via the shared `LogSink` (`take_logs()`), so they are never paced by a `Delay`.
|
||||
- **Constants in scope:** only `Registry` is pushed into each object's scope (the `me`/`state` views are hook *parameters* now). `register_global_constants` registers the `North`/`South`/`East`/`West` `Direction` values and the 16 named colors as a **global module**, visible at any call depth (including Rhai→Rhai calls and helper functions) — unlike scope constants such as `Registry`.
|
||||
- `Registry` (a `Board`-handle wrapper) — `Registry.get(key)` / `set(key, value)` / `get_or(key, default)` read and write the board's `registry: HashMap<String, RegistryValue>`, which **persists across board transitions**. `set(key, ())` removes a key; unsupported value types are ignored; `get_or` only returns the stored value when it matches the default's Rhai type.
|
||||
- **Sender identity uses stable ids:** the issuing `ObjectId` rides the per-call **tag** — `run_*` calls `call_fn_with_options(...with_tag(id)...)` and the write fns read it via `NativeCallContext::tag()` (decoded by `source_of`; id 0, never valid, is the no-source fallback). It matches `Board::objects`' `BTreeMap` keys, so it survives object spawn/destroy/reorder.
|
||||
@@ -107,11 +109,12 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
||||
- `api::object_info` — `ObjectInfo` (a snapshot of one object: `id`, `x`, `y`, a board handle, `script_name`, and a clone of the object's `ObjQueue`), used as both the `me` parameter and the return type of the `Board` lookups (so self and other objects are the **same** type). Getters `x`/`y`/`id`/`name`/`tags`/`glyph`/`waiting`/`queue`; methods `has_tag(tag)`, `delay(secs)`, `can_push(dir)`, `blocked(dir)`. `from_id`/`from_def` build it; `drain(&mut Vec<BoardAction>, dt)` moves the underlying object's ready queued actions into the target `Vec`.
|
||||
- `api::queue` — `ObjQueue(Rc<RefCell<VecDeque<Action>>>)` (Rhai type **`Queue`**), one object's output queue, owned by its `ObjectDef` and reachable as `me.queue`. Rhai surface: getter `length`, methods `clear()`, `delay(secs)`. Rust surface used by the host: `act`, `delay` (merges a trailing delay), `now` (promote the last-enqueued action to the front), `drain` (eat leading delays by `dt`, then move the ready actions into a `&mut Vec<BoardAction>`), `waiting` (front is a `Delay`).
|
||||
|
||||
**`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`).
|
||||
**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the grid work lives in `layer.rs`):
|
||||
- `MapFile { map: MapHeader, grid: GridData, triggers: Vec<TriggerSpec>, decorations: Vec<DecorationSpec> }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `floor: FloorSpec` and `board_script_name`; **no** `player_start` — the player is a `kind = "player"` grid char), the single `[grid]`, and the off-grid `[[triggers]]`/`[[decorations]]` lists. Does **not** include scripts (those live in `World::scripts`).
|
||||
- `FloorSpec { generator?, tile?, fg?, bg? }` → `Floor` via `resolve` (generator → biome + pre-roll; any of tile/fg/bg → fixed glyph; empty → blank; unknown generator → blank + error). `TriggerSpec { x, y, script_name, name?, tags? }` → a non-solid glyphless scripted `ObjectDef`. `DecorationSpec { x, y, kind, tile?, fg?, bg? }` → a `Decoration` (a solid archetype is rejected).
|
||||
- `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. Finally it calls `Board::expand_builtin_archetypes` to turn script-backed archetype cells (pushers/spinners) into their scripted objects (these get ids after the hand-placed objects).
|
||||
- `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).
|
||||
- `impl TryFrom<MapFile> for Board` — the load conversion: builds the single grid via `layer::build_grid` (collecting object/portal/player placements), resolves the floor, then runs the cross-cell validations. **Best-effort/nonfatal**: only a 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 (a conflicting solid object is dropped); object names board-unique (a dup is cleared, via `claim_name`), portal names unique (a dup is dropped). Then `[[triggers]]` load as non-solid glyphless objects (after the grid objects) and `[[decorations]]` as off-grid non-solids. Finally it calls `Board::expand_builtin_archetypes` to turn script-backed archetype cells into their scripted objects.
|
||||
- `impl From<&Board> for MapFile` — save: emits the single `[grid]` (deduping each unique `(Glyph, Archetype)` to a palette char; objects to `kind = "object"`, portals, player), **except** trigger objects (`is_trigger`: non-solid + transparent glyph + scripted + non-builtin) which go to `[[triggers]]`, and decorations to `[[decorations]]`. The floor is written back via `floor_to_spec` — a biome re-emits its `generator` name (procedural floors now round-trip), a fixed glyph its tile/fg/bg.
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//! object that issued it).
|
||||
|
||||
use std::fmt::Debug;
|
||||
use crate::log::LogLine;
|
||||
use crate::utils::{Direction, ObjectId};
|
||||
use color::Rgba8;
|
||||
use rhai::Dynamic;
|
||||
@@ -73,8 +72,6 @@ pub enum Action {
|
||||
Move(Direction),
|
||||
/// Set the source object's glyph tile index.
|
||||
SetTile(u32),
|
||||
/// Append a styled line to the game log.
|
||||
Log(LogLine),
|
||||
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
|
||||
SetTag {
|
||||
target: ObjectId,
|
||||
@@ -134,7 +131,6 @@ impl Debug for Action {
|
||||
match self {
|
||||
Action::Move(dir) => write!(f, "Move({:?})", dir),
|
||||
Action::SetTile(i) => write!(f, "SetTile({i})"),
|
||||
Action::Log(msg) => write!(f, "Log({:?})", msg),
|
||||
Action::SetTag { .. } => write!(f, "SetTag"),
|
||||
Action::Say(_, _) => write!(f, "Say"),
|
||||
Action::Delay(t) => write!(f, "Delay({t})"),
|
||||
|
||||
@@ -19,13 +19,13 @@ use crate::{Board, Direction};
|
||||
use crate::api::object_info::ObjectInfo;
|
||||
use crate::api::registry::Registry;
|
||||
use crate::script::Registerable;
|
||||
use crate::utils::{ErrorSink, ObjectId};
|
||||
use crate::utils::{LogSink, ObjectId};
|
||||
|
||||
/// A read-only handle to the world, exposed to scripts as `Board`.
|
||||
pub type BoardRef = Rc<RefCell<Board>>;
|
||||
|
||||
impl Registerable for BoardRef {
|
||||
fn register(engine: &mut Engine, error_sink: ErrorSink) {
|
||||
fn register(engine: &mut Engine, log_sink: LogSink) {
|
||||
engine.register_type_with_name::<BoardRef>("Board");
|
||||
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
|
||||
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
|
||||
@@ -48,14 +48,14 @@ impl Registerable for BoardRef {
|
||||
// Board.get(id) -> ObjectInfo | () (unknown id logs error)
|
||||
engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic {
|
||||
if id <= 0 {
|
||||
error_sink.error(format!("Board.get: invalid id {id}"));
|
||||
log_sink.error(format!("Board.get: invalid id {id}"));
|
||||
return Dynamic::UNIT;
|
||||
}
|
||||
|
||||
if let Some(obj) = ObjectInfo::from_id(id as ObjectId, board.clone()) {
|
||||
Dynamic::from(obj)
|
||||
} else {
|
||||
error_sink.error(format!("Board.get: no object with id {id}"));
|
||||
log_sink.error(format!("Board.get: no object with id {id}"));
|
||||
Dynamic::UNIT
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::Direction;
|
||||
use crate::action::BoardAction;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::script::Registerable;
|
||||
use crate::utils::{ErrorSink, ObjectId};
|
||||
use crate::utils::{LogSink, ObjectId};
|
||||
|
||||
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
|
||||
/// and `Board.get`. Passed by value — scripts read fields, not a live reference.
|
||||
@@ -79,7 +79,7 @@ impl ObjectInfo {
|
||||
}
|
||||
|
||||
impl Registerable for ObjectInfo {
|
||||
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
|
||||
fn register(engine: &mut Engine, _log_sink: LogSink) {
|
||||
engine.register_type_with_name::<ObjectInfo>("ObjectInfo")
|
||||
.register_get("x", |obj: &mut ObjectInfo| obj.x)
|
||||
.register_get("y", |obj: &mut ObjectInfo| obj.y)
|
||||
|
||||
@@ -2,7 +2,7 @@ use rhai::Engine;
|
||||
use crate::api::board::BoardRef;
|
||||
use crate::player::PlayerRef;
|
||||
use crate::script::Registerable;
|
||||
use crate::utils::ErrorSink;
|
||||
use crate::utils::LogSink;
|
||||
|
||||
/// GameState stores player state but Board stores its position, and we want one
|
||||
/// object to register with Rhai
|
||||
@@ -10,7 +10,7 @@ use crate::utils::ErrorSink;
|
||||
pub struct PlayerWithPos(pub PlayerRef, pub BoardRef);
|
||||
|
||||
impl Registerable for PlayerWithPos {
|
||||
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
|
||||
fn register(engine: &mut Engine, _log_sink: LogSink) {
|
||||
engine.register_type_with_name::<PlayerWithPos>("Player")
|
||||
.register_get("gems", |player: &mut PlayerWithPos| player.0.borrow().gems)
|
||||
.register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health)
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::rc::Rc;
|
||||
use rhai::{Dynamic, Engine};
|
||||
use crate::action::{Action, BoardAction};
|
||||
use crate::script::Registerable;
|
||||
use crate::utils::{ErrorSink, ObjectId};
|
||||
use crate::utils::{LogSink, ObjectId};
|
||||
|
||||
/// A single object's output queue.
|
||||
#[derive(Clone)]
|
||||
@@ -81,7 +81,7 @@ impl ObjQueue {
|
||||
}
|
||||
|
||||
impl Registerable for ObjQueue {
|
||||
fn register(engine: &mut Engine, error_sink: ErrorSink) {
|
||||
fn register(engine: &mut Engine, log_sink: LogSink) {
|
||||
engine.register_type_with_name::<ObjQueue>("Queue");
|
||||
engine.register_get("length", |q: &mut ObjQueue| q.0.borrow().len() as i64);
|
||||
engine.register_fn("clear", ObjQueue::clear);
|
||||
@@ -99,7 +99,7 @@ impl Registerable for ObjQueue {
|
||||
|
||||
match secs {
|
||||
Ok(secs) => q.delay(secs),
|
||||
Err(msg) => error_sink.error(msg.to_string())
|
||||
Err(msg) => log_sink.error(msg.to_string())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use rhai::{Dynamic, Engine, ImmutableString};
|
||||
use crate::api::board::BoardRef;
|
||||
use crate::script::Registerable;
|
||||
use crate::utils::{ErrorSink, RegistryValue};
|
||||
use crate::utils::{LogSink, RegistryValue};
|
||||
|
||||
/// The board's script registry, pushed into scope as the constant `Registry`.
|
||||
/// `get`/`set`/`get_or` methods let scripts read and write per-board key→value pairs
|
||||
@@ -11,7 +11,7 @@ use crate::utils::{ErrorSink, RegistryValue};
|
||||
pub struct Registry(pub BoardRef);
|
||||
|
||||
impl Registerable for Registry {
|
||||
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
|
||||
fn register(engine: &mut Engine, _log_sink: LogSink) {
|
||||
engine.register_type_with_name::<Registry>("Registry");
|
||||
|
||||
// Registry.get(key) -> Dynamic — returns () if the key is absent.
|
||||
|
||||
+170
-213
@@ -1,12 +1,33 @@
|
||||
use crate::archetype::Archetype;
|
||||
use crate::floor::Floor;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::layer::Layer;
|
||||
use crate::log::LogLine;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::Direction;
|
||||
use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
/// A non-solid `(glyph, archetype)` placed at a board coordinate, **outside** the
|
||||
/// main grid, drawn only when the grid cell at `(x, y)` is empty.
|
||||
///
|
||||
/// Decorations exist so a single file format can serve both world files and save
|
||||
/// files: at authoring time every cell holds at most one thing, but during play a
|
||||
/// runtime solid can end up sharing a cell with a non-solid that was already there.
|
||||
/// The non-solid is recorded here (the grid keeps the solid). The editor does not
|
||||
/// place decorations; they are a save/runtime concern. A decoration's archetype is
|
||||
/// always non-solid (a solid one is rejected at load).
|
||||
#[derive(Clone)]
|
||||
pub struct Decoration {
|
||||
/// Column (0-indexed).
|
||||
pub x: usize,
|
||||
/// Row (0-indexed).
|
||||
pub y: usize,
|
||||
/// The decoration's visual.
|
||||
pub glyph: Glyph,
|
||||
/// The decoration's (non-solid) archetype.
|
||||
pub archetype: Archetype,
|
||||
}
|
||||
|
||||
/// The complete state of one game board (a single room or screen).
|
||||
///
|
||||
/// `Board` is the central data structure of the engine, equivalent to a
|
||||
@@ -37,12 +58,18 @@ pub struct Board {
|
||||
pub width: usize,
|
||||
/// Height of the board in cells.
|
||||
pub height: usize,
|
||||
/// 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>,
|
||||
/// The single row-major grid of `(Glyph, Archetype)` cells (`width * height`),
|
||||
/// holding every solid and most non-solids. A transparent cell (glyph tile 0)
|
||||
/// draws nothing, revealing a [`decoration`](Board::decorations) or the
|
||||
/// [`floor`](Board::floor) beneath. Access a cell with [`Board::get`]/
|
||||
/// [`Board::get_mut`] by `(x, y)`.
|
||||
pub(crate) grid: Vec<(Glyph, Archetype)>,
|
||||
/// The board's cosmetic floor (blank / one fixed glyph / a biome), drawn beneath
|
||||
/// everything. Replaces the old dedicated floor layer.
|
||||
pub(crate) floor: Floor,
|
||||
/// Non-solid things placed off the main grid, drawn only where the grid cell is
|
||||
/// empty (see [`Decoration`]). Normally empty; populated by save files.
|
||||
pub(crate) decorations: Vec<Decoration>,
|
||||
/// Current player position on this board. See [`PlayerPos`] for caveats
|
||||
/// about its future. Game-global player *stats* live in [`crate::player::Player`].
|
||||
pub player: PlayerPos,
|
||||
@@ -75,98 +102,91 @@ pub struct Board {
|
||||
}
|
||||
|
||||
impl Board {
|
||||
/// Number of draw layers on this board (≥ 1 for a loaded board).
|
||||
pub fn layer_count(&self) -> usize {
|
||||
self.layers.len()
|
||||
}
|
||||
|
||||
/// Return a list of all `ObjectId`s currently on the board.
|
||||
pub fn all_ids(&self) -> Vec<ObjectId> {
|
||||
self.objects.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Returns a reference to the cell at `(x, y)` on layer `z`.
|
||||
/// Returns a reference to the `(Glyph, Archetype)` cell at `(x, y)`.
|
||||
///
|
||||
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are
|
||||
/// out of bounds.
|
||||
pub fn get(&self, z: usize, x: usize, y: usize) -> &(Glyph, Archetype) {
|
||||
&self.layers[z].cells[y * self.width + x]
|
||||
/// Panics if `x` or `y` are out of bounds.
|
||||
pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) {
|
||||
&self.grid[y * self.width + x]
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the cell at `(x, y)` on layer `z`.
|
||||
/// Returns a mutable reference to the cell at `(x, y)`.
|
||||
///
|
||||
/// 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) {
|
||||
/// Panics if `x` or `y` are out of bounds.
|
||||
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
|
||||
let w = self.width;
|
||||
&mut self.layers[z].cells[y * w + x]
|
||||
&mut self.grid[y * w + x]
|
||||
}
|
||||
|
||||
/// Replace the solid (if any) at `(x, y)` with `Empty`
|
||||
/// Replace the solid terrain (if any) at `(x, y)` with a transparent `Empty`
|
||||
/// cell, revealing the floor beneath.
|
||||
pub fn clear_solid(&mut self, x: usize, y: usize) {
|
||||
if self.in_bounds((x as i64, y as i64))
|
||||
&& let Some(z) = self.solid_cell_layer(x, y) {
|
||||
*self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty)
|
||||
}
|
||||
if self.in_bounds((x as i64, y as i64)) && self.get(x, y).1.behavior().solid {
|
||||
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the glyph to display at `(x, y)`, honoring layer draw order.
|
||||
/// Returns the glyph to display at `(x, y)`.
|
||||
///
|
||||
/// 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:
|
||||
/// With a single grid the draw order is a fixed precedence (no layer walk):
|
||||
///
|
||||
/// 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`).
|
||||
/// 1. the player (drawn on top; not part of the grid yet);
|
||||
/// 2. an object at the cell — a solid object always, otherwise the first
|
||||
/// non-transparent non-solid object (`tile != 0`, so invisible objects exist);
|
||||
/// 3. the grid cell `(glyph, arch)` — a solid always draws, a non-solid only when
|
||||
/// visible (`tile != 0`);
|
||||
/// 4. a portal at the cell (portals sit on a transparent grid cell);
|
||||
/// 5. a [`decoration`](Board::decorations) at the cell (reached only because the
|
||||
/// grid cell was empty);
|
||||
/// 6. the [`floor`](Board::floor) glyph, if any;
|
||||
/// 7. the canonical black `Empty` glyph.
|
||||
///
|
||||
/// 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 {
|
||||
// The player is rendered above the whole stack (see the `Player` notes).
|
||||
// The player is rendered above everything (see the `Player` notes).
|
||||
if self.player.x == x as i64 && self.player.y == y as i64 {
|
||||
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);
|
||||
}
|
||||
// Objects: a solid object always draws; otherwise the first non-transparent
|
||||
// non-solid object (lets invisible trigger objects exist).
|
||||
let mut nonsolid: Option<Glyph> = None;
|
||||
for o in self.objects.values().filter(|o| o.x == x && o.y == y) {
|
||||
if o.solid {
|
||||
return 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;
|
||||
if nonsolid.is_none() && o.glyph.tile != 0 {
|
||||
nonsolid = Some(o.glyph);
|
||||
}
|
||||
}
|
||||
if let Some(g) = nonsolid {
|
||||
return g;
|
||||
}
|
||||
|
||||
// Nothing on any layer: the canonical black empty cell.
|
||||
Archetype::Empty.default_glyph()
|
||||
// The grid cell: a solid always draws; a non-solid only if visible.
|
||||
let (glyph, arch) = self.get(x, y);
|
||||
if arch.behavior().solid || glyph.tile != 0 {
|
||||
return *glyph;
|
||||
}
|
||||
|
||||
// A portal sits on its (transparent) grid cell.
|
||||
if self.portals.iter().any(|p| p.x == x && p.y == y) {
|
||||
return PortalDef::default_glyph();
|
||||
}
|
||||
|
||||
// The grid cell was empty: a decoration may show here.
|
||||
if let Some(d) = self.decorations.iter().find(|d| d.x == x && d.y == y) {
|
||||
return d.glyph;
|
||||
}
|
||||
|
||||
// Then the floor, else the canonical black empty cell.
|
||||
self.floor
|
||||
.glyph_at(x, y, self.width)
|
||||
.unwrap_or_else(|| Archetype::Empty.default_glyph())
|
||||
}
|
||||
|
||||
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
|
||||
@@ -218,20 +238,14 @@ impl Board {
|
||||
};
|
||||
return Some(Solid::object_at(x, y, id, behavior));
|
||||
}
|
||||
// Otherwise some layer's terrain archetype may be solid (e.g. a wall).
|
||||
if let Some(z) = self.solid_cell_layer(x, y) {
|
||||
let (glyph, arch) = *self.get(z, x, y);
|
||||
return Some(Solid::terrain_at(x, y, z, glyph, arch));
|
||||
// Otherwise the grid cell's terrain archetype may be solid (e.g. a wall).
|
||||
let (glyph, arch) = *self.get(x, y);
|
||||
if arch.behavior().solid {
|
||||
return Some(Solid::terrain_at(x, y, glyph, arch));
|
||||
}
|
||||
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 `true` if a mover can enter `(x, y)` — i.e. no solid occupies it.
|
||||
///
|
||||
/// Convenience inverse of [`solid_at`](Board::solid_at).
|
||||
@@ -381,10 +395,9 @@ impl Board {
|
||||
|
||||
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
|
||||
///
|
||||
/// 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.
|
||||
/// A solid object is relocated; otherwise the solid terrain archetype (a crate)
|
||||
/// is moved, leaving a transparent cell behind so the floor shows through. The
|
||||
/// caller guarantees the destination is already clear.
|
||||
fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) {
|
||||
let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize);
|
||||
let Some(solid) = self.solid_at(x, y) else {
|
||||
@@ -393,8 +406,8 @@ impl Board {
|
||||
// A terrain cell leaves a transparent cell behind (revealing any floor); the
|
||||
// player and objects carry no grid cell, so there is nothing to vacate. `place`
|
||||
// captured the glyph/arch, so clearing the source first is safe.
|
||||
if let Some(z) = self.solid_cell_layer(x, y) {
|
||||
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||
if self.get(x, y).1.behavior().solid {
|
||||
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||
}
|
||||
solid.place(self, tx, ty);
|
||||
}
|
||||
@@ -465,23 +478,19 @@ impl Board {
|
||||
/// drawing tools cannot place, remove, or alter a floor):
|
||||
///
|
||||
/// - **Terrain** (`arch != Empty`, always solid today): removes any solid object
|
||||
/// already in the cell, then writes `(glyph, arch)` into the cell's terrain.
|
||||
/// - **Erase** (`arch == Empty`): removes the cell's terrain *and* every object in
|
||||
/// it, leaving the floor (a visible `Empty` cell on a lower layer) in place.
|
||||
/// already in the cell, then writes `(glyph, arch)` into the grid cell.
|
||||
/// - **Erase** (`arch == Empty`): removes the grid cell's terrain *and* every
|
||||
/// object in it, leaving the floor beneath in place.
|
||||
///
|
||||
/// Terrain is written to the cell's existing terrain layer (the single non-`Empty`
|
||||
/// archetype across layers, if any) or else the top layer; a vacated terrain cell
|
||||
/// becomes a transparent `Empty` so a lower floor shows through. Panics if `(x, y)`
|
||||
/// is out of bounds.
|
||||
/// A vacated grid cell becomes a transparent `Empty` so the floor shows through.
|
||||
/// Panics if `(x, y)` is out of bounds.
|
||||
pub fn place_archetype(&mut self, x: usize, y: usize, arch: Archetype, glyph: Glyph) {
|
||||
if arch == Archetype::Empty {
|
||||
// Erase: drop every object in the cell and clear its terrain (keep floor).
|
||||
// Erase: drop every object in the cell and clear its grid cell (keep floor).
|
||||
for id in self.object_ids_at(x, y) {
|
||||
self.objects.remove(&id);
|
||||
}
|
||||
if let Some(z) = self.terrain_layer_at(x, y) {
|
||||
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||
}
|
||||
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -489,10 +498,7 @@ impl Board {
|
||||
if let Some(id) = self.solid_object_id_at(x, y) {
|
||||
self.objects.remove(&id);
|
||||
}
|
||||
// Reuse the existing terrain layer if the cell already has terrain, else the
|
||||
// top layer (so the new wall draws above any floor on a lower layer).
|
||||
let z = self.terrain_layer_at(x, y).unwrap_or(self.layers.len() - 1);
|
||||
*self.get_mut(z, x, y) = (glyph, arch);
|
||||
*self.get_mut(x, y) = (glyph, arch);
|
||||
}
|
||||
|
||||
/// Replaces every terrain cell whose archetype is script-backed (e.g. a
|
||||
@@ -509,26 +515,23 @@ impl Board {
|
||||
/// already loaded as objects are untouched, so it is safe to call more than once.
|
||||
pub fn expand_builtin_archetypes(&mut self) {
|
||||
use crate::builtin_scripts::builtin_tag;
|
||||
// Collect first: the loop below mutates both layers and the object map.
|
||||
let mut found: Vec<(usize, usize, usize, Glyph, Archetype)> = Vec::new();
|
||||
for z in 0..self.layers.len() {
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
let (glyph, arch) = *self.get(z, x, y);
|
||||
if matches!(arch, Archetype::Builtin(_, _)) {
|
||||
found.push((z, x, y, glyph, arch));
|
||||
}
|
||||
// Collect first: the loop below mutates both the grid and the object map.
|
||||
let mut found: Vec<(usize, usize, Glyph, Archetype)> = Vec::new();
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
let (glyph, arch) = *self.get(x, y);
|
||||
if matches!(arch, Archetype::Builtin(_, _)) {
|
||||
found.push((x, y, glyph, arch));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (z, x, y, glyph, arch) in found {
|
||||
// Vacate the terrain cell (revealing any floor beneath), then spawn the
|
||||
for (x, y, glyph, arch) in found {
|
||||
// Vacate the grid cell (revealing any floor beneath), then spawn the
|
||||
// object — mirroring `resolve_entry`'s object template.
|
||||
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||
let Archetype::Builtin(b, _alias) = arch else { continue };
|
||||
let beh = b.behavior();
|
||||
let mut obj = ObjectDef::new(x, y);
|
||||
obj.z = z;
|
||||
obj.glyph = glyph;
|
||||
obj.solid = beh.solid;
|
||||
obj.opaque = beh.opaque;
|
||||
@@ -617,13 +620,6 @@ impl Board {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
|
||||
/// (the cell's single terrain archetype, if any). By the one-solid-per-cell
|
||||
/// invariant there is at most one such layer.
|
||||
fn terrain_layer_at(&self, x: usize, y: usize) -> Option<usize> {
|
||||
(0..self.layers.len()).find(|&z| self.get(z, x, y).1 != Archetype::Empty)
|
||||
}
|
||||
|
||||
/// Clear the queues of all objects on this board: called when entering a board, objects
|
||||
/// don't retain their state across board visits (they get initialized again, but can
|
||||
/// store things in the board registry)
|
||||
@@ -638,20 +634,20 @@ impl Board {
|
||||
pub(crate) mod tests {
|
||||
use super::Board;
|
||||
use crate::archetype::{Archetype, Builtin};
|
||||
use crate::floor::Floor;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::layer::Layer;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::Direction;
|
||||
use crate::utils::{ObjectId, PlayerPos};
|
||||
use color::Rgba8;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// 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.
|
||||
/// Builds an all-empty `w×h` board with the given player position and objects.
|
||||
/// 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.
|
||||
/// The grid is fully transparent (a blank floor), so terrain stamped via
|
||||
/// [`crate_at`] etc. lands on the single grid. Use [`add_floor`] to give the
|
||||
/// board a visible fixed floor underneath.
|
||||
pub(crate) fn open_board(
|
||||
w: usize,
|
||||
h: usize,
|
||||
@@ -669,9 +665,9 @@ pub(crate) mod tests {
|
||||
name: "test".into(),
|
||||
width: w,
|
||||
height: h,
|
||||
layers: vec![Layer {
|
||||
cells: vec![(Glyph::transparent(), Archetype::Empty); w * h],
|
||||
}],
|
||||
grid: vec![(Glyph::transparent(), Archetype::Empty); w * h],
|
||||
floor: Floor::Blank,
|
||||
decorations: Vec::new(),
|
||||
player: PlayerPos {
|
||||
x: player.0,
|
||||
y: player.1,
|
||||
@@ -685,42 +681,25 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a visible floor layer (filled with `glyph`) below everything,
|
||||
/// bumping existing terrain and objects up one layer.
|
||||
/// Gives the board a uniform fixed floor glyph (the single-grid replacement for
|
||||
/// the old separate floor 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;
|
||||
}
|
||||
board.floor = Floor::Fixed(glyph);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Stamps a crate cell onto the grid.
|
||||
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
|
||||
let z = top(board);
|
||||
*board.get_mut(z, x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
|
||||
*board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
|
||||
}
|
||||
|
||||
/// Stamps a wall cell onto the board's top layer.
|
||||
/// Stamps a wall cell onto the grid.
|
||||
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
|
||||
let z = top(board);
|
||||
*board.get_mut(z, x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
||||
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
||||
}
|
||||
|
||||
/// Stamps an arbitrary archetype cell onto the board's top layer.
|
||||
/// Stamps an arbitrary archetype cell onto the grid.
|
||||
pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
|
||||
let z = top(board);
|
||||
*board.get_mut(z, x, y) = (arch.default_glyph(), arch);
|
||||
*board.get_mut(x, y) = (arch.default_glyph(), arch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -790,8 +769,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(0, 1, 0).1, Archetype::Crate); // no mutation
|
||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation
|
||||
assert_eq!(board.get(2, 0).1, Archetype::Empty);
|
||||
|
||||
let mut board = open_board(3, 1, (0, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
@@ -808,7 +787,7 @@ pub(crate) mod tests {
|
||||
// Crate with open space ahead: shiftable.
|
||||
crate_at(&mut board, 0, 0);
|
||||
assert!(board.can_shift(0, 0, Direction::East));
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // read-only
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Crate); // read-only
|
||||
|
||||
// Crate with another pushable crate ahead: still shiftable (unlike can_push,
|
||||
// which would follow the chain to the wall and fail).
|
||||
@@ -854,8 +833,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(0, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!((board.player.x, board.player.y), (3, 0));
|
||||
}
|
||||
|
||||
@@ -867,7 +846,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(0, 1, 0).1, Archetype::Crate);
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Crate);
|
||||
assert_eq!((board.player.x, board.player.y), (2, 0));
|
||||
}
|
||||
|
||||
@@ -890,7 +869,7 @@ pub(crate) mod tests {
|
||||
a: 255,
|
||||
},
|
||||
};
|
||||
// Floor on a lower layer, a wall on the top (terrain) layer at (0,0).
|
||||
// A fixed floor attribute, a wall on the grid 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.
|
||||
@@ -898,29 +877,9 @@ pub(crate) mod tests {
|
||||
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 place_wall_keeps_floor_and_removes_solid_object() {
|
||||
// Floor on layer 0, terrain layer on top; a solid object sits at (1,0).
|
||||
// A fixed floor attribute; a solid object sits on the grid at (1,0).
|
||||
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
|
||||
let floor = Glyph {
|
||||
tile: '.' as u32,
|
||||
@@ -931,9 +890,8 @@ pub(crate) mod tests {
|
||||
let wall = Archetype::Wall.default_glyph();
|
||||
board.place_archetype(1, 0, Archetype::Wall, wall);
|
||||
|
||||
// The wall landed on the terrain (top) layer; the floor below is untouched.
|
||||
assert_eq!(board.get(1, 1, 0), &(wall, Archetype::Wall));
|
||||
assert_eq!(board.get(0, 1, 0).0, floor);
|
||||
// The wall landed on the grid; the floor attribute is untouched.
|
||||
assert_eq!(board.get(1, 0), &(wall, Archetype::Wall));
|
||||
// The solid object that was there is gone.
|
||||
assert!(board.object_ids_at(1, 0).is_empty());
|
||||
assert_eq!(board.glyph_at(1, 0), wall);
|
||||
@@ -941,17 +899,17 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn place_wall_overwrites_existing_terrain_in_place() {
|
||||
// A crate already occupies the top layer at (1,0).
|
||||
// A crate already occupies the grid at (1,0).
|
||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
let wall = Archetype::Wall.default_glyph();
|
||||
board.place_archetype(1, 0, Archetype::Wall, wall);
|
||||
assert_eq!(board.get(0, 1, 0), &(wall, Archetype::Wall));
|
||||
assert_eq!(board.get(1, 0), &(wall, Archetype::Wall));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erase_removes_terrain_and_objects_but_keeps_floor() {
|
||||
// Floor, a wall on the terrain layer, and a (non-solid) object all at (1,0).
|
||||
// A fixed floor attribute, a wall on the grid, and a (non-solid) object at (1,0).
|
||||
let mut obj = ObjectDef::new(1, 0);
|
||||
obj.solid = false;
|
||||
let mut board = open_board(3, 1, (2, 0), vec![obj]);
|
||||
@@ -964,13 +922,12 @@ pub(crate) mod tests {
|
||||
|
||||
board.place_archetype(1, 0, Archetype::Empty, Glyph::transparent());
|
||||
|
||||
// Terrain cleared to transparent Empty; object removed; floor still there.
|
||||
// Grid cell cleared to transparent Empty; object removed; floor still there.
|
||||
assert_eq!(
|
||||
board.get(1, 1, 0),
|
||||
board.get(1, 0),
|
||||
&(Glyph::transparent(), Archetype::Empty)
|
||||
);
|
||||
assert!(board.object_ids_at(1, 0).is_empty());
|
||||
assert_eq!(board.get(0, 1, 0).0, floor);
|
||||
assert_eq!(board.glyph_at(1, 0), floor);
|
||||
}
|
||||
|
||||
@@ -990,7 +947,7 @@ pub(crate) mod tests {
|
||||
board.expand_builtin_archetypes();
|
||||
|
||||
// The terrain cell is vacated and a scripted object takes its place.
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Empty);
|
||||
let obj = board.objects.values().next().expect("spinner object");
|
||||
assert_eq!((obj.x, obj.y), (0, 0));
|
||||
assert!(obj.solid);
|
||||
@@ -1019,7 +976,7 @@ pub(crate) mod tests {
|
||||
crate_at(&mut board, 0, 0);
|
||||
let errs = board.apply_shift(&[(0, 0), (9, 0)]);
|
||||
assert_eq!(errs.len(), 1);
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Crate); // unchanged
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1043,11 +1000,11 @@ pub(crate) mod tests {
|
||||
// Crate(3,0)→(4,0), Crate(4,0)→(0,0) wrap
|
||||
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]);
|
||||
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // wrapped from (4,0)
|
||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // moved from (0,0)
|
||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Wall); // blocked, immobile
|
||||
assert_eq!(board.get(0, 3, 0).1, Archetype::Empty); // cleared, crate moved
|
||||
assert_eq!(board.get(0, 4, 0).1, Archetype::Crate); // moved from (3,0)
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Crate); // wrapped from (4,0)
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Crate); // moved from (0,0)
|
||||
assert_eq!(board.get(2, 0).1, Archetype::Wall); // blocked, immobile
|
||||
assert_eq!(board.get(3, 0).1, Archetype::Empty); // cleared, crate moved
|
||||
assert_eq!(board.get(4, 0).1, Archetype::Crate); // moved from (3,0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1066,9 +1023,9 @@ pub(crate) mod tests {
|
||||
|
||||
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
|
||||
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // HCrate moved out
|
||||
assert_eq!(board.get(0, 1, 0).1, Archetype::HCrate); // moved from (0,0)
|
||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); // moved from (1,0)
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Empty); // HCrate moved out
|
||||
assert_eq!(board.get(1, 0).1, Archetype::HCrate); // moved from (0,0)
|
||||
assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0)
|
||||
}
|
||||
|
||||
// Subcase B: VCrate stays; Crate behind it still moves
|
||||
@@ -1084,9 +1041,9 @@ pub(crate) mod tests {
|
||||
// Crate at (1,0) is NOT in blocked, so it moves.
|
||||
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
|
||||
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::VCrate); // immobile
|
||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty); // crate moved out
|
||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); // moved from (1,0)
|
||||
assert_eq!(board.get(0, 0).1, Archetype::VCrate); // immobile
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Empty); // crate moved out
|
||||
assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,9 +1062,9 @@ pub(crate) mod tests {
|
||||
|
||||
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
|
||||
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // VCrate moved out
|
||||
assert_eq!(board.get(0, 0, 1).1, Archetype::VCrate); // moved from (0,0)
|
||||
assert_eq!(board.get(0, 0, 2).1, Archetype::Crate); // moved from (0,1)
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Empty); // VCrate moved out
|
||||
assert_eq!(board.get(0, 1).1, Archetype::VCrate); // moved from (0,0)
|
||||
assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1)
|
||||
}
|
||||
|
||||
// Subcase B: HCrate stays; Crate behind it still moves
|
||||
@@ -1123,9 +1080,9 @@ pub(crate) mod tests {
|
||||
// Crate at (0,1) is NOT in blocked, so it moves.
|
||||
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
|
||||
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::HCrate); // immobile
|
||||
assert_eq!(board.get(0, 0, 1).1, Archetype::Empty); // crate moved out
|
||||
assert_eq!(board.get(0, 0, 2).1, Archetype::Crate); // moved from (0,1)
|
||||
assert_eq!(board.get(0, 0).1, Archetype::HCrate); // immobile
|
||||
assert_eq!(board.get(0, 1).1, Archetype::Empty); // crate moved out
|
||||
assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+67
-1
@@ -12,7 +12,63 @@
|
||||
|
||||
use crate::glyph::Glyph;
|
||||
use color::Rgba8;
|
||||
use tinyrand::{Probability, Rand, StdRand};
|
||||
use tinyrand::{Probability, Rand, Seeded, StdRand};
|
||||
|
||||
/// A board's floor: the cosmetic backdrop drawn beneath everything, replacing the
|
||||
/// old dedicated floor *layer*. A board has exactly one [`Floor`] (see
|
||||
/// [`Board::floor`](crate::board::Board)), given in the map file's `[map]` header
|
||||
/// as an optional `floor = { … }` attribute.
|
||||
///
|
||||
/// Three forms: [`Blank`](Floor::Blank) (the canonical empty/black cell shows
|
||||
/// through), [`Fixed`](Floor::Fixed) (one glyph tiled across the whole board), or
|
||||
/// [`Biome`](Floor::Biome) (a procedural [`FloorGenerator`] texture). A biome keeps
|
||||
/// its generator (so save re-emits the generator name) alongside a per-cell glyph
|
||||
/// buffer pre-rolled once at load from [`FLOOR_SEED`] — deterministic, and the
|
||||
/// direct replacement for the old per-cell floor-layer rolling.
|
||||
#[derive(Clone)]
|
||||
pub enum Floor {
|
||||
/// No floor: the canonical black empty cell shows.
|
||||
Blank,
|
||||
/// A single fixed glyph tiled across the whole board.
|
||||
Fixed(Glyph),
|
||||
/// A procedural biome floor: the `generator` (retained for save) plus a
|
||||
/// `glyphs` buffer holding one pre-rolled glyph per cell (row-major).
|
||||
Biome {
|
||||
/// The generator this biome was built from; re-emitted on save.
|
||||
generator: FloorGenerator,
|
||||
/// One pre-rolled glyph per cell (`width * height`, row-major).
|
||||
glyphs: Vec<Glyph>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for Floor {
|
||||
/// A board with no declared floor is [`Floor::Blank`].
|
||||
fn default() -> Self {
|
||||
Floor::Blank
|
||||
}
|
||||
}
|
||||
|
||||
impl Floor {
|
||||
/// Builds a [`Floor::Biome`] for a `width × height` board, pre-rolling one glyph
|
||||
/// per cell from a [`FLOOR_SEED`]-seeded PRNG (so the result is deterministic and
|
||||
/// depends only on the board dimensions + generator).
|
||||
pub(crate) fn biome(generator: FloorGenerator, width: usize, height: usize) -> Floor {
|
||||
let mut rng = StdRand::seed(FLOOR_SEED);
|
||||
let glyphs = (0..width * height).map(|_| generator.generate(&mut rng)).collect();
|
||||
Floor::Biome { generator, glyphs }
|
||||
}
|
||||
|
||||
/// The floor glyph to draw at `(x, y)`, or `None` for [`Floor::Blank`].
|
||||
///
|
||||
/// `width` is the board width, needed to index a biome's row-major buffer.
|
||||
pub(crate) fn glyph_at(&self, x: usize, y: usize, width: usize) -> Option<Glyph> {
|
||||
match self {
|
||||
Floor::Blank => None,
|
||||
Floor::Fixed(g) => Some(*g),
|
||||
Floor::Biome { glyphs, .. } => glyphs.get(y * width + x).copied(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed seed for the floor PRNG, so a board's generated floor is deterministic
|
||||
/// for a given map (stable across reloads within a run, and testable). The layer
|
||||
@@ -46,6 +102,16 @@ impl FloorGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
/// The generator's map-file name (inverse of [`from_name`](FloorGenerator::from_name)),
|
||||
/// re-emitted on save so a biome floor round-trips.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
FloorGenerator::Grass => "grass",
|
||||
FloorGenerator::Dirt => "dirt",
|
||||
FloorGenerator::Stone => "stone",
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates one random floor [`Glyph`] for this generator.
|
||||
///
|
||||
/// Picks a background ground color within the generator's scheme, then with
|
||||
|
||||
+67
-40
@@ -135,7 +135,7 @@ impl GameState {
|
||||
board_transition: None,
|
||||
player
|
||||
};
|
||||
state.drain_errors();
|
||||
state.drain_log();
|
||||
state
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ impl GameState {
|
||||
// Fire any bump/send reactions, then flush errors.
|
||||
let mut called = CalledSet::new();
|
||||
self.settle(ev, &mut called);
|
||||
self.drain_errors();
|
||||
self.drain_log();
|
||||
}
|
||||
|
||||
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
|
||||
@@ -238,15 +238,16 @@ impl GameState {
|
||||
// Fire the bump/send reactions those ticks triggered, then flush errors.
|
||||
let mut called = CalledSet::new();
|
||||
self.settle(ev, &mut called);
|
||||
self.drain_errors();
|
||||
self.drain_log();
|
||||
}
|
||||
|
||||
/// Drains errors collected by the script host into the game log.
|
||||
fn drain_errors(&mut self) {
|
||||
/// Drains the log lines collected by the script host — script `log()` output
|
||||
/// plus engine/runtime errors — into the game log.
|
||||
fn drain_log(&mut self) {
|
||||
// TODO: errors are only logged for now. This is the place to halt execution /
|
||||
// set an error state when a script faults.
|
||||
let errors = self.scripts.take_errors();
|
||||
self.log.extend(errors);
|
||||
let lines = self.scripts.take_logs();
|
||||
self.log.extend(lines);
|
||||
}
|
||||
|
||||
/// Applies one object's drained `actions` to the board and returns the `bump`
|
||||
@@ -259,9 +260,12 @@ impl GameState {
|
||||
/// reactions are returned rather than fired here, so the caller can run them once
|
||||
/// all object hooks in this pass have applied.
|
||||
fn apply_actions(&mut self, actions: Vec<BoardAction>) -> Events {
|
||||
// Logs are collected here rather than pushed inline, since the board borrow
|
||||
// below also borrows `self`.
|
||||
let mut logs: Vec<LogLine> = Vec::new();
|
||||
// Application-time errors (teleport/push/shift failures) go straight onto
|
||||
// the shared LogSink — the same immediate channel as script `log()` output,
|
||||
// so everything lands in the log in one emission order and is flushed by
|
||||
// `drain_log`. A cheap Rc clone lets us push while the board borrow (which
|
||||
// also borrows `self`) is held.
|
||||
let log_sink = self.scripts.log_sink().clone();
|
||||
let mut bumps: Vec<(ObjectId, Direction)> = Vec::new();
|
||||
// Net change to player stats from AddGems / AlterHealth actions; applied
|
||||
// to `self` after the board borrow drops.
|
||||
@@ -289,7 +293,6 @@ impl GameState {
|
||||
obj.glyph.tile = tile;
|
||||
}
|
||||
}
|
||||
Action::Log(line) => logs.push(line),
|
||||
Action::SetTag {
|
||||
target,
|
||||
tag,
|
||||
@@ -340,9 +343,9 @@ impl GameState {
|
||||
}
|
||||
Action::Teleport { target, x, y } => {
|
||||
if !board.in_bounds((x, y)) {
|
||||
logs.push(LogLine::error(format!(
|
||||
log_sink.error(format!(
|
||||
"teleport({target},{x},{y}): out of bounds"
|
||||
)));
|
||||
));
|
||||
} else if target == -1 {
|
||||
// Move the player. A solid *other than the player itself*
|
||||
// blocks the destination.
|
||||
@@ -352,9 +355,9 @@ impl GameState {
|
||||
Some(s) if !s.player()
|
||||
);
|
||||
if blocked {
|
||||
logs.push(LogLine::error(format!(
|
||||
log_sink.error(format!(
|
||||
"teleport(player,{x},{y}): destination is solid"
|
||||
)));
|
||||
));
|
||||
} else {
|
||||
board.player.x = ux as i64;
|
||||
board.player.y = uy as i64;
|
||||
@@ -364,9 +367,9 @@ impl GameState {
|
||||
// a solid mover, unless the occupant is that same object.
|
||||
let (ux, uy) = (x as usize, y as usize);
|
||||
match board.objects.get(&tid) {
|
||||
None => logs.push(LogLine::error(format!(
|
||||
None => log_sink.error(format!(
|
||||
"teleport({target},{x},{y}): no such object"
|
||||
))),
|
||||
)),
|
||||
Some(obj) => {
|
||||
let source_solid = obj.solid;
|
||||
let blocked = source_solid
|
||||
@@ -375,9 +378,9 @@ impl GameState {
|
||||
Some(s) if s.object_id() != Some(tid)
|
||||
);
|
||||
if blocked {
|
||||
logs.push(LogLine::error(format!(
|
||||
log_sink.error(format!(
|
||||
"teleport({target},{x},{y}): destination is solid"
|
||||
)));
|
||||
));
|
||||
} else if let Some(obj) = board.objects.get_mut(&tid) {
|
||||
obj.x = ux;
|
||||
obj.y = uy;
|
||||
@@ -385,22 +388,24 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logs.push(LogLine::error(format!(
|
||||
log_sink.error(format!(
|
||||
"teleport({target},{x},{y}): invalid target id"
|
||||
)));
|
||||
));
|
||||
}
|
||||
}
|
||||
// push() self-checks can_push, so an in-bounds guard is all we add.
|
||||
Action::Push { x, y, dir } => {
|
||||
if !board.in_bounds((x, y)) {
|
||||
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
|
||||
log_sink.error(format!("push({x},{y}): out of bounds"));
|
||||
} else {
|
||||
board.push(x as usize, y as usize, dir);
|
||||
}
|
||||
}
|
||||
// apply_shift moves the named cells.
|
||||
// apply_shift moves the named cells, returning any error lines.
|
||||
Action::Shift(cells) => {
|
||||
logs.extend(board.apply_shift(&cells));
|
||||
for line in board.apply_shift(&cells) {
|
||||
log_sink.line(line);
|
||||
}
|
||||
}
|
||||
// Accumulated and applied to `self.player.gems` after the borrow drops.
|
||||
Action::AddGems(n) => gem_delta += n,
|
||||
@@ -415,7 +420,6 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.log.extend(logs);
|
||||
for bubble in new_bubbles {
|
||||
// One bubble per object: replace the existing one if present.
|
||||
self.speech_bubbles
|
||||
@@ -435,7 +439,7 @@ impl GameState {
|
||||
}
|
||||
for (color, present) in key_changes {
|
||||
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
|
||||
self.log.push(LogLine::error(format!("set_key: unknown color {color:?}")));
|
||||
log_sink.error(format!("set_key: unknown color {color:?}"));
|
||||
}
|
||||
}
|
||||
// Return the reactions for the caller's settle pass rather than firing them here.
|
||||
@@ -490,7 +494,7 @@ impl GameState {
|
||||
let ev = self.apply_actions(actions);
|
||||
let mut called = CalledSet::new();
|
||||
self.settle(ev, &mut called);
|
||||
self.drain_errors();
|
||||
self.drain_log();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,7 +616,7 @@ impl GameState {
|
||||
// Settle the grab/bump reactions (and any they cascade into) before returning.
|
||||
let mut called = CalledSet::new();
|
||||
self.settle(ev, &mut called);
|
||||
self.drain_errors();
|
||||
self.drain_log();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,8 +728,8 @@ mod tests {
|
||||
);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(3, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -754,6 +758,29 @@ mod tests {
|
||||
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_is_immediate_and_not_paced_by_the_queue() {
|
||||
// `delay(5.0)` parks the object's action queue for 5 seconds, then `log()`
|
||||
// fires. Because logging bypasses the queue entirely, the line must appear
|
||||
// right after run_init (dt = 0) — under the old queued-Action behavior it
|
||||
// would have been stuck behind the delay and absent here.
|
||||
let mut obj = ObjectDef::new(0, 0);
|
||||
obj.solid = false;
|
||||
obj.script_name = Some("s".to_string());
|
||||
let board = open_board(4, 1, (3, 0), vec![obj]);
|
||||
let src = "fn init(m) { delay(5.0); log(\"immediate\"); }";
|
||||
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
|
||||
assert!(
|
||||
game.log
|
||||
.iter()
|
||||
.any(|l| l.spans.iter().any(|s| s.text == "immediate")),
|
||||
"log() should hit the game log immediately, even behind a delay"
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a 3×3 board with a non-solid clockwise spinner object (running the
|
||||
/// real `scripts/spinner.rhai`) at the centre and the player parked on it.
|
||||
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
|
||||
@@ -797,9 +824,9 @@ mod tests {
|
||||
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N kept
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed)
|
||||
assert_eq!(b.get(0, 2, 1).1, Archetype::Wall); // wall kept
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N kept
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE kept (not destroyed)
|
||||
assert_eq!(b.get(2, 1).1, Archetype::Wall); // wall kept
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -809,10 +836,10 @@ mod tests {
|
||||
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE: filled by N
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW
|
||||
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W
|
||||
assert_eq!(b.get(0, 0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE: filled by N
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N: filled by NW
|
||||
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: filled by W
|
||||
assert_eq!(b.get(0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -822,9 +849,9 @@ mod tests {
|
||||
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated
|
||||
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Empty); // NE: untouched
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty); // N: vacated
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,7 @@ use color::Rgba8;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use rhai::Engine;
|
||||
use crate::script::Registerable;
|
||||
use crate::utils::ErrorSink;
|
||||
use crate::utils::LogSink;
|
||||
|
||||
/// The visual representation of a single board cell.
|
||||
///
|
||||
@@ -36,7 +36,7 @@ impl Hash for Glyph {
|
||||
}
|
||||
|
||||
impl Registerable for Glyph {
|
||||
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
|
||||
fn register(engine: &mut Engine, _log_sink: LogSink) {
|
||||
engine.register_type_with_name::<Glyph>("Glyph");
|
||||
engine.register_get("tile", |g: &mut Glyph| g.tile);
|
||||
engine.register_get("fg", |g: &mut Glyph| {
|
||||
|
||||
@@ -2,7 +2,7 @@ use color::Rgba8;
|
||||
use rhai::Engine;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::script::Registerable;
|
||||
use crate::utils::ErrorSink;
|
||||
use crate::utils::LogSink;
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum KeyType {
|
||||
@@ -88,7 +88,7 @@ impl Keyring {
|
||||
}
|
||||
|
||||
impl Registerable for Keyring {
|
||||
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
|
||||
fn register(engine: &mut Engine, _log_sink: LogSink) {
|
||||
engine.register_type_with_name::<Keyring>("Keyring")
|
||||
.register_get("red", |keyring: &mut Keyring| keyring.red)
|
||||
.register_get("orange", |keyring: &mut Keyring| keyring.orange)
|
||||
|
||||
+53
-96
@@ -1,54 +1,37 @@
|
||||
//! Palette layers: the unit of the map-file format and the board's draw stack.
|
||||
//! The board grid: the palette+char map-file unit and its load-time conversion.
|
||||
//!
|
||||
//! 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".
|
||||
//! A board is a single **grid** — a character grid plus a palette mapping each
|
||||
//! character to one *kind* of thing: an archetype (terrain), a scripted object, a
|
||||
//! portal, or the player. (The board's cosmetic floor is a separate `[map]`
|
||||
//! attribute, not a grid cell; see [`crate::floor`].)
|
||||
//!
|
||||
//! 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`].
|
||||
//! This module owns the grid serde type ([`GridData`], [`PaletteEntry`]) and the
|
||||
//! load-time conversion ([`build_grid`]) that turns one `GridData` into the board's
|
||||
//! `Vec<(Glyph, Archetype)>` cells plus a list of [`Placement`]s (objects/portals/
|
||||
//! player) for the map loader to resolve. Cross-cell 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.
|
||||
#[derive(Clone)]
|
||||
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 plus its palette.
|
||||
/// Serde representation of the board `[grid]`: a char grid plus its palette.
|
||||
///
|
||||
/// The grid is given in exactly one of three ways (precedence: `content`, then
|
||||
/// `fill`, then `sparse`; none of them ⇒ an all-spaces grid):
|
||||
/// - `content` — a multi-line grid string, one char per cell (`width × height`).
|
||||
/// - `fill` — a single character; the whole grid is filled with it (handy for a
|
||||
/// layer of identical floor).
|
||||
/// - `fill` — a single character; the whole grid is filled with it.
|
||||
/// - `sparse` — a list of `{ x, y, ch }` cells over an otherwise all-spaces grid
|
||||
/// (handy for a layer holding just a few objects).
|
||||
/// (handy for a grid holding just a few things).
|
||||
///
|
||||
/// A space (`' '`) is always a transparent empty cell and is never a palette key
|
||||
/// (any `" "` entry in `palette` is ignored).
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(crate) struct LayerData {
|
||||
#[derive(Deserialize, Serialize, Default)]
|
||||
pub(crate) struct GridData {
|
||||
/// Multi-line grid string; one char per cell, looked up in `palette`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
@@ -63,7 +46,7 @@ pub(crate) struct LayerData {
|
||||
pub palette: HashMap<String, PaletteEntry>,
|
||||
}
|
||||
|
||||
/// One cell in a [`LayerData::sparse`] list: a single character at `(x, y)`.
|
||||
/// One cell in a [`GridData::sparse`] list: a single character at `(x, y)`.
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub(crate) struct SparseCell {
|
||||
/// Column (0-indexed).
|
||||
@@ -78,24 +61,21 @@ pub(crate) struct SparseCell {
|
||||
///
|
||||
/// 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`].
|
||||
/// meta-kinds `empty`, `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`.
|
||||
/// What this entry is: an archetype name, or `empty`/`object`/`portal`/`player`.
|
||||
pub kind: String,
|
||||
/// Tile index (int or single-char string). Used by archetype/floor/object kinds.
|
||||
/// Tile index (int or single-char string). Used by archetype/object kinds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tile: Option<TileIndex>,
|
||||
/// Foreground `"#RRGGBB"`. Used by archetype/floor/object kinds.
|
||||
/// Foreground `"#RRGGBB"`. Used by archetype/object kinds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fg: Option<String>,
|
||||
/// Background `"#RRGGBB"`. Used by archetype/floor/object kinds.
|
||||
/// Background `"#RRGGBB"`. Used by archetype/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>,
|
||||
@@ -122,11 +102,14 @@ pub(crate) struct PaletteEntry {
|
||||
pub target_entry: Option<String>,
|
||||
}
|
||||
|
||||
/// A non-terrain thing a layer places at a grid cell, resolved by the map loader.
|
||||
/// A single grid cell: its visual and its behavioral class.
|
||||
pub(crate) type GridCell = (Glyph, Archetype);
|
||||
|
||||
/// A non-terrain thing the grid places at a 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.
|
||||
/// Terrain goes straight into the grid cells; these need cross-cell handling (ids,
|
||||
/// name uniqueness, the single player), so [`build_grid`] 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),
|
||||
@@ -136,7 +119,7 @@ pub(crate) enum Placement {
|
||||
Player(usize, usize),
|
||||
}
|
||||
|
||||
/// A resolved object definition minus board-assigned fields (`id`, `z`).
|
||||
/// A resolved object definition minus board-assigned fields (`id`).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ObjectTemplate {
|
||||
pub glyph: Glyph,
|
||||
@@ -148,7 +131,7 @@ pub(crate) struct ObjectTemplate {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// A resolved portal definition minus `(x, y, z)`.
|
||||
/// A resolved portal definition minus `(x, y)`.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PortalTemplate {
|
||||
pub name: String,
|
||||
@@ -159,10 +142,8 @@ pub(crate) struct PortalTemplate {
|
||||
/// 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).
|
||||
/// A fixed cell written straight into the grid (terrain, 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.
|
||||
@@ -173,10 +154,10 @@ enum Resolved {
|
||||
|
||||
/// 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.
|
||||
/// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `portal`
|
||||
/// missing required fields falls back to a transparent cell (the grid char is
|
||||
/// still consumed). Object/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 {
|
||||
@@ -186,27 +167,8 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
|
||||
};
|
||||
|
||||
match e.kind.as_str() {
|
||||
// Transparent: lower layers show through here.
|
||||
// Transparent: the floor / a lower thing shows 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),
|
||||
@@ -248,7 +210,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a layer's grid to a `height × width` matrix of chars from whichever of
|
||||
/// Resolves the grid to a `height × width` matrix of chars from whichever of
|
||||
/// `content` / `fill` / `sparse` is supplied (in that precedence; none ⇒ all spaces).
|
||||
///
|
||||
/// Only an explicit `content` can mismatch the board dimensions — the single hard
|
||||
@@ -256,7 +218,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
|
||||
/// `fill`/`ch` or an out-of-bounds `sparse` cell is recorded on `errors` and the
|
||||
/// offending cell falls back to (or stays) a space.
|
||||
fn grid_chars(
|
||||
data: &LayerData,
|
||||
data: &GridData,
|
||||
width: usize,
|
||||
height: usize,
|
||||
errors: &mut Vec<LogLine>,
|
||||
@@ -279,7 +241,7 @@ fn grid_chars(
|
||||
let rows: Vec<&str> = content.lines().collect();
|
||||
if rows.len() != height {
|
||||
return Err(format!(
|
||||
"layer grid has {} rows but the board is {height} tall",
|
||||
"grid has {} rows but the board is {height} tall",
|
||||
rows.len()
|
||||
));
|
||||
}
|
||||
@@ -288,7 +250,7 @@ fn grid_chars(
|
||||
let row: Vec<char> = line.chars().collect();
|
||||
if row.len() != width {
|
||||
return Err(format!(
|
||||
"layer grid row {i} has {} characters but the board is {width} wide",
|
||||
"grid row {i} has {} characters but the board is {width} wide",
|
||||
row.len()
|
||||
));
|
||||
}
|
||||
@@ -301,7 +263,7 @@ fn grid_chars(
|
||||
// A whole grid of one character.
|
||||
let ch = single_char(
|
||||
fill,
|
||||
format!("layer fill must be a single character (got {fill:?}); using a space"),
|
||||
format!("grid fill must be a single character (got {fill:?}); using a space"),
|
||||
errors,
|
||||
)
|
||||
.unwrap_or(' ');
|
||||
@@ -333,23 +295,19 @@ fn grid_chars(
|
||||
Ok(grid)
|
||||
}
|
||||
|
||||
/// Builds one [`Layer`] from its [`LayerData`], plus the non-terrain placements it
|
||||
/// contains (with their `(x, y)`).
|
||||
/// Builds the board's grid cells from its [`GridData`], 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,
|
||||
/// Returns `Err` only on a grid-dimension mismatch (the single hard error);
|
||||
/// every other problem is recorded on `errors`.
|
||||
pub(crate) fn build_grid(
|
||||
data: &GridData,
|
||||
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).
|
||||
// Space is always a transparent empty cell, so it is never a palette key — any
|
||||
// `" "` entry is ignored.
|
||||
) -> Result<(Vec<GridCell>, Vec<Placement>), String> {
|
||||
// Resolve each palette entry once. Space is always a transparent empty cell, so
|
||||
// it is never a palette key — any `" "` entry is ignored.
|
||||
let resolved: HashMap<char, Resolved> = data
|
||||
.palette
|
||||
.iter()
|
||||
@@ -363,7 +321,7 @@ pub(crate) fn build_layer(
|
||||
let grid = grid_chars(data, width, height, errors)?;
|
||||
|
||||
// Walk the grid, filling cells and collecting placements.
|
||||
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height);
|
||||
let mut cells: Vec<GridCell> = Vec::with_capacity(width * height);
|
||||
let mut placements: Vec<Placement> = Vec::new();
|
||||
for (y, row) in grid.iter().enumerate() {
|
||||
for (x, &ch) in row.iter().enumerate() {
|
||||
@@ -374,7 +332,6 @@ pub(crate) fn build_layer(
|
||||
}
|
||||
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));
|
||||
@@ -397,5 +354,5 @@ pub(crate) fn build_layer(
|
||||
}
|
||||
}
|
||||
|
||||
Ok((Layer { cells }, placements))
|
||||
Ok((cells, placements))
|
||||
}
|
||||
|
||||
+330
-115
@@ -16,30 +16,37 @@ use std::collections::hash_map::Entry;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::convert::TryFrom;
|
||||
use std::path::Path;
|
||||
use tinyrand::{Seeded, StdRand};
|
||||
use crate::api::queue::ObjQueue;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::board::{Board, Decoration};
|
||||
use crate::builtin_scripts::archetype_from_builtin_tag;
|
||||
use crate::floor::FLOOR_SEED;
|
||||
use crate::floor::{Floor, FloorGenerator};
|
||||
use crate::glyph::Glyph;
|
||||
use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer};
|
||||
use crate::layer::{GridData, PaletteEntry, Placement, build_grid};
|
||||
use crate::log::LogLine;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::{ObjectId, PlayerPos, PortalDef};
|
||||
|
||||
/// The serde shell for one board in a `.toml` file: a header and its layers.
|
||||
/// The serde shell for one board in a `.toml` file: a header, the single grid, and
|
||||
/// the off-grid trigger/decoration lists.
|
||||
///
|
||||
/// On load this is converted into a [`Board`] via [`TryFrom`] and discarded; on
|
||||
/// save a [`Board`] is converted back via [`From<&Board>`]. See `maps/start.toml`
|
||||
/// for a complete example of the format.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct MapFile {
|
||||
/// The `[map]` header: name, dimensions, optional board script.
|
||||
/// The `[map]` header: name, dimensions, floor, optional board script.
|
||||
pub map: MapHeader,
|
||||
/// The ordered `[[layers]]` stack, bottom (index 0) to top.
|
||||
/// The single `[grid]`: palette + char map for all solids and most non-solids.
|
||||
#[serde(default)]
|
||||
pub(crate) layers: Vec<LayerData>,
|
||||
pub(crate) grid: GridData,
|
||||
/// Invisible, non-solid, script-only objects (`[[triggers]]`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub(crate) triggers: Vec<TriggerSpec>,
|
||||
/// Non-solid `(glyph, archetype)` cells drawn only where the grid is empty
|
||||
/// (`[[decorations]]`). Normally absent; used by save files.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub(crate) decorations: Vec<DecorationSpec>,
|
||||
}
|
||||
|
||||
/// The `[map]` header section of a board.
|
||||
@@ -47,15 +54,107 @@ pub struct MapFile {
|
||||
pub struct MapHeader {
|
||||
/// Human-readable name for this board, e.g. `"Opening Room"`.
|
||||
pub name: String,
|
||||
/// Width of the board in cells. Must match every layer row's length.
|
||||
/// Width of the board in cells. Must match the grid row length.
|
||||
pub width: usize,
|
||||
/// Height of the board in cells. Must match every layer's row count.
|
||||
/// Height of the board in cells. Must match the grid row count.
|
||||
pub height: usize,
|
||||
/// The board's optional cosmetic floor. Absent ⇒ blank; see [`FloorSpec`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub floor: Option<FloorSpec>,
|
||||
/// Name of the board-level script in the `[scripts]` table, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub board_script_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Serde form of the board floor attribute (`floor = { … }` in `[map]`).
|
||||
///
|
||||
/// Resolves (in [`FloorSpec::resolve`]) to a [`Floor`]: a `generator` name gives a
|
||||
/// biome, otherwise any of `tile`/`fg`/`bg` gives a single fixed glyph, and an
|
||||
/// empty spec is blank.
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct FloorSpec {
|
||||
/// Procedural biome name (`"grass"`/`"dirt"`/`"stone"`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generator: Option<String>,
|
||||
/// Fixed-glyph tile index (int or single-char string).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tile: Option<TileIndex>,
|
||||
/// Fixed-glyph foreground `"#RRGGBB"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fg: Option<String>,
|
||||
/// Fixed-glyph background `"#RRGGBB"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bg: Option<String>,
|
||||
}
|
||||
|
||||
impl FloorSpec {
|
||||
/// Resolves this spec to a [`Floor`] for a `width × height` board, recording a
|
||||
/// nonfatal error (and falling back to [`Floor::Blank`]) for an unknown generator.
|
||||
fn resolve(&self, width: usize, height: usize, errors: &mut Vec<LogLine>) -> Floor {
|
||||
if let Some(name) = &self.generator {
|
||||
return match FloorGenerator::from_name(name) {
|
||||
Some(g) => Floor::biome(g, width, height),
|
||||
None => {
|
||||
errors.push(LogLine::error(format!(
|
||||
"floor names unknown generator '{name}'; using blank floor"
|
||||
)));
|
||||
Floor::Blank
|
||||
}
|
||||
};
|
||||
}
|
||||
// A fixed glyph if any visual field is given, else a blank floor.
|
||||
if self.tile.is_some() || self.fg.is_some() || self.bg.is_some() {
|
||||
Floor::Fixed(Glyph {
|
||||
tile: self.tile.map(TileIndex::into_u32).unwrap_or(32),
|
||||
fg: self.fg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }),
|
||||
bg: self.bg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }),
|
||||
})
|
||||
} else {
|
||||
Floor::Blank
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serde form of one `[[triggers]]` entry: an invisible, non-solid, script-only
|
||||
/// object at `(x, y)`. Triggers are folded into [`Board::objects`] at load; they
|
||||
/// are re-emitted here on save (recognised as scripted, non-solid, glyphless).
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub(crate) struct TriggerSpec {
|
||||
/// Column (0-indexed).
|
||||
pub x: usize,
|
||||
/// Row (0-indexed).
|
||||
pub y: usize,
|
||||
/// Name of the Rhai script (in `[scripts]`) this trigger runs.
|
||||
pub script_name: String,
|
||||
/// Optional board-unique name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Optional open-ended labels.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Serde form of one `[[decorations]]` entry: a non-solid `(glyph, archetype)` at
|
||||
/// `(x, y)`, drawn only where the grid cell is empty. A solid archetype is rejected.
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub(crate) struct DecorationSpec {
|
||||
/// Column (0-indexed).
|
||||
pub x: usize,
|
||||
/// Row (0-indexed).
|
||||
pub y: usize,
|
||||
/// Archetype name (or `"empty"`); must be non-solid.
|
||||
pub kind: String,
|
||||
/// Tile index (int or single-char string).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tile: Option<TileIndex>,
|
||||
/// Foreground `"#RRGGBB"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fg: Option<String>,
|
||||
/// Background `"#RRGGBB"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bg: Option<String>,
|
||||
}
|
||||
|
||||
/// A tile index in a palette entry: either a plain integer or a character literal.
|
||||
///
|
||||
/// Accepting both forms lets map files write `tile = 35` or `tile = "#"` (the char
|
||||
@@ -104,13 +203,16 @@ pub(crate) fn color_to_hex(color: Rgba8) -> String {
|
||||
|
||||
/// Converts a parsed map file into a runtime [`Board`].
|
||||
///
|
||||
/// Builds each layer (collecting its object/portal/player placements), then runs
|
||||
/// the cross-layer validations that span the whole board:
|
||||
/// Builds the single grid (collecting object/portal/player placements), resolves
|
||||
/// the floor, then runs the cross-cell validations that span the whole board:
|
||||
/// - the player must appear exactly once (missing → `(0, 0)`; multiple → the first), and wins its cell;
|
||||
/// - at most one solid may occupy a cell across all layers (a stacked solid is dropped);
|
||||
/// - at most one solid may occupy a cell (a conflicting solid object is dropped);
|
||||
/// - object names must be board-unique (a duplicate is cleared) and portal names unique (a duplicate is dropped).
|
||||
///
|
||||
/// Returns `Err` only when a layer's grid dimensions disagree with the header.
|
||||
/// Finally the `[[triggers]]` load as non-solid glyphless objects and the
|
||||
/// `[[decorations]]` as off-grid non-solid cells.
|
||||
///
|
||||
/// Returns `Err` only when the grid dimensions disagree with the header.
|
||||
impl TryFrom<MapFile> for Board {
|
||||
type Error = String;
|
||||
|
||||
@@ -119,30 +221,28 @@ impl TryFrom<MapFile> for Board {
|
||||
let h = mf.map.height;
|
||||
let mut load_errors: Vec<LogLine> = Vec::new();
|
||||
|
||||
// One PRNG for the whole board so generated floors depend only on content.
|
||||
let mut rng = StdRand::seed(FLOOR_SEED);
|
||||
|
||||
// Build every layer, collecting non-terrain placements with their layer z.
|
||||
let mut layers: Vec<Layer> = Vec::with_capacity(mf.layers.len());
|
||||
let mut object_specs: Vec<(crate::layer::ObjectTemplate, usize, usize, usize)> = Vec::new();
|
||||
let mut portal_specs: Vec<(crate::layer::PortalTemplate, usize, usize, usize)> = Vec::new();
|
||||
// Build the single grid, collecting non-terrain placements.
|
||||
let (mut grid, placements) = build_grid(&mf.grid, w, h, &mut load_errors)?;
|
||||
let mut object_specs: Vec<(crate::layer::ObjectTemplate, usize, usize)> = Vec::new();
|
||||
let mut portal_specs: Vec<(crate::layer::PortalTemplate, usize, usize)> = Vec::new();
|
||||
let mut player_positions: Vec<(usize, usize)> = Vec::new();
|
||||
for (z, data) in mf.layers.iter().enumerate() {
|
||||
let (layer, placements) = build_layer(data, w, h, &mut rng, &mut load_errors)?;
|
||||
for p in placements {
|
||||
match p {
|
||||
Placement::Object(t, x, y) => object_specs.push((t, x, y, z)),
|
||||
Placement::Portal(t, x, y) => portal_specs.push((t, x, y, z)),
|
||||
Placement::Player(x, y) => player_positions.push((x, y)),
|
||||
}
|
||||
for p in placements {
|
||||
match p {
|
||||
Placement::Object(t, x, y) => object_specs.push((t, x, y)),
|
||||
Placement::Portal(t, x, y) => portal_specs.push((t, x, y)),
|
||||
Placement::Player(x, y) => player_positions.push((x, y)),
|
||||
}
|
||||
layers.push(layer);
|
||||
}
|
||||
if layers.is_empty() {
|
||||
return Err("map has no [[layers]]".into());
|
||||
}
|
||||
|
||||
// The player must be placed exactly once across all layers.
|
||||
// Resolve the cosmetic floor attribute (blank / fixed glyph / biome).
|
||||
let floor = mf
|
||||
.map
|
||||
.floor
|
||||
.as_ref()
|
||||
.map(|f| f.resolve(w, h, &mut load_errors))
|
||||
.unwrap_or(Floor::Blank);
|
||||
|
||||
// The player must be placed exactly once.
|
||||
let (px, py) = match player_positions.len() {
|
||||
1 => player_positions[0],
|
||||
0 => {
|
||||
@@ -153,50 +253,34 @@ impl TryFrom<MapFile> for Board {
|
||||
}
|
||||
n => {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"player cell appears {n} times across layers; using the first"
|
||||
"player cell appears {n} times; using the first"
|
||||
)));
|
||||
player_positions[0]
|
||||
}
|
||||
};
|
||||
let pidx = py * w + px;
|
||||
|
||||
// Enforce one solid per cell across all layers: the first solid seen at a
|
||||
// cell claims it; a later (upper) solid stacked on it is dropped.
|
||||
// Track which cells hold a solid (the grid's own solids seed the map).
|
||||
let mut solid_occupied = vec![false; w * h];
|
||||
for (z, layer) in layers.iter_mut().enumerate() {
|
||||
for (idx, cell) in layer.cells.iter_mut().enumerate() {
|
||||
if !cell.1.behavior().solid {
|
||||
continue;
|
||||
}
|
||||
if solid_occupied[idx] {
|
||||
let (x, y) = (idx % w, idx / w);
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"two solids stacked at ({x}, {y}) on layer {z}; dropping the upper one"
|
||||
)));
|
||||
*cell = (Glyph::transparent(), Archetype::Empty);
|
||||
} else {
|
||||
solid_occupied[idx] = true;
|
||||
}
|
||||
for (idx, cell) in grid.iter().enumerate() {
|
||||
if cell.1.behavior().solid {
|
||||
solid_occupied[idx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The player wins its cell: clear any solid terrain under it (any layer)
|
||||
// and claim the cell so a solid object placed here is dropped below.
|
||||
if solid_occupied[pidx] {
|
||||
for layer in &mut layers {
|
||||
if layer.cells[pidx].1.behavior().solid {
|
||||
layer.cells[pidx] = (Glyph::transparent(), Archetype::Empty);
|
||||
}
|
||||
}
|
||||
// The player wins its cell: clear any solid terrain under it and claim the
|
||||
// cell so a solid object placed here is dropped below.
|
||||
if grid[pidx].1.behavior().solid {
|
||||
grid[pidx] = (Glyph::transparent(), Archetype::Empty);
|
||||
}
|
||||
solid_occupied[pidx] = true;
|
||||
|
||||
// Spawn objects in layer-then-reading order, so ids are deterministic and
|
||||
// "lowest id wins a collision" / "first claimant keeps the name" hold.
|
||||
// Spawn objects in reading order, so ids are deterministic and "lowest id
|
||||
// wins a collision" / "first claimant keeps the name" hold.
|
||||
let mut objects: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
|
||||
let mut next_object_id: ObjectId = 1;
|
||||
let mut seen_names: HashMap<String, ObjectId> = HashMap::new();
|
||||
for (t, x, y, z) in object_specs {
|
||||
for (t, x, y) in object_specs {
|
||||
let idx = y * w + x;
|
||||
// A solid object may not share a cell with another solid.
|
||||
if t.solid && solid_occupied[idx] {
|
||||
@@ -210,19 +294,7 @@ impl TryFrom<MapFile> for Board {
|
||||
}
|
||||
let id = next_object_id;
|
||||
// Name uniqueness: first claimant keeps it; later duplicates are cleared.
|
||||
let name = t.name.and_then(|n| match seen_names.entry(n.clone()) {
|
||||
Entry::Vacant(v) => {
|
||||
v.insert(id);
|
||||
Some(n)
|
||||
}
|
||||
Entry::Occupied(o) => {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"object name {n:?} already used by object {}; clearing name",
|
||||
o.get()
|
||||
)));
|
||||
None
|
||||
}
|
||||
});
|
||||
let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors));
|
||||
if t.solid {
|
||||
solid_occupied[idx] = true;
|
||||
}
|
||||
@@ -232,7 +304,6 @@ impl TryFrom<MapFile> for Board {
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
glyph: t.glyph,
|
||||
solid: t.solid,
|
||||
opaque: t.opaque,
|
||||
@@ -252,10 +323,44 @@ impl TryFrom<MapFile> for Board {
|
||||
next_object_id += 1;
|
||||
}
|
||||
|
||||
// Triggers: invisible, non-solid, script-only objects. They join the same
|
||||
// `objects` map (so ids/name-uniqueness/script dispatch all apply), after the
|
||||
// hand-placed grid objects.
|
||||
for t in mf.triggers {
|
||||
if !t.x.lt(&w) || !t.y.lt(&h) {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"trigger at ({}, {}) is out of bounds; skipping",
|
||||
t.x, t.y
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
let id = next_object_id;
|
||||
let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors));
|
||||
objects.insert(
|
||||
id,
|
||||
ObjectDef {
|
||||
id,
|
||||
x: t.x,
|
||||
y: t.y,
|
||||
glyph: Glyph::transparent(),
|
||||
solid: false,
|
||||
opaque: false,
|
||||
pushable: false,
|
||||
grab: false,
|
||||
script_name: Some(t.script_name),
|
||||
builtin_script: None,
|
||||
tags: t.tags.unwrap_or_default().into_iter().collect(),
|
||||
queue: ObjQueue::new(),
|
||||
name,
|
||||
},
|
||||
);
|
||||
next_object_id += 1;
|
||||
}
|
||||
|
||||
// Build the portal list, dropping duplicate names (first claimant wins).
|
||||
let mut seen_portal_names: HashSet<String> = HashSet::new();
|
||||
let mut portals: Vec<PortalDef> = Vec::new();
|
||||
for (t, x, y, z) in portal_specs {
|
||||
for (t, x, y) in portal_specs {
|
||||
if !seen_portal_names.insert(t.name.clone()) {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"portal name {:?} already used by another portal; skipping portal",
|
||||
@@ -267,17 +372,27 @@ impl TryFrom<MapFile> for Board {
|
||||
name: t.name,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
target_map: t.target_map,
|
||||
target_entry: t.target_entry,
|
||||
});
|
||||
}
|
||||
|
||||
// Decorations: non-solid off-grid cells (a solid archetype is rejected).
|
||||
let mut decorations: Vec<Decoration> = Vec::new();
|
||||
for d in mf.decorations {
|
||||
match resolve_decoration(&d) {
|
||||
Ok(dec) => decorations.push(dec),
|
||||
Err(msg) => load_errors.push(LogLine::error(msg)),
|
||||
}
|
||||
}
|
||||
|
||||
let mut board = Board {
|
||||
name: mf.map.name,
|
||||
width: w,
|
||||
height: h,
|
||||
layers,
|
||||
grid,
|
||||
floor,
|
||||
decorations,
|
||||
player: PlayerPos {
|
||||
x: px as i64,
|
||||
y: py as i64,
|
||||
@@ -290,13 +405,64 @@ impl TryFrom<MapFile> for Board {
|
||||
registry: HashMap::new(),
|
||||
};
|
||||
// Turn script-backed archetype cells (pushers/spinners) into their scripted
|
||||
// objects. Runs after the cross-layer validation above, so board invariants
|
||||
// objects. Runs after the cross-cell validation above, so board invariants
|
||||
// hold; the same call also fixes editor-placed machines before a playtest.
|
||||
board.expand_builtin_archetypes();
|
||||
Ok(board)
|
||||
}
|
||||
}
|
||||
|
||||
/// Claims `name` for object `id` in `seen_names`, returning `Some(name)` for the
|
||||
/// first claimant and `None` (with a logged error) for any later duplicate.
|
||||
fn claim_name(
|
||||
n: String,
|
||||
id: ObjectId,
|
||||
seen_names: &mut HashMap<String, ObjectId>,
|
||||
errors: &mut Vec<LogLine>,
|
||||
) -> Option<String> {
|
||||
match seen_names.entry(n.clone()) {
|
||||
Entry::Vacant(v) => {
|
||||
v.insert(id);
|
||||
Some(n)
|
||||
}
|
||||
Entry::Occupied(o) => {
|
||||
errors.push(LogLine::error(format!(
|
||||
"object name {n:?} already used by object {}; clearing name",
|
||||
o.get()
|
||||
)));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a [`DecorationSpec`] into a [`Decoration`], erroring if its archetype is
|
||||
/// unknown or solid (decorations must be non-solid).
|
||||
fn resolve_decoration(d: &DecorationSpec) -> Result<Decoration, String> {
|
||||
let arch = if d.kind == "empty" {
|
||||
Archetype::Empty
|
||||
} else {
|
||||
Archetype::try_from(d.kind.as_str())
|
||||
.map_err(|msg| format!("decoration at ({}, {}): {msg}; skipping", d.x, d.y))?
|
||||
};
|
||||
if arch.behavior().solid {
|
||||
return Err(format!(
|
||||
"decoration at ({}, {}) has solid archetype {:?}; skipping",
|
||||
d.x, d.y, d.kind
|
||||
));
|
||||
}
|
||||
let default = arch.default_glyph();
|
||||
Ok(Decoration {
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
glyph: Glyph {
|
||||
tile: d.tile.map(TileIndex::into_u32).unwrap_or(default.tile),
|
||||
fg: d.fg.as_deref().map(parse_color).unwrap_or(default.fg),
|
||||
bg: d.bg.as_deref().map(parse_color).unwrap_or(default.bg),
|
||||
},
|
||||
archetype: arch,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pool of palette characters for save: printable ASCII (plus a leading space for
|
||||
/// the common transparent-empty cell), excluding `"` and `\` which would need
|
||||
/// escaping inside a TOML string.
|
||||
@@ -310,24 +476,15 @@ fn char_pool() -> Vec<char> {
|
||||
pool
|
||||
}
|
||||
|
||||
/// Builds the [`PaletteEntry`] for a terrain/floor cell `(glyph, arch)`.
|
||||
/// Builds the [`PaletteEntry`] for a grid terrain cell `(glyph, arch)`.
|
||||
///
|
||||
/// A grid `Empty` cell is always transparent now (floors are a board attribute),
|
||||
/// so it maps to `kind = "empty"`; anything else is its archetype keyword.
|
||||
fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
|
||||
if arch == Archetype::Empty {
|
||||
if glyph.tile == 0 {
|
||||
// Transparent: lower layers show through.
|
||||
PaletteEntry {
|
||||
kind: "empty".into(),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
// A fixed floor glyph (generators bake to literal glyphs on load).
|
||||
PaletteEntry {
|
||||
kind: "floor".into(),
|
||||
tile: Some(TileIndex::Num(glyph.tile)),
|
||||
fg: Some(color_to_hex(glyph.fg)),
|
||||
bg: Some(color_to_hex(glyph.bg)),
|
||||
..Default::default()
|
||||
}
|
||||
PaletteEntry {
|
||||
kind: "empty".into(),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
PaletteEntry {
|
||||
@@ -340,19 +497,25 @@ fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes one layer `z` of `board` to a [`LayerData`], optionally writing the
|
||||
/// player cell into this layer (done on the top layer only).
|
||||
fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
|
||||
/// Whether an object is a **trigger** — an invisible, non-solid, script-only object
|
||||
/// authored/serialized in `[[triggers]]` rather than the grid palette.
|
||||
fn is_trigger(o: &ObjectDef) -> bool {
|
||||
!o.solid && o.glyph.tile == 0 && o.script_name.is_some() && o.builtin_script.is_none()
|
||||
}
|
||||
|
||||
/// Serializes `board`'s single grid (terrain + non-trigger objects + portals +
|
||||
/// player) into a [`GridData`].
|
||||
fn grid_to_data(board: &Board) -> GridData {
|
||||
let (w, h) = (board.width, board.height);
|
||||
let mut pool = char_pool().into_iter();
|
||||
let mut palette: HashMap<String, PaletteEntry> = HashMap::new();
|
||||
let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new();
|
||||
let mut grid: Vec<Vec<char>> = vec![vec![' '; w]; h];
|
||||
|
||||
// Terrain/floor cells: dedup each unique (glyph, archetype) to one palette char.
|
||||
// Terrain cells: dedup each unique (glyph, archetype) to one palette char.
|
||||
for (y, row) in grid.iter_mut().enumerate() {
|
||||
for (x, slot) in row.iter_mut().enumerate() {
|
||||
let (glyph, arch) = *board.get(z, x, y);
|
||||
let (glyph, arch) = *board.get(x, y);
|
||||
*slot = *cell_to_key.entry((glyph, arch)).or_insert_with(|| {
|
||||
let ch = pool.next().expect("ran out of palette characters");
|
||||
palette.insert(ch.to_string(), cell_entry(glyph, arch));
|
||||
@@ -361,8 +524,9 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
|
||||
}
|
||||
}
|
||||
|
||||
// Objects on this layer overwrite their (transparent) cell with an object char.
|
||||
for o in board.objects.values().filter(|o| o.z == z) {
|
||||
// Objects overwrite their (transparent) grid cell with an object char. Triggers
|
||||
// are written to `[[triggers]]` instead (see `From<&Board>`), so skip them here.
|
||||
for o in board.objects.values().filter(|o| !is_trigger(o)) {
|
||||
// A built-in archetype object (e.g. a pusher) round-trips back to its
|
||||
// archetype keyword: emit it as a terrain cell (deduped with real terrain),
|
||||
// using the object's current glyph, rather than a `kind = "object"` entry.
|
||||
@@ -397,8 +561,8 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
|
||||
grid[o.y][o.x] = ch;
|
||||
}
|
||||
|
||||
// Portals on this layer.
|
||||
for p in board.portals.iter().filter(|p| p.z == z) {
|
||||
// Portals.
|
||||
for p in board.portals.iter() {
|
||||
let ch = pool.next().expect("ran out of palette characters");
|
||||
palette.insert(
|
||||
ch.to_string(),
|
||||
@@ -413,8 +577,8 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
|
||||
grid[p.y][p.x] = ch;
|
||||
}
|
||||
|
||||
// The player is written onto the top layer.
|
||||
if place_player {
|
||||
// The player.
|
||||
{
|
||||
let ch = pool.next().expect("ran out of palette characters");
|
||||
palette.insert(
|
||||
ch.to_string(),
|
||||
@@ -433,7 +597,7 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
// Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences.
|
||||
LayerData {
|
||||
GridData {
|
||||
content: Some(content),
|
||||
fill: None,
|
||||
sparse: None,
|
||||
@@ -441,25 +605,76 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the [`FloorSpec`] for `board`'s floor, or `None` for a blank floor. A
|
||||
/// biome re-emits its generator name (so the procedural floor round-trips); a fixed
|
||||
/// glyph re-emits its tile/fg/bg.
|
||||
fn floor_to_spec(floor: &Floor) -> Option<FloorSpec> {
|
||||
match floor {
|
||||
Floor::Blank => None,
|
||||
Floor::Fixed(g) => Some(FloorSpec {
|
||||
generator: None,
|
||||
tile: Some(TileIndex::Num(g.tile)),
|
||||
fg: Some(color_to_hex(g.fg)),
|
||||
bg: Some(color_to_hex(g.bg)),
|
||||
}),
|
||||
Floor::Biome { generator, .. } => Some(FloorSpec {
|
||||
generator: Some(generator.name().into()),
|
||||
tile: None,
|
||||
fg: None,
|
||||
bg: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a runtime [`Board`] back into a serializable [`MapFile`].
|
||||
///
|
||||
/// Emits one `[[layers]]` entry per board layer; the player is written onto the
|
||||
/// top layer. Procedural floors were baked to literal glyphs at load, so they are
|
||||
/// saved as fixed `floor` glyphs (the generator declaration is not recovered).
|
||||
/// Emits the single `[grid]`, the `[[triggers]]` (invisible script objects) and
|
||||
/// `[[decorations]]` lists, and the `floor` attribute (a biome re-emits its
|
||||
/// generator name, so procedural floors round-trip).
|
||||
impl From<&Board> for MapFile {
|
||||
fn from(board: &Board) -> Self {
|
||||
let top = board.layer_count() - 1;
|
||||
let layers = (0..board.layer_count())
|
||||
.map(|z| layer_to_data(board, z, z == top))
|
||||
let grid = grid_to_data(board);
|
||||
// Trigger objects → `[[triggers]]`.
|
||||
let triggers = board
|
||||
.objects
|
||||
.values()
|
||||
.filter(|o| is_trigger(o))
|
||||
.map(|o| {
|
||||
let mut tags: Vec<String> = o.tags.iter().cloned().collect();
|
||||
tags.sort();
|
||||
TriggerSpec {
|
||||
x: o.x,
|
||||
y: o.y,
|
||||
script_name: o.script_name.clone().unwrap_or_default(),
|
||||
name: o.name.clone(),
|
||||
tags: (!tags.is_empty()).then_some(tags),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Decorations → `[[decorations]]`.
|
||||
let decorations = board
|
||||
.decorations
|
||||
.iter()
|
||||
.map(|d| DecorationSpec {
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
kind: d.archetype.name().into(),
|
||||
tile: Some(TileIndex::Num(d.glyph.tile)),
|
||||
fg: Some(color_to_hex(d.glyph.fg)),
|
||||
bg: Some(color_to_hex(d.glyph.bg)),
|
||||
})
|
||||
.collect();
|
||||
MapFile {
|
||||
map: MapHeader {
|
||||
name: board.name.clone(),
|
||||
width: board.width,
|
||||
height: board.height,
|
||||
floor: floor_to_spec(&board.floor),
|
||||
board_script_name: board.board_script_name.clone(),
|
||||
},
|
||||
layers,
|
||||
grid,
|
||||
triggers,
|
||||
decorations,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,6 @@ 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,
|
||||
@@ -101,7 +97,6 @@ impl ObjectDef {
|
||||
id: 0,
|
||||
x,
|
||||
y,
|
||||
z: 0,
|
||||
glyph: Self::default_glyph(),
|
||||
solid: true,
|
||||
opaque: true,
|
||||
|
||||
+40
-31
@@ -35,7 +35,7 @@ use crate::game::SAY_DURATION;
|
||||
use crate::log::LogLine;
|
||||
use crate::map_file::parse_color;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::{Direction, ErrorSink, Hook, ObjectId};
|
||||
use crate::utils::{Direction, LogSink, Hook, ObjectId};
|
||||
use rhai::{
|
||||
Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext,
|
||||
Scope, AST,
|
||||
@@ -53,7 +53,7 @@ use crate::player::PlayerRef;
|
||||
/// Types which can be registered to be sent to Rhai
|
||||
pub trait Registerable {
|
||||
/// Register this type and relevant getters / setters with a Rhai engine
|
||||
fn register(engine: &mut Engine, error_sink: ErrorSink);
|
||||
fn register(engine: &mut Engine, log_sink: LogSink);
|
||||
}
|
||||
|
||||
/// A compiled script plus which lifecycle hooks it defines.
|
||||
@@ -93,7 +93,7 @@ pub struct ScriptHost {
|
||||
engine: Engine,
|
||||
scripts: HashMap<String, CompiledScript>,
|
||||
scopes: HashMap<ObjectId, Scope<'static>>,
|
||||
errors: ErrorSink,
|
||||
log_sink: LogSink,
|
||||
board: BoardRef
|
||||
}
|
||||
|
||||
@@ -106,20 +106,20 @@ impl ScriptHost {
|
||||
/// `scripts` is the world-level script pool (script name → Rhai source); it is
|
||||
/// read only during construction and not retained afterward.
|
||||
pub fn new(board_ref: BoardRef, player: PlayerRef, script_sources: &HashMap<String, String>) -> Self {
|
||||
let errors = ErrorSink::new();
|
||||
let log_sink = LogSink::new();
|
||||
let mut scopes = HashMap::new();
|
||||
|
||||
let mut engine = Engine::new();
|
||||
|
||||
PlayerWithPos::register(&mut engine, errors.clone());
|
||||
Keyring::register(&mut engine, errors.clone());
|
||||
BoardRef::register(&mut engine, errors.clone());
|
||||
ObjectInfo::register(&mut engine, errors.clone());
|
||||
Glyph::register(&mut engine, errors.clone());
|
||||
ObjQueue::register(&mut engine, errors.clone());
|
||||
Registry::register(&mut engine, errors.clone());
|
||||
PlayerWithPos::register(&mut engine, log_sink.clone());
|
||||
Keyring::register(&mut engine, log_sink.clone());
|
||||
BoardRef::register(&mut engine, log_sink.clone());
|
||||
ObjectInfo::register(&mut engine, log_sink.clone());
|
||||
Glyph::register(&mut engine, log_sink.clone());
|
||||
ObjQueue::register(&mut engine, log_sink.clone());
|
||||
Registry::register(&mut engine, log_sink.clone());
|
||||
|
||||
register_write_api(&mut engine, board_ref.clone());
|
||||
register_write_api(&mut engine, board_ref.clone(), log_sink.clone());
|
||||
register_global_constants(&mut engine, board_ref.clone(), player.clone());
|
||||
|
||||
let board = board_ref.borrow();
|
||||
@@ -145,7 +145,7 @@ impl ScriptHost {
|
||||
Some(src) => src,
|
||||
None => {
|
||||
failed.insert(key.clone());
|
||||
errors.error(format!("object references unknown script '{key}'"));
|
||||
log_sink.error(format!("object references unknown script '{key}'"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ impl ScriptHost {
|
||||
}
|
||||
Err(err) => {
|
||||
failed.insert(key.clone());
|
||||
errors.error(format!("script '{key}' failed to compile: {err}"));
|
||||
log_sink.error(format!("script '{key}' failed to compile: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ impl ScriptHost {
|
||||
Self {
|
||||
engine,
|
||||
scripts,
|
||||
errors,
|
||||
log_sink,
|
||||
scopes,
|
||||
board: board_ref
|
||||
}
|
||||
@@ -242,7 +242,7 @@ impl ScriptHost {
|
||||
hook.to_str(),
|
||||
args,
|
||||
) {
|
||||
self.errors.error(format!("script '{}' {} error: {err}", script_key, hook));
|
||||
self.log_sink.error(format!("script '{}' {} error: {err}", script_key, hook));
|
||||
}
|
||||
}
|
||||
// Run the drain regardless of if we have the hook, otherwise
|
||||
@@ -299,7 +299,7 @@ impl ScriptHost {
|
||||
|
||||
// If it's not there at all, just bail:
|
||||
if arities.is_empty() {
|
||||
self.errors.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
|
||||
self.log_sink.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ impl ScriptHost {
|
||||
fn_name,
|
||||
args,
|
||||
) {
|
||||
self.errors.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
|
||||
self.log_sink.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
|
||||
}
|
||||
info.drain(&mut actions, 0.0)
|
||||
}
|
||||
@@ -331,15 +331,23 @@ impl ScriptHost {
|
||||
actions
|
||||
}
|
||||
|
||||
/// Removes and returns the errors collected since the last drain.
|
||||
pub fn take_errors(&mut self) -> Vec<LogLine> {
|
||||
self.errors.take()
|
||||
/// Removes and returns the log lines (script `log()` output and errors)
|
||||
/// collected since the last drain.
|
||||
pub fn take_logs(&mut self) -> Vec<LogLine> {
|
||||
self.log_sink.take()
|
||||
}
|
||||
|
||||
/// The shared, immediate log channel. Exposed so `GameState::apply_actions`
|
||||
/// can push its application-time errors (teleport/push/shift failures) onto
|
||||
/// the same ordered channel as script `log()` output, instead of a side vec.
|
||||
pub(crate) fn log_sink(&self) -> &LogSink {
|
||||
&self.log_sink
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn register_write_api(engine: &mut Engine, board: BoardRef) {
|
||||
fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
|
||||
engine.register_type_with_name::<Direction>("Direction");
|
||||
// Rhai does not auto-derive comparison for custom types, so register `==`/`!=`
|
||||
// to let scripts test the `bump` direction (e.g. `if dir == West { … }`).
|
||||
@@ -418,14 +426,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) {
|
||||
emit(&b, source_of(&ctx), Action::Die);
|
||||
});
|
||||
|
||||
let b = board.clone();
|
||||
engine.register_fn(
|
||||
"log",
|
||||
move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||
let id = source_of(&ctx);
|
||||
emit(&b, id, Action::Log(LogLine::raw(msg.to_string())));
|
||||
},
|
||||
);
|
||||
// log(msg): write to the game log immediately, bypassing the object's action
|
||||
// queue — so a `log()` is not paced by a pending move/delay and surfaces the
|
||||
// moment the hook runs. GameState flushes the shared LogSink into its log.
|
||||
let sink = log_sink.clone();
|
||||
engine.register_fn("log", move |msg: ImmutableString| {
|
||||
sink.line(LogLine::raw(msg.to_string()));
|
||||
});
|
||||
|
||||
let b = board.clone();
|
||||
engine.register_fn(
|
||||
@@ -591,11 +598,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) {
|
||||
// a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable,
|
||||
// and won't move anything into a cell that's not vacant (or vacated by this shift).
|
||||
let b = board.clone();
|
||||
let sink = log_sink.clone();
|
||||
engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| {
|
||||
let src = source_of(&ctx);
|
||||
match read_coord_array(&arr) {
|
||||
Ok(pairs) => emit(&b, src, Action::Shift(pairs)),
|
||||
Err(_) => emit(&b, src, Action::Log(LogLine::error("shift: each entry must be [x, y]".to_string())))
|
||||
// Malformed args are caught at call time, so log the error immediately.
|
||||
Err(_) => sink.error("shift: each entry must be [x, y]".to_string()),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ fn object_pushes_crate_on_init() {
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(3, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::floor::Floor;
|
||||
use crate::game::GameState;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::layer::Layer;
|
||||
use crate::utils::{Direction, PlayerPos, PortalDef};
|
||||
use crate::world::World;
|
||||
use std::cell::RefCell;
|
||||
@@ -15,9 +15,9 @@ fn make_board(px: i64, py: i64, portals: Vec<PortalDef>) -> Board {
|
||||
name: "test".into(),
|
||||
width: 3,
|
||||
height: 3,
|
||||
layers: vec![Layer {
|
||||
cells: vec![(Glyph::transparent(), Archetype::Empty); 9],
|
||||
}],
|
||||
grid: vec![(Glyph::transparent(), Archetype::Empty); 9],
|
||||
floor: Floor::Blank,
|
||||
decorations: Vec::new(),
|
||||
player: PlayerPos { x: px, y: py },
|
||||
objects: BTreeMap::new(),
|
||||
next_object_id: 1,
|
||||
@@ -40,7 +40,6 @@ fn two_board_world() -> World {
|
||||
name: "to_b2".into(),
|
||||
x: 2,
|
||||
y: 0,
|
||||
z: 0,
|
||||
target_map: "b2".into(),
|
||||
target_entry: "from_b1".into(),
|
||||
}],
|
||||
@@ -52,7 +51,6 @@ fn two_board_world() -> World {
|
||||
name: "from_b1".into(),
|
||||
x: 1,
|
||||
y: 1,
|
||||
z: 0,
|
||||
target_map: "b1".into(),
|
||||
target_entry: "to_b2".into(),
|
||||
}],
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
use super::load_board;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::map_file::parse_color;
|
||||
|
||||
#[test]
|
||||
fn fill_builds_a_full_grid_of_one_char() {
|
||||
// Layer 0 is a solid fill of a fixed floor glyph; a later sparse layer places
|
||||
// the player. Every cell of layer 0 must be that floor glyph.
|
||||
// `fill` fills the whole single grid with one palette char. No player char is
|
||||
// possible in a filled grid, so the player falls back to (0, 0) and wins (clears)
|
||||
// that cell; every *other* cell is the filled archetype.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 3
|
||||
height = 2
|
||||
|
||||
[[layers]]
|
||||
fill = "f"
|
||||
palette = { "f" = { kind = "floor", tile = ".", fg = "#112233", bg = "#445566" } }
|
||||
|
||||
[[layers]]
|
||||
sparse = [ { x = 0, y = 0, ch = "@" } ]
|
||||
palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
|
||||
[grid]
|
||||
fill = "#"
|
||||
palette = { "#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" } }
|
||||
"##;
|
||||
let board = load_board(toml);
|
||||
assert!(board.is_valid());
|
||||
let floor = Glyph {
|
||||
tile: '.' as u32,
|
||||
fg: parse_color("#112233"),
|
||||
bg: parse_color("#445566"),
|
||||
};
|
||||
for y in 0..2 {
|
||||
for x in 0..3 {
|
||||
assert_eq!(*board.get(0, x, y), (floor, Archetype::Empty));
|
||||
let expected = if (x, y) == (0, 0) {
|
||||
Archetype::Empty // player won its fallback cell
|
||||
} else {
|
||||
Archetype::Wall
|
||||
};
|
||||
assert_eq!(board.get(x, y).1, expected, "cell ({x}, {y})");
|
||||
}
|
||||
}
|
||||
assert_eq!((board.player.x, board.player.y), (0, 0));
|
||||
@@ -38,18 +32,14 @@ palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
|
||||
|
||||
#[test]
|
||||
fn sparse_places_only_listed_cells() {
|
||||
// A grass floor underneath; a sparse layer holding just the player and one object.
|
||||
// A sparse grid holding just the player and one object; every other cell empty.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 4
|
||||
height = 1
|
||||
|
||||
[[layers]]
|
||||
fill = "g"
|
||||
palette = { "g" = { kind = "floor", generator = "grass" } }
|
||||
|
||||
[[layers]]
|
||||
[grid]
|
||||
sparse = [ { x = 0, y = 0, ch = "@" }, { x = 2, y = 0, ch = "O" } ]
|
||||
palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } }
|
||||
"##;
|
||||
@@ -59,7 +49,7 @@ palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind =
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (2, 0));
|
||||
// An unlisted sparse cell is a transparent empty.
|
||||
assert_eq!(board.get(1, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -70,7 +60,7 @@ name = "Test"
|
||||
width = 2
|
||||
height = 1
|
||||
|
||||
[[layers]]
|
||||
[grid]
|
||||
sparse = [ { x = 5, y = 0, ch = "O" }, { x = 0, y = 0, ch = "@" } ]
|
||||
palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } }
|
||||
"##;
|
||||
@@ -95,18 +85,14 @@ name = "Test"
|
||||
width = 2
|
||||
height = 1
|
||||
|
||||
[[layers]]
|
||||
[grid]
|
||||
fill = "xy"
|
||||
palette = { " " = { kind = "empty" } }
|
||||
|
||||
[[layers]]
|
||||
sparse = [ { x = 0, y = 0, ch = "@" } ]
|
||||
palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
|
||||
"##;
|
||||
let board = load_board(toml);
|
||||
assert!(
|
||||
!board.is_valid(),
|
||||
"a non-single-char fill is a nonfatal error"
|
||||
);
|
||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Empty);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{layer, load_board, map};
|
||||
use super::{grid, load_board, map};
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::map_file::MapFile;
|
||||
@@ -6,7 +6,7 @@ use crate::map_file::MapFile;
|
||||
#[test]
|
||||
fn grid_wrong_row_count_returns_error() {
|
||||
// height = 3 but only 2 rows in the layer grid.
|
||||
let toml = map(3, 3, &[layer("...\n...", &[(".", "kind = \"empty\"")])]);
|
||||
let toml = map(3, 3, &grid("...\n...", &[(".", "kind = \"empty\"")]));
|
||||
let mf: MapFile = toml::from_str(&toml).unwrap();
|
||||
let result = Board::try_from(mf);
|
||||
assert!(result.is_err());
|
||||
@@ -24,7 +24,7 @@ fn grid_wrong_row_count_returns_error() {
|
||||
#[test]
|
||||
fn grid_wrong_row_width_returns_error() {
|
||||
// width = 4 but second row is only 3 characters.
|
||||
let toml = map(4, 2, &[layer("....\n...", &[(".", "kind = \"empty\"")])]);
|
||||
let toml = map(4, 2, &grid("....\n...", &[(".", "kind = \"empty\"")]));
|
||||
let mf: MapFile = toml::from_str(&toml).unwrap();
|
||||
let result = Board::try_from(mf);
|
||||
assert!(result.is_err());
|
||||
@@ -46,14 +46,14 @@ fn unknown_kind_produces_error_block() {
|
||||
let toml = map(
|
||||
2,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"X@",
|
||||
&[("X", "kind = \"frobnicate\""), ("@", "kind = \"player\"")],
|
||||
)],
|
||||
),
|
||||
);
|
||||
let board = load_board(&toml);
|
||||
assert_eq!(
|
||||
*board.get(0, 0, 0),
|
||||
*board.get(0, 0),
|
||||
(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,33 +16,29 @@ fn load_board(toml: &str) -> Board {
|
||||
Board::try_from(mf).expect("convert to board")
|
||||
}
|
||||
|
||||
/// Builds one `[[layers]]` block: a triple-quoted `content` grid plus an inline
|
||||
/// Builds the `[grid]` 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 {
|
||||
fn grid(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")
|
||||
format!("\n[grid]\ncontent = \"\"\"\n{content}\n\"\"\"\npalette = {{ {pal} }}\n")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Wraps a `[map]` header of the given size around the single `[grid]` block
|
||||
/// (produced by [`grid`]).
|
||||
fn map(width: usize, height: usize, grid_block: &str) -> String {
|
||||
format!("[map]\nname = \"Test\"\nwidth = {width}\nheight = {height}\n{grid_block}")
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
fn map_3x1_object(grid_str: &str, ch: &str, extra: &str) -> String {
|
||||
let body = if extra.is_empty() {
|
||||
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"".to_string()
|
||||
} else {
|
||||
@@ -51,8 +47,8 @@ fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String {
|
||||
map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
grid,
|
||||
&grid(
|
||||
grid_str,
|
||||
&[
|
||||
(" ", "kind = \"empty\""),
|
||||
(".", "kind = \"empty\""),
|
||||
@@ -63,6 +59,6 @@ fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String {
|
||||
("@", "kind = \"player\""),
|
||||
(ch, &body),
|
||||
],
|
||||
)],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
use super::{layer, load_board, map, map_3x1_object};
|
||||
use super::{grid, load_board, map, map_3x1_object};
|
||||
use crate::archetype::Archetype;
|
||||
|
||||
/// Palette shorthands.
|
||||
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
|
||||
/// Palette shorthand.
|
||||
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 {
|
||||
@@ -25,14 +20,14 @@ fn duplicate_name_clears_second_but_keeps_both_objects() {
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"GH@",
|
||||
&[
|
||||
("G", &obj("name = \"gate\"")),
|
||||
("H", &obj("name = \"gate\", solid = false")),
|
||||
PLAYER,
|
||||
],
|
||||
)],
|
||||
),
|
||||
));
|
||||
assert_eq!(board.objects.len(), 2, "both objects survive");
|
||||
assert_eq!(board.objects[&1].name.as_deref(), Some("gate"));
|
||||
@@ -46,7 +41,7 @@ fn palette_placement_puts_object_on_empty_floor() {
|
||||
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, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -65,10 +60,10 @@ fn palette_char_multi_occurrence_only_first_keeps_name() {
|
||||
let board = load_board(&map(
|
||||
4,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"GGG@",
|
||||
&[("G", &obj("solid = false, name = \"guard\"")), PLAYER],
|
||||
)],
|
||||
),
|
||||
));
|
||||
assert_eq!(board.objects.len(), 3);
|
||||
assert_eq!(board.objects[&1].name.as_deref(), Some("guard"));
|
||||
@@ -77,41 +72,11 @@ fn palette_char_multi_occurrence_only_first_keeps_name() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() {
|
||||
// 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 = 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() {
|
||||
// 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(""))]),
|
||||
],
|
||||
));
|
||||
fn non_solid_object_and_wall_coexist_in_separate_cells() {
|
||||
// With one grid each cell holds a single palette char, so a solid object can
|
||||
// never be authored onto a wall cell. A wall and a (separate-cell) non-solid
|
||||
// object both load fine.
|
||||
let board = load_board(&map_3x1_object("#G@", "G", "solid = false"));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert!(!board.is_valid());
|
||||
assert!(board.is_valid());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{layer, load_board, map};
|
||||
use super::{grid, load_board, map};
|
||||
use crate::archetype::Archetype;
|
||||
|
||||
/// Palette shorthands shared by these tests.
|
||||
@@ -11,61 +11,22 @@ const WALL: (&str, &str) = (
|
||||
|
||||
#[test]
|
||||
fn player_char_places_on_empty_floor() {
|
||||
let b = load_board(&map(3, 1, &[layer(".@.", &[EMPTY, PLAYER])]));
|
||||
let b = load_board(&map(3, 1, &grid(".@.", &[EMPTY, PLAYER])));
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_wins_solid_terrain_silently() {
|
||||
// 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, 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 (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_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_appearing_twice_uses_first() {
|
||||
let b = load_board(&map(3, 1, &[layer("@.@", &[EMPTY, PLAYER])]));
|
||||
let b = load_board(&map(3, 1, &grid("@.@", &[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(&map(3, 1, &[layer("...", &[EMPTY])]));
|
||||
let b = load_board(&map(3, 1, &grid("...", &[EMPTY])));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
@@ -74,8 +35,30 @@ fn player_char_missing_falls_back_to_origin() {
|
||||
fn player_fallback_to_origin_clears_solid_terrain() {
|
||||
// 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])]));
|
||||
let b = load_board(&map(3, 1, &grid("#..", &[WALL, EMPTY])));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(0, 0, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 0).1, Archetype::Empty);
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_fallback_wins_against_a_solid_object() {
|
||||
// No player cell → player falls back to (0, 0), which holds a solid object. The
|
||||
// player wins its cell and the object is dropped.
|
||||
let b = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&grid(
|
||||
"O..",
|
||||
&[
|
||||
EMPTY,
|
||||
(
|
||||
"O",
|
||||
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"",
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(b.objects.is_empty());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{layer, load_board, map};
|
||||
use super::{grid, load_board, map};
|
||||
|
||||
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
|
||||
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
|
||||
@@ -9,7 +9,7 @@ fn portal_duplicate_name_drops_second() {
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"12@",
|
||||
&[
|
||||
(
|
||||
@@ -22,7 +22,7 @@ fn portal_duplicate_name_drops_second() {
|
||||
),
|
||||
PLAYER,
|
||||
],
|
||||
)],
|
||||
),
|
||||
));
|
||||
assert_eq!(
|
||||
board.portals.len(),
|
||||
@@ -38,7 +38,7 @@ fn portal_palette_char_places_portal_at_grid_position() {
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"1.@",
|
||||
&[
|
||||
(
|
||||
@@ -48,7 +48,7 @@ fn portal_palette_char_places_portal_at_grid_position() {
|
||||
EMPTY,
|
||||
PLAYER,
|
||||
],
|
||||
)],
|
||||
),
|
||||
));
|
||||
assert_eq!(board.portals.len(), 1);
|
||||
assert_eq!((board.portals[0].x, board.portals[0].y), (0, 0));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Pushers are now scripted objects (the `pusher_*` archetypes expand into objects
|
||||
//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_<dir>` tag).
|
||||
|
||||
use super::{layer, load_board, map};
|
||||
use super::{grid, load_board, map};
|
||||
use crate::archetype::Archetype;
|
||||
use crate::game::GameState;
|
||||
use crate::map_file::MapFile;
|
||||
@@ -24,10 +24,10 @@ fn pusher_loads_as_a_tagged_scripted_solid_object() {
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"P @",
|
||||
&[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")],
|
||||
)],
|
||||
),
|
||||
));
|
||||
let mut id = 0;
|
||||
let p = pusher(&board, &mut id);
|
||||
@@ -48,14 +48,14 @@ fn pusher_advances_and_shoves_a_crate() {
|
||||
let board = load_board(&map(
|
||||
5,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"Po @",
|
||||
&[
|
||||
("P", "kind = \"pusher_east\""),
|
||||
("o", "kind = \"crate\""),
|
||||
("@", "kind = \"player\""),
|
||||
],
|
||||
)],
|
||||
),
|
||||
));
|
||||
let mut pid = 0;
|
||||
pusher(&board, &mut pid);
|
||||
@@ -69,12 +69,12 @@ fn pusher_advances_and_shoves_a_crate() {
|
||||
let b = game.board();
|
||||
assert!(b.objects[&pid].x > 0, "pusher advanced east");
|
||||
assert_eq!(
|
||||
b.get(0, 1, 0).1,
|
||||
b.get(1, 0).1,
|
||||
Archetype::Empty,
|
||||
"crate left its start cell"
|
||||
);
|
||||
assert!(
|
||||
(2..b.width).any(|x| b.get(0, x, 0).1 == Archetype::Crate),
|
||||
(2..b.width).any(|x| b.get(x, 0).1 == Archetype::Crate),
|
||||
"crate was shoved east"
|
||||
);
|
||||
}
|
||||
@@ -84,14 +84,14 @@ fn pusher_blocked_by_wall_stays_put() {
|
||||
let board = load_board(&map(
|
||||
4,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"P# @",
|
||||
&[
|
||||
("P", "kind = \"pusher_east\""),
|
||||
("#", "kind = \"wall\""),
|
||||
("@", "kind = \"player\""),
|
||||
],
|
||||
)],
|
||||
),
|
||||
));
|
||||
let mut pid = 0;
|
||||
pusher(&board, &mut pid);
|
||||
@@ -113,10 +113,10 @@ fn pusher_round_trips_to_its_keyword() {
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"P @",
|
||||
&[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")],
|
||||
)],
|
||||
),
|
||||
));
|
||||
// Save collapses the expanded object back into the `pusher_east` keyword.
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{layer, load_board, map};
|
||||
use super::{grid, load_board, map};
|
||||
use crate::glyph::Glyph;
|
||||
use crate::map_file::{MapFile, parse_color};
|
||||
use color::Rgba8;
|
||||
@@ -25,7 +25,7 @@ fn round_trip(toml: &str) -> crate::board::Board {
|
||||
|
||||
#[test]
|
||||
fn object_glyph_round_trips_through_toml() {
|
||||
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
|
||||
let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY]));
|
||||
let board = load_board(&toml);
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
let obj0 = &board.objects[&1];
|
||||
@@ -53,10 +53,10 @@ fn object_tags_round_trip_through_toml() {
|
||||
let toml = map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"@O.",
|
||||
&[PLAYER, ("O", &obj("tags = [\"enemy\", \"boss\"]")), EMPTY],
|
||||
)],
|
||||
),
|
||||
);
|
||||
let board = load_board(&toml);
|
||||
let obj0 = &board.objects[&1];
|
||||
@@ -75,7 +75,7 @@ fn object_tags_round_trip_through_toml() {
|
||||
|
||||
#[test]
|
||||
fn object_empty_tags_omitted_from_toml() {
|
||||
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
|
||||
let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY]));
|
||||
let board = load_board(&toml);
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
assert!(
|
||||
@@ -89,10 +89,10 @@ fn object_name_round_trips_through_toml() {
|
||||
let toml = map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"@O.",
|
||||
&[PLAYER, ("O", &obj("name = \"beacon\"")), EMPTY],
|
||||
)],
|
||||
),
|
||||
);
|
||||
let board = load_board(&toml);
|
||||
assert_eq!(board.objects[&1].name.as_deref(), Some("beacon"));
|
||||
@@ -108,7 +108,7 @@ fn object_name_round_trips_through_toml() {
|
||||
|
||||
#[test]
|
||||
fn unnamed_object_name_stays_none_through_toml() {
|
||||
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
|
||||
let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY]));
|
||||
let board2 = round_trip(&toml);
|
||||
assert_eq!(
|
||||
board2.objects[&1].name, None,
|
||||
@@ -117,31 +117,41 @@ fn unnamed_object_name_stays_none_through_toml() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
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,
|
||||
],
|
||||
)],
|
||||
);
|
||||
fn fixed_floor_attribute_round_trips_through_toml() {
|
||||
// A fixed floor glyph is now a board attribute (`floor = { … }`), not a grid
|
||||
// cell. It must survive save→load and show through empty grid cells.
|
||||
let toml = "[map]\nname = \"Test\"\nwidth = 3\nheight = 1\n\
|
||||
floor = { tile = \"#\", fg = \"#010203\", bg = \"#040506\" }\n\
|
||||
[grid]\ncontent = \"\"\"\n@..\n\"\"\"\n\
|
||||
palette = { \"@\" = { kind = \"player\" }, \".\" = { kind = \"empty\" } }\n";
|
||||
let fixed = Glyph {
|
||||
tile: '#' as u32,
|
||||
fg: parse_color("#010203"),
|
||||
bg: parse_color("#040506"),
|
||||
};
|
||||
let board = load_board(&toml);
|
||||
let board = load_board(toml);
|
||||
// An empty grid cell reveals the fixed floor.
|
||||
assert_eq!(board.glyph_at(1, 0), fixed);
|
||||
|
||||
let board2 = round_trip(&toml);
|
||||
let board2 = round_trip(toml);
|
||||
assert_eq!(board2.glyph_at(1, 0), fixed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biome_floor_round_trips_generator_name() {
|
||||
// A biome floor re-emits its generator name (not baked glyphs), so the save
|
||||
// stays compact and the reloaded board is identical.
|
||||
let toml = "[map]\nname = \"Test\"\nwidth = 3\nheight = 1\n\
|
||||
floor = { generator = \"grass\" }\n\
|
||||
[grid]\ncontent = \"\"\"\n@..\n\"\"\"\n\
|
||||
palette = { \"@\" = { kind = \"player\" }, \".\" = { kind = \"empty\" } }\n";
|
||||
let board = load_board(toml);
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
assert!(
|
||||
toml_out.contains("generator = \"grass\""),
|
||||
"biome floor must re-emit its generator name, got: {toml_out}"
|
||||
);
|
||||
// Reloaded floor glyphs match (deterministic seed).
|
||||
let board2 = load_board(&toml_out);
|
||||
assert_eq!(board2.glyph_at(1, 0), board.glyph_at(1, 0));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! into objects carrying the embedded `spinner.rhai` plus a `BUILTIN_spinner_<dir>`
|
||||
//! tag, and collapse back to the keyword on save (just like pushers).
|
||||
|
||||
use super::{layer, load_board, map};
|
||||
use super::{grid, load_board, map};
|
||||
use crate::map_file::MapFile;
|
||||
|
||||
#[test]
|
||||
@@ -10,10 +10,10 @@ fn spinner_loads_as_a_tagged_scripted_solid_object() {
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"S @",
|
||||
&[("S", "kind = \"spinner_cw\""), ("@", "kind = \"player\"")],
|
||||
)],
|
||||
),
|
||||
));
|
||||
let (_, obj) = board
|
||||
.objects
|
||||
@@ -36,10 +36,10 @@ fn spinner_round_trips_to_its_keyword() {
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"S @",
|
||||
&[("S", "kind = \"spinner_ccw\""), ("@", "kind = \"player\"")],
|
||||
)],
|
||||
),
|
||||
));
|
||||
// Save collapses the expanded object back into the `spinner_ccw` keyword.
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `BUILTIN_transporter_<dir>` tag). Bumping one from its facing side teleports
|
||||
//! the bumper past it, or out of a paired opposite-facing transporter.
|
||||
|
||||
use super::{layer, load_board, map};
|
||||
use super::{grid, load_board, map};
|
||||
use crate::game::GameState;
|
||||
use crate::map_file::MapFile;
|
||||
use crate::utils::Direction;
|
||||
@@ -14,10 +14,10 @@ fn transporter_loads_as_a_tagged_scripted_solid_object() {
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"@T ",
|
||||
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
|
||||
)],
|
||||
),
|
||||
));
|
||||
let (_, obj) = board
|
||||
.objects
|
||||
@@ -37,10 +37,10 @@ fn bumping_a_transporter_drops_you_on_its_far_side() {
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"@T ",
|
||||
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
|
||||
)],
|
||||
),
|
||||
));
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
@@ -63,7 +63,7 @@ fn a_blocked_far_side_transports_out_of_the_paired_transporter() {
|
||||
let board = load_board(&map(
|
||||
6,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"@T# W ",
|
||||
&[
|
||||
("@", "kind = \"player\""),
|
||||
@@ -71,7 +71,7 @@ fn a_blocked_far_side_transports_out_of_the_paired_transporter() {
|
||||
("#", "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\""),
|
||||
("W", "kind = \"transporter_west\""),
|
||||
],
|
||||
)],
|
||||
),
|
||||
));
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
@@ -95,14 +95,14 @@ fn a_pushed_crate_is_transported_through() {
|
||||
let board = load_board(&map(
|
||||
4,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"@oT ",
|
||||
&[
|
||||
("@", "kind = \"player\""),
|
||||
("o", "kind = \"crate\""),
|
||||
("T", "kind = \"transporter_east\""),
|
||||
],
|
||||
)],
|
||||
),
|
||||
));
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
@@ -130,10 +130,10 @@ fn transporter_round_trips_to_its_keyword() {
|
||||
let board = load_board(&map(
|
||||
3,
|
||||
1,
|
||||
&[layer(
|
||||
&grid(
|
||||
"@T ",
|
||||
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
|
||||
)],
|
||||
),
|
||||
));
|
||||
// Save collapses the expanded object back into the `transporter_east` keyword.
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
|
||||
@@ -7,9 +7,9 @@ mod scripting;
|
||||
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::floor::Floor;
|
||||
use crate::game::GameState;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::layer::Layer;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::PlayerPos;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
@@ -29,9 +29,9 @@ fn board_with_object(
|
||||
name: "test".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
layers: vec![Layer {
|
||||
cells: vec![(Glyph::transparent(), Archetype::Empty)],
|
||||
}],
|
||||
grid: vec![(Glyph::transparent(), Archetype::Empty)],
|
||||
floor: Floor::Blank,
|
||||
decorations: Vec::new(),
|
||||
player: PlayerPos { x: 0, y: 0 },
|
||||
objects: BTreeMap::from([(1, object)]),
|
||||
next_object_id: 2,
|
||||
|
||||
@@ -32,8 +32,8 @@ fn pushing_a_crate_reveals_the_floor_underneath() {
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(1, 2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(1, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.glyph_at(0, 0), floor_glyph);
|
||||
}
|
||||
|
||||
@@ -45,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(0, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -58,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(0, 1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Wall);
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Wall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -71,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(0, 1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -83,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(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);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -98,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(0, 1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,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(0, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::HCrate);
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::HCrate);
|
||||
drop(b);
|
||||
|
||||
// Pushing north into an HCrate: blocked, nothing moves.
|
||||
@@ -135,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, 0, 2).1, Archetype::HCrate);
|
||||
assert_eq!(b.get(0, 2).1, Archetype::HCrate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -147,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, 0, 2).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 0, 1).1, Archetype::VCrate);
|
||||
assert_eq!(b.get(0, 2).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 1).1, Archetype::VCrate);
|
||||
drop(b);
|
||||
|
||||
// Pushing east into a VCrate: blocked, nothing moves.
|
||||
@@ -158,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(0, 1, 0).1, Archetype::VCrate);
|
||||
assert_eq!(b.get(1, 0).1, Archetype::VCrate);
|
||||
}
|
||||
|
||||
+17
-13
@@ -85,8 +85,6 @@ enum SolidKind {
|
||||
Object(ObjectId),
|
||||
/// A solid terrain cell, with everything needed to rewrite it elsewhere.
|
||||
Terrain {
|
||||
/// Layer the terrain lives on.
|
||||
z: usize,
|
||||
/// The cell's glyph.
|
||||
glyph: Glyph,
|
||||
/// The cell's archetype.
|
||||
@@ -149,12 +147,12 @@ impl Solid {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a solid terrain cell at `(x, y)` on layer `z`.
|
||||
pub(crate) fn terrain_at(x: usize, y: usize, z: usize, glyph: Glyph, arch: Archetype) -> Solid {
|
||||
/// Builds a solid terrain cell at `(x, y)`.
|
||||
pub(crate) fn terrain_at(x: usize, y: usize, glyph: Glyph, arch: Archetype) -> Solid {
|
||||
Solid {
|
||||
x,
|
||||
y,
|
||||
kind: SolidKind::Terrain { z, glyph, arch },
|
||||
kind: SolidKind::Terrain { glyph, arch },
|
||||
behavior: arch.behavior(),
|
||||
}
|
||||
}
|
||||
@@ -228,8 +226,8 @@ impl Solid {
|
||||
obj.y = y;
|
||||
}
|
||||
}
|
||||
SolidKind::Terrain { z, glyph, arch } => {
|
||||
*board.get_mut(z, x, y) = (glyph, arch);
|
||||
SolidKind::Terrain { glyph, arch } => {
|
||||
*board.get_mut(x, y) = (glyph, arch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,9 +254,6 @@ 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.
|
||||
@@ -464,15 +459,24 @@ impl Display for Hook {
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel for engine/compile/runtime errors.
|
||||
/// Shared, immediate channel for log output: script `log()` lines plus
|
||||
/// engine/compile/runtime errors. A script host pushes to it *during* hook
|
||||
/// execution (so a `log()` is not paced by the object's action queue), and
|
||||
/// [`GameState`](crate::game::GameState) flushes it into its log each frame.
|
||||
#[derive(Clone)]
|
||||
pub struct ErrorSink(Rc<RefCell<Vec<LogLine>>>);
|
||||
pub struct LogSink(Rc<RefCell<Vec<LogLine>>>);
|
||||
|
||||
impl ErrorSink {
|
||||
impl LogSink {
|
||||
pub fn new() -> Self {
|
||||
Self(Rc::new(RefCell::new(Vec::new())))
|
||||
}
|
||||
|
||||
/// Pushes a pre-built [`LogLine`] onto the channel immediately.
|
||||
pub fn line(&self, line: LogLine) {
|
||||
self.0.borrow_mut().push(line);
|
||||
}
|
||||
|
||||
/// Pushes a red-on-black error line onto the channel immediately.
|
||||
pub fn error(&self, msg: String) {
|
||||
self.0.borrow_mut().push(LogLine::error(msg));
|
||||
}
|
||||
|
||||
@@ -149,11 +149,11 @@ mod tests {
|
||||
|
||||
// The copy changed; the original is still empty at that cell.
|
||||
assert_eq!(
|
||||
copy.boards["start"].borrow().get(0, 1, 0).1,
|
||||
copy.boards["start"].borrow().get(1, 0).1,
|
||||
Archetype::Wall
|
||||
);
|
||||
assert_eq!(
|
||||
world.boards["start"].borrow().get(0, 1, 0).1,
|
||||
world.boards["start"].borrow().get(1, 0).1,
|
||||
Archetype::Empty
|
||||
);
|
||||
// And they are genuinely different allocations.
|
||||
|
||||
+29
-55
@@ -180,50 +180,28 @@ fn tick(me, dt) {
|
||||
}
|
||||
"""
|
||||
|
||||
# A trigger script: no glyph, not solid — it just watches for the player stepping
|
||||
# onto its cell and logs. Triggers are placed via [[boards.NAME.triggers]].
|
||||
tripwire = """
|
||||
fn tick(me, dt) {
|
||||
if Player.x == me.x && Player.y == me.y && !me.waiting {
|
||||
log("tripwire: the player crossed here");
|
||||
me.delay(2.0);
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
[boards.start.map]
|
||||
name = "Starting Room"
|
||||
width = 60
|
||||
height = 25
|
||||
# A single procedural grass floor across the whole board (the old mixed
|
||||
# grass/dirt/stone/water floor is not representable with one floor attribute).
|
||||
floor = { generator = "grass" }
|
||||
|
||||
# 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
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
ggggggwwwwwwgggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||
ggggggwwwwwwgggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||
ggggggwwwwwwgggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||
ggggggwwwwwwgggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||
gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||
gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||
gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
|
||||
"""
|
||||
[boards.start.layers.palette]
|
||||
"g" = { kind = "floor", generator = "grass" }
|
||||
"d" = { kind = "floor", generator = "dirt" }
|
||||
"s" = { kind = "floor", generator = "stone" }
|
||||
"w" = { kind = "floor", tile = "~", fg = "#6666ff", bg = "#000066" }
|
||||
|
||||
# Layer 1: terrain, the player, objects and the portal. A space is always a
|
||||
# transparent empty cell, so the floor below shows through.
|
||||
[[boards.start.layers]]
|
||||
# The single grid: all solids and most non-solids (terrain, the player, objects and
|
||||
# the portal). A space is always a transparent empty cell, so the floor shows through.
|
||||
[boards.start.grid]
|
||||
content = """
|
||||
############################################################
|
||||
# #
|
||||
@@ -251,7 +229,7 @@ content = """
|
||||
# #
|
||||
############################################################
|
||||
"""
|
||||
[boards.start.layers.palette]
|
||||
[boards.start.grid.palette]
|
||||
"#" = { 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" }
|
||||
@@ -272,26 +250,22 @@ content = """
|
||||
")" = { kind = "transporter_east" }
|
||||
"(" = { kind = "transporter_west" }
|
||||
|
||||
# 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]]
|
||||
sparse = [{ x = 30, y = 12, ch = "*" }]
|
||||
[boards.start.layers.palette]
|
||||
"*" = { kind = "object", tile = 15, fg = "#ffff66", bg = "#000000", solid = false }
|
||||
# Triggers: invisible, non-solid, script-only objects placed off the grid. This one
|
||||
# watches for the player and logs when they reach its cell.
|
||||
[[boards.start.triggers]]
|
||||
x = 40
|
||||
y = 12
|
||||
script_name = "tripwire"
|
||||
|
||||
[boards.house.map]
|
||||
name = "The House"
|
||||
width = 60
|
||||
height = 25
|
||||
# A single fixed floor glyph across the whole board.
|
||||
floor = { tile = 176, fg = "#888888", bg = "#444444" }
|
||||
|
||||
# Layer 0: a single fixed floor glyph across the whole board.
|
||||
[[boards.house.layers]]
|
||||
fill = "f"
|
||||
[boards.house.layers.palette]
|
||||
"f" = { kind = "floor", tile = 176, fg = "#888888", bg = "#444444" }
|
||||
|
||||
# Layer 1: terrain, the player, objects and the return portal.
|
||||
[[boards.house.layers]]
|
||||
# The single grid: terrain, the player, objects and the return portal.
|
||||
[boards.house.grid]
|
||||
content = """
|
||||
############################################################
|
||||
# #
|
||||
@@ -319,7 +293,7 @@ content = """
|
||||
# #
|
||||
############################################################
|
||||
"""
|
||||
[boards.house.layers.palette]
|
||||
[boards.house.grid.palette]
|
||||
"#" = { kind = "wall", tile = 178, fg = "#888888", bg = "#555555" }
|
||||
"@" = { kind = "player" }
|
||||
"1" = { kind = "portal", name = "from_start", target_map = "start", target_entry = "to_house" }
|
||||
|
||||
+10
-9
@@ -6,15 +6,15 @@ start = "start"
|
||||
# Objects reference a script by name via `script_name`.
|
||||
[scripts]
|
||||
greeter = """
|
||||
fn init(me, state) {
|
||||
log(`hello from object — player at ${state.player.x}, ${state.player.y}`);
|
||||
fn init(me) {
|
||||
log(`hello from object — player at ${Player.x}, ${Player.y}`);
|
||||
set_tile(2); // change my glyph to ☻ — proves the write path
|
||||
//say("Hello there,\\ntraveller!");
|
||||
log(`Player health: ${state.player.health}`);
|
||||
log(`Player health: ${Player.health}`);
|
||||
}
|
||||
|
||||
fn tick(me, state, dt) {
|
||||
if state.player.x == me.x && state.player.y == me.y && !me.waiting {
|
||||
fn tick(me, dt) {
|
||||
if Player.x == me.x && Player.y == me.y && !me.waiting {
|
||||
say("Hey! Get offa me!");
|
||||
me.queue.delay(5.0);
|
||||
}
|
||||
@@ -25,10 +25,11 @@ fn tick(me, state, dt) {
|
||||
name = "Starting Room"
|
||||
width = 21
|
||||
height = 6
|
||||
# No floor attribute → a blank (black) floor.
|
||||
|
||||
# Layer 1: terrain, the player, objects and the portal. A space is always a
|
||||
# transparent empty cell, so the floor below shows through.
|
||||
[[boards.start.layers]]
|
||||
# The single grid: all solids and most non-solids. A space is always a transparent
|
||||
# empty cell, so the floor shows through.
|
||||
[boards.start.grid]
|
||||
content = """
|
||||
#####################
|
||||
# #
|
||||
@@ -37,7 +38,7 @@ content = """
|
||||
# #
|
||||
#####################
|
||||
"""
|
||||
[boards.start.layers.palette]
|
||||
[boards.start.grid.palette]
|
||||
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" }
|
||||
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
|
||||
"@" = { kind = "player" }
|
||||
|
||||
Reference in New Issue
Block a user