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).
126 lines
4.0 KiB
TypeScript
126 lines
4.0 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|