Compare commits

...

24 Commits

Author SHA1 Message Date
randrews 03185c9c68 lighting 2026-07-11 14:47:08 -05:00
randrews 6dccf5fc23 fov 2026-07-11 12:40:35 -05:00
randrews f1eaaae5d0 only tick nonwaiting objects 2026-07-11 12:04:33 -05:00
randrews b1b723fd1b enter hook 2026-07-11 11:47:45 -05:00
randrews cdeae455dc Extract kiln-ui crate 2026-07-11 00:11:35 -05:00
randrews ad95a9cd8d new layer model 2026-07-10 23:16:28 -05:00
randrews 325d2c27dd drain object queues after each object 2026-07-10 01:20:26 -05:00
randrews 386967e936 simplify 2026-07-09 00:18:40 -05:00
randrews e545395ac6 redoing bump 2026-07-08 13:21:27 -05:00
randrews f407b5d9a6 transporters 2026-07-08 10:06:25 -05:00
randrews e43dae54a8 add_gems -> alter_gems 2026-07-07 23:58:51 -05:00
randrews 23b5bf2afb no need for scriptstate any more 2026-07-07 23:55:27 -05:00
randrews 78823fcf96 Registry refactor and clear queues on board entry 2026-07-06 00:41:38 -05:00
randrews b7063277e7 Fixing tests and comments 2026-07-05 23:41:19 -05:00
randrews c16b21c603 Removed action_to_map 2026-06-28 00:22:33 -05:00
randrews 83409a5c25 clippy 2026-06-28 00:17:08 -05:00
randrews 9de31d933b Big API refactor 2026-06-28 00:12:52 -05:00
randrews db9a5d37b6 refactor 2026-06-25 19:16:46 -05:00
randrews db8c8e615d player object 2026-06-25 00:04:42 -05:00
randrews d8a3f17379 heart containers 2026-06-23 22:52:25 -05:00
randrews 8637c0a52a map editor scroll 2026-06-23 21:59:40 -05:00
randrews cca56a6153 keys 2 2026-06-23 21:51:31 -05:00
randrews cbbe522fb1 keys 1 2026-06-23 20:07:53 -05:00
randrews 2ea2ce0212 key inv display 2026-06-23 01:56:45 -05:00
76 changed files with 4680 additions and 4864 deletions
+42 -42
View File
@@ -20,14 +20,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Finishing an epic ## Finishing an epic
When the user says **"finish the epic"**, do all of the following in order: When the user says **"finish the epic"**, do all of the following in order:
1. Update the relevant `CLAUDE.md` to reflect any new modules, types, or behaviors added during the session — the root file for project-wide changes, or the crate's own `CLAUDE.md` (`kiln-core/CLAUDE.md`, `kiln-ui/CLAUDE.md`, `kiln-tui/CLAUDE.md`) for module-level changes. 1. Update the relevant `CLAUDE.md` to reflect any new modules, types, or behaviors added during the session — the root file for project-wide changes, or the crate's own `CLAUDE.md` (`kiln-core/CLAUDE.md`, `kiln-tui/CLAUDE.md`) for module-level changes. (`kiln-ui` is now a separate repo with its own `CLAUDE.md`.)
2. Run the `/simplify` skill on changed code. 2. Run the `/simplify` skill on changed code.
3. Commit everything with a summary message. 3. Commit everything with a summary message.
## Commands ## Commands
```bash ```bash
cargo build # compile the workspace (kiln-core + kiln-ui + kiln-tui) cargo build # compile the workspace (kiln-core + kiln-tui; fetches the kiln-ui git dep)
cargo run -p kiln-tui -- maps/start.toml # play/edit a world in the terminal cargo run -p kiln-tui -- maps/start.toml # play/edit a world in the terminal
cargo test # run all tests cargo test # run all tests
cargo test <name> # run a single test by name (substring match) cargo test <name> # run a single test by name (substring match)
@@ -37,15 +37,15 @@ cargo fmt # format
## Architecture ## Architecture
`kiln` is a Cargo **workspace** that separates the engine from its front-ends so the same game data can be driven by different UIs: `kiln` is a Cargo **workspace** that separates the engine from its front-end, and depends on an external, game-agnostic UI toolkit:
- **`kiln-core`** — the engine: all core game types (`Board`, `Glyph`, `Archetype`, `GameState`, …) plus `.toml` map-file load/save. No rendering or UI; every front-end depends on it. - **`kiln-core`** — the engine: all core game types (`Board`, `Glyph`, `Archetype`, `GameState`, …) plus `.toml` map-file load/save. No rendering or UI; every front-end depends on it.
- **`kiln-ui`** — reusable terminal UI widgets on **ratatui**, mostly **game-agnostic**. Each widget owns its presentation + input handling and is generic over a host context `Ctx` it mutates only through callbacks. Holds the dialog/text-field system (`dialog::Dialog<Ctx>`), the shared `dim_area` overlay helper, a hand-written modeless script code editor (`code_editor::CodeEditor`), and the **glyph picker** (`glyph_dialog::GlyphDialog<Ctx>`). The glyph picker is the **one** widget that depends on `kiln-core` (for `Glyph`/`Rgba8` + the CP437 table); everything else is game-agnostic. The **syntect** (highlighting) and **arboard** (clipboard) deps make this crate desktop/terminal-only (not WASM), unlike its ratatui core. - **`kiln-ui`** — reusable terminal UI widgets on **ratatui**, **game-agnostic** and now a **separate git repo** (`https://notpi.com/randrews/kiln-ui.git`), consumed by kiln-tui as a git dependency. Each widget owns its presentation + input handling and is generic over a host context `Ctx` it mutates only through callbacks. Holds the dialog/text-field system (`dialog::Dialog<Ctx>`), the shared `dim_area`/`render_overlay`/`CursorOverlay` overlay scaffolding, and a hand-written modeless script code editor (`code_editor::CodeEditor`). It has **no `kiln-core` dependency** — the one kiln-core-coupled widget, the **glyph picker**, was left behind in kiln-tui (see below). The **syntect** (highlighting) and **arboard** (clipboard) deps make it desktop/terminal-only (not WASM), unlike its ratatui core.
- **`kiln-tui`** — a terminal **player + world editor** built on **ratatui 0.30 / crossterm**, depending on both `kiln-core` and `kiln-ui`. Takes a world-file path on the command line; plays a board (arrow keys) and edits it (`Esc` → menu → `e`). - **`kiln-tui`** — a terminal **player + world editor** built on **ratatui 0.30 / crossterm**, depending on `kiln-core` and the external `kiln-ui`. Takes a world-file path on the command line; plays a board (arrow keys) and edits it (`Esc` → menu → `e`). Owns the **glyph picker** (`glyph_dialog::GlyphDialog<Ctx>`) — the one game-coupled UI widget (needs `Glyph`/`Rgba8` + the CP437 table), built on `kiln-ui`'s public dialog scaffolding.
Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui", "kiln-ui"]`, with `ratatui` pinned in `[workspace.dependencies]` so kiln-ui and kiln-tui share one version (their public APIs exchange ratatui types). Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`, with `ratatui` pinned in `[workspace.dependencies]`. The external `kiln-ui` pins the same `ratatui` `0.30.x` so their public APIs (which exchange ratatui types) unify.
Module-by-module reference for each crate lives in that crate's own `CLAUDE.md` — [`kiln-core/CLAUDE.md`](kiln-core/CLAUDE.md), [`kiln-ui/CLAUDE.md`](kiln-ui/CLAUDE.md), and [`kiln-tui/CLAUDE.md`](kiln-tui/CLAUDE.md) — which Claude Code loads automatically when you work in that crate. Module-by-module reference for each workspace crate lives in that crate's own `CLAUDE.md` — [`kiln-core/CLAUDE.md`](kiln-core/CLAUDE.md) and [`kiln-tui/CLAUDE.md`](kiln-tui/CLAUDE.md) — which Claude Code loads automatically when you work in that crate. (`kiln-ui`'s reference lives in its own repo.)
### World file format (`maps/*.toml`) ### World file format (`maps/*.toml`)
@@ -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.** **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 ```toml
# Two terser layer forms, equivalent to a full `content` grid: # Two terser grid forms, equivalent to a full `content` grid:
[[boards.room1.layers]] [boards.room1.grid]
fill = "g" # whole layer is grass floor fill = "#" # whole grid is wall
palette = { "g" = { kind = "floor", generator = "grass" } } palette = { "#" = { kind = "wall" } }
[[boards.room1.layers]] [boards.room1.grid]
sparse = [ { x = 5, y = 3, ch = "C" }, { x = 9, y = 7, ch = "C" } ] # two chests 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 palette = { "C" = { kind = "object", script_name = "chest" } } # spaces are implicitly empty
``` ```
@@ -75,14 +79,14 @@ start = "room1" # key of the starting board
# by script_name = "key". Scripts do NOT live on individual boards. # by script_name = "key". Scripts do NOT live on individual boards.
[scripts] [scripts]
greeter = """ greeter = """
fn init() { # optional, run once at startup fn init(me, state) { # optional, run once at startup
log(`player at ${Board.player_x}, ${Board.player_y}`); log(`player at ${state.player.x}, ${state.player.y}`);
set_tile(2); # write: change my glyph set_tile(2); # write: change my glyph
} }
fn tick(dt) { fn tick(me, state, dt) {
if Queue.length() == 0 && !blocked(North) { move(North); } if !me.waiting && !me.blocked(North) { move(North); }
} }
fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); } fn bump(me, dir) { log(`bumped from ${dir}`); say("Ouch!"); } # dir: the side it came from
""" """
# Each board is a named subtable under [boards]. The board key ("room1") is # Each board is a named subtable under [boards]. The board key ("room1") is
@@ -92,54 +96,50 @@ fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); }
name = "Room One" name = "Room One"
width = 60 width = 60
height = 25 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 # The single grid: terrain, the player, objects and portals. A space is always a
# cell; a fixed-glyph form (tile/fg/bg, no generator) is also allowed. # transparent empty cell, so the floor shows through.
[[boards.room1.layers]] [boards.room1.grid]
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]]
content = """ content = """
############################################################ ############################################################
# G @ 1 # # G @ 1 #
############################################################ ############################################################
""" """
[boards.room1.layers.palette] [boards.room1.grid.palette]
# A space is always a transparent empty cell — it is never a palette key. # A space is always a transparent empty cell — it is never a palette key.
"#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" } "#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" }
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } # solid + pushable (any dir) "o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } # solid + pushable (any dir)
"-" = { kind = "hcrate" } # pushable east/west only "-" = { kind = "hcrate" } # pushable east/west only
"|" = { kind = "vcrate" } # pushable north/south only "|" = { kind = "vcrate" } # pushable north/south only
">" = { kind = "pusher_east" } ">" = { 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). # 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). # If it has a `name`, only the first instance keeps it (names are board-unique).
"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", "G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000",
solid = false, name = "greeter", script_name = "greeter" } solid = false, name = "greeter", script_name = "greeter" }
# A portal: its char must appear exactly once. # A portal: its char must appear exactly once.
"1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" } "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`, `spinner_cw|spinner_ccw`, `gem`). 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()`, `tick(dt)`, `bump(id)`, and `grab()` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, `can_push(x, y, dir) -> bool`, `can_shift(x, y, dir) -> bool`, and `passable(x, y) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)` (shove a chain at arbitrary coords), `swap([[src_x, src_y, dst_x, dst_y], …])` (move several solids simultaneously), `add_gems(n)` (change the player's gem count), and `die()` (remove the calling object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`. Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** 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)`, `enter(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. `enter` is the **non-solid** counterpart: it fires on a non-solid object when a solid *relocates onto* its cell (a player/object step, a pushed crate, a `teleport`, or a `shift`), with `dir` the side the entrant came from — exact (`dir.opposite()`) for a one-cell move/push, best-effort (dominant axis, via `Direction::from_delta`) for a `teleport`/`shift` that jumps an arbitrary distance. 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). `tick` is invoked **only when the object's output queue is empty** — while actions from a prior tick are still pending (including a pacing `Delay`), the engine just drains the queue by `dt` without re-running `tick`, so a plain `move()` paces itself one step per drain and no `if me.queue.length == 0` guard is needed. (Every other hook — `bump`/`enter`/`grab`/`send`/`init` — fires regardless of queued actions.) 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`/`enter`/`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/enter/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)`). **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 ### 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. - **`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`. - **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 (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. - **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.
- **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. - **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. - **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. - **`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. - **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,11 +153,11 @@ 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). - `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. - 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. - `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 ### Other not-yet-implemented threads
- **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call). - **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call).
- **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates, `remove_object` deletes — used by `apply_swap` to despawn a displaced object), so references survive reordering. What's still missing: wiring live spawn/destroy into the `ScriptHost` (it builds one `ObjectRuntime` per object once in `GameState::new`, so an object added after that has no script runtime, and a removed one keeps a stale — but harmless, no-op — runtime until the host is rebuilt). - **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates, `remove_object` deletes — used by `apply_shift` to despawn a displaced object), so references survive reordering. What's still missing: wiring live spawn/destroy into the `ScriptHost` (it compiles one script + builds one persistent `Scope` per object when constructed, so an object added after that has no script runtime, and a removed one keeps a stale — but harmless, no-op — scope until the host is rebuilt).
- **Portals & multi-board** — **done**: `world::load` loads all boards as `Rc<RefCell<Board>>`, `GameState` owns the whole `World` and tracks `current_board_name`. `try_move` detects stepping onto a portal and calls `GameState::enter_board(target_map, target_entry)`, which switches `current_board_name`, places the player at the named arrival portal, clears per-board transient state (speech/scroll), rebuilds the `ScriptHost` for the new board, re-runs `init()`, and sets `board_transition` as a hook. kiln-tui detects the board change in `try_move_with_transition` and plays the `TransitionAnimation` wipe. (Errors — missing target board or entry — are logged, not fatal.) - **Portals & multi-board** — **done**: `world::load` loads all boards as `Rc<RefCell<Board>>`, `GameState` owns the whole `World` and tracks `current_board_name`. `try_move` detects stepping onto a portal and calls `GameState::enter_board(target_map, target_entry)`, which switches `current_board_name`, places the player at the named arrival portal, clears per-board transient state (speech/scroll), rebuilds the `ScriptHost` for the new board, re-runs `init()`, and sets `board_transition` as a hook. kiln-tui detects the board change in `try_move_with_transition` and plays the `TransitionAnimation` wipe. (Errors — missing target board or entry — are logged, not fatal.)
- **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup). - **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).
Generated
+13 -5
View File
@@ -420,6 +420,12 @@ dependencies = [
"litrs", "litrs",
] ]
[[package]]
name = "doryen-fov"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8e6ef8177b52d581c728655ff05fe03a6634ed496fc2c6642bb17b91d27aa9"
[[package]] [[package]]
name = "downcast-rs" name = "downcast-rs"
version = "1.2.1" version = "1.2.1"
@@ -754,6 +760,8 @@ name = "kiln-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"color", "color",
"doryen-fov",
"log",
"rhai", "rhai",
"serde", "serde",
"tinyrand", "tinyrand",
@@ -768,15 +776,15 @@ dependencies = [
"kiln-core", "kiln-core",
"kiln-ui", "kiln-ui",
"ratatui", "ratatui",
"toml",
] ]
[[package]] [[package]]
name = "kiln-ui" name = "kiln-ui"
version = "0.1.0" version = "0.1.0"
source = "git+https://notpi.com/randrews/kiln-ui.git#eabeaf1bf8440e6dd784f6b3bc9fea855e8761e3"
dependencies = [ dependencies = [
"arboard", "arboard",
"color",
"kiln-core",
"ratatui", "ratatui",
"syntect", "syntect",
"unicode-width", "unicode-width",
@@ -850,9 +858,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "lru" name = "lru"
@@ -1109,7 +1117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["kiln-core", "kiln-tui", "kiln-ui"] members = ["kiln-core", "kiln-tui"]
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
+356 -205
View File
@@ -1,262 +1,363 @@
# Kiln Script API Reference # Kiln Script API Reference
Scripts are written in [Rhai](https://rhai.rs) and live in the `[scripts]` section of a `.toml` map Scripts are written in [Rhai](https://rhai.rs) and live in the `[scripts]` section of a `.toml` map
file. Each scripted object references a script by name via `script_name`. A script may define up to file. Each scripted object references a script by name via `script_name`. A script defines lifecycle
three lifecycle hooks; any or all may be omitted. hooks (any may be omitted) and may define arbitrary handler functions reachable via `send()` and
scroll choices.
Every hook receives the same first two parameters — `me` (this object) and `state` (the world) —
through which all reads happen. Writes go through free functions (`move`, `say`, `set_tile`, …) that
implicitly act on the calling object.
> **Status:** this document describes the API as it currently exists. It has grown organically and
> has known rough edges — duplication, naming inconsistency, and some unwieldy machinery. Those are
> catalogued in [Design notes](#design-notes-rough-edges) at the end, as the starting point for a
> redesign.
--- ---
## Lifecycle hooks ## Lifecycle hooks
### `fn init()` Hooks are detected by name **and** arity (parameter count). A function with the right name but wrong
number of parameters is not recognized as a hook. Every hook takes `me` (an [`ObjectInfo`](#self-and-other-objects-objectinfo)
for the object running the script) and `state` (a [`State`](#the-world-state) handle to the world),
in that order, before any hook-specific argument.
Called once for every scripted object after the entire map is loaded, before the first frame. ### `fn init(me, state)`
Use it to set up initial tile appearance, log a startup message, etc.
Called once for every scripted object after the entire map is loaded, before the first frame (and
again after a board transition re-enters this board's script host).
```rhai ```rhai
fn init() { fn init(me, state) {
set_tile(42); set_tile(42);
log("ready"); log("ready");
} }
``` ```
### `fn tick(dt)` ### `fn tick(me, state, dt)`
Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Movement Called each frame **only when this object's output queue is empty** — i.e. once its previous
and other timed actions pace themselves through the queue; you do not need to track time manually actions (and any pacing `Delay`) have fully drained. While actions from an earlier tick are still
for simple walking patterns. pending, the engine just advances the queue by `dt` and does **not** re-run `tick`. This means you no
longer need to guard the body with `if me.queue.length == 0` / `if !me.waiting` — the engine does it
for you, so a plain `move(North)` naturally paces itself one step per drain. `dt` is the elapsed time
since the last tick, in seconds (a `f64`). (All other hooks — `bump`/`enter`/`grab`/`send`/`init`
still fire regardless of queued actions.)
```rhai ```rhai
fn tick(dt) { fn tick(me, state, dt) {
if Queue.length() == 0 && !blocked(North) { if !me.blocked(North) {
move(North); move(North);
} }
} }
``` ```
### `fn bump(id)` ### `fn bump(me, dir)`
Called when another entity walks into this object's cell. Called when a solid presses into this object's cell — the player, another object, or a **crate** being
pushed into it (the push chain is walked through to reach the object it presses against).
- `id` is the bumper's `ObjectId` (an `i64`). - `dir` is the [`Direction`] the bump **came from**: it points from this object toward the bumper, so
- `id == -1` means the player bumped this object. the bumper occupies the cell at `(me.x + dir.dx, me.y + dir.dy)`.
- Compare it against the `North`/`South`/`East`/`West` constants.
- The hook can no longer tell *who* did the bumping (there is no id) — a crate bumps just like the player.
```rhai ```rhai
fn bump(id) { fn bump(me, dir) {
if id == -1 { if dir == West { say("Something shoved me from the west."); }
say("Hey! Watch it."); else { log(`bumped from ${dir}`); }
} else {
log(`object ${id} bumped me`);
}
} }
``` ```
### `fn enter(me, dir)`
The **non-solid** counterpart to `bump`. Called when a solid — the player, another object, or a
**crate** being pushed/shifted — relocates *onto* this object's cell. Only makes sense on a non-solid
object (a trigger, or a decorative `solid = false` object); a solid object blocks the entrant instead
of being entered, so its `bump` fires rather than `enter`.
- `dir` is the [`Direction`] the entrant **came from** — the same convention as `bump`. For a one-cell
move or push it is exactly the opposite of the entrant's travel direction. For a `teleport` or `shift`
that jumps a solid an arbitrary distance it is **best-effort**: the dominant axis of the displacement
(a longer horizontal jump reads as East/West, a longer vertical one as North/South).
- Fires for *every* solid that lands on the cell: a player/object step, and each crate a push or shift
moved onto it.
```rhai
fn enter(me, dir) {
say("Who goes there?");
log(`something stepped on me from the ${dir}`);
}
```
### `fn grab(me, state)`
Called when the **player walks onto** this object and the object is a *grab* thing (gems, hearts —
`solid + grab`). Unlike `bump`, the player ends up on the object's cell; the hook typically adjusts a
player stat and removes the object. It fires followed by an immediate resolve, so any `die()` /
`alter_gems()` / `alter_health()` applies before the player's move returns.
```rhai
fn grab(me, state) {
alter_gems(1);
die();
}
```
> Grab is **only** triggered by the player walking onto the object. A grab thing *pushed* or
> *shifted* into the player is treated as an ordinary solid (no `grab()`).
### Custom handler functions
Any other function can be invoked on an object two ways:
- Another object calls `send(target_id, "fn_name" [, arg])`.
- A `scroll()` **choice** is selected: the choice key is dispatched to the source object as a
function call of that name.
The host picks the handler's parameter list by arity, in this order:
- **3 params** → `(me, state, arg)``arg` is the value passed to `send` (or unit for a scroll choice).
- **2 params** → `(me, state)`.
- **1 param** → `(arg)`.
- **0 params** → `()`.
If no matching function name exists, the call is silently dropped.
```rhai
// reached via send(my_id, "open") or a scroll choice keyed "open"
fn open(me, state) { set_tile(47); }
fn set_level(me, state, n) { set_tag(me.id, "level", true); log(`level ${n}`); }
```
--- ---
## Constants in scope ## Constants in scope
These are pre-set in every object's scope and cannot be reassigned. Unlike the `me`/`state` parameters (which every hook receives), these are injected directly into
every object's scope.
| Name | Type | Description | | Name | Type | Description |
|------|------|-------------| |------|------|-------------|
| `Board` | `Board` | Read-only handle to the game world. | | `Registry` | `Registry` | Board-scoped key→value store; persists across board transitions. |
| `Queue` | `Queue` | Handle to this object's own pending-action queue. | | `North` / `South` / `East` / `West` | `Direction` | Cardinal directions. |
| `North` | `Direction` | Cardinal direction: up (y 1). | | `Black` `White` (16) | `String` | EGA/VGA palette colors as `"#RRGGBB"` strings. |
| `South` | `Direction` | Cardinal direction: down (y + 1). |
| `East` | `Direction` | Cardinal direction: right (x + 1). | `Registry` is a **scope constant**: it is only visible at the top level of an engine-called hook, not
| `West` | `Direction` | Cardinal direction: left (x 1). | inside helper functions you define (those run at a deeper call depth — see [Pitfalls](#pitfalls-and-sharp-edges)).
| `MY_ID` | `i64` | This object's stable `ObjectId`. Use with `set_tag`. | The direction/color constants come from a global module and *are* visible at any call depth (including
Rhai→Rhai calls).
The 16 color names, in palette order: `Black`, `Blue`, `Green`, `Cyan`, `Red`, `Magenta`, `Brown`,
`LightGray`, `DarkGray`, `BrightBlue`, `BrightGreen`, `BrightCyan`, `BrightRed`, `BrightMagenta`,
`Yellow`, `White`. They are plain hex strings, so they are accepted anywhere a color string is (e.g.
`set_fg(BrightRed)`).
--- ---
## Read functions ## Self and other objects: `ObjectInfo`
### `Board` getters `me` is an `ObjectInfo` — a snapshot of one board object. The same type comes back from
[`state.board.get` / `.named` / `.tagged`](#board-reads), so an object you look up has exactly the
same members as `me`.
Access via `Board.<property>`: | Member | Kind | Returns | Description |
|--------|------|---------|-------------|
| `.id` | getter | `i64` | The object's stable `ObjectId`. |
| `.name` | getter | `String` or `()` | Name, or unit if unnamed. |
| `.x` / `.y` | getter | `i64` | Current cell position. |
| `.tags` | getter | `Array` | All tag strings on this object. |
| `.glyph` | getter | `Glyph` | The object's glyph (`.tile`, `.fg`, `.bg`). |
| `.waiting` | getter | `bool` | Whether the front of the object's queue is a delay (i.e. it is "busy"). |
| `.queue` | getter | `Queue` | The object's [pending-action queue](#queue-object). |
| `.has_tag(tag)` | method | `bool` | Whether the object carries `tag`. |
| `.delay(secs)` | method | — | Append a pause to the object's queue. |
| `.blocked(dir)` | method | `bool` | Whether moving the object one step in `dir` is impossible (off-board or a solid). |
| `.can_push(dir)` | method | `bool` | Whether the pushable chain ahead of the object in `dir` can be shoved. |
```rhai
fn tick(me, state, dt) {
if me.has_tag("asleep") { return; }
let here = me.glyph;
log(`my tile is ${here.tile}`);
if !me.waiting && !me.blocked(East) { move(East); }
}
```
A `Glyph` value (from `me.glyph` or a looked-up object) exposes `.tile` (`i64`), `.fg`, `.bg` (both
`"#RRGGBB"` strings).
> Because hooks receive `me`/`state` as **parameters**, they are local to the hook. To use them in a
> helper function you define, pass them down explicitly: `fn dance(me) { … }` called as `dance(me)`.
---
## The world: `state`
`state` is a handle bundling the two read surfaces:
| Member | Type | Description |
|--------|------|-------------|
| `state.player` | `Player` | The player's game-global stats and position. |
| `state.board` | `Board` | Read-only handle to the current board. |
### Player stats: `state.player`
A read-only snapshot of the game-global player state, refreshed before each hook call.
| Property | Type | Description | | Property | Type | Description |
|----------|------|-------------| |----------|------|-------------|
| `Board.player_x` | `i64` | Player's current column (0-indexed). | | `state.player.gems` | `i64` | Gems collected. |
| `Board.player_y` | `i64` | Player's current row (0-indexed). | | `state.player.health` | `i64` | Current health. |
| `Board.width` | `i64` | Board width in cells. | | `state.player.max_health` | `i64` | Health ceiling. |
| `Board.height` | `i64` | Board height in cells. | | `state.player.x` / `.y` | `i64` | Player's current cell. |
| `state.player.keys` | `Keyring` | The player's key inventory (see below). |
```rhai ```rhai
let dist_x = (Board.player_x - 30).abs(); if state.player.health < 2 { say("You look hurt."); }
if state.player.keys.red { say("You have the red key."); }
``` ```
### `blocked(dir) -> bool` The `Keyring` from `state.player.keys` exposes one `bool` getter per color: `.red`, `.orange`,
`.yellow`, `.green`, `.blue`, `.cyan`, `.purple`, `.white`.
Returns `true` if moving in `dir` from this object's current cell would be impossible — because ### Board reads
the target cell is out of bounds, holds a solid, or another object has already queued a move there.
Note: the object's *own* queued moves are not yet on the board queue when `blocked` is called, so #### Properties
an object cannot accidentally block itself.
| Property | Type | Description |
|----------|------|-------------|
| `state.board.width` | `i64` | Board width in cells. |
| `state.board.height` | `i64` | Board height in cells. |
#### Object lookups
| Call | Returns | Description |
|------|---------|-------------|
| `state.board.tagged(tag)` | `Array` of `ObjectInfo` | Every object carrying `tag`. |
| `state.board.named(name)` | `ObjectInfo` or `()` | The object with that name, else unit. |
| `state.board.get(id)` | `ObjectInfo` or `()` | Look up by id; an invalid (`id <= 0`) or unknown id logs an error and returns unit. |
```rhai ```rhai
fn tick(dt) { let enemies = state.board.tagged("enemy");
if !blocked(East) { move(East); }
}
```
### `has_tag(tag) -> bool`
Returns `true` if this object has the named tag.
```rhai
if has_tag("enemy") { say("I'm an enemy!"); }
```
### `get_tags() -> Array`
Returns an array of strings listing every tag on this object.
```rhai
for t in get_tags() { log(t); }
```
### `objects_with_tag(tag) -> Array`
Returns an array of `i64` object IDs for every object on the board that currently carries `tag`.
```rhai
let enemies = objects_with_tag("enemy");
log(`there are ${enemies.len()} enemies`); log(`there are ${enemies.len()} enemies`);
let door = state.board.named("door");
if door != () { send(door.id, "open"); }
``` ```
### `my_name() -> String` #### Cell queries
Returns this object's name string, or `""` if the object has no name. | Call | Returns | Description |
|------|---------|-------------|
| `state.board.can_push(x, y, dir)` | `bool` | Does a pushable chain starting at `(x, y)` end in open space when shoved in `dir`? `false` off-board. |
| `state.board.passable(x, y)` | `bool` | Is `(x, y)` on-board and free of any solid (an enterable hole)? Distinguishes a hole from a blocked solid. |
For queries about the calling object itself, use `me.blocked(dir)` and `me.can_push(dir)`
([above](#self-and-other-objects-objectinfo)).
---
## Registry (shared key→value store)
Board-scoped, persists across board transitions. Stores primitive values.
| Call | Description |
|------|-------------|
| `Registry.get(key)` | Stored value, or `()` if absent. |
| `Registry.set(key, value)` | Store a primitive; passing `()` removes the key; unsupported types are silently ignored. |
| `Registry.get_or(key, default)` | Stored value **only if** it has the same type as `default`; otherwise `default`. |
```rhai ```rhai
log(`I am ${my_name()}`); fn tick(me, state, dt) {
``` // Per-object counter that survives across ticks and board transitions.
let key = `count_${me.id}`;
### `object_id_for_name(name) -> i64` let n = Registry.get_or(key, 0);
Registry.set(key, n + 1);
Looks up an object by its name and returns its `ObjectId`. Returns `0` if no object has that name.
`0` is never a valid id, so it can be used as a null check.
```rhai
let door_id = object_id_for_name("door");
if door_id != 0 {
set_tag(door_id, "open", true);
} }
``` ```
The Registry is shared by the whole board, so namespace per-instance keys by `me.id` as above.
--- ---
## Write functions ## Write functions
Scripts do not mutate the world directly. Each write function appends an action to this object's Scripts never mutate the world directly. Each write appends an `Action` to the calling object's
**output queue**. Actions are drained to the shared board queue between script calls and resolved **output queue**; the moment the hook returns, that object's ready actions are drained and applied
by the engine after the batch. by the engine — before the next object's hook runs. The *subject* of a write is implicit and varies
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.
### `move(dir)` | Call | Subject | Cost | Description |
|------|---------|------|-------------|
| `move(dir)` | self | 0.25 s | Enqueue a one-cell move, plus a rate-limiting delay (~4/s). A blocked move still costs the cooldown. |
| `teleport(id, x, y)` | arbitrary entity | 0 | Jump entity `id` to an arbitrary cell (pass `me.id` for self; `id == -1` is the player). Refused (error logged at resolve) if that entity is solid and the destination already holds a *different* solid. |
| `push(x, y, dir)` | arbitrary cell | 0 | Shove the pushable chain at `(x, y)` one step in `dir`. |
| `shift([[x, y], …])` | arbitrary cells | 0 | Rotate cell contents: each listed cell moves to the next coordinate, last→first. Skips non-pushables and won't move into a non-vacated occupied cell. |
| `set_tile(n)` | self | 0 | Set glyph tile index (CP437 code point in the default font). |
| `set_fg(color)` | self | 0 | Set foreground color (hex string). |
| `set_bg(color)` | self | 0 | Set background color. |
| `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. **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. |
| `now()` | self | — | Move the **most recently enqueued** action to the front of the queue. |
| `alter_gems(n)` | player | 0 | Change the player's gem count (clamped at 0). |
| `alter_health(dh)` | player | 0 | Change the player's health (clamped to `[0, max_health]`). |
| `set_key(color, present)` | player | 0 | Give/take a key color (`"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`, `"orange"`, `"yellow"`, `"white"`). Unknown name logs and is ignored. |
| `die()` | self | 0 | Remove the calling object from the board. |
Enqueues a move one cell in `dir`. Also inserts a `Delay` of 0.25 s after the move, limiting this `delay`/`now` also exist as methods on `me` and on a queue handle: `me.delay(secs)` and
object to ~4 moves per second regardless of frame rate. A blocked move still costs the cooldown. `me.queue.delay(secs)` are equivalent to the free `delay(secs)` for the calling object.
### `scroll(lines)`
Opens a full-screen scrollable overlay and pauses game ticks until the player closes it. Each element
of `lines` is either:
- a **string** — a plain text line, or
- a **2-element array** `[choice_key, display_text]` — a selectable choice.
When the player selects a choice, `choice_key` is dispatched back to the **source object** as a
function call named `choice_key` (see [custom handler functions](#custom-handler-functions) for the
arity rules — a choice handler is called with no `arg`).
```rhai ```rhai
fn tick(dt) { fn bump(me, dir) {
if !blocked(South) { move(South); } scroll([
"The muffin looks delicious.",
"",
["eat", "Eat it"],
["ignore", "Walk away"],
]);
} }
``` fn eat(me, state) { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore(me, state) { log("Wise."); }
### `delay(secs)`
Inserts an explicit pause of `secs` seconds into this object's queue. Adjacent delays are merged
(you never get two consecutive `Delay` entries). Use it to slow down an action sequence or pause
between speech bubbles.
```rhai
fn init() {
say("One…");
delay(2.0);
say("Two!");
now(); // promote the last say() past the delay
}
```
### `now()`
Moves the most recently enqueued action to the **front** of the queue, bypassing any pending
delays. Useful after a zero-cost action (like `say` or `log`) to make it visible immediately even
if a move delay is in progress.
```rhai
fn bump(id) {
say("Ouch!");
now(); // show the bubble right away
}
```
### `set_tile(n)`
Sets this object's glyph tile index to `n` (an integer). The tile index corresponds to a CP437
code point when using the default kiln font.
```rhai
fn init() {
set_tile(1); // ☺
}
```
### `log(msg)`
Appends a plain-text line to the game log. The message is a string.
```rhai
log(`player is at ${Board.player_x}, ${Board.player_y}`);
```
### `say(msg)`
Displays a speech bubble above this object containing `msg`. The bubble lasts 3 seconds. If the
object already has an active bubble, it is replaced (text and timer reset). Zero-cost; combine with
`now()` to surface it immediately from a `bump` or `init` hook.
```rhai
fn bump(id) {
say("Hey!");
now();
}
```
### `set_tag(target_id, tag, present)`
Adds (`present = true`) or removes (`present = false`) a tag on any object identified by
`target_id`. Use `MY_ID` to mutate the calling object's own tags.
```rhai
// Add a tag to self
set_tag(MY_ID, "activated", true);
// Remove a tag from another object by name
let lever_id = object_id_for_name("lever");
set_tag(lever_id, "pulled", true);
``` ```
--- ---
## `Queue` object ## `Queue` object
Accessed via the `Queue` constant in scope. Provides inspection and control of this object's own Inspection and control of an object's own pending-action queue, reached via `me.queue`. Most scripts
pending-action queue. do not need it directly — `me.waiting` covers the common "am I busy?" check.
| Method | Returns | Description | | Member | Kind | Returns | Description |
|--------|---------|-------------| |--------|------|---------|-------------|
| `Queue.length()` | `i64` | Number of actions currently in the queue. | | `me.queue.length` | getter | `i64` | Number of pending actions. |
| `Queue.clear()` | — | Discard all pending actions. | | `me.queue.clear()` | method | — | Discard all pending actions (including delays). |
| `me.queue.delay(secs)` | method | — | Append a pause to the queue. |
Use `Queue.length() == 0` in `tick` to avoid queuing new moves while a previous one is in flight:
```rhai ```rhai
fn tick(dt) { fn tick(me, state, dt) {
if Queue.length() == 0 { if !me.waiting {
if !blocked(East) { move(East); } if !me.blocked(East) { move(East); }
else if !blocked(West) { move(West); } else if !me.blocked(West) { move(West); }
} }
} }
``` ```
@@ -266,49 +367,99 @@ fn tick(dt) {
## Rate limiting and timing ## Rate limiting and timing
- `move(dir)` costs **0.25 s** of queue delay (~4 moves/second max). - `move(dir)` costs **0.25 s** of queue delay (~4 moves/second max).
- `delay(secs)` adds an arbitrary pause. - `delay(secs)` adds an arbitrary pause; `now()` can bypass a delay for a zero-cost action already in
- `now()` can bypass a delay for zero-cost actions already in the queue. the queue.
- `Queue.clear()` cancels everything, including pending delays. - `me.queue.clear()` cancels everything, including pending delays.
- Adjacent delays are always merged: two `delay(0.5)` calls become one `Delay(1.0)` entry. - Adjacent delays are always merged: two `delay(0.5)` calls become one `1.0 s` delay.
- All other writes (`set_tile`, `say`, `push`, `shift`, `teleport`, stat changes, `die`, …) are
zero-cost: a run of them resolves together, and at most one *timed* action (a `move`) is released
per object per cycle.
- `me.waiting` is `true` while a delay sits at the front of the queue — the idiom for "don't issue a
new action until the last one finished" is `if !me.waiting { … }`.
--- ---
## Error handling ## Error handling
Script errors (compile-time or runtime) are logged to the game log as plain-text lines and do not Script errors (compile-time or runtime) are appended to the game log as plain-text lines and do not
crash the game. A script that throws an exception on `tick` will log the error and resume normally crash the game. A script that throws during `tick` logs the error and resumes on the next frame.
on the next frame.
--- ---
## Full example ## Full example
```rhai ```rhai
// A guard that patrols east/west and greets the player when bumped. // A guard that patrols east/west and greets whatever bumps into it.
fn init() { fn init(me, state) {
set_tile(2); set_tile(2);
log(`${my_name()} standing watch`); log(`${me.name} standing watch`);
} }
fn tick(dt) { fn tick(me, state, dt) {
if Queue.length() > 0 { return; } // still moving if me.waiting { return; } // still moving
if !me.blocked(East) { move(East); }
if !blocked(East) { else if !me.blocked(West) { move(West); }
move(East);
} else if !blocked(West) {
move(West);
}
// Stuck in a corner — do nothing this tick.
} }
fn bump(id) { fn bump(me, dir) {
if id == -1 { say(`Halt! Who approaches from the ${dir}?`); now();
say("Halt! Who goes there?");
now();
} else {
say("Watch where you're going!");
now();
}
} }
``` ```
---
## Design notes (rough edges)
Collected friction points in the current surface, as input to a redesign. None of these are bugs;
they are shape problems.
### Duplication — several ways to do one thing
- **Player position/state is reachable two ways.** Player *stats* and *position* both live on
`state.player` now (`.gems`/`.health`/`.x`/`.y`/`.keys`), which is an improvement — but a looked-up
player no longer exists (`state.board.get(-1)` is gone), so code that wants "the player as an
object" has nothing to look up, while every other entity is an `ObjectInfo`.
- **Reads are split across three handles** (`me`, `state.player`, `state.board`) with no single
"query" entry point; which handle owns a given fact (e.g. cell queries on `state.board` vs. self
queries on `me`) has to be memorized.
### Inconsistency — naming and conventions
- **No consistent verb convention.** Setters by name (`set_tile`, `set_tag`, `set_fg`, `set_bg`,
`set_color`, `set_key`), delta verbs (`alter_gems`, `alter_health`), and bare verbs (`move`, `die`,
`teleport`, `push`, `shift`, `scroll`, `say`, `log`) all coexist. Worse, the two "change by a
delta" operations use *different* verbs: `alter_gems` vs `alter_health`.
- **The implicit subject of a write is unpredictable.** `set_tile`/`set_fg`/`say`/`die` act on
**self**; `alter_gems`/`alter_health`/`set_key` act on the **player**; `set_tag`/`send` act on an
**arbitrary target**; `push`/`shift` act on **cells** and `teleport` on an **arbitrary entity**. The `set_*` prefix in particular
means three different subjects.
- **Reads are methods/getters on handles, but writes are free functions.** `me.blocked(dir)` reads,
but `move(dir)` (the matching write) is a bare global that acts on `me` implicitly — the symmetry is
invisible.
- **Mixed addressing for movement.** `move`/`push` take a typed `Direction`, while
`teleport`/`passable`/`can_push`/`shift` take raw `i64` coordinates. There's no cell or position
value type.
- **Self-mutation can't go through `me`.** `me` is read-only, so writing your own tag is
`set_tag(me.id, …)` — you pass your own id back to a free function instead of `me.set_tag(…)`.
- **Colors are stringly-typed** (`"#RRGGBB"`, with the named constants being strings) while tiles are
bare integers with no symbolic names; glyph reads hand back colors as hex strings.
- **Overload coverage is uneven.** `say` has a duration overload, `log` does not; `delay` has int and
float overloads, most numeric fns don't.
### Unwieldy — machinery that leaks
- **The output-queue + `now()`/`delay()` model is subtle.** `now()` reorders by *enqueue
recency* ("most recently enqueued action to the front"), which is position-dependent and easy to
get wrong; the per-object delay draining (leading delays eaten by `dt`, then the ready run applied)
is still a fair amount of implicit state to reason about for "do X, wait, do Y".
- **Two persistence mechanisms.** Tags and the `Registry` are separate stores with separate APIs;
cross-board state must pick one.
- **Stringly-typed dispatch.** `send` and scroll choices route by function *name*; a scroll choice
key is silently also a function name. Renaming a handler breaks callers with no signal.
- **`Registry` alone is a scope constant.** Now that `me`/`state` are parameters, `Registry` is the
one handle with the "invisible inside helper functions" gotcha — an inconsistency with everything
else a hook can reach.
- **Read/write asymmetry on player keys is now closed** (`state.player.keys.*` reads, `set_key`
writes) — but reads are per-color getters while the write is a stringly-named color, so the two
sides still don't mirror each other.
+4 -4
View File
@@ -69,7 +69,7 @@ fn bump(id) {
} }
fn grab() { fn grab() {
add_gems(1); alter_gems(1);
die(); die();
} }
``` ```
@@ -218,7 +218,7 @@ are zero-cost and may all resolve in the same frame.
| `move(dir)` | Step one cell in `dir`, shoving any pushable chain (including the player) ahead; a no-op if blocked. **Costs 0.25 s** before this object can move again. | | `move(dir)` | Step one cell in `dir`, shoving any pushable chain (including the player) ahead; a no-op if blocked. **Costs 0.25 s** before this object can move again. |
| `delay(secs)` | Insert an explicit pause in this object's queue (accepts int or float). Adjacent delays merge. | | `delay(secs)` | Insert an explicit pause in this object's queue (accepts int or float). Adjacent delays merge. |
| `now()` | Promote the most-recently-enqueued action to the **front** of the queue (jump the delay). | | `now()` | Promote the most-recently-enqueued action to the **front** of the queue (jump the delay). |
| `teleport(x, y)` | Jump to an arbitrary cell. Zero cost. Refused (with a logged error at resolve time) if this object is solid and the destination already holds a solid. | | `teleport(id, x, y)` | Jump entity `id` to an arbitrary cell (pass `me.id` for self; `id == -1` is the player). Zero cost. Refused (with a logged error at resolve time) if that entity is solid and the destination already holds a *different* solid. |
### Appearance ### Appearance
@@ -254,7 +254,7 @@ fn tick(dt) {
]); ]);
} }
} }
fn yes() { say("Brave soul!"); add_gems(5); } fn yes() { say("Brave soul!"); alter_gems(5); }
fn no() { say("Coward."); } fn no() { say("Coward."); }
``` ```
@@ -299,7 +299,7 @@ swap([[3, 3, 3, 4], [3, 4, 3, 3]]);
| Call | Effect | | Call | Effect |
|---|---| |---|---|
| `add_gems(n)` | Add `n` to the player's gem count (negative subtracts; clamped at 0). | | `alter_gems(n)` | Add `n` to the player's gem count (negative subtracts; clamped at 0). |
| `die()` | Remove the calling object from the board. Zero cost. | | `die()` | Remove the calling object from the board. Zero cost. |
--- ---
+64 -42
View File
@@ -19,80 +19,102 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/archetype.rs`** — element taxonomy: **`kiln-core/src/archetype.rs`** — element taxonomy:
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Builtin(Builtin, &'static str)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` checks the `Builtin` registry first (via `Builtin::from_name`), then the hard-coded terrain names; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`). - `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Builtin(Builtin, &'static str)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` checks the `Builtin` registry first (via `Builtin::from_name`), then the hard-coded terrain names; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
- `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Pusher`, `Spinner`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_<alias>` tag on the expanded object, and the compile-cache key. - `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Heart`, `Pusher`, `Spinner`, `Key`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_<alias>` tag on the expanded object, and the compile-cache key.
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable). All aliases in a family share one Rhai script; the script reads `Me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality. - `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable), `Transporter` (`"transporter_north|south|east|west"` → 94/118/41/40 `^`/`v`/`)`/`(`; cyan, solid + **see-through** (`opaque: false`) + unpushable — animates a 4-frame loop and, on a front bump, moves whatever solid presses into its entrance — the player, an object, or a **pushed crate** — via a coordinate `shift`, either onto the cell just past it or, if that is blocked, out the far side of the nearest paired opposite-facing transporter). All aliases in a family share one Rhai script; the script reads `me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
**`kiln-core/src/builtin_scripts.rs`** — tag helpers for script-backed archetypes (`pub(crate)`): **`kiln-core/src/builtin_scripts.rs`** — tag helpers for script-backed archetypes (`pub(crate)`):
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_<alias>` (e.g. `"BUILTIN_pusher_east"`) and also uses this string as the object's `script_name` compile-cache key (so each alias gets its own cached AST, though the Rhai source is the same for all aliases in a family). The script reads its tag to determine per-instance direction. `map_file::save` uses `archetype_from_builtin_tag` to collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live in `archetype.rs` via the `builtins!` macro (via `include_str!` of `scripts/*.rhai`); this file has only the three tag helpers. - `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_<alias>` (e.g. `"BUILTIN_pusher_east"`) and also uses this string as the object's `script_name` compile-cache key (so each alias gets its own cached AST, though the Rhai source is the same for all aliases in a family). The script reads its tag to determine per-instance direction. `map_file::save` uses `archetype_from_builtin_tag` to collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live in `archetype.rs` via the `builtins!` macro (via `include_str!` of `scripts/*.rhai`); this file has only the three tag helpers.
**`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`): **`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`):
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`. - `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, and only `Gem` sets the flag). - `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. - `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.
- `Player { x: i32, y: i32 }`current player position. - `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)`): **`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
- `ObjectDef` — a scripted tile: `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `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): **`kiln-core/src/layer.rs`** — the board grid (palette + char map load unit; file/type name kept for history):
- `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. - `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>`.)
- `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.) - `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_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`. - `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; `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. - `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: **`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. - `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`].
- `layer_count() -> usize`; `get(z, x, y) -> &(Glyph, Archetype)` / `get_mut(z, x, y)` — per-layer cell access (panics OOB). - `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.
- `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. - `get(x, y) -> &(Glyph, Archetype)` / `get_mut(x, y)` — grid cell access (panics OOB). (No `z` / `layer_count` — the board is one grid.)
- `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`). - `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`. - `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? - `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
- `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 right gate for a simultaneous shift/rotation via `apply_swap`. The **player** is always a blocker (a shift can't relocate it and `apply_swap` refuses to overwrite it). - `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()`.
- `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`). - `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.
- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries. - `push(x, y, dir) -> Vec<(usize, usize)>` — mutating: shoves the chain one step, leaving a transparent cell behind (so the floor is revealed). A pushed solid moves within the grid. **Returns the destination cells** each shoved solid moved into (empty when nothing moved), so callers can fire `enter` on any non-solid a pushed solid landed on.
- `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/swapped into the player is treated as an ordinary solid (no special-case). (`remove_object` only edits the `objects` map; a live `ScriptHost` keeps a stale `ObjectRuntime` whose later host-fn calls resolve to a missing id and no-op — benign.) - `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)`, `non_solid_object_ids_at(x, y)` (the non-solid objects on a cell — the `enter`-hook targets) — object add/remove + queries.
- `apply_swap(pairs: &[(i32,i32,i32,i32)]) -> Vec<LogLine>` — applies a batch of one-way solid moves **simultaneously** (reads every source's solid occupant — player/object/terrain — into a private `SolidSnapshot` before writing any destination, so cycles and two-cell swaps resolve). A source with no solid moves an "empty", removing the destination's solid (terrain cleared; a displaced object despawned via `remove_object`). The **player is never destroyed** — a write that would overwrite it without relocating it is skipped + logged (a grab object is no exception: it is refused like any other solid). Because a refused solid stays at its source cell, which another entry may also target, a final **overlap sweep** over the swapped cells keeps one solid per cell and deletes the rest (never the player), logging an error per deletion. Out-of-bounds entries are skipped + logged. Backs the script `swap()` fn. - `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.)
- `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`. - `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)]) -> ShiftOutcome` — 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. Returns a `ShiftOutcome { errors: Vec<LogLine>, moves: Vec<((i64,i64),(i64,i64))> }` — the error lines to log plus each `(from, to)` relocation performed, so the caller can fire `enter` at each destination (direction best-effort via `Direction::from_delta`, since a ring may rotate non-adjacent cells).
- `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). - `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. - `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface. - `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
**`kiln-core/src/player.rs`** — game-global player state (`pub`):
- `Player { health: i64, max_health: i64, keys: Keyring, gems: i64 }` — the player's persistent stats, owned by `GameState::player` (separate from the per-board `PlayerPos` position in `utils.rs`). `Default` starts `health = max_health = 5`, `gems = 0`, no keys. `alter_gems(delta) -> bool` adds to `gems` but refuses (returns `false`, no change) if it would go negative. `alter_health(delta)` adds to `health` clamped to `[0, max_health]`. A `Copy` snapshot is handed to each script hook and exposed to Rhai (read-only) as the `Player` constant.
**`kiln-core/src/keys.rs`** — key inventory and colors (`pub`):
- `KeyType` (`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Cyan`/`Purple`/`White`) — the eight key colors. `glyph() -> Glyph` returns the key tile (12, `♀`) in that color's fg — the single source of truth for key colors, used by `Keyring::colors` and by kiln-tui's editor menu. (Moved here from `game.rs`; `archetype.rs`'s `Key` builtin aliases must match these glyphs — see its `key_aliases_have_distinct_fg_colors` test.)
- `Keyring { red, orange, yellow, green, blue, cyan, purple, white: bool }` — the player's key inventory (one bool per color), held by `Player::keys`. `set_by_name(name, value) -> bool` sets a slot by color name (returns `false` for an unknown name); `colors() -> [(bool, Rgba8); 8]` lists all eight in display order paired with their render color.
**`kiln-core/src/game.rs`** — game-loop logic only: **`kiln-core/src/game.rs`** — game-loop logic only:
- `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). - `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). - `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`. - `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>`, `pub player_health: u32` (starts 5) and `pub player_gems: u32` (starts 0) — game-global player stats that persist across board transitions. `player_gems` is changed at runtime by the `add_gems(n)` script fn (e.g. grabbing a gem); `player_health` is still display-only. Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` + an immediate `resolve()` so its `die()`/`add_gems()` apply 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)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log``log`, `SetTile` → source glyph, `Move(dir)``step_object`, `Say``speech_bubbles`, `Scroll``active_scroll`, `AddGems(n)``player_gems`, `Die``remove_object(source)`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object. - `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, 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; it also fires `enter(dir_from)` on any **non-solid** object under the player's new cell or under a cell a shoved crate landed on; 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`). `tick` is called on an object **only when its output queue is empty**; a non-empty queue (pending actions/`Delay` from an earlier tick) means the queue is just drained by `dt` and the script is not re-run until it empties. The workhorse is `apply_actions(Vec<BoardAction>) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` and `(entered, 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`/`enter`/`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. `Events` carries three reaction lists — `bumps`, `enters`, `sends`; the `Move`/`Push`/`Teleport`/`Shift` arms populate `enters` for every non-solid a solid relocated onto (via `Board::non_solid_object_ids_at`), the direction exact for a one-cell move/push and best-effort (`Direction::from_delta`) for a teleport/shift. `step_object` returns a `StepOutcome { bumped, entered }` (a non-solid mover contributes no own-cell `enter`; pushed crates always do). `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/enter/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 Rhai-facing conversion: **`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). - `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). - `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).
- `Action` (`pub(crate)`)deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String)`, `Delay(f64)` (never reaches the board queue — consumed by `ScriptHost::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Swap(Vec<(i32,i32,i32,i32)>)` (batch of simultaneous one-way solid moves; zero time cost), `AddGems(i64)` (change `GameState::player_gems`, clamped at 0; zero cost), `Die` (remove the source object; zero cost). - `SendArg`the optional argument carried by a `Send` action / `send()` call: `None`/`Int`/`Float`/`String`, with `From<Dynamic>`/`Into<Dynamic>` conversions.
- `action_to_map(action) -> rhai::Map` — converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. The map always has a `"type"` key; other keys carry the payload. `Scroll`/`Swap` emit `type` only (their payloads are not inspectable via the map API). `Log` is flattened to its first span's text. - `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: **`kiln-core/src/floor.rs`** — the board floor attribute + procedural 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. - `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; `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. - `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: **`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. - `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a `LogLine` to its own styled text at render time.
**`kiln-core/src/script.rs`** — Rhai scripting runtime: **`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).
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects, a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: the key is the object's `script_name` (a world-pool name for a named script, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins, e.g. all pushers, share one AST); the source is the pool entry for that name, or the object's embedded `builtin_script` when present. Reports compile/unknown-script failures onto the error sink; runs nothing. - `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`.
- Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), and `grab()` (zero-arg; fired when the player walks onto a `grab` thing — the only grab trigger), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)` / `run_grab(object_id)`; runtime errors go to the error sink (drained to the log), not fatal. - `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`/`has_enter`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the `LogSink`; runs nothing.
- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `can_push(x, y, dir) -> bool` is a read fn mirroring `Board::can_push`: true if `(x, y)` holds a pushable whose chain can be shoved in `dir` (false off-board). `can_shift(x, y, dir) -> bool` mirrors `Board::can_shift`: like `can_push` but only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation via `swap`. `passable(x, y) -> bool` mirrors `Board::is_passable`: true if `(x, y)` is on-board and holds no solid (off-board → false), letting a script tell a hole apart from a blocked solid (which `can_shift`/`can_push` alone cannot). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`. - 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), `enter(me, dir)` (the non-solid counterpart to `bump`: fired on a non-solid object when a solid *relocates onto* its cell — a player/object step, a pushed crate, a `teleport`, or a `shift` — with `dir` the side the entrant came from, best-effort for teleport/shift), 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 and enter are 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_enter(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. The `Tick` hook is **gated on an empty queue** (`hook != Hook::Tick || info.queue.is_empty()`), so an object with pending actions/`Delay` from an earlier tick isn't re-ticked; all other hooks fire whenever defined. After the call — **whether or not the hook was called** — it always drains the object's queue, so a delay still advances on an object that has no `tick` (or whose `tick` was skipped this frame). Runtime errors go to the shared `LogSink` (drained to the log), not fatal.
- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)`, `swap(pairs)`, `add_gems(n)` (adjust the player's gem count), `die()` (remove the calling object) append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`/`Push`/`Swap`; `MOVE_COST = 0.25` s for `Move`, else 0 — `push`/`swap` add no delay) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `push(x, y, dir)` shoves the pushable chain at arbitrary coords `(x, y)`. `swap(pairs)` takes an array of four-int `[src_x, src_y, dst_x, dst_y]` arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see `Board::apply_swap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`). - `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.
- The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`). - **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.
- **Sender identity:** `source` (which object issued an action) rides the per-call **tag**`run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From<Direction> for (i32,i32)` gives the delta. - **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.
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui. - `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
- **Sender identity uses stable ids:** `BoardAction.source`, `ObjectRuntime.object_id`, and the `QueueMap` keys are all the object's `ObjectId` (matching `Board::objects`' `BTreeMap` keys), so they survive object spawn/destroy/reorder. The per-call tag carries the id as an `i64` (id 0 — never valid — is the no-source fallback). `ScriptHost::objects` is still a `Vec<ObjectRuntime>` built by iterating the board map, so it stays in ascending-id order.
**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in `layer.rs`): **`kiln-core/src/api/`** — the Rhai **script API**: the types scripts actually touch, each implementing `script::Registerable`. Split out of `script.rs` so the host (engine/queues/dispatch) and the script-facing surface can evolve separately. (Author-facing reference: [`docs/script-api.md`](../docs/script-api.md).)
- `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`). - `api::state``ScriptState { player: PlayerWithPos, board: BoardRef }` (Rhai type **`State`**), the `state` parameter of every hook. `from_game_state` snapshots the player and clones the board handle; getters `state.player` / `state.board`.
- `api::player``PlayerWithPos(Player, PlayerPos)` (Rhai type **`Player`**), a `Copy` bundle of the game-global `Player` stats and the per-board `PlayerPos` so they register as one Rhai object. Getters `gems`, `health`, `max_health`, `keys` (a `Keyring`, exposing one `bool` getter per color), `x`, `y`.
- `api::board``BoardRef = Rc<RefCell<Board>>` (Rhai type **`Board`**), a read-only handle. Getters `width`/`height`; methods `can_push(x, y, dir)`, `passable(x, y)`, `get(id)` (→ `ObjectInfo` or `()`; `id <= 0` logs an error), `named(name)` (→ `ObjectInfo` or `()`), `tagged(tag)` (→ array of `ObjectInfo`).
- `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 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`. - `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 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 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 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 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. - `pub fn save(board: &Board, path: &Path) -> Result<(), …>` — serializes `Board` back to a single-board TOML file.
+2
View File
@@ -5,7 +5,9 @@ edition = "2024"
[dependencies] [dependencies]
color = "0.3.3" color = "0.3.3"
doryen-fov = "0.1"
rhai = "1" rhai = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
tinyrand = "0.5" tinyrand = "0.5"
toml = { version = "0.8", features = ["preserve_order"] } toml = { version = "0.8", features = ["preserve_order"] }
log = "0.4.33"
+89 -126
View File
@@ -1,12 +1,12 @@
//! The [`Action`] enum and its Rhai-facing conversions. //! The [`Action`] enum and supporting types.
//! //!
//! This module owns the `Action` type (which scripts enqueue via write host //! This module owns the `Action` type (which scripts enqueue via write host
//! functions), the rate-limiting delay constant, and [`action_to_map`] which //! functions), the rate-limiting delay constant [`MOVE_COST`], the [`ScrollLine`]
//! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. //! and [`SendArg`] payload types, and [`BoardAction`] (an action tagged with the
//! object that issued it).
use crate::log::LogLine; use std::fmt::Debug;
use crate::map_file::color_to_hex; use crate::utils::{Direction, ObjectId};
use crate::utils::{Direction, ObjectId, ScriptArg};
use color::Rgba8; use color::Rgba8;
use rhai::Dynamic; use rhai::Dynamic;
@@ -27,19 +27,54 @@ pub enum ScrollLine {
Choice { choice: String, display: String }, Choice { choice: String, display: String },
} }
#[derive(Clone, PartialEq, Debug)]
pub enum SendArg {
None,
Int(i64),
Float(f64),
String(String),
}
impl From<Dynamic> for SendArg {
fn from(value: Dynamic) -> Self {
if value.is_int() {
Self::Int(value.as_int().unwrap())
} else if value.is_float() {
Self::Float(value.as_float().unwrap())
} else if value.is_string() {
Self::String(value.into_string().unwrap())
} else {
Self::None
}
}
}
impl From<SendArg> for Dynamic {
fn from(val: SendArg) -> Self {
match val {
SendArg::None => Dynamic::UNIT,
SendArg::Int(value) => Dynamic::from(value),
SendArg::Float(value) => Dynamic::from(value),
SendArg::String(value) => Dynamic::from(value),
}
}
}
/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`] /// One deferred mutation emitted by a script, applied by [`crate::game::GameState`]
/// after it is promoted onto the board queue. /// after it is promoted onto the board queue.
/// ///
/// [`Action::Delay`] is the exception: it is **never** promoted to the board queue. /// [`Action::Delay`] is the exception: it is **never** promoted to the board queue.
/// It lives only in the per-object output queue and is consumed by `ScriptHost::drain` /// It lives only in the per-object output queue and is consumed by `ScriptHost::drain`
/// to pace how quickly other actions are released. /// to pace how quickly other actions are released.
pub(crate) enum Action { #[derive(Clone)]
pub enum Action {
/// Move the source object one cell in a direction (subject to passability). /// Move the source object one cell in a direction (subject to passability).
Move(Direction), Move(Direction),
/// Set the source object's glyph tile index. /// Set the source object's glyph tile index.
SetTile(u32), SetTile(u32),
/// Append a styled line to the game log. /// Set the source object's light radius in cells (0 = no light). Zero time
Log(LogLine), /// cost. Applied to the source [`ObjectDef::light`](crate::object_def::ObjectDef::light).
SetLight(u32),
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`. /// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
SetTag { SetTag {
target: ObjectId, target: ObjectId,
@@ -59,141 +94,69 @@ pub(crate) enum Action {
Send { Send {
target: ObjectId, target: ObjectId,
fn_name: String, fn_name: String,
arg: Option<ScriptArg>, arg: SendArg,
}, },
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice /// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// selection dispatches [`Send`](Action::Send) to the source object. /// selection dispatches [`Send`](Action::Send) to the source object.
Scroll(Vec<ScrollLine>), Scroll(Vec<ScrollLine>),
/// Teleport the source object to `(x, y)`. Blocked (with a logged error) if /// Teleport object `target` to `(x, y)` (`target == -1` is the player).
/// the source is solid and the destination already has a solid occupant. /// Blocked (with a logged error) if that entity is solid and the destination
/// Zero time cost — multiple teleports may fire in one frame. /// already has a *different* solid occupant. Zero time cost — multiple
Teleport { x: i32, y: i32 }, /// teleports may fire in one frame.
Teleport { target: i64, x: i64, y: i64 },
/// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on /// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on
/// arbitrary cells, not the source object). Zero time cost. /// arbitrary cells, not the source object). Zero time cost.
Push { x: i32, y: i32, dir: Direction }, Push { x: i64, y: i64, dir: Direction },
/// Shift a set of cells, given as `(x, y)` coordinates: each cell moves to the /// Shift a set of cells, given as `(x, y)` coordinates: each cell moves to the
/// next coordinate in the list, unless it can't move, or that cell is blocked. /// next coordinate in the list, unless it can't move, or that cell is blocked.
/// Rotates the contents of the last cell back to the beginning. /// Rotates the contents of the last cell back to the beginning.
/// Zero time cost. /// Zero time cost.
Shift(Vec<(i32, i32)>), Shift(Vec<(i64, i64)>),
/// Add `n` to the player's gem count (negative subtracts; the count is /// Add `n` to the player's gem count (negative subtracts; the count is
/// clamped at 0). Zero time cost. Applied to `GameState::player_gems`. /// clamped at 0). Zero time cost. Applied to `GameState::player.gems`.
AddGems(i64), AddGems(i64),
/// Add `dh` to the player's health (clamped to `[0, max_health]`). Zero time
/// cost. Applied to `GameState::player.health`.
AlterHealth(i64),
/// Give (`true`) or take (`false`) the named key color from the player.
///
/// Color must be one of `"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`,
/// `"orange"`, `"yellow"`, `"white"`. An unrecognized name is logged and
/// ignored. Zero time cost. Applied to `GameState::player.keys`.
SetKey(String, bool),
/// Remove the source object from the board. Zero time cost. Used by grab /// Remove the source object from the board. Zero time cost. Used by grab
/// things (e.g. gems) to despawn themselves from their `grab()` hook. /// things (e.g. gems) to despawn themselves from their `grab()` hook.
Die, Die,
} }
// ── Direction helper ────────────────────────────────────────────────────────── impl Debug for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
fn dir_to_str(d: Direction) -> &'static str { match self {
match d { Action::Move(dir) => write!(f, "Move({:?})", dir),
Direction::North => "North", Action::SetTile(i) => write!(f, "SetTile({i})"),
Direction::South => "South", Action::SetLight(r) => write!(f, "SetLight({r})"),
Direction::East => "East", Action::SetTag { .. } => write!(f, "SetTag"),
Direction::West => "West", Action::Say(_, _) => write!(f, "Say"),
Action::Delay(t) => write!(f, "Delay({t})"),
Action::SetColor { .. } => write!(f, "SetColor"),
Action::Send { .. } => write!(f, "Send"),
Action::Scroll(_) => write!(f, "Scroll"),
Action::Teleport { .. } => write!(f, "Teleport"),
Action::Push { .. } => write!(f, "Push"),
Action::Shift(_) => write!(f, "Shift"),
Action::AddGems(n) => write!(f, "AddGems({n}"),
Action::AlterHealth(health) => write!(f, "AlterHealth({health})"),
Action::SetKey(_, _) => write!(f, "SetKey"),
Action::Die => write!(f, "Die")
}
} }
} }
// ── action_to_map ───────────────────────────────────────────────────────────── /// An action promoted from an object's output queue onto the board queue, tagged
/// with the object that issued it.
/// Converts an [`Action`] to a Rhai map for `Queue.peek()` / `Queue.pop()`. pub struct BoardAction {
/// /// The stable [`ObjectId`] of the object that issued the action.
/// The map always has a `"type"` key naming the variant; other keys carry the pub(crate) source: ObjectId,
/// payload. [`Action::Log`] is flattened to its first span's text. /// The action to apply.
pub(crate) fn action_to_map(action: &Action) -> rhai::Map { pub(crate) action: Action,
// Dynamic::from(String) avoids rhai 1.x's From<&str> lifetime restriction.
let ds = |v: &'static str| Dynamic::from(v.to_string());
let mut m = rhai::Map::new();
match action {
Action::Move(dir) => {
m.insert("type".into(), ds("Move"));
m.insert("dir".into(), ds(dir_to_str(*dir)));
}
Action::SetTile(n) => {
m.insert("type".into(), ds("SetTile"));
m.insert("tile".into(), Dynamic::from(*n as i64));
}
Action::Log(line) => {
let text = line
.spans
.first()
.map(|s| s.text.as_str())
.unwrap_or("")
.to_string();
m.insert("type".into(), ds("Log"));
m.insert("msg".into(), Dynamic::from(text));
}
Action::SetTag {
target,
tag,
present,
} => {
m.insert("type".into(), ds("SetTag"));
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("tag".into(), Dynamic::from(tag.clone()));
m.insert("present".into(), Dynamic::from(*present));
}
Action::Say(text, dur) => {
m.insert("type".into(), ds("Say"));
m.insert("msg".into(), Dynamic::from(text.clone()));
m.insert("duration".into(), Dynamic::from(*dur));
}
Action::Delay(secs) => {
m.insert("type".into(), ds("Delay"));
m.insert("secs".into(), Dynamic::from(*secs));
}
Action::SetColor { fg, bg } => {
m.insert("type".into(), ds("SetColor"));
if let Some(c) = fg {
m.insert("fg".into(), Dynamic::from(color_to_hex(*c)));
}
if let Some(c) = bg {
m.insert("bg".into(), Dynamic::from(color_to_hex(*c)));
}
}
Action::Send {
target,
fn_name,
arg,
} => {
m.insert("type".into(), ds("Send"));
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("name".into(), Dynamic::from(fn_name.clone()));
if let Some(a) = arg {
let dyn_arg = match a {
ScriptArg::Str(s) => Dynamic::from(s.clone()),
ScriptArg::Num(n) => Dynamic::from(*n),
};
m.insert("arg".into(), dyn_arg);
}
}
// Scroll lines are not inspectable via the queue map API; emit type only.
Action::Scroll(_) => {
m.insert("type".into(), ds("Scroll"));
}
Action::Teleport { x, y } => {
m.insert("type".into(), ds("Teleport"));
m.insert("x".into(), Dynamic::from(*x as i64));
m.insert("y".into(), Dynamic::from(*y as i64));
}
Action::Push { x, y, dir } => {
m.insert("type".into(), ds("Push"));
m.insert("x".into(), Dynamic::from(*x as i64));
m.insert("y".into(), Dynamic::from(*y as i64));
m.insert("dir".into(), ds(dir_to_str(*dir)));
}
// Shift cells are not inspectable via the queue map API; emit type only.
Action::Shift(_) => {
m.insert("type".into(), ds("Shift"));
}
Action::AddGems(n) => {
m.insert("type".into(), ds("AddGems"));
m.insert("n".into(), Dynamic::from(*n));
}
Action::Die => {
m.insert("type".into(), ds("Die"));
}
}
m
} }
+98
View File
@@ -0,0 +1,98 @@
//! ## Board read API (`state.board.*`)
//!
//! - `board.tagged(tag) -> Array[ObjectInfo]` — objects carrying a tag
//! - `board.named(name) -> ObjectInfo | ()` — object with that name (or unit)
//! - `board.get(id) -> ObjectInfo | ()` — look up by id; `-1` returns player info
//! - `board.width` - Board width
//! - `board.height` - Board height
//!
//! ## Cell queries
//!
//! - `board.can_push(x, y, dir) -> bool` — is the pushable chain at `(x, y)` shovable in `dir`
//! - `board.passable(x, y) -> bool` — is `(x, y)` on-board and free of any solid (a hole)
//! cell (one empty, or a grab thing and the player)
use std::cell::RefCell;
use std::rc::Rc;
use rhai::{Dynamic, Engine, ImmutableString};
use crate::{Board, Direction};
use crate::api::object_info::ObjectInfo;
use crate::api::registry::Registry;
use crate::script::Registerable;
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, 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);
// can_push(x, y, dir) — true if (x, y) holds a pushable whose chain can be
// shoved one step in dir (the read-only half of push()). False off-board.
engine.register_fn("can_push", move |board: BoardRef, x: i64, y: i64, dir: Direction| -> bool {
let board = board.borrow();
board.in_bounds((x, y)) && board.can_push(x as usize, y as usize, dir)
});
// passable(x, y) — true if (x, y) is on-board and holds no solid (an empty cell
// a mover could enter). Off-board is not passable. Lets a script distinguish a
// hole from a blocked solid (which can_shift/can_push alone cannot).
engine.register_fn("passable", move |board: BoardRef, x: i64, y: i64| -> bool {
let board = board.borrow();
board.in_bounds((x, y)) && board.is_passable(x as usize, y as usize)
});
// Board.get(id) -> ObjectInfo | () (unknown id logs error)
engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic {
if id <= 0 {
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 {
log_sink.error(format!("Board.get: no object with id {id}"));
Dynamic::UNIT
}
});
// Board.named(name) -> ObjectInfo | ()
engine.register_fn("named", move |board_ref: &mut BoardRef, name: ImmutableString| -> Dynamic {
let board = board_ref.borrow();
board
.objects
.iter()
.find_map(|(_id, def)| {
if def.name.as_deref() == Some(name.as_str()) {
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone())))
} else {
None
}
})
.unwrap_or(Dynamic::UNIT)
},
);
// Board.tagged(tag) -> Array[ObjectInfo]
engine.register_fn("tagged", move |board_ref: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
let board = board_ref.borrow();
board
.objects.values().filter_map(|def| {
if def.tags.contains(tag.as_str()) {
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone())))
} else {
None
}
})
.collect()
},
);
// Board.registry -> Registry
engine.register_get("registry", |b: &mut BoardRef| Registry(b.clone()));
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod player;
pub mod board;
pub mod object_info;
pub mod queue;
pub mod registry;
+146
View File
@@ -0,0 +1,146 @@
//! ## Object Rhai API
//!
//! ### Getters
//!
//! - x, y -> Board location of object
//! - id -> The object's board-unique id
//! - name -> The object's name, or ()
//! - waiting -> bool for whether or not the front of this object's queue is a delay action
//! - queue -> the action queue for this object
//! - tags -> Array of tags for this object
//! - glyph -> The object's current glyph
//!
//! ### Functions
//!
//! - has_tag(tag) -> Whether the object has this tag
//! - delay(secs) -> Queue a delay action
//! - can_push(dir) -> Whether a push in this direction would succeed
//! - blocked(dir) -> Whether a solid neighbors me in this direction
//!
//! Note on blocking: can_push and blocked only take into account the contents of the board
//! at the start of the call; if another thing moves before your actions are flushed to the
//! gamestate, a move might still fail. This is a TODO.
use rhai::{Dynamic, Engine};
use crate::api::board::BoardRef;
use crate::api::queue::ObjQueue;
use crate::Direction;
use crate::action::BoardAction;
use crate::object_def::ObjectDef;
use crate::script::Registerable;
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.
/// It also contains things like script_name used by ScriptHost, since we can't keep a live
/// borrow of the board while doing anything: we create one of these loose and then
/// it borrows the board only for the duration of what it needs.
#[derive(Clone)]
pub struct ObjectInfo {
pub id: ObjectId,
pub x: i64,
pub y: i64,
pub board: BoardRef,
pub script_name: Option<String>,
pub queue: ObjQueue
}
impl ObjectInfo {
pub fn from_id(id: ObjectId, board: BoardRef) -> Option<ObjectInfo> {
let b = board.borrow();
let obj = b.objects.get(&id)?;
Some(ObjectInfo {
id,
x: obj.x as i64,
y: obj.y as i64,
board: board.clone(),
script_name: obj.script_name.clone(),
queue: obj.queue.clone()
})
}
pub fn from_def(obj: &ObjectDef, board: BoardRef) -> ObjectInfo {
Self {
id: obj.id,
x: obj.x as i64,
y: obj.y as i64,
board: board.clone(),
script_name: obj.script_name.clone(),
queue: obj.queue.clone()
}
}
pub fn drain(&mut self, target: &mut Vec<BoardAction>, dt: f64) {
let mut b = self.board.borrow_mut();
if let Some(def) = b.objects.get_mut(&self.id) {
def.queue.drain(self.id, target, dt)
}
}
}
impl Registerable for ObjectInfo {
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)
.register_get("id", |obj: &mut ObjectInfo| obj.id as i64)
.register_get("waiting", |obj: &mut ObjectInfo| obj.queue.waiting())
.register_get("queue", |obj: &mut ObjectInfo| obj.queue.clone());
engine.register_get("name", |o: &mut ObjectInfo| {
let board = o.board.borrow();
let obj = board.objects.get(&o.id);
if let Some(ObjectDef { name: Some(name), ..}) = obj {
Dynamic::from(name.clone())
} else {
Dynamic::UNIT
}
});
engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array {
let board = o.board.borrow();
let obj = board.objects.get(&o.id);
if let Some(ObjectDef { tags, .. }) = obj {
tags.iter().map(|t| Dynamic::from(t.clone())).collect()
} else {
rhai::Array::new()
}
});
engine.register_get("glyph", |o: &mut ObjectInfo| {
let board = o.board.borrow();
let obj = board.objects.get(&o.id).unwrap();
obj.glyph
});
// me.light: the object's current emitted light radius in cells (0 = none).
engine.register_get("light", |o: &mut ObjectInfo| -> i64 {
match o.board.borrow().objects.get(&o.id) {
Some(ObjectDef { light, .. }) => *light as i64,
None => 0,
}
});
engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| {
if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) {
tags.contains(&t)
} else {
false
}
});
engine.register_fn("delay", |o: &mut ObjectInfo, dt: f64| o.queue.delay(dt));
engine.register_fn("can_push", move |o: &mut ObjectInfo, dir: Direction| {
let board = o.board.borrow();
board.in_bounds((o.x, o.y)) && board.can_push(o.x as usize, o.y as usize, dir)
});
engine.register_fn("blocked", move |o: &mut ObjectInfo, dir: Direction| -> bool {
let board = o.board.borrow();
let tx = o.x + dir.dx();
let ty = o.y + dir.dy();
board.in_bounds((o.x, o.y)) && !board.is_passable(tx as usize, ty as usize)
});
}
}
+22
View File
@@ -0,0 +1,22 @@
use rhai::Engine;
use crate::api::board::BoardRef;
use crate::player::PlayerRef;
use crate::script::Registerable;
use crate::utils::LogSink;
/// GameState stores player state but Board stores its position, and we want one
/// object to register with Rhai
#[derive(Clone)]
pub struct PlayerWithPos(pub PlayerRef, pub BoardRef);
impl Registerable for PlayerWithPos {
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)
.register_get("max_health", |player: &mut PlayerWithPos| player.0.borrow().max_health)
.register_get("keys", |player: &mut PlayerWithPos| player.0.borrow().keys)
.register_get("x", |player: &mut PlayerWithPos| player.1.borrow().player.x)
.register_get("y", |player: &mut PlayerWithPos| player.1.borrow().player.y);
}
}
+111
View File
@@ -0,0 +1,111 @@
//! ## Queue API
//!
//! `queue.length`, `queue.clear()`, `queue.delay()`
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use rhai::{Dynamic, Engine};
use crate::action::{Action, BoardAction};
use crate::script::Registerable;
use crate::utils::{LogSink, ObjectId};
/// A single object's output queue.
#[derive(Clone)]
pub struct ObjQueue(Rc<RefCell<VecDeque<Action>>>);
impl ObjQueue {
pub fn new() -> Self {
Self(Rc::new(RefCell::new(VecDeque::new())))
}
/// Add a delay to the tail of the queue, or alter the value already there by the given amount.
/// A delay action's value can never be less than 0.0
/// TODO This really ought to be Duration
pub fn delay(&mut self, delay: f64) {
let mut q = self.0.borrow_mut();
if let Some(Action::Delay(r)) = q.back_mut() {
*r = (*r + delay).max(0.0)
} else {
q.push_back(Action::Delay(delay.max(0.0)))
}
}
/// Add an action to the tail of the queue
pub fn act(&mut self, action: Action) {
self.0.borrow_mut().push_back(action);
}
/// promote the most recently enqueued action to the front.
pub fn now(&mut self) {
let mut q = self.0.borrow_mut();
if let Some(back) = q.pop_back() {
q.push_front(back);
}
}
/// Drains object `i`'s output queue into `target`: first advances leading
/// `Delay` actions by dt, then moves the run of ready actions into `target`
/// until we run out (or hit another delay). Each ready action is tagged with
/// `source` so [`GameState`](crate::game::GameState) knows who issued it.
pub fn drain(&mut self, source: ObjectId, target: &mut Vec<BoardAction>, mut dt: f64) {
let mut queue = self.0.borrow_mut();
loop {
match queue.front_mut() {
None => break, // No more actions, we're done
Some(Action::Delay(time)) => { // A delay, decrease it by dt
if dt < *time { // Delay is too long, bail out
*time -= dt;
break;
} else { // dt eats the delay, pop it and continue
dt -= *time;
queue.pop_front();
}
}
Some(_) => {
let action = queue.pop_front().unwrap();
target.push(BoardAction { source, action });
}
}
}
}
/// Return whether this queue has an `Action::Delay` in the front
pub fn waiting(&mut self) -> bool {
matches!(self.0.borrow().front(), Some(Action::Delay(_)))
}
/// Whether this queue currently holds no pending actions.
pub fn is_empty(&self) -> bool {
self.0.borrow().is_empty()
}
pub fn clear(&mut self) {
self.0.borrow_mut().clear();
}
}
impl Registerable for ObjQueue {
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);
// Appends a [`Action::Delay`] to the back of `queue`, merging with an existing
// trailing delay to prevent adjacent delays from accumulating.
engine.register_fn("delay", move |q: &mut ObjQueue, secs: Dynamic| {
let secs: Result<f64, &str> = if secs.is_float() {
secs.as_float()
} else if secs.is_int() {
secs.as_int().map(|i| i as f64)
} else {
Err("delay() must be an int or float")
};
match secs {
Ok(secs) => q.delay(secs),
Err(msg) => log_sink.error(msg.to_string())
}
});
}
}
+63
View File
@@ -0,0 +1,63 @@
use rhai::{Dynamic, Engine, ImmutableString};
use crate::api::board::BoardRef;
use crate::script::Registerable;
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
/// that persist across board transitions. Mutation goes through the inner
/// `Rc<RefCell<Board>>`, so the struct itself can be shared as a scope constant.
#[derive(Clone)]
pub struct Registry(pub BoardRef);
impl Registerable for Registry {
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.
engine.register_fn("get", |r: &mut Registry, key: ImmutableString| -> Dynamic {
r.0
.borrow()
.registry
.get(key.as_str())
.map(|v| v.clone().into())
.unwrap_or(Dynamic::UNIT)
});
// Registry.set(key, value) — stores a primitive value; () removes the key;
// unsupported types (closures, custom objects) are silently ignored.
engine.register_fn(
"set",
|r: &mut Registry, key: ImmutableString, value: Dynamic| {
match RegistryValue::try_from(value.clone()) {
Ok(rv) => {
r.0.borrow_mut().registry.insert(key.to_string(), rv);
}
Err(()) if value.is_unit() => {
r.0.borrow_mut().registry.remove(key.as_str());
}
Err(()) => {} // unsupported type — silently ignore
}
},
);
// Registry.get_or(key, default) — returns the stored value when present and the
// same Rhai type as `default`; falls back to `default` otherwise.
engine.register_fn(
"get_or",
|r: &mut Registry, key: ImmutableString, default: Dynamic| -> Dynamic {
if let Some(stored) = r.0.borrow().registry.get(key.as_str()).cloned() {
let candidate: Dynamic = stored.into();
// Only return the stored value if it matches the type of the default.
if candidate.type_id() == default.type_id() {
candidate
} else {
default
}
} else {
default
}
},
);
}
}
+116 -22
View File
@@ -1,15 +1,16 @@
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::utils::{Behavior, Pushable}; use crate::utils::{Behavior, Pushable};
use color::Rgba8; use color::Rgba8;
use crate::keys::KeyType;
/// Declares the set of script-backed archetype families. /// Declares the set of script-backed archetype families.
/// ///
/// Each entry specifies: /// Each entry specifies:
/// - A `Variant` name (becomes a [`Builtin`] enum variant). /// - A `Variant` name (becomes a [`Builtin`] enum variant).
/// - A `["name" => tile, …]` list: one map-file keyword per alias with the tile /// - A `["name" => Glyph { … }, …]` list: one map-file keyword per alias with the
/// index for the editor glyph. All aliases in a family share `behavior`, `fg`, /// default [`Glyph`] for the editor. Per-alias glyphs allow aliases in the same
/// `bg`, and embedded Rhai `script`. /// family to differ in color (e.g. the eight `Key` variants).
/// - `behavior`, `fg`, `bg`: per-family defaults. /// - `behavior`: shared across all aliases in the family.
/// - `script`: the embedded Rhai source; `include_str!` paths are relative to this /// - `script`: the embedded Rhai source; `include_str!` paths are relative to this
/// file, so `include_str!("scripts/pusher.rhai")` resolves to /// file, so `include_str!("scripts/pusher.rhai")` resolves to
/// `kiln-core/src/scripts/pusher.rhai`. /// `kiln-core/src/scripts/pusher.rhai`.
@@ -21,10 +22,8 @@ use color::Rgba8;
macro_rules! builtins { macro_rules! builtins {
( (
$( $(
$variant:ident => [ $( $name:literal => $tile:literal ),+ $(,)? ] { $variant:ident => [ $( $name:literal => $glyph:expr ),+ $(,)? ] {
behavior: $behavior:expr, behavior: $behavior:expr,
fg: $fg:expr,
bg: $bg:expr,
script: $script:expr $(,)? script: $script:expr $(,)?
} }
),+ $(,)? ),+ $(,)?
@@ -66,16 +65,14 @@ macro_rules! builtins {
} }
} }
/// Returns the glyph for `alias`: the family's fg/bg with the alias's tile. /// Returns the default glyph for `alias`. Each alias owns its own glyph,
/// so aliases within a family can differ in color (e.g. colored keys).
/// Falls back to a transparent glyph for unrecognized aliases (shouldn't /// Falls back to a transparent glyph for unrecognized aliases (shouldn't
/// happen in practice since aliases are all from the macro). /// happen in practice since aliases are all from the macro).
pub(crate) fn default_glyph_for(self, alias: &str) -> Glyph { pub(crate) fn default_glyph_for(self, alias: &str) -> Glyph {
// The outer repetition brings $fg/$bg into scope; the inner one selects
// the tile for the specific alias. Both are statically resolved at
// compile time.
match alias { match alias {
$( $(
$( $name => Glyph { tile: $tile, fg: $fg, bg: $bg }, )+ $( $name => $glyph, )+
)+ )+
_ => Glyph::transparent(), _ => Glyph::transparent(),
} }
@@ -91,25 +88,65 @@ macro_rules! builtins {
}; };
} }
builtins! { // Shorthand helpers used only within the builtins! invocation below.
Gem => ["gem" => 4] { // `g(tile, r, g, b)` builds a Glyph with the given tile and fg on black bg.
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true }, const fn g(tile: u32, r: u8, gr: u8, b: u8) -> Glyph {
fg: Rgba8 { r: 0x50, g: 0x50, b: 0xFF, a: 255 }, Glyph {
tile,
fg: Rgba8 { r, g: gr, b, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
builtins! {
Gem => ["gem" => g(4, 0x50, 0x50, 0xFF)] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true },
script: include_str!("scripts/gem.rhai"), script: include_str!("scripts/gem.rhai"),
}, },
Pusher => ["pusher_north" => 30, "pusher_south" => 31, "pusher_east" => 16, "pusher_west" => 17] { Heart => ["heart" => g(3, 0xCC, 0x22, 0x22)] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true },
script: include_str!("scripts/heart.rhai"),
},
Pusher => [
"pusher_north" => g(30, 0xAA, 0xAA, 0xAA),
"pusher_south" => g(31, 0xAA, 0xAA, 0xAA),
"pusher_east" => g(16, 0xAA, 0xAA, 0xAA),
"pusher_west" => g(17, 0xAA, 0xAA, 0xAA),
] {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false }, behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false },
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
script: include_str!("scripts/pusher.rhai"), script: include_str!("scripts/pusher.rhai"),
}, },
Spinner => ["spinner_cw" => 47, "spinner_ccw" => 92] { Spinner => [
"spinner_cw" => g(47, 0xAA, 0xAA, 0xAA),
"spinner_ccw" => g(92, 0xAA, 0xAA, 0xAA),
] {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false }, behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false },
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
script: include_str!("scripts/spinner.rhai"), script: include_str!("scripts/spinner.rhai"),
}, },
// Solid, see-through (opaque: false), unpushable teleporters. Each direction's
// default glyph is the first frame of its animation loop (see transporter.rhai).
Transporter => [
"transporter_north" => g(94, 0x55, 0xFF, 0xFF), // '^'
"transporter_south" => g(118, 0x55, 0xFF, 0xFF), // 'v'
"transporter_east" => g(41, 0x55, 0xFF, 0xFF), // ')'
"transporter_west" => g(40, 0x55, 0xFF, 0xFF), // '('
] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::No, grab: false },
script: include_str!("scripts/transporter.rhai"),
},
Key => [ // TODO these should refer to the key colors in Keyring
"key_blue" => KeyType::Blue.glyph(),
"key_green" => KeyType::Green.glyph(),
"key_cyan" => KeyType::Cyan.glyph(),
"key_red" => KeyType::Red.glyph(),
"key_purple" => KeyType::Purple.glyph(),
"key_orange" => KeyType::Orange.glyph(),
"key_yellow" => KeyType::Yellow.glyph(),
"key_white" => KeyType::White.glyph(),
] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true },
script: include_str!("scripts/key.rhai"),
}
} }
/// A class of board cell, encoding its default behavior and appearance. /// A class of board cell, encoding its default behavior and appearance.
@@ -147,6 +184,9 @@ pub enum Archetype {
HCrate, HCrate,
/// A crate that can only be pushed north/south (vertical axis). Glyph ↕. /// A crate that can only be pushed north/south (vertical axis). Glyph ↕.
VCrate, VCrate,
/// A wall-mounted torch: non-solid, non-opaque decorative terrain that emits
/// warm light on a `dark` board (radius from [`Archetype::light`]). Glyph ☼.
Torch,
/// A script-backed archetype expanded from a map-file keyword. /// A script-backed archetype expanded from a map-file keyword.
/// ///
/// - `Builtin` is the family (e.g. `Builtin::Pusher`), which selects the /// - `Builtin` is the family (e.g. `Builtin::Pusher`), which selects the
@@ -197,6 +237,13 @@ impl Archetype {
pushable: Pushable::Vertical, pushable: Pushable::Vertical,
grab: false, grab: false,
}, },
// A torch you can walk past and see through; it only lights the room.
Archetype::Torch => Behavior {
solid: false,
opaque: false,
pushable: Pushable::No,
grab: false,
},
Archetype::ErrorBlock => Behavior { Archetype::ErrorBlock => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
@@ -215,10 +262,23 @@ impl Archetype {
Archetype::Crate => "crate", Archetype::Crate => "crate",
Archetype::HCrate => "hcrate", Archetype::HCrate => "hcrate",
Archetype::VCrate => "vcrate", Archetype::VCrate => "vcrate",
Archetype::Torch => "torch",
Archetype::ErrorBlock => "error_block", Archetype::ErrorBlock => "error_block",
} }
} }
/// Light radius in cells this archetype emits on a `dark` board (0 = none).
///
/// The emitted *color* is the cell's glyph foreground color (see [`crate::fov`]).
/// Only `Torch` glows today; everything else is dark. Script-backed builtins
/// carry their light on the expanded [`ObjectDef::light`] instead.
pub fn light(&self) -> u32 {
match self {
Archetype::Torch => 6,
_ => 0,
}
}
/// Returns the default glyph painted when the editor stamps this archetype. /// Returns the default glyph painted when the editor stamps this archetype.
/// ///
/// This glyph is used only for new cells created in the editor; existing /// This glyph is used only for new cells created in the editor; existing
@@ -252,6 +312,11 @@ impl Archetype {
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}, },
Archetype::Torch => Glyph {
tile: 15, // CP437 ☼ (sun) — a warm point of light
fg: Rgba8 { r: 0xFF, g: 0xB0, b: 0x40, a: 255 }, // warm amber (also its light color)
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
// Visually distinct so malformed map files are immediately obvious. // Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph { Archetype::ErrorBlock => Glyph {
tile: 63, tile: 63,
@@ -282,6 +347,7 @@ impl TryFrom<&str> for Archetype {
"crate" => Ok(Archetype::Crate), "crate" => Ok(Archetype::Crate),
"hcrate" => Ok(Archetype::HCrate), "hcrate" => Ok(Archetype::HCrate),
"vcrate" => Ok(Archetype::VCrate), "vcrate" => Ok(Archetype::VCrate),
"torch" => Ok(Archetype::Torch),
// "object", "portal", "player" are intentionally absent: they are // "object", "portal", "player" are intentionally absent: they are
// meta-kinds handled by the layer builder, not Archetype variants. // meta-kinds handled by the layer builder, not Archetype variants.
_ => Err(format!("unknown archetype: {name}")), _ => Err(format!("unknown archetype: {name}")),
@@ -310,6 +376,9 @@ mod tests {
("pusher_west", 17), ("pusher_west", 17),
("spinner_cw", 47), ("spinner_cw", 47),
("spinner_ccw", 92), ("spinner_ccw", 92),
("key_red", 12),
("key_blue", 12),
("key_white", 12),
] { ] {
let arch = Archetype::try_from(name) let arch = Archetype::try_from(name)
.unwrap_or_else(|_| panic!("'{name}' should parse as a builtin")); .unwrap_or_else(|_| panic!("'{name}' should parse as a builtin"));
@@ -321,4 +390,29 @@ mod tests {
); );
} }
} }
#[test]
fn key_aliases_have_distinct_fg_colors() {
use crate::keys::KeyType;
// Each alias must match the corresponding KeyType glyph — single source of truth.
let cases = [
("key_blue", KeyType::Blue),
("key_green", KeyType::Green),
("key_cyan", KeyType::Cyan),
("key_red", KeyType::Red),
("key_purple", KeyType::Purple),
("key_orange", KeyType::Orange),
("key_yellow", KeyType::Yellow),
("key_white", KeyType::White),
];
for (name, key_type) in cases {
let arch = Archetype::try_from(name)
.unwrap_or_else(|_| panic!("'{name}' should parse"));
assert_eq!(
arch.default_glyph().fg,
key_type.glyph().fg,
"'{name}' fg doesn't match KeyType"
);
}
}
} }
+423 -260
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -17,7 +17,7 @@
/// space since a literal NUL is not displayable. /// space since a literal NUL is not displayable.
const CP437: [char; 256] = [ const CP437: [char; 256] = [
// 0x000x0F // 0x000x0F
' ', '☺', '☻', '', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼', ' ', '☺', '☻', '', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
// 0x100x1F // 0x100x1F
'►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼', '►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
// 0x200x2F (ASCII space onward) // 0x200x2F (ASCII space onward)
+67 -1
View File
@@ -12,7 +12,63 @@
use crate::glyph::Glyph; use crate::glyph::Glyph;
use color::Rgba8; 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 /// 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 /// 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. /// Generates one random floor [`Glyph`] for this generator.
/// ///
/// Picks a background ground color within the generator's scheme, then with /// Picks a background ground color within the generator's scheme, then with
+154
View File
@@ -0,0 +1,154 @@
//! Lighting and field-of-view for "dark" boards.
//!
//! A [`Board`](crate::board::Board) marked `dark` reveals a cell only when the
//! player has a **line of sight** to it *and* it receives **light** from some
//! source. Both are computed with the [`doryen_fov`] crate's recursive
//! shadow-casting algorithm — pure Rust and WASM-safe, matching kiln's
//! WASM-first requirement.
//!
//! [`Board::lighting`](crate::board::Board::lighting) builds a [`Lighting`] by:
//! 1. casting an unbounded line-of-sight field from the player (pure geometry),
//! 2. accumulating per-cell colored light from every source — the player's
//! torch plus each light-emitting object and glowing terrain cell — where a
//! source's color is its own glyph foreground and its contribution falls off
//! linearly to zero at its radius.
//!
//! The front-end then, per cell, asks [`Lighting::is_visible`] (LOS ∩ lit) and
//! [`Lighting::tint`] (modulate the glyph's colors by the received light).
use color::Rgba8;
use doryen_fov::MapData;
/// Default player torch radius in cells, used when a world omits `torch = N`
/// from its `[world]` header. See [`World::torch`](crate::world::World::torch).
pub const SIGHT_RADIUS: usize = 10;
/// A finished lighting computation for one dark-board frame: per-cell player
/// line-of-sight plus accumulated colored light. Produced by
/// [`Board::lighting`](crate::board::Board::lighting); queried per cell by
/// front-ends when rendering a dark board.
pub struct Lighting {
/// Board width in cells (row stride for `los` / `light`).
width: usize,
/// Player line-of-sight: `true` where the player has an unobstructed
/// sightline, regardless of whether the cell is lit.
los: Vec<bool>,
/// Accumulated linear light per cell as `[r, g, b]` in `0.0..` (may exceed
/// `1.0` where lights overlap; clamped when applied). Zero means unlit.
light: Vec<[f32; 3]>,
}
impl Lighting {
/// Builds an all-dark lighting buffer of `width * height` cells for the
/// caller ([`Board::lighting`]) to fill.
pub(crate) fn new(width: usize, height: usize) -> Self {
Self {
width,
los: vec![false; width * height],
light: vec![[0.0; 3]; width * height],
}
}
/// Is board cell `(x, y)` visible — player has a sightline **and** it is lit?
///
/// This is the render/speech gate: a lit area the player can't line up to
/// (around a corner) is not visible, and a cell in view but unlit is dark.
pub fn is_visible(&self, x: usize, y: usize) -> bool {
let i = y * self.width + x;
self.los[i] && intensity(self.light[i]) > LIGHT_EPSILON
}
/// Modulates a base color by the light this cell receives: each channel is
/// multiplied by the accumulated light (clamped to `1.0`), so a white light
/// just dims with distance while a colored light tints toward its own hue.
/// Alpha is preserved. Callers should only tint cells that
/// [`is_visible`](Lighting::is_visible).
pub fn tint(&self, x: usize, y: usize, base: Rgba8) -> Rgba8 {
let l = self.light[y * self.width + x];
Rgba8 {
r: (base.r as f32 * l[0].min(1.0)) as u8,
g: (base.g as f32 * l[1].min(1.0)) as u8,
b: (base.b as f32 * l[2].min(1.0)) as u8,
a: base.a,
}
}
/// Marks cell `(x, y)` as within the player's line of sight. Used by the
/// LOS pass in [`Board::lighting`].
pub(crate) fn set_los(&mut self, x: usize, y: usize) {
self.los[y * self.width + x] = true;
}
/// Adds `amount` linear light (already color- and falloff-scaled) to cell
/// `(x, y)`. Used by the per-source accumulation in [`Board::lighting`].
pub(crate) fn add_light(&mut self, x: usize, y: usize, amount: [f32; 3]) {
let cell = &mut self.light[y * self.width + x];
cell[0] += amount[0];
cell[1] += amount[1];
cell[2] += amount[2];
}
}
/// Cells dimmer than this (summed linear intensity) count as unlit.
const LIGHT_EPSILON: f32 = 0.001;
/// Summed linear intensity of an accumulated light value — the "is it lit at
/// all" measure used by [`Lighting::is_visible`].
fn intensity(l: [f32; 3]) -> f32 {
l[0] + l[1] + l[2]
}
/// Normalizes an 8-bit color to a `0.0..=1.0` linear-ish RGB triple, used as a
/// light source's color (a source emits its own glyph's foreground color).
pub(crate) fn color_to_rgb(c: Rgba8) -> [f32; 3] {
[c.r as f32 / 255.0, c.g as f32 / 255.0, c.b as f32 / 255.0]
}
/// A reusable helper wrapping the `doryen_fov` shadow-caster and a single
/// scratch [`MapData`] whose transparency is set once from the board's opaque
/// cells, so [`Board::lighting`] can cast many sources without reallocating.
pub(crate) struct FovCaster {
algo: doryen_fov::FovRecursiveShadowCasting,
map: MapData,
}
impl FovCaster {
/// Builds a caster over a `width * height` map. `transparent(x, y)` supplies
/// each cell's transparency (`true` = light/sight passes through).
pub(crate) fn new(width: usize, height: usize, transparent: impl Fn(usize, usize) -> bool) -> Self {
let mut map = MapData::new(width, height);
for y in 0..height {
for x in 0..width {
map.set_transparent(x, y, transparent(x, y));
}
}
Self {
algo: doryen_fov::FovRecursiveShadowCasting::new(),
map,
}
}
/// Casts a field of view from `(x, y)` out to `radius` (0 = unbounded) and
/// invokes `visit(cx, cy)` for every cell it lights. `light_walls = true`
/// includes opaque cells at the edge of the field. Clears prior results
/// first, so successive calls are independent.
pub(crate) fn cast(
&mut self,
x: usize,
y: usize,
radius: usize,
mut visit: impl FnMut(usize, usize),
) {
use doryen_fov::FovAlgorithm;
self.map.clear_fov();
self.algo.compute_fov(&mut self.map, x, y, radius, true);
let (w, h) = (self.map.width, self.map.height);
for cy in 0..h {
for cx in 0..w {
if self.map.is_in_fov(cx, cy) {
visit(cx, cy);
}
}
}
}
}
+468 -138
View File
@@ -1,18 +1,56 @@
use crate::action::Action; use crate::action::{Action, BoardAction, SendArg};
use crate::board::Board; use crate::board::Board;
use crate::log::LogLine; use crate::log::LogLine;
use crate::script::ScriptHost; use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, Player, ScriptArg}; use crate::utils::{Direction, ObjectId, PlayerPos};
use crate::world::World; use crate::world::World;
use std::cell::{Ref, RefMut}; use std::cell::{Ref, RefMut};
use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
/// The bump and send reactions produced while applying a batch of actions.
///
/// [`GameState::apply_actions`] collects these but does not fire them; the
/// follow-up [`GameState::settle`] pass runs them after all object hooks, so a
/// bumped object reacts to the fully-updated board.
#[derive(Default)]
struct Events {
/// `(bumped object, direction the bump came from)` for each triggered `bump`.
bumps: Vec<(ObjectId, Direction)>,
/// `(entered non-solid object, direction the entrant came from)` for each
/// `enter` — a solid relocating onto a non-solid object's cell.
enters: Vec<(ObjectId, Direction)>,
/// `(target object, function name, argument)` for each `send`.
sends: Vec<(ObjectId, String, SendArg)>,
}
impl Events {
/// Appends `other`'s reactions onto `self`.
fn merge(&mut self, other: Events) {
self.bumps.extend(other.bumps);
self.enters.extend(other.enters);
self.sends.extend(other.sends);
}
/// Whether there is anything left to fire.
fn is_empty(&self) -> bool {
self.bumps.is_empty() && self.enters.is_empty() && self.sends.is_empty()
}
}
/// Records which `(object, hook/fn, args)` reactions have already fired during a
/// single `tick` / `try_move` / `run_init`. [`GameState::settle`] refuses to fire
/// a key twice, so a bump/send cascade always terminates — even if two objects
/// bump each other in a cycle, each side fires at most once. Reset per invocation.
type CalledSet = HashSet<(ObjectId, String, String)>;
/// How long a `say()` speech bubble stays on screen, in seconds. /// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0; pub const SAY_DURATION: f64 = 3.0;
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without // Re-export ScrollLine so kiln-tui can pattern-match scroll content without
// accessing the private `action` module directly. // accessing the private `action` module directly.
pub use crate::action::ScrollLine; pub use crate::action::ScrollLine;
use crate::player::{Player, PlayerRef};
/// An active scroll overlay opened by a scripted object via `scroll()`. /// An active scroll overlay opened by a scripted object via `scroll()`.
/// ///
@@ -66,14 +104,11 @@ pub struct GameState {
/// by [`enter_board`](GameState::enter_board). Front-ends tick this down and /// by [`enter_board`](GameState::enter_board). Front-ends tick this down and
/// may block input or show a visual effect while it is `Some(t)` where `t > 0`. /// may block input or show a visual effect while it is `Some(t)` where `t > 0`.
pub board_transition: Option<f64>, pub board_transition: Option<f64>,
/// The player's current health. Game-global (not per-board), so it persists /// The game-global player state (health, gems, keys) — see [`Player`]. Not
/// across board transitions. Players start with `5`. There is no runtime /// per-board: it persists across board transitions, unlike the per-board
/// mutation path yet — front-ends only display it. /// position in [`Board::player`](crate::board::Board::player). Scripts mutate
pub player_health: u32, /// it via `alter_gems`/`alter_health`/`set_key` and read a snapshot of it.
/// The number of gems the player has collected. Game-global like pub player: PlayerRef,
/// [`player_health`](GameState::player_health); starts at `0` and is
/// display-only for now.
pub player_gems: u32,
} }
impl GameState { impl GameState {
@@ -85,11 +120,13 @@ impl GameState {
/// Compile errors are surfaced into the log immediately. /// Compile errors are surfaced into the log immediately.
pub fn from_world(world: World) -> Self { pub fn from_world(world: World) -> Self {
let name = world.start.clone(); let name = world.start.clone();
let player = Player::new_ref();
let host = ScriptHost::new( let host = ScriptHost::new(
world world
.boards .boards
.get(&name) .get(&name)
.expect("world::load guarantees start board exists"), .expect("world::load guarantees start board exists").clone(),
player.clone(),
&world.scripts, &world.scripts,
); );
let mut state = Self { let mut state = Self {
@@ -100,10 +137,9 @@ impl GameState {
speech_bubbles: Vec::new(), speech_bubbles: Vec::new(),
active_scroll: None, active_scroll: None,
board_transition: None, board_transition: None,
player_health: 5, player
player_gems: 0,
}; };
state.drain_errors(); state.drain_log();
state state
} }
@@ -125,6 +161,7 @@ impl GameState {
let world = World { let world = World {
name: String::new(), name: String::new(),
start: "board".to_string(), start: "board".to_string(),
torch: crate::fov::SIGHT_RADIUS as u32,
scripts, scripts,
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]), boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
}; };
@@ -146,6 +183,12 @@ impl GameState {
&self.current_board_name &self.current_board_name
} }
/// The player's torch radius (world-wide config; see [`World::torch`]),
/// passed to [`Board::lighting`] when rendering a dark board.
pub fn torch(&self) -> u32 {
self.world.torch
}
/// Returns a clone of the `Rc` for the active board. /// Returns a clone of the `Rc` for the active board.
/// ///
/// Lets a front-end hold a reference to the current board across a board /// Lets a front-end hold a reference to the current board across a board
@@ -165,8 +208,18 @@ impl GameState {
/// the game is about to start — never during map deserialization, since a script /// the game is about to start — never during map deserialization, since a script
/// may inspect the board. /// may inspect the board.
pub fn run_init(&mut self) { pub fn run_init(&mut self) {
self.scripts.run_init(); // Run each object's init hook in ascending id order, applying its actions
self.resolve(); // immediately so a later object's init sees what an earlier one did.
let mut ev = Events::default();
let ids = self.board().all_ids();
for id in ids {
let actions = self.scripts.run_init_on(id);
ev.merge(self.apply_actions(actions));
}
// Fire any bump/send reactions, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
} }
/// Advances real-time game state by `dt` (the elapsed time since the last tick). /// Advances real-time game state by `dt` (the elapsed time since the last tick).
@@ -179,51 +232,78 @@ impl GameState {
// this runs exactly once per player interaction with a scroll. // this runs exactly once per player interaction with a scroll.
self.handle_scroll(); self.handle_scroll();
let secs = dt.as_secs_f64(); let secs = dt.as_secs_f64();
self.scripts.run_tick(secs); // Expire speech bubbles once per frame, before applying new actions so a
// Expire speech bubbles before resolving new actions so a fresh say() // fresh say() this frame isn't immediately culled.
// this frame isn't immediately culled.
self.speech_bubbles.retain_mut(|b| { self.speech_bubbles.retain_mut(|b| {
b.remaining -= secs; b.remaining -= secs;
b.remaining > 0.0 b.remaining > 0.0
}); });
self.resolve(); // Run each object's tick in ascending id order, applying its drained
// actions immediately so the next object sees the updated board.
let mut ev = Events::default();
let ids = self.board().all_ids();
for id in ids {
let actions = self.scripts.run_tick_on(id, secs);
ev.merge(self.apply_actions(actions));
}
// Fire the bump/send reactions those ticks triggered, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
} }
/// Drains errors collected by the script host into the game log. /// Drains the log lines collected by the script host — script `log()` output
fn drain_errors(&mut self) { /// 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 / // TODO: errors are only logged for now. This is the place to halt execution /
// set an error state when a script faults. // set an error state when a script faults.
let errors = self.scripts.take_errors(); let lines = self.scripts.take_logs();
self.log.extend(errors); self.log.extend(lines);
} }
/// Resolves the board queue: applies each promoted action to the board, then fires /// Applies one object's drained `actions` to the board and returns the `bump`
/// the `bump` and `send` hooks the moves triggered. /// and `send` reactions they triggered (fired later by [`settle`](GameState::settle)).
/// ///
/// Done in two phases so no `board_mut` borrow is held while scripts run /// Done in two phases so no `board_mut` borrow is held while scripts run
/// (they read the board through its getters): phase A mutates the board and records /// (they read the board through its getters): phase A mutates the board and records
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B fires the /// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B applies the
/// hooks after the borrow drops. /// player-stat / bubble / scroll changes after the borrow drops. The collected
fn resolve(&mut self) { /// reactions are returned rather than fired here, so the caller can run them once
let actions = self.scripts.take_board_queue(); /// all object hooks in this pass have applied.
// Logs are collected here rather than pushed inline, since the board borrow fn apply_actions(&mut self, actions: Vec<BoardAction>) -> Events {
// below also borrows `self`. // Application-time errors (teleport/push/shift failures) go straight onto
let mut logs: Vec<LogLine> = Vec::new(); // the shared LogSink — the same immediate channel as script `log()` output,
let mut bumps: Vec<(ObjectId, i64)> = Vec::new(); // so everything lands in the log in one emission order and is flushed by
// Net change to the player's gem count from AddGems actions; applied to // `drain_log`. A cheap Rc clone lets us push while the board borrow (which
// `self` after the board borrow drops. // also borrows `self`) is held.
let log_sink = self.scripts.log_sink().clone();
let mut bumps: Vec<(ObjectId, Direction)> = Vec::new();
// `enter` reactions: a solid relocating onto a non-solid object's cell.
let mut enters: Vec<(ObjectId, Direction)> = Vec::new();
// Net change to player stats from AddGems / AlterHealth actions; applied
// to `self` after the board borrow drops.
let mut gem_delta: i64 = 0; let mut gem_delta: i64 = 0;
let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new(); let mut health_delta: i64 = 0;
let mut key_changes: Vec<(String, bool)> = Vec::new();
let mut sends: Vec<(ObjectId, String, SendArg)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new(); let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll. // Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None; let mut new_scroll: Option<Scroll> = None;
{ {
let mut board = self.board_mut(); let mut board = self.board_mut();
for ba in actions { for ba in actions {
match ba.action { match ba.action {
Action::Move(dir) => { Action::Move(dir) => {
if let Some(bumped) = step_object(&mut board, ba.source, dir) { let StepOutcome { bumped, entered } =
bumps.push((bumped, ba.source as i64)); step_object(&mut board, ba.source, dir);
// The bump / enter "comes from" the side the mover advanced
// from, i.e. the opposite of its travel direction.
if let Some(bumped) = bumped {
bumps.push((bumped, dir.opposite()));
}
for id in entered {
enters.push((id, dir.opposite()));
} }
} }
Action::SetTile(tile) => { Action::SetTile(tile) => {
@@ -231,7 +311,11 @@ impl GameState {
obj.glyph.tile = tile; obj.glyph.tile = tile;
} }
} }
Action::Log(line) => logs.push(line), Action::SetLight(radius) => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.light = radius;
}
}
Action::SetTag { Action::SetTag {
target, target,
tag, tag,
@@ -280,40 +364,125 @@ impl GameState {
choice: None, choice: None,
}); });
} }
Action::Teleport { x, y } => { Action::Teleport { target, x, y } => {
if !board.in_bounds((x, y)) { if !board.in_bounds((x, y)) {
logs.push(LogLine::error(format!("teleport({x},{y}): out of bounds"))); log_sink.error(format!(
} else { "teleport({target},{x},{y}): out of bounds"
));
} else if target == -1 {
// Move the player. A solid *other than the player itself*
// blocks the destination.
let (ux, uy) = (x as usize, y as usize); let (ux, uy) = (x as usize, y as usize);
let source_solid = board let blocked = matches!(
.objects board.solid_at(ux, uy),
.get(&ba.source) Some(s) if !s.player()
.map(|o| o.solid) );
.unwrap_or(false); if blocked {
if source_solid && board.solid_at(ux, uy).is_some() { log_sink.error(format!(
logs.push(LogLine::error(format!( "teleport(player,{x},{y}): destination is solid"
"teleport({x},{y}): destination is solid" ));
))); } else {
} else if let Some(obj) = board.objects.get_mut(&ba.source) { let (old_x, old_y) = (board.player.x, board.player.y);
obj.x = ux; board.player.x = ux as i64;
obj.y = uy; board.player.y = uy as i64;
// The player is solid, so it may land on non-solids:
// fire `enter` with a best-effort came-from direction
// (the jump is arbitrary, so it has no exact cardinal).
if let Some(from) =
Direction::from_delta(old_x - ux as i64, old_y - uy as i64)
{
for id in board.non_solid_object_ids_at(ux, uy) {
enters.push((id, from));
}
}
} }
} else if let Ok(tid) = ObjectId::try_from(target) {
// Move object `tid` to (x, y). A solid destination blocks
// 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 => log_sink.error(format!(
"teleport({target},{x},{y}): no such object"
)),
Some(obj) => {
let source_solid = obj.solid;
let (old_x, old_y) = (obj.x as i64, obj.y as i64);
let blocked = source_solid
&& matches!(
board.solid_at(ux, uy),
Some(s) if s.object_id() != Some(tid)
);
if blocked {
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;
}
// Only a solid mover triggers `enter`; the
// direction is best-effort (arbitrary jump).
if source_solid
&& let Some(from) = Direction::from_delta(
old_x - ux as i64,
old_y - uy as i64,
)
{
for id in board.non_solid_object_ids_at(ux, uy) {
enters.push((id, from));
}
}
}
}
}
} else {
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. // push() self-checks can_push, so an in-bounds guard is all we add.
Action::Push { x, y, dir } => { Action::Push { x, y, dir } => {
if !board.in_bounds((x, y)) { 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 { } else {
board.push(x as usize, y as usize, dir); // Each pushed solid stepped one cell in `dir`; fire `enter`
// on any non-solid it landed on (came-from `dir.opposite()`).
for (cx, cy) in board.push(x as usize, y as usize, dir) {
for id in board.non_solid_object_ids_at(cx, cy) {
enters.push((id, dir.opposite()));
}
}
} }
} }
// apply_shift moves the named cells. // apply_shift moves the named cells, returning error lines plus the
// relocations it performed (for `enter` at each destination).
Action::Shift(cells) => { Action::Shift(cells) => {
logs.extend(board.apply_shift(&cells)); let outcome = board.apply_shift(&cells);
for line in outcome.errors {
log_sink.line(line);
}
for (from, to) in outcome.moves {
// A shift can rotate non-adjacent cells, so the came-from
// direction is best-effort (dominant axis of the jump).
if let Some(from_dir) =
Direction::from_delta(from.0 - to.0, from.1 - to.1)
{
for id in
board.non_solid_object_ids_at(to.0 as usize, to.1 as usize)
{
enters.push((id, from_dir));
}
}
}
} }
// Accumulated and applied to `self.player_gems` after the borrow drops. // Accumulated and applied to `self.player.gems` after the borrow drops.
Action::AddGems(n) => gem_delta += n, Action::AddGems(n) => gem_delta += n,
// Accumulated and applied to `self.player.health` after the borrow drops.
Action::AlterHealth(dh) => health_delta += dh,
// Collected and applied to `self.player.keys` after the borrow drops.
Action::SetKey(color, present) => key_changes.push((color, present)),
// A grab thing despawns itself from its grab() hook. // A grab thing despawns itself from its grab() hook.
Action::Die => { Action::Die => {
board.remove_object(ba.source); board.remove_object(ba.source);
@@ -321,7 +490,6 @@ impl GameState {
} }
} }
} }
self.log.extend(logs);
for bubble in new_bubbles { for bubble in new_bubbles {
// One bubble per object: replace the existing one if present. // One bubble per object: replace the existing one if present.
self.speech_bubbles self.speech_bubbles
@@ -333,15 +501,62 @@ impl GameState {
} }
// Apply the net gem change (clamped at 0, since the count is unsigned). // Apply the net gem change (clamped at 0, since the count is unsigned).
if gem_delta != 0 { if gem_delta != 0 {
self.player_gems = (self.player_gems as i64 + gem_delta).max(0) as u32; self.player.borrow_mut().alter_gems(gem_delta);
} }
for (bumped, bumper) in bumps { // Apply the net health change (clamped to [0, max_health]).
self.scripts.run_bump(bumped, bumper); if health_delta != 0 {
self.player.borrow_mut().alter_health(health_delta);
} }
for (target, fn_name, arg) in sends { for (color, present) in key_changes {
self.scripts.run_send(target, &fn_name, arg); if !self.player.borrow_mut().keys.set_by_name(&color, present) {
log_sink.error(format!("set_key: unknown color {color:?}"));
}
}
// Return the reactions for the caller's settle pass rather than firing them here.
Events {
bumps,
enters,
sends,
}
}
/// Fires the `bump` / `send` reactions in `events` (and any they cascade into)
/// until the board is quiescent, applying each hook's actions as it runs.
///
/// `called` records every `(object, hook/fn, args)` already fired this
/// invocation; a reaction whose key is already present is skipped. Since each
/// key fires at most once, the loop is finite even when objects bump each
/// other in a cycle — the guard is what makes bump-loops impossible.
fn settle(&mut self, initial: Events, called: &mut CalledSet) {
let mut pending = initial;
while !pending.is_empty() {
let mut next = Events::default();
for (id, dir) in std::mem::take(&mut pending.bumps) {
// Skip a bump already fired this pass (dedup key includes the direction).
if !called.insert((id, "bump".to_string(), format!("{dir:?}"))) {
continue;
}
let actions = self.scripts.run_bump(id, dir);
next.merge(self.apply_actions(actions));
}
for (id, dir) in std::mem::take(&mut pending.enters) {
// Skip an enter already fired this pass (dedup key includes the direction).
if !called.insert((id, "enter".to_string(), format!("{dir:?}"))) {
continue;
}
let actions = self.scripts.run_enter(id, dir);
next.merge(self.apply_actions(actions));
}
for (id, fn_name, arg) in std::mem::take(&mut pending.sends) {
// Dedup key: target + function name + argument.
if !called.insert((id, fn_name.clone(), format!("{arg:?}"))) {
continue;
}
let actions = self.scripts.run_send(id, &fn_name, arg);
next.merge(self.apply_actions(actions));
}
pending = next;
} }
self.drain_errors();
} }
/// Consumes the active scroll, dispatching the player's choice (if any) back /// Consumes the active scroll, dispatching the player's choice (if any) back
@@ -355,8 +570,13 @@ impl GameState {
if let Some(scroll) = self.active_scroll.take() if let Some(scroll) = self.active_scroll.take()
&& let Some(choice) = scroll.choice && let Some(choice) = scroll.choice
{ {
self.scripts.run_send(scroll.source, &choice, None); // Dispatch the choice back to the source object and apply whatever it
self.drain_errors(); // queues (plus any bump/send cascade), the same as a tick.
let actions = self.scripts.run_send(scroll.source, &choice, SendArg::None);
let ev = self.apply_actions(actions);
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
} }
} }
@@ -394,13 +614,15 @@ impl GameState {
self.active_scroll = None; self.active_scroll = None;
// Switch to the new board and place the player at the arrival portal. // Switch to the new board and place the player at the arrival portal.
self.current_board_name = target_map.to_string(); self.current_board_name = target_map.to_string();
self.board_mut().player = Player { self.board_mut().player = PlayerPos {
x: ax as i32, x: ax as i64,
y: ay as i32, y: ay as i64,
}; };
self.board_mut().clear_all_queues();
// Rebuild the script host for the new board's objects. // Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new( self.scripts = ScriptHost::new(
&self.world.boards[&self.current_board_name], self.world.boards[&self.current_board_name].clone(),
self.player.clone(),
&self.world.scripts, &self.world.scripts,
); );
// Stub hook for the front-end transition animation (1 second). // Stub hook for the front-end transition animation (1 second).
@@ -413,14 +635,18 @@ impl GameState {
/// ///
/// The move is ignored if the target cell is out of bounds, or it is neither /// The move is ignored if the target cell is out of bounds, or it is neither
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the /// passable nor a pushable solid that can be shoved aside. No-ops silently (the
/// caller does not need to check). If the target cell holds a solid object, that /// caller does not need to check). If a solid object lies in the path — directly
/// object's `bump(-1)` hook fires (whether or not the player ends up moving). /// or at the end of a chain of crates the player is shoving — its `bump` hook
/// fires with the direction the bump came from (whether or not the player moves).
pub fn try_move(&mut self, dir: Direction) { pub fn try_move(&mut self, dir: Direction) {
let bumped; let bumped;
let grabbed; let grabbed;
let portal_target; let portal_target;
// Non-solid objects the player (or the crates it shoved) landed on this move,
// collected while the board is borrowed and fired as `enter` after the borrow.
let mut entered: Vec<ObjectId> = Vec::new();
{ {
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
let mut board = self.board_mut(); let mut board = self.board_mut();
let target = (board.player.x + dx, board.player.y + dy); let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) { if !board.in_bounds(target) {
@@ -430,20 +656,27 @@ impl GameState {
// Walking onto a grab thing (e.g. a gem) is never blocked: the player // Walking onto a grab thing (e.g. a gem) is never blocked: the player
// moves onto it and its grab() hook fires (the thing despawns itself). // moves onto it and its grab() hook fires (the thing despawns itself).
grabbed = board.grab_object_at(nx, ny); grabbed = board.grab_object_at(nx, ny);
// A solid object in the way is bumped by the player (id -1) — but a // A solid object in the way is bumped by the player — possibly through a
// grab thing fires grab() instead of bump(), so don't also bump it. // chain of crates the player is shoving (see `bump_target`) — but a grab
bumped = board // thing fires grab() instead of bump(), so don't also bump it.
.solid_at(nx, ny) bumped = if grabbed.is_none() {
.filter(|_| grabbed.is_none()) board.bump_target(nx, ny, dir)
.and_then(|s| s.object_id()); } else {
None
};
if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) { if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
// Don't push a grab thing aside — walk onto it. Otherwise shove any // Don't push a grab thing aside — walk onto it. Otherwise shove any
// pushable chain out of the way (no-op when there's nothing to push). // pushable chain out of the way (no-op when there's nothing to push).
if grabbed.is_none() { if grabbed.is_none() {
board.push(nx, ny, dir); // Each pushed solid lands on a cell that may hold a non-solid.
for (cx, cy) in board.push(nx, ny, dir) {
entered.extend(board.non_solid_object_ids_at(cx, cy));
}
} }
board.player.x = nx as i32; board.player.x = nx as i64;
board.player.y = ny as i32; board.player.y = ny as i64;
// The player is solid, so any non-solid on its new cell gets `enter`.
entered.extend(board.non_solid_object_ids_at(nx, ny));
// Check for a portal at the new position; clone strings to release the borrow. // Check for a portal at the new position; clone strings to release the borrow.
portal_target = board portal_target = board
.portals .portals
@@ -454,49 +687,87 @@ impl GameState {
portal_target = None; portal_target = None;
} }
} }
// A portal takes priority: board transitions skip the bump hook. // A portal takes priority: board transitions skip the bump/enter hooks.
if let Some((target_map, target_entry)) = portal_target { if let Some((target_map, target_entry)) = portal_target {
self.enter_board(&target_map, &target_entry); self.enter_board(&target_map, &target_entry);
return; return;
} }
// Fire the grab hook and resolve it immediately so the grabbed thing's let mut ev = Events::default();
// die()/add_gems() apply now — no player+object overlap survives this call. // Fire the grab hook and apply it immediately so the grabbed thing's
// die()/alter_gems() apply now — no player+object overlap survives this call.
if let Some(id) = grabbed { if let Some(id) = grabbed {
self.scripts.run_grab(id); let actions = self.scripts.run_grab(id);
self.resolve(); ev.merge(self.apply_actions(actions));
} }
if let Some(idx) = bumped { if let Some(idx) = bumped {
self.scripts.run_bump(idx, -1); // The player advanced in `dir`, so the bump arrives from the opposite side.
self.drain_errors(); ev.bumps.push((idx, dir.opposite()));
} }
for id in entered {
// The player advanced in `dir`, so it entered from the opposite side.
ev.enters.push((id, dir.opposite()));
}
// Settle the grab/bump reactions (and any they cascade into) before returning.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
} }
} }
/// Moves object `id` one cell in `dir` on `board`, returning the object it bumped /// What a [`step_object`] call produced, for the caller to resolve after the board
/// (if any) for the caller to resolve after the board borrow drops. /// borrow drops.
struct StepOutcome {
/// The solid object the move pressed into (directly or at a crate-chain's end),
/// which receives a `bump`.
bumped: Option<ObjectId>,
/// Non-solid objects a solid landed on this move — the mover itself (only if it
/// is solid) and every crate it shoved — each of which receives an `enter`.
entered: Vec<ObjectId>,
}
/// Moves object `id` one cell in `dir` on `board`, reporting the `bump`/`enter`
/// reactions for the caller to resolve after the board borrow drops.
/// ///
/// The move itself proceeds only if the target is in bounds and either passable or a /// The move itself proceeds only if the target is in bounds and either passable or a
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The /// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
/// bump is recorded whenever a solid object occupies the target — whether it gets /// bump is recorded for the solid object the move presses into — directly, or at the
/// pushed aside or blocks the move — since something tried to move into it. Walls and /// end of a chain of crates being shoved (see [`Board::bump_target`]) — whether it
/// crates carry no script, so only solid objects yield a bump. /// gets pushed aside or blocks the move. Walls and crates carry no script, so only
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> { /// solid objects yield a bump. An `enter` is recorded for every non-solid object a
let (dx, dy): (i32, i32) = dir.into(); /// solid lands on: the mover's destination cell (only when the mover is itself solid)
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?; /// and each cell a pushed crate moved into (crates are always solid entrants).
let target = (ox as i32 + dx, oy as i32 + dy); fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome {
let mut out = StepOutcome {
bumped: None,
entered: Vec::new(),
};
let (dx, dy): (i64, i64) = dir.into();
let Some((ox, oy, solid)) = board.objects.get(&id).map(|o| (o.x, o.y, o.solid)) else {
return out;
};
let target = (ox as i64 + dx, oy as i64 + dy);
if !board.in_bounds(target) { if !board.in_bounds(target) {
return None; return out;
} }
let (nx, ny) = (target.0 as usize, target.1 as usize); let (nx, ny) = (target.0 as usize, target.1 as usize);
// Capture the bumped object before any push relocates it (its id is stable). // Capture the bumped object before any push relocates it (its id is stable).
let bumped = board.solid_at(nx, ny).and_then(|s| s.object_id()); // Walks through a pushed crate chain to the object it presses against.
out.bumped = board.bump_target(nx, ny, dir);
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) { if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise // Shove a crate/object out of the way (no-op otherwise); each pushed solid
// may land on a non-solid, which gets `enter` regardless of the mover.
for (cx, cy) in board.push(nx, ny, dir) {
out.entered.extend(board.non_solid_object_ids_at(cx, cy));
}
let obj = board.objects.get_mut(&id).expect("id checked above"); let obj = board.objects.get_mut(&id).expect("id checked above");
obj.x = nx; obj.x = nx;
obj.y = ny; obj.y = ny;
// Only a solid mover triggers `enter` on non-solids under its own new cell.
if solid {
out.entered.extend(board.non_solid_object_ids_at(nx, ny));
}
} }
bumped out
} }
#[cfg(test)] #[cfg(test)]
@@ -522,7 +793,7 @@ mod tests {
game.try_move(Direction::East); game.try_move(Direction::East);
// The gem was grabbed: gem count up, gem object gone, player on its cell. // The gem was grabbed: gem count up, gem object gone, player on its cell.
assert_eq!(game.player_gems, 1); assert_eq!(game.player.borrow().gems, 1);
assert!(game.board().objects.is_empty()); assert!(game.board().objects.is_empty());
assert_eq!((game.board().player.x, game.board().player.y), (1, 0)); assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
} }
@@ -550,7 +821,7 @@ mod tests {
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
// No grab: the gem is untouched and the player never moved. // No grab: the gem is untouched and the player never moved.
assert_eq!(game.player_gems, 0); assert_eq!(game.player.borrow().gems, 0);
assert!(game.board().objects.values().any(|o| o.grab)); assert!(game.board().objects.values().any(|o| o.grab));
assert_eq!((game.board().player.x, game.board().player.y), (2, 0)); assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
} }
@@ -560,7 +831,7 @@ mod tests {
let mut obj = ObjectDef::new(0, 0); let mut obj = ObjectDef::new(0, 0);
obj.solid = false; obj.solid = false;
obj.script_name = Some("s".to_string()); obj.script_name = Some("s".to_string());
let mut board = open_board(board_w, 1, (board_w as i32 - 1, 0), vec![obj]); let mut board = open_board(board_w, 1, (board_w as i64 - 1, 0), vec![obj]);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
let scripts = HashMap::from([("s".to_string(), src.to_string())]); let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
@@ -568,31 +839,18 @@ mod tests {
game game
} }
#[test]
fn script_push_shoves_a_crate() {
// The object pushes the crate at (2,0) one step east on its first tick.
let mut game = game_with_object_script(
5,
"fn tick(dt) { if Queue.length() == 0 { push(2, 0, East); } }",
);
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);
}
#[test] #[test]
fn script_shift_rotates_crates() { fn script_shift_rotates_crates() {
// Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle: // Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle:
// crate moves to (3,0), empty moves back to (2,0). // crate moves to (3,0), empty moves back to (2,0).
let mut game = game_with_object_script( let mut game = game_with_object_script(
5, 5,
"fn tick(dt) { if Queue.length() == 0 { shift([[2, 0], [3, 0]]); } }", "fn tick(m,dt) { if m.queue.length == 0 { shift([[2, 0], [3, 0]]); } }",
); );
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
let b = game.board(); let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); assert_eq!(b.get(2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate); assert_eq!(b.get(3, 0).1, Archetype::Crate);
} }
#[test] #[test]
@@ -605,10 +863,10 @@ mod tests {
obj.script_name = Some("s".to_string()); obj.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]); let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
let src = "fn init() { \ let src = "fn init(m) { \
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \ log(if Board.passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \ log(if Board.passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }"; log(if Board.passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]); let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -621,6 +879,29 @@ mod tests {
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]); 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 /// 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. /// real `scripts/spinner.rhai`) at the centre and the player parked on it.
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState { fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
@@ -664,9 +945,9 @@ mod tests {
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]); let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
let b = game.board(); let b = game.board();
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N kept assert_eq!(b.get(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(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(2, 1).1, Archetype::Wall); // wall kept
} }
#[test] #[test]
@@ -676,10 +957,10 @@ mod tests {
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]); let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
let b = game.board(); let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE: filled by N assert_eq!(b.get(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(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, 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(0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
} }
#[test] #[test]
@@ -689,9 +970,9 @@ mod tests {
let mut game = spinner_board_dir(&[(1, 0)], &[], true); let mut game = spinner_board_dir(&[(1, 0)], &[], true);
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
let b = game.board(); let b = game.board();
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise assert_eq!(b.get(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(2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated assert_eq!(b.get(1, 0).1, Archetype::Empty); // N: vacated
} }
#[test] #[test]
@@ -714,4 +995,53 @@ mod tests {
} }
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]); assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]);
} }
#[test]
fn set_key_gives_and_takes_keys() {
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
"s".to_string(),
"fn init(m) { set_key(\"blue\", true); set_key(\"red\", true); }".to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
let keys = game.player.borrow().keys;
assert!(keys.blue);
assert!(keys.red);
assert!(!keys.cyan); // cyan was not set by the script
// A second script can take a key.
let mut sobj2 = ObjectDef::new(0, 0);
sobj2.solid = false;
sobj2.script_name = Some("t".to_string());
let board2 = open_board(2, 1, (1, 0), vec![sobj2]);
let scripts2 = HashMap::from([(
"t".to_string(),
"fn init(m) { set_key(\"blue\", true); set_key(\"blue\", false); }".to_string(),
)]);
let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init();
assert!(!game2.player.borrow().keys.blue);
}
#[test]
fn set_key_unknown_color_logs_error() {
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
"s".to_string(),
r#"fn init(m) { set_key("chartreuse", true); }"#.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.contains("set_key"))));
}
} }
+16
View File
@@ -1,5 +1,8 @@
use color::Rgba8; use color::Rgba8;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use rhai::Engine;
use crate::script::Registerable;
use crate::utils::LogSink;
/// The visual representation of a single board cell. /// The visual representation of a single board cell.
/// ///
@@ -32,6 +35,19 @@ impl Hash for Glyph {
} }
} }
impl Registerable for Glyph {
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| {
format!("#{:02x}{:02x}{:02x}", g.fg.r, g.fg.g, g.fg.b)
});
engine.register_get("bg", |g: &mut Glyph| {
format!("#{:02x}{:02x}{:02x}", g.bg.r, g.bg.g, g.bg.b)
});
}
}
impl Glyph { impl Glyph {
/// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue. /// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue.
/// ///
+102
View File
@@ -0,0 +1,102 @@
use color::Rgba8;
use rhai::Engine;
use crate::glyph::Glyph;
use crate::script::Registerable;
use crate::utils::LogSink;
#[derive(Copy, Clone, Debug)]
pub enum KeyType {
Red, Orange, Yellow, Green, Blue, Cyan, Purple, White
}
impl KeyType {
pub fn glyph(self) -> Glyph {
let fg = match self {
KeyType::Red => Rgba8 { r: 0xFF, g: 0x50, b: 0x50, a: 255 },
KeyType::Orange => Rgba8 { r: 0xFF, g: 0x88, b: 0x00, a: 255 },
KeyType::Yellow => Rgba8 { r: 0xFF, g: 0xFF, b: 0x50, a: 255 },
KeyType::Green => Rgba8 { r: 0x50, g: 0xFF, b: 0x50, a: 255 },
KeyType::Blue => Rgba8 { r: 0x50, g: 0x50, b: 0xFF, a: 255 },
KeyType::Cyan => Rgba8 { r: 0x00, g: 0xAA, b: 0xAA, a: 255 },
KeyType::Purple => Rgba8 { r: 0xAA, g: 0x00, b: 0xAA, a: 255 },
KeyType::White => Rgba8 { r: 0xFF, g: 0xFF, b: 0xFF, a: 255 }
};
Glyph { tile: 12, fg, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } }
}
}
/// The player's key inventory: one boolean per key color.
///
/// Each field is `true` when the player holds a key of that color. Use
/// [`colors`](Keyring::colors) to iterate all eight in display order, each
/// paired with its [`Rgba8`] color for rendering.
#[derive(Copy, Clone, Default)]
pub struct Keyring {
/// Red key.
pub red: bool,
/// Orange key (custom color, not in the standard EGA palette).
pub orange: bool,
/// Yellow key.
pub yellow: bool,
/// Green key.
pub green: bool,
/// Blue key.
pub blue: bool,
/// Cyan key.
pub cyan: bool,
/// Purple key.
pub purple: bool,
/// White key.
pub white: bool,
}
impl Keyring {
/// Sets the named key slot to `value`. Returns `false` if `name` is not a
/// recognized color (`"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`,
/// `"orange"`, `"yellow"`, `"white"`).
pub fn set_by_name(&mut self, name: &str, value: bool) -> bool {
match name {
"blue" => self.blue = value,
"green" => self.green = value,
"cyan" => self.cyan = value,
"red" => self.red = value,
"purple" => self.purple = value,
"orange" => self.orange = value,
"yellow" => self.yellow = value,
"white" => self.white = value,
_ => return false,
}
true
}
/// Returns all eight keys in display order, each paired with its color.
///
/// Order: blue, green, cyan, red, purple, orange, yellow, white.
pub fn colors(&self) -> [(bool, Rgba8); 8] {
[
(self.blue, KeyType::Blue.glyph().fg),
(self.green, KeyType::Green.glyph().fg),
(self.cyan, KeyType::Cyan.glyph().fg),
(self.red, KeyType::Red.glyph().fg),
(self.purple, KeyType::Purple.glyph().fg),
(self.orange, KeyType::Orange.glyph().fg),
(self.yellow, KeyType::Yellow.glyph().fg),
(self.white, KeyType::White.glyph().fg),
]
}
}
impl Registerable for Keyring {
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)
.register_get("yellow", |keyring: &mut Keyring| keyring.yellow)
.register_get("green", |keyring: &mut Keyring| keyring.green)
.register_get("blue", |keyring: &mut Keyring| keyring.blue)
.register_get("cyan", |keyring: &mut Keyring| keyring.cyan)
.register_get("purple", |keyring: &mut Keyring| keyring.purple)
.register_get("white", |keyring: &mut Keyring| keyring.white);
}
}
+60 -96
View File
@@ -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 //! A board is a single **grid** — a character grid plus a palette mapping each
//! layer is a character grid plus a palette mapping each character to one *kind* //! character to one *kind* of thing: an archetype (terrain), a scripted object, a
//! of thing — an archetype (terrain), a floor glyph, a scripted object, a portal, //! portal, or the player. (The board's cosmetic floor is a separate `[map]`
//! or the player. "Put two things on one cell" simply means "use two layers". //! attribute, not a grid cell; see [`crate::floor`].)
//! //!
//! This module owns the per-layer serde types ([`LayerData`], [`PaletteEntry`]), //! This module owns the grid serde type ([`GridData`], [`PaletteEntry`]) and the
//! the runtime [`Layer`] (a grid of `(Glyph, Archetype)` cells, where a cell with //! load-time conversion ([`build_grid`]) that turns one `GridData` into the board's
//! a transparent glyph lets lower layers show through), and the load-time //! `Vec<(Glyph, Archetype)>` cells plus a list of [`Placement`]s (objects/portals/
//! conversion ([`build_layer`]) that turns one `LayerData` into a `Layer` plus a //! player) for the map loader to resolve. Cross-cell validation (one solid per
//! list of [`Placement`]s (objects/portals/player) for the map loader to resolve //! cell, unique names, the player winning its cell) lives in [`crate::map_file`].
//! across layers. Cross-layer validation (one solid per cell, unique names, the
//! player winning its cell) lives in [`crate::map_file`].
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::floor::FloorGenerator;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::log::LogLine; use crate::log::LogLine;
use crate::map_file::{TileIndex, parse_color}; use crate::map_file::{TileIndex, parse_color};
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use tinyrand::StdRand;
/// A single draw layer: a row-major grid of `(Glyph, Archetype)` cells. /// Serde representation of the board `[grid]`: a char grid plus its palette.
///
/// 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.
/// ///
/// The grid is given in exactly one of three ways (precedence: `content`, then /// The grid is given in exactly one of three ways (precedence: `content`, then
/// `fill`, then `sparse`; none of them ⇒ an all-spaces grid): /// `fill`, then `sparse`; none of them ⇒ an all-spaces grid):
/// - `content` — a multi-line grid string, one char per cell (`width × height`). /// - `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 /// - `fill` — a single character; the whole grid is filled with it.
/// layer of identical floor).
/// - `sparse` — a list of `{ x, y, ch }` cells over an otherwise all-spaces grid /// - `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 /// A space (`' '`) is always a transparent empty cell and is never a palette key
/// (any `" "` entry in `palette` is ignored). /// (any `" "` entry in `palette` is ignored).
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Default)]
pub(crate) struct LayerData { pub(crate) struct GridData {
/// Multi-line grid string; one char per cell, looked up in `palette`. /// Multi-line grid string; one char per cell, looked up in `palette`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>, pub content: Option<String>,
@@ -63,7 +46,7 @@ pub(crate) struct LayerData {
pub palette: HashMap<String, PaletteEntry>, 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)] #[derive(Deserialize, Serialize, Clone)]
pub(crate) struct SparseCell { pub(crate) struct SparseCell {
/// Column (0-indexed). /// 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 /// 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 /// any archetype name (`"wall"`, `"crate"`, `"pusher_east"`, …) *or* one of the
/// meta-kinds `empty`, `floor`, `object`, `portal`, `player`. Only the fields /// meta-kinds `empty`, `object`, `portal`, `player`. Only the fields relevant to a
/// relevant to a given kind are read; the rest stay `None`. See [`resolve_entry`]. /// given kind are read; the rest stay `None`. See [`resolve_entry`].
#[derive(Deserialize, Serialize, Default, Clone)] #[derive(Deserialize, Serialize, Default, Clone)]
pub(crate) struct PaletteEntry { 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, 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<TileIndex>, 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>, 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>, 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"`. /// Object solidity (defaults `true`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub solid: Option<bool>, pub solid: Option<bool>,
@@ -105,6 +85,10 @@ pub(crate) struct PaletteEntry {
/// Object pushability (defaults `false`). Only for `kind = "object"`. /// Object pushability (defaults `false`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub pushable: Option<bool>, pub pushable: Option<bool>,
/// Light radius in cells emitted on a dark board (defaults `0` = none).
/// Only for `kind = "object"`. See [`ObjectDef::light`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub light: Option<u32>,
/// Rhai script name. Only for `kind = "object"`. /// Rhai script name. Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub script_name: Option<String>, pub script_name: Option<String>,
@@ -122,11 +106,14 @@ pub(crate) struct PaletteEntry {
pub target_entry: Option<String>, 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 /// Terrain goes straight into the grid cells; these need cross-cell handling (ids,
/// handling (ids, name uniqueness, the single player), so [`build_layer`] returns /// name uniqueness, the single player), so [`build_grid`] returns them separately
/// them separately with their `(x, y)` for [`crate::map_file`] to finish. /// with their `(x, y)` for [`crate::map_file`] to finish.
pub(crate) enum Placement { pub(crate) enum Placement {
/// A scripted object to spawn at `(x, y)`. /// A scripted object to spawn at `(x, y)`.
Object(ObjectTemplate, usize, usize), Object(ObjectTemplate, usize, usize),
@@ -136,19 +123,21 @@ pub(crate) enum Placement {
Player(usize, usize), Player(usize, usize),
} }
/// A resolved object definition minus board-assigned fields (`id`, `z`). /// A resolved object definition minus board-assigned fields (`id`).
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct ObjectTemplate { pub(crate) struct ObjectTemplate {
pub glyph: Glyph, pub glyph: Glyph,
pub solid: bool, pub solid: bool,
pub opaque: bool, pub opaque: bool,
pub pushable: bool, pub pushable: bool,
/// Light radius in cells (0 = none); see [`ObjectDef::light`].
pub light: u32,
pub script_name: Option<String>, pub script_name: Option<String>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub name: Option<String>, pub name: Option<String>,
} }
/// A resolved portal definition minus `(x, y, z)`. /// A resolved portal definition minus `(x, y)`.
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct PortalTemplate { pub(crate) struct PortalTemplate {
pub name: String, pub name: String,
@@ -159,10 +148,8 @@ pub(crate) struct PortalTemplate {
/// What a palette character resolves to during the grid walk. /// What a palette character resolves to during the grid walk.
#[derive(Clone)] #[derive(Clone)]
enum Resolved { 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), Cell(Glyph, Archetype),
/// A procedural floor: roll a fresh glyph per occurrence.
FloorGen(FloorGenerator),
/// A scripted object placed per occurrence. /// A scripted object placed per occurrence.
Object(ObjectTemplate), Object(ObjectTemplate),
/// A portal placed per occurrence. /// A portal placed per occurrence.
@@ -173,10 +160,10 @@ enum Resolved {
/// Resolves one palette entry to a [`Resolved`], recording any nonfatal problem. /// Resolves one palette entry to a [`Resolved`], recording any nonfatal problem.
/// ///
/// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `floor` /// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `portal`
/// with an unknown generator or a `portal` missing required fields falls back to a /// missing required fields falls back to a transparent cell (the grid char is
/// transparent cell (the grid char is still consumed). Object/floor/archetype /// still consumed). Object/archetype glyphs fall back to the relevant default for
/// glyphs fall back to the relevant default for any absent visual field. /// any absent visual field.
fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resolved { 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`. // Builds a glyph from the entry's tile/fg/bg, each falling back to `default`.
let glyph_with_default = |default: Glyph| Glyph { let glyph_with_default = |default: Glyph| Glyph {
@@ -186,32 +173,14 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
}; };
match e.kind.as_str() { 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), "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 { "object" => Resolved::Object(ObjectTemplate {
glyph: glyph_with_default(ObjectDef::default_glyph()), glyph: glyph_with_default(ObjectDef::default_glyph()),
solid: e.solid.unwrap_or(true), solid: e.solid.unwrap_or(true),
opaque: e.opaque.unwrap_or(true), opaque: e.opaque.unwrap_or(true),
pushable: e.pushable.unwrap_or(false), pushable: e.pushable.unwrap_or(false),
light: e.light.unwrap_or(0),
script_name: e.script_name.clone(), script_name: e.script_name.clone(),
tags: e.tags.clone().unwrap_or_default(), tags: e.tags.clone().unwrap_or_default(),
name: e.name.clone(), name: e.name.clone(),
@@ -248,7 +217,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). /// `content` / `fill` / `sparse` is supplied (in that precedence; none ⇒ all spaces).
/// ///
/// Only an explicit `content` can mismatch the board dimensions — the single hard /// Only an explicit `content` can mismatch the board dimensions — the single hard
@@ -256,7 +225,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 /// `fill`/`ch` or an out-of-bounds `sparse` cell is recorded on `errors` and the
/// offending cell falls back to (or stays) a space. /// offending cell falls back to (or stays) a space.
fn grid_chars( fn grid_chars(
data: &LayerData, data: &GridData,
width: usize, width: usize,
height: usize, height: usize,
errors: &mut Vec<LogLine>, errors: &mut Vec<LogLine>,
@@ -279,7 +248,7 @@ fn grid_chars(
let rows: Vec<&str> = content.lines().collect(); let rows: Vec<&str> = content.lines().collect();
if rows.len() != height { if rows.len() != height {
return Err(format!( return Err(format!(
"layer grid has {} rows but the board is {height} tall", "grid has {} rows but the board is {height} tall",
rows.len() rows.len()
)); ));
} }
@@ -288,7 +257,7 @@ fn grid_chars(
let row: Vec<char> = line.chars().collect(); let row: Vec<char> = line.chars().collect();
if row.len() != width { if row.len() != width {
return Err(format!( 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() row.len()
)); ));
} }
@@ -301,7 +270,7 @@ fn grid_chars(
// A whole grid of one character. // A whole grid of one character.
let ch = single_char( let ch = single_char(
fill, 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, errors,
) )
.unwrap_or(' '); .unwrap_or(' ');
@@ -333,23 +302,19 @@ fn grid_chars(
Ok(grid) Ok(grid)
} }
/// Builds one [`Layer`] from its [`LayerData`], plus the non-terrain placements it /// Builds the board's grid cells from its [`GridData`], plus the non-terrain
/// contains (with their `(x, y)`). /// placements it contains (with their `(x, y)`).
/// ///
/// `rng` is the shared, deterministically-seeded floor PRNG (one per board build), /// Returns `Err` only on a grid-dimension mismatch (the single hard error);
/// threaded through so procedural floors depend only on map content. Returns /// every other problem is recorded on `errors`.
/// `Err` only on a grid-dimension mismatch (the single hard error, matching the pub(crate) fn build_grid(
/// pre-layers loader); every other problem is recorded on `errors`. data: &GridData,
pub(crate) fn build_layer(
data: &LayerData,
width: usize, width: usize,
height: usize, height: usize,
rng: &mut StdRand,
errors: &mut Vec<LogLine>, errors: &mut Vec<LogLine>,
) -> Result<(Layer, Vec<Placement>), String> { ) -> Result<(Vec<GridCell>, Vec<Placement>), String> {
// Resolve each palette entry once (per-cell work like generators happens below). // Resolve each palette entry once. Space is always a transparent empty cell, so
// Space is always a transparent empty cell, so it is never a palette key — any // it is never a palette key — any `" "` entry is ignored.
// `" "` entry is ignored.
let resolved: HashMap<char, Resolved> = data let resolved: HashMap<char, Resolved> = data
.palette .palette
.iter() .iter()
@@ -363,7 +328,7 @@ pub(crate) fn build_layer(
let grid = grid_chars(data, width, height, errors)?; let grid = grid_chars(data, width, height, errors)?;
// Walk the grid, filling cells and collecting placements. // 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(); let mut placements: Vec<Placement> = Vec::new();
for (y, row) in grid.iter().enumerate() { for (y, row) in grid.iter().enumerate() {
for (x, &ch) in row.iter().enumerate() { for (x, &ch) in row.iter().enumerate() {
@@ -374,7 +339,6 @@ pub(crate) fn build_layer(
} }
match resolved.get(&ch) { match resolved.get(&ch) {
Some(Resolved::Cell(g, a)) => cells.push((*g, *a)), Some(Resolved::Cell(g, a)) => cells.push((*g, *a)),
Some(Resolved::FloorGen(g)) => cells.push((g.generate(rng), Archetype::Empty)),
Some(Resolved::Object(t)) => { Some(Resolved::Object(t)) => {
cells.push((Glyph::transparent(), Archetype::Empty)); cells.push((Glyph::transparent(), Archetype::Empty));
placements.push(Placement::Object(t.clone(), x, y)); placements.push(Placement::Object(t.clone(), x, y));
@@ -397,5 +361,5 @@ pub(crate) fn build_layer(
} }
} }
Ok((Layer { cells }, placements)) Ok((cells, placements))
} }
+6 -1
View File
@@ -8,6 +8,8 @@ pub mod colors;
pub mod cp437; pub mod cp437;
/// Procedural floor generators ([`floor::FloorGenerator`]). /// Procedural floor generators ([`floor::FloorGenerator`]).
pub mod floor; pub mod floor;
/// Lighting & field-of-view for dark boards ([`fov::Lighting`]).
pub mod fov;
/// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc. /// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc.
pub mod game; pub mod game;
pub mod glyph; pub mod glyph;
@@ -22,10 +24,13 @@ pub mod script;
mod utils; mod utils;
/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]). /// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]).
pub mod world; pub mod world;
pub mod player;
pub mod keys;
pub use archetype::{Archetype, Builtin}; pub use archetype::{Archetype, Builtin};
pub use board::Board; pub use board::Board;
pub use fov::{Lighting, SIGHT_RADIUS};
pub use utils::Direction; pub use utils::Direction;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
mod api;
+352 -120
View File
@@ -16,30 +16,37 @@ use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::TryFrom; use std::convert::TryFrom;
use std::path::Path; use std::path::Path;
use tinyrand::{Seeded, StdRand}; use crate::api::queue::ObjQueue;
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::board::Board; use crate::board::{Board, Decoration};
use crate::builtin_scripts::archetype_from_builtin_tag; use crate::builtin_scripts::archetype_from_builtin_tag;
use crate::floor::FLOOR_SEED; use crate::floor::{Floor, FloorGenerator};
use crate::glyph::Glyph; 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::log::LogLine;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use crate::utils::{ObjectId, Player, PortalDef}; 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 /// 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` /// save a [`Board`] is converted back via [`From<&Board>`]. See `maps/start.toml`
/// for a complete example of the format. /// for a complete example of the format.
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct MapFile { pub struct MapFile {
/// The `[map]` header: name, dimensions, optional board script. /// The `[map]` header: name, dimensions, floor, optional board script.
pub map: MapHeader, 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)] #[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. /// The `[map]` header section of a board.
@@ -47,13 +54,116 @@ pub struct MapFile {
pub struct MapHeader { pub struct MapHeader {
/// Human-readable name for this board, e.g. `"Opening Room"`. /// Human-readable name for this board, e.g. `"Opening Room"`.
pub name: String, 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, 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, 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. /// Name of the board-level script in the `[scripts]` table, if any.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub board_script_name: Option<String>, pub board_script_name: Option<String>,
/// When `true`, this is a "dark" board: front-ends reveal only cells within
/// the player's field of view. Absent ⇒ `false` (fully lit); omitted from
/// the saved TOML when `false`. See [`Board::dark`](crate::board::Board::dark).
#[serde(default, skip_serializing_if = "is_false")]
pub dark: bool,
}
/// serde `skip_serializing_if` predicate: omit a `bool` field when it is `false`
/// (so the common non-dark board doesn't emit a `dark = false` line).
fn is_false(b: &bool) -> bool {
!*b
}
/// 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. /// A tile index in a palette entry: either a plain integer or a character literal.
@@ -104,13 +214,16 @@ pub(crate) fn color_to_hex(color: Rgba8) -> String {
/// Converts a parsed map file into a runtime [`Board`]. /// Converts a parsed map file into a runtime [`Board`].
/// ///
/// Builds each layer (collecting its object/portal/player placements), then runs /// Builds the single grid (collecting object/portal/player placements), resolves
/// the cross-layer validations that span the whole board: /// 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; /// - 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). /// - 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 { impl TryFrom<MapFile> for Board {
type Error = String; type Error = String;
@@ -119,30 +232,28 @@ impl TryFrom<MapFile> for Board {
let h = mf.map.height; let h = mf.map.height;
let mut load_errors: Vec<LogLine> = Vec::new(); let mut load_errors: Vec<LogLine> = Vec::new();
// One PRNG for the whole board so generated floors depend only on content. // Build the single grid, collecting non-terrain placements.
let mut rng = StdRand::seed(FLOOR_SEED); 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();
// Build every layer, collecting non-terrain placements with their layer z. let mut portal_specs: Vec<(crate::layer::PortalTemplate, usize, usize)> = Vec::new();
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();
let mut player_positions: Vec<(usize, usize)> = Vec::new(); let mut player_positions: Vec<(usize, usize)> = Vec::new();
for (z, data) in mf.layers.iter().enumerate() { for p in placements {
let (layer, placements) = build_layer(data, w, h, &mut rng, &mut load_errors)?; match p {
for p in placements { Placement::Object(t, x, y) => object_specs.push((t, x, y)),
match p { Placement::Portal(t, x, y) => portal_specs.push((t, x, y)),
Placement::Object(t, x, y) => object_specs.push((t, x, y, z)), Placement::Player(x, y) => player_positions.push((x, y)),
Placement::Portal(t, x, y) => portal_specs.push((t, x, y, z)),
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() { let (px, py) = match player_positions.len() {
1 => player_positions[0], 1 => player_positions[0],
0 => { 0 => {
@@ -153,50 +264,34 @@ impl TryFrom<MapFile> for Board {
} }
n => { n => {
load_errors.push(LogLine::error(format!( 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] player_positions[0]
} }
}; };
let pidx = py * w + px; let pidx = py * w + px;
// Enforce one solid per cell across all layers: the first solid seen at a // Track which cells hold a solid (the grid's own solids seed the map).
// cell claims it; a later (upper) solid stacked on it is dropped.
let mut solid_occupied = vec![false; w * h]; let mut solid_occupied = vec![false; w * h];
for (z, layer) in layers.iter_mut().enumerate() { for (idx, cell) in grid.iter().enumerate() {
for (idx, cell) in layer.cells.iter_mut().enumerate() { if cell.1.behavior().solid {
if !cell.1.behavior().solid { solid_occupied[idx] = true;
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;
}
} }
} }
// The player wins its cell: clear any solid terrain under it (any layer) // The player wins its cell: clear any solid terrain under it and claim the
// and claim the cell so a solid object placed here is dropped below. // cell so a solid object placed here is dropped below.
if solid_occupied[pidx] { if grid[pidx].1.behavior().solid {
for layer in &mut layers { grid[pidx] = (Glyph::transparent(), Archetype::Empty);
if layer.cells[pidx].1.behavior().solid {
layer.cells[pidx] = (Glyph::transparent(), Archetype::Empty);
}
}
} }
solid_occupied[pidx] = true; solid_occupied[pidx] = true;
// Spawn objects in layer-then-reading order, so ids are deterministic and // Spawn objects in reading order, so ids are deterministic and "lowest id
// "lowest id wins a collision" / "first claimant keeps the name" hold. // wins a collision" / "first claimant keeps the name" hold.
let mut objects: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new(); let mut objects: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
let mut next_object_id: ObjectId = 1; let mut next_object_id: ObjectId = 1;
let mut seen_names: HashMap<String, ObjectId> = HashMap::new(); 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; let idx = y * w + x;
// A solid object may not share a cell with another solid. // A solid object may not share a cell with another solid.
if t.solid && solid_occupied[idx] { if t.solid && solid_occupied[idx] {
@@ -210,19 +305,7 @@ impl TryFrom<MapFile> for Board {
} }
let id = next_object_id; let id = next_object_id;
// Name uniqueness: first claimant keeps it; later duplicates are cleared. // Name uniqueness: first claimant keeps it; later duplicates are cleared.
let name = t.name.and_then(|n| match seen_names.entry(n.clone()) { let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors));
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
}
});
if t.solid { if t.solid {
solid_occupied[idx] = true; solid_occupied[idx] = true;
} }
@@ -232,11 +315,11 @@ impl TryFrom<MapFile> for Board {
id, id,
x, x,
y, y,
z,
glyph: t.glyph, glyph: t.glyph,
solid: t.solid, solid: t.solid,
opaque: t.opaque, opaque: t.opaque,
pushable: t.pushable, pushable: t.pushable,
light: t.light,
// Hand-placed objects are never grab targets; only expanded // Hand-placed objects are never grab targets; only expanded
// grab archetypes (e.g. gems) set this (see expand_builtin_archetypes). // grab archetypes (e.g. gems) set this (see expand_builtin_archetypes).
grab: false, grab: false,
@@ -245,6 +328,42 @@ impl TryFrom<MapFile> for Board {
// (see `expand_builtin_archetypes` below), not via templates. // (see `expand_builtin_archetypes` below), not via templates.
builtin_script: None, builtin_script: None,
tags: t.tags.into_iter().collect(), tags: t.tags.into_iter().collect(),
queue: ObjQueue::new(),
name,
},
);
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,
light: 0,
grab: false,
script_name: Some(t.script_name),
builtin_script: None,
tags: t.tags.unwrap_or_default().into_iter().collect(),
queue: ObjQueue::new(),
name, name,
}, },
); );
@@ -254,7 +373,7 @@ impl TryFrom<MapFile> for Board {
// Build the portal list, dropping duplicate names (first claimant wins). // Build the portal list, dropping duplicate names (first claimant wins).
let mut seen_portal_names: HashSet<String> = HashSet::new(); let mut seen_portal_names: HashSet<String> = HashSet::new();
let mut portals: Vec<PortalDef> = Vec::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()) { if !seen_portal_names.insert(t.name.clone()) {
load_errors.push(LogLine::error(format!( load_errors.push(LogLine::error(format!(
"portal name {:?} already used by another portal; skipping portal", "portal name {:?} already used by another portal; skipping portal",
@@ -266,36 +385,98 @@ impl TryFrom<MapFile> for Board {
name: t.name, name: t.name,
x, x,
y, y,
z,
target_map: t.target_map, target_map: t.target_map,
target_entry: t.target_entry, 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 { let mut board = Board {
name: mf.map.name, name: mf.map.name,
width: w, width: w,
height: h, height: h,
layers, grid,
player: Player { floor,
x: px as i32, decorations,
y: py as i32, player: PlayerPos {
x: px as i64,
y: py as i64,
}, },
objects, objects,
next_object_id, next_object_id,
portals, portals,
board_script_name: mf.map.board_script_name, board_script_name: mf.map.board_script_name,
dark: mf.map.dark,
load_errors, load_errors,
registry: HashMap::new(), registry: HashMap::new(),
}; };
// Turn script-backed archetype cells (pushers/spinners) into their scripted // 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. // hold; the same call also fixes editor-placed machines before a playtest.
board.expand_builtin_archetypes(); board.expand_builtin_archetypes();
Ok(board) 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 /// Pool of palette characters for save: printable ASCII (plus a leading space for
/// the common transparent-empty cell), excluding `"` and `\` which would need /// the common transparent-empty cell), excluding `"` and `\` which would need
/// escaping inside a TOML string. /// escaping inside a TOML string.
@@ -309,24 +490,15 @@ fn char_pool() -> Vec<char> {
pool 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 { fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
if arch == Archetype::Empty { if arch == Archetype::Empty {
if glyph.tile == 0 { PaletteEntry {
// Transparent: lower layers show through. kind: "empty".into(),
PaletteEntry { ..Default::default()
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()
}
} }
} else { } else {
PaletteEntry { PaletteEntry {
@@ -339,19 +511,25 @@ fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
} }
} }
/// Serializes one layer `z` of `board` to a [`LayerData`], optionally writing the /// Whether an object is a **trigger** — an invisible, non-solid, script-only object
/// player cell into this layer (done on the top layer only). /// authored/serialized in `[[triggers]]` rather than the grid palette.
fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData { 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 (w, h) = (board.width, board.height);
let mut pool = char_pool().into_iter(); let mut pool = char_pool().into_iter();
let mut palette: HashMap<String, PaletteEntry> = HashMap::new(); let mut palette: HashMap<String, PaletteEntry> = HashMap::new();
let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new(); let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new();
let mut grid: Vec<Vec<char>> = vec![vec![' '; w]; h]; 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 (y, row) in grid.iter_mut().enumerate() {
for (x, slot) in row.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(|| { *slot = *cell_to_key.entry((glyph, arch)).or_insert_with(|| {
let ch = pool.next().expect("ran out of palette characters"); let ch = pool.next().expect("ran out of palette characters");
palette.insert(ch.to_string(), cell_entry(glyph, arch)); palette.insert(ch.to_string(), cell_entry(glyph, arch));
@@ -360,8 +538,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. // Objects overwrite their (transparent) grid cell with an object char. Triggers
for o in board.objects.values().filter(|o| o.z == z) { // 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 // 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), // archetype keyword: emit it as a terrain cell (deduped with real terrain),
// using the object's current glyph, rather than a `kind = "object"` entry. // using the object's current glyph, rather than a `kind = "object"` entry.
@@ -387,6 +566,7 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
solid: Some(o.solid), solid: Some(o.solid),
opaque: Some(o.opaque), opaque: Some(o.opaque),
pushable: Some(o.pushable), pushable: Some(o.pushable),
light: (o.light > 0).then_some(o.light),
script_name: o.script_name.clone(), script_name: o.script_name.clone(),
tags: (!tags.is_empty()).then_some(tags), tags: (!tags.is_empty()).then_some(tags),
name: o.name.clone(), name: o.name.clone(),
@@ -396,8 +576,8 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
grid[o.y][o.x] = ch; grid[o.y][o.x] = ch;
} }
// Portals on this layer. // Portals.
for p in board.portals.iter().filter(|p| p.z == z) { for p in board.portals.iter() {
let ch = pool.next().expect("ran out of palette characters"); let ch = pool.next().expect("ran out of palette characters");
palette.insert( palette.insert(
ch.to_string(), ch.to_string(),
@@ -412,8 +592,8 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
grid[p.y][p.x] = ch; grid[p.y][p.x] = ch;
} }
// The player is written onto the top layer. // The player.
if place_player { {
let ch = pool.next().expect("ran out of palette characters"); let ch = pool.next().expect("ran out of palette characters");
palette.insert( palette.insert(
ch.to_string(), ch.to_string(),
@@ -432,7 +612,7 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
.join("\n") .join("\n")
+ "\n"; + "\n";
// Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences. // Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences.
LayerData { GridData {
content: Some(content), content: Some(content),
fill: None, fill: None,
sparse: None, sparse: None,
@@ -440,25 +620,77 @@ 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`]. /// Converts a runtime [`Board`] back into a serializable [`MapFile`].
/// ///
/// Emits one `[[layers]]` entry per board layer; the player is written onto the /// Emits the single `[grid]`, the `[[triggers]]` (invisible script objects) and
/// top layer. Procedural floors were baked to literal glyphs at load, so they are /// `[[decorations]]` lists, and the `floor` attribute (a biome re-emits its
/// saved as fixed `floor` glyphs (the generator declaration is not recovered). /// generator name, so procedural floors round-trip).
impl From<&Board> for MapFile { impl From<&Board> for MapFile {
fn from(board: &Board) -> Self { fn from(board: &Board) -> Self {
let top = board.layer_count() - 1; let grid = grid_to_data(board);
let layers = (0..board.layer_count()) // Trigger objects → `[[triggers]]`.
.map(|z| layer_to_data(board, z, z == top)) 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(); .collect();
MapFile { MapFile {
map: MapHeader { map: MapHeader {
name: board.name.clone(), name: board.name.clone(),
width: board.width, width: board.width,
height: board.height, height: board.height,
floor: floor_to_spec(&board.floor),
board_script_name: board.board_script_name.clone(), board_script_name: board.board_script_name.clone(),
dark: board.dark,
}, },
layers, grid,
triggers,
decorations,
} }
} }
} }
+10 -5
View File
@@ -2,6 +2,7 @@ use crate::glyph::Glyph;
use crate::utils::ObjectId; use crate::utils::ObjectId;
use color::Rgba8; use color::Rgba8;
use std::collections::HashSet; use std::collections::HashSet;
use crate::api::queue::ObjQueue;
/// A scripted object placed on the board, loaded from a map file. /// A scripted object placed on the board, loaded from a map file.
/// ///
@@ -33,10 +34,6 @@ pub struct ObjectDef {
pub x: usize, pub x: usize,
/// Row of this object on the board (0-indexed). /// Row of this object on the board (0-indexed).
pub y: usize, 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 /// 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. /// from the grid cell), so scripts can change tile, fg, and bg at runtime.
pub glyph: Glyph, pub glyph: Glyph,
@@ -54,6 +51,11 @@ pub struct ObjectDef {
/// remove itself via `die()`). Set when a grab archetype (e.g. a gem) is /// remove itself via `die()`). Set when a grab archetype (e.g. a gem) is
/// expanded into an object. Defaults to `false`. /// expanded into an object. Defaults to `false`.
pub grab: bool, pub grab: bool,
/// Light radius in cells that this object emits on a `dark` board (0 = no
/// light). The emitted *color* is this object's own glyph foreground color,
/// so a red glyph casts red light. See [`crate::fov`] for the lighting model.
/// Scripts can change it at runtime via `set_light(n)`. Defaults to `0`.
pub light: u32,
/// Compile-key of the Rhai script that drives this object: a name in /// Compile-key of the Rhai script that drives this object: a name in
/// [`World::scripts`](crate::world::World::scripts) for a hand-authored object, /// [`World::scripts`](crate::world::World::scripts) for a hand-authored object,
/// or a synthetic `BUILTIN_*` name set when a script-backed archetype is /// or a synthetic `BUILTIN_*` name set when a script-backed archetype is
@@ -75,6 +77,8 @@ pub struct ObjectDef {
/// Names are validated for uniqueness at map-load time; a duplicate name is /// Names are validated for uniqueness at map-load time; a duplicate name is
/// cleared to `None` (the object survives but becomes anonymous). /// cleared to `None` (the object survives but becomes anonymous).
pub name: Option<String>, pub name: Option<String>,
/// The output queue of actions for this object
pub queue: ObjQueue,
} }
impl ObjectDef { impl ObjectDef {
@@ -98,15 +102,16 @@ impl ObjectDef {
id: 0, id: 0,
x, x,
y, y,
z: 0,
glyph: Self::default_glyph(), glyph: Self::default_glyph(),
solid: true, solid: true,
opaque: true, opaque: true,
pushable: false, pushable: false,
grab: false, grab: false,
light: 0,
script_name: None, script_name: None,
builtin_script: None, builtin_script: None,
tags: HashSet::new(), tags: HashSet::new(),
queue: ObjQueue::new(),
name: None, name: None,
} }
} }
+65
View File
@@ -0,0 +1,65 @@
use std::cell::RefCell;
use std::rc::Rc;
use crate::keys::Keyring;
/// The game-global player state: stats that follow the player across boards.
///
/// Owned by [`GameState::player`](crate::game::GameState) (a single value, not
/// per-board), so health, gems, and keys persist through board transitions.
/// Distinct from [`PlayerPos`](crate::utils::PlayerPos), which is the player's
/// position *on a particular board*. A `Copy` snapshot is handed to each script
/// hook and exposed to Rhai (read-only) as the `Player` constant.
#[derive(Copy, Clone)]
pub struct Player {
/// The player's current health. Game-global (not per-board), so it persists
/// across board transitions. Scripts change it via `alter_health(dh)`, which
/// clamps to `[0, max_health]`. Front-ends display it via the status sidebar.
pub health: i64,
/// The maximum health the player can have. Scripts clamp `alter_health` to
/// this ceiling; the status sidebar uses it to size the heart row.
pub max_health: i64,
/// The player's key inventory. Game-global; persists across board transitions.
pub keys: Keyring,
/// The number of gems the player has collected. Game-global like
/// [`health`](Player::health); starts at `0`.
pub gems: i64,
}
pub type PlayerRef = Rc<RefCell<Player>>;
impl Player {
/// Create a new PlayerRef from a default player
pub fn new_ref() -> PlayerRef {
Rc::new(RefCell::from(Player::default()))
}
/// Attempt to modify the gem total by the given amount, but maintain a minimum of zero.
/// If the modification would take up below zero, leave it alone and return false.
pub fn alter_gems(&mut self, delta: i64) -> bool {
if delta + self.gems < 0 {
false
} else {
self.gems += delta;
true
}
}
/// Modify the player's health by the given delta, clamped to within the range `[0, max_health]`
pub fn alter_health(&mut self, delta: i64) {
self.health = (self.health + delta).clamp(0, self.max_health);
}
}
impl Default for Player {
fn default() -> Player {
Self {
health: 5,
max_health: 5,
gems: 0,
keys: Keyring::default(),
}
}
}
+324 -866
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,7 +3,7 @@
// A gem is a grabbable collectible: walking onto it (or pushing it into the // A gem is a grabbable collectible: walking onto it (or pushing it into the
// player) fires this `grab()` hook instead of blocking. We bump the player's gem // player) fires this `grab()` hook instead of blocking. We bump the player's gem
// count and remove ourselves from the board. // count and remove ourselves from the board.
fn grab() { fn grab(me) {
add_gems(1); alter_gems(1);
die(); die();
} }
+8
View File
@@ -0,0 +1,8 @@
// Built-in script for the `heart` archetype.
//
// A heart is a grabbable collectible: walking onto it fires `grab()` instead
// of blocking. It restores 1 health and removes itself from the board.
fn grab(me) {
alter_health(1);
die();
}
+25
View File
@@ -0,0 +1,25 @@
// Built-in script for the `gem` archetype (see kiln-core/src/builtin_scripts.rs).
//
// A gem is a grabbable collectible: walking onto it (or pushing it into the
// player) fires this `grab()` hook instead of blocking. We bump the player's gem
// count and remove ourselves from the board.
fn grab(me) {
let colors = [
"red",
"orange",
"yellow",
"green",
"blue",
"cyan",
"purple",
"white"
];
for c in colors {
if me.has_tag(`BUILTIN_key_${c}`) {
set_key(c, true);
}
}
die();
}
+7 -7
View File
@@ -6,17 +6,17 @@
// pusher shares one compiled copy and learns its direction from the // pusher shares one compiled copy and learns its direction from the
// `BUILTIN_pusher_<dir>` tag the map loader attached to it. // `BUILTIN_pusher_<dir>` tag the map loader attached to it.
fn tick(dt) { fn tick(me, dt) {
// Only queue a step when idle. `move` shoves the chain ahead via step_object // Only queue a step when idle. `move` shoves the chain ahead via step_object
// and is a no-op when blocked; the delay pads each step out to ~0.5s (move // and is a no-op when blocked; the delay pads each step out to ~0.5s (move
// itself costs 0.25s), matching the old global pusher heartbeat. // itself costs 0.25s), matching the old global pusher heartbeat.
// //
// The direction is read here, at the top level of the hook, because `Me` is a // The direction is read from `me` (the hook's first parameter); to use it in a
// per-object scope constant and is not visible inside other script functions. // helper function you would pass `me` down explicitly.
if Queue.length() == 0 { if !me.waiting {
let dir = if Me.has_tag("BUILTIN_pusher_north") { North } let dir = if me.has_tag("BUILTIN_pusher_north") { North }
else if Me.has_tag("BUILTIN_pusher_south") { South } else if me.has_tag("BUILTIN_pusher_south") { South }
else if Me.has_tag("BUILTIN_pusher_east") { East } else if me.has_tag("BUILTIN_pusher_east") { East }
else { West }; else { West };
move(dir); move(dir);
delay(0.25); delay(0.25);
+22 -25
View File
@@ -1,31 +1,28 @@
// Rotates this object's 8 neighbours one step around a ring every 0.5s, shoving // Rotates this object's 8 neighbours one step around a ring every 0.5s via a single
// each pushable into the next ring slot via a single simultaneous `swap`. The spin // `shift()` of the ring coordinates. `shift` moves each pushable to the next slot,
// direction comes from the `BUILTIN_spinner_*` tag the map loader attaches (it // skips non-pushables, and won't move anything into a cell that isn't vacant (or
// defaults to clockwise when no tag is present). // vacated by this same rotation) — so the "is the slot free?" decision lives in the
// // engine and the script just hands it the ring. The spin direction comes from the
// A neighbour may only rotate if its destination slot will actually be free: the // `BUILTIN_spinner_*` tag the map loader attaches (it defaults to clockwise when no
// slot is a hole (`passable`), or that slot's own occupant also rotates out. We // tag is present).
// can't decide that with `can_shift` alone — `can_shift(j) == false` is ambiguous fn tick(me, dt) {
// between "j is empty" and "j is a blocked solid" — so we seed with `can_shift`
// and then cascade-demote using `passable` to spot the genuine holes.
fn tick(dt) {
// Only start a new rotation when the previous one (and its delay) has drained, // Only start a new rotation when the previous one (and its delay) has drained,
// exactly like the built-in pusher's pacing. // exactly like the built-in pusher's pacing.
if Queue.length() != 0 { return; } if me.waiting { return; }
// Direction from the builtin tag; clockwise unless explicitly counter-clockwise. // Direction from the builtin tag; clockwise unless explicitly counter-clockwise.
let cw = !Me.has_tag("BUILTIN_spinner_ccw"); let cw = !me.has_tag("BUILTIN_spinner_ccw");
// The 8 neighbour offsets in clockwise order, starting at North. // The 8 neighbour offsets in clockwise order, starting at North.
let cw_ring = [ let cw_ring = [
[Me.x, Me.y-1], // N [me.x, me.y-1], // N
[Me.x+1, Me.y-1], // NE [me.x+1, me.y-1], // NE
[Me.x+1, Me.y], // E [me.x+1, me.y], // E
[Me.x+1, Me.y+1], // SE [me.x+1, me.y+1], // SE
[Me.x, Me.y+1], // S [me.x, me.y+1], // S
[Me.x-1, Me.y+1], // SW [me.x-1, me.y+1], // SW
[Me.x-1, Me.y], // W [me.x-1, me.y], // W
[Me.x-1, Me.y-1], // NW [me.x-1, me.y-1], // NW
]; ];
// For CCW, it's the same list in reverse // For CCW, it's the same list in reverse
@@ -45,10 +42,10 @@ fn tick(dt) {
// line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in // line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in
// the board Registry, keyed per spinner, since script scope resets each tick. // the board Registry, keyed per spinner, since script scope resets each tick.
let frames = if cw { [47, 0xc4, 92, 0xb3] } else { [92, 0xc4, 47, 0xb3] }; let frames = if cw { [47, 0xc4, 92, 0xb3] } else { [92, 0xc4, 47, 0xb3] };
let fkey = `spin_${Me.id}`; let fkey = `spin_${me.id}`;
let f = Registry.get_or(fkey, 0); let f = Board.registry.get_or(fkey, 0);
set_tile(frames[f % 4]); set_tile(frames[f % 4]);
Registry.set(fkey, (f + 1) % 4); Board.registry.set(fkey, (f + 1) % 4);
delay(0.5); me.delay(0.5);
} }
+112
View File
@@ -0,0 +1,112 @@
// Built-in script for the `transporter_*` archetypes (see archetype.rs).
//
// A transporter is a solid, see-through, unpushable machine that teleports
// whatever bumps into it from its facing side. It animates through a 4-frame
// loop (500 ms/frame) and, on a front bump, moves the bumper either to the cell
// just past it or — if that's blocked — out of the nearest opposite-facing
// transporter further along its axis (skipping any whose entrance is blocked).
//
// The direction is not baked into this source: every transporter shares one
// compiled copy and reads its `BUILTIN_transporter_<dir>` tag. The world is
// reached through the global `Board`/`Player` constants. The bumper is moved by
// coordinate via `shift([[from], [to]])`, which relocates whatever solid sits at
// the entrance — player, object, or a pushed crate — onto the empty destination.
// This transporter's facing unit vector, from its direction tag.
fn facing(me) {
if me.has_tag("BUILTIN_transporter_north") { [0, -1] }
else if me.has_tag("BUILTIN_transporter_south") { [0, 1] }
else if me.has_tag("BUILTIN_transporter_east") { [1, 0] }
else { [-1, 0] }
}
// The tag of the opposite-facing transporter this one pairs with.
fn opposite_tag(me) {
if me.has_tag("BUILTIN_transporter_north") { "BUILTIN_transporter_south" }
else if me.has_tag("BUILTIN_transporter_south") { "BUILTIN_transporter_north" }
else if me.has_tag("BUILTIN_transporter_east") { "BUILTIN_transporter_west" }
else { "BUILTIN_transporter_east" }
}
// The 4-frame animation loop for this direction (CP437 tile codes).
fn frames(me) {
if me.has_tag("BUILTIN_transporter_north") { [94, 45, 94, 126] } // ^ - ^ ~
else if me.has_tag("BUILTIN_transporter_south") { [118, 95, 118, 45] } // v _ v -
else if me.has_tag("BUILTIN_transporter_east") { [41, 124, 41, 62] } // ) | ) >
else { [40, 124, 40, 60] } // ( | ( <
}
fn tick(me, dt) {
// Advance one animation frame every 0.5s, like the spinner: frame state lives
// in the board Registry (script scope resets each tick), keyed per instance.
if me.waiting { return; }
let fs = frames(me);
let fkey = `xport_${me.id}`;
let f = Board.registry.get_or(fkey, 0);
set_tile(fs[f % 4]);
Board.registry.set(fkey, (f + 1) % 4);
me.delay(0.15);
}
// Move whatever solid sits at (sx, sy) onto the empty cell (tx, ty) immediately.
// A two-cell `shift` relocates the source solid — player, object, or crate — and
// leaves its old cell empty (the destination is checked empty by the caller). Kept
// as a helper so the nested-array literal stays at a shallow expression depth.
fn transport(sx, sy, tx, ty) {
shift([[sx, sy], [tx, ty]]); now();
}
fn bump(me, dir) {
let d = facing(me);
let dx = d[0];
let dy = d[1];
// Only transport things that hit us from the front — the entrance side (-facing).
// `dir` is the side the bump came from, so its offset must be the opposite of our
// facing; a bump from any other side does nothing.
if dir.dx != -dx || dir.dy != -dy { return; }
// Whatever solid is pressed against our entrance cell (me - d) gets moved:
// the player, another object, or a pushed crate — all handled uniformly by
// shifting the solid at that coordinate onto an empty destination.
let ex = me.x - dx;
let ey = me.y - dy;
// 1. The cell just past us (the opposite side): if nothing solid is there,
// drop the bumper onto it.
let fx = me.x + dx;
let fy = me.y + dy;
if Board.passable(fx, fy) {
transport(ex, ey, fx, fy);
return;
}
// 2. Otherwise scan along our axis for the nearest opposite-facing transporter
// whose far side (the cell just past it) is free, and drop the bumper there.
// A blocked pair is skipped; give up at the board edge.
let opp = Board.tagged(opposite_tag(me));
let cx = fx;
let cy = fy;
loop {
cx += dx;
cy += dy;
if cx < 0 || cy < 0 || cx >= Board.width || cy >= Board.height {
return;
}
// Is one of the opposite transporters sitting at (cx, cy)?
let here = false;
for o in opp {
if o.x == cx && o.y == cy { here = true; }
}
if here {
// Emerge on the paired transporter's far side — the cell just past it
// along our scan (cx + d), out its back.
let tx = cx + dx;
let ty = cy + dy;
if Board.passable(tx, ty) {
transport(ex, ey, tx, ty);
return;
}
}
}
}
+13 -42
View File
@@ -8,7 +8,7 @@ use std::time::Duration;
fn move_command_relocates_the_source_object() { fn move_command_relocates_the_source_object() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]); let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
game.run_init(); game.run_init();
let b = game.board(); let b = game.board();
// East increments x by one; the object started at (2, 1). // East increments x by one; the object started at (2, 1).
@@ -20,7 +20,7 @@ fn move_into_a_wall_or_edge_is_a_noop() {
// Object at the west edge moving west: out of bounds, ignored. // Object at the west edge moving west: out of bounds, ignored.
let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]); let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(West); }")]));
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].x, 0); assert_eq!(game.board().objects[&1].x, 0);
} }
@@ -29,7 +29,7 @@ fn move_into_a_wall_or_edge_is_a_noop() {
fn set_tile_command_changes_the_source_glyph() { fn set_tile_command_changes_the_source_glyph() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]); let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")])); GameState::with_scripts(board, scripts_from(&[("s", "fn init(m) { set_tile(7); }")]));
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7); assert_eq!(game.board().objects[&1].glyph.tile, 7);
} }
@@ -40,12 +40,12 @@ fn object_pushes_crate_on_init() {
let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]); let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
game.run_init(); game.run_init();
let b = game.board(); let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); 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(2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate); assert_eq!(b.get(3, 0).1, Archetype::Crate);
} }
#[test] #[test]
@@ -53,7 +53,7 @@ fn object_push_into_player() {
// A scripted object moving into the player pushes the player when there's room. // A scripted object moving into the player pushes the player when there's room.
let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]); let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
game.run_init(); game.run_init();
{ {
let b = game.board(); let b = game.board();
@@ -65,7 +65,7 @@ fn object_push_into_player() {
let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]); let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
wall_at(&mut board, 3, 0); wall_at(&mut board, 3, 0);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
game.run_init(); game.run_init();
let b = game.board(); let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0));
@@ -79,7 +79,7 @@ fn move_cost_rate_limits_repeated_moves() {
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]); let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("m", "fn init() { move(East); move(East); }")]), scripts_from(&[("m", "fn init(m) { move(East); move(East); }")]),
); );
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2) assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
@@ -102,7 +102,7 @@ fn inline_delay_paces_subsequent_moves() {
wall_at(&mut board, 2, 1); wall_at(&mut board, 2, 1);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("m", "fn init() { move(East); move(South); }")]), scripts_from(&[("m", "fn init(m) { move(East); move(South); }")]),
); );
game.run_init(); game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved. // First (eastward) move is blocked by the wall: object hasn't moved.
@@ -136,7 +136,7 @@ fn queue_length_reports_pending_actions() {
board, board,
scripts_from(&[( scripts_from(&[(
"q", "q",
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", "fn init(m) { set_tile(5); set_tile(6); log(`len=${m.queue.length}`); }",
)]), )]),
); );
game.run_init(); game.run_init();
@@ -166,7 +166,7 @@ fn blocked_reports_solid_and_clear() {
board, board,
scripts_from(&[( scripts_from(&[(
"b", "b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", "fn init(m) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]), )]),
); );
game.run_init(); game.run_init();
@@ -178,38 +178,9 @@ fn blocked_reports_solid_and_clear() {
board, board,
scripts_from(&[( scripts_from(&[(
"b", "b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", "fn init(m) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]), )]),
); );
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7); assert_eq!(game.board().objects[&1].glyph.tile, 7);
} }
#[test]
fn blocked_sees_earlier_objects_pending_move() {
// obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked()
// toward that same cell and must see the pending move.
let board = open_board(
5,
3,
(0, 0),
vec![
scripted_object(1, 1, "mover"),
scripted_object(3, 1, "checker"),
],
);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("mover", "fn tick(dt) { move(East); }"),
(
"checker",
"fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }",
),
]),
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1));
assert_eq!(b.objects[&2].glyph.tile, 1); // checker saw the pending move
}
+5 -4
View File
@@ -18,11 +18,11 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
scripts_from(&[ scripts_from(&[
( (
"e", "e",
"fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }", "fn init(m) { move(East); } fn bump(m,dir) { log(`o0 from ${dir}`); }",
), ),
( (
"w", "w",
"fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }", "fn init(m) { move(West); } fn bump(m,dir) { log(`o1 from ${dir}`); }",
), ),
]), ]),
); );
@@ -36,6 +36,7 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
assert_eq!((b.objects[&2].x, b.objects[&2].y), (2, 0)); // obj1 blocked assert_eq!((b.objects[&2].x, b.objects[&2].y), (2, 0)); // obj1 blocked
} }
let logs = log_texts(&game); let logs = log_texts(&game);
assert!(logs.iter().any(|t| t == "o0 by 2")); // obj0 bumped by obj1 // obj1 moved West into obj0, so the bump arrives from the East side of obj0.
assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped assert!(logs.iter().any(|t| t == "o0 from East")); // obj0 bumped by obj1
assert!(!logs.iter().any(|t| t.starts_with("o1 from"))); // obj1 not bumped
} }
+10 -10
View File
@@ -1,28 +1,29 @@
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::board::Board; use crate::board::Board;
use crate::floor::Floor;
use crate::game::GameState; use crate::game::GameState;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::layer::Layer; use crate::utils::{Direction, PlayerPos, PortalDef};
use crate::utils::{Direction, Player, PortalDef};
use crate::world::World; use crate::world::World;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::rc::Rc; use std::rc::Rc;
/// Builds a 3×3 board with the player at `(px, py)` and the given portals. /// Builds a 3×3 board with the player at `(px, py)` and the given portals.
fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board { fn make_board(px: i64, py: i64, portals: Vec<PortalDef>) -> Board {
Board { Board {
name: "test".into(), name: "test".into(),
width: 3, width: 3,
height: 3, height: 3,
layers: vec![Layer { grid: vec![(Glyph::transparent(), Archetype::Empty); 9],
cells: vec![(Glyph::transparent(), Archetype::Empty); 9], floor: Floor::Blank,
}], decorations: Vec::new(),
player: Player { x: px, y: py }, player: PlayerPos { x: px, y: py },
objects: BTreeMap::new(), objects: BTreeMap::new(),
next_object_id: 1, next_object_id: 1,
portals, portals,
board_script_name: None, board_script_name: None,
dark: false,
load_errors: Vec::new(), load_errors: Vec::new(),
registry: HashMap::new(), registry: HashMap::new(),
} }
@@ -40,7 +41,6 @@ fn two_board_world() -> World {
name: "to_b2".into(), name: "to_b2".into(),
x: 2, x: 2,
y: 0, y: 0,
z: 0,
target_map: "b2".into(), target_map: "b2".into(),
target_entry: "from_b1".into(), target_entry: "from_b1".into(),
}], }],
@@ -52,7 +52,6 @@ fn two_board_world() -> World {
name: "from_b1".into(), name: "from_b1".into(),
x: 1, x: 1,
y: 1, y: 1,
z: 0,
target_map: "b1".into(), target_map: "b1".into(),
target_entry: "to_b2".into(), target_entry: "to_b2".into(),
}], }],
@@ -60,6 +59,7 @@ fn two_board_world() -> World {
World { World {
name: "test".into(), name: "test".into(),
start: "b1".into(), start: "b1".into(),
torch: 10,
scripts: HashMap::new(), scripts: HashMap::new(),
boards: HashMap::from([ boards: HashMap::from([
("b1".to_string(), Rc::new(RefCell::new(b1))), ("b1".to_string(), Rc::new(RefCell::new(b1))),
@@ -112,7 +112,7 @@ fn enter_board_unknown_entry_logs_error() {
fn try_move_onto_portal_switches_board() { fn try_move_onto_portal_switches_board() {
let world = two_board_world(); let world = two_board_world();
// Start the player one cell west of the portal at (2, 0). // Start the player one cell west of the portal at (2, 0).
world.boards["b1"].borrow_mut().player = Player { x: 1, y: 0 }; world.boards["b1"].borrow_mut().player = PlayerPos { x: 1, y: 0 };
let mut game = GameState::from_world(world); let mut game = GameState::from_world(world);
game.try_move(Direction::East); game.try_move(Direction::East);
assert_eq!(game.current_board_name(), "b2"); assert_eq!(game.current_board_name(), "b2");
+18 -32
View File
@@ -1,36 +1,30 @@
use super::load_board; use super::load_board;
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::glyph::Glyph;
use crate::map_file::parse_color;
#[test] #[test]
fn fill_builds_a_full_grid_of_one_char() { 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 // `fill` fills the whole single grid with one palette char. No player char is
// the player. Every cell of layer 0 must be that floor glyph. // 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##" let toml = r##"
[map] [map]
name = "Test" name = "Test"
width = 3 width = 3
height = 2 height = 2
[[layers]] [grid]
fill = "f" fill = "#"
palette = { "f" = { kind = "floor", tile = ".", fg = "#112233", bg = "#445566" } } palette = { "#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" } }
[[layers]]
sparse = [ { x = 0, y = 0, ch = "@" } ]
palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
"##; "##;
let board = load_board(toml); 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 y in 0..2 {
for x in 0..3 { 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)); assert_eq!((board.player.x, board.player.y), (0, 0));
@@ -38,18 +32,14 @@ palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
#[test] #[test]
fn sparse_places_only_listed_cells() { 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##" let toml = r##"
[map] [map]
name = "Test" name = "Test"
width = 4 width = 4
height = 1 height = 1
[[layers]] [grid]
fill = "g"
palette = { "g" = { kind = "floor", generator = "grass" } }
[[layers]]
sparse = [ { x = 0, y = 0, ch = "@" }, { x = 2, y = 0, ch = "O" } ] 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" } } 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.len(), 1);
assert_eq!((board.objects[&1].x, board.objects[&1].y), (2, 0)); assert_eq!((board.objects[&1].x, board.objects[&1].y), (2, 0));
// An unlisted sparse cell is a transparent empty. // 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] #[test]
@@ -70,7 +60,7 @@ name = "Test"
width = 2 width = 2
height = 1 height = 1
[[layers]] [grid]
sparse = [ { x = 5, y = 0, ch = "O" }, { x = 0, y = 0, ch = "@" } ] 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" } } palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } }
"##; "##;
@@ -95,18 +85,14 @@ name = "Test"
width = 2 width = 2
height = 1 height = 1
[[layers]] [grid]
fill = "xy" fill = "xy"
palette = { " " = { kind = "empty" } } palette = { " " = { kind = "empty" } }
[[layers]]
sparse = [ { x = 0, y = 0, ch = "@" } ]
palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
"##; "##;
let board = load_board(toml); let board = load_board(toml);
assert!( assert!(
!board.is_valid(), !board.is_valid(),
"a non-single-char fill is a nonfatal error" "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);
} }
+6 -6
View File
@@ -1,4 +1,4 @@
use super::{layer, load_board, map}; use super::{grid, load_board, map};
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::board::Board; use crate::board::Board;
use crate::map_file::MapFile; use crate::map_file::MapFile;
@@ -6,7 +6,7 @@ use crate::map_file::MapFile;
#[test] #[test]
fn grid_wrong_row_count_returns_error() { fn grid_wrong_row_count_returns_error() {
// height = 3 but only 2 rows in the layer grid. // 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 mf: MapFile = toml::from_str(&toml).unwrap();
let result = Board::try_from(mf); let result = Board::try_from(mf);
assert!(result.is_err()); assert!(result.is_err());
@@ -24,7 +24,7 @@ fn grid_wrong_row_count_returns_error() {
#[test] #[test]
fn grid_wrong_row_width_returns_error() { fn grid_wrong_row_width_returns_error() {
// width = 4 but second row is only 3 characters. // 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 mf: MapFile = toml::from_str(&toml).unwrap();
let result = Board::try_from(mf); let result = Board::try_from(mf);
assert!(result.is_err()); assert!(result.is_err());
@@ -46,14 +46,14 @@ fn unknown_kind_produces_error_block() {
let toml = map( let toml = map(
2, 2,
1, 1,
&[layer( &grid(
"X@", "X@",
&[("X", "kind = \"frobnicate\""), ("@", "kind = \"player\"")], &[("X", "kind = \"frobnicate\""), ("@", "kind = \"player\"")],
)], ),
); );
let board = load_board(&toml); let board = load_board(&toml);
assert_eq!( assert_eq!(
*board.get(0, 0, 0), *board.get(0, 0),
(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock) (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)
); );
} }
+12 -15
View File
@@ -6,6 +6,7 @@ mod portal_placement;
mod pushers; mod pushers;
mod round_trip; mod round_trip;
mod spinners; mod spinners;
mod transporters;
use crate::board::Board; use crate::board::Board;
use crate::map_file::MapFile; use crate::map_file::MapFile;
@@ -15,33 +16,29 @@ fn load_board(toml: &str) -> Board {
Board::try_from(mf).expect("convert to 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 /// `palette` table. Each palette entry is `(char_key, inline-body)` where the
/// body is the inside of the entry's `{ ... }` (e.g. `kind = "wall"`). /// 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 let pal = palette
.iter() .iter()
.map(|(k, body)| format!("\"{k}\" = {{ {body} }}")) .map(|(k, body)| format!("\"{k}\" = {{ {body} }}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .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]]` /// Wraps a `[map]` header of the given size around the single `[grid]` block
/// blocks (each produced by [`layer`]). /// (produced by [`grid`]).
fn map(width: usize, height: usize, layers: &[String]) -> String { fn map(width: usize, height: usize, grid_block: &str) -> String {
let mut s = format!("[map]\nname = \"Test\"\nwidth = {width}\nheight = {height}\n"); format!("[map]\nname = \"Test\"\nwidth = {width}\nheight = {height}\n{grid_block}")
for l in layers {
s.push_str(l);
}
s
} }
/// A 3×1 single-layer map: an `empty`/`wall` palette plus one `object` entry /// 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 /// 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; /// 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. /// 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() { let body = if extra.is_empty() {
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"".to_string() "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"".to_string()
} else { } else {
@@ -50,8 +47,8 @@ fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String {
map( map(
3, 3,
1, 1,
&[layer( &grid(
grid, grid_str,
&[ &[
(" ", "kind = \"empty\""), (" ", "kind = \"empty\""),
(".", "kind = \"empty\""), (".", "kind = \"empty\""),
@@ -62,6 +59,6 @@ fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String {
("@", "kind = \"player\""), ("@", "kind = \"player\""),
(ch, &body), (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; use crate::archetype::Archetype;
/// Palette shorthands. /// Palette shorthand.
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
const PLAYER: (&str, &str) = ("@", "kind = \"player\""); 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. /// An object palette entry body with the given `extra` fields appended.
fn obj(extra: &str) -> String { fn obj(extra: &str) -> String {
@@ -25,14 +20,14 @@ fn duplicate_name_clears_second_but_keeps_both_objects() {
let board = load_board(&map( let board = load_board(&map(
3, 3,
1, 1,
&[layer( &grid(
"GH@", "GH@",
&[ &[
("G", &obj("name = \"gate\"")), ("G", &obj("name = \"gate\"")),
("H", &obj("name = \"gate\", solid = false")), ("H", &obj("name = \"gate\", solid = false")),
PLAYER, PLAYER,
], ],
)], ),
)); ));
assert_eq!(board.objects.len(), 2, "both objects survive"); assert_eq!(board.objects.len(), 2, "both objects survive");
assert_eq!(board.objects[&1].name.as_deref(), Some("gate")); 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", "")); let board = load_board(&map_3x1_object("G.@", "G", ""));
assert_eq!(board.objects.len(), 1); assert_eq!(board.objects.len(), 1);
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); 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] #[test]
@@ -65,10 +60,10 @@ fn palette_char_multi_occurrence_only_first_keeps_name() {
let board = load_board(&map( let board = load_board(&map(
4, 4,
1, 1,
&[layer( &grid(
"GGG@", "GGG@",
&[("G", &obj("solid = false, name = \"guard\"")), PLAYER], &[("G", &obj("solid = false, name = \"guard\"")), PLAYER],
)], ),
)); ));
assert_eq!(board.objects.len(), 3); assert_eq!(board.objects.len(), 3);
assert_eq!(board.objects[&1].name.as_deref(), Some("guard")); assert_eq!(board.objects[&1].name.as_deref(), Some("guard"));
@@ -77,41 +72,11 @@ fn palette_char_multi_occurrence_only_first_keeps_name() {
} }
#[test] #[test]
fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() { fn non_solid_object_and_wall_coexist_in_separate_cells() {
// Solid object stacked on a wall (different layer): dropped for the conflict. // With one grid each cell holds a single palette char, so a solid object can
let solid = map( // never be authored onto a wall cell. A wall and a (separate-cell) non-solid
3, // object both load fine.
1, let board = load_board(&map_3x1_object("#G@", "G", "solid = false"));
&[
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(""))]),
],
));
assert_eq!(board.objects.len(), 1); 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; use crate::archetype::Archetype;
/// Palette shorthands shared by these tests. /// Palette shorthands shared by these tests.
@@ -11,61 +11,22 @@ const WALL: (&str, &str) = (
#[test] #[test]
fn player_char_places_on_empty_floor() { 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.player.x, b.player.y), (1, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); assert_eq!(b.get(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!(b.is_valid()); assert!(b.is_valid());
} }
#[test] #[test]
fn player_char_appearing_twice_uses_first() { 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_eq!((b.player.x, b.player.y), (0, 0));
assert!(!b.is_valid()); assert!(!b.is_valid());
} }
#[test] #[test]
fn player_char_missing_falls_back_to_origin() { 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_eq!((b.player.x, b.player.y), (0, 0));
assert!(!b.is_valid()); assert!(!b.is_valid());
} }
@@ -74,8 +35,30 @@ fn player_char_missing_falls_back_to_origin() {
fn player_fallback_to_origin_clears_solid_terrain() { fn player_fallback_to_origin_clears_solid_terrain() {
// No player cell, so the player falls back to (0, 0) — which holds a wall. The // 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. // 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.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()); 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 EMPTY: (&str, &str) = (".", "kind = \"empty\"");
const PLAYER: (&str, &str) = ("@", "kind = \"player\""); const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
@@ -9,7 +9,7 @@ fn portal_duplicate_name_drops_second() {
let board = load_board(&map( let board = load_board(&map(
3, 3,
1, 1,
&[layer( &grid(
"12@", "12@",
&[ &[
( (
@@ -22,7 +22,7 @@ fn portal_duplicate_name_drops_second() {
), ),
PLAYER, PLAYER,
], ],
)], ),
)); ));
assert_eq!( assert_eq!(
board.portals.len(), board.portals.len(),
@@ -38,7 +38,7 @@ fn portal_palette_char_places_portal_at_grid_position() {
let board = load_board(&map( let board = load_board(&map(
3, 3,
1, 1,
&[layer( &grid(
"1.@", "1.@",
&[ &[
( (
@@ -48,7 +48,7 @@ fn portal_palette_char_places_portal_at_grid_position() {
EMPTY, EMPTY,
PLAYER, PLAYER,
], ],
)], ),
)); ));
assert_eq!(board.portals.len(), 1); assert_eq!(board.portals.len(), 1);
assert_eq!((board.portals[0].x, board.portals[0].y), (0, 0)); assert_eq!((board.portals[0].x, board.portals[0].y), (0, 0));
+11 -11
View File
@@ -1,7 +1,7 @@
//! Pushers are now scripted objects (the `pusher_*` archetypes expand into objects //! Pushers are now scripted objects (the `pusher_*` archetypes expand into objects
//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_<dir>` tag). //! 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::archetype::Archetype;
use crate::game::GameState; use crate::game::GameState;
use crate::map_file::MapFile; use crate::map_file::MapFile;
@@ -24,10 +24,10 @@ fn pusher_loads_as_a_tagged_scripted_solid_object() {
let board = load_board(&map( let board = load_board(&map(
3, 3,
1, 1,
&[layer( &grid(
"P @", "P @",
&[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")], &[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")],
)], ),
)); ));
let mut id = 0; let mut id = 0;
let p = pusher(&board, &mut id); let p = pusher(&board, &mut id);
@@ -48,14 +48,14 @@ fn pusher_advances_and_shoves_a_crate() {
let board = load_board(&map( let board = load_board(&map(
5, 5,
1, 1,
&[layer( &grid(
"Po @", "Po @",
&[ &[
("P", "kind = \"pusher_east\""), ("P", "kind = \"pusher_east\""),
("o", "kind = \"crate\""), ("o", "kind = \"crate\""),
("@", "kind = \"player\""), ("@", "kind = \"player\""),
], ],
)], ),
)); ));
let mut pid = 0; let mut pid = 0;
pusher(&board, &mut pid); pusher(&board, &mut pid);
@@ -69,12 +69,12 @@ fn pusher_advances_and_shoves_a_crate() {
let b = game.board(); let b = game.board();
assert!(b.objects[&pid].x > 0, "pusher advanced east"); assert!(b.objects[&pid].x > 0, "pusher advanced east");
assert_eq!( assert_eq!(
b.get(0, 1, 0).1, b.get(1, 0).1,
Archetype::Empty, Archetype::Empty,
"crate left its start cell" "crate left its start cell"
); );
assert!( 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" "crate was shoved east"
); );
} }
@@ -84,14 +84,14 @@ fn pusher_blocked_by_wall_stays_put() {
let board = load_board(&map( let board = load_board(&map(
4, 4,
1, 1,
&[layer( &grid(
"P# @", "P# @",
&[ &[
("P", "kind = \"pusher_east\""), ("P", "kind = \"pusher_east\""),
("#", "kind = \"wall\""), ("#", "kind = \"wall\""),
("@", "kind = \"player\""), ("@", "kind = \"player\""),
], ],
)], ),
)); ));
let mut pid = 0; let mut pid = 0;
pusher(&board, &mut pid); pusher(&board, &mut pid);
@@ -113,10 +113,10 @@ fn pusher_round_trips_to_its_keyword() {
let board = load_board(&map( let board = load_board(&map(
3, 3,
1, 1,
&[layer( &grid(
"P @", "P @",
&[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")], &[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")],
)], ),
)); ));
// Save collapses the expanded object back into the `pusher_east` keyword. // Save collapses the expanded object back into the `pusher_east` keyword.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
+37 -27
View File
@@ -1,4 +1,4 @@
use super::{layer, load_board, map}; use super::{grid, load_board, map};
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::map_file::{MapFile, parse_color}; use crate::map_file::{MapFile, parse_color};
use color::Rgba8; use color::Rgba8;
@@ -25,7 +25,7 @@ fn round_trip(toml: &str) -> crate::board::Board {
#[test] #[test]
fn object_glyph_round_trips_through_toml() { 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); let board = load_board(&toml);
assert_eq!(board.objects.len(), 1); assert_eq!(board.objects.len(), 1);
let obj0 = &board.objects[&1]; let obj0 = &board.objects[&1];
@@ -53,10 +53,10 @@ fn object_tags_round_trip_through_toml() {
let toml = map( let toml = map(
3, 3,
1, 1,
&[layer( &grid(
"@O.", "@O.",
&[PLAYER, ("O", &obj("tags = [\"enemy\", \"boss\"]")), EMPTY], &[PLAYER, ("O", &obj("tags = [\"enemy\", \"boss\"]")), EMPTY],
)], ),
); );
let board = load_board(&toml); let board = load_board(&toml);
let obj0 = &board.objects[&1]; let obj0 = &board.objects[&1];
@@ -75,7 +75,7 @@ fn object_tags_round_trip_through_toml() {
#[test] #[test]
fn object_empty_tags_omitted_from_toml() { 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 board = load_board(&toml);
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
assert!( assert!(
@@ -89,10 +89,10 @@ fn object_name_round_trips_through_toml() {
let toml = map( let toml = map(
3, 3,
1, 1,
&[layer( &grid(
"@O.", "@O.",
&[PLAYER, ("O", &obj("name = \"beacon\"")), EMPTY], &[PLAYER, ("O", &obj("name = \"beacon\"")), EMPTY],
)], ),
); );
let board = load_board(&toml); let board = load_board(&toml);
assert_eq!(board.objects[&1].name.as_deref(), Some("beacon")); assert_eq!(board.objects[&1].name.as_deref(), Some("beacon"));
@@ -108,7 +108,7 @@ fn object_name_round_trips_through_toml() {
#[test] #[test]
fn unnamed_object_name_stays_none_through_toml() { 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); let board2 = round_trip(&toml);
assert_eq!( assert_eq!(
board2.objects[&1].name, None, board2.objects[&1].name, None,
@@ -117,31 +117,41 @@ fn unnamed_object_name_stays_none_through_toml() {
} }
#[test] #[test]
fn fixed_floor_glyph_round_trips_through_toml() { fn fixed_floor_attribute_round_trips_through_toml() {
// A fixed floor glyph (visual-only Empty cell) must survive save→load. // A fixed floor glyph is now a board attribute (`floor = { … }`), not a grid
let toml = map( // cell. It must survive save→load and show through empty grid cells.
3, let toml = "[map]\nname = \"Test\"\nwidth = 3\nheight = 1\n\
1, floor = { tile = \"#\", fg = \"#010203\", bg = \"#040506\" }\n\
&[layer( [grid]\ncontent = \"\"\"\n@..\n\"\"\"\n\
"@F.", palette = { \"@\" = { kind = \"player\" }, \".\" = { kind = \"empty\" } }\n";
&[
PLAYER,
(
"F",
"kind = \"floor\", tile = \"#\", fg = \"#010203\", bg = \"#040506\"",
),
EMPTY,
],
)],
);
let fixed = Glyph { let fixed = Glyph {
tile: '#' as u32, tile: '#' as u32,
fg: parse_color("#010203"), fg: parse_color("#010203"),
bg: parse_color("#040506"), 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); 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); 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));
}
+5 -5
View File
@@ -2,7 +2,7 @@
//! into objects carrying the embedded `spinner.rhai` plus a `BUILTIN_spinner_<dir>` //! into objects carrying the embedded `spinner.rhai` plus a `BUILTIN_spinner_<dir>`
//! tag, and collapse back to the keyword on save (just like pushers). //! 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; use crate::map_file::MapFile;
#[test] #[test]
@@ -10,10 +10,10 @@ fn spinner_loads_as_a_tagged_scripted_solid_object() {
let board = load_board(&map( let board = load_board(&map(
3, 3,
1, 1,
&[layer( &grid(
"S @", "S @",
&[("S", "kind = \"spinner_cw\""), ("@", "kind = \"player\"")], &[("S", "kind = \"spinner_cw\""), ("@", "kind = \"player\"")],
)], ),
)); ));
let (_, obj) = board let (_, obj) = board
.objects .objects
@@ -36,10 +36,10 @@ fn spinner_round_trips_to_its_keyword() {
let board = load_board(&map( let board = load_board(&map(
3, 3,
1, 1,
&[layer( &grid(
"S @", "S @",
&[("S", "kind = \"spinner_ccw\""), ("@", "kind = \"player\"")], &[("S", "kind = \"spinner_ccw\""), ("@", "kind = \"player\"")],
)], ),
)); ));
// Save collapses the expanded object back into the `spinner_ccw` keyword. // Save collapses the expanded object back into the `spinner_ccw` keyword.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
@@ -0,0 +1,154 @@
//! Transporters are scripted objects (the `transporter_*` archetypes expand into
//! objects carrying the embedded `transporter.rhai` plus a
//! `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::{grid, load_board, map};
use crate::game::GameState;
use crate::map_file::MapFile;
use crate::utils::Direction;
use std::time::Duration;
#[test]
fn transporter_loads_as_a_tagged_scripted_solid_object() {
let board = load_board(&map(
3,
1,
&grid(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
),
));
let (_, obj) = board
.objects
.iter()
.find(|(_, o)| o.tags.contains("BUILTIN_transporter_east"))
.expect("transporter object");
assert_eq!((obj.x, obj.y), (1, 0));
assert!(obj.builtin_script.is_some(), "carries the embedded script");
assert!(obj.solid, "transporter is solid");
assert!(!obj.opaque, "transporter is see-through");
assert!(!obj.pushable, "transporter cannot be pushed");
}
#[test]
fn bumping_a_transporter_drops_you_on_its_far_side() {
// Player at (0,0), an east transporter at (1,0), and a free cell at (2,0).
let board = load_board(&map(
3,
1,
&grid(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
),
));
let mut game = GameState::new(board);
game.run_init();
// Walk east into the transporter: it's solid so the player doesn't step onto
// it, but the bump fires a teleport that resolves within this try_move. The
// trailing tick just advances the transporter's idle animation.
game.try_move(Direction::East);
game.tick(Duration::from_secs_f64(0.1));
let b = game.board();
assert_eq!((b.player.x, b.player.y), (2, 0), "transported past the transporter");
}
#[test]
fn a_blocked_far_side_transports_out_of_the_paired_transporter() {
// Player, east transporter, a wall blocking its far side, a gap, the paired
// west transporter, then a free cell. The player should pop out on the paired
// transporter's far side (the cell just past it), across the wall.
let board = load_board(&map(
6,
1,
&grid(
"@T# W ",
&[
("@", "kind = \"player\""),
("T", "kind = \"transporter_east\""),
("#", "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\""),
("W", "kind = \"transporter_west\""),
],
),
));
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_secs_f64(0.1));
let b = game.board();
assert_eq!(
(b.player.x, b.player.y),
(5, 0),
"transported past the paired transporter",
);
}
#[test]
fn a_pushed_crate_is_transported_through() {
// Player, a crate, an east transporter, then a free cell. Pushing the crate
// east into the transporter bumps it (through the crate chain); the transporter
// moves the crate — a plain terrain solid, no object id — out its far side.
let board = load_board(&map(
4,
1,
&grid(
"@oT ",
&[
("@", "kind = \"player\""),
("o", "kind = \"crate\""),
("T", "kind = \"transporter_east\""),
],
),
));
let mut game = GameState::new(board);
game.run_init();
// Push east into the crate: the crate can't move (the transporter blocks it),
// but the transporter is bumped from the West and teleports the crate to (3,0).
game.try_move(Direction::East);
game.tick(Duration::from_secs_f64(0.1));
{
let b = game.board();
assert!(b.solid_at(3, 0).is_some(), "crate transported to the far side");
assert!(b.is_passable(1, 0), "crate's old cell is now empty");
assert_eq!((b.player.x, b.player.y), (0, 0), "player didn't move yet");
}
// With the crate gone, a second push walks the player onto the vacated cell.
game.try_move(Direction::East);
game.tick(Duration::from_secs_f64(0.1));
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0), "player follows into the gap");
}
#[test]
fn transporter_round_trips_to_its_keyword() {
let board = load_board(&map(
3,
1,
&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();
assert!(
toml_out.contains("transporter_east"),
"transporter should save as its archetype keyword, got:\n{toml_out}"
);
// Reload: it is a working transporter object again.
let board2 = load_board(&toml_out);
assert!(
board2
.objects
.values()
.any(|o| o.tags.contains("BUILTIN_transporter_east") && o.builtin_script.is_some()),
"reloads as a tagged transporter object",
);
}
+15 -6
View File
@@ -7,11 +7,11 @@ mod scripting;
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::board::Board; use crate::board::Board;
use crate::floor::Floor;
use crate::game::GameState; use crate::game::GameState;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::layer::Layer;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use crate::utils::Player; use crate::utils::PlayerPos;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
/// Builds a 1×1 board with a single object that optionally references a script, /// Builds a 1×1 board with a single object that optionally references a script,
@@ -29,14 +29,15 @@ fn board_with_object(
name: "test".into(), name: "test".into(),
width: 1, width: 1,
height: 1, height: 1,
layers: vec![Layer { grid: vec![(Glyph::transparent(), Archetype::Empty)],
cells: vec![(Glyph::transparent(), Archetype::Empty)], floor: Floor::Blank,
}], decorations: Vec::new(),
player: Player { x: 0, y: 0 }, player: PlayerPos { x: 0, y: 0 },
objects: BTreeMap::from([(1, object)]), objects: BTreeMap::from([(1, object)]),
next_object_id: 2, next_object_id: 2,
portals: Vec::new(), portals: Vec::new(),
board_script_name: None, board_script_name: None,
dark: false,
load_errors: Vec::new(), load_errors: Vec::new(),
registry: HashMap::new(), registry: HashMap::new(),
}; };
@@ -59,6 +60,14 @@ fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
o o
} }
/// Returns a **non-solid** `ObjectDef` at `(x, y)` bound to the named script — the
/// kind that receives an `enter` hook when a solid relocates onto its cell.
fn nonsolid_object(x: usize, y: usize, script: &str) -> ObjectDef {
let mut o = scripted_object(x, y, script);
o.solid = false;
o
}
/// Flattens each log line into a single string for easy assertions. /// Flattens each log line into a single string for easy assertions.
fn log_texts(game: &GameState) -> Vec<String> { fn log_texts(game: &GameState) -> Vec<String> {
game.log game.log
+18 -18
View File
@@ -32,8 +32,8 @@ fn pushing_a_crate_reveals_the_floor_underneath() {
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!(b.get(1, 2, 0).1, Archetype::Crate); assert_eq!(b.get(2, 0).1, Archetype::Crate);
assert_eq!(b.get(1, 1, 0).1, Archetype::Empty); assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.glyph_at(0, 0), floor_glyph); assert_eq!(b.glyph_at(0, 0), floor_glyph);
} }
@@ -45,8 +45,8 @@ fn player_pushes_single_crate() {
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0)); assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); assert_eq!(b.get(2, 0).1, Archetype::Crate);
} }
#[test] #[test]
@@ -58,8 +58,8 @@ fn push_blocked_by_wall_moves_nothing() {
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); 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);
assert_eq!(b.get(0, 2, 0).1, Archetype::Wall); assert_eq!(b.get(2, 0).1, Archetype::Wall);
} }
#[test] #[test]
@@ -71,7 +71,7 @@ fn push_blocked_by_edge_moves_nothing() {
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); 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] #[test]
@@ -83,9 +83,9 @@ fn cascade_pushes_two_crates() {
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0)); assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); assert_eq!(b.get(2, 0).1, Archetype::Crate);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate); assert_eq!(b.get(3, 0).1, Archetype::Crate);
} }
#[test] #[test]
@@ -98,8 +98,8 @@ fn cascade_blocked_by_wall_moves_nothing() {
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); 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);
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); assert_eq!(b.get(2, 0).1, Archetype::Crate);
} }
#[test] #[test]
@@ -124,8 +124,8 @@ fn hcrate_pushes_east_but_not_north() {
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0)); assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 2, 0).1, Archetype::HCrate); assert_eq!(b.get(2, 0).1, Archetype::HCrate);
drop(b); drop(b);
// Pushing north into an HCrate: blocked, nothing moves. // Pushing north into an HCrate: blocked, nothing moves.
@@ -135,7 +135,7 @@ fn hcrate_pushes_east_but_not_north() {
game.try_move(Direction::North); game.try_move(Direction::North);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 3)); 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] #[test]
@@ -147,8 +147,8 @@ fn vcrate_pushes_north_but_not_east() {
game.try_move(Direction::North); game.try_move(Direction::North);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 2)); 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, 2).1, Archetype::Empty);
assert_eq!(b.get(0, 0, 1).1, Archetype::VCrate); assert_eq!(b.get(0, 1).1, Archetype::VCrate);
drop(b); drop(b);
// Pushing east into a VCrate: blocked, nothing moves. // Pushing east into a VCrate: blocked, nothing moves.
@@ -158,5 +158,5 @@ fn vcrate_pushes_north_but_not_east() {
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); 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);
} }
+239 -29
View File
@@ -1,5 +1,5 @@
use super::{board_with_object, log_texts, scripted_object, scripts_from}; use super::{board_with_object, log_texts, nonsolid_object, scripted_object, scripts_from};
use crate::board::tests::open_board; use crate::board::tests::{crate_at, open_board};
use crate::game::{GameState, ScrollLine}; use crate::game::{GameState, ScrollLine};
use crate::utils::Direction; use crate::utils::Direction;
use std::time::Duration; use std::time::Duration;
@@ -8,7 +8,7 @@ use std::time::Duration;
fn init_runs_only_on_run_init_not_at_construction() { fn init_runs_only_on_run_init_not_at_construction() {
let (board, scripts) = board_with_object( let (board, scripts) = board_with_object(
Some("greet"), Some("greet"),
&[("greet", r#"fn init() { log("hello"); }"#)], &[("greet", r#"fn init(m) { log("hello"); }"#)],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
// init must not fire during construction / deserialization. // init must not fire during construction / deserialization.
@@ -22,7 +22,7 @@ fn init_runs_only_on_run_init_not_at_construction() {
fn tick_calls_script_tick_with_elapsed_seconds() { fn tick_calls_script_tick_with_elapsed_seconds() {
let (board, scripts) = board_with_object( let (board, scripts) = board_with_object(
Some("t"), Some("t"),
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)], &[("t", r#"fn tick(m,dt) { log(dt.to_string()); }"#)],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -69,7 +69,7 @@ fn script_reads_board_through_view() {
let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]); let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")]), scripts_from(&[("r", "fn init(m) { log(Player.x.to_string()); }")]),
); );
game.run_init(); game.run_init();
assert_eq!(log_texts(&game), vec!["3"]); assert_eq!(log_texts(&game), vec!["3"]);
@@ -88,8 +88,8 @@ fn commands_are_routed_to_their_own_source_object() {
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[ scripts_from(&[
("e", "fn init() { move(East); }"), ("e", "fn init(m) { move(East); }"),
("w", "fn init() { move(West); }"), ("w", "fn init(m) { move(West); }"),
]), ]),
); );
game.run_init(); game.run_init();
@@ -127,7 +127,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// present on the object afterward. // present on the object afterward.
let (board, scripts) = board_with_object( let (board, scripts) = board_with_object(
Some("t"), Some("t"),
&[("t", r#"fn init() { set_tag(Me.id, "active", true); }"#)], &[("t", r#"fn init(m) { set_tag(m.id, "active", true); }"#)],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -136,7 +136,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// A script removes a pre-existing tag. // A script removes a pre-existing tag.
let (mut board2, scripts2) = board_with_object( let (mut board2, scripts2) = board_with_object(
Some("t2"), Some("t2"),
&[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)], &[("t2", r#"fn init(m) { set_tag(m.id, "active", false); }"#)],
); );
// Seed the tag before construction. // Seed the tag before construction.
board2 board2
@@ -158,8 +158,8 @@ fn has_tag_reads_own_tags() {
Some("t"), Some("t"),
&[( &[(
"t", "t",
r#"fn init() { set_tag(Me.id, "active", true); } r#"fn init(m) { set_tag(m.id, "active", true); }
fn tick(dt) { log(Me.has_tag("active").to_string()); }"#, fn tick(m,dt) { log(m.has_tag("active").to_string()); }"#,
)], )],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
@@ -182,7 +182,7 @@ fn objects_with_tag_returns_matching_ids() {
scripts_from(&[ scripts_from(&[
( (
"q", "q",
r#"fn init() { r#"fn init(m) {
let infos = Board.tagged("enemy"); let infos = Board.tagged("enemy");
log(infos.len().to_string()); log(infos.len().to_string());
log(infos[0].id.to_string()); log(infos[0].id.to_string());
@@ -201,18 +201,18 @@ fn objects_with_tag_returns_matching_ids() {
fn my_name_returns_name_or_empty_string() { fn my_name_returns_name_or_empty_string() {
// An object with a name set on its ObjectDef should see it via my_name(). // An object with a name set on its ObjectDef should see it via my_name().
let (mut board, scripts) = let (mut board, scripts) =
board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]); board_with_object(Some("n"), &[("n", r#"fn init(m) { log(m.name); }"#)]);
board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string()); board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string());
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
assert_eq!(log_texts(&game), vec!["beacon"]); assert_eq!(log_texts(&game), vec!["beacon"]);
// An unnamed object should get an empty string. // An unnamed object should get ().
let (board2, scripts2) = let (board2, scripts2) =
board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]); board_with_object(Some("n"), &[("n", r#"fn init(m) { if m.name == () { log("null"); }}"#)]);
let mut game2 = GameState::with_scripts(board2, scripts2); let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init(); game2.run_init();
assert_eq!(log_texts(&game2), vec![""]); assert_eq!(log_texts(&game2), vec!["null"]);
} }
#[test] #[test]
@@ -228,7 +228,7 @@ fn object_id_for_name_finds_by_name() {
scripts_from(&[ scripts_from(&[
( (
"q", "q",
r#"fn init() { r#"fn init(m) {
log(Board.named("target").id.to_string()); log(Board.named("target").id.to_string());
let miss = Board.named("missing"); let miss = Board.named("missing");
log(if miss == () { "not_found" } else { miss.id.to_string() }); log(if miss == () { "not_found" } else { miss.id.to_string() });
@@ -245,20 +245,40 @@ fn object_id_for_name_finds_by_name() {
// Ensure try_move from the player side also triggers scripted bump // Ensure try_move from the player side also triggers scripted bump
#[test] #[test]
fn player_bump_fires_with_negative_one() { fn player_bump_reports_the_direction_it_came_from() {
// The player walks into a solid scripted object; the object is bumped with -1 // The player walks East into a solid scripted object; the object is bumped from
// and the player does not move onto it. // the West side (opposite the player's travel) and the player does not move onto it.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")]), scripts_from(&[("b", "fn bump(m,dir) { log(`bumped from ${dir}`); }")]),
); );
game.run_init(); game.run_init();
game.try_move(Direction::East); game.try_move(Direction::East);
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
// bump's log is emitted into the object's queue; a tick flushes it. // bump's log is emitted into the object's queue; a tick flushes it.
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "bumped by -1")); assert!(log_texts(&game).iter().any(|t| t == "bumped from West"));
}
// A bump handler can compare the direction and read its `dx`/`dy` offset to
// locate the bumper's cell.
#[test]
fn bump_direction_supports_comparison_and_offset() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"b",
// Player bumps from the West, so `dir == West` and the bumper sits at
// (me.x + dir.dx) = 1 + (-1) = 0.
"fn bump(m,dir) { if dir == West { log(`bumper at ${m.x + dir.dx}`); } }",
)]),
);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "bumper at 0"));
} }
#[test] #[test]
@@ -271,12 +291,12 @@ fn scroll_opens_on_player_bump() {
board, board,
scripts_from(&[( scripts_from(&[(
"s", "s",
r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#, r#"fn bump(m,dir) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
)]), )]),
); );
game.run_init(); game.run_init();
// The bump resolves within try_move now, so the scroll is open immediately.
game.try_move(Direction::East); game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
let scroll = game let scroll = game
.active_scroll .active_scroll
@@ -295,14 +315,14 @@ fn handle_scroll_without_choice_clears_it() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)]), scripts_from(&[("s", r#"fn bump(m,dir) { scroll(["Hello"]); }"#)]),
); );
game.run_init(); game.run_init();
// The bump resolves within try_move, so the scroll is open right away.
game.try_move(Direction::East); game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_some()); assert!(game.active_scroll.is_some());
// No choice set — next tick clears the scroll without dispatching. // No choice set — a tick clears the scroll without dispatching.
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_none()); assert!(game.active_scroll.is_none());
} }
@@ -317,14 +337,14 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
scripts_from(&[( scripts_from(&[(
"s", "s",
r#" r#"
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); } fn bump(m,dir) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); } fn eat() { log("eaten"); }
"#, "#,
)]), )]),
); );
game.run_init(); game.run_init();
// The bump resolves within try_move, so the scroll is open right away.
game.try_move(Direction::East); game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_some()); assert!(game.active_scroll.is_some());
// Set the choice, then tick — handle_scroll dispatches "eat" and resolve picks up the log. // Set the choice, then tick — handle_scroll dispatches "eat" and resolve picks up the log.
@@ -336,3 +356,193 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
"eat() should have logged" "eat() should have logged"
); );
} }
#[test]
fn a_later_object_sees_an_earlier_objects_move_this_tick() {
// The core of the epic: each object's queued actions apply immediately, before
// the next (higher-id) object runs its hook. Object A (id 1) at (0,0) moves East
// onto (1,0); object B (id 2) at (1,1) then checks the cell to its North (1,0).
// Because A already moved there this tick, B observes it as blocked. Under the
// old collect-all-then-apply model B would have seen (1,0) still empty.
let a = scripted_object(0, 0, "a");
let b = scripted_object(1, 1, "b");
let board = open_board(3, 2, (2, 1), vec![a, b]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("a", "fn tick(m,dt) { if m.queue.length == 0 { move(East); } }"),
("b", r#"fn tick(m,dt) { log(if m.blocked(North) { "blocked" } else { "clear" }); }"#),
]),
);
game.run_init();
game.tick(Duration::from_millis(16));
// A moved onto (1,0), and B saw it there the same tick.
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 0));
assert_eq!(log_texts(&game), vec!["blocked"]);
}
#[test]
fn tick_is_gated_on_an_empty_queue() {
// `tick` fires only when the object's output queue is empty — so an *unguarded*
// tick that just `move`s east paces itself one step per drained move instead of
// piling up. `move` enqueues a Move plus a 0.25s Delay; while that Delay is still
// draining the engine skips re-running `tick`. With 100 ms frames the object steps
// once (x=1) then waits ~0.25s (the pending Delay) before the next call fires.
// Under the old every-frame model an unguarded tick would enqueue a fresh move each
// frame, racing the object east far faster.
let obj = scripted_object(0, 0, "m");
let board = open_board(6, 1, (5, 0), vec![obj]);
let mut game = GameState::with_scripts(
board,
// Note: no `if m.queue.length == 0` guard — the engine provides it.
scripts_from(&[("m", "fn tick(m, dt) { move(East); }")]),
);
game.run_init();
// Over 0.3s only the first move has resolved; the pending Delay suppresses the
// re-tick, so the object is still at x=1 rather than having stacked more moves.
for _ in 0..3 {
game.tick(Duration::from_millis(100));
}
assert_eq!(game.board().objects[&1].x, 1);
// Once the Delay fully drains the queue empties, so the next frame calls `tick`
// again and the object takes its second step.
game.tick(Duration::from_millis(100));
assert_eq!(game.board().objects[&1].x, 2);
}
#[test]
fn a_send_cycle_terminates_via_the_called_guard() {
// Two objects send "go" to each other in a cycle. Without the per-invocation
// "already-called" guard this would recurse forever; with it, each (object, fn,
// args) fires at most once, so the cascade settles after one round-trip. That the
// call returns at all — and logs exactly one "B" then one "A" — proves it.
let a = scripted_object(0, 0, "a");
let b = scripted_object(1, 0, "b");
let board = open_board(3, 1, (2, 0), vec![a, b]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("a", r#"fn init(m) { send(2, "poke"); } fn poke(m) { log("A"); send(2, "poke"); }"#),
("b", r#"fn poke(m) { log("B"); send(1, "poke"); }"#),
]),
);
game.run_init();
// B.poke fires once (from A.init's send), then A.poke once (from B.poke's send);
// A.poke's re-send to B.poke is a repeat key and is skipped, so the cascade stops.
assert_eq!(log_texts(&game), vec!["B", "A"]);
}
// ── enter hook ──────────────────────────────────────────────────────────────
// `enter(me, dir)` fires on a non-solid object when a solid relocates onto its
// cell. `dir` is the side the entrant came from (opposite the travel direction
// for a cardinal move/push; best-effort for teleport/shift).
#[test]
fn player_walking_onto_a_nonsolid_fires_enter_from_the_travel_side() {
// The player walks East onto a non-solid trigger; it entered from the West.
let board = open_board(3, 1, (0, 0), vec![nonsolid_object(1, 0, "e")]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("e", "fn enter(m, dir) { log(`entered from ${dir}`); }")]),
);
game.run_init();
game.try_move(Direction::East);
// The player is not blocked by a non-solid — it moves onto the cell.
assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
}
#[test]
fn object_moving_onto_a_nonsolid_fires_enter() {
// A solid mover ticks East onto a non-solid trigger sharing the destination.
let mover = scripted_object(0, 0, "m");
let trigger = nonsolid_object(1, 0, "e");
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("m", "fn tick(m, dt) { if m.queue.length == 0 { move(East); } }"),
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
]),
);
game.run_init();
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
}
#[test]
fn pushing_a_crate_onto_a_nonsolid_fires_enter() {
// Player pushes a crate East onto a non-solid trigger's cell; the crate is the
// solid entrant, so `enter` fires (from West).
let mut board = open_board(4, 1, (0, 0), vec![nonsolid_object(2, 0, "e")]);
crate_at(&mut board, 1, 0);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("e", "fn enter(m, dir) { log(`entered from ${dir}`); }")]),
);
game.run_init();
game.try_move(Direction::East);
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
}
#[test]
fn teleporting_onto_a_nonsolid_fires_enter() {
// A solid object teleports itself from (0,0) onto the non-solid trigger at (1,0).
let mover = scripted_object(0, 0, "t");
let trigger = nonsolid_object(1, 0, "e");
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("t", "fn init(m) { teleport(m.id, 1, 0); }"),
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
]),
);
game.run_init();
// Best-effort direction: the jump was one cell east, so it entered from West.
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
}
#[test]
fn shifting_a_crate_onto_a_nonsolid_fires_enter() {
// An object shifts the ring [(1,0),(2,0)]: the crate at (1,0) rotates onto the
// non-solid trigger at (2,0), firing `enter` (best-effort West).
let shifter = nonsolid_object(0, 0, "s");
let trigger = nonsolid_object(2, 0, "e");
let mut board = open_board(4, 1, (3, 0), vec![shifter, trigger]);
crate_at(&mut board, 1, 0);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("s", "fn init(m) { shift([[1, 0], [2, 0]]); }"),
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
]),
);
game.run_init();
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
}
#[test]
fn a_nonsolid_mover_onto_a_nonsolid_does_not_fire_enter() {
// Only *solid* entrants trigger enter: a non-solid mover walking onto another
// non-solid must NOT fire it.
let mover = nonsolid_object(0, 0, "m");
let trigger = nonsolid_object(1, 0, "e");
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("m", "fn tick(m, dt) { if m.queue.length == 0 { move(East); } }"),
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
]),
);
game.run_init();
game.tick(Duration::from_millis(16));
assert!(!log_texts(&game).iter().any(|t| t.starts_with("entered")));
}
+132 -24
View File
@@ -1,8 +1,12 @@
use std::cell::RefCell;
use std::fmt::Display;
use std::rc::Rc;
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use color::Rgba8; use color::Rgba8;
use rhai::Dynamic; use rhai::Dynamic;
use crate::Board; use crate::Board;
use crate::log::LogLine;
/// Which directions a solid may be pushed in. /// Which directions a solid may be pushed in.
/// ///
@@ -81,8 +85,6 @@ enum SolidKind {
Object(ObjectId), Object(ObjectId),
/// A solid terrain cell, with everything needed to rewrite it elsewhere. /// A solid terrain cell, with everything needed to rewrite it elsewhere.
Terrain { Terrain {
/// Layer the terrain lives on.
z: usize,
/// The cell's glyph. /// The cell's glyph.
glyph: Glyph, glyph: Glyph,
/// The cell's archetype. /// The cell's archetype.
@@ -145,12 +147,12 @@ impl Solid {
} }
} }
/// Builds a solid terrain cell at `(x, y)` on layer `z`. /// Builds a solid terrain cell at `(x, y)`.
pub(crate) fn terrain_at(x: usize, y: usize, z: usize, glyph: Glyph, arch: Archetype) -> Solid { pub(crate) fn terrain_at(x: usize, y: usize, glyph: Glyph, arch: Archetype) -> Solid {
Solid { Solid {
x, x,
y, y,
kind: SolidKind::Terrain { z, glyph, arch }, kind: SolidKind::Terrain { glyph, arch },
behavior: arch.behavior(), behavior: arch.behavior(),
} }
} }
@@ -215,8 +217,8 @@ impl Solid {
pub fn place(self, board: &mut Board, x: usize, y: usize) { pub fn place(self, board: &mut Board, x: usize, y: usize) {
match self.kind { match self.kind {
SolidKind::Player => { SolidKind::Player => {
board.player.x = x as i32; board.player.x = x as i64;
board.player.y = y as i32; board.player.y = y as i64;
} }
SolidKind::Object(id) => { SolidKind::Object(id) => {
if let Some(obj) = board.objects.get_mut(&id) { if let Some(obj) = board.objects.get_mut(&id) {
@@ -224,8 +226,8 @@ impl Solid {
obj.y = y; obj.y = y;
} }
} }
SolidKind::Terrain { z, glyph, arch } => { SolidKind::Terrain { glyph, arch } => {
*board.get_mut(z, x, y) = (glyph, arch); *board.get_mut(x, y) = (glyph, arch);
} }
} }
} }
@@ -252,9 +254,6 @@ pub struct PortalDef {
pub x: usize, pub x: usize,
/// Row of this portal on the board (0-indexed). /// Row of this portal on the board (0-indexed).
pub y: usize, 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`. /// Key of the target board in `World::boards`.
pub target_map: String, pub target_map: String,
/// Name of the arrival portal on the target board. /// Name of the arrival portal on the target board.
@@ -291,11 +290,11 @@ impl PortalDef {
/// See the "player may become an object" notes in `CLAUDE.md` for the design /// See the "player may become an object" notes in `CLAUDE.md` for the design
/// tensions this implies. /// tensions this implies.
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub struct Player { pub struct PlayerPos {
/// Column position (0-indexed, increasing rightward). /// Column position (0-indexed, increasing rightward).
pub x: i32, pub x: i64,
/// Row position (0-indexed, increasing downward). /// Row position (0-indexed, increasing downward).
pub y: i32, pub y: i64,
} }
#[cfg(test)] #[cfg(test)]
@@ -325,14 +324,6 @@ mod tests {
} }
} }
/// An optional argument passed to a script function via [`crate::action::Action::Send`].
pub(crate) enum ScriptArg {
/// A string argument.
Str(String),
/// A numeric argument (stored as `f64` to cover both integer and float callers).
Num(f64),
}
/// A cardinal movement direction. /// A cardinal movement direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Direction { pub enum Direction {
@@ -342,6 +333,73 @@ pub enum Direction {
West, West,
} }
impl Direction {
pub fn char(self) -> char {
match self {
Direction::North => 'n',
Direction::South => 's',
Direction::East => 'e',
Direction::West => 'w',
}
}
pub fn all() -> [Direction; 4] {
[Direction::North, Direction::South, Direction::East, Direction::West]
}
pub fn dx(self) -> i64 {
match self {
Direction::North | Direction::South => 0,
Direction::East => 1,
Direction::West => -1,
}
}
pub fn dy(self) -> i64 {
match self {
Direction::East | Direction::West => 0,
Direction::South => 1,
Direction::North => -1,
}
}
/// The direction pointing the opposite way.
///
/// Used to turn a mover's travel direction into the "came-from" direction
/// reported to a bumped object's `bump` hook (a bumper heading East arrives
/// from the West side of the thing it hits).
pub fn opposite(self) -> Direction {
match self {
Direction::North => Direction::South,
Direction::South => Direction::North,
Direction::East => Direction::West,
Direction::West => Direction::East,
}
}
/// The cardinal direction of the dominant axis of a `(dx, dy)` displacement,
/// or `None` when the displacement is zero.
///
/// A one-cell cardinal move maps to its obvious direction; for an arbitrary
/// relocation (e.g. a `teleport` or `shift` that jumps a solid several cells
/// or diagonally) it picks whichever axis moved farther — a **best-effort**
/// direction used to fill in the `enter` hook's `dir` when there is no exact
/// cardinal travel direction. Ties (|dx| == |dy|, dx != 0) resolve to the
/// horizontal axis.
pub fn from_delta(dx: i64, dy: i64) -> Option<Direction> {
if dx == 0 && dy == 0 {
return None;
}
// Compare magnitudes so a longer horizontal jump reads as East/West and a
// longer vertical jump as North/South; the horizontal axis wins ties.
if dx.abs() >= dy.abs() {
Some(if dx > 0 { Direction::East } else { Direction::West })
} else {
Some(if dy > 0 { Direction::South } else { Direction::North })
}
}
}
/// A value that can be stored in a board's script registry across board transitions. /// A value that can be stored in a board's script registry across board transitions.
/// ///
/// Restricted to primitive types that convert cleanly to and from `rhai::Dynamic` /// Restricted to primitive types that convert cleanly to and from `rhai::Dynamic`
@@ -389,7 +447,7 @@ impl Into<Dynamic> for RegistryValue {
} }
} }
impl From<Direction> for (i32, i32) { impl From<Direction> for (i64, i64) {
/// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down). /// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down).
fn from(d: Direction) -> Self { fn from(d: Direction) -> Self {
match d { match d {
@@ -400,3 +458,53 @@ impl From<Direction> for (i32, i32) {
} }
} }
} }
#[derive(Copy,Clone,Debug, PartialEq)]
pub enum Hook {
Init, Tick, Bump, Grab, Enter
}
impl Hook {
pub fn to_str(self) -> &'static str {
match self {
Hook::Init => "init",
Hook::Bump => "bump",
Hook::Grab => "grab",
Hook::Tick => "tick",
Hook::Enter => "enter"
}
}
}
impl Display for Hook {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_str())
}
}
/// 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 LogSink(Rc<RefCell<Vec<LogLine>>>);
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));
}
pub fn take(&mut self) -> Vec<LogLine> {
std::mem::take(&mut self.0.borrow_mut())
}
}
+14 -2
View File
@@ -1,6 +1,7 @@
//! World type: a named collection of boards loaded from a single `.toml` file. //! World type: a named collection of boards loaded from a single `.toml` file.
use crate::board::Board; use crate::board::Board;
use crate::fov::SIGHT_RADIUS;
use crate::map_file::MapFile; use crate::map_file::MapFile;
use serde::Deserialize; use serde::Deserialize;
use std::cell::RefCell; use std::cell::RefCell;
@@ -22,6 +23,11 @@ pub struct World {
pub name: String, pub name: String,
/// Key of the starting board; matches a key in [`World::boards`]. /// Key of the starting board; matches a key in [`World::boards`].
pub start: String, pub start: String,
/// The player's torch radius in cells on `dark` boards — world-wide config
/// from the `[world]` header (`torch = N`), defaulting to [`SIGHT_RADIUS`]
/// when absent. The player's torch is a white light source centered on the
/// player; see [`Board::lighting`](crate::board::Board::lighting).
pub torch: u32,
/// Named Rhai scripts shared across all boards: script name → source text. /// Named Rhai scripts shared across all boards: script name → source text.
/// ///
/// Objects on any board reference a script by name via /// Objects on any board reference a script by name via
@@ -49,6 +55,7 @@ impl World {
World { World {
name: self.name.clone(), name: self.name.clone(),
start: self.start.clone(), start: self.start.clone(),
torch: self.torch,
scripts: self.scripts.clone(), scripts: self.scripts.clone(),
boards: self boards: self
.boards .boards
@@ -78,6 +85,9 @@ struct WorldHeader {
name: String, name: String,
/// Key of the board to start on; must match a key in `[boards]`. /// Key of the board to start on; must match a key in `[boards]`.
start: String, start: String,
/// Optional player torch radius on dark boards; defaults to [`SIGHT_RADIUS`].
#[serde(default)]
torch: Option<u32>,
} }
/// Load a world from a `.toml` world file at `path`. /// Load a world from a `.toml` world file at `path`.
@@ -110,6 +120,7 @@ pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> {
Ok(World { Ok(World {
name: wf.world.name, name: wf.world.name,
start: wf.world.start, start: wf.world.start,
torch: wf.world.torch.unwrap_or(SIGHT_RADIUS as u32),
scripts: wf.scripts, scripts: wf.scripts,
boards, boards,
}) })
@@ -134,6 +145,7 @@ mod tests {
let world = World { let world = World {
name: "w".into(), name: "w".into(),
start: "start".into(), start: "start".into(),
torch: 10,
scripts: HashMap::new(), scripts: HashMap::new(),
boards, boards,
}; };
@@ -149,11 +161,11 @@ mod tests {
// The copy changed; the original is still empty at that cell. // The copy changed; the original is still empty at that cell.
assert_eq!( assert_eq!(
copy.boards["start"].borrow().get(0, 1, 0).1, copy.boards["start"].borrow().get(1, 0).1,
Archetype::Wall Archetype::Wall
); );
assert_eq!( assert_eq!(
world.boards["start"].borrow().get(0, 1, 0).1, world.boards["start"].borrow().get(1, 0).1,
Archetype::Empty Archetype::Empty
); );
// And they are genuinely different allocations. // And they are genuinely different allocations.
+11 -5
View File
@@ -1,9 +1,11 @@
# kiln-tui # kiln-tui
Module reference for the `kiln-tui` crate — a terminal **player + world Module reference for the `kiln-tui` crate — a terminal **player + world
editor** built on **ratatui 0.30 / crossterm**, depending on both `kiln-core` editor** built on **ratatui 0.30 / crossterm**, depending on `kiln-core`
and `kiln-ui`. See the repository-root `CLAUDE.md` for project-wide guidance, and the external, game-agnostic **`kiln-ui`** widget crate (a git dependency:
code style, the world file format, and key design decisions. `Dialog`/`CodeEditor`/`TextField`, plus `render_overlay`/`dim_area`/`CursorOverlay`).
See the repository-root `CLAUDE.md` for project-wide guidance, code style, the
world file format, and key design decisions.
Renders the board as text: each `Glyph.tile` index is reinterpreted as a character (the default kiln font is CP437, where the tile index equals the code point) and drawn with the glyph's RGB colors. No bitmap font or UV mapping is involved. The app is one of two top-level [`Mode`]s — `Play` (live, ticking game) or `Edit` (a freshly reloaded, non-ticking world) — and the active mode plus a `Ui` value are threaded through the loop. Renders the board as text: each `Glyph.tile` index is reinterpreted as a character (the default kiln font is CP437, where the tile index equals the code point) and drawn with the glyph's RGB colors. No bitmap font or UV mapping is involved. The app is one of two top-level [`Mode`]s — `Play` (live, ticking game) or `Edit` (a freshly reloaded, non-ticking world) — and the active mode plus a `Ui` value are threaded through the loop.
@@ -22,11 +24,15 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact
**`kiln-tui/src/editor.rs`** — world editor mode: **`kiln-tui/src/editor.rs`** — world editor mode:
- `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`). - `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`).
- `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`/`Items`) — a sidebar menu level; (`Main``[i] Items``[t] ♦ Gem`, which `set_current(Archetype::Builtin(Builtin::Gem, "gem"))`); `title()` (breadcrumb segment) and `entries() -> Vec<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main``World`/`Floor`/`Terrain`/`Machines`; `Machines``Pushers`), **sets the drawing tool** (`Terrain`/`Pushers``set_current(arch)`, plus `Machines``[s]` CW / `[c]` CCW spinner, all using `Archetype::Builtin(Builtin::…, "alias")`), or **launches a playtest** (`Main``[p] Play board``playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor. - `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`/`Transporters`/`Items`) — a sidebar menu level; (`Main``[i] Items``[t] ♦ Gem` / `[h] ♥ Heart`, which call `set_current(Archetype::Builtin(Builtin::Gem, "gem"))` / `set_current(Archetype::Builtin(Builtin::Heart, "heart"))` respectively); `title()` (breadcrumb segment) and `entries() -> Vec<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main``World`/`Floor`/`Terrain`/`Machines`; `Machines``Pushers`/`Transporters`), **sets the drawing tool** (`Terrain`/`Pushers`/`Transporters``set_current(arch)`, plus `Machines``[s]` CW / `[c]` CCW spinner, all using `Archetype::Builtin(Builtin::…, "alias")`), or **launches a playtest** (`Main``[p] Play board``playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor.
- Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, **expands script-backed archetypes** in every board via `Board::expand_builtin_archetypes` so editor-stamped spinners/pushers run, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs). - Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, **expands script-backed archetypes** in every board via `Board::expand_builtin_archetypes` so editor-stamped spinners/pushers run, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs).
- Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`. - Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`.
- `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel. - `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel.
**`kiln-tui/src/glyph_dialog.rs`** — the modal **glyph picker** (`GlyphDialog<Ctx>`):
- A modal picker for a `kiln_core::glyph::Glyph` (CP437 tile index + fg/bg colors), same API shape as `kiln_ui::dialog::Dialog`: `GlyphDialog::new(title, initial: Glyph, on_done)`, `handle_event(&Event) -> DialogResult`, `finish(result, &mut Ctx)`. A 32×8 char grid plus two `ColorPicker`s (a strip of the 16 `kiln_core::colors::NAMED_COLORS` swatches + a custom `#RRGGBB` `TextField` that is the source of truth). Draws via a `CursorOverlay` impl (host renders it through `kiln_ui::render_overlay`), rendering the grid with `kiln_core::cp437::tile_to_char`.
- **This is the one game-coupled UI widget.** It depends on `kiln-core` (`Glyph`/`Rgba8`, `NAMED_COLORS`, `tile_to_char`), so it lives here rather than in the game-agnostic external `kiln-ui` crate — it reuses `kiln-ui`'s public `DialogResult`, `TextField`, `CursorOverlay`, and `dim_area`. Seeded/read by the editor's `open_glyph_picker()` (bound to `g`); unit-tested with a dummy `Ctx`.
**`kiln-tui/src/ui.rs`** — front-end presentation state (not on `GameState`): **`kiln-tui/src/ui.rs`** — front-end presentation state (not on `GameState`):
- `Ui { log: LogState, overlay: ScrollOverlayState, pending_transition, active_animation: Option<Box<dyn Animation>>, menu: Option<MenuState>, should_quit, world_path, pending_mode, suspended_editor: Option<EditorState>, exit_playtest: bool }` (the last two drive the editor playtest: the parked editor and its `Esc`-to-exit signal). While `active_animation` is `Some` all input is suppressed. `tick(dt, mode)` advances the active animation (and, on completion, clears scroll/menu state when returning to `Board`), then ticks the active mode — the game only in `Play` + `Board` input mode (so an open scroll/menu pauses it), the editor's cursor blink in `Edit`; a scroll that appears from a tick starts an open animation. - `Ui { log: LogState, overlay: ScrollOverlayState, pending_transition, active_animation: Option<Box<dyn Animation>>, menu: Option<MenuState>, should_quit, world_path, pending_mode, suspended_editor: Option<EditorState>, exit_playtest: bool }` (the last two drive the editor playtest: the parked editor and its `Esc`-to-exit signal). While `active_animation` is `Some` all input is suppressed. `tick(dt, mode)` advances the active animation (and, on completion, clears scroll/menu state when returning to `Board`), then ticks the active mode — the game only in `Play` + `Board` input mode (so an open scroll/menu pauses it), the editor's cursor blink in `Edit`; a scroll that appears from a tick starts an open animation.
- `open_menu(items)` / `begin_close_menu()` — start the menu open/close animations (snapshotting `MenuState`). `begin_close(choice, game)` — set the player's scroll choice and start the scroll close animation. `start_transition(old, new, area)` — build the board-wipe once the inner area is known. `scroll_lines(delta)` / `scroll_menu(delta)` — clamped overlay/menu scrolling. - `open_menu(items)` / `begin_close_menu()` — start the menu open/close animations (snapshotting `MenuState`). `begin_close(choice, game)` — set the player's scroll choice and start the scroll close animation. `start_transition(old, new, area)` — build the board-wipe once the inner area is known. `scroll_lines(delta)` / `scroll_menu(delta)` — clamped overlay/menu scrolling.
@@ -61,7 +67,7 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact
- `board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if off-screen. Used by bubble placement. - `board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if off-screen. Used by bubble placement.
**`kiln-tui/src/status.rs`** — play-mode status sidebar widget: **`kiln-tui/src/status.rs`** — play-mode status sidebar widget:
- `StatusSidebarWidget` — a `ratatui::widgets::Widget` built with `new(health, gems)` that draws the player's stats in a bordered `Block` titled `Status`: a `Health:` label above a row of `MAX_HEARTS` (5) `♥` glyphs (first `health` bright red, rest dark red) and a `Gems: ♦ N` line. The gem indicator is rendered from `Archetype::Builtin(Builtin::Gem, "gem").default_glyph()` (char via `cp437::tile_to_char`, color via `rgba8_to_color`) so it matches gems on the board — not a hardcoded glyph/color. Purely presentational — reads the values handed in, never mutates game state. Drawn by `draw_play` when `ui.show_status` is set; `Tab` toggles it. - `StatusSidebarWidget(pub Player)` — a `ratatui::widgets::Widget` wrapping a `kiln_core::player::Player` snapshot that draws the player's stats in a bordered `Block` titled `Status`: a `Health:` label above a row of `player.max_health` `♥` glyphs (first `player.health` bright red, rest dark red), a `Gems: ♦ N` line, and a `Keys:` row of 8 colored key glyphs (colored when held, near-black when absent, via `player.keys.colors()`). The gem indicator is rendered from `Archetype::Builtin(Builtin::Gem, "gem").default_glyph()` (char via `cp437::tile_to_char`, color via `rgba8_to_color`) so it matches gems on the board — not a hardcoded glyph/color. Purely presentational — reads the values handed in, never mutates game state. Drawn by `draw_play` when `ui.show_status` is set; `Tab` toggles it.
**`kiln-tui/src/log.rs`** — log panel widget: **`kiln-tui/src/log.rs`** — log panel widget:
- `LogState { open, height, scroll }` — panel visibility, height in rows, and scroll offset. `toggle()`, `scroll_by(delta, log_len)`, `resize(target, total)`. - `LogState { open, height, scroll }` — panel visibility, height in rows, and scroll offset. `toggle()`, `scroll_by(delta, log_len)`, `resize(target, total)`.
+6 -1
View File
@@ -5,6 +5,11 @@ edition = "2024"
[dependencies] [dependencies]
kiln-core = { path = "../kiln-core" } kiln-core = { path = "../kiln-core" }
kiln-ui = { path = "../kiln-ui" } # Reusable, game-agnostic ratatui widgets, now a standalone repo (tracks the default branch).
kiln-ui = { git = "https://notpi.com/randrews/kiln-ui.git" }
color = "0.3.3" color = "0.3.3"
ratatui = { workspace = true, features = ["unstable-rendered-line-info"] } ratatui = { workspace = true, features = ["unstable-rendered-line-info"] }
[dev-dependencies]
# Used only by rendering tests to build a Board from an inline map-file string.
toml = { version = "0.8", features = ["preserve_order"] }
+19 -7
View File
@@ -10,10 +10,11 @@
use crate::editor_menu; use crate::editor_menu;
use crate::editor_menu::{MenuEntry, MenuLevel}; use crate::editor_menu::{MenuEntry, MenuLevel};
use crate::glyph_dialog::GlyphDialog;
use crate::log::{LogWidget, log_preview_line}; use crate::log::{LogWidget, log_preview_line};
use crate::render::{BoardWidget, board_to_screen, screen_to_board}; use crate::render::{BoardWidget, board_to_screen, screen_to_board};
use crate::ui::Ui; use crate::ui::Ui;
use crate::utils::rgba8_to_color; use crate::utils::{glyph_to_span, rgba8_to_color};
use kiln_core::cp437::tile_to_char; use kiln_core::cp437::tile_to_char;
use kiln_core::game::GameState; use kiln_core::game::GameState;
use kiln_core::glyph::Glyph; use kiln_core::glyph::Glyph;
@@ -22,7 +23,6 @@ use kiln_core::world::World;
use kiln_core::{Archetype, Board}; use kiln_core::{Archetype, Board};
use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome}; use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome};
use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse}; use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse};
use kiln_ui::glyph_dialog::GlyphDialog;
use ratatui::Frame; use ratatui::Frame;
use ratatui::crossterm::event::Event; use ratatui::crossterm::event::Event;
use ratatui::layout::{Constraint, Layout, Rect, Spacing}; use ratatui::layout::{Constraint, Layout, Rect, Spacing};
@@ -100,6 +100,10 @@ impl EditorState {
/// Builds an editor session for `world`, starting on its designated start board. /// Builds an editor session for `world`, starting on its designated start board.
pub(crate) fn new(world: World) -> Self { pub(crate) fn new(world: World) -> Self {
let board_name = world.start.clone(); let board_name = world.start.clone();
let cursor = {
let b = world.boards[&board_name].borrow();
(b.width as i32 / 2, b.height as i32 / 2)
};
Self { Self {
world, world,
board_name, board_name,
@@ -107,7 +111,7 @@ impl EditorState {
dialog: None, dialog: None,
code_editor: None, code_editor: None,
glyph_dialog: None, glyph_dialog: None,
cursor: (0, 0), cursor,
current_archetype: Archetype::Wall, current_archetype: Archetype::Wall,
current_glyph: Archetype::Wall.default_glyph(), current_glyph: Archetype::Wall.default_glyph(),
draw_mode: false, draw_mode: false,
@@ -296,7 +300,7 @@ impl EditorState {
/// board (right-click handling). No-op when the click is off the board. When draw /// board (right-click handling). No-op when the click is off the board. When draw
/// mode is on and the cursor lands on a new cell, stamps the current thing there. /// mode is on and the cursor lands on a new cell, stamps the current thing there.
pub(crate) fn set_cursor_at_screen(&mut self, sx: u16, sy: u16) { pub(crate) fn set_cursor_at_screen(&mut self, sx: u16, sy: u16) {
let cell = screen_to_board(self.last_board_area, &self.board(), sx, sy); let cell = screen_to_board(self.last_board_area, &self.board(), sx, sy, self.cursor);
if let Some((bx, by)) = cell { if let Some((bx, by)) = cell {
let target = (bx as i32, by as i32); let target = (bx as i32, by as i32);
let moved = target != self.cursor; let moved = target != self.cursor;
@@ -413,12 +417,12 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui)
} }
let inner = block.inner(board_area); let inner = block.inner(board_area);
frame.render_widget(block, board_area); frame.render_widget(block, board_area);
frame.render_widget(BoardWidget::new(&board), inner); frame.render_widget(BoardWidget::new(&board).with_focus(ed.cursor.0, ed.cursor.1), inner);
// Overlay the cursor during its visible phase (a light-gray solid block). // Overlay the cursor during its visible phase (a light-gray solid block).
if ed.blink_on { if ed.blink_on {
let (cx, cy) = (ed.cursor.0 as usize, ed.cursor.1 as usize); let (cx, cy) = (ed.cursor.0 as usize, ed.cursor.1 as usize);
if let Some((sx, sy)) = board_to_screen(inner, &board, cx, cy) if let Some((sx, sy)) = board_to_screen(inner, &board, cx, cy, (ed.cursor.0, ed.cursor.1))
&& let Some(cell) = frame.buffer_mut().cell_mut((sx, sy)) && let Some(cell) = frame.buffer_mut().cell_mut((sx, sy))
{ {
cell.set_char(CURSOR_CHAR) cell.set_char(CURSOR_CHAR)
@@ -454,6 +458,14 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui)
Span::styled(label, label_style), Span::styled(label, label_style),
])); ]));
} }
MenuEntry::Glyph { key, glyph, label, .. } => {
lines.push(Line::from(vec![
Span::styled(format!("[{}] ", key), key_style),
glyph_to_span(glyph),
Span::raw(" "),
Span::styled(label, label_style),
]));
}
MenuEntry::Blank => lines.push(Line::from("")), MenuEntry::Blank => lines.push(Line::from("")),
// A horizontal rule spanning the sidebar's inner width. // A horizontal rule spanning the sidebar's inner width.
MenuEntry::Separator => lines.push(Line::from(Span::styled( MenuEntry::Separator => lines.push(Line::from(Span::styled(
@@ -510,7 +522,7 @@ fn draw_footer_lines<'a>(
// The archetype currently being drawn (no UI to change it yet). // The archetype currently being drawn (no UI to change it yet).
Line::from(Span::styled(ed.current_archetype.name(), label_style)), Line::from(Span::styled(ed.current_archetype.name(), label_style)),
Line::from(vec![ Line::from(vec![
Span::styled("[g] ", key_style), Span::styled("[G] ", key_style),
Span::styled("Glyph ", label_style), Span::styled("Glyph ", label_style),
preview, preview,
]), ]),
+65 -4
View File
@@ -2,6 +2,8 @@ use crate::editor::EditorState;
use crate::menu::{MenuItem, MenuKey}; use crate::menu::{MenuItem, MenuKey};
use crate::mode::PendingMode; use crate::mode::PendingMode;
use kiln_core::{Archetype, Builtin}; use kiln_core::{Archetype, Builtin};
use kiln_core::keys::KeyType;
use kiln_core::glyph::Glyph;
/// One level in the editor's sidebar menu tree. /// One level in the editor's sidebar menu tree.
/// ///
@@ -26,6 +28,8 @@ pub(crate) enum MenuLevel {
Machines, Machines,
/// Various directions of pushers /// Various directions of pushers
Pushers, Pushers,
/// Various directions of transporters
Transporters,
/// Collectible items the player picks up (e.g. gems) /// Collectible items the player picks up (e.g. gems)
Items, Items,
} }
@@ -40,6 +44,7 @@ impl MenuLevel {
MenuLevel::Terrain => "Terrain", MenuLevel::Terrain => "Terrain",
MenuLevel::Machines => "Machines", MenuLevel::Machines => "Machines",
MenuLevel::Pushers => "Pushers", MenuLevel::Pushers => "Pushers",
MenuLevel::Transporters => "Transporters",
MenuLevel::Items => "Items", MenuLevel::Items => "Items",
} }
} }
@@ -79,6 +84,9 @@ impl MenuLevel {
], ],
MenuLevel::Machines => vec![ MenuLevel::Machines => vec![
MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)), MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)),
MenuEntry::item('t', "Transporters...", |ed| {
ed.menu.push(MenuLevel::Transporters)
}),
MenuEntry::item('s', "/ CW spinner", |ed| { MenuEntry::item('s', "/ CW spinner", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Spinner, "spinner_cw")) ed.set_current(Archetype::Builtin(Builtin::Spinner, "spinner_cw"))
}), }),
@@ -100,10 +108,37 @@ impl MenuLevel {
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west")) ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west"))
}), }),
], ],
MenuLevel::Items => vec![MenuEntry::item('t', "♦ Gem", |ed| { MenuLevel::Transporters => vec![
// 't' for 'treasure' — 'g' conflicts with glyph picker MenuEntry::item('n', "▲ Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Gem, "gem")) ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_north"))
})], }),
MenuEntry::item('s', "▼ Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_south"))
}),
MenuEntry::item('e', "► Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_east"))
}),
MenuEntry::item('w', "◄ Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_west"))
}),
],
MenuLevel::Items => vec![
MenuEntry::glyph('t', Archetype::Builtin(Builtin::Gem, "gem").default_glyph(), "Gem", |ed| {
// 't' for 'treasure' — 'g' conflicts with glyph picker
ed.set_current(Archetype::Builtin(Builtin::Gem, "gem"))
}),
MenuEntry::glyph('h', Archetype::Builtin(Builtin::Heart, "heart").default_glyph(), "Heart",
|ed| ed.set_current(Archetype::Builtin(Builtin::Heart, "heart"))),
MenuEntry::Separator,
MenuEntry::glyph('b', KeyType::Blue.glyph(), "Blue key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_blue"))),
MenuEntry::glyph('g', KeyType::Green.glyph(),"Green key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_green"))),
MenuEntry::glyph('c', KeyType::Cyan.glyph(),"Cyan key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_cyan"))),
MenuEntry::glyph('r', KeyType::Red.glyph(),"Red key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_red"))),
MenuEntry::glyph('p', KeyType::Purple.glyph(),"Purple key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_purple"))),
MenuEntry::glyph('o', KeyType::Orange.glyph(), "Orange key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_orange"))),
MenuEntry::glyph('y', KeyType::Yellow.glyph(), "Yellow key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_yellow"))),
MenuEntry::glyph('w', KeyType::White.glyph(), "White key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_white"))),
],
} }
} }
@@ -113,6 +148,7 @@ impl MenuLevel {
pub(crate) fn perform_key(self, ch: char, editor: &mut EditorState) { pub(crate) fn perform_key(self, ch: char, editor: &mut EditorState) {
let action = self.entries().into_iter().find_map(|entry| match entry { let action = self.entries().into_iter().find_map(|entry| match entry {
MenuEntry::Item { key, action, .. } if key == ch => Some(action), MenuEntry::Item { key, action, .. } if key == ch => Some(action),
MenuEntry::Glyph { key, action, .. } if key == ch => Some(action),
_ => None, _ => None,
}); });
if let Some(action) = action { if let Some(action) = action {
@@ -134,6 +170,17 @@ pub(crate) enum MenuEntry {
/// Run when the user presses `key`; mutates the editor session. /// Run when the user presses `key`; mutates the editor session.
action: Box<dyn FnOnce(&mut EditorState)>, action: Box<dyn FnOnce(&mut EditorState)>,
}, },
/// An item labeled by a styled Glyph
Glyph {
/// The letter the user presses to activate this item.
key: char,
/// The glyph to show next to the label
glyph: Glyph,
/// Display label shown after the `[key]` in the sidebar.
label: String,
/// Run when the user presses `key`; mutates the editor session.
action: Box<dyn FnOnce(&mut EditorState)>,
},
/// A blank spacer line. Part of the menu API; no current level uses one yet. /// A blank spacer line. Part of the menu API; no current level uses one yet.
#[allow(dead_code)] #[allow(dead_code)]
Blank, Blank,
@@ -156,6 +203,20 @@ impl MenuEntry {
} }
} }
pub(crate) fn glyph<F: FnOnce(&mut EditorState) + 'static>(
key: char,
glyph: Glyph,
label: impl Into<String>,
action: F,
) -> Self {
MenuEntry::Glyph {
key,
glyph,
label: label.into(),
action: Box::new(action),
}
}
pub(crate) fn value<F: FnOnce(&EditorState) -> String + 'static>(action: F) -> Self { pub(crate) fn value<F: FnOnce(&EditorState) -> String + 'static>(action: F) -> Self {
MenuEntry::CurrentValue(Box::new(action)) MenuEntry::CurrentValue(Box::new(action))
} }
@@ -7,19 +7,24 @@
//! pressing Down/Tab on the strip). The hex value is the source of truth — picking a //! pressing Down/Tab on the strip). The hex value is the source of truth — picking a
//! named swatch fills it in. //! named swatch fills it in.
//! //!
//! This mirrors the [`dialog`](crate::dialog) API — build a [`GlyphDialog`], route //! This mirrors the [`dialog`](kiln_ui::dialog) API — build a [`GlyphDialog`], route
//! events to [`handle_event`](GlyphDialog::handle_event), draw it via its //! events to [`handle_event`](GlyphDialog::handle_event), draw it via its
//! [`CursorOverlay`](crate::CursorOverlay) impl (see [`crate::render_overlay`]), and on //! [`CursorOverlay`](kiln_ui::CursorOverlay) impl (see [`kiln_ui::render_overlay`]), and on
//! dismissal call [`finish`](GlyphDialog::finish), which fires a stored callback with //! dismissal call [`finish`](GlyphDialog::finish), which fires a stored callback with
//! the chosen [`Glyph`] (or `None` on cancel) plus `&mut Ctx`. //! the chosen [`Glyph`] (or `None` on cancel) plus `&mut Ctx`.
//! //!
//! It is the first kiln-ui widget that depends on `kiln-core` (for [`Glyph`]/`Rgba8`, //! This is the glyph-picker widget that couples the game engine to the reusable UI: it
//! the [`tile_to_char`] table, and the named colors); kiln-ui is otherwise game-agnostic. //! depends on `kiln-core` (for [`Glyph`]/`Rgba8`, the [`tile_to_char`] table, and the
//! named colors), so it lives here in kiln-tui rather than in the game-agnostic `kiln-ui`
//! crate — building its dialog scaffolding on `kiln_ui`'s public widgets.
use color::Rgba8; use color::Rgba8;
use kiln_core::colors::NAMED_COLORS; use kiln_core::colors::NAMED_COLORS;
use kiln_core::cp437::tile_to_char; use kiln_core::cp437::tile_to_char;
use kiln_core::glyph::Glyph; use kiln_core::glyph::Glyph;
use kiln_ui::dialog::DialogResult;
use kiln_ui::text_field::TextField;
use kiln_ui::{CursorOverlay, dim_area};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers}; use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::layout::{Constraint, Layout, Position, Rect};
@@ -27,11 +32,7 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Widget}; use ratatui::widgets::{Block, Clear, Paragraph, Widget};
use crate::dialog::DialogResult; /// Background color filling the dialog window (matches [`kiln_ui::dialog`]).
use crate::text_field::TextField;
use crate::{CursorOverlay, dim_area};
/// Background color filling the dialog window (matches [`crate::dialog`]).
const BG: Color = Color::Rgb(10, 15, 35); const BG: Color = Color::Rgb(10, 15, 35);
/// Border color for the dialog window. /// Border color for the dialog window.
const BORDER: Color = Color::Rgb(80, 130, 210); const BORDER: Color = Color::Rgb(80, 130, 210);
@@ -509,7 +510,7 @@ impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
/// Lets a `&GlyphDialog` be drawn with `frame.render_widget` where the caret isn't /// Lets a `&GlyphDialog` be drawn with `frame.render_widget` where the caret isn't
/// needed; the caret position from [`CursorOverlay::render`] is discarded. Use /// needed; the caret position from [`CursorOverlay::render`] is discarded. Use
/// [`render_overlay`](crate::render_overlay) when the terminal cursor must be placed. /// [`render_overlay`](kiln_ui::render_overlay) when the terminal cursor must be placed.
impl<Ctx> Widget for &GlyphDialog<Ctx> { impl<Ctx> Widget for &GlyphDialog<Ctx> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
CursorOverlay::render(self, area, buf); CursorOverlay::render(self, area, buf);
+2 -2
View File
@@ -131,7 +131,7 @@ pub(crate) fn handle_editor_input(event: Event, term_w: u16, ed: &mut EditorStat
KeyCode::Right => ed.move_cursor(1, 0), KeyCode::Right => ed.move_cursor(1, 0),
KeyCode::Char('l') => ui.log.toggle(), KeyCode::Char('l') => ui.log.toggle(),
// `g` opens the glyph picker to edit the current drawing glyph. // `g` opens the glyph picker to edit the current drawing glyph.
KeyCode::Char('g') => ed.open_glyph_picker(), KeyCode::Char('G') => ed.open_glyph_picker(),
// Space stamps the current thing; Tab toggles draw mode. // Space stamps the current thing; Tab toggles draw mode.
KeyCode::Char(' ') => ed.place_current(), KeyCode::Char(' ') => ed.place_current(),
KeyCode::Tab => ed.toggle_draw_mode(), KeyCode::Tab => ed.toggle_draw_mode(),
@@ -143,7 +143,7 @@ pub(crate) fn handle_editor_input(event: Event, term_w: u16, ed: &mut EditorStat
}, },
Event::Mouse(m) => match m.kind { Event::Mouse(m) => match m.kind {
// Right-click moves the cursor to the clicked cell (if on the board). // Right-click moves the cursor to the clicked cell (if on the board).
MouseEventKind::Down(MouseButton::Right) => ed.set_cursor_at_screen(m.column, m.row), MouseEventKind::Down(MouseButton::Left) => ed.set_cursor_at_screen(m.column, m.row),
// Dragging the divider resizes the sidebar. // Dragging the divider resizes the sidebar.
MouseEventKind::Drag(_) => ed.resize_sidebar(term_w.saturating_sub(m.column), term_w), MouseEventKind::Drag(_) => ed.resize_sidebar(term_w.saturating_sub(m.column), term_w),
_ => {} _ => {}
+30 -4
View File
@@ -10,6 +10,7 @@
mod animation; mod animation;
mod editor; mod editor;
mod editor_menu; mod editor_menu;
mod glyph_dialog;
mod input; mod input;
mod log; mod log;
mod menu; mod menu;
@@ -36,7 +37,7 @@ use crate::input::{
use crate::log::{LogWidget, log_preview_line}; use crate::log::{LogWidget, log_preview_line};
use crate::menu::MenuWidget; use crate::menu::MenuWidget;
use crate::mode::{Mode, PendingMode}; use crate::mode::{Mode, PendingMode};
use crate::scroll_overlay::ScrollOverlayWidget; use crate::scroll_overlay::{ScrollOpenAnimation, ScrollOverlayWidget};
use crate::speech::SpeechBubblesWidget; use crate::speech::SpeechBubblesWidget;
use crate::status::StatusSidebarWidget; use crate::status::StatusSidebarWidget;
use crate::ui::Ui; use crate::ui::Ui;
@@ -142,6 +143,11 @@ fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) ->
// only changed cells are actually written to the terminal. // only changed cells are actually written to the terminal.
terminal.draw(|frame| draw(frame, mode, ui))?; terminal.draw(|frame| draw(frame, mode, ui))?;
// Remember whether a scroll overlay was already open, so we can detect one
// that opens this frame — from a player bump during input *or* from a tick —
// and play its open animation exactly once (see after the tick below).
let scroll_active_before = matches!(mode, Mode::Play(g) if g.active_scroll.is_some());
// Wait for input only up to the next frame deadline so the loop wakes to // Wait for input only up to the next frame deadline so the loop wakes to
// tick even with no keypresses. // tick even with no keypresses.
let timeout = FRAME.saturating_sub(last_tick.elapsed()); let timeout = FRAME.saturating_sub(last_tick.elapsed());
@@ -194,6 +200,19 @@ fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) ->
ui.tick(dt, mode); ui.tick(dt, mode);
} }
// A scroll can be opened either by a script tick or by a player bump during
// input handling (bump reactions now resolve within try_move). Start its open
// animation once, whichever opened it — detected by the scroll appearing since
// the top of this frame while no animation is running.
if ui.active_animation.is_none()
&& !scroll_active_before
&& let Mode::Play(game) = &*mode
&& let Some(scroll) = &game.active_scroll
{
let lines = scroll.lines.clone();
ui.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines)));
}
// Apply any Play↔Edit switch a menu action requested this frame. // Apply any Play↔Edit switch a menu action requested this frame.
apply_pending_mode(mode, ui); apply_pending_mode(mode, ui);
@@ -342,7 +361,7 @@ fn draw_play(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
.spacing(Spacing::Overlap(1)) .spacing(Spacing::Overlap(1))
.areas(frame.area()); .areas(frame.area());
frame.render_widget( frame.render_widget(
StatusSidebarWidget::new(game.player_health - 2, game.player_gems), StatusSidebarWidget(game.player.clone()),
sidebar_area, sidebar_area,
); );
rest rest
@@ -405,7 +424,14 @@ fn draw_board_area(frame: &mut Frame, inner: Rect, game: &GameState, ui: &mut Ui
); );
} else { } else {
let board = &game.board(); let board = &game.board();
frame.render_widget(BoardWidget::new(board), inner); // On a dark board this is the player's lighting (LOS + colored light); on a
frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner); // lit board it's None (draw everything). Computed once and shared by both
// widgets so the board and its speech bubbles agree on what's visible.
let fov = board.lighting(game.torch());
frame.render_widget(BoardWidget::new(board).fov(fov.as_ref()), inner);
frame.render_widget(
SpeechBubblesWidget::new(board, &game.speech_bubbles).fov(fov.as_ref()),
inner,
);
} }
} }
+195 -45
View File
@@ -6,23 +6,53 @@
//! player is drawn on top of everything. //! player is drawn on top of everything.
use crate::utils::rgba8_to_color; use crate::utils::rgba8_to_color;
use color::Rgba8;
use kiln_core::Board; use kiln_core::Board;
use kiln_core::Lighting;
use kiln_core::cp437::tile_to_char; use kiln_core::cp437::tile_to_char;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
/// The background color drawn for cells outside the field of view on a dark
/// board — a near-black gray, so unseen terrain reads as "dark" rather than
/// pure void (which is what the normal empty floor already is).
const DARKNESS_BG: Rgba8 = Rgba8 { r: 32, g: 32, b: 32, a: 255 };
/// A ratatui widget that draws a [`Board`] (with objects and the player) into a /// A ratatui widget that draws a [`Board`] (with objects and the player) into a
/// rectangular area, scrolled so the player stays visible on larger boards. /// rectangular area, scrolled so a chosen focus cell stays visible on larger boards.
pub struct BoardWidget<'a> { pub struct BoardWidget<'a> {
/// The board to render. Borrowed for the duration of the draw. /// The board to render. Borrowed for the duration of the draw.
pub board: &'a Board, pub board: &'a Board,
/// The board cell (x, y) the viewport scrolls to keep visible. Defaults to
/// the player position so play mode requires no extra setup.
focus: (i32, i32),
/// The player's lighting on a [`dark`](Board::dark) board, if any. When
/// `Some`, cells that aren't visible are drawn as darkness and visible cells
/// are tinted by the light they receive. `None` (a lit board) draws normally.
fov: Option<&'a Lighting>,
} }
impl<'a> BoardWidget<'a> { impl<'a> BoardWidget<'a> {
/// Creates a widget that renders `board`. /// Creates a widget that renders `board`, scrolling to follow the player.
pub fn new(board: &'a Board) -> Self { pub fn new(board: &'a Board) -> Self {
Self { board } let focus = (board.player.x as i32, board.player.y as i32);
Self { board, focus, fov: None }
}
/// Overrides the scroll focus to `(x, y)` — use in the editor to follow the
/// cursor instead of the player.
pub fn with_focus(mut self, x: i32, y: i32) -> Self {
self.focus = (x, y);
self
}
/// Supplies the player's lighting (see [`Board::lighting`]). When `Some`,
/// cells outside view render as darkness and visible cells are tinted by
/// their received light; `None` renders everything at full color.
pub fn fov(mut self, fov: Option<&'a Lighting>) -> Self {
self.fov = fov;
self
} }
/// Lays out one axis of the board within a viewport `view` cells long. /// Lays out one axis of the board within a viewport `view` cells long.
@@ -51,17 +81,25 @@ impl<'a> BoardWidget<'a> {
} }
/// Maps a board cell `(bx, by)` to its screen position within `area`, using the /// Maps a board cell `(bx, by)` to its screen position within `area`, using the
/// same centering/scrolling layout as [`BoardWidget`]. Returns `None` when the /// same centering/scrolling layout as [`BoardWidget`] with the given `focus`.
/// cell is scrolled out of view. /// Returns `None` when the cell is scrolled out of view.
///
/// Pass `(board.player.x, board.player.y)` as `focus` in play mode, or the
/// editor cursor coordinates in editor mode, to match the active viewport.
/// ///
/// The inverse of [`screen_to_board`]; both reuse [`BoardWidget::axis`] so they /// The inverse of [`screen_to_board`]; both reuse [`BoardWidget::axis`] so they
/// stay consistent with how the board is actually drawn. (Unlike /// stay consistent with how the board is actually drawn. (Unlike
/// `speech::board_screen_pos`, which clamps off-screen cells to the edge, this /// `speech::board_screen_pos`, which clamps off-screen cells to the edge, this
/// reports off-screen cells as `None` — needed for exact cursor placement.) /// reports off-screen cells as `None` — needed for exact cursor placement.)
pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> { pub fn board_to_screen(
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); area: Rect,
let (off_y, pad_y, rows) = board: &Board,
BoardWidget::axis(board.height, area.height as usize, board.player.y); bx: usize,
by: usize,
focus: (i32, i32),
) -> Option<(u16, u16)> {
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, focus.0);
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, focus.1);
// Cell must fall within the visible window on both axes. // Cell must fall within the visible window on both axes.
if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows { if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows {
return None; return None;
@@ -72,12 +110,19 @@ pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Optio
} }
/// Maps a screen position `(sx, sy)` to the board cell drawn there within `area`, /// Maps a screen position `(sx, sy)` to the board cell drawn there within `area`,
/// the inverse of [`board_to_screen`]. Returns `None` when the point lies outside /// the inverse of [`board_to_screen`]. `focus` must match the focus used when
/// the drawn board (in the centering pad or past the visible cells). /// the board was rendered so the coordinate mapping is consistent.
pub fn screen_to_board(area: Rect, board: &Board, sx: u16, sy: u16) -> Option<(usize, usize)> { /// Returns `None` when the point lies outside the drawn board (in the centering
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); /// pad or past the visible cells).
let (off_y, pad_y, rows) = pub fn screen_to_board(
BoardWidget::axis(board.height, area.height as usize, board.player.y); area: Rect,
board: &Board,
sx: u16,
sy: u16,
focus: (i32, i32),
) -> Option<(usize, usize)> {
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, focus.0);
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, focus.1);
// Screen-space offset from the first drawn cell; reject anything before it. // Screen-space offset from the first drawn cell; reject anything before it.
let col = (sx as i32) - (area.x as i32 + pad_x as i32); let col = (sx as i32) - (area.x as i32 + pad_x as i32);
let row = (sy as i32) - (area.y as i32 + pad_y as i32); let row = (sy as i32) - (area.y as i32 + pad_y as i32);
@@ -87,9 +132,143 @@ pub fn screen_to_board(area: Rect, board: &Board, sx: u16, sy: u16) -> Option<(u
Some((off_x + col as usize, off_y + row as usize)) Some((off_x + col as usize, off_y + row as usize))
} }
impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let board = self.board;
// Resolve each axis independently: a small board is centered, a large
// one scrolls to keep the focus cell on screen.
let (off_x, pad_x, cols) = Self::axis(board.width, area.width as usize, self.focus.0);
let (off_y, pad_y, rows) = Self::axis(board.height, area.height as usize, self.focus.1);
// Walk the visible board cells, placing each after the centering pad.
for row in 0..rows {
let by = off_y + row;
let sy = area.y + pad_y + row as u16;
for col in 0..cols {
let bx = off_x + col;
let sx = area.x + pad_x + col as u16;
// On a dark board, a cell the player can't see (no sightline or
// unlit) is drawn as darkness (blank on dark gray). A visible cell
// is drawn with its glyph, tinted by the light it receives. A lit
// board (`fov == None`) shows every cell at full color.
let visible = self.fov.is_none_or(|l| l.is_visible(bx, by));
if let Some(cell) = buf.cell_mut((sx, sy)) {
if visible {
let glyph = board.glyph_at(bx, by);
// Modulate by lighting when the board is dark; pass through otherwise.
let (fg, bg) = match self.fov {
Some(l) => (l.tint(bx, by, glyph.fg), l.tint(bx, by, glyph.bg)),
None => (glyph.fg, glyph.bg),
};
cell.set_char(tile_to_char(glyph.tile))
.set_fg(rgba8_to_color(fg))
.set_bg(rgba8_to_color(bg));
} else {
cell.set_char(' ')
.set_fg(rgba8_to_color(DARKNESS_BG))
.set_bg(rgba8_to_color(DARKNESS_BG));
}
}
}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::BoardWidget; use super::{BoardWidget, DARKNESS_BG};
use crate::utils::rgba8_to_color;
use kiln_core::Board;
use kiln_core::map_file::MapFile;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
/// Builds a tiny dark board: a 5×1 corridor with the player at the left end
/// and a wall at x=2 occluding the two cells behind it.
fn dark_corridor() -> Board {
let toml = r##"
[map]
name = "corridor"
width = 5
height = 1
dark = true
[grid]
content = "@ # "
[grid.palette]
"@" = { kind = "player" }
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" }
"##;
let mf: MapFile = toml::from_str(toml).unwrap();
Board::try_from(mf).unwrap()
}
#[test]
fn dark_board_renders_occluded_cells_as_darkness() {
let board = dark_corridor();
let fov = board.lighting(10);
assert!(fov.is_some(), "a dark board must produce lighting");
let area = Rect::new(0, 0, 5, 1);
let mut buf = Buffer::empty(area);
BoardWidget::new(&board).fov(fov.as_ref()).render(area, &mut buf);
// The wall at x=2 is visible (light_walls); the cells behind it are drawn
// as darkness: a blank cell on the dark-gray background.
let dark = rgba8_to_color(DARKNESS_BG);
assert_eq!(buf[(2, 0)].symbol(), "#", "the wall itself is lit");
for x in [3u16, 4] {
let cell = &buf[(x, 0)];
assert_eq!(cell.symbol(), " ", "occluded cell is blank");
assert_eq!(cell.bg, dark, "occluded cell uses the darkness background");
}
}
#[test]
fn lit_board_renders_every_cell_normally() {
// The same board without `dark` gets no lighting, so nothing is hidden.
let mut board = dark_corridor();
board.dark = false;
assert!(board.lighting(10).is_none());
let area = Rect::new(0, 0, 5, 1);
let mut buf = Buffer::empty(area);
BoardWidget::new(&board).fov(None).render(area, &mut buf);
// The far cell behind the wall is drawn from the board, not as darkness.
assert_ne!(buf[(4, 0)].bg, rgba8_to_color(DARKNESS_BG));
}
#[test]
fn colored_object_light_tints_nearby_and_darkens_far() {
// A dark 6×1 corridor, no player torch: a single red light object at x=2
// (radius 2) tints its own cell red; a cell beyond its reach is darkness.
let toml = r##"
[map]
name = "lit"
width = 6
height = 1
dark = true
[grid]
sparse = [ { x = 2, y = 0, ch = "L" } ]
[grid.palette]
"L" = { kind = "object", tile = 1, fg = "#ff0000", bg = "#000000", solid = false, light = 2 }
"##;
let board = Board::try_from(toml::from_str::<MapFile>(toml).unwrap()).unwrap();
let fov = board.lighting(0); // no player torch — only the object lights
let area = Rect::new(0, 0, 6, 1);
let mut buf = Buffer::empty(area);
BoardWidget::new(&board).fov(fov.as_ref()).render(area, &mut buf);
// The light's own cell is visible and tinted red (green/blue killed).
let lit = buf[(2, 0)].fg;
assert!(matches!(lit, Color::Rgb(r, 0, 0) if r > 0), "light cell tinted red, got {lit:?}");
// x=5 is out of the light's radius → darkness.
assert_eq!(buf[(5, 0)].symbol(), " ");
assert_eq!(buf[(5, 0)].bg, rgba8_to_color(DARKNESS_BG));
}
#[test] #[test]
fn axis_centers_a_small_board() { fn axis_centers_a_small_board() {
@@ -113,32 +292,3 @@ mod tests {
assert_eq!(BoardWidget::axis(100, 20, 99), (80, 0, 20)); assert_eq!(BoardWidget::axis(100, 20, 99), (80, 0, 20));
} }
} }
impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let board = self.board;
// Resolve each axis independently: a small board is centered, a large
// one scrolls to keep the player on screen.
let (off_x, pad_x, cols) = Self::axis(board.width, area.width as usize, board.player.x);
let (off_y, pad_y, rows) = Self::axis(board.height, area.height as usize, board.player.y);
// Walk the visible board cells, placing each after the centering pad.
for row in 0..rows {
let by = off_y + row;
let sy = area.y + pad_y + row as u16;
for col in 0..cols {
let bx = off_x + col;
let sx = area.x + pad_x + col as u16;
let glyph = board.glyph_at(bx, by);
// Paint the resolved glyph into the terminal cell.
if let Some(cell) = buf.cell_mut((sx, sy)) {
cell.set_char(tile_to_char(glyph.tile))
.set_fg(rgba8_to_color(glyph.fg))
.set_bg(rgba8_to_color(glyph.bg));
}
}
}
}
}
+19 -5
View File
@@ -1,7 +1,7 @@
use crate::render::BoardWidget; use crate::render::BoardWidget;
use crate::utils::rects_overlap; use crate::utils::rects_overlap;
use kiln_core::game::SpeechBubble; use kiln_core::game::SpeechBubble;
use kiln_core::{Board, Direction}; use kiln_core::{Board, Direction, Lighting};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::prelude::{Color, Line, Style, Widget}; use ratatui::prelude::{Color, Line, Style, Widget};
@@ -19,6 +19,10 @@ pub struct SpeechBubblesWidget<'a> {
pub board: &'a Board, pub board: &'a Board,
/// The active speech bubbles to render. /// The active speech bubbles to render.
pub bubbles: &'a [SpeechBubble], pub bubbles: &'a [SpeechBubble],
/// The player's lighting on a dark board, if any. A speaker the player can't
/// see still gets a bubble, but with no tail (like an off-screen speaker) —
/// you hear the unseen thing without a line pointing to where it hides.
fov: Option<&'a Lighting>,
} }
/// A fully-resolved bubble placement ready for the three draw passes. /// A fully-resolved bubble placement ready for the three draw passes.
@@ -40,7 +44,14 @@ struct Placement<'a> {
impl<'a> SpeechBubblesWidget<'a> { impl<'a> SpeechBubblesWidget<'a> {
/// Creates a widget for the given board and bubble slice. /// Creates a widget for the given board and bubble slice.
pub fn new(board: &'a Board, bubbles: &'a [SpeechBubble]) -> Self { pub fn new(board: &'a Board, bubbles: &'a [SpeechBubble]) -> Self {
Self { board, bubbles } Self { board, bubbles, fov: None }
}
/// Supplies the player's lighting (see [`Board::lighting`]). A speaker the
/// player can't see has its tail suppressed; `None` (a lit board) leaves tails as-is.
pub fn fov(mut self, fov: Option<&'a Lighting>) -> Self {
self.fov = fov;
self
} }
/// Places a bubble for a speaker at `(obj_sx, obj_sy)`. /// Places a bubble for a speaker at `(obj_sx, obj_sy)`.
@@ -117,7 +128,10 @@ impl Widget for SpeechBubblesWidget<'_> {
.filter_map(|b| { .filter_map(|b| {
let obj = self.board.objects.get(&b.object_id)?; let obj = self.board.objects.get(&b.object_id)?;
let (sx, sy, on_screen) = board_screen_pos(area, self.board, obj.x, obj.y); let (sx, sy, on_screen) = board_screen_pos(area, self.board, obj.x, obj.y);
Some((sx, sy, on_screen, b)) // On a dark board, a speaker the player can't see is treated like an
// off-screen speaker: the box still draws, but its tail is suppressed.
let visible = self.fov.is_none_or(|v| v.is_visible(obj.x, obj.y));
Some((sx, sy, on_screen && visible, b))
}) })
.collect(); .collect();
@@ -409,8 +423,8 @@ fn center_top(obj_sy: u16, box_h: u16, area: Rect) -> u16 {
/// Returns `(sx, sy, on_screen)`. Off-screen coordinates are clamped to the viewport edge; /// Returns `(sx, sy, on_screen)`. Off-screen coordinates are clamped to the viewport edge;
/// `on_screen` is `false` when clamping occurred (tail should be suppressed for that bubble). /// `on_screen` is `false` when clamping occurred (tail should be suppressed for that bubble).
fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> (u16, u16, bool) { fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> (u16, u16, bool) {
let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x); let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x as i32);
let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y); let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y as i32);
let raw_sx = area.x as i32 + pad_x as i32 + (bx as i32 - off_x as i32); let raw_sx = area.x as i32 + pad_x as i32 + (bx as i32 - off_x as i32);
let raw_sy = area.y as i32 + pad_y as i32 + (by as i32 - off_y as i32); let raw_sy = area.y as i32 + pad_y as i32 + (by as i32 - off_y as i32);
let sx = raw_sx.clamp(area.left() as i32, area.right() as i32 - 1) as u16; let sx = raw_sx.clamp(area.left() as i32, area.right() as i32 - 1) as u16;
+28 -20
View File
@@ -5,6 +5,7 @@
//! presentational: it reads `health`/`gems` values handed in at construction and //! presentational: it reads `health`/`gems` values handed in at construction and
//! never mutates game state. //! never mutates game state.
use std::collections::VecDeque;
use crate::utils::rgba8_to_color; use crate::utils::rgba8_to_color;
use kiln_core::{Archetype, Builtin}; use kiln_core::{Archetype, Builtin};
use kiln_core::cp437::tile_to_char; use kiln_core::cp437::tile_to_char;
@@ -14,9 +15,8 @@ use ratatui::style::{Color, Style};
use ratatui::symbols::merge::MergeStrategy; use ratatui::symbols::merge::MergeStrategy;
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
use kiln_core::player::PlayerRef;
/// How many hearts the health row always shows (the player's max health).
const MAX_HEARTS: u32 = 5;
/// A filled heart: bright red. /// A filled heart: bright red.
const HEART_FULL: Color = Color::Rgb(255, 0, 0); const HEART_FULL: Color = Color::Rgb(255, 0, 0);
/// A depleted heart: dark red. /// A depleted heart: dark red.
@@ -24,28 +24,18 @@ const HEART_EMPTY: Color = Color::Rgb(90, 0, 0);
/// A ratatui widget that renders the play-mode status sidebar. /// A ratatui widget that renders the play-mode status sidebar.
/// ///
/// Shows a `Health:` label above a row of [`MAX_HEARTS`] heart glyphs (the first /// Shows a `Health:` label above a row of [`MAX_HEARTS`] heart glyphs, a
/// `health` filled bright red, the rest dark red) and a `Gems: ♦ N` line. /// `Gems: ♦ N` line, and a `Keys:` row of 8 `♀` glyphs colored when held
pub struct StatusSidebarWidget { /// and dark gray when absent.
/// The player's current health, clamped to [`MAX_HEARTS`] when drawn. pub struct StatusSidebarWidget(pub PlayerRef);
health: u32,
/// The number of gems collected, shown as a count.
gems: u32,
}
impl StatusSidebarWidget {
/// Builds a sidebar widget for the given player stats.
pub fn new(health: u32, gems: u32) -> Self {
Self { health, gems }
}
}
impl Widget for StatusSidebarWidget { impl Widget for StatusSidebarWidget {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let player = self.0.borrow();
// One bright-red span per filled heart, dark-red for the rest, so a // One bright-red span per filled heart, dark-red for the rest, so a
// partly-depleted bar reads at a glance. // partly-depleted bar reads at a glance.
let filled = self.health.min(MAX_HEARTS); let filled = player.health;
let hearts: Vec<Span> = (0..MAX_HEARTS) let hearts: Vec<Span> = (0..player.max_health)
.map(|i| { .map(|i| {
let color = if i < filled { HEART_FULL } else { HEART_EMPTY }; let color = if i < filled { HEART_FULL } else { HEART_EMPTY };
Span::styled("", Style::default().fg(color)) Span::styled("", Style::default().fg(color))
@@ -58,6 +48,22 @@ impl Widget for StatusSidebarWidget {
let gem_char = tile_to_char(gem_glyph.tile).to_string(); let gem_char = tile_to_char(gem_glyph.tile).to_string();
let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg)); let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg));
// Build the key row: 8 ♀ glyphs, colored when held, near-black when absent.
let mut key_spans: VecDeque<_> = player
.keys
.colors()
.iter()
.filter_map(|(held, color)| {
if *held {
let fg = rgba8_to_color(*color);
Some(Span::styled("", Style::default().fg(fg)))
} else {
None
}
})
.collect();
key_spans.push_front(Span::raw("Keys: "));
let lines = vec![ let lines = vec![
Line::from("Health:"), Line::from("Health:"),
Line::from(hearts), Line::from(hearts),
@@ -65,8 +71,10 @@ impl Widget for StatusSidebarWidget {
Line::from(vec![ Line::from(vec![
Span::raw("Gems: "), Span::raw("Gems: "),
Span::styled(gem_char, gem_style), Span::styled(gem_char, gem_style),
Span::raw(format!(" {}", self.gems)), Span::raw(format!("{} ", player.gems)),
]), ]),
Line::from(""),
Line::from_iter(key_spans.into_iter()),
]; ];
// A bordered block; `merge_borders` joins its right edge with the board // A bordered block; `merge_borders` joins its right edge with the board
+4 -7
View File
@@ -3,7 +3,7 @@ use crate::editor::EditorState;
use crate::log::LogState; use crate::log::LogState;
use crate::menu::{MenuCloseAnimation, MenuItem, MenuOpenAnimation, MenuState}; use crate::menu::{MenuCloseAnimation, MenuItem, MenuOpenAnimation, MenuState};
use crate::mode::{Mode, PendingMode}; use crate::mode::{Mode, PendingMode};
use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState}; use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOverlayState};
use crate::transition::TransitionAnimation; use crate::transition::TransitionAnimation;
use kiln_core::Board; use kiln_core::Board;
use kiln_core::game::GameState; use kiln_core::game::GameState;
@@ -137,15 +137,12 @@ impl Ui {
// scroll the instant its open animation finished. // scroll the instant its open animation finished.
let input_mode = crate::input::current_input_mode(mode, self); let input_mode = crate::input::current_input_mode(mode, self);
match mode { match mode {
// Play: tick the game (only in Board mode), then check whether a scroll // Play: tick the game only in Board mode (a scroll/menu overlay pauses
// overlay appeared as a result of this tick. // it). The run loop starts the scroll open animation when one appears,
// whether from this tick or from a player bump during input.
Mode::Play(game) => { Mode::Play(game) => {
if matches!(input_mode, crate::input::InputMode::Board) { if matches!(input_mode, crate::input::InputMode::Board) {
game.tick(dt); game.tick(dt);
if let Some(scroll) = &game.active_scroll {
let lines = scroll.lines.clone();
self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines)));
}
} }
} }
// Edit: only the cursor blink advances; the game does not exist here. // Edit: only the cursor blink advances; the game does not exist here.
+15
View File
@@ -1,6 +1,10 @@
use color::Rgba8; use color::Rgba8;
use kiln_core::cp437::tile_to_char;
use kiln_core::glyph::Glyph;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::prelude::Color; use ratatui::prelude::Color;
use ratatui::style::Style;
use ratatui::text::Span;
/// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`]. /// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`].
/// ///
@@ -10,6 +14,17 @@ pub fn rgba8_to_color(c: Rgba8) -> Color {
Color::Rgb(c.r, c.g, c.b) Color::Rgb(c.r, c.g, c.b)
} }
/// Converts a [`Glyph`] to a single-character styled [`Span`].
///
/// The tile index is mapped to a CP437 character; fg and bg are both applied.
pub fn glyph_to_span(glyph: Glyph) -> Span<'static> {
let ch = tile_to_char(glyph.tile).to_string();
let style = Style::default()
.fg(rgba8_to_color(glyph.fg))
.bg(rgba8_to_color(glyph.bg));
Span::styled(ch, style)
}
/// Returns true if two `Rect`s share at least one cell. /// Returns true if two `Rect`s share at least one cell.
pub fn rects_overlap(a: Rect, b: Rect) -> bool { pub fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
-37
View File
@@ -1,37 +0,0 @@
# kiln-ui
Module reference for the `kiln-ui` crate — reusable terminal UI widgets on
**ratatui**, mostly **game-agnostic**. See the repository-root `CLAUDE.md` for
project-wide guidance, code style, and key design decisions.
A widget owns its presentation + input handling and is generic over a host context `Ctx` it mutates *only through callbacks*, so a front-end decides what a choice does without the widget knowing about game/world types. The front-end owns the event loop and routes input to the widget while it is focused. The lone exception is `glyph_dialog`, which depends on `kiln-core` for `Glyph`/`Rgba8` and `cp437::tile_to_char` (the accepted, documented first kiln-core dependency — it would matter only if kiln-ui were ever split into a standalone crate). The **syntect** (highlighting) and **arboard** (clipboard) deps make this crate desktop/terminal-only (not WASM), unlike its ratatui core.
**`kiln-ui/src/lib.rs`** — crate root:
- `dim_area(area, buf)` (`pub`) — dims every cell in `area` (background tint for a modal overlay). Shared scaffolding: used by [`dialog`] here and re-borrowed by kiln-tui's `overlay::render_overlay_frame` so both modal styles dim identically.
- `CursorOverlay` (`pub` trait) + `render_overlay(frame, &impl CursorOverlay)` (`pub`) — a modal overlay paints into a `&mut Buffer` and *returns* `Option<Position>` (where the terminal caret goes) from `render(area, buf)`, instead of placing the cursor itself. `render_overlay` is the one place that needs a `Frame`: it calls `render` over `frame.area()` then applies the returned position via `set_cursor_position`. The split exists because a ratatui `Widget` only sees a buffer (can't move the hardware cursor), and it makes `render` unit-testable headless. Implemented by `Dialog` and `GlyphDialog`, which *also* `impl Widget for &Dialog`/`&GlyphDialog` (forwarding to `CursorOverlay::render` and discarding the caret) so they can be drawn with `frame.render_widget` where the cursor isn't needed.
- Modules: `pub mod dialog; pub mod code_editor; pub mod text_field; pub mod glyph_dialog;` (widgets) plus `mod clipboard; mod text;` (`pub(crate)` shared internals — see below).
**`kiln-ui/src/clipboard.rs` / `text.rs`** — `pub(crate)` shared internals for the text widgets:
- `clipboard::Clipboard` — system clipboard via `arboard` when available, always backed by an internal buffer (so copy/paste work headless / on the web); `set`/`get` write-through and prefer-system-on-read. Used by both `code_editor` and `text_field`.
- `text::{byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl, TAB_WIDTH}` — char↔byte index, display-width (tab stops + `unicode-width` wide chars), within-line word boundaries, and the `⌘``ctrl` key rewrite, shared by both editors.
**`kiln-ui/src/dialog.rs`** — modal dialog widgets:
- `Dialog<Ctx>` — an open dialog generic over the host context its callback mutates. Built via `Dialog::text(title, initial, on_done)` (a single free-text field; `on_done: FnOnce(Option<String>, &mut Ctx)`, `None` on cancel) or `Dialog::list(title, items, allow_create, on_done)` (a filter field over a selectable list; `on_done: FnOnce(ListDialogResponse, &mut Ctx)`). Holds a `DialogBody` (`Text`/`List`) plus the boxed callback. Each field is a [`TextField`].
- `ListDialogResponse { Select(String), Create(String), Cancel }` — list outcome: an existing entry, a newly-typed value (only when `allow_create`), or cancel. `DialogResult { Continue, Submit, Cancel }` — what a key event did.
- `handle_event(&Event) -> DialogResult``Esc``Cancel`; `Enter``Submit` only when OK is enabled (always for text; for a list, a valid entry is selected *or* `allow_create` + non-empty); Up/Down move a list's selection; other keys edit the field (and re-clamp the list selection to the new prefix filter). `finish(result, &mut Ctx)` consumes the dialog and fires its callback. The host pattern: `take()` the dialog out of its slot on `Submit`/`Cancel`, then call `finish` so the callback gets an un-borrowed `&mut Ctx`.
- `Dialog` draws via its `CursorOverlay` impl (`render(area, buf) -> Option<Position>`; host renders it through `render_overlay`) — dims the screen, draws a centered bordered box. A **text** dialog centers its field box (h+v) with a distinct `FIELD_BG` background so it reads as an input box; a **list** dialog puts the filter field atop the scrolling, selection-highlighted list. Both show an `[enter] OK [esc] Cancel` footer (OK dimmed when disabled). `render` returns the active field's caret position (the host places the real terminal cursor). Unit-tested with a dummy `Ctx`.
**`kiln-ui/src/glyph_dialog.rs`** — the glyph picker (the one kiln-core-dependent widget):
- `GlyphDialog<Ctx>` — a modal picker for a [`Glyph`] (CP437 tile index + fg/bg colors), same API shape as `Dialog`. Built via `GlyphDialog::new(title, initial: Glyph, on_done)`; `on_done: FnOnce(Option<Glyph>, &mut Ctx)` fires `Some(glyph)` on OK, `None` on cancel. Holds the selected `tile: u32`, two `ColorPicker`s (`fg`/`bg`), and a `Focus` (`Grid`/`FgStrip`/`FgField`/`BgStrip`/`BgField`).
- `ColorPicker` (private) — one color selector: a `selected` slot index over a strip of the 16 `kiln_core::colors::NAMED_COLORS` swatches plus a final **custom** slot (`CUSTOM == NAMED_COLORS.len()`), and a 6-hex `TextField` that is the **source of truth** (`color()` parses it). Picking a named swatch fills the field via `TextField::set`; the custom slot leaves it user-editable. Seeds from an `Rgba8` by matching a named color (else the custom slot).
- `handle_event(&Event) -> DialogResult` (reuses `dialog::DialogResult`) — `Esc``Cancel`; `Enter``Submit` only when both colors resolve; `Tab`/`Shift-Tab`/`BackTab` cycle focus (skipping a color's `*Field` stop unless it's on the custom slot). On the grid, arrows move the 32×8 selection (clamped); on a strip, Left/Right pick a swatch and Down enters the custom field; in a custom field, keys edit it and Up returns to the strip. `finish(result, &mut Ctx)` fires the callback (a `Submit` with both colors valid yields `Some(Glyph{..})`).
- Drawing is its `CursorOverlay` impl (`render(area, buf) -> Option<Position>`; host renders it through `render_overlay`) — dims + centered bordered box; renders the 256-char grid via `kiln_core::cp437::tile_to_char` (chosen fg/bg when both colors resolve, else gray-on-black; selected cell is a bright cursor when the grid is focused, else reversed), then for each color a **swatch strip** (one solid cell per named color + the custom slot; selected slot marked `●`, custom slot otherwise `+`) above a **hex row** (an editable `#RRGGBB` box, red when invalid, when on the custom slot; else the dimmed name + hex of the selected named color), and the footer (which previews the chosen glyph). Returns the focused custom field's caret position (or `None`). Unit-tested with a dummy `Ctx`.
**`kiln-ui/src/text_field.rs`** — a single-line editable text field (a one-line sibling of `code_editor`, used by `dialog`'s text/filter inputs):
- `TextField``text: String` + a char-indexed `cursor` + a `Clipboard` + an optional `max_length`. `new(initial)` (unbounded) or `sized(initial, max_length)` (truncates the seed and caps typed input); `value()`, `display_cursor()` (caret's display column), `set(text)` (replace contents, truncated to `max_length`), and `handle_key(KeyEvent) -> bool` (`true` if consumed). Handles printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves), Home/End, and `ctrl`/`⌘`+`v` paste (newlines stripped); leaves Esc/Enter/Up/Down to the caller. Presentation is the caller's job (it reads `value()`/`display_cursor()`). No selection yet. Unit-tested.
**`kiln-ui/src/code_editor.rs`** — a hand-written **modeless, keyboard-only** code editor (hand-written; `edtui` was rejected for panicking on bare Shift and lacking line-wrap):
- `CodeEditor` — edits one script's text: `lines: Vec<String>`, a char-indexed `cursor`/`anchor` (selection), `desired_col` (vertical-move column), `scroll` (top row + left display-col, cursor-following), undo/redo `Snapshot` stacks, a cached `HighlightParts`, and a `Clipboard` (system via `arboard` when available, always backed by an internal buffer so copy/paste work headless / on the web). Public API: `new(name, text)`, `name()`, `text()` (lines joined with `\n`), `handle_event(&Event) -> CodeEditorOutcome { Continue, Exit }`, `draw(&mut self, frame, area)`.
- Input (`handle_event`): `⌘` is mapped to `ctrl` up front; then printable keys insert, Enter/Backspace/Delete edit (Backspace/Delete at a line edge join lines, Left/Right wrap across line ends), arrows move (`shift` extends the selection), **`ctrl`+Left/Right jump by word** (`text::{prev,next}_word`, wrapping at line ends) and **`ctrl`+Up/Down jump by paragraph** (to the nearest blank/whitespace-only line, via `is_blank_line`), Home/End/PageUp/PageDown, `ctrl`+`c`/`x`/`v` (system clipboard), `ctrl`+`a` (select all), `ctrl`+`z`/`y` (undo/redo), `Esc``Exit`. Bracketed `Event::Paste` inserts text. **Unknown keycodes are ignored** (the whole point — no panic on Shift/F-keys). Undo coalesces a run of typing (or deletes) into one step; movement/structural edits break the run.
- Rendering (`draw`): a titled border, a line-number gutter, and per-line styled text. One width model (`char_cells`: tabs → next 4-stop, wide chars via `unicode-width`) drives the cursor X, selection background, and horizontal scroll together. Highlighting: `build_highlight_parts()` parses the embedded `resources/rhai.sublime-syntax` into a one-syntax `SyntaxSet` + `base16-ocean.dark` theme (cached); `draw` runs `syntect::easy::HighlightLines` from line 0 to the viewport bottom each frame (small scripts), mapping syntect colors to `Color::Rgb`. Long lines clip with the cursor-following horizontal scroll (no soft-wrap). Mouse is out of scope for now.
- Unit-tested (pure logic, no TTY).
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "kiln-ui"
version = "0.1.0"
edition = "2024"
[dependencies]
# First kiln-core dependency in kiln-ui: the glyph picker needs `Glyph`/`Rgba8`.
kiln-core = { path = "../kiln-core" }
color = "0.3.3"
# text-only clipboard: drop the default `image-data` deps (image/tiff/jpeg), add Linux Wayland support
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
ratatui = { workspace = true }
syntect = "5.3.0"
unicode-width = "0.2"
-405
View File
@@ -1,405 +0,0 @@
%YAML 1.2
---
# http://www.sublimetext.com/docs/3/syntax.html
name: Rhai
file_extensions:
- rhai
scope: source.rhai
contexts:
main:
- include: core
brackets:
- include: round-brackets
- include: square-brackets
- include: curly-brackets
comments:
- match: '/\*\*(?![/|\*])'
captures:
0: punctuation.definition.comment.block.documentation.rhai
push:
- meta_scope: comment.block.documentation.rhai
- match: \*/
captures:
0: punctuation.definition.comment.block.documentation.rhai
pop: true
- include: comments
- match: /\*
captures:
0: punctuation.definition.comment.block.rhai
push:
- meta_scope: comment.block.rhai
- match: \*/
captures:
0: punctuation.definition.comment.block.rhai
pop: true
- include: comments
- match: '(///)[^/].*$\n?'
scope: comment.line.documentation.rhai
captures:
1: punctuation.definition.comment.documentation.rhai
- match: (//).*$\n?
scope: comment.line.double-slash.rhai
captures:
1: punctuation.definition.comment.double-slash.rhai
- match: ^(#!).*$\n?
scope: comment.line.shebang.rhai
captures:
1: punctuation.definition.comment.rhai
core:
- include: expression
curly-brackets:
- match: '\{'
captures:
0: meta.brace.curly.rhai
push:
- meta_scope: meta.group.braces.curly
- match: '\}'
captures:
0: meta.brace.curly.rhai
pop: true
- include: main
expression:
- include: literal-closure-labels
- include: literal-labels
- include: literal-keywords
- include: support
- include: literal-function
- include: literal-closure
- include: literal-constant
- include: literal-template-string
- include: literal-language-variable
- include: literal-module
- include: literal-method-call
- include: literal-function-call
- include: comments
- include: brackets
- include: literal-operators
- include: literal-namespace
- include: literal-variable
- include: literal-punctuation
function-declaration-parameters:
- match: \(
captures:
0: punctuation.definition.parameters.begin.rhai
push:
- match: \)
captures:
0: punctuation.definition.parameters.end.rhai
pop: true
- match: '[_a-zA-Z]\w*'
scope: variable.parameter.function.rhai
- match: \,
scope: punctuation.separator.parameter.function.rhai
- include: parameters-list
literal-closure:
- match: (\|)
captures:
1: punctuation.definition.parameters.closure.begin.rhai
push:
- meta_scope: meta.function.closure.rhai
- match: (\|)
captures:
1: punctuation.definition.parameters.closure.end.rhai
pop: true
- include: parameters-list
- match: '(\b[_a-zA-Z]\w*)\s*(=)\s*(\|)'
captures:
1: entity.name.function.closure.rhai
2: keyword.operator.assignment.rhai
3: punctuation.definition.parameters.closure.begin.rhai
push:
- meta_scope: meta.function.closure.rhai
- match: (\|)
captures:
1: punctuation.definition.parameters.closure.end.rhai
pop: true
- include: parameters-list
literal-closure-labels:
- match: '(\b[_a-zA-Z]\w*)\s*(\:)\s*(\|)'
captures:
1: string.unquoted.label.rhai entity.name.function.method.rhai
2: punctuation.separator.key-value.rhai
3: punctuation.definition.parameters.closure.begin.rhai
push:
- meta_scope: meta.function.closure.rhai
- match: (\|)
captures:
1: punctuation.definition.parameters.closure.end.rhai
pop: true
- include: parameters-list
- match: '((\")((?:[^"]|\\")*)(\"))\s*(:)\s*(\|)'
captures:
1: string.quoted.double.rhai
2: punctuation.definition.string.begin.rhai
3: entity.name.function.method.rhai
4: punctuation.definition.string.end.rhai
5: punctuation.separator.key-value.rhai
6: punctuation.definition.parameters.closure.begin.rhai
push:
- meta_scope: meta.function.closure.rhai
- match: (\|)
captures:
1: punctuation.definition.parameters.closure.end.rhai
pop: true
- include: parameters-list
literal-constant:
- include: literal-number
- include: literal-string
- include: literal-language-constant
literal-function:
- match: |-
(?x) (?:\b(private)\s+)?
\s*(fn)
\s*([_a-zA-Z]\w*)\s*
captures:
1: storage.modifier.rhai
2: storage.type.function.rhai
3: entity.name.function.rhai
push:
- meta_scope: meta.function.rhai
- match: (?<=\))
pop: true
- include: parameters-list
literal-function-call:
- match: |-
(?x) ([_a-zA-Z]\w*)(!)?\s*
(\(\s*\))
scope: meta.function-call.without-arguments.rhai
captures:
1: entity.name.function.rhai
2: punctuation.function-call.capture.rhai
3: meta.group.braces.round.function.arguments.rhai
- match: |-
(?x) ([_a-zA-Z]\w*)(!)?\s*
(?=\()
scope: meta.function-call.with-arguments.rhai
captures:
1: entity.name.function.rhai
2: punctuation.function-call.capture.rhai
literal-keyword-storage:
- match: \b(const|let)\b
scope: storage.type.rhai
literal-keywords:
- include: literal-keyword-storage
- match: \b(return)\b
scope: keyword.control.flow.rhai
- match: \b(if|else|switch)\b
scope: keyword.control.conditional.rhai
- match: \b(throw|try|catch)\b
scope: keyword.control.trycatch.rhai
- match: \b(for|in|loop|do|while|until|break|continue)\b
scope: keyword.control.loop.rhai
literal-labels:
- match: |-
(?x) (?<!\?)(?<!\?\s)
(?=((")((?:[^\:"]|\\")*)("))\s*:)
push:
- match: ":"
captures:
0: punctuation.separator.key-value.rhai
pop: true
- include: literal-string
- match: '(?<!\.|\?|\?\s)([_a-zA-Z]\w*)\s*(:)(?!\:)'
scope: constant.other.object.key.rhai
captures:
1: string.unquoted.label.rhai
2: punctuation.separator.key-value.rhai
literal-language-constant:
- match: \btrue\b
scope: constant.language.boolean.true.rhai
- match: \bfalse\b
scope: constant.language.boolean.false.rhai
literal-language-namespace:
- match: '(?<!\:\:)\s*((global)\s*(\:\:))(?!<)'
captures:
1: meta.path.rhai
2: constant.language.namespace.global.rhai entity.name.namespace.rhai
3: punctuation.separator.namespace.rhai
literal-language-variable:
- match: \bthis\b
scope: variable.language.this.rhai
literal-method-call:
- match: |-
(?x) (?<=\.)
\s*([_a-zA-Z]\w*)\s*
(\(\s*\))
scope: meta.function-call.method.without-arguments.rhai
captures:
1: entity.name.function.rhai
2: meta.group.braces.round.function.arguments.rhai
- match: |-
(?x) (?<=\.)
\s*([_a-zA-Z]\w*)\s*
(?=\()
scope: meta.function-call.method.with-arguments.rhai
captures:
1: entity.name.function.rhai
literal-module:
- match: \b(import|export|as)\b
scope: keyword.control.import.rhai
literal-namespace:
- include: literal-language-namespace
- match: '([_a-zA-Z]\w*)\s*(\:\:)(?!<)'
scope: meta.path.rhai
captures:
1: entity.name.namespace.rhai
2: punctuation.separator.namespace.rhai
- match: '(?<=\:\:)(\s*([_a-zA-Z]\w*))\s*(?!\:\:)'
captures:
1: meta.path.rhai
2: variable.other.constant.rhai
literal-number:
- match: |-
(?xi) (?:
\b0b[0-1][_0-1]*| # binary
\b0o[0-7][_0-7]*| # octal
\b0x[\da-f][_\da-f]*| # hex
(\B[+\-])?\b\d[_\d]*\.\d[_\d]*(e[+\-]?\d[_\d]*)?| # e.g. 999.999, 999.99e+123
(\B[+\-])?\b\d[_\d]*\.\B| # e.g. 999.
(\B[+\-])?\b\d[_\d]*(e[+\-]?\d[_\d]*)? # e.g. 999, 999e+123
)
scope: constant.numeric.rhai
literal-operators:
- match: |-
(?x) !(?!=)| # logical-not right-to-left right
&& | # logical-and left-to-right both
\|\| # logical-or left-to-right both
scope: keyword.operator.logical.rhai
- match: "(?x) =(?![=>]) # assignment right-to-left both"
scope: keyword.operator.assignment.rhai
- match: |-
(?x) %= | # assignment right-to-left both
&= | # assignment right-to-left both
\*\*=| # assignment right-to-left both
(?<!\*)\*= | # assignment right-to-left both
\+= | # assignment right-to-left both
-= | # assignment right-to-left both
/= | # assignment right-to-left both
\^= | # assignment right-to-left both
\|= | # assignment right-to-left both
<<= | # assignment right-to-left both
>>= # assignment right-to-left both
scope: keyword.operator.assignment.augmented.rhai
- match: |-
(?x) << | # bitwise-shift left-to-right both
>> | # bitwise-shift left-to-right both
& | # bitwise-and left-to-right both
\^ | # bitwise-xor left-to-right both
\| # bitwise-or left-to-right both
scope: keyword.operator.bitwise.rhai
- match: |-
(?x) <= | # relational left-to-right both
>= | # relational left-to-right both
<(?!-) | # relational left-to-right both
(?<!-)> # relational left-to-right both
scope: keyword.operator.relational.rhai
- match: |-
(?x) ==(?!=) | # equality left-to-right both
!=(?!=) # equality left-to-right both
scope: keyword.operator.comparison.rhai
- match: |-
(?x) / | # division left-to-right both
% | # modulus left-to-right both
\*\* | # power left-to-right both
\*(?!\)) | # multiplication left-to-right both
\+(?!\+) | # addition left-to-right both
-(?![>-]) # subtraction left-to-right both
scope: keyword.operator.arithmetic.rhai
- match: \.(?!\.)
scope: keyword.operator.accessor.rhai punctuation.accessor.rhai
- match: "=>"
scope: punctuation.separator.switch.case.rhai
- match: '(\(\*|\*\)|\+\+|--|\.\.+|~|#(?!{)|@|\$(?!{)|->|<-|===|!==|\:=|\:\:<)'
scope: invalid.illegal.operator.rhai
literal-punctuation:
- match: \;
scope: punctuation.terminator.statement.rhai
- match: \,
scope: meta.delimiter.comma.rhai
literal-string:
- match: '(''([^''\\]|\\([tnr''\\]|x\h{2}|u\h{4}|U\h{8}))'')'
scope: string.quoted.single.rhai
- match: (")
captures:
1: punctuation.definition.string.begin.rhai
push:
- meta_scope: string.quoted.double.rhai
- match: (")
captures:
1: punctuation.definition.string.end.rhai
pop: true
- include: string-content
- match: (?<!\\)\n
scope: invalid.illegal.newline.rhai
- match: \\\n
scope: constant.character.escape.newline.rhai punctuation.separator.continuation
literal-template-string:
- match: "`"
captures:
0: punctuation.definition.string.begin.rhai
push:
- meta_scope: string.interpolated.rhai
- match: "`"
captures:
0: punctuation.definition.string.end.rhai
pop: true
- include: string-content
- match: '\${'
captures:
0: punctuation.section.interpolation.begin.rhai
push:
- meta_scope: meta.interpolation.rhai
- match: "}"
captures:
0: punctuation.section.interpolation.end.rhai
pop: true
- include: expression
literal-variable:
- match: '[A-Z][_\dA-Z]*\b'
scope: variable.other.constant.rhai
- match: '(?<!\.)\s*([_a-zA-Z]\w*)\s*(?=\.)'
captures:
1: variable.other.object.rhai
- match: '(?<=\.)\s*([_a-zA-Z]\w*)'
captures:
1: variable.other.property.rhai entity.name.property.rhai
- match: '[_a-zA-Z]\w*'
scope: variable.other.readwrite.rhai
parameters-list:
- match: '[_a-zA-Z]\w*'
scope: variable.parameter.function.rhai
- match: \,
scope: punctuation.separator.parameter.function.rhai
- include: comments
round-brackets:
- match: \((?!\*)
captures:
0: meta.brace.round.rhai
push:
- meta_scope: meta.group.braces.round
- match: (?<!\*)\)
captures:
0: meta.brace.round.rhai
pop: true
- include: expression
square-brackets:
- match: '\['
captures:
0: meta.brace.square.rhai
push:
- meta_scope: meta.group.braces.square
- match: '\]'
captures:
0: meta.brace.square.rhai
pop: true
- include: expression
string-content:
- match: '\\(x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8}|t|r|n|\\)'
scope: constant.character.escape.rhai
- match: '\\[^xuUtrn\\\n]'
scope: invalid.illegal.escape.rhai
support:
- match: \b(print|debug|call|curry|eval|type_of|is_def_var|is_def_fn|is_shared)\b
scope: support.function.rhai
- match: \b(var|static|shared|goto|exit|match|case|public|protected|new|use|with|module|package|super|thread|spawn|go|await|async|sync|yield|default|void|null|nil)\b
scope: invalid.illegal.keyword.rhai
-54
View File
@@ -1,54 +0,0 @@
//! A clipboard shared by the text widgets ([`code_editor`](crate::code_editor),
//! [`text_field`](crate::text_field)).
//!
//! It is backed by an always-present internal buffer, mirrored to the system clipboard
//! when one is available — so copy/paste works the same everywhere, degrading cleanly.
/// A clipboard backed by an always-present internal buffer, mirrored to the system
/// clipboard when one is available.
///
/// `arboard::Clipboard::new()` fails with no display (headless Linux, etc.) and isn't
/// available on the web; in those cases `system` is `None` and copy/paste still work
/// *within* the app via `internal`. When the system clipboard is present we write
/// through to it (so other apps see copies) and prefer it on read (so we see their
/// copies), falling back to `internal` if a read fails.
pub(crate) struct Clipboard {
system: Option<arboard::Clipboard>,
internal: String,
}
impl Clipboard {
/// Creates a clipboard, attaching the system one if it initializes.
///
/// Under `cfg(test)` the system clipboard is never attached: arboard isn't safe to
/// initialize from many threads at once (the parallel test harness would otherwise
/// crash on macOS), and tests exercise copy/paste through the internal buffer anyway.
pub(crate) fn new() -> Self {
#[cfg(not(test))]
let system = arboard::Clipboard::new().ok();
#[cfg(test)]
let system = None;
Self {
system,
internal: String::new(),
}
}
/// Stores `text` internally and (best-effort) on the system clipboard.
pub(crate) fn set(&mut self, text: String) {
if let Some(sys) = &mut self.system {
let _ = sys.set_text(&text);
}
self.internal = text;
}
/// Returns the system clipboard text if readable, else the internal buffer.
pub(crate) fn get(&mut self) -> String {
if let Some(sys) = &mut self.system
&& let Ok(text) = sys.get_text()
{
return text;
}
self.internal.clone()
}
}
-930
View File
@@ -1,930 +0,0 @@
//! A small, purpose-built **modeless, keyboard-only** code editor widget.
//!
//! It edits like an ordinary text box (no vim modes): arrow keys move (wrapping across line
//! ends), printable keys insert, `shift`+arrows select, `ctrl`/`⌘`+`c`/`x`/`v` use the system
//! clipboard, `ctrl`/`⌘`+`z`/`y` undo/redo, and `Esc` asks the host to close. It shows a
//! line-number gutter and syntect syntax highlighting (the embedded Rhai syntax).
//!
//! Mouse is intentionally out of scope for now (its click hit-testing — gutter + scroll +
//! unicode width + tab expansion — is the hard part); the same per-line width model here
//! would invert to support it later. Like [`dialog`](crate::dialog) this is presentation +
//! input only; the host owns the loop and decides what open/close means.
use std::sync::Arc;
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use syntect::easy::HighlightLines;
use syntect::highlighting::{Theme, ThemeSet};
use syntect::parsing::syntax_definition::SyntaxDefinition;
use syntect::parsing::{SyntaxReference, SyntaxSet, SyntaxSetBuilder};
use crate::clipboard::Clipboard;
use crate::text::{
TAB_WIDTH, byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl,
};
/// The embedded Rhai Sublime-syntax definition used for highlighting.
const RHAI_SYNTAX: &str = include_str!("../resources/rhai.sublime-syntax");
/// A dark syntect theme that ships with `ThemeSet::load_defaults`.
const THEME_NAME: &str = "base16-ocean.dark";
/// Maximum retained undo (and redo) snapshots.
const UNDO_DEPTH: usize = 200;
/// Default text color when no syntax color applies.
const DEFAULT_FG: Color = Color::Rgb(220, 220, 220);
/// Background of selected text.
const SELECT_BG: Color = Color::Rgb(40, 60, 110);
/// Line-number gutter color.
const GUTTER_FG: Color = Color::Rgb(90, 100, 120);
/// What a key/paste event did to the editor, reported back to the host.
pub enum CodeEditorOutcome {
/// The editor stays open.
Continue,
/// The user pressed `Esc`; the host should close the editor.
Exit,
}
/// A cursor/selection position: `col` is a **character** index within `row`'s line.
#[derive(Clone, Copy, PartialEq, Eq)]
struct Pos {
row: usize,
col: usize,
}
/// Which kind of edit just happened, used to coalesce undo steps.
#[derive(Clone, Copy, PartialEq, Eq)]
enum EditKind {
/// A run of single-character insertions.
Insert,
/// A run of single-character deletions.
Delete,
/// Any structural edit (newline, paste, selection delete) — never coalesced.
Other,
}
/// A point-in-time buffer snapshot for undo/redo.
#[derive(Clone)]
struct Snapshot {
lines: Vec<String>,
cursor: Pos,
}
/// Cached syntect pieces for highlighting (reused across frames).
struct HighlightParts {
theme: Theme,
syntax_ref: SyntaxReference,
syntax_set: Arc<SyntaxSet>,
}
/// A modeless, keyboard-driven code editor over a single script's text.
pub struct CodeEditor {
/// The script name, shown in the title border and used by the host on save.
name: String,
/// The text buffer, one entry per line (always at least one line).
lines: Vec<String>,
/// The cursor position.
cursor: Pos,
/// Remembered display intent for vertical movement (so up/down keep a column).
desired_col: usize,
/// Selection anchor; the selection spans `anchor..cursor` when `Some`.
anchor: Option<Pos>,
/// Scroll offset as `(top row, left display column)`, kept following the cursor.
scroll: (usize, usize),
/// Visible text rows from the last `draw`, used by PageUp/PageDown.
view_rows: usize,
/// Syntax-highlighting data, or `None` if the syntax/theme failed to load.
highlight: Option<HighlightParts>,
/// Clipboard: the system one when available, always backed by an internal buffer.
clipboard: Clipboard,
/// Undo/redo snapshot stacks and the open-edit-group marker for coalescing.
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
last_edit: Option<EditKind>,
}
impl CodeEditor {
/// Builds an editor for `name` pre-filled with `text`.
pub fn new(name: &str, text: &str) -> Self {
let lines: Vec<String> = if text.is_empty() {
vec![String::new()]
} else {
text.split('\n').map(str::to_string).collect()
};
Self {
name: name.to_string(),
lines,
cursor: Pos { row: 0, col: 0 },
desired_col: 0,
anchor: None,
scroll: (0, 0),
view_rows: 0,
highlight: build_highlight_parts(),
clipboard: Clipboard::new(),
undo: Vec::new(),
redo: Vec::new(),
last_edit: None,
}
}
/// The script name (the host writes [`text`](CodeEditor::text) back under it).
pub fn name(&self) -> &str {
&self.name
}
/// The current buffer contents (lines joined with `\n`).
pub fn text(&self) -> String {
self.lines.join("\n")
}
/// Handles a key/paste event. `Esc` requests close; unrecognized keys are ignored.
pub fn handle_event(&mut self, event: &Event) -> CodeEditorOutcome {
match event {
// Terminal (bracketed) paste: insert the text as-is.
Event::Paste(text) => {
self.break_group();
self.replace_selection();
self.insert_text(text);
}
Event::Key(key) => {
// Treat ⌘ as ctrl (terminals report Super for Cmd under the kitty protocol).
let key = super_to_ctrl(*key);
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Esc => return CodeEditorOutcome::Exit,
// Clipboard / undo / select-all chords.
KeyCode::Char(c) if ctrl => match c.to_ascii_lowercase() {
'c' => self.copy(),
'x' => self.cut(),
'v' => self.paste(),
'a' => self.select_all(),
'z' => {
if shift {
self.redo()
} else {
self.undo()
}
}
'y' => self.redo(),
_ => {}
},
// Printable text.
KeyCode::Char(c) if !alt => self.insert_char(c),
KeyCode::Tab => self.insert_tab(),
KeyCode::Enter => self.insert_newline(),
KeyCode::Backspace => self.backspace(),
KeyCode::Delete => self.delete_forward(),
KeyCode::Left => self.moved(
shift,
if ctrl {
Self::move_word_left
} else {
Self::move_left
},
),
KeyCode::Right => self.moved(
shift,
if ctrl {
Self::move_word_right
} else {
Self::move_right
},
),
KeyCode::Up => self.moved(
shift,
if ctrl {
Self::move_paragraph_up
} else {
Self::move_up
},
),
KeyCode::Down => self.moved(
shift,
if ctrl {
Self::move_paragraph_down
} else {
Self::move_down
},
),
KeyCode::Home => self.moved(shift, Self::move_home),
KeyCode::End => self.moved(shift, Self::move_end),
KeyCode::PageUp => self.moved(shift, Self::move_page_up),
KeyCode::PageDown => self.moved(shift, Self::move_page_down),
_ => {} // ignore anything else — never panic on an unknown key
}
}
_ => {} // mouse and other events are ignored for now
}
CodeEditorOutcome::Continue
}
// ── Movement ─────────────────────────────────────────────────────────────
/// Runs a movement, first setting/clearing the selection anchor per `shift`.
fn moved(&mut self, shift: bool, mv: fn(&mut Self)) {
self.break_group();
if shift {
// Begin (or continue) a selection from the pre-move cursor.
if self.anchor.is_none() {
self.anchor = Some(self.cursor);
}
} else {
self.anchor = None;
}
mv(self);
}
/// Number of characters in line `row`.
fn line_len(&self, row: usize) -> usize {
self.lines[row].chars().count()
}
fn move_left(&mut self) {
if self.cursor.col > 0 {
self.cursor.col -= 1;
} else if self.cursor.row > 0 {
// Wrap to the end of the previous line.
self.cursor.row -= 1;
self.cursor.col = self.line_len(self.cursor.row);
}
self.desired_col = self.cursor.col;
}
fn move_right(&mut self) {
if self.cursor.col < self.line_len(self.cursor.row) {
self.cursor.col += 1;
} else if self.cursor.row + 1 < self.lines.len() {
// Wrap to the start of the next line.
self.cursor.row += 1;
self.cursor.col = 0;
}
self.desired_col = self.cursor.col;
}
fn move_up(&mut self) {
if self.cursor.row > 0 {
self.cursor.row -= 1;
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
}
fn move_down(&mut self) {
if self.cursor.row + 1 < self.lines.len() {
self.cursor.row += 1;
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
}
/// ctrl+Left: jump to the previous word start (wrapping to the previous line's end).
fn move_word_left(&mut self) {
if self.cursor.col == 0 && self.cursor.row > 0 {
self.cursor.row -= 1;
self.cursor.col = self.line_len(self.cursor.row);
} else {
self.cursor.col = prev_word(&self.lines[self.cursor.row], self.cursor.col);
}
self.desired_col = self.cursor.col;
}
/// ctrl+Right: jump to the next word start (wrapping to the next line's start).
fn move_word_right(&mut self) {
if self.cursor.col == self.line_len(self.cursor.row)
&& self.cursor.row + 1 < self.lines.len()
{
self.cursor.row += 1;
self.cursor.col = 0;
} else {
self.cursor.col = next_word(&self.lines[self.cursor.row], self.cursor.col);
}
self.desired_col = self.cursor.col;
}
/// ctrl+Up: jump to the nearest blank/whitespace-only line above, else the first line.
fn move_paragraph_up(&mut self) {
self.cursor.row = (0..self.cursor.row)
.rev()
.find(|&r| self.is_blank_line(r))
.unwrap_or(0);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
/// ctrl+Down: jump to the nearest blank/whitespace-only line below, else the last line.
fn move_paragraph_down(&mut self) {
let last = self.lines.len() - 1;
self.cursor.row = (self.cursor.row + 1..self.lines.len())
.find(|&r| self.is_blank_line(r))
.unwrap_or(last);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
/// Whether line `row` is empty or contains only whitespace.
fn is_blank_line(&self, row: usize) -> bool {
self.lines[row].trim().is_empty()
}
fn move_home(&mut self) {
self.cursor.col = 0;
self.desired_col = 0;
}
fn move_end(&mut self) {
self.cursor.col = self.line_len(self.cursor.row);
self.desired_col = self.cursor.col;
}
fn move_page_up(&mut self) {
let step = self.view_rows.max(1);
self.cursor.row = self.cursor.row.saturating_sub(step);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
fn move_page_down(&mut self) {
let step = self.view_rows.max(1);
self.cursor.row = (self.cursor.row + step).min(self.lines.len() - 1);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
// ── Editing ──────────────────────────────────────────────────────────────
fn insert_char(&mut self, c: char) {
self.replace_selection();
self.begin_edit(EditKind::Insert);
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
self.lines[self.cursor.row].insert(b, c);
self.cursor.col += 1;
self.desired_col = self.cursor.col;
}
/// Inserts spaces to advance to the next tab stop.
fn insert_tab(&mut self) {
self.replace_selection();
let pad = TAB_WIDTH - (self.display_col(self.cursor.row, self.cursor.col) % TAB_WIDTH);
for _ in 0..pad {
self.insert_char(' ');
}
}
fn insert_newline(&mut self) {
self.replace_selection();
self.begin_edit(EditKind::Other);
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
let tail = self.lines[self.cursor.row].split_off(b);
self.lines.insert(self.cursor.row + 1, tail);
self.cursor.row += 1;
self.cursor.col = 0;
self.desired_col = 0;
}
/// Inserts arbitrary text (possibly multi-line) at the cursor.
fn insert_text(&mut self, text: &str) {
self.begin_edit(EditKind::Other);
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
// Each '\n' splits the current line.
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
let tail = self.lines[self.cursor.row].split_off(b);
self.lines.insert(self.cursor.row + 1, tail);
self.cursor.row += 1;
self.cursor.col = 0;
}
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
self.lines[self.cursor.row].insert_str(b, part);
self.cursor.col += part.chars().count();
}
self.desired_col = self.cursor.col;
}
fn backspace(&mut self) {
if self.anchor.is_some() {
self.replace_selection();
return;
}
self.begin_edit(EditKind::Delete);
if self.cursor.col > 0 {
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col - 1);
self.lines[self.cursor.row].remove(b);
self.cursor.col -= 1;
} else if self.cursor.row > 0 {
// Join with the previous line.
let line = self.lines.remove(self.cursor.row);
self.cursor.row -= 1;
self.cursor.col = self.line_len(self.cursor.row);
self.lines[self.cursor.row].push_str(&line);
}
self.desired_col = self.cursor.col;
}
fn delete_forward(&mut self) {
if self.anchor.is_some() {
self.replace_selection();
return;
}
self.begin_edit(EditKind::Delete);
if self.cursor.col < self.line_len(self.cursor.row) {
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
self.lines[self.cursor.row].remove(b);
} else if self.cursor.row + 1 < self.lines.len() {
// Pull the next line up onto this one.
let next = self.lines.remove(self.cursor.row + 1);
self.lines[self.cursor.row].push_str(&next);
}
}
// ── Selection ──────────────────────────────────────────────────────────────
/// The selection as an ordered `(start, end)` pair, if any.
fn selection(&self) -> Option<(Pos, Pos)> {
let a = self.anchor?;
let c = self.cursor;
if (a.row, a.col) <= (c.row, c.col) {
Some((a, c))
} else {
Some((c, a))
}
}
/// Deletes the current selection (if any) and places the cursor at its start.
fn replace_selection(&mut self) {
let Some((a, b)) = self.selection() else {
return;
};
self.begin_edit(EditKind::Other);
if a.row == b.row {
let s = &mut self.lines[a.row];
let (ba, bb) = (byte_of(s, a.col), byte_of(s, b.col));
s.replace_range(ba..bb, "");
} else {
let bb = byte_of(&self.lines[b.row], b.col);
let tail = self.lines[b.row][bb..].to_string();
let ba = byte_of(&self.lines[a.row], a.col);
self.lines[a.row].truncate(ba);
self.lines[a.row].push_str(&tail);
self.lines.drain(a.row + 1..=b.row);
}
self.cursor = a;
self.desired_col = a.col;
self.anchor = None;
}
/// The text covered by an ordered selection `(a, b)`.
fn selected_text(&self, a: Pos, b: Pos) -> String {
if a.row == b.row {
let s = &self.lines[a.row];
return s[byte_of(s, a.col)..byte_of(s, b.col)].to_string();
}
let mut out = self.lines[a.row][byte_of(&self.lines[a.row], a.col)..].to_string();
for row in a.row + 1..b.row {
out.push('\n');
out.push_str(&self.lines[row]);
}
out.push('\n');
out.push_str(&self.lines[b.row][..byte_of(&self.lines[b.row], b.col)]);
out
}
fn select_all(&mut self) {
self.break_group();
let last = self.lines.len() - 1;
self.anchor = Some(Pos { row: 0, col: 0 });
self.cursor = Pos {
row: last,
col: self.line_len(last),
};
}
// ── Clipboard ──────────────────────────────────────────────────────────────
fn copy(&mut self) {
if let Some((a, b)) = self.selection() {
let text = self.selected_text(a, b);
self.clipboard.set(text);
}
}
fn cut(&mut self) {
self.copy();
self.replace_selection();
}
fn paste(&mut self) {
let text = self.clipboard.get();
if text.is_empty() {
return;
}
self.break_group();
self.replace_selection();
self.insert_text(&text);
}
// ── Undo / redo ──────────────────────────────────────────────────────────
/// Begins an edit of `kind`, snapshotting state unless it coalesces with the run in
/// progress (consecutive inserts, or consecutive deletes, collapse to one undo step).
fn begin_edit(&mut self, kind: EditKind) {
let coalesce = kind != EditKind::Other && self.last_edit == Some(kind);
if !coalesce {
self.push_snapshot();
}
self.last_edit = Some(kind);
}
/// Ends the current edit run so the next edit starts a fresh undo step.
fn break_group(&mut self) {
self.last_edit = None;
}
/// Pushes the current state to the undo stack and clears redo.
fn push_snapshot(&mut self) {
self.undo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
if self.undo.len() > UNDO_DEPTH {
self.undo.remove(0);
}
self.redo.clear();
}
fn undo(&mut self) {
if let Some(prev) = self.undo.pop() {
self.redo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
self.restore(prev);
}
}
fn redo(&mut self) {
if let Some(next) = self.redo.pop() {
self.undo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
self.restore(next);
}
}
/// Replaces the buffer/cursor with a snapshot, ending any edit run and selection.
fn restore(&mut self, snap: Snapshot) {
self.lines = snap.lines;
self.cursor = snap.cursor;
self.desired_col = self.cursor.col;
self.anchor = None;
self.last_edit = None;
}
// ── Rendering ──────────────────────────────────────────────────────────────
/// Display column (terminal cells) of character index `col` on line `row` (tabs to the
/// next [`TAB_WIDTH`] stop, wide chars as two cells).
fn display_col(&self, row: usize, col: usize) -> usize {
display_width(&self.lines[row], col)
}
/// Renders the editor (titled border, gutter, highlighted text, cursor) into `area`.
pub fn draw(&mut self, frame: &mut Frame, area: Rect) {
let block = Block::bordered().title(format!(" {} ", self.name));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let rows = inner.height as usize;
self.view_rows = rows;
// Gutter holds the largest line number plus a trailing space.
let gutter_w = (self.lines.len().to_string().len() + 1) as u16;
let text_x = inner.x + gutter_w;
let text_w = inner.width.saturating_sub(gutter_w);
if text_w == 0 {
return;
}
self.update_scroll(rows, text_w as usize);
let (top, left) = self.scroll;
let bottom = (top + rows).min(self.lines.len());
// Highlight from line 0 to the viewport bottom (syntect needs prior lines for context).
let colors = self.highlight_through(bottom);
let sel = self.selection();
for (i, row) in (top..bottom).enumerate() {
let y = inner.y + i as u16;
// Right-aligned line number in the gutter (temporary buffer borrow per call).
let num = format!("{:>w$} ", row + 1, w = gutter_w as usize - 1);
frame
.buffer_mut()
.set_string(inner.x, y, num, Style::default().fg(GUTTER_FG));
// Styled, tab-expanded line; Paragraph applies the horizontal scroll + clipping.
let line = self.render_line(row, colors.get(row).map(Vec::as_slice), sel);
let rect = Rect {
x: text_x,
y,
width: text_w,
height: 1,
};
frame.render_widget(Paragraph::new(line).scroll((0, left as u16)), rect);
}
// Place the real terminal cursor if it's within the visible window.
let cx = self.display_col(self.cursor.row, self.cursor.col);
if (top..bottom).contains(&self.cursor.row) && cx >= left && cx - left < text_w as usize {
let x = text_x + (cx - left) as u16;
let y = inner.y + (self.cursor.row - top) as u16;
frame.set_cursor_position((x, y));
}
}
/// Adjusts `scroll` so the cursor stays visible (vertical by row, horizontal by cell).
fn update_scroll(&mut self, rows: usize, text_w: usize) {
let (mut top, mut left) = self.scroll;
if self.cursor.row < top {
top = self.cursor.row;
} else if self.cursor.row >= top + rows {
top = self.cursor.row + 1 - rows;
}
let cx = self.display_col(self.cursor.row, self.cursor.col);
if cx < left {
left = cx;
} else if cx >= left + text_w {
left = cx + 1 - text_w;
}
self.scroll = (top, left);
}
/// Per-character foreground colors for lines `0..upto`, via syntect (empty if disabled).
fn highlight_through(&self, upto: usize) -> Vec<Vec<Color>> {
let Some(hl) = &self.highlight else {
return Vec::new();
};
let mut h = HighlightLines::new(&hl.syntax_ref, &hl.theme);
let mut out = Vec::with_capacity(upto);
for line in self.lines.iter().take(upto) {
// Feed a trailing newline so multi-line constructs keep state, then drop it.
let with_nl = format!("{line}\n");
let regions = h
.highlight_line(&with_nl, &hl.syntax_set)
.unwrap_or_default();
let mut colors = Vec::with_capacity(line.chars().count());
for (style, text) in regions {
let c = Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b);
colors.extend(text.chars().map(|_| c));
}
colors.truncate(line.chars().count());
out.push(colors);
}
out
}
/// Builds one display [`Line`]: per-char fg (from `colors`), selection background, and
/// tabs expanded to spaces. Width/clipping/scroll are left to the rendering `Paragraph`.
fn render_line(
&self,
row: usize,
colors: Option<&[Color]>,
sel: Option<(Pos, Pos)>,
) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut col_cells = 0; // running display column, for tab expansion
for (i, c) in self.lines[row].chars().enumerate() {
let fg = colors
.and_then(|cs| cs.get(i))
.copied()
.unwrap_or(DEFAULT_FG);
let mut style = Style::default().fg(fg);
if sel.is_some_and(|(a, b)| pos_in_range(row, i, a, b)) {
style = style.bg(SELECT_BG);
}
if c == '\t' {
let pad = TAB_WIDTH - (col_cells % TAB_WIDTH);
spans.push(Span::styled(" ".repeat(pad), style));
col_cells += pad;
} else {
spans.push(Span::styled(c.to_string(), style));
col_cells += char_cells(c, col_cells);
}
}
Line::from(spans)
}
}
/// Whether character `(row, col)` lies within ordered selection `[a, b)`.
fn pos_in_range(row: usize, col: usize, a: Pos, b: Pos) -> bool {
let after_start = row > a.row || (row == a.row && col >= a.col);
let before_end = row < b.row || (row == b.row && col < b.col);
after_start && before_end
}
/// Parses the embedded Rhai syntax and a default theme into reusable highlight parts.
/// Returns `None` (highlighting disabled, editor still works) on any failure.
fn build_highlight_parts() -> Option<HighlightParts> {
let syntax = SyntaxDefinition::load_from_str(RHAI_SYNTAX, true, None).ok()?;
let mut builder = SyntaxSetBuilder::new();
builder.add(syntax);
let syntax_set = builder.build();
let syntax_ref = syntax_set.find_syntax_by_extension("rhai")?.clone();
let theme = ThemeSet::load_defaults().themes.get(THEME_NAME)?.clone();
Some(HighlightParts {
theme,
syntax_ref,
syntax_set: Arc::new(syntax_set),
})
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyEvent;
/// Sends a bare key code to the editor.
fn send(ed: &mut CodeEditor, code: KeyCode) {
ed.handle_event(&Event::Key(KeyEvent::new(code, KeyModifiers::NONE)));
}
/// Sends a key code with modifiers (e.g. ctrl/shift).
fn send_mod(ed: &mut CodeEditor, code: KeyCode, mods: KeyModifiers) {
ed.handle_event(&Event::Key(KeyEvent::new(code, mods)));
}
/// Types each character of `s` in order.
fn typ(ed: &mut CodeEditor, s: &str) {
for c in s.chars() {
send(ed, KeyCode::Char(c));
}
}
#[test]
fn embedded_rhai_syntax_parses_and_theme_loads() {
assert!(build_highlight_parts().is_some());
}
#[test]
fn text_round_trips_and_typing_inserts() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "let x = 1;");
assert_eq!(ed.text(), "let x = 1;");
assert_eq!(CodeEditor::new("s", "a\nb\nc").text(), "a\nb\nc");
}
#[test]
fn left_at_col0_wraps_to_previous_line_end() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::Down); // (1,0)
send(&mut ed, KeyCode::Left); // should wrap to end of line 0 = (0,2)
typ(&mut ed, "X");
assert_eq!(ed.text(), "abX\ncd");
}
#[test]
fn right_at_line_end_wraps_down() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::End); // (0,2)
send(&mut ed, KeyCode::Right); // wrap to (1,0)
typ(&mut ed, "X");
assert_eq!(ed.text(), "ab\nXcd");
}
#[test]
fn backspace_at_col0_joins_lines() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::Down); // (1,0)
send(&mut ed, KeyCode::Backspace);
assert_eq!(ed.text(), "abcd");
typ(&mut ed, "X"); // cursor should sit at the join (0,2)
assert_eq!(ed.text(), "abXcd");
}
#[test]
fn shift_selection_then_delete_removes_it() {
let mut ed = CodeEditor::new("s", "abcd");
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
send(&mut ed, KeyCode::Backspace);
assert_eq!(ed.text(), "cd");
}
#[test]
fn bracketed_paste_replaces_selection() {
let mut ed = CodeEditor::new("s", "abcd");
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
ed.handle_event(&Event::Paste("XY\nZ".to_string()));
assert_eq!(ed.text(), "XY\nZcd");
}
#[test]
fn copy_then_paste_round_trips_via_internal_clipboard() {
// Works even headless: the internal buffer backs copy/paste when there's no display.
let mut ed = CodeEditor::new("s", "abc");
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
send_mod(&mut ed, KeyCode::Char('c'), KeyModifiers::CONTROL); // copy
send(&mut ed, KeyCode::End); // clear selection, go to end
send_mod(&mut ed, KeyCode::Char('v'), KeyModifiers::CONTROL); // paste
assert_eq!(ed.text(), "abcab");
}
#[test]
fn undo_and_redo_round_trip() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "abc"); // coalesces to one undo step
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
assert_eq!(ed.text(), "");
send_mod(
&mut ed,
KeyCode::Char('z'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(ed.text(), "abc");
}
#[test]
fn movement_breaks_undo_coalescing() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "ab");
send(&mut ed, KeyCode::Left); // breaks the typing group
typ(&mut ed, "c"); // "acb" (inserted before 'b')... actually at (0,1): "acb"
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
assert_eq!(ed.text(), "ab"); // only the post-move insert is undone
}
/// Sends a key code with modifiers and types a marker, returning where it landed.
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
#[test]
fn ctrl_right_jumps_to_next_word_start() {
let mut ed = CodeEditor::new("s", "foo bar baz"); // cursor at (0,0) via Home
send(&mut ed, KeyCode::Home);
ed.handle_event(&Event::Key(ctrl(KeyCode::Right)));
typ(&mut ed, "|"); // insert marker at the landing column
assert_eq!(ed.text(), "foo |bar baz"); // landed at start of "bar"
}
#[test]
fn ctrl_left_jumps_to_previous_word_start() {
let mut ed = CodeEditor::new("s", "foo bar baz");
send(&mut ed, KeyCode::End); // move to end (0,11) first
ed.handle_event(&Event::Key(ctrl(KeyCode::Left)));
typ(&mut ed, "|");
assert_eq!(ed.text(), "foo bar |baz"); // landed at start of "baz"
}
#[test]
fn ctrl_right_wraps_at_end_of_line() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::End); // (0,2) end of first line
ed.handle_event(&Event::Key(ctrl(KeyCode::Right)));
typ(&mut ed, "X"); // should land at start of line 2
assert_eq!(ed.text(), "ab\nXcd");
}
#[test]
fn ctrl_down_and_up_jump_to_blank_lines() {
let mut ed = CodeEditor::new("s", "alpha\nbeta\n\ngamma");
send(&mut ed, KeyCode::Home); // (0,0)
ed.handle_event(&Event::Key(ctrl(KeyCode::Down))); // → blank line (row 2)
typ(&mut ed, "X");
assert_eq!(ed.text(), "alpha\nbeta\nX\ngamma");
// From the blank line, ctrl+Up returns to the top (no blank above).
send(&mut ed, KeyCode::Home);
ed.handle_event(&Event::Key(ctrl(KeyCode::Up)));
typ(&mut ed, "Y");
assert_eq!(ed.text(), "Yalpha\nbeta\nX\ngamma");
}
#[test]
fn shift_ctrl_right_selects_a_word() {
let mut ed = CodeEditor::new("s", "foo bar");
send(&mut ed, KeyCode::Home);
ed.handle_event(&Event::Key(KeyEvent::new(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
))); // select "foo "
send(&mut ed, KeyCode::Backspace); // delete the selection
assert_eq!(ed.text(), "bar");
}
#[test]
fn esc_requests_exit() {
let mut ed = CodeEditor::new("s", "x");
assert!(matches!(
ed.handle_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))),
CodeEditorOutcome::Exit
));
}
#[test]
fn cmd_z_maps_to_ctrl_undo() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "hi");
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::SUPER);
assert_eq!(ed.text(), "");
}
}
-508
View File
@@ -1,508 +0,0 @@
//! Self-contained dialog overlays: a [text dialog](Dialog::text) (single free-text
//! field) and a [list dialog](Dialog::list) (a filterable, arrow-selectable list).
//!
//! Both are [`Dialog<Ctx>`] values generic over a host context type (the front-end
//! supplies its own; the editor uses its `EditorState`). While a dialog is open the
//! host routes all input to it; when dismissed it fires a stored callback with the
//! result **and** `&mut Ctx`, so the callback can apply the choice to host state. The
//! text/filter field is a [`TextField`](crate::text_field::TextField) (the same in-house
//! single-line editor used elsewhere in kiln-ui).
//!
//! Drawing is the [`CursorOverlay`] impl: it paints into a buffer and returns where
//! the active text field's caret should go, so the host can place the real terminal
//! cursor (see [`crate::render_overlay`]).
use crate::text_field::TextField;
use crate::{CursorOverlay, dim_area};
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode};
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
/// Background color filling a dialog window.
const BG: Color = Color::Rgb(10, 15, 35);
/// Border color for a dialog window.
const BORDER: Color = Color::Rgb(80, 130, 210);
/// Color for the currently selected list entry's background.
const SELECT_BG: Color = Color::Rgb(40, 60, 110);
/// Background color of an editable text field, so it reads as an input box.
const FIELD_BG: Color = Color::Rgb(35, 45, 70);
/// The outcome a list dialog reports to its callback.
#[derive(Clone, Debug, PartialEq)]
pub enum ListDialogResponse {
/// The player chose an existing entry from the list.
Select(String),
/// The player typed a new value and confirmed it (only possible when the list
/// dialog was opened with `allow_create = true`). No current caller enables
/// creation, so the payload is unused for now — kept as part of the dialog API.
#[allow(dead_code)]
Create(String),
/// The player dismissed the dialog with `Esc`.
Cancel,
}
/// What kind of field a dialog shows, plus its associated state.
enum DialogBody {
/// A single free-text field.
Text { field: TextField },
/// A filter field above a selectable list. `selected` indexes the *filtered*
/// view (entries of `all` whose text starts with the filter).
List {
field: TextField,
all: Vec<String>,
selected: usize,
allow_create: bool,
},
}
/// Boxed callback for a text dialog: `Some(value)` on OK, `None` on Cancel.
type TextCallback<Ctx> = Box<dyn FnOnce(Option<String>, &mut Ctx)>;
/// Boxed callback for a list dialog: see [`ListDialogResponse`].
type ListCallback<Ctx> = Box<dyn FnOnce(ListDialogResponse, &mut Ctx)>;
/// The callback fired once when a dialog is dismissed, paired by body kind.
enum DialogCallback<Ctx> {
/// Text-dialog callback.
Text(TextCallback<Ctx>),
/// List-dialog callback.
List(ListCallback<Ctx>),
}
/// How a key event affected an open dialog.
pub enum DialogResult {
/// The dialog stays open; nothing more to do.
Continue,
/// The player confirmed (`Enter` with a valid choice) — fire the callback with the value.
Submit,
/// The player cancelled (`Esc`) — fire the callback with the cancel result.
Cancel,
}
/// An open dialog overlay, generic over the host context `Ctx` its callback mutates.
pub struct Dialog<Ctx> {
/// Title shown in the window's top border.
title: String,
/// The field kind + state.
body: DialogBody,
/// The callback fired exactly once on dismissal.
callback: DialogCallback<Ctx>,
}
impl<Ctx> Dialog<Ctx> {
/// Builds a text dialog. `initial` pre-fills the field; `on_done` receives
/// `Some(value)` on OK or `None` on Cancel, plus `&mut Ctx`.
pub fn text(
title: impl Into<String>,
initial: impl Into<String>,
on_done: impl FnOnce(Option<String>, &mut Ctx) + 'static,
) -> Self {
Self {
title: title.into(),
body: DialogBody::Text {
field: TextField::new(initial),
},
callback: DialogCallback::Text(Box::new(on_done)),
}
}
/// Builds a list dialog over `items`. Typing filters the list to entries starting
/// with the typed text; arrow keys move the selection. When `allow_create` is true
/// the player may confirm typed text that matches nothing (→ [`ListDialogResponse::Create`]);
/// otherwise OK is disabled unless a list entry is selected. `on_done` receives the
/// [`ListDialogResponse`] plus `&mut Ctx`.
pub fn list(
title: impl Into<String>,
items: Vec<String>,
allow_create: bool,
on_done: impl FnOnce(ListDialogResponse, &mut Ctx) + 'static,
) -> Self {
Self {
title: title.into(),
body: DialogBody::List {
field: TextField::new(""),
all: items,
selected: 0,
allow_create,
},
callback: DialogCallback::List(Box::new(on_done)),
}
}
/// Updates dialog state for an input event and reports whether it should close.
///
/// `Esc` → [`DialogResult::Cancel`]; `Enter` → [`DialogResult::Submit`] only when OK
/// is enabled (always for text; for a list, when a valid entry is selected or
/// `allow_create` and the field is non-empty). Arrow keys move a list's selection;
/// any other key edits the field (and re-clamps a list's selection to the new filter).
pub fn handle_event(&mut self, event: &Event) -> DialogResult {
let Event::Key(key) = event else {
return DialogResult::Continue;
};
match key.code {
KeyCode::Esc => DialogResult::Cancel,
KeyCode::Enter => {
if self.ok_enabled() {
DialogResult::Submit
} else {
DialogResult::Continue
}
}
// Arrow up/down only mean something for a list (move selection within the filtered view).
KeyCode::Up | KeyCode::Down => {
// Compute the filtered length (immutable borrow) before taking the
// mutable borrow of `selected`.
let n = self.filtered_len();
if let DialogBody::List { selected, .. } = &mut self.body
&& n > 0
{
// Wrap-free clamp: up decrements, down increments, bounded to [0, n-1].
if matches!(key.code, KeyCode::Up) {
*selected = selected.saturating_sub(1);
} else {
*selected = (*selected + 1).min(n - 1);
}
}
DialogResult::Continue
}
// Everything else edits the text/filter field.
_ => {
if self.field_mut().handle_key(*key) {
// The filter may have changed, so the previous selection index may now
// be out of range — clamp it back (compute length first, then clamp).
let n = self.filtered_len();
if let DialogBody::List { selected, .. } = &mut self.body {
*selected = (*selected).min(n.saturating_sub(1));
}
}
DialogResult::Continue
}
}
}
/// Consumes the dialog and fires its callback. `result` must be the
/// [`DialogResult::Submit`] or [`DialogResult::Cancel`] just returned by
/// [`handle_event`](Dialog::handle_event); `Continue` is treated as cancel.
pub fn finish(self, result: DialogResult, ctx: &mut Ctx) {
let submit = matches!(result, DialogResult::Submit);
match (self.body, self.callback) {
(DialogBody::Text { field }, DialogCallback::Text(cb)) => {
cb(submit.then(|| field.value().to_string()), ctx);
}
(
DialogBody::List {
field,
all,
selected,
allow_create,
},
DialogCallback::List(cb),
) => {
let resp = if !submit {
ListDialogResponse::Cancel
} else if let Some(entry) = filtered_items(&all, field.value())
.into_iter()
.nth(selected)
{
ListDialogResponse::Select(entry.clone())
} else if allow_create {
ListDialogResponse::Create(field.value().to_string())
} else {
ListDialogResponse::Cancel
};
cb(resp, ctx);
}
// The body and callback are always constructed as a matching pair.
_ => unreachable!("dialog body/callback kinds always match"),
}
}
/// Whether the OK action is currently allowed (controls Enter + label styling).
fn ok_enabled(&self) -> bool {
match &self.body {
DialogBody::Text { .. } => true,
DialogBody::List {
field,
allow_create,
..
} => self.filtered_len() > 0 || (*allow_create && !field.value().is_empty()),
}
}
/// Mutable access to whichever [`TextField`] this dialog owns.
fn field_mut(&mut self) -> &mut TextField {
match &mut self.body {
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
}
}
/// Number of list entries currently passing the filter (0 for a text dialog).
fn filtered_len(&self) -> usize {
filtered_count(&self.body)
}
}
impl<Ctx> CursorOverlay for Dialog<Ctx> {
/// Draws the dialog centered over `area`: dims the background, then renders the
/// bordered window, the field (centered for a text dialog, atop the list for a
/// list dialog), and the OK/Cancel labels. Returns the active field's caret
/// position so the host can place the real terminal cursor.
fn render(&self, area: Rect, buf: &mut Buffer) -> Option<Position> {
dim_area(area, buf);
// Size the window: a list dialog is taller to show its entries.
let is_list = matches!(self.body, DialogBody::List { .. });
let want_w = 50u16;
let want_h = if is_list { 16 } else { 8 };
let w = want_w.min(area.width.saturating_sub(2)).max(8);
let h = want_h.min(area.height.saturating_sub(2)).max(5);
let rect = Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
};
// Window frame: clear underlying content, then a bordered titled block.
Clear.render(rect, buf);
let block = Block::bordered()
.title(format!(" {} ", self.title))
.border_style(Style::default().fg(BORDER).bg(BG))
.style(Style::default().bg(BG));
let inner = block.inner(rect);
block.render(rect, buf);
// Carve the footer off the bottom; the rest is the body (field [+ list]).
let [body_area, footer_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
let field = match &self.body {
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
};
// The body draws the field(s) and reports the caret position.
let cursor = if let DialogBody::List {
all,
selected,
field,
..
} = &self.body
{
// List dialog: the filter field sits atop the scrolling list.
let [field_area, list_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(body_area);
let cursor = draw_field(buf, field_area, field);
draw_list(buf, list_area, all, field.value(), *selected);
cursor
} else {
// Text dialog: a single field box, centered horizontally and vertically.
let fw = body_area.width.saturating_sub(4).max(1);
let field_area = Rect {
x: body_area.x + body_area.width.saturating_sub(fw) / 2,
y: body_area.y + body_area.height / 2,
width: fw,
height: 1,
};
draw_field(buf, field_area, field)
};
// Footer: [enter] OK (dimmed when disabled) and [esc] Cancel.
let ok_style = if self.ok_enabled() {
Style::default().fg(Color::White).bg(BG)
} else {
Style::default().fg(Color::DarkGray).bg(BG)
};
let key_style = Style::default().fg(Color::Rgb(150, 200, 255)).bg(BG);
let footer = Line::from(vec![
Span::styled("[enter] ", key_style),
Span::styled("OK ", ok_style),
Span::styled("[esc] ", key_style),
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
]);
Paragraph::new(footer).render(footer_area, buf);
cursor
}
}
/// Lets a `&Dialog` be drawn with `frame.render_widget` where the caret isn't needed;
/// the caret position from [`CursorOverlay::render`] is discarded. Use
/// [`render_overlay`](crate::render_overlay) when the terminal cursor must be placed.
impl<Ctx> Widget for &Dialog<Ctx> {
fn render(self, area: Rect, buf: &mut Buffer) {
CursorOverlay::render(self, area, buf);
}
}
/// Counts the entries passing a list body's current filter (0 for a text body).
fn filtered_count(body: &DialogBody) -> usize {
match body {
DialogBody::List { all, field, .. } => filtered_items(all, field.value()).len(),
DialogBody::Text { .. } => 0,
}
}
/// Returns references to the entries of `all` that start with `filter` (prefix match).
fn filtered_items<'a>(all: &'a [String], filter: &str) -> Vec<&'a String> {
all.iter().filter(|s| s.starts_with(filter)).collect()
}
/// Renders an editable text field into `area`: a distinct [`FIELD_BG`] background
/// (so it reads as an input box) and the current value. Returns the caret position
/// (the field's display cursor column) for the host to place the terminal cursor, or
/// `None` if the area has no width.
fn draw_field(buf: &mut Buffer, area: Rect, field: &TextField) -> Option<Position> {
if area.width == 0 {
return None;
}
// The paragraph's style fills the whole field area, including empty cells.
Paragraph::new(field.value())
.style(Style::default().fg(Color::White).bg(FIELD_BG))
.render(area, buf);
let cur_x = area.x + (field.display_cursor() as u16).min(area.width.saturating_sub(1));
Some(Position::new(cur_x, area.y))
}
/// Renders the filtered list into `area`, highlighting `selected` and scrolling so
/// the selection stays visible.
fn draw_list(buf: &mut Buffer, area: Rect, all: &[String], filter: &str, selected: usize) {
if area.height == 0 {
return;
}
let items = filtered_items(all, filter);
let rows = area.height as usize;
// Scroll so the selected row is within the visible window.
let top = selected
.saturating_sub(rows.saturating_sub(1))
.min(items.len().saturating_sub(rows.max(1)));
let lines: Vec<Line> = items
.iter()
.enumerate()
.skip(top)
.take(rows)
.map(|(i, s)| {
let style = if i == selected {
Style::default().fg(Color::White).bg(SELECT_BG)
} else {
Style::default().fg(Color::Gray).bg(BG)
};
Line::from(Span::styled(s.as_str(), style))
})
.collect();
Paragraph::new(lines)
.style(Style::default().bg(BG))
.render(area, buf);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
/// Builds a key-press event for a given key code.
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
/// Three sample list entries; "alpha"/"alps" share the "al" prefix.
fn items() -> Vec<String> {
vec!["alpha".into(), "beta".into(), "alps".into()]
}
#[test]
fn typing_filters_by_prefix_and_clamps_selection() {
let mut d: Dialog<()> = Dialog::list("t", items(), false, |_, _| {});
// No filter: all three entries pass.
assert_eq!(d.filtered_len(), 3);
// Move selection to the last entry, then filter so fewer remain.
d.handle_event(&key(KeyCode::Down));
d.handle_event(&key(KeyCode::Down)); // selected = 2
d.handle_event(&key(KeyCode::Char('a'))); // filter "a" -> alpha, alps
assert_eq!(d.filtered_len(), 2);
// The out-of-range selection is clamped back into the filtered list.
let DialogBody::List { selected, .. } = &d.body else {
unreachable!()
};
assert_eq!(*selected, 1);
}
#[test]
fn ok_disabled_when_no_match_and_create_not_allowed() {
let mut d: Dialog<()> = Dialog::list("t", items(), false, |_, _| {});
for c in "zzz".chars() {
d.handle_event(&key(KeyCode::Char(c)));
}
assert_eq!(d.filtered_len(), 0);
assert!(!d.ok_enabled());
// Enter is ignored while OK is disabled.
assert!(matches!(
d.handle_event(&key(KeyCode::Enter)),
DialogResult::Continue
));
}
#[test]
fn list_submit_selects_highlighted_entry() {
let mut out: Option<ListDialogResponse> = None;
let mut d: Dialog<Option<ListDialogResponse>> =
Dialog::list("t", items(), false, |r, c| *c = Some(r));
d.handle_event(&key(KeyCode::Down)); // select index 1 ("beta")
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut out);
assert!(matches!(out, Some(ListDialogResponse::Select(s)) if s == "beta"));
}
#[test]
fn list_submit_creates_when_allowed_and_no_match() {
let mut out: Option<ListDialogResponse> = None;
let mut d: Dialog<Option<ListDialogResponse>> =
Dialog::list("t", items(), true, |r, c| *c = Some(r));
for c in "gamma".chars() {
d.handle_event(&key(KeyCode::Char(c)));
}
assert!(d.ok_enabled());
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut out);
assert!(matches!(out, Some(ListDialogResponse::Create(s)) if s == "gamma"));
}
#[test]
fn esc_yields_cancel_for_list() {
let mut out: Option<ListDialogResponse> = None;
let d: Dialog<Option<ListDialogResponse>> =
Dialog::list("t", items(), false, |r, c| *c = Some(r));
d.finish(DialogResult::Cancel, &mut out);
assert!(matches!(out, Some(ListDialogResponse::Cancel)));
}
#[test]
fn text_dialog_reports_value_on_submit_and_none_on_cancel() {
// Submit returns the edited value.
let mut got: Option<Option<String>> = None;
let mut d: Dialog<Option<Option<String>>> = Dialog::text("t", "hi", |v, c| *c = Some(v));
d.handle_event(&key(KeyCode::Char('!')));
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut got);
assert_eq!(got, Some(Some("hi!".to_string())));
// Cancel returns None.
let mut got2: Option<Option<String>> = None;
let d2: Dialog<Option<Option<String>>> = Dialog::text("t", "hi", |v, c| *c = Some(v));
d2.finish(DialogResult::Cancel, &mut got2);
assert_eq!(got2, Some(None));
}
#[test]
fn render_is_buffer_only_and_reports_cursor() {
use ratatui::buffer::Buffer;
// The split lets us render headless into a plain Buffer (no Frame/TTY); a text
// dialog always wants its caret shown, inside the drawn window.
let area = Rect::new(0, 0, 60, 25);
let mut buf = Buffer::empty(area);
let d: Dialog<()> = Dialog::text("t", "hi", |_, _| {});
let pos = CursorOverlay::render(&d, area, &mut buf).expect("text dialog wants a cursor");
assert!(pos.x < area.width && pos.y < area.height);
}
}
-68
View File
@@ -1,68 +0,0 @@
//! `kiln-ui` — reusable, game-agnostic terminal UI widgets built on [`ratatui`].
//!
//! This crate is the home for kiln's front-end UI widgets, kept deliberately separate
//! from game concerns:
//!
//! - **Widget responsibilities only.** Each widget owns its *presentation* (drawing)
//! and *input handling*; it never references game/world types. No dependency on
//! `kiln-core`.
//! - **Generic over a host context.** A widget that needs to apply a result mutates a
//! caller-supplied context `Ctx` *only through callbacks* (e.g. [`dialog::Dialog<Ctx>`]),
//! so the front-end decides what a choice does without the widget knowing about it.
//! - **The host drives the loop.** Widgets expose plain `handle_event` / `draw` entry
//! points; the front-end owns the event loop, decides when a widget is focused, and
//! routes input to it.
//!
//! This is also where a full-screen script text-editor widget will live (wrapping a
//! third-party editor crate) once that work begins.
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Color, Style};
mod clipboard;
pub mod code_editor;
pub mod dialog;
pub mod glyph_dialog;
mod text;
pub mod text_field;
/// Dims every cell in `area` so a modal overlay drawn on top stands out and the
/// content beneath does not show through.
///
/// Shared scaffolding: used by [`dialog`] here, and re-borrowed by kiln-tui's
/// overlay-frame renderer so both modal styles dim the background identically.
pub fn dim_area(area: Rect, buf: &mut Buffer) {
let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black);
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(dim_style);
}
}
}
}
/// A modal overlay that paints itself into a [`Buffer`] and may want the terminal
/// cursor placed at a position (e.g. the caret in a dialog's text field).
///
/// Painting is split from cursor placement because only a [`Frame`] can move the
/// real hardware cursor (a [`Widget`](ratatui::widgets::Widget)'s `render` sees just
/// a buffer). So [`render`](CursorOverlay::render) is buffer-only — and therefore
/// unit-testable headless — and *returns* where the caret goes; the host applies it
/// (via [`render_overlay`]).
pub trait CursorOverlay {
/// Paints the overlay into `buf` within `area`, returning the desired terminal
/// cursor position, or `None` if no cursor should be shown.
fn render(&self, area: Rect, buf: &mut Buffer) -> Option<Position>;
}
/// Paints a [`CursorOverlay`] over the whole frame and places the hardware cursor
/// if the overlay asked for one. The single place that needs a [`Frame`].
pub fn render_overlay(frame: &mut Frame, overlay: &impl CursorOverlay) {
let area = frame.area();
if let Some(pos) = overlay.render(area, frame.buffer_mut()) {
frame.set_cursor_position(pos);
}
}
-71
View File
@@ -1,71 +0,0 @@
//! Small char/width/key helpers shared by the text widgets
//! ([`code_editor`](crate::code_editor), [`text_field`](crate::text_field)).
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
use unicode_width::UnicodeWidthChar;
/// Columns a tab advances to (tab stop).
pub(crate) const TAB_WIDTH: usize = 4;
/// Byte offset of character index `col` within `s` (clamped to `s.len()`).
pub(crate) fn byte_of(s: &str, col: usize) -> usize {
s.char_indices().nth(col).map_or(s.len(), |(b, _)| b)
}
/// Display width of `c` at running display column `at` (tabs advance to the next
/// [`TAB_WIDTH`] stop; wide characters are two cells; zero-width control chars are 0).
pub(crate) fn char_cells(c: char, at: usize) -> usize {
if c == '\t' {
TAB_WIDTH - (at % TAB_WIDTH)
} else {
UnicodeWidthChar::width(c).unwrap_or(0)
}
}
/// Display width (terminal cells) of the first `col` characters of `s`.
pub(crate) fn display_width(s: &str, col: usize) -> usize {
let mut w = 0;
for c in s.chars().take(col) {
w += char_cells(c, w);
}
w
}
/// Character index after moving one word forward from `col` in `line`: skip the current
/// run of non-whitespace, then the following whitespace (lands on the next word's start).
pub(crate) fn next_word(line: &str, col: usize) -> usize {
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
let mut i = col;
while i < n && !chars[i].is_whitespace() {
i += 1;
}
while i < n && chars[i].is_whitespace() {
i += 1;
}
i
}
/// Character index after moving one word backward from `col` in `line`: skip whitespace,
/// then the preceding word (lands on that word's start).
pub(crate) fn prev_word(line: &str, col: usize) -> usize {
let chars: Vec<char> = line.chars().collect();
let mut i = col;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
i
}
/// Rewrites a `⌘`-modified key to its `ctrl` equivalent (terminals report Super for Cmd
/// under the kitty protocol; edtui/our bindings key off ctrl).
pub(crate) fn super_to_ctrl(mut key: KeyEvent) -> KeyEvent {
if key.modifiers.contains(KeyModifiers::SUPER) {
key.modifiers.remove(KeyModifiers::SUPER);
key.modifiers.insert(KeyModifiers::CONTROL);
}
key
}
-268
View File
@@ -1,268 +0,0 @@
//! A single-line editable text field — a one-line sibling of [`code_editor`](crate::code_editor),
//! used by [`dialog`](crate::dialog) for its text and list-filter inputs.
//!
//! Presentation is left to the caller (it reads [`value`](TextField::value) and
//! [`display_cursor`](TextField::display_cursor)); the field only owns the text, the cursor,
//! and a [`Clipboard`] for paste. No newlines, no selection (yet) — deliberately minimal.
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::clipboard::Clipboard;
use crate::text::{byte_of, display_width, next_word, prev_word, super_to_ctrl};
/// A single-line text input with a character-indexed cursor.
pub struct TextField {
/// The current text (never contains newlines).
text: String,
/// Cursor position as a character index in `0..=text.chars().count()`.
cursor: usize,
/// Clipboard for `ctrl`/`⌘`+`v` paste.
clipboard: Clipboard,
/// How long we will allow the string to be:
max_length: Option<usize>,
}
impl TextField {
/// Creates a field pre-filled with `initial`, cursor at the end.
pub fn new(initial: impl Into<String>) -> Self {
let text = initial.into();
let cursor = text.chars().count();
Self {
text,
cursor,
clipboard: Clipboard::new(),
max_length: None,
}
}
pub fn sized(initial: impl Into<String>, max_length: usize) -> Self {
let mut text = initial.into();
text.truncate(max_length);
let cursor = text.chars().count();
Self {
text,
cursor,
clipboard: Clipboard::new(),
max_length: Some(max_length),
}
}
/// The current text.
pub fn value(&self) -> &str {
&self.text
}
/// Replaces the entire text (truncated to `max_length` if set), moving the cursor
/// to the end. Used to overwrite the field programmatically — e.g. when a color
/// picker selects a named swatch and fills in its hex.
pub fn set(&mut self, text: impl Into<String>) {
let mut text = text.into();
if let Some(max) = self.max_length {
text.truncate(max);
}
self.cursor = text.chars().count();
self.text = text;
}
/// The cursor's display column (terminal cells from the start), for placing the caret.
pub fn display_cursor(&self) -> usize {
display_width(&self.text, self.cursor)
}
/// Number of characters in the field.
fn len(&self) -> usize {
self.text.chars().count()
}
/// Handles a key event. Returns `true` if it was consumed (edited or moved the cursor).
///
/// Editing keys only: printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves),
/// Home/End, and `ctrl`/`⌘`+`v` paste. Esc/Enter/Up/Down are left to the caller.
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
let key = super_to_ctrl(key);
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Char('v') if ctrl => self.paste(),
KeyCode::Char(_) if ctrl => return false, // other chords aren't ours
KeyCode::Char(c) if !alt => self.insert(c),
KeyCode::Backspace => return self.backspace(),
KeyCode::Delete => return self.delete_forward(),
KeyCode::Left if ctrl => self.cursor = prev_word(&self.text, self.cursor),
KeyCode::Right if ctrl => self.cursor = next_word(&self.text, self.cursor),
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len()),
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.len(),
_ => return false,
}
true
}
fn insert(&mut self, c: char) {
let b = byte_of(&self.text, self.cursor);
if self
.max_length
.is_none_or(|max_length| max_length > self.text.len())
{
self.text.insert(b, c);
}
if self
.max_length
.is_none_or(|max_length| max_length > self.cursor)
{
self.cursor += 1;
}
}
fn backspace(&mut self) -> bool {
if self.cursor == 0 {
return false;
}
let b = byte_of(&self.text, self.cursor - 1);
self.text.remove(b);
self.cursor -= 1;
true
}
fn delete_forward(&mut self) -> bool {
if self.cursor >= self.len() {
return false;
}
let b = byte_of(&self.text, self.cursor);
self.text.remove(b);
true
}
/// Inserts clipboard text at the cursor, with any newlines stripped (single-line).
fn paste(&mut self) {
let clean: String = self
.clipboard
.get()
.chars()
.filter(|c| !matches!(c, '\n' | '\r'))
.collect();
let b = byte_of(&self.text, self.cursor);
self.text.insert_str(b, &clean);
self.cursor += clean.chars().count();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn typing_inserts_and_value_reflects_it() {
let mut f = TextField::new("");
for c in "abc".chars() {
assert!(f.handle_key(key(KeyCode::Char(c))));
}
assert_eq!(f.value(), "abc");
assert_eq!(f.display_cursor(), 3);
}
#[test]
fn backspace_and_delete() {
let mut f = TextField::new("abc"); // cursor at end (3)
assert!(f.handle_key(key(KeyCode::Backspace)));
assert_eq!(f.value(), "ab");
f.handle_key(key(KeyCode::Home));
assert!(f.handle_key(key(KeyCode::Delete)));
assert_eq!(f.value(), "b");
// Nothing to delete at the boundaries.
assert!(!TextField::new("").handle_key(key(KeyCode::Backspace)));
}
#[test]
fn cursor_movement_inserts_at_right_place() {
let mut f = TextField::new("ac");
f.handle_key(key(KeyCode::Left)); // between a and c
f.handle_key(key(KeyCode::Char('b')));
assert_eq!(f.value(), "abc");
f.handle_key(key(KeyCode::Home));
f.handle_key(key(KeyCode::Char('X')));
assert_eq!(f.value(), "Xabc");
}
#[test]
fn ctrl_v_pastes_clipboard_with_newlines_stripped() {
let mut f = TextField::new("");
f.clipboard.set("multi\nline".to_string());
assert!(f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)));
assert_eq!(f.value(), "multiline");
}
#[test]
fn cmd_v_maps_to_ctrl_v() {
let mut f = TextField::new("");
f.clipboard.set("hi".to_string());
f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER));
assert_eq!(f.value(), "hi");
}
#[test]
fn display_cursor_counts_wide_chars() {
let mut f = TextField::new(""); // one double-width char, cursor at end
assert_eq!(f.display_cursor(), 2);
f.handle_key(key(KeyCode::Home));
assert_eq!(f.display_cursor(), 0);
}
#[test]
fn sized_truncates_overlong_initial_value() {
// `sized` clamps the seed text to the max length up front.
let f = TextField::sized("FF0000extra", 6);
assert_eq!(f.value(), "FF0000");
}
#[test]
fn max_length_caps_typed_input() {
// Typing into a sized field fills up to the cap, then further input is dropped.
let mut f = TextField::sized("", 3);
for c in "abcd".chars() {
f.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(f.value(), "abc");
// A field seeded at its cap ignores further typing (the glyph picker's hex fields).
let mut g = TextField::sized("FF0000", 6);
g.handle_key(key(KeyCode::Char('9')));
assert_eq!(g.value(), "FF0000");
// An unsized field is unbounded.
let mut u = TextField::new("");
for c in "abcdef".chars() {
u.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(u.value(), "abcdef");
}
#[test]
fn set_replaces_text_and_respects_max_length() {
let mut f = TextField::sized("FF0000", 6);
f.set("00AA00");
assert_eq!(f.value(), "00AA00");
assert_eq!(f.display_cursor(), 6); // cursor at end
// Overlong replacements are truncated to the cap.
f.set("ABCDEFGH");
assert_eq!(f.value(), "ABCDEF");
// An unsized field accepts any length.
let mut u = TextField::new("x");
u.set("anything goes");
assert_eq!(u.value(), "anything goes");
}
#[test]
fn unhandled_keys_return_false() {
let mut f = TextField::new("x");
assert!(!f.handle_key(key(KeyCode::Enter)));
assert!(!f.handle_key(key(KeyCode::Up)));
assert!(!f.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
}
}
+64
View File
@@ -0,0 +1,64 @@
[world]
name = "Dark Maze"
start = "cave"
# The player's own torch is small, so external lights clearly matter. Remove this
# line to fall back to the default radius of 10.
torch = 4
[scripts]
# An always-talking object. Placed behind a wall so it starts out of the player's
# field of view — its bubble should draw with NO tail until you can see it.
hidden = """
fn tick(me, dt) {
if !me.waiting {
say("Who's there?");
delay(2);
}
}
"""
# A talker placed in the open near the player, so its bubble keeps its tail.
lantern = """
fn init(me) {
say("A warm light\\nflickers here.");
}
fn bump(me, _dir) {
say("The lantern\\nglows steadily.");
}
"""
[boards.cave.map]
name = "The Cave"
width = 40
height = 15
dark = true
floor = { generator = "stone" }
# The 'H' object glows magenta inside a walled chamber: its light fills that room
# but the player can't see in until they line up with the gap in the wall. 'L' is
# an amber lantern out in the open, and 'T' is a wall torch (glowing terrain) off
# in a far corner — a warm point of light you'll only reach by walking to it.
[boards.cave.grid]
content = """
########################################
# #
# #
# ########### #
# # # #
# @ # H # L #
# # # #
# #### ###### #
# #
# #
# T #
# #
# #
# #
########################################
"""
[boards.cave.grid.palette]
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" }
"@" = { kind = "player" }
"T" = { kind = "torch" } # glowing terrain: emits its own warm light
"H" = { kind = "object", tile = 2, fg = "#cc66cc", bg = "#000000", solid = true, light = 5, name = "hidden", script_name = "hidden" }
"L" = { kind = "object", tile = 15, fg = "#ffcc55", bg = "#000000", solid = true, light = 6, name = "lantern", script_name = "lantern" }
+73 -99
View File
@@ -6,65 +6,63 @@ start = "start"
# Objects reference a script by name via `script_name`. # Objects reference a script by name via `script_name`.
[scripts] [scripts]
greeter = """ greeter = """
fn init() { fn init(me) {
log(`hello from object player at ${Board.player_x}, ${Board.player_y}`); log(`hello from object player at ${Player.x}, ${Player.y}`);
set_tile(2); // change my glyph to proves the write path set_tile(2); // change my glyph to proves the write path
say("Hello there,\\ntraveller!"); say("Hello there,\\ntraveller!");
log(`Player health: ${Player.health}`);
} }
fn tick(dt) { fn enter(me, dir) {
//log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`); if Player.x == me.x && Player.y == me.y && !me.waiting {
if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 {
say("Hey! Get offa me!"); say("Hey! Get offa me!");
delay(5.0); me.delay(5.0);
} }
} }
""" """
mover = """ mover = """
fn init() { fn init(me) {
if Registry.get("dancer_x") != () { log(`q: ${me.queue.length}`);
let new_x = Registry.get("dancer_x"); if Board.registry.get("dancer_x") != () {
let new_y = Registry.get("dancer_y"); let new_x = Board.registry.get("dancer_x");
if Me.x != new_x || Me.y != new_y { let new_y = Board.registry.get("dancer_y");
teleport(new_x, new_y); if me.x != new_x || me.y != new_y {
teleport(me.id, new_x, new_y);
} }
} else { } else {
Registry.set("dancer_x", Me.x); Board.registry.set("dancer_x", me.x);
Registry.set("dancer_y", Me.y); Board.registry.set("dancer_y", me.y);
log(`Set reg to ${Me.x} ${Me.y}`); log(`Set reg to ${me.x} ${me.y}`);
} }
} }
// move() costs 250 ms, so the object steps ~4 cells/sec no matter how often // move() costs 250 ms, so the object steps ~4 cells/sec no matter how often
// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking // tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking
// into a wall (or another object's already-queued move this tick). // into a wall (or another object's already-queued move this tick).
fn tick(dt) { fn tick(me, dt) {
if Queue.length() == 0 { dance(); }
}
fn dance() {
move(East); move(East);
move(South); move(South);
move(North); move(North);
move(East); move(East);
say("Hey!", 0.5); say("Hey!", 0.5);
delay(0.5); delay(me, 0.5);
move(West); move(West);
move(South); move(South);
move(North); move(North);
move(West); move(West);
say("Ho!", 0.5); say("Ho!", 0.5);
delay(0.5); me.delay(0.5);
} }
// Fires when something steps into this object; id is the bumper's object id, // Fires when a solid (the player, an object, or a pushed crate) presses into this
// or -1 for the player. // object; dir is the direction the bump came from.
fn bump(id) { fn bump(me, dir) {
log(`mover bumped by ${id}`); log(`mover bumped from ${dir}`);
say("Ow!"); say("Ow!");
} }
""" """
muffin = """ muffin = """
fn bump(id) { fn bump(me, _dir) {
scroll([ scroll([
"You find a small muffin on the ground, your favorite kind.", "You find a small muffin on the ground, your favorite kind.",
"It smells of cinnamon and warm mornings.", "It smells of cinnamon and warm mornings.",
@@ -72,9 +70,9 @@ fn bump(id) {
["ignore", "This is obviously a trap — ignore it."] ["ignore", "This is obviously a trap — ignore it."]
]); ]);
} }
fn eat() { fn eat(me) {
log("You ate the muffin. Delicious."); say("Yeah it was poisoned.");
say("Mmm!"); alter_health(-2);
} }
fn ignore() { fn ignore() {
log("You walk away from the muffin. Wise."); log("You walk away from the muffin. Wise.");
@@ -83,7 +81,7 @@ fn ignore() {
""" """
noticeboard = """ noticeboard = """
fn bump(id) { fn bump(me, _dir) {
scroll([ scroll([
" TOWN NOTICE BOARD", " TOWN NOTICE BOARD",
" ", " ",
@@ -110,13 +108,13 @@ fn bump(id) {
"dusk. All residents are warmly encouraged to attend.", "dusk. All residents are warmly encouraged to attend.",
" ", " ",
" Posted by order of the Town Council.", " Posted by order of the Town Council.",
" Unauthorised notices will be removed.", " Unauthorized notices will be removed.",
]); ]);
} }
""" """
bookshelf = """ bookshelf = """
fn bump(id) { fn bump(me, _dir) {
scroll([ scroll([
" THE BOOKSHELF", " THE BOOKSHELF",
" ", " ",
@@ -135,13 +133,13 @@ fn bump(id) {
""" """
fireplace = """ fireplace = """
fn bump(id) { fn bump(me, _dir) {
say("The fire crackles warmly.\\nYou feel at ease."); say("The fire crackles warmly.\\nYou feel at ease.");
} }
""" """
chest = """ chest = """
fn bump(id) { fn bump(me, _dir) {
scroll([ scroll([
" THE CHEST", " THE CHEST",
" ", " ",
@@ -159,70 +157,45 @@ fn bump(id) {
""" """
yammerer = """ yammerer = """
fn tick(dt) { fn tick(me, dt) {
if Queue.length() == 0 { say("blahblahblah", 2.0);
say("blahblahblah"); delay(2);
delay(1);
}
} }
""" """
shifter = """ shifter = """
fn tick(dt) { fn tick(me, dt) {
let x = Me.x; let x = me.x;
let y = Me.y; let y = me.y;
if Queue.length() == 0 { if !me.waiting {
shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]); shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]);
delay(0.25); delay(0.25);
} }
} }
""" """
# 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 enter(me, dir) {
if Player.x == me.x && Player.y == me.y {
log("tripwire: the player crossed here");
}
}
"""
[boards.start.map] [boards.start.map]
name = "Starting Room" name = "Starting Room"
width = 60 width = 60
height = 25 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 # The single grid: all solids and most non-solids (terrain, the player, objects and
# glyph per cell; the space char is a fixed water glyph. # the portal). A space is always a transparent empty cell, so the floor shows through.
[[boards.start.layers]] [boards.start.grid]
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]]
content = """ content = """
############################################################ ############################################################
# # # #
@@ -230,9 +203,9 @@ content = """
# oS # # oS #
# # # #
# 1 # # 1 #
# ###### B # # )######( B #
# # # # # #
# +++ # # # +++ # v #
# # # # # #
# | # # | #
# # # #
@@ -250,7 +223,7 @@ content = """
# # # #
############################################################ ############################################################
""" """
[boards.start.layers.palette] [boards.start.grid.palette]
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" } "#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" }
"+" = { kind = "banana", tile = "#", fg = "#808080", bg = "#606060" } # unknown kind -> ErrorBlock demo "+" = { kind = "banana", tile = "#", fg = "#808080", bg = "#606060" } # unknown kind -> ErrorBlock demo
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } "o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
@@ -267,27 +240,28 @@ content = """
"S" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "shifter", script_name = "shifter" } "S" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "shifter", script_name = "shifter" }
"/" = { kind = "spinner_cw" } "/" = { kind = "spinner_cw" }
"t" = { kind = "gem" } "t" = { kind = "gem" }
"v" = { kind = "transporter_south" }
")" = { kind = "transporter_east" }
"(" = { kind = "transporter_west" }
# Layer 2: a purely decorative, non-solid object drawn *above* the solid crate # Triggers: invisible, non-solid, script-only objects placed off the grid. This one
# beneath it — only possible because draw order follows layer order. # watches for the player and logs when they reach its cell.
[[boards.start.layers]] [[boards.start.triggers]]
sparse = [{ x = 30, y = 12, ch = "*" }] x = 40
[boards.start.layers.palette] y = 12
"*" = { kind = "object", tile = 15, fg = "#ffff66", bg = "#000000", solid = false } script_name = "tripwire"
[boards.house.map] [boards.house.map]
name = "The House" name = "The House"
width = 60 width = 60
height = 25 height = 25
dark = true
# Layer 0: a single fixed floor glyph across the whole board. # A single fixed floor glyph across the whole board.
[[boards.house.layers]] floor = { tile = 176, fg = "#888888", bg = "#444444" }
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. # The single grid: terrain, the player, objects and the return portal.
[[boards.house.layers]] [boards.house.grid]
content = """ content = """
############################################################ ############################################################
# # # #
@@ -315,7 +289,7 @@ content = """
# # # #
############################################################ ############################################################
""" """
[boards.house.layers.palette] [boards.house.grid.palette]
"#" = { kind = "wall", tile = 178, fg = "#888888", bg = "#555555" } "#" = { kind = "wall", tile = 178, fg = "#888888", bg = "#555555" }
"@" = { kind = "player" } "@" = { kind = "player" }
"1" = { kind = "portal", name = "from_start", target_map = "start", target_entry = "to_house" } "1" = { kind = "portal", name = "from_start", target_map = "start", target_entry = "to_house" }
+45
View File
@@ -0,0 +1,45 @@
[world]
name = "Tiny"
start = "start"
# Named Rhai scripts shared across all boards in this world.
# Objects reference a script by name via `script_name`.
[scripts]
greeter = """
fn init(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: ${Player.health}`);
}
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);
}
}
"""
[boards.start.map]
name = "Starting Room"
width = 21
height = 6
# No floor attribute → a blank (black) floor.
# 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 = """
#####################
# #
# G @ #
# oo #
# #
#####################
"""
[boards.start.grid.palette]
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" }
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
"@" = { kind = "player" }
"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", solid = false, script_name = "greeter" }