From ac6a8dec57c09cdab7913cd8e2997c93373d6c53 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sun, 17 May 2026 15:20:46 -0500 Subject: [PATCH] Replace Element with Behavior + Archetype; update map format Element is replaced by two types: Behavior (a plain struct of runtime properties: passable, opaque) and Archetype (an enum of named presets: Empty, Wall, Object, ErrorBlock). Board.cells changes from Vec<(Glyph, usize)> to Vec<(Glyph, Archetype)>, eliminating the per-board element palette. Map files now reference archetypes by name (archetype = "wall") instead of property bags (passable = false), so the archetype list can be reordered without breaking saved games. Unknown archetype names produce an ErrorBlock cell (yellow ? on red) so malformed maps are immediately visible in-game. Co-Authored-By: Claude Sonnet 4.6 --- maps/start.toml | 7 +- src/game.rs | 174 +++++++++++++++++++++++++++++++++++------------- src/main.rs | 6 +- src/map_file.rs | 63 ++++++++++-------- 4 files changed, 168 insertions(+), 82 deletions(-) diff --git a/maps/start.toml b/maps/start.toml index 464dadf..360a5e6 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -5,8 +5,9 @@ height = 25 player_start = [30, 12] [palette] -" " = { passable = true, ch = " ", fg = "#000000", bg = "#000000" } -"#" = { passable = false, ch = "#", fg = "#808080", bg = "#606060" } +" " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" } +"#" = { archetype = "wall", ch = "#", fg = "#808080", bg = "#606060" } +"+" = { archetype = "banana", ch = "#", fg = "#808080", bg = "#606060" } [grid] content = """ @@ -18,7 +19,7 @@ content = """ # # # ###### # # # # -# # # +# +++ # # # # # # # # # diff --git a/src/game.rs b/src/game.rs index 00791c4..deac5fd 100644 --- a/src/game.rs +++ b/src/game.rs @@ -4,10 +4,10 @@ use serde::Deserialize; /// The visual representation of a single board cell. /// /// `Glyph` holds everything needed to draw one cell on screen: which character -/// to display and what colors to use. It is stored per-cell (not per element -/// type), so individual cells can vary their appearance independently — for -/// example, a field of "fire" tiles where each flame has a slightly different -/// color while all sharing the same [`Element`] behavior. +/// to display and what colors to use. It is stored per-cell (not per archetype), +/// so individual cells can vary their appearance independently — for example, +/// a field of "fire" tiles where each flame has a slightly different color +/// while all sharing the same [`Archetype`] behavior. /// /// `Glyph` values come from the map file palette and are set at load time. /// The player is the only entity whose glyph is hardcoded at runtime @@ -33,20 +33,115 @@ impl Glyph { } } -/// The behavioral definition of a tile type. +/// The behavioral properties of a board cell at runtime. /// -/// `Element` describes how a tile *behaves*, independent of how it looks. -/// Behavior is shared: many cells on the board can reference the same -/// `Element` by index (see [`Board::elements`]), so changing one `Element` -/// affects all cells that use it. +/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It +/// contains the properties the engine needs to simulate a cell — currently +/// passability and opacity. Future properties (pushable, shootable, etc.) can +/// be added here without changing call sites. /// -/// The visual side is handled separately by [`Glyph`], which is stored -/// per-cell and can vary even among cells of the same element type. -pub struct Element { - /// Whether the player (or other entities) can walk onto this tile. +/// For scripted objects (`Archetype::Object`), the behavior will eventually be +/// determined by the Rhai script rather than this struct. See the Object variant +/// for details. +#[derive(Copy, Clone, Debug)] +pub struct Behavior { + /// Whether an entity can walk through this cell. pub passable: bool, + /// Whether this cell blocks line of sight (reserved for future rendering). + pub opaque: bool, } +/// A class of board cell, encoding its default behavior and appearance. +/// +/// `Archetype` is an enum of the element types the engine knows about. Each +/// variant provides a default [`Behavior`] (via [`Archetype::behavior`]) and a +/// default [`Glyph`] (via [`Archetype::default_glyph`]) used when the editor +/// stamps a cell. +/// +/// Map files reference archetypes by [`name`](Archetype::name) (e.g. `"wall"`), +/// so the list of variants can be reordered without breaking saved games. +/// +/// ## Object special case +/// +/// `Archetype::Object` currently returns a static default `Behavior`. +/// TODO: In the future, Object passability and opacity will come from the cell's Rhai script +/// 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)] +pub enum Archetype { + /// An open cell; the player and other entities can pass through it. + Empty, + /// A solid wall; impassable and opaque. + Wall, + /// A scripted object. Behavior defaults to impassable until script-driven. + Object, + /// Sentinel for map files that reference an unknown archetype name. + /// Renders as a yellow `?` on red to make the error visible in-game. + ErrorBlock, +} + +impl Archetype { + /// Returns the default [`Behavior`] for this archetype. + /// + /// For `Object`, this is a placeholder until Rhai scripts drive the behavior. + pub fn behavior(&self) -> Behavior { + match self { + Archetype::Empty => Behavior { passable: true, opaque: false }, + Archetype::Wall => Behavior { passable: false, opaque: true }, + Archetype::Object => Behavior { passable: false, opaque: false }, + Archetype::ErrorBlock => Behavior { passable: false, opaque: true }, + } + } + + /// Returns the canonical name used to reference this archetype in map files. + pub fn name(&self) -> &'static str { + match self { + Archetype::Empty => "empty", + Archetype::Wall => "wall", + Archetype::Object => "object", + Archetype::ErrorBlock => "error_block", + } + } + + /// Returns the default glyph painted when the editor stamps this archetype. + /// + /// This glyph is used only for new cells created in the editor; existing + /// cells retain their own per-cell glyph. + pub fn default_glyph(&self) -> Glyph { + match self { + Archetype::Empty => Glyph { ch: ' ', fg: Color32::BLACK, bg: Color32::BLACK }, + Archetype::Wall => Glyph { ch: '#', fg: Color32::from_rgb(0x80, 0x80, 0x80), bg: Color32::from_rgb(0x60, 0x60, 0x60) }, + Archetype::Object => Glyph { ch: '?', fg: Color32::YELLOW, bg: Color32::BLACK }, + // Visually distinct so malformed map files are immediately obvious. + Archetype::ErrorBlock => Glyph { ch: '?', fg: Color32::YELLOW, bg: Color32::RED }, + } + } +} + +impl TryFrom<&str> for Archetype { + type Error = String; + + /// Parses an archetype by its map-file name. + /// + /// Returns an error for unrecognized names; the caller should substitute + /// [`Archetype::ErrorBlock`] and log the error so the problem is visible. + fn try_from(name: &str) -> Result { + match name { + "empty" => Ok(Archetype::Empty), + "wall" => Ok(Archetype::Wall), + "object" => Ok(Archetype::Object), + _ => Err(format!("unknown archetype: {name}")), + } + } +} + +/// The archetypes available for placement in the editor, in display order. +/// +/// `ErrorBlock` is excluded — it is a sentinel for load errors, not a valid +/// editing choice. +pub const ALL_ARCHETYPES: &[Archetype] = &[Archetype::Empty, Archetype::Wall, Archetype::Object]; + /// A scripted object placed on the board, loaded from a map file. /// /// `ObjectDef` represents a tile that has Rhai script attached to it. @@ -108,32 +203,26 @@ pub struct Player { /// "board" in ZZT. It contains everything needed to represent and run one /// self-contained area of the game world: /// -/// - A grid of cells, each with a visual ([`Glyph`]) and a behavior index -/// - A palette of [`Element`] behavior definitions shared across cells +/// - A grid of cells, each with a visual representation ([`Glyph`]) and an [`Archetype`] /// - The current player position /// - Scripted objects and portals (loaded but not yet active) /// /// ## Cell storage /// -/// Cells are stored as `(Glyph, usize)` tuples in a row-major `Vec`. The -/// `usize` is an index into [`Board::elements`]. It is an anonymous tuple -/// field intentionally — the index has no meaning outside the context of -/// a specific `Board`'s element palette, so it cannot be accidentally -/// detached and misused. -/// -/// Access cells with [`Board::get`] and [`Board::get_mut`] using `(x, y)` -/// coordinates. Use [`Board::is_passable`] for collision checks. +/// Cells are stored as `(Glyph, Archetype)` tuples in a row-major `Vec`. +/// Each cell directly owns its visual and behavioral class — there is no +/// 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 { /// Width of the board in cells. pub width: usize, /// Height of the board in cells. pub height: usize, - /// Palette of element behavior definitions. Cells reference these by index. - pub elements: Vec, - /// Row-major grid of `(Glyph, element_index)` pairs. Use [`Board::get`] - /// to access by coordinates. - pub(crate) cells: Vec<(Glyph, usize)>, + /// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to + /// access by `(x, y)` coordinates. + pub(crate) cells: Vec<(Glyph, Archetype)>, /// Current player position. See [`Player`] for caveats about its future. pub player: Player, /// Scripted objects on this board. Parsed from the map file; not yet active. @@ -143,22 +232,11 @@ pub struct Board { } impl Board { - /// Adds an element to this board's palette and returns its index. - /// - /// The returned index can be used as the second field of a cell tuple. - /// Indices are stable for the lifetime of the board. - #[allow(dead_code)] - pub fn add_element(&mut self, element: Element) -> usize { - let idx = self.elements.len(); - self.elements.push(element); - idx - } - /// Returns a reference to the cell at `(x, y)`. /// - /// The cell is a `(Glyph, usize)` tuple where the `usize` is an index - /// into [`Board::elements`]. Panics if `x` or `y` are out of bounds. - pub fn get(&self, x: usize, y: usize) -> &(Glyph, usize) { + /// The cell is a `(Glyph, Archetype)` tuple. Panics if `x` or `y` are + /// out of bounds. + pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) { &self.cells[y * self.width + x] } @@ -166,16 +244,18 @@ impl Board { /// /// Panics if `x` or `y` are out of bounds. #[allow(dead_code)] - pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, usize) { + pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) { &mut self.cells[y * self.width + x] } - /// Returns `true` if the element at `(x, y)` is passable. + /// Returns `true` if the archetype at `(x, y)` allows entities to pass through. /// /// Panics if `x` or `y` are out of bounds. pub fn is_passable(&self, x: usize, y: usize) -> bool { - let (_, elem_idx) = self.get(x, y); - self.elements[*elem_idx].passable + 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 } } @@ -200,7 +280,7 @@ impl GameState { /// Attempts to move the player by `(dx, dy)` cells. /// - /// The move is ignored if the target cell is out of bounds or its element + /// The move is ignored if the target cell is out of bounds or its behavior /// is not passable. No-ops silently (the caller does not need to check). pub fn try_move(&mut self, dx: i32, dy: i32) { let nx = self.board.player.x + dx; diff --git a/src/main.rs b/src/main.rs index f2b3858..2fa473d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,9 +110,9 @@ impl eframe::App for App { ); }); - // Force a repaint every frame so input is processed continuously. - // Remove this if the game becomes purely event-driven. - ctx.request_repaint(); + // egui repaints automatically on input events, so no explicit + // request_repaint() is needed while the game is purely event-driven. + // Add it back (or call it from game logic) when continuous animation is needed. } } diff --git a/src/map_file.rs b/src/map_file.rs index 2cd5d95..97f75be 100644 --- a/src/map_file.rs +++ b/src/map_file.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use serde::Deserialize; use eframe::egui::Color32; -use crate::game::{Board, Element, Glyph, ObjectDef, Player, PortalDef}; +use crate::game::{Archetype, Board, Glyph, ObjectDef, Player, PortalDef}; /// The top-level deserialization type for a `.toml` map file. /// @@ -15,7 +15,7 @@ pub struct MapFile { /// The `[map]` section: name, dimensions, player start position. pub map: MapHeader, /// The `[palette]` section: maps single-character keys to tile definitions. - /// Each entry defines both the visual ([`Glyph`]) and behavior ([`Element`]) + /// Each entry defines both the visual ([`Glyph`]) and the [`Archetype`] /// for all cells that use that character in the grid. pub palette: HashMap, /// The `[grid]` section: the multi-line string that defines cell layout. @@ -51,8 +51,8 @@ pub struct MapHeader { /// /// Each palette entry is keyed by a single character (e.g. `"#"` or `" "`). /// That character is used in the `[grid]` content string to place tiles. -/// The entry defines both how the tile looks (`ch`, `fg`, `bg`) and how it -/// behaves (`passable`). +/// The entry defines both how the tile looks (`ch`, `fg`, `bg`) and which +/// [`Archetype`] it uses for behavior. /// /// Note that `ch` (the display character) does not have to match the palette /// key. The key is just a label for the grid; `ch` is what actually gets @@ -60,8 +60,10 @@ pub struct MapHeader { /// wall tile that displays as `#`. #[derive(Deserialize)] pub struct PaletteEntry { - /// Whether entities can walk onto this tile. - pub passable: bool, + /// The archetype name for this tile, e.g. `"wall"` or `"empty"`. + /// Parsed via [`Archetype::try_from`]; unknown names produce an + /// [`Archetype::ErrorBlock`] cell and a logged warning. + pub archetype: String, /// The character displayed in this cell. pub ch: char, /// Foreground color as an `"#RRGGBB"` hex string. @@ -100,42 +102,46 @@ fn parse_color(hex: &str) -> Color32 { /// /// The conversion proceeds in two passes: /// -/// 1. **Palette pass** — each palette entry becomes an [`Element`] (pushed -/// into `elements`) and a [`Glyph`] (stored in a temporary `HashMap` -/// keyed by the palette character). The element index is recorded -/// alongside the glyph so cells can reference it. +/// 1. **Palette pass** — each entry is parsed into a `(Glyph, Archetype)` pair +/// and stored in a temporary `HashMap` keyed by the palette character. +/// Unknown archetype names produce an [`Archetype::ErrorBlock`] and a +/// logged warning so malformed maps are visible in-game. /// /// 2. **Grid pass** — `grid.content` is split into lines; each character -/// is looked up in the palette map. Unknown characters are silently -/// skipped (they produce no cell), so a malformed grid will result in -/// a `cells` vec shorter than `width * height`. No explicit validation -/// is done here yet. +/// is looked up in the palette map and pushed directly into `cells`. +/// Unknown characters are silently skipped, so a malformed grid will +/// result in a `cells` vec shorter than `width * height`. impl From for Board { fn from(mf: MapFile) -> Self { let w = mf.map.width; let h = mf.map.height; - // Pass 1: build the element palette and a char→(Glyph, idx) lookup. - let mut elements: Vec = Vec::new(); - let mut palette: HashMap = HashMap::new(); + // Pass 1: build a char → (Glyph, Archetype) lookup from the palette. + let mut palette: HashMap = HashMap::new(); for (key, entry) in &mf.palette { let key_char = key.chars().next().unwrap_or(' '); - let idx = elements.len(); - elements.push(Element { passable: entry.passable }); - palette.insert(key_char, (Glyph { - ch: entry.ch, - fg: parse_color(&entry.fg), - bg: parse_color(&entry.bg), - }, idx)); + // Unknown archetype names fall back to ErrorBlock so the error is + // visible in-game rather than silently producing empty tiles. + match Archetype::try_from(entry.archetype.as_str()) { + Ok(archetype) => { + let glyph = Glyph { ch: entry.ch, fg: parse_color(&entry.fg), bg: parse_color(&entry.bg) }; + palette.insert(key_char, (glyph, archetype)); + } + Err(e) => { + // Be sure to log and ignore the default glyph for an unknown archetype + eprintln!("{e}"); + palette.insert(key_char, (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)); + } + } } // Pass 2: walk the grid string and build cells. - let mut cells: Vec<(Glyph, usize)> = Vec::with_capacity(w * h); + let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h); for line in mf.grid.content.lines() { for ch in line.chars() { - if let Some(&(glyph, idx)) = palette.get(&ch) { - cells.push((glyph, idx)); + if let Some(&cell) = palette.get(&ch) { + cells.push(cell); } } } @@ -143,7 +149,6 @@ impl From for Board { Board { width: w, height: h, - elements, cells, player: Player { x: mf.map.player_start[0], @@ -167,4 +172,4 @@ 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)) -} +} \ No newline at end of file