Compare commits
10 Commits
e43dae54a8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 03185c9c68 | |||
| 6dccf5fc23 | |||
| f1eaaae5d0 | |||
| b1b723fd1b | |||
| cdeae455dc | |||
| ad95a9cd8d | |||
| 325d2c27dd | |||
| 386967e936 | |||
| e545395ac6 | |||
| f407b5d9a6 |
@@ -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
|
||||||
```
|
```
|
||||||
@@ -82,7 +86,7 @@ fn init(me, state) { # optional, run once at startup
|
|||||||
fn tick(me, state, dt) {
|
fn tick(me, state, dt) {
|
||||||
if !me.waiting && !me.blocked(North) { move(North); }
|
if !me.waiting && !me.blocked(North) { move(North); }
|
||||||
}
|
}
|
||||||
fn bump(me, state, 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(me, state, 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`, `heart`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph.
|
Palette `kind` values: the meta-kinds `empty` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `transporter_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). (`floor` is **no longer** a grid kind — it is the board-level `floor` attribute.) For archetype/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged).
|
||||||
|
|
||||||
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, state, id)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`) and `state` (the world). 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(x, y)`, `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately: 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`. The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md).
|
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** in the grid (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). The grid's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, `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)`) or the per-board `Registry` (which also persists across transitions).
|
**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,7 +153,7 @@ Today's baked-in assumptions that will need to change (don't make `Board.player`
|
|||||||
- `Board.player: Player` is required and non-optional; a player-less board can't be represented (should become `Option<Player>`, or move the player out of `Board` to the engine layer).
|
- `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
|
||||||
|
|
||||||
|
|||||||
Generated
+9
-2
@@ -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,7 @@ name = "kiln-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"color",
|
"color",
|
||||||
|
"doryen-fov",
|
||||||
"log",
|
"log",
|
||||||
"rhai",
|
"rhai",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -769,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",
|
||||||
|
|||||||
+1
-1
@@ -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]
|
||||||
|
|||||||
+53
-23
@@ -37,28 +37,57 @@ fn init(me, state) {
|
|||||||
|
|
||||||
### `fn tick(me, state, dt)`
|
### `fn tick(me, state, dt)`
|
||||||
|
|
||||||
Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Timed
|
Called each frame **only when this object's output queue is empty** — i.e. once its previous
|
||||||
actions pace themselves through the queue; you do not track time manually for simple walking.
|
actions (and any pacing `Delay`) have fully drained. While actions from an earlier tick are still
|
||||||
|
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(me, state, dt) {
|
fn tick(me, state, dt) {
|
||||||
if !me.waiting && !me.blocked(North) {
|
if !me.blocked(North) {
|
||||||
move(North);
|
move(North);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `fn bump(me, state, 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(me, state, id) {
|
fn bump(me, dir) {
|
||||||
if id == -1 { say("Hey! Watch it."); }
|
if dir == West { say("Something shoved me from the west."); }
|
||||||
else { log(`object ${id} bumped me`); }
|
else { log(`bumped from ${dir}`); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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}`);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -256,13 +285,15 @@ The Registry is shared by the whole board, so namespace per-instance keys by `me
|
|||||||
## Write functions
|
## Write functions
|
||||||
|
|
||||||
Scripts never mutate the world directly. Each write appends an `Action` to the calling object's
|
Scripts never mutate the world directly. Each write appends an `Action` to the calling object's
|
||||||
**output queue**; actions drain to a shared board queue between script calls and are resolved by the
|
**output queue**; the moment the hook returns, that object's ready actions are drained and applied
|
||||||
engine after the batch. The *subject* of a write is implicit and varies by function (see the table).
|
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.
|
||||||
|
|
||||||
| Call | Subject | Cost | Description |
|
| 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. |
|
| `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(x, y)` | self | 0 | Jump to an arbitrary cell. Refused (error logged at resolve) if self is solid and the destination is occupied. |
|
| `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`. |
|
| `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. |
|
| `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_tile(n)` | self | 0 | Set glyph tile index (CP437 code point in the default font). |
|
||||||
@@ -271,7 +302,7 @@ engine after the batch. The *subject* of a write is implicit and varies by funct
|
|||||||
| `set_color(fg, bg)` | self | 0 | Set both colors. |
|
| `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. |
|
| `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. |
|
| `say(msg)` / `say(msg, secs)` | self | 0 | Speech bubble above this object; default 3 s, or `secs`. Replaces any current bubble. |
|
||||||
| `log(msg)` | log | 0 | Append a plain-text line to the game log. |
|
| `log(msg)` | log | 0 | Append a plain-text line to the game log. **Immediate** — unlike the other writes it is *not* queued, so it appears the instant it's called, even behind a pending `move`/`delay`. |
|
||||||
| `scroll(lines)` | UI | 0 | Open a full-screen overlay (see below). |
|
| `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. |
|
| `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. |
|
| `delay(secs)` | self | — | Insert an explicit pause; integer and float overloads. Adjacent delays merge. |
|
||||||
@@ -297,7 +328,7 @@ function call named `choice_key` (see [custom handler functions](#custom-handler
|
|||||||
arity rules — a choice handler is called with no `arg`).
|
arity rules — a choice handler is called with no `arg`).
|
||||||
|
|
||||||
```rhai
|
```rhai
|
||||||
fn bump(me, state, id) {
|
fn bump(me, dir) {
|
||||||
scroll([
|
scroll([
|
||||||
"The muffin looks delicious.",
|
"The muffin looks delicious.",
|
||||||
"",
|
"",
|
||||||
@@ -358,7 +389,7 @@ crash the game. A script that throws during `tick` logs the error and resumes on
|
|||||||
## 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(me, state) {
|
fn init(me, state) {
|
||||||
set_tile(2);
|
set_tile(2);
|
||||||
@@ -371,9 +402,8 @@ fn tick(me, state, dt) {
|
|||||||
else if !me.blocked(West) { move(West); }
|
else if !me.blocked(West) { move(West); }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bump(me, state, id) {
|
fn bump(me, dir) {
|
||||||
if id == -1 { say("Halt! Who goes there?"); now(); }
|
say(`Halt! Who approaches from the ${dir}?`); now();
|
||||||
else { say("Watch it!"); now(); }
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -402,7 +432,7 @@ they are shape problems.
|
|||||||
delta" operations use *different* verbs: `alter_gems` vs `alter_health`.
|
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
|
- **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
|
**self**; `alter_gems`/`alter_health`/`set_key` act on the **player**; `set_tag`/`send` act on an
|
||||||
**arbitrary target**; `push`/`shift`/`teleport` act on **cells**. The `set_*` prefix in particular
|
**arbitrary target**; `push`/`shift` act on **cells** and `teleport` on an **arbitrary entity**. The `set_*` prefix in particular
|
||||||
means three different subjects.
|
means three different subjects.
|
||||||
- **Reads are methods/getters on handles, but writes are free functions.** `me.blocked(dir)` reads,
|
- **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
|
but `move(dir)` (the matching write) is a bare global that acts on `me` implicitly — the symmetry is
|
||||||
@@ -419,10 +449,10 @@ they are shape problems.
|
|||||||
|
|
||||||
### Unwieldy — machinery that leaks
|
### Unwieldy — machinery that leaks
|
||||||
|
|
||||||
- **The two-tier queue + `now()`/`delay()` model is subtle.** `now()` reorders by *enqueue
|
- **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
|
recency* ("most recently enqueued action to the front"), which is position-dependent and easy to
|
||||||
get wrong; the object-queue → board-queue promotion plus per-object delay draining is a lot of
|
get wrong; the per-object delay draining (leading delays eaten by `dt`, then the ready run applied)
|
||||||
implicit state to reason about for "do X, wait, do Y".
|
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;
|
- **Two persistence mechanisms.** Tags and the `Registry` are separate stores with separate APIs;
|
||||||
cross-board state must pick one.
|
cross-board state must pick one.
|
||||||
- **Stringly-typed dispatch.** `send` and scroll choices route by function *name*; a scroll choice
|
- **Stringly-typed dispatch.** `send` and scroll choices route by function *name*; a scroll choice
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
+39
-35
@@ -20,7 +20,7 @@ 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`, `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.
|
- `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), `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). 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.
|
||||||
@@ -29,33 +29,36 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
|||||||
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`.
|
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`.
|
||||||
- `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`, `grab: bool` (the player walking onto a `solid + grab` thing isn't blocked — it moves onto it and the thing's `grab()` hook fires; this is the *only* grab trigger; `Gem` and `Heart` both set the flag).
|
- `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.
|
||||||
- `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.
|
- `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: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained onto the board queue between script calls — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet<String>`, `name: Option<String>`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
|
- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained into a batch of actions that `GameState` applies immediately after the object's hook — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet<String>`, `name: Option<String>`. A **trigger** is just an `ObjectDef` that is non-solid, non-opaque, non-pushable, glyphless (transparent glyph) and scripted (authored via `[[triggers]]`). The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
|
||||||
|
|
||||||
**`kiln-core/src/layer.rs`** — palette layers (the map-file/draw-stack unit):
|
**`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?
|
||||||
|
- `bump_target(x, y, dir) -> Option<ObjectId>` — the object a move **into** `(x, y)` heading `dir` bumps: the solid object in the target cell, or the one at the end of a chain of pushed crates (walked through in `dir`). Open space, the player, or a wall yield `None`. This is what lets a plain crate — not just an object or the player — trigger a `bump`. Used by `GameState::try_move` / `step_object`; the caller supplies the came-from direction as `dir.opposite()`.
|
||||||
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The **player** counts as a blocker for this check. A Rust-side companion read to `apply_shift`; not itself exposed to scripts.
|
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The **player** counts as a blocker for this check. A Rust-side companion read to `apply_shift`; not itself exposed to scripts.
|
||||||
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`).
|
- `push(x, y, dir) -> 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.
|
||||||
- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries.
|
- `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.
|
||||||
- `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.)
|
- `grab_object_at(x, y) -> Option<ObjectId>` — a solid `grab` object on the cell (the player-walks-onto-it case). `GameState::try_move` uses it to fire `grab()` instead of bumping when the player steps onto a grab thing. Grab is **only** a player-movement event: a grab thing pushed/shifted into the player is treated as an ordinary solid (no special-case). (`remove_object` deletes the object and its `ObjQueue` from the `objects` map; a live `ScriptHost` keeps an unused `Scope` for that id until it is rebuilt — harmless, since hook dispatch iterates the board's *current* ids.)
|
||||||
- `apply_shift(cells: &[(i32, i32)]) -> Vec<LogLine>` — rotates the solids occupying a ring of cells one step: each cell's solid moves to the next coordinate in the list, and the last wraps back to the first. Backs the script `shift()` fn. A cell is **immobile** if its solid is unpushable, or is an `HCrate`/`VCrate` asked to move along its forbidden axis; immobility **cascades backward** along the ring (the cell that would feed an immobile cell is also held), so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is `place`d at its target (a displaced object is despawned via `remove_object`). Any out-of-bounds coordinate **rejects the whole shift** (returned as a `LogLine`). The player rotates like any other solid.
|
- `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).
|
||||||
- `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`.
|
- `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)` 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).
|
- `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.
|
||||||
|
|
||||||
@@ -70,30 +73,30 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
|||||||
- `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one).
|
- `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>`, 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(-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()`/`alter_gems()`/`alter_health()` 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`/`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, `Send` → `run_send`, `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 supporting types:
|
**`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).
|
||||||
- `SendArg` — the optional argument carried by a `Send` action / `send()` call: `None`/`Int`/`Float`/`String`, with `From<Dynamic>`/`Into<Dynamic>` conversions.
|
- `SendArg` — the optional argument carried by a `Send` action / `send()` call: `None`/`Int`/`Float`/`String`, with `From<Dynamic>`/`Into<Dynamic>` conversions.
|
||||||
- `Action` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never reaches the board queue — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost).
|
- `Action` (`pub(crate)`) — deferred mutation emitted by a script, drained from the issuing object's queue and applied by `GameState::apply_actions`: `Move(Direction)`, `SetTile(u32)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never drained as an action — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost).
|
||||||
- `BoardAction { source, action }` — an `Action` promoted onto the board queue, tagged with the `ObjectId` that issued it.
|
- `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 **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).
|
**`kiln-core/src/script.rs`** — Rhai scripting **host** (the script-facing types live in `kiln-core/src/api/`, below). The script-author's reference is [`docs/script-api.md`](../docs/script-api.md).
|
||||||
- `Registerable` trait — `fn register(engine, error_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`.
|
- `Registerable` trait — `fn register(engine, log_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`.
|
||||||
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap<String, CompiledScript>`), one persistent `Scope` per scripted object (`HashMap<ObjectId, Scope>`), the shared board queue, and the error sink. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the error sink; runs nothing.
|
- `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.
|
||||||
- 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, state, id)` (the bumper's `ObjectId`, or `-1` for the player), 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** (2/3/3/2). Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(state, id, bumper)` / `run_grab(state, id)`, all funneling through `run_hook_on_one`: it builds a fresh `ObjectInfo` for the object, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal.
|
- 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.
|
||||||
- `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.
|
- `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists.
|
||||||
- **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore.
|
- **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore.
|
||||||
- **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(x, y)`, `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`.
|
- **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 (two-tier):** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(board_queue, dt)` promotes them onto the shared **board queue** (`Vec<BoardAction>`): 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. `GameState` takes the board queue with `take_board_queue()` and applies it after the batch, so scripts mutate without a `&mut GameState` borrow. Errors bypass the queues via the error sink (`take_errors()`).
|
- **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`.
|
- **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.
|
- `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.
|
- **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.
|
||||||
@@ -103,14 +106,15 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
|||||||
- `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::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::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::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(target, dt)` promotes the underlying object's queued actions onto the board queue.
|
- `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 promote ready actions), `waiting` (front is a `Delay`).
|
- `api::queue` — `ObjQueue(Rc<RefCell<VecDeque<Action>>>)` (Rhai type **`Queue`**), one object's output queue, owned by its `ObjectDef` and reachable as `me.queue`. Rhai surface: getter `length`, methods `clear()`, `delay(secs)`. Rust surface used by the host: `act`, `delay` (merges a trailing delay), `now` (promote the last-enqueued action to the front), `drain` (eat leading delays by `dt`, then move the ready actions into a `&mut Vec<BoardAction>`), `waiting` (front is a `Delay`).
|
||||||
|
|
||||||
**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in `layer.rs`):
|
**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the grid work lives in `layer.rs`):
|
||||||
- `MapFile { map: MapHeader, layers: Vec<LayerData> }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `board_script_name`; **no** `player_start` — the player is a `kind = "player"` palette char) plus the `[[layers]]` stack. Does **not** include scripts (those live in `World::scripts`).
|
- `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.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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"
|
||||||
|
|||||||
+10
-9
@@ -6,7 +6,6 @@
|
|||||||
//! object that issued it).
|
//! object that issued it).
|
||||||
|
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use crate::log::LogLine;
|
|
||||||
use crate::utils::{Direction, ObjectId};
|
use crate::utils::{Direction, ObjectId};
|
||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
use rhai::Dynamic;
|
use rhai::Dynamic;
|
||||||
@@ -28,7 +27,7 @@ pub enum ScrollLine {
|
|||||||
Choice { choice: String, display: String },
|
Choice { choice: String, display: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum SendArg {
|
pub enum SendArg {
|
||||||
None,
|
None,
|
||||||
Int(i64),
|
Int(i64),
|
||||||
@@ -73,8 +72,9 @@ pub enum Action {
|
|||||||
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,
|
||||||
@@ -99,10 +99,11 @@ pub enum Action {
|
|||||||
/// 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: i64, y: i64 },
|
/// 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: i64, y: i64, dir: Direction },
|
Push { x: i64, y: i64, dir: Direction },
|
||||||
@@ -133,7 +134,7 @@ impl Debug for Action {
|
|||||||
match self {
|
match self {
|
||||||
Action::Move(dir) => write!(f, "Move({:?})", dir),
|
Action::Move(dir) => write!(f, "Move({:?})", dir),
|
||||||
Action::SetTile(i) => write!(f, "SetTile({i})"),
|
Action::SetTile(i) => write!(f, "SetTile({i})"),
|
||||||
Action::Log(msg) => write!(f, "Log({:?})", msg),
|
Action::SetLight(r) => write!(f, "SetLight({r})"),
|
||||||
Action::SetTag { .. } => write!(f, "SetTag"),
|
Action::SetTag { .. } => write!(f, "SetTag"),
|
||||||
Action::Say(_, _) => write!(f, "Say"),
|
Action::Say(_, _) => write!(f, "Say"),
|
||||||
Action::Delay(t) => write!(f, "Delay({t})"),
|
Action::Delay(t) => write!(f, "Delay({t})"),
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ use crate::{Board, Direction};
|
|||||||
use crate::api::object_info::ObjectInfo;
|
use crate::api::object_info::ObjectInfo;
|
||||||
use crate::api::registry::Registry;
|
use crate::api::registry::Registry;
|
||||||
use crate::script::Registerable;
|
use crate::script::Registerable;
|
||||||
use crate::utils::{ErrorSink, ObjectId};
|
use crate::utils::{LogSink, ObjectId};
|
||||||
|
|
||||||
/// A read-only handle to the world, exposed to scripts as `Board`.
|
/// A read-only handle to the world, exposed to scripts as `Board`.
|
||||||
pub type BoardRef = Rc<RefCell<Board>>;
|
pub type BoardRef = Rc<RefCell<Board>>;
|
||||||
|
|
||||||
impl Registerable for BoardRef {
|
impl Registerable for BoardRef {
|
||||||
fn register(engine: &mut Engine, error_sink: ErrorSink) {
|
fn register(engine: &mut Engine, log_sink: LogSink) {
|
||||||
engine.register_type_with_name::<BoardRef>("Board");
|
engine.register_type_with_name::<BoardRef>("Board");
|
||||||
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
|
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
|
||||||
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
|
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
|
||||||
@@ -48,14 +48,14 @@ impl Registerable for BoardRef {
|
|||||||
// Board.get(id) -> ObjectInfo | () (unknown id logs error)
|
// Board.get(id) -> ObjectInfo | () (unknown id logs error)
|
||||||
engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic {
|
engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic {
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
error_sink.error(format!("Board.get: invalid id {id}"));
|
log_sink.error(format!("Board.get: invalid id {id}"));
|
||||||
return Dynamic::UNIT;
|
return Dynamic::UNIT;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(obj) = ObjectInfo::from_id(id as ObjectId, board.clone()) {
|
if let Some(obj) = ObjectInfo::from_id(id as ObjectId, board.clone()) {
|
||||||
Dynamic::from(obj)
|
Dynamic::from(obj)
|
||||||
} else {
|
} else {
|
||||||
error_sink.error(format!("Board.get: no object with id {id}"));
|
log_sink.error(format!("Board.get: no object with id {id}"));
|
||||||
Dynamic::UNIT
|
Dynamic::UNIT
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ use rhai::{Dynamic, Engine};
|
|||||||
use crate::api::board::BoardRef;
|
use crate::api::board::BoardRef;
|
||||||
use crate::api::queue::ObjQueue;
|
use crate::api::queue::ObjQueue;
|
||||||
use crate::Direction;
|
use crate::Direction;
|
||||||
|
use crate::action::BoardAction;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::script::{BoardQueue, Registerable};
|
use crate::script::Registerable;
|
||||||
use crate::utils::{ErrorSink, ObjectId};
|
use crate::utils::{LogSink, ObjectId};
|
||||||
|
|
||||||
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
|
/// 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.
|
/// and `Board.get`. Passed by value — scripts read fields, not a live reference.
|
||||||
@@ -69,7 +70,7 @@ impl ObjectInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drain(&mut self, target: BoardQueue, dt: f64) {
|
pub fn drain(&mut self, target: &mut Vec<BoardAction>, dt: f64) {
|
||||||
let mut b = self.board.borrow_mut();
|
let mut b = self.board.borrow_mut();
|
||||||
if let Some(def) = b.objects.get_mut(&self.id) {
|
if let Some(def) = b.objects.get_mut(&self.id) {
|
||||||
def.queue.drain(self.id, target, dt)
|
def.queue.drain(self.id, target, dt)
|
||||||
@@ -78,7 +79,7 @@ impl ObjectInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Registerable for ObjectInfo {
|
impl Registerable for ObjectInfo {
|
||||||
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
|
fn register(engine: &mut Engine, _log_sink: LogSink) {
|
||||||
engine.register_type_with_name::<ObjectInfo>("ObjectInfo")
|
engine.register_type_with_name::<ObjectInfo>("ObjectInfo")
|
||||||
.register_get("x", |obj: &mut ObjectInfo| obj.x)
|
.register_get("x", |obj: &mut ObjectInfo| obj.x)
|
||||||
.register_get("y", |obj: &mut ObjectInfo| obj.y)
|
.register_get("y", |obj: &mut ObjectInfo| obj.y)
|
||||||
@@ -112,6 +113,14 @@ impl Registerable for ObjectInfo {
|
|||||||
obj.glyph
|
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| {
|
engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| {
|
||||||
if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) {
|
if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) {
|
||||||
tags.contains(&t)
|
tags.contains(&t)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use rhai::Engine;
|
|||||||
use crate::api::board::BoardRef;
|
use crate::api::board::BoardRef;
|
||||||
use crate::player::PlayerRef;
|
use crate::player::PlayerRef;
|
||||||
use crate::script::Registerable;
|
use crate::script::Registerable;
|
||||||
use crate::utils::ErrorSink;
|
use crate::utils::LogSink;
|
||||||
|
|
||||||
/// GameState stores player state but Board stores its position, and we want one
|
/// GameState stores player state but Board stores its position, and we want one
|
||||||
/// object to register with Rhai
|
/// object to register with Rhai
|
||||||
@@ -10,7 +10,7 @@ use crate::utils::ErrorSink;
|
|||||||
pub struct PlayerWithPos(pub PlayerRef, pub BoardRef);
|
pub struct PlayerWithPos(pub PlayerRef, pub BoardRef);
|
||||||
|
|
||||||
impl Registerable for PlayerWithPos {
|
impl Registerable for PlayerWithPos {
|
||||||
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
|
fn register(engine: &mut Engine, _log_sink: LogSink) {
|
||||||
engine.register_type_with_name::<PlayerWithPos>("Player")
|
engine.register_type_with_name::<PlayerWithPos>("Player")
|
||||||
.register_get("gems", |player: &mut PlayerWithPos| player.0.borrow().gems)
|
.register_get("gems", |player: &mut PlayerWithPos| player.0.borrow().gems)
|
||||||
.register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health)
|
.register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use std::collections::VecDeque;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use rhai::{Dynamic, Engine};
|
use rhai::{Dynamic, Engine};
|
||||||
use crate::action::{Action, BoardAction};
|
use crate::action::{Action, BoardAction};
|
||||||
use crate::script::{BoardQueue, Registerable};
|
use crate::script::Registerable;
|
||||||
use crate::utils::{ErrorSink, ObjectId};
|
use crate::utils::{LogSink, ObjectId};
|
||||||
|
|
||||||
/// A single object's output queue.
|
/// A single object's output queue.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -44,10 +44,11 @@ impl ObjQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drains object `i`'s output queue onto a target queue: first advances
|
/// Drains object `i`'s output queue into `target`: first advances leading
|
||||||
/// leading `Delay` actions by dt, then drains actions into the target
|
/// `Delay` actions by dt, then moves the run of ready actions into `target`
|
||||||
/// queue until we run out (or hit another delay)
|
/// until we run out (or hit another delay). Each ready action is tagged with
|
||||||
pub fn drain(&mut self, source: ObjectId, target: BoardQueue, mut dt: f64) {
|
/// `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();
|
let mut queue = self.0.borrow_mut();
|
||||||
loop {
|
loop {
|
||||||
match queue.front_mut() {
|
match queue.front_mut() {
|
||||||
@@ -63,7 +64,7 @@ impl ObjQueue {
|
|||||||
}
|
}
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
let action = queue.pop_front().unwrap();
|
let action = queue.pop_front().unwrap();
|
||||||
target.borrow_mut().push(BoardAction { source, action });
|
target.push(BoardAction { source, action });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,13 +75,18 @@ impl ObjQueue {
|
|||||||
matches!(self.0.borrow().front(), Some(Action::Delay(_)))
|
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) {
|
pub fn clear(&mut self) {
|
||||||
self.0.borrow_mut().clear();
|
self.0.borrow_mut().clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Registerable for ObjQueue {
|
impl Registerable for ObjQueue {
|
||||||
fn register(engine: &mut Engine, error_sink: ErrorSink) {
|
fn register(engine: &mut Engine, log_sink: LogSink) {
|
||||||
engine.register_type_with_name::<ObjQueue>("Queue");
|
engine.register_type_with_name::<ObjQueue>("Queue");
|
||||||
engine.register_get("length", |q: &mut ObjQueue| q.0.borrow().len() as i64);
|
engine.register_get("length", |q: &mut ObjQueue| q.0.borrow().len() as i64);
|
||||||
engine.register_fn("clear", ObjQueue::clear);
|
engine.register_fn("clear", ObjQueue::clear);
|
||||||
@@ -98,7 +104,7 @@ impl Registerable for ObjQueue {
|
|||||||
|
|
||||||
match secs {
|
match secs {
|
||||||
Ok(secs) => q.delay(secs),
|
Ok(secs) => q.delay(secs),
|
||||||
Err(msg) => error_sink.error(msg.to_string())
|
Err(msg) => log_sink.error(msg.to_string())
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use rhai::{Dynamic, Engine, ImmutableString};
|
use rhai::{Dynamic, Engine, ImmutableString};
|
||||||
use crate::api::board::BoardRef;
|
use crate::api::board::BoardRef;
|
||||||
use crate::script::Registerable;
|
use crate::script::Registerable;
|
||||||
use crate::utils::{ErrorSink, RegistryValue};
|
use crate::utils::{LogSink, RegistryValue};
|
||||||
|
|
||||||
/// The board's script registry, pushed into scope as the constant `Registry`.
|
/// 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
|
/// `get`/`set`/`get_or` methods let scripts read and write per-board key→value pairs
|
||||||
@@ -11,7 +11,7 @@ use crate::utils::{ErrorSink, RegistryValue};
|
|||||||
pub struct Registry(pub BoardRef);
|
pub struct Registry(pub BoardRef);
|
||||||
|
|
||||||
impl Registerable for Registry {
|
impl Registerable for Registry {
|
||||||
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
|
fn register(engine: &mut Engine, _log_sink: LogSink) {
|
||||||
engine.register_type_with_name::<Registry>("Registry");
|
engine.register_type_with_name::<Registry>("Registry");
|
||||||
|
|
||||||
// Registry.get(key) -> Dynamic — returns () if the key is absent.
|
// Registry.get(key) -> Dynamic — returns () if the key is absent.
|
||||||
|
|||||||
@@ -123,6 +123,17 @@ builtins! {
|
|||||||
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false },
|
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false },
|
||||||
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 => [ // TODO these should refer to the key colors in Keyring
|
||||||
"key_blue" => KeyType::Blue.glyph(),
|
"key_blue" => KeyType::Blue.glyph(),
|
||||||
"key_green" => KeyType::Green.glyph(),
|
"key_green" => KeyType::Green.glyph(),
|
||||||
@@ -173,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
|
||||||
@@ -223,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,
|
||||||
@@ -241,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
|
||||||
@@ -278,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,
|
||||||
@@ -308,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}")),
|
||||||
|
|||||||
+403
-221
@@ -1,12 +1,47 @@
|
|||||||
use crate::archetype::Archetype;
|
use crate::archetype::Archetype;
|
||||||
|
use crate::floor::Floor;
|
||||||
|
use crate::fov::{FovCaster, Lighting, color_to_rgb};
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
use crate::layer::Layer;
|
|
||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::utils::Direction;
|
use crate::utils::Direction;
|
||||||
use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid};
|
use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid};
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
|
||||||
|
/// The result of [`Board::apply_shift`]: any error lines to log, plus the
|
||||||
|
/// `(from, to)` cell relocations the shift actually performed.
|
||||||
|
///
|
||||||
|
/// The `moves` let the caller fire an `enter` hook on any non-solid object each
|
||||||
|
/// shifted solid landed on; the direction is derived best-effort from `to - from`
|
||||||
|
/// (a shift can rotate cells that aren't cardinally adjacent).
|
||||||
|
pub struct ShiftOutcome {
|
||||||
|
/// Error lines (e.g. an out-of-bounds cell) for the caller to log.
|
||||||
|
pub errors: Vec<LogLine>,
|
||||||
|
/// Each `(from, to)` relocation a non-blocked solid underwent.
|
||||||
|
pub moves: Vec<((i64, i64), (i64, i64))>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-solid `(glyph, archetype)` placed at a board coordinate, **outside** the
|
||||||
|
/// main grid, drawn only when the grid cell at `(x, y)` is empty.
|
||||||
|
///
|
||||||
|
/// Decorations exist so a single file format can serve both world files and save
|
||||||
|
/// files: at authoring time every cell holds at most one thing, but during play a
|
||||||
|
/// runtime solid can end up sharing a cell with a non-solid that was already there.
|
||||||
|
/// The non-solid is recorded here (the grid keeps the solid). The editor does not
|
||||||
|
/// place decorations; they are a save/runtime concern. A decoration's archetype is
|
||||||
|
/// always non-solid (a solid one is rejected at load).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Decoration {
|
||||||
|
/// Column (0-indexed).
|
||||||
|
pub x: usize,
|
||||||
|
/// Row (0-indexed).
|
||||||
|
pub y: usize,
|
||||||
|
/// The decoration's visual.
|
||||||
|
pub glyph: Glyph,
|
||||||
|
/// The decoration's (non-solid) archetype.
|
||||||
|
pub archetype: Archetype,
|
||||||
|
}
|
||||||
|
|
||||||
/// The complete state of one game board (a single room or screen).
|
/// The complete state of one game board (a single room or screen).
|
||||||
///
|
///
|
||||||
/// `Board` is the central data structure of the engine, equivalent to a
|
/// `Board` is the central data structure of the engine, equivalent to a
|
||||||
@@ -37,12 +72,18 @@ pub struct Board {
|
|||||||
pub width: usize,
|
pub width: usize,
|
||||||
/// Height of the board in cells.
|
/// Height of the board in cells.
|
||||||
pub height: usize,
|
pub height: usize,
|
||||||
/// Ordered draw stack of [`Layer`]s, bottom (index 0) to top. Each layer holds
|
/// The single row-major grid of `(Glyph, Archetype)` cells (`width * height`),
|
||||||
/// a row-major grid of `(Glyph, Archetype)` cells; a transparent cell lets the
|
/// holding every solid and most non-solids. A transparent cell (glyph tile 0)
|
||||||
/// layer beneath show through. Drawing ([`Board::glyph_at`]) walks the stack
|
/// draws nothing, revealing a [`decoration`](Board::decorations) or the
|
||||||
/// top-down; solidity ([`Board::solid_at`]) scans every layer. Access a single
|
/// [`floor`](Board::floor) beneath. Access a cell with [`Board::get`]/
|
||||||
/// cell with [`Board::get`]/[`Board::get_mut`] by `(z, x, y)`.
|
/// [`Board::get_mut`] by `(x, y)`.
|
||||||
pub(crate) layers: Vec<Layer>,
|
pub(crate) grid: Vec<(Glyph, Archetype)>,
|
||||||
|
/// The board's cosmetic floor (blank / one fixed glyph / a biome), drawn beneath
|
||||||
|
/// everything. Replaces the old dedicated floor layer.
|
||||||
|
pub(crate) floor: Floor,
|
||||||
|
/// Non-solid things placed off the main grid, drawn only where the grid cell is
|
||||||
|
/// empty (see [`Decoration`]). Normally empty; populated by save files.
|
||||||
|
pub(crate) decorations: Vec<Decoration>,
|
||||||
/// Current player position on this board. See [`PlayerPos`] for caveats
|
/// Current player position on this board. See [`PlayerPos`] for caveats
|
||||||
/// about its future. Game-global player *stats* live in [`crate::player::Player`].
|
/// about its future. Game-global player *stats* live in [`crate::player::Player`].
|
||||||
pub player: PlayerPos,
|
pub player: PlayerPos,
|
||||||
@@ -62,6 +103,12 @@ pub struct Board {
|
|||||||
/// rather than being tied to a specific object cell. Scripts live in
|
/// rather than being tied to a specific object cell. Scripts live in
|
||||||
/// [`World::scripts`](crate::world::World) and are looked up by this name.
|
/// [`World::scripts`](crate::world::World) and are looked up by this name.
|
||||||
pub board_script_name: Option<String>,
|
pub board_script_name: Option<String>,
|
||||||
|
/// When `true`, this board is "dark": front-ends reveal only the cells the
|
||||||
|
/// player can see and that receive light (see [`Board::lighting`]) and draw
|
||||||
|
/// everything else as unlit darkness. Sight and light are blocked by opaque cells.
|
||||||
|
/// Loaded from / saved to the `dark` key in the map file's `[map]` header;
|
||||||
|
/// defaults to `false` (fully lit).
|
||||||
|
pub dark: bool,
|
||||||
/// Nonfatal problems collected while loading this map (e.g. unknown
|
/// Nonfatal problems collected while loading this map (e.g. unknown
|
||||||
/// archetypes, dropped objects, recovered placement chars), as red-on-black
|
/// archetypes, dropped objects, recovered placement chars), as red-on-black
|
||||||
/// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of
|
/// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of
|
||||||
@@ -75,98 +122,91 @@ pub struct Board {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Board {
|
impl Board {
|
||||||
/// Number of draw layers on this board (≥ 1 for a loaded board).
|
|
||||||
pub fn layer_count(&self) -> usize {
|
|
||||||
self.layers.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a list of all `ObjectId`s currently on the board.
|
/// Return a list of all `ObjectId`s currently on the board.
|
||||||
pub fn all_ids(&self) -> Vec<ObjectId> {
|
pub fn all_ids(&self) -> Vec<ObjectId> {
|
||||||
self.objects.keys().cloned().collect()
|
self.objects.keys().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a reference to the cell at `(x, y)` on layer `z`.
|
/// Returns a reference to the `(Glyph, Archetype)` cell at `(x, y)`.
|
||||||
///
|
///
|
||||||
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are
|
/// Panics if `x` or `y` are out of bounds.
|
||||||
/// out of bounds.
|
pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) {
|
||||||
pub fn get(&self, z: usize, x: usize, y: usize) -> &(Glyph, Archetype) {
|
&self.grid[y * self.width + x]
|
||||||
&self.layers[z].cells[y * self.width + x]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a mutable reference to the cell at `(x, y)` on layer `z`.
|
/// Returns a mutable reference to the cell at `(x, y)`.
|
||||||
///
|
///
|
||||||
/// Panics if `z`, `x`, or `y` are out of bounds.
|
/// Panics if `x` or `y` are out of bounds.
|
||||||
pub fn get_mut(&mut self, z: usize, x: usize, y: usize) -> &mut (Glyph, Archetype) {
|
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
|
||||||
let w = self.width;
|
let w = self.width;
|
||||||
&mut self.layers[z].cells[y * w + x]
|
&mut self.grid[y * w + x]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the solid (if any) at `(x, y)` with `Empty`
|
/// Replace the solid terrain (if any) at `(x, y)` with a transparent `Empty`
|
||||||
|
/// cell, revealing the floor beneath.
|
||||||
pub fn clear_solid(&mut self, x: usize, y: usize) {
|
pub fn clear_solid(&mut self, x: usize, y: usize) {
|
||||||
if self.in_bounds((x as i64, y as i64))
|
if self.in_bounds((x as i64, y as i64)) && self.get(x, y).1.behavior().solid {
|
||||||
&& let Some(z) = self.solid_cell_layer(x, y) {
|
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||||
*self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty)
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the glyph to display at `(x, y)`, honoring layer draw order.
|
/// Returns the glyph to display at `(x, y)`.
|
||||||
///
|
///
|
||||||
/// The player is always drawn on top (it is not part of the layer stack yet).
|
/// With a single grid the draw order is a fixed precedence (no layer walk):
|
||||||
/// Otherwise the layers are walked **top-down**; the first thing that draws on
|
|
||||||
/// a layer wins:
|
|
||||||
///
|
///
|
||||||
/// 1. an object on that layer (a solid object always; otherwise a non-solid
|
/// 1. the player (drawn on top; not part of the grid yet);
|
||||||
/// object whose glyph is not transparent, i.e. `tile != 0`),
|
/// 2. an object at the cell — a solid object always, otherwise the first
|
||||||
/// 2. a portal on that layer,
|
/// non-transparent non-solid object (`tile != 0`, so invisible objects exist);
|
||||||
/// 3. the layer's terrain cell — a solid always draws, and a non-solid draws
|
/// 3. the grid cell `(glyph, arch)` — a solid always draws, a non-solid only when
|
||||||
/// only when not transparent (`tile != 0`).
|
/// visible (`tile != 0`);
|
||||||
|
/// 4. a portal at the cell (portals sit on a transparent grid cell);
|
||||||
|
/// 5. a [`decoration`](Board::decorations) at the cell (reached only because the
|
||||||
|
/// grid cell was empty);
|
||||||
|
/// 6. the [`floor`](Board::floor) glyph, if any;
|
||||||
|
/// 7. the canonical black `Empty` glyph.
|
||||||
///
|
///
|
||||||
/// If no layer contributes anything, the canonical black `Empty` glyph is used.
|
|
||||||
/// Panics if out of bounds.
|
/// Panics if out of bounds.
|
||||||
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
|
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
|
||||||
// The player is rendered above the whole stack (see the `Player` notes).
|
// The player is rendered above everything (see the `Player` notes).
|
||||||
if self.player.x == x as i64 && self.player.y == y as i64 {
|
if self.player.x == x as i64 && self.player.y == y as i64 {
|
||||||
return Glyph::player();
|
return Glyph::player();
|
||||||
}
|
}
|
||||||
|
|
||||||
for z in (0..self.layers.len()).rev() {
|
// Objects: a solid object always draws; otherwise the first non-transparent
|
||||||
// Objects on this layer: a solid object always draws; otherwise the
|
// non-solid object (lets invisible trigger objects exist).
|
||||||
// first non-transparent non-solid object (lets invisible objects exist).
|
let mut nonsolid: Option<Glyph> = None;
|
||||||
let mut nonsolid: Option<Glyph> = None;
|
for o in self.objects.values().filter(|o| o.x == x && o.y == y) {
|
||||||
for o in self
|
if o.solid {
|
||||||
.objects
|
return o.glyph;
|
||||||
.values()
|
|
||||||
.filter(|o| o.x == x && o.y == y && o.z == z)
|
|
||||||
{
|
|
||||||
if o.solid {
|
|
||||||
return o.glyph;
|
|
||||||
}
|
|
||||||
if nonsolid.is_none() && o.glyph.tile != 0 {
|
|
||||||
nonsolid = Some(o.glyph);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(g) = nonsolid {
|
if nonsolid.is_none() && o.glyph.tile != 0 {
|
||||||
return g;
|
nonsolid = Some(o.glyph);
|
||||||
}
|
|
||||||
|
|
||||||
// A portal on this layer draws above its (transparent) terrain cell.
|
|
||||||
if self
|
|
||||||
.portals
|
|
||||||
.iter()
|
|
||||||
.any(|p| p.x == x && p.y == y && p.z == z)
|
|
||||||
{
|
|
||||||
return PortalDef::default_glyph();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The terrain cell: a solid always draws; a non-solid only if visible.
|
|
||||||
let (glyph, arch) = self.get(z, x, y);
|
|
||||||
if arch.behavior().solid || glyph.tile != 0 {
|
|
||||||
return *glyph;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(g) = nonsolid {
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
// Nothing on any layer: the canonical black empty cell.
|
// The grid cell: a solid always draws; a non-solid only if visible.
|
||||||
Archetype::Empty.default_glyph()
|
let (glyph, arch) = self.get(x, y);
|
||||||
|
if arch.behavior().solid || glyph.tile != 0 {
|
||||||
|
return *glyph;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A portal sits on its (transparent) grid cell.
|
||||||
|
if self.portals.iter().any(|p| p.x == x && p.y == y) {
|
||||||
|
return PortalDef::default_glyph();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The grid cell was empty: a decoration may show here.
|
||||||
|
if let Some(d) = self.decorations.iter().find(|d| d.x == x && d.y == y) {
|
||||||
|
return d.glyph;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then the floor, else the canonical black empty cell.
|
||||||
|
self.floor
|
||||||
|
.glyph_at(x, y, self.width)
|
||||||
|
.unwrap_or_else(|| Archetype::Empty.default_glyph())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
|
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
|
||||||
@@ -218,20 +258,14 @@ impl Board {
|
|||||||
};
|
};
|
||||||
return Some(Solid::object_at(x, y, id, behavior));
|
return Some(Solid::object_at(x, y, id, behavior));
|
||||||
}
|
}
|
||||||
// Otherwise some layer's terrain archetype may be solid (e.g. a wall).
|
// Otherwise the grid cell's terrain archetype may be solid (e.g. a wall).
|
||||||
if let Some(z) = self.solid_cell_layer(x, y) {
|
let (glyph, arch) = *self.get(x, y);
|
||||||
let (glyph, arch) = *self.get(z, x, y);
|
if arch.behavior().solid {
|
||||||
return Some(Solid::terrain_at(x, y, z, glyph, arch));
|
return Some(Solid::terrain_at(x, y, glyph, arch));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the index of the layer whose terrain cell at `(x, y)` is solid, if
|
|
||||||
/// any. By the one-solid-per-cell invariant there is at most one such layer.
|
|
||||||
fn solid_cell_layer(&self, x: usize, y: usize) -> Option<usize> {
|
|
||||||
(0..self.layers.len()).find(|&z| self.get(z, x, y).1.behavior().solid)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it.
|
/// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it.
|
||||||
///
|
///
|
||||||
/// Convenience inverse of [`solid_at`](Board::solid_at).
|
/// Convenience inverse of [`solid_at`](Board::solid_at).
|
||||||
@@ -240,6 +274,76 @@ impl Board {
|
|||||||
self.solid_at(x, y).is_none()
|
self.solid_at(x, y).is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if cell `(x, y)` blocks line of sight (and light).
|
||||||
|
///
|
||||||
|
/// A cell is sight-blocking if its grid terrain is opaque (e.g. a `Wall`)
|
||||||
|
/// **or** any object on it is opaque. This is the input to lighting on
|
||||||
|
/// [`dark`](Board::dark) boards; see [`Board::lighting`].
|
||||||
|
/// Panics if `x` or `y` are out of bounds.
|
||||||
|
pub fn is_opaque_at(&self, x: usize, y: usize) -> bool {
|
||||||
|
self.get(x, y).1.behavior().opaque
|
||||||
|
|| self.objects.values().any(|o| o.x == x && o.y == y && o.opaque)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes lighting + line-of-sight for the player on this board.
|
||||||
|
///
|
||||||
|
/// Returns `None` unless the board is [`dark`](Board::dark) — a lit board
|
||||||
|
/// needs no lighting and front-ends draw every cell at full color. On a dark
|
||||||
|
/// board it (a) casts an unbounded line-of-sight field from the player, then
|
||||||
|
/// (b) accumulates colored light from every source — the player's torch
|
||||||
|
/// (radius `player_torch`, white), each object with `light > 0`, and each
|
||||||
|
/// terrain cell with [`Archetype::light`] `> 0` — each source colored by its
|
||||||
|
/// own glyph fg and falling off linearly to its radius. Opaque cells (via
|
||||||
|
/// [`is_opaque_at`](Board::is_opaque_at)) block both sight and light.
|
||||||
|
pub fn lighting(&self, player_torch: u32) -> Option<Lighting> {
|
||||||
|
if !self.dark {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (w, h) = (self.width, self.height);
|
||||||
|
let mut lighting = Lighting::new(w, h);
|
||||||
|
// One caster whose transparency is seeded once from the opaque cells;
|
||||||
|
// reused for the LOS pass and every light source (its FOV is cleared per cast).
|
||||||
|
let mut caster = FovCaster::new(w, h, |x, y| !self.is_opaque_at(x, y));
|
||||||
|
|
||||||
|
let (px, py) = (self.player.x as usize, self.player.y as usize);
|
||||||
|
// (a) Player line of sight — unbounded (radius 0), pure geometry.
|
||||||
|
caster.cast(px, py, 0, |x, y| lighting.set_los(x, y));
|
||||||
|
|
||||||
|
// (b) Accumulate each light source into the per-cell color buffer. A
|
||||||
|
// source paints every cell it can see within its radius, tinted by its
|
||||||
|
// glyph fg and dimmed by a linear falloff (full at the source, 0 at the edge).
|
||||||
|
let mut add_source = |lighting: &mut Lighting, sx: usize, sy: usize, radius: u32, color: [f32; 3]| {
|
||||||
|
let r = radius as f32;
|
||||||
|
caster.cast(sx, sy, radius as usize, |x, y| {
|
||||||
|
let d = ((x as f32 - sx as f32).powi(2) + (y as f32 - sy as f32).powi(2)).sqrt();
|
||||||
|
let falloff = (1.0 - d / r).max(0.0);
|
||||||
|
lighting.add_light(x, y, [color[0] * falloff, color[1] * falloff, color[2] * falloff]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// The player's torch: a white light centered on the player.
|
||||||
|
if player_torch > 0 {
|
||||||
|
add_source(&mut lighting, px, py, player_torch, [1.0, 1.0, 1.0]);
|
||||||
|
}
|
||||||
|
// Light-emitting objects: color = their own glyph foreground.
|
||||||
|
for o in self.objects.values() {
|
||||||
|
if o.light > 0 {
|
||||||
|
add_source(&mut lighting, o.x, o.y, o.light, color_to_rgb(o.glyph.fg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Glowing terrain (e.g. a `Torch` cell): color = the cell's glyph foreground.
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let (glyph, arch) = self.get(x, y);
|
||||||
|
let radius = arch.light();
|
||||||
|
if radius > 0 {
|
||||||
|
add_source(&mut lighting, x, y, radius, color_to_rgb(glyph.fg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(lighting)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
|
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
|
||||||
///
|
///
|
||||||
/// Non-solid things are never pushable: `pushable` only matters for solids.
|
/// Non-solid things are never pushable: `pushable` only matters for solids.
|
||||||
@@ -252,6 +356,44 @@ impl Board {
|
|||||||
.is_some_and(|s| s.pushable().allows(dir))
|
.is_some_and(|s| s.pushable().allows(dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The object that a move **into** `(x, y)` heading `dir` bumps, if any.
|
||||||
|
///
|
||||||
|
/// Walks the chain of pushable crates from the target cell in `dir` until it
|
||||||
|
/// reaches something that stops it, and reports the object it presses against:
|
||||||
|
/// - a solid object in the target cell (or at the end of a crate chain) is the
|
||||||
|
/// bumped object,
|
||||||
|
/// - open space or the player means nothing is bumped,
|
||||||
|
/// - a non-pushable terrain cell (a wall) blocks the chain with no object to bump.
|
||||||
|
///
|
||||||
|
/// This is what lets *any* solid — the player, another object, or a pushed
|
||||||
|
/// crate — trigger a `bump`: the bumper need not be an object, since we only
|
||||||
|
/// return the *bumped* object's id (the direction it came from is supplied by
|
||||||
|
/// the caller from its move direction).
|
||||||
|
pub fn bump_target(&self, x: usize, y: usize, dir: Direction) -> Option<ObjectId> {
|
||||||
|
let (dx, dy): (i64, i64) = dir.into();
|
||||||
|
let (mut cx, mut cy) = (x, y);
|
||||||
|
loop {
|
||||||
|
// Open space ends the walk with nothing bumped.
|
||||||
|
let solid = self.solid_at(cx, cy)?;
|
||||||
|
// A solid object — directly, or at the end of a crate chain — is the target.
|
||||||
|
if let Some(id) = solid.object_id() {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
// The player is never itself "bumped"; a wall (non-pushable terrain) stops
|
||||||
|
// the chain with no object behind it. Only a pushable crate is walked
|
||||||
|
// through — reuse the already-fetched `solid` rather than re-scanning.
|
||||||
|
if solid.player() || !solid.pushable().allows(dir) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let next = (cx as i64 + dx, cy as i64 + dy);
|
||||||
|
if !self.in_bounds(next) {
|
||||||
|
return None; // crate chain runs off the board
|
||||||
|
}
|
||||||
|
cx = next.0 as usize;
|
||||||
|
cy = next.1 as usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the chain of pushable solids starting at `(x, y)` can be shoved one
|
/// Whether the chain of pushable solids starting at `(x, y)` can be shoved one
|
||||||
/// step in `dir` — i.e. the chain ends at a passable cell rather than the board
|
/// step in `dir` — i.e. the chain ends at a passable cell rather than the board
|
||||||
/// edge or a non-pushable solid.
|
/// edge or a non-pushable solid.
|
||||||
@@ -321,9 +463,12 @@ impl Board {
|
|||||||
///
|
///
|
||||||
/// No-op when the chain can't move (it self-checks via [`can_push`](Board::can_push)),
|
/// No-op when the chain can't move (it self-checks via [`can_push`](Board::can_push)),
|
||||||
/// so it is safe to call unconditionally.
|
/// so it is safe to call unconditionally.
|
||||||
pub fn push(&mut self, x: usize, y: usize, dir: Direction) {
|
/// Returns the cells the shoved solids moved **into** (each chain cell stepped
|
||||||
|
/// one cell in `dir`), so the caller can fire `enter` on any non-solid object a
|
||||||
|
/// pushed solid landed on. Empty when nothing moved.
|
||||||
|
pub fn push(&mut self, x: usize, y: usize, dir: Direction) -> Vec<(usize, usize)> {
|
||||||
if !self.can_push(x, y, dir) {
|
if !self.can_push(x, y, dir) {
|
||||||
return;
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let (dx, dy): (i64, i64) = dir.into();
|
let (dx, dy): (i64, i64) = dir.into();
|
||||||
// can_push guaranteed the chain ends at an in-bounds passable cell, so
|
// can_push guaranteed the chain ends at an in-bounds passable cell, so
|
||||||
@@ -339,14 +484,19 @@ impl Board {
|
|||||||
for &(px, py) in chain.iter().rev() {
|
for &(px, py) in chain.iter().rev() {
|
||||||
self.shift_solid(px, py, dx, dy);
|
self.shift_solid(px, py, dx, dy);
|
||||||
}
|
}
|
||||||
|
// Each solid ended up one step along `dir`; those destination cells are
|
||||||
|
// where an `enter` may need to fire.
|
||||||
|
chain
|
||||||
|
.iter()
|
||||||
|
.map(|&(px, py)| ((px as i64 + dx) as usize, (py as i64 + dy) as usize))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
|
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
|
||||||
///
|
///
|
||||||
/// A solid object is relocated (keeping its layer); otherwise the solid
|
/// A solid object is relocated; otherwise the solid terrain archetype (a crate)
|
||||||
/// terrain archetype (a crate) is moved within its own layer, leaving a
|
/// is moved, leaving a transparent cell behind so the floor shows through. The
|
||||||
/// transparent cell behind so the layer beneath (e.g. floor) shows through.
|
/// caller guarantees the destination is already clear.
|
||||||
/// The caller guarantees the destination is already clear.
|
|
||||||
fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) {
|
fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) {
|
||||||
let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize);
|
let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize);
|
||||||
let Some(solid) = self.solid_at(x, y) else {
|
let Some(solid) = self.solid_at(x, y) else {
|
||||||
@@ -355,8 +505,8 @@ impl Board {
|
|||||||
// A terrain cell leaves a transparent cell behind (revealing any floor); the
|
// A terrain cell leaves a transparent cell behind (revealing any floor); the
|
||||||
// player and objects carry no grid cell, so there is nothing to vacate. `place`
|
// player and objects carry no grid cell, so there is nothing to vacate. `place`
|
||||||
// captured the glyph/arch, so clearing the source first is safe.
|
// captured the glyph/arch, so clearing the source first is safe.
|
||||||
if let Some(z) = self.solid_cell_layer(x, y) {
|
if self.get(x, y).1.behavior().solid {
|
||||||
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||||
}
|
}
|
||||||
solid.place(self, tx, ty);
|
solid.place(self, tx, ty);
|
||||||
}
|
}
|
||||||
@@ -370,6 +520,19 @@ impl Board {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the [`ObjectId`]s of the **non-solid** objects at `(x, y)`.
|
||||||
|
///
|
||||||
|
/// These are the targets of an `enter` hook when a solid relocates onto the
|
||||||
|
/// cell (terrain is always solid, so only objects can be non-solid). Mirrors
|
||||||
|
/// [`object_ids_at`](Board::object_ids_at) / [`solid_object_id_at`](Board::solid_object_id_at).
|
||||||
|
pub fn non_solid_object_ids_at(&self, x: usize, y: usize) -> Vec<ObjectId> {
|
||||||
|
self.objects
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, o)| o.x == x && o.y == y && !o.solid)
|
||||||
|
.map(|(&id, _)| id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns a borrow of the actual object at `(x, y)` if any
|
/// Returns a borrow of the actual object at `(x, y)` if any
|
||||||
pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option<ObjectId> {
|
pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option<ObjectId> {
|
||||||
self.objects.iter().find_map(|(&id, o)| {
|
self.objects.iter().find_map(|(&id, o)| {
|
||||||
@@ -427,23 +590,19 @@ impl Board {
|
|||||||
/// drawing tools cannot place, remove, or alter a floor):
|
/// drawing tools cannot place, remove, or alter a floor):
|
||||||
///
|
///
|
||||||
/// - **Terrain** (`arch != Empty`, always solid today): removes any solid object
|
/// - **Terrain** (`arch != Empty`, always solid today): removes any solid object
|
||||||
/// already in the cell, then writes `(glyph, arch)` into the cell's terrain.
|
/// already in the cell, then writes `(glyph, arch)` into the grid cell.
|
||||||
/// - **Erase** (`arch == Empty`): removes the cell's terrain *and* every object in
|
/// - **Erase** (`arch == Empty`): removes the grid cell's terrain *and* every
|
||||||
/// it, leaving the floor (a visible `Empty` cell on a lower layer) in place.
|
/// object in it, leaving the floor beneath in place.
|
||||||
///
|
///
|
||||||
/// Terrain is written to the cell's existing terrain layer (the single non-`Empty`
|
/// A vacated grid cell becomes a transparent `Empty` so the floor shows through.
|
||||||
/// archetype across layers, if any) or else the top layer; a vacated terrain cell
|
/// Panics if `(x, y)` is out of bounds.
|
||||||
/// becomes a transparent `Empty` so a lower floor shows through. Panics if `(x, y)`
|
|
||||||
/// is out of bounds.
|
|
||||||
pub fn place_archetype(&mut self, x: usize, y: usize, arch: Archetype, glyph: Glyph) {
|
pub fn place_archetype(&mut self, x: usize, y: usize, arch: Archetype, glyph: Glyph) {
|
||||||
if arch == Archetype::Empty {
|
if arch == Archetype::Empty {
|
||||||
// Erase: drop every object in the cell and clear its terrain (keep floor).
|
// Erase: drop every object in the cell and clear its grid cell (keep floor).
|
||||||
for id in self.object_ids_at(x, y) {
|
for id in self.object_ids_at(x, y) {
|
||||||
self.objects.remove(&id);
|
self.objects.remove(&id);
|
||||||
}
|
}
|
||||||
if let Some(z) = self.terrain_layer_at(x, y) {
|
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||||
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,10 +610,7 @@ impl Board {
|
|||||||
if let Some(id) = self.solid_object_id_at(x, y) {
|
if let Some(id) = self.solid_object_id_at(x, y) {
|
||||||
self.objects.remove(&id);
|
self.objects.remove(&id);
|
||||||
}
|
}
|
||||||
// Reuse the existing terrain layer if the cell already has terrain, else the
|
*self.get_mut(x, y) = (glyph, arch);
|
||||||
// top layer (so the new wall draws above any floor on a lower layer).
|
|
||||||
let z = self.terrain_layer_at(x, y).unwrap_or(self.layers.len() - 1);
|
|
||||||
*self.get_mut(z, x, y) = (glyph, arch);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces every terrain cell whose archetype is script-backed (e.g. a
|
/// Replaces every terrain cell whose archetype is script-backed (e.g. a
|
||||||
@@ -471,26 +627,23 @@ impl Board {
|
|||||||
/// already loaded as objects are untouched, so it is safe to call more than once.
|
/// already loaded as objects are untouched, so it is safe to call more than once.
|
||||||
pub fn expand_builtin_archetypes(&mut self) {
|
pub fn expand_builtin_archetypes(&mut self) {
|
||||||
use crate::builtin_scripts::builtin_tag;
|
use crate::builtin_scripts::builtin_tag;
|
||||||
// Collect first: the loop below mutates both layers and the object map.
|
// Collect first: the loop below mutates both the grid and the object map.
|
||||||
let mut found: Vec<(usize, usize, usize, Glyph, Archetype)> = Vec::new();
|
let mut found: Vec<(usize, usize, Glyph, Archetype)> = Vec::new();
|
||||||
for z in 0..self.layers.len() {
|
for y in 0..self.height {
|
||||||
for y in 0..self.height {
|
for x in 0..self.width {
|
||||||
for x in 0..self.width {
|
let (glyph, arch) = *self.get(x, y);
|
||||||
let (glyph, arch) = *self.get(z, x, y);
|
if matches!(arch, Archetype::Builtin(_, _)) {
|
||||||
if matches!(arch, Archetype::Builtin(_, _)) {
|
found.push((x, y, glyph, arch));
|
||||||
found.push((z, x, y, glyph, arch));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (z, x, y, glyph, arch) in found {
|
for (x, y, glyph, arch) in found {
|
||||||
// Vacate the terrain cell (revealing any floor beneath), then spawn the
|
// Vacate the grid cell (revealing any floor beneath), then spawn the
|
||||||
// object — mirroring `resolve_entry`'s object template.
|
// object — mirroring `resolve_entry`'s object template.
|
||||||
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||||
let Archetype::Builtin(b, _alias) = arch else { continue };
|
let Archetype::Builtin(b, _alias) = arch else { continue };
|
||||||
let beh = b.behavior();
|
let beh = b.behavior();
|
||||||
let mut obj = ObjectDef::new(x, y);
|
let mut obj = ObjectDef::new(x, y);
|
||||||
obj.z = z;
|
|
||||||
obj.glyph = glyph;
|
obj.glyph = glyph;
|
||||||
obj.solid = beh.solid;
|
obj.solid = beh.solid;
|
||||||
obj.opaque = beh.opaque;
|
obj.opaque = beh.opaque;
|
||||||
@@ -511,11 +664,16 @@ impl Board {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script
|
/// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script
|
||||||
/// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log.
|
/// `shift()` fn. Returns a [`ShiftOutcome`] carrying any error [`LogLine`]s for
|
||||||
pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> Vec<LogLine> {
|
/// the caller to log plus the `(from, to)` relocations it performed (so the
|
||||||
|
/// caller can fire `enter` on non-solids each moved solid landed on).
|
||||||
|
pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> ShiftOutcome {
|
||||||
// Validate all the cells are in bounds, error if not:
|
// Validate all the cells are in bounds, error if not:
|
||||||
if cells.iter().any(|&c| !self.in_bounds(c)) {
|
if cells.iter().any(|&c| !self.in_bounds(c)) {
|
||||||
return vec![LogLine::error("Called shift() with a cell out of bounds")]
|
return ShiftOutcome {
|
||||||
|
errors: vec![LogLine::error("Called shift() with a cell out of bounds")],
|
||||||
|
moves: Vec::new(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all the Solids at these cells:
|
// Get all the Solids at these cells:
|
||||||
@@ -568,22 +726,19 @@ impl Board {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now, move anything that we've decided is not blocked:
|
// Now, move anything that we've decided is not blocked, recording each
|
||||||
|
// relocation so the caller can fire `enter` at every destination.
|
||||||
|
let mut moves = Vec::new();
|
||||||
for (curr_idx, curr) in solids.iter().enumerate() {
|
for (curr_idx, curr) in solids.iter().enumerate() {
|
||||||
if let Some(solid) = curr && !blocked.contains(&curr_idx) {
|
if let Some(solid) = curr && !blocked.contains(&curr_idx) {
|
||||||
|
let origin = cells[curr_idx];
|
||||||
let target = cells[(curr_idx + 1) % cells.len()];
|
let target = cells[(curr_idx + 1) % cells.len()];
|
||||||
solid.place(self, target.0 as usize, target.1 as usize);
|
solid.place(self, target.0 as usize, target.1 as usize);
|
||||||
|
moves.push((origin, target));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
vec![]
|
ShiftOutcome { errors: Vec::new(), moves }
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
|
|
||||||
/// (the cell's single terrain archetype, if any). By the one-solid-per-cell
|
|
||||||
/// invariant there is at most one such layer.
|
|
||||||
fn terrain_layer_at(&self, x: usize, y: usize) -> Option<usize> {
|
|
||||||
(0..self.layers.len()).find(|&z| self.get(z, x, y).1 != Archetype::Empty)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the queues of all objects on this board: called when entering a board, objects
|
/// Clear the queues of all objects on this board: called when entering a board, objects
|
||||||
@@ -600,20 +755,20 @@ impl Board {
|
|||||||
pub(crate) mod tests {
|
pub(crate) mod tests {
|
||||||
use super::Board;
|
use super::Board;
|
||||||
use crate::archetype::{Archetype, Builtin};
|
use crate::archetype::{Archetype, Builtin};
|
||||||
|
use crate::floor::Floor;
|
||||||
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::Direction;
|
use crate::utils::Direction;
|
||||||
use crate::utils::{ObjectId, PlayerPos};
|
use crate::utils::{ObjectId, PlayerPos};
|
||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
/// Builds an all-empty `w×h` single-layer board with the given player position
|
/// Builds an all-empty `w×h` board with the given player position and objects.
|
||||||
/// and objects (all on layer 0). Assigns sequential ids (1..=n) to objects.
|
/// Assigns sequential ids (1..=n) to objects.
|
||||||
///
|
///
|
||||||
/// The single layer is fully transparent, so terrain stamped via [`crate_at`]
|
/// The grid is fully transparent (a blank floor), so terrain stamped via
|
||||||
/// etc. always lands on the board's top layer. Use [`add_floor`] to slip a
|
/// [`crate_at`] etc. lands on the single grid. Use [`add_floor`] to give the
|
||||||
/// visible floor layer underneath.
|
/// board a visible fixed floor underneath.
|
||||||
pub(crate) fn open_board(
|
pub(crate) fn open_board(
|
||||||
w: usize,
|
w: usize,
|
||||||
h: usize,
|
h: usize,
|
||||||
@@ -631,9 +786,9 @@ pub(crate) mod tests {
|
|||||||
name: "test".into(),
|
name: "test".into(),
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
layers: vec![Layer {
|
grid: vec![(Glyph::transparent(), Archetype::Empty); w * h],
|
||||||
cells: vec![(Glyph::transparent(), Archetype::Empty); w * h],
|
floor: Floor::Blank,
|
||||||
}],
|
decorations: Vec::new(),
|
||||||
player: PlayerPos {
|
player: PlayerPos {
|
||||||
x: player.0,
|
x: player.0,
|
||||||
y: player.1,
|
y: player.1,
|
||||||
@@ -642,47 +797,31 @@ pub(crate) mod tests {
|
|||||||
next_object_id,
|
next_object_id,
|
||||||
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inserts a visible floor layer (filled with `glyph`) below everything,
|
/// Gives the board a uniform fixed floor glyph (the single-grid replacement for
|
||||||
/// bumping existing terrain and objects up one layer.
|
/// the old separate floor layer).
|
||||||
pub(crate) fn add_floor(board: &mut Board, glyph: Glyph) {
|
pub(crate) fn add_floor(board: &mut Board, glyph: Glyph) {
|
||||||
let count = board.width * board.height;
|
board.floor = Floor::Fixed(glyph);
|
||||||
board.layers.insert(
|
|
||||||
0,
|
|
||||||
Layer {
|
|
||||||
cells: vec![(glyph, Archetype::Empty); count],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
for o in board.objects.values_mut() {
|
|
||||||
o.z += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The index of the board's top (terrain) layer, where stamps are written.
|
/// Stamps a crate cell onto the grid.
|
||||||
fn top(board: &Board) -> usize {
|
|
||||||
board.layers.len() - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stamps a crate cell onto the board's top layer.
|
|
||||||
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
|
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
|
||||||
let z = top(board);
|
*board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
|
||||||
*board.get_mut(z, x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stamps a wall cell onto the board's top layer.
|
/// Stamps a wall cell onto the grid.
|
||||||
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
|
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
|
||||||
let z = top(board);
|
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
||||||
*board.get_mut(z, x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stamps an arbitrary archetype cell onto the board's top layer.
|
/// Stamps an arbitrary archetype cell onto the grid.
|
||||||
pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
|
pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
|
||||||
let z = top(board);
|
*board.get_mut(x, y) = (arch.default_glyph(), arch);
|
||||||
*board.get_mut(z, x, y) = (arch.default_glyph(), arch);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -752,8 +891,8 @@ pub(crate) mod tests {
|
|||||||
let mut board = open_board(3, 1, (0, 0), vec![]);
|
let mut board = open_board(3, 1, (0, 0), vec![]);
|
||||||
crate_at(&mut board, 1, 0);
|
crate_at(&mut board, 1, 0);
|
||||||
assert!(board.can_push(1, 0, Direction::East));
|
assert!(board.can_push(1, 0, Direction::East));
|
||||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // no mutation
|
assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation
|
||||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Empty);
|
assert_eq!(board.get(2, 0).1, Archetype::Empty);
|
||||||
|
|
||||||
let mut board = open_board(3, 1, (0, 0), vec![]);
|
let mut board = open_board(3, 1, (0, 0), vec![]);
|
||||||
crate_at(&mut board, 1, 0);
|
crate_at(&mut board, 1, 0);
|
||||||
@@ -770,7 +909,7 @@ pub(crate) mod tests {
|
|||||||
// Crate with open space ahead: shiftable.
|
// Crate with open space ahead: shiftable.
|
||||||
crate_at(&mut board, 0, 0);
|
crate_at(&mut board, 0, 0);
|
||||||
assert!(board.can_shift(0, 0, Direction::East));
|
assert!(board.can_shift(0, 0, Direction::East));
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // read-only
|
assert_eq!(board.get(0, 0).1, Archetype::Crate); // read-only
|
||||||
|
|
||||||
// Crate with another pushable crate ahead: still shiftable (unlike can_push,
|
// Crate with another pushable crate ahead: still shiftable (unlike can_push,
|
||||||
// which would follow the chain to the wall and fail).
|
// which would follow the chain to the wall and fail).
|
||||||
@@ -816,8 +955,8 @@ pub(crate) mod tests {
|
|||||||
crate_at(&mut board, 1, 0);
|
crate_at(&mut board, 1, 0);
|
||||||
assert!(board.can_push(1, 0, Direction::East));
|
assert!(board.can_push(1, 0, Direction::East));
|
||||||
board.push(1, 0, Direction::East);
|
board.push(1, 0, Direction::East);
|
||||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
|
assert_eq!(board.get(1, 0).1, Archetype::Empty);
|
||||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
|
assert_eq!(board.get(2, 0).1, Archetype::Crate);
|
||||||
assert_eq!((board.player.x, board.player.y), (3, 0));
|
assert_eq!((board.player.x, board.player.y), (3, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,7 +968,7 @@ pub(crate) mod tests {
|
|||||||
wall_at(&mut board, 3, 0);
|
wall_at(&mut board, 3, 0);
|
||||||
assert!(!board.can_push(1, 0, Direction::East));
|
assert!(!board.can_push(1, 0, Direction::East));
|
||||||
board.push(1, 0, Direction::East); // no-op
|
board.push(1, 0, Direction::East); // no-op
|
||||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
|
assert_eq!(board.get(1, 0).1, Archetype::Crate);
|
||||||
assert_eq!((board.player.x, board.player.y), (2, 0));
|
assert_eq!((board.player.x, board.player.y), (2, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,7 +991,7 @@ pub(crate) mod tests {
|
|||||||
a: 255,
|
a: 255,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// Floor on a lower layer, a wall on the top (terrain) layer at (0,0).
|
// A fixed floor attribute, a wall on the grid at (0,0).
|
||||||
add_floor(&mut board, floor_glyph);
|
add_floor(&mut board, floor_glyph);
|
||||||
wall_at(&mut board, 0, 0);
|
wall_at(&mut board, 0, 0);
|
||||||
// The wall (solid) draws over the floor; the empty cell reveals the floor.
|
// The wall (solid) draws over the floor; the empty cell reveals the floor.
|
||||||
@@ -860,29 +999,9 @@ pub(crate) mod tests {
|
|||||||
assert_eq!(board.glyph_at(1, 0), floor_glyph);
|
assert_eq!(board.glyph_at(1, 0), floor_glyph);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn glyph_at_draws_higher_layer_over_lower() {
|
|
||||||
// A non-solid object on an upper layer renders above a wall on a lower one.
|
|
||||||
let mut board = open_board(2, 1, (1, 0), vec![]);
|
|
||||||
wall_at(&mut board, 0, 0); // wall on layer 0
|
|
||||||
// Add an upper layer holding a visible, non-solid object at (0,0).
|
|
||||||
board.layers.push(Layer {
|
|
||||||
cells: vec![(Glyph::transparent(), Archetype::Empty); 2],
|
|
||||||
});
|
|
||||||
let mut obj = ObjectDef::new(0, 0);
|
|
||||||
obj.z = 1;
|
|
||||||
obj.solid = false;
|
|
||||||
obj.glyph = Glyph {
|
|
||||||
tile: '*' as u32,
|
|
||||||
..Glyph::transparent()
|
|
||||||
};
|
|
||||||
board.add_object(obj);
|
|
||||||
assert_eq!(board.glyph_at(0, 0).tile, '*' as u32);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn place_wall_keeps_floor_and_removes_solid_object() {
|
fn place_wall_keeps_floor_and_removes_solid_object() {
|
||||||
// Floor on layer 0, terrain layer on top; a solid object sits at (1,0).
|
// A fixed floor attribute; a solid object sits on the grid at (1,0).
|
||||||
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
|
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
|
||||||
let floor = Glyph {
|
let floor = Glyph {
|
||||||
tile: '.' as u32,
|
tile: '.' as u32,
|
||||||
@@ -893,9 +1012,8 @@ pub(crate) mod tests {
|
|||||||
let wall = Archetype::Wall.default_glyph();
|
let wall = Archetype::Wall.default_glyph();
|
||||||
board.place_archetype(1, 0, Archetype::Wall, wall);
|
board.place_archetype(1, 0, Archetype::Wall, wall);
|
||||||
|
|
||||||
// The wall landed on the terrain (top) layer; the floor below is untouched.
|
// The wall landed on the grid; the floor attribute is untouched.
|
||||||
assert_eq!(board.get(1, 1, 0), &(wall, Archetype::Wall));
|
assert_eq!(board.get(1, 0), &(wall, Archetype::Wall));
|
||||||
assert_eq!(board.get(0, 1, 0).0, floor);
|
|
||||||
// The solid object that was there is gone.
|
// The solid object that was there is gone.
|
||||||
assert!(board.object_ids_at(1, 0).is_empty());
|
assert!(board.object_ids_at(1, 0).is_empty());
|
||||||
assert_eq!(board.glyph_at(1, 0), wall);
|
assert_eq!(board.glyph_at(1, 0), wall);
|
||||||
@@ -903,17 +1021,17 @@ pub(crate) mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn place_wall_overwrites_existing_terrain_in_place() {
|
fn place_wall_overwrites_existing_terrain_in_place() {
|
||||||
// A crate already occupies the top layer at (1,0).
|
// A crate already occupies the grid at (1,0).
|
||||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||||
crate_at(&mut board, 1, 0);
|
crate_at(&mut board, 1, 0);
|
||||||
let wall = Archetype::Wall.default_glyph();
|
let wall = Archetype::Wall.default_glyph();
|
||||||
board.place_archetype(1, 0, Archetype::Wall, wall);
|
board.place_archetype(1, 0, Archetype::Wall, wall);
|
||||||
assert_eq!(board.get(0, 1, 0), &(wall, Archetype::Wall));
|
assert_eq!(board.get(1, 0), &(wall, Archetype::Wall));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn erase_removes_terrain_and_objects_but_keeps_floor() {
|
fn erase_removes_terrain_and_objects_but_keeps_floor() {
|
||||||
// Floor, a wall on the terrain layer, and a (non-solid) object all at (1,0).
|
// A fixed floor attribute, a wall on the grid, and a (non-solid) object at (1,0).
|
||||||
let mut obj = ObjectDef::new(1, 0);
|
let mut obj = ObjectDef::new(1, 0);
|
||||||
obj.solid = false;
|
obj.solid = false;
|
||||||
let mut board = open_board(3, 1, (2, 0), vec![obj]);
|
let mut board = open_board(3, 1, (2, 0), vec![obj]);
|
||||||
@@ -926,13 +1044,12 @@ pub(crate) mod tests {
|
|||||||
|
|
||||||
board.place_archetype(1, 0, Archetype::Empty, Glyph::transparent());
|
board.place_archetype(1, 0, Archetype::Empty, Glyph::transparent());
|
||||||
|
|
||||||
// Terrain cleared to transparent Empty; object removed; floor still there.
|
// Grid cell cleared to transparent Empty; object removed; floor still there.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
board.get(1, 1, 0),
|
board.get(1, 0),
|
||||||
&(Glyph::transparent(), Archetype::Empty)
|
&(Glyph::transparent(), Archetype::Empty)
|
||||||
);
|
);
|
||||||
assert!(board.object_ids_at(1, 0).is_empty());
|
assert!(board.object_ids_at(1, 0).is_empty());
|
||||||
assert_eq!(board.get(0, 1, 0).0, floor);
|
|
||||||
assert_eq!(board.glyph_at(1, 0), floor);
|
assert_eq!(board.glyph_at(1, 0), floor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,7 +1069,7 @@ pub(crate) mod tests {
|
|||||||
board.expand_builtin_archetypes();
|
board.expand_builtin_archetypes();
|
||||||
|
|
||||||
// The terrain cell is vacated and a scripted object takes its place.
|
// The terrain cell is vacated and a scripted object takes its place.
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
|
assert_eq!(board.get(0, 0).1, Archetype::Empty);
|
||||||
let obj = board.objects.values().next().expect("spinner object");
|
let obj = board.objects.values().next().expect("spinner object");
|
||||||
assert_eq!((obj.x, obj.y), (0, 0));
|
assert_eq!((obj.x, obj.y), (0, 0));
|
||||||
assert!(obj.solid);
|
assert!(obj.solid);
|
||||||
@@ -980,8 +1097,8 @@ pub(crate) mod tests {
|
|||||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||||
crate_at(&mut board, 0, 0);
|
crate_at(&mut board, 0, 0);
|
||||||
let errs = board.apply_shift(&[(0, 0), (9, 0)]);
|
let errs = board.apply_shift(&[(0, 0), (9, 0)]);
|
||||||
assert_eq!(errs.len(), 1);
|
assert_eq!(errs.errors.len(), 1);
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged
|
assert_eq!(board.get(0, 0).1, Archetype::Crate); // unchanged
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1005,11 +1122,11 @@ pub(crate) mod tests {
|
|||||||
// Crate(3,0)→(4,0), Crate(4,0)→(0,0) wrap
|
// Crate(3,0)→(4,0), Crate(4,0)→(0,0) wrap
|
||||||
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]);
|
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]);
|
||||||
|
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // wrapped from (4,0)
|
assert_eq!(board.get(0, 0).1, Archetype::Crate); // wrapped from (4,0)
|
||||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // moved from (0,0)
|
assert_eq!(board.get(1, 0).1, Archetype::Crate); // moved from (0,0)
|
||||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Wall); // blocked, immobile
|
assert_eq!(board.get(2, 0).1, Archetype::Wall); // blocked, immobile
|
||||||
assert_eq!(board.get(0, 3, 0).1, Archetype::Empty); // cleared, crate moved
|
assert_eq!(board.get(3, 0).1, Archetype::Empty); // cleared, crate moved
|
||||||
assert_eq!(board.get(0, 4, 0).1, Archetype::Crate); // moved from (3,0)
|
assert_eq!(board.get(4, 0).1, Archetype::Crate); // moved from (3,0)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1028,9 +1145,9 @@ pub(crate) mod tests {
|
|||||||
|
|
||||||
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
|
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
|
||||||
|
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // HCrate moved out
|
assert_eq!(board.get(0, 0).1, Archetype::Empty); // HCrate moved out
|
||||||
assert_eq!(board.get(0, 1, 0).1, Archetype::HCrate); // moved from (0,0)
|
assert_eq!(board.get(1, 0).1, Archetype::HCrate); // moved from (0,0)
|
||||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); // moved from (1,0)
|
assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subcase B: VCrate stays; Crate behind it still moves
|
// Subcase B: VCrate stays; Crate behind it still moves
|
||||||
@@ -1046,9 +1163,9 @@ pub(crate) mod tests {
|
|||||||
// Crate at (1,0) is NOT in blocked, so it moves.
|
// Crate at (1,0) is NOT in blocked, so it moves.
|
||||||
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
|
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
|
||||||
|
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::VCrate); // immobile
|
assert_eq!(board.get(0, 0).1, Archetype::VCrate); // immobile
|
||||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty); // crate moved out
|
assert_eq!(board.get(1, 0).1, Archetype::Empty); // crate moved out
|
||||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); // moved from (1,0)
|
assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1067,9 +1184,9 @@ pub(crate) mod tests {
|
|||||||
|
|
||||||
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
|
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
|
||||||
|
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // VCrate moved out
|
assert_eq!(board.get(0, 0).1, Archetype::Empty); // VCrate moved out
|
||||||
assert_eq!(board.get(0, 0, 1).1, Archetype::VCrate); // moved from (0,0)
|
assert_eq!(board.get(0, 1).1, Archetype::VCrate); // moved from (0,0)
|
||||||
assert_eq!(board.get(0, 0, 2).1, Archetype::Crate); // moved from (0,1)
|
assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subcase B: HCrate stays; Crate behind it still moves
|
// Subcase B: HCrate stays; Crate behind it still moves
|
||||||
@@ -1085,10 +1202,75 @@ pub(crate) mod tests {
|
|||||||
// Crate at (0,1) is NOT in blocked, so it moves.
|
// Crate at (0,1) is NOT in blocked, so it moves.
|
||||||
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
|
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
|
||||||
|
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::HCrate); // immobile
|
assert_eq!(board.get(0, 0).1, Archetype::HCrate); // immobile
|
||||||
assert_eq!(board.get(0, 0, 1).1, Archetype::Empty); // crate moved out
|
assert_eq!(board.get(0, 1).1, Archetype::Empty); // crate moved out
|
||||||
assert_eq!(board.get(0, 0, 2).1, Archetype::Crate); // moved from (0,1)
|
assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lighting_none_when_not_dark() {
|
||||||
|
// A lit board needs no lighting; front-ends draw every cell.
|
||||||
|
let board = open_board(5, 1, (0, 0), vec![]);
|
||||||
|
assert!(!board.dark);
|
||||||
|
assert!(board.lighting(10).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wall_is_opaque_empty_is_not() {
|
||||||
|
let mut board = open_board(3, 1, (0, 0), vec![]);
|
||||||
|
wall_at(&mut board, 1, 0);
|
||||||
|
assert!(board.is_opaque_at(1, 0)); // wall blocks sight
|
||||||
|
assert!(!board.is_opaque_at(2, 0)); // empty cell is transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dark_board_hides_cells_behind_a_wall() {
|
||||||
|
// Player at the left end of a 1-wide corridor; a wall at x=2 occludes
|
||||||
|
// everything past it. The player's torch lights cells before the wall
|
||||||
|
// (and the wall itself); the cells behind the wall are neither lit nor
|
||||||
|
// in line of sight, so they are not visible.
|
||||||
|
let mut board = open_board(5, 1, (0, 0), vec![]);
|
||||||
|
board.dark = true;
|
||||||
|
wall_at(&mut board, 2, 0);
|
||||||
|
|
||||||
|
let lit = board.lighting(10).expect("dark board yields Lighting");
|
||||||
|
assert!(lit.is_visible(0, 0)); // the player's own cell
|
||||||
|
assert!(lit.is_visible(1, 0)); // open cell before the wall
|
||||||
|
assert!(lit.is_visible(2, 0)); // the wall itself (light_walls = true)
|
||||||
|
assert!(!lit.is_visible(3, 0)); // occluded behind the wall
|
||||||
|
assert!(!lit.is_visible(4, 0)); // occluded behind the wall
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlit_cell_in_sight_is_not_visible() {
|
||||||
|
// A long lit-free corridor: with a tiny torch, far cells are in line of
|
||||||
|
// sight but receive no light, so they are not visible (LOS ∩ lit).
|
||||||
|
let mut board = open_board(10, 1, (0, 0), vec![]);
|
||||||
|
board.dark = true;
|
||||||
|
let lit = board.lighting(2).expect("dark board yields Lighting");
|
||||||
|
assert!(lit.is_visible(0, 0)); // at the torch
|
||||||
|
assert!(lit.is_visible(1, 0)); // within the torch radius
|
||||||
|
assert!(!lit.is_visible(8, 0)); // in sight but unlit → dark
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_light_tints_toward_its_color() {
|
||||||
|
// A dark board with no player torch and one red-glyph light object: the
|
||||||
|
// object's cell is lit red, so a white base tints red (green/blue killed).
|
||||||
|
let mut board = open_board(3, 1, (1, 0), vec![]);
|
||||||
|
board.dark = true;
|
||||||
|
let mut lamp = ObjectDef::new(1, 0);
|
||||||
|
lamp.solid = false;
|
||||||
|
lamp.light = 4;
|
||||||
|
lamp.glyph = Glyph { tile: 1, fg: Rgba8 { r: 255, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } };
|
||||||
|
board.add_object(lamp);
|
||||||
|
|
||||||
|
let lit = board.lighting(0).expect("dark board yields Lighting"); // no player torch
|
||||||
|
let white = Rgba8 { r: 255, g: 255, b: 255, a: 255 };
|
||||||
|
let t = lit.tint(1, 0, white);
|
||||||
|
assert!(t.r > 0, "red channel survives");
|
||||||
|
assert_eq!(t.g, 0, "green killed by red light");
|
||||||
|
assert_eq!(t.b, 0, "blue killed by red light");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-1
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+368
-91
@@ -1,12 +1,49 @@
|
|||||||
use crate::action::{Action, SendArg};
|
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, PlayerPos};
|
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;
|
||||||
|
|
||||||
@@ -102,7 +139,7 @@ impl GameState {
|
|||||||
board_transition: None,
|
board_transition: None,
|
||||||
player
|
player
|
||||||
};
|
};
|
||||||
state.drain_errors();
|
state.drain_log();
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,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)]),
|
||||||
};
|
};
|
||||||
@@ -145,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
|
||||||
@@ -164,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).
|
||||||
@@ -178,37 +232,54 @@ 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
|
||||||
|
// `drain_log`. A cheap Rc clone lets us push while the board borrow (which
|
||||||
|
// also borrows `self`) is held.
|
||||||
|
let log_sink = self.scripts.log_sink().clone();
|
||||||
|
let mut bumps: Vec<(ObjectId, Direction)> = Vec::new();
|
||||||
|
// `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
|
// Net change to player stats from AddGems / AlterHealth actions; applied
|
||||||
// to `self` after the board borrow drops.
|
// to `self` after the board borrow drops.
|
||||||
let mut gem_delta: i64 = 0;
|
let mut gem_delta: i64 = 0;
|
||||||
@@ -224,8 +295,15 @@ impl GameState {
|
|||||||
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) => {
|
||||||
@@ -233,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,
|
||||||
@@ -282,37 +364,118 @@ 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,
|
||||||
@@ -327,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
|
||||||
@@ -347,16 +509,54 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
for (color, present) in key_changes {
|
for (color, present) in key_changes {
|
||||||
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
|
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
|
||||||
self.log.push(LogLine::error(format!("set_key: unknown color {color:?}")));
|
log_sink.error(format!("set_key: unknown color {color:?}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (bumped, bumper) in bumps {
|
// Return the reactions for the caller's settle pass rather than firing them here.
|
||||||
self.scripts.run_bump(bumped, bumper);
|
Events {
|
||||||
|
bumps,
|
||||||
|
enters,
|
||||||
|
sends,
|
||||||
}
|
}
|
||||||
for (target, fn_name, arg) in sends {
|
}
|
||||||
self.scripts.run_send(target, &fn_name, arg);
|
|
||||||
|
/// 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
|
||||||
@@ -370,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, SendArg::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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,12 +635,16 @@ 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): (i64, i64) = dir.into();
|
let (dx, dy): (i64, i64) = dir.into();
|
||||||
let mut board = self.board_mut();
|
let mut board = self.board_mut();
|
||||||
@@ -447,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 i64;
|
board.player.x = nx as i64;
|
||||||
board.player.y = ny as i64;
|
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
|
||||||
@@ -471,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();
|
||||||
|
// 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.
|
// 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
|
||||||
|
/// solid lands on: the mover's destination cell (only when the mover is itself solid)
|
||||||
|
/// and each cell a pushed crate moved into (crates are always solid entrants).
|
||||||
|
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 (dx, dy): (i64, i64) = dir.into();
|
||||||
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?;
|
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);
|
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)]
|
||||||
@@ -595,8 +849,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
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]
|
||||||
@@ -625,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 {
|
||||||
@@ -668,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]
|
||||||
@@ -680,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]
|
||||||
@@ -693,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]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use color::Rgba8;
|
|||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use rhai::Engine;
|
use rhai::Engine;
|
||||||
use crate::script::Registerable;
|
use crate::script::Registerable;
|
||||||
use crate::utils::ErrorSink;
|
use crate::utils::LogSink;
|
||||||
|
|
||||||
/// The visual representation of a single board cell.
|
/// The visual representation of a single board cell.
|
||||||
///
|
///
|
||||||
@@ -36,7 +36,7 @@ impl Hash for Glyph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Registerable for Glyph {
|
impl Registerable for Glyph {
|
||||||
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
|
fn register(engine: &mut Engine, _log_sink: LogSink) {
|
||||||
engine.register_type_with_name::<Glyph>("Glyph");
|
engine.register_type_with_name::<Glyph>("Glyph");
|
||||||
engine.register_get("tile", |g: &mut Glyph| g.tile);
|
engine.register_get("tile", |g: &mut Glyph| g.tile);
|
||||||
engine.register_get("fg", |g: &mut Glyph| {
|
engine.register_get("fg", |g: &mut Glyph| {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use color::Rgba8;
|
|||||||
use rhai::Engine;
|
use rhai::Engine;
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
use crate::script::Registerable;
|
use crate::script::Registerable;
|
||||||
use crate::utils::ErrorSink;
|
use crate::utils::LogSink;
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug)]
|
#[derive(Copy, Clone, Debug)]
|
||||||
pub enum KeyType {
|
pub enum KeyType {
|
||||||
@@ -88,7 +88,7 @@ impl Keyring {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Registerable for Keyring {
|
impl Registerable for Keyring {
|
||||||
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
|
fn register(engine: &mut Engine, _log_sink: LogSink) {
|
||||||
engine.register_type_with_name::<Keyring>("Keyring")
|
engine.register_type_with_name::<Keyring>("Keyring")
|
||||||
.register_get("red", |keyring: &mut Keyring| keyring.red)
|
.register_get("red", |keyring: &mut Keyring| keyring.red)
|
||||||
.register_get("orange", |keyring: &mut Keyring| keyring.orange)
|
.register_get("orange", |keyring: &mut Keyring| keyring.orange)
|
||||||
|
|||||||
+60
-96
@@ -1,54 +1,37 @@
|
|||||||
//! Palette layers: the unit of the map-file format and the board's draw stack.
|
//! The board grid: the palette+char map-file unit and its load-time conversion.
|
||||||
//!
|
//!
|
||||||
//! A board is an ordered list of **layers**, drawn bottom (index 0) to top. Each
|
//! 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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -26,6 +28,7 @@ pub mod player;
|
|||||||
pub mod keys;
|
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)]
|
||||||
|
|||||||
+346
-115
@@ -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::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, PlayerPos, 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,
|
||||||
@@ -252,10 +335,45 @@ impl TryFrom<MapFile> for Board {
|
|||||||
next_object_id += 1;
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
next_object_id += 1;
|
||||||
|
}
|
||||||
|
|
||||||
// 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",
|
||||||
@@ -267,17 +385,27 @@ 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,
|
||||||
|
floor,
|
||||||
|
decorations,
|
||||||
player: PlayerPos {
|
player: PlayerPos {
|
||||||
x: px as i64,
|
x: px as i64,
|
||||||
y: py as i64,
|
y: py as i64,
|
||||||
@@ -286,17 +414,69 @@ impl TryFrom<MapFile> for Board {
|
|||||||
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.
|
||||||
@@ -310,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 {
|
||||||
@@ -340,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));
|
||||||
@@ -361,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.
|
||||||
@@ -388,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(),
|
||||||
@@ -397,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(),
|
||||||
@@ -413,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(),
|
||||||
@@ -433,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,
|
||||||
@@ -441,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,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,
|
||||||
@@ -55,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
|
||||||
@@ -101,12 +102,12 @@ 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(),
|
||||||
|
|||||||
+131
-81
@@ -6,8 +6,11 @@
|
|||||||
//!
|
//!
|
||||||
//! - `init(me, state)` — run once after the whole map is loaded (see [`ScriptHost::run_init`]).
|
//! - `init(me, state)` — run once after the whole map is loaded (see [`ScriptHost::run_init`]).
|
||||||
//! - `tick(me, state, dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]).
|
//! - `tick(me, state, dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]).
|
||||||
//! - `bump(me, state, id)` — run when another mover steps into this object's cell, with the
|
//! - `bump(me, dir)` — run when a solid (the player, an object, or a pushed crate) presses into this
|
||||||
//! bumper's object index (`-1` for the player); see [`ScriptHost::run_bump`].
|
//! object's cell, with the [`Direction`] the bump came *from*; see [`ScriptHost::run_bump`].
|
||||||
|
//! - `enter(me, dir)` — run when a solid (the player, an object, or a pushed crate) relocates *onto*
|
||||||
|
//! this **non-solid** object's cell, with the [`Direction`] the entrant came *from* (best-effort for
|
||||||
|
//! teleport/shift); see [`ScriptHost::run_enter`].
|
||||||
//! - `grab(me, state)` — run when the player walks onto a `grab` object; see [`ScriptHost::run_grab`].
|
//! - `grab(me, state)` — run when the player walks onto a `grab` object; see [`ScriptHost::run_grab`].
|
||||||
//! Typically adds a stat + `die()`s.
|
//! Typically adds a stat + `die()`s.
|
||||||
//!
|
//!
|
||||||
@@ -27,7 +30,7 @@
|
|||||||
//!
|
//!
|
||||||
//! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`,
|
//! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`,
|
||||||
//! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`,
|
//! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`,
|
||||||
//! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`,
|
//! `send(target_id, fn_name [, arg])`, `teleport(id, x, y)`, `push(x, y, dir)`,
|
||||||
//! `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`
|
//! `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`
|
||||||
|
|
||||||
use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST};
|
use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST};
|
||||||
@@ -35,14 +38,12 @@ use crate::game::SAY_DURATION;
|
|||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::map_file::parse_color;
|
use crate::map_file::parse_color;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::utils::{Direction, ErrorSink, Hook, ObjectId};
|
use crate::utils::{Direction, LogSink, Hook, ObjectId};
|
||||||
use rhai::{
|
use rhai::{
|
||||||
Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext,
|
Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext,
|
||||||
Scope, AST,
|
Scope, AST,
|
||||||
};
|
};
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::rc::Rc;
|
|
||||||
use crate::api::board::BoardRef;
|
use crate::api::board::BoardRef;
|
||||||
use crate::api::object_info::ObjectInfo;
|
use crate::api::object_info::ObjectInfo;
|
||||||
use crate::api::player::PlayerWithPos;
|
use crate::api::player::PlayerWithPos;
|
||||||
@@ -55,12 +56,9 @@ use crate::player::PlayerRef;
|
|||||||
/// Types which can be registered to be sent to Rhai
|
/// Types which can be registered to be sent to Rhai
|
||||||
pub trait Registerable {
|
pub trait Registerable {
|
||||||
/// Register this type and relevant getters / setters with a Rhai engine
|
/// Register this type and relevant getters / setters with a Rhai engine
|
||||||
fn register(engine: &mut Engine, error_sink: ErrorSink);
|
fn register(engine: &mut Engine, log_sink: LogSink);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The board queue: actions promoted from object queues, awaiting resolution.
|
|
||||||
pub type BoardQueue = Rc<RefCell<Vec<BoardAction>>>;
|
|
||||||
|
|
||||||
/// A compiled script plus which lifecycle hooks it defines.
|
/// A compiled script plus which lifecycle hooks it defines.
|
||||||
struct CompiledScript {
|
struct CompiledScript {
|
||||||
ast: AST,
|
ast: AST,
|
||||||
@@ -68,6 +66,7 @@ struct CompiledScript {
|
|||||||
has_tick: bool,
|
has_tick: bool,
|
||||||
has_bump: bool,
|
has_bump: bool,
|
||||||
has_grab: bool,
|
has_grab: bool,
|
||||||
|
has_enter: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompiledScript {
|
impl CompiledScript {
|
||||||
@@ -76,7 +75,8 @@ impl CompiledScript {
|
|||||||
Hook::Init => self.has_init,
|
Hook::Init => self.has_init,
|
||||||
Hook::Tick => self.has_tick,
|
Hook::Tick => self.has_tick,
|
||||||
Hook::Grab => self.has_grab,
|
Hook::Grab => self.has_grab,
|
||||||
Hook::Bump => self.has_bump
|
Hook::Bump => self.has_bump,
|
||||||
|
Hook::Enter => self.has_enter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,8 +98,7 @@ pub struct ScriptHost {
|
|||||||
engine: Engine,
|
engine: Engine,
|
||||||
scripts: HashMap<String, CompiledScript>,
|
scripts: HashMap<String, CompiledScript>,
|
||||||
scopes: HashMap<ObjectId, Scope<'static>>,
|
scopes: HashMap<ObjectId, Scope<'static>>,
|
||||||
board_queue: BoardQueue,
|
log_sink: LogSink,
|
||||||
errors: ErrorSink,
|
|
||||||
board: BoardRef
|
board: BoardRef
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,21 +111,20 @@ impl ScriptHost {
|
|||||||
/// `scripts` is the world-level script pool (script name → Rhai source); it is
|
/// `scripts` is the world-level script pool (script name → Rhai source); it is
|
||||||
/// read only during construction and not retained afterward.
|
/// read only during construction and not retained afterward.
|
||||||
pub fn new(board_ref: BoardRef, player: PlayerRef, script_sources: &HashMap<String, String>) -> Self {
|
pub fn new(board_ref: BoardRef, player: PlayerRef, script_sources: &HashMap<String, String>) -> Self {
|
||||||
let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new()));
|
let log_sink = LogSink::new();
|
||||||
let errors = ErrorSink::new();
|
|
||||||
let mut scopes = HashMap::new();
|
let mut scopes = HashMap::new();
|
||||||
|
|
||||||
let mut engine = Engine::new();
|
let mut engine = Engine::new();
|
||||||
|
|
||||||
PlayerWithPos::register(&mut engine, errors.clone());
|
PlayerWithPos::register(&mut engine, log_sink.clone());
|
||||||
Keyring::register(&mut engine, errors.clone());
|
Keyring::register(&mut engine, log_sink.clone());
|
||||||
BoardRef::register(&mut engine, errors.clone());
|
BoardRef::register(&mut engine, log_sink.clone());
|
||||||
ObjectInfo::register(&mut engine, errors.clone());
|
ObjectInfo::register(&mut engine, log_sink.clone());
|
||||||
Glyph::register(&mut engine, errors.clone());
|
Glyph::register(&mut engine, log_sink.clone());
|
||||||
ObjQueue::register(&mut engine, errors.clone());
|
ObjQueue::register(&mut engine, log_sink.clone());
|
||||||
Registry::register(&mut engine, errors.clone());
|
Registry::register(&mut engine, log_sink.clone());
|
||||||
|
|
||||||
register_write_api(&mut engine, board_ref.clone());
|
register_write_api(&mut engine, board_ref.clone(), log_sink.clone());
|
||||||
register_global_constants(&mut engine, board_ref.clone(), player.clone());
|
register_global_constants(&mut engine, board_ref.clone(), player.clone());
|
||||||
|
|
||||||
let board = board_ref.borrow();
|
let board = board_ref.borrow();
|
||||||
@@ -152,7 +150,7 @@ impl ScriptHost {
|
|||||||
Some(src) => src,
|
Some(src) => src,
|
||||||
None => {
|
None => {
|
||||||
failed.insert(key.clone());
|
failed.insert(key.clone());
|
||||||
errors.error(format!("object references unknown script '{key}'"));
|
log_sink.error(format!("object references unknown script '{key}'"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,13 +168,14 @@ impl ScriptHost {
|
|||||||
has_tick: defines("tick", 2),
|
has_tick: defines("tick", 2),
|
||||||
has_bump: defines("bump", 2),
|
has_bump: defines("bump", 2),
|
||||||
has_grab: defines("grab", 1),
|
has_grab: defines("grab", 1),
|
||||||
|
has_enter: defines("enter", 2),
|
||||||
ast,
|
ast,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
failed.insert(key.clone());
|
failed.insert(key.clone());
|
||||||
errors.error(format!("script '{key}' failed to compile: {err}"));
|
log_sink.error(format!("script '{key}' failed to compile: {err}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,43 +195,52 @@ impl ScriptHost {
|
|||||||
Self {
|
Self {
|
||||||
engine,
|
engine,
|
||||||
scripts,
|
scripts,
|
||||||
board_queue,
|
log_sink,
|
||||||
errors,
|
|
||||||
scopes,
|
scopes,
|
||||||
board: board_ref
|
board: board_ref
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calls `tick(dt)` on every scripted object that defines it, then drains queues.
|
/// Runs the `tick(me, dt)` hook on one object and returns the actions it
|
||||||
pub fn run_tick(&mut self, dt: f64) {
|
/// drained (see [`run_hook_on_one`](ScriptHost::run_hook_on_one)).
|
||||||
self.run_hook_on_all(Hook::Tick, dt);
|
pub(crate) fn run_tick_on(&mut self, id: ObjectId, dt: f64) -> Vec<BoardAction> {
|
||||||
|
self.run_hook_on(Hook::Tick, id, dt)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_init(&mut self) {
|
/// Runs the `init(me)` hook on one object and returns the actions it drained.
|
||||||
self.run_hook_on_all(Hook::Init, 0.0)
|
pub(crate) fn run_init_on(&mut self, id: ObjectId) -> Vec<BoardAction> {
|
||||||
|
self.run_hook_on(Hook::Init, id, 0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the given hook on every object that defines it. For hooks other than
|
/// Runs `hook` on the single object `id` and returns the actions it drained.
|
||||||
/// `Tick`, dt should just be 0.0
|
///
|
||||||
fn run_hook_on_all(&mut self, hook: Hook, dt: f64) {
|
/// The `dt` arg is only meaningful for `Tick` (it becomes the hook's `dt`
|
||||||
let all_ids = self.board.borrow().all_ids();
|
/// parameter and paces the object's delay draining); pass `0.0` otherwise.
|
||||||
|
pub(crate) fn run_hook_on(&mut self, hook: Hook, id: ObjectId, dt: f64) -> Vec<BoardAction> {
|
||||||
let arg = match hook {
|
let arg = match hook {
|
||||||
Hook::Tick => Some(Dynamic::from(dt)),
|
Hook::Tick => Some(Dynamic::from(dt)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
self.run_hook_on_one(hook, id, arg, dt)
|
||||||
for id in all_ids {
|
|
||||||
self.run_hook_on_one(hook, id, arg.clone(), dt)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_hook_on_one(&mut self, hook: Hook, id: ObjectId, arg: Option<Dynamic>, dt: f64) {
|
/// Calls one lifecycle `hook` on object `id` (if the script defines it), then
|
||||||
|
/// drains that object's ready actions into a fresh `Vec` and returns them —
|
||||||
|
/// [`GameState`](crate::game::GameState) applies them immediately, before the
|
||||||
|
/// next object runs, so each object sees the board state its actions run against.
|
||||||
|
fn run_hook_on_one(&mut self, hook: Hook, id: ObjectId, arg: Option<Dynamic>, dt: f64) -> Vec<BoardAction> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
|
if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
|
||||||
if let Some(script_key) = info.script_name.as_ref()
|
if let Some(script_key) = info.script_name.as_ref()
|
||||||
&& let Some(script) = self.scripts.get(script_key)
|
&& let Some(script) = self.scripts.get(script_key)
|
||||||
&& let Some(scope) = self.scopes.get_mut(&id) {
|
&& let Some(scope) = self.scopes.get_mut(&id) {
|
||||||
|
|
||||||
if script.has(hook) {
|
// `tick` only fires on an object whose previous output has fully
|
||||||
|
// drained. A non-empty queue means we just advance the pending
|
||||||
|
// actions this frame (the unconditional drain below) without
|
||||||
|
// re-running the script. Every other hook (init/bump/enter/grab/
|
||||||
|
// send) fires regardless of queued actions.
|
||||||
|
if script.has(hook) && (hook != Hook::Tick || info.queue.is_empty()) {
|
||||||
let mut args = vec![Dynamic::from(info.clone())];
|
let mut args = vec![Dynamic::from(info.clone())];
|
||||||
if let Some(d) = arg { args.push(d) }
|
if let Some(d) = arg { args.push(d) }
|
||||||
|
|
||||||
@@ -245,32 +253,42 @@ impl ScriptHost {
|
|||||||
hook.to_str(),
|
hook.to_str(),
|
||||||
args,
|
args,
|
||||||
) {
|
) {
|
||||||
self.errors.error(format!("script '{}' {} error: {err}", script_key, hook));
|
self.log_sink.error(format!("script '{}' {} error: {err}", script_key, hook));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Run the drain regardless of if we have the hook, otherwise
|
// Run the drain regardless of if we have the hook, otherwise
|
||||||
// things with no tick will never advance past a delay
|
// things with no tick will never advance past a delay
|
||||||
info.drain(self.board_queue.clone(), dt)
|
info.drain(&mut actions, dt)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
unreachable!("Object not found");
|
unreachable!("Object not found");
|
||||||
}
|
}
|
||||||
|
actions
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calls `bump(id)` on the object with [`ObjectId`] `object_id`, if it defines
|
/// Calls `bump(dir)` on the object with [`ObjectId`] `id`, if it defines the
|
||||||
/// the hook. After the hook, drains the object's queue with `dt = 0`.
|
/// hook, and returns the actions it drained. `dir` is the [`Direction`] the
|
||||||
pub fn run_bump(&mut self, id: ObjectId, bumper: i64) {
|
/// bump came *from* (it points from the bumped object toward the bumper).
|
||||||
self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(bumper)), 0.0);
|
pub(crate) fn run_bump(&mut self, id: ObjectId, dir: Direction) -> Vec<BoardAction> {
|
||||||
|
self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls `enter(dir)` on the object with [`ObjectId`] `id`, if it defines the
|
||||||
|
/// hook, and returns the actions it drained. Fired when a solid (the player, an
|
||||||
|
/// object, or a pushed crate) relocates onto this non-solid object's cell; `dir`
|
||||||
|
/// is the [`Direction`] the entrant came *from* (best-effort for teleport/shift).
|
||||||
|
pub(crate) fn run_enter(&mut self, id: ObjectId, dir: Direction) -> Vec<BoardAction> {
|
||||||
|
self.run_hook_on_one(Hook::Enter, id, Some(Dynamic::from(dir)), 0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the
|
/// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the
|
||||||
/// hook, then drains its queue with `dt = 0`.
|
/// hook, and returns the actions it drained.
|
||||||
///
|
///
|
||||||
/// Fired when the player walks onto a grab object or a grab object is pushed
|
/// Fired when the player walks onto a grab object (see
|
||||||
/// into the player (see [`GameState`](crate::game::GameState)). The hook
|
/// [`GameState`](crate::game::GameState)). The hook typically increments a
|
||||||
/// typically increments a player stat and removes the object via `die()`.
|
/// player stat and removes the object via `die()`.
|
||||||
pub fn run_grab(&mut self, object_id: ObjectId) {
|
pub(crate) fn run_grab(&mut self, object_id: ObjectId) -> Vec<BoardAction> {
|
||||||
self.run_hook_on_one(Hook::Grab, object_id, None, 0.0);
|
self.run_hook_on_one(Hook::Grab, object_id, None, 0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calls the named function on the object with [`ObjectId`] `target_id`.
|
/// Calls the named function on the object with [`ObjectId`] `target_id`.
|
||||||
@@ -280,8 +298,10 @@ impl ScriptHost {
|
|||||||
/// - If it has arity 1, we pass the ObjectInfo alone
|
/// - If it has arity 1, we pass the ObjectInfo alone
|
||||||
/// - If it has arity 0, we pass nothing
|
/// - If it has arity 0, we pass nothing
|
||||||
///
|
///
|
||||||
/// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`
|
/// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`.
|
||||||
pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) {
|
/// Returns the actions the call drained.
|
||||||
|
pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) -> Vec<BoardAction> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
|
if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
|
||||||
if let Some(script_key) = info.script_name.as_ref()
|
if let Some(script_key) = info.script_name.as_ref()
|
||||||
&& let Some(script) = self.scripts.get(script_key)
|
&& let Some(script) = self.scripts.get(script_key)
|
||||||
@@ -298,8 +318,8 @@ impl ScriptHost {
|
|||||||
|
|
||||||
// If it's not there at all, just bail:
|
// If it's not there at all, just bail:
|
||||||
if arities.is_empty() {
|
if arities.is_empty() {
|
||||||
self.errors.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
|
self.log_sink.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
|
||||||
return
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assemble the args
|
// Assemble the args
|
||||||
@@ -320,30 +340,51 @@ impl ScriptHost {
|
|||||||
fn_name,
|
fn_name,
|
||||||
args,
|
args,
|
||||||
) {
|
) {
|
||||||
self.errors.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
|
self.log_sink.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
|
||||||
}
|
}
|
||||||
info.drain(self.board_queue.clone(), 0.0)
|
info.drain(&mut actions, 0.0)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
unreachable!("Object id not found, tried to send");
|
unreachable!("Object id not found, tried to send");
|
||||||
}
|
}
|
||||||
|
actions
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes and returns the actions promoted onto the board queue.
|
/// Removes and returns the log lines (script `log()` output and errors)
|
||||||
pub(crate) fn take_board_queue(&mut self) -> Vec<BoardAction> {
|
/// collected since the last drain.
|
||||||
std::mem::take(&mut self.board_queue.borrow_mut())
|
pub fn take_logs(&mut self) -> Vec<LogLine> {
|
||||||
|
self.log_sink.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes and returns the errors collected since the last drain.
|
/// The shared, immediate log channel. Exposed so `GameState::apply_actions`
|
||||||
pub fn take_errors(&mut self) -> Vec<LogLine> {
|
/// can push its application-time errors (teleport/push/shift failures) onto
|
||||||
self.errors.take()
|
/// the same ordered channel as script `log()` output, instead of a side vec.
|
||||||
|
pub(crate) fn log_sink(&self) -> &LogSink {
|
||||||
|
&self.log_sink
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Write API ─────────────────────────────────────────────────────────────────
|
// ── Write API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn register_write_api(engine: &mut Engine, board: BoardRef) {
|
fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
|
||||||
engine.register_type_with_name::<Direction>("Direction");
|
engine.register_type_with_name::<Direction>("Direction");
|
||||||
|
// Rhai does not auto-derive comparison for custom types, so register `==`/`!=`
|
||||||
|
// to let scripts test the `bump` direction (e.g. `if dir == West { … }`).
|
||||||
|
engine.register_fn("==", |a: Direction, b: Direction| a == b);
|
||||||
|
engine.register_fn("!=", |a: Direction, b: Direction| a != b);
|
||||||
|
// Let a Direction interpolate/print as its name (e.g. in `log(`from ${dir}`)`).
|
||||||
|
let dir_name = |d: Direction| match d {
|
||||||
|
Direction::North => "North",
|
||||||
|
Direction::South => "South",
|
||||||
|
Direction::East => "East",
|
||||||
|
Direction::West => "West",
|
||||||
|
};
|
||||||
|
engine.register_fn("to_string", dir_name);
|
||||||
|
engine.register_fn("to_debug", dir_name);
|
||||||
|
// Expose the unit-step components so scripts can turn a direction into a cell
|
||||||
|
// offset (e.g. the bumper's cell is `(me.x + dir.dx, me.y + dir.dy)`).
|
||||||
|
engine.register_get("dx", |d: &mut Direction| d.dx());
|
||||||
|
engine.register_get("dy", |d: &mut Direction| d.dy());
|
||||||
|
|
||||||
// move(dir): enqueue a Move followed by a rate-limiting Delay.
|
// move(dir): enqueue a Move followed by a rate-limiting Delay.
|
||||||
let b = board.clone();
|
let b = board.clone();
|
||||||
@@ -377,6 +418,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) {
|
|||||||
emit(&b, source_of(&ctx), Action::SetTile(tile as u32));
|
emit(&b, source_of(&ctx), Action::SetTile(tile as u32));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// set_light(radius): change the source object's emitted light radius in cells
|
||||||
|
// (0 = no light). Negatives clamp to 0. Only visible on `dark` boards.
|
||||||
|
let b = board.clone();
|
||||||
|
engine.register_fn("set_light", move |ctx: NativeCallContext, radius: i64| {
|
||||||
|
emit(&b, source_of(&ctx), Action::SetLight(radius.max(0) as u32));
|
||||||
|
});
|
||||||
|
|
||||||
// alter_gems(n): change the player's gem count (negative subtracts; clamped at 0).
|
// alter_gems(n): change the player's gem count (negative subtracts; clamped at 0).
|
||||||
let b = board.clone();
|
let b = board.clone();
|
||||||
engine.register_fn("alter_gems", move |ctx: NativeCallContext, n: i64| {
|
engine.register_fn("alter_gems", move |ctx: NativeCallContext, n: i64| {
|
||||||
@@ -404,14 +452,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) {
|
|||||||
emit(&b, source_of(&ctx), Action::Die);
|
emit(&b, source_of(&ctx), Action::Die);
|
||||||
});
|
});
|
||||||
|
|
||||||
let b = board.clone();
|
// log(msg): write to the game log immediately, bypassing the object's action
|
||||||
engine.register_fn(
|
// queue — so a `log()` is not paced by a pending move/delay and surfaces the
|
||||||
"log",
|
// moment the hook runs. GameState flushes the shared LogSink into its log.
|
||||||
move |ctx: NativeCallContext, msg: ImmutableString| {
|
let sink = log_sink.clone();
|
||||||
let id = source_of(&ctx);
|
engine.register_fn("log", move |msg: ImmutableString| {
|
||||||
emit(&b, id, Action::Log(LogLine::raw(msg.to_string())));
|
sink.line(LogLine::raw(msg.to_string()));
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
let b = board.clone();
|
let b = board.clone();
|
||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
@@ -554,12 +601,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// teleport(x, y): move the calling object to an arbitrary cell. Zero time cost.
|
// teleport(target, x, y): move object `target` to an arbitrary cell (pass your
|
||||||
// Blocked (with an error logged at resolve time) if the object is solid and the
|
// own `me.id` to move yourself; `target == -1` moves the player). Zero time
|
||||||
// destination already has a solid occupant.
|
// cost. Blocked (with an error logged at resolve time) if that entity is solid
|
||||||
|
// and the destination already holds a different solid occupant.
|
||||||
let b = board.clone();
|
let b = board.clone();
|
||||||
engine.register_fn("teleport", move |ctx: NativeCallContext, x: i64, y: i64| {
|
engine.register_fn("teleport", move |ctx: NativeCallContext, target: i64, x: i64, y: i64| {
|
||||||
emit(&b, source_of(&ctx), Action::Teleport { x, y });
|
emit(&b, source_of(&ctx), Action::Teleport { target, x, y });
|
||||||
});
|
});
|
||||||
|
|
||||||
// push(x, y, dir): shove the pushable chain at (x, y) one step in dir. Acts on
|
// push(x, y, dir): shove the pushable chain at (x, y) one step in dir. Acts on
|
||||||
@@ -576,11 +624,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) {
|
|||||||
// a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable,
|
// a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable,
|
||||||
// and won't move anything into a cell that's not vacant (or vacated by this shift).
|
// and won't move anything into a cell that's not vacant (or vacated by this shift).
|
||||||
let b = board.clone();
|
let b = board.clone();
|
||||||
|
let sink = log_sink.clone();
|
||||||
engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| {
|
engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| {
|
||||||
let src = source_of(&ctx);
|
let src = source_of(&ctx);
|
||||||
match read_coord_array(&arr) {
|
match read_coord_array(&arr) {
|
||||||
Ok(pairs) => emit(&b, src, Action::Shift(pairs)),
|
Ok(pairs) => emit(&b, src, Action::Shift(pairs)),
|
||||||
Err(_) => emit(&b, src, Action::Log(LogLine::error("shift: each entry must be [x, y]".to_string())))
|
// Malformed args are caught at call time, so log the error immediately.
|
||||||
|
Err(_) => sink.error("shift: each entry must be [x, y]".to_string()),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,8 +44,8 @@ fn object_pushes_crate_on_init() {
|
|||||||
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]
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
|
|||||||
scripts_from(&[
|
scripts_from(&[
|
||||||
(
|
(
|
||||||
"e",
|
"e",
|
||||||
"fn init(m) { move(East); } fn bump(m,id) { log(`o0 by ${id}`); }",
|
"fn init(m) { move(East); } fn bump(m,dir) { log(`o0 from ${dir}`); }",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"w",
|
"w",
|
||||||
"fn init(m) { move(West); } fn bump(m,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
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, PlayerPos, PortalDef};
|
||||||
use crate::world::World;
|
use crate::world::World;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -15,14 +15,15 @@ fn make_board(px: i64, py: i64, portals: Vec<PortalDef>) -> 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: PlayerPos { 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))),
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ 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::PlayerPos;
|
use crate::utils::PlayerPos;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
@@ -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: PlayerPos { 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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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(m,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(m,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(m,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(m,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")));
|
||||||
|
}
|
||||||
|
|||||||
+56
-15
@@ -85,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.
|
||||||
@@ -149,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,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.
|
||||||
@@ -367,6 +362,42 @@ impl Direction {
|
|||||||
Direction::North => -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.
|
||||||
@@ -430,7 +461,7 @@ impl From<Direction> for (i64, i64) {
|
|||||||
|
|
||||||
#[derive(Copy,Clone,Debug, PartialEq)]
|
#[derive(Copy,Clone,Debug, PartialEq)]
|
||||||
pub enum Hook {
|
pub enum Hook {
|
||||||
Init, Tick, Bump, Grab
|
Init, Tick, Bump, Grab, Enter
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Hook {
|
impl Hook {
|
||||||
@@ -439,7 +470,8 @@ impl Hook {
|
|||||||
Hook::Init => "init",
|
Hook::Init => "init",
|
||||||
Hook::Bump => "bump",
|
Hook::Bump => "bump",
|
||||||
Hook::Grab => "grab",
|
Hook::Grab => "grab",
|
||||||
Hook::Tick => "tick"
|
Hook::Tick => "tick",
|
||||||
|
Hook::Enter => "enter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,15 +482,24 @@ impl Display for Hook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channel for engine/compile/runtime errors.
|
/// Shared, immediate channel for log output: script `log()` lines plus
|
||||||
|
/// engine/compile/runtime errors. A script host pushes to it *during* hook
|
||||||
|
/// execution (so a `log()` is not paced by the object's action queue), and
|
||||||
|
/// [`GameState`](crate::game::GameState) flushes it into its log each frame.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ErrorSink(Rc<RefCell<Vec<LogLine>>>);
|
pub struct LogSink(Rc<RefCell<Vec<LogLine>>>);
|
||||||
|
|
||||||
impl ErrorSink {
|
impl LogSink {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self(Rc::new(RefCell::new(Vec::new())))
|
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) {
|
pub fn error(&self, msg: String) {
|
||||||
self.0.borrow_mut().push(LogLine::error(msg));
|
self.0.borrow_mut().push(LogLine::error(msg));
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -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.
|
||||||
|
|||||||
+10
-4
@@ -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` / `[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`), **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.
|
||||||
|
|||||||
+6
-1
@@ -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"] }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -28,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,
|
||||||
}
|
}
|
||||||
@@ -42,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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,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"))
|
||||||
}),
|
}),
|
||||||
@@ -102,6 +108,20 @@ impl MenuLevel {
|
|||||||
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west"))
|
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west"))
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
MenuLevel::Transporters => vec![
|
||||||
|
MenuEntry::item('n', "▲ Transporter", |ed| {
|
||||||
|
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 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);
|
||||||
+29
-3
@@ -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);
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+155
-31
@@ -6,12 +6,19 @@
|
|||||||
//! 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 a chosen focus cell 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> {
|
||||||
@@ -20,13 +27,17 @@ pub struct BoardWidget<'a> {
|
|||||||
/// The board cell (x, y) the viewport scrolls to keep visible. Defaults to
|
/// The board cell (x, y) the viewport scrolls to keep visible. Defaults to
|
||||||
/// the player position so play mode requires no extra setup.
|
/// the player position so play mode requires no extra setup.
|
||||||
focus: (i32, i32),
|
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`, scrolling to follow the player.
|
/// 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 {
|
||||||
let focus = (board.player.x as i32, board.player.y as i32);
|
let focus = (board.player.x as i32, board.player.y as i32);
|
||||||
Self { board, focus }
|
Self { board, focus, fov: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overrides the scroll focus to `(x, y)` — use in the editor to follow the
|
/// Overrides the scroll focus to `(x, y)` — use in the editor to follow the
|
||||||
@@ -36,6 +47,14 @@ impl<'a> BoardWidget<'a> {
|
|||||||
self
|
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.
|
||||||
///
|
///
|
||||||
/// Returns `(off, pad, count)` where `off` is the first board cell to draw,
|
/// Returns `(off, pad, count)` where `off` is the first board cell to draw,
|
||||||
@@ -113,9 +132,143 @@ pub fn screen_to_board(
|
|||||||
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() {
|
||||||
@@ -139,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 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;
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-3
@@ -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();
|
||||||
|
|
||||||
|
|||||||
+4
-7
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(), "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }
|
||||||
+48
-75
@@ -13,7 +13,7 @@ fn init(me) {
|
|||||||
log(`Player health: ${Player.health}`);
|
log(`Player health: ${Player.health}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tick(me, dt) {
|
fn enter(me, dir) {
|
||||||
if Player.x == me.x && Player.y == me.y && !me.waiting {
|
if Player.x == me.x && Player.y == me.y && !me.waiting {
|
||||||
say("Hey! Get offa me!");
|
say("Hey! Get offa me!");
|
||||||
me.delay(5.0);
|
me.delay(5.0);
|
||||||
@@ -28,7 +28,7 @@ fn init(me) {
|
|||||||
let new_x = Board.registry.get("dancer_x");
|
let new_x = Board.registry.get("dancer_x");
|
||||||
let new_y = Board.registry.get("dancer_y");
|
let new_y = Board.registry.get("dancer_y");
|
||||||
if me.x != new_x || me.y != new_y {
|
if me.x != new_x || me.y != new_y {
|
||||||
teleport(new_x, new_y);
|
teleport(me.id, new_x, new_y);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Board.registry.set("dancer_x", me.x);
|
Board.registry.set("dancer_x", me.x);
|
||||||
@@ -39,10 +39,7 @@ fn init(me) {
|
|||||||
// 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(me, state, dt) {
|
fn tick(me, dt) {
|
||||||
if !me.waiting { dance(me); }
|
|
||||||
}
|
|
||||||
fn dance(me) {
|
|
||||||
move(East);
|
move(East);
|
||||||
move(South);
|
move(South);
|
||||||
move(North);
|
move(North);
|
||||||
@@ -56,16 +53,16 @@ fn dance(me) {
|
|||||||
say("Ho!", 0.5);
|
say("Ho!", 0.5);
|
||||||
me.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(me, id) {
|
fn bump(me, dir) {
|
||||||
log(`mover bumped by ${id}`);
|
log(`mover bumped from ${dir}`);
|
||||||
say("Ow!");
|
say("Ow!");
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
muffin = """
|
muffin = """
|
||||||
fn bump(me, _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.",
|
||||||
@@ -84,7 +81,7 @@ fn ignore() {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
noticeboard = """
|
noticeboard = """
|
||||||
fn bump(me, id) {
|
fn bump(me, _dir) {
|
||||||
scroll([
|
scroll([
|
||||||
" TOWN NOTICE BOARD",
|
" TOWN NOTICE BOARD",
|
||||||
" ",
|
" ",
|
||||||
@@ -117,7 +114,7 @@ fn bump(me, id) {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
bookshelf = """
|
bookshelf = """
|
||||||
fn bump(me, id) {
|
fn bump(me, _dir) {
|
||||||
scroll([
|
scroll([
|
||||||
" THE BOOKSHELF",
|
" THE BOOKSHELF",
|
||||||
" ",
|
" ",
|
||||||
@@ -136,13 +133,13 @@ fn bump(me, id) {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
fireplace = """
|
fireplace = """
|
||||||
fn bump(me, 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(me, id) {
|
fn bump(me, _dir) {
|
||||||
scroll([
|
scroll([
|
||||||
" THE CHEST",
|
" THE CHEST",
|
||||||
" ",
|
" ",
|
||||||
@@ -161,10 +158,8 @@ fn bump(me, id) {
|
|||||||
|
|
||||||
yammerer = """
|
yammerer = """
|
||||||
fn tick(me, dt) {
|
fn tick(me, dt) {
|
||||||
if !me.waiting {
|
say("blahblahblah", 2.0);
|
||||||
say("blahblahblah");
|
delay(2);
|
||||||
delay(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -180,50 +175,27 @@ fn tick(me, dt) {
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# A trigger script: no glyph, not solid — it just watches for the player stepping
|
||||||
|
# onto its cell and logs. Triggers are placed via [[boards.NAME.triggers]].
|
||||||
|
tripwire = """
|
||||||
|
fn 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 = """
|
||||||
############################################################
|
############################################################
|
||||||
# #
|
# #
|
||||||
@@ -231,9 +203,9 @@ content = """
|
|||||||
# oS #
|
# oS #
|
||||||
# #
|
# #
|
||||||
# 1 #
|
# 1 #
|
||||||
# ###### B #
|
# )######( B #
|
||||||
# # #
|
# # #
|
||||||
# +++ # #
|
# +++ # v #
|
||||||
# # #
|
# # #
|
||||||
# | #
|
# | #
|
||||||
# #
|
# #
|
||||||
@@ -251,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" }
|
||||||
@@ -268,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 = """
|
||||||
############################################################
|
############################################################
|
||||||
# #
|
# #
|
||||||
@@ -316,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" }
|
||||||
|
|||||||
+10
-9
@@ -6,15 +6,15 @@ 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(me, state) {
|
fn init(me) {
|
||||||
log(`hello from object — player at ${state.player.x}, ${state.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: ${state.player.health}`);
|
log(`Player health: ${Player.health}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tick(me, state, dt) {
|
fn tick(me, dt) {
|
||||||
if state.player.x == me.x && state.player.y == me.y && !me.waiting {
|
if Player.x == me.x && Player.y == me.y && !me.waiting {
|
||||||
say("Hey! Get offa me!");
|
say("Hey! Get offa me!");
|
||||||
me.queue.delay(5.0);
|
me.queue.delay(5.0);
|
||||||
}
|
}
|
||||||
@@ -25,10 +25,11 @@ fn tick(me, state, dt) {
|
|||||||
name = "Starting Room"
|
name = "Starting Room"
|
||||||
width = 21
|
width = 21
|
||||||
height = 6
|
height = 6
|
||||||
|
# No floor attribute → a blank (black) floor.
|
||||||
|
|
||||||
# Layer 1: terrain, the player, objects and the portal. A space is always a
|
# The single grid: all solids and most non-solids. A space is always a transparent
|
||||||
# transparent empty cell, so the floor below shows through.
|
# empty cell, so the floor shows through.
|
||||||
[[boards.start.layers]]
|
[boards.start.grid]
|
||||||
content = """
|
content = """
|
||||||
#####################
|
#####################
|
||||||
# #
|
# #
|
||||||
@@ -37,7 +38,7 @@ content = """
|
|||||||
# #
|
# #
|
||||||
#####################
|
#####################
|
||||||
"""
|
"""
|
||||||
[boards.start.layers.palette]
|
[boards.start.grid.palette]
|
||||||
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" }
|
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" }
|
||||||
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
|
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
|
||||||
"@" = { kind = "player" }
|
"@" = { kind = "player" }
|
||||||
|
|||||||
Reference in New Issue
Block a user