Different content grids
This commit is contained in:
+117
-20
@@ -34,16 +34,42 @@ pub(crate) struct Layer {
|
||||
pub(crate) cells: Vec<(Glyph, Archetype)>,
|
||||
}
|
||||
|
||||
/// Serde representation of one `[[layers]]` entry: a grid string and its palette.
|
||||
/// 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).
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(crate) struct LayerData {
|
||||
/// Multi-line grid string; one char per cell, looked up in `palette`.
|
||||
pub content: String,
|
||||
#[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>>,
|
||||
/// Char (as a one-character string key) → palette entry.
|
||||
#[serde(default)]
|
||||
pub palette: HashMap<String, PaletteEntry>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// One palette entry, discriminated by [`kind`](PaletteEntry::kind).
|
||||
///
|
||||
/// A single flat struct (rather than an enum) because `kind` is open-ended: it is
|
||||
@@ -215,6 +241,91 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a layer's grid to a `height × width` matrix of chars from whichever of
|
||||
/// `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)
|
||||
}
|
||||
|
||||
/// Builds one [`Layer`] from its [`LayerData`], plus the non-terrain placements it
|
||||
/// contains (with their `(x, y)`).
|
||||
///
|
||||
@@ -239,28 +350,14 @@ pub(crate) fn build_layer(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Validate dimensions before walking — the only fatal error.
|
||||
let rows: Vec<&str> = data.content.lines().collect();
|
||||
if rows.len() != height {
|
||||
return Err(format!(
|
||||
"layer grid has {} rows but the board is {height} tall",
|
||||
rows.len()
|
||||
));
|
||||
}
|
||||
for (i, line) in rows.iter().enumerate() {
|
||||
let cols = line.chars().count();
|
||||
if cols != width {
|
||||
return Err(format!(
|
||||
"layer grid row {i} has {cols} characters but the board is {width} wide"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Resolve the grid (content / fill / sparse) before walking it.
|
||||
let grid = grid_chars(data, width, height, errors)?;
|
||||
|
||||
// Walk the grid, filling cells and collecting placements.
|
||||
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height);
|
||||
let mut placements: Vec<Placement> = Vec::new();
|
||||
for (y, line) in rows.iter().enumerate() {
|
||||
for (x, ch) in line.chars().enumerate() {
|
||||
for (y, row) in grid.iter().enumerate() {
|
||||
for (x, &ch) in row.iter().enumerate() {
|
||||
match resolved.get(&ch) {
|
||||
Some(Resolved::Cell(g, a)) => cells.push((*g, *a)),
|
||||
Some(Resolved::FloorGen(g)) => cells.push((g.generate(rng), Archetype::Empty)),
|
||||
|
||||
@@ -407,7 +407,13 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
LayerData { content, palette }
|
||||
// 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`].
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use super::load_board;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::map_file::parse_color;
|
||||
|
||||
#[test]
|
||||
fn fill_builds_a_full_grid_of_one_char() {
|
||||
// Layer 0 is a solid fill of a fixed floor glyph; a later sparse layer places
|
||||
// the player. Every cell of layer 0 must be that floor glyph.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 3
|
||||
height = 2
|
||||
|
||||
[[layers]]
|
||||
fill = "f"
|
||||
palette = { "f" = { kind = "floor", tile = ".", fg = "#112233", bg = "#445566" } }
|
||||
|
||||
[[layers]]
|
||||
sparse = [ { x = 0, y = 0, ch = "@" } ]
|
||||
palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
|
||||
"##;
|
||||
let board = load_board(toml);
|
||||
assert!(board.is_valid());
|
||||
let floor = Glyph {
|
||||
tile: '.' as u32,
|
||||
fg: parse_color("#112233"),
|
||||
bg: parse_color("#445566"),
|
||||
};
|
||||
for y in 0..2 {
|
||||
for x in 0..3 {
|
||||
assert_eq!(*board.get(0, x, y), (floor, Archetype::Empty));
|
||||
}
|
||||
}
|
||||
assert_eq!((board.player.x, board.player.y), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_places_only_listed_cells() {
|
||||
// A grass floor underneath; a sparse layer holding just the player and one object.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 4
|
||||
height = 1
|
||||
|
||||
[[layers]]
|
||||
fill = "g"
|
||||
palette = { "g" = { kind = "floor", generator = "grass" } }
|
||||
|
||||
[[layers]]
|
||||
sparse = [ { x = 0, y = 0, ch = "@" }, { x = 2, y = 0, ch = "O" } ]
|
||||
palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } }
|
||||
"##;
|
||||
let board = load_board(toml);
|
||||
assert!(board.is_valid());
|
||||
assert_eq!((board.player.x, board.player.y), (0, 0));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (2, 0));
|
||||
// An unlisted sparse cell is a transparent empty.
|
||||
assert_eq!(board.get(1, 1, 0).1, Archetype::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_out_of_bounds_cell_is_nonfatal() {
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 2
|
||||
height = 1
|
||||
|
||||
[[layers]]
|
||||
sparse = [ { x = 5, y = 0, ch = "O" }, { x = 0, y = 0, ch = "@" } ]
|
||||
palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } }
|
||||
"##;
|
||||
let board = load_board(toml);
|
||||
assert!(
|
||||
board.objects.is_empty(),
|
||||
"out-of-bounds object cell is skipped"
|
||||
);
|
||||
assert!(
|
||||
!board.is_valid(),
|
||||
"out-of-bounds sparse cell is a nonfatal error"
|
||||
);
|
||||
assert_eq!((board.player.x, board.player.y), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_must_be_a_single_char() {
|
||||
// A multi-char fill falls back to a space (nonfatal); spaces resolve to empty.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 2
|
||||
height = 1
|
||||
|
||||
[[layers]]
|
||||
fill = "xy"
|
||||
palette = { " " = { kind = "empty" } }
|
||||
|
||||
[[layers]]
|
||||
sparse = [ { x = 0, y = 0, ch = "@" } ]
|
||||
palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
|
||||
"##;
|
||||
let board = load_board(toml);
|
||||
assert!(
|
||||
!board.is_valid(),
|
||||
"a non-single-char fill is a nonfatal error"
|
||||
);
|
||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod fill_sparse;
|
||||
mod grid_errors;
|
||||
mod object_placement;
|
||||
mod player_placement;
|
||||
|
||||
Reference in New Issue
Block a user