POKéGEAR in the start menu runs the clock, Town Map and phone cards with the original joypad rules. Calls to Elm and Mom run their callee scripts, with the out-of-area and out-of-hours cases and HangUp's corrected timing. Mom saves a quarter of trainer prizes once asked, talking to her at home opens BankOfMom (GET, SAVE, CHANGE and the DST checks) with its digit amount box, and MomTriesToBuySomething buys items and dolls with her savings. Saves gain Mom's money, her next item and its trigger balance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3456aa4e11
commit
209778cabe
29 files changed
+2726
-47
No files matched your search
@@ -0,0 +1,432 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import { PC, POKEGEAR, SYMBOLS } from "../rom/profile";
|
||||
import { TrainerData } from "../rom/trainers";
|
||||
import { OW } from "../world/constants";
|
||||
import {
|
||||
HANG_UP,
|
||||
ScriptRun,
|
||||
landmarkName,
|
||||
type ScriptEffect,
|
||||
type ScriptHost,
|
||||
type ScriptState,
|
||||
} from "./interpreter";
|
||||
import { readString, readText, type TextContext } from "./text";
|
||||
|
||||
type Input = number | string | undefined;
|
||||
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
||||
|
||||
/** POKEGEARCARD_* in order. */
|
||||
export type PokegearCard = "clock" | "map" | "phone" | "radio";
|
||||
/** The buttons the Pokégear reads (hJoyLast and hJoyPressed). */
|
||||
export type PokegearButton =
|
||||
| "a"
|
||||
| "b"
|
||||
| "start"
|
||||
| "select"
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right";
|
||||
export const POKEGEAR_BUTTONS: readonly string[] = [
|
||||
"a",
|
||||
"b",
|
||||
"start",
|
||||
"select",
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
];
|
||||
|
||||
/** What a Pokégear card shows. The original draws its own tilemaps; here
|
||||
* the card's contents are presented natively. */
|
||||
export interface PokegearView {
|
||||
card: PokegearCard;
|
||||
/** The cards whose icons Pokegear_FinishTilemap places. */
|
||||
cards: PokegearCard[];
|
||||
/** The clock card: GearTodayText's weekday and PrintHoursMins. */
|
||||
clock?: { day: string; time: string };
|
||||
/** The map card: the region shown, the landmark under the cursor and
|
||||
* the one where the player's icon stands. */
|
||||
map?: { kanto: boolean; cursor: number; player: number; name: string };
|
||||
/** The phone card's four rows (PHONE_DISPLAY_HEIGHT), the ▶ cursor, the
|
||||
* ▷ on a chosen contact, whether the map has service, and the
|
||||
* CALL/DELETE/CANCEL submenu while it is open. */
|
||||
phone?: {
|
||||
rows: string[];
|
||||
cursor: number;
|
||||
chosen?: number;
|
||||
signal: boolean;
|
||||
submenu?: { items: string[]; selected: number };
|
||||
};
|
||||
}
|
||||
|
||||
/** What the Pokégear needs from the world. */
|
||||
export interface PokegearHost {
|
||||
reader: RomReader;
|
||||
data: PokemonData;
|
||||
text(): TextContext;
|
||||
/** The script host a phone call's callee script runs with. */
|
||||
scriptHost(): ScriptHost;
|
||||
/** GetWorldMapLocation for any map. */
|
||||
landmarkOf(mapId: string): number;
|
||||
}
|
||||
|
||||
const PHONE_DISPLAY_HEIGHT = 4;
|
||||
const CONTACT_LIST_SIZE = 10;
|
||||
const PHONE_CONTACT_SIZE = 12;
|
||||
/** Ticks Sfx_Call plays while "……" shows (six four-frame notes). */
|
||||
const CALL_TICKS = 12;
|
||||
|
||||
/** Pokegear_UpdateClock: the weekday (text_today) and PrintHoursMins's
|
||||
* 12-hour time with the ROM's AM and PM strings. */
|
||||
export function pokegearClock(
|
||||
reader: RomReader,
|
||||
context: TextContext,
|
||||
hours: number,
|
||||
minutes: number,
|
||||
) {
|
||||
const day = readText(
|
||||
reader,
|
||||
POKEGEAR["Pokegear_UpdateClock.GearTodayText"],
|
||||
context,
|
||||
);
|
||||
const hour = hours % 12 || 12;
|
||||
const suffix = readString(
|
||||
reader,
|
||||
hours < 12 ? POKEGEAR.String_AM : POKEGEAR.String_PM,
|
||||
context,
|
||||
);
|
||||
return {
|
||||
day,
|
||||
time: `${String(hour).padStart(2, " ")}:${String(minutes).padStart(2, "0")} ${suffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** PokeGear (engine/pokegear/pokegear.asm): the clock, map, phone and
|
||||
* radio cards, with the original's joypad handling for each card. Each
|
||||
* card is yielded as a `pokegear` effect answered with a button. Phone
|
||||
* calls run the contact's callee script from PhoneContacts on this run's
|
||||
* draft, as MakePhoneCallFromPokegear does. */
|
||||
export class Pokegear {
|
||||
private card: PokegearCard = "clock";
|
||||
/** wPokegearMapCursorLandmark and wPokegearMapPlayerIconLandmark. */
|
||||
private cursorLandmark: number;
|
||||
private playerLandmark: number;
|
||||
/** wPokegearPhoneScrollPosition and wPokegearPhoneCursorPosition. */
|
||||
private scroll = 0;
|
||||
private cursor = 0;
|
||||
|
||||
constructor(
|
||||
private state: ScriptState,
|
||||
private host: PokegearHost,
|
||||
) {
|
||||
// TownMap_InitCursorAndPlayerIconPositions.
|
||||
const scriptHost = host.scriptHost();
|
||||
let landmark = host.landmarkOf(scriptHost.mapId);
|
||||
if (landmark === OW.LANDMARK_SPECIAL && state.backupWarp)
|
||||
landmark = host.landmarkOf(state.backupWarp.map);
|
||||
this.playerLandmark = landmark;
|
||||
this.cursorLandmark =
|
||||
landmark === OW.LANDMARK_FAST_SHIP ? OW.LANDMARK_NEW_BARK_TOWN : landmark;
|
||||
}
|
||||
|
||||
private get reader() {
|
||||
return this.host.reader;
|
||||
}
|
||||
private has(flag: number) {
|
||||
return this.state.flags.has(flag);
|
||||
}
|
||||
private pages(address: number) {
|
||||
return readText(this.reader, address, this.host.text())
|
||||
.split("\n\n")
|
||||
.filter(Boolean);
|
||||
}
|
||||
/** The cards Pokegear_FinishTilemap shows icons for. */
|
||||
private cards(): PokegearCard[] {
|
||||
const cards: PokegearCard[] = ["clock"];
|
||||
if (this.has(OW.ENGINE_MAP_CARD)) cards.push("map");
|
||||
if (this.has(OW.ENGINE_PHONE_CARD)) cards.push("phone");
|
||||
if (this.has(OW.ENGINE_RADIO_CARD)) cards.push("radio");
|
||||
return cards;
|
||||
}
|
||||
/** Show the card and wait for a button. */
|
||||
private *show(
|
||||
message: number | string[],
|
||||
extra: Partial<PokegearView> = {},
|
||||
): Gen<PokegearButton> {
|
||||
const view: PokegearView = {
|
||||
card: this.card,
|
||||
cards: this.cards(),
|
||||
...extra,
|
||||
};
|
||||
const pages = typeof message === "number" ? this.pages(message) : message;
|
||||
const button = yield { kind: "pokegear", pages, view };
|
||||
if (typeof button !== "string" || !POKEGEAR_BUTTONS.includes(button))
|
||||
throw new Error("Invalid Pokégear button.");
|
||||
return button as PokegearButton;
|
||||
}
|
||||
/** Close the Pokégear's screen. */
|
||||
private *close(): Gen {
|
||||
yield { kind: "pokegear", pages: [] };
|
||||
}
|
||||
private *hold(address: number, ticks: number): Gen {
|
||||
yield { kind: "text", pages: this.pages(address), holdTicks: ticks };
|
||||
}
|
||||
|
||||
/** PokeGear: the clock card first; returns when the player exits. */
|
||||
*run(): Gen {
|
||||
for (;;) {
|
||||
const next =
|
||||
this.card === "clock"
|
||||
? yield* this.clock()
|
||||
: this.card === "map"
|
||||
? yield* this.map()
|
||||
: this.card === "phone"
|
||||
? yield* this.phone()
|
||||
: yield* this.radio();
|
||||
if (!next) return yield* this.close();
|
||||
// Pokegear_SwitchPage.
|
||||
this.card = next;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- clock
|
||||
|
||||
/** PokegearClock_Joypad: any of A, B, Select or Start exits; right goes
|
||||
* to the first card the player has. */
|
||||
private *clock(): Gen<PokegearCard | undefined> {
|
||||
for (;;) {
|
||||
const time = this.host.scriptHost().clock();
|
||||
const button = yield* this.show(POKEGEAR.PokegearPressButtonText, {
|
||||
clock: pokegearClock(
|
||||
this.reader,
|
||||
this.host.text(),
|
||||
time.hours,
|
||||
time.minutes,
|
||||
),
|
||||
});
|
||||
if (["a", "b", "start", "select"].includes(button)) return undefined;
|
||||
if (button !== "right") continue;
|
||||
if (this.has(OW.ENGINE_MAP_CARD)) return "map";
|
||||
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
|
||||
if (this.has(OW.ENGINE_RADIO_CARD)) return "radio";
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- map
|
||||
|
||||
/** PokegearMap_ContinueMap: B exits, left and right change cards, up and
|
||||
* down step the cursor through the region's landmarks with wrapping. */
|
||||
private *map(): Gen<PokegearCard | undefined> {
|
||||
const kanto =
|
||||
this.playerLandmark !== OW.LANDMARK_FAST_SHIP &&
|
||||
this.playerLandmark >= OW.KANTO_LANDMARK;
|
||||
// TownMap_GetKantoLandmarkLimits: Kanto's routes need the Hall of Fame.
|
||||
const [first, last] = !kanto
|
||||
? [OW.JOHTO_LANDMARK, OW.JOHTO_LANDMARK_LAST]
|
||||
: this.has(OW.ENGINE_CREDITS_SKIP)
|
||||
? [OW.KANTO_LANDMARK, OW.KANTO_LANDMARK_LAST]
|
||||
: [OW.LANDMARK_VICTORY_ROAD, OW.LANDMARK_ROUTE_28];
|
||||
for (;;) {
|
||||
const button = yield* this.show([], {
|
||||
map: {
|
||||
kanto,
|
||||
cursor: this.cursorLandmark,
|
||||
player: this.playerLandmark,
|
||||
name: landmarkName(this.reader, this.cursorLandmark, this.host.text()),
|
||||
},
|
||||
});
|
||||
if (button === "b") return undefined;
|
||||
if (button === "right") {
|
||||
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
|
||||
if (this.has(OW.ENGINE_RADIO_CARD)) return "radio";
|
||||
} else if (button === "left") return "clock";
|
||||
else if (button === "up")
|
||||
this.cursorLandmark =
|
||||
this.cursorLandmark < last ? this.cursorLandmark + 1 : first;
|
||||
else if (button === "down")
|
||||
this.cursorLandmark =
|
||||
this.cursorLandmark !== first ? this.cursorLandmark - 1 : last;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- phone
|
||||
|
||||
/** wPhoneList: CONTACT_LIST_SIZE slots, empty ones zero. */
|
||||
private contact(slot: number) {
|
||||
return this.state.phone[slot] ?? 0;
|
||||
}
|
||||
/** PhoneContacts' trainer class and number for a contact. */
|
||||
private callerClass(contact: number) {
|
||||
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
||||
return { classId: this.reader.byte(entry), id: this.reader.byte(entry + 1) };
|
||||
}
|
||||
/** 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());
|
||||
}
|
||||
private rows() {
|
||||
return Array.from({ length: PHONE_DISPLAY_HEIGHT }, (_, row) =>
|
||||
this.callerName(this.contact(this.scroll + row)),
|
||||
);
|
||||
}
|
||||
private signal() {
|
||||
return !this.host.scriptHost().phoneService();
|
||||
}
|
||||
private *phoneCard(
|
||||
message: number | string[],
|
||||
extra: Partial<NonNullable<PokegearView["phone"]>> = {},
|
||||
) {
|
||||
return yield* this.show(message, {
|
||||
phone: {
|
||||
rows: this.rows(),
|
||||
cursor: this.cursor,
|
||||
signal: this.signal(),
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** PokegearPhone_Init and PokegearPhone_Joypad. */
|
||||
private *phone(): Gen<PokegearCard | undefined> {
|
||||
this.scroll = this.cursor = 0;
|
||||
for (;;) {
|
||||
const button = yield* this.phoneCard(POKEGEAR.PokegearAskWhoCallText);
|
||||
if (button === "b") return undefined;
|
||||
if (button === "a") {
|
||||
const contact = this.contact(this.scroll + this.cursor);
|
||||
if (contact) yield* this.contactSubmenu(contact);
|
||||
} else if (button === "left")
|
||||
return this.has(OW.ENGINE_MAP_CARD) ? "map" : "clock";
|
||||
else if (button === "right") {
|
||||
if (this.has(OW.ENGINE_RADIO_CARD)) return "radio";
|
||||
} else if (button === "up") {
|
||||
if (this.cursor) this.cursor--;
|
||||
else if (this.scroll) this.scroll--;
|
||||
} else if (button === "down") {
|
||||
if (this.cursor < PHONE_DISPLAY_HEIGHT - 1) this.cursor++;
|
||||
else if (this.scroll < CONTACT_LIST_SIZE - PHONE_DISPLAY_HEIGHT)
|
||||
this.scroll++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** CheckCanDeletePhoneNumber: Mom and Elm stay. */
|
||||
private canDelete(contact: number) {
|
||||
const { classId, id } = this.callerClass(contact);
|
||||
return (
|
||||
classId !== 0 || (id !== OW.PHONECONTACT_MOM && id !== OW.PHONECONTACT_ELM)
|
||||
);
|
||||
}
|
||||
/** PokegearPhoneContactSubmenu: CALL (DELETE) CANCEL; up and down stop
|
||||
* at the ends; B cancels. */
|
||||
private *contactSubmenu(contact: number): Gen {
|
||||
const deletable = this.canDelete(contact);
|
||||
const strings =
|
||||
POKEGEAR[
|
||||
deletable
|
||||
? "PokegearPhoneContactSubmenu.CallDeleteCancelStrings"
|
||||
: "PokegearPhoneContactSubmenu.CallCancelStrings"
|
||||
];
|
||||
const r = this.reader,
|
||||
count = r.byte(strings + 2);
|
||||
const items = readString(r, strings + 3, this.host.text()).split("\n");
|
||||
if (items.length !== count) throw new Error("Unexpected phone submenu.");
|
||||
const actions = deletable
|
||||
? (["call", "delete", "cancel"] as const)
|
||||
: (["call", "cancel"] as const);
|
||||
let selected = 0;
|
||||
for (;;) {
|
||||
const button = yield* this.phoneCard(POKEGEAR.PokegearAskWhoCallText, {
|
||||
chosen: this.cursor,
|
||||
submenu: { items, selected },
|
||||
});
|
||||
if (button === "up") selected = Math.max(0, selected - 1);
|
||||
else if (button === "down") selected = Math.min(count - 1, selected + 1);
|
||||
else if (button === "a" || button === "b") {
|
||||
const action = button === "b" ? "cancel" : actions[selected];
|
||||
if (action === "call") return yield* this.call(contact);
|
||||
if (action === "delete") {
|
||||
const answer = yield {
|
||||
kind: "yesno",
|
||||
pages: this.pages(POKEGEAR.PokegearAskDeleteText),
|
||||
};
|
||||
if (answer) this.deleteContact();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/** PokegearPhone_DeletePhoneNumber: the slot empties. Its compaction
|
||||
* loop only moves an entry up past the emptied slot, which the native
|
||||
* list (kept without gaps) already does. */
|
||||
private deleteContact() {
|
||||
this.state.phone.splice(this.scroll + this.cursor, 1);
|
||||
}
|
||||
|
||||
/** PokegearPhone_MakePhoneCall, MakePhoneCallFromPokegear and
|
||||
* PokegearPhone_FinishPhoneCall. */
|
||||
private *call(contact: number): Gen {
|
||||
const r = this.reader,
|
||||
scriptHost = this.host.scriptHost();
|
||||
if (scriptHost.phoneService()) {
|
||||
// Phone_NoSignal: the three boops of Phone_CallEnd.
|
||||
yield* this.hold(POKEGEAR.PhoneEllipseText, HANG_UP.endTicks);
|
||||
const pages = this.pages(
|
||||
POKEGEAR["PokegearPhone_MakePhoneCall.GearOutOfServiceText"],
|
||||
);
|
||||
yield { kind: "text", pages };
|
||||
return;
|
||||
}
|
||||
for (let ring = 0; ring < 2; ring++)
|
||||
yield* this.hold(
|
||||
POKEGEAR["PokegearPhone_MakePhoneCall.GearEllipseText"],
|
||||
CALL_TICKS,
|
||||
);
|
||||
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
||||
// CheckPhoneContactTimeOfDay: the callee's time mask against wTimeOfDay.
|
||||
const time = r.byte(entry + 4) & (OW.MORN | OW.DAY | OW.NITE);
|
||||
const script = !(time & (1 << scriptHost.timeOfDay()))
|
||||
? POKEGEAR.PhoneOutOfAreaScript
|
||||
: `${r.byte(entry + 2)}:${r.byte(entry + 3)}` === scriptHost.mapId
|
||||
? POKEGEAR.PhoneScript_JustTalkToThem
|
||||
: r.far(entry + 5);
|
||||
const run = new ScriptRun(r, script, this.state, scriptHost);
|
||||
let input: Input;
|
||||
for (let effect = run.resume(); effect; effect = run.resume(input))
|
||||
input = yield effect;
|
||||
if (run.deferred !== undefined)
|
||||
throw new Error("A phone script deferred another script.");
|
||||
Object.assign(this.state, run.state);
|
||||
// HangUp after A or B; the callee's last text waited for that button.
|
||||
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. */
|
||||
private *radio(): Gen<PokegearCard | undefined> {
|
||||
yield { kind: "notice", text: "The radio is not ported yet." };
|
||||
for (;;) {
|
||||
const button = yield* this.show([]);
|
||||
if (button === "b") return undefined;
|
||||
if (button === "left") {
|
||||
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
|
||||
if (this.has(OW.ENGINE_MAP_CARD)) return "map";
|
||||
return "clock";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user