UNPKG

@threlte/extras

Version:

Utilities, abstractions and plugins for your Threlte apps

338 lines (337 loc) 13.8 kB
import { onDestroy } from 'svelte'; import { currentWritable, useTask, useThrelte } from '@threlte/core'; 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']; const gamepadEvents = ['change', 'press', 'down', 'up', 'touch', 'touchstart', 'touchend']; const createButton = (events, index) => { const off = (name, fn) => { if (!(index in events) || !(name in events[index])) return; const arrayIndex = events[index][name].indexOf(fn); if (arrayIndex > -1) events[index][name].splice(arrayIndex, 1); }; const on = (name, fn) => { events[index][name] ??= []; events[index][name].push(fn); return () => off(name, fn); }; return { pressed: false, touched: false, value: 0, on, off }; }; const createAxis = (events, index) => { const off = (name, fn) => { if (!(index in events) || !(name in events[index])) return; const arrayIndex = events[index][name].indexOf(fn); if (arrayIndex > -1) events[index][name].splice(arrayIndex, 1); }; const on = (name, fn) => { events[index][name] ??= []; events[index][name].push(fn); return () => off(name, fn); }; return { x: 0, y: 0, on, off }; }; const createXrStandard = (allEvents, events) => { const off = (name, fn) => { if (!allEvents[name]) return; const index = allEvents[name].indexOf(fn); if (index > -1) allEvents[name].splice(index, 1); }; const on = (name, fn) => { allEvents[name] ??= []; allEvents[name].push(fn); return () => off(name, fn); }; return { on, off, /** The Gamepad connection status */ connected: currentWritable(false), /** The raw Gamepad object */ raw: null, /** buttons[0] - Primary trigger */ trigger: createButton(events, 0), /** buttons[1] - Primary squeeze button */ squeeze: createButton(events, 1), /** buttons[2] - Primary touchpad */ touchpadButton: createButton(events, 2), /** buttons[3] - Primary thumbstick */ thumbstickButton: createButton(events, 3), /** buttons[4] - Bottom cluster button */ clusterBottom: createButton(events, 4), /** buttons[5] - Top cluster button */ clusterTop: createButton(events, 5), /** axes[0], axes[1] - Horizontal / vertical axis for the primary touchpad */ touchpad: createAxis(events, 6), /** axes[2], axes[3] - Horizontal / vertical axis for the primary thumbstick */ thumbstick: createAxis(events, 7) }; }; const createStandard = (allEvents, events) => { const off = (name, fn) => { if (!allEvents[name]) return; const index = allEvents[name].indexOf(fn); if (index > -1) allEvents[name].splice(index, 1); }; const on = (name, fn) => { allEvents[name] ??= []; allEvents[name].push(fn); return () => off(name, fn); }; return { on, off, /** The Gamepad connection status */ connected: currentWritable(false), /** The raw Gamepad object */ raw: null, /** buttons[0] - Botton button in right cluster */ clusterBottom: createButton(events, 0), /** buttons[1] - Right button in right cluster */ clusterRight: createButton(events, 1), /** buttons[2] - Left button in right cluster */ clusterLeft: createButton(events, 2), /** buttons[3] - Top button in right cluster */ clusterTop: createButton(events, 3), /** buttons[4] - Top left front button */ leftBumper: createButton(events, 4), /** buttons[5] - Top right front button */ rightBumper: createButton(events, 5), /** buttons[6] - Bottom left front button */ leftTrigger: createButton(events, 6), /** buttons[7] - Bottom right front button */ rightTrigger: createButton(events, 7), /** buttons[8] - Left button in center cluster */ select: createButton(events, 8), /** buttons[9] - Right button in center cluster */ start: createButton(events, 9), /** buttons[10] - Left stick pressed button */ leftStickButton: createButton(events, 10), /** buttons[11] - Right stick pressed button */ rightStickButton: createButton(events, 11), /** buttons[12] - Top button in left cluster */ directionalTop: createButton(events, 12), /** buttons[13] - Bottom button in left cluster */ directionalBottom: createButton(events, 13), /** buttons[14] - Left button in left cluster */ directionalLeft: createButton(events, 14), /** buttons[15] - Right button in left cluster */ directionalRight: createButton(events, 15), /** buttons[16] - Center button in center cluster */ center: createButton(events, 16), /** axes[0], axes[1] - Horizontal / vertical axis for left stick (negative left/positive right) */ leftStick: createAxis(events, 17), /** axes[2], axes[3] - Horizontal / vertical axis for right stick (negative left/positive right) */ rightStick: createAxis(events, 18) }; }; const processButton = (target, mappedButton, allEvents, buttonEvents, source) => { 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) { allEvents.down?.forEach((fn) => fn({ type: 'down', target, value })); buttonEvents.down?.forEach((fn) => fn({ type: 'down', target, value })); } else if (lastPressed && !mappedButton.pressed) { 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.touchpad, allEvents, events[6], axisDeadzone, axes[0], axes[1]); processAxis('thumbstick', gamepad.thumbstick, allEvents, events[7], axisDeadzone, axes[2], axes[3]); }); }; // useTask automatically stops whenever the host component unmounts, so we // don't need to clean up here. const { start, stop } = useTask(processSnapshot, { autoStart: false, autoInvalidate: false }); const handleConnected = (event) => { if (event.data.handedness !== options.hand) return; const pad = event.data.gamepad; if (pad) { gamepad.raw = pad; gamepad.connected.set(true); start(); } }; const handleDisconnected = (event) => { if (event.data.handedness !== options.hand) return; gamepad.raw = null; gamepad.connected.set(false); stop(); }; // 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); start(); } }); } for (const index of [0, 1]) { const controller = xr.getController(index); controller.addEventListener('connected', handleConnected); controller.addEventListener('disconnected', handleDisconnected); } onDestroy(() => { for (const index of [0, 1]) { const controller = xr.getController(index); controller.removeEventListener('connected', handleConnected); controller.removeEventListener('disconnected', handleDisconnected); } }); return gamepad; } else { for (let i = 0; i < standardButtons.length + standardAxes.length; i += 1) { events.push({}); } const { index: gamepadIndex = 0 } = options; const gamepad = createStandard(allEvents, events); 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; const { buttons = [], axes = [] } = pad ?? {}; standardButtons.forEach((name, index) => processButton(name, gamepad[name], allEvents, events[index], buttons[index])); processAxis('leftStick', gamepad.leftStick, allEvents, events[17], axisDeadzone, axes[0], axes[1]); processAxis('rightStick', gamepad.rightStick, allEvents, events[18], axisDeadzone, axes[2], axes[3]); }; // useTask automatically stops whenever the host component unmounts, so we // don't need to clean up here. const { start, stop } = useTask(processSnapshot, { autoStart: false, autoInvalidate: false }); const handleGamepadDisconnected = (event) => { const { id } = event.gamepad; if (id === gamepad.raw?.id) { gamepad.raw = null; gamepad.connected.set(false); stop(); } }; const handleGamepadConnected = () => { const pad = navigator.getGamepads()[gamepadIndex]; if (pad) { gamepad.raw = pad; gamepad.connected.set(true); start(); } }; // Check if gamepads are already connected. handleGamepadConnected(); window.addEventListener('gamepadconnected', handleGamepadConnected); window.addEventListener('gamepaddisconnected', handleGamepadDisconnected); onDestroy(() => { window.removeEventListener('gamepadconnected', handleGamepadConnected); window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected); }); return gamepad; } }