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
@@ -131,6 +131,8 @@ export type BattleInput = number | string | BattleAction | undefined;
|
||||
export type BattleOutcome = {
|
||||
result: number;
|
||||
prizeMoney?: number;
|
||||
/** The part of the prize sent to Mom's savings. */
|
||||
momMoney?: number;
|
||||
evolvable?: number;
|
||||
};
|
||||
|
||||
@@ -177,6 +179,9 @@ export interface BattleOptions {
|
||||
landmark?: number;
|
||||
/** wPartyMenuCursor as the last party menu outside battle left it. */
|
||||
partyMenuCursor?: number;
|
||||
/** wMomSavingMoney's MOM_SAVING_MONEY_MASK bits and wMomsMoney: how much
|
||||
* of a trainer's prize goes to Mom. */
|
||||
mom?: { saving: number; money: number };
|
||||
}
|
||||
|
||||
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
|
||||
@@ -796,16 +801,49 @@ export class Battle {
|
||||
yield this.hud();
|
||||
}
|
||||
if (trainer.winText) yield* this.say(trainer.winText);
|
||||
const prizeMoney = trainer.reward * (amuletCoin ? 2 : 1);
|
||||
this.numbers[RAM.battleReward] = prizeMoney;
|
||||
yield* this.say(BATTLE.GotMoneyForWinningText);
|
||||
return { result: this.battleResult, prizeMoney, evolvable: this.evolvable };
|
||||
return {
|
||||
result: this.battleResult,
|
||||
...(yield* this.giveMoney(trainer.reward, amuletCoin)),
|
||||
evolvable: this.evolvable,
|
||||
};
|
||||
}
|
||||
if (trainer && this.options.canLose && trainer.lossText)
|
||||
yield* this.say(trainer.lossText);
|
||||
return { result: this.battleResult, evolvable: this.evolvable };
|
||||
}
|
||||
|
||||
/** WinTrainerBattle.give_money: the reward (TrainerClassAttributes'
|
||||
* base times the last Pokémon's level, which `reward` holds four times)
|
||||
* is added four times, to Mom for as many quarters as her saving mode
|
||||
* takes unless her account is full, and to the wallet for the rest. Each
|
||||
* account stops at MAX_MONEY. The text shows four times the reward. */
|
||||
private *giveMoney(
|
||||
reward: number,
|
||||
amuletCoin: boolean,
|
||||
): Gen<{ prizeMoney: number; momMoney?: number }> {
|
||||
const quarter = (reward / 4) * (amuletCoin ? 2 : 1);
|
||||
const mom = this.options.mom ?? { saving: 0, money: 0 },
|
||||
maxedOut = mom.money >= C.MAX_MONEY;
|
||||
if (![0, 1, 2, 3].includes(mom.saving))
|
||||
throw new UnsupportedBattle("Unsupported Mom saving mode.");
|
||||
// .CheckMaxedOutMomMoney, then SOME|HALF counts as all four quarters.
|
||||
const toMom = maxedOut ? 0 : mom.saving === 3 ? 4 : mom.saving;
|
||||
this.numbers[RAM.battleReward] = quarter * 4;
|
||||
yield* this.say(
|
||||
maxedOut || !mom.saving
|
||||
? BATTLE.GotMoneyForWinningText
|
||||
: [
|
||||
BATTLE.SentSomeToMomText,
|
||||
BATTLE.SentHalfToMomText,
|
||||
BATTLE.SentAllToMomText,
|
||||
][mom.saving - 1],
|
||||
);
|
||||
return {
|
||||
prizeMoney: quarter * (4 - toMom),
|
||||
...(toMom ? { momMoney: quarter * toMom } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private *battleTurn(): Gen {
|
||||
for (let turns = 0; turns < 1000; turns++) {
|
||||
this.curDamage = 0;
|
||||
|
||||
@@ -3,12 +3,19 @@ import { OVERWORLD, SYMBOLS } from "../rom/profile";
|
||||
import { decompress } from "../rom/lz";
|
||||
import { drawTile } from "./pixels";
|
||||
|
||||
/** The ROM's Town Map, used by wall maps and the bedroom poster. */
|
||||
export function drawTownMap(canvas: HTMLCanvasElement, world: GameWorld) {
|
||||
/** The ROM's Town Map, used by wall maps, the bedroom poster and the
|
||||
* Pokégear's map card, which chooses the region and marks the landmark
|
||||
* under its cursor. */
|
||||
export function drawTownMap(
|
||||
canvas: HTMLCanvasElement,
|
||||
world: GameWorld,
|
||||
card?: { kanto: boolean; player: number; cursor: number; name: string },
|
||||
) {
|
||||
const { assets } = world,
|
||||
r = assets.reader;
|
||||
const landmark = world.map.landmark;
|
||||
const map = landmark >= 47 ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap;
|
||||
const landmark = card?.player ?? world.map.landmark;
|
||||
const kanto = card?.kanto ?? landmark >= 47;
|
||||
const map = kanto ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap;
|
||||
const graphics = decompress(r, OVERWORLD.TownMapGFX, 0x1000);
|
||||
const palette =
|
||||
world.avatar === "kris"
|
||||
@@ -37,8 +44,24 @@ export function drawTownMap(canvas: HTMLCanvasElement, world: GameWorld) {
|
||||
}
|
||||
context.putImageData(image, 0, 0);
|
||||
// Landmark coordinates are the same ones TownMapPlayerIcon uses.
|
||||
const p = SYMBOLS.landmarks + landmark * 4;
|
||||
const at = (index: number) => {
|
||||
const p = SYMBOLS.landmarks + index * 4;
|
||||
return { x: r.byte(p) - 8, y: r.byte(p + 1) - 16 };
|
||||
};
|
||||
const player = at(landmark);
|
||||
context.fillStyle = "#b32628";
|
||||
context.fillRect(r.byte(p) - 10, r.byte(p + 1) - 18, 4, 4);
|
||||
canvas.setAttribute("aria-label", `Town Map: ${world.map.location}`);
|
||||
context.fillRect(player.x - 2, player.y - 2, 4, 4);
|
||||
if (card) {
|
||||
// The Pokégear's arrow cursor, drawn as a frame around the landmark.
|
||||
const cursor = at(card.cursor);
|
||||
context.strokeStyle = "#2366a5";
|
||||
context.lineWidth = 2;
|
||||
context.strokeRect(cursor.x - 5, cursor.y - 5, 10, 10);
|
||||
}
|
||||
canvas.setAttribute(
|
||||
"aria-label",
|
||||
card
|
||||
? `Town Map: ${card.name}${card.cursor === card.player ? ", you are here" : ""}`
|
||||
: `Town Map: ${world.map.location}`,
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,7 @@ export class CrystalAssets {
|
||||
tileset: r.byte(header + 1),
|
||||
environment: r.byte(header + 2),
|
||||
paletteMode: r.byte(header + 7) & 15,
|
||||
phoneService: r.byte(header + 7) >> 4,
|
||||
attributes,
|
||||
blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight),
|
||||
warps: [],
|
||||
|
||||
@@ -513,6 +513,9 @@ export const BATTLE = {
|
||||
WantsToBattleText: 0x807a9,
|
||||
BattleText_EnemyWasDefeated: 0x809da,
|
||||
GotMoneyForWinningText: 0x809be,
|
||||
SentSomeToMomText: 0x809fc,
|
||||
SentHalfToMomText: 0x80a2a,
|
||||
SentAllToMomText: 0x80a3d,
|
||||
BattleText_EnemySentOut: 0x80af8,
|
||||
BattleText_TheresNoEscapeFromTrainerBattle: 0x80b49,
|
||||
AccuracyLevelMultipliers: 0x34eb2,
|
||||
@@ -671,6 +674,67 @@ export const BATTLE = {
|
||||
WontRiseAnymoreText: 0x81272,
|
||||
} as const;
|
||||
|
||||
/** The Pokégear (engine/pokegear/pokegear.asm) and the phone calls it
|
||||
* makes (engine/phone/phone.asm): texts, strings and scripts, keyed by
|
||||
* their PRET labels so that \`npm run check:reference\` can verify each
|
||||
* address by name. */
|
||||
export const POKEGEAR = {
|
||||
PokegearPressButtonText: 0x914d3,
|
||||
PokegearAskWhoCallText: 0x914ce,
|
||||
PokegearAskDeleteText: 0x914d8,
|
||||
"Pokegear_UpdateClock.GearTodayText": 0x90faf,
|
||||
"PokegearPhone_MakePhoneCall.GearEllipseText": 0x9124c,
|
||||
"PokegearPhone_MakePhoneCall.GearOutOfServiceText": 0x91251,
|
||||
"PokegearPhoneContactSubmenu.CallDeleteCancelStrings": 0x9143f,
|
||||
"PokegearPhoneContactSubmenu.CallCancelStrings": 0x9145b,
|
||||
NonTrainerCallerNames: 0x903d6,
|
||||
PhoneOutOfAreaScript: 0x90657,
|
||||
PhoneScript_JustTalkToThem: 0x90660,
|
||||
PhoneEllipseText: 0x90336,
|
||||
String_AM: 0x1dd6fc,
|
||||
String_PM: 0x1dd6ff,
|
||||
"Mom_GetScriptPointer.ItemScript": 0xfd0fd,
|
||||
"Mom_GetScriptPointer.DollScript": 0xfd10a,
|
||||
MomItems_1: 0xfd136,
|
||||
"MomItems_1.End": 0xfd15e,
|
||||
MomItems_2: 0xfd15e,
|
||||
"MomItems_2.End": 0xfd1ae,
|
||||
} as const;
|
||||
|
||||
/** BankOfMom (engine/events/mom.asm): its texts, strings, menu and digit
|
||||
* steps, keyed by their PRET labels. */
|
||||
export const MOM = {
|
||||
BankOfMom: 0x16218,
|
||||
"DSTChecks.TimesetAskAdjustDSTText": 0x164f4,
|
||||
"DSTChecks.MomLostGearBookletText": 0x164f9,
|
||||
"DSTChecks.TimesetAskDSTText": 0x164fe,
|
||||
"DSTChecks.TimesetDSTText": 0x16503,
|
||||
"DSTChecks.TimesetAskNotDSTText": 0x16508,
|
||||
"DSTChecks.TimesetNotDSTText": 0x1650d,
|
||||
"Mom_WithdrawDepositMenuJoypad.DigitQuantities": 0x16613,
|
||||
MomLeavingText1: 0x16649,
|
||||
MomLeavingText2: 0x1664e,
|
||||
MomLeavingText3: 0x16653,
|
||||
MomIsThisAboutYourMoneyText: 0x16658,
|
||||
MomBankWhatDoYouWantToDoText: 0x1665d,
|
||||
MomStoreMoneyText: 0x16662,
|
||||
MomTakeMoneyText: 0x16667,
|
||||
MomSaveMoneyText: 0x1666c,
|
||||
MomHaventSavedThatMuchText: 0x16671,
|
||||
MomNotEnoughRoomInWalletText: 0x16676,
|
||||
MomInsufficientFundsInWalletText: 0x1667b,
|
||||
MomNotEnoughRoomInBankText: 0x16680,
|
||||
MomStartSavingMoneyText: 0x16685,
|
||||
MomStoredMoneyText: 0x1668a,
|
||||
MomTakenMoneyText: 0x1668f,
|
||||
MomJustDoWhatYouCanText: 0x16694,
|
||||
Mom_SavedString: 0x16699,
|
||||
Mon_WithdrawString: 0x1669f,
|
||||
Mom_DepositString: 0x166a8,
|
||||
Mom_HeldString: 0x166b0,
|
||||
"BankOfMom_MenuHeader.MenuData": 0x166bd,
|
||||
} as const;
|
||||
|
||||
export const MAP_GROUP_COUNT = 26;
|
||||
export const NUM_POKEMON = 251;
|
||||
export const NUM_TRAINER_CLASSES = 67;
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
NATIVE_ROUTINES,
|
||||
NUM_FRUIT_TREES,
|
||||
EVENTS,
|
||||
MOM,
|
||||
OVERWORLD,
|
||||
PC,
|
||||
POKEGEAR,
|
||||
RAM,
|
||||
SPECIAL_ROUTINES,
|
||||
SYMBOLS,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
copyStorage,
|
||||
currentBox,
|
||||
emptyStorage,
|
||||
receivePCItem,
|
||||
type PokemonStorage,
|
||||
} from "../party/storage";
|
||||
import {
|
||||
@@ -47,6 +50,8 @@ import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||
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 {
|
||||
defaultTextContext,
|
||||
readString,
|
||||
@@ -81,6 +86,13 @@ export interface ScriptState {
|
||||
pokedex: { seen: Set<number>; caught: Set<number> };
|
||||
/** wMoney: a 24-bit amount. */
|
||||
money: number;
|
||||
/** wMomsMoney: what Mom has saved, up to MAX_MONEY. */
|
||||
momsMoney: number;
|
||||
/** wWhichMomItem: the next MomItems_2 entry Mom buys. */
|
||||
momItem: number;
|
||||
/** wMomItemTriggerBalance: the next multiple of MOM_MONEY at which Mom
|
||||
* buys from MomItems_1. */
|
||||
momItemTriggerBalance: number;
|
||||
/** wFruitTreeFlags: trees (FRUITTREE_* ids) picked since the last reset. */
|
||||
fruitTrees: Set<number>;
|
||||
/** Local calendar day of the last CheckDailyResetTimer reset; null until
|
||||
@@ -120,6 +132,9 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
pcItems: [],
|
||||
pokedex: { seen: new Set(), caught: new Set() },
|
||||
money: C.START_MONEY,
|
||||
momsMoney: 0,
|
||||
momItem: 0,
|
||||
momItemTriggerBalance: OW.MOM_MONEY,
|
||||
fruitTrees: new Set(),
|
||||
dailyReset: null,
|
||||
lastSpawnMap: null,
|
||||
@@ -148,6 +163,9 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
caught: new Set(state.pokedex.caught),
|
||||
},
|
||||
money: state.money,
|
||||
momsMoney: state.momsMoney,
|
||||
momItem: state.momItem,
|
||||
momItemTriggerBalance: state.momItemTriggerBalance,
|
||||
fruitTrees: new Set(state.fruitTrees),
|
||||
dailyReset: state.dailyReset,
|
||||
lastSpawnMap: state.lastSpawnMap,
|
||||
@@ -255,6 +273,12 @@ export type ScriptEffect =
|
||||
| { kind: "notice"; text: string }
|
||||
/** The Pokémon stats screen, closed with a button. */
|
||||
| { kind: "stats"; mon: PartyMon }
|
||||
/** A Pokégear card with the textbox's pages, answered with the button
|
||||
* pressed (PokegearButton); without a view the Pokégear closes. */
|
||||
| { kind: "pokegear"; pages: string[]; view?: PokegearView }
|
||||
/** BankOfMom's deposit or withdrawal box with the textbox's pages,
|
||||
* answered with the button pressed (MomBankButton). */
|
||||
| { kind: "mombank"; pages: string[]; view: MomBankView }
|
||||
/** EvolutionAnimation: `from` flashes into `to`. Answered with 1 when
|
||||
* B stopped it (only if `cancelable`), otherwise 0. */
|
||||
| { kind: "evolve"; from: number; to: number; cancelable: boolean }
|
||||
@@ -307,6 +331,8 @@ export interface ScriptHost {
|
||||
/** wPlayerTileCollision and wEnvironment. */
|
||||
playerCollision(): number;
|
||||
environment(): number;
|
||||
/** GetMapPhoneService: nonzero where the phone has no signal. */
|
||||
phoneService(): number;
|
||||
}
|
||||
|
||||
export class UnsupportedScript extends Error {
|
||||
@@ -329,7 +355,30 @@ 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;
|
||||
/** A momitem: trigger and cost (bigdt), then MOM_ITEM or MOM_DOLL and the
|
||||
* item or decoration. */
|
||||
const MOMITEM_SIZE = 8,
|
||||
MOM_ITEM = 1;
|
||||
const BATTLERESULT_BOX_FULL = 0x80;
|
||||
/** HangUp (engine/phone/phone.asm): "Click!" for HangUp_Beep's 20
|
||||
* frames, then Phone_CallEnd's three boops, "……" shown and cleared over six
|
||||
* more 20-frame waits. */
|
||||
export const HANG_UP = { clickTicks: 10, endTicks: 60 } as const;
|
||||
|
||||
/** GetLandmarkName (engine/overworld/landmarks.asm): the ROM's name of a
|
||||
* landmark, as text prints it (<BSP> is a space). */
|
||||
export function landmarkName(
|
||||
reader: RomReader,
|
||||
landmark: number,
|
||||
context: TextContext,
|
||||
) {
|
||||
const entry = SYMBOLS.landmarks + landmark * 4 + 2;
|
||||
return readString(
|
||||
reader,
|
||||
reader.near(entry, reader.bank(SYMBOLS.landmarks)),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
/** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm):
|
||||
* a one-tick sleep, then normal steps to the player's tile, dropping the
|
||||
@@ -725,10 +774,16 @@ export class ScriptRun {
|
||||
receiveItem(data, this.state.bag, ball.item, ball.quantity),
|
||||
);
|
||||
} else if (routine === NATIVE_ROUTINES.hangUp) {
|
||||
// HangUp_Beep prints "Click!", then seven 20-frame waits for the
|
||||
// hang-up tones pass before closetext; no button is needed.
|
||||
// HangUp_Beep prints "Click!", then the hang-up tones pass
|
||||
// before closetext; no button is needed.
|
||||
write(SYMBOLS.phoneClickText);
|
||||
yield { kind: "text", pages: take(), holdTicks: 140 };
|
||||
yield {
|
||||
kind: "text",
|
||||
pages: take(),
|
||||
holdTicks: HANG_UP.clickTicks,
|
||||
};
|
||||
write(POKEGEAR.PhoneEllipseText);
|
||||
yield { kind: "text", pages: take(), holdTicks: HANG_UP.endTicks };
|
||||
} else if (
|
||||
routine === NATIVE_ROUTINES.ringTwiceStartCall ||
|
||||
routine === NATIVE_ROUTINES.initCallReceiveDelay
|
||||
@@ -948,11 +1003,13 @@ export class ScriptRun {
|
||||
case 0x60:
|
||||
// Script_reloadmapafterbattle: a loss continues with the whiteout.
|
||||
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
|
||||
// BATTLESCRIPT_WILD_F is set by trainer battles, which run
|
||||
// MomTriesToBuySomething. Without a purchase it changes nothing.
|
||||
else if (trainer) yield* this.momTriesToBuySomething();
|
||||
// A wild catch that filled the box queues Bill's call
|
||||
// (Script_SpecialBillCall: LoadCallerScript for PHONE_BILL, then
|
||||
// Script_ReceivePhoneCall). BATTLESCRIPT_WILD_F is set by trainer
|
||||
// battles, which instead run the unported MomTriesToBuySomething.
|
||||
else if (!trainer && battleResult & BATTLERESULT_BOX_FULL)
|
||||
// Script_ReceivePhoneCall).
|
||||
else if (battleResult & BATTLERESULT_BOX_FULL)
|
||||
yield synchronous({
|
||||
kind: "memscript",
|
||||
script: PC.Script_ReceivePhoneCall,
|
||||
@@ -1078,6 +1135,49 @@ export class ScriptRun {
|
||||
case 0x9c:
|
||||
this.state.specialPhoneCall = word();
|
||||
break;
|
||||
case 0x17: {
|
||||
// random: wScriptVar = a byte below the operand, rejecting the
|
||||
// bytes past the largest multiple of it (Script_random).
|
||||
const range = byte();
|
||||
value = range && this.randomRange(range);
|
||||
break;
|
||||
}
|
||||
case 0x22:
|
||||
case 0x23:
|
||||
case 0x24: {
|
||||
// givemoney, takemoney and checkmoney on YOUR_MONEY or MOMS_MONEY.
|
||||
const account = byte() ? "momsMoney" : "money",
|
||||
amount = (byte() << 16) | (byte() << 8) | byte();
|
||||
const balance = this.state[account];
|
||||
if (opcode === 0x22)
|
||||
this.state[account] = Math.min(C.MAX_MONEY, balance + amount);
|
||||
else if (opcode === 0x23) {
|
||||
// TakeMoney leaves the account unchanged when it is short.
|
||||
if (balance >= amount) this.state[account] = balance - amount;
|
||||
} else
|
||||
value =
|
||||
balance > amount
|
||||
? OW.HAVE_MORE
|
||||
: balance === amount
|
||||
? OW.HAVE_AMOUNT
|
||||
: OW.HAVE_LESS;
|
||||
break;
|
||||
}
|
||||
case 0x3d: {
|
||||
// getmoney: the account's balance, left-aligned without the ¥.
|
||||
const balance = byte() ? this.state.momsMoney : this.state.money;
|
||||
this.strings[buffer(byte())] = String(balance);
|
||||
break;
|
||||
}
|
||||
case 0x42:
|
||||
case 0xa5: {
|
||||
// getcurlandmarkname and getlandmarkname (whose landmark operand
|
||||
// comes first): GetLandmarkName copies the name, whose <BSP>
|
||||
// prints as a space.
|
||||
const landmark = opcode === 0x42 ? this.host.landmark() : byte();
|
||||
this.strings[buffer(byte())] = landmarkName(r, landmark, this.text());
|
||||
break;
|
||||
}
|
||||
case 0x9d:
|
||||
value = Number(this.state.specialPhoneCall !== 0);
|
||||
break;
|
||||
@@ -1847,6 +1947,75 @@ export class ScriptRun {
|
||||
});
|
||||
}
|
||||
|
||||
/** MomTriesToBuySomething (engine/events/mom_phone.asm) after a trainer
|
||||
* battle, where the phone has service. CheckBalance_MomItem2: Mom buys
|
||||
* the next MomItems_2 entry once her savings reach its trigger.
|
||||
* Otherwise wMomItemTriggerBalance climbs in MOM_MONEY steps while it is
|
||||
* below her savings, and landing exactly on them buys a random MomItems_1
|
||||
* entry. Mom_GiveItemOrDoll puts an item in the PC (nothing happens when
|
||||
* it is full) or sets a doll's decoration flag, and her call is queued.
|
||||
* The call's .ASMFunction (the cost and the next wWhichMomItem) is
|
||||
* applied here rather than when the call starts; nothing runs between.
|
||||
* wMapReentryScriptQueueFlag's check is the world's: a script already
|
||||
* queued is kept. */
|
||||
private *momTriesToBuySomething(): Generator<ScriptEffect, void, Input> {
|
||||
if (this.host.phoneService()) return;
|
||||
const r = this.reader,
|
||||
state = this.state;
|
||||
const money = (p: number) =>
|
||||
(r.byte(p) << 16) | (r.byte(p + 1) << 8) | r.byte(p + 2);
|
||||
const items1 =
|
||||
(POKEGEAR["MomItems_1.End"] - POKEGEAR.MomItems_1) / MOMITEM_SIZE,
|
||||
items2 =
|
||||
(POKEGEAR["MomItems_2.End"] - POKEGEAR.MomItems_2) / MOMITEM_SIZE;
|
||||
// wWhichMomItemSet: 0 for MomItems_2, or a MomItems_1 entry plus one.
|
||||
let set = 0;
|
||||
if (
|
||||
state.momItem >= items2 ||
|
||||
state.momsMoney <
|
||||
money(POKEGEAR.MomItems_2 + state.momItem * MOMITEM_SIZE)
|
||||
) {
|
||||
while (state.momItemTriggerBalance < state.momsMoney)
|
||||
state.momItemTriggerBalance += OW.MOM_MONEY;
|
||||
if (state.momItemTriggerBalance !== state.momsMoney) return;
|
||||
state.momItemTriggerBalance += OW.MOM_MONEY;
|
||||
set = this.randomRange(items1) + 1;
|
||||
}
|
||||
// GetItemFromMom
|
||||
const entry = set
|
||||
? POKEGEAR.MomItems_1 + (set - 1) * MOMITEM_SIZE
|
||||
: POKEGEAR.MomItems_2 + state.momItem * MOMITEM_SIZE;
|
||||
const doll = r.byte(entry + 6) !== MOM_ITEM,
|
||||
item = r.byte(entry + 7);
|
||||
if (doll)
|
||||
state.events.add(
|
||||
new DecorationData(r, this.host.data).attribute(item).event,
|
||||
);
|
||||
else if (!receivePCItem(state.pcItems, item, 1)) return;
|
||||
// .ASMFunction: MomBuysItem_DeductFunds, then the next MomItems_2 entry.
|
||||
state.momsMoney = Math.max(0, state.momsMoney - money(entry + 3));
|
||||
if (!set) state.momItem++;
|
||||
yield {
|
||||
kind: "memscript",
|
||||
script: PC.Script_ReceivePhoneCall,
|
||||
callerScript: doll
|
||||
? POKEGEAR["Mom_GetScriptPointer.DollScript"]
|
||||
: POKEGEAR["Mom_GetScriptPointer.ItemScript"],
|
||||
};
|
||||
}
|
||||
|
||||
/** RandomRange: a byte below `range`, rejecting the draws past its
|
||||
* largest multiple, as Script_random also does. */
|
||||
private randomRange(range: number) {
|
||||
const limit = 256 - (256 % range);
|
||||
let draw = this.host.random();
|
||||
for (let tries = 0; limit < 256 && draw >= limit; tries++) {
|
||||
if (tries > 4096) throw new Error("Random draw limit exceeded.");
|
||||
draw = this.host.random();
|
||||
}
|
||||
return draw % range;
|
||||
}
|
||||
|
||||
/** readvar: the script variables that native state can answer. */
|
||||
private readVar(id: number, offset: number): number {
|
||||
const clock = this.host.clock();
|
||||
@@ -2031,6 +2200,17 @@ export class ScriptRun {
|
||||
case OVERWORLD.OverworldTownMap:
|
||||
yield { kind: "townmap" };
|
||||
return value;
|
||||
case MOM.BankOfMom:
|
||||
yield* bankOfMom(
|
||||
{
|
||||
state: this.state,
|
||||
reader: r,
|
||||
text: () => this.text(),
|
||||
hours: () => this.host.clock().hours,
|
||||
},
|
||||
pages,
|
||||
);
|
||||
return value;
|
||||
case PC.PokemonCenterPC:
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
yield* this.pc().pokemonCenterPC();
|
||||
@@ -2125,6 +2305,7 @@ export function runScript(
|
||||
objectPosition: () => undefined,
|
||||
playerCollision: () => 0,
|
||||
environment: () => 0,
|
||||
phoneService: () => 0,
|
||||
});
|
||||
const pages: string[] = [];
|
||||
for (let effect = run.resume(); effect; effect = run.resume()) {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import { MOM } from "../rom/profile";
|
||||
import { C } from "../battle/constants";
|
||||
import { OW } from "../world/constants";
|
||||
import { readString, readText, type TextContext } from "./text";
|
||||
import type { ClockSettings, ScriptEffect, ScriptState } from "./interpreter";
|
||||
|
||||
type Input = number | string | undefined;
|
||||
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
||||
|
||||
/** The buttons Mom_WithdrawDepositMenuJoypad reads: A and B from
|
||||
* hJoyPressed, the directions from hJoyLast. */
|
||||
export type MomBankButton = "a" | "b" | "up" | "down" | "left" | "right";
|
||||
export const MOM_BANK_BUTTONS: readonly string[] = [
|
||||
"a",
|
||||
"b",
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
];
|
||||
|
||||
/** Mom_ContinueMenuSetup's box: what Mom has saved, what the player holds,
|
||||
* and the amount being chosen, whose digit under the cursor blinks. */
|
||||
export interface MomBankView {
|
||||
/** Mom_SavedString, Mom_HeldString, then Mom_DepositString or
|
||||
* Mon_WithdrawString. */
|
||||
labels: [string, string, string];
|
||||
saved: number;
|
||||
held: number;
|
||||
amount: number;
|
||||
/** wMomBankDigitCursorPosition: 0 is the leftmost of the six digits. */
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
/** What BankOfMom needs from the running script. */
|
||||
export interface MomHost {
|
||||
state: ScriptState;
|
||||
reader: RomReader;
|
||||
text(): TextContext;
|
||||
/** hHours: the game clock's hour. */
|
||||
hours(): number;
|
||||
}
|
||||
|
||||
/** BankOfMom's jumptable indices. CheckIfBankInitialized (0) runs first;
|
||||
* AskDST (8) only sets JUMPTABLE_EXIT_F. */
|
||||
const INITIALIZE_BANK = 1,
|
||||
IS_THIS_ABOUT_YOUR_MONEY = 2,
|
||||
ACCESS_BANK_OF_MOM = 3,
|
||||
STORE_MONEY = 4,
|
||||
TAKE_MONEY = 5,
|
||||
STOP_OR_START_SAVING_MONEY = 6,
|
||||
JUST_DO_WHAT_YOU_CAN = 7;
|
||||
/** The six digits' cursor positions, as .movecursorright stops at 5. */
|
||||
const LAST_DIGIT = 5;
|
||||
|
||||
/** DSTChecks' SetClockForward and SetClockBack move wStartHour by an hour,
|
||||
* carrying into wStartDay. The native clock keeps its minute offset within
|
||||
* half a day, so a wrap moves the weekday offset the other way instead. */
|
||||
export function shiftClockHour(clock: ClockSettings, hours: 1 | -1) {
|
||||
let offset = clock.minuteOffset + hours * 60;
|
||||
if (offset >= 720) {
|
||||
offset -= 1440;
|
||||
clock.weekdayOffset = (clock.weekdayOffset + 1) % 7;
|
||||
} else if (offset < -720) {
|
||||
offset += 1440;
|
||||
clock.weekdayOffset = (clock.weekdayOffset + 6) % 7;
|
||||
}
|
||||
clock.minuteOffset = offset;
|
||||
}
|
||||
|
||||
/** BankOfMom (engine/events/mom.asm), the special Mom's script calls once
|
||||
* the Mystery Egg is delivered. The first time she offers to save money;
|
||||
* afterwards GET, SAVE and CHANGE withdraw, deposit or toggle her savings,
|
||||
* and declining runs DSTChecks. wMomSavingMoney is kept as its two engine
|
||||
* flags: BankOfMom only ever writes MOM_ACTIVE_F and
|
||||
* MOM_SAVING_SOME_MONEY_F. The last text stays in `pages` for the
|
||||
* script's waitbutton. */
|
||||
export function* bankOfMom(host: MomHost, pages: string[]): Gen {
|
||||
const { reader: r, state } = host;
|
||||
const write = (address: number) => {
|
||||
const content = readText(r, address, host.text());
|
||||
if (content) pages.push(...content.split("\n\n"));
|
||||
};
|
||||
const take = () => pages.splice(0, pages.length);
|
||||
const yesNo = function* (): Gen<boolean> {
|
||||
return Boolean(yield { kind: "yesno", pages: take() });
|
||||
};
|
||||
/** wMomSavingMoney: MOM_ACTIVE_F, with or without MOM_SAVING_SOME_MONEY_F. */
|
||||
const setSaving = (saving: boolean) => {
|
||||
state.flags.add(OW.ENGINE_MOM_ACTIVE);
|
||||
if (saving) state.flags.add(OW.ENGINE_MOM_SAVING_MONEY);
|
||||
else state.flags.delete(OW.ENGINE_MOM_SAVING_MONEY);
|
||||
};
|
||||
|
||||
// .CheckIfBankInitialized
|
||||
let index = IS_THIS_ABOUT_YOUR_MONEY;
|
||||
if (!state.flags.has(OW.ENGINE_MOM_ACTIVE)) {
|
||||
state.flags.add(OW.ENGINE_MOM_ACTIVE);
|
||||
index = INITIALIZE_BANK;
|
||||
}
|
||||
for (;;) {
|
||||
switch (index) {
|
||||
case INITIALIZE_BANK:
|
||||
write(MOM.MomLeavingText1);
|
||||
if (yield* yesNo()) {
|
||||
write(MOM.MomLeavingText2);
|
||||
setSaving(true);
|
||||
} else setSaving(false);
|
||||
write(MOM.MomLeavingText3);
|
||||
return;
|
||||
case IS_THIS_ABOUT_YOUR_MONEY:
|
||||
write(MOM.MomIsThisAboutYourMoneyText);
|
||||
if (yield* yesNo()) index = ACCESS_BANK_OF_MOM;
|
||||
else {
|
||||
yield* dstChecks(host, pages);
|
||||
index = JUST_DO_WHAT_YOU_CAN;
|
||||
}
|
||||
break;
|
||||
case ACCESS_BANK_OF_MOM: {
|
||||
write(MOM.MomBankWhatDoYouWantToDoText);
|
||||
// BankOfMom_MenuHeader's flags and item count precede the strings.
|
||||
const items = menuStrings(
|
||||
host,
|
||||
MOM["BankOfMom_MenuHeader.MenuData"] + 2,
|
||||
4,
|
||||
);
|
||||
const choice = yield {
|
||||
kind: "menu",
|
||||
pages: take(),
|
||||
items,
|
||||
selected: 0,
|
||||
};
|
||||
index =
|
||||
choice === 0
|
||||
? TAKE_MONEY
|
||||
: choice === 1
|
||||
? STORE_MONEY
|
||||
: choice === 2
|
||||
? STOP_OR_START_SAVING_MONEY
|
||||
: JUST_DO_WHAT_YOU_CAN;
|
||||
break;
|
||||
}
|
||||
case STORE_MONEY: {
|
||||
write(MOM.MomStoreMoneyText);
|
||||
const amount = yield* digitMenu(host, MOM.Mom_DepositString, take());
|
||||
if (!amount) {
|
||||
index = JUST_DO_WHAT_YOU_CAN;
|
||||
break;
|
||||
}
|
||||
// CompareMoney, then GiveMoney's MAX_MONEY carry on Mom's side; a
|
||||
// refusal returns to .StoreMoney with a fresh amount.
|
||||
if (state.money < amount) write(MOM.MomInsufficientFundsInWalletText);
|
||||
else if (state.momsMoney + amount > C.MAX_MONEY)
|
||||
write(MOM.MomNotEnoughRoomInBankText);
|
||||
else {
|
||||
state.money -= amount;
|
||||
state.momsMoney += amount;
|
||||
write(MOM.MomStoredMoneyText);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TAKE_MONEY: {
|
||||
write(MOM.MomTakeMoneyText);
|
||||
const amount = yield* digitMenu(host, MOM.Mon_WithdrawString, take());
|
||||
if (!amount) {
|
||||
index = JUST_DO_WHAT_YOU_CAN;
|
||||
break;
|
||||
}
|
||||
if (state.momsMoney < amount) write(MOM.MomHaventSavedThatMuchText);
|
||||
else if (state.money + amount > C.MAX_MONEY)
|
||||
write(MOM.MomNotEnoughRoomInWalletText);
|
||||
else {
|
||||
state.momsMoney -= amount;
|
||||
state.money += amount;
|
||||
write(MOM.MomTakenMoneyText);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case STOP_OR_START_SAVING_MONEY:
|
||||
write(MOM.MomSaveMoneyText);
|
||||
if (yield* yesNo()) {
|
||||
setSaving(true);
|
||||
write(MOM.MomStartSavingMoneyText);
|
||||
return;
|
||||
}
|
||||
setSaving(false);
|
||||
index = JUST_DO_WHAT_YOU_CAN;
|
||||
break;
|
||||
default:
|
||||
write(MOM.MomJustDoWhatYouCanText);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Consecutive "@"-terminated strings, as static menus store them. */
|
||||
function menuStrings(host: MomHost, address: number, count: number) {
|
||||
const r = host.reader,
|
||||
result: string[] = [];
|
||||
let p = address;
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(readString(r, p, host.text()));
|
||||
while (r.byte(p++) !== 0x50);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Mom_SetUpDepositMenu or Mom_SetUpWithdrawMenu, then
|
||||
* Mom_WithdrawDepositMenuJoypad: the amount starts at zero with the cursor
|
||||
* on the last digit. Up and Down add or take the digit's DigitQuantities
|
||||
* step through GiveMoney and TakeMoney, which stop at MAX_MONEY and zero.
|
||||
* Returns the amount on A, or 0 on B. */
|
||||
function* digitMenu(
|
||||
host: MomHost,
|
||||
label: number,
|
||||
prompt: string[],
|
||||
): Gen<number> {
|
||||
const r = host.reader,
|
||||
state = host.state,
|
||||
table = MOM["Mom_WithdrawDepositMenuJoypad.DigitQuantities"];
|
||||
const step = (digit: number) =>
|
||||
(r.byte(table + digit * 3) << 16) |
|
||||
(r.byte(table + digit * 3 + 1) << 8) |
|
||||
r.byte(table + digit * 3 + 2);
|
||||
const labels: MomBankView["labels"] = [
|
||||
readString(r, MOM.Mom_SavedString, host.text()),
|
||||
readString(r, MOM.Mom_HeldString, host.text()),
|
||||
readString(r, label, host.text()),
|
||||
];
|
||||
let amount = 0,
|
||||
cursor = LAST_DIGIT,
|
||||
// Earlier pages (a refusal) show once; the prompt stays with the box.
|
||||
pages = prompt;
|
||||
for (;;) {
|
||||
const button = yield {
|
||||
kind: "mombank",
|
||||
pages,
|
||||
view: {
|
||||
labels,
|
||||
saved: state.momsMoney,
|
||||
held: state.money,
|
||||
amount,
|
||||
cursor,
|
||||
},
|
||||
};
|
||||
pages = prompt.slice(-1);
|
||||
// hJoyPressed's B, then A, before the directions.
|
||||
if (button === "b") return 0;
|
||||
if (button === "a") return amount;
|
||||
if (button === "up") amount = Math.min(C.MAX_MONEY, amount + step(cursor));
|
||||
else if (button === "down") amount = Math.max(0, amount - step(cursor));
|
||||
else if (button === "left") cursor = Math.max(0, cursor - 1);
|
||||
else if (button === "right") cursor = Math.min(LAST_DIGIT, cursor + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** DSTChecks: offer to switch Daylight Saving Time on or off, unless doing
|
||||
* so would change the day (within an hour of midnight), when Mom says she
|
||||
* lost the Pokégear's booklet instead. */
|
||||
function* dstChecks(host: MomHost, pages: string[]): Gen {
|
||||
const r = host.reader,
|
||||
clock = host.state.clock;
|
||||
const write = (address: number) => {
|
||||
const content = readText(r, address, host.text());
|
||||
if (content) pages.push(...content.split("\n\n"));
|
||||
};
|
||||
const yesNo = function* (): Gen<boolean> {
|
||||
return Boolean(
|
||||
yield { kind: "yesno", pages: pages.splice(0, pages.length) },
|
||||
);
|
||||
};
|
||||
const hours = host.hours();
|
||||
if (clock.dst ? hours === 0 : hours === 23) {
|
||||
write(MOM["DSTChecks.TimesetAskAdjustDSTText"]);
|
||||
if (yield* yesNo()) write(MOM["DSTChecks.MomLostGearBookletText"]);
|
||||
return;
|
||||
}
|
||||
if (clock.dst) {
|
||||
write(MOM["DSTChecks.TimesetAskNotDSTText"]);
|
||||
if (!(yield* yesNo())) return;
|
||||
clock.dst = false;
|
||||
shiftClockHour(clock, -1);
|
||||
write(MOM["DSTChecks.TimesetNotDSTText"]);
|
||||
} else {
|
||||
write(MOM["DSTChecks.TimesetAskDSTText"]);
|
||||
if (!(yield* yesNo())) return;
|
||||
clock.dst = true;
|
||||
shiftClockHour(clock, 1);
|
||||
write(MOM["DSTChecks.TimesetDSTText"]);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
copyScriptState,
|
||||
type MartEntry,
|
||||
type ScriptEffect,
|
||||
type ScriptHost,
|
||||
type ScriptState,
|
||||
} from "./interpreter";
|
||||
import { Pokegear } from "./pokegear";
|
||||
import { readString, readText, type TextContext } from "./text";
|
||||
|
||||
type Input = number | string | undefined;
|
||||
@@ -109,6 +111,10 @@ export interface StartMenuHost {
|
||||
landmark(): number;
|
||||
/** A field move's checks for party slot `mon`, on the menu's draft. */
|
||||
fieldMove(state: ScriptState, mon: number, move: number): MenuFieldMove;
|
||||
/** The script host a Pokégear phone call runs with. */
|
||||
scriptHost(): ScriptHost;
|
||||
/** GetWorldMapLocation for any map. */
|
||||
landmarkOf(mapId: string): number;
|
||||
}
|
||||
|
||||
/** The overworld start menu (engine/menus/start_menu.asm) with its
|
||||
@@ -282,6 +288,16 @@ export class StartMenuRun {
|
||||
case STARTMENUITEM_PACK:
|
||||
if (yield* this.pack()) return;
|
||||
break;
|
||||
case STARTMENUITEM_POKEGEAR:
|
||||
// StartMenu_Pokegear returns to the start menu.
|
||||
yield* new Pokegear(this.state, {
|
||||
reader: this.reader,
|
||||
data: this.data,
|
||||
text: () => this.text(),
|
||||
scriptHost: () => this.host.scriptHost(),
|
||||
landmarkOf: (mapId) => this.host.landmarkOf(mapId),
|
||||
}).run();
|
||||
break;
|
||||
case STARTMENUITEM_EXIT:
|
||||
return;
|
||||
case STARTMENUITEM_SAVE:
|
||||
|
||||
@@ -87,6 +87,38 @@ export const OW = {
|
||||
LANDMARK_SPECIAL: 0,
|
||||
LANDMARK_FAST_SHIP: 95,
|
||||
KANTO_LANDMARK: 47,
|
||||
JOHTO_LANDMARK: 1,
|
||||
JOHTO_LANDMARK_LAST: 46,
|
||||
KANTO_LANDMARK_LAST: 94,
|
||||
LANDMARK_NEW_BARK_TOWN: 1,
|
||||
LANDMARK_VICTORY_ROAD: 88,
|
||||
LANDMARK_ROUTE_28: 94,
|
||||
// The Pokégear's cards and Mom's savings (constants/engine_flags.asm).
|
||||
ENGINE_RADIO_CARD: 0,
|
||||
ENGINE_MAP_CARD: 1,
|
||||
ENGINE_PHONE_CARD: 2,
|
||||
ENGINE_MOM_SAVING_MONEY: 8,
|
||||
ENGINE_MOM_ACTIVE: 9,
|
||||
ENGINE_CREDITS_SKIP: 15,
|
||||
// Phone contacts (constants/trainer_constants.asm).
|
||||
PHONECONTACT_MOM: 1,
|
||||
PHONECONTACT_ELM: 4,
|
||||
// wTimeOfDay bits (constants/ram_constants.asm).
|
||||
MORN: 1,
|
||||
DAY: 2,
|
||||
NITE: 4,
|
||||
// Money accounts and checkmoney results (constants/script_constants.asm).
|
||||
YOUR_MONEY: 0,
|
||||
MOMS_MONEY: 1,
|
||||
HAVE_MORE: 0,
|
||||
HAVE_AMOUNT: 1,
|
||||
HAVE_LESS: 2,
|
||||
// wMomSavingMoney (constants/ram_constants.asm).
|
||||
MOM_SAVING_SOME_MONEY_F: 0,
|
||||
MOM_SAVING_HALF_MONEY_F: 1,
|
||||
MOM_SAVING_ALL_MONEY_F: 2,
|
||||
MOM_ACTIVE_F: 7,
|
||||
MOM_MONEY: 2300,
|
||||
COLL_WATER: 0x29,
|
||||
// Sprite ranges (constants/sprite_constants.asm).
|
||||
NUM_OVERWORLD_SPRITES: 102,
|
||||
|
||||
@@ -57,6 +57,9 @@ export interface GameMap {
|
||||
tileset: number;
|
||||
environment: number;
|
||||
paletteMode: number;
|
||||
/** The palette byte's high nybble: nonzero where the phone has no
|
||||
* signal (GetMapPhoneService). */
|
||||
phoneService: number;
|
||||
/** The map attributes' ROM offset, which some routines compare. */
|
||||
attributes?: number;
|
||||
blocks: Uint8Array;
|
||||
|
||||
+102
-5
@@ -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,
|
||||
|
||||
+140
-1
@@ -5,6 +5,7 @@ import { bindTabs } from "./ui/tabs";
|
||||
import { NativeRuntime } from "./game/runtime";
|
||||
import { LocalMapView } from "./game/rendering/minimap";
|
||||
import type { Choice, GameWorld } from "./game/world/world";
|
||||
import type { PokegearCard } from "./game/scripts/pokegear";
|
||||
import { drawTile } from "./game/rendering/pixels";
|
||||
import { PokemonData } from "./game/rom/pokemon";
|
||||
import type { Direction, Palette } from "./game/world/types";
|
||||
@@ -47,6 +48,7 @@ function showWorld(world: GameWorld) {
|
||||
get("game-notice").textContent = world.notice;
|
||||
showBattle(world);
|
||||
showStatsScreen(world);
|
||||
showPokegear(world);
|
||||
get("town-map-view").hidden = !world.townMap;
|
||||
if (world.townMap)
|
||||
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
|
||||
@@ -60,6 +62,8 @@ function showWorld(world: GameWorld) {
|
||||
get("native-dialogue").hidden =
|
||||
!dialogue ||
|
||||
pictureWait ||
|
||||
// The Pokégear's map and radio cards have no textbox.
|
||||
(dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) ||
|
||||
Boolean(mart) ||
|
||||
world.townMap ||
|
||||
Boolean(world.statsScreen);
|
||||
@@ -113,7 +117,11 @@ function showChoice(choice: Choice | undefined) {
|
||||
return element;
|
||||
};
|
||||
if (!choice) container.replaceChildren();
|
||||
else if (choice.kind === "yesno")
|
||||
else if (choice.kind === "pokegear") {
|
||||
// The Pokégear reads the controls directly.
|
||||
container.replaceChildren();
|
||||
container.hidden = true;
|
||||
} else if (choice.kind === "yesno")
|
||||
container.replaceChildren(
|
||||
button("YES", choice.selected === 1, () => runtime.choose(1)),
|
||||
button("NO", choice.selected === 0, () => runtime.choose(0)),
|
||||
@@ -156,6 +164,51 @@ function showChoice(choice: Choice | undefined) {
|
||||
);
|
||||
container.firstElementChild?.setAttribute("aria-label", "Earlier");
|
||||
container.children[2]?.setAttribute("aria-label", "Later");
|
||||
} else if (choice.kind === "mombank") {
|
||||
// Mom_ContinueMenuSetup's box: SAVED, HELD and the amount, whose digit
|
||||
// under the cursor blinks. The buttons send the joypad's own presses.
|
||||
const { view } = choice;
|
||||
const box = document.createElement("div");
|
||||
box.className = "mom-bank";
|
||||
const row = (label: string, value: HTMLElement | string) => {
|
||||
const line = document.createElement("p"),
|
||||
name = document.createElement("span"),
|
||||
amount = document.createElement("output");
|
||||
name.textContent = label;
|
||||
amount.append(value);
|
||||
line.append(name, amount);
|
||||
return line;
|
||||
};
|
||||
const digits = document.createElement("span");
|
||||
digits.setAttribute("aria-label", `¥${view.amount}`);
|
||||
digits.append(
|
||||
"¥",
|
||||
...[...String(view.amount).padStart(6, "0")].map((digit, index) => {
|
||||
const cell = document.createElement("span");
|
||||
cell.textContent = digit;
|
||||
if (index === view.cursor) cell.className = "mom-bank-cursor";
|
||||
return cell;
|
||||
}),
|
||||
);
|
||||
box.append(
|
||||
row(view.labels[0], `¥${view.saved}`),
|
||||
row(view.labels[1], `¥${view.held}`),
|
||||
row(view.labels[2], digits),
|
||||
);
|
||||
const press = (label: string, aria: string, name: string) => {
|
||||
const element = button(label, undefined, () => runtime.choose(name));
|
||||
element.setAttribute("aria-label", aria);
|
||||
return element;
|
||||
};
|
||||
container.replaceChildren(
|
||||
box,
|
||||
press("◀", "Previous digit", "left"),
|
||||
press("▼", "Decrease digit", "down"),
|
||||
press("▲", "Increase digit", "up"),
|
||||
press("▶", "Next digit", "right"),
|
||||
press("OK", "OK", "a"),
|
||||
press("CANCEL", "Cancel", "b"),
|
||||
);
|
||||
} else if (choice.kind === "name")
|
||||
container.replaceChildren(
|
||||
...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
|
||||
@@ -807,6 +860,92 @@ function showBattle(world: GameWorld) {
|
||||
);
|
||||
highlightBattleChoice();
|
||||
}
|
||||
let pokegearKey = "";
|
||||
const POKEGEAR_CARDS: Record<PokegearCard, string> = {
|
||||
clock: "CLOCK",
|
||||
map: "MAP",
|
||||
phone: "PHONE",
|
||||
radio: "RADIO",
|
||||
};
|
||||
/** The Pokégear's cards (engine/pokegear/pokegear.asm): the card icons,
|
||||
* then the clock, the Town Map with its cursor's landmark, the phone list
|
||||
* or the radio. Its textbox is the native dialogue. */
|
||||
function showPokegear(world: GameWorld) {
|
||||
const view = world.pokegear;
|
||||
get("pokegear").hidden = !view;
|
||||
if (!view) {
|
||||
pokegearKey = "";
|
||||
return;
|
||||
}
|
||||
const key = JSON.stringify(view);
|
||||
if (key === pokegearKey) return;
|
||||
pokegearKey = key;
|
||||
const element = <K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
text = "",
|
||||
className = "",
|
||||
) => {
|
||||
const node = document.createElement(tag);
|
||||
node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
};
|
||||
get("pokegear-cards").replaceChildren(
|
||||
...view.cards.map((card) => {
|
||||
const icon = element("span", POKEGEAR_CARDS[card]);
|
||||
icon.setAttribute("aria-current", String(card === view.card));
|
||||
return icon;
|
||||
}),
|
||||
);
|
||||
const body = get("pokegear-card");
|
||||
if (view.clock) {
|
||||
const clock = element("div", "", "pokegear-clock");
|
||||
clock.append(element("span", view.clock.day), element("strong", view.clock.time));
|
||||
body.replaceChildren(clock);
|
||||
} else if (view.map) {
|
||||
const map = element("div", "", "pokegear-map"),
|
||||
canvas = element("canvas");
|
||||
canvas.setAttribute("role", "img");
|
||||
drawTownMap(canvas, world, view.map);
|
||||
map.append(element("p", `▶ ${view.map.name}`), canvas);
|
||||
body.replaceChildren(map);
|
||||
} else if (view.phone) {
|
||||
const phone = view.phone,
|
||||
card = element("div", "", "pokegear-phone"),
|
||||
list = element("ol");
|
||||
list.setAttribute("aria-label", "Phone numbers");
|
||||
list.replaceChildren(
|
||||
...phone.rows.map((row, index) => {
|
||||
const item = element("li", row);
|
||||
item.dataset.mark =
|
||||
phone.chosen === index
|
||||
? "▷"
|
||||
: phone.chosen === undefined && phone.cursor === index
|
||||
? "▶"
|
||||
: " ";
|
||||
if (item.dataset.mark !== " ") item.setAttribute("aria-current", "true");
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
card.append(
|
||||
element("span", phone.signal ? "Signal ▮▮▮" : "Out of service", "pokegear-signal"),
|
||||
list,
|
||||
);
|
||||
if (phone.submenu) {
|
||||
const submenu = element("ol", "", "pokegear-submenu");
|
||||
submenu.replaceChildren(
|
||||
...phone.submenu.items.map((item, index) => {
|
||||
const entry = element("li", item);
|
||||
entry.dataset.mark = index === phone.submenu!.selected ? "▶" : " ";
|
||||
return entry;
|
||||
}),
|
||||
);
|
||||
card.append(submenu);
|
||||
}
|
||||
body.replaceChildren(card);
|
||||
} else body.replaceChildren();
|
||||
}
|
||||
|
||||
/** The stats screen's pages (engine/pokemon/stats_screen.asm), with its
|
||||
* strings, type names and stat labels read from the ROM. */
|
||||
function showStatsScreen(world: GameWorld) {
|
||||
|
||||
@@ -32,6 +32,7 @@ export function renderShell(root: HTMLElement) {
|
||||
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
|
||||
</div>
|
||||
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
|
||||
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
|
||||
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
|
||||
<div id="mart" class="mart" hidden aria-label="Poké Mart">
|
||||
<p id="mart-money" class="mart-money"></p>
|
||||
|
||||
+126
-1
@@ -538,7 +538,8 @@ dialog h2 {
|
||||
.intro-screen[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.intro-screen:not([hidden]) ~ .minimap-overlay {
|
||||
.intro-screen:not([hidden]) ~ .minimap-overlay,
|
||||
.pokegear:not([hidden]) ~ .minimap-overlay {
|
||||
display: none;
|
||||
}
|
||||
.pokepic {
|
||||
@@ -667,6 +668,38 @@ dialog h2 {
|
||||
.dialogue-choice button {
|
||||
min-width: 64px;
|
||||
}
|
||||
/* BankOfMom's amount box: one row per amount, the cursor digit blinking
|
||||
16 frames in 32 as hVBlankCounter's bit 4 does. */
|
||||
.mom-bank {
|
||||
flex: 1 0 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.mom-bank p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
max-width: 22ch;
|
||||
margin: 0 0 2px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.dialogue-choice .mom-bank output {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mom-bank-cursor {
|
||||
animation: mom-bank-blink 0.53s steps(1) infinite;
|
||||
}
|
||||
@keyframes mom-bank-blink {
|
||||
50% {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mom-bank-cursor {
|
||||
animation: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
.dialogue-choice button[aria-pressed="true"]::before {
|
||||
content: "▶ ";
|
||||
}
|
||||
@@ -1136,3 +1169,95 @@ dialog dd {
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* The Pokégear: card icons along the top, the card below and the
|
||||
textbox (the native dialogue) over its lower part. */
|
||||
.pokegear {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 8px;
|
||||
padding: 10px 10px 118px;
|
||||
color: var(--ink);
|
||||
background: var(--blue-dark);
|
||||
}
|
||||
.pokegear-cards {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.pokegear-cards span {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--cream);
|
||||
border: 3px double var(--cream);
|
||||
}
|
||||
.pokegear-cards span[aria-current="true"] {
|
||||
color: var(--ink);
|
||||
background: var(--cream);
|
||||
}
|
||||
.pokegear-card {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--cream);
|
||||
border: 5px double var(--ink);
|
||||
}
|
||||
.pokegear-clock {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
justify-items: center;
|
||||
}
|
||||
.pokegear-clock strong {
|
||||
font-size: 2rem;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: pre;
|
||||
}
|
||||
.pokegear-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pokegear-map canvas {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
}
|
||||
.pokegear-phone {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
.pokegear-phone ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.pokegear-phone li {
|
||||
min-height: 2.9em;
|
||||
white-space: pre;
|
||||
}
|
||||
.pokegear-phone li::before {
|
||||
content: attr(data-mark) " ";
|
||||
}
|
||||
.pokegear-signal {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: end;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.pokegear-submenu {
|
||||
padding: 4px 8px;
|
||||
border: 3px double var(--ink);
|
||||
}
|
||||
Reference in new issue
Block a user