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:
1 parent
cfd083c16d
commit
b13e09ec20
22 files changed
+1547
-171
No files matched your search
+193
-6
@@ -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 });
|
||||
};
|
||||
Reference in new issue
Block a user