Port the Pokégear, phone calls and Mom's bank
Checks / web (push) Canceled after 0s

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:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-01 11:38:22 +02:00
1 parent 3456aa4e11
commit 209778cabe
29 files changed
+2726 -47

No files matched your search

+102 -5
View File
@@ -15,6 +15,12 @@ import {
SYMBOLS,
} from "../rom/profile";
import { OW } from "./constants";
import {
POKEGEAR_BUTTONS,
pokegearClock,
type PokegearView,
} from "../scripts/pokegear";
import { MOM_BANK_BUTTONS, type MomBankView } from "../scripts/mom";
import {
PLAYER,
ScriptRun,
@@ -99,6 +105,11 @@ export interface NativeSave {
pcItems: ItemSlot[];
pokedex: { seen: number[]; caught: number[] };
money: number;
/** wMomsMoney: Mom's savings. */
momsMoney: number;
/** wWhichMomItem and wMomItemTriggerBalance: Mom's purchases. */
momItem: number;
momItemTriggerBalance: number;
fruitTrees: number[];
dailyReset: number | null;
/** wLastSpawnMapGroup/Number: the outdoor map last entered through a
@@ -229,6 +240,10 @@ export type Choice =
pocket: number;
selected: number;
}
/** A Pokégear card, answered with the button pressed. */
| { kind: "pokegear"; view: PokegearView; selected: 0 }
/** BankOfMom's amount box, answered with the button pressed. */
| { kind: "mombank"; view: MomBankView; selected: 0 }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
@@ -240,6 +255,10 @@ export type Choice =
prompt: string;
selected: number;
};
/** Choices that read the buttons themselves and are answered with the
* button's name. */
const buttonChoice = (choice: Choice) =>
choice.kind === "pokegear" || choice.kind === "mombank";
export interface Dialogue {
pages: string[];
index: number;
@@ -1745,6 +1764,7 @@ export class GameWorld {
playerCollision: () =>
this.assets.collision(this.map, this.player.x, this.player.y),
environment: () => this.assets.map(mapId).environment,
phoneService: () => this.assets.map(mapId).phoneService,
};
}
private weekdayName(day: number) {
@@ -1839,11 +1859,19 @@ export class GameWorld {
}
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
useSelect(): boolean {
if (this.dialogue?.choice?.kind === "pokegear") {
this.choose("select");
return true;
}
return this.openStartMenu(true);
}
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
* the player stands still. Its changes commit when it closes. */
openStartMenu(select = false): boolean {
if (!select && this.dialogue?.choice?.kind === "pokegear") {
this.choose("start");
return true;
}
if (this.busy || this.introPending) return false;
this.clearNotice();
this.script = {
@@ -1858,6 +1886,8 @@ export class GameWorld {
landmark: () => this.assets.map(this.mapId).landmark,
fieldMove: (state, mon, move) =>
this.menuFieldMove(state, mon, move),
scriptHost: () => this.host(PLAYER),
landmarkOf: (mapId) => this.assets.map(mapId).landmark,
},
this.menuCursors,
select,
@@ -2119,6 +2149,12 @@ export class GameWorld {
tutorial: effect.tutorial,
landmark: this.assets.map(this.mapId).landmark,
partyMenuCursor: this.menuCursors.party,
// Only MOM_SAVING_SOME_MONEY_F has an engine flag; nothing in the
// ROM sets the half and all bits.
mom: {
saving: Number(draft.flags.has(OW.ENGINE_MOM_SAVING_MONEY)),
money: draft.momsMoney,
},
});
this.battle = { engine, selected: 1 };
this.battleRun = engine.run();
@@ -2154,6 +2190,22 @@ export class GameWorld {
this.statsScreen = effect.mon;
this.dialogue = { pages: ["Stats"], index: 0 };
return true;
case "pokegear":
this.pokegear = effect.view;
if (!effect.view) return false;
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
index: 0,
choice: { kind: "pokegear", view: effect.view, selected: 0 },
};
return true;
case "mombank":
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
index: 0,
choice: { kind: "mombank", view: effect.view, selected: 0 },
};
return true;
case "menu":
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
@@ -2572,6 +2624,7 @@ export class GameWorld {
this.memScript ??= script.memScript;
this.townMap = false;
this.statsScreen = undefined;
this.pokegear = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
@@ -2611,6 +2664,7 @@ export class GameWorld {
this.script = undefined;
this.townMap = false;
this.statsScreen = undefined;
this.pokegear = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
@@ -2639,11 +2693,12 @@ export class GameWorld {
input = undefined;
view.prompt = undefined;
if (next.done) {
if (this.script && next.value.prizeMoney)
this.script.run.state.money = Math.min(
C.MAX_MONEY,
this.script.run.state.money + next.value.prizeMoney,
);
if (this.script) {
const draft = this.script.run.state,
{ prizeMoney = 0, momMoney = 0 } = next.value;
draft.money = Math.min(C.MAX_MONEY, draft.money + prizeMoney);
draft.momsMoney = Math.min(C.MAX_MONEY, draft.momsMoney + momMoney);
}
this.battle = undefined;
this.battleRun = undefined;
// ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM.
@@ -3049,6 +3104,9 @@ export class GameWorld {
townMap = false;
/** The Pokémon a script's stats screen shows (Bill's PC). */
statsScreen: PartyMon | undefined;
/** The Pokégear card on screen while the Pokégear is open, including
* during the phone calls it makes. */
pokegear: PokegearView | undefined;
ticks = 0;
tick(held?: Direction) {
@@ -3069,6 +3127,17 @@ export class GameWorld {
}
const script = this.script;
if (script) {
// Pokegear_UpdateClock runs every frame while the clock card shows.
const gear = this.pokegear;
if (gear?.clock) {
const { hours, minutes } = this.clock();
gear.clock = pokegearClock(
this.assets.reader,
this.host(PLAYER).text(),
hours,
minutes,
);
}
const evolution = this.evolution;
if (evolution) {
const frame = evolution.schedule[++evolution.step];
@@ -3187,6 +3256,7 @@ export class GameWorld {
if (choice && this.dialogue.index === this.dialogue.pages.length - 1) {
// The naming screen is answered with text through choose().
if (choice.kind === "pack") this.choosePackEntry();
else if (buttonChoice(choice)) this.choose("a");
else if (choice.kind !== "name") this.choose(choice.selected);
} else this.advanceDialogue();
return;
@@ -3516,6 +3586,7 @@ export class GameWorld {
const choice = dialogue.choice;
if (choice.kind === "yesno") this.choose(0);
else if (choice.kind === "martmenu") this.choose(2);
else if (buttonChoice(choice)) this.choose("b");
else if (
choice.kind === "martlist" ||
choice.kind === "martquantity" ||
@@ -3545,6 +3616,7 @@ export class GameWorld {
if (!choice || this.dialogue!.index !== this.dialogue!.pages.length - 1)
return;
if (choice.kind === "name") return;
if (buttonChoice(choice)) return this.choose(direction);
if (choice.kind === "yesno") {
if (direction === "up") choice.selected = 1;
if (direction === "down") choice.selected = 0;
@@ -3623,6 +3695,13 @@ export class GameWorld {
(answer !== "" && !NICKNAME_PATTERN.test(answer)))
)
return;
if (
(dialogue.choice.kind === "pokegear" &&
!POKEGEAR_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "mombank" &&
!MOM_BANK_BUTTONS.includes(answer as never))
)
return;
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
@@ -3675,6 +3754,9 @@ export class GameWorld {
caught: [...state.pokedex.caught].sort((a, b) => a - b),
},
money: state.money,
momsMoney: state.momsMoney,
momItem: state.momItem,
momItemTriggerBalance: state.momItemTriggerBalance,
decorations: { ...state.decorations },
fruitTrees: [...state.fruitTrees].sort((a, b) => a - b),
dailyReset: state.dailyReset,
@@ -3759,6 +3841,9 @@ export class GameWorld {
!species(save.pokedex.caught) ||
!save.pokedex.caught.every((id) => save.pokedex.seen.includes(id)) ||
!integer(save.money, 0, C.MAX_MONEY) ||
!integer(save.momsMoney, 0, C.MAX_MONEY) ||
!integer(save.momItem, 0, 10) ||
!integer(save.momItemTriggerBalance, 0, 0xffffff) ||
!Array.isArray(save.fruitTrees) ||
!save.fruitTrees.every((id) => integer(id, 1, NUM_FRUIT_TREES)) ||
new Set(save.fruitTrees).size !== save.fruitTrees.length ||
@@ -3837,6 +3922,9 @@ export class GameWorld {
caught: new Set(save.pokedex.caught),
},
money: save.money,
momsMoney: save.momsMoney,
momItem: save.momItem,
momItemTriggerBalance: save.momItemTriggerBalance,
fruitTrees: new Set(save.fruitTrees),
dailyReset: save.dailyReset,
lastSpawnMap: save.lastSpawnMap,
@@ -3892,6 +3980,7 @@ export class GameWorld {
this.playerId = save.playerId;
this.townMap = false;
this.statsScreen = undefined;
this.pokegear = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
@@ -3929,6 +4018,11 @@ export function migrateSave(
decorations: initialDecorations(),
}),
...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }),
...(!Object.hasOwn(save, "momsMoney") && { momsMoney: 0 }),
...(!Object.hasOwn(save, "momItem") && {
momItem: 0,
momItemTriggerBalance: OW.MOM_MONEY,
}),
...(!Object.hasOwn(save, "storage") && { storage: emptyStorage() }),
...(!Object.hasOwn(save, "pcItems") && { pcItems: [] }),
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
@@ -3980,6 +4074,9 @@ export function migrateSave(
specialPhoneCall: 0,
rivalName: "???",
money: C.START_MONEY,
momsMoney: 0,
momItem: 0,
momItemTriggerBalance: OW.MOM_MONEY,
fruitTrees: [],
dailyReset: null,
playerState: 0,