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