feat: initial romm web ui architecture, bento grids, and spatial gamepad bindings
This commit is contained in:
19
src/hooks/useFocusableAutoScroll.ts
Normal file
19
src/hooks/useFocusableAutoScroll.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFocusable, UseFocusableConfig } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useInputMode } from '../context/InputModeContext';
|
||||
|
||||
export const useFocusableAutoScroll = (config?: UseFocusableConfig) => {
|
||||
const result = useFocusable(config);
|
||||
const { mode } = useInputMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'gamepad' && result.focused && result.ref.current) {
|
||||
result.ref.current.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
||||
}
|
||||
}, [result.focused, mode]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
focused: mode === 'gamepad' ? result.focused : false
|
||||
};
|
||||
};
|
||||
110
src/hooks/useGamepad.ts
Normal file
110
src/hooks/useGamepad.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { navigateByDirection } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useInputMode } from '../context/InputModeContext';
|
||||
|
||||
const BUTTON_A = 0;
|
||||
const BUTTON_B = 1;
|
||||
const BUTTON_DPAD_UP = 12;
|
||||
const BUTTON_DPAD_DOWN = 13;
|
||||
const BUTTON_DPAD_LEFT = 14;
|
||||
const BUTTON_DPAD_RIGHT = 15;
|
||||
const AXIS_THRESHOLD = 0.5;
|
||||
const INITIAL_DELAY_MS = 400;
|
||||
const REPEAT_RATE_MS = 100;
|
||||
|
||||
export const useGamepad = () => {
|
||||
const requestRef = useRef<number>();
|
||||
const lastState = useRef<Record<string, boolean>>({});
|
||||
const lastFireTime = useRef<Record<string, number>>({});
|
||||
const { setMode } = useInputMode();
|
||||
|
||||
const handleInput = (id: string, isPressed: boolean, action: () => void, repeatable = true) => {
|
||||
const now = performance.now();
|
||||
const wasPressed = lastState.current[id];
|
||||
|
||||
if (isPressed) {
|
||||
setMode('gamepad');
|
||||
if (!wasPressed) {
|
||||
// Initial press isolated
|
||||
action();
|
||||
lastFireTime.current[id] = now;
|
||||
lastState.current[id] = true;
|
||||
} else if (repeatable) {
|
||||
// Holding press calculates repeat boundaries natively
|
||||
const holdTime = now - lastFireTime.current[id];
|
||||
if (holdTime > INITIAL_DELAY_MS) {
|
||||
action();
|
||||
// Reset tracker mathematically for consecutive rapid fires
|
||||
lastFireTime.current[id] = now - INITIAL_DELAY_MS + REPEAT_RATE_MS;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (wasPressed) {
|
||||
lastFireTime.current[id] = 0;
|
||||
lastState.current[id] = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchEnter = () => {
|
||||
const eventParams = { bubbles: true, cancelable: true };
|
||||
const keydown = new window.KeyboardEvent('keydown', eventParams);
|
||||
Object.defineProperty(keydown, 'keyCode', { get: () => 13 });
|
||||
Object.defineProperty(keydown, 'key', { get: () => 'Enter' });
|
||||
document.dispatchEvent(keydown);
|
||||
window.dispatchEvent(keydown);
|
||||
};
|
||||
|
||||
const dispatchEscape = () => {
|
||||
const eventParams = { bubbles: true, cancelable: true };
|
||||
const keydown = new window.KeyboardEvent('keydown', eventParams);
|
||||
Object.defineProperty(keydown, 'keyCode', { get: () => 27 });
|
||||
Object.defineProperty(keydown, 'key', { get: () => 'Escape' });
|
||||
document.dispatchEvent(keydown);
|
||||
window.dispatchEvent(keydown);
|
||||
};
|
||||
|
||||
const checkGamepad = () => {
|
||||
try {
|
||||
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
|
||||
// Multiplex all gamepads to defeat Ghost Virtual Devices hogs natively occupying slot 0
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
const gp = gamepads[i];
|
||||
if (!gp) continue;
|
||||
|
||||
const up = gp.buttons[BUTTON_DPAD_UP]?.pressed || (gp.axes[1] !== undefined && gp.axes[1] < -AXIS_THRESHOLD);
|
||||
const down = gp.buttons[BUTTON_DPAD_DOWN]?.pressed || (gp.axes[1] !== undefined && gp.axes[1] > AXIS_THRESHOLD);
|
||||
const left = gp.buttons[BUTTON_DPAD_LEFT]?.pressed || (gp.axes[0] !== undefined && gp.axes[0] < -AXIS_THRESHOLD);
|
||||
const right = gp.buttons[BUTTON_DPAD_RIGHT]?.pressed || (gp.axes[0] !== undefined && gp.axes[0] > AXIS_THRESHOLD);
|
||||
const enter = gp.buttons[BUTTON_A]?.pressed;
|
||||
const back = gp.buttons[BUTTON_B]?.pressed;
|
||||
|
||||
handleInput(`gp${i}_up`, up, () => navigateByDirection('up', {}));
|
||||
handleInput(`gp${i}_down`, down, () => navigateByDirection('down', {}));
|
||||
handleInput(`gp${i}_left`, left, () => navigateByDirection('left', {}));
|
||||
handleInput(`gp${i}_right`, right, () => navigateByDirection('right', {}));
|
||||
handleInput(`gp${i}_enter`, enter, () => dispatchEnter(), false);
|
||||
handleInput(`gp${i}_back`, back, () => dispatchEscape(), false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Gamepad Polling Error", e);
|
||||
}
|
||||
|
||||
requestRef.current = requestAnimationFrame(checkGamepad);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onConnect = () => console.log("Gamepad Connected Native Callback!");
|
||||
const onDisconnect = () => console.log("Gamepad Disconnected Native Callback!");
|
||||
window.addEventListener("gamepadconnected", onConnect);
|
||||
window.addEventListener("gamepaddisconnected", onDisconnect);
|
||||
|
||||
requestRef.current = requestAnimationFrame(checkGamepad);
|
||||
return () => {
|
||||
if (requestRef.current) cancelAnimationFrame(requestRef.current);
|
||||
window.removeEventListener("gamepadconnected", onConnect);
|
||||
window.removeEventListener("gamepaddisconnected", onDisconnect);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
Reference in New Issue
Block a user