import { drawTownMap } from "./game/rendering/town-map"; import { OW } from "./game/world/constants"; import "./ui/styles.css"; import { renderShell } from "./ui/shell"; 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 { drawTile } from "./game/rendering/pixels"; 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 { GameSession } from "./app/session"; import { InputController } from "./input/controller"; import { bindControls } from "./input/bindings"; import { validateRom } from "./rom/validate"; import { BrowserSaveRepository, encodeSave } from "./storage/saves"; import { acquireSessionLock } from "./platform/session-lock"; 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); showFieldMenu(world); get("town-map-view").hidden = !world.townMap; if (world.townMap) drawTownMap(get("town-map-canvas"), world); const dialogue = world.dialogue; 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 || Boolean(mart) || world.townMap || Boolean(world.statsScreen); 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; const choice = last && !mart ? 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}. Arrow keys move. X interacts. Z continues dialogue.`, ); } function showChoice(choice: Choice | undefined) { const container = get("dialogue-choice"); container.hidden = !choice; const key = choice ? JSON.stringify(choice) : ""; if (container.dataset.choice === key) return; container.dataset.choice = key; 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 === "yesno") container.replaceChildren( 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 === "name") container.replaceChildren( ...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; }), ); container.replaceChildren( list, button("CANCEL", undefined, () => runtime.choose(-1)), ); } } /** 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]; } 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" : "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; const key = JSON.stringify(mart); if (key === martMenuKey) return; martMenuKey = key; const menu = get("mart-menu"); 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. const tabs = 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)); return tab; }); const entries = mart.pockets[mart.pocket].entries; const buttons = entries.map((entry, index) => button( entry.name, () => runtime.choosePackEntry(mart.pocket, index), { detail: entry.owned === undefined ? undefined : `×${entry.owned}`, selected: mart.selected === index, }, ), ); if (!entries.length) { const empty = button("Empty", () => {}, { wide: true }); empty.disabled = true; buttons.push(empty); } buttons.push(button("BACK", () => runtime.choose(-1), { wide: true })); menu.replaceChildren(...tabs, ...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.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 = ""; let fieldCursor = 0; function fieldButtons() { return [ ...get("field-items").querySelectorAll( "button:not(:disabled)", ), ]; } function showFieldMenu(world: GameWorld) { const panel = get("field-menu"); panel.hidden = !runtime.fieldMenuOpen; if (panel.hidden) return; const buttons = world.state.bag.keyItems.map((item) => { const button = document.createElement("button"); button.textContent = world.assets.pokemon.itemName(item); button.disabled = item !== OW.BICYCLE; button.onclick = () => { if (session.state !== "playing") return; runtime.useBicycle(); canvas.focus({ preventScroll: true }); }; return button; }); const close = document.createElement("button"); close.textContent = "CLOSE"; close.onclick = () => { runtime.fieldMenuOpen = false; showFieldMenu(world); canvas.focus(); }; get("field-items").replaceChildren(...buttons, close); fieldButtons().forEach((button, i) => button.classList.toggle("battle-selected", i === fieldCursor), ); } function handleFieldInput(button: Button): boolean { const world = runtime.world; if (!world || world.battle) return false; // Start opens the start menu; Select, standing in for a registered // item, opens the Key Items shortcut. if (button === "start" && !runtime.fieldMenuOpen && !world.busy) { runtime.openStartMenu(); return true; } if (button === "select" && !world.busy) { runtime.fieldMenuOpen = !runtime.fieldMenuOpen; fieldCursor = 0; return true; } if (!runtime.fieldMenuOpen) return false; if (button === "b") runtime.fieldMenuOpen = false; else if (button === "a") fieldButtons()[fieldCursor]?.click(); else if (["up", "down", "left", "right"].includes(button)) { const length = fieldButtons().length; fieldCursor = (fieldCursor + (button === "up" || button === "left" ? -1 : 1) + length) % length; } return true; } function battleButtons() { return [ ...get("battle-menu").querySelectorAll( "button:not(:disabled)", ), ]; } 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 (["up", "down", "left", "right"].includes(button)) { const delta = button === "up" || button === "left" ? -1 : 1; battleCursor = (battleCursor + delta + buttons.length) % buttons.length; if (world.battle.prompt?.kind === "yesno") 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), 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), 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.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 (prompt?.kind === "yesno") 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", () => { showMoves = true; showWorld(world); }), button("PKMN", () => runtime.battleAction({ action: "pkmn" })), button("PACK", () => { showPack = true; showWorld(world); }), button("RUN", () => runtime.battleAction({ action: "run" })), ); } } 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); }, { detail: `: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 }), ); } menu.replaceChildren(...buttons); battleCursor = Math.min( battleCursor, Math.max(0, battleButtons().length - 1), ); highlightBattleChoice(); } /** 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); drawPic( get("stats-pic"), data.frontPic(mon.species), 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 GameSession( runtime, new BrowserSaveRepository("crystal-native-port"), ); const input = new InputController((button, pressed) => runtime.setButton(button, pressed), ); const controls = bindControls( root, input, () => session.state === "playing" && !busy && tabs.active() === "game", () => {}, ); const ready = () => ["paused", "playing"].includes(session.state); const tabs = bindTabs(root, () => { pause(); controls.release(); update(); }); 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", "backup", "import-save", "screen-resume"]) { get(id).disabled = busy || !ready(); } 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"; } 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"); }) .catch((error) => { saveStatus.textContent = "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 X 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 = 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.", ); } 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", () => { try { download( encodeSave(session.snapshot()), `crystal-${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`, "application/json", ); message("Session backup exported. Keep it somewhere safe."); } catch (error) { showError(error); } }); 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) return; if (file.size > 2_000_000) { showError(new Error("This backup is too large.")); return; } if ( !window.confirm( "Replace your current session with this backup? Export your current session first if you want to keep it.", ) ) return; busy = true; update(); try { await saving; await session.importSave(await file.text()); message("Backup restored. Press Play to continue."); saveStatus.textContent = "Imported session saved on this device."; } catch (error) { showError(error); } finally { busy = false; update(); } }); get("help").addEventListener("click", () => { pause(); get("help-dialog").showModal(); }); get("close-help").addEventListener("click", () => get("help-dialog").close(), ); 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("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 }); };