gamepad-helper.jsjavascript
/**
 * Gamepad Helper Module
 * This module provides a set of utilities for working with gamepads in web applications.
 */

const {
    GamepadVisualizer,
    getControllerImagePath,
    getControllerVisualConfig,
} = require('./gamepad-visualizer');

const FIREFOX_SWITCH_GAMEPAD_FIXED_VERSION = 155;
const FIREFOX_SWITCH_GAMEPAD_ISSUE_URL = 'https://bugzilla.mozilla.org/show_bug.cgi?id=1704419';

/**
 * Controller identity metadata used by browser ID lookups.
 * @typedef {Object} GamepadIdentityMapping
 * @property {string} name - Human-readable controller name
 * @property {string[]} gamepad_api_ids - Known complete Gamepad API IDs
 * @property {string[]} [vendor_product_ids] - Additional normalized `vendor:product` IDs
 * @property {string} type - Controller type
 */
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, GamepadIdentityMapping>}
         */
        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",
                ],
                vendor_product_ids: ["1209:0001"],
                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)",
                ],
                vendor_product_ids: ["045e:028e"],
                type: this.CONTROLLER_TYPES.XBOX
            },
            6: {
                name: "Xbox One/Series",
                gamepad_api_ids: [
                    "HID-compliant game controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)",
                ],
                vendor_product_ids: ["045e:02ea", "045e:0b12", "045e:0b13", "045e:0b20"],
                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, GamepadIdentityMapping>}
         */
        this.exactIdLookup = {};

        /**
         * Vendor/product lookup used when browser-specific product names differ.
         * @type {Object.<string, GamepadIdentityMapping>}
         */
        this.vendorProductLookup = {};
        Object.values(this.exactGamepadMappings).forEach(mapping => {
            mapping.gamepad_api_ids.forEach(id => {
                this.exactIdLookup[id] = mapping;

                const vendorProductId = this.extractVendorProductId(id);
                if (vendorProductId) {
                    this.vendorProductLookup[vendorProductId] = mapping;
                }
            });

            mapping.vendor_product_ids?.forEach(vendorProductId => {
                this.vendorProductLookup[vendorProductId] = 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',
                    17: 'Share',
                },
                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',
                    17: 'Share.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
        const imageMapping = imageMappings[controllerType];
        if (imageMapping?.buttons[buttonIndex]) {
            return basePath + imageMapping.folder + imageMapping.buttons[buttonIndex];
        }

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

    /**
     * Get the image path for a full controller visual.
     * @param {string} controllerType - The type of controller (XBOX, PLAYSTATION, SWITCH)
     * @param {string} [basePath='/assets/img/gamepads/'] - The base path for the images
     * @param {string} [colorScheme='White'] - The image color ('Black' or 'White')
     * @returns {string|null} Encoded controller image path, or null when unavailable
     */
    getControllerImagePath(
        controllerType,
        basePath = '/assets/img/gamepads/',
        colorScheme = 'White',
    ) {
        return getControllerImagePath(controllerType, basePath, colorScheme);
    }

    /**
     * Get a copy of the visual definition for a controller type.
     * @param {string} controllerType - The type of controller (XBOX, PLAYSTATION, SWITCH)
     * @returns {Object|null} Controller visual definition, or null when unavailable
     */
    getControllerVisualConfig(controllerType) {
        return getControllerVisualConfig(controllerType);
    }

    /**
     * Create a reusable visualizer in a caller-provided DOM container.
     * @param {Element} container - DOM element that will contain the visual
     * @param {Object} [options] - Visualizer options
     * @returns {GamepadVisualizer} Controller visualizer
     */
    createVisualizer(container, options = {}) {
        return new GamepadVisualizer(this, container, options);
    }

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

    /**
     * Extract a normalized vendor/product identifier from a browser Gamepad API ID.
     * @param {string|null} gamepadId - The ID of the gamepad as given by the Gamepad API
     * @returns {string|null} A lower-case, zero-padded `vendor:product` identifier
     */
    extractVendorProductId(gamepadId) {
        if (!gamepadId) {
            return null;
        }

        const chromiumMatch = /Vendor:\s*([\da-f]{1,4})\s+Product:\s*([\da-f]{1,4})/i.exec(gamepadId);
        const firefoxMatch = /^([\da-f]{1,4})-([\da-f]{1,4})-/i.exec(gamepadId);
        const match = chromiumMatch || firefoxMatch;

        if (!match) {
            return null;
        }

        const vendorId = match[1].padStart(4, '0').toLowerCase();
        const productId = match[2].padStart(4, '0').toLowerCase();
        return `${vendorId}:${productId}`;
    }

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

        const vendorProductId = this.extractVendorProductId(gamepadId);
        const vendorProductMatch = vendorProductId ? this.vendorProductLookup[vendorProductId] : null;
        if (vendorProductMatch) {
            return {
                type: vendorProductMatch.type,
                name: vendorProductMatch.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;
    }

    /**
     * Extract the major Firefox version from a user-agent string.
     * @param {string|null} userAgent - Browser user-agent string
     * @returns {number|null} Firefox major version, or null for other browsers
     */
    getFirefoxMajorVersion(userAgent) {
        if (typeof userAgent !== 'string') {
            return null;
        }

        const match = /\bFirefox\/(\d+)/.exec(userAgent);
        return match ? Number.parseInt(match[1], 10) : null;
    }

    /**
     * Get known browser/controller compatibility issues for a gamepad.
     * @param {Gamepad|null} gamepad - Gamepad to inspect
     * @param {Object} [options] - Detection options
     * @param {string} [options.userAgent=navigator.userAgent] - Browser user-agent string
     * @returns {Object[]} Structured compatibility issues
     */
    getCompatibilityIssues(gamepad, options = {}) {
        if (!gamepad) {
            return [];
        }

        const userAgent = options.userAgent ?? globalThis.navigator?.userAgent ?? '';
        const firefoxVersion = this.getFirefoxMajorVersion(userAgent);
        const controllerInfo = this.getGamepadInfo(gamepad.id);
        if (
            firefoxVersion === null
            || firefoxVersion >= FIREFOX_SWITCH_GAMEPAD_FIXED_VERSION
            || controllerInfo.type !== this.CONTROLLER_TYPES.SWITCH
        ) {
            return [];
        }

        return [{
            code: 'firefox-switch-gamepad-mapping',
            severity: 'warning',
            browser: 'firefox',
            browserVersion: firefoxVersion,
            controllerType: controllerInfo.type,
            fixedVersion: FIREFOX_SWITCH_GAMEPAD_FIXED_VERSION,
            issueUrl: FIREFOX_SWITCH_GAMEPAD_ISSUE_URL,
            message: `Firefox versions before ${FIREFOX_SWITCH_GAMEPAD_FIXED_VERSION} can report incorrect buttons and axes for Nintendo Switch controllers.`,
        }];
    }

    /**
     * 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?.[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?.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?.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?.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?.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 (globalThis.window) {
    globalThis.GamepadHelper = GamepadHelper;
}

GamepadHelper.GamepadVisualizer = GamepadVisualizer;

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