Add bitmap font rendering via PNG tilesheet

Replace egui text rendering with a BitmapFont system:
- Glyph.tile: u32 replaces ch: char; top-left pixel of PNG is background sentinel
- BitmapFont loads/preprocesses PNG to opaque-white + transparent, tinted at render time
- Default font: assets/vga-font-8x16.png (CP437, 8×16 tiles); placeholder generated if missing
- Boards can specify a per-board font via [font] in their .toml file
- Editor Board tab: "Font…" button opens font dialog (rfd file picker, tile dims, tilesheet preview)
- Glyph picker: tile grid from active font replaces hardcoded ASCII char grid
- map_file: TileIndex untagged enum accepts char or integer in palette entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 00:07:04 -05:00
parent 12ebe77932
commit 212297ecf9
14 changed files with 1174 additions and 260 deletions
+80 -19
View File
@@ -27,19 +27,26 @@ src/
main.rs — App, AppMode, frame loop (input → menu → panels → board → dialogs) main.rs — App, AppMode, frame loop (input → menu → panels → board → dialogs)
game.rs — core data types and game logic game.rs — core data types and game logic
map_file.rs — TOML deserialization, Board loader map_file.rs — TOML deserialization, Board loader
render.rs — cell-size constants, draw_glyph/draw_board, board_origin, pos_to_cell render.rs — drawing primitives; cell size comes from BitmapFont
font.rs — BitmapFont, tile UV mapping, placeholder font
font_dialog.rs — floating font-picker dialog (path, tile dims, preview, apply)
editor.rs — EditorTab, EditorState, show_editor_panel editor.rs — EditorTab, EditorState, show_editor_panel
glyph_picker.rs — floating Glyph picker dialog; decoupled (open, glyph) interface glyph_picker.rs — floating Glyph picker dialog; decoupled (open, glyph, font) interface
maps/ maps/
start.toml — the starting map (loaded at launch) start.toml — the starting map (loaded at launch)
assets/
vga-font-8x16.png — default CP437 bitmap font (256×128 px, 32 cols × 8 rows, 8×16 tiles)
``` ```
### `game.rs` — core types ### `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. The types here are the runtime representation of a game world. They are deliberately free of any file-loading or rendering concerns.
**`Glyph`** (`Copy`) **`Glyph`** (`Copy`, `Eq`, `Hash`)
The visual representation of one cell: a character, 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. 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`** **`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. 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.
@@ -64,6 +71,7 @@ The complete unit of a game world — one "room" or "screen" in ZZT terminology.
- `player: Player` — current player position on this board - `player: Player` — current player position on this board
- `objects: Vec<ObjectDef>` — scripted objects (parsed, not yet runtime-wired) - `objects: Vec<ObjectDef>` — scripted objects (parsed, not yet runtime-wired)
- `portals: Vec<PortalDef>` — exits to other boards (parsed, not yet runtime-wired) - `portals: Vec<PortalDef>` — exits to other boards (parsed, not yet runtime-wired)
- `font: Option<FontSpec>` — per-board font override; `None` means use the app default
**Why `cells` is `Vec<(Glyph, Archetype)>` with no palette:** **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. 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.
@@ -76,10 +84,44 @@ Currently a thin wrapper around `Board` that provides game-logic methods (`try_m
--- ---
### `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 ### `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. **`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. **`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. **`pub fn load(path: &str) -> Result<Board, ...>`** — reads a file, deserializes, converts. Called from `main()` before the window is created.
@@ -91,7 +133,11 @@ eframe requires `NativeOptions` (including window size) to be set before calling
### `main.rs` — app + frame loop ### `main.rs` — app + frame loop
`App` holds a `GameState`, an `AppMode` (`Play` | `Edit`), and an `EditorState`. The `update` method phases: `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`. The active font for the frame is `board_font.as_ref().unwrap_or(&default_font)`, extracted as a `let` binding before mutable borrows to satisfy the borrow checker.
The `update` method phases:
1. Reads arrow key input and calls `GameState::try_move`**Play mode only** 1. Reads arrow key input and calls `GameState::try_move`**Play mode only**
2. Draws the menu bar with a File menu and a Play/Edit mode toggle 2. Draws the menu bar with a File menu and a Play/Edit mode toggle
@@ -109,19 +155,21 @@ The board is wrapped in `egui::ScrollArea::new([true, true])`, which shows scrol
--- ---
### `render.rs` — constants and drawing primitives ### `render.rs` — drawing primitives
All pixel-level rendering knowledge lives here. No dependency on `EditorState` or `App`. 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:** `CELL_W = 14.0`, `CELL_H = 20.0` pixels per cell. Window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`) are defined here so they stay co-located with the cell measurements they derive from. **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.
**`draw_glyph(painter, origin, x, y, glyph)`** — renders one cell as a filled background rect plus a centered monospace character. **`paint_glyph(painter, rect, glyph, font)`** — fills `rect` with `glyph.bg`, then draws the tile image tinted by `glyph.fg`. Used by both `draw_glyph` and `glyph_picker`.
**`draw_board(painter, origin, board)`** — iterates all cells calling `draw_glyph`, then draws the player as an overlay on top. **`draw_glyph(painter, origin, x, y, glyph, font)`** — computes the cell rect from `font.tile_w/tile_h`, delegates to `paint_glyph`.
**`pos_to_cell(origin, pos) -> (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. **`draw_board(painter, origin, board, font)`** — iterates all cells calling `draw_glyph`, then draws the player as an overlay on top.
**`board_origin(available, board_w, board_h, player) -> Pos2`** — computes the pixel origin for Play-mode rendering (centering or player-tracking with edge clamping). **`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) -> Pos2`** — computes the pixel origin for Play-mode rendering (centering or player-tracking with edge clamping).
--- ---
@@ -134,19 +182,21 @@ All pixel-level rendering knowledge lives here. No dependency on `EditorState` o
- `glyph: Glyph` — the visual to stamp (independent of the archetype's default; resets to `archetype.default_glyph()` when the archetype selection changes) - `glyph: Glyph` — the visual to stamp (independent of the archetype's default; resets to `archetype.default_glyph()` when the archetype selection changes)
- `glyph_picker_open: bool` — whether the glyph picker dialog is visible - `glyph_picker_open: bool` — whether the glyph picker dialog is visible
- `tab: EditorTab` — which side-panel tab is active - `tab: EditorTab` — which side-panel tab is active
- `font_dialog_open: bool` — whether the font picker dialog is visible
- `font_dialog_state: FontDialogState` — in-progress edits for the font dialog
**`show_editor_panel(ctx, editor, board)`** — renders the resizable right-side panel (default 200 px). On the Palette tab: a scrollable archetype list (at most 5 rows visible) and a current-glyph preview button that sets `glyph_picker_open = true`. Board and World tabs are placeholders. **`show_editor_panel(ctx, editor, font_spec, active_font)`** — renders the resizable right-side panel (default 200 px). Palette tab: scrollable archetype list and glyph preview button. Board tab: current font path label + "Font…" button that initializes `font_dialog_state` from `font_spec` and opens the font dialog. World tab: placeholder.
--- ---
### `glyph_picker.rs` — glyph picker dialog ### `glyph_picker.rs` — glyph picker dialog
**`show(ctx, open, glyph, board_cells)`** — 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. **`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: Three sections:
1. **Board palette** — unique glyphs found on the current board, shown as clickable cells. The current glyph is highlighted with a white stroke. 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`. 2. **Colors**`color_edit_button_srgba` pickers for `glyph.fg` and `glyph.bg`.
3. **Character grid**16 × 6 grid of printable ASCII (0x200x7E). Clicking a cell updates `glyph.ch`. The current character is highlighted. 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.
--- ---
@@ -154,6 +204,8 @@ Three sections:
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. 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 ```toml
[map] [map]
name = "Room Name" name = "Room Name"
@@ -161,9 +213,15 @@ width = 60
height = 25 height = 25
player_start = [30, 12] 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] [palette]
" " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" } " " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", ch = "#", fg = "#808080", bg = "#606060" } "#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
[grid] [grid]
content = """ content = """
@@ -189,6 +247,9 @@ target_entry = "west_door"
**Why TOML over a custom format:** **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. 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:** **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. 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.
@@ -213,7 +274,7 @@ A direct mapping from palette character → `(Glyph, Archetype)` means the map f
**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. **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. This requires a native file dialog (e.g. `rfd`). WASM support for file dialogs is an open question. **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. **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.
+45 -17
View File
@@ -45,46 +45,68 @@ The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI.
### Modules ### Modules
**`src/game.rs`** — all core game types: **`src/game.rs`** — all core game types:
- `Glyph` (`Copy`) — per-cell visual: `ch: char`, `fg/bg: Color32`. `Glyph::player()` is the only constructor used at runtime (colors for board tiles come from the map file). - `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. - `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`, `Object`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red. - `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `Object`, `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`). - `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>`. `cells` is `pub(crate)`. - `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)`.
- `Player``x: i32, y: i32` - `Player``x: i32, y: i32`
- `ObjectDef` / `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired - `ObjectDef` / `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
- `GameState` — holds `board: Board`; `try_move(dx, dy)` checks passability before moving the player - `GameState` — holds `board: Board`; `try_move(dx, dy)` checks passability before moving the player
**`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::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.
**`src/map_file.rs`** — map file loading: **`src/map_file.rs`** — map file loading:
- `MapFile` and friends — serde `Deserialize` types for TOML map files - `MapFile` and friends — serde `Deserialize` types for TOML map files
- `impl From<MapFile> for Board` — converts a parsed file into a ready-to-use `Board` - `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`
- `pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>` — reads and converts a `.toml` map file - `pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>` — reads and converts a `.toml` map file
**`src/main.rs`** — app entry point and frame loop: **`src/main.rs`** — app entry point and frame loop:
- `AppMode` enum (`Play` | `Edit`) — gates arrow-key input; toggles the side panel and viewport mode - `AppMode` enum (`Play` | `Edit`) — gates arrow-key input; toggles the side panel and viewport mode
- `App` holds `GameState`, `AppMode`, and `EditorState`; `update` phases: input → menu bar → editor panel → board → glyph picker dialog - `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
- Font change detection: `font_spec_before` is snapshotted before `show_editor_panel`; if changed, `apply_font_spec` reloads `board_font`
- Borrow split: `let active_font = self.board_font.as_ref().unwrap_or(&self.default_font)` is extracted before mutable borrows of `editor` and `board.font` to satisfy the borrow checker
- **Play mode**: arrow keys move player; `render::board_origin` centers or player-tracks the viewport - **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.glyph, editor.selected)`; calls `editor::show_editor_panel` and `glyph_picker::show` - **Edit mode**: `ScrollArea::both()` wraps the board; click-to-paint stamps `(editor.glyph, editor.selected)`; calls `editor::show_editor_panel` and `glyph_picker::show`
**`src/render.rs`** — cell rendering constants and drawing primitives: **`src/render.rs`** — cell rendering and drawing primitives:
- `CELL_W = 14.0`, `CELL_H = 20.0` and window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`) - 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`
- `draw_glyph(painter, origin, x, y, glyph)` — filled rect (bg) + centered monospace char (fg) - `paint_glyph(painter, rect, glyph, font)``rect_filled` with `glyph.bg`, then `painter.image` tinted by `glyph.fg`
- `draw_board(painter, origin, board)` — draws all cells then player overlay - `draw_glyph(painter, origin, x, y, glyph, font)` — sizes cell from `font.tile_w/tile_h`, delegates to `paint_glyph`
- `board_origin(available, board_w, board_h, player)` — centers board or clamps to player with no empty space - `draw_board(painter, origin, board, font)` — draws all cells then player overlay
- `pos_to_cell(origin, pos) -> (i32, i32)` — pixel → cell coordinates via floor division; negatives signal out-of-bounds - `board_origin(available, board_w, board_h, player, font)` — 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
**`src/editor.rs`** — editor state and side panel: **`src/editor.rs`** — editor state and side panel:
- `EditorTab` enum (`Palette` | `Board` | `World`) — which tab is active - `EditorTab` enum (`Palette` | `Board` | `World`) — which tab is active
- `EditorState` — holds `selected: Archetype`, `glyph: Glyph`, `glyph_picker_open: bool`, `tab: EditorTab`; selecting a new archetype resets `glyph` to that archetype's default - `EditorState` — holds `selected: Archetype`, `glyph: Glyph`, `glyph_picker_open: bool`, `tab: EditorTab`, `font_dialog_open: bool`, `font_dialog_state: FontDialogState`; selecting a new archetype resets `glyph` to that archetype's default
- `show_editor_panel(ctx, editor, board)` — resizable right-side panel (default 200 px); Palette tab shows archetype list and glyph preview button; Board/World tabs are placeholders - `show_editor_panel(ctx, editor, font_spec, active_font)` — resizable right-side panel (default 200 px); Palette tab shows archetype list and glyph preview button; Board tab shows current font path and "Font…" button that opens `font_dialog::show`; World tab is a placeholder
**`src/glyph_picker.rs`** — floating glyph picker dialog: **`src/glyph_picker.rs`** — floating glyph picker dialog:
- `show(ctx, open, glyph, board_cells)` — takes `open: &mut bool` and `glyph: &mut Glyph` directly; no dependency on `EditorState` - `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, click to select), FG/BG color pickers, 16×6 printable ASCII character grid - 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)
### Map file format (`maps/*.toml`) ### 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. 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.
**Keep this example in sync with `map_file.rs` whenever the format changes.**
```toml ```toml
[map] [map]
name = "Room Name" name = "Room Name"
@@ -92,9 +114,15 @@ width = 60
height = 25 height = 25
player_start = [30, 12] 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] [palette]
" " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" } " " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", ch = "#", fg = "#808080", bg = "#606060" } "#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
[grid] [grid]
content = """ content = """
@@ -117,7 +145,7 @@ target_map = "cave"
target_entry = "west_door" target_entry = "west_door"
``` ```
Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. 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. 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.
### Key design decisions ### Key design decisions
Generated
+159 -14
View File
@@ -215,6 +215,28 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -253,6 +275,17 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]] [[package]]
name = "async-io" name = "async-io"
version = "2.6.0" version = "2.6.0"
@@ -282,6 +315,17 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]] [[package]]
name = "async-process" name = "async-process"
version = "2.5.0" version = "2.5.0"
@@ -450,6 +494,15 @@ dependencies = [
"objc2 0.5.2", "objc2 0.5.2",
] ]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2 0.6.4",
]
[[package]] [[package]]
name = "blocking" name = "blocking"
version = "1.6.2" version = "1.6.2"
@@ -745,6 +798,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4", "objc2 0.6.4",
] ]
@@ -1099,6 +1154,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.32"
@@ -1148,8 +1212,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-io",
"futures-macro", "futures-macro",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
] ]
@@ -1643,6 +1709,8 @@ name = "kiln"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"eframe", "eframe",
"image",
"rfd",
"rhai", "rhai",
"serde", "serde",
"toml", "toml",
@@ -1930,7 +1998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2 0.5.1",
"libc", "libc",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-core-data", "objc2-core-data",
@@ -1946,6 +2014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2 0.6.2",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
@@ -1959,7 +2028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-core-location", "objc2-core-location",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
@@ -1971,7 +2040,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [ dependencies = [
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -1983,7 +2052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -2018,7 +2087,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [ dependencies = [
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-metal", "objc2-metal",
@@ -2030,7 +2099,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [ dependencies = [
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-contacts", "objc2-contacts",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
@@ -2049,7 +2118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2 0.5.1",
"dispatch", "dispatch",
"libc", "libc",
"objc2 0.5.2", "objc2 0.5.2",
@@ -2083,7 +2152,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [ dependencies = [
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
@@ -2096,7 +2165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -2108,7 +2177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-metal", "objc2-metal",
@@ -2131,7 +2200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-cloud-kit", "objc2-cloud-kit",
"objc2-core-data", "objc2-core-data",
@@ -2151,7 +2220,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [ dependencies = [
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -2163,7 +2232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-core-location", "objc2-core-location",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
@@ -2333,6 +2402,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@@ -2357,6 +2432,15 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "presser" name = "presser"
version = "0.3.1" version = "0.3.1"
@@ -2440,6 +2524,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "range-alloc" name = "range-alloc"
version = "0.1.5" version = "0.1.5"
@@ -2485,6 +2598,30 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"js-sys",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rhai" name = "rhai"
version = "1.24.0" version = "1.24.0"
@@ -3149,8 +3286,15 @@ dependencies = [
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -3990,7 +4134,7 @@ dependencies = [
"android-activity", "android-activity",
"atomic-waker", "atomic-waker",
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2 0.5.1",
"bytemuck", "bytemuck",
"calloop 0.13.0", "calloop 0.13.0",
"cfg_aliases", "cfg_aliases",
@@ -4437,6 +4581,7 @@ dependencies = [
"endi", "endi",
"enumflags2", "enumflags2",
"serde", "serde",
"url",
"winnow 1.0.3", "winnow 1.0.3",
"zvariant_derive", "zvariant_derive",
"zvariant_utils", "zvariant_utils",
+2
View File
@@ -8,3 +8,5 @@ eframe = "0.33.3"
rhai = "1" rhai = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
toml = "0.8" toml = "0.8"
image = { version = "0.25", default-features = false, features = ["png"] }
rfd = "0.15"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+3 -3
View File
@@ -5,9 +5,9 @@ height = 25
player_start = [30, 12] player_start = [30, 12]
[palette] [palette]
" " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" } " " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", ch = "#", fg = "#808080", bg = "#606060" } "#" = { archetype = "wall", tile = "#", fg = "#808080", bg = "#606060" }
"+" = { archetype = "banana", ch = "#", fg = "#808080", bg = "#606060" } "+" = { archetype = "banana", tile = "#", fg = "#808080", bg = "#606060" }
[grid] [grid]
content = """ content = """
+57 -19
View File
@@ -1,15 +1,17 @@
use eframe::egui; use eframe::egui;
use egui::{Sense, vec2}; use egui::{Sense, vec2};
use crate::game::{Archetype, Glyph, ALL_ARCHETYPES}; use crate::font::BitmapFont;
use crate::render::{CELL_H, CELL_W, PALETTE_PANEL_W, paint_glyph}; use crate::font_dialog::{FontDialogState, show as show_font_dialog};
use crate::game::{ALL_ARCHETYPES, Archetype, FontSpec, Glyph};
use crate::render::{PALETTE_PANEL_W, paint_glyph};
/// Which tab is active in the editor side panel. /// Which tab is active in the editor side panel.
#[derive(Copy, Clone, PartialEq, Eq)] #[derive(Copy, Clone, PartialEq, Eq)]
pub(crate) enum EditorTab { pub(crate) enum EditorTab {
/// Archetype + glyph palette for painting cells. /// Archetype + glyph palette for painting cells.
Palette, Palette,
/// Board-level properties (placeholder). /// Board-level properties.
Board, Board,
/// World-level properties (placeholder). /// World-level properties (placeholder).
World, World,
@@ -19,21 +21,27 @@ pub(crate) enum EditorTab {
pub(crate) struct EditorState { pub(crate) struct EditorState {
/// The archetype that will be stamped when the user clicks a cell. /// The archetype that will be stamped when the user clicks a cell.
pub(crate) selected: Archetype, pub(crate) selected: Archetype,
/// The glyph (character + colors) to stamp. Independent of the archetype's default. /// The glyph (tile index + colors) to stamp. Independent of the archetype's default.
pub(crate) glyph: Glyph, pub(crate) glyph: Glyph,
/// Whether the glyph picker dialog is currently open. /// Whether the glyph picker dialog is currently open.
pub(crate) glyph_picker_open: bool, pub(crate) glyph_picker_open: bool,
/// Whether the font picker dialog is currently open.
pub(crate) font_dialog_open: bool,
/// In-progress state for the font dialog while it is open.
pub(crate) font_dialog_state: FontDialogState,
/// Which side-panel tab is currently shown. /// Which side-panel tab is currently shown.
pub(crate) tab: EditorTab, pub(crate) tab: EditorTab,
} }
impl EditorState { impl EditorState {
/// Creates editor state with `Wall` selected and the picker closed. /// Creates editor state with `Wall` selected and all dialogs closed.
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
Self { Self {
selected: Archetype::Wall, selected: Archetype::Wall,
glyph: Archetype::Wall.default_glyph(), glyph: Archetype::Wall.default_glyph(),
glyph_picker_open: false, glyph_picker_open: false,
font_dialog_open: false,
font_dialog_state: FontDialogState::from_spec(None),
tab: EditorTab::Palette, tab: EditorTab::Palette,
} }
} }
@@ -41,17 +49,26 @@ impl EditorState {
/// Renders the editor side panel (must be called before `CentralPanel`). /// Renders the editor side panel (must be called before `CentralPanel`).
/// ///
/// Shows tab bar (Palette / Board / World) and, on the Palette tab, the /// Shows tab bar (Palette / Board / World) and the content for the active tab.
/// scrollable archetype list and current-glyph preview button. /// `font_spec` is the board's current font override (may be `None` for default).
pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState) { /// `active_font` is the resolved font currently in use for rendering.
pub(crate) fn show_editor_panel(
ctx: &egui::Context,
editor: &mut EditorState,
font_spec: &mut Option<FontSpec>,
active_font: &BitmapFont,
) {
let tw = active_font.tile_w as f32;
let th = active_font.tile_h as f32;
egui::SidePanel::right("palette_panel") egui::SidePanel::right("palette_panel")
.resizable(true) .resizable(true)
.default_width(PALETTE_PANEL_W) .default_width(PALETTE_PANEL_W)
.show(ctx, |ui| { .show(ctx, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.selectable_value(&mut editor.tab, EditorTab::Palette, "Palette"); ui.selectable_value(&mut editor.tab, EditorTab::Palette, "Palette");
ui.selectable_value(&mut editor.tab, EditorTab::Board, "Board"); ui.selectable_value(&mut editor.tab, EditorTab::Board, "Board");
ui.selectable_value(&mut editor.tab, EditorTab::World, "World"); ui.selectable_value(&mut editor.tab, EditorTab::World, "World");
}); });
ui.separator(); ui.separator();
@@ -60,7 +77,7 @@ pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState) {
ui.label("Element"); ui.label("Element");
// Scrollable archetype list; shows at most 5 rows before scrolling. // Scrollable archetype list; shows at most 5 rows before scrolling.
let row_h = CELL_H + ui.spacing().item_spacing.y; let row_h = th + ui.spacing().item_spacing.y;
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.max_height(row_h * 5.0) .max_height(row_h * 5.0)
.id_salt("arch_list") .id_salt("arch_list")
@@ -69,14 +86,14 @@ pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState) {
let is_selected = editor.selected == archetype; let is_selected = editor.selected == archetype;
let glyph = archetype.default_glyph(); let glyph = archetype.default_glyph();
ui.horizontal(|ui| { ui.horizontal(|ui| {
let (r, cell_resp) = ui.allocate_exact_size( let (r, cell_resp) =
vec2(CELL_W, CELL_H), Sense::click()); ui.allocate_exact_size(vec2(tw, th), Sense::click());
paint_glyph(ui.painter(), r, &glyph); paint_glyph(ui.painter(), r, &glyph, active_font);
let label_resp = let label_resp =
ui.selectable_label(is_selected, archetype.name()); ui.selectable_label(is_selected, archetype.name());
if cell_resp.clicked() || label_resp.clicked() { if cell_resp.clicked() || label_resp.clicked() {
editor.selected = archetype; editor.selected = archetype;
editor.glyph = archetype.default_glyph(); editor.glyph = archetype.default_glyph();
} }
}); });
} }
@@ -87,15 +104,36 @@ pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState) {
ui.label("Glyph"); ui.label("Glyph");
// Paint a preview cell showing the current glyph, sized like a board cell. // Paint a preview cell showing the current glyph, sized like a board cell.
let (cell_rect, glyph_btn) = let (cell_rect, glyph_btn) =
ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click()); ui.allocate_exact_size(vec2(tw, th), Sense::click());
paint_glyph(ui.painter(), cell_rect, &editor.glyph); paint_glyph(ui.painter(), cell_rect, &editor.glyph, active_font);
if glyph_btn.clicked() { if glyph_btn.clicked() {
editor.glyph_picker_open = true; editor.glyph_picker_open = true;
} }
} }
EditorTab::Board => {}
EditorTab::Board => {
let font_label = match font_spec {
Some(s) => s.path.clone(),
None => "(default)".to_owned(),
};
ui.label(format!("Font: {font_label}"));
if ui.button("Font…").clicked() {
editor.font_dialog_state = FontDialogState::from_spec(font_spec.as_ref());
editor.font_dialog_open = true;
}
}
EditorTab::World => {} EditorTab::World => {}
} }
}); });
}
// Font dialog floats outside the side panel; show it here so it renders on top.
if editor.font_dialog_open {
show_font_dialog(
ctx,
&mut editor.font_dialog_open,
&mut editor.font_dialog_state,
font_spec,
);
}
}
+263
View File
@@ -0,0 +1,263 @@
use eframe::egui;
use egui::{ColorImage, Rect, TextureHandle, TextureOptions, vec2};
use image::GenericImageView;
use std::path::Path;
/// Computes a UV rectangle for `tile` within an image of known dimensions.
///
/// This is the core tile-index-to-UV mapping; pulled out so tests can verify
/// it without needing an egui [`Context`] to construct a [`BitmapFont`].
fn compute_tile_uv(tile: u32, tile_w: u32, tile_h: u32, img_w: u32, img_h: u32) -> Rect {
let tiles_per_row = (img_w / tile_w).max(1);
let col = tile % tiles_per_row;
let row = tile / tiles_per_row;
let u0 = col as f32 * tile_w as f32 / img_w as f32;
let v0 = row as f32 * tile_h as f32 / img_h as f32;
let u1 = u0 + tile_w as f32 / img_w as f32;
let v1 = v0 + tile_h as f32 / img_h as f32;
Rect::from_min_max(egui::pos2(u0, v0), egui::pos2(u1, v1))
}
/// A loaded bitmap font ready for rendering.
///
/// Wraps an egui texture that has been pre-processed to a two-value RGBA
/// image: "background" pixels (matching the top-left pixel of the source
/// image) become fully transparent, and all other pixels become opaque white.
///
/// At render time, each tile is drawn by:
/// 1. Filling the cell rect with the glyph's `bg` color.
/// 2. Calling `painter.image(…, tile_uv(tile), fg_color)` — egui tints the
/// opaque-white foreground pixels to `fg` and the transparent pixels stay
/// transparent, revealing the `bg` fill underneath.
pub struct BitmapFont {
/// The uploaded egui texture (preprocessed to opaque-white / transparent).
pub texture: TextureHandle,
/// Width of each tile in pixels.
pub tile_w: u32,
/// Height of each tile in pixels.
pub tile_h: u32,
/// Width of the full font image in pixels.
img_w: u32,
/// Height of the full font image in pixels.
img_h: u32,
}
impl BitmapFont {
/// Loads a bitmap font from raw PNG bytes (e.g. via `include_bytes!`).
///
/// `label` is passed to egui as the texture name for debugging.
/// Returns an error if the image cannot be decoded.
#[allow(dead_code)]
pub fn from_bytes(
ctx: &egui::Context,
bytes: &[u8],
tile_w: u32,
tile_h: u32,
label: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
let img = image::load_from_memory(bytes)?;
Self::from_image(ctx, img, tile_w, tile_h, label)
}
/// Loads a bitmap font from a PNG file on disk.
pub fn load(
ctx: &egui::Context,
path: &Path,
tile_w: u32,
tile_h: u32,
) -> Result<Self, Box<dyn std::error::Error>> {
let img = image::open(path)?;
let label = path.to_string_lossy();
Self::from_image(ctx, img, tile_w, tile_h, &label)
}
fn from_image(
ctx: &egui::Context,
img: image::DynamicImage,
tile_w: u32,
tile_h: u32,
label: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
let img_w = img.width();
let img_h = img.height();
// The top-left pixel defines the "background" color to treat as transparent.
let bg_pixel = img.get_pixel(0, 0);
let [br, bg, bb, _] = bg_pixel.0;
// Convert to RGBA: bg-colored pixels → transparent, all others → opaque white.
let rgba = img.to_rgba8();
let mut pixels: Vec<egui::Color32> = Vec::with_capacity((img_w * img_h) as usize);
for pixel in rgba.pixels() {
let [r, g, b, _] = pixel.0;
if r == br && g == bg && b == bb {
pixels.push(egui::Color32::TRANSPARENT);
} else {
pixels.push(egui::Color32::WHITE);
}
}
let color_image = ColorImage {
size: [img_w as usize, img_h as usize],
pixels,
source_size: vec2(img_w as f32, img_h as f32),
};
let texture = ctx.load_texture(label, color_image, TextureOptions::NEAREST);
Ok(Self {
texture,
tile_w,
tile_h,
img_w,
img_h,
})
}
/// Returns the UV rectangle (in 0.01.0 coordinates) for `tile` in the font image.
///
/// Tiles are numbered left-to-right, top-to-bottom. For a 16-column font,
/// tile 0 is top-left, tile 15 is top-right, tile 16 is the first tile of
/// row 2, etc.
pub fn tile_uv(&self, tile: u32) -> Rect {
compute_tile_uv(tile, self.tile_w, self.tile_h, self.img_w, self.img_h)
}
/// Returns the number of tile columns in the font image.
pub fn tile_cols(&self) -> u32 {
(self.img_w / self.tile_w).max(1)
}
/// Returns the total number of tiles in this font image.
pub fn tile_count(&self) -> u32 {
let rows = (self.img_h / self.tile_h).max(1);
self.tile_cols() * rows
}
/// Width of the full font image in pixels.
pub fn img_w(&self) -> u32 {
self.img_w
}
/// Height of the full font image in pixels.
pub fn img_h(&self) -> u32 {
self.img_h
}
/// Creates a procedural placeholder font (16×16 grid of 8×8 tiles).
///
/// Each tile renders the 8 bits of its index as a vertical bar pattern,
/// making tiles visually distinct. Used as a fallback when no font file
/// is present.
pub fn create_placeholder(ctx: &egui::Context) -> Self {
let tile_w: u32 = 8;
let tile_h: u32 = 8;
let cols: u32 = 16;
let rows: u32 = 16;
let img_w = cols * tile_w;
let img_h = rows * tile_h;
let mut pixels = vec![egui::Color32::TRANSPARENT; (img_w * img_h) as usize];
for tile in 0..256u32 {
let tc = tile % cols;
let tr = tile / cols;
// Render the 8 bits of the tile index as a column bar pattern (rows 16).
let byte = (tile & 0xFF) as u8;
for py in 1u32..7 {
for px in 0u32..8 {
let bit = (byte >> (7 - px)) & 1;
if bit == 1 {
let x = tc * tile_w + px;
let y = tr * tile_h + py;
pixels[(y * img_w + x) as usize] = egui::Color32::WHITE;
}
}
}
}
let color_image = ColorImage {
size: [img_w as usize, img_h as usize],
pixels,
source_size: vec2(img_w as f32, img_h as f32),
};
let texture = ctx.load_texture(
"default_font_placeholder",
color_image,
TextureOptions::NEAREST,
);
Self {
texture,
tile_w,
tile_h,
img_w,
img_h,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-5
}
fn rect_approx_eq(a: Rect, b: Rect) -> bool {
approx_eq(a.min.x, b.min.x)
&& approx_eq(a.min.y, b.min.y)
&& approx_eq(a.max.x, b.max.x)
&& approx_eq(a.max.y, b.max.y)
}
#[test]
fn tile_zero_is_top_left() {
let uv = compute_tile_uv(0, 8, 8, 128, 128);
assert!(rect_approx_eq(
uv,
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0 / 16.0, 1.0 / 16.0))
));
}
#[test]
fn tile_at_end_of_first_row() {
// 16 cols, so tile 15 is top-right.
let uv = compute_tile_uv(15, 8, 8, 128, 128);
assert!(approx_eq(uv.min.x, 15.0 / 16.0));
assert!(approx_eq(uv.min.y, 0.0));
}
#[test]
fn tile_at_start_of_second_row() {
// Tile 16 should be col 0, row 1.
let uv = compute_tile_uv(16, 8, 8, 128, 128);
assert!(approx_eq(uv.min.x, 0.0));
assert!(approx_eq(uv.min.y, 1.0 / 16.0));
}
#[test]
fn tile_64_is_at_position() {
// '@' = ASCII 64; in a 16-col font: col = 64%16 = 0, row = 64/16 = 4.
let uv = compute_tile_uv(64, 8, 8, 128, 128);
assert!(approx_eq(uv.min.x, 0.0));
assert!(approx_eq(uv.min.y, 4.0 / 16.0));
}
#[test]
fn non_square_font_layout() {
// 32 columns, 8 rows = 256 tiles; 6×8 tiles; image 192×64.
// tile 32 = col 0, row 1.
let uv = compute_tile_uv(32, 6, 8, 192, 64);
assert!(approx_eq(uv.min.x, 0.0));
assert!(approx_eq(uv.min.y, 1.0 / 8.0));
}
#[test]
fn uv_width_matches_tile_fraction() {
let uv = compute_tile_uv(0, 8, 8, 128, 128);
// Each tile is 1/16 of the image width.
assert!(approx_eq(uv.width(), 1.0 / 16.0));
assert!(approx_eq(uv.height(), 1.0 / 16.0));
}
}
+180
View File
@@ -0,0 +1,180 @@
use eframe::egui;
use egui::{Color32, DragValue, Rect, Stroke, vec2};
use crate::font::BitmapFont;
use crate::game::FontSpec;
/// State for the font-picker dialog.
///
/// Holds the in-progress edit of font path and tile dimensions, and a loaded
/// preview font so the user can see the tilesheet before applying.
pub(crate) struct FontDialogState {
/// Path string as edited by the user (may differ from the applied font).
pub path: String,
/// Tile width being edited.
pub tile_w: u32,
/// Tile height being edited.
pub tile_h: u32,
/// Loaded preview of the current path+dimensions, if valid.
pub preview: Option<BitmapFont>,
}
impl FontDialogState {
/// Creates dialog state pre-populated from an existing [`FontSpec`], or
/// defaults (8×8, empty path) when there is no board-level font.
pub(crate) fn from_spec(spec: Option<&FontSpec>) -> Self {
match spec {
Some(s) => Self {
path: s.path.clone(),
tile_w: s.tile_w,
tile_h: s.tile_h,
preview: None,
},
None => Self {
path: String::new(),
tile_w: 8,
tile_h: 8,
preview: None,
},
}
}
}
/// Renders the floating font-picker dialog.
///
/// Lets the user choose a PNG file, set tile dimensions, preview the tilesheet
/// with grid lines, and apply the selection. When the user clicks "Apply",
/// `font_spec` is written and `open` is set to `false`.
pub(crate) fn show(
ctx: &egui::Context,
open: &mut bool,
state: &mut FontDialogState,
font_spec: &mut Option<FontSpec>,
) {
let mut should_close = false;
egui::Window::new("Font")
.collapsible(false)
.resizable(true)
.open(open)
.show(ctx, |ui| {
// --- File chooser ---
ui.horizontal(|ui| {
ui.label("File:");
ui.text_edit_singleline(&mut state.path);
if ui.button("Browse…").clicked() {
if let Some(path) = rfd::FileDialog::new()
.add_filter("PNG image", &["png"])
.pick_file()
{
state.path = path.to_string_lossy().into_owned();
state.preview = None; // reload on next reload click
}
}
});
// --- Tile dimensions ---
ui.horizontal(|ui| {
ui.label("Tile W:");
if ui
.add(DragValue::new(&mut state.tile_w).range(1..=64))
.changed()
{
state.preview = None;
}
ui.label("Tile H:");
if ui
.add(DragValue::new(&mut state.tile_h).range(1..=64))
.changed()
{
state.preview = None;
}
if ui.button("Load").clicked() {
state.preview = None;
if !state.path.is_empty() {
match BitmapFont::load(
ctx,
std::path::Path::new(&state.path),
state.tile_w,
state.tile_h,
) {
Ok(f) => state.preview = Some(f),
Err(e) => eprintln!("font load error: {e}"),
}
}
}
});
ui.separator();
// --- Preview ---
if let Some(font) = &state.preview {
let tw = font.tile_w as f32;
let th = font.tile_h as f32;
let preview_size = vec2(font.img_w() as f32, font.img_h() as f32);
egui::ScrollArea::both()
.max_width(400.0)
.max_height(200.0)
.id_salt("font_preview")
.show(ui, |ui| {
let (rect, _) = ui.allocate_exact_size(preview_size, egui::Sense::hover());
// Draw the full font texture.
ui.painter().image(
font.texture.id(),
rect,
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
// Overlay grid lines at tile boundaries.
let p = ui.painter();
let cols = font.tile_cols();
let rows = font.img_h() / font.tile_h;
let stroke =
Stroke::new(0.5, Color32::from_rgba_unmultiplied(255, 255, 0, 120));
for c in 0..=cols {
let x = rect.min.x + c as f32 * tw;
p.line_segment(
[egui::pos2(x, rect.min.y), egui::pos2(x, rect.max.y)],
stroke,
);
}
for r in 0..=rows {
let y = rect.min.y + r as f32 * th;
p.line_segment(
[egui::pos2(rect.min.x, y), egui::pos2(rect.max.x, y)],
stroke,
);
}
});
} else {
ui.label("(no preview — click Load after setting path and tile size)");
}
ui.separator();
// --- Apply / Clear ---
ui.horizontal(|ui| {
let can_apply = state.preview.is_some();
if ui
.add_enabled(can_apply, egui::Button::new("Apply"))
.clicked()
{
*font_spec = Some(FontSpec {
path: state.path.clone(),
tile_w: state.tile_w,
tile_h: state.tile_h,
});
should_close = true;
}
if ui.button("Use default").clicked() {
*font_spec = None;
should_close = true;
}
});
});
if should_close {
*open = false;
}
}
+77 -28
View File
@@ -3,36 +3,55 @@ use serde::Deserialize;
/// The visual representation of a single board cell. /// The visual representation of a single board cell.
/// ///
/// `Glyph` holds everything needed to draw one cell on screen: which character /// `Glyph` holds everything needed to draw one cell on screen: which tile
/// to display and what colors to use. It is stored per-cell (not per archetype), /// index to display and what colors to use. It is stored per-cell (not per
/// so individual cells can vary their appearance independently — for example, /// archetype), so individual cells can vary their appearance independently.
/// a field of "fire" tiles where each flame has a slightly different color ///
/// while all sharing the same [`Archetype`] behavior. /// `tile` is a left-to-right, top-to-bottom index into the board's bitmap
/// font. For the default CP437 font this matches the ASCII/CP437 code point.
/// ///
/// `Glyph` values come from the map file palette and are set at load time. /// `Glyph` values come from the map file palette and are set at load time.
/// The player is the only entity whose glyph is hardcoded at runtime /// The player is the only entity whose glyph is hardcoded at runtime
/// (see [`Glyph::player`]). /// (see [`Glyph::player`]).
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct Glyph { pub struct Glyph {
/// The character to display in this cell. /// Tile index into the board's bitmap font (left-to-right, top-to-bottom).
pub ch: char, pub tile: u32,
/// Foreground (text) color. /// Foreground color, applied to non-background pixels of the tile.
pub fg: Color32, pub fg: Color32,
/// Background color, drawn as a filled rectangle behind the character. /// Background color, drawn as a filled rectangle behind the tile.
pub bg: Color32, pub bg: Color32,
} }
impl Glyph { impl Glyph {
/// Returns the glyph used to render the player: `@` in light blue on black. /// Returns the glyph used to render the player: tile 64 (`@`) in light blue on black.
/// ///
/// This is the only hardcoded glyph; all other glyphs come from the map /// This is the only hardcoded glyph; all other glyphs come from the map
/// file palette. It will be removed once the player becomes a scripted /// file palette. It will be removed once the player becomes a scripted
/// object with its own palette entry. /// object with its own palette entry.
pub fn player() -> Self { pub const fn player() -> Self {
Self { ch: '@', fg: Color32::LIGHT_BLUE, bg: Color32::BLACK } Self {
tile: 64,
fg: Color32::LIGHT_BLUE,
bg: Color32::BLACK,
}
} }
} }
/// Specifies a bitmap font for a board.
///
/// Each board can optionally specify a font image and tile dimensions.
/// When absent, the app's default embedded CP437 font is used.
#[derive(Clone, PartialEq, Eq)]
pub struct FontSpec {
/// Path to the PNG font image, relative to the working directory.
pub path: String,
/// Width of each tile in the font image, in pixels.
pub tile_w: u32,
/// Height of each tile in the font image, in pixels.
pub tile_h: u32,
}
/// The behavioral properties of a board cell at runtime. /// The behavioral properties of a board cell at runtime.
/// ///
/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It /// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It
@@ -87,19 +106,31 @@ impl Archetype {
/// For `Object`, this is a placeholder until Rhai scripts drive the behavior. /// For `Object`, this is a placeholder until Rhai scripts drive the behavior.
pub fn behavior(&self) -> Behavior { pub fn behavior(&self) -> Behavior {
match self { match self {
Archetype::Empty => Behavior { passable: true, opaque: false }, Archetype::Empty => Behavior {
Archetype::Wall => Behavior { passable: false, opaque: true }, passable: true,
Archetype::Object => Behavior { passable: false, opaque: false }, opaque: false,
Archetype::ErrorBlock => Behavior { passable: false, opaque: true }, },
Archetype::Wall => Behavior {
passable: false,
opaque: true,
},
Archetype::Object => Behavior {
passable: false,
opaque: false,
},
Archetype::ErrorBlock => Behavior {
passable: false,
opaque: true,
},
} }
} }
/// Returns the canonical name used to reference this archetype in map files. /// Returns the canonical name used to reference this archetype in map files.
pub fn name(&self) -> &'static str { pub fn name(&self) -> &'static str {
match self { match self {
Archetype::Empty => "empty", Archetype::Empty => "empty",
Archetype::Wall => "wall", Archetype::Wall => "wall",
Archetype::Object => "object", Archetype::Object => "object",
Archetype::ErrorBlock => "error_block", Archetype::ErrorBlock => "error_block",
} }
} }
@@ -110,11 +141,27 @@ impl Archetype {
/// cells retain their own per-cell glyph. /// cells retain their own per-cell glyph.
pub fn default_glyph(&self) -> Glyph { pub fn default_glyph(&self) -> Glyph {
match self { match self {
Archetype::Empty => Glyph { ch: ' ', fg: Color32::BLACK, bg: Color32::BLACK }, Archetype::Empty => Glyph {
Archetype::Wall => Glyph { ch: '#', fg: Color32::from_rgb(0x80, 0x80, 0x80), bg: Color32::from_rgb(0x60, 0x60, 0x60) }, tile: 32,
Archetype::Object => Glyph { ch: '?', fg: Color32::YELLOW, bg: Color32::BLACK }, fg: Color32::BLACK,
bg: Color32::BLACK,
},
Archetype::Wall => Glyph {
tile: 35,
fg: Color32::from_rgb(0x80, 0x80, 0x80),
bg: Color32::from_rgb(0x60, 0x60, 0x60),
},
Archetype::Object => Glyph {
tile: 63,
fg: Color32::YELLOW,
bg: Color32::BLACK,
},
// Visually distinct so malformed map files are immediately obvious. // Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph { ch: '?', fg: Color32::YELLOW, bg: Color32::RED }, Archetype::ErrorBlock => Glyph {
tile: 63,
fg: Color32::YELLOW,
bg: Color32::RED,
},
} }
} }
} }
@@ -128,10 +175,10 @@ impl TryFrom<&str> for Archetype {
/// [`Archetype::ErrorBlock`] and log the error so the problem is visible. /// [`Archetype::ErrorBlock`] and log the error so the problem is visible.
fn try_from(name: &str) -> Result<Self, Self::Error> { fn try_from(name: &str) -> Result<Self, Self::Error> {
match name { match name {
"empty" => Ok(Archetype::Empty), "empty" => Ok(Archetype::Empty),
"wall" => Ok(Archetype::Wall), "wall" => Ok(Archetype::Wall),
"object" => Ok(Archetype::Object), "object" => Ok(Archetype::Object),
_ => Err(format!("unknown archetype: {name}")), _ => Err(format!("unknown archetype: {name}")),
} }
} }
} }
@@ -230,6 +277,8 @@ pub struct Board {
pub objects: Vec<ObjectDef>, pub objects: Vec<ObjectDef>,
/// Portals on this board. Parsed from the map file; not yet active. /// Portals on this board. Parsed from the map file; not yet active.
pub portals: Vec<PortalDef>, pub portals: Vec<PortalDef>,
/// Optional font override for this board. When `None`, the app default is used.
pub font: Option<FontSpec>,
} }
impl Board { impl Board {
@@ -295,4 +344,4 @@ impl GameState {
} }
} }
} }
} }
+77 -46
View File
@@ -1,32 +1,38 @@
use std::collections::HashSet;
use eframe::egui; use eframe::egui;
use egui::{Color32, Rect, Sense, Stroke, vec2}; use egui::{Color32, Rect, Sense, Stroke, vec2};
use crate::font::BitmapFont;
use crate::game::{Archetype, Glyph}; use crate::game::{Archetype, Glyph};
use crate::render::{CELL_H, CELL_W, paint_glyph, pos_to_cell}; use crate::render::{paint_glyph, pos_to_cell};
/// Renders the floating glyph-picker dialog and updates `glyph` in place. /// Renders the floating glyph-picker dialog and updates `glyph` in place.
/// ///
/// Shows three sections: board palette (unique glyphs on the current board), /// Shows three sections: board palette (unique glyphs on the current board),
/// FG/BG color pickers, and a 16×6 printable-ASCII character grid. /// FG/BG color pickers, and a scrollable grid of all tiles in the active font.
/// Closes when the window's X button is clicked, writing `false` to `open`. /// Closes when the window's X button is clicked, writing `false` to `open`.
pub(crate) fn show( pub(crate) fn show(
ctx: &egui::Context, ctx: &egui::Context,
open: &mut bool, open: &mut bool,
glyph: &mut Glyph, glyph: &mut Glyph,
board_cells: &[(Glyph, Archetype)], board_cells: &[(Glyph, Archetype)],
font: &BitmapFont,
) { ) {
// Collect unique glyphs from the board before the window closure borrows // Collect unique glyphs from the board before the window closure borrows
// `glyph` mutably, to avoid a borrow conflict with `board_cells`. // `glyph` mutably, to avoid a borrow conflict with `board_cells`.
let board_glyphs: Vec<Glyph> = { let board_glyphs: Vec<Glyph> = {
let mut seen: Vec<Glyph> = Vec::new(); let mut seen: HashSet<Glyph> = HashSet::new();
for (g, _) in board_cells { board_cells
if !seen.contains(g) { .iter()
seen.push(*g); .map(|(g, _)| *g)
} .filter(|g| seen.insert(*g))
} .collect()
seen
}; };
let tw = font.tile_w as f32;
let th = font.tile_h as f32;
egui::Window::new("Glyph") egui::Window::new("Glyph")
.collapsible(false) .collapsible(false)
.resizable(false) .resizable(false)
@@ -35,13 +41,16 @@ pub(crate) fn show(
ui.label("Board palette"); ui.label("Board palette");
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
for &g in &board_glyphs { for &g in &board_glyphs {
let (r, resp) = let (r, resp) = ui.allocate_exact_size(vec2(tw, th), Sense::click());
ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click());
let p = ui.painter(); let p = ui.painter();
paint_glyph(p, r, &g); paint_glyph(p, r, &g, font);
if g == *glyph { if g == *glyph {
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), p.rect_stroke(
egui::epaint::StrokeKind::Middle); r,
0.0,
Stroke::new(1.0, Color32::WHITE),
egui::epaint::StrokeKind::Middle,
);
} }
if resp.clicked() { if resp.clicked() {
*glyph = g; *glyph = g;
@@ -55,45 +64,67 @@ pub(crate) fn show(
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("FG"); ui.label("FG");
egui::color_picker::color_edit_button_srgba( egui::color_picker::color_edit_button_srgba(
ui, &mut glyph.fg, egui::color_picker::Alpha::Opaque); ui,
&mut glyph.fg,
egui::color_picker::Alpha::Opaque,
);
ui.label("BG"); ui.label("BG");
egui::color_picker::color_edit_button_srgba( egui::color_picker::color_edit_button_srgba(
ui, &mut glyph.bg, egui::color_picker::Alpha::Opaque); ui,
&mut glyph.bg,
egui::color_picker::Alpha::Opaque,
);
}); });
ui.separator(); ui.separator();
// 16 columns × 6 rows covering printable ASCII 0x200x7E. // Full tile grid: column count matches the font image layout so the
ui.label("Character"); // picker visually mirrors the tilesheet.
const CHAR_COLS: usize = 16; ui.label("Tile");
let chars: Vec<char> = let tile_cols = font.tile_cols() as usize;
(0x20u32..=0x7eu32).filter_map(char::from_u32).collect(); let tile_count = font.tile_count() as usize;
let rows = chars.len().div_ceil(CHAR_COLS); let rows = tile_count.div_ceil(tile_cols);
let grid_size = vec2(CHAR_COLS as f32 * CELL_W, rows as f32 * CELL_H); let grid_size = vec2(tile_cols as f32 * tw, rows as f32 * th);
let (grid_rect, grid_resp) =
ui.allocate_exact_size(grid_size, Sense::click()); egui::ScrollArea::vertical()
{ .max_height(th * 16.0)
let p = ui.painter(); .id_salt("tile_grid")
for (i, &ch) in chars.iter().enumerate() { .show(ui, |ui| {
let col = i % CHAR_COLS; let (grid_rect, grid_resp) = ui.allocate_exact_size(grid_size, Sense::click());
let row = i / CHAR_COLS; {
let tl = grid_rect.min let p = ui.painter();
+ vec2(col as f32 * CELL_W, row as f32 * CELL_H); for i in 0..tile_count {
let cell = Rect::from_min_size(tl, vec2(CELL_W, CELL_H)); let col = i % tile_cols;
paint_glyph(p, cell, &Glyph { ch, fg: glyph.fg, bg: glyph.bg }); let row = i / tile_cols;
if ch == glyph.ch { let tl = grid_rect.min + vec2(col as f32 * tw, row as f32 * th);
p.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::WHITE), let cell = Rect::from_min_size(tl, vec2(tw, th));
egui::epaint::StrokeKind::Middle); let tile_glyph = Glyph {
tile: i as u32,
fg: glyph.fg,
bg: glyph.bg,
};
paint_glyph(p, cell, &tile_glyph, font);
if i as u32 == glyph.tile {
p.rect_stroke(
cell,
0.0,
Stroke::new(1.0, Color32::WHITE),
egui::epaint::StrokeKind::Middle,
);
}
}
} }
} if grid_resp.clicked()
} && let Some(pos) = grid_resp.interact_pointer_pos()
if grid_resp.clicked() {
&& let Some(pos) = grid_resp.interact_pointer_pos() { let (col, row) = pos_to_cell(grid_rect.min, pos, tw, th);
let (col, row) = pos_to_cell(grid_rect.min, pos); if col >= 0 && row >= 0 {
let idx = row as usize * CHAR_COLS + col as usize; let idx = row as usize * tile_cols + col as usize;
if let Some(&ch) = chars.get(idx) { if idx < tile_count {
glyph.ch = ch; glyph.tile = idx as u32;
}
}
} }
} });
}); });
} }
+92 -33
View File
@@ -1,17 +1,21 @@
use eframe::egui; use eframe::egui;
use egui::{Key, Sense, vec2}; use egui::{Key, Sense, vec2};
use std::path::Path;
mod editor; mod editor;
mod font;
mod font_dialog;
mod game; mod game;
mod glyph_picker; mod glyph_picker;
mod map_file; mod map_file;
mod render; mod render;
use editor::{EditorState, show_editor_panel}; use editor::{EditorState, show_editor_panel};
use game::GameState; use font::BitmapFont;
use game::{FontSpec, GameState};
use render::{ use render::{
DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W, DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W, board_origin, draw_board,
board_origin, draw_board, pos_to_cell, CELL_H, CELL_W, pos_to_cell,
}; };
/// Whether the application is in play mode or edit mode. /// Whether the application is in play mode or edit mode.
@@ -39,7 +43,7 @@ fn main() -> eframe::Result<()> {
eframe::run_native( eframe::run_native(
"Kiln", "Kiln",
options, options,
Box::new(move |_cc| Ok(Box::new(App::new(board)))), Box::new(move |cc| Ok(Box::new(App::new(board, &cc.egui_ctx)))),
) )
} }
@@ -51,17 +55,47 @@ struct App {
state: GameState, state: GameState,
mode: AppMode, mode: AppMode,
editor: EditorState, editor: EditorState,
/// The default font, loaded from `assets/default_font.png` or a placeholder.
default_font: BitmapFont,
/// A per-board font loaded from `board.font`, if one is specified.
board_font: Option<BitmapFont>,
} }
impl App { impl App {
/// Creates the app with the given board, starting in Play mode. /// Creates the app with the given board, starting in Play mode.
fn new(board: game::Board) -> Self { ///
/// `egui_ctx` is needed to upload font textures before the first frame.
fn new(board: game::Board, egui_ctx: &egui::Context) -> Self {
// Load the default font from assets/. Fall back to a placeholder if missing.
let default_font = BitmapFont::load(egui_ctx, Path::new("assets/vga-font-8x16.png"), 8, 16)
.unwrap_or_else(|_| BitmapFont::create_placeholder(egui_ctx));
// If the board specifies its own font, try to load it now.
let board_font = board.font.as_ref().and_then(|spec| {
BitmapFont::load(egui_ctx, Path::new(&spec.path), spec.tile_w, spec.tile_h)
.map_err(|e| eprintln!("board font load error: {e}"))
.ok()
});
Self { Self {
state: GameState::new(board), state: GameState::new(board),
mode: AppMode::Play, mode: AppMode::Play,
editor: EditorState::new(), editor: EditorState::new(),
default_font,
board_font,
} }
} }
/// Reloads `board_font` from `font_spec`, or clears it if `font_spec` is `None`.
///
/// Called after the font dialog applies a change.
fn apply_font_spec(&mut self, egui_ctx: &egui::Context, font_spec: Option<&FontSpec>) {
self.board_font = font_spec.and_then(|spec| {
BitmapFont::load(egui_ctx, Path::new(&spec.path), spec.tile_w, spec.tile_h)
.map_err(|e| eprintln!("font apply error: {e}"))
.ok()
});
}
} }
impl eframe::App for App { impl eframe::App for App {
@@ -70,10 +104,18 @@ impl eframe::App for App {
// Arrow keys move the player only in Play mode. // Arrow keys move the player only in Play mode.
if self.mode == AppMode::Play { if self.mode == AppMode::Play {
ctx.input(|i| { ctx.input(|i| {
if i.key_pressed(Key::ArrowUp) { self.state.try_move( 0, -1); } if i.key_pressed(Key::ArrowUp) {
if i.key_pressed(Key::ArrowDown) { self.state.try_move( 0, 1); } self.state.try_move(0, -1);
if i.key_pressed(Key::ArrowLeft) { self.state.try_move(-1, 0); } }
if i.key_pressed(Key::ArrowRight) { self.state.try_move( 1, 0); } if i.key_pressed(Key::ArrowDown) {
self.state.try_move(0, 1);
}
if i.key_pressed(Key::ArrowLeft) {
self.state.try_move(-1, 0);
}
if i.key_pressed(Key::ArrowRight) {
self.state.try_move(1, 0);
}
}); });
} }
@@ -94,61 +136,78 @@ impl eframe::App for App {
// --- Editor side panel --- // --- Editor side panel ---
// Must be declared before CentralPanel so egui allocates its space first. // Must be declared before CentralPanel so egui allocates its space first.
// Track whether font_spec changed so we can reload the board font.
let font_spec_before = self.state.board.font.clone();
if self.mode == AppMode::Edit { if self.mode == AppMode::Edit {
show_editor_panel(ctx, &mut self.editor); // Split borrows explicitly: active_font borrows board_font/default_font,
// while show_editor_panel takes &mut editor and &mut board.font separately.
let active_font = self.board_font.as_ref().unwrap_or(&self.default_font);
show_editor_panel(
ctx,
&mut self.editor,
&mut self.state.board.font,
active_font,
);
}
// If the board's font spec was changed by the editor, reload the board font.
if self.state.board.font != font_spec_before {
let spec = self.state.board.font.clone();
self.apply_font_spec(ctx, spec.as_ref());
} }
// Snapshot the active font for this frame so all rendering uses the same one.
// We re-borrow active_font here since apply_font_spec may have changed board_font.
let font = self.board_font.as_ref().unwrap_or(&self.default_font);
let tw = font.tile_w as f32;
let th = font.tile_h as f32;
// --- Board rendering --- // --- Board rendering ---
// The board is drawn using the egui Painter API (direct 2D drawing), // The board is drawn using the egui Painter API (direct 2D drawing).
// not egui widgets. This gives pixel-level control over placement.
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
let board_w = self.state.board.width; let board_w = self.state.board.width;
let board_h = self.state.board.height; let board_h = self.state.board.height;
let board_px = vec2(board_w as f32 * CELL_W, board_h as f32 * CELL_H); let board_px = vec2(board_w as f32 * tw, board_h as f32 * th);
if self.mode == AppMode::Edit { if self.mode == AppMode::Edit {
// Edit mode: ScrollArea lets the user scroll when the board // Edit mode: ScrollArea lets the user scroll when the board overflows.
// overflows the viewport, showing scroll bars as needed.
egui::ScrollArea::new([true, true]).show(ui, |ui| { egui::ScrollArea::new([true, true]).show(ui, |ui| {
let (rect, response) = ui.allocate_exact_size(board_px, Sense::click()); let (rect, response) = ui.allocate_exact_size(board_px, Sense::click());
draw_board(ui.painter(), rect.min, &self.state.board); draw_board(ui.painter(), rect.min, &self.state.board, font);
if response.clicked() if response.clicked()
&& let Some(pos) = response.interact_pointer_pos() { && let Some(pos) = response.interact_pointer_pos()
let (cx, cy) = pos_to_cell(rect.min, pos); {
// floor() keeps out-of-bounds negatives negative; caught here. let (cx, cy) = pos_to_cell(rect.min, pos, tw, th);
if cx >= 0 && cy >= 0 { if cx >= 0 && cy >= 0 {
let cx = cx as usize; let cx = cx as usize;
let cy = cy as usize; let cy = cy as usize;
let board = &mut self.state.board; let board = &mut self.state.board;
if cx < board.width && cy < board.height { if cx < board.width && cy < board.height {
let arch = self.editor.selected; let arch = self.editor.selected;
*board.get_mut(cx, cy) = (self.editor.glyph, arch); *board.get_mut(cx, cy) = (self.editor.glyph, arch);
}
} }
} }
}
}); });
} else { } else {
// Play mode: player-centered viewport, no scroll bars. // Play mode: player-centered viewport, no scroll bars.
let available = ui.available_rect_before_wrap(); let available = ui.available_rect_before_wrap();
let player = self.state.board.player; let player = self.state.board.player;
let origin = board_origin(available, board_w, board_h, player); let origin = board_origin(available, board_w, board_h, player, font);
draw_board(ui.painter(), origin, &self.state.board); draw_board(ui.painter(), origin, &self.state.board, font);
} }
}); });
// --- Glyph picker dialog --- // --- Glyph picker dialog ---
// Rendered after all panels so it floats on top. // Rendered after all panels so it floats on top.
if self.editor.glyph_picker_open { if self.editor.glyph_picker_open {
let font = self.board_font.as_ref().unwrap_or(&self.default_font);
glyph_picker::show( glyph_picker::show(
ctx, ctx,
&mut self.editor.glyph_picker_open, &mut self.editor.glyph_picker_open,
&mut self.editor.glyph, &mut self.editor.glyph,
&self.state.board.cells, &self.state.board.cells,
font,
); );
} }
// egui repaints automatically on input events, so no explicit
// request_repaint() is needed while the game is purely event-driven.
// Add it back (or call it from game logic) when continuous animation is needed.
} }
} }
+75 -41
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap; use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef};
use serde::Deserialize;
use eframe::egui::Color32; use eframe::egui::Color32;
use crate::game::{Archetype, Board, Glyph, ObjectDef, Player, PortalDef}; use serde::Deserialize;
use std::collections::HashMap;
/// The top-level deserialization type for a `.toml` map file. /// The top-level deserialization type for a `.toml` map file.
/// ///
@@ -15,11 +15,12 @@ pub struct MapFile {
/// The `[map]` section: name, dimensions, player start position. /// The `[map]` section: name, dimensions, player start position.
pub map: MapHeader, pub map: MapHeader,
/// The `[palette]` section: maps single-character keys to tile definitions. /// The `[palette]` section: maps single-character keys to tile definitions.
/// Each entry defines both the visual ([`Glyph`]) and the [`Archetype`]
/// for all cells that use that character in the grid.
pub palette: HashMap<String, PaletteEntry>, pub palette: HashMap<String, PaletteEntry>,
/// The `[grid]` section: the multi-line string that defines cell layout. /// The `[grid]` section: the multi-line string that defines cell layout.
pub grid: GridData, pub grid: GridData,
/// Optional `[font]` section specifying a bitmap font for this board.
#[serde(default)]
pub font: Option<FontHeader>,
/// Any `[[objects]]` entries. Optional; defaults to empty. /// Any `[[objects]]` entries. Optional; defaults to empty.
#[serde(default)] #[serde(default)]
pub objects: Vec<ObjectDef>, pub objects: Vec<ObjectDef>,
@@ -32,40 +33,68 @@ pub struct MapFile {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct MapHeader { pub struct MapHeader {
/// Human-readable name for this board, e.g. `"Opening Room"`. /// Human-readable name for this board, e.g. `"Opening Room"`.
/// Not yet displayed anywhere at runtime.
#[allow(dead_code)] #[allow(dead_code)]
pub name: String, pub name: String,
/// Width of the board in cells. Must match the length of every row in /// Width of the board in cells. Must match the length of every row in `[grid] content`.
/// `[grid] content`.
pub width: usize, pub width: usize,
/// Height of the board in cells. Must match the number of rows in /// Height of the board in cells. Must match the number of rows in `[grid] content`.
/// `[grid] content`.
pub height: usize, pub height: usize,
/// Starting position of the player as `[x, y]` (0-indexed, origin /// Starting position of the player as `[x, y]` (0-indexed, origin top-left).
/// top-left). This field will become optional once the player is a
/// scripted object rather than a hardcoded entity.
pub player_start: [i32; 2], pub player_start: [i32; 2],
} }
/// The `[font]` section of a map file, specifying a bitmap font for this board.
///
/// When absent, the app's default embedded CP437 font is used.
#[derive(Deserialize)]
pub struct FontHeader {
/// Path to the PNG font image, relative to the working directory.
pub path: String,
/// Width of each tile in the font image, in pixels.
pub tile_w: u32,
/// Height of each tile in the font image, in pixels.
pub tile_h: u32,
}
/// A tile index in a palette entry: either a plain integer or a character literal.
///
/// Accepting both forms lets map files use `tile = 32` for new files or
/// `tile = " "` for convenience (the char is converted to its Unicode scalar).
/// This means existing maps that used `ch = "#"` can be migrated by renaming
/// the key to `tile`; the char form keeps working.
#[derive(Deserialize)]
#[serde(untagged)]
enum TileIndex {
/// A direct tile index (e.g. `tile = 35`).
Num(u32),
/// A single-character shorthand (e.g. `tile = "#"`); converted to its Unicode scalar.
Chr(char),
}
impl TileIndex {
fn into_u32(self) -> u32 {
match self {
TileIndex::Num(n) => n,
TileIndex::Chr(c) => c as u32,
}
}
}
/// One entry in the `[palette]` table. /// One entry in the `[palette]` table.
/// ///
/// Each palette entry is keyed by a single character (e.g. `"#"` or `" "`). /// Each entry is keyed by a single character (e.g. `"#"` or `" "`).
/// That character is used in the `[grid]` content string to place tiles. /// That character is used in the `[grid]` content string to place tiles.
/// The entry defines both how the tile looks (`ch`, `fg`, `bg`) and which /// The entry defines both how the tile looks (`tile`, `fg`, `bg`) and which
/// [`Archetype`] it uses for behavior. /// [`Archetype`] it uses for behavior.
/// ///
/// Note that `ch` (the display character) does not have to match the palette /// `tile` accepts either an integer (`tile = 35`) or a char (`tile = "#"`).
/// key. The key is just a label for the grid; `ch` is what actually gets
/// rendered. This allows, for example, using `"W"` as the palette key for a
/// wall tile that displays as `#`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PaletteEntry { pub struct PaletteEntry {
/// The archetype name for this tile, e.g. `"wall"` or `"empty"`. /// The archetype name for this tile, e.g. `"wall"` or `"empty"`.
/// Parsed via [`Archetype::try_from`]; unknown names produce an
/// [`Archetype::ErrorBlock`] cell and a logged warning.
pub archetype: String, pub archetype: String,
/// The character displayed in this cell. /// The tile index into the board's bitmap font.
pub ch: char, /// Accepts either an integer or a single-character string.
tile: TileIndex,
/// Foreground color as an `"#RRGGBB"` hex string. /// Foreground color as an `"#RRGGBB"` hex string.
pub fg: String, pub fg: String,
/// Background color as an `"#RRGGBB"` hex string. /// Background color as an `"#RRGGBB"` hex string.
@@ -76,9 +105,7 @@ pub struct PaletteEntry {
/// ///
/// `content` is a TOML multi-line string. Each line is one row of the board; /// `content` is a TOML multi-line string. Each line is one row of the board;
/// each character in a line is looked up in the palette to determine the /// each character in a line is looked up in the palette to determine the
/// tile for that cell. TOML automatically strips the newline immediately /// tile for that cell.
/// following the opening `"""`, so no special handling is needed for a
/// leading blank line.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct GridData { pub struct GridData {
/// The raw grid content. Split by [`str::lines`] during loading. /// The raw grid content. Split by [`str::lines`] during loading.
@@ -104,13 +131,11 @@ fn parse_color(hex: &str) -> Color32 {
/// ///
/// 1. **Palette pass** — each entry is parsed into a `(Glyph, Archetype)` pair /// 1. **Palette pass** — each entry is parsed into a `(Glyph, Archetype)` pair
/// and stored in a temporary `HashMap` keyed by the palette character. /// and stored in a temporary `HashMap` keyed by the palette character.
/// Unknown archetype names produce an [`Archetype::ErrorBlock`] and a /// Unknown archetype names produce an [`Archetype::ErrorBlock`] and a logged
/// logged warning so malformed maps are visible in-game. /// warning so malformed maps are visible in-game.
/// ///
/// 2. **Grid pass** — `grid.content` is split into lines; each character /// 2. **Grid pass** — `grid.content` is split into lines; each character is
/// is looked up in the palette map and pushed directly into `cells`. /// looked up in the palette map and pushed directly into `cells`.
/// Unknown characters are silently skipped, so a malformed grid will
/// result in a `cells` vec shorter than `width * height`.
impl From<MapFile> for Board { impl From<MapFile> for Board {
fn from(mf: MapFile) -> Self { fn from(mf: MapFile) -> Self {
let w = mf.map.width; let w = mf.map.width;
@@ -119,19 +144,24 @@ impl From<MapFile> for Board {
// Pass 1: build a char → (Glyph, Archetype) lookup from the palette. // Pass 1: build a char → (Glyph, Archetype) lookup from the palette.
let mut palette: HashMap<char, (Glyph, Archetype)> = HashMap::new(); let mut palette: HashMap<char, (Glyph, Archetype)> = HashMap::new();
for (key, entry) in &mf.palette { for (key, entry) in mf.palette {
let key_char = key.chars().next().unwrap_or(' '); let key_char = key.chars().next().unwrap_or(' ');
// Unknown archetype names fall back to ErrorBlock so the error is let tile = entry.tile.into_u32();
// visible in-game rather than silently producing empty tiles.
match Archetype::try_from(entry.archetype.as_str()) { match Archetype::try_from(entry.archetype.as_str()) {
Ok(archetype) => { Ok(archetype) => {
let glyph = Glyph { ch: entry.ch, fg: parse_color(&entry.fg), bg: parse_color(&entry.bg) }; let glyph = Glyph {
tile,
fg: parse_color(&entry.fg),
bg: parse_color(&entry.bg),
};
palette.insert(key_char, (glyph, archetype)); palette.insert(key_char, (glyph, archetype));
} }
Err(e) => { Err(e) => {
// Be sure to log and ignore the default glyph for an unknown archetype
eprintln!("{e}"); eprintln!("{e}");
palette.insert(key_char, (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)); palette.insert(
key_char,
(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock),
);
} }
} }
} }
@@ -146,6 +176,12 @@ impl From<MapFile> for Board {
} }
} }
let font = mf.font.map(|f| FontSpec {
path: f.path,
tile_w: f.tile_w,
tile_h: f.tile_h,
});
Board { Board {
width: w, width: w,
height: h, height: h,
@@ -156,6 +192,7 @@ impl From<MapFile> for Board {
}, },
objects: mf.objects, objects: mf.objects,
portals: mf.portals, portals: mf.portals,
font,
} }
} }
} }
@@ -165,9 +202,6 @@ impl From<MapFile> for Board {
/// Reads the file at `path`, deserializes it as TOML into a [`MapFile`], /// Reads the file at `path`, deserializes it as TOML into a [`MapFile`],
/// then converts it via [`From<MapFile>`]. Propagates both I/O errors and /// then converts it via [`From<MapFile>`]. Propagates both I/O errors and
/// TOML parse errors through the `Box<dyn Error>` return. /// TOML parse errors through the `Box<dyn Error>` return.
///
/// Called from `main()` before the window is created so that board
/// dimensions are available for window sizing.
pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>> { pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?; let content = std::fs::read_to_string(path)?;
let map_file: MapFile = toml::from_str(&content)?; let map_file: MapFile = toml::from_str(&content)?;
+64 -40
View File
@@ -1,11 +1,11 @@
// Layout constants — which value controls which part of the window: // Layout constants — which value controls which part of the window:
// //
// One board cell: // One board cell (size varies with the active font's tile dimensions):
// //
// ◄── CELL_W (14 px) ──► // ◄── tile_w ──►
// ╔════════════════════╗ ▲ // ╔═════════════╗ ▲
// ║ @ ║ │ CELL_H (20 px) // ║ @ ║ │ tile_h
// ╚════════════════════╝ ▼ // ╚═════════════╝ ▼
// //
// Full window (Edit mode shown; side panel absent in Play mode): // Full window (Edit mode shown; side panel absent in Play mode):
// //
@@ -16,7 +16,7 @@
// │ PANEL_MARGIN (8 px) on all sides │ PALETTE_ │ ▲ // │ PANEL_MARGIN (8 px) on all sides │ PALETTE_ │ ▲
// │ ┌──────────────────────────────────────────┐ │ PANEL_W │ │ // │ ┌──────────────────────────────────────────┐ │ PANEL_W │ │
// │ │ │ │ (200 px) │ │ DEFAULT_ // │ │ │ │ (200 px) │ │ DEFAULT_
// │ │ board cells (CELL_W × CELL_H each) │ │ Edit mode │ │ WINDOW_H // │ │ board cells (tile_w × tile_h each) │ │ Edit mode │ │ WINDOW_H
// │ │ │ │ only │ │ (524 px) // │ │ │ │ only │ │ (524 px)
// │ └──────────────────────────────────────────┘ │ │ │ // │ └──────────────────────────────────────────┘ │ │ │
// │ │ │ ▼ // │ │ │ ▼
@@ -25,15 +25,11 @@
// MIN_WINDOW_W and MIN_WINDOW_H are derived: 10 cells wide / 5 cells tall plus margins. // MIN_WINDOW_W and MIN_WINDOW_H are derived: 10 cells wide / 5 cells tall plus margins.
use eframe::egui; use eframe::egui;
use egui::{Align2, FontId, Pos2, Rect, vec2}; use egui::{Pos2, Rect, vec2};
use crate::font::BitmapFont;
use crate::game::{Board, Glyph, Player}; use crate::game::{Board, Glyph, Player};
/// Width of one board cell in pixels.
pub(crate) const CELL_W: f32 = 14.0;
/// Height of one board cell in pixels.
pub(crate) const CELL_H: f32 = 20.0;
/// Approximate height of the egui menu bar in pixels, used for window sizing. /// Approximate height of the egui menu bar in pixels, used for window sizing.
pub(crate) const MENU_H: f32 = 24.0; pub(crate) const MENU_H: f32 = 24.0;
/// Default inner margin that egui's CentralPanel adds on all sides, in pixels. /// Default inner margin that egui's CentralPanel adds on all sides, in pixels.
@@ -44,53 +40,73 @@ pub(crate) const PALETTE_PANEL_W: f32 = 200.0;
pub(crate) const DEFAULT_WINDOW_W: f32 = 840.0; pub(crate) const DEFAULT_WINDOW_W: f32 = 840.0;
/// Default window height in pixels. Independent of map size. /// Default window height in pixels. Independent of map size.
pub(crate) const DEFAULT_WINDOW_H: f32 = 524.0; pub(crate) const DEFAULT_WINDOW_H: f32 = 524.0;
/// Minimum window width: enough room for 10 cells plus margins. /// Minimum window width: enough room for 10 cells plus margins (using 8 px tile width).
pub(crate) const MIN_WINDOW_W: f32 = CELL_W * 10.0 + 2.0 * PANEL_MARGIN; pub(crate) const MIN_WINDOW_W: f32 = 8.0 * 10.0 + 2.0 * PANEL_MARGIN;
/// Minimum window height: enough room for 5 cells plus menu and margins. /// Minimum window height: enough room for 5 cells plus menu and margins (using 8 px tile height).
pub(crate) const MIN_WINDOW_H: f32 = CELL_H * 5.0 + MENU_H + 2.0 * PANEL_MARGIN; pub(crate) const MIN_WINDOW_H: f32 = 8.0 * 5.0 + MENU_H + 2.0 * PANEL_MARGIN;
/// Paints a glyph into an already-allocated `rect`. /// Paints a glyph into an already-allocated `rect` using a bitmap font.
/// ///
/// Unlike [`draw_glyph`], this does not compute the rect from grid coordinates — /// Fills `rect` with `glyph.bg`, then draws the tile from `font` tinted with
/// the caller owns the rect (typically from [`egui::Ui::allocate_exact_size`]) /// `glyph.fg`. Unlike [`draw_glyph`], the caller owns the rect and handles
/// and handles interaction. Use this when you need the click [`egui::Response`]. /// interaction — use this when you need the click [`egui::Response`].
pub(crate) fn paint_glyph(painter: &egui::Painter, rect: Rect, glyph: &Glyph) { pub(crate) fn paint_glyph(painter: &egui::Painter, rect: Rect, glyph: &Glyph, font: &BitmapFont) {
painter.rect_filled(rect, 0.0, glyph.bg); painter.rect_filled(rect, 0.0, glyph.bg);
painter.text(rect.center(), Align2::CENTER_CENTER, glyph.ch, FontId::monospace(CELL_H), glyph.fg); let uv = font.tile_uv(glyph.tile);
painter.image(font.texture.id(), rect, uv, glyph.fg);
} }
/// Draws a single cell at grid position `(x, y)` using the given glyph. /// Draws a single cell at grid position `(x, y)` using the given glyph and font.
/// ///
/// `origin` is the pixel position of cell `(0, 0)` — the top-left corner of /// `origin` is the pixel position of cell `(0, 0)` — the top-left corner of
/// the board within the panel. Cell positions are computed from `origin` using /// the board within the panel. Cell positions are computed from `origin` using
/// [`CELL_W`] and [`CELL_H`]. /// the font's tile dimensions.
pub(crate) fn draw_glyph(painter: &egui::Painter, origin: Pos2, x: usize, y: usize, glyph: &Glyph) { pub(crate) fn draw_glyph(
let tl = origin + vec2(x as f32 * CELL_W, y as f32 * CELL_H); painter: &egui::Painter,
let rect = Rect::from_min_size(tl, vec2(CELL_W, CELL_H)); origin: Pos2,
paint_glyph(painter, rect, glyph); x: usize,
y: usize,
glyph: &Glyph,
font: &BitmapFont,
) {
let tw = font.tile_w as f32;
let th = font.tile_h as f32;
let tl = origin + vec2(x as f32 * tw, y as f32 * th);
let rect = Rect::from_min_size(tl, vec2(tw, th));
paint_glyph(painter, rect, glyph, font);
} }
/// Draws all board cells then the player overlay at `origin`. /// Draws all board cells then the player overlay at `origin`.
pub(crate) fn draw_board(painter: &egui::Painter, origin: Pos2, board: &Board) { pub(crate) fn draw_board(painter: &egui::Painter, origin: Pos2, board: &Board, font: &BitmapFont) {
for y in 0..board.height { for y in 0..board.height {
for x in 0..board.width { for x in 0..board.width {
let (glyph, _) = board.get(x, y); let (glyph, _) = board.get(x, y);
draw_glyph(painter, origin, x, y, glyph); draw_glyph(painter, origin, x, y, glyph, font);
} }
} }
// The player is not a board cell; it is rendered on top as an overlay. // The player is not a board cell; it is rendered on top as an overlay.
// This separate draw will be removed once the player becomes a scripted object. // This separate draw will be removed once the player becomes a scripted object.
let player_glyph = Glyph::player(); let player_glyph = Glyph::player();
draw_glyph(painter, origin, board.player.x as usize, board.player.y as usize, &player_glyph); draw_glyph(
painter,
origin,
board.player.x as usize,
board.player.y as usize,
&player_glyph,
font,
);
} }
/// Converts a pixel position to cell coordinates relative to `origin`. /// Converts a pixel position to cell coordinates relative to `origin`.
/// ///
/// Uses floor so positions above or left of `origin` produce negative values — /// Uses floor so positions above or left of `origin` produce negative values —
/// callers must guard `>= 0` before treating the result as a valid cell index. /// callers must guard `>= 0` before treating the result as a valid cell index.
pub(crate) fn pos_to_cell(origin: Pos2, pos: Pos2) -> (i32, i32) { pub(crate) fn pos_to_cell(origin: Pos2, pos: Pos2, tile_w: f32, tile_h: f32) -> (i32, i32) {
let rel = pos - origin; let rel = pos - origin;
((rel.x / CELL_W).floor() as i32, (rel.y / CELL_H).floor() as i32) (
(rel.x / tile_w).floor() as i32,
(rel.y / tile_h).floor() as i32,
)
} }
/// Computes the pixel position of board cell (0, 0) within `available`. /// Computes the pixel position of board cell (0, 0) within `available`.
@@ -98,25 +114,33 @@ pub(crate) fn pos_to_cell(origin: Pos2, pos: Pos2) -> (i32, i32) {
/// If the board fits along an axis, it is centered. If it overflows, the /// 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 /// viewport is centered on the player and clamped so no empty space appears
/// at the board edges. /// at the board edges.
pub(crate) fn board_origin(available: Rect, board_w: usize, board_h: usize, player: Player) -> Pos2 { pub(crate) fn board_origin(
let board_px_w = board_w as f32 * CELL_W; available: Rect,
let board_px_h = board_h as f32 * CELL_H; board_w: usize,
board_h: usize,
player: Player,
font: &BitmapFont,
) -> Pos2 {
let tw = font.tile_w as f32;
let th = font.tile_h as f32;
let board_px_w = board_w as f32 * tw;
let board_px_h = board_h as f32 * th;
let origin_x = if board_px_w <= available.width() { let origin_x = if board_px_w <= available.width() {
// Board fits: center it horizontally. // Board fits: center it horizontally.
available.min.x + (available.width() - board_px_w) / 2.0 available.min.x + (available.width() - board_px_w) / 2.0
} else { } else {
// Board overflows: keep player at viewport center, clamp to board edges. // Board overflows: keep player at viewport center, clamp to board edges.
let player_px_x = player.x as f32 * CELL_W + CELL_W / 2.0; let player_px_x = player.x as f32 * tw + tw / 2.0;
(available.center().x - player_px_x) (available.center().x - player_px_x)
.max(available.max.x - board_px_w) // right edge must reach viewport right .max(available.max.x - board_px_w) // right edge must reach viewport right
.min(available.min.x) // left edge must reach viewport left .min(available.min.x) // left edge must reach viewport left
}; };
let origin_y = if board_px_h <= available.height() { let origin_y = if board_px_h <= available.height() {
available.min.y + (available.height() - board_px_h) / 2.0 available.min.y + (available.height() - board_px_h) / 2.0
} else { } else {
let player_px_y = player.y as f32 * CELL_H + CELL_H / 2.0; let player_px_y = player.y as f32 * th + th / 2.0;
(available.center().y - player_px_y) (available.center().y - player_px_y)
.max(available.max.y - board_px_h) .max(available.max.y - board_px_h)
.min(available.min.y) .min(available.min.y)