// Keyboard and touch input, translated into game intents. const KEY_MAP = { ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right', ArrowUp: 'jump', KeyW: 'jump', Space: 'jump', ShiftLeft: 'boost', ShiftRight: 'boost', KeyE: 'edge', Enter: 'confirm', NumpadEnter: 'confirm', KeyP: 'pause', Escape: 'pause', KeyM: 'mute', }; export class Input { constructor(target = window) { this.target = target; this.pressed = new Set(); this.held = new Set(); this.muted = false; this.onFirstGesture = null; this.keydown = (e) => { const action = KEY_MAP[e.code]; if (!action) return; // Stop the page scrolling under the game. if (e.code === 'Space' || e.code.startsWith('Arrow')) e.preventDefault(); if (!this.held.has(action)) this.pressed.add(action); this.held.add(action); this.#gesture(); }; this.keyup = (e) => { const action = KEY_MAP[e.code]; if (action) this.held.delete(action); }; this.blur = () => this.held.clear(); this.touchstart = (e) => { const t = e.changedTouches[0]; this.touchOrigin = { x: t.clientX, y: t.clientY, time: performance.now() }; this.#gesture(); }; this.touchend = (e) => { if (!this.touchOrigin) return; const t = e.changedTouches[0]; const dx = t.clientX - this.touchOrigin.x; const dy = t.clientY - this.touchOrigin.y; const dt = performance.now() - this.touchOrigin.time; const THRESHOLD = 34; if (Math.abs(dx) < THRESHOLD && Math.abs(dy) < THRESHOLD) { if (dt < 400) this.pressed.add('jump'); } else if (Math.abs(dx) > Math.abs(dy)) { this.pressed.add(dx > 0 ? 'right' : 'left'); } else if (dy < 0) { this.pressed.add('jump'); } else { this.pressed.add('edge'); } this.touchOrigin = null; }; this.pointerdown = () => this.#gesture(); target.addEventListener('keydown', this.keydown); target.addEventListener('keyup', this.keyup); target.addEventListener('blur', this.blur); target.addEventListener('touchstart', this.touchstart, { passive: true }); target.addEventListener('touchend', this.touchend, { passive: true }); target.addEventListener('pointerdown', this.pointerdown); } #gesture() { if (!this.gestured) { this.gestured = true; this.onFirstGesture?.(); } } // True once per press. consume(action) { if (!this.pressed.has(action)) return false; this.pressed.delete(action); return true; } isHeld(action) { return this.held.has(action); } // Drop queued presses that were not handled this frame so they cannot fire // late, for example a jump buffered while the game over screen was up. endFrame() { this.pressed.clear(); } destroy() { this.target.removeEventListener('keydown', this.keydown); this.target.removeEventListener('keyup', this.keyup); this.target.removeEventListener('blur', this.blur); this.target.removeEventListener('touchstart', this.touchstart); this.target.removeEventListener('touchend', this.touchend); this.target.removeEventListener('pointerdown', this.pointerdown); } }