Files
NebulaBrowser/gamepad-handler.js
T
2025-12-30 19:48:04 +13:00

454 lines
12 KiB
JavaScript

/**
* Nebula Browser - Global Gamepad Input Handler (Standalone Reference)
*
* NOTE: This is a standalone reference implementation. The actual gamepad handler
* used by Nebula is integrated directly into preload.js for proper context isolation
* compatibility. This file is kept for reference and potential future use.
*
* This module actively polls and consumes gamepad input from the Gamepad API.
* This is CRITICAL for Steam Deck/SteamOS Game Mode:
*
* Steam only stops applying Desktop mouse emulation when:
* - The application actively reads controller/gamepad input, OR
* - Steam Input is enabled (which requires explicit configuration)
*
* If the app does not read controller input at all, Steam assumes the user
* needs mouse emulation. By continuously polling navigator.getGamepads(),
* Steam recognizes that the app is consuming gamepad events and backs off
* the Desktop mouse emulation layer.
*
* This module should be loaded as early as possible in the renderer process.
*/
(function() {
'use strict';
// Prevent double initialization
if (window.__nebulaGamepadHandler) {
return;
}
const CONFIG = {
// Polling rate in ms (60fps = ~16ms, we use requestAnimationFrame)
POLL_INTERVAL: 16,
// Deadzone for analog sticks
STICK_DEADZONE: 0.15,
TRIGGER_DEADZONE: 0.1,
// Enable debug logging
DEBUG: false,
};
// Global state
const state = {
initialized: false,
gamepads: {},
connectedCount: 0,
activeGamepadIndex: null,
lastPollTime: 0,
rafId: null,
// Button states for edge detection
buttonStates: {},
// Callbacks for interested listeners
listeners: {
connect: [],
disconnect: [],
button: [],
axis: [],
input: [], // Any input (for keeping the polling "active")
},
};
// Debug logger
const log = (...args) => {
if (CONFIG.DEBUG) {
console.log('[NebulaGamepad]', ...args);
}
};
/**
* Initialize the gamepad handler.
* This should be called as early as possible.
*/
function init() {
if (state.initialized) {
log('Already initialized');
return;
}
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
console.warn('[NebulaGamepad] Gamepad API not available');
return;
}
log('Initializing gamepad handler');
// Listen for connect/disconnect events
window.addEventListener('gamepadconnected', handleGamepadConnected);
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected);
// Do an initial scan for already-connected gamepads
// (important for Steam Deck where the controller is always connected)
scanGamepads();
// Start the polling loop immediately
// This is the KEY part: continuously polling getGamepads() signals to Steam
// that we're actively consuming gamepad input
startPolling();
state.initialized = true;
log('Gamepad handler initialized');
// Expose debug info
if (CONFIG.DEBUG) {
window.__nebulaGamepadDebug = {
state,
getActiveGamepad,
getConnectedGamepads,
};
}
}
/**
* Handle gamepad connection event
*/
function handleGamepadConnected(event) {
const gamepad = event.gamepad;
log('Gamepad connected:', gamepad.index, gamepad.id);
state.gamepads[gamepad.index] = {
id: gamepad.id,
index: gamepad.index,
connected: true,
mapping: gamepad.mapping,
timestamp: Date.now(),
};
state.connectedCount++;
// Set as active if we don't have one
if (state.activeGamepadIndex === null) {
state.activeGamepadIndex = gamepad.index;
log('Set active gamepad:', gamepad.index);
}
// Initialize button states for this gamepad
state.buttonStates[gamepad.index] = {};
// Notify listeners
emitEvent('connect', { gamepad, index: gamepad.index, id: gamepad.id });
}
/**
* Handle gamepad disconnection event
*/
function handleGamepadDisconnected(event) {
const gamepad = event.gamepad;
log('Gamepad disconnected:', gamepad.index, gamepad.id);
if (state.gamepads[gamepad.index]) {
state.gamepads[gamepad.index].connected = false;
delete state.gamepads[gamepad.index];
state.connectedCount--;
}
// Clear button states
delete state.buttonStates[gamepad.index];
// If this was the active gamepad, find another
if (state.activeGamepadIndex === gamepad.index) {
state.activeGamepadIndex = null;
// Try to find another connected gamepad
const gamepads = navigator.getGamepads();
for (let i = 0; i < gamepads.length; i++) {
if (gamepads[i]) {
state.activeGamepadIndex = i;
log('Switched active gamepad to:', i);
break;
}
}
}
// Notify listeners
emitEvent('disconnect', { index: gamepad.index, id: gamepad.id });
}
/**
* Scan for already-connected gamepads
* This is important because on Linux/Steam Deck, the gamepadconnected event
* may not fire until the first button press
*/
function scanGamepads() {
const gamepads = navigator.getGamepads();
for (let i = 0; i < gamepads.length; i++) {
const gamepad = gamepads[i];
if (gamepad && !state.gamepads[gamepad.index]) {
log('Found pre-connected gamepad:', gamepad.index, gamepad.id);
state.gamepads[gamepad.index] = {
id: gamepad.id,
index: gamepad.index,
connected: true,
mapping: gamepad.mapping,
timestamp: Date.now(),
};
state.connectedCount++;
if (state.activeGamepadIndex === null) {
state.activeGamepadIndex = gamepad.index;
}
state.buttonStates[gamepad.index] = {};
}
}
}
/**
* Start the gamepad polling loop
* Uses requestAnimationFrame for efficient, consistent polling
*/
function startPolling() {
if (state.rafId !== null) {
return; // Already polling
}
function pollLoop(timestamp) {
state.lastPollTime = timestamp;
// CRITICAL: This call to getGamepads() is what tells Steam we're
// actively consuming gamepad input
const gamepads = navigator.getGamepads();
// Process input from all connected gamepads
for (let i = 0; i < gamepads.length; i++) {
const gamepad = gamepads[i];
if (gamepad) {
processGamepadInput(gamepad);
}
}
// Also do periodic scans for newly connected gamepads
// (handles edge case where event doesn't fire)
if (timestamp % 1000 < 20) {
scanGamepads();
}
// Continue polling
state.rafId = requestAnimationFrame(pollLoop);
}
state.rafId = requestAnimationFrame(pollLoop);
log('Started gamepad polling');
}
/**
* Stop the polling loop (called on page unload)
*/
function stopPolling() {
if (state.rafId !== null) {
cancelAnimationFrame(state.rafId);
state.rafId = null;
log('Stopped gamepad polling');
}
}
/**
* Process input from a gamepad
*/
function processGamepadInput(gamepad) {
const index = gamepad.index;
const buttonState = state.buttonStates[index] || {};
let hasInput = false;
// Process buttons
for (let i = 0; i < gamepad.buttons.length; i++) {
const button = gamepad.buttons[i];
const wasPressed = buttonState[`b${i}`] || false;
const isPressed = button.pressed || button.value > 0.5;
if (isPressed !== wasPressed) {
buttonState[`b${i}`] = isPressed;
hasInput = true;
emitEvent('button', {
gamepad,
index,
button: i,
pressed: isPressed,
value: button.value,
});
log(`Button ${i}: ${isPressed ? 'pressed' : 'released'}`);
}
}
// Process axes (analog sticks, triggers)
for (let i = 0; i < gamepad.axes.length; i++) {
const value = gamepad.axes[i];
const prevValue = buttonState[`a${i}`] || 0;
// Only emit if there's significant change
if (Math.abs(value - prevValue) > 0.01) {
buttonState[`a${i}`] = value;
// Check if beyond deadzone
if (Math.abs(value) > CONFIG.STICK_DEADZONE) {
hasInput = true;
emitEvent('axis', {
gamepad,
index,
axis: i,
value,
});
}
}
}
state.buttonStates[index] = buttonState;
// Emit generic input event if any input detected
if (hasInput) {
emitEvent('input', { gamepad, index });
}
}
/**
* Emit an event to registered listeners
*/
function emitEvent(type, data) {
const listeners = state.listeners[type] || [];
for (const listener of listeners) {
try {
listener(data);
} catch (err) {
console.error('[NebulaGamepad] Listener error:', err);
}
}
}
/**
* Register a listener for gamepad events
* @param {string} type - Event type: 'connect', 'disconnect', 'button', 'axis', 'input'
* @param {function} callback - Callback function
* @returns {function} Unsubscribe function
*/
function on(type, callback) {
if (!state.listeners[type]) {
state.listeners[type] = [];
}
state.listeners[type].push(callback);
return () => {
const idx = state.listeners[type].indexOf(callback);
if (idx !== -1) {
state.listeners[type].splice(idx, 1);
}
};
}
/**
* Get the currently active gamepad
* @returns {Gamepad|null}
*/
function getActiveGamepad() {
if (state.activeGamepadIndex === null) {
return null;
}
const gamepads = navigator.getGamepads();
return gamepads[state.activeGamepadIndex] || null;
}
/**
* Get all connected gamepads
* @returns {Gamepad[]}
*/
function getConnectedGamepads() {
const gamepads = navigator.getGamepads();
return Array.from(gamepads).filter(gp => gp !== null);
}
/**
* Check if any gamepad is connected
* @returns {boolean}
*/
function isGamepadConnected() {
return state.connectedCount > 0;
}
/**
* Set the active gamepad by index
* @param {number} index
*/
function setActiveGamepad(index) {
const gamepads = navigator.getGamepads();
if (gamepads[index]) {
state.activeGamepadIndex = index;
log('Active gamepad set to:', index);
return true;
}
return false;
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
stopPolling();
window.removeEventListener('gamepadconnected', handleGamepadConnected);
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected);
});
// Pause polling when page is hidden to save resources
// but not for too long - we still want Steam to see we're active
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Continue polling but at a slower rate when hidden
// We don't stop entirely because Steam needs to see we're consuming input
log('Page hidden, continuing polling');
} else {
log('Page visible');
}
});
// Export the API
const gamepadHandler = {
init,
on,
getActiveGamepad,
getConnectedGamepads,
isGamepadConnected,
setActiveGamepad,
// Expose state for debugging
get state() {
return { ...state, buttonStates: { ...state.buttonStates } };
},
// Config
get config() {
return { ...CONFIG };
},
setDebug(enabled) {
CONFIG.DEBUG = !!enabled;
},
};
// Mark as initialized and expose globally
window.__nebulaGamepadHandler = gamepadHandler;
// Auto-initialize when DOM is ready (or immediately if already loaded)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// DOM already loaded, initialize immediately
init();
}
log('Gamepad handler module loaded');
})();