Board layers
This commit is contained in:
+47
-19
@@ -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()),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user