portals
This commit is contained in:
+157
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user