Files
kiln/kiln-core/src/board.rs
T
2026-06-07 00:33:16 -05:00

486 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::{BTreeMap, HashMap};
use crate::archetype::Archetype;
use crate::archetype::Archetype::Empty;
use crate::font::FontSpec;
use crate::glyph::Glyph;
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::script::Direction;
use crate::utils::{ObjectId, Player, PortalDef, Solid};
/// 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,
/// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to
/// access by `(x, y)` coordinates.
pub(crate) cells: Vec<(Glyph, Archetype)>,
/// Row-major cache of the visual floor layer: the glyph drawn for a cell when
/// it would otherwise render as [`Archetype::Empty`]. Computed once at load
/// from [`floor_spec`](Board::floor_spec) (see [`crate::floor::build_floor`]).
/// When a map declares no `[floor]`, every entry is black-on-black space (the
/// historical look of an empty cell). Read it via [`Board::glyph_at`].
pub(crate) floor: Vec<Glyph>,
/// The raw `[floor]` declaration, kept so [`crate::map_file::save`] can
/// round-trip it. `None` when the map declared no floor. (Generators
/// re-randomize on reload; literal glyph grids are preserved exactly.)
pub(crate) floor_spec: Option<crate::floor::FloorSpec>,
/// 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>,
/// Optional font override for this board. When `None`, the app default is used.
pub font: Option<FontSpec>,
/// Integer scale factor applied to tiles when rendering this board. 1 = natural size.
pub zoom: u32,
/// Named Rhai scripts available on this board: script name → source text.
///
/// All script source lives here; [`ObjectDef`]s and [`Board::board_script_name`]
/// reference entries by name. This means multiple objects can share the same
/// source while each running instance gets its own Rhai scope at runtime.
pub scripts: HashMap<String, String>,
/// Name of the board-level script in [`Board::scripts`], if any.
///
/// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`)
/// rather than being tied to a specific object cell.
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>,
}
impl Board {
/// Returns a reference to the cell at `(x, y)`.
///
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `x` or `y` are
/// out of bounds.
pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) {
&self.cells[y * self.width + x]
}
/// Returns a mutable reference to the cell at `(x, y)`.
///
/// Panics if `x` or `y` are out of bounds.
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
&mut self.cells[y * self.width + x]
}
/// Returns the glyph we should display for a given coordinate:
///
/// - If the coord contains a [`Solid`], use the appropriate glyph for that
/// - If it contains at least one non-solid object with a glyph other than 0, use one (arbitrarily)
/// - If it contains any non-Empty archetype, use that glyph
/// - Fall back to the [`floor`](Board::floor) glyph.
///
/// An `Empty` cell's *own* glyph is intentionally ignored — the floor layer
/// supersedes it (so a cell vacated by a pushed crate reveals the floor, not
/// black). Panics if out of bounds.
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
if let Some(solid) = self.solid_at(x, y) {
// Use the solid
return match solid {
Solid::Player => Glyph::player(),
Solid::Cell(_) => self.get(x, y).0,
Solid::Object(id) => self.objects[&id].glyph
}
}
let (glyph, arch) = *self.get(x, y);
let ids = self.object_ids_at(x, y); // IDs of non-solid (ethereal?) objects
// Objects with nonzero (nonblank) glyphs take precedence. This allows us to use invisible
// nonsolid objects on the board.
if let Some(glyph) = ids.iter().find(|id| self.objects[id].glyph.tile != 0 ).map(|id| self.objects[id].glyph) {
return glyph
}
// Is there a normal archetype?
if arch != Empty {
return glyph
}
self.floor[y * self.width + x]
}
/// 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()
}
/// 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);
}
// A solid object shadows the grid cell it sits on.
if let Some(id) = self.solid_object_id_at(x, y)
{
return Some(Solid::Object(id));
}
// 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 {
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)`.
///
/// A solid object is relocated; otherwise the grid archetype (a crate) is
/// moved, leaving `Empty` floor behind (the grid has no separate floor layer).
/// 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);
// The player owns its cell, so move it before considering objects/grid.
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;
} else if let Some(id) = self.solid_object_id_at(x, y)
{
let obj = self.objects.get_mut(&id).expect("id from object_id_at");
obj.x = tx;
obj.y = ty;
} else {
let moved = *self.get(x, y);
*self.get_mut(tx, ty) = moved;
*self.get_mut(x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty);
}
}
/// 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)
.map(|(&id, _)| id).collect()
}
/// 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> {
self.objects
.iter()
.find_map(|(&id, o)| {
if o.x == x && o.y == y && o.solid {
Some(id)
} else {
None
}
})
}
/// 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.
pub fn add_object(&mut self, object: ObjectDef) -> ObjectId {
let id = self.next_object_id;
self.next_object_id += 1;
self.objects.insert(id, object);
id
}
}
#[cfg(test)]
pub(crate) mod tests {
use std::collections::{BTreeMap, HashMap};
use color::Rgba8;
use crate::archetype::Archetype;
use crate::glyph::Glyph;
use crate::object_def::ObjectDef;
use crate::script::Direction;
use crate::utils::{ObjectId, Player, Solid};
use super::Board;
/// Builds an all-empty `w×h` board with the given player position, objects,
/// and `(name, source)` scripts. Assigns sequential ids (1..=n) to objects.
pub(crate) fn open_board(
w: usize,
h: usize,
player: (i32, i32),
objects: Vec<ObjectDef>,
scripts: &[(&str, &str)],
) -> Board {
let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
let mut next_object_id: ObjectId = 1;
for o in objects {
object_map.insert(next_object_id, o);
next_object_id += 1;
}
Board {
name: "test".into(),
width: w,
height: h,
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
floor: vec![Archetype::Empty.default_glyph(); w * h],
floor_spec: None,
player: Player { x: player.0, y: player.1 },
objects: object_map,
next_object_id,
portals: Vec::new(),
font: None,
zoom: 1,
scripts: scripts.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<HashMap<_,_>>(),
board_script_name: None,
load_errors: Vec::new(),
}
}
/// Stamps a crate cell onto a board.
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
*board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
}
/// Stamps a wall cell onto a board.
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
}
/// Stamps an arbitrary archetype cell onto a board.
pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
*board.get_mut(x, y) = (arch.default_glyph(), arch);
}
#[test]
fn solid_at_reports_wall_object_and_empty() {
let mut board = open_board(4, 1, (3, 0), vec![], &[]);
board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall);
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) {
Some(Solid::Object(id)) => assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0)),
_ => 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;
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 solid_at_reports_player() {
let board = open_board(3, 1, (1, 0), vec![], &[]);
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() {
let board = open_board(3, 2, (0, 0), vec![], &[]);
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() {
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation
assert_eq!(board.get(2, 0).1, Archetype::Empty);
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
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.
let mut board = open_board(4, 1, (2, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
board.push(1, 0, Direction::East);
assert_eq!(board.get(1, 0).1, Archetype::Empty);
assert_eq!(board.get(2, 0).1, Archetype::Crate);
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.
let mut board = open_board(4, 1, (2, 0), vec![], &[]);
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
assert_eq!(board.get(1, 0).1, Archetype::Crate);
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.
let mut board = open_board(3, 1, (2, 0), vec![], &[]);
let floor_glyph = Glyph {
tile: '.' as u32,
fg: Rgba8 { r: 10, g: 20, b: 30, a: 255 },
bg: Rgba8 { r: 1, g: 2, b: 3, a: 255 },
};
board.floor = vec![floor_glyph; 3];
wall_at(&mut board, 0, 0);
assert_eq!(board.glyph_at(0, 0), Archetype::Wall.default_glyph());
assert_eq!(board.glyph_at(1, 0), floor_glyph);
}
#[test]
fn fresh_board_is_valid_and_reports_errors() {
let mut board = open_board(1, 1, (0, 0), vec![], &[]);
assert!(board.is_valid());
board.report_error("something went wrong");
assert!(!board.is_valid());
assert_eq!(board.load_errors.len(), 1);
}
}