This commit is contained in:
2026-06-21 22:04:10 -05:00
parent 475534f7c4
commit 87979ac610
7 changed files with 134 additions and 255 deletions
+8 -7
View File
@@ -71,10 +71,11 @@ pub(crate) enum Action {
/// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on /// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on
/// arbitrary cells, not the source object). Zero time cost. /// arbitrary cells, not the source object). Zero time cost.
Push { x: i32, y: i32, dir: Direction }, Push { x: i32, y: i32, dir: Direction },
/// Apply a batch of one-way solid moves simultaneously (read-all, then /// Shift a set of cells, given as `(x, y)` coordinates: each cell moves to the
/// write-all, so cycles/swaps work). Each tuple is `(src_x, src_y, dst_x, /// next coordinate in the list, unless it can't move, or that cell is blocked.
/// dst_y)`. Zero time cost. /// Rotates the contents of the last cell back to the beginning.
Swap(Vec<(i32, i32, i32, i32)>), /// Zero time cost.
Shift(Vec<(i32, i32)>),
/// Add `n` to the player's gem count (negative subtracts; the count is /// Add `n` to the player's gem count (negative subtracts; the count is
/// clamped at 0). Zero time cost. Applied to `GameState::player_gems`. /// clamped at 0). Zero time cost. Applied to `GameState::player_gems`.
AddGems(i64), AddGems(i64),
@@ -182,9 +183,9 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
m.insert("y".into(), Dynamic::from(*y as i64)); m.insert("y".into(), Dynamic::from(*y as i64));
m.insert("dir".into(), ds(dir_to_str(*dir))); m.insert("dir".into(), ds(dir_to_str(*dir)));
} }
// Swap pairs are not inspectable via the queue map API; emit type only. // Shift cells are not inspectable via the queue map API; emit type only.
Action::Swap(_) => { Action::Shift(_) => {
m.insert("type".into(), ds("Swap")); m.insert("type".into(), ds("Shift"));
} }
Action::AddGems(n) => { Action::AddGems(n) => {
m.insert("type".into(), ds("AddGems")); m.insert("type".into(), ds("AddGems"));
+1 -1
View File
@@ -157,7 +157,7 @@ impl Archetype {
pub fn default_glyph(&self) -> Glyph { pub fn default_glyph(&self) -> Glyph {
match self { match self {
Archetype::Empty => Glyph { Archetype::Empty => Glyph {
tile: 32, tile: 0,
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}, },
+64 -146
View File
@@ -96,6 +96,15 @@ impl Board {
&mut self.layers[z].cells[y * w + x] &mut self.layers[z].cells[y * w + x]
} }
/// Replace the solid (if any) at `(x, y)` with `Empty`
pub fn clear_solid(&mut self, x: usize, y: usize) {
if self.in_bounds((x as i32, y as i32)) {
if let Some(z) = self.solid_cell_layer(x, y) {
*self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty)
}
}
}
/// Returns the glyph to display at `(x, y)`, honoring layer draw order. /// Returns the glyph to display at `(x, y)`, honoring layer draw order.
/// ///
/// The player is always drawn on top (it is not part of the layer stack yet). /// The player is always drawn on top (it is not part of the layer stack yet).
@@ -524,165 +533,74 @@ impl Board {
self.add_object(obj); self.add_object(obj);
} }
} }
/// Applies a batch of one-way solid moves **simultaneously**, returning any /// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script
/// nonfatal error lines (out-of-bounds entries / a write blocked by the player). /// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log.
/// pub fn apply_shift(&mut self, cells: &[(i32, i32)]) -> Vec<LogLine> {
/// Each tuple is `(src_x, src_y, dst_x, dst_y)`: the solid occupant of `(src_x, // Validate all the cells are in bounds, error if not:
/// src_y)` — the player, a solid object, or a terrain crate/wall — moves to if cells.iter().any(|&c| !self.in_bounds(c)) {
/// `(dst_x, dst_y)`. A source with no solid moves an "empty", which **removes** return vec![LogLine::error("Called shift() with a cell out of bounds")]
/// 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).
///
/// A solid refused onto the player is left at its source cell, which another
/// entry may also target; a final sweep over the swapped cells resolves any
/// such **overlap** — it keeps one solid, deletes the rest (never the player),
/// and logs an error per deletion.
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 // Get all the Solids at these cells:
// source before any write is what lets cycles/swaps resolve. `None` is an let solids: Vec<_> = cells.iter().map(|&c| self.solid_at(c.0 as usize, c.1 as usize)).collect();
// empty source (the old `SolidSnapshot::Empty`).
let mut snapshots: HashMap<(i32, i32), Option<Solid>> = HashMap::new();
for &(src, _) in &valid {
snapshots.entry(src).or_insert_with(|| {
self.solid_at(src.0 as usize, src.1 as usize)
});
}
// 3. Compute the final occupant of every affected cell. A source that is not // Tell whether these are normally pushable in this direction. This doesn't count whether
// anyone's destination is vacated (None); each entry writes its source's // their target zone will be empty, just whether they would be willing to move that way at
// snapshot into its destination (a later entry wins a repeated destination). // all. The direction comes in because of hcrates / vcrates: if their target cell is adjacent,
let dsts: HashSet<(i32, i32)> = valid.iter().map(|&(_, d)| d).collect(); // then we'll check directions
let mut final_state: HashMap<(i32, i32), Option<Solid>> = HashMap::new(); //let mut pushable: Vec<bool> = Vec::with_capacity(solids.len());
for &(src, _) in &valid {
if !dsts.contains(&src) {
final_state.insert(src, None);
}
}
for &(src, dst) in &valid {
final_state.insert(dst, snapshots[&src]);
}
// The player's final cell: where a Player snapshot is installed, else its let mut immobile = HashSet::new();
// current cell (it stays put). Used to protect the player from being for (curr_idx, curr) in solids.iter().enumerate() {
// overwritten — computed up front so it's stable across the write loop. let origin = cells[curr_idx];
let player_final = final_state let target = cells[(curr_idx + 1) % cells.len()];
.iter()
.find(|(_, s)| s.is_some_and(|s| s.player()))
.map(|(&c, _)| c)
.unwrap_or((self.player.x, self.player.y));
// 4a. Clear each affected cell's current occupant, despawning any object that // Whether these represent a single-cell h or v move.
// doesn't survive (isn't reused in final_state). The player and surviving let hmove = target.1 == origin.1 && (target.0 - origin.0).abs() == 1;
// objects keep their entity and are repositioned by the install step. let vmove = target.0 == origin.0 && (target.1 - origin.1).abs() == 1;
let survivors: HashSet<ObjectId> = final_state
.values() // We are never allowed to push a thing that won't push
.filter_map(|s| s.and_then(|s| s.object_id())) // We won't push a horizontal-only thing vertically
.collect(); // We won't push a vertical-only thing horizontally
let affected: HashSet<(i32, i32)> = snapshots let pushable = curr.as_ref().map_or(Pushable::Any, |c| c.pushable());
.keys() if pushable == Pushable::No ||
.copied() pushable == Pushable::Vertical && hmove ||
.chain(final_state.keys().copied()) pushable == Pushable::Horizontal && vmove {
.collect(); immobile.insert(curr_idx);
for &(cx, cy) in &affected {
let (ux, uy) = (cx as usize, cy as usize);
match self.solid_at(ux, uy) {
Some(s) if s.object_id().is_some_and(|id| !survivors.contains(&id)) => {
self.remove_object(s.object_id().unwrap());
}
Some(s) if s.archetype().is_some() => {
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. // Trace back from each immobile until we find an empty:
for (&(cx, cy), snap) in &final_state { let mut blocked = HashSet::new();
let (ux, uy) = (cx as usize, cy as usize); for curr_idx in immobile {
// Empty cells were already cleared in phase 4a. let mut prev_idx = curr_idx;
let Some(solid) = snap else { continue }; loop {
// The player wins its cell: never overwrite player_final with anything if solids[prev_idx].is_some() && !blocked.contains(&prev_idx) {
// other than the player itself. A refused solid stays at its source blocked.insert(prev_idx);
// cell; the overlap sweep below cleans up if that cell is now also prev_idx = (prev_idx + cells.len() - 1) % cells.len();
// someone else's destination. } else {
if (cx, cy) == player_final && !solid.player() { break
errors.push(LogLine::error(format!(
"swap: cannot overwrite the player at ({cx},{cy})"
)));
continue;
}
solid.place(self, ux, uy);
}
// 5. Overlap sweep: a solid refused onto the player (above) stays at its
// source cell, which may now also be a destination another entry wrote
// into. For any swapped cell holding more than one solid, keep a single
// occupant and delete the rest (never the player). Logs an error per
// deletion.
for &(cx, cy) in &affected {
let (ux, uy) = (cx as usize, cy as usize);
let player_here = self.player.x == cx && self.player.y == cy;
// Solid objects on this cell; keep the first, delete the rest.
let objs: Vec<ObjectId> = self
.objects
.iter()
.filter(|(_, o)| o.x == ux && o.y == uy && o.solid)
.map(|(&id, _)| id)
.collect();
let has_terrain = self.solid_cell_layer(ux, uy).is_some();
// How many solids share the cell (player + solid objects + solid terrain).
let total = player_here as usize + objs.len() + has_terrain as usize;
if total <= 1 {
continue;
}
errors.push(LogLine::error(format!(
"swap: {total} solids overlap at ({cx},{cy}); deleting extras"
)));
// Pick the survivor (priority: player > first object > terrain) and
// delete every other solid. The player is only ever a survivor, so it
// is never deleted. `keep_obj` is the object we keep, if any.
let keep_obj = (!player_here).then(|| objs.first().copied()).flatten();
for id in &objs {
if Some(*id) != keep_obj {
self.remove_object(*id);
} }
} }
// Clear terrain unless it's the sole survivor (no player, no kept object). }
if has_terrain
&& (player_here || keep_obj.is_some()) // Clear all the cells so everything can be placed:
&& let Some(z) = self.solid_cell_layer(ux, uy) for (curr_idx, curr) in cells.iter().enumerate() {
{ if !blocked.contains(&curr_idx) {
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty); self.clear_solid(curr.0 as usize, curr.1 as usize);
} }
} }
errors // Now, move anything that we've decided is not blocked:
for (curr_idx, curr) in solids.iter().enumerate() {
if let Some(solid) = curr && !blocked.contains(&curr_idx) {
let target = cells[(curr_idx + 1) % cells.len()];
solid.place(self, target.0 as usize, target.1 as usize);
}
}
vec![]
} }
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty` /// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
+3 -4
View File
@@ -308,10 +308,9 @@ impl GameState {
board.push(x as usize, y as usize, dir); board.push(x as usize, y as usize, dir);
} }
} }
// apply_swap reads all sources then writes all destinations, so a // apply_shift moves the named cells.
// whole batch of moves resolves simultaneously (cycles included). Action::Shift(cells) => {
Action::Swap(pairs) => { logs.extend(board.apply_shift(&cells));
logs.extend(board.apply_swap(&pairs));
} }
// Accumulated and applied to `self.player_gems` after the borrow drops. // Accumulated and applied to `self.player_gems` after the borrow drops.
Action::AddGems(n) => gem_delta += n, Action::AddGems(n) => gem_delta += n,
+23 -25
View File
@@ -1107,37 +1107,35 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
}, },
); );
// swap(pairs): apply a batch of one-way solid moves simultaneously. `pairs` is // shift([[x, y], ...): Emits a shift action which moves the contents of each given cell in
// an array whose elements are four-int arrays `[src_x, src_y, dst_x, dst_y]`. A // a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable,
// malformed entry is skipped with a logged error. Zero time cost. // and won't move anything into a cell that's not vacant (or vacated by this shift).
let q = queues.clone(); let q = queues.clone();
engine.register_fn("swap", move |ctx: NativeCallContext, arr: Array| { engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| {
let src = source_of(&ctx); let src = source_of(&ctx);
let mut pairs: Vec<(i32, i32, i32, i32)> = Vec::new(); match read_coord_array(&arr) {
for elem in &arr { Ok(pairs) => emit(&q, src, Action::Shift(pairs)),
// Each entry must be an array of exactly four integers. Err(_) => emit(&q, src, Action::Log(LogLine::error("shift: each entry must be [x, y]".to_string())))
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));
}); });
} }
/// Read a `Vec<(i32, i32)>` from a Rhai array, to receive a list of coordinates from a script.
/// Returns Err if the array isn't `[[x, y], ...]`
fn read_coord_array(arr: &Array) -> Result<Vec<(i32, i32)>, ()> {
let mut pairs: Vec<(i32, i32)> = Vec::new();
for elem in arr {
let coords: Option<Vec<i64>> = elem.read_lock::<Array>().and_then(|inner| {
inner.iter().map(|v| v.as_int().ok()).collect()
});
if let Some(coords) = coords && coords.len() == 2 {
pairs.push((coords[0] as i32, coords[1] as i32))
} else {
return Err(());
}
}
Ok(pairs)
}
// ── Queue API ───────────────────────────────────────────────────────────────── // ── Queue API ─────────────────────────────────────────────────────────────────
fn register_queue_api(engine: &mut Engine) { fn register_queue_api(engine: &mut Engine) {
+20 -70
View File
@@ -16,30 +16,30 @@ fn tick(dt) {
// Direction from the builtin tag; clockwise unless explicitly counter-clockwise. // Direction from the builtin tag; clockwise unless explicitly counter-clockwise.
let cw = !Me.has_tag("BUILTIN_spinner_ccw"); 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. // The 8 neighbour offsets in clockwise order, starting at North.
let ring = [ let cw_ring = [
[ 0, -1], // N [Me.x, Me.y-1], // N
[ 1, -1], // NE [Me.x+1, Me.y-1], // NE
[ 1, 0], // E [Me.x+1, Me.y], // E
[ 1, 1], // SE [Me.x+1, Me.y+1], // SE
[ 0, 1], // S [Me.x, Me.y+1], // S
[-1, 1], // SW [Me.x-1, Me.y+1], // SW
[-1, 0], // W [Me.x-1, Me.y], // W
[-1, -1], // NW [Me.x-1, Me.y-1], // NW
]; ];
// The cardinal step that carries each ring cell to the *next* slot in the spin // For CCW, it's the same list in reverse
// direction, and the index offset to that slot (+1 clockwise, -1 == +7 counter). let ccw_ring = [];
// CW: N->NE is East, NE->E is South, ... CCW: N->NW is West, NE->N is West, ... for neighbor in cw_ring {
let dirs = if cw { ccw_ring.insert(0, neighbor);
[East, South, South, West, West, North, North, East] }
// Rotate the neighbor cells:
if cw {
shift(cw_ring);
} else { } else {
[West, West, North, North, East, East, South, South] shift(ccw_ring);
}; }
let step = if cw { 1 } else { 7 };
// Animate the glyph one frame per rotation (changing only the character): the // Animate the glyph one frame per rotation (changing only the character): the
// line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in // line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in
@@ -50,55 +50,5 @@ fn tick(dt) {
set_tile(frames[f % 4]); set_tile(frames[f % 4]);
Registry.set(fkey, (f + 1) % 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 pn = (i + step - 1) % 8;
let dx = ox + ring[n][0];
let dy = oy + ring[n][1];
let sx = ox + ring[pn][0];
let sy = oy + ring[pn][1];
let blocked = !passable(dx, dy) && !combinable(sx, sy, dx, dy);
if moves[i] && blocked && !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); delay(0.5);
} }
+15 -2
View File
@@ -167,6 +167,18 @@ fn tick(dt) {
} }
""" """
shifter = """
fn tick(dt) {
let x = Me.x;
let y = Me.y;
if Queue.length() == 0 {
shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]);
delay(0.25);
}
}
"""
[boards.start.map] [boards.start.map]
name = "Starting Room" name = "Starting Room"
width = 60 width = 60
@@ -214,8 +226,8 @@ gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
content = """ content = """
############################################################ ############################################################
# # # #
# # # o #
# # # oS #
# # # #
# 1 # # 1 #
# ###### B # # ###### B #
@@ -252,6 +264,7 @@ content = """
"V" = { kind = "object", tile = 1, fg = "#33aa33", bg = "#000000", script_name = "mover" } "V" = { kind = "object", tile = 1, fg = "#33aa33", bg = "#000000", script_name = "mover" }
"M" = { kind = "object", tile = 15, fg = "#ffdd88", bg = "#000000", solid = true, name = "muffin", script_name = "muffin" } "M" = { kind = "object", tile = 15, fg = "#ffdd88", bg = "#000000", solid = true, name = "muffin", script_name = "muffin" }
"B" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "noticeboard", script_name = "noticeboard" } "B" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "noticeboard", script_name = "noticeboard" }
"S" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "shifter", script_name = "shifter" }
"/" = { kind = "spinner_cw" } "/" = { kind = "spinner_cw" }
"t" = { kind = "gem" } "t" = { kind = "gem" }