Port the Pokédex list, entries and tab (#35)
The start menu's POKéDEX runs Pokedex in New mode: the list up to the last seen Pokémon, its cursor and page moves, and DisplayDexEntry with the category, number, footprint, and the height, weight and pages once caught. The Pokédex tab shows the same records from the loaded game. Order, entries and footprints are compared with the pinned PRET source. AREA, CRY, PRNT, the option and search screens, Old and A to Z modes and Unown mode remain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03aa913a94
commit
cdee576818
20 files changed
+1624
-29
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
} from "./profile";
|
||||
import { defaultTextContext, readString } from "../scripts/text";
|
||||
import { PokemonData } from "./pokemon";
|
||||
import { PokedexData } from "./pokedex";
|
||||
import { OW } from "../world/constants";
|
||||
import type {
|
||||
Direction,
|
||||
@@ -27,6 +28,7 @@ import type {
|
||||
export class CrystalAssets {
|
||||
readonly reader: RomReader;
|
||||
readonly pokemon: PokemonData;
|
||||
readonly pokedex: PokedexData;
|
||||
private maps = new Map<string, GameMap>();
|
||||
private tilesets = new Map<string, Tileset>();
|
||||
private sprites = new Map<number, Sprite>();
|
||||
@@ -35,6 +37,7 @@ export class CrystalAssets {
|
||||
constructor(bytes: Uint8Array) {
|
||||
this.reader = new RomReader(bytes);
|
||||
this.pokemon = new PokemonData(this.reader);
|
||||
this.pokedex = new PokedexData(this.reader);
|
||||
}
|
||||
|
||||
/** Areas whose rules and scripts are ported. Other maps can be decoded,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { RomReader } from "./reader";
|
||||
import { decompress } from "./lz";
|
||||
import { NUM_POKEMON, POKEDEX } from "./profile";
|
||||
import { defaultTextContext, readString } from "../scripts/text";
|
||||
import type { Palette } from "../world/types";
|
||||
|
||||
/** A Pokédex entry (data/pokemon/dex_entries): the species category,
|
||||
* height and weight, and the two pages of text. */
|
||||
export interface DexEntry {
|
||||
/** The species name DisplayDexEntry prints under the name, as "SEED". */
|
||||
category: string;
|
||||
/** Feet and inches as stored: 204 is 2'04". Zero leaves "?'??". */
|
||||
height: number;
|
||||
/** Tenths of a pound. Zero leaves "???". */
|
||||
weight: number;
|
||||
/** Each page's lines, joined by "\n" as <NEXT> breaks them. */
|
||||
pages: [string, string];
|
||||
}
|
||||
|
||||
/** The Pokédex font's own tiles in the strings Pokedex_PlaceString draws
|
||||
* (gfx/pokedex/pokedex.png): the foot and inch marks and the bottom bar's
|
||||
* left edge. */
|
||||
const DEX_TILES: Record<number, string> = {
|
||||
0x3b: "",
|
||||
0x5e: "'",
|
||||
0x5f: '"',
|
||||
};
|
||||
|
||||
/** The Pokédex's ROM data: its strings, the New mode order, the entries,
|
||||
* footprints and the question mark picture. */
|
||||
export class PokedexData {
|
||||
private entries = new Map<number, DexEntry>();
|
||||
private order?: number[];
|
||||
constructor(readonly reader: RomReader) {}
|
||||
|
||||
private checkSpecies(species: number) {
|
||||
if (!Number.isInteger(species) || species < 1 || species > NUM_POKEMON)
|
||||
throw new Error("Unknown Pokémon species.");
|
||||
}
|
||||
/** A "@"-terminated string in the standard font. */
|
||||
private string(address: number, limit = 32) {
|
||||
return readString(this.reader, address, defaultTextContext(""), limit);
|
||||
}
|
||||
/** A string Pokedex_PlaceString draws, ended by -1, mixing the standard
|
||||
* font with the Pokédex's tiles. */
|
||||
dexString(label: keyof typeof POKEDEX): string {
|
||||
const r = this.reader;
|
||||
let output = "";
|
||||
for (let p = POKEDEX[label], end = p + 32; p < end; p++) {
|
||||
const code = r.byte(p);
|
||||
if (code === 0xff) return output;
|
||||
output +=
|
||||
DEX_TILES[code] ??
|
||||
readString(
|
||||
new RomReader(new Uint8Array([code, 0x50])),
|
||||
0,
|
||||
defaultTextContext(""),
|
||||
);
|
||||
}
|
||||
throw new Error("Unterminated Pokédex string.");
|
||||
}
|
||||
/** Pokedex_PlaceDefaultStringIfNotSeen.NameNotSeen. */
|
||||
notSeenName(): string {
|
||||
return this.string(
|
||||
POKEDEX["Pokedex_PlaceDefaultStringIfNotSeen.NameNotSeen"],
|
||||
);
|
||||
}
|
||||
|
||||
/** NewPokedexOrder: the species in New mode's (Johto) order. */
|
||||
newOrder(): readonly number[] {
|
||||
if (!this.order) {
|
||||
const order = [
|
||||
...this.reader.slice(POKEDEX.NewPokedexOrder, NUM_POKEMON),
|
||||
];
|
||||
const sorted = [...order].sort((a, b) => a - b);
|
||||
if (sorted.some((species, index) => species !== index + 1))
|
||||
throw new Error("The New Pokédex order is not a permutation.");
|
||||
this.order = order;
|
||||
}
|
||||
return this.order;
|
||||
}
|
||||
|
||||
/** GetDexEntryPointer: the bank comes from the species' top two bits,
|
||||
* one bank per 64 species. */
|
||||
private entryAddress(species: number) {
|
||||
const r = this.reader;
|
||||
const index = species - 1;
|
||||
const bank = r.byte(
|
||||
POKEDEX["GetDexEntryPointer.PokedexEntryBanks"] + ((index >> 6) & 3),
|
||||
);
|
||||
return r.near(POKEDEX.PokedexDataPointerTable + index * 2, bank);
|
||||
}
|
||||
/** DisplayDexEntry and GetDexEntryPagePointer: the category, the height
|
||||
* and weight words, then each page up to its "@". */
|
||||
entry(species: number): DexEntry {
|
||||
this.checkSpecies(species);
|
||||
const cached = this.entries.get(species);
|
||||
if (cached) return cached;
|
||||
const r = this.reader;
|
||||
let p = this.entryAddress(species);
|
||||
const category = this.string(p, 16);
|
||||
while (r.byte(p) !== 0x50) p++;
|
||||
p++;
|
||||
const height = r.word(p),
|
||||
weight = r.word(p + 2);
|
||||
p += 4;
|
||||
const page1 = p;
|
||||
while (r.byte(p) !== 0x50) p++;
|
||||
const page2 = p + 1;
|
||||
const entry: DexEntry = {
|
||||
category,
|
||||
height,
|
||||
weight,
|
||||
pages: [this.string(page1, 128), this.string(page2, 128)],
|
||||
};
|
||||
this.entries.set(species, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Pokedex_LoadAnyFootprint: two 1bpp tiles from the footprint sheet's
|
||||
* row for the species, then the two below them, 16 tiles further. */
|
||||
footprint(species: number): Uint8Array {
|
||||
this.checkSpecies(species);
|
||||
const index = species - 1;
|
||||
const top = POKEDEX.Footprints + (index >> 3) * 0x100 + (index & 7) * 0x10;
|
||||
const tiles = new Uint8Array(32);
|
||||
tiles.set(this.reader.slice(top, 16), 0);
|
||||
tiles.set(this.reader.slice(top + 0x80, 16), 16);
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/** LoadQuestionMarkPic: the 7x7 picture of an unseen Pokémon, column by
|
||||
* column like a front picture. */
|
||||
questionMarkPic(): Uint8Array {
|
||||
const tiles = decompress(
|
||||
this.reader,
|
||||
POKEDEX["LoadQuestionMarkPic.QuestionMarkLZ"],
|
||||
0x2000,
|
||||
);
|
||||
if (tiles.length < 49 * 16) throw new Error("Picture data is truncated.");
|
||||
return tiles.slice(0, 49 * 16);
|
||||
}
|
||||
/** PokedexQuestionMarkPalette: _CGB_Pokedex's picture palette while the
|
||||
* list shows (wCurPartySpecies is -1). */
|
||||
questionMarkPalette(): Palette {
|
||||
const r = this.reader;
|
||||
return [0, 1, 2, 3].map((i) => {
|
||||
const value = r.word(POKEDEX.PokedexQuestionMarkPalette + i * 2);
|
||||
return [
|
||||
Math.round(((value & 31) * 255) / 31),
|
||||
Math.round((((value >> 5) & 31) * 255) / 31),
|
||||
Math.round((((value >> 10) & 31) * 255) / 31),
|
||||
] as const;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** DisplayDexEntry's height: PrintNum with two of four digits before the
|
||||
* point, which becomes the foot mark, written over the ".Height" string. */
|
||||
export function dexHeight(template: string, height: number): string {
|
||||
if (!height) return template;
|
||||
const feet = String(Math.floor(height / 100)).padStart(2, " ");
|
||||
const inches = String(height % 100).padStart(2, "0");
|
||||
// The number starts three tiles into the string (hlcoord 12, 7 after 9, 7).
|
||||
return template.slice(0, 3) + `${feet}'${inches}` + template.slice(8);
|
||||
}
|
||||
/** DisplayDexEntry's weight: PrintNum with four of five digits before the
|
||||
* point, written over the ".Weight" string's question marks. */
|
||||
export function dexWeight(template: string, weight: number): string {
|
||||
if (!weight) return template;
|
||||
const pounds = String(Math.floor(weight / 10)).padStart(4, " ");
|
||||
// The number starts two tiles into the string (hlcoord 11, 9 after 9, 9).
|
||||
return template.slice(0, 2) + `${pounds}.${weight % 10}` + template.slice(8);
|
||||
}
|
||||
@@ -999,6 +999,24 @@ export const TRAINER_CARD = {
|
||||
PredefPals: 0x9df6,
|
||||
} as const;
|
||||
|
||||
/** Pokedex (engine/pokedex/pokedex.asm, pokedex_2.asm, pokedex_3.asm):
|
||||
* its strings, the New mode order, the entries and their graphics, keyed
|
||||
* by their PRET labels. */
|
||||
export const POKEDEX = {
|
||||
String_SEEN: 0x407e1,
|
||||
String_OWN: 0x407e6,
|
||||
"Pokedex_DrawDexEntryScreenBG.Height": 0x40852,
|
||||
"Pokedex_DrawDexEntryScreenBG.Weight": 0x4085c,
|
||||
"Pokedex_DrawDexEntryScreenBG.MenuItems": 0x40867,
|
||||
"Pokedex_PlaceDefaultStringIfNotSeen.NameNotSeen": 0x40b9a,
|
||||
NewPokedexOrder: 0x40d60,
|
||||
"GetDexEntryPointer.PokedexEntryBanks": 0x44351,
|
||||
PokedexDataPointerTable: 0x44378,
|
||||
Footprints: 0xf9434,
|
||||
"LoadQuestionMarkPic.QuestionMarkLZ": 0x1de0e1,
|
||||
PokedexQuestionMarkPalette: 0x8fba,
|
||||
} as const;
|
||||
|
||||
/** _Option (engine/menus/options_menu.asm): its screen and setting
|
||||
* strings, keyed by their PRET labels. */
|
||||
export const OPTIONS = {
|
||||
|
||||
@@ -59,6 +59,7 @@ import type { PokegearView } from "./pokegear";
|
||||
import type { FlyMapView } from "./fly-map";
|
||||
import { rockMonEncounter, treeMonEncounter } from "./treemons";
|
||||
import type { TrainerCardView } from "./trainer-card";
|
||||
import type { PokedexView } from "./pokedex";
|
||||
import {
|
||||
defaultOptions,
|
||||
type GameOptions,
|
||||
@@ -441,6 +442,7 @@ export type ScriptEffect =
|
||||
/** A trainer card page, answered with the button pressed; without a
|
||||
* view the card closes. */
|
||||
| { kind: "trainercard"; view?: TrainerCardView }
|
||||
| { kind: "pokedex"; view?: PokedexView }
|
||||
/** The options screen, answered with the button pressed; without a
|
||||
* view it closes. */
|
||||
| { kind: "options"; view?: OptionsView }
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { NUM_POKEMON } from "../rom/profile";
|
||||
import type { PokedexData } from "../rom/pokedex";
|
||||
import { dexHeight, dexWeight } from "../rom/pokedex";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import type { ScriptEffect } from "./interpreter";
|
||||
|
||||
type Input = number | string | undefined;
|
||||
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
||||
|
||||
/** The buttons the Pokédex reads from hJoyPressed and hJoyLast. */
|
||||
export const POKEDEX_BUTTONS: readonly string[] = [
|
||||
"a",
|
||||
"b",
|
||||
"start",
|
||||
"select",
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
];
|
||||
|
||||
/** wDexListingHeight on the main screen. */
|
||||
const LISTING_HEIGHT = 7;
|
||||
/** DexEntryScreen_ArrowCursorData: PAGE, AREA, CRY and PRNT. */
|
||||
const ENTRY_ACTIONS = 4;
|
||||
|
||||
/** One line of the main screen's list. Rows past the last seen Pokémon
|
||||
* still print, as "-----". */
|
||||
export interface PokedexRow {
|
||||
name: string;
|
||||
seen: boolean;
|
||||
caught: boolean;
|
||||
}
|
||||
|
||||
/** What the Pokédex shows. The original draws its own tilemap; here the
|
||||
* text is presented natively and the pictures come from the ROM. */
|
||||
export interface PokedexView {
|
||||
screen: "list" | "entry";
|
||||
/** The species whose front picture shows, or undefined for the question
|
||||
* mark. The list draws it in PokedexQuestionMarkPalette, the entry in
|
||||
* the species' palette. */
|
||||
picture?: number;
|
||||
/** wPokedexSeen and wPokedexCaught's set bits. */
|
||||
seen: number;
|
||||
own: number;
|
||||
/** wDexListingHeight rows from wDexListingScrollOffset. */
|
||||
rows: PokedexRow[];
|
||||
/** wDexListingCursor. */
|
||||
cursor: number;
|
||||
/** wDexListingScrollOffset and wDexListingEnd, for the scroll bar. */
|
||||
scroll: number;
|
||||
end: number;
|
||||
entry?: PokedexEntryView;
|
||||
/** String_SEEN and String_OWN from the ROM. */
|
||||
labels: { seen: string; own: string };
|
||||
}
|
||||
|
||||
/** DisplayDexEntry: the number, name and category always; the height,
|
||||
* weight and page text only once the species is caught. */
|
||||
export interface PokedexEntryView {
|
||||
species: number;
|
||||
name: string;
|
||||
category: string;
|
||||
/** The national number with leading zeros. */
|
||||
number: string;
|
||||
/** ".Height" and ".Weight", with the numbers printed over them. */
|
||||
height: string;
|
||||
weight: string;
|
||||
caught: boolean;
|
||||
/** wPokedexStatus: page 1 or 2. */
|
||||
page: 1 | 2;
|
||||
text?: string;
|
||||
/** ".MenuItems": PAGE, AREA, CRY and PRNT. */
|
||||
actions: string[];
|
||||
/** wDexArrowCursorPosIndex. */
|
||||
action: number;
|
||||
}
|
||||
|
||||
/** What the Pokédex needs from the world. */
|
||||
export interface PokedexHost {
|
||||
data: PokemonData;
|
||||
dex: PokedexData;
|
||||
seen: ReadonlySet<number>;
|
||||
caught: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
/** Pokedex (engine/pokedex/pokedex.asm) in New mode: the main list and
|
||||
* the entry screen. B leaves the list; A opens a seen Pokémon's entry,
|
||||
* where left and right move the arrow cursor, up and down step through
|
||||
* seen Pokémon, A runs the action under the cursor and B returns. Only
|
||||
* PAGE acts: AREA, CRY and PRNT, the option (SELECT) and search (START)
|
||||
* screens and the Unown mode are not ported. */
|
||||
export class Pokedex {
|
||||
/** wPokedexOrder, in DEXMODE_NEW. */
|
||||
private order: readonly number[];
|
||||
/** wDexListingEnd: the position of the last seen Pokémon, plus one. */
|
||||
private end: number;
|
||||
private cursor = 0;
|
||||
private scroll = 0;
|
||||
/** wPrevDexEntry: the last entry shown, which places the cursor when
|
||||
* the Pokédex opens again. */
|
||||
prevEntry: number;
|
||||
|
||||
constructor(
|
||||
private host: PokedexHost,
|
||||
prevEntry = 0,
|
||||
) {
|
||||
this.prevEntry = prevEntry;
|
||||
this.order = host.dex.newOrder();
|
||||
this.end = this.findLastSeen();
|
||||
this.initCursorPosition();
|
||||
}
|
||||
|
||||
*run(): Gen {
|
||||
for (;;) {
|
||||
const button = yield* this.button(this.listView());
|
||||
if (button === "b") break;
|
||||
if (button === "a") {
|
||||
if (!this.host.seen.has(this.selected())) continue;
|
||||
yield* this.entry();
|
||||
continue;
|
||||
}
|
||||
// SELECT opens the option screen and START the search screen.
|
||||
if (button === "select" || button === "start") continue;
|
||||
this.listingInput(button);
|
||||
}
|
||||
yield { kind: "pokedex" };
|
||||
}
|
||||
|
||||
private *button(view: PokedexView): Gen<string> {
|
||||
const button = yield { kind: "pokedex", view };
|
||||
if (typeof button !== "string" || !POKEDEX_BUTTONS.includes(button))
|
||||
throw new Error("Invalid Pokédex button.");
|
||||
return button;
|
||||
}
|
||||
|
||||
/** Pokedex_InitDexEntryScreen and Pokedex_UpdateDexEntryScreen. */
|
||||
private *entry(): Gen {
|
||||
let page: 1 | 2 = 1;
|
||||
let action = 0;
|
||||
this.prevEntry = this.selected();
|
||||
for (;;) {
|
||||
const button = yield* this.button(this.entryView(page, action));
|
||||
// Pokedex_MoveArrowCursor reads left and right only.
|
||||
if (button === "left" && action > 0) action--;
|
||||
else if (button === "right" && action < ENTRY_ACTIONS - 1) action++;
|
||||
if (button === "b") return;
|
||||
if (button === "a") {
|
||||
// Pokedex_Page; .Area, .Cry and .Print are not ported.
|
||||
if (action === 0) {
|
||||
page = page === 1 ? 2 : 1;
|
||||
this.prevEntry = this.selected();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(button === "up" || button === "down") &&
|
||||
this.nextOrPrevious(button)
|
||||
) {
|
||||
// Pokedex_ReinitDexEntryScreen: page 1 and the cursor on PAGE.
|
||||
page = 1;
|
||||
action = 0;
|
||||
this.prevEntry = this.selected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pokedex_GetSelectedMon. */
|
||||
private selected() {
|
||||
return this.order[this.cursor + this.scroll] ?? 0;
|
||||
}
|
||||
|
||||
/** Pokedex_OrderMonsByMode.FindLastSeen. */
|
||||
private findLastSeen() {
|
||||
let end = NUM_POKEMON;
|
||||
while (end > 0 && !this.host.seen.has(this.order[end - 1])) end--;
|
||||
return end;
|
||||
}
|
||||
|
||||
/** Pokedex_InitCursorPosition: scroll, then move the cursor, until the
|
||||
* row holds wPrevDexEntry. It gives up after the last page's start and
|
||||
* seven rows, as the original's counters do. */
|
||||
private initCursorPosition() {
|
||||
const species = this.prevEntry;
|
||||
if (!species || species > NUM_POKEMON) return;
|
||||
let index = 0;
|
||||
if (this.end >= 8) {
|
||||
for (let c = this.end - 7; c > 0; c--) {
|
||||
if (this.order[index] === species) return;
|
||||
index++;
|
||||
this.scroll++;
|
||||
}
|
||||
}
|
||||
for (let c = LISTING_HEIGHT; c > 0; c--) {
|
||||
if (this.order[index] === species) return;
|
||||
index++;
|
||||
this.cursor++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pokedex_ListingHandleDPadInput: up and down move a row, left and
|
||||
* right a page, but only when the list is longer than one. */
|
||||
private listingInput(button: string): boolean {
|
||||
if (button === "up") return this.moveUp();
|
||||
if (button === "down") return this.moveDown();
|
||||
if (LISTING_HEIGHT >= this.end) return false;
|
||||
if (button === "left") {
|
||||
// Pokedex_ListingMoveUpOnePage.
|
||||
if (!this.scroll) return false;
|
||||
this.scroll =
|
||||
this.scroll < LISTING_HEIGHT ? 0 : this.scroll - LISTING_HEIGHT;
|
||||
return true;
|
||||
}
|
||||
if (button === "right") {
|
||||
// Pokedex_ListingMoveDownOnePage, whose sum wraps at 256.
|
||||
const sum = 2 * LISTING_HEIGHT + this.scroll;
|
||||
this.scroll =
|
||||
sum > 0xff || sum >= this.end
|
||||
? this.end - LISTING_HEIGHT
|
||||
: this.scroll + LISTING_HEIGHT;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Pokedex_ListingMoveCursorUp. */
|
||||
private moveUp() {
|
||||
if (this.cursor) this.cursor--;
|
||||
else if (this.scroll) this.scroll--;
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
/** Pokedex_ListingMoveCursorDown. */
|
||||
private moveDown() {
|
||||
const next = this.cursor + 1;
|
||||
if (next >= this.end) return false;
|
||||
if (next < LISTING_HEIGHT) this.cursor = next;
|
||||
else if (next + this.scroll < this.end) this.scroll++;
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pokedex_NextOrPreviousDexEntry: move until a seen Pokémon, or stay
|
||||
* where the cursor was. */
|
||||
private nextOrPrevious(button: "up" | "down"): boolean {
|
||||
const cursor = this.cursor,
|
||||
scroll = this.scroll;
|
||||
for (;;) {
|
||||
const moved = button === "up" ? this.moveUp() : this.moveDown();
|
||||
if (!moved) {
|
||||
this.cursor = cursor;
|
||||
this.scroll = scroll;
|
||||
return false;
|
||||
}
|
||||
if (this.host.seen.has(this.selected())) return true;
|
||||
}
|
||||
}
|
||||
|
||||
private common() {
|
||||
const { dex } = this.host;
|
||||
return {
|
||||
seen: this.host.seen.size,
|
||||
own: this.host.caught.size,
|
||||
cursor: this.cursor,
|
||||
scroll: this.scroll,
|
||||
end: this.end,
|
||||
labels: {
|
||||
seen: dex.dexString("String_SEEN"),
|
||||
own: dex.dexString("String_OWN"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Pokedex_PrintListing and Pokedex_LoadSelectedMonTiles. */
|
||||
private listView(): PokedexView {
|
||||
const { data, dex, seen, caught } = this.host;
|
||||
const rows: PokedexRow[] = [];
|
||||
for (let row = 0; row < LISTING_HEIGHT; row++) {
|
||||
const species = this.order[this.scroll + row];
|
||||
if (!species) continue;
|
||||
const shown = seen.has(species);
|
||||
rows.push({
|
||||
name: shown ? data.speciesName(species) : dex.notSeenName(),
|
||||
seen: shown,
|
||||
caught: shown && caught.has(species),
|
||||
});
|
||||
}
|
||||
const species = this.selected();
|
||||
return {
|
||||
screen: "list",
|
||||
...(seen.has(species) && { picture: species }),
|
||||
rows,
|
||||
...this.common(),
|
||||
};
|
||||
}
|
||||
|
||||
/** DisplayDexEntry. */
|
||||
private entryView(page: 1 | 2, action: number): PokedexView {
|
||||
const { data, dex, caught } = this.host;
|
||||
const species = this.selected();
|
||||
const entry = dex.entry(species);
|
||||
const owned = caught.has(species);
|
||||
const height = dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"),
|
||||
weight = dex.dexString("Pokedex_DrawDexEntryScreenBG.Weight");
|
||||
return {
|
||||
screen: "entry",
|
||||
picture: species,
|
||||
rows: [],
|
||||
...this.common(),
|
||||
entry: {
|
||||
species,
|
||||
name: data.speciesName(species),
|
||||
category: entry.category,
|
||||
number: String(species).padStart(3, "0"),
|
||||
height: owned ? dexHeight(height, entry.height) : height,
|
||||
weight: owned ? dexWeight(weight, entry.weight) : weight,
|
||||
caught: owned,
|
||||
page,
|
||||
...(owned && { text: entry.pages[page - 1] }),
|
||||
actions: dex
|
||||
.dexString("Pokedex_DrawDexEntryScreenBG.MenuItems")
|
||||
.trim()
|
||||
.split(/\s+/),
|
||||
action,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import type { PokedexData } from "../rom/pokedex";
|
||||
import { BATTLE, ENGINE_FLAGS, MENU, OVERWORLD, RAM } from "../rom/profile";
|
||||
import { C } from "../battle/constants";
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from "./interpreter";
|
||||
import { Pokegear } from "./pokegear";
|
||||
import { TrainerCard } from "./trainer-card";
|
||||
import { Pokedex } from "./pokedex";
|
||||
import { OptionsMenu, menuAccount } from "./options";
|
||||
import { flyMap } from "./fly-map";
|
||||
import { OW } from "../world/constants";
|
||||
@@ -102,11 +104,13 @@ const FACING_VECTORS = [
|
||||
|
||||
/** Menu cursors in work RAM, which CleanUpBattleRAM clears after every
|
||||
* battle: wBattleMenuCursorPosition (the start menu's 1-based position,
|
||||
* shared with the battle menu), wPartyMenuCursor (1-based) and wLastPocket. */
|
||||
* shared with the battle menu), wPartyMenuCursor (1-based) and wLastPocket.
|
||||
* wPrevDexEntry, the Pokédex's last entry, survives battles. */
|
||||
export interface MenuCursors {
|
||||
start: number;
|
||||
party: number;
|
||||
pocket: number;
|
||||
dex: number;
|
||||
}
|
||||
|
||||
/** The world's verdict on a field move chosen from the party menu: a
|
||||
@@ -131,6 +135,7 @@ export type MenuFieldMove =
|
||||
export interface StartMenuHost {
|
||||
reader: RomReader;
|
||||
data: PokemonData;
|
||||
dex: PokedexData;
|
||||
text(): TextContext;
|
||||
/** BikeFunction: the script that getting on or off the Bicycle queues
|
||||
* here, or undefined when .CheckEnvironment refuses it. */
|
||||
@@ -187,7 +192,7 @@ export class StartMenuRun {
|
||||
constructor(
|
||||
state: ScriptState,
|
||||
private host: StartMenuHost,
|
||||
cursors: MenuCursors = { start: 0, party: 0, pocket: 0 },
|
||||
cursors: MenuCursors = { start: 0, party: 0, pocket: 0, dex: 0 },
|
||||
select = false,
|
||||
) {
|
||||
this.state = copyScriptState(state);
|
||||
@@ -323,6 +328,22 @@ export class StartMenuRun {
|
||||
this.cursors.start = choice + 1;
|
||||
const item = items[choice];
|
||||
switch (item) {
|
||||
case STARTMENUITEM_POKEDEX: {
|
||||
// StartMenu_Pokedex needs a party and returns to the start menu.
|
||||
if (!this.state.party.length) break;
|
||||
const dex = new Pokedex(
|
||||
{
|
||||
data: this.data,
|
||||
dex: this.host.dex,
|
||||
seen: this.state.pokedex.seen,
|
||||
caught: this.state.pokedex.caught,
|
||||
},
|
||||
this.cursors.dex,
|
||||
);
|
||||
yield* dex.run();
|
||||
this.cursors.dex = dex.prevEntry;
|
||||
break;
|
||||
}
|
||||
case STARTMENUITEM_POKEMON:
|
||||
if (yield* this.pokemon()) return;
|
||||
break;
|
||||
|
||||
+23
-3
@@ -56,6 +56,7 @@ import {
|
||||
} from "../scripts/interpreter";
|
||||
import { IntroRun } from "../scripts/intro";
|
||||
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
|
||||
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
|
||||
import {
|
||||
StartMenuRun,
|
||||
type MenuCursors,
|
||||
@@ -311,6 +312,8 @@ export type Choice =
|
||||
| { kind: "mombank"; view: MomBankView; selected: 0 }
|
||||
/** A trainer card page, answered with the button pressed. */
|
||||
| { kind: "trainercard"; view: TrainerCardView; selected: 0 }
|
||||
/** A Pokédex screen, answered with the button pressed. */
|
||||
| { kind: "pokedex"; view: PokedexView; selected: 0 }
|
||||
/** The options screen, answered with the button pressed. */
|
||||
| { kind: "options"; view: OptionsView; selected: 0 }
|
||||
/** The Fly map, answered with the button pressed. */
|
||||
@@ -332,6 +335,7 @@ const buttonChoice = (choice: Choice) =>
|
||||
choice.kind === "pokegear" ||
|
||||
choice.kind === "mombank" ||
|
||||
choice.kind === "trainercard" ||
|
||||
choice.kind === "pokedex" ||
|
||||
choice.kind === "options" ||
|
||||
choice.kind === "flymap";
|
||||
export interface Dialogue {
|
||||
@@ -683,7 +687,7 @@ export class GameWorld {
|
||||
private turnTo?: Direction;
|
||||
private turnWait = 0;
|
||||
/** Start menu, party menu and pack cursors (work RAM, not saved). */
|
||||
private menuCursors: MenuCursors = { start: 0, party: 0, pocket: 0 };
|
||||
private menuCursors: MenuCursors = { start: 0, party: 0, pocket: 0, dex: 0 };
|
||||
/** OBJECT_ACTION_BUMP this tick: walking in place against an obstacle. */
|
||||
private bumping = false;
|
||||
/** A screen shake object (earthquake, step_shake) in progress. */
|
||||
@@ -2230,6 +2234,7 @@ export class GameWorld {
|
||||
{
|
||||
reader: this.assets.reader,
|
||||
data: this.assets.pokemon,
|
||||
dex: this.assets.pokedex,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bicycleScript: (state, select) => this.bicycleScript(state, select),
|
||||
timeOfDay: () => this.clockTimeOfDay,
|
||||
@@ -2619,6 +2624,14 @@ export class GameWorld {
|
||||
choice: { kind: "trainercard", view: effect.view, selected: 0 },
|
||||
};
|
||||
return true;
|
||||
case "pokedex":
|
||||
if (!effect.view) return false;
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
index: 0,
|
||||
choice: { kind: "pokedex", view: effect.view, selected: 0 },
|
||||
};
|
||||
return true;
|
||||
case "options":
|
||||
if (!effect.view) return false;
|
||||
this.dialogue = {
|
||||
@@ -3168,7 +3181,12 @@ export class GameWorld {
|
||||
this.battleRun = undefined;
|
||||
// ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM.
|
||||
const { result, evolvable } = next.value;
|
||||
this.menuCursors = { start: 0, party: 0, pocket: 0 };
|
||||
this.menuCursors = {
|
||||
start: 0,
|
||||
party: 0,
|
||||
pocket: 0,
|
||||
dex: this.menuCursors.dex,
|
||||
};
|
||||
if (this.script && evolvable && !(result & 0x0f)) {
|
||||
this.script.interlude = {
|
||||
run: new PartyEvolution(
|
||||
@@ -4202,6 +4220,8 @@ export class GameWorld {
|
||||
!MOM_BANK_BUTTONS.includes(answer as never)) ||
|
||||
(dialogue.choice.kind === "trainercard" &&
|
||||
!TRAINER_CARD_BUTTONS.includes(answer as never)) ||
|
||||
(dialogue.choice.kind === "pokedex" &&
|
||||
!POKEDEX_BUTTONS.includes(answer as never)) ||
|
||||
(dialogue.choice.kind === "options" &&
|
||||
!OPTIONS_BUTTONS.includes(answer as never)) ||
|
||||
(dialogue.choice.kind === "flymap" &&
|
||||
@@ -4538,7 +4558,7 @@ export class GameWorld {
|
||||
this.battle = undefined;
|
||||
this.battleRun = undefined;
|
||||
this.memScript = undefined;
|
||||
this.menuCursors = { start: 0, party: 0, pocket: 0 };
|
||||
this.menuCursors = { start: 0, party: 0, pocket: 0, dex: 0 };
|
||||
this.clearNotice();
|
||||
this.introPending = false;
|
||||
// A save made during the introduction starts it again.
|
||||
|
||||
+63
-7
@@ -5,6 +5,8 @@ import {
|
||||
} from "./game/rendering/trainer-card";
|
||||
import "./ui/styles.css";
|
||||
import { renderShell } from "./ui/shell";
|
||||
import { renderPokedexScreen, renderPokedexTab } from "./ui/pokedex";
|
||||
import { ENGINE_FLAGS } from "./game/rom/profile";
|
||||
import { bindTabs } from "./ui/tabs";
|
||||
import { NativeRuntime } from "./game/runtime";
|
||||
import { LocalMapView } from "./game/rendering/minimap";
|
||||
@@ -61,6 +63,7 @@ function showWorld(world: GameWorld) {
|
||||
showMail(world);
|
||||
showPokegear(world);
|
||||
showTrainerCard(world);
|
||||
showPokedex(world);
|
||||
showOptions(world);
|
||||
showFlyMap(world);
|
||||
get("town-map-view").hidden = !world.townMap;
|
||||
@@ -79,6 +82,7 @@ function showWorld(world: GameWorld) {
|
||||
// The Pokégear's map and radio cards have no textbox.
|
||||
(dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) ||
|
||||
dialogue.choice?.kind === "trainercard" ||
|
||||
dialogue.choice?.kind === "pokedex" ||
|
||||
dialogue.choice?.kind === "options" ||
|
||||
dialogue.choice?.kind === "flymap" ||
|
||||
Boolean(mart) ||
|
||||
@@ -138,11 +142,12 @@ function showChoice(choice: Choice | undefined) {
|
||||
else if (
|
||||
choice.kind === "pokegear" ||
|
||||
choice.kind === "trainercard" ||
|
||||
choice.kind === "pokedex" ||
|
||||
choice.kind === "options" ||
|
||||
choice.kind === "flymap"
|
||||
) {
|
||||
// The Pokégear, the trainer card, the options and the Fly map read
|
||||
// the controls directly.
|
||||
// The Pokégear, the trainer card, the Pokédex, the options and the
|
||||
// Fly map read the controls directly.
|
||||
container.replaceChildren();
|
||||
container.hidden = true;
|
||||
} else if (choice.kind === "yesno")
|
||||
@@ -1078,6 +1083,50 @@ function showPokegear(world: GameWorld) {
|
||||
} else body.replaceChildren();
|
||||
}
|
||||
|
||||
let pokedexKey = "";
|
||||
/** Pokedex: the main list or an entry, from the world's current view. */
|
||||
function showPokedex(world: GameWorld) {
|
||||
const choice = world.dialogue?.choice;
|
||||
const view = choice?.kind === "pokedex" ? choice.view : undefined;
|
||||
const panel = get("pokedex-screen");
|
||||
panel.hidden = !view;
|
||||
if (!view) {
|
||||
pokedexKey = "";
|
||||
return;
|
||||
}
|
||||
const key = JSON.stringify(view);
|
||||
if (key === pokedexKey) return;
|
||||
pokedexKey = key;
|
||||
renderPokedexScreen(panel, view, world.assets);
|
||||
}
|
||||
|
||||
let pokedexTabKey = "";
|
||||
let pokedexTabSelection: number | undefined;
|
||||
/** The Pokédex tab, from the loaded game's committed or draft state. */
|
||||
function showPokedexTab() {
|
||||
if (tabs.active() !== "pokedex") return;
|
||||
const world = ready() ? runtime.world : undefined;
|
||||
const state = world?.liveState;
|
||||
const progress = state && {
|
||||
owned: state.flags.has(ENGINE_FLAGS.pokedex),
|
||||
seen: state.pokedex.seen,
|
||||
caught: state.pokedex.caught,
|
||||
};
|
||||
const key = JSON.stringify([
|
||||
Boolean(world),
|
||||
progress && [progress.owned, [...progress.seen], [...progress.caught]],
|
||||
pokedexTabSelection,
|
||||
]);
|
||||
if (key === pokedexTabKey) return;
|
||||
pokedexTabKey = key;
|
||||
renderPokedexTab(get("panel-pokedex"), world?.assets, progress, pokedexTabSelection, (species) => {
|
||||
pokedexTabSelection = species;
|
||||
showPokedexTab();
|
||||
// On narrow screens the entry sits below the list.
|
||||
get("panel-pokedex").querySelector(".pokedex-tab-entry")?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
|
||||
let optionsKey = "";
|
||||
/** _Option's screen: each option with its setting, and the ▶ cursor. */
|
||||
function showOptions(world: GameWorld) {
|
||||
@@ -1454,11 +1503,17 @@ const controls = bindControls(
|
||||
() => {},
|
||||
);
|
||||
const ready = () => ["paused", "playing"].includes(session.state);
|
||||
const tabs = bindTabs(root, () => {
|
||||
pause();
|
||||
controls.release();
|
||||
update();
|
||||
});
|
||||
const tabs = bindTabs(
|
||||
root,
|
||||
() => {
|
||||
pause();
|
||||
controls.release();
|
||||
update();
|
||||
},
|
||||
(id) => {
|
||||
if (id === "pokedex") showPokedexTab();
|
||||
},
|
||||
);
|
||||
|
||||
function showError(error: unknown) {
|
||||
status.textContent =
|
||||
@@ -1498,6 +1553,7 @@ function update() {
|
||||
get("rom-label").classList.toggle("disabled", busy);
|
||||
get<HTMLButtonElement>("dialogue-next").disabled =
|
||||
busy || session.state !== "playing";
|
||||
showPokedexTab();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { CrystalAssets } from "../game/rom/assets";
|
||||
import { dexHeight, dexWeight } from "../game/rom/pokedex";
|
||||
import { drawTile } from "../game/rendering/pixels";
|
||||
import type { PokedexView } from "../game/scripts/pokedex";
|
||||
import type { Palette } from "../game/world/types";
|
||||
|
||||
/** The ink and paper the footprint's 1bpp tiles are drawn with. */
|
||||
const FOOTPRINT_INK: Palette = [
|
||||
[40, 40, 49],
|
||||
[255, 251, 234],
|
||||
];
|
||||
|
||||
const element = <K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
text = "",
|
||||
className = "",
|
||||
) => {
|
||||
const node = document.createElement(tag);
|
||||
node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
};
|
||||
|
||||
/** Pokedex_LoadSelectedMonTiles: a seen Pokémon's front picture, or the
|
||||
* question mark. The list draws it in the question mark's palette. */
|
||||
function dexPicture(
|
||||
assets: CrystalAssets,
|
||||
species: number | undefined,
|
||||
ownPalette: boolean,
|
||||
) {
|
||||
const canvas = element("canvas", "", "pokedex-pic");
|
||||
canvas.width = canvas.height = 56;
|
||||
canvas.setAttribute("role", "img");
|
||||
const tiles =
|
||||
species === undefined
|
||||
? assets.pokedex.questionMarkPic()
|
||||
: assets.pokemon.frontPic(species);
|
||||
const palette =
|
||||
species !== undefined && ownPalette
|
||||
? assets.pokemon.palette(species)
|
||||
: assets.pokedex.questionMarkPalette();
|
||||
const context = canvas.getContext("2d")!;
|
||||
const image = context.createImageData(56, 56);
|
||||
// Picture tiles are stored column by column.
|
||||
for (let tile = 0; tile < 49; tile++)
|
||||
drawTile(
|
||||
image.data,
|
||||
56,
|
||||
56,
|
||||
tiles,
|
||||
tile,
|
||||
Math.floor(tile / 7) * 8,
|
||||
(tile % 7) * 8,
|
||||
palette,
|
||||
);
|
||||
context.putImageData(image, 0, 0);
|
||||
canvas.setAttribute(
|
||||
"aria-label",
|
||||
species === undefined
|
||||
? "Unknown Pokémon"
|
||||
: assets.pokemon.speciesName(species),
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** Pokedex_DrawFootprint: the 2x2 1bpp tiles, whose clear bits are the
|
||||
* print, like the Pokédex's inverted font. */
|
||||
function footprint(assets: CrystalAssets, species: number) {
|
||||
const canvas = element("canvas", "", "pokedex-footprint");
|
||||
canvas.width = canvas.height = 16;
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
const tiles = assets.pokedex.footprint(species);
|
||||
const context = canvas.getContext("2d")!;
|
||||
const image = context.createImageData(16, 16);
|
||||
for (let tile = 0; tile < 4; tile++)
|
||||
for (let row = 0; row < 8; row++) {
|
||||
const bits = tiles[tile * 8 + row];
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const color = FOOTPRINT_INK[(bits >> (7 - col)) & 1];
|
||||
const i = (((tile >> 1) * 8 + row) * 16 + (tile & 1) * 8 + col) * 4;
|
||||
image.data.set([...color, 255], i);
|
||||
}
|
||||
}
|
||||
context.putImageData(image, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** The ball DisplayDexEntry's list marks owned Pokémon with (tile $4f). */
|
||||
function ownedMark(owned: boolean) {
|
||||
const mark = element("span", "", "pokedex-ball");
|
||||
if (owned) mark.setAttribute("aria-label", "Owned");
|
||||
else mark.classList.add("empty");
|
||||
return mark;
|
||||
}
|
||||
|
||||
/** DisplayDexEntry's top half: the picture and footprint, the name and
|
||||
* category, the number and the measurements. */
|
||||
function entryHeader(
|
||||
assets: CrystalAssets,
|
||||
entry: {
|
||||
species: number;
|
||||
name: string;
|
||||
category: string;
|
||||
number: string;
|
||||
height: string;
|
||||
weight: string;
|
||||
},
|
||||
) {
|
||||
const header = element("div", "", "pokedex-entry-top");
|
||||
const details = element("div", "", "pokedex-entry-details");
|
||||
details.append(
|
||||
element("strong", entry.name),
|
||||
element("span", entry.category),
|
||||
element("span", `№.${entry.number}`),
|
||||
element("span", entry.height),
|
||||
element("span", entry.weight),
|
||||
);
|
||||
header.append(
|
||||
dexPicture(assets, entry.species, true),
|
||||
details,
|
||||
footprint(assets, entry.species),
|
||||
);
|
||||
return header;
|
||||
}
|
||||
|
||||
/** Pokedex (engine/pokedex/pokedex.asm): the main list with the picture
|
||||
* and counts beside it, or a Pokémon's entry. */
|
||||
export function renderPokedexScreen(
|
||||
container: HTMLElement,
|
||||
view: PokedexView,
|
||||
assets: CrystalAssets,
|
||||
) {
|
||||
container.dataset.screen = view.screen;
|
||||
const entry = view.entry;
|
||||
if (view.screen === "entry" && entry) {
|
||||
const text = element("p", entry.text ?? "", "pokedex-text");
|
||||
text.setAttribute("aria-live", "polite");
|
||||
const page = element("span", `P.${entry.page}`, "pokedex-page");
|
||||
const actions = element("ol", "", "pokedex-actions");
|
||||
entry.actions.forEach((label, index) => {
|
||||
const item = element("li", label);
|
||||
item.dataset.mark = index === entry.action ? "▶" : " ";
|
||||
if (index === entry.action) item.setAttribute("aria-current", "true");
|
||||
actions.append(item);
|
||||
});
|
||||
container.replaceChildren(
|
||||
entryHeader(assets, entry),
|
||||
element("div", "", "pokedex-text-box"),
|
||||
actions,
|
||||
);
|
||||
container.querySelector(".pokedex-text-box")!.append(page, text);
|
||||
return;
|
||||
}
|
||||
const side = element("div", "", "pokedex-side");
|
||||
const counts = element("dl", "", "pokedex-counts");
|
||||
counts.append(
|
||||
element("dt", view.labels.seen),
|
||||
element("dd", String(view.seen).padStart(3, " ")),
|
||||
element("dt", view.labels.own),
|
||||
element("dd", String(view.own).padStart(3, " ")),
|
||||
);
|
||||
side.append(dexPicture(assets, view.picture, false), counts);
|
||||
const list = element("ol", "", "pokedex-list");
|
||||
view.rows.forEach((row, index) => {
|
||||
const item = element("li");
|
||||
item.dataset.mark = index === view.cursor ? "▶" : " ";
|
||||
if (index === view.cursor) item.setAttribute("aria-current", "true");
|
||||
item.append(ownedMark(row.caught), element("span", row.name));
|
||||
list.append(item);
|
||||
});
|
||||
// Pokedex_PutScrollbarOAM: the bar's position along the seen range.
|
||||
const bar = element("div", "", "pokedex-scrollbar");
|
||||
const thumb = element("i");
|
||||
const range = Math.max(1, view.end - view.rows.length);
|
||||
const percent = Math.min(100, (view.scroll / range) * 100);
|
||||
// The 12px thumb stays inside the bar at either end.
|
||||
thumb.style.top = `calc(${percent}% - ${(percent * 12) / 100}px)`;
|
||||
bar.setAttribute("aria-hidden", "true");
|
||||
bar.append(thumb);
|
||||
container.replaceChildren(side, list, bar);
|
||||
}
|
||||
|
||||
/** What the Pokédex tab reads from a loaded game. */
|
||||
export interface PokedexProgress {
|
||||
owned: boolean;
|
||||
seen: ReadonlySet<number>;
|
||||
caught: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
/** The Pokédex tab: New mode's list up to the last seen Pokémon, and the
|
||||
* selected entry. Unseen Pokémon stay unnamed and uncaught ones show no
|
||||
* measurements or text, as in the game. */
|
||||
export function renderPokedexTab(
|
||||
panel: HTMLElement,
|
||||
assets: CrystalAssets | undefined,
|
||||
progress: PokedexProgress | undefined,
|
||||
selected: number | undefined,
|
||||
select: (species: number) => void,
|
||||
) {
|
||||
const status = element("p", "", "pokedex-tab-status");
|
||||
const header = element("header");
|
||||
header.append(element("p", "POKÉDEX", "menu-title"));
|
||||
if (!assets || !progress) {
|
||||
header.append(element("h2", "No game loaded"));
|
||||
status.textContent =
|
||||
"Choose a ROM to read the Pokémon you have seen and caught.";
|
||||
panel.replaceChildren(header, status);
|
||||
return;
|
||||
}
|
||||
if (!progress.owned) {
|
||||
header.append(element("h2", "No Pokédex yet"));
|
||||
status.textContent =
|
||||
"Your records appear here once you receive the Pokédex in the game.";
|
||||
panel.replaceChildren(header, status);
|
||||
return;
|
||||
}
|
||||
const dex = assets.pokedex;
|
||||
header.append(
|
||||
element(
|
||||
"h2",
|
||||
`${dex.dexString("String_SEEN")} ${progress.seen.size} · ${dex.dexString("String_OWN")} ${progress.caught.size}`,
|
||||
),
|
||||
);
|
||||
const order = dex.newOrder();
|
||||
let end = order.length;
|
||||
while (end > 0 && !progress.seen.has(order[end - 1])) end--;
|
||||
const list = element("ol", "", "pokedex-tab-list");
|
||||
list.setAttribute("aria-label", "New mode order");
|
||||
for (const species of order.slice(0, end)) {
|
||||
const item = element("li");
|
||||
const seen = progress.seen.has(species);
|
||||
if (seen) {
|
||||
const button = element("button");
|
||||
button.type = "button";
|
||||
button.append(
|
||||
ownedMark(progress.caught.has(species)),
|
||||
element("span", assets.pokemon.speciesName(species)),
|
||||
);
|
||||
button.setAttribute("aria-pressed", String(species === selected));
|
||||
button.addEventListener("click", () => select(species));
|
||||
item.append(button);
|
||||
} else item.append(ownedMark(false), element("span", dex.notSeenName()));
|
||||
list.append(item);
|
||||
}
|
||||
const detail = element("section", "", "pokedex-tab-entry");
|
||||
detail.setAttribute("aria-live", "polite");
|
||||
const species =
|
||||
selected !== undefined && progress.seen.has(selected)
|
||||
? selected
|
||||
: undefined;
|
||||
if (species === undefined) {
|
||||
detail.append(
|
||||
element(
|
||||
"p",
|
||||
end
|
||||
? "Select a Pokémon you have seen to read its entry."
|
||||
: "You have not seen any Pokémon yet.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const entry = dex.entry(species);
|
||||
const caught = progress.caught.has(species);
|
||||
const height = dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"),
|
||||
weight = dex.dexString("Pokedex_DrawDexEntryScreenBG.Weight");
|
||||
detail.append(
|
||||
entryHeader(assets, {
|
||||
species,
|
||||
name: assets.pokemon.speciesName(species),
|
||||
category: entry.category,
|
||||
number: String(species).padStart(3, "0"),
|
||||
height: caught ? dexHeight(height, entry.height) : height,
|
||||
weight: caught ? dexWeight(weight, entry.weight) : weight,
|
||||
}),
|
||||
);
|
||||
if (caught)
|
||||
for (const page of entry.pages)
|
||||
detail.append(element("p", page, "pokedex-text"));
|
||||
else
|
||||
detail.append(
|
||||
element(
|
||||
"p",
|
||||
"Catch this Pokémon to read its entry.",
|
||||
"pokedex-tab-status",
|
||||
),
|
||||
);
|
||||
}
|
||||
const body = element("div", "", "pokedex-tab-body");
|
||||
body.append(list, detail);
|
||||
panel.replaceChildren(header, body);
|
||||
}
|
||||
+2
-1
@@ -35,6 +35,7 @@ export function renderShell(root: HTMLElement) {
|
||||
<div id="mail-view" class="mail-view" hidden role="dialog" aria-labelledby="mail-type"><p id="mail-type" class="mail-type"></p><p id="mail-message" class="mail-message"></p><p id="mail-author" class="mail-author"></p><button id="mail-close">Close</button></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="pokedex-screen" class="pokedex-screen" hidden aria-label="Pokédex"></div>
|
||||
<div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div>
|
||||
<div id="fly-map" class="fly-map" hidden aria-label="Fly map"><p id="fly-map-bubble" class="fly-map-bubble" aria-live="polite"></p><canvas id="fly-map-canvas" width="160" height="144" role="img"></canvas></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>
|
||||
@@ -67,7 +68,7 @@ export function renderShell(root: HTMLElement) {
|
||||
<header><p class="menu-title">MAP</p><h2 id="map-location">No area loaded</h2><p id="map-position">Choose a ROM to see your position.</p></header>
|
||||
<div class="world-map-frame"><canvas id="world-map" width="160" height="128"></canvas><p id="map-empty">Your current area will appear here after loading a game. A wider Johto and Kanto atlas is planned.</p></div>
|
||||
</section>
|
||||
<section id="panel-pokedex" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-pokedex" data-panel="pokedex" hidden><p class="menu-title">POKÉDEX</p><h2>Planned</h2><p>Track the Pokémon you have seen and caught. Available after encounters and catching arrive.</p></section>
|
||||
<section id="panel-pokedex" class="tab-panel menu-panel pokedex-panel" role="tabpanel" aria-labelledby="tab-pokedex" data-panel="pokedex" hidden><header><p class="menu-title">POKÉDEX</p><h2>No game loaded</h2></header><p class="pokedex-tab-status">Choose a ROM to read the Pokémon you have seen and caught.</p></section>
|
||||
<section id="panel-link" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-link" data-panel="link" hidden><p class="menu-title">LINK</p><h2>Planned</h2><p>Trade and battle with other players. Online Link is planned.</p></section>
|
||||
<section id="panel-friends" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-friends" data-panel="friends" hidden><p class="menu-title">FRIENDS</p><h2>Planned</h2><p>Keep your friends together and send private Link invites. Friends are planned.</p></section>
|
||||
</main>
|
||||
|
||||
@@ -583,6 +583,7 @@ dialog h2 {
|
||||
.intro-screen:not([hidden]) ~ .minimap-overlay,
|
||||
.pokegear:not([hidden]) ~ .minimap-overlay,
|
||||
.trainer-card:not([hidden]) ~ .minimap-overlay,
|
||||
.pokedex-screen:not([hidden]) ~ .minimap-overlay,
|
||||
.options-screen:not([hidden]) ~ .minimap-overlay,
|
||||
.fly-map:not([hidden]) ~ .minimap-overlay {
|
||||
display: none;
|
||||
@@ -1371,6 +1372,219 @@ dialog dd {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* Pokedex: PREDEFPAL_POKEDEX's red frame around the list or the entry. */
|
||||
.pokedex-screen {
|
||||
--dex-red: #d65231;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
color: var(--ink);
|
||||
background: var(--dex-red);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pokedex-screen[data-screen="list"] {
|
||||
grid-template-columns: auto minmax(0, 1fr) 8px;
|
||||
}
|
||||
.pokedex-screen[data-screen="entry"] {
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
.pokedex-entry-top .pokedex-pic {
|
||||
width: min(22vw, 112px);
|
||||
}
|
||||
.pokedex-side,
|
||||
.pokedex-list,
|
||||
.pokedex-entry-top,
|
||||
.pokedex-text-box,
|
||||
.pokedex-actions {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--cream);
|
||||
border: 5px double var(--ink);
|
||||
}
|
||||
.pokedex-side {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.pokedex-pic {
|
||||
width: min(28vw, 112px);
|
||||
aspect-ratio: 1;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.pokedex-counts {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 2px 10px;
|
||||
margin: 0;
|
||||
}
|
||||
.pokedex-counts dt,
|
||||
.pokedex-counts dd {
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
.pokedex-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
}
|
||||
.pokedex-list li,
|
||||
.pokedex-tab-list li,
|
||||
.pokedex-tab-list button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: pre;
|
||||
}
|
||||
.pokedex-list li::before,
|
||||
.pokedex-actions li::before {
|
||||
content: attr(data-mark);
|
||||
width: 1.2em;
|
||||
}
|
||||
/* The owned mark: a Poké Ball, or its empty space. */
|
||||
.pokedex-ball {
|
||||
flex: none;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(var(--dex-red, #d65231) 45%, var(--ink) 45% 55%, #fff 55%);
|
||||
}
|
||||
.pokedex-ball.empty {
|
||||
visibility: hidden;
|
||||
}
|
||||
.pokedex-scrollbar {
|
||||
position: relative;
|
||||
background: var(--cream);
|
||||
border: 2px solid var(--ink);
|
||||
}
|
||||
.pokedex-scrollbar i {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 12px;
|
||||
background: var(--ink);
|
||||
}
|
||||
.pokedex-entry-top {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
.pokedex-entry-details {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
white-space: pre;
|
||||
}
|
||||
.pokedex-footprint {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.pokedex-text-box {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
}
|
||||
.pokedex-text {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.pokedex-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
list-style: none;
|
||||
}
|
||||
.pokedex-actions li {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* The Pokédex tab: the New mode list beside the selected entry. */
|
||||
.pokedex-panel {
|
||||
--dex-red: #d65231;
|
||||
}
|
||||
.pokedex-tab-status {
|
||||
max-width: 680px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.pokedex-tab-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 260px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.pokedex-tab-list {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
background: var(--cream);
|
||||
border: 5px double var(--ink);
|
||||
}
|
||||
.pokedex-tab-list li {
|
||||
min-height: 32px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.pokedex-tab-list button {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pokedex-tab-list button[aria-pressed="true"]::after {
|
||||
content: " ◀";
|
||||
}
|
||||
.pokedex-tab-entry {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--cream);
|
||||
border: 5px double var(--ink);
|
||||
}
|
||||
.pokedex-tab-entry .pokedex-entry-top {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.pokedex-screen {
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
}
|
||||
.pokedex-side,
|
||||
.pokedex-list,
|
||||
.pokedex-entry-top,
|
||||
.pokedex-text-box,
|
||||
.pokedex-actions {
|
||||
padding: 4px 8px;
|
||||
border-width: 4px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.pokedex-tab-body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.pokedex-tab-list {
|
||||
max-height: 40vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* _Option: one framed box of options, settings beside their labels. */
|
||||
.options-screen {
|
||||
position: absolute;
|
||||
|
||||
+6
-1
@@ -1,7 +1,11 @@
|
||||
const tabIds = ["game", "saves", "map", "pokedex", "link", "friends"] as const;
|
||||
type TabId = (typeof tabIds)[number];
|
||||
|
||||
export function bindTabs(root: HTMLElement, onLeaveGame: () => void) {
|
||||
export function bindTabs(
|
||||
root: HTMLElement,
|
||||
onLeaveGame: () => void,
|
||||
onSelect: (id: TabId) => void = () => {},
|
||||
) {
|
||||
const tabs = tabIds.map((id) =>
|
||||
root.querySelector<HTMLButtonElement>(`#tab-${id}`),
|
||||
);
|
||||
@@ -25,6 +29,7 @@ export function bindTabs(root: HTMLElement, onLeaveGame: () => void) {
|
||||
button.innerHTML = `${selected ? '<span aria-hidden="true">▶</span>' : ""}${button.textContent?.replace(/^▶/, "").trim() ?? ""}`;
|
||||
panels[index]!.hidden = !selected;
|
||||
});
|
||||
onSelect(current);
|
||||
};
|
||||
|
||||
tabs.forEach((tab, index) => {
|
||||
|
||||
Reference in new issue
Block a user