Port the start menu, the party menu and the pack outside battle
Checks / web (push) Canceled after 0s
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:
1 parent
60e06ae678
commit
1af2beacdc
18 files changed
+1918
-49
No files matched your search
@@ -169,6 +169,8 @@ export interface BattleOptions {
|
||||
tutorial?: boolean;
|
||||
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
|
||||
landmark?: number;
|
||||
/** wPartyMenuCursor as the last party menu outside battle left it. */
|
||||
partyMenuCursor?: number;
|
||||
}
|
||||
|
||||
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
|
||||
@@ -424,9 +426,9 @@ export class Battle {
|
||||
private battleHasJustStarted = 0;
|
||||
/** wCurPartyMon: the party index a menu picked. */
|
||||
private curPartyMon = 0;
|
||||
/** wPartyMenuCursor: the 1-based cursor of the last party menu, which
|
||||
* no ported field menu sets before a battle. */
|
||||
private partyMenuCursor = 0;
|
||||
/** wPartyMenuCursor: the 1-based cursor of the last party menu, from
|
||||
* the start menu's until the battle's own party menu sets it. */
|
||||
private partyMenuCursor: number;
|
||||
/** wLastPlayerMon: the Pokémon that was out before the last switch. */
|
||||
lastPlayerMon = 0;
|
||||
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
|
||||
@@ -455,6 +457,7 @@ export class Battle {
|
||||
constructor(private options: BattleOptions) {
|
||||
this.data = new BattleData(options.data.reader);
|
||||
this.matchups = this.data.typeMatchups();
|
||||
this.partyMenuCursor = options.partyMenuCursor ?? 0;
|
||||
if (options.trainer) this.battleMode = C.TRAINER_BATTLE;
|
||||
if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL;
|
||||
}
|
||||
|
||||
@@ -239,6 +239,26 @@ export const C = {
|
||||
BATTLEPLAYERACTION_SWITCH: 2,
|
||||
ITEMMENU_CURRENT: 4,
|
||||
ITEMMENU_PARTY: 5,
|
||||
ITEMMENU_CLOSE: 6,
|
||||
// Menus outside battle (constants/menu_constants.asm)
|
||||
MONMENU_FIELD_MOVE: 0,
|
||||
MONMENUITEM_STATS: 15,
|
||||
MONMENUITEM_SWITCH: 16,
|
||||
MONMENUITEM_ITEM: 17,
|
||||
MONMENUITEM_CANCEL: 18,
|
||||
MONMENUITEM_MOVE: 19,
|
||||
MONMENUITEM_MAIL: 20,
|
||||
NUM_MONMENU_ITEMS: 8,
|
||||
PARTYMENUACTION_CHOOSE_POKEMON: 0,
|
||||
PARTYMENUACTION_HEALING_ITEM: 1,
|
||||
PARTYMENUACTION_MOVE: 4,
|
||||
PARTYMENUACTION_GIVE_ITEM: 8,
|
||||
KEY_ITEM_POCKET: 2,
|
||||
TM_HM_POCKET: 3,
|
||||
PP_UP: 0x3e,
|
||||
SKETCH: 0xa6,
|
||||
PP_UP_MASK: 0xc0,
|
||||
PP_UP_ONE: 0x40,
|
||||
PARTYMENUTEXT_HEAL_HP: 0xf5,
|
||||
PARTYMENUTEXT_HEAL_CONFUSION: 0xf9,
|
||||
PARTYMENUTEXT_REVIVE: 0xf7,
|
||||
|
||||
@@ -170,6 +170,20 @@ export class PokemonData {
|
||||
);
|
||||
return Boolean(permissions & 0x80);
|
||||
}
|
||||
/** ItemAttributes permissions, bit 6 (CANT_SELECT_F): CheckSelectableItem. */
|
||||
itemUnselectable(item: number): boolean {
|
||||
this.checkItem(item);
|
||||
const permissions = this.reader.byte(
|
||||
SYMBOLS.itemAttributes + (item - 1) * 7 + 4,
|
||||
);
|
||||
return Boolean(permissions & 0x40);
|
||||
}
|
||||
/** CheckItemMenu: the field menu (ITEMMENU_*), the help byte's high
|
||||
* nibble. CheckItemContext reads the low one in battle. */
|
||||
itemFieldMenu(item: number): number {
|
||||
this.checkItem(item);
|
||||
return this.reader.byte(SYMBOLS.itemAttributes + (item - 1) * 7 + 6) >> 4;
|
||||
}
|
||||
pocketName(pocket: Pocket): string {
|
||||
const r = this.reader,
|
||||
bank = r.bank(SYMBOLS.itemPocketNames);
|
||||
|
||||
@@ -173,6 +173,7 @@ export const EVENTS = {
|
||||
|
||||
/** Engine flag indices named directly by translated routines. */
|
||||
export const ENGINE_FLAGS = {
|
||||
pokegear: 4,
|
||||
pokedex: 11,
|
||||
allFruitTrees: 84,
|
||||
} as const;
|
||||
@@ -404,6 +405,42 @@ export const PC = {
|
||||
Script_ReceivePhoneCall: 0x90241,
|
||||
} as const;
|
||||
|
||||
/** The start menu, the party menu and its Pokémon submenu, and the pack
|
||||
* outside battle: menu data, strings and texts, keyed by their PRET labels
|
||||
* so that \`npm run check:reference\` can verify each address by name. */
|
||||
export const MENU = {
|
||||
"StartMenu.Items": 0x126eb,
|
||||
PartyMenuStrings: 0x504d2,
|
||||
MonMenuOptionStrings: 0x24caf,
|
||||
MonMenuOptions: 0x24cd9,
|
||||
"GiveTakeItemMenuData.Items": 0x12ca3,
|
||||
String_MoveWhere: 0x1316b,
|
||||
MailItems: 0xb9e80,
|
||||
PokemonSwapItemText: 0x12caf,
|
||||
PokemonHoldItemText: 0x12cb4,
|
||||
PokemonRemoveMailText: 0x12cb9,
|
||||
PokemonNotHoldingText: 0x12cbe,
|
||||
ItemStorageFullText: 0x12cc3,
|
||||
PokemonTookItemText: 0x12cc8,
|
||||
PokemonAskSwapItemText: 0x12ccd,
|
||||
ItemCantHeldText: 0x12cd2,
|
||||
"GiveItem.AnEggCantHoldAnItemText": 0x1048d,
|
||||
"MenuHeader_UsableKeyItem.MenuData": 0x10251,
|
||||
"MenuHeader_UsableItem.MenuData": 0x1027c,
|
||||
"MenuHeader_UnusableItem.MenuData": 0x102a1,
|
||||
"MenuHeader_UnusableKeyItem.MenuData": 0x102b8,
|
||||
"MenuHeader_HoldableKeyItem.MenuData": 0x102d5,
|
||||
"MenuHeader_HoldableItem.MenuData": 0x102fa,
|
||||
AskThrowAwayText: 0x10ae4,
|
||||
AskQuantityThrowAwayText: 0x10ae9,
|
||||
ThrewAwayText: 0x10aee,
|
||||
YouDontHaveAMonText: 0x10af8,
|
||||
RaiseThePPOfWhichMoveText: 0xf725,
|
||||
PPIsMaxedOutText: 0xf72f,
|
||||
PPsIncreasedText: 0xf734,
|
||||
BicycleEffect: 0xee08,
|
||||
} 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 = {
|
||||
|
||||
+12
-3
@@ -71,9 +71,13 @@ export class NativeRuntime implements GameRuntime {
|
||||
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno")
|
||||
)
|
||||
world.moveCursor(button as Direction);
|
||||
if ((button === "start" || button === "select") && !world.busy)
|
||||
world.notice =
|
||||
"Use the Savegames tab to save, export or import. Party, pack and Pokégear menus are not ported yet.";
|
||||
if (button === "start") world.openStartMenu();
|
||||
this.publish();
|
||||
}
|
||||
/** Start: the start menu, while the player stands still. */
|
||||
openStartMenu() {
|
||||
this.fieldMenuOpen = false;
|
||||
this.world?.openStartMenu();
|
||||
this.publish();
|
||||
}
|
||||
useBicycle() {
|
||||
@@ -82,6 +86,11 @@ export class NativeRuntime implements GameRuntime {
|
||||
this.world.notice = "You can’t use the BICYCLE here.";
|
||||
this.publish();
|
||||
}
|
||||
/** Choose a pack entry from a pointer control. */
|
||||
choosePackEntry(pocket: number, index: number) {
|
||||
this.world?.choosePackEntry(pocket, index);
|
||||
this.publish();
|
||||
}
|
||||
/** Answer a choice from a pointer or keyboard control outside the canvas. */
|
||||
choose(answer: number | string) {
|
||||
this.world?.choose(answer);
|
||||
|
||||
@@ -250,6 +250,15 @@ export type ScriptEffect =
|
||||
| { kind: "notice"; text: string }
|
||||
/** The Pokémon stats screen, closed with a button. */
|
||||
| { kind: "stats"; mon: PartyMon }
|
||||
/** The pack's pockets (Pack and DepositSellPack), opened on `pocket`;
|
||||
* left and right change pockets. Answered with the pocket index × 256
|
||||
* plus the entry's index, or -1 for B. */
|
||||
| {
|
||||
kind: "pack";
|
||||
pockets: { name: string; entries: MartEntry[] }[];
|
||||
pocket: number;
|
||||
selected: number;
|
||||
}
|
||||
/** 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
|
||||
|
||||
@@ -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] ?? "";
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { RAM } from "../rom/profile";
|
||||
const CHARACTERS: Record<number, string> = {
|
||||
0x1f: " ",
|
||||
0x22: "\n",
|
||||
// PlacePOKe: "<PO><KE>".
|
||||
0x24: "POKé",
|
||||
0x25: "",
|
||||
0x4a: "Pokémon",
|
||||
0x4b: "\n",
|
||||
|
||||
+111
-19
@@ -27,6 +27,7 @@ import {
|
||||
type ScriptState,
|
||||
} from "../scripts/interpreter";
|
||||
import { IntroRun } from "../scripts/intro";
|
||||
import { StartMenuRun, type MenuCursors } from "../scripts/start-menu";
|
||||
import type { MovementPose, MovementStep } from "../scripts/movement";
|
||||
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
|
||||
import {
|
||||
@@ -213,6 +214,13 @@ export type Choice =
|
||||
entries: MartEntry[];
|
||||
selected: number;
|
||||
}
|
||||
/** The pack's pockets; `selected` is an entry of pocket `pocket`. */
|
||||
| {
|
||||
kind: "pack";
|
||||
pockets: { name: string; entries: MartEntry[] }[];
|
||||
pocket: number;
|
||||
selected: number;
|
||||
}
|
||||
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
|
||||
| {
|
||||
kind: "martquantity";
|
||||
@@ -250,7 +258,7 @@ export interface BattleView {
|
||||
const BATTLE_MESSAGE_TICKS = 30;
|
||||
|
||||
interface ActiveScript {
|
||||
run: ScriptRun | IntroRun;
|
||||
run: ScriptRun | IntroRun | StartMenuRun;
|
||||
effect?: ScriptEffect;
|
||||
/** Committed save data for the whole interaction, used for mid-script saves. */
|
||||
committed: NativeSave;
|
||||
@@ -506,6 +514,8 @@ export class GameWorld {
|
||||
* ChangeDirectionScript holds input for three ticks. */
|
||||
private turnTo?: Direction;
|
||||
private turnWait = 0;
|
||||
/** Start menu, party menu and pack cursors (work RAM, not saved). */
|
||||
private menuCursors: MenuCursors = { start: 0, party: 0, pocket: 0 };
|
||||
/** OBJECT_ACTION_BUMP this tick: walking in place against an obstacle. */
|
||||
private bumping = false;
|
||||
/** A screen shake object (earthquake, step_shake) in progress. */
|
||||
@@ -1599,6 +1609,41 @@ export class GameWorld {
|
||||
this.pump();
|
||||
}
|
||||
|
||||
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
|
||||
* the player stands still. Its changes commit when it closes. */
|
||||
openStartMenu(): boolean {
|
||||
if (this.busy || this.introPending) return false;
|
||||
this.clearNotice();
|
||||
this.script = {
|
||||
run: new StartMenuRun(
|
||||
this.state,
|
||||
{
|
||||
reader: this.assets.reader,
|
||||
data: this.assets.pokemon,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bicycleScript: (state) => this.bicycleScript(state),
|
||||
},
|
||||
this.menuCursors,
|
||||
),
|
||||
committed: this.save(),
|
||||
rollback: {
|
||||
state: this.state,
|
||||
mapId: this.mapId,
|
||||
player: copyActor(this.player),
|
||||
actors: new Map(
|
||||
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
|
||||
),
|
||||
spawned: new Set(this.spawned),
|
||||
blocks: new Uint8Array(this.map.blocks),
|
||||
},
|
||||
waitTicks: 0,
|
||||
moving: new Map(),
|
||||
scene: false,
|
||||
};
|
||||
this.pump();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** NewGame's PlayerProfileSetup and OakSpeech, before the player
|
||||
* appears in the bedroom. Saves made meanwhile start it again. */
|
||||
beginIntro() {
|
||||
@@ -1823,6 +1868,7 @@ export class GameWorld {
|
||||
),
|
||||
tutorial: effect.tutorial,
|
||||
landmark: this.assets.map(this.mapId).landmark,
|
||||
partyMenuCursor: this.menuCursors.party,
|
||||
});
|
||||
this.battle = { engine, selected: 1 };
|
||||
this.battleRun = engine.run();
|
||||
@@ -1909,6 +1955,18 @@ export class GameWorld {
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "pack":
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
index: 0,
|
||||
choice: {
|
||||
kind: "pack",
|
||||
pockets: effect.pockets,
|
||||
pocket: effect.pocket,
|
||||
selected: effect.selected,
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "martquantity":
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
@@ -2265,8 +2323,14 @@ export class GameWorld {
|
||||
}
|
||||
if (!script.scene || script.run.deferred !== undefined)
|
||||
this.sceneCheck = true;
|
||||
if (script.run instanceof StartMenuRun) this.menuCursors = script.run.cursors;
|
||||
if (script.run.deferred !== undefined)
|
||||
this.startScript(script.run.deferred, PLAYER);
|
||||
this.startScript(script.run.deferred, PLAYER, {
|
||||
usedItem:
|
||||
script.run instanceof StartMenuRun
|
||||
? script.run.deferredItem
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
private abortScript(error: unknown) {
|
||||
const script = this.script!;
|
||||
@@ -2316,6 +2380,8 @@ export class GameWorld {
|
||||
);
|
||||
this.battle = undefined;
|
||||
this.battleRun = undefined;
|
||||
// CleanUpBattleRAM
|
||||
this.menuCursors = { start: 0, party: 0, pocket: 0 };
|
||||
this.pump(next.value.result);
|
||||
return;
|
||||
}
|
||||
@@ -2837,7 +2903,8 @@ export class GameWorld {
|
||||
const choice = this.dialogue.choice;
|
||||
if (choice && this.dialogue.index === this.dialogue.pages.length - 1) {
|
||||
// The naming screen is answered with text through choose().
|
||||
if (choice.kind !== "name") this.choose(choice.selected);
|
||||
if (choice.kind === "pack") this.choosePackEntry();
|
||||
else if (choice.kind !== "name") this.choose(choice.selected);
|
||||
} else this.advanceDialogue();
|
||||
return;
|
||||
}
|
||||
@@ -3065,8 +3132,15 @@ export class GameWorld {
|
||||
!this.state.bag.keyItems.some((slot) => slot === OW.BICYCLE)
|
||||
)
|
||||
return false;
|
||||
const map = this.map,
|
||||
state = this.state;
|
||||
const script = this.bicycleScript(this.state);
|
||||
if (script === undefined) return false;
|
||||
this.startScript(script, PLAYER, { usedItem: OW.BICYCLE });
|
||||
return true;
|
||||
}
|
||||
/** BikeFunction.TryBike: the script QueueScript runs, or undefined when
|
||||
* .CheckEnvironment or the player's state refuses the Bicycle. */
|
||||
private bicycleScript(state: ScriptState): number | undefined {
|
||||
const map = this.map;
|
||||
const outdoors =
|
||||
map.environment === OW.TOWN || map.environment === OW.ROUTE;
|
||||
const allowed =
|
||||
@@ -3075,20 +3149,14 @@ export class GameWorld {
|
||||
map.environment === OW.GATE) &&
|
||||
this.assets.permission(this.assets.collision(map, this.x, this.y)) ===
|
||||
OW.LAND_TILE;
|
||||
if (!allowed) return false;
|
||||
const usedItem = OW.BICYCLE;
|
||||
if (!allowed) return undefined;
|
||||
if (state.playerState === OW.PLAYER_NORMAL)
|
||||
this.startScript(OVERWORLD.Script_GetOnBike, PLAYER, { usedItem });
|
||||
else if (state.playerState === OW.PLAYER_BIKE)
|
||||
this.startScript(
|
||||
state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
|
||||
? OVERWORLD.Script_CantGetOffBike
|
||||
: OVERWORLD.Script_GetOffBike,
|
||||
PLAYER,
|
||||
{ usedItem },
|
||||
);
|
||||
else return false;
|
||||
return true;
|
||||
return OVERWORLD.Script_GetOnBike;
|
||||
if (state.playerState === OW.PLAYER_BIKE)
|
||||
return state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
|
||||
? OVERWORLD.Script_CantGetOffBike
|
||||
: OVERWORLD.Script_GetOffBike;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** GetPlayerSprite: Chris's or Kris's sprite for wPlayerState. */
|
||||
@@ -3147,7 +3215,8 @@ export class GameWorld {
|
||||
else if (
|
||||
choice.kind === "martlist" ||
|
||||
choice.kind === "martquantity" ||
|
||||
choice.kind === "menu"
|
||||
choice.kind === "menu" ||
|
||||
choice.kind === "pack"
|
||||
)
|
||||
this.choose(-1);
|
||||
return;
|
||||
@@ -3191,6 +3260,20 @@ export class GameWorld {
|
||||
if (direction === "up") choice.selected = (choice.selected + 1) % length;
|
||||
if (direction === "down")
|
||||
choice.selected = (choice.selected + length - 1) % length;
|
||||
} else if (choice.kind === "pack") {
|
||||
// Pack_InterpretJoypad: left and right change pockets.
|
||||
const pockets = choice.pockets.length;
|
||||
if (direction === "left" || direction === "right") {
|
||||
choice.pocket =
|
||||
(choice.pocket + (direction === "left" ? pockets - 1 : 1)) % pockets;
|
||||
choice.selected = 0;
|
||||
return;
|
||||
}
|
||||
const length = choice.pockets[choice.pocket].entries.length;
|
||||
if (!length) return;
|
||||
if (direction === "up") choice.selected = Math.max(0, choice.selected - 1);
|
||||
if (direction === "down")
|
||||
choice.selected = Math.min(length - 1, choice.selected + 1);
|
||||
} else if (choice.kind === "martlist") {
|
||||
const length = choice.entries.length;
|
||||
if (!length) return;
|
||||
@@ -3214,6 +3297,15 @@ export class GameWorld {
|
||||
} else if (direction === "up") choice.selected = (choice.selected + 1) % 7;
|
||||
else if (direction === "down") choice.selected = (choice.selected + 6) % 7;
|
||||
}
|
||||
/** Choose the highlighted pack entry, if its pocket has one. */
|
||||
choosePackEntry(pocket?: number, index?: number) {
|
||||
const choice = this.dialogue?.choice;
|
||||
if (choice?.kind !== "pack") return;
|
||||
pocket ??= choice.pocket;
|
||||
index ??= choice.selected;
|
||||
if (!choice.pockets[pocket]?.entries[index]) return;
|
||||
this.choose(pocket * 256 + index);
|
||||
}
|
||||
/** Answer the current choice: 1/0 for yes/no, a weekday index, or a
|
||||
* name ("" keeps the current one). */
|
||||
choose(answer: number | string) {
|
||||
|
||||
Reference in new issue
Block a user