refactoring

This commit is contained in:
2026-06-07 00:19:53 -05:00
parent ea18fe8fc2
commit ea79dc20f9
13 changed files with 805 additions and 749 deletions
+38 -14
View File
@@ -47,18 +47,42 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
### kiln-core modules
**`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: 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`: `Player` (the player; pushable any direction), `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. The player is checked first, so its cell reports `Solid::Player` (and is impassable to movers).
- `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), `floor: Vec<Glyph>` + `floor_spec: Option<FloorSpec>` (the visual floor layer — see `floor.rs`), `player: Player`, `objects: BTreeMap<ObjectId, ObjectDef>` (keyed by stable id; iterates in ascending-id = load order), `next_object_id: ObjectId` (counter starting at 1), `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells`, `floor`, and `floor_spec` are `pub(crate)`. `ObjectId` is a `pub type ObjectId = u32` (in `game.rs`). `add_object(obj) -> ObjectId` allocates the next id, inserts, and returns it; `object_id_at(x, y) -> Option<ObjectId>` and `object_at(x, y) -> Option<&ObjectDef>` look up by position. `display_glyph(x, y)` returns the static-layer glyph for a cell — the grid archetype's glyph, or the `floor[idx]` glyph when the cell is `Empty` (front-ends call this for the non-object/non-player layer; an `Empty` cell's *own* glyph is ignored, so a cell vacated by a pushed crate reveals the floor). `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)`/`bump(id)` 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(dir: Direction)` moves the player (pushing a crate/solid out of the way if possible) and fires `bump(-1)` on a solid object it walks into; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` advances each object's cooldown then runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue (`take_board_queue()`) and applies each `Action`: `Log``log`, `SetTile` → the source object's glyph, `Move(dir)` → the free fn `step_object` (gates on `in_bounds` then `is_passable || can_push`, pushing before it relocates, and returns the `ObjectId` of any solid object it bumped). Resolution is two-phase — mutate the board collecting `(bumped, bumper)` pairs, then drop the board borrow and fire `run_bump` for each (a `bump` script reads `Board.*`, so no `board_mut` borrow may be held while it runs). A bumped object's own emitted actions wait for the next tick's pump (no same-tick cascade). `drain_errors()` moves the script error sink into `log`. This deferred apply (writes after the batch) is what keeps script execution borrow-safe.
The core types that were formerly monolithic in `game.rs` are now split into focused modules. `game.rs` contains only `GameState`; the data types live in their own files.
**`kiln-core/src/glyph.rs`** — per-cell visual:
- `Glyph` (`Copy`, `Eq`, `Hash`) — `tile: u32` (tilesheet index), `fg/bg: Rgba8`. `Glyph::player()` is a `const fn` (tile 64 `@`, white on dark blue); all other glyphs come from map files. `Hash` is hand-implemented via packed `u32` representations so it stays in sync with `Eq`.
**`kiln-core/src/archetype.rs`** — element taxonomy:
- `Archetype` (`Copy`, `PartialEq`, `Hash`)enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
**`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`):
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`.
- `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`.
- `ObjectId = u32` — stable identifier for board objects.
- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Cell(Archetype)`, or `Object(ObjectId)`.
- `Player { x: i32, y: i32 }` — current player position.
- `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired.
**`kiln-core/src/font.rs`** — font override (`pub(crate)`):
- `FontSpec { path: String, tile_w: u32, tile_h: u32 }` — optional per-board bitmap font. When `None`, the app default is used.
**`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
- `ObjectDef` — a scripted tile: `x`, `y`, `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option<String>`. `ObjectDef::new(x, y)` constructs with defaults. `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
**`kiln-core/src/board.rs`** — the board data type:
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; `pub(crate)`), `floor: Vec<Glyph>` + `floor_spec: Option<FloorSpec>` (visual floor layer; `pub(crate)`), `player: Player`, `objects: BTreeMap<ObjectId, ObjectDef>`, `next_object_id: ObjectId`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`, `zoom: u32` (integer tile scale factor), `scripts: HashMap<String, String>` (named Rhai source text; script name → source), `board_script_name: Option<String>` (name of a board-level script, if any), `load_errors: Vec<LogLine>` (`pub(crate)`).
- `get(x, y) -> &(Glyph, Archetype)` / `get_mut` — direct cell access (panics OOB).
- `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`. Priority: player > solid object > non-solid object with nonzero tile > non-Empty grid archetype > floor. **Includes the player**`glyph_at` at the player's cell returns `Glyph::player()`. An `Empty` cell's own glyph is always ignored (floor supersedes it). Front-ends call this per-cell instead of managing a separate player overlay.
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then grid archetype).
- `is_passable(x, y)` — convenience inverse of `solid_at`.
- `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving `Empty` behind.
- `add_object(obj) -> ObjectId`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object queries.
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
**`kiln-core/src/game.rs`** — game-loop logic only:
- `GameState` — holds `board: Rc<RefCell<Board>>`, `log: Vec<LogLine>`, and `scripts: ScriptHost`. Front-ends reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into. `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns and runs `tick(dt)` hooks every frame. Both end by calling `apply_commands()`, which drains the board queue and applies each `Action` (`Log``log`, `SetTile` → source glyph, `Move(dir)``step_object`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`.
**`kiln-core/src/floor.rs`** — the visual floor layer:
- The floor is a cosmetic per-cell background drawn wherever a cell would render as `Archetype::Empty` (it is *how* `Empty` is drawn; the cell's own `Empty` glyph is ignored). Computed once at load and cached on `Board::floor`, so there is no per-frame cost / flicker; the raw `FloorSpec` is also kept (`Board::floor_spec`) so `map_file::save` round-trips it.
@@ -105,7 +129,7 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint
**`kiln-tui/src/render.rs`** — board → ratatui buffer:
- `rgba8_to_color(Rgba8) -> Color::Rgb` — lossless RGB bridge (alpha dropped); truecolor terminals show exact colors, 256-color ones approximate.
- `BoardWidget<'a>` — a `ratatui::widgets::Widget` that draws the board. Per axis, `axis(board_len, view, player) -> (offset, pad, count)`: a board smaller than the viewport is **centered** with screen padding, a larger one **scrolls** to keep the player on screen with no empty margins. Each cell resolves glyph priority player > object > grid floor, then writes char + fg/bg into the buffer.
- `BoardWidget<'a>` — a `ratatui::widgets::Widget` that draws the board. Per axis, `axis(board_len, view, player) -> (offset, pad, count)`: a board smaller than the viewport is **centered** with screen padding, a larger one **scrolls** to keep the player on screen with no empty margins. Each cell calls `Board::glyph_at`, which already folds in the player glyph at the player's position — no separate player-overlay pass needed.
**`kiln-tui/src/term.rs`** — terminal capability detection and Kitty setup:
- `TerminalCaps { keyboard_enhancement: bool, truecolor: bool }` — detected once at startup. `keyboard_enhancement` is a real query/response (`supports_keyboard_enhancement()`); `truecolor` reads `$COLORTERM`. Intended to be handed to scripts so they can pick bindings the terminal actually supports.
@@ -262,7 +286,7 @@ Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coord
### 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.
- **The floor layer is how `Empty` is drawn** — `Board::display_glyph` returns the per-cell `floor` glyph for any `Empty` cell, so an `Empty` cell's *own* glyph is ignored (and a cell vacated by a pushed crate automatically shows the floor with no movement-code changes). The floor is computed once at load and cached (`Board::floor`); the raw `FloorSpec` is kept (`Board::floor_spec`) only so saves round-trip. One consequence: a map that colored an `Empty` cell's bg no longer shows it — empties always come from the floor layer (default black-on-black). See `floor.rs`.
- **The floor layer is how `Empty` is drawn** — `Board::glyph_at` returns the per-cell `floor` glyph for any `Empty` cell, so an `Empty` cell's *own* glyph is ignored (and a cell vacated by a pushed crate automatically shows the floor with no movement-code changes). The floor is computed once at load and cached (`Board::floor`); the raw `FloorSpec` is kept (`Board::floor_spec`) only so saves round-trip. One consequence: a map that colored an `Empty` cell's bg no longer shows it — empties always come from the floor layer (default black-on-black). See `floor.rs`.
- **One solid per cell** — at most one solid entity (the player, a solid grid archetype, *or* a solid object) may occupy a cell. The player is a first-class solid: `Board::solid_at` reports `Solid::Player` for the player's cell (checked first), it blocks movers, and it is **pushable in any direction** — a push chain that reaches the player slides the player along (and is rejected if the player has nowhere to go, e.g. against a wall). The map loader enforces the invariant: a solid object landing on an already-solid cell is dropped (recorded via `report_error`); the player *wins* its cell — its resolved position (including the `(0, 0)` fallback when `player_start` is unresolvable) silently clears any solid terrain under it and drops 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. 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.