The player's name in the start menu opens TrainerCard: page 1 with the name, ID, money, Pokédex count and play time, page 2 with the Johto leaders and the badges owned, turned and closed with the original buttons. The card picture, leader faces and animated badges come from the ROM with _CGB_TrainerCard's palettes. GameTimer now counts the play time from the end of the introduction, capped at 999:59:59, and saves keep it. Closes #23 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
4.6 KiB
TypeScript
142 lines
4.6 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;
|
|
|
|
/** 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;
|
|
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}`);
|
|
}
|
|
|
|
/** 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;
|
|
}
|