import { drawAtlas, drawTownMap } from "./game/rendering/town-map"; import { drawTileScreen } from "./game/rendering/tile-screen"; import type { AtlasRegion } from "./game/world/atlas"; import { drawCardPic, drawTrainerCardBadges, } from "./game/rendering/trainer-card"; import "./ui/styles.css"; import { renderShell } from "./ui/shell"; import { renderPokedexScreen, renderPokedexTab } from "./ui/pokedex"; import { RESERVED_KEYS, TEXT_SCALES, VOLUMES, type Volume, applyTextScale, buttonKeys, defaultPreferences, keyMap, keyName, loadPreferences, rebind, savePreferences, type Preferences, type TextScale, } from "./ui/preferences"; import type { Button as GameButton } from "./input/types"; import { ENGINE_FLAGS } from "./game/rom/profile"; import { bindTabs } from "./ui/tabs"; import { NativeRuntime } from "./game/runtime"; import { LocalMapView } from "./game/rendering/minimap"; import type { Choice, GameWorld } from "./game/world/world"; import type { PokegearCard } from "./game/scripts/pokegear"; import { drawTile } from "./game/rendering/pixels"; import { C } from "./game/battle/constants"; import { PokemonData } from "./game/rom/pokemon"; import type { Direction, Palette } from "./game/world/types"; import type { Button } from "./input/types"; import { NICKNAME_PATTERN, maxPP } from "./game/party/pokemon"; import { MAIL_LINE_LENGTH, mailMessage, validMailMessage, } from "./game/party/mail"; import { SlotSession, type ImportPreview, type SlotEntry } from "./app/slot-session"; import { InputController } from "./input/controller"; import { bindControls } from "./input/bindings"; import { validateRom } from "./rom/validate"; import { SlotRepository, type SlotSummary } from "./storage/slots"; import { acquireSessionLock } from "./platform/session-lock"; import { InstallableApp, quotaExceeded, requestPersistentStorage, storageStatus, } from "./platform/pwa"; const root = document.querySelector("#app")!; renderShell(root); const get = (id: string) => document.getElementById(id) as T; const canvas = get("game-screen"); const mapView = new LocalMapView( get("minimap"), get("world-map"), ); const status = get("status"); const saveStatus = get("save-status"); let busy = false; let lockRelease: (() => void) | undefined; let saving: Promise | undefined; function showWorld(world: GameWorld) { mapView.update(world); get("map-location").textContent = world.map.name === world.map.location ? world.map.name : `${world.map.name} · ${world.map.location}`; get("map-position").textContent = `You are at ${world.x}, ${world.y}. Red marks your position.`; get("minimap-position").textContent = `${world.x}, ${world.y}`; get("map-empty").hidden = true; get("intro-screen").hidden = !world.intro; get("location").textContent = world.map.name; get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`; get("game-notice").textContent = world.notice; showBattle(world); showStatsScreen(world); showMail(world); showPokegear(world); showTrainerCard(world); showPokedex(world); showOptions(world); showFlyMap(world); showTileScreen(world); get("town-map-view").hidden = !world.townMap; if (world.townMap) drawTownMap(get("town-map-canvas"), world); // While a script waits for its sound, the last page stays in the box. const dialogue = world.dialogue ?? (world.waitingPage !== undefined ? { pages: [world.waitingPage], index: 0, holdTicks: 0 } : undefined); showPicture(world); // A page without text is a button wait while a picture is shown. const pictureWait = Boolean( dialogue && !dialogue.choice && dialogue.pages[dialogue.index] === "", ); const mart = dialogue?.choice && martChoice(dialogue.choice); get("native-dialogue").hidden = !dialogue || pictureWait || // 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" || dialogue.choice?.kind === "screen" || Boolean(mart) || world.townMap || Boolean(world.statsScreen) || Boolean(world.mailView); get("pokepic-next").hidden = !pictureWait; get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? ""; get("dialogue-page").textContent = dialogue && dialogue.pages.length > 1 ? `${dialogue.index + 1} / ${dialogue.pages.length}` : ""; const last = dialogue && dialogue.index === dialogue.pages.length - 1; // A house radio shows only its text box; A, B or Continue stops it. const choice = last && !mart && dialogue.choice?.kind !== "mapradio" ? dialogue.choice : undefined; // Timed pages advance themselves; choices replace Continue. get("dialogue-next").hidden = Boolean( choice || mart || (last && dialogue.holdTicks !== undefined), ); showChoice(choice); showMart(world, mart); // Coordinates provide a stable accessible description and a test observation // point, without exposing mutable game state or a production debug API. canvas.dataset.map = world.mapId; canvas.dataset.x = String(world.x); canvas.dataset.y = String(world.y); canvas.dataset.activity = world.activity; canvas.dataset.intro = String(world.intro); canvas.setAttribute( "aria-label", `${world.map.name}. Position ${world.x}, ${world.y}. ${controlsSummary()}`, ); } /** The choice box floats above the text box, so it may only be as tall as * the screen above the text box: a phone's start menu then scrolls instead * of running off the top of the screen, where it cannot be tapped. */ function fitChoiceBox() { const container = get("dialogue-choice"); if (container.hidden) return; // The box's own 13px gap above the text box and the 12px inset the text // box keeps from the screen's edges. const room = get("native-dialogue").offsetTop - 13 - 12; container.style.setProperty("--choice-room", `${Math.max(room, 0)}px`); } new ResizeObserver(fitChoiceBox).observe(get("native-dialogue")); new ResizeObserver(fitChoiceBox).observe(canvas.parentElement!); function showChoice(choice: Choice | undefined) { const container = get("dialogue-choice"); const shown = !container.hidden; container.hidden = !choice; if (choice && !shown) fitChoiceBox(); const key = choice ? JSON.stringify(choice) : ""; if (container.dataset.choice === key) return; container.dataset.choice = key; container.dataset.kind = choice?.kind ?? ""; const button = ( label: string, pressed: boolean | undefined, action: () => void, ) => { const element = document.createElement("button"); element.type = "button"; element.textContent = label; if (pressed !== undefined) element.setAttribute("aria-pressed", String(pressed)); element.addEventListener("click", () => { if (session.state !== "playing") return; action(); canvas.focus({ preventScroll: true }); }); return element; }; if (!choice) container.replaceChildren(); else if ( choice.kind === "pokegear" || choice.kind === "trainercard" || choice.kind === "pokedex" || choice.kind === "options" || choice.kind === "flymap" || choice.kind === "screen" ) { // The Pokégear, the trainer card, the Pokédex, the options, the Fly // map and tile screens read the controls directly. container.replaceChildren(); container.hidden = true; } else if (choice.kind === "yesno") container.replaceChildren( ...balanceBox(choice.balance), button("YES", choice.selected === 1, () => runtime.choose(1)), button("NO", choice.selected === 0, () => runtime.choose(0)), ); else if (choice.kind === "weekday") { // The original weekday box: Up for the next day, Down for the previous. const day = document.createElement("output"); day.textContent = choice.names[choice.selected]; day.setAttribute("aria-live", "polite"); container.replaceChildren( button("▼", undefined, () => { runtime.world?.moveCursor("down"); if (runtime.world) showWorld(runtime.world); }), day, button("▲", undefined, () => { runtime.world?.moveCursor("up"); if (runtime.world) showWorld(runtime.world); }), button("OK", true, () => runtime.choose(choice.selected)), ); container.firstElementChild?.setAttribute("aria-label", "Previous day"); container.children[2]?.setAttribute("aria-label", "Next day"); } else if (choice.kind === "clock") { // InitClock's hour or minutes: Up for later, Down for earlier. const value = document.createElement("output"); value.textContent = choice.labels[choice.selected]; value.setAttribute("aria-live", "polite"); container.replaceChildren( button("▼", undefined, () => { runtime.world?.moveCursor("down"); if (runtime.world) showWorld(runtime.world); }), value, button("▲", undefined, () => { runtime.world?.moveCursor("up"); if (runtime.world) showWorld(runtime.world); }), button("OK", true, () => runtime.choose(choice.selected)), ); container.firstElementChild?.setAttribute("aria-label", "Earlier"); container.children[2]?.setAttribute("aria-label", "Later"); } else if (choice.kind === "mombank") { // Mom_ContinueMenuSetup's box: SAVED, HELD and the amount, whose digit // under the cursor blinks. The buttons send the joypad's own presses. const { view } = choice; const box = document.createElement("div"); box.className = "mom-bank"; const row = (label: string, value: HTMLElement | string) => { const line = document.createElement("p"), name = document.createElement("span"), amount = document.createElement("output"); name.textContent = label; amount.append(value); line.append(name, amount); return line; }; const digits = document.createElement("span"); digits.setAttribute("aria-label", `¥${view.amount}`); digits.append( "¥", ...[...String(view.amount).padStart(6, "0")].map((digit, index) => { const cell = document.createElement("span"); cell.textContent = digit; if (index === view.cursor) cell.className = "mom-bank-cursor"; return cell; }), ); box.append( row(view.labels[0], `¥${view.saved}`), row(view.labels[1], `¥${view.held}`), row(view.labels[2], digits), ); const press = (label: string, aria: string, name: string) => { const element = button(label, undefined, () => runtime.choose(name)); element.setAttribute("aria-label", aria); return element; }; container.replaceChildren( box, press("◀", "Previous digit", "left"), press("▼", "Decrease digit", "down"), press("▲", "Increase digit", "up"), press("▶", "Next digit", "right"), press("OK", "OK", "a"), press("CANCEL", "Cancel", "b"), ); } else if (choice.kind === "name") container.replaceChildren( ...(choice.mail ? mailField((message) => runtime.choose(message)) : nameField(choice.current, choice.max, (name) => runtime.choose(name), )), ); else if (choice.kind === "menu") { // A vertical menu such as the elevator's floors; B cancels. const list = document.createElement("div"); list.className = "dialogue-menu"; list.setAttribute("role", "listbox"); list.replaceChildren( ...choice.items.map((item, index) => { const option = button(item, choice.selected === index, () => runtime.choose(index), ); const detail = choice.details?.[index]; if (detail) { const small = document.createElement("small"); small.textContent = detail; option.append(small); } option.setAttribute("role", "option"); option.setAttribute("aria-label", detail ? `${item} ${detail}` : item); option.setAttribute("aria-selected", String(choice.selected === index)); return option; }), ); // As in the original there is no CANCEL entry: B leaves the menu. container.replaceChildren(...balanceBox(choice.balance), list); // On a small screen the box scrolls; keep the highlighted entry in view. list .querySelector('[aria-selected="true"]') ?.scrollIntoView({ block: "nearest" }); } } /** The money or coin box a script placed beside a choice. */ function balanceBox(balance: string | undefined): HTMLElement[] { if (!balance) return []; const box = document.createElement("output"); box.className = "choice-balance"; box.textContent = balance; return [box]; } /** The naming screen as a text field and OK: an empty name keeps the * current one. */ function nameField( current: string, max: number, answer: (name: string) => void, ): HTMLElement[] { const input = document.createElement("input"); input.maxLength = max; input.placeholder = current; input.setAttribute("aria-label", `Name (empty keeps ${current})`); input.autocomplete = "off"; input.spellcheck = false; const submit = () => { const name = input.value.trim(); if (name && !NICKNAME_PATTERN.test(name)) { input.setCustomValidity( "Use letters, digits, spaces and simple punctuation.", ); input.reportValidity(); return; } answer(name); canvas.focus({ preventScroll: true }); }; input.addEventListener("input", () => input.setCustomValidity("")); input.addEventListener("keydown", (event) => { if (event.key === "Enter") submit(); }); const ok = document.createElement("button"); ok.type = "button"; ok.textContent = "OK"; ok.addEventListener("click", () => { if (session.state === "playing") submit(); }); queueMicrotask(() => input.focus({ preventScroll: true })); return [input, ok]; } /** _ComposeMailMessage's entry as two lines of 16 characters and OK. A * second line starts once the first is full, so the first is padded. */ function mailField(answer: (message: string) => void): HTMLElement[] { const lines = [1, 2].map((line) => { const input = document.createElement("input"); input.maxLength = MAIL_LINE_LENGTH; input.setAttribute("aria-label", `MAIL line ${line}`); input.autocomplete = "off"; input.spellcheck = false; input.addEventListener("input", () => input.setCustomValidity("")); return input; }); const submit = () => { const message = mailMessage(lines[0].value, lines[1].value.trimEnd()); if (!validMailMessage(message)) { lines[0].setCustomValidity( "Use letters, digits, spaces and the MAIL's punctuation.", ); lines[0].reportValidity(); return; } answer(message); canvas.focus({ preventScroll: true }); }; lines[0].addEventListener("keydown", (event) => { if (event.key === "Enter") lines[1].focus({ preventScroll: true }); }); lines[1].addEventListener("keydown", (event) => { if (event.key === "Enter") submit(); }); const ok = document.createElement("button"); ok.type = "button"; ok.textContent = "OK"; ok.addEventListener("click", () => { if (session.state === "playing") submit(); }); queueMicrotask(() => lines[0].focus({ preventScroll: true })); const box = document.createElement("div"); box.className = "mail-lines"; box.append(...lines); return [box, ok]; } /** ReadAnyMail: cartridge stationery plus accessible message text. */ function showMail(world: GameWorld) { const mail = world.mailView; get("mail-view").hidden = !mail; if (!mail) return; get("mail-type").textContent = world.assets.pokemon.itemName(mail.type); get("mail-message").textContent = mail.message; get("mail-author").textContent = mail.author; const choice = world.dialogue?.choice; if (choice?.kind === "screen") drawTileScreen(get("mail-canvas"), choice.screen, 0); } type MartChoice = Extract< Choice, { kind: "martmenu" | "martlist" | "martquantity" | "pack" } >; function martChoice(choice: Choice): MartChoice | undefined { return choice.kind === "martmenu" || choice.kind === "martlist" || choice.kind === "martquantity" || choice.kind === "pack" ? choice : undefined; } let martMenuKey = ""; /** The mart's BUY/SELL/QUIT box, item list or quantity stepper, plus a * money display. Follows the battle menu's button-grid style. */ function showMart(world: GameWorld, mart: MartChoice | undefined) { get("mart").hidden = !mart; if (!mart) { martMenuKey = ""; return; } // The PC's item storage and the pack reuse these lists without prices. const shop = mart.kind === "martmenu" || (mart.kind !== "pack" && (mart.mode === "buy" || mart.mode === "sell")); get("mart").setAttribute( "aria-label", shop ? "Poké Mart" : mart.kind === "pack" ? "Pack" : mart.kind === "martquantity" && mart.mode === "quantity" ? "How many" : "Item storage", ); get("mart-money").hidden = !shop; get("mart-money").textContent = `MONEY ¥${world.liveState.money}`; get("mart-prompt").textContent = mart.kind === "martlist" ? "" : mart.kind === "pack" ? (mart.prompt ?? "").replace(/\n/g, " ") : mart.prompt; const key = JSON.stringify(mart); if (key === martMenuKey) return; martMenuKey = key; const menu = get("mart-menu"), tabs = get("mart-tabs"); tabs.hidden = mart.kind !== "pack"; const button = ( label: string, action: () => void, options: { detail?: string; selected?: boolean; wide?: boolean } = {}, ) => { const element = document.createElement("button"); element.type = "button"; element.textContent = label; if (options.detail) { const small = document.createElement("small"); small.textContent = options.detail; element.append(small); } if (options.selected) element.classList.add("battle-selected"); if (options.wide) element.classList.add("wide"); element.addEventListener("click", () => { if (session.state !== "playing") return; action(); canvas.focus({ preventScroll: true }); }); return element; }; if (mart.kind === "martmenu") { menu.replaceChildren( ...(["BUY", "SELL", "QUIT"] as const).map((label, index) => button(label, () => runtime.choose(index), { selected: mart.selected === index, }), ), ); return; } if (mart.kind === "pack") { // Pocket tabs (left and right on the keyboard), then the pocket's items. // While an item moves with Select, the pocket stays and the moving // entry is marked; choosing another entry places it there. const switching = mart.switching; const pocketTabs = mart.pockets.map((pocket, index) => { const tab = button(pocket.name, () => { const world = runtime.world; const choice = world?.dialogue?.choice; if (choice?.kind !== "pack" || !world) return; choice.pocket = index; choice.selected = 0; showWorld(world); }); tab.setAttribute("aria-pressed", String(mart.pocket === index)); tab.disabled = switching !== undefined && mart.pocket !== index; return tab; }); const entries = mart.pockets[mart.pocket].entries; const buttons = entries.map((entry, index) => { const entryButton = button( switching === index ? `▷${entry.name}` : entry.name, () => runtime.choosePackEntry(mart.pocket, index), { detail: entry.owned === undefined ? undefined : `×${entry.owned}`, selected: mart.selected === index, }, ); if (switching === index) entryButton.setAttribute("aria-current", "true"); return entryButton; }); if (!entries.length) { const empty = button("Empty", () => {}, { wide: true }); empty.disabled = true; buttons.push(empty); } buttons.push(button("BACK", () => runtime.choose(-1), { wide: true })); tabs.replaceChildren(...pocketTabs); menu.replaceChildren(...buttons); return; } if (mart.kind === "martlist") { const buttons = mart.entries.map((entry, index) => button(entry.name, () => runtime.choose(index), { detail: mart.mode === "sell" ? `have ${entry.owned}` : mart.mode === "buy" ? `¥${entry.price}` : `×${entry.owned}`, selected: mart.selected === index, }), ); buttons.push(button("BACK", () => runtime.choose(-1), { wide: true })); menu.replaceChildren(...buttons); return; } // martquantity: a 1-99 stepper (±1 Up/Down, ±10 Left/Right, matching // BuySellToss_InterpretJoypad), then BUY/SELL and CANCEL. const total = mart.mode === "sell" ? Math.floor((mart.price * mart.selected) / 2) : mart.price * mart.selected; const stepper = document.createElement("div"); stepper.className = "mart-quantity"; const step = (label: string, aria: string, direction: Direction) => { const element = document.createElement("button"); element.type = "button"; element.textContent = label; element.setAttribute("aria-label", aria); element.addEventListener("click", () => { if (session.state !== "playing") return; runtime.world?.moveCursor(direction); if (runtime.world) showWorld(runtime.world); canvas.focus({ preventScroll: true }); }); return element; }; const output = document.createElement("output"); output.textContent = shop ? `${mart.name} ×${mart.selected} ¥${total}` : `${mart.name} ×${mart.selected}`; stepper.append( step("◀◀", "Minus 10", "left"), step("▼", "Minus 1", "down"), output, step("▲", "Plus 1", "up"), step("▶▶", "Plus 10", "right"), ); menu.replaceChildren( stepper, button( mart.mode === "quantity" ? "OK" : mart.mode.toUpperCase(), () => runtime.choose(mart.selected), { wide: true }, ), button("CANCEL", () => runtime.choose(-1), { wide: true }), ); } const STATUS_NAMES: [number, string][] = [ [0x08, "PSN"], [0x10, "BRN"], [0x20, "FRZ"], [0x40, "PAR"], ]; function statusName(status: number) { if (status & 7) return "SLP"; return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? ""; } function drawPic( target: HTMLCanvasElement, tiles: Uint8Array, size: number, palette: Palette, flip = false, ) { const context = target.getContext("2d")!; const pixels = size * 8; const image = context.createImageData(pixels, pixels); for (let tile = 0; tile < size * size; tile++) drawTile( image.data, pixels, pixels, tiles, tile, Math.floor(tile / size) * 8, (tile % size) * 8, palette, { flipX: flip }, ); context.putImageData(image, 0, 0); } const battlePics = { foe: "", player: "" }; let battleMenuKey = ""; let showMoves = false; /** BattlePack, opened on the last pocket used (wLastPocket). */ let showPack = false; let packPocket: "item" | "ball" = "item"; /** The party slot whose BattleMonMenu (SWITCH/STATS/CANCEL) is open. */ let partyChoice: number | undefined; /** The stats screen and its page (pink, green, blue): opened from * BattleMonMenu for a party slot, or by a script (Bill's PC) without one. */ let statsView: { slot?: number; page: number } | undefined; let battleCursor = 0; let battleSelectionKey = ""; /** Start opens the start menu and Select uses the registered item, while * the player stands still (StartMenuScript and SelectMenuScript). */ function handleFieldInput(button: Button): boolean { const world = runtime.world; if (!world || world.battle || world.busy) return false; if (button === "start") runtime.openStartMenu(); else if (button === "select") runtime.useSelect(); else return false; return true; } /** Battle prompts answered with YES/NO. */ const yesNoPrompt = (prompt?: { kind: string }) => prompt?.kind === "yesno" || prompt?.kind === "contestswitch"; function battleButtons() { return [ ...get("battle-menu").querySelectorAll( "button:not(:disabled)", ), ]; } /** The enabled button next to `from` in the arrow's direction as drawn, so * the cursor always follows the layout: a column moves with up and down, a * row with left and right, and a grid with all four. */ function adjacentButton( buttons: HTMLButtonElement[], from: number, arrow: "up" | "down" | "left" | "right", ): number | undefined { const origin = buttons[from]?.getBoundingClientRect(); if (!origin) return undefined; const vertical = arrow === "up" || arrow === "down"; let best: number | undefined; let bestScore = Infinity; buttons.forEach((candidate, index) => { if (index === from) return; const box = candidate.getBoundingClientRect(); const gap = arrow === "up" ? origin.top - box.bottom : arrow === "down" ? box.top - origin.bottom : arrow === "left" ? origin.left - box.right : box.left - origin.right; if (gap < -1) return; const offset = vertical ? Math.abs(box.left + box.width / 2 - (origin.left + origin.width / 2)) : Math.abs(box.top + box.height / 2 - (origin.top + origin.height / 2)); const score = Math.max(gap, 0) + offset * 2; if (score < bestScore) { bestScore = score; best = index; } }); return best; } function highlightBattleChoice() { battleButtons().forEach((button, index) => { button.classList.toggle("battle-selected", index === battleCursor); }); } /** Keyboard and on-screen D-pad share the same menu buttons as pointer input. */ function handleBattleInput(button: Button): boolean { if (handleFieldInput(button)) return true; const world = runtime.world; if (statsView && world) { // StatsScreen_JoypadAction: left and right turn pages, B leaves. // BattleMenuPKMN_ReturnFromStats reopens the party menu itself. if (button === "b" && statsView.slot === undefined) world.advanceDialogue(); else if (button === "b") statsView = partyChoice = undefined; else if (button === "left" || button === "up") statsView.page = (statsView.page + 2) % 3; else if (button === "right" || button === "down" || button === "a") statsView.page = (statsView.page + 1) % 3; showWorld(world); return true; } if (!world?.battle) return false; if (button === "b" && (showMoves || showPack)) { showMoves = showPack = false; return true; } if (button === "b" && partyChoice !== undefined) { partyChoice = undefined; showWorld(world); return true; } const buttons = battleButtons(); if (!buttons.length) return false; if (button === "a") { buttons[battleCursor]?.click(); return true; } if ( button === "up" || button === "down" || button === "left" || button === "right" ) { battleCursor = adjacentButton(buttons, battleCursor, button) ?? battleCursor; if (yesNoPrompt(world.battle.prompt)) world.battle.selected = battleCursor === 0 ? 1 : 0; highlightBattleChoice(); return true; } return false; } /** The battle screen: pictures, HUDs, messages and the current choice. */ function showBattle(world: GameWorld) { const view = world.battle; const screen = get("battle"); screen.hidden = !view; if (!view) { battlePics.foe = battlePics.player = battleMenuKey = ""; showMoves = showPack = false; battleCursor = 0; battleSelectionKey = ""; return; } const data = world.assets.pokemon; const { player, enemy } = view.engine; const foe = enemy.mon, own = player.mon; if (foe) { const key = `${foe.species}:${foe.dvs}`; if (battlePics.foe !== key) { battlePics.foe = key; drawPic( get("battle-foe-pic"), data.frontPic(foe.species, PokemonData.unownLetter(foe.dvs)), 7, data.palette(foe.species, PokemonData.shiny(foe.dvs)), ); get("battle-foe-pic").setAttribute("aria-label", foe.nickname); } get("battle-foe-name").textContent = foe.nickname; get("battle-foe-level").textContent = `:L${foe.level}`; get("battle-foe-status").textContent = statusName(foe.status); setHp(get("battle-foe-hp"), foe.hp, foe.maxHp); } get("battle-player").hidden = !own; if (own) { const key = `${own.species}:${own.dvs}`; if (battlePics.player !== key) { battlePics.player = key; drawPic( get("battle-player-pic"), data.backPic(own.species, PokemonData.unownLetter(own.dvs)), 6, data.palette(own.species, PokemonData.shiny(own.dvs)), ); get("battle-player-pic").setAttribute("aria-label", own.nickname); } get("battle-player-name").textContent = own.nickname; get("battle-player-level").textContent = `:L${own.level}`; get("battle-player-status").textContent = statusName(own.status); get("battle-player-hp-text").textContent = `${own.hp}/ ${own.maxHp}`; setHp(get("battle-player-hp"), own.hp, own.maxHp); } const prompt = view.prompt; get("battle-text").textContent = prompt?.kind === "name" ? prompt.title : prompt?.kind === "party" || prompt?.kind === "move" || prompt?.kind === "contestswitch" ? prompt.text : (view.message?.text ?? ""); if (prompt?.kind !== "action") showMoves = showPack = false; if (prompt?.kind !== "party") { partyChoice = undefined; if (statsView?.slot !== undefined) statsView = undefined; } const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}:${partyChoice}`; if (selectionKey !== battleSelectionKey) { battleCursor = 0; battleSelectionKey = selectionKey; } if (yesNoPrompt(prompt)) battleCursor = view.selected === 1 ? 0 : 1; const bag = world.liveState.bag; // The Pokémon out has its live battle HP; the others their party values. const party = world.liveState.party.map((mon, index) => index === view.engine.curBattleMon && own ? { ...mon, hp: own.hp, status: own.status } : mon, ); const key = JSON.stringify([ prompt, view.selected, showMoves, showPack, partyChoice, packPocket, own?.pp, bag.items, bag.balls, prompt?.kind === "party" && party.map(({ hp, status, level }) => [hp, status, level]), view.message?.prompt, ]); if (key === battleMenuKey) return; battleMenuKey = key; const menu = get("battle-menu"); const button = ( label: string, action: () => void, options: { detail?: string; disabled?: string; wide?: boolean; pressed?: boolean; } = {}, ) => { const element = document.createElement("button"); element.type = "button"; element.textContent = label; if (options.detail) { const small = document.createElement("small"); small.textContent = options.detail; element.append(small); } if (options.disabled) { element.disabled = true; element.title = options.disabled; } if (options.wide) element.classList.add("wide"); if (options.pressed !== undefined) element.setAttribute("aria-pressed", String(options.pressed)); element.addEventListener("click", () => { if (session.state !== "playing") return; action(); canvas.focus({ preventScroll: true }); }); return element; }; const buttons: HTMLButtonElement[] = []; if (prompt?.kind === "action" && own) { if (showMoves) { own.moves.forEach((move, index) => { if (!move) return; buttons.push( button( data.moveName(move), () => { showMoves = false; runtime.battleAction({ action: "fight", move: index }); }, { detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` }, ), ); }); buttons.push( button( "BACK", () => { showMoves = false; showWorld(world); }, { wide: true }, ), ); } else if (showPack) { for (const pocket of ["item", "ball"] as const) buttons.push( button( data.pocketName(pocket), () => { packPocket = pocket; showWorld(world); }, { pressed: packPocket === pocket }, ), ); const slots = packPocket === "item" ? bag.items : bag.balls; slots.forEach(({ item, quantity }, slot) => buttons.push( button( data.itemName(item), () => { showPack = false; runtime.battleAction({ action: "item", item, slot }); }, { detail: `×${quantity}` }, ), ), ); if (!slots.length) buttons.push( button("Empty", () => {}, { disabled: `The ${data.pocketName(packPocket)} is empty.`, wide: true, }), ); buttons.push( button( "BACK", () => { showPack = false; showWorld(world); }, { wide: true }, ), ); } else { buttons.push( button("FIGHT", () => { if (view.engine.fightKeepsMove) return runtime.battleAction({ action: "fight", move: 0 }); showMoves = true; showWorld(world); }), button("PKMN", () => runtime.battleAction({ action: "pkmn" })), // ContestBattleMenu: PARKBALL× with two digits throws a Park Ball. prompt.parkBalls !== undefined ? button( `PARKBALL×${String(prompt.parkBalls).padStart(2, "0")}`, () => runtime.battleAction({ action: "item", item: C.PARK_BALL, slot: -1, }), ) : button("PACK", () => { // BattleMenu_Pack.ItemsCantBeUsed: the engine says why. if (prompt.noItems) return runtime.battleAction({ action: "item", item: 0, slot: -1 }); showPack = true; showWorld(world); }), button("RUN", () => runtime.battleAction({ action: "run" })), ); } } else if (prompt?.kind === "contestswitch") { // DisplayCaughtContestMonStats: STOCK and THIS boxes, then Yes/No. for (const [label, mon] of [ ["STOCK", prompt.stock], ["THIS", prompt.caught], ] as const) buttons.push( button(`${label} ${mon.name} :L${mon.level}`, () => {}, { detail: `HEALTH ${mon.maxHp}`, disabled: " ", wide: true, }), ); buttons.push( button("YES", () => runtime.battleChoose(1), { pressed: view.selected === 1, }), button("NO", () => runtime.battleChoose(0), { pressed: view.selected === 0, }), ); } else if (prompt?.kind === "yesno") { buttons.push( button("YES", () => runtime.battleChoose(1), { pressed: view.selected === 1, }), button("NO", () => runtime.battleChoose(0), { pressed: view.selected === 0, }), ); } else if (prompt?.kind === "party" && partyChoice !== undefined) { // BattleMonMenu. STATS opens the stats screen, which is not ported. const slot = partyChoice; buttons.push( button("SWITCH", () => { partyChoice = undefined; runtime.battleChoose(slot); }), button("STATS", () => { statsView = { slot, page: 0 }; showWorld(world); }), button("CANCEL", () => { partyChoice = undefined; showWorld(world); }), ); } else if (prompt?.kind === "party") { party.forEach((mon, slot) => buttons.push( button( mon.nickname, () => { if (!prompt.menu) return runtime.battleChoose(slot); partyChoice = slot; showWorld(world); }, { // An Egg's row has no level or HP. detail: mon.egg ? "" : `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${ mon.status ? ` ${statusName(mon.status)}` : "" }`, wide: true, }, ), ), ); if (prompt.cancel) buttons.push( button("CANCEL", () => runtime.battleChoose(-1), { wide: true }), ); } else if (prompt?.kind === "move") { prompt.moves.forEach((move, index) => { if (!move) return; buttons.push( button(data.moveName(move), () => runtime.battleChoose(index), { detail: `PP ${prompt.pp[index] & 63}`, }), ); }); buttons.push( button("CANCEL", () => runtime.battleChoose(-1), { wide: true }), ); } else if (prompt?.kind === "forget") { prompt.moves.forEach((move, index) => buttons.push( button(data.moveName(move), () => runtime.battleChoose(index)), ), ); buttons.push( button("CANCEL", () => runtime.battleChoose(4), { wide: true }), ); } else if (prompt?.kind === "name") { menu.replaceChildren( ...nameField(prompt.current, prompt.max, (name) => runtime.battleName(name), ), ); return; } else if (prompt?.kind === "stats") { prompt.labels.forEach((label, index) => buttons.push( button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " }), ), ); buttons.push( button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }), ); } else if (view.message?.prompt) { buttons.push( button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }), ); } // FIGHT/PKMN/PACK/RUN is the original's 2x2 grid; every other choice is a // vertical list, moved with Up and Down as it is drawn. const grid = (prompt?.kind === "action" && !showMoves && !showPack) || prompt?.kind === "stats"; menu.dataset.layout = grid ? "grid" : "list"; menu.dataset.kind = prompt?.kind ?? ""; menu.replaceChildren(...buttons); battleCursor = Math.min( battleCursor, Math.max(0, battleButtons().length - 1), ); highlightBattleChoice(); } let pokegearKey = ""; const POKEGEAR_CARDS: Record = { clock: "CLOCK", map: "MAP", phone: "PHONE", radio: "RADIO", }; /** The Pokégear's cards (engine/pokegear/pokegear.asm): the card icons, * then the clock, the Town Map with its cursor's landmark, the phone list * or the radio. Its textbox is the native dialogue. */ function showPokegear(world: GameWorld) { const view = world.pokegear; get("pokegear").hidden = !view; if (!view) { pokegearKey = ""; return; } const key = JSON.stringify(view); if (key === pokegearKey) return; pokegearKey = key; const element = ( tag: K, text = "", className = "", ) => { const node = document.createElement(tag); node.textContent = text; if (className) node.className = className; return node; }; get("pokegear-cards").replaceChildren( ...view.cards.map((card) => { const icon = element("span", POKEGEAR_CARDS[card]); icon.setAttribute("aria-current", String(card === view.card)); return icon; }), ); const body = get("pokegear-card"); if (view.clock) { const clock = element("div", "", "pokegear-clock"); clock.append(element("span", view.clock.day), element("strong", view.clock.time)); body.replaceChildren(clock); } else if (view.map) { const map = element("div", "", "pokegear-map"), canvas = element("canvas"); canvas.setAttribute("role", "img"); drawTownMap(canvas, world, view.map); map.append(element("p", `▶ ${view.map.name}`), canvas); body.replaceChildren(map); } else if (view.phone) { const phone = view.phone, card = element("div", "", "pokegear-phone"), list = element("ol"); list.setAttribute("aria-label", "Phone numbers"); list.replaceChildren( ...phone.rows.map((row, index) => { const item = element("li", row); item.dataset.mark = phone.chosen === index ? "▷" : phone.chosen === undefined && phone.cursor === index ? "▶" : " "; if (item.dataset.mark !== " ") item.setAttribute("aria-current", "true"); return item; }), ); card.append( element("span", phone.signal ? "Signal ▮▮▮" : "Out of service", "pokegear-signal"), list, ); if (phone.submenu) { const submenu = element("ol", "", "pokegear-submenu"); submenu.replaceChildren( ...phone.submenu.items.map((item, index) => { const entry = element("li", item); entry.dataset.mark = index === phone.submenu!.selected ? "▶" : " "; return entry; }), ); card.append(submenu); } body.replaceChildren(card); } else if (view.radio) { // The tuning dial's frequency, the station's name and its text box. const radio = view.radio, card = element("div", "", "pokegear-radio"), dial = element("p", `FM ${radio.frequency}`, "pokegear-frequency"), box = element("div", "", "pokegear-radio-text"); box.setAttribute("aria-live", "polite"); box.append(element("span", radio.lines[0]), element("span", radio.lines[1])); card.append(dial, element("strong", radio.name), box); body.replaceChildren(card); } 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, firstUnownSeen: state.firstUnownSeen, }; const key = JSON.stringify([ Boolean(world), progress && [progress.owned, [...progress.seen], [...progress.caught], progress.firstUnownSeen], 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 atlasKey = ""; let atlasRegion: AtlasRegion | undefined; /** The Map tab's region atlas, from the loaded game's live state. */ function showAtlas() { if (tabs.active() !== "map") return; const world = ready() ? runtime.world : undefined; const panel = get("atlas"); panel.hidden = !world; if (!world) { atlasKey = ""; return; } const view = world.atlas(atlasRegion); const key = JSON.stringify([view, world.avatar]); if (key === atlasKey) return; atlasKey = key; const names = { johto: "Johto", kanto: "Kanto" } as const; get("atlas-title").textContent = names[view.region]; get("atlas-regions").replaceChildren( ...(view.regions.length > 1 ? view.regions : []).map((region) => { const button = document.createElement("button"); button.type = "button"; button.textContent = names[region]; button.setAttribute("aria-pressed", String(region === view.region)); button.addEventListener("click", () => { atlasRegion = region; showAtlas(); }); return button; }), ); const canvas = get("atlas-map"); drawAtlas(canvas, world, view); const here = view.landmarks.find((landmark) => landmark.landmark === view.player); canvas.setAttribute("aria-label", `${names[view.region]} map${here ? `, you are in ${here.name}` : ""}`); get("atlas-here").textContent = here ? `You are in ${here.name}.` : `You are outside ${names[view.region]}.`; const items = (list: string[], empty: string) => (list.length ? list : [empty]).map((text) => { const item = document.createElement("li"); item.textContent = text; return item; }); get("atlas-fly").replaceChildren( ...items( view.landmarks.filter((landmark) => landmark.flypoint?.visited).map((landmark) => landmark.name), "None yet", ), ); get("atlas-open").replaceChildren( ...items( view.landmarks.filter((landmark) => landmark.open).map((landmark) => landmark.name), "None", ), ); } let optionsKey = ""; /** _Option's screen: each option with its setting, and the ▶ cursor. */ function showOptions(world: GameWorld) { const choice = world.dialogue?.choice; const view = choice?.kind === "options" ? choice.view : undefined; get("options").hidden = !view; if (!view) { optionsKey = ""; return; } const key = JSON.stringify(view); if (key === optionsKey) return; optionsKey = key; get("options-list").replaceChildren( ...view.rows.map((row, index) => { const item = document.createElement("li"), label = document.createElement("span"), value = document.createElement("strong"); label.textContent = row.label; value.textContent = row.value; item.append(label, value); item.dataset.mark = index === view.cursor ? "▶" : " "; if (index === view.cursor) item.setAttribute("aria-current", "true"); return item; }), ); } let tileScreenKey = ""; /** A minigame or special's tile screen; blinking sprites follow the * world's frames. */ function showTileScreen(world: GameWorld) { const choice = world.dialogue?.choice; const screen = choice?.kind === "screen" && !world.mailView ? choice.screen : undefined; get("tile-screen").hidden = !screen; get("tile-screen").classList.toggle("overlay", Boolean(screen?.overlay)); if (!screen) { tileScreenKey = ""; return; } const blink = screen.sprites.some((sprite) => sprite.blink); const frame = world.ticks * 2; const key = JSON.stringify([ screen.tileset, screen.map, screen.attrs, screen.sprites, blink && frame & 0x10, ]); if (key === tileScreenKey) return; tileScreenKey = key; drawTileScreen(get("tile-screen-canvas"), screen, frame); } let flyMapKey = ""; /** _FlyMap: TownMapBubble's "Where?" and flypoint name over the Town Map, * with the Fly user's icon on the flypoint. */ function showFlyMap(world: GameWorld) { const choice = world.dialogue?.choice; const view = choice?.kind === "flymap" ? choice.view : undefined; get("fly-map").hidden = !view; if (!view) { flyMapKey = ""; return; } const key = JSON.stringify(view); if (key === flyMapKey) return; flyMapKey = key; const where = document.createElement("span"), arrows = document.createElement("span"); where.textContent = `${view.where}\n${view.name}`; arrows.textContent = "▲▼"; arrows.setAttribute("aria-hidden", "true"); get("fly-map-bubble").replaceChildren(where, arrows); drawTownMap(get("fly-map-canvas"), world, view); } 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 = ( 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("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) { const panel = get("stats-screen"); const view = world.battle; // A script's stats screen opens on the first page and closes with it. if (world.statsScreen && !statsView) statsView = { page: 0 }; if (!world.statsScreen && statsView?.slot === undefined) statsView = undefined; const slot = statsView?.slot; const own = view?.engine.player.mon; const stored = slot === undefined ? world.statsScreen : world.liveState.party[slot]; panel.hidden = !statsView || !stored; if (!statsView || !stored) return; const data = world.assets.pokemon; const mon = view && slot === view.engine.curBattleMon && own ? { ...stored, hp: own.hp, status: own.status } : stored; const shiny = PokemonData.shiny(mon.dvs); const text = (label: Parameters[0]) => data.statsString(label); if (mon.egg) { // EggStatsScreen: the Egg's picture, "EGG", unknown ID and OT, and a // hint picked by the egg cycles left. drawPic(get("stats-pic"), data.eggPic(), 7, data.eggPalette()); get("stats-pic").setAttribute("aria-label", text("EggString")); const strong = document.createElement("strong"); strong.textContent = text("EggString"); const unknown = text("FiveQMarkString"); const row = (...parts: (string | Node)[]) => { const p = document.createElement("p"); p.append(...parts); return p; }; get("stats-header").replaceChildren( row(strong), row(`${text("IDNoString")}${unknown}`), row(`${text("OTString")}${unknown}`), ); const cycles = mon.happiness; get("stats-page").replaceChildren( row( text( cycles < 6 ? "EggSoonString" : cycles < 0xb ? "EggCloseString" : cycles < 0x29 ? "EggMoreTimeString" : "EggALotMoreTimeString", ).replace(/\n/g, " "), ), ); panel.dataset.page = "egg"; return; } drawPic( get("stats-pic"), data.frontPic(mon.species, PokemonData.unownLetter(mon.dvs)), 7, data.palette(mon.species, shiny), ); get("stats-pic").setAttribute("aria-label", data.speciesName(mon.species)); const gender = data.gender(mon.species, mon.dvs); const line = (...parts: (string | Node)[]) => { const p = document.createElement("p"); p.append(...parts); return p; }; const name = document.createElement("strong"); name.textContent = `${mon.nickname}${gender === "male" ? "♂" : gender === "female" ? "♀" : ""}`; get("stats-header").replaceChildren( line( `№.${String(mon.species).padStart(3, "0")} :L${mon.level}${shiny ? " ★" : ""}`, ), line(name), line(`/${data.speciesName(mon.species)}`), ); const list = (rows: [string, string][], wide = false) => { const dl = document.createElement("dl"); if (wide) dl.classList.add("wide"); for (const [term, value] of rows) { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = term; dd.textContent = value; dl.append(dt, dd); } return dl; }; const growth = data.baseData(mon.species).growthRate; const page: Node[] = []; if (statsView.page === 0) { const [statusLabel, typeLabel] = text("LoadPinkPage.Status_Type").split( "\n", ); const types = [...new Set(data.baseData(mon.species).types)].map((type) => data.typeName(type), ); const next = Math.min(mon.level + 1, 100); page.push( list([ ["HP", `${mon.hp}/ ${mon.stats[0]}`], [ statusLabel, mon.pokerus & 0x0f ? text("LoadPinkPage.PkrsStr") : !mon.hp ? "FNT" : statusName(mon.status) || text("LoadPinkPage.OK_str").trim(), ], [typeLabel, types.join(" / ")], ]), list([ [text("LoadPinkPage.ExpPointStr"), String(mon.exp)], [ text("LoadPinkPage.LevelUpStr"), String( mon.level >= 100 ? 0 : data.expAtLevel(growth, next) - mon.exp, ), ], [text("LoadPinkPage.ToStr"), `:L${next}`], ]), ); } else if (statsView.page === 1) { page.push( list( [ [ text("LoadGreenPage.Item"), mon.item ? data.itemName(mon.item) : text("LoadGreenPage.ThreeDashes"), ], ...mon.moves.flatMap((move, index): [string, string][] => move ? [ [ index ? "" : text("LoadGreenPage.Move"), `${data.moveName(move)} PP ${mon.pp[index] & 63}/${maxPP(data, move, mon.pp[index] >> 6) & 63}`, ], ] : [], ), ], true, ), ); } else { // The OT's gender comes from the caught data unless it is unset. const caught = mon.caughtData & 0xff; const otGender = !caught || caught === 0x7f ? "" : caught & 0x80 ? "♀" : "♂"; const labels = data.statNames(); page.push( list([ [text("IDNoString"), String(mon.otId).padStart(5, "0")], [text("OTString"), `${mon.ot}${otGender}`], ]), list( labels.map((label, index): [string, string] => [ label, String(mon.stats[[1, 2, 4, 5, 3][index]]), ]), ), ); } get("stats-page").replaceChildren(...page); panel.dataset.page = String(statsView.page); get("stats-page-number").textContent = `${statsView.page + 1} / 3`; } function setHp(bar: HTMLElement, hp: number, max: number) { const fraction = max ? hp / max : 0; bar.style.width = `${Math.round(fraction * 100)}%`; bar.classList.toggle("low", fraction <= 0.5 && fraction > 0.2); bar.classList.toggle("critical", fraction <= 0.2); } let shownPicture = ""; /** Draw a pokepic, or the introduction's trainer or player picture, from * the ROM's picture tiles and palette. */ function showPicture(world: GameWorld) { const box = get("pokepic"); const portrait = world.portrait; box.hidden = world.picture === undefined && !portrait; const key = JSON.stringify([world.picture, portrait]); if (key === shownPicture) return; shownPicture = key; if (box.hidden) return; const data = world.assets.pokemon; const picture = get("pokepic-canvas"); const context = picture.getContext("2d")!; const image = context.createImageData(56, 56); let tiles: Uint8Array, palette: Palette, label: string; if (world.picture !== undefined) { tiles = data.frontPic(world.picture); palette = data.palette(world.picture); label = data.speciesName(world.picture); } else if (portrait && "player" in portrait) { // DrawIntroPlayerPic: CHRIS's palette, or KRIS's (class 1). tiles = data.playerPic(portrait.player); palette = data.trainerPalette(portrait.player ? 1 : 0); label = portrait.player ? "Kris" : "Chris"; } else { const trainer = (portrait as { trainer: number }).trainer; tiles = data.trainerPic(trainer); palette = data.trainerPalette(trainer); label = "Professor Oak"; } // 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); picture.setAttribute("aria-label", label); } const runtime = new NativeRuntime( showWorld, (error) => { session.state = "error"; controls.release(); showError(error); update(); }, handleBattleInput, ); const session = new SlotSession(runtime, new SlotRepository("crystal-native-port")); const input = new InputController((button, pressed) => runtime.setButton(button, pressed), ); let preferences = loadPreferences(); let keys = keyMap(preferences); applyTextScale(preferences.textScale); runtime.setVolume(preferences.volume); const controls = bindControls( root, input, () => session.state === "playing" && !busy && tabs.active() === "game", () => {}, () => keys, ); const ready = () => ["paused", "playing"].includes(session.state); const tabs = bindTabs( root, () => { pause(); controls.release(); update(); }, (id) => { if (id === "pokedex") showPokedexTab(); if (id === "map") showAtlas(); if (id === "saves") { showAppSettings(); void showSlots(); } }, ); function showError(error: unknown) { status.textContent = error instanceof Error ? error.message : "Something went wrong. Please try again."; status.classList.add("error"); } function message(text: string) { status.textContent = text; status.classList.remove("error"); } function update() { const playing = session.state === "playing"; root.classList.toggle("has-game", ready()); for (const id of ["play", "save", "new-game", "backup", "import-save", "screen-resume"]) { get(id).disabled = busy || !ready(); } get("slot-list") .querySelectorAll("button") .forEach((button) => { button.disabled = busy || !ready() || button.dataset.locked === "true"; }); root .querySelectorAll("[data-game-button]") .forEach((button) => { button.disabled = !playing || busy; }); get("play").textContent = playing ? "Pause" : "Play"; get("power-label").textContent = playing ? "PLAYING" : ready() ? "PAUSED" : "STANDBY"; get("power-light").classList.toggle("on", playing); get("screen-empty").hidden = ready(); get("minimap-position").hidden = !ready(); get("world-map").hidden = !ready(); get("map-empty").hidden = ready(); get("pause-overlay").hidden = session.state !== "paused"; get("rom-file").disabled = busy; get("rom-label").classList.toggle("disabled", busy); get("dialogue-next").disabled = busy || session.state !== "playing"; showPokedexTab(); showAtlas(); } async function save() { if (!ready()) return; // Capture again after an earlier write, so pausing never drops the last frame. if (saving) { await saving.catch(() => {}); return save(); } saving = session .save() .then((time) => { saveStatus.textContent = `Session saved on this device · ${new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; saveStatus.classList.remove("error"); if (tabs.active() === "saves") void showSlots(); }) .catch((error) => { saveStatus.textContent = quotaExceeded(error) ? "This device is out of space for saves. Export a backup, then free some storage." : "Session could not be saved. Export a backup to keep your progress."; saveStatus.classList.add("error"); throw error; }) .finally(() => { saving = undefined; }); return saving; } function pause() { if (session.state !== "playing") return; session.pause(); controls.release(); update(); void save().catch(showError); } function play() { if (busy) return; tabs.select("game"); session.play(); canvas.focus({ preventScroll: true }); message( `Press ${buttonKeys(preferences, "a")} or A to talk and read. Up and Down change choices.`, ); update(); } get("rom-file").addEventListener("change", async (event) => { const field = event.target as HTMLInputElement; const file = field.files?.[0]; field.value = ""; if (!file || busy) return; busy = true; controls.release(); update(); message("Checking your cartridge locally…"); try { const rom = await validateRom(file); if (!lockRelease) lockRelease = await acquireSessionLock("crystal-native-session"); if (ready()) { session.pause(); controls.release(); await saving; await save(); } const { restored, recovered, skipped } = await session.load(rom, canvas); tabs.select("game"); message( [ restored ? "Your saved session is ready. Press Play to continue." : "Cartridge verified. Press Play to explore your room and New Bark Town.", recovered && `The last write of “${session.active?.name}” was damaged; its previous save was loaded.`, ...skipped.map( (slot) => `“${slot.name}” could not be opened here and was kept unchanged.`, ), ] .filter(Boolean) .join(" "), ); void showSlots(); } catch (error) { showError(error); } finally { busy = false; update(); } }); get("play").addEventListener("click", () => session.state === "playing" ? pause() : play(), ); get("screen-resume").addEventListener("click", play); get("pokepic-next").addEventListener("click", () => { if (session.state !== "playing") return; runtime.advanceDialogue(); canvas.focus({ preventScroll: true }); }); get("dialogue-next").addEventListener("click", () => { if (session.state !== "playing") return; runtime.advanceDialogue(); canvas.focus({ preventScroll: true }); }); get("save").addEventListener("click", () => { void save().catch(showError); }); function download(contents: BlobPart, filename: string, type: string) { const url = URL.createObjectURL(new Blob([contents], { type })); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } get("backup").addEventListener("click", () => { if (session.active) void exportSlot(session.active.id); }); async function exportSlot(id: string) { try { const { slot, text } = await session.exportSlot(id); const name = slot.name.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-|-$/g, ""); download( text, `crystal-${name ? `${name}-` : ""}${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`, "application/json", ); message(`“${slot.name}” exported. Keep the backup somewhere safe.`); } catch (error) { showError(error); } } /** Savegames: the slots made with this ROM, newest first. */ let slotsShown = 0; async function showSlots() { const list = get("slot-list"); const request = ++slotsShown; let entries: SlotEntry[] = []; try { entries = ready() ? await session.slots() : []; } catch (error) { showError(error); } // A later refresh may have finished first. if (request !== slotsShown) return; // Autosaves refresh the list; keep the keyboard focus where it was. const focused = list.contains(document.activeElement) ? (document.activeElement as HTMLElement) : undefined; const focusId = focused?.closest("[data-slot-id]")?.dataset.slotId; const focusAction = focused?.dataset.slotAction; get("slots-empty").hidden = entries.length > 0; list.hidden = entries.length === 0; list.replaceChildren(...entries.map(slotItem)); update(); if (focusId && focusAction) [...list.querySelectorAll("button[data-slot-action]")] .find( (button) => button.dataset.slotAction === focusAction && button.closest("[data-slot-id]")?.dataset.slotId === focusId, ) ?.focus(); } function element( tag: K, className: string, text = "", ) { const node = document.createElement(tag); if (className) node.className = className; node.textContent = text; return node; } function summaryLines(summary?: SlotSummary) { if (!summary) return ["No details available"]; if (summary.newGame) return ["New game", "The introduction is not finished"]; const badges = `${summary.badges} badge${summary.badges === 1 ? "" : "s"}`; return [ `${summary.player} · ${summary.place}`, `${summary.hours}:${String(summary.minutes).padStart(2, "0")} played · ${badges} · ${summary.caught} caught`, ]; } const savedTime = (iso: string) => new Date(iso).toLocaleString([], { dateStyle: "medium", timeStyle: "short", }); function slotItem({ slot, compatible, active }: SlotEntry) { const item = element("li", "slot"); item.dataset.slotId = slot.id; if (active) item.setAttribute("aria-current", "true"); const heading = element("h3", "slot-title"); heading.append(element("span", "slot-name", slot.name)); if (active) heading.append(element("span", "slot-tag", "Playing")); if (!compatible) heading.append(element("span", "slot-tag warning", "Newer version")); item.append(heading); const lines = compatible ? summaryLines(slot.summary) : ["Made by a newer version of Crystal. It is kept unchanged."]; for (const line of lines) item.append(element("p", "slot-detail", line)); item.append(element("p", "slot-saved", `Saved ${savedTime(slot.updatedAt)}`)); const actions = element("div", "slot-actions"); const action = (name: string, label: string, locked = false) => { const button = element("button", "", label); button.dataset.slotAction = name; button.setAttribute("aria-label", `${label} ${slot.name}`); if (locked) button.dataset.locked = "true"; actions.append(button); }; if (compatible) { if (!active) action("load", "Continue"); action("rename", "Rename"); action("duplicate", "Duplicate"); } action("export", "Export"); action("delete", "Delete", active); if (active) actions.append( element("p", "slot-hint", "Continue another save to delete this one."), ); item.append(actions); return item; } get("slot-list").addEventListener("click", (event) => { const button = (event.target as HTMLElement).closest( "button[data-slot-action]", ); const id = button?.closest("[data-slot-id]")?.dataset.slotId; if (!button || !id || busy || button.disabled) return; const name = button.closest("li")?.querySelector(".slot-name")?.textContent ?? ""; void slotAction(button.dataset.slotAction!, id, name); }); /** Runs one Savegames action with the session paused and saved. */ async function withSlots(work: () => Promise) { busy = true; pause(); update(); try { await saving; const text = await work(); if (text) message(text); } catch (error) { showError(error); } finally { busy = false; await showSlots(); } } async function slotAction(action: string, id: string, name: string) { switch (action) { case "load": return withSlots(async () => { const { recovered } = await session.switchTo(id); return recovered ? `“${name}” is ready. Its last write was damaged; the previous save was loaded. Press Play to continue.` : `“${name}” is ready. Press Play to continue.`; }); case "rename": { const newName = await askSlot({ title: `Rename “${name}”`, text: "Names have 1 to 24 characters.", confirm: "Rename", value: name, }); if (newName === undefined) return; return withSlots(async () => { const slot = await session.rename(id, newName); return `Renamed to “${slot.name}”.`; }); } case "duplicate": return withSlots(async () => { const slot = await session.duplicate(id); return `“${name}” copied to “${slot.name}”.`; }); case "export": return exportSlot(id); case "delete": { const confirmed = await askSlot({ title: `Delete “${name}”?`, text: "Its progress is removed from this device. Export it first to keep a backup.", confirm: "Delete", }); if (confirmed === undefined) return; return withSlots(async () => { await session.delete(id); return `“${name}” deleted.`; }); } } } get("new-game").addEventListener("click", () => { void withSlots(async () => { const slot = await session.newGame(); tabs.select("game"); return `“${slot.name}” created. Press Play to start your new game.`; }); }); /** The rename and delete dialog. Resolves with the name entered (or "" for * a confirmation), or undefined when cancelled. */ function askSlot(options: { title: string; text: string; confirm: string; value?: string; }): Promise { const dialog = get("slot-dialog"); const field = get("slot-name"); const error = get("slot-dialog-error"); get("slot-dialog-title").textContent = options.title; get("slot-dialog-text").textContent = options.text; get("slot-dialog-confirm").textContent = options.confirm; get("slot-name-label").hidden = options.value === undefined; field.required = options.value !== undefined; field.value = options.value ?? ""; error.textContent = ""; dialog.showModal(); if (options.value !== undefined) field.select(); return new Promise((resolve) => { const submit = (event: SubmitEvent) => { const confirmed = (event.submitter as HTMLButtonElement | null)?.value === "confirm"; if (confirmed && options.value !== undefined && !field.value.trim()) { event.preventDefault(); error.textContent = "Enter a name."; return; } dialog.removeEventListener("close", closed); dialog.querySelector("form")!.removeEventListener("submit", submit); resolve(confirmed ? field.value : undefined); }; const closed = () => { dialog.querySelector("form")!.removeEventListener("submit", submit); resolve(undefined); }; dialog.querySelector("form")!.addEventListener("submit", submit); dialog.addEventListener("close", closed, { once: true }); }); } get("import-save").addEventListener("click", () => { pause(); get("save-file").click(); }); get("save-file").addEventListener("change", async (event) => { const field = event.target as HTMLInputElement; const file = field.files?.[0]; field.value = ""; if (!file || busy || !ready()) return; let preview: ImportPreview; try { preview = file.size > 2_000_000 ? { ok: false, reason: "This backup is too large." } : await session.previewImport(await file.text()); } catch (error) { showError(error); return; } const target = await previewImport(file.name, preview); if (!target || !preview.ok) return; const ok = preview; await withSlots(async () => { const slot = await session.importBackup(ok, target); saveStatus.textContent = `Imported into “${slot.name}” on this device.`; saveStatus.classList.remove("error"); return "Backup restored. Press Play to continue."; }); }); /** Shows what a backup holds and what importing it would replace. * Resolves with "new", a slot ID to replace, or undefined. */ function previewImport( file: string, preview: ImportPreview, ): Promise { const dialog = get("import-dialog"); const details = get("import-details"); const conflicts = get("import-conflicts"); const actions = get("import-actions"); const row = (term: string, value: string) => { details.append(element("dt", "", term), element("dd", "", value)); }; details.replaceChildren(); conflicts.replaceChildren(); actions.replaceChildren(); const button = (value: string, label: string, primary = false, id = "") => { const node = element("button", primary ? "menu-button" : "secondary-button", label); node.value = value; if (id) node.id = id; actions.append(node); }; if (!preview.ok) { get("import-title").textContent = "This backup cannot be imported"; get("import-text").textContent = `${preview.reason} Your saves are unchanged.`; row("File", file); if (typeof preview.identity?.savedAt === "string") row("Saved", savedTime(preview.identity.savedAt)); button("cancel", "Close", true); } else { const { summary } = preview; get("import-title").textContent = summary.newGame ? "A new game" : `${summary.player}'s game`; get("import-text").textContent = "Check the backup before importing it. Importing continues it in place of the running game, which is saved first."; row("File", file); row( "Trainer", summary.newGame ? "Not named yet" : `${summary.player} · ID No. ${String(summary.playerId).padStart(5, "0")}`, ); row("Place", summary.newGame ? "Introduction" : summary.place); for (const line of summaryLines(summary).slice(1)) row("Progress", line); row("Saved", savedTime(preview.record.savedAt)); row( "Format", preview.schema < 3 ? `Native save, game data version ${preview.schema}: upgraded to version 3 on import` : `Native save, game data version ${preview.schema}`, ); if (preview.conflicts.length) { conflicts.append(element("h3", "", "Saves of the same trainer")); const list = element("ul", ""); for (const { slot, identical } of preview.conflicts) list.append( element( "li", "", `“${slot.name}”, saved ${savedTime(slot.updatedAt)}${identical ? ": the same progress as this backup" : slot.updatedAt > preview.record.savedAt ? ": saved after this backup" : ": saved before this backup"}`, ), ); conflicts.append(list); } button("new", "Add as a new save", true, "import-new"); const active = session.active; const replaceable = [ ...(active ? [active] : []), ...preview.conflicts .map((conflict) => conflict.slot) .filter((slot) => slot.id !== active?.id), ]; for (const slot of replaceable) button( slot.id, `Replace “${slot.name}”`, false, slot.id === active?.id ? "import-current" : "", ); button("cancel", "Cancel"); } dialog.returnValue = ""; dialog.showModal(); return new Promise((resolve) => { dialog.addEventListener( "close", () => { const value = dialog.returnValue; resolve(value && value !== "cancel" ? value : undefined); }, { once: true }, ); }); } /** The installable app: updates wait for the player, and the Savegames * tab offers installation and persistent storage. */ const app = new InstallableApp({ onUpdateReady: () => { get("update-banner").hidden = false; }, onInstallChange: () => showAppSettings(), }); void app.register(); // An installed app asks once at start; browsers usually grant it then. if (app.installed) void requestPersistentStorage(); get("update-app").addEventListener("click", async () => { // Save first: the reload ends the session. pause(); await save()?.catch(() => {}); if (!app.update()) message("The update is no longer waiting. Reload the page to get it."); }); function showAppSettings() { const install = get("install-app"); install.hidden = app.installed || !app.canPrompt; get("install-status").textContent = app.installed ? "Crystal is installed. The app opens offline; select your ROM each time you open it." : app.canPrompt ? "Install Crystal to open it from your home screen and play the app offline. Select your ROM each time you open it." : app.appleDevice ? "To install Crystal, open this page in Safari, choose Share, then Add to Home Screen." : "This browser does not offer to install Crystal. It still saves here and works online."; void storageStatus().then((status) => { const button = get("persist-storage"); button.hidden = !status || status.persisted; const used = status?.usage !== undefined && status.quota ? ` Using ${(status.usage / 1_048_576).toFixed(1)} MB of ${(status.quota / 1_048_576).toFixed(0)} MB.` : ""; get("storage-status").textContent = !status ? "This browser does not report its storage. Export backups to keep your progress safe." : status.persisted ? `Saves are kept on this device even when space runs low.${used}` : `The browser may clear saves when space runs low. Keep them, or export backups.${used}`; }); } get("install-app").addEventListener("click", async () => { await app.install(); showAppSettings(); }); get("persist-storage").addEventListener("click", async () => { const granted = await requestPersistentStorage(); if (!granted) message("The browser kept its storage rules. Export backups to keep your progress safe."); showAppSettings(); }); get("help").addEventListener("click", () => { pause(); showHelpKeys(); get("help-dialog").showModal(); }); get("close-help").addEventListener("click", () => get("help-dialog").close(), ); const BUTTON_NAMES: Record = { a: "A", b: "B", start: "START", select: "SELECT", up: "Up", down: "Down", left: "Left", right: "Right", }; const BUTTON_ORDER: GameButton[] = ["a", "b", "start", "select", "up", "down", "left", "right"]; /** One sentence naming the current keys, for the game screen's label. */ function controlsSummary() { const directions = (["up", "down", "left", "right"] as const) .map((button) => buttonKeys(preferences, button)) .join(" "); const buttons = (["a", "b", "start", "select"] as const) .map((button) => `${buttonKeys(preferences, button)} is ${BUTTON_NAMES[button]}`) .join(", "); return `${directions} move. ${buttons}.`; } function showHelpKeys() { const list = get("help-keys"); list.replaceChildren(); const add = (term: string, detail: string) => { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = term; dd.textContent = detail; list.append(dt, dd); }; const key = (button: GameButton) => buttonKeys(preferences, button); add("Move", (["up", "down", "left", "right"] as const).map(key).join(" ")); add("Interact / Continue", `${key("a")} (A)`); add("Back / No", `${key("b")} (B)`); add("Start menu", `${key("start")} (START)`); add("Registered item", `${key("select")} (SELECT)`); } get("game-screen").setAttribute("aria-label", `Game screen. ${controlsSummary()}`); /** Settings: the text size and the keyboard controls, kept for this * browser only. */ let capturing: GameButton | undefined; /** Escape ended a key change, so the dialog's cancel must not close it. */ let keepOpen = false; function setPreferences(next: Preferences, status = "") { preferences = next; keys = keyMap(preferences); savePreferences(preferences); applyTextScale(preferences.textScale); runtime.setVolume(preferences.volume); get("game-screen").setAttribute("aria-label", `Game screen. ${controlsSummary()}`); showSettings(); get("settings-status").textContent = status; } function showSettings() { for (const radio of document.querySelectorAll('input[name="text-scale"]')) radio.checked = Number(radio.value) === preferences.textScale; for (const radio of document.querySelectorAll('input[name="volume"]')) radio.checked = Number(radio.value) === preferences.volume; get("key-table").replaceChildren( ...BUTTON_ORDER.map((button) => { const row = document.createElement("tr"), name = document.createElement("th"), key = document.createElement("td"), action = document.createElement("td"), change = document.createElement("button"); name.scope = "row"; name.textContent = BUTTON_NAMES[button]; key.textContent = capturing === button ? "Press a key…" : buttonKeys(preferences, button); change.type = "button"; change.textContent = capturing === button ? "Cancel" : "Change"; change.dataset.button = button; change.setAttribute("aria-label", `${change.textContent} the key for ${BUTTON_NAMES[button]}`); change.addEventListener("click", () => { capturing = capturing === button ? undefined : button; showSettings(); get("settings-status").textContent = capturing ? `Press the key for ${BUTTON_NAMES[button]}.` : ""; get("key-table").querySelector(`button[data-button="${button}"]`)?.focus(); }); action.append(change); row.append(name, key, action); return row; }), ); } const settingsDialog = get("settings-dialog"); get("settings").addEventListener("click", () => { pause(); capturing = undefined; showSettings(); get("settings-status").textContent = ""; settingsDialog.showModal(); }); get("close-settings").addEventListener("click", () => settingsDialog.close()); settingsDialog.addEventListener("close", () => { capturing = undefined; }); // While a key is awaited, the next key press becomes the button's key. settingsDialog.addEventListener( "keydown", (event) => { const button = capturing; if (!button) return; if (event.code === "Tab") return; event.preventDefault(); event.stopPropagation(); capturing = undefined; if (event.code === "Escape") { // Some browsers skip the cancel event once the key is handled here. keepOpen = true; requestAnimationFrame(() => (keepOpen = false)); showSettings(); get("settings-status").textContent = "Key change cancelled."; } else if (RESERVED_KEYS.includes(event.code)) { showSettings(); get("settings-status").textContent = `${keyName(event.code)} keeps its usual role. Choose another key.`; } else { const previous = BUTTON_ORDER.find( (other) => other !== button && preferences.keys[other].includes(event.code), ); setPreferences( rebind(preferences, button, event.code), `${BUTTON_NAMES[button]} is now ${keyName(event.code)}.` + (previous ? ` ${BUTTON_NAMES[previous]} has no key until you choose one.` : ""), ); } get("key-table").querySelector(`button[data-button="${button}"]`)?.focus(); }, true, ); // Escape cancels a key change rather than closing the dialog. settingsDialog.addEventListener("cancel", (event) => { if (capturing || keepOpen) event.preventDefault(); keepOpen = false; }); for (const radio of document.querySelectorAll('input[name="text-scale"]')) radio.addEventListener("change", () => { const scale = Number(radio.value) as TextScale; if (TEXT_SCALES.includes(scale)) setPreferences({ ...preferences, textScale: scale }, `Text size: ${radio.parentElement?.textContent?.trim()}.`); }); for (const radio of document.querySelectorAll('input[name="volume"]')) radio.addEventListener("change", () => { const volume = Number(radio.value) as Volume; if (VOLUMES.includes(volume)) setPreferences({ ...preferences, volume }, `Sound: ${radio.parentElement?.textContent?.trim()}.`); }); get("reset-keys").addEventListener("click", () => setPreferences({ ...preferences, keys: defaultPreferences().keys }, "Controls reset to the defaults."), ); window.addEventListener("blur", pause); document.addEventListener("visibilitychange", () => { if (document.hidden) pause(); }); window.addEventListener("pagehide", () => { pause(); }); setInterval(() => { if (ready() && !busy) void save().catch(() => {}); }, 10_000); update(); get("mail-close").onclick = () => { if (session.state !== "playing" || !runtime.world?.mailView) return; runtime.advanceDialogue(); canvas.focus({ preventScroll: true }); }; get("town-map-close").onclick = () => { if (session.state !== "playing") return; runtime.advanceDialogue(); canvas.focus({ preventScroll: true }); }; for (const [id, turn] of [ ["stats-prev", 2], ["stats-next", 1], ["stats-close", 0], ] as const) get(id).onclick = () => { const world = runtime.world; if (session.state !== "playing" || !statsView || !world) return; if (turn) statsView.page = (statsView.page + turn) % 3; else if (statsView.slot === undefined) runtime.advanceDialogue(); else statsView = partyChoice = undefined; showWorld(world); canvas.focus({ preventScroll: true }); };