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 / first weekday) 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 : 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; /** Shortest walkable path on the current map, avoiding other warps. */ export function findPath( world: GameWorld, targetX: number, targetY: number, ): 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; if ( world.assets.permission(world.assets.collision(world.map, x, y)) !== 0 ) continue; if (world.objects().some((object) => object.x === x && object.y === y)) continue; if ( 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}`); }