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
+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,
|
||||
|
||||
Reference in new issue
Block a user