Ring with trainers' calls, rematch them and play the radio (Closes #30)
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:
1 parent
7f30c3f450
commit
7c12c81a27
21 files changed
+2021
-64
No files matched your search
+21
-3
@@ -7,6 +7,7 @@ import {
|
||||
NUM_ENGINE_FLAGS,
|
||||
NUM_SPRITEMOVEDATA,
|
||||
OVERWORLD,
|
||||
PHONE,
|
||||
RAM,
|
||||
SYMBOLS,
|
||||
} from "./profile";
|
||||
@@ -387,9 +388,15 @@ export class CrystalAssets {
|
||||
grassWildMons(
|
||||
id: string,
|
||||
kanto = false,
|
||||
/** SwarmGrassWildMons in place of the region's table. */
|
||||
swarm = false,
|
||||
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
|
||||
const r = this.reader;
|
||||
const table = kanto ? OVERWORLD.KantoGrassWildMons : SYMBOLS.johtoGrassWildMons;
|
||||
const table = swarm
|
||||
? PHONE.SwarmGrassWildMons
|
||||
: kanto
|
||||
? OVERWORLD.KantoGrassWildMons
|
||||
: SYMBOLS.johtoGrassWildMons;
|
||||
for (let p = table, i = 0; i < 256; i++, p += 47) {
|
||||
const group = r.byte(p);
|
||||
if (group === 0xff) return undefined;
|
||||
@@ -410,9 +417,15 @@ export class CrystalAssets {
|
||||
waterWildMons(
|
||||
id: string,
|
||||
kanto = false,
|
||||
/** SwarmWaterWildMons in place of the region's table. */
|
||||
swarm = false,
|
||||
): { rate: number; slots: { level: number; species: number }[] } | undefined {
|
||||
const r = this.reader;
|
||||
const table = kanto ? OVERWORLD.KantoWaterWildMons : OVERWORLD.JohtoWaterWildMons;
|
||||
const table = swarm
|
||||
? PHONE.SwarmWaterWildMons
|
||||
: kanto
|
||||
? OVERWORLD.KantoWaterWildMons
|
||||
: OVERWORLD.JohtoWaterWildMons;
|
||||
for (let p = table, i = 0; i < 256; i++, p += 9) {
|
||||
const group = r.byte(p);
|
||||
if (group === 0xff) return undefined;
|
||||
@@ -434,7 +447,12 @@ export class CrystalAssets {
|
||||
flags: number[] = [];
|
||||
for (let index = 0; index < NUM_ENGINE_FLAGS; index++) {
|
||||
const address = r.word(SYMBOLS.engineFlags + index * 3);
|
||||
if (address >= RAM.dailyFlags1 && address <= RAM.unusedDailyFlag)
|
||||
// CheckDailyResetTimer: wDailyFlags1 to wUnusedDailyFlag, then the
|
||||
// rematch, phone item and phone time-of-day flags (4 bytes each).
|
||||
if (
|
||||
(address >= RAM.dailyFlags1 && address <= RAM.unusedDailyFlag) ||
|
||||
(address >= RAM.dailyRematchFlags && address < RAM.dailyPhoneTimeOfDayFlags + 4)
|
||||
)
|
||||
flags.push(index);
|
||||
}
|
||||
return flags;
|
||||
|
||||
@@ -154,6 +154,8 @@ export const RAM = {
|
||||
curFruit: 0xd03f,
|
||||
dailyFlags1: 0xdc1e,
|
||||
unusedDailyFlag: 0xdc21,
|
||||
dailyRematchFlags: 0xdc4c,
|
||||
dailyPhoneTimeOfDayFlags: 0xdc54,
|
||||
itemQuantityChange: 0xd10c,
|
||||
movementBuffer: 0xd007,
|
||||
hiddenItemID: 0xd040,
|
||||
@@ -211,6 +213,12 @@ export const ENGINE_FLAGS = {
|
||||
bugContestTimer: 17,
|
||||
rocketsInRadioTower: 19,
|
||||
rocketsInMahogany: 23,
|
||||
expnCard: 3,
|
||||
rocketSignal: 14,
|
||||
fishSwarm: 82,
|
||||
buenasPassword: 95,
|
||||
dunsparceSwarm: 160,
|
||||
yanmaSwarm: 161,
|
||||
} as const;
|
||||
|
||||
/** Overworld scripts, routines and tables, keyed by their PRET labels so
|
||||
@@ -1065,6 +1073,130 @@ export const OPTIONS = {
|
||||
"Options_MenuAccount.On": 0xe44f6,
|
||||
} as const;
|
||||
|
||||
/** The phone's calls and their specials (engine/phone/phone.asm,
|
||||
* engine/overworld/wildmons.asm, engine/events/specials.asm), keyed by
|
||||
* their PRET labels. */
|
||||
export const PHONE = {
|
||||
ActivateFishingSwarm: 0xc3fc,
|
||||
MapRadio: 0xc355,
|
||||
InitCallReceiveDelay: 0x113e5,
|
||||
RandomUnseenWildMon: 0x2a4ab,
|
||||
"RandomUnseenWildMon.JustSawSomeRareMonText": 0x2a51a,
|
||||
RandomPhoneWildMon: 0x2a51f,
|
||||
JohtoGrassWildMons: 0x2a5e9,
|
||||
KantoGrassWildMons: 0x2b274,
|
||||
SwarmGrassWildMons: 0x2b8d0,
|
||||
SwarmWaterWildMons: 0x2b92f,
|
||||
Script_ReceivePhoneCall: 0x90241,
|
||||
RingTwice_StartCall: 0x9026f,
|
||||
HangUp: 0x902eb,
|
||||
AskRememberPassword: 0x4ae12,
|
||||
BuenasPassword: 0x8af6b,
|
||||
BuenaPrize: 0x8afd4,
|
||||
"BuenaPrize.BuenaAskWhichPrizeText": 0x8b072,
|
||||
"BuenaPrize.BuenaIsThatRightText": 0x8b077,
|
||||
"BuenaPrize.BuenaHereYouGoText": 0x8b07c,
|
||||
"BuenaPrize.BuenaNotEnoughPointsText": 0x8b081,
|
||||
"BuenaPrize.BuenaNoRoomText": 0x8b086,
|
||||
"BuenaPrize.BuenaComeAgainText": 0x8b08b,
|
||||
"PrintBlueCardBalance.Points_string": 0x8b0ca,
|
||||
BuenaPrizeItems: 0x8b15e,
|
||||
} as const;
|
||||
|
||||
/** The radio (engine/pokegear/radio.asm, pokegear.asm's stations):
|
||||
* its texts and tables, keyed by their PRET labels. */
|
||||
export const RADIO = {
|
||||
OPT_IntroText1: 0xb8820,
|
||||
OPT_IntroText2: 0xb8825,
|
||||
OPT_IntroText3: 0xb882a,
|
||||
OPT_OakText1: 0xb882f,
|
||||
OPT_OakText2: 0xb8834,
|
||||
OPT_OakText3: 0xb8839,
|
||||
OPT_MaryText1: 0xb884f,
|
||||
"OaksPKMNTalk8.Adverbs": 0xb8869,
|
||||
"OaksPKMNTalk9.Adjectives": 0xb88fe,
|
||||
OPT_PokemonChannelText: 0xb898e,
|
||||
OaksPKMNTalkRoutes: 0xb87f2,
|
||||
"OaksPKMNTalkRoutes.End": 0xb8810,
|
||||
PokedexShowText: 0xb8b30,
|
||||
BenIntroText1: 0xb8baa,
|
||||
BenIntroText2: 0xb8baf,
|
||||
BenIntroText3: 0xb8bb4,
|
||||
FernIntroText1: 0xb8bb9,
|
||||
FernIntroText2: 0xb8bbe,
|
||||
BenFernText1: 0xb8bc3,
|
||||
BenFernText2A: 0xb8bc8,
|
||||
BenFernText2B: 0xb8bcd,
|
||||
BenFernText3A: 0xb8bd2,
|
||||
BenFernText3B: 0xb8bd7,
|
||||
LC_Text1: 0xb8c7e,
|
||||
LC_Text2: 0xb8c83,
|
||||
LC_Text3: 0xb8c88,
|
||||
LC_Text4: 0xb8c8d,
|
||||
LC_Text5: 0xb8c92,
|
||||
LC_Text6: 0xb8c97,
|
||||
LC_Text7: 0xb8c9c,
|
||||
LC_Text8: 0xb8ca1,
|
||||
LC_Text9: 0xb8ca6,
|
||||
LC_Text10: 0xb8cab,
|
||||
LC_Text11: 0xb8cb0,
|
||||
LC_DragText1: 0xb8cb5,
|
||||
LC_DragText2: 0xb8cba,
|
||||
PnP_Text1: 0xb8ce3,
|
||||
PnP_Text2: 0xb8ce8,
|
||||
PnP_Text3: 0xb8ced,
|
||||
PnP_Text4: 0xb8d51,
|
||||
PnP_Text5: 0xb8e23,
|
||||
"PeoplePlaces5.Adjectives": 0xb8d7d,
|
||||
"PeoplePlaces7.Adjectives": 0xb8e52,
|
||||
PnP_HiddenPeople: 0xb8d3e,
|
||||
PnP_HiddenPeople_BeatE4: 0xb8d43,
|
||||
PnP_HiddenPeople_BeatKanto: 0xb8d4b,
|
||||
PnP_Places: 0xb8e11,
|
||||
"PnP_Places.End": 0xb8e23,
|
||||
RocketRadioText1: 0xb8ec5,
|
||||
RocketRadioText2: 0xb8eca,
|
||||
RocketRadioText3: 0xb8ecf,
|
||||
RocketRadioText4: 0xb8ed4,
|
||||
RocketRadioText5: 0xb8ed9,
|
||||
RocketRadioText6: 0xb8ede,
|
||||
RocketRadioText7: 0xb8ee3,
|
||||
RocketRadioText8: 0xb8ee8,
|
||||
RocketRadioText9: 0xb8eed,
|
||||
RocketRadioText10: 0xb8ef2,
|
||||
BuenaRadioText1: 0xb9182,
|
||||
BuenaRadioText2: 0xb9187,
|
||||
BuenaRadioText3: 0xb918c,
|
||||
BuenaRadioText4: 0xb9191,
|
||||
BuenaRadioText5: 0xb9196,
|
||||
BuenaRadioText6: 0xb919b,
|
||||
BuenaRadioText7: 0xb91a0,
|
||||
BuenaRadioMidnightText1: 0xb91a5,
|
||||
BuenaRadioMidnightText2: 0xb91aa,
|
||||
BuenaRadioMidnightText3: 0xb91af,
|
||||
BuenaRadioMidnightText4: 0xb91b4,
|
||||
BuenaRadioMidnightText5: 0xb91b9,
|
||||
BuenaRadioMidnightText6: 0xb91be,
|
||||
BuenaRadioMidnightText7: 0xb91c3,
|
||||
BuenaRadioMidnightText8: 0xb91c8,
|
||||
BuenaRadioMidnightText9: 0xb91cd,
|
||||
BuenaRadioMidnightText10: 0xb91d2,
|
||||
BuenaOffTheAirText: 0xb91d7,
|
||||
BuenasPasswordTable: 0xb8ff9,
|
||||
BuenasPasswordChannelName: 0xb9171,
|
||||
RadioChannelSongs: 0xb920b,
|
||||
RadioChannels: 0x916ad,
|
||||
OaksPKMNTalkName: 0x918bf,
|
||||
PokedexShowName: 0x918cc,
|
||||
PokemonMusicName: 0x918d6,
|
||||
LuckyChannelName: 0x918e1,
|
||||
UnownStationName: 0x918ef,
|
||||
PlacesAndPeopleName: 0x918f5,
|
||||
LetsAllSingName: 0x91905,
|
||||
PokeFluteStationName: 0x91914,
|
||||
BuenasPasswordName: 0x917c3,
|
||||
} as const;
|
||||
|
||||
/** The sound engine (audio/engine.asm) and its tables, keyed by their
|
||||
* PRET labels. */
|
||||
export const AUDIO = {
|
||||
@@ -1329,6 +1461,41 @@ export const JOHTO = {
|
||||
* writemem, loadmem and readvar), keyed by their PRET labels; the native
|
||||
* save keeps them by label in `memory`. */
|
||||
export const SAVED_WRAM = {
|
||||
wJackFightCount: 0xd9f2,
|
||||
wBeverlyFightCount: 0xd9f3,
|
||||
wHueyFightCount: 0xd9f4,
|
||||
wGavenFightCount: 0xd9f5,
|
||||
wBethFightCount: 0xd9f6,
|
||||
wJoseFightCount: 0xd9f7,
|
||||
wReenaFightCount: 0xd9f8,
|
||||
wJoeyFightCount: 0xd9f9,
|
||||
wWadeFightCount: 0xd9fa,
|
||||
wRalphFightCount: 0xd9fb,
|
||||
wLizFightCount: 0xd9fc,
|
||||
wAnthonyFightCount: 0xd9fd,
|
||||
wToddFightCount: 0xd9fe,
|
||||
wGinaFightCount: 0xd9ff,
|
||||
wIrwinFightCount: 0xda00,
|
||||
wArnieFightCount: 0xda01,
|
||||
wAlanFightCount: 0xda02,
|
||||
wDanaFightCount: 0xda03,
|
||||
wChadFightCount: 0xda04,
|
||||
wDerekFightCount: 0xda05,
|
||||
wTullyFightCount: 0xda06,
|
||||
wBrentFightCount: 0xda07,
|
||||
wTiffanyFightCount: 0xda08,
|
||||
wVanceFightCount: 0xda09,
|
||||
wWiltonFightCount: 0xda0a,
|
||||
wKenjiFightCount: 0xda0b,
|
||||
wParryFightCount: 0xda0c,
|
||||
wErinFightCount: 0xda0d,
|
||||
wBuenasPassword: 0xdc4a,
|
||||
wKenjiBreakTimer: 0xdc58,
|
||||
wYanmaMapGroup: 0xdc5a,
|
||||
wYanmaMapNumber: 0xdc5b,
|
||||
wDunsparceMapGroup: 0xdfcc,
|
||||
wDunsparceMapNumber: 0xdfcd,
|
||||
wFishingSwarmFlag: 0xdfce,
|
||||
wMooMooBerries: 0xd962,
|
||||
wFarfetchdPosition: 0xd964,
|
||||
wKurtApricornQuantity: 0xdca4,
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
import type { GameRuntime } from "../app/runtime";
|
||||
import type { Button } from "../input/types";
|
||||
import { CrystalAssets } from "./rom/assets";
|
||||
import { GameWorld, TICKS_PER_SECOND } from "./world/world";
|
||||
import { GameWorld, TICKS_PER_SECOND, realtimeChoice } from "./world/world";
|
||||
import type { BattleAction } from "./battle/battle";
|
||||
import type { Direction } from "./world/types";
|
||||
import { WorldRenderer } from "./rendering/renderer";
|
||||
@@ -84,8 +84,7 @@ export class NativeRuntime implements GameRuntime {
|
||||
}
|
||||
// A realtime screen (the Game Corner) reads every button itself, once
|
||||
// per its own frame, rather than through the usual per-button actions.
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind === "screen" && choice.realtime) {
|
||||
if (realtimeChoice(world.dialogue?.choice)) {
|
||||
world.bufferScreenButton(button);
|
||||
this.publish();
|
||||
return;
|
||||
|
||||
@@ -17,9 +17,9 @@ const NITE_F = 2;
|
||||
* (FISHGROUP_*, never FISHGROUP_NONE). A bite when Random falls below the
|
||||
* group's chance, then the first entry whose chance a second Random does
|
||||
* not exceed; species 0 picks the time of day's TimeFishGroups entry.
|
||||
* Undefined when nothing bites. GetFishGroupIndex's swarm groups need
|
||||
* DAILYFLAGS1_FISH_SWARM_F and wFishingSwarmFlag, which only trainer calls
|
||||
* set; they are not ported, so the map's own group is used. */
|
||||
* Undefined when nothing bites. GetFishGroupIndex's swarm groups (the
|
||||
* world passes them) need DAILYFLAGS1_FISH_SWARM_F and wFishingSwarmFlag,
|
||||
* which trainers' calls set. */
|
||||
export function fish(
|
||||
r: RomReader,
|
||||
group: number,
|
||||
|
||||
+234
-12
@@ -18,6 +18,7 @@ import {
|
||||
MOM,
|
||||
OVERWORLD,
|
||||
PC,
|
||||
PHONE,
|
||||
POKEGEAR,
|
||||
RAM,
|
||||
SAVED_WRAM,
|
||||
@@ -67,7 +68,8 @@ import { OW } from "../world/constants";
|
||||
import { readMovement, type MovementStep } from "./movement";
|
||||
import { PcSession } from "./pc";
|
||||
import { bankOfMom, type MomBankView } from "./mom";
|
||||
import type { PokegearView } from "./pokegear";
|
||||
import { callerClassAndName, radioSpecialContext, type PokegearView } from "./pokegear";
|
||||
import { ENTER_MAP_MUSIC, RadioShow, buenasPassword, exitRadioMusic, mapRadioStation, type RadioHost } from "./radio";
|
||||
import type { FlyMapView } from "./fly-map";
|
||||
import type { TileScreen } from "./screen";
|
||||
import { unownPuzzle } from "./unown-puzzle";
|
||||
@@ -550,6 +552,9 @@ export type ScriptEffect =
|
||||
| { kind: "waitsfx" }
|
||||
/** DelayFrames inside a special: the map waits, frozen. */
|
||||
| { kind: "delay"; ticks: number }
|
||||
/** PlayRadio's text box for one video frame, answered with the button
|
||||
* pressed ("" for none). */
|
||||
| { kind: "mapradio"; text: string }
|
||||
/** A front picture in a box (pokepic); species 0 closes it. */
|
||||
| { kind: "picture"; species: number }
|
||||
/** The introduction's trainer or player picture; none closes it. */
|
||||
@@ -703,7 +708,7 @@ export type ScriptEffect =
|
||||
| { kind: "warpcheck" }
|
||||
/** LoadMemScript: a script (with a phone caller's script) that runs at
|
||||
* the next PlayerEvents, unless one is already queued. */
|
||||
| { kind: "memscript"; script: number; callerScript?: number }
|
||||
| { kind: "memscript"; script: number; callerScript?: number; caller?: number }
|
||||
/** changeblock: the block covering map tile (x, y) becomes `block`. */
|
||||
| { kind: "changeblock"; x: number; y: number; block: number }
|
||||
/** changemapblocks: replace the map's block data from the ROM. */
|
||||
@@ -763,6 +768,10 @@ export interface ScriptHost {
|
||||
/** The sound engine, and GetMapMusic_MaybeSpecial for this map. */
|
||||
sound?: GameSound;
|
||||
mapMusic?(state: ScriptState): number;
|
||||
/** InitCallReceiveDelay: the random-call timer starts over. */
|
||||
initCallReceiveDelay?(): void;
|
||||
/** GetWorldMapLocation for any map. */
|
||||
landmarkOf?(mapId: string): number;
|
||||
}
|
||||
|
||||
/** A sound a script started while its text was still to be shown: it
|
||||
@@ -793,6 +802,12 @@ const savedByte = (address: number) =>
|
||||
);
|
||||
/** VAR_FACING's values (DOWN, UP, LEFT, RIGHT) as directions. */
|
||||
const FACINGS: Direction[] = ["down", "up", "left", "right"];
|
||||
/** GRASS_WILDDATA_LENGTH: map, three rates, then seven (level, species)
|
||||
* slots for each time of day. */
|
||||
const GRASS_MON_BYTES = 7 * 2;
|
||||
/** NUM_BUENA_PRIZES */
|
||||
const BUENA_PRIZES = 9;
|
||||
const GRASS_WILDDATA_LENGTH = 2 + 3 + 3 * GRASS_MON_BYTES;
|
||||
/** ITEM_FROM_MEM: take the item from wScriptVar. */
|
||||
const ITEM_FROM_MEM = 0xff;
|
||||
/** wCallerContact's script-2 pointer (bank, address) within the contact. */
|
||||
@@ -800,6 +815,8 @@ const PHONE_CONTACT_SCRIPT2_BANK = 9;
|
||||
/** PhoneContacts entries (data/phone/phone_contacts.asm) and PHONE_BILL. */
|
||||
const PHONE_CONTACT_SIZE = 12;
|
||||
const PHONE_BILL = 3;
|
||||
/** PHONE_MOM: Mom's PhoneContacts entry. */
|
||||
const PHONE_MOM = 1;
|
||||
/** A momitem: trigger and cost (bigdt), then MOM_ITEM or MOM_DOLL and the
|
||||
* item or decoration. */
|
||||
const MOMITEM_SIZE = 8,
|
||||
@@ -935,6 +952,8 @@ export class ScriptRun {
|
||||
/** wCallerContact's second script, copied by CheckSpecialPhoneCall
|
||||
* from the queued SpecialPhoneCallList entry. */
|
||||
callerScript?: number;
|
||||
/** wCurCaller, as LoadCallerScript sets it for a received call. */
|
||||
caller?: number;
|
||||
fieldMove?: FieldMoveContext;
|
||||
/** An item used from the pack: UseItem names it in wStringBuffer1 and
|
||||
* CopyName1 copies it to wStringBuffer2. */
|
||||
@@ -960,6 +979,7 @@ export class ScriptRun {
|
||||
}
|
||||
if (options.trainer)
|
||||
this.tempTrainer = new TrainerData(reader).header(options.trainer.header);
|
||||
if (options.caller !== undefined) this.curCaller = options.caller;
|
||||
this.iterator = this.execute(start);
|
||||
}
|
||||
|
||||
@@ -1306,8 +1326,9 @@ export class ScriptRun {
|
||||
receiveItem(data, this.state.bag, ball.item, ball.quantity),
|
||||
);
|
||||
} else if (routine === NATIVE_ROUTINES.hangUp) {
|
||||
// HangUp_Beep prints "Click!", then the hang-up tones pass
|
||||
// before closetext; no button is needed.
|
||||
// HangUp_Beep prints "Click!" with SFX_HANG_UP, then the
|
||||
// hang-up tones pass before closetext; no button is needed.
|
||||
this.host.sound?.playSFX(SOUND.SFX_HANG_UP);
|
||||
write(SYMBOLS.phoneClickText);
|
||||
yield {
|
||||
kind: "text",
|
||||
@@ -1316,13 +1337,21 @@ export class ScriptRun {
|
||||
};
|
||||
write(POKEGEAR.PhoneEllipseText);
|
||||
yield { kind: "text", pages: take(), holdTicks: HANG_UP.endTicks };
|
||||
} else if (
|
||||
routine === NATIVE_ROUTINES.ringTwiceStartCall ||
|
||||
routine === NATIVE_ROUTINES.initCallReceiveDelay
|
||||
) {
|
||||
// The ringing tones and caller name box are not presented yet.
|
||||
// InitCallReceiveDelay restarts the random-call timers, and
|
||||
// random calls are not ported.
|
||||
} else if (routine === NATIVE_ROUTINES.ringTwiceStartCall) {
|
||||
// RingTwice_StartCall: twice SFX_CALL (after any sound effect)
|
||||
// with the caller's name box shown, cleared and shown again,
|
||||
// 20 frames each.
|
||||
yield* flush();
|
||||
const name = `☎ ${callerClassAndName(r, this.curCaller, this.text())}`;
|
||||
for (let ring = 0; ring < 2; ring++) {
|
||||
yield* waitSFX();
|
||||
this.host.sound?.playSFX(SOUND.SFX_CALL);
|
||||
for (const page of [name, " ", name])
|
||||
yield { kind: "text", pages: [page], holdTicks: 10 };
|
||||
}
|
||||
} else if (routine === NATIVE_ROUTINES.initCallReceiveDelay) {
|
||||
// InitCallReceiveDelay restarts the random-call timer.
|
||||
this.host.initCallReceiveDelay?.();
|
||||
} else if (routine === OVERWORLD.GetPartyNickname)
|
||||
this.partyNickname();
|
||||
else if (routine === OVERWORLD["AskCutScript.CheckMap"])
|
||||
@@ -1515,8 +1544,12 @@ export class ScriptRun {
|
||||
break;
|
||||
}
|
||||
case 0x1d: {
|
||||
// writevar: only VAR_MOVEMENT is writable natively.
|
||||
// writevar: VAR_MOVEMENT and VAR_BLUECARDBALANCE are writable.
|
||||
const variable = byte();
|
||||
if (variable === 0x18) {
|
||||
this.state.blueCardBalance = value & 0xff;
|
||||
break;
|
||||
}
|
||||
if (variable !== 8)
|
||||
throw new UnsupportedScript(
|
||||
offset,
|
||||
@@ -1651,6 +1684,7 @@ export class ScriptRun {
|
||||
PHONE_BILL * PHONE_CONTACT_SIZE +
|
||||
PHONE_CONTACT_SCRIPT2_BANK,
|
||||
),
|
||||
caller: PHONE_BILL,
|
||||
});
|
||||
canLose = false;
|
||||
break;
|
||||
@@ -1862,6 +1896,22 @@ export class ScriptRun {
|
||||
// itself is unchanged.
|
||||
this.strings[buffer(byte())] = String(value);
|
||||
break;
|
||||
case 0xa0: {
|
||||
// swarm: StoreSwarmMapIndices, Dunsparce's (0) or Yanma's map.
|
||||
const which = byte(),
|
||||
group = byte(),
|
||||
number = byte();
|
||||
const memory = this.state.memory;
|
||||
if (which) Object.assign(memory, { wYanmaMapGroup: group, wYanmaMapNumber: number });
|
||||
else Object.assign(memory, { wDunsparceMapGroup: group, wDunsparceMapNumber: number });
|
||||
break;
|
||||
}
|
||||
case 0xa6: {
|
||||
// gettrainerclassname: GetName for TRAINER_NAME, the class's name.
|
||||
const classId = byte();
|
||||
this.strings[buffer(byte())] = new TrainerData(r).className(classId);
|
||||
break;
|
||||
}
|
||||
case 0xa4: {
|
||||
// battletowertext: BattleTowerText for the loaded opponent.
|
||||
const which = byte();
|
||||
@@ -3184,6 +3234,7 @@ export class ScriptRun {
|
||||
callerScript: doll
|
||||
? POKEGEAR["Mom_GetScriptPointer.DollScript"]
|
||||
: POKEGEAR["Mom_GetScriptPointer.ItemScript"],
|
||||
caller: PHONE_MOM,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3370,11 +3421,99 @@ export class ScriptRun {
|
||||
return this.state.unownDex.length;
|
||||
case 0x16:
|
||||
return this.state.memory.wKurtApricornQuantity ?? 0;
|
||||
case 0x18:
|
||||
return this.state.blueCardBalance;
|
||||
case 0x19:
|
||||
return this.state.memory.wBuenasPassword ?? 0;
|
||||
case 0x1a:
|
||||
// VAR_KENJI_BREAK: wKenjiBreakTimer's days left.
|
||||
return this.state.memory.wKenjiBreakTimer ?? 0;
|
||||
default:
|
||||
throw new UnsupportedScript(offset, 0x1c, ` (variable ${id})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** BuenaPrize: the prizes and their points beside the Blue Card's
|
||||
* balance; a prize is confirmed, paid for and received, until B. */
|
||||
private *buenaPrize(pages: string[], take: () => string[]): Generator<ScriptEffect, number, Input> {
|
||||
const r = this.reader,
|
||||
data = this.host.data;
|
||||
const prizes = Array.from({ length: BUENA_PRIZES }, (_, index) => ({
|
||||
item: r.byte(PHONE.BuenaPrizeItems + index * 2),
|
||||
points: r.byte(PHONE.BuenaPrizeItems + index * 2 + 1),
|
||||
}));
|
||||
const say = (address: number) =>
|
||||
pages.push(...readText(r, address, this.text()).split("\n\n").filter(Boolean));
|
||||
const balance = () =>
|
||||
`${readString(r, PHONE["PrintBlueCardBalance.Points_string"], this.text())} ${String(this.state.blueCardBalance).padStart(2, " ")}`;
|
||||
let selected = 0;
|
||||
say(PHONE["BuenaPrize.BuenaAskWhichPrizeText"]);
|
||||
for (;;) {
|
||||
const choice = yield {
|
||||
kind: "menu",
|
||||
pages: take(),
|
||||
items: prizes.map(({ item }) => data.itemName(item)),
|
||||
details: prizes.map(({ points }) => String(points)),
|
||||
selected,
|
||||
balance: balance(),
|
||||
};
|
||||
if (typeof choice !== "number" || choice < 0 || choice >= prizes.length) break;
|
||||
selected = choice;
|
||||
const prize = prizes[choice];
|
||||
this.strings[RAM.stringBuffer1] = data.itemName(prize.item);
|
||||
say(PHONE["BuenaPrize.BuenaIsThatRightText"]);
|
||||
if (yield { kind: "yesno", pages: take() }) {
|
||||
if (this.state.blueCardBalance < prize.points) say(PHONE["BuenaPrize.BuenaNotEnoughPointsText"]);
|
||||
else if (!receiveItem(data, this.state.bag, prize.item, 1)) say(PHONE["BuenaPrize.BuenaNoRoomText"]);
|
||||
else {
|
||||
this.state.blueCardBalance -= prize.points;
|
||||
this.host.sound?.playSFX(SOUND.SFX_TRANSACTION);
|
||||
say(PHONE["BuenaPrize.BuenaHereYouGoText"]);
|
||||
}
|
||||
yield { kind: "text", pages: take() };
|
||||
}
|
||||
say(PHONE["BuenaPrize.BuenaAskWhichPrizeText"]);
|
||||
}
|
||||
take();
|
||||
say(PHONE["BuenaPrize.BuenaComeAgainText"]);
|
||||
yield { kind: "text", pages: take() };
|
||||
this.host.sound?.playSFX(SOUND.SFX_READ_TEXT_2);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The radio's view of this script: its draft, clock and sound. */
|
||||
private radioHost(): RadioHost {
|
||||
const host = this.host;
|
||||
return {
|
||||
reader: this.reader,
|
||||
data: host.data,
|
||||
dex: new PokedexData(this.reader),
|
||||
state: this.state,
|
||||
text: () => this.text(),
|
||||
random: () => host.random(),
|
||||
timeOfDay: () => host.timeOfDay(),
|
||||
hours: () => host.clock().hours,
|
||||
weekday: () => host.clock().weekday,
|
||||
landmark: () => host.landmark(),
|
||||
landmarkOf: (mapId) => host.landmarkOf?.(mapId) ?? host.landmark(),
|
||||
special: () => radioSpecialContext(this.state, host),
|
||||
sound: host.sound,
|
||||
};
|
||||
}
|
||||
|
||||
/** GetCallerLocation and LookUpWildmonsForMapDE: the caller's map's
|
||||
* entry in JohtoGrassWildMons, else KantoGrassWildMons. */
|
||||
private callerGrassTable(): number | undefined {
|
||||
const r = this.reader,
|
||||
contact = PC.PhoneContacts + this.curCaller * PHONE_CONTACT_SIZE;
|
||||
const group = r.byte(contact + 2),
|
||||
number = r.byte(contact + 3);
|
||||
for (const table of [PHONE.JohtoGrassWildMons, PHONE.KantoGrassWildMons])
|
||||
for (let entry = table; r.byte(entry) !== 0xff; entry += GRASS_WILDDATA_LENGTH)
|
||||
if (r.byte(entry) === group && r.byte(entry + 1) === number) return entry;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Native translations of special routines, selected by routine address.
|
||||
* `value` is the script's current wScriptVar, in case a routine (like
|
||||
* CheckPokerus) returns a new one; routines that leave it alone return it
|
||||
@@ -3600,6 +3739,89 @@ export class ScriptRun {
|
||||
case JOHTO.PhotoStudio:
|
||||
yield* photoStudio(this.specialContext(pages, take));
|
||||
return value;
|
||||
case PHONE.MapRadio: {
|
||||
// MapRadio and PlayRadio: the station's name in quotes for 100
|
||||
// frames, then the station, every frame until A or B.
|
||||
if (this.options.callback) throw new Error("Map callbacks cannot present scenes.");
|
||||
const sound = this.host.sound;
|
||||
const radioHost = this.radioHost();
|
||||
const station = mapRadioStation(
|
||||
r,
|
||||
value & 0xff,
|
||||
{ landmark: this.host.landmark(), timeOfDay: this.host.timeOfDay() },
|
||||
this.text(),
|
||||
);
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
const show = new RadioShow(radioHost, station.line, station.name);
|
||||
let wait = 100;
|
||||
let text = `“${station.name}”`;
|
||||
for (;;) {
|
||||
const button = yield { kind: "mapradio", text };
|
||||
if (wait) {
|
||||
wait--;
|
||||
continue;
|
||||
}
|
||||
if (button === "a" || button === "b") break;
|
||||
show.frame();
|
||||
text = show.lines.join("\n");
|
||||
}
|
||||
exitRadioMusic(sound, show.musicPlaying || ENTER_MAP_MUSIC, this.state.playerState, this.host.mapMusic?.(this.state) ?? 0);
|
||||
return value;
|
||||
}
|
||||
case PHONE.AskRememberPassword: {
|
||||
// AskRememberPassword: a YES/NO box; YES is 1.
|
||||
const answer = yield { kind: "yesno", pages: take() };
|
||||
return answer ? 1 : 0;
|
||||
}
|
||||
case PHONE.BuenasPassword: {
|
||||
// BuenasPassword: the category's three words, B disabled; 1 when
|
||||
// the chosen one is today's password.
|
||||
const password = this.state.memory.wBuenasPassword ?? 0;
|
||||
const items = [0, 1, 2].map((word) =>
|
||||
buenasPassword(r, this.host.data, (password & 0xf0) + word, this.text()),
|
||||
);
|
||||
const shown = take();
|
||||
for (;;) {
|
||||
const choice = yield { kind: "menu", pages: shown.length ? shown : [""], items, selected: 0 };
|
||||
if (typeof choice === "number" && choice >= 0 && choice < 3)
|
||||
return Number(choice === (password & 3));
|
||||
}
|
||||
}
|
||||
case PHONE.BuenaPrize:
|
||||
return yield* this.buenaPrize(pages, take);
|
||||
case PHONE.ActivateFishingSwarm:
|
||||
this.state.memory.wFishingSwarmFlag = value & 0xff;
|
||||
return value;
|
||||
case PHONE.RandomPhoneWildMon: {
|
||||
// RandomPhoneWildMon: one of the four commonest grass Pokémon at
|
||||
// this time of day on the caller's map, named in wStringBuffer4.
|
||||
const entry = this.callerGrassTable();
|
||||
const slot = this.host.random() & 3;
|
||||
const species = entry === undefined
|
||||
? 0
|
||||
: r.byte(entry + 5 + this.host.timeOfDay() * GRASS_MON_BYTES + slot * 2 + 1);
|
||||
this.strings[RAM.stringBuffer4] = this.host.data.speciesName(species);
|
||||
return value;
|
||||
}
|
||||
case PHONE.RandomUnseenWildMon: {
|
||||
// RandomUnseenWildMon: one of the three rarest grass Pokémon on the
|
||||
// caller's map at this time of day, compared with the morning's
|
||||
// commonest four (the original's bug); one never seen gets a text.
|
||||
const entry = this.callerGrassTable();
|
||||
if (entry === undefined) return 1;
|
||||
let roll: number;
|
||||
do roll = this.host.random() & 3;
|
||||
while (!roll);
|
||||
const rare = r.byte(entry + 5 + 4 * 2 + this.host.timeOfDay() * GRASS_MON_BYTES + (roll - 1) * 2 + 1);
|
||||
for (let slot = 0; slot < 4; slot++)
|
||||
if (r.byte(entry + 5 + slot * 2 + 1) === rare) return 1;
|
||||
if (this.state.pokedex.seen.has(rare)) return 1;
|
||||
// CopyName1 keeps wStringBuffer1 in wStringBuffer2 first.
|
||||
this.strings[RAM.stringBuffer2] = this.strings[RAM.stringBuffer1] ?? "";
|
||||
this.strings[RAM.stringBuffer1] = this.host.data.speciesName(rare);
|
||||
pages.push(...readText(r, PHONE["RandomUnseenWildMon.JustSawSomeRareMonText"], this.text()).split("\n\n"));
|
||||
return 0;
|
||||
}
|
||||
case JOHTO.PlaySlowCry: {
|
||||
// PlaySlowCry: wScriptVar's cry, pitch - $140 and length + $60.
|
||||
const species = value & 0xff;
|
||||
|
||||
+142
-18
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import type { PokedexData } from "../rom/pokedex";
|
||||
import { NUM_POKEMON, NUM_TRAINER_CLASSES, RADIO, RAM, ENGINE_FLAGS, SYMBOLS } from "../rom/profile";
|
||||
import { TrainerData } from "../rom/trainers";
|
||||
import { OW } from "../world/constants";
|
||||
import { SOUND } from "../audio/constants";
|
||||
import type { GameSound } from "../audio/sound";
|
||||
import type { ScriptState } from "./interpreter";
|
||||
import type { SpecialContext } from "./special-context";
|
||||
import { readString, readText, type TextContext } from "./text";
|
||||
import { landmarkName } from "./interpreter";
|
||||
import { checkLuckyNumberShowFlag, resetLuckyNumberShowFlag } from "./goldenrod";
|
||||
|
||||
/** Radio channels and segments (constants/radio_constants.asm), verified
|
||||
* by `npm run check:reference`. */
|
||||
export const RADIO_LINES = {
|
||||
OAKS_POKEMON_TALK: 0x00,
|
||||
POKEDEX_SHOW: 0x01,
|
||||
POKEMON_MUSIC: 0x02,
|
||||
LUCKY_CHANNEL: 0x03,
|
||||
BUENAS_PASSWORD: 0x04,
|
||||
PLACES_AND_PEOPLE: 0x05,
|
||||
LETS_ALL_SING: 0x06,
|
||||
ROCKET_RADIO: 0x07,
|
||||
POKE_FLUTE_RADIO: 0x08,
|
||||
UNOWN_RADIO: 0x09,
|
||||
EVOLUTION_RADIO: 0x0a,
|
||||
OAKS_POKEMON_TALK_2: 0x0b,
|
||||
OAKS_POKEMON_TALK_3: 0x0c,
|
||||
OAKS_POKEMON_TALK_4: 0x0d,
|
||||
OAKS_POKEMON_TALK_5: 0x0e,
|
||||
OAKS_POKEMON_TALK_6: 0x0f,
|
||||
OAKS_POKEMON_TALK_7: 0x10,
|
||||
OAKS_POKEMON_TALK_8: 0x11,
|
||||
OAKS_POKEMON_TALK_9: 0x12,
|
||||
POKEDEX_SHOW_2: 0x13,
|
||||
POKEDEX_SHOW_3: 0x14,
|
||||
POKEDEX_SHOW_4: 0x15,
|
||||
POKEDEX_SHOW_5: 0x16,
|
||||
POKEMON_MUSIC_2: 0x17,
|
||||
POKEMON_MUSIC_3: 0x18,
|
||||
POKEMON_MUSIC_4: 0x19,
|
||||
POKEMON_MUSIC_5: 0x1a,
|
||||
POKEMON_MUSIC_6: 0x1b,
|
||||
POKEMON_MUSIC_7: 0x1c,
|
||||
LETS_ALL_SING_2: 0x1d,
|
||||
LUCKY_NUMBER_SHOW_2: 0x1e,
|
||||
LUCKY_NUMBER_SHOW_3: 0x1f,
|
||||
LUCKY_NUMBER_SHOW_4: 0x20,
|
||||
LUCKY_NUMBER_SHOW_5: 0x21,
|
||||
LUCKY_NUMBER_SHOW_6: 0x22,
|
||||
LUCKY_NUMBER_SHOW_7: 0x23,
|
||||
LUCKY_NUMBER_SHOW_8: 0x24,
|
||||
LUCKY_NUMBER_SHOW_9: 0x25,
|
||||
LUCKY_NUMBER_SHOW_10: 0x26,
|
||||
LUCKY_NUMBER_SHOW_11: 0x27,
|
||||
LUCKY_NUMBER_SHOW_12: 0x28,
|
||||
LUCKY_NUMBER_SHOW_13: 0x29,
|
||||
LUCKY_NUMBER_SHOW_14: 0x2a,
|
||||
LUCKY_NUMBER_SHOW_15: 0x2b,
|
||||
PLACES_AND_PEOPLE_2: 0x2c,
|
||||
PLACES_AND_PEOPLE_3: 0x2d,
|
||||
PLACES_AND_PEOPLE_4: 0x2e,
|
||||
PLACES_AND_PEOPLE_5: 0x2f,
|
||||
PLACES_AND_PEOPLE_6: 0x30,
|
||||
PLACES_AND_PEOPLE_7: 0x31,
|
||||
ROCKET_RADIO_2: 0x32,
|
||||
ROCKET_RADIO_10: 0x3a,
|
||||
OAKS_POKEMON_TALK_10: 0x3b,
|
||||
OAKS_POKEMON_TALK_11: 0x3c,
|
||||
OAKS_POKEMON_TALK_12: 0x3d,
|
||||
OAKS_POKEMON_TALK_13: 0x3e,
|
||||
OAKS_POKEMON_TALK_14: 0x3f,
|
||||
BUENAS_PASSWORD_2: 0x40,
|
||||
BUENAS_PASSWORD_3: 0x41,
|
||||
BUENAS_PASSWORD_4: 0x42,
|
||||
BUENAS_PASSWORD_5: 0x43,
|
||||
BUENAS_PASSWORD_6: 0x44,
|
||||
BUENAS_PASSWORD_7: 0x45,
|
||||
BUENAS_PASSWORD_8: 0x46,
|
||||
BUENAS_PASSWORD_9: 0x47,
|
||||
BUENAS_PASSWORD_19: 0x51,
|
||||
BUENAS_PASSWORD_20: 0x52,
|
||||
BUENAS_PASSWORD_21: 0x53,
|
||||
RADIO_SCROLL: 0x54,
|
||||
POKEDEX_SHOW_6: 0x55,
|
||||
POKEDEX_SHOW_7: 0x56,
|
||||
POKEDEX_SHOW_8: 0x57,
|
||||
MAPRADIO_POKEMON_CHANNEL: 0,
|
||||
MAPRADIO_OAKS_POKEMON_TALK: 1,
|
||||
MAPRADIO_POKEDEX_SHOW: 2,
|
||||
MAPRADIO_POKEMON_MUSIC: 3,
|
||||
MAPRADIO_LUCKY_CHANNEL: 4,
|
||||
MAPRADIO_UNOWN: 5,
|
||||
MAPRADIO_PLACES_PEOPLE: 6,
|
||||
MAPRADIO_LETS_ALL_SING: 7,
|
||||
MAPRADIO_ROCKET: 8,
|
||||
NUM_PASSWORD_CATEGORIES: 11,
|
||||
NUM_PASSWORDS_PER_CATEGORY: 3,
|
||||
BUENA_MON: 0,
|
||||
BUENA_ITEM: 1,
|
||||
BUENA_MOVE: 2,
|
||||
BUENA_STRING: 3,
|
||||
NITE_HOUR: 18,
|
||||
LANDMARK_RUINS_OF_ALPH: 0x09,
|
||||
LANDMARK_MAHOGANY_TOWN: 0x24,
|
||||
LANDMARK_ROUTE_43: 0x25,
|
||||
LANDMARK_LAKE_OF_RAGE: 0x26,
|
||||
LANDMARK_FAST_SHIP: 95,
|
||||
KANTO_LANDMARK: 47,
|
||||
} as const;
|
||||
const L = RADIO_LINES;
|
||||
|
||||
/** wPokegearRadioMusicPlaying's markers for ExitPokegearRadio_HandleMusic. */
|
||||
export const RESTART_MAP_MUSIC = 0xfe;
|
||||
export const ENTER_MAP_MUSIC = 0xff;
|
||||
|
||||
export interface RadioHost {
|
||||
reader: RomReader;
|
||||
data: PokemonData;
|
||||
dex: PokedexData;
|
||||
/** The draft the radio may change: the Lucky Number, Buena's password. */
|
||||
state: ScriptState;
|
||||
text(): TextContext;
|
||||
random(): number;
|
||||
/** wTimeOfDay, hHours and GetWeekday. */
|
||||
timeOfDay(): number;
|
||||
hours(): number;
|
||||
weekday(): number;
|
||||
/** The player's landmark (wPokegearMapPlayerIconLandmark, IsInJohto). */
|
||||
landmark(): number;
|
||||
/** GetWorldMapLocation for any map. */
|
||||
landmarkOf(mapId: string): number;
|
||||
/** The Lucky Number routines' context. */
|
||||
special(): SpecialContext;
|
||||
sound?: GameSound;
|
||||
}
|
||||
|
||||
/** A radio station playing (engine/pokegear/radio.asm's PlayRadioShow and
|
||||
* pokegear.asm's LoadStation_*): wCurRadioLine's segment runs once per
|
||||
* video frame; each line stays 100 frames before the box scrolls up. The
|
||||
* box shows `lines` (the top and bottom rows). */
|
||||
export class RadioShow {
|
||||
/** wCurRadioLine, wNextRadioLine, wRadioTextDelay, wNumRadioLinesPrinted. */
|
||||
private line: number;
|
||||
private next = 0;
|
||||
private delay = 0;
|
||||
private printed = 0;
|
||||
/** wOaksPKMNTalkSegmentCounter and wCurPartySpecies. */
|
||||
private segments = 0;
|
||||
private species = 0;
|
||||
/** The Pokédex show's lines still to come (wPokedexShowPointerAddr). */
|
||||
private dexLines: string[] = [];
|
||||
/** wRadioText: the line copied for printing. */
|
||||
private pending = "";
|
||||
private strings: Record<number, string> = {};
|
||||
/** The text box's two rows. */
|
||||
lines: [string, string] = ["", ""];
|
||||
/** The station name LoadStation_* placed, or BuenasPassword1's. */
|
||||
name: string;
|
||||
/** wPokegearRadioMusicPlaying. */
|
||||
musicPlaying = 0;
|
||||
|
||||
constructor(
|
||||
private host: RadioHost,
|
||||
station: number,
|
||||
name: string,
|
||||
) {
|
||||
this.line = station;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/** PlayRadioShow: one frame. */
|
||||
frame() {
|
||||
// Team Rocket broadcasts on every station from the tower, in Johto.
|
||||
if (
|
||||
this.line < L.POKE_FLUTE_RADIO &&
|
||||
this.host.state.flags.has(ENGINE_FLAGS.rocketsInRadioTower) &&
|
||||
inJohto(this.host.landmark())
|
||||
)
|
||||
this.line = L.ROCKET_RADIO;
|
||||
this.segment(this.line);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- helpers
|
||||
|
||||
private textLine(address: number) {
|
||||
const context = { ...this.host.text() };
|
||||
context.strings = { ...context.strings, ...this.strings };
|
||||
return readText(this.host.reader, address, context).replace(/\n+/g, " ").trim();
|
||||
}
|
||||
/** NextRadioLine: copy a text, then print it. */
|
||||
private nextLine(address: number, next: number) {
|
||||
this.pending = this.textLine(address);
|
||||
this.printLine(next);
|
||||
}
|
||||
/** PrintRadioLine: the first two lines fill the box, later ones the
|
||||
* bottom row; the box then waits 100 frames before scrolling. */
|
||||
private printLine(next: number) {
|
||||
this.next = next;
|
||||
if (this.printed < 2) {
|
||||
this.printed++;
|
||||
if (this.printed === 1) this.lines = [this.pending, this.lines[1]];
|
||||
else this.lines = [this.lines[0], this.pending];
|
||||
} else this.lines = [this.lines[0], this.pending];
|
||||
this.line = L.RADIO_SCROLL;
|
||||
this.delay = 100;
|
||||
}
|
||||
/** RadioScroll */
|
||||
private scroll() {
|
||||
if (this.delay) {
|
||||
this.delay--;
|
||||
return;
|
||||
}
|
||||
this.line = this.next;
|
||||
if (this.printed !== 1) this.lines = [this.lines[1], ""];
|
||||
else this.lines = [this.lines[0], ""];
|
||||
}
|
||||
/** RadioMusicRestartDE: the station's song, which also becomes the map
|
||||
* music. */
|
||||
private music(song: number) {
|
||||
this.musicPlaying = song;
|
||||
const sound = this.host.sound;
|
||||
if (!sound) return;
|
||||
sound.playMusic(SOUND.MUSIC_NONE);
|
||||
sound.engine.mapMusic = song;
|
||||
sound.playMusic(song);
|
||||
}
|
||||
/** StartRadioStation: on the first line, an empty box and the channel's
|
||||
* song (RadioChannelSongs). */
|
||||
private start() {
|
||||
if (this.printed) return;
|
||||
this.lines = ["", ""];
|
||||
const r = this.host.reader;
|
||||
this.music(r.word(RADIO.RadioChannelSongs + this.line * 2));
|
||||
}
|
||||
private random() {
|
||||
return this.host.random() & 0xff;
|
||||
}
|
||||
private far(table: number, index: number) {
|
||||
const r = this.host.reader;
|
||||
return r.near(table + index * 2, r.bank(table));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- segments
|
||||
|
||||
private segment(line: number) {
|
||||
const r = this.host.reader,
|
||||
data = this.host.data,
|
||||
state = this.host.state;
|
||||
switch (line) {
|
||||
case L.RADIO_SCROLL:
|
||||
return this.scroll();
|
||||
// Oak's Pokémon Talk
|
||||
case L.OAKS_POKEMON_TALK:
|
||||
this.segments = 5;
|
||||
this.start();
|
||||
return this.nextLine(RADIO.OPT_IntroText1, L.OAKS_POKEMON_TALK_2);
|
||||
case L.OAKS_POKEMON_TALK_2:
|
||||
return this.nextLine(RADIO.OPT_IntroText2, L.OAKS_POKEMON_TALK_3);
|
||||
case L.OAKS_POKEMON_TALK_3:
|
||||
return this.nextLine(RADIO.OPT_IntroText3, L.OAKS_POKEMON_TALK_4);
|
||||
case L.OAKS_POKEMON_TALK_4: {
|
||||
// A random route, then a random Pokémon of its morning, day or
|
||||
// night list's middle slots.
|
||||
const routes = (RADIO["OaksPKMNTalkRoutes.End"] - RADIO.OaksPKMNTalkRoutes) / 2;
|
||||
let route: number;
|
||||
do route = this.random() & 0x1f;
|
||||
while (route >= routes);
|
||||
const group = r.byte(RADIO.OaksPKMNTalkRoutes + route * 2),
|
||||
number = r.byte(RADIO.OaksPKMNTalkRoutes + route * 2 + 1);
|
||||
let entry = SYMBOLS.johtoGrassWildMons;
|
||||
while (r.byte(entry) !== 0xff && !(r.byte(entry) === group && r.byte(entry + 1) === number))
|
||||
entry += 47;
|
||||
// .overflow: the previous line prints again.
|
||||
if (r.byte(entry) === 0xff) return this.printLine(L.OAKS_POKEMON_TALK);
|
||||
let time: number;
|
||||
do time = this.random() & 3;
|
||||
while (time === 3);
|
||||
let slot: number;
|
||||
do slot = this.random() & 7;
|
||||
while (slot < 2 || slot >= 5);
|
||||
this.species = r.byte(entry + 5 + time * 14 + slot * 2 + 1);
|
||||
this.strings[RAM.monOrItemNameBuffer] = data.speciesName(this.species);
|
||||
this.strings[RAM.stringBuffer1] = landmarkName(r, this.host.landmarkOf(`${group}:${number}`), this.host.text());
|
||||
this.pending = this.textLine(RADIO.OPT_OakText1);
|
||||
return this.printLine(L.OAKS_POKEMON_TALK_5);
|
||||
}
|
||||
case L.OAKS_POKEMON_TALK_5:
|
||||
return this.nextLine(RADIO.OPT_OakText2, L.OAKS_POKEMON_TALK_6);
|
||||
case L.OAKS_POKEMON_TALK_6:
|
||||
return this.nextLine(RADIO.OPT_OakText3, L.OAKS_POKEMON_TALK_7);
|
||||
case L.OAKS_POKEMON_TALK_7:
|
||||
this.strings[RAM.stringBuffer1] = data.speciesName(this.species);
|
||||
return this.nextLine(RADIO.OPT_MaryText1, L.OAKS_POKEMON_TALK_8);
|
||||
case L.OAKS_POKEMON_TALK_8:
|
||||
return this.nextLine(this.far(RADIO["OaksPKMNTalk8.Adverbs"], this.random() & 15), L.OAKS_POKEMON_TALK_9);
|
||||
case L.OAKS_POKEMON_TALK_9: {
|
||||
const text = this.far(RADIO["OaksPKMNTalk9.Adjectives"], this.random() & 15);
|
||||
let next: number = L.OAKS_POKEMON_TALK_4;
|
||||
if (!--this.segments) {
|
||||
this.segments = 5;
|
||||
next = L.OAKS_POKEMON_TALK_10;
|
||||
}
|
||||
return this.nextLine(text, next);
|
||||
}
|
||||
case L.OAKS_POKEMON_TALK_10:
|
||||
// RadioMusicRestartPokemonChannel, then "#MON" alone.
|
||||
this.musicPlaying = RESTART_MAP_MUSIC;
|
||||
this.host.sound?.playMusic(SOUND.MUSIC_NONE);
|
||||
this.host.sound?.playMusic(SOUND.MUSIC_POKEMON_CHANNEL);
|
||||
this.lines = [this.textLine(RADIO.OPT_PokemonChannelText), ""];
|
||||
this.line = L.OAKS_POKEMON_TALK_11;
|
||||
this.delay = 100;
|
||||
return;
|
||||
case L.OAKS_POKEMON_TALK_11:
|
||||
case L.OAKS_POKEMON_TALK_12:
|
||||
case L.OAKS_POKEMON_TALK_13: {
|
||||
if (--this.delay) return;
|
||||
const pokemon = this.lines[0];
|
||||
if (line === L.OAKS_POKEMON_TALK_11) this.lines = [`${pokemon} ${pokemon}`, ""];
|
||||
else if (line === L.OAKS_POKEMON_TALK_12) this.lines = [this.lines[0], `${pokemon.split(" ")[0]} Channel`];
|
||||
this.line = line + 1;
|
||||
this.delay = 100;
|
||||
return;
|
||||
}
|
||||
case L.OAKS_POKEMON_TALK_14:
|
||||
if (--this.delay) return;
|
||||
this.music(SOUND.MUSIC_POKEMON_TALK);
|
||||
this.lines = ["", ""];
|
||||
this.next = L.OAKS_POKEMON_TALK_4;
|
||||
this.printed = 0;
|
||||
this.line = L.RADIO_SCROLL;
|
||||
this.delay = 10;
|
||||
return;
|
||||
// The Pokédex Show
|
||||
case L.POKEDEX_SHOW: {
|
||||
this.start();
|
||||
if (!state.pokedex.caught.size) return;
|
||||
let species: number;
|
||||
do species = this.random();
|
||||
while (species >= NUM_POKEMON || !state.pokedex.caught.has(species + 1));
|
||||
this.species = species + 1;
|
||||
this.strings[RAM.stringBuffer1] = data.speciesName(this.species);
|
||||
return this.nextLine(RADIO.PokedexShowText, L.POKEDEX_SHOW_2);
|
||||
}
|
||||
case L.POKEDEX_SHOW_2: {
|
||||
// The species category, then the entry's six lines one by one.
|
||||
const entry = this.host.dex.entry(this.species);
|
||||
this.dexLines = [...entry.pages[0].split("\n"), ...entry.pages[1].split("\n")];
|
||||
this.pending = entry.category;
|
||||
return this.printLine(L.POKEDEX_SHOW_3);
|
||||
}
|
||||
case L.POKEDEX_SHOW_3:
|
||||
case L.POKEDEX_SHOW_4:
|
||||
case L.POKEDEX_SHOW_5:
|
||||
case L.POKEDEX_SHOW_6:
|
||||
case L.POKEDEX_SHOW_7:
|
||||
case L.POKEDEX_SHOW_8: {
|
||||
this.pending = this.dexLines.shift() ?? "";
|
||||
const next =
|
||||
line === L.POKEDEX_SHOW_5
|
||||
? L.POKEDEX_SHOW_6
|
||||
: line === L.POKEDEX_SHOW_8
|
||||
? L.POKEDEX_SHOW
|
||||
: line + 1;
|
||||
return this.printLine(next);
|
||||
}
|
||||
// Pokémon Music and Let's All Sing
|
||||
case L.POKEMON_MUSIC:
|
||||
this.startMusicChannel();
|
||||
return this.nextLine(RADIO.BenIntroText1, L.POKEMON_MUSIC_2);
|
||||
case L.POKEMON_MUSIC_2:
|
||||
return this.nextLine(RADIO.BenIntroText2, L.POKEMON_MUSIC_3);
|
||||
case L.POKEMON_MUSIC_3:
|
||||
return this.nextLine(RADIO.BenIntroText3, L.POKEMON_MUSIC_4);
|
||||
case L.LETS_ALL_SING:
|
||||
this.startMusicChannel();
|
||||
return this.nextLine(RADIO.FernIntroText1, L.LETS_ALL_SING_2);
|
||||
case L.LETS_ALL_SING_2:
|
||||
return this.nextLine(RADIO.FernIntroText2, L.POKEMON_MUSIC_4);
|
||||
case L.POKEMON_MUSIC_4:
|
||||
return this.nextLine(RADIO.BenFernText1, L.POKEMON_MUSIC_5);
|
||||
case L.POKEMON_MUSIC_5:
|
||||
return this.nextLine(
|
||||
this.host.weekday() & 1 ? RADIO.BenFernText2B : RADIO.BenFernText2A,
|
||||
L.POKEMON_MUSIC_6,
|
||||
);
|
||||
case L.POKEMON_MUSIC_6:
|
||||
return this.nextLine(
|
||||
this.host.weekday() & 1 ? RADIO.BenFernText3B : RADIO.BenFernText3A,
|
||||
L.POKEMON_MUSIC_7,
|
||||
);
|
||||
case L.POKEMON_MUSIC_7:
|
||||
return;
|
||||
// The Lucky Channel
|
||||
case L.LUCKY_CHANNEL: {
|
||||
this.start();
|
||||
const special = this.host.special();
|
||||
if (checkLuckyNumberShowFlag(special)) resetLuckyNumberShowFlag(special);
|
||||
return this.nextLine(RADIO.LC_Text1, L.LUCKY_NUMBER_SHOW_2);
|
||||
}
|
||||
case L.LUCKY_NUMBER_SHOW_2:
|
||||
case L.LUCKY_NUMBER_SHOW_3:
|
||||
case L.LUCKY_NUMBER_SHOW_4:
|
||||
case L.LUCKY_NUMBER_SHOW_5:
|
||||
case L.LUCKY_NUMBER_SHOW_6: {
|
||||
const texts = [RADIO.LC_Text2, RADIO.LC_Text3, RADIO.LC_Text4, RADIO.LC_Text5, RADIO.LC_Text6];
|
||||
return this.nextLine(texts[line - L.LUCKY_NUMBER_SHOW_2], line + 1);
|
||||
}
|
||||
case L.LUCKY_NUMBER_SHOW_7:
|
||||
return this.nextLine(RADIO.LC_Text7, L.LUCKY_NUMBER_SHOW_8);
|
||||
case L.LUCKY_NUMBER_SHOW_8:
|
||||
this.strings[RAM.stringBuffer1] = String(state.luckyNumber.id).padStart(5, "0");
|
||||
return this.nextLine(RADIO.LC_Text8, L.LUCKY_NUMBER_SHOW_9);
|
||||
case L.LUCKY_NUMBER_SHOW_9:
|
||||
return this.nextLine(RADIO.LC_Text9, L.LUCKY_NUMBER_SHOW_10);
|
||||
case L.LUCKY_NUMBER_SHOW_10:
|
||||
return this.nextLine(RADIO.LC_Text7, L.LUCKY_NUMBER_SHOW_11);
|
||||
case L.LUCKY_NUMBER_SHOW_11:
|
||||
return this.nextLine(RADIO.LC_Text8, L.LUCKY_NUMBER_SHOW_12);
|
||||
case L.LUCKY_NUMBER_SHOW_12:
|
||||
return this.nextLine(RADIO.LC_Text10, L.LUCKY_NUMBER_SHOW_13);
|
||||
case L.LUCKY_NUMBER_SHOW_13:
|
||||
return this.nextLine(RADIO.LC_Text11, this.random() ? L.LUCKY_CHANNEL : L.LUCKY_NUMBER_SHOW_14);
|
||||
case L.LUCKY_NUMBER_SHOW_14:
|
||||
return this.nextLine(RADIO.LC_DragText1, L.LUCKY_NUMBER_SHOW_15);
|
||||
case L.LUCKY_NUMBER_SHOW_15:
|
||||
return this.nextLine(RADIO.LC_DragText2, L.LUCKY_CHANNEL);
|
||||
// Places and People
|
||||
case L.PLACES_AND_PEOPLE:
|
||||
this.start();
|
||||
return this.nextLine(RADIO.PnP_Text1, L.PLACES_AND_PEOPLE_2);
|
||||
case L.PLACES_AND_PEOPLE_2:
|
||||
return this.nextLine(RADIO.PnP_Text2, L.PLACES_AND_PEOPLE_3);
|
||||
case L.PLACES_AND_PEOPLE_3:
|
||||
return this.nextLine(
|
||||
RADIO.PnP_Text3,
|
||||
this.random() < 123 ? L.PLACES_AND_PEOPLE_4 : L.PLACES_AND_PEOPLE_6,
|
||||
);
|
||||
case L.PLACES_AND_PEOPLE_4: {
|
||||
// A random trainer class, but none the player has yet to meet.
|
||||
const hidden = state.hallOfFame
|
||||
? this.allKantoBadges()
|
||||
? RADIO.PnP_HiddenPeople_BeatKanto
|
||||
: RADIO.PnP_HiddenPeople_BeatE4
|
||||
: RADIO.PnP_HiddenPeople;
|
||||
const excluded: number[] = [];
|
||||
for (let p = hidden; r.byte(p) !== 0xff; p++) excluded.push(r.byte(p));
|
||||
let classId: number;
|
||||
do classId = ((this.random() & 0x7f) + 1) & 0xff;
|
||||
while (classId >= NUM_TRAINER_CLASSES || excluded.includes(classId));
|
||||
const trainers = new TrainerData(r);
|
||||
this.strings[RAM.stringBuffer2] = trainers.className(classId);
|
||||
this.strings[RAM.stringBuffer1] = trainers.trainer(classId, 1).name;
|
||||
return this.nextLine(RADIO.PnP_Text4, L.PLACES_AND_PEOPLE_5);
|
||||
}
|
||||
case L.PLACES_AND_PEOPLE_5:
|
||||
case L.PLACES_AND_PEOPLE_7: {
|
||||
const table = line === L.PLACES_AND_PEOPLE_5 ? RADIO["PeoplePlaces5.Adjectives"] : RADIO["PeoplePlaces7.Adjectives"];
|
||||
const text = this.far(table, this.random() & 15);
|
||||
const next =
|
||||
this.random() < 10
|
||||
? L.PLACES_AND_PEOPLE
|
||||
: this.random() < 123
|
||||
? L.PLACES_AND_PEOPLE_4
|
||||
: L.PLACES_AND_PEOPLE_6;
|
||||
return this.nextLine(text, next);
|
||||
}
|
||||
case L.PLACES_AND_PEOPLE_6: {
|
||||
const places = (RADIO["PnP_Places.End"] - RADIO.PnP_Places) / 2;
|
||||
let place: number;
|
||||
do place = this.random();
|
||||
while (place >= places);
|
||||
const map = `${r.byte(RADIO.PnP_Places + place * 2)}:${r.byte(RADIO.PnP_Places + place * 2 + 1)}`;
|
||||
this.strings[RAM.stringBuffer1] = landmarkName(r, this.host.landmarkOf(map), this.host.text());
|
||||
return this.nextLine(RADIO.PnP_Text5, L.PLACES_AND_PEOPLE_7);
|
||||
}
|
||||
// Team Rocket's broadcast
|
||||
case L.ROCKET_RADIO:
|
||||
this.start();
|
||||
return this.nextLine(RADIO.RocketRadioText1, L.ROCKET_RADIO_2);
|
||||
// Stations with music only
|
||||
case L.POKE_FLUTE_RADIO:
|
||||
case L.UNOWN_RADIO:
|
||||
case L.EVOLUTION_RADIO:
|
||||
this.start();
|
||||
this.printed = 1;
|
||||
return;
|
||||
// Buena's Password
|
||||
case L.BUENAS_PASSWORD:
|
||||
return this.buena1();
|
||||
case L.BUENAS_PASSWORD_2:
|
||||
return this.nextLine(RADIO.BuenaRadioText2, L.BUENAS_PASSWORD_3);
|
||||
case L.BUENAS_PASSWORD_3:
|
||||
if (this.host.hours() < L.NITE_HOUR) return this.buenaAfterMidnight(RADIO.BuenaRadioText3);
|
||||
return this.nextLine(RADIO.BuenaRadioText3, L.BUENAS_PASSWORD_4);
|
||||
case L.BUENAS_PASSWORD_4: {
|
||||
if (this.host.hours() < L.NITE_HOUR) return this.buena8();
|
||||
let password = state.memory.wBuenasPassword ?? 0;
|
||||
// A new password once a day, kept in wBuenasPassword.
|
||||
if (!state.flags.has(ENGINE_FLAGS.buenasPassword)) {
|
||||
let category: number, word: number;
|
||||
do category = this.random() & 15;
|
||||
while (category >= L.NUM_PASSWORD_CATEGORIES);
|
||||
do word = this.random() & 3;
|
||||
while (word >= L.NUM_PASSWORDS_PER_CATEGORY);
|
||||
password = (category << 4) + word;
|
||||
state.memory.wBuenasPassword = password;
|
||||
state.flags.add(ENGINE_FLAGS.buenasPassword);
|
||||
}
|
||||
this.strings[RAM.stringBuffer1] = buenasPassword(r, data, password, this.host.text());
|
||||
return this.nextLine(RADIO.BuenaRadioText4, L.BUENAS_PASSWORD_5);
|
||||
}
|
||||
case L.BUENAS_PASSWORD_5:
|
||||
return this.nextLine(RADIO.BuenaRadioText5, L.BUENAS_PASSWORD_6);
|
||||
case L.BUENAS_PASSWORD_6:
|
||||
return this.nextLine(RADIO.BuenaRadioText6, L.BUENAS_PASSWORD_7);
|
||||
case L.BUENAS_PASSWORD_7:
|
||||
if (this.host.hours() < L.NITE_HOUR) return this.buenaAfterMidnight(RADIO.BuenaRadioText7);
|
||||
return this.nextLine(RADIO.BuenaRadioText7, L.BUENAS_PASSWORD);
|
||||
case L.BUENAS_PASSWORD_8:
|
||||
return this.buena8();
|
||||
case L.BUENAS_PASSWORD_19:
|
||||
return this.nextLine(RADIO.BuenaRadioMidnightText10, L.BUENAS_PASSWORD_20);
|
||||
case L.BUENAS_PASSWORD_20:
|
||||
return this.buena20();
|
||||
case L.BUENAS_PASSWORD_21:
|
||||
this.line = L.BUENAS_PASSWORD;
|
||||
this.printed = 0;
|
||||
if (this.host.hours() >= L.NITE_HOUR) return this.buena1();
|
||||
return this.nextLine(RADIO.BuenaOffTheAirText, L.BUENAS_PASSWORD_21);
|
||||
default:
|
||||
if (line > L.ROCKET_RADIO_2 - 1 && line <= L.ROCKET_RADIO_10) {
|
||||
// RocketRadio2-10: the texts in turn, then the first again.
|
||||
const index = line - L.ROCKET_RADIO_2 + 2;
|
||||
const texts = [
|
||||
RADIO.RocketRadioText2, RADIO.RocketRadioText3, RADIO.RocketRadioText4, RADIO.RocketRadioText5,
|
||||
RADIO.RocketRadioText6, RADIO.RocketRadioText7, RADIO.RocketRadioText8, RADIO.RocketRadioText9,
|
||||
RADIO.RocketRadioText10,
|
||||
];
|
||||
return this.nextLine(texts[index - 2], line === L.ROCKET_RADIO_10 ? L.ROCKET_RADIO : line + 1);
|
||||
}
|
||||
if (line > L.BUENAS_PASSWORD_8 && line < L.BUENAS_PASSWORD_19) {
|
||||
// BuenasPassword9-18: the midnight texts 1 to 10.
|
||||
const texts = [
|
||||
RADIO.BuenaRadioMidnightText1, RADIO.BuenaRadioMidnightText2, RADIO.BuenaRadioMidnightText3,
|
||||
RADIO.BuenaRadioMidnightText4, RADIO.BuenaRadioMidnightText5, RADIO.BuenaRadioMidnightText6,
|
||||
RADIO.BuenaRadioMidnightText7, RADIO.BuenaRadioMidnightText8, RADIO.BuenaRadioMidnightText9,
|
||||
RADIO.BuenaRadioMidnightText10,
|
||||
];
|
||||
return this.nextLine(texts[line - L.BUENAS_PASSWORD_9], line + 1);
|
||||
}
|
||||
throw new Error(`Radio segment ${line} is unknown.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** StartPokemonMusicChannel: an empty box, then the Pokémon March on
|
||||
* even weekdays and the Lullaby on odd ones. */
|
||||
private startMusicChannel() {
|
||||
this.lines = ["", ""];
|
||||
this.music(this.host.weekday() & 1 ? SOUND.MUSIC_POKEMON_LULLABY : SOUND.MUSIC_POKEMON_MARCH);
|
||||
}
|
||||
private allKantoBadges() {
|
||||
for (let bit = 0; bit < 8; bit++)
|
||||
if (!this.host.state.flags.has(OW.ENGINE_ZEPHYRBADGE + 8 + bit)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** BuenasPassword1: the show plays from 6 PM; before, the off-air or
|
||||
* midnight texts. */
|
||||
private buena1() {
|
||||
if (this.host.hours() < L.NITE_HOUR) {
|
||||
if (!this.printed) return this.buena20();
|
||||
return this.buena8();
|
||||
}
|
||||
this.start();
|
||||
this.name = readString(this.host.reader, RADIO.BuenasPasswordChannelName, this.host.text());
|
||||
return this.nextLine(RADIO.BuenaRadioText1, L.BUENAS_PASSWORD_2);
|
||||
}
|
||||
private buenaAfterMidnight(text: number) {
|
||||
this.host.state.flags.delete(ENGINE_FLAGS.buenasPassword);
|
||||
return this.nextLine(text, L.BUENAS_PASSWORD_8);
|
||||
}
|
||||
private buena8() {
|
||||
this.host.state.flags.delete(ENGINE_FLAGS.buenasPassword);
|
||||
return this.nextLine(RADIO.BuenaRadioMidnightText10, L.BUENAS_PASSWORD_9);
|
||||
}
|
||||
private buena20() {
|
||||
// NoRadioMusic and NoRadioName, then "off the air".
|
||||
this.musicPlaying = ENTER_MAP_MUSIC;
|
||||
this.host.sound?.playMusic(SOUND.MUSIC_NONE);
|
||||
this.name = "";
|
||||
this.lines = ["", ""];
|
||||
this.host.state.flags.delete(ENGINE_FLAGS.buenasPassword);
|
||||
this.line = L.BUENAS_PASSWORD;
|
||||
this.printed = 0;
|
||||
return this.nextLine(RADIO.BuenaOffTheAirText, L.BUENAS_PASSWORD_21);
|
||||
}
|
||||
}
|
||||
|
||||
/** IsInJohto, as the radio's stations test it: Johto's landmarks and the
|
||||
* S.S. Aqua. */
|
||||
export function inJohto(landmark: number) {
|
||||
return landmark === L.LANDMARK_FAST_SHIP || landmark < L.KANTO_LANDMARK;
|
||||
}
|
||||
|
||||
/** GetBuenasPassword: the word of wBuenasPassword's category (high nybble)
|
||||
* and index (low nybble). */
|
||||
export function buenasPassword(r: RomReader, data: PokemonData, password: number, text: TextContext) {
|
||||
const category = r.near(RADIO.BuenasPasswordTable + ((password >> 4) & 0xf) * 2, r.bank(RADIO.BuenasPasswordTable));
|
||||
const kind = r.byte(category),
|
||||
index = password & 0xf;
|
||||
const values = category + 2;
|
||||
if (kind === L.BUENA_MON) return data.speciesName(r.byte(values + index));
|
||||
if (kind === L.BUENA_ITEM) return data.itemName(r.byte(values + index));
|
||||
if (kind === L.BUENA_MOVE) return data.moveName(r.byte(values + index));
|
||||
let p = values;
|
||||
for (let skip = 0; skip < index; skip++) while (r.byte(p++) !== 0x50);
|
||||
return readString(r, p, text);
|
||||
}
|
||||
|
||||
/** The Pokégear's stations (RadioChannels): the tuning knob's value
|
||||
* (4 × frequency − 2) and the station there, when it can be heard. */
|
||||
export function radioStation(
|
||||
r: RomReader,
|
||||
knob: number,
|
||||
where: { landmark: number; timeOfDay: number; expnCard: boolean; rocketSignal: boolean; rocketsInRadioTower: boolean },
|
||||
text: TextContext,
|
||||
): { line: number; name: string } | undefined {
|
||||
const name = (label: keyof typeof RADIO) => readString(r, RADIO[label], text);
|
||||
const johto = inJohto(where.landmark);
|
||||
switch (knob) {
|
||||
case 16:
|
||||
if (!johto) return undefined;
|
||||
return where.timeOfDay === 0
|
||||
? { line: L.POKEDEX_SHOW, name: name("PokedexShowName") }
|
||||
: { line: L.OAKS_POKEMON_TALK, name: name("OaksPKMNTalkName") };
|
||||
case 28:
|
||||
return johto ? { line: L.POKEMON_MUSIC, name: name("PokemonMusicName") } : undefined;
|
||||
case 32:
|
||||
return johto ? { line: L.LUCKY_CHANNEL, name: name("LuckyChannelName") } : undefined;
|
||||
case 40:
|
||||
// LoadStation_BuenasPassword names the station only while the
|
||||
// Rockets hold the tower; the show names itself when it plays.
|
||||
return johto
|
||||
? { line: L.BUENAS_PASSWORD, name: where.rocketsInRadioTower ? name("BuenasPasswordName") : "" }
|
||||
: undefined;
|
||||
case 52:
|
||||
return where.landmark === L.LANDMARK_RUINS_OF_ALPH
|
||||
? { line: L.UNOWN_RADIO, name: name("UnownStationName") }
|
||||
: undefined;
|
||||
case 64:
|
||||
return !johto && where.expnCard ? { line: L.PLACES_AND_PEOPLE, name: name("PlacesAndPeopleName") } : undefined;
|
||||
case 72:
|
||||
return !johto && where.expnCard ? { line: L.LETS_ALL_SING, name: name("LetsAllSingName") } : undefined;
|
||||
case 78:
|
||||
return !johto && where.expnCard ? { line: L.POKE_FLUTE_RADIO, name: name("PokeFluteStationName") } : undefined;
|
||||
case 80:
|
||||
return where.rocketSignal &&
|
||||
[L.LANDMARK_MAHOGANY_TOWN, L.LANDMARK_ROUTE_43, L.LANDMARK_LAKE_OF_RAGE].includes(where.landmark as never)
|
||||
? { line: L.EVOLUTION_RADIO, name: name("UnownStationName") }
|
||||
: undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** PlayRadioStationPointers (MapRadio): the station a house radio plays. */
|
||||
export function mapRadioStation(
|
||||
r: RomReader,
|
||||
station: number,
|
||||
where: { landmark: number; timeOfDay: number },
|
||||
text: TextContext,
|
||||
): { line: number; name: string } {
|
||||
const name = (label: keyof typeof RADIO) => readString(r, RADIO[label], text);
|
||||
switch (station) {
|
||||
case L.MAPRADIO_POKEMON_CHANNEL:
|
||||
if (!inJohto(where.landmark)) return { line: L.PLACES_AND_PEOPLE, name: name("PlacesAndPeopleName") };
|
||||
return where.timeOfDay === 0
|
||||
? { line: L.POKEDEX_SHOW, name: name("PokedexShowName") }
|
||||
: { line: L.OAKS_POKEMON_TALK, name: name("OaksPKMNTalkName") };
|
||||
case L.MAPRADIO_OAKS_POKEMON_TALK:
|
||||
return { line: L.OAKS_POKEMON_TALK, name: name("OaksPKMNTalkName") };
|
||||
case L.MAPRADIO_POKEDEX_SHOW:
|
||||
return { line: L.POKEDEX_SHOW, name: name("PokedexShowName") };
|
||||
case L.MAPRADIO_POKEMON_MUSIC:
|
||||
return { line: L.POKEMON_MUSIC, name: name("PokemonMusicName") };
|
||||
case L.MAPRADIO_LUCKY_CHANNEL:
|
||||
return { line: L.LUCKY_CHANNEL, name: name("LuckyChannelName") };
|
||||
case L.MAPRADIO_UNOWN:
|
||||
return { line: L.UNOWN_RADIO, name: name("UnownStationName") };
|
||||
case L.MAPRADIO_PLACES_PEOPLE:
|
||||
return { line: L.PLACES_AND_PEOPLE, name: name("PlacesAndPeopleName") };
|
||||
case L.MAPRADIO_LETS_ALL_SING:
|
||||
return { line: L.LETS_ALL_SING, name: name("LetsAllSingName") };
|
||||
case L.MAPRADIO_ROCKET:
|
||||
// LoadStation_RocketRadio uses the sing-along station's name.
|
||||
return { line: L.ROCKET_RADIO, name: name("LetsAllSingName") };
|
||||
default:
|
||||
throw new Error(`Map radio station ${station} is unknown.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** ExitPokegearRadio_HandleMusic: the map's music again after the
|
||||
* Pokémon Channel's jingle or silence; a station's song keeps playing. */
|
||||
export function exitRadioMusic(sound: GameSound | undefined, playing: number, playerState: number, mapMusic: number) {
|
||||
if (!sound) return;
|
||||
if (playing === RESTART_MAP_MUSIC) sound.restartMapMusic();
|
||||
else if (playing === ENTER_MAP_MUSIC) sound.playMapMusicBike(mapMusic, playerState);
|
||||
}
|
||||
@@ -27,6 +27,13 @@ export const OW = {
|
||||
COLL_STAIRCASE: 0x7a,
|
||||
COLL_CAVE: 0x7b,
|
||||
COLL_WARP_PANEL: 0x7c,
|
||||
// Fish groups and swarms (map_data_constants.asm, script_constants.asm).
|
||||
FISHGROUP_QWILFISH_SWARM: 6,
|
||||
FISHGROUP_REMORAID_SWARM: 7,
|
||||
FISHGROUP_QWILFISH: 11,
|
||||
FISHGROUP_REMORAID: 12,
|
||||
FISHSWARM_QWILFISH: 1,
|
||||
FISHSWARM_REMORAID: 2,
|
||||
COLL_WARP_CARPET_RIGHT: 0x7e,
|
||||
COLL_COUNTER: 0x90,
|
||||
HI_NYBBLE_CURRENT: 0x30,
|
||||
|
||||
+136
-12
@@ -19,6 +19,7 @@ import {
|
||||
SAVED_WRAM,
|
||||
SYMBOLS,
|
||||
type SavedByte,
|
||||
PHONE,
|
||||
} from "../rom/profile";
|
||||
import { OW } from "./constants";
|
||||
import {
|
||||
@@ -246,6 +247,16 @@ function randomByte(): number {
|
||||
}
|
||||
|
||||
/** One overworld tick is two video frames (MaxOverworldDelay). */
|
||||
/** A screen that runs every video frame, reading the buttons itself: the
|
||||
* Game Corner's games and the Pokégear's radio card. */
|
||||
export function realtimeChoice(choice: Choice | undefined) {
|
||||
return (
|
||||
(choice?.kind === "screen" && Boolean(choice.realtime)) ||
|
||||
(choice?.kind === "pokegear" && Boolean(choice.view.radio)) ||
|
||||
choice?.kind === "mapradio"
|
||||
);
|
||||
}
|
||||
|
||||
/** Choices answered through the menu routines that click (MenuClickSound). */
|
||||
const CLICKING_CHOICES = new Set<string>([
|
||||
"yesno",
|
||||
@@ -258,6 +269,9 @@ const CLICKING_CHOICES = new Set<string>([
|
||||
"pack",
|
||||
]);
|
||||
|
||||
/** PHONE_CONTACT_SIZE: a PhoneContacts entry. */
|
||||
const PHONE_CONTACT_SIZE = 12;
|
||||
|
||||
/** Which map setup script loads a map, for its music. */
|
||||
type MapSetup = "warp" | "door" | "connection" | "continue" | "none";
|
||||
|
||||
@@ -354,6 +368,8 @@ export interface Pose {
|
||||
}
|
||||
export type Choice =
|
||||
| { kind: "yesno"; selected: 0 | 1; balance?: string }
|
||||
/** A house radio (MapRadio) playing in the text box; A or B stops it. */
|
||||
| { kind: "mapradio"; selected: 0 }
|
||||
| { kind: "weekday"; names: string[]; selected: number }
|
||||
/** InitClock's hour or minute: Up and Down wrap through `labels`. */
|
||||
| { kind: "clock"; labels: string[]; selected: number }
|
||||
@@ -432,7 +448,8 @@ const buttonChoice = (choice: Choice) =>
|
||||
choice.kind === "pokedex" ||
|
||||
choice.kind === "options" ||
|
||||
choice.kind === "flymap" ||
|
||||
choice.kind === "screen";
|
||||
choice.kind === "screen" ||
|
||||
choice.kind === "mapradio";
|
||||
export interface Dialogue {
|
||||
pages: string[];
|
||||
index: number;
|
||||
@@ -540,6 +557,8 @@ interface ActiveScript {
|
||||
interface MemScript {
|
||||
script: number;
|
||||
callerScript?: number;
|
||||
/** wCurCaller, which LoadCallerScript set for a queued call. */
|
||||
caller?: number;
|
||||
}
|
||||
|
||||
const OPPOSITE: Record<Direction, Direction> = {
|
||||
@@ -823,6 +842,7 @@ export class GameWorld {
|
||||
// original InitializeEventsScript, which supplies initial NPC flags.
|
||||
const entry = this.prepareMap(INITIAL_POSITION.map, "warp");
|
||||
this.applyMap(entry, "none");
|
||||
this.initCallReceiveDelay();
|
||||
}
|
||||
|
||||
get x() {
|
||||
@@ -1710,6 +1730,7 @@ export class GameWorld {
|
||||
this.startScript(SYMBOLS.receiveSpecialPhoneCall, PLAYER, {
|
||||
committedPosition: previous,
|
||||
callerScript: r.far(entry + 3),
|
||||
caller: r.byte(entry + 2),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1792,17 +1813,33 @@ export class GameWorld {
|
||||
const collision = this.assets.collision(this.map, this.x, this.y);
|
||||
const water = this.assets.permission(collision) === OW.WATER_TILE,
|
||||
kanto = this.inKanto();
|
||||
// _SwarmWildmonCheck: the swarm table first, on the swarm's map.
|
||||
const swarm = this.swarmMap(state);
|
||||
const grass = water
|
||||
? undefined
|
||||
: this.assets.grassWildMons(this.mapId, kanto);
|
||||
: (swarm && this.assets.grassWildMons(this.mapId, kanto, true)) ||
|
||||
this.assets.grassWildMons(this.mapId, kanto);
|
||||
const sea = water
|
||||
? this.assets.waterWildMons(this.mapId, kanto)
|
||||
? (swarm && this.assets.waterWildMons(this.mapId, kanto, true)) ||
|
||||
this.assets.waterWildMons(this.mapId, kanto)
|
||||
: undefined;
|
||||
let rate = (water ? sea?.rate : grass?.rates[this.clockTimeOfDay]) ?? 0;
|
||||
// ApplyCleanseTagEffectOnEncounterRate
|
||||
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
|
||||
return { water, grass, sea, rate };
|
||||
}
|
||||
/** _SwarmWildmonCheck's map test: Dunsparce's swarm (when on, only its
|
||||
* map counts), else Yanma's, whose maps a trainer's call recorded. */
|
||||
private swarmMap(state: ScriptState) {
|
||||
const memory = state.memory;
|
||||
const at = (group?: number, number?: number) => `${group ?? 0}:${number ?? 0}` === this.mapId;
|
||||
if (state.flags.has(ENGINE_FLAGS.dunsparceSwarm) && at(memory.wDunsparceMapGroup, memory.wDunsparceMapNumber))
|
||||
return true;
|
||||
return (
|
||||
state.flags.has(ENGINE_FLAGS.yanmaSwarm) &&
|
||||
at(memory.wYanmaMapGroup, memory.wYanmaMapNumber)
|
||||
);
|
||||
}
|
||||
/** ChooseWildEncounter: 1-100 against GrassMonProbTable or
|
||||
* WaterMonProbTable, whose entries are (percent, slot offset); surfing
|
||||
* varies the level. Undefined when no valid Pokémon is chosen.
|
||||
@@ -2292,6 +2329,8 @@ export class GameWorld {
|
||||
random: () => this.random() & 0xff,
|
||||
sound: this.sound,
|
||||
mapMusic: (state) => this.mapMusicId(state),
|
||||
initCallReceiveDelay: () => this.initCallReceiveDelay(),
|
||||
landmarkOf: (id) => this.assets.map(id).landmark,
|
||||
landmark: () => this.assets.map(mapId).landmark,
|
||||
facing: () => DIRECTION_ORDER.indexOf(this.player.direction),
|
||||
position: () => ({ x: this.player.x, y: this.player.y }),
|
||||
@@ -2330,8 +2369,14 @@ export class GameWorld {
|
||||
this.assets.collision(this.map, this.player.x + dx, this.player.y + dy) ?? 0;
|
||||
if (surfing || this.assets.permission(tile) !== OW.WATER_TILE)
|
||||
return { texts: [] };
|
||||
const fieldMove = { mon: 0 },
|
||||
group = this.map.fishGroup;
|
||||
const fieldMove = { mon: 0 };
|
||||
// GetFishGroupIndex: a fishing swarm a trainer called about.
|
||||
let group = this.map.fishGroup;
|
||||
const swarm = state.flags.has(ENGINE_FLAGS.fishSwarm) ? state.memory.wFishingSwarmFlag : 0;
|
||||
if (group === OW.FISHGROUP_QWILFISH && swarm === OW.FISHSWARM_QWILFISH)
|
||||
group = OW.FISHGROUP_QWILFISH_SWARM;
|
||||
else if (group === OW.FISHGROUP_REMORAID && swarm === OW.FISHSWARM_REMORAID)
|
||||
group = OW.FISHGROUP_REMORAID_SWARM;
|
||||
if (!group) return { script: OVERWORLD.Script_NotEvenANibble2, fieldMove };
|
||||
const hooked = fish(
|
||||
this.assets.reader,
|
||||
@@ -2386,6 +2431,7 @@ export class GameWorld {
|
||||
hiddenItem,
|
||||
sighted,
|
||||
callerScript,
|
||||
caller,
|
||||
fieldMove,
|
||||
usedItem,
|
||||
fish,
|
||||
@@ -2405,6 +2451,8 @@ export class GameWorld {
|
||||
hiddenItem?: { event: number; item: number };
|
||||
sighted?: number;
|
||||
callerScript?: number;
|
||||
/** wCurCaller for a received call. */
|
||||
caller?: number;
|
||||
} = {},
|
||||
) {
|
||||
const committed = this.save();
|
||||
@@ -2426,6 +2474,7 @@ export class GameWorld {
|
||||
trainer,
|
||||
hiddenItem,
|
||||
callerScript,
|
||||
caller,
|
||||
fieldMove,
|
||||
usedItem,
|
||||
fish,
|
||||
@@ -2465,7 +2514,7 @@ export class GameWorld {
|
||||
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
|
||||
useSelect(): boolean {
|
||||
const choice = this.dialogue?.choice;
|
||||
if (choice?.kind === "screen" && choice.realtime) {
|
||||
if (realtimeChoice(choice)) {
|
||||
this.bufferScreenButton("select");
|
||||
return true;
|
||||
}
|
||||
@@ -2483,7 +2532,7 @@ export class GameWorld {
|
||||
* the player stands still. Its changes commit when it closes. */
|
||||
openStartMenu(select = false): boolean {
|
||||
const choice = this.dialogue?.choice;
|
||||
if (!select && choice?.kind === "screen" && choice.realtime) {
|
||||
if (!select && realtimeChoice(choice)) {
|
||||
this.bufferScreenButton("start");
|
||||
return true;
|
||||
}
|
||||
@@ -2941,6 +2990,7 @@ export class GameWorld {
|
||||
script.memScript ??= {
|
||||
script: effect.script,
|
||||
callerScript: effect.callerScript,
|
||||
caller: effect.caller,
|
||||
};
|
||||
return false;
|
||||
case "townmap":
|
||||
@@ -2955,6 +3005,13 @@ export class GameWorld {
|
||||
this.mailView = { ...effect.mail };
|
||||
this.dialogue = { pages: ["MAIL"], index: 0 };
|
||||
return true;
|
||||
case "mapradio":
|
||||
this.dialogue = {
|
||||
pages: [effect.text],
|
||||
index: 0,
|
||||
choice: { kind: "mapradio", selected: 0 },
|
||||
};
|
||||
return true;
|
||||
case "pokegear":
|
||||
this.pokegear = effect.view;
|
||||
if (!effect.view) return false;
|
||||
@@ -3692,8 +3749,71 @@ export class GameWorld {
|
||||
if (this.state.dailyReset === day) return;
|
||||
this.dailyFlags ??= this.assets.dailyEngineFlags();
|
||||
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
|
||||
// Kenji's break: a countdown of 3 to 6 days, sampled again at zero.
|
||||
const memory = this.state.memory,
|
||||
kenji = memory.wKenjiBreakTimer ?? 0;
|
||||
if (!kenji || !(memory.wKenjiBreakTimer = kenji - 1))
|
||||
memory.wKenjiBreakTimer = (this.random() & 3) + 3;
|
||||
this.state.dailyReset = day;
|
||||
}
|
||||
|
||||
/** InitCallReceiveDelay's and CheckReceiveCallTimer's timer: work RAM
|
||||
* that StartMap restarts whenever the game is loaded. */
|
||||
private callTimer = { cycles: 0, minutes: 20, start: 0 };
|
||||
/** InitCallReceiveDelay */
|
||||
initCallReceiveDelay() {
|
||||
this.callTimer.cycles = 0;
|
||||
this.nextCallReceiveDelay();
|
||||
}
|
||||
/** NextCallReceiveDelay: 20, 10, 5, then 3 minutes between calls. */
|
||||
private nextCallReceiveDelay() {
|
||||
const timer = this.callTimer;
|
||||
timer.minutes = [20, 10, 5, 3][Math.min(timer.cycles, 3)];
|
||||
timer.start = Math.floor(this.gameNow(this.state.clock).getTime() / 60_000);
|
||||
}
|
||||
/** CheckReceiveCallTimer: true (carry) once the delay has passed, which
|
||||
* shortens the next one. */
|
||||
private checkReceiveCallTimer() {
|
||||
const timer = this.callTimer;
|
||||
const now = Math.floor(this.gameNow(this.state.clock).getTime() / 60_000);
|
||||
if (now - timer.start < timer.minutes) return false;
|
||||
timer.cycles = Math.min(timer.cycles + 1, 3);
|
||||
this.nextCallReceiveDelay();
|
||||
return true;
|
||||
}
|
||||
/** CheckPhoneCall: off a doorway, once the timer allows, half the time,
|
||||
* with a signal, a registered trainer or caller whose calling hours
|
||||
* include now and who is not on this map rings with their caller
|
||||
* script. */
|
||||
private checkPhoneCall() {
|
||||
const tile = this.assets.collision(this.map, this.player.x, this.player.y);
|
||||
if (
|
||||
tile === OW.COLL_DOOR ||
|
||||
tile === OW.COLL_DOOR_79 ||
|
||||
tile === OW.COLL_STAIRCASE ||
|
||||
tile === OW.COLL_CAVE
|
||||
)
|
||||
return;
|
||||
if (!this.checkReceiveCallTimer()) return;
|
||||
if (this.random() & 0x80) return;
|
||||
if (this.map.phoneService) return;
|
||||
const r = this.assets.reader,
|
||||
time = 1 << this.clockTimeOfDay;
|
||||
const callers = this.state.phone.filter((contact) => {
|
||||
if (!contact) return false;
|
||||
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
||||
if (!(r.byte(entry + 8) & time)) return false;
|
||||
return `${r.byte(entry + 2)}:${r.byte(entry + 3)}` !== this.mapId;
|
||||
});
|
||||
if (!callers.length) return;
|
||||
// ChooseRandomCaller: hRandomAdd's swapped low five bits, modulo.
|
||||
const roll = this.random();
|
||||
const caller = callers[(((roll << 4) | (roll >> 4)) & 0x1f) % callers.length];
|
||||
this.startScript(PHONE.Script_ReceivePhoneCall, PLAYER, {
|
||||
callerScript: r.far(PC.PhoneContacts + caller * PHONE_CONTACT_SIZE + 9),
|
||||
caller,
|
||||
});
|
||||
}
|
||||
/** CheckBugContestTimer, from CheckTimeEvents while
|
||||
* STATUSFLAGS2_BUG_CONTEST_TIMER_F is set: GiveParkBalls' deadline
|
||||
* replaces the ROM's wBugContestStartTime day/hour/min/sec struct (see
|
||||
@@ -4107,7 +4227,7 @@ export class GameWorld {
|
||||
}
|
||||
const dialogue = this.dialogue;
|
||||
if (dialogue) {
|
||||
if (dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
|
||||
if (realtimeChoice(dialogue.choice)) {
|
||||
// SlotsLoop/CardFlip's MasterLoop poll the joypad and redraw once
|
||||
// per video frame; two frames run per overworld tick, as elsewhere.
|
||||
this.advanceScreenFrame();
|
||||
@@ -4203,6 +4323,7 @@ export class GameWorld {
|
||||
this.memScript = undefined;
|
||||
this.startScript(memScript.script, PLAYER, {
|
||||
callerScript: memScript.callerScript,
|
||||
caller: memScript.caller,
|
||||
});
|
||||
if (this.busy) return;
|
||||
}
|
||||
@@ -4210,7 +4331,10 @@ export class GameWorld {
|
||||
if (this.busy) return;
|
||||
// CheckTimeEvents: a running contest skips the daily path entirely.
|
||||
if (this.state.bugContest) this.checkBugContestTimer();
|
||||
else this.checkDailyReset();
|
||||
else {
|
||||
this.checkDailyReset();
|
||||
this.checkPhoneCall();
|
||||
}
|
||||
if (this.busy) return;
|
||||
// PlayerEvents precede HandleMapObjects: a step starts and moves at once.
|
||||
const stepped = this.beginStep(held);
|
||||
@@ -4756,9 +4880,7 @@ export class GameWorld {
|
||||
/** One video frame of a realtime screen (SlotsLoop, CardFlip's
|
||||
* MasterLoop): resume it with the next buffered button, or none. */
|
||||
private advanceScreenFrame() {
|
||||
const choice = this.dialogue?.choice;
|
||||
if (!this.script || !choice || choice.kind !== "screen" || !choice.realtime)
|
||||
return;
|
||||
if (!this.script || !realtimeChoice(this.dialogue?.choice)) return;
|
||||
this.choose(this.screenInput.shift() ?? "");
|
||||
}
|
||||
|
||||
@@ -5145,6 +5267,8 @@ export class GameWorld {
|
||||
}
|
||||
this.script = undefined;
|
||||
this.applyMap(entry, continued ? "continue" : "none");
|
||||
// StartMap's InitCallReceiveDelay.
|
||||
this.initCallReceiveDelay();
|
||||
for (const object of save.objects) {
|
||||
const source = map.objects.find(
|
||||
(candidate) => candidate.id === object.id,
|
||||
|
||||
+12
-1
@@ -128,7 +128,8 @@ function showWorld(world: GameWorld) {
|
||||
? `${dialogue.index + 1} / ${dialogue.pages.length}`
|
||||
: "";
|
||||
const last = dialogue && dialogue.index === dialogue.pages.length - 1;
|
||||
const choice = last && !mart ? dialogue.choice : undefined;
|
||||
// A house radio shows only its text box; A, B or Continue stops it.
|
||||
const choice = last && !mart && dialogue.choice?.kind !== "mapradio" ? dialogue.choice : undefined;
|
||||
// Timed pages advance themselves; choices replace Continue.
|
||||
get("dialogue-next").hidden = Boolean(
|
||||
choice || mart || (last && dialogue.holdTicks !== undefined),
|
||||
@@ -1199,6 +1200,16 @@ function showPokegear(world: GameWorld) {
|
||||
card.append(submenu);
|
||||
}
|
||||
body.replaceChildren(card);
|
||||
} else if (view.radio) {
|
||||
// The tuning dial's frequency, the station's name and its text box.
|
||||
const radio = view.radio,
|
||||
card = element("div", "", "pokegear-radio"),
|
||||
dial = element("p", `FM ${radio.frequency}`, "pokegear-frequency"),
|
||||
box = element("div", "", "pokegear-radio-text");
|
||||
box.setAttribute("aria-live", "polite");
|
||||
box.append(element("span", radio.lines[0]), element("span", radio.lines[1]));
|
||||
card.append(dial, element("strong", radio.name), box);
|
||||
body.replaceChildren(card);
|
||||
} else body.replaceChildren();
|
||||
}
|
||||
|
||||
|
||||
@@ -1926,3 +1926,27 @@ dialog dd {
|
||||
white-space: pre;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pokegear-radio {
|
||||
display: grid;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.pokegear-frequency {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pokegear-radio strong {
|
||||
min-height: 1.3em;
|
||||
}
|
||||
.pokegear-radio-text {
|
||||
display: grid;
|
||||
gap: 0.25em;
|
||||
min-height: 2.8em;
|
||||
padding: 0.5em 0.75em;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.pokegear-radio-text span {
|
||||
min-height: 1.2em;
|
||||
white-space: pre;
|
||||
}
|
||||
Reference in new issue
Block a user