390 lines
34 KiB
Markdown
390 lines
34 KiB
Markdown
# 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. |