import { autoPlacement, useFloating } from "@floating-ui/react-dom"; import Button from "@thorium/ui/Button"; import Checkbox from "@thorium/ui/Checkbox"; import { Icon } from "@thorium/ui/Icon"; import Input from "@thorium/ui/Input"; import Select from "@thorium/ui/Select"; import { capitalCase } from "change-case"; import { produce } from "immer"; import { type Dispatch, type ReactNode, type SetStateAction, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import useOnClickOutside from "./useClickOutside"; const gamepadKeys = [ "yaw", "pitch", "roll", "x-thrusters", "y-thrusters", "z-thrusters", "zoom-adjust", "zoom-set", "pilot-sensor-tilt", "autopilot-lock-on", "autopilot-activate", "full-stop", "impulse-speed", "impulse-adjust", "warp-focus-set", "warp-focus-adjust", "warp-engage", ] as const; export type GamepadKey = (typeof gamepadKeys)[number]; const keyLabels: Partial> = { "zoom-adjust": "Hold down a button to adjust zoom. Use with two buttons.", "zoom-set": "Set the current zoom to the value of an axis.", "pilot-sensor-tilt": "Press a button to toggle the sensor tilt.", "impulse-speed": "Set the current speed to the value of an axis.", "impulse-adjust": "Hold down a button to adjust speed. Use with two buttons.", "warp-focus-set": "Use an axis to shift focus between the warp speed buttons.", "warp-focus-adjust": "Use two buttons to shift focus between the warp speed buttons.", "warp-engage": "Activate the currently focused warp speed.", }; type GamepadActionConfig = ( | { control: "axis"; invert?: boolean; deadZone?: number; } | { control: "button"; /** Multiply the button output so it can be used like a joystick */ multiplier?: number; invert?: boolean; } | { control: "hat"; axis: "x" | "y"; } ) & { /** Gamepad ID */ gamepad: string; index: number; }; interface JoystickConfig { name: string; actions: { [K in GamepadKey]?: GamepadActionConfig[] }; } export const useGamepadStore = create<{ gamepads: (Gamepad | null)[] }>(() => ({ gamepads: [], })); export const useGamepadConfigStore = create( persist<{ configs: JoystickConfig[]; activeConfig: number; createConfig: (name: string) => void; renameConfig: (name: string) => void; deleteConfig: () => void; addAction: (key: GamepadKey, action: GamepadActionConfig) => void; removeAction: (key: GamepadKey, gamepad: string) => void; updateAction: ( key: GamepadKey, action: Partial & { gamepad: string; index: number }, ) => void; }>( (set, get) => ({ configs: [ { name: "Default", actions: {}, }, ], activeConfig: 0, createConfig: (name: string) => { set( produce(get(), (draft) => { draft.configs.push({ name, actions: {} }); }), ); }, renameConfig: (name: string) => { set( produce(get(), (draft) => { draft.configs[get().activeConfig].name = name; }), ); }, deleteConfig: () => { set( produce(get(), (draft) => { draft.configs.splice(get().activeConfig, 1); }), ); }, addAction: (key: GamepadKey, action: GamepadActionConfig) => { if ( get().configs[get().activeConfig].actions[key]?.some( (a) => a.gamepad === action.gamepad && a.control === action.control && a.index === action.index, ) ) return; set( produce(get(), (draft) => { draft.configs[get().activeConfig].actions[key] = draft.configs[get().activeConfig].actions[key] || []; draft.configs[get().activeConfig].actions[key]?.push(action); }), ); }, removeAction: (key: GamepadKey, gamepad: string) => { set( produce(get(), (draft) => { draft.configs[get().activeConfig].actions[key] = draft.configs[get().activeConfig].actions[key] || []; const actionIndex = draft.configs[get().activeConfig].actions[key]?.findIndex( (item) => item.gamepad === gamepad, ) || -1; draft.configs[get().activeConfig].actions[key]?.splice(actionIndex, 1); }), ); }, updateAction: ( key: GamepadKey, action: Partial & { gamepad: string; index: number; }, ) => { set( produce(get(), (draft) => { draft.configs[get().activeConfig].actions[key] = draft.configs[get().activeConfig].actions[key] || []; const actionIndex = get().configs[get().activeConfig].actions[key]?.findIndex( (item) => item.gamepad === action.gamepad && item.index === action.index, ) ?? -1; if (actionIndex === -1) return; try { for (const item in action) { // @ts-expect-error Immer weirdness draft.configs[get().activeConfig].actions[key][actionIndex][ item // @ts-expect-error Immer weirdness ] = action[item]; } } catch {} }), ); }, }), { name: "gamepad-store", version: 5, partialize(state) { return { configs: state.configs, activeConfig: state.activeConfig, } as typeof state; }, }, ), ); const gamepadTimestamps: Record = {}; function updateGamepadStore() { const gamepads = navigator.getGamepads(); let shouldUpdate = false; for (const [index, gamepad] of Object.entries(gamepads)) { if (!gamepad) continue; if (gamepad.timestamp !== gamepadTimestamps[index]) { gamepadTimestamps[index] = gamepad.timestamp; shouldUpdate = true; } } if (shouldUpdate) { useGamepadStore.setState({ gamepads: navigator.getGamepads(), }); } requestAnimationFrame(updateGamepadStore); } if (typeof window !== "undefined") { requestAnimationFrame(updateGamepadStore); } export function calculateHatDirection(num: number) { let x = 0; let y = 0; const input = num.toFixed(5); if (input.startsWith("-1") || input.startsWith("-0.71429") || input.startsWith("1")) { y = 1; } if ( input.startsWith("-0.71429") || input.startsWith("-0.42857") || input.startsWith("-0.14286") ) { x = 1; } if (input.startsWith("-0.14286") || input.startsWith("0.14286") || input.startsWith("0.42857")) { y = -1; } if (input.startsWith("0.42857") || input.startsWith("0.71429") || input.startsWith("1")) { x = -1; } if (x && y) { // If it's at an angle, we should // normalize each dimension to the unit circle x *= Math.cos(Math.PI / 4); y *= Math.sin(Math.PI / 4); } return { x, y }; } function objectDiff( o1: ({ axes: number[]; buttons: number[] } | null)[], o2: ({ axes: number[]; buttons: number[] } | null)[], ) { const diffs: { path: string; old: number; newVal: number }[] = []; for (const [key, value] of o1.entries()) { if (value === o2[key] && value === null) continue; for (const [axis, axisValue] of value?.axes.entries() || []) { if (axisValue !== o2[key]?.axes[axis]) diffs.push({ path: `${key}.axes.${axis}`, old: o2[key]?.axes[axis] || 0, newVal: axisValue, }); } for (const [button, buttonValue] of value?.buttons.entries() || []) { if (buttonValue !== o2[key]?.buttons[button]) diffs.push({ path: `${key}.buttons.${button}`, old: o2[key]?.buttons[button] || 0, newVal: buttonValue, }); } } return diffs; } function parseGamepads(gamepads: (Gamepad | null)[]) { return gamepads.map((gamepad) => gamepad ? { id: gamepad.id, axes: gamepad.axes.concat(), buttons: gamepad.buttons.map((button) => button.value), } : null, ); } export function useGamepadValue( key: GamepadKey | undefined, callback: (val: number) => void, ): void { // First check all the gamepads in the current config // to see if any of them have a value for this key. const config = useGamepadConfigStore((store) => store.configs[store.activeConfig]); const callbackRef = useRef(callback); useEffect(() => { callbackRef.current = callback; }, [callback]); const oldValue = useRef(0); const actions = config?.actions[key || ("" as GamepadKey)]; useEffect(() => { if (!actions) return; const unsubscribe = useGamepadStore.subscribe((store) => { let value = 0; for (const action of actions) { let actionValue = 0; const gamepad = store.gamepads.find((gamepad) => gamepad && action.gamepad === gamepad.id); switch (action.control) { case "axis": { let { index, deadZone = 0.05, invert } = action; actionValue = gamepad?.axes[index] || 0; if (deadZone < 0 || deadZone >= 1) deadZone = 0; if (deadZone !== 0) { actionValue = actionValue < deadZone && actionValue > deadZone * -1 ? 0 : actionValue; } if (invert) { actionValue *= -1; } break; } case "button": { const { index, invert, multiplier = 1 } = action; actionValue = gamepad?.buttons[index].value || 0; if (invert) { actionValue = 1 - actionValue; } actionValue *= multiplier; break; } case "hat": { const { index, axis } = action; actionValue = gamepad?.axes[index] || 0; const direction = calculateHatDirection(actionValue); actionValue = direction[axis]; } } value += actionValue; } if (value !== oldValue.current) { oldValue.current = value; callbackRef.current(value); } }); return () => unsubscribe(); }, [actions, key]); } export function useGamepadPress( key: GamepadKey | undefined, callbacks: { onDown?: (val: number) => void; onUp?: (val: number) => void }, ) { const lastVal = useRef(0); useGamepadValue(key, (value) => { if (Math.abs(value) > 0.5) { callbacks.onDown?.(value); } else if (Math.abs(lastVal.current) > 0.5) { callbacks.onUp?.(lastVal.current); } lastVal.current = value; }); } export function GamepadConfig() { const store = useGamepadStore(); const gamepads = store.gamepads.map((g) => g?.id).filter(filterNull); const [gamepad, setGamepad] = useState(gamepads[0]); const [assigningKey, setAssigningKey] = useState(null); useEffect(() => { if (!gamepad && !assigningKey) return; let old = parseGamepads(useGamepadStore.getState().gamepads); const unsub = useGamepadStore.subscribe((store) => { const newVal = parseGamepads(store.gamepads); if (old.length === 0 && newVal.length > 0) { old = newVal; return; } const diff = objectDiff(newVal, old); for (const { path, newVal } of diff) { const [diffGamepad, type, index] = path.split(".") as [string, "axes" | "buttons", string]; const gamepadObj = useGamepadStore.getState().gamepads[Number(diffGamepad)]; if (!gamepadObj) continue; if (!assigningKey) continue; if (gamepad !== gamepadObj.id) continue; // Make sure the axis is actually being adjusted if (Math.abs(newVal) > 0.5) { const config: GamepadActionConfig = { index: Number(index), gamepad: gamepadObj.id, ...(type === "axes" ? { control: "axis" } : { control: "button" }), }; useGamepadConfigStore.getState().addAction(assigningKey, config); setAssigningKey(null); break; } } }); return () => unsub(); }, [assigningKey, gamepad]); return ( <> !Number.isNaN(Number(evt.target.value)) && gamepad && useGamepadConfigStore.getState().updateAction(key, { deadZone: Number(evt.target.value), index: keyConfig.index, gamepad, }) } /> ) : keyConfig.control === "button" ? (
gamepad && useGamepadConfigStore.getState().updateAction(key, { invert: evt.target.checked, index: keyConfig.index, gamepad, }) } /> !Number.isNaN(Number(evt.target.value)) && gamepad && useGamepadConfigStore.getState().updateAction(key, { multiplier: Number(evt.target.value), index: keyConfig.index, gamepad, }) } />
) : null} ))} ); } function ActionConfig({ children }: { children: ReactNode }) { const { x, y, strategy, refs, update } = useFloating({ placement: "right", middleware: [autoPlacement()], }); const [visible, setVisible] = useState(false); useLayoutEffect(() => { if (visible) { update(); } }, [update, visible]); useOnClickOutside(refs.floating as React.MutableRefObject, () => setVisible(false)); return ( <>
{children}
); } function filterNull(val: T | null | undefined): val is T { return !!val; }