Giving MAIL opens the mail entry: two lines of up to sixteen characters, kept with the player's name and ID, the holder's species and the MAIL. The party submenu's MAIL reads the letter or takes it, either to the PC's MAILBOX (ten at most) or into the pack without its message. The player's PC lists the MAILBOX by author to read, put in the pack or attach, and Bill's PC keeps a Pokémon holding MAIL out of the boxes. Saves keep each holder's MAIL and the MAILBOX. The letter shows its message and author natively; the stationery's graphics are not drawn yet. Closes #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1576 lines
57 KiB
TypeScript
1576 lines
57 KiB
TypeScript
import type { RomReader } from "../rom/reader";
|
|
import type { PokemonData } from "../rom/pokemon";
|
|
import { BATTLE, ENGINE_FLAGS, MENU, OVERWORLD, RAM } from "../rom/profile";
|
|
import { C } from "../battle/constants";
|
|
import {
|
|
changedHappiness,
|
|
restoreHealth,
|
|
restoredPP,
|
|
} from "../battle/battle";
|
|
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
|
|
import { calcStats, maxPP, type PartyMon } from "../party/pokemon";
|
|
import type { GameTime } from "../world/game-time";
|
|
import { PartyEvolution, evoStoneCompatible } from "./evolution";
|
|
import {
|
|
copyScriptState,
|
|
type FieldMoveContext,
|
|
type MartEntry,
|
|
type ScriptEffect,
|
|
type ScriptHost,
|
|
type ScriptState,
|
|
} from "./interpreter";
|
|
import { Pokegear } from "./pokegear";
|
|
import { TrainerCard } from "./trainer-card";
|
|
import { OptionsMenu, menuAccount } from "./options";
|
|
import { flyMap } from "./fly-map";
|
|
import { OW } from "../world/constants";
|
|
import { DecorationData } from "../world/decorations";
|
|
import {
|
|
MAIL_LINE_LENGTH,
|
|
MAILBOX_CAPACITY,
|
|
validMailMessage,
|
|
type Mail,
|
|
} from "../party/mail";
|
|
import { readString, readText, type TextContext } from "./text";
|
|
|
|
type Input = number | string | undefined;
|
|
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
|
|
|
/** StartMenu.Items indexes (STARTMENUITEM_*). */
|
|
const STARTMENUITEM_POKEDEX = 0,
|
|
STARTMENUITEM_POKEMON = 1,
|
|
STARTMENUITEM_PACK = 2,
|
|
STARTMENUITEM_STATUS = 3,
|
|
STARTMENUITEM_SAVE = 4,
|
|
STARTMENUITEM_OPTION = 5,
|
|
STARTMENUITEM_EXIT = 6,
|
|
STARTMENUITEM_POKEGEAR = 7;
|
|
/** The pack's pockets in Pack_InterpretJoypad order (*_POCKET). */
|
|
const POCKETS = ["item", "ball", "key", "tm"] as const;
|
|
type PocketIndex = 0 | 1 | 2 | 3;
|
|
/** The pack's item submenus: the strings of each MenuHeader_*.MenuData
|
|
* and the action of each line. */
|
|
type ItemAction = "use" | "give" | "toss" | "sel" | "quit";
|
|
const ITEM_MENUS: Record<string, ItemAction[]> = {
|
|
"MenuHeader_UsableKeyItem.MenuData": ["use", "give", "toss", "sel", "quit"],
|
|
"MenuHeader_UsableItem.MenuData": ["use", "give", "toss", "quit"],
|
|
"MenuHeader_UnusableItem.MenuData": ["use", "quit"],
|
|
"MenuHeader_UnusableKeyItem.MenuData": ["use", "sel", "quit"],
|
|
"MenuHeader_HoldableKeyItem.MenuData": ["give", "toss", "sel", "quit"],
|
|
"MenuHeader_HoldableItem.MenuData": ["give", "toss", "quit"],
|
|
};
|
|
const {
|
|
EGG,
|
|
ITEMMENU_CURRENT,
|
|
ITEMMENU_PARTY,
|
|
ITEMMENU_CLOSE,
|
|
MONMENU_FIELD_MOVE,
|
|
MONMENUITEM_STATS,
|
|
MONMENUITEM_SWITCH,
|
|
MONMENUITEM_ITEM,
|
|
MONMENUITEM_CANCEL,
|
|
MONMENUITEM_MOVE,
|
|
MONMENUITEM_MAIL,
|
|
NUM_MONMENU_ITEMS,
|
|
PARTYMENUACTION_CHOOSE_POKEMON,
|
|
PARTYMENUACTION_HEALING_ITEM,
|
|
PARTYMENUACTION_MOVE,
|
|
PARTYMENUACTION_GIVE_ITEM,
|
|
KEY_ITEM_POCKET,
|
|
TM_HM_POCKET,
|
|
PP_UP,
|
|
SKETCH,
|
|
PP_UP_MASK,
|
|
PP_UP_ONE,
|
|
} = C;
|
|
const NOT_PORTED = "is not ported yet.";
|
|
/** MAX_ITEM_STACK */
|
|
const MAX_ITEM_STACK = 99;
|
|
/** MAP_RADIO_TOWER_3F and MAP_GOLDENROD_UNDERGROUND
|
|
* (constants/map_constants.asm). */
|
|
const RADIO_TOWER_3F = "3:19",
|
|
GOLDENROD_UNDERGROUND = "3:53";
|
|
/** wPlayerDirection's UP, as the host's facing() numbers it. */
|
|
const FACING_UP = 1;
|
|
const FACING_VECTORS = [
|
|
[0, 1],
|
|
[0, -1],
|
|
[-1, 0],
|
|
[1, 0],
|
|
] as const;
|
|
|
|
/** Menu cursors in work RAM, which CleanUpBattleRAM clears after every
|
|
* battle: wBattleMenuCursorPosition (the start menu's 1-based position,
|
|
* shared with the battle menu), wPartyMenuCursor (1-based) and wLastPocket. */
|
|
export interface MenuCursors {
|
|
start: number;
|
|
party: number;
|
|
pocket: number;
|
|
}
|
|
|
|
/** The world's verdict on a field move chosen from the party menu: a
|
|
* script to run once the menu closes (with the context the world checked),
|
|
* the messages of a failed attempt, a notice for what is not ported, or
|
|
* Fly's map to choose a destination from. */
|
|
export type MenuFieldMove =
|
|
| {
|
|
script: number;
|
|
fieldMove: FieldMoveContext;
|
|
wild?: { species: number; level: number };
|
|
/** hLastTalked, when the move acts on a facing object. */
|
|
lastTalked?: number;
|
|
/** BATTLETYPE_FISH: the wild Pokémon is on a fishing rod's hook. */
|
|
fish?: boolean;
|
|
}
|
|
| { texts: number[] }
|
|
| { notice: string }
|
|
| { flyMap: true };
|
|
|
|
/** What the start menu needs from the world. */
|
|
export interface StartMenuHost {
|
|
reader: RomReader;
|
|
data: PokemonData;
|
|
text(): TextContext;
|
|
/** BikeFunction: the script that getting on or off the Bicycle queues
|
|
* here, or undefined when .CheckEnvironment refuses it. */
|
|
bicycleScript(state: ScriptState, select: boolean): number | undefined;
|
|
/** wTimeOfDay: morning 0, day 1, night 2. */
|
|
timeOfDay(): number;
|
|
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
|
|
landmark(): number;
|
|
/** A field move's checks for party slot `mon`, on the menu's draft. */
|
|
fieldMove(state: ScriptState, mon: number, move: number): MenuFieldMove;
|
|
/** FlyFunction after the Fly map: flying to SPAWN_* point `spawn`. */
|
|
flyTo(state: ScriptState, mon: number, spawn: number): MenuFieldMove;
|
|
/** EscapeRopeFunction's checks, on the menu's draft. */
|
|
escapeRope(state: ScriptState): MenuFieldMove;
|
|
/** HasVisitedSpawn: the wVisitedSpawns bit of a SPAWN_* point. */
|
|
visitedSpawn(state: ScriptState, spawn: number): boolean;
|
|
/** The script host a Pokégear phone call runs with. */
|
|
scriptHost(): ScriptHost;
|
|
/** GetWorldMapLocation for any map. */
|
|
landmarkOf(mapId: string): number;
|
|
/** wGameTime, for the trainer card. */
|
|
gameTime(): GameTime;
|
|
/** CheckForHiddenItems: an unfound hidden item on the screen. */
|
|
hiddenItemOnScreen(state: ScriptState): boolean;
|
|
/** FishFunction with rod 0-2: a script, or a silent failure. */
|
|
fish(state: ScriptState, rod: number): MenuFieldMove;
|
|
}
|
|
|
|
/** The overworld start menu (engine/menus/start_menu.asm) with its
|
|
* POKéMON party menu and submenu (engine/pokemon/mon_menu.asm,
|
|
* mon_submenu.asm, party_menu.asm) and PACK (engine/items/pack.asm and
|
|
* the field side of engine/items/item_effects.asm). The original draws its
|
|
* own screens; here menus, lists and messages are presented natively. It
|
|
* runs on a draft like any script, so a refused step changes nothing. */
|
|
export class StartMenuRun {
|
|
readonly state: ScriptState;
|
|
/** A script queued by an item (QueueScript), which runs after the menu
|
|
* closes, with the item named as UseItem names it. */
|
|
deferred?: number;
|
|
deferredItem?: number;
|
|
/** The field move context and wild Pokémon a queued script needs. */
|
|
deferredMove?: Extract<MenuFieldMove, { script: number }>;
|
|
/** The menu cursors kept in work RAM between openings. */
|
|
readonly cursors: MenuCursors;
|
|
private strings: Record<number, string> = {};
|
|
private numbers: Record<number, number> = {};
|
|
private iterator: Gen;
|
|
private done = false;
|
|
|
|
/** wUsingItemWithSelect: the registered item is being used. */
|
|
private usingSelect = false;
|
|
|
|
/** `select` runs SelectMenu (the Select button) instead of StartMenu. */
|
|
constructor(
|
|
state: ScriptState,
|
|
private host: StartMenuHost,
|
|
cursors: MenuCursors = { start: 0, party: 0, pocket: 0 },
|
|
select = false,
|
|
) {
|
|
this.state = copyScriptState(state);
|
|
this.cursors = { ...cursors };
|
|
this.iterator = select ? this.selectMenu() : this.run();
|
|
}
|
|
|
|
/** Advance until the next effect, or undefined when the menu closed. */
|
|
resume(input?: Input): ScriptEffect | undefined {
|
|
if (this.done) return undefined;
|
|
const next = this.iterator.next(input);
|
|
if (next.done) {
|
|
this.done = true;
|
|
return undefined;
|
|
}
|
|
return next.value;
|
|
}
|
|
|
|
// ------------------------------------------------------------ helpers
|
|
|
|
private get data() {
|
|
return this.host.data;
|
|
}
|
|
private get reader() {
|
|
return this.host.reader;
|
|
}
|
|
private text(): TextContext {
|
|
const base = this.host.text();
|
|
return {
|
|
...base,
|
|
rival: this.state.rivalName,
|
|
strings: { ...base.strings, ...this.strings },
|
|
numbers: { ...base.numbers, ...this.numbers },
|
|
};
|
|
}
|
|
private string(address: number) {
|
|
return readString(this.reader, address, this.text());
|
|
}
|
|
/** Consecutive "@"-terminated strings, as static menus store them. */
|
|
private stringList(address: number, count: number) {
|
|
const r = this.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;
|
|
}
|
|
private pages(address: number) {
|
|
return readText(this.reader, address, this.text())
|
|
.split("\n\n")
|
|
.filter(Boolean);
|
|
}
|
|
/** PrintText or a menu textbox that waits for a button. */
|
|
private *print(address: number): Gen {
|
|
const pages = this.pages(address);
|
|
if (pages.length) yield { kind: "text", pages };
|
|
}
|
|
private *yesNo(address: number): Gen<boolean> {
|
|
return Boolean(yield { kind: "yesno", pages: this.pages(address) });
|
|
}
|
|
/** 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" &&
|
|
Number.isInteger(choice) &&
|
|
choice >= 0 &&
|
|
choice < items.length
|
|
? choice
|
|
: -1;
|
|
}
|
|
private *notice(text: string): Gen {
|
|
yield { kind: "notice", text };
|
|
}
|
|
/** GetItemName and CopyName1: the item in wStringBuffer1 and 2. */
|
|
private nameItem(item: number) {
|
|
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] =
|
|
this.data.itemName(item);
|
|
}
|
|
/** GetCurNickname copied to wMonOrItemNameBuffer. */
|
|
private nameMon(slot: number) {
|
|
this.strings[RAM.monOrItemNameBuffer] = this.state.party[slot].nickname;
|
|
}
|
|
private isMail(item: number) {
|
|
return this.data.itemIsMail(item);
|
|
}
|
|
|
|
// --------------------------------------------------------- start menu
|
|
|
|
/** StartMenu.SetUpMenuItems: no Link mode or Bug-Catching Contest yet. */
|
|
private menuItems() {
|
|
const items: number[] = [];
|
|
if (this.state.flags.has(ENGINE_FLAGS.pokedex))
|
|
items.push(STARTMENUITEM_POKEDEX);
|
|
if (this.state.party.length) items.push(STARTMENUITEM_POKEMON);
|
|
items.push(STARTMENUITEM_PACK);
|
|
if (this.state.flags.has(ENGINE_FLAGS.pokegear))
|
|
items.push(STARTMENUITEM_POKEGEAR);
|
|
items.push(
|
|
STARTMENUITEM_STATUS,
|
|
STARTMENUITEM_SAVE,
|
|
STARTMENUITEM_OPTION,
|
|
STARTMENUITEM_EXIT,
|
|
);
|
|
return items;
|
|
}
|
|
/** StartMenu.Items: (routine, string, description) pointers. */
|
|
private itemString(item: number, field: 1 | 2) {
|
|
const r = this.reader,
|
|
entry = MENU["StartMenu.Items"] + item * 6 + field * 2;
|
|
return this.string(r.near(entry, r.bank(entry)));
|
|
}
|
|
|
|
private *run(): Gen {
|
|
for (;;) {
|
|
const items = this.menuItems();
|
|
const choice = yield* this.menu(
|
|
[],
|
|
items.map((item) => this.itemString(item, 1)),
|
|
// The menu account (wOptions2) describes the highlighted item.
|
|
menuAccount(this.state.options)
|
|
? items.map((item) => this.itemString(item, 2).replace("\n", " "))
|
|
: undefined,
|
|
Math.min(items.length - 1, Math.max(0, this.cursors.start - 1)),
|
|
);
|
|
if (choice < 0) return;
|
|
this.cursors.start = choice + 1;
|
|
const item = items[choice];
|
|
switch (item) {
|
|
case STARTMENUITEM_POKEMON:
|
|
if (yield* this.pokemon()) return;
|
|
break;
|
|
case STARTMENUITEM_PACK:
|
|
if (yield* this.pack()) return;
|
|
break;
|
|
case STARTMENUITEM_POKEGEAR:
|
|
// StartMenu_Pokegear returns to the start menu.
|
|
yield* new Pokegear(this.state, {
|
|
reader: this.reader,
|
|
data: this.data,
|
|
text: () => this.text(),
|
|
scriptHost: () => this.host.scriptHost(),
|
|
landmarkOf: (mapId) => this.host.landmarkOf(mapId),
|
|
}).run();
|
|
break;
|
|
case STARTMENUITEM_STATUS:
|
|
// StartMenu_Status returns to the start menu.
|
|
yield* new TrainerCard(this.state, {
|
|
reader: this.reader,
|
|
text: () => this.text(),
|
|
player: () => this.host.scriptHost().player(),
|
|
gameTime: () => this.host.gameTime(),
|
|
}).run();
|
|
break;
|
|
case STARTMENUITEM_OPTION:
|
|
// StartMenu_Option returns to the start menu.
|
|
yield* new OptionsMenu(this.state.options, {
|
|
reader: this.reader,
|
|
text: () => this.text(),
|
|
}).run();
|
|
break;
|
|
case STARTMENUITEM_EXIT:
|
|
return;
|
|
case STARTMENUITEM_SAVE:
|
|
yield* this.notice(
|
|
"The game saves itself as you play. Use the Savegames tab for backups.",
|
|
);
|
|
break;
|
|
default:
|
|
yield* this.notice(`${this.itemString(item, 1)} ${NOT_PORTED}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------- select menu
|
|
|
|
/** SelectMenu: use the registered item, or suggest registering one. */
|
|
private *selectMenu(): Gen {
|
|
const registered = this.checkRegisteredItem();
|
|
if (!registered) return yield* this.print(MENU.MayRegisterItemText);
|
|
const { item, index } = registered;
|
|
// UseRegisteredItem by the item's field menu.
|
|
const menu = this.data.itemFieldMenu(item);
|
|
if (menu < ITEMMENU_CURRENT) return yield* this.print(MENU.ItemsOakWarningText);
|
|
this.usingSelect = menu === ITEMMENU_CLOSE;
|
|
const used = yield* this.doItemEffect(index, item);
|
|
this.usingSelect = false;
|
|
if (menu === ITEMMENU_CLOSE && used === false)
|
|
yield* this.print(MENU.ItemsOakWarningText);
|
|
}
|
|
/** CheckRegisteredItem: the registered item if it is still in its slot
|
|
* (or, for key items, anywhere in the pocket); otherwise the
|
|
* registration is cleared. A registered ball is never found, because
|
|
* .CheckBall's slot test is inverted. */
|
|
private checkRegisteredItem(): { item: number; index: number } | undefined {
|
|
const registered = this.state.registeredItem,
|
|
bag = this.state.bag;
|
|
const pocket = registered.which >> 6,
|
|
index = (registered.which & 0x3f) - 1;
|
|
let found: { item: number; index: number } | undefined;
|
|
if (registered.which) {
|
|
if (pocket === 0) {
|
|
if (index < bag.items.length && bag.items[index].item === registered.item)
|
|
found = { item: registered.item, index };
|
|
} else if (pocket === KEY_ITEM_POCKET) {
|
|
if (bag.keyItems.includes(registered.item))
|
|
found = { item: registered.item, index: -1 };
|
|
}
|
|
}
|
|
if (!found) this.state.registeredItem = { which: 0, item: 0 };
|
|
return found;
|
|
}
|
|
/** RegisterItem: CANT_SELECT items cannot be registered. */
|
|
private *registerItem(pocket: PocketIndex, index: number, item: number): Gen {
|
|
if (this.data.itemUnselectable(item)) return yield* this.print(MENU.CantRegisterText);
|
|
this.state.registeredItem = {
|
|
which: ((pocket << 6) & 0xc0) | ((index + 1) & 0x3f),
|
|
item,
|
|
};
|
|
this.nameItem(item);
|
|
yield* this.print(MENU.RegisteredItemText);
|
|
}
|
|
|
|
// --------------------------------------------------------- party menu
|
|
|
|
/** The party menu with PlacePartyMenuText's prompt. Returns the party
|
|
* index, or -1 when cancelled. `from` marks SwitchPartyMons' source. */
|
|
private *partyMenu(
|
|
action: number,
|
|
from?: number,
|
|
item = 0,
|
|
): Gen<number> {
|
|
const r = this.reader,
|
|
entry = MENU.PartyMenuStrings + action * 2;
|
|
const prompt = this.string(r.near(entry, r.bank(entry)));
|
|
const party = this.state.party;
|
|
const choice = yield* this.menu(
|
|
[prompt],
|
|
party.map((mon, slot) => (slot === from ? `▷${mon.nickname}` : mon.nickname)),
|
|
party.map((mon) =>
|
|
// PlacePartyMonEvoStoneCompatibility replaces the HP bar.
|
|
action === C.PARTYMENUACTION_EVO_STONE
|
|
? mon.species === EGG
|
|
? ""
|
|
: this.string(
|
|
evoStoneCompatible(r, mon.species, item)
|
|
? MENU["PlacePartyMonEvoStoneCompatibility.string_able"]
|
|
: MENU["PlacePartyMonEvoStoneCompatibility.string_not_able"],
|
|
)
|
|
: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`,
|
|
),
|
|
// InitPartyMenuWithCancel: the last row chosen, if still listed.
|
|
this.cursors.party >= 1 && this.cursors.party <= party.length
|
|
? this.cursors.party - 1
|
|
: 0,
|
|
);
|
|
if (choice >= 0) this.cursors.party = choice + 1;
|
|
return choice;
|
|
}
|
|
|
|
/** StartMenu_Pokemon: the party menu until B. Returns true when an action
|
|
* closes every menu (a field move). */
|
|
private *pokemon(): Gen<boolean> {
|
|
if (!this.state.party.length) return false;
|
|
for (;;) {
|
|
const slot = yield* this.partyMenu(PARTYMENUACTION_CHOOSE_POKEMON);
|
|
if (slot < 0) return false;
|
|
if (yield* this.pokemonActionSubmenu(slot)) return true;
|
|
}
|
|
}
|
|
|
|
/** GetMonSubmenuItems: field moves the Pokémon knows, then STATS,
|
|
* SWITCH, MOVE, ITEM or MAIL, and CANCEL if there is room. */
|
|
private monSubmenuItems(mon: PartyMon) {
|
|
if (mon.species === EGG)
|
|
return [MONMENUITEM_STATS, MONMENUITEM_SWITCH, MONMENUITEM_CANCEL];
|
|
const r = this.reader,
|
|
items: number[] = [];
|
|
for (const move of mon.moves) {
|
|
if (!move) continue;
|
|
// IsFieldMove: MonMenuOptions' field moves, before the options.
|
|
for (let p = MENU.MonMenuOptions; r.byte(p) === MONMENU_FIELD_MOVE; p += 3)
|
|
if (r.byte(p + 2) === move) {
|
|
items.push(r.byte(p + 1));
|
|
break;
|
|
}
|
|
}
|
|
items.push(
|
|
MONMENUITEM_STATS,
|
|
MONMENUITEM_SWITCH,
|
|
MONMENUITEM_MOVE,
|
|
this.isMail(mon.item) ? MONMENUITEM_MAIL : MONMENUITEM_ITEM,
|
|
);
|
|
if (items.length !== NUM_MONMENU_ITEMS) items.push(MONMENUITEM_CANCEL);
|
|
return items;
|
|
}
|
|
/** GetMonMenuString: a field move's name, or MonMenuOptionStrings. */
|
|
private monMenuString(item: number) {
|
|
const r = this.reader;
|
|
let p = MENU.MonMenuOptions;
|
|
while (r.byte(p + 1) !== item) p += 3;
|
|
const value = r.byte(p + 2);
|
|
if (r.byte(p) === MONMENU_FIELD_MOVE) return this.data.moveName(value);
|
|
return this.stringList(MENU.MonMenuOptionStrings, value)[value - 1];
|
|
}
|
|
|
|
/** PokemonActionSubmenu (MonSubmenu); B is CANCEL. Returns true when a
|
|
* field move closes every menu to run its script. */
|
|
private *pokemonActionSubmenu(slot: number): Gen<boolean> {
|
|
const mon = this.state.party[slot];
|
|
const items = this.monSubmenuItems(mon);
|
|
const choice = yield* this.menu(
|
|
[mon.nickname],
|
|
items.map((item) => this.monMenuString(item)),
|
|
);
|
|
const item = choice < 0 ? MONMENUITEM_CANCEL : items[choice];
|
|
switch (item) {
|
|
case MONMENUITEM_STATS:
|
|
// OpenPartyStats
|
|
yield { kind: "stats", mon };
|
|
break;
|
|
case MONMENUITEM_SWITCH:
|
|
yield* this.switchPartyMons(slot);
|
|
break;
|
|
case MONMENUITEM_ITEM:
|
|
yield* this.giveTakePartyMonItem(slot);
|
|
break;
|
|
case MONMENUITEM_MOVE:
|
|
yield* this.managePokemonMoves(slot);
|
|
break;
|
|
case MONMENUITEM_CANCEL:
|
|
break;
|
|
case MONMENUITEM_MAIL:
|
|
yield* this.monMailAction(slot);
|
|
break;
|
|
default:
|
|
return yield* this.fieldMove(slot, item);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** MonMenu_Cut and the other field moves: Softboiled and Milk Drink run
|
|
* here; the others ask the world, which checks the map. */
|
|
private *fieldMove(slot: number, item: number): Gen<boolean> {
|
|
const r = this.reader;
|
|
let p = MENU.MonMenuOptions;
|
|
while (r.byte(p + 1) !== item) p += 3;
|
|
const move = r.byte(p + 2);
|
|
if (move === C.SOFTBOILED || move === C.MILK_DRINK) {
|
|
yield* this.softboiledMilkDrink(slot);
|
|
return false;
|
|
}
|
|
let result = this.host.fieldMove(this.state, slot, move);
|
|
if ("flyMap" in result) {
|
|
// FlyFunction.TryFly: B on the map (.illegal) returns to the party
|
|
// menu like a failure.
|
|
const spawn = yield* flyMap(
|
|
{
|
|
reader: this.reader,
|
|
text: () => this.text(),
|
|
landmark: () => {
|
|
const landmark = this.host.landmark();
|
|
const backup = this.state.backupWarp?.map;
|
|
return landmark === OW.LANDMARK_SPECIAL && backup
|
|
? this.host.landmarkOf(backup)
|
|
: landmark;
|
|
},
|
|
visited: (spawn) => this.host.visitedSpawn(this.state, spawn),
|
|
},
|
|
this.state.party[slot].species,
|
|
);
|
|
if (spawn < 0) return false;
|
|
result = this.host.flyTo(this.state, slot, spawn);
|
|
}
|
|
if ("script" in result) {
|
|
this.deferred = result.script;
|
|
this.deferredMove = result;
|
|
return true;
|
|
}
|
|
if ("notice" in result) yield* this.notice(result.notice);
|
|
else if ("texts" in result)
|
|
for (const text of result.texts) yield* this.print(text);
|
|
return false;
|
|
}
|
|
|
|
/** MonMenu_Softboiled_MilkDrink: the user needs more than a fifth of
|
|
* its maximum HP, and gives that much to another Pokémon that has not
|
|
* fainted and is not at full HP. */
|
|
private *softboiledMilkDrink(user: number): Gen {
|
|
const party = this.state.party,
|
|
source = party[user];
|
|
const fifth = Math.floor(source.stats[0] / 5);
|
|
if (source.hp <= fifth) {
|
|
yield* this.print(MENU["MonMenu_Softboiled_MilkDrink.PokemonNotEnoughHPText"]);
|
|
return;
|
|
}
|
|
for (;;) {
|
|
const target = yield* this.partyMenu(PARTYMENUACTION_HEALING_ITEM);
|
|
if (target < 0) break;
|
|
const mon = party[target];
|
|
if (target === user || !mon.hp || mon.hp >= mon.stats[0]) {
|
|
yield* this.print(
|
|
OVERWORLD["Softboiled_MilkDrinkFunction.ItemCantUseOnMonText"],
|
|
);
|
|
continue;
|
|
}
|
|
source.hp = Math.max(0, source.hp - fifth);
|
|
this.setHP(mon, restoreHealth(mon.hp, mon.stats[0], fifth));
|
|
yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP);
|
|
break;
|
|
}
|
|
// .skip: the cursor returns to the user.
|
|
this.cursors.party = user + 1;
|
|
}
|
|
|
|
/** SwitchPartyMons and _SwitchPartyMons: "Move to where?" without a
|
|
* CANCEL row (B still cancels); the two Pokémon trade places. */
|
|
private *switchPartyMons(from: number): Gen {
|
|
const party = this.state.party;
|
|
if (party.length < 2) return;
|
|
const to = yield* this.partyMenu(PARTYMENUACTION_MOVE, from);
|
|
if (to < 0 || to === from) return;
|
|
[party[from], party[to]] = [party[to], party[from]];
|
|
}
|
|
|
|
/** GiveTakePartyMonItem: GIVE opens the pack, TAKE returns the item. */
|
|
private *giveTakePartyMonItem(slot: number): Gen {
|
|
const mon = this.state.party[slot];
|
|
if (mon.species === EGG) return;
|
|
const choice = yield* this.menu(
|
|
[mon.nickname],
|
|
this.stringList(MENU["GiveTakeItemMenuData.Items"] + 2, 2),
|
|
);
|
|
if (choice < 0) return;
|
|
this.nameMon(slot);
|
|
if (choice === 1) return yield* this.takePartyItem(slot);
|
|
// GiveTakePartyMonItem.GiveItem: DepositSellPack until an item is
|
|
// chosen that a Pokémon can hold, or B.
|
|
for (;;) {
|
|
const chosen = yield* this.choosePackItem();
|
|
if (!chosen) return;
|
|
const { pocket, index, item } = chosen;
|
|
if (pocket === KEY_ITEM_POCKET || this.data.itemUnsellable(item)) {
|
|
yield* this.print(MENU.ItemCantHeldText);
|
|
continue;
|
|
}
|
|
return yield* this.tryGiveItemToPartymon(slot, pocket, index, item);
|
|
}
|
|
}
|
|
|
|
/** TryGiveItemToPartymon: hold the item, or offer to swap it for the one
|
|
* held. GiveItemToPokemon tosses one from the pack slot it came from. */
|
|
private *tryGiveItemToPartymon(
|
|
slot: number,
|
|
pocket: PocketIndex,
|
|
index: number,
|
|
item: number,
|
|
): Gen {
|
|
const mon = this.state.party[slot];
|
|
this.nameItem(item);
|
|
const held = mon.item;
|
|
if (!held) {
|
|
this.giveItemToPokemon(pocket, index, item);
|
|
yield* this.print(MENU.PokemonHoldItemText);
|
|
return yield* this.givePartyItem(slot, item);
|
|
}
|
|
if (this.isMail(held)) return yield* this.print(MENU.PokemonRemoveMailText);
|
|
this.strings[RAM.stringBuffer1] = this.data.itemName(held);
|
|
if (!(yield* this.yesNo(MENU.PokemonAskSwapItemText))) return;
|
|
this.giveItemToPokemon(pocket, index, item);
|
|
if (!receiveItem(this.data, this.state.bag, held, 1)) {
|
|
receiveItem(this.data, this.state.bag, item, 1);
|
|
return yield* this.print(MENU.ItemStorageFullText);
|
|
}
|
|
yield* this.print(MENU.PokemonSwapItemText);
|
|
yield* this.givePartyItem(slot, item);
|
|
}
|
|
/** GivePartyItem: the Pokémon holds the item, and MAIL gets its message
|
|
* (ComposeMailMessage). */
|
|
private *givePartyItem(slot: number, item: number): Gen {
|
|
const mon = this.state.party[slot];
|
|
mon.item = item;
|
|
if (!this.isMail(item)) return;
|
|
// _ComposeMailMessage's entry, then the author (wPlayerName), wPlayerID,
|
|
// wCurPartySpecies and the MAIL (wCurItem).
|
|
// The mail entry has no title above its two lines.
|
|
const message = yield {
|
|
kind: "name",
|
|
pages: [""],
|
|
current: "",
|
|
max: MAIL_LINE_LENGTH,
|
|
mail: true,
|
|
};
|
|
if (!validMailMessage(message)) throw new Error("Invalid MAIL message.");
|
|
mon.mail = {
|
|
message,
|
|
author: this.text().player,
|
|
authorId: this.host.scriptHost().player().id,
|
|
species: mon.species,
|
|
type: item,
|
|
};
|
|
}
|
|
|
|
/** MonMailAction: READ, TAKE or QUIT for a Pokémon holding MAIL. TAKE
|
|
* sends the MAIL to the PC (SendMailToPC), or puts the item in the pack
|
|
* once its message may be lost. */
|
|
private *monMailAction(slot: number): Gen {
|
|
const mon = this.state.party[slot];
|
|
const choice = yield* this.menu(
|
|
[],
|
|
this.stringList(MENU["MonMailAction.MenuData"] + 2, 3),
|
|
);
|
|
if (choice === 0) return yield* this.readMail(mon.mail);
|
|
if (choice !== 1) return;
|
|
if (yield* this.yesNo(MENU["MonMailAction.MailAskSendToPCText"])) {
|
|
if (!mon.mail || this.state.mailbox.length >= MAILBOX_CAPACITY)
|
|
return yield* this.print(MENU["MonMailAction.MailboxFullText"]);
|
|
this.state.mailbox.push(mon.mail);
|
|
delete mon.mail;
|
|
mon.item = 0;
|
|
return yield* this.print(MENU["MonMailAction.MailSentToPCText"]);
|
|
}
|
|
// .RemoveMailToBag
|
|
if (!(yield* this.yesNo(MENU["MonMailAction.MailLoseMessageText"]))) return;
|
|
if (!receiveItem(this.data, this.state.bag, mon.item, 1))
|
|
return yield* this.print(MENU["MonMailAction.MailNoSpaceText"]);
|
|
mon.item = 0;
|
|
delete mon.mail;
|
|
this.strings[RAM.stringBuffer1] = mon.nickname;
|
|
yield* this.print(MENU["MonMailAction.MailDetachedText"]);
|
|
}
|
|
/** ReadPartyMonMail: the letter, until a button. */
|
|
private *readMail(mail: Mail | undefined): Gen {
|
|
if (!mail) throw new Error("The Pokémon holds MAIL without a message.");
|
|
yield { kind: "mail", mail: { ...mail } };
|
|
}
|
|
private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) {
|
|
const list = this.pocketSlots(pocket);
|
|
if (!list || !removeFromSlot(list, index, item, 1))
|
|
throw new Error("The item is not in the pack.");
|
|
}
|
|
|
|
/** TakePartyItem: the held item goes back to the pack. */
|
|
private *takePartyItem(slot: number): Gen {
|
|
const mon = this.state.party[slot];
|
|
if (!mon.item) return yield* this.print(MENU.PokemonNotHoldingText);
|
|
if (!receiveItem(this.data, this.state.bag, mon.item, 1))
|
|
return yield* this.print(MENU.ItemStorageFullText);
|
|
this.strings[RAM.stringBuffer1] = this.data.itemName(mon.item);
|
|
mon.item = 0;
|
|
yield* this.print(MENU.PokemonTookItemText);
|
|
}
|
|
|
|
/** ManagePokemonMoves (MoveScreenLoop): choose a move, then "Where?";
|
|
* the moves and their PP trade places. Left and right show the previous
|
|
* or next party member's moves, skipping Eggs, unless a move is being
|
|
* moved. */
|
|
private *managePokemonMoves(slot: number): Gen {
|
|
if (this.state.party[slot].species === EGG) return;
|
|
const where = this.string(MENU.String_MoveWhere);
|
|
let cursor = 0;
|
|
for (;;) {
|
|
// MoveScreenLoop puts the party menu cursor on wCurPartyMon.
|
|
this.cursors.party = slot + 1;
|
|
const mon = this.state.party[slot];
|
|
const count = mon.moves.filter(Boolean).length;
|
|
const names = mon.moves.slice(0, count).map((move) => this.data.moveName(move));
|
|
const details = mon.moves
|
|
.slice(0, count)
|
|
.map(
|
|
(move, index) =>
|
|
`PP ${mon.pp[index] & C.PP_MASK}/${maxPP(this.data, move, mon.pp[index] >> 6) & C.PP_MASK}`,
|
|
);
|
|
const heading = `${mon.nickname} :L${mon.level}`;
|
|
const answer = yield {
|
|
kind: "menu",
|
|
pages: [heading],
|
|
items: names,
|
|
details,
|
|
selected: cursor,
|
|
sides: true,
|
|
};
|
|
if (answer === "left" || answer === "right") {
|
|
const next = this.cycleMoveScreen(slot, answer);
|
|
// Load2DMenuData puts the cursor back on the first move.
|
|
if (next !== slot) [slot, cursor] = [next, 0];
|
|
continue;
|
|
}
|
|
const first =
|
|
typeof answer === "number" &&
|
|
Number.isInteger(answer) &&
|
|
answer >= 0 &&
|
|
answer < names.length
|
|
? answer
|
|
: -1;
|
|
if (first < 0) return;
|
|
const second = yield* this.menu([where], names, details, first);
|
|
if (second < 0) {
|
|
cursor = first;
|
|
continue;
|
|
}
|
|
for (const list of [mon.moves, mon.pp])
|
|
[list[first], list[second]] = [list[second], list[first]];
|
|
cursor = second;
|
|
}
|
|
}
|
|
|
|
/** MoveScreenLoop's .cycle_right and .cycle_left: the next party member
|
|
* that is not an Egg; past the last one, back to the last that is not.
|
|
* The original's routines call each other, so a step can come back to
|
|
* where it started. */
|
|
private cycleMoveScreen(slot: number, direction: "left" | "right") {
|
|
const party = this.state.party;
|
|
let current = slot;
|
|
const egg = (index: number) => party[index].species === EGG;
|
|
let step: "left" | "right" | "done" = direction;
|
|
for (let guard = 0; step !== "done" && guard < 64; guard++) {
|
|
if (step === "right") {
|
|
current++;
|
|
if (current >= party.length) step = "left";
|
|
else if (!egg(current)) step = "done";
|
|
} else {
|
|
// .cycle_left
|
|
if (current === 0) break;
|
|
current--;
|
|
if (!egg(current)) step = "done";
|
|
else if (current === 0) step = "right";
|
|
}
|
|
}
|
|
return current;
|
|
}
|
|
|
|
// --------------------------------------------------------------- pack
|
|
|
|
private pocketSlots(pocket: PocketIndex): ItemSlot[] | undefined {
|
|
const bag = this.state.bag;
|
|
return pocket === 0 ? bag.items : pocket === 1 ? bag.balls : undefined;
|
|
}
|
|
/** A pocket's list: quantities for items and balls, none for key items,
|
|
* and no TMs or HMs yet. */
|
|
private pocketEntries(pocket: PocketIndex): MartEntry[] {
|
|
const bag = this.state.bag;
|
|
const slots: ItemSlot[] =
|
|
pocket === KEY_ITEM_POCKET
|
|
? bag.keyItems.map((item) => ({ item, quantity: 1 }))
|
|
: (this.pocketSlots(pocket) ?? []);
|
|
return slots.map(({ item, quantity }) => ({
|
|
item,
|
|
name: this.data.itemName(item),
|
|
price: 0,
|
|
...(pocket !== KEY_ITEM_POCKET && { owned: quantity }),
|
|
}));
|
|
}
|
|
/** The pack's pockets from wLastPocket; the chosen pocket, index and
|
|
* item, or undefined for B. With `reorder` (the start menu's PACK,
|
|
* Pack_InterpretJoypad), Select moves items within a pocket. */
|
|
private *choosePackItem(
|
|
reorder = false,
|
|
): Gen<{ pocket: PocketIndex; index: number; item: number } | undefined> {
|
|
let selected = 0;
|
|
/** wSwitchItem: the entry being moved, if any. */
|
|
let switching: number | undefined;
|
|
for (;;) {
|
|
const answer = yield {
|
|
kind: "pack",
|
|
pockets: POCKETS.map((pocket, index) => ({
|
|
name: this.data.pocketName(pocket),
|
|
entries: this.pocketEntries(index as PocketIndex),
|
|
})),
|
|
pocket: this.cursors.pocket,
|
|
selected,
|
|
...(switching !== undefined && {
|
|
switching,
|
|
prompt: readText(this.reader, MENU.AskItemMoveText, this.text()),
|
|
}),
|
|
};
|
|
const select = typeof answer === "string" && /^select:\d+$/.test(answer);
|
|
// DepositSellPack's joypad ignores Select.
|
|
if (select && !reorder) continue;
|
|
const value = select ? Number((answer as string).slice(7)) : answer;
|
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
// .switching_item: B ends the move and stays in the pack.
|
|
if (switching === undefined) return undefined;
|
|
switching = undefined;
|
|
continue;
|
|
}
|
|
const pocket = (value >> 8) as PocketIndex,
|
|
index = value & 0xff;
|
|
if (pocket > TM_HM_POCKET) continue;
|
|
selected = index;
|
|
if (switching !== undefined) {
|
|
// .place_insert: A or Select on the destination.
|
|
if (pocket === this.cursors.pocket)
|
|
this.switchItems(pocket, switching, index);
|
|
switching = undefined;
|
|
continue;
|
|
}
|
|
this.cursors.pocket = pocket;
|
|
if (select) {
|
|
// SwitchItemsInBag's .init; the TM pocket has its own joypad.
|
|
if (pocket !== TM_HM_POCKET) switching = index;
|
|
continue;
|
|
}
|
|
const item = this.pocketEntries(pocket)[index]?.item;
|
|
if (item !== undefined) return { pocket, index, item };
|
|
}
|
|
}
|
|
/** SwitchItemsInBag placing the entry at `from` on `to`: the same item
|
|
* merges into the destination's stack unless either holds 99 (the rest
|
|
* stays behind when they add up to more); otherwise the entry moves to
|
|
* the destination's row and the ones between shift. */
|
|
private switchItems(pocket: PocketIndex, from: number, to: number) {
|
|
if (from === to) return;
|
|
const list: (ItemSlot | number)[] | undefined =
|
|
pocket === KEY_ITEM_POCKET
|
|
? this.state.bag.keyItems
|
|
: this.pocketSlots(pocket);
|
|
if (!list || from >= list.length || to >= list.length) return;
|
|
const source = list[from],
|
|
target = list[to];
|
|
if (
|
|
typeof source === "object" &&
|
|
typeof target === "object" &&
|
|
source.item === target.item &&
|
|
source.quantity !== MAX_ITEM_STACK &&
|
|
target.quantity !== MAX_ITEM_STACK
|
|
) {
|
|
const total = source.quantity + target.quantity;
|
|
target.quantity = Math.min(total, MAX_ITEM_STACK);
|
|
if (total > MAX_ITEM_STACK) source.quantity = total - MAX_ITEM_STACK;
|
|
else list.splice(from, 1);
|
|
return;
|
|
}
|
|
list.splice(to, 0, ...list.splice(from, 1));
|
|
}
|
|
|
|
/** StartMenu_Pack (Pack): returns true when an item closes the menus to
|
|
* run a script (PACKSTATE_QUITRUNSCRIPT). */
|
|
private *pack(): Gen<boolean> {
|
|
for (;;) {
|
|
const chosen = yield* this.choosePackItem(true);
|
|
if (!chosen) return false;
|
|
if (yield* this.itemSubmenu(chosen.pocket, chosen.index, chosen.item))
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/** Pack.ItemBallsKey_LoadSubmenu: the submenu the item's permissions and
|
|
* field menu select. */
|
|
private *itemSubmenu(
|
|
pocket: PocketIndex,
|
|
index: number,
|
|
item: number,
|
|
): Gen<boolean> {
|
|
const data = this.data;
|
|
const usable = data.itemFieldMenu(item) !== 0;
|
|
const label = data.itemUnsellable(item)
|
|
? data.itemUnselectable(item)
|
|
? "MenuHeader_UnusableItem.MenuData"
|
|
: "MenuHeader_UnusableKeyItem.MenuData"
|
|
: data.itemUnselectable(item)
|
|
? usable
|
|
? "MenuHeader_UsableItem.MenuData"
|
|
: "MenuHeader_HoldableItem.MenuData"
|
|
: usable
|
|
? "MenuHeader_UsableKeyItem.MenuData"
|
|
: "MenuHeader_HoldableKeyItem.MenuData";
|
|
const actions = ITEM_MENUS[label];
|
|
const choice = yield* this.menu(
|
|
[data.itemName(item)],
|
|
this.stringList(MENU[label as keyof typeof MENU] + 2, actions.length),
|
|
);
|
|
switch (choice < 0 ? "quit" : actions[choice]) {
|
|
case "use":
|
|
return yield* this.useItem(index, item);
|
|
case "give":
|
|
yield* this.giveItem(pocket, index, item);
|
|
return false;
|
|
case "toss":
|
|
yield* this.tossMenu(pocket, index, item);
|
|
return false;
|
|
case "sel":
|
|
yield* this.registerItem(pocket, index, item);
|
|
return false;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** TossMenu: how many, confirm, then TossItem from the chosen slot. */
|
|
private *tossMenu(pocket: PocketIndex, index: number, item: number): Gen {
|
|
const list = this.pocketSlots(pocket);
|
|
const slot = list?.[index];
|
|
if (!list || slot?.item !== item) return;
|
|
this.nameItem(item);
|
|
const quantity = yield {
|
|
kind: "martquantity",
|
|
mode: "toss",
|
|
item,
|
|
name: this.data.itemName(item),
|
|
price: 0,
|
|
max: slot.quantity,
|
|
prompt: readText(this.reader, MENU.AskThrowAwayText, this.text()),
|
|
selected: 1,
|
|
};
|
|
if (
|
|
typeof quantity !== "number" ||
|
|
!Number.isInteger(quantity) ||
|
|
quantity < 1 ||
|
|
quantity > slot.quantity
|
|
)
|
|
return;
|
|
this.nameItem(item);
|
|
this.numbers[RAM.itemQuantityChange] = quantity;
|
|
if (!(yield* this.yesNo(MENU.AskQuantityThrowAwayText))) return;
|
|
removeFromSlot(list, index, item, quantity);
|
|
this.nameItem(item);
|
|
yield* this.print(MENU.ThrewAwayText);
|
|
}
|
|
|
|
/** The pack's GiveItem: "To which PKMN?" until a Pokémon that is not an
|
|
* Egg is chosen, or B. */
|
|
private *giveItem(pocket: PocketIndex, index: number, item: number): Gen {
|
|
if (!this.state.party.length)
|
|
return yield* this.print(MENU.YouDontHaveAMonText);
|
|
for (;;) {
|
|
const slot = yield* this.partyMenu(PARTYMENUACTION_GIVE_ITEM);
|
|
if (slot < 0) return;
|
|
if (this.state.party[slot].species === EGG) {
|
|
yield* this.print(MENU["GiveItem.AnEggCantHoldAnItemText"]);
|
|
continue;
|
|
}
|
|
this.nameMon(slot);
|
|
return yield* this.tryGiveItemToPartymon(slot, pocket, index, item);
|
|
}
|
|
}
|
|
|
|
/** UseItem: the field menu decides. Returns true when the pack quits to
|
|
* run the script an ITEMMENU_CLOSE item queued. */
|
|
private *useItem(index: number, item: number): Gen<boolean> {
|
|
const menu = this.data.itemFieldMenu(item);
|
|
if (menu < ITEMMENU_CURRENT) {
|
|
yield* this.print(BATTLE.OakThisIsntTheTimeText);
|
|
return false;
|
|
}
|
|
if (menu === ITEMMENU_PARTY && !this.state.party.length) {
|
|
yield* this.print(MENU.YouDontHaveAMonText);
|
|
return false;
|
|
}
|
|
const used = yield* this.doItemEffect(index, item);
|
|
if (menu !== ITEMMENU_CLOSE) return false;
|
|
if (used === false) yield* this.print(BATTLE.OakThisIsntTheTimeText);
|
|
return used === true;
|
|
}
|
|
|
|
/** _DoItemEffect: ItemEffects by ROM address. Returns
|
|
* wItemEffectSucceeded, or "unported" after a notice. */
|
|
private *doItemEffect(
|
|
index: number,
|
|
item: number,
|
|
): Gen<boolean | "unported"> {
|
|
const r = this.reader;
|
|
this.nameItem(item);
|
|
const effect = r.near(
|
|
BATTLE.ItemEffects + (item - 1) * 2,
|
|
r.bank(BATTLE.ItemEffects),
|
|
);
|
|
const use = { item, index };
|
|
switch (effect) {
|
|
case BATTLE.RestoreHPEffect:
|
|
return yield* this.statusHealerResult(yield* this.itemRestoreHP(use));
|
|
case BATTLE.EnergypowderEffect:
|
|
case BATTLE.EnergyRootEffect: {
|
|
const code = yield* this.itemRestoreHP(use);
|
|
if (!code)
|
|
yield* this.looksBitter(
|
|
effect === BATTLE.EnergyRootEffect
|
|
? C.HAPPINESS_ENERGYROOT
|
|
: C.HAPPINESS_BITTERPOWDER,
|
|
);
|
|
return yield* this.statusHealerResult(code);
|
|
}
|
|
case BATTLE.StatusHealingEffect: {
|
|
const target = yield* this.useItemSelectMon();
|
|
if (target < 0) return false;
|
|
return yield* this.statusHealerResult(
|
|
yield* this.useStatusHealer(use, target),
|
|
);
|
|
}
|
|
case BATTLE.HealPowderEffect: {
|
|
const target = yield* this.useItemSelectMon();
|
|
if (target < 0) return false;
|
|
const code = yield* this.useStatusHealer(use, target);
|
|
if (!code) yield* this.looksBitter(C.HAPPINESS_BITTERPOWDER);
|
|
return yield* this.statusHealerResult(code);
|
|
}
|
|
case BATTLE.FullRestoreEffect:
|
|
return yield* this.fullRestoreEffect(use);
|
|
case BATTLE.ReviveEffect:
|
|
case BATTLE.RevivalHerbEffect: {
|
|
const target = yield* this.useItemSelectMon();
|
|
if (target < 0) return false;
|
|
const code = yield* this.revivePokemon(use, target);
|
|
if (!code && effect === BATTLE.RevivalHerbEffect)
|
|
yield* this.looksBitter(C.HAPPINESS_REVIVALHERB);
|
|
return yield* this.statusHealerResult(code);
|
|
}
|
|
case BATTLE.RestorePPEffect:
|
|
return yield* this.restorePPEffect(use);
|
|
case MENU.RareCandyEffect:
|
|
return yield* this.rareCandyEffect(use);
|
|
case MENU.VitaminEffect:
|
|
return yield* this.vitaminEffect(use);
|
|
case MENU.EvoStoneEffect:
|
|
return yield* this.evoStoneEffect(use);
|
|
case MENU.EscapeRopeEffect: {
|
|
// EscapeRopeEffect: UseDisposableItem once EscapeRopeOrDig queued
|
|
// its script; a failure prints nothing before Oak's message.
|
|
const result = this.host.escapeRope(this.state);
|
|
if ("notice" in result) {
|
|
yield* this.notice(result.notice);
|
|
return "unported";
|
|
}
|
|
if (!("script" in result)) return false;
|
|
this.useDisposableItem(use);
|
|
this.deferred = result.script;
|
|
this.deferredItem = item;
|
|
this.deferredMove = result;
|
|
return true;
|
|
}
|
|
case MENU.BicycleEffect: {
|
|
const script = this.host.bicycleScript(this.state, this.usingSelect);
|
|
if (script === undefined) return false;
|
|
this.deferred = script;
|
|
this.deferredItem = item;
|
|
return true;
|
|
}
|
|
case MENU.RepelEffect:
|
|
case MENU.SuperRepelEffect:
|
|
case MENU.MaxRepelEffect:
|
|
return yield* this.useRepel(
|
|
use,
|
|
effect === MENU.RepelEffect
|
|
? 100
|
|
: effect === MENU.SuperRepelEffect
|
|
? 200
|
|
: 250,
|
|
);
|
|
case MENU.CoinCaseEffect:
|
|
this.numbers[RAM.coins] = this.state.coins;
|
|
yield* this.print(MENU["CoinCaseEffect.CoinCaseCountText"]);
|
|
return true;
|
|
case MENU.BlueCardEffect:
|
|
this.numbers[RAM.blueCardBalance] = this.state.blueCardBalance;
|
|
yield* this.print(MENU["BlueCardEffect.BlueCardBalanceText"]);
|
|
return true;
|
|
case MENU.SacredAshEffect:
|
|
// _SacredAsh: SacredAshScript heals the party once the menu closes,
|
|
// if a Pokémon that is not an Egg has fainted (CheckAnyFaintedMon).
|
|
if (!this.state.party.some((mon) => mon.species !== EGG && !mon.hp))
|
|
return false;
|
|
this.useDisposableItem(use);
|
|
this.deferred = MENU.SacredAshScript;
|
|
this.deferredItem = item;
|
|
return true;
|
|
case MENU.OldRodEffect:
|
|
case MENU.GoodRodEffect:
|
|
case MENU.SuperRodEffect: {
|
|
// UseRod: FishFunction queues a script unless it fails.
|
|
const result = this.host.fish(
|
|
this.state,
|
|
effect === MENU.OldRodEffect ? 0 : effect === MENU.GoodRodEffect ? 1 : 2,
|
|
);
|
|
if (!("script" in result)) return false;
|
|
this.deferred = result.script;
|
|
this.deferredItem = item;
|
|
this.deferredMove = result;
|
|
return true;
|
|
}
|
|
case MENU.ItemfinderEffect:
|
|
// ItemFinder: one script or the other, and always a success.
|
|
this.deferred = this.host.hiddenItemOnScreen(this.state)
|
|
? OVERWORLD["ItemFinder.Script_FoundSomething"]
|
|
: OVERWORLD["ItemFinder.Script_FoundNothing"];
|
|
this.deferredItem = item;
|
|
return true;
|
|
case MENU.CardKeyEffect:
|
|
// _CardKey: facing up at the slot on Radio Tower 3F.
|
|
return this.useKey(
|
|
item,
|
|
RADIO_TOWER_3F,
|
|
{ x: 18, y: 6 },
|
|
OVERWORLD["_CardKey.CardKeyScript"],
|
|
FACING_UP,
|
|
);
|
|
case MENU.BasementKeyEffect:
|
|
// _BasementKey: facing the door in the Goldenrod Underground.
|
|
return this.useKey(
|
|
item,
|
|
GOLDENROD_UNDERGROUND,
|
|
{ x: 22, y: 10 },
|
|
OVERWORLD["_BasementKey.BasementKeyScript"],
|
|
);
|
|
case MENU.SquirtbottleEffect:
|
|
// _Squirtbottle: its script checks for Sudowoodo.
|
|
this.deferred = OVERWORLD["_Squirtbottle.SquirtbottleScript"];
|
|
this.deferredItem = item;
|
|
return true;
|
|
case MENU.NormalBoxEffect:
|
|
case MENU.GorgeousBoxEffect:
|
|
return yield* this.openBox(
|
|
use,
|
|
effect === MENU.NormalBoxEffect
|
|
? C.DECOFLAG_SILVER_TROPHY_DOLL
|
|
: C.DECOFLAG_GOLD_TROPHY_DOLL,
|
|
);
|
|
}
|
|
yield* this.notice(`Using ${this.data.itemName(item)} ${NOT_PORTED}`);
|
|
return "unported";
|
|
}
|
|
|
|
/** _CardKey and _BasementKey: the key's script runs once the menu closes
|
|
* if the player faces `tile` (in wPlayerMapX/Y coordinates, which count
|
|
* the map's border) on `mapId`, and `facing` if given. */
|
|
private useKey(
|
|
item: number,
|
|
mapId: string,
|
|
tile: { x: number; y: number },
|
|
script: number,
|
|
facing?: number,
|
|
): boolean {
|
|
const host = this.host.scriptHost(),
|
|
direction = host.facing(),
|
|
[dx, dy] = FACING_VECTORS[direction],
|
|
{ x, y } = host.position();
|
|
if (
|
|
host.mapId !== mapId ||
|
|
(facing !== undefined && direction !== facing) ||
|
|
x + dx + 4 !== tile.x ||
|
|
y + dy + 4 !== tile.y
|
|
)
|
|
return false;
|
|
this.deferred = script;
|
|
this.deferredItem = item;
|
|
return true;
|
|
}
|
|
/** UseRepel: a Repel for `steps` steps, unless one is in effect. */
|
|
private *useRepel(use: FieldItemUse, steps: number): Gen<boolean> {
|
|
if (this.state.repel) {
|
|
yield* this.print(MENU.RepelUsedEarlierIsStillInEffectText);
|
|
return false;
|
|
}
|
|
this.state.repel = steps;
|
|
yield* this.useItemText(use);
|
|
return true;
|
|
}
|
|
/** UseItemText: "<PLAYER> used the …", then UseDisposableItem. */
|
|
private *useItemText(use: FieldItemUse): Gen {
|
|
yield* this.print(BATTLE.ItemUsedText);
|
|
this.useDisposableItem(use);
|
|
}
|
|
/** OpenBox: SetSpecificDecorationFlag gives the trophy doll, which the
|
|
* player sends home. */
|
|
private *openBox(use: FieldItemUse, flag: number): Gen<boolean> {
|
|
const r = this.reader,
|
|
decoration = r.byte(OVERWORLD.DecorationIDs + flag);
|
|
this.state.events.add(
|
|
new DecorationData(r, this.data).attribute(decoration).event,
|
|
);
|
|
yield* this.print(MENU["OpenBox.SentTrophyHomeText"]);
|
|
this.useDisposableItem(use);
|
|
return true;
|
|
}
|
|
|
|
/** UseItem_SelectMon: the party menu for an item. The party index, or
|
|
* -1 when cancelled or an Egg was chosen. */
|
|
private *useItemSelectMon(
|
|
action: number = PARTYMENUACTION_HEALING_ITEM,
|
|
item = 0,
|
|
): Gen<number> {
|
|
const slot = yield* this.partyMenu(action, undefined, item);
|
|
if (slot < 0) return -1;
|
|
if (this.state.party[slot].species === EGG) {
|
|
yield* this.print(BATTLE.ItemCantUseOnEggText);
|
|
return -1;
|
|
}
|
|
return slot;
|
|
}
|
|
/** StatusHealer_Jumptable: 0 used, 1 "won't have any effect", 2 cancelled. */
|
|
private *statusHealerResult(code: number): Gen<boolean> {
|
|
if (code === 1) yield* this.print(BATTLE.ItemWontHaveEffectText);
|
|
return code === 0;
|
|
}
|
|
/** ItemActionTextWaitButton: PrintPartyMenuActionText for the Pokémon. */
|
|
private *itemActionText(target: number, text: number): Gen {
|
|
this.strings[RAM.stringBuffer1] = this.state.party[target].nickname;
|
|
const r = this.reader,
|
|
table = BATTLE["PrintPartyMenuActionText.MenuActionTexts"];
|
|
yield* this.print(r.near(table + (text & 0x0f) * 2, r.bank(table)));
|
|
}
|
|
/** UseDisposableItem: one from the Items pocket slot it was used from. */
|
|
private useDisposableItem({ item, index }: FieldItemUse) {
|
|
if (!removeFromSlot(this.state.bag.items, index, item, 1))
|
|
throw new Error("The item is not in the pack.");
|
|
}
|
|
/** ChangeHappiness outside battle, then LooksBitterMessage. */
|
|
private *looksBitter(action: number) {
|
|
this.changeHappiness(this.cursors.party - 1, action);
|
|
yield* this.print(BATTLE.ItemLooksBitterText);
|
|
}
|
|
private changeHappiness(slot: number, action: number) {
|
|
const mon = this.state.party[slot];
|
|
if (mon.species === EGG) return;
|
|
const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
|
|
mon.happiness = changedHappiness(
|
|
mon.happiness,
|
|
this.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier),
|
|
);
|
|
}
|
|
/** The new HP, with HealHP_SFX_GFX's delta for the action text. */
|
|
private setHP(mon: PartyMon, hp: number) {
|
|
this.numbers[RAM.curHPAnimDeltaHP] = hp - mon.hp;
|
|
mon.hp = hp;
|
|
}
|
|
|
|
/** ItemRestoreHP: HealingHPAmounts up to the maximum HP. */
|
|
private *itemRestoreHP(use: FieldItemUse): Gen<number> {
|
|
const target = yield* this.useItemSelectMon();
|
|
if (target < 0) return 2;
|
|
const mon = this.state.party[target];
|
|
if (!mon.hp || mon.hp >= mon.stats[0]) return 1;
|
|
const r = this.reader;
|
|
let amount: number | undefined;
|
|
for (let p = BATTLE.HealingHPAmounts; r.byte(p) !== 0xff; p += 3)
|
|
if (r.byte(p) === use.item) {
|
|
amount = r.word(p + 1);
|
|
break;
|
|
}
|
|
if (amount === undefined) throw new Error("Missing healing amount.");
|
|
this.setHP(mon, restoreHealth(mon.hp, mon.stats[0], amount));
|
|
yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP);
|
|
this.useDisposableItem(use);
|
|
return 0;
|
|
}
|
|
/** GetItemHealingAction: StatusHealingActions' text and status mask. */
|
|
private healingAction(item: number) {
|
|
const r = this.reader;
|
|
let p = BATTLE.StatusHealingActions;
|
|
while (r.byte(p) !== item) {
|
|
if (r.byte(p) === 0xff) throw new Error("Missing status healing action.");
|
|
p += 3;
|
|
}
|
|
return { text: r.byte(p + 1), mask: r.byte(p + 2) };
|
|
}
|
|
/** UseStatusHealer outside battle: 0 if used, 1 if it had no effect. */
|
|
private *useStatusHealer(use: FieldItemUse, target: number): Gen<number> {
|
|
const mon = this.state.party[target];
|
|
if (!mon.hp) return 1;
|
|
const { text, mask } = this.healingAction(use.item);
|
|
if (!(mon.status & mask)) return 1;
|
|
mon.status = 0;
|
|
yield* this.itemActionText(target, text);
|
|
this.useDisposableItem(use);
|
|
return 0;
|
|
}
|
|
/** FullRestoreEffect: full HP and no status; at full HP only the status. */
|
|
private *fullRestoreEffect(use: FieldItemUse): Gen<boolean> {
|
|
const target = yield* this.useItemSelectMon();
|
|
if (target < 0) return false;
|
|
const mon = this.state.party[target];
|
|
if (!mon.hp) return yield* this.statusHealerResult(1);
|
|
if (mon.hp >= mon.stats[0])
|
|
return yield* this.statusHealerResult(
|
|
yield* this.useStatusHealer(use, target),
|
|
);
|
|
this.setHP(mon, mon.stats[0]);
|
|
mon.status = 0;
|
|
yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP);
|
|
this.useDisposableItem(use);
|
|
return true;
|
|
}
|
|
/** RevivePokemon: a Revive restores half the maximum HP, the others all
|
|
* of it. 0 if used, 1 if the Pokémon had not fainted. */
|
|
private *revivePokemon(use: FieldItemUse, target: number): Gen<number> {
|
|
const mon = this.state.party[target];
|
|
if (mon.hp) return 1;
|
|
this.setHP(mon, use.item === C.REVIVE ? mon.stats[0] >> 1 : mon.stats[0]);
|
|
yield* this.itemActionText(target, C.PARTYMENUTEXT_REVIVE);
|
|
this.useDisposableItem(use);
|
|
return 0;
|
|
}
|
|
|
|
/** Evolution and move learning on this menu's draft. */
|
|
private evolution() {
|
|
return new PartyEvolution(this.state, this.host);
|
|
}
|
|
|
|
/** RareCandyEffect: one level at the level's minimum experience, the
|
|
* stats (and HP, even of a fainted Pokémon) raised, the level-up
|
|
* happiness, new moves and a possible evolution. */
|
|
private *rareCandyEffect(use: FieldItemUse): Gen<boolean> {
|
|
const data = this.data;
|
|
const target = yield* this.useItemSelectMon();
|
|
if (target < 0) return false;
|
|
const mon = this.state.party[target];
|
|
this.strings[RAM.stringBuffer1] = mon.nickname;
|
|
if (mon.level >= C.MAX_LEVEL) {
|
|
yield* this.print(BATTLE.ItemWontHaveEffectText);
|
|
return false;
|
|
}
|
|
const level = ++mon.level;
|
|
mon.exp = data.expAtLevel(data.baseData(mon.species).growthRate, level);
|
|
const oldMax = mon.stats[0];
|
|
mon.stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp);
|
|
mon.hp = (mon.hp + mon.stats[0] - oldMax) & 0xffff;
|
|
// LevelUpHappinessMod: more on the map where it was caught.
|
|
this.changeHappiness(
|
|
target,
|
|
(mon.caughtData & 0x7f) === this.host.landmark()
|
|
? C.HAPPINESS_GAINLEVELATHOME
|
|
: C.HAPPINESS_GAINLEVEL,
|
|
);
|
|
this.numbers[RAM.curPartyLevel] = level;
|
|
yield* this.itemActionText(target, C.PARTYMENUTEXT_LEVEL_UP);
|
|
// PrintTempMonStats: Attack, Defense, Sp. Atk, Sp. Def and Speed.
|
|
const labels = readString(
|
|
this.reader,
|
|
BATTLE["PrintTempMonStats.StatNames"],
|
|
this.text(),
|
|
64,
|
|
)
|
|
.split("\n")
|
|
.filter(Boolean);
|
|
const values = [1, 2, 4, 5, 3].map((stat) => mon.stats[stat]);
|
|
yield {
|
|
kind: "text",
|
|
pages: [labels.map((label, i) => `${label} ${values[i]}`).join("\n")],
|
|
};
|
|
const evolution = this.evolution();
|
|
yield* evolution.learnLevelMoves(target, level);
|
|
// EvolvePokemon, not forced: a level or happiness evolution.
|
|
yield* evolution.evolveAfterBattle(1 << target, false, use.item);
|
|
this.useDisposableItem(use);
|
|
return true;
|
|
}
|
|
|
|
/** VitaminEffect: ten more in the high byte of a stat's experience, up
|
|
* to 100, then CalcMonStats (the current HP stays). */
|
|
private *vitaminEffect(use: FieldItemUse): Gen<boolean> {
|
|
const r = this.reader;
|
|
const target = yield* this.useItemSelectMon();
|
|
if (target < 0) return false;
|
|
const mon = this.state.party[target];
|
|
this.strings[RAM.stringBuffer1] = mon.nickname;
|
|
// GetStatExpRelativePointer: the stat experience word for the item.
|
|
let p = MENU.StatExpItemPointerOffsets;
|
|
while (r.byte(p) !== use.item) p += 2;
|
|
const offset = r.byte(p + 1),
|
|
stat = offset >> 1;
|
|
if (mon.statExp[stat] >> 8 >= 100) {
|
|
yield* this.print(BATTLE.ItemWontHaveEffectText);
|
|
return false;
|
|
}
|
|
mon.statExp[stat] += 10 << 8;
|
|
mon.stats = calcStats(this.data, mon.species, mon.dvs, mon.level, mon.statExp);
|
|
const entry = MENU.StatStrings + offset;
|
|
this.strings[RAM.stringBuffer2] = this.string(r.near(entry, r.bank(entry)));
|
|
yield* this.print(MENU.ItemStatRoseText);
|
|
this.changeHappiness(target, C.HAPPINESS_USEDITEM);
|
|
this.useDisposableItem(use);
|
|
return true;
|
|
}
|
|
|
|
/** EvoStoneEffect: a forced EvolvePokemon with the stone as wCurItem.
|
|
* An Everstone or no matching evolution has no effect. */
|
|
private *evoStoneEffect(use: FieldItemUse): Gen<boolean> {
|
|
const target = yield* this.useItemSelectMon(
|
|
C.PARTYMENUACTION_EVO_STONE,
|
|
use.item,
|
|
);
|
|
if (target < 0) return false;
|
|
if (this.state.party[target].item === C.EVERSTONE) {
|
|
yield* this.print(BATTLE.ItemWontHaveEffectText);
|
|
return false;
|
|
}
|
|
if (
|
|
!(yield* this.evolution().evolveAfterBattle(1 << target, true, use.item))
|
|
) {
|
|
yield* this.print(BATTLE.ItemWontHaveEffectText);
|
|
return false;
|
|
}
|
|
this.useDisposableItem(use);
|
|
return true;
|
|
}
|
|
|
|
/** RestorePPEffect for Ethers, Elixers, the MysteryBerry and PP Up. B on
|
|
* the move list goes back to the party menu. */
|
|
private *restorePPEffect(use: FieldItemUse): Gen<boolean> {
|
|
const data = this.data,
|
|
item = use.item;
|
|
for (;;) {
|
|
const target = yield* this.useItemSelectMon();
|
|
if (target < 0) return false;
|
|
const mon = this.state.party[target];
|
|
if (item === C.ELIXER || item === C.MAX_ELIXER) {
|
|
// Elixer_RestorePPofAllMoves
|
|
let restored = false;
|
|
for (let slot = 0; slot < 4; slot++)
|
|
if (mon.moves[slot] && this.restorePP(mon, slot, item)) restored = true;
|
|
if (!restored) return yield* this.statusHealerResult(1);
|
|
yield* this.print(BATTLE.PPRestoredText);
|
|
this.useDisposableItem(use);
|
|
return true;
|
|
}
|
|
const ask =
|
|
item === PP_UP
|
|
? MENU.RaiseThePPOfWhichMoveText
|
|
: BATTLE.RestoreThePPOfWhichMoveText;
|
|
for (;;) {
|
|
// MoveSelectionScreen with wMoveSelectionMenuType 2.
|
|
const count = mon.moves.filter(Boolean).length;
|
|
const move = yield* this.menu(
|
|
this.pages(ask),
|
|
mon.moves.slice(0, count).map((id) => data.moveName(id)),
|
|
mon.moves
|
|
.slice(0, count)
|
|
.map((id, slot) => `PP ${mon.pp[slot] & C.PP_MASK}/${maxPP(data, id, mon.pp[slot] >> 6) & C.PP_MASK}`),
|
|
);
|
|
if (move < 0) break;
|
|
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] =
|
|
data.moveName(mon.moves[move]);
|
|
if (item !== PP_UP) {
|
|
if (!this.restorePP(mon, move, item))
|
|
return yield* this.statusHealerResult(1);
|
|
yield* this.print(BATTLE.PPRestoredText);
|
|
this.useDisposableItem(use);
|
|
return true;
|
|
}
|
|
if (mon.moves[move] === SKETCH || mon.pp[move] >= PP_UP_MASK) {
|
|
yield* this.print(MENU.PPIsMaxedOutText);
|
|
continue;
|
|
}
|
|
// ApplyPPUp: wUsePPUp shares wTempPP's byte, so ComputeMaxPP adds
|
|
// a single step (a fifth of the base PP, at most 7) to the PP.
|
|
const base = data.move(mon.moves[move]).pp;
|
|
mon.pp[move] =
|
|
(mon.pp[move] + PP_UP_ONE + Math.min(Math.floor(base / 5), 7)) & 0xff;
|
|
yield* this.print(MENU.PPsIncreasedText);
|
|
this.useDisposableItem(use);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
/** RestorePP up to GetMaxPPOfMove; false if the PP was already full. */
|
|
private restorePP(mon: PartyMon, slot: number, item: number) {
|
|
const max =
|
|
maxPP(this.data, mon.moves[slot], mon.pp[slot] >> 6) & C.PP_MASK;
|
|
const pp = restoredPP(mon.pp[slot], max, item);
|
|
if (pp === undefined) return false;
|
|
mon.pp[slot] = pp;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/** An item used from a pack slot (wCurItem and wCurItemQuantity). */
|
|
interface FieldItemUse {
|
|
item: number;
|
|
index: number;
|
|
}
|
|
|
|
const STATUS_NAMES: [number, string][] = [
|
|
[0x08, " PSN"],
|
|
[0x10, " BRN"],
|
|
[0x20, " FRZ"],
|
|
[0x40, " PAR"],
|
|
];
|
|
function statusName(status: number) {
|
|
if (status & 7) return " SLP";
|
|
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
|
|
}
|