@threlte/extras
Version:
Utilities, abstractions and plugins for your Threlte apps
159 lines (158 loc) • 5.63 kB
JavaScript
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
};
}