Files
kiln/CLAUDE.md
T

173 lines
14 KiB
Markdown
Raw Normal View History

2026-05-15 23:42:14 -05:00
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
2026-05-17 19:38:41 -05:00
`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).
2026-05-15 23:42:14 -05:00
## Code style
- Add `///` rustdoc comments to every `pub` type, field, and function. The user reads rustdoc in their IDE to understand the code while making changes.
- Add inline `//` comments inside non-trivial function bodies to explain the *why* of each logical step — especially in `update` loops, rendering math, and conversion logic.
- Keep comments accurate: update them when the code they describe changes.
## Finishing an epic
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.
2026-05-15 23:42:14 -05:00
## Commands
```bash
cargo build # compile
2026-05-16 01:51:57 -05:00
cargo run # build and run (loads maps/start.toml)
2026-05-15 23:42:14 -05:00
cargo test # run all tests
cargo test <name> # run a single test by name (substring match)
cargo clippy # lint
cargo fmt # format
```
## Architecture
The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI. eframe drives a retained-mode UI: the `App::update` method is called every frame and is responsible for both drawing and responding to input.
`update` is structured in phases:
- Input handling — arrow keys move the player (Play mode only)
- `egui::TopBottomPanel::top` — menu bar (File → Exit, Play/Edit mode toggle)
- `editor::show_editor_panel` — archetype palette panel (Edit mode only; declared before CentralPanel)
- `egui::CentralPanel::default` — game viewport; rendered with `render::draw_board`; click-to-paint in Edit mode
- `glyph_picker::show` — floating picker dialog (Edit mode only, when open)
2026-05-15 23:42:14 -05:00
2026-05-16 01:51:57 -05:00
### Modules
**`src/game.rs`** — all core game types:
2026-05-19 00:07:04 -05:00
- `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: `passable: bool`, `opaque: bool`. Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere.
- `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.
- `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)`. `is_passable(x, y)` checks objects before the grid cell: an impassable object blocks even over a passable floor tile.
2026-05-16 01:51:57 -05:00
- `Player``x: i32, y: i32`
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `passable: bool`, `opaque: bool`, `script_name: Option<String>`. `passable` defaults `false` and `opaque` defaults `true` in map files. Scripts not yet runtime-wired.
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
2026-05-16 01:51:57 -05:00
- `GameState` — holds `board: Board`; `try_move(dx, dy)` checks passability before moving the player
2026-05-19 00:07:04 -05:00
**`src/font.rs`** — bitmap font loading and UV mapping:
- `BitmapFont` — wraps an egui `TextureHandle` (preprocessed to opaque-white / transparent) plus tile dimensions. The top-left pixel of the source PNG defines the "background" color; those pixels become `TRANSPARENT`, all others become `WHITE`. At render time, `painter.image(…, tile_uv, fg_color)` tints white pixels to `fg` and transparent pixels reveal the `bg` fill behind.
- `BitmapFont::load(ctx, path, tile_w, tile_h)` — loads from disk
- `BitmapFont::from_bytes(ctx, bytes, tile_w, tile_h, label)` — loads from embedded bytes
- `BitmapFont::create_placeholder(ctx)` — 16×16 grid of 8×8 procedural tiles (bit-pattern bars), used as fallback when no font file is present
- `BitmapFont::tile_uv(tile)` — returns a UV `Rect` for the given tile index (left-to-right, top-to-bottom). Unit-tested via `compute_tile_uv`.
2026-06-03 17:45:43 -05:00
- `BitmapFont::cell_size(zoom)` — pixel size (`Vec2`) of one rendered cell at the given integer zoom; centralizes the `tile_w * zoom` math used across rendering and hit-testing
2026-05-19 00:07:04 -05:00
- `BitmapFont::tile_cols()`, `tile_count()`, `img_w()`, `img_h()` — geometry helpers used by the glyph picker and font dialog
**`src/font_dialog.rs`** — font picker dialog:
- `FontDialogState` — in-progress edit: `path: String`, `tile_w/tile_h: u32`, `preview: Option<BitmapFont>`
- `FontDialogState::from_spec(spec)` — initializes from an existing `FontSpec` or defaults (8×16, empty path)
- `show(ctx, open, state, font_spec)` — floating dialog with file browser (`rfd::FileDialog`), tile dimension `DragValue` controls, scrollable tilesheet preview with grid overlay, and Apply / Use default buttons. Uses a `should_close` flag to avoid the egui `.open()` borrow conflict.
2026-05-16 01:51:57 -05:00
**`src/map_file.rs`** — map file loading:
- `MapFile` and friends — serde `Deserialize` types for TOML map files
2026-05-19 00:07:04 -05:00
- `TileIndex``#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably
- `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`
2026-05-16 01:51:57 -05:00
- `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
2026-05-16 01:51:57 -05:00
**`src/main.rs`** — app entry point and frame loop:
- `AppMode` enum (`Play` | `Edit`) — gates arrow-key input; toggles the side panel and viewport mode
2026-05-19 00:07:04 -05:00
- `App` holds `GameState`, `AppMode`, `EditorState`, `default_font: BitmapFont`, and `board_font: Option<BitmapFont>`
- `App::new(board, egui_ctx)` — loads `assets/vga-font-8x16.png` as the default font (falls back to `create_placeholder`); loads per-board font from `board.font` if present
2026-06-03 17:45:43 -05:00
- `App::update` is a thin sequence of phase methods: `handle_input` (table-driven arrow keys, Play only) → `menu_bar` (File Save/Save As via `save_to`/`save_as`, Exit, mode toggle) → `try_show_script_editor` (returns `true` to take over the frame) → editor side panel + font reload → `show_board``handle_board_click``show_glyph_pickers` → object-glyph writeback
- `App::active_font()` — resolves the per-board font or the default; used wherever an exclusive borrow of `self` is not also needed
- `App::show_board(&self) -> Option<(usize, usize)>` — draws the viewport and returns the clicked cell (Edit mode); the click is dispatched to `handle_board_click(&mut self, cx, cy)` *after* `show_board` returns, so the `&mut` handler never conflicts with the font borrow
2026-05-19 00:07:04 -05:00
- Font change detection: `font_spec_before` is snapshotted before `show_editor_panel`; if changed, `apply_font_spec` reloads `board_font`
2026-06-03 17:45:43 -05:00
- Borrow split: at the editor-panel and glyph-picker call sites, `self.board_font.as_ref().unwrap_or(&self.default_font)` is used inline (not `active_font()`) so the font borrow stays disjoint from the `&mut self.editor`/`&mut board` borrows
- **Play mode**: arrow keys move player; `render::board_origin` centers or player-tracks the viewport
2026-06-03 17:45:43 -05:00
- **Edit mode**: `ScrollArea::both()` wraps the board; click-to-paint stamps `(editor.palette.glyph, editor.palette.selected)`; calls `editor::show_editor_panel` and `glyph_picker::show`
2026-05-19 00:07:04 -05:00
**`src/render.rs`** — cell rendering and drawing primitives:
- Window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`); no fixed `CELL_W/H` — cell size comes from the active `BitmapFont`
2026-06-03 17:45:43 -05:00
- `rgba8_to_color32(c)` / `color32_to_rgba8(c)` — inverse bridges between core `color::Rgba8` and egui `Color32`
2026-05-19 00:07:04 -05:00
- `paint_glyph(painter, rect, glyph, font)``rect_filled` with `glyph.bg`, then `painter.image` tinted by `glyph.fg`
2026-06-03 17:45:43 -05:00
- `glyph_preview_button(ui, glyph, font) -> Response` — allocates a one-cell swatch (unzoomed), paints `glyph`, returns the click response; used by the Palette and Objects tabs
- `draw_glyph(painter, origin, x, y, glyph, font, zoom)` — sizes cell via `font.cell_size(zoom)`, delegates to `paint_glyph`
- `draw_board(painter, origin, board, font, zoom)` — draws all cells then player overlay
- `draw_object_overlays(painter, origin, board, font, selected, zoom)` — highlights all object cells in the Objects editor tab; the selected object gets a brighter border
- `board_origin(available, board_w, board_h, player, font, zoom)` — centers board or clamps to player with no empty space
2026-05-19 00:07:04 -05:00
- `pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32)` — pixel → cell coordinates; negatives signal out-of-bounds
**`src/editor.rs`** — editor state and side panel:
2026-06-03 17:45:43 -05:00
- `EditorTab` enum (`Palette` | `Objects` | `Board` | `Scripts` | `World`) — which tab is active
- `EditorState``tab: EditorTab` plus four concern-grouped sub-structs so each tab takes only the borrow it needs:
- `palette: PaletteState``selected: Archetype`, `glyph: Glyph`, `picker_open: bool` (selecting a new archetype resets `glyph` to its default)
- `font_dialog: FontDialog``open: bool`, `state: FontDialogState`
- `objects: ObjectEdit``selected: Option<usize>`, `picker_open: bool`, `editing_glyph: Glyph`, `placing: bool`
- `scripts: ScriptEdit``editing: Option<String>`, `content: String`, `new_name: String`
- `show_editor_panel(ctx, editor, board, active_font)` — resizable right-side panel (default 200 px); renders the tab bar then dispatches to a per-tab function (`palette_tab`, `objects_tab`, `board_tab`, `scripts_tab`; World is empty), passing the relevant sub-struct(s). Palette shows the archetype list and glyph preview button; Board shows the font path, "Font…" button, and zoom slider; Objects lists the selected object's glyph preview, **Passable/Opaque checkboxes**, and script combobox; Scripts creates/edits named scripts
**`src/glyph_picker.rs`** — floating glyph picker dialog:
2026-05-19 00:07:04 -05:00
- `show(ctx, open, glyph, board_cells, font)` — takes `open: &mut bool` and `glyph: &mut Glyph` directly; no dependency on `EditorState`
- Three sections: board palette (unique glyphs deduped via `HashSet<Glyph>`, click to select), FG/BG color pickers, scrollable tile grid (all tiles in the active font at `tile_w × tile_h` per cell)
2026-05-16 01:51:57 -05:00
### Map file format (`maps/*.toml`)
XPM-inspired: a `[palette]` maps single characters to `(Glyph, Archetype)` definitions; `[grid] content` is a TOML multi-line string where each character indexes the palette.
2026-05-16 01:51:57 -05:00
2026-05-19 00:07:04 -05:00
**Keep this example in sync with `map_file.rs` whenever the format changes.**
2026-05-16 01:51:57 -05:00
```toml
[map]
name = "Room Name"
width = 60
height = 25
player_start = [30, 12]
2026-05-19 00:07:04 -05:00
# Optional: override the default font for this board.
[font]
path = "assets/my_font.png"
tile_w = 8
tile_h = 16
2026-05-16 01:51:57 -05:00
[palette]
2026-05-19 00:07:04 -05:00
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
2026-05-16 01:51:57 -05:00
[grid]
content = """
############################################################
# #
############################################################
"""
[[objects]] # optional; parsed but not yet runtime-wired
x = 10
y = 5
script = """
on_touch(|| { send_message("open"); });
"""
[[portals]] # optional; parsed but not yet runtime-wired
x = 59
y = 12
target_map = "cave"
target_entry = "west_door"
```
2026-05-19 00:07:04 -05:00
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.
2026-05-16 01:51:57 -05:00
### 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 (`passable`, `opaque`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site.
- **`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.
2026-05-16 01:51:57 -05:00
- **`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.
2026-05-16 01:51:57 -05:00
- **File loading happens in `main()`** before the window is created, so board dimensions are available for window sizing.
2026-05-15 23:42:14 -05:00
eframe runs on its own event loop thread; do not assume single-threaded execution.