import type { LoadPinballOptions, PinballInstance } from './types.js'; export type { LoadPinballOptions, PinballInstance } from './types.js'; export { attachTouchControls } from './touch-controls.js'; export type { TouchControlsOptions, TouchControlsHandle } from './touch-controls.js'; const DEFAULT_TABLE_PATH = '/tables/default.vpx'; const UPLOADED_TABLE_PATH = '/tables/uploaded.vpx'; /** * Instantiates the WebAssembly build of Visual Pinball against the given * canvas and returns a handle to control its lifecycle. */ export async function loadPinball(options: LoadPinballOptions): Promise { // Resolved to an absolute URL against the *page's* location up front, // rather than used as-is: a bare relative baseUrl works for // Module.locateFile and wireDownloadProgress's fetch() (both resolve // relative to the page), but not for the dynamic import() below, whose // relative-specifier resolution is against *this module's own* URL // (dist/index.js) instead - the two disagree for any page location that // doesn't happen to cancel the difference out (as examples/basic/'s // '../../dist' accidentally does). Resolving once here, to an absolute // URL, makes every use of it agree. const baseUrl = new URL(options.baseUrl ?? '.', document.baseURI).href.replace(/\/$/, ''); let tablePath = DEFAULT_TABLE_PATH; const moduleArgs: Record = { canvas: options.canvas, locateFile: (path: string) => `${baseUrl}/${path}`, }; if (options.onProgress) { await wireDownloadProgress(moduleArgs, baseUrl, options.onProgress); } // dist/vpinball.js is built with -sMODULARIZE=1 -sEXPORT_NAME=VPinballModule, // so importing it yields a factory function, not a module with side effects. const factory = (await import(/* @vite-ignore */ `${baseUrl}/vpinball.js`)).default as ( moduleArgs: Record ) => Promise; const module = await factory(moduleArgs); options.onProgress?.(1); if (options.tableData) { module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(options.tableData)); tablePath = UPLOADED_TABLE_PATH; } return { loadTable(vpxBytes: ArrayBuffer) { module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(vpxBytes)); tablePath = UPLOADED_TABLE_PATH; }, loadRom(gameName: string, romZipBytes: ArrayBuffer) { module.FS.mkdirTree(PINMAME_ROMS_DIR); module.FS.writeFile(`${PINMAME_ROMS_DIR}/${gameName}.zip`, new Uint8Array(romZipBytes)); }, start() { module.ccall?.('vpinball_wasm_start', 'number', ['string'], [tablePath]); }, stop() { module.ccall?.('vpinball_wasm_stop', null, [], []); }, dispose() { module.ccall?.('vpinball_wasm_dispose', null, [], []); }, requestFullscreen() { return options.canvas.requestFullscreen(); }, evalScript(script: string) { return Boolean(module.ccall?.('vpinball_wasm_eval_script', 'number', ['string'], [script])); }, }; } /** * Best-effort byte-level download progress: manually fetches vpinball.wasm * with a streaming reader (assigning the result to Module.wasmBinary so the * factory doesn't re-fetch it), weighted against Module's own coarse * "Downloading data..." status callback for the preloaded asset package. * Emscripten's own dependency counter (monitorRunDependencies) doesn't give * byte-level granularity, hence fetching the .wasm by hand instead. */ async function wireDownloadProgress( moduleArgs: Record, baseUrl: string, onProgress: (fraction: number) => void ): Promise { // Roughly half the total download is the wasm binary, half the data // package - this is an estimate (see README's size-tuning roadmap item), // not a guarantee, so progress may jump at the wasm/data boundary. const WASM_WEIGHT = 0.5; try { const response = await fetch(`${baseUrl}/vpinball.wasm`); const total = Number(response.headers.get('Content-Length') ?? 0); const reader = response.body?.getReader(); if (!reader || !total) { return; } const chunks: Uint8Array[] = []; let received = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.length; onProgress((received / total) * WASM_WEIGHT); } const wasmBinary = new Uint8Array(received); let offset = 0; for (const chunk of chunks) { wasmBinary.set(chunk, offset); offset += chunk.length; } moduleArgs.wasmBinary = wasmBinary; } catch { // Streaming progress is best-effort; fall through to the factory's // own default fetch if this fails for any reason (e.g. no CORS // Content-Length exposed, older browser). } moduleArgs.setStatus = (text: string) => { if (text) onProgress(WASM_WEIGHT + (1 - WASM_WEIGHT) * 0.5); }; } interface EmscriptenModule { FS: { writeFile(path: string, data: Uint8Array): void; mkdirTree(path: string): void; }; ccall?: (name: string, returnType: string | null, argTypes: string[], args: unknown[]) => unknown; } const PINMAME_ROMS_DIR = '/tables/pinmame/roms';