2026-06-15 23:35:18 -05:00
|
|
|
|
//! 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`].
|
|
|
|
|
|
|
2026-05-30 20:20:09 -05:00
|
|
|
|
use color::Rgba8;
|
2026-05-30 18:48:41 -05:00
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-06-13 15:33:04 -05:00
|
|
|
|
use std::collections::hash_map::Entry;
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
2026-05-30 18:48:41 -05:00
|
|
|
|
use std::convert::TryFrom;
|
|
|
|
|
|
use std::path::Path;
|
2026-06-28 00:12:52 -05:00
|
|
|
|
use crate::api::queue::ObjQueue;
|
2026-06-07 00:19:53 -05:00
|
|
|
|
use crate::archetype::Archetype;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
use crate::board::{Board, Decoration};
|
2026-06-16 14:34:42 -05:00
|
|
|
|
use crate::builtin_scripts::archetype_from_builtin_tag;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
use crate::floor::{Floor, FloorGenerator};
|
2026-06-07 00:19:53 -05:00
|
|
|
|
use crate::glyph::Glyph;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
use crate::layer::{GridData, PaletteEntry, Placement, build_grid};
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use crate::log::LogLine;
|
2026-06-07 00:19:53 -05:00
|
|
|
|
use crate::object_def::ObjectDef;
|
2026-06-25 00:04:42 -05:00
|
|
|
|
use crate::utils::{ObjectId, PlayerPos, PortalDef};
|
2026-05-16 01:50:05 -05:00
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// The serde shell for one board in a `.toml` file: a header, the single grid, and
|
|
|
|
|
|
/// the off-grid trigger/decoration lists.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// 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.
|
2026-05-30 18:48:41 -05:00
|
|
|
|
#[derive(Deserialize, Serialize)]
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub struct MapFile {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// The `[map]` header: name, dimensions, floor, optional board script.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub map: MapHeader,
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// The single `[grid]`: palette + char map for all solids and most non-solids.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
#[serde(default)]
|
2026-07-10 23:16:28 -05:00
|
|
|
|
pub(crate) grid: GridData,
|
|
|
|
|
|
/// Invisible, non-solid, script-only objects (`[[triggers]]`).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub(crate) triggers: Vec<TriggerSpec>,
|
|
|
|
|
|
/// Non-solid `(glyph, archetype)` cells drawn only where the grid is empty
|
|
|
|
|
|
/// (`[[decorations]]`). Normally absent; used by save files.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub(crate) decorations: Vec<DecorationSpec>,
|
2026-05-16 01:50:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// The `[map]` header section of a board.
|
2026-05-30 18:48:41 -05:00
|
|
|
|
#[derive(Deserialize, Serialize)]
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub struct MapHeader {
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Human-readable name for this board, e.g. `"Opening Room"`.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub name: String,
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Width of the board in cells. Must match the grid row length.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub width: usize,
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Height of the board in cells. Must match the grid row count.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub height: usize,
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// The board's optional cosmetic floor. Absent ⇒ blank; see [`FloorSpec`].
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub floor: Option<FloorSpec>,
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// 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>,
|
2026-07-11 12:40:35 -05:00
|
|
|
|
/// When `true`, this is a "dark" board: front-ends reveal only cells within
|
|
|
|
|
|
/// the player's field of view. Absent ⇒ `false` (fully lit); omitted from
|
|
|
|
|
|
/// the saved TOML when `false`. See [`Board::dark`](crate::board::Board::dark).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
|
|
|
|
pub dark: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// serde `skip_serializing_if` predicate: omit a `bool` field when it is `false`
|
|
|
|
|
|
/// (so the common non-dark board doesn't emit a `dark = false` line).
|
|
|
|
|
|
fn is_false(b: &bool) -> bool {
|
|
|
|
|
|
!*b
|
2026-05-19 00:07:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Serde form of the board floor attribute (`floor = { … }` in `[map]`).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Resolves (in [`FloorSpec::resolve`]) to a [`Floor`]: a `generator` name gives a
|
|
|
|
|
|
/// biome, otherwise any of `tile`/`fg`/`bg` gives a single fixed glyph, and an
|
|
|
|
|
|
/// empty spec is blank.
|
|
|
|
|
|
#[derive(Deserialize, Serialize, Clone)]
|
|
|
|
|
|
pub struct FloorSpec {
|
|
|
|
|
|
/// Procedural biome name (`"grass"`/`"dirt"`/`"stone"`).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub generator: Option<String>,
|
|
|
|
|
|
/// Fixed-glyph tile index (int or single-char string).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub tile: Option<TileIndex>,
|
|
|
|
|
|
/// Fixed-glyph foreground `"#RRGGBB"`.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub fg: Option<String>,
|
|
|
|
|
|
/// Fixed-glyph background `"#RRGGBB"`.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub bg: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl FloorSpec {
|
|
|
|
|
|
/// Resolves this spec to a [`Floor`] for a `width × height` board, recording a
|
|
|
|
|
|
/// nonfatal error (and falling back to [`Floor::Blank`]) for an unknown generator.
|
|
|
|
|
|
fn resolve(&self, width: usize, height: usize, errors: &mut Vec<LogLine>) -> Floor {
|
|
|
|
|
|
if let Some(name) = &self.generator {
|
|
|
|
|
|
return match FloorGenerator::from_name(name) {
|
|
|
|
|
|
Some(g) => Floor::biome(g, width, height),
|
|
|
|
|
|
None => {
|
|
|
|
|
|
errors.push(LogLine::error(format!(
|
|
|
|
|
|
"floor names unknown generator '{name}'; using blank floor"
|
|
|
|
|
|
)));
|
|
|
|
|
|
Floor::Blank
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
// A fixed glyph if any visual field is given, else a blank floor.
|
|
|
|
|
|
if self.tile.is_some() || self.fg.is_some() || self.bg.is_some() {
|
|
|
|
|
|
Floor::Fixed(Glyph {
|
|
|
|
|
|
tile: self.tile.map(TileIndex::into_u32).unwrap_or(32),
|
|
|
|
|
|
fg: self.fg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }),
|
|
|
|
|
|
bg: self.bg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }),
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Floor::Blank
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Serde form of one `[[triggers]]` entry: an invisible, non-solid, script-only
|
|
|
|
|
|
/// object at `(x, y)`. Triggers are folded into [`Board::objects`] at load; they
|
|
|
|
|
|
/// are re-emitted here on save (recognised as scripted, non-solid, glyphless).
|
|
|
|
|
|
#[derive(Deserialize, Serialize, Clone)]
|
|
|
|
|
|
pub(crate) struct TriggerSpec {
|
|
|
|
|
|
/// Column (0-indexed).
|
|
|
|
|
|
pub x: usize,
|
|
|
|
|
|
/// Row (0-indexed).
|
|
|
|
|
|
pub y: usize,
|
|
|
|
|
|
/// Name of the Rhai script (in `[scripts]`) this trigger runs.
|
|
|
|
|
|
pub script_name: String,
|
|
|
|
|
|
/// Optional board-unique name.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub name: Option<String>,
|
|
|
|
|
|
/// Optional open-ended labels.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub tags: Option<Vec<String>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Serde form of one `[[decorations]]` entry: a non-solid `(glyph, archetype)` at
|
|
|
|
|
|
/// `(x, y)`, drawn only where the grid cell is empty. A solid archetype is rejected.
|
|
|
|
|
|
#[derive(Deserialize, Serialize, Clone)]
|
|
|
|
|
|
pub(crate) struct DecorationSpec {
|
|
|
|
|
|
/// Column (0-indexed).
|
|
|
|
|
|
pub x: usize,
|
|
|
|
|
|
/// Row (0-indexed).
|
|
|
|
|
|
pub y: usize,
|
|
|
|
|
|
/// Archetype name (or `"empty"`); must be non-solid.
|
|
|
|
|
|
pub kind: String,
|
|
|
|
|
|
/// Tile index (int or single-char string).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub tile: Option<TileIndex>,
|
|
|
|
|
|
/// Foreground `"#RRGGBB"`.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub fg: Option<String>,
|
|
|
|
|
|
/// Background `"#RRGGBB"`.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub bg: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// A tile index in a palette entry: either a plain integer or a character literal.
|
|
|
|
|
|
///
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Accepting both forms lets map files write `tile = 35` or `tile = "#"` (the char
|
|
|
|
|
|
/// is converted to its Unicode scalar) interchangeably.
|
2026-06-06 18:49:45 -05:00
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Copy)]
|
2026-05-19 00:07:04 -05:00
|
|
|
|
#[serde(untagged)]
|
2026-06-06 18:49:45 -05:00
|
|
|
|
pub enum TileIndex {
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// 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 {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Returns the tile index as a `u32`, converting a char to its scalar value.
|
2026-05-30 20:20:09 -05:00
|
|
|
|
pub(crate) fn into_u32(self) -> u32 {
|
2026-05-19 00:07:04 -05:00
|
|
|
|
match self {
|
|
|
|
|
|
TileIndex::Num(n) => n,
|
|
|
|
|
|
TileIndex::Chr(c) => c as u32,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 20:20:09 -05:00
|
|
|
|
/// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`].
|
|
|
|
|
|
/// Returns opaque black on any parse failure.
|
2026-06-06 18:49:45 -05:00
|
|
|
|
pub(crate) fn parse_color(hex: &str) -> Rgba8 {
|
2026-05-16 01:50:05 -05:00
|
|
|
|
let hex = hex.trim_start_matches('#');
|
|
|
|
|
|
if hex.len() != 6 {
|
2026-06-04 23:52:33 -05:00
|
|
|
|
return Rgba8 {
|
|
|
|
|
|
r: 0,
|
|
|
|
|
|
g: 0,
|
|
|
|
|
|
b: 0,
|
|
|
|
|
|
a: 255,
|
|
|
|
|
|
};
|
2026-05-16 01:50:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-05-30 20:20:09 -05:00
|
|
|
|
Rgba8 { r, g, b, a: 255 }
|
2026-05-16 01:50:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 20:20:09 -05:00
|
|
|
|
/// Converts an [`Rgba8`] to an `"#RRGGBB"` hex string (alpha is ignored).
|
2026-06-06 18:49:45 -05:00
|
|
|
|
pub(crate) fn color_to_hex(color: Rgba8) -> String {
|
2026-05-30 20:20:09 -05:00
|
|
|
|
format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b)
|
2026-05-30 18:48:41 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Converts a parsed map file into a runtime [`Board`].
|
|
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Builds the single grid (collecting object/portal/player placements), resolves
|
|
|
|
|
|
/// the floor, then runs the cross-cell validations that span the whole board:
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// - the player must appear exactly once (missing → `(0, 0)`; multiple → the first), and wins its cell;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// - at most one solid may occupy a cell (a conflicting solid object is dropped);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// - object names must be board-unique (a duplicate is cleared) and portal names unique (a duplicate is dropped).
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Finally the `[[triggers]]` load as non-solid glyphless objects and the
|
|
|
|
|
|
/// `[[decorations]]` as off-grid non-solid cells.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns `Err` only when the grid dimensions disagree with the header.
|
2026-05-30 18:48:41 -05:00
|
|
|
|
impl TryFrom<MapFile> for Board {
|
|
|
|
|
|
type Error = String;
|
|
|
|
|
|
|
|
|
|
|
|
fn try_from(mf: MapFile) -> Result<Self, Self::Error> {
|
2026-05-16 01:50:05 -05:00
|
|
|
|
let w = mf.map.width;
|
|
|
|
|
|
let h = mf.map.height;
|
2026-06-06 14:11:20 -05:00
|
|
|
|
let mut load_errors: Vec<LogLine> = Vec::new();
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Build the single grid, collecting non-terrain placements.
|
|
|
|
|
|
let (mut grid, placements) = build_grid(&mf.grid, w, h, &mut load_errors)?;
|
|
|
|
|
|
let mut object_specs: Vec<(crate::layer::ObjectTemplate, usize, usize)> = Vec::new();
|
|
|
|
|
|
let mut portal_specs: Vec<(crate::layer::PortalTemplate, usize, usize)> = Vec::new();
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let mut player_positions: Vec<(usize, usize)> = Vec::new();
|
2026-07-10 23:16:28 -05:00
|
|
|
|
for p in placements {
|
|
|
|
|
|
match p {
|
|
|
|
|
|
Placement::Object(t, x, y) => object_specs.push((t, x, y)),
|
|
|
|
|
|
Placement::Portal(t, x, y) => portal_specs.push((t, x, y)),
|
|
|
|
|
|
Placement::Player(x, y) => player_positions.push((x, y)),
|
2026-05-17 15:20:46 -05:00
|
|
|
|
}
|
2026-05-30 18:48:41 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Resolve the cosmetic floor attribute (blank / fixed glyph / biome).
|
|
|
|
|
|
let floor = mf
|
|
|
|
|
|
.map
|
|
|
|
|
|
.floor
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.map(|f| f.resolve(w, h, &mut load_errors))
|
|
|
|
|
|
.unwrap_or(Floor::Blank);
|
|
|
|
|
|
|
|
|
|
|
|
// The player must be placed exactly once.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
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)
|
2026-06-06 14:11:20 -05:00
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
n => {
|
2026-06-06 14:11:20 -05:00
|
|
|
|
load_errors.push(LogLine::error(format!(
|
2026-07-10 23:16:28 -05:00
|
|
|
|
"player cell appears {n} times; using the first"
|
2026-06-06 14:11:20 -05:00
|
|
|
|
)));
|
2026-06-15 23:35:18 -05:00
|
|
|
|
player_positions[0]
|
2026-06-06 01:37:19 -05:00
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
};
|
|
|
|
|
|
let pidx = py * w + px;
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Track which cells hold a solid (the grid's own solids seed the map).
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let mut solid_occupied = vec![false; w * h];
|
2026-07-10 23:16:28 -05:00
|
|
|
|
for (idx, cell) in grid.iter().enumerate() {
|
|
|
|
|
|
if cell.1.behavior().solid {
|
|
|
|
|
|
solid_occupied[idx] = true;
|
2026-06-06 01:37:19 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// The player wins its cell: clear any solid terrain under it and claim the
|
|
|
|
|
|
// cell so a solid object placed here is dropped below.
|
|
|
|
|
|
if grid[pidx].1.behavior().solid {
|
|
|
|
|
|
grid[pidx] = (Glyph::transparent(), Archetype::Empty);
|
2026-06-13 16:24:29 -05:00
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
solid_occupied[pidx] = true;
|
2026-06-13 16:24:29 -05:00
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Spawn objects in reading order, so ids are deterministic and "lowest id
|
|
|
|
|
|
// wins a collision" / "first claimant keeps the name" hold.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let mut objects: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
|
|
|
|
|
|
let mut next_object_id: ObjectId = 1;
|
|
|
|
|
|
let mut seen_names: HashMap<String, ObjectId> = HashMap::new();
|
2026-07-10 23:16:28 -05:00
|
|
|
|
for (t, x, y) in object_specs {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
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 {
|
2026-06-06 14:11:20 -05:00
|
|
|
|
load_errors.push(LogLine::error(format!(
|
2026-06-15 23:35:18 -05:00
|
|
|
|
"solid object at ({x}, {y}) conflicts with an existing solid; skipping object"
|
2026-06-06 14:11:20 -05:00
|
|
|
|
)));
|
2026-05-16 01:50:05 -05:00
|
|
|
|
}
|
2026-06-06 01:37:19 -05:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let id = next_object_id;
|
|
|
|
|
|
// Name uniqueness: first claimant keeps it; later duplicates are cleared.
|
2026-07-10 23:16:28 -05:00
|
|
|
|
let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors));
|
2026-06-15 23:35:18 -05:00
|
|
|
|
if t.solid {
|
|
|
|
|
|
solid_occupied[idx] = true;
|
2026-06-13 16:24:29 -05:00
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
objects.insert(
|
|
|
|
|
|
id,
|
|
|
|
|
|
ObjectDef {
|
|
|
|
|
|
id,
|
2026-06-13 13:59:13 -05:00
|
|
|
|
x,
|
|
|
|
|
|
y,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
glyph: t.glyph,
|
|
|
|
|
|
solid: t.solid,
|
|
|
|
|
|
opaque: t.opaque,
|
|
|
|
|
|
pushable: t.pushable,
|
2026-07-11 14:47:08 -05:00
|
|
|
|
light: t.light,
|
2026-06-21 18:27:45 -05:00
|
|
|
|
// Hand-placed objects are never grab targets; only expanded
|
|
|
|
|
|
// grab archetypes (e.g. gems) set this (see expand_builtin_archetypes).
|
|
|
|
|
|
grab: false,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
script_name: t.script_name,
|
2026-06-21 01:32:47 -05:00
|
|
|
|
// Script-backed archetypes are expanded after the board is built
|
|
|
|
|
|
// (see `expand_builtin_archetypes` below), not via templates.
|
|
|
|
|
|
builtin_script: None,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
tags: t.tags.into_iter().collect(),
|
2026-06-28 00:12:52 -05:00
|
|
|
|
queue: ObjQueue::new(),
|
2026-06-13 13:59:13 -05:00
|
|
|
|
name,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
next_object_id += 1;
|
2026-06-06 01:37:19 -05:00
|
|
|
|
}
|
2026-05-30 19:25:10 -05:00
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Triggers: invisible, non-solid, script-only objects. They join the same
|
|
|
|
|
|
// `objects` map (so ids/name-uniqueness/script dispatch all apply), after the
|
|
|
|
|
|
// hand-placed grid objects.
|
|
|
|
|
|
for t in mf.triggers {
|
|
|
|
|
|
if !t.x.lt(&w) || !t.y.lt(&h) {
|
|
|
|
|
|
load_errors.push(LogLine::error(format!(
|
|
|
|
|
|
"trigger at ({}, {}) is out of bounds; skipping",
|
|
|
|
|
|
t.x, t.y
|
|
|
|
|
|
)));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let id = next_object_id;
|
|
|
|
|
|
let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors));
|
|
|
|
|
|
objects.insert(
|
|
|
|
|
|
id,
|
|
|
|
|
|
ObjectDef {
|
|
|
|
|
|
id,
|
|
|
|
|
|
x: t.x,
|
|
|
|
|
|
y: t.y,
|
|
|
|
|
|
glyph: Glyph::transparent(),
|
|
|
|
|
|
solid: false,
|
|
|
|
|
|
opaque: false,
|
|
|
|
|
|
pushable: false,
|
2026-07-11 14:47:08 -05:00
|
|
|
|
light: 0,
|
2026-07-10 23:16:28 -05:00
|
|
|
|
grab: false,
|
|
|
|
|
|
script_name: Some(t.script_name),
|
|
|
|
|
|
builtin_script: None,
|
|
|
|
|
|
tags: t.tags.unwrap_or_default().into_iter().collect(),
|
|
|
|
|
|
queue: ObjQueue::new(),
|
|
|
|
|
|
name,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
next_object_id += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// Build the portal list, dropping duplicate names (first claimant wins).
|
|
|
|
|
|
let mut seen_portal_names: HashSet<String> = HashSet::new();
|
2026-06-13 16:24:29 -05:00
|
|
|
|
let mut portals: Vec<PortalDef> = Vec::new();
|
2026-07-10 23:16:28 -05:00
|
|
|
|
for (t, x, y) in portal_specs {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
if !seen_portal_names.insert(t.name.clone()) {
|
2026-06-13 16:24:29 -05:00
|
|
|
|
load_errors.push(LogLine::error(format!(
|
2026-06-15 23:35:18 -05:00
|
|
|
|
"portal name {:?} already used by another portal; skipping portal",
|
|
|
|
|
|
t.name
|
2026-06-13 16:24:29 -05:00
|
|
|
|
)));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
portals.push(PortalDef {
|
|
|
|
|
|
name: t.name,
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
target_map: t.target_map,
|
|
|
|
|
|
target_entry: t.target_entry,
|
|
|
|
|
|
});
|
2026-06-13 16:24:29 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Decorations: non-solid off-grid cells (a solid archetype is rejected).
|
|
|
|
|
|
let mut decorations: Vec<Decoration> = Vec::new();
|
|
|
|
|
|
for d in mf.decorations {
|
|
|
|
|
|
match resolve_decoration(&d) {
|
|
|
|
|
|
Ok(dec) => decorations.push(dec),
|
|
|
|
|
|
Err(msg) => load_errors.push(LogLine::error(msg)),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 01:32:47 -05:00
|
|
|
|
let mut board = Board {
|
2026-05-30 18:48:41 -05:00
|
|
|
|
name: mf.map.name,
|
2026-05-16 01:50:05 -05:00
|
|
|
|
width: w,
|
|
|
|
|
|
height: h,
|
2026-07-10 23:16:28 -05:00
|
|
|
|
grid,
|
|
|
|
|
|
floor,
|
|
|
|
|
|
decorations,
|
2026-06-25 00:04:42 -05:00
|
|
|
|
player: PlayerPos {
|
2026-06-28 00:12:52 -05:00
|
|
|
|
x: px as i64,
|
|
|
|
|
|
y: py as i64,
|
2026-05-16 01:50:05 -05:00
|
|
|
|
},
|
2026-05-30 19:25:10 -05:00
|
|
|
|
objects,
|
2026-06-06 20:01:07 -05:00
|
|
|
|
next_object_id,
|
2026-06-13 16:24:29 -05:00
|
|
|
|
portals,
|
2026-05-30 18:48:41 -05:00
|
|
|
|
board_script_name: mf.map.board_script_name,
|
2026-07-11 12:40:35 -05:00
|
|
|
|
dark: mf.map.dark,
|
2026-06-06 14:11:20 -05:00
|
|
|
|
load_errors,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
registry: HashMap::new(),
|
2026-06-21 01:32:47 -05:00
|
|
|
|
};
|
|
|
|
|
|
// Turn script-backed archetype cells (pushers/spinners) into their scripted
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// objects. Runs after the cross-cell validation above, so board invariants
|
2026-06-21 01:32:47 -05:00
|
|
|
|
// hold; the same call also fixes editor-placed machines before a playtest.
|
|
|
|
|
|
board.expand_builtin_archetypes();
|
|
|
|
|
|
Ok(board)
|
2026-05-30 18:48:41 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Claims `name` for object `id` in `seen_names`, returning `Some(name)` for the
|
|
|
|
|
|
/// first claimant and `None` (with a logged error) for any later duplicate.
|
|
|
|
|
|
fn claim_name(
|
|
|
|
|
|
n: String,
|
|
|
|
|
|
id: ObjectId,
|
|
|
|
|
|
seen_names: &mut HashMap<String, ObjectId>,
|
|
|
|
|
|
errors: &mut Vec<LogLine>,
|
|
|
|
|
|
) -> Option<String> {
|
|
|
|
|
|
match seen_names.entry(n.clone()) {
|
|
|
|
|
|
Entry::Vacant(v) => {
|
|
|
|
|
|
v.insert(id);
|
|
|
|
|
|
Some(n)
|
|
|
|
|
|
}
|
|
|
|
|
|
Entry::Occupied(o) => {
|
|
|
|
|
|
errors.push(LogLine::error(format!(
|
|
|
|
|
|
"object name {n:?} already used by object {}; clearing name",
|
|
|
|
|
|
o.get()
|
|
|
|
|
|
)));
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Resolves a [`DecorationSpec`] into a [`Decoration`], erroring if its archetype is
|
|
|
|
|
|
/// unknown or solid (decorations must be non-solid).
|
|
|
|
|
|
fn resolve_decoration(d: &DecorationSpec) -> Result<Decoration, String> {
|
|
|
|
|
|
let arch = if d.kind == "empty" {
|
|
|
|
|
|
Archetype::Empty
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Archetype::try_from(d.kind.as_str())
|
|
|
|
|
|
.map_err(|msg| format!("decoration at ({}, {}): {msg}; skipping", d.x, d.y))?
|
|
|
|
|
|
};
|
|
|
|
|
|
if arch.behavior().solid {
|
|
|
|
|
|
return Err(format!(
|
|
|
|
|
|
"decoration at ({}, {}) has solid archetype {:?}; skipping",
|
|
|
|
|
|
d.x, d.y, d.kind
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
let default = arch.default_glyph();
|
|
|
|
|
|
Ok(Decoration {
|
|
|
|
|
|
x: d.x,
|
|
|
|
|
|
y: d.y,
|
|
|
|
|
|
glyph: Glyph {
|
|
|
|
|
|
tile: d.tile.map(TileIndex::into_u32).unwrap_or(default.tile),
|
|
|
|
|
|
fg: d.fg.as_deref().map(parse_color).unwrap_or(default.fg),
|
|
|
|
|
|
bg: d.bg.as_deref().map(parse_color).unwrap_or(default.bg),
|
|
|
|
|
|
},
|
|
|
|
|
|
archetype: arch,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// 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)
|
2026-05-30 18:48:41 -05:00
|
|
|
|
.filter(|&b| b != b'"' && b != b'\\')
|
2026-06-15 23:35:18 -05:00
|
|
|
|
.map(|b| b as char),
|
|
|
|
|
|
);
|
|
|
|
|
|
pool
|
|
|
|
|
|
}
|
2026-05-30 18:48:41 -05:00
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Builds the [`PaletteEntry`] for a grid terrain cell `(glyph, arch)`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// A grid `Empty` cell is always transparent now (floors are a board attribute),
|
|
|
|
|
|
/// so it maps to `kind = "empty"`; anything else is its archetype keyword.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
|
|
|
|
|
|
if arch == Archetype::Empty {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
PaletteEntry {
|
|
|
|
|
|
kind: "empty".into(),
|
|
|
|
|
|
..Default::default()
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
} 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()
|
2026-05-30 18:48:41 -05:00
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-30 18:48:41 -05:00
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Whether an object is a **trigger** — an invisible, non-solid, script-only object
|
|
|
|
|
|
/// authored/serialized in `[[triggers]]` rather than the grid palette.
|
|
|
|
|
|
fn is_trigger(o: &ObjectDef) -> bool {
|
|
|
|
|
|
!o.solid && o.glyph.tile == 0 && o.script_name.is_some() && o.builtin_script.is_none()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Serializes `board`'s single grid (terrain + non-trigger objects + portals +
|
|
|
|
|
|
/// player) into a [`GridData`].
|
|
|
|
|
|
fn grid_to_data(board: &Board) -> GridData {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
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];
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Terrain cells: dedup each unique (glyph, archetype) to one palette char.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
for (y, row) in grid.iter_mut().enumerate() {
|
|
|
|
|
|
for (x, slot) in row.iter_mut().enumerate() {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
let (glyph, arch) = *board.get(x, y);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
*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
|
|
|
|
|
|
});
|
2026-05-30 18:48:41 -05:00
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|
2026-05-30 18:48:41 -05:00
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Objects overwrite their (transparent) grid cell with an object char. Triggers
|
|
|
|
|
|
// are written to `[[triggers]]` instead (see `From<&Board>`), so skip them here.
|
|
|
|
|
|
for o in board.objects.values().filter(|o| !is_trigger(o)) {
|
2026-06-16 14:34:42 -05:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
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),
|
2026-07-11 14:47:08 -05:00
|
|
|
|
light: (o.light > 0).then_some(o.light),
|
2026-05-30 18:48:41 -05:00
|
|
|
|
script_name: o.script_name.clone(),
|
2026-06-15 23:35:18 -05:00
|
|
|
|
tags: (!tags.is_empty()).then_some(tags),
|
2026-06-08 19:50:43 -05:00
|
|
|
|
name: o.name.clone(),
|
2026-06-15 23:35:18 -05:00
|
|
|
|
..Default::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
grid[o.y][o.x] = ch;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Portals.
|
|
|
|
|
|
for p in board.portals.iter() {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// The player.
|
|
|
|
|
|
{
|
2026-06-15 23:35:18 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-30 20:20:09 -05:00
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let content = grid
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|row| row.into_iter().collect::<String>())
|
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
|
.join("\n")
|
|
|
|
|
|
+ "\n";
|
2026-06-16 00:01:02 -05:00
|
|
|
|
// Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences.
|
2026-07-10 23:16:28 -05:00
|
|
|
|
GridData {
|
2026-06-16 00:01:02 -05:00
|
|
|
|
content: Some(content),
|
|
|
|
|
|
fill: None,
|
|
|
|
|
|
sparse: None,
|
|
|
|
|
|
palette,
|
|
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|
2026-05-30 18:48:41 -05:00
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Builds the [`FloorSpec`] for `board`'s floor, or `None` for a blank floor. A
|
|
|
|
|
|
/// biome re-emits its generator name (so the procedural floor round-trips); a fixed
|
|
|
|
|
|
/// glyph re-emits its tile/fg/bg.
|
|
|
|
|
|
fn floor_to_spec(floor: &Floor) -> Option<FloorSpec> {
|
|
|
|
|
|
match floor {
|
|
|
|
|
|
Floor::Blank => None,
|
|
|
|
|
|
Floor::Fixed(g) => Some(FloorSpec {
|
|
|
|
|
|
generator: None,
|
|
|
|
|
|
tile: Some(TileIndex::Num(g.tile)),
|
|
|
|
|
|
fg: Some(color_to_hex(g.fg)),
|
|
|
|
|
|
bg: Some(color_to_hex(g.bg)),
|
|
|
|
|
|
}),
|
|
|
|
|
|
Floor::Biome { generator, .. } => Some(FloorSpec {
|
|
|
|
|
|
generator: Some(generator.name().into()),
|
|
|
|
|
|
tile: None,
|
|
|
|
|
|
fg: None,
|
|
|
|
|
|
bg: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Converts a runtime [`Board`] back into a serializable [`MapFile`].
|
|
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Emits the single `[grid]`, the `[[triggers]]` (invisible script objects) and
|
|
|
|
|
|
/// `[[decorations]]` lists, and the `floor` attribute (a biome re-emits its
|
|
|
|
|
|
/// generator name, so procedural floors round-trip).
|
2026-06-15 23:35:18 -05:00
|
|
|
|
impl From<&Board> for MapFile {
|
|
|
|
|
|
fn from(board: &Board) -> Self {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
let grid = grid_to_data(board);
|
|
|
|
|
|
// Trigger objects → `[[triggers]]`.
|
|
|
|
|
|
let triggers = board
|
|
|
|
|
|
.objects
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.filter(|o| is_trigger(o))
|
|
|
|
|
|
.map(|o| {
|
|
|
|
|
|
let mut tags: Vec<String> = o.tags.iter().cloned().collect();
|
|
|
|
|
|
tags.sort();
|
|
|
|
|
|
TriggerSpec {
|
|
|
|
|
|
x: o.x,
|
|
|
|
|
|
y: o.y,
|
|
|
|
|
|
script_name: o.script_name.clone().unwrap_or_default(),
|
|
|
|
|
|
name: o.name.clone(),
|
|
|
|
|
|
tags: (!tags.is_empty()).then_some(tags),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
// Decorations → `[[decorations]]`.
|
|
|
|
|
|
let decorations = board
|
|
|
|
|
|
.decorations
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|d| DecorationSpec {
|
|
|
|
|
|
x: d.x,
|
|
|
|
|
|
y: d.y,
|
|
|
|
|
|
kind: d.archetype.name().into(),
|
|
|
|
|
|
tile: Some(TileIndex::Num(d.glyph.tile)),
|
|
|
|
|
|
fg: Some(color_to_hex(d.glyph.fg)),
|
|
|
|
|
|
bg: Some(color_to_hex(d.glyph.bg)),
|
|
|
|
|
|
})
|
2026-06-15 23:35:18 -05:00
|
|
|
|
.collect();
|
2026-05-30 18:48:41 -05:00
|
|
|
|
MapFile {
|
|
|
|
|
|
map: MapHeader {
|
|
|
|
|
|
name: board.name.clone(),
|
|
|
|
|
|
width: board.width,
|
|
|
|
|
|
height: board.height,
|
2026-07-10 23:16:28 -05:00
|
|
|
|
floor: floor_to_spec(&board.floor),
|
2026-05-30 18:48:41 -05:00
|
|
|
|
board_script_name: board.board_script_name.clone(),
|
2026-07-11 12:40:35 -05:00
|
|
|
|
dark: board.dark,
|
2026-05-30 18:48:41 -05:00
|
|
|
|
},
|
2026-07-10 23:16:28 -05:00
|
|
|
|
grid,
|
|
|
|
|
|
triggers,
|
|
|
|
|
|
decorations,
|
2026-05-16 01:50:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Loads a map file from disk and returns a ready-to-use [`Board`].
|
|
|
|
|
|
///
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// 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.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
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)?;
|
2026-05-30 18:48:41 -05:00
|
|
|
|
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(())
|
|
|
|
|
|
}
|