Board layers

This commit is contained in:
2026-06-15 23:35:18 -05:00
parent d1d0824d37
commit 01cd73ca3b
29 changed files with 2166 additions and 2051 deletions
+47 -19
View File
@@ -41,15 +41,26 @@ pub(crate) enum Action {
/// Append a styled line to the game log.
Log(LogLine),
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
SetTag { target: ObjectId, tag: String, present: bool },
SetTag {
target: ObjectId,
tag: String,
present: bool,
},
/// Display a speech bubble above the source object for `duration` seconds.
Say(String, f64),
/// Pause draining this object's queue for `secs` seconds. Never reaches the board queue.
Delay(f64),
/// Set the source object's foreground and/or background color.
SetColor { fg: Option<Rgba8>, bg: Option<Rgba8> },
SetColor {
fg: Option<Rgba8>,
bg: Option<Rgba8>,
},
/// Call `fn_name` on `target` object, optionally passing `arg`.
Send { target: ObjectId, fn_name: String, arg: Option<ScriptArg> },
Send {
target: ObjectId,
fn_name: String,
arg: Option<ScriptArg>,
},
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// selection dispatches [`Send`](Action::Send) to the source object.
Scroll(Vec<ScrollLine>),
@@ -65,8 +76,8 @@ fn dir_to_str(d: Direction) -> &'static str {
match d {
Direction::North => "North",
Direction::South => "South",
Direction::East => "East",
Direction::West => "West",
Direction::East => "East",
Direction::West => "West",
}
}
@@ -83,26 +94,35 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
match action {
Action::Move(dir) => {
m.insert("type".into(), ds("Move"));
m.insert("dir".into(), ds(dir_to_str(*dir)));
m.insert("dir".into(), ds(dir_to_str(*dir)));
}
Action::SetTile(n) => {
m.insert("type".into(), ds("SetTile"));
m.insert("tile".into(), Dynamic::from(*n as i64));
}
Action::Log(line) => {
let text = line.spans.first().map(|s| s.text.as_str()).unwrap_or("").to_string();
let text = line
.spans
.first()
.map(|s| s.text.as_str())
.unwrap_or("")
.to_string();
m.insert("type".into(), ds("Log"));
m.insert("msg".into(), Dynamic::from(text));
m.insert("msg".into(), Dynamic::from(text));
}
Action::SetTag { target, tag, present } => {
m.insert("type".into(), ds("SetTag"));
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("tag".into(), Dynamic::from(tag.clone()));
Action::SetTag {
target,
tag,
present,
} => {
m.insert("type".into(), ds("SetTag"));
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("tag".into(), Dynamic::from(tag.clone()));
m.insert("present".into(), Dynamic::from(*present));
}
Action::Say(text, dur) => {
m.insert("type".into(), ds("Say"));
m.insert("msg".into(), Dynamic::from(text.clone()));
m.insert("type".into(), ds("Say"));
m.insert("msg".into(), Dynamic::from(text.clone()));
m.insert("duration".into(), Dynamic::from(*dur));
}
Action::Delay(secs) => {
@@ -111,13 +131,21 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
}
Action::SetColor { fg, bg } => {
m.insert("type".into(), ds("SetColor"));
if let Some(c) = fg { m.insert("fg".into(), Dynamic::from(color_to_hex(*c))); }
if let Some(c) = bg { m.insert("bg".into(), Dynamic::from(color_to_hex(*c))); }
if let Some(c) = fg {
m.insert("fg".into(), Dynamic::from(color_to_hex(*c)));
}
if let Some(c) = bg {
m.insert("bg".into(), Dynamic::from(color_to_hex(*c)));
}
}
Action::Send { target, fn_name, arg } => {
m.insert("type".into(), ds("Send"));
Action::Send {
target,
fn_name,
arg,
} => {
m.insert("type".into(), ds("Send"));
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("name".into(), Dynamic::from(fn_name.clone()));
m.insert("name".into(), Dynamic::from(fn_name.clone()));
if let Some(a) = arg {
let dyn_arg = match a {
ScriptArg::Str(s) => Dynamic::from(s.clone()),
+5 -5
View File
@@ -1,6 +1,6 @@
use color::Rgba8;
use crate::glyph::Glyph;
use crate::utils::{Behavior, Direction, Pushable};
use color::Rgba8;
/// A class of board cell, encoding its default behavior and appearance.
///
@@ -95,8 +95,8 @@ impl Archetype {
Archetype::VCrate => "vcrate",
Archetype::Pusher(Direction::North) => "pusher_north",
Archetype::Pusher(Direction::South) => "pusher_south",
Archetype::Pusher(Direction::East) => "pusher_east",
Archetype::Pusher(Direction::West) => "pusher_west",
Archetype::Pusher(Direction::East) => "pusher_east",
Archetype::Pusher(Direction::West) => "pusher_west",
Archetype::ErrorBlock => "error_block",
}
}
@@ -174,8 +174,8 @@ impl TryFrom<&str> for Archetype {
// entries in map files. They live in [[objects]] with their own glyph.
"pusher_north" => Ok(Archetype::Pusher(Direction::North)),
"pusher_south" => Ok(Archetype::Pusher(Direction::South)),
"pusher_east" => Ok(Archetype::Pusher(Direction::East)),
"pusher_west" => Ok(Archetype::Pusher(Direction::West)),
"pusher_east" => Ok(Archetype::Pusher(Direction::East)),
"pusher_west" => Ok(Archetype::Pusher(Direction::West)),
_ => Err(format!("unknown archetype: {name}")),
}
}
+216 -111
View File
@@ -1,11 +1,11 @@
use std::collections::{BTreeMap, HashMap};
use crate::archetype::Archetype;
use crate::archetype::Archetype::Empty;
use crate::glyph::Glyph;
use crate::layer::Layer;
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::utils::Direction;
use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid};
use std::collections::{BTreeMap, HashMap};
/// The complete state of one game board (a single room or screen).
///
@@ -31,19 +31,12 @@ pub struct Board {
pub width: usize,
/// Height of the board in cells.
pub height: usize,
/// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to
/// access by `(x, y)` coordinates.
pub(crate) cells: Vec<(Glyph, Archetype)>,
/// Row-major cache of the visual floor layer: the glyph drawn for a cell when
/// it would otherwise render as [`Archetype::Empty`]. Computed once at load
/// from [`floor_spec`](Board::floor_spec) (see [`crate::floor::build_floor`]).
/// When a map declares no `[floor]`, every entry is black-on-black space (the
/// historical look of an empty cell). Read it via [`Board::glyph_at`].
pub(crate) floor: Vec<Glyph>,
/// The raw `[floor]` declaration, kept so [`crate::map_file::save`] can
/// round-trip it. `None` when the map declared no floor. (Generators
/// re-randomize on reload; literal glyph grids are preserved exactly.)
pub(crate) floor_spec: Option<crate::floor::FloorSpec>,
/// 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>,
/// Current player position. See [`Player`] for caveats about its future.
pub player: Player,
/// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap`
@@ -75,61 +68,85 @@ pub struct Board {
}
impl Board {
/// Returns a reference to the cell at `(x, y)`.
/// Number of draw layers on this board (≥ 1 for a loaded board).
pub fn layer_count(&self) -> usize {
self.layers.len()
}
/// Returns a reference to the cell at `(x, y)` on layer `z`.
///
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `x` or `y` are
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are
/// out of bounds.
pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) {
&self.cells[y * self.width + x]
pub fn get(&self, z: usize, x: usize, y: usize) -> &(Glyph, Archetype) {
&self.layers[z].cells[y * self.width + x]
}
/// Returns a mutable reference to the cell at `(x, y)`.
/// Returns a mutable reference to the cell at `(x, y)` on layer `z`.
///
/// Panics if `x` or `y` are out of bounds.
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
&mut self.cells[y * self.width + x]
/// 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) {
let w = self.width;
&mut self.layers[z].cells[y * w + x]
}
/// Returns the glyph we should display for a given coordinate:
/// Returns the glyph to display at `(x, y)`, honoring layer draw order.
///
/// - If the coord contains a [`Solid`], use the appropriate glyph for that
/// - If it contains at least one non-solid object with a glyph other than 0, use one (arbitrarily)
/// - If it contains any non-Empty archetype, use that glyph
/// - Fall back to the [`floor`](Board::floor) glyph.
/// 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:
///
/// An `Empty` cell's *own* glyph is intentionally ignored — the floor layer
/// supersedes it (so a cell vacated by a pushed crate reveals the floor, not
/// black). Panics if out of bounds.
/// 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`).
///
/// 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 {
if let Some(solid) = self.solid_at(x, y) {
// Use the solid
return match solid {
Solid::Player => Glyph::player(),
Solid::Cell(_) => self.get(x, y).0,
Solid::Object(id) => self.objects[&id].glyph
// The player is rendered above the whole stack (see the `Player` notes).
if self.player.x == x as i32 && self.player.y == y as i32 {
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);
}
}
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;
}
}
let (glyph, arch) = *self.get(x, y);
let ids = self.object_ids_at(x, y); // IDs of non-solid (ethereal?) objects
// Objects with nonzero (nonblank) glyphs take precedence. This allows us to use invisible
// nonsolid objects on the board.
if let Some(glyph) = ids.iter().find(|id| self.objects[id].glyph.tile != 0 ).map(|id| self.objects[id].glyph) {
return glyph
}
// Is there a normal archetype?
if arch != Empty {
return glyph
}
// Portal: passable and non-opaque, but visible above the floor.
if self.portals.iter().any(|p| p.x == x && p.y == y) {
return PortalDef::default_glyph();
}
self.floor[y * self.width + x]
// Nothing on any layer: the canonical black empty cell.
Archetype::Empty.default_glyph()
}
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
@@ -153,7 +170,7 @@ impl Board {
pub fn is_valid(&self) -> bool {
self.load_errors.is_empty()
}
/// Returns the single solid entity occupying `(x, y)`, if any.
///
/// Checks player first, then objects, then the grid archetype. Because at most one solid
@@ -165,19 +182,38 @@ impl Board {
if self.player.x == x as i32 && self.player.y == y as i32 {
return Some(Solid::Player);
}
// A solid object shadows the grid cell it sits on.
if let Some(id) = self.solid_object_id_at(x, y)
{
// A solid object shadows the cell it sits on.
if let Some(id) = self.solid_object_id_at(x, y) {
return Some(Solid::Object(id));
}
// Otherwise the grid archetype itself may be solid (e.g. a wall).
let arch = self.get(x, y).1;
if arch.behavior().solid {
return Some(Solid::Cell(arch));
// Otherwise some layer's terrain archetype may be solid (e.g. a wall).
if let Some(z) = self.solid_cell_layer(x, y) {
return Some(Solid::Cell(self.get(z, x, y).1));
}
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 the coordinates of every [`Archetype::Pusher`] terrain cell on the
/// board (scanning all layers). Used by the pusher heartbeat in
/// [`GameState::tick`](crate::game::GameState::tick).
pub fn pusher_cells(&self) -> Vec<(usize, usize)> {
let mut cells = Vec::new();
for y in 0..self.height {
for x in 0..self.width {
if matches!(self.solid_at(x, y), Some(Solid::Cell(Archetype::Pusher(_)))) {
cells.push((x, y));
}
}
}
cells
}
/// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it.
///
/// Convenience inverse of [`solid_at`](Board::solid_at).
@@ -261,7 +297,12 @@ impl Board {
/// - If the target has a pushable chain: pushes it first, then slides in.
/// - If blocked or out of bounds: no-op, returns `false`.
pub(crate) fn advance_pusher(&mut self, x: usize, y: usize) -> bool {
let Archetype::Pusher(dir) = self.get(x, y).1 else { return false; };
let Some(z) = self.solid_cell_layer(x, y) else {
return false;
};
let Archetype::Pusher(dir) = self.get(z, x, y).1 else {
return false;
};
let (dx, dy): (i32, i32) = dir.into();
let tx = x as i32 + dx;
let ty = y as i32 + dy;
@@ -283,24 +324,24 @@ impl Board {
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
///
/// A solid object is relocated; otherwise the grid archetype (a crate) is
/// moved, leaving `Empty` floor behind (the grid has no separate floor layer).
/// 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.
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
// The player owns its cell, so move it before considering objects/grid.
// The player owns its cell, so move it before considering objects/terrain.
if self.player.x == x as i32 && self.player.y == y as i32 {
self.player.x = tx as i32;
self.player.y = ty as i32;
} else if let Some(id) = self.solid_object_id_at(x, y)
{
} else if let Some(id) = self.solid_object_id_at(x, y) {
let obj = self.objects.get_mut(&id).expect("id from object_id_at");
obj.x = tx;
obj.y = ty;
} else {
let moved = *self.get(x, y);
*self.get_mut(tx, ty) = moved;
*self.get_mut(x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty);
} else if let Some(z) = self.solid_cell_layer(x, y) {
let moved = *self.get(z, x, y);
*self.get_mut(z, tx, ty) = moved;
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
}
}
@@ -309,20 +350,19 @@ impl Board {
self.objects
.iter()
.filter(|(_, o)| o.x == x && o.y == y)
.map(|(&id, _)| id).collect()
.map(|(&id, _)| id)
.collect()
}
/// Returns a borrow of the actual object at `(x, y)` if any
pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option<ObjectId> {
self.objects
.iter()
.find_map(|(&id, o)| {
if o.x == x && o.y == y && o.solid {
Some(id)
} else {
None
}
})
self.objects.iter().find_map(|(&id, o)| {
if o.x == x && o.y == y && o.solid {
Some(id)
} else {
None
}
})
}
/// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id.
@@ -340,17 +380,22 @@ impl Board {
#[cfg(test)]
pub(crate) mod tests {
use std::collections::{BTreeMap, HashMap};
use color::Rgba8;
use super::Board;
use crate::archetype::Archetype;
use crate::glyph::Glyph;
use crate::layer::Layer;
use crate::object_def::ObjectDef;
use crate::utils::Direction;
use crate::utils::{ObjectId, Player, Solid};
use super::Board;
use color::Rgba8;
use std::collections::{BTreeMap, HashMap};
/// Builds an all-empty `w×h` board with the given player position and objects.
/// Assigns sequential ids (1..=n) to objects.
/// 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.
///
/// 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.
pub(crate) fn open_board(
w: usize,
h: usize,
@@ -368,10 +413,13 @@ pub(crate) mod tests {
name: "test".into(),
width: w,
height: h,
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
floor: vec![Archetype::Empty.default_glyph(); w * h],
floor_spec: None,
player: Player { x: player.0, y: player.1 },
layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); w * h],
}],
player: Player {
x: player.0,
y: player.1,
},
objects: object_map,
next_object_id,
portals: Vec::new(),
@@ -381,25 +429,48 @@ pub(crate) mod tests {
}
}
/// Stamps a crate cell onto a board.
/// Inserts a visible floor layer (filled with `glyph`) below everything,
/// bumping existing terrain and objects up one 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;
}
}
/// 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.
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
*board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
let z = top(board);
*board.get_mut(z, x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
}
/// Stamps a wall cell onto a board.
/// Stamps a wall cell onto the board's top layer.
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
let z = top(board);
*board.get_mut(z, x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
}
/// Stamps an arbitrary archetype cell onto a board.
/// Stamps an arbitrary archetype cell onto the board's top layer.
pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
*board.get_mut(x, y) = (arch.default_glyph(), arch);
let z = top(board);
*board.get_mut(z, x, y) = (arch.default_glyph(), arch);
}
#[test]
fn solid_at_reports_wall_object_and_empty() {
let mut board = open_board(4, 1, (3, 0), vec![]);
board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall);
wall_at(&mut board, 1, 0);
board.add_object(ObjectDef::new(2, 0));
assert!(board.solid_at(0, 0).is_none());
@@ -412,7 +483,9 @@ pub(crate) mod tests {
assert!(!board.is_passable(1, 0));
match board.solid_at(2, 0) {
Some(Solid::Object(id)) => assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0)),
Some(Solid::Object(id)) => {
assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0))
}
_ => panic!("expected Solid::Object"),
}
assert!(!board.is_passable(2, 0));
@@ -453,8 +526,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(1, 0).1, Archetype::Crate); // no mutation
assert_eq!(board.get(2, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // no mutation
assert_eq!(board.get(0, 2, 0).1, Archetype::Empty);
let mut board = open_board(3, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
@@ -469,8 +542,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(1, 0).1, Archetype::Empty);
assert_eq!(board.get(2, 0).1, Archetype::Crate);
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
assert_eq!((board.player.x, board.player.y), (3, 0));
}
@@ -482,7 +555,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(1, 0).1, Archetype::Crate);
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
assert_eq!((board.player.x, board.player.y), (2, 0));
}
@@ -492,15 +565,47 @@ pub(crate) mod tests {
let mut board = open_board(3, 1, (2, 0), vec![]);
let floor_glyph = Glyph {
tile: '.' as u32,
fg: Rgba8 { r: 10, g: 20, b: 30, a: 255 },
bg: Rgba8 { r: 1, g: 2, b: 3, a: 255 },
fg: Rgba8 {
r: 10,
g: 20,
b: 30,
a: 255,
},
bg: Rgba8 {
r: 1,
g: 2,
b: 3,
a: 255,
},
};
board.floor = vec![floor_glyph; 3];
// Floor on a lower layer, a wall on the top (terrain) layer 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.
assert_eq!(board.glyph_at(0, 0), Archetype::Wall.default_glyph());
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 fresh_board_is_valid_and_reports_errors() {
let mut board = open_board(1, 1, (0, 0), vec![]);
@@ -509,4 +614,4 @@ pub(crate) mod tests {
assert!(!board.is_valid());
assert_eq!(board.load_errors.len(), 1);
}
}
}
+38 -266
View File
@@ -1,29 +1,24 @@
//! The floor layer: a per-cell visual background drawn wherever a board cell
//! would otherwise render as [`Archetype::Empty`](crate::archetype::Archetype::Empty).
//! Procedural floor generators.
//!
//! The floor is purely cosmetic — it carries no behavior. It exists to give
//! boards some non-distracting visual flavor (textured ground) with little
//! A "floor" is a purely cosmetic, non-solid visual placed on a board layer (a
//! palette entry with `kind = "floor"`). It carries no behavior — it exists to
//! give boards some non-distracting visual flavor (textured ground) with little
//! authoring effort, including randomly-generated "grass" / "dirt" / "stone".
//!
//! A board's floor is computed once at load time ([`build_floor`]) and cached as
//! a `Vec<Glyph>` on the [`Board`](crate::board::Board); generators are run during
//! that single pass and their results stored, so there is no per-frame cost and
//! no tile flicker. The raw [`FloorSpec`] is also kept on the board so a save
//! round-trips the declaration (generators re-randomize on reload; literal glyph
//! grids are preserved exactly).
//! The actual placement / per-cell expansion happens in [`crate::layer`] during
//! map load: a floor palette entry either names one of these generators (a fresh
//! glyph is rolled per grid cell) or gives a fixed glyph. This module only owns
//! the generators themselves.
use crate::archetype::Archetype;
use crate::log::LogLine;
use crate::map_file::{parse_color, TileIndex};
use color::Rgba8;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use tinyrand::{Probability, Rand, Seeded, StdRand};
use crate::glyph::Glyph;
use color::Rgba8;
use tinyrand::{Probability, Rand, StdRand};
/// Fixed seed for the floor PRNG, so a board's generated floor is deterministic
/// for a given spec (stable across reloads within a run, and testable).
const FLOOR_SEED: u64 = 0x_C0FF_EE15_F100_0001;
/// for a given map (stable across reloads within a run, and testable). The layer
/// builder seeds one [`StdRand`] with this and threads it through every
/// generator call so the result depends only on the map content.
pub(crate) const FLOOR_SEED: u64 = 0x_C0FF_EE15_F100_0001;
/// A procedural floor texture. Each generator differs only in its color scheme
/// and the probability/character set of its scattered "texture" glyphs; the
@@ -56,7 +51,7 @@ impl FloorGenerator {
/// Picks a background ground color within the generator's scheme, then with
/// the generator's probability scatters a lighter "texture" char on top
/// (otherwise a blank space, whose fg is irrelevant).
fn generate(&self, rng: &mut StdRand) -> Glyph {
pub(crate) fn generate(&self, rng: &mut StdRand) -> Glyph {
// (background color ranges, texture probability, texture chars).
let (bg, chars, prob) = match self {
// Green → greenish-yellow: g dominant, a touch of r for the yellow tilt.
@@ -131,221 +126,41 @@ fn lighten(c: Rgba8, amt: u8) -> Rgba8 {
}
}
/// A fixed floor glyph declared in a map file: visual only, no archetype.
///
/// Reuses [`TileIndex`] (so `tile` accepts a char or an int) and `"#RRGGBB"`
/// color strings, exactly like a `[palette]` entry minus the archetype.
#[derive(Deserialize, Serialize, Clone)]
pub struct FloorGlyph {
/// Tile index into the board's font. Accepts an integer or single-char string.
pub tile: TileIndex,
/// Foreground color as an `"#RRGGBB"` hex string.
pub fg: String,
/// Background color as an `"#RRGGBB"` hex string.
pub bg: String,
}
impl FloorGlyph {
/// Converts this declaration into a concrete [`Glyph`].
fn to_glyph(&self) -> Glyph {
Glyph {
tile: self.tile.into_u32(),
fg: parse_color(&self.fg),
bg: parse_color(&self.bg),
}
}
}
/// One entry in a floor grid's palette: either a generator name or a fixed glyph.
///
/// `#[serde(untagged)]`: a TOML string parses as [`FloorTile::Generator`]; a TOML
/// table parses as [`FloorTile::Glyph`].
#[derive(Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum FloorTile {
/// A generator name, e.g. `"grass"`.
Generator(String),
/// A fixed glyph definition.
Glyph(FloorGlyph),
}
/// The optional `[floor]` declaration in a map file, in one of four shapes.
///
/// `#[serde(untagged)]`, so the variants are distinguished by TOML shape — order
/// matters: a string hits [`FloorSpec::Generator`]; a table with a `tile` key hits
/// [`FloorSpec::Single`]; a table with a `content` key hits [`FloorSpec::Grid`].
/// (Absence of a `[floor]` section is represented by `Option::None` on the map
/// file, not a variant here, and yields a default black-on-black floor.)
#[derive(Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum FloorSpec {
/// A single generator name covering the whole board, e.g. `floor = "grass"`.
Generator(String),
/// A single fixed glyph filling every floor cell.
Single(FloorGlyph),
/// A grid (like `[grid]`) with its own palette of generators and/or glyphs.
Grid {
/// Multi-line grid string; one char per cell, looked up in `palette`.
content: String,
/// Char → [`FloorTile`] lookup for the grid.
palette: HashMap<String, FloorTile>,
},
}
/// The canonical "empty" floor glyph: a black-on-black space. This is the default
/// floor (when a map declares no `[floor]`) and the fallback for any cell that
/// can't be resolved.
fn empty_glyph() -> Glyph {
Archetype::Empty.default_glyph()
}
/// Builds the cached per-cell floor `Vec<Glyph>` (row-major, `width * height`)
/// from an optional [`FloorSpec`].
///
/// Best-effort and nonfatal: any problem (unknown generator/char, grid-dimension
/// mismatch) is recorded on `errors` and that cell falls back to [`empty_glyph`],
/// so the board still loads. With no spec, the floor is all black-on-black space
/// (the previous behavior, where `Empty` cells rendered as nothing).
pub fn build_floor(
spec: &Option<FloorSpec>,
width: usize,
height: usize,
errors: &mut Vec<LogLine>,
) -> Vec<Glyph> {
let mut rng = StdRand::seed(FLOOR_SEED);
let count = width * height;
match spec {
// No declaration: the historical default of black-on-black whitespace.
None => vec![empty_glyph(); count],
// One generator for the whole board: run it once per cell.
Some(FloorSpec::Generator(name)) => match FloorGenerator::from_name(name) {
Some(generator) => (0..count).map(|_| generator.generate(&mut rng)).collect(),
None => {
errors.push(LogLine::error(format!("unknown floor generator '{name}'")));
vec![empty_glyph(); count]
}
},
// One fixed glyph: parse once, fill every cell.
Some(FloorSpec::Single(glyph)) => vec![glyph.to_glyph(); count],
// A grid with its own palette of generators and/or fixed glyphs.
Some(FloorSpec::Grid { content, palette }) => {
build_grid_floor(content, palette, width, height, &mut rng, errors)
}
}
}
/// Builds the floor for a [`FloorSpec::Grid`]: each grid character is looked up in
/// `palette` and resolved to a glyph (generators run per-cell). Dimension
/// mismatches and unknown chars/generators are reported once and fall back to the
/// empty glyph.
fn build_grid_floor(
content: &str,
palette: &HashMap<String, FloorTile>,
width: usize,
height: usize,
rng: &mut StdRand,
errors: &mut Vec<LogLine>,
) -> Vec<Glyph> {
// Key the palette by char for O(1) per-cell lookup.
let by_char: HashMap<char, &FloorTile> = palette
.iter()
.filter_map(|(k, v)| k.chars().next().map(|c| (c, v)))
.collect();
let rows: Vec<&str> = content.lines().collect();
if rows.len() != height {
errors.push(LogLine::error(format!(
"floor grid has {} rows but the board is {height} tall; missing cells use empty floor",
rows.len()
)));
}
// Report each unknown char / bad generator only once, not per occurrence.
let mut reported: HashSet<char> = HashSet::new();
let mut floor = Vec::with_capacity(width * height);
for y in 0..height {
// `chars().nth(x)` is O(x) but floors are small; this keeps the lookup
// correct for multi-byte chars (a byte index would not).
let row: Vec<char> = rows.get(y).map(|r| r.chars().collect()).unwrap_or_default();
for x in 0..width {
let glyph = match row.get(x) {
Some(&ch) => match by_char.get(&ch) {
Some(FloorTile::Glyph(g)) => g.to_glyph(),
Some(FloorTile::Generator(name)) => match FloorGenerator::from_name(name) {
Some(generator) => generator.generate(rng),
None => {
if reported.insert(ch) {
errors.push(LogLine::error(format!(
"floor palette '{ch}' names unknown generator '{name}'; using empty floor"
)));
}
empty_glyph()
}
},
None => {
if reported.insert(ch) {
errors.push(LogLine::error(format!(
"unknown floor grid character '{ch}'; using empty floor"
)));
}
empty_glyph()
}
},
// Row missing or too short: padded with empty floor (already reported above).
None => empty_glyph(),
};
floor.push(glyph);
}
}
floor
}
#[cfg(test)]
mod tests {
use super::*;
use tinyrand::Seeded;
/// Parses a `FloorSpec` from a standalone TOML snippet of the form
/// `floor = ...` / `[floor]\n...`, returning the parsed spec.
fn parse_spec(toml: &str) -> FloorSpec {
#[derive(Deserialize)]
struct Wrap {
floor: FloorSpec,
}
toml::from_str::<Wrap>(toml)
.expect("parse floor spec")
.floor
/// Rolls `count` glyphs from `generator` against a freshly-seeded RNG, the
/// same way the layer builder does.
fn roll(generator: FloorGenerator, count: usize) -> Vec<Glyph> {
let mut rng = StdRand::seed(FLOOR_SEED);
(0..count).map(|_| generator.generate(&mut rng)).collect()
}
#[test]
fn no_spec_yields_all_empty_floor() {
let mut errs = Vec::new();
let floor = build_floor(&None, 3, 2, &mut errs);
assert_eq!(floor.len(), 6);
assert!(floor.iter().all(|g| *g == empty_glyph()));
assert!(errs.is_empty());
}
#[test]
fn single_glyph_fills_every_cell() {
let spec = parse_spec("[floor]\ntile = \".\"\nfg = \"#112233\"\nbg = \"#445566\"\n");
let mut errs = Vec::new();
let floor = build_floor(&Some(spec), 2, 2, &mut errs);
let expected = Glyph {
tile: '.' as u32,
fg: parse_color("#112233"),
bg: parse_color("#445566"),
};
assert_eq!(floor.len(), 4);
assert!(floor.iter().all(|g| *g == expected));
fn from_name_parses_known_generators() {
assert_eq!(
FloorGenerator::from_name("grass"),
Some(FloorGenerator::Grass)
);
assert_eq!(
FloorGenerator::from_name("dirt"),
Some(FloorGenerator::Dirt)
);
assert_eq!(
FloorGenerator::from_name("stone"),
Some(FloorGenerator::Stone)
);
assert_eq!(FloorGenerator::from_name("lava"), None);
}
#[test]
fn grass_generator_stays_in_scheme_and_is_deterministic() {
let mut errs = Vec::new();
let a = build_floor(&Some(FloorSpec::Generator("grass".into())), 8, 8, &mut errs);
let b = build_floor(&Some(FloorSpec::Generator("grass".into())), 8, 8, &mut errs);
let a = roll(FloorGenerator::Grass, 64);
let b = roll(FloorGenerator::Grass, 64);
assert_eq!(a.len(), 64);
// Same seed → identical floors across builds.
// Same seed → identical rolls across builds.
assert!(a.iter().zip(&b).all(|(x, y)| x == y));
// Every cell's ground (bg) sits inside grass's channel ranges.
for g in &a {
@@ -353,48 +168,5 @@ mod tests {
assert!((45..=85).contains(&g.bg.g), "g out of range: {}", g.bg.g);
assert!((20..=35).contains(&g.bg.b), "b out of range: {}", g.bg.b);
}
assert!(errs.is_empty());
}
#[test]
fn unknown_generator_falls_back_and_reports() {
let mut errs = Vec::new();
let floor = build_floor(&Some(FloorSpec::Generator("lava".into())), 2, 1, &mut errs);
assert!(floor.iter().all(|g| *g == empty_glyph()));
assert_eq!(errs.len(), 1);
}
#[test]
fn grid_resolves_glyph_and_generator_entries() {
let spec = parse_spec(
"[floor]\ncontent = \"\"\"\ng.\n.g\n\"\"\"\n[floor.palette]\n\"g\" = \"grass\"\n\".\" = { tile = \"#\", fg = \"#010203\", bg = \"#040506\" }\n",
);
let mut errs = Vec::new();
let floor = build_floor(&Some(spec), 2, 2, &mut errs);
assert!(errs.is_empty());
let fixed = Glyph {
tile: '#' as u32,
fg: parse_color("#010203"),
bg: parse_color("#040506"),
};
// (0,0) and (1,1) are grass; (1,0) and (0,1) are the fixed glyph.
assert_eq!(floor[1], fixed);
assert_eq!(floor[2], fixed);
assert!((20..=55).contains(&floor[0].bg.r)); // grass ground
assert!((20..=55).contains(&floor[3].bg.r));
}
#[test]
fn grid_dimension_mismatch_is_nonfatal() {
// Declares 1 row for a 2-tall board: missing row is padded with empty floor.
let spec = parse_spec(
"[floor]\ncontent = \"\"\"\ngg\n\"\"\"\n[floor.palette]\n\"g\" = \"grass\"\n",
);
let mut errs = Vec::new();
let floor = build_floor(&Some(spec), 2, 2, &mut errs);
assert_eq!(floor.len(), 4);
assert_eq!(errs.len(), 1); // the row-count mismatch was reported
assert_eq!(floor[2], empty_glyph()); // padded second row
assert_eq!(floor[3], empty_glyph());
}
}
+56 -33
View File
@@ -1,12 +1,11 @@
use crate::action::Action;
use crate::archetype::Archetype;
use crate::board::Board;
use crate::log::LogLine;
use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid};
use crate::world::World;
use std::cell::{Ref, RefMut};
use std::time::Duration;
use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid};
/// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0;
@@ -85,7 +84,10 @@ impl GameState {
pub fn from_world(world: World) -> Self {
let name = world.start.clone();
let host = ScriptHost::new(
world.boards.get(&name).expect("world::load guarantees start board exists"),
world
.boards
.get(&name)
.expect("world::load guarantees start board exists"),
&world.scripts,
);
let mut state = Self {
@@ -177,7 +179,10 @@ impl GameState {
self.scripts.run_tick(secs);
// Expire speech bubbles before resolving new actions so a fresh say()
// this frame isn't immediately culled.
self.speech_bubbles.retain_mut(|b| { b.remaining -= secs; b.remaining > 0.0 });
self.speech_bubbles.retain_mut(|b| {
b.remaining -= secs;
b.remaining > 0.0
});
self.tick_pushers(secs);
self.resolve();
}
@@ -193,18 +198,7 @@ impl GameState {
return;
}
self.pusher_timer += PUSHER_INTERVAL;
let pushers: Vec<(usize, usize)> = {
let board = self.board();
let mut v = Vec::new();
for y in 0..board.height {
for x in 0..board.width {
if matches!(board.get(x, y).1, Archetype::Pusher(_)) {
v.push((x, y));
}
}
}
v
};
let pushers: Vec<(usize, usize)> = self.board().pusher_cells();
let mut board = self.board_mut();
for (x, y) in pushers {
board.advance_pusher(x, y);
@@ -251,9 +245,17 @@ impl GameState {
}
}
Action::Log(line) => logs.push(line),
Action::SetTag { target, tag, present } => {
Action::SetTag {
target,
tag,
present,
} => {
if let Some(obj) = board.objects.get_mut(&target) {
if present { obj.tags.insert(tag); } else { obj.tags.remove(&tag); }
if present {
obj.tags.insert(tag);
} else {
obj.tags.remove(&tag);
}
}
}
// Replace any existing bubble from this object so repeated say() calls
@@ -267,26 +269,38 @@ impl GameState {
Action::Delay(_) => {}
Action::SetColor { fg, bg } => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
if let Some(c) = fg { obj.glyph.fg = c; }
if let Some(c) = bg { obj.glyph.bg = c; }
if let Some(c) = fg {
obj.glyph.fg = c;
}
if let Some(c) = bg {
obj.glyph.bg = c;
}
}
}
// Collected and fired after the board borrow drops, like bumps.
Action::Send { target, fn_name, arg } => {
Action::Send {
target,
fn_name,
arg,
} => {
sends.push((target, fn_name, arg));
}
// Later scrolls overwrite earlier ones from the same tick.
Action::Scroll(lines) => {
new_scroll = Some(Scroll { source: ba.source, lines, choice: None });
new_scroll = Some(Scroll {
source: ba.source,
lines,
choice: None,
});
}
Action::Teleport { x, y } => {
if !board.in_bounds((x, y)) {
logs.push(LogLine::error(format!(
"teleport({x},{y}): out of bounds"
)));
logs.push(LogLine::error(format!("teleport({x},{y}): out of bounds")));
} else {
let (ux, uy) = (x as usize, y as usize);
let source_solid = board.objects.get(&ba.source)
let source_solid = board
.objects
.get(&ba.source)
.map(|o| o.solid)
.unwrap_or(false);
if source_solid && board.solid_at(ux, uy).is_some() {
@@ -305,7 +319,8 @@ impl GameState {
self.log.extend(logs);
for bubble in new_bubbles {
// One bubble per object: replace the existing one if present.
self.speech_bubbles.retain(|b| b.object_id != bubble.object_id);
self.speech_bubbles
.retain(|b| b.object_id != bubble.object_id);
self.speech_bubbles.push(bubble);
}
if let Some(scroll) = new_scroll {
@@ -344,12 +359,16 @@ impl GameState {
/// to play a visual effect during the switch.
pub fn enter_board(&mut self, target_map: &str, target_entry: &str) {
if !self.world.boards.contains_key(target_map) {
self.log.push(LogLine::error(format!("portal target board {target_map:?} not found")));
self.log.push(LogLine::error(format!(
"portal target board {target_map:?} not found"
)));
return;
}
// Find the named arrival portal on the target board (borrow then release).
let arrival = self.world.boards[target_map].borrow()
.portals.iter()
let arrival = self.world.boards[target_map]
.borrow()
.portals
.iter()
.find(|p| p.name == target_entry)
.map(|p| (p.x, p.y));
let (ax, ay) = match arrival {
@@ -366,7 +385,10 @@ impl GameState {
self.active_scroll = None;
// Switch to the new board and place the player at the arrival portal.
self.current_board_name = target_map.to_string();
self.board_mut().player = Player { x: ax as i32, y: ay as i32 };
self.board_mut().player = Player {
x: ax as i32,
y: ay as i32,
};
// Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new(
&self.world.boards[&self.current_board_name],
@@ -405,7 +427,9 @@ impl GameState {
board.player.x = nx as i32;
board.player.y = ny as i32;
// Check for a portal at the new position; clone strings to release the borrow.
portal_target = board.portals.iter()
portal_target = board
.portals
.iter()
.find(|p| p.x == nx && p.y == ny)
.map(|p| (p.target_map.clone(), p.target_entry.clone()));
} else {
@@ -453,4 +477,3 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
}
bumped
}
+16 -2
View File
@@ -1,5 +1,5 @@
use std::hash::{Hash, Hasher};
use color::Rgba8;
use std::hash::{Hash, Hasher};
/// The visual representation of a single board cell.
///
@@ -46,4 +46,18 @@ impl Glyph {
bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue
}
}
}
/// A fully transparent glyph: tile `0` (the see-through sentinel) on black.
///
/// A layer cell holding this glyph contributes nothing to drawing, so a lower
/// layer shows through. It is what an `empty` palette entry resolves to, and
/// what is left behind when a solid is pushed/moved off a cell.
#[rustfmt::skip]
pub const fn transparent() -> Self {
Self {
tile: 0,
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Palette layers: the unit of the map-file format and the board's draw stack.
//!
//! 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".
//!
//! 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`].
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.
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 string and its palette.
#[derive(Deserialize, Serialize)]
pub(crate) struct LayerData {
/// Multi-line grid string; one char per cell, looked up in `palette`.
pub content: String,
/// Char (as a one-character string key) → palette entry.
#[serde(default)]
pub palette: HashMap<String, PaletteEntry>,
}
/// One palette entry, discriminated by [`kind`](PaletteEntry::kind).
///
/// 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`].
#[derive(Deserialize, Serialize, Default, Clone)]
pub(crate) struct PaletteEntry {
/// What this entry is: an archetype name, or `empty`/`floor`/`object`/`portal`/`player`.
pub kind: String,
/// Tile index (int or single-char string). Used by archetype/floor/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<TileIndex>,
/// Foreground `"#RRGGBB"`. Used by archetype/floor/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Background `"#RRGGBB"`. Used by archetype/floor/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>,
/// Object opacity (defaults `true`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opaque: Option<bool>,
/// Object pushability (defaults `false`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pushable: Option<bool>,
/// Rhai script name. Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script_name: Option<String>,
/// Open-ended labels. Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
/// Unique name. For `kind = "object"` (optional) or `kind = "portal"` (required).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Target board key. Required for `kind = "portal"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_map: Option<String>,
/// Target portal name on the destination board. Required for `kind = "portal"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_entry: Option<String>,
}
/// A non-terrain thing a layer places at a grid 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.
pub(crate) enum Placement {
/// A scripted object to spawn at `(x, y)`.
Object(ObjectTemplate, usize, usize),
/// A portal at `(x, y)`.
Portal(PortalTemplate, usize, usize),
/// The player's start cell `(x, y)`.
Player(usize, usize),
}
/// A resolved object definition minus board-assigned fields (`id`, `z`).
#[derive(Clone)]
pub(crate) struct ObjectTemplate {
pub glyph: Glyph,
pub solid: bool,
pub opaque: bool,
pub pushable: bool,
pub script_name: Option<String>,
pub tags: Vec<String>,
pub name: Option<String>,
}
/// A resolved portal definition minus `(x, y, z)`.
#[derive(Clone)]
pub(crate) struct PortalTemplate {
pub name: String,
pub target_map: String,
pub target_entry: String,
}
/// 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).
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.
Portal(PortalTemplate),
/// The player's start cell.
Player,
}
/// 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.
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 {
tile: e.tile.map(TileIndex::into_u32).unwrap_or(default.tile),
fg: e.fg.as_deref().map(parse_color).unwrap_or(default.fg),
bg: e.bg.as_deref().map(parse_color).unwrap_or(default.bg),
};
match e.kind.as_str() {
// Transparent: lower layers show 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),
opaque: e.opaque.unwrap_or(true),
pushable: e.pushable.unwrap_or(false),
script_name: e.script_name.clone(),
tags: e.tags.clone().unwrap_or_default(),
name: e.name.clone(),
}),
"portal" => match (e.name.clone(), e.target_map.clone(), e.target_entry.clone()) {
(Some(name), Some(target_map), Some(target_entry)) => {
Resolved::Portal(PortalTemplate {
name,
target_map,
target_entry,
})
}
_ => {
errors.push(LogLine::error(format!(
"portal palette '{ch}' needs name, target_map and target_entry; skipping"
)));
Resolved::Cell(Glyph::transparent(), Archetype::Empty)
}
},
"player" => Resolved::Player,
// Any other kind must be an archetype name.
other => match Archetype::try_from(other) {
Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a),
Err(msg) => {
errors.push(LogLine::error(format!(
"palette '{ch}': {msg}; using error block"
)));
Resolved::Cell(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)
}
},
}
}
/// Builds one [`Layer`] from its [`LayerData`], 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,
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).
let resolved: HashMap<char, Resolved> = data
.palette
.iter()
.map(|(key, entry)| {
let ch = key.chars().next().unwrap_or(' ');
(ch, resolve_entry(ch, entry, errors))
})
.collect();
// Validate dimensions before walking — the only fatal error.
let rows: Vec<&str> = data.content.lines().collect();
if rows.len() != height {
return Err(format!(
"layer grid has {} rows but the board is {height} tall",
rows.len()
));
}
for (i, line) in rows.iter().enumerate() {
let cols = line.chars().count();
if cols != width {
return Err(format!(
"layer grid row {i} has {cols} characters but the board is {width} wide"
));
}
}
// Walk the grid, filling cells and collecting placements.
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(width * height);
let mut placements: Vec<Placement> = Vec::new();
for (y, line) in rows.iter().enumerate() {
for (x, ch) in line.chars().enumerate() {
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));
}
Some(Resolved::Portal(t)) => {
cells.push((Glyph::transparent(), Archetype::Empty));
placements.push(Placement::Portal(t.clone(), x, y));
}
Some(Resolved::Player) => {
cells.push((Glyph::transparent(), Archetype::Empty));
placements.push(Placement::Player(x, y));
}
None => {
errors.push(LogLine::error(format!(
"unknown grid character '{ch}' at ({x}, {y}); using error block"
)));
cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock));
}
}
}
}
Ok((Layer { cells }, placements))
}
+10 -9
View File
@@ -1,24 +1,25 @@
/// The optional per-cell visual floor layer ([`floor::FloorSpec`], [`floor::build_floor`]).
mod action;
mod archetype;
mod board;
/// Procedural floor generators ([`floor::FloorGenerator`]).
pub mod floor;
/// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc.
pub mod game;
pub mod glyph;
mod layer;
/// Styled log messages ([`log::LogLine`]) for the in-game message feed.
pub mod log;
/// Map file loading and saving (`.toml` format).
pub mod map_file;
/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]).
pub mod world;
mod object_def;
/// Rhai scripting runtime for board objects ([`script::ScriptHost`]).
pub mod script;
pub mod glyph;
mod action;
mod utils;
mod archetype;
mod object_def;
mod board;
/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]).
pub mod world;
pub use board::Board;
pub use utils::Direction;
#[cfg(test)]
mod tests;
mod tests;
+291 -694
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,7 +1,7 @@
use color::Rgba8;
use std::collections::HashSet;
use crate::glyph::Glyph;
use crate::utils::ObjectId;
use color::Rgba8;
use std::collections::HashSet;
/// A scripted object placed on the board, loaded from a map file.
///
@@ -32,6 +32,10 @@ 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,
@@ -78,6 +82,7 @@ impl ObjectDef {
id: 0,
x,
y,
z: 0,
glyph: Self::default_glyph(),
solid: true,
opaque: true,
@@ -87,4 +92,4 @@ impl ObjectDef {
name: None,
}
}
}
}
+352 -162
View File
@@ -53,13 +53,16 @@
//!
//! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()`
use crate::action::{action_to_map, Action, ScrollLine, MOVE_COST};
use crate::game::SAY_DURATION;
use crate::action::{Action, MOVE_COST, ScrollLine, action_to_map};
use crate::board::Board;
use crate::game::SAY_DURATION;
use crate::log::LogLine;
use crate::map_file::{color_to_hex, parse_color};
use crate::utils::{Direction, ObjectId, RegistryValue, ScriptArg};
use rhai::{Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module, NativeCallContext, Scope, AST};
use rhai::{
AST, Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module,
NativeCallContext, Scope,
};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
@@ -206,20 +209,28 @@ impl ScriptHost {
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
let mut failed: HashSet<String> = HashSet::new();
for obj in board.objects.values() {
let Some(name) = obj.script_name.as_ref() else { continue; };
if scripts.contains_key(name) || failed.contains(name) { continue; }
let Some(name) = obj.script_name.as_ref() else {
continue;
};
if scripts.contains_key(name) || failed.contains(name) {
continue;
}
match script_sources.get(name) {
Some(src) => match engine.compile(src) {
Ok(ast) => {
let defines = |n: &str, params: usize| {
ast.iter_functions().any(|f| f.name == n && f.params.len() == params)
ast.iter_functions()
.any(|f| f.name == n && f.params.len() == params)
};
scripts.insert(name.clone(), CompiledScript {
has_init: defines("init", 0),
has_tick: defines("tick", 1),
has_bump: defines("bump", 1),
ast,
});
scripts.insert(
name.clone(),
CompiledScript {
has_init: defines("init", 0),
has_tick: defines("tick", 1),
has_bump: defines("bump", 1),
ast,
},
);
}
Err(err) => {
failed.insert(name.clone());
@@ -240,8 +251,12 @@ impl ScriptHost {
// One runtime per object whose script compiled.
let mut objects = Vec::new();
for (&id, obj) in board.objects.iter() {
let Some(name) = obj.script_name.as_ref() else { continue; };
if !scripts.contains_key(name) { continue; }
let Some(name) = obj.script_name.as_ref() else {
continue;
};
if !scripts.contains_key(name) {
continue;
}
let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new()));
queues.borrow_mut().insert(id, queue.clone());
objects.push(ObjectRuntime {
@@ -253,7 +268,13 @@ impl ScriptHost {
}
drop(board);
Self { engine, scripts, objects, board_queue, errors }
Self {
engine,
scripts,
objects,
board_queue,
errors,
}
}
/// Calls `init()` on every scripted object that defines it and drains each queue.
@@ -273,16 +294,31 @@ impl ScriptHost {
return;
};
{
let Self { engine, scripts, objects, errors, .. } = self;
let Self {
engine,
scripts,
objects,
errors,
..
} = self;
let obj = &mut objects[i];
let Some(compiled) = scripts.get(&obj.script_name) else { return; };
if !compiled.has_bump { return; }
let Some(compiled) = scripts.get(&obj.script_name) else {
return;
};
if !compiled.has_bump {
return;
}
let options = CallFnOptions::default().with_tag(object_id as i64);
if let Err(err) = engine.call_fn_with_options::<()>(
options, &mut obj.scope, &compiled.ast, "bump", (bumper,),
options,
&mut obj.scope,
&compiled.ast,
"bump",
(bumper,),
) {
errors.borrow_mut().push(LogLine::error(format!(
"script '{}' bump error: {err}", obj.script_name
"script '{}' bump error: {err}",
obj.script_name
)));
}
}
@@ -300,13 +336,25 @@ impl ScriptHost {
return;
};
{
let Self { engine, scripts, objects, errors, .. } = self;
let Self {
engine,
scripts,
objects,
errors,
..
} = self;
let obj = &mut objects[i];
let Some(compiled) = scripts.get(&obj.script_name) else { return; };
let Some(compiled) = scripts.get(&obj.script_name) else {
return;
};
let has_1 = compiled.ast.iter_functions()
let has_1 = compiled
.ast
.iter_functions()
.any(|f| f.name == fn_name && f.params.len() == 1);
let has_0 = compiled.ast.iter_functions()
let has_0 = compiled
.ast
.iter_functions()
.any(|f| f.name == fn_name && f.params.is_empty());
let options = CallFnOptions::default().with_tag(obj.object_id as i64);
@@ -318,11 +366,19 @@ impl ScriptHost {
None => Dynamic::UNIT,
};
engine.call_fn_with_options::<()>(
options, &mut obj.scope, &compiled.ast, fn_name, (dyn_arg,),
options,
&mut obj.scope,
&compiled.ast,
fn_name,
(dyn_arg,),
)
} else if has_0 {
engine.call_fn_with_options::<()>(
options, &mut obj.scope, &compiled.ast, fn_name, (),
options,
&mut obj.scope,
&compiled.ast,
fn_name,
(),
)
} else {
return; // Function not defined on this object
@@ -330,7 +386,8 @@ impl ScriptHost {
if let Err(err) = result {
errors.borrow_mut().push(LogLine::error(format!(
"script '{}' send('{}') error: {err}", obj.script_name, fn_name
"script '{}' send('{}') error: {err}",
obj.script_name, fn_name
)));
}
}
@@ -357,7 +414,9 @@ impl ScriptHost {
if is_delay {
let expired = {
let mut q = queue.borrow_mut();
let Some(Action::Delay(r)) = q.front_mut() else { break };
let Some(Action::Delay(r)) = q.front_mut() else {
break;
};
*r -= dt;
dt = 0.0;
*r <= 0.0
@@ -368,8 +427,13 @@ impl ScriptHost {
break;
}
} else {
let Some(action) = queue.borrow_mut().pop_front() else { break };
self.board_queue.borrow_mut().push(BoardAction { source: oid, action });
let Some(action) = queue.borrow_mut().pop_front() else {
break;
};
self.board_queue.borrow_mut().push(BoardAction {
source: oid,
action,
});
}
}
}
@@ -384,17 +448,28 @@ impl ScriptHost {
) {
for i in 0..self.objects.len() {
{
let Self { engine, scripts, objects, errors, .. } = self;
let Self {
engine,
scripts,
objects,
errors,
..
} = self;
let obj = &mut objects[i];
if let Some(compiled) = scripts.get(&obj.script_name)
&& defined(compiled)
{
let options = CallFnOptions::default().with_tag(obj.object_id as i64);
if let Err(err) = engine.call_fn_with_options::<()>(
options, &mut obj.scope, &compiled.ast, hook, args,
options,
&mut obj.scope,
&compiled.ast,
hook,
args,
) {
errors.borrow_mut().push(LogLine::error(format!(
"script '{}' {hook} error: {err}", obj.script_name
"script '{}' {hook} error: {err}",
obj.script_name
)));
}
}
@@ -412,36 +487,46 @@ fn register_me_type(engine: &mut Engine) {
engine.register_get("id", |me: &mut Me| me.object_id as i64);
engine.register_get("name", |me: &mut Me| {
me.board.borrow()
.objects.get(&me.object_id)
me.board
.borrow()
.objects
.get(&me.object_id)
.and_then(|o| o.name.as_deref())
.unwrap_or("")
.to_string()
});
engine.register_get("x", |me: &mut Me| {
me.board.borrow()
.objects.get(&me.object_id)
me.board
.borrow()
.objects
.get(&me.object_id)
.map(|o| o.x as i64)
.unwrap_or(0)
});
engine.register_get("y", |me: &mut Me| {
me.board.borrow()
.objects.get(&me.object_id)
me.board
.borrow()
.objects
.get(&me.object_id)
.map(|o| o.y as i64)
.unwrap_or(0)
});
engine.register_fn("has_tag", |me: &mut Me, tag: ImmutableString| {
me.board.borrow()
.objects.get(&me.object_id)
me.board
.borrow()
.objects
.get(&me.object_id)
.is_some_and(|o| o.tags.contains(tag.as_str()))
});
engine.register_fn("tags", |me: &mut Me| -> rhai::Array {
me.board.borrow()
.objects.get(&me.object_id)
me.board
.borrow()
.objects
.get(&me.object_id)
.map(|o| o.tags.iter().map(|t| Dynamic::from(t.clone())).collect())
.unwrap_or_default()
});
@@ -455,7 +540,11 @@ fn register_me_type(engine: &mut Engine) {
bg: color_to_hex(obj.glyph.bg),
}
} else {
RhaiGlyph { tile: 0, fg: "#000000".to_string(), bg: "#000000".to_string() }
RhaiGlyph {
tile: 0,
fg: "#000000".to_string(),
bg: "#000000".to_string(),
}
}
});
}
@@ -467,7 +556,8 @@ fn register_registry_type(engine: &mut Engine) {
// Registry.get(key) -> Dynamic — returns () if the key is absent.
engine.register_fn("get", |r: &mut Registry, key: ImmutableString| -> Dynamic {
r.board.borrow()
r.board
.borrow()
.registry
.get(key.as_str())
.map(|v| v.clone().into())
@@ -476,25 +566,39 @@ fn register_registry_type(engine: &mut Engine) {
// Registry.set(key, value) — stores a primitive value; () removes the key;
// unsupported types (closures, custom objects) are silently ignored.
engine.register_fn("set", |r: &mut Registry, key: ImmutableString, value: Dynamic| {
match RegistryValue::try_from(value.clone()) {
Ok(rv) => { r.board.borrow_mut().registry.insert(key.to_string(), rv); }
Err(()) if value.is_unit() => { r.board.borrow_mut().registry.remove(key.as_str()); }
Err(()) => {} // unsupported type — silently ignore
}
});
engine.register_fn(
"set",
|r: &mut Registry, key: ImmutableString, value: Dynamic| {
match RegistryValue::try_from(value.clone()) {
Ok(rv) => {
r.board.borrow_mut().registry.insert(key.to_string(), rv);
}
Err(()) if value.is_unit() => {
r.board.borrow_mut().registry.remove(key.as_str());
}
Err(()) => {} // unsupported type — silently ignore
}
},
);
// Registry.get_or(key, default) — returns the stored value when present and the
// same Rhai type as `default`; falls back to `default` otherwise.
engine.register_fn("get_or", |r: &mut Registry, key: ImmutableString, default: Dynamic| -> Dynamic {
if let Some(stored) = r.board.borrow().registry.get(key.as_str()).cloned() {
let candidate: Dynamic = stored.into();
// Only return the stored value if it matches the type of the default.
if candidate.type_id() == default.type_id() { candidate } else { default }
} else {
default
}
});
engine.register_fn(
"get_or",
|r: &mut Registry, key: ImmutableString, default: Dynamic| -> Dynamic {
if let Some(stored) = r.board.borrow().registry.get(key.as_str()).cloned() {
let candidate: Dynamic = stored.into();
// Only return the stored value if it matches the type of the default.
if candidate.type_id() == default.type_id() {
candidate
} else {
default
}
} else {
default
}
},
);
}
// ── Register ObjectInfo type ──────────────────────────────────────────────────
@@ -502,9 +606,9 @@ fn register_registry_type(engine: &mut Engine) {
fn register_object_info_type(engine: &mut Engine) {
engine.register_type_with_name::<ObjectInfo>("ObjectInfo");
engine.register_get("id", |o: &mut ObjectInfo| o.id);
engine.register_get("x", |o: &mut ObjectInfo| o.x);
engine.register_get("y", |o: &mut ObjectInfo| o.y);
engine.register_get("id", |o: &mut ObjectInfo| o.id);
engine.register_get("x", |o: &mut ObjectInfo| o.x);
engine.register_get("y", |o: &mut ObjectInfo| o.y);
engine.register_get("name", |o: &mut ObjectInfo| {
o.name.as_deref().unwrap_or("").to_string()
@@ -520,8 +624,8 @@ fn register_object_info_type(engine: &mut Engine) {
fn register_glyph_type(engine: &mut Engine) {
engine.register_type_with_name::<RhaiGlyph>("Glyph");
engine.register_get("tile", |g: &mut RhaiGlyph| g.tile);
engine.register_get("fg", |g: &mut RhaiGlyph| g.fg.clone());
engine.register_get("bg", |g: &mut RhaiGlyph| g.bg.clone());
engine.register_get("fg", |g: &mut RhaiGlyph| g.fg.clone());
engine.register_get("bg", |g: &mut RhaiGlyph| g.bg.clone());
}
// ── Board read API ────────────────────────────────────────────────────────────
@@ -535,8 +639,8 @@ fn register_read_api(
engine.register_type_with_name::<BoardRef>("Board");
engine.register_get("player_x", |b: &mut BoardRef| b.borrow().player.x as i64);
engine.register_get("player_y", |b: &mut BoardRef| b.borrow().player.y as i64);
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
// blocked(dir) — true if moving in dir would be impossible for the calling object.
let b = board.clone();
@@ -547,25 +651,35 @@ fn register_read_api(
// Board.tagged(tag) -> Array[ObjectInfo]
let b = board.clone();
engine.register_fn("tagged", move |_board: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
let board = b.borrow();
board.objects.iter()
.filter(|(_, o)| o.tags.contains(tag.as_str()))
.filter_map(|(&id, _)| object_info_from(id, &board))
.map(Dynamic::from)
.collect()
});
engine.register_fn(
"tagged",
move |_board: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
let board = b.borrow();
board
.objects
.iter()
.filter(|(_, o)| o.tags.contains(tag.as_str()))
.filter_map(|(&id, _)| object_info_from(id, &board))
.map(Dynamic::from)
.collect()
},
);
// Board.named(name) -> ObjectInfo | ()
let b = board.clone();
engine.register_fn("named", move |_board: &mut BoardRef, name: ImmutableString| -> Dynamic {
let board = b.borrow();
board.objects.iter()
.find(|(_, o)| o.name.as_deref() == Some(name.as_str()))
.and_then(|(&id, _)| object_info_from(id, &board))
.map(Dynamic::from)
.unwrap_or(Dynamic::UNIT)
});
engine.register_fn(
"named",
move |_board: &mut BoardRef, name: ImmutableString| -> Dynamic {
let board = b.borrow();
board
.objects
.iter()
.find(|(_, o)| o.name.as_deref() == Some(name.as_str()))
.and_then(|(&id, _)| object_info_from(id, &board))
.map(Dynamic::from)
.unwrap_or(Dynamic::UNIT)
},
);
// Board.get(id) -> ObjectInfo | () (-1 returns player; unknown id logs error)
let b = board.clone();
@@ -576,13 +690,15 @@ fn register_read_api(
return Dynamic::from(object_info_player(&board));
}
if id <= 0 {
errs.borrow_mut().push(LogLine::error(format!("Board.get: invalid id {id}")));
errs.borrow_mut()
.push(LogLine::error(format!("Board.get: invalid id {id}")));
return Dynamic::UNIT;
}
match object_info_from(id as ObjectId, &board) {
Some(info) => Dynamic::from(info),
None => {
errs.borrow_mut().push(LogLine::error(format!("Board.get: no object with id {id}")));
errs.borrow_mut()
.push(LogLine::error(format!("Board.get: no object with id {id}")));
Dynamic::UNIT
}
}
@@ -597,12 +713,18 @@ fn is_blocked(
dir: Direction,
) -> bool {
let board = board.borrow();
let Some(obj) = board.objects.get(&source) else { return true; };
let Some(obj) = board.objects.get(&source) else {
return true;
};
let (dx, dy): (i32, i32) = dir.into();
let target = (obj.x as i32 + dx, obj.y as i32 + dy);
if !board.in_bounds(target) { return true; }
if !board.in_bounds(target) {
return true;
}
let (tx, ty) = (target.0 as usize, target.1 as usize);
if board.solid_at(tx, ty).is_some() { return true; }
if board.solid_at(tx, ty).is_some() {
return true;
}
board_queue.borrow().iter().any(|ba| {
if let Action::Move(d) = &ba.action
&& let Some(mover) = board.objects.get(&ba.source)
@@ -663,37 +785,57 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
});
let q = queues.clone();
engine.register_fn("log", move |ctx: NativeCallContext, msg: ImmutableString| {
emit(&q, source_of(&ctx), Action::Log(LogLine::raw(msg.to_string())));
});
engine.register_fn(
"log",
move |ctx: NativeCallContext, msg: ImmutableString| {
emit(
&q,
source_of(&ctx),
Action::Log(LogLine::raw(msg.to_string())),
);
},
);
let q = queues.clone();
engine.register_fn("say", move |ctx: NativeCallContext, msg: ImmutableString| {
emit(&q, source_of(&ctx), Action::Say(msg.to_string(), SAY_DURATION));
});
engine.register_fn(
"say",
move |ctx: NativeCallContext, msg: ImmutableString| {
emit(
&q,
source_of(&ctx),
Action::Say(msg.to_string(), SAY_DURATION),
);
},
);
let q = queues.clone();
engine.register_fn("say", move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| {
emit(&q, source_of(&ctx), Action::Say(msg.to_string(), dur));
});
engine.register_fn(
"say",
move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| {
emit(&q, source_of(&ctx), Action::Say(msg.to_string(), dur));
},
);
// scroll(lines): open a full-screen scrollable overlay. Each element of `lines`
// is either a plain string (text) or a 2-element array [choice_key, display_text].
let q = queues.clone();
engine.register_fn("scroll", move |ctx: NativeCallContext, arr: Array| {
let lines = arr.into_iter().filter_map(|item| {
if let Ok(s) = item.clone().into_string() {
Some(ScrollLine::Text(s))
} else {
let pair: Vec<Dynamic> = item.into_array().ok()?;
if pair.len() == 2 {
let choice = pair[0].clone().into_string().ok()?;
let display = pair[1].clone().into_string().ok()?;
Some(ScrollLine::Choice { choice, display })
let lines = arr
.into_iter()
.filter_map(|item| {
if let Ok(s) = item.clone().into_string() {
Some(ScrollLine::Text(s))
} else {
None
let pair: Vec<Dynamic> = item.into_array().ok()?;
if pair.len() == 2 {
let choice = pair[0].clone().into_string().ok()?;
let display = pair[1].clone().into_string().ok()?;
Some(ScrollLine::Choice { choice, display })
} else {
None
}
}
}
}).collect();
})
.collect();
emit(&q, source_of(&ctx), Action::Scroll(lines));
});
@@ -701,41 +843,63 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
engine.register_fn(
"set_tag",
move |ctx: NativeCallContext, target: i64, tag: ImmutableString, present: bool| {
emit(&q, source_of(&ctx), Action::SetTag {
target: target as ObjectId,
tag: tag.to_string(),
present,
});
emit(
&q,
source_of(&ctx),
Action::SetTag {
target: target as ObjectId,
tag: tag.to_string(),
present,
},
);
},
);
// set_fg(fg): change foreground color only.
let q = queues.clone();
engine.register_fn("set_fg", move |ctx: NativeCallContext, fg: ImmutableString| {
emit(&q, source_of(&ctx), Action::SetColor {
fg: Some(parse_color(fg.as_str())),
bg: None,
});
});
engine.register_fn(
"set_fg",
move |ctx: NativeCallContext, fg: ImmutableString| {
emit(
&q,
source_of(&ctx),
Action::SetColor {
fg: Some(parse_color(fg.as_str())),
bg: None,
},
);
},
);
// set_bg(bg): change background color only.
let q = queues.clone();
engine.register_fn("set_bg", move |ctx: NativeCallContext, bg: ImmutableString| {
emit(&q, source_of(&ctx), Action::SetColor {
fg: None,
bg: Some(parse_color(bg.as_str())),
});
});
engine.register_fn(
"set_bg",
move |ctx: NativeCallContext, bg: ImmutableString| {
emit(
&q,
source_of(&ctx),
Action::SetColor {
fg: None,
bg: Some(parse_color(bg.as_str())),
},
);
},
);
// set_color(fg, bg): change both colors.
let q = queues.clone();
engine.register_fn(
"set_color",
move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| {
emit(&q, source_of(&ctx), Action::SetColor {
fg: Some(parse_color(fg.as_str())),
bg: Some(parse_color(bg.as_str())),
});
emit(
&q,
source_of(&ctx),
Action::SetColor {
fg: Some(parse_color(fg.as_str())),
bg: Some(parse_color(bg.as_str())),
},
);
},
);
@@ -744,11 +908,15 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
engine.register_fn(
"send",
move |ctx: NativeCallContext, target: i64, name: ImmutableString| {
emit(&q, source_of(&ctx), Action::Send {
target: target as ObjectId,
fn_name: name.to_string(),
arg: None,
});
emit(
&q,
source_of(&ctx),
Action::Send {
target: target as ObjectId,
fn_name: name.to_string(),
arg: None,
},
);
},
);
@@ -766,11 +934,15 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
} else {
None
};
emit(&q, source_of(&ctx), Action::Send {
target: target as ObjectId,
fn_name: name.to_string(),
arg: script_arg,
});
emit(
&q,
source_of(&ctx),
Action::Send {
target: target as ObjectId,
fn_name: name.to_string(),
arg: script_arg,
},
);
},
);
@@ -779,7 +951,14 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
// destination already has a solid occupant.
let q = queues.clone();
engine.register_fn("teleport", move |ctx: NativeCallContext, x: i64, y: i64| {
emit(&q, source_of(&ctx), Action::Teleport { x: x as i32, y: y as i32 });
emit(
&q,
source_of(&ctx),
Action::Teleport {
x: x as i32,
y: y as i32,
},
);
});
}
@@ -788,7 +967,7 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
fn register_queue_api(engine: &mut Engine) {
engine.register_type_with_name::<ObjQueue>("Queue");
engine.register_fn("length", |q: &mut ObjQueue| q.borrow().len() as i64);
engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear());
engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear());
// peek(): front action as a Rhai map, or () if empty.
engine.register_fn("peek", |q: &mut ObjQueue| -> Dynamic {
@@ -817,25 +996,25 @@ fn register_global_constants(engine: &mut Engine) {
let mut m = Module::new();
m.set_var("North", Direction::North);
m.set_var("South", Direction::South);
m.set_var("East", Direction::East);
m.set_var("West", Direction::West);
m.set_var("East", Direction::East);
m.set_var("West", Direction::West);
// 16 EGA/VGA color constants as "#RRGGBB" strings.
m.set_var("Black", "#000000");
m.set_var("Blue", "#0000AA");
m.set_var("Green", "#00AA00");
m.set_var("Cyan", "#00AAAA");
m.set_var("Red", "#AA0000");
m.set_var("Magenta", "#AA00AA");
m.set_var("Brown", "#AA5500");
m.set_var("LightGray", "#AAAAAA");
m.set_var("DarkGray", "#555555");
m.set_var("BrightBlue", "#5555FF");
m.set_var("BrightGreen", "#55FF55");
m.set_var("BrightCyan", "#55FFFF");
m.set_var("BrightRed", "#FF5555");
m.set_var("Black", "#000000");
m.set_var("Blue", "#0000AA");
m.set_var("Green", "#00AA00");
m.set_var("Cyan", "#00AAAA");
m.set_var("Red", "#AA0000");
m.set_var("Magenta", "#AA00AA");
m.set_var("Brown", "#AA5500");
m.set_var("LightGray", "#AAAAAA");
m.set_var("DarkGray", "#555555");
m.set_var("BrightBlue", "#5555FF");
m.set_var("BrightGreen", "#55FF55");
m.set_var("BrightCyan", "#55FFFF");
m.set_var("BrightRed", "#FF5555");
m.set_var("BrightMagenta", "#FF55FF");
m.set_var("Yellow", "#FFFF55");
m.set_var("White", "#FFFFFF");
m.set_var("Yellow", "#FFFF55");
m.set_var("White", "#FFFFFF");
engine.register_global_module(m.into());
}
@@ -845,8 +1024,19 @@ fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) ->
let mut scope = Scope::new();
scope.push_constant("Board", board.clone());
scope.push_constant("Queue", queue.clone());
scope.push_constant("Me", Me { object_id, board: board.clone() });
scope.push_constant("Registry", Registry { board: board.clone() });
scope.push_constant(
"Me",
Me {
object_id,
board: board.clone(),
},
);
scope.push_constant(
"Registry",
Registry {
board: board.clone(),
},
);
scope
}
+71 -32
View File
@@ -1,13 +1,14 @@
use std::time::Duration;
use super::{log_texts, scripted_object, scripts_from};
use crate::archetype::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at};
use crate::game::GameState;
use super::{scripted_object, log_texts, scripts_from};
use std::time::Duration;
#[test]
fn move_command_relocates_the_source_object() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
game.run_init();
let b = game.board();
// East increments x by one; the object started at (2, 1).
@@ -18,7 +19,8 @@ fn move_command_relocates_the_source_object() {
fn move_into_a_wall_or_edge_is_a_noop() {
// Object at the west edge moving west: out of bounds, ignored.
let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")]));
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")]));
game.run_init();
assert_eq!(game.board().objects[&1].x, 0);
}
@@ -26,7 +28,8 @@ fn move_into_a_wall_or_edge_is_a_noop() {
#[test]
fn set_tile_command_changes_the_source_glyph() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")]));
let mut game =
GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")]));
game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7);
}
@@ -36,19 +39,21 @@ fn object_pushes_crate_on_init() {
// A scripted object moving east into a crate shoves it (step_object path).
let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
crate_at(&mut board, 2, 0);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
game.run_init();
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
assert_eq!(b.get(2, 0).1, Archetype::Empty);
assert_eq!(b.get(3, 0).1, Archetype::Crate);
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
}
#[test]
fn object_push_into_player() {
// A scripted object moving into the player pushes the player when there's room.
let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
game.run_init();
{
let b = game.board();
@@ -59,7 +64,8 @@ fn object_push_into_player() {
// With a wall behind the player, the object is blocked and nothing moves.
let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
wall_at(&mut board, 3, 0);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
game.run_init();
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0));
@@ -71,7 +77,10 @@ fn move_cost_rate_limits_repeated_moves() {
// init queues two moves; only the first resolves immediately, the second must
// wait the full 250 ms cooldown.
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(East); }")]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[("m", "fn init() { move(East); move(East); }")]),
);
game.run_init();
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
@@ -91,19 +100,31 @@ fn inline_delay_paces_subsequent_moves() {
// is held back by that delay even if the first move was blocked by a wall.
let mut board = open_board(3, 3, (0, 0), vec![scripted_object(1, 1, "m")]);
wall_at(&mut board, 2, 1);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(South); }")]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[("m", "fn init() { move(East); move(South); }")]),
);
game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved.
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1));
assert_eq!(
(game.board().objects[&1].x, game.board().objects[&1].y),
(1, 1)
);
// The Delay(250 ms) appended by move(East) is still live, so South is pending.
game.tick(Duration::from_millis(100));
game.tick(Duration::from_millis(100));
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1));
assert_eq!(
(game.board().objects[&1].x, game.board().objects[&1].y),
(1, 1)
);
// Past 250 ms the queued South move resolves.
game.tick(Duration::from_millis(100));
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 2));
assert_eq!(
(game.board().objects[&1].x, game.board().objects[&1].y),
(1, 2)
);
}
#[test]
@@ -111,10 +132,13 @@ fn queue_length_reports_pending_actions() {
// Two zero-cost actions are queued before the length is read, then all three
// (incl. the log) drain in one pump.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "q")]);
let mut game = GameState::with_scripts(board, scripts_from(&[(
"q",
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }",
)]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"q",
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }",
)]),
);
game.run_init();
assert!(log_texts(&game).iter().any(|t| t == "len=2"));
assert_eq!(game.board().objects[&1].glyph.tile, 6); // last set_tile won
@@ -124,7 +148,10 @@ fn queue_length_reports_pending_actions() {
fn queue_clear_drops_pending_actions() {
// clear() empties the output queue mid-script, so the queued moves never run.
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "c")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")]),
);
game.run_init();
game.tick(Duration::from_millis(300));
assert_eq!(game.board().objects[&1].x, 1); // never moved
@@ -135,19 +162,25 @@ fn blocked_reports_solid_and_clear() {
// Solid ahead (a wall): blocked() is true.
let mut board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
wall_at(&mut board, 2, 0);
let mut game = GameState::with_scripts(board, scripts_from(&[(
"b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]),
);
game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 9);
// Open ahead, nothing pending: blocked() is false.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts(board, scripts_from(&[(
"b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]),
);
game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7);
}
@@ -165,10 +198,16 @@ fn blocked_sees_earlier_objects_pending_move() {
scripted_object(3, 1, "checker"),
],
);
let mut game = GameState::with_scripts(board, scripts_from(&[
("mover", "fn tick(dt) { move(East); }"),
("checker", "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }"),
]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("mover", "fn tick(dt) { move(East); }"),
(
"checker",
"fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }",
),
]),
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1));
+15 -6
View File
@@ -1,7 +1,7 @@
use std::time::Duration;
use super::{log_texts, scripted_object, scripts_from};
use crate::board::tests::open_board;
use crate::game::GameState;
use super::{scripted_object, log_texts, scripts_from};
use std::time::Duration;
#[test]
fn collision_priority_resolves_in_array_order_and_bumps() {
@@ -13,10 +13,19 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
(0, 1), // player off the contested row
vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")],
);
let mut game = GameState::with_scripts(board, scripts_from(&[
("e", "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }"),
("w", "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }"),
]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[
(
"e",
"fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }",
),
(
"w",
"fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }",
),
]),
);
game.run_init();
// The bump fires during resolution but its log is emitted into obj0's queue;
// a later tick (past both objects' move cooldown) flushes it to the game log.
+32 -12
View File
@@ -1,11 +1,13 @@
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use std::cell::RefCell;
use crate::archetype::Archetype;
use crate::board::Board;
use crate::game::GameState;
use crate::glyph::Glyph;
use crate::layer::Layer;
use crate::utils::{Direction, Player, PortalDef};
use crate::world::World;
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
/// Builds a 3×3 board with the player at `(px, py)` and the given portals.
fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board {
@@ -13,9 +15,9 @@ fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board {
name: "test".into(),
width: 3,
height: 3,
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); 9],
floor: vec![Archetype::Empty.default_glyph(); 9],
floor_spec: None,
layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); 9],
}],
player: Player { x: px, y: py },
objects: BTreeMap::new(),
next_object_id: 1,
@@ -31,12 +33,30 @@ fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board {
/// - `"b1"`: player at `(0, 0)`, portal `"to_b2"` at `(2, 0)` → `"b2"` / `"from_b1"`
/// - `"b2"`: player at `(0, 0)`, portal `"from_b1"` at `(1, 1)` → `"b1"` / `"to_b2"`
fn two_board_world() -> World {
let b1 = make_board(0, 0, vec![
PortalDef { name: "to_b2".into(), x: 2, y: 0, target_map: "b2".into(), target_entry: "from_b1".into() },
]);
let b2 = make_board(0, 0, vec![
PortalDef { name: "from_b1".into(), x: 1, y: 1, target_map: "b1".into(), target_entry: "to_b2".into() },
]);
let b1 = make_board(
0,
0,
vec![PortalDef {
name: "to_b2".into(),
x: 2,
y: 0,
z: 0,
target_map: "b2".into(),
target_entry: "from_b1".into(),
}],
);
let b2 = make_board(
0,
0,
vec![PortalDef {
name: "from_b1".into(),
x: 1,
y: 1,
z: 0,
target_map: "b1".into(),
target_entry: "to_b2".into(),
}],
);
World {
name: "test".into(),
start: "b1".into(),
+37 -31
View File
@@ -1,53 +1,59 @@
use super::{layer, load_board, map};
use crate::archetype::Archetype;
use crate::board::Board;
use crate::map_file::MapFile;
use super::minimal_toml;
#[test]
fn grid_wrong_row_count_returns_error() {
// height = 3 but only 2 rows in the grid
let toml = minimal_toml(3, 3, &["...", "..."]);
// height = 3 but only 2 rows in the layer grid.
let toml = map(3, 3, &[layer("...\n...", &[(".", "kind = \"empty\"")])]);
let mf: MapFile = toml::from_str(&toml).unwrap();
let result = Board::try_from(mf);
assert!(result.is_err());
let msg = result.err().unwrap();
assert!(msg.contains("2 rows"), "expected row count in error, got: {msg}");
assert!(msg.contains("height = 3"), "expected declared height in error, got: {msg}");
assert!(
msg.contains("2 rows"),
"expected row count in error, got: {msg}"
);
assert!(
msg.contains("3 tall"),
"expected declared height in error, got: {msg}"
);
}
#[test]
fn grid_wrong_row_width_returns_error() {
// width = 4 but second row is only 3 characters
let toml = minimal_toml(4, 2, &["....", "..."]);
// width = 4 but second row is only 3 characters.
let toml = map(4, 2, &[layer("....\n...", &[(".", "kind = \"empty\"")])]);
let mf: MapFile = toml::from_str(&toml).unwrap();
let result = Board::try_from(mf);
assert!(result.is_err());
let msg = result.err().unwrap();
assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}");
assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}");
assert!(
msg.contains("3 characters"),
"expected col count in error, got: {msg}"
);
assert!(
msg.contains("4 wide"),
"expected declared width in error, got: {msg}"
);
}
#[test]
fn object_archetype_in_palette_produces_error_block() {
// "object" is no longer a valid palette archetype; it should produce ErrorBlock.
// Player is placed away from the O cell so it isn't cleared.
let toml = r##"
[map]
name = "Test"
width = 2
height = 1
player_start = [1, 0]
[palette]
"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" }
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
[grid]
content = """
O.
"""
"##;
let mf: MapFile = toml::from_str(toml).unwrap();
let board = Board::try_from(mf).unwrap();
assert_eq!(*board.get(0, 0), (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock));
fn unknown_kind_produces_error_block() {
// A palette `kind` that is neither a meta-kind nor a known archetype name
// becomes a visible ErrorBlock. Player placed away from the X cell.
let toml = map(
2,
1,
&[layer(
"X@",
&[("X", "kind = \"frobnicate\""), ("@", "kind = \"player\"")],
)],
);
let board = load_board(&toml);
assert_eq!(
*board.get(0, 0, 0),
(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)
);
}
+44 -70
View File
@@ -12,79 +12,53 @@ fn load_board(toml: &str) -> Board {
Board::try_from(mf).expect("convert to board")
}
/// A 3×1 map (`empty` palette `.`) whose grid is `grid`, plus the given
/// `[[objects]]` TOML appended. Keeps placement tests compact.
fn map_3x1(grid: &str, objects: &str) -> String {
format!(
r##"
[map]
name = "Test"
width = 3
height = 1
player_start = [2, 0]
[palette]
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
[grid]
content = """
{grid}
"""
{objects}
"##
)
/// Builds one `[[layers]]` 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 {
let pal = palette
.iter()
.map(|(k, body)| format!("\"{k}\" = {{ {body} }}"))
.collect::<Vec<_>>()
.join(", ");
format!("\n[[layers]]\ncontent = \"\"\"\n{content}\n\"\"\"\npalette = {{ {pal} }}\n")
}
/// A standard `[[objects]]` block placed by palette char `ch`.
fn obj_palette(ch: &str, extra: &str) -> String {
format!(
"[[objects]]\npalette = \"{ch}\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n{extra}"
)
/// 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
}
/// A 1-row map of the given `width` with a raw `player_start` TOML value
/// (`start`, e.g. `[1, 0]` or `"@"`), grid, and objects block.
fn player_map(width: usize, start: &str, grid: &str, objects: &str) -> String {
format!(
r##"
[map]
name = "Test"
width = {width}
height = 1
player_start = {start}
[palette]
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
[grid]
content = """
{grid}
"""
{objects}
"##
)
}
/// Minimal valid TOML with a configurable grid, used as a base for dimension tests.
fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String {
let content = grid_rows.join("\n");
format!(
r##"
[map]
name = "Test"
width = {width}
height = {height}
player_start = [0, 0]
[palette]
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
[grid]
content = """
{content}
"""
"##
/// 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 {
let body = if extra.is_empty() {
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"".to_string()
} else {
format!("kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\", {extra}")
};
map(
3,
1,
&[layer(
grid,
&[
(" ", "kind = \"empty\""),
(".", "kind = \"empty\""),
(
"#",
"kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"",
),
("@", "kind = \"player\""),
(ch, &body),
],
)],
)
}
@@ -1,18 +1,40 @@
use super::{layer, load_board, map, map_3x1_object};
use crate::archetype::Archetype;
use super::{load_board, map_3x1, obj_palette};
/// Palette shorthands.
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
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 {
let base = "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"";
if extra.is_empty() {
base.to_string()
} else {
format!("{base}, {extra}")
}
}
#[test]
fn duplicate_name_clears_second_but_keeps_both_objects() {
// Two objects share the name "gate": first keeps it, second is cleared.
// Both objects still appear on the board; the map is flagged invalid.
let objs = format!(
"{}\n{}",
"[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\nname = \"gate\"\n",
"[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\nname = \"gate\"\nsolid = false\n",
);
let board = load_board(&map_3x1("...", &objs));
// Two object entries share the name "gate": first keeps it, second is cleared.
let board = load_board(&map(
3,
1,
&[layer(
"GH@",
&[
("G", &obj("name = \"gate\"")),
("H", &obj("name = \"gate\", solid = false")),
PLAYER,
],
)],
));
assert_eq!(board.objects.len(), 2, "both objects survive");
// First object (id 1) keeps the name; second (id 2) is cleared.
assert_eq!(board.objects[&1].name.as_deref(), Some("gate"));
assert_eq!(board.objects[&2].name, None);
assert!(!board.is_valid(), "duplicate name is a nonfatal load error");
@@ -20,80 +42,76 @@ fn duplicate_name_clears_second_but_keeps_both_objects() {
#[test]
fn palette_placement_puts_object_on_empty_floor() {
// `G` appears once, isn't a palette key → object lands there, cell is Empty.
let board = load_board(&map_3x1("G..", &obj_palette("G", "")));
// `G` lands an object; its cell stays Empty (transparent).
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).1, Archetype::Empty);
}
#[test]
fn palette_char_absent_from_grid_drops_object() {
let board = load_board(&map_3x1("...", &obj_palette("G", "")));
assert!(board.objects.is_empty());
assert!(!board.is_valid());
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
}
#[test]
fn palette_char_appearing_twice_spawns_two_objects() {
// `G` appears at (0,0) and (1,0); player is at (2,0) so neither G conflicts.
// Two independent ObjectDefs are spawned.
let board = load_board(&map_3x1("GG.", &obj_palette("G", "")));
// `G` appears at (0,0) and (1,0); two independent ObjectDefs are spawned.
let board = load_board(&map_3x1_object("GG@", "G", "solid = false"));
assert_eq!(board.objects.len(), 2);
// IDs are assigned in reading order (left-to-right).
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
assert_eq!((board.objects[&2].x, board.objects[&2].y), (1, 0));
// Both cells load as Empty in the grid.
assert_eq!(board.get(0, 0).1, Archetype::Empty);
assert_eq!(board.get(1, 0).1, Archetype::Empty);
assert!(board.is_valid(), "multiple occurrences are not an error");
}
#[test]
fn palette_char_multi_occurrence_only_first_keeps_name() {
// Two occurrences of a named entry (solid = false to avoid player conflict at (2,0)).
// First instance gets the name; second is anonymous.
let entry = obj_palette("G", "solid = false\nname = \"guard\"\n");
let board = load_board(&map_3x1("GGG", &entry));
// Three occurrences of a named entry: the first keeps the name, the rest don't.
let board = load_board(&map(
4,
1,
&[layer(
"GGG@",
&[("G", &obj("solid = false, name = \"guard\"")), PLAYER],
)],
));
assert_eq!(board.objects.len(), 3);
assert_eq!(board.objects[&1].name.as_deref(), Some("guard"));
assert_eq!(board.objects[&2].name, None);
assert_eq!(board.objects[&3].name, None);
}
#[test]
fn palette_char_reused_by_two_objects_keeps_first() {
// Two objects claim `G`: the first keeps it, the later one is dropped.
let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", ""));
let board = load_board(&map_3x1("G..", &two));
assert_eq!(board.objects.len(), 1);
assert!(!board.is_valid());
}
#[test]
fn palette_char_colliding_with_palette_key_drops_object() {
// `#` is a terrain palette key, so it can't be an object placeholder.
let board = load_board(&map_3x1("#..", &obj_palette("#", "")));
assert!(board.objects.is_empty());
}
#[test]
fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() {
// Solid object at the wall cell (0,0): dropped for conflicting with the wall.
let solid = "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let board = load_board(&map_3x1("#..", solid));
assert!(board.objects.is_empty());
// 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 = format!("{solid}solid = false\n");
let board = load_board(&map_3x1("#..", &nonsolid));
assert_eq!(board.objects.len(), 1);
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() {
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let board = load_board(&map_3x1("...", &format!("{obj}{obj}")));
// 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(""))]),
],
));
assert_eq!(board.objects.len(), 1);
assert!(!board.is_valid());
}
@@ -1,70 +1,81 @@
use super::{layer, load_board, map};
use crate::archetype::Archetype;
use super::{load_board, player_map};
#[test]
fn player_coords_place_the_player() {
let b = load_board(&player_map(3, "[1, 0]", "...", ""));
assert_eq!((b.player.x, b.player.y), (1, 0));
assert!(b.is_valid());
}
/// Palette shorthands shared by these tests.
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
const WALL: (&str, &str) = (
"#",
"kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"",
);
#[test]
fn player_char_places_on_empty_floor() {
let b = load_board(&player_map(3, "\"@\"", ".@.", ""));
let b = load_board(&map(3, 1, &[layer(".@.", &[EMPTY, PLAYER])]));
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
assert!(b.is_valid());
}
#[test]
fn player_wins_solid_terrain_silently() {
// Player coords land on a wall: the wall is cleared, no error reported.
let b = load_board(&player_map(3, "[0, 0]", "#..", ""));
// 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).1, Archetype::Empty);
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 is dropped, silently (player wins).
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let b = load_board(&player_map(3, "[1, 0]", "...", obj));
// 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!(b.is_valid());
}
#[test]
fn player_char_is_reserved_from_objects() {
// An object trying to use '@' is dropped; the player is still placed.
let obj = "[[objects]]\npalette = \"@\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let b = load_board(&player_map(3, "\"@\"", "@..", obj));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(b.objects.is_empty());
assert!(!b.is_valid());
}
#[test]
fn player_char_appearing_twice_uses_first() {
let b = load_board(&player_map(3, "\"@\"", "@.@", ""));
let b = load_board(&map(3, 1, &[layer("@.@", &[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(&player_map(3, "\"@\"", "...", ""));
let b = load_board(&map(3, 1, &[layer("...", &[EMPTY])]));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(!b.is_valid());
}
#[test]
fn player_fallback_to_origin_clears_solid_terrain() {
// The '@' char is absent, so the player falls back to (0, 0) — which holds a
// wall. The player wins its cell: the wall is cleared so one-solid-per-cell
// holds. The fallback is still reported.
let b = load_board(&player_map(3, "\"@\"", "#..", ""));
// 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])]));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 0, 0).1, Archetype::Empty);
assert!(!b.is_valid());
}
@@ -1,47 +1,56 @@
use super::{load_board, map_3x1};
use super::{layer, load_board, map};
fn portal_toml(portals: &str) -> String {
format!(
"{}{}",
map_3x1("...", ""),
portals
)
}
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
#[test]
fn portal_duplicate_name_drops_second() {
let toml = portal_toml(
"[[portals]]\nname = \"a\"\nx = 0\ny = 0\ntarget_map = \"x\"\ntarget_entry = \"b\"\n\
[[portals]]\nname = \"a\"\nx = 1\ny = 0\ntarget_map = \"x\"\ntarget_entry = \"c\"\n",
// Two portal cells share the name "a": the second is dropped.
let board = load_board(&map(
3,
1,
&[layer(
"12@",
&[
(
"1",
"kind = \"portal\", name = \"a\", target_map = \"x\", target_entry = \"b\"",
),
(
"2",
"kind = \"portal\", name = \"a\", target_map = \"x\", target_entry = \"c\"",
),
PLAYER,
],
)],
));
assert_eq!(
board.portals.len(),
1,
"second portal with duplicate name should be dropped"
);
let board = load_board(&toml);
assert_eq!(board.portals.len(), 1, "second portal with duplicate name should be dropped");
assert_eq!(board.portals[0].x, 0);
assert!(!board.is_valid(), "duplicate name should be a load error");
}
#[test]
fn portal_palette_char_places_portal_at_grid_position() {
let toml = format!(
"{}{}",
map_3x1("1..", ""),
"[[portals]]\nname = \"p\"\npalette = \"1\"\ntarget_map = \"x\"\ntarget_entry = \"e\"\n"
);
let board = load_board(&toml);
let board = load_board(&map(
3,
1,
&[layer(
"1.@",
&[
(
"1",
"kind = \"portal\", name = \"p\", target_map = \"x\", target_entry = \"e\"",
),
EMPTY,
PLAYER,
],
)],
));
assert_eq!(board.portals.len(), 1);
assert_eq!(board.portals[0].x, 0);
assert_eq!(board.portals[0].y, 0);
assert_eq!((board.portals[0].x, board.portals[0].y), (0, 0));
assert_eq!(board.portals[0].name, "p");
}
#[test]
fn portal_palette_char_colliding_with_object_is_dropped() {
let toml = format!(
"{}{}{}",
map_3x1("1..", ""),
"[[objects]]\npalette = \"1\"\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\n",
"[[portals]]\nname = \"p\"\npalette = \"1\"\ntarget_map = \"x\"\ntarget_entry = \"e\"\n"
);
let board = load_board(&toml);
assert_eq!(board.portals.len(), 0, "portal whose palette char is already an object char should be dropped");
}
+103 -159
View File
@@ -1,203 +1,147 @@
use color::Rgba8;
use crate::board::Board;
use super::{layer, load_board, map};
use crate::glyph::Glyph;
use crate::map_file::{MapFile, parse_color};
use super::{load_board, map_3x1, obj_palette};
use color::Rgba8;
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
/// An object palette entry body with the given `extra` fields appended.
fn obj(extra: &str) -> String {
let base = "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"";
if extra.is_empty() {
base.to_string()
} else {
format!("{base}, {extra}")
}
}
/// Round-trips a board through save→load and returns the reloaded board.
fn round_trip(toml: &str) -> crate::board::Board {
let board = load_board(toml);
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
load_board(&toml_out)
}
#[test]
fn object_glyph_round_trips_through_toml() {
// Objects must survive a save→load cycle with their glyph intact.
let toml = r##"
[map]
name = "Test"
width = 3
height = 3
player_start = [0, 0]
[palette]
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
[grid]
content = """
...
...
...
"""
[[objects]]
x = 1
y = 1
tile = 64
fg = "#00FFFF"
bg = "#000000"
"##;
let mf: MapFile = toml::from_str(toml).unwrap();
let board = Board::try_from(mf).unwrap();
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
let board = load_board(&toml);
assert_eq!(board.objects.len(), 1);
let obj = &board.objects[&1];
assert_eq!(obj.x, 1);
assert_eq!(obj.y, 1);
assert_eq!(obj.glyph.tile, 64);
assert_eq!(obj.glyph.fg, Rgba8 { r: 0x00, g: 0xFF, b: 0xFF, a: 255 });
assert_eq!(obj.glyph.bg, Rgba8 { r: 0, g: 0, b: 0, a: 255 });
let obj0 = &board.objects[&1];
assert_eq!((obj0.x, obj0.y), (1, 0));
assert_eq!(obj0.glyph.tile, 64);
assert_eq!(
obj0.glyph.fg,
Rgba8 {
r: 0x00,
g: 0xFF,
b: 0xFF,
a: 255
}
);
// Save back to TOML and reload; glyph must survive.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
let mf2: MapFile = toml::from_str(&toml_out).unwrap();
let board2 = Board::try_from(mf2).unwrap();
let board2 = round_trip(&toml);
let obj2 = &board2.objects[&1];
assert_eq!(obj2.glyph.tile, obj.glyph.tile);
assert_eq!(obj2.glyph.fg, obj.glyph.fg);
assert_eq!(obj2.glyph.bg, obj.glyph.bg);
assert_eq!(obj2.glyph.tile, obj0.glyph.tile);
assert_eq!(obj2.glyph.fg, obj0.glyph.fg);
assert_eq!(obj2.glyph.bg, obj0.glyph.bg);
}
#[test]
fn object_tags_round_trip_through_toml() {
// Tags declared in the map file must survive a save→load cycle, sorted.
let toml = r##"
[map]
name = "Test"
width = 3
height = 3
player_start = [0, 0]
let toml = map(
3,
1,
&[layer(
"@O.",
&[PLAYER, ("O", &obj("tags = [\"enemy\", \"boss\"]")), EMPTY],
)],
);
let board = load_board(&toml);
let obj0 = &board.objects[&1];
assert!(obj0.tags.contains("enemy") && obj0.tags.contains("boss"));
assert_eq!(obj0.tags.len(), 2);
[palette]
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
[grid]
content = """
...
...
...
"""
[[objects]]
x = 1
y = 1
tile = 64
fg = "#ffffff"
bg = "#000000"
tags = ["enemy", "boss"]
"##;
let mf: MapFile = toml::from_str(toml).unwrap();
let board = Board::try_from(mf).unwrap();
let obj = &board.objects[&1];
assert!(obj.tags.contains("enemy"));
assert!(obj.tags.contains("boss"));
assert_eq!(obj.tags.len(), 2);
// Save and reload; tags must survive, and be sorted alphabetically.
// Saved tags must be sorted alphabetically (boss before enemy).
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
// Both tags must appear; "boss" must come before "enemy" (sorted).
let boss_pos = toml_out.find("\"boss\"").expect("boss in TOML");
let enemy_pos = toml_out.find("\"enemy\"").expect("enemy in TOML");
assert!(boss_pos < enemy_pos, "tags must be sorted: boss before enemy");
let mf2: MapFile = toml::from_str(&toml_out).unwrap();
let board2 = Board::try_from(mf2).unwrap();
let obj2 = &board2.objects[&1];
assert_eq!(obj2.tags, obj.tags);
let boss = toml_out.find("\"boss\"").expect("boss in TOML");
let enemy = toml_out.find("\"enemy\"").expect("enemy in TOML");
assert!(boss < enemy, "tags must be sorted: boss before enemy");
let board2 = load_board(&toml_out);
assert_eq!(board2.objects[&1].tags, obj0.tags);
}
#[test]
fn object_empty_tags_omitted_from_toml() {
// An object with no tags must not emit a `tags` key in TOML.
let toml = r##"
[map]
name = "Test"
width = 1
height = 1
player_start = [0, 0]
[palette]
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
[grid]
content = """
.
"""
[[objects]]
x = 0
y = 0
tile = 64
fg = "#ffffff"
bg = "#000000"
"##;
let board = load_board(toml);
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
let board = load_board(&toml);
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
assert!(!toml_out.contains("tags"), "empty tags must not appear in TOML output");
assert!(
!toml_out.contains("tags"),
"empty tags must not appear in TOML output"
);
}
#[test]
fn object_name_round_trips_through_toml() {
// A named object must survive a save→load cycle with its name intact.
let board = load_board(&map_3x1("G..", &obj_palette("G", "name = \"beacon\"\n")));
let toml = map(
3,
1,
&[layer(
"@O.",
&[PLAYER, ("O", &obj("name = \"beacon\"")), EMPTY],
)],
);
let board = load_board(&toml);
assert_eq!(board.objects[&1].name.as_deref(), Some("beacon"));
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
assert!(toml_out.contains("\"beacon\""), "name must appear in saved TOML");
assert!(
toml_out.contains("\"beacon\""),
"name must appear in saved TOML"
);
let board2 = load_board(&toml_out);
assert_eq!(board2.objects[&1].name.as_deref(), Some("beacon"));
}
#[test]
fn unnamed_object_omits_name_from_toml() {
// An unnamed object must not emit a `name` key in TOML; check via the parsed
// value so the map header's `name = "Test"` doesn't produce a false positive.
let board = load_board(&map_3x1("G..", &obj_palette("G", "")));
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
let val: toml::Value = toml_out.parse().unwrap();
let objects = val["objects"].as_array().unwrap();
assert!(
!objects[0].as_table().unwrap().contains_key("name"),
"unnamed object must not emit name key"
fn unnamed_object_name_stays_none_through_toml() {
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
let board2 = round_trip(&toml);
assert_eq!(
board2.objects[&1].name, None,
"unnamed object must round-trip as None"
);
}
#[test]
fn floor_spec_round_trips_through_toml() {
// A 2×2 board with a grid floor (a generator and a fixed glyph) must survive
// save→load with its `[floor]` declaration intact, and the reloaded board's
// computed floor must place the fixed glyph back at its declared cell.
let toml = r##"
[map]
name = "Test"
width = 2
height = 2
player_start = [0, 0]
[palette]
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
[grid]
content = """
..
..
"""
[floor]
content = """
g.
.g
"""
[floor.palette]
"g" = "grass"
"." = { tile = "#", fg = "#010203", bg = "#040506" }
"##;
let board = load_board(toml);
assert!(board.floor_spec.is_some());
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,
],
)],
);
let fixed = Glyph {
tile: '#' as u32,
fg: parse_color("#010203"),
bg: parse_color("#040506"),
};
// (1,0) is the fixed `.` floor glyph (the cell archetype is Empty).
let board = load_board(&toml);
assert_eq!(board.glyph_at(1, 0), fixed);
// Save back to TOML and reload; the floor declaration must be preserved.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
let board2 = load_board(&toml_out);
assert!(board2.floor_spec.is_some());
let board2 = round_trip(&toml);
assert_eq!(board2.glyph_at(1, 0), fixed);
}
+15 -7
View File
@@ -5,18 +5,23 @@ mod map_file;
mod movement;
mod scripting;
use std::collections::{BTreeMap, HashMap};
use crate::archetype::Archetype;
use crate::board::Board;
use crate::game::GameState;
use crate::glyph::Glyph;
use crate::layer::Layer;
use crate::object_def::ObjectDef;
use crate::utils::Player;
use crate::game::GameState;
use std::collections::{BTreeMap, HashMap};
/// Builds a 1×1 board with a single object that optionally references a script,
/// and returns both the board and the `(name, source)` entries as a script map.
///
/// Pass the returned scripts to [`GameState::with_scripts`] so they are compiled.
fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> (Board, HashMap<String, String>) {
fn board_with_object(
object_script: Option<&str>,
scripts: &[(&str, &str)],
) -> (Board, HashMap<String, String>) {
let mut object = ObjectDef::new(0, 0);
object.id = 1;
object.script_name = object_script.map(str::to_string);
@@ -24,9 +29,9 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> (
name: "test".into(),
width: 1,
height: 1,
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)],
floor: vec![Archetype::Empty.default_glyph()],
floor_spec: None,
layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty)],
}],
player: Player { x: 0, y: 0 },
objects: BTreeMap::from([(1, object)]),
next_object_id: 2,
@@ -41,7 +46,10 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> (
/// Converts a `(name, source)` slice into a `HashMap` suitable for
/// [`GameState::with_scripts`].
fn scripts_from(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
/// Returns an `ObjectDef` at `(x, y)` bound to the named script.
+36 -26
View File
@@ -1,29 +1,39 @@
use color::Rgba8;
use crate::archetype::Archetype;
use crate::board::tests::{crate_at, open_board, stamp, wall_at};
use crate::board::tests::{add_floor, crate_at, open_board, stamp, wall_at};
use crate::game::GameState;
use crate::glyph::Glyph;
use crate::object_def::ObjectDef;
use crate::utils::Direction;
use color::Rgba8;
#[test]
fn pushing_a_crate_reveals_the_floor_underneath() {
// The cell a crate is pushed off of becomes Empty and glyph_at shows floor.
// After the push the player is at (1,0); check the cell the player vacated
// (0,0) — it is Empty and must reveal the floor glyph, not black-on-black.
// The cell a crate is pushed off of becomes transparent and glyph_at shows the
// floor on the layer beneath. After the push the player is at (1,0); check the
// cell the player vacated (0,0) — it must reveal the floor glyph, not black.
let mut board = open_board(4, 1, (0, 0), vec![]);
let floor_glyph = Glyph {
tile: ',' as u32,
fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 },
bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 },
fg: Rgba8 {
r: 40,
g: 60,
b: 40,
a: 255,
},
bg: Rgba8 {
r: 5,
g: 10,
b: 5,
a: 255,
},
};
board.floor = vec![floor_glyph; 4];
add_floor(&mut board, floor_glyph); // floor below; terrain becomes layer 1
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!(b.get(2, 0).1, Archetype::Crate);
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(1, 2, 0).1, Archetype::Crate);
assert_eq!(b.get(1, 1, 0).1, Archetype::Empty);
assert_eq!(b.glyph_at(0, 0), floor_glyph);
}
@@ -35,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(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::Crate);
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate);
}
#[test]
@@ -48,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(1, 0).1, Archetype::Crate);
assert_eq!(b.get(2, 0).1, Archetype::Wall);
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate);
assert_eq!(b.get(0, 2, 0).1, Archetype::Wall);
}
#[test]
@@ -61,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(1, 0).1, Archetype::Crate);
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate);
}
#[test]
@@ -73,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(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::Crate);
assert_eq!(b.get(3, 0).1, Archetype::Crate);
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);
}
#[test]
@@ -88,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(1, 0).1, Archetype::Crate);
assert_eq!(b.get(2, 0).1, Archetype::Crate);
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate);
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate);
}
#[test]
@@ -114,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(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::HCrate);
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 2, 0).1, Archetype::HCrate);
drop(b);
// Pushing north into an HCrate: blocked, nothing moves.
@@ -125,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, 2).1, Archetype::HCrate);
assert_eq!(b.get(0, 0, 2).1, Archetype::HCrate);
}
#[test]
@@ -137,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, 2).1, Archetype::Empty);
assert_eq!(b.get(0, 1).1, Archetype::VCrate);
assert_eq!(b.get(0, 0, 2).1, Archetype::Empty);
assert_eq!(b.get(0, 0, 1).1, Archetype::VCrate);
drop(b);
// Pushing east into a VCrate: blocked, nothing moves.
@@ -148,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(1, 0).1, Archetype::VCrate);
assert_eq!(b.get(0, 1, 0).1, Archetype::VCrate);
}
+88 -49
View File
@@ -1,8 +1,8 @@
use std::time::Duration;
use super::{board_with_object, log_texts, scripted_object, scripts_from};
use crate::board::tests::open_board;
use crate::game::{GameState, ScrollLine};
use crate::utils::Direction;
use super::{board_with_object, scripted_object, log_texts, scripts_from};
use std::time::Duration;
#[test]
fn init_runs_only_on_run_init_not_at_construction() {
@@ -44,10 +44,7 @@ fn missing_hooks_and_no_script_are_noops() {
assert!(game.log.is_empty());
// Script defines neither init nor tick: also a no-op.
let (board, scripts) = board_with_object(
Some("e"),
&[("e", "fn other() { log(\"x\"); }")],
);
let (board, scripts) = board_with_object(Some("e"), &[("e", "fn other() { log(\"x\"); }")]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game.tick(Duration::from_millis(33));
@@ -69,13 +66,11 @@ fn compile_and_unknown_script_errors_are_logged() {
#[test]
fn script_reads_board_through_view() {
let board = open_board(
5,
3,
(3, 1),
vec![scripted_object(2, 1, "r")],
let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")]),
);
let mut game = GameState::with_scripts(board, scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")]));
game.run_init();
assert_eq!(log_texts(&game), vec!["3"]);
}
@@ -90,10 +85,13 @@ fn commands_are_routed_to_their_own_source_object() {
(0, 0),
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
);
let mut game = GameState::with_scripts(board, scripts_from(&[
("e", "fn init() { move(East); }"),
("w", "fn init() { move(West); }"),
]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("e", "fn init() { move(East); }"),
("w", "fn init() { move(West); }"),
]),
);
game.run_init();
let b = game.board();
assert_eq!(b.objects[&1].x, 1); // moved east from 0
@@ -112,7 +110,9 @@ fn start_map_greeter_runs_init() {
let mut game = GameState::from_world(world);
game.run_init();
assert!(
log_texts(&game).iter().any(|t| t.contains("hello from object")),
log_texts(&game)
.iter()
.any(|t| t.contains("hello from object")),
"greeter init should log a greeting"
);
assert!(
@@ -139,7 +139,12 @@ fn set_tag_adds_and_removes_via_my_id() {
&[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)],
);
// Seed the tag before construction.
board2.objects.get_mut(&1).unwrap().tags.insert("active".to_string());
board2
.objects
.get_mut(&1)
.unwrap()
.tags
.insert("active".to_string());
let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init();
assert!(!game2.board().objects[&1].tags.contains("active"));
@@ -172,37 +177,39 @@ fn objects_with_tag_returns_matching_ids() {
// obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not.
obj2.tags.insert("enemy".to_string());
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
let mut game = GameState::with_scripts(board, scripts_from(&[
("q", r#"fn init() {
let mut game = GameState::with_scripts(
board,
scripts_from(&[
(
"q",
r#"fn init() {
let infos = Board.tagged("enemy");
log(infos.len().to_string());
log(infos[0].id.to_string());
}"#),
("none", ""),
]));
}"#,
),
("none", ""),
]),
);
game.run_init();
let texts = log_texts(&game);
assert_eq!(texts[0], "1"); // exactly one match
assert_eq!(texts[1], "2"); // obj2 is id 2
assert_eq!(texts[0], "1"); // exactly one match
assert_eq!(texts[1], "2"); // obj2 is id 2
}
#[test]
fn my_name_returns_name_or_empty_string() {
// An object with a name set on its ObjectDef should see it via my_name().
let (mut board, scripts) = board_with_object(
Some("n"),
&[("n", r#"fn init() { log(Me.name); }"#)],
);
let (mut board, scripts) =
board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]);
board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string());
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert_eq!(log_texts(&game), vec!["beacon"]);
// An unnamed object should get an empty string.
let (board2, scripts2) = board_with_object(
Some("n"),
&[("n", r#"fn init() { log(Me.name); }"#)],
);
let (board2, scripts2) =
board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]);
let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init();
assert_eq!(log_texts(&game2), vec![""]);
@@ -216,18 +223,24 @@ fn object_id_for_name_finds_by_name() {
let mut obj2 = scripted_object(1, 0, "none");
obj2.name = Some("target".to_string());
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
let mut game = GameState::with_scripts(board, scripts_from(&[
("q", r#"fn init() {
let mut game = GameState::with_scripts(
board,
scripts_from(&[
(
"q",
r#"fn init() {
log(Board.named("target").id.to_string());
let miss = Board.named("missing");
log(if miss == () { "not_found" } else { miss.id.to_string() });
}"#),
("none", ""),
]));
}"#,
),
("none", ""),
]),
);
game.run_init();
let texts = log_texts(&game);
assert_eq!(texts[0], "2"); // obj2 is id 2
assert_eq!(texts[1], "not_found"); // Board.named returns () when no match
assert_eq!(texts[0], "2"); // obj2 is id 2
assert_eq!(texts[1], "not_found"); // Board.named returns () when no match
}
// Ensure try_move from the player side also triggers scripted bump
@@ -236,7 +249,10 @@ fn player_bump_fires_with_negative_one() {
// The player walks into a solid scripted object; the object is bumped with -1
// and the player does not move onto it.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")]),
);
game.run_init();
game.try_move(Direction::East);
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
@@ -251,22 +267,36 @@ fn scroll_opens_on_player_bump() {
// player bumps it and a tick resolves the queued action, active_scroll is set
// with the correct ScrollLine variants.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#)]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"s",
r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
)]),
);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
let scroll = game.active_scroll.as_ref().expect("scroll should be active after bump");
let scroll = game
.active_scroll
.as_ref()
.expect("scroll should be active after bump");
assert_eq!(scroll.lines.len(), 2);
assert!(matches!(&scroll.lines[0], ScrollLine::Text(t) if t == "Hello world"));
assert!(matches!(&scroll.lines[1], ScrollLine::Choice { choice, display }
if choice == "eat" && display == "Eat it"));
assert!(
matches!(&scroll.lines[1], ScrollLine::Choice { choice, display }
if choice == "eat" && display == "Eat it")
);
}
#[test]
fn handle_scroll_without_choice_clears_it() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)]));
let mut game = GameState::with_scripts(
board,
scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)]),
);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
@@ -282,10 +312,16 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
// Setting choice on the scroll before a tick fires send() on the source object;
// fn eat() logging "eaten" confirms the dispatch arrived.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"s",
r#"
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); }
"#)]));
"#,
)]),
);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
@@ -295,5 +331,8 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
game.active_scroll.as_mut().unwrap().choice = Some("eat".to_string());
game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_none());
assert!(log_texts(&game).iter().any(|t| t == "eaten"), "eat() should have logged");
assert!(
log_texts(&game).iter().any(|t| t == "eaten"),
"eat() should have logged"
);
}
+38 -12
View File
@@ -1,6 +1,6 @@
use color::Rgba8;
use crate::archetype::Archetype;
use crate::glyph::Glyph;
use color::Rgba8;
use rhai::Dynamic;
/// Which directions a solid may be pushed in.
@@ -98,6 +98,9 @@ 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.
@@ -109,8 +112,18 @@ impl PortalDef {
pub fn default_glyph() -> Glyph {
Glyph {
tile: 240,
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 255, g: 255, b: 255, a: 255 },
fg: Rgba8 {
r: 0,
g: 0,
b: 0,
a: 255,
},
bg: Rgba8 {
r: 255,
g: 255,
b: 255,
a: 255,
},
}
}
}
@@ -133,12 +146,17 @@ pub struct Player {
#[cfg(test)]
mod tests {
use crate::utils::Direction;
use super::Pushable;
use crate::utils::Direction;
#[test]
fn pushable_allows_only_its_axis() {
for d in [Direction::North, Direction::South, Direction::East, Direction::West] {
for d in [
Direction::North,
Direction::South,
Direction::East,
Direction::West,
] {
assert!(!Pushable::No.allows(d));
assert!(Pushable::Any.allows(d));
}
@@ -187,10 +205,18 @@ impl TryFrom<Dynamic> for RegistryValue {
type Error = ();
/// Returns `Err(())` for `()` (unit) and all unsupported types.
fn try_from(value: Dynamic) -> Result<Self, ()> {
if value.is_bool() { return Ok(RegistryValue::Bool(value.as_bool().unwrap())); }
if value.is_int() { return Ok(RegistryValue::Int(value.as_int().unwrap())); }
if value.is_float() { return Ok(RegistryValue::Float(value.as_float().unwrap())); }
if value.is_string() { return Ok(RegistryValue::Str(value.into_string().unwrap())); }
if value.is_bool() {
return Ok(RegistryValue::Bool(value.as_bool().unwrap()));
}
if value.is_int() {
return Ok(RegistryValue::Int(value.as_int().unwrap()));
}
if value.is_float() {
return Ok(RegistryValue::Float(value.as_float().unwrap()));
}
if value.is_string() {
return Ok(RegistryValue::Str(value.into_string().unwrap()));
}
Err(())
}
}
@@ -202,9 +228,9 @@ impl Into<Dynamic> for RegistryValue {
fn into(self) -> Dynamic {
match self {
RegistryValue::Bool(b) => Dynamic::from(b),
RegistryValue::Int(n) => Dynamic::from(n),
RegistryValue::Int(n) => Dynamic::from(n),
RegistryValue::Float(f) => Dynamic::from(f),
RegistryValue::Str(s) => Dynamic::from(s),
RegistryValue::Str(s) => Dynamic::from(s),
}
}
}
@@ -219,4 +245,4 @@ impl From<Direction> for (i32, i32) {
Direction::West => (-1, 0),
}
}
}
}
+4 -5
View File
@@ -1,11 +1,11 @@
//! World type: a named collection of boards loaded from a single `.toml` file.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use crate::board::Board;
use crate::map_file::MapFile;
use serde::Deserialize;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
/// A named world containing one or more boards, loaded from a `.toml` file.
///
@@ -71,8 +71,7 @@ pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> {
// hard errors; everything else is nonfatal and lands on Board::load_errors.
let mut boards = HashMap::new();
for (key, map_file) in wf.boards {
let board = Board::try_from(map_file)
.map_err(|e| format!("board '{}': {}", key, e))?;
let board = Board::try_from(map_file).map_err(|e| format!("board '{}': {}", key, e))?;
boards.insert(key, Rc::new(RefCell::new(board)));
}