2026-06-15 23:35:18 -05:00
|
|
|
|
//! Palette layers: the unit of the map-file format and the board's draw stack.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! 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".
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! 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`].
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
2026-06-20 19:05:46 -05:00
|
|
|
|
#[derive(Clone)]
|
2026-06-15 23:35:18 -05:00
|
|
|
|
pub(crate) struct Layer {
|
|
|
|
|
|
/// Row-major `(Glyph, Archetype)` cells for this layer.
|
|
|
|
|
|
pub(crate) cells: Vec<(Glyph, Archetype)>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 00:01:02 -05:00
|
|
|
|
/// Serde representation of one `[[layers]]` entry: a 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).
|
|
|
|
|
|
/// - `sparse` — a list of `{ x, y, ch }` cells over an otherwise all-spaces grid
|
|
|
|
|
|
/// (handy for a layer holding just a few objects).
|
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-06-15 23:35:18 -05:00
|
|
|
|
#[derive(Deserialize, Serialize)]
|
|
|
|
|
|
pub(crate) struct LayerData {
|
|
|
|
|
|
/// 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-06-16 00:01:02 -05:00
|
|
|
|
/// One cell in a [`LayerData::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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
/// meta-kinds `empty`, `floor`, `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`.
|
|
|
|
|
|
pub kind: String,
|
|
|
|
|
|
/// Tile index (int or single-char string). Used by archetype/floor/object kinds.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub tile: Option<TileIndex>,
|
|
|
|
|
|
/// Foreground `"#RRGGBB"`. Used by archetype/floor/object kinds.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub fg: Option<String>,
|
|
|
|
|
|
/// Background `"#RRGGBB"`. Used by archetype/floor/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>,
|
|
|
|
|
|
/// 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>,
|
|
|
|
|
|
/// 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 non-terrain thing a layer places at a grid 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.
|
|
|
|
|
|
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`, `z`).
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
|
pub(crate) struct ObjectTemplate {
|
|
|
|
|
|
pub glyph: Glyph,
|
|
|
|
|
|
pub solid: bool,
|
|
|
|
|
|
pub opaque: bool,
|
|
|
|
|
|
pub pushable: bool,
|
|
|
|
|
|
pub script_name: Option<String>,
|
2026-06-16 14:34:42 -05:00
|
|
|
|
pub builtin_script: Option<&'static str>,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
pub tags: Vec<String>,
|
|
|
|
|
|
pub name: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A resolved portal definition minus `(x, y, z)`.
|
|
|
|
|
|
#[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 layer (terrain, fixed floor, 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.
|
|
|
|
|
|
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 `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.
|
|
|
|
|
|
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: lower layers show 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),
|
|
|
|
|
|
opaque: e.opaque.unwrap_or(true),
|
|
|
|
|
|
pushable: e.pushable.unwrap_or(false),
|
|
|
|
|
|
script_name: e.script_name.clone(),
|
2026-06-16 14:34:42 -05:00
|
|
|
|
builtin_script: None,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
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.
|
|
|
|
|
|
other => match Archetype::try_from(other) {
|
2026-06-16 14:34:42 -05:00
|
|
|
|
// Script-backed archetypes (e.g. pushers) expand into an object carrying
|
|
|
|
|
|
// the embedded script plus a `BUILTIN_<archetype>` tag the script reads.
|
|
|
|
|
|
Ok(a) => match crate::builtin_scripts::archetype_script(a) {
|
|
|
|
|
|
Some(src) => {
|
|
|
|
|
|
let b = a.behavior();
|
|
|
|
|
|
Resolved::Object(ObjectTemplate {
|
|
|
|
|
|
glyph: glyph_with_default(a.default_glyph()),
|
|
|
|
|
|
solid: b.solid,
|
|
|
|
|
|
opaque: b.opaque,
|
|
|
|
|
|
pushable: false,
|
|
|
|
|
|
script_name: None,
|
|
|
|
|
|
builtin_script: Some(src),
|
|
|
|
|
|
tags: vec![crate::builtin_scripts::builtin_tag(a)],
|
|
|
|
|
|
name: None,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
None => 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-06-16 00:01:02 -05:00
|
|
|
|
/// Resolves a layer's 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: &LayerData,
|
|
|
|
|
|
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!(
|
|
|
|
|
|
"layer 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!(
|
|
|
|
|
|
"layer 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!("layer 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Builds one [`Layer`] from its [`LayerData`], 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,
|
|
|
|
|
|
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).
|
2026-06-16 00:14:59 -05:00
|
|
|
|
// 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.
|
|
|
|
|
|
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height);
|
|
|
|
|
|
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::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));
|
|
|
|
|
|
}
|
|
|
|
|
|
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((Layer { cells }, placements))
|
|
|
|
|
|
}
|