454 lines
12 KiB
JavaScript
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');
|
|
|
|
})();
|