Port the Pokédex list, entries and tab (#35)
Checks / web (push) Failing after 21s
Private ROM checks / private-rom (push) Failing after 14m27s

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:
samyandClaude Opus 5.5 committed 2026-10-03 20:19:24 +02:00
1 parent 03aa913a94
commit cdee576818
20 files changed
+1624 -29

No files matched your search

+290
View File
@@ -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);
}