Port field moves from the menu, SEL with Select, and moving Pokémon in Bill's PC
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
Field moves chosen in the party menu run their original checks and the ROM's from-menu scripts (Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent), and Softboiled and Milk Drink share HP. SEL registers a key item that Select then uses, replacing the Key Items shortcut, and Bill's PC moves Pokémon between the party and every box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45275ba83b
commit
3456aa4e11
25 files changed
+1129
-186
No files matched your search
@@ -256,6 +256,8 @@ export const C = {
|
||||
KEY_ITEM_POCKET: 2,
|
||||
TM_HM_POCKET: 3,
|
||||
PP_UP: 0x3e,
|
||||
SOFTBOILED: 0x87,
|
||||
MILK_DRINK: 0xd0,
|
||||
RARE_CANDY: 0x20,
|
||||
HP_UP: 0x1a,
|
||||
PROTEIN: 0x1b,
|
||||
|
||||
@@ -85,6 +85,15 @@ export function withdrawnMon(data: PokemonData, box: BoxMon): PartyMon {
|
||||
return { ...mon, level, status: 0, hp: stats[0], stats };
|
||||
}
|
||||
|
||||
/** A boxed Pokémon in MOVE PKMN W/O MAIL's wBufferMon: its stored level,
|
||||
* CalcBufferMonStats' stats with stat experience, full HP (none for an
|
||||
* Egg) and no status. */
|
||||
export function bufferMon(data: PokemonData, box: BoxMon): PartyMon {
|
||||
const mon = structuredClone(box);
|
||||
const stats = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
|
||||
return { ...mon, status: 0, hp: mon.species === 0xfd ? 0 : stats[0], stats };
|
||||
}
|
||||
|
||||
/** ReceiveItem into wNumPCItems. */
|
||||
export function receivePCItem(
|
||||
items: ItemSlot[],
|
||||
|
||||
@@ -124,6 +124,7 @@ export class CrystalAssets {
|
||||
tileset: r.byte(header + 1),
|
||||
environment: r.byte(header + 2),
|
||||
paletteMode: r.byte(header + 7) & 15,
|
||||
attributes,
|
||||
blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight),
|
||||
warps: [],
|
||||
backgroundEvents: [],
|
||||
|
||||
@@ -169,6 +169,7 @@ export const EVENTS = {
|
||||
playersHouseDoll1: 1858,
|
||||
playersHouseDoll2: 1859,
|
||||
playersHouseBigDoll: 1860,
|
||||
wallOpenedInAerodactylChamber: 809,
|
||||
} as const;
|
||||
|
||||
/** Engine flag indices named directly by translated routines. */
|
||||
@@ -219,6 +220,26 @@ export const OVERWORLD = {
|
||||
IconPointers: 0x8ebbf,
|
||||
AskSurfScript: 0xca2c,
|
||||
"UsedSurfScript.stubbed_fn": 0xc9a2,
|
||||
Script_CutFromMenu: 0xc7fe,
|
||||
SurfFromMenuScript: 0xc983,
|
||||
Script_StrengthFromMenu: 0xcd29,
|
||||
Script_UseFlash: 0xc8e6,
|
||||
UseFlashTextScript: 0xc8f3,
|
||||
"UseFlashTextScript.BlankText": 0xc908,
|
||||
BlindingFlash: 0x8c7e1,
|
||||
Script_WhirlpoolFromMenu: 0xce0b,
|
||||
Script_WaterfallFromMenu: 0xcb1c,
|
||||
"SweetScentFromMenu.SweetScent": 0x506c8,
|
||||
SweetScentEncounter: 0x506ef,
|
||||
"CheckBadge.BadgeRequiredText": 0xc73d,
|
||||
"FieldMoveFailed.CantUseItemText": 0xc780,
|
||||
CutNothingText: 0xc7c9,
|
||||
CantSurfText: 0xc9ae,
|
||||
AlreadySurfingText: 0xc9b3,
|
||||
"EscapeRopeOrDig.CantUseDigText": 0xcc26,
|
||||
"TeleportFunction.CantUseTeleportText": 0xccb6,
|
||||
"Softboiled_MilkDrinkFunction.ItemCantUseOnMonText": 0xf44a,
|
||||
RuinsOfAlphAerodactylChamber_MapAttributes: 0x958e8,
|
||||
AskCutScript: 0xd1a9,
|
||||
"AskCutScript.CheckMap": 0xd1ba,
|
||||
CantCutScript: 0xd1cd,
|
||||
@@ -237,7 +258,9 @@ export const OVERWORLD = {
|
||||
ChrisStateSprites: 0x141b7,
|
||||
KrisStateSprites: 0x141c0,
|
||||
Script_GetOnBike: 0xd13e,
|
||||
Script_GetOnBike_Register: 0xd14e,
|
||||
Script_GetOffBike: 0xd158,
|
||||
Script_GetOffBike_Register: 0xd16b,
|
||||
Script_CantGetOffBike: 0xd171,
|
||||
UpdatePlayerSprite: 0xe4a,
|
||||
PlayMapMusic: 0x3cdf,
|
||||
@@ -387,6 +410,12 @@ export const PC = {
|
||||
PCString_BoxFull: 0xe3575,
|
||||
PCString_PartyFull: 0xe3586,
|
||||
PCString_NoReleasingEGGS: 0xe3597,
|
||||
PCString_MoveToWhere: 0xe3500,
|
||||
PCString_TheresNoRoom: 0xe3521,
|
||||
"_MovePKMNWithoutMail.MenuData": 0xe28cb,
|
||||
MoveMonWOMailSaveText: 0x152a6,
|
||||
"BillsPC_MovePKMNMenu.PCMonHoldingMailText": 0xe4f9,
|
||||
"MovePKMNWithoutMail_InsertMon.Saving_LeaveOn": 0xe3233,
|
||||
"BillsPC_PlaceChooseABoxString.ChooseABox": 0xe37a1,
|
||||
"BillsPC_PlaceEmptyBoxString_SFX.NoMonString": 0xe37d3,
|
||||
"BillsPC_PrintBoxName.Current": 0xe36f1,
|
||||
@@ -415,6 +444,11 @@ export const MENU = {
|
||||
MonMenuOptions: 0x24cd9,
|
||||
"GiveTakeItemMenuData.Items": 0x12ca3,
|
||||
String_MoveWhere: 0x1316b,
|
||||
"MonMenu_Softboiled_MilkDrink.PokemonNotEnoughHPText": 0x12f00,
|
||||
MayRegisterItemText: 0x13340,
|
||||
RegisteredItemText: 0x10afd,
|
||||
CantRegisterText: 0x10b02,
|
||||
ItemsOakWarningText: 0x12a67,
|
||||
MailItems: 0xb9e80,
|
||||
PokemonSwapItemText: 0x12caf,
|
||||
PokemonHoldItemText: 0x12cb4,
|
||||
|
||||
+5
-9
@@ -9,7 +9,6 @@ import { WorldRenderer } from "./rendering/renderer";
|
||||
export class NativeRuntime implements GameRuntime {
|
||||
readonly id = "crystal-native-world-v1";
|
||||
world?: GameWorld;
|
||||
fieldMenuOpen = false;
|
||||
private renderer?: WorldRenderer;
|
||||
private pressed = new Set<Button>();
|
||||
private raf = 0;
|
||||
@@ -26,7 +25,6 @@ export class NativeRuntime implements GameRuntime {
|
||||
) {}
|
||||
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
|
||||
this.dispose();
|
||||
this.fieldMenuOpen = false;
|
||||
const assets = new CrystalAssets(bytes);
|
||||
const world = new GameWorld(assets);
|
||||
this.world = world;
|
||||
@@ -72,18 +70,17 @@ export class NativeRuntime implements GameRuntime {
|
||||
)
|
||||
world.moveCursor(button as Direction);
|
||||
if (button === "start") world.openStartMenu();
|
||||
if (button === "select") world.useSelect();
|
||||
this.publish();
|
||||
}
|
||||
/** Start: the start menu, while the player stands still. */
|
||||
openStartMenu() {
|
||||
this.fieldMenuOpen = false;
|
||||
this.world?.openStartMenu();
|
||||
this.publish();
|
||||
}
|
||||
useBicycle() {
|
||||
this.fieldMenuOpen = false;
|
||||
if (this.world && !this.world.useBicycle())
|
||||
this.world.notice = "You can’t use the BICYCLE here.";
|
||||
/** Select: the registered item (SelectMenu). */
|
||||
useSelect() {
|
||||
this.world?.useSelect();
|
||||
this.publish();
|
||||
}
|
||||
/** Choose a pack entry from a pointer control. */
|
||||
@@ -128,7 +125,7 @@ export class NativeRuntime implements GameRuntime {
|
||||
let ticks = 0;
|
||||
while (this.pending >= 1 / TICKS_PER_SECOND && ticks < 6) {
|
||||
this.pending -= 1 / TICKS_PER_SECOND;
|
||||
if (!this.fieldMenuOpen) world.tick(held);
|
||||
world.tick(held);
|
||||
ticks++;
|
||||
}
|
||||
if (ticks === 6) this.pending = 0;
|
||||
@@ -247,7 +244,6 @@ export class NativeRuntime implements GameRuntime {
|
||||
restoreState(bytes: Uint8Array) {
|
||||
if (!this.world || bytes.length > 64_000)
|
||||
throw new Error("Invalid native save size.");
|
||||
this.fieldMenuOpen = false;
|
||||
this.world.restore(
|
||||
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
||||
);
|
||||
|
||||
@@ -102,6 +102,9 @@ export interface ScriptState {
|
||||
* (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */
|
||||
flash: boolean;
|
||||
noWildEncounters: boolean;
|
||||
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
|
||||
* and wRegisteredItem: the item Select uses; zeros when none. */
|
||||
registeredItem: { which: number; item: number };
|
||||
}
|
||||
export const emptyScriptState = (): ScriptState => ({
|
||||
decorations: emptyDecorations(),
|
||||
@@ -126,6 +129,7 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
backupWarp: null,
|
||||
flash: false,
|
||||
noWildEncounters: false,
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
});
|
||||
export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
decorations: { ...state.decorations },
|
||||
@@ -153,6 +157,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
backupWarp: state.backupWarp && { ...state.backupWarp },
|
||||
flash: state.flash,
|
||||
noWildEncounters: state.noWildEncounters,
|
||||
registeredItem: { ...state.registeredItem },
|
||||
});
|
||||
|
||||
/** Object operands keep the script's numbering: 0 is the player, 2+ are map
|
||||
@@ -472,6 +477,11 @@ export class ScriptRun {
|
||||
rival: this.state.rivalName,
|
||||
strings: { ...this.host.text().strings, ...this.strings },
|
||||
numbers: { ...this.host.text().numbers, ...this.numbers },
|
||||
// UseFlashTextScript: SFX_FLASH, then an empty text.
|
||||
asm: (offset) =>
|
||||
offset === OVERWORLD.UseFlashTextScript + 4
|
||||
? OVERWORLD["UseFlashTextScript.BlankText"]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -759,6 +769,13 @@ export class ScriptRun {
|
||||
) {
|
||||
const tile = this.host.playerCollision();
|
||||
value = Number(!(tile === 0x33 || tile === 0x3b)); // CheckWaterfallTile
|
||||
} else if (routine === OVERWORLD.BlindingFlash) {
|
||||
// STATUSFLAGS_FLASH_F; the fades and palettes are presentation.
|
||||
this.state.flash = true;
|
||||
} else if (routine === OVERWORLD.SweetScentEncounter) {
|
||||
// Chosen by the world when the move was used (CanEncounterWildMon,
|
||||
// GetMapEncounterRate and ChooseWildEncounter).
|
||||
value = Number(this.options.wild !== undefined);
|
||||
} else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) {
|
||||
// StubbedTrainerRankings_Surf: the cut Trainer Rankings feature.
|
||||
} else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
|
||||
|
||||
+129
-7
@@ -1,14 +1,23 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import { ENGINE_FLAGS, PC, RAM } from "../rom/profile";
|
||||
import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile";
|
||||
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
|
||||
import { BOX_NAME_PATTERN, PARTY_LENGTH } from "../party/pokemon";
|
||||
import {
|
||||
BOX_NAME_PATTERN,
|
||||
PARTY_LENGTH,
|
||||
type PartyMon,
|
||||
} from "../party/pokemon";
|
||||
import {
|
||||
MONS_PER_BOX,
|
||||
NUM_BOXES,
|
||||
bufferMon,
|
||||
currentBox,
|
||||
depositMon,
|
||||
receivePCItem,
|
||||
restorePP,
|
||||
toBoxMon,
|
||||
withdrawnMon,
|
||||
type BoxMon,
|
||||
} from "../party/storage";
|
||||
import { readString, readText, type TextContext } from "./text";
|
||||
import type { MartEntry, ScriptEffect, ScriptState } from "./interpreter";
|
||||
@@ -422,15 +431,128 @@ export class PcSession {
|
||||
if (choice === BILLSPC_WITHDRAW) yield* this.withdrawMons();
|
||||
else if (choice === BILLSPC_DEPOSIT) yield* this.depositMons();
|
||||
else if (choice === BILLSPC_CHANGE_BOX) yield* this.changeBox();
|
||||
else if (choice === BILLSPC_MOVE)
|
||||
yield {
|
||||
kind: "notice",
|
||||
text: "Moving Pokémon between boxes is not ported yet.",
|
||||
};
|
||||
else if (choice === BILLSPC_MOVE) yield* this.movePKMNMenu();
|
||||
else return;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------ MOVE PKMN W/O MAIL
|
||||
|
||||
/** BillsPC_MovePKMNMenu: refused while a party Pokémon holds MAIL
|
||||
* (IsAnyMonHoldingMail), then StartMoveMonWOMail_SaveGame's question. The
|
||||
* native session saves by itself, so no save messages follow. */
|
||||
private *movePKMNMenu(): Gen {
|
||||
const r = this.host.reader;
|
||||
const mail = new Set<number>();
|
||||
for (let p = MENU.MailItems; r.byte(p) !== 0xff; p++) mail.add(r.byte(p));
|
||||
if (this.state.party.some((mon) => mail.has(mon.item)))
|
||||
return yield* this.print(PC["BillsPC_MovePKMNMenu.PCMonHoldingMailText"]);
|
||||
const ok = yield { kind: "yesno", pages: this.pages(PC.MoveMonWOMailSaveText) };
|
||||
if (ok) yield* this.moveWithoutMail();
|
||||
}
|
||||
/** wBillsPC_LoadedBox: 0 is the party, 1-14 the boxes. */
|
||||
private loadedBox(box: number): (PartyMon | BoxMon)[] {
|
||||
return box ? this.state.storage.boxes[box - 1] : this.state.party;
|
||||
}
|
||||
private loadedBoxName(box: number) {
|
||||
return box
|
||||
? this.boxName(box - 1)
|
||||
: this.string(PC["BillsPC_BoxName.PartyPKMN"]);
|
||||
}
|
||||
/** Choose the party or a box (left and right in the original). */
|
||||
private *chooseLoadedBox(prompt: string, selected: number): Gen<number> {
|
||||
const boxes = Array.from({ length: NUM_BOXES + 1 }, (_, box) => box);
|
||||
return yield* this.menu(
|
||||
[prompt],
|
||||
boxes.map((box) => this.loadedBoxName(box)),
|
||||
boxes.map((box) => `${this.loadedBox(box).length}/${box ? MONS_PER_BOX : PARTY_LENGTH}`),
|
||||
selected,
|
||||
);
|
||||
}
|
||||
/** _MovePKMNWithoutMail: choose a Pokémon in the party or a box, MOVE it
|
||||
* (STATS shows it) and choose where it goes; the list is presented per
|
||||
* box instead of with left and right. */
|
||||
private *moveWithoutMail(): Gen {
|
||||
const data = this.host.data;
|
||||
const choose = this.string(PC.PCString_ChooseaPKMN),
|
||||
where = this.string(PC.PCString_MoveToWhere),
|
||||
cancel = this.string(PC["BillsPC_RefreshTextboxes.CancelString"]),
|
||||
options = this.strings(PC["_MovePKMNWithoutMail.MenuData"] + 2, 3);
|
||||
let loaded = this.state.storage.current + 1;
|
||||
for (;;) {
|
||||
const from = yield* this.chooseLoadedBox(choose, loaded);
|
||||
if (from < 0) return;
|
||||
loaded = from;
|
||||
const list = this.loadedBox(from);
|
||||
const index = yield* this.menu(
|
||||
[`${this.loadedBoxName(from)}\n${choose}`],
|
||||
[...list.map((mon) => mon.nickname), cancel],
|
||||
this.monDetails(list),
|
||||
);
|
||||
if (index < 0 || index >= list.length) continue;
|
||||
const action = yield* this.menu([this.string(PC.PCString_WhatsUp)], options);
|
||||
if (action === 1) {
|
||||
const mon = list[index];
|
||||
yield {
|
||||
kind: "stats",
|
||||
mon: from ? bufferMon(data, mon as BoxMon) : structuredClone(mon as PartyMon),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (action !== 0) continue;
|
||||
// BillsPC_CheckMail_PreventBlackout, even within the party.
|
||||
if (!from && !(yield* this.partyMonMayLeave(index))) continue;
|
||||
// Where: a box, then the position to insert before.
|
||||
for (;;) {
|
||||
const to = yield* this.chooseLoadedBox(where, from);
|
||||
if (to < 0) break;
|
||||
const destination = this.loadedBox(to);
|
||||
const position = yield* this.menu(
|
||||
[`${this.loadedBoxName(to)}\n${where}`],
|
||||
[...destination.map((mon) => mon.nickname), "▶"],
|
||||
this.monDetails(destination),
|
||||
destination.length,
|
||||
);
|
||||
if (position < 0) continue;
|
||||
// BillsPC_CheckSpaceInDestination
|
||||
if (
|
||||
to !== from &&
|
||||
destination.length >= (to ? MONS_PER_BOX : PARTY_LENGTH)
|
||||
) {
|
||||
yield* this.placeString(this.string(PC.PCString_TheresNoRoom));
|
||||
continue;
|
||||
}
|
||||
yield* this.placeString(
|
||||
this.string(PC["MovePKMNWithoutMail_InsertMon.Saving_LeaveOn"]),
|
||||
10,
|
||||
);
|
||||
this.insertMon(from, index, to, position);
|
||||
loaded = to;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/** MovePKMNWithoutMail_InsertMon: remove, then insert before `position`
|
||||
* (one earlier when moving down the same list, .CheckTrivialMove). A
|
||||
* boxed Pokémon joining the party gets CalcBufferMonStats' full HP;
|
||||
* InsertPokemonIntoBox restores the PP. */
|
||||
private insertMon(from: number, index: number, to: number, position: number) {
|
||||
const data = this.host.data;
|
||||
const source = this.loadedBox(from);
|
||||
const [moved] = source.splice(index, 1);
|
||||
if (from === to && index < position) position--;
|
||||
if (to) {
|
||||
const stored = from ? (moved as BoxMon) : toBoxMon(moved as PartyMon);
|
||||
restorePP(data, stored);
|
||||
this.state.storage.boxes[to - 1].splice(position, 0, stored);
|
||||
} else
|
||||
this.state.party.splice(
|
||||
position,
|
||||
0,
|
||||
from ? bufferMon(data, moved as BoxMon) : (moved as PartyMon),
|
||||
);
|
||||
}
|
||||
|
||||
/** BillsPC_CheckMail_PreventBlackout for a party Pokémon: the last one,
|
||||
* or the last that has not fainted, stays. No MAIL exists yet. */
|
||||
private *partyMonMayLeave(index: number): Gen<boolean> {
|
||||
|
||||
+155
-22
@@ -1,6 +1,6 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import { BATTLE, ENGINE_FLAGS, MENU, RAM } from "../rom/profile";
|
||||
import { BATTLE, ENGINE_FLAGS, MENU, OVERWORLD, RAM } from "../rom/profile";
|
||||
import { C } from "../battle/constants";
|
||||
import {
|
||||
changedHappiness,
|
||||
@@ -79,6 +79,22 @@ export interface MenuCursors {
|
||||
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, or a notice for what is not ported. */
|
||||
export type MenuFieldMove =
|
||||
| {
|
||||
script: number;
|
||||
fieldMove: {
|
||||
mon: number;
|
||||
surfState?: number;
|
||||
block?: { x: number; y: number; block: number };
|
||||
};
|
||||
wild?: { species: number; level: number };
|
||||
}
|
||||
| { texts: number[] }
|
||||
| { notice: string };
|
||||
|
||||
/** What the start menu needs from the world. */
|
||||
export interface StartMenuHost {
|
||||
reader: RomReader;
|
||||
@@ -86,11 +102,13 @@ export interface StartMenuHost {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
/** The overworld start menu (engine/menus/start_menu.asm) with its
|
||||
@@ -105,6 +123,8 @@ export class StartMenuRun {
|
||||
* 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> = {};
|
||||
@@ -112,14 +132,19 @@ export class StartMenuRun {
|
||||
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 = this.run();
|
||||
this.iterator = select ? this.selectMenu() : this.run();
|
||||
}
|
||||
|
||||
/** Advance until the next effect, or undefined when the menu closed. */
|
||||
@@ -270,6 +295,55 @@ export class StartMenuRun {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- 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
|
||||
@@ -314,7 +388,7 @@ export class StartMenuRun {
|
||||
for (;;) {
|
||||
const slot = yield* this.partyMenu(PARTYMENUACTION_CHOOSE_POKEMON);
|
||||
if (slot < 0) return false;
|
||||
yield* this.pokemonActionSubmenu(slot);
|
||||
if (yield* this.pokemonActionSubmenu(slot)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,8 +427,9 @@ export class StartMenuRun {
|
||||
return this.stringList(MENU.MonMenuOptionStrings, value)[value - 1];
|
||||
}
|
||||
|
||||
/** PokemonActionSubmenu (MonSubmenu); B is CANCEL. */
|
||||
private *pokemonActionSubmenu(slot: number): Gen {
|
||||
/** 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(
|
||||
@@ -366,22 +441,77 @@ export class StartMenuRun {
|
||||
case MONMENUITEM_STATS:
|
||||
// OpenPartyStats
|
||||
yield { kind: "stats", mon };
|
||||
return;
|
||||
break;
|
||||
case MONMENUITEM_SWITCH:
|
||||
return yield* this.switchPartyMons(slot);
|
||||
yield* this.switchPartyMons(slot);
|
||||
break;
|
||||
case MONMENUITEM_ITEM:
|
||||
return yield* this.giveTakePartyMonItem(slot);
|
||||
yield* this.giveTakePartyMonItem(slot);
|
||||
break;
|
||||
case MONMENUITEM_MOVE:
|
||||
return yield* this.managePokemonMoves(slot);
|
||||
yield* this.managePokemonMoves(slot);
|
||||
break;
|
||||
case MONMENUITEM_CANCEL:
|
||||
return;
|
||||
break;
|
||||
case MONMENUITEM_MAIL:
|
||||
return yield* this.notice(`MAIL ${NOT_PORTED}`);
|
||||
yield* this.notice(`MAIL ${NOT_PORTED}`);
|
||||
break;
|
||||
default:
|
||||
return yield* this.notice(
|
||||
`Using ${this.monMenuString(item)} from the menu ${NOT_PORTED}`,
|
||||
);
|
||||
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;
|
||||
}
|
||||
const result = this.host.fieldMove(this.state, slot, move);
|
||||
if ("script" in result) {
|
||||
this.deferred = result.script;
|
||||
this.deferredMove = result;
|
||||
return true;
|
||||
}
|
||||
if ("notice" in result) yield* this.notice(result.notice);
|
||||
else 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
|
||||
@@ -591,7 +721,7 @@ export class StartMenuRun {
|
||||
yield* this.tossMenu(pocket, index, item);
|
||||
return false;
|
||||
case "sel":
|
||||
yield* this.notice(`Registering items for SELECT ${NOT_PORTED}`);
|
||||
yield* this.registerItem(pocket, index, item);
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
@@ -660,13 +790,16 @@ export class StartMenuRun {
|
||||
}
|
||||
const used = yield* this.doItemEffect(index, item);
|
||||
if (menu !== ITEMMENU_CLOSE) return false;
|
||||
if (!used) yield* this.print(BATTLE.OakThisIsntTheTimeText);
|
||||
return used;
|
||||
if (used === false) yield* this.print(BATTLE.OakThisIsntTheTimeText);
|
||||
return used === true;
|
||||
}
|
||||
|
||||
/** _DoItemEffect: ItemEffects by ROM address. Returns
|
||||
* wItemEffectSucceeded. */
|
||||
private *doItemEffect(index: number, item: number): Gen<boolean> {
|
||||
* 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(
|
||||
@@ -722,7 +855,7 @@ export class StartMenuRun {
|
||||
case MENU.EvoStoneEffect:
|
||||
return yield* this.evoStoneEffect(use);
|
||||
case MENU.BicycleEffect: {
|
||||
const script = this.host.bicycleScript(this.state);
|
||||
const script = this.host.bicycleScript(this.state, this.usingSelect);
|
||||
if (script === undefined) return false;
|
||||
this.deferred = script;
|
||||
this.deferredItem = item;
|
||||
@@ -730,7 +863,7 @@ export class StartMenuRun {
|
||||
}
|
||||
}
|
||||
yield* this.notice(`Using ${this.data.itemName(item)} ${NOT_PORTED}`);
|
||||
return false;
|
||||
return "unported";
|
||||
}
|
||||
|
||||
/** UseItem_SelectMon: the party menu for an item. The party index, or
|
||||
|
||||
@@ -85,7 +85,7 @@ export interface TextContext {
|
||||
battle?: { turn: number; player: string; enemy: string; enemyPrefix: string };
|
||||
/** Native handlers for text_asm, keyed by the command's ROM offset. They
|
||||
* return where text continues, as the original routine returns in hl. */
|
||||
asm?: (offset: number) => number;
|
||||
asm?: (offset: number) => number | undefined;
|
||||
}
|
||||
|
||||
export const defaultTextContext = (player: string): TextContext => ({
|
||||
|
||||
@@ -51,6 +51,7 @@ export const OW = {
|
||||
ENGINE_HIVEBADGE: 28,
|
||||
ENGINE_PLAINBADGE: 29,
|
||||
ENGINE_FOGBADGE: 30,
|
||||
ENGINE_STORMBADGE: 32,
|
||||
ENGINE_GLACIERBADGE: 33,
|
||||
ENGINE_RISINGBADGE: 34,
|
||||
// Map environments (constants/map_data_constants.asm).
|
||||
@@ -99,6 +100,14 @@ export const OW = {
|
||||
WHIRLPOOL: 250,
|
||||
WATERFALL: 127,
|
||||
HEADBUTT: 29,
|
||||
FLY: 0x13,
|
||||
DIG: 0x5b,
|
||||
TELEPORT: 0x64,
|
||||
FLASH: 0x94,
|
||||
SWEET_SCENT: 0xe6,
|
||||
ROCK_SMASH: 0xf9,
|
||||
// SpriteMovementData (constants/map_object_constants.asm).
|
||||
SPRITEMOVEDATA_SMASHABLE_ROCK: 0x18,
|
||||
BICYCLE: 7,
|
||||
PIKACHU: 25,
|
||||
EGG: 0xfd,
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface GameMap {
|
||||
tileset: number;
|
||||
environment: number;
|
||||
paletteMode: number;
|
||||
/** The map attributes' ROM offset, which some routines compare. */
|
||||
attributes?: number;
|
||||
blocks: Uint8Array;
|
||||
warps: Warp[];
|
||||
backgroundEvents: BackgroundEvent[];
|
||||
|
||||
+232
-24
@@ -7,6 +7,7 @@ import { CrystalAssets } from "../rom/assets";
|
||||
import { TrainerData } from "../rom/trainers";
|
||||
import {
|
||||
ENGINE_FLAGS,
|
||||
EVENTS,
|
||||
INITIAL_POSITION,
|
||||
NUM_FRUIT_TREES,
|
||||
OVERWORLD,
|
||||
@@ -27,7 +28,11 @@ import {
|
||||
type ScriptState,
|
||||
} from "../scripts/interpreter";
|
||||
import { IntroRun } from "../scripts/intro";
|
||||
import { StartMenuRun, type MenuCursors } from "../scripts/start-menu";
|
||||
import {
|
||||
StartMenuRun,
|
||||
type MenuCursors,
|
||||
type MenuFieldMove,
|
||||
} from "../scripts/start-menu";
|
||||
import { PartyEvolution } from "../scripts/evolution";
|
||||
import type { MovementPose, MovementStep } from "../scripts/movement";
|
||||
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
|
||||
@@ -107,6 +112,8 @@ export interface NativeSave {
|
||||
/** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */
|
||||
flash: boolean;
|
||||
noWildEncounters: boolean;
|
||||
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
|
||||
registeredItem: { which: number; item: number };
|
||||
}
|
||||
|
||||
/** A random byte from the platform's secure generator. */
|
||||
@@ -1364,7 +1371,20 @@ export class GameWorld {
|
||||
)
|
||||
return;
|
||||
if (isIce(collision)) return;
|
||||
// GetMapEncounterRate: CheckOnWater picks the water rate.
|
||||
const table = this.encounterTable(state);
|
||||
if (!(this.random() < table.rate)) return;
|
||||
const chosen = this.chooseWildEncounter(table);
|
||||
if (!chosen) return;
|
||||
if (!state.party.some((mon) => mon.hp > 0)) return;
|
||||
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
|
||||
committedPosition: previous,
|
||||
wild: chosen,
|
||||
});
|
||||
}
|
||||
/** GetMapEncounterRate: CheckOnWater picks the water table and rate,
|
||||
* which a Cleanse Tag halves. */
|
||||
private encounterTable(state: ScriptState) {
|
||||
const collision = this.assets.collision(this.map, this.x, this.y);
|
||||
const water = this.assets.permission(collision) === OW.WATER_TILE,
|
||||
kanto = this.inKanto();
|
||||
const grass = water
|
||||
@@ -1376,10 +1396,17 @@ export class GameWorld {
|
||||
let rate = (water ? sea?.rate : grass?.rates[this.clockTimeOfDay]) ?? 0;
|
||||
// ApplyCleanseTagEffectOnEncounterRate
|
||||
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
|
||||
if (!(this.random() < rate)) return;
|
||||
// ChooseWildEncounter: 1-100 against GrassMonProbTable or
|
||||
// WaterMonProbTable, whose entries are (percent, slot offset).
|
||||
if (!grass && !sea) return;
|
||||
return { water, grass, sea, rate };
|
||||
}
|
||||
/** ChooseWildEncounter: 1-100 against GrassMonProbTable or
|
||||
* WaterMonProbTable, whose entries are (percent, slot offset); surfing
|
||||
* varies the level. Undefined when no valid Pokémon is chosen. */
|
||||
private chooseWildEncounter({
|
||||
water,
|
||||
grass,
|
||||
sea,
|
||||
}: ReturnType<GameWorld["encounterTable"]>) {
|
||||
if (!grass && !sea) return undefined;
|
||||
let roll: number;
|
||||
do roll = this.random();
|
||||
while (roll >= 100);
|
||||
@@ -1399,12 +1426,167 @@ export class GameWorld {
|
||||
}
|
||||
// ValidateTempWildMonSpecies; Unown needs unlocked Unown (not ported).
|
||||
if (!chosen.species || chosen.species > 251 || chosen.species === C.UNOWN)
|
||||
return;
|
||||
if (!state.party.some((mon) => mon.hp > 0)) return;
|
||||
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
|
||||
committedPosition: previous,
|
||||
wild: chosen,
|
||||
});
|
||||
return undefined;
|
||||
return chosen;
|
||||
}
|
||||
/** CanEncounterWildMon: wildoff; caves and dungeons need no grass; never
|
||||
* on ice. */
|
||||
private canEncounterWildMon(state: ScriptState) {
|
||||
const map = this.map,
|
||||
collision = this.assets.collision(map, this.x, this.y);
|
||||
return (
|
||||
!state.noWildEncounters &&
|
||||
(map.environment === OW.CAVE ||
|
||||
map.environment === OW.DUNGEON ||
|
||||
this.assets.grassCollisions().includes(collision)) &&
|
||||
!isIce(collision)
|
||||
);
|
||||
}
|
||||
|
||||
/** A field move chosen from the party menu (CutFunction, SurfFunction and
|
||||
* the rest in engine/events/overworld.asm), checked on the menu's draft
|
||||
* for party slot `mon`: a script queued for when the menu closes, the
|
||||
* messages of a failed attempt, or a notice for what is not ported. */
|
||||
private menuFieldMove(
|
||||
state: ScriptState,
|
||||
mon: number,
|
||||
move: number,
|
||||
): MenuFieldMove {
|
||||
const [dx, dy] = VECTORS[this.player.direction];
|
||||
const x = this.player.x + dx,
|
||||
y = this.player.y + dy;
|
||||
const tile = this.assets.collision(this.map, x, y) ?? 0;
|
||||
const env = this.map.environment,
|
||||
outdoors = env === OW.TOWN || env === OW.ROUTE;
|
||||
const failed = { texts: [OVERWORLD["FieldMoveFailed.CantUseItemText"]] };
|
||||
const badge = (flag: number) =>
|
||||
state.flags.has(flag)
|
||||
? undefined
|
||||
: { texts: [OVERWORLD["CheckBadge.BadgeRequiredText"]] };
|
||||
const facingObject = () =>
|
||||
this.objects().find((object) => object.x === x && object.y === y);
|
||||
switch (move) {
|
||||
case OW.CUT: {
|
||||
const block = this.fieldMoveBlock(x, y, tile, "cut");
|
||||
return (
|
||||
badge(OW.ENGINE_HIVEBADGE) ??
|
||||
(block
|
||||
? {
|
||||
script: OVERWORLD.Script_CutFromMenu,
|
||||
fieldMove: { mon, block },
|
||||
}
|
||||
: { texts: [OVERWORLD.CutNothingText] })
|
||||
);
|
||||
}
|
||||
case OW.SURF: {
|
||||
const noBadge = badge(OW.ENGINE_FOGBADGE);
|
||||
if (noBadge) return noBadge;
|
||||
const cant = { texts: [OVERWORLD.CantSurfText] };
|
||||
if (state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)) return cant;
|
||||
if (
|
||||
state.playerState === OW.PLAYER_SURF ||
|
||||
state.playerState === OW.PLAYER_SURF_PIKA
|
||||
)
|
||||
return { texts: [OVERWORLD.AlreadySurfingText] };
|
||||
const current = this.assets.collision(this.map, this.x, this.y);
|
||||
if (
|
||||
this.assets.permission(tile) !== OW.WATER_TILE ||
|
||||
this.sideBlocked(current, tile, this.player.direction) ||
|
||||
facingObject()
|
||||
)
|
||||
return cant;
|
||||
return {
|
||||
script: OVERWORLD.SurfFromMenuScript,
|
||||
fieldMove: {
|
||||
mon,
|
||||
// GetSurfType: Pikachu surfs on its own sprite.
|
||||
surfState:
|
||||
state.party[mon].species === OW.PIKACHU
|
||||
? OW.PLAYER_SURF_PIKA
|
||||
: OW.PLAYER_SURF,
|
||||
},
|
||||
};
|
||||
}
|
||||
case OW.STRENGTH:
|
||||
return (
|
||||
badge(OW.ENGINE_PLAINBADGE) ?? {
|
||||
script: OVERWORLD.Script_StrengthFromMenu,
|
||||
fieldMove: { mon },
|
||||
}
|
||||
);
|
||||
case OW.FLASH: {
|
||||
const noBadge = badge(OW.ENGINE_ZEPHYRBADGE);
|
||||
if (noBadge) return noBadge;
|
||||
// SpecialAerodactylChamber opens its wall; otherwise the map must
|
||||
// be dark (DARKNESS_PALSET).
|
||||
const chamber =
|
||||
this.map.attributes ===
|
||||
OVERWORLD.RuinsOfAlphAerodactylChamber_MapAttributes;
|
||||
if (chamber) state.events.add(EVENTS.wallOpenedInAerodactylChamber);
|
||||
return chamber || this.timeOfDay === 3
|
||||
? { script: OVERWORLD.Script_UseFlash, fieldMove: { mon } }
|
||||
: failed;
|
||||
}
|
||||
case OW.WHIRLPOOL: {
|
||||
const noBadge = badge(OW.ENGINE_GLACIERBADGE);
|
||||
if (noBadge) return noBadge;
|
||||
const block = isWhirlpool(tile)
|
||||
? this.fieldMoveBlock(x, y, tile, "whirlpool")
|
||||
: undefined;
|
||||
return block
|
||||
? {
|
||||
script: OVERWORLD.Script_WhirlpoolFromMenu,
|
||||
fieldMove: { mon, block },
|
||||
}
|
||||
: failed;
|
||||
}
|
||||
case OW.WATERFALL: {
|
||||
const noBadge = badge(OW.ENGINE_RISINGBADGE);
|
||||
if (noBadge) return noBadge;
|
||||
// CheckMapCanWaterfall: facing up at a waterfall tile.
|
||||
return this.player.direction === "up" &&
|
||||
(tile === OW.COLL_WATERFALL || tile === OW.COLL_CURRENT_DOWN)
|
||||
? { script: OVERWORLD.Script_WaterfallFromMenu, fieldMove: { mon } }
|
||||
: failed;
|
||||
}
|
||||
case OW.FLY:
|
||||
return (
|
||||
badge(OW.ENGINE_STORMBADGE) ??
|
||||
(outdoors ? { notice: "The Fly map is not ported yet." } : failed)
|
||||
);
|
||||
case OW.DIG:
|
||||
return env === OW.CAVE || env === OW.DUNGEON
|
||||
? { notice: "Digging out of a cave is not ported yet." }
|
||||
: { texts: [OVERWORLD["EscapeRopeOrDig.CantUseDigText"]] };
|
||||
case OW.TELEPORT:
|
||||
return outdoors && state.lastSpawnMap !== null
|
||||
? { notice: "Teleport is not ported yet." }
|
||||
: { texts: [OVERWORLD["TeleportFunction.CantUseTeleportText"]] };
|
||||
case OW.HEADBUTT:
|
||||
return tile === OW.COLL_HEADBUTT_TREE ||
|
||||
tile === OW.COLL_HEADBUTT_TREE_1D
|
||||
? { notice: "Headbutt trees are not ported yet." }
|
||||
: failed;
|
||||
case OW.ROCK_SMASH:
|
||||
return facingObject()?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK
|
||||
? { notice: "Smashing rocks is not ported yet." }
|
||||
: failed;
|
||||
case OW.SWEET_SCENT: {
|
||||
// SweetScentEncounter, drawn now: no rate roll, and no battle
|
||||
// without a rate or a valid Pokémon.
|
||||
let wild: { species: number; level: number } | undefined;
|
||||
if (this.canEncounterWildMon(state)) {
|
||||
const table = this.encounterTable(state);
|
||||
if (table.rate) wild = this.chooseWildEncounter(table);
|
||||
}
|
||||
return {
|
||||
script: OVERWORLD["SweetScentFromMenu.SweetScent"],
|
||||
fieldMove: { mon },
|
||||
wild,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { notice: "This field move is not ported yet." };
|
||||
}
|
||||
|
||||
/** IsInJohto: Kanto landmarks start at KANTO_LANDMARK; the Fast Ship
|
||||
@@ -1655,9 +1837,13 @@ export class GameWorld {
|
||||
text: () => this.host(PLAYER).text(),
|
||||
};
|
||||
}
|
||||
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
|
||||
useSelect(): boolean {
|
||||
return this.openStartMenu(true);
|
||||
}
|
||||
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
|
||||
* the player stands still. Its changes commit when it closes. */
|
||||
openStartMenu(): boolean {
|
||||
openStartMenu(select = false): boolean {
|
||||
if (this.busy || this.introPending) return false;
|
||||
this.clearNotice();
|
||||
this.script = {
|
||||
@@ -1667,11 +1853,14 @@ export class GameWorld {
|
||||
reader: this.assets.reader,
|
||||
data: this.assets.pokemon,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bicycleScript: (state) => this.bicycleScript(state),
|
||||
bicycleScript: (state, select) => this.bicycleScript(state, select),
|
||||
timeOfDay: () => this.clockTimeOfDay,
|
||||
landmark: () => this.assets.map(this.mapId).landmark,
|
||||
fieldMove: (state, mon, move) =>
|
||||
this.menuFieldMove(state, mon, move),
|
||||
},
|
||||
this.menuCursors,
|
||||
select,
|
||||
),
|
||||
committed: this.save(),
|
||||
rollback: {
|
||||
@@ -2396,12 +2585,17 @@ export class GameWorld {
|
||||
this.sceneCheck = true;
|
||||
if (script.run instanceof StartMenuRun) this.menuCursors = script.run.cursors;
|
||||
if (script.run.deferred !== undefined)
|
||||
this.startScript(script.run.deferred, PLAYER, {
|
||||
usedItem:
|
||||
script.run instanceof StartMenuRun
|
||||
? script.run.deferredItem
|
||||
: undefined,
|
||||
});
|
||||
this.startScript(
|
||||
script.run.deferred,
|
||||
PLAYER,
|
||||
script.run instanceof StartMenuRun
|
||||
? {
|
||||
usedItem: script.run.deferredItem,
|
||||
fieldMove: script.run.deferredMove?.fieldMove,
|
||||
wild: script.run.deferredMove?.wild,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
}
|
||||
private abortScript(error: unknown) {
|
||||
const script = this.script!;
|
||||
@@ -3228,7 +3422,7 @@ export class GameWorld {
|
||||
}
|
||||
/** BikeFunction.TryBike: the script QueueScript runs, or undefined when
|
||||
* .CheckEnvironment or the player's state refuses the Bicycle. */
|
||||
private bicycleScript(state: ScriptState): number | undefined {
|
||||
private bicycleScript(state: ScriptState, select = false): number | undefined {
|
||||
const map = this.map;
|
||||
const outdoors =
|
||||
map.environment === OW.TOWN || map.environment === OW.ROUTE;
|
||||
@@ -3239,12 +3433,17 @@ export class GameWorld {
|
||||
this.assets.permission(this.assets.collision(map, this.x, this.y)) ===
|
||||
OW.LAND_TILE;
|
||||
if (!allowed) return undefined;
|
||||
// .CheckIfRegistered: Select uses the scripts without a message.
|
||||
if (state.playerState === OW.PLAYER_NORMAL)
|
||||
return OVERWORLD.Script_GetOnBike;
|
||||
return select
|
||||
? OVERWORLD.Script_GetOnBike_Register
|
||||
: OVERWORLD.Script_GetOnBike;
|
||||
if (state.playerState === OW.PLAYER_BIKE)
|
||||
return state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
|
||||
? OVERWORLD.Script_CantGetOffBike
|
||||
: OVERWORLD.Script_GetOffBike;
|
||||
: select
|
||||
? OVERWORLD.Script_GetOffBike_Register
|
||||
: OVERWORLD.Script_GetOffBike;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3484,6 +3683,7 @@ export class GameWorld {
|
||||
backupWarp: state.backupWarp && { ...state.backupWarp },
|
||||
flash: state.flash,
|
||||
noWildEncounters: state.noWildEncounters,
|
||||
registeredItem: { ...state.registeredItem },
|
||||
};
|
||||
}
|
||||
restore(value: unknown) {
|
||||
@@ -3579,7 +3779,10 @@ export class GameWorld {
|
||||
!/^\d{1,2}:\d{1,3}$/.test(save.backupWarp.map) ||
|
||||
!integer(save.backupWarp.warp, 1, 254))) ||
|
||||
typeof save.flash !== "boolean" ||
|
||||
typeof save.noWildEncounters !== "boolean"
|
||||
typeof save.noWildEncounters !== "boolean" ||
|
||||
!save.registeredItem ||
|
||||
!integer(save.registeredItem.which, 0, 255) ||
|
||||
!integer(save.registeredItem.item, 0, 255)
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid native save. Emulator snapshots cannot be imported into this port.",
|
||||
@@ -3641,6 +3844,7 @@ export class GameWorld {
|
||||
backupWarp: save.backupWarp && { ...save.backupWarp },
|
||||
flash: save.flash,
|
||||
noWildEncounters: save.noWildEncounters,
|
||||
registeredItem: { ...save.registeredItem },
|
||||
specialPhoneCall: save.specialPhoneCall,
|
||||
rivalName: save.rivalName,
|
||||
};
|
||||
@@ -3738,6 +3942,9 @@ export function migrateSave(
|
||||
...(!Object.hasOwn(save, "noWildEncounters") && {
|
||||
noWildEncounters: false,
|
||||
}),
|
||||
...(!Object.hasOwn(save, "registeredItem") && {
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
}),
|
||||
...(!Object.hasOwn(save, "intro") && { intro: false }),
|
||||
// Clocks saved before InitClock was ported follow the device time.
|
||||
...(save.clock !== null &&
|
||||
@@ -3779,6 +3986,7 @@ export function migrateSave(
|
||||
backupWarp: null,
|
||||
flash: false,
|
||||
noWildEncounters: false,
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
intro: false,
|
||||
} as unknown as NativeSave;
|
||||
}
|
||||
+6
-58
@@ -1,5 +1,4 @@
|
||||
import { drawTownMap } from "./game/rendering/town-map";
|
||||
import { OW } from "./game/world/constants";
|
||||
import "./ui/styles.css";
|
||||
import { renderShell } from "./ui/shell";
|
||||
import { bindTabs } from "./ui/tabs";
|
||||
@@ -48,7 +47,6 @@ function showWorld(world: GameWorld) {
|
||||
get("game-notice").textContent = world.notice;
|
||||
showBattle(world);
|
||||
showStatsScreen(world);
|
||||
showFieldMenu(world);
|
||||
get("town-map-view").hidden = !world.townMap;
|
||||
if (world.townMap)
|
||||
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
|
||||
@@ -438,64 +436,14 @@ let statsView: { slot?: number; page: number } | undefined;
|
||||
let battleCursor = 0;
|
||||
let battleSelectionKey = "";
|
||||
|
||||
let fieldCursor = 0;
|
||||
function fieldButtons() {
|
||||
return [
|
||||
...get("field-items").querySelectorAll<HTMLButtonElement>(
|
||||
"button:not(:disabled)",
|
||||
),
|
||||
];
|
||||
}
|
||||
function showFieldMenu(world: GameWorld) {
|
||||
const panel = get("field-menu");
|
||||
panel.hidden = !runtime.fieldMenuOpen;
|
||||
if (panel.hidden) return;
|
||||
const buttons = world.state.bag.keyItems.map((item) => {
|
||||
const button = document.createElement("button");
|
||||
button.textContent = world.assets.pokemon.itemName(item);
|
||||
button.disabled = item !== OW.BICYCLE;
|
||||
button.onclick = () => {
|
||||
if (session.state !== "playing") return;
|
||||
runtime.useBicycle();
|
||||
canvas.focus({ preventScroll: true });
|
||||
};
|
||||
return button;
|
||||
});
|
||||
const close = document.createElement("button");
|
||||
close.textContent = "CLOSE";
|
||||
close.onclick = () => {
|
||||
runtime.fieldMenuOpen = false;
|
||||
showFieldMenu(world);
|
||||
canvas.focus();
|
||||
};
|
||||
get("field-items").replaceChildren(...buttons, close);
|
||||
fieldButtons().forEach((button, i) =>
|
||||
button.classList.toggle("battle-selected", i === fieldCursor),
|
||||
);
|
||||
}
|
||||
/** Start opens the start menu and Select uses the registered item, while
|
||||
* the player stands still (StartMenuScript and SelectMenuScript). */
|
||||
function handleFieldInput(button: Button): boolean {
|
||||
const world = runtime.world;
|
||||
if (!world || world.battle) return false;
|
||||
// Start opens the start menu; Select, standing in for a registered
|
||||
// item, opens the Key Items shortcut.
|
||||
if (button === "start" && !runtime.fieldMenuOpen && !world.busy) {
|
||||
runtime.openStartMenu();
|
||||
return true;
|
||||
}
|
||||
if (button === "select" && !world.busy) {
|
||||
runtime.fieldMenuOpen = !runtime.fieldMenuOpen;
|
||||
fieldCursor = 0;
|
||||
return true;
|
||||
}
|
||||
if (!runtime.fieldMenuOpen) return false;
|
||||
if (button === "b") runtime.fieldMenuOpen = false;
|
||||
else if (button === "a") fieldButtons()[fieldCursor]?.click();
|
||||
else if (["up", "down", "left", "right"].includes(button)) {
|
||||
const length = fieldButtons().length;
|
||||
fieldCursor =
|
||||
(fieldCursor + (button === "up" || button === "left" ? -1 : 1) + length) %
|
||||
length;
|
||||
}
|
||||
if (!world || world.battle || world.busy) return false;
|
||||
if (button === "start") runtime.openStartMenu();
|
||||
else if (button === "select") runtime.useSelect();
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ export function renderShell(root: HTMLElement) {
|
||||
</div>
|
||||
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
|
||||
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
|
||||
<div id="field-menu" class="field-menu" hidden aria-label="Pack"><p>KEY ITEMS</p><div id="field-items"></div></div>
|
||||
<div id="mart" class="mart" hidden aria-label="Poké Mart">
|
||||
<p id="mart-money" class="mart-money"></p>
|
||||
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-tabs" class="pack-tabs" aria-label="Pockets" hidden></div><div id="mart-menu" class="mart-menu"></div></div>
|
||||
|
||||
@@ -1117,30 +1117,6 @@ dialog dd {
|
||||
}
|
||||
}
|
||||
|
||||
.field-menu {
|
||||
position: absolute;
|
||||
inset: 1rem 1rem auto auto;
|
||||
z-index: 6;
|
||||
max-height: calc(100% - 2rem);
|
||||
overflow: auto;
|
||||
min-width: 10rem;
|
||||
padding: 1rem;
|
||||
border: 2px solid #27332a;
|
||||
border-radius: 0.4rem;
|
||||
background: #f8f4df;
|
||||
color: #27332a;
|
||||
}
|
||||
.field-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.field-menu p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
#field-items {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.town-map-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
Reference in new issue
Block a user