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:
+80
-19
@@ -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<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`**
|
||||
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<ObjectDef>` — scripted objects (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:**
|
||||
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
|
||||
|
||||
**`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.
|
||||
|
||||
**`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
|
||||
|
||||
`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**
|
||||
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<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`.
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user