Enable Route 32 and its Pokémon Center, the Ruins of Alph with the four chambers, item and word rooms, inner chamber and research center, Union Cave, Route 33, Azalea Town with Kurt's house, the kiln and Bugsy's gym, the Slowpoke Well, Ilex Forest and Route 34. Port what their scripts need: saved WRAM bytes by label (readmem, writemem, loadmem: wFarfetchdPosition, wKurtApricornQuantity), VAR_UNOWNCOUNT and VAR_KURT_APRICORNS, SelectApricornForKurt and verbosegiveitemvar, Unown letters (GetUnownLetter, per-letter pictures, CheckUnownLetter's redraw, UpdateUnownDex, wFirstUnownSeen), the chamber puzzles (_UnownPuzzle), DisplayUnownWords, HoOhChamber and OmanyteChamber. Minigames and specials that draw a whole screen in tiles now share a tile screen effect with ROM graphics, palettes and sprites, answered with buttons; the standard font and textbox frame load for them. The playthrough continues through Kurt's Slowpoke Well, Bugsy, the rival and the Farfetch'd to the Route 34 gate; its helpers move to tests/playthrough-driver.ts. New tests solve the Kabuto puzzle through the world, catch Unown and make Kurt's balls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
5.3 KiB
TypeScript
161 lines
5.3 KiB
TypeScript
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] : [];
|
|
|
|
/** 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<string>();
|
|
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) 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 (
|
|
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<T extends { gameTime?: unknown }>(save: T) {
|
|
const { gameTime: _, ...rest } = save;
|
|
return rest;
|
|
}
|