Port the PC and boxes
Checks / web (push) Canceled after 0s

Read TileCollisionStdScripts from the ROM so the Pokémon Center PC runs
PokemonCenterPC: Bill's PC (deposit, withdraw, release, change and name
boxes), the player's item storage (withdraw, deposit, toss, mail box) and
Oak's Pokédex rating. The house PC now opens the full player's PC menu.

A catch with a full party goes to the current box through SendMonIntoBox;
a full box keeps the ball, and filling it queues Bill's call as a memory
script after the battle. Saves gain boxes, box names and PC items,
migrated by absence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 17:12:26 +02:00
1 parent 451464f7c6
commit 3bdca0099e
21 files changed
+1999 -131

No files matched your search

+84 -25
View File
@@ -2,10 +2,18 @@ import { RomReader } from "../rom/reader";
import { BATTLE, RAM, SYMBOLS } from "../rom/profile";
import type { PokemonData } from "../rom/pokemon";
import type { Bag } from "../party/bag";
import {
MONS_PER_BOX,
currentBox,
restorePP,
type BoxMon,
type PokemonStorage,
} from "../party/storage";
import {
NICKNAME_PATTERN,
PARTY_LENGTH,
calcStats,
levelForExp,
type PartyMon,
} from "../party/pokemon";
import { readString, readTextEnd, type TextContext } from "../scripts/text";
@@ -140,6 +148,8 @@ export interface BattleOptions {
text: () => TextContext;
/** The player's pack, needed to throw Poké Balls. */
bag?: Bag;
/** Bill's PC: a catch with a full party goes to the current box. */
storage?: PokemonStorage;
/** ENGINE_POKEDEX: a first catch shows the new-data message. */
hasPokedex?: boolean;
/** SetCaughtData's word for a Pokémon caught at this level. */
@@ -148,6 +158,8 @@ export interface BattleOptions {
tutorial?: boolean;
}
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
const BATTLERESULT_BOX_FULL = 0x80;
const SUB1 = 1,
SUB2 = 2,
SUB3 = 3,
@@ -844,12 +856,22 @@ export class Battle {
this.tossBall(item, slot);
return true;
}
// With a full party the Pokémon would go to the current box; if that is
// full too, the ball is not used (Ball_BoxIsFullMessage).
const storage = this.options.storage;
if (
this.options.party.length >= PARTY_LENGTH &&
(!storage || currentBox(storage).length >= MONS_PER_BOX)
) {
// The Dude's tutorial reaches this check too, and the original then
// repeats the unusable ball forever (docs/bugs_and_glitches.md).
if (tutorial) throw new UnsupportedBattle("The Dude's Poké Ball cannot be used with a full party and box.");
if (!storage) throw new UnsupportedBattle("Missing PC boxes.");
yield* this.say(BATTLE.BallBoxFullText);
return false;
}
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL].includes(item as never))
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
// A full party sends the Pokémon into the current box (SendMonIntoBox).
// The tutorial never keeps its Pokémon, so it does not reach the box.
if (!tutorial && this.options.party.length >= PARTY_LENGTH)
throw new UnsupportedBattle("PC boxes are not ported yet.");
this.wildMon = 0;
yield* this.say(BATTLE.ItemUsedText);
@@ -920,10 +942,15 @@ export class Battle {
// NewPokedexEntry's Pokédex page is not presented yet.
yield* this.say(BATTLE.NewDexDataText);
}
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
// the wild Pokémon, then SetCaughtData.
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
const name = data.speciesName(species);
if (this.options.party.length >= PARTY_LENGTH) {
yield* this.sendToPC(species, level, name);
this.tossBall(item, slot);
return true;
}
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
// the wild Pokémon, then SetCaughtData.
const mon: PartyMon = {
species,
item: enemy.item,
@@ -944,24 +971,59 @@ export class Battle {
stats: calcStats(data, species, enemy.dvs, level),
};
this.options.party.push(mon);
this.strings[RAM.stringBuffer1] = name;
yield* this.say(BATTLE.AskGiveNicknameText);
if (yield { kind: "yesno", text: "" }) {
// NamingScreen (NAME_MON): "<SPECIES>'S" / "NICKNAME?".
let second = SYMBOLS.nicknameStrings;
while (this.reader.byte(second++) !== 0x50);
const context = this.context(),
suffix = readString(this.reader, SYMBOLS.nicknameStrings, context),
title = readString(this.reader, second, context);
const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 };
if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer)))
throw new UnsupportedBattle("Invalid nickname.");
// InitName: a name of zero or more spaces takes the species name.
if (!/^ *$/.test(answer)) mon.nickname = answer;
}
mon.nickname = yield* this.askNickname(name);
this.tossBall(item, slot);
return true;
}
/** AskGiveNicknameText, then NamingScreen (NAME_MON) if accepted: the
* chosen name, or the species name for a declined or blank one. */
private *askNickname(name: string): Gen<string> {
this.strings[RAM.stringBuffer1] = name;
yield* this.say(BATTLE.AskGiveNicknameText);
if (!(yield { kind: "yesno", text: "" })) return name;
// "<SPECIES>'S" / "NICKNAME?".
let second = SYMBOLS.nicknameStrings;
while (this.reader.byte(second++) !== 0x50);
const context = this.context(),
suffix = readString(this.reader, SYMBOLS.nicknameStrings, context),
title = readString(this.reader, second, context);
const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 };
if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer)))
throw new UnsupportedBattle("Invalid nickname.");
// InitName: a name of zero or more spaces takes the species name.
return /^ *$/.test(answer) ? name : answer;
}
/** PokeBallEffect's .SendToPC: SendMonIntoBox puts the caught Pokémon
* first in the current box (from wEnemyMon, with restored PP), then
* SetBoxMonCaughtData. A box filled by it sets BATTLERESULT_BOX_FULL for
* Bill's call; the nickname and BallSentToPCText follow. */
private *sendToPC(species: number, level: number, name: string): Gen {
const data = this.options.data,
storage = this.options.storage!,
box = currentBox(storage);
const mon: BoxMon = {
species,
item: this.enemy.mon.item,
moves: [...this.wildMoves] as BoxMon["moves"],
otId: this.options.playerId,
ot: this.options.playerName,
nickname: name,
exp: data.expAtLevel(data.baseData(species).growthRate, level),
statExp: [0, 0, 0, 0, 0],
dvs: this.enemy.mon.dvs,
pp: [...this.wildPP] as BoxMon["pp"],
happiness: C.BASE_HAPPINESS,
pokerus: 0,
caughtData: this.options.caughtData!(level),
level,
};
restorePP(data, mon);
box.unshift(mon);
if (box.length === MONS_PER_BOX) this.battleResult |= BATTLERESULT_BOX_FULL;
mon.nickname = yield* this.askNickname(name);
this.strings[RAM.monOrItemNameBuffer] = mon.nickname;
yield* this.say(BATTLE.BallSentToPCText);
}
/** TossItem after a throw: RemoveItemFromPocket takes the ball from the
* selected slot if it matches, otherwise from the first matching slot. */
private tossBall(item: number, slot: number) {
@@ -2798,10 +2860,7 @@ export class Battle {
}
/** CalcLevel: the highest level whose exp does not exceed the total. */
private levelForExp(species: number, exp: number) {
const growth = this.options.data.baseData(species).growthRate;
let level = 1;
while (level < C.MAX_LEVEL && this.options.data.expAtLevel(growth, level + 1) <= exp) level++;
return level;
return levelForExp(this.options.data, species, exp);
}
/** LearnLevelMoves: moves the species learns at exactly this level. */
private *learnLevelMoves(mon: PartyMon, index: number, level: number): Gen {
+50 -11
View File
@@ -1,9 +1,13 @@
import type { PokemonData, Pocket } from "../rom/pokemon";
export interface ItemSlot {
item: number;
quantity: number;
}
/** The player's pack, with the original pocket capacities. */
export interface Bag {
items: { item: number; quantity: number }[];
balls: { item: number; quantity: number }[];
items: ItemSlot[];
balls: ItemSlot[];
keyItems: number[];
}
export const emptyBag = (): Bag => ({ items: [], balls: [], keyItems: [] });
@@ -20,8 +24,8 @@ const MAX_ITEMS = 20,
/** PutItemInPocket: room exists if matching stacks can absorb the quantity
* or a new slot is free; stacks then fill to 99 before a new one starts. */
function putInPocket(
pocket: { item: number; quantity: number }[],
export function putInPocket(
pocket: ItemSlot[],
capacity: number,
item: number,
quantity: number,
@@ -80,9 +84,15 @@ export function hasItem(bag: Bag, item: number): boolean {
);
}
export function validBag(data: PokemonData, value: unknown): value is Bag {
const bag = value as Bag;
const slots = (list: unknown, capacity: number, pocket: Pocket) =>
/** A list of item stacks. Without `pocket`, any item with a pack pocket
* (the PC's list holds items from every pocket). */
export function validItemSlots(
data: PokemonData,
list: unknown,
capacity: number,
pocket?: Pocket,
): list is ItemSlot[] {
return (
Array.isArray(list) &&
list.length <= capacity &&
list.every(
@@ -92,13 +102,22 @@ export function validBag(data: PokemonData, value: unknown): value is Bag {
Number.isInteger(slot.quantity) &&
slot.quantity >= 1 &&
slot.quantity <= MAX_ITEM_STACK &&
itemIn(data, slot.item, pocket),
);
(pocket
? itemIn(data, slot.item, pocket)
: ["item", "ball", "key"].some((each) =>
itemIn(data, slot.item, each as Pocket),
)),
)
);
}
export function validBag(data: PokemonData, value: unknown): value is Bag {
const bag = value as Bag;
return (
Boolean(bag) &&
typeof bag === "object" &&
slots(bag.items, MAX_ITEMS, "item") &&
slots(bag.balls, MAX_BALLS, "ball") &&
validItemSlots(data, bag.items, MAX_ITEMS, "item") &&
validItemSlots(data, bag.balls, MAX_BALLS, "ball") &&
Array.isArray(bag.keyItems) &&
bag.keyItems.length <= MAX_KEY_ITEMS &&
bag.keyItems.every((item) => itemIn(data, item, "key"))
@@ -131,3 +150,23 @@ export function tossItem(bag: Bag, item: number, quantity: number): boolean {
}
return false;
}
/** RemoveItemFromPocket: take from the stack at `index` (wCurItemQuantity)
* if it holds `item`, otherwise from the first stack that does. Fails if
* that stack holds fewer. */
export function removeFromSlot(
list: ItemSlot[],
index: number,
item: number,
quantity: number,
): boolean {
const at =
list[index]?.item === item
? index
: list.findIndex((slot) => slot.item === item);
const slot = list[at];
if (!slot || quantity > slot.quantity) return false;
slot.quantity -= quantity;
if (!slot.quantity) list.splice(at, 1);
return true;
}
+64 -23
View File
@@ -30,9 +30,12 @@ export const PARTY_LENGTH = 6;
/** Characters a nickname can hold: the naming screen's letters and
* symbols, plus the digits and apostrophes of default species names. */
export const NICKNAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,10}$/u;
/** The same characters, for box names (up to eight). */
export const BOX_NAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,8}$/u;
/** BASE_HAPPINESS */
const BASE_HAPPINESS = 70;
const MAX_STAT_VALUE = 999;
const MAX_LEVEL = 100;
/** GetSquareRoot: the first of 1..255 whose square is not below `value`. */
function squareRoot(value: number) {
@@ -159,11 +162,30 @@ export function generatePartyMon(
};
}
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
export function validPartyMon(
/** CalcLevel: the highest level whose experience the total reaches. */
export function levelForExp(
data: PokemonData,
value: unknown,
): value is PartyMon {
species: number,
exp: number,
): number {
const growth = data.baseData(species).growthRate;
let level = 1;
while (level < MAX_LEVEL && data.expAtLevel(growth, level + 1) <= exp)
level++;
return level;
}
/** ComputeMaxPP for a move with `ups` PP Ups: each adds a fifth of the base
* PP, clamped to 7, so a 40 PP move maxes at 61 instead of overflowing the
* packed byte at 64. The PP Ups stay in the top two bits. */
export function maxPP(data: PokemonData, move: number, ups: number): number {
const base = data.move(move).pp;
return (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups);
}
/** The box_struct fields of a stored Pokémon: everything but status, HP
* and stats. */
export function validBoxFields(value: unknown): boolean {
const mon = value as PartyMon;
const integer = (value: unknown, min: number, max: number): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
@@ -171,28 +193,47 @@ export function validPartyMon(
Array.isArray(value) &&
value.length === length &&
value.every((item) => integer(item, 0, max));
try {
return (
Boolean(mon) &&
typeof mon === "object" &&
integer(mon.species, 1, 251) &&
integer(mon.item, 0, 255) &&
tuple(mon.moves, 4, 251) &&
integer(mon.otId, 0, 0xffff) &&
typeof mon.ot === "string" &&
/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) &&
typeof mon.nickname === "string" &&
NICKNAME_PATTERN.test(mon.nickname) &&
integer(mon.exp, 0, 0xffffff) &&
tuple(mon.statExp, 5, 0xffff) &&
integer(mon.dvs, 0, 0xffff) &&
tuple(mon.pp, 4, 255) &&
integer(mon.happiness, 0, 255) &&
integer(mon.pokerus, 0, 255) &&
integer(mon.caughtData, 0, 0xffff) &&
integer(mon.level, 1, 100)
);
} catch {
return false;
}
}
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
export function validPartyMon(
data: PokemonData,
value: unknown,
): value is PartyMon {
const mon = value as PartyMon;
const integer = (value: unknown, min: number, max: number): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
try {
if (
!mon ||
typeof mon !== "object" ||
!integer(mon.species, 1, 251) ||
!integer(mon.item, 0, 255) ||
!tuple(mon.moves, 4, 251) ||
!integer(mon.otId, 0, 0xffff) ||
typeof mon.ot !== "string" ||
!/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) ||
typeof mon.nickname !== "string" ||
!NICKNAME_PATTERN.test(mon.nickname) ||
!integer(mon.exp, 0, 0xffffff) ||
!tuple(mon.statExp, 5, 0xffff) ||
!integer(mon.dvs, 0, 0xffff) ||
!tuple(mon.pp, 4, 255) ||
!integer(mon.happiness, 0, 255) ||
!integer(mon.pokerus, 0, 255) ||
!integer(mon.caughtData, 0, 0xffff) ||
!integer(mon.level, 1, 100) ||
!validBoxFields(mon) ||
!integer(mon.status, 0, 255) ||
!tuple(mon.stats, 6, MAX_STAT_VALUE) ||
!Array.isArray(mon.stats) ||
mon.stats.length !== 6 ||
!mon.stats.every((stat) => integer(stat, 0, MAX_STAT_VALUE)) ||
!integer(mon.hp, 0, mon.stats[0])
)
return false;
+133
View File
@@ -0,0 +1,133 @@
import type { PokemonData } from "../rom/pokemon";
import {
BOX_NAME_PATTERN,
calcStats,
levelForExp,
maxPP,
validBoxFields,
type PartyMon,
} from "./pokemon";
import { putInPocket, validItemSlots, type ItemSlot } from "./bag";
/** NUM_BOXES and MONS_PER_BOX (constants/pokemon_data_constants.asm). */
export const NUM_BOXES = 14;
export const MONS_PER_BOX = 20;
/** BOX_NAME_LENGTH minus the terminator. */
export const BOX_NAME_MAX = 8;
/** MAX_PC_ITEMS (constants/item_data_constants.asm). */
export const MAX_PC_ITEMS = 50;
/** The fields a stored BoxMon has, so no party fields slip into a box. */
const BOX_MON_FIELDS = 14;
/** box_struct: a party_struct without status, HP and stats. */
export type BoxMon = Omit<PartyMon, "status" | "hp" | "stats">;
/** Bill's PC: every box, wCurBox and wBoxNames. A null name is the one
* SetDefaultBoxNames writes ("BOX" and the box number, from the ROM). */
export interface PokemonStorage {
current: number;
names: (string | null)[];
boxes: BoxMon[][];
}
export const emptyStorage = (): PokemonStorage => ({
current: 0,
names: Array.from({ length: NUM_BOXES }, () => null),
boxes: Array.from({ length: NUM_BOXES }, () => []),
});
export const copyStorage = (storage: PokemonStorage): PokemonStorage => ({
current: storage.current,
names: [...storage.names],
boxes: structuredClone(storage.boxes),
});
export const currentBox = (storage: PokemonStorage) =>
storage.boxes[storage.current];
/** The first BOXMON_STRUCT_LENGTH bytes of a party struct. */
export function toBoxMon(mon: PartyMon): BoxMon {
const {
status: _status,
hp: _hp,
stats: _stats,
...box
} = structuredClone(mon);
return box;
}
/** RestorePPOfDepositedPokemon: each move's PP becomes its maximum,
* keeping the PP Ups (GetMaxPPOfMove through ComputeMaxPP). */
export function restorePP(data: PokemonData, mon: BoxMon) {
mon.pp = mon.moves.map((move, slot) =>
move ? maxPP(data, move, mon.pp[slot] >> 6) : mon.pp[slot],
) as BoxMon["pp"];
}
/** SendGetMonIntoFromBox with PC_DEPOSIT: the Pokémon joins the end of
* the current box with its PP restored. Returns false if the box is full. */
export function depositMon(
data: PokemonData,
storage: PokemonStorage,
mon: PartyMon,
): boolean {
const box = currentBox(storage);
if (box.length >= MONS_PER_BOX) return false;
const stored = toBoxMon(mon);
restorePP(data, stored);
box.push(stored);
return true;
}
/** SendGetMonIntoFromBox with PC_WITHDRAW: CalcLevel from the experience,
* then CalcMonStats with stat experience, no status and full HP. */
export function withdrawnMon(data: PokemonData, box: BoxMon): PartyMon {
const mon = structuredClone(box);
const level = levelForExp(data, mon.species, mon.exp);
const stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp);
return { ...mon, level, status: 0, hp: stats[0], stats };
}
/** ReceiveItem into wNumPCItems. */
export function receivePCItem(
items: ItemSlot[],
item: number,
quantity: number,
): boolean {
return putInPocket(items, MAX_PC_ITEMS, item, quantity);
}
export function validStorage(value: unknown): value is PokemonStorage {
const storage = value as PokemonStorage;
return (
Boolean(storage) &&
typeof storage === "object" &&
Number.isInteger(storage.current) &&
storage.current >= 0 &&
storage.current < NUM_BOXES &&
Array.isArray(storage.names) &&
storage.names.length === NUM_BOXES &&
storage.names.every(
(name) =>
name === null ||
(typeof name === "string" &&
BOX_NAME_PATTERN.test(name) &&
!/^ *$/.test(name)),
) &&
Array.isArray(storage.boxes) &&
storage.boxes.length === NUM_BOXES &&
storage.boxes.every(
(box) =>
Array.isArray(box) &&
box.length <= MONS_PER_BOX &&
box.every(
(mon) =>
validBoxFields(mon) &&
Object.keys(mon as object).length === BOX_MON_FIELDS,
),
)
);
}
export function validPCItems(
data: PokemonData,
value: unknown,
): value is ItemSlot[] {
return validItemSlots(data, value, MAX_PC_ITEMS);
}
+69
View File
@@ -281,6 +281,73 @@ export const OVERWORLD = {
MansionPalette2: 0x496fe,
} as const;
/** Pokémon Center and house PCs, Bill's PC and Oak's PC: routines, menu
* data, strings and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
export const PC = {
TileCollisionStdScripts: 0x13681,
PokemonCenterPC: 0x1559a,
"PokemonCenterPC.WhichPC": 0x1562c,
"PokemonCenterPC.Jumptable": 0x155e6,
"PC_CheckPartyForPokemon.PokecenterPCCantUseText": 0x15663,
PokecenterPCTurnOnText: 0x15a27,
PokecenterPCWhoseText: 0x15a2c,
PokecenterBillsPCText: 0x15a31,
PokecenterPlayersPCText: 0x15a36,
PokecenterOaksPCText: 0x15a3b,
PokecenterPCOaksClosedText: 0x15a40,
PlayersPCTurnOnText: 0x156ff,
PlayersPCAskWhatDoText: 0x157cc,
"PlayersPCMenuData.WhichPC": 0x157ac,
"PlayersPCMenuData.PlayersPCMenuPointers": 0x15746,
"PlayerWithdrawItemMenu.PlayersPCHowManyWithdrawText": 0x15850,
"PlayerWithdrawItemMenu.PlayersPCWithdrewItemsText": 0x15855,
"PlayerWithdrawItemMenu.PlayersPCNoRoomWithdrawText": 0x1585a,
"PlayerDepositItemMenu.PlayersPCNoItemsText": 0x158c7,
"PlayerDepositItemMenu.PlayersPCHowManyDepositText": 0x1596e,
"PlayerDepositItemMenu.PlayersPCDepositItemsText": 0x15973,
"PlayerDepositItemMenu.PlayersPCNoRoomDepositText": 0x15978,
"TossItemFromPC.ItemsTossOutHowManyText": 0x12a45,
"TossItemFromPC.ItemsThrowAwayText": 0x12a4a,
"TossItemFromPC.ItemsDiscardedText": 0x12a4f,
"TossItemFromPC.ItemsTooImportantText": 0x12a5b,
"_PlayerMailBoxMenu.EmptyMailboxText": 0x447b4,
"_BillsPC.PCGottaHavePokemonText": 0xe417,
"_BillsPC.PCWhatText": 0xe43a,
"_BillsPC.strings": 0xe47f,
"BillsPCDepositMenuHeader.MenuData": 0xe2545,
"BillsPC_Withdraw.MenuData": 0xe2739,
"BillsPC_ChangeBoxSubmenu.MenuData": 0xe3783,
PCString_ChooseaPKMN: 0xe34dd,
PCString_WhatsUp: 0xe34ea,
PCString_ReleasePKMN: 0xe34f4,
PCString_ItsYourLastPKMN: 0xe350f,
PCString_NoMoreUsablePKMN: 0xe3531,
PCString_ReleasedPKMN: 0xe3551,
PCString_Bye: 0xe355e,
PCString_Stored: 0xe3563,
PCString_Got: 0xe356b,
PCString_BoxFull: 0xe3575,
PCString_PartyFull: 0xe3586,
PCString_NoReleasingEGGS: 0xe3597,
"BillsPC_PlaceChooseABoxString.ChooseABox": 0xe37a1,
"BillsPC_PlaceEmptyBoxString_SFX.NoMonString": 0xe37d3,
"BillsPC_PrintBoxName.Current": 0xe36f1,
"BillsPC_PrintBoxCountAndCapacity.OutOf20": 0xe3668,
"BillsPC_RefreshTextboxes.CancelString": 0xe2c67,
"BillsPC_BoxName.PartyPKMN": 0xe2abd,
"NamingScreenJumptable.BoxNameString": 0x11822,
"SetDefaultBoxNames.Box": 0x5ccf,
ChangeBoxSaveText: 0x152a1,
OakPCText1: 0x266de,
OakPCText2: 0x266e3,
OakPCText3: 0x266e8,
OakPCText4: 0x2674c,
OakRatings: 0x2667f,
PhoneContacts: 0x9045f,
Script_ReceivePhoneCall: 0x90241,
} as const;
/** Battle engine tables and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
export const BATTLE = {
@@ -294,6 +361,8 @@ export const BATTLE = {
NewDexDataText: 0xedf0,
AskGiveNicknameText: 0xedf5,
BallBlockedText: 0xf824,
BallBoxFullText: 0xf838,
BallSentToPCText: 0xedeb,
BallDontBeAThiefText: 0xf829,
WobbleProbabilities: 0xf9ba,
"CatchTutorial.Dude": 0x4e5da,
+79 -20
View File
@@ -11,6 +11,7 @@ import {
NUM_FRUIT_TREES,
EVENTS,
OVERWORLD,
PC,
RAM,
SPECIAL_ROUTINES,
SYMBOLS,
@@ -24,18 +25,28 @@ import {
receiveItem,
tossItem,
type Bag,
type ItemSlot,
} from "../party/bag";
import {
MONS_PER_BOX,
copyStorage,
currentBox,
emptyStorage,
type PokemonStorage,
} from "../party/storage";
import {
NICKNAME_PATTERN,
PARTY_LENGTH,
caughtData,
generatePartyMon,
maxPP,
type PartyMon,
} from "../party/pokemon";
import { TrainerData, type TrainerHeader } from "../rom/trainers";
import { DIRECTION_ORDER, type Direction } from "../world/types";
import { OW } from "../world/constants";
import { readMovement, type MovementStep } from "./movement";
import { PcSession } from "./pc";
import {
defaultTextContext,
readString,
@@ -59,6 +70,10 @@ export interface ScriptState {
clock: ClockSettings;
party: PartyMon[];
bag: Bag;
/** Bill's PC: the boxes, their names and wCurBox. */
storage: PokemonStorage;
/** wPCItems: the Player's PC item storage. */
pcItems: ItemSlot[];
pokedex: { seen: Set<number>; caught: Set<number> };
/** wMoney: a 24-bit amount. */
money: number;
@@ -94,6 +109,8 @@ export const emptyScriptState = (): ScriptState => ({
clock: { weekdayOffset: 0, dst: false },
party: [],
bag: emptyBag(),
storage: emptyStorage(),
pcItems: [],
pokedex: { seen: new Set(), caught: new Set() },
money: C.START_MONEY,
fruitTrees: new Set(),
@@ -116,6 +133,8 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
clock: { ...state.clock },
party: structuredClone(state.party),
bag: copyBag(state.bag),
storage: copyStorage(state.storage),
pcItems: state.pcItems.map((slot) => ({ ...slot })),
pokedex: {
seen: new Set(state.pokedex.seen),
caught: new Set(state.pokedex.caught),
@@ -138,9 +157,10 @@ export const PLAYER = 0;
const LAST_TALKED = 0xfe;
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
const CONTACT_LIST_SIZE = 10;
const MONS_PER_BOX = 20;
/** One row of a mart's buy or sell list. `owned` is set only when selling. */
/** The mart's lists and quantity box, which the PC's item storage reuses. */
export type MartMode = "buy" | "sell" | "withdraw" | "deposit" | "toss";
export interface MartEntry {
item: number;
name: string;
@@ -174,7 +194,7 @@ export type ScriptEffect =
* answered with the chosen entry's index, or a negative number to cancel. */
| {
kind: "martlist";
mode: "buy" | "sell";
mode: MartMode;
entries: MartEntry[];
selected: number;
}
@@ -182,7 +202,7 @@ export type ScriptEffect =
* number below 1 to cancel back to the item list. */
| {
kind: "martquantity";
mode: "buy" | "sell";
mode: MartMode;
item: number;
name: string;
price: number;
@@ -209,9 +229,22 @@ export type ScriptEffect =
| { kind: "warp"; map: string; x: number; y: number; facing?: Direction }
/** A vertical menu (the elevator's floors); answered with the chosen
* item's index, or -1 when cancelled with B. */
| { kind: "menu"; pages: string[]; items: string[]; selected: number }
| {
kind: "menu";
pages: string[];
items: string[];
/** Secondary text per item, such as a level or a box's count. */
details?: string[];
selected: number;
}
/** A native message for presentation the port does not have yet; the
* script continues. */
| { kind: "notice"; text: string }
/** warpcheck: the player's tile events run again after the script. */
| { kind: "warpcheck" }
/** LoadMemScript: a script (with a phone caller's script) that runs at
* the next PlayerEvents, unless one is already queued. */
| { kind: "memscript"; script: number; callerScript?: number }
/** changeblock: the block covering map tile (x, y) becomes `block`. */
| { kind: "changeblock"; x: number; y: number; block: number }
/** changemapblocks: replace the map's block data from the ROM. */
@@ -265,6 +298,10 @@ type Input = number | string | undefined;
const ITEM_FROM_MEM = 0xff;
/** wCallerContact's script-2 pointer (bank, address) within the contact. */
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;
const BATTLERESULT_BOX_FULL = 0x80;
/** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm):
* a one-tick sleep, then normal steps to the player's tile, dropping the
@@ -871,6 +908,20 @@ export class ScriptRun {
case 0x60:
// Script_reloadmapafterbattle: a loss continues with the whiteout.
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
// 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)
yield synchronous({
kind: "memscript",
script: PC.Script_ReceivePhoneCall,
callerScript: r.far(
PC.PhoneContacts +
PHONE_BILL * PHONE_CONTACT_SIZE +
PHONE_CONTACT_SCRIPT2_BANK,
),
});
canLose = false;
break;
case 0x7b:
@@ -1597,6 +1648,19 @@ export class ScriptRun {
}
}
/** The PCs, sharing this script's state and text buffers. */
private pc() {
return new PcSession({
state: this.state,
reader: this.reader,
data: this.host.data,
text: () => this.text(),
strings: this.strings,
numbers: this.numbers,
decorationMenu: () => this.decorationMenu(),
});
}
/** _PlayerDecorationMenu: owned categories, replacement and ornament sides. */
private *decorationMenu(): Generator<
ScriptEffect,
@@ -1782,10 +1846,8 @@ export class ScriptRun {
case 0x0d:
return number;
case 0x10:
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount. Nothing can be
// deposited yet (catching with a full party is refused and the PC
// is not ported), so the current box is empty.
return MONS_PER_BOX;
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount.
return MONS_PER_BOX - currentBox(this.state.storage).length;
case 0x12:
return this.host.position().x;
case 0x13:
@@ -1862,16 +1924,9 @@ export class ScriptRun {
for (const mon of this.state.party) {
mon.status = 0;
mon.hp = mon.stats[0];
mon.pp = mon.moves.map((move, slot) => {
if (!move) return 0;
const ups = mon.pp[slot] >> 6,
base = this.host.data.move(move).pp;
// ComputeMaxPP clamps each PP Up's bonus to 7, so a 40 PP move
// maxes at 61 instead of overflowing the packed byte at 64.
return (
(ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups)
);
}) as PartyMon["pp"];
mon.pp = mon.moves.map((move, slot) =>
move ? maxPP(this.host.data, move, mon.pp[slot] >> 6) : 0,
) as PartyMon["pp"];
}
return value;
case SPECIAL_ROUTINES.checkPokerus:
@@ -1937,9 +1992,13 @@ export class ScriptRun {
case OVERWORLD.OverworldTownMap:
yield { kind: "townmap" };
return value;
case PC.PokemonCenterPC:
if (pages.length) yield { kind: "text", pages: take() };
yield* this.pc().pokemonCenterPC();
return value;
case OVERWORLD.PlayersHousePC: {
take();
const changed = yield* this.decorationMenu();
if (pages.length) yield { kind: "text", pages: take() };
const changed = yield* this.pc().playersHousePC();
if (changed)
yield {
kind: "warp",
+647
View File
@@ -0,0 +1,647 @@
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, PC, RAM } from "../rom/profile";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import { BOX_NAME_PATTERN, PARTY_LENGTH } from "../party/pokemon";
import {
NUM_BOXES,
currentBox,
depositMon,
receivePCItem,
withdrawnMon,
} from "../party/storage";
import { readString, readText, type TextContext } from "./text";
import type { MartEntry, ScriptEffect, ScriptState } from "./interpreter";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** What the PC routines need from the running script. */
export interface PcHost {
state: ScriptState;
reader: RomReader;
data: PokemonData;
text(): TextContext;
/** text_ram and text_decimal values, keyed by RAM address. */
strings: Record<number, string>;
numbers: Record<number, number>;
/** _PlayerDecorationMenu: true if a decoration changed. */
decorationMenu(): Gen<boolean>;
}
/** PokemonCenterPC's menu items (PCPCITEM_*). */
const PCPCITEM_PLAYERS_PC = 0,
PCPCITEM_BILLS_PC = 1,
PCPCITEM_OAKS_PC = 2,
PCPCITEM_TURN_OFF = 4;
/** _PlayersPC's menu items (PLAYERSPCITEM_*). */
const PLAYERSPCITEM_WITHDRAW_ITEM = 0,
PLAYERSPCITEM_DEPOSIT_ITEM = 1,
PLAYERSPCITEM_TOSS_ITEM = 2,
PLAYERSPCITEM_MAIL_BOX = 3,
PLAYERSPCITEM_DECORATION = 4;
/** _BillsPC's menu items. */
const BILLSPC_WITHDRAW = 0,
BILLSPC_DEPOSIT = 1,
BILLSPC_CHANGE_BOX = 2,
BILLSPC_MOVE = 3;
/** The box submenus: DEPOSIT or WITHDRAW, STATS, RELEASE, CANCEL; and
* SWITCH, NAME, PRINT, QUIT for a box. */
const SUBMENU_TAKE = 0,
SUBMENU_STATS = 1,
SUBMENU_RELEASE = 2;
const BOX_SWITCH = 0,
BOX_NAME = 1,
BOX_PRINT = 2;
/** DelayFrames after a Bill's PC message, in overworld ticks (two frames). */
const MESSAGE_TICKS = 25,
RELEASED_TICKS = 40;
const EGG = 0xfd;
/** A pack entry and where it came from, for TossItem's wCurItemQuantity. */
interface PackSlot extends ItemSlot {
pocket: "items" | "balls" | "keyItems";
index: number;
}
/** The Pokémon Center PC (engine/events/pokecenter_pc.asm), Bill's PC
* (engine/pokemon/bills_pc_top.asm and bills_pc.asm) and Oak's PC
* (engine/events/prof_oaks_pc.asm). The original draws its own screens;
* here menus, lists and messages are presented natively. */
export class PcSession {
constructor(private host: PcHost) {}
private get state() {
return this.host.state;
}
private string(address: number) {
return readString(this.host.reader, address, this.host.text());
}
private pages(address: number) {
return readText(this.host.reader, address, this.host.text()).split("\n\n");
}
private *print(address: number): Gen {
yield { kind: "text", pages: this.pages(address) };
}
/** BillsPC_PlaceString followed by DelayFrames: a message that stays
* for a fixed time without waiting for a button. */
private *placeString(text: string, ticks = MESSAGE_TICKS): Gen {
yield { kind: "text", pages: [text], holdTicks: ticks };
}
/** A menu of `items`; the chosen index, or -1 for B. */
private *menu(
pages: string[],
items: string[],
details?: string[],
selected = 0,
): Gen<number> {
const choice = yield { kind: "menu", pages, items, details, selected };
return typeof choice === "number" && choice >= 0 && choice < items.length
? choice
: -1;
}
/** Consecutive "@"-terminated strings, as static menus store them. */
private strings(address: number, count: number) {
const r = this.host.reader,
result: string[] = [];
let p = address;
for (let i = 0; i < count; i++) {
result.push(this.string(p));
while (r.byte(p++) !== 0x50);
}
return result;
}
/** A WhichPC list: a count, the item ids, then -1. */
private indexSet(address: number, set: number) {
const r = this.host.reader;
let p = address;
for (let i = 0; i < set; i++) p += r.byte(p) + 2;
const count = r.byte(p);
return Array.from({ length: count }, (_, i) => r.byte(p + 1 + i));
}
/** A jumptable of (routine, string) pointers: the item's string. */
private itemString(table: number, item: number) {
const r = this.host.reader,
entry = table + item * 4 + 2;
return this.string(r.near(entry, r.bank(entry)));
}
// ------------------------------------------------------------ PC menus
/** PokemonCenterPC: a Pokémon is needed, then the PCs listed for the
* player's progress until TURN OFF or B. */
*pokemonCenterPC(): Gen {
if (!this.state.party.length) {
yield* this.print(PC["PC_CheckPartyForPokemon.PokecenterPCCantUseText"]);
return;
}
yield* this.print(PC.PokecenterPCTurnOnText);
const whose = this.pages(PC.PokecenterPCWhoseText);
let cursor = 0;
for (;;) {
// .ChooseWhichPCListToUse: PCPC_BEFORE_POKEDEX or PCPC_BEFORE_HOF.
// No Hall of Fame entry exists before the League is ported.
const set = this.state.flags.has(ENGINE_FLAGS.pokedex) ? 1 : 0;
const items = this.indexSet(PC["PokemonCenterPC.WhichPC"], set);
const choice = yield* this.menu(
whose,
items.map((item) =>
this.itemString(PC["PokemonCenterPC.Jumptable"], item),
),
undefined,
cursor,
);
if (choice < 0) return;
cursor = choice;
switch (items[choice]) {
case PCPCITEM_BILLS_PC:
yield* this.print(PC.PokecenterBillsPCText);
yield* this.billsPC();
break;
case PCPCITEM_PLAYERS_PC:
yield* this.print(PC.PokecenterPlayersPCText);
yield* this.playersPC(false);
break;
case PCPCITEM_OAKS_PC:
yield* this.print(PC.PokecenterOaksPCText);
yield* this.oaksPC();
break;
case PCPCITEM_TURN_OFF:
yield* this.print(PC.PokecenterPCOaksClosedText);
return;
default:
throw new Error("The Hall of Fame PC is not ported.");
}
}
}
/** _PlayersHousePC: returns true when a decoration changed, which makes
* the script reload the room. */
*playersHousePC(): Gen<boolean> {
yield* this.print(PC.PlayersPCTurnOnText);
return yield* this.playersPC(true);
}
/** _PlayersPC: PLAYERSPC_NORMAL or PLAYERSPC_HOUSE. A changed
* decoration closes the PC. */
private *playersPC(house: boolean): Gen<boolean> {
const items = this.indexSet(PC["PlayersPCMenuData.WhichPC"], house ? 1 : 0);
const names = items.map((item) =>
this.itemString(PC["PlayersPCMenuData.PlayersPCMenuPointers"], item),
);
const ask = this.pages(PC.PlayersPCAskWhatDoText);
let cursor = 0;
for (;;) {
const choice = yield* this.menu(ask, names, undefined, cursor);
if (choice < 0) return false;
cursor = choice;
switch (items[choice]) {
case PLAYERSPCITEM_WITHDRAW_ITEM:
yield* this.withdrawItems();
break;
case PLAYERSPCITEM_DEPOSIT_ITEM:
yield* this.depositItems();
break;
case PLAYERSPCITEM_TOSS_ITEM:
yield* this.tossItems();
break;
case PLAYERSPCITEM_MAIL_BOX:
// _PlayerMailBoxMenu: no MAIL can be stored yet.
yield* this.print(PC["_PlayerMailBoxMenu.EmptyMailboxText"]);
break;
case PLAYERSPCITEM_DECORATION:
if (yield* this.host.decorationMenu()) return true;
break;
default:
// LOG OFF and TURN OFF.
return false;
}
}
}
// ------------------------------------------------------ item storage
private itemEntries(slots: ItemSlot[]): MartEntry[] {
const data = this.host.data;
return slots.map((slot) => ({
item: slot.item,
name: data.itemName(slot.item),
price: 0,
owned: slot.quantity,
}));
}
/** The item named by PartyMonItemName (wStringBuffer1 and 2) and the
* quantity text_decimal shows (wItemQuantityChange). */
private nameItem(item: number, quantity?: number) {
const name = this.host.data.itemName(item);
this.host.strings[RAM.stringBuffer1] = name;
this.host.strings[RAM.stringBuffer2] = name;
if (quantity !== undefined)
this.host.numbers[RAM.itemQuantityChange] = quantity;
}
/** SelectQuantityToToss for a stack, unless _CheckTossableItem makes it
* always one. Returns 0 if cancelled. */
private *quantity(
mode: "withdraw" | "deposit" | "toss",
slot: ItemSlot,
prompt: number,
): Gen<number> {
if (this.host.data.itemUnsellable(slot.item)) return 1;
this.nameItem(slot.item);
const chosen = yield {
kind: "martquantity",
mode,
item: slot.item,
name: this.host.data.itemName(slot.item),
price: 0,
max: slot.quantity,
prompt: readText(this.host.reader, prompt, this.host.text()),
selected: 1,
};
return typeof chosen === "number" &&
Number.isInteger(chosen) &&
chosen >= 1 &&
chosen <= slot.quantity
? chosen
: 0;
}
/** PlayerWithdrawItemMenu: into the matching pack pocket. */
private *withdrawItems(): Gen {
const items = this.state.pcItems;
for (;;) {
const index = yield {
kind: "martlist",
mode: "withdraw",
entries: this.itemEntries(items),
selected: 0,
};
const slot = typeof index === "number" ? items[index] : undefined;
if (!slot) return;
const quantity = yield* this.quantity(
"withdraw",
slot,
PC["PlayerWithdrawItemMenu.PlayersPCHowManyWithdrawText"],
);
if (!quantity) continue;
const item = slot.item;
if (!receiveItem(this.host.data, this.state.bag, item, quantity)) {
yield* this.print(
PC["PlayerWithdrawItemMenu.PlayersPCNoRoomWithdrawText"],
);
continue;
}
removeFromSlot(items, index as number, item, quantity);
this.nameItem(item, quantity);
yield* this.print(
PC["PlayerWithdrawItemMenu.PlayersPCWithdrewItemsText"],
);
}
}
/** The pack as DepositSellPack lists it: items, balls and key items,
* with the pocket and index each entry comes from. */
private packSlots(): PackSlot[] {
const bag = this.state.bag;
return [
...bag.items.map((slot, index) => ({
...slot,
pocket: "items" as const,
index,
})),
...bag.balls.map((slot, index) => ({
...slot,
pocket: "balls" as const,
index,
})),
...bag.keyItems.map((item, index) => ({
item,
quantity: 1,
pocket: "keyItems" as const,
index,
})),
];
}
/** PlayerDepositItemMenu: from any pocket into the PC. */
private *depositItems(): Gen {
if (!this.packSlots().length) {
yield* this.print(PC["PlayerDepositItemMenu.PlayersPCNoItemsText"]);
return;
}
for (;;) {
const slots = this.packSlots();
const choice = yield {
kind: "martlist",
mode: "deposit",
entries: this.itemEntries(slots),
selected: 0,
};
const slot = typeof choice === "number" ? slots[choice] : undefined;
if (!slot) return;
const quantity = yield* this.quantity(
"deposit",
slot,
PC["PlayerDepositItemMenu.PlayersPCHowManyDepositText"],
);
if (!quantity) continue;
if (!receivePCItem(this.state.pcItems, slot.item, quantity)) {
yield* this.print(
PC["PlayerDepositItemMenu.PlayersPCNoRoomDepositText"],
);
continue;
}
const bag = this.state.bag;
if (slot.pocket === "keyItems") bag.keyItems.splice(slot.index, 1);
else removeFromSlot(bag[slot.pocket], slot.index, slot.item, quantity);
this.nameItem(slot.item, quantity);
yield* this.print(PC["PlayerDepositItemMenu.PlayersPCDepositItemsText"]);
}
}
/** PlayerTossItemMenu and TossItemFromPC. */
private *tossItems(): Gen {
const items = this.state.pcItems;
for (;;) {
const index = yield {
kind: "martlist",
mode: "toss",
entries: this.itemEntries(items),
selected: 0,
};
const slot = typeof index === "number" ? items[index] : undefined;
if (!slot) return;
if (this.host.data.itemUnsellable(slot.item)) {
yield* this.print(PC["TossItemFromPC.ItemsTooImportantText"]);
continue;
}
const quantity = yield* this.quantity(
"toss",
slot,
PC["TossItemFromPC.ItemsTossOutHowManyText"],
);
if (!quantity) continue;
const item = slot.item;
this.nameItem(item, quantity);
const confirmed = yield {
kind: "yesno",
pages: this.pages(PC["TossItemFromPC.ItemsThrowAwayText"]),
};
if (!confirmed) continue;
removeFromSlot(items, index as number, item, quantity);
this.nameItem(item, quantity);
yield* this.print(PC["TossItemFromPC.ItemsDiscardedText"]);
}
}
// --------------------------------------------------------- Bill's PC
/** GetBoxName: the saved name, or SetDefaultBoxNames' "BOX" and number. */
boxName(box: number) {
return (
this.state.storage.names[box] ??
`${this.string(PC["SetDefaultBoxNames.Box"])}${box + 1}`
);
}
private monDetails(mons: { level: number }[]) {
return [...mons.map((mon) => `:L${mon.level}`), ""];
}
/** _BillsPC: the top menu until SEE YA! or B. */
private *billsPC(): Gen {
if (!this.state.party.length) {
yield* this.print(PC["_BillsPC.PCGottaHavePokemonText"]);
return;
}
const names = this.strings(PC["_BillsPC.strings"], 5);
const what = this.pages(PC["_BillsPC.PCWhatText"]);
let cursor = 0;
for (;;) {
const choice = yield* this.menu(what, names, undefined, cursor);
if (choice < 0) return;
cursor = choice;
if (choice === BILLSPC_WITHDRAW) yield* this.withdrawMons();
else if (choice === BILLSPC_DEPOSIT) yield* this.depositMons();
else if (choice === BILLSPC_CHANGE_BOX) yield* this.changeBox();
else if (choice === BILLSPC_MOVE)
yield {
kind: "notice",
text: "Moving Pokémon between boxes is not ported yet.",
};
else return;
}
}
/** BillsPC_CheckMail_PreventBlackout for a party Pokémon: the last one,
* or the last that has not fainted, stays. No MAIL exists yet. */
private *partyMonMayLeave(index: number): Gen<boolean> {
const party = this.state.party;
let text: number | undefined;
if (party.length < 2) text = PC.PCString_ItsYourLastPKMN;
else if (party.every((mon, i) => i === index || !mon.hp))
text = PC.PCString_NoMoreUsablePKMN;
if (text === undefined) return true;
yield* this.placeString(this.string(text));
return false;
}
/** ReleasePKMN_ByePKMN: the species name, not the nickname. */
private *released(species: number): Gen {
yield* this.placeString(
this.string(PC.PCString_ReleasedPKMN),
RELEASED_TICKS,
);
yield* this.placeString(
`${this.string(PC.PCString_Bye)} ${this.host.data.speciesName(species)}!`,
);
}
private *confirmRelease(): Gen<boolean> {
return Boolean(
yield { kind: "yesno", pages: [this.string(PC.PCString_ReleasePKMN)] },
);
}
/** _DepositPKMN: choose a party Pokémon, then DEPOSIT, STATS or RELEASE. */
private *depositMons(): Gen {
const party = this.state.party,
options = this.strings(PC["BillsPCDepositMenuHeader.MenuData"] + 2, 4);
for (;;) {
const index = yield* this.menu(
[
`${this.string(PC["BillsPC_BoxName.PartyPKMN"])}\n${this.string(PC.PCString_ChooseaPKMN)}`,
],
[
...party.map((mon) => mon.nickname),
this.string(PC["BillsPC_RefreshTextboxes.CancelString"]),
],
this.monDetails(party),
);
if (index < 0 || index >= party.length) return;
for (;;) {
const action = yield* this.menu(
[this.string(PC.PCString_WhatsUp)],
options,
);
const mon = party[index];
if (action === SUBMENU_TAKE) {
if (!(yield* this.partyMonMayLeave(index))) break;
if (!depositMon(this.host.data, this.state.storage, mon)) {
yield* this.placeString(this.string(PC.PCString_BoxFull));
continue;
}
party.splice(index, 1);
yield* this.placeString(
`${this.string(PC.PCString_Stored)}${mon.nickname}!`,
);
break;
}
if (action === SUBMENU_STATS) {
yield { kind: "notice", text: "The stats screen is not ported yet." };
continue;
}
if (action !== SUBMENU_RELEASE) break;
if (!(yield* this.partyMonMayLeave(index))) break;
if (mon.species === EGG) {
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
break;
}
if (!(yield* this.confirmRelease())) continue;
party.splice(index, 1);
yield* this.released(mon.species);
break;
}
}
}
/** _WithdrawPKMN: choose a Pokémon in the current box, then WITHDRAW,
* STATS or RELEASE. */
private *withdrawMons(): Gen {
const options = this.strings(PC["BillsPC_Withdraw.MenuData"] + 2, 4);
for (;;) {
const box = currentBox(this.state.storage);
const index = yield* this.menu(
[
`${this.boxName(this.state.storage.current)}\n${this.string(PC.PCString_ChooseaPKMN)}`,
],
[
...box.map((mon) => mon.nickname),
this.string(PC["BillsPC_RefreshTextboxes.CancelString"]),
],
this.monDetails(box),
);
if (index < 0 || index >= box.length) return;
for (;;) {
const action = yield* this.menu(
[this.string(PC.PCString_WhatsUp)],
options,
);
const mon = box[index];
if (action === SUBMENU_TAKE) {
if (this.state.party.length >= PARTY_LENGTH) {
yield* this.placeString(this.string(PC.PCString_PartyFull));
continue;
}
box.splice(index, 1);
this.state.party.push(withdrawnMon(this.host.data, mon));
yield* this.placeString(
`${this.string(PC.PCString_Got)}${mon.nickname}!`,
);
break;
}
if (action === SUBMENU_STATS) {
yield { kind: "notice", text: "The stats screen is not ported yet." };
continue;
}
if (action !== SUBMENU_RELEASE) break;
if (mon.species === EGG) {
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
continue;
}
if (!(yield* this.confirmRelease())) continue;
box.splice(index, 1);
yield* this.released(mon.species);
break;
}
}
}
/** _ChangeBox: choose a box, then SWITCH, NAME, PRINT or QUIT. */
private *changeBox(): Gen {
const storage = this.state.storage,
options = this.strings(PC["BillsPC_ChangeBoxSubmenu.MenuData"] + 2, 4),
current = this.string(PC["BillsPC_PrintBoxName.Current"]),
outOf = this.string(PC["BillsPC_PrintBoxCountAndCapacity.OutOf20"]);
let cursor = 0;
for (;;) {
const names = Array.from({ length: NUM_BOXES }, (_, box) =>
this.boxName(box),
);
const box = yield* this.menu(
[
`${current}\n${this.boxName(storage.current)}\n${this.string(PC["BillsPC_PlaceChooseABoxString.ChooseABox"])}`,
],
names,
storage.boxes.map((mons) => `${mons.length}${outOf}`),
cursor,
);
if (box < 0) return;
cursor = box;
const action = yield* this.menu(
[this.string(PC.PCString_WhatsUp)],
options,
);
if (action === BOX_SWITCH) {
if (box === storage.current) continue;
// ChangeBoxSaveGame: the native session saves continuously, so the
// switch itself is what the confirmation commits.
const confirmed = yield {
kind: "yesno",
pages: this.pages(PC.ChangeBoxSaveText),
};
if (confirmed) storage.current = box;
} else if (action === BOX_NAME) {
const name = yield {
kind: "name",
pages: [this.string(PC["NamingScreenJumptable.BoxNameString"])],
current: names[box],
max: 8,
};
if (typeof name !== "string" || (name && !BOX_NAME_PATTERN.test(name)))
throw new Error("Invalid box name.");
// InitString: a blank name keeps the box's name.
if (!/^ *$/.test(name)) storage.names[box] = name;
} else if (action === BOX_PRINT) {
if (!storage.boxes[box].length)
yield* this.placeString(
this.string(PC["BillsPC_PlaceEmptyBoxString_SFX.NoMonString"]),
);
else
yield {
kind: "notice",
text: "Printing needs a Game Boy Printer, which is not supported.",
};
}
}
}
// ----------------------------------------------------------- Oak's PC
/** ProfOaksPC: an optional Pokédex rating from OakRatings. */
private *oaksPC(): Gen {
const r = this.host.reader;
const rate = yield {
kind: "yesno",
pages: this.pages(PC.OakPCText1),
};
if (rate) {
yield* this.print(PC.OakPCText2);
const { seen, caught } = this.state.pokedex;
this.host.strings[RAM.stringBuffer3] = String(seen.size);
this.host.strings[RAM.stringBuffer4] = String(caught.size);
yield* this.print(PC.OakPCText3);
// FindOakRating: the first entry whose count reaches the owned total.
let entry = PC.OakRatings;
while (r.byte(entry) < caught.size) entry += 5;
yield* this.print(r.near(entry + 3, r.bank(entry)));
}
yield* this.print(PC.OakPCText4);
}
}
+67 -15
View File
@@ -10,6 +10,7 @@ import {
INITIAL_POSITION,
NUM_FRUIT_TREES,
OVERWORLD,
PC,
SYMBOLS,
} from "../rom/profile";
import { OW } from "./constants";
@@ -19,12 +20,20 @@ import {
copyScriptState,
emptyScriptState,
type MartEntry,
type MartMode,
type ScriptEffect,
type ScriptHost,
type ScriptState,
} from "../scripts/interpreter";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag } from "../party/bag";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
copyStorage,
emptyStorage,
validPCItems,
validStorage,
type PokemonStorage,
} from "../party/storage";
import {
NICKNAME_PATTERN,
caughtData,
@@ -75,6 +84,8 @@ export interface NativeSave {
playerId: number;
party: PartyMon[];
bag: Bag;
storage: PokemonStorage;
pcItems: ItemSlot[];
pokedex: { seen: number[]; caught: number[] };
money: number;
fruitTrees: number[];
@@ -186,20 +197,20 @@ export type Choice =
/** The naming screen: an empty answer keeps `current`. */
| { kind: "name"; current: string; max: number; selected?: undefined }
/** A vertical menu; `selected` answers with that item's index. */
| { kind: "menu"; items: string[]; selected: number }
| { kind: "menu"; items: string[]; details?: string[]; selected: number }
/** The mart's BUY/SELL/QUIT box. */
| { kind: "martmenu"; prompt: string; selected: number }
/** The mart's item list; `selected` answers with that entry's index. */
| {
kind: "martlist";
mode: "buy" | "sell";
mode: MartMode;
entries: MartEntry[];
selected: number;
}
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
mode: "buy" | "sell";
mode: MartMode;
item: number;
name: string;
price: number;
@@ -263,10 +274,18 @@ interface ActiveScript {
warp?: { map: string; x: number; y: number; facing?: Direction };
/** warpcheck: the player's tile events run on the tick after the script. */
warpCheck?: boolean;
/** LoadMemScript: queued for the next PlayerEvents once this commits. */
memScript?: MemScript;
/** The trainer whose sight started this script, if any. */
sighted?: number;
}
/** wMapReentryScriptQueueFlag's script: work RAM, so it is not saved. */
interface MemScript {
script: number;
callerScript?: number;
}
const OPPOSITE: Record<Direction, Direction> = {
down: "up",
up: "down",
@@ -455,6 +474,7 @@ export class GameWorld {
notice = "";
diagnostic = "";
private actors = new Map<number, Actor>();
private memScript?: MemScript;
private currentMap?: GameMap;
/** wCmdQueue's CMDQUEUE_STONETABLE entries for the current map. */
private stoneTables: number[] = [];
@@ -1731,6 +1751,7 @@ export class GameWorld {
canLose: effect.canLose,
text: () => this.host(PLAYER).text(),
bag: draft.bag,
storage: draft.storage,
hasPokedex: draft.flags.has(ENGINE_FLAGS.pokedex),
caughtData: (level) =>
caughtData(
@@ -1757,6 +1778,16 @@ export class GameWorld {
case "warpcheck":
script.warpCheck = true;
return false;
case "notice":
this.notice = effect.text;
return false;
case "memscript":
// LoadMemScript keeps a script that is already queued.
script.memScript ??= {
script: effect.script,
callerScript: effect.callerScript,
};
return false;
case "townmap":
this.townMap = true;
this.dialogue = { pages: ["Town Map"], index: 0 };
@@ -1768,6 +1799,7 @@ export class GameWorld {
choice: {
kind: "menu",
items: effect.items,
...(effect.details && { details: effect.details }),
selected: effect.selected,
},
};
@@ -2153,6 +2185,7 @@ export class GameWorld {
this.arrival = { x: this.x, y: this.y };
}
this.script = undefined;
this.memScript ??= script.memScript;
this.townMap = false;
this.dialogue = undefined;
this.emote = undefined;
@@ -2686,6 +2719,15 @@ export class GameWorld {
}
if (this.busy || this.mapId !== map) return;
} else if (this.checkTrainers()) return;
// RunMemScript precedes RunSceneScript.
const memScript = this.memScript;
if (memScript) {
this.memScript = undefined;
this.startScript(memScript.script, PLAYER, {
callerScript: memScript.callerScript,
});
if (this.busy) return;
}
this.checkScene();
if (this.busy) return;
this.checkDailyReset();
@@ -2791,17 +2833,16 @@ export class GameWorld {
return;
}
} else {
const standard: Record<number, number> = {
0x91: 2,
0x97: 9,
0x9d: 8,
0x96: 6,
0x9f: 5,
};
if (standard[collision] !== undefined && this.player.direction === "up")
script = this.assets.reader.far(
SYMBOLS.standardScripts + standard[collision] * 3,
);
// CheckFacingTileForStdScript: TileCollisionStdScripts pairs a
// collision type with a std script index, in any facing direction.
const r = this.assets.reader;
for (let entry = PC.TileCollisionStdScripts; ; entry += 3) {
const type = r.byte(entry);
if (type === 0xff) break;
if (type !== collision) continue;
script = r.far(SYMBOLS.standardScripts + r.word(entry + 1) * 3);
break;
}
}
if (script === undefined) {
const [fx, fy] = VECTORS[this.player.direction];
@@ -3136,6 +3177,8 @@ export class GameWorld {
playerId: this.playerId,
party: structuredClone(state.party),
bag: structuredClone(state.bag),
storage: copyStorage(state.storage),
pcItems: structuredClone(state.pcItems),
pokedex: {
seen: [...state.pokedex.seen].sort((a, b) => a - b),
caught: [...state.pokedex.caught].sort((a, b) => a - b),
@@ -3215,6 +3258,8 @@ export class GameWorld {
save.party.length > 6 ||
!save.party.every((mon) => validPartyMon(this.assets.pokemon, mon)) ||
!validBag(this.assets.pokemon, save.bag) ||
!validStorage(save.storage) ||
!validPCItems(this.assets.pokemon, save.pcItems) ||
!save.pokedex ||
!species(save.pokedex.seen) ||
!species(save.pokedex.caught) ||
@@ -3288,6 +3333,8 @@ export class GameWorld {
clock: { ...save.clock },
party: structuredClone(save.party),
bag: structuredClone(save.bag),
storage: copyStorage(save.storage),
pcItems: structuredClone(save.pcItems),
pokedex: {
seen: new Set(save.pokedex.seen),
caught: new Set(save.pokedex.caught),
@@ -3351,6 +3398,7 @@ export class GameWorld {
this.picture = undefined;
this.battle = undefined;
this.battleRun = undefined;
this.memScript = undefined;
this.clearNotice();
}
}
@@ -3376,6 +3424,8 @@ export function migrateSave(
decorations: initialDecorations(),
}),
...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }),
...(!Object.hasOwn(save, "storage") && { storage: emptyStorage() }),
...(!Object.hasOwn(save, "pcItems") && { pcItems: [] }),
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
...(!Object.hasOwn(save, "dailyReset") && { dailyReset: null }),
...(!Object.hasOwn(save, "lastSpawnMap") && { lastSpawnMap: null }),
@@ -3407,6 +3457,8 @@ export function migrateSave(
playerId: newPlayerId(),
party: [],
bag: emptyBag(),
storage: emptyStorage(),
pcItems: [],
pokedex: { seen: [], caught: [] },
lastSpawnMap: null,
specialPhoneCall: 0,
+23 -8
View File
@@ -148,8 +148,14 @@ function showChoice(choice: Choice | undefined) {
const option = button(item, choice.selected === index, () =>
runtime.choose(index),
);
const detail = choice.details?.[index];
if (detail) {
const small = document.createElement("small");
small.textContent = detail;
option.append(small);
}
option.setAttribute("role", "option");
option.setAttribute("aria-label", item);
option.setAttribute("aria-label", detail ? `${item} ${detail}` : item);
option.setAttribute("aria-selected", String(choice.selected === index));
return option;
}),
@@ -218,6 +224,11 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
martMenuKey = "";
return;
}
// The PC's item storage reuses these lists without prices.
const shop =
mart.kind === "martmenu" || mart.mode === "buy" || mart.mode === "sell";
get("mart").setAttribute("aria-label", shop ? "Poké Mart" : "Item storage");
get("mart-money").hidden = !shop;
get("mart-money").textContent = `MONEY ¥${world.liveState.money}`;
get("mart-prompt").textContent = mart.kind === "martlist" ? "" : mart.prompt;
const key = JSON.stringify(mart);
@@ -260,7 +271,11 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
const buttons = mart.entries.map((entry, index) =>
button(entry.name, () => runtime.choose(index), {
detail:
mart.mode === "sell" ? `have ${entry.owned}` : `¥${entry.price}`,
mart.mode === "sell"
? `have ${entry.owned}`
: mart.mode === "buy"
? `¥${entry.price}`
: `×${entry.owned}`,
selected: mart.selected === index,
}),
);
@@ -290,7 +305,9 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
return element;
};
const output = document.createElement("output");
output.textContent = `${mart.name} ×${mart.selected} ¥${total}`;
output.textContent = shop
? `${mart.name} ×${mart.selected} ¥${total}`
: `${mart.name} ×${mart.selected}`;
stepper.append(
step("◀◀", "Minus 10", "left"),
step("▼", "Minus 1", "down"),
@@ -300,11 +317,9 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
);
menu.replaceChildren(
stepper,
button(
mart.mode === "sell" ? "SELL" : "BUY",
() => runtime.choose(mart.selected),
{ wide: true },
),
button(mart.mode.toUpperCase(), () => runtime.choose(mart.selected), {
wide: true,
}),
button("CANCEL", () => runtime.choose(-1), { wide: true }),
);
}
+1 -1
View File
@@ -76,5 +76,5 @@ export function renderShell(root: HTMLElement) {
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
</div>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls from the battle PACK. Party and bag menus, the PC and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls from the battle PACK. The Pokémon Center PC stores Pokémon and items. Party and bag menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
}