Files
systemic-game-design-course/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs
T
Lucas WintherandClaude Fable 5.2 8467c7929e fix(puzzle): an object steps once per movement phase; negated conversion is settled per object
Bug 1 (modules 08-10): a YOU object pulled by an earlier mover took a second step of its own. Level movement now keeps the ids that stepped in a committed plan this phase; such a YOU object starts no move, and Planner try_move answers no for it. Regression: test_08x_pulled_you_moves_once (Module 8 Expansion Drop).

Bug 2 (modules 09-10): convert honoured negation only per rule, so ALL IS ROCK beat BABA IS NOT ROCK. A target Y is now skipped for an object if any active negated rule IS NOT Y names it. Module 9 and 10 references (and their drop solutions) now carry ALL, the Module 7 Expansion Drop. Regression: test_09_negated_conversion_beats_group.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 09:18:00 +01:00

144 lines
4.8 KiB
Rust

//! Movement: validate, then commit.
//!
//! A move is planned before anything on the board changes. The `Planner` asks
//! whether one object can step one tile; to answer, it runs every movement
//! response content provides for that step, and a response may ask the planner
//! to move other objects too (a push), or to destroy objects. If any response
//! says no, the planner forgets everything that step added to the plan, so a
//! failed push leaves no trace. Only a plan that succeeded is committed.
//!
//! The engine knows the board, its edge, and the order of the steps. What each
//! property does when something moves (`PUSH`, `STOP`, `OPEN`/`SHUT`, ...) is a
//! response in content, run in the order content lists them.
use std::collections::BTreeSet;
use super::grid::ObjectId;
use super::level::Level;
/// One of the four directions a move can take.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dir {
Up,
Down,
Left,
Right,
}
impl Dir {
/// One tile in this direction, as `(dx, dy)`; `y` grows down.
pub fn delta(self) -> (i32, i32) {
match self {
Dir::Up => (0, -1),
Dir::Down => (0, 1),
Dir::Left => (-1, 0),
Dir::Right => (1, 0),
}
}
}
/// One object stepping one tile, from `from` to `to`, in direction `dir`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Step {
pub mover: ObjectId,
pub from: (i32, i32),
pub to: (i32, i32),
pub dir: Dir,
}
/// A movement response: content's rule for what happens when an object steps.
/// It returns `false` to block the step. It may plan other moves or destroy
/// objects through the planner.
pub type Response = fn(&mut Planner<'_>, Step) -> bool;
/// What a move will do: the objects that step one tile, and the objects destroyed.
/// A destroyed object doesn't step, even if it was pushed.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Plan {
pub moving: BTreeSet<ObjectId>,
pub destroyed: BTreeSet<ObjectId>,
}
/// Plans one move on a board it only reads. `stepped` holds the objects that have
/// already stepped in this movement phase, in an earlier committed plan: an object
/// steps at most once per phase, so none of them can step again.
pub struct Planner<'a> {
level: &'a Level,
dir: Dir,
stepped: &'a BTreeSet<ObjectId>,
plan: Plan,
}
impl<'a> Planner<'a> {
pub fn new(level: &'a Level, dir: Dir, stepped: &'a BTreeSet<ObjectId>) -> Planner<'a> {
Planner {
level,
dir,
stepped,
plan: Plan::default(),
}
}
/// Does object `id` have `property`, under the rules in force before the move?
pub fn has(&self, id: ObjectId, property: &str) -> bool {
self.level.has_property(id, property)
}
/// The objects that will still be on tile `at` when the plan so far is
/// committed: those there now, less those planned to move away or be destroyed.
/// Oldest first.
pub fn staying_at(&self, (x, y): (i32, i32)) -> Vec<ObjectId> {
self.level
.board()
.at(x, y)
.map(|o| o.id)
.filter(|id| !self.plan.moving.contains(id) && !self.plan.destroyed.contains(id))
.collect()
}
/// Plan object `id`'s destruction.
pub fn destroy(&mut self, id: ObjectId) {
self.plan.destroyed.insert(id);
}
/// Plan object `id` stepping one tile in the move's direction, with whatever
/// the responses add. Returns whether it can. If it can't, the plan is left as
/// it was before the call. An object already planned to move, or to be
/// destroyed, is out of the way: the answer is yes, and nothing is added. An
/// object that has already stepped in this movement phase can't step again: the
/// answer is no.
pub fn try_move(&mut self, id: ObjectId) -> bool {
if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) {
return true;
}
if self.stepped.contains(&id) {
return false;
}
let level = self.level;
let o = level.board().get(id);
let (dx, dy) = self.dir.delta();
let to = (o.x + dx, o.y + dy);
// The edge of the level blocks every step (the course's choice; see the lesson).
if !level.board().in_bounds(to.0, to.1) {
return false;
}
let before = self.plan.clone();
self.plan.moving.insert(id);
let step = Step {
mover: id,
from: (o.x, o.y),
to,
dir: self.dir,
};
let allowed = level.responses().iter().all(|respond| respond(self, step));
if !allowed {
self.plan = before;
}
allowed
}
pub fn into_plan(self) -> Plan {
self.plan
}
}