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