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 = Generator; /** 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 = {}, ): Gen { 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 { 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 { 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> = {}, ) { return yield* this.show(message, { phone: { rows: this.rows(), cursor: this.cursor, signal: this.signal(), ...extra, }, }); } /** PokegearPhone_Init and PokegearPhone_Joypad. */ private *phone(): Gen { 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 { 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, }; }