Port the start menu, the party menu and the pack outside battle
Checks / web (push) Canceled after 0s

Start opens the original start menu. POKéMON shows the party with the
Pokémon submenu (stats, switching, move order, held items); PACK shows the
four pockets with the field item effects for healing, PP and the Bicycle,
plus giving and tossing. The menu runs on a script draft, and its cursors
persist until a battle clears them. Select keeps the Key Items shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 16:10:22 +02:00
1 parent 60e06ae678
commit 1af2beacdc
18 files changed
+1918 -49

No files matched your search

+912
View File
@@ -0,0 +1,912 @@
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { BATTLE, ENGINE_FLAGS, MENU, 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 { maxPP, type PartyMon } from "../party/pokemon";
import {
copyScriptState,
type MartEntry,
type ScriptEffect,
type ScriptState,
} from "./interpreter";
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.";
/** 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;
}
/** 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): number | undefined;
}
/** 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 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;
constructor(
state: ScriptState,
private host: StartMenuHost,
cursors: MenuCursors = { start: 0, party: 0, pocket: 0 },
) {
this.state = copyScriptState(state);
this.cursors = { ...cursors };
this.iterator = 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) {
const r = this.reader;
for (let p = MENU.MailItems; r.byte(p) !== 0xff; p++)
if (r.byte(p) === item) return true;
return false;
}
// --------------------------------------------------------- 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 (on by default) describes the highlighted item.
items.map((item) => this.itemString(item, 2).replace("\n", " ")),
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_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}`);
}
}
}
// --------------------------------------------------------- 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): 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) =>
`: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;
yield* this.pokemonActionSubmenu(slot);
}
}
/** 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. */
private *pokemonActionSubmenu(slot: number): Gen {
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 };
return;
case MONMENUITEM_SWITCH:
return yield* this.switchPartyMons(slot);
case MONMENUITEM_ITEM:
return yield* this.giveTakePartyMonItem(slot);
case MONMENUITEM_MOVE:
return yield* this.managePokemonMoves(slot);
case MONMENUITEM_CANCEL:
return;
case MONMENUITEM_MAIL:
return yield* this.notice(`MAIL ${NOT_PORTED}`);
default:
return yield* this.notice(
`Using ${this.monMenuString(item)} from the menu ${NOT_PORTED}`,
);
}
}
/** 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];
// ComposeMailMessage follows a given MAIL; it is refused before
// anything changes.
if (this.isMail(item)) return yield* this.notice(`Writing MAIL ${NOT_PORTED}`);
this.nameItem(item);
const held = mon.item;
if (!held) {
this.giveItemToPokemon(pocket, index, item);
yield* this.print(MENU.PokemonHoldItemText);
mon.item = item;
return;
}
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);
mon.item = item;
}
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, which move to
* another party member's moves, are not offered. */
private *managePokemonMoves(slot: number): Gen {
const mon = this.state.party[slot];
if (mon.species === EGG) return;
const where = this.string(MENU.String_MoveWhere);
let cursor = 0;
for (;;) {
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 first = yield* this.menu([heading], names, details, cursor);
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;
}
}
// --------------------------------------------------------------- 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. */
private *choosePackItem(): Gen<
{ pocket: PocketIndex; index: number; item: 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: 0,
};
if (typeof answer !== "number" || !Number.isInteger(answer) || answer < 0)
return undefined;
const pocket = (answer >> 8) as PocketIndex,
index = answer & 0xff;
if (pocket > TM_HM_POCKET) continue;
this.cursors.pocket = pocket;
const item = this.pocketEntries(pocket)[index]?.item;
if (item !== undefined) return { pocket, index, item };
}
}
/** 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();
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.notice(`Registering items for SELECT ${NOT_PORTED}`);
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) yield* this.print(BATTLE.OakThisIsntTheTimeText);
return used;
}
/** _DoItemEffect: ItemEffects by ROM address. Returns
* wItemEffectSucceeded. */
private *doItemEffect(index: number, item: number): Gen<boolean> {
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.BicycleEffect: {
const script = this.host.bicycleScript(this.state);
if (script === undefined) return false;
this.deferred = script;
this.deferredItem = item;
return true;
}
}
yield* this.notice(`Using ${this.data.itemName(item)} ${NOT_PORTED}`);
return false;
}
/** UseItem_SelectMon: the party menu for an item. The party index, or
* -1 when cancelled or an Egg was chosen. */
private *useItemSelectMon(): Gen<number> {
const slot = yield* this.partyMenu(PARTYMENUACTION_HEALING_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;
}
/** 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] ?? "";
}