Files
kiln/kiln-core/src/map_file.rs
T
2026-06-21 01:32:47 -05:00

481 lines
18 KiB
Rust

//! Per-board map-file load/save and the small serde types that orchestrate it.
//!
//! A board in a `.toml` world file is a `[map]` header plus an ordered array of
//! `[[layers]]` (see [`crate::layer`]). This module owns the [`MapFile`]/
//! [`MapHeader`] serde shells and the conversions to and from a runtime
//! [`Board`]; the per-layer grid/palette work lives in [`crate::layer`].
//!
//! Loading is **best-effort/nonfatal**: only a layer grid-dimension mismatch is a
//! hard error. Every other problem (unknown archetype/char → `ErrorBlock`, missing
//! or duplicate player cell, two solids stacked on a cell, duplicate object/portal
//! names) is recovered and recorded on [`Board::load_errors`].
use color::Rgba8;
use serde::{Deserialize, Serialize};
use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::TryFrom;
use std::path::Path;
use tinyrand::{Seeded, StdRand};
use crate::archetype::Archetype;
use crate::board::Board;
use crate::builtin_scripts::archetype_from_builtin_tag;
use crate::floor::FLOOR_SEED;
use crate::glyph::Glyph;
use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer};
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::utils::{ObjectId, Player, PortalDef};
/// The serde shell for one board in a `.toml` file: a header and its layers.
///
/// On load this is converted into a [`Board`] via [`TryFrom`] and discarded; on
/// save a [`Board`] is converted back via [`From<&Board>`]. See `maps/start.toml`
/// for a complete example of the format.
#[derive(Deserialize, Serialize)]
pub struct MapFile {
/// The `[map]` header: name, dimensions, optional board script.
pub map: MapHeader,
/// The ordered `[[layers]]` stack, bottom (index 0) to top.
#[serde(default)]
pub(crate) layers: Vec<LayerData>,
}
/// The `[map]` header section of a board.
#[derive(Deserialize, Serialize)]
pub struct MapHeader {
/// Human-readable name for this board, e.g. `"Opening Room"`.
pub name: String,
/// Width of the board in cells. Must match every layer row's length.
pub width: usize,
/// Height of the board in cells. Must match every layer's row count.
pub height: usize,
/// Name of the board-level script in the `[scripts]` table, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub board_script_name: Option<String>,
}
/// A tile index in a palette entry: either a plain integer or a character literal.
///
/// Accepting both forms lets map files write `tile = 35` or `tile = "#"` (the char
/// is converted to its Unicode scalar) interchangeably.
#[derive(Deserialize, Serialize, Clone, Copy)]
#[serde(untagged)]
pub enum TileIndex {
/// A direct tile index (e.g. `tile = 35`).
Num(u32),
/// A single-character shorthand (e.g. `tile = "#"`); converted to its Unicode scalar.
Chr(char),
}
impl TileIndex {
/// Returns the tile index as a `u32`, converting a char to its scalar value.
pub(crate) fn into_u32(self) -> u32 {
match self {
TileIndex::Num(n) => n,
TileIndex::Chr(c) => c as u32,
}
}
}
/// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`].
/// Returns opaque black on any parse failure.
pub(crate) fn parse_color(hex: &str) -> Rgba8 {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Rgba8 {
r: 0,
g: 0,
b: 0,
a: 255,
};
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
Rgba8 { r, g, b, a: 255 }
}
/// Converts an [`Rgba8`] to an `"#RRGGBB"` hex string (alpha is ignored).
pub(crate) fn color_to_hex(color: Rgba8) -> String {
format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b)
}
/// Converts a parsed map file into a runtime [`Board`].
///
/// Builds each layer (collecting its object/portal/player placements), then runs
/// the cross-layer validations that span the whole board:
/// - the player must appear exactly once (missing → `(0, 0)`; multiple → the first), and wins its cell;
/// - at most one solid may occupy a cell across all layers (a stacked solid is dropped);
/// - object names must be board-unique (a duplicate is cleared) and portal names unique (a duplicate is dropped).
///
/// Returns `Err` only when a layer's grid dimensions disagree with the header.
impl TryFrom<MapFile> for Board {
type Error = String;
fn try_from(mf: MapFile) -> Result<Self, Self::Error> {
let w = mf.map.width;
let h = mf.map.height;
let mut load_errors: Vec<LogLine> = Vec::new();
// One PRNG for the whole board so generated floors depend only on content.
let mut rng = StdRand::seed(FLOOR_SEED);
// Build every layer, collecting non-terrain placements with their layer z.
let mut layers: Vec<Layer> = Vec::with_capacity(mf.layers.len());
let mut object_specs: Vec<(crate::layer::ObjectTemplate, usize, usize, usize)> = Vec::new();
let mut portal_specs: Vec<(crate::layer::PortalTemplate, usize, usize, usize)> = Vec::new();
let mut player_positions: Vec<(usize, usize)> = Vec::new();
for (z, data) in mf.layers.iter().enumerate() {
let (layer, placements) = build_layer(data, w, h, &mut rng, &mut load_errors)?;
for p in placements {
match p {
Placement::Object(t, x, y) => object_specs.push((t, x, y, z)),
Placement::Portal(t, x, y) => portal_specs.push((t, x, y, z)),
Placement::Player(x, y) => player_positions.push((x, y)),
}
}
layers.push(layer);
}
if layers.is_empty() {
return Err("map has no [[layers]]".into());
}
// The player must be placed exactly once across all layers.
let (px, py) = match player_positions.len() {
1 => player_positions[0],
0 => {
load_errors.push(LogLine::error(
"no player cell (kind = \"player\") found; placing player at (0, 0)",
));
(0, 0)
}
n => {
load_errors.push(LogLine::error(format!(
"player cell appears {n} times across layers; using the first"
)));
player_positions[0]
}
};
let pidx = py * w + px;
// Enforce one solid per cell across all layers: the first solid seen at a
// cell claims it; a later (upper) solid stacked on it is dropped.
let mut solid_occupied = vec![false; w * h];
for (z, layer) in layers.iter_mut().enumerate() {
for (idx, cell) in layer.cells.iter_mut().enumerate() {
if !cell.1.behavior().solid {
continue;
}
if solid_occupied[idx] {
let (x, y) = (idx % w, idx / w);
load_errors.push(LogLine::error(format!(
"two solids stacked at ({x}, {y}) on layer {z}; dropping the upper one"
)));
*cell = (Glyph::transparent(), Archetype::Empty);
} else {
solid_occupied[idx] = true;
}
}
}
// The player wins its cell: clear any solid terrain under it (any layer)
// and claim the cell so a solid object placed here is dropped below.
if solid_occupied[pidx] {
for layer in &mut layers {
if layer.cells[pidx].1.behavior().solid {
layer.cells[pidx] = (Glyph::transparent(), Archetype::Empty);
}
}
}
solid_occupied[pidx] = true;
// Spawn objects in layer-then-reading order, so ids are deterministic and
// "lowest id wins a collision" / "first claimant keeps the name" hold.
let mut objects: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
let mut next_object_id: ObjectId = 1;
let mut seen_names: HashMap<String, ObjectId> = HashMap::new();
for (t, x, y, z) in object_specs {
let idx = y * w + x;
// A solid object may not share a cell with another solid.
if t.solid && solid_occupied[idx] {
// The player silently wins its cell; any other conflict is reported.
if idx != pidx {
load_errors.push(LogLine::error(format!(
"solid object at ({x}, {y}) conflicts with an existing solid; skipping object"
)));
}
continue;
}
let id = next_object_id;
// Name uniqueness: first claimant keeps it; later duplicates are cleared.
let name = t.name.and_then(|n| match seen_names.entry(n.clone()) {
Entry::Vacant(v) => {
v.insert(id);
Some(n)
}
Entry::Occupied(o) => {
load_errors.push(LogLine::error(format!(
"object name {n:?} already used by object {}; clearing name",
o.get()
)));
None
}
});
if t.solid {
solid_occupied[idx] = true;
}
objects.insert(
id,
ObjectDef {
id,
x,
y,
z,
glyph: t.glyph,
solid: t.solid,
opaque: t.opaque,
pushable: t.pushable,
script_name: t.script_name,
// Script-backed archetypes are expanded after the board is built
// (see `expand_builtin_archetypes` below), not via templates.
builtin_script: None,
tags: t.tags.into_iter().collect(),
name,
},
);
next_object_id += 1;
}
// Build the portal list, dropping duplicate names (first claimant wins).
let mut seen_portal_names: HashSet<String> = HashSet::new();
let mut portals: Vec<PortalDef> = Vec::new();
for (t, x, y, z) in portal_specs {
if !seen_portal_names.insert(t.name.clone()) {
load_errors.push(LogLine::error(format!(
"portal name {:?} already used by another portal; skipping portal",
t.name
)));
continue;
}
portals.push(PortalDef {
name: t.name,
x,
y,
z,
target_map: t.target_map,
target_entry: t.target_entry,
});
}
let mut board = Board {
name: mf.map.name,
width: w,
height: h,
layers,
player: Player {
x: px as i32,
y: py as i32,
},
objects,
next_object_id,
portals,
board_script_name: mf.map.board_script_name,
load_errors,
registry: HashMap::new(),
};
// Turn script-backed archetype cells (pushers/spinners) into their scripted
// objects. Runs after the cross-layer validation above, so board invariants
// hold; the same call also fixes editor-placed machines before a playtest.
board.expand_builtin_archetypes();
Ok(board)
}
}
/// Pool of palette characters for save: printable ASCII (plus a leading space for
/// the common transparent-empty cell), excluding `"` and `\` which would need
/// escaping inside a TOML string.
fn char_pool() -> Vec<char> {
let mut pool = vec![' '];
pool.extend(
(33u8..=126u8)
.filter(|&b| b != b'"' && b != b'\\')
.map(|b| b as char),
);
pool
}
/// Builds the [`PaletteEntry`] for a terrain/floor cell `(glyph, arch)`.
fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
if arch == Archetype::Empty {
if glyph.tile == 0 {
// Transparent: lower layers show through.
PaletteEntry {
kind: "empty".into(),
..Default::default()
}
} else {
// A fixed floor glyph (generators bake to literal glyphs on load).
PaletteEntry {
kind: "floor".into(),
tile: Some(TileIndex::Num(glyph.tile)),
fg: Some(color_to_hex(glyph.fg)),
bg: Some(color_to_hex(glyph.bg)),
..Default::default()
}
}
} else {
PaletteEntry {
kind: arch.name().into(),
tile: Some(TileIndex::Num(glyph.tile)),
fg: Some(color_to_hex(glyph.fg)),
bg: Some(color_to_hex(glyph.bg)),
..Default::default()
}
}
}
/// Serializes one layer `z` of `board` to a [`LayerData`], optionally writing the
/// player cell into this layer (done on the top layer only).
fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
let (w, h) = (board.width, board.height);
let mut pool = char_pool().into_iter();
let mut palette: HashMap<String, PaletteEntry> = HashMap::new();
let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new();
let mut grid: Vec<Vec<char>> = vec![vec![' '; w]; h];
// Terrain/floor cells: dedup each unique (glyph, archetype) to one palette char.
for (y, row) in grid.iter_mut().enumerate() {
for (x, slot) in row.iter_mut().enumerate() {
let (glyph, arch) = *board.get(z, x, y);
*slot = *cell_to_key.entry((glyph, arch)).or_insert_with(|| {
let ch = pool.next().expect("ran out of palette characters");
palette.insert(ch.to_string(), cell_entry(glyph, arch));
ch
});
}
}
// Objects on this layer overwrite their (transparent) cell with an object char.
for o in board.objects.values().filter(|o| o.z == z) {
// A built-in archetype object (e.g. a pusher) round-trips back to its
// archetype keyword: emit it as a terrain cell (deduped with real terrain),
// using the object's current glyph, rather than a `kind = "object"` entry.
if let Some(arch) = o.tags.iter().find_map(|t| archetype_from_builtin_tag(t)) {
let ch = *cell_to_key.entry((o.glyph, arch)).or_insert_with(|| {
let ch = pool.next().expect("ran out of palette characters");
palette.insert(ch.to_string(), cell_entry(o.glyph, arch));
ch
});
grid[o.y][o.x] = ch;
continue;
}
let ch = pool.next().expect("ran out of palette characters");
let mut tags: Vec<String> = o.tags.iter().cloned().collect();
tags.sort();
palette.insert(
ch.to_string(),
PaletteEntry {
kind: "object".into(),
tile: Some(TileIndex::Num(o.glyph.tile)),
fg: Some(color_to_hex(o.glyph.fg)),
bg: Some(color_to_hex(o.glyph.bg)),
solid: Some(o.solid),
opaque: Some(o.opaque),
pushable: Some(o.pushable),
script_name: o.script_name.clone(),
tags: (!tags.is_empty()).then_some(tags),
name: o.name.clone(),
..Default::default()
},
);
grid[o.y][o.x] = ch;
}
// Portals on this layer.
for p in board.portals.iter().filter(|p| p.z == z) {
let ch = pool.next().expect("ran out of palette characters");
palette.insert(
ch.to_string(),
PaletteEntry {
kind: "portal".into(),
name: Some(p.name.clone()),
target_map: Some(p.target_map.clone()),
target_entry: Some(p.target_entry.clone()),
..Default::default()
},
);
grid[p.y][p.x] = ch;
}
// The player is written onto the top layer.
if place_player {
let ch = pool.next().expect("ran out of palette characters");
palette.insert(
ch.to_string(),
PaletteEntry {
kind: "player".into(),
..Default::default()
},
);
grid[board.player.y as usize][board.player.x as usize] = ch;
}
let content = grid
.into_iter()
.map(|row| row.into_iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
+ "\n";
// Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences.
LayerData {
content: Some(content),
fill: None,
sparse: None,
palette,
}
}
/// Converts a runtime [`Board`] back into a serializable [`MapFile`].
///
/// Emits one `[[layers]]` entry per board layer; the player is written onto the
/// top layer. Procedural floors were baked to literal glyphs at load, so they are
/// saved as fixed `floor` glyphs (the generator declaration is not recovered).
impl From<&Board> for MapFile {
fn from(board: &Board) -> Self {
let top = board.layer_count() - 1;
let layers = (0..board.layer_count())
.map(|z| layer_to_data(board, z, z == top))
.collect();
MapFile {
map: MapHeader {
name: board.name.clone(),
width: board.width,
height: board.height,
board_script_name: board.board_script_name.clone(),
},
layers,
}
}
}
/// Loads a map file from disk and returns a ready-to-use [`Board`].
///
/// Reads the file at `path`, deserializes it as a single-board [`MapFile`], then
/// converts it via [`TryFrom`]. Production code uses [`crate::world::load`] for
/// multi-board world files instead.
pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let map_file: MapFile = toml::from_str(&content)?;
Board::try_from(map_file).map_err(|e| e.into())
}
/// Serializes a [`Board`] to a `.toml` map file at `path`.
pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let map_file = MapFile::from(board);
let toml_str = toml::to_string_pretty(&map_file)?;
std::fs::write(path, toml_str)?;
Ok(())
}