UNPKG

@threlte/extras

Version:

Utilities, abstractions and plugins for your Threlte apps

159 lines (158 loc) 5.63 kB
import { useTask } from '@threlte/core'; class ActionState { /** Whether any binding for this action is currently active */ pressed = $state(false); /** Whether this action became active this frame */ justPressed = $state(false); /** Whether this action became inactive this frame */ justReleased = $state(false); /** Analog strength 0–1. Digital bindings produce 0 or 1. */ strength = $state(0); } const bindingHelpers = { /** Bind a keyboard key by its `KeyboardEvent.key` value. Matching is case-insensitive. */ key: (key) => ({ type: 'keyboard', key }), /** Bind a standard gamepad button (e.g. `'clusterBottom'`, `'leftTrigger'`). */ gamepadButton: (button) => ({ type: 'gamepadButton', button }), /** * Bind a gamepad stick axis. * * ```ts * gamepadAxis('leftStick', 'x', 1) // right on the left stick * gamepadAxis('leftStick', 'y', -1) // up on the left stick * ``` */ gamepadAxis: (stick, axis, direction, threshold = 0.1) => ({ type: 'gamepadAxis', stick, axis, direction, threshold }) }; export function useInputMap(definitionsFn, options) { const keyboard = options.keyboard; const gamepad = options.gamepad; let _activeDevice = $state('keyboard'); const actionStates = new Map(); const previousPressed = new Map(); // Eagerly create action states so they're available before the first frame for (const name of Object.keys(definitionsFn(bindingHelpers))) { actionStates.set(name, new ActionState()); previousPressed.set(name, false); } /** Resolve a single binding to its current strength (0–1). */ const getBindingStrength = (binding) => { switch (binding.type) { case 'keyboard': { return keyboard.key(binding.key).pressed ? 1 : 0; } case 'gamepadButton': { if (!gamepad) return 0; const btn = gamepad[binding.button]; if (!btn) return 0; return btn.value ?? (btn.pressed ? 1 : 0); } case 'gamepadAxis': { if (!gamepad) return 0; const stick = gamepad[binding.stick]; if (!stick) return 0; const raw = stick[binding.axis] ?? 0; const directed = raw * binding.direction; return directed > binding.threshold ? directed : 0; } } }; const getOrCreateState = (name) => { let state = actionStates.get(name); if (!state) { state = new ActionState(); actionStates.set(name, state); previousPressed.set(name, false); } return state; }; const actionEntries = $derived(Object.entries(definitionsFn(bindingHelpers))); /** * Process all action states once per frame, after both the keyboard and * gamepad tasks have populated their state for the frame. */ const { task } = useTask(Symbol('useInputMap'), () => { for (let i = 0; i < actionEntries.length; i++) { const [name, bindings] = actionEntries[i]; const state = getOrCreateState(name); let maxStrength = 0; for (const binding of bindings) { const s = getBindingStrength(binding); if (s > maxStrength) { maxStrength = s; _activeDevice = binding.type === 'keyboard' ? 'keyboard' : 'gamepad'; } } const wasPressed = previousPressed.get(name); const isPressed = maxStrength > 0; state.strength = maxStrength; state.pressed = isPressed; state.justPressed = isPressed && !wasPressed; state.justReleased = !isPressed && wasPressed; previousPressed.set(name, isPressed); } }, { after: gamepad ? [keyboard.task, gamepad.task] : keyboard.task, autoInvalidate: false }); /** Get the current state of a named action. */ const action = (name) => { const state = actionStates.get(name); if (!state) { throw new Error(`Unknown action: "${name}"`); } return state; }; /** * Get a signed axis value from two opposing actions. * Returns a value from –1 (negative fully active) to 1 (positive fully active). */ const axis = (negative, positive) => { return action(positive).strength - action(negative).strength; }; /** * Get a 2D vector from four directional actions. * The result is clamped to a unit circle (magnitude ≤ 1). */ const vec = { x: 0, y: 0 }; const vector = (negativeX, positiveX, negativeY, positiveY) => { vec.x = axis(negativeX, positiveX); vec.y = axis(negativeY, positiveY); const lengthSq = vec.x * vec.x + vec.y * vec.y; if (lengthSq > 1) { const length = Math.sqrt(lengthSq); vec.x /= length; vec.y /= length; } return vec; }; return { /** The internal task, for ordering other tasks via `after`/`before`. */ task, /** The most recently active input device. Switches when a new device provides input. */ activeDevice: { get current() { return _activeDevice; } }, action, axis, vector }; }