new layer model

This commit is contained in:
2026-07-10 23:16:28 -05:00
parent 325d2c27dd
commit ad95a9cd8d
36 changed files with 1040 additions and 897 deletions
-4
View File
@@ -6,7 +6,6 @@
//! object that issued it).
use std::fmt::Debug;
use crate::log::LogLine;
use crate::utils::{Direction, ObjectId};
use color::Rgba8;
use rhai::Dynamic;
@@ -73,8 +72,6 @@ pub enum Action {
Move(Direction),
/// Set the source object's glyph tile index.
SetTile(u32),
/// Append a styled line to the game log.
Log(LogLine),
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
SetTag {
target: ObjectId,
@@ -134,7 +131,6 @@ impl Debug for Action {
match self {
Action::Move(dir) => write!(f, "Move({:?})", dir),
Action::SetTile(i) => write!(f, "SetTile({i})"),
Action::Log(msg) => write!(f, "Log({:?})", msg),
Action::SetTag { .. } => write!(f, "SetTag"),
Action::Say(_, _) => write!(f, "Say"),
Action::Delay(t) => write!(f, "Delay({t})"),
+4 -4
View File
@@ -19,13 +19,13 @@ use crate::{Board, Direction};
use crate::api::object_info::ObjectInfo;
use crate::api::registry::Registry;
use crate::script::Registerable;
use crate::utils::{ErrorSink, ObjectId};
use crate::utils::{LogSink, ObjectId};
/// A read-only handle to the world, exposed to scripts as `Board`.
pub type BoardRef = Rc<RefCell<Board>>;
impl Registerable for BoardRef {
fn register(engine: &mut Engine, error_sink: ErrorSink) {
fn register(engine: &mut Engine, log_sink: LogSink) {
engine.register_type_with_name::<BoardRef>("Board");
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
@@ -48,14 +48,14 @@ impl Registerable for BoardRef {
// Board.get(id) -> ObjectInfo | () (unknown id logs error)
engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic {
if id <= 0 {
error_sink.error(format!("Board.get: invalid id {id}"));
log_sink.error(format!("Board.get: invalid id {id}"));
return Dynamic::UNIT;
}
if let Some(obj) = ObjectInfo::from_id(id as ObjectId, board.clone()) {
Dynamic::from(obj)
} else {
error_sink.error(format!("Board.get: no object with id {id}"));
log_sink.error(format!("Board.get: no object with id {id}"));
Dynamic::UNIT
}
});
+2 -2
View File
@@ -28,7 +28,7 @@ use crate::Direction;
use crate::action::BoardAction;
use crate::object_def::ObjectDef;
use crate::script::Registerable;
use crate::utils::{ErrorSink, ObjectId};
use crate::utils::{LogSink, ObjectId};
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
/// and `Board.get`. Passed by value — scripts read fields, not a live reference.
@@ -79,7 +79,7 @@ impl ObjectInfo {
}
impl Registerable for ObjectInfo {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
fn register(engine: &mut Engine, _log_sink: LogSink) {
engine.register_type_with_name::<ObjectInfo>("ObjectInfo")
.register_get("x", |obj: &mut ObjectInfo| obj.x)
.register_get("y", |obj: &mut ObjectInfo| obj.y)
+2 -2
View File
@@ -2,7 +2,7 @@ use rhai::Engine;
use crate::api::board::BoardRef;
use crate::player::PlayerRef;
use crate::script::Registerable;
use crate::utils::ErrorSink;
use crate::utils::LogSink;
/// GameState stores player state but Board stores its position, and we want one
/// object to register with Rhai
@@ -10,7 +10,7 @@ use crate::utils::ErrorSink;
pub struct PlayerWithPos(pub PlayerRef, pub BoardRef);
impl Registerable for PlayerWithPos {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
fn register(engine: &mut Engine, _log_sink: LogSink) {
engine.register_type_with_name::<PlayerWithPos>("Player")
.register_get("gems", |player: &mut PlayerWithPos| player.0.borrow().gems)
.register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health)
+3 -3
View File
@@ -8,7 +8,7 @@ use std::rc::Rc;
use rhai::{Dynamic, Engine};
use crate::action::{Action, BoardAction};
use crate::script::Registerable;
use crate::utils::{ErrorSink, ObjectId};
use crate::utils::{LogSink, ObjectId};
/// A single object's output queue.
#[derive(Clone)]
@@ -81,7 +81,7 @@ impl ObjQueue {
}
impl Registerable for ObjQueue {
fn register(engine: &mut Engine, error_sink: ErrorSink) {
fn register(engine: &mut Engine, log_sink: LogSink) {
engine.register_type_with_name::<ObjQueue>("Queue");
engine.register_get("length", |q: &mut ObjQueue| q.0.borrow().len() as i64);
engine.register_fn("clear", ObjQueue::clear);
@@ -99,7 +99,7 @@ impl Registerable for ObjQueue {
match secs {
Ok(secs) => q.delay(secs),
Err(msg) => error_sink.error(msg.to_string())
Err(msg) => log_sink.error(msg.to_string())
}
});
}
+2 -2
View File
@@ -1,7 +1,7 @@
use rhai::{Dynamic, Engine, ImmutableString};
use crate::api::board::BoardRef;
use crate::script::Registerable;
use crate::utils::{ErrorSink, RegistryValue};
use crate::utils::{LogSink, RegistryValue};
/// The board's script registry, pushed into scope as the constant `Registry`.
/// `get`/`set`/`get_or` methods let scripts read and write per-board key→value pairs
@@ -11,7 +11,7 @@ use crate::utils::{ErrorSink, RegistryValue};
pub struct Registry(pub BoardRef);
impl Registerable for Registry {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
fn register(engine: &mut Engine, _log_sink: LogSink) {
engine.register_type_with_name::<Registry>("Registry");
// Registry.get(key) -> Dynamic — returns () if the key is absent.
+170 -213
View File
@@ -1,12 +1,33 @@
use crate::archetype::Archetype;
use crate::floor::Floor;
use crate::glyph::Glyph;
use crate::layer::Layer;
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::utils::Direction;
use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid};
use std::collections::{BTreeMap, HashMap, HashSet};
/// A non-solid `(glyph, archetype)` placed at a board coordinate, **outside** the
/// main grid, drawn only when the grid cell at `(x, y)` is empty.
///
/// Decorations exist so a single file format can serve both world files and save
/// files: at authoring time every cell holds at most one thing, but during play a
/// runtime solid can end up sharing a cell with a non-solid that was already there.
/// The non-solid is recorded here (the grid keeps the solid). The editor does not
/// place decorations; they are a save/runtime concern. A decoration's archetype is
/// always non-solid (a solid one is rejected at load).
#[derive(Clone)]
pub struct Decoration {
/// Column (0-indexed).
pub x: usize,
/// Row (0-indexed).
pub y: usize,
/// The decoration's visual.
pub glyph: Glyph,
/// The decoration's (non-solid) archetype.
pub archetype: Archetype,
}
/// The complete state of one game board (a single room or screen).
///
/// `Board` is the central data structure of the engine, equivalent to a
@@ -37,12 +58,18 @@ pub struct Board {
pub width: usize,
/// Height of the board in cells.
pub height: usize,
/// Ordered draw stack of [`Layer`]s, bottom (index 0) to top. Each layer holds
/// a row-major grid of `(Glyph, Archetype)` cells; a transparent cell lets the
/// layer beneath show through. Drawing ([`Board::glyph_at`]) walks the stack
/// top-down; solidity ([`Board::solid_at`]) scans every layer. Access a single
/// cell with [`Board::get`]/[`Board::get_mut`] by `(z, x, y)`.
pub(crate) layers: Vec<Layer>,
/// The single row-major grid of `(Glyph, Archetype)` cells (`width * height`),
/// holding every solid and most non-solids. A transparent cell (glyph tile 0)
/// draws nothing, revealing a [`decoration`](Board::decorations) or the
/// [`floor`](Board::floor) beneath. Access a cell with [`Board::get`]/
/// [`Board::get_mut`] by `(x, y)`.
pub(crate) grid: Vec<(Glyph, Archetype)>,
/// The board's cosmetic floor (blank / one fixed glyph / a biome), drawn beneath
/// everything. Replaces the old dedicated floor layer.
pub(crate) floor: Floor,
/// Non-solid things placed off the main grid, drawn only where the grid cell is
/// empty (see [`Decoration`]). Normally empty; populated by save files.
pub(crate) decorations: Vec<Decoration>,
/// Current player position on this board. See [`PlayerPos`] for caveats
/// about its future. Game-global player *stats* live in [`crate::player::Player`].
pub player: PlayerPos,
@@ -75,98 +102,91 @@ pub struct Board {
}
impl Board {
/// Number of draw layers on this board (≥ 1 for a loaded board).
pub fn layer_count(&self) -> usize {
self.layers.len()
}
/// Return a list of all `ObjectId`s currently on the board.
pub fn all_ids(&self) -> Vec<ObjectId> {
self.objects.keys().cloned().collect()
}
/// Returns a reference to the cell at `(x, y)` on layer `z`.
/// Returns a reference to the `(Glyph, Archetype)` cell at `(x, y)`.
///
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are
/// out of bounds.
pub fn get(&self, z: usize, x: usize, y: usize) -> &(Glyph, Archetype) {
&self.layers[z].cells[y * self.width + x]
/// Panics if `x` or `y` are out of bounds.
pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) {
&self.grid[y * self.width + x]
}
/// Returns a mutable reference to the cell at `(x, y)` on layer `z`.
/// Returns a mutable reference to the cell at `(x, y)`.
///
/// Panics if `z`, `x`, or `y` are out of bounds.
pub fn get_mut(&mut self, z: usize, x: usize, y: usize) -> &mut (Glyph, Archetype) {
/// Panics if `x` or `y` are out of bounds.
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
let w = self.width;
&mut self.layers[z].cells[y * w + x]
&mut self.grid[y * w + x]
}
/// Replace the solid (if any) at `(x, y)` with `Empty`
/// Replace the solid terrain (if any) at `(x, y)` with a transparent `Empty`
/// cell, revealing the floor beneath.
pub fn clear_solid(&mut self, x: usize, y: usize) {
if self.in_bounds((x as i64, y as i64))
&& let Some(z) = self.solid_cell_layer(x, y) {
*self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty)
}
if self.in_bounds((x as i64, y as i64)) && self.get(x, y).1.behavior().solid {
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
}
}
/// Returns the glyph to display at `(x, y)`, honoring layer draw order.
/// Returns the glyph to display at `(x, y)`.
///
/// The player is always drawn on top (it is not part of the layer stack yet).
/// Otherwise the layers are walked **top-down**; the first thing that draws on
/// a layer wins:
/// With a single grid the draw order is a fixed precedence (no layer walk):
///
/// 1. an object on that layer (a solid object always; otherwise a non-solid
/// object whose glyph is not transparent, i.e. `tile != 0`),
/// 2. a portal on that layer,
/// 3. the layer's terrain cell — a solid always draws, and a non-solid draws
/// only when not transparent (`tile != 0`).
/// 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.
///
/// If no layer contributes anything, the canonical black `Empty` glyph is used.
/// Panics if out of bounds.
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
// The player is rendered above the whole stack (see the `Player` notes).
// The player is rendered above everything (see the `Player` notes).
if self.player.x == x as i64 && self.player.y == y as i64 {
return Glyph::player();
}
for z in (0..self.layers.len()).rev() {
// Objects on this layer: a solid object always draws; otherwise the
// first non-transparent non-solid object (lets invisible objects exist).
let mut nonsolid: Option<Glyph> = None;
for o in self
.objects
.values()
.filter(|o| o.x == x && o.y == y && o.z == z)
{
if o.solid {
return o.glyph;
}
if nonsolid.is_none() && o.glyph.tile != 0 {
nonsolid = Some(o.glyph);
}
// Objects: a solid object always draws; otherwise the first non-transparent
// non-solid object (lets invisible trigger objects exist).
let mut nonsolid: Option<Glyph> = None;
for o in self.objects.values().filter(|o| o.x == x && o.y == y) {
if o.solid {
return o.glyph;
}
if let Some(g) = nonsolid {
return g;
}
// A portal on this layer draws above its (transparent) terrain cell.
if self
.portals
.iter()
.any(|p| p.x == x && p.y == y && p.z == z)
{
return PortalDef::default_glyph();
}
// The terrain cell: a solid always draws; a non-solid only if visible.
let (glyph, arch) = self.get(z, x, y);
if arch.behavior().solid || glyph.tile != 0 {
return *glyph;
if nonsolid.is_none() && o.glyph.tile != 0 {
nonsolid = Some(o.glyph);
}
}
if let Some(g) = nonsolid {
return g;
}
// Nothing on any layer: the canonical black empty cell.
Archetype::Empty.default_glyph()
// The grid cell: a solid always draws; a non-solid only if visible.
let (glyph, arch) = self.get(x, y);
if arch.behavior().solid || glyph.tile != 0 {
return *glyph;
}
// A portal sits on its (transparent) grid cell.
if self.portals.iter().any(|p| p.x == x && p.y == y) {
return PortalDef::default_glyph();
}
// The grid cell was empty: a decoration may show here.
if let Some(d) = self.decorations.iter().find(|d| d.x == x && d.y == y) {
return d.glyph;
}
// Then the floor, else the canonical black empty cell.
self.floor
.glyph_at(x, y, self.width)
.unwrap_or_else(|| Archetype::Empty.default_glyph())
}
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
@@ -218,20 +238,14 @@ impl Board {
};
return Some(Solid::object_at(x, y, id, behavior));
}
// Otherwise some layer's terrain archetype may be solid (e.g. a wall).
if let Some(z) = self.solid_cell_layer(x, y) {
let (glyph, arch) = *self.get(z, x, y);
return Some(Solid::terrain_at(x, y, z, glyph, arch));
// Otherwise the grid cell's terrain archetype may be solid (e.g. a wall).
let (glyph, arch) = *self.get(x, y);
if arch.behavior().solid {
return Some(Solid::terrain_at(x, y, glyph, arch));
}
None
}
/// Returns the index of the layer whose terrain cell at `(x, y)` is solid, if
/// any. By the one-solid-per-cell invariant there is at most one such layer.
fn solid_cell_layer(&self, x: usize, y: usize) -> Option<usize> {
(0..self.layers.len()).find(|&z| self.get(z, x, y).1.behavior().solid)
}
/// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it.
///
/// Convenience inverse of [`solid_at`](Board::solid_at).
@@ -381,10 +395,9 @@ impl Board {
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
///
/// A solid object is relocated (keeping its layer); otherwise the solid
/// terrain archetype (a crate) is moved within its own layer, leaving a
/// transparent cell behind so the layer beneath (e.g. floor) shows through.
/// The caller guarantees the destination is already clear.
/// 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.
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);
let Some(solid) = self.solid_at(x, y) else {
@@ -393,8 +406,8 @@ impl Board {
// A terrain cell leaves a transparent cell behind (revealing any floor); the
// player and objects carry no grid cell, so there is nothing to vacate. `place`
// captured the glyph/arch, so clearing the source first is safe.
if let Some(z) = self.solid_cell_layer(x, y) {
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
if self.get(x, y).1.behavior().solid {
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
}
solid.place(self, tx, ty);
}
@@ -465,23 +478,19 @@ impl Board {
/// drawing tools cannot place, remove, or alter a floor):
///
/// - **Terrain** (`arch != Empty`, always solid today): removes any solid object
/// already in the cell, then writes `(glyph, arch)` into the cell's terrain.
/// - **Erase** (`arch == Empty`): removes the cell's terrain *and* every object in
/// it, leaving the floor (a visible `Empty` cell on a lower layer) in place.
/// 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.
///
/// Terrain is written to the cell's existing terrain layer (the single non-`Empty`
/// archetype across layers, if any) or else the top layer; a vacated terrain cell
/// becomes a transparent `Empty` so a lower floor shows through. Panics if `(x, y)`
/// is out of bounds.
/// A vacated grid cell becomes a transparent `Empty` so the floor shows through.
/// Panics if `(x, y)` is out of bounds.
pub fn place_archetype(&mut self, x: usize, y: usize, arch: Archetype, glyph: Glyph) {
if arch == Archetype::Empty {
// Erase: drop every object in the cell and clear its terrain (keep floor).
// Erase: drop every object in the cell and clear its grid cell (keep floor).
for id in self.object_ids_at(x, y) {
self.objects.remove(&id);
}
if let Some(z) = self.terrain_layer_at(x, y) {
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
}
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
return;
}
@@ -489,10 +498,7 @@ impl Board {
if let Some(id) = self.solid_object_id_at(x, y) {
self.objects.remove(&id);
}
// Reuse the existing terrain layer if the cell already has terrain, else the
// top layer (so the new wall draws above any floor on a lower layer).
let z = self.terrain_layer_at(x, y).unwrap_or(self.layers.len() - 1);
*self.get_mut(z, x, y) = (glyph, arch);
*self.get_mut(x, y) = (glyph, arch);
}
/// Replaces every terrain cell whose archetype is script-backed (e.g. a
@@ -509,26 +515,23 @@ impl Board {
/// already loaded as objects are untouched, so it is safe to call more than once.
pub fn expand_builtin_archetypes(&mut self) {
use crate::builtin_scripts::builtin_tag;
// Collect first: the loop below mutates both layers and the object map.
let mut found: Vec<(usize, usize, usize, Glyph, Archetype)> = Vec::new();
for z in 0..self.layers.len() {
for y in 0..self.height {
for x in 0..self.width {
let (glyph, arch) = *self.get(z, x, y);
if matches!(arch, Archetype::Builtin(_, _)) {
found.push((z, x, y, glyph, arch));
}
// Collect first: the loop below mutates both the grid and the object map.
let mut found: Vec<(usize, usize, Glyph, Archetype)> = Vec::new();
for y in 0..self.height {
for x in 0..self.width {
let (glyph, arch) = *self.get(x, y);
if matches!(arch, Archetype::Builtin(_, _)) {
found.push((x, y, glyph, arch));
}
}
}
for (z, x, y, glyph, arch) in found {
// Vacate the terrain cell (revealing any floor beneath), then spawn the
for (x, y, glyph, arch) in found {
// Vacate the grid cell (revealing any floor beneath), then spawn the
// object — mirroring `resolve_entry`'s object template.
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
let Archetype::Builtin(b, _alias) = arch else { continue };
let beh = b.behavior();
let mut obj = ObjectDef::new(x, y);
obj.z = z;
obj.glyph = glyph;
obj.solid = beh.solid;
obj.opaque = beh.opaque;
@@ -617,13 +620,6 @@ impl Board {
vec![]
}
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
/// (the cell's single terrain archetype, if any). By the one-solid-per-cell
/// invariant there is at most one such layer.
fn terrain_layer_at(&self, x: usize, y: usize) -> Option<usize> {
(0..self.layers.len()).find(|&z| self.get(z, x, y).1 != Archetype::Empty)
}
/// 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)
@@ -638,20 +634,20 @@ impl Board {
pub(crate) mod tests {
use super::Board;
use crate::archetype::{Archetype, Builtin};
use crate::floor::Floor;
use crate::glyph::Glyph;
use crate::layer::Layer;
use crate::object_def::ObjectDef;
use crate::utils::Direction;
use crate::utils::{ObjectId, PlayerPos};
use color::Rgba8;
use std::collections::{BTreeMap, HashMap};
/// Builds an all-empty `w×h` single-layer board with the given player position
/// and objects (all on layer 0). Assigns sequential ids (1..=n) to objects.
/// Builds an all-empty `w×h` board with the given player position and objects.
/// Assigns sequential ids (1..=n) to objects.
///
/// The single layer is fully transparent, so terrain stamped via [`crate_at`]
/// etc. always lands on the board's top layer. Use [`add_floor`] to slip a
/// visible floor layer underneath.
/// 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.
pub(crate) fn open_board(
w: usize,
h: usize,
@@ -669,9 +665,9 @@ pub(crate) mod tests {
name: "test".into(),
width: w,
height: h,
layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); w * h],
}],
grid: vec![(Glyph::transparent(), Archetype::Empty); w * h],
floor: Floor::Blank,
decorations: Vec::new(),
player: PlayerPos {
x: player.0,
y: player.1,
@@ -685,42 +681,25 @@ pub(crate) mod tests {
}
}
/// Inserts a visible floor layer (filled with `glyph`) below everything,
/// bumping existing terrain and objects up one layer.
/// Gives the board a uniform fixed floor glyph (the single-grid replacement for
/// the old separate floor layer).
pub(crate) fn add_floor(board: &mut Board, glyph: Glyph) {
let count = board.width * board.height;
board.layers.insert(
0,
Layer {
cells: vec![(glyph, Archetype::Empty); count],
},
);
for o in board.objects.values_mut() {
o.z += 1;
}
board.floor = Floor::Fixed(glyph);
}
/// The index of the board's top (terrain) layer, where stamps are written.
fn top(board: &Board) -> usize {
board.layers.len() - 1
}
/// Stamps a crate cell onto the board's top layer.
/// Stamps a crate cell onto the grid.
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
let z = top(board);
*board.get_mut(z, x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
*board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
}
/// Stamps a wall cell onto the board's top layer.
/// Stamps a wall cell onto the grid.
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
let z = top(board);
*board.get_mut(z, x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
}
/// Stamps an arbitrary archetype cell onto the board's top layer.
/// Stamps an arbitrary archetype cell onto the grid.
pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
let z = top(board);
*board.get_mut(z, x, y) = (arch.default_glyph(), arch);
*board.get_mut(x, y) = (arch.default_glyph(), arch);
}
#[test]
@@ -790,8 +769,8 @@ pub(crate) mod tests {
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(0, 1, 0).1, Archetype::Crate); // no mutation
assert_eq!(board.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation
assert_eq!(board.get(2, 0).1, Archetype::Empty);
let mut board = open_board(3, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
@@ -808,7 +787,7 @@ pub(crate) mod tests {
// Crate with open space ahead: shiftable.
crate_at(&mut board, 0, 0);
assert!(board.can_shift(0, 0, Direction::East));
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // read-only
assert_eq!(board.get(0, 0).1, Archetype::Crate); // read-only
// Crate with another pushable crate ahead: still shiftable (unlike can_push,
// which would follow the chain to the wall and fail).
@@ -854,8 +833,8 @@ pub(crate) mod tests {
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
board.push(1, 0, Direction::East);
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
assert_eq!(board.get(1, 0).1, Archetype::Empty);
assert_eq!(board.get(2, 0).1, Archetype::Crate);
assert_eq!((board.player.x, board.player.y), (3, 0));
}
@@ -867,7 +846,7 @@ pub(crate) mod tests {
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(0, 1, 0).1, Archetype::Crate);
assert_eq!(board.get(1, 0).1, Archetype::Crate);
assert_eq!((board.player.x, board.player.y), (2, 0));
}
@@ -890,7 +869,7 @@ pub(crate) mod tests {
a: 255,
},
};
// Floor on a lower layer, a wall on the top (terrain) layer at (0,0).
// A fixed floor attribute, a wall on the grid at (0,0).
add_floor(&mut board, floor_glyph);
wall_at(&mut board, 0, 0);
// The wall (solid) draws over the floor; the empty cell reveals the floor.
@@ -898,29 +877,9 @@ pub(crate) mod tests {
assert_eq!(board.glyph_at(1, 0), floor_glyph);
}
#[test]
fn glyph_at_draws_higher_layer_over_lower() {
// A non-solid object on an upper layer renders above a wall on a lower one.
let mut board = open_board(2, 1, (1, 0), vec![]);
wall_at(&mut board, 0, 0); // wall on layer 0
// Add an upper layer holding a visible, non-solid object at (0,0).
board.layers.push(Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); 2],
});
let mut obj = ObjectDef::new(0, 0);
obj.z = 1;
obj.solid = false;
obj.glyph = Glyph {
tile: '*' as u32,
..Glyph::transparent()
};
board.add_object(obj);
assert_eq!(board.glyph_at(0, 0).tile, '*' as u32);
}
#[test]
fn place_wall_keeps_floor_and_removes_solid_object() {
// Floor on layer 0, terrain layer on top; a solid object sits at (1,0).
// A fixed floor attribute; a solid object sits on the grid at (1,0).
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
let floor = Glyph {
tile: '.' as u32,
@@ -931,9 +890,8 @@ pub(crate) mod tests {
let wall = Archetype::Wall.default_glyph();
board.place_archetype(1, 0, Archetype::Wall, wall);
// The wall landed on the terrain (top) layer; the floor below is untouched.
assert_eq!(board.get(1, 1, 0), &(wall, Archetype::Wall));
assert_eq!(board.get(0, 1, 0).0, floor);
// The wall landed on the grid; the floor attribute is untouched.
assert_eq!(board.get(1, 0), &(wall, Archetype::Wall));
// The solid object that was there is gone.
assert!(board.object_ids_at(1, 0).is_empty());
assert_eq!(board.glyph_at(1, 0), wall);
@@ -941,17 +899,17 @@ pub(crate) mod tests {
#[test]
fn place_wall_overwrites_existing_terrain_in_place() {
// A crate already occupies the top layer at (1,0).
// A crate already occupies the grid at (1,0).
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 1, 0);
let wall = Archetype::Wall.default_glyph();
board.place_archetype(1, 0, Archetype::Wall, wall);
assert_eq!(board.get(0, 1, 0), &(wall, Archetype::Wall));
assert_eq!(board.get(1, 0), &(wall, Archetype::Wall));
}
#[test]
fn erase_removes_terrain_and_objects_but_keeps_floor() {
// Floor, a wall on the terrain layer, and a (non-solid) object all at (1,0).
// A fixed floor attribute, a wall on the grid, and a (non-solid) object at (1,0).
let mut obj = ObjectDef::new(1, 0);
obj.solid = false;
let mut board = open_board(3, 1, (2, 0), vec![obj]);
@@ -964,13 +922,12 @@ pub(crate) mod tests {
board.place_archetype(1, 0, Archetype::Empty, Glyph::transparent());
// Terrain cleared to transparent Empty; object removed; floor still there.
// Grid cell cleared to transparent Empty; object removed; floor still there.
assert_eq!(
board.get(1, 1, 0),
board.get(1, 0),
&(Glyph::transparent(), Archetype::Empty)
);
assert!(board.object_ids_at(1, 0).is_empty());
assert_eq!(board.get(0, 1, 0).0, floor);
assert_eq!(board.glyph_at(1, 0), floor);
}
@@ -990,7 +947,7 @@ pub(crate) mod tests {
board.expand_builtin_archetypes();
// The terrain cell is vacated and a scripted object takes its place.
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 0).1, Archetype::Empty);
let obj = board.objects.values().next().expect("spinner object");
assert_eq!((obj.x, obj.y), (0, 0));
assert!(obj.solid);
@@ -1019,7 +976,7 @@ pub(crate) mod tests {
crate_at(&mut board, 0, 0);
let errs = board.apply_shift(&[(0, 0), (9, 0)]);
assert_eq!(errs.len(), 1);
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged
assert_eq!(board.get(0, 0).1, Archetype::Crate); // unchanged
}
#[test]
@@ -1043,11 +1000,11 @@ pub(crate) mod tests {
// 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)]);
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // wrapped from (4,0)
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // moved from (0,0)
assert_eq!(board.get(0, 2, 0).1, Archetype::Wall); // blocked, immobile
assert_eq!(board.get(0, 3, 0).1, Archetype::Empty); // cleared, crate moved
assert_eq!(board.get(0, 4, 0).1, Archetype::Crate); // moved from (3,0)
assert_eq!(board.get(0, 0).1, Archetype::Crate); // wrapped from (4,0)
assert_eq!(board.get(1, 0).1, Archetype::Crate); // moved from (0,0)
assert_eq!(board.get(2, 0).1, Archetype::Wall); // blocked, immobile
assert_eq!(board.get(3, 0).1, Archetype::Empty); // cleared, crate moved
assert_eq!(board.get(4, 0).1, Archetype::Crate); // moved from (3,0)
}
#[test]
@@ -1066,9 +1023,9 @@ pub(crate) mod tests {
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // HCrate moved out
assert_eq!(board.get(0, 1, 0).1, Archetype::HCrate); // moved from (0,0)
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); // moved from (1,0)
assert_eq!(board.get(0, 0).1, Archetype::Empty); // HCrate moved out
assert_eq!(board.get(1, 0).1, Archetype::HCrate); // moved from (0,0)
assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0)
}
// Subcase B: VCrate stays; Crate behind it still moves
@@ -1084,9 +1041,9 @@ pub(crate) mod tests {
// Crate at (1,0) is NOT in blocked, so it moves.
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
assert_eq!(board.get(0, 0, 0).1, Archetype::VCrate); // immobile
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty); // crate moved out
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); // moved from (1,0)
assert_eq!(board.get(0, 0).1, Archetype::VCrate); // immobile
assert_eq!(board.get(1, 0).1, Archetype::Empty); // crate moved out
assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0)
}
}
@@ -1105,9 +1062,9 @@ pub(crate) mod tests {
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // VCrate moved out
assert_eq!(board.get(0, 0, 1).1, Archetype::VCrate); // moved from (0,0)
assert_eq!(board.get(0, 0, 2).1, Archetype::Crate); // moved from (0,1)
assert_eq!(board.get(0, 0).1, Archetype::Empty); // VCrate moved out
assert_eq!(board.get(0, 1).1, Archetype::VCrate); // moved from (0,0)
assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1)
}
// Subcase B: HCrate stays; Crate behind it still moves
@@ -1123,9 +1080,9 @@ pub(crate) mod tests {
// Crate at (0,1) is NOT in blocked, so it moves.
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
assert_eq!(board.get(0, 0, 0).1, Archetype::HCrate); // immobile
assert_eq!(board.get(0, 0, 1).1, Archetype::Empty); // crate moved out
assert_eq!(board.get(0, 0, 2).1, Archetype::Crate); // moved from (0,1)
assert_eq!(board.get(0, 0).1, Archetype::HCrate); // immobile
assert_eq!(board.get(0, 1).1, Archetype::Empty); // crate moved out
assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1)
}
}
+67 -1
View File
@@ -12,7 +12,63 @@
use crate::glyph::Glyph;
use color::Rgba8;
use tinyrand::{Probability, Rand, StdRand};
use tinyrand::{Probability, Rand, Seeded, StdRand};
/// A board's floor: the cosmetic backdrop drawn beneath everything, replacing the
/// old dedicated floor *layer*. A board has exactly one [`Floor`] (see
/// [`Board::floor`](crate::board::Board)), given in the map file's `[map]` header
/// as an optional `floor = { … }` attribute.
///
/// Three forms: [`Blank`](Floor::Blank) (the canonical empty/black cell shows
/// through), [`Fixed`](Floor::Fixed) (one glyph tiled across the whole board), or
/// [`Biome`](Floor::Biome) (a procedural [`FloorGenerator`] texture). A biome keeps
/// its generator (so save re-emits the generator name) alongside a per-cell glyph
/// buffer pre-rolled once at load from [`FLOOR_SEED`] — deterministic, and the
/// direct replacement for the old per-cell floor-layer rolling.
#[derive(Clone)]
pub enum Floor {
/// No floor: the canonical black empty cell shows.
Blank,
/// A single fixed glyph tiled across the whole board.
Fixed(Glyph),
/// A procedural biome floor: the `generator` (retained for save) plus a
/// `glyphs` buffer holding one pre-rolled glyph per cell (row-major).
Biome {
/// The generator this biome was built from; re-emitted on save.
generator: FloorGenerator,
/// One pre-rolled glyph per cell (`width * height`, row-major).
glyphs: Vec<Glyph>,
},
}
impl Default for Floor {
/// A board with no declared floor is [`Floor::Blank`].
fn default() -> Self {
Floor::Blank
}
}
impl Floor {
/// Builds a [`Floor::Biome`] for a `width × height` board, pre-rolling one glyph
/// per cell from a [`FLOOR_SEED`]-seeded PRNG (so the result is deterministic and
/// depends only on the board dimensions + generator).
pub(crate) fn biome(generator: FloorGenerator, width: usize, height: usize) -> Floor {
let mut rng = StdRand::seed(FLOOR_SEED);
let glyphs = (0..width * height).map(|_| generator.generate(&mut rng)).collect();
Floor::Biome { generator, glyphs }
}
/// The floor glyph to draw at `(x, y)`, or `None` for [`Floor::Blank`].
///
/// `width` is the board width, needed to index a biome's row-major buffer.
pub(crate) fn glyph_at(&self, x: usize, y: usize, width: usize) -> Option<Glyph> {
match self {
Floor::Blank => None,
Floor::Fixed(g) => Some(*g),
Floor::Biome { glyphs, .. } => glyphs.get(y * width + x).copied(),
}
}
}
/// Fixed seed for the floor PRNG, so a board's generated floor is deterministic
/// for a given map (stable across reloads within a run, and testable). The layer
@@ -46,6 +102,16 @@ impl FloorGenerator {
}
}
/// The generator's map-file name (inverse of [`from_name`](FloorGenerator::from_name)),
/// re-emitted on save so a biome floor round-trips.
pub fn name(&self) -> &'static str {
match self {
FloorGenerator::Grass => "grass",
FloorGenerator::Dirt => "dirt",
FloorGenerator::Stone => "stone",
}
}
/// Generates one random floor [`Glyph`] for this generator.
///
/// Picks a background ground color within the generator's scheme, then with
+67 -40
View File
@@ -135,7 +135,7 @@ impl GameState {
board_transition: None,
player
};
state.drain_errors();
state.drain_log();
state
}
@@ -208,7 +208,7 @@ impl GameState {
// Fire any bump/send reactions, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
self.drain_log();
}
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
@@ -238,15 +238,16 @@ impl GameState {
// Fire the bump/send reactions those ticks triggered, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
self.drain_log();
}
/// Drains errors collected by the script host into the game log.
fn drain_errors(&mut self) {
/// Drains the log lines collected by the script host — script `log()` output
/// plus engine/runtime errors — into the game log.
fn drain_log(&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);
let lines = self.scripts.take_logs();
self.log.extend(lines);
}
/// Applies one object's drained `actions` to the board and returns the `bump`
@@ -259,9 +260,12 @@ impl GameState {
/// reactions are returned rather than fired here, so the caller can run them once
/// all object hooks in this pass have applied.
fn apply_actions(&mut self, actions: Vec<BoardAction>) -> Events {
// Logs are collected here rather than pushed inline, since the board borrow
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
// Application-time errors (teleport/push/shift failures) go straight onto
// the shared LogSink — the same immediate channel as script `log()` output,
// so everything lands in the log in one emission order and is flushed by
// `drain_log`. A cheap Rc clone lets us push while the board borrow (which
// also borrows `self`) is held.
let log_sink = self.scripts.log_sink().clone();
let mut bumps: Vec<(ObjectId, Direction)> = Vec::new();
// Net change to player stats from AddGems / AlterHealth actions; applied
// to `self` after the board borrow drops.
@@ -289,7 +293,6 @@ impl GameState {
obj.glyph.tile = tile;
}
}
Action::Log(line) => logs.push(line),
Action::SetTag {
target,
tag,
@@ -340,9 +343,9 @@ impl GameState {
}
Action::Teleport { target, x, y } => {
if !board.in_bounds((x, y)) {
logs.push(LogLine::error(format!(
log_sink.error(format!(
"teleport({target},{x},{y}): out of bounds"
)));
));
} else if target == -1 {
// Move the player. A solid *other than the player itself*
// blocks the destination.
@@ -352,9 +355,9 @@ impl GameState {
Some(s) if !s.player()
);
if blocked {
logs.push(LogLine::error(format!(
log_sink.error(format!(
"teleport(player,{x},{y}): destination is solid"
)));
));
} else {
board.player.x = ux as i64;
board.player.y = uy as i64;
@@ -364,9 +367,9 @@ impl GameState {
// a solid mover, unless the occupant is that same object.
let (ux, uy) = (x as usize, y as usize);
match board.objects.get(&tid) {
None => logs.push(LogLine::error(format!(
None => log_sink.error(format!(
"teleport({target},{x},{y}): no such object"
))),
)),
Some(obj) => {
let source_solid = obj.solid;
let blocked = source_solid
@@ -375,9 +378,9 @@ impl GameState {
Some(s) if s.object_id() != Some(tid)
);
if blocked {
logs.push(LogLine::error(format!(
log_sink.error(format!(
"teleport({target},{x},{y}): destination is solid"
)));
));
} else if let Some(obj) = board.objects.get_mut(&tid) {
obj.x = ux;
obj.y = uy;
@@ -385,22 +388,24 @@ impl GameState {
}
}
} else {
logs.push(LogLine::error(format!(
log_sink.error(format!(
"teleport({target},{x},{y}): invalid target id"
)));
));
}
}
// push() self-checks can_push, so an in-bounds guard is all we add.
Action::Push { x, y, dir } => {
if !board.in_bounds((x, y)) {
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
log_sink.error(format!("push({x},{y}): out of bounds"));
} else {
board.push(x as usize, y as usize, dir);
}
}
// apply_shift moves the named cells.
// apply_shift moves the named cells, returning any error lines.
Action::Shift(cells) => {
logs.extend(board.apply_shift(&cells));
for line in board.apply_shift(&cells) {
log_sink.line(line);
}
}
// Accumulated and applied to `self.player.gems` after the borrow drops.
Action::AddGems(n) => gem_delta += n,
@@ -415,7 +420,6 @@ impl GameState {
}
}
}
self.log.extend(logs);
for bubble in new_bubbles {
// One bubble per object: replace the existing one if present.
self.speech_bubbles
@@ -435,7 +439,7 @@ impl GameState {
}
for (color, present) in key_changes {
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
self.log.push(LogLine::error(format!("set_key: unknown color {color:?}")));
log_sink.error(format!("set_key: unknown color {color:?}"));
}
}
// Return the reactions for the caller's settle pass rather than firing them here.
@@ -490,7 +494,7 @@ impl GameState {
let ev = self.apply_actions(actions);
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
self.drain_log();
}
}
@@ -612,7 +616,7 @@ impl GameState {
// Settle the grab/bump reactions (and any they cascade into) before returning.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
self.drain_log();
}
}
@@ -724,8 +728,8 @@ mod tests {
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
assert_eq!(b.get(2, 0).1, Archetype::Empty);
assert_eq!(b.get(3, 0).1, Archetype::Crate);
}
#[test]
@@ -754,6 +758,29 @@ mod tests {
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
}
#[test]
fn log_is_immediate_and_not_paced_by_the_queue() {
// `delay(5.0)` parks the object's action queue for 5 seconds, then `log()`
// fires. Because logging bypasses the queue entirely, the line must appear
// right after run_init (dt = 0) — under the old queued-Action behavior it
// would have been stuck behind the delay and absent here.
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.script_name = Some("s".to_string());
let board = open_board(4, 1, (3, 0), vec![obj]);
let src = "fn init(m) { delay(5.0); log(\"immediate\"); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(
game.log
.iter()
.any(|l| l.spans.iter().any(|s| s.text == "immediate")),
"log() should hit the game log immediately, even behind a delay"
);
}
/// Builds a 3×3 board with a non-solid clockwise spinner object (running the
/// real `scripts/spinner.rhai`) at the centre and the player parked on it.
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
@@ -797,9 +824,9 @@ mod tests {
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N kept
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed)
assert_eq!(b.get(0, 2, 1).1, Archetype::Wall); // wall kept
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N kept
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE kept (not destroyed)
assert_eq!(b.get(2, 1).1, Archetype::Wall); // wall kept
}
#[test]
@@ -809,10 +836,10 @@ mod tests {
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE: filled by N
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W
assert_eq!(b.get(0, 0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE: filled by N
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N: filled by NW
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: filled by W
assert_eq!(b.get(0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
}
#[test]
@@ -822,9 +849,9 @@ mod tests {
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
assert_eq!(b.get(2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(1, 0).1, Archetype::Empty); // N: vacated
}
#[test]
+2 -2
View File
@@ -2,7 +2,7 @@ use color::Rgba8;
use std::hash::{Hash, Hasher};
use rhai::Engine;
use crate::script::Registerable;
use crate::utils::ErrorSink;
use crate::utils::LogSink;
/// The visual representation of a single board cell.
///
@@ -36,7 +36,7 @@ impl Hash for Glyph {
}
impl Registerable for Glyph {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
fn register(engine: &mut Engine, _log_sink: LogSink) {
engine.register_type_with_name::<Glyph>("Glyph");
engine.register_get("tile", |g: &mut Glyph| g.tile);
engine.register_get("fg", |g: &mut Glyph| {
+2 -2
View File
@@ -2,7 +2,7 @@ use color::Rgba8;
use rhai::Engine;
use crate::glyph::Glyph;
use crate::script::Registerable;
use crate::utils::ErrorSink;
use crate::utils::LogSink;
#[derive(Copy, Clone, Debug)]
pub enum KeyType {
@@ -88,7 +88,7 @@ impl Keyring {
}
impl Registerable for Keyring {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
fn register(engine: &mut Engine, _log_sink: LogSink) {
engine.register_type_with_name::<Keyring>("Keyring")
.register_get("red", |keyring: &mut Keyring| keyring.red)
.register_get("orange", |keyring: &mut Keyring| keyring.orange)
+53 -96
View File
@@ -1,54 +1,37 @@
//! Palette layers: the unit of the map-file format and the board's draw stack.
//! The board grid: the palette+char map-file unit and its load-time conversion.
//!
//! A board is an ordered list of **layers**, drawn bottom (index 0) to top. Each
//! layer is a character grid plus a palette mapping each character to one *kind*
//! of thing — an archetype (terrain), a floor glyph, a scripted object, a portal,
//! or the player. "Put two things on one cell" simply means "use two layers".
//! A board is a single **grid** — a character grid plus a palette mapping each
//! character to one *kind* of thing: an archetype (terrain), a scripted object, a
//! portal, or the player. (The board's cosmetic floor is a separate `[map]`
//! attribute, not a grid cell; see [`crate::floor`].)
//!
//! This module owns the per-layer serde types ([`LayerData`], [`PaletteEntry`]),
//! the runtime [`Layer`] (a grid of `(Glyph, Archetype)` cells, where a cell with
//! a transparent glyph lets lower layers show through), and the load-time
//! conversion ([`build_layer`]) that turns one `LayerData` into a `Layer` plus a
//! list of [`Placement`]s (objects/portals/player) for the map loader to resolve
//! across layers. Cross-layer validation (one solid per cell, unique names, the
//! player winning its cell) lives in [`crate::map_file`].
//! This module owns the grid serde type ([`GridData`], [`PaletteEntry`]) and the
//! load-time conversion ([`build_grid`]) that turns one `GridData` into the board's
//! `Vec<(Glyph, Archetype)>` cells plus a list of [`Placement`]s (objects/portals/
//! player) for the map loader to resolve. Cross-cell validation (one solid per
//! cell, unique names, the player winning its cell) lives in [`crate::map_file`].
use crate::archetype::Archetype;
use crate::floor::FloorGenerator;
use crate::glyph::Glyph;
use crate::log::LogLine;
use crate::map_file::{TileIndex, parse_color};
use crate::object_def::ObjectDef;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tinyrand::StdRand;
/// A single draw layer: a row-major grid of `(Glyph, Archetype)` cells.
///
/// A cell whose `glyph.tile == 0` (see [`Glyph::transparent`]) is transparent —
/// it draws nothing and lets the layer beneath show through. Solidity comes from
/// the archetype and is independent of transparency. The grid is `width * height`
/// long; index it via the owning [`Board`](crate::board::Board)'s dimensions.
#[derive(Clone)]
pub(crate) struct Layer {
/// Row-major `(Glyph, Archetype)` cells for this layer.
pub(crate) cells: Vec<(Glyph, Archetype)>,
}
/// Serde representation of one `[[layers]]` entry: a grid plus its palette.
/// Serde representation of the board `[grid]`: a char grid plus its palette.
///
/// The grid is given in exactly one of three ways (precedence: `content`, then
/// `fill`, then `sparse`; none of them ⇒ an all-spaces grid):
/// - `content` — a multi-line grid string, one char per cell (`width × height`).
/// - `fill` — a single character; the whole grid is filled with it (handy for a
/// layer of identical floor).
/// - `fill` — a single character; the whole grid is filled with it.
/// - `sparse` — a list of `{ x, y, ch }` cells over an otherwise all-spaces grid
/// (handy for a layer holding just a few objects).
/// (handy for a grid holding just a few things).
///
/// A space (`' '`) is always a transparent empty cell and is never a palette key
/// (any `" "` entry in `palette` is ignored).
#[derive(Deserialize, Serialize)]
pub(crate) struct LayerData {
#[derive(Deserialize, Serialize, Default)]
pub(crate) struct GridData {
/// Multi-line grid string; one char per cell, looked up in `palette`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
@@ -63,7 +46,7 @@ pub(crate) struct LayerData {
pub palette: HashMap<String, PaletteEntry>,
}
/// One cell in a [`LayerData::sparse`] list: a single character at `(x, y)`.
/// One cell in a [`GridData::sparse`] list: a single character at `(x, y)`.
#[derive(Deserialize, Serialize, Clone)]
pub(crate) struct SparseCell {
/// Column (0-indexed).
@@ -78,24 +61,21 @@ pub(crate) struct SparseCell {
///
/// A single flat struct (rather than an enum) because `kind` is open-ended: it is
/// any archetype name (`"wall"`, `"crate"`, `"pusher_east"`, …) *or* one of the
/// meta-kinds `empty`, `floor`, `object`, `portal`, `player`. Only the fields
/// relevant to a given kind are read; the rest stay `None`. See [`resolve_entry`].
/// meta-kinds `empty`, `object`, `portal`, `player`. Only the fields relevant to a
/// given kind are read; the rest stay `None`. See [`resolve_entry`].
#[derive(Deserialize, Serialize, Default, Clone)]
pub(crate) struct PaletteEntry {
/// What this entry is: an archetype name, or `empty`/`floor`/`object`/`portal`/`player`.
/// What this entry is: an archetype name, or `empty`/`object`/`portal`/`player`.
pub kind: String,
/// Tile index (int or single-char string). Used by archetype/floor/object kinds.
/// Tile index (int or single-char string). Used by archetype/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<TileIndex>,
/// Foreground `"#RRGGBB"`. Used by archetype/floor/object kinds.
/// Foreground `"#RRGGBB"`. Used by archetype/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Background `"#RRGGBB"`. Used by archetype/floor/object kinds.
/// Background `"#RRGGBB"`. Used by archetype/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
/// Floor generator name (`"grass"`/`"dirt"`/`"stone"`). Only for `kind = "floor"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generator: Option<String>,
/// Object solidity (defaults `true`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solid: Option<bool>,
@@ -122,11 +102,14 @@ pub(crate) struct PaletteEntry {
pub target_entry: Option<String>,
}
/// A non-terrain thing a layer places at a grid cell, resolved by the map loader.
/// A single grid cell: its visual and its behavioral class.
pub(crate) type GridCell = (Glyph, Archetype);
/// A non-terrain thing the grid places at a cell, resolved by the map loader.
///
/// Terrain/floor go straight into [`Layer::cells`]; these need cross-layer
/// handling (ids, name uniqueness, the single player), so [`build_layer`] returns
/// them separately with their `(x, y)` for [`crate::map_file`] to finish.
/// Terrain goes straight into the grid cells; these need cross-cell handling (ids,
/// name uniqueness, the single player), so [`build_grid`] returns them separately
/// with their `(x, y)` for [`crate::map_file`] to finish.
pub(crate) enum Placement {
/// A scripted object to spawn at `(x, y)`.
Object(ObjectTemplate, usize, usize),
@@ -136,7 +119,7 @@ pub(crate) enum Placement {
Player(usize, usize),
}
/// A resolved object definition minus board-assigned fields (`id`, `z`).
/// A resolved object definition minus board-assigned fields (`id`).
#[derive(Clone)]
pub(crate) struct ObjectTemplate {
pub glyph: Glyph,
@@ -148,7 +131,7 @@ pub(crate) struct ObjectTemplate {
pub name: Option<String>,
}
/// A resolved portal definition minus `(x, y, z)`.
/// A resolved portal definition minus `(x, y)`.
#[derive(Clone)]
pub(crate) struct PortalTemplate {
pub name: String,
@@ -159,10 +142,8 @@ pub(crate) struct PortalTemplate {
/// What a palette character resolves to during the grid walk.
#[derive(Clone)]
enum Resolved {
/// A fixed cell written straight into the layer (terrain, fixed floor, empty, error block).
/// A fixed cell written straight into the grid (terrain, empty, error block).
Cell(Glyph, Archetype),
/// A procedural floor: roll a fresh glyph per occurrence.
FloorGen(FloorGenerator),
/// A scripted object placed per occurrence.
Object(ObjectTemplate),
/// A portal placed per occurrence.
@@ -173,10 +154,10 @@ enum Resolved {
/// Resolves one palette entry to a [`Resolved`], recording any nonfatal problem.
///
/// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `floor`
/// with an unknown generator or a `portal` missing required fields falls back to a
/// transparent cell (the grid char is still consumed). Object/floor/archetype
/// glyphs fall back to the relevant default for any absent visual field.
/// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `portal`
/// missing required fields falls back to a transparent cell (the grid char is
/// still consumed). Object/archetype glyphs fall back to the relevant default for
/// any absent visual field.
fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resolved {
// Builds a glyph from the entry's tile/fg/bg, each falling back to `default`.
let glyph_with_default = |default: Glyph| Glyph {
@@ -186,27 +167,8 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
};
match e.kind.as_str() {
// Transparent: lower layers show through here.
// Transparent: the floor / a lower thing shows through here.
"empty" => Resolved::Cell(Glyph::transparent(), Archetype::Empty),
"floor" => match &e.generator {
Some(name) => match FloorGenerator::from_name(name) {
Some(g) => Resolved::FloorGen(g),
None => {
errors.push(LogLine::error(format!(
"floor palette '{ch}' names unknown generator '{name}'; using empty floor"
)));
Resolved::Cell(Glyph::transparent(), Archetype::Empty)
}
},
// A fixed floor glyph: a visual-only cell (no archetype behavior).
None => Resolved::Cell(
glyph_with_default(Glyph {
tile: 32,
..Glyph::transparent()
}),
Archetype::Empty,
),
},
"object" => Resolved::Object(ObjectTemplate {
glyph: glyph_with_default(ObjectDef::default_glyph()),
solid: e.solid.unwrap_or(true),
@@ -248,7 +210,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
}
}
/// Resolves a layer's grid to a `height × width` matrix of chars from whichever of
/// Resolves the grid to a `height × width` matrix of chars from whichever of
/// `content` / `fill` / `sparse` is supplied (in that precedence; none ⇒ all spaces).
///
/// Only an explicit `content` can mismatch the board dimensions — the single hard
@@ -256,7 +218,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
/// `fill`/`ch` or an out-of-bounds `sparse` cell is recorded on `errors` and the
/// offending cell falls back to (or stays) a space.
fn grid_chars(
data: &LayerData,
data: &GridData,
width: usize,
height: usize,
errors: &mut Vec<LogLine>,
@@ -279,7 +241,7 @@ fn grid_chars(
let rows: Vec<&str> = content.lines().collect();
if rows.len() != height {
return Err(format!(
"layer grid has {} rows but the board is {height} tall",
"grid has {} rows but the board is {height} tall",
rows.len()
));
}
@@ -288,7 +250,7 @@ fn grid_chars(
let row: Vec<char> = line.chars().collect();
if row.len() != width {
return Err(format!(
"layer grid row {i} has {} characters but the board is {width} wide",
"grid row {i} has {} characters but the board is {width} wide",
row.len()
));
}
@@ -301,7 +263,7 @@ fn grid_chars(
// A whole grid of one character.
let ch = single_char(
fill,
format!("layer fill must be a single character (got {fill:?}); using a space"),
format!("grid fill must be a single character (got {fill:?}); using a space"),
errors,
)
.unwrap_or(' ');
@@ -333,23 +295,19 @@ fn grid_chars(
Ok(grid)
}
/// Builds one [`Layer`] from its [`LayerData`], plus the non-terrain placements it
/// contains (with their `(x, y)`).
/// Builds the board's grid cells from its [`GridData`], plus the non-terrain
/// placements it contains (with their `(x, y)`).
///
/// `rng` is the shared, deterministically-seeded floor PRNG (one per board build),
/// threaded through so procedural floors depend only on map content. Returns
/// `Err` only on a grid-dimension mismatch (the single hard error, matching the
/// pre-layers loader); every other problem is recorded on `errors`.
pub(crate) fn build_layer(
data: &LayerData,
/// Returns `Err` only on a grid-dimension mismatch (the single hard error);
/// every other problem is recorded on `errors`.
pub(crate) fn build_grid(
data: &GridData,
width: usize,
height: usize,
rng: &mut StdRand,
errors: &mut Vec<LogLine>,
) -> Result<(Layer, Vec<Placement>), String> {
// Resolve each palette entry once (per-cell work like generators happens below).
// Space is always a transparent empty cell, so it is never a palette key — any
// `" "` entry is ignored.
) -> Result<(Vec<GridCell>, Vec<Placement>), String> {
// Resolve each palette entry once. Space is always a transparent empty cell, so
// it is never a palette key — any `" "` entry is ignored.
let resolved: HashMap<char, Resolved> = data
.palette
.iter()
@@ -363,7 +321,7 @@ pub(crate) fn build_layer(
let grid = grid_chars(data, width, height, errors)?;
// Walk the grid, filling cells and collecting placements.
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height);
let mut cells: Vec<GridCell> = Vec::with_capacity(width * height);
let mut placements: Vec<Placement> = Vec::new();
for (y, row) in grid.iter().enumerate() {
for (x, &ch) in row.iter().enumerate() {
@@ -374,7 +332,6 @@ pub(crate) fn build_layer(
}
match resolved.get(&ch) {
Some(Resolved::Cell(g, a)) => cells.push((*g, *a)),
Some(Resolved::FloorGen(g)) => cells.push((g.generate(rng), Archetype::Empty)),
Some(Resolved::Object(t)) => {
cells.push((Glyph::transparent(), Archetype::Empty));
placements.push(Placement::Object(t.clone(), x, y));
@@ -397,5 +354,5 @@ pub(crate) fn build_layer(
}
}
Ok((Layer { cells }, placements))
Ok((cells, placements))
}
+330 -115
View File
@@ -16,30 +16,37 @@ use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::TryFrom;
use std::path::Path;
use tinyrand::{Seeded, StdRand};
use crate::api::queue::ObjQueue;
use crate::archetype::Archetype;
use crate::board::Board;
use crate::board::{Board, Decoration};
use crate::builtin_scripts::archetype_from_builtin_tag;
use crate::floor::FLOOR_SEED;
use crate::floor::{Floor, FloorGenerator};
use crate::glyph::Glyph;
use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer};
use crate::layer::{GridData, PaletteEntry, Placement, build_grid};
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::utils::{ObjectId, PlayerPos, PortalDef};
/// The serde shell for one board in a `.toml` file: a header and its layers.
/// The serde shell for one board in a `.toml` file: a header, the single grid, and
/// the off-grid trigger/decoration lists.
///
/// On load this is converted into a [`Board`] via [`TryFrom`] and discarded; on
/// save a [`Board`] is converted back via [`From<&Board>`]. See `maps/start.toml`
/// for a complete example of the format.
#[derive(Deserialize, Serialize)]
pub struct MapFile {
/// The `[map]` header: name, dimensions, optional board script.
/// The `[map]` header: name, dimensions, floor, optional board script.
pub map: MapHeader,
/// The ordered `[[layers]]` stack, bottom (index 0) to top.
/// The single `[grid]`: palette + char map for all solids and most non-solids.
#[serde(default)]
pub(crate) layers: Vec<LayerData>,
pub(crate) grid: GridData,
/// Invisible, non-solid, script-only objects (`[[triggers]]`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) triggers: Vec<TriggerSpec>,
/// Non-solid `(glyph, archetype)` cells drawn only where the grid is empty
/// (`[[decorations]]`). Normally absent; used by save files.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) decorations: Vec<DecorationSpec>,
}
/// The `[map]` header section of a board.
@@ -47,15 +54,107 @@ pub struct MapFile {
pub struct MapHeader {
/// Human-readable name for this board, e.g. `"Opening Room"`.
pub name: String,
/// Width of the board in cells. Must match every layer row's length.
/// Width of the board in cells. Must match the grid row length.
pub width: usize,
/// Height of the board in cells. Must match every layer's row count.
/// Height of the board in cells. Must match the grid row count.
pub height: usize,
/// The board's optional cosmetic floor. Absent ⇒ blank; see [`FloorSpec`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor: Option<FloorSpec>,
/// Name of the board-level script in the `[scripts]` table, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub board_script_name: Option<String>,
}
/// Serde form of the board floor attribute (`floor = { … }` in `[map]`).
///
/// Resolves (in [`FloorSpec::resolve`]) to a [`Floor`]: a `generator` name gives a
/// biome, otherwise any of `tile`/`fg`/`bg` gives a single fixed glyph, and an
/// empty spec is blank.
#[derive(Deserialize, Serialize, Clone)]
pub struct FloorSpec {
/// Procedural biome name (`"grass"`/`"dirt"`/`"stone"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generator: Option<String>,
/// Fixed-glyph tile index (int or single-char string).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<TileIndex>,
/// Fixed-glyph foreground `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Fixed-glyph background `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
}
impl FloorSpec {
/// Resolves this spec to a [`Floor`] for a `width × height` board, recording a
/// nonfatal error (and falling back to [`Floor::Blank`]) for an unknown generator.
fn resolve(&self, width: usize, height: usize, errors: &mut Vec<LogLine>) -> Floor {
if let Some(name) = &self.generator {
return match FloorGenerator::from_name(name) {
Some(g) => Floor::biome(g, width, height),
None => {
errors.push(LogLine::error(format!(
"floor names unknown generator '{name}'; using blank floor"
)));
Floor::Blank
}
};
}
// A fixed glyph if any visual field is given, else a blank floor.
if self.tile.is_some() || self.fg.is_some() || self.bg.is_some() {
Floor::Fixed(Glyph {
tile: self.tile.map(TileIndex::into_u32).unwrap_or(32),
fg: self.fg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }),
bg: self.bg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }),
})
} else {
Floor::Blank
}
}
}
/// Serde form of one `[[triggers]]` entry: an invisible, non-solid, script-only
/// object at `(x, y)`. Triggers are folded into [`Board::objects`] at load; they
/// are re-emitted here on save (recognised as scripted, non-solid, glyphless).
#[derive(Deserialize, Serialize, Clone)]
pub(crate) struct TriggerSpec {
/// Column (0-indexed).
pub x: usize,
/// Row (0-indexed).
pub y: usize,
/// Name of the Rhai script (in `[scripts]`) this trigger runs.
pub script_name: String,
/// Optional board-unique name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Optional open-ended labels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
/// Serde form of one `[[decorations]]` entry: a non-solid `(glyph, archetype)` at
/// `(x, y)`, drawn only where the grid cell is empty. A solid archetype is rejected.
#[derive(Deserialize, Serialize, Clone)]
pub(crate) struct DecorationSpec {
/// Column (0-indexed).
pub x: usize,
/// Row (0-indexed).
pub y: usize,
/// Archetype name (or `"empty"`); must be non-solid.
pub kind: String,
/// Tile index (int or single-char string).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<TileIndex>,
/// Foreground `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Background `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
}
/// A tile index in a palette entry: either a plain integer or a character literal.
///
/// Accepting both forms lets map files write `tile = 35` or `tile = "#"` (the char
@@ -104,13 +203,16 @@ pub(crate) fn color_to_hex(color: Rgba8) -> String {
/// Converts a parsed map file into a runtime [`Board`].
///
/// Builds each layer (collecting its object/portal/player placements), then runs
/// the cross-layer validations that span the whole board:
/// Builds the single grid (collecting object/portal/player placements), resolves
/// the floor, then runs the cross-cell validations that span the whole board:
/// - the player must appear exactly once (missing → `(0, 0)`; multiple → the first), and wins its cell;
/// - at most one solid may occupy a cell across all layers (a stacked solid is dropped);
/// - at most one solid may occupy a cell (a conflicting solid object is dropped);
/// - object names must be board-unique (a duplicate is cleared) and portal names unique (a duplicate is dropped).
///
/// Returns `Err` only when a layer's grid dimensions disagree with the header.
/// Finally the `[[triggers]]` load as non-solid glyphless objects and the
/// `[[decorations]]` as off-grid non-solid cells.
///
/// Returns `Err` only when the grid dimensions disagree with the header.
impl TryFrom<MapFile> for Board {
type Error = String;
@@ -119,30 +221,28 @@ impl TryFrom<MapFile> for Board {
let h = mf.map.height;
let mut load_errors: Vec<LogLine> = Vec::new();
// One PRNG for the whole board so generated floors depend only on content.
let mut rng = StdRand::seed(FLOOR_SEED);
// Build every layer, collecting non-terrain placements with their layer z.
let mut layers: Vec<Layer> = Vec::with_capacity(mf.layers.len());
let mut object_specs: Vec<(crate::layer::ObjectTemplate, usize, usize, usize)> = Vec::new();
let mut portal_specs: Vec<(crate::layer::PortalTemplate, usize, usize, usize)> = Vec::new();
// Build the single grid, collecting non-terrain placements.
let (mut grid, placements) = build_grid(&mf.grid, w, h, &mut load_errors)?;
let mut object_specs: Vec<(crate::layer::ObjectTemplate, usize, usize)> = Vec::new();
let mut portal_specs: Vec<(crate::layer::PortalTemplate, usize, usize)> = Vec::new();
let mut player_positions: Vec<(usize, usize)> = Vec::new();
for (z, data) in mf.layers.iter().enumerate() {
let (layer, placements) = build_layer(data, w, h, &mut rng, &mut load_errors)?;
for p in placements {
match p {
Placement::Object(t, x, y) => object_specs.push((t, x, y, z)),
Placement::Portal(t, x, y) => portal_specs.push((t, x, y, z)),
Placement::Player(x, y) => player_positions.push((x, y)),
}
for p in placements {
match p {
Placement::Object(t, x, y) => object_specs.push((t, x, y)),
Placement::Portal(t, x, y) => portal_specs.push((t, x, y)),
Placement::Player(x, y) => player_positions.push((x, y)),
}
layers.push(layer);
}
if layers.is_empty() {
return Err("map has no [[layers]]".into());
}
// The player must be placed exactly once across all layers.
// Resolve the cosmetic floor attribute (blank / fixed glyph / biome).
let floor = mf
.map
.floor
.as_ref()
.map(|f| f.resolve(w, h, &mut load_errors))
.unwrap_or(Floor::Blank);
// The player must be placed exactly once.
let (px, py) = match player_positions.len() {
1 => player_positions[0],
0 => {
@@ -153,50 +253,34 @@ impl TryFrom<MapFile> for Board {
}
n => {
load_errors.push(LogLine::error(format!(
"player cell appears {n} times across layers; using the first"
"player cell appears {n} times; using the first"
)));
player_positions[0]
}
};
let pidx = py * w + px;
// Enforce one solid per cell across all layers: the first solid seen at a
// cell claims it; a later (upper) solid stacked on it is dropped.
// Track which cells hold a solid (the grid's own solids seed the map).
let mut solid_occupied = vec![false; w * h];
for (z, layer) in layers.iter_mut().enumerate() {
for (idx, cell) in layer.cells.iter_mut().enumerate() {
if !cell.1.behavior().solid {
continue;
}
if solid_occupied[idx] {
let (x, y) = (idx % w, idx / w);
load_errors.push(LogLine::error(format!(
"two solids stacked at ({x}, {y}) on layer {z}; dropping the upper one"
)));
*cell = (Glyph::transparent(), Archetype::Empty);
} else {
solid_occupied[idx] = true;
}
for (idx, cell) in grid.iter().enumerate() {
if cell.1.behavior().solid {
solid_occupied[idx] = true;
}
}
// The player wins its cell: clear any solid terrain under it (any layer)
// and claim the cell so a solid object placed here is dropped below.
if solid_occupied[pidx] {
for layer in &mut layers {
if layer.cells[pidx].1.behavior().solid {
layer.cells[pidx] = (Glyph::transparent(), Archetype::Empty);
}
}
// The player wins its cell: clear any solid terrain under it and claim the
// cell so a solid object placed here is dropped below.
if grid[pidx].1.behavior().solid {
grid[pidx] = (Glyph::transparent(), Archetype::Empty);
}
solid_occupied[pidx] = true;
// Spawn objects in layer-then-reading order, so ids are deterministic and
// "lowest id wins a collision" / "first claimant keeps the name" hold.
// Spawn objects in reading order, so ids are deterministic and "lowest id
// wins a collision" / "first claimant keeps the name" hold.
let mut objects: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
let mut next_object_id: ObjectId = 1;
let mut seen_names: HashMap<String, ObjectId> = HashMap::new();
for (t, x, y, z) in object_specs {
for (t, x, y) in object_specs {
let idx = y * w + x;
// A solid object may not share a cell with another solid.
if t.solid && solid_occupied[idx] {
@@ -210,19 +294,7 @@ impl TryFrom<MapFile> for Board {
}
let id = next_object_id;
// Name uniqueness: first claimant keeps it; later duplicates are cleared.
let name = t.name.and_then(|n| match seen_names.entry(n.clone()) {
Entry::Vacant(v) => {
v.insert(id);
Some(n)
}
Entry::Occupied(o) => {
load_errors.push(LogLine::error(format!(
"object name {n:?} already used by object {}; clearing name",
o.get()
)));
None
}
});
let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors));
if t.solid {
solid_occupied[idx] = true;
}
@@ -232,7 +304,6 @@ impl TryFrom<MapFile> for Board {
id,
x,
y,
z,
glyph: t.glyph,
solid: t.solid,
opaque: t.opaque,
@@ -252,10 +323,44 @@ impl TryFrom<MapFile> for Board {
next_object_id += 1;
}
// Triggers: invisible, non-solid, script-only objects. They join the same
// `objects` map (so ids/name-uniqueness/script dispatch all apply), after the
// hand-placed grid objects.
for t in mf.triggers {
if !t.x.lt(&w) || !t.y.lt(&h) {
load_errors.push(LogLine::error(format!(
"trigger at ({}, {}) is out of bounds; skipping",
t.x, t.y
)));
continue;
}
let id = next_object_id;
let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors));
objects.insert(
id,
ObjectDef {
id,
x: t.x,
y: t.y,
glyph: Glyph::transparent(),
solid: false,
opaque: false,
pushable: false,
grab: false,
script_name: Some(t.script_name),
builtin_script: None,
tags: t.tags.unwrap_or_default().into_iter().collect(),
queue: ObjQueue::new(),
name,
},
);
next_object_id += 1;
}
// Build the portal list, dropping duplicate names (first claimant wins).
let mut seen_portal_names: HashSet<String> = HashSet::new();
let mut portals: Vec<PortalDef> = Vec::new();
for (t, x, y, z) in portal_specs {
for (t, x, y) in portal_specs {
if !seen_portal_names.insert(t.name.clone()) {
load_errors.push(LogLine::error(format!(
"portal name {:?} already used by another portal; skipping portal",
@@ -267,17 +372,27 @@ impl TryFrom<MapFile> for Board {
name: t.name,
x,
y,
z,
target_map: t.target_map,
target_entry: t.target_entry,
});
}
// Decorations: non-solid off-grid cells (a solid archetype is rejected).
let mut decorations: Vec<Decoration> = Vec::new();
for d in mf.decorations {
match resolve_decoration(&d) {
Ok(dec) => decorations.push(dec),
Err(msg) => load_errors.push(LogLine::error(msg)),
}
}
let mut board = Board {
name: mf.map.name,
width: w,
height: h,
layers,
grid,
floor,
decorations,
player: PlayerPos {
x: px as i64,
y: py as i64,
@@ -290,13 +405,64 @@ impl TryFrom<MapFile> for Board {
registry: HashMap::new(),
};
// Turn script-backed archetype cells (pushers/spinners) into their scripted
// objects. Runs after the cross-layer validation above, so board invariants
// objects. Runs after the cross-cell validation above, so board invariants
// hold; the same call also fixes editor-placed machines before a playtest.
board.expand_builtin_archetypes();
Ok(board)
}
}
/// Claims `name` for object `id` in `seen_names`, returning `Some(name)` for the
/// first claimant and `None` (with a logged error) for any later duplicate.
fn claim_name(
n: String,
id: ObjectId,
seen_names: &mut HashMap<String, ObjectId>,
errors: &mut Vec<LogLine>,
) -> Option<String> {
match seen_names.entry(n.clone()) {
Entry::Vacant(v) => {
v.insert(id);
Some(n)
}
Entry::Occupied(o) => {
errors.push(LogLine::error(format!(
"object name {n:?} already used by object {}; clearing name",
o.get()
)));
None
}
}
}
/// Resolves a [`DecorationSpec`] into a [`Decoration`], erroring if its archetype is
/// unknown or solid (decorations must be non-solid).
fn resolve_decoration(d: &DecorationSpec) -> Result<Decoration, String> {
let arch = if d.kind == "empty" {
Archetype::Empty
} else {
Archetype::try_from(d.kind.as_str())
.map_err(|msg| format!("decoration at ({}, {}): {msg}; skipping", d.x, d.y))?
};
if arch.behavior().solid {
return Err(format!(
"decoration at ({}, {}) has solid archetype {:?}; skipping",
d.x, d.y, d.kind
));
}
let default = arch.default_glyph();
Ok(Decoration {
x: d.x,
y: d.y,
glyph: Glyph {
tile: d.tile.map(TileIndex::into_u32).unwrap_or(default.tile),
fg: d.fg.as_deref().map(parse_color).unwrap_or(default.fg),
bg: d.bg.as_deref().map(parse_color).unwrap_or(default.bg),
},
archetype: arch,
})
}
/// Pool of palette characters for save: printable ASCII (plus a leading space for
/// the common transparent-empty cell), excluding `"` and `\` which would need
/// escaping inside a TOML string.
@@ -310,24 +476,15 @@ fn char_pool() -> Vec<char> {
pool
}
/// Builds the [`PaletteEntry`] for a terrain/floor cell `(glyph, arch)`.
/// Builds the [`PaletteEntry`] for a grid terrain cell `(glyph, arch)`.
///
/// A grid `Empty` cell is always transparent now (floors are a board attribute),
/// so it maps to `kind = "empty"`; anything else is its archetype keyword.
fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
if arch == Archetype::Empty {
if glyph.tile == 0 {
// Transparent: lower layers show through.
PaletteEntry {
kind: "empty".into(),
..Default::default()
}
} else {
// A fixed floor glyph (generators bake to literal glyphs on load).
PaletteEntry {
kind: "floor".into(),
tile: Some(TileIndex::Num(glyph.tile)),
fg: Some(color_to_hex(glyph.fg)),
bg: Some(color_to_hex(glyph.bg)),
..Default::default()
}
PaletteEntry {
kind: "empty".into(),
..Default::default()
}
} else {
PaletteEntry {
@@ -340,19 +497,25 @@ fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
}
}
/// Serializes one layer `z` of `board` to a [`LayerData`], optionally writing the
/// player cell into this layer (done on the top layer only).
fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
/// Whether an object is a **trigger** — an invisible, non-solid, script-only object
/// authored/serialized in `[[triggers]]` rather than the grid palette.
fn is_trigger(o: &ObjectDef) -> bool {
!o.solid && o.glyph.tile == 0 && o.script_name.is_some() && o.builtin_script.is_none()
}
/// Serializes `board`'s single grid (terrain + non-trigger objects + portals +
/// player) into a [`GridData`].
fn grid_to_data(board: &Board) -> GridData {
let (w, h) = (board.width, board.height);
let mut pool = char_pool().into_iter();
let mut palette: HashMap<String, PaletteEntry> = HashMap::new();
let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new();
let mut grid: Vec<Vec<char>> = vec![vec![' '; w]; h];
// Terrain/floor cells: dedup each unique (glyph, archetype) to one palette char.
// Terrain cells: dedup each unique (glyph, archetype) to one palette char.
for (y, row) in grid.iter_mut().enumerate() {
for (x, slot) in row.iter_mut().enumerate() {
let (glyph, arch) = *board.get(z, x, y);
let (glyph, arch) = *board.get(x, y);
*slot = *cell_to_key.entry((glyph, arch)).or_insert_with(|| {
let ch = pool.next().expect("ran out of palette characters");
palette.insert(ch.to_string(), cell_entry(glyph, arch));
@@ -361,8 +524,9 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
}
}
// Objects on this layer overwrite their (transparent) cell with an object char.
for o in board.objects.values().filter(|o| o.z == z) {
// Objects overwrite their (transparent) grid cell with an object char. Triggers
// are written to `[[triggers]]` instead (see `From<&Board>`), so skip them here.
for o in board.objects.values().filter(|o| !is_trigger(o)) {
// A built-in archetype object (e.g. a pusher) round-trips back to its
// archetype keyword: emit it as a terrain cell (deduped with real terrain),
// using the object's current glyph, rather than a `kind = "object"` entry.
@@ -397,8 +561,8 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
grid[o.y][o.x] = ch;
}
// Portals on this layer.
for p in board.portals.iter().filter(|p| p.z == z) {
// Portals.
for p in board.portals.iter() {
let ch = pool.next().expect("ran out of palette characters");
palette.insert(
ch.to_string(),
@@ -413,8 +577,8 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
grid[p.y][p.x] = ch;
}
// The player is written onto the top layer.
if place_player {
// The player.
{
let ch = pool.next().expect("ran out of palette characters");
palette.insert(
ch.to_string(),
@@ -433,7 +597,7 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
.join("\n")
+ "\n";
// Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences.
LayerData {
GridData {
content: Some(content),
fill: None,
sparse: None,
@@ -441,25 +605,76 @@ fn layer_to_data(board: &Board, z: usize, place_player: bool) -> LayerData {
}
}
/// Builds the [`FloorSpec`] for `board`'s floor, or `None` for a blank floor. A
/// biome re-emits its generator name (so the procedural floor round-trips); a fixed
/// glyph re-emits its tile/fg/bg.
fn floor_to_spec(floor: &Floor) -> Option<FloorSpec> {
match floor {
Floor::Blank => None,
Floor::Fixed(g) => Some(FloorSpec {
generator: None,
tile: Some(TileIndex::Num(g.tile)),
fg: Some(color_to_hex(g.fg)),
bg: Some(color_to_hex(g.bg)),
}),
Floor::Biome { generator, .. } => Some(FloorSpec {
generator: Some(generator.name().into()),
tile: None,
fg: None,
bg: None,
}),
}
}
/// Converts a runtime [`Board`] back into a serializable [`MapFile`].
///
/// Emits one `[[layers]]` entry per board layer; the player is written onto the
/// top layer. Procedural floors were baked to literal glyphs at load, so they are
/// saved as fixed `floor` glyphs (the generator declaration is not recovered).
/// Emits the single `[grid]`, the `[[triggers]]` (invisible script objects) and
/// `[[decorations]]` lists, and the `floor` attribute (a biome re-emits its
/// generator name, so procedural floors round-trip).
impl From<&Board> for MapFile {
fn from(board: &Board) -> Self {
let top = board.layer_count() - 1;
let layers = (0..board.layer_count())
.map(|z| layer_to_data(board, z, z == top))
let grid = grid_to_data(board);
// Trigger objects → `[[triggers]]`.
let triggers = board
.objects
.values()
.filter(|o| is_trigger(o))
.map(|o| {
let mut tags: Vec<String> = o.tags.iter().cloned().collect();
tags.sort();
TriggerSpec {
x: o.x,
y: o.y,
script_name: o.script_name.clone().unwrap_or_default(),
name: o.name.clone(),
tags: (!tags.is_empty()).then_some(tags),
}
})
.collect();
// Decorations → `[[decorations]]`.
let decorations = board
.decorations
.iter()
.map(|d| DecorationSpec {
x: d.x,
y: d.y,
kind: d.archetype.name().into(),
tile: Some(TileIndex::Num(d.glyph.tile)),
fg: Some(color_to_hex(d.glyph.fg)),
bg: Some(color_to_hex(d.glyph.bg)),
})
.collect();
MapFile {
map: MapHeader {
name: board.name.clone(),
width: board.width,
height: board.height,
floor: floor_to_spec(&board.floor),
board_script_name: board.board_script_name.clone(),
},
layers,
grid,
triggers,
decorations,
}
}
}
-5
View File
@@ -34,10 +34,6 @@ pub struct ObjectDef {
pub x: usize,
/// Row of this object on the board (0-indexed).
pub y: usize,
/// Index of the layer this object belongs to (0 = bottom). Determines its
/// draw order: objects on higher layers render above lower-layer terrain and
/// objects. Set at load from the layer the object's palette char appeared in.
pub z: usize,
/// Visual representation of this object. Owned by the object (not derived
/// from the grid cell), so scripts can change tile, fg, and bg at runtime.
pub glyph: Glyph,
@@ -101,7 +97,6 @@ impl ObjectDef {
id: 0,
x,
y,
z: 0,
glyph: Self::default_glyph(),
solid: true,
opaque: true,
+40 -31
View File
@@ -35,7 +35,7 @@ use crate::game::SAY_DURATION;
use crate::log::LogLine;
use crate::map_file::parse_color;
use crate::object_def::ObjectDef;
use crate::utils::{Direction, ErrorSink, Hook, ObjectId};
use crate::utils::{Direction, LogSink, Hook, ObjectId};
use rhai::{
Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext,
Scope, AST,
@@ -53,7 +53,7 @@ use crate::player::PlayerRef;
/// Types which can be registered to be sent to Rhai
pub trait Registerable {
/// Register this type and relevant getters / setters with a Rhai engine
fn register(engine: &mut Engine, error_sink: ErrorSink);
fn register(engine: &mut Engine, log_sink: LogSink);
}
/// A compiled script plus which lifecycle hooks it defines.
@@ -93,7 +93,7 @@ pub struct ScriptHost {
engine: Engine,
scripts: HashMap<String, CompiledScript>,
scopes: HashMap<ObjectId, Scope<'static>>,
errors: ErrorSink,
log_sink: LogSink,
board: BoardRef
}
@@ -106,20 +106,20 @@ impl ScriptHost {
/// `scripts` is the world-level script pool (script name → Rhai source); it is
/// read only during construction and not retained afterward.
pub fn new(board_ref: BoardRef, player: PlayerRef, script_sources: &HashMap<String, String>) -> Self {
let errors = ErrorSink::new();
let log_sink = LogSink::new();
let mut scopes = HashMap::new();
let mut engine = Engine::new();
PlayerWithPos::register(&mut engine, errors.clone());
Keyring::register(&mut engine, errors.clone());
BoardRef::register(&mut engine, errors.clone());
ObjectInfo::register(&mut engine, errors.clone());
Glyph::register(&mut engine, errors.clone());
ObjQueue::register(&mut engine, errors.clone());
Registry::register(&mut engine, errors.clone());
PlayerWithPos::register(&mut engine, log_sink.clone());
Keyring::register(&mut engine, log_sink.clone());
BoardRef::register(&mut engine, log_sink.clone());
ObjectInfo::register(&mut engine, log_sink.clone());
Glyph::register(&mut engine, log_sink.clone());
ObjQueue::register(&mut engine, log_sink.clone());
Registry::register(&mut engine, log_sink.clone());
register_write_api(&mut engine, board_ref.clone());
register_write_api(&mut engine, board_ref.clone(), log_sink.clone());
register_global_constants(&mut engine, board_ref.clone(), player.clone());
let board = board_ref.borrow();
@@ -145,7 +145,7 @@ impl ScriptHost {
Some(src) => src,
None => {
failed.insert(key.clone());
errors.error(format!("object references unknown script '{key}'"));
log_sink.error(format!("object references unknown script '{key}'"));
continue;
}
}
@@ -169,7 +169,7 @@ impl ScriptHost {
}
Err(err) => {
failed.insert(key.clone());
errors.error(format!("script '{key}' failed to compile: {err}"));
log_sink.error(format!("script '{key}' failed to compile: {err}"));
}
}
}
@@ -189,7 +189,7 @@ impl ScriptHost {
Self {
engine,
scripts,
errors,
log_sink,
scopes,
board: board_ref
}
@@ -242,7 +242,7 @@ impl ScriptHost {
hook.to_str(),
args,
) {
self.errors.error(format!("script '{}' {} error: {err}", script_key, hook));
self.log_sink.error(format!("script '{}' {} error: {err}", script_key, hook));
}
}
// Run the drain regardless of if we have the hook, otherwise
@@ -299,7 +299,7 @@ impl ScriptHost {
// If it's not there at all, just bail:
if arities.is_empty() {
self.errors.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
self.log_sink.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
return actions;
}
@@ -321,7 +321,7 @@ impl ScriptHost {
fn_name,
args,
) {
self.errors.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
self.log_sink.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
}
info.drain(&mut actions, 0.0)
}
@@ -331,15 +331,23 @@ impl ScriptHost {
actions
}
/// Removes and returns the errors collected since the last drain.
pub fn take_errors(&mut self) -> Vec<LogLine> {
self.errors.take()
/// Removes and returns the log lines (script `log()` output and errors)
/// collected since the last drain.
pub fn take_logs(&mut self) -> Vec<LogLine> {
self.log_sink.take()
}
/// The shared, immediate log channel. Exposed so `GameState::apply_actions`
/// can push its application-time errors (teleport/push/shift failures) onto
/// the same ordered channel as script `log()` output, instead of a side vec.
pub(crate) fn log_sink(&self) -> &LogSink {
&self.log_sink
}
}
// ── Write API ─────────────────────────────────────────────────────────────────
fn register_write_api(engine: &mut Engine, board: BoardRef) {
fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
engine.register_type_with_name::<Direction>("Direction");
// Rhai does not auto-derive comparison for custom types, so register `==`/`!=`
// to let scripts test the `bump` direction (e.g. `if dir == West { … }`).
@@ -418,14 +426,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) {
emit(&b, source_of(&ctx), Action::Die);
});
let b = board.clone();
engine.register_fn(
"log",
move |ctx: NativeCallContext, msg: ImmutableString| {
let id = source_of(&ctx);
emit(&b, id, Action::Log(LogLine::raw(msg.to_string())));
},
);
// log(msg): write to the game log immediately, bypassing the object's action
// queue — so a `log()` is not paced by a pending move/delay and surfaces the
// moment the hook runs. GameState flushes the shared LogSink into its log.
let sink = log_sink.clone();
engine.register_fn("log", move |msg: ImmutableString| {
sink.line(LogLine::raw(msg.to_string()));
});
let b = board.clone();
engine.register_fn(
@@ -591,11 +598,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) {
// a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable,
// and won't move anything into a cell that's not vacant (or vacated by this shift).
let b = board.clone();
let sink = log_sink.clone();
engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| {
let src = source_of(&ctx);
match read_coord_array(&arr) {
Ok(pairs) => emit(&b, src, Action::Shift(pairs)),
Err(_) => emit(&b, src, Action::Log(LogLine::error("shift: each entry must be [x, y]".to_string())))
// Malformed args are caught at call time, so log the error immediately.
Err(_) => sink.error("shift: each entry must be [x, y]".to_string()),
}
});
}
+2 -2
View File
@@ -44,8 +44,8 @@ fn object_pushes_crate_on_init() {
game.run_init();
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
assert_eq!(b.get(2, 0).1, Archetype::Empty);
assert_eq!(b.get(3, 0).1, Archetype::Crate);
}
#[test]
+4 -6
View File
@@ -1,8 +1,8 @@
use crate::archetype::Archetype;
use crate::board::Board;
use crate::floor::Floor;
use crate::game::GameState;
use crate::glyph::Glyph;
use crate::layer::Layer;
use crate::utils::{Direction, PlayerPos, PortalDef};
use crate::world::World;
use std::cell::RefCell;
@@ -15,9 +15,9 @@ fn make_board(px: i64, py: i64, portals: Vec<PortalDef>) -> Board {
name: "test".into(),
width: 3,
height: 3,
layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); 9],
}],
grid: vec![(Glyph::transparent(), Archetype::Empty); 9],
floor: Floor::Blank,
decorations: Vec::new(),
player: PlayerPos { x: px, y: py },
objects: BTreeMap::new(),
next_object_id: 1,
@@ -40,7 +40,6 @@ fn two_board_world() -> World {
name: "to_b2".into(),
x: 2,
y: 0,
z: 0,
target_map: "b2".into(),
target_entry: "from_b1".into(),
}],
@@ -52,7 +51,6 @@ fn two_board_world() -> World {
name: "from_b1".into(),
x: 1,
y: 1,
z: 0,
target_map: "b1".into(),
target_entry: "to_b2".into(),
}],
+18 -32
View File
@@ -1,36 +1,30 @@
use super::load_board;
use crate::archetype::Archetype;
use crate::glyph::Glyph;
use crate::map_file::parse_color;
#[test]
fn fill_builds_a_full_grid_of_one_char() {
// Layer 0 is a solid fill of a fixed floor glyph; a later sparse layer places
// the player. Every cell of layer 0 must be that floor glyph.
// `fill` fills the whole single grid with one palette char. No player char is
// possible in a filled grid, so the player falls back to (0, 0) and wins (clears)
// that cell; every *other* cell is the filled archetype.
let toml = r##"
[map]
name = "Test"
width = 3
height = 2
[[layers]]
fill = "f"
palette = { "f" = { kind = "floor", tile = ".", fg = "#112233", bg = "#445566" } }
[[layers]]
sparse = [ { x = 0, y = 0, ch = "@" } ]
palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
[grid]
fill = "#"
palette = { "#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" } }
"##;
let board = load_board(toml);
assert!(board.is_valid());
let floor = Glyph {
tile: '.' as u32,
fg: parse_color("#112233"),
bg: parse_color("#445566"),
};
for y in 0..2 {
for x in 0..3 {
assert_eq!(*board.get(0, x, y), (floor, Archetype::Empty));
let expected = if (x, y) == (0, 0) {
Archetype::Empty // player won its fallback cell
} else {
Archetype::Wall
};
assert_eq!(board.get(x, y).1, expected, "cell ({x}, {y})");
}
}
assert_eq!((board.player.x, board.player.y), (0, 0));
@@ -38,18 +32,14 @@ palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
#[test]
fn sparse_places_only_listed_cells() {
// A grass floor underneath; a sparse layer holding just the player and one object.
// A sparse grid holding just the player and one object; every other cell empty.
let toml = r##"
[map]
name = "Test"
width = 4
height = 1
[[layers]]
fill = "g"
palette = { "g" = { kind = "floor", generator = "grass" } }
[[layers]]
[grid]
sparse = [ { x = 0, y = 0, ch = "@" }, { x = 2, y = 0, ch = "O" } ]
palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } }
"##;
@@ -59,7 +49,7 @@ palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind =
assert_eq!(board.objects.len(), 1);
assert_eq!((board.objects[&1].x, board.objects[&1].y), (2, 0));
// An unlisted sparse cell is a transparent empty.
assert_eq!(board.get(1, 1, 0).1, Archetype::Empty);
assert_eq!(board.get(1, 0).1, Archetype::Empty);
}
#[test]
@@ -70,7 +60,7 @@ name = "Test"
width = 2
height = 1
[[layers]]
[grid]
sparse = [ { x = 5, y = 0, ch = "O" }, { x = 0, y = 0, ch = "@" } ]
palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } }
"##;
@@ -95,18 +85,14 @@ name = "Test"
width = 2
height = 1
[[layers]]
[grid]
fill = "xy"
palette = { " " = { kind = "empty" } }
[[layers]]
sparse = [ { x = 0, y = 0, ch = "@" } ]
palette = { " " = { kind = "empty" }, "@" = { kind = "player" } }
"##;
let board = load_board(toml);
assert!(
!board.is_valid(),
"a non-single-char fill is a nonfatal error"
);
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(board.get(1, 0).1, Archetype::Empty);
}
+6 -6
View File
@@ -1,4 +1,4 @@
use super::{layer, load_board, map};
use super::{grid, load_board, map};
use crate::archetype::Archetype;
use crate::board::Board;
use crate::map_file::MapFile;
@@ -6,7 +6,7 @@ use crate::map_file::MapFile;
#[test]
fn grid_wrong_row_count_returns_error() {
// height = 3 but only 2 rows in the layer grid.
let toml = map(3, 3, &[layer("...\n...", &[(".", "kind = \"empty\"")])]);
let toml = map(3, 3, &grid("...\n...", &[(".", "kind = \"empty\"")]));
let mf: MapFile = toml::from_str(&toml).unwrap();
let result = Board::try_from(mf);
assert!(result.is_err());
@@ -24,7 +24,7 @@ fn grid_wrong_row_count_returns_error() {
#[test]
fn grid_wrong_row_width_returns_error() {
// width = 4 but second row is only 3 characters.
let toml = map(4, 2, &[layer("....\n...", &[(".", "kind = \"empty\"")])]);
let toml = map(4, 2, &grid("....\n...", &[(".", "kind = \"empty\"")]));
let mf: MapFile = toml::from_str(&toml).unwrap();
let result = Board::try_from(mf);
assert!(result.is_err());
@@ -46,14 +46,14 @@ fn unknown_kind_produces_error_block() {
let toml = map(
2,
1,
&[layer(
&grid(
"X@",
&[("X", "kind = \"frobnicate\""), ("@", "kind = \"player\"")],
)],
),
);
let board = load_board(&toml);
assert_eq!(
*board.get(0, 0, 0),
*board.get(0, 0),
(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)
);
}
+11 -15
View File
@@ -16,33 +16,29 @@ fn load_board(toml: &str) -> Board {
Board::try_from(mf).expect("convert to board")
}
/// Builds one `[[layers]]` block: a triple-quoted `content` grid plus an inline
/// Builds the `[grid]` block: a triple-quoted `content` grid plus an inline
/// `palette` table. Each palette entry is `(char_key, inline-body)` where the
/// body is the inside of the entry's `{ ... }` (e.g. `kind = "wall"`).
fn layer(content: &str, palette: &[(&str, &str)]) -> String {
fn grid(content: &str, palette: &[(&str, &str)]) -> String {
let pal = palette
.iter()
.map(|(k, body)| format!("\"{k}\" = {{ {body} }}"))
.collect::<Vec<_>>()
.join(", ");
format!("\n[[layers]]\ncontent = \"\"\"\n{content}\n\"\"\"\npalette = {{ {pal} }}\n")
format!("\n[grid]\ncontent = \"\"\"\n{content}\n\"\"\"\npalette = {{ {pal} }}\n")
}
/// Wraps a `[map]` header of the given size around the supplied `[[layers]]`
/// blocks (each produced by [`layer`]).
fn map(width: usize, height: usize, layers: &[String]) -> String {
let mut s = format!("[map]\nname = \"Test\"\nwidth = {width}\nheight = {height}\n");
for l in layers {
s.push_str(l);
}
s
/// Wraps a `[map]` header of the given size around the single `[grid]` block
/// (produced by [`grid`]).
fn map(width: usize, height: usize, grid_block: &str) -> String {
format!("[map]\nname = \"Test\"\nwidth = {width}\nheight = {height}\n{grid_block}")
}
/// A 3×1 single-layer map: an `empty`/`wall` palette plus one `object` entry
/// placed by char `ch` (cyan `@` glyph), with `extra` appended to its body. The
/// player is placed at the far-right cell via a second char where room allows;
/// callers that need the player elsewhere build the map directly.
fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String {
fn map_3x1_object(grid_str: &str, ch: &str, extra: &str) -> String {
let body = if extra.is_empty() {
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"".to_string()
} else {
@@ -51,8 +47,8 @@ fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String {
map(
3,
1,
&[layer(
grid,
&grid(
grid_str,
&[
(" ", "kind = \"empty\""),
(".", "kind = \"empty\""),
@@ -63,6 +59,6 @@ fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String {
("@", "kind = \"player\""),
(ch, &body),
],
)],
),
)
}
@@ -1,13 +1,8 @@
use super::{layer, load_board, map, map_3x1_object};
use super::{grid, load_board, map, map_3x1_object};
use crate::archetype::Archetype;
/// Palette shorthands.
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
/// Palette shorthand.
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
const WALL: (&str, &str) = (
"#",
"kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"",
);
/// An object palette entry body with the given `extra` fields appended.
fn obj(extra: &str) -> String {
@@ -25,14 +20,14 @@ fn duplicate_name_clears_second_but_keeps_both_objects() {
let board = load_board(&map(
3,
1,
&[layer(
&grid(
"GH@",
&[
("G", &obj("name = \"gate\"")),
("H", &obj("name = \"gate\", solid = false")),
PLAYER,
],
)],
),
));
assert_eq!(board.objects.len(), 2, "both objects survive");
assert_eq!(board.objects[&1].name.as_deref(), Some("gate"));
@@ -46,7 +41,7 @@ fn palette_placement_puts_object_on_empty_floor() {
let board = load_board(&map_3x1_object("G.@", "G", ""));
assert_eq!(board.objects.len(), 1);
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 0).1, Archetype::Empty);
}
#[test]
@@ -65,10 +60,10 @@ fn palette_char_multi_occurrence_only_first_keeps_name() {
let board = load_board(&map(
4,
1,
&[layer(
&grid(
"GGG@",
&[("G", &obj("solid = false, name = \"guard\"")), PLAYER],
)],
),
));
assert_eq!(board.objects.len(), 3);
assert_eq!(board.objects[&1].name.as_deref(), Some("guard"));
@@ -77,41 +72,11 @@ fn palette_char_multi_occurrence_only_first_keeps_name() {
}
#[test]
fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() {
// Solid object stacked on a wall (different layer): dropped for the conflict.
let solid = map(
3,
1,
&[
layer("#.@", &[WALL, EMPTY, PLAYER]),
layer("O..", &[("O", &obj("")), EMPTY]),
],
);
assert!(load_board(&solid).objects.is_empty());
// Same placement but non-solid: kept (it doesn't claim the cell's solidity).
let nonsolid = map(
3,
1,
&[
layer("#.@", &[WALL, EMPTY, PLAYER]),
layer("O..", &[("O", &obj("solid = false")), EMPTY]),
],
);
assert_eq!(load_board(&nonsolid).objects.len(), 1);
}
#[test]
fn second_solid_object_on_a_cell_is_dropped() {
// Two solid objects on the same cell across layers: the upper one is dropped.
let board = load_board(&map(
3,
1,
&[
layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY]),
layer(".O.", &[EMPTY, ("O", &obj(""))]),
],
));
fn non_solid_object_and_wall_coexist_in_separate_cells() {
// With one grid each cell holds a single palette char, so a solid object can
// never be authored onto a wall cell. A wall and a (separate-cell) non-solid
// object both load fine.
let board = load_board(&map_3x1_object("#G@", "G", "solid = false"));
assert_eq!(board.objects.len(), 1);
assert!(!board.is_valid());
assert!(board.is_valid());
}
@@ -1,4 +1,4 @@
use super::{layer, load_board, map};
use super::{grid, load_board, map};
use crate::archetype::Archetype;
/// Palette shorthands shared by these tests.
@@ -11,61 +11,22 @@ const WALL: (&str, &str) = (
#[test]
fn player_char_places_on_empty_floor() {
let b = load_board(&map(3, 1, &[layer(".@.", &[EMPTY, PLAYER])]));
let b = load_board(&map(3, 1, &grid(".@.", &[EMPTY, PLAYER])));
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
assert!(b.is_valid());
}
#[test]
fn player_wins_solid_terrain_silently() {
// Wall on layer 0, player on layer 1 at the same cell: the wall is cleared,
// no error reported.
let b = load_board(&map(
3,
1,
&[layer("#..", &[WALL, EMPTY]), layer("@..", &[PLAYER, EMPTY])],
));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 0, 0).1, Archetype::Empty); // wall cleared on layer 0
assert!(b.is_valid());
}
#[test]
fn player_wins_against_a_solid_object_silently() {
// A solid object on the player's cell (different layer) is dropped silently.
let b = load_board(&map(
3,
1,
&[
layer(
".O.",
&[
EMPTY,
(
"O",
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"",
),
],
),
layer(".@.", &[EMPTY, PLAYER]),
],
));
assert_eq!((b.player.x, b.player.y), (1, 0));
assert!(b.objects.is_empty());
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert!(b.is_valid());
}
#[test]
fn player_char_appearing_twice_uses_first() {
let b = load_board(&map(3, 1, &[layer("@.@", &[EMPTY, PLAYER])]));
let b = load_board(&map(3, 1, &grid("@.@", &[EMPTY, PLAYER])));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(!b.is_valid());
}
#[test]
fn player_char_missing_falls_back_to_origin() {
let b = load_board(&map(3, 1, &[layer("...", &[EMPTY])]));
let b = load_board(&map(3, 1, &grid("...", &[EMPTY])));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(!b.is_valid());
}
@@ -74,8 +35,30 @@ fn player_char_missing_falls_back_to_origin() {
fn player_fallback_to_origin_clears_solid_terrain() {
// No player cell, so the player falls back to (0, 0) — which holds a wall. The
// player wins its cell: the wall is cleared. The fallback is still reported.
let b = load_board(&map(3, 1, &[layer("#..", &[WALL, EMPTY])]));
let b = load_board(&map(3, 1, &grid("#..", &[WALL, EMPTY])));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 0).1, Archetype::Empty);
assert!(!b.is_valid());
}
#[test]
fn player_fallback_wins_against_a_solid_object() {
// No player cell → player falls back to (0, 0), which holds a solid object. The
// player wins its cell and the object is dropped.
let b = load_board(&map(
3,
1,
&grid(
"O..",
&[
EMPTY,
(
"O",
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"",
),
],
),
));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(b.objects.is_empty());
}
@@ -1,4 +1,4 @@
use super::{layer, load_board, map};
use super::{grid, load_board, map};
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
@@ -9,7 +9,7 @@ fn portal_duplicate_name_drops_second() {
let board = load_board(&map(
3,
1,
&[layer(
&grid(
"12@",
&[
(
@@ -22,7 +22,7 @@ fn portal_duplicate_name_drops_second() {
),
PLAYER,
],
)],
),
));
assert_eq!(
board.portals.len(),
@@ -38,7 +38,7 @@ fn portal_palette_char_places_portal_at_grid_position() {
let board = load_board(&map(
3,
1,
&[layer(
&grid(
"1.@",
&[
(
@@ -48,7 +48,7 @@ fn portal_palette_char_places_portal_at_grid_position() {
EMPTY,
PLAYER,
],
)],
),
));
assert_eq!(board.portals.len(), 1);
assert_eq!((board.portals[0].x, board.portals[0].y), (0, 0));
+11 -11
View File
@@ -1,7 +1,7 @@
//! Pushers are now scripted objects (the `pusher_*` archetypes expand into objects
//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_<dir>` tag).
use super::{layer, load_board, map};
use super::{grid, load_board, map};
use crate::archetype::Archetype;
use crate::game::GameState;
use crate::map_file::MapFile;
@@ -24,10 +24,10 @@ fn pusher_loads_as_a_tagged_scripted_solid_object() {
let board = load_board(&map(
3,
1,
&[layer(
&grid(
"P @",
&[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")],
)],
),
));
let mut id = 0;
let p = pusher(&board, &mut id);
@@ -48,14 +48,14 @@ fn pusher_advances_and_shoves_a_crate() {
let board = load_board(&map(
5,
1,
&[layer(
&grid(
"Po @",
&[
("P", "kind = \"pusher_east\""),
("o", "kind = \"crate\""),
("@", "kind = \"player\""),
],
)],
),
));
let mut pid = 0;
pusher(&board, &mut pid);
@@ -69,12 +69,12 @@ fn pusher_advances_and_shoves_a_crate() {
let b = game.board();
assert!(b.objects[&pid].x > 0, "pusher advanced east");
assert_eq!(
b.get(0, 1, 0).1,
b.get(1, 0).1,
Archetype::Empty,
"crate left its start cell"
);
assert!(
(2..b.width).any(|x| b.get(0, x, 0).1 == Archetype::Crate),
(2..b.width).any(|x| b.get(x, 0).1 == Archetype::Crate),
"crate was shoved east"
);
}
@@ -84,14 +84,14 @@ fn pusher_blocked_by_wall_stays_put() {
let board = load_board(&map(
4,
1,
&[layer(
&grid(
"P# @",
&[
("P", "kind = \"pusher_east\""),
("#", "kind = \"wall\""),
("@", "kind = \"player\""),
],
)],
),
));
let mut pid = 0;
pusher(&board, &mut pid);
@@ -113,10 +113,10 @@ fn pusher_round_trips_to_its_keyword() {
let board = load_board(&map(
3,
1,
&[layer(
&grid(
"P @",
&[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")],
)],
),
));
// Save collapses the expanded object back into the `pusher_east` keyword.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
+37 -27
View File
@@ -1,4 +1,4 @@
use super::{layer, load_board, map};
use super::{grid, load_board, map};
use crate::glyph::Glyph;
use crate::map_file::{MapFile, parse_color};
use color::Rgba8;
@@ -25,7 +25,7 @@ fn round_trip(toml: &str) -> crate::board::Board {
#[test]
fn object_glyph_round_trips_through_toml() {
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY]));
let board = load_board(&toml);
assert_eq!(board.objects.len(), 1);
let obj0 = &board.objects[&1];
@@ -53,10 +53,10 @@ fn object_tags_round_trip_through_toml() {
let toml = map(
3,
1,
&[layer(
&grid(
"@O.",
&[PLAYER, ("O", &obj("tags = [\"enemy\", \"boss\"]")), EMPTY],
)],
),
);
let board = load_board(&toml);
let obj0 = &board.objects[&1];
@@ -75,7 +75,7 @@ fn object_tags_round_trip_through_toml() {
#[test]
fn object_empty_tags_omitted_from_toml() {
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY]));
let board = load_board(&toml);
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
assert!(
@@ -89,10 +89,10 @@ fn object_name_round_trips_through_toml() {
let toml = map(
3,
1,
&[layer(
&grid(
"@O.",
&[PLAYER, ("O", &obj("name = \"beacon\"")), EMPTY],
)],
),
);
let board = load_board(&toml);
assert_eq!(board.objects[&1].name.as_deref(), Some("beacon"));
@@ -108,7 +108,7 @@ fn object_name_round_trips_through_toml() {
#[test]
fn unnamed_object_name_stays_none_through_toml() {
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY]));
let board2 = round_trip(&toml);
assert_eq!(
board2.objects[&1].name, None,
@@ -117,31 +117,41 @@ fn unnamed_object_name_stays_none_through_toml() {
}
#[test]
fn fixed_floor_glyph_round_trips_through_toml() {
// A fixed floor glyph (visual-only Empty cell) must survive save→load.
let toml = map(
3,
1,
&[layer(
"@F.",
&[
PLAYER,
(
"F",
"kind = \"floor\", tile = \"#\", fg = \"#010203\", bg = \"#040506\"",
),
EMPTY,
],
)],
);
fn fixed_floor_attribute_round_trips_through_toml() {
// A fixed floor glyph is now a board attribute (`floor = { … }`), not a grid
// cell. It must survive save→load and show through empty grid cells.
let toml = "[map]\nname = \"Test\"\nwidth = 3\nheight = 1\n\
floor = { tile = \"#\", fg = \"#010203\", bg = \"#040506\" }\n\
[grid]\ncontent = \"\"\"\n@..\n\"\"\"\n\
palette = { \"@\" = { kind = \"player\" }, \".\" = { kind = \"empty\" } }\n";
let fixed = Glyph {
tile: '#' as u32,
fg: parse_color("#010203"),
bg: parse_color("#040506"),
};
let board = load_board(&toml);
let board = load_board(toml);
// An empty grid cell reveals the fixed floor.
assert_eq!(board.glyph_at(1, 0), fixed);
let board2 = round_trip(&toml);
let board2 = round_trip(toml);
assert_eq!(board2.glyph_at(1, 0), fixed);
}
#[test]
fn biome_floor_round_trips_generator_name() {
// A biome floor re-emits its generator name (not baked glyphs), so the save
// stays compact and the reloaded board is identical.
let toml = "[map]\nname = \"Test\"\nwidth = 3\nheight = 1\n\
floor = { generator = \"grass\" }\n\
[grid]\ncontent = \"\"\"\n@..\n\"\"\"\n\
palette = { \"@\" = { kind = \"player\" }, \".\" = { kind = \"empty\" } }\n";
let board = load_board(toml);
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
assert!(
toml_out.contains("generator = \"grass\""),
"biome floor must re-emit its generator name, got: {toml_out}"
);
// Reloaded floor glyphs match (deterministic seed).
let board2 = load_board(&toml_out);
assert_eq!(board2.glyph_at(1, 0), board.glyph_at(1, 0));
}
+5 -5
View File
@@ -2,7 +2,7 @@
//! into objects carrying the embedded `spinner.rhai` plus a `BUILTIN_spinner_<dir>`
//! tag, and collapse back to the keyword on save (just like pushers).
use super::{layer, load_board, map};
use super::{grid, load_board, map};
use crate::map_file::MapFile;
#[test]
@@ -10,10 +10,10 @@ fn spinner_loads_as_a_tagged_scripted_solid_object() {
let board = load_board(&map(
3,
1,
&[layer(
&grid(
"S @",
&[("S", "kind = \"spinner_cw\""), ("@", "kind = \"player\"")],
)],
),
));
let (_, obj) = board
.objects
@@ -36,10 +36,10 @@ fn spinner_round_trips_to_its_keyword() {
let board = load_board(&map(
3,
1,
&[layer(
&grid(
"S @",
&[("S", "kind = \"spinner_ccw\""), ("@", "kind = \"player\"")],
)],
),
));
// Save collapses the expanded object back into the `spinner_ccw` keyword.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
+11 -11
View File
@@ -3,7 +3,7 @@
//! `BUILTIN_transporter_<dir>` tag). Bumping one from its facing side teleports
//! the bumper past it, or out of a paired opposite-facing transporter.
use super::{layer, load_board, map};
use super::{grid, load_board, map};
use crate::game::GameState;
use crate::map_file::MapFile;
use crate::utils::Direction;
@@ -14,10 +14,10 @@ fn transporter_loads_as_a_tagged_scripted_solid_object() {
let board = load_board(&map(
3,
1,
&[layer(
&grid(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
)],
),
));
let (_, obj) = board
.objects
@@ -37,10 +37,10 @@ fn bumping_a_transporter_drops_you_on_its_far_side() {
let board = load_board(&map(
3,
1,
&[layer(
&grid(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
)],
),
));
let mut game = GameState::new(board);
game.run_init();
@@ -63,7 +63,7 @@ fn a_blocked_far_side_transports_out_of_the_paired_transporter() {
let board = load_board(&map(
6,
1,
&[layer(
&grid(
"@T# W ",
&[
("@", "kind = \"player\""),
@@ -71,7 +71,7 @@ fn a_blocked_far_side_transports_out_of_the_paired_transporter() {
("#", "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\""),
("W", "kind = \"transporter_west\""),
],
)],
),
));
let mut game = GameState::new(board);
game.run_init();
@@ -95,14 +95,14 @@ fn a_pushed_crate_is_transported_through() {
let board = load_board(&map(
4,
1,
&[layer(
&grid(
"@oT ",
&[
("@", "kind = \"player\""),
("o", "kind = \"crate\""),
("T", "kind = \"transporter_east\""),
],
)],
),
));
let mut game = GameState::new(board);
game.run_init();
@@ -130,10 +130,10 @@ fn transporter_round_trips_to_its_keyword() {
let board = load_board(&map(
3,
1,
&[layer(
&grid(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
)],
),
));
// Save collapses the expanded object back into the `transporter_east` keyword.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
+4 -4
View File
@@ -7,9 +7,9 @@ mod scripting;
use crate::archetype::Archetype;
use crate::board::Board;
use crate::floor::Floor;
use crate::game::GameState;
use crate::glyph::Glyph;
use crate::layer::Layer;
use crate::object_def::ObjectDef;
use crate::utils::PlayerPos;
use std::collections::{BTreeMap, HashMap};
@@ -29,9 +29,9 @@ fn board_with_object(
name: "test".into(),
width: 1,
height: 1,
layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty)],
}],
grid: vec![(Glyph::transparent(), Archetype::Empty)],
floor: Floor::Blank,
decorations: Vec::new(),
player: PlayerPos { x: 0, y: 0 },
objects: BTreeMap::from([(1, object)]),
next_object_id: 2,
+18 -18
View File
@@ -32,8 +32,8 @@ fn pushing_a_crate_reveals_the_floor_underneath() {
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!(b.get(1, 2, 0).1, Archetype::Crate);
assert_eq!(b.get(1, 1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::Crate);
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.glyph_at(0, 0), floor_glyph);
}
@@ -45,8 +45,8 @@ fn player_pushes_single_crate() {
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate);
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::Crate);
}
#[test]
@@ -58,8 +58,8 @@ fn push_blocked_by_wall_moves_nothing() {
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate);
assert_eq!(b.get(0, 2, 0).1, Archetype::Wall);
assert_eq!(b.get(1, 0).1, Archetype::Crate);
assert_eq!(b.get(2, 0).1, Archetype::Wall);
}
#[test]
@@ -71,7 +71,7 @@ fn push_blocked_by_edge_moves_nothing() {
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate);
assert_eq!(b.get(1, 0).1, Archetype::Crate);
}
#[test]
@@ -83,9 +83,9 @@ fn cascade_pushes_two_crates() {
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
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]
@@ -98,8 +98,8 @@ fn cascade_blocked_by_wall_moves_nothing() {
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate);
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate);
assert_eq!(b.get(1, 0).1, Archetype::Crate);
assert_eq!(b.get(2, 0).1, Archetype::Crate);
}
#[test]
@@ -124,8 +124,8 @@ fn hcrate_pushes_east_but_not_north() {
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 2, 0).1, Archetype::HCrate);
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.
@@ -135,7 +135,7 @@ fn hcrate_pushes_east_but_not_north() {
game.try_move(Direction::North);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 3));
assert_eq!(b.get(0, 0, 2).1, Archetype::HCrate);
assert_eq!(b.get(0, 2).1, Archetype::HCrate);
}
#[test]
@@ -147,8 +147,8 @@ fn vcrate_pushes_north_but_not_east() {
game.try_move(Direction::North);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 2));
assert_eq!(b.get(0, 0, 2).1, Archetype::Empty);
assert_eq!(b.get(0, 0, 1).1, Archetype::VCrate);
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.
@@ -158,5 +158,5 @@ fn vcrate_pushes_north_but_not_east() {
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 1, 0).1, Archetype::VCrate);
assert_eq!(b.get(1, 0).1, Archetype::VCrate);
}
+17 -13
View File
@@ -85,8 +85,6 @@ enum SolidKind {
Object(ObjectId),
/// A solid terrain cell, with everything needed to rewrite it elsewhere.
Terrain {
/// Layer the terrain lives on.
z: usize,
/// The cell's glyph.
glyph: Glyph,
/// The cell's archetype.
@@ -149,12 +147,12 @@ impl Solid {
}
}
/// Builds a solid terrain cell at `(x, y)` on layer `z`.
pub(crate) fn terrain_at(x: usize, y: usize, z: usize, glyph: Glyph, arch: Archetype) -> Solid {
/// Builds a solid terrain cell at `(x, y)`.
pub(crate) fn terrain_at(x: usize, y: usize, glyph: Glyph, arch: Archetype) -> Solid {
Solid {
x,
y,
kind: SolidKind::Terrain { z, glyph, arch },
kind: SolidKind::Terrain { glyph, arch },
behavior: arch.behavior(),
}
}
@@ -228,8 +226,8 @@ impl Solid {
obj.y = y;
}
}
SolidKind::Terrain { z, glyph, arch } => {
*board.get_mut(z, x, y) = (glyph, arch);
SolidKind::Terrain { glyph, arch } => {
*board.get_mut(x, y) = (glyph, arch);
}
}
}
@@ -256,9 +254,6 @@ pub struct PortalDef {
pub x: usize,
/// Row of this portal on the board (0-indexed).
pub y: usize,
/// Index of the layer this portal belongs to (0 = bottom). Determines its
/// draw order relative to terrain/objects on other layers.
pub z: usize,
/// Key of the target board in `World::boards`.
pub target_map: String,
/// Name of the arrival portal on the target board.
@@ -464,15 +459,24 @@ impl Display for Hook {
}
}
/// Channel for engine/compile/runtime errors.
/// Shared, immediate channel for log output: script `log()` lines plus
/// engine/compile/runtime errors. A script host pushes to it *during* hook
/// execution (so a `log()` is not paced by the object's action queue), and
/// [`GameState`](crate::game::GameState) flushes it into its log each frame.
#[derive(Clone)]
pub struct ErrorSink(Rc<RefCell<Vec<LogLine>>>);
pub struct LogSink(Rc<RefCell<Vec<LogLine>>>);
impl ErrorSink {
impl LogSink {
pub fn new() -> Self {
Self(Rc::new(RefCell::new(Vec::new())))
}
/// Pushes a pre-built [`LogLine`] onto the channel immediately.
pub fn line(&self, line: LogLine) {
self.0.borrow_mut().push(line);
}
/// Pushes a red-on-black error line onto the channel immediately.
pub fn error(&self, msg: String) {
self.0.borrow_mut().push(LogLine::error(msg));
}
+2 -2
View File
@@ -149,11 +149,11 @@ mod tests {
// The copy changed; the original is still empty at that cell.
assert_eq!(
copy.boards["start"].borrow().get(0, 1, 0).1,
copy.boards["start"].borrow().get(1, 0).1,
Archetype::Wall
);
assert_eq!(
world.boards["start"].borrow().get(0, 1, 0).1,
world.boards["start"].borrow().get(1, 0).1,
Archetype::Empty
);
// And they are genuinely different allocations.