Port the trainer card and the play time

The player's name in the start menu opens TrainerCard: page 1 with the
name, ID, money, Pokédex count and play time, page 2 with the Johto
leaders and the badges owned, turned and closed with the original
buttons. The card picture, leader faces and animated badges come from the
ROM with _CGB_TrainerCard's palettes. GameTimer now counts the play time
from the end of the introduction, capped at 999:59:59, and saves keep it.

Closes #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-01 13:32:14 +02:00
1 parent 38b9562bd3
commit 47698565ab
24 files changed
+954 -30

No files matched your search

+85 -2
View File
@@ -1,4 +1,8 @@
import { drawTownMap } from "./game/rendering/town-map";
import {
drawCardPic,
drawTrainerCardBadges,
} from "./game/rendering/trainer-card";
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { bindTabs } from "./ui/tabs";
@@ -49,6 +53,7 @@ function showWorld(world: GameWorld) {
showBattle(world);
showStatsScreen(world);
showPokegear(world);
showTrainerCard(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
@@ -64,6 +69,7 @@ function showWorld(world: GameWorld) {
pictureWait ||
// The Pokégear's map and radio cards have no textbox.
(dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) ||
dialogue.choice?.kind === "trainercard" ||
Boolean(mart) ||
world.townMap ||
Boolean(world.statsScreen);
@@ -117,8 +123,8 @@ function showChoice(choice: Choice | undefined) {
return element;
};
if (!choice) container.replaceChildren();
else if (choice.kind === "pokegear") {
// The Pokégear reads the controls directly.
else if (choice.kind === "pokegear" || choice.kind === "trainercard") {
// The Pokégear and the trainer card read the controls directly.
container.replaceChildren();
container.hidden = true;
} else if (choice.kind === "yesno")
@@ -946,6 +952,83 @@ function showPokegear(world: GameWorld) {
} else body.replaceChildren();
}
let trainerCardKey = "";
/** TrainerCard (engine/menus/trainer_card.asm): the top half with the
* name, ID, money and card picture, then page 1's Pokédex count and play
* time or page 2's leaders and badges. */
function showTrainerCard(world: GameWorld) {
const view = world.trainerCard;
const panel = get("trainer-card");
panel.hidden = !view;
if (!view) {
trainerCardKey = "";
return;
}
const key = JSON.stringify(view);
if (key === trainerCardKey) return;
const first = !trainerCardKey;
trainerCardKey = key;
const element = <K extends keyof HTMLElementTagNameMap>(
tag: K,
text = "",
) => {
const node = document.createElement(tag);
node.textContent = text;
return node;
};
const rows = (list: [string, string][]) => {
const dl = element("dl");
for (const [term, value] of list)
dl.append(element("dt", term), element("dd", value));
return dl;
};
if (first) {
// The frame in the other gender's palette, the picture in the player's.
const frame = world.assets.pokemon.trainerPalette(view.female ? 0 : 1)[1];
panel.style.setProperty("--trainer-card-frame", `rgb(${frame.join(" ")})`);
const pic = get<HTMLCanvasElement>("trainer-card-pic");
drawCardPic(pic, world.assets, view.female);
pic.setAttribute("aria-label", view.female ? "Kris" : "Chris");
}
const { labels } = view;
get("trainer-card-info").replaceChildren(
...rows([
[labels.name, view.name],
["ID NO", view.id],
[labels.money, `¥${view.money}`],
]).childNodes,
);
const page = get("trainer-card-page");
if (view.page === 1) {
const { hours, minutes } = view.time;
const time = `${String(hours).padStart(4, " ")}${view.colon ? ":" : " "}${String(minutes).padStart(2, "0")}`;
const list: [string, string][] = [];
if (view.pokedex !== undefined)
list.push([labels.pokedex, String(view.pokedex).padStart(3, " ")]);
list.push([labels.playTime, time]);
const status = rows(list);
status.setAttribute(
"aria-label",
`Play time ${hours} hours ${minutes} minutes`,
);
page.replaceChildren(element("h3", "STATUS"), status, element("p", labels.badges));
} else {
const canvas = element("canvas");
canvas.setAttribute("role", "img");
let owned = 0;
for (let bit = 0; bit < 8; bit++) if (view.badges & (1 << bit)) owned++;
canvas.setAttribute("aria-label", `${owned} of 8 Johto badges`);
drawTrainerCardBadges(
canvas,
world.assets,
view.female,
view.badges,
view.badgeFrame,
);
page.replaceChildren(element("h3", labels.badges.replace("▶", "")), canvas);
}
}
/** 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) {