import type { GameWorld } from "../src/game/world/world"; import type { Direction } from "../src/game/world/types"; /** Drive a world through scenes the way a player would: acknowledge pages, * answer choices in order (default YES, the first weekday or menu item, or * the clock's shown value) and wait out timed * effects. Returns every page that was shown. */ export function settle( world: GameWorld, answers: (number | string)[] = [], ): string[] { const shown: string[] = []; let lastHeld = ""; for (let tick = 0; tick < 20_000; tick++) { const dialogue = world.dialogue; if (dialogue) { const page = dialogue.pages[dialogue.index]; const last = dialogue.index === dialogue.pages.length - 1; if (last && dialogue.choice) { shown.push(page); // The naming screen waits for the caller unless a name is queued. if (dialogue.choice.kind === "name") { if (typeof answers[0] !== "string") return shown; world.choose(answers.shift()!); continue; } const answer = answers.shift(); world.choose( typeof answer === "number" ? answer : dialogue.choice.kind === "yesno" ? 1 : dialogue.choice.kind === "clock" ? dialogue.choice.selected : 0, ); } else if (last && dialogue.holdTicks !== undefined) { if (page !== lastHeld) shown.push(page); lastHeld = page; world.tick(); } else { shown.push(page); world.advanceDialogue(); } continue; } lastHeld = ""; if (!world.busy) { // Scene scripts start on an idle tick. world.tick(); if (!world.busy) return shown; continue; } world.tick(); } throw new Error("The world did not settle."); } const DIRECTIONS = [ ["up", 0, -1], ["down", 0, 1], ["left", -1, 0], ["right", 1, 0], ] as const; /** .ledge_table's side-wall directions (GetMovementPermissions): side * walls ($b0) and buoys ($c0) block these directions. */ const EDGES: readonly (readonly Direction[])[] = [ ["right"], ["left"], ["up"], ["down"], ["down", "right"], ["down", "left"], ["up", "right"], ["up", "left"], ]; const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" } as const; const edges = (tile: number): readonly Direction[] => (tile & 0xf0) === 0xb0 || (tile & 0xf0) === 0xc0 ? EDGES[tile & 7] : []; /** CheckWarpCollision's own two hazard nybbles ($6x pits, $7x doors/ * stairs/carpets): a tile only needs avoiding here if stepping onto it * could actually trigger a warp. Burned Tower B1F's fall/ladder warps * (e.g. the coordinates `warpcheck` and a `changeblock`-revealed ladder * read) sit on ordinary $00 floor and never fire from an ordinary step, * so they are left walkable like any other floor tile. */ const isWarpHazard = (tile: number) => (tile & 0xf0) === 0x60 || (tile & 0xf0) === 0x70; /** COLL_WATER and the unused COLL_WATER_21: open water a surfer crosses * freely, unlike currents, waterfalls and whirlpools. */ const isOpenWater = (tile: number) => tile === 0x29 || tile === 0x21; /** Shortest walkable path on the current map, avoiding other warps. * `water` also crosses open water, for a player who can Surf. */ export function findPath( world: GameWorld, targetX: number, targetY: number, { water = false } = {}, ): Direction[] { const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }]; const visited = new Set(); for (let i = 0; i < queue.length; i++) { const node = queue[i], key = `${node.x},${node.y}`; if (visited.has(key)) continue; visited.add(key); if (node.x === targetX && node.y === targetY) return node.path; for (const [direction, dx, dy] of DIRECTIONS) { const x = node.x + dx, y = node.y + dy; if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) continue; const tile = world.assets.collision(world.map, x, y); if (world.assets.permission(tile) !== 0 && !(water && isOpenWater(tile))) continue; if ( edges(world.assets.collision(world.map, node.x, node.y)).includes(direction) || edges(tile).includes(OPPOSITE[direction]) ) continue; if (world.objects().some((object) => object.x === x && object.y === y)) continue; if ( isWarpHazard(tile) && world.map.warps.some((warp) => warp.x === x && warp.y === y) && (x !== targetX || y !== targetY) ) continue; queue.push({ x, y, path: [...node.path, direction] }); } } throw new Error(`No route to ${targetX},${targetY}`); } /** Walk to a tile, settling any scene that a step triggers. A roaming NPC * that steps into the way is waited out, then the route is planned again. */ export function walkTo( world: GameWorld, targetX: number, targetY: number, answers: (number | string)[] = [], ): string[] { // Finish anything in progress first, such as the tick after a warp. const shown: string[] = settle(world, answers); for (let attempt = 0; attempt < 20; attempt++) { let blocked = false; for (const direction of findPath(world, targetX, targetY)) { if (!world.move(direction)) { const [, dx, dy] = DIRECTIONS.find(([name]) => name === direction)!; const blocker = world .objects() .some((object) => object.x === world.x + dx && object.y === world.y + dy); if (!blocker) throw new Error( `Blocked moving ${direction} at ${world.x},${world.y}: ${world.notice}`, ); for (let tick = 0; tick < 64; tick++) world.tick(); blocked = true; break; } shown.push(...settle(world, answers)); } // A warp target leaves the map, so a completed path is the success test. if (!blocked) return shown; } throw new Error(`Could not reach ${targetX},${targetY}`); } /** A save without the play time, which keeps counting while refused or * rolled-back actions leave everything else unchanged. */ export function withoutPlayTime(save: T) { const { gameTime: _, ...rest } = save; return rest; } /** Restoring a save always moves the roam-mon bookkeeping to the map just * loaded (Continue's own farcall JumpRoamMons, then _BackUpMapIndices), * even before any roaming Pokémon has been released; a round-trip * comparison needs `reloaded`'s own `roamMons`/`roamMonsMap` in place of * `saved`'s to still match everything else exactly. */ export function afterRestore< T extends { roamMons?: unknown; roamMonsMap?: unknown }, >(saved: T, reloaded: T) { return { ...saved, roamMons: reloaded.roamMons, roamMonsMap: reloaded.roamMonsMap }; }