2026-06-07 00:19:53 -05:00
|
|
|
|
use crate::archetype::Archetype;
|
|
|
|
|
|
use crate::glyph::Glyph;
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use crate::layer::Layer;
|
2026-06-07 00:19:53 -05:00
|
|
|
|
use crate::log::LogLine;
|
|
|
|
|
|
use crate::object_def::ObjectDef;
|
2026-06-08 22:15:44 -05:00
|
|
|
|
use crate::utils::Direction;
|
2026-06-13 17:58:04 -05:00
|
|
|
|
use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid};
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
2026-06-07 00:19:53 -05:00
|
|
|
|
|
|
|
|
|
|
/// The complete state of one game board (a single room or screen).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `Board` is the central data structure of the engine, equivalent to a
|
|
|
|
|
|
/// "board" in ZZT. It contains everything needed to represent and run one
|
|
|
|
|
|
/// self-contained area of the game world:
|
|
|
|
|
|
///
|
|
|
|
|
|
/// - A grid of cells, each with a visual representation ([`Glyph`]) and an [`Archetype`]
|
|
|
|
|
|
/// - The current player position
|
|
|
|
|
|
/// - Scripted objects and portals (loaded but not yet active)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// ## Cell storage
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cells are stored as `(Glyph, Archetype)` tuples in a row-major `Vec`.
|
|
|
|
|
|
/// Each cell directly owns its visual and behavioral class — there is no
|
|
|
|
|
|
/// separate element palette or index indirection. Access cells with
|
|
|
|
|
|
/// [`Board::get`] and [`Board::get_mut`] using `(x, y)` coordinates.
|
|
|
|
|
|
/// Use [`Board::is_passable`] for collision checks.
|
|
|
|
|
|
pub struct Board {
|
|
|
|
|
|
/// Human-readable name for this board, loaded from the map file and round-tripped on save.
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
/// Width of the board in cells.
|
|
|
|
|
|
pub width: usize,
|
|
|
|
|
|
/// Height of the board in cells.
|
|
|
|
|
|
pub height: usize,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Ordered draw stack of [`Layer`]s, bottom (index 0) to top. Each layer holds
|
|
|
|
|
|
/// a row-major grid of `(Glyph, Archetype)` cells; a transparent cell lets the
|
|
|
|
|
|
/// layer beneath show through. Drawing ([`Board::glyph_at`]) walks the stack
|
|
|
|
|
|
/// top-down; solidity ([`Board::solid_at`]) scans every layer. Access a single
|
|
|
|
|
|
/// cell with [`Board::get`]/[`Board::get_mut`] by `(z, x, y)`.
|
|
|
|
|
|
pub(crate) layers: Vec<Layer>,
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// Current player position. See [`Player`] for caveats about its future.
|
|
|
|
|
|
pub player: Player,
|
|
|
|
|
|
/// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap`
|
|
|
|
|
|
/// (not a `Vec`) so an object can be removed without invalidating other
|
|
|
|
|
|
/// objects' ids; iteration is in ascending-id order, which equals load order
|
|
|
|
|
|
/// (ids are assigned sequentially as the map loads).
|
|
|
|
|
|
pub objects: BTreeMap<ObjectId, ObjectDef>,
|
|
|
|
|
|
/// The next [`ObjectId`] to hand out (starts at 1, monotonically increasing).
|
|
|
|
|
|
/// See [`Board::add_object`].
|
|
|
|
|
|
pub next_object_id: ObjectId,
|
|
|
|
|
|
/// Portals on this board. Parsed from the map file; not yet active.
|
|
|
|
|
|
pub portals: Vec<PortalDef>,
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// Name of the board-level script in the world script pool, if any.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`)
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// rather than being tied to a specific object cell. Scripts live in
|
|
|
|
|
|
/// [`World::scripts`](crate::world::World) and are looked up by this name.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
pub board_script_name: Option<String>,
|
|
|
|
|
|
/// Nonfatal problems collected while loading this map (e.g. unknown
|
|
|
|
|
|
/// archetypes, dropped objects, recovered placement chars), as red-on-black
|
|
|
|
|
|
/// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of
|
|
|
|
|
|
/// the map file (purely a load diagnostic).
|
|
|
|
|
|
pub(crate) load_errors: Vec<LogLine>,
|
2026-06-13 17:58:04 -05:00
|
|
|
|
/// Per-board key→value store written and read by Rhai scripts via the
|
|
|
|
|
|
/// `Registry` scope constant. Persists automatically across board transitions
|
|
|
|
|
|
/// because all boards live as `Rc<RefCell<Board>>` in `World::boards` and are
|
|
|
|
|
|
/// never evicted. Not saved to disk in v1.
|
|
|
|
|
|
pub registry: HashMap<String, RegistryValue>,
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Board {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Number of draw layers on this board (≥ 1 for a loaded board).
|
|
|
|
|
|
pub fn layer_count(&self) -> usize {
|
|
|
|
|
|
self.layers.len()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns a reference to the cell at `(x, y)` on layer `z`.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// out of bounds.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
pub fn get(&self, z: usize, x: usize, y: usize) -> &(Glyph, Archetype) {
|
|
|
|
|
|
&self.layers[z].cells[y * self.width + x]
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Returns a mutable reference to the cell at `(x, y)` on layer `z`.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Panics if `z`, `x`, or `y` are out of bounds.
|
|
|
|
|
|
pub fn get_mut(&mut self, z: usize, x: usize, y: usize) -> &mut (Glyph, Archetype) {
|
|
|
|
|
|
let w = self.width;
|
|
|
|
|
|
&mut self.layers[z].cells[y * w + x]
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Returns the glyph to display at `(x, y)`, honoring layer draw order.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The player is always drawn on top (it is not part of the layer stack yet).
|
|
|
|
|
|
/// Otherwise the layers are walked **top-down**; the first thing that draws on
|
|
|
|
|
|
/// a layer wins:
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// 1. an object on that layer (a solid object always; otherwise a non-solid
|
|
|
|
|
|
/// object whose glyph is not transparent, i.e. `tile != 0`),
|
|
|
|
|
|
/// 2. a portal on that layer,
|
|
|
|
|
|
/// 3. the layer's terrain cell — a solid always draws, and a non-solid draws
|
|
|
|
|
|
/// only when not transparent (`tile != 0`).
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// If no layer contributes anything, the canonical black `Empty` glyph is used.
|
|
|
|
|
|
/// Panics if out of bounds.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// The player is rendered above the whole stack (see the `Player` notes).
|
|
|
|
|
|
if self.player.x == x as i32 && self.player.y == y as i32 {
|
|
|
|
|
|
return Glyph::player();
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
for z in (0..self.layers.len()).rev() {
|
|
|
|
|
|
// Objects on this layer: a solid object always draws; otherwise the
|
|
|
|
|
|
// first non-transparent non-solid object (lets invisible objects exist).
|
|
|
|
|
|
let mut nonsolid: Option<Glyph> = None;
|
|
|
|
|
|
for o in self
|
|
|
|
|
|
.objects
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.filter(|o| o.x == x && o.y == y && o.z == z)
|
|
|
|
|
|
{
|
|
|
|
|
|
if o.solid {
|
|
|
|
|
|
return o.glyph;
|
|
|
|
|
|
}
|
|
|
|
|
|
if nonsolid.is_none() && o.glyph.tile != 0 {
|
|
|
|
|
|
nonsolid = Some(o.glyph);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(g) = nonsolid {
|
|
|
|
|
|
return g;
|
|
|
|
|
|
}
|
2026-06-07 00:19:53 -05:00
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// A portal on this layer draws above its (transparent) terrain cell.
|
|
|
|
|
|
if self
|
|
|
|
|
|
.portals
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|p| p.x == x && p.y == y && p.z == z)
|
|
|
|
|
|
{
|
|
|
|
|
|
return PortalDef::default_glyph();
|
|
|
|
|
|
}
|
2026-06-07 00:19:53 -05:00
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// The terrain cell: a solid always draws; a non-solid only if visible.
|
|
|
|
|
|
let (glyph, arch) = self.get(z, x, y);
|
|
|
|
|
|
if arch.behavior().solid || glyph.tile != 0 {
|
|
|
|
|
|
return *glyph;
|
|
|
|
|
|
}
|
2026-06-13 16:24:29 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// Nothing on any layer: the canonical black empty cell.
|
|
|
|
|
|
Archetype::Empty.default_glyph()
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Takes signed coords so callers can pass a raw `pos + delta` without first
|
|
|
|
|
|
/// checking for negatives.
|
|
|
|
|
|
pub fn in_bounds(&self, pos: (i32, i32)) -> bool {
|
|
|
|
|
|
let (x, y) = pos;
|
|
|
|
|
|
x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Records a nonfatal error: appends `message` as a red-on-black line to the
|
|
|
|
|
|
/// board's [`load_errors`](Board::load_errors). Used by the map loader (and
|
|
|
|
|
|
/// available at runtime) to surface recoverable problems.
|
|
|
|
|
|
pub fn report_error(&mut self, message: impl Into<String>) {
|
|
|
|
|
|
self.load_errors.push(LogLine::error(message));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns `true` if the map loaded with no nonfatal errors (the error list
|
|
|
|
|
|
/// is empty).
|
|
|
|
|
|
pub fn is_valid(&self) -> bool {
|
|
|
|
|
|
self.load_errors.is_empty()
|
|
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// Returns the single solid entity occupying `(x, y)`, if any.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Checks player first, then objects, 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> {
|
|
|
|
|
|
// The player wins its cell (load-time invariant), so it is the solid there.
|
|
|
|
|
|
if self.player.x == x as i32 && self.player.y == y as i32 {
|
|
|
|
|
|
return Some(Solid::Player);
|
|
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// A solid object shadows the cell it sits on.
|
|
|
|
|
|
if let Some(id) = self.solid_object_id_at(x, y) {
|
2026-06-07 00:19:53 -05:00
|
|
|
|
return Some(Solid::Object(id));
|
|
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// Otherwise some layer's terrain archetype may be solid (e.g. a wall).
|
|
|
|
|
|
if let Some(z) = self.solid_cell_layer(x, y) {
|
|
|
|
|
|
return Some(Solid::Cell(self.get(z, x, y).1));
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Returns the index of the layer whose terrain cell at `(x, y)` is solid, if
|
|
|
|
|
|
/// any. By the one-solid-per-cell invariant there is at most one such layer.
|
|
|
|
|
|
fn solid_cell_layer(&self, x: usize, y: usize) -> Option<usize> {
|
|
|
|
|
|
(0..self.layers.len()).find(|&z| self.get(z, x, y).1.behavior().solid)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// 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 {
|
|
|
|
|
|
self.solid_at(x, y).is_none()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Non-solid things are never pushable: `pushable` only matters for solids.
|
|
|
|
|
|
/// Grid archetypes may restrict the axis (see [`Pushable`]); pushable objects
|
|
|
|
|
|
/// can be shoved in any direction.
|
|
|
|
|
|
fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool {
|
|
|
|
|
|
match self.solid_at(x, y) {
|
|
|
|
|
|
Some(Solid::Player) => true, // the player is pushable in any direction
|
|
|
|
|
|
Some(Solid::Cell(a)) => a.behavior().pushable.allows(dir),
|
|
|
|
|
|
Some(Solid::Object(id)) => self.objects[&id].pushable,
|
|
|
|
|
|
None => false,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Whether the chain of pushable solids starting at `(x, y)` can be shoved one
|
|
|
|
|
|
/// step in `dir` — i.e. the chain ends at a passable cell rather than the board
|
|
|
|
|
|
/// edge or a non-pushable solid.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Read-only (`&self`); pairs with [`push`](Board::push). Returns `false` when
|
|
|
|
|
|
/// `(x, y)` itself holds no pushable solid, so it doubles as the "is the cell
|
|
|
|
|
|
/// ahead shovable?" half of a "can I move here?" query.
|
|
|
|
|
|
pub fn can_push(&self, x: usize, y: usize, dir: Direction) -> bool {
|
|
|
|
|
|
let (dx, dy): (i32, i32) = dir.into();
|
|
|
|
|
|
let (mut cx, mut cy) = (x, y);
|
|
|
|
|
|
loop {
|
|
|
|
|
|
// This cell must hold a solid pushable in `dir` to advance the chain.
|
|
|
|
|
|
if !self.is_pushable(cx, cy, dir) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let next = (cx as i32 + dx, cy as i32 + dy);
|
|
|
|
|
|
if !self.in_bounds(next) {
|
|
|
|
|
|
return false; // chain runs off the board
|
|
|
|
|
|
}
|
|
|
|
|
|
let (nx, ny) = (next.0 as usize, next.1 as usize);
|
|
|
|
|
|
if self.is_passable(nx, ny) {
|
|
|
|
|
|
return true; // open space at the end: the whole chain can move
|
|
|
|
|
|
}
|
|
|
|
|
|
// Next cell holds a solid too; continue (it must itself be pushable).
|
|
|
|
|
|
cx = nx;
|
|
|
|
|
|
cy = ny;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Shoves the chain of pushable solids starting at `(x, y)` one step in `dir`,
|
|
|
|
|
|
/// leaving `Empty` floor behind each moved cell.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// No-op when the chain can't move (it self-checks via [`can_push`](Board::can_push)),
|
|
|
|
|
|
/// so it is safe to call unconditionally.
|
|
|
|
|
|
pub fn push(&mut self, x: usize, y: usize, dir: Direction) {
|
|
|
|
|
|
if !self.can_push(x, y, dir) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let (dx, dy): (i32, i32) = dir.into();
|
|
|
|
|
|
// can_push guaranteed the chain ends at an in-bounds passable cell, so
|
|
|
|
|
|
// re-walk it (no bounds checks needed) and shift the far end first, which
|
|
|
|
|
|
// keeps each destination cell vacated before its occupant arrives.
|
|
|
|
|
|
let mut chain: Vec<(usize, usize)> = Vec::new();
|
|
|
|
|
|
let (mut cx, mut cy) = (x, y);
|
|
|
|
|
|
while !self.is_passable(cx, cy) {
|
|
|
|
|
|
chain.push((cx, cy));
|
|
|
|
|
|
cx = (cx as i32 + dx) as usize;
|
|
|
|
|
|
cy = (cy as i32 + dy) as usize;
|
|
|
|
|
|
}
|
|
|
|
|
|
for &(px, py) in chain.iter().rev() {
|
|
|
|
|
|
self.shift_solid(px, py, dx, dy);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
|
|
|
|
|
|
///
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// A solid object is relocated (keeping its layer); otherwise the solid
|
|
|
|
|
|
/// terrain archetype (a crate) is moved within its own layer, leaving a
|
|
|
|
|
|
/// transparent cell behind so the layer beneath (e.g. floor) shows through.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// The caller guarantees the destination is already clear.
|
|
|
|
|
|
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
|
|
|
|
|
|
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// The player owns its cell, so move it before considering objects/terrain.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
if self.player.x == x as i32 && self.player.y == y as i32 {
|
|
|
|
|
|
self.player.x = tx as i32;
|
|
|
|
|
|
self.player.y = ty as i32;
|
2026-06-15 23:35:18 -05:00
|
|
|
|
} else if let Some(id) = self.solid_object_id_at(x, y) {
|
2026-06-07 00:19:53 -05:00
|
|
|
|
let obj = self.objects.get_mut(&id).expect("id from object_id_at");
|
|
|
|
|
|
obj.x = tx;
|
|
|
|
|
|
obj.y = ty;
|
2026-06-15 23:35:18 -05:00
|
|
|
|
} else if let Some(z) = self.solid_cell_layer(x, y) {
|
|
|
|
|
|
let moved = *self.get(z, x, y);
|
|
|
|
|
|
*self.get_mut(z, tx, ty) = moved;
|
|
|
|
|
|
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns the [`ObjectId`]s of the objects at `(x, y)`, if any.
|
|
|
|
|
|
pub fn object_ids_at(&self, x: usize, y: usize) -> Vec<ObjectId> {
|
|
|
|
|
|
self.objects
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|(_, o)| o.x == x && o.y == y)
|
2026-06-15 23:35:18 -05:00
|
|
|
|
.map(|(&id, _)| id)
|
|
|
|
|
|
.collect()
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns a borrow of the actual object at `(x, y)` if any
|
|
|
|
|
|
pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option<ObjectId> {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
self.objects.iter().find_map(|(&id, o)| {
|
|
|
|
|
|
if o.x == x && o.y == y && o.solid {
|
|
|
|
|
|
Some(id)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Ids start at 1 and increase monotonically; an id is never reused, so it
|
|
|
|
|
|
/// stays a valid handle to this object for the board's lifetime.
|
2026-06-07 01:26:18 -05:00
|
|
|
|
pub fn add_object(&mut self, mut object: ObjectDef) -> ObjectId {
|
2026-06-07 00:19:53 -05:00
|
|
|
|
let id = self.next_object_id;
|
|
|
|
|
|
self.next_object_id += 1;
|
2026-06-07 01:26:18 -05:00
|
|
|
|
object.id = id;
|
2026-06-07 00:19:53 -05:00
|
|
|
|
self.objects.insert(id, object);
|
|
|
|
|
|
id
|
|
|
|
|
|
}
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
pub(crate) mod tests {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use super::Board;
|
2026-06-07 00:33:16 -05:00
|
|
|
|
use crate::archetype::Archetype;
|
|
|
|
|
|
use crate::glyph::Glyph;
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use crate::layer::Layer;
|
2026-06-07 00:33:16 -05:00
|
|
|
|
use crate::object_def::ObjectDef;
|
2026-06-08 22:15:44 -05:00
|
|
|
|
use crate::utils::Direction;
|
2026-06-07 00:33:16 -05:00
|
|
|
|
use crate::utils::{ObjectId, Player, Solid};
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use color::Rgba8;
|
|
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
2026-06-07 00:33:16 -05:00
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Builds an all-empty `w×h` single-layer board with the given player position
|
|
|
|
|
|
/// and objects (all on layer 0). Assigns sequential ids (1..=n) to objects.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The single layer is fully transparent, so terrain stamped via [`crate_at`]
|
|
|
|
|
|
/// etc. always lands on the board's top layer. Use [`add_floor`] to slip a
|
|
|
|
|
|
/// visible floor layer underneath.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn open_board(
|
|
|
|
|
|
w: usize,
|
|
|
|
|
|
h: usize,
|
|
|
|
|
|
player: (i32, i32),
|
|
|
|
|
|
objects: Vec<ObjectDef>,
|
|
|
|
|
|
) -> Board {
|
|
|
|
|
|
let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
|
|
|
|
|
|
let mut next_object_id: ObjectId = 1;
|
2026-06-07 01:26:18 -05:00
|
|
|
|
for mut o in objects {
|
|
|
|
|
|
o.id = next_object_id;
|
2026-06-07 00:33:16 -05:00
|
|
|
|
object_map.insert(next_object_id, o);
|
|
|
|
|
|
next_object_id += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
Board {
|
|
|
|
|
|
name: "test".into(),
|
|
|
|
|
|
width: w,
|
|
|
|
|
|
height: h,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
layers: vec![Layer {
|
|
|
|
|
|
cells: vec![(Glyph::transparent(), Archetype::Empty); w * h],
|
|
|
|
|
|
}],
|
|
|
|
|
|
player: Player {
|
|
|
|
|
|
x: player.0,
|
|
|
|
|
|
y: player.1,
|
|
|
|
|
|
},
|
2026-06-07 00:33:16 -05:00
|
|
|
|
objects: object_map,
|
|
|
|
|
|
next_object_id,
|
|
|
|
|
|
portals: Vec::new(),
|
|
|
|
|
|
board_script_name: None,
|
|
|
|
|
|
load_errors: Vec::new(),
|
2026-06-13 17:58:04 -05:00
|
|
|
|
registry: HashMap::new(),
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Inserts a visible floor layer (filled with `glyph`) below everything,
|
|
|
|
|
|
/// bumping existing terrain and objects up one layer.
|
|
|
|
|
|
pub(crate) fn add_floor(board: &mut Board, glyph: Glyph) {
|
|
|
|
|
|
let count = board.width * board.height;
|
|
|
|
|
|
board.layers.insert(
|
|
|
|
|
|
0,
|
|
|
|
|
|
Layer {
|
|
|
|
|
|
cells: vec![(glyph, Archetype::Empty); count],
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
for o in board.objects.values_mut() {
|
|
|
|
|
|
o.z += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The index of the board's top (terrain) layer, where stamps are written.
|
|
|
|
|
|
fn top(board: &Board) -> usize {
|
|
|
|
|
|
board.layers.len() - 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Stamps a crate cell onto the board's top layer.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let z = top(board);
|
|
|
|
|
|
*board.get_mut(z, x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Stamps a wall cell onto the board's top layer.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let z = top(board);
|
|
|
|
|
|
*board.get_mut(z, x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Stamps an arbitrary archetype cell onto the board's top layer.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let z = top(board);
|
|
|
|
|
|
*board.get_mut(z, x, y) = (arch.default_glyph(), arch);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn solid_at_reports_wall_object_and_empty() {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let mut board = open_board(4, 1, (3, 0), vec![]);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
wall_at(&mut board, 1, 0);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
board.add_object(ObjectDef::new(2, 0));
|
|
|
|
|
|
|
|
|
|
|
|
assert!(board.solid_at(0, 0).is_none());
|
|
|
|
|
|
assert!(board.is_passable(0, 0));
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
|
|
match board.solid_at(2, 0) {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
Some(Solid::Object(id)) => {
|
|
|
|
|
|
assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0))
|
|
|
|
|
|
}
|
2026-06-07 00:33:16 -05:00
|
|
|
|
_ => panic!("expected Solid::Object"),
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(!board.is_passable(2, 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn non_solid_object_does_not_block() {
|
|
|
|
|
|
let mut obj = ObjectDef::new(1, 0);
|
|
|
|
|
|
obj.solid = false;
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let board = open_board(3, 1, (0, 0), vec![obj]);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
assert!(board.solid_at(1, 0).is_none());
|
|
|
|
|
|
assert!(board.is_passable(1, 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn solid_at_reports_player() {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let board = open_board(3, 1, (1, 0), vec![]);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
match board.solid_at(1, 0) {
|
|
|
|
|
|
Some(Solid::Player) => {}
|
|
|
|
|
|
_ => panic!("expected Solid::Player at the player's cell"),
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(!board.is_passable(1, 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn in_bounds_checks_grid_boundaries() {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let board = open_board(3, 2, (0, 0), vec![]);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
assert!(board.in_bounds((0, 0)));
|
|
|
|
|
|
assert!(board.in_bounds((2, 1)));
|
|
|
|
|
|
assert!(!board.in_bounds((-1, 0)));
|
|
|
|
|
|
assert!(!board.in_bounds((0, -1)));
|
|
|
|
|
|
assert!(!board.in_bounds((3, 0)));
|
|
|
|
|
|
assert!(!board.in_bounds((0, 2)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn can_push_is_read_only_and_correct() {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let mut board = open_board(3, 1, (0, 0), vec![]);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
assert!(board.can_push(1, 0, Direction::East));
|
2026-06-15 23:35:18 -05:00
|
|
|
|
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // no mutation
|
|
|
|
|
|
assert_eq!(board.get(0, 2, 0).1, Archetype::Empty);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let mut board = open_board(3, 1, (0, 0), vec![]);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
wall_at(&mut board, 2, 0);
|
|
|
|
|
|
assert!(!board.can_push(1, 0, Direction::East));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn push_into_player_pushes_player() {
|
|
|
|
|
|
// Crate shoved east into the player slides the player along into open space.
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let mut board = open_board(4, 1, (2, 0), vec![]);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
assert!(board.can_push(1, 0, Direction::East));
|
|
|
|
|
|
board.push(1, 0, Direction::East);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
|
|
|
|
|
|
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
assert_eq!((board.player.x, board.player.y), (3, 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn push_into_player_blocked_by_wall() {
|
|
|
|
|
|
// Player backed against a wall: push has nowhere to go, nothing moves.
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let mut board = open_board(4, 1, (2, 0), vec![]);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
wall_at(&mut board, 3, 0);
|
|
|
|
|
|
assert!(!board.can_push(1, 0, Direction::East));
|
|
|
|
|
|
board.push(1, 0, Direction::East); // no-op
|
2026-06-15 23:35:18 -05:00
|
|
|
|
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
assert_eq!((board.player.x, board.player.y), (2, 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn glyph_at_uses_floor_for_empty_and_grid_for_solid() {
|
|
|
|
|
|
// Player parked at (2,0) so it doesn't overlap either asserted cell.
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let mut board = open_board(3, 1, (2, 0), vec![]);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
let floor_glyph = Glyph {
|
|
|
|
|
|
tile: '.' as u32,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
fg: Rgba8 {
|
|
|
|
|
|
r: 10,
|
|
|
|
|
|
g: 20,
|
|
|
|
|
|
b: 30,
|
|
|
|
|
|
a: 255,
|
|
|
|
|
|
},
|
|
|
|
|
|
bg: Rgba8 {
|
|
|
|
|
|
r: 1,
|
|
|
|
|
|
g: 2,
|
|
|
|
|
|
b: 3,
|
|
|
|
|
|
a: 255,
|
|
|
|
|
|
},
|
2026-06-07 00:33:16 -05:00
|
|
|
|
};
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// Floor on a lower layer, a wall on the top (terrain) layer at (0,0).
|
|
|
|
|
|
add_floor(&mut board, floor_glyph);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
wall_at(&mut board, 0, 0);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// The wall (solid) draws over the floor; the empty cell reveals the floor.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
assert_eq!(board.glyph_at(0, 0), Archetype::Wall.default_glyph());
|
|
|
|
|
|
assert_eq!(board.glyph_at(1, 0), floor_glyph);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn glyph_at_draws_higher_layer_over_lower() {
|
|
|
|
|
|
// A non-solid object on an upper layer renders above a wall on a lower one.
|
|
|
|
|
|
let mut board = open_board(2, 1, (1, 0), vec![]);
|
|
|
|
|
|
wall_at(&mut board, 0, 0); // wall on layer 0
|
|
|
|
|
|
// Add an upper layer holding a visible, non-solid object at (0,0).
|
|
|
|
|
|
board.layers.push(Layer {
|
|
|
|
|
|
cells: vec![(Glyph::transparent(), Archetype::Empty); 2],
|
|
|
|
|
|
});
|
|
|
|
|
|
let mut obj = ObjectDef::new(0, 0);
|
|
|
|
|
|
obj.z = 1;
|
|
|
|
|
|
obj.solid = false;
|
|
|
|
|
|
obj.glyph = Glyph {
|
|
|
|
|
|
tile: '*' as u32,
|
|
|
|
|
|
..Glyph::transparent()
|
|
|
|
|
|
};
|
|
|
|
|
|
board.add_object(obj);
|
|
|
|
|
|
assert_eq!(board.glyph_at(0, 0).tile, '*' as u32);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:33:16 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn fresh_board_is_valid_and_reports_errors() {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let mut board = open_board(1, 1, (0, 0), vec![]);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
assert!(board.is_valid());
|
|
|
|
|
|
board.report_error("something went wrong");
|
|
|
|
|
|
assert!(!board.is_valid());
|
|
|
|
|
|
assert_eq!(board.load_errors.len(), 1);
|
|
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|