@threlte/extras
Version:
Utilities, abstractions and plugins for your Threlte apps
458 lines (457 loc) • 19 kB
JavaScript
import { currentWritable, useTask, useThrelte } from '@threlte/core';
import { readButton, readHatDirections, readStick, resolveMapping } from './mappings.js';
const warnedGamepadIds = new Set();
const warnUnknownGamepad = (id) => {
if (warnedGamepadIds.has(id))
return;
warnedGamepadIds.add(id);
console.warn(`[threlte useGamepad] Connected gamepad reports a non-standard mapping and ` +
`no remap is registered for it: "${id}". Button and axis positions may be ` +
`incorrect. Add a custom mapping via useGamepad({ mappings: { ... } }).`);
};
const standardButtons = [
'clusterBottom',
'clusterRight',
'clusterLeft',
'clusterTop',
'leftBumper',
'rightBumper',
'leftTrigger',
'rightTrigger',
'select',
'start',
'leftStickButton',
'rightStickButton',
'directionalTop',
'directionalBottom',
'directionalLeft',
'directionalRight',
'center'
];
const xrButtons = [
'trigger',
'squeeze',
'touchpadButton',
'thumbstickButton',
'clusterBottom',
'clusterTop'
];
const standardAxes = ['leftStick', 'rightStick'];
const xrAxes = ['touchpad', 'thumbstick'];
class GamepadButtonState {
pressed = $state(false);
justPressed = $state(false);
justReleased = $state(false);
touched = $state(false);
value = $state(0);
on;
off;
constructor(events, index) {
this.off = (name, fn) => {
events[index]?.[name]?.delete(fn);
};
this.on = (name, fn) => {
events[index][name] ??= new Set();
events[index][name].add(fn);
return () => {
this.off(name, fn);
};
};
}
}
class GamepadAxisState {
x = $state(0);
y = $state(0);
on;
off;
constructor(events, index) {
this.off = (name, fn) => {
events[index]?.[name]?.delete(fn);
};
this.on = (name, fn) => {
events[index][name] ??= new Set();
events[index][name].add(fn);
return () => this.off(name, fn);
};
}
}
const createButton = (events, index) => new GamepadButtonState(events, index);
const createAxis = (events, index) => new GamepadAxisState(events, index);
const createXrStandard = (allEvents, events) => {
const off = (name, fn) => {
allEvents[name]?.delete(fn);
};
const on = (name, fn) => {
allEvents[name] ??= new Set();
allEvents[name].add(fn);
return () => off(name, fn);
};
const buttons = {
trigger: createButton(events, 0),
squeeze: createButton(events, 1),
touchpadButton: createButton(events, 2),
thumbstickButton: createButton(events, 3),
clusterBottom: createButton(events, 4),
clusterTop: createButton(events, 5)
};
const sticks = {
touchpad: createAxis(events, 6),
thumbstick: createAxis(events, 7)
};
return {
on,
off,
/** The Gamepad connection status */
connected: currentWritable(false),
/** The raw Gamepad object */
raw: null,
/** Get a button by name. */
button: (name) => {
const btn = buttons[name];
if (!btn)
throw new Error(`Unknown XR gamepad button: "${name}"`);
return btn;
},
/** Get a stick by name. */
stick: (name) => {
const s = sticks[name];
if (!s)
throw new Error(`Unknown XR gamepad stick: "${name}"`);
return s;
},
/** @deprecated Use `button('trigger')` instead */
trigger: buttons.trigger,
/** @deprecated Use `button('squeeze')` instead */
squeeze: buttons.squeeze,
/** @deprecated Use `button('touchpadButton')` instead */
touchpadButton: buttons.touchpadButton,
/** @deprecated Use `button('thumbstickButton')` instead */
thumbstickButton: buttons.thumbstickButton,
/** @deprecated Use `button('clusterBottom')` instead */
clusterBottom: buttons.clusterBottom,
/** @deprecated Use `button('clusterTop')` instead */
clusterTop: buttons.clusterTop,
/** @deprecated Use `stick('touchpad')` instead */
touchpad: sticks.touchpad,
/** @deprecated Use `stick('thumbstick')` instead */
thumbstick: sticks.thumbstick
};
};
const createStandard = (allEvents, events) => {
const off = (name, fn) => {
allEvents[name]?.delete(fn);
};
const on = (name, fn) => {
allEvents[name] ??= new Set();
allEvents[name].add(fn);
return () => off(name, fn);
};
const buttons = {
clusterBottom: createButton(events, 0),
clusterRight: createButton(events, 1),
clusterLeft: createButton(events, 2),
clusterTop: createButton(events, 3),
leftBumper: createButton(events, 4),
rightBumper: createButton(events, 5),
leftTrigger: createButton(events, 6),
rightTrigger: createButton(events, 7),
select: createButton(events, 8),
start: createButton(events, 9),
leftStickButton: createButton(events, 10),
rightStickButton: createButton(events, 11),
directionalTop: createButton(events, 12),
directionalBottom: createButton(events, 13),
directionalLeft: createButton(events, 14),
directionalRight: createButton(events, 15),
center: createButton(events, 16)
};
const sticks = {
leftStick: createAxis(events, 17),
rightStick: createAxis(events, 18)
};
return {
on,
off,
/** The Gamepad connection status */
connected: currentWritable(false),
/** The raw Gamepad object */
raw: null,
/** Get a button by name. */
button: (name) => {
const btn = buttons[name];
if (!btn)
throw new Error(`Unknown gamepad button: "${name}"`);
return btn;
},
/** Get a stick by name. */
stick: (name) => {
const s = sticks[name];
if (!s)
throw new Error(`Unknown gamepad stick: "${name}"`);
return s;
},
/** @deprecated Use `button('clusterBottom')` instead */
clusterBottom: buttons.clusterBottom,
/** @deprecated Use `button('clusterRight')` instead */
clusterRight: buttons.clusterRight,
/** @deprecated Use `button('clusterLeft')` instead */
clusterLeft: buttons.clusterLeft,
/** @deprecated Use `button('clusterTop')` instead */
clusterTop: buttons.clusterTop,
/** @deprecated Use `button('leftBumper')` instead */
leftBumper: buttons.leftBumper,
/** @deprecated Use `button('rightBumper')` instead */
rightBumper: buttons.rightBumper,
/** @deprecated Use `button('leftTrigger')` instead */
leftTrigger: buttons.leftTrigger,
/** @deprecated Use `button('rightTrigger')` instead */
rightTrigger: buttons.rightTrigger,
/** @deprecated Use `button('select')` instead */
select: buttons.select,
/** @deprecated Use `button('start')` instead */
start: buttons.start,
/** @deprecated Use `button('leftStickButton')` instead */
leftStickButton: buttons.leftStickButton,
/** @deprecated Use `button('rightStickButton')` instead */
rightStickButton: buttons.rightStickButton,
/** @deprecated Use `button('directionalTop')` instead */
directionalTop: buttons.directionalTop,
/** @deprecated Use `button('directionalBottom')` instead */
directionalBottom: buttons.directionalBottom,
/** @deprecated Use `button('directionalLeft')` instead */
directionalLeft: buttons.directionalLeft,
/** @deprecated Use `button('directionalRight')` instead */
directionalRight: buttons.directionalRight,
/** @deprecated Use `button('center')` instead */
center: buttons.center,
/** @deprecated Use `stick('leftStick')` instead */
leftStick: sticks.leftStick,
/** @deprecated Use `stick('rightStick')` instead */
rightStick: sticks.rightStick
};
};
const processButton = (target, mappedButton, allEvents, buttonEvents, source) => {
// Clear last frame's transient states
if (mappedButton.justPressed)
mappedButton.justPressed = false;
if (mappedButton.justReleased)
mappedButton.justReleased = false;
const lastTouched = mappedButton.touched;
const lastPressed = mappedButton.pressed;
const lastValue = mappedButton.value;
mappedButton.touched = source?.touched ?? false;
mappedButton.pressed = source?.pressed ?? false;
const value = (mappedButton.value = source?.value ?? 0);
if (!lastTouched && mappedButton.touched) {
allEvents.touchstart?.forEach((fn) => fn({ type: 'touchstart', target, value }));
buttonEvents.touchstart?.forEach((fn) => fn({ type: 'touchstart', target, value }));
}
else if (lastTouched && !mappedButton.touched) {
allEvents.touch?.forEach((fn) => fn({ type: 'touch', target, value }));
buttonEvents.touch?.forEach((fn) => fn({ type: 'touch', target, value }));
allEvents.touchend?.forEach((fn) => fn({ type: 'touchend', target, value }));
buttonEvents.touchend?.forEach((fn) => fn({ type: 'touchend', target, value }));
}
if (!lastPressed && mappedButton.pressed) {
mappedButton.justPressed = true;
allEvents.down?.forEach((fn) => fn({ type: 'down', target, value }));
buttonEvents.down?.forEach((fn) => fn({ type: 'down', target, value }));
}
else if (lastPressed && !mappedButton.pressed) {
mappedButton.justReleased = true;
allEvents.press?.forEach((fn) => fn({ type: 'press', target, value }));
buttonEvents.press?.forEach((fn) => fn({ type: 'press', target, value }));
allEvents.up?.forEach((fn) => fn({ type: 'up', target, value }));
buttonEvents.up?.forEach((fn) => fn({ type: 'up', target, value }));
}
if (lastValue !== mappedButton.value) {
allEvents.change?.forEach((fn) => fn({ type: 'change', target, value }));
buttonEvents.change?.forEach((fn) => fn({ type: 'change', target, value }));
}
};
const processAxis = (target, mappedStick, allEvents, axisEvents, axisDeadzone, rawX = 0, rawY = 0) => {
const lastValueX = mappedStick.x;
const lastValueY = mappedStick.y;
const x = Math.abs(rawX) < axisDeadzone ? 0 : rawX;
const y = Math.abs(rawY) < axisDeadzone ? 0 : rawY;
mappedStick.x = x;
mappedStick.y = y;
if (lastValueX !== x || lastValueY !== y) {
allEvents.change?.forEach((fn) => fn({ type: 'change', target, value: { x, y } }));
axisEvents.change?.forEach((fn) => fn({ type: 'change', target, value: { x, y } }));
}
};
export function useGamepad(options = {}) {
const { axisDeadzone = 0.05 } = options;
const allEvents = {};
const events = [];
if ('xr' in options) {
for (let i = 0; i < xrButtons.length + xrAxes.length; i += 1) {
events.push({});
}
const gamepad = createXrStandard(allEvents, events);
const { xr } = useThrelte().renderer;
const processSnapshot = () => {
xr.getSession()?.inputSources.forEach((source) => {
if (source.handedness !== options.hand) {
return;
}
gamepad.raw = source.gamepad ?? null;
const { buttons = [], axes = [] } = gamepad.raw ?? {};
xrButtons.forEach((name, index) => processButton(name, gamepad[name], allEvents, events[index], buttons[index]));
processAxis('touchpad', gamepad.stick('touchpad'), allEvents, events[6], axisDeadzone, axes[0], axes[1]);
processAxis('thumbstick', gamepad.stick('thumbstick'), allEvents, events[7], axisDeadzone, axes[2], axes[3]);
});
};
let running = $state(false);
const { task } = useTask(Symbol('useGamepad'), processSnapshot, {
autoInvalidate: false,
running: () => running
});
const handleConnected = (event) => {
if (event.data.handedness !== options.hand)
return;
const pad = event.data.gamepad;
if (pad) {
gamepad.raw = pad;
gamepad.connected.set(true);
running = true;
}
};
const handleDisconnected = (event) => {
if (event.data.handedness !== options.hand)
return;
gamepad.raw = null;
gamepad.connected.set(false);
running = false;
};
// Check if gamepads are already connected. Since XR controllers do not show
// up in the regular navigator.getGamepads() array, we have to check the
// XRSession's inputSources array.
const session = xr.getSession();
if (session) {
session.inputSources.forEach((source) => {
if (source.handedness !== options.hand) {
return;
}
const pad = source.gamepad;
// we could be dealing with hands here, so we need to check if the gamepad is null
if (pad) {
gamepad.raw = pad;
gamepad.connected.set(true);
running = true;
}
});
}
$effect(() => {
for (const index of [0, 1]) {
const controller = xr.getController(index);
controller.addEventListener('connected', handleConnected);
controller.addEventListener('disconnected', handleDisconnected);
}
return () => {
for (const index of [0, 1]) {
const controller = xr.getController(index);
controller.removeEventListener('connected', handleConnected);
controller.removeEventListener('disconnected', handleDisconnected);
}
};
});
return Object.assign(gamepad, { task });
}
else {
for (let i = 0; i < standardButtons.length + standardAxes.length; i += 1) {
events.push({});
}
const { index: gamepadIndex = 0, mappings: userMappings, useBuiltinMappings = true } = options;
const gamepad = createStandard(allEvents, events);
// Cache the resolved remap per-pad so the lookup only runs when the
// connected controller's id changes (e.g. user swaps gamepads).
let cachedMappingForId = null;
let cachedMapping = null;
const processSnapshot = () => {
/**
* getGamepads() will return a snapshot of a gamepad that will never change,
* so it must be polled continuously to receive new values.
*/
const pad = navigator.getGamepads()[gamepadIndex];
gamepad.raw = pad;
if (!pad) {
// Clear transient button/axis state by feeding empty sources.
for (let i = 0; i < standardButtons.length; i += 1) {
processButton(standardButtons[i], gamepad[standardButtons[i]], allEvents, events[i]);
}
processAxis('leftStick', gamepad.leftStick, allEvents, events[17], axisDeadzone, 0, 0);
processAxis('rightStick', gamepad.rightStick, allEvents, events[18], axisDeadzone, 0, 0);
return;
}
if (pad.id !== cachedMappingForId) {
cachedMappingForId = pad.id;
cachedMapping = resolveMapping(pad, userMappings, useBuiltinMappings);
if (pad.mapping !== 'standard' && !cachedMapping) {
warnUnknownGamepad(pad.id);
}
}
const mapping = cachedMapping;
const hatDirs = mapping?.dpad ? readHatDirections(pad, mapping.dpad) : undefined;
for (let i = 0; i < standardButtons.length; i += 1) {
const name = standardButtons[i];
let source;
if (hatDirs &&
(name === 'directionalTop' ||
name === 'directionalRight' ||
name === 'directionalBottom' ||
name === 'directionalLeft')) {
const pressed = name === 'directionalTop'
? hatDirs.up
: name === 'directionalRight'
? hatDirs.right
: name === 'directionalBottom'
? hatDirs.down
: hatDirs.left;
source = { pressed, touched: pressed, value: pressed ? 1 : 0 };
}
else {
source = readButton(pad, mapping?.buttons?.[name], i);
}
processButton(name, gamepad[name], allEvents, events[i], source);
}
const leftStickVals = readStick(pad, mapping?.leftStick, 0, 1);
const rightStickVals = readStick(pad, mapping?.rightStick, 2, 3);
processAxis('leftStick', gamepad.stick('leftStick'), allEvents, events[17], axisDeadzone, leftStickVals.x, leftStickVals.y);
processAxis('rightStick', gamepad.stick('rightStick'), allEvents, events[18], axisDeadzone, rightStickVals.x, rightStickVals.y);
};
let running = $state(false);
const { task } = useTask(Symbol('useGamepad'), processSnapshot, {
autoInvalidate: false,
running: () => running
});
const handleGamepadDisconnected = (event) => {
const { id } = event.gamepad;
if (id === gamepad.raw?.id) {
gamepad.raw = null;
gamepad.connected.set(false);
running = false;
}
};
const handleGamepadConnected = () => {
const pad = navigator.getGamepads()[gamepadIndex];
if (pad) {
gamepad.raw = pad;
gamepad.connected.set(true);
running = true;
}
};
// Check if gamepads are already connected.
handleGamepadConnected();
$effect.pre(() => {
window.addEventListener('gamepadconnected', handleGamepadConnected);
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected);
return () => {
window.removeEventListener('gamepadconnected', handleGamepadConnected);
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected);
};
});
return Object.assign(gamepad, { task });
}
}