Port the remaining battle items, the stats screen and hBattleTurn at the menu

- Run every non-ball item effect usable in battle from the ROM's
  ItemEffects: Full Restore, revives, bitter medicine, Bitter Berry, Ethers
  and Elixers, X items, X Accuracy, Guard Spec, Dire Hit and Poké Doll.
- Read happiness changes from HappinessChanges, including the larger
  level-up gain where a Pokémon was caught.
- Model hBattleTurn at the battle menu, including the trainer AI's writes,
  and drop the status-heal refusal.
- Port the stats screen for battles and Bill's PC.
- Compare the send-out and withdraw texts, RestoreHealth, RestorePP,
  ChangeHappiness and CalcPlayerStats with the original routines.

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:24:55 +02:00
1 parent cfd083c16d
commit b13e09ec20
22 files changed
+1547 -171

No files matched your search

+193 -6
View File
@@ -10,7 +10,7 @@ import { drawTile } from "./game/rendering/pixels";
import { PokemonData } from "./game/rom/pokemon";
import type { Direction, Palette } from "./game/world/types";
import type { Button } from "./input/types";
import { NICKNAME_PATTERN } from "./game/party/pokemon";
import { NICKNAME_PATTERN, maxPP } from "./game/party/pokemon";
import { GameSession } from "./app/session";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
@@ -47,6 +47,7 @@ function showWorld(world: GameWorld) {
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
get("game-notice").textContent = world.notice;
showBattle(world);
showStatsScreen(world);
showFieldMenu(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
@@ -59,7 +60,11 @@ function showWorld(world: GameWorld) {
);
const mart = dialogue?.choice && martChoice(dialogue.choice);
get("native-dialogue").hidden =
!dialogue || pictureWait || Boolean(mart) || world.townMap;
!dialogue ||
pictureWait ||
Boolean(mart) ||
world.townMap ||
Boolean(world.statsScreen);
get("pokepic-next").hidden = !pictureWait;
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
get("dialogue-page").textContent =
@@ -384,6 +389,9 @@ let showPack = false;
let packPocket: "item" | "ball" = "item";
/** The party slot whose BattleMonMenu (SWITCH/STATS/CANCEL) is open. */
let partyChoice: number | undefined;
/** The stats screen and its page (pink, green, blue): opened from
* BattleMonMenu for a party slot, or by a script (Bill's PC) without one. */
let statsView: { slot?: number; page: number } | undefined;
let battleCursor = 0;
let battleSelectionKey = "";
@@ -458,6 +466,18 @@ function highlightBattleChoice() {
function handleBattleInput(button: Button): boolean {
if (handleFieldInput(button)) return true;
const world = runtime.world;
if (statsView && world) {
// StatsScreen_JoypadAction: left and right turn pages, B leaves.
// BattleMenuPKMN_ReturnFromStats reopens the party menu itself.
if (button === "b" && statsView.slot === undefined) world.advanceDialogue();
else if (button === "b") statsView = partyChoice = undefined;
else if (button === "left" || button === "up")
statsView.page = (statsView.page + 2) % 3;
else if (button === "right" || button === "down" || button === "a")
statsView.page = (statsView.page + 1) % 3;
showWorld(world);
return true;
}
if (!world?.battle) return false;
if (button === "b" && (showMoves || showPack)) {
showMoves = showPack = false;
@@ -540,11 +560,14 @@ function showBattle(world: GameWorld) {
get("battle-text").textContent =
prompt?.kind === "name"
? prompt.title
: prompt?.kind === "party"
: prompt?.kind === "party" || prompt?.kind === "move"
? prompt.text
: (view.message?.text ?? "");
if (prompt?.kind !== "action") showMoves = showPack = false;
if (prompt?.kind !== "party") partyChoice = undefined;
if (prompt?.kind !== "party") {
partyChoice = undefined;
if (statsView?.slot !== undefined) statsView = undefined;
}
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}:${partyChoice}`;
if (selectionKey !== battleSelectionKey) {
battleCursor = 0;
@@ -706,8 +729,9 @@ function showBattle(world: GameWorld) {
partyChoice = undefined;
runtime.battleChoose(slot);
}),
button("STATS", () => {}, {
disabled: "The stats screen is not ported yet.",
button("STATS", () => {
statsView = { slot, page: 0 };
showWorld(world);
}),
button("CANCEL", () => {
partyChoice = undefined;
@@ -737,6 +761,18 @@ function showBattle(world: GameWorld) {
buttons.push(
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
);
} else if (prompt?.kind === "move") {
prompt.moves.forEach((move, index) => {
if (!move) return;
buttons.push(
button(data.moveName(move), () => runtime.battleChoose(index), {
detail: `PP ${prompt.pp[index] & 63}`,
}),
);
});
buttons.push(
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
);
} else if (prompt?.kind === "forget") {
prompt.moves.forEach((move, index) =>
buttons.push(
@@ -774,6 +810,143 @@ function showBattle(world: GameWorld) {
);
highlightBattleChoice();
}
/** The stats screen's pages (engine/pokemon/stats_screen.asm), with its
* strings, type names and stat labels read from the ROM. */
function showStatsScreen(world: GameWorld) {
const panel = get("stats-screen");
const view = world.battle;
// A script's stats screen opens on the first page and closes with it.
if (world.statsScreen && !statsView) statsView = { page: 0 };
if (!world.statsScreen && statsView?.slot === undefined) statsView = undefined;
const slot = statsView?.slot;
const own = view?.engine.player.mon;
const stored =
slot === undefined ? world.statsScreen : world.liveState.party[slot];
panel.hidden = !statsView || !stored;
if (!statsView || !stored) return;
const data = world.assets.pokemon;
const mon =
view && slot === view.engine.curBattleMon && own
? { ...stored, hp: own.hp, status: own.status }
: stored;
const shiny = PokemonData.shiny(mon.dvs);
const text = (label: Parameters<PokemonData["statsString"]>[0]) =>
data.statsString(label);
drawPic(
get("stats-pic"),
data.frontPic(mon.species),
7,
data.palette(mon.species, shiny),
);
get("stats-pic").setAttribute("aria-label", data.speciesName(mon.species));
const gender = data.gender(mon.species, mon.dvs);
const line = (...parts: (string | Node)[]) => {
const p = document.createElement("p");
p.append(...parts);
return p;
};
const name = document.createElement("strong");
name.textContent = `${mon.nickname}${gender === "male" ? "♂" : gender === "female" ? "♀" : ""}`;
get("stats-header").replaceChildren(
line(
`№.${String(mon.species).padStart(3, "0")} :L${mon.level}${shiny ? " ★" : ""}`,
),
line(name),
line(`/${data.speciesName(mon.species)}`),
);
const list = (rows: [string, string][], wide = false) => {
const dl = document.createElement("dl");
if (wide) dl.classList.add("wide");
for (const [term, value] of rows) {
const dt = document.createElement("dt"),
dd = document.createElement("dd");
dt.textContent = term;
dd.textContent = value;
dl.append(dt, dd);
}
return dl;
};
const growth = data.baseData(mon.species).growthRate;
const page: Node[] = [];
if (statsView.page === 0) {
const [statusLabel, typeLabel] = text("LoadPinkPage.Status_Type").split(
"\n",
);
const types = [...new Set(data.baseData(mon.species).types)].map((type) =>
data.typeName(type),
);
const next = Math.min(mon.level + 1, 100);
page.push(
list([
["HP", `${mon.hp}/ ${mon.stats[0]}`],
[
statusLabel,
mon.pokerus & 0x0f
? text("LoadPinkPage.PkrsStr")
: !mon.hp
? "FNT"
: statusName(mon.status) || text("LoadPinkPage.OK_str").trim(),
],
[typeLabel, types.join(" / ")],
]),
list([
[text("LoadPinkPage.ExpPointStr"), String(mon.exp)],
[
text("LoadPinkPage.LevelUpStr"),
String(
mon.level >= 100 ? 0 : data.expAtLevel(growth, next) - mon.exp,
),
],
[text("LoadPinkPage.ToStr"), `:L${next}`],
]),
);
} else if (statsView.page === 1) {
page.push(
list(
[
[
text("LoadGreenPage.Item"),
mon.item
? data.itemName(mon.item)
: text("LoadGreenPage.ThreeDashes"),
],
...mon.moves.flatMap((move, index): [string, string][] =>
move
? [
[
index ? "" : text("LoadGreenPage.Move"),
`${data.moveName(move)} PP ${mon.pp[index] & 63}/${maxPP(data, move, mon.pp[index] >> 6) & 63}`,
],
]
: [],
),
],
true,
),
);
} else {
// The OT's gender comes from the caught data unless it is unset.
const caught = mon.caughtData & 0xff;
const otGender =
!caught || caught === 0x7f ? "" : caught & 0x80 ? "♀" : "♂";
const labels = data.statNames();
page.push(
list([
[text("IDNoString"), String(mon.otId).padStart(5, "0")],
[text("OTString"), `${mon.ot}${otGender}`],
]),
list(
labels.map((label, index): [string, string] => [
label,
String(mon.stats[[1, 2, 4, 5, 3][index]]),
]),
),
);
}
get("stats-page").replaceChildren(...page);
panel.dataset.page = String(statsView.page);
get("stats-page-number").textContent = `${statsView.page + 1} / 3`;
}
function setHp(bar: HTMLElement, hp: number, max: number) {
const fraction = max ? hp / max : 0;
bar.style.width = `${Math.round(fraction * 100)}%`;
@@ -1067,3 +1240,17 @@ get("town-map-close").onclick = () => {
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
};
for (const [id, turn] of [
["stats-prev", 2],
["stats-next", 1],
["stats-close", 0],
] as const)
get(id).onclick = () => {
const world = runtime.world;
if (session.state !== "playing" || !statsView || !world) return;
if (turn) statsView.page = (statsView.page + turn) % 3;
else if (statsView.slot === undefined) runtime.advanceDialogue();
else statsView = partyChoice = undefined;
showWorld(world);
canvas.focus({ preventScroll: true });
};