spinners and fmt
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user