Finish the opening milestone's switching, items and acceptance run

- Decode evolution lists from the ROM; all 251 match the pinned PRET source.
- Compare CalcMonStats and CalcExpAtLevel against the original routines in
  the reference emulator.
- Add a continuous opening playthrough from the intro to the catching
  tutorial, reloading a JSON save after every stage.
- Port player switching in battle: the PKMN menu, forced choices after a
  faint, Shift mode's offer and double faints, with the original texts.
- Port HP and status healing items in battle through the ROM's ItemEffects,
  HealingHPAmounts and StatusHealingActions tables.
- Add the battle party menu and the Items pocket to the UI.

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 11:04:11 +02:00
1 parent eb03a20d8f
commit f8e0f9c65a
20 files changed
+1902 -96

No files matched your search

+86 -12
View File
@@ -379,8 +379,11 @@ function drawPic(
const battlePics = { foe: "", player: "" };
let battleMenuKey = "";
let showMoves = false;
/** BattlePack's Balls pocket, the only pocket usable in battle so far. */
/** BattlePack, opened on the last pocket used (wLastPocket). */
let showPack = false;
let packPocket: "item" | "ball" = "item";
/** The party slot whose BattleMonMenu (SWITCH/STATS/CANCEL) is open. */
let partyChoice: number | undefined;
let battleCursor = 0;
let battleSelectionKey = "";
@@ -460,6 +463,11 @@ function handleBattleInput(button: Button): boolean {
showMoves = showPack = false;
return true;
}
if (button === "b" && partyChoice !== undefined) {
partyChoice = undefined;
showWorld(world);
return true;
}
const buttons = battleButtons();
if (!buttons.length) return false;
if (button === "a") {
@@ -530,22 +538,38 @@ function showBattle(world: GameWorld) {
}
const prompt = view.prompt;
get("battle-text").textContent =
prompt?.kind === "name" ? prompt.title : (view.message?.text ?? "");
prompt?.kind === "name"
? prompt.title
: prompt?.kind === "party"
? prompt.text
: (view.message?.text ?? "");
if (prompt?.kind !== "action") showMoves = showPack = false;
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}`;
if (prompt?.kind !== "party") partyChoice = undefined;
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}:${partyChoice}`;
if (selectionKey !== battleSelectionKey) {
battleCursor = 0;
battleSelectionKey = selectionKey;
}
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
const balls = world.liveState.bag.balls;
const bag = world.liveState.bag;
// The Pokémon out has its live battle HP; the others their party values.
const party = world.liveState.party.map((mon, index) =>
index === view.engine.curBattleMon && own
? { ...mon, hp: own.hp, status: own.status }
: mon,
);
const key = JSON.stringify([
prompt,
view.selected,
showMoves,
showPack,
partyChoice,
packPocket,
own?.pp,
balls,
bag.items,
bag.balls,
prompt?.kind === "party" &&
party.map(({ hp, status, level }) => [hp, status, level]),
view.message?.prompt,
]);
if (key === battleMenuKey) return;
@@ -610,7 +634,19 @@ function showBattle(world: GameWorld) {
),
);
} else if (showPack) {
balls.forEach(({ item, quantity }, slot) =>
for (const pocket of ["item", "ball"] as const)
buttons.push(
button(
data.pocketName(pocket),
() => {
packPocket = pocket;
showWorld(world);
},
{ pressed: packPocket === pocket },
),
);
const slots = packPocket === "item" ? bag.items : bag.balls;
slots.forEach(({ item, quantity }, slot) =>
buttons.push(
button(
data.itemName(item),
@@ -622,10 +658,11 @@ function showBattle(world: GameWorld) {
),
),
);
if (!balls.length)
if (!slots.length)
buttons.push(
button("No BALLS", () => {}, {
disabled: "The BALL pocket is empty.",
button("Empty", () => {}, {
disabled: `The ${data.pocketName(packPocket)} is empty.`,
wide: true,
}),
);
buttons.push(
@@ -644,9 +681,7 @@ function showBattle(world: GameWorld) {
showMoves = true;
showWorld(world);
}),
button("PKMN", () => {}, {
disabled: "Switching Pokémon is not ported yet.",
}),
button("PKMN", () => runtime.battleAction({ action: "pkmn" })),
button("PACK", () => {
showPack = true;
showWorld(world);
@@ -663,6 +698,45 @@ function showBattle(world: GameWorld) {
pressed: view.selected === 0,
}),
);
} else if (prompt?.kind === "party" && partyChoice !== undefined) {
// BattleMonMenu. STATS opens the stats screen, which is not ported.
const slot = partyChoice;
buttons.push(
button("SWITCH", () => {
partyChoice = undefined;
runtime.battleChoose(slot);
}),
button("STATS", () => {}, {
disabled: "The stats screen is not ported yet.",
}),
button("CANCEL", () => {
partyChoice = undefined;
showWorld(world);
}),
);
} else if (prompt?.kind === "party") {
party.forEach((mon, slot) =>
buttons.push(
button(
mon.nickname,
() => {
if (!prompt.menu) return runtime.battleChoose(slot);
partyChoice = slot;
showWorld(world);
},
{
detail: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${
mon.status ? ` ${statusName(mon.status)}` : ""
}`,
wide: true,
},
),
),
);
if (prompt.cancel)
buttons.push(
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
);
} else if (prompt?.kind === "forget") {
prompt.moves.forEach((move, index) =>
buttons.push(