diff --git a/src/editor.rs b/src/editor.rs index d744f6d..75642d5 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -39,13 +39,15 @@ pub(crate) struct EditorState { 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. + /// Local copy of the selected object's 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. + /// 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) object_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_object: bool, } impl EditorState { @@ -61,6 +63,7 @@ impl EditorState { selected_object: None, object_glyph_picker_open: false, object_editing_glyph: Glyph::player(), // overwritten on first selection + placing_object: false, } } } @@ -134,9 +137,27 @@ pub(crate) fn show_editor_panel( } EditorTab::Objects => { + // "Add Object" toggles placement mode; next board click places a new object. + let add_label = if editor.placing_object { + "Cancel Add" + } else { + "Add Object" + }; + if ui.button(add_label).clicked() { + editor.placing_object = !editor.placing_object; + editor.selected_object = None; + } + if editor.placing_object { + ui.label("Click on the board to place."); + } + + ui.separator(); + match editor.selected_object { None => { - ui.label("Click an object on the board to select it."); + if !editor.placing_object { + ui.label("Click an object on the board to select it."); + } } Some(i) => { // Collect sorted script names before taking a mutable borrow on objects. diff --git a/src/game.rs b/src/game.rs index 982fbbc..4a8e5d6 100644 --- a/src/game.rs +++ b/src/game.rs @@ -178,7 +178,8 @@ impl TryFrom<&str> for Archetype { match name { "empty" => Ok(Archetype::Empty), "wall" => Ok(Archetype::Wall), - "object" => Ok(Archetype::Object), + // "object" is intentionally absent: objects are not valid palette + // entries in map files. They live in [[objects]] with their own glyph. _ => Err(format!("unknown archetype: {name}")), } } @@ -197,22 +198,28 @@ 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`]. /// +/// Objects are rendered as an overlay on top of the board grid — the grid +/// cell at `(x, y)` holds the background (floor) that is revealed if the +/// object moves away. `glyph` is owned by the object itself and may be +/// mutated by its Rhai script at runtime. +/// /// 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. +/// source text but will run with independent Rhai scopes 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, 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, + /// Visual representation of this object. Owned by the object (not derived + /// from the grid cell), so scripts can change tile, fg, and bg at runtime. + pub glyph: Glyph, /// 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, } @@ -316,14 +323,21 @@ impl Board { &mut self.cells[y * self.width + x] } - /// Returns `true` if the archetype at `(x, y)` allows entities to pass through. + /// Returns `true` if the cell at `(x, y)` allows entities to pass through. /// + /// Objects block movement before the grid cell archetype is consulted. /// Panics if `x` or `y` are out of bounds. pub fn is_passable(&self, x: usize, y: usize) -> bool { - let (_, arch) = self.get(x, y); - // Derive passability from the archetype's behavior. - // Object cells will need special handling here once scripts are wired. - arch.behavior().passable + // An object on this cell always blocks (until scripting overrides this). + if self.object_at(x, y).is_some() { + return false; + } + self.get(x, y).1.behavior().passable + } + + /// Returns the index into [`Board::objects`] of the object at `(x, y)`, if any. + pub fn object_at(&self, x: usize, y: usize) -> Option { + self.objects.iter().position(|o| o.x == x && o.y == y) } } diff --git a/src/main.rs b/src/main.rs index 619d71c..bad475f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ mod render; use editor::{EditorState, EditorTab, show_editor_panel}; use font::BitmapFont; -use game::{Archetype, FontSpec, GameState, ObjectDef}; +use game::{Archetype, FontSpec, GameState, Glyph, ObjectDef}; use render::{ DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W, board_origin, draw_board, draw_object_overlays, pos_to_cell, @@ -234,36 +234,36 @@ impl eframe::App for App { let board = &mut self.state.board; if cx < board.width && cy < board.height { 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) { + if self.editor.placing_object { + // Placement mode: create a new object here if none exists. + if board.object_at(cx, cy).is_none() { board.objects.push(ObjectDef { x: cx, y: cy, + glyph: Glyph { + tile: Archetype::Object.default_glyph().tile, + fg: Archetype::Object.default_glyph().fg, + bg: Archetype::Object.default_glyph().bg, + }, 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)); + self.editor.placing_object = false; + } else { + // Selection mode: click to select an existing object. + let found = board.object_at(cx, cy); + self.editor.selected_object = found; + self.editor.object_glyph_picker_open = false; + if let Some(i) = found { + // Snapshot the object's glyph for the picker. + self.editor.object_editing_glyph = + board.objects[i].glyph; + } } + } else { + // Other tabs: paint the grid cell (floor only). + // Objects on this cell are left untouched — they sit on top. + *board.get_mut(cx, cy) = (self.editor.glyph, self.editor.selected); } } } @@ -308,9 +308,7 @@ impl eframe::App for App { // 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; + self.state.board.objects[i].glyph = self.editor.object_editing_glyph; } } } diff --git a/src/map_file.rs b/src/map_file.rs index bea57c5..39c320c 100644 --- a/src/map_file.rs +++ b/src/map_file.rs @@ -26,7 +26,7 @@ pub struct MapFile { pub font: Option, /// Any `[[objects]]` entries. Optional; defaults to empty. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub objects: Vec, + pub objects: Vec, /// Any `[[portals]]` entries. Optional; defaults to empty. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub portals: Vec, @@ -121,6 +121,28 @@ pub struct GridData { pub content: String, } +/// One `[[objects]]` entry in a map file. +/// +/// Used only for TOML serialization/deserialization. Converted to/from +/// [`ObjectDef`] (which holds a [`Glyph`] directly) in the [`TryFrom`] / +/// [`From`] impls below. +#[derive(Deserialize, Serialize)] +pub(crate) struct MapFileObjectEntry { + /// Column of this object on the board (0-indexed). + x: usize, + /// Row of this object on the board (0-indexed). + y: usize, + /// Tile index into the board's bitmap font. Accepts integer or single-char string. + tile: TileIndex, + /// Foreground color as an `"#RRGGBB"` hex string. + fg: String, + /// Background color as an `"#RRGGBB"` hex string. + bg: String, + /// Name of the Rhai script in the `[scripts]` table, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + script_name: Option, +} + /// Parses an `"#RRGGBB"` hex color string into a [`Color32`]. /// Returns black on any parse failure. fn parse_color(hex: &str) -> Color32 { @@ -222,6 +244,22 @@ impl TryFrom for Board { tile_h: f.tile_h, }); + // Convert MapFileObjectEntry → ObjectDef, resolving tile index and color strings. + let objects = mf + .objects + .into_iter() + .map(|e| ObjectDef { + x: e.x, + y: e.y, + glyph: Glyph { + tile: e.tile.into_u32(), + fg: parse_color(&e.fg), + bg: parse_color(&e.bg), + }, + script_name: e.script_name, + }) + .collect(); + Ok(Board { name: mf.map.name, width: w, @@ -231,7 +269,7 @@ impl TryFrom for Board { x: mf.map.player_start[0], y: mf.map.player_start[1], }, - objects: mf.objects, + objects, portals: mf.portals, font, scripts: mf.scripts, @@ -317,13 +355,16 @@ impl From<&Board> for MapFile { tile_h: f.tile_h, }); - // Reconstruct objects as plain structs (ObjectDef/PortalDef don't implement Clone). - let objects = board + // Convert ObjectDef → MapFileObjectEntry, encoding the glyph as TOML-compatible fields. + let objects: Vec = board .objects .iter() - .map(|o| ObjectDef { + .map(|o| MapFileObjectEntry { x: o.x, y: o.y, + tile: TileIndex::Num(o.glyph.tile), + fg: color_to_hex(o.glyph.fg), + bg: color_to_hex(o.glyph.bg), script_name: o.script_name.clone(), }) .collect(); @@ -429,4 +470,78 @@ content = """ assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}"); assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}"); } + + #[test] + fn object_archetype_in_palette_produces_error_block() { + // "object" is no longer a valid palette archetype; it should produce ErrorBlock. + let toml = r##" +[map] +name = "Test" +width = 1 +height = 1 +player_start = [0, 0] + +[palette] +"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } + +[grid] +content = """ +O +""" +"##; + let mf: MapFile = toml::from_str(toml).unwrap(); + let board = Board::try_from(mf).unwrap(); + let (_, arch) = board.get(0, 0); + assert_eq!(*arch, crate::game::Archetype::ErrorBlock); + } + + #[test] + fn object_glyph_round_trips_through_toml() { + // Objects must survive a save→load cycle with their glyph intact. + use eframe::egui::Color32; + + let toml = r##" +[map] +name = "Test" +width = 3 +height = 3 +player_start = [0, 0] + +[palette] +"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } + +[grid] +content = """ +... +... +... +""" + +[[objects]] +x = 1 +y = 1 +tile = 64 +fg = "#00FFFF" +bg = "#000000" +"##; + let mf: MapFile = toml::from_str(toml).unwrap(); + let board = Board::try_from(mf).unwrap(); + assert_eq!(board.objects.len(), 1); + let obj = &board.objects[0]; + assert_eq!(obj.x, 1); + assert_eq!(obj.y, 1); + assert_eq!(obj.glyph.tile, 64); + assert_eq!(obj.glyph.fg, Color32::from_rgb(0x00, 0xFF, 0xFF)); + assert_eq!(obj.glyph.bg, Color32::BLACK); + + // Save back to TOML and reload; glyph must survive. + let map_file = MapFile::from(&board); + let toml_out = toml::to_string_pretty(&map_file).unwrap(); + let mf2: MapFile = toml::from_str(&toml_out).unwrap(); + let board2 = Board::try_from(mf2).unwrap(); + let obj2 = &board2.objects[0]; + assert_eq!(obj2.glyph.tile, obj.glyph.tile); + assert_eq!(obj2.glyph.fg, obj.glyph.fg); + assert_eq!(obj2.glyph.bg, obj.glyph.bg); + } } diff --git a/src/render.rs b/src/render.rs index 7f0b8f1..4c0ab6b 100644 --- a/src/render.rs +++ b/src/render.rs @@ -76,16 +76,23 @@ pub(crate) fn draw_glyph( paint_glyph(painter, rect, glyph, font); } -/// Draws all board cells then the player overlay at `origin`. +/// 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. pub(crate) fn draw_board(painter: &egui::Painter, origin: Pos2, board: &Board, font: &BitmapFont) { + // 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); } } - // The player is not a board cell; it is rendered on top as an overlay. - // This separate draw will be removed once the player becomes a scripted object. + // 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); + } + // Pass 3: player overlay. Will be removed once the player becomes a scripted object. let player_glyph = Glyph::player(); draw_glyph( painter, @@ -126,8 +133,7 @@ pub(crate) fn draw_object_overlays( 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); + paint_glyph(painter, rect, &obj.glyph, font); // Yellow outline for the selected object, white for all others. let outline = if selected == Some(i) {