rm egui
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "kiln-egui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "kiln"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
kiln-core = { path = "../kiln-core" }
|
||||
eframe = "0.33.3"
|
||||
color = "0.3.3"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
rfd = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
egui_code_editor = "=0.2.20"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,342 +0,0 @@
|
||||
use eframe::egui;
|
||||
use egui::{Sense, vec2};
|
||||
|
||||
use crate::font::BitmapFont;
|
||||
use crate::font_dialog::{FontDialogState, show as show_font_dialog};
|
||||
use crate::render::{PALETTE_PANEL_W, glyph_preview_button, paint_glyph};
|
||||
use kiln_core::game::{ALL_ARCHETYPES, Archetype, Board, Glyph};
|
||||
|
||||
/// Which tab is active in the editor side panel.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum EditorTab {
|
||||
/// Archetype + glyph palette for painting cells.
|
||||
Palette,
|
||||
/// Object placement and script assignment.
|
||||
Objects,
|
||||
/// Board-level properties (font, board script).
|
||||
Board,
|
||||
/// Named script library for this board.
|
||||
Scripts,
|
||||
/// World-level properties (placeholder).
|
||||
World,
|
||||
}
|
||||
|
||||
/// Palette-tab state: what gets stamped when painting cells.
|
||||
pub(crate) struct PaletteState {
|
||||
/// The archetype that will be stamped when the user clicks a cell.
|
||||
pub(crate) selected: Archetype,
|
||||
/// The glyph (tile index + colors) to stamp. Independent of the archetype's default.
|
||||
pub(crate) glyph: Glyph,
|
||||
/// Whether the glyph picker dialog is currently open (Palette tab).
|
||||
pub(crate) picker_open: bool,
|
||||
}
|
||||
|
||||
/// Font-dialog state: the floating font picker and its in-progress edit.
|
||||
pub(crate) struct FontDialog {
|
||||
/// Whether the font picker dialog is currently open.
|
||||
pub(crate) open: bool,
|
||||
/// In-progress state for the font dialog while it is open.
|
||||
pub(crate) state: FontDialogState,
|
||||
}
|
||||
|
||||
/// Objects-tab state: selection, placement, and the glyph being edited.
|
||||
pub(crate) struct ObjectEdit {
|
||||
/// Index into `board.objects` of the currently selected object, or `None`.
|
||||
pub(crate) selected: Option<usize>,
|
||||
/// Whether the glyph picker is open for editing the selected object's glyph.
|
||||
pub(crate) picker_open: bool,
|
||||
/// Local copy of the selected object's glyph for the picker to edit.
|
||||
///
|
||||
/// Kept separate from `board.objects` to avoid a borrow conflict while the
|
||||
/// glyph picker holds `&mut Glyph` and the board is also borrowed. Written
|
||||
/// back to `board.objects[i].glyph` each frame while an object is selected.
|
||||
pub(crate) editing_glyph: Glyph,
|
||||
/// When `true`, the next board click in the Objects tab places a new object.
|
||||
/// Set by the "Add Object" button; cleared after placement or on tab switch.
|
||||
pub(crate) placing: bool,
|
||||
}
|
||||
|
||||
/// Scripts-tab state: the script open in the code editor and the new-script draft.
|
||||
pub(crate) struct ScriptEdit {
|
||||
/// Name of the script currently open in the code editor, or `None` if closed.
|
||||
pub(crate) editing: Option<String>,
|
||||
/// Working copy of the script body while the code editor is open.
|
||||
/// Written back to `board.scripts` when the user clicks "Back".
|
||||
pub(crate) content: String,
|
||||
/// Draft name typed into the "new script" text field in the Scripts tab.
|
||||
pub(crate) new_name: String,
|
||||
}
|
||||
|
||||
/// Transient editor state. Only meaningful when the app is in Edit mode.
|
||||
///
|
||||
/// Fields are grouped by concern into sub-structs so each editor tab can take
|
||||
/// the narrow borrow it needs rather than all of `EditorState`.
|
||||
pub(crate) struct EditorState {
|
||||
/// Which side-panel tab is currently shown.
|
||||
pub(crate) tab: EditorTab,
|
||||
/// Palette tab: archetype + glyph being painted.
|
||||
pub(crate) palette: PaletteState,
|
||||
/// The font picker dialog and its edit buffer.
|
||||
pub(crate) font_dialog: FontDialog,
|
||||
/// Objects tab: selection, placement, and object glyph editing.
|
||||
pub(crate) objects: ObjectEdit,
|
||||
/// Scripts tab: the open script and new-script draft.
|
||||
pub(crate) scripts: ScriptEdit,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Creates editor state with `Wall` selected and all dialogs closed.
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
tab: EditorTab::Palette,
|
||||
palette: PaletteState {
|
||||
selected: Archetype::Wall,
|
||||
glyph: Archetype::Wall.default_glyph(),
|
||||
picker_open: false,
|
||||
},
|
||||
font_dialog: FontDialog {
|
||||
open: false,
|
||||
state: FontDialogState::from_spec(None),
|
||||
},
|
||||
objects: ObjectEdit {
|
||||
selected: None,
|
||||
picker_open: false,
|
||||
editing_glyph: Glyph::player(), // overwritten on first selection
|
||||
placing: false,
|
||||
},
|
||||
scripts: ScriptEdit {
|
||||
editing: None,
|
||||
content: String::new(),
|
||||
new_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the editor side panel (must be called before `CentralPanel`).
|
||||
///
|
||||
/// Shows tab bar (Palette / Objects / Board / Scripts / World) and the content
|
||||
/// for the active tab. `board` is the current board, used by the Objects and
|
||||
/// Scripts tabs and to read/write the board font. `active_font` is the resolved
|
||||
/// font currently in use for rendering.
|
||||
pub(crate) fn show_editor_panel(
|
||||
ctx: &egui::Context,
|
||||
editor: &mut EditorState,
|
||||
board: &mut Board,
|
||||
active_font: &BitmapFont,
|
||||
) {
|
||||
egui::SidePanel::right("palette_panel")
|
||||
.resizable(true)
|
||||
.default_width(PALETTE_PANEL_W)
|
||||
.show(ctx, |ui| {
|
||||
// Tab bar — wraps automatically if the panel is narrow.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.selectable_value(&mut editor.tab, EditorTab::Palette, "Palette");
|
||||
ui.selectable_value(&mut editor.tab, EditorTab::Objects, "Objects");
|
||||
ui.selectable_value(&mut editor.tab, EditorTab::Board, "Board");
|
||||
ui.selectable_value(&mut editor.tab, EditorTab::Scripts, "Scripts");
|
||||
ui.selectable_value(&mut editor.tab, EditorTab::World, "World");
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// Dispatch to the active tab's renderer, passing only the sub-struct each
|
||||
// tab needs. World is an empty placeholder.
|
||||
match editor.tab {
|
||||
EditorTab::Palette => palette_tab(ui, &mut editor.palette, active_font),
|
||||
EditorTab::Objects => objects_tab(
|
||||
ui,
|
||||
&mut editor.objects,
|
||||
&mut editor.scripts,
|
||||
board,
|
||||
active_font,
|
||||
),
|
||||
EditorTab::Board => board_tab(ui, &mut editor.font_dialog, board),
|
||||
EditorTab::Scripts => scripts_tab(ui, &mut editor.scripts, board),
|
||||
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,
|
||||
&mut board.font,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Palette tab: scrollable archetype list plus the current-glyph preview button.
|
||||
fn palette_tab(ui: &mut egui::Ui, palette: &mut PaletteState, active_font: &BitmapFont) {
|
||||
let tw = active_font.tile_w as f32;
|
||||
let th = active_font.tile_h as f32;
|
||||
|
||||
ui.label("Element");
|
||||
|
||||
// Scrollable archetype list; shows at most 5 rows before scrolling.
|
||||
let row_h = th + ui.spacing().item_spacing.y;
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(row_h * 5.0)
|
||||
.id_salt("arch_list")
|
||||
.show(ui, |ui| {
|
||||
for &archetype in ALL_ARCHETYPES {
|
||||
let is_selected = palette.selected == archetype;
|
||||
let glyph = archetype.default_glyph();
|
||||
ui.horizontal(|ui| {
|
||||
let (r, cell_resp) = ui.allocate_exact_size(vec2(tw, th), Sense::click());
|
||||
paint_glyph(ui.painter(), r, &glyph, active_font);
|
||||
let label_resp = ui.selectable_label(is_selected, archetype.name());
|
||||
if cell_resp.clicked() || label_resp.clicked() {
|
||||
palette.selected = archetype;
|
||||
palette.glyph = archetype.default_glyph();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.label("Glyph");
|
||||
// Preview swatch for the current glyph; clicking opens the glyph picker.
|
||||
if glyph_preview_button(ui, &palette.glyph, active_font).clicked() {
|
||||
palette.picker_open = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects tab: add/select objects and edit the selected object's glyph, flags, and script.
|
||||
fn objects_tab(
|
||||
ui: &mut egui::Ui,
|
||||
objects: &mut ObjectEdit,
|
||||
scripts: &mut ScriptEdit,
|
||||
board: &mut Board,
|
||||
active_font: &BitmapFont,
|
||||
) {
|
||||
// "Add Object" toggles placement mode; next board click places a new object.
|
||||
let add_label = if objects.placing {
|
||||
"Cancel Add"
|
||||
} else {
|
||||
"Add Object"
|
||||
};
|
||||
if ui.button(add_label).clicked() {
|
||||
objects.placing = !objects.placing;
|
||||
objects.selected = None;
|
||||
}
|
||||
if objects.placing {
|
||||
ui.label("Click on the board to place.");
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let Some(i) = objects.selected else {
|
||||
if !objects.placing {
|
||||
ui.label("Click an object on the board to select it.");
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// Collect sorted script names before taking a mutable borrow on board.objects.
|
||||
let mut script_names: Vec<String> = board.scripts.keys().cloned().collect();
|
||||
script_names.sort();
|
||||
|
||||
ui.label("Glyph");
|
||||
// Preview swatch for the object's glyph; clicking opens the glyph picker.
|
||||
if glyph_preview_button(ui, &objects.editing_glyph, active_font).clicked() {
|
||||
objects.picker_open = true;
|
||||
}
|
||||
|
||||
let obj = &mut board.objects[i];
|
||||
|
||||
ui.checkbox(&mut obj.passable, "Passable");
|
||||
ui.checkbox(&mut obj.opaque, "Opaque");
|
||||
|
||||
let current = obj.script_name.as_deref().unwrap_or("(none)").to_string();
|
||||
|
||||
ui.label("Script");
|
||||
egui::ComboBox::from_id_salt("obj_script")
|
||||
.selected_text(¤t)
|
||||
.show_ui(ui, |ui| {
|
||||
// "(none)" clears the script assignment.
|
||||
if ui
|
||||
.selectable_label(obj.script_name.is_none(), "(none)")
|
||||
.clicked()
|
||||
{
|
||||
obj.script_name = None;
|
||||
}
|
||||
for name in &script_names {
|
||||
let is_sel = obj.script_name.as_deref() == Some(name.as_str());
|
||||
if ui.selectable_label(is_sel, name.as_str()).clicked() {
|
||||
obj.script_name = Some(name.clone());
|
||||
}
|
||||
}
|
||||
// Placeholder for future "create and edit a new script" flow.
|
||||
let _ = ui.selectable_label(false, "(create new...)");
|
||||
});
|
||||
|
||||
if ui.button("Edit Script").clicked()
|
||||
&& let Some(ref script_name) = obj.script_name.clone()
|
||||
{
|
||||
scripts.content = board.scripts.get(script_name).cloned().unwrap_or_default();
|
||||
scripts.editing = Some(script_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Board tab: board-level font selection and zoom.
|
||||
fn board_tab(ui: &mut egui::Ui, font_dialog: &mut FontDialog, board: &mut Board) {
|
||||
let font_label = match &board.font {
|
||||
Some(s) => s.path.clone(),
|
||||
None => "(default)".to_owned(),
|
||||
};
|
||||
ui.label(format!("Font: {font_label}"));
|
||||
if ui.button("Font…").clicked() {
|
||||
font_dialog.state = FontDialogState::from_spec(board.font.as_ref());
|
||||
font_dialog.open = true;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.label("Zoom");
|
||||
ui.add(egui::Slider::new(&mut board.zoom, 1..=8).text("×"));
|
||||
}
|
||||
|
||||
/// Scripts tab: create a named script and edit existing ones.
|
||||
fn scripts_tab(ui: &mut egui::Ui, scripts: &mut ScriptEdit, board: &mut Board) {
|
||||
// New script: text field + Create button.
|
||||
ui.horizontal(|ui| {
|
||||
ui.text_edit_singleline(&mut scripts.new_name);
|
||||
let can_create =
|
||||
!scripts.new_name.is_empty() && !board.scripts.contains_key(&scripts.new_name);
|
||||
if ui
|
||||
.add_enabled(can_create, egui::Button::new("Create"))
|
||||
.clicked()
|
||||
{
|
||||
let name = scripts.new_name.clone();
|
||||
board.scripts.insert(name.clone(), String::new());
|
||||
scripts.new_name.clear();
|
||||
scripts.content = String::new();
|
||||
scripts.editing = Some(name);
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
if board.scripts.is_empty() {
|
||||
ui.label("No scripts yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut names: Vec<String> = board.scripts.keys().cloned().collect();
|
||||
names.sort();
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("scripts_list")
|
||||
.show(ui, |ui| {
|
||||
for name in &names {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(name);
|
||||
if ui.button("Edit").clicked() {
|
||||
scripts.content = board.scripts.get(name).cloned().unwrap_or_default();
|
||||
scripts.editing = Some(name.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
use eframe::egui;
|
||||
use egui::{ColorImage, Rect, TextureHandle, TextureOptions, Vec2, 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 pixel size of one rendered cell at the given integer `zoom`.
|
||||
///
|
||||
/// A cell is `tile_w × tile_h` scaled by `zoom`. Centralizes the
|
||||
/// `tile_w as f32 * zoom as f32` math used throughout rendering and hit-testing.
|
||||
pub fn cell_size(&self, zoom: u32) -> Vec2 {
|
||||
vec2(
|
||||
self.tile_w as f32 * zoom as f32,
|
||||
self.tile_h as f32 * zoom as f32,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
use eframe::egui;
|
||||
use egui::{Color32, DragValue, Rect, Stroke, vec2};
|
||||
|
||||
use crate::font::BitmapFont;
|
||||
use kiln_core::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;
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use eframe::egui;
|
||||
use egui::{Color32, Rect, Sense, Stroke, vec2};
|
||||
|
||||
use crate::font::BitmapFont;
|
||||
use crate::render::{color32_to_rgba8, paint_glyph, pos_to_cell, rgba8_to_color32};
|
||||
use kiln_core::game::{Archetype, Glyph};
|
||||
|
||||
/// Renders the floating glyph-picker dialog and updates `glyph` in place.
|
||||
///
|
||||
/// Shows three sections: board palette (unique glyphs on the current board),
|
||||
/// FG/BG color pickers, and a scrollable grid of all tiles in the active font.
|
||||
/// Closes when the window's X button is clicked, writing `false` to `open`.
|
||||
pub(crate) fn show(
|
||||
ctx: &egui::Context,
|
||||
open: &mut bool,
|
||||
glyph: &mut Glyph,
|
||||
board_cells: &[(Glyph, Archetype)],
|
||||
font: &BitmapFont,
|
||||
) {
|
||||
// Collect unique glyphs from the board before the window closure borrows
|
||||
// `glyph` mutably, to avoid a borrow conflict with `board_cells`.
|
||||
let board_glyphs: Vec<Glyph> = {
|
||||
let mut seen: HashSet<Glyph> = HashSet::new();
|
||||
board_cells
|
||||
.iter()
|
||||
.map(|(g, _)| *g)
|
||||
.filter(|g| seen.insert(*g))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let tw = font.tile_w as f32;
|
||||
let th = font.tile_h as f32;
|
||||
|
||||
egui::Window::new("Glyph")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.open(open)
|
||||
.show(ctx, |ui| {
|
||||
ui.label("Board palette");
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for &g in &board_glyphs {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(tw, th), Sense::click());
|
||||
let p = ui.painter();
|
||||
paint_glyph(p, r, &g, font);
|
||||
if g == *glyph {
|
||||
p.rect_stroke(
|
||||
r,
|
||||
0.0,
|
||||
Stroke::new(1.0, Color32::WHITE),
|
||||
egui::epaint::StrokeKind::Middle,
|
||||
);
|
||||
}
|
||||
if resp.clicked() {
|
||||
*glyph = g;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.label("Colors");
|
||||
ui.horizontal(|ui| {
|
||||
// The color picker works with Color32; convert to/from Rgba8 around the call.
|
||||
let mut fg = rgba8_to_color32(glyph.fg);
|
||||
ui.label("FG");
|
||||
egui::color_picker::color_edit_button_srgba(
|
||||
ui,
|
||||
&mut fg,
|
||||
egui::color_picker::Alpha::Opaque,
|
||||
);
|
||||
glyph.fg = color32_to_rgba8(fg);
|
||||
|
||||
let mut bg = rgba8_to_color32(glyph.bg);
|
||||
ui.label("BG");
|
||||
egui::color_picker::color_edit_button_srgba(
|
||||
ui,
|
||||
&mut bg,
|
||||
egui::color_picker::Alpha::Opaque,
|
||||
);
|
||||
glyph.bg = color32_to_rgba8(bg);
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Full tile grid: column count matches the font image layout so the
|
||||
// picker visually mirrors the tilesheet.
|
||||
ui.label("Tile");
|
||||
let tile_cols = font.tile_cols() as usize;
|
||||
let tile_count = font.tile_count() as usize;
|
||||
let rows = tile_count.div_ceil(tile_cols);
|
||||
let grid_size = vec2(tile_cols as f32 * tw, rows as f32 * th);
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(th * 16.0)
|
||||
.id_salt("tile_grid")
|
||||
.show(ui, |ui| {
|
||||
let (grid_rect, grid_resp) = ui.allocate_exact_size(grid_size, Sense::click());
|
||||
{
|
||||
let p = ui.painter();
|
||||
for i in 0..tile_count {
|
||||
let col = i % tile_cols;
|
||||
let row = i / tile_cols;
|
||||
let tl = grid_rect.min + vec2(col as f32 * tw, row as f32 * th);
|
||||
let cell = Rect::from_min_size(tl, vec2(tw, th));
|
||||
let tile_glyph = Glyph {
|
||||
tile: i as u32,
|
||||
fg: glyph.fg,
|
||||
bg: glyph.bg,
|
||||
};
|
||||
paint_glyph(p, cell, &tile_glyph, font);
|
||||
if i as u32 == glyph.tile {
|
||||
p.rect_stroke(
|
||||
cell,
|
||||
0.0,
|
||||
Stroke::new(1.0, Color32::WHITE),
|
||||
egui::epaint::StrokeKind::Middle,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if grid_resp.clicked()
|
||||
&& let Some(pos) = grid_resp.interact_pointer_pos()
|
||||
{
|
||||
let (col, row) = pos_to_cell(grid_rect.min, pos, tw, th);
|
||||
if col >= 0 && row >= 0 {
|
||||
let idx = row as usize * tile_cols + col as usize;
|
||||
if idx < tile_count {
|
||||
glyph.tile = idx as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
use eframe::egui;
|
||||
use egui::{Key, Sense, vec2};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod editor;
|
||||
mod font;
|
||||
mod font_dialog;
|
||||
mod glyph_picker;
|
||||
mod render;
|
||||
mod script_editor;
|
||||
|
||||
use editor::{EditorState, EditorTab, show_editor_panel};
|
||||
use font::BitmapFont;
|
||||
use kiln_core::game::{FontSpec, GameState, ObjectDef};
|
||||
use kiln_core::map_file;
|
||||
use render::{
|
||||
DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W, board_origin, draw_board,
|
||||
draw_object_overlays, pos_to_cell,
|
||||
};
|
||||
|
||||
/// Whether the application is in play mode or edit mode.
|
||||
///
|
||||
/// In `Play` mode the game runs normally: arrow keys move the player.
|
||||
/// In `Edit` mode arrow keys are inactive, a side panel shows the archetype
|
||||
/// palette, and clicking on the viewport paints cells.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
enum AppMode {
|
||||
/// Normal game play. Arrow keys control the player.
|
||||
Play,
|
||||
/// Board editing. Side panel shows archetypes; click to paint cells.
|
||||
Edit,
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let start_path = PathBuf::from("maps/start.toml");
|
||||
let board =
|
||||
map_file::load(start_path.to_str().unwrap()).expect("failed to load maps/start.toml");
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([DEFAULT_WINDOW_W, DEFAULT_WINDOW_H])
|
||||
.with_min_inner_size([MIN_WINDOW_W, MIN_WINDOW_H]),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"Kiln",
|
||||
options,
|
||||
Box::new(move |cc| Ok(Box::new(App::new(board, &cc.egui_ctx, start_path)))),
|
||||
)
|
||||
}
|
||||
|
||||
/// The eframe application. Owns the game state and drives the frame loop.
|
||||
///
|
||||
/// eframe calls [`App::update`] every frame. All input handling, game logic
|
||||
/// updates, and rendering happen there. There is no separate game loop thread.
|
||||
struct App {
|
||||
state: GameState,
|
||||
mode: AppMode,
|
||||
editor: EditorState,
|
||||
/// The default font, embedded in the binary and loaded at startup.
|
||||
default_font: BitmapFont,
|
||||
/// A per-board font loaded from `board.font`, if one is specified.
|
||||
board_font: Option<BitmapFont>,
|
||||
/// Path of the file currently open. `None` until the user saves for the first time.
|
||||
/// Used by Save to write without prompting; Save As always prompts and updates this.
|
||||
current_file_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Creates the app with the given board, starting in Play mode.
|
||||
///
|
||||
/// `egui_ctx` is needed to upload font textures before the first frame.
|
||||
/// `initial_path` is the file the board was loaded from and becomes the
|
||||
/// default Save target.
|
||||
fn new(board: kiln_core::game::Board, egui_ctx: &egui::Context, initial_path: PathBuf) -> Self {
|
||||
// Default font is embedded in the binary; no runtime file I/O needed.
|
||||
let font_bytes = include_bytes!("../assets/vga-font-8x16.png");
|
||||
let default_font = BitmapFont::from_bytes(egui_ctx, font_bytes, 8, 16, "vga-font-8x16")
|
||||
.unwrap_or_else(|_| BitmapFont::create_placeholder(egui_ctx));
|
||||
|
||||
// If the board specifies its own font, try to load it now.
|
||||
let board_font = board.font.as_ref().and_then(|spec| {
|
||||
BitmapFont::load(egui_ctx, Path::new(&spec.path), spec.tile_w, spec.tile_h)
|
||||
.map_err(|e| eprintln!("board font load error: {e}"))
|
||||
.ok()
|
||||
});
|
||||
|
||||
Self {
|
||||
state: GameState::new(board),
|
||||
mode: AppMode::Play,
|
||||
editor: EditorState::new(),
|
||||
default_font,
|
||||
board_font,
|
||||
current_file_path: Some(initial_path),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
});
|
||||
}
|
||||
|
||||
/// The font currently used for rendering: the per-board font if loaded, else the default.
|
||||
fn active_font(&self) -> &BitmapFont {
|
||||
self.board_font.as_ref().unwrap_or(&self.default_font)
|
||||
}
|
||||
|
||||
/// Writes the board to `path`, logging any error. Does not change `current_file_path`.
|
||||
fn save_to(&self, path: &Path) {
|
||||
if let Err(e) = map_file::save(&self.state.board, path) {
|
||||
eprintln!("save error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompts for a destination and saves there, recording it as the current file on success.
|
||||
fn save_as(&mut self) {
|
||||
if let Some(path) = rfd::FileDialog::new()
|
||||
.add_filter("Map file", &["toml"])
|
||||
.save_file()
|
||||
{
|
||||
if let Err(e) = map_file::save(&self.state.board, &path) {
|
||||
eprintln!("save error: {e}");
|
||||
} else {
|
||||
self.current_file_path = Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Play-mode input: arrow keys move the player one cell.
|
||||
fn handle_input(&mut self, ctx: &egui::Context) {
|
||||
if self.mode != AppMode::Play {
|
||||
return;
|
||||
}
|
||||
// (key, dx, dy) movement table — iterated instead of four near-identical ifs.
|
||||
const MOVES: [(Key, i32, i32); 4] = [
|
||||
(Key::ArrowUp, 0, -1),
|
||||
(Key::ArrowDown, 0, 1),
|
||||
(Key::ArrowLeft, -1, 0),
|
||||
(Key::ArrowRight, 1, 0),
|
||||
];
|
||||
ctx.input(|i| {
|
||||
for (key, dx, dy) in MOVES {
|
||||
if i.key_pressed(key) {
|
||||
self.state.try_move(dx, dy);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Top menu bar: File (Save / Save As / Exit) and the Play/Edit mode toggle.
|
||||
fn menu_bar(&mut self, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("Save").clicked() {
|
||||
ui.close();
|
||||
// Save to the known path if we have one; otherwise behave like Save As.
|
||||
match self.current_file_path.clone() {
|
||||
Some(path) => self.save_to(&path),
|
||||
None => self.save_as(),
|
||||
}
|
||||
}
|
||||
if ui.button("Save As…").clicked() {
|
||||
ui.close();
|
||||
self.save_as();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Exit").clicked() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
});
|
||||
// Mode toggle: Play keeps the game running; Edit shows the palette panel.
|
||||
ui.separator();
|
||||
ui.selectable_value(&mut self.mode, AppMode::Play, "Play");
|
||||
ui.selectable_value(&mut self.mode, AppMode::Edit, "Edit");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// If a script is open for editing, fills the central panel with the code editor.
|
||||
///
|
||||
/// Returns `true` when it consumed the frame (the caller should early-return so
|
||||
/// the side panel and board are skipped). On "Back", writes the edited body back
|
||||
/// to `board.scripts` and closes the editor.
|
||||
fn try_show_script_editor(&mut self, ctx: &egui::Context) -> bool {
|
||||
if self.mode != AppMode::Edit {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = self.editor.scripts.editing.clone() else {
|
||||
return false;
|
||||
};
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if script_editor::show(ui, &name, &mut self.editor.scripts.content) {
|
||||
self.state
|
||||
.board
|
||||
.scripts
|
||||
.insert(name, self.editor.scripts.content.clone());
|
||||
self.editor.scripts.editing = None;
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws the board viewport and returns the clicked cell in Edit mode, if any.
|
||||
///
|
||||
/// Play mode centers/player-tracks the viewport and never returns a click.
|
||||
/// Edit mode wraps the board in a `ScrollArea`, draws the object overlay on the
|
||||
/// Objects tab, and returns validated `(cx, cy)` cell coordinates on a click so
|
||||
/// the caller can dispatch to [`App::handle_board_click`] without holding the
|
||||
/// font borrow.
|
||||
fn show_board(&self, ctx: &egui::Context) -> Option<(usize, usize)> {
|
||||
let font = self.active_font();
|
||||
let zoom = self.state.board.zoom;
|
||||
let cell = font.cell_size(zoom);
|
||||
let board_w = self.state.board.width;
|
||||
let board_h = self.state.board.height;
|
||||
|
||||
let mut clicked_cell = None;
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if self.mode == AppMode::Edit {
|
||||
// Edit mode: ScrollArea lets the user scroll when the board overflows.
|
||||
let board_px = vec2(board_w as f32 * cell.x, board_h as f32 * cell.y);
|
||||
egui::ScrollArea::new([true, true]).show(ui, |ui| {
|
||||
let (rect, response) = ui.allocate_exact_size(board_px, Sense::click());
|
||||
draw_board(ui.painter(), rect.min, &self.state.board, font, zoom);
|
||||
|
||||
// When the Objects tab is active, overlay the object highlights.
|
||||
if self.editor.tab == EditorTab::Objects {
|
||||
draw_object_overlays(
|
||||
ui.painter(),
|
||||
rect.min,
|
||||
&self.state.board,
|
||||
font,
|
||||
self.editor.objects.selected,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
|
||||
if response.clicked()
|
||||
&& let Some(pos) = response.interact_pointer_pos()
|
||||
{
|
||||
let (cx, cy) = pos_to_cell(rect.min, pos, cell.x, cell.y);
|
||||
if cx >= 0 && cy >= 0 {
|
||||
let (cx, cy) = (cx as usize, cy as usize);
|
||||
if cx < board_w && cy < board_h {
|
||||
clicked_cell = Some((cx, cy));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Play mode: player-centered viewport, no scroll bars.
|
||||
let available = ui.available_rect_before_wrap();
|
||||
let player = self.state.board.player;
|
||||
let origin = board_origin(available, board_w, board_h, player, font, zoom);
|
||||
draw_board(ui.painter(), origin, &self.state.board, font, zoom);
|
||||
}
|
||||
});
|
||||
clicked_cell
|
||||
}
|
||||
|
||||
/// Applies an Edit-mode board click at validated cell `(cx, cy)`.
|
||||
///
|
||||
/// On the Objects tab the click places a new object (placement mode) or selects
|
||||
/// an existing one; on any other tab it paints the grid cell (floor only),
|
||||
/// leaving objects that sit on top untouched.
|
||||
fn handle_board_click(&mut self, cx: usize, cy: usize) {
|
||||
let board = &mut self.state.board;
|
||||
if self.editor.tab != EditorTab::Objects {
|
||||
*board.get_mut(cx, cy) = (self.editor.palette.glyph, self.editor.palette.selected);
|
||||
return;
|
||||
}
|
||||
if self.editor.objects.placing {
|
||||
// Placement mode: create a new object here if none exists.
|
||||
if board.object_index_at(cx, cy).is_none() {
|
||||
board.objects.push(ObjectDef::new(cx, cy));
|
||||
}
|
||||
self.editor.objects.placing = false;
|
||||
} else {
|
||||
// Selection mode: click to select an existing object.
|
||||
let found = board.object_index_at(cx, cy);
|
||||
self.editor.objects.selected = found;
|
||||
self.editor.objects.picker_open = false;
|
||||
if let Some(i) = found {
|
||||
// Snapshot the object's glyph for the picker.
|
||||
self.editor.objects.editing_glyph = board.objects[i].glyph;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the floating glyph pickers (Palette and Objects tabs) on top of the board.
|
||||
fn show_glyph_pickers(&mut self, ctx: &egui::Context) {
|
||||
// Disjoint-field borrow: `font` reads board_font/default_font while the pickers
|
||||
// mutate editor fields and read board cells — all distinct fields, so allowed.
|
||||
// (`active_font()` can't be used here: it borrows all of `self`.)
|
||||
let font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||
|
||||
if self.editor.palette.picker_open {
|
||||
glyph_picker::show(
|
||||
ctx,
|
||||
&mut self.editor.palette.picker_open,
|
||||
&mut self.editor.palette.glyph,
|
||||
self.state.board.cells(),
|
||||
font,
|
||||
);
|
||||
}
|
||||
|
||||
// Uses objects.editing_glyph as the target to avoid aliasing board.cells.
|
||||
if self.editor.objects.picker_open && self.editor.tab == EditorTab::Objects {
|
||||
glyph_picker::show(
|
||||
ctx,
|
||||
&mut self.editor.objects.picker_open,
|
||||
&mut self.editor.objects.editing_glyph,
|
||||
self.state.board.cells(),
|
||||
font,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Each phase is a focused method; this reads as the sequence of frame steps.
|
||||
self.handle_input(ctx);
|
||||
self.menu_bar(ctx);
|
||||
|
||||
// When a script is open, the code editor takes over the frame entirely.
|
||||
if self.try_show_script_editor(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Editor side panel (Edit mode) ---
|
||||
// Must be shown before CentralPanel so egui allocates its space first.
|
||||
// Snapshot the font spec so we can reload the board font if the panel changed it.
|
||||
let font_spec_before = self.state.board.font.clone();
|
||||
if self.mode == AppMode::Edit {
|
||||
// Disjoint-field borrow: active_font reads board_font/default_font while
|
||||
// show_editor_panel takes &mut editor and &mut board (distinct fields).
|
||||
let active_font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||
show_editor_panel(ctx, &mut self.editor, &mut self.state.board, active_font);
|
||||
}
|
||||
if self.state.board.font != font_spec_before {
|
||||
let spec = self.state.board.font.clone();
|
||||
self.apply_font_spec(ctx, spec.as_ref());
|
||||
}
|
||||
|
||||
// --- Board viewport ---
|
||||
// show_board draws and reports the clicked cell; the click is dispatched
|
||||
// afterward so the &mut handler doesn't conflict with the font borrow.
|
||||
if let Some((cx, cy)) = self.show_board(ctx) {
|
||||
self.handle_board_click(cx, cy);
|
||||
}
|
||||
|
||||
// --- Floating glyph pickers, drawn last so they sit on top ---
|
||||
self.show_glyph_pickers(ctx);
|
||||
|
||||
// Flush any picker edit of the selected object's glyph back to the board.
|
||||
if let Some(i) = self.editor.objects.selected
|
||||
&& i < self.state.board.objects.len()
|
||||
{
|
||||
self.state.board.objects[i].glyph = self.editor.objects.editing_glyph;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
// Layout constants — which value controls which part of the window:
|
||||
//
|
||||
// One board cell (size varies with the active font's tile dimensions):
|
||||
//
|
||||
// ◄── tile_w ──►
|
||||
// ╔═════════════╗ ▲
|
||||
// ║ @ ║ │ tile_h
|
||||
// ╚═════════════╝ ▼
|
||||
//
|
||||
// Full window (Edit mode shown; side panel absent in Play mode):
|
||||
//
|
||||
// ◄────────────────── DEFAULT_WINDOW_W (840 px) ───────────────────►
|
||||
// ┌────────────────────────────────────────────────────────────────┐ ▲
|
||||
// │ File │ Play Edit │ │ MENU_H (24 px)
|
||||
// ├────────────────────────────────────────────────┬───────────────┤ ▼
|
||||
// │ PANEL_MARGIN (8 px) on all sides │ PALETTE_ │ ▲
|
||||
// │ ┌──────────────────────────────────────────┐ │ PANEL_W │ │
|
||||
// │ │ │ │ (200 px) │ │ DEFAULT_
|
||||
// │ │ board cells (tile_w × tile_h each) │ │ Edit mode │ │ WINDOW_H
|
||||
// │ │ │ │ only │ │ (524 px)
|
||||
// │ └──────────────────────────────────────────┘ │ │ │
|
||||
// │ │ │ ▼
|
||||
// └────────────────────────────────────────────────┴───────────────┘
|
||||
//
|
||||
// MIN_WINDOW_W and MIN_WINDOW_H are derived: 10 cells wide / 5 cells tall plus margins.
|
||||
|
||||
use eframe::egui;
|
||||
use egui::{Color32, Pos2, Rect, vec2};
|
||||
|
||||
use crate::font::BitmapFont;
|
||||
use kiln_core::game::{Board, Glyph, Player};
|
||||
|
||||
/// Converts a core [`Glyph`] color ([`color::Rgba8`]) to egui [`Color32`].
|
||||
pub(crate) fn rgba8_to_color32(c: color::Rgba8) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
|
||||
}
|
||||
|
||||
/// Converts an egui [`Color32`] back to a core [`color::Rgba8`].
|
||||
///
|
||||
/// Inverse of [`rgba8_to_color32`]; used when reading colors out of egui's
|
||||
/// color-picker widgets back into a [`Glyph`].
|
||||
pub(crate) fn color32_to_rgba8(c: Color32) -> color::Rgba8 {
|
||||
color::Rgba8 {
|
||||
r: c.r(),
|
||||
g: c.g(),
|
||||
b: c.b(),
|
||||
a: c.a(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximate height of the egui menu bar in pixels, used for window sizing.
|
||||
pub(crate) const MENU_H: f32 = 24.0;
|
||||
/// Default inner margin that egui's CentralPanel adds on all sides, in pixels.
|
||||
pub(crate) const PANEL_MARGIN: f32 = 8.0;
|
||||
/// Default width of the editor side panel in pixels. The panel is resizable.
|
||||
pub(crate) const PALETTE_PANEL_W: f32 = 200.0;
|
||||
/// Default window width in pixels. Independent of map size.
|
||||
pub(crate) const DEFAULT_WINDOW_W: f32 = 840.0;
|
||||
/// Default window height in pixels. Independent of map size.
|
||||
pub(crate) const DEFAULT_WINDOW_H: f32 = 524.0;
|
||||
/// Minimum window width: enough room for 10 cells plus margins (using 8 px tile width).
|
||||
pub(crate) const MIN_WINDOW_W: f32 = 8.0 * 10.0 + 2.0 * PANEL_MARGIN;
|
||||
/// Minimum window height: enough room for 5 cells plus menu and margins (using 8 px tile height).
|
||||
pub(crate) const MIN_WINDOW_H: f32 = 8.0 * 5.0 + MENU_H + 2.0 * PANEL_MARGIN;
|
||||
|
||||
/// Paints a glyph into an already-allocated `rect` using a bitmap font.
|
||||
///
|
||||
/// Fills `rect` with `glyph.bg`, then draws the tile from `font` tinted with
|
||||
/// `glyph.fg`. Unlike [`draw_glyph`], the caller owns the rect and handles
|
||||
/// interaction — use this when you need the click [`egui::Response`].
|
||||
pub(crate) fn paint_glyph(painter: &egui::Painter, rect: Rect, glyph: &Glyph, font: &BitmapFont) {
|
||||
painter.rect_filled(rect, 0.0, rgba8_to_color32(glyph.bg));
|
||||
let uv = font.tile_uv(glyph.tile);
|
||||
painter.image(font.texture.id(), rect, uv, rgba8_to_color32(glyph.fg));
|
||||
}
|
||||
|
||||
/// Allocates a one-cell preview swatch showing `glyph` and returns its click [`Response`].
|
||||
///
|
||||
/// The cell is sized to the font's unzoomed tile dimensions and painted with
|
||||
/// `glyph`. Editor tabs use this for the editable glyph swatch; the caller
|
||||
/// inspects the returned [`Response`] to open a glyph picker on click.
|
||||
pub(crate) fn glyph_preview_button(
|
||||
ui: &mut egui::Ui,
|
||||
glyph: &Glyph,
|
||||
font: &BitmapFont,
|
||||
) -> egui::Response {
|
||||
let (rect, response) = ui.allocate_exact_size(font.cell_size(1), egui::Sense::click());
|
||||
paint_glyph(ui.painter(), rect, glyph, font);
|
||||
response
|
||||
}
|
||||
|
||||
/// Draws a single cell at grid position `(x, y)` using the given glyph and font.
|
||||
///
|
||||
/// `origin` is the pixel position of cell `(0, 0)` — the top-left corner of
|
||||
/// the board within the panel. Cell positions are computed from `origin` using
|
||||
/// the font's tile dimensions multiplied by `zoom`.
|
||||
pub(crate) fn draw_glyph(
|
||||
painter: &egui::Painter,
|
||||
origin: Pos2,
|
||||
x: usize,
|
||||
y: usize,
|
||||
glyph: &Glyph,
|
||||
font: &BitmapFont,
|
||||
zoom: u32,
|
||||
) {
|
||||
let cell = font.cell_size(zoom);
|
||||
let tl = origin + vec2(x as f32 * cell.x, y as f32 * cell.y);
|
||||
let rect = Rect::from_min_size(tl, cell);
|
||||
paint_glyph(painter, rect, glyph, font);
|
||||
}
|
||||
|
||||
/// Draws all board cells, then the object overlay, then the player at `origin`.
|
||||
///
|
||||
/// Rendering order: grid (floor) → objects → player. Objects are drawn on top
|
||||
/// of their grid cell's background, which is revealed if the object moves away.
|
||||
/// `zoom` scales each tile by an integer factor.
|
||||
pub(crate) fn draw_board(
|
||||
painter: &egui::Painter,
|
||||
origin: Pos2,
|
||||
board: &Board,
|
||||
font: &BitmapFont,
|
||||
zoom: u32,
|
||||
) {
|
||||
// Pass 1: grid floor.
|
||||
for y in 0..board.height {
|
||||
for x in 0..board.width {
|
||||
let (glyph, _) = board.get(x, y);
|
||||
draw_glyph(painter, origin, x, y, glyph, font, zoom);
|
||||
}
|
||||
}
|
||||
// Pass 2: objects drawn on top of the floor.
|
||||
for obj in &board.objects {
|
||||
draw_glyph(painter, origin, obj.x, obj.y, &obj.glyph, font, zoom);
|
||||
}
|
||||
// Pass 3: player overlay. Will be removed once the player becomes a scripted object.
|
||||
let player_glyph = Glyph::player();
|
||||
draw_glyph(
|
||||
painter,
|
||||
origin,
|
||||
board.player.x as usize,
|
||||
board.player.y as usize,
|
||||
&player_glyph,
|
||||
font,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
|
||||
/// Draws the objects-mode overlay over an already-drawn board.
|
||||
///
|
||||
/// Dims the entire board with a semi-transparent fill, then for each
|
||||
/// [`ObjectDef`](crate::game::ObjectDef) redraws that cell at full brightness
|
||||
/// and outlines it in white. The selected object (by index into
|
||||
/// `board.objects`) gets a yellow outline instead. `zoom` must match the zoom
|
||||
/// used to draw the board.
|
||||
pub(crate) fn draw_object_overlays(
|
||||
painter: &egui::Painter,
|
||||
origin: Pos2,
|
||||
board: &Board,
|
||||
font: &BitmapFont,
|
||||
selected: Option<usize>,
|
||||
zoom: u32,
|
||||
) {
|
||||
let cell = font.cell_size(zoom);
|
||||
|
||||
// Dim the whole board so non-object cells recede visually.
|
||||
let board_rect = Rect::from_min_size(
|
||||
origin,
|
||||
vec2(board.width as f32 * cell.x, board.height as f32 * cell.y),
|
||||
);
|
||||
painter.rect_filled(board_rect, 0.0, Color32::from_black_alpha(160));
|
||||
|
||||
// Redraw each object cell at full brightness and surround it with an outline.
|
||||
for (i, obj) in board.objects.iter().enumerate() {
|
||||
let tl = origin + vec2(obj.x as f32 * cell.x, obj.y as f32 * cell.y);
|
||||
let rect = Rect::from_min_size(tl, cell);
|
||||
|
||||
// Redraw on top of the dim overlay so this cell remains bright.
|
||||
paint_glyph(painter, rect, &obj.glyph, font);
|
||||
|
||||
// Yellow outline for the selected object, white for all others.
|
||||
let outline = if selected == Some(i) {
|
||||
Color32::YELLOW
|
||||
} else {
|
||||
Color32::WHITE
|
||||
};
|
||||
painter.rect_stroke(rect, 0.0, (1.0, outline), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a pixel position to cell coordinates relative to `origin`.
|
||||
///
|
||||
/// Uses floor so positions above or left of `origin` produce negative values —
|
||||
/// callers must guard `>= 0` before treating the result as a valid cell index.
|
||||
pub(crate) fn pos_to_cell(origin: Pos2, pos: Pos2, tile_w: f32, tile_h: f32) -> (i32, i32) {
|
||||
let rel = pos - origin;
|
||||
(
|
||||
(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`.
|
||||
///
|
||||
/// If the board fits along an axis, it is centered. If it overflows, the
|
||||
/// viewport is centered on the player and clamped so no empty space appears
|
||||
/// at the board edges. `zoom` scales tile dimensions before computing pixel sizes.
|
||||
pub(crate) fn board_origin(
|
||||
available: Rect,
|
||||
board_w: usize,
|
||||
board_h: usize,
|
||||
player: Player,
|
||||
font: &BitmapFont,
|
||||
zoom: u32,
|
||||
) -> Pos2 {
|
||||
let cell = font.cell_size(zoom);
|
||||
let board_px_w = board_w as f32 * cell.x;
|
||||
let board_px_h = board_h as f32 * cell.y;
|
||||
|
||||
let origin_x = if board_px_w <= available.width() {
|
||||
// Board fits: center it horizontally.
|
||||
available.min.x + (available.width() - board_px_w) / 2.0
|
||||
} else {
|
||||
// Board overflows: keep player at viewport center, clamp to board edges.
|
||||
let player_px_x = player.x as f32 * cell.x + cell.x / 2.0;
|
||||
(available.center().x - player_px_x)
|
||||
.max(available.max.x - board_px_w) // right edge must reach viewport right
|
||||
.min(available.min.x) // left edge must reach viewport left
|
||||
};
|
||||
|
||||
let origin_y = if board_px_h <= available.height() {
|
||||
available.min.y + (available.height() - board_px_h) / 2.0
|
||||
} else {
|
||||
let player_px_y = player.y as f32 * cell.y + cell.y / 2.0;
|
||||
(available.center().y - player_px_y)
|
||||
.max(available.max.y - board_px_h)
|
||||
.min(available.min.y)
|
||||
};
|
||||
|
||||
Pos2::new(origin_x, origin_y)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
use eframe::egui;
|
||||
use egui_code_editor::{CodeEditor, ColorTheme, Syntax};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Returns a [`Syntax`] definition for the Rhai scripting language.
|
||||
///
|
||||
/// Keyword sets are sourced from the official
|
||||
/// [rhaiscript/sublime-rhai](https://github.com/rhaiscript/sublime-rhai) grammar (MPL-2.0).
|
||||
pub(crate) fn rhai_syntax() -> Syntax {
|
||||
Syntax {
|
||||
language: "Rhai",
|
||||
case_sensitive: true,
|
||||
comment: "//",
|
||||
comment_multiline: ["/*", "*/"],
|
||||
hyperlinks: BTreeSet::new(),
|
||||
keywords: BTreeSet::from([
|
||||
"if", "else", "switch", "for", "in", "loop", "do", "while", "until", "break",
|
||||
"continue", "return", "throw", "try", "catch", "let", "const", "fn", "import",
|
||||
"export", "as", "private",
|
||||
]),
|
||||
// Boolean literals and context keywords get a distinct type color.
|
||||
types: BTreeSet::from(["true", "false", "this", "global"]),
|
||||
// Built-in Rhai functions highlighted as special identifiers.
|
||||
special: BTreeSet::from([
|
||||
"print",
|
||||
"debug",
|
||||
"type_of",
|
||||
"is_def_var",
|
||||
"is_def_fn",
|
||||
"is_shared",
|
||||
"call",
|
||||
"curry",
|
||||
"eval",
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the script code editor and header bar inside `ui`.
|
||||
///
|
||||
/// Returns `true` when the user clicks "Back", signalling the caller to save
|
||||
/// `content` back to `board.scripts` and close the editor.
|
||||
pub(crate) fn show(ui: &mut egui::Ui, name: &str, content: &mut String) -> bool {
|
||||
let mut done = false;
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("◄ Back").clicked() {
|
||||
done = true;
|
||||
}
|
||||
ui.label(format!("Editing: {name}"));
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
CodeEditor::default()
|
||||
.id_source("script_editor")
|
||||
.with_rows(30)
|
||||
.with_fontsize(14.0)
|
||||
.with_theme(ColorTheme::GRUVBOX)
|
||||
.with_syntax(rhai_syntax())
|
||||
.show(ui, content);
|
||||
|
||||
done
|
||||
}
|
||||
Reference in New Issue
Block a user