gamepad-helper.js

/**
 * Gamepad Helper Module
 * This module provides a set of utilities for working with gamepads in web applications.
 */
class GamepadHelper {
    constructor() {
        /**
         * Controller Types
         * @type {Object}
         * @property {string} XBOX - Xbox family of controllers
         * @property {string} PLAYSTATION - PlayStation family of controllers
         * @property {string} SWITCH - Nintendo Switch controllers
         * @property {string} STANDARD - Generic or standard controllers
         */
        this.CONTROLLER_TYPES = {
            XBOX: 'xbox',
            PLAYSTATION: 'playstation',
            SWITCH: 'switch',
            STANDARD: 'standard'
        };

        /**
         * Exact Gamepad Mappings
         * This object maps specific gamepad API IDs to controller types and names.
         * @type {Object.<number, {name: string, gamepad_api_ids: string[], type: string}>}
         */
        this.exactGamepadMappings = {
            0: {
                name: "Generic Gamepad",
                gamepad_api_ids: [
                    "USB Gamepad (STANDARD GAMEPAD Vendor: 0079 Product: 0011)",
                    "Logitech Cordless RumblePad 2 (STANDARD GAMEPAD Vendor: 046d Product: c219)",
                    "Unknown Gamepad (Vendor: 2563 Product: 0575)",
                    "PC/PS3/Android (Vendor: 2563 Product: 0575)",
                    "Core (Plus) Wired Controller (Vendor: 20d6 Product: a711)",
                    "Wireless Controller Extended Gamepad",
                ],
                type: this.CONTROLLER_TYPES.STANDARD,
            },
            1: {
                name: "Sony PlayStation 3",
                gamepad_api_ids: [
                    "54c-268-PLAYSTATION(R)3 Controller",
                    "PLAYSTATION(R)3 Controller (STANDARD GAMEPAD Vendor: 054c Product: 0268)",
                    "PLAYSTATION(R)3 Controller (Vendor: 054c Product: 0268)",
                    "PS3 GamePad (Vendor: 054c Product: 0268)",
                    "PS3/PC Wired GamePad (Vendor: 2563 Product: 0523)",
                ],
                type: this.CONTROLLER_TYPES.PLAYSTATION
            },
            2: {
                name: "Sony DualShock (PS4)",
                gamepad_api_ids: [
                    "054c-05c4-Wireless Controller",
                    "Wireless controller (STANDARD GAMEPAD Vendor: 054c Product: 05c4)",
                    "054c-09cc-Unknown Gamepad",
                    "Unknown Gamepad (STANDARD GAMEPAD Vendor: 054c Product: 09cc)",
                    "DS4 Wired Controller (Vendor: 7545 Product: 0104)",
                ],
                type: this.CONTROLLER_TYPES.PLAYSTATION
            },
            3: {
                name: "Sony DualSense (PS5)",
                gamepad_api_ids: [
                    "054c-0ce6-Wireless Controller",
                    "Wireless Controller (Vendor: 054c Product: 0ce6)",
                    "Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)",
                ],
                type: this.CONTROLLER_TYPES.PLAYSTATION
            },
            4: {
                name: "Xbox",
                gamepad_api_ids: [
                    "xinput",
                    "Xbox Wireless Controller Extended Gamepad",
                    "Xbox Wireless Controller",
                ],
                type: this.CONTROLLER_TYPES.XBOX
            },
            5: {
                name: "Xbox 360",
                gamepad_api_ids: [
                    "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
                ],
                type: this.CONTROLLER_TYPES.XBOX
            },
            6: {
                name: "Xbox One/Series",
                gamepad_api_ids: [
                    "HID-compliant game controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)",
                ],
                type: this.CONTROLLER_TYPES.XBOX
            },
            7: {
                name: "Nintendo Switch Pro Controller",
                gamepad_api_ids: [
                    "Pro Controller (STANDARD GAMEPAD Vendor: 057e Product: 2009)",
                ],
                type: this.CONTROLLER_TYPES.SWITCH
            },
            8: {
                name: "Stadia Controller",
                gamepad_api_ids: [
                    "Stadia Controller rev. A (STANDARD GAMEPAD Vendor: 18d1 Product: 9400)",
                ],
                type: this.CONTROLLER_TYPES.STANDARD,
            },
            9: {
                name: "SNES Gamepad",
                gamepad_api_ids: [
                    "usb gamepad (Vendor: 0810 Product: e501)",
                ],
                type: this.CONTROLLER_TYPES.STANDARD,
            }
        };

        /**
         * Exact ID Lookup
         * This object maps gamepad API IDs to their respective controller mappings.
         * @type {Object.<string, {name: string, gamepad_api_ids: string[], type: string}>}
         */
        this.exactIdLookup = {};
        Object.values(this.exactGamepadMappings).forEach(mapping => {
            mapping.gamepad_api_ids.forEach(id => {
                this.exactIdLookup[id] = mapping;
            });
        });

        /**
         * Controller Mappings
         * Maps controller types to their respective button and axis mappings.
         * @type {Object.<string, {buttonMap: Object.<number, string>, axisMap: Object.<number, string>}>}
         */
        this.controllerMappings = {
            // Xbox controllers
            [this.CONTROLLER_TYPES.XBOX]: {
                buttonMap: {
                    0: 'A',
                    1: 'B',
                    2: 'X',
                    3: 'Y',
                    4: 'LB',
                    5: 'RB',
                    6: 'LT',
                    7: 'RT',
                    8: 'Back',
                    9: 'Start',
                    10: 'LS',
                    11: 'RS',
                    12: 'DUp',
                    13: 'DDown',
                    14: 'DLeft',
                    15: 'DRight',
                    16: 'Home',
                },
                axisMap: {
                    0: 'Left Stick X',
                    1: 'Left Stick Y',
                    2: 'Right Stick X',
                    3: 'Right Stick Y'
                }
            },

            // PlayStation controllers
            [this.CONTROLLER_TYPES.PLAYSTATION]: {
                buttonMap: {
                    0: '×',
                    1: '○',
                    2: '□',
                    3: '△',
                    4: 'L1',
                    5: 'R1',
                    6: 'L2',
                    7: 'R2',
                    8: 'Share',
                    9: 'Options',
                    10: 'L3',
                    11: 'R3',
                    12: 'DUp',
                    13: 'DDown',
                    14: 'DLeft',
                    15: 'DRight',
                    16: 'PS',
                    17: 'TouchPad',
                },
                axisMap: {
                    0: 'Left Stick X',
                    1: 'Left Stick Y',
                    2: 'Right Stick X',
                    3: 'Right Stick Y'
                }
            },

            // Nintendo Switch controllers
            [this.CONTROLLER_TYPES.SWITCH]: {
                buttonMap: {
                    0: 'B',
                    1: 'A',
                    2: 'Y',
                    3: 'X',
                    4: 'L',
                    5: 'R',
                    6: 'ZL',
                    7: 'ZR',
                    8: 'Minus',
                    9: 'Plus',
                    10: 'LS',
                    11: 'RS',
                    12: 'DUp',
                    13: 'DDown',
                    14: 'DLeft',
                    15: 'DRight',
                    16: 'Home',
                    17: 'Capture',
                },
                axisMap: {
                    0: 'Left Stick X',
                    1: 'Left Stick Y',
                    2: 'Right Stick X',
                    3: 'Right Stick Y'
                }
            },

            // Default mapping for unknown controllers
            [this.CONTROLLER_TYPES.STANDARD]: {
                buttonMap: {},  // Will use index numbers by default
                axisMap: {
                    0: 'Axis 0',
                    1: 'Axis 1',
                    2: 'Axis 2',
                    3: 'Axis 3'
                }
            }
        };
    }

    /**
     * Get the image path for a specific button on a controller
     * @param {string} controllerType - The type of controller (XBOX, PLAYSTATION, SWITCH)
     * @param {number} buttonIndex - The index of the button
     * @param {string} [basePath='/assets/img/gamepads/'] - The base path for the images
     * @param {string} [buttonColor='White'] - The color of the button ('Black' or 'White')
     * @param {string} [buttonType='Outline'] - The type of button ('Outline', 'Solid', 'Full Solid')
     * @returns {string|null} The path to the button image or null if not found
     */
    getButtonImagePath(
        controllerType,
        buttonIndex,
        basePath = '/assets/img/gamepads/',
        buttonColor = 'White',
        buttonType = 'Outline',
    ) {
        // input validation
        if (!['Black', 'White'].includes(buttonColor)) {
            console.warn(`Invalid buttonColor: ${buttonColor}. Using 'White' instead.`);
            buttonColor = 'White';
        }

        if (!['Outline', 'Solid', 'Full Solid'].includes(buttonType)) {
            console.warn(`Invalid buttonType: ${buttonType}. Using 'Outline' instead.`);
            buttonType = 'Outline';
        }

        // Controller-specific paths and button mappings
        const imageMappings = {
            [this.CONTROLLER_TYPES.XBOX]: {
                folder: `xbox/Buttons ${buttonType}/${buttonColor}/SVG/`,
                buttons: {
                    0: 'A.svg',
                    1: 'B.svg',
                    2: 'X.svg',
                    3: 'Y.svg',
                    4: 'Left Bumper.svg',
                    5: 'Right Bumper.svg',
                    6: 'Left Trigger.svg',
                    7: 'Right Trigger.svg',
                    8: 'View.svg',
                    9: 'Menu.svg',
                    10: 'Left Stick.svg',
                    11: 'Right Stick.svg',
                    12: 'D-Pad Up.svg',
                    13: 'D-Pad Down.svg',
                    14: 'D-Pad Left.svg',
                    15: 'D-Pad Right.svg',
                    16: 'Home.svg'
                }
            },
            [this.CONTROLLER_TYPES.PLAYSTATION]: {
                folder: `playstation/Buttons ${buttonType}/${buttonColor}/SVG/`,
                buttons: {
                    0: 'Cross.svg',
                    1: 'Circle.svg',
                    2: 'Square.svg',
                    3: 'Triangle.svg',
                    4: 'L1.svg',
                    5: 'R1.svg',
                    6: 'L2.svg',
                    7: 'R2.svg',
                    8: 'Create.svg',
                    9: 'Options.svg',
                    10: 'Left Stick.svg',
                    11: 'Right Stick.svg',
                    12: 'D-Pad Up.svg',
                    13: 'D-Pad Down.svg',
                    14: 'D-Pad Left.svg',
                    15: 'D-Pad Right.svg',
                    16: 'Home.svg',
                    17: 'Touch Pad Press.svg'
                }
            },
            [this.CONTROLLER_TYPES.SWITCH]: {
                folder: `switch/Buttons ${buttonType}/${buttonColor}/SVG/`,
                buttons: {
                    0: 'B.svg',
                    1: 'A.svg',
                    2: 'Y.svg',
                    3: 'X.svg',
                    4: 'L.svg',
                    5: 'R.svg',
                    6: 'ZL.svg',
                    7: 'ZR.svg',
                    8: 'Minus.svg',
                    9: 'Plus.svg',
                    10: 'Left Stick.svg',
                    11: 'Right Stick.svg',
                    12: 'Pro D-Pad Up.svg',
                    13: 'Pro D-Pad Down.svg',
                    14: 'Pro D-Pad Left.svg',
                    15: 'Pro D-Pad Right.svg',
                    16: 'Home.svg',
                    17: 'Capture.svg'
                }
            }
        };

        // Check if we have an image mapping for this controller and button
        if (imageMappings[controllerType] && imageMappings[controllerType].buttons[buttonIndex]) {
            return basePath + imageMappings[controllerType].folder + imageMappings[controllerType].buttons[buttonIndex];
        }

        // Return null if no image is available
        return null;
    }

    /**
     * Check if the Gamepad API is supported in the current browser
     * @returns {boolean} True if supported, false otherwise
     */
    isSupported() {
        return !!navigator.getGamepads;
    }

    /**
     * Get gamepad information based on the gamepad ID
     * @param {string|null} gamepadId - The ID of the gamepad as given by the Gamepad API
     * @returns {{type: string, name: string}} Controller type and name information
     */
    getGamepadInfo(gamepadId) {
        if (!gamepadId) {
            return {
                type: this.CONTROLLER_TYPES.STANDARD,
                name: 'Generic Controller'
            };
        }

        // Check for exact match first
        const exactMatch = this.exactIdLookup[gamepadId];
        if (exactMatch) {
            return {
                type: exactMatch.type,
                name: exactMatch.name
            };
        }

        return {
            type: this.CONTROLLER_TYPES.STANDARD,
            name: 'Generic Controller'
        };
    }

    /**
     * Detect the controller type based on the gamepad ID
     * @param {string|null} gamepadId - The ID of the gamepad as given by the Gamepad API
     * @returns {string} The type of controller (XBOX, PLAYSTATION, SWITCH, STANDARD)
     */
    detectControllerType(gamepadId) {
        return this.getGamepadInfo(gamepadId).type;
    }

    /**
     * Get button name for given controller type and button index
     * @param {string} controllerType - The type of controller (XBOX, PLAYSTATION, SWITCH, STANDARD)
     * @param {number} buttonIndex - The index of the button
     * @returns {string} The name of the button (e.g., 'A', 'B', 'X', etc.)
     */
    getButtonName(controllerType, buttonIndex) {
        const mapping = this.controllerMappings[controllerType] || this.controllerMappings[this.CONTROLLER_TYPES.STANDARD];
        return mapping.buttonMap[buttonIndex] || `B${buttonIndex}`;
    }

    /**
     * Get axis name for given controller type and axis index
     * @param {string} controllerType - The type of controller (XBOX, PLAYSTATION, SWITCH, STANDARD)
     * @param {number} axisIndex - The index of the axis
     * @returns {string} The name of the axis (e.g., 'Left Stick X', 'Right Stick Y', etc.)
     */
    getAxisName(controllerType, axisIndex) {
        const mapping = this.controllerMappings[controllerType] || this.controllerMappings[this.CONTROLLER_TYPES.STANDARD];
        return mapping.axisMap && mapping.axisMap[axisIndex] || `Axis ${axisIndex}`;
    }

    /**
     * Check if vibration is supported on the gamepad
     * @param {Gamepad|null} gamepad - The gamepad object from the Gamepad API
     * @returns {boolean} True if vibration is supported, false otherwise
     */
    isVibrationSupported(gamepad) {
        return !(!gamepad || !gamepad.vibrationActuator);
    }

    /**
     * Get vibration capabilities of the gamepad
     * @param {Gamepad|null} gamepad - The gamepad object from the Gamepad API
     * @returns {{supported: boolean, type: string|null}} Vibration capabilities information
     */
    getVibrationCapabilities(gamepad) {
        if (!gamepad || !gamepad.vibrationActuator) {
            return { supported: false, type: null };
        }

        return {
            supported: true,
            type: gamepad.vibrationActuator.type || 'unknown'
        };
    }

    /**
     * Vibrate the gamepad
     * @param {Gamepad|null} gamepad - The gamepad object from the Gamepad API
     * @param {Object} [options={}] - Options for vibration
     * @param {number} [options.weakMagnitude=0.5] - Weak rumble magnitude (0-1)
     * @param {number} [options.strongMagnitude=0.5] - Strong rumble magnitude (0-1)
     * @param {number} [options.duration=1000] - Duration in milliseconds
     * @param {number} [options.startDelay=0] - Start delay in milliseconds
     * @returns {Promise<GamepadHapticsResult|Error>} Promise that resolves when vibration completes
     */
    vibrate(gamepad, options = {}) {
        const { weakMagnitude = 0.5, strongMagnitude = 0.5, duration = 1000, startDelay = 0 } = options;

        if (!gamepad || !gamepad.vibrationActuator) {
            return Promise.reject(new Error('Vibration not supported on this gamepad'));
        }

        const actuator = gamepad.vibrationActuator;
        const actuatorType = actuator.type || 'unknown';

        // Different handling based on actuator type
        switch (actuatorType) {
            case 'dual-rumble':
                return actuator.playEffect("dual-rumble", {
                    startDelay: startDelay,
                    duration: duration,
                    weakMagnitude: weakMagnitude,
                    strongMagnitude: strongMagnitude
                });

            case 'vibration':
                // Some devices just have a simple vibration effect
                {
                    const magnitude = Math.max(weakMagnitude, strongMagnitude);
                    return actuator.playEffect("vibration", {
                        startDelay: startDelay,
                        duration: duration,
                        magnitude: magnitude
                    });
                }

            default:
                // Try the default effect type for unknown actuators
                try {
                    return actuator.playEffect(actuator.type, {
                        startDelay: startDelay,
                        duration: duration,
                        weakMagnitude: weakMagnitude,
                        strongMagnitude: strongMagnitude,
                        magnitude: Math.max(weakMagnitude, strongMagnitude)
                    });
                } catch (e) {
                    console.warn(`Attempted to use unknown actuator type: ${actuatorType}\nError: ${e}`);
                    // Fallback to dual-rumble as it's the most common
                    return actuator.playEffect("dual-rumble", {
                        startDelay: startDelay,
                        duration: duration,
                        weakMagnitude: weakMagnitude,
                        strongMagnitude: strongMagnitude
                    });
                }
        }
    }

    /**
     * Stop vibration on the gamepad
     * @param {Gamepad|null} gamepad - The gamepad object from the Gamepad API
     * @returns {Promise<GamepadHapticsResult|Error>} Promise that resolves when vibration stops
     */
    stopVibration(gamepad) {
        if (gamepad && gamepad.vibrationActuator) {
            return this.vibrate(gamepad, { weakMagnitude: 0, strongMagnitude: 0 });
        }

        return Promise.reject(new Error('Vibration not supported on this browser or gamepad'));
    }

    /**
     * Get all connected gamepads
     * @returns {Gamepad[]} Array of connected gamepad objects
     */
    getConnectedGamepads() {
        if (!this.isSupported()) return [];

        const gamepads = navigator.getGamepads();
        return Array.from(gamepads).filter(gamepad => gamepad !== null);
    }
}

// Expose to the global scope
if (typeof window !== 'undefined') {
    window.GamepadHelper = GamepadHelper;
}

// Export the GamepadHelper class
module.exports = GamepadHelper;