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. */ /** RingTwice_StartCall's pages: the caller's name box and the cleared box * between rings. */ export const isRinging = (page: string) => page.startsWith("☎") || page === " "; 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) { // A received call's ringing (the caller's name box) is not text. if (page !== lastHeld && !isRinging(page)) 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; /** COLL_WHIRLPOOL and COLL_WHIRLPOOL_2C: water once Whirlpool clears it. */ export const isWhirlpool = (tile: number) => tile === 0x24 || tile === 0x2c; /** COLL_ICE and COLL_ICE_2B: a step onto them keeps sliding (.CheckForced). */ const isIce = (tile: number) => tile === 0x23 || tile === 0x2b; /** What one path search reads many times: the visible objects' tiles and * the map's warp tiles, taken once per search. */ interface Terrain { occupied: Set; warps: Set; } const terrainOf = (world: GameWorld): Terrain => ({ occupied: new Set(world.objects().map((object) => `${object.x},${object.y}`)), warps: new Set(world.map.warps.map((warp) => `${warp.x},${warp.y}`)), }); const VECTORS: Record = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0], }; /** Whether a step from (fromX, fromY) onto the next tile in `direction` * is open: walkable (or open water for a surfer), no side wall, no object, * and no warp other than the target. */ function open( world: GameWorld, terrain: Terrain, fromX: number, fromY: number, direction: Direction, water: boolean, target?: { x: number; y: number }, whirlpools = false, ) { const [dx, dy] = VECTORS[direction]; const x = fromX + dx, y = fromY + dy; if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) return false; const tile = world.assets.collision(world.map, x, y); if ( world.assets.permission(tile) !== 0 && !(water && (isOpenWater(tile) || (whirlpools && isWhirlpool(tile)))) ) return false; if ( edges(world.assets.collision(world.map, fromX, fromY)).includes(direction) || edges(tile).includes(OPPOSITE[direction]) ) return false; const key = `${x},${y}`; if (terrain.occupied.has(key)) return false; if ( isWarpHazard(tile) && terrain.warps.has(key) && (x !== target?.x || y !== target?.y) ) return false; return true; } /** .ledge_table: the directions each COLL_HOP_* tile jumps toward. */ const LEDGES: readonly (readonly Direction[])[] = [ ["right"], ["left"], ["up"], ["down"], ["down", "right"], ["down", "left"], ["up", "right"], ["up", "left"], ]; interface PathOptions { water?: boolean; target?: { x: number; y: number }; whirlpools?: boolean; ledges?: boolean; } /** Where a step in `direction` from (x, y) ends: the next tile, two tiles * on when standing on a ledge that jumps that way (.TryJump), or, on ice, * the last tile of the slide before something blocks it. Undefined when * the step is blocked. */ export function landing( world: GameWorld, x: number, y: number, direction: Direction, options: PathOptions = {}, terrain = terrainOf(world), ): { x: number; y: number } | undefined { const { water = false, target, whirlpools = false, ledges = false } = options; const [dx, dy] = VECTORS[direction]; if (ledges) { const standing = world.assets.collision(world.map, x, y); if ((standing & 0xf0) === 0xa0 && LEDGES[standing & 7].includes(direction)) { const lx = x + dx * 2, ly = y + dy * 2; const landingTile = world.assets.collision(world.map, lx, ly); if ( lx >= 0 && ly >= 0 && lx < world.map.width && ly < world.map.height && world.assets.permission(landingTile) === 0 && !terrain.occupied.has(`${x + dx},${y + dy}`) && !terrain.occupied.has(`${lx},${ly}`) && !( isWarpHazard(landingTile) && terrain.warps.has(`${lx},${ly}`) && (lx !== target?.x || ly !== target?.y) ) ) return { x: lx, y: ly }; } } // COLL_WATERFALL: a surfer entering from above is carried down it. if (water && world.assets.collision(world.map, x + dx, y + dy) === 0x33) { if (direction !== "down") return undefined; let fy = y + 1; while (world.assets.collision(world.map, x, fy) === 0x33) fy++; if (!open(world, terrain, x, fy - 1, "down", water, target, whirlpools)) return undefined; return { x, y: fy }; } if (!open(world, terrain, x, y, direction, water, target, whirlpools)) return undefined; x += dx; y += dy; while ( isIce(world.assets.collision(world.map, x, y)) && open(world, terrain, x, y, direction, water, target, whirlpools) ) { x += dx; y += dy; } return { x, y }; } /** Breadth first from the player: each reached tile's path, in the order * the search reaches them. Stops at `target` when one is given. */ function search(world: GameWorld, options: PathOptions) { const terrain = terrainOf(world); const paths = new Map(); const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }]; for (let i = 0; i < queue.length; i++) { const node = queue[i], key = `${node.x},${node.y}`; if (paths.has(key)) continue; paths.set(key, node.path); if (node.x === options.target?.x && node.y === options.target?.y) break; for (const [direction] of DIRECTIONS) { const end = landing(world, node.x, node.y, direction, options, terrain); if (end) queue.push({ ...end, path: [...node.path, direction] }); } } return paths; } /** Shortest walkable path on the current map, avoiding other warps. * `water` also crosses open water, for a player who can Surf, and * `whirlpools` the whirlpools a player with Whirlpool can clear; `ledges` * takes ledge jumps; a step onto ice slides on until blocked. */ export function findPath( world: GameWorld, targetX: number, targetY: number, { water = false, whirlpools = false, ledges = false } = {}, ): Direction[] { const path = search(world, { water, whirlpools, ledges, target: { x: targetX, y: targetY }, }).get(`${targetX},${targetY}`); if (!path) throw new Error(`No route to ${targetX},${targetY}`); return path; } /** Every tile the player can reach (not stepping onto warps), with its * shortest path's length: one search for many candidate targets. */ export function pathLengths( world: GameWorld, { water = false, ledges = false } = {}, ): Map { const lengths = new Map(); for (const [key, path] of search(world, { water, ledges })) lengths.set(key, path.length); return lengths; } /** 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 }; }