// 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; destroyed: Set; } /** 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; } }