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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user