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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user