Files
vpinball-wasm/package/src/touch-controls.ts
T

126 lines
4.0 KiB
TypeScript
Raw Normal View History

/**
* 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);
}