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 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 15:20:46 -05:00
parent 453c7baf4e
commit ac6a8dec57
4 changed files with 168 additions and 82 deletions
+4 -3
View File
@@ -5,8 +5,9 @@ height = 25
player_start = [30, 12] player_start = [30, 12]
[palette] [palette]
" " = { passable = true, ch = " ", fg = "#000000", bg = "#000000" } " " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" }
"#" = { passable = false, ch = "#", fg = "#808080", bg = "#606060" } "#" = { archetype = "wall", ch = "#", fg = "#808080", bg = "#606060" }
"+" = { archetype = "banana", ch = "#", fg = "#808080", bg = "#606060" }
[grid] [grid]
content = """ content = """
@@ -18,7 +19,7 @@ content = """
# # # #
# ###### # # ###### #
# # # # # #
# # # # +++ # #
# # # # # #
# # # #
# # # #
+127 -47
View File
@@ -4,10 +4,10 @@ use serde::Deserialize;
/// The visual representation of a single board cell. /// The visual representation of a single board cell.
/// ///
/// `Glyph` holds everything needed to draw one cell on screen: which character /// `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 /// to display and what colors to use. It is stored per-cell (not per archetype),
/// type), so individual cells can vary their appearance independently — for /// so individual cells can vary their appearance independently — for example,
/// example, a field of "fire" tiles where each flame has a slightly different /// a field of "fire" tiles where each flame has a slightly different color
/// color while all sharing the same [`Element`] behavior. /// while all sharing the same [`Archetype`] behavior.
/// ///
/// `Glyph` values come from the map file palette and are set at load time. /// `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 /// 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 a plain data struct returned by [`Archetype::behavior`]. It
/// Behavior is shared: many cells on the board can reference the same /// contains the properties the engine needs to simulate a cell — currently
/// `Element` by index (see [`Board::elements`]), so changing one `Element` /// passability and opacity. Future properties (pushable, shootable, etc.) can
/// affects all cells that use it. /// be added here without changing call sites.
/// ///
/// The visual side is handled separately by [`Glyph`], which is stored /// For scripted objects (`Archetype::Object`), the behavior will eventually be
/// per-cell and can vary even among cells of the same element type. /// determined by the Rhai script rather than this struct. See the Object variant
pub struct Element { /// for details.
/// Whether the player (or other entities) can walk onto this tile. #[derive(Copy, Clone, Debug)]
pub struct Behavior {
/// Whether an entity can walk through this cell.
pub passable: bool, 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<Self, Self::Error> {
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. /// A scripted object placed on the board, loaded from a map file.
/// ///
/// `ObjectDef` represents a tile that has Rhai script attached to it. /// `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 /// "board" in ZZT. It contains everything needed to represent and run one
/// self-contained area of the game world: /// self-contained area of the game world:
/// ///
/// - A grid of cells, each with a visual ([`Glyph`]) and a behavior index /// - A grid of cells, each with a visual representation ([`Glyph`]) and an [`Archetype`]
/// - A palette of [`Element`] behavior definitions shared across cells
/// - The current player position /// - The current player position
/// - Scripted objects and portals (loaded but not yet active) /// - Scripted objects and portals (loaded but not yet active)
/// ///
/// ## Cell storage /// ## Cell storage
/// ///
/// Cells are stored as `(Glyph, usize)` tuples in a row-major `Vec`. The /// Cells are stored as `(Glyph, Archetype)` tuples in a row-major `Vec`.
/// `usize` is an index into [`Board::elements`]. It is an anonymous tuple /// Each cell directly owns its visual and behavioral class — there is no
/// field intentionally — the index has no meaning outside the context of /// separate element palette or index indirection. Access cells with
/// a specific `Board`'s element palette, so it cannot be accidentally /// [`Board::get`] and [`Board::get_mut`] using `(x, y)` coordinates.
/// detached and misused. /// Use [`Board::is_passable`] for collision checks.
///
/// Access cells with [`Board::get`] and [`Board::get_mut`] using `(x, y)`
/// coordinates. Use [`Board::is_passable`] for collision checks.
#[allow(dead_code)] #[allow(dead_code)]
pub struct Board { pub struct Board {
/// Width of the board in cells. /// Width of the board in cells.
pub width: usize, pub width: usize,
/// Height of the board in cells. /// Height of the board in cells.
pub height: usize, pub height: usize,
/// Palette of element behavior definitions. Cells reference these by index. /// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to
pub elements: Vec<Element>, /// access by `(x, y)` coordinates.
/// Row-major grid of `(Glyph, element_index)` pairs. Use [`Board::get`] pub(crate) cells: Vec<(Glyph, Archetype)>,
/// to access by coordinates.
pub(crate) cells: Vec<(Glyph, usize)>,
/// Current player position. See [`Player`] for caveats about its future. /// Current player position. See [`Player`] for caveats about its future.
pub player: Player, pub player: Player,
/// Scripted objects on this board. Parsed from the map file; not yet active. /// Scripted objects on this board. Parsed from the map file; not yet active.
@@ -143,22 +232,11 @@ pub struct Board {
} }
impl 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)`. /// Returns a reference to the cell at `(x, y)`.
/// ///
/// The cell is a `(Glyph, usize)` tuple where the `usize` is an index /// The cell is a `(Glyph, Archetype)` tuple. Panics if `x` or `y` are
/// into [`Board::elements`]. Panics if `x` or `y` are out of bounds. /// out of bounds.
pub fn get(&self, x: usize, y: usize) -> &(Glyph, usize) { pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) {
&self.cells[y * self.width + x] &self.cells[y * self.width + x]
} }
@@ -166,16 +244,18 @@ impl Board {
/// ///
/// Panics if `x` or `y` are out of bounds. /// Panics if `x` or `y` are out of bounds.
#[allow(dead_code)] #[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] &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. /// Panics if `x` or `y` are out of bounds.
pub fn is_passable(&self, x: usize, y: usize) -> bool { pub fn is_passable(&self, x: usize, y: usize) -> bool {
let (_, elem_idx) = self.get(x, y); let (_, arch) = self.get(x, y);
self.elements[*elem_idx].passable // 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. /// 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). /// is not passable. No-ops silently (the caller does not need to check).
pub fn try_move(&mut self, dx: i32, dy: i32) { pub fn try_move(&mut self, dx: i32, dy: i32) {
let nx = self.board.player.x + dx; let nx = self.board.player.x + dx;
+3 -3
View File
@@ -110,9 +110,9 @@ impl eframe::App for App {
); );
}); });
// Force a repaint every frame so input is processed continuously. // egui repaints automatically on input events, so no explicit
// Remove this if the game becomes purely event-driven. // request_repaint() is needed while the game is purely event-driven.
ctx.request_repaint(); // Add it back (or call it from game logic) when continuous animation is needed.
} }
} }
+34 -29
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use serde::Deserialize; use serde::Deserialize;
use eframe::egui::Color32; 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. /// 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. /// The `[map]` section: name, dimensions, player start position.
pub map: MapHeader, pub map: MapHeader,
/// The `[palette]` section: maps single-character keys to tile definitions. /// 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. /// for all cells that use that character in the grid.
pub palette: HashMap<String, PaletteEntry>, pub palette: HashMap<String, PaletteEntry>,
/// The `[grid]` section: the multi-line string that defines cell layout. /// 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 `" "`). /// Each palette entry is keyed by a single character (e.g. `"#"` or `" "`).
/// That character is used in the `[grid]` content string to place tiles. /// 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 /// The entry defines both how the tile looks (`ch`, `fg`, `bg`) and which
/// behaves (`passable`). /// [`Archetype`] it uses for behavior.
/// ///
/// Note that `ch` (the display character) does not have to match the palette /// 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 /// 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 `#`. /// wall tile that displays as `#`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PaletteEntry { pub struct PaletteEntry {
/// Whether entities can walk onto this tile. /// The archetype name for this tile, e.g. `"wall"` or `"empty"`.
pub passable: bool, /// 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. /// The character displayed in this cell.
pub ch: char, pub ch: char,
/// Foreground color as an `"#RRGGBB"` hex string. /// Foreground color as an `"#RRGGBB"` hex string.
@@ -100,42 +102,46 @@ fn parse_color(hex: &str) -> Color32 {
/// ///
/// The conversion proceeds in two passes: /// The conversion proceeds in two passes:
/// ///
/// 1. **Palette pass** — each palette entry becomes an [`Element`] (pushed /// 1. **Palette pass** — each entry is parsed into a `(Glyph, Archetype)` pair
/// into `elements`) and a [`Glyph`] (stored in a temporary `HashMap` /// and stored in a temporary `HashMap` keyed by the palette character.
/// keyed by the palette character). The element index is recorded /// Unknown archetype names produce an [`Archetype::ErrorBlock`] and a
/// alongside the glyph so cells can reference it. /// logged warning so malformed maps are visible in-game.
/// ///
/// 2. **Grid pass** — `grid.content` is split into lines; each character /// 2. **Grid pass** — `grid.content` is split into lines; each character
/// is looked up in the palette map. Unknown characters are silently /// is looked up in the palette map and pushed directly into `cells`.
/// skipped (they produce no cell), so a malformed grid will result in /// Unknown characters are silently skipped, so a malformed grid will
/// a `cells` vec shorter than `width * height`. No explicit validation /// result in a `cells` vec shorter than `width * height`.
/// is done here yet.
impl From<MapFile> for Board { impl From<MapFile> for Board {
fn from(mf: MapFile) -> Self { fn from(mf: MapFile) -> Self {
let w = mf.map.width; let w = mf.map.width;
let h = mf.map.height; let h = mf.map.height;
// Pass 1: build the element palette and a char(Glyph, idx) lookup. // Pass 1: build a char(Glyph, Archetype) lookup from the palette.
let mut elements: Vec<Element> = Vec::new(); let mut palette: HashMap<char, (Glyph, Archetype)> = HashMap::new();
let mut palette: HashMap<char, (Glyph, usize)> = HashMap::new();
for (key, entry) in &mf.palette { for (key, entry) in &mf.palette {
let key_char = key.chars().next().unwrap_or(' '); let key_char = key.chars().next().unwrap_or(' ');
let idx = elements.len(); // Unknown archetype names fall back to ErrorBlock so the error is
elements.push(Element { passable: entry.passable }); // visible in-game rather than silently producing empty tiles.
palette.insert(key_char, (Glyph { match Archetype::try_from(entry.archetype.as_str()) {
ch: entry.ch, Ok(archetype) => {
fg: parse_color(&entry.fg), let glyph = Glyph { ch: entry.ch, fg: parse_color(&entry.fg), bg: parse_color(&entry.bg) };
bg: parse_color(&entry.bg), palette.insert(key_char, (glyph, archetype));
}, idx)); }
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. // 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 line in mf.grid.content.lines() {
for ch in line.chars() { for ch in line.chars() {
if let Some(&(glyph, idx)) = palette.get(&ch) { if let Some(&cell) = palette.get(&ch) {
cells.push((glyph, idx)); cells.push(cell);
} }
} }
} }
@@ -143,7 +149,6 @@ impl From<MapFile> for Board {
Board { Board {
width: w, width: w,
height: h, height: h,
elements,
cells, cells,
player: Player { player: Player {
x: mf.map.player_start[0], x: mf.map.player_start[0],
@@ -167,4 +172,4 @@ pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?; let content = std::fs::read_to_string(path)?;
let map_file: MapFile = toml::from_str(&content)?; let map_file: MapFile = toml::from_str(&content)?;
Ok(Board::from(map_file)) Ok(Board::from(map_file))
} }