This commit is contained in:
2026-06-13 16:24:29 -05:00
parent 78547696d7
commit 49d32eedf5
9 changed files with 410 additions and 15 deletions
+157 -4
View File
@@ -37,7 +37,7 @@ pub struct MapFile {
pub(crate) objects: Vec<MapFileObjectEntry>,
/// Any `[[portals]]` entries. Optional; defaults to empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub portals: Vec<PortalDef>,
pub(crate) portals: Vec<MapFilePortalEntry>,
}
/// The `[map]` header section of a map file.
@@ -178,6 +178,34 @@ pub(crate) struct MapFileObjectEntry {
name: Option<String>,
}
/// One `[[portals]]` entry in a map file.
///
/// Used only for TOML serialization/deserialization. Converted to [`PortalDef`]
/// in the [`TryFrom`] impl. On save, always written with concrete `x`/`y`
/// (the `palette` form is a load-time convenience only).
///
/// By convention, portal palette chars are digits (`'1'``'9'`), parallel to the
/// uppercase-letter convention for objects.
#[derive(Deserialize, Serialize)]
pub(crate) struct MapFilePortalEntry {
/// Board-unique name; also used as `target_entry` by the other end.
pub name: String,
/// Column of this portal (0-indexed). Mutually exclusive with `palette`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<usize>,
/// Row of this portal (0-indexed). See `x`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<usize>,
/// Single grid character (conventionally a digit). Alternative to `x`/`y`.
/// A portal's char may appear only once in the grid.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub palette: Option<String>,
/// Key of the target board in `World::boards`.
pub target_map: String,
/// Name of the arrival portal on the target board.
pub target_entry: String,
}
fn default_true() -> bool {
true
}
@@ -389,13 +417,69 @@ impl TryFrom<MapFile> for Board {
}
}
// Pass 2: walk the validated grid and build cells. Object palette chars
// Validate portal `palette` references — same rules as objects, plus portals
// may not claim chars already taken by objects (and vice versa).
// `portal_skip[i]` drops portal `i`; `portal_palette_char_owner` maps char → portal index.
let mut portal_skip = vec![false; mf.portals.len()];
let mut portal_palette_char_owner: HashMap<char, usize> = HashMap::new();
for (i, e) in mf.portals.iter().enumerate() {
let Some(p) = e.palette.as_deref() else { continue; };
if e.x.is_some() || e.y.is_some() {
load_errors.push(LogLine::error(format!(
"portal {i} ({:?}): specify either x/y or palette, not both; skipping portal", e.name
)));
portal_skip[i] = true;
continue;
}
let mut chs = p.chars();
let (Some(ch), None) = (chs.next(), chs.next()) else {
load_errors.push(LogLine::error(format!(
"portal {i} ({:?}): palette must be a single character (got {p:?}); skipping portal", e.name
)));
portal_skip[i] = true;
continue;
};
if Some(ch) == player_char {
load_errors.push(LogLine::error(format!(
"portal {i} ({:?}): palette char '{ch}' is reserved for the player; skipping portal", e.name
)));
portal_skip[i] = true;
continue;
}
if palette.contains_key(&ch) {
load_errors.push(LogLine::error(format!(
"portal {i} ({:?}): palette char '{ch}' is already a [palette] key; skipping portal", e.name
)));
portal_skip[i] = true;
continue;
}
if palette_char_owner.contains_key(&ch) {
load_errors.push(LogLine::error(format!(
"portal {i} ({:?}): palette char '{ch}' is already used by an object; skipping portal", e.name
)));
portal_skip[i] = true;
continue;
}
match portal_palette_char_owner.entry(ch) {
Entry::Occupied(_) => {
load_errors.push(LogLine::error(format!(
"portal {i} ({:?}): palette char '{ch}' is already used by another portal; skipping portal", e.name
)));
portal_skip[i] = true;
}
Entry::Vacant(v) => { v.insert(i); }
}
}
// Pass 2: walk the validated grid and build cells. Object/portal palette chars
// become `Empty` floor (with their positions 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);
// All grid positions (in reading order) at which each object palette char appears.
let mut obj_palette_positions: HashMap<char, Vec<(usize, usize)>> = HashMap::new();
// The single grid position at which each portal palette char appears (first sighting).
let mut portal_palette_positions: HashMap<char, Vec<(usize, usize)>> = HashMap::new();
// Where the player's char was first seen, and how many times (for recovery).
let mut player_grid_pos: Option<(usize, usize)> = None;
let mut player_grid_count: usize = 0;
@@ -411,6 +495,9 @@ impl TryFrom<MapFile> for Board {
} else if palette_char_owner.contains_key(&ch) {
cells.push(canonical_empty);
obj_palette_positions.entry(ch).or_default().push((x, y));
} else if portal_palette_char_owner.contains_key(&ch) {
cells.push(canonical_empty);
portal_palette_positions.entry(ch).or_default().push((x, y));
} else {
load_errors.push(LogLine::error(format!(
"unknown grid character '{ch}' at ({x}, {y}); using error block"
@@ -434,6 +521,26 @@ impl TryFrom<MapFile> for Board {
}
}
// Resolve portal palette chars: must appear exactly once (warn + use first if multiple).
for (&ch, &owner) in &portal_palette_char_owner {
if portal_skip[owner] { continue; }
let positions = portal_palette_positions.get(&ch).map(Vec::as_slice).unwrap_or(&[]);
match positions.len() {
0 => {
load_errors.push(LogLine::error(format!(
"portal palette char '{ch}' not found in the grid; skipping portal"
)));
portal_skip[owner] = true;
}
n if n > 1 => {
load_errors.push(LogLine::error(format!(
"portal palette char '{ch}' appears {n} times in the grid; using the first"
)));
}
_ => {}
}
}
// Build the cached floor layer from the (optional) `[floor]` declaration.
// Best-effort: any problem is recorded on `load_errors`, not fatal. The
// raw spec is kept on the board so a save can round-trip it.
@@ -551,6 +658,44 @@ impl TryFrom<MapFile> for Board {
}
}
// Build the validated portal list, resolving palette chars to concrete (x, y).
let mut seen_portal_names: HashMap<String, ()> = HashMap::new();
let mut portals: Vec<PortalDef> = Vec::new();
for (i, e) in mf.portals.into_iter().enumerate() {
if portal_skip[i] { continue; }
let (x, y) = if let Some(p) = e.palette.as_deref() {
let ch = p.chars().next().unwrap();
let positions = portal_palette_positions.get(&ch).map(Vec::as_slice).unwrap_or(&[]);
if positions.is_empty() { continue; }
positions[0]
} else {
match (e.x, e.y) {
(Some(x), Some(y)) if x < w && y < h => (x, y),
(Some(x), Some(y)) => {
load_errors.push(LogLine::error(format!(
"portal {i} ({:?}): x/y ({x}, {y}) out of bounds; skipping portal", e.name
)));
continue;
}
_ => {
load_errors.push(LogLine::error(format!(
"portal {i} ({:?}): must specify both x and y (or a palette char); skipping portal", e.name
)));
continue;
}
}
};
// Duplicate names: first claimant keeps the name, later ones are dropped.
if seen_portal_names.contains_key(&e.name) {
load_errors.push(LogLine::error(format!(
"portal {i}: name {:?} already used by another portal; skipping portal", e.name
)));
continue;
}
seen_portal_names.insert(e.name.clone(), ());
portals.push(PortalDef { name: e.name, x, y, target_map: e.target_map, target_entry: e.target_entry });
}
Ok(Board {
name: mf.map.name,
width: w,
@@ -564,7 +709,7 @@ impl TryFrom<MapFile> for Board {
},
objects,
next_object_id,
portals: mf.portals,
portals,
board_script_name: mf.map.board_script_name,
load_errors,
})
@@ -646,7 +791,15 @@ impl From<&Board> for MapFile {
})
.collect();
let portals = board.portals.clone();
// Always save portals with concrete x/y; the palette form is load-time only.
let portals: Vec<MapFilePortalEntry> = board.portals.iter().map(|p| MapFilePortalEntry {
name: p.name.clone(),
x: Some(p.x),
y: Some(p.y),
palette: None,
target_map: p.target_map.clone(),
target_entry: p.target_entry.clone(),
}).collect();
MapFile {
map: MapHeader {