Files
Crystal/src/game/scripts/pokegear.ts
T
kawaandClaude Opus 5.5 d3be732118
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m40s
Open Kanto: the S.S. Aqua, the Kanto gyms, the Magnet Train, Mt. Silver and Red (Closes #33)
All 146 Kanto maps, Route 22, Route 28 and Silver Cave are open. The
Magnet Train, Snorlax and the Poké Flute channel, the Diploma, the Trainer
House, Bill's grandpa, Oak's Pokédex rating in Pallet and Goldenrod's
postgame move tutor are ported; big objects cover 2x2 tiles. The Magnet
Train and the Diploma are compared with the cartridge frame by frame.

The playthrough continues from the Hall of Fame through the S.S. Aqua,
the eight Kanto badges, Oak opening Mt. Silver and Red's defeat and
credits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:53:57 +02:00

561 lines
19 KiB
TypeScript

import { SOUND } from "../audio/constants";
import { ENTER_MAP_MUSIC, RadioShow, exitRadioMusic, radioStation, type RadioHost } from "./radio";
import { PokedexData } from "../rom/pokedex";
import type { SpecialContext } from "./special-context";
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, PC, POKEGEAR, SYMBOLS } from "../rom/profile";
import { TrainerData } from "../rom/trainers";
import { OW } from "../world/constants";
import {
HANG_UP,
ScriptRun,
landmarkName,
type ScriptEffect,
type ScriptHost,
type ScriptState,
} from "./interpreter";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** POKEGEARCARD_* in order. */
/** GetCallerClassAndName: "NAME:" over the trainer class, or the
* NonTrainerCallerNames entry (dashes for an empty slot). */
export function callerClassAndName(r: RomReader, contact: number, text: TextContext) {
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
const classId = r.byte(entry),
id = r.byte(entry + 1);
if (classId) {
const trainers = new TrainerData(r);
return `${trainers.trainer(classId, id).name}:\n ${trainers.className(classId)}`;
}
const pointer = POKEGEAR.NonTrainerCallerNames + id * 2;
return readString(r, r.near(pointer, r.bank(pointer)), text);
}
export type PokegearCard = "clock" | "map" | "phone" | "radio";
/** The buttons the Pokégear reads (hJoyLast and hJoyPressed). */
export type PokegearButton =
| ""
| "a"
| "b"
| "start"
| "select"
| "up"
| "down"
| "left"
| "right";
export const POKEGEAR_BUTTONS: readonly string[] = [
// A frame without a button, for the radio card.
"",
"a",
"b",
"start",
"select",
"up",
"down",
"left",
"right",
];
/** What a Pokégear card shows. The original draws its own tilemaps; here
* the card's contents are presented natively. */
export interface PokegearView {
card: PokegearCard;
/** The cards whose icons Pokegear_FinishTilemap places. */
cards: PokegearCard[];
/** The clock card: GearTodayText's weekday and PrintHoursMins. */
clock?: { day: string; time: string };
/** The map card: the region shown, the landmark under the cursor and
* the one where the player's icon stands. */
map?: { kanto: boolean; cursor: number; player: number; name: string };
/** The phone card's four rows (PHONE_DISPLAY_HEIGHT), the ▶ cursor, the
* ▷ on a chosen contact, whether the map has service, and the
* CALL/DELETE/CANCEL submenu while it is open. */
phone?: {
rows: string[];
cursor: number;
chosen?: number;
signal: boolean;
submenu?: { items: string[]; selected: number };
};
/** The radio card, which runs every frame: the tuning knob's frequency,
* the station's name and the text box's two rows. */
radio?: { frequency: string; name: string; lines: [string, string] };
}
/** What the Pokégear needs from the world. */
export interface PokegearHost {
reader: RomReader;
data: PokemonData;
text(): TextContext;
/** The script host a phone call's callee script runs with. */
scriptHost(): ScriptHost;
/** GetWorldMapLocation for any map. */
landmarkOf(mapId: string): number;
/** wRadioTuningKnob, kept while the game runs. */
radioKnob?: { value: number };
}
const PHONE_DISPLAY_HEIGHT = 4;
const CONTACT_LIST_SIZE = 10;
const PHONE_CONTACT_SIZE = 12;
/** Ticks Sfx_Call plays while "……" shows (six four-frame notes). */
const CALL_TICKS = 12;
/** Pokegear_UpdateClock: the weekday (text_today) and PrintHoursMins's
* 12-hour time with the ROM's AM and PM strings. */
export function pokegearClock(
reader: RomReader,
context: TextContext,
hours: number,
minutes: number,
) {
const day = readText(
reader,
POKEGEAR["Pokegear_UpdateClock.GearTodayText"],
context,
);
const hour = hours % 12 || 12;
const suffix = readString(
reader,
hours < 12 ? POKEGEAR.String_AM : POKEGEAR.String_PM,
context,
);
return {
day,
time: `${String(hour).padStart(2, " ")}:${String(minutes).padStart(2, "0")} ${suffix}`,
};
}
/** PokeGear (engine/pokegear/pokegear.asm): the clock, map, phone and
* radio cards, with the original's joypad handling for each card. Each
* card is yielded as a `pokegear` effect answered with a button. Phone
* calls run the contact's callee script from PhoneContacts on this run's
* draft, as MakePhoneCallFromPokegear does. */
export class Pokegear {
private card: PokegearCard = "clock";
/** wPokegearMapCursorLandmark and wPokegearMapPlayerIconLandmark. */
private cursorLandmark: number;
private playerLandmark: number;
/** wPokegearPhoneScrollPosition and wPokegearPhoneCursorPosition. */
private scroll = 0;
private cursor = 0;
constructor(
private state: ScriptState,
private host: PokegearHost,
) {
// TownMap_InitCursorAndPlayerIconPositions.
const scriptHost = host.scriptHost();
let landmark = host.landmarkOf(scriptHost.mapId);
if (landmark === OW.LANDMARK_SPECIAL && state.backupWarp)
landmark = host.landmarkOf(state.backupWarp.map);
this.playerLandmark = landmark;
this.cursorLandmark =
landmark === OW.LANDMARK_FAST_SHIP ? OW.LANDMARK_NEW_BARK_TOWN : landmark;
}
private get reader() {
return this.host.reader;
}
private has(flag: number) {
return this.state.flags.has(flag);
}
private pages(address: number) {
return readText(this.reader, address, this.host.text())
.split("\n\n")
.filter(Boolean);
}
/** The cards Pokegear_FinishTilemap shows icons for. */
private cards(): PokegearCard[] {
const cards: PokegearCard[] = ["clock"];
if (this.has(OW.ENGINE_MAP_CARD)) cards.push("map");
if (this.has(OW.ENGINE_PHONE_CARD)) cards.push("phone");
if (this.has(OW.ENGINE_RADIO_CARD)) cards.push("radio");
return cards;
}
/** Show the card and wait for a button. */
private *show(
message: number | string[],
extra: Partial<PokegearView> = {},
): Gen<PokegearButton> {
const view: PokegearView = {
card: this.card,
cards: this.cards(),
...extra,
};
const pages = typeof message === "number" ? this.pages(message) : message;
const button = yield { kind: "pokegear", pages, view };
if (typeof button !== "string" || !POKEGEAR_BUTTONS.includes(button))
throw new Error("Invalid Pokégear button.");
return button as PokegearButton;
}
/** Close the Pokégear's screen. */
private *close(): Gen {
yield { kind: "pokegear", pages: [] };
}
private *hold(address: number, ticks: number): Gen {
yield { kind: "text", pages: this.pages(address), holdTicks: ticks };
}
/** PokeGear: the clock card first; returns when the player exits. */
*run(): Gen {
for (;;) {
const next =
this.card === "clock"
? yield* this.clock()
: this.card === "map"
? yield* this.map()
: this.card === "phone"
? yield* this.phone()
: yield* this.radio();
if (!next) return yield* this.close();
// Pokegear_SwitchPage.
this.card = next;
}
}
// ----------------------------------------------------------------- clock
/** PokegearClock_Joypad: any of A, B, Select or Start exits; right goes
* to the first card the player has. */
private *clock(): Gen<PokegearCard | undefined> {
for (;;) {
const time = this.host.scriptHost().clock();
const button = yield* this.show(POKEGEAR.PokegearPressButtonText, {
clock: pokegearClock(
this.reader,
this.host.text(),
time.hours,
time.minutes,
),
});
if (["a", "b", "start", "select"].includes(button)) return undefined;
if (button !== "right") continue;
if (this.has(OW.ENGINE_MAP_CARD)) return "map";
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
if (this.has(OW.ENGINE_RADIO_CARD)) return "radio";
}
}
// ------------------------------------------------------------------- map
/** PokegearMap_ContinueMap: B exits, left and right change cards, up and
* down step the cursor through the region's landmarks with wrapping. */
private *map(): Gen<PokegearCard | undefined> {
const kanto =
this.playerLandmark !== OW.LANDMARK_FAST_SHIP &&
this.playerLandmark >= OW.KANTO_LANDMARK;
// TownMap_GetKantoLandmarkLimits: Kanto's routes need the Hall of Fame.
const [first, last] = !kanto
? [OW.JOHTO_LANDMARK, OW.JOHTO_LANDMARK_LAST]
: this.has(OW.ENGINE_CREDITS_SKIP)
? [OW.KANTO_LANDMARK, OW.KANTO_LANDMARK_LAST]
: [OW.LANDMARK_VICTORY_ROAD, OW.LANDMARK_ROUTE_28];
for (;;) {
const button = yield* this.show([], {
map: {
kanto,
cursor: this.cursorLandmark,
player: this.playerLandmark,
name: landmarkName(this.reader, this.cursorLandmark, this.host.text()),
},
});
if (button === "b") return undefined;
if (button === "right") {
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
if (this.has(OW.ENGINE_RADIO_CARD)) return "radio";
} else if (button === "left") return "clock";
else if (button === "up")
this.cursorLandmark =
this.cursorLandmark < last ? this.cursorLandmark + 1 : first;
else if (button === "down")
this.cursorLandmark =
this.cursorLandmark !== first ? this.cursorLandmark - 1 : last;
}
}
// ----------------------------------------------------------------- phone
/** wPhoneList: CONTACT_LIST_SIZE slots, empty ones zero. */
private contact(slot: number) {
return this.state.phone[slot] ?? 0;
}
/** PhoneContacts' trainer class and number for a contact. */
private callerClass(contact: number) {
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
return { classId: this.reader.byte(entry), id: this.reader.byte(entry + 1) };
}
private callerName(contact: number) {
return callerClassAndName(this.reader, contact, this.host.text());
}
private rows() {
return Array.from({ length: PHONE_DISPLAY_HEIGHT }, (_, row) =>
this.callerName(this.contact(this.scroll + row)),
);
}
private signal() {
return !this.host.scriptHost().phoneService();
}
private *phoneCard(
message: number | string[],
extra: Partial<NonNullable<PokegearView["phone"]>> = {},
) {
return yield* this.show(message, {
phone: {
rows: this.rows(),
cursor: this.cursor,
signal: this.signal(),
...extra,
},
});
}
/** PokegearPhone_Init and PokegearPhone_Joypad. */
private *phone(): Gen<PokegearCard | undefined> {
this.scroll = this.cursor = 0;
for (;;) {
const button = yield* this.phoneCard(POKEGEAR.PokegearAskWhoCallText);
if (button === "b") return undefined;
if (button === "a") {
const contact = this.contact(this.scroll + this.cursor);
if (contact) yield* this.contactSubmenu(contact);
} else if (button === "left")
return this.has(OW.ENGINE_MAP_CARD) ? "map" : "clock";
else if (button === "right") {
if (this.has(OW.ENGINE_RADIO_CARD)) return "radio";
} else if (button === "up") {
if (this.cursor) this.cursor--;
else if (this.scroll) this.scroll--;
} else if (button === "down") {
if (this.cursor < PHONE_DISPLAY_HEIGHT - 1) this.cursor++;
else if (this.scroll < CONTACT_LIST_SIZE - PHONE_DISPLAY_HEIGHT)
this.scroll++;
}
}
}
/** CheckCanDeletePhoneNumber: Mom and Elm stay. */
private canDelete(contact: number) {
const { classId, id } = this.callerClass(contact);
return (
classId !== 0 || (id !== OW.PHONECONTACT_MOM && id !== OW.PHONECONTACT_ELM)
);
}
/** PokegearPhoneContactSubmenu: CALL (DELETE) CANCEL; up and down stop
* at the ends; B cancels. */
private *contactSubmenu(contact: number): Gen {
const deletable = this.canDelete(contact);
const strings =
POKEGEAR[
deletable
? "PokegearPhoneContactSubmenu.CallDeleteCancelStrings"
: "PokegearPhoneContactSubmenu.CallCancelStrings"
];
const r = this.reader,
count = r.byte(strings + 2);
const items = readString(r, strings + 3, this.host.text()).split("\n");
if (items.length !== count) throw new Error("Unexpected phone submenu.");
const actions = deletable
? (["call", "delete", "cancel"] as const)
: (["call", "cancel"] as const);
let selected = 0;
for (;;) {
const button = yield* this.phoneCard(POKEGEAR.PokegearAskWhoCallText, {
chosen: this.cursor,
submenu: { items, selected },
});
if (button === "up") selected = Math.max(0, selected - 1);
else if (button === "down") selected = Math.min(count - 1, selected + 1);
else if (button === "a" || button === "b") {
const action = button === "b" ? "cancel" : actions[selected];
if (action === "call") return yield* this.call(contact);
if (action === "delete") {
const answer = yield {
kind: "yesno",
pages: this.pages(POKEGEAR.PokegearAskDeleteText),
};
if (answer) this.deleteContact();
}
return;
}
}
}
/** PokegearPhone_DeletePhoneNumber: the slot empties. Its compaction
* loop only moves an entry up past the emptied slot, which the native
* list (kept without gaps) already does. */
private deleteContact() {
this.state.phone.splice(this.scroll + this.cursor, 1);
}
/** PokegearPhone_MakePhoneCall, MakePhoneCallFromPokegear and
* PokegearPhone_FinishPhoneCall. */
private *call(contact: number): Gen {
const r = this.reader,
scriptHost = this.host.scriptHost();
const sound = scriptHost.sound;
if (scriptHost.phoneService()) {
// Phone_NoSignal: SFX_NO_SIGNAL, then Phone_CallEnd's three boops.
sound?.playSFX(SOUND.SFX_NO_SIGNAL);
yield* this.hold(POKEGEAR.PhoneEllipseText, HANG_UP.endTicks);
const pages = this.pages(
POKEGEAR["PokegearPhone_MakePhoneCall.GearOutOfServiceText"],
);
yield { kind: "text", pages };
return;
}
// SFX_CALL with each "……", the second after the first has ended.
for (let ring = 0; ring < 2; ring++) {
sound?.queueSFX([SOUND.SFX_CALL]);
yield* this.hold(
POKEGEAR["PokegearPhone_MakePhoneCall.GearEllipseText"],
CALL_TICKS,
);
}
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
// CheckPhoneContactTimeOfDay: the callee's time mask against wTimeOfDay.
const time = r.byte(entry + 4) & (OW.MORN | OW.DAY | OW.NITE);
const script = !(time & (1 << scriptHost.timeOfDay()))
? POKEGEAR.PhoneOutOfAreaScript
: `${r.byte(entry + 2)}:${r.byte(entry + 3)}` === scriptHost.mapId
? POKEGEAR.PhoneScript_JustTalkToThem
: r.far(entry + 5);
const run = new ScriptRun(r, script, this.state, scriptHost);
let input: Input;
for (let effect = run.resume(); effect; effect = run.resume(input))
input = yield effect;
if (run.deferred !== undefined)
throw new Error("A phone script deferred another script.");
Object.assign(this.state, run.state);
// HangUp after A or B; the callee's last text waited for that button.
sound?.playSFX(SOUND.SFX_HANG_UP);
yield* this.hold(SYMBOLS.phoneClickText, HANG_UP.clickTicks);
yield* this.hold(POKEGEAR.PhoneEllipseText, HANG_UP.endTicks);
}
// ----------------------------------------------------------------- radio
/** wPokegearRadioMusicPlaying, which ExitPokegearRadio_HandleMusic reads
* when the radio card is left. */
private radioMusic = 0;
private radioHost(): RadioHost {
const host = this.host,
scriptHost = host.scriptHost();
return {
reader: host.reader,
data: host.data,
dex: new PokedexData(host.reader),
state: this.state,
text: () => host.text(),
random: () => scriptHost.random(),
timeOfDay: () => scriptHost.timeOfDay(),
hours: () => scriptHost.clock().hours,
weekday: () => scriptHost.clock().weekday,
landmark: () => this.playerLandmark,
landmarkOf: (mapId) => host.landmarkOf(mapId),
special: () => radioSpecialContext(this.state, scriptHost),
sound: scriptHost.sound,
};
}
/** PokegearRadio_Init, then PokegearRadio_Joypad and AnimateTuningKnob
* once per video frame (the world answers "" for a frame without a
* button): B exits, left changes cards, up and down turn the knob by
* two (0 to 80) and retune; the station plays on. */
private *radio(): Gen<PokegearCard | undefined> {
const knob = (this.host.radioKnob ??= { value: 0 });
const radioHost = this.radioHost();
let show: RadioShow | undefined;
const tune = () => {
// UpdateRadioStation: the station at the knob, or NoRadioStation.
if (show) this.radioMusic = show.musicPlaying;
const station = radioStation(
this.reader,
knob.value,
{
landmark: this.playerLandmark,
timeOfDay: radioHost.timeOfDay(),
expnCard: this.has(ENGINE_FLAGS.expnCard),
rocketSignal: this.has(ENGINE_FLAGS.rocketSignal),
rocketsInRadioTower: this.has(ENGINE_FLAGS.rocketsInRadioTower),
},
this.host.text(),
);
show = station && new RadioShow(radioHost, station.line, station.name);
if (!station) {
// NoRadioMusic
this.radioMusic = ENTER_MAP_MUSIC;
radioHost.sound?.playMusic(SOUND.MUSIC_NONE);
}
};
tune();
for (;;) {
const view = {
frequency: ((knob.value + 2) / 4).toFixed(1),
name: show?.name ?? "",
lines: show?.lines ?? (["", ""] as [string, string]),
};
const button = yield* this.show([], { radio: view });
if (show) this.radioMusic = show.musicPlaying || this.radioMusic;
if (button === "b") {
this.exitRadio();
return undefined;
}
if (button === "left") {
this.exitRadio();
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
if (this.has(OW.ENGINE_MAP_CARD)) return "map";
return "clock";
}
if (button === "up" && knob.value < 80) {
knob.value += 2;
tune();
} else if (button === "down" && knob.value > 0) {
knob.value -= 2;
tune();
}
if (button === "" && show) show.frame();
}
}
/** ExitPokegearRadio_HandleMusic */
private exitRadio() {
const scriptHost = this.host.scriptHost();
exitRadioMusic(
scriptHost.sound,
this.radioMusic,
this.state.playerState,
scriptHost.mapMusic?.(this.state) ?? 0,
);
this.radioMusic = 0;
}
}
/** The Lucky Number routines' SpecialContext, for the Lucky Channel. */
export function radioSpecialContext(state: ScriptState, scriptHost: ScriptHost): SpecialContext {
const unused = () => {
throw new Error("The radio needs no such routine.");
};
return {
reader: undefined as never,
data: scriptHost.data,
state,
strings: {},
numbers: {},
text: () => scriptHost.text(),
random: () => scriptHost.random(),
player: () => ({ id: scriptHost.player().id, name: scriptHost.text().player }),
today: () => scriptHost.day?.() ?? 0,
weekday: () => scriptHost.clock().weekday,
timeOfDay: () => scriptHost.timeOfDay(),
write: unused,
take: unused,
flush: unused,
selectMon: unused,
playSFX: unused,
cry: unused,
waitSFX: unused,
};
}