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)
|
main.rs — App, AppMode, frame loop (input → menu → panels → board → dialogs)
|
||||||
game.rs — core data types and game logic
|
game.rs — core data types and game logic
|
||||||
map_file.rs — TOML deserialization, Board loader
|
map_file.rs — TOML deserialization, Board loader
|
||||||
render.rs — cell-size constants, draw_glyph/draw_board, board_origin, pos_to_cell
|
render.rs — drawing primitives; cell size comes from BitmapFont
|
||||||
|
font.rs — BitmapFont, tile UV mapping, placeholder font
|
||||||
|
font_dialog.rs — floating font-picker dialog (path, tile dims, preview, apply)
|
||||||
editor.rs — EditorTab, EditorState, show_editor_panel
|
editor.rs — EditorTab, EditorState, show_editor_panel
|
||||||
glyph_picker.rs — floating Glyph picker dialog; decoupled (open, glyph) interface
|
glyph_picker.rs — floating Glyph picker dialog; decoupled (open, glyph, font) interface
|
||||||
maps/
|
maps/
|
||||||
start.toml — the starting map (loaded at launch)
|
start.toml — the starting map (loaded at launch)
|
||||||
|
assets/
|
||||||
|
vga-font-8x16.png — default CP437 bitmap font (256×128 px, 32 cols × 8 rows, 8×16 tiles)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `game.rs` — core types
|
### `game.rs` — core types
|
||||||
|
|
||||||
The types here are the runtime representation of a game world. They are deliberately free of any file-loading or rendering concerns.
|
The types here are the runtime representation of a game world. They are deliberately free of any file-loading or rendering concerns.
|
||||||
|
|
||||||
**`Glyph`** (`Copy`)
|
**`Glyph`** (`Copy`, `Eq`, `Hash`)
|
||||||
The visual representation of one cell: a character, a foreground color, and a background color. Stored *per cell* (not per element type) so individual cells can animate or vary their appearance independently — e.g. a "fire" element where each flame tile has a slightly different color — without changing their behavior.
|
The visual representation of one cell: a `tile: u32` index into the active bitmap font, a foreground color, and a background color. Stored *per cell* (not per element type) so individual cells can animate or vary their appearance independently — e.g. a "fire" element where each flame tile has a slightly different color — without changing their behavior. Derives `Hash` so the glyph picker can deduplicate the board palette into a `HashSet<Glyph>`.
|
||||||
|
|
||||||
|
**`FontSpec`**
|
||||||
|
Optional per-board font override: `path: String`, `tile_w: u32`, `tile_h: u32`. Stored on `Board.font`. When `None`, `App`'s `default_font` is used. The editor's Board tab exposes a "Font…" button to change this at runtime.
|
||||||
|
|
||||||
**`Behavior`**
|
**`Behavior`**
|
||||||
A plain data struct of runtime behavioral properties — currently `passable: bool` and `opaque: bool`. Returned by `Archetype::behavior()`. Adding a new property (e.g. `shootable`) requires only a new field here; no match arms needed at call sites. This is a deliberate improvement over storing behavior as a bag of booleans on a per-cell or per-archetype basis.
|
A plain data struct of runtime behavioral properties — currently `passable: bool` and `opaque: bool`. Returned by `Archetype::behavior()`. Adding a new property (e.g. `shootable`) requires only a new field here; no match arms needed at call sites. This is a deliberate improvement over storing behavior as a bag of booleans on a per-cell or per-archetype basis.
|
||||||
@@ -64,6 +71,7 @@ The complete unit of a game world — one "room" or "screen" in ZZT terminology.
|
|||||||
- `player: Player` — current player position on this board
|
- `player: Player` — current player position on this board
|
||||||
- `objects: Vec<ObjectDef>` — scripted objects (parsed, not yet runtime-wired)
|
- `objects: Vec<ObjectDef>` — scripted objects (parsed, not yet runtime-wired)
|
||||||
- `portals: Vec<PortalDef>` — exits to other boards (parsed, not yet runtime-wired)
|
- `portals: Vec<PortalDef>` — exits to other boards (parsed, not yet runtime-wired)
|
||||||
|
- `font: Option<FontSpec>` — per-board font override; `None` means use the app default
|
||||||
|
|
||||||
**Why `cells` is `Vec<(Glyph, Archetype)>` with no palette:**
|
**Why `cells` is `Vec<(Glyph, Archetype)>` with no palette:**
|
||||||
An earlier design had `cells: Vec<(Glyph, usize)>` where the `usize` indexed a per-board `elements: Vec<Element>` palette. This was eliminated because the palette indirection added complexity without benefit: `Archetype` is a `Copy` enum (4 bytes), so storing it per-cell is as efficient as storing an index, and it removes the invariant that the index must stay in sync with a specific board's palette.
|
An earlier design had `cells: Vec<(Glyph, usize)>` where the `usize` indexed a per-board `elements: Vec<Element>` palette. This was eliminated because the palette indirection added complexity without benefit: `Archetype` is a `Copy` enum (4 bytes), so storing it per-cell is as efficient as storing an index, and it removes the invariant that the index must stay in sync with a specific board's palette.
|
||||||
@@ -76,10 +84,44 @@ Currently a thin wrapper around `Board` that provides game-logic methods (`try_m
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `font.rs` — bitmap font
|
||||||
|
|
||||||
|
**`BitmapFont`** wraps an egui `TextureHandle` plus tile geometry (`tile_w`, `tile_h`, `img_w`, `img_h`). The texture is preprocessed at load time: the top-left pixel of the source PNG becomes the "background" sentinel — those pixels are stored as `TRANSPARENT`, all others as opaque `WHITE`. This lets egui's image tinting do all the colorization work at render time:
|
||||||
|
|
||||||
|
1. `painter.rect_filled(cell_rect, 0.0, glyph.bg)` — paint the background
|
||||||
|
2. `painter.image(texture_id, cell_rect, tile_uv, glyph.fg)` — draw the tile; egui multiplies each pixel by the tint color, so white → `fg` and transparent → nothing (revealing `bg`)
|
||||||
|
|
||||||
|
No custom shaders required.
|
||||||
|
|
||||||
|
**`tile_uv(tile)`** delegates to the private `compute_tile_uv` function which is unit-tested directly (no egui `Context` needed). `tile_cols()`, `tile_count()`, `img_w()`, `img_h()` are helpers used by the picker and dialog to lay out grids.
|
||||||
|
|
||||||
|
**`create_placeholder(ctx)`** generates a 16×16 grid of 8×8 tiles where each tile shows the 8 bits of its index as a column bar. Used as a fallback when `assets/vga-font-8x16.png` is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `font_dialog.rs` — font picker dialog
|
||||||
|
|
||||||
|
**`FontDialogState`** holds the in-progress edits: `path`, `tile_w`, `tile_h`, and a `preview: Option<BitmapFont>` loaded when the user clicks "Load". Separating dialog state from `FontSpec` means the user can explore settings without committing.
|
||||||
|
|
||||||
|
**`show(ctx, open, state, font_spec)`** renders a floating window with:
|
||||||
|
- A text field + "Browse…" button (`rfd::FileDialog`)
|
||||||
|
- `DragValue` controls for tile dimensions
|
||||||
|
- A "Load" button that decodes the PNG and stores it in `state.preview`
|
||||||
|
- A scrollable preview of the full tilesheet with grid overlay at tile boundaries
|
||||||
|
- "Apply" (enabled only after a successful Load) writes to `font_spec` and closes; "Use default" clears `font_spec`
|
||||||
|
|
||||||
|
Uses a `should_close: bool` flag outside the closure to work around egui's `.open(open)` borrow conflict.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `map_file.rs` — file loading
|
### `map_file.rs` — file loading
|
||||||
|
|
||||||
**`MapFile`** and supporting structs are serde deserialization types only — they exist solely to parse TOML and are never used at runtime.
|
**`MapFile`** and supporting structs are serde deserialization types only — they exist solely to parse TOML and are never used at runtime.
|
||||||
|
|
||||||
|
**`TileIndex`** is a `#[serde(untagged)]` enum (`Num(u32)` | `Chr(char)`) that accepts either an integer (`tile = 35`) or a single-character string (`tile = "#"`) in palette entries. This preserves backward compatibility with existing map files while allowing numeric indices in new ones.
|
||||||
|
|
||||||
|
**`FontHeader`** deserializes the optional `[font]` section and is converted to `FontSpec` in `From<MapFile> for Board`.
|
||||||
|
|
||||||
**`impl From<MapFile> for Board`** — the single conversion point. Reads the palette (each entry maps a character to a `(Glyph, Archetype)` pair), then walks the grid string character-by-character to build `cells`. Unknown archetype names produce an `ErrorBlock` cell and log a warning. This is the only place that knows about both the file format and the runtime representation.
|
**`impl From<MapFile> for Board`** — the single conversion point. Reads the palette (each entry maps a character to a `(Glyph, Archetype)` pair), then walks the grid string character-by-character to build `cells`. Unknown archetype names produce an `ErrorBlock` cell and log a warning. This is the only place that knows about both the file format and the runtime representation.
|
||||||
|
|
||||||
**`pub fn load(path: &str) -> Result<Board, ...>`** — reads a file, deserializes, converts. Called from `main()` before the window is created.
|
**`pub fn load(path: &str) -> Result<Board, ...>`** — reads a file, deserializes, converts. Called from `main()` before the window is created.
|
||||||
@@ -91,7 +133,11 @@ eframe requires `NativeOptions` (including window size) to be set before calling
|
|||||||
|
|
||||||
### `main.rs` — app + frame loop
|
### `main.rs` — app + frame loop
|
||||||
|
|
||||||
`App` holds a `GameState`, an `AppMode` (`Play` | `Edit`), and an `EditorState`. The `update` method phases:
|
`App` holds a `GameState`, an `AppMode` (`Play` | `Edit`), an `EditorState`, a `default_font: BitmapFont` (loaded from `assets/vga-font-8x16.png` at startup, falling back to `create_placeholder`), and a `board_font: Option<BitmapFont>` (loaded from `board.font` when a per-board font is set). `App::new` takes `&egui::Context` to upload textures before the first frame.
|
||||||
|
|
||||||
|
Font change detection: before calling `show_editor_panel`, `font_spec_before` is cloned; after the call, if `board.font` differs, `apply_font_spec` reloads `board_font`. The active font for the frame is `board_font.as_ref().unwrap_or(&default_font)`, extracted as a `let` binding before mutable borrows to satisfy the borrow checker.
|
||||||
|
|
||||||
|
The `update` method phases:
|
||||||
|
|
||||||
1. Reads arrow key input and calls `GameState::try_move` — **Play mode only**
|
1. Reads arrow key input and calls `GameState::try_move` — **Play mode only**
|
||||||
2. Draws the menu bar with a File menu and a Play/Edit mode toggle
|
2. Draws the menu bar with a File menu and a Play/Edit mode toggle
|
||||||
@@ -109,19 +155,21 @@ The board is wrapped in `egui::ScrollArea::new([true, true])`, which shows scrol
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `render.rs` — constants and drawing primitives
|
### `render.rs` — drawing primitives
|
||||||
|
|
||||||
All pixel-level rendering knowledge lives here. No dependency on `EditorState` or `App`.
|
All pixel-level rendering knowledge lives here. No dependency on `EditorState` or `App`. Cell size is no longer a fixed constant — it comes from the active `BitmapFont`.
|
||||||
|
|
||||||
**Constants:** `CELL_W = 14.0`, `CELL_H = 20.0` pixels per cell. Window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`) are defined here so they stay co-located with the cell measurements they derive from.
|
**Constants:** Window sizing (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`) defined here. No `CELL_W`/`CELL_H` — those were removed when bitmap fonts were introduced.
|
||||||
|
|
||||||
**`draw_glyph(painter, origin, x, y, glyph)`** — renders one cell as a filled background rect plus a centered monospace character.
|
**`paint_glyph(painter, rect, glyph, font)`** — fills `rect` with `glyph.bg`, then draws the tile image tinted by `glyph.fg`. Used by both `draw_glyph` and `glyph_picker`.
|
||||||
|
|
||||||
**`draw_board(painter, origin, board)`** — iterates all cells calling `draw_glyph`, then draws the player as an overlay on top.
|
**`draw_glyph(painter, origin, x, y, glyph, font)`** — computes the cell rect from `font.tile_w/tile_h`, delegates to `paint_glyph`.
|
||||||
|
|
||||||
**`pos_to_cell(origin, pos) -> (i32, i32)`** — converts a pixel position to cell coordinates using floor division. Returns negative values for clicks above/left of the origin; callers must guard `>= 0`. Mirrors the rendering math so clicks land on the correct cell.
|
**`draw_board(painter, origin, board, font)`** — iterates all cells calling `draw_glyph`, then draws the player as an overlay on top.
|
||||||
|
|
||||||
**`board_origin(available, board_w, board_h, player) -> Pos2`** — computes the pixel origin for Play-mode rendering (centering or player-tracking with edge clamping).
|
**`pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32)`** — converts a pixel position to cell coordinates using floor division. Returns negative values for clicks above/left of the origin; callers must guard `>= 0`. Mirrors the rendering math so clicks land on the correct cell.
|
||||||
|
|
||||||
|
**`board_origin(available, board_w, board_h, player, font) -> Pos2`** — computes the pixel origin for Play-mode rendering (centering or player-tracking with edge clamping).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -134,19 +182,21 @@ All pixel-level rendering knowledge lives here. No dependency on `EditorState` o
|
|||||||
- `glyph: Glyph` — the visual to stamp (independent of the archetype's default; resets to `archetype.default_glyph()` when the archetype selection changes)
|
- `glyph: Glyph` — the visual to stamp (independent of the archetype's default; resets to `archetype.default_glyph()` when the archetype selection changes)
|
||||||
- `glyph_picker_open: bool` — whether the glyph picker dialog is visible
|
- `glyph_picker_open: bool` — whether the glyph picker dialog is visible
|
||||||
- `tab: EditorTab` — which side-panel tab is active
|
- `tab: EditorTab` — which side-panel tab is active
|
||||||
|
- `font_dialog_open: bool` — whether the font picker dialog is visible
|
||||||
|
- `font_dialog_state: FontDialogState` — in-progress edits for the font dialog
|
||||||
|
|
||||||
**`show_editor_panel(ctx, editor, board)`** — renders the resizable right-side panel (default 200 px). On the Palette tab: a scrollable archetype list (at most 5 rows visible) and a current-glyph preview button that sets `glyph_picker_open = true`. Board and World tabs are placeholders.
|
**`show_editor_panel(ctx, editor, font_spec, active_font)`** — renders the resizable right-side panel (default 200 px). Palette tab: scrollable archetype list and glyph preview button. Board tab: current font path label + "Font…" button that initializes `font_dialog_state` from `font_spec` and opens the font dialog. World tab: placeholder.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `glyph_picker.rs` — glyph picker dialog
|
### `glyph_picker.rs` — glyph picker dialog
|
||||||
|
|
||||||
**`show(ctx, open, glyph, board_cells)`** — renders a floating `egui::Window`. Takes `open: &mut bool` and `glyph: &mut Glyph` directly, with no dependency on `EditorState`, so it can be invoked from any context that needs glyph selection.
|
**`show(ctx, open, glyph, board_cells, font)`** — renders a floating `egui::Window`. Takes `open: &mut bool` and `glyph: &mut Glyph` directly, with no dependency on `EditorState`, so it can be invoked from any context that needs glyph selection.
|
||||||
|
|
||||||
Three sections:
|
Three sections:
|
||||||
1. **Board palette** — unique glyphs found on the current board, shown as clickable cells. The current glyph is highlighted with a white stroke.
|
1. **Board palette** — unique glyphs found on the current board (deduplicated via `HashSet<Glyph>`), shown as clickable cells rendered with `paint_glyph`. The current glyph is highlighted with a white stroke.
|
||||||
2. **Colors** — `color_edit_button_srgba` pickers for `glyph.fg` and `glyph.bg`.
|
2. **Colors** — `color_edit_button_srgba` pickers for `glyph.fg` and `glyph.bg`.
|
||||||
3. **Character grid** — 16 × 6 grid of printable ASCII (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.
|
XPM-inspired (XPM is an old X11 image format that uses a character palette to define pixel colors). A `[palette]` section maps single characters to both a `Glyph` (visual) and an `Element` (behavior). The `[grid] content` is a TOML multi-line string where each character is a palette key.
|
||||||
|
|
||||||
|
**Keep this example in sync with `map_file.rs` whenever the format changes.**
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[map]
|
[map]
|
||||||
name = "Room Name"
|
name = "Room Name"
|
||||||
@@ -161,9 +213,15 @@ width = 60
|
|||||||
height = 25
|
height = 25
|
||||||
player_start = [30, 12]
|
player_start = [30, 12]
|
||||||
|
|
||||||
|
# Optional: override the default font for this board.
|
||||||
|
[font]
|
||||||
|
path = "assets/my_font.png"
|
||||||
|
tile_w = 8
|
||||||
|
tile_h = 16
|
||||||
|
|
||||||
[palette]
|
[palette]
|
||||||
" " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" }
|
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
|
||||||
"#" = { archetype = "wall", ch = "#", fg = "#808080", bg = "#606060" }
|
"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
|
||||||
|
|
||||||
[grid]
|
[grid]
|
||||||
content = """
|
content = """
|
||||||
@@ -189,6 +247,9 @@ target_entry = "west_door"
|
|||||||
**Why TOML over a custom format:**
|
**Why TOML over a custom format:**
|
||||||
The `toml` crate gives us deserialization with minimal code. Multi-line strings for the grid give a visual representation of the map. Embedded Rhai scripts fit naturally in TOML multi-line strings without escaping issues.
|
The `toml` crate gives us deserialization with minimal code. Multi-line strings for the grid give a visual representation of the map. Embedded Rhai scripts fit naturally in TOML multi-line strings without escaping issues.
|
||||||
|
|
||||||
|
**Why `tile` accepts both char and integer:**
|
||||||
|
`TileIndex` is a `#[serde(untagged)]` enum. Old map files using `tile = " "` (a single-character string) continue to work unchanged. New map files can use `tile = 32` (integer) for clarity or when the tile has no printable character equivalent.
|
||||||
|
|
||||||
**Why archetypes by name, not by property:**
|
**Why archetypes by name, not by property:**
|
||||||
Palette entries used to store `passable = true/false` directly. Switching to `archetype = "wall"` means adding a new behavioral property (e.g. `opaque`) only requires a code change — existing map files don't need to be updated. It also makes files more readable: `archetype = "wall"` is self-documenting. Unknown names produce a visible `ErrorBlock` cell rather than silently defaulting.
|
Palette entries used to store `passable = true/false` directly. Switching to `archetype = "wall"` means adding a new behavioral property (e.g. `opaque`) only requires a code change — existing map files don't need to be updated. It also makes files more readable: `archetype = "wall"` is self-documenting. Unknown names produce a visible `ErrorBlock` cell rather than silently defaulting.
|
||||||
|
|
||||||
@@ -213,7 +274,7 @@ A direct mapping from palette character → `(Glyph, Archetype)` means the map f
|
|||||||
|
|
||||||
**Multi-board world** — right now the engine loads a single `maps/start.toml`. Future: a world file or directory of boards, lazy-loaded as the player moves through portals.
|
**Multi-board world** — right now the engine loads a single `maps/start.toml`. Future: a world file or directory of boards, lazy-loaded as the player moves through portals.
|
||||||
|
|
||||||
**File open dialog** — the editor has no way to load a different map file from the UI. This requires a native file dialog (e.g. `rfd`). WASM support for file dialogs is an open question.
|
**File open dialog** — the editor has no way to load a different map file from the UI. `rfd` is already a dependency (used by the font dialog), so a "Open map…" button is straightforward to add. WASM support for `rfd` file dialogs is an open question.
|
||||||
|
|
||||||
**Map save** — the editor can paint cells but has no way to write changes back to a `.toml` file. Saving will require serializing the `Board` back to `MapFile` format and writing it to disk.
|
**Map save** — the editor can paint cells but has no way to write changes back to a `.toml` file. Saving will require serializing the `Board` back to `MapFile` format and writing it to disk.
|
||||||
|
|
||||||
|
|||||||
@@ -45,46 +45,68 @@ The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI.
|
|||||||
### Modules
|
### Modules
|
||||||
|
|
||||||
**`src/game.rs`** — all core game types:
|
**`src/game.rs`** — all core game types:
|
||||||
- `Glyph` (`Copy`) — per-cell visual: `ch: char`, `fg/bg: Color32`. `Glyph::player()` is the only constructor used at runtime (colors for board tiles come from the map file).
|
- `Glyph` (`Copy`, `Eq`, `Hash`) — per-cell visual: `tile: u32` (tilesheet index), `fg/bg: Color32`. `Glyph::player()` is a `const fn`; all other glyphs come from the map file. Derives `Hash` so boards can deduplicate glyphs into a palette.
|
||||||
|
- `FontSpec` — optional per-board bitmap font override: `path: String`, `tile_w: u32`, `tile_h: u32`. When `None`, the app default font is used.
|
||||||
- `Behavior` — plain data struct of runtime behavioral properties: `passable: bool`, `opaque: bool`. Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere.
|
- `Behavior` — plain data struct of runtime behavioral properties: `passable: bool`, `opaque: bool`. Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere.
|
||||||
- `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `Object`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red.
|
- `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `Object`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red.
|
||||||
- `ALL_ARCHETYPES: &[Archetype]` — ordered list of valid editor choices (excludes `ErrorBlock`).
|
- `ALL_ARCHETYPES: &[Archetype]` — ordered list of valid editor choices (excludes `ErrorBlock`).
|
||||||
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec<ObjectDef>`, `portals: Vec<PortalDef>`. `cells` is `pub(crate)`.
|
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec<ObjectDef>`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells` is `pub(crate)`.
|
||||||
- `Player` — `x: i32, y: i32`
|
- `Player` — `x: i32, y: i32`
|
||||||
- `ObjectDef` / `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
|
- `ObjectDef` / `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
|
||||||
- `GameState` — holds `board: Board`; `try_move(dx, dy)` checks passability before moving the player
|
- `GameState` — holds `board: Board`; `try_move(dx, dy)` checks passability before moving the player
|
||||||
|
|
||||||
|
**`src/font.rs`** — bitmap font loading and UV mapping:
|
||||||
|
- `BitmapFont` — wraps an egui `TextureHandle` (preprocessed to opaque-white / transparent) plus tile dimensions. The top-left pixel of the source PNG defines the "background" color; those pixels become `TRANSPARENT`, all others become `WHITE`. At render time, `painter.image(…, tile_uv, fg_color)` tints white pixels to `fg` and transparent pixels reveal the `bg` fill behind.
|
||||||
|
- `BitmapFont::load(ctx, path, tile_w, tile_h)` — loads from disk
|
||||||
|
- `BitmapFont::from_bytes(ctx, bytes, tile_w, tile_h, label)` — loads from embedded bytes
|
||||||
|
- `BitmapFont::create_placeholder(ctx)` — 16×16 grid of 8×8 procedural tiles (bit-pattern bars), used as fallback when no font file is present
|
||||||
|
- `BitmapFont::tile_uv(tile)` — returns a UV `Rect` for the given tile index (left-to-right, top-to-bottom). Unit-tested via `compute_tile_uv`.
|
||||||
|
- `BitmapFont::tile_cols()`, `tile_count()`, `img_w()`, `img_h()` — geometry helpers used by the glyph picker and font dialog
|
||||||
|
|
||||||
|
**`src/font_dialog.rs`** — font picker dialog:
|
||||||
|
- `FontDialogState` — in-progress edit: `path: String`, `tile_w/tile_h: u32`, `preview: Option<BitmapFont>`
|
||||||
|
- `FontDialogState::from_spec(spec)` — initializes from an existing `FontSpec` or defaults (8×16, empty path)
|
||||||
|
- `show(ctx, open, state, font_spec)` — floating dialog with file browser (`rfd::FileDialog`), tile dimension `DragValue` controls, scrollable tilesheet preview with grid overlay, and Apply / Use default buttons. Uses a `should_close` flag to avoid the egui `.open()` borrow conflict.
|
||||||
|
|
||||||
**`src/map_file.rs`** — map file loading:
|
**`src/map_file.rs`** — map file loading:
|
||||||
- `MapFile` and friends — serde `Deserialize` types for TOML map files
|
- `MapFile` and friends — serde `Deserialize` types for TOML map files
|
||||||
- `impl From<MapFile> for Board` — converts a parsed file into a ready-to-use `Board`
|
- `TileIndex` — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably
|
||||||
|
- `FontHeader` — deserializes the optional `[font]` section (`path`, `tile_w`, `tile_h`)
|
||||||
|
- `impl From<MapFile> for Board` — converts a parsed file into a ready-to-use `Board`; unknown archetype names produce `ErrorBlock`
|
||||||
- `pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>` — reads and converts a `.toml` map file
|
- `pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>` — reads and converts a `.toml` map file
|
||||||
|
|
||||||
**`src/main.rs`** — app entry point and frame loop:
|
**`src/main.rs`** — app entry point and frame loop:
|
||||||
- `AppMode` enum (`Play` | `Edit`) — gates arrow-key input; toggles the side panel and viewport mode
|
- `AppMode` enum (`Play` | `Edit`) — gates arrow-key input; toggles the side panel and viewport mode
|
||||||
- `App` holds `GameState`, `AppMode`, and `EditorState`; `update` phases: input → menu bar → editor panel → board → glyph picker dialog
|
- `App` holds `GameState`, `AppMode`, `EditorState`, `default_font: BitmapFont`, and `board_font: Option<BitmapFont>`
|
||||||
|
- `App::new(board, egui_ctx)` — loads `assets/vga-font-8x16.png` as the default font (falls back to `create_placeholder`); loads per-board font from `board.font` if present
|
||||||
|
- Font change detection: `font_spec_before` is snapshotted before `show_editor_panel`; if changed, `apply_font_spec` reloads `board_font`
|
||||||
|
- Borrow split: `let active_font = self.board_font.as_ref().unwrap_or(&self.default_font)` is extracted before mutable borrows of `editor` and `board.font` to satisfy the borrow checker
|
||||||
- **Play mode**: arrow keys move player; `render::board_origin` centers or player-tracks the viewport
|
- **Play mode**: arrow keys move player; `render::board_origin` centers or player-tracks the viewport
|
||||||
- **Edit mode**: `ScrollArea::both()` wraps the board; click-to-paint stamps `(editor.glyph, editor.selected)`; calls `editor::show_editor_panel` and `glyph_picker::show`
|
- **Edit mode**: `ScrollArea::both()` wraps the board; click-to-paint stamps `(editor.glyph, editor.selected)`; calls `editor::show_editor_panel` and `glyph_picker::show`
|
||||||
|
|
||||||
**`src/render.rs`** — cell rendering constants and drawing primitives:
|
**`src/render.rs`** — cell rendering and drawing primitives:
|
||||||
- `CELL_W = 14.0`, `CELL_H = 20.0` and window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`)
|
- Window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`); no fixed `CELL_W/H` — cell size comes from the active `BitmapFont`
|
||||||
- `draw_glyph(painter, origin, x, y, glyph)` — filled rect (bg) + centered monospace char (fg)
|
- `paint_glyph(painter, rect, glyph, font)` — `rect_filled` with `glyph.bg`, then `painter.image` tinted by `glyph.fg`
|
||||||
- `draw_board(painter, origin, board)` — draws all cells then player overlay
|
- `draw_glyph(painter, origin, x, y, glyph, font)` — sizes cell from `font.tile_w/tile_h`, delegates to `paint_glyph`
|
||||||
- `board_origin(available, board_w, board_h, player)` — centers board or clamps to player with no empty space
|
- `draw_board(painter, origin, board, font)` — draws all cells then player overlay
|
||||||
- `pos_to_cell(origin, pos) -> (i32, i32)` — pixel → cell coordinates via floor division; negatives signal out-of-bounds
|
- `board_origin(available, board_w, board_h, player, font)` — centers board or clamps to player with no empty space
|
||||||
|
- `pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32)` — pixel → cell coordinates; negatives signal out-of-bounds
|
||||||
|
|
||||||
**`src/editor.rs`** — editor state and side panel:
|
**`src/editor.rs`** — editor state and side panel:
|
||||||
- `EditorTab` enum (`Palette` | `Board` | `World`) — which tab is active
|
- `EditorTab` enum (`Palette` | `Board` | `World`) — which tab is active
|
||||||
- `EditorState` — holds `selected: Archetype`, `glyph: Glyph`, `glyph_picker_open: bool`, `tab: EditorTab`; selecting a new archetype resets `glyph` to that archetype's default
|
- `EditorState` — holds `selected: Archetype`, `glyph: Glyph`, `glyph_picker_open: bool`, `tab: EditorTab`, `font_dialog_open: bool`, `font_dialog_state: FontDialogState`; selecting a new archetype resets `glyph` to that archetype's default
|
||||||
- `show_editor_panel(ctx, editor, board)` — resizable right-side panel (default 200 px); Palette tab shows archetype list and glyph preview button; Board/World tabs are placeholders
|
- `show_editor_panel(ctx, editor, font_spec, active_font)` — resizable right-side panel (default 200 px); Palette tab shows archetype list and glyph preview button; Board tab shows current font path and "Font…" button that opens `font_dialog::show`; World tab is a placeholder
|
||||||
|
|
||||||
**`src/glyph_picker.rs`** — floating glyph picker dialog:
|
**`src/glyph_picker.rs`** — floating glyph picker dialog:
|
||||||
- `show(ctx, open, glyph, board_cells)` — takes `open: &mut bool` and `glyph: &mut Glyph` directly; no dependency on `EditorState`
|
- `show(ctx, open, glyph, board_cells, font)` — takes `open: &mut bool` and `glyph: &mut Glyph` directly; no dependency on `EditorState`
|
||||||
- Three sections: board palette (unique glyphs, click to select), FG/BG color pickers, 16×6 printable ASCII character grid
|
- Three sections: board palette (unique glyphs deduped via `HashSet<Glyph>`, click to select), FG/BG color pickers, scrollable tile grid (all tiles in the active font at `tile_w × tile_h` per cell)
|
||||||
|
|
||||||
### Map file format (`maps/*.toml`)
|
### Map file format (`maps/*.toml`)
|
||||||
|
|
||||||
XPM-inspired: a `[palette]` maps single characters to `(Glyph, Archetype)` definitions; `[grid] content` is a TOML multi-line string where each character indexes the palette.
|
XPM-inspired: a `[palette]` maps single characters to `(Glyph, Archetype)` definitions; `[grid] content` is a TOML multi-line string where each character indexes the palette.
|
||||||
|
|
||||||
|
**Keep this example in sync with `map_file.rs` whenever the format changes.**
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[map]
|
[map]
|
||||||
name = "Room Name"
|
name = "Room Name"
|
||||||
@@ -92,9 +114,15 @@ width = 60
|
|||||||
height = 25
|
height = 25
|
||||||
player_start = [30, 12]
|
player_start = [30, 12]
|
||||||
|
|
||||||
|
# Optional: override the default font for this board.
|
||||||
|
[font]
|
||||||
|
path = "assets/my_font.png"
|
||||||
|
tile_w = 8
|
||||||
|
tile_h = 16
|
||||||
|
|
||||||
[palette]
|
[palette]
|
||||||
" " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" }
|
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
|
||||||
"#" = { archetype = "wall", ch = "#", fg = "#808080", bg = "#606060" }
|
"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
|
||||||
|
|
||||||
[grid]
|
[grid]
|
||||||
content = """
|
content = """
|
||||||
@@ -117,7 +145,7 @@ target_map = "cave"
|
|||||||
target_entry = "west_door"
|
target_entry = "west_door"
|
||||||
```
|
```
|
||||||
|
|
||||||
Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning.
|
Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning.
|
||||||
|
|
||||||
### Key design decisions
|
### Key design decisions
|
||||||
|
|
||||||
|
|||||||
Generated
+159
-14
@@ -215,6 +215,28 @@ dependencies = [
|
|||||||
"libloading",
|
"libloading",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ashpd"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
|
||||||
|
dependencies = [
|
||||||
|
"async-fs",
|
||||||
|
"async-net",
|
||||||
|
"enumflags2",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
|
"rand",
|
||||||
|
"raw-window-handle",
|
||||||
|
"serde",
|
||||||
|
"serde_repr",
|
||||||
|
"url",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -253,6 +275,17 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-fs"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
|
||||||
|
dependencies = [
|
||||||
|
"async-lock",
|
||||||
|
"blocking",
|
||||||
|
"futures-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-io"
|
name = "async-io"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
@@ -282,6 +315,17 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-net"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
|
||||||
|
dependencies = [
|
||||||
|
"async-io",
|
||||||
|
"blocking",
|
||||||
|
"futures-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-process"
|
name = "async-process"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -450,6 +494,15 @@ dependencies = [
|
|||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block2"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "blocking"
|
name = "blocking"
|
||||||
version = "1.6.2"
|
version = "1.6.2"
|
||||||
@@ -745,6 +798,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1099,6 +1154,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1148,8 +1212,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
@@ -1643,6 +1709,8 @@ name = "kiln"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"eframe",
|
"eframe",
|
||||||
|
"image",
|
||||||
|
"rfd",
|
||||||
"rhai",
|
"rhai",
|
||||||
"serde",
|
"serde",
|
||||||
"toml",
|
"toml",
|
||||||
@@ -1930,7 +1998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
@@ -1946,6 +2014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
|
"block2 0.6.2",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
@@ -1959,7 +2028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -1971,7 +2040,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -1983,7 +2052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2018,7 +2087,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-metal",
|
"objc2-metal",
|
||||||
@@ -2030,7 +2099,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-contacts",
|
"objc2-contacts",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2049,7 +2118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
@@ -2083,7 +2152,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2096,7 +2165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2108,7 +2177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-metal",
|
"objc2-metal",
|
||||||
@@ -2131,7 +2200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-cloud-kit",
|
"objc2-cloud-kit",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
@@ -2151,7 +2220,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2163,7 +2232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2333,6 +2402,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pollster"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -2357,6 +2432,15 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "presser"
|
name = "presser"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2440,6 +2524,35 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "range-alloc"
|
name = "range-alloc"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2485,6 +2598,30 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.15.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
|
||||||
|
dependencies = [
|
||||||
|
"ashpd",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"dispatch2",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-app-kit 0.3.2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"pollster",
|
||||||
|
"raw-window-handle",
|
||||||
|
"urlencoding",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rhai"
|
name = "rhai"
|
||||||
version = "1.24.0"
|
version = "1.24.0"
|
||||||
@@ -3149,8 +3286,15 @@ dependencies = [
|
|||||||
"idna",
|
"idna",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3990,7 +4134,7 @@ dependencies = [
|
|||||||
"android-activity",
|
"android-activity",
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"calloop 0.13.0",
|
"calloop 0.13.0",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
@@ -4437,6 +4581,7 @@ dependencies = [
|
|||||||
"endi",
|
"endi",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
|
"url",
|
||||||
"winnow 1.0.3",
|
"winnow 1.0.3",
|
||||||
"zvariant_derive",
|
"zvariant_derive",
|
||||||
"zvariant_utils",
|
"zvariant_utils",
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ eframe = "0.33.3"
|
|||||||
rhai = "1"
|
rhai = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
rfd = "0.15"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
+3
-3
@@ -5,9 +5,9 @@ height = 25
|
|||||||
player_start = [30, 12]
|
player_start = [30, 12]
|
||||||
|
|
||||||
[palette]
|
[palette]
|
||||||
" " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" }
|
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
|
||||||
"#" = { archetype = "wall", ch = "#", fg = "#808080", bg = "#606060" }
|
"#" = { archetype = "wall", tile = "#", fg = "#808080", bg = "#606060" }
|
||||||
"+" = { archetype = "banana", ch = "#", fg = "#808080", bg = "#606060" }
|
"+" = { archetype = "banana", tile = "#", fg = "#808080", bg = "#606060" }
|
||||||
|
|
||||||
[grid]
|
[grid]
|
||||||
content = """
|
content = """
|
||||||
|
|||||||
+57
-19
@@ -1,15 +1,17 @@
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use egui::{Sense, vec2};
|
use egui::{Sense, vec2};
|
||||||
|
|
||||||
use crate::game::{Archetype, Glyph, ALL_ARCHETYPES};
|
use crate::font::BitmapFont;
|
||||||
use crate::render::{CELL_H, CELL_W, PALETTE_PANEL_W, paint_glyph};
|
use crate::font_dialog::{FontDialogState, show as show_font_dialog};
|
||||||
|
use crate::game::{ALL_ARCHETYPES, Archetype, FontSpec, Glyph};
|
||||||
|
use crate::render::{PALETTE_PANEL_W, paint_glyph};
|
||||||
|
|
||||||
/// Which tab is active in the editor side panel.
|
/// Which tab is active in the editor side panel.
|
||||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||||
pub(crate) enum EditorTab {
|
pub(crate) enum EditorTab {
|
||||||
/// Archetype + glyph palette for painting cells.
|
/// Archetype + glyph palette for painting cells.
|
||||||
Palette,
|
Palette,
|
||||||
/// Board-level properties (placeholder).
|
/// Board-level properties.
|
||||||
Board,
|
Board,
|
||||||
/// World-level properties (placeholder).
|
/// World-level properties (placeholder).
|
||||||
World,
|
World,
|
||||||
@@ -19,21 +21,27 @@ pub(crate) enum EditorTab {
|
|||||||
pub(crate) struct EditorState {
|
pub(crate) struct EditorState {
|
||||||
/// The archetype that will be stamped when the user clicks a cell.
|
/// The archetype that will be stamped when the user clicks a cell.
|
||||||
pub(crate) selected: Archetype,
|
pub(crate) selected: Archetype,
|
||||||
/// The glyph (character + colors) to stamp. Independent of the archetype's default.
|
/// The glyph (tile index + colors) to stamp. Independent of the archetype's default.
|
||||||
pub(crate) glyph: Glyph,
|
pub(crate) glyph: Glyph,
|
||||||
/// Whether the glyph picker dialog is currently open.
|
/// Whether the glyph picker dialog is currently open.
|
||||||
pub(crate) glyph_picker_open: bool,
|
pub(crate) glyph_picker_open: bool,
|
||||||
|
/// Whether the font picker dialog is currently open.
|
||||||
|
pub(crate) font_dialog_open: bool,
|
||||||
|
/// In-progress state for the font dialog while it is open.
|
||||||
|
pub(crate) font_dialog_state: FontDialogState,
|
||||||
/// Which side-panel tab is currently shown.
|
/// Which side-panel tab is currently shown.
|
||||||
pub(crate) tab: EditorTab,
|
pub(crate) tab: EditorTab,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorState {
|
impl EditorState {
|
||||||
/// Creates editor state with `Wall` selected and the picker closed.
|
/// Creates editor state with `Wall` selected and all dialogs closed.
|
||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
selected: Archetype::Wall,
|
selected: Archetype::Wall,
|
||||||
glyph: Archetype::Wall.default_glyph(),
|
glyph: Archetype::Wall.default_glyph(),
|
||||||
glyph_picker_open: false,
|
glyph_picker_open: false,
|
||||||
|
font_dialog_open: false,
|
||||||
|
font_dialog_state: FontDialogState::from_spec(None),
|
||||||
tab: EditorTab::Palette,
|
tab: EditorTab::Palette,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,17 +49,26 @@ impl EditorState {
|
|||||||
|
|
||||||
/// Renders the editor side panel (must be called before `CentralPanel`).
|
/// Renders the editor side panel (must be called before `CentralPanel`).
|
||||||
///
|
///
|
||||||
/// Shows tab bar (Palette / Board / World) and, on the Palette tab, the
|
/// Shows tab bar (Palette / Board / World) and the content for the active tab.
|
||||||
/// scrollable archetype list and current-glyph preview button.
|
/// `font_spec` is the board's current font override (may be `None` for default).
|
||||||
pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState) {
|
/// `active_font` is the resolved font currently in use for rendering.
|
||||||
|
pub(crate) fn show_editor_panel(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
editor: &mut EditorState,
|
||||||
|
font_spec: &mut Option<FontSpec>,
|
||||||
|
active_font: &BitmapFont,
|
||||||
|
) {
|
||||||
|
let tw = active_font.tile_w as f32;
|
||||||
|
let th = active_font.tile_h as f32;
|
||||||
|
|
||||||
egui::SidePanel::right("palette_panel")
|
egui::SidePanel::right("palette_panel")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.default_width(PALETTE_PANEL_W)
|
.default_width(PALETTE_PANEL_W)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.selectable_value(&mut editor.tab, EditorTab::Palette, "Palette");
|
ui.selectable_value(&mut editor.tab, EditorTab::Palette, "Palette");
|
||||||
ui.selectable_value(&mut editor.tab, EditorTab::Board, "Board");
|
ui.selectable_value(&mut editor.tab, EditorTab::Board, "Board");
|
||||||
ui.selectable_value(&mut editor.tab, EditorTab::World, "World");
|
ui.selectable_value(&mut editor.tab, EditorTab::World, "World");
|
||||||
});
|
});
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
@@ -60,7 +77,7 @@ pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState) {
|
|||||||
ui.label("Element");
|
ui.label("Element");
|
||||||
|
|
||||||
// Scrollable archetype list; shows at most 5 rows before scrolling.
|
// Scrollable archetype list; shows at most 5 rows before scrolling.
|
||||||
let row_h = CELL_H + ui.spacing().item_spacing.y;
|
let row_h = th + ui.spacing().item_spacing.y;
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.max_height(row_h * 5.0)
|
.max_height(row_h * 5.0)
|
||||||
.id_salt("arch_list")
|
.id_salt("arch_list")
|
||||||
@@ -69,14 +86,14 @@ pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState) {
|
|||||||
let is_selected = editor.selected == archetype;
|
let is_selected = editor.selected == archetype;
|
||||||
let glyph = archetype.default_glyph();
|
let glyph = archetype.default_glyph();
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let (r, cell_resp) = ui.allocate_exact_size(
|
let (r, cell_resp) =
|
||||||
vec2(CELL_W, CELL_H), Sense::click());
|
ui.allocate_exact_size(vec2(tw, th), Sense::click());
|
||||||
paint_glyph(ui.painter(), r, &glyph);
|
paint_glyph(ui.painter(), r, &glyph, active_font);
|
||||||
let label_resp =
|
let label_resp =
|
||||||
ui.selectable_label(is_selected, archetype.name());
|
ui.selectable_label(is_selected, archetype.name());
|
||||||
if cell_resp.clicked() || label_resp.clicked() {
|
if cell_resp.clicked() || label_resp.clicked() {
|
||||||
editor.selected = archetype;
|
editor.selected = archetype;
|
||||||
editor.glyph = archetype.default_glyph();
|
editor.glyph = archetype.default_glyph();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -87,15 +104,36 @@ pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState) {
|
|||||||
ui.label("Glyph");
|
ui.label("Glyph");
|
||||||
// Paint a preview cell showing the current glyph, sized like a board cell.
|
// Paint a preview cell showing the current glyph, sized like a board cell.
|
||||||
let (cell_rect, glyph_btn) =
|
let (cell_rect, glyph_btn) =
|
||||||
ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click());
|
ui.allocate_exact_size(vec2(tw, th), Sense::click());
|
||||||
paint_glyph(ui.painter(), cell_rect, &editor.glyph);
|
paint_glyph(ui.painter(), cell_rect, &editor.glyph, active_font);
|
||||||
if glyph_btn.clicked() {
|
if glyph_btn.clicked() {
|
||||||
editor.glyph_picker_open = true;
|
editor.glyph_picker_open = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditorTab::Board => {}
|
|
||||||
|
EditorTab::Board => {
|
||||||
|
let font_label = match font_spec {
|
||||||
|
Some(s) => s.path.clone(),
|
||||||
|
None => "(default)".to_owned(),
|
||||||
|
};
|
||||||
|
ui.label(format!("Font: {font_label}"));
|
||||||
|
if ui.button("Font…").clicked() {
|
||||||
|
editor.font_dialog_state = FontDialogState::from_spec(font_spec.as_ref());
|
||||||
|
editor.font_dialog_open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
EditorTab::World => {}
|
EditorTab::World => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
|
// Font dialog floats outside the side panel; show it here so it renders on top.
|
||||||
|
if editor.font_dialog_open {
|
||||||
|
show_font_dialog(
|
||||||
|
ctx,
|
||||||
|
&mut editor.font_dialog_open,
|
||||||
|
&mut editor.font_dialog_state,
|
||||||
|
font_spec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
use egui::{ColorImage, Rect, TextureHandle, TextureOptions, vec2};
|
||||||
|
use image::GenericImageView;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Computes a UV rectangle for `tile` within an image of known dimensions.
|
||||||
|
///
|
||||||
|
/// This is the core tile-index-to-UV mapping; pulled out so tests can verify
|
||||||
|
/// it without needing an egui [`Context`] to construct a [`BitmapFont`].
|
||||||
|
fn compute_tile_uv(tile: u32, tile_w: u32, tile_h: u32, img_w: u32, img_h: u32) -> Rect {
|
||||||
|
let tiles_per_row = (img_w / tile_w).max(1);
|
||||||
|
let col = tile % tiles_per_row;
|
||||||
|
let row = tile / tiles_per_row;
|
||||||
|
let u0 = col as f32 * tile_w as f32 / img_w as f32;
|
||||||
|
let v0 = row as f32 * tile_h as f32 / img_h as f32;
|
||||||
|
let u1 = u0 + tile_w as f32 / img_w as f32;
|
||||||
|
let v1 = v0 + tile_h as f32 / img_h as f32;
|
||||||
|
Rect::from_min_max(egui::pos2(u0, v0), egui::pos2(u1, v1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A loaded bitmap font ready for rendering.
|
||||||
|
///
|
||||||
|
/// Wraps an egui texture that has been pre-processed to a two-value RGBA
|
||||||
|
/// image: "background" pixels (matching the top-left pixel of the source
|
||||||
|
/// image) become fully transparent, and all other pixels become opaque white.
|
||||||
|
///
|
||||||
|
/// At render time, each tile is drawn by:
|
||||||
|
/// 1. Filling the cell rect with the glyph's `bg` color.
|
||||||
|
/// 2. Calling `painter.image(…, tile_uv(tile), fg_color)` — egui tints the
|
||||||
|
/// opaque-white foreground pixels to `fg` and the transparent pixels stay
|
||||||
|
/// transparent, revealing the `bg` fill underneath.
|
||||||
|
pub struct BitmapFont {
|
||||||
|
/// The uploaded egui texture (preprocessed to opaque-white / transparent).
|
||||||
|
pub texture: TextureHandle,
|
||||||
|
/// Width of each tile in pixels.
|
||||||
|
pub tile_w: u32,
|
||||||
|
/// Height of each tile in pixels.
|
||||||
|
pub tile_h: u32,
|
||||||
|
/// Width of the full font image in pixels.
|
||||||
|
img_w: u32,
|
||||||
|
/// Height of the full font image in pixels.
|
||||||
|
img_h: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BitmapFont {
|
||||||
|
/// Loads a bitmap font from raw PNG bytes (e.g. via `include_bytes!`).
|
||||||
|
///
|
||||||
|
/// `label` is passed to egui as the texture name for debugging.
|
||||||
|
/// Returns an error if the image cannot be decoded.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_bytes(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
bytes: &[u8],
|
||||||
|
tile_w: u32,
|
||||||
|
tile_h: u32,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let img = image::load_from_memory(bytes)?;
|
||||||
|
Self::from_image(ctx, img, tile_w, tile_h, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a bitmap font from a PNG file on disk.
|
||||||
|
pub fn load(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
path: &Path,
|
||||||
|
tile_w: u32,
|
||||||
|
tile_h: u32,
|
||||||
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let img = image::open(path)?;
|
||||||
|
let label = path.to_string_lossy();
|
||||||
|
Self::from_image(ctx, img, tile_w, tile_h, &label)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_image(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
img: image::DynamicImage,
|
||||||
|
tile_w: u32,
|
||||||
|
tile_h: u32,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let img_w = img.width();
|
||||||
|
let img_h = img.height();
|
||||||
|
|
||||||
|
// The top-left pixel defines the "background" color to treat as transparent.
|
||||||
|
let bg_pixel = img.get_pixel(0, 0);
|
||||||
|
let [br, bg, bb, _] = bg_pixel.0;
|
||||||
|
|
||||||
|
// Convert to RGBA: bg-colored pixels → transparent, all others → opaque white.
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
let mut pixels: Vec<egui::Color32> = Vec::with_capacity((img_w * img_h) as usize);
|
||||||
|
for pixel in rgba.pixels() {
|
||||||
|
let [r, g, b, _] = pixel.0;
|
||||||
|
if r == br && g == bg && b == bb {
|
||||||
|
pixels.push(egui::Color32::TRANSPARENT);
|
||||||
|
} else {
|
||||||
|
pixels.push(egui::Color32::WHITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let color_image = ColorImage {
|
||||||
|
size: [img_w as usize, img_h as usize],
|
||||||
|
pixels,
|
||||||
|
source_size: vec2(img_w as f32, img_h as f32),
|
||||||
|
};
|
||||||
|
|
||||||
|
let texture = ctx.load_texture(label, color_image, TextureOptions::NEAREST);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
texture,
|
||||||
|
tile_w,
|
||||||
|
tile_h,
|
||||||
|
img_w,
|
||||||
|
img_h,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the UV rectangle (in 0.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
use egui::{Color32, DragValue, Rect, Stroke, vec2};
|
||||||
|
|
||||||
|
use crate::font::BitmapFont;
|
||||||
|
use crate::game::FontSpec;
|
||||||
|
|
||||||
|
/// State for the font-picker dialog.
|
||||||
|
///
|
||||||
|
/// Holds the in-progress edit of font path and tile dimensions, and a loaded
|
||||||
|
/// preview font so the user can see the tilesheet before applying.
|
||||||
|
pub(crate) struct FontDialogState {
|
||||||
|
/// Path string as edited by the user (may differ from the applied font).
|
||||||
|
pub path: String,
|
||||||
|
/// Tile width being edited.
|
||||||
|
pub tile_w: u32,
|
||||||
|
/// Tile height being edited.
|
||||||
|
pub tile_h: u32,
|
||||||
|
/// Loaded preview of the current path+dimensions, if valid.
|
||||||
|
pub preview: Option<BitmapFont>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FontDialogState {
|
||||||
|
/// Creates dialog state pre-populated from an existing [`FontSpec`], or
|
||||||
|
/// defaults (8×8, empty path) when there is no board-level font.
|
||||||
|
pub(crate) fn from_spec(spec: Option<&FontSpec>) -> Self {
|
||||||
|
match spec {
|
||||||
|
Some(s) => Self {
|
||||||
|
path: s.path.clone(),
|
||||||
|
tile_w: s.tile_w,
|
||||||
|
tile_h: s.tile_h,
|
||||||
|
preview: None,
|
||||||
|
},
|
||||||
|
None => Self {
|
||||||
|
path: String::new(),
|
||||||
|
tile_w: 8,
|
||||||
|
tile_h: 8,
|
||||||
|
preview: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the floating font-picker dialog.
|
||||||
|
///
|
||||||
|
/// Lets the user choose a PNG file, set tile dimensions, preview the tilesheet
|
||||||
|
/// with grid lines, and apply the selection. When the user clicks "Apply",
|
||||||
|
/// `font_spec` is written and `open` is set to `false`.
|
||||||
|
pub(crate) fn show(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
open: &mut bool,
|
||||||
|
state: &mut FontDialogState,
|
||||||
|
font_spec: &mut Option<FontSpec>,
|
||||||
|
) {
|
||||||
|
let mut should_close = false;
|
||||||
|
|
||||||
|
egui::Window::new("Font")
|
||||||
|
.collapsible(false)
|
||||||
|
.resizable(true)
|
||||||
|
.open(open)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
// --- File chooser ---
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("File:");
|
||||||
|
ui.text_edit_singleline(&mut state.path);
|
||||||
|
if ui.button("Browse…").clicked() {
|
||||||
|
if let Some(path) = rfd::FileDialog::new()
|
||||||
|
.add_filter("PNG image", &["png"])
|
||||||
|
.pick_file()
|
||||||
|
{
|
||||||
|
state.path = path.to_string_lossy().into_owned();
|
||||||
|
state.preview = None; // reload on next reload click
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Tile dimensions ---
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Tile W:");
|
||||||
|
if ui
|
||||||
|
.add(DragValue::new(&mut state.tile_w).range(1..=64))
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
state.preview = None;
|
||||||
|
}
|
||||||
|
ui.label("Tile H:");
|
||||||
|
if ui
|
||||||
|
.add(DragValue::new(&mut state.tile_h).range(1..=64))
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
state.preview = None;
|
||||||
|
}
|
||||||
|
if ui.button("Load").clicked() {
|
||||||
|
state.preview = None;
|
||||||
|
if !state.path.is_empty() {
|
||||||
|
match BitmapFont::load(
|
||||||
|
ctx,
|
||||||
|
std::path::Path::new(&state.path),
|
||||||
|
state.tile_w,
|
||||||
|
state.tile_h,
|
||||||
|
) {
|
||||||
|
Ok(f) => state.preview = Some(f),
|
||||||
|
Err(e) => eprintln!("font load error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// --- Preview ---
|
||||||
|
if let Some(font) = &state.preview {
|
||||||
|
let tw = font.tile_w as f32;
|
||||||
|
let th = font.tile_h as f32;
|
||||||
|
let preview_size = vec2(font.img_w() as f32, font.img_h() as f32);
|
||||||
|
|
||||||
|
egui::ScrollArea::both()
|
||||||
|
.max_width(400.0)
|
||||||
|
.max_height(200.0)
|
||||||
|
.id_salt("font_preview")
|
||||||
|
.show(ui, |ui| {
|
||||||
|
let (rect, _) = ui.allocate_exact_size(preview_size, egui::Sense::hover());
|
||||||
|
// Draw the full font texture.
|
||||||
|
ui.painter().image(
|
||||||
|
font.texture.id(),
|
||||||
|
rect,
|
||||||
|
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||||
|
Color32::WHITE,
|
||||||
|
);
|
||||||
|
// Overlay grid lines at tile boundaries.
|
||||||
|
let p = ui.painter();
|
||||||
|
let cols = font.tile_cols();
|
||||||
|
let rows = font.img_h() / font.tile_h;
|
||||||
|
let stroke =
|
||||||
|
Stroke::new(0.5, Color32::from_rgba_unmultiplied(255, 255, 0, 120));
|
||||||
|
for c in 0..=cols {
|
||||||
|
let x = rect.min.x + c as f32 * tw;
|
||||||
|
p.line_segment(
|
||||||
|
[egui::pos2(x, rect.min.y), egui::pos2(x, rect.max.y)],
|
||||||
|
stroke,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for r in 0..=rows {
|
||||||
|
let y = rect.min.y + r as f32 * th;
|
||||||
|
p.line_segment(
|
||||||
|
[egui::pos2(rect.min.x, y), egui::pos2(rect.max.x, y)],
|
||||||
|
stroke,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ui.label("(no preview — click Load after setting path and tile size)");
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// --- Apply / Clear ---
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let can_apply = state.preview.is_some();
|
||||||
|
if ui
|
||||||
|
.add_enabled(can_apply, egui::Button::new("Apply"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
*font_spec = Some(FontSpec {
|
||||||
|
path: state.path.clone(),
|
||||||
|
tile_w: state.tile_w,
|
||||||
|
tile_h: state.tile_h,
|
||||||
|
});
|
||||||
|
should_close = true;
|
||||||
|
}
|
||||||
|
if ui.button("Use default").clicked() {
|
||||||
|
*font_spec = None;
|
||||||
|
should_close = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if should_close {
|
||||||
|
*open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-28
@@ -3,36 +3,55 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
/// The visual representation of a single board cell.
|
/// The visual representation of a single board cell.
|
||||||
///
|
///
|
||||||
/// `Glyph` holds everything needed to draw one cell on screen: which character
|
/// `Glyph` holds everything needed to draw one cell on screen: which tile
|
||||||
/// to display and what colors to use. It is stored per-cell (not per archetype),
|
/// index to display and what colors to use. It is stored per-cell (not per
|
||||||
/// so individual cells can vary their appearance independently — for example,
|
/// archetype), so individual cells can vary their appearance independently.
|
||||||
/// a field of "fire" tiles where each flame has a slightly different color
|
///
|
||||||
/// while all sharing the same [`Archetype`] behavior.
|
/// `tile` is a left-to-right, top-to-bottom index into the board's bitmap
|
||||||
|
/// font. For the default CP437 font this matches the ASCII/CP437 code point.
|
||||||
///
|
///
|
||||||
/// `Glyph` values come from the map file palette and are set at load time.
|
/// `Glyph` values come from the map file palette and are set at load time.
|
||||||
/// The player is the only entity whose glyph is hardcoded at runtime
|
/// The player is the only entity whose glyph is hardcoded at runtime
|
||||||
/// (see [`Glyph::player`]).
|
/// (see [`Glyph::player`]).
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub struct Glyph {
|
pub struct Glyph {
|
||||||
/// The character to display in this cell.
|
/// Tile index into the board's bitmap font (left-to-right, top-to-bottom).
|
||||||
pub ch: char,
|
pub tile: u32,
|
||||||
/// Foreground (text) color.
|
/// Foreground color, applied to non-background pixels of the tile.
|
||||||
pub fg: Color32,
|
pub fg: Color32,
|
||||||
/// Background color, drawn as a filled rectangle behind the character.
|
/// Background color, drawn as a filled rectangle behind the tile.
|
||||||
pub bg: Color32,
|
pub bg: Color32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Glyph {
|
impl Glyph {
|
||||||
/// Returns the glyph used to render the player: `@` in light blue on black.
|
/// Returns the glyph used to render the player: tile 64 (`@`) in light blue on black.
|
||||||
///
|
///
|
||||||
/// This is the only hardcoded glyph; all other glyphs come from the map
|
/// This is the only hardcoded glyph; all other glyphs come from the map
|
||||||
/// file palette. It will be removed once the player becomes a scripted
|
/// file palette. It will be removed once the player becomes a scripted
|
||||||
/// object with its own palette entry.
|
/// object with its own palette entry.
|
||||||
pub fn player() -> Self {
|
pub const fn player() -> Self {
|
||||||
Self { ch: '@', fg: Color32::LIGHT_BLUE, bg: Color32::BLACK }
|
Self {
|
||||||
|
tile: 64,
|
||||||
|
fg: Color32::LIGHT_BLUE,
|
||||||
|
bg: Color32::BLACK,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Specifies a bitmap font for a board.
|
||||||
|
///
|
||||||
|
/// Each board can optionally specify a font image and tile dimensions.
|
||||||
|
/// When absent, the app's default embedded CP437 font is used.
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct FontSpec {
|
||||||
|
/// Path to the PNG font image, relative to the working directory.
|
||||||
|
pub path: String,
|
||||||
|
/// Width of each tile in the font image, in pixels.
|
||||||
|
pub tile_w: u32,
|
||||||
|
/// Height of each tile in the font image, in pixels.
|
||||||
|
pub tile_h: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// The behavioral properties of a board cell at runtime.
|
/// The behavioral properties of a board cell at runtime.
|
||||||
///
|
///
|
||||||
/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It
|
/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It
|
||||||
@@ -87,19 +106,31 @@ impl Archetype {
|
|||||||
/// For `Object`, this is a placeholder until Rhai scripts drive the behavior.
|
/// For `Object`, this is a placeholder until Rhai scripts drive the behavior.
|
||||||
pub fn behavior(&self) -> Behavior {
|
pub fn behavior(&self) -> Behavior {
|
||||||
match self {
|
match self {
|
||||||
Archetype::Empty => Behavior { passable: true, opaque: false },
|
Archetype::Empty => Behavior {
|
||||||
Archetype::Wall => Behavior { passable: false, opaque: true },
|
passable: true,
|
||||||
Archetype::Object => Behavior { passable: false, opaque: false },
|
opaque: false,
|
||||||
Archetype::ErrorBlock => Behavior { passable: false, opaque: true },
|
},
|
||||||
|
Archetype::Wall => Behavior {
|
||||||
|
passable: false,
|
||||||
|
opaque: true,
|
||||||
|
},
|
||||||
|
Archetype::Object => Behavior {
|
||||||
|
passable: false,
|
||||||
|
opaque: false,
|
||||||
|
},
|
||||||
|
Archetype::ErrorBlock => Behavior {
|
||||||
|
passable: false,
|
||||||
|
opaque: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the canonical name used to reference this archetype in map files.
|
/// Returns the canonical name used to reference this archetype in map files.
|
||||||
pub fn name(&self) -> &'static str {
|
pub fn name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Archetype::Empty => "empty",
|
Archetype::Empty => "empty",
|
||||||
Archetype::Wall => "wall",
|
Archetype::Wall => "wall",
|
||||||
Archetype::Object => "object",
|
Archetype::Object => "object",
|
||||||
Archetype::ErrorBlock => "error_block",
|
Archetype::ErrorBlock => "error_block",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,11 +141,27 @@ impl Archetype {
|
|||||||
/// cells retain their own per-cell glyph.
|
/// cells retain their own per-cell glyph.
|
||||||
pub fn default_glyph(&self) -> Glyph {
|
pub fn default_glyph(&self) -> Glyph {
|
||||||
match self {
|
match self {
|
||||||
Archetype::Empty => Glyph { ch: ' ', fg: Color32::BLACK, bg: Color32::BLACK },
|
Archetype::Empty => Glyph {
|
||||||
Archetype::Wall => Glyph { ch: '#', fg: Color32::from_rgb(0x80, 0x80, 0x80), bg: Color32::from_rgb(0x60, 0x60, 0x60) },
|
tile: 32,
|
||||||
Archetype::Object => Glyph { ch: '?', fg: Color32::YELLOW, bg: Color32::BLACK },
|
fg: Color32::BLACK,
|
||||||
|
bg: Color32::BLACK,
|
||||||
|
},
|
||||||
|
Archetype::Wall => Glyph {
|
||||||
|
tile: 35,
|
||||||
|
fg: Color32::from_rgb(0x80, 0x80, 0x80),
|
||||||
|
bg: Color32::from_rgb(0x60, 0x60, 0x60),
|
||||||
|
},
|
||||||
|
Archetype::Object => Glyph {
|
||||||
|
tile: 63,
|
||||||
|
fg: Color32::YELLOW,
|
||||||
|
bg: Color32::BLACK,
|
||||||
|
},
|
||||||
// Visually distinct so malformed map files are immediately obvious.
|
// Visually distinct so malformed map files are immediately obvious.
|
||||||
Archetype::ErrorBlock => Glyph { ch: '?', fg: Color32::YELLOW, bg: Color32::RED },
|
Archetype::ErrorBlock => Glyph {
|
||||||
|
tile: 63,
|
||||||
|
fg: Color32::YELLOW,
|
||||||
|
bg: Color32::RED,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,10 +175,10 @@ impl TryFrom<&str> for Archetype {
|
|||||||
/// [`Archetype::ErrorBlock`] and log the error so the problem is visible.
|
/// [`Archetype::ErrorBlock`] and log the error so the problem is visible.
|
||||||
fn try_from(name: &str) -> Result<Self, Self::Error> {
|
fn try_from(name: &str) -> Result<Self, Self::Error> {
|
||||||
match name {
|
match name {
|
||||||
"empty" => Ok(Archetype::Empty),
|
"empty" => Ok(Archetype::Empty),
|
||||||
"wall" => Ok(Archetype::Wall),
|
"wall" => Ok(Archetype::Wall),
|
||||||
"object" => Ok(Archetype::Object),
|
"object" => Ok(Archetype::Object),
|
||||||
_ => Err(format!("unknown archetype: {name}")),
|
_ => Err(format!("unknown archetype: {name}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +277,8 @@ pub struct Board {
|
|||||||
pub objects: Vec<ObjectDef>,
|
pub objects: Vec<ObjectDef>,
|
||||||
/// Portals on this board. Parsed from the map file; not yet active.
|
/// Portals on this board. Parsed from the map file; not yet active.
|
||||||
pub portals: Vec<PortalDef>,
|
pub portals: Vec<PortalDef>,
|
||||||
|
/// Optional font override for this board. When `None`, the app default is used.
|
||||||
|
pub font: Option<FontSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Board {
|
impl Board {
|
||||||
@@ -295,4 +344,4 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-46
@@ -1,32 +1,38 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use egui::{Color32, Rect, Sense, Stroke, vec2};
|
use egui::{Color32, Rect, Sense, Stroke, vec2};
|
||||||
|
|
||||||
|
use crate::font::BitmapFont;
|
||||||
use crate::game::{Archetype, Glyph};
|
use crate::game::{Archetype, Glyph};
|
||||||
use crate::render::{CELL_H, CELL_W, paint_glyph, pos_to_cell};
|
use crate::render::{paint_glyph, pos_to_cell};
|
||||||
|
|
||||||
/// Renders the floating glyph-picker dialog and updates `glyph` in place.
|
/// Renders the floating glyph-picker dialog and updates `glyph` in place.
|
||||||
///
|
///
|
||||||
/// Shows three sections: board palette (unique glyphs on the current board),
|
/// Shows three sections: board palette (unique glyphs on the current board),
|
||||||
/// FG/BG color pickers, and a 16×6 printable-ASCII character grid.
|
/// FG/BG color pickers, and a scrollable grid of all tiles in the active font.
|
||||||
/// Closes when the window's X button is clicked, writing `false` to `open`.
|
/// Closes when the window's X button is clicked, writing `false` to `open`.
|
||||||
pub(crate) fn show(
|
pub(crate) fn show(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
open: &mut bool,
|
open: &mut bool,
|
||||||
glyph: &mut Glyph,
|
glyph: &mut Glyph,
|
||||||
board_cells: &[(Glyph, Archetype)],
|
board_cells: &[(Glyph, Archetype)],
|
||||||
|
font: &BitmapFont,
|
||||||
) {
|
) {
|
||||||
// Collect unique glyphs from the board before the window closure borrows
|
// Collect unique glyphs from the board before the window closure borrows
|
||||||
// `glyph` mutably, to avoid a borrow conflict with `board_cells`.
|
// `glyph` mutably, to avoid a borrow conflict with `board_cells`.
|
||||||
let board_glyphs: Vec<Glyph> = {
|
let board_glyphs: Vec<Glyph> = {
|
||||||
let mut seen: Vec<Glyph> = Vec::new();
|
let mut seen: HashSet<Glyph> = HashSet::new();
|
||||||
for (g, _) in board_cells {
|
board_cells
|
||||||
if !seen.contains(g) {
|
.iter()
|
||||||
seen.push(*g);
|
.map(|(g, _)| *g)
|
||||||
}
|
.filter(|g| seen.insert(*g))
|
||||||
}
|
.collect()
|
||||||
seen
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let tw = font.tile_w as f32;
|
||||||
|
let th = font.tile_h as f32;
|
||||||
|
|
||||||
egui::Window::new("Glyph")
|
egui::Window::new("Glyph")
|
||||||
.collapsible(false)
|
.collapsible(false)
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
@@ -35,13 +41,16 @@ pub(crate) fn show(
|
|||||||
ui.label("Board palette");
|
ui.label("Board palette");
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
for &g in &board_glyphs {
|
for &g in &board_glyphs {
|
||||||
let (r, resp) =
|
let (r, resp) = ui.allocate_exact_size(vec2(tw, th), Sense::click());
|
||||||
ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click());
|
|
||||||
let p = ui.painter();
|
let p = ui.painter();
|
||||||
paint_glyph(p, r, &g);
|
paint_glyph(p, r, &g, font);
|
||||||
if g == *glyph {
|
if g == *glyph {
|
||||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE),
|
p.rect_stroke(
|
||||||
egui::epaint::StrokeKind::Middle);
|
r,
|
||||||
|
0.0,
|
||||||
|
Stroke::new(1.0, Color32::WHITE),
|
||||||
|
egui::epaint::StrokeKind::Middle,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
*glyph = g;
|
*glyph = g;
|
||||||
@@ -55,45 +64,67 @@ pub(crate) fn show(
|
|||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("FG");
|
ui.label("FG");
|
||||||
egui::color_picker::color_edit_button_srgba(
|
egui::color_picker::color_edit_button_srgba(
|
||||||
ui, &mut glyph.fg, egui::color_picker::Alpha::Opaque);
|
ui,
|
||||||
|
&mut glyph.fg,
|
||||||
|
egui::color_picker::Alpha::Opaque,
|
||||||
|
);
|
||||||
ui.label("BG");
|
ui.label("BG");
|
||||||
egui::color_picker::color_edit_button_srgba(
|
egui::color_picker::color_edit_button_srgba(
|
||||||
ui, &mut glyph.bg, egui::color_picker::Alpha::Opaque);
|
ui,
|
||||||
|
&mut glyph.bg,
|
||||||
|
egui::color_picker::Alpha::Opaque,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
// 16 columns × 6 rows covering printable ASCII 0x20–0x7E.
|
// Full tile grid: column count matches the font image layout so the
|
||||||
ui.label("Character");
|
// picker visually mirrors the tilesheet.
|
||||||
const CHAR_COLS: usize = 16;
|
ui.label("Tile");
|
||||||
let chars: Vec<char> =
|
let tile_cols = font.tile_cols() as usize;
|
||||||
(0x20u32..=0x7eu32).filter_map(char::from_u32).collect();
|
let tile_count = font.tile_count() as usize;
|
||||||
let rows = chars.len().div_ceil(CHAR_COLS);
|
let rows = tile_count.div_ceil(tile_cols);
|
||||||
let grid_size = vec2(CHAR_COLS as f32 * CELL_W, rows as f32 * CELL_H);
|
let grid_size = vec2(tile_cols as f32 * tw, rows as f32 * th);
|
||||||
let (grid_rect, grid_resp) =
|
|
||||||
ui.allocate_exact_size(grid_size, Sense::click());
|
egui::ScrollArea::vertical()
|
||||||
{
|
.max_height(th * 16.0)
|
||||||
let p = ui.painter();
|
.id_salt("tile_grid")
|
||||||
for (i, &ch) in chars.iter().enumerate() {
|
.show(ui, |ui| {
|
||||||
let col = i % CHAR_COLS;
|
let (grid_rect, grid_resp) = ui.allocate_exact_size(grid_size, Sense::click());
|
||||||
let row = i / CHAR_COLS;
|
{
|
||||||
let tl = grid_rect.min
|
let p = ui.painter();
|
||||||
+ vec2(col as f32 * CELL_W, row as f32 * CELL_H);
|
for i in 0..tile_count {
|
||||||
let cell = Rect::from_min_size(tl, vec2(CELL_W, CELL_H));
|
let col = i % tile_cols;
|
||||||
paint_glyph(p, cell, &Glyph { ch, fg: glyph.fg, bg: glyph.bg });
|
let row = i / tile_cols;
|
||||||
if ch == glyph.ch {
|
let tl = grid_rect.min + vec2(col as f32 * tw, row as f32 * th);
|
||||||
p.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::WHITE),
|
let cell = Rect::from_min_size(tl, vec2(tw, th));
|
||||||
egui::epaint::StrokeKind::Middle);
|
let tile_glyph = Glyph {
|
||||||
|
tile: i as u32,
|
||||||
|
fg: glyph.fg,
|
||||||
|
bg: glyph.bg,
|
||||||
|
};
|
||||||
|
paint_glyph(p, cell, &tile_glyph, font);
|
||||||
|
if i as u32 == glyph.tile {
|
||||||
|
p.rect_stroke(
|
||||||
|
cell,
|
||||||
|
0.0,
|
||||||
|
Stroke::new(1.0, Color32::WHITE),
|
||||||
|
egui::epaint::StrokeKind::Middle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if grid_resp.clicked()
|
||||||
}
|
&& let Some(pos) = grid_resp.interact_pointer_pos()
|
||||||
if grid_resp.clicked()
|
{
|
||||||
&& let Some(pos) = grid_resp.interact_pointer_pos() {
|
let (col, row) = pos_to_cell(grid_rect.min, pos, tw, th);
|
||||||
let (col, row) = pos_to_cell(grid_rect.min, pos);
|
if col >= 0 && row >= 0 {
|
||||||
let idx = row as usize * CHAR_COLS + col as usize;
|
let idx = row as usize * tile_cols + col as usize;
|
||||||
if let Some(&ch) = chars.get(idx) {
|
if idx < tile_count {
|
||||||
glyph.ch = ch;
|
glyph.tile = idx as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-33
@@ -1,17 +1,21 @@
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use egui::{Key, Sense, vec2};
|
use egui::{Key, Sense, vec2};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
mod editor;
|
mod editor;
|
||||||
|
mod font;
|
||||||
|
mod font_dialog;
|
||||||
mod game;
|
mod game;
|
||||||
mod glyph_picker;
|
mod glyph_picker;
|
||||||
mod map_file;
|
mod map_file;
|
||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
use editor::{EditorState, show_editor_panel};
|
use editor::{EditorState, show_editor_panel};
|
||||||
use game::GameState;
|
use font::BitmapFont;
|
||||||
|
use game::{FontSpec, GameState};
|
||||||
use render::{
|
use render::{
|
||||||
DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W,
|
DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W, board_origin, draw_board,
|
||||||
board_origin, draw_board, pos_to_cell, CELL_H, CELL_W,
|
pos_to_cell,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Whether the application is in play mode or edit mode.
|
/// Whether the application is in play mode or edit mode.
|
||||||
@@ -39,7 +43,7 @@ fn main() -> eframe::Result<()> {
|
|||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"Kiln",
|
"Kiln",
|
||||||
options,
|
options,
|
||||||
Box::new(move |_cc| Ok(Box::new(App::new(board)))),
|
Box::new(move |cc| Ok(Box::new(App::new(board, &cc.egui_ctx)))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,17 +55,47 @@ struct App {
|
|||||||
state: GameState,
|
state: GameState,
|
||||||
mode: AppMode,
|
mode: AppMode,
|
||||||
editor: EditorState,
|
editor: EditorState,
|
||||||
|
/// The default font, loaded from `assets/default_font.png` or a placeholder.
|
||||||
|
default_font: BitmapFont,
|
||||||
|
/// A per-board font loaded from `board.font`, if one is specified.
|
||||||
|
board_font: Option<BitmapFont>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
/// Creates the app with the given board, starting in Play mode.
|
/// Creates the app with the given board, starting in Play mode.
|
||||||
fn new(board: game::Board) -> Self {
|
///
|
||||||
|
/// `egui_ctx` is needed to upload font textures before the first frame.
|
||||||
|
fn new(board: game::Board, egui_ctx: &egui::Context) -> Self {
|
||||||
|
// Load the default font from assets/. Fall back to a placeholder if missing.
|
||||||
|
let default_font = BitmapFont::load(egui_ctx, Path::new("assets/vga-font-8x16.png"), 8, 16)
|
||||||
|
.unwrap_or_else(|_| BitmapFont::create_placeholder(egui_ctx));
|
||||||
|
|
||||||
|
// If the board specifies its own font, try to load it now.
|
||||||
|
let board_font = board.font.as_ref().and_then(|spec| {
|
||||||
|
BitmapFont::load(egui_ctx, Path::new(&spec.path), spec.tile_w, spec.tile_h)
|
||||||
|
.map_err(|e| eprintln!("board font load error: {e}"))
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
state: GameState::new(board),
|
state: GameState::new(board),
|
||||||
mode: AppMode::Play,
|
mode: AppMode::Play,
|
||||||
editor: EditorState::new(),
|
editor: EditorState::new(),
|
||||||
|
default_font,
|
||||||
|
board_font,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reloads `board_font` from `font_spec`, or clears it if `font_spec` is `None`.
|
||||||
|
///
|
||||||
|
/// Called after the font dialog applies a change.
|
||||||
|
fn apply_font_spec(&mut self, egui_ctx: &egui::Context, font_spec: Option<&FontSpec>) {
|
||||||
|
self.board_font = font_spec.and_then(|spec| {
|
||||||
|
BitmapFont::load(egui_ctx, Path::new(&spec.path), spec.tile_w, spec.tile_h)
|
||||||
|
.map_err(|e| eprintln!("font apply error: {e}"))
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for App {
|
impl eframe::App for App {
|
||||||
@@ -70,10 +104,18 @@ impl eframe::App for App {
|
|||||||
// Arrow keys move the player only in Play mode.
|
// Arrow keys move the player only in Play mode.
|
||||||
if self.mode == AppMode::Play {
|
if self.mode == AppMode::Play {
|
||||||
ctx.input(|i| {
|
ctx.input(|i| {
|
||||||
if i.key_pressed(Key::ArrowUp) { self.state.try_move( 0, -1); }
|
if i.key_pressed(Key::ArrowUp) {
|
||||||
if i.key_pressed(Key::ArrowDown) { self.state.try_move( 0, 1); }
|
self.state.try_move(0, -1);
|
||||||
if i.key_pressed(Key::ArrowLeft) { self.state.try_move(-1, 0); }
|
}
|
||||||
if i.key_pressed(Key::ArrowRight) { self.state.try_move( 1, 0); }
|
if i.key_pressed(Key::ArrowDown) {
|
||||||
|
self.state.try_move(0, 1);
|
||||||
|
}
|
||||||
|
if i.key_pressed(Key::ArrowLeft) {
|
||||||
|
self.state.try_move(-1, 0);
|
||||||
|
}
|
||||||
|
if i.key_pressed(Key::ArrowRight) {
|
||||||
|
self.state.try_move(1, 0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,61 +136,78 @@ impl eframe::App for App {
|
|||||||
|
|
||||||
// --- Editor side panel ---
|
// --- Editor side panel ---
|
||||||
// Must be declared before CentralPanel so egui allocates its space first.
|
// Must be declared before CentralPanel so egui allocates its space first.
|
||||||
|
// Track whether font_spec changed so we can reload the board font.
|
||||||
|
let font_spec_before = self.state.board.font.clone();
|
||||||
if self.mode == AppMode::Edit {
|
if self.mode == AppMode::Edit {
|
||||||
show_editor_panel(ctx, &mut self.editor);
|
// Split borrows explicitly: active_font borrows board_font/default_font,
|
||||||
|
// while show_editor_panel takes &mut editor and &mut board.font separately.
|
||||||
|
let active_font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||||
|
show_editor_panel(
|
||||||
|
ctx,
|
||||||
|
&mut self.editor,
|
||||||
|
&mut self.state.board.font,
|
||||||
|
active_font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// If the board's font spec was changed by the editor, reload the board font.
|
||||||
|
if self.state.board.font != font_spec_before {
|
||||||
|
let spec = self.state.board.font.clone();
|
||||||
|
self.apply_font_spec(ctx, spec.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot the active font for this frame so all rendering uses the same one.
|
||||||
|
// We re-borrow active_font here since apply_font_spec may have changed board_font.
|
||||||
|
let font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||||
|
let tw = font.tile_w as f32;
|
||||||
|
let th = font.tile_h as f32;
|
||||||
|
|
||||||
// --- Board rendering ---
|
// --- Board rendering ---
|
||||||
// The board is drawn using the egui Painter API (direct 2D drawing),
|
// The board is drawn using the egui Painter API (direct 2D drawing).
|
||||||
// not egui widgets. This gives pixel-level control over placement.
|
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
let board_w = self.state.board.width;
|
let board_w = self.state.board.width;
|
||||||
let board_h = self.state.board.height;
|
let board_h = self.state.board.height;
|
||||||
let board_px = vec2(board_w as f32 * CELL_W, board_h as f32 * CELL_H);
|
let board_px = vec2(board_w as f32 * tw, board_h as f32 * th);
|
||||||
|
|
||||||
if self.mode == AppMode::Edit {
|
if self.mode == AppMode::Edit {
|
||||||
// Edit mode: ScrollArea lets the user scroll when the board
|
// Edit mode: ScrollArea lets the user scroll when the board overflows.
|
||||||
// overflows the viewport, showing scroll bars as needed.
|
|
||||||
egui::ScrollArea::new([true, true]).show(ui, |ui| {
|
egui::ScrollArea::new([true, true]).show(ui, |ui| {
|
||||||
let (rect, response) = ui.allocate_exact_size(board_px, Sense::click());
|
let (rect, response) = ui.allocate_exact_size(board_px, Sense::click());
|
||||||
draw_board(ui.painter(), rect.min, &self.state.board);
|
draw_board(ui.painter(), rect.min, &self.state.board, font);
|
||||||
if response.clicked()
|
if response.clicked()
|
||||||
&& let Some(pos) = response.interact_pointer_pos() {
|
&& let Some(pos) = response.interact_pointer_pos()
|
||||||
let (cx, cy) = pos_to_cell(rect.min, pos);
|
{
|
||||||
// floor() keeps out-of-bounds negatives negative; caught here.
|
let (cx, cy) = pos_to_cell(rect.min, pos, tw, th);
|
||||||
if cx >= 0 && cy >= 0 {
|
if cx >= 0 && cy >= 0 {
|
||||||
let cx = cx as usize;
|
let cx = cx as usize;
|
||||||
let cy = cy as usize;
|
let cy = cy as usize;
|
||||||
let board = &mut self.state.board;
|
let board = &mut self.state.board;
|
||||||
if cx < board.width && cy < board.height {
|
if cx < board.width && cy < board.height {
|
||||||
let arch = self.editor.selected;
|
let arch = self.editor.selected;
|
||||||
*board.get_mut(cx, cy) = (self.editor.glyph, arch);
|
*board.get_mut(cx, cy) = (self.editor.glyph, arch);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Play mode: player-centered viewport, no scroll bars.
|
// Play mode: player-centered viewport, no scroll bars.
|
||||||
let available = ui.available_rect_before_wrap();
|
let available = ui.available_rect_before_wrap();
|
||||||
let player = self.state.board.player;
|
let player = self.state.board.player;
|
||||||
let origin = board_origin(available, board_w, board_h, player);
|
let origin = board_origin(available, board_w, board_h, player, font);
|
||||||
draw_board(ui.painter(), origin, &self.state.board);
|
draw_board(ui.painter(), origin, &self.state.board, font);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Glyph picker dialog ---
|
// --- Glyph picker dialog ---
|
||||||
// Rendered after all panels so it floats on top.
|
// Rendered after all panels so it floats on top.
|
||||||
if self.editor.glyph_picker_open {
|
if self.editor.glyph_picker_open {
|
||||||
|
let font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||||
glyph_picker::show(
|
glyph_picker::show(
|
||||||
ctx,
|
ctx,
|
||||||
&mut self.editor.glyph_picker_open,
|
&mut self.editor.glyph_picker_open,
|
||||||
&mut self.editor.glyph,
|
&mut self.editor.glyph,
|
||||||
&self.state.board.cells,
|
&self.state.board.cells,
|
||||||
|
font,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// egui repaints automatically on input events, so no explicit
|
|
||||||
// request_repaint() is needed while the game is purely event-driven.
|
|
||||||
// Add it back (or call it from game logic) when continuous animation is needed.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-41
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef};
|
||||||
use serde::Deserialize;
|
|
||||||
use eframe::egui::Color32;
|
use eframe::egui::Color32;
|
||||||
use crate::game::{Archetype, Board, Glyph, ObjectDef, Player, PortalDef};
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// The top-level deserialization type for a `.toml` map file.
|
/// The top-level deserialization type for a `.toml` map file.
|
||||||
///
|
///
|
||||||
@@ -15,11 +15,12 @@ pub struct MapFile {
|
|||||||
/// The `[map]` section: name, dimensions, player start position.
|
/// The `[map]` section: name, dimensions, player start position.
|
||||||
pub map: MapHeader,
|
pub map: MapHeader,
|
||||||
/// The `[palette]` section: maps single-character keys to tile definitions.
|
/// The `[palette]` section: maps single-character keys to tile definitions.
|
||||||
/// Each entry defines both the visual ([`Glyph`]) and the [`Archetype`]
|
|
||||||
/// for all cells that use that character in the grid.
|
|
||||||
pub palette: HashMap<String, PaletteEntry>,
|
pub palette: HashMap<String, PaletteEntry>,
|
||||||
/// The `[grid]` section: the multi-line string that defines cell layout.
|
/// The `[grid]` section: the multi-line string that defines cell layout.
|
||||||
pub grid: GridData,
|
pub grid: GridData,
|
||||||
|
/// Optional `[font]` section specifying a bitmap font for this board.
|
||||||
|
#[serde(default)]
|
||||||
|
pub font: Option<FontHeader>,
|
||||||
/// Any `[[objects]]` entries. Optional; defaults to empty.
|
/// Any `[[objects]]` entries. Optional; defaults to empty.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub objects: Vec<ObjectDef>,
|
pub objects: Vec<ObjectDef>,
|
||||||
@@ -32,40 +33,68 @@ pub struct MapFile {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct MapHeader {
|
pub struct MapHeader {
|
||||||
/// Human-readable name for this board, e.g. `"Opening Room"`.
|
/// Human-readable name for this board, e.g. `"Opening Room"`.
|
||||||
/// Not yet displayed anywhere at runtime.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Width of the board in cells. Must match the length of every row in
|
/// Width of the board in cells. Must match the length of every row in `[grid] content`.
|
||||||
/// `[grid] content`.
|
|
||||||
pub width: usize,
|
pub width: usize,
|
||||||
/// Height of the board in cells. Must match the number of rows in
|
/// Height of the board in cells. Must match the number of rows in `[grid] content`.
|
||||||
/// `[grid] content`.
|
|
||||||
pub height: usize,
|
pub height: usize,
|
||||||
/// Starting position of the player as `[x, y]` (0-indexed, origin
|
/// Starting position of the player as `[x, y]` (0-indexed, origin top-left).
|
||||||
/// top-left). This field will become optional once the player is a
|
|
||||||
/// scripted object rather than a hardcoded entity.
|
|
||||||
pub player_start: [i32; 2],
|
pub player_start: [i32; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `[font]` section of a map file, specifying a bitmap font for this board.
|
||||||
|
///
|
||||||
|
/// When absent, the app's default embedded CP437 font is used.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct FontHeader {
|
||||||
|
/// Path to the PNG font image, relative to the working directory.
|
||||||
|
pub path: String,
|
||||||
|
/// Width of each tile in the font image, in pixels.
|
||||||
|
pub tile_w: u32,
|
||||||
|
/// Height of each tile in the font image, in pixels.
|
||||||
|
pub tile_h: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tile index in a palette entry: either a plain integer or a character literal.
|
||||||
|
///
|
||||||
|
/// Accepting both forms lets map files use `tile = 32` for new files or
|
||||||
|
/// `tile = " "` for convenience (the char is converted to its Unicode scalar).
|
||||||
|
/// This means existing maps that used `ch = "#"` can be migrated by renaming
|
||||||
|
/// the key to `tile`; the char form keeps working.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum TileIndex {
|
||||||
|
/// A direct tile index (e.g. `tile = 35`).
|
||||||
|
Num(u32),
|
||||||
|
/// A single-character shorthand (e.g. `tile = "#"`); converted to its Unicode scalar.
|
||||||
|
Chr(char),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TileIndex {
|
||||||
|
fn into_u32(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
TileIndex::Num(n) => n,
|
||||||
|
TileIndex::Chr(c) => c as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One entry in the `[palette]` table.
|
/// One entry in the `[palette]` table.
|
||||||
///
|
///
|
||||||
/// Each palette entry is keyed by a single character (e.g. `"#"` or `" "`).
|
/// Each entry is keyed by a single character (e.g. `"#"` or `" "`).
|
||||||
/// That character is used in the `[grid]` content string to place tiles.
|
/// That character is used in the `[grid]` content string to place tiles.
|
||||||
/// The entry defines both how the tile looks (`ch`, `fg`, `bg`) and which
|
/// The entry defines both how the tile looks (`tile`, `fg`, `bg`) and which
|
||||||
/// [`Archetype`] it uses for behavior.
|
/// [`Archetype`] it uses for behavior.
|
||||||
///
|
///
|
||||||
/// Note that `ch` (the display character) does not have to match the palette
|
/// `tile` accepts either an integer (`tile = 35`) or a char (`tile = "#"`).
|
||||||
/// key. The key is just a label for the grid; `ch` is what actually gets
|
|
||||||
/// rendered. This allows, for example, using `"W"` as the palette key for a
|
|
||||||
/// wall tile that displays as `#`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PaletteEntry {
|
pub struct PaletteEntry {
|
||||||
/// The archetype name for this tile, e.g. `"wall"` or `"empty"`.
|
/// The archetype name for this tile, e.g. `"wall"` or `"empty"`.
|
||||||
/// Parsed via [`Archetype::try_from`]; unknown names produce an
|
|
||||||
/// [`Archetype::ErrorBlock`] cell and a logged warning.
|
|
||||||
pub archetype: String,
|
pub archetype: String,
|
||||||
/// The character displayed in this cell.
|
/// The tile index into the board's bitmap font.
|
||||||
pub ch: char,
|
/// Accepts either an integer or a single-character string.
|
||||||
|
tile: TileIndex,
|
||||||
/// Foreground color as an `"#RRGGBB"` hex string.
|
/// Foreground color as an `"#RRGGBB"` hex string.
|
||||||
pub fg: String,
|
pub fg: String,
|
||||||
/// Background color as an `"#RRGGBB"` hex string.
|
/// Background color as an `"#RRGGBB"` hex string.
|
||||||
@@ -76,9 +105,7 @@ pub struct PaletteEntry {
|
|||||||
///
|
///
|
||||||
/// `content` is a TOML multi-line string. Each line is one row of the board;
|
/// `content` is a TOML multi-line string. Each line is one row of the board;
|
||||||
/// each character in a line is looked up in the palette to determine the
|
/// each character in a line is looked up in the palette to determine the
|
||||||
/// tile for that cell. TOML automatically strips the newline immediately
|
/// tile for that cell.
|
||||||
/// following the opening `"""`, so no special handling is needed for a
|
|
||||||
/// leading blank line.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct GridData {
|
pub struct GridData {
|
||||||
/// The raw grid content. Split by [`str::lines`] during loading.
|
/// The raw grid content. Split by [`str::lines`] during loading.
|
||||||
@@ -104,13 +131,11 @@ fn parse_color(hex: &str) -> Color32 {
|
|||||||
///
|
///
|
||||||
/// 1. **Palette pass** — each entry is parsed into a `(Glyph, Archetype)` pair
|
/// 1. **Palette pass** — each entry is parsed into a `(Glyph, Archetype)` pair
|
||||||
/// and stored in a temporary `HashMap` keyed by the palette character.
|
/// and stored in a temporary `HashMap` keyed by the palette character.
|
||||||
/// Unknown archetype names produce an [`Archetype::ErrorBlock`] and a
|
/// Unknown archetype names produce an [`Archetype::ErrorBlock`] and a logged
|
||||||
/// logged warning so malformed maps are visible in-game.
|
/// warning so malformed maps are visible in-game.
|
||||||
///
|
///
|
||||||
/// 2. **Grid pass** — `grid.content` is split into lines; each character
|
/// 2. **Grid pass** — `grid.content` is split into lines; each character is
|
||||||
/// is looked up in the palette map and pushed directly into `cells`.
|
/// looked up in the palette map and pushed directly into `cells`.
|
||||||
/// Unknown characters are silently skipped, so a malformed grid will
|
|
||||||
/// result in a `cells` vec shorter than `width * height`.
|
|
||||||
impl From<MapFile> for Board {
|
impl From<MapFile> for Board {
|
||||||
fn from(mf: MapFile) -> Self {
|
fn from(mf: MapFile) -> Self {
|
||||||
let w = mf.map.width;
|
let w = mf.map.width;
|
||||||
@@ -119,19 +144,24 @@ impl From<MapFile> for Board {
|
|||||||
// Pass 1: build a char → (Glyph, Archetype) lookup from the palette.
|
// Pass 1: build a char → (Glyph, Archetype) lookup from the palette.
|
||||||
let mut palette: HashMap<char, (Glyph, Archetype)> = HashMap::new();
|
let mut palette: HashMap<char, (Glyph, Archetype)> = HashMap::new();
|
||||||
|
|
||||||
for (key, entry) in &mf.palette {
|
for (key, entry) in mf.palette {
|
||||||
let key_char = key.chars().next().unwrap_or(' ');
|
let key_char = key.chars().next().unwrap_or(' ');
|
||||||
// Unknown archetype names fall back to ErrorBlock so the error is
|
let tile = entry.tile.into_u32();
|
||||||
// visible in-game rather than silently producing empty tiles.
|
|
||||||
match Archetype::try_from(entry.archetype.as_str()) {
|
match Archetype::try_from(entry.archetype.as_str()) {
|
||||||
Ok(archetype) => {
|
Ok(archetype) => {
|
||||||
let glyph = Glyph { ch: entry.ch, fg: parse_color(&entry.fg), bg: parse_color(&entry.bg) };
|
let glyph = Glyph {
|
||||||
|
tile,
|
||||||
|
fg: parse_color(&entry.fg),
|
||||||
|
bg: parse_color(&entry.bg),
|
||||||
|
};
|
||||||
palette.insert(key_char, (glyph, archetype));
|
palette.insert(key_char, (glyph, archetype));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Be sure to log and ignore the default glyph for an unknown archetype
|
|
||||||
eprintln!("{e}");
|
eprintln!("{e}");
|
||||||
palette.insert(key_char, (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock));
|
palette.insert(
|
||||||
|
key_char,
|
||||||
|
(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,6 +176,12 @@ impl From<MapFile> for Board {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let font = mf.font.map(|f| FontSpec {
|
||||||
|
path: f.path,
|
||||||
|
tile_w: f.tile_w,
|
||||||
|
tile_h: f.tile_h,
|
||||||
|
});
|
||||||
|
|
||||||
Board {
|
Board {
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
@@ -156,6 +192,7 @@ impl From<MapFile> for Board {
|
|||||||
},
|
},
|
||||||
objects: mf.objects,
|
objects: mf.objects,
|
||||||
portals: mf.portals,
|
portals: mf.portals,
|
||||||
|
font,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,9 +202,6 @@ impl From<MapFile> for Board {
|
|||||||
/// Reads the file at `path`, deserializes it as TOML into a [`MapFile`],
|
/// Reads the file at `path`, deserializes it as TOML into a [`MapFile`],
|
||||||
/// then converts it via [`From<MapFile>`]. Propagates both I/O errors and
|
/// then converts it via [`From<MapFile>`]. Propagates both I/O errors and
|
||||||
/// TOML parse errors through the `Box<dyn Error>` return.
|
/// TOML parse errors through the `Box<dyn Error>` return.
|
||||||
///
|
|
||||||
/// Called from `main()` before the window is created so that board
|
|
||||||
/// dimensions are available for window sizing.
|
|
||||||
pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>> {
|
pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>> {
|
||||||
let content = std::fs::read_to_string(path)?;
|
let content = std::fs::read_to_string(path)?;
|
||||||
let map_file: MapFile = toml::from_str(&content)?;
|
let map_file: MapFile = toml::from_str(&content)?;
|
||||||
|
|||||||
+64
-40
@@ -1,11 +1,11 @@
|
|||||||
// Layout constants — which value controls which part of the window:
|
// Layout constants — which value controls which part of the window:
|
||||||
//
|
//
|
||||||
// One board cell:
|
// One board cell (size varies with the active font's tile dimensions):
|
||||||
//
|
//
|
||||||
// ◄── CELL_W (14 px) ──►
|
// ◄── tile_w ──►
|
||||||
// ╔════════════════════╗ ▲
|
// ╔═════════════╗ ▲
|
||||||
// ║ @ ║ │ CELL_H (20 px)
|
// ║ @ ║ │ tile_h
|
||||||
// ╚════════════════════╝ ▼
|
// ╚═════════════╝ ▼
|
||||||
//
|
//
|
||||||
// Full window (Edit mode shown; side panel absent in Play mode):
|
// Full window (Edit mode shown; side panel absent in Play mode):
|
||||||
//
|
//
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
// │ PANEL_MARGIN (8 px) on all sides │ PALETTE_ │ ▲
|
// │ PANEL_MARGIN (8 px) on all sides │ PALETTE_ │ ▲
|
||||||
// │ ┌──────────────────────────────────────────┐ │ PANEL_W │ │
|
// │ ┌──────────────────────────────────────────┐ │ PANEL_W │ │
|
||||||
// │ │ │ │ (200 px) │ │ DEFAULT_
|
// │ │ │ │ (200 px) │ │ DEFAULT_
|
||||||
// │ │ board cells (CELL_W × CELL_H each) │ │ Edit mode │ │ WINDOW_H
|
// │ │ board cells (tile_w × tile_h each) │ │ Edit mode │ │ WINDOW_H
|
||||||
// │ │ │ │ only │ │ (524 px)
|
// │ │ │ │ only │ │ (524 px)
|
||||||
// │ └──────────────────────────────────────────┘ │ │ │
|
// │ └──────────────────────────────────────────┘ │ │ │
|
||||||
// │ │ │ ▼
|
// │ │ │ ▼
|
||||||
@@ -25,15 +25,11 @@
|
|||||||
// MIN_WINDOW_W and MIN_WINDOW_H are derived: 10 cells wide / 5 cells tall plus margins.
|
// MIN_WINDOW_W and MIN_WINDOW_H are derived: 10 cells wide / 5 cells tall plus margins.
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use egui::{Align2, FontId, Pos2, Rect, vec2};
|
use egui::{Pos2, Rect, vec2};
|
||||||
|
|
||||||
|
use crate::font::BitmapFont;
|
||||||
use crate::game::{Board, Glyph, Player};
|
use crate::game::{Board, Glyph, Player};
|
||||||
|
|
||||||
/// Width of one board cell in pixels.
|
|
||||||
pub(crate) const CELL_W: f32 = 14.0;
|
|
||||||
/// Height of one board cell in pixels.
|
|
||||||
pub(crate) const CELL_H: f32 = 20.0;
|
|
||||||
|
|
||||||
/// Approximate height of the egui menu bar in pixels, used for window sizing.
|
/// Approximate height of the egui menu bar in pixels, used for window sizing.
|
||||||
pub(crate) const MENU_H: f32 = 24.0;
|
pub(crate) const MENU_H: f32 = 24.0;
|
||||||
/// Default inner margin that egui's CentralPanel adds on all sides, in pixels.
|
/// Default inner margin that egui's CentralPanel adds on all sides, in pixels.
|
||||||
@@ -44,53 +40,73 @@ pub(crate) const PALETTE_PANEL_W: f32 = 200.0;
|
|||||||
pub(crate) const DEFAULT_WINDOW_W: f32 = 840.0;
|
pub(crate) const DEFAULT_WINDOW_W: f32 = 840.0;
|
||||||
/// Default window height in pixels. Independent of map size.
|
/// Default window height in pixels. Independent of map size.
|
||||||
pub(crate) const DEFAULT_WINDOW_H: f32 = 524.0;
|
pub(crate) const DEFAULT_WINDOW_H: f32 = 524.0;
|
||||||
/// Minimum window width: enough room for 10 cells plus margins.
|
/// Minimum window width: enough room for 10 cells plus margins (using 8 px tile width).
|
||||||
pub(crate) const MIN_WINDOW_W: f32 = CELL_W * 10.0 + 2.0 * PANEL_MARGIN;
|
pub(crate) const MIN_WINDOW_W: f32 = 8.0 * 10.0 + 2.0 * PANEL_MARGIN;
|
||||||
/// Minimum window height: enough room for 5 cells plus menu and margins.
|
/// Minimum window height: enough room for 5 cells plus menu and margins (using 8 px tile height).
|
||||||
pub(crate) const MIN_WINDOW_H: f32 = CELL_H * 5.0 + MENU_H + 2.0 * PANEL_MARGIN;
|
pub(crate) const MIN_WINDOW_H: f32 = 8.0 * 5.0 + MENU_H + 2.0 * PANEL_MARGIN;
|
||||||
|
|
||||||
/// Paints a glyph into an already-allocated `rect`.
|
/// Paints a glyph into an already-allocated `rect` using a bitmap font.
|
||||||
///
|
///
|
||||||
/// Unlike [`draw_glyph`], this does not compute the rect from grid coordinates —
|
/// Fills `rect` with `glyph.bg`, then draws the tile from `font` tinted with
|
||||||
/// the caller owns the rect (typically from [`egui::Ui::allocate_exact_size`])
|
/// `glyph.fg`. Unlike [`draw_glyph`], the caller owns the rect and handles
|
||||||
/// and handles interaction. Use this when you need the click [`egui::Response`].
|
/// interaction — use this when you need the click [`egui::Response`].
|
||||||
pub(crate) fn paint_glyph(painter: &egui::Painter, rect: Rect, glyph: &Glyph) {
|
pub(crate) fn paint_glyph(painter: &egui::Painter, rect: Rect, glyph: &Glyph, font: &BitmapFont) {
|
||||||
painter.rect_filled(rect, 0.0, glyph.bg);
|
painter.rect_filled(rect, 0.0, glyph.bg);
|
||||||
painter.text(rect.center(), Align2::CENTER_CENTER, glyph.ch, FontId::monospace(CELL_H), glyph.fg);
|
let uv = font.tile_uv(glyph.tile);
|
||||||
|
painter.image(font.texture.id(), rect, uv, glyph.fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws a single cell at grid position `(x, y)` using the given glyph.
|
/// Draws a single cell at grid position `(x, y)` using the given glyph and font.
|
||||||
///
|
///
|
||||||
/// `origin` is the pixel position of cell `(0, 0)` — the top-left corner of
|
/// `origin` is the pixel position of cell `(0, 0)` — the top-left corner of
|
||||||
/// the board within the panel. Cell positions are computed from `origin` using
|
/// the board within the panel. Cell positions are computed from `origin` using
|
||||||
/// [`CELL_W`] and [`CELL_H`].
|
/// the font's tile dimensions.
|
||||||
pub(crate) fn draw_glyph(painter: &egui::Painter, origin: Pos2, x: usize, y: usize, glyph: &Glyph) {
|
pub(crate) fn draw_glyph(
|
||||||
let tl = origin + vec2(x as f32 * CELL_W, y as f32 * CELL_H);
|
painter: &egui::Painter,
|
||||||
let rect = Rect::from_min_size(tl, vec2(CELL_W, CELL_H));
|
origin: Pos2,
|
||||||
paint_glyph(painter, rect, glyph);
|
x: usize,
|
||||||
|
y: usize,
|
||||||
|
glyph: &Glyph,
|
||||||
|
font: &BitmapFont,
|
||||||
|
) {
|
||||||
|
let tw = font.tile_w as f32;
|
||||||
|
let th = font.tile_h as f32;
|
||||||
|
let tl = origin + vec2(x as f32 * tw, y as f32 * th);
|
||||||
|
let rect = Rect::from_min_size(tl, vec2(tw, th));
|
||||||
|
paint_glyph(painter, rect, glyph, font);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws all board cells then the player overlay at `origin`.
|
/// Draws all board cells then the player overlay at `origin`.
|
||||||
pub(crate) fn draw_board(painter: &egui::Painter, origin: Pos2, board: &Board) {
|
pub(crate) fn draw_board(painter: &egui::Painter, origin: Pos2, board: &Board, font: &BitmapFont) {
|
||||||
for y in 0..board.height {
|
for y in 0..board.height {
|
||||||
for x in 0..board.width {
|
for x in 0..board.width {
|
||||||
let (glyph, _) = board.get(x, y);
|
let (glyph, _) = board.get(x, y);
|
||||||
draw_glyph(painter, origin, x, y, glyph);
|
draw_glyph(painter, origin, x, y, glyph, font);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The player is not a board cell; it is rendered on top as an overlay.
|
// The player is not a board cell; it is rendered on top as an overlay.
|
||||||
// This separate draw will be removed once the player becomes a scripted object.
|
// This separate draw will be removed once the player becomes a scripted object.
|
||||||
let player_glyph = Glyph::player();
|
let player_glyph = Glyph::player();
|
||||||
draw_glyph(painter, origin, board.player.x as usize, board.player.y as usize, &player_glyph);
|
draw_glyph(
|
||||||
|
painter,
|
||||||
|
origin,
|
||||||
|
board.player.x as usize,
|
||||||
|
board.player.y as usize,
|
||||||
|
&player_glyph,
|
||||||
|
font,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts a pixel position to cell coordinates relative to `origin`.
|
/// Converts a pixel position to cell coordinates relative to `origin`.
|
||||||
///
|
///
|
||||||
/// Uses floor so positions above or left of `origin` produce negative values —
|
/// Uses floor so positions above or left of `origin` produce negative values —
|
||||||
/// callers must guard `>= 0` before treating the result as a valid cell index.
|
/// callers must guard `>= 0` before treating the result as a valid cell index.
|
||||||
pub(crate) fn pos_to_cell(origin: Pos2, pos: Pos2) -> (i32, i32) {
|
pub(crate) fn pos_to_cell(origin: Pos2, pos: Pos2, tile_w: f32, tile_h: f32) -> (i32, i32) {
|
||||||
let rel = pos - origin;
|
let rel = pos - origin;
|
||||||
((rel.x / CELL_W).floor() as i32, (rel.y / CELL_H).floor() as i32)
|
(
|
||||||
|
(rel.x / tile_w).floor() as i32,
|
||||||
|
(rel.y / tile_h).floor() as i32,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the pixel position of board cell (0, 0) within `available`.
|
/// Computes the pixel position of board cell (0, 0) within `available`.
|
||||||
@@ -98,25 +114,33 @@ pub(crate) fn pos_to_cell(origin: Pos2, pos: Pos2) -> (i32, i32) {
|
|||||||
/// If the board fits along an axis, it is centered. If it overflows, the
|
/// If the board fits along an axis, it is centered. If it overflows, the
|
||||||
/// viewport is centered on the player and clamped so no empty space appears
|
/// viewport is centered on the player and clamped so no empty space appears
|
||||||
/// at the board edges.
|
/// at the board edges.
|
||||||
pub(crate) fn board_origin(available: Rect, board_w: usize, board_h: usize, player: Player) -> Pos2 {
|
pub(crate) fn board_origin(
|
||||||
let board_px_w = board_w as f32 * CELL_W;
|
available: Rect,
|
||||||
let board_px_h = board_h as f32 * CELL_H;
|
board_w: usize,
|
||||||
|
board_h: usize,
|
||||||
|
player: Player,
|
||||||
|
font: &BitmapFont,
|
||||||
|
) -> Pos2 {
|
||||||
|
let tw = font.tile_w as f32;
|
||||||
|
let th = font.tile_h as f32;
|
||||||
|
let board_px_w = board_w as f32 * tw;
|
||||||
|
let board_px_h = board_h as f32 * th;
|
||||||
|
|
||||||
let origin_x = if board_px_w <= available.width() {
|
let origin_x = if board_px_w <= available.width() {
|
||||||
// Board fits: center it horizontally.
|
// Board fits: center it horizontally.
|
||||||
available.min.x + (available.width() - board_px_w) / 2.0
|
available.min.x + (available.width() - board_px_w) / 2.0
|
||||||
} else {
|
} else {
|
||||||
// Board overflows: keep player at viewport center, clamp to board edges.
|
// Board overflows: keep player at viewport center, clamp to board edges.
|
||||||
let player_px_x = player.x as f32 * CELL_W + CELL_W / 2.0;
|
let player_px_x = player.x as f32 * tw + tw / 2.0;
|
||||||
(available.center().x - player_px_x)
|
(available.center().x - player_px_x)
|
||||||
.max(available.max.x - board_px_w) // right edge must reach viewport right
|
.max(available.max.x - board_px_w) // right edge must reach viewport right
|
||||||
.min(available.min.x) // left edge must reach viewport left
|
.min(available.min.x) // left edge must reach viewport left
|
||||||
};
|
};
|
||||||
|
|
||||||
let origin_y = if board_px_h <= available.height() {
|
let origin_y = if board_px_h <= available.height() {
|
||||||
available.min.y + (available.height() - board_px_h) / 2.0
|
available.min.y + (available.height() - board_px_h) / 2.0
|
||||||
} else {
|
} else {
|
||||||
let player_px_y = player.y as f32 * CELL_H + CELL_H / 2.0;
|
let player_px_y = player.y as f32 * th + th / 2.0;
|
||||||
(available.center().y - player_px_y)
|
(available.center().y - player_px_y)
|
||||||
.max(available.max.y - board_px_h)
|
.max(available.max.y - board_px_h)
|
||||||
.min(available.min.y)
|
.min(available.min.y)
|
||||||
|
|||||||
Reference in New Issue
Block a user