Ring with trainers' calls, rematch them and play the radio (Closes #30)
Checks / web (push) Successful in 2m38s
Private ROM checks / private-rom (push) Successful in 20m37s

Every registered trainer's callee and caller scripts run, with the
missing specials and commands (RandomPhoneWildMon, RandomUnseenWildMon,
swarms, gettrainerclassname, Kenji's break, the Blue Card and Buena's
password variables). Fight counts are saved, so rematches and gifts work
on every open map, and the daily reset clears the phone's flags.

Random calls follow CheckPhoneCall and the 20/10/5/3-minute call timer;
received calls ring twice with the caller's name, and calls play their
ringing, hang-up and no-signal sounds. Grass and fishing swarms use the
swarm tables.

The radio ports PlayRadioShow and every station frame by frame, with its
music: the Pokégear's radio card tunes by knob, house radios play through
MapRadio, and Buena's show takes the day's password for Blue Card points
that her receptionist exchanges for prizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-16 11:10:02 +02:00
1 parent 7f30c3f450
commit 7c12c81a27
21 files changed
+2021 -64

No files matched your search

+142 -18
View File
@@ -1,6 +1,10 @@
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 { PC, POKEGEAR, SYMBOLS } from "../rom/profile";
import { ENGINE_FLAGS, PC, POKEGEAR, SYMBOLS } from "../rom/profile";
import { TrainerData } from "../rom/trainers";
import { OW } from "../world/constants";
import {
@@ -17,9 +21,24 @@ 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"
@@ -29,6 +48,8 @@ export type PokegearButton =
| "left"
| "right";
export const POKEGEAR_BUTTONS: readonly string[] = [
// A frame without a button, for the radio card.
"",
"a",
"b",
"start",
@@ -60,6 +81,9 @@ export interface PokegearView {
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. */
@@ -71,6 +95,8 @@ export interface PokegearHost {
scriptHost(): ScriptHost;
/** GetWorldMapLocation for any map. */
landmarkOf(mapId: string): number;
/** wRadioTuningKnob, kept while the game runs. */
radioKnob?: { value: number };
}
const PHONE_DISPLAY_HEIGHT = 4;
@@ -263,17 +289,8 @@ export class Pokegear {
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
return { classId: this.reader.byte(entry), id: this.reader.byte(entry + 1) };
}
/** GetCallerClassAndName: "NAME:" over the trainer class, or the
* NonTrainerCallerNames entry (dashes for an empty slot). */
private callerName(contact: number) {
const r = this.reader,
{ classId, id } = this.callerClass(contact);
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)), this.host.text());
return callerClassAndName(this.reader, contact, this.host.text());
}
private rows() {
return Array.from({ length: PHONE_DISPLAY_HEIGHT }, (_, row) =>
@@ -379,8 +396,10 @@ export class Pokegear {
private *call(contact: number): Gen {
const r = this.reader,
scriptHost = this.host.scriptHost();
const sound = scriptHost.sound;
if (scriptHost.phoneService()) {
// Phone_NoSignal: the three boops of Phone_CallEnd.
// 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"],
@@ -388,11 +407,14 @@ export class Pokegear {
yield { kind: "text", pages };
return;
}
for (let ring = 0; ring < 2; ring++)
// 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);
@@ -409,24 +431,126 @@ export class Pokegear {
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
/** PokegearRadio_Joypad: B exits and left changes cards. The stations
* are not ported. */
/** 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> {
yield { kind: "notice", text: "The radio is not ported yet." };
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 button = yield* this.show([]);
if (button === "b") return undefined;
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,
write: unused,
take: unused,
flush: unused,
selectMon: unused,
};
}