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
+64 -146
View File
@@ -96,6 +96,15 @@ impl Board {
&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.
///
/// 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);
}
}
/// 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).
///
/// 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)));
/// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script
/// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log.
pub fn apply_shift(&mut self, cells: &[(i32, i32)]) -> Vec<LogLine> {
// Validate all the cells are in bounds, error if not:
if cells.iter().any(|&c| !self.in_bounds(c)) {
return vec![LogLine::error("Called shift() with a cell out of bounds")]
}
// 2. Snapshot the solid at each unique source (read phase). Reading every
// source before any write is what lets cycles/swaps resolve. `None` is an
// 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)
});
}
// Get all the Solids at these cells:
let solids: Vec<_> = cells.iter().map(|&c| self.solid_at(c.0 as usize, c.1 as usize)).collect();
// 3. Compute the final occupant of every affected cell. A source that is not
// anyone's destination is vacated (None); 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), Option<Solid>> = HashMap::new();
for &(src, _) in &valid {
if !dsts.contains(&src) {
final_state.insert(src, None);
}
}
for &(src, dst) in &valid {
final_state.insert(dst, snapshots[&src]);
}
// Tell whether these are normally pushable in this direction. This doesn't count whether
// their target zone will be empty, just whether they would be willing to move that way at
// all. The direction comes in because of hcrates / vcrates: if their target cell is adjacent,
// then we'll check directions
//let mut pushable: Vec<bool> = Vec::with_capacity(solids.len());
// 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)| s.is_some_and(|s| s.player()))
.map(|(&c, _)| c)
.unwrap_or((self.player.x, self.player.y));
let mut immobile = HashSet::new();
for (curr_idx, curr) in solids.iter().enumerate() {
let origin = cells[curr_idx];
let target = cells[(curr_idx + 1) % cells.len()];
// 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| s.and_then(|s| s.object_id()))
.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(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);
}
}
_ => {}
// Whether these represent a single-cell h or v move.
let hmove = target.1 == origin.1 && (target.0 - origin.0).abs() == 1;
let vmove = target.0 == origin.0 && (target.1 - origin.1).abs() == 1;
// We are never allowed to push a thing that won't push
// We won't push a horizontal-only thing vertically
// We won't push a vertical-only thing horizontally
let pushable = curr.as_ref().map_or(Pushable::Any, |c| c.pushable());
if pushable == Pushable::No ||
pushable == Pushable::Vertical && hmove ||
pushable == Pushable::Horizontal && vmove {
immobile.insert(curr_idx);
}
}
// 4b. Install each cell's computed occupant.
for (&(cx, cy), snap) in &final_state {
let (ux, uy) = (cx as usize, cy as usize);
// Empty cells were already cleared in phase 4a.
let Some(solid) = snap else { continue };
// The player wins its cell: never overwrite player_final with anything
// other than the player itself. A refused solid stays at its source
// cell; the overlap sweep below cleans up if that cell is now also
// someone else's destination.
if (cx, cy) == player_final && !solid.player() {
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);
// Trace back from each immobile until we find an empty:
let mut blocked = HashSet::new();
for curr_idx in immobile {
let mut prev_idx = curr_idx;
loop {
if solids[prev_idx].is_some() && !blocked.contains(&prev_idx) {
blocked.insert(prev_idx);
prev_idx = (prev_idx + cells.len() - 1) % cells.len();
} else {
break
}
}
// Clear terrain unless it's the sole survivor (no player, no kept object).
if has_terrain
&& (player_here || keep_obj.is_some())
&& let Some(z) = self.solid_cell_layer(ux, uy)
{
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
}
// Clear all the cells so everything can be placed:
for (curr_idx, curr) in cells.iter().enumerate() {
if !blocked.contains(&curr_idx) {
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`