Files
kiln/kiln-core/src/layer.rs
T

366 lines
15 KiB
Rust
Raw Normal View History

2026-07-10 23:16:28 -05:00
//! The board grid: the palette+char map-file unit and its load-time conversion.
2026-06-15 23:35:18 -05:00
//!
2026-07-10 23:16:28 -05:00
//! 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`].)
2026-06-15 23:35:18 -05:00
//!
2026-07-10 23:16:28 -05:00
//! 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`].
2026-06-15 23:35:18 -05:00
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;
2026-07-10 23:16:28 -05:00
/// Serde representation of the board `[grid]`: a char grid plus its palette.
2026-06-16 00:01:02 -05:00
///
/// 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`).
2026-07-10 23:16:28 -05:00
/// - `fill` — a single character; the whole grid is filled with it.
2026-06-16 00:01:02 -05:00
/// - `sparse` — a list of `{ x, y, ch }` cells over an otherwise all-spaces grid
2026-07-10 23:16:28 -05:00
/// (handy for a grid holding just a few things).
2026-06-16 00:14:59 -05:00
///
/// A space (`' '`) is always a transparent empty cell and is never a palette key
/// (any `" "` entry in `palette` is ignored).
2026-07-10 23:16:28 -05:00
#[derive(Deserialize, Serialize, Default)]
pub(crate) struct GridData {
2026-06-15 23:35:18 -05:00
/// Multi-line grid string; one char per cell, looked up in `palette`.
2026-06-16 00:01:02 -05:00
#[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>>,
2026-06-15 23:35:18 -05:00
/// Char (as a one-character string key) → palette entry.
#[serde(default)]
pub palette: HashMap<String, PaletteEntry>,
}
2026-07-10 23:16:28 -05:00
/// One cell in a [`GridData::sparse`] list: a single character at `(x, y)`.
2026-06-16 00:01:02 -05:00
#[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,
}
2026-06-15 23:35:18 -05:00
/// 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
2026-07-10 23:16:28 -05:00
/// meta-kinds `empty`, `object`, `portal`, `player`. Only the fields relevant to a
/// given kind are read; the rest stay `None`. See [`resolve_entry`].
2026-06-15 23:35:18 -05:00
#[derive(Deserialize, Serialize, Default, Clone)]
pub(crate) struct PaletteEntry {
2026-07-10 23:16:28 -05:00
/// What this entry is: an archetype name, or `empty`/`object`/`portal`/`player`.
2026-06-15 23:35:18 -05:00
pub kind: String,
2026-07-10 23:16:28 -05:00
/// Tile index (int or single-char string). Used by archetype/object kinds.
2026-06-15 23:35:18 -05:00
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<TileIndex>,
2026-07-10 23:16:28 -05:00
/// Foreground `"#RRGGBB"`. Used by archetype/object kinds.
2026-06-15 23:35:18 -05:00
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
2026-07-10 23:16:28 -05:00
/// Background `"#RRGGBB"`. Used by archetype/object kinds.
2026-06-15 23:35:18 -05:00
#[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>,
2026-07-11 14:47:08 -05:00
/// 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>,
2026-06-15 23:35:18 -05:00
/// 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>,
}
2026-07-10 23:16:28 -05:00
/// 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.
2026-06-15 23:35:18 -05:00
///
2026-07-10 23:16:28 -05:00
/// 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.
2026-06-15 23:35:18 -05:00
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),
}
2026-07-10 23:16:28 -05:00
/// A resolved object definition minus board-assigned fields (`id`).
2026-06-15 23:35:18 -05:00
#[derive(Clone)]
pub(crate) struct ObjectTemplate {
pub glyph: Glyph,
pub solid: bool,
pub opaque: bool,
pub pushable: bool,
2026-07-11 14:47:08 -05:00
/// Light radius in cells (0 = none); see [`ObjectDef::light`].
pub light: u32,
2026-06-15 23:35:18 -05:00
pub script_name: Option<String>,
pub tags: Vec<String>,
pub name: Option<String>,
}
2026-07-10 23:16:28 -05:00
/// A resolved portal definition minus `(x, y)`.
2026-06-15 23:35:18 -05:00
#[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 {
2026-07-10 23:16:28 -05:00
/// A fixed cell written straight into the grid (terrain, empty, error block).
2026-06-15 23:35:18 -05:00
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.
///
2026-07-10 23:16:28 -05:00
/// 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.
2026-06-15 23:35:18 -05:00
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() {
2026-07-10 23:16:28 -05:00
// Transparent: the floor / a lower thing shows through here.
2026-06-15 23:35:18 -05:00
"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),
2026-07-11 14:47:08 -05:00
light: e.light.unwrap_or(0),
2026-06-15 23:35:18 -05:00
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,
2026-06-21 01:32:47 -05:00
// 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.
2026-06-15 23:35:18 -05:00
other => match Archetype::try_from(other) {
2026-06-21 01:32:47 -05:00
Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a),
2026-06-15 23:35:18 -05:00
Err(msg) => {
errors.push(LogLine::error(format!(
"palette '{ch}': {msg}; using error block"
)));
Resolved::Cell(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)
}
},
}
}
2026-07-10 23:16:28 -05:00
/// Resolves the grid to a `height × width` matrix of chars from whichever of
2026-06-16 00:01:02 -05:00
/// `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(
2026-07-10 23:16:28 -05:00
data: &GridData,
2026-06-16 00:01:02 -05:00
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!(
2026-07-10 23:16:28 -05:00
"grid has {} rows but the board is {height} tall",
2026-06-16 00:01:02 -05:00
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!(
2026-07-10 23:16:28 -05:00
"grid row {i} has {} characters but the board is {width} wide",
2026-06-16 00:01:02 -05:00
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,
2026-07-10 23:16:28 -05:00
format!("grid fill must be a single character (got {fill:?}); using a space"),
2026-06-16 00:01:02 -05:00
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)
}
2026-07-10 23:16:28 -05:00
/// Builds the board's grid cells from its [`GridData`], plus the non-terrain
/// placements it contains (with their `(x, y)`).
2026-06-15 23:35:18 -05:00
///
2026-07-10 23:16:28 -05:00
/// 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,
2026-06-15 23:35:18 -05:00
width: usize,
height: usize,
errors: &mut Vec<LogLine>,
2026-07-10 23:16:28 -05:00
) -> 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.
2026-06-15 23:35:18 -05:00
let resolved: HashMap<char, Resolved> = data
.palette
.iter()
2026-06-16 00:14:59 -05:00
.filter_map(|(key, entry)| {
2026-06-15 23:35:18 -05:00
let ch = key.chars().next().unwrap_or(' ');
2026-06-16 00:14:59 -05:00
(ch != ' ').then(|| (ch, resolve_entry(ch, entry, errors)))
2026-06-15 23:35:18 -05:00
})
.collect();
2026-06-16 00:01:02 -05:00
// Resolve the grid (content / fill / sparse) before walking it.
let grid = grid_chars(data, width, height, errors)?;
2026-06-15 23:35:18 -05:00
// Walk the grid, filling cells and collecting placements.
2026-07-10 23:16:28 -05:00
let mut cells: Vec<GridCell> = Vec::with_capacity(width * height);
2026-06-15 23:35:18 -05:00
let mut placements: Vec<Placement> = Vec::new();
2026-06-16 00:01:02 -05:00
for (y, row) in grid.iter().enumerate() {
for (x, &ch) in row.iter().enumerate() {
2026-06-16 00:14:59 -05:00
// A space is always a transparent empty cell, palette or not.
if ch == ' ' {
cells.push((Glyph::transparent(), Archetype::Empty));
continue;
}
2026-06-15 23:35:18 -05:00
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));
}
}
}
}
2026-07-10 23:16:28 -05:00
Ok((cells, placements))
2026-06-15 23:35:18 -05:00
}