From 212297ecf9b44be45106ccd7f9f0359227a4fbc2 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Tue, 19 May 2026 00:07:04 -0500 Subject: [PATCH] Add bitmap font rendering via PNG tilesheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 99 ++++++++++++--- CLAUDE.md | 62 ++++++--- Cargo.lock | 173 ++++++++++++++++++++++--- Cargo.toml | 2 + assets/vga-font-8x16.png | Bin 0 -> 1846 bytes maps/start.toml | 6 +- src/editor.rs | 76 ++++++++--- src/font.rs | 263 +++++++++++++++++++++++++++++++++++++++ src/font_dialog.rs | 180 +++++++++++++++++++++++++++ src/game.rs | 105 +++++++++++----- src/glyph_picker.rs | 123 +++++++++++------- src/main.rs | 125 ++++++++++++++----- src/map_file.rs | 116 +++++++++++------ src/render.rs | 104 ++++++++++------ 14 files changed, 1174 insertions(+), 260 deletions(-) create mode 100644 assets/vga-font-8x16.png create mode 100644 src/font.rs create mode 100644 src/font_dialog.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ac281fa..fecaec6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,19 +27,26 @@ src/ main.rs — App, AppMode, frame loop (input → menu → panels → board → dialogs) game.rs — core data types and game logic 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 - glyph_picker.rs — floating Glyph picker dialog; decoupled (open, glyph) interface + glyph_picker.rs — floating Glyph picker dialog; decoupled (open, glyph, font) interface maps/ 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 The types here are the runtime representation of a game world. They are deliberately free of any file-loading or rendering concerns. -**`Glyph`** (`Copy`) -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. +**`Glyph`** (`Copy`, `Eq`, `Hash`) +The visual representation of one cell: a `tile: u32` index into the active bitmap font, a foreground color, and a background color. Stored *per cell* (not per element type) so individual cells can animate or vary their appearance independently — e.g. a "fire" element where each flame tile has a slightly different color — without changing their behavior. Derives `Hash` so the glyph picker can deduplicate the board palette into a `HashSet`. + +**`FontSpec`** +Optional per-board font override: `path: String`, `tile_w: u32`, `tile_h: u32`. Stored on `Board.font`. When `None`, `App`'s `default_font` is used. The editor's Board tab exposes a "Font…" button to change this at runtime. **`Behavior`** A plain data struct of runtime behavioral properties — currently `passable: bool` and `opaque: bool`. Returned by `Archetype::behavior()`. Adding a new property (e.g. `shootable`) requires only a new field here; no match arms needed at call sites. This is a deliberate improvement over storing behavior as a bag of booleans on a per-cell or per-archetype basis. @@ -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 - `objects: Vec` — scripted objects (parsed, not yet runtime-wired) - `portals: Vec` — exits to other boards (parsed, not yet runtime-wired) +- `font: Option` — per-board font override; `None` means use the app default **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` 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` loaded when the user clicks "Load". Separating dialog state from `FontSpec` means the user can explore settings without committing. + +**`show(ctx, open, state, font_spec)`** renders a floating window with: +- A text field + "Browse…" button (`rfd::FileDialog`) +- `DragValue` controls for tile dimensions +- A "Load" button that decodes the PNG and stores it in `state.preview` +- A scrollable preview of the full tilesheet with grid overlay at tile boundaries +- "Apply" (enabled only after a successful Load) writes to `font_spec` and closes; "Use default" clears `font_spec` + +Uses a `should_close: bool` flag outside the closure to work around egui's `.open(open)` borrow conflict. + +--- + ### `map_file.rs` — file loading **`MapFile`** and supporting structs are serde deserialization types only — they exist solely to parse TOML and are never used at runtime. +**`TileIndex`** is a `#[serde(untagged)]` enum (`Num(u32)` | `Chr(char)`) that accepts either an integer (`tile = 35`) or a single-character string (`tile = "#"`) in palette entries. This preserves backward compatibility with existing map files while allowing numeric indices in new ones. + +**`FontHeader`** deserializes the optional `[font]` section and is converted to `FontSpec` in `From for Board`. + **`impl From 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`** — 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 -`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` (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** 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_picker_open: bool` — whether the glyph picker dialog is visible - `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 -**`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: -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`), shown as clickable cells rendered with `paint_glyph`. The current glyph is highlighted with a white stroke. 2. **Colors** — `color_edit_button_srgba` pickers for `glyph.fg` and `glyph.bg`. -3. **Character grid** — 16 × 6 grid of printable ASCII (0x20–0x7E). 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. +**Keep this example in sync with `map_file.rs` whenever the format changes.** + ```toml [map] name = "Room Name" @@ -161,9 +213,15 @@ width = 60 height = 25 player_start = [30, 12] +# Optional: override the default font for this board. +[font] +path = "assets/my_font.png" +tile_w = 8 +tile_h = 16 + [palette] -" " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" } -"#" = { archetype = "wall", ch = "#", fg = "#808080", bg = "#606060" } +" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" } +"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" } [grid] content = """ @@ -189,6 +247,9 @@ target_entry = "west_door" **Why TOML over a custom format:** The `toml` crate gives us deserialization with minimal code. Multi-line strings for the grid give a visual representation of the map. Embedded Rhai scripts fit naturally in TOML multi-line strings without escaping issues. +**Why `tile` accepts both char and integer:** +`TileIndex` is a `#[serde(untagged)]` enum. Old map files using `tile = " "` (a single-character string) continue to work unchanged. New map files can use `tile = 32` (integer) for clarity or when the tile has no printable character equivalent. + **Why archetypes by name, not by property:** Palette entries used to store `passable = true/false` directly. Switching to `archetype = "wall"` means adding a new behavioral property (e.g. `opaque`) only requires a code change — existing map files don't need to be updated. It also makes files more readable: `archetype = "wall"` is self-documenting. Unknown names produce a visible `ErrorBlock` cell rather than silently defaulting. @@ -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. -**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. diff --git a/CLAUDE.md b/CLAUDE.md index 5989dc0..fa77689 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,46 +45,68 @@ The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI. ### Modules **`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. - `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`). -- `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`, `portals: Vec`. `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`, `portals: Vec`, `font: Option`. `cells` is `pub(crate)`. - `Player` — `x: i32, y: i32` - `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 +**`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` +- `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: - `MapFile` and friends — serde `Deserialize` types for TOML map files -- `impl From 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 for Board` — converts a parsed file into a ready-to-use `Board`; unknown archetype names produce `ErrorBlock` - `pub fn load(path: &str) -> Result>` — reads and converts a `.toml` map file **`src/main.rs`** — app entry point and frame loop: - `AppMode` enum (`Play` | `Edit`) — gates arrow-key input; toggles the side panel and viewport mode -- `App` holds `GameState`, `AppMode`, 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` +- `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 - **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: -- `CELL_W = 14.0`, `CELL_H = 20.0` and window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`) -- `draw_glyph(painter, origin, x, y, glyph)` — filled rect (bg) + centered monospace char (fg) -- `draw_board(painter, origin, board)` — draws all cells then player overlay -- `board_origin(available, board_w, board_h, player)` — centers board or clamps to player with no empty space -- `pos_to_cell(origin, pos) -> (i32, i32)` — pixel → cell coordinates via floor division; negatives signal out-of-bounds +**`src/render.rs`** — cell rendering and drawing primitives: +- Window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`); no fixed `CELL_W/H` — cell size comes from the active `BitmapFont` +- `paint_glyph(painter, rect, glyph, font)` — `rect_filled` with `glyph.bg`, then `painter.image` tinted by `glyph.fg` +- `draw_glyph(painter, origin, x, y, glyph, font)` — sizes cell from `font.tile_w/tile_h`, delegates to `paint_glyph` +- `draw_board(painter, origin, board, font)` — draws all cells then player overlay +- `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: - `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 -- `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 +- `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, 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: -- `show(ctx, open, glyph, board_cells)` — 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 +- `show(ctx, open, glyph, board_cells, font)` — takes `open: &mut bool` and `glyph: &mut Glyph` directly; no dependency on `EditorState` +- Three sections: board palette (unique glyphs deduped via `HashSet`, 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`) 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 [map] name = "Room Name" @@ -92,9 +114,15 @@ width = 60 height = 25 player_start = [30, 12] +# Optional: override the default font for this board. +[font] +path = "assets/my_font.png" +tile_w = 8 +tile_h = 16 + [palette] -" " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" } -"#" = { archetype = "wall", ch = "#", fg = "#808080", bg = "#606060" } +" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" } +"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" } [grid] content = """ @@ -117,7 +145,7 @@ target_map = "cave" 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 diff --git a/Cargo.lock b/Cargo.lock index 40bcf6f..e06b763 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,28 @@ dependencies = [ "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]] name = "async-broadcast" version = "0.7.2" @@ -253,6 +275,17 @@ dependencies = [ "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]] name = "async-io" version = "2.6.0" @@ -282,6 +315,17 @@ dependencies = [ "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]] name = "async-process" version = "2.5.0" @@ -450,6 +494,15 @@ dependencies = [ "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]] name = "blocking" version = "1.6.2" @@ -745,6 +798,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.1", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1099,6 +1154,15 @@ dependencies = [ "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]] name = "futures-core" version = "0.3.32" @@ -1148,8 +1212,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", "futures-macro", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1643,6 +1709,8 @@ name = "kiln" version = "0.1.0" dependencies = [ "eframe", + "image", + "rfd", "rhai", "serde", "toml", @@ -1930,7 +1998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -1946,6 +2014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.1", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -1959,7 +2028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -1971,7 +2040,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -1983,7 +2052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2018,7 +2087,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2030,7 +2099,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -2049,7 +2118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -2083,7 +2152,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -2096,7 +2165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2108,7 +2177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2131,7 +2200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -2151,7 +2220,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2163,7 +2232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -2333,6 +2402,12 @@ dependencies = [ "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]] name = "portable-atomic" version = "1.13.1" @@ -2357,6 +2432,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" @@ -2440,6 +2524,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "range-alloc" version = "0.1.5" @@ -2485,6 +2598,30 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "rhai" version = "1.24.0" @@ -3149,8 +3286,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3990,7 +4134,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.11.1", - "block2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases", @@ -4437,6 +4581,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 1.0.3", "zvariant_derive", "zvariant_utils", diff --git a/Cargo.toml b/Cargo.toml index 448ac1a..68f38d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,5 @@ eframe = "0.33.3" rhai = "1" serde = { version = "1", features = ["derive"] } toml = "0.8" +image = { version = "0.25", default-features = false, features = ["png"] } +rfd = "0.15" diff --git a/assets/vga-font-8x16.png b/assets/vga-font-8x16.png new file mode 100644 index 0000000000000000000000000000000000000000..336b7b4d064885d508741ebc1f05be2989daf306 GIT binary patch literal 1846 zcmV-62g&$}P)DM!yhWvfWR~sMok2sLU zagYX?OtQ)GTUVq%x_Soql~RL+58xPLR-&;~ zC=^C07eGpdCIYx@a%Ts0;$F zlW#{+-pvm`3S8Ij*d4p#x~^+QdGc9=ow+pnn~8~0yvMUsnUUh9;-zA;y55>?mJr1( z7sF2HVq#JQzylVkQD6)~i3mlB00sI1oV$R=RYric^anu$6V%pPKZ6V|1KM(0WgZZi z|AHVGt^))C8w8&wb|iH`PzTy=A8IXV17Qd-j==^XA`o3RLXmJ&gT+k1yL=6Z4fs$= zCsij6-F-HK5^351w%f_KqhQR<&pe7;*ADDQcHY(|-F%c6`GeS1cMtc+V)x9!MdQ1P z@q+I!t}v2yPGGVwm36Z_rzOpRoSFtGqcTZZD&;-tFQxPn@}S+jVD^Zv31*~i*qv$B z98mbuv%P80ZZs^{bt8a`;6|%x6zAW5X}zs*{Da~LZ#%^-u*fGD{3m1uQ(;&(rwBl0 z4Ipp;t-&w;EMKA|I18-ov>UGV{M*YztJ{9M(-#3KD9O?;;Euv-CYuykQ?N8xU8>IC zp4FMv1FD0!=c|vFyvwD(n@_m#+=ALI$&`EfkZ9dT)b??t>>kcQl3&4acc#5>$+r`8 zZ#fd6YLrbh%E+>tm7q~J%DApHX%C2eL(MdrSu$?Z(wk!qgEum6^UbC^Hb&O^^kp%c zmD#M{AUozeqrO&O;K$i9|C~Q&yLua*^}^6SBBmM9NV=|TmGf7da6Z!@Ra3(=z&Q(k34g^IyjBN=5FCcz`db>zDPW_QuqW<(^4v$K?4$)NX8|YK zi;45*bFxG0G2+8yu$naWI;CyEDh`O%Bq~isNuskOM&4B*-CR;aAgqE8aC53$g^>OT z1_`%U>?;-B`LzSz60luIrVjZGGlzaTP^O=EUpfDolrjgo9J!n%En6zS<#y^&qX4g+ zcXq86Qk8OAH?jtRgon4R-2g)xhUF%G!F~$X{UrA!irQ^)79fB!7&-k$?%^$qk&%KqQlKD2|44E++n|6w!Gpx!Ykv~ehR~UF$@nt?lkcu4q$R8U?t>$Sg=R-YCuXP zQs#2x$dQvd$VtdaNlb7o{{Bza{}`Z;)Tw)MD!#W#41FhIb2ng%t*?%zwo*q^q+n|c zhI)X$&A2b#>`NW%i=XzzPvei{_-J3>sgF0;PZC2)e4ZNGN)2tr_u|y4ICUzO8hZWS z=UY!-AL`%wAG`T~1sp6pE{#Jqm~d=u3IsDzZ)zA$05AY#;n|G|5S*(M0TnZQoFhN5 z!BAiBskxAti0W9=PPXK8#3)A6S3xGXhG8S72`@VpEWJdlG6RB^d Self { Self { selected: Archetype::Wall, glyph: Archetype::Wall.default_glyph(), glyph_picker_open: false, + font_dialog_open: false, + font_dialog_state: FontDialogState::from_spec(None), tab: EditorTab::Palette, } } @@ -41,17 +49,26 @@ impl EditorState { /// Renders the editor side panel (must be called before `CentralPanel`). /// -/// Shows tab bar (Palette / Board / World) and, on the Palette tab, the -/// scrollable archetype list and current-glyph preview button. -pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState) { +/// Shows tab bar (Palette / Board / World) and the content for the active tab. +/// `font_spec` is the board's current font override (may be `None` for default). +/// `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, + active_font: &BitmapFont, +) { + let tw = active_font.tile_w as f32; + let th = active_font.tile_h as f32; + egui::SidePanel::right("palette_panel") .resizable(true) .default_width(PALETTE_PANEL_W) .show(ctx, |ui| { ui.horizontal(|ui| { 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::World, "World"); + ui.selectable_value(&mut editor.tab, EditorTab::Board, "Board"); + ui.selectable_value(&mut editor.tab, EditorTab::World, "World"); }); ui.separator(); @@ -60,7 +77,7 @@ pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState) { ui.label("Element"); // 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() .max_height(row_h * 5.0) .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 glyph = archetype.default_glyph(); ui.horizontal(|ui| { - let (r, cell_resp) = ui.allocate_exact_size( - vec2(CELL_W, CELL_H), Sense::click()); - paint_glyph(ui.painter(), r, &glyph); + let (r, cell_resp) = + ui.allocate_exact_size(vec2(tw, th), Sense::click()); + paint_glyph(ui.painter(), r, &glyph, active_font); let label_resp = ui.selectable_label(is_selected, archetype.name()); if cell_resp.clicked() || label_resp.clicked() { 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"); // Paint a preview cell showing the current glyph, sized like a board cell. let (cell_rect, glyph_btn) = - ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click()); - paint_glyph(ui.painter(), cell_rect, &editor.glyph); + ui.allocate_exact_size(vec2(tw, th), Sense::click()); + paint_glyph(ui.painter(), cell_rect, &editor.glyph, active_font); if glyph_btn.clicked() { 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 => {} } }); -} + // 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, + ); + } +} diff --git a/src/font.rs b/src/font.rs new file mode 100644 index 0000000..f80681a --- /dev/null +++ b/src/font.rs @@ -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> { + 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> { + 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> { + 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 = 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.0–1.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 1–6). + 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)); + } +} diff --git a/src/font_dialog.rs b/src/font_dialog.rs new file mode 100644 index 0000000..6fd17ea --- /dev/null +++ b/src/font_dialog.rs @@ -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, +} + +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, +) { + 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; + } +} diff --git a/src/game.rs b/src/game.rs index b951ba8..566716b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -3,36 +3,55 @@ use serde::Deserialize; /// The visual representation of a single board cell. /// -/// `Glyph` holds everything needed to draw one cell on screen: which character -/// to display and what colors to use. It is stored per-cell (not per archetype), -/// so individual cells can vary their appearance independently — for example, -/// a field of "fire" tiles where each flame has a slightly different color -/// while all sharing the same [`Archetype`] behavior. +/// `Glyph` holds everything needed to draw one cell on screen: which tile +/// index to display and what colors to use. It is stored per-cell (not per +/// archetype), so individual cells can vary their appearance independently. +/// +/// `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. /// The player is the only entity whose glyph is hardcoded at runtime /// (see [`Glyph::player`]). -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct Glyph { - /// The character to display in this cell. - pub ch: char, - /// Foreground (text) color. + /// Tile index into the board's bitmap font (left-to-right, top-to-bottom). + pub tile: u32, + /// Foreground color, applied to non-background pixels of the tile. 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, } 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 /// file palette. It will be removed once the player becomes a scripted /// object with its own palette entry. - pub fn player() -> Self { - Self { ch: '@', fg: Color32::LIGHT_BLUE, bg: Color32::BLACK } + pub const fn player() -> Self { + 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. /// /// `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. pub fn behavior(&self) -> Behavior { match self { - Archetype::Empty => Behavior { passable: true, opaque: false }, - Archetype::Wall => Behavior { passable: false, opaque: true }, - Archetype::Object => Behavior { passable: false, opaque: false }, - Archetype::ErrorBlock => Behavior { passable: false, opaque: true }, + Archetype::Empty => Behavior { + passable: true, + opaque: false, + }, + 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. pub fn name(&self) -> &'static str { match self { - Archetype::Empty => "empty", - Archetype::Wall => "wall", - Archetype::Object => "object", + Archetype::Empty => "empty", + Archetype::Wall => "wall", + Archetype::Object => "object", Archetype::ErrorBlock => "error_block", } } @@ -110,11 +141,27 @@ impl Archetype { /// cells retain their own per-cell glyph. pub fn default_glyph(&self) -> Glyph { match self { - Archetype::Empty => Glyph { ch: ' ', fg: Color32::BLACK, bg: Color32::BLACK }, - Archetype::Wall => Glyph { ch: '#', fg: Color32::from_rgb(0x80, 0x80, 0x80), bg: Color32::from_rgb(0x60, 0x60, 0x60) }, - Archetype::Object => Glyph { ch: '?', fg: Color32::YELLOW, bg: Color32::BLACK }, + Archetype::Empty => Glyph { + tile: 32, + 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. - 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. fn try_from(name: &str) -> Result { match name { - "empty" => Ok(Archetype::Empty), - "wall" => Ok(Archetype::Wall), + "empty" => Ok(Archetype::Empty), + "wall" => Ok(Archetype::Wall), "object" => Ok(Archetype::Object), - _ => Err(format!("unknown archetype: {name}")), + _ => Err(format!("unknown archetype: {name}")), } } } @@ -230,6 +277,8 @@ pub struct Board { pub objects: Vec, /// Portals on this board. Parsed from the map file; not yet active. pub portals: Vec, + /// Optional font override for this board. When `None`, the app default is used. + pub font: Option, } impl Board { @@ -295,4 +344,4 @@ impl GameState { } } } -} \ No newline at end of file +} diff --git a/src/glyph_picker.rs b/src/glyph_picker.rs index 07fd07f..ed33ad2 100644 --- a/src/glyph_picker.rs +++ b/src/glyph_picker.rs @@ -1,32 +1,38 @@ +use std::collections::HashSet; + use eframe::egui; use egui::{Color32, Rect, Sense, Stroke, vec2}; +use crate::font::BitmapFont; 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. /// /// 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`. pub(crate) fn show( ctx: &egui::Context, open: &mut bool, glyph: &mut Glyph, board_cells: &[(Glyph, Archetype)], + font: &BitmapFont, ) { // Collect unique glyphs from the board before the window closure borrows // `glyph` mutably, to avoid a borrow conflict with `board_cells`. let board_glyphs: Vec = { - let mut seen: Vec = Vec::new(); - for (g, _) in board_cells { - if !seen.contains(g) { - seen.push(*g); - } - } - seen + let mut seen: HashSet = HashSet::new(); + board_cells + .iter() + .map(|(g, _)| *g) + .filter(|g| seen.insert(*g)) + .collect() }; + let tw = font.tile_w as f32; + let th = font.tile_h as f32; + egui::Window::new("Glyph") .collapsible(false) .resizable(false) @@ -35,13 +41,16 @@ pub(crate) fn show( ui.label("Board palette"); ui.horizontal_wrapped(|ui| { for &g in &board_glyphs { - let (r, resp) = - ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(tw, th), Sense::click()); let p = ui.painter(); - paint_glyph(p, r, &g); + paint_glyph(p, r, &g, font); if g == *glyph { - p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), - egui::epaint::StrokeKind::Middle); + p.rect_stroke( + r, + 0.0, + Stroke::new(1.0, Color32::WHITE), + egui::epaint::StrokeKind::Middle, + ); } if resp.clicked() { *glyph = g; @@ -55,45 +64,67 @@ pub(crate) fn show( ui.horizontal(|ui| { ui.label("FG"); 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"); 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(); - // 16 columns × 6 rows covering printable ASCII 0x20–0x7E. - ui.label("Character"); - const CHAR_COLS: usize = 16; - let chars: Vec = - (0x20u32..=0x7eu32).filter_map(char::from_u32).collect(); - let rows = chars.len().div_ceil(CHAR_COLS); - let grid_size = vec2(CHAR_COLS as f32 * CELL_W, rows as f32 * CELL_H); - let (grid_rect, grid_resp) = - ui.allocate_exact_size(grid_size, Sense::click()); - { - let p = ui.painter(); - for (i, &ch) in chars.iter().enumerate() { - let col = i % CHAR_COLS; - let row = i / CHAR_COLS; - let tl = grid_rect.min - + vec2(col as f32 * CELL_W, row as f32 * CELL_H); - let cell = Rect::from_min_size(tl, vec2(CELL_W, CELL_H)); - paint_glyph(p, cell, &Glyph { ch, fg: glyph.fg, bg: glyph.bg }); - if ch == glyph.ch { - p.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::WHITE), - egui::epaint::StrokeKind::Middle); + // Full tile grid: column count matches the font image layout so the + // picker visually mirrors the tilesheet. + ui.label("Tile"); + let tile_cols = font.tile_cols() as usize; + let tile_count = font.tile_count() as usize; + let rows = tile_count.div_ceil(tile_cols); + let grid_size = vec2(tile_cols as f32 * tw, rows as f32 * th); + + egui::ScrollArea::vertical() + .max_height(th * 16.0) + .id_salt("tile_grid") + .show(ui, |ui| { + let (grid_rect, grid_resp) = ui.allocate_exact_size(grid_size, Sense::click()); + { + let p = ui.painter(); + for i in 0..tile_count { + let col = i % tile_cols; + let row = i / tile_cols; + let tl = grid_rect.min + vec2(col as f32 * tw, row as f32 * th); + let cell = Rect::from_min_size(tl, vec2(tw, th)); + 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() { - let (col, row) = pos_to_cell(grid_rect.min, pos); - let idx = row as usize * CHAR_COLS + col as usize; - if let Some(&ch) = chars.get(idx) { - glyph.ch = ch; + if grid_resp.clicked() + && let Some(pos) = grid_resp.interact_pointer_pos() + { + let (col, row) = pos_to_cell(grid_rect.min, pos, tw, th); + if col >= 0 && row >= 0 { + let idx = row as usize * tile_cols + col as usize; + if idx < tile_count { + glyph.tile = idx as u32; + } + } } - } + }); }); } diff --git a/src/main.rs b/src/main.rs index 9cddaec..603f20d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,21 @@ use eframe::egui; use egui::{Key, Sense, vec2}; +use std::path::Path; mod editor; +mod font; +mod font_dialog; mod game; mod glyph_picker; mod map_file; mod render; use editor::{EditorState, show_editor_panel}; -use game::GameState; +use font::BitmapFont; +use game::{FontSpec, GameState}; use render::{ - DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W, - board_origin, draw_board, pos_to_cell, CELL_H, CELL_W, + DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W, board_origin, draw_board, + pos_to_cell, }; /// Whether the application is in play mode or edit mode. @@ -39,7 +43,7 @@ fn main() -> eframe::Result<()> { eframe::run_native( "Kiln", 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, mode: AppMode, 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, } impl App { /// 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 { state: GameState::new(board), mode: AppMode::Play, 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 { @@ -70,10 +104,18 @@ impl eframe::App for App { // Arrow keys move the player only in Play mode. if self.mode == AppMode::Play { ctx.input(|i| { - if i.key_pressed(Key::ArrowUp) { self.state.try_move( 0, -1); } - 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); } + if i.key_pressed(Key::ArrowUp) { + self.state.try_move(0, -1); + } + 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 --- // 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 { - 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 --- - // The board is drawn using the egui Painter API (direct 2D drawing), - // not egui widgets. This gives pixel-level control over placement. + // The board is drawn using the egui Painter API (direct 2D drawing). egui::CentralPanel::default().show(ctx, |ui| { let board_w = self.state.board.width; 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 { - // Edit mode: ScrollArea lets the user scroll when the board - // overflows the viewport, showing scroll bars as needed. + // Edit mode: ScrollArea lets the user scroll when the board overflows. egui::ScrollArea::new([true, true]).show(ui, |ui| { 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() - && 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. - if cx >= 0 && cy >= 0 { - let cx = cx as usize; - let cy = cy as usize; - let board = &mut self.state.board; - if cx < board.width && cy < board.height { - let arch = self.editor.selected; - *board.get_mut(cx, cy) = (self.editor.glyph, arch); - } + && let Some(pos) = response.interact_pointer_pos() + { + let (cx, cy) = pos_to_cell(rect.min, pos, tw, th); + if cx >= 0 && cy >= 0 { + let cx = cx as usize; + let cy = cy as usize; + let board = &mut self.state.board; + if cx < board.width && cy < board.height { + let arch = self.editor.selected; + *board.get_mut(cx, cy) = (self.editor.glyph, arch); } } + } }); } else { // Play mode: player-centered viewport, no scroll bars. let available = ui.available_rect_before_wrap(); let player = self.state.board.player; - let origin = board_origin(available, board_w, board_h, player); - draw_board(ui.painter(), origin, &self.state.board); + let origin = board_origin(available, board_w, board_h, player, font); + draw_board(ui.painter(), origin, &self.state.board, font); } }); // --- Glyph picker dialog --- // Rendered after all panels so it floats on top. if self.editor.glyph_picker_open { + let font = self.board_font.as_ref().unwrap_or(&self.default_font); glyph_picker::show( ctx, &mut self.editor.glyph_picker_open, &mut self.editor.glyph, &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. } } diff --git a/src/map_file.rs b/src/map_file.rs index fa5b3e9..a53ef0c 100644 --- a/src/map_file.rs +++ b/src/map_file.rs @@ -1,7 +1,7 @@ -use std::collections::HashMap; -use serde::Deserialize; +use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef}; 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. /// @@ -15,11 +15,12 @@ pub struct MapFile { /// The `[map]` section: name, dimensions, player start position. pub map: MapHeader, /// 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, /// The `[grid]` section: the multi-line string that defines cell layout. pub grid: GridData, + /// Optional `[font]` section specifying a bitmap font for this board. + #[serde(default)] + pub font: Option, /// Any `[[objects]]` entries. Optional; defaults to empty. #[serde(default)] pub objects: Vec, @@ -32,40 +33,68 @@ pub struct MapFile { #[derive(Deserialize)] pub struct MapHeader { /// Human-readable name for this board, e.g. `"Opening Room"`. - /// Not yet displayed anywhere at runtime. #[allow(dead_code)] pub name: String, - /// Width of the board in cells. Must match the length of every row in - /// `[grid] content`. + /// Width of the board in cells. Must match the length of every row in `[grid] content`. pub width: usize, - /// Height of the board in cells. Must match the number of rows in - /// `[grid] content`. + /// Height of the board in cells. Must match the number of rows in `[grid] content`. pub height: usize, - /// Starting position of the player as `[x, y]` (0-indexed, origin - /// top-left). This field will become optional once the player is a - /// scripted object rather than a hardcoded entity. + /// Starting position of the player as `[x, y]` (0-indexed, origin top-left). 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. /// -/// 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. -/// 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. /// -/// Note that `ch` (the display character) does not have to match the palette -/// 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 `#`. +/// `tile` accepts either an integer (`tile = 35`) or a char (`tile = "#"`). #[derive(Deserialize)] pub struct PaletteEntry { /// 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, - /// The character displayed in this cell. - pub ch: char, + /// The tile index into the board's bitmap font. + /// Accepts either an integer or a single-character string. + tile: TileIndex, /// Foreground color as an `"#RRGGBB"` hex string. pub fg: 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; /// each character in a line is looked up in the palette to determine the -/// tile for that cell. TOML automatically strips the newline immediately -/// following the opening `"""`, so no special handling is needed for a -/// leading blank line. +/// tile for that cell. #[derive(Deserialize)] pub struct GridData { /// 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 /// and stored in a temporary `HashMap` keyed by the palette character. -/// Unknown archetype names produce an [`Archetype::ErrorBlock`] and a -/// logged warning so malformed maps are visible in-game. +/// Unknown archetype names produce an [`Archetype::ErrorBlock`] and a logged +/// warning so malformed maps are visible in-game. /// -/// 2. **Grid pass** — `grid.content` is split into lines; each character -/// is 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`. +/// 2. **Grid pass** — `grid.content` is split into lines; each character is +/// looked up in the palette map and pushed directly into `cells`. impl From for Board { fn from(mf: MapFile) -> Self { let w = mf.map.width; @@ -119,19 +144,24 @@ impl From for Board { // Pass 1: build a char → (Glyph, Archetype) lookup from the palette. let mut palette: HashMap = HashMap::new(); - for (key, entry) in &mf.palette { + for (key, entry) in mf.palette { let key_char = key.chars().next().unwrap_or(' '); - // Unknown archetype names fall back to ErrorBlock so the error is - // visible in-game rather than silently producing empty tiles. + let tile = entry.tile.into_u32(); match Archetype::try_from(entry.archetype.as_str()) { 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)); } Err(e) => { - // Be sure to log and ignore the default glyph for an unknown archetype 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 for Board { } } + let font = mf.font.map(|f| FontSpec { + path: f.path, + tile_w: f.tile_w, + tile_h: f.tile_h, + }); + Board { width: w, height: h, @@ -156,6 +192,7 @@ impl From for Board { }, objects: mf.objects, portals: mf.portals, + font, } } } @@ -165,9 +202,6 @@ impl From for Board { /// Reads the file at `path`, deserializes it as TOML into a [`MapFile`], /// then converts it via [`From`]. Propagates both I/O errors and /// TOML parse errors through the `Box` return. -/// -/// Called from `main()` before the window is created so that board -/// dimensions are available for window sizing. pub fn load(path: &str) -> Result> { let content = std::fs::read_to_string(path)?; let map_file: MapFile = toml::from_str(&content)?; diff --git a/src/render.rs b/src/render.rs index a91571a..783b224 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,11 +1,11 @@ // 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) ──► -// ╔════════════════════╗ ▲ -// ║ @ ║ │ CELL_H (20 px) -// ╚════════════════════╝ ▼ +// ◄── tile_w ──► +// ╔═════════════╗ ▲ +// ║ @ ║ │ tile_h +// ╚═════════════╝ ▼ // // Full window (Edit mode shown; side panel absent in Play mode): // @@ -16,7 +16,7 @@ // │ PANEL_MARGIN (8 px) on all sides │ PALETTE_ │ ▲ // │ ┌──────────────────────────────────────────┐ │ PANEL_W │ │ // │ │ │ │ (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) // │ └──────────────────────────────────────────┘ │ │ │ // │ │ │ ▼ @@ -25,15 +25,11 @@ // MIN_WINDOW_W and MIN_WINDOW_H are derived: 10 cells wide / 5 cells tall plus margins. 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}; -/// 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. pub(crate) const MENU_H: f32 = 24.0; /// 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; /// Default window height in pixels. Independent of map size. pub(crate) const DEFAULT_WINDOW_H: f32 = 524.0; -/// Minimum window width: enough room for 10 cells plus margins. -pub(crate) const MIN_WINDOW_W: f32 = CELL_W * 10.0 + 2.0 * PANEL_MARGIN; -/// Minimum window height: enough room for 5 cells plus menu and margins. -pub(crate) const MIN_WINDOW_H: f32 = CELL_H * 5.0 + MENU_H + 2.0 * PANEL_MARGIN; +/// Minimum window width: enough room for 10 cells plus margins (using 8 px tile width). +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 (using 8 px tile height). +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 — -/// the caller owns the rect (typically from [`egui::Ui::allocate_exact_size`]) -/// and handles interaction. Use this when you need the click [`egui::Response`]. -pub(crate) fn paint_glyph(painter: &egui::Painter, rect: Rect, glyph: &Glyph) { +/// Fills `rect` with `glyph.bg`, then draws the tile from `font` tinted with +/// `glyph.fg`. Unlike [`draw_glyph`], the caller owns the rect and handles +/// interaction — use this when you need the click [`egui::Response`]. +pub(crate) fn paint_glyph(painter: &egui::Painter, rect: Rect, glyph: &Glyph, font: &BitmapFont) { 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 /// the board within the panel. Cell positions are computed from `origin` using -/// [`CELL_W`] and [`CELL_H`]. -pub(crate) fn draw_glyph(painter: &egui::Painter, origin: Pos2, x: usize, y: usize, glyph: &Glyph) { - let tl = origin + vec2(x as f32 * CELL_W, y as f32 * CELL_H); - let rect = Rect::from_min_size(tl, vec2(CELL_W, CELL_H)); - paint_glyph(painter, rect, glyph); +/// the font's tile dimensions. +pub(crate) fn draw_glyph( + painter: &egui::Painter, + origin: Pos2, + 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`. -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 x in 0..board.width { 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. // This separate draw will be removed once the player becomes a scripted object. 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`. /// /// 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. -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; - ((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`. @@ -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 /// viewport is centered on the player and clamped so no empty space appears /// at the board edges. -pub(crate) fn board_origin(available: Rect, board_w: usize, board_h: usize, player: Player) -> Pos2 { - let board_px_w = board_w as f32 * CELL_W; - let board_px_h = board_h as f32 * CELL_H; +pub(crate) fn board_origin( + available: Rect, + 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() { // Board fits: center it horizontally. available.min.x + (available.width() - board_px_w) / 2.0 } else { // 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) - .max(available.max.x - board_px_w) // right edge must reach viewport right - .min(available.min.x) // left edge must reach viewport left + .max(available.max.x - board_px_w) // right edge must reach viewport right + .min(available.min.x) // left edge must reach viewport left }; let origin_y = if board_px_h <= available.height() { available.min.y + (available.height() - board_px_h) / 2.0 } 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) .max(available.max.y - board_px_h) .min(available.min.y)