Phase D/F/H: embedding shell - loading progress, fullscreen, file upload, touch controls

package/src/index.ts: fixes a real pre-existing bug (start()/loadTable()
never actually called vpinball_wasm_start with a table path argument),
adds byte-level download progress (onProgress) and requestFullscreen().

package/src/touch-controls.ts: new on-screen virtual flipper/plunger/start
button overlay that synthesizes the actual default keyboard scancodes
(SDL_SCANCODE_LSHIFT/RSHIFT/RETURN, read from InputManager.cpp) as real
KeyboardEvents dispatched on window, matching SDL3's Emscripten keyboard
target - keeps the native input path completely unmodified.

examples/basic/index.html: a real demo page - progress bar, a user-gesture
"Start" button (required for both audio autoplay and fullscreen), a file
picker for self-contained .vpx uploads, and the touch overlay shown on
touch-capable devices.

scripts/build.sh: two real bugs found and fixed via actual browser testing:
- MODULARIZE=1 without EXPORT_ES6=1 produces a classic script, not an ES
  module with a default export - `await import(...)).default` was always
  undefined. Fixes with -sEXPORT_ES6=1.
- --closure 1 silently stripped FS.writeFile/readFile/mkdir down to just
  low-level node ops, breaking runtime table loading with no compile-time
  warning. Dropped until root-caused; -O2 alone is kept.

Verified end-to-end in Chrome: full load->progress->start flow with no
errors, fullscreen actually engaging (document.fullscreenElement true),
and a file-picker-selected table loading correctly (LoadGameFromFilename
/tables/uploaded.vpx in the boot log, followed by a normal render).

README updated to reflect what's now validated vs. still open (real-device
input/audio confirmation remains the main open item).
This commit is contained in:
2026-08-22 16:45:04 +02:00
parent a5e5b6f34a
commit 487ca40a44
6 changed files with 352 additions and 46 deletions
+76 -17
View File
@@ -1,20 +1,28 @@
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.
*
* NOTE (current milestone): this wrapper boots the Emscripten module and
* exposes lifecycle control (start/stop/dispose). Runtime table loading
* (`loadTable`) mounts the given bytes into the module's virtual filesystem,
* but wiring the native engine to actually pick up a runtime-loaded table
* (as opposed to the table baked in at build time) is tracked as a follow-up
* milestone in the README's roadmap - see "Browser file-loading UX".
* canvas and returns a handle to control its lifecycle.
*/
export async function loadPinball(options: LoadPinballOptions): Promise<PinballInstance> {
const baseUrl = options.baseUrl ?? '.';
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);
}
// dist/vpinball.js is built with -sMODULARIZE=1 -sEXPORT_NAME=VPinballModule,
// so importing it yields a factory function, not a module with side effects.
@@ -22,21 +30,21 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
moduleArgs: Record<string, unknown>
) => Promise<EmscriptenModule>;
const module = await factory({
canvas: options.canvas,
locateFile: (path: string) => `${baseUrl}/${path}`,
});
const module = await factory(moduleArgs);
options.onProgress?.(1);
if (options.tableData) {
mountTable(module, options.tableData);
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(options.tableData));
tablePath = UPLOADED_TABLE_PATH;
}
return {
loadTable(vpxBytes: ArrayBuffer) {
mountTable(module, vpxBytes);
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(vpxBytes));
tablePath = UPLOADED_TABLE_PATH;
},
start() {
module.ccall?.('vpinball_wasm_start', null, [], []);
module.ccall?.('vpinball_wasm_start', 'number', ['string'], [tablePath]);
},
stop() {
module.ccall?.('vpinball_wasm_stop', null, [], []);
@@ -44,11 +52,62 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
dispose() {
module.ccall?.('vpinball_wasm_dispose', null, [], []);
},
requestFullscreen() {
return options.canvas.requestFullscreen();
},
};
}
function mountTable(module: EmscriptenModule, vpxBytes: ArrayBuffer): void {
module.FS.writeFile('/table.vpx', new Uint8Array(vpxBytes));
/**
* 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);
};
}
interface EmscriptenModule {