Files
kiln/kiln-core/src/layer.rs
T
2026-07-11 14:47:08 -05:00

366 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
/// Single character to fill the whole `width × height` grid with.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill: Option<String>,
/// Individual cells over an otherwise all-spaces grid.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sparse: Option<Vec<SparseCell>>,
/// Char (as a one-character string key) → palette entry.
#[serde(default)]
pub palette: HashMap<String, PaletteEntry>,
}
/// 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<TileIndex>,
/// Foreground `"#RRGGBB"`. Used by archetype/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Background `"#RRGGBB"`. Used by archetype/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
/// Object solidity (defaults `true`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solid: Option<bool>,
/// Object opacity (defaults `true`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opaque: Option<bool>,
/// Object pushability (defaults `false`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pushable: Option<bool>,
/// 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<u32>,
/// Rhai script name. Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script_name: Option<String>,
/// Open-ended labels. Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
/// Unique name. For `kind = "object"` (optional) or `kind = "portal"` (required).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Target board key. Required for `kind = "portal"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_map: Option<String>,
/// Target portal name on the destination board. Required for `kind = "portal"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_entry: Option<String>,
}
/// 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<String>,
pub tags: Vec<String>,
pub name: Option<String>,
}
/// 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<LogLine>) -> 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<MapFile>`), 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<LogLine>,
) -> Result<Vec<Vec<char>>, 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<LogLine>| {
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<char> = 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<LogLine>,
) -> Result<(Vec<GridCell>, Vec<Placement>), 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<char, Resolved> = 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<GridCell> = Vec::with_capacity(width * height);
let mut placements: Vec<Placement> = 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))
}