Different content grids

This commit is contained in:
2026-06-16 00:01:02 -05:00
parent 4929734212
commit 50d56aa428
5 changed files with 251 additions and 82 deletions
+14 -61
View File
@@ -41,7 +41,6 @@ cargo fmt # format
- **`kiln-core`** — the engine: all core game types (`Board`, `Glyph`, `Archetype`, `GameState`, …) plus `.toml` map-file load/save. No rendering or UI; every front-end depends on it. - **`kiln-core`** — the engine: all core game types (`Board`, `Glyph`, `Archetype`, `GameState`, …) plus `.toml` map-file load/save. No rendering or UI; every front-end depends on it.
- **`kiln-tui`** — a terminal **player** (no editor yet) built on **ratatui 0.30 / crossterm**. Takes a map-file path on the command line and lets you walk the player around the board with the arrow keys. - **`kiln-tui`** — a terminal **player** (no editor yet) built on **ratatui 0.30 / crossterm**. Takes a map-file path on the command line and lets you walk the player around the board with the arrow keys.
- **`kiln-egui`** — the original eframe/egui desktop app (player + editor). **Still in the tree but no longer a workspace member**: it was removed from the root `Cargo.toml` `members` and its files were left untouched, so it is not built at the root and is not guaranteed to compile against the current engine. Its notes below are retained for when it is revived.
Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`. Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
@@ -63,15 +62,12 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `Player { x: i32, y: i32 }` — current player position. - `Player { x: i32, y: i32 }` — current player position.
- `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired. - `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)`): **`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
- `ObjectDef` — a scripted tile: `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option<String>`, `tags: HashSet<String>`, `name: Option<String>`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black. - `ObjectDef` — a scripted tile: `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option<String>`, `tags: HashSet<String>`, `name: Option<String>`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
**`kiln-core/src/layer.rs`** — palette layers (the map-file/draw-stack unit): **`kiln-core/src/layer.rs`** — palette layers (the map-file/draw-stack unit):
- `Layer { cells: Vec<(Glyph, Archetype)> }` (`pub(crate)`) — one row-major draw layer. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the layer beneath shows through; solidity comes from the archetype, independent of transparency. - `Layer { cells: Vec<(Glyph, Archetype)> }` (`pub(crate)`) — one row-major draw layer. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the layer beneath shows through; solidity comes from the archetype, independent of transparency.
- `LayerData { content, palette: HashMap<String, PaletteEntry> }` — serde for one `[[layers]]` entry. `PaletteEntry` is a single flat struct with a `kind: String` discriminator plus all-optional fields (`tile`, `fg`, `bg`, `generator`, `solid`, `opaque`, `pushable`, `script_name`, `tags`, `name`, `target_map`, `target_entry`); only the fields relevant to the kind are read. (One flat struct, not an enum, because `kind` is open-ended — any archetype name *or* a meta-kind.) - `LayerData { content, fill, sparse, palette: HashMap<String, PaletteEntry> }` — serde for one `[[layers]]` entry. The grid comes from exactly one of three optional fields (precedence `content``fill``sparse`; none ⇒ all spaces): `content` (a multi-line grid string), `fill` (one char filling the whole grid — handy for a uniform floor layer), or `sparse` (a `Vec<SparseCell>` of `{ x, y, ch }` over an otherwise all-spaces grid — handy for a layer of just a few objects). Only `content` can mismatch the board dims (hard error); `fill`/`sparse` are always exactly sized (a bad `fill`/`ch` or out-of-bounds `sparse` cell is a nonfatal error). `PaletteEntry` is a single flat struct with a `kind: String` discriminator plus all-optional fields (`tile`, `fg`, `bg`, `generator`, `solid`, `opaque`, `pushable`, `script_name`, `tags`, `name`, `target_map`, `target_entry`); only the fields relevant to the kind are read. (One flat struct, not an enum, because `kind` is open-ended — any archetype name *or* a meta-kind.)
- `build_layer(data, w, h, &mut StdRand, &mut Vec<LogLine>) -> Result<(Layer, Vec<Placement>), String>` — resolves the palette (via `resolve_entry`), validates the grid dims (the only hard `Err`), and walks the grid building the `Layer` plus a `Vec<Placement>` (`Object(ObjectTemplate, x, y)` / `Portal(PortalTemplate, x, y)` / `Player(x, y)`) for the map loader to resolve across layers. Procedural floors roll a fresh glyph per cell from the shared seeded `StdRand`. - `build_layer(data, w, h, &mut StdRand, &mut Vec<LogLine>) -> Result<(Layer, Vec<Placement>), String>` — resolves the palette (via `resolve_entry`), validates the grid dims (the only hard `Err`), and walks the grid building the `Layer` plus a `Vec<Placement>` (`Object(ObjectTemplate, x, y)` / `Portal(PortalTemplate, x, y)` / `Player(x, y)`) for the map loader to resolve across layers. Procedural floors roll a fresh glyph per cell from the shared seeded `StdRand`.
- `resolve_entry` maps `kind``Resolved`: `empty` → transparent cell; `floor` → a generator (per-cell roll) or a fixed visual-only `Empty` glyph; `object`/`portal`/`player` → a placement; any other string → `Archetype::try_from` (`Ok` → terrain cell; `Err` → visible `ErrorBlock` + logged error). A `portal` missing `name`/`target_map`/`target_entry` is dropped to a transparent cell with an error. - `resolve_entry` maps `kind``Resolved`: `empty` → transparent cell; `floor` → a generator (per-cell roll) or a fixed visual-only `Empty` glyph; `object`/`portal`/`player` → a placement; any other string → `Archetype::try_from` (`Ok` → terrain cell; `Err` → visible `ErrorBlock` + logged error). A `portal` missing `name`/`target_map`/`target_entry` is dropped to a transparent cell with an error.
@@ -184,65 +180,24 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint
- `summary_line() -> Line` — styled one-line badge for the bottom border; when `truecolor`, the word "truecolor" is drawn with each letter a different rainbow color (`hue_to_rgb`, an HSV→RGB helper), else a plain "256-color". - `summary_line() -> Line` — styled one-line badge for the bottom border; when `truecolor`, the word "truecolor" is drawn with each letter a different rainbow color (`hue_to_rgb`, an HSV→RGB helper), else a plain "256-color".
- `push_kitty_flags()` / `pop_kitty_flags()` — enable/disable the Kitty keyboard protocol (`DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS | REPORT_ALL_KEYS_AS_ESCAPE_CODES`); the last flag makes modifier-only keypresses observable. Pushed only when supported, popped before restore. Enabling `REPORT_EVENT_TYPES` is why the input loop must filter out `Release` events. - `push_kitty_flags()` / `pop_kitty_flags()` — enable/disable the Kitty keyboard protocol (`DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS | REPORT_ALL_KEYS_AS_ESCAPE_CODES`); the last flag makes modifier-only keypresses observable. Pushed only when supported, popped before restore. Enabling `REPORT_EVENT_TYPES` is why the input loop must filter out `Release` events.
### kiln-egui modules (no longer a workspace member)
**`kiln-egui/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`.
- `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
- `BitmapFont::tile_cols()`, `tile_count()`, `img_w()`, `img_h()` — geometry helpers used by the glyph picker and font dialog
**`kiln-egui/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.
**`kiln-egui/src/main.rs`** — app entry point and frame loop:
- `AppMode` enum (`Play` | `Edit`) — gates arrow-key input; toggles the side panel and viewport mode
- `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
- `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
- Font change detection: `font_spec_before` is snapshotted before `show_editor_panel`; if changed, `apply_font_spec` reloads `board_font`
- 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
- **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`
**`kiln-egui/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`
- `rgba8_to_color32(c)` / `color32_to_rgba8(c)` — inverse bridges between core `color::Rgba8` and egui `Color32`
- `paint_glyph(painter, rect, glyph, font)``rect_filled` with `glyph.bg`, then `painter.image` tinted by `glyph.fg`
- `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
- `pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32)` — pixel → cell coordinates; negatives signal out-of-bounds
**`kiln-egui/src/editor.rs`** — editor state and side panel:
- `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
**`kiln-egui/src/glyph_picker.rs`** — floating glyph picker dialog:
- `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)
### World file format (`maps/*.toml`) ### World file format (`maps/*.toml`)
Each `.toml` file is a **world**: a named collection of boards plus a shared script pool. The `[world]` header names the world and designates the starting board. Scripts live once at the top level and are referenced by name from any board's objects. Each board lives under `[boards.NAME.*]` where `NAME` is the board's identifier string. Each `.toml` file is a **world**: a named collection of boards plus a shared script pool. The `[world]` header names the world and designates the starting board. Scripts live once at the top level and are referenced by name from any board's objects. Each board lives under `[boards.NAME.*]` where `NAME` is the board's identifier string.
**Keep this example in sync with `world.rs` / `map_file.rs` whenever the format changes.** **Keep this example in sync with `world.rs` / `map_file.rs` whenever the format changes.**
A board is a `[map]` header plus an **ordered array of `[[layers]]`** (bottom→top). Each layer is a `content` grid and a `palette` mapping each char to one *kind* of thing. To put more than one thing on a cell (e.g. a non-solid object above a wall), put them on different layers — drawing follows layer order. A board is a `[map]` header plus an **ordered array of `[[layers]]`** (bottom→top). Each layer has a `palette` mapping each char to one *kind* of thing, and a grid given in one of three ways: `content` (a multi-line grid string), `fill = "x"` (the whole grid filled with one char — e.g. a uniform floor), or `sparse = [{ x, y, ch }, …]` (an all-spaces grid with just those cells set — e.g. a layer of a few objects). To put more than one thing on a cell (e.g. a non-solid object above a wall), put them on different layers — drawing follows layer order.
```toml
# Two terser layer forms, equivalent to a full `content` grid:
[[boards.room1.layers]]
fill = "g" # whole layer is grass floor
palette = { "g" = { kind = "floor", generator = "grass" } }
[[boards.room1.layers]]
sparse = [ { x = 5, y = 3, ch = "C" }, { x = 9, y = 7, ch = "C" } ] # two chests
palette = { " " = { kind = "empty" }, "C" = { kind = "object", script_name = "chest" } }
```
```toml ```toml
[world] [world]
@@ -308,7 +263,7 @@ content = """
Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph. Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph.
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error). An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, and `scroll(lines)`. Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`. Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, and `scroll(lines)`. Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`.
**Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(Me.id, "visited", true)`). **Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(Me.id, "visited", true)`).
@@ -323,8 +278,6 @@ Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"`
- **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. - **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.
- **World loading happens in `main()`** before the terminal is initialized, so load errors print to a normal screen and board dimensions are known before the game loop starts. - **World loading happens in `main()`** before the terminal is initialized, so load errors print to a normal screen and board dimensions are known before the game loop starts.
eframe runs on its own event loop thread; do not assume single-threaded execution.
### Future direction: the player may become an object ### 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. 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.
+117 -20
View File
@@ -34,16 +34,42 @@ pub(crate) struct Layer {
pub(crate) cells: Vec<(Glyph, Archetype)>, pub(crate) cells: Vec<(Glyph, Archetype)>,
} }
/// Serde representation of one `[[layers]]` entry: a grid string and its palette. /// Serde representation of one `[[layers]]` entry: a grid plus its palette.
///
/// The grid is given in exactly one of three ways (precedence: `content`, then
/// `fill`, then `sparse`; none of them ⇒ an all-spaces grid):
/// - `content` — a multi-line grid string, one char per cell (`width × height`).
/// - `fill` — a single character; the whole grid is filled with it (handy for a
/// layer of identical floor).
/// - `sparse` — a list of `{ x, y, ch }` cells over an otherwise all-spaces grid
/// (handy for a layer holding just a few objects).
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub(crate) struct LayerData { pub(crate) struct LayerData {
/// Multi-line grid string; one char per cell, looked up in `palette`. /// Multi-line grid string; one char per cell, looked up in `palette`.
pub content: String, #[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Single character to fill the whole `width × height` grid with.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill: Option<String>,
/// Individual cells over an otherwise all-spaces grid.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sparse: Option<Vec<SparseCell>>,
/// Char (as a one-character string key) → palette entry. /// Char (as a one-character string key) → palette entry.
#[serde(default)] #[serde(default)]
pub palette: HashMap<String, PaletteEntry>, pub palette: HashMap<String, PaletteEntry>,
} }
/// One cell in a [`LayerData::sparse`] list: a single character at `(x, y)`.
#[derive(Deserialize, Serialize, Clone)]
pub(crate) struct SparseCell {
/// Column (0-indexed).
pub x: usize,
/// Row (0-indexed).
pub y: usize,
/// The grid character at this cell (a one-character string).
pub ch: String,
}
/// One palette entry, discriminated by [`kind`](PaletteEntry::kind). /// One palette entry, discriminated by [`kind`](PaletteEntry::kind).
/// ///
/// A single flat struct (rather than an enum) because `kind` is open-ended: it is /// A single flat struct (rather than an enum) because `kind` is open-ended: it is
@@ -215,6 +241,91 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
} }
} }
/// Resolves a layer's grid to a `height × width` matrix of chars from whichever of
/// `content` / `fill` / `sparse` is supplied (in that precedence; none ⇒ all spaces).
///
/// Only an explicit `content` can mismatch the board dimensions — the single hard
/// error. `fill`/`sparse` always produce an exactly-sized grid; a non-single-char
/// `fill`/`ch` or an out-of-bounds `sparse` cell is recorded on `errors` and the
/// offending cell falls back to (or stays) a space.
fn grid_chars(
data: &LayerData,
width: usize,
height: usize,
errors: &mut Vec<LogLine>,
) -> Result<Vec<Vec<char>>, String> {
// Reads a one-character string field, recording `context` and returning `None`
// when it is empty or longer than one char.
let single_char = |s: &str, context: String, errors: &mut Vec<LogLine>| {
let mut chs = s.chars();
match (chs.next(), chs.next()) {
(Some(c), None) => Some(c),
_ => {
errors.push(LogLine::error(context));
None
}
}
};
if let Some(content) = &data.content {
// Explicit grid: validate it matches the declared dimensions.
let rows: Vec<&str> = content.lines().collect();
if rows.len() != height {
return Err(format!(
"layer grid has {} rows but the board is {height} tall",
rows.len()
));
}
let mut grid = Vec::with_capacity(height);
for (i, line) in rows.iter().enumerate() {
let row: Vec<char> = line.chars().collect();
if row.len() != width {
return Err(format!(
"layer grid row {i} has {} characters but the board is {width} wide",
row.len()
));
}
grid.push(row);
}
return Ok(grid);
}
if let Some(fill) = &data.fill {
// A whole grid of one character.
let ch = single_char(
fill,
format!("layer fill must be a single character (got {fill:?}); using a space"),
errors,
)
.unwrap_or(' ');
return Ok(vec![vec![ch; width]; height]);
}
// `sparse` (or nothing): an all-spaces grid with the listed cells stamped in.
let mut grid = vec![vec![' '; width]; height];
for cell in data.sparse.iter().flatten() {
let Some(ch) = single_char(
&cell.ch,
format!(
"sparse cell ch must be a single character (got {:?}); skipping",
cell.ch
),
errors,
) else {
continue;
};
if cell.x >= width || cell.y >= height {
errors.push(LogLine::error(format!(
"sparse cell ({}, {}) is out of bounds; skipping",
cell.x, cell.y
)));
continue;
}
grid[cell.y][cell.x] = ch;
}
Ok(grid)
}
/// Builds one [`Layer`] from its [`LayerData`], plus the non-terrain placements it /// Builds one [`Layer`] from its [`LayerData`], plus the non-terrain placements it
/// contains (with their `(x, y)`). /// contains (with their `(x, y)`).
/// ///
@@ -239,28 +350,14 @@ pub(crate) fn build_layer(
}) })
.collect(); .collect();
// Validate dimensions before walking — the only fatal error. // Resolve the grid (content / fill / sparse) before walking it.
let rows: Vec<&str> = data.content.lines().collect(); let grid = grid_chars(data, width, height, errors)?;
if rows.len() != height {
return Err(format!(
"layer grid has {} rows but the board is {height} tall",
rows.len()
));
}
for (i, line) in rows.iter().enumerate() {
let cols = line.chars().count();
if cols != width {
return Err(format!(
"layer grid row {i} has {cols} characters but the board is {width} wide"
));
}
}
// Walk the grid, filling cells and collecting placements. // Walk the grid, filling cells and collecting placements.
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height); let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height);
let mut placements: Vec<Placement> = Vec::new(); let mut placements: Vec<Placement> = Vec::new();
for (y, line) in rows.iter().enumerate() { for (y, row) in grid.iter().enumerate() {
for (x, ch) in line.chars().enumerate() { for (x, &ch) in row.iter().enumerate() {
match resolved.get(&ch) { match resolved.get(&ch) {
Some(Resolved::Cell(g, a)) => cells.push((*g, *a)), Some(Resolved::Cell(g, a)) => cells.push((*g, *a)),
Some(Resolved::FloorGen(g)) => cells.push((g.generate(rng), Archetype::Empty)), Some(Resolved::FloorGen(g)) => cells.push((g.generate(rng), Archetype::Empty)),
+7 -1
View File
@@ -407,7 +407,13 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n")
+ "\n"; + "\n";
LayerData { content, palette } // Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences.
LayerData {
content: Some(content),
fill: None,
sparse: None,
palette,
}
} }
/// Converts a runtime [`Board`] back into a serializable [`MapFile`]. /// Converts a runtime [`Board`] back into a serializable [`MapFile`].
+112
View File
@@ -0,0 +1,112 @@
use super::load_board;
use crate::archetype::Archetype;
use crate::glyph::Glyph;
use crate::map_file::parse_color;
#[test]
fn fill_builds_a_full_grid_of_one_char() {
// Layer 0 is a solid fill of a fixed floor glyph; a later sparse layer places
// the player. Every cell of layer 0 must be that floor glyph.
let toml = r##"
[map]
name = "Test"
width = 3
height = 2
[[layers]]
fill = "f"
palette = { "f" = { kind = "floor", tile = ".", fg = "#112233", bg = "#445566" } }
[[layers]]
sparse = [ { x = 0, y = 0, ch = "@" } ]
palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
"##;
let board = load_board(toml);
assert!(board.is_valid());
let floor = Glyph {
tile: '.' as u32,
fg: parse_color("#112233"),
bg: parse_color("#445566"),
};
for y in 0..2 {
for x in 0..3 {
assert_eq!(*board.get(0, x, y), (floor, Archetype::Empty));
}
}
assert_eq!((board.player.x, board.player.y), (0, 0));
}
#[test]
fn sparse_places_only_listed_cells() {
// A grass floor underneath; a sparse layer holding just the player and one object.
let toml = r##"
[map]
name = "Test"
width = 4
height = 1
[[layers]]
fill = "g"
palette = { "g" = { kind = "floor", generator = "grass" } }
[[layers]]
sparse = [ { x = 0, y = 0, ch = "@" }, { x = 2, y = 0, ch = "O" } ]
palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } }
"##;
let board = load_board(toml);
assert!(board.is_valid());
assert_eq!((board.player.x, board.player.y), (0, 0));
assert_eq!(board.objects.len(), 1);
assert_eq!((board.objects[&1].x, board.objects[&1].y), (2, 0));
// An unlisted sparse cell is a transparent empty.
assert_eq!(board.get(1, 1, 0).1, Archetype::Empty);
}
#[test]
fn sparse_out_of_bounds_cell_is_nonfatal() {
let toml = r##"
[map]
name = "Test"
width = 2
height = 1
[[layers]]
sparse = [ { x = 5, y = 0, ch = "O" }, { x = 0, y = 0, ch = "@" } ]
palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } }
"##;
let board = load_board(toml);
assert!(
board.objects.is_empty(),
"out-of-bounds object cell is skipped"
);
assert!(
!board.is_valid(),
"out-of-bounds sparse cell is a nonfatal error"
);
assert_eq!((board.player.x, board.player.y), (0, 0));
}
#[test]
fn fill_must_be_a_single_char() {
// A multi-char fill falls back to a space (nonfatal); spaces resolve to empty.
let toml = r##"
[map]
name = "Test"
width = 2
height = 1
[[layers]]
fill = "xy"
palette = { " " = { kind = "empty" } }
[[layers]]
sparse = [ { x = 0, y = 0, ch = "@" } ]
palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
"##;
let board = load_board(toml);
assert!(
!board.is_valid(),
"a non-single-char fill is a nonfatal error"
);
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
}
+1
View File
@@ -1,3 +1,4 @@
mod fill_sparse;
mod grid_errors; mod grid_errors;
mod object_placement; mod object_placement;
mod player_placement; mod player_placement;