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,132 @@
|
||||
import type { CrystalAssets } from "../rom/assets";
|
||||
import { TRAINER_CARD } from "../rom/profile";
|
||||
import { OW } from "../world/constants";
|
||||
import { drawTile } from "./pixels";
|
||||
|
||||
const CARD_PIC_TILES = 35;
|
||||
const LEADER_TILES = 86;
|
||||
const BADGE_TILES = 44;
|
||||
/** CardRightCornerGFX replaces the picture's tile $1c, which the card's
|
||||
* border corner covers. */
|
||||
const CARD_CORNER_TILE = 0x1c;
|
||||
/** _CGB_TrainerCard's palettes 1-7 by trainer class; Clair uses palette 1. */
|
||||
const LEADER_PALETTES = [
|
||||
OW.FALKNER,
|
||||
OW.BUGSY,
|
||||
OW.WHITNEY,
|
||||
OW.MORTY,
|
||||
OW.CHUCK,
|
||||
OW.JASMINE,
|
||||
OW.PRYCE,
|
||||
OW.FALKNER,
|
||||
];
|
||||
|
||||
function context(canvas: HTMLCanvasElement, width: number, height: number) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d")!;
|
||||
const image = context.createImageData(width, height);
|
||||
image.data.fill(255);
|
||||
return { context, image };
|
||||
}
|
||||
|
||||
/** GetCardPic's Chris or Kris picture, placed by PlaceGraphic column by
|
||||
* column, in the player's own palette. */
|
||||
export function drawCardPic(
|
||||
canvas: HTMLCanvasElement,
|
||||
assets: CrystalAssets,
|
||||
female: boolean,
|
||||
) {
|
||||
const { context: target, image } = context(canvas, 40, 56);
|
||||
const tiles = assets.reader.slice(
|
||||
female ? TRAINER_CARD.KrisCardPic : TRAINER_CARD.ChrisCardPic,
|
||||
CARD_PIC_TILES * 16,
|
||||
);
|
||||
const palette = assets.pokemon.trainerPalette(female ? 1 : 0);
|
||||
for (let tile = 0; tile < CARD_PIC_TILES; tile++)
|
||||
if (tile !== CARD_CORNER_TILE)
|
||||
drawTile(
|
||||
image.data,
|
||||
40,
|
||||
56,
|
||||
tiles,
|
||||
tile,
|
||||
Math.floor(tile / 7) * 8,
|
||||
(tile % 7) * 8,
|
||||
palette,
|
||||
);
|
||||
target.putImageData(image, 0, 0);
|
||||
}
|
||||
|
||||
/** Page 2's leaders and badges: tiles 2-17 of rows 10-15, as
|
||||
* TrainerCard_Page2_3_InitObjectsAndStrings places the faces, with the
|
||||
* attribute map of _CGB_TrainerCard and the badge sprites of
|
||||
* TrainerCard_Page2_3_OAMUpdate for the badges owned at `frame`. */
|
||||
export function drawTrainerCardBadges(
|
||||
canvas: HTMLCanvasElement,
|
||||
assets: CrystalAssets,
|
||||
female: boolean,
|
||||
badges: number,
|
||||
frame: number,
|
||||
) {
|
||||
const r = assets.reader;
|
||||
const width = 128,
|
||||
height = 48;
|
||||
const { context: target, image } = context(canvas, width, height);
|
||||
const leaders = r.slice(TRAINER_CARD.LeaderGFX, LEADER_TILES * 16);
|
||||
// The card fills the screen with the other gender's palette.
|
||||
const border = assets.pokemon.trainerPalette(female ? 0 : 1);
|
||||
for (let leader = 0; leader < 8; leader++) {
|
||||
const base = (leader < 4 ? 0x29 : 0x51) + (leader % 4) * 10 - 0x29;
|
||||
const x = (leader % 4) * 32,
|
||||
y = leader < 4 ? 0 : 24;
|
||||
const face = assets.pokemon.trainerPalette(LEADER_PALETTES[leader]);
|
||||
// TrainerCard_Page2_3_PlaceLeadersFaces: four tiles, then two rows
|
||||
// of three one tile to the right.
|
||||
for (let i = 0; i < 4; i++)
|
||||
drawTile(image.data, width, height, leaders, base + i, x + i * 8, y, border);
|
||||
for (let row = 1; row <= 2; row++)
|
||||
for (let i = 0; i < 3; i++)
|
||||
drawTile(
|
||||
image.data,
|
||||
width,
|
||||
height,
|
||||
leaders,
|
||||
base + 1 + row * 3 + i,
|
||||
x + 8 + i * 8,
|
||||
y + row * 8,
|
||||
face,
|
||||
);
|
||||
}
|
||||
const graphics = r.slice(TRAINER_CARD.BadgeGFX, BADGE_TILES * 16);
|
||||
const palette = assets.palette(
|
||||
TRAINER_CARD.PredefPals + OW.PREDEFPAL_CGB_BADGE * 8,
|
||||
);
|
||||
// dw wJohtoBadges, then y, x, palette and eight tile steps per badge.
|
||||
for (let badge = 0; badge < OW.NUM_JOHTO_BADGES; badge++) {
|
||||
if (!(badges & (1 << badge))) continue;
|
||||
const entry = TRAINER_CARD.TrainerCard_JohtoBadgesOAM + 2 + badge * 11;
|
||||
// OAM coordinates, less the hardware offsets and this area's origin.
|
||||
const y = r.byte(entry) - 16 - 10 * 8,
|
||||
x = r.byte(entry + 1) - 8 - 2 * 8;
|
||||
const id = r.byte(entry + 3 + frame);
|
||||
const flip = (id & 0x80) !== 0,
|
||||
tile = id & 0x7f;
|
||||
// .facing1, or .facing2's mirrored order.
|
||||
const order = flip ? [1, 0, 3, 2] : [0, 1, 2, 3];
|
||||
order.forEach((offset, i) =>
|
||||
drawTile(
|
||||
image.data,
|
||||
width,
|
||||
height,
|
||||
graphics,
|
||||
tile + offset,
|
||||
x + (i % 2) * 8,
|
||||
y + Math.floor(i / 2) * 8,
|
||||
palette,
|
||||
{ transparent: true, flipX: flip },
|
||||
),
|
||||
);
|
||||
}
|
||||
target.putImageData(image, 0, 0);
|
||||
}
|
||||
@@ -735,6 +735,20 @@ export const MOM = {
|
||||
"BankOfMom_MenuHeader.MenuData": 0x166bd,
|
||||
} as const;
|
||||
|
||||
/** TrainerCard (engine/menus/trainer_card.asm): its strings, the badges'
|
||||
* OAM templates and the card graphics, keyed by their PRET labels. */
|
||||
export const TRAINER_CARD = {
|
||||
"TrainerCard_PrintTopHalfOfCard.Name_Money": 0x252ec,
|
||||
"TrainerCard_Page1_PrintDexCaught_GameTime.Dex_PlayTime": 0x2534c,
|
||||
"TrainerCard_Page1_PrintDexCaught_GameTime.Badges": 0x2535c,
|
||||
TrainerCard_JohtoBadgesOAM: 0x254c9,
|
||||
LeaderGFX: 0x25583,
|
||||
BadgeGFX: 0x26043,
|
||||
ChrisCardPic: 0x88365,
|
||||
KrisCardPic: 0x88595,
|
||||
PredefPals: 0x9df6,
|
||||
} as const;
|
||||
|
||||
export const MAP_GROUP_COUNT = 26;
|
||||
export const NUM_POKEMON = 251;
|
||||
export const NUM_TRAINER_CLASSES = 67;
|
||||
|
||||
@@ -52,6 +52,7 @@ import { readMovement, type MovementStep } from "./movement";
|
||||
import { PcSession } from "./pc";
|
||||
import { bankOfMom, type MomBankView } from "./mom";
|
||||
import type { PokegearView } from "./pokegear";
|
||||
import type { TrainerCardView } from "./trainer-card";
|
||||
import {
|
||||
defaultTextContext,
|
||||
readString,
|
||||
@@ -276,6 +277,9 @@ export type ScriptEffect =
|
||||
/** A Pokégear card with the textbox's pages, answered with the button
|
||||
* pressed (PokegearButton); without a view the Pokégear closes. */
|
||||
| { kind: "pokegear"; pages: string[]; view?: PokegearView }
|
||||
/** A trainer card page, answered with the button pressed; without a
|
||||
* view the card closes. */
|
||||
| { kind: "trainercard"; view?: TrainerCardView }
|
||||
/** BankOfMom's deposit or withdrawal box with the textbox's pages,
|
||||
* answered with the button pressed (MomBankButton). */
|
||||
| { kind: "mombank"; pages: string[]; view: MomBankView }
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../battle/battle";
|
||||
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
|
||||
import { calcStats, maxPP, type PartyMon } from "../party/pokemon";
|
||||
import type { GameTime } from "../world/game-time";
|
||||
import { PartyEvolution, evoStoneCompatible } from "./evolution";
|
||||
import {
|
||||
copyScriptState,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
type ScriptState,
|
||||
} from "./interpreter";
|
||||
import { Pokegear } from "./pokegear";
|
||||
import { TrainerCard } from "./trainer-card";
|
||||
import { readString, readText, type TextContext } from "./text";
|
||||
|
||||
type Input = number | string | undefined;
|
||||
@@ -115,6 +117,8 @@ export interface StartMenuHost {
|
||||
scriptHost(): ScriptHost;
|
||||
/** GetWorldMapLocation for any map. */
|
||||
landmarkOf(mapId: string): number;
|
||||
/** wGameTime, for the trainer card. */
|
||||
gameTime(): GameTime;
|
||||
}
|
||||
|
||||
/** The overworld start menu (engine/menus/start_menu.asm) with its
|
||||
@@ -298,6 +302,15 @@ export class StartMenuRun {
|
||||
landmarkOf: (mapId) => this.host.landmarkOf(mapId),
|
||||
}).run();
|
||||
break;
|
||||
case STARTMENUITEM_STATUS:
|
||||
// StartMenu_Status returns to the start menu.
|
||||
yield* new TrainerCard(this.state, {
|
||||
reader: this.reader,
|
||||
text: () => this.text(),
|
||||
player: () => this.host.scriptHost().player(),
|
||||
gameTime: () => this.host.gameTime(),
|
||||
}).run();
|
||||
break;
|
||||
case STARTMENUITEM_EXIT:
|
||||
return;
|
||||
case STARTMENUITEM_SAVE:
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import { ENGINE_FLAGS, TRAINER_CARD } from "../rom/profile";
|
||||
import { OW } from "../world/constants";
|
||||
import type { GameTime } from "../world/game-time";
|
||||
import type { ScriptEffect, ScriptState } from "./interpreter";
|
||||
import { readString, type TextContext } from "./text";
|
||||
|
||||
type Input = number | string | undefined;
|
||||
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
||||
|
||||
/** The buttons the card reads from hJoyLast. */
|
||||
export const TRAINER_CARD_BUTTONS: readonly string[] = [
|
||||
"a",
|
||||
"b",
|
||||
"start",
|
||||
"select",
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
];
|
||||
|
||||
/** What the trainer card shows. The original draws its own tilemap; here
|
||||
* the text is presented natively and the pictures come from the ROM. */
|
||||
export interface TrainerCardView {
|
||||
/** TRAINERCARDSTATE_PAGE1 or PAGE2 (page 3 is unreachable). */
|
||||
page: 1 | 2;
|
||||
/** wPlayerGender: the card picture and the palettes follow it. */
|
||||
female: boolean;
|
||||
name: string;
|
||||
/** wPlayerID with leading zeros, five digits. */
|
||||
id: string;
|
||||
money: number;
|
||||
/** wPokedexCaught's set bits; absent without the Pokédex, whose line
|
||||
* TrainerCard_Page1_PrintDexCaught_GameTime clears. */
|
||||
pokedex?: number;
|
||||
/** wGameTimeHours and wGameTimeMinutes, reprinted every frame. */
|
||||
time: { hours: number; minutes: number };
|
||||
/** The small colon between them, toggled every 32 frames. */
|
||||
colon: boolean;
|
||||
/** wJohtoBadges. */
|
||||
badges: number;
|
||||
/** wTrainerCardBadgeFrameCounter: the badges' animation step. */
|
||||
badgeFrame: number;
|
||||
/** .Name_Money, .Dex_PlayTime and .Badges from the ROM. */
|
||||
labels: {
|
||||
name: string;
|
||||
money: string;
|
||||
pokedex: string;
|
||||
playTime: string;
|
||||
badges: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** What the card needs from the world. */
|
||||
export interface TrainerCardHost {
|
||||
reader: RomReader;
|
||||
text(): TextContext;
|
||||
player(): { id: number; gender: 0 | 1 };
|
||||
gameTime(): GameTime;
|
||||
}
|
||||
|
||||
/** The per-frame parts of the card, `frames` after its page loaded:
|
||||
* TrainerCard_Page1_PrintGameTime's time and colon, and
|
||||
* TrainerCard_Page2_3_AnimateBadges' step every eight frames. */
|
||||
export function refreshTrainerCard(
|
||||
view: TrainerCardView,
|
||||
frames: number,
|
||||
time: GameTime,
|
||||
) {
|
||||
view.time = { hours: time.hours, minutes: time.minutes };
|
||||
view.colon = view.page === 1 && Math.floor(frames / 32) % 2 === 1;
|
||||
// Without badges no sprite moves, so the step is not shown.
|
||||
view.badgeFrame =
|
||||
view.page === 2 && view.badges ? Math.floor(frames / 8) % 8 : 0;
|
||||
}
|
||||
|
||||
/** TrainerCard (engine/menus/trainer_card.asm): page 1 shows the Pokédex
|
||||
* count and the play time, page 2 the Johto leaders and badges. B leaves
|
||||
* from either page; A or right turns to page 2, where A leaves and left
|
||||
* turns back. The Kanto page is only reachable through unreferenced code. */
|
||||
export class TrainerCard {
|
||||
constructor(
|
||||
private state: ScriptState,
|
||||
private host: TrainerCardHost,
|
||||
) {}
|
||||
|
||||
*run(): Gen {
|
||||
let page: 1 | 2 = 1;
|
||||
for (;;) {
|
||||
const button = yield {
|
||||
kind: "trainercard",
|
||||
view: this.view(page),
|
||||
};
|
||||
if (typeof button !== "string" || !TRAINER_CARD_BUTTONS.includes(button))
|
||||
throw new Error("Invalid trainer card button.");
|
||||
if (button === "b") break;
|
||||
if (page === 1 && (button === "a" || button === "right")) page = 2;
|
||||
else if (page === 2 && button === "a") break;
|
||||
else if (page === 2 && button === "left") page = 1;
|
||||
}
|
||||
yield { kind: "trainercard" };
|
||||
}
|
||||
|
||||
private string(address: number) {
|
||||
return readString(this.host.reader, address, this.host.text());
|
||||
}
|
||||
|
||||
private view(page: 1 | 2): TrainerCardView {
|
||||
const player = this.host.player();
|
||||
const [name, , money] = this.string(
|
||||
TRAINER_CARD["TrainerCard_PrintTopHalfOfCard.Name_Money"],
|
||||
).split("\n");
|
||||
const [pokedex, playTime] = this.string(
|
||||
TRAINER_CARD["TrainerCard_Page1_PrintDexCaught_GameTime.Dex_PlayTime"],
|
||||
).split("\n");
|
||||
let badges = 0;
|
||||
for (let bit = 0; bit < OW.NUM_JOHTO_BADGES; bit++)
|
||||
if (this.state.flags.has(OW.ENGINE_ZEPHYRBADGE + bit)) badges |= 1 << bit;
|
||||
const view: TrainerCardView = {
|
||||
page,
|
||||
female: player.gender === 1,
|
||||
name: this.host.text().player,
|
||||
id: String(player.id).padStart(5, "0"),
|
||||
money: this.state.money,
|
||||
...(this.state.flags.has(ENGINE_FLAGS.pokedex) && {
|
||||
pokedex: this.state.pokedex.caught.size,
|
||||
}),
|
||||
time: { hours: 0, minutes: 0 },
|
||||
colon: false,
|
||||
badges,
|
||||
badgeFrame: 0,
|
||||
labels: {
|
||||
name,
|
||||
money,
|
||||
pokedex,
|
||||
playTime,
|
||||
badges: this.string(
|
||||
TRAINER_CARD["TrainerCard_Page1_PrintDexCaught_GameTime.Badges"],
|
||||
).trim(),
|
||||
},
|
||||
};
|
||||
refreshTrainerCard(view, 0, this.host.gameTime());
|
||||
return view;
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,21 @@ export const OW = {
|
||||
ENGINE_HIVEBADGE: 28,
|
||||
ENGINE_PLAINBADGE: 29,
|
||||
ENGINE_FOGBADGE: 30,
|
||||
ENGINE_MINERALBADGE: 31,
|
||||
ENGINE_STORMBADGE: 32,
|
||||
ENGINE_GLACIERBADGE: 33,
|
||||
ENGINE_RISINGBADGE: 34,
|
||||
// wJohtoBadges (constants/engine_flags.asm) and the trainer card's
|
||||
// palettes (constants/scgb_constants.asm, constants/trainer_constants.asm).
|
||||
NUM_JOHTO_BADGES: 8,
|
||||
PREDEFPAL_CGB_BADGE: 0x24,
|
||||
FALKNER: 1,
|
||||
WHITNEY: 2,
|
||||
BUGSY: 3,
|
||||
MORTY: 4,
|
||||
PRYCE: 5,
|
||||
JASMINE: 6,
|
||||
CHUCK: 7,
|
||||
// Map environments (constants/map_data_constants.asm).
|
||||
TOWN: 1,
|
||||
ROUTE: 2,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/** wGameTimeHours, wGameTimeMinutes, wGameTimeSeconds and wGameTimeFrames,
|
||||
* with wGameTimeCap's GAME_TIME_CAPPED bit. */
|
||||
export interface GameTime {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
frames: number;
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
/** ResetGameTime. */
|
||||
export const emptyGameTime = (): GameTime => ({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
frames: 0,
|
||||
capped: false,
|
||||
});
|
||||
|
||||
/** GameTimer.Function (home/game_time.asm): one frame, 60 to the second.
|
||||
* Reaching 1000 hours caps the timer at 999:59:59.00 for good. */
|
||||
export function advanceGameTime(time: GameTime) {
|
||||
if (time.capped) return;
|
||||
if (++time.frames < 60) return;
|
||||
time.frames = 0;
|
||||
if (++time.seconds < 60) return;
|
||||
time.seconds = 0;
|
||||
if (++time.minutes < 60) return;
|
||||
time.minutes = 0;
|
||||
if (time.hours + 1 >= 1000) {
|
||||
time.capped = true;
|
||||
time.minutes = time.seconds = 59;
|
||||
return;
|
||||
}
|
||||
time.hours++;
|
||||
}
|
||||
|
||||
export function validGameTime(value: unknown): value is GameTime {
|
||||
const time = value as GameTime | undefined;
|
||||
const integer = (value: unknown, max: number) =>
|
||||
Number.isInteger(value) && Number(value) >= 0 && Number(value) <= max;
|
||||
return (
|
||||
Boolean(time) &&
|
||||
typeof time === "object" &&
|
||||
integer(time!.hours, 999) &&
|
||||
integer(time!.minutes, 59) &&
|
||||
integer(time!.seconds, 59) &&
|
||||
integer(time!.frames, 59) &&
|
||||
typeof time!.capped === "boolean"
|
||||
);
|
||||
}
|
||||
+69
-3
@@ -21,6 +21,17 @@ import {
|
||||
type PokegearView,
|
||||
} from "../scripts/pokegear";
|
||||
import { MOM_BANK_BUTTONS, type MomBankView } from "../scripts/mom";
|
||||
import {
|
||||
TRAINER_CARD_BUTTONS,
|
||||
refreshTrainerCard,
|
||||
type TrainerCardView,
|
||||
} from "../scripts/trainer-card";
|
||||
import {
|
||||
advanceGameTime,
|
||||
emptyGameTime,
|
||||
validGameTime,
|
||||
type GameTime,
|
||||
} from "./game-time";
|
||||
import {
|
||||
PLAYER,
|
||||
ScriptRun,
|
||||
@@ -84,6 +95,8 @@ export interface NativeSave {
|
||||
name: string;
|
||||
avatar: "chris" | "kris";
|
||||
tilesWalked: number;
|
||||
/** wGameTime: the play time the trainer card shows. */
|
||||
gameTime: GameTime;
|
||||
events: number[];
|
||||
flags: number[];
|
||||
variables: Record<number, number>;
|
||||
@@ -244,6 +257,8 @@ export type Choice =
|
||||
| { kind: "pokegear"; view: PokegearView; selected: 0 }
|
||||
/** BankOfMom's amount box, answered with the button pressed. */
|
||||
| { kind: "mombank"; view: MomBankView; selected: 0 }
|
||||
/** A trainer card page, answered with the button pressed. */
|
||||
| { kind: "trainercard"; view: TrainerCardView; selected: 0 }
|
||||
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
|
||||
| {
|
||||
kind: "martquantity";
|
||||
@@ -258,7 +273,9 @@ export type Choice =
|
||||
/** Choices that read the buttons themselves and are answered with the
|
||||
* button's name. */
|
||||
const buttonChoice = (choice: Choice) =>
|
||||
choice.kind === "pokegear" || choice.kind === "mombank";
|
||||
choice.kind === "pokegear" ||
|
||||
choice.kind === "mombank" ||
|
||||
choice.kind === "trainercard";
|
||||
export interface Dialogue {
|
||||
pages: string[];
|
||||
index: number;
|
||||
@@ -525,6 +542,9 @@ export class GameWorld {
|
||||
name = "CHRIS";
|
||||
avatar: "chris" | "kris" = "chris";
|
||||
tilesWalked = 0;
|
||||
/** wGameTime. It counts from the end of the introduction, through
|
||||
* scenes, menus and battles, as GameTimer does in every VBlank. */
|
||||
gameTime: GameTime = emptyGameTime();
|
||||
state: ScriptState = emptyScriptState();
|
||||
dialogue: Dialogue | undefined;
|
||||
emote: { object: number; emote: number } | undefined;
|
||||
@@ -1888,6 +1908,7 @@ export class GameWorld {
|
||||
this.menuFieldMove(state, mon, move),
|
||||
scriptHost: () => this.host(PLAYER),
|
||||
landmarkOf: (mapId) => this.assets.map(mapId).landmark,
|
||||
gameTime: () => this.gameTime,
|
||||
},
|
||||
this.menuCursors,
|
||||
select,
|
||||
@@ -1916,6 +1937,8 @@ export class GameWorld {
|
||||
beginIntro() {
|
||||
if (this.script) throw new Error("The introduction needs an idle world.");
|
||||
this.introPending = true;
|
||||
// A new game's ResetGameTime; the timer starts when it finishes.
|
||||
this.gameTime = emptyGameTime();
|
||||
this.script = {
|
||||
run: new IntroRun(this.state, {
|
||||
reader: this.assets.reader,
|
||||
@@ -2199,6 +2222,21 @@ export class GameWorld {
|
||||
choice: { kind: "pokegear", view: effect.view, selected: 0 },
|
||||
};
|
||||
return true;
|
||||
case "trainercard":
|
||||
if (!effect.view) {
|
||||
this.trainerCard = undefined;
|
||||
return false;
|
||||
}
|
||||
// Each page starts its colon and badge timing when it loads.
|
||||
if (this.trainerCard?.page !== effect.view.page) this.cardFrames = 0;
|
||||
this.trainerCard = effect.view;
|
||||
refreshTrainerCard(effect.view, this.cardFrames, this.gameTime);
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
index: 0,
|
||||
choice: { kind: "trainercard", view: effect.view, selected: 0 },
|
||||
};
|
||||
return true;
|
||||
case "mombank":
|
||||
this.dialogue = {
|
||||
pages: effect.pages.length ? effect.pages : [""],
|
||||
@@ -2625,6 +2663,7 @@ export class GameWorld {
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
this.pokegear = undefined;
|
||||
this.trainerCard = undefined;
|
||||
this.dialogue = undefined;
|
||||
this.emote = undefined;
|
||||
this.picture = undefined;
|
||||
@@ -2665,6 +2704,7 @@ export class GameWorld {
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
this.pokegear = undefined;
|
||||
this.trainerCard = undefined;
|
||||
this.dialogue = undefined;
|
||||
this.emote = undefined;
|
||||
this.picture = undefined;
|
||||
@@ -3107,10 +3147,18 @@ export class GameWorld {
|
||||
/** The Pokégear card on screen while the Pokégear is open, including
|
||||
* during the phone calls it makes. */
|
||||
pokegear: PokegearView | undefined;
|
||||
/** The trainer card page on screen, and the frames since it loaded. */
|
||||
trainerCard: TrainerCardView | undefined;
|
||||
private cardFrames = 0;
|
||||
ticks = 0;
|
||||
|
||||
tick(held?: Direction) {
|
||||
this.ticks++;
|
||||
// GameTimer: two frames per overworld tick, once the game has started.
|
||||
if (!this.introPending) {
|
||||
advanceGameTime(this.gameTime);
|
||||
advanceGameTime(this.gameTime);
|
||||
}
|
||||
// StepFunction_ScreenShake counts down once per tick of the map loop.
|
||||
if (this.shake && !this.battle && --this.shake.ticks <= 0)
|
||||
this.shake = undefined;
|
||||
@@ -3138,6 +3186,11 @@ export class GameWorld {
|
||||
minutes,
|
||||
);
|
||||
}
|
||||
const card = this.trainerCard;
|
||||
if (card) {
|
||||
this.cardFrames += 2;
|
||||
refreshTrainerCard(card, this.cardFrames, this.gameTime);
|
||||
}
|
||||
const evolution = this.evolution;
|
||||
if (evolution) {
|
||||
const frame = evolution.schedule[++evolution.step];
|
||||
@@ -3699,7 +3752,9 @@ export class GameWorld {
|
||||
(dialogue.choice.kind === "pokegear" &&
|
||||
!POKEGEAR_BUTTONS.includes(answer as never)) ||
|
||||
(dialogue.choice.kind === "mombank" &&
|
||||
!MOM_BANK_BUTTONS.includes(answer as never))
|
||||
!MOM_BANK_BUTTONS.includes(answer as never)) ||
|
||||
(dialogue.choice.kind === "trainercard" &&
|
||||
!TRAINER_CARD_BUTTONS.includes(answer as never))
|
||||
)
|
||||
return;
|
||||
this.townMap = false;
|
||||
@@ -3712,7 +3767,12 @@ export class GameWorld {
|
||||
|
||||
save(): NativeSave {
|
||||
// Incomplete interactions are replayed after reload; no partial quest writes.
|
||||
if (this.script) return structuredClone(this.script.committed);
|
||||
// The play time keeps counting through scenes, like GameTimer.
|
||||
if (this.script)
|
||||
return {
|
||||
...structuredClone(this.script.committed),
|
||||
gameTime: { ...this.gameTime },
|
||||
};
|
||||
const state = this.state;
|
||||
return {
|
||||
format: "crystal-native",
|
||||
@@ -3726,6 +3786,7 @@ export class GameWorld {
|
||||
name: this.name,
|
||||
avatar: this.avatar,
|
||||
tilesWalked: this.tilesWalked,
|
||||
gameTime: { ...this.gameTime },
|
||||
events: [...state.events].sort((a, b) => a - b),
|
||||
flags: [...state.flags].sort((a, b) => a - b),
|
||||
variables: { ...state.variables },
|
||||
@@ -3802,6 +3863,7 @@ export class GameWorld {
|
||||
!/^[A-Za-z0-9 -]{1,7}$/.test(save.name) ||
|
||||
!["chris", "kris"].includes(save.avatar) ||
|
||||
!integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) ||
|
||||
!validGameTime(save.gameTime) ||
|
||||
!ids(save.events) ||
|
||||
!ids(save.flags) ||
|
||||
!new DecorationData(this.assets.reader, this.assets.pokemon).valid(
|
||||
@@ -3977,10 +4039,12 @@ export class GameWorld {
|
||||
this.name = save.name;
|
||||
this.avatar = save.avatar;
|
||||
this.tilesWalked = save.tilesWalked;
|
||||
this.gameTime = { ...save.gameTime };
|
||||
this.playerId = save.playerId;
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
this.pokegear = undefined;
|
||||
this.trainerCard = undefined;
|
||||
this.dialogue = undefined;
|
||||
this.emote = undefined;
|
||||
this.picture = undefined;
|
||||
@@ -4040,6 +4104,7 @@ export function migrateSave(
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
}),
|
||||
...(!Object.hasOwn(save, "intro") && { intro: false }),
|
||||
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
|
||||
// Clocks saved before InitClock was ported follow the device time.
|
||||
...(save.clock !== null &&
|
||||
typeof save.clock === "object" &&
|
||||
@@ -4085,5 +4150,6 @@ export function migrateSave(
|
||||
noWildEncounters: false,
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
intro: false,
|
||||
gameTime: emptyGameTime(),
|
||||
} as unknown as NativeSave;
|
||||
}
|
||||
+85
-2
@@ -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) {
|
||||
|
||||
@@ -33,6 +33,7 @@ export function renderShell(root: HTMLElement) {
|
||||
</div>
|
||||
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
|
||||
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
|
||||
<div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div>
|
||||
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
|
||||
<div id="mart" class="mart" hidden aria-label="Poké Mart">
|
||||
<p id="mart-money" class="mart-money"></p>
|
||||
|
||||
+66
-1
@@ -539,7 +539,8 @@ dialog h2 {
|
||||
display: none;
|
||||
}
|
||||
.intro-screen:not([hidden]) ~ .minimap-overlay,
|
||||
.pokegear:not([hidden]) ~ .minimap-overlay {
|
||||
.pokegear:not([hidden]) ~ .minimap-overlay,
|
||||
.trainer-card:not([hidden]) ~ .minimap-overlay {
|
||||
display: none;
|
||||
}
|
||||
.pokepic {
|
||||
@@ -1261,3 +1262,67 @@ dialog dd {
|
||||
padding: 4px 8px;
|
||||
border: 3px double var(--ink);
|
||||
}
|
||||
|
||||
/* TrainerCard: the card's frame takes the other gender's palette. */
|
||||
.trainer-card {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
color: var(--ink);
|
||||
background: var(--trainer-card-frame, var(--blue-dark));
|
||||
}
|
||||
.trainer-card-top,
|
||||
.trainer-card-page {
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border: 5px double var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.trainer-card-top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.trainer-card dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.trainer-card dt,
|
||||
.trainer-card dd {
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
.trainer-card-top canvas {
|
||||
width: min(22vw, 100px);
|
||||
aspect-ratio: 40 / 56;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.trainer-card-page {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
}
|
||||
.trainer-card-page h3 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.trainer-card-page p {
|
||||
margin: 0;
|
||||
justify-self: end;
|
||||
}
|
||||
.trainer-card-page canvas {
|
||||
width: 100%;
|
||||
max-width: 512px;
|
||||
justify-self: center;
|
||||
aspect-ratio: 128 / 48;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
Reference in new issue
Block a user