Enable Route 35, Route 36, Route 37, the National Park and its gates, Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's room and both floors of the Burned Tower. The story runs from Floria, the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the rival in the Burned Tower and the beasts' release to Ecruteak Gym, Morty, the FOGBADGE and TM30. Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons, CheckEncounterRoamMon) with their state in the save; only Continue moves them on restore, so a failed import's rollback keeps them in place. Port the Bug-Catching Contest: registration on contest days, Park Balls, contestants, the timer, QUIT, the catch-and-swap prompt, judging from the ROM's contestant table, prizes and the saved contest state. Port the Game Corner's SlotMachine and CardFlip on a new realtime tile screen that advances two frames per tick with buffered buttons: reel strips and scrolling, bias tables, the seven streak, Golem and Chansey, payouts, the Card Flip board, cursor and navigation. Their tilemaps are checked against the ROM's own data. Also port getnum, a plain warp that continues its script on the new map, and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the Burned Tower rival) is no longer sighted once its own flag is set, as the original's read of bit 1 of wNumKeyItems gives with the usual key items. The playthrough walks through the National Park and continues to Morty; its driver chooses moves by type matchup, buys at Marts and uses Super Potions and Awakenings in trainer battles. New tests cover roaming, the contest, the Game Corner and Ecruteak's side content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
6.4 KiB
TypeScript
182 lines
6.4 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] : [];
|
|
|
|
/** 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;
|
|
|
|
/** 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 (
|
|
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<T extends { gameTime?: unknown }>(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 };
|
|
}
|