/** * GamepadMapper - A comprehensive gamepad mapping and input handling system * Addresses shortcomings of the vanilla Gamepad API: * - Stick drift compensation with configurable deadzones * - Button debouncing to prevent multiple rapid presses * - Standardized controller mapping across different brands * - Event-driven API instead of polling */ export class GamepadMapper { constructor(options = {}) { // Configuration this.config = { deadzone: options.deadzone || 0.1, debounceTime: options.debounceTime || 50, // ms pollRate: options.pollRate || 60, // fps enableVibration: options.enableVibration || false, ...options }; // State tracking this.connectedGamepads = new Map(); this.previousStates = new Map(); this.buttonDebounceTimers = new Map(); this.eventListeners = new Map(); this.isPolling = false; // Standard controller mappings this.controllerMappings = this.initializeControllerMappings(); this.init(); } init() { // Set up gamepad connection events window.addEventListener('gamepadconnected', this.handleGamepadConnected.bind(this)); window.addEventListener('gamepaddisconnected', this.handleGamepadDisconnected.bind(this)); // Check for already connected gamepads this.scanForGamepads(); // Start polling loop this.startPolling(); } initializeControllerMappings() { return { // Standard mapping (most Xbox-compatible controllers) 'standard': { buttons: { 0: 'A', // Bottom face button 1: 'B', // Right face button 2: 'X', // Left face button 3: 'Y', // Top face button 4: 'LB', // Left bumper 5: 'RB', // Right bumper 6: 'LT', // Left trigger (digital) 7: 'RT', // Right trigger (digital) 8: 'SELECT', // Select/Back button 9: 'START', // Start/Menu button 10: 'LS', // Left stick press 11: 'RS', // Right stick press 12: 'DPAD_UP', 13: 'DPAD_DOWN', 14: 'DPAD_LEFT', 15: 'DPAD_RIGHT', 16: 'HOME' // Home/Guide button (if available) }, axes: { 0: 'LEFT_STICK_X', 1: 'LEFT_STICK_Y', 2: 'RIGHT_STICK_X', 3: 'RIGHT_STICK_Y', // Some controllers have analog triggers on axes 2/3 instead // We'll detect this dynamically } }, // PlayStation controllers (DS4, DualSense) 'playstation': { buttons: { 0: 'X', // Cross (bottom) 1: 'CIRCLE', // Circle (right) 2: 'SQUARE', // Square (left) 3: 'TRIANGLE', // Triangle (top) 4: 'L1', // Left bumper 5: 'R1', // Right bumper 6: 'L2', // Left trigger (digital) 7: 'R2', // Right trigger (digital) 8: 'SHARE', // Share button 9: 'OPTIONS', // Options button 10: 'L3', // Left stick press 11: 'R3', // Right stick press 12: 'DPAD_UP', 13: 'DPAD_DOWN', 14: 'DPAD_LEFT', 15: 'DPAD_RIGHT', 16: 'PS' // PlayStation button }, axes: { 0: 'LEFT_STICK_X', 1: 'LEFT_STICK_Y', 2: 'RIGHT_STICK_X', 3: 'RIGHT_STICK_Y' } }, // Nintendo Switch Pro Controller 'nintendo': { buttons: { 0: 'B', // B (bottom) 1: 'A', // A (right) 2: 'Y', // Y (left) 3: 'X', // X (top) 4: 'L', // Left bumper 5: 'R', // Right bumper 6: 'ZL', // Left trigger 7: 'ZR', // Right trigger 8: 'MINUS', // Minus button 9: 'PLUS', // Plus button 10: 'LS', // Left stick press 11: 'RS', // Right stick press 12: 'HOME', // Home button 13: 'SCREENSHOT' // Screenshot button }, axes: { 0: 'DPAD_AXIS', // Nintendo DPAD on axis 0 (interferes with LEFT_STICK_Y) 1: 'LEFT_STICK_X', // Nintendo left stick X on axis 1 2: 'LEFT_STICK_Y', // Nintendo left stick Y on axis 2 (was showing as RIGHT_STICK_X) 3: 'RIGHT_STICK_X', // Nintendo right stick X on axis 3 (was showing as RIGHT_STICK_Y) 4: 'RIGHT_STICK_Y' // Nintendo right stick Y on axis 4 (Hori controller) } } }; } detectControllerType(gamepad) { const id = gamepad.id.toLowerCase(); // PlayStation controllers if (id.includes('playstation') || id.includes('dualshock') || id.includes('dualsense') || id.includes('054c')) { // Sony vendor ID return 'playstation'; } // Nintendo controllers if (id.includes('nintendo') || id.includes('pro controller') || id.includes('057e')) { // Nintendo vendor ID return 'nintendo'; } // Xbox and most other controllers use standard mapping return 'standard'; } handleGamepadConnected(event) { const gamepad = event.gamepad; const controllerType = this.detectControllerType(gamepad); console.log(`🎮 Gamepad connected: ${gamepad.id} (Type: ${controllerType})`); this.connectedGamepads.set(gamepad.index, { gamepad, type: controllerType, mapping: this.controllerMappings[controllerType], lastUpdate: 0 }); this.previousStates.set(gamepad.index, { buttons: {}, axes: {} }); this.emit('connected', { index: gamepad.index, id: gamepad.id, type: controllerType }); } handleGamepadDisconnected(event) { const index = event.gamepad.index; console.log(`🎮 Gamepad disconnected: ${event.gamepad.id}`); this.connectedGamepads.delete(index); this.previousStates.delete(index); // Clear any pending debounce timers if (this.buttonDebounceTimers.has(index)) { const timers = this.buttonDebounceTimers.get(index); Object.values(timers).forEach(timer => clearTimeout(timer)); this.buttonDebounceTimers.delete(index); } this.emit('disconnected', { index: index, id: event.gamepad.id }); } scanForGamepads() { const gamepads = navigator.getGamepads(); for (let i = 0; i < gamepads.length; i++) { if (gamepads[i] && !this.connectedGamepads.has(i)) { // Simulate connection event for already connected gamepads this.handleGamepadConnected({ gamepad: gamepads[i] }); } } } startPolling() { if (this.isPolling) return; this.isPolling = true; const poll = () => { if (!this.isPolling) return; this.updateGamepadStates(); setTimeout(() => requestAnimationFrame(poll), 1000 / this.config.pollRate); }; requestAnimationFrame(poll); } stopPolling() { this.isPolling = false; } updateGamepadStates() { const gamepads = navigator.getGamepads(); for (const [index, controllerData] of this.connectedGamepads) { const gamepad = gamepads[index]; if (!gamepad) continue; const previousState = this.previousStates.get(index); const currentState = { buttons: {}, axes: {} }; // Process buttons this.processButtons(gamepad, controllerData, previousState, currentState); // Process axes (analog sticks, triggers) this.processAxes(gamepad, controllerData, previousState, currentState); // Update previous state this.previousStates.set(index, currentState); } } processButtons(gamepad, controllerData, previousState, currentState) { const mapping = controllerData.mapping.buttons; gamepad.buttons.forEach((button, buttonIndex) => { const buttonName = mapping[buttonIndex]; if (!buttonName) return; const isPressed = button.pressed; const wasPressed = previousState.buttons[buttonName] || false; currentState.buttons[buttonName] = isPressed; // Handle analog triggers as axes if they have analog values if ((buttonName === 'LT' || buttonName === 'RT' || buttonName === 'L2' || buttonName === 'R2') && button.value > 0) { const axisName = buttonName === 'LT' || buttonName === 'L2' ? 'LEFT_TRIGGER' : 'RIGHT_TRIGGER'; const previousValue = previousState.axes[axisName] || 0; currentState.axes[axisName] = button.value; // Emit axis change for analog triggers if (Math.abs(button.value - previousValue) > 0.01) { this.emit('axischange', { gamepadIndex: gamepad.index, axis: axisName, value: button.value, rawValue: button.value }); } } // Detect button press (with debouncing) if (isPressed && !wasPressed) { this.handleButtonPress(gamepad.index, buttonName, button.value); } // Detect button release if (!isPressed && wasPressed) { this.handleButtonRelease(gamepad.index, buttonName); } }); } processAxes(gamepad, controllerData, previousState, currentState) { const mapping = controllerData.mapping.axes; gamepad.axes.forEach((axisValue, axisIndex) => { const axisName = mapping[axisIndex]; if (!axisName) return; // DPAD_AXIS is immune to deadzone filtering and gets special processing let processedValue; if (axisName === 'DPAD_AXIS') { processedValue = this.processDpadAxis(axisValue); } else { // Apply deadzone to all other axes processedValue = this.applyDeadzone(axisValue); } const previousValue = previousState.axes[axisName] || 0; currentState.axes[axisName] = processedValue; // Only emit events if value changed significantly const threshold = axisName === 'DPAD_AXIS' ? 0.001 : 0.01; if (Math.abs(processedValue - previousValue) > threshold) { this.emit('axischange', { gamepadIndex: gamepad.index, axis: axisName, value: processedValue, rawValue: axisValue }); } }); } processDpadAxis(rawValue) { // DPAD axis mapping for Nintendo controllers (raw values to degrees): // 1.29 = neutral (not pressed) -> null // 0.14 = down -> 270° // -0.14 = down right -> 315° // -0.43 = right -> 0° // -0.71 = up right -> 45° // -1 = up -> 90° // 1 = up left -> 135° // 0.71 = left -> 180° // 0.43 = down left -> 225° // Use tolerance for floating point comparison const tolerance = 0.05; // Check for neutral position first if (Math.abs(rawValue - 1.29) < tolerance) { return null; // Not pressed } // Map raw values to degrees if (Math.abs(rawValue - (-0.43)) < tolerance) { return 0; // Right } else if (Math.abs(rawValue - (-0.71)) < tolerance) { return 45; // Up right } else if (Math.abs(rawValue - (-1)) < tolerance) { return 90; // Up } else if (Math.abs(rawValue - 1) < tolerance) { return 135; // Up left } else if (Math.abs(rawValue - 0.71) < tolerance) { return 180; // Left } else if (Math.abs(rawValue - 0.43) < tolerance) { return 225; // Down left } else if (Math.abs(rawValue - 0.14) < tolerance) { return 270; // Down } else if (Math.abs(rawValue - (-0.14)) < tolerance) { return 315; // Down right } // Return null for unrecognized values (treat as neutral) return null; } applyDeadzone(value) { if (Math.abs(value) < this.config.deadzone) { return 0; } // Scale the remaining range to 0-1 const sign = Math.sign(value); const scaledValue = (Math.abs(value) - this.config.deadzone) / (1 - this.config.deadzone); return sign * scaledValue; } handleButtonPress(gamepadIndex, buttonName, value) { // Implement debouncing const timerId = `${gamepadIndex}_${buttonName}`; if (!this.buttonDebounceTimers.has(gamepadIndex)) { this.buttonDebounceTimers.set(gamepadIndex, {}); } const gamepadTimers = this.buttonDebounceTimers.get(gamepadIndex); if (gamepadTimers[buttonName]) { clearTimeout(gamepadTimers[buttonName]); } gamepadTimers[buttonName] = setTimeout(() => { delete gamepadTimers[buttonName]; this.emit('buttondown', { gamepadIndex, button: buttonName, value: value || 1 }); }, this.config.debounceTime); } handleButtonRelease(gamepadIndex, buttonName) { this.emit('buttonup', { gamepadIndex, button: buttonName }); } // Event system on(eventType, callback) { if (!this.eventListeners.has(eventType)) { this.eventListeners.set(eventType, []); } this.eventListeners.get(eventType).push(callback); } off(eventType, callback) { if (!this.eventListeners.has(eventType)) return; const listeners = this.eventListeners.get(eventType); const index = listeners.indexOf(callback); if (index > -1) { listeners.splice(index, 1); } } emit(eventType, data) { if (!this.eventListeners.has(eventType)) return; this.eventListeners.get(eventType).forEach(callback => { try { callback(data); } catch (error) { console.error(`Error in gamepad event listener for ${eventType}:`, error); } }); } // Utility methods getGamepadInfo(index) { return this.connectedGamepads.get(index); } getConnectedGamepads() { return Array.from(this.connectedGamepads.keys()); } setDeadzone(deadzone) { this.config.deadzone = Math.max(0, Math.min(1, deadzone)); } setDebounceTime(time) { this.config.debounceTime = Math.max(0, time); } // Vibration support (if available) vibrate(gamepadIndex, duration = 200, strongMagnitude = 1.0, weakMagnitude = 1.0) { if (!this.config.enableVibration) return false; const gamepads = navigator.getGamepads(); const gamepad = gamepads[gamepadIndex]; if (!gamepad || !gamepad.vibrationActuator) return false; try { gamepad.vibrationActuator.playEffect('dual-rumble', { duration, strongMagnitude: Math.max(0, Math.min(1, strongMagnitude)), weakMagnitude: Math.max(0, Math.min(1, weakMagnitude)) }); return true; } catch (error) { console.warn('Vibration not supported:', error); return false; } } destroy() { this.stopPolling(); // Clear all timers for (const timers of this.buttonDebounceTimers.values()) { Object.values(timers).forEach(timer => clearTimeout(timer)); } // Clear all event listeners this.eventListeners.clear(); // Remove window event listeners window.removeEventListener('gamepadconnected', this.handleGamepadConnected); window.removeEventListener('gamepaddisconnected', this.handleGamepadDisconnected); } }