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 } 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("player-name").value = world.name; get("player-avatar").value = world.avatar; get("location").textContent = world.map.name; get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`; get("game-notice").textContent = world.notice; showBattle(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); 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.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 === "name") container.replaceChildren( ...nameField(choice.current, choice.max, (name) => runtime.choose(name)), ); } /** 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" } >; function martChoice(choice: Choice): MartChoice | undefined { return choice.kind === "martmenu" || choice.kind === "martlist" || choice.kind === "martquantity" ? 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; } get("mart-money").textContent = `MONEY ¥${world.liveState.money}`; get("mart-prompt").textContent = mart.kind === "martlist" ? "" : 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 === "martlist") { const buttons = mart.entries.map((entry, index) => button(entry.name, () => runtime.choose(index), { detail: mart.mode === "sell" ? `have ${entry.owned}` : `¥${entry.price}`, 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 = `${mart.name} ×${mart.selected} ¥${total}`; 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 === "sell" ? "SELL" : "BUY", () => 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's Balls pocket, the only pocket usable in battle so far. */ let showPack = false; let battleCursor = 0; let battleSelectionKey = ""; 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 { const world = runtime.world; if (!world?.battle) return false; if (button === "b" && (showMoves || showPack)) { showMoves = showPack = false; 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 : (view.message?.text ?? ""); if (prompt?.kind !== "action") showMoves = showPack = false; const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}`; if (selectionKey !== battleSelectionKey) { battleCursor = 0; battleSelectionKey = selectionKey; } if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1; const balls = world.liveState.bag.balls; const key = JSON.stringify([prompt, view.selected, showMoves, showPack, own?.pp, balls, 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) { balls.forEach(({ item, quantity }, slot) => buttons.push( button(data.itemName(item), () => { showPack = false; runtime.battleAction({ action: "item", item, slot }); }, { detail: `×${quantity}` }), ), ); if (!balls.length) buttons.push(button("No BALLS", () => {}, { disabled: "The BALL pocket is empty." })); buttons.push(button("BACK", () => { showPack = false; showWorld(world); }, { wide: true })); } else { buttons.push( button("FIGHT", () => { showMoves = true; showWorld(world); }), button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }), 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 === "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(); } 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: number | undefined; /** Draw a pokepic from the ROM's front picture tiles and palette. */ function showPicture(world: GameWorld) { const box = get("pokepic"); box.hidden = world.picture === undefined; if (world.picture === shownPicture) return; shownPicture = world.picture; if (world.picture === undefined) return; const data = world.assets.pokemon; const picture = get("pokepic-canvas"); const context = picture.getContext("2d")!; const image = context.createImageData(56, 56); const tiles = data.frontPic(world.picture), palette = data.palette(world.picture); // 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", data.speciesName(world.picture)); } const runtime = new NativeRuntime( showWorld, (error) => { session.state = "error"; controls.release(); showError(error); update(); }, () => ({ name: get("player-name") .value.trim() .replace(/[^A-Za-z0-9 -]/g, "") .slice(0, 7) || "CHRIS", avatar: get("player-avatar").value === "kris" ? "kris" : "chris", }), 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("player-name").disabled = busy || ready(); get("player-avatar").disabled = busy || ready(); 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();