2026-06-03 22:46:54 -05:00
|
|
|
|
use crate::log::LogLine;
|
2026-06-06 17:36:00 -05:00
|
|
|
|
use crate::script::{Action, Direction, ScriptHost};
|
2026-05-30 20:20:09 -05:00
|
|
|
|
use color::Rgba8;
|
2026-05-30 18:48:41 -05:00
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-06-04 23:52:33 -05:00
|
|
|
|
use std::cell::{Ref, RefCell, RefMut};
|
2026-05-30 18:48:41 -05:00
|
|
|
|
use std::collections::HashMap;
|
2026-05-30 20:20:09 -05:00
|
|
|
|
use std::hash::{Hash, Hasher};
|
2026-06-04 23:52:33 -05:00
|
|
|
|
use std::rc::Rc;
|
2026-06-04 20:11:55 -05:00
|
|
|
|
use std::time::Duration;
|
2026-05-16 01:10:04 -05:00
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// The visual representation of a single board cell.
|
|
|
|
|
|
///
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// `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.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// `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`]).
|
2026-06-06 18:49:45 -05:00
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
2026-05-16 01:10:04 -05:00
|
|
|
|
pub struct Glyph {
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// 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.
|
2026-05-30 20:20:09 -05:00
|
|
|
|
pub fg: Rgba8,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// Background color, drawn as a filled rectangle behind the tile.
|
2026-05-30 20:20:09 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Glyph {
|
2026-06-03 22:46:54 -05:00
|
|
|
|
/// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// 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.
|
2026-06-04 23:52:33 -05:00
|
|
|
|
#[rustfmt::skip]
|
2026-05-19 00:07:04 -05:00
|
|
|
|
pub const fn player() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
tile: 64,
|
2026-06-03 22:46:54 -05:00
|
|
|
|
fg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, // white
|
|
|
|
|
|
bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue
|
2026-05-19 00:07:04 -05:00
|
|
|
|
}
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// 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),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 15:20:46 -05:00
|
|
|
|
/// The behavioral properties of a board cell at runtime.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
2026-05-17 15:20:46 -05:00
|
|
|
|
/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It
|
|
|
|
|
|
/// contains the properties the engine needs to simulate a cell — currently
|
2026-06-06 01:37:19 -05:00
|
|
|
|
/// solidity, opacity, and pushability. Future properties (shootable, etc.) can
|
2026-05-17 15:20:46 -05:00
|
|
|
|
/// be added here without changing call sites.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
2026-06-06 01:37:19 -05:00
|
|
|
|
/// For scripted objects, solidity and opacity are stored directly on
|
2026-05-30 21:48:40 -05:00
|
|
|
|
/// [`ObjectDef`] and will eventually be overridable by Rhai scripts at runtime.
|
2026-05-17 15:20:46 -05:00
|
|
|
|
#[derive(Copy, Clone, Debug)]
|
|
|
|
|
|
pub struct Behavior {
|
2026-06-06 01:37:19 -05:00
|
|
|
|
/// 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,
|
2026-05-17 15:20:46 -05:00
|
|
|
|
/// Whether this cell blocks line of sight (reserved for future rendering).
|
|
|
|
|
|
pub opaque: bool,
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// Which directions a mover can shove this cell in (only meaningful when `solid`).
|
|
|
|
|
|
pub pushable: Pushable,
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 15:20:46 -05:00
|
|
|
|
/// 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.
|
2026-05-30 18:48:41 -05:00
|
|
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
2026-05-17 15:20:46 -05:00
|
|
|
|
pub enum Archetype {
|
|
|
|
|
|
/// An open cell; the player and other entities can pass through it.
|
|
|
|
|
|
Empty,
|
|
|
|
|
|
/// A solid wall; impassable and opaque.
|
|
|
|
|
|
Wall,
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// 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,
|
2026-05-17 15:20:46 -05:00
|
|
|
|
/// 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 {
|
2026-05-19 00:07:04 -05:00
|
|
|
|
Archetype::Empty => Behavior {
|
2026-06-06 01:37:19 -05:00
|
|
|
|
solid: false,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
opaque: false,
|
2026-06-06 14:11:20 -05:00
|
|
|
|
pushable: Pushable::No,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
},
|
|
|
|
|
|
Archetype::Wall => Behavior {
|
2026-06-06 01:37:19 -05:00
|
|
|
|
solid: true,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
opaque: true,
|
2026-06-06 14:11:20 -05:00
|
|
|
|
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,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
},
|
|
|
|
|
|
Archetype::ErrorBlock => Behavior {
|
2026-06-06 01:37:19 -05:00
|
|
|
|
solid: true,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
opaque: true,
|
2026-06-06 14:11:20 -05:00
|
|
|
|
pushable: Pushable::No,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
},
|
2026-05-17 15:20:46 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns the canonical name used to reference this archetype in map files.
|
|
|
|
|
|
pub fn name(&self) -> &'static str {
|
|
|
|
|
|
match self {
|
2026-05-19 00:07:04 -05:00
|
|
|
|
Archetype::Empty => "empty",
|
|
|
|
|
|
Archetype::Wall => "wall",
|
2026-06-06 14:11:20 -05:00
|
|
|
|
Archetype::Crate => "crate",
|
|
|
|
|
|
Archetype::HCrate => "hcrate",
|
|
|
|
|
|
Archetype::VCrate => "vcrate",
|
2026-05-17 15:20:46 -05:00
|
|
|
|
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.
|
2026-06-04 23:52:33 -05:00
|
|
|
|
#[rustfmt::skip]
|
2026-05-17 15:20:46 -05:00
|
|
|
|
pub fn default_glyph(&self) -> Glyph {
|
|
|
|
|
|
match self {
|
2026-05-19 00:07:04 -05:00
|
|
|
|
Archetype::Empty => Glyph {
|
|
|
|
|
|
tile: 32,
|
2026-05-30 20:20:09 -05:00
|
|
|
|
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
|
|
|
|
|
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
2026-05-19 00:07:04 -05:00
|
|
|
|
},
|
|
|
|
|
|
Archetype::Wall => Glyph {
|
|
|
|
|
|
tile: 35,
|
2026-05-30 20:20:09 -05:00
|
|
|
|
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
|
|
|
|
|
|
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 },
|
2026-05-19 00:07:04 -05:00
|
|
|
|
},
|
2026-06-06 14:11:20 -05:00
|
|
|
|
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 },
|
|
|
|
|
|
},
|
2026-05-17 15:20:46 -05:00
|
|
|
|
// Visually distinct so malformed map files are immediately obvious.
|
2026-05-19 00:07:04 -05:00
|
|
|
|
Archetype::ErrorBlock => Glyph {
|
|
|
|
|
|
tile: 63,
|
2026-05-30 20:20:09 -05:00
|
|
|
|
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow on red
|
|
|
|
|
|
bg: Rgba8 { r: 255, g: 0, b: 0, a: 255 },
|
2026-05-19 00:07:04 -05:00
|
|
|
|
},
|
2026-05-17 15:20:46 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-05-19 00:07:04 -05:00
|
|
|
|
"empty" => Ok(Archetype::Empty),
|
|
|
|
|
|
"wall" => Ok(Archetype::Wall),
|
2026-06-06 14:11:20 -05:00
|
|
|
|
"crate" => Ok(Archetype::Crate),
|
|
|
|
|
|
"hcrate" => Ok(Archetype::HCrate),
|
|
|
|
|
|
"vcrate" => Ok(Archetype::VCrate),
|
2026-05-30 19:25:10 -05:00
|
|
|
|
// "object" is intentionally absent: objects are not valid palette
|
|
|
|
|
|
// entries in map files. They live in [[objects]] with their own glyph.
|
2026-05-19 00:07:04 -05:00
|
|
|
|
_ => Err(format!("unknown archetype: {name}")),
|
2026-05-17 15:20:46 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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.
|
2026-06-06 14:11:20 -05:00
|
|
|
|
pub const ALL_ARCHETYPES: &[Archetype] = &[
|
|
|
|
|
|
Archetype::Empty,
|
|
|
|
|
|
Archetype::Wall,
|
|
|
|
|
|
Archetype::Crate,
|
|
|
|
|
|
Archetype::HCrate,
|
|
|
|
|
|
Archetype::VCrate,
|
|
|
|
|
|
];
|
2026-05-17 15:20:46 -05:00
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// 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`].
|
|
|
|
|
|
///
|
2026-05-30 19:25:10 -05:00
|
|
|
|
/// 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.
|
|
|
|
|
|
///
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// 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
|
2026-06-04 20:11:55 -05:00
|
|
|
|
/// source text but run with independent Rhai scopes (see [`crate::script`]).
|
2026-05-30 18:48:41 -05:00
|
|
|
|
///
|
2026-06-04 20:11:55 -05:00
|
|
|
|
/// 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.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub struct ObjectDef {
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Column of this object on the board (0-indexed).
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub x: usize,
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Row of this object on the board (0-indexed).
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub y: usize,
|
2026-05-30 19:25:10 -05:00
|
|
|
|
/// 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,
|
2026-06-06 01:37:19 -05:00
|
|
|
|
/// 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,
|
2026-05-30 21:22:36 -05:00
|
|
|
|
/// Whether the object blocks line of sight / FOV for the player
|
|
|
|
|
|
pub opaque: bool,
|
2026-06-06 01:37:19 -05:00
|
|
|
|
/// Whether the object can be shoved by a mover. Unused for now (no push
|
|
|
|
|
|
/// mechanic yet); defaults to `false`.
|
|
|
|
|
|
pub pushable: bool,
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// 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>,
|
2026-05-16 01:50:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 21:22:36 -05:00
|
|
|
|
impl ObjectDef {
|
2026-05-30 21:48:40 -05:00
|
|
|
|
/// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black.
|
2026-06-04 23:52:33 -05:00
|
|
|
|
#[rustfmt::skip]
|
2026-05-30 21:22:36 -05:00
|
|
|
|
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 },
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-30 21:48:40 -05:00
|
|
|
|
|
|
|
|
|
|
/// Creates a new object at `(x, y)` with default glyph and blocking behavior.
|
|
|
|
|
|
///
|
2026-06-06 01:37:19 -05:00
|
|
|
|
/// 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.
|
2026-05-30 21:48:40 -05:00
|
|
|
|
pub fn new(x: usize, y: usize) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
glyph: Self::default_glyph(),
|
2026-06-06 01:37:19 -05:00
|
|
|
|
solid: true,
|
2026-05-30 21:48:40 -05:00
|
|
|
|
opaque: true,
|
2026-06-06 01:37:19 -05:00
|
|
|
|
pushable: false,
|
2026-05-30 21:48:40 -05:00
|
|
|
|
script_name: None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-30 21:22:36 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 01:37:19 -05:00
|
|
|
|
/// The single solid occupant of a board cell, returned by [`Board::solid_at`].
|
|
|
|
|
|
///
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// 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.
|
2026-06-06 01:37:19 -05:00
|
|
|
|
pub enum Solid<'a> {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// The player occupies the cell. The player is solid (it blocks movers) and
|
|
|
|
|
|
/// pushable in any direction (see [`Board::is_pushable`]).
|
|
|
|
|
|
Player,
|
2026-06-06 01:37:19 -05:00
|
|
|
|
/// The cell's grid archetype is itself solid (e.g. [`Archetype::Wall`]).
|
|
|
|
|
|
Cell(Archetype),
|
|
|
|
|
|
/// A solid [`ObjectDef`] occupies the cell.
|
|
|
|
|
|
Object(&'a ObjectDef),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// 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.
|
2026-05-30 18:48:41 -05:00
|
|
|
|
#[derive(Deserialize, Serialize)]
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub struct PortalDef {
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Column of this portal on the board (0-indexed).
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub x: usize,
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Row of this portal on the board (0-indexed).
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub y: usize,
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// File name (without extension) of the target board, e.g. `"cave"`.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub target_map: String,
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Named entry point on the target board where the player arrives.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub target_entry: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// 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.
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// See the "player may become an object" notes in `CLAUDE.md` for the design
|
|
|
|
|
|
/// tensions this implies.
|
2026-05-17 16:49:52 -05:00
|
|
|
|
#[derive(Copy, Clone)]
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub struct Player {
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Column position (0-indexed, increasing rightward).
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub x: i32,
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Row position (0-indexed, increasing downward).
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub y: i32,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:48:52 -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:
|
|
|
|
|
|
///
|
2026-05-17 15:20:46 -05:00
|
|
|
|
/// - A grid of cells, each with a visual representation ([`Glyph`]) and an [`Archetype`]
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// - The current player position
|
|
|
|
|
|
/// - Scripted objects and portals (loaded but not yet active)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// ## Cell storage
|
|
|
|
|
|
///
|
2026-05-17 15:20:46 -05:00
|
|
|
|
/// 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.
|
2026-05-16 01:10:04 -05:00
|
|
|
|
pub struct Board {
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// Human-readable name for this board, loaded from the map file and round-tripped on save.
|
|
|
|
|
|
pub name: String,
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Width of the board in cells.
|
2026-05-16 01:10:04 -05:00
|
|
|
|
pub width: usize,
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Height of the board in cells.
|
2026-05-16 01:10:04 -05:00
|
|
|
|
pub height: usize,
|
2026-05-17 15:20:46 -05:00
|
|
|
|
/// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to
|
|
|
|
|
|
/// access by `(x, y)` coordinates.
|
|
|
|
|
|
pub(crate) cells: Vec<(Glyph, Archetype)>,
|
2026-06-06 18:49:45 -05:00
|
|
|
|
/// 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>,
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Current player position. See [`Player`] for caveats about its future.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub player: Player,
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Scripted objects on this board. Parsed from the map file; not yet active.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub objects: Vec<ObjectDef>,
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Portals on this board. Parsed from the map file; not yet active.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub portals: Vec<PortalDef>,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// Optional font override for this board. When `None`, the app default is used.
|
|
|
|
|
|
pub font: Option<FontSpec>,
|
2026-05-31 23:51:51 -05:00
|
|
|
|
/// Integer scale factor applied to tiles when rendering this board. 1 = natural size.
|
|
|
|
|
|
pub zoom: u32,
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// 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>,
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// 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-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Board {
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Returns a reference to the cell at `(x, y)`.
|
|
|
|
|
|
///
|
2026-05-17 15:20:46 -05:00
|
|
|
|
/// 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) {
|
2026-05-16 01:10:04 -05:00
|
|
|
|
&self.cells[y * self.width + x]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Returns a mutable reference to the cell at `(x, y)`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Panics if `x` or `y` are out of bounds.
|
2026-05-17 15:20:46 -05:00
|
|
|
|
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
|
2026-05-16 01:10:04 -05:00
|
|
|
|
&mut self.cells[y * self.width + x]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:49:45 -05:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 14:11:20 -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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The nonfatal errors collected while loading this board, newest last.
|
|
|
|
|
|
pub fn load_errors(&self) -> &[LogLine] {
|
|
|
|
|
|
&self.load_errors
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 20:20:09 -05:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 01:37:19 -05:00
|
|
|
|
/// Returns the single solid entity occupying `(x, y)`, if any.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
2026-06-06 01:37:19 -05:00
|
|
|
|
/// 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`.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Panics if `x` or `y` are out of bounds.
|
2026-06-06 01:37:19 -05:00
|
|
|
|
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid<'_>> {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
// 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-06 01:37:19 -05:00
|
|
|
|
// A solid object shadows the grid cell it sits on.
|
2026-06-04 23:52:33 -05:00
|
|
|
|
if let Some(obj) = self.object_at(x, y)
|
2026-06-06 01:37:19 -05:00
|
|
|
|
&& obj.solid
|
2026-06-04 23:52:33 -05:00
|
|
|
|
{
|
2026-06-06 01:37:19 -05:00
|
|
|
|
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));
|
2026-05-30 19:25:10 -05:00
|
|
|
|
}
|
2026-06-06 01:37:19 -05:00
|
|
|
|
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()
|
2026-05-30 19:25:10 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// 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) {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
Some(Solid::Player) => true, // the player is pushable in any direction
|
2026-06-06 14:11:20 -05:00
|
|
|
|
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);
|
2026-06-06 17:36:00 -05:00
|
|
|
|
// 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(idx) = self.object_index_at(x, y)
|
2026-06-06 14:11:20 -05:00
|
|
|
|
&& self.objects[idx].solid
|
|
|
|
|
|
{
|
|
|
|
|
|
self.objects[idx].x = tx;
|
|
|
|
|
|
self.objects[idx].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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 19:25:10 -05:00
|
|
|
|
/// Returns the index into [`Board::objects`] of the object at `(x, y)`, if any.
|
2026-05-30 21:22:36 -05:00
|
|
|
|
pub fn object_index_at(&self, x: usize, y: usize) -> Option<usize> {
|
2026-05-30 19:25:10 -05:00
|
|
|
|
self.objects.iter().position(|o| o.x == x && o.y == y)
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
2026-05-30 21:22:36 -05:00
|
|
|
|
|
|
|
|
|
|
/// Returns a borrow of the actual object at `(x, y)` if any
|
|
|
|
|
|
pub fn object_at(&self, x: usize, y: usize) -> Option<&ObjectDef> {
|
|
|
|
|
|
self.object_index_at(x, y).map(|idx| &self.objects[idx])
|
|
|
|
|
|
}
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
|
/// Holds the active game world and provides game-logic operations.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// `GameState` is the boundary between the engine (rendering, input) and the
|
2026-06-05 00:12:57 -05:00
|
|
|
|
/// game data. It owns the board (behind a shared `Rc<RefCell<Board>>`),
|
2026-06-04 23:52:33 -05:00
|
|
|
|
/// the message log, and the [`ScriptHost`] driving object scripts. Front-ends and
|
|
|
|
|
|
/// internal logic reach the board through [`board`](GameState::board) /
|
|
|
|
|
|
/// [`board_mut`](GameState::board_mut). Scripts read the board directly and
|
|
|
|
|
|
/// request mutations as commands, applied by [`apply_commands`](GameState::apply_commands)
|
|
|
|
|
|
/// after each script batch.
|
2026-05-16 01:10:04 -05:00
|
|
|
|
pub struct GameState {
|
2026-06-04 23:52:33 -05:00
|
|
|
|
/// The scriptable world, shared with the script host's read getters.
|
2026-06-05 00:12:57 -05:00
|
|
|
|
board: Rc<RefCell<Board>>,
|
2026-06-03 22:46:54 -05:00
|
|
|
|
/// The in-game message log, oldest first (newest pushed at the end).
|
|
|
|
|
|
pub log: Vec<LogLine>,
|
2026-06-04 20:11:55 -05:00
|
|
|
|
/// The Rhai scripting runtime driving this board's scripted objects.
|
|
|
|
|
|
scripts: ScriptHost,
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl GameState {
|
2026-05-17 12:48:52 -05:00
|
|
|
|
/// Creates a `GameState` from a pre-loaded [`Board`].
|
2026-06-04 20:11:55 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// Compiles the board's object scripts but does **not** run any of them;
|
|
|
|
|
|
/// call [`GameState::run_init`] once the game is ready to start. Any script
|
|
|
|
|
|
/// compile errors are surfaced into the log here.
|
2026-05-16 01:50:05 -05:00
|
|
|
|
pub fn new(board: Board) -> Self {
|
2026-06-05 00:12:57 -05:00
|
|
|
|
let board = Rc::new(RefCell::new(board));
|
|
|
|
|
|
let scripts = ScriptHost::new(&board);
|
2026-06-04 20:11:55 -05:00
|
|
|
|
let mut state = Self {
|
2026-06-05 00:12:57 -05:00
|
|
|
|
board,
|
2026-06-03 22:46:54 -05:00
|
|
|
|
log: Vec::new(),
|
2026-06-04 20:11:55 -05:00
|
|
|
|
scripts,
|
|
|
|
|
|
};
|
|
|
|
|
|
// Surface any compile-time script errors collected during ScriptHost::new.
|
2026-06-06 17:36:00 -05:00
|
|
|
|
state.drain_errors();
|
2026-06-04 20:11:55 -05:00
|
|
|
|
state
|
2026-06-03 22:46:54 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
|
/// Borrows the active board for reading (e.g. by a front-end renderer).
|
|
|
|
|
|
pub fn board(&self) -> Ref<'_, Board> {
|
2026-06-05 00:12:57 -05:00
|
|
|
|
self.board.borrow()
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Borrows the active board for mutation.
|
|
|
|
|
|
pub fn board_mut(&self) -> RefMut<'_, Board> {
|
2026-06-05 00:12:57 -05:00
|
|
|
|
self.board.borrow_mut()
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 22:46:54 -05:00
|
|
|
|
/// Appends a styled message to the log.
|
|
|
|
|
|
pub fn log(&mut self, line: LogLine) {
|
|
|
|
|
|
self.log.push(line);
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// Runs the `init()` hook of every scripted object, pumps their queues, and
|
|
|
|
|
|
/// resolves the resulting actions. Call once, after the whole map is loaded and
|
|
|
|
|
|
/// the game is about to start — never during map deserialization, since a script
|
|
|
|
|
|
/// may inspect the board.
|
2026-06-04 20:11:55 -05:00
|
|
|
|
pub fn run_init(&mut self) {
|
|
|
|
|
|
self.scripts.run_init();
|
2026-06-06 17:36:00 -05:00
|
|
|
|
self.resolve();
|
2026-06-04 20:11:55 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
|
|
|
|
|
|
/// Called once per frame by the front-end's game loop. Counts down object
|
|
|
|
|
|
/// cooldowns, drives every scripted object's `tick(dt)` hook (pumping each queue),
|
|
|
|
|
|
/// then resolves the actions that were promoted onto the board queue.
|
2026-06-03 23:21:05 -05:00
|
|
|
|
pub fn tick(&mut self, dt: Duration) {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
let secs = dt.as_secs_f64();
|
|
|
|
|
|
self.scripts.advance_timers(secs);
|
|
|
|
|
|
self.scripts.run_tick(secs);
|
|
|
|
|
|
self.resolve();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Drains errors collected by the script host into the game log.
|
|
|
|
|
|
fn drain_errors(&mut self) {
|
|
|
|
|
|
// TODO: errors are only logged for now. This is the place to halt execution /
|
|
|
|
|
|
// set an error state when a script faults.
|
|
|
|
|
|
let errors = self.scripts.take_errors();
|
|
|
|
|
|
self.log.extend(errors);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Resolves the board queue: applies each promoted action to the board, then fires
|
|
|
|
|
|
/// the `bump` hooks the moves triggered.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Done in two phases so no `board_mut` borrow is held while `bump` scripts run
|
|
|
|
|
|
/// (they read the board through its getters): phase A mutates the board and records
|
|
|
|
|
|
/// `(bumped_object, bumper)` pairs; phase B fires the bumps after the borrow drops.
|
|
|
|
|
|
fn resolve(&mut self) {
|
|
|
|
|
|
let actions = self.scripts.take_board_queue();
|
|
|
|
|
|
// Logs are collected here rather than pushed inline, since the board borrow
|
|
|
|
|
|
// below also borrows `self`.
|
|
|
|
|
|
let mut logs: Vec<LogLine> = Vec::new();
|
|
|
|
|
|
let mut bumps: Vec<(usize, i64)> = Vec::new();
|
|
|
|
|
|
{
|
|
|
|
|
|
let mut board = self.board_mut();
|
|
|
|
|
|
for ba in actions {
|
|
|
|
|
|
match ba.action {
|
|
|
|
|
|
Action::Move(dir) => {
|
|
|
|
|
|
if let Some(bumped) = step_object(&mut board, ba.source, dir) {
|
|
|
|
|
|
bumps.push((bumped, ba.source as i64));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Action::SetTile(tile) => {
|
|
|
|
|
|
if let Some(obj) = board.objects.get_mut(ba.source) {
|
|
|
|
|
|
obj.glyph.tile = tile;
|
|
|
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
Action::Log(line) => logs.push(line),
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
self.log.extend(logs);
|
|
|
|
|
|
for (bumped, bumper) in bumps {
|
|
|
|
|
|
self.scripts.run_bump(bumped, bumper);
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
self.drain_errors();
|
2026-06-03 23:21:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// Attempts to move the player one cell in `dir`.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// The move is ignored if the target cell is out of bounds, or it is neither
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the
|
|
|
|
|
|
/// caller does not need to check). If the target cell holds a solid object, that
|
|
|
|
|
|
/// object's `bump(-1)` hook fires (whether or not the player ends up moving).
|
2026-06-06 14:11:20 -05:00
|
|
|
|
pub fn try_move(&mut self, dir: Direction) {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
let bumped;
|
|
|
|
|
|
{
|
|
|
|
|
|
let (dx, dy): (i32, i32) = dir.into();
|
|
|
|
|
|
let mut board = self.board_mut();
|
|
|
|
|
|
let target = (board.player.x + dx, board.player.y + dy);
|
|
|
|
|
|
if !board.in_bounds(target) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let (nx, ny) = (target.0 as usize, target.1 as usize);
|
|
|
|
|
|
// A solid object in the way is bumped by the player (id -1).
|
|
|
|
|
|
bumped = match board.solid_at(nx, ny) {
|
|
|
|
|
|
Some(Solid::Object(_)) => board.object_index_at(nx, ny),
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
};
|
|
|
|
|
|
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
|
|
|
|
|
|
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
|
|
|
|
|
|
board.player.x = nx as i32;
|
|
|
|
|
|
board.player.y = ny as i32;
|
|
|
|
|
|
}
|
2026-06-06 14:11:20 -05:00
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
if let Some(idx) = bumped {
|
|
|
|
|
|
self.scripts.run_bump(idx, -1);
|
|
|
|
|
|
self.drain_errors();
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-19 00:07:04 -05:00
|
|
|
|
}
|
2026-06-03 23:21:05 -05:00
|
|
|
|
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// Moves object `idx` one cell in `dir` on `board`, returning the index of a solid
|
|
|
|
|
|
/// object it bumped (its target cell was occupied by that object), if any.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The move itself proceeds only if the target is in bounds and either passable or a
|
|
|
|
|
|
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
|
|
|
|
|
|
/// bump is recorded whenever a solid object occupies the target — whether it gets
|
|
|
|
|
|
/// pushed aside or blocks the move — since something tried to move into it. Walls and
|
|
|
|
|
|
/// crates carry no script, so only solid objects yield a bump.
|
|
|
|
|
|
fn step_object(board: &mut Board, idx: usize, dir: Direction) -> Option<usize> {
|
|
|
|
|
|
let (dx, dy): (i32, i32) = dir.into();
|
|
|
|
|
|
let (ox, oy) = board.objects.get(idx).map(|o| (o.x, o.y))?;
|
|
|
|
|
|
let target = (ox as i32 + dx, oy as i32 + dy);
|
|
|
|
|
|
if !board.in_bounds(target) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let (nx, ny) = (target.0 as usize, target.1 as usize);
|
|
|
|
|
|
// Capture the bumped object before any push relocates it (its index is stable).
|
|
|
|
|
|
let bumped = match board.solid_at(nx, ny) {
|
|
|
|
|
|
Some(Solid::Object(_)) => board.object_index_at(nx, ny),
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
};
|
|
|
|
|
|
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
|
|
|
|
|
|
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
|
|
|
|
|
|
let obj = &mut board.objects[idx];
|
|
|
|
|
|
obj.x = nx;
|
|
|
|
|
|
obj.y = ny;
|
|
|
|
|
|
}
|
|
|
|
|
|
bumped
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 23:21:05 -05:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
2026-06-04 20:11:55 -05:00
|
|
|
|
/// Builds a 1×1 board with a single object that optionally references a
|
|
|
|
|
|
/// script, plus the given `(name, source)` script table entries.
|
|
|
|
|
|
fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> Board {
|
|
|
|
|
|
let mut object = ObjectDef::new(0, 0);
|
|
|
|
|
|
object.script_name = object_script.map(str::to_string);
|
2026-06-03 23:21:05 -05:00
|
|
|
|
Board {
|
|
|
|
|
|
name: "test".into(),
|
|
|
|
|
|
width: 1,
|
|
|
|
|
|
height: 1,
|
|
|
|
|
|
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)],
|
2026-06-06 18:49:45 -05:00
|
|
|
|
floor: vec![Archetype::Empty.default_glyph()],
|
|
|
|
|
|
floor_spec: None,
|
2026-06-03 23:21:05 -05:00
|
|
|
|
player: Player { x: 0, y: 0 },
|
2026-06-04 20:11:55 -05:00
|
|
|
|
objects: vec![object],
|
2026-06-03 23:21:05 -05:00
|
|
|
|
portals: Vec::new(),
|
|
|
|
|
|
font: None,
|
|
|
|
|
|
zoom: 1,
|
2026-06-04 20:11:55 -05:00
|
|
|
|
scripts: scripts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
|
|
|
|
.collect(),
|
2026-06-03 23:21:05 -05:00
|
|
|
|
board_script_name: None,
|
2026-06-06 14:11:20 -05:00
|
|
|
|
load_errors: Vec::new(),
|
2026-06-03 23:21:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
|
/// An `ObjectDef` at `(x, y)` bound to the named script.
|
|
|
|
|
|
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
|
|
|
|
|
|
let mut o = ObjectDef::new(x, y);
|
|
|
|
|
|
o.script_name = Some(script.to_string());
|
|
|
|
|
|
o
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Builds an all-empty (passable) `w×h` board with the given player position,
|
|
|
|
|
|
/// objects, and `(name, source)` scripts — room for movement, unlike the 1×1
|
|
|
|
|
|
/// `board_with_object`.
|
|
|
|
|
|
fn open_board(
|
|
|
|
|
|
w: usize,
|
|
|
|
|
|
h: usize,
|
|
|
|
|
|
player: (i32, i32),
|
|
|
|
|
|
objects: Vec<ObjectDef>,
|
|
|
|
|
|
scripts: &[(&str, &str)],
|
|
|
|
|
|
) -> Board {
|
|
|
|
|
|
Board {
|
|
|
|
|
|
name: "test".into(),
|
|
|
|
|
|
width: w,
|
|
|
|
|
|
height: h,
|
|
|
|
|
|
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
|
2026-06-06 18:49:45 -05:00
|
|
|
|
floor: vec![Archetype::Empty.default_glyph(); w * h],
|
|
|
|
|
|
floor_spec: None,
|
2026-06-04 23:52:33 -05:00
|
|
|
|
player: Player {
|
|
|
|
|
|
x: player.0,
|
|
|
|
|
|
y: player.1,
|
|
|
|
|
|
},
|
|
|
|
|
|
objects,
|
|
|
|
|
|
portals: Vec::new(),
|
|
|
|
|
|
font: None,
|
|
|
|
|
|
zoom: 1,
|
|
|
|
|
|
scripts: scripts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
|
|
|
|
.collect(),
|
|
|
|
|
|
board_script_name: None,
|
2026-06-06 14:11:20 -05:00
|
|
|
|
load_errors: Vec::new(),
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 20:11:55 -05:00
|
|
|
|
/// Flattens each log line into a single string for easy assertions.
|
|
|
|
|
|
fn log_texts(game: &GameState) -> Vec<String> {
|
|
|
|
|
|
game.log
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|line| line.spans.iter().map(|s| s.text.as_str()).collect())
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 01:37:19 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn solid_at_reports_wall_object_and_empty() {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
// A 4×1 board: empty floor, a wall, an object, and the player parked at (3,0)
|
|
|
|
|
|
// (kept off the asserted cells, since the player is itself solid).
|
|
|
|
|
|
let mut board = open_board(4, 1, (3, 0), vec![], &[]);
|
2026-06-06 01:37:19 -05:00
|
|
|
|
board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
|
|
|
|
|
// A solid object on the otherwise-empty cell (2, 0).
|
|
|
|
|
|
board.objects.push(ObjectDef::new(2, 0)); // solid by default
|
|
|
|
|
|
|
|
|
|
|
|
// Empty floor: nothing solid.
|
|
|
|
|
|
assert!(board.solid_at(0, 0).is_none());
|
|
|
|
|
|
assert!(board.is_passable(0, 0));
|
|
|
|
|
|
|
|
|
|
|
|
// Wall cell: the grid archetype is the solid.
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
|
|
// 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)),
|
|
|
|
|
|
_ => panic!("expected Solid::Object"),
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(!board.is_passable(2, 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn non_solid_object_does_not_block() {
|
|
|
|
|
|
// A non-solid object sits on empty floor: the cell stays passable.
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// Stamps a crate cell onto an otherwise-open board.
|
|
|
|
|
|
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 an otherwise-open board.
|
|
|
|
|
|
fn wall_at(board: &mut Board, x: usize, y: usize) {
|
|
|
|
|
|
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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))); // x == width
|
|
|
|
|
|
assert!(!board.in_bounds((0, 2))); // y == height
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn can_push_is_read_only_and_correct() {
|
|
|
|
|
|
// Crate with empty space beyond: pushable, and the board is left untouched.
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
// Crate against a wall: not pushable.
|
|
|
|
|
|
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 player_pushes_single_crate() {
|
|
|
|
|
|
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
|
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.try_move(Direction::East);
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (1, 0)); // player advanced onto crate's old cell
|
|
|
|
|
|
assert_eq!(b.get(1, 0).1, Archetype::Empty); // crate left this cell
|
|
|
|
|
|
assert_eq!(b.get(2, 0).1, Archetype::Crate); // crate moved one east
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn push_blocked_by_wall_moves_nothing() {
|
|
|
|
|
|
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
|
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
wall_at(&mut board, 2, 0);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.try_move(Direction::East);
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (0, 0)); // blocked
|
|
|
|
|
|
assert_eq!(b.get(1, 0).1, Archetype::Crate); // crate didn't move
|
|
|
|
|
|
assert_eq!(b.get(2, 0).1, Archetype::Wall); // wall didn't move
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn push_blocked_by_edge_moves_nothing() {
|
|
|
|
|
|
// Crate on the last column; player pushes it toward the board edge.
|
|
|
|
|
|
let mut board = open_board(2, 1, (0, 0), vec![], &[]);
|
|
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.try_move(Direction::East);
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (0, 0));
|
|
|
|
|
|
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn cascade_pushes_two_crates() {
|
|
|
|
|
|
let mut board = open_board(5, 1, (0, 0), vec![], &[]);
|
|
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
crate_at(&mut board, 2, 0);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.try_move(Direction::East);
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (1, 0));
|
|
|
|
|
|
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
|
|
|
|
|
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
|
|
|
|
|
assert_eq!(b.get(3, 0).1, Archetype::Crate);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn cascade_blocked_by_wall_moves_nothing() {
|
|
|
|
|
|
let mut board = open_board(5, 1, (0, 0), vec![], &[]);
|
|
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
crate_at(&mut board, 2, 0);
|
|
|
|
|
|
wall_at(&mut board, 3, 0);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.try_move(Direction::East);
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (0, 0));
|
|
|
|
|
|
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
|
|
|
|
|
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn player_pushes_pushable_solid_object() {
|
|
|
|
|
|
// A solid, pushable object is shoved exactly like a crate.
|
|
|
|
|
|
let mut obj = ObjectDef::new(1, 0); // solid by default
|
|
|
|
|
|
obj.pushable = true;
|
|
|
|
|
|
let board = open_board(4, 1, (0, 0), vec![obj], &[]);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.try_move(Direction::East);
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (1, 0));
|
|
|
|
|
|
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 0)); // object shoved east
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn object_pushes_crate_on_init() {
|
|
|
|
|
|
// A scripted object moving east into a crate shoves it (move_object path).
|
|
|
|
|
|
let mut board = open_board(
|
|
|
|
|
|
4,
|
|
|
|
|
|
1,
|
|
|
|
|
|
(0, 0), // player behind the object, out of the push path
|
|
|
|
|
|
vec![scripted_object(1, 0, "m")],
|
|
|
|
|
|
&[("m", "fn init() { move(East); }")],
|
|
|
|
|
|
);
|
|
|
|
|
|
crate_at(&mut board, 2, 0);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 0)); // object advanced
|
|
|
|
|
|
assert_eq!(b.get(2, 0).1, Archetype::Empty); // crate left (2,0)
|
|
|
|
|
|
assert_eq!(b.get(3, 0).1, Archetype::Crate); // crate pushed to (3,0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn pushable_allows_only_its_axis() {
|
|
|
|
|
|
use Direction::*;
|
|
|
|
|
|
for d in [North, South, East, West] {
|
|
|
|
|
|
assert!(!Pushable::No.allows(d));
|
|
|
|
|
|
assert!(Pushable::Any.allows(d));
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(Pushable::Horizontal.allows(East) && Pushable::Horizontal.allows(West));
|
|
|
|
|
|
assert!(!Pushable::Horizontal.allows(North) && !Pushable::Horizontal.allows(South));
|
|
|
|
|
|
assert!(Pushable::Vertical.allows(North) && Pushable::Vertical.allows(South));
|
|
|
|
|
|
assert!(!Pushable::Vertical.allows(East) && !Pushable::Vertical.allows(West));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Stamps an archetype cell onto an otherwise-open board.
|
|
|
|
|
|
fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
|
|
|
|
|
|
*board.get_mut(x, y) = (arch.default_glyph(), arch);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn hcrate_pushes_east_but_not_north() {
|
|
|
|
|
|
// Pushing east: the HCrate slides.
|
|
|
|
|
|
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
|
|
|
|
|
stamp(&mut board, 1, 0, Archetype::HCrate);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.try_move(Direction::East);
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (1, 0));
|
|
|
|
|
|
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
|
|
|
|
|
assert_eq!(b.get(2, 0).1, Archetype::HCrate);
|
|
|
|
|
|
drop(b);
|
|
|
|
|
|
|
|
|
|
|
|
// Pushing north into an HCrate: blocked, nothing moves.
|
|
|
|
|
|
let mut board = open_board(1, 4, (0, 3), vec![], &[]);
|
|
|
|
|
|
stamp(&mut board, 0, 2, Archetype::HCrate);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.try_move(Direction::North);
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (0, 3)); // blocked
|
|
|
|
|
|
assert_eq!(b.get(0, 2).1, Archetype::HCrate); // didn't move
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn vcrate_pushes_north_but_not_east() {
|
|
|
|
|
|
// Pushing north: the VCrate slides.
|
|
|
|
|
|
let mut board = open_board(1, 4, (0, 3), vec![], &[]);
|
|
|
|
|
|
stamp(&mut board, 0, 2, Archetype::VCrate);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.try_move(Direction::North);
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (0, 2));
|
|
|
|
|
|
assert_eq!(b.get(0, 2).1, Archetype::Empty);
|
|
|
|
|
|
assert_eq!(b.get(0, 1).1, Archetype::VCrate);
|
|
|
|
|
|
drop(b);
|
|
|
|
|
|
|
|
|
|
|
|
// Pushing east into a VCrate: blocked, nothing moves.
|
|
|
|
|
|
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
|
|
|
|
|
stamp(&mut board, 1, 0, Archetype::VCrate);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.try_move(Direction::East);
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (0, 0)); // blocked
|
|
|
|
|
|
assert_eq!(b.get(1, 0).1, Archetype::VCrate); // didn't move
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn directional_crate_default_glyph_tiles() {
|
|
|
|
|
|
assert_eq!(Archetype::HCrate.default_glyph().tile, 29);
|
|
|
|
|
|
assert_eq!(Archetype::VCrate.default_glyph().tile, 18);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 20:11:55 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn init_runs_only_on_run_init_not_at_construction() {
|
2026-06-04 23:52:33 -05:00
|
|
|
|
let board = board_with_object(
|
|
|
|
|
|
Some("greet"),
|
|
|
|
|
|
&[("greet", r#"fn init() { log("hello"); }"#)],
|
|
|
|
|
|
);
|
2026-06-04 20:11:55 -05:00
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
// init must not fire during construction / deserialization.
|
|
|
|
|
|
assert!(game.log.is_empty());
|
|
|
|
|
|
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
assert_eq!(log_texts(&game), vec!["hello"]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 23:21:05 -05:00
|
|
|
|
#[test]
|
2026-06-04 20:11:55 -05:00
|
|
|
|
fn tick_calls_script_tick_with_elapsed_seconds() {
|
|
|
|
|
|
let board = board_with_object(
|
|
|
|
|
|
Some("t"),
|
|
|
|
|
|
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
// No tick hook runs until tick() is called.
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
assert!(game.log.is_empty());
|
2026-06-03 23:21:05 -05:00
|
|
|
|
|
|
|
|
|
|
game.tick(Duration::from_millis(500));
|
|
|
|
|
|
assert_eq!(game.log.len(), 1);
|
2026-06-04 20:11:55 -05:00
|
|
|
|
// The dt argument reached the script as ~0.5 seconds.
|
|
|
|
|
|
let logged: f64 = log_texts(&game)[0].parse().unwrap();
|
|
|
|
|
|
assert!((logged - 0.5).abs() < 1e-9);
|
|
|
|
|
|
}
|
2026-06-03 23:21:05 -05:00
|
|
|
|
|
2026-06-04 20:11:55 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn missing_hooks_and_no_script_are_noops() {
|
|
|
|
|
|
// Object with no script: nothing happens.
|
|
|
|
|
|
let mut game = GameState::new(board_with_object(None, &[]));
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
game.tick(Duration::from_millis(33));
|
|
|
|
|
|
assert!(game.log.is_empty());
|
|
|
|
|
|
|
|
|
|
|
|
// Script defines neither init nor tick: also a no-op.
|
2026-06-04 23:52:33 -05:00
|
|
|
|
let mut game = GameState::new(board_with_object(
|
|
|
|
|
|
Some("e"),
|
|
|
|
|
|
&[("e", "fn other() { log(\"x\"); }")],
|
|
|
|
|
|
));
|
2026-06-04 20:11:55 -05:00
|
|
|
|
game.run_init();
|
|
|
|
|
|
game.tick(Duration::from_millis(33));
|
|
|
|
|
|
assert!(game.log.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn compile_and_unknown_script_errors_are_logged() {
|
|
|
|
|
|
// A reference to a script name that isn't in the table.
|
|
|
|
|
|
let game = GameState::new(board_with_object(Some("ghost"), &[]));
|
|
|
|
|
|
assert!(log_texts(&game)[0].contains("unknown script 'ghost'"));
|
|
|
|
|
|
|
|
|
|
|
|
// A script that fails to compile is reported at construction time.
|
|
|
|
|
|
let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")]));
|
|
|
|
|
|
assert!(log_texts(&game)[0].contains("failed to compile"));
|
2026-06-03 23:21:05 -05:00
|
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn script_reads_board_through_view() {
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
5,
|
|
|
|
|
|
3,
|
|
|
|
|
|
(3, 1),
|
|
|
|
|
|
vec![scripted_object(2, 1, "r")],
|
2026-06-05 01:09:50 -05:00
|
|
|
|
&[("r", "fn init() { log(Board.player_x.to_string()); }")],
|
2026-06-04 23:52:33 -05:00
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
// The view reported the player's x (3).
|
|
|
|
|
|
assert_eq!(log_texts(&game), vec!["3"]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn move_command_relocates_the_source_object() {
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
5,
|
|
|
|
|
|
3,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![scripted_object(2, 1, "m")],
|
2026-06-05 01:09:50 -05:00
|
|
|
|
&[("m", "fn init() { move(East); }")],
|
2026-06-04 23:52:33 -05:00
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
// East increments x by one; the object started at (2, 1).
|
|
|
|
|
|
assert_eq!((b.objects[0].x, b.objects[0].y), (3, 1));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn move_into_a_wall_or_edge_is_a_noop() {
|
|
|
|
|
|
// Object at the west edge moving west: out of bounds, ignored.
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
5,
|
|
|
|
|
|
3,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![scripted_object(0, 1, "m")],
|
2026-06-05 01:09:50 -05:00
|
|
|
|
&[("m", "fn init() { move(West); }")],
|
2026-06-04 23:52:33 -05:00
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
assert_eq!(game.board().objects[0].x, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn set_tile_command_changes_the_source_glyph() {
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
5,
|
|
|
|
|
|
3,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![scripted_object(2, 1, "s")],
|
|
|
|
|
|
&[("s", "fn init() { set_tile(7); }")],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
assert_eq!(game.board().objects[0].glyph.tile, 7);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn start_map_greeter_runs_init() {
|
|
|
|
|
|
// End-to-end against the shipped example map: load it, run init, and
|
|
|
|
|
|
// confirm the greeter read the board (interpolated message) and wrote to
|
|
|
|
|
|
// itself (set_tile). Also guards the example from drifting out of sync.
|
|
|
|
|
|
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml");
|
|
|
|
|
|
let board = crate::map_file::load(path).expect("load start.toml");
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
log_texts(&game)
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|t| t.contains("hello from object")),
|
|
|
|
|
|
"greeter init should log a greeting"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
game.board().objects.iter().any(|o| o.glyph.tile == 2),
|
|
|
|
|
|
"greeter set_tile(2) should change its glyph"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn commands_are_routed_to_their_own_source_object() {
|
|
|
|
|
|
// Two objects with different scripts move in opposite directions; each
|
|
|
|
|
|
// must affect only itself (the per-call tag routes the command source).
|
|
|
|
|
|
// Targets are kept apart so the impassable objects don't block each other.
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
5,
|
|
|
|
|
|
3,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
|
|
|
|
|
|
&[
|
2026-06-05 01:09:50 -05:00
|
|
|
|
("e", "fn init() { move(East); }"),
|
|
|
|
|
|
("w", "fn init() { move(West); }"),
|
2026-06-04 23:52:33 -05:00
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!(b.objects[0].x, 1); // moved east from 0
|
|
|
|
|
|
assert_eq!(b.objects[1].x, 3); // moved west from 4
|
|
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn move_cost_rate_limits_repeated_moves() {
|
|
|
|
|
|
// init queues two moves; only the first resolves immediately, the second must
|
|
|
|
|
|
// wait the full 250 ms cooldown.
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
5,
|
|
|
|
|
|
1,
|
|
|
|
|
|
(0, 0), // player to the west, out of the object's path
|
|
|
|
|
|
vec![scripted_object(1, 0, "m")],
|
|
|
|
|
|
&[("m", "fn init() { move(East); move(East); }")],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
assert_eq!(game.board().objects[0].x, 2); // first move applied (1 -> 2)
|
|
|
|
|
|
|
|
|
|
|
|
// 200 ms of ticks: still inside the cooldown, no further movement.
|
|
|
|
|
|
game.tick(Duration::from_millis(100));
|
|
|
|
|
|
game.tick(Duration::from_millis(100));
|
|
|
|
|
|
assert_eq!(game.board().objects[0].x, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// Crossing the 250 ms mark releases the queued second move.
|
|
|
|
|
|
game.tick(Duration::from_millis(100));
|
|
|
|
|
|
assert_eq!(game.board().objects[0].x, 3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn blocked_move_still_costs_cooldown() {
|
|
|
|
|
|
// A move into a wall fails but still charges the 250 ms cooldown, so a second
|
|
|
|
|
|
// queued move (into open space) is delayed just as a successful move would be.
|
|
|
|
|
|
let mut board = open_board(
|
|
|
|
|
|
3,
|
|
|
|
|
|
3,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![scripted_object(1, 1, "m")],
|
|
|
|
|
|
&[("m", "fn init() { move(East); move(South); }")],
|
|
|
|
|
|
);
|
|
|
|
|
|
wall_at(&mut board, 2, 1); // blocks the eastward move
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
// First (eastward) move is blocked by the wall: object hasn't moved.
|
2026-06-06 18:49:45 -05:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
(game.board().objects[0].x, game.board().objects[0].y),
|
|
|
|
|
|
(1, 1)
|
|
|
|
|
|
);
|
2026-06-06 17:36:00 -05:00
|
|
|
|
|
|
|
|
|
|
// The blocked move charged the cooldown, so South is still pending here.
|
|
|
|
|
|
game.tick(Duration::from_millis(100));
|
|
|
|
|
|
game.tick(Duration::from_millis(100));
|
2026-06-06 18:49:45 -05:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
(game.board().objects[0].x, game.board().objects[0].y),
|
|
|
|
|
|
(1, 1)
|
|
|
|
|
|
);
|
2026-06-06 17:36:00 -05:00
|
|
|
|
|
|
|
|
|
|
// Past 250 ms the queued South move resolves.
|
|
|
|
|
|
game.tick(Duration::from_millis(100));
|
2026-06-06 18:49:45 -05:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
(game.board().objects[0].x, game.board().objects[0].y),
|
|
|
|
|
|
(1, 2)
|
|
|
|
|
|
);
|
2026-06-06 17:36:00 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn queue_length_reports_pending_actions() {
|
|
|
|
|
|
// Two zero-cost actions are queued before the length is read, then all three
|
|
|
|
|
|
// (incl. the log) drain in one pump.
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
3,
|
|
|
|
|
|
1,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![scripted_object(1, 0, "q")],
|
2026-06-06 18:49:45 -05:00
|
|
|
|
&[(
|
|
|
|
|
|
"q",
|
|
|
|
|
|
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }",
|
|
|
|
|
|
)],
|
2026-06-06 17:36:00 -05:00
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
assert!(log_texts(&game).iter().any(|t| t == "len=2"));
|
|
|
|
|
|
assert_eq!(game.board().objects[0].glyph.tile, 6); // last set_tile won
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn queue_clear_drops_pending_actions() {
|
|
|
|
|
|
// clear() empties the output queue mid-script, so the queued moves never run.
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
5,
|
|
|
|
|
|
1,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![scripted_object(1, 0, "c")],
|
|
|
|
|
|
&[("c", "fn init() { move(East); move(East); Queue.clear(); }")],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
game.tick(Duration::from_millis(300));
|
|
|
|
|
|
assert_eq!(game.board().objects[0].x, 1); // never moved
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn blocked_reports_solid_and_clear() {
|
|
|
|
|
|
// Solid ahead (a wall): blocked() is true.
|
|
|
|
|
|
let mut board = open_board(
|
|
|
|
|
|
3,
|
|
|
|
|
|
1,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![scripted_object(1, 0, "b")],
|
2026-06-06 18:49:45 -05:00
|
|
|
|
&[(
|
|
|
|
|
|
"b",
|
|
|
|
|
|
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
|
|
|
|
|
)],
|
2026-06-06 17:36:00 -05:00
|
|
|
|
);
|
|
|
|
|
|
wall_at(&mut board, 2, 0);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
assert_eq!(game.board().objects[0].glyph.tile, 9);
|
|
|
|
|
|
|
|
|
|
|
|
// Open ahead, nothing pending: blocked() is false.
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
3,
|
|
|
|
|
|
1,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![scripted_object(1, 0, "b")],
|
2026-06-06 18:49:45 -05:00
|
|
|
|
&[(
|
|
|
|
|
|
"b",
|
|
|
|
|
|
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
|
|
|
|
|
)],
|
2026-06-06 17:36:00 -05:00
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
assert_eq!(game.board().objects[0].glyph.tile, 7);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn blocked_sees_earlier_objects_pending_move() {
|
|
|
|
|
|
// obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked()
|
|
|
|
|
|
// toward that same cell and must see the pending move.
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
5,
|
|
|
|
|
|
3,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![
|
|
|
|
|
|
scripted_object(1, 1, "mover"),
|
|
|
|
|
|
scripted_object(3, 1, "checker"),
|
|
|
|
|
|
],
|
|
|
|
|
|
&[
|
|
|
|
|
|
("mover", "fn tick(dt) { move(East); }"),
|
|
|
|
|
|
(
|
|
|
|
|
|
"checker",
|
|
|
|
|
|
"fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }",
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.tick(Duration::from_millis(16));
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 1)); // mover advanced
|
|
|
|
|
|
assert_eq!(b.objects[1].glyph.tile, 1); // checker saw the pending move
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn collision_priority_resolves_in_array_order_and_bumps() {
|
|
|
|
|
|
// Two objects move into the same empty cell (1,0). obj0 (earlier) wins it;
|
|
|
|
|
|
// obj1 is blocked and bumps obj0. obj1 itself receives no bump.
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
3,
|
|
|
|
|
|
2,
|
|
|
|
|
|
(0, 1), // player off the contested row
|
|
|
|
|
|
vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")],
|
|
|
|
|
|
&[
|
|
|
|
|
|
(
|
|
|
|
|
|
"e",
|
|
|
|
|
|
"fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }",
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"w",
|
|
|
|
|
|
"fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }",
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
// The bump fires during resolution but its log is emitted into obj0's queue;
|
|
|
|
|
|
// a later tick (past both objects' move cooldown) flushes it to the game log.
|
|
|
|
|
|
game.tick(Duration::from_millis(300));
|
|
|
|
|
|
{
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.objects[0].x, b.objects[0].y), (1, 0)); // obj0 won the cell
|
|
|
|
|
|
assert_eq!((b.objects[1].x, b.objects[1].y), (2, 0)); // obj1 blocked
|
|
|
|
|
|
}
|
|
|
|
|
|
let logs = log_texts(&game);
|
|
|
|
|
|
assert!(logs.iter().any(|t| t == "o0 by 1")); // obj0 bumped by obj1
|
|
|
|
|
|
assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn player_bump_fires_with_negative_one() {
|
|
|
|
|
|
// The player walks into a solid scripted object; the object is bumped with -1
|
|
|
|
|
|
// and the player does not move onto it.
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
3,
|
|
|
|
|
|
1,
|
|
|
|
|
|
(0, 0),
|
|
|
|
|
|
vec![scripted_object(1, 0, "b")],
|
|
|
|
|
|
&[("b", "fn bump(id) { log(`bumped by ${id}`); }")],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
game.try_move(Direction::East);
|
|
|
|
|
|
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
|
|
|
|
|
|
// bump's log is emitted into the object's queue; a tick flushes it.
|
|
|
|
|
|
game.tick(Duration::from_millis(16));
|
|
|
|
|
|
assert!(log_texts(&game).iter().any(|t| t == "bumped by -1"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:49:45 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn display_glyph_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![], &[]);
|
|
|
|
|
|
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; 2];
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn pushing_a_crate_reveals_the_floor_underneath() {
|
|
|
|
|
|
// The cell a crate is pushed off of becomes Empty, so its `display_glyph`
|
|
|
|
|
|
// should show the floor glyph rather than black-on-black.
|
|
|
|
|
|
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
|
|
|
|
|
let floor_glyph = Glyph {
|
|
|
|
|
|
tile: ',' as u32,
|
|
|
|
|
|
fg: Rgba8 {
|
|
|
|
|
|
r: 40,
|
|
|
|
|
|
g: 60,
|
|
|
|
|
|
b: 40,
|
|
|
|
|
|
a: 255,
|
|
|
|
|
|
},
|
|
|
|
|
|
bg: Rgba8 {
|
|
|
|
|
|
r: 5,
|
|
|
|
|
|
g: 10,
|
|
|
|
|
|
b: 5,
|
|
|
|
|
|
a: 255,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
board.floor = vec![floor_glyph; 4];
|
|
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:36:00 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn solid_at_reports_player() {
|
|
|
|
|
|
// The player's own cell is solid (and thus impassable to movers).
|
|
|
|
|
|
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 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); // crate left its cell
|
|
|
|
|
|
assert_eq!(board.get(2, 0).1, Archetype::Crate); // crate took the player's old cell
|
|
|
|
|
|
assert_eq!((board.player.x, board.player.y), (3, 0)); // player shoved east
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn push_into_player_blocked_by_wall() {
|
|
|
|
|
|
// Player backed against a wall: the push has nowhere to go, so nothing moves
|
|
|
|
|
|
// and the player is never overlapped.
|
|
|
|
|
|
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 object_push_into_player() {
|
|
|
|
|
|
// A scripted object moving into the player pushes the player when there's room.
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
5,
|
|
|
|
|
|
1,
|
|
|
|
|
|
(2, 0),
|
|
|
|
|
|
vec![scripted_object(1, 0, "m")],
|
|
|
|
|
|
&[("m", "fn init() { move(East); }")],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
{
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 0)); // object took player's cell
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (3, 0)); // player shoved east
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// With a wall behind the player, the object is blocked and nothing moves.
|
|
|
|
|
|
let mut board = open_board(
|
|
|
|
|
|
4,
|
|
|
|
|
|
1,
|
|
|
|
|
|
(2, 0),
|
|
|
|
|
|
vec![scripted_object(1, 0, "m")],
|
|
|
|
|
|
&[("m", "fn init() { move(East); }")],
|
|
|
|
|
|
);
|
|
|
|
|
|
wall_at(&mut board, 3, 0);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!((b.objects[0].x, b.objects[0].y), (1, 0)); // object blocked
|
|
|
|
|
|
assert_eq!((b.player.x, b.player.y), (2, 0)); // player not pushed
|
|
|
|
|
|
}
|
2026-06-03 23:21:05 -05:00
|
|
|
|
}
|