2026-07-10 23:16:28 -05:00
|
|
|
|
use crate::floor::Floor;
|
2026-07-24 21:52:05 -05:00
|
|
|
|
use crate::fov::{color_to_rgb, FovCaster, Lighting};
|
2026-06-07 00:19:53 -05:00
|
|
|
|
use crate::glyph::Glyph;
|
|
|
|
|
|
use crate::log::LogLine;
|
2026-06-08 22:15:44 -05:00
|
|
|
|
use crate::utils::Direction;
|
2026-07-24 21:52:05 -05:00
|
|
|
|
use crate::utils::{ObjectId, RegistryValue};
|
|
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
|
|
use crate::portal::Portal;
|
|
|
|
|
|
use crate::tile::{DrawLayer, EnterResponse, Hookable, IntoTile, LocatedObject, ScriptAttributes, Sensor, Tile, TileSpec};
|
2026-07-10 23:16:28 -05:00
|
|
|
|
|
2026-06-07 00:19:53 -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:
|
|
|
|
|
|
///
|
|
|
|
|
|
/// - 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.
|
2026-06-20 19:05:46 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// `Board` derives [`Clone`] to support deep-copying a whole [`World`](crate::world::World)
|
|
|
|
|
|
/// (see [`World::deep_clone`](crate::world::World::deep_clone)) — e.g. the editor's
|
|
|
|
|
|
/// playtest runs a game against an isolated copy so play mutations never touch the
|
|
|
|
|
|
/// boards being edited.
|
|
|
|
|
|
#[derive(Clone)]
|
2026-06-07 00:19:53 -05:00
|
|
|
|
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,
|
2026-07-24 21:52:05 -05:00
|
|
|
|
/// The single row-major grid of `Option<Tile>` cells (`width * height`),
|
|
|
|
|
|
/// holding every solid. A transparent cell (glyph tile 0)
|
|
|
|
|
|
/// draws nothing, revealing a `Sensor` or the
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// [`floor`](Board::floor) beneath. Access a cell with [`Board::get`]/
|
|
|
|
|
|
/// [`Board::get_mut`] by `(x, y)`.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
pub(crate) grid: Vec<Option<Tile>>,
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// The board's cosmetic floor (blank / one fixed glyph / a biome), drawn beneath
|
2026-07-24 21:52:05 -05:00
|
|
|
|
/// everything.
|
2026-07-10 23:16:28 -05:00
|
|
|
|
pub(crate) floor: Floor,
|
2026-07-14 23:48:50 -05:00
|
|
|
|
/// Non-solid things placed off the main grid, can't affect movement but see other hooks
|
|
|
|
|
|
pub sensors: Vec<Sensor>,
|
2026-07-24 21:52:05 -05:00
|
|
|
|
/// The portals aren't really a kind of sensor, and they can't be on the grid because the player
|
|
|
|
|
|
/// can share a space with them:
|
|
|
|
|
|
pub portals: Vec<Portal>,
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// The next [`ObjectId`] to hand out (starts at 1, monotonically increasing).
|
|
|
|
|
|
/// See [`Board::add_object`].
|
|
|
|
|
|
pub next_object_id: ObjectId,
|
2026-07-11 14:47:08 -05:00
|
|
|
|
/// When `true`, this board is "dark": front-ends reveal only the cells the
|
|
|
|
|
|
/// player can see and that receive light (see [`Board::lighting`]) and draw
|
|
|
|
|
|
/// everything else as unlit darkness. Sight and light are blocked by opaque cells.
|
2026-07-11 12:40:35 -05:00
|
|
|
|
/// Loaded from / saved to the `dark` key in the map file's `[map]` header;
|
|
|
|
|
|
/// defaults to `false` (fully lit).
|
|
|
|
|
|
pub dark: bool,
|
2026-06-13 17:58:04 -05:00
|
|
|
|
/// Per-board key→value store written and read by Rhai scripts via the
|
|
|
|
|
|
/// `Registry` scope constant. Persists automatically across board transitions
|
|
|
|
|
|
/// because all boards live as `Rc<RefCell<Board>>` in `World::boards` and are
|
|
|
|
|
|
/// never evicted. Not saved to disk in v1.
|
|
|
|
|
|
pub registry: HashMap<String, RegistryValue>,
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Board {
|
2026-06-28 00:12:52 -05:00
|
|
|
|
/// Return a list of all `ObjectId`s currently on the board.
|
|
|
|
|
|
pub fn all_ids(&self) -> Vec<ObjectId> {
|
2026-07-24 23:46:43 -05:00
|
|
|
|
let mut grid_ids = self.grid.iter().filter_map(|cell| {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
if let Some(Tile::Object(def)) = cell {
|
|
|
|
|
|
Some(def.scripting.id)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
2026-07-24 23:46:43 -05:00
|
|
|
|
}).collect::<Vec<_>>();
|
|
|
|
|
|
grid_ids.extend(self.sensors.iter().map(|s| s.scripting.id));
|
|
|
|
|
|
grid_ids.sort();
|
|
|
|
|
|
grid_ids
|
2026-06-28 00:12:52 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Returns a reference to the `(Glyph, Archetype)` cell at `(x, y)`.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Panics if `x` or `y` are out of bounds.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
pub fn get(&self, x: usize, y: usize) -> &Option<Tile> {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
&self.grid[y * self.width + x]
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Returns a mutable reference to the cell at `(x, y)`.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Panics if `x` or `y` are out of bounds.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut Option<Tile> {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let w = self.width;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
&mut self.grid[y * w + x]
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Replace the solid terrain (if any) at `(x, y)` with a transparent `Empty`
|
|
|
|
|
|
/// cell, revealing the floor beneath.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
pub fn clear_cell(&mut self, x: usize, y: usize) {
|
|
|
|
|
|
if self.in_bounds((x as i64, y as i64)) {
|
|
|
|
|
|
*self.get_mut(x, y) = None;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
}
|
2026-06-21 22:04:10 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Returns the glyph to display at `(x, y)`.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// With a single grid the draw order is a fixed precedence (no layer walk):
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// 1. the player (drawn on top; not part of the grid yet);
|
|
|
|
|
|
/// 2. an object at the cell — a solid object always, otherwise the first
|
|
|
|
|
|
/// non-transparent non-solid object (`tile != 0`, so invisible objects exist);
|
|
|
|
|
|
/// 3. the grid cell `(glyph, arch)` — a solid always draws, a non-solid only when
|
|
|
|
|
|
/// visible (`tile != 0`);
|
|
|
|
|
|
/// 4. a portal at the cell (portals sit on a transparent grid cell);
|
|
|
|
|
|
/// 5. a [`decoration`](Board::decorations) at the cell (reached only because the
|
|
|
|
|
|
/// grid cell was empty);
|
|
|
|
|
|
/// 6. the [`floor`](Board::floor) glyph, if any;
|
|
|
|
|
|
/// 7. the canonical black `Empty` glyph.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Panics if out of bounds.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let grid_glyph = self.get(x, y).as_ref().map(Tile::glyph);
|
2026-06-07 00:19:53 -05:00
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let sensors = self.sensors.iter().filter(|&s| s.x == x && s.y == y);
|
2026-06-07 00:19:53 -05:00
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
// Is there a sensor above the grid?
|
|
|
|
|
|
if let Some(above) = sensors.clone().find(|s| s.draw_layer == DrawLayer::Above && s.scripting.glyph.tile != 0) {
|
|
|
|
|
|
return above.scripting.glyph;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
}
|
2026-06-07 00:19:53 -05:00
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
// Does the grid have a good glyph?
|
|
|
|
|
|
if let Some(glyph) = grid_glyph && glyph.tile != 0 {
|
|
|
|
|
|
return glyph;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
// Is there a sensor below the grid?
|
|
|
|
|
|
if let Some(below) = sensors.clone().find(|s| s.draw_layer == DrawLayer::Below && s.scripting.glyph.tile != 0) {
|
|
|
|
|
|
return below.scripting.glyph;
|
2026-06-13 16:24:29 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
// Otherwise the floor, or the canonical black empty cell.
|
2026-07-10 23:16:28 -05:00
|
|
|
|
self.floor
|
|
|
|
|
|
.glyph_at(x, y, self.width)
|
2026-07-24 21:52:05 -05:00
|
|
|
|
.unwrap_or_else(|| Glyph::transparent())
|
2026-06-07 00:19:53 -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.
|
2026-06-28 00:12:52 -05:00
|
|
|
|
pub fn in_bounds(&self, pos: (i64, i64)) -> bool {
|
2026-06-07 00:19:53 -05:00
|
|
|
|
let (x, y) = pos;
|
|
|
|
|
|
x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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 {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
self.get(x, y).is_none()
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 14:47:08 -05:00
|
|
|
|
/// Returns `true` if cell `(x, y)` blocks line of sight (and light).
|
2026-07-11 12:40:35 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// A cell is sight-blocking if its grid terrain is opaque (e.g. a `Wall`)
|
2026-07-11 14:47:08 -05:00
|
|
|
|
/// **or** any object on it is opaque. This is the input to lighting on
|
|
|
|
|
|
/// [`dark`](Board::dark) boards; see [`Board::lighting`].
|
2026-07-11 12:40:35 -05:00
|
|
|
|
/// Panics if `x` or `y` are out of bounds.
|
|
|
|
|
|
pub fn is_opaque_at(&self, x: usize, y: usize) -> bool {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
if self.sensors.iter().any(|s| s.x == x && s.y == y && s.scripting.optics.opaque) {
|
|
|
|
|
|
true
|
|
|
|
|
|
} else {
|
|
|
|
|
|
match self.get(x, y) {
|
|
|
|
|
|
None => false,
|
|
|
|
|
|
Some(Tile::Player) => false,
|
|
|
|
|
|
Some(Tile::Object(def)) => {
|
|
|
|
|
|
def.scripting.optics.opaque
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-11 12:40:35 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 14:47:08 -05:00
|
|
|
|
/// Computes lighting + line-of-sight for the player on this board.
|
2026-07-11 12:40:35 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// Returns `None` unless the board is [`dark`](Board::dark) — a lit board
|
2026-07-11 14:47:08 -05:00
|
|
|
|
/// needs no lighting and front-ends draw every cell at full color. On a dark
|
|
|
|
|
|
/// board it (a) casts an unbounded line-of-sight field from the player, then
|
|
|
|
|
|
/// (b) accumulates colored light from every source — the player's torch
|
|
|
|
|
|
/// (radius `player_torch`, white), each object with `light > 0`, and each
|
|
|
|
|
|
/// terrain cell with [`Archetype::light`] `> 0` — each source colored by its
|
|
|
|
|
|
/// own glyph fg and falling off linearly to its radius. Opaque cells (via
|
|
|
|
|
|
/// [`is_opaque_at`](Board::is_opaque_at)) block both sight and light.
|
|
|
|
|
|
pub fn lighting(&self, player_torch: u32) -> Option<Lighting> {
|
2026-07-11 12:40:35 -05:00
|
|
|
|
if !self.dark {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
2026-07-11 14:47:08 -05:00
|
|
|
|
let (w, h) = (self.width, self.height);
|
|
|
|
|
|
let mut lighting = Lighting::new(w, h);
|
|
|
|
|
|
// One caster whose transparency is seeded once from the opaque cells;
|
|
|
|
|
|
// reused for the LOS pass and every light source (its FOV is cleared per cast).
|
|
|
|
|
|
let mut caster = FovCaster::new(w, h, |x, y| !self.is_opaque_at(x, y));
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let (px, py) = self.player_pos();
|
|
|
|
|
|
|
2026-07-11 14:47:08 -05:00
|
|
|
|
// (a) Player line of sight — unbounded (radius 0), pure geometry.
|
|
|
|
|
|
caster.cast(px, py, 0, |x, y| lighting.set_los(x, y));
|
|
|
|
|
|
|
|
|
|
|
|
// (b) Accumulate each light source into the per-cell color buffer. A
|
|
|
|
|
|
// source paints every cell it can see within its radius, tinted by its
|
|
|
|
|
|
// glyph fg and dimmed by a linear falloff (full at the source, 0 at the edge).
|
|
|
|
|
|
let mut add_source = |lighting: &mut Lighting, sx: usize, sy: usize, radius: u32, color: [f32; 3]| {
|
|
|
|
|
|
let r = radius as f32;
|
|
|
|
|
|
caster.cast(sx, sy, radius as usize, |x, y| {
|
|
|
|
|
|
let d = ((x as f32 - sx as f32).powi(2) + (y as f32 - sy as f32).powi(2)).sqrt();
|
|
|
|
|
|
let falloff = (1.0 - d / r).max(0.0);
|
|
|
|
|
|
lighting.add_light(x, y, [color[0] * falloff, color[1] * falloff, color[2] * falloff]);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// The player's torch: a white light centered on the player.
|
|
|
|
|
|
if player_torch > 0 {
|
|
|
|
|
|
add_source(&mut lighting, px, py, player_torch, [1.0, 1.0, 1.0]);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Glowing terrain (e.g. a `Torch` cell): color = the cell's glyph foreground.
|
|
|
|
|
|
for y in 0..h {
|
|
|
|
|
|
for x in 0..w {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
if let Some(Tile::Object(obj)) = self.get(x, y) {
|
|
|
|
|
|
let radius = obj.scripting.optics.glow;
|
|
|
|
|
|
if radius > 0 {
|
|
|
|
|
|
add_source(&mut lighting, x, y, radius, color_to_rgb(obj.scripting.glyph.fg));
|
|
|
|
|
|
}
|
2026-07-11 14:47:08 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 23:46:43 -05:00
|
|
|
|
// Glowing sensors
|
|
|
|
|
|
for s in self.sensors.iter() {
|
|
|
|
|
|
if s.optics().glow > 0 {
|
|
|
|
|
|
add_source(&mut lighting, s.x, s.y, s.optics().glow, color_to_rgb(s.scripting.glyph.fg));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 14:47:08 -05:00
|
|
|
|
Some(lighting)
|
2026-07-11 12:40:35 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
|
|
|
|
|
|
///
|
2026-07-24 21:52:05 -05:00
|
|
|
|
/// This is whether the thing in this cell will _transmit_ a push impulse through
|
|
|
|
|
|
/// it. Empty cells and cells that can't be pushed in that direction break the
|
|
|
|
|
|
/// chain.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
match self.get(x, y) {
|
|
|
|
|
|
Some(Tile::Player) => true,
|
|
|
|
|
|
Some(Tile::Object(obj)) => {
|
|
|
|
|
|
if let EnterResponse::Push(p) = obj.enter_response {
|
|
|
|
|
|
p.allows(dir)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => false,
|
|
|
|
|
|
}
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 13:21:27 -05:00
|
|
|
|
/// The object that a move **into** `(x, y)` heading `dir` bumps, if any.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Walks the chain of pushable crates from the target cell in `dir` until it
|
|
|
|
|
|
/// reaches something that stops it, and reports the object it presses against:
|
2026-07-24 21:52:05 -05:00
|
|
|
|
/// - a `Block` object in the target cell (or at the end of a pushable chain) is the
|
2026-07-08 13:21:27 -05:00
|
|
|
|
/// bumped object,
|
|
|
|
|
|
/// - open space or the player means nothing is bumped,
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This is what lets *any* solid — the player, another object, or a pushed
|
|
|
|
|
|
/// crate — trigger a `bump`: the bumper need not be an object, since we only
|
|
|
|
|
|
/// return the *bumped* object's id (the direction it came from is supplied by
|
|
|
|
|
|
/// the caller from its move direction).
|
|
|
|
|
|
pub fn bump_target(&self, x: usize, y: usize, dir: Direction) -> Option<ObjectId> {
|
|
|
|
|
|
let (dx, dy): (i64, i64) = dir.into();
|
|
|
|
|
|
let (mut cx, mut cy) = (x, y);
|
|
|
|
|
|
loop {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
if !self.in_bounds((cx as i64, cy as i64)) {
|
|
|
|
|
|
return None; // push chain runs off the board
|
2026-07-09 00:18:40 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
|
|
|
|
|
|
if let Some(Tile::Object(obj)) = self.get(cx, cy) {
|
|
|
|
|
|
if obj.enter_response.transmits_push(dir) {
|
|
|
|
|
|
// An object we can push through, go to the next cell
|
|
|
|
|
|
cx = (cx as i64 + dx) as usize;
|
|
|
|
|
|
cy = (cy as i64 + dy) as usize;
|
|
|
|
|
|
} else if obj.enter_response.bumpable(dir) {
|
|
|
|
|
|
return Some(obj.scripting.id)
|
|
|
|
|
|
}
|
2026-07-08 13:21:27 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// 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 {
|
2026-06-28 00:12:52 -05:00
|
|
|
|
let (dx, dy): (i64, i64) = dir.into();
|
2026-06-07 00:19:53 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-28 00:12:52 -05:00
|
|
|
|
let next = (cx as i64 + dx, cy as i64 + dy);
|
2026-06-07 00:19:53 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 01:32:47 -05:00
|
|
|
|
/// Whether the solid at `(x, y)` can be **shifted** one step in `dir`: it is
|
|
|
|
|
|
/// itself a pushable solid *and* the next cell is either empty or holds another
|
|
|
|
|
|
/// pushable solid.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Unlike [`can_push`](Board::can_push) this inspects only the single cell
|
|
|
|
|
|
/// ahead — it does **not** verify the whole chain ends in open space. It is the
|
|
|
|
|
|
/// right test for a simultaneous rotation/shift (applied via
|
|
|
|
|
|
/// [`apply_swap`](Board::apply_swap)), where a destination is occupied by
|
|
|
|
|
|
/// another pushable that is itself moving the same frame. Returns `false` if
|
|
|
|
|
|
/// `(x, y)` holds no pushable, or the cell ahead runs off the board.
|
2026-06-21 18:27:45 -05:00
|
|
|
|
///
|
2026-06-21 19:15:43 -05:00
|
|
|
|
/// The **player** is always a blocker: a shift can't relocate the player, and
|
|
|
|
|
|
/// `apply_swap` refuses to overwrite it, so a cell holding the player is never
|
|
|
|
|
|
/// an acceptable shift destination.
|
2026-06-21 01:32:47 -05:00
|
|
|
|
pub fn can_shift(&self, x: usize, y: usize, dir: Direction) -> bool {
|
|
|
|
|
|
// The source must hold a solid pushable in `dir`.
|
|
|
|
|
|
if !self.is_pushable(x, y, dir) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-06-28 00:12:52 -05:00
|
|
|
|
let (dx, dy): (i64, i64) = dir.into();
|
|
|
|
|
|
let next = (x as i64 + dx, y as i64 + dy);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
if !self.in_bounds(next) {
|
|
|
|
|
|
return false; // nothing to shift into off the board
|
|
|
|
|
|
}
|
|
|
|
|
|
let (nx, ny) = (next.0 as usize, next.1 as usize);
|
2026-06-21 19:15:43 -05:00
|
|
|
|
// The player always blocks a shift: a shift can't relocate it and
|
|
|
|
|
|
// `apply_swap` refuses to overwrite it. (The player reads as pushable, so it
|
|
|
|
|
|
// must be excluded explicitly before the cell-ahead test below.)
|
2026-07-24 21:52:05 -05:00
|
|
|
|
if self.get(nx, ny).as_ref().is_some_and(Tile::player) {
|
2026-06-21 19:15:43 -05:00
|
|
|
|
return false;
|
2026-06-21 18:27:45 -05:00
|
|
|
|
}
|
2026-06-21 01:32:47 -05:00
|
|
|
|
// The cell ahead is acceptable if it is empty or another pushable solid.
|
|
|
|
|
|
self.is_passable(nx, ny) || self.is_pushable(nx, ny, dir)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// 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.
|
2026-07-11 11:47:45 -05:00
|
|
|
|
/// Returns the cells the shoved solids moved **into** (each chain cell stepped
|
|
|
|
|
|
/// one cell in `dir`), so the caller can fire `enter` on any non-solid object a
|
|
|
|
|
|
/// pushed solid landed on. Empty when nothing moved.
|
|
|
|
|
|
pub fn push(&mut self, x: usize, y: usize, dir: Direction) -> Vec<(usize, usize)> {
|
2026-06-07 00:19:53 -05:00
|
|
|
|
if !self.can_push(x, y, dir) {
|
2026-07-11 11:47:45 -05:00
|
|
|
|
return Vec::new();
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
2026-06-28 00:12:52 -05:00
|
|
|
|
let (dx, dy): (i64, i64) = dir.into();
|
2026-06-07 00:19:53 -05:00
|
|
|
|
// 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));
|
2026-06-28 00:12:52 -05:00
|
|
|
|
cx = (cx as i64 + dx) as usize;
|
|
|
|
|
|
cy = (cy as i64 + dy) as usize;
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
for &(px, py) in chain.iter().rev() {
|
|
|
|
|
|
self.shift_solid(px, py, dx, dy);
|
|
|
|
|
|
}
|
2026-07-11 11:47:45 -05:00
|
|
|
|
// Each solid ended up one step along `dir`; those destination cells are
|
|
|
|
|
|
// where an `enter` may need to fire.
|
|
|
|
|
|
chain
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|&(px, py)| ((px as i64 + dx) as usize, (py as i64 + dy) as usize))
|
|
|
|
|
|
.collect()
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
|
|
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// A solid object is relocated; otherwise the solid terrain archetype (a crate)
|
|
|
|
|
|
/// is moved, leaving a transparent cell behind so the floor shows through. The
|
|
|
|
|
|
/// caller guarantees the destination is already clear.
|
2026-06-28 00:12:52 -05:00
|
|
|
|
fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) {
|
|
|
|
|
|
let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize);
|
2026-07-24 21:52:05 -05:00
|
|
|
|
if self.get(x, y).is_none() {
|
2026-06-21 19:15:43 -05:00
|
|
|
|
return; // nothing to shift
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
*self.get_mut(tx, ty) = self.get_mut(x, y).take();
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 11:47:45 -05:00
|
|
|
|
/// Returns the [`ObjectId`]s of the **non-solid** objects at `(x, y)`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// These are the targets of an `enter` hook when a solid relocates onto the
|
|
|
|
|
|
/// cell (terrain is always solid, so only objects can be non-solid). Mirrors
|
|
|
|
|
|
/// [`object_ids_at`](Board::object_ids_at) / [`solid_object_id_at`](Board::solid_object_id_at).
|
2026-07-24 21:52:05 -05:00
|
|
|
|
pub fn sensor_ids_at(&self, x: usize, y: usize) -> Vec<ObjectId> {
|
|
|
|
|
|
self.sensors
|
2026-07-11 11:47:45 -05:00
|
|
|
|
.iter()
|
2026-07-24 21:52:05 -05:00
|
|
|
|
.filter(|s| s.x == x && s.y == y)
|
|
|
|
|
|
.map(|s| s.scripting.id)
|
2026-07-11 11:47:45 -05:00
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
/// Find and return the portal at the given location
|
|
|
|
|
|
pub fn portal_at(&self, x: usize, y: usize) -> Option<&Portal> {
|
|
|
|
|
|
self.portals.iter().find(|&portal| portal.location() == (x, y))
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 17:53:47 -05:00
|
|
|
|
/// Editor primitive: stamps `arch` (with visual `glyph`) into the cell at
|
|
|
|
|
|
/// `(x, y)`, applying the editor's placement/removal rules.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Two cases, keyed only on the archetype (the floor is **never** touched — the
|
|
|
|
|
|
/// drawing tools cannot place, remove, or alter a floor):
|
|
|
|
|
|
///
|
|
|
|
|
|
/// - **Terrain** (`arch != Empty`, always solid today): removes any solid object
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// already in the cell, then writes `(glyph, arch)` into the grid cell.
|
|
|
|
|
|
/// - **Erase** (`arch == Empty`): removes the grid cell's terrain *and* every
|
|
|
|
|
|
/// object in it, leaving the floor beneath in place.
|
2026-06-20 17:53:47 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// A vacated grid cell becomes a transparent `Empty` so the floor shows through.
|
|
|
|
|
|
/// Panics if `(x, y)` is out of bounds.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
pub fn place(&mut self, x: usize, y: usize, spec: Option<TileSpec>) -> Result<(), String> {
|
|
|
|
|
|
if let Some(spec) = spec {
|
|
|
|
|
|
let tile = spec.into_tile(&mut self.next_object_id)?;
|
|
|
|
|
|
*self.get_mut(x, y) = Some(tile);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
*self.get_mut(x, y) = None;
|
2026-06-20 17:53:47 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
Ok(())
|
2026-06-20 17:53:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:10 -05:00
|
|
|
|
/// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script
|
2026-07-11 11:47:45 -05:00
|
|
|
|
/// `shift()` fn. Returns a [`ShiftOutcome`] carrying any error [`LogLine`]s for
|
|
|
|
|
|
/// the caller to log plus the `(from, to)` relocations it performed (so the
|
|
|
|
|
|
/// caller can fire `enter` on non-solids each moved solid landed on).
|
2026-07-24 21:52:05 -05:00
|
|
|
|
pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> Result<Vec<((i64, i64), (i64, i64))>, String> {
|
2026-06-21 22:04:10 -05:00
|
|
|
|
// Validate all the cells are in bounds, error if not:
|
|
|
|
|
|
if cells.iter().any(|&c| !self.in_bounds(c)) {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
return Err("Called shift() with a cell out of bounds".to_string())
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:10 -05:00
|
|
|
|
// Get all the Solids at these cells:
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let solids: Vec<_> = cells.iter().map(|&c| self.get_mut(c.0 as usize, c.1 as usize).take()).collect();
|
2026-06-21 22:04:10 -05:00
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
// Find which ones are blockers
|
2026-06-21 22:04:10 -05:00
|
|
|
|
let mut immobile = HashSet::new();
|
|
|
|
|
|
for (curr_idx, curr) in solids.iter().enumerate() {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let pushable = curr.as_ref().map_or(true, |c| c.shiftable());
|
|
|
|
|
|
if !pushable {
|
2026-06-21 22:04:10 -05:00
|
|
|
|
immobile.insert(curr_idx);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:10 -05:00
|
|
|
|
// Trace back from each immobile until we find an empty:
|
|
|
|
|
|
let mut blocked = HashSet::new();
|
|
|
|
|
|
for curr_idx in immobile {
|
|
|
|
|
|
let mut prev_idx = curr_idx;
|
|
|
|
|
|
loop {
|
|
|
|
|
|
if solids[prev_idx].is_some() && !blocked.contains(&prev_idx) {
|
|
|
|
|
|
blocked.insert(prev_idx);
|
|
|
|
|
|
prev_idx = (prev_idx + cells.len() - 1) % cells.len();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
break
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 11:47:45 -05:00
|
|
|
|
// Now, move anything that we've decided is not blocked, recording each
|
|
|
|
|
|
// relocation so the caller can fire `enter` at every destination.
|
|
|
|
|
|
let mut moves = Vec::new();
|
2026-07-24 21:52:05 -05:00
|
|
|
|
for (curr_idx, curr) in solids.into_iter().enumerate() {
|
|
|
|
|
|
if let Some(solid) = curr {
|
|
|
|
|
|
if !blocked.contains(&curr_idx) {
|
|
|
|
|
|
// Not blocked, write it to target
|
|
|
|
|
|
let origin = cells[curr_idx];
|
|
|
|
|
|
let target = cells[(curr_idx + 1) % cells.len()];
|
|
|
|
|
|
*self.get_mut(target.0 as usize, target.1 as usize) = Some(solid);
|
|
|
|
|
|
moves.push((origin, target));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// it was blocked so just write it back where it was
|
|
|
|
|
|
let origin = cells[curr_idx];
|
|
|
|
|
|
*self.get_mut(origin.0 as usize, origin.1 as usize) = Some(solid);
|
|
|
|
|
|
}
|
2026-06-21 18:27:45 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
Ok(moves)
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 00:41:38 -05:00
|
|
|
|
/// Clear the queues of all objects on this board: called when entering a board, objects
|
|
|
|
|
|
/// don't retain their state across board visits (they get initialized again, but can
|
|
|
|
|
|
/// store things in the board registry)
|
|
|
|
|
|
pub fn clear_all_queues(&mut self) {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
for cell in self.grid.iter_mut() {
|
|
|
|
|
|
if let Some(Tile::Object(obj)) = cell {
|
|
|
|
|
|
obj.scripting.queue.clear()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn player_pos(&self) -> (usize, usize) {
|
|
|
|
|
|
self.grid.iter().enumerate().find_map(|(i, cell)| {
|
|
|
|
|
|
if matches!(cell, Some(Tile::Player)) {
|
|
|
|
|
|
Some((i % self.width, i / self.width))
|
|
|
|
|
|
} else { None }
|
|
|
|
|
|
}).expect("No player found!") // This should never happen, player presence is validated when building a board
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn get_hookable(&self, id: ObjectId) -> Option<Box<dyn Hookable + '_>> {
|
|
|
|
|
|
// Search sensors first because it's probably shorter
|
|
|
|
|
|
for sensor in self.sensors.iter() {
|
|
|
|
|
|
if sensor.scripting.id == id {
|
|
|
|
|
|
return Some(Box::new(sensor))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (i, tile) in self.grid.iter().enumerate() {
|
|
|
|
|
|
if let Some(Tile::Object(obj)) = tile && obj.scripting.id == id {
|
|
|
|
|
|
return Some(Box::new(LocatedObject(obj, (i % self.width, i / self.width))))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn get_named(&self, name: &str) -> Option<Box<dyn Hookable + '_>> {
|
|
|
|
|
|
// Search sensors first because it's probably shorter
|
|
|
|
|
|
for sensor in self.sensors.iter() {
|
|
|
|
|
|
if let Some(n) = sensor.scripting.name.as_ref() &&
|
|
|
|
|
|
n.as_str() == name {
|
|
|
|
|
|
return Some(Box::new(sensor))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (i, tile) in self.grid.iter().enumerate() {
|
|
|
|
|
|
if let Some(Tile::Object(obj)) = tile &&
|
|
|
|
|
|
let Some(n) = obj.scripting.name.as_ref() &&
|
|
|
|
|
|
n.as_str() == name {
|
|
|
|
|
|
return Some(Box::new(LocatedObject(obj, (i % self.width, i / self.width))))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn get_tagged(&self, tag: &str) -> Vec<Box<dyn Hookable + '_>> {
|
|
|
|
|
|
self.sorted_hookables().into_iter().filter(|hookable| {
|
|
|
|
|
|
hookable.scriptable().tags.contains(tag)
|
|
|
|
|
|
}).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn scripting_mut(&mut self, id: ObjectId) -> Option<&mut ScriptAttributes> {
|
|
|
|
|
|
for sensor in self.sensors.iter_mut() {
|
|
|
|
|
|
if sensor.scripting.id == id {
|
|
|
|
|
|
return Some(&mut sensor.scripting)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for tile in self.grid.iter_mut() {
|
|
|
|
|
|
if let Some(Tile::Object(obj)) = tile && obj.scripting.id == id {
|
|
|
|
|
|
return Some(&mut obj.scripting)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn sorted_hookables(&self) -> Vec<Box<dyn Hookable + '_>> {
|
|
|
|
|
|
// Collect all the sensors
|
|
|
|
|
|
let mut hookables = self.sensors.iter().map(|s| Box::new(s) as Box<dyn Hookable>).collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
|
|
// Add the objects into it
|
|
|
|
|
|
for (i, tile) in self.grid.iter().enumerate() {
|
|
|
|
|
|
if let Some(Tile::Object(obj)) = tile {
|
|
|
|
|
|
hookables.push(Box::new(LocatedObject(obj, (i % self.width, i / self.width))))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sort by id
|
|
|
|
|
|
hookables.sort_by(|a, b| a.id().cmp(&b.id()));
|
|
|
|
|
|
hookables
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn remove_object(&mut self, id: ObjectId) {
|
|
|
|
|
|
if let Some(tile) = self.grid.iter_mut().find(|cell| { matches!(cell, Some(Tile::Object(obj)) if obj.scripting.id == id) }) {
|
|
|
|
|
|
tile.take();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.sensors.retain(|s| s.id() != id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn named_portal(&self, name: &str) -> Option<&Portal> {
|
|
|
|
|
|
self.portals.iter().find(|p| p.name == name)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn move_sensor(&mut self, id: ObjectId, dir: Direction) {
|
|
|
|
|
|
if let Some((sensor_idx, _)) = self.sensors.iter().enumerate().find(|(idx, s)| s.scripting.id == id) {
|
|
|
|
|
|
let new_loc = (self.sensors[sensor_idx].x as i64 + dir.dx(), self.sensors[sensor_idx].y as i64 + dir.dy());
|
|
|
|
|
|
if self.in_bounds(new_loc) {
|
|
|
|
|
|
self.sensors[sensor_idx].x = new_loc.0 as usize;
|
|
|
|
|
|
self.sensors[sensor_idx].y = new_loc.1 as usize;
|
|
|
|
|
|
}
|
2026-07-06 00:41:38 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
pub(crate) mod tests {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
use std::assert_matches;
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use super::Board;
|
2026-07-24 21:52:05 -05:00
|
|
|
|
use crate::builtin::Builtin;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
use crate::floor::Floor;
|
2026-06-07 00:33:16 -05:00
|
|
|
|
use crate::glyph::Glyph;
|
2026-07-25 00:09:53 -05:00
|
|
|
|
use crate::utils::{Direction, ObjectId};
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use color::Rgba8;
|
2026-07-24 21:52:05 -05:00
|
|
|
|
use std::collections::HashMap;
|
2026-07-25 00:09:53 -05:00
|
|
|
|
use crate::tile::{DrawLayer, IntoTile, Optics, ScriptAttributes, Sensor, SensorSpec, Tile, TileSpec};
|
2026-06-07 00:33:16 -05:00
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
/// Builds an all-empty `w×h` board.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// The grid is fully transparent (a blank floor), so terrain stamped via
|
|
|
|
|
|
/// [`crate_at`] etc. lands on the single grid. Use [`add_floor`] to give the
|
|
|
|
|
|
/// board a visible fixed floor underneath.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn open_board(
|
|
|
|
|
|
w: usize,
|
|
|
|
|
|
h: usize,
|
2026-07-24 21:52:05 -05:00
|
|
|
|
player_pos: (usize, usize)
|
2026-06-07 00:33:16 -05:00
|
|
|
|
) -> Board {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = Board {
|
2026-06-07 00:33:16 -05:00
|
|
|
|
name: "test".into(),
|
|
|
|
|
|
width: w,
|
|
|
|
|
|
height: h,
|
2026-07-24 21:52:05 -05:00
|
|
|
|
grid: vec![None; w * h],
|
2026-07-10 23:16:28 -05:00
|
|
|
|
floor: Floor::Blank,
|
2026-07-14 23:48:50 -05:00
|
|
|
|
sensors: Vec::new(),
|
2026-06-07 00:33:16 -05:00
|
|
|
|
portals: Vec::new(),
|
2026-07-24 21:52:05 -05:00
|
|
|
|
next_object_id: 1,
|
2026-07-11 12:40:35 -05:00
|
|
|
|
dark: false,
|
2026-06-13 17:58:04 -05:00
|
|
|
|
registry: HashMap::new(),
|
2026-07-24 21:52:05 -05:00
|
|
|
|
};
|
|
|
|
|
|
player_at(&mut board, player_pos.0, player_pos.1);
|
|
|
|
|
|
board
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Gives the board a uniform fixed floor glyph (the single-grid replacement for
|
|
|
|
|
|
/// the old separate floor layer).
|
2026-06-15 23:35:18 -05:00
|
|
|
|
pub(crate) fn add_floor(board: &mut Board, glyph: Glyph) {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
board.floor = Floor::Fixed(glyph);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Stamps a crate cell onto the grid.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
*board.get_mut(x, y) = Some(TileSpec::krate().into_tile(&mut board.next_object_id).unwrap());
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Stamps a wall cell onto the grid.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
*board.get_mut(x, y) = Some(TileSpec::wall().into_tile(&mut board.next_object_id).unwrap());
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
/// Stamps a gem cell onto the grid.
|
|
|
|
|
|
pub(crate) fn gem_at(board: &mut Board, x: usize, y: usize) {
|
|
|
|
|
|
*board.get_mut(x, y) = Some(TileSpec::gem().into_tile(&mut board.next_object_id).unwrap());
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 00:09:53 -05:00
|
|
|
|
/// Stamps the builtin named `kind` (any alias accepted by [`Builtin::from_name`],
|
|
|
|
|
|
/// e.g. `"spinner_cw"`, `"pusher_east"`) onto the grid, returning its id.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The generic counterpart to [`crate_at`]/[`wall_at`]/[`gem_at`]: `into_tile`
|
|
|
|
|
|
/// attaches the family's [`ScriptKey`](crate::tile::ScriptKey) and the
|
|
|
|
|
|
/// `BUILTIN_<kind>` tag, so the object is fully live with no world script pool.
|
|
|
|
|
|
/// Panics on an unknown `kind`.
|
|
|
|
|
|
pub(crate) fn builtin_at(board: &mut Board, x: usize, y: usize, kind: &str) -> ObjectId {
|
|
|
|
|
|
let tile = TileSpec::Builtin { kind: kind.to_string(), glyph: None }
|
|
|
|
|
|
.into_tile(&mut board.next_object_id)
|
|
|
|
|
|
.unwrap_or_else(|e| panic!("{e}"));
|
|
|
|
|
|
let id = match &tile {
|
|
|
|
|
|
Tile::Object(obj) => obj.scripting.id,
|
|
|
|
|
|
Tile::Player => unreachable!("a builtin never resolves to the player"),
|
|
|
|
|
|
};
|
|
|
|
|
|
*board.get_mut(x, y) = Some(tile);
|
|
|
|
|
|
id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Adds an invisible, script-only [`Sensor`] at `(x, y)` running the world script
|
|
|
|
|
|
/// named `script`, returning its id.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This is how a test gets "a scripted thing that doesn't get in the way": every
|
|
|
|
|
|
/// object on the grid is solid now, so a script host that must not block movement
|
|
|
|
|
|
/// (or must share a cell) has to live off-grid in [`Board::sensors`].
|
|
|
|
|
|
pub(crate) fn sensor_at(board: &mut Board, x: usize, y: usize, script: &str) -> ObjectId {
|
|
|
|
|
|
let sensor = SensorSpec {
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
script: Some(script.to_string()),
|
|
|
|
|
|
glyph: Glyph::transparent(),
|
|
|
|
|
|
optics: Optics::default(),
|
|
|
|
|
|
name: None,
|
|
|
|
|
|
tags: Vec::new(),
|
|
|
|
|
|
draw_layer: DrawLayer::Below,
|
|
|
|
|
|
}
|
|
|
|
|
|
.into_sensor(&mut board.next_object_id);
|
|
|
|
|
|
let id = sensor.scripting.id;
|
|
|
|
|
|
board.sensors.push(sensor);
|
|
|
|
|
|
id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
/// Stamps a player cell onto the grid.
|
|
|
|
|
|
pub(crate) fn player_at(board: &mut Board, x: usize, y: usize) {
|
|
|
|
|
|
*board.get_mut(x, y) = Some(TileSpec::player().into_tile(&mut board.next_object_id).unwrap());
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
pub(crate) fn lamp_at(board: &mut Board, x: usize, y: usize) {
|
|
|
|
|
|
let lamp = Sensor {
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
draw_layer: DrawLayer::Above,
|
|
|
|
|
|
scripting: ScriptAttributes {
|
|
|
|
|
|
id: board.next_object_id,
|
|
|
|
|
|
glyph: Glyph { tile: 1, fg: Rgba8 { r: 255, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } },
|
|
|
|
|
|
optics: Optics {
|
|
|
|
|
|
glow: 4,
|
|
|
|
|
|
opaque: false
|
|
|
|
|
|
},
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
board.next_object_id += 1;
|
|
|
|
|
|
board.sensors.push(lamp);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
pub(crate) fn is_builtin(board: &Board, x: usize, y: usize, tag: &str) -> bool {
|
|
|
|
|
|
if let Some(Tile::Object(obj)) = board.get(x, y) {
|
|
|
|
|
|
obj.scripting.tags.contains(&format!("BUILTIN_{tag}"))
|
|
|
|
|
|
} else { false }
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn in_bounds_checks_grid_boundaries() {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let board = open_board(3, 2, (0, 0));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
assert!(board.in_bounds((0, 0)));
|
|
|
|
|
|
assert!(board.in_bounds((2, 1)));
|
|
|
|
|
|
assert!(!board.in_bounds((-1, 0)));
|
|
|
|
|
|
assert!(!board.in_bounds((0, -1)));
|
|
|
|
|
|
assert!(!board.in_bounds((3, 0)));
|
|
|
|
|
|
assert!(!board.in_bounds((0, 2)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn can_push_is_read_only_and_correct() {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(3, 1, (0, 0));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
assert!(board.can_push(1, 0, Direction::East));
|
2026-07-24 21:52:05 -05:00
|
|
|
|
assert_matches!(board.get(1, 0), Some(Tile::Object(_)));
|
|
|
|
|
|
assert_matches!(board.get(2, 0), None);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(3, 1, (0, 0));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
wall_at(&mut board, 2, 0);
|
|
|
|
|
|
assert!(!board.can_push(1, 0, Direction::East));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 01:32:47 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn can_shift_only_checks_the_cell_ahead() {
|
|
|
|
|
|
// Source must be pushable.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(4, 1, (3, 0));
|
2026-06-21 01:32:47 -05:00
|
|
|
|
assert!(!board.can_shift(0, 0, Direction::East)); // empty source
|
|
|
|
|
|
|
|
|
|
|
|
// Crate with open space ahead: shiftable.
|
|
|
|
|
|
crate_at(&mut board, 0, 0);
|
|
|
|
|
|
assert!(board.can_shift(0, 0, Direction::East));
|
2026-07-24 21:52:05 -05:00
|
|
|
|
assert_matches!(board.get(0, 0), Some(Tile::Object(_)));
|
2026-06-21 01:32:47 -05:00
|
|
|
|
|
|
|
|
|
|
// Crate with another pushable crate ahead: still shiftable (unlike can_push,
|
|
|
|
|
|
// which would follow the chain to the wall and fail).
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(4, 1, (3, 0));
|
2026-06-21 01:32:47 -05:00
|
|
|
|
crate_at(&mut board, 0, 0);
|
|
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
wall_at(&mut board, 2, 0);
|
|
|
|
|
|
assert!(board.can_shift(0, 0, Direction::East));
|
|
|
|
|
|
assert!(!board.can_push(0, 0, Direction::East));
|
|
|
|
|
|
|
|
|
|
|
|
// Crate with a non-pushable wall ahead: not shiftable.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(3, 1, (2, 0));
|
2026-06-21 01:32:47 -05:00
|
|
|
|
crate_at(&mut board, 0, 0);
|
|
|
|
|
|
wall_at(&mut board, 1, 0);
|
|
|
|
|
|
assert!(!board.can_shift(0, 0, Direction::East));
|
|
|
|
|
|
|
|
|
|
|
|
// Crate at the board edge facing off-board: not shiftable.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(2, 1, (0, 0));
|
2026-06-21 01:32:47 -05:00
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
assert!(!board.can_shift(1, 0, Direction::East));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 18:27:45 -05:00
|
|
|
|
#[test]
|
2026-06-21 19:15:43 -05:00
|
|
|
|
fn can_shift_treats_the_player_as_a_blocker() {
|
|
|
|
|
|
// The player is always a blocker for a shift — even a grab gem may not shift
|
|
|
|
|
|
// onto it (grab now fires only on player movement, not on being shifted in).
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(2, 1, (1, 0));
|
|
|
|
|
|
gem_at(&mut board, 0, 0);
|
2026-06-21 19:15:43 -05:00
|
|
|
|
assert!(!board.can_shift(0, 0, Direction::East));
|
2026-06-21 18:27:45 -05:00
|
|
|
|
|
2026-06-21 19:15:43 -05:00
|
|
|
|
// A plain crate likewise may not shift onto the player.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(2, 1, (1, 0));
|
2026-06-21 18:27:45 -05:00
|
|
|
|
crate_at(&mut board, 0, 0);
|
|
|
|
|
|
assert!(!board.can_shift(0, 0, Direction::East));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:33:16 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn push_into_player_pushes_player() {
|
|
|
|
|
|
// Crate shoved east into the player slides the player along into open space.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(4, 1, (2, 0));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
assert!(board.can_push(1, 0, Direction::East));
|
|
|
|
|
|
board.push(1, 0, Direction::East);
|
2026-07-24 21:52:05 -05:00
|
|
|
|
assert!(board.get(1, 0).is_none());
|
|
|
|
|
|
assert!(is_builtin(&board, 2, 0,"crate"));
|
|
|
|
|
|
assert_eq!(board.player_pos(), (3, 0));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn push_into_player_blocked_by_wall() {
|
|
|
|
|
|
// Player backed against a wall: push has nowhere to go, nothing moves.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(4, 1, (2, 0));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
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
|
2026-07-24 21:52:05 -05:00
|
|
|
|
assert!(is_builtin(&board, 1, 0, "crate"));
|
|
|
|
|
|
assert_eq!(board.player_pos(), (2, 0));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn glyph_at_uses_floor_for_empty_and_grid_for_solid() {
|
|
|
|
|
|
// Player parked at (2,0) so it doesn't overlap either asserted cell.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(3, 1, (2, 0));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
let floor_glyph = Glyph {
|
|
|
|
|
|
tile: '.' as u32,
|
2026-06-15 23:35:18 -05:00
|
|
|
|
fg: Rgba8 {
|
|
|
|
|
|
r: 10,
|
|
|
|
|
|
g: 20,
|
|
|
|
|
|
b: 30,
|
|
|
|
|
|
a: 255,
|
|
|
|
|
|
},
|
|
|
|
|
|
bg: Rgba8 {
|
|
|
|
|
|
r: 1,
|
|
|
|
|
|
g: 2,
|
|
|
|
|
|
b: 3,
|
|
|
|
|
|
a: 255,
|
|
|
|
|
|
},
|
2026-06-07 00:33:16 -05:00
|
|
|
|
};
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// A fixed floor attribute, a wall on the grid at (0,0).
|
2026-06-15 23:35:18 -05:00
|
|
|
|
add_floor(&mut board, floor_glyph);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
wall_at(&mut board, 0, 0);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// The wall (solid) draws over the floor; the empty cell reveals the floor.
|
2026-07-14 23:48:50 -05:00
|
|
|
|
assert_eq!(board.glyph_at(0, 0), Builtin::Wall.default_glyph_for("wall"));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
assert_eq!(board.glyph_at(1, 0), floor_glyph);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 01:32:47 -05:00
|
|
|
|
#[test]
|
2026-06-21 22:35:35 -05:00
|
|
|
|
fn apply_shift_out_of_bounds_rejects_immediately() {
|
|
|
|
|
|
// apply_shift validates all cells upfront; any out-of-bounds cell causes immediate failure.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(3, 1, (2, 0));
|
2026-06-21 01:32:47 -05:00
|
|
|
|
crate_at(&mut board, 0, 0);
|
2026-06-21 22:35:35 -05:00
|
|
|
|
let errs = board.apply_shift(&[(0, 0), (9, 0)]);
|
2026-07-24 21:52:05 -05:00
|
|
|
|
assert!(errs.is_err());
|
|
|
|
|
|
assert!(is_builtin(&board, 0, 0, "crate")); // unchanged
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-21 22:35:35 -05:00
|
|
|
|
fn apply_shift_wall_stops_cascade_but_empty_limits_it() {
|
|
|
|
|
|
// A non-pushable Wall is immobile. Backward cascade from the wall traces
|
|
|
|
|
|
// through preceding solids until it hits empty, marking those as blocked.
|
|
|
|
|
|
// Solids on the *other* side of the empty (outside the blocked region) still move.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(6, 1, (5, 0));
|
2026-06-21 01:32:47 -05:00
|
|
|
|
crate_at(&mut board, 0, 0);
|
2026-06-21 22:35:35 -05:00
|
|
|
|
// (1,0) stays empty
|
|
|
|
|
|
wall_at(&mut board, 2, 0);
|
|
|
|
|
|
crate_at(&mut board, 3, 0);
|
|
|
|
|
|
crate_at(&mut board, 4, 0);
|
|
|
|
|
|
|
|
|
|
|
|
// Cycle: idx 0 → idx 1 → idx 2 → idx 3 → idx 4 → idx 0 (wrap)
|
|
|
|
|
|
// immobile = {2} (Wall, Pushable::No)
|
|
|
|
|
|
// Backward trace from idx 2: solids[2]=Some → blocked.insert(2), prev=1
|
|
|
|
|
|
// solids[1]=None (empty) → break
|
|
|
|
|
|
// blocked = {2}; Crate at (0,0) is NOT blocked
|
|
|
|
|
|
// Result: Crate(0,0)→(1,0), empty→no-op, Wall stays at (2,0),
|
|
|
|
|
|
// Crate(3,0)→(4,0), Crate(4,0)→(0,0) wrap
|
|
|
|
|
|
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]);
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
assert!(is_builtin(&board, 0, 0, "crate")); // wrapped from (4,0)
|
|
|
|
|
|
assert!(is_builtin(&board, 1, 0, "crate")); // moved from (0,0)
|
|
|
|
|
|
assert!(is_builtin(&board, 2, 0, "wall")); // blocked, immobile
|
|
|
|
|
|
assert!(board.get(3, 0).is_none()); // cleared, crate moved
|
|
|
|
|
|
assert!(is_builtin(&board, 4, 0, "crate")); // moved from (3,0)
|
2026-06-21 18:27:45 -05:00
|
|
|
|
}
|
2026-06-21 22:35:35 -05:00
|
|
|
|
|
2026-07-11 12:40:35 -05:00
|
|
|
|
#[test]
|
2026-07-11 14:47:08 -05:00
|
|
|
|
fn lighting_none_when_not_dark() {
|
|
|
|
|
|
// A lit board needs no lighting; front-ends draw every cell.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let board = open_board(5, 1, (0, 0));
|
2026-07-11 12:40:35 -05:00
|
|
|
|
assert!(!board.dark);
|
2026-07-11 14:47:08 -05:00
|
|
|
|
assert!(board.lighting(10).is_none());
|
2026-07-11 12:40:35 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn wall_is_opaque_empty_is_not() {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(3, 1, (0, 0));
|
2026-07-11 12:40:35 -05:00
|
|
|
|
wall_at(&mut board, 1, 0);
|
|
|
|
|
|
assert!(board.is_opaque_at(1, 0)); // wall blocks sight
|
|
|
|
|
|
assert!(!board.is_opaque_at(2, 0)); // empty cell is transparent
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dark_board_hides_cells_behind_a_wall() {
|
|
|
|
|
|
// Player at the left end of a 1-wide corridor; a wall at x=2 occludes
|
2026-07-11 14:47:08 -05:00
|
|
|
|
// everything past it. The player's torch lights cells before the wall
|
|
|
|
|
|
// (and the wall itself); the cells behind the wall are neither lit nor
|
|
|
|
|
|
// in line of sight, so they are not visible.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(5, 1, (0, 0));
|
2026-07-11 12:40:35 -05:00
|
|
|
|
board.dark = true;
|
|
|
|
|
|
wall_at(&mut board, 2, 0);
|
|
|
|
|
|
|
2026-07-11 14:47:08 -05:00
|
|
|
|
let lit = board.lighting(10).expect("dark board yields Lighting");
|
|
|
|
|
|
assert!(lit.is_visible(0, 0)); // the player's own cell
|
|
|
|
|
|
assert!(lit.is_visible(1, 0)); // open cell before the wall
|
|
|
|
|
|
assert!(lit.is_visible(2, 0)); // the wall itself (light_walls = true)
|
|
|
|
|
|
assert!(!lit.is_visible(3, 0)); // occluded behind the wall
|
|
|
|
|
|
assert!(!lit.is_visible(4, 0)); // occluded behind the wall
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn unlit_cell_in_sight_is_not_visible() {
|
|
|
|
|
|
// A long lit-free corridor: with a tiny torch, far cells are in line of
|
|
|
|
|
|
// sight but receive no light, so they are not visible (LOS ∩ lit).
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(10, 1, (0, 0));
|
2026-07-11 14:47:08 -05:00
|
|
|
|
board.dark = true;
|
|
|
|
|
|
let lit = board.lighting(2).expect("dark board yields Lighting");
|
|
|
|
|
|
assert!(lit.is_visible(0, 0)); // at the torch
|
|
|
|
|
|
assert!(lit.is_visible(1, 0)); // within the torch radius
|
|
|
|
|
|
assert!(!lit.is_visible(8, 0)); // in sight but unlit → dark
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn object_light_tints_toward_its_color() {
|
|
|
|
|
|
// A dark board with no player torch and one red-glyph light object: the
|
|
|
|
|
|
// object's cell is lit red, so a white base tints red (green/blue killed).
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(3, 1, (1, 0));
|
2026-07-11 14:47:08 -05:00
|
|
|
|
board.dark = true;
|
2026-07-24 21:52:05 -05:00
|
|
|
|
lamp_at(&mut board, 1, 0);
|
2026-07-11 14:47:08 -05:00
|
|
|
|
|
|
|
|
|
|
let lit = board.lighting(0).expect("dark board yields Lighting"); // no player torch
|
|
|
|
|
|
let white = Rgba8 { r: 255, g: 255, b: 255, a: 255 };
|
|
|
|
|
|
let t = lit.tint(1, 0, white);
|
|
|
|
|
|
assert!(t.r > 0, "red channel survives");
|
|
|
|
|
|
assert_eq!(t.g, 0, "green killed by red light");
|
|
|
|
|
|
assert_eq!(t.b, 0, "blue killed by red light");
|
2026-07-11 12:40:35 -05:00
|
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|