Port field moves from the menu, SEL with Select, and moving Pokémon in Bill's PC
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:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 17:22:56 +02:00
1 parent 45275ba83b
commit 3456aa4e11
25 files changed
+1129 -186

No files matched your search

+2
View File
@@ -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,
+9
View File
@@ -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[],
+1
View File
@@ -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: [],
+34
View File
@@ -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
View File
@@ -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)),
);
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 => ({
+9
View File
@@ -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,
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
-1
View File
@@ -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>
-24
View File
@@ -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;