Solidity
This commit is contained in:
@@ -46,12 +46,13 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
|
||||
**`kiln-core/src/game.rs`** — all core game types:
|
||||
- `Glyph` (`Copy`, `Eq`, `Hash`) — per-cell visual: `tile: u32` (tilesheet index), `fg/bg: Color32`. `Glyph::player()` is a `const fn`; all other glyphs come from the map file. Derives `Hash` so boards can deduplicate glyphs into a palette.
|
||||
- `FontSpec` — optional per-board bitmap font override: `path: String`, `tile_w: u32`, `tile_h: u32`. When `None`, the app default font is used.
|
||||
- `Behavior` — plain data struct of runtime behavioral properties: `passable: bool`, `opaque: bool`. Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere.
|
||||
- `Behavior` — plain data struct of runtime behavioral properties: `solid: bool` (blocks/participates in movement — inverse of the old `passable`), `opaque: bool`, `pushable: bool` (all archetypes `false` for now; the push mechanic is future work). Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere.
|
||||
- `Solid<'a>` — the single solid occupant of a cell, returned by `Board::solid_at`: `Cell(Archetype)` (a solid grid archetype like a wall) or `Object(&ObjectDef)` (a solid object). At most one solid may occupy a cell — enforced at load time.
|
||||
- `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red.
|
||||
- `ALL_ARCHETYPES: &[Archetype]` — ordered list of valid editor choices (excludes `ErrorBlock`).
|
||||
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec<ObjectDef>`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells` is `pub(crate)`. `is_passable(x, y)` checks objects before the grid cell: an impassable object blocks even over a passable floor tile.
|
||||
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec<ObjectDef>`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells` is `pub(crate)`. `solid_at(x, y) -> Option<Solid>` returns the cell's single solid occupant (object checked before grid archetype); `is_passable(x, y)` is the convenience inverse (`solid_at(...).is_none()`).
|
||||
- `Player` — `x: i32, y: i32`
|
||||
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `passable: bool`, `opaque: bool`, `script_name: Option<String>`. `passable` defaults `false` and `opaque` defaults `true` in map files. Its `init()`/`tick(dt)` hooks are run by the scripting runtime (see `script.rs`).
|
||||
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `solid: bool`, `opaque: bool`, `pushable: bool`, `script_name: Option<String>`. `solid` and `opaque` default `true`, `pushable` defaults `false` in map files. Its `init()`/`tick(dt)` hooks are run by the scripting runtime (see `script.rs`).
|
||||
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
|
||||
- `GameState` — holds `board: Rc<RefCell<Board>>`, the message `log: Vec<LogLine>`, and a `ScriptHost`. The board sits behind `Rc<RefCell<…>>` as a sibling of the `ScriptHost` so script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. Front-ends/logic reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (there is no `pub board` field). `try_move` moves the player; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` runs object `tick(dt)` hooks every frame. After each script batch, `apply_commands()` drains the script command queue: `Log`/`Error` → `log`, `SetTile` → the source object's glyph, `Move(dir)` → `move_object` (bounds + `is_passable`). This deferred apply (writes after the batch) is what keeps script execution borrow-safe.
|
||||
|
||||
@@ -177,17 +178,20 @@ tile_h = 16
|
||||
[grid]
|
||||
content = """
|
||||
############################################################
|
||||
# #
|
||||
# G #
|
||||
############################################################
|
||||
"""
|
||||
|
||||
[[objects]] # optional
|
||||
x = 10
|
||||
y = 5
|
||||
# Placement: either `x`/`y`, OR a `palette` char that appears in the grid.
|
||||
# Convention: object palette chars are uppercase letters (here `G`). The char
|
||||
# must appear exactly once in the grid, be unique across objects, and not be a
|
||||
# [palette] key; the cell under it loads as Empty.
|
||||
palette = "G"
|
||||
tile = "#"
|
||||
fg = "#aa3333"
|
||||
bg = "#000000"
|
||||
passable = true
|
||||
solid = false # blocks movement? defaults true. (pushable defaults false)
|
||||
script_name = "greeter" # references a key in [scripts]
|
||||
|
||||
[[portals]] # optional; parsed but not yet runtime-wired
|
||||
@@ -207,11 +211,12 @@ fn tick(dt) { move(North); } # optional, run every frame; dt = elapsed s
|
||||
"""
|
||||
```
|
||||
|
||||
Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`/`tick(dt)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`) and **write** via host functions `move(dir)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, and `log(s)`.
|
||||
Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object's `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention); placement and one-solid-per-cell violations are logged and the offending object is dropped (the rest of the board still loads). Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`/`tick(dt)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`) and **write** via host functions `move(dir)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, and `log(s)`.
|
||||
|
||||
### Key design decisions
|
||||
|
||||
- **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`passable`, `opaque`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site.
|
||||
- **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`solid`, `opaque`, `pushable`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site.
|
||||
- **One solid per cell** — at most one solid entity (a solid grid archetype *or* a solid object) may occupy a cell. The map loader enforces this: a solid object landing on an already-solid cell is logged and dropped. `Board::solid_at` relies on this invariant.
|
||||
- **`cells: Vec<(Glyph, Archetype)>`** — each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient.
|
||||
- **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games.
|
||||
- **`Board` is the complete unit** — grid, player, objects, and portals all live on `Board`, matching how ZZT treats a "board". No separate wrapper struct.
|
||||
|
||||
+100
-22
@@ -73,17 +73,22 @@ pub struct FontSpec {
|
||||
///
|
||||
/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It
|
||||
/// contains the properties the engine needs to simulate a cell — currently
|
||||
/// passability and opacity. Future properties (pushable, shootable, etc.) can
|
||||
/// solidity, opacity, and pushability. Future properties (shootable, etc.) can
|
||||
/// be added here without changing call sites.
|
||||
///
|
||||
/// For scripted objects, passability and opacity are stored directly on
|
||||
/// For scripted objects, solidity and opacity are stored directly on
|
||||
/// [`ObjectDef`] and will eventually be overridable by Rhai scripts at runtime.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct Behavior {
|
||||
/// Whether an entity can walk through this cell.
|
||||
pub passable: bool,
|
||||
/// Whether this cell blocks / participates in movement. A solid cell stops a
|
||||
/// mover (and is the only kind of cell that can later be pushed or receive a
|
||||
/// collision event). This is the inverse of the old `passable` flag.
|
||||
pub solid: bool,
|
||||
/// Whether this cell blocks line of sight (reserved for future rendering).
|
||||
pub opaque: bool,
|
||||
/// Whether a mover can shove this cell. Unused for now — every archetype is
|
||||
/// `false`; the push mechanic is future work.
|
||||
pub pushable: bool,
|
||||
}
|
||||
|
||||
/// A class of board cell, encoding its default behavior and appearance.
|
||||
@@ -121,16 +126,19 @@ impl Archetype {
|
||||
pub fn behavior(&self) -> Behavior {
|
||||
match self {
|
||||
Archetype::Empty => Behavior {
|
||||
passable: true,
|
||||
solid: false,
|
||||
opaque: false,
|
||||
pushable: false,
|
||||
},
|
||||
Archetype::Wall => Behavior {
|
||||
passable: false,
|
||||
solid: true,
|
||||
opaque: true,
|
||||
pushable: false,
|
||||
},
|
||||
Archetype::ErrorBlock => Behavior {
|
||||
passable: false,
|
||||
solid: true,
|
||||
opaque: true,
|
||||
pushable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -222,10 +230,15 @@ pub struct ObjectDef {
|
||||
/// Visual representation of this object. Owned by the object (not derived
|
||||
/// from the grid cell), so scripts can change tile, fg, and bg at runtime.
|
||||
pub glyph: Glyph,
|
||||
/// Whether the object blocks movement, of players or other objects
|
||||
pub passable: bool,
|
||||
/// Whether the object blocks / participates in movement. A solid object stops
|
||||
/// a mover walking into it; at most one solid (object or grid archetype) may
|
||||
/// occupy a cell. Inverse of the old `passable` flag.
|
||||
pub solid: bool,
|
||||
/// Whether the object blocks line of sight / FOV for the player
|
||||
pub opaque: bool,
|
||||
/// Whether the object can be shoved by a mover. Unused for now (no push
|
||||
/// mechanic yet); defaults to `false`.
|
||||
pub pushable: bool,
|
||||
/// Name of the Rhai script in [`Board::scripts`] that drives this object.
|
||||
/// `None` means this object has no script yet.
|
||||
pub script_name: Option<String>,
|
||||
@@ -244,20 +257,34 @@ impl ObjectDef {
|
||||
|
||||
/// Creates a new object at `(x, y)` with default glyph and blocking behavior.
|
||||
///
|
||||
/// Defaults: `passable = false`, `opaque = true`, no script. These match
|
||||
/// the serde defaults in the map file format so new objects round-trip correctly.
|
||||
/// Defaults: `solid = true`, `opaque = true`, `pushable = false`, no script.
|
||||
/// These match the serde defaults in the map file format so new objects
|
||||
/// round-trip correctly.
|
||||
pub fn new(x: usize, y: usize) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
glyph: Self::default_glyph(),
|
||||
passable: false,
|
||||
solid: true,
|
||||
opaque: true,
|
||||
pushable: false,
|
||||
script_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single solid occupant of a board cell, returned by [`Board::solid_at`].
|
||||
///
|
||||
/// At most one solid — a grid [`Archetype`] *or* an [`ObjectDef`] — may occupy a
|
||||
/// cell (the invariant enforced at load time), so this represents the one thing a
|
||||
/// mover would collide with there.
|
||||
pub enum Solid<'a> {
|
||||
/// The cell's grid archetype is itself solid (e.g. [`Archetype::Wall`]).
|
||||
Cell(Archetype),
|
||||
/// A solid [`ObjectDef`] occupies the cell.
|
||||
Object(&'a ObjectDef),
|
||||
}
|
||||
|
||||
/// A portal that teleports the player to a named entry point on another board.
|
||||
///
|
||||
/// Portals are loaded from `[[portals]]` entries in `.toml` map files and
|
||||
@@ -367,19 +394,33 @@ impl Board {
|
||||
&self.cells
|
||||
}
|
||||
|
||||
/// Returns `true` if the cell at `(x, y)` allows entities to pass through.
|
||||
/// Returns the single solid entity occupying `(x, y)`, if any.
|
||||
///
|
||||
/// Objects block movement before the grid cell archetype is consulted.
|
||||
/// Checks objects first, then the grid archetype. Because at most one solid
|
||||
/// may occupy a cell (an invariant enforced when the board is loaded — see
|
||||
/// [`crate::map_file`]), this returns that one occupant or `None`.
|
||||
/// Panics if `x` or `y` are out of bounds.
|
||||
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid<'_>> {
|
||||
// A solid object shadows the grid cell it sits on.
|
||||
if let Some(obj) = self.object_at(x, y)
|
||||
&& obj.solid
|
||||
{
|
||||
return Some(Solid::Object(obj));
|
||||
}
|
||||
// Otherwise the grid archetype itself may be solid (e.g. a wall).
|
||||
let arch = self.get(x, y).1;
|
||||
if arch.behavior().solid {
|
||||
return Some(Solid::Cell(arch));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it.
|
||||
///
|
||||
/// Convenience inverse of [`solid_at`](Board::solid_at).
|
||||
/// Panics if `x` or `y` are out of bounds.
|
||||
pub fn is_passable(&self, x: usize, y: usize) -> bool {
|
||||
// An object on this cell blocks if it's unpassable
|
||||
if let Some(obj) = self.object_at(x, y)
|
||||
&& !obj.passable
|
||||
{
|
||||
false
|
||||
} else {
|
||||
self.get(x, y).1.behavior().passable
|
||||
}
|
||||
self.solid_at(x, y).is_none()
|
||||
}
|
||||
|
||||
/// Returns the index into [`Board::objects`] of the object at `(x, y)`, if any.
|
||||
@@ -595,6 +636,43 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_at_reports_wall_object_and_empty() {
|
||||
// A 3×1 board: empty floor, a wall, and an empty cell holding one object.
|
||||
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
|
||||
board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
||||
// A solid object on the otherwise-empty cell (2, 0).
|
||||
board.objects.push(ObjectDef::new(2, 0)); // solid by default
|
||||
|
||||
// Empty floor: nothing solid.
|
||||
assert!(board.solid_at(0, 0).is_none());
|
||||
assert!(board.is_passable(0, 0));
|
||||
|
||||
// Wall cell: the grid archetype is the solid.
|
||||
match board.solid_at(1, 0) {
|
||||
Some(Solid::Cell(Archetype::Wall)) => {}
|
||||
other => panic!("expected Solid::Cell(Wall), got {:?}", other.is_some()),
|
||||
}
|
||||
assert!(!board.is_passable(1, 0));
|
||||
|
||||
// Object cell: the solid object shadows the empty floor under it.
|
||||
match board.solid_at(2, 0) {
|
||||
Some(Solid::Object(o)) => assert_eq!((o.x, o.y), (2, 0)),
|
||||
_ => panic!("expected Solid::Object"),
|
||||
}
|
||||
assert!(!board.is_passable(2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_solid_object_does_not_block() {
|
||||
// A non-solid object sits on empty floor: the cell stays passable.
|
||||
let mut obj = ObjectDef::new(1, 0);
|
||||
obj.solid = false;
|
||||
let board = open_board(3, 1, (0, 0), vec![obj], &[]);
|
||||
assert!(board.solid_at(1, 0).is_none());
|
||||
assert!(board.is_passable(1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_runs_only_on_run_init_not_at_construction() {
|
||||
let board = board_with_object(
|
||||
|
||||
+249
-23
@@ -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 {
|
||||
|
||||
+5
-4
@@ -23,7 +23,7 @@ content = """
|
||||
# # #
|
||||
# #
|
||||
# #
|
||||
# #
|
||||
# G #
|
||||
# #
|
||||
# #
|
||||
# #
|
||||
@@ -38,13 +38,14 @@ content = """
|
||||
############################################################
|
||||
"""
|
||||
|
||||
# Placed by its grid character `G` (uppercase-letter convention for objects)
|
||||
# rather than x/y; the cell under it loads as Empty.
|
||||
[[objects]]
|
||||
fg = "#aa3333"
|
||||
bg = "#000000"
|
||||
x = 10
|
||||
y = 10
|
||||
palette = "G"
|
||||
tile = "#"
|
||||
passable = true
|
||||
solid = false
|
||||
script_name = "greeter"
|
||||
|
||||
[scripts]
|
||||
|
||||
Reference in New Issue
Block a user