"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import type { LoadPinballOptions, PinballInstance } from "@valknar/vpinball-wasm"; import { hasWebGL2 } from "./webglSupport"; // Served as a plain static file, not bundled — loadPinball() internally does // a dynamic import(`${baseUrl}/vpinball.js`) with a template-literal path // that webpack can't statically resolve, so the whole engine must stay // outside webpack's module graph (see scripts/copy-engine-assets.mjs). const ENGINE_BASE_URL = "/vendor/vpinball-wasm"; export type PinballStatus = "idle" | "loading" | "ready" | "starting" | "running" | "error"; interface EngineModule { loadPinball(options: LoadPinballOptions): Promise; } async function loadEngineModule(): Promise { return (await import( /* webpackIgnore: true */ `${ENGINE_BASE_URL}/index.js` )) as EngineModule; } export interface UsePinballInstanceResult { status: PinballStatus; /** 0-1 download progress, only meaningful while status is "loading". */ progress: number; error: string | null; loadTimeMs: number | null; instanceRef: React.RefObject; /** Call from a user-gesture handler (audio autoplay / fullscreen policy). */ start: () => void; } export interface RomSelection { gameName: string; romData: ArrayBuffer; } export function usePinballInstance( canvasRef: React.RefObject, tableData: ArrayBuffer | undefined, rom: RomSelection | undefined, ): UsePinballInstanceResult { // Computed once at mount (not inside the effect below) so the WebGL2- // unsupported case is derived initial state rather than a setState call // that an effect makes only to immediately return. const [webgl2Supported] = useState(hasWebGL2); const [status, setStatus] = useState(webgl2Supported ? "idle" : "error"); const [progress, setProgress] = useState(0); const [error, setError] = useState( webgl2Supported ? null : "Your browser doesn't support WebGL2, which this pinball engine requires.", ); const [loadTimeMs, setLoadTimeMs] = useState(null); const instanceRef = useRef(null); const disposedRef = useRef(false); useEffect(() => { disposedRef.current = false; const canvas = canvasRef.current; if (!canvas || !webgl2Supported) return; // The engine sets a table-supplied image as the page favicon via a // blob: URL (see vpinball.js's rel~='icon' link handling) — undesirable // here, where the app's own favicon should stay put. Snapshot the // current icon links up front and revert any blob: href the moment it // appears, rather than patching the (compiled) engine itself. const originalIconHrefs = new Map(); document.querySelectorAll("link[rel~='icon']").forEach((link) => { originalIconHrefs.set(link, link.href); }); const faviconObserver = new MutationObserver(() => { document.querySelectorAll("link[rel~='icon']").forEach((link) => { if (!link.href.startsWith("blob:")) return; const blobUrl = link.href; const original = originalIconHrefs.get(link); if (original) link.href = original; else link.remove(); URL.revokeObjectURL(blobUrl); }); }); faviconObserver.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["href"], }); setStatus("loading"); const startedAt = performance.now(); (async () => { try { const { loadPinball } = await loadEngineModule(); const instance = await loadPinball({ canvas, baseUrl: ENGINE_BASE_URL, tableData, onProgress: (fraction) => { if (!disposedRef.current) setProgress(fraction); }, }); if (disposedRef.current) { instance.dispose(); return; } if (rom) { try { // Must run before start() — Controller.Run() reads the ROM // file synchronously at table-init time. A bad/mismatched ROM // zip fails cleanly inside the engine rather than here, so this // is defensive against loadRom() itself throwing (e.g. a // corrupt zip), not something expected to fire routinely. instance.loadRom(rom.gameName, rom.romData); } catch (err) { console.error("Failed to load ROM:", err); } } instanceRef.current = instance; setLoadTimeMs(performance.now() - startedAt); setStatus("ready"); } catch (err) { if (!disposedRef.current) { setStatus("error"); setError(err instanceof Error ? err.message : "Failed to load the pinball engine."); } } })(); return () => { faviconObserver.disconnect(); disposedRef.current = true; const instance = instanceRef.current; instanceRef.current = null; if (instance) { // stop() halts the engine's render loop, but only takes effect on // its next internal step rather than immediately. Calling dispose() // synchronously right after — freeing WASM memory before that step // runs — is what hangs the tab. Give it a couple of frames first. instance.stop(); requestAnimationFrame(() => { requestAnimationFrame(() => { instance.dispose(); }); }); } }; // Intentionally run once: PlayView mounts a fresh PinballCanvasImpl // (via `key`) per table rather than swapping tableData in place. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const start = useCallback(() => { if (!instanceRef.current) return; setStatus("starting"); // instance.start() runs synchronously and blocks the main thread for a // noticeable moment (heavy WASM init) — a nested rAF lets the browser // paint the "starting" spinner from the state update above before that // block hits, instead of the UI freezing on the old screen mid-click. requestAnimationFrame(() => { requestAnimationFrame(() => { if (disposedRef.current || !instanceRef.current) return; instanceRef.current.start(); setStatus("running"); }); }); }, []); return { status, progress, error, loadTimeMs, instanceRef, start }; }