//! The board grid: the palette+char map-file unit and its load-time conversion. //! //! A board is a single **grid** — a character grid plus a palette mapping each //! character to one *kind* of thing: an archetype (terrain), a scripted object, a //! portal, or the player. (The board's cosmetic floor is a separate `[map]` //! attribute, not a grid cell; see [`crate::floor`].) //! //! This module owns the grid serde type ([`GridData`], [`PaletteEntry`]) and the //! load-time conversion ([`build_grid`]) that turns one `GridData` into the board's //! `Vec<(Glyph, Archetype)>` cells plus a list of [`Placement`]s (objects/portals/ //! player) for the map loader to resolve. Cross-cell validation (one solid per //! cell, unique names, the player winning its cell) lives in [`crate::map_file`]. use crate::archetype::Archetype; use crate::glyph::Glyph; use crate::log::LogLine; use crate::map_file::{TileIndex, parse_color}; use crate::object_def::ObjectDef; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Serde representation of the board `[grid]`: a char grid plus its palette. /// /// The grid is given in exactly one of three ways (precedence: `content`, then /// `fill`, then `sparse`; none of them ⇒ an all-spaces grid): /// - `content` — a multi-line grid string, one char per cell (`width × height`). /// - `fill` — a single character; the whole grid is filled with it. /// - `sparse` — a list of `{ x, y, ch }` cells over an otherwise all-spaces grid /// (handy for a grid holding just a few things). /// /// A space (`' '`) is always a transparent empty cell and is never a palette key /// (any `" "` entry in `palette` is ignored). #[derive(Deserialize, Serialize, Default)] pub(crate) struct GridData { /// Multi-line grid string; one char per cell, looked up in `palette`. #[serde(default, skip_serializing_if = "Option::is_none")] pub content: Option, /// Single character to fill the whole `width × height` grid with. #[serde(default, skip_serializing_if = "Option::is_none")] pub fill: Option, /// Individual cells over an otherwise all-spaces grid. #[serde(default, skip_serializing_if = "Option::is_none")] pub sparse: Option>, /// Char (as a one-character string key) → palette entry. #[serde(default)] pub palette: HashMap, } /// One cell in a [`GridData::sparse`] list: a single character at `(x, y)`. #[derive(Deserialize, Serialize, Clone)] pub(crate) struct SparseCell { /// Column (0-indexed). pub x: usize, /// Row (0-indexed). pub y: usize, /// The grid character at this cell (a one-character string). pub ch: String, } /// One palette entry, discriminated by [`kind`](PaletteEntry::kind). /// /// A single flat struct (rather than an enum) because `kind` is open-ended: it is /// any archetype name (`"wall"`, `"crate"`, `"pusher_east"`, …) *or* one of the /// meta-kinds `empty`, `object`, `portal`, `player`. Only the fields relevant to a /// given kind are read; the rest stay `None`. See [`resolve_entry`]. #[derive(Deserialize, Serialize, Default, Clone)] pub(crate) struct PaletteEntry { /// What this entry is: an archetype name, or `empty`/`object`/`portal`/`player`. pub kind: String, /// Tile index (int or single-char string). Used by archetype/object kinds. #[serde(default, skip_serializing_if = "Option::is_none")] pub tile: Option, /// Foreground `"#RRGGBB"`. Used by archetype/object kinds. #[serde(default, skip_serializing_if = "Option::is_none")] pub fg: Option, /// Background `"#RRGGBB"`. Used by archetype/object kinds. #[serde(default, skip_serializing_if = "Option::is_none")] pub bg: Option, /// Object solidity (defaults `true`). Only for `kind = "object"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub solid: Option, /// Object opacity (defaults `true`). Only for `kind = "object"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub opaque: Option, /// Object pushability (defaults `false`). Only for `kind = "object"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub pushable: Option, /// Light radius in cells emitted on a dark board (defaults `0` = none). /// Only for `kind = "object"`. See [`ObjectDef::light`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub light: Option, /// Rhai script name. Only for `kind = "object"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub script_name: Option, /// Open-ended labels. Only for `kind = "object"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option>, /// Unique name. For `kind = "object"` (optional) or `kind = "portal"` (required). #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, /// Target board key. Required for `kind = "portal"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub target_map: Option, /// Target portal name on the destination board. Required for `kind = "portal"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub target_entry: Option, } /// A single grid cell: its visual and its behavioral class. pub(crate) type GridCell = (Glyph, Archetype); /// A non-terrain thing the grid places at a cell, resolved by the map loader. /// /// Terrain goes straight into the grid cells; these need cross-cell handling (ids, /// name uniqueness, the single player), so [`build_grid`] returns them separately /// with their `(x, y)` for [`crate::map_file`] to finish. pub(crate) enum Placement { /// A scripted object to spawn at `(x, y)`. Object(ObjectTemplate, usize, usize), /// A portal at `(x, y)`. Portal(PortalTemplate, usize, usize), /// The player's start cell `(x, y)`. Player(usize, usize), } /// A resolved object definition minus board-assigned fields (`id`). #[derive(Clone)] pub(crate) struct ObjectTemplate { pub glyph: Glyph, pub solid: bool, pub opaque: bool, pub pushable: bool, /// Light radius in cells (0 = none); see [`ObjectDef::light`]. pub light: u32, pub script_name: Option, pub tags: Vec, pub name: Option, } /// A resolved portal definition minus `(x, y)`. #[derive(Clone)] pub(crate) struct PortalTemplate { pub name: String, pub target_map: String, pub target_entry: String, } /// What a palette character resolves to during the grid walk. #[derive(Clone)] enum Resolved { /// A fixed cell written straight into the grid (terrain, empty, error block). Cell(Glyph, Archetype), /// A scripted object placed per occurrence. Object(ObjectTemplate), /// A portal placed per occurrence. Portal(PortalTemplate), /// The player's start cell. Player, } /// Resolves one palette entry to a [`Resolved`], recording any nonfatal problem. /// /// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `portal` /// missing required fields falls back to a transparent cell (the grid char is /// still consumed). Object/archetype glyphs fall back to the relevant default for /// any absent visual field. fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec) -> Resolved { // Builds a glyph from the entry's tile/fg/bg, each falling back to `default`. let glyph_with_default = |default: Glyph| Glyph { tile: e.tile.map(TileIndex::into_u32).unwrap_or(default.tile), fg: e.fg.as_deref().map(parse_color).unwrap_or(default.fg), bg: e.bg.as_deref().map(parse_color).unwrap_or(default.bg), }; match e.kind.as_str() { // Transparent: the floor / a lower thing shows through here. "empty" => Resolved::Cell(Glyph::transparent(), Archetype::Empty), "object" => Resolved::Object(ObjectTemplate { glyph: glyph_with_default(ObjectDef::default_glyph()), solid: e.solid.unwrap_or(true), opaque: e.opaque.unwrap_or(true), pushable: e.pushable.unwrap_or(false), light: e.light.unwrap_or(0), script_name: e.script_name.clone(), tags: e.tags.clone().unwrap_or_default(), name: e.name.clone(), }), "portal" => match (e.name.clone(), e.target_map.clone(), e.target_entry.clone()) { (Some(name), Some(target_map), Some(target_entry)) => { Resolved::Portal(PortalTemplate { name, target_map, target_entry, }) } _ => { errors.push(LogLine::error(format!( "portal palette '{ch}' needs name, target_map and target_entry; skipping" ))); Resolved::Cell(Glyph::transparent(), Archetype::Empty) } }, "player" => Resolved::Player, // Any other kind must be an archetype name. Script-backed archetypes (e.g. // pushers/spinners) load as a plain terrain cell here and are turned into // their scripted objects afterward by `Board::expand_builtin_archetypes` // (called from `TryFrom`), so the expansion lives in one place. other => match Archetype::try_from(other) { Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a), Err(msg) => { errors.push(LogLine::error(format!( "palette '{ch}': {msg}; using error block" ))); Resolved::Cell(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock) } }, } } /// Resolves the grid to a `height × width` matrix of chars from whichever of /// `content` / `fill` / `sparse` is supplied (in that precedence; none ⇒ all spaces). /// /// Only an explicit `content` can mismatch the board dimensions — the single hard /// error. `fill`/`sparse` always produce an exactly-sized grid; a non-single-char /// `fill`/`ch` or an out-of-bounds `sparse` cell is recorded on `errors` and the /// offending cell falls back to (or stays) a space. fn grid_chars( data: &GridData, width: usize, height: usize, errors: &mut Vec, ) -> Result>, String> { // Reads a one-character string field, recording `context` and returning `None` // when it is empty or longer than one char. let single_char = |s: &str, context: String, errors: &mut Vec| { let mut chs = s.chars(); match (chs.next(), chs.next()) { (Some(c), None) => Some(c), _ => { errors.push(LogLine::error(context)); None } } }; if let Some(content) = &data.content { // Explicit grid: validate it matches the declared dimensions. let rows: Vec<&str> = content.lines().collect(); if rows.len() != height { return Err(format!( "grid has {} rows but the board is {height} tall", rows.len() )); } let mut grid = Vec::with_capacity(height); for (i, line) in rows.iter().enumerate() { let row: Vec = line.chars().collect(); if row.len() != width { return Err(format!( "grid row {i} has {} characters but the board is {width} wide", row.len() )); } grid.push(row); } return Ok(grid); } if let Some(fill) = &data.fill { // A whole grid of one character. let ch = single_char( fill, format!("grid fill must be a single character (got {fill:?}); using a space"), errors, ) .unwrap_or(' '); return Ok(vec![vec![ch; width]; height]); } // `sparse` (or nothing): an all-spaces grid with the listed cells stamped in. let mut grid = vec![vec![' '; width]; height]; for cell in data.sparse.iter().flatten() { let Some(ch) = single_char( &cell.ch, format!( "sparse cell ch must be a single character (got {:?}); skipping", cell.ch ), errors, ) else { continue; }; if cell.x >= width || cell.y >= height { errors.push(LogLine::error(format!( "sparse cell ({}, {}) is out of bounds; skipping", cell.x, cell.y ))); continue; } grid[cell.y][cell.x] = ch; } Ok(grid) } /// Builds the board's grid cells from its [`GridData`], plus the non-terrain /// placements it contains (with their `(x, y)`). /// /// Returns `Err` only on a grid-dimension mismatch (the single hard error); /// every other problem is recorded on `errors`. pub(crate) fn build_grid( data: &GridData, width: usize, height: usize, errors: &mut Vec, ) -> Result<(Vec, Vec), String> { // Resolve each palette entry once. Space is always a transparent empty cell, so // it is never a palette key — any `" "` entry is ignored. let resolved: HashMap = data .palette .iter() .filter_map(|(key, entry)| { let ch = key.chars().next().unwrap_or(' '); (ch != ' ').then(|| (ch, resolve_entry(ch, entry, errors))) }) .collect(); // Resolve the grid (content / fill / sparse) before walking it. let grid = grid_chars(data, width, height, errors)?; // Walk the grid, filling cells and collecting placements. let mut cells: Vec = Vec::with_capacity(width * height); let mut placements: Vec = Vec::new(); for (y, row) in grid.iter().enumerate() { for (x, &ch) in row.iter().enumerate() { // A space is always a transparent empty cell, palette or not. if ch == ' ' { cells.push((Glyph::transparent(), Archetype::Empty)); continue; } match resolved.get(&ch) { Some(Resolved::Cell(g, a)) => cells.push((*g, *a)), Some(Resolved::Object(t)) => { cells.push((Glyph::transparent(), Archetype::Empty)); placements.push(Placement::Object(t.clone(), x, y)); } Some(Resolved::Portal(t)) => { cells.push((Glyph::transparent(), Archetype::Empty)); placements.push(Placement::Portal(t.clone(), x, y)); } Some(Resolved::Player) => { cells.push((Glyph::transparent(), Archetype::Empty)); placements.push(Placement::Player(x, y)); } None => { errors.push(LogLine::error(format!( "unknown grid character '{ch}' at ({x}, {y}); using error block" ))); cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)); } } } } Ok((cells, placements)) }