speech bubble fixes, multi objects
This commit is contained in:
+79
-71
@@ -163,9 +163,11 @@ pub(crate) struct MapFileObjectEntry {
|
||||
#[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`.
|
||||
/// `x`/`y`. By convention an uppercase letter. The char may appear multiple
|
||||
/// times in the grid — one `ObjectDef` is spawned per occurrence in reading
|
||||
/// order. Only one `[[objects]]` entry may use a given char, and it must not
|
||||
/// collide with a `[palette]` key. Each cell under the char loads as `Empty`.
|
||||
/// If the entry has a `name`, only the first spawned instance keeps it.
|
||||
#[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.
|
||||
@@ -364,12 +366,12 @@ impl TryFrom<MapFile> for Board {
|
||||
}
|
||||
|
||||
// Pass 2: walk the validated grid and build cells. Object palette chars
|
||||
// become `Empty` floor (with their position remembered); truly unknown
|
||||
// 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);
|
||||
let mut obj_palette_pos: HashMap<char, (usize, usize)> = HashMap::new();
|
||||
let mut obj_palette_count: HashMap<char, usize> = HashMap::new();
|
||||
// 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();
|
||||
// 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;
|
||||
@@ -384,8 +386,7 @@ impl TryFrom<MapFile> for Board {
|
||||
player_grid_count += 1;
|
||||
} else if palette_char_owner.contains_key(&ch) {
|
||||
cells.push(canonical_empty);
|
||||
obj_palette_pos.entry(ch).or_insert((x, y));
|
||||
*obj_palette_count.entry(ch).or_insert(0) += 1;
|
||||
obj_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"
|
||||
@@ -395,23 +396,17 @@ impl TryFrom<MapFile> for Board {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve each surviving object palette char: missing → drop; multiple →
|
||||
// keep the object at the first occurrence (already recorded) and report.
|
||||
// Resolve each surviving object palette char: missing → drop the entry.
|
||||
// Multiple occurrences are valid — each spawns its own ObjectDef below.
|
||||
for (&ch, &owner) in &palette_char_owner {
|
||||
if skip[owner] {
|
||||
continue;
|
||||
}
|
||||
match obj_palette_count.get(&ch).copied().unwrap_or(0) {
|
||||
0 => {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"object palette char '{ch}' not found in the grid; skipping object"
|
||||
)));
|
||||
skip[owner] = true;
|
||||
}
|
||||
1 => {}
|
||||
n => load_errors.push(LogLine::error(format!(
|
||||
"object palette char '{ch}' appears {n} times in the grid; using the first"
|
||||
))),
|
||||
if obj_palette_positions.get(&ch).map_or(true, Vec::is_empty) {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"object palette char '{ch}' not found in the grid; skipping object"
|
||||
)));
|
||||
skip[owner] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,13 +474,14 @@ impl TryFrom<MapFile> for Board {
|
||||
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()]
|
||||
// Collect all grid positions for this entry. Palette entries produce one
|
||||
// position per occurrence (in reading order); x/y entries produce exactly one.
|
||||
let positions: Vec<(usize, usize)> = if let Some(p) = e.palette.as_deref() {
|
||||
let ch = p.chars().next().unwrap();
|
||||
obj_palette_positions.get(&ch).cloned().unwrap_or_default()
|
||||
} else {
|
||||
match (e.x, e.y) {
|
||||
(Some(x), Some(y)) if x < w && y < h => (x, y),
|
||||
(Some(x), Some(y)) if x < w && y < h => vec![(x, y)],
|
||||
(Some(x), Some(y)) => {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"object {i}: x/y ({x}, {y}) out of bounds; skipping object"
|
||||
@@ -500,54 +496,66 @@ impl TryFrom<MapFile> for Board {
|
||||
}
|
||||
}
|
||||
};
|
||||
// Validate name uniqueness: first claimant keeps the name, later ones
|
||||
// have their name cleared and a nonfatal error is recorded.
|
||||
let name = e.name.and_then(|n| {
|
||||
use std::collections::hash_map::Entry;
|
||||
match seen_names.entry(n.clone()) {
|
||||
Entry::Vacant(v) => { v.insert(i); Some(n) }
|
||||
Entry::Occupied(o) => {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"object {i}: name {n:?} already used by object {}; clearing name",
|
||||
o.get()
|
||||
)));
|
||||
None
|
||||
|
||||
// Spawn one ObjectDef per position. Only the first instance may claim the
|
||||
// entry's name; subsequent copies are anonymous (names must be board-unique).
|
||||
for (instance_idx, &(x, y)) in positions.iter().enumerate() {
|
||||
let name = if instance_idx == 0 {
|
||||
// Validate name uniqueness: first claimant keeps the name, later ones
|
||||
// have their name cleared and a nonfatal error is recorded.
|
||||
e.name.as_ref().and_then(|n| {
|
||||
use std::collections::hash_map::Entry;
|
||||
match seen_names.entry(n.clone()) {
|
||||
Entry::Vacant(v) => { v.insert(i); Some(n.clone()) }
|
||||
Entry::Occupied(o) => {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"object {i}: name {n:?} already used by object {}; clearing name",
|
||||
o.get()
|
||||
)));
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let obj = ObjectDef {
|
||||
id: 0, // stamped by Board::add_object
|
||||
x,
|
||||
y,
|
||||
glyph: Glyph {
|
||||
tile: e.tile.into_u32(),
|
||||
fg: parse_color(&e.fg),
|
||||
bg: parse_color(&e.bg),
|
||||
},
|
||||
solid: e.solid,
|
||||
opaque: e.opaque,
|
||||
pushable: e.pushable,
|
||||
script_name: e.script_name.clone(),
|
||||
tags: e.tags.iter().cloned().collect(),
|
||||
name,
|
||||
};
|
||||
|
||||
// A solid object may not share a cell with another solid.
|
||||
if obj.solid {
|
||||
let idx = y * w + x;
|
||||
if solid_occupied[idx] {
|
||||
// The player silently wins its own cell (by design); any other
|
||||
// solid collision is a reportable mistake.
|
||||
if idx != pidx {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object"
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
solid_occupied[idx] = true;
|
||||
}
|
||||
});
|
||||
let obj = ObjectDef {
|
||||
id: 0, // stamped by Board::add_object
|
||||
x,
|
||||
y,
|
||||
glyph: Glyph {
|
||||
tile: e.tile.into_u32(),
|
||||
fg: parse_color(&e.fg),
|
||||
bg: parse_color(&e.bg),
|
||||
},
|
||||
solid: e.solid,
|
||||
opaque: e.opaque,
|
||||
pushable: e.pushable,
|
||||
script_name: e.script_name,
|
||||
tags: e.tags.into_iter().collect(),
|
||||
name,
|
||||
};
|
||||
// A solid object may not share a cell with another solid.
|
||||
if obj.solid {
|
||||
let idx = y * w + x;
|
||||
if solid_occupied[idx] {
|
||||
// The player silently wins its own cell (by design); any other
|
||||
// solid collision is a reportable mistake.
|
||||
if idx != pidx {
|
||||
load_errors.push(LogLine::error(format!(
|
||||
"object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object"
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
solid_occupied[idx] = true;
|
||||
|
||||
objects.insert(next_object_id, obj);
|
||||
next_object_id += 1;
|
||||
}
|
||||
objects.insert(next_object_id, obj);
|
||||
next_object_id += 1;
|
||||
}
|
||||
|
||||
Ok(Board {
|
||||
|
||||
@@ -35,13 +35,30 @@ fn palette_char_absent_from_grid_drops_object() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_appearing_twice_uses_first() {
|
||||
// `G` appears at (0,0) and (2,0): the object lands at the first, and the
|
||||
// map is flagged invalid (an extra appearance was reported).
|
||||
let board = load_board(&map_3x1("G.G", &obj_palette("G", "")));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
fn palette_char_appearing_twice_spawns_two_objects() {
|
||||
// `G` appears at (0,0) and (1,0); player is at (2,0) so neither G conflicts.
|
||||
// Two independent ObjectDefs are spawned.
|
||||
let board = load_board(&map_3x1("GG.", &obj_palette("G", "")));
|
||||
assert_eq!(board.objects.len(), 2);
|
||||
// IDs are assigned in reading order (left-to-right).
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
|
||||
assert!(!board.is_valid());
|
||||
assert_eq!((board.objects[&2].x, board.objects[&2].y), (1, 0));
|
||||
// Both cells load as Empty in the grid.
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Empty);
|
||||
assert!(board.is_valid(), "multiple occurrences are not an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_multi_occurrence_only_first_keeps_name() {
|
||||
// Two occurrences of a named entry (solid = false to avoid player conflict at (2,0)).
|
||||
// First instance gets the name; second is anonymous.
|
||||
let entry = obj_palette("G", "solid = false\nname = \"guard\"\n");
|
||||
let board = load_board(&map_3x1("GGG", &entry));
|
||||
assert_eq!(board.objects.len(), 3);
|
||||
assert_eq!(board.objects[&1].name.as_deref(), Some("guard"));
|
||||
assert_eq!(board.objects[&2].name, None);
|
||||
assert_eq!(board.objects[&3].name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -106,7 +106,9 @@ fn start_map_greeter_runs_init() {
|
||||
// confirm the greeter read the board (interpolated message) and wrote to
|
||||
// itself (set_tile). Also guards the example from drifting out of sync.
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml");
|
||||
let world = crate::world::load(path).expect("load start.toml");
|
||||
let mut world = crate::world::load(path).expect("load start.toml");
|
||||
// Pin to the "start" board regardless of the world's current default entry point.
|
||||
world.start = "start".to_string();
|
||||
let mut game = GameState::from_world(world);
|
||||
game.run_init();
|
||||
assert!(
|
||||
|
||||
@@ -142,7 +142,7 @@ pub(crate) enum ScriptArg {
|
||||
}
|
||||
|
||||
/// A cardinal movement direction.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Direction {
|
||||
North,
|
||||
South,
|
||||
|
||||
Reference in New Issue
Block a user