Something went wrong. Try again.
Starship Simulator Controls thoriumsim.com
Something went wrong. Try again.
17 kB · 607 lines
TSX
at develop
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608import { 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<Record<GamepadKey, string>> = { "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<GamepadActionConfig> & { 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<GamepadActionConfig> & { 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<string, number> = {};
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<string | null>(gamepads[0]); const [assigningKey, setAssigningKey] = useState<GamepadKey | null>(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 ( <> <Select label="Gamepad" items={gamepads.map((g) => ({ id: g, label: g }))} selected={gamepad} setSelected={(val) => { if (Array.isArray(val)) return; setGamepad(val); setAssigningKey(null); }} /> <small>Connect your gamepad and press a button to recognize it.</small> <form className="divide-y divide-gray-800"> {gamepadKeys.map((key) => ( <GamepadAction key={key} width={Math.max(...gamepadKeys.map((key) => key.length))} keyData={key} gamepad={gamepad} assigningKey={assigningKey} setAssigningKey={setAssigningKey} /> ))} </form> </> );}
function GamepadAction({ keyData: key, gamepad, assigningKey, setAssigningKey, width,}: { keyData: GamepadKey; gamepad: string | null; assigningKey: GamepadKey | null; setAssigningKey: Dispatch<SetStateAction<GamepadKey | null>>; width: number;}) { const store = useGamepadConfigStore(); const keyConfigs = store.configs[store.activeConfig].actions[key]?.filter( (action) => action.gamepad === gamepad, ) || [];
return ( <div className="flex items-center justify-between gap-2 py-1"> <div style={{ width: `${width}ch` }}> {capitalCase(key)} <small className="block text-gray-400">{keyLabels[key]}</small> </div>
<ul className="flex-1"> {keyConfigs.map((keyConfig) => ( <li key={`${keyConfig.gamepad}-${keyConfig.control}-${keyConfig.index}`}> {capitalCase(keyConfig.control)}: {keyConfig.index}{" "} <button className="px-1 py-2" type="button" onClick={() => gamepad && useGamepadConfigStore.getState().removeAction(key, gamepad)} > <Icon name="ban" className="text-red-500" /> </button> <ActionConfig> {keyConfig.control === "axis" ? ( <div> <Checkbox label="Invert" checked={!!keyConfig.invert} onChange={(evt) => gamepad && useGamepadConfigStore.getState().updateAction(key, { invert: evt.target.checked, index: keyConfig.index, gamepad, }) } />{" "} <Input label="Dead Zone" helperText="Reduce jitter by ignoring axis inputs below this number" placeholder="0.05" type="text" inputMode="numeric" pattern="[0-9]*" defaultValue={keyConfig.deadZone} onChange={(evt) => !Number.isNaN(Number(evt.target.value)) && gamepad && useGamepadConfigStore.getState().updateAction(key, { deadZone: Number(evt.target.value), index: keyConfig.index, gamepad, }) } /> </div> ) : keyConfig.control === "button" ? ( <div> <Checkbox label="Invert" checked={!!keyConfig.invert} onChange={(evt) => gamepad && useGamepadConfigStore.getState().updateAction(key, { invert: evt.target.checked, index: keyConfig.index, gamepad, }) } /> <Input label="Multiplier" helperText="Multiply the button output so it can be used like a joystick" placeholder="1" type="text" inputMode="numeric" pattern="[0-9]*" defaultValue={keyConfig.multiplier} onChange={(evt) => !Number.isNaN(Number(evt.target.value)) && gamepad && useGamepadConfigStore.getState().updateAction(key, { multiplier: Number(evt.target.value), index: keyConfig.index, gamepad, }) } /> </div> ) : null} </ActionConfig> </li> ))} </ul> <Button disabled={!gamepad} type="button" className={`btn-xs max-w-fit place-self-end self-center ${ assigningKey !== key ? "btn-warning" : "" }`} onClick={() => setAssigningKey(assigningKey ? null : key)} > {assigningKey === key ? "Use Button or Joystick" : "Assign"} </Button> </div> );}
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<HTMLElement>, () => setVisible(false));
return ( <> <button className="px-1 py-2" type="button" ref={refs.setReference} onClick={(e) => { e.preventDefault(); e.stopPropagation(); setVisible((v) => !v); }} > <Icon name="pencil" className="text-primary" /> </button> <div ref={refs.setFloating} style={{ position: strategy, top: y ?? "", left: x ?? "", }} className={`z-10 w-max max-w-xs rounded-lg border-transparent bg-black/90 p-2 shadow-lg ${ visible ? "block" : "hidden" }`} > {children} </div> </> );}
function filterNull<T>(val: T | null | undefined): val is T { return !!val;}