spinners and fmt

This commit is contained in:
2026-06-21 01:32:47 -05:00
parent d32914d99d
commit 8e90084b53
31 changed files with 1445 additions and 284 deletions
+17
View File
@@ -68,6 +68,13 @@ pub(crate) enum Action {
/// the source is solid and the destination already has a solid occupant.
/// Zero time cost — multiple teleports may fire in one frame.
Teleport { x: i32, y: i32 },
/// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on
/// arbitrary cells, not the source object). Zero time cost.
Push { x: i32, y: i32, dir: Direction },
/// Apply a batch of one-way solid moves simultaneously (read-all, then
/// write-all, so cycles/swaps work). Each tuple is `(src_x, src_y, dst_x,
/// dst_y)`. Zero time cost.
Swap(Vec<(i32, i32, i32, i32)>),
}
// ── Direction helper ──────────────────────────────────────────────────────────
@@ -163,6 +170,16 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
m.insert("x".into(), Dynamic::from(*x as i64));
m.insert("y".into(), Dynamic::from(*y as i64));
}
Action::Push { x, y, dir } => {
m.insert("type".into(), ds("Push"));
m.insert("x".into(), Dynamic::from(*x as i64));
m.insert("y".into(), Dynamic::from(*y as i64));
m.insert("dir".into(), ds(dir_to_str(*dir)));
}
// Swap pairs are not inspectable via the queue map API; emit type only.
Action::Swap(_) => {
m.insert("type".into(), ds("Swap"));
}
}
m
}
+69 -1
View File
@@ -38,11 +38,27 @@ pub enum Archetype {
/// [`crate::builtin_scripts`]). The script self-propels it one cell at a time in
/// the facing direction, shoving any pushable chain ahead — no engine special-case.
Pusher(Direction),
/// A rotating machine — solid, opaque, non-pushable. Like [`Pusher`](Archetype::Pusher)
/// this is a map-file *keyword* only: at load it expands into a scripted
/// [`ObjectDef`] carrying the embedded `spinner.rhai` and a `BUILTIN_spinner_<dir>`
/// tag (see [`crate::builtin_scripts`]). The script reads the tag for its spin
/// direction, rotates the 8 neighbouring cells one step each 0.5 s, and animates
/// its own glyph through a spinning line `/ ─ \ │` (slash-swapped for counter-clockwise).
Spinner(SpinDirection),
/// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game.
ErrorBlock,
}
/// Which way a [`Spinner`](Archetype::Spinner) rotates the ring of cells around it.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum SpinDirection {
/// Rotate neighbours clockwise (N→NE→E→…).
Clockwise,
/// Rotate neighbours counter-clockwise (N→NW→W→…).
CounterClockwise,
}
impl Archetype {
/// Returns the default [`Behavior`] for this archetype.
///
@@ -79,6 +95,11 @@ impl Archetype {
opaque: true,
pushable: Pushable::No,
},
Archetype::Spinner(_) => Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
},
Archetype::ErrorBlock => Behavior {
solid: true,
opaque: true,
@@ -99,6 +120,8 @@ impl Archetype {
Archetype::Pusher(Direction::South) => "pusher_south",
Archetype::Pusher(Direction::East) => "pusher_east",
Archetype::Pusher(Direction::West) => "pusher_west",
Archetype::Spinner(SpinDirection::Clockwise) => "spinner_cw",
Archetype::Spinner(SpinDirection::CounterClockwise) => "spinner_ccw",
Archetype::ErrorBlock => "error_block",
}
}
@@ -148,6 +171,18 @@ impl Archetype {
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
Archetype::Spinner(dir) => {
// First frame of the spin animation: '/' clockwise, '\' counter.
let tile = match dir {
SpinDirection::Clockwise => 47, // '/'
SpinDirection::CounterClockwise => 92, // '\'
};
Glyph {
tile,
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
// Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph {
tile: 63,
@@ -178,6 +213,8 @@ impl TryFrom<&str> for Archetype {
"pusher_south" => Ok(Archetype::Pusher(Direction::South)),
"pusher_east" => Ok(Archetype::Pusher(Direction::East)),
"pusher_west" => Ok(Archetype::Pusher(Direction::West)),
"spinner_cw" => Ok(Archetype::Spinner(SpinDirection::Clockwise)),
"spinner_ccw" => Ok(Archetype::Spinner(SpinDirection::CounterClockwise)),
_ => Err(format!("unknown archetype: {name}")),
}
}
@@ -185,11 +222,42 @@ impl TryFrom<&str> for Archetype {
#[cfg(test)]
mod tests {
use super::Archetype;
use super::{Archetype, SpinDirection};
#[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 spinner_names_round_trip() {
for arch in [
Archetype::Spinner(SpinDirection::Clockwise),
Archetype::Spinner(SpinDirection::CounterClockwise),
] {
assert_eq!(Archetype::try_from(arch.name()), Ok(arch));
}
assert_eq!(
Archetype::try_from("spinner_cw"),
Ok(Archetype::Spinner(SpinDirection::Clockwise))
);
}
#[test]
fn spinner_default_glyph_first_frame() {
// First animation frame: '/' (47) clockwise, '\' (92) counter-clockwise.
assert_eq!(
Archetype::Spinner(SpinDirection::Clockwise)
.default_glyph()
.tile,
47
);
assert_eq!(
Archetype::Spinner(SpinDirection::CounterClockwise)
.default_glyph()
.tile,
92
);
}
}
+393 -2
View File
@@ -5,7 +5,28 @@ 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};
use std::collections::{BTreeMap, HashMap, HashSet};
/// A captured solid occupant of a cell, used by [`Board::apply_swap`] to read
/// every source cell before writing any destination (so cyclic moves work).
#[derive(Clone)]
enum SolidSnapshot {
/// The player occupied the cell.
Player,
/// A solid scripted object occupied the cell, identified by its stable id.
Object(ObjectId),
/// A solid terrain cell — its visual/behavior plus the layer it lived on.
Terrain {
/// Layer the terrain lived on (restored on the destination).
z: usize,
/// The cell's glyph.
glyph: Glyph,
/// The cell's archetype.
arch: Archetype,
},
/// No solid occupant.
Empty,
}
/// The complete state of one game board (a single room or screen).
///
@@ -256,6 +277,31 @@ impl Board {
}
}
/// Whether the solid at `(x, y)` can be **shifted** one step in `dir`: it is
/// itself a pushable solid *and* the next cell is either empty or holds another
/// pushable solid.
///
/// Unlike [`can_push`](Board::can_push) this inspects only the single cell
/// ahead — it does **not** verify the whole chain ends in open space. It is the
/// right test for a simultaneous rotation/shift (applied via
/// [`apply_swap`](Board::apply_swap)), where a destination is occupied by
/// another pushable that is itself moving the same frame. Returns `false` if
/// `(x, y)` holds no pushable, or the cell ahead runs off the board.
pub fn can_shift(&self, x: usize, y: usize, dir: Direction) -> bool {
// The source must hold a solid pushable in `dir`.
if !self.is_pushable(x, y, dir) {
return false;
}
let (dx, dy): (i32, i32) = dir.into();
let next = (x as i32 + dx, y as i32 + dy);
if !self.in_bounds(next) {
return false; // nothing to shift into off the board
}
let (nx, ny) = (next.0 as usize, next.1 as usize);
// The cell ahead is acceptable if it is empty or another pushable solid.
self.is_passable(nx, ny) || self.is_pushable(nx, ny, dir)
}
/// Shoves the chain of pushable solids starting at `(x, y)` one step in `dir`,
/// leaving `Empty` floor behind each moved cell.
///
@@ -336,6 +382,18 @@ impl Board {
id
}
/// Removes the object with `id`, returning its [`ObjectDef`] if it existed.
///
/// This only touches the board's `objects` map. A live [`ScriptHost`] built
/// before the removal keeps a stale `ObjectRuntime` for the gone object; its
/// subsequent host-fn calls resolve to a missing id and become no-ops, so the
/// removal is benign even mid-game (see CLAUDE.md's runtime spawn/destroy note).
///
/// [`ScriptHost`]: crate::script::ScriptHost
pub fn remove_object(&mut self, id: ObjectId) -> Option<ObjectDef> {
self.objects.remove(&id)
}
/// Editor primitive: stamps `arch` (with visual `glyph`) into the cell at
/// `(x, y)`, applying the editor's placement/removal rules.
///
@@ -373,6 +431,194 @@ impl Board {
*self.get_mut(z, x, y) = (glyph, arch);
}
/// Replaces every terrain cell whose archetype is script-backed (e.g. a
/// `Spinner` or `Pusher`) with the scripted object it expands to — the same
/// transformation the map loader applies in [`layer::build_layer`](crate::layer),
/// but run against a live, already-built board.
///
/// The editor stamps these archetypes as plain terrain cells (via
/// [`place_archetype`](Board::place_archetype)); they only come alive once
/// expanded into objects carrying their embedded script + `BUILTIN_*` tag. Call
/// this before running a board assembled in memory (e.g. entering a playtest), so
/// editor-placed machines actually run. A save→reload round-trip expands them via
/// the normal load path, so this is only needed for the in-memory path. Cells
/// already loaded as objects are untouched, so it is safe to call more than once.
pub fn expand_builtin_archetypes(&mut self) {
use crate::builtin_scripts::{archetype_script, builtin_tag};
// Collect first: the loop below mutates both layers and the object map.
let mut found: Vec<(usize, usize, usize, Glyph, Archetype)> = Vec::new();
for z in 0..self.layers.len() {
for y in 0..self.height {
for x in 0..self.width {
let (glyph, arch) = *self.get(z, x, y);
if archetype_script(arch).is_some() {
found.push((z, x, y, glyph, arch));
}
}
}
}
for (z, x, y, glyph, arch) in found {
// Vacate the terrain cell (revealing any floor beneath), then spawn the
// object — mirroring `resolve_entry`'s object template.
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
let b = arch.behavior();
let mut obj = ObjectDef::new(x, y);
obj.z = z;
obj.glyph = glyph;
obj.solid = b.solid;
obj.opaque = b.opaque;
obj.pushable = false;
obj.builtin_script = archetype_script(arch);
obj.tags.insert(builtin_tag(arch));
self.add_object(obj);
}
}
/// Applies a batch of one-way solid moves **simultaneously**, returning any
/// nonfatal error lines (out-of-bounds entries / a write blocked by the player).
///
/// Each tuple is `(src_x, src_y, dst_x, dst_y)`: the solid occupant of `(src_x,
/// src_y)` — the player, a solid object, or a terrain crate/wall — moves to
/// `(dst_x, dst_y)`. A source with no solid moves an "empty", which **removes**
/// whatever solid was at the destination. Every source is read before any
/// destination is written, so cyclic permutations and two-cell swaps resolve
/// correctly (e.g. `[a→b],[b→a]` swaps `a` and `b`).
///
/// Displacement rules: a destination's prior solid that isn't itself being moved
/// is removed (terrain cleared; a scripted object despawned via
/// [`remove_object`](Board::remove_object)). The **player is never destroyed** —
/// a write that would overwrite the player without relocating it is skipped and
/// logged (the player wins its cell, per the one-solid-per-cell invariant).
pub fn apply_swap(&mut self, pairs: &[(i32, i32, i32, i32)]) -> Vec<LogLine> {
let mut errors = Vec::new();
// 1. Validate: keep only entries whose source and destination are in bounds.
let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new();
for &(sx, sy, dx, dy) in pairs {
if !self.in_bounds((sx, sy)) || !self.in_bounds((dx, dy)) {
errors.push(LogLine::error(format!(
"swap: out-of-bounds entry ({sx},{sy})->({dx},{dy})"
)));
continue;
}
valid.push(((sx, sy), (dx, dy)));
}
// 2. Snapshot the solid at each unique source (read phase). Reading every
// source before any write is what lets cycles/swaps resolve.
let mut snapshots: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
for &(src, _) in &valid {
snapshots
.entry(src)
.or_insert_with(|| self.snapshot_solid(src));
}
// 3. Compute the final occupant of every affected cell. A source that is not
// anyone's destination is vacated (Empty); each entry writes its source's
// snapshot into its destination (a later entry wins a repeated destination).
let dsts: HashSet<(i32, i32)> = valid.iter().map(|&(_, d)| d).collect();
let mut final_state: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
for &(src, _) in &valid {
if !dsts.contains(&src) {
final_state.insert(src, SolidSnapshot::Empty);
}
}
for &(src, dst) in &valid {
final_state.insert(dst, snapshots[&src].clone());
}
// The player's final cell: where a Player snapshot is installed, else its
// current cell (it stays put). Used to protect the player from being
// overwritten — computed up front so it's stable across the write loop.
let player_final = final_state
.iter()
.find(|(_, s)| matches!(s, SolidSnapshot::Player))
.map(|(&c, _)| c)
.unwrap_or((self.player.x, self.player.y));
// 4a. Clear each affected cell's current occupant, despawning any object that
// doesn't survive (isn't reused in final_state). The player and surviving
// objects keep their entity and are repositioned by the install step.
let survivors: HashSet<ObjectId> = final_state
.values()
.filter_map(|s| match s {
SolidSnapshot::Object(id) => Some(*id),
_ => None,
})
.collect();
let affected: HashSet<(i32, i32)> = snapshots
.keys()
.copied()
.chain(final_state.keys().copied())
.collect();
for &(cx, cy) in &affected {
let (ux, uy) = (cx as usize, cy as usize);
match self.solid_at(ux, uy) {
Some(Solid::Object(id)) if !survivors.contains(&id) => {
self.remove_object(id);
}
Some(Solid::Cell(_)) => {
if let Some(z) = self.solid_cell_layer(ux, uy) {
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
}
}
_ => {}
}
}
// 4b. Install each cell's computed occupant.
for (&(cx, cy), snap) in &final_state {
let (ux, uy) = (cx as usize, cy as usize);
// The player wins its cell: never overwrite player_final with anything
// other than the player itself.
if (cx, cy) == player_final && !matches!(snap, SolidSnapshot::Player) {
errors.push(LogLine::error(format!(
"swap: cannot overwrite the player at ({cx},{cy})"
)));
continue;
}
match snap {
SolidSnapshot::Empty => {} // already cleared
SolidSnapshot::Terrain { z, glyph, arch } => {
*self.get_mut(*z, ux, uy) = (*glyph, *arch);
}
SolidSnapshot::Object(id) => {
if let Some(obj) = self.objects.get_mut(id) {
obj.x = ux;
obj.y = uy;
}
}
SolidSnapshot::Player => {
self.player.x = cx;
self.player.y = cy;
}
}
}
errors
}
/// Reads the solid occupant of `(x, y)` into a [`SolidSnapshot`] (for
/// [`apply_swap`](Board::apply_swap)). Coordinates must be in bounds.
fn snapshot_solid(&self, (x, y): (i32, i32)) -> SolidSnapshot {
let (ux, uy) = (x as usize, y as usize);
match self.solid_at(ux, uy) {
Some(Solid::Player) => SolidSnapshot::Player,
Some(Solid::Object(id)) => SolidSnapshot::Object(id),
Some(Solid::Cell(arch)) => {
let z = self
.solid_cell_layer(ux, uy)
.expect("a solid terrain cell has a layer");
SolidSnapshot::Terrain {
z,
glyph: self.get(z, ux, uy).0,
arch,
}
}
None => SolidSnapshot::Empty,
}
}
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
/// (the cell's single terrain archetype, if any). By the one-solid-per-cell
/// invariant there is at most one such layer.
@@ -384,7 +630,7 @@ impl Board {
#[cfg(test)]
pub(crate) mod tests {
use super::Board;
use crate::archetype::Archetype;
use crate::archetype::{Archetype, SpinDirection};
use crate::glyph::Glyph;
use crate::layer::Layer;
use crate::object_def::ObjectDef;
@@ -538,6 +784,38 @@ pub(crate) mod tests {
assert!(!board.can_push(1, 0, Direction::East));
}
#[test]
fn can_shift_only_checks_the_cell_ahead() {
// Source must be pushable.
let mut board = open_board(4, 1, (3, 0), vec![]);
assert!(!board.can_shift(0, 0, Direction::East)); // empty source
// Crate with open space ahead: shiftable.
crate_at(&mut board, 0, 0);
assert!(board.can_shift(0, 0, Direction::East));
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // read-only
// Crate with another pushable crate ahead: still shiftable (unlike can_push,
// which would follow the chain to the wall and fail).
let mut board = open_board(4, 1, (3, 0), vec![]);
crate_at(&mut board, 0, 0);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
assert!(board.can_shift(0, 0, Direction::East));
assert!(!board.can_push(0, 0, Direction::East));
// Crate with a non-pushable wall ahead: not shiftable.
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0);
wall_at(&mut board, 1, 0);
assert!(!board.can_shift(0, 0, Direction::East));
// Crate at the board edge facing off-board: not shiftable.
let mut board = open_board(2, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
assert!(!board.can_shift(1, 0, Direction::East));
}
#[test]
fn push_into_player_pushes_player() {
// Crate shoved east into the player slides the player along into open space.
@@ -673,4 +951,117 @@ pub(crate) mod tests {
assert!(!board.is_valid());
assert_eq!(board.load_errors.len(), 1);
}
#[test]
fn expand_builtin_archetypes_replaces_a_spinner_cell_with_an_object() {
let mut board = open_board(3, 1, (2, 0), vec![]);
stamp(
&mut board,
0,
0,
Archetype::Spinner(SpinDirection::Clockwise),
);
board.expand_builtin_archetypes();
// The terrain cell is vacated and a scripted object takes its place.
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
let obj = board.objects.values().next().expect("spinner object");
assert_eq!((obj.x, obj.y), (0, 0));
assert!(obj.solid);
assert!(obj.builtin_script.is_some());
assert!(obj.tags.contains("BUILTIN_spinner_cw"));
// Idempotent: nothing left to expand on a second pass.
board.expand_builtin_archetypes();
assert_eq!(board.objects.len(), 1);
}
#[test]
fn remove_object_deletes_from_map() {
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(0, 0)]);
assert!(board.solid_object_id_at(0, 0).is_some());
let removed = board.remove_object(1);
assert!(removed.is_some());
assert!(board.solid_object_id_at(0, 0).is_none());
assert!(board.remove_object(1).is_none()); // already gone
}
#[test]
fn apply_swap_swaps_two_terrain_cells() {
// A crate and a wall trade places in one batch (read-all then write-all).
let mut board = open_board(3, 1, (1, 0), vec![]);
crate_at(&mut board, 0, 0);
wall_at(&mut board, 2, 0);
let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Wall);
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
}
#[test]
fn apply_swap_cycle_propagates_empty() {
// The spec example: move a->b and b->c with a empty ⇒ a empty, b empty,
// c holds what b held (the empty written into b doesn't block b->c).
let mut board = open_board(4, 1, (3, 0), vec![]);
// a = (0,0) empty, b = (1,0) crate, c = (2,0) wall.
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
let errs = board.apply_swap(&[(0, 0, 1, 0), (1, 0, 2, 0)]);
assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
}
#[test]
fn apply_swap_empty_onto_object_removes_it() {
// Moving an empty source onto an object despawns the object.
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
assert!(errs.is_empty());
assert!(board.solid_object_id_at(1, 0).is_none());
assert!(board.objects.is_empty());
}
#[test]
fn apply_swap_terrain_onto_terrain_removes_displaced() {
// Moving a crate onto a wall clears the source and removes the wall.
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0);
wall_at(&mut board, 1, 0);
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
}
#[test]
fn apply_swap_moves_object_and_player() {
// An object and the player relocate (swap places) in one batch.
let mut board = open_board(3, 1, (0, 0), vec![ObjectDef::new(2, 0)]);
let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
assert!(errs.is_empty());
assert_eq!((board.player.x, board.player.y), (2, 0));
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
}
#[test]
fn apply_swap_out_of_bounds_skips_and_logs() {
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0);
let errs = board.apply_swap(&[(0, 0, 9, 0)]);
assert_eq!(errs.len(), 1);
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged
}
#[test]
fn apply_swap_will_not_overwrite_player() {
// A crate moved onto the (non-relocating) player is rejected and logged.
let mut board = open_board(3, 1, (1, 0), vec![]);
crate_at(&mut board, 0, 0);
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
assert_eq!(errs.len(), 1);
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // source still vacated
}
}
+5
View File
@@ -22,6 +22,10 @@ pub(crate) const BUILTIN_TAG_PREFIX: &str = "BUILTIN_";
/// direction comes from the object's tag, so all pushers share one compiled AST.
const PUSHER: &str = include_str!("scripts/pusher.rhai");
/// The spinner behavior, embedded into the binary. Shared by both spin directions —
/// direction comes from the object's tag, so all spinners share one compiled AST.
const SPINNER: &str = include_str!("scripts/spinner.rhai");
/// The tag identifying an object as the expanded form of `arch`, e.g.
/// `"BUILTIN_pusher_east"`.
pub(crate) fn builtin_tag(arch: Archetype) -> String {
@@ -41,6 +45,7 @@ pub(crate) fn archetype_from_builtin_tag(tag: &str) -> Option<Archetype> {
pub(crate) fn archetype_script(arch: Archetype) -> Option<&'static str> {
match arch {
Archetype::Pusher(_) => Some(PUSHER),
Archetype::Spinner(_) => Some(SPINNER),
_ => None,
}
}
+181
View File
@@ -287,6 +287,17 @@ impl GameState {
}
}
}
// push() self-checks can_push, so an in-bounds guard is all we add.
Action::Push { x, y, dir } => {
if board.in_bounds((x, y)) {
board.push(x as usize, y as usize, dir);
} else {
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
}
}
// apply_swap reads all sources then writes all destinations, so a
// whole batch of moves resolves simultaneously (cycles included).
Action::Swap(pairs) => logs.extend(board.apply_swap(&pairs)),
}
}
}
@@ -451,3 +462,173 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
}
bumped
}
#[cfg(test)]
mod tests {
use super::GameState;
use crate::archetype::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at};
use crate::object_def::ObjectDef;
use std::collections::HashMap;
use std::time::Duration;
/// 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.script_name = Some("s".to_string());
let mut board = open_board(board_w, 1, (board_w as i32 - 1, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game
}
#[test]
fn script_push_shoves_a_crate() {
// The object pushes the crate at (2,0) one step east on its first tick.
let mut game = game_with_object_script(
5,
"fn tick(dt) { if Queue.length() == 0 { push(2, 0, East); } }",
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
}
#[test]
fn script_swap_moves_crates_simultaneously() {
// Swap the crate at (2,0) with the empty cell at (3,0): one ends up empty,
// the other holds the crate, applied in a single batch.
let mut game = game_with_object_script(
5,
"fn tick(dt) { if Queue.length() == 0 { swap([[2, 0, 3, 0]]); } }",
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
}
#[test]
fn script_passable_distinguishes_empty_solid_and_offboard() {
// (1,0) empty, (2,0) a solid crate, (9,0) off the 4-wide board. The non-solid
// 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.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let src = "fn init() { \
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
let lines: Vec<String> = game
.log
.iter()
.map(|l| l.spans.first().map(|s| s.text.clone()).unwrap_or_default())
.collect();
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
}
/// Builds a 3×3 board with a non-solid clockwise spinner object (running the
/// real `scripts/spinner.rhai`) at the centre and the player parked on it.
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
spinner_board_dir(crates, walls, false)
}
/// Like [`spinner_board`] but `ccw` attaches the `BUILTIN_spinner_ccw` tag so the
/// script spins counter-clockwise (clockwise is the default when no tag present).
fn spinner_board_dir(
crates: &[(usize, usize)],
walls: &[(usize, usize)],
ccw: bool,
) -> GameState {
let mut obj = ObjectDef::new(1, 1);
obj.solid = false;
obj.script_name = Some("spinner".to_string());
if ccw {
obj.tags.insert("BUILTIN_spinner_ccw".to_string());
}
let mut board = open_board(3, 3, (1, 1), vec![obj]);
for &(x, y) in crates {
crate_at(&mut board, x, y);
}
for &(x, y) in walls {
wall_at(&mut board, x, y);
}
let scripts = HashMap::from([(
"spinner".to_string(),
include_str!("scripts/spinner.rhai").to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game
}
#[test]
fn spinner_does_not_destroy_a_blocked_neighbour() {
// Crates at N(1,0) and NE(2,0), a wall at E(2,1). NE can't rotate into the
// wall, so N must not rotate onto NE — the old (can_shift-only) script
// overwrote and destroyed NE's crate. The cascade leaves everything put.
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N kept
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed)
assert_eq!(b.get(0, 2, 1).1, Archetype::Wall); // wall kept
}
#[test]
fn spinner_rotates_an_arc_into_a_hole() {
// An arc W(0,1) -> NW(0,0) -> N(1,0) draining into the hole at NE(2,0).
// Clockwise, every crate advances one slot and the hole ends up at W.
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE: filled by N
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W
assert_eq!(b.get(0, 0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
}
#[test]
fn spinner_ccw_rotates_the_other_way() {
// A lone crate at N(1,0) with both diagonal holes open. Clockwise it would
// go to NE(2,0); counter-clockwise it goes to NW(0,0) instead.
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated
}
#[test]
fn spinner_animates_its_glyph_without_recoloring() {
// The glyph character cycles '/'(47) '─'(0xC4) '\'(92) '│'(0xB3) one frame
// per 0.5s rotation, while the colours stay put.
let mut game = spinner_board(&[], &[]);
let id = *game.board().objects.keys().next().unwrap();
let (fg0, bg0) = {
let b = game.board();
(b.objects[&id].glyph.fg, b.objects[&id].glyph.bg)
};
let mut seq = Vec::new();
for _ in 0..5 {
game.tick(Duration::from_secs_f64(0.5));
let b = game.board();
seq.push(b.objects[&id].glyph.tile);
assert_eq!(b.objects[&id].glyph.fg, fg0, "fg unchanged");
assert_eq!(b.objects[&id].glyph.bg, bg0, "bg unchanged");
}
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]);
}
}
+5 -21
View File
@@ -144,7 +144,6 @@ pub(crate) struct ObjectTemplate {
pub opaque: bool,
pub pushable: bool,
pub script_name: Option<String>,
pub builtin_script: Option<&'static str>,
pub tags: Vec<String>,
pub name: Option<String>,
}
@@ -214,7 +213,6 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
opaque: e.opaque.unwrap_or(true),
pushable: e.pushable.unwrap_or(false),
script_name: e.script_name.clone(),
builtin_script: None,
tags: e.tags.clone().unwrap_or_default(),
name: e.name.clone(),
}),
@@ -234,26 +232,12 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
}
},
"player" => Resolved::Player,
// Any other kind must be an archetype name.
// Any other kind must be an archetype name. Script-backed archetypes (e.g.
// pushers/spinners) load as a plain terrain cell here and are turned into
// their scripted objects afterward by `Board::expand_builtin_archetypes`
// (called from `TryFrom<MapFile>`), so the expansion lives in one place.
other => match Archetype::try_from(other) {
// Script-backed archetypes (e.g. pushers) expand into an object carrying
// the embedded script plus a `BUILTIN_<archetype>` tag the script reads.
Ok(a) => match crate::builtin_scripts::archetype_script(a) {
Some(src) => {
let b = a.behavior();
Resolved::Object(ObjectTemplate {
glyph: glyph_with_default(a.default_glyph()),
solid: b.solid,
opaque: b.opaque,
pushable: false,
script_name: None,
builtin_script: Some(src),
tags: vec![crate::builtin_scripts::builtin_tag(a)],
name: None,
})
}
None => Resolved::Cell(glyph_with_default(a.default_glyph()), a),
},
Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a),
Err(msg) => {
errors.push(LogLine::error(format!(
"palette '{ch}': {msg}; using error block"
+1 -1
View File
@@ -23,7 +23,7 @@ mod utils;
/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]).
pub mod world;
pub use archetype::Archetype;
pub use archetype::{Archetype, SpinDirection};
pub use board::Board;
pub use utils::Direction;
+10 -3
View File
@@ -238,7 +238,9 @@ impl TryFrom<MapFile> for Board {
opaque: t.opaque,
pushable: t.pushable,
script_name: t.script_name,
builtin_script: t.builtin_script,
// Script-backed archetypes are expanded after the board is built
// (see `expand_builtin_archetypes` below), not via templates.
builtin_script: None,
tags: t.tags.into_iter().collect(),
name,
},
@@ -267,7 +269,7 @@ impl TryFrom<MapFile> for Board {
});
}
Ok(Board {
let mut board = Board {
name: mf.map.name,
width: w,
height: h,
@@ -282,7 +284,12 @@ impl TryFrom<MapFile> for Board {
board_script_name: mf.map.board_script_name,
load_errors,
registry: HashMap::new(),
})
};
// Turn script-backed archetype cells (pushers/spinners) into their scripted
// objects. Runs after the cross-layer validation above, so board invariants
// hold; the same call also fixes editor-placed machines before a playtest.
board.expand_builtin_archetypes();
Ok(board)
}
}
+83 -1
View File
@@ -36,6 +36,13 @@
//! - `Board.named(name) -> ObjectInfo | ()` — object with that name (or unit)
//! - `Board.get(id) -> ObjectInfo | ()` — look up by id; `-1` returns player info
//!
//! ## Cell queries (free fns)
//!
//! - `blocked(dir) -> bool` — would the caller be blocked moving one step in `dir`
//! - `can_push(x, y, dir) -> bool` — is the pushable chain at `(x, y)` shovable in `dir`
//! - `can_shift(x, y, dir) -> bool` — like `can_push`, but only checks the cell ahead
//! - `passable(x, y) -> bool` — is `(x, y)` on-board and free of any solid (a hole)
//!
//! ## Self-reference API (`Me.*`)
//!
//! - `Me.id / name / x / y` — getters
@@ -47,7 +54,8 @@
//!
//! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`,
//! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`,
//! `send(target_id, fn_name [, arg])`, `teleport(x, y)`
//! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`,
//! `swap([[src_x, src_y, dst_x, dst_y], …])`
//!
//! ## Queue API
//!
@@ -672,6 +680,32 @@ fn register_read_api(
is_blocked(&b, &bq, source_of(&ctx), dir)
});
// can_push(x, y, dir) — true if (x, y) holds a pushable whose chain can be
// shoved one step in dir (the read-only half of push()). False off-board.
let b = board.clone();
engine.register_fn("can_push", move |x: i64, y: i64, dir: Direction| -> bool {
let board = b.borrow();
board.in_bounds((x as i32, y as i32)) && board.can_push(x as usize, y as usize, dir)
});
// can_shift(x, y, dir) — like can_push but inspects only the cell ahead: (x, y)
// holds a pushable and the next cell is empty or another pushable. False
// off-board. The right gate for a simultaneous shift/rotation via swap().
let b = board.clone();
engine.register_fn("can_shift", move |x: i64, y: i64, dir: Direction| -> bool {
let board = b.borrow();
board.in_bounds((x as i32, y as i32)) && board.can_shift(x as usize, y as usize, dir)
});
// passable(x, y) — true if (x, y) is on-board and holds no solid (an empty cell
// a mover could enter). Off-board is not passable. Lets a script distinguish a
// hole from a blocked solid (which can_shift/can_push alone cannot).
let b = board.clone();
engine.register_fn("passable", move |x: i64, y: i64| -> bool {
let board = b.borrow();
board.in_bounds((x as i32, y as i32)) && board.is_passable(x as usize, y as usize)
});
// Board.tagged(tag) -> Array[ObjectInfo]
let b = board.clone();
engine.register_fn(
@@ -983,6 +1017,54 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
},
);
});
// push(x, y, dir): shove the pushable chain at (x, y) one step in dir. Acts on
// arbitrary cells, not the caller, and adds no delay (zero time cost).
let q = queues.clone();
engine.register_fn(
"push",
move |ctx: NativeCallContext, x: i64, y: i64, dir: Direction| {
emit(
&q,
source_of(&ctx),
Action::Push {
x: x as i32,
y: y as i32,
dir,
},
);
},
);
// swap(pairs): apply a batch of one-way solid moves simultaneously. `pairs` is
// an array whose elements are four-int arrays `[src_x, src_y, dst_x, dst_y]`. A
// malformed entry is skipped with a logged error. Zero time cost.
let q = queues.clone();
engine.register_fn("swap", move |ctx: NativeCallContext, arr: Array| {
let src = source_of(&ctx);
let mut pairs: Vec<(i32, i32, i32, i32)> = Vec::new();
for elem in &arr {
// Each entry must be an array of exactly four integers.
let coords: Option<Vec<i64>> = elem.read_lock::<Array>().and_then(|inner| {
if inner.len() == 4 {
inner.iter().map(|v| v.as_int().ok()).collect()
} else {
None
}
});
match coords {
Some(c) => pairs.push((c[0] as i32, c[1] as i32, c[2] as i32, c[3] as i32)),
None => emit(
&q,
src,
Action::Log(LogLine::error(
"swap: each entry must be [src_x, src_y, dst_x, dst_y]".to_string(),
)),
),
}
}
emit(&q, src, Action::Swap(pairs));
});
}
// ── Queue API ─────────────────────────────────────────────────────────────────
+98
View File
@@ -0,0 +1,98 @@
// Rotates this object's 8 neighbours one step around a ring every 0.5s, shoving
// each pushable into the next ring slot via a single simultaneous `swap`. The spin
// direction comes from the `BUILTIN_spinner_*` tag the map loader attaches (it
// defaults to clockwise when no tag is present).
//
// A neighbour may only rotate if its destination slot will actually be free: the
// slot is a hole (`passable`), or that slot's own occupant also rotates out. We
// can't decide that with `can_shift` alone — `can_shift(j) == false` is ambiguous
// between "j is empty" and "j is a blocked solid" — so we seed with `can_shift`
// and then cascade-demote using `passable` to spot the genuine holes.
fn tick(dt) {
// Only start a new rotation when the previous one (and its delay) has drained,
// exactly like the built-in pusher's pacing.
if Queue.length() != 0 { return; }
// Direction from the builtin tag; clockwise unless explicitly counter-clockwise.
let cw = !Me.has_tag("BUILTIN_spinner_ccw");
let ox = Me.x;
let oy = Me.y;
// The 8 neighbour offsets in clockwise order, starting at North.
let ring = [
[ 0, -1], // N
[ 1, -1], // NE
[ 1, 0], // E
[ 1, 1], // SE
[ 0, 1], // S
[-1, 1], // SW
[-1, 0], // W
[-1, -1], // NW
];
// The cardinal step that carries each ring cell to the *next* slot in the spin
// direction, and the index offset to that slot (+1 clockwise, -1 == +7 counter).
// CW: N->NE is East, NE->E is South, ... CCW: N->NW is West, NE->N is West, ...
let dirs = if cw {
[East, South, South, West, West, North, North, East]
} else {
[West, West, North, North, East, East, South, South]
};
let step = if cw { 1 } else { 7 };
// Animate the glyph one frame per rotation (changing only the character): the
// line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in
// the board Registry, keyed per spinner, since script scope resets each tick.
let frames = if cw { [47, 0xc4, 92, 0xb3] } else { [92, 0xc4, 47, 0xb3] };
let fkey = `spin_${Me.id}`;
let f = Registry.get_or(fkey, 0);
set_tile(frames[f % 4]);
Registry.set(fkey, (f + 1) % 4);
// moves[i]: will ring cell i rotate into its destination slot? Seed with
// can_shift, which is true only when cell i holds a pushable whose immediate
// next cell isn't a wall (so it could move *if* that next cell ends up free).
let moves = [];
for i in 0..8 {
let sx = ox + ring[i][0];
let sy = oy + ring[i][1];
moves.push(can_shift(sx, sy, dirs[i]));
}
// Cascade: a cell may only move if its destination is a hole (`passable`), or
// the occupant of that destination also moves. Demote until stable. A broken
// arc collapses back from the jam; a full pushable cycle never demotes (every
// destination's occupant moves) and rotates whole via swap's cycle handling.
loop {
let changed = false;
for i in 0..8 {
let n = (i + step) % 8;
let dx = ox + ring[n][0];
let dy = oy + ring[n][1];
if moves[i] && !passable(dx, dy) && !moves[n] {
moves[i] = false;
changed = true;
}
}
if !changed { break; }
}
// Emit the surviving rotations as one simultaneous batch, then pace to twice
// a second. Coordinates are pulled into locals first to keep each expression
// simple (Rhai caps expression complexity).
let batch = [];
for i in 0..8 {
if moves[i] {
let n = (i + step) % 8;
let sx = ox + ring[i][0];
let sy = oy + ring[i][1];
let dx = ox + ring[n][0];
let dy = oy + ring[n][1];
batch.push([sx, sy, dx, dy]);
}
}
swap(batch);
delay(0.5);
}
+1
View File
@@ -5,6 +5,7 @@ mod player_placement;
mod portal_placement;
mod pushers;
mod round_trip;
mod spinners;
use crate::board::Board;
use crate::map_file::MapFile;
+4 -1
View File
@@ -148,7 +148,10 @@ mod tests {
);
// The copy changed; the original is still empty at that cell.
assert_eq!(copy.boards["start"].borrow().get(0, 1, 0).1, Archetype::Wall);
assert_eq!(
copy.boards["start"].borrow().get(0, 1, 0).1,
Archetype::Wall
);
assert_eq!(
world.boards["start"].borrow().get(0, 1, 0).1,
Archetype::Empty