new layer model

This commit is contained in:
2026-07-10 23:16:28 -05:00
parent 325d2c27dd
commit ad95a9cd8d
36 changed files with 1040 additions and 897 deletions
+53 -96
View File
@@ -1,54 +1,37 @@
//! Palette layers: the unit of the map-file format and the board's draw stack.
//! The board grid: the palette+char map-file unit and its load-time conversion.
//!
//! A board is an ordered list of **layers**, drawn bottom (index 0) to top. Each
//! layer is a character grid plus a palette mapping each character to one *kind*
//! of thing — an archetype (terrain), a floor glyph, a scripted object, a portal,
//! or the player. "Put two things on one cell" simply means "use two layers".
//! 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 per-layer serde types ([`LayerData`], [`PaletteEntry`]),
//! the runtime [`Layer`] (a grid of `(Glyph, Archetype)` cells, where a cell with
//! a transparent glyph lets lower layers show through), and the load-time
//! conversion ([`build_layer`]) that turns one `LayerData` into a `Layer` plus a
//! list of [`Placement`]s (objects/portals/player) for the map loader to resolve
//! across layers. Cross-layer validation (one solid per cell, unique names, the
//! player winning its cell) lives in [`crate::map_file`].
//! 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::floor::FloorGenerator;
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;
use tinyrand::StdRand;
/// A single draw layer: a row-major grid of `(Glyph, Archetype)` cells.
///
/// A cell whose `glyph.tile == 0` (see [`Glyph::transparent`]) is transparent —
/// it draws nothing and lets the layer beneath show through. Solidity comes from
/// the archetype and is independent of transparency. The grid is `width * height`
/// long; index it via the owning [`Board`](crate::board::Board)'s dimensions.
#[derive(Clone)]
pub(crate) struct Layer {
/// Row-major `(Glyph, Archetype)` cells for this layer.
pub(crate) cells: Vec<(Glyph, Archetype)>,
}
/// Serde representation of one `[[layers]]` entry: a grid plus its palette.
/// 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 (handy for a
/// layer of identical floor).
/// - `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 layer holding just a few objects).
/// (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)]
pub(crate) struct LayerData {
#[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>,
@@ -63,7 +46,7 @@ pub(crate) struct LayerData {
pub palette: HashMap<String, PaletteEntry>,
}
/// One cell in a [`LayerData::sparse`] list: a single character at `(x, y)`.
/// One cell in a [`GridData::sparse`] list: a single character at `(x, y)`.
#[derive(Deserialize, Serialize, Clone)]
pub(crate) struct SparseCell {
/// Column (0-indexed).
@@ -78,24 +61,21 @@ pub(crate) struct SparseCell {
///
/// 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`, `floor`, `object`, `portal`, `player`. Only the fields
/// relevant to a given kind are read; the rest stay `None`. See [`resolve_entry`].
/// 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`/`floor`/`object`/`portal`/`player`.
/// 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/floor/object kinds.
/// 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/floor/object kinds.
/// Foreground `"#RRGGBB"`. Used by archetype/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Background `"#RRGGBB"`. Used by archetype/floor/object kinds.
/// Background `"#RRGGBB"`. Used by archetype/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
/// Floor generator name (`"grass"`/`"dirt"`/`"stone"`). Only for `kind = "floor"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generator: Option<String>,
/// Object solidity (defaults `true`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solid: Option<bool>,
@@ -122,11 +102,14 @@ pub(crate) struct PaletteEntry {
pub target_entry: Option<String>,
}
/// A non-terrain thing a layer places at a grid cell, resolved by the map loader.
/// 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/floor go straight into [`Layer::cells`]; these need cross-layer
/// handling (ids, name uniqueness, the single player), so [`build_layer`] returns
/// them separately with their `(x, y)` for [`crate::map_file`] to finish.
/// 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),
@@ -136,7 +119,7 @@ pub(crate) enum Placement {
Player(usize, usize),
}
/// A resolved object definition minus board-assigned fields (`id`, `z`).
/// A resolved object definition minus board-assigned fields (`id`).
#[derive(Clone)]
pub(crate) struct ObjectTemplate {
pub glyph: Glyph,
@@ -148,7 +131,7 @@ pub(crate) struct ObjectTemplate {
pub name: Option<String>,
}
/// A resolved portal definition minus `(x, y, z)`.
/// A resolved portal definition minus `(x, y)`.
#[derive(Clone)]
pub(crate) struct PortalTemplate {
pub name: String,
@@ -159,10 +142,8 @@ pub(crate) struct PortalTemplate {
/// What a palette character resolves to during the grid walk.
#[derive(Clone)]
enum Resolved {
/// A fixed cell written straight into the layer (terrain, fixed floor, empty, error block).
/// A fixed cell written straight into the grid (terrain, empty, error block).
Cell(Glyph, Archetype),
/// A procedural floor: roll a fresh glyph per occurrence.
FloorGen(FloorGenerator),
/// A scripted object placed per occurrence.
Object(ObjectTemplate),
/// A portal placed per occurrence.
@@ -173,10 +154,10 @@ enum Resolved {
/// Resolves one palette entry to a [`Resolved`], recording any nonfatal problem.
///
/// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `floor`
/// with an unknown generator or a `portal` missing required fields falls back to a
/// transparent cell (the grid char is still consumed). Object/floor/archetype
/// glyphs fall back to the relevant default for any absent visual field.
/// 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 {
@@ -186,27 +167,8 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
};
match e.kind.as_str() {
// Transparent: lower layers show through here.
// Transparent: the floor / a lower thing shows through here.
"empty" => Resolved::Cell(Glyph::transparent(), Archetype::Empty),
"floor" => match &e.generator {
Some(name) => match FloorGenerator::from_name(name) {
Some(g) => Resolved::FloorGen(g),
None => {
errors.push(LogLine::error(format!(
"floor palette '{ch}' names unknown generator '{name}'; using empty floor"
)));
Resolved::Cell(Glyph::transparent(), Archetype::Empty)
}
},
// A fixed floor glyph: a visual-only cell (no archetype behavior).
None => Resolved::Cell(
glyph_with_default(Glyph {
tile: 32,
..Glyph::transparent()
}),
Archetype::Empty,
),
},
"object" => Resolved::Object(ObjectTemplate {
glyph: glyph_with_default(ObjectDef::default_glyph()),
solid: e.solid.unwrap_or(true),
@@ -248,7 +210,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
}
}
/// Resolves a layer's grid to a `height × width` matrix of chars from whichever of
/// 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
@@ -256,7 +218,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
/// `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: &LayerData,
data: &GridData,
width: usize,
height: usize,
errors: &mut Vec<LogLine>,
@@ -279,7 +241,7 @@ fn grid_chars(
let rows: Vec<&str> = content.lines().collect();
if rows.len() != height {
return Err(format!(
"layer grid has {} rows but the board is {height} tall",
"grid has {} rows but the board is {height} tall",
rows.len()
));
}
@@ -288,7 +250,7 @@ fn grid_chars(
let row: Vec<char> = line.chars().collect();
if row.len() != width {
return Err(format!(
"layer grid row {i} has {} characters but the board is {width} wide",
"grid row {i} has {} characters but the board is {width} wide",
row.len()
));
}
@@ -301,7 +263,7 @@ fn grid_chars(
// A whole grid of one character.
let ch = single_char(
fill,
format!("layer fill must be a single character (got {fill:?}); using a space"),
format!("grid fill must be a single character (got {fill:?}); using a space"),
errors,
)
.unwrap_or(' ');
@@ -333,23 +295,19 @@ fn grid_chars(
Ok(grid)
}
/// Builds one [`Layer`] from its [`LayerData`], plus the non-terrain placements it
/// contains (with their `(x, y)`).
/// Builds the board's grid cells from its [`GridData`], plus the non-terrain
/// placements it contains (with their `(x, y)`).
///
/// `rng` is the shared, deterministically-seeded floor PRNG (one per board build),
/// threaded through so procedural floors depend only on map content. Returns
/// `Err` only on a grid-dimension mismatch (the single hard error, matching the
/// pre-layers loader); every other problem is recorded on `errors`.
pub(crate) fn build_layer(
data: &LayerData,
/// 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,
rng: &mut StdRand,
errors: &mut Vec<LogLine>,
) -> Result<(Layer, Vec<Placement>), String> {
// Resolve each palette entry once (per-cell work like generators happens below).
// Space is always a transparent empty cell, so it is never a palette key — any
// `" "` entry is ignored.
) -> 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()
@@ -363,7 +321,7 @@ pub(crate) fn build_layer(
let grid = grid_chars(data, width, height, errors)?;
// Walk the grid, filling cells and collecting placements.
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height);
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() {
@@ -374,7 +332,6 @@ pub(crate) fn build_layer(
}
match resolved.get(&ch) {
Some(Resolved::Cell(g, a)) => cells.push((*g, *a)),
Some(Resolved::FloorGen(g)) => cells.push((g.generate(rng), Archetype::Empty)),
Some(Resolved::Object(t)) => {
cells.push((Glyph::transparent(), Archetype::Empty));
placements.push(Placement::Object(t.clone(), x, y));
@@ -397,5 +354,5 @@ pub(crate) fn build_layer(
}
}
Ok((Layer { cells }, placements))
Ok((cells, placements))
}