From 96ec8e8e9265180cd3454e2264dd731c0476e778 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sat, 30 May 2026 18:48:41 -0500 Subject: [PATCH] object scripting wip --- Cargo.lock | 1 + Cargo.toml | 2 +- src/editor.rs | 124 ++++++++++++++++++++-- src/game.rs | 36 +++++-- src/main.rs | 128 ++++++++++++++++++++--- src/map_file.rs | 269 +++++++++++++++++++++++++++++++++++++++++++----- src/render.rs | 44 +++++++- 7 files changed, 547 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e06b763..4bc8f13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3129,6 +3129,7 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ + "indexmap", "serde", "serde_spanned", "toml_datetime 0.6.11", diff --git a/Cargo.toml b/Cargo.toml index 68f38d3..4d0a15b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,6 @@ edition = "2024" eframe = "0.33.3" rhai = "1" serde = { version = "1", features = ["derive"] } -toml = "0.8" +toml = { version = "0.8", features = ["preserve_order"] } image = { version = "0.25", default-features = false, features = ["png"] } rfd = "0.15" diff --git a/src/editor.rs b/src/editor.rs index 5ddbd3a..d744f6d 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3,7 +3,7 @@ use egui::{Sense, vec2}; use crate::font::BitmapFont; use crate::font_dialog::{FontDialogState, show as show_font_dialog}; -use crate::game::{ALL_ARCHETYPES, Archetype, FontSpec, Glyph}; +use crate::game::{ALL_ARCHETYPES, Archetype, Board, Glyph}; use crate::render::{PALETTE_PANEL_W, paint_glyph}; /// Which tab is active in the editor side panel. @@ -11,8 +11,12 @@ use crate::render::{PALETTE_PANEL_W, paint_glyph}; pub(crate) enum EditorTab { /// Archetype + glyph palette for painting cells. Palette, - /// Board-level properties. + /// 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, } @@ -23,7 +27,7 @@ pub(crate) struct EditorState { 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. + /// Whether the glyph picker dialog is currently open (Palette tab). pub(crate) glyph_picker_open: bool, /// Whether the font picker dialog is currently open. pub(crate) font_dialog_open: bool, @@ -31,6 +35,17 @@ pub(crate) struct EditorState { pub(crate) font_dialog_state: FontDialogState, /// Which side-panel tab is currently shown. pub(crate) tab: EditorTab, + /// Index into `board.objects` of the currently selected object, or `None`. + pub(crate) selected_object: Option, + /// Whether the glyph picker is open for editing the selected object's glyph. + pub(crate) object_glyph_picker_open: bool, + /// Local copy of the selected object's cell glyph for the picker to edit. + /// + /// Kept separate from `board.cells` to avoid a borrow conflict: `glyph_picker::show` + /// takes both `&mut Glyph` (edit target) and `&[(Glyph, Archetype)]` (board palette), + /// and those can't both alias `board.cells`. This copy is written back to the board + /// each frame while an object is selected. + pub(crate) object_editing_glyph: Glyph, } impl EditorState { @@ -43,19 +58,23 @@ impl EditorState { font_dialog_open: false, font_dialog_state: FontDialogState::from_spec(None), tab: EditorTab::Palette, + selected_object: None, + object_glyph_picker_open: false, + object_editing_glyph: Glyph::player(), // overwritten on first selection } } } /// Renders the editor side panel (must be called before `CentralPanel`). /// -/// Shows tab bar (Palette / Board / World) and the content for the active tab. -/// `font_spec` is the board's current font override (may be `None` for default). -/// `active_font` is the resolved font currently in use for rendering. +/// 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, - font_spec: &mut Option, + board: &mut Board, active_font: &BitmapFont, ) { let tw = active_font.tile_w as f32; @@ -65,9 +84,12 @@ pub(crate) fn show_editor_panel( .resizable(true) .default_width(PALETTE_PANEL_W) .show(ctx, |ui| { - ui.horizontal(|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(); @@ -111,18 +133,98 @@ pub(crate) fn show_editor_panel( } } + EditorTab::Objects => { + match editor.selected_object { + None => { + ui.label("Click an object on the board to select it."); + } + Some(i) => { + // Collect sorted script names before taking a mutable borrow on objects. + let mut script_names: Vec = + board.scripts.keys().cloned().collect(); + script_names.sort(); + + ui.label("Glyph"); + // Preview button — same pattern as Palette tab. + let (cell_rect, glyph_btn) = + ui.allocate_exact_size(vec2(tw, th), Sense::click()); + paint_glyph( + ui.painter(), + cell_rect, + &editor.object_editing_glyph, + active_font, + ); + if glyph_btn.clicked() { + editor.object_glyph_picker_open = true; + } + + let obj = &mut board.objects[i]; + 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() { + // TODO: open script editor + } + } + } + } + EditorTab::Board => { - let font_label = match font_spec { + 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() { - editor.font_dialog_state = FontDialogState::from_spec(font_spec.as_ref()); + editor.font_dialog_state = + FontDialogState::from_spec(board.font.as_ref()); editor.font_dialog_open = true; } } + EditorTab::Scripts => { + if board.scripts.is_empty() { + ui.label("No scripts defined."); + } else { + let mut names: Vec<&String> = board.scripts.keys().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() { + // TODO: open script editor + } + }); + } + }); + } + } + EditorTab::World => {} } }); @@ -133,7 +235,7 @@ pub(crate) fn show_editor_panel( ctx, &mut editor.font_dialog_open, &mut editor.font_dialog_state, - font_spec, + &mut board.font, ); } } diff --git a/src/game.rs b/src/game.rs index 566716b..982fbbc 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,5 +1,6 @@ use eframe::egui::Color32; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// The visual representation of a single board cell. /// @@ -87,7 +88,7 @@ pub struct Behavior { /// rather than from this enum. [`Board::is_passable`] will need a special branch /// at that point. `ErrorBlock` is used as a sentinel for unrecognized archetype /// names in map files — it should never appear in a valid board. -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum Archetype { /// An open cell; the player and other entities can pass through it. Empty, @@ -196,17 +197,23 @@ pub const ALL_ARCHETYPES: &[Archetype] = &[Archetype::Empty, Archetype::Wall, Ar /// tile. Objects are parsed from `[[objects]]` entries in `.toml` map files /// and stored on [`Board`]. /// +/// Script text lives in [`Board::scripts`]; this struct holds only the name +/// used to look it up. Two `ObjectDef`s with the same `script_name` share +/// source text but will run with independent Rhai scopes (independent local +/// variables) when the scripting runtime is wired. +/// /// **Not yet runtime-wired.** Objects are loaded and stored but their scripts /// are not yet executed. Scripting dispatch is a future feature. -#[derive(Deserialize)] -#[allow(dead_code)] +#[derive(Deserialize, Serialize)] pub struct ObjectDef { /// Column of this object on the board (0-indexed). pub x: usize, /// Row of this object on the board (0-indexed). pub y: usize, - /// The Rhai script source for this object. - pub script: String, + /// Name of the Rhai script in [`Board::scripts`] that drives this object. + /// `None` means this object has no script yet. + #[serde(default)] + pub script_name: Option, } /// A portal that teleports the player to a named entry point on another board. @@ -217,8 +224,7 @@ pub struct ObjectDef { /// /// **Not yet runtime-wired.** Portal navigation (multi-board loading and /// switching) is a future feature. -#[derive(Deserialize)] -#[allow(dead_code)] +#[derive(Deserialize, Serialize)] pub struct PortalDef { /// Column of this portal on the board (0-indexed). pub x: usize, @@ -262,8 +268,9 @@ pub struct Player { /// separate element palette or index indirection. Access cells with /// [`Board::get`] and [`Board::get_mut`] using `(x, y)` coordinates. /// Use [`Board::is_passable`] for collision checks. -#[allow(dead_code)] pub struct Board { + /// Human-readable name for this board, loaded from the map file and round-tripped on save. + pub name: String, /// Width of the board in cells. pub width: usize, /// Height of the board in cells. @@ -279,6 +286,17 @@ pub struct Board { pub portals: Vec, /// Optional font override for this board. When `None`, the app default is used. pub font: Option, + /// Named Rhai scripts available on this board: script name → source text. + /// + /// All script source lives here; [`ObjectDef`]s and [`Board::board_script_name`] + /// reference entries by name. This means multiple objects can share the same + /// source while each running instance gets its own Rhai scope at runtime. + pub scripts: HashMap, + /// Name of the board-level script in [`Board::scripts`], if any. + /// + /// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`) + /// rather than being tied to a specific object cell. + pub board_script_name: Option, } impl Board { diff --git a/src/main.rs b/src/main.rs index 603f20d..619d71c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use eframe::egui; use egui::{Key, Sense, vec2}; -use std::path::Path; +use std::path::{Path, PathBuf}; mod editor; mod font; @@ -10,12 +10,12 @@ mod glyph_picker; mod map_file; mod render; -use editor::{EditorState, show_editor_panel}; +use editor::{EditorState, EditorTab, show_editor_panel}; use font::BitmapFont; -use game::{FontSpec, GameState}; +use game::{Archetype, FontSpec, GameState, ObjectDef}; use render::{ DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W, board_origin, draw_board, - pos_to_cell, + draw_object_overlays, pos_to_cell, }; /// Whether the application is in play mode or edit mode. @@ -32,7 +32,8 @@ enum AppMode { } fn main() -> eframe::Result<()> { - let board = map_file::load("maps/start.toml").expect("failed to load maps/start.toml"); + 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() @@ -43,7 +44,7 @@ fn main() -> eframe::Result<()> { eframe::run_native( "Kiln", options, - Box::new(move |cc| Ok(Box::new(App::new(board, &cc.egui_ctx)))), + Box::new(move |cc| Ok(Box::new(App::new(board, &cc.egui_ctx, start_path)))), ) } @@ -59,13 +60,18 @@ struct App { default_font: BitmapFont, /// A per-board font loaded from `board.font`, if one is specified. board_font: Option, + /// 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, } 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. - fn new(board: game::Board, egui_ctx: &egui::Context) -> Self { + /// `initial_path` is the file the board was loaded from and becomes the + /// default Save target. + fn new(board: game::Board, egui_ctx: &egui::Context, initial_path: PathBuf) -> 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)); @@ -83,6 +89,7 @@ impl App { editor: EditorState::new(), default_font, board_font, + current_file_path: Some(initial_path), } } @@ -123,6 +130,38 @@ impl eframe::App for App { 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(); + let path = self.current_file_path.clone(); + if let Some(path) = path { + if let Err(e) = map_file::save(&self.state.board, &path) { + eprintln!("save error: {e}"); + } + } else 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); + } + } + } + if ui.button("Save As…").clicked() { + ui.close(); + 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); + } + } + } + ui.separator(); if ui.button("Exit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } @@ -140,12 +179,12 @@ impl eframe::App for App { let font_spec_before = self.state.board.font.clone(); if self.mode == AppMode::Edit { // Split borrows explicitly: active_font borrows board_font/default_font, - // while show_editor_panel takes &mut editor and &mut board.font separately. + // while show_editor_panel takes &mut editor and &mut board 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, + &mut self.state.board, active_font, ); } @@ -173,6 +212,18 @@ impl eframe::App for App { 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); + + // 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.selected_object, + ); + } + if response.clicked() && let Some(pos) = response.interact_pointer_pos() { @@ -182,8 +233,38 @@ impl eframe::App for App { let cy = cy as usize; let board = &mut self.state.board; if cx < board.width && cy < board.height { - let arch = self.editor.selected; - *board.get_mut(cx, cy) = (self.editor.glyph, arch); + if self.editor.tab == EditorTab::Objects { + // Objects tab: clicks select an object rather than paint. + let found = board + .objects + .iter() + .position(|o| o.x == cx && o.y == cy); + self.editor.selected_object = found; + self.editor.object_glyph_picker_open = false; + if let Some(i) = found { + // Snapshot the object's cell glyph for the picker. + self.editor.object_editing_glyph = + board.get(board.objects[i].x, board.objects[i].y).0; + } + } else { + // Other tabs: paint the clicked cell and keep objects in sync. + let old_arch = board.get(cx, cy).1; + let arch = self.editor.selected; + *board.get_mut(cx, cy) = (self.editor.glyph, arch); + if arch == Archetype::Object { + // Add an ObjectDef if one doesn't already exist here. + if !board.objects.iter().any(|o| o.x == cx && o.y == cy) { + board.objects.push(ObjectDef { + x: cx, + y: cy, + script_name: None, + }); + } + } else if old_arch == Archetype::Object { + // Overwriting an Object cell: remove its ObjectDef. + board.objects.retain(|o| !(o.x == cx && o.y == cy)); + } + } } } } @@ -197,7 +278,7 @@ impl eframe::App for App { } }); - // --- Glyph picker dialog --- + // --- Glyph picker dialog (Palette tab) --- // Rendered after all panels so it floats on top. if self.editor.glyph_picker_open { let font = self.board_font.as_ref().unwrap_or(&self.default_font); @@ -209,5 +290,28 @@ impl eframe::App for App { font, ); } + + // --- Object glyph picker (Objects tab) --- + // Uses object_editing_glyph as the target to avoid aliasing board.cells. + if self.editor.object_glyph_picker_open && self.editor.tab == EditorTab::Objects { + let font = self.board_font.as_ref().unwrap_or(&self.default_font); + glyph_picker::show( + ctx, + &mut self.editor.object_glyph_picker_open, + &mut self.editor.object_editing_glyph, + &self.state.board.cells, + font, + ); + } + + // --- Write back the object editing glyph to the board each frame --- + // Any change the picker made to object_editing_glyph is flushed here. + if let Some(i) = self.editor.selected_object { + if i < self.state.board.objects.len() { + let x = self.state.board.objects[i].x; + let y = self.state.board.objects[i].y; + self.state.board.get_mut(x, y).0 = self.editor.object_editing_glyph; + } + } } } diff --git a/src/map_file.rs b/src/map_file.rs index a53ef0c..bea57c5 100644 --- a/src/map_file.rs +++ b/src/map_file.rs @@ -1,16 +1,19 @@ use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef}; use eframe::egui::Color32; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::convert::TryFrom; +use std::path::Path; -/// The top-level deserialization type for a `.toml` map file. +/// The top-level serialization/deserialization type for a `.toml` map file. /// -/// This struct mirrors the TOML file structure exactly and is used only -/// during loading — it is immediately converted into a [`Board`] via -/// [`From`] and then discarded. It is never used at runtime. +/// This struct mirrors the TOML file structure exactly. On load it is +/// immediately converted into a [`Board`] via [`TryFrom`] and then +/// discarded. On save a [`Board`] is converted back into this struct via +/// [`From<&Board>`] before serialization. /// /// See `maps/start.toml` for a complete example of the file format. -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct MapFile { /// The `[map]` section: name, dimensions, player start position. pub map: MapHeader, @@ -19,21 +22,24 @@ pub struct MapFile { /// The `[grid]` section: the multi-line string that defines cell layout. pub grid: GridData, /// Optional `[font]` section specifying a bitmap font for this board. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub font: Option, /// Any `[[objects]]` entries. Optional; defaults to empty. - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub objects: Vec, /// Any `[[portals]]` entries. Optional; defaults to empty. - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub portals: Vec, + /// The `[scripts]` table: maps script names to Rhai source text. + /// Optional; defaults to empty. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub scripts: HashMap, } /// The `[map]` header section of a map file. -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct MapHeader { /// Human-readable name for this board, e.g. `"Opening Room"`. - #[allow(dead_code)] pub name: String, /// Width of the board in cells. Must match the length of every row in `[grid] content`. pub width: usize, @@ -41,12 +47,15 @@ pub struct MapHeader { pub height: usize, /// Starting position of the player as `[x, y]` (0-indexed, origin top-left). pub player_start: [i32; 2], + /// Name of the board-level script in the `[scripts]` table, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub board_script_name: Option, } /// 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)] +#[derive(Deserialize, Serialize)] pub struct FontHeader { /// Path to the PNG font image, relative to the working directory. pub path: String, @@ -62,7 +71,7 @@ pub struct FontHeader { /// `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)] +#[derive(Deserialize, Serialize)] #[serde(untagged)] enum TileIndex { /// A direct tile index (e.g. `tile = 35`). @@ -88,7 +97,7 @@ impl TileIndex { /// [`Archetype`] it uses for behavior. /// /// `tile` accepts either an integer (`tile = 35`) or a char (`tile = "#"`). -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct PaletteEntry { /// The archetype name for this tile, e.g. `"wall"` or `"empty"`. pub archetype: String, @@ -106,7 +115,7 @@ pub struct PaletteEntry { /// `content` is a TOML multi-line string. Each line is one row of the board; /// each character in a line is looked up in the palette to determine the /// tile for that cell. -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct GridData { /// The raw grid content. Split by [`str::lines`] during loading. pub content: String, @@ -125,8 +134,17 @@ fn parse_color(hex: &str) -> Color32 { Color32::from_rgb(r, g, b) } +/// Converts a [`Color32`] to an `"#RRGGBB"` hex string. +fn color_to_hex(color: Color32) -> String { + format!("#{:02X}{:02X}{:02X}", color.r(), color.g(), color.b()) +} + /// Converts a parsed map file into a runtime [`Board`]. /// +/// Returns `Err(String)` if the grid dimensions do not match the header: +/// - wrong number of rows (actual row count ≠ `height`) +/// - any row whose character count ≠ `width` +/// /// The conversion proceeds in two passes: /// /// 1. **Palette pass** — each entry is parsed into a `(Glyph, Archetype)` pair @@ -136,8 +154,10 @@ fn parse_color(hex: &str) -> Color32 { /// /// 2. **Grid pass** — `grid.content` is split into lines; each character is /// looked up in the palette map and pushed directly into `cells`. -impl From for Board { - fn from(mf: MapFile) -> Self { +impl TryFrom for Board { + type Error = String; + + fn try_from(mf: MapFile) -> Result { let w = mf.map.width; let h = mf.map.height; @@ -166,9 +186,29 @@ impl From for Board { } } - // Pass 2: walk the grid string and build cells. + // Validate grid dimensions before walking cells. + // Collect lines eagerly so we can check the count and reuse them. + let rows: Vec<&str> = mf.grid.content.lines().collect(); + if rows.len() != h { + return Err(format!( + "grid has {} rows but map header declares height = {}", + rows.len(), + h + )); + } + for (i, line) in rows.iter().enumerate() { + let col_count = line.chars().count(); + if col_count != w { + return Err(format!( + "grid row {} has {} characters but map header declares width = {}", + i, col_count, w + )); + } + } + + // Pass 2: walk the validated grid and build cells. let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h); - for line in mf.grid.content.lines() { + for line in &rows { for ch in line.chars() { if let Some(&cell) = palette.get(&ch) { cells.push(cell); @@ -182,7 +222,8 @@ impl From for Board { tile_h: f.tile_h, }); - Board { + Ok(Board { + name: mf.map.name, width: w, height: h, cells, @@ -193,6 +234,124 @@ impl From for Board { objects: mf.objects, portals: mf.portals, font, + scripts: mf.scripts, + board_script_name: mf.map.board_script_name, + }) + } +} + +/// Converts a runtime [`Board`] back into a serializable [`MapFile`]. +/// +/// The palette is reconstructed by enumerating unique `(Glyph, Archetype)` +/// combinations in the board's cells and assigning each a single printable +/// ASCII character key. The grid is rebuilt by looking up each cell in the +/// resulting palette map. +impl From<&Board> for MapFile { + fn from(board: &Board) -> Self { + // Pool for non-empty archetypes: printable ASCII 33-126 ('!' onward), + // excluding `"` and `\` which would require escaping inside TOML strings. + // Space (32) is reserved exclusively for Archetype::Empty. + let pool: Vec = (33u8..=126u8) + .filter(|&b| b != b'"' && b != b'\\') + .map(|b| b as char) + .collect(); + let mut pool_iter = pool.iter(); + + // The canonical empty cell (tile 32, black/black) is always serialized as ' '. + // Every other unique (Glyph, Archetype) pair draws from the pool above. + let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty); + let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new(); + let mut palette: HashMap = HashMap::new(); + + for y in 0..board.height { + for x in 0..board.width { + let (glyph, arch) = board.get(x, y); + let key = (*glyph, *arch); + if !cell_to_key.contains_key(&key) { + if key == canonical_empty { + cell_to_key.insert(key, ' '); + palette.insert( + " ".to_string(), + PaletteEntry { + archetype: arch.name().to_string(), + tile: TileIndex::Num(glyph.tile), + fg: color_to_hex(glyph.fg), + bg: color_to_hex(glyph.bg), + }, + ); + } else { + let ch = *pool_iter + .next() + .expect("board has more than 92 unique non-canonical cell types"); + cell_to_key.insert(key, ch); + palette.insert( + ch.to_string(), + PaletteEntry { + archetype: arch.name().to_string(), + tile: TileIndex::Num(glyph.tile), + fg: color_to_hex(glyph.fg), + bg: color_to_hex(glyph.bg), + }, + ); + } + } + } + } + + // Pass 2: reconstruct the grid string row by row. + let mut rows: Vec = Vec::with_capacity(board.height); + for y in 0..board.height { + let mut row = String::with_capacity(board.width); + for x in 0..board.width { + let (glyph, arch) = board.get(x, y); + row.push(cell_to_key[&(*glyph, *arch)]); + } + rows.push(row); + } + // Trailing newline ensures the last row is complete; str::lines handles it correctly. + let content = rows.join("\n") + "\n"; + + let font = board.font.as_ref().map(|f| FontHeader { + path: f.path.clone(), + tile_w: f.tile_w, + tile_h: f.tile_h, + }); + + // Reconstruct objects as plain structs (ObjectDef/PortalDef don't implement Clone). + let objects = board + .objects + .iter() + .map(|o| ObjectDef { + x: o.x, + y: o.y, + script_name: o.script_name.clone(), + }) + .collect(); + let portals = board + .portals + .iter() + .map(|p| PortalDef { + x: p.x, + y: p.y, + target_map: p.target_map.clone(), + target_entry: p.target_entry.clone(), + }) + .collect(); + + MapFile { + map: MapHeader { + name: board.name.clone(), + width: board.width, + height: board.height, + player_start: [board.player.x, board.player.y], + board_script_name: board.board_script_name.clone(), + }, + palette, + grid: GridData { content }, + font, + objects, + portals, + scripts: board.scripts.clone(), } } } @@ -200,10 +359,74 @@ impl From for Board { /// Loads a map file from disk and returns a ready-to-use [`Board`]. /// /// Reads the file at `path`, deserializes it as TOML into a [`MapFile`], -/// then converts it via [`From`]. Propagates both I/O errors and -/// TOML parse errors through the `Box` return. +/// then converts it via [`TryFrom`]. Propagates I/O errors, TOML +/// parse errors, and grid dimension mismatches through the `Box` return. pub fn load(path: &str) -> Result> { let content = std::fs::read_to_string(path)?; let map_file: MapFile = toml::from_str(&content)?; - Ok(Board::from(map_file)) + Board::try_from(map_file).map_err(|e| e.into()) +} + +/// Serializes a [`Board`] to a `.toml` map file at `path`. +/// +/// Converts the board to a [`MapFile`] via [`From<&Board>`], then serializes +/// it with `toml::to_string_pretty`. Propagates I/O and serialization errors. +pub fn save(board: &Board, path: &Path) -> Result<(), Box> { + let map_file = MapFile::from(board); + let toml_str = toml::to_string_pretty(&map_file)?; + std::fs::write(path, toml_str)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal valid TOML with a configurable grid, used as a base for dimension tests. + fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String { + let content = grid_rows.join("\n"); + // Use r##"..."## so the "# in color strings does not close the raw string. + format!( + r##" +[map] +name = "Test" +width = {width} +height = {height} +player_start = [0, 0] + +[palette] +"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} + +[grid] +content = """ +{content} +""" +"## + ) + } + + #[test] + fn grid_wrong_row_count_returns_error() { + // height = 3 but only 2 rows in the grid + let toml = minimal_toml(3, 3, &["...", "..."]); + let mf: MapFile = toml::from_str(&toml).unwrap(); + let result = Board::try_from(mf); + assert!(result.is_err()); + // Use .err().unwrap() instead of .unwrap_err() to avoid requiring Board: Debug. + let msg = result.err().unwrap(); + assert!(msg.contains("2 rows"), "expected row count in error, got: {msg}"); + assert!(msg.contains("height = 3"), "expected declared height in error, got: {msg}"); + } + + #[test] + fn grid_wrong_row_width_returns_error() { + // width = 4 but second row is only 3 characters + let toml = minimal_toml(4, 2, &["....", "..."]); + let mf: MapFile = toml::from_str(&toml).unwrap(); + let result = Board::try_from(mf); + assert!(result.is_err()); + let msg = result.err().unwrap(); + assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}"); + assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}"); + } } diff --git a/src/render.rs b/src/render.rs index 783b224..7f0b8f1 100644 --- a/src/render.rs +++ b/src/render.rs @@ -25,7 +25,7 @@ // MIN_WINDOW_W and MIN_WINDOW_H are derived: 10 cells wide / 5 cells tall plus margins. use eframe::egui; -use egui::{Pos2, Rect, vec2}; +use egui::{Color32, Pos2, Rect, vec2}; use crate::font::BitmapFont; use crate::game::{Board, Glyph, Player}; @@ -97,6 +97,48 @@ pub(crate) fn draw_board(painter: &egui::Painter, origin: Pos2, board: &Board, f ); } +/// 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. +pub(crate) fn draw_object_overlays( + painter: &egui::Painter, + origin: Pos2, + board: &Board, + font: &BitmapFont, + selected: Option, +) { + let tw = font.tile_w as f32; + let th = font.tile_h as f32; + + // Dim the whole board so non-object cells recede visually. + let board_rect = Rect::from_min_size( + origin, + vec2(board.width as f32 * tw, board.height as f32 * th), + ); + 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 * tw, obj.y as f32 * th); + let rect = Rect::from_min_size(tl, vec2(tw, th)); + + // Redraw on top of the dim overlay so this cell remains bright. + let (glyph, _) = board.get(obj.x, obj.y); + paint_glyph(painter, rect, 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 —