diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 9ec522d..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -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`. - -**`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` — scripted objects; their `init()`/`tick(dt)` hooks run via the scripting runtime, and `passable`/`opaque` affect collision -- `portals: Vec` — exits to other boards (parsed, not yet runtime-wired) -- `scripts: HashMap` — named Rhai source, referenced by objects via `script_name` -- `font: Option` — 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`. `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` 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>`, 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_mut() -> RefMut` (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 }`, where `LogSpan { text, fg: Option, bg: Option }`, 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>)`, 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>` (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>>` 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 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 0x00–0x1F, box-drawing for 0xB0–0xDF, 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` 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 for Board`. - -**`impl From 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`** — 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` (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` (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` (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`), 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`, 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. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a033b4d..877555a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`, `portals: Vec`, `font: Option`. `cells` is `pub(crate)`. `solid_at(x, y) -> Option` 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`, `portals: Vec`, `font: Option`. `cells` is `pub(crate)`. `solid_at(x, y) -> Option` 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`); `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`. `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>`, the message `log: Vec`, and a `ScriptHost`. The board sits behind `Rc>` 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_mut() -> RefMut` (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>`, the message `log: Vec`, and a `ScriptHost`. The board sits behind `Rc>` 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_mut() -> RefMut` (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, bg: Option }` and `LogLine { spans: Vec }` — 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, bg: Option }` and `LogLine { spans: Vec }` — 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>)` (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 for Board` — converts a parsed file into a ready-to-use `Board`; unknown archetype names produce `ErrorBlock` +- `impl TryFrom 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>` — reads and converts a `.toml` map file - `pub fn save(board: &Board, path: &Path) -> Result<(), Box>` — 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`, 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). diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 4ae393f..0c9b742 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -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, + /// 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, } 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) { + 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 { 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( diff --git a/kiln-core/src/log.rs b/kiln-core/src/log.rs index 3d096f2..19340c6 100644 --- a/kiln-core/src/log.rs +++ b/kiln-core/src/log.rs @@ -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) -> 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, fg: Option, bg: Option) -> Self { diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 6711619..2826911 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -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, @@ -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 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 = 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 = HashMap::new(); @@ -236,7 +264,7 @@ impl TryFrom 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 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 for Board { let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h); let mut obj_palette_pos: HashMap = HashMap::new(); let mut obj_palette_count: HashMap = 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 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 = 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 = Vec::new(); for (i, e) in mf.objects.into_iter().enumerate() { if skip[i] { @@ -374,13 +464,15 @@ impl TryFrom 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 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 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 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(); diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index a16c81c..5bdcf9d 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -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; diff --git a/maps/start.toml b/maps/start.toml index ef68b6f..0d6452d 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -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 - | @ # # # # # # #