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:
+76
-17
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* On-screen touch controls for mobile/tablet play: an HTML/CSS overlay of
|
||||
* virtual buttons that synthesize the same keyboard events a physical
|
||||
* keyboard would send, using vpinball's actual default key bindings
|
||||
* (see vendor/vpinball/src/input/InputManager.cpp's addFlipperKeyAction/
|
||||
* addKeyAction calls). Deliberately implemented in JS/HTML rather than
|
||||
* native code - this keeps the native SDL keyboard input path completely
|
||||
* unmodified and is the dominant pattern for adding touch controls to a
|
||||
* keyboard-oriented Emscripten port.
|
||||
*
|
||||
* SDL3's Emscripten video backend listens for keydown/keyup on the
|
||||
* "#window" target by default (see SDL_emscriptenvideo.c's keyboard_element
|
||||
* default), so dispatching a real KeyboardEvent on `window` reaches it the
|
||||
* same way a physical keypress would.
|
||||
*/
|
||||
|
||||
const KEY_BINDINGS = {
|
||||
leftFlipper: { key: 'Shift', code: 'ShiftLeft' },
|
||||
rightFlipper: { key: 'Shift', code: 'ShiftRight' },
|
||||
plunger: { key: 'Enter', code: 'Enter' },
|
||||
start: { key: '1', code: 'Digit1' },
|
||||
} as const;
|
||||
|
||||
export interface TouchControlsOptions {
|
||||
/** Container to render the overlay into. Positioned absolutely, filling this element. */
|
||||
container: HTMLElement;
|
||||
/**
|
||||
* Which buttons to show. Defaults to the full set (both flippers,
|
||||
* plunger, start).
|
||||
*/
|
||||
buttons?: Array<keyof typeof KEY_BINDINGS>;
|
||||
}
|
||||
|
||||
export interface TouchControlsHandle {
|
||||
/** Remove the overlay and its event listeners. */
|
||||
detach(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a touch-control overlay to `options.container`. Call this
|
||||
* conditionally (e.g. only when `'ontouchstart' in window` or on narrow
|
||||
* viewports) - it's additive UI, not required for desktop/mouse+keyboard play.
|
||||
*/
|
||||
export function attachTouchControls(options: TouchControlsOptions): TouchControlsHandle {
|
||||
const buttons = options.buttons ?? (Object.keys(KEY_BINDINGS) as Array<keyof typeof KEY_BINDINGS>);
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('data-vpinball-touch-controls', '');
|
||||
applyStyle(root, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
fontFamily: 'sans-serif',
|
||||
});
|
||||
|
||||
const elements: HTMLElement[] = [];
|
||||
|
||||
if (buttons.includes('leftFlipper')) {
|
||||
elements.push(makeButton('◀', KEY_BINDINGS.leftFlipper, { left: '0', bottom: '0' }));
|
||||
}
|
||||
if (buttons.includes('rightFlipper')) {
|
||||
elements.push(makeButton('▶', KEY_BINDINGS.rightFlipper, { right: '0', bottom: '0' }));
|
||||
}
|
||||
if (buttons.includes('plunger')) {
|
||||
elements.push(makeButton('⬆', KEY_BINDINGS.plunger, { right: '0', top: '40%' }));
|
||||
}
|
||||
if (buttons.includes('start')) {
|
||||
elements.push(makeButton('1', KEY_BINDINGS.start, { left: '50%', top: '0', transform: 'translateX(-50%)' }));
|
||||
}
|
||||
|
||||
for (const el of elements) root.appendChild(el);
|
||||
options.container.appendChild(root);
|
||||
|
||||
return {
|
||||
detach() {
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeButton(label: string, binding: { key: string; code: string }, position: Record<string, string>): HTMLElement {
|
||||
const btn = document.createElement('div');
|
||||
btn.textContent = label;
|
||||
applyStyle(btn, {
|
||||
position: 'absolute',
|
||||
width: '72px',
|
||||
height: '72px',
|
||||
margin: '16px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '28px',
|
||||
pointerEvents: 'auto',
|
||||
touchAction: 'none',
|
||||
...position,
|
||||
});
|
||||
|
||||
const down = () => {
|
||||
btn.style.background = 'rgba(255,255,255,0.35)';
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: binding.key, code: binding.code, bubbles: true }));
|
||||
};
|
||||
const up = () => {
|
||||
btn.style.background = 'rgba(255,255,255,0.15)';
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: binding.key, code: binding.code, bubbles: true }));
|
||||
};
|
||||
|
||||
btn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
btn.setPointerCapture(e.pointerId);
|
||||
down();
|
||||
});
|
||||
btn.addEventListener('pointerup', up);
|
||||
btn.addEventListener('pointercancel', up);
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
function applyStyle(el: HTMLElement, style: Record<string, string>): void {
|
||||
Object.assign(el.style, style);
|
||||
}
|
||||
+29
-8
@@ -9,22 +9,43 @@ export interface LoadPinballOptions {
|
||||
/** Canvas the engine renders into via SDL3's WebGL2 backend. */
|
||||
canvas: HTMLCanvasElement;
|
||||
/**
|
||||
* Raw bytes of a .vpx table file to load on startup. If omitted, the
|
||||
* bundled default table (package/assets/test000-default-table.vpx) is
|
||||
* used instead.
|
||||
* Raw bytes of a self-contained .vpx table file to play instead of the
|
||||
* bundled default table. Real-world tables that reference an external
|
||||
* .vbs script override or a Music/ folder are not supported this way -
|
||||
* see the README's "Browser file-loading UX" section.
|
||||
*/
|
||||
tableData?: ArrayBuffer;
|
||||
/** Base URL to fetch dist/vpinball.wasm (and .data, if present) from. */
|
||||
/** Base URL to fetch dist/vpinball.wasm/.data (and vpinball.js) from. */
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Called as the wasm binary and preloaded asset package download,
|
||||
* with a 0-1 fraction of bytes received. Not called at all if the
|
||||
* browser doesn't support streaming fetch progress (falls back to
|
||||
* Emscripten's own default loading behavior with no progress callback).
|
||||
*/
|
||||
onProgress?: (fraction: number) => void;
|
||||
}
|
||||
|
||||
export interface PinballInstance {
|
||||
/** Load a different .vpx table at runtime, replacing the current one. */
|
||||
/**
|
||||
* Stage a different self-contained .vpx table to play on the *next*
|
||||
* start() call. Does not affect an already-running session - table
|
||||
* switching mid-session isn't supported; call stop() first (a fresh
|
||||
* loadPinball() call is the supported way to load a genuinely new
|
||||
* session with a different table).
|
||||
*/
|
||||
loadTable(vpxBytes: ArrayBuffer): void;
|
||||
/** Start (or resume) the simulation's main loop. */
|
||||
/** Start the simulation's main loop. No-op if already running. */
|
||||
start(): void;
|
||||
/** Pause the simulation's main loop. */
|
||||
/** Stop the simulation; the engine tears down (script Exit event, settings save) on its next step. */
|
||||
stop(): void;
|
||||
/** Tear down the instance and free its WebAssembly memory. */
|
||||
/** Tear down the instance immediately and free its WebAssembly memory. */
|
||||
dispose(): void;
|
||||
/**
|
||||
* Request fullscreen on the canvas via the standard Fullscreen API.
|
||||
* Must be called from within a user gesture (e.g. a click handler) -
|
||||
* browsers reject fullscreen requests otherwise. Returns the promise
|
||||
* from the underlying requestFullscreen() call.
|
||||
*/
|
||||
requestFullscreen(): Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user