Movement planned in full, then committed: a Planner builds a plan (moving and destroyed ids) by running content's movement responses (OPEN/SHUT, PUSH, STOP) for every step, rolling back failed branches. Edge of level blocks; YOU objects move furthest-ahead first; rules are reparsed once after movement. Harness: move/move_dir(Dir), posOf, kindsAt. Expansion Drop: PULL as one word plus one content response. Co-Authored-By: Claude Opus 5.5 <[email protected]>
122 lines
4.0 KiB
TypeScript
122 lines
4.0 KiB
TypeScript
// 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.
|
|
|
|
import type { Board, ObjectId } from "./grid";
|
|
|
|
/** One of the four directions a move can take. */
|
|
export type Dir = "up" | "down" | "left" | "right";
|
|
|
|
export type Pos = readonly [number, number];
|
|
|
|
/** One tile in direction `dir`, as `[dx, dy]`; `y` grows down. */
|
|
export function delta(dir: Dir): Pos {
|
|
switch (dir) {
|
|
case "up":
|
|
return [0, -1];
|
|
case "down":
|
|
return [0, 1];
|
|
case "left":
|
|
return [-1, 0];
|
|
case "right":
|
|
return [1, 0];
|
|
}
|
|
}
|
|
|
|
/** One object stepping one tile, from `from` to `to`, in direction `dir`. */
|
|
export interface Step {
|
|
mover: ObjectId;
|
|
from: Pos;
|
|
to: Pos;
|
|
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.
|
|
*/
|
|
export type Response = (p: Planner, s: Step) => boolean;
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export interface Plan {
|
|
moving: Set<ObjectId>;
|
|
destroyed: Set<ObjectId>;
|
|
}
|
|
|
|
/** What the planner reads: the board, the properties in force, the responses. */
|
|
export interface MoveContext {
|
|
readonly board: Board;
|
|
hasProperty(id: ObjectId, property: string): boolean;
|
|
responses(): readonly Response[];
|
|
}
|
|
|
|
/** Plans one move on a board it only reads. */
|
|
export class Planner {
|
|
private plan: Plan = { moving: new Set(), destroyed: new Set() };
|
|
|
|
constructor(
|
|
private readonly level: MoveContext,
|
|
private readonly dir: Dir,
|
|
) {}
|
|
|
|
/** Does object `id` have `property`, under the rules in force before the move? */
|
|
has(id: ObjectId, property: string): boolean {
|
|
return this.level.hasProperty(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.
|
|
*/
|
|
stayingAt([x, y]: Pos): ObjectId[] {
|
|
return this.level.board
|
|
.at(x, y)
|
|
.map((o) => o.id)
|
|
.filter((id) => !this.plan.moving.has(id) && !this.plan.destroyed.has(id));
|
|
}
|
|
|
|
/** Plan object `id`'s destruction. */
|
|
destroy(id: ObjectId): void {
|
|
this.plan.destroyed.add(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.
|
|
*/
|
|
tryMove(id: ObjectId): boolean {
|
|
if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true;
|
|
const o = this.level.board.get(id);
|
|
const [dx, dy] = delta(this.dir);
|
|
const to: Pos = [o.x + dx, o.y + dy];
|
|
// The edge of the level blocks every step (the course's choice; see the lesson).
|
|
if (!this.level.board.inBounds(...to)) return false;
|
|
const before: Plan = { moving: new Set(this.plan.moving), destroyed: new Set(this.plan.destroyed) };
|
|
this.plan.moving.add(id);
|
|
const step: Step = { mover: id, from: [o.x, o.y], to, dir: this.dir };
|
|
const allowed = this.level.responses().every((respond) => respond(this, step));
|
|
if (!allowed) this.plan = before;
|
|
return allowed;
|
|
}
|
|
|
|
result(): Plan {
|
|
return this.plan;
|
|
}
|
|
}
|