"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" | "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 function usePinballInstance( canvasRef: React.RefObject, tableData: ArrayBuffer | 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; 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; } 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 () => { disposedRef.current = true; instanceRef.current?.dispose(); instanceRef.current = null; }; // 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; instanceRef.current.start(); setStatus("running"); }, []); return { status, progress, error, loadTimeMs, instanceRef, start }; }