|
|
@@ -0,0 +1,448 @@
|
|
|
+/**
|
|
|
+ * 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: 'DPAD_UP',
|
|
|
+ 13: 'DPAD_DOWN',
|
|
|
+ 14: 'DPAD_LEFT',
|
|
|
+ 15: 'DPAD_RIGHT',
|
|
|
+ 16: 'HOME' // Home button
|
|
|
+ },
|
|
|
+ axes: {
|
|
|
+ 0: 'LEFT_STICK_X',
|
|
|
+ 1: 'LEFT_STICK_Y',
|
|
|
+ 2: 'RIGHT_STICK_X',
|
|
|
+ 3: 'RIGHT_STICK_Y'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ 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;
|
|
|
+
|
|
|
+ // Apply deadzone
|
|
|
+ const processedValue = this.applyDeadzone(axisValue);
|
|
|
+ const previousValue = previousState.axes[axisName] || 0;
|
|
|
+
|
|
|
+ currentState.axes[axisName] = processedValue;
|
|
|
+
|
|
|
+ // Only emit events if value changed significantly
|
|
|
+ if (Math.abs(processedValue - previousValue) > 0.01) {
|
|
|
+ this.emit('axischange', {
|
|
|
+ gamepadIndex: gamepad.index,
|
|
|
+ axis: axisName,
|
|
|
+ value: processedValue,
|
|
|
+ rawValue: axisValue
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+}
|