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
+330 -115
View File
@@ -16,30 +16,37 @@ 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::api::queue::ObjQueue;
use crate::archetype::Archetype;
use crate::board::Board;
use crate::board::{Board, Decoration};
use crate::builtin_scripts::archetype_from_builtin_tag;
use crate::floor::FLOOR_SEED;
use crate::floor::{Floor, FloorGenerator};
use crate::glyph::Glyph;
use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer};
use crate::layer::{GridData, PaletteEntry, Placement, build_grid};
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::utils::{ObjectId, PlayerPos, PortalDef};
/// The serde shell for one board in a `.toml` file: a header and its layers.
/// The serde shell for one board in a `.toml` file: a header, the single grid, and
/// the off-grid trigger/decoration lists.
///
/// 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.
/// The `[map]` header: name, dimensions, floor, optional board script.
pub map: MapHeader,
/// The ordered `[[layers]]` stack, bottom (index 0) to top.
/// The single `[grid]`: palette + char map for all solids and most non-solids.
#[serde(default)]
pub(crate) layers: Vec<LayerData>,
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>,
}
/// The `[map]` header section of a board.
@@ -47,15 +54,107 @@ pub struct MapFile {
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.
/// Width of the board in cells. Must match the grid row length.
pub width: usize,
/// Height of the board in cells. Must match every layer's row count.
/// Height of the board in cells. Must match the grid row count.
pub height: usize,
/// The board's optional cosmetic floor. Absent ⇒ blank; see [`FloorSpec`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor: Option<FloorSpec>,
/// 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>,
}
/// 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>,
}
/// 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
@@ -104,13 +203,16 @@ pub(crate) fn color_to_hex(color: Rgba8) -> String {
/// 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:
/// Builds the single grid (collecting object/portal/player placements), resolves
/// the floor, then runs the cross-cell 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);
/// - at most one solid may occupy a cell (a conflicting solid object 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.
/// 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.
impl TryFrom<MapFile> for Board {
type Error = String;
@@ -119,30 +221,28 @@ impl TryFrom<MapFile> for Board {
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();
// 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();
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)),
}
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)),
}
layers.push(layer);
}
if layers.is_empty() {
return Err("map has no [[layers]]".into());
}
// The player must be placed exactly once across all layers.
// 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.
let (px, py) = match player_positions.len() {
1 => player_positions[0],
0 => {
@@ -153,50 +253,34 @@ impl TryFrom<MapFile> for Board {
}
n => {
load_errors.push(LogLine::error(format!(
"player cell appears {n} times across layers; using the first"
"player cell appears {n} times; 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.
// Track which cells hold a solid (the grid's own solids seed the map).
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;
}
for (idx, cell) in grid.iter().enumerate() {
if cell.1.behavior().solid {
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);
}
}
// 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);
}
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.
// Spawn objects in 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 {
for (t, x, y) 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] {
@@ -210,19 +294,7 @@ impl TryFrom<MapFile> for Board {
}
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
}
});
let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors));
if t.solid {
solid_occupied[idx] = true;
}
@@ -232,7 +304,6 @@ impl TryFrom<MapFile> for Board {
id,
x,
y,
z,
glyph: t.glyph,
solid: t.solid,
opaque: t.opaque,
@@ -252,10 +323,44 @@ impl TryFrom<MapFile> for Board {
next_object_id += 1;
}
// 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,
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;
}
// 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 {
for (t, x, y) 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",
@@ -267,17 +372,27 @@ impl TryFrom<MapFile> for Board {
name: t.name,
x,
y,
z,
target_map: t.target_map,
target_entry: t.target_entry,
});
}
// 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)),
}
}
let mut board = Board {
name: mf.map.name,
width: w,
height: h,
layers,
grid,
floor,
decorations,
player: PlayerPos {
x: px as i64,
y: py as i64,
@@ -290,13 +405,64 @@ impl TryFrom<MapFile> for Board {
registry: HashMap::new(),
};
// Turn script-backed archetype cells (pushers/spinners) into their scripted
// objects. Runs after the cross-layer validation above, so board invariants
// objects. Runs after the cross-cell validation above, so board invariants
// hold; the same call also fixes editor-placed machines before a playtest.
board.expand_builtin_archetypes();
Ok(board)
}
}
/// 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,
})
}
/// 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.
@@ -310,24 +476,15 @@ fn char_pool() -> Vec<char> {
pool
}
/// Builds the [`PaletteEntry`] for a terrain/floor cell `(glyph, arch)`.
/// 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.
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()
}
PaletteEntry {
kind: "empty".into(),
..Default::default()
}
} else {
PaletteEntry {
@@ -340,19 +497,25 @@ fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
}
}
/// 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 {
/// 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 {
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.
// Terrain 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);
let (glyph, arch) = *board.get(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));
@@ -361,8 +524,9 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
}
}
// Objects on this layer overwrite their (transparent) cell with an object char.
for o in board.objects.values().filter(|o| o.z == z) {
// 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)) {
// 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.
@@ -397,8 +561,8 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
grid[o.y][o.x] = ch;
}
// Portals on this layer.
for p in board.portals.iter().filter(|p| p.z == z) {
// Portals.
for p in board.portals.iter() {
let ch = pool.next().expect("ran out of palette characters");
palette.insert(
ch.to_string(),
@@ -413,8 +577,8 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
grid[p.y][p.x] = ch;
}
// The player is written onto the top layer.
if place_player {
// The player.
{
let ch = pool.next().expect("ran out of palette characters");
palette.insert(
ch.to_string(),
@@ -433,7 +597,7 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
.join("\n")
+ "\n";
// Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences.
LayerData {
GridData {
content: Some(content),
fill: None,
sparse: None,
@@ -441,25 +605,76 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
}
}
/// 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,
}),
}
}
/// 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).
/// 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).
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))
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)),
})
.collect();
MapFile {
map: MapHeader {
name: board.name.clone(),
width: board.width,
height: board.height,
floor: floor_to_spec(&board.floor),
board_script_name: board.board_script_name.clone(),
},
layers,
grid,
triggers,
decorations,
}
}
}