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, /// 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, /// 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, /// 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, /// Optional font override for this board. When `None`, the app default is used. pub font: Option, /// 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, /// 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, /// 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, } 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) { 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 { // 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 { 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 { 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, scripts: &[(&str, &str)], ) -> Board { let mut object_map: BTreeMap = 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::>(), 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); } }