This commit is contained in:
2026-06-06 01:37:19 -05:00
parent 8f2cca2907
commit 05c9fdbde9
4 changed files with 368 additions and 58 deletions
+249 -23
View File
@@ -131,22 +131,35 @@ pub struct GridData {
/// [`From`] impls below.
#[derive(Deserialize, Serialize)]
pub(crate) struct MapFileObjectEntry {
/// Column of this object on the board (0-indexed).
x: usize,
/// Row of this object on the board (0-indexed).
y: usize,
/// Column of this object on the board (0-indexed). Mutually exclusive with
/// `palette`: provide either `x`/`y` or `palette`, not both.
#[serde(default, skip_serializing_if = "Option::is_none")]
x: Option<usize>,
/// Row of this object on the board (0-indexed). See `x`.
#[serde(default, skip_serializing_if = "Option::is_none")]
y: Option<usize>,
/// Single grid character marking where this object sits, as an alternative to
/// `x`/`y`. By convention an uppercase letter. The char must appear exactly
/// once in the grid, be unique across objects, and not collide with a
/// `[palette]` key; the cell under it is loaded as `Empty`.
#[serde(default, skip_serializing_if = "Option::is_none")]
palette: Option<String>,
/// Tile index into the board's bitmap font. Accepts integer or single-char string.
tile: TileIndex,
/// Foreground color as an `"#RRGGBB"` hex string.
fg: String,
/// Background color as an `"#RRGGBB"` hex string.
bg: String,
/// Whether the object blocks player / object movement
#[serde(default)]
passable: bool,
/// Whether the object blocks / participates in movement. Defaults to `true`
/// (blocking). Inverse of the old `passable` field.
#[serde(default = "default_true")]
solid: bool,
/// Whether the object blocks player line of sight / FOV
#[serde(default = "default_true")]
opaque: bool,
/// Whether the object can be shoved by a mover. Unused for now; defaults `false`.
#[serde(default)]
pushable: bool,
/// Name of the Rhai script in the `[scripts]` table, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
script_name: Option<String>,
@@ -252,39 +265,152 @@ impl TryFrom<MapFile> for Board {
}
}
// Pass 2: walk the validated grid and build cells.
// Validate object `palette` references before the grid pass, since the
// grid pass needs to know which chars are object placeholders.
// `skip[i]` drops object `i`; `palette_char_owner` maps a valid object
// palette char to the single object that owns it.
let mut skip = vec![false; mf.objects.len()];
let mut palette_char_owner: HashMap<char, usize> = HashMap::new();
for (i, e) in mf.objects.iter().enumerate() {
let Some(p) = e.palette.as_deref() else {
continue;
};
// `palette` is mutually exclusive with explicit `x`/`y`.
if e.x.is_some() || e.y.is_some() {
eprintln!("object {i}: specify either x/y or palette, not both; skipping object");
skip[i] = true;
continue;
}
// It must be exactly one character.
let mut chs = p.chars();
let (Some(ch), None) = (chs.next(), chs.next()) else {
eprintln!(
"object {i}: palette must be a single character (got {p:?}); skipping object"
);
skip[i] = true;
continue;
};
// It can't reuse a terrain palette key.
if palette.contains_key(&ch) {
eprintln!(
"object {i}: palette char '{ch}' is already a [palette] key; skipping object"
);
skip[i] = true;
continue;
}
// It must be unique across objects — a clash drops both owners.
use std::collections::hash_map::Entry;
match palette_char_owner.entry(ch) {
Entry::Occupied(o) => {
eprintln!(
"object palette char '{ch}' is used by more than one object; skipping both"
);
skip[i] = true;
skip[*o.get()] = true;
}
Entry::Vacant(v) => {
v.insert(i);
}
}
}
// Pass 2: walk the validated grid and build cells. Object palette chars
// become `Empty` floor (with their position remembered); truly unknown
// chars become a visible `ErrorBlock` so the cell count stays correct.
let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty);
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h);
for line in &rows {
for ch in line.chars() {
let mut obj_palette_pos: HashMap<char, (usize, usize)> = HashMap::new();
let mut obj_palette_count: HashMap<char, usize> = HashMap::new();
for (y, line) in rows.iter().enumerate() {
for (x, ch) in line.chars().enumerate() {
if let Some(&cell) = palette.get(&ch) {
cells.push(cell);
} else if palette_char_owner.contains_key(&ch) {
cells.push(canonical_empty);
obj_palette_pos.insert(ch, (x, y));
*obj_palette_count.entry(ch).or_insert(0) += 1;
} else {
eprintln!("unknown grid character '{ch}' at ({x}, {y}); using error block");
cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock));
}
}
}
// Each surviving object palette char must appear in the grid exactly once.
for (&ch, &owner) in &palette_char_owner {
if skip[owner] {
continue;
}
let n = obj_palette_count.get(&ch).copied().unwrap_or(0);
if n != 1 {
eprintln!(
"object palette char '{ch}' appears {n} times in the grid \
(must be exactly once); skipping object"
);
skip[owner] = true;
}
}
let font = mf.font.map(|f| FontSpec {
path: f.path,
tile_w: f.tile_w,
tile_h: f.tile_h,
});
// Convert MapFileObjectEntry → ObjectDef, resolving tile index and color strings.
let objects = mf
.objects
.into_iter()
.map(|e| ObjectDef {
x: e.x,
y: e.y,
// Convert MapFileObjectEntry → ObjectDef, enforcing one solid per cell.
// `solid_occupied` is seeded from the grid (solid archetypes); each solid
// object then claims its cell, and a second solid on a cell is dropped.
let mut solid_occupied: Vec<bool> = cells.iter().map(|(_, a)| a.behavior().solid).collect();
let mut objects: Vec<ObjectDef> = Vec::new();
for (i, e) in mf.objects.into_iter().enumerate() {
if skip[i] {
continue;
}
// Resolve the object's cell from either its palette char or x/y.
let (x, y) = if let Some(p) = e.palette.as_deref() {
// Single-char and uniqueness already validated; position recorded.
obj_palette_pos[&p.chars().next().unwrap()]
} else {
match (e.x, e.y) {
(Some(x), Some(y)) if x < w && y < h => (x, y),
(Some(x), Some(y)) => {
eprintln!("object {i}: x/y ({x}, {y}) out of bounds; skipping object");
continue;
}
_ => {
eprintln!(
"object {i}: must specify both x and y (or a palette char); skipping object"
);
continue;
}
}
};
let obj = ObjectDef {
x,
y,
glyph: Glyph {
tile: e.tile.into_u32(),
fg: parse_color(&e.fg),
bg: parse_color(&e.bg),
},
passable: e.passable,
solid: e.solid,
opaque: e.opaque,
pushable: e.pushable,
script_name: e.script_name,
})
.collect();
};
// A solid object may not share a cell with another solid.
if obj.solid {
let idx = y * w + x;
if solid_occupied[idx] {
eprintln!(
"object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object"
);
continue;
}
solid_occupied[idx] = true;
}
objects.push(obj);
}
Ok(Board {
name: mf.map.name,
@@ -387,13 +513,17 @@ impl From<&Board> for MapFile {
.objects
.iter()
.map(|o| MapFileObjectEntry {
x: o.x,
y: o.y,
// Saving always emits concrete coordinates; the palette form is a
// load-time convenience only.
x: Some(o.x),
y: Some(o.y),
palette: None,
tile: TileIndex::Num(o.glyph.tile),
fg: color_to_hex(o.glyph.fg),
bg: color_to_hex(o.glyph.bg),
passable: o.passable,
solid: o.solid,
opaque: o.opaque,
pushable: o.pushable,
script_name: o.script_name.clone(),
})
.collect();
@@ -453,6 +583,102 @@ pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>
#[cfg(test)]
mod tests {
use super::*;
use crate::game::Archetype;
/// Parses `toml` and converts it to a [`Board`], panicking on any hard error.
/// (Object/placement validation failures are non-fatal `eprintln`s, so the
/// board still loads — these tests assert on the resulting objects/cells.)
fn load_board(toml: &str) -> Board {
let mf: MapFile = toml::from_str(toml).expect("parse toml");
Board::try_from(mf).expect("convert to board")
}
/// A 3×1 map (`empty` palette `.`) whose grid is `grid`, plus the given
/// `[[objects]]` TOML appended. Keeps placement tests compact.
fn map_3x1(grid: &str, objects: &str) -> String {
format!(
r##"
[map]
name = "Test"
width = 3
height = 1
player_start = [0, 0]
[palette]
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
[grid]
content = """
{grid}
"""
{objects}
"##
)
}
/// A standard `[[objects]]` block placed by palette char `ch`.
fn obj_palette(ch: &str, extra: &str) -> String {
format!(
"[[objects]]\npalette = \"{ch}\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n{extra}"
)
}
#[test]
fn palette_placement_puts_object_on_empty_floor() {
// `G` appears once, isn't a palette key → object lands there, cell is Empty.
let board = load_board(&map_3x1("G..", &obj_palette("G", "")));
assert_eq!(board.objects.len(), 1);
assert_eq!((board.objects[0].x, board.objects[0].y), (0, 0));
// The cell under the object is canonical Empty floor.
assert_eq!(board.get(0, 0).1, Archetype::Empty);
}
#[test]
fn palette_char_absent_from_grid_drops_object() {
let board = load_board(&map_3x1("...", &obj_palette("G", "")));
assert!(board.objects.is_empty());
}
#[test]
fn palette_char_appearing_twice_drops_object() {
let board = load_board(&map_3x1("G.G", &obj_palette("G", "")));
assert!(board.objects.is_empty());
}
#[test]
fn palette_char_reused_by_two_objects_drops_both() {
let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", ""));
let board = load_board(&map_3x1("G..", &two));
assert!(board.objects.is_empty());
}
#[test]
fn palette_char_colliding_with_palette_key_drops_object() {
// `#` is a terrain palette key, so it can't be an object placeholder.
let board = load_board(&map_3x1("#..", &obj_palette("#", "")));
assert!(board.objects.is_empty());
}
#[test]
fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() {
// Solid object at the wall cell (0,0): dropped for conflicting with the wall.
let solid = "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let board = load_board(&map_3x1("#..", solid));
assert!(board.objects.is_empty());
// Same placement but non-solid: kept (it doesn't claim the cell's solidity).
let nonsolid = format!("{solid}solid = false\n");
let board = load_board(&map_3x1("#..", &nonsolid));
assert_eq!(board.objects.len(), 1);
}
#[test]
fn second_solid_object_on_a_cell_is_dropped() {
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let board = load_board(&map_3x1("...", &format!("{obj}{obj}")));
assert_eq!(board.objects.len(), 1);
}
/// Minimal valid TOML with a configurable grid, used as a base for dimension tests.
fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String {