refactoring
This commit is contained in:
+27
-711
@@ -1,708 +1,10 @@
|
||||
use crate::log::LogLine;
|
||||
use crate::script::{Action, Direction, ScriptHost};
|
||||
use color::Rgba8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cell::{Ref, RefCell, RefMut};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The visual representation of a single board cell.
|
||||
///
|
||||
/// `Glyph` holds everything needed to draw one cell on screen: which tile
|
||||
/// index to display and what colors to use. It is stored per-cell (not per
|
||||
/// archetype), so individual cells can vary their appearance independently.
|
||||
///
|
||||
/// `tile` is a left-to-right, top-to-bottom index into the board's bitmap
|
||||
/// font. For the default CP437 font this matches the ASCII/CP437 code point.
|
||||
///
|
||||
/// `Glyph` values come from the map file palette and are set at load time.
|
||||
/// The player is the only entity whose glyph is hardcoded at runtime
|
||||
/// (see [`Glyph::player`]).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct Glyph {
|
||||
/// Tile index into the board's bitmap font (left-to-right, top-to-bottom).
|
||||
pub tile: u32,
|
||||
/// Foreground color, applied to non-background pixels of the tile.
|
||||
pub fg: Rgba8,
|
||||
/// Background color, drawn as a filled rectangle behind the tile.
|
||||
pub bg: Rgba8,
|
||||
}
|
||||
|
||||
impl Hash for Glyph {
|
||||
/// Hash via packed u32 representations so the impl stays in sync with Eq.
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.tile.hash(state);
|
||||
self.fg.to_u32().hash(state);
|
||||
self.bg.to_u32().hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Glyph {
|
||||
/// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue.
|
||||
///
|
||||
/// This is the only hardcoded glyph; all other glyphs come from the map
|
||||
/// file palette. It will be removed once the player becomes a scripted
|
||||
/// object with its own palette entry.
|
||||
#[rustfmt::skip]
|
||||
pub const fn player() -> Self {
|
||||
Self {
|
||||
tile: 64,
|
||||
fg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, // white
|
||||
bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Specifies a bitmap font for a board.
|
||||
///
|
||||
/// Each board can optionally specify a font image and tile dimensions.
|
||||
/// When absent, the app's default embedded CP437 font is used.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct FontSpec {
|
||||
/// Path to the PNG font image, relative to the working directory.
|
||||
pub path: String,
|
||||
/// Width of each tile in the font image, in pixels.
|
||||
pub tile_w: u32,
|
||||
/// Height of each tile in the font image, in pixels.
|
||||
pub tile_h: u32,
|
||||
}
|
||||
|
||||
/// Which directions a solid may be pushed in.
|
||||
///
|
||||
/// Only meaningful for `solid` cells (a non-solid cell is passable, so nothing is
|
||||
/// ever pushed into it). `Crate` is [`Pushable::Any`]; the directional crates
|
||||
/// constrain pushes to one axis.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Pushable {
|
||||
/// Cannot be pushed.
|
||||
No,
|
||||
/// Pushable in any direction.
|
||||
Any,
|
||||
/// Pushable east/west only.
|
||||
Horizontal,
|
||||
/// Pushable north/south only.
|
||||
Vertical,
|
||||
}
|
||||
|
||||
impl Pushable {
|
||||
/// Whether a push in `dir` is allowed.
|
||||
pub fn allows(self, dir: Direction) -> bool {
|
||||
match self {
|
||||
Pushable::No => false,
|
||||
Pushable::Any => true,
|
||||
Pushable::Horizontal => matches!(dir, Direction::East | Direction::West),
|
||||
Pushable::Vertical => matches!(dir, Direction::North | Direction::South),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The behavioral properties of a board cell at runtime.
|
||||
///
|
||||
/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It
|
||||
/// contains the properties the engine needs to simulate a cell — currently
|
||||
/// solidity, opacity, and pushability. Future properties (shootable, etc.) can
|
||||
/// be added here without changing call sites.
|
||||
///
|
||||
/// 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 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,
|
||||
/// Which directions a mover can shove this cell in (only meaningful when `solid`).
|
||||
pub pushable: Pushable,
|
||||
}
|
||||
|
||||
/// A class of board cell, encoding its default behavior and appearance.
|
||||
///
|
||||
/// `Archetype` is an enum of the element types the engine knows about. Each
|
||||
/// variant provides a default [`Behavior`] (via [`Archetype::behavior`]) and a
|
||||
/// default [`Glyph`] (via [`Archetype::default_glyph`]) used when the editor
|
||||
/// stamps a cell.
|
||||
///
|
||||
/// Map files reference archetypes by [`name`](Archetype::name) (e.g. `"wall"`),
|
||||
/// so the list of variants can be reordered without breaking saved games.
|
||||
///
|
||||
/// ## Object special case
|
||||
///
|
||||
/// `Archetype::Object` currently returns a static default `Behavior`.
|
||||
/// TODO: In the future, Object passability and opacity will come from the cell's Rhai script
|
||||
/// rather than from this enum. [`Board::is_passable`] will need a special branch
|
||||
/// at that point. `ErrorBlock` is used as a sentinel for unrecognized archetype
|
||||
/// names in map files — it should never appear in a valid board.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Archetype {
|
||||
/// An open cell; the player and other entities can pass through it.
|
||||
Empty,
|
||||
/// A solid wall; impassable and opaque.
|
||||
Wall,
|
||||
/// A solid, pushable box. Walking into it shoves it one cell in the same
|
||||
/// direction (cascading through a chain of crates) if there is open space.
|
||||
Crate,
|
||||
/// A crate that can only be pushed east/west (horizontal axis). Glyph ↔.
|
||||
HCrate,
|
||||
/// A crate that can only be pushed north/south (vertical axis). Glyph ↕.
|
||||
VCrate,
|
||||
/// Sentinel for map files that reference an unknown archetype name.
|
||||
/// Renders as a yellow `?` on red to make the error visible in-game.
|
||||
ErrorBlock,
|
||||
}
|
||||
|
||||
impl Archetype {
|
||||
/// Returns the default [`Behavior`] for this archetype.
|
||||
///
|
||||
/// For `Object`, this is a placeholder until Rhai scripts drive the behavior.
|
||||
pub fn behavior(&self) -> Behavior {
|
||||
match self {
|
||||
Archetype::Empty => Behavior {
|
||||
solid: false,
|
||||
opaque: false,
|
||||
pushable: Pushable::No,
|
||||
},
|
||||
Archetype::Wall => Behavior {
|
||||
solid: true,
|
||||
opaque: true,
|
||||
pushable: Pushable::No,
|
||||
},
|
||||
Archetype::Crate => Behavior {
|
||||
solid: true,
|
||||
opaque: true,
|
||||
pushable: Pushable::Any,
|
||||
},
|
||||
Archetype::HCrate => Behavior {
|
||||
solid: true,
|
||||
opaque: true,
|
||||
pushable: Pushable::Horizontal,
|
||||
},
|
||||
Archetype::VCrate => Behavior {
|
||||
solid: true,
|
||||
opaque: true,
|
||||
pushable: Pushable::Vertical,
|
||||
},
|
||||
Archetype::ErrorBlock => Behavior {
|
||||
solid: true,
|
||||
opaque: true,
|
||||
pushable: Pushable::No,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the canonical name used to reference this archetype in map files.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Archetype::Empty => "empty",
|
||||
Archetype::Wall => "wall",
|
||||
Archetype::Crate => "crate",
|
||||
Archetype::HCrate => "hcrate",
|
||||
Archetype::VCrate => "vcrate",
|
||||
Archetype::ErrorBlock => "error_block",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default glyph painted when the editor stamps this archetype.
|
||||
///
|
||||
/// This glyph is used only for new cells created in the editor; existing
|
||||
/// cells retain their own per-cell glyph.
|
||||
#[rustfmt::skip]
|
||||
pub fn default_glyph(&self) -> Glyph {
|
||||
match self {
|
||||
Archetype::Empty => Glyph {
|
||||
tile: 32,
|
||||
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
||||
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
||||
},
|
||||
Archetype::Wall => Glyph {
|
||||
tile: 35,
|
||||
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
|
||||
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 },
|
||||
},
|
||||
Archetype::Crate => Glyph {
|
||||
tile: 254, // CP437 ■ (small filled square)
|
||||
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // light gray on black
|
||||
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
||||
},
|
||||
Archetype::HCrate => Glyph {
|
||||
tile: 29, // CP437 ↔ (left-right arrow) — pushable east/west
|
||||
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
|
||||
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
||||
},
|
||||
Archetype::VCrate => Glyph {
|
||||
tile: 18, // CP437 ↕ (up-down arrow) — pushable north/south
|
||||
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
|
||||
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
||||
},
|
||||
// Visually distinct so malformed map files are immediately obvious.
|
||||
Archetype::ErrorBlock => Glyph {
|
||||
tile: 63,
|
||||
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow on red
|
||||
bg: Rgba8 { r: 255, g: 0, b: 0, a: 255 },
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Archetype {
|
||||
type Error = String;
|
||||
|
||||
/// Parses an archetype by its map-file name.
|
||||
///
|
||||
/// Returns an error for unrecognized names; the caller should substitute
|
||||
/// [`Archetype::ErrorBlock`] and log the error so the problem is visible.
|
||||
fn try_from(name: &str) -> Result<Self, Self::Error> {
|
||||
match name {
|
||||
"empty" => Ok(Archetype::Empty),
|
||||
"wall" => Ok(Archetype::Wall),
|
||||
"crate" => Ok(Archetype::Crate),
|
||||
"hcrate" => Ok(Archetype::HCrate),
|
||||
"vcrate" => Ok(Archetype::VCrate),
|
||||
// "object" is intentionally absent: objects are not valid palette
|
||||
// entries in map files. They live in [[objects]] with their own glyph.
|
||||
_ => Err(format!("unknown archetype: {name}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The archetypes available for placement in the editor, in display order.
|
||||
///
|
||||
/// `ErrorBlock` is excluded — it is a sentinel for load errors, not a valid
|
||||
/// editing choice.
|
||||
pub const ALL_ARCHETYPES: &[Archetype] = &[
|
||||
Archetype::Empty,
|
||||
Archetype::Wall,
|
||||
Archetype::Crate,
|
||||
Archetype::HCrate,
|
||||
Archetype::VCrate,
|
||||
];
|
||||
|
||||
/// A stable, unique identifier for a board object.
|
||||
///
|
||||
/// Ids are handed out by [`Board::add_object`] from the per-board
|
||||
/// [`Board::next_object_id`] counter (starting at 1) and never reused, so a
|
||||
/// reference to an object stays valid even as other objects are added or removed.
|
||||
/// This replaces the old "index into `Board::objects`" scheme, which shifted when
|
||||
/// objects were reordered/destroyed.
|
||||
pub type ObjectId = u32;
|
||||
|
||||
/// A scripted object placed on the board, loaded from a map file.
|
||||
///
|
||||
/// `ObjectDef` represents a tile that has Rhai script attached to it.
|
||||
/// Scripts can respond to events like the player touching or shooting the
|
||||
/// tile. Objects are parsed from `[[objects]]` entries in `.toml` map files
|
||||
/// and stored on [`Board`].
|
||||
///
|
||||
/// Objects are rendered as an overlay on top of the board grid — the grid
|
||||
/// cell at `(x, y)` holds the background (floor) that is revealed if the
|
||||
/// object moves away. `glyph` is owned by the object itself and may be
|
||||
/// mutated by its Rhai script at runtime.
|
||||
///
|
||||
/// Script text lives in [`Board::scripts`]; this struct holds only the name
|
||||
/// used to look it up. Two `ObjectDef`s with the same `script_name` share
|
||||
/// source text but run with independent Rhai scopes (see [`crate::script`]).
|
||||
///
|
||||
/// Scripts are executed by [`crate::script::ScriptHost`]: an object's optional
|
||||
/// `init()` and `tick(dt)` functions are called via [`GameState::run_init`] and
|
||||
/// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work.
|
||||
pub struct ObjectDef {
|
||||
/// Column of this object on the board (0-indexed).
|
||||
pub x: usize,
|
||||
/// Row of this object on the board (0-indexed).
|
||||
pub y: usize,
|
||||
/// 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 / 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>,
|
||||
}
|
||||
|
||||
impl ObjectDef {
|
||||
/// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black.
|
||||
#[rustfmt::skip]
|
||||
pub fn default_glyph() -> Glyph {
|
||||
Glyph {
|
||||
tile: 63,
|
||||
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow
|
||||
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new object at `(x, y)` with default glyph and blocking behavior.
|
||||
///
|
||||
/// 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(),
|
||||
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 — the player, 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 player occupies the cell. The player is solid (it blocks movers) and
|
||||
/// pushable in any direction (see [`Board::is_pushable`]).
|
||||
Player,
|
||||
/// 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
|
||||
/// stored on [`Board`]. When the player steps onto a portal's cell, they
|
||||
/// should be moved to `target_entry` on the board named by `target_map`.
|
||||
///
|
||||
/// **Not yet runtime-wired.** Portal navigation (multi-board loading and
|
||||
/// switching) is a future feature.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct PortalDef {
|
||||
/// Column of this portal on the board (0-indexed).
|
||||
pub x: usize,
|
||||
/// Row of this portal on the board (0-indexed).
|
||||
pub y: usize,
|
||||
/// File name (without extension) of the target board, e.g. `"cave"`.
|
||||
pub target_map: String,
|
||||
/// Named entry point on the target board where the player arrives.
|
||||
pub target_entry: String,
|
||||
}
|
||||
|
||||
/// The player's current position on the board.
|
||||
///
|
||||
/// The player is currently a special entity rendered on top of the board
|
||||
/// rather than being stored as a board cell. This is expected to change:
|
||||
/// the player will eventually become a scripted object that responds to
|
||||
/// input events, at which point this struct may be removed or made optional.
|
||||
/// See the "player may become an object" notes in `CLAUDE.md` for the design
|
||||
/// tensions this implies.
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Player {
|
||||
/// Column position (0-indexed, increasing rightward).
|
||||
pub x: i32,
|
||||
/// Row position (0-indexed, increasing downward).
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// 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::display_glyph`].
|
||||
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 to draw for the **static layer** of cell `(x, y)` — the
|
||||
/// grid archetype, or the floor underneath an [`Archetype::Empty`] cell.
|
||||
///
|
||||
/// This is the single source of truth front-ends use for the non-object,
|
||||
/// non-player layer: a wall/crate draws its own per-cell glyph, while an empty
|
||||
/// cell draws its [`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 display_glyph(&self, x: usize, y: usize) -> Glyph {
|
||||
let (glyph, arch) = self.get(x, y);
|
||||
if *arch == Archetype::Empty {
|
||||
self.floor[y * self.width + x]
|
||||
} else {
|
||||
*glyph
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// The nonfatal errors collected while loading this board, newest last.
|
||||
pub fn load_errors(&self) -> &[LogLine] {
|
||||
&self.load_errors
|
||||
}
|
||||
|
||||
/// Returns a slice of all cells in row-major order.
|
||||
///
|
||||
/// Useful for iterating over the full board (e.g. to collect unique glyphs).
|
||||
pub fn cells(&self) -> &[(Glyph, Archetype)] {
|
||||
&self.cells
|
||||
}
|
||||
|
||||
/// Returns the single solid entity occupying `(x, y)`, if any.
|
||||
///
|
||||
/// 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<'_>> {
|
||||
// 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(obj) = self.solid_object_at(x, y)
|
||||
{
|
||||
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 {
|
||||
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(o)) => o.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_at(&self, x: usize, y: usize) -> Option<&ObjectDef> {
|
||||
self.objects.values().find(|o| o.x == x && o.y == y && o.solid)
|
||||
}
|
||||
|
||||
/// 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(|(id, o)| o.x == x && o.y == y && o.solid)
|
||||
.map(|(&id, _)| id)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
use crate::board::Board;
|
||||
use crate::utils::{ObjectId, Solid};
|
||||
|
||||
/// Holds the active game world and provides game-logic operations.
|
||||
///
|
||||
@@ -887,6 +189,15 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use color::Rgba8;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::{ObjectId, Player, Pushable, Solid};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Builds a 1×1 board with a single object that optionally references a
|
||||
@@ -996,7 +307,10 @@ mod tests {
|
||||
|
||||
// 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)),
|
||||
Some(Solid::Object(id)) => {
|
||||
let o = &board.objects[&id];
|
||||
assert_eq!((o.x, o.y), (2, 0))
|
||||
},
|
||||
_ => panic!("expected Solid::Object"),
|
||||
}
|
||||
assert!(!board.is_passable(2, 0));
|
||||
@@ -1216,7 +530,7 @@ mod tests {
|
||||
assert!(board.is_valid());
|
||||
board.report_error("something went wrong");
|
||||
assert!(!board.is_valid());
|
||||
assert_eq!(board.load_errors().len(), 1);
|
||||
assert_eq!(board.load_errors.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1603,10 +917,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_glyph_uses_floor_for_empty_and_grid_for_solid() {
|
||||
fn glyph_at_uses_floor_for_empty_and_grid_for_solid() {
|
||||
// A board with a distinctive floor glyph; empty cells show the floor, while
|
||||
// a wall keeps drawing its own grid glyph (floor ignored under solids).
|
||||
let mut board = open_board(2, 1, (1, 0), vec![], &[]);
|
||||
// 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 {
|
||||
@@ -1622,15 +937,15 @@ mod tests {
|
||||
a: 255,
|
||||
},
|
||||
};
|
||||
board.floor = vec![floor_glyph; 2];
|
||||
board.floor = vec![floor_glyph; 3];
|
||||
wall_at(&mut board, 0, 0);
|
||||
assert_eq!(board.display_glyph(0, 0), Archetype::Wall.default_glyph()); // solid: grid glyph
|
||||
assert_eq!(board.display_glyph(1, 0), floor_glyph); // empty: floor glyph
|
||||
assert_eq!(board.glyph_at(0, 0), Archetype::Wall.default_glyph()); // solid: grid glyph
|
||||
assert_eq!(board.glyph_at(1, 0), floor_glyph); // empty: floor glyph
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_a_crate_reveals_the_floor_underneath() {
|
||||
// The cell a crate is pushed off of becomes Empty, so its `display_glyph`
|
||||
// The cell a crate is pushed off of becomes Empty, so its `glyph_at`
|
||||
// should show the floor glyph rather than black-on-black.
|
||||
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
||||
let floor_glyph = Glyph {
|
||||
@@ -1654,9 +969,10 @@ mod tests {
|
||||
game.try_move(Direction::East); // player at (0,0) pushes the crate east
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate); // crate moved one east
|
||||
// The vacated cell (1,0) is now Empty and reveals the floor.
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.display_glyph(1, 0), floor_glyph);
|
||||
// After the push the player is at (1,0); check the cell the player vacated
|
||||
// (0,0) — it is Empty and must reveal the floor glyph, not black-on-black.
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty); // crate's old cell is empty
|
||||
assert_eq!(b.glyph_at(0, 0), floor_glyph); // player's old cell shows floor
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user