crates, player coords, arch.md gone

This commit is contained in:
2026-06-06 14:11:20 -05:00
parent 05c9fdbde9
commit 8ac6da184c
7 changed files with 744 additions and 484 deletions
-390
View File
@@ -1,390 +0,0 @@
# Kiln — Architecture & Design Notes
## What this is
Kiln is a **game-making system**, not just a game. The model is ZZT (1991, Epic MegaGames) — a DOS game that shipped with a built-in editor and a simple scripting language (ZZT-OOP), which let players create and share their own worlds. The goal here is something similar: a runtime + authoring environment where game worlds are defined in plain text files with embedded scripts, and the engine interprets them.
This document records the architectural decisions made so far and the reasoning behind them, so future development sessions don't have to rediscover the "why."
---
## Tech stack
| Concern | Choice | Reason |
|---------|--------|--------|
| Engine core | `kiln-core` crate | UI-agnostic game types + map I/O; the one crate every front-end depends on |
| Terminal front-end | `kiln-tui` — ratatui 0.30 / crossterm | Pure Rust TUI **player**; renders boards as colored text |
| Desktop front-end | `kiln-egui` — eframe / egui 0.33 | Pure Rust retained-mode GUI player + editor. **Currently not a workspace member** (see Module structure) |
| Scripting | Rhai 1.x | Pure Rust, sandboxed, WASM-compatible; designed for embedding |
| Map format | TOML + serde | Human-readable, good Rust tooling, standard in the ecosystem |
**WASM compatibility is a first-class requirement.** Everything in the stack must compile to WASM. This ruled out Lua (C FFI via mlua/rlua) for scripting. Rhai was chosen specifically because it is pure Rust with no C dependencies and explicit no_std/WASM support.
---
## Module structure
Cargo **workspace**; root `members = ["kiln-core", "kiln-tui"]`. `kiln-egui` stays in the tree but was removed from the workspace and is not built at the root.
```
kiln-core/src/ — the engine (UI-agnostic)
game.rs — core data types and game logic
log.rs — styled, UI-agnostic log messages (LogLine/LogSpan)
script.rs — Rhai scripting runtime (ScriptHost); object init/tick hooks
map_file.rs — TOML deserialization, Board load/save
kiln-tui/src/ — terminal player (no editor)
main.rs — CLI arg + play loop; TerminalCaps detection; Kitty flags
render.rs — BoardWidget: draws the board into a ratatui buffer as text
cp437.rs — CP437 → Unicode table; renders tile indices as characters
term.rs — TerminalCaps detection + Kitty keyboard-protocol setup
kiln-egui/src/ — eframe/egui player + editor (NOT a workspace member)
main.rs, render.rs, font.rs, font_dialog.rs, editor.rs, glyph_picker.rs
maps/
start.toml — example map; play via `cargo run -p kiln-tui -- maps/start.toml`
assets/
vga-font-8x16.png — default CP437 bitmap font (256×128 px, 8×16 tiles); used by kiln-egui
```
The per-module deep-dives below are labeled by file. `game.rs`/`map_file.rs` are **kiln-core**; the kiln-tui player has its own section; `font.rs`, `font_dialog.rs`, and the egui `main.rs`/`render.rs`/`editor.rs`/`glyph_picker.rs` are **kiln-egui** (retained as design notes for when the GUI is revived).
### `game.rs` — core types
The types here are the runtime representation of a game world. They are deliberately free of any file-loading or rendering concerns.
**`Glyph`** (`Copy`, `Eq`, `Hash`)
The visual representation of one cell: a `tile: u32` index into the active bitmap font, a foreground color, and a background color. Stored *per cell* (not per element type) so individual cells can animate or vary their appearance independently — e.g. a "fire" element where each flame tile has a slightly different color — without changing their behavior. Derives `Hash` so the glyph picker can deduplicate the board palette into a `HashSet<Glyph>`.
**`FontSpec`**
Optional per-board font override: `path: String`, `tile_w: u32`, `tile_h: u32`. Stored on `Board.font`. When `None`, `App`'s `default_font` is used. The editor's Board tab exposes a "Font…" button to change this at runtime.
**`Behavior`**
A plain data struct of runtime behavioral properties — currently `passable: bool` and `opaque: bool`. Returned by `Archetype::behavior()`. Adding a new property (e.g. `shootable`) requires only a new field here; no match arms needed at call sites. This is a deliberate improvement over storing behavior as a bag of booleans on a per-cell or per-archetype basis.
**`Archetype`**
An enum of named element types: `Empty`, `Wall`, `ErrorBlock`. Each variant knows:
- Its canonical map-file name (e.g. `"wall"`) via `name()`
- Its default `Behavior` via `behavior()`
- Its default `Glyph` via `default_glyph()` — used by the editor when stamping a cell
`ErrorBlock` is a sentinel for map files that reference an unknown archetype name. It renders as a yellow `?` on red so malformed maps are immediately visible in-game. It is excluded from `ALL_ARCHETYPES` (the editor's list) because it is not a valid authoring choice.
**Why `Behavior` and `Archetype` are separate types:**
`Archetype` is the *named class* of a thing — it lets you say "this is a wall" in a map file and in the editor. `Behavior` is the *runtime properties* that drive simulation. Keeping them separate means adding a new property (e.g. `pushable`) only requires a new field on `Behavior`; no match arms need to be updated across the codebase.
**Why Glyph and Archetype are separate:**
In ZZT, each board tile had both a visual (character + color pair) and an element type. The visual could vary per-tile even for the same element type. We replicate this: `Glyph` is the visual (per-cell), `Archetype` is the class (shared across cells of the same type). This lets you have a wall that's gray in one room and blue in another without creating two archetype variants.
**`Board`**
The complete unit of a game world — one "room" or "screen" in ZZT terminology. Holds:
- `cells: Vec<(Glyph, Archetype)>` — row-major grid; each cell directly owns its visual and behavioral class
- `player: Player` — current player position on this board
- `objects: Vec<ObjectDef>` — scripted objects; their `init()`/`tick(dt)` hooks run via the scripting runtime, and `passable`/`opaque` affect collision
- `portals: Vec<PortalDef>` — exits to other boards (parsed, not yet runtime-wired)
- `scripts: HashMap<String, String>` — named Rhai source, referenced by objects via `script_name`
- `font: Option<FontSpec>` — per-board font override; `None` means use the app default
**`ObjectDef`**
A scripted object placed on the board. Holds `x`, `y`, `glyph: Glyph` (owned by the object so scripts can change its appearance), `passable: bool`, `opaque: bool`, and `script_name: Option<String>`. `passable` defaults `false` and `opaque` defaults `true` in map files (objects block by default). `Board::is_passable` checks objects before consulting the grid cell's `Archetype::behavior()`, so an impassable object blocks movement even when placed over a passable floor tile. The `passable` and `opaque` flags are editable via checkboxes in the editor's Objects tab.
**Why `cells` is `Vec<(Glyph, Archetype)>` with no palette:**
An earlier design had `cells: Vec<(Glyph, usize)>` where the `usize` indexed a per-board `elements: Vec<Element>` palette. This was eliminated because the palette indirection added complexity without benefit: `Archetype` is a `Copy` enum (4 bytes), so storing it per-cell is as efficient as storing an index, and it removes the invariant that the index must stay in sync with a specific board's palette.
**Why `Board` is the complete unit (no wrapper struct):**
An earlier design had `GameMap { board: Board, player: Player, ... }`. This was eliminated because the split was artificial: there's no meaningful use of a `Board` without a player position, and no meaningful use of a player without a `Board`. ZZT itself treats a board as containing everything — the grid, the objects, and the player entry point. Collapsing to a single struct matches the domain model.
**`GameState`**
Owns the board behind an `Rc<RefCell<Board>>`, alongside the message `log` and a `ScriptHost`. Keeping the board a **sibling** of the script host (rather than owned by it) is the key ownership move: host functions capture a clone of that `Rc` — a `'static` handle to a *different* `RefCell` than the running engine — so they can read/queue writes without aliasing the borrow that's executing scripts. Front-ends and internal logic reach the board only through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (no `pub board`). `try_move` moves the player; `run_init()` / `tick(dt)` run object hooks; `apply_commands()` drains the script command queue *after* each batch — the deferred apply is when `&mut` is finally free of any outstanding borrow.
---
### `log.rs` — styled messages
`LogLine { spans: Vec<LogSpan> }`, where `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }`, is a **UI-agnostic** styled message: colors are core `Rgba8`, not a front-end type, so the engine stays UI-free. `LogLine::raw()`, a chainable `push()`, and `append()` build messages; each front-end converts a `LogLine` to its own styled text at render time (kiln-tui's `render::logline_to_line`). The game log lives on `GameState`; both the engine (scripts) and front-ends append to it.
### `script.rs` — Rhai scripting runtime
`ScriptHost` owns the Rhai `Engine`, the scripts referenced by a board's objects (compiled once per name via `Engine::compile`), a persistent per-object `Scope`, and a shared **command queue**. Built with `ScriptHost::new(&Rc<RefCell<Board>>)`, which registers the API, compiles scripts, and records each object's available hooks (detected with `AST::iter_functions()` by name/arity) but **runs nothing** — compile/unknown-script failures are queued as `GameCommand::Error`.
Two optional lifecycle hooks per object: `init()` (zero-arg, run once) and `tick(dt)` (elapsed seconds as `f64`, run per frame), driven by `run_init()` / `run_tick(dt)`. Runtime errors become `Error` commands, not fatal.
**Reads vs writes.** Scripts **read** the world directly: read getters are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai as type `Board`) — getters only, so read-only by construction — and a clone of the handle is pushed into each scope as the constant `Board`, so a script writes `Board.player_x`; the getter briefly borrows the shared `Board`. Scripts **write** by enqueuing `Command { source, kind }` where `kind: GameCommand` is `Move(Direction)` / `SetTile(u32)` / `Log(LogLine)` / `Error(String)`. The host fns `move`/`set_tile`/`log` push into a shared `Rc<RefCell<Vec<Command>>>` cloned into each closure (the same `'static`-closure trick the old log sink used, generalized); `GameState::apply_commands` drains and applies them after the batch. Reads-are-live, writes-are-deferred gives frame-coherent semantics and keeps script execution free of any `&mut GameState` borrow.
**Sender identity** (which object issued a command) rides Rhai's per-call **tag**: `run` calls `call_fn_with_options(...with_tag(object_index)...)` and the host fns read it via `NativeCallContext::tag()`, so scripts write `move(North)` without a `this` argument (`North`/`South`/`East`/`West` are `Direction` constants in scope; `impl From<Direction> for (i32,i32)` yields the delta). The object's identity is currently its index into `Board::objects` — a stopgap (a `// TODO` flags replacing it with stable unique ids that survive spawn/destroy). Default `call_fn` rewinds the scope per call, so script-local persistence across ticks is still future work; the per-object `Scope` is the seam for that. `Engine`/`Scope` are not `Send` — fine for the single-threaded kiln-tui.
---
### kiln-tui — terminal player
`kiln-tui` is a **play-only** front-end (no editor yet): it takes a map-file path on the command line, loads a `Board` via `kiln_core::map_file::load`, and lets the player walk around with the arrow keys (or `hjkl`). It depends only on `kiln-core`, `ratatui`/`crossterm`, and the `color` crate — none of the egui stack. This is the proof that `kiln-core` is genuinely UI-agnostic: the engine needed **zero changes** to add a second front-end.
**Text rendering instead of bitmap tiles.**
A terminal can't blit a bitmap font, so kiln-tui reinterprets each `Glyph.tile` index as a *character*. The default kiln font is CP437, where the tile index equals the code point, so `cp437::tile_to_char` maps indices through a 256-entry CP437→Unicode table (graphic glyphs for 0x000x1F, box-drawing for 0xB00xDF, ASCII in between). The `[font]` map section is still parsed and stored on `Board`, but kiln-tui ignores the image path and uses the tile indices directly. `Glyph.fg`/`bg` (`Rgba8`) map to `ratatui::style::Color::Rgb` — full 24-bit truecolor, alpha dropped (256-color terminals approximate it).
**`render.rs``BoardWidget`.**
A `ratatui::widgets::Widget` that draws the board into the frame buffer. The `axis(board_len, view, player) -> (offset, pad, count)` helper resolves each axis independently: a board smaller than the viewport is **centered** with screen padding; a larger one **scrolls** to keep the player visible, clamped so no empty margin shows past the board edge. Per cell, glyph priority is player > object > grid floor.
**`term.rs` — capability detection + Kitty protocol.**
`TerminalCaps { keyboard_enhancement, truecolor }` is detected once at startup: `keyboard_enhancement` via crossterm's `supports_keyboard_enhancement()` (a real query/response with the terminal), `truecolor` from `$COLORTERM`. When keyboard enhancement is supported, kiln-tui pushes the Kitty keyboard-protocol flags (`DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS | REPORT_ALL_KEYS_AS_ESCAPE_CODES`) after `ratatui::init()` and pops them before `restore()`. This unlocks richer input — modifier-only keypresses, key release/repeat events, unambiguous Ctrl combos — which the engine intends to hand to scripts so they can choose bindings the terminal actually supports.
**Real-time loop.**
The loop runs at ~30 FPS: it `event::poll`s only until the next frame deadline, so input stays responsive, and otherwise wakes to advance the game by the real elapsed `dt` via `GameState::tick` (which drives object `tick(dt)` hooks). Because `REPORT_EVENT_TYPES` makes the terminal emit Release (and Repeat) events, it acts on `Press` **and** `Repeat` — so holding a key moves continuously at the terminal's own repeat cadence — and skips `Release`, which would otherwise fire a second, spurious move. Mouse capture is enabled so the log panel can be scrolled (wheel) and resized (drag the divider). `game.run_init()` runs object `init()` hooks after the board loads and before the loop.
**Log panel + capability line.**
The bottom-left border previews the latest log line after a `[l]og:` label; pressing `l` opens a bottom panel listing the log newest-first (`render::logline_to_line` converts each `LogLine`). `TerminalCaps::status_logline()` returns a `LogLine` (not a border badge) seeded into the log at startup: when truecolor is available it draws "truecolor" with each letter a different rainbow color (`hue_to_rgb`, a full-saturation HSV→RGB helper), else a plain "256-color", followed by the input descriptor.
**Why a capability struct, not a terminal name.**
Scripts should branch on *what works* (truecolor, keyboard enhancement), not on a brittle terminal brand string that is often unset over SSH or inside tmux. `TerminalCaps` deliberately carries no program-identity field.
---
### `font.rs` — bitmap font
**`BitmapFont`** wraps an egui `TextureHandle` plus tile geometry (`tile_w`, `tile_h`, `img_w`, `img_h`). The texture is preprocessed at load time: the top-left pixel of the source PNG becomes the "background" sentinel — those pixels are stored as `TRANSPARENT`, all others as opaque `WHITE`. This lets egui's image tinting do all the colorization work at render time:
1. `painter.rect_filled(cell_rect, 0.0, glyph.bg)` — paint the background
2. `painter.image(texture_id, cell_rect, tile_uv, glyph.fg)` — draw the tile; egui multiplies each pixel by the tint color, so white → `fg` and transparent → nothing (revealing `bg`)
No custom shaders required.
**`tile_uv(tile)`** delegates to the private `compute_tile_uv` function which is unit-tested directly (no egui `Context` needed). `tile_cols()`, `tile_count()`, `img_w()`, `img_h()` are helpers used by the picker and dialog to lay out grids.
**`create_placeholder(ctx)`** generates a 16×16 grid of 8×8 tiles where each tile shows the 8 bits of its index as a column bar. Used as a fallback when `assets/vga-font-8x16.png` is missing.
---
### `font_dialog.rs` — font picker dialog
**`FontDialogState`** holds the in-progress edits: `path`, `tile_w`, `tile_h`, and a `preview: Option<BitmapFont>` loaded when the user clicks "Load". Separating dialog state from `FontSpec` means the user can explore settings without committing.
**`show(ctx, open, state, font_spec)`** renders a floating window with:
- A text field + "Browse…" button (`rfd::FileDialog`)
- `DragValue` controls for tile dimensions
- A "Load" button that decodes the PNG and stores it in `state.preview`
- A scrollable preview of the full tilesheet with grid overlay at tile boundaries
- "Apply" (enabled only after a successful Load) writes to `font_spec` and closes; "Use default" clears `font_spec`
Uses a `should_close: bool` flag outside the closure to work around egui's `.open(open)` borrow conflict.
---
### `map_file.rs` — file loading
**`MapFile`** and supporting structs are serde deserialization types only — they exist solely to parse TOML and are never used at runtime.
**`TileIndex`** is a `#[serde(untagged)]` enum (`Num(u32)` | `Chr(char)`) that accepts either an integer (`tile = 35`) or a single-character string (`tile = "#"`) in palette entries. This preserves backward compatibility with existing map files while allowing numeric indices in new ones.
**`FontHeader`** deserializes the optional `[font]` section and is converted to `FontSpec` in `From<MapFile> for Board`.
**`impl From<MapFile> for Board`** — the single conversion point. Reads the palette (each entry maps a character to a `(Glyph, Archetype)` pair), then walks the grid string character-by-character to build `cells`. Unknown archetype names produce an `ErrorBlock` cell and log a warning. This is the only place that knows about both the file format and the runtime representation.
**`pub fn load(path: &str) -> Result<Board, ...>`** — reads a file, deserializes, converts. Called from `main()` before the window is created.
**Why loading happens before window creation:**
eframe requires `NativeOptions` (including window size) to be set before calling `run_native`. Loading the board first means its dimensions are available if they're ever needed for sizing logic (currently the window uses fixed defaults, but the board must exist before `App::new` is called).
---
### `main.rs` — app + frame loop
`App` holds a `GameState`, an `AppMode` (`Play` | `Edit`), an `EditorState`, a `default_font: BitmapFont` (loaded from `assets/vga-font-8x16.png` at startup, falling back to `create_placeholder`), and a `board_font: Option<BitmapFont>` (loaded from `board.font` when a per-board font is set). `App::new` takes `&egui::Context` to upload textures before the first frame.
Font change detection: before calling `show_editor_panel`, `font_spec_before` is cloned; after the call, if `board.font` differs, `apply_font_spec` reloads `board_font`. `App::active_font()` resolves `board_font.as_ref().unwrap_or(&default_font)`; at the editor-panel and glyph-picker sites the same expression is used inline instead so the font borrow stays disjoint from the `&mut editor`/`&mut board` borrows (a method call would borrow all of `self`).
`update` is a thin sequence of phase methods, each owning one concern:
1. `handle_input` — table-driven arrow keys → `GameState::try_move` (**Play mode only**)
2. `menu_bar` — File menu (Save/Save As via `save_to`/`save_as`, Exit) and the Play/Edit toggle
3. `try_show_script_editor` — if a script is open, fills the `CentralPanel` with the code editor and returns `true`, ending the frame early
4. Editor side panel (Edit mode) — `editor::show_editor_panel`, declared before `CentralPanel`; then the font-reload check
5. `show_board` — draws the viewport and returns the clicked cell; the click is dispatched to `handle_board_click` *after* it returns
6. `show_glyph_pickers` — the Palette and Objects glyph pickers; then the selected object's glyph is flushed back to `board.objects[i]`
**Click dispatch borrow split:** `show_board(&self)` only draws and returns `Option<(usize, usize)>` (validated cell coords). The mutating `handle_board_click(&mut self, cx, cy)` runs afterward, so the immutable font borrow held during drawing never conflicts with the `&mut self` handler.
**Viewport — Play mode:**
`render::board_origin(available, board_w, board_h, player, font, zoom)` computes the pixel position of cell (0,0). If the board fits along an axis it is centered; if it overflows the viewport is centered on the player and clamped so no empty space appears at the board edges. No scroll bars.
**Viewport — Edit mode:**
The board is wrapped in `egui::ScrollArea::new([true, true])`, which shows scroll bars when the board overflows the viewport. Content is allocated at exact board size; `rect.min` from `allocate_exact_size` serves as the origin passed to `render::draw_board`.
**Stamp action:** `*board.get_mut(cx, cy) = (self.editor.palette.glyph, self.editor.palette.selected)` — the custom glyph and selected archetype are written together. The glyph can differ from the archetype's `default_glyph()` if the user has customized it.
---
### `render.rs` — drawing primitives
All pixel-level rendering knowledge lives here. No dependency on `EditorState` or `App`. Cell size is no longer a fixed constant — it comes from the active `BitmapFont`.
**Constants:** Window sizing (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`) defined here. No `CELL_W`/`CELL_H` — those were removed when bitmap fonts were introduced. Per-cell pixel size comes from `BitmapFont::cell_size(zoom)`.
**`rgba8_to_color32(c)` / `color32_to_rgba8(c)`** — inverse conversions between the core `color::Rgba8` and egui's `Color32`, used at the glyph-picker boundary where colors cross between the two type systems.
**`paint_glyph(painter, rect, glyph, font)`** — fills `rect` with `glyph.bg`, then draws the tile image tinted by `glyph.fg`. Used by `draw_glyph`, `glyph_preview_button`, and `glyph_picker`.
**`glyph_preview_button(ui, glyph, font) -> Response`** — allocates a one-cell swatch (unzoomed), paints `glyph`, and returns the click `Response`. Shared by the Palette and Objects editor tabs for their editable glyph swatches.
**`draw_glyph(painter, origin, x, y, glyph, font, zoom)`** — computes the cell rect from `font.cell_size(zoom)`, delegates to `paint_glyph`.
**`draw_board(painter, origin, board, font, zoom)`** — iterates all cells calling `draw_glyph`, then draws objects and the player as overlays on top.
**`draw_object_overlays(painter, origin, board, font, selected, zoom)`** — called from Edit mode when the Objects tab is active. Draws a dim border on every object cell and a bright border on the selected one, making objects discoverable without obscuring the underlying tile.
**`pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32)`** — converts a pixel position to cell coordinates using floor division. Returns negative values for clicks above/left of the origin; callers must guard `>= 0`. Mirrors the rendering math so clicks land on the correct cell.
**`board_origin(available, board_w, board_h, player, font, zoom) -> Pos2`** — computes the pixel origin for Play-mode rendering (centering or player-tracking with edge clamping).
---
### `editor.rs` — editor state and side panel
**`EditorTab`** (`Palette` | `Objects` | `Board` | `Scripts` | `World`) — which tab is active in the side panel.
**`EditorState`** — transient editor state, grouped by concern into sub-structs so each tab can take only the borrow it needs (the dispatch in `show_editor_panel` passes disjoint sub-struct borrows, e.g. `&mut editor.objects` and `&mut editor.scripts`):
- `tab: EditorTab` — which side-panel tab is active
- `palette: PaletteState``selected: Archetype` (class to stamp), `glyph: Glyph` (visual to stamp; resets to `archetype.default_glyph()` when the archetype changes), `picker_open: bool`
- `font_dialog: FontDialog``open: bool`, `state: FontDialogState`
- `objects: ObjectEdit``selected: Option<usize>` (index into `board.objects`), `picker_open: bool`, `editing_glyph: Glyph` (local copy written back to `board.objects[i].glyph` each frame), `placing: bool` (next board click creates a new `ObjectDef`)
- `scripts: ScriptEdit``editing: Option<String>` (script open in the code editor), `content: String` (working copy), `new_name: String` (new-script draft)
**`show_editor_panel(ctx, editor, board, active_font)`** — renders the resizable right-side panel (default 200 px): a tab bar, then dispatch to one function per tab. `palette_tab`: scrollable archetype list and glyph preview button. `objects_tab`: "Add Object" placement toggle, click-to-select, and for the selected object a glyph preview, **Passable**/**Opaque** checkboxes (writing directly to `board.objects[i]`), and a script combobox. `board_tab`: font path label + "Font…" button + zoom slider. `scripts_tab`: create and edit named scripts. World tab: placeholder. The glyph preview swatches use `render::glyph_preview_button`.
---
### `glyph_picker.rs` — glyph picker dialog
**`show(ctx, open, glyph, board_cells, font)`** — renders a floating `egui::Window`. Takes `open: &mut bool` and `glyph: &mut Glyph` directly, with no dependency on `EditorState`, so it can be invoked from any context that needs glyph selection.
Three sections:
1. **Board palette** — unique glyphs found on the current board (deduplicated via `HashSet<Glyph>`), shown as clickable cells rendered with `paint_glyph`. The current glyph is highlighted with a white stroke.
2. **Colors**`color_edit_button_srgba` pickers for `glyph.fg` and `glyph.bg`.
3. **Tile grid** — all tiles from the active `BitmapFont`, displayed at `tile_w × tile_h` per cell in a scrollable area. Column count matches the font image layout. Clicking a cell updates `glyph.tile`. The current tile is highlighted.
---
## Map file format
XPM-inspired (XPM is an old X11 image format that uses a character palette to define pixel colors). A `[palette]` section maps single characters to both a `Glyph` (visual) and an `Element` (behavior). The `[grid] content` is a TOML multi-line string where each character is a palette key.
**Keep this example in sync with `map_file.rs` whenever the format changes.**
```toml
[map]
name = "Room Name"
width = 60
height = 25
player_start = [30, 12]
# Optional: override the default font for this board.
[font]
path = "assets/my_font.png"
tile_w = 8
tile_h = 16
[palette]
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
[grid]
content = """
############################################################
# #
############################################################
"""
[[objects]]
x = 10
y = 5
tile = "#"
fg = "#aa3333"
bg = "#000000"
passable = true
script_name = "greeter"
[[portals]]
x = 59
y = 12
target_map = "cave"
target_entry = "west_door"
[scripts]
greeter = """
fn init() {
log(`player at ${Board.player_x}, ${Board.player_y}`); # read
set_tile(2); # write
}
fn tick(dt) { move(North); } # dt = elapsed seconds; dir ∈ North/South/East/West
"""
```
**Why TOML over a custom format:**
The `toml` crate gives us deserialization with minimal code. Multi-line strings for the grid give a visual representation of the map. Embedded Rhai scripts fit naturally in TOML multi-line strings without escaping issues.
**Why `tile` accepts both char and integer:**
`TileIndex` is a `#[serde(untagged)]` enum. Old map files using `tile = " "` (a single-character string) continue to work unchanged. New map files can use `tile = 32` (integer) for clarity or when the tile has no printable character equivalent.
**Why archetypes by name, not by property:**
Palette entries used to store `passable = true/false` directly. Switching to `archetype = "wall"` means adding a new behavioral property (e.g. `opaque`) only requires a code change — existing map files don't need to be updated. It also makes files more readable: `archetype = "wall"` is self-documenting. Unknown names produce a visible `ErrorBlock` cell rather than silently defaulting.
**Why the palette approach:**
A direct mapping from palette character → `(Glyph, Archetype)` means the map file is both human-readable (you can see the shape of the room from the grid string) and flexible (per-cell visual variation is possible by using different palette chars with the same archetype but different colors).
**Colors** are `"#RRGGBB"` hex strings — universally understood, hand-editable.
**`player_start`** is a header field, not a palette character. The player is not a board cell; they are an entity that moves over the board. Using a palette character for player start (like `@` in many roguelikes) would mean the tile under the player is always that character, which makes it awkward to place a player over different terrain.
---
## What's not yet implemented
**Object scripting** — the runtime exists (`script.rs`): objects run optional `init()`/`tick(dt)` hooks, **read** the world via `Board.*`, and **write** via queued commands (`move`, `set_tile`, `log`). Still to come:
1. More event hooks beyond lifecycle — e.g. fire `on_touch` when the player moves onto an object's cell
2. Grow the command vocabulary and read API (`send_message`, randomness, board queries, more glyph setters)
3. **Stable object ids**`Command.source` / `ObjectRuntime.object_index` are array indices into `Board::objects`, which break under spawn/destroy/reorder; replace with monotonically-increasing unique ids and key objects by them (a `// TODO` marks this in code)
4. Persist script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call)
5. `PortalDef` is still parsed-only — no multi-board navigation yet
**Object behavior from scripts**`ObjectDef.passable` and `ObjectDef.opaque` are live and editable in the editor, but they are static author-set values. Eventually they should be overridable by the object's Rhai script at runtime (e.g. a door script that flips `passable` when opened). `Board::is_passable` already has the right shape for this; the script runtime just needs to be wired in.
**Portal navigation**`PortalDef` stores a `target_map` and `target_entry` but there's no multi-board loading or board switching yet.
**Multi-board world** — right now the engine loads a single `maps/start.toml`. Future: a world file or directory of boards, lazy-loaded as the player moves through portals.
**File open dialog** — the editor has no way to load a different map file from the UI. `rfd` is already a dependency (used by the font dialog), so a "Open map…" button is straightforward to add. WASM support for `rfd` file dialogs is an open question.
**Map save** — the editor can paint cells but has no way to write changes back to a `.toml` file. Saving will require serializing the `Board` back to `MapFile` format and writing it to disk.
**Terminal editor**`kiln-tui` is play-only; it has no editing UI. The editor currently lives only in `kiln-egui`, which is out of the workspace. A future TUI editor (or reviving the egui one) is an open direction.
---
## Future considerations
These are not current requirements but intended future directions. Where a planned change conflicts with current design, the tension is called out explicitly so it can be addressed before it becomes a problem.
### The player may become an object; boards may have no player
The long-term goal is for the "player" to be an object on the board that happens to respond to arrow key events — not a hardcoded special entity. Some boards may have no player-like object at all and do something else with input events (a cutscene, a menu, a puzzle that reacts to keys differently).
ZZT hard-coded the player as a special element and many game authors had to work around this limitation (e.g. hiding the real player behind a wall and scripting a fake one). This is a deliberate improvement over that model.
**Current design tension:**
The following assumptions are baked in today and will need to change when this is implemented:
- `Board.player: Player` is a required non-optional field. A board with no player can't be represented. This should eventually become `Option<Player>`, or the player should be removed from `Board` entirely and tracked by the engine layer only when present.
- `player_start` in the map file is a required header field. It will need to become optional, or player spawning will move into the object/script system (an object with a special role, spawned at its `x`/`y` position).
- `GameState::try_move` directly mutates `board.player`. Once the player is an object driven by Rhai, movement will go through the scripting dispatch layer instead. `try_move` will likely be replaced by something like `engine.dispatch_event(ArrowKey(dx, dy))`.
- In `main.rs`, the player is rendered as a hardcoded overlay using `Glyph::player()`. Once the player is an object, it should be rendered as part of the normal object layer, not as a special case.
None of these are blockers for current work, but avoid making `Board.player` more central than it already is (e.g. don't add methods that assume player presence, don't derive window sizing from player position).
---
## ZZT reference
ZZT (1991) was a text-mode game for DOS. Its playfield was 60×25 characters (the right 20 columns were the stats panel). Each board was a self-contained screen with objects (tiles with embedded ZZT-OOP scripts), passageways to adjacent boards, and a fixed element type system (about 50 built-in element types). Players could create worlds with the built-in editor and share `.ZZT` files.
Kiln takes the core ideas — tile-based boards, embedded scripts per object, named portals between boards — and rebuilds them in a modern, WASM-capable stack with a more flexible scripting language and a human-readable file format.
+39 -14
View File
@@ -4,7 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
`kiln` is a ZZT-inspired game-making system written in Rust (edition 2024). The goal is a system where players can create games using a scripting language, similar to the classic DOS game ZZT. It uses **Rhai** for scripting (WASM-compatible, sandboxed, pure Rust).
`kiln` is a ZZT-inspired game-making system written in Rust (edition 2024) — a runtime **and** authoring environment where game worlds are plain-text files with embedded scripts. The model is ZZT (1991, Epic MegaGames): a DOS game that shipped with a built-in editor and a scripting language (ZZT-OOP), letting players create and share worlds. ZZT's playfield was 60×25 characters; each board was a self-contained screen with scripted objects, passages to adjacent boards, and ~50 built-in element types.
**Tech-stack rationale (the "why"):**
- **WASM compatibility is a first-class requirement** — everything in the stack must compile to WASM. This is why scripting uses **Rhai** (pure Rust, sandboxed, no C deps, explicit WASM support) rather than Lua (which needs C FFI via mlua/rlua).
- **Maps are TOML + serde** — human-readable, hand-editable, good Rust tooling; grid multi-line strings make the room shape visible, and embedded Rhai scripts fit TOML multi-line strings without escaping.
- **The engine (`kiln-core`) is UI-agnostic** — no rendering/UI types leak in (e.g. log colors are core `Rgba8`, not a front-end type), so the same game data drives multiple front-ends. kiln-tui being added with zero engine changes is the proof.
## Code style
@@ -16,9 +21,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
When the user says **"finish the epic"**, do all of the following in order:
1. Update `CLAUDE.md` to reflect any new modules, types, or behaviors added during the session.
2. Update `ARCHITECTURE.md` to reflect the same.
3. Run the `/simplify` skill on changed code.
4. Commit everything with a summary message.
2. Run the `/simplify` skill on changed code.
3. Commit everything with a summary message.
## Commands
@@ -46,18 +50,18 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
**`kiln-core/src/game.rs`** — all core game types:
- `Glyph` (`Copy`, `Eq`, `Hash`) — per-cell visual: `tile: u32` (tilesheet index), `fg/bg: Color32`. `Glyph::player()` is a `const fn`; all other glyphs come from the map file. Derives `Hash` so boards can deduplicate glyphs into a palette.
- `FontSpec` — optional per-board bitmap font override: `path: String`, `tile_w: u32`, `tile_h: u32`. When `None`, the app default font is used.
- `Behavior` — plain data struct of runtime behavioral properties: `solid: bool` (blocks/participates in movement — inverse of the old `passable`), `opaque: bool`, `pushable: bool` (all archetypes `false` for now; the push mechanic is future work). Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere.
- `Behavior` — plain data struct of runtime behavioral properties: `solid: bool` (blocks/participates in movement — inverse of the old `passable`), `opaque: bool`, `pushable: Pushable` (an enum `No`/`Any`/`Horizontal`/`Vertical` with `allows(dir)`; only meaningful for `solid` things). Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere. (`ObjectDef.pushable` is still a plain `bool` = any direction.)
- `Solid<'a>` — the single solid occupant of a cell, returned by `Board::solid_at`: `Cell(Archetype)` (a solid grid archetype like a wall) or `Object(&ObjectDef)` (a solid object). At most one solid may occupy a cell — enforced at load time.
- `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red.
- `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `Crate` is solid and pushable any direction (CP437 ■, char 254, light gray on black); `HCrate`/`VCrate` are crates pushable only east/west (↔, char 29) / north/south (↕, char 18) respectively, same colors. `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red.
- `ALL_ARCHETYPES: &[Archetype]` — ordered list of valid editor choices (excludes `ErrorBlock`).
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec<ObjectDef>`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells` is `pub(crate)`. `solid_at(x, y) -> Option<Solid>` returns the cell's single solid occupant (object checked before grid archetype); `is_passable(x, y)` is the convenience inverse (`solid_at(...).is_none()`).
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec<ObjectDef>`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells` is `pub(crate)`. `solid_at(x, y) -> Option<Solid>` returns the cell's single solid occupant (object checked before grid archetype); `is_passable(x, y)` is the convenience inverse (`solid_at(...).is_none()`). `in_bounds((i32, i32))` bounds-checks a (possibly negative) coordinate. Push support is split into the read-only `can_push(x, y, dir)` (does the chain of pushable solids starting here end at open space?) and the mutating `push(x, y, dir)` (shoves that chain one cell, leaving `Empty` behind); the read-only half lets a mover answer "can I move here?" via `is_passable || can_push` before committing. `is_valid()` / `load_errors()` expose nonfatal problems collected while loading (a non-serialized `Vec<LogLine>`); `report_error(msg)` appends a red-on-black line and `is_valid()` is just "no load errors".
- `Player``x: i32, y: i32`
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `solid: bool`, `opaque: bool`, `pushable: bool`, `script_name: Option<String>`. `solid` and `opaque` default `true`, `pushable` defaults `false` in map files. Its `init()`/`tick(dt)` hooks are run by the scripting runtime (see `script.rs`).
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
- `GameState` — holds `board: Rc<RefCell<Board>>`, the message `log: Vec<LogLine>`, and a `ScriptHost`. The board sits behind `Rc<RefCell<…>>` as a sibling of the `ScriptHost` so script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. Front-ends/logic reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (there is no `pub board` field). `try_move` moves the player; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` runs object `tick(dt)` hooks every frame. After each script batch, `apply_commands()` drains the script command queue: `Log`/`Error``log`, `SetTile` → the source object's glyph, `Move(dir)``move_object` (bounds + `is_passable`). This deferred apply (writes after the batch) is what keeps script execution borrow-safe.
- `GameState` — holds `board: Rc<RefCell<Board>>`, the message `log: Vec<LogLine>`, and a `ScriptHost`. The board sits behind `Rc<RefCell<…>>` as a sibling of the `ScriptHost` so script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. Front-ends/logic reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (there is no `pub board` field). `try_move(dir: Direction)` moves the player (pushing a crate/solid out of the way if possible); `run_init()` runs object `init()` hooks once at startup; `tick(dt)` runs object `tick(dt)` hooks every frame. After each script batch, `apply_commands()` drains the script command queue: `Log`/`Error``log`, `SetTile` → the source object's glyph, `Move(dir)``move_object` (which gates on `in_bounds` then `is_passable || can_push`, pushing before it relocates). This deferred apply (writes after the batch) is what keeps script execution borrow-safe.
**`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()`, and `append()` build messages; each front-end converts a `LogLine` to its own styled text at render time.
- `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a `LogLine` to its own styled text at render time.
**`kiln-core/src/script.rs`** — Rhai scripting runtime:
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent `Scope`, and a shared command queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>)` (registers the API, compiles scripts, reports compile/unknown-script failures as `GameCommand::Error`; runs nothing).
@@ -71,8 +75,9 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
**`kiln-core/src/map_file.rs`** — map file loading:
- `MapFile` and friends — serde `Deserialize` types for TOML map files
- `TileIndex``#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably
- `PlayerStart``#[serde(untagged)]` enum (`Coord([i32;2])` | `Char(char)`); `player_start = [x, y]` or `player_start = "@"` (locate that char in the grid). Same dual-form trick as `TileIndex`
- `FontHeader` — deserializes the optional `[font]` section (`path`, `tile_w`, `tile_h`)
- `impl From<MapFile> for Board`converts a parsed file into a ready-to-use `Board`; unknown archetype names produce `ErrorBlock`
- `impl TryFrom<MapFile> for Board`the single load conversion. **Best-effort/nonfatal**: only a grid-dimension mismatch returns `Err`; every other problem (unknown archetype/grid char → `ErrorBlock`; object/player placement-char issues; one-solid-per-cell conflicts) is recovered and recorded on `Board::load_errors` (see `Board::is_valid`). Resolves the player (coord or `@` char, first-occurrence, falling back to `(0,0)`) and lets it *win* its cell; places objects by `x`/`y` or `palette` char
- `pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>` — reads and converts a `.toml` map file
- `pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>>` — serializes `Board` back to TOML and writes to disk
@@ -163,7 +168,7 @@ XPM-inspired: a `[palette]` maps single characters to `(Glyph, Archetype)` defin
name = "Room Name"
width = 60
height = 25
player_start = [30, 12]
player_start = [30, 12] # OR a grid char: player_start = "@"
# Optional: override the default font for this board.
[font]
@@ -174,6 +179,9 @@ tile_h = 16
[palette]
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
"o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } # solid + pushable (any dir)
"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } # crate, pushable east/west only
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # crate, pushable north/south only
[grid]
content = """
@@ -211,16 +219,33 @@ fn tick(dt) { move(North); } # optional, run every frame; dt = elapsed s
"""
```
Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object's `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention); placement and one-solid-per-cell violations are logged and the offending object is dropped (the rest of the board still loads). Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`/`tick(dt)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`) and **write** via host functions `move(dir)` (`dir``North`/`South`/`East`/`West`), `set_tile(n)`, and `log(s)`.
Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`/`tick(dt)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`) and **write** via host functions `move(dir)` (`dir``North`/`South`/`East`/`West`), `set_tile(n)`, and `log(s)`.
### 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.
- **One solid per cell** — at most one solid entity (a solid grid archetype *or* a solid object) may occupy a cell. The map loader enforces this: a solid object landing on an already-solid cell is logged and dropped. `Board::solid_at` relies on this invariant.
- **One solid per cell** — at most one solid entity (a solid grid archetype *or* a solid object, and conceptually the player) may occupy a cell. The map loader enforces this: a solid object landing on an already-solid cell is dropped (recorded via `report_error`). The player is placed first and *wins* its cell (silently clearing solid terrain / dropping a conflicting object). `Board::solid_at` relies on this invariant.
- **`cells: Vec<(Glyph, Archetype)>`** — each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient.
- **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games.
- **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games. Adding a variant: the `match`es in `behavior()`/`name()`/`default_glyph()` are exhaustive (the compiler flags them), but **`TryFrom<&str>` and `ALL_ARCHETYPES` are not** — forget the `TryFrom` arm and the archetype silently loads as `ErrorBlock`. Also update the map-format example.
- **`Board` is the complete unit** — grid, player, objects, and portals all live on `Board`, matching how ZZT treats a "board". No separate wrapper struct.
- **Glyph (visual) and Archetype (behavior) are decoupled** — each cell has its own `Glyph` (so colors can vary per-cell, e.g. fire flickering) while sharing an `Archetype` with other cells of the same type.
- **File loading happens in `main()`** before the window is created, so board dimensions are available for window sizing.
eframe runs on its own event loop thread; do not assume single-threaded execution.
### Future direction: the player may become an object
The long-term goal is for the "player" to be a board *object* that happens to respond to arrow-key events — not a hardcoded special entity — and for some boards to have **no** player at all (a cutscene/menu/puzzle that handles input differently). ZZT hardcoded the player as a special element and authors had to hack around it; avoiding that is deliberate.
Today's baked-in assumptions that will need to change (don't make `Board.player` *more* central in the meantime — e.g. don't add methods that assume player presence):
- `Board.player: Player` is required and non-optional; a player-less board can't be represented (should become `Option<Player>`, or move the player out of `Board` to the engine layer).
- `player_start` is effectively required; player spawning should eventually move into the object/script system.
- `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.
### Other not-yet-implemented threads
- **Scripting growth** — more event hooks beyond `init`/`tick` (e.g. `on_touch` when the player steps onto an object), a larger command/read vocabulary, and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call).
- **Stable object ids** — `Command.source` / object identity are array indices into `Board::objects`, which break under spawn/destroy/reorder; replace with monotonic unique ids (a `// TODO` marks this in `script.rs`).
- **Portals & multi-board** — `PortalDef` is parsed but not runtime-wired; there is no multi-board world/loading yet (the engine loads a single map).
- **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).
+442 -29
View File
@@ -69,6 +69,35 @@ pub struct FontSpec {
pub tile_h: u32,
}
/// Which directions a solid may be pushed in.
///
/// Only meaningful for `solid` cells (a non-solid cell is passable, so nothing is
/// ever pushed into it). `Crate` is [`Pushable::Any`]; the directional crates
/// constrain pushes to one axis.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Pushable {
/// Cannot be pushed.
No,
/// Pushable in any direction.
Any,
/// Pushable east/west only.
Horizontal,
/// Pushable north/south only.
Vertical,
}
impl Pushable {
/// Whether a push in `dir` is allowed.
pub fn allows(self, dir: Direction) -> bool {
match self {
Pushable::No => false,
Pushable::Any => true,
Pushable::Horizontal => matches!(dir, Direction::East | Direction::West),
Pushable::Vertical => matches!(dir, Direction::North | Direction::South),
}
}
}
/// The behavioral properties of a board cell at runtime.
///
/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It
@@ -86,9 +115,8 @@ pub struct Behavior {
pub solid: bool,
/// Whether this cell blocks line of sight (reserved for future rendering).
pub opaque: bool,
/// Whether a mover can shove this cell. Unused for now — every archetype is
/// `false`; the push mechanic is future work.
pub pushable: bool,
/// Which directions a mover can shove this cell in (only meaningful when `solid`).
pub pushable: Pushable,
}
/// A class of board cell, encoding its default behavior and appearance.
@@ -114,6 +142,13 @@ pub enum Archetype {
Empty,
/// A solid wall; impassable and opaque.
Wall,
/// A solid, pushable box. Walking into it shoves it one cell in the same
/// direction (cascading through a chain of crates) if there is open space.
Crate,
/// A crate that can only be pushed east/west (horizontal axis). Glyph ↔.
HCrate,
/// A crate that can only be pushed north/south (vertical axis). Glyph ↕.
VCrate,
/// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game.
ErrorBlock,
@@ -128,17 +163,32 @@ impl Archetype {
Archetype::Empty => Behavior {
solid: false,
opaque: false,
pushable: false,
pushable: Pushable::No,
},
Archetype::Wall => Behavior {
solid: true,
opaque: true,
pushable: false,
pushable: Pushable::No,
},
Archetype::Crate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Any,
},
Archetype::HCrate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Horizontal,
},
Archetype::VCrate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Vertical,
},
Archetype::ErrorBlock => Behavior {
solid: true,
opaque: true,
pushable: false,
pushable: Pushable::No,
},
}
}
@@ -148,6 +198,9 @@ impl Archetype {
match self {
Archetype::Empty => "empty",
Archetype::Wall => "wall",
Archetype::Crate => "crate",
Archetype::HCrate => "hcrate",
Archetype::VCrate => "vcrate",
Archetype::ErrorBlock => "error_block",
}
}
@@ -169,6 +222,21 @@ impl Archetype {
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 },
},
Archetype::Crate => Glyph {
tile: 254, // CP437 ■ (small filled square)
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // light gray on black
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::HCrate => Glyph {
tile: 29, // CP437 ↔ (left-right arrow) — pushable east/west
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::VCrate => Glyph {
tile: 18, // CP437 ↕ (up-down arrow) — pushable north/south
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
// Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph {
tile: 63,
@@ -190,6 +258,9 @@ impl TryFrom<&str> for Archetype {
match name {
"empty" => Ok(Archetype::Empty),
"wall" => Ok(Archetype::Wall),
"crate" => Ok(Archetype::Crate),
"hcrate" => Ok(Archetype::HCrate),
"vcrate" => Ok(Archetype::VCrate),
// "object" is intentionally absent: objects are not valid palette
// entries in map files. They live in [[objects]] with their own glyph.
_ => Err(format!("unknown archetype: {name}")),
@@ -201,7 +272,13 @@ impl TryFrom<&str> for Archetype {
///
/// `ErrorBlock` is excluded — it is a sentinel for load errors, not a valid
/// editing choice.
pub const ALL_ARCHETYPES: &[Archetype] = &[Archetype::Empty, Archetype::Wall];
pub const ALL_ARCHETYPES: &[Archetype] = &[
Archetype::Empty,
Archetype::Wall,
Archetype::Crate,
Archetype::HCrate,
Archetype::VCrate,
];
/// A scripted object placed on the board, loaded from a map file.
///
@@ -311,7 +388,8 @@ pub struct PortalDef {
/// rather than being stored as a board cell. This is expected to change:
/// the player will eventually become a scripted object that responds to
/// input events, at which point this struct may be removed or made optional.
/// See `ARCHITECTURE.md` for details.
/// See the "player may become an object" notes in `CLAUDE.md` for the design
/// tensions this implies.
#[derive(Copy, Clone)]
pub struct Player {
/// Column position (0-indexed, increasing rightward).
@@ -368,6 +446,11 @@ pub struct Board {
/// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`)
/// rather than being tied to a specific object cell.
pub board_script_name: Option<String>,
/// Nonfatal problems collected while loading this map (e.g. unknown
/// archetypes, dropped objects, recovered placement chars), as red-on-black
/// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of
/// the map file (purely a load diagnostic).
pub(crate) load_errors: Vec<LogLine>,
}
impl Board {
@@ -382,11 +465,37 @@ impl Board {
/// Returns a mutable reference to the cell at `(x, y)`.
///
/// Panics if `x` or `y` are out of bounds.
#[allow(dead_code)]
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
&mut self.cells[y * self.width + x]
}
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
///
/// Takes signed coords so callers can pass a raw `pos + delta` without first
/// checking for negatives.
pub fn in_bounds(&self, pos: (i32, i32)) -> bool {
let (x, y) = pos;
x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height
}
/// Records a nonfatal error: appends `message` as a red-on-black line to the
/// board's [`load_errors`](Board::load_errors). Used by the map loader (and
/// available at runtime) to surface recoverable problems.
pub fn report_error(&mut self, message: impl Into<String>) {
self.load_errors.push(LogLine::error(message));
}
/// Returns `true` if the map loaded with no nonfatal errors (the error list
/// is empty).
pub fn is_valid(&self) -> bool {
self.load_errors.is_empty()
}
/// The nonfatal errors collected while loading this board, newest last.
pub fn load_errors(&self) -> &[LogLine] {
&self.load_errors
}
/// Returns a slice of all cells in row-major order.
///
/// Useful for iterating over the full board (e.g. to collect unique glyphs).
@@ -423,6 +532,92 @@ impl Board {
self.solid_at(x, y).is_none()
}
/// 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.
/// Grid archetypes may restrict the axis (see [`Pushable`]); pushable objects
/// can be shoved in any direction.
fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool {
match self.solid_at(x, y) {
Some(Solid::Cell(a)) => a.behavior().pushable.allows(dir),
Some(Solid::Object(o)) => o.pushable,
None => false,
}
}
/// 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
/// edge or a non-pushable solid.
///
/// Read-only (`&self`); pairs with [`push`](Board::push). Returns `false` when
/// `(x, y)` itself holds no pushable solid, so it doubles as the "is the cell
/// ahead shovable?" half of a "can I move here?" query.
pub fn can_push(&self, x: usize, y: usize, dir: Direction) -> bool {
let (dx, dy): (i32, i32) = dir.into();
let (mut cx, mut cy) = (x, y);
loop {
// This cell must hold a solid pushable in `dir` to advance the chain.
if !self.is_pushable(cx, cy, dir) {
return false;
}
let next = (cx as i32 + dx, cy as i32 + dy);
if !self.in_bounds(next) {
return false; // chain runs off the board
}
let (nx, ny) = (next.0 as usize, next.1 as usize);
if self.is_passable(nx, ny) {
return true; // open space at the end: the whole chain can move
}
// Next cell holds a solid too; continue (it must itself be pushable).
cx = nx;
cy = ny;
}
}
/// Shoves the chain of pushable solids starting at `(x, y)` one step in `dir`,
/// leaving `Empty` floor behind each moved cell.
///
/// No-op when the chain can't move (it self-checks via [`can_push`](Board::can_push)),
/// so it is safe to call unconditionally.
pub fn push(&mut self, x: usize, y: usize, dir: Direction) {
if !self.can_push(x, y, dir) {
return;
}
let (dx, dy): (i32, i32) = dir.into();
// can_push guaranteed the chain ends at an in-bounds passable cell, so
// re-walk it (no bounds checks needed) and shift the far end first, which
// keeps each destination cell vacated before its occupant arrives.
let mut chain: Vec<(usize, usize)> = Vec::new();
let (mut cx, mut cy) = (x, y);
while !self.is_passable(cx, cy) {
chain.push((cx, cy));
cx = (cx as i32 + dx) as usize;
cy = (cy as i32 + dy) as usize;
}
for &(px, py) in chain.iter().rev() {
self.shift_solid(px, py, dx, dy);
}
}
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
///
/// A solid object is relocated; otherwise the grid archetype (a crate) is
/// moved, leaving `Empty` floor behind (the grid has no separate floor layer).
/// The caller guarantees the destination is already clear.
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
if let Some(idx) = self.object_index_at(x, y)
&& self.objects[idx].solid
{
self.objects[idx].x = tx;
self.objects[idx].y = ty;
} else {
let moved = *self.get(x, y);
*self.get_mut(tx, ty) = moved;
*self.get_mut(x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty);
}
}
/// Returns the index into [`Board::objects`] of the object at `(x, y)`, if any.
pub fn object_index_at(&self, x: usize, y: usize) -> Option<usize> {
self.objects.iter().position(|o| o.x == x && o.y == y)
@@ -523,42 +718,45 @@ impl GameState {
}
}
/// Moves object `idx` one cell in `dir`, if the target is in bounds and
/// passable. This is where movement rules live (a no-op when blocked).
/// Moves object `idx` one cell in `dir`. The move proceeds 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`]). A no-op when blocked.
fn move_object(&mut self, idx: usize, dir: Direction) {
let (dx, dy): (i32, i32) = dir.into();
let mut board = self.board_mut();
let Some((ox, oy)) = board.objects.get(idx).map(|o| (o.x, o.y)) else {
return;
};
let nx = ox as i32 + dx;
let ny = oy as i32 + dy;
if nx < 0 || ny < 0 {
let target = (ox as i32 + dx, oy as i32 + dy);
if !board.in_bounds(target) {
return;
}
let (nx, ny) = (nx as usize, ny as usize);
if nx < board.width && ny < board.height && board.is_passable(nx, ny) {
let (nx, ny) = (target.0 as usize, target.1 as usize);
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
let obj = &mut board.objects[idx];
obj.x = nx;
obj.y = ny;
}
}
/// Attempts to move the player by `(dx, dy)` cells.
/// Attempts to move the player one cell in `dir`.
///
/// The move is ignored if the target cell is out of bounds or its behavior
/// is not passable. No-ops silently (the caller does not need to check).
pub fn try_move(&mut self, dx: i32, dy: i32) {
/// 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 caller does not need to check).
pub fn try_move(&mut self, dir: Direction) {
let (dx, dy): (i32, i32) = dir.into();
let mut board = self.board_mut();
let nx = board.player.x + dx;
let ny = board.player.y + dy;
if nx >= 0 && ny >= 0 {
let nx = nx as usize;
let ny = ny as usize;
if nx < board.width && ny < board.height && board.is_passable(nx, ny) {
board.player.x = nx as i32;
board.player.y = ny as i32;
}
let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) {
return;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
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
board.player.x = nx as i32;
board.player.y = ny as i32;
}
}
}
@@ -587,6 +785,7 @@ mod tests {
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
board_script_name: None,
load_errors: Vec::new(),
}
}
@@ -625,6 +824,7 @@ mod tests {
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
board_script_name: None,
load_errors: Vec::new(),
}
}
@@ -673,6 +873,219 @@ mod tests {
assert!(board.is_passable(1, 0));
}
/// Stamps a crate cell onto an otherwise-open board.
fn crate_at(board: &mut Board, x: usize, y: usize) {
*board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
}
/// Stamps a wall cell onto an otherwise-open board.
fn wall_at(board: &mut Board, x: usize, y: usize) {
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
}
#[test]
fn in_bounds_checks_grid_boundaries() {
let board = open_board(3, 2, (0, 0), vec![], &[]);
assert!(board.in_bounds((0, 0)));
assert!(board.in_bounds((2, 1)));
assert!(!board.in_bounds((-1, 0)));
assert!(!board.in_bounds((0, -1)));
assert!(!board.in_bounds((3, 0))); // x == width
assert!(!board.in_bounds((0, 2))); // y == height
}
#[test]
fn can_push_is_read_only_and_correct() {
// Crate with empty space beyond: pushable, and the board is left untouched.
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation
assert_eq!(board.get(2, 0).1, Archetype::Empty);
// Crate against a wall: not pushable.
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
assert!(!board.can_push(1, 0, Direction::East));
}
#[test]
fn player_pushes_single_crate() {
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0)); // player advanced onto crate's old cell
assert_eq!(b.get(1, 0).1, Archetype::Empty); // crate left this cell
assert_eq!(b.get(2, 0).1, Archetype::Crate); // crate moved one east
}
#[test]
fn push_blocked_by_wall_moves_nothing() {
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); // blocked
assert_eq!(b.get(1, 0).1, Archetype::Crate); // crate didn't move
assert_eq!(b.get(2, 0).1, Archetype::Wall); // wall didn't move
}
#[test]
fn push_blocked_by_edge_moves_nothing() {
// Crate on the last column; player pushes it toward the board edge.
let mut board = open_board(2, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Crate);
}
#[test]
fn cascade_pushes_two_crates() {
let mut board = open_board(5, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
crate_at(&mut board, 2, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::Crate);
assert_eq!(b.get(3, 0).1, Archetype::Crate);
}
#[test]
fn cascade_blocked_by_wall_moves_nothing() {
let mut board = open_board(5, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
crate_at(&mut board, 2, 0);
wall_at(&mut board, 3, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Crate);
assert_eq!(b.get(2, 0).1, Archetype::Crate);
}
#[test]
fn player_pushes_pushable_solid_object() {
// A solid, pushable object is shoved exactly like a crate.
let mut obj = ObjectDef::new(1, 0); // solid by default
obj.pushable = true;
let board = open_board(4, 1, (0, 0), vec![obj], &[]);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 0)); // object shoved east
}
#[test]
fn object_pushes_crate_on_init() {
// A scripted object moving east into a crate shoves it (move_object path).
let mut board = open_board(
4,
1,
(0, 0), // player behind the object, out of the push path
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); }")],
);
crate_at(&mut board, 2, 0);
let mut game = GameState::new(board);
game.run_init();
let b = game.board();
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 0)); // object advanced
assert_eq!(b.get(2, 0).1, Archetype::Empty); // crate left (2,0)
assert_eq!(b.get(3, 0).1, Archetype::Crate); // crate pushed to (3,0)
}
#[test]
fn pushable_allows_only_its_axis() {
use Direction::*;
for d in [North, South, East, West] {
assert!(!Pushable::No.allows(d));
assert!(Pushable::Any.allows(d));
}
assert!(Pushable::Horizontal.allows(East) && Pushable::Horizontal.allows(West));
assert!(!Pushable::Horizontal.allows(North) && !Pushable::Horizontal.allows(South));
assert!(Pushable::Vertical.allows(North) && Pushable::Vertical.allows(South));
assert!(!Pushable::Vertical.allows(East) && !Pushable::Vertical.allows(West));
}
/// Stamps an archetype cell onto an otherwise-open board.
fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
*board.get_mut(x, y) = (arch.default_glyph(), arch);
}
#[test]
fn hcrate_pushes_east_but_not_north() {
// Pushing east: the HCrate slides.
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
stamp(&mut board, 1, 0, Archetype::HCrate);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::HCrate);
drop(b);
// Pushing north into an HCrate: blocked, nothing moves.
let mut board = open_board(1, 4, (0, 3), vec![], &[]);
stamp(&mut board, 0, 2, Archetype::HCrate);
let mut game = GameState::new(board);
game.try_move(Direction::North);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 3)); // blocked
assert_eq!(b.get(0, 2).1, Archetype::HCrate); // didn't move
}
#[test]
fn vcrate_pushes_north_but_not_east() {
// Pushing north: the VCrate slides.
let mut board = open_board(1, 4, (0, 3), vec![], &[]);
stamp(&mut board, 0, 2, Archetype::VCrate);
let mut game = GameState::new(board);
game.try_move(Direction::North);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 2));
assert_eq!(b.get(0, 2).1, Archetype::Empty);
assert_eq!(b.get(0, 1).1, Archetype::VCrate);
drop(b);
// Pushing east into a VCrate: blocked, nothing moves.
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
stamp(&mut board, 1, 0, Archetype::VCrate);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); // blocked
assert_eq!(b.get(1, 0).1, Archetype::VCrate); // didn't move
}
#[test]
fn fresh_board_is_valid_and_reports_errors() {
let mut board = open_board(1, 1, (0, 0), vec![], &[]);
assert!(board.is_valid());
board.report_error("something went wrong");
assert!(!board.is_valid());
assert_eq!(board.load_errors().len(), 1);
}
#[test]
fn directional_crate_default_glyph_tiles() {
assert_eq!(Archetype::HCrate.default_glyph().tile, 29);
assert_eq!(Archetype::VCrate.default_glyph().tile, 18);
}
#[test]
fn init_runs_only_on_run_init_not_at_construction() {
let board = board_with_object(
+20
View File
@@ -44,6 +44,26 @@ impl LogLine {
}
}
/// Creates an error line: a single span in red on black, used for surfacing
/// nonfatal problems (e.g. recoverable map-load errors).
pub fn error(text: impl Into<String>) -> Self {
Self::new().push(
text,
Some(Rgba8 {
r: 255,
g: 0,
b: 0,
a: 255,
}),
Some(Rgba8 {
r: 0,
g: 0,
b: 0,
a: 255,
}),
)
}
/// Appends a styled span and returns `self`, so calls can be chained when
/// building a multi-color line.
pub fn push(mut self, text: impl Into<String>, fg: Option<Rgba8>, bg: Option<Rgba8>) -> Self {
+233 -45
View File
@@ -1,4 +1,5 @@
use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef};
use crate::log::LogLine;
use color::Rgba8;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -45,8 +46,9 @@ pub struct MapHeader {
pub width: usize,
/// Height of the board in cells. Must match the number of rows in `[grid] content`.
pub height: usize,
/// Starting position of the player as `[x, y]` (0-indexed, origin top-left).
pub player_start: [i32; 2],
/// Where the player starts: either explicit `[x, y]` coordinates or a single
/// grid character to locate (e.g. `"@"`). See [`PlayerStart`].
pub player_start: PlayerStart,
/// Name of the board-level script in the `[scripts]` table, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub board_script_name: Option<String>,
@@ -92,6 +94,22 @@ impl TileIndex {
}
}
/// Where the player starts in a map file: explicit coordinates or a single grid
/// character to locate.
///
/// Untagged like [`TileIndex`], so `player_start = [30, 12]` parses as
/// [`PlayerStart::Coord`] and `player_start = "@"` as [`PlayerStart::Char`]
/// (`Coord` is listed first, so a TOML array matches it and a string falls
/// through to `Char`).
#[derive(Deserialize, Serialize)]
#[serde(untagged)]
pub enum PlayerStart {
/// Explicit `[x, y]` coordinates (0-indexed, origin top-left).
Coord([i32; 2]),
/// A single grid character marking the player's cell (its cell loads `Empty`).
Char(char),
}
/// One entry in the `[palette]` table.
///
/// Each entry is keyed by a single character (e.g. `"#"` or `" "`).
@@ -220,6 +238,16 @@ impl TryFrom<MapFile> for Board {
let w = mf.map.width;
let h = mf.map.height;
// Nonfatal problems collected as we load; surfaced on the Board so callers
// can read `Board::is_valid`. Only grid-dimension mismatch (below) is fatal.
let mut load_errors: Vec<LogLine> = Vec::new();
// If the player is placed by a grid char, this is that char (e.g. '@').
let player_char = match mf.map.player_start {
PlayerStart::Char(c) => Some(c),
PlayerStart::Coord(_) => None,
};
// Pass 1: build a char → (Glyph, Archetype) lookup from the palette.
let mut palette: HashMap<char, (Glyph, Archetype)> = HashMap::new();
@@ -236,7 +264,7 @@ impl TryFrom<MapFile> for Board {
palette.insert(key_char, (glyph, archetype));
}
Err(e) => {
eprintln!("{e}");
load_errors.push(LogLine::error(e));
palette.insert(
key_char,
(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock),
@@ -277,36 +305,45 @@ impl TryFrom<MapFile> for Board {
};
// `palette` is mutually exclusive with explicit `x`/`y`.
if e.x.is_some() || e.y.is_some() {
eprintln!("object {i}: specify either x/y or palette, not both; skipping object");
load_errors.push(LogLine::error(format!(
"object {i}: specify either x/y or palette, not both; skipping object"
)));
skip[i] = true;
continue;
}
// It must be exactly one character.
let mut chs = p.chars();
let (Some(ch), None) = (chs.next(), chs.next()) else {
eprintln!(
load_errors.push(LogLine::error(format!(
"object {i}: palette must be a single character (got {p:?}); skipping object"
);
)));
skip[i] = true;
continue;
};
// It can't reuse a terrain palette key.
if palette.contains_key(&ch) {
eprintln!(
"object {i}: palette char '{ch}' is already a [palette] key; skipping object"
);
// The player's char (e.g. '@') is reserved.
if Some(ch) == player_char {
load_errors.push(LogLine::error(format!(
"object {i}: palette char '{ch}' is reserved for the player; skipping object"
)));
skip[i] = true;
continue;
}
// It must be unique across objects — a clash drops both owners.
// It can't reuse a terrain palette key.
if palette.contains_key(&ch) {
load_errors.push(LogLine::error(format!(
"object {i}: palette char '{ch}' is already a [palette] key; skipping object"
)));
skip[i] = true;
continue;
}
// It must be unique across objects — keep the first claimant, drop later ones.
use std::collections::hash_map::Entry;
match palette_char_owner.entry(ch) {
Entry::Occupied(o) => {
eprintln!(
"object palette char '{ch}' is used by more than one object; skipping both"
);
Entry::Occupied(_) => {
load_errors.push(LogLine::error(format!(
"object {i}: palette char '{ch}' is already used by another object; skipping object"
)));
skip[i] = true;
skip[*o.get()] = true;
}
Entry::Vacant(v) => {
v.insert(i);
@@ -321,33 +358,48 @@ impl TryFrom<MapFile> for Board {
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h);
let mut obj_palette_pos: HashMap<char, (usize, usize)> = HashMap::new();
let mut obj_palette_count: HashMap<char, usize> = HashMap::new();
// Where the player's char was first seen, and how many times (for recovery).
let mut player_grid_pos: Option<(usize, usize)> = None;
let mut player_grid_count: usize = 0;
for (y, line) in rows.iter().enumerate() {
for (x, ch) in line.chars().enumerate() {
if let Some(&cell) = palette.get(&ch) {
cells.push(cell);
} else if Some(ch) == player_char {
// Player placeholder: floor underneath; keep the first sighting.
cells.push(canonical_empty);
player_grid_pos.get_or_insert((x, y));
player_grid_count += 1;
} else if palette_char_owner.contains_key(&ch) {
cells.push(canonical_empty);
obj_palette_pos.insert(ch, (x, y));
obj_palette_pos.entry(ch).or_insert((x, y));
*obj_palette_count.entry(ch).or_insert(0) += 1;
} else {
eprintln!("unknown grid character '{ch}' at ({x}, {y}); using error block");
load_errors.push(LogLine::error(format!(
"unknown grid character '{ch}' at ({x}, {y}); using error block"
)));
cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock));
}
}
}
// Each surviving object palette char must appear in the grid exactly once.
// Resolve each surviving object palette char: missing → drop; multiple →
// keep the object at the first occurrence (already recorded) and report.
for (&ch, &owner) in &palette_char_owner {
if skip[owner] {
continue;
}
let n = obj_palette_count.get(&ch).copied().unwrap_or(0);
if n != 1 {
eprintln!(
"object palette char '{ch}' appears {n} times in the grid \
(must be exactly once); skipping object"
);
skip[owner] = true;
match obj_palette_count.get(&ch).copied().unwrap_or(0) {
0 => {
load_errors.push(LogLine::error(format!(
"object palette char '{ch}' not found in the grid; skipping object"
)));
skip[owner] = true;
}
1 => {}
n => load_errors.push(LogLine::error(format!(
"object palette char '{ch}' appears {n} times in the grid; using the first"
))),
}
}
@@ -361,6 +413,44 @@ impl TryFrom<MapFile> for Board {
// `solid_occupied` is seeded from the grid (solid archetypes); each solid
// object then claims its cell, and a second solid on a cell is dropped.
let mut solid_occupied: Vec<bool> = cells.iter().map(|(_, a)| a.behavior().solid).collect();
// Resolve the player's cell (nonfatal; fall back to (0, 0) on any problem),
// then let the player *win* its cell: clear solid terrain under it and claim
// the cell so a solid object placed here is dropped by the loop below.
let (px, py) = match mf.map.player_start {
PlayerStart::Coord([x, y])
if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h =>
{
(x as usize, y as usize)
}
PlayerStart::Coord([x, y]) => {
load_errors.push(LogLine::error(format!(
"player_start ({x}, {y}) is out of bounds; placing player at (0, 0)"
)));
(0, 0)
}
PlayerStart::Char(c) => match (player_grid_pos, player_grid_count) {
(Some(pos), 1) => pos,
(Some(pos), n) => {
load_errors.push(LogLine::error(format!(
"player char '{c}' appears {n} times in the grid; using the first"
)));
pos
}
_ => {
load_errors.push(LogLine::error(format!(
"player char '{c}' not found in the grid; placing player at (0, 0)"
)));
(0, 0)
}
},
};
let pidx = py * w + px;
if solid_occupied[pidx] {
cells[pidx] = canonical_empty;
}
solid_occupied[pidx] = true;
let mut objects: Vec<ObjectDef> = Vec::new();
for (i, e) in mf.objects.into_iter().enumerate() {
if skip[i] {
@@ -374,13 +464,15 @@ impl TryFrom<MapFile> for Board {
match (e.x, e.y) {
(Some(x), Some(y)) if x < w && y < h => (x, y),
(Some(x), Some(y)) => {
eprintln!("object {i}: x/y ({x}, {y}) out of bounds; skipping object");
load_errors.push(LogLine::error(format!(
"object {i}: x/y ({x}, {y}) out of bounds; skipping object"
)));
continue;
}
_ => {
eprintln!(
load_errors.push(LogLine::error(format!(
"object {i}: must specify both x and y (or a palette char); skipping object"
);
)));
continue;
}
}
@@ -402,9 +494,13 @@ impl TryFrom<MapFile> for Board {
if obj.solid {
let idx = y * w + x;
if solid_occupied[idx] {
eprintln!(
"object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object"
);
// The player silently wins its own cell (by design); any other
// solid collision is a reportable mistake.
if idx != pidx {
load_errors.push(LogLine::error(format!(
"object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object"
)));
}
continue;
}
solid_occupied[idx] = true;
@@ -418,8 +514,8 @@ impl TryFrom<MapFile> for Board {
height: h,
cells,
player: Player {
x: mf.map.player_start[0],
y: mf.map.player_start[1],
x: px as i32,
y: py as i32,
},
objects,
portals: mf.portals,
@@ -427,6 +523,7 @@ impl TryFrom<MapFile> for Board {
zoom: mf.map.zoom,
scripts: mf.scripts,
board_script_name: mf.map.board_script_name,
load_errors,
})
}
}
@@ -544,7 +641,7 @@ impl From<&Board> for MapFile {
name: board.name.clone(),
width: board.width,
height: board.height,
player_start: [board.player.x, board.player.y],
player_start: PlayerStart::Coord([board.player.x, board.player.y]),
board_script_name: board.board_script_name.clone(),
zoom: board.zoom,
},
@@ -602,7 +699,7 @@ mod tests {
name = "Test"
width = 3
height = 1
player_start = [0, 0]
player_start = [2, 0]
[palette]
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
@@ -638,19 +735,26 @@ content = """
fn palette_char_absent_from_grid_drops_object() {
let board = load_board(&map_3x1("...", &obj_palette("G", "")));
assert!(board.objects.is_empty());
assert!(!board.is_valid()); // a dropped object is a recoverable error
}
#[test]
fn palette_char_appearing_twice_drops_object() {
fn palette_char_appearing_twice_uses_first() {
// `G` appears at (0,0) and (2,0): the object lands at the first, and the
// map is flagged invalid (an extra appearance was reported).
let board = load_board(&map_3x1("G.G", &obj_palette("G", "")));
assert!(board.objects.is_empty());
assert_eq!(board.objects.len(), 1);
assert_eq!((board.objects[0].x, board.objects[0].y), (0, 0));
assert!(!board.is_valid());
}
#[test]
fn palette_char_reused_by_two_objects_drops_both() {
fn palette_char_reused_by_two_objects_keeps_first() {
// Two objects claim `G`: the first keeps it, the later one is dropped.
let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", ""));
let board = load_board(&map_3x1("G..", &two));
assert!(board.objects.is_empty());
assert_eq!(board.objects.len(), 1);
assert!(!board.is_valid());
}
#[test]
@@ -678,6 +782,88 @@ content = """
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let board = load_board(&map_3x1("...", &format!("{obj}{obj}")));
assert_eq!(board.objects.len(), 1);
assert!(!board.is_valid());
}
/// A 1-row map of the given `width` with a raw `player_start` TOML value
/// (`start`, e.g. `[1, 0]` or `"@"`), grid, and objects block.
fn player_map(width: usize, start: &str, grid: &str, objects: &str) -> String {
format!(
r##"
[map]
name = "Test"
width = {width}
height = 1
player_start = {start}
[palette]
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
[grid]
content = """
{grid}
"""
{objects}
"##
)
}
#[test]
fn player_coords_place_the_player() {
let b = load_board(&player_map(3, "[1, 0]", "...", ""));
assert_eq!((b.player.x, b.player.y), (1, 0));
assert!(b.is_valid());
}
#[test]
fn player_char_places_on_empty_floor() {
let b = load_board(&player_map(3, "\"@\"", ".@.", ""));
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert!(b.is_valid());
}
#[test]
fn player_wins_solid_terrain_silently() {
// Player coords land on a wall: the wall is cleared, no error reported.
let b = load_board(&player_map(3, "[0, 0]", "#..", ""));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 0).1, Archetype::Empty);
assert!(b.is_valid());
}
#[test]
fn player_wins_against_a_solid_object_silently() {
// A solid object on the player's cell is dropped, silently (player wins).
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let b = load_board(&player_map(3, "[1, 0]", "...", obj));
assert!(b.objects.is_empty());
assert!(b.is_valid());
}
#[test]
fn player_char_is_reserved_from_objects() {
// An object trying to use '@' is dropped; the player is still placed.
let obj = "[[objects]]\npalette = \"@\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let b = load_board(&player_map(3, "\"@\"", "@..", obj));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(b.objects.is_empty());
assert!(!b.is_valid());
}
#[test]
fn player_char_appearing_twice_uses_first() {
let b = load_board(&player_map(3, "\"@\"", "@.@", ""));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(!b.is_valid());
}
#[test]
fn player_char_missing_falls_back_to_origin() {
let b = load_board(&player_map(3, "\"@\"", "...", ""));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(!b.is_valid());
}
/// Minimal valid TOML with a configurable grid, used as a base for dimension tests.
@@ -742,20 +928,22 @@ content = """
#[test]
fn object_archetype_in_palette_produces_error_block() {
// "object" is no longer a valid palette archetype; it should produce ErrorBlock.
// "object" is no longer a valid palette archetype; it should produce
// ErrorBlock. Player is placed away from the O cell so it isn't cleared.
let toml = r##"
[map]
name = "Test"
width = 1
width = 2
height = 1
player_start = [0, 0]
player_start = [1, 0]
[palette]
"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" }
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
[grid]
content = """
O
O.
"""
"##;
let mf: MapFile = toml::from_str(toml).unwrap();
+5 -4
View File
@@ -14,6 +14,7 @@ mod term;
use kiln_core::game::GameState;
use kiln_core::log::LogLine;
use kiln_core::map_file;
use kiln_core::script::Direction;
use ratatui::Frame;
use ratatui::crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
@@ -143,11 +144,11 @@ fn run(
// which would otherwise fire a second, spurious move per keypress.
Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Up => game.try_move(0, -1),
KeyCode::Down => game.try_move(0, 1),
KeyCode::Left => game.try_move(-1, 0),
KeyCode::Up => game.try_move(Direction::North),
KeyCode::Down => game.try_move(Direction::South),
KeyCode::Left => game.try_move(Direction::West),
// Right arrow moves right; 'l' is the log panel.
KeyCode::Right => game.try_move(1, 0),
KeyCode::Right => game.try_move(Direction::East),
// 'l' toggles the log panel; reset scroll to newest.
KeyCode::Char('l') => {
ui.log_open = !ui.log_open;
+5 -2
View File
@@ -2,12 +2,15 @@
name = "Starting Room"
width = 60
height = 25
player_start = [30, 12]
player_start = "@" # placed by the '@' grid char (convention)
[palette]
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", tile = "#", fg = "#808080", bg = "#606060" }
"+" = { archetype = "banana", tile = "#", fg = "#808080", bg = "#606060" }
"o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" }
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" }
[grid]
content = """
@@ -23,7 +26,7 @@ content = """
# # #
# #
# #
# G #
# oo G - | @ #
# #
# #
# #