2026-08-22 14:30:14 +02:00
|
|
|
import type { LoadPinballOptions, PinballInstance } from './types.js';
|
|
|
|
|
|
|
|
|
|
export type { LoadPinballOptions, PinballInstance } from './types.js';
|
2026-08-22 16:45:04 +02:00
|
|
|
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';
|
2026-08-22 14:30:14 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Instantiates the WebAssembly build of Visual Pinball against the given
|
2026-08-22 16:45:04 +02:00
|
|
|
* canvas and returns a handle to control its lifecycle.
|
2026-08-22 14:30:14 +02:00
|
|
|
*/
|
|
|
|
|
export async function loadPinball(options: LoadPinballOptions): Promise<PinballInstance> {
|
|
|
|
|
const baseUrl = options.baseUrl ?? '.';
|
2026-08-22 16:45:04 +02:00
|
|
|
let tablePath = DEFAULT_TABLE_PATH;
|
|
|
|
|
|
|
|
|
|
const moduleArgs: Record<string, unknown> = {
|
|
|
|
|
canvas: options.canvas,
|
|
|
|
|
locateFile: (path: string) => `${baseUrl}/${path}`,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (options.onProgress) {
|
|
|
|
|
await wireDownloadProgress(moduleArgs, baseUrl, options.onProgress);
|
|
|
|
|
}
|
2026-08-22 14:30:14 +02:00
|
|
|
|
|
|
|
|
// 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<string, unknown>
|
|
|
|
|
) => Promise<EmscriptenModule>;
|
|
|
|
|
|
2026-08-22 16:45:04 +02:00
|
|
|
const module = await factory(moduleArgs);
|
|
|
|
|
options.onProgress?.(1);
|
2026-08-22 14:30:14 +02:00
|
|
|
|
|
|
|
|
if (options.tableData) {
|
2026-08-22 16:45:04 +02:00
|
|
|
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(options.tableData));
|
|
|
|
|
tablePath = UPLOADED_TABLE_PATH;
|
2026-08-22 14:30:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
loadTable(vpxBytes: ArrayBuffer) {
|
2026-08-22 16:45:04 +02:00
|
|
|
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(vpxBytes));
|
|
|
|
|
tablePath = UPLOADED_TABLE_PATH;
|
2026-08-22 14:30:14 +02:00
|
|
|
},
|
|
|
|
|
start() {
|
2026-08-22 16:45:04 +02:00
|
|
|
module.ccall?.('vpinball_wasm_start', 'number', ['string'], [tablePath]);
|
2026-08-22 14:30:14 +02:00
|
|
|
},
|
|
|
|
|
stop() {
|
|
|
|
|
module.ccall?.('vpinball_wasm_stop', null, [], []);
|
|
|
|
|
},
|
|
|
|
|
dispose() {
|
|
|
|
|
module.ccall?.('vpinball_wasm_dispose', null, [], []);
|
|
|
|
|
},
|
2026-08-22 16:45:04 +02:00
|
|
|
requestFullscreen() {
|
|
|
|
|
return options.canvas.requestFullscreen();
|
|
|
|
|
},
|
2026-08-22 18:14:49 +02:00
|
|
|
evalScript(script: string) {
|
|
|
|
|
return Boolean(module.ccall?.('vpinball_wasm_eval_script', 'number', ['string'], [script]));
|
|
|
|
|
},
|
2026-08-22 14:30:14 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 16:45:04 +02:00
|
|
|
/**
|
|
|
|
|
* 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<string, unknown>,
|
|
|
|
|
baseUrl: string,
|
|
|
|
|
onProgress: (fraction: number) => void
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
// 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);
|
|
|
|
|
};
|
2026-08-22 14:30:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EmscriptenModule {
|
|
|
|
|
FS: {
|
|
|
|
|
writeFile(path: string, data: Uint8Array): void;
|
|
|
|
|
};
|
|
|
|
|
ccall?: (name: string, returnType: string | null, argTypes: string[], args: unknown[]) => unknown;
|
|
|
|
|
}
|