refactoring
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user