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 = ( 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; caught: ReadonlySet; } /** 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); }