spinners and gems
This commit is contained in:
+241
-10
@@ -6,6 +6,7 @@ use crate::object_def::ObjectDef;
|
||||
use crate::utils::Direction;
|
||||
use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use crate::builtin_scripts::archetype_script_key;
|
||||
|
||||
/// 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).
|
||||
@@ -234,6 +235,38 @@ impl Board {
|
||||
self.solid_at(x, y).is_none()
|
||||
}
|
||||
|
||||
/// Returns `true` if the mover as `(x1, y1)` can enter `(x2, y2)` — i.e. it would not
|
||||
/// break the rules of "one solid per cell, except for player + grab".
|
||||
///
|
||||
/// Convenience inverse of [`solid_at`](Board::solid_at).
|
||||
/// Panics if `x` or `y` are out of bounds.
|
||||
pub fn is_combinable(&self, x1: usize, y1: usize, x2: usize, y2: usize) -> bool {
|
||||
// Are either out of bounds?
|
||||
if !self.in_bounds((x1 as i32, y1 as i32)) || !self.in_bounds((x2 as i32, y2 as i32)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Grab the solids
|
||||
let solid1 = self.solid_at(x1, y1);
|
||||
let solid2 = self.solid_at(x2, y2);
|
||||
|
||||
// Is one cell empty?
|
||||
if solid1.is_none() || solid2.is_none() { return true }
|
||||
|
||||
// They're both present, unwrap them:
|
||||
let solid1 = solid1.unwrap();
|
||||
let solid2 = solid2.unwrap();
|
||||
|
||||
// This is probably disallowed then, but let's check for a player coexisting with a grab:
|
||||
if solid1.player() && solid2.grab(self) ||
|
||||
solid2.player() && solid1.grab(self) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Nope, two solids that can't coexist:
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
|
||||
///
|
||||
/// Non-solid things are never pushable: `pushable` only matters for solids.
|
||||
@@ -287,6 +320,12 @@ impl Board {
|
||||
/// [`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.
|
||||
///
|
||||
/// Shifting onto the **player** is allowed only when the source is a
|
||||
/// [`grab`](crate::object_def::ObjectDef::grab) thing: it isn't shoving the
|
||||
/// player aside (a shift can't relocate the player, and `apply_swap` would
|
||||
/// refuse to overwrite it), it's being grabbed — the player stays put and the
|
||||
/// thing despawns. Any other solid treats the player as a blocker.
|
||||
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) {
|
||||
@@ -298,6 +337,11 @@ impl Board {
|
||||
return false; // nothing to shift into off the board
|
||||
}
|
||||
let (nx, ny) = (next.0 as usize, next.1 as usize);
|
||||
// Shifting onto the player is only legitimate for a grab thing (it gets
|
||||
// grabbed, the player isn't moved); otherwise the player is a blocker.
|
||||
if matches!(self.solid_at(nx, ny), Some(Solid::Player)) {
|
||||
return self.grab_object_at(x, y).is_some();
|
||||
}
|
||||
// The cell ahead is acceptable if it is empty or another pushable solid.
|
||||
self.is_passable(nx, ny) || self.is_pushable(nx, ny, dir)
|
||||
}
|
||||
@@ -370,6 +414,50 @@ impl Board {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the [`ObjectId`] of a solid, **grab**bable object at `(x, y)`, if any.
|
||||
///
|
||||
/// Used by [`GameState::try_move`](crate::game::GameState::try_move) to detect
|
||||
/// the player walking onto a grab thing (e.g. a gem): the move isn't blocked,
|
||||
/// the object's `grab()` hook fires instead.
|
||||
pub fn grab_object_at(&self, x: usize, y: usize) -> Option<ObjectId> {
|
||||
self.objects.iter().find_map(|(&id, o)| {
|
||||
if o.x == x && o.y == y && o.solid && o.grab {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the [`ObjectId`] of a **grab** object that a push at `(x, y)` in
|
||||
/// `dir` would shove into the player, if any.
|
||||
///
|
||||
/// Walks the pushable chain from `(x, y)`; if a chain cell holds a grab object
|
||||
/// whose immediate forward neighbour is the player, that object is returned.
|
||||
/// This is the "grab thing pushed into the player" case: rather than sliding
|
||||
/// the player along, the caller grabs the thing (see
|
||||
/// [`GameState`](crate::game::GameState)). Returns `None` for an ordinary push.
|
||||
pub fn pushed_grab_into_player(&self, x: usize, y: usize, dir: Direction) -> Option<ObjectId> {
|
||||
let (dx, dy): (i32, i32) = dir.into();
|
||||
let (mut cx, mut cy) = (x, y);
|
||||
// Advance through the contiguous run of pushable solids.
|
||||
while self.is_pushable(cx, cy, dir) {
|
||||
let next = (cx as i32 + dx, cy as i32 + dy);
|
||||
if !self.in_bounds(next) {
|
||||
return None;
|
||||
}
|
||||
let (nx, ny) = (next.0 as usize, next.1 as usize);
|
||||
// If the next cell is the player and this cell is a grab object, the
|
||||
// push would drive that grab thing into the player: grab it instead.
|
||||
if matches!(self.solid_at(nx, ny), Some(Solid::Player)) {
|
||||
return self.grab_object_at(cx, cy);
|
||||
}
|
||||
cx = nx;
|
||||
cy = ny;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id.
|
||||
///
|
||||
/// Ids start at 1 and increase monotonically; an id is never reused, so it
|
||||
@@ -467,8 +555,13 @@ impl Board {
|
||||
obj.glyph = glyph;
|
||||
obj.solid = b.solid;
|
||||
obj.opaque = b.opaque;
|
||||
obj.pushable = false;
|
||||
// Carry the archetype's pushability/grab onto the object (pushers and
|
||||
// spinners are Pushable::No, so they stay unpushable; gems are pushable
|
||||
// and grabbable).
|
||||
obj.pushable = b.pushable != crate::utils::Pushable::No;
|
||||
obj.grab = b.grab;
|
||||
obj.builtin_script = archetype_script(arch);
|
||||
obj.script_name = archetype_script_key(arch).map(|s| s.to_owned());
|
||||
obj.tags.insert(builtin_tag(arch));
|
||||
self.add_object(obj);
|
||||
}
|
||||
@@ -489,8 +582,21 @@ impl Board {
|
||||
/// [`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> {
|
||||
///
|
||||
/// **Grab:** a [`grab`](crate::object_def::ObjectDef::grab) object whose
|
||||
/// destination is the player isn't written onto the player — it is left where it
|
||||
/// is and its id is returned in the second tuple element so the caller can fire
|
||||
/// its `grab()` hook (it gets grabbed, the player isn't moved). Because a
|
||||
/// grabbed object stays put (and its `grab()` may not despawn it), it can end up
|
||||
/// sharing a cell with a solid that moved in; a final sweep over the swapped
|
||||
/// cells resolves any such **overlap** — it keeps one solid (preferring a
|
||||
/// grabbed object so its `grab()` can still fire), 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>, Vec<ObjectId>) {
|
||||
let mut errors = Vec::new();
|
||||
// Grab objects whose destination was the player: left in place here, their
|
||||
// grab() hooks fired by the caller (GameState).
|
||||
let mut grabbed: Vec<ObjectId> = Vec::new();
|
||||
|
||||
// 1. Validate: keep only entries whose source and destination are in bounds.
|
||||
let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new();
|
||||
@@ -572,6 +678,16 @@ impl Board {
|
||||
// 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) {
|
||||
// A grab object shoved onto the player is grabbed, not blocked: leave
|
||||
// it where it is (don't install onto the player) and report it so the
|
||||
// caller fires its grab() hook. The overlap sweep below cleans up if
|
||||
// its source cell is now also someone else's destination.
|
||||
if let SolidSnapshot::Object(id) = snap
|
||||
&& self.objects.get(id).is_some_and(|o| o.grab)
|
||||
{
|
||||
grabbed.push(*id);
|
||||
continue;
|
||||
}
|
||||
errors.push(LogLine::error(format!(
|
||||
"swap: cannot overwrite the player at ({cx},{cy})"
|
||||
)));
|
||||
@@ -595,7 +711,52 @@ impl Board {
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
// 5. Overlap sweep: a grabbed object left in place (above) may now share a
|
||||
// cell with a solid that moved in. For any swapped cell holding more than
|
||||
// one solid, keep a single occupant — a grabbed object if present (so its
|
||||
// grab() can still fire), otherwise whatever remains — 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, with grabbed ones first so we keep one.
|
||||
let mut objs: Vec<ObjectId> = self
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|(_, o)| o.x == ux && o.y == uy && o.solid)
|
||||
.map(|(&id, _)| id)
|
||||
.collect();
|
||||
objs.sort_by_key(|id| !grabbed.contains(id));
|
||||
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 > grabbed/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())
|
||||
&& let Some(z) = self.solid_cell_layer(ux, uy)
|
||||
{
|
||||
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
|
||||
}
|
||||
}
|
||||
|
||||
(errors, grabbed)
|
||||
}
|
||||
|
||||
/// Reads the solid occupant of `(x, y)` into a [`SolidSnapshot`] (for
|
||||
@@ -740,6 +901,27 @@ pub(crate) mod tests {
|
||||
assert!(!board.is_passable(2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grab_helpers_detect_a_gem() {
|
||||
// A grabbable gem object at (1,0); the player at (2,0).
|
||||
let mut gem = ObjectDef::new(1, 0);
|
||||
gem.grab = true;
|
||||
gem.pushable = true;
|
||||
let board = open_board(3, 1, (2, 0), vec![gem]);
|
||||
|
||||
// grab_object_at finds the gem on its own cell, nowhere else.
|
||||
assert_eq!(board.grab_object_at(1, 0), Some(1));
|
||||
assert_eq!(board.grab_object_at(0, 0), None);
|
||||
|
||||
// Pushing the gem east shoves it into the player → reported as a grab.
|
||||
assert_eq!(
|
||||
board.pushed_grab_into_player(1, 0, Direction::East),
|
||||
Some(1)
|
||||
);
|
||||
// Pushing it west (away from the player) is an ordinary push.
|
||||
assert_eq!(board.pushed_grab_into_player(1, 0, Direction::West), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_solid_object_does_not_block() {
|
||||
let mut obj = ObjectDef::new(1, 0);
|
||||
@@ -816,6 +998,22 @@ pub(crate) mod tests {
|
||||
assert!(!board.can_shift(1, 0, Direction::East));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_shift_into_player_only_for_a_grab_thing() {
|
||||
// A grab gem at (0,0) may shift east onto the player at (1,0): it gets
|
||||
// grabbed rather than blocked.
|
||||
let mut gem = ObjectDef::new(0, 0);
|
||||
gem.grab = true;
|
||||
gem.pushable = true;
|
||||
let board = open_board(2, 1, (1, 0), vec![gem]);
|
||||
assert!(board.can_shift(0, 0, Direction::East));
|
||||
|
||||
// A plain crate may not shift onto the player — the player blocks it.
|
||||
let mut board = open_board(2, 1, (1, 0), vec![]);
|
||||
crate_at(&mut board, 0, 0);
|
||||
assert!(!board.can_shift(0, 0, Direction::East));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_into_player_pushes_player() {
|
||||
// Crate shoved east into the player slides the player along into open space.
|
||||
@@ -992,7 +1190,7 @@ pub(crate) mod tests {
|
||||
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)]);
|
||||
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);
|
||||
@@ -1006,7 +1204,7 @@ pub(crate) mod tests {
|
||||
// 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)]);
|
||||
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);
|
||||
@@ -1017,7 +1215,7 @@ pub(crate) mod tests {
|
||||
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)]);
|
||||
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());
|
||||
@@ -1029,7 +1227,7 @@ pub(crate) mod tests {
|
||||
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)]);
|
||||
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);
|
||||
@@ -1039,7 +1237,7 @@ pub(crate) mod tests {
|
||||
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)]);
|
||||
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));
|
||||
@@ -1049,7 +1247,7 @@ pub(crate) mod tests {
|
||||
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)]);
|
||||
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
|
||||
}
|
||||
@@ -1059,9 +1257,42 @@ pub(crate) mod tests {
|
||||
// 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)]);
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_swap_grab_onto_player_is_reported_not_moved() {
|
||||
// A grab gem swapped onto the player is reported (for its grab() hook) and
|
||||
// left at its source rather than overwriting / sliding the player.
|
||||
let mut gem = ObjectDef::new(0, 0);
|
||||
gem.grab = true;
|
||||
gem.pushable = true;
|
||||
let mut board = open_board(2, 1, (1, 0), vec![gem]);
|
||||
let (errs, grabbed) = board.apply_swap(&[(0, 0, 1, 0)]);
|
||||
assert!(errs.is_empty());
|
||||
assert_eq!(grabbed, vec![1]);
|
||||
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // gem stayed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_swap_overlap_after_grab_deletes_the_other_solid() {
|
||||
// gem (0,0)→player (1,0) [grabbed, stays at (0,0)] while a crate (2,0)→(0,0)
|
||||
// moves into the gem's cell. The sweep keeps the grabbed gem and deletes the
|
||||
// crate, logging the overlap.
|
||||
let mut gem = ObjectDef::new(0, 0);
|
||||
gem.grab = true;
|
||||
gem.pushable = true;
|
||||
let mut board = open_board(3, 1, (1, 0), vec![gem]);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let (errs, grabbed) = board.apply_swap(&[(0, 0, 1, 0), (2, 0, 0, 0)]);
|
||||
assert_eq!(grabbed, vec![1]);
|
||||
assert_eq!(errs.len(), 1); // the overlap was logged
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // gem kept
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // crate deleted
|
||||
assert_eq!((board.player.x, board.player.y), (1, 0)); // player untouched
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user