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:
1 parent
38b9562bd3
commit
47698565ab
24 files changed
+954
-30
No files matched your search
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld, migrateSave } from "../src/game/world/world";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { ENGINE_FLAGS } from "../src/game/rom/profile";
|
||||
import { OW } from "../src/game/world/constants";
|
||||
import {
|
||||
advanceGameTime,
|
||||
emptyGameTime,
|
||||
type GameTime,
|
||||
} from "../src/game/world/game-time";
|
||||
import type { TrainerCardView } from "../src/game/scripts/trainer-card";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const NEW_BARK_TOWN = "24:4",
|
||||
CYNDAQUIL = 155;
|
||||
|
||||
describe("the game timer", () => {
|
||||
const frames = (time: GameTime, count: number) => {
|
||||
for (let i = 0; i < count; i++) advanceGameTime(time);
|
||||
return time;
|
||||
};
|
||||
it("counts 60 frames to the second and carries into hours", () => {
|
||||
expect(frames(emptyGameTime(), 59)).toEqual({ ...emptyGameTime(), frames: 59 });
|
||||
expect(frames(emptyGameTime(), 60)).toEqual({ ...emptyGameTime(), seconds: 1 });
|
||||
const time = { hours: 4, minutes: 59, seconds: 59, frames: 59, capped: false };
|
||||
expect(frames(time, 1)).toEqual({ ...emptyGameTime(), hours: 5 });
|
||||
});
|
||||
it("caps at 999:59:59.00 when it would reach 1000 hours", () => {
|
||||
const time = { hours: 999, minutes: 59, seconds: 59, frames: 58, capped: false };
|
||||
frames(time, 1);
|
||||
expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 59, capped: false });
|
||||
frames(time, 1);
|
||||
expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 0, capped: true });
|
||||
frames(time, 5000);
|
||||
expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 0, capped: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the trainer card", () => {
|
||||
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
function fixture(avatar: "chris" | "kris" = "chris") {
|
||||
const world = new GameWorld(
|
||||
assets(),
|
||||
() => new Date("2026-09-25T12:00:00"),
|
||||
() => 0x42,
|
||||
);
|
||||
world.state.party = [
|
||||
generatePartyMon(world.assets.pokemon, {
|
||||
species: CYNDAQUIL,
|
||||
level: 8,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x9abc,
|
||||
caughtData: 0,
|
||||
}),
|
||||
];
|
||||
world.state.money = 3456;
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map: NEW_BARK_TOWN,
|
||||
x: 13,
|
||||
y: 7,
|
||||
direction: "down",
|
||||
objects: [],
|
||||
name: avatar === "kris" ? "KRIS" : "GOLD",
|
||||
avatar,
|
||||
playerId: 417,
|
||||
});
|
||||
return world;
|
||||
}
|
||||
function open(world: GameWorld) {
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "menu") throw new Error("Expected the start menu.");
|
||||
// StartMenu.StatusString is <PLAYER>.
|
||||
const index = choice.items.indexOf(world.name);
|
||||
expect(index).toBeGreaterThan(-1);
|
||||
world.choose(index);
|
||||
return view(world);
|
||||
}
|
||||
function view(world: GameWorld): TrainerCardView {
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "trainercard")
|
||||
throw new Error(`Expected the trainer card, got ${choice?.kind}`);
|
||||
expect(world.trainerCard).toBe(choice.view);
|
||||
return choice.view;
|
||||
}
|
||||
function press(world: GameWorld, button: "a" | "b" | "up" | "down" | "left" | "right") {
|
||||
if (button === "a") world.interact();
|
||||
else if (button === "b") world.advanceDialogue();
|
||||
else world.moveCursor(button);
|
||||
}
|
||||
|
||||
it("shows the name, ID, money and play time with ROM labels", () => {
|
||||
const world = fixture();
|
||||
world.gameTime = { hours: 12, minutes: 7, seconds: 3, frames: 0, capped: false };
|
||||
const card = open(world);
|
||||
expect(card).toMatchObject({
|
||||
page: 1,
|
||||
female: false,
|
||||
name: "GOLD",
|
||||
id: "00417",
|
||||
money: 3456,
|
||||
time: { hours: 12, minutes: 7 },
|
||||
colon: false,
|
||||
badges: 0,
|
||||
labels: {
|
||||
name: "NAME/",
|
||||
money: "MONEY",
|
||||
pokedex: "POKéDEX",
|
||||
playTime: "PLAY TIME",
|
||||
badges: "BADGES▶",
|
||||
},
|
||||
});
|
||||
// Without the Pokédex its line is cleared.
|
||||
expect(card.pokedex).toBeUndefined();
|
||||
});
|
||||
|
||||
it("counts caught Pokémon once the Pokédex is owned", () => {
|
||||
const world = fixture("kris");
|
||||
world.state.flags.add(ENGINE_FLAGS.pokedex);
|
||||
world.state.pokedex.seen = new Set([1, 4, 7, 155]);
|
||||
world.state.pokedex.caught = new Set([155, 7]);
|
||||
const card = open(world);
|
||||
expect(card.pokedex).toBe(2);
|
||||
expect(card.female).toBe(true);
|
||||
expect(card.name).toBe("KRIS");
|
||||
});
|
||||
|
||||
it("turns pages and leaves with the original buttons", () => {
|
||||
const world = fixture();
|
||||
open(world);
|
||||
// Left, up and down do nothing on page 1; A or right turns the page.
|
||||
for (const button of ["left", "up", "down"] as const) {
|
||||
press(world, button);
|
||||
expect(view(world).page).toBe(1);
|
||||
}
|
||||
press(world, "right");
|
||||
expect(view(world).page).toBe(2);
|
||||
// Right does nothing on page 2; left turns back.
|
||||
press(world, "right");
|
||||
expect(view(world).page).toBe(2);
|
||||
press(world, "left");
|
||||
expect(view(world).page).toBe(1);
|
||||
press(world, "a");
|
||||
expect(view(world).page).toBe(2);
|
||||
// A on page 2 leaves, back to the start menu.
|
||||
press(world, "a");
|
||||
expect(world.trainerCard).toBeUndefined();
|
||||
expect(world.dialogue?.choice?.kind).toBe("menu");
|
||||
// B leaves from page 1.
|
||||
world.choose((world.dialogue!.choice as { items: string[] }).items.indexOf("GOLD"));
|
||||
expect(view(world).page).toBe(1);
|
||||
press(world, "b");
|
||||
expect(world.trainerCard).toBeUndefined();
|
||||
expect(world.dialogue?.choice?.kind).toBe("menu");
|
||||
settle(world, [-1]);
|
||||
expect(world.busy).toBe(false);
|
||||
expect(world.diagnostic).toBe("");
|
||||
});
|
||||
|
||||
it("keeps play time running, blinks the colon and animates owned badges", () => {
|
||||
const world = fixture();
|
||||
world.state.flags.add(OW.ENGINE_ZEPHYRBADGE);
|
||||
world.state.flags.add(OW.ENGINE_MINERALBADGE);
|
||||
world.gameTime = { hours: 0, minutes: 0, seconds: 59, frames: 40, capped: false };
|
||||
const card = open(world);
|
||||
expect(card.badges).toBe(0b10001);
|
||||
// The colon appears after 32 frames (16 ticks) and goes after 64.
|
||||
for (let tick = 0; tick < 15; tick++) world.tick();
|
||||
expect(view(world).colon).toBe(false);
|
||||
world.tick();
|
||||
expect(view(world)).toMatchObject({ colon: true, time: { hours: 0, minutes: 1 } });
|
||||
for (let tick = 0; tick < 16; tick++) world.tick();
|
||||
expect(view(world).colon).toBe(false);
|
||||
// Page 2 restarts the timing; the badges step every eight frames.
|
||||
press(world, "right");
|
||||
expect(view(world).badgeFrame).toBe(0);
|
||||
const steps = [];
|
||||
for (let tick = 0; tick < 36; tick++) {
|
||||
world.tick();
|
||||
steps.push(view(world).badgeFrame);
|
||||
}
|
||||
expect(steps.filter((_, i) => i % 4 === 3)).toEqual([1, 2, 3, 4, 5, 6, 7, 0, 1]);
|
||||
expect(view(world).colon).toBe(false);
|
||||
// Two frames per tick were counted throughout: 68 ticks from 0:59.40.
|
||||
expect(world.gameTime).toMatchObject({ minutes: 1, seconds: 1, frames: 56 });
|
||||
});
|
||||
|
||||
it("shows no badge animation without badges", () => {
|
||||
const world = fixture();
|
||||
open(world);
|
||||
press(world, "right");
|
||||
for (let tick = 0; tick < 20; tick++) world.tick();
|
||||
expect(view(world)).toMatchObject({ badges: 0, badgeFrame: 0 });
|
||||
});
|
||||
|
||||
it("does not count during the introduction", () => {
|
||||
const world = fixture();
|
||||
world.gameTime = { hours: 3, minutes: 0, seconds: 0, frames: 0, capped: false };
|
||||
world.beginIntro();
|
||||
expect(world.gameTime).toEqual(emptyGameTime());
|
||||
for (let tick = 0; tick < 100; tick++) world.tick();
|
||||
expect(world.gameTime).toEqual(emptyGameTime());
|
||||
});
|
||||
|
||||
it("saves the play time, including time spent in a scene", () => {
|
||||
const world = fixture();
|
||||
world.gameTime = { hours: 7, minutes: 30, seconds: 0, frames: 0, capped: false };
|
||||
open(world);
|
||||
for (let tick = 0; tick < 30; tick++) world.tick();
|
||||
const save = world.save();
|
||||
expect(save.gameTime).toEqual({ hours: 7, minutes: 30, seconds: 1, frames: 0, capped: false });
|
||||
const other = fixture();
|
||||
other.restore(save);
|
||||
expect(other.gameTime).toEqual(save.gameTime);
|
||||
// Older saves start from zero; invalid times are refused.
|
||||
const { gameTime: _, ...old } = save;
|
||||
expect(migrateSave(old, () => 0)?.gameTime).toEqual(emptyGameTime());
|
||||
expect(() =>
|
||||
other.restore({ ...save, gameTime: { ...save.gameTime, minutes: 60 } }),
|
||||
).toThrow(/Invalid native save/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user