This commit is contained in:
2026-07-14 23:48:50 -05:00
parent 03185c9c68
commit 5146cc9bcc
25 changed files with 380 additions and 272 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ use crate::Direction;
use crate::action::BoardAction;
use crate::object_def::ObjectDef;
use crate::script::Registerable;
use crate::utils::{LogSink, ObjectId};
use crate::utils::{Behavior, LogSink, ObjectId};
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
/// and `Board.get`. Passed by value — scripts read fields, not a live reference.
@@ -116,7 +116,7 @@ impl Registerable for ObjectInfo {
// me.light: the object's current emitted light radius in cells (0 = none).
engine.register_get("light", |o: &mut ObjectInfo| -> i64 {
match o.board.borrow().objects.get(&o.id) {
Some(ObjectDef { light, .. }) => *light as i64,
Some(ObjectDef { behavior: Behavior { glow, .. }, .. }) => *glow as i64,
None => 0,
}
});
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::script::Registerable;
use crate::utils::{LogSink, ObjectId};
/// A single object's output queue.
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct ObjQueue(Rc<RefCell<VecDeque<Action>>>);
impl ObjQueue {
+50 -84
View File
@@ -2,6 +2,8 @@ use crate::glyph::Glyph;
use crate::utils::{Behavior, Pushable};
use color::Rgba8;
use crate::keys::KeyType;
use serde::{Serialize, Deserialize};
use crate::tile::EnterResponse;
/// Declares the set of script-backed archetype families.
///
@@ -40,7 +42,7 @@ macro_rules! builtins {
///
/// Add one entry to the `builtins!` invocation in `archetype.rs` and write
/// `kiln-core/src/scripts/<name>.rhai`. No other files need to change.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Builtin {
$( $variant ),+
}
@@ -49,7 +51,7 @@ macro_rules! builtins {
/// Returns `(family_variant, matched_alias)` for `name`, or `None` if `name`
/// is not a known builtin keyword. The returned `&'static str` is the exact
/// literal from the macro (always valid for a `Archetype::Builtin` field).
pub(crate) fn from_name(name: &str) -> Option<(Self, &'static str)> {
pub fn from_name(name: &str) -> Option<(Self, &'static str)> {
match name {
$(
$( $name => Some((Builtin::$variant, $name)), )+
@@ -59,7 +61,7 @@ macro_rules! builtins {
}
/// Returns the uniform behavior shared by all aliases in this family.
pub(crate) fn behavior(self) -> Behavior {
pub fn behavior(self) -> Behavior {
match self {
$( Builtin::$variant => $behavior, )+
}
@@ -69,7 +71,7 @@ macro_rules! builtins {
/// so aliases within a family can differ in color (e.g. colored keys).
/// Falls back to a transparent glyph for unrecognized aliases (shouldn't
/// happen in practice since aliases are all from the macro).
pub(crate) fn default_glyph_for(self, alias: &str) -> Glyph {
pub fn default_glyph_for(self, alias: &str) -> Glyph {
match alias {
$(
$( $name => $glyph, )+
@@ -79,7 +81,7 @@ macro_rules! builtins {
}
/// Returns the embedded Rhai source shared by all aliases in this family.
pub(crate) fn script(self) -> &'static str {
pub fn script(self) -> &'static str {
match self {
$( Builtin::$variant => $script, )+
}
@@ -100,11 +102,11 @@ const fn g(tile: u32, r: u8, gr: u8, b: u8) -> Glyph {
builtins! {
Gem => ["gem" => g(4, 0x50, 0x50, 0xFF)] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true },
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true, glow: 0 },
script: include_str!("scripts/gem.rhai"),
},
Heart => ["heart" => g(3, 0xCC, 0x22, 0x22)] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true },
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true, glow: 0 },
script: include_str!("scripts/heart.rhai"),
},
Pusher => [
@@ -113,14 +115,14 @@ builtins! {
"pusher_east" => g(16, 0xAA, 0xAA, 0xAA),
"pusher_west" => g(17, 0xAA, 0xAA, 0xAA),
] {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false },
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false, glow: 0 },
script: include_str!("scripts/pusher.rhai"),
},
Spinner => [
"spinner_cw" => g(47, 0xAA, 0xAA, 0xAA),
"spinner_ccw" => g(92, 0xAA, 0xAA, 0xAA),
] {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false },
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false, glow: 0 },
script: include_str!("scripts/spinner.rhai"),
},
// Solid, see-through (opaque: false), unpushable teleporters. Each direction's
@@ -131,7 +133,7 @@ builtins! {
"transporter_east" => g(41, 0x55, 0xFF, 0xFF), // ')'
"transporter_west" => g(40, 0x55, 0xFF, 0xFF), // '('
] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::No, grab: false },
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::No, grab: false, glow: 0 },
script: include_str!("scripts/transporter.rhai"),
},
Key => [ // TODO these should refer to the key colors in Keyring
@@ -144,9 +146,28 @@ builtins! {
"key_yellow" => KeyType::Yellow.glyph(),
"key_white" => KeyType::White.glyph(),
] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true },
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true, glow: 0 },
script: include_str!("scripts/key.rhai"),
}
},
Wall => ["wall" => Glyph {
tile: 35,
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 }}] {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false, glow: 0 },
script: ""
},
Crate => ["crate" => g(254, 0xaa, 0xaa, 0xaa)] { // CP437 ■ (small filled square)
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::Any, grab: false, glow: 0 },
script: ""
},
HCrate => ["hcrate" => g(29, 0xaa, 0xaa, 0xaa)] { // CP437 ↔ (left-right arrow) — pushable east/west
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::Horizontal, grab: false, glow: 0 },
script: ""
},
VCrate => ["vcrate" => g(18, 0xaa, 0xaa, 0xaa)] { // CP437 ↕ (up-down arrow) — pushable north/south
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::Vertical, grab: false, glow: 0 },
script: ""
},
}
/// A class of board cell, encoding its default behavior and appearance.
@@ -171,22 +192,17 @@ builtins! {
///
/// [`ObjectDef`]: crate::object_def::ObjectDef
/// [`Board::expand_builtin_archetypes`]: crate::board::Board::expand_builtin_archetypes
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Archetype {
/// An open cell; the player and other entities can pass through it.
Empty,
/// A solid wall; impassable and opaque.
Wall,
/// A solid, pushable box. Walking into it shoves it one cell in the same
/// direction (cascading through a chain of crates) if there is open space.
Crate,
/// A crate that can only be pushed east/west (horizontal axis). Glyph ↔.
HCrate,
/// A crate that can only be pushed north/south (vertical axis). Glyph ↕.
VCrate,
/// A wall-mounted torch: non-solid, non-opaque decorative terrain that emits
/// warm light on a `dark` board (radius from [`Archetype::light`]). Glyph ☼.
Torch,
/// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game.
ErrorBlock,
/// A script-backed archetype expanded from a map-file keyword.
///
/// - `Builtin` is the family (e.g. `Builtin::Pusher`), which selects the
@@ -196,10 +212,8 @@ pub enum Archetype {
/// `BUILTIN_<alias>` tag on the expanded object.
///
/// See [`Builtin`] and the [`builtins!`] invocation for the full registry.
#[serde(untagged)]
Builtin(Builtin, &'static str),
/// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game.
ErrorBlock,
}
impl Archetype {
@@ -212,30 +226,7 @@ impl Archetype {
opaque: false,
pushable: Pushable::No,
grab: false,
},
Archetype::Wall => Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
grab: false,
},
Archetype::Crate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Any,
grab: false,
},
Archetype::HCrate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Horizontal,
grab: false,
},
Archetype::VCrate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Vertical,
grab: false,
glow: 0,
},
// A torch you can walk past and see through; it only lights the room.
Archetype::Torch => Behavior {
@@ -243,12 +234,14 @@ impl Archetype {
opaque: false,
pushable: Pushable::No,
grab: false,
glow: 6,
},
Archetype::ErrorBlock => Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
grab: false,
glow: 0,
},
}
}
@@ -258,10 +251,6 @@ impl Archetype {
match self {
Archetype::Builtin(_, alias) => alias,
Archetype::Empty => "empty",
Archetype::Wall => "wall",
Archetype::Crate => "crate",
Archetype::HCrate => "hcrate",
Archetype::VCrate => "vcrate",
Archetype::Torch => "torch",
Archetype::ErrorBlock => "error_block",
}
@@ -292,26 +281,6 @@ impl Archetype {
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::Wall => Glyph {
tile: 35,
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 },
},
Archetype::Crate => Glyph {
tile: 254, // CP437 ■ (small filled square)
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::HCrate => Glyph {
tile: 29, // CP437 ↔ (left-right arrow) — pushable east/west
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::VCrate => Glyph {
tile: 18, // CP437 ↕ (up-down arrow) — pushable north/south
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::Torch => Glyph {
tile: 15, // CP437 ☼ (sun) — a warm point of light
fg: Rgba8 { r: 0xFF, g: 0xB0, b: 0x40, a: 255 }, // warm amber (also its light color)
@@ -343,14 +312,17 @@ impl TryFrom<&str> for Archetype {
}
match name {
"empty" => Ok(Archetype::Empty),
"wall" => Ok(Archetype::Wall),
"crate" => Ok(Archetype::Crate),
"hcrate" => Ok(Archetype::HCrate),
"vcrate" => Ok(Archetype::VCrate),
"torch" => Ok(Archetype::Torch),
// "object", "portal", "player" are intentionally absent: they are
// meta-kinds handled by the layer builder, not Archetype variants.
_ => Err(format!("unknown archetype: {name}")),
_ => {
// is it a valid builtin?
if let Some((b, s)) = Builtin::from_name(name) {
Ok(Archetype::Builtin(b, s))
} else {
Err(format!("unknown archetype: {name}"))
}
},
}
}
}
@@ -359,12 +331,6 @@ impl TryFrom<&str> for Archetype {
mod tests {
use super::Archetype;
#[test]
fn directional_crate_default_glyph_tiles() {
assert_eq!(Archetype::HCrate.default_glyph().tile, 29);
assert_eq!(Archetype::VCrate.default_glyph().tile, 18);
}
#[test]
fn builtin_names_glyphs_and_round_trip() {
// All known aliases must parse, round-trip via name(), and give the right tile.
+68 -71
View File
@@ -5,8 +5,9 @@ use crate::glyph::Glyph;
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::utils::Direction;
use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid};
use crate::utils::{ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid};
use std::collections::{BTreeMap, HashMap, HashSet};
use crate::tile::{EnterResponse, Sensor};
/// The result of [`Board::apply_shift`]: any error lines to log, plus the
/// `(from, to)` cell relocations the shift actually performed.
@@ -84,6 +85,8 @@ pub struct Board {
/// Non-solid things placed off the main grid, drawn only where the grid cell is
/// empty (see [`Decoration`]). Normally empty; populated by save files.
pub(crate) decorations: Vec<Decoration>,
/// Non-solid things placed off the main grid, can't affect movement but see other hooks
pub sensors: Vec<Sensor>,
/// Current player position on this board. See [`PlayerPos`] for caveats
/// about its future. Game-global player *stats* live in [`crate::player::Player`].
pub player: PlayerPos,
@@ -176,7 +179,7 @@ impl Board {
// non-solid object (lets invisible trigger objects exist).
let mut nonsolid: Option<Glyph> = None;
for o in self.objects.values().filter(|o| o.x == x && o.y == y) {
if o.solid {
if o.behavior.solid {
return o.glyph;
}
if nonsolid.is_none() && o.glyph.tile != 0 {
@@ -245,17 +248,7 @@ impl Board {
// A solid object shadows the cell it sits on; capture its behavior now.
if let Some(id) = self.solid_object_id_at(x, y) {
let obj = &self.objects[&id];
let behavior = Behavior {
solid: obj.solid,
opaque: obj.opaque,
// ObjectDef stores pushability as a bool meaning "any direction".
pushable: if obj.pushable {
Pushable::Any
} else {
Pushable::No
},
grab: obj.grab,
};
let behavior = obj.behavior;
return Some(Solid::object_at(x, y, id, behavior));
}
// Otherwise the grid cell's terrain archetype may be solid (e.g. a wall).
@@ -282,7 +275,7 @@ impl Board {
/// Panics if `x` or `y` are out of bounds.
pub fn is_opaque_at(&self, x: usize, y: usize) -> bool {
self.get(x, y).1.behavior().opaque
|| self.objects.values().any(|o| o.x == x && o.y == y && o.opaque)
|| self.objects.values().any(|o| o.x == x && o.y == y && o.behavior.opaque)
}
/// Computes lighting + line-of-sight for the player on this board.
@@ -327,8 +320,8 @@ impl Board {
}
// Light-emitting objects: color = their own glyph foreground.
for o in self.objects.values() {
if o.light > 0 {
add_source(&mut lighting, o.x, o.y, o.light, color_to_rgb(o.glyph.fg));
if o.behavior.glow > 0 {
add_source(&mut lighting, o.x, o.y, o.behavior.glow, color_to_rgb(o.glyph.fg));
}
}
// Glowing terrain (e.g. a `Torch` cell): color = the cell's glyph foreground.
@@ -377,7 +370,16 @@ impl Board {
let solid = self.solid_at(cx, cy)?;
// A solid object — directly, or at the end of a crate chain — is the target.
if let Some(id) = solid.object_id() {
return Some(id);
let obj = self.objects.get(&id)?;
let resp = EnterResponse::from(obj.behavior);
if let EnterResponse::Push(p) = resp && !p.allows(dir) {
// This object can't be pushed that way, so, this is a bump. If it's pushable, the
// default push behavior happens (it just gets pushed)
return Some(id);
} else if resp == EnterResponse::Block {
// It's just a wall, block everything
return Some(id);
}
}
// The player is never itself "bumped"; a wall (non-pushable terrain) stops
// the chain with no object behind it. Only a pushable crate is walked
@@ -528,7 +530,7 @@ impl Board {
pub fn non_solid_object_ids_at(&self, x: usize, y: usize) -> Vec<ObjectId> {
self.objects
.iter()
.filter(|(_, o)| o.x == x && o.y == y && !o.solid)
.filter(|(_, o)| o.x == x && o.y == y && !o.behavior.solid)
.map(|(&id, _)| id)
.collect()
}
@@ -536,7 +538,7 @@ impl Board {
/// 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 {
if o.x == x && o.y == y && o.behavior.solid {
Some(id)
} else {
None
@@ -551,7 +553,7 @@ impl Board {
/// the object's `grab()` hook fires instead.
pub fn grab_object_at(&self, x: usize, y: usize) -> Option<ObjectId> {
self.objects.iter().find_map(|(&id, o)| {
if o.x == x && o.y == y && o.solid && o.grab {
if o.x == x && o.y == y && EnterResponse::from(o.behavior) == EnterResponse::Grab {
Some(id)
} else {
None
@@ -645,13 +647,7 @@ impl Board {
let beh = b.behavior();
let mut obj = ObjectDef::new(x, y);
obj.glyph = glyph;
obj.solid = beh.solid;
obj.opaque = beh.opaque;
// Carry the archetype's pushability/grab onto the object (pushers and
// spinners are Pushable::No, so they stay unpushable; gems are pushable
// and grabbable).
obj.pushable = beh.pushable != crate::utils::Pushable::No;
obj.grab = beh.grab;
obj.behavior = beh;
obj.builtin_script = Some(b.script());
// The compile-cache key and the BUILTIN_* tag are both derived from the
// alias (e.g. "BUILTIN_pusher_east"), so each alias gets its own cached
@@ -758,7 +754,7 @@ pub(crate) mod tests {
use crate::floor::Floor;
use crate::glyph::Glyph;
use crate::object_def::ObjectDef;
use crate::utils::Direction;
use crate::utils::{Direction, Pushable};
use crate::utils::{ObjectId, PlayerPos};
use color::Rgba8;
use std::collections::{BTreeMap, HashMap};
@@ -789,6 +785,7 @@ pub(crate) mod tests {
grid: vec![(Glyph::transparent(), Archetype::Empty); w * h],
floor: Floor::Blank,
decorations: Vec::new(),
sensors: Vec::new(),
player: PlayerPos {
x: player.0,
y: player.1,
@@ -811,12 +808,12 @@ pub(crate) mod tests {
/// Stamps a crate cell onto the grid.
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
*board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
*board.get_mut(x, y) = (Archetype::Builtin(Builtin::Crate, "crate").default_glyph(), Archetype::Builtin(Builtin::Crate, "crate"));
}
/// Stamps a wall cell onto the grid.
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
*board.get_mut(x, y) = (Builtin::Wall.default_glyph_for("wall"), Archetype::Builtin(Builtin::Wall, "wall"));
}
/// Stamps an arbitrary archetype cell onto the grid.
@@ -824,7 +821,7 @@ pub(crate) mod tests {
*board.get_mut(x, y) = (arch.default_glyph(), arch);
}
#[test]
#[test]
fn solid_at_reports_wall_object_and_empty() {
let mut board = open_board(4, 1, (3, 0), vec![]);
wall_at(&mut board, 1, 0);
@@ -834,7 +831,7 @@ pub(crate) mod tests {
assert!(board.is_passable(0, 0));
let wall = board.solid_at(1, 0).expect("a wall is solid");
assert_eq!(wall.archetype(), Some(Archetype::Wall));
assert_eq!(wall.archetype(), Some(Archetype::Builtin(Builtin::Wall, "wall")));
assert!(!board.is_passable(1, 0));
let obj = board.solid_at(2, 0).expect("an object is solid");
@@ -847,8 +844,8 @@ pub(crate) mod tests {
fn grab_object_at_detects_a_gem() {
// A grabbable gem object at (1,0); the player at (2,0).
let mut gem = ObjectDef::new(1, 0);
gem.grab = true;
gem.pushable = true;
gem.behavior.grab = true;
gem.behavior.pushable = Pushable::Any;
let board = open_board(3, 1, (2, 0), vec![gem]);
// grab_object_at finds the gem on its own cell, nowhere else.
@@ -859,7 +856,7 @@ pub(crate) mod tests {
#[test]
fn non_solid_object_does_not_block() {
let mut obj = ObjectDef::new(1, 0);
obj.solid = false;
obj.behavior.solid = false;
let board = open_board(3, 1, (0, 0), vec![obj]);
assert!(board.solid_at(1, 0).is_none());
assert!(board.is_passable(1, 0));
@@ -891,7 +888,7 @@ 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(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // no mutation
assert_eq!(board.get(2, 0).1, Archetype::Empty);
let mut board = open_board(3, 1, (0, 0), vec![]);
@@ -909,7 +906,7 @@ pub(crate) mod tests {
// Crate with open space ahead: shiftable.
crate_at(&mut board, 0, 0);
assert!(board.can_shift(0, 0, Direction::East));
assert_eq!(board.get(0, 0).1, Archetype::Crate); // read-only
assert_eq!(board.get(0, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // read-only
// Crate with another pushable crate ahead: still shiftable (unlike can_push,
// which would follow the chain to the wall and fail).
@@ -937,8 +934,8 @@ pub(crate) mod tests {
// The player is always a blocker for a shift — even a grab gem may not shift
// onto it (grab now fires only on player movement, not on being shifted in).
let mut gem = ObjectDef::new(0, 0);
gem.grab = true;
gem.pushable = true;
gem.behavior.grab = true;
gem.behavior.pushable = Pushable::Any;
let board = open_board(2, 1, (1, 0), vec![gem]);
assert!(!board.can_shift(0, 0, Direction::East));
@@ -956,7 +953,7 @@ pub(crate) mod tests {
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(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!((board.player.x, board.player.y), (3, 0));
}
@@ -968,7 +965,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(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!((board.player.x, board.player.y), (2, 0));
}
@@ -995,7 +992,7 @@ pub(crate) mod tests {
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(0, 0), Builtin::Wall.default_glyph_for("wall"));
assert_eq!(board.glyph_at(1, 0), floor_glyph);
}
@@ -1009,11 +1006,11 @@ pub(crate) mod tests {
};
add_floor(&mut board, floor);
let wall = Archetype::Wall.default_glyph();
board.place_archetype(1, 0, Archetype::Wall, wall);
let wall = Builtin::Wall.default_glyph_for("wall");
board.place_archetype(1, 0, Archetype::Builtin(Builtin::Wall, "wall"), wall);
// The wall landed on the grid; the floor attribute is untouched.
assert_eq!(board.get(1, 0), &(wall, Archetype::Wall));
assert_eq!(board.get(1, 0), &(wall, Archetype::Builtin(Builtin::Wall, "wall")));
// The solid object that was there is gone.
assert!(board.object_ids_at(1, 0).is_empty());
assert_eq!(board.glyph_at(1, 0), wall);
@@ -1024,16 +1021,16 @@ pub(crate) mod tests {
// A crate already occupies the grid at (1,0).
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 1, 0);
let wall = Archetype::Wall.default_glyph();
board.place_archetype(1, 0, Archetype::Wall, wall);
assert_eq!(board.get(1, 0), &(wall, Archetype::Wall));
let wall = Builtin::Wall.default_glyph_for("wall");
board.place_archetype(1, 0, Archetype::Builtin(Builtin::Wall, "wall"), wall);
assert_eq!(board.get(1, 0), &(wall, Archetype::Builtin(Builtin::Wall, "wall")));
}
#[test]
fn erase_removes_terrain_and_objects_but_keeps_floor() {
// A fixed floor attribute, a wall on the grid, and a (non-solid) object at (1,0).
let mut obj = ObjectDef::new(1, 0);
obj.solid = false;
obj.behavior.solid = false;
let mut board = open_board(3, 1, (2, 0), vec![obj]);
let floor = Glyph {
tile: '.' as u32,
@@ -1072,7 +1069,7 @@ pub(crate) mod tests {
assert_eq!(board.get(0, 0).1, Archetype::Empty);
let obj = board.objects.values().next().expect("spinner object");
assert_eq!((obj.x, obj.y), (0, 0));
assert!(obj.solid);
assert!(obj.behavior.solid);
assert!(obj.builtin_script.is_some());
assert!(obj.tags.contains("BUILTIN_spinner_cw"));
@@ -1098,7 +1095,7 @@ pub(crate) mod tests {
crate_at(&mut board, 0, 0);
let errs = board.apply_shift(&[(0, 0), (9, 0)]);
assert_eq!(errs.errors.len(), 1);
assert_eq!(board.get(0, 0).1, Archetype::Crate); // unchanged
assert_eq!(board.get(0, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // unchanged
}
#[test]
@@ -1122,11 +1119,11 @@ pub(crate) mod tests {
// Crate(3,0)→(4,0), Crate(4,0)→(0,0) wrap
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]);
assert_eq!(board.get(0, 0).1, Archetype::Crate); // wrapped from (4,0)
assert_eq!(board.get(1, 0).1, Archetype::Crate); // moved from (0,0)
assert_eq!(board.get(2, 0).1, Archetype::Wall); // blocked, immobile
assert_eq!(board.get(0, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // wrapped from (4,0)
assert_eq!(board.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (0,0)
assert_eq!(board.get(2, 0).1, Archetype::Builtin(Builtin::Wall, "wall")); // blocked, immobile
assert_eq!(board.get(3, 0).1, Archetype::Empty); // cleared, crate moved
assert_eq!(board.get(4, 0).1, Archetype::Crate); // moved from (3,0)
assert_eq!(board.get(4, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (3,0)
}
#[test]
@@ -1139,21 +1136,21 @@ pub(crate) mod tests {
// Subcase A: HCrate moves
{
let mut board = open_board(4, 1, (3, 0), vec![]);
stamp(&mut board, 0, 0, Archetype::HCrate);
stamp(&mut board, 0, 0, Archetype::Builtin(Builtin::HCrate, "hcrate"));
crate_at(&mut board, 1, 0);
// (2,0) empty
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
assert_eq!(board.get(0, 0).1, Archetype::Empty); // HCrate moved out
assert_eq!(board.get(1, 0).1, Archetype::HCrate); // moved from (0,0)
assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0)
assert_eq!(board.get(1, 0).1, Archetype::Builtin(Builtin::HCrate, "hcrate")); // moved from (0,0)
assert_eq!(board.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (1,0)
}
// Subcase B: VCrate stays; Crate behind it still moves
{
let mut board = open_board(4, 1, (3, 0), vec![]);
stamp(&mut board, 0, 0, Archetype::VCrate);
stamp(&mut board, 0, 0, Archetype::Builtin(Builtin::VCrate, "vcrate"));
crate_at(&mut board, 1, 0);
// (2,0) empty
@@ -1163,9 +1160,9 @@ pub(crate) mod tests {
// Crate at (1,0) is NOT in blocked, so it moves.
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
assert_eq!(board.get(0, 0).1, Archetype::VCrate); // immobile
assert_eq!(board.get(0, 0).1, Archetype::Builtin(Builtin::VCrate, "vcrate")); // immobile
assert_eq!(board.get(1, 0).1, Archetype::Empty); // crate moved out
assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0)
assert_eq!(board.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (1,0)
}
}
@@ -1178,22 +1175,22 @@ pub(crate) mod tests {
// Subcase A: VCrate moves
{
let mut board = open_board(1, 4, (0, 3), vec![]);
stamp(&mut board, 0, 0, Archetype::VCrate);
stamp(&mut board, 0, 1, Archetype::Crate);
stamp(&mut board, 0, 0, Archetype::Builtin(Builtin::VCrate, "vcrate"));
stamp(&mut board, 0, 1, Archetype::Builtin(Builtin::Crate, "crate"));
// (0,2) empty
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
assert_eq!(board.get(0, 0).1, Archetype::Empty); // VCrate moved out
assert_eq!(board.get(0, 1).1, Archetype::VCrate); // moved from (0,0)
assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1)
assert_eq!(board.get(0, 1).1, Archetype::Builtin(Builtin::VCrate, "vcrate")); // moved from (0,0)
assert_eq!(board.get(0, 2).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (0,1)
}
// Subcase B: HCrate stays; Crate behind it still moves
{
let mut board = open_board(1, 4, (0, 3), vec![]);
stamp(&mut board, 0, 0, Archetype::HCrate);
stamp(&mut board, 0, 1, Archetype::Crate);
stamp(&mut board, 0, 0, Archetype::Builtin(Builtin::HCrate, "hcrate"));
stamp(&mut board, 0, 1, Archetype::Builtin(Builtin::Crate, "crate"));
// (0,2) empty
// HCrate (idx 0): target (0,1), origin (0,0), vmove=true.
@@ -1202,9 +1199,9 @@ pub(crate) mod tests {
// Crate at (0,1) is NOT in blocked, so it moves.
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
assert_eq!(board.get(0, 0).1, Archetype::HCrate); // immobile
assert_eq!(board.get(0, 0).1, Archetype::Builtin(Builtin::HCrate, "hcrate")); // immobile
assert_eq!(board.get(0, 1).1, Archetype::Empty); // crate moved out
assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1)
assert_eq!(board.get(0, 2).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (0,1)
}
}
@@ -1261,8 +1258,8 @@ pub(crate) mod tests {
let mut board = open_board(3, 1, (1, 0), vec![]);
board.dark = true;
let mut lamp = ObjectDef::new(1, 0);
lamp.solid = false;
lamp.light = 4;
lamp.behavior.solid = false;
lamp.behavior.glow = 4;
lamp.glyph = Glyph { tile: 1, fg: Rgba8 { r: 255, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } };
board.add_object(lamp);
+20 -20
View File
@@ -313,7 +313,7 @@ impl GameState {
}
Action::SetLight(radius) => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.light = radius;
obj.behavior.glow = radius;
}
}
Action::SetTag {
@@ -405,7 +405,7 @@ impl GameState {
"teleport({target},{x},{y}): no such object"
)),
Some(obj) => {
let source_solid = obj.solid;
let source_solid = obj.behavior.solid;
let (old_x, old_y) = (obj.x as i64, obj.y as i64);
let blocked = source_solid
&& matches!(
@@ -742,7 +742,7 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome {
entered: Vec::new(),
};
let (dx, dy): (i64, i64) = dir.into();
let Some((ox, oy, solid)) = board.objects.get(&id).map(|o| (o.x, o.y, o.solid)) else {
let Some((ox, oy, solid)) = board.objects.get(&id).map(|o| (o.x, o.y, o.behavior.solid)) else {
return out;
};
let target = (ox as i64 + dx, oy as i64 + dy);
@@ -805,7 +805,7 @@ mod tests {
// ordinary solid here: the chain can't move (the player is backed against
// the board edge), so nothing happens and the gem is not collected.
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.behavior.solid = false;
sobj.script_name = Some("s".to_string());
let mut board = open_board(3, 1, (2, 0), vec![sobj]);
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
@@ -822,14 +822,14 @@ mod tests {
// No grab: the gem is untouched and the player never moved.
assert_eq!(game.player.borrow().gems, 0);
assert!(game.board().objects.values().any(|o| o.grab));
assert!(game.board().objects.values().any(|o| o.behavior.grab));
assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
}
/// Builds a `GameState` with one non-solid object running `src` as its script.
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.behavior.solid = false;
obj.script_name = Some("s".to_string());
let mut board = open_board(board_w, 1, (board_w as i64 - 1, 0), vec![obj]);
crate_at(&mut board, 2, 0);
@@ -850,7 +850,7 @@ mod tests {
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(2, 0).1, Archetype::Empty);
assert_eq!(b.get(3, 0).1, Archetype::Crate);
assert_eq!(b.get(3, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
}
#[test]
@@ -859,7 +859,7 @@ mod tests {
// object sits at (0,0); the player at (3,0). passable should report only the
// genuinely empty in-bounds cell.
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.behavior.solid = false;
obj.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0);
@@ -886,7 +886,7 @@ mod tests {
// right after run_init (dt = 0) — under the old queued-Action behavior it
// would have been stuck behind the delay and absent here.
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.behavior.solid = false;
obj.script_name = Some("s".to_string());
let board = open_board(4, 1, (3, 0), vec![obj]);
let src = "fn init(m) { delay(5.0); log(\"immediate\"); }";
@@ -916,7 +916,7 @@ mod tests {
ccw: bool,
) -> GameState {
let mut obj = ObjectDef::new(1, 1);
obj.solid = false;
obj.behavior.solid = false;
obj.script_name = Some("spinner".to_string());
if ccw {
obj.tags.insert("BUILTIN_spinner_ccw".to_string());
@@ -945,9 +945,9 @@ mod tests {
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N kept
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE kept (not destroyed)
assert_eq!(b.get(2, 1).1, Archetype::Wall); // wall kept
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // N kept
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // NE kept (not destroyed)
assert_eq!(b.get(2, 1).1, Archetype::Builtin(Builtin::Wall, "wall")); // wall kept
}
#[test]
@@ -957,9 +957,9 @@ mod tests {
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE: filled by N
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N: filled by NW
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: filled by W
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // NE: filled by N
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // N: filled by NW
assert_eq!(b.get(0, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // NW: filled by W
assert_eq!(b.get(0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
}
@@ -970,7 +970,7 @@ mod tests {
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
assert_eq!(b.get(0, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // NW: crate rotated counter-clockwise
assert_eq!(b.get(2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(1, 0).1, Archetype::Empty); // N: vacated
}
@@ -999,7 +999,7 @@ mod tests {
#[test]
fn set_key_gives_and_takes_keys() {
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.behavior.solid = false;
sobj.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
@@ -1016,7 +1016,7 @@ mod tests {
// A second script can take a key.
let mut sobj2 = ObjectDef::new(0, 0);
sobj2.solid = false;
sobj2.behavior.solid = false;
sobj2.script_name = Some("t".to_string());
let board2 = open_board(2, 1, (1, 0), vec![sobj2]);
let scripts2 = HashMap::from([(
@@ -1032,7 +1032,7 @@ mod tests {
#[test]
fn set_key_unknown_color_logs_error() {
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.behavior.solid = false;
sobj.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
+2 -1
View File
@@ -1,6 +1,7 @@
use color::Rgba8;
use std::hash::{Hash, Hasher};
use rhai::Engine;
use serde::{Deserialize, Serialize};
use crate::script::Registerable;
use crate::utils::LogSink;
@@ -16,7 +17,7 @@ use crate::utils::LogSink;
/// `Glyph` values come from the map file palette and are set at load time.
/// The player is the only entity whose glyph is hardcoded at runtime
/// (see [`Glyph::player`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Glyph {
/// Tile index into the board's bitmap font (left-to-right, top-to-bottom).
pub tile: u32,
+4 -3
View File
@@ -18,6 +18,7 @@ use crate::map_file::{TileIndex, parse_color};
use crate::object_def::ObjectDef;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::utils::Pushable;
/// Serde representation of the board `[grid]`: a char grid plus its palette.
///
@@ -84,7 +85,7 @@ pub(crate) struct PaletteEntry {
pub opaque: Option<bool>,
/// Object pushability (defaults `false`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pushable: Option<bool>,
pub pushable: Option<Pushable>,
/// Light radius in cells emitted on a dark board (defaults `0` = none).
/// Only for `kind = "object"`. See [`ObjectDef::light`].
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -129,7 +130,7 @@ pub(crate) struct ObjectTemplate {
pub glyph: Glyph,
pub solid: bool,
pub opaque: bool,
pub pushable: bool,
pub pushable: Pushable,
/// Light radius in cells (0 = none); see [`ObjectDef::light`].
pub light: u32,
pub script_name: Option<String>,
@@ -179,7 +180,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
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),
pushable: e.pushable.unwrap_or(Pushable::No),
light: e.light.unwrap_or(0),
script_name: e.script_name.clone(),
tags: e.tags.clone().unwrap_or_default(),
+1
View File
@@ -34,3 +34,4 @@ pub use utils::Direction;
#[cfg(test)]
mod tests;
mod api;
pub mod tile;
+24 -18
View File
@@ -25,7 +25,7 @@ use crate::glyph::Glyph;
use crate::layer::{GridData, PaletteEntry, Placement, build_grid};
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::utils::{ObjectId, PlayerPos, PortalDef};
use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable};
/// The serde shell for one board in a `.toml` file: a header, the single grid, and
/// the off-grid trigger/decoration lists.
@@ -316,13 +316,16 @@ impl TryFrom<MapFile> for Board {
x,
y,
glyph: t.glyph,
solid: t.solid,
opaque: t.opaque,
pushable: t.pushable,
light: t.light,
// Hand-placed objects are never grab targets; only expanded
// grab archetypes (e.g. gems) set this (see expand_builtin_archetypes).
grab: false,
behavior: Behavior {
solid: t.solid,
opaque: t.opaque,
pushable: t.pushable,
glow: t.light,
// Hand-placed objects are never grab targets; only expanded
// grab archetypes (e.g. gems) set this (see expand_builtin_archetypes).
// TODO this is wrong, obviously we want grabbable objects
grab: false,
},
script_name: t.script_name,
// Script-backed archetypes are expanded after the board is built
// (see `expand_builtin_archetypes` below), not via templates.
@@ -355,11 +358,13 @@ impl TryFrom<MapFile> for Board {
x: t.x,
y: t.y,
glyph: Glyph::transparent(),
solid: false,
opaque: false,
pushable: false,
light: 0,
grab: false,
behavior: Behavior {
solid: false,
opaque: false,
pushable: Pushable::No,
glow: 0,
grab: false,
},
script_name: Some(t.script_name),
builtin_script: None,
tags: t.tags.unwrap_or_default().into_iter().collect(),
@@ -406,6 +411,7 @@ impl TryFrom<MapFile> for Board {
grid,
floor,
decorations,
sensors: Vec::new(),
player: PlayerPos {
x: px as i64,
y: py as i64,
@@ -514,7 +520,7 @@ fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
/// Whether an object is a **trigger** — an invisible, non-solid, script-only object
/// authored/serialized in `[[triggers]]` rather than the grid palette.
fn is_trigger(o: &ObjectDef) -> bool {
!o.solid && o.glyph.tile == 0 && o.script_name.is_some() && o.builtin_script.is_none()
!o.behavior.solid && o.glyph.tile == 0 && o.script_name.is_some() && o.builtin_script.is_none()
}
/// Serializes `board`'s single grid (terrain + non-trigger objects + portals +
@@ -563,10 +569,10 @@ fn grid_to_data(board: &Board) -> GridData {
tile: Some(TileIndex::Num(o.glyph.tile)),
fg: Some(color_to_hex(o.glyph.fg)),
bg: Some(color_to_hex(o.glyph.bg)),
solid: Some(o.solid),
opaque: Some(o.opaque),
pushable: Some(o.pushable),
light: (o.light > 0).then_some(o.light),
solid: Some(o.behavior.solid),
opaque: Some(o.behavior.opaque),
pushable: Some(o.behavior.pushable),
light: (o.behavior.glow > 0).then_some(o.behavior.glow),
script_name: o.script_name.clone(),
tags: (!tags.is_empty()).then_some(tags),
name: o.name.clone(),
+10 -25
View File
@@ -1,5 +1,5 @@
use crate::glyph::Glyph;
use crate::utils::ObjectId;
use crate::utils::{Behavior, ObjectId, Pushable};
use color::Rgba8;
use std::collections::HashSet;
use crate::api::queue::ObjQueue;
@@ -37,25 +37,8 @@ pub struct ObjectDef {
/// 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,
/// Whether the object blocks / participates in movement. A solid object stops
/// a mover walking into it; at most one solid (object or grid archetype) may
/// occupy a cell. Inverse of the old `passable` flag.
pub solid: bool,
/// Whether the object blocks line of sight / FOV for the player
pub opaque: bool,
/// Whether the object can be shoved by a mover. Unused for now (no push
/// mechanic yet); defaults to `false`.
pub pushable: bool,
/// Whether walking into this object **grabs** it: the player moves onto the
/// cell and the object's `grab()` hook fires (the object is expected to
/// remove itself via `die()`). Set when a grab archetype (e.g. a gem) is
/// expanded into an object. Defaults to `false`.
pub grab: bool,
/// Light radius in cells that this object emits on a `dark` board (0 = no
/// light). The emitted *color* is this object's own glyph foreground color,
/// so a red glyph casts red light. See [`crate::fov`] for the lighting model.
/// Scripts can change it at runtime via `set_light(n)`. Defaults to `0`.
pub light: u32,
/// All the normal `Behavior` things about this object
pub behavior: Behavior,
/// Compile-key of the Rhai script that drives this object: a name in
/// [`World::scripts`](crate::world::World::scripts) for a hand-authored object,
/// or a synthetic `BUILTIN_*` name set when a script-backed archetype is
@@ -103,11 +86,13 @@ impl ObjectDef {
x,
y,
glyph: Self::default_glyph(),
solid: true,
opaque: true,
pushable: false,
grab: false,
light: 0,
behavior: Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
grab: false,
glow: 0,
},
script_name: None,
builtin_script: None,
tags: HashSet::new(),
+2 -1
View File
@@ -3,6 +3,7 @@ use crate::archetype::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at};
use crate::game::GameState;
use std::time::Duration;
use crate::Builtin;
#[test]
fn move_command_relocates_the_source_object() {
@@ -45,7 +46,7 @@ fn object_pushes_crate_on_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(3, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
}
#[test]
+1
View File
@@ -18,6 +18,7 @@ fn make_board(px: i64, py: i64, portals: Vec<PortalDef>) -> Board {
grid: vec![(Glyph::transparent(), Archetype::Empty); 9],
floor: Floor::Blank,
decorations: Vec::new(),
sensors: Vec::new(),
player: PlayerPos { x: px, y: py },
objects: BTreeMap::new(),
next_object_id: 1,
+7 -5
View File
@@ -19,12 +19,14 @@ palette = { "#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" } }
let board = load_board(toml);
for y in 0..2 {
for x in 0..3 {
let expected = if (x, y) == (0, 0) {
Archetype::Empty // player won its fallback cell
if (x, y) == (0, 0) { // player won its fallback cell
assert!(board.solid_at(x, y).unwrap().player());
} else {
Archetype::Wall
};
assert_eq!(board.get(x, y).1, expected, "cell ({x}, {y})");
let obj = &board.objects[&board.object_ids_at(x, y)[0]];
let tag = obj.tags.iter().next().unwrap();
assert_eq!(tag, "BUILTIN_wall")
}
}
}
assert_eq!((board.player.x, board.player.y), (0, 0));
@@ -77,6 +77,6 @@ fn non_solid_object_and_wall_coexist_in_separate_cells() {
// never be authored onto a wall cell. A wall and a (separate-cell) non-solid
// object both load fine.
let board = load_board(&map_3x1_object("#G@", "G", "solid = false"));
assert_eq!(board.objects.len(), 1);
assert_eq!(board.objects.len(), 2); // The wall (for now) shows up as an object because it's a builtin, TODO
assert!(board.is_valid());
}
+6 -1
View File
@@ -7,6 +7,7 @@ use crate::game::GameState;
use crate::map_file::MapFile;
use crate::object_def::ObjectDef;
use std::time::Duration;
use crate::{Board, Builtin};
/// Finds the pusher object on a board (by its built-in tag).
fn pusher<'a>(board: &'a crate::board::Board, id: &mut u32) -> &'a ObjectDef {
@@ -42,6 +43,10 @@ fn pusher_loads_as_a_tagged_scripted_solid_object() {
assert!(!board.is_passable(0, 0), "pusher is solid");
}
fn is_tag(board: &Board, x: usize, y: usize, tag: &str) -> bool {
board.object_ids_at(x, y).iter().any(|id| board.objects[id].tags.contains(tag))
}
#[test]
fn pusher_advances_and_shoves_a_crate() {
// Pusher at (0,0), crate at (1,0), the player parked at the east edge.
@@ -74,7 +79,7 @@ fn pusher_advances_and_shoves_a_crate() {
"crate left its start cell"
);
assert!(
(2..b.width).any(|x| b.get(x, 0).1 == Archetype::Crate),
(2..b.width).any(|x| is_tag(&b, x, 0, "BUILTIN_crate")),
"crate was shoved east"
);
}
+1 -4
View File
@@ -10,7 +10,7 @@ use crate::utils::Direction;
use std::time::Duration;
#[test]
fn transporter_loads_as_a_tagged_scripted_solid_object() {
fn transporter_loads_as_a_tagged_scripted_object() {
let board = load_board(&map(
3,
1,
@@ -26,9 +26,6 @@ fn transporter_loads_as_a_tagged_scripted_solid_object() {
.expect("transporter object");
assert_eq!((obj.x, obj.y), (1, 0));
assert!(obj.builtin_script.is_some(), "carries the embedded script");
assert!(obj.solid, "transporter is solid");
assert!(!obj.opaque, "transporter is see-through");
assert!(!obj.pushable, "transporter cannot be pushed");
}
#[test]
+2 -1
View File
@@ -32,6 +32,7 @@ fn board_with_object(
grid: vec![(Glyph::transparent(), Archetype::Empty)],
floor: Floor::Blank,
decorations: Vec::new(),
sensors: Vec::new(),
player: PlayerPos { x: 0, y: 0 },
objects: BTreeMap::from([(1, object)]),
next_object_id: 2,
@@ -64,7 +65,7 @@ fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
/// kind that receives an `enter` hook when a solid relocates onto its cell.
fn nonsolid_object(x: usize, y: usize, script: &str) -> ObjectDef {
let mut o = scripted_object(x, y, script);
o.solid = false;
o.behavior.solid = false;
o
}
+20 -19
View File
@@ -3,8 +3,9 @@ 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 crate::utils::{Direction, Pushable};
use color::Rgba8;
use crate::Builtin;
#[test]
fn pushing_a_crate_reveals_the_floor_underneath() {
@@ -32,7 +33,7 @@ fn pushing_a_crate_reveals_the_floor_underneath() {
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!(b.get(2, 0).1, Archetype::Crate);
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.glyph_at(0, 0), floor_glyph);
}
@@ -46,7 +47,7 @@ fn player_pushes_single_crate() {
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(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
}
#[test]
@@ -58,8 +59,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(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Wall, "wall"));
}
#[test]
@@ -71,7 +72,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(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
}
#[test]
@@ -84,8 +85,8 @@ fn cascade_pushes_two_crates() {
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(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.get(3, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
}
#[test]
@@ -98,15 +99,15 @@ 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(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
}
#[test]
fn player_pushes_pushable_solid_object() {
// A solid, pushable object is shoved exactly like a crate.
let mut obj = ObjectDef::new(1, 0);
obj.pushable = true;
obj.behavior.pushable = Pushable::Any;
let board = open_board(4, 1, (0, 0), vec![obj]);
let mut game = GameState::new(board);
game.try_move(Direction::East);
@@ -119,44 +120,44 @@ fn player_pushes_pushable_solid_object() {
fn hcrate_pushes_east_but_not_north() {
// Pushing east: the HCrate slides.
let mut board = open_board(4, 1, (0, 0), vec![]);
stamp(&mut board, 1, 0, Archetype::HCrate);
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::HCrate, "hcrate"));
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::HCrate);
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::HCrate, "hcrate"));
drop(b);
// Pushing north into an HCrate: blocked, nothing moves.
let mut board = open_board(1, 4, (0, 3), vec![]);
stamp(&mut board, 0, 2, Archetype::HCrate);
stamp(&mut board, 0, 2, Archetype::Builtin(Builtin::HCrate, "hcrate"));
let mut game = GameState::new(board);
game.try_move(Direction::North);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 3));
assert_eq!(b.get(0, 2).1, Archetype::HCrate);
assert_eq!(b.get(0, 2).1, Archetype::Builtin(Builtin::HCrate, "hcrate"));
}
#[test]
fn vcrate_pushes_north_but_not_east() {
// Pushing north: the VCrate slides.
let mut board = open_board(1, 4, (0, 3), vec![]);
stamp(&mut board, 0, 2, Archetype::VCrate);
stamp(&mut board, 0, 2, Archetype::Builtin(Builtin::VCrate, "vcrate"));
let mut game = GameState::new(board);
game.try_move(Direction::North);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 2));
assert_eq!(b.get(0, 2).1, Archetype::Empty);
assert_eq!(b.get(0, 1).1, Archetype::VCrate);
assert_eq!(b.get(0, 1).1, Archetype::Builtin(Builtin::VCrate, "vcrate"));
drop(b);
// Pushing east into a VCrate: blocked, nothing moves.
let mut board = open_board(4, 1, (0, 0), vec![]);
stamp(&mut board, 1, 0, Archetype::VCrate);
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::VCrate, "vcrate"));
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::VCrate);
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::VCrate, "vcrate"));
}
+128
View File
@@ -0,0 +1,128 @@
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::api::queue::ObjQueue;
use crate::glyph::Glyph;
use crate::object_def::ObjectDef;
use crate::utils::{Behavior, ObjectId, Pushable};
/// The various ways that a tile might respond to another tile trying to move on top of it
#[derive(Serialize, Deserialize, PartialEq, Copy, Clone, Debug)]
pub enum EnterResponse {
/// Flat denial: don't let the move happen, block it.
Block,
/// Call the `grab` hook and then remove this object.
/// TODO will do something different once inventory exists as a concept
Grab,
/// Attempt to exit the cell ourselves, the opposite direction. This of course recurses; if our move
/// is denied then this is the same as a block.
Push(Pushable),
/// Call an `enter` hook and do something. The hook is responsible for resolving the conflict:
/// placing the moving-object somewhere else, moving ourselves somewhere else, or whatever.
/// It should return `true` or `false`: this return value is passed back up a `Push` chain to
/// the original moving object, determining whether the moves should happen.
/// Example: a teleporter moves anything that enters it to another spot, if it's unblocked. It
/// will check if the target cell is open and move the object there if it is, returning true.
/// If you push a crate into it, it returning true means you should move (into the space the crate
/// left behind); it returning false means the crate wasn't teleported so your move is blocked
/// also.
/// The hook return value is only used for _other_ things in the chain: regardless of what's returned,
/// the engine won't touch the crate or the teleporter; the hook is responsible for handling the
/// actual collision. A teleporter that teleports the crate and returns false will leave an empty
/// space behind (the player won't move into it because it returned false); a teleporter that
/// returns true without teleporting the crate, the crate will be destroyed by the player moving
/// on top of it.
Hook,
/// Swap places with whatever moved on top of you
Swap,
/// Get overwritten and destroyed by whatever moved on top of us. Right now, equivalent to `Grab`
/// if the hook does nothing (but in the future `Grab` will have other builtin behavior)
Squish
}
impl From<Behavior> for EnterResponse {
fn from(behavior: Behavior) -> EnterResponse {
if behavior.grab {
EnterResponse::Grab
} else if behavior.pushable != Pushable::No {
EnterResponse::Push(behavior.pushable)
} else if behavior.solid {
EnterResponse::Block
} else {
EnterResponse::Squish
}
}
}
/// Where `Sensor`s are drawn, in relation to the grid:
/// - `Above` is above everything, including the player. If the glyph has a nonzero tile, and it's
/// visible / lit, it will be drawn.
/// - `Below` is below the grid but above the floor. A nonzero-tile-glyph will be drawn only if
/// there's not a grid-thing in the same cell.
#[derive(Serialize, Deserialize, PartialEq, Copy, Clone, Debug)]
#[serde(rename = "lowercase")]
pub enum DrawLayer { Above, Below }
/// How a thing interacts with the lighting and visibility models
#[derive(Serialize, Deserialize, PartialEq, Copy, Clone, Debug)]
#[serde(rename = "lowercase")]
pub struct Optics {
/// Opaque things block field of view
pub opaque: bool,
/// The brightness (in number of cells' radius) of light this
/// emits. It will emit light in the color of the foreground of its glyph.
/// A cell is visible if:
/// - the level isn't dark, in which case lighting isn't calculated
/// - the cell is within the field of view
/// - the cell is within the glow radius of at least one light source
///
/// Its color will be tinted by the light sources illuminating it.
pub glow: u32,
}
/// Everything an object-or-sensor needs to have a Rhai script attached.
/// TODO clean this up some, especially the script_name-vs-builtin_script dichotomy
#[derive(Clone, Default)]
pub struct Scripting {
/// Compile-key of the Rhai script that drives this object: a name in
/// [`World::scripts`](crate::world::World::scripts) for a hand-authored object,
/// or a synthetic `BUILTIN_*` name set when a script-backed archetype is
/// expanded (see [`builtin_script`](ObjectDef::builtin_script)). `None` means
/// this object has no script yet.
pub script_name: Option<String>,
/// Embedded built-in script source, set when a script-backed archetype (e.g. a
/// `pusher_*` or `gem`) is expanded into an object at load time (see
/// [`crate::builtin_scripts`]). When set, this is the object's script *source*
/// (its compile-key is the synthetic `BUILTIN_*` [`script_name`](ObjectDef::script_name)
/// the same expansion assigns). Not part of the map file — it is regenerated
/// from the archetype on load.
pub builtin_script: Option<&'static str>,
/// Open-ended string labels for this object. Serialized as a TOML array;
/// not subject to any rate limit — mutations take effect immediately after
/// the frame's action queue is drained.
pub tags: HashSet<String>,
/// Optional unique human-readable name for this object. `None` if unnamed.
/// Names are validated for uniqueness at map-load time; a duplicate name is
/// cleared to `None` (the object survives but becomes anonymous).
pub name: Option<String>,
/// The output queue of actions for this object
pub queue: ObjQueue,
}
/// A `Sensor` is some kind of object that exists alongside the map: it's drawn on the board but can't
/// affect board movement, as it doesn't live in the grid.
#[derive(Serialize, Deserialize, Clone)]
pub struct Sensor {
/// The unique ID of this sensor
pub id: ObjectId,
/// Where it is on the board: x coord
pub x: usize,
/// Where it is on the board: y coord
pub y: usize,
/// What it looks like
pub glyph: Glyph,
/// Sensors interact with FOV and lighting
pub optics: Optics,
/// Sensors have scripting ability
#[serde(skip)]
pub scripting: Scripting,
}
+15 -4
View File
@@ -5,15 +5,18 @@ use crate::archetype::Archetype;
use crate::glyph::Glyph;
use color::Rgba8;
use rhai::Dynamic;
use serde::{Deserialize, Serialize};
use crate::Board;
use crate::log::LogLine;
use crate::tile::EnterResponse;
/// Which directions a solid may be pushed in.
///
/// Only meaningful for `solid` cells (a non-solid cell is passable, so nothing is
/// ever pushed into it). `Crate` is [`Pushable::Any`]; the directional crates
/// constrain pushes to one axis.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Pushable {
/// Cannot be pushed.
No,
@@ -61,6 +64,9 @@ pub struct Behavior {
/// the thing is expected to remove itself via `die()`). Only meaningful when
/// `solid`. The same grab fires if the thing is pushed into the player.
pub grab: bool,
/// The radius of light this emits, if any. Light _color_ is determined by the
/// glyph foreground color of whatever it is.
pub glow: u32,
}
/// A stable, unique identifier for a board object.
@@ -133,6 +139,7 @@ impl Solid {
opaque: true,
pushable: Pushable::Any,
grab: false,
glow: 6 // TODO player should not inherently glow, but glowing inventory items need to wait for inventory
},
}
}
@@ -185,18 +192,22 @@ impl Solid {
/// Which directions the occupant may be pushed (the player → [`Pushable::Any`]).
pub fn pushable(&self) -> Pushable {
self.behavior.pushable
if let EnterResponse::Push(p) = EnterResponse::from(self.behavior) {
p
} else {
Pushable::No
}
}
/// Whether this occupant is a **grab** thing (a gem-like solid the player
/// collects on contact). The player is never grabbable.
pub fn grab(&self) -> bool {
self.behavior.grab
EnterResponse::from(self.behavior) == EnterResponse::Grab
}
/// Whether this occupant blocks movement (always true for a `Solid`).
pub fn solid(&self) -> bool {
self.behavior.solid
EnterResponse::from(self.behavior) == EnterResponse::Block
}
/// Whether this occupant blocks line of sight.
+4 -3
View File
@@ -134,6 +134,7 @@ mod tests {
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use crate::Builtin;
/// A `deep_clone`d world owns independent boards: mutating the copy must leave the
/// original board untouched (the property the editor's playtest relies on).
@@ -155,14 +156,14 @@ mod tests {
copy.boards["start"].borrow_mut().place_archetype(
1,
0,
Archetype::Wall,
Archetype::Wall.default_glyph(),
Archetype::Builtin(Builtin::Wall, "wall"),
Builtin::Wall.default_glyph_for("wall"),
);
// The copy changed; the original is still empty at that cell.
assert_eq!(
copy.boards["start"].borrow().get(1, 0).1,
Archetype::Wall
Archetype::Builtin(Builtin::Wall, "wall")
);
assert_eq!(
world.boards["start"].borrow().get(1, 0).1,