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:
1 parent
451464f7c6
commit
3bdca0099e
21 files changed
+1999
-131
No files matched your search
+84
-25
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user