/** * 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; } 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); 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): 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): void { Object.assign(el.style, style); }