mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
gamepad ripout
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DefZtORt.js"></script>
|
<script type="module" crossorigin src="/assets/index-DWCUBkjO.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bg0Bo3-n.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D-qq3X_S.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,48 +1,57 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import { GAMEPAD_MAPPING_DEFAULT, INPUT_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||||
|
import {
|
||||||
function useGamepad() {
|
computeGamepadOutputs,
|
||||||
const [connected, setConnected] = useState(false);
|
createProfileForPad,
|
||||||
|
} from '../controls/inputs/gamepadBindings.js';
|
||||||
useEffect(() => {
|
import { useGamepadHubState } from '../controls/inputs/gamepadHub.js';
|
||||||
function updateStatus() {
|
|
||||||
const pads = navigator.getGamepads?.();
|
|
||||||
setConnected(Boolean(pads && Array.from(pads).some(Boolean)));
|
|
||||||
}
|
|
||||||
updateStatus();
|
|
||||||
window.addEventListener('gamepadconnected', updateStatus);
|
|
||||||
window.addEventListener('gamepaddisconnected', updateStatus);
|
|
||||||
const id = setInterval(updateStatus, 2000);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('gamepadconnected', updateStatus);
|
|
||||||
window.removeEventListener('gamepaddisconnected', updateStatus);
|
|
||||||
clearInterval(id);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NUMBER_FORMAT = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
|
const NUMBER_FORMAT = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
|
||||||
|
|
||||||
const ACTIONS = [
|
const ACTIONS = [
|
||||||
{ id: 'driveHorizontal', label: 'Drive horizontal', type: 'axis', section: 'Drive joystick', path: ['drive', 'horizontal'] },
|
{
|
||||||
{ id: 'driveVertical', label: 'Drive vertical', type: 'axis', section: 'Drive joystick', path: ['drive', 'vertical'] },
|
id: 'drive',
|
||||||
{ id: 'cameraVertical', label: 'Camera vertical', type: 'axis', section: 'Camera joystick', path: ['camera', 'vertical'] },
|
label: 'Drive stick',
|
||||||
{ id: 'mainAxis', label: 'Main brush axis', type: 'axis', section: 'Brush analog', path: ['brushes', 'mainAxis'] },
|
kind: 'axisPair',
|
||||||
{ id: 'sideAxis', label: 'Side brush axis', type: 'axis', section: 'Brush analog', path: ['brushes', 'sideAxis'] },
|
section: 'Driving',
|
||||||
{ id: 'mainReverse', label: 'Toggle main reverse', type: 'button', section: 'Brush toggles', path: ['buttons', 'mainReverse'] },
|
invertDefaults: { invertX: false, invertY: true },
|
||||||
{ id: 'sideReverse', label: 'Toggle side reverse', type: 'button', section: 'Brush toggles', path: ['buttons', 'sideReverse'] },
|
},
|
||||||
{ id: 'vacuum', label: 'Vacuum button', type: 'button', section: 'Auxiliary buttons', path: ['buttons', 'vacuum'] },
|
{
|
||||||
{ id: 'allAux', label: 'All aux button', type: 'button', section: 'Auxiliary buttons', path: ['buttons', 'allAux'] },
|
id: 'cameraTilt',
|
||||||
{ id: 'drive', label: 'Drive macro button', type: 'button', section: 'Mode buttons', path: ['buttons', 'drive'] },
|
label: 'Camera tilt',
|
||||||
{ id: 'dock', label: 'Dock macro button', type: 'button', section: 'Mode buttons', path: ['buttons', 'dock'] },
|
kind: 'axis',
|
||||||
|
section: 'Camera',
|
||||||
|
invertDefaults: { invert: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mainBrush',
|
||||||
|
label: 'Main brush',
|
||||||
|
kind: 'axis',
|
||||||
|
section: 'Brushes',
|
||||||
|
invertDefaults: { invert: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sideBrush',
|
||||||
|
label: 'Side brush',
|
||||||
|
kind: 'axis',
|
||||||
|
section: 'Brushes',
|
||||||
|
invertDefaults: { invert: false },
|
||||||
|
},
|
||||||
|
{ id: 'vacuum', label: 'Vacuum', kind: 'button', section: 'Aux buttons' },
|
||||||
|
{ id: 'allAux', label: 'All aux', kind: 'button', section: 'Aux buttons' },
|
||||||
|
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
|
||||||
|
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
|
||||||
|
{ id: 'driveMacro', label: 'Drive macro', kind: 'button', section: 'Mode macros' },
|
||||||
|
{ id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CAPTURE_AXIS_THRESHOLD = 0.45;
|
||||||
|
const CAPTURE_BUTTON_THRESHOLD = 0.6;
|
||||||
|
|
||||||
function SliderField({ label, description, min, max, step, value, onChange }) {
|
function SliderField({ label, description, min, max, step, value, onChange }) {
|
||||||
return (
|
return (
|
||||||
<label className="surface block">
|
<label className="surface-muted block p-0.5">
|
||||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||||
<span className="font-semibold text-slate-100">{label}</span>
|
<span className="font-semibold text-slate-100">{label}</span>
|
||||||
<span className="font-mono text-slate-400">{NUMBER_FORMAT.format(value)}</span>
|
<span className="font-mono text-slate-400">{NUMBER_FORMAT.format(value)}</span>
|
||||||
@@ -61,202 +70,460 @@ function SliderField({ label, description, min, max, step, value, onChange }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAxis(value) {
|
function formatSource(source) {
|
||||||
if (!value) return 'Unassigned';
|
if (!source) return 'Unassigned';
|
||||||
return `Axis ${value.index}${value.invert ? ' (invert)' : ''}`;
|
if (source.kind === 'axisPair') {
|
||||||
|
const invert = `${source.invertX ? 'X inv ' : ''}${source.invertY ? 'Y inv' : ''}`.trim();
|
||||||
|
return `Axes ${source.x}/${source.y}${invert ? ` (${invert})` : ''}`;
|
||||||
|
}
|
||||||
|
if (source.kind === 'axis') {
|
||||||
|
return `Axis ${source.index}${source.invert ? ' (inv)' : ''}`;
|
||||||
|
}
|
||||||
|
if (source.kind === 'button') {
|
||||||
|
return `Button ${source.index}`;
|
||||||
|
}
|
||||||
|
if (source.kind === 'buttonAxis') {
|
||||||
|
return `Button ${source.index} (analog)`;
|
||||||
|
}
|
||||||
|
if (source.kind === 'axisButton') {
|
||||||
|
const dir = source.direction < 0 ? '<' : '>';
|
||||||
|
return `Axis ${source.index} ${dir} ${NUMBER_FORMAT.format(source.threshold ?? 0.6)}`;
|
||||||
|
}
|
||||||
|
return 'Unassigned';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatButton(value) {
|
function groupActions(actions) {
|
||||||
if (!value) return 'Unassigned';
|
return actions.reduce((acc, action) => {
|
||||||
return `Button ${value.index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePath(mapping, path, updater) {
|
|
||||||
const [group, key] = path;
|
|
||||||
const next = { ...(mapping ?? GAMEPAD_MAPPING_DEFAULT) };
|
|
||||||
next[group] = { ...(next[group] ?? GAMEPAD_MAPPING_DEFAULT[group]) };
|
|
||||||
next[group][key] = updater(next[group][key]);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GamepadMappingSettings() {
|
|
||||||
const gamepadConnected = useGamepad();
|
|
||||||
const { value: mapping, save, reset } = useSettingsNamespace('gamepadMapping', GAMEPAD_MAPPING_DEFAULT);
|
|
||||||
const { value: inputSettings, save: saveInputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
|
||||||
const gamepadTuning = inputSettings.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad;
|
|
||||||
const [capture, setCapture] = useState(null);
|
|
||||||
const axisBaselineRef = useRef(null);
|
|
||||||
|
|
||||||
const updateTuning = (patch) => {
|
|
||||||
saveInputSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
gamepad: { ...(prev.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad), ...patch },
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
axisBaselineRef.current = null;
|
|
||||||
}, [capture]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!capture) return undefined;
|
|
||||||
let raf;
|
|
||||||
const scan = () => {
|
|
||||||
const pads = navigator.getGamepads?.();
|
|
||||||
const pad = pads && Array.from(pads).find(Boolean);
|
|
||||||
if (pad) {
|
|
||||||
if (capture.type === 'axis') {
|
|
||||||
if (!axisBaselineRef.current) {
|
|
||||||
axisBaselineRef.current = Array.from(pad.axes ?? []).map((value) => value ?? 0);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < pad.axes.length; i += 1) {
|
|
||||||
const value = pad.axes[i];
|
|
||||||
const baseline = axisBaselineRef.current?.[i] ?? 0;
|
|
||||||
const delta = Math.abs(value - baseline);
|
|
||||||
if (Math.abs(value) > 0.65 && delta > 0.5) {
|
|
||||||
save((prev) =>
|
|
||||||
updatePath(prev, capture.path, () => ({
|
|
||||||
index: i,
|
|
||||||
invert: value < 0,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
setCapture(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (capture.type === 'button') {
|
|
||||||
for (let i = 0; i < pad.buttons.length; i += 1) {
|
|
||||||
const btn = pad.buttons[i];
|
|
||||||
if (btn && (btn.pressed || btn.value > 0.6)) {
|
|
||||||
save((prev) => updatePath(prev, capture.path, () => ({ index: i })));
|
|
||||||
setCapture(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
raf = requestAnimationFrame(scan);
|
|
||||||
};
|
|
||||||
raf = requestAnimationFrame(scan);
|
|
||||||
return () => {
|
|
||||||
if (raf) cancelAnimationFrame(raf);
|
|
||||||
};
|
|
||||||
}, [capture, save]);
|
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
|
||||||
return ACTIONS.reduce((acc, action) => {
|
|
||||||
const list = acc[action.section] || (acc[action.section] = []);
|
const list = acc[action.section] || (acc[action.section] = []);
|
||||||
list.push(action);
|
list.push(action);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
const getStored = (action) => {
|
function pickActivePad(pads, activeSignature) {
|
||||||
const [group, key] = action.path;
|
if (!pads || pads.length === 0) return null;
|
||||||
return mapping?.[group]?.[key] ?? null;
|
if (activeSignature) {
|
||||||
};
|
const match = pads.find((pad) => pad.signature === activeSignature);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return pads[0];
|
||||||
|
}
|
||||||
|
|
||||||
const getValueLabel = (action) => {
|
function snapshotBaseline(pad) {
|
||||||
const stored = getStored(action);
|
return {
|
||||||
return action.type === 'axis' ? formatAxis(stored) : formatButton(stored);
|
axes: Array.from(pad.axes ?? []),
|
||||||
};
|
buttons: (pad.buttons ?? []).map((btn) => ({
|
||||||
|
pressed: Boolean(btn?.pressed),
|
||||||
const handleClear = (action) => {
|
value: typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0,
|
||||||
save((prev) => updatePath(prev, action.path, () => null));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInvert = (action) => {
|
|
||||||
const stored = getStored(action);
|
|
||||||
if (!stored) return;
|
|
||||||
save((prev) =>
|
|
||||||
updatePath(prev, action.path, (current) => ({
|
|
||||||
...current,
|
|
||||||
invert: !current?.invert,
|
|
||||||
})),
|
})),
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectAxisCapture(pad, baseline, action) {
|
||||||
|
const deltas = (pad.axes ?? []).map((value, index) => ({
|
||||||
|
index,
|
||||||
|
delta: Math.abs((value ?? 0) - (baseline.axes?.[index] ?? 0)),
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
deltas.sort((a, b) => b.delta - a.delta);
|
||||||
|
if (action.kind === 'axisPair') {
|
||||||
|
const top = deltas.filter((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD).slice(0, 2);
|
||||||
|
if (top.length < 2) return null;
|
||||||
|
return {
|
||||||
|
kind: 'axisPair',
|
||||||
|
x: top[0].index,
|
||||||
|
y: top[1].index,
|
||||||
|
...(action.invertDefaults ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const match = deltas.find((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
kind: 'axis',
|
||||||
|
index: match.index,
|
||||||
|
...(action.invertDefaults ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectButtonCapture(pad, baseline, action) {
|
||||||
|
const buttons = pad.buttons ?? [];
|
||||||
|
for (let i = 0; i < buttons.length; i += 1) {
|
||||||
|
const btn = buttons[i];
|
||||||
|
const value = typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0;
|
||||||
|
if (btn?.pressed || value > CAPTURE_BUTTON_THRESHOLD) {
|
||||||
|
if (action.kind === 'axis') {
|
||||||
|
return { kind: 'buttonAxis', index: i };
|
||||||
|
}
|
||||||
|
return { kind: 'button', index: i };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const axes = pad.axes ?? [];
|
||||||
|
for (let i = 0; i < axes.length; i += 1) {
|
||||||
|
const value = axes[i] ?? 0;
|
||||||
|
const delta = Math.abs(value - (baseline.axes?.[i] ?? 0));
|
||||||
|
if (Math.abs(value) > 0.7 && delta > 0.5) {
|
||||||
|
if (action.kind === 'axis') {
|
||||||
|
return { kind: 'axis', index: i, ...(action.invertDefaults ?? {}) };
|
||||||
|
}
|
||||||
|
return { kind: 'axisButton', index: i, direction: value >= 0 ? 1 : -1, threshold: 0.6 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDescriptorFromCapture(pad, baseline, action) {
|
||||||
|
if (!pad) return null;
|
||||||
|
if (action.kind === 'axis' || action.kind === 'axisPair') {
|
||||||
|
const axisDescriptor = detectAxisCapture(pad, baseline, action);
|
||||||
|
if (axisDescriptor) return axisDescriptor;
|
||||||
|
}
|
||||||
|
return detectButtonCapture(pad, baseline, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GamepadMappingSettings() {
|
||||||
|
const hubState = useGamepadHubState();
|
||||||
|
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||||
|
'gamepad',
|
||||||
|
GAMEPAD_SETTINGS_DEFAULTS,
|
||||||
|
);
|
||||||
|
const [captureAction, setCaptureAction] = useState(null);
|
||||||
|
const baselineRef = useRef(null);
|
||||||
|
const grouped = useMemo(() => groupActions(ACTIONS), []);
|
||||||
|
|
||||||
|
const activePad = useMemo(
|
||||||
|
() => pickActivePad(hubState.pads, gamepadSettings.activeSignature),
|
||||||
|
[hubState.pads, gamepadSettings.activeSignature],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSignature = activePad?.signature ?? null;
|
||||||
|
const activeProfile = useMemo(() => {
|
||||||
|
if (!activeSignature) {
|
||||||
|
return gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
gamepadSettings?.profiles?.[activeSignature] ??
|
||||||
|
gamepadSettings?.defaults?.profile ??
|
||||||
|
GAMEPAD_PROFILE_DEFAULT
|
||||||
|
);
|
||||||
|
}, [activeSignature, gamepadSettings?.defaults?.profile, gamepadSettings?.profiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activePad || !activeSignature) return;
|
||||||
|
if (gamepadSettings?.profiles?.[activeSignature]) return;
|
||||||
|
saveGamepadSettings((prev) => {
|
||||||
|
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||||
|
if (current.profiles?.[activeSignature]) return current;
|
||||||
|
const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||||
|
const nextProfile = createProfileForPad(activePad, base);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
profiles: {
|
||||||
|
...(current.profiles ?? {}),
|
||||||
|
[activeSignature]: nextProfile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [activePad, activeSignature, gamepadSettings?.profiles, saveGamepadSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
baselineRef.current = null;
|
||||||
|
}, [captureAction, activeSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!captureAction || !activePad) return;
|
||||||
|
if (!baselineRef.current) {
|
||||||
|
baselineRef.current = snapshotBaseline(activePad);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction);
|
||||||
|
if (!descriptor) return;
|
||||||
|
saveGamepadSettings((prev) => {
|
||||||
|
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||||
|
const baseProfile =
|
||||||
|
current.profiles?.[activeSignature] ?? current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||||
|
const nextProfile = {
|
||||||
|
...baseProfile,
|
||||||
|
bindings: {
|
||||||
|
...(baseProfile.bindings ?? {}),
|
||||||
|
[captureAction.id]: {
|
||||||
|
...(baseProfile.bindings?.[captureAction.id] ?? {}),
|
||||||
|
kind: captureAction.kind,
|
||||||
|
sources: [descriptor],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
profiles: {
|
||||||
|
...(current.profiles ?? {}),
|
||||||
|
[activeSignature]: nextProfile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setCaptureAction(null);
|
||||||
|
}, [activePad, activeSignature, captureAction, saveGamepadSettings]);
|
||||||
|
|
||||||
|
const setActiveSignature = useCallback(
|
||||||
|
(signature) => {
|
||||||
|
saveGamepadSettings((prev) => ({
|
||||||
|
...(prev ?? GAMEPAD_SETTINGS_DEFAULTS),
|
||||||
|
activeSignature: signature || null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[saveGamepadSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCalibration = useCallback(
|
||||||
|
(patch) => {
|
||||||
|
saveGamepadSettings((prev) => {
|
||||||
|
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||||
|
const baseProfile =
|
||||||
|
(activeSignature && current.profiles?.[activeSignature]) ??
|
||||||
|
current?.defaults?.profile ??
|
||||||
|
GAMEPAD_PROFILE_DEFAULT;
|
||||||
|
const nextProfile = {
|
||||||
|
...baseProfile,
|
||||||
|
calibration: {
|
||||||
|
...(baseProfile.calibration ?? {}),
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!activeSignature) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
defaults: {
|
||||||
|
...(current.defaults ?? {}),
|
||||||
|
profile: nextProfile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
profiles: {
|
||||||
|
...(current.profiles ?? {}),
|
||||||
|
[activeSignature]: nextProfile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[activeSignature, saveGamepadSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateBinding = useCallback(
|
||||||
|
(actionId, updater) => {
|
||||||
|
saveGamepadSettings((prev) => {
|
||||||
|
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||||
|
const baseProfile =
|
||||||
|
(activeSignature && current.profiles?.[activeSignature]) ??
|
||||||
|
current?.defaults?.profile ??
|
||||||
|
GAMEPAD_PROFILE_DEFAULT;
|
||||||
|
const nextBinding = updater(baseProfile.bindings?.[actionId] ?? {});
|
||||||
|
const nextProfile = {
|
||||||
|
...baseProfile,
|
||||||
|
bindings: {
|
||||||
|
...(baseProfile.bindings ?? {}),
|
||||||
|
[actionId]: nextBinding,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!activeSignature) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
defaults: {
|
||||||
|
...(current.defaults ?? {}),
|
||||||
|
profile: nextProfile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
profiles: {
|
||||||
|
...(current.profiles ?? {}),
|
||||||
|
[activeSignature]: nextProfile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[activeSignature, saveGamepadSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClear = useCallback(
|
||||||
|
(action) => {
|
||||||
|
updateBinding(action.id, (binding) => ({
|
||||||
|
...binding,
|
||||||
|
sources: [],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[updateBinding],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInvert = useCallback(
|
||||||
|
(action, axisKey) => {
|
||||||
|
updateBinding(action.id, (binding) => {
|
||||||
|
const sources = Array.isArray(binding.sources) ? [...binding.sources] : [];
|
||||||
|
if (!sources[0]) return binding;
|
||||||
|
const next = { ...sources[0] };
|
||||||
|
if (axisKey === 'invertX') {
|
||||||
|
next.invertX = !next.invertX;
|
||||||
|
} else if (axisKey === 'invertY') {
|
||||||
|
next.invertY = !next.invertY;
|
||||||
|
} else {
|
||||||
|
next.invert = !next.invert;
|
||||||
|
}
|
||||||
|
sources[0] = next;
|
||||||
|
return { ...binding, sources };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateBinding],
|
||||||
|
);
|
||||||
|
|
||||||
|
const diagnostics = useMemo(() => {
|
||||||
|
if (!activePad) return null;
|
||||||
|
const outputs = computeGamepadOutputs(activePad, activeProfile);
|
||||||
|
return { outputs };
|
||||||
|
}, [activePad, activeProfile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-sm">
|
<section className="panel-section space-y-0.5 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-400">Gamepad mapping</p>
|
<p className="text-xs text-slate-400">Controller</p>
|
||||||
<p className="text-[0.65rem] text-slate-500">
|
<p className="text-[0.65rem] text-slate-500">
|
||||||
{gamepadConnected
|
{activePad ? 'Move sticks or press buttons to bind' : 'Connect a controller to configure.'}
|
||||||
? 'Move sticks/axes with a big sweep or press buttons when capturing.'
|
|
||||||
: 'Connect a controller to configure.'}
|
|
||||||
</p>
|
</p>
|
||||||
{capture && (
|
{captureAction && (
|
||||||
<p className="mt-0 text-[0.7rem] text-emerald-400">Capturing {capture.label}…</p>
|
<p className="mt-0 text-[0.7rem] text-emerald-400">Capturing {captureAction.label}…</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => reset()} className="button-dark text-xs">
|
|
||||||
Clear all
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="space-y-0.5 surface">
|
<div className="space-y-0.5 surface">
|
||||||
<p className="text-[0.7rem] text-slate-500">Sensitivity & feel</p>
|
<p className="text-[0.7rem] text-slate-500">Connected controller</p>
|
||||||
|
{hubState.pads.length === 0 ? (
|
||||||
|
<p className="text-[0.7rem] text-slate-400">No controller detected.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-0.5 text-xs">
|
||||||
|
<select
|
||||||
|
value={activeSignature ?? ''}
|
||||||
|
onChange={(event) => setActiveSignature(event.target.value)}
|
||||||
|
className="flex-1 rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.75rem] text-slate-100"
|
||||||
|
>
|
||||||
|
{hubState.pads.map((pad) => (
|
||||||
|
<option key={pad.signature} value={pad.signature}>
|
||||||
|
{pad.id || 'Unknown controller'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-[0.7rem] text-slate-400">{activePad?.mapping ?? 'unknown'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5 surface">
|
||||||
|
<p className="text-[0.7rem] text-slate-500">Calibration</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<SliderField
|
<SliderField
|
||||||
label="Drive deadzone"
|
label="Drive deadzone"
|
||||||
description="Ignore small drive stick movement"
|
description="Ignore small drive stick drift"
|
||||||
min={0}
|
min={0}
|
||||||
max={0.6}
|
max={0.6}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={gamepadTuning.driveDeadzone ?? 0.2}
|
value={activeProfile.calibration?.driveDeadzone ?? 0.18}
|
||||||
onChange={(driveDeadzone) => updateTuning({ driveDeadzone })}
|
onChange={(value) => updateCalibration({ driveDeadzone: value })}
|
||||||
/>
|
/>
|
||||||
<SliderField
|
<SliderField
|
||||||
label="Camera deadzone"
|
label="Camera deadzone"
|
||||||
description="Tilt stick sensitivity"
|
description="Ignore small camera tilt drift"
|
||||||
min={0}
|
min={0}
|
||||||
max={0.6}
|
max={0.4}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={gamepadTuning.cameraDeadzone ?? 0.25}
|
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
|
||||||
onChange={(cameraDeadzone) => updateTuning({ cameraDeadzone })}
|
onChange={(value) => updateCalibration({ cameraDeadzone: value })}
|
||||||
/>
|
/>
|
||||||
<SliderField
|
<SliderField
|
||||||
label="Servo step (deg)"
|
label="Aux deadzone"
|
||||||
description="Degrees per camera tick"
|
description="Ignore small trigger noise"
|
||||||
min={0.5}
|
min={0}
|
||||||
max={6}
|
max={0.4}
|
||||||
step={0.25}
|
step={0.01}
|
||||||
value={gamepadTuning.servoStep ?? 2}
|
value={activeProfile.calibration?.auxDeadzone ?? 0.05}
|
||||||
onChange={(servoStep) => updateTuning({ servoStep })}
|
onChange={(value) => updateCalibration({ auxDeadzone: value })}
|
||||||
/>
|
/>
|
||||||
<SliderField
|
<SliderField
|
||||||
label="Side reverse multiplier"
|
label="Side brush scale"
|
||||||
description="Scale when reversing the side brush"
|
description="Scale side brush output"
|
||||||
min={0.3}
|
min={0.3}
|
||||||
max={1}
|
max={1}
|
||||||
step={0.05}
|
step={0.05}
|
||||||
value={gamepadTuning.auxReverseScale ?? 0.55}
|
value={activeProfile.calibration?.auxSideScale ?? 0.55}
|
||||||
onChange={(auxReverseScale) => updateTuning({ auxReverseScale })}
|
onChange={(value) => updateCalibration({ auxSideScale: value })}
|
||||||
|
/>
|
||||||
|
<label className="surface-muted block p-0.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||||
|
<span className="font-semibold text-slate-100">Camera mode</span>
|
||||||
|
<select
|
||||||
|
value={activeProfile.calibration?.cameraMode ?? 'absolute'}
|
||||||
|
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
|
||||||
|
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.75rem] text-slate-100"
|
||||||
|
>
|
||||||
|
<option value="absolute">Absolute</option>
|
||||||
|
<option value="velocity">Velocity</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.65rem] text-slate-500">Absolute maps stick to angle; velocity moves over time.</p>
|
||||||
|
</label>
|
||||||
|
<SliderField
|
||||||
|
label="Camera sensitivity"
|
||||||
|
description="Velocity mode degrees per second"
|
||||||
|
min={10}
|
||||||
|
max={180}
|
||||||
|
step={5}
|
||||||
|
value={activeProfile.calibration?.cameraSensitivity ?? 60}
|
||||||
|
onChange={(value) => updateCalibration({ cameraSensitivity: value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5">
|
||||||
{Object.entries(grouped).map(([section, actions]) => (
|
{Object.entries(grouped).map(([section, actions]) => (
|
||||||
<div key={section} className="space-y-0.5 surface">
|
<div key={section} className="space-y-0.5 surface">
|
||||||
<p className="text-[0.7rem] text-slate-500">{section}</p>
|
<p className="text-[0.7rem] text-slate-500">{section}</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{actions.map((action) => (
|
{actions.map((action) => {
|
||||||
|
const binding = activeProfile.bindings?.[action.id];
|
||||||
|
const source = binding?.sources?.[0] ?? null;
|
||||||
|
return (
|
||||||
<div key={action.id} className="surface-muted flex items-center justify-between text-xs">
|
<div key={action.id} className="surface-muted flex items-center justify-between text-xs">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-slate-100">{action.label}</p>
|
<p className="font-semibold text-slate-100">{action.label}</p>
|
||||||
<p className="text-[0.65rem] text-slate-400">{getValueLabel(action)}</p>
|
<p className="text-[0.65rem] text-slate-400">{formatSource(source)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{action.type === 'axis' && (
|
{action.kind === 'axisPair' && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!getStored(action)}
|
disabled={!source}
|
||||||
|
onClick={() => handleInvert(action, 'invertX')}
|
||||||
|
className="button-dark text-[0.7rem] font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Invert X
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!source}
|
||||||
|
onClick={() => handleInvert(action, 'invertY')}
|
||||||
|
className="button-dark text-[0.7rem] font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Invert Y
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{action.kind === 'axis' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!source}
|
||||||
onClick={() => handleInvert(action)}
|
onClick={() => handleInvert(action)}
|
||||||
className={`${
|
className="button-dark text-[0.7rem] font-medium disabled:opacity-50"
|
||||||
getStored(action)?.invert
|
|
||||||
? 'px-0.5 py-0.5 bg-amber-400 text-amber-900 hover:bg-amber-300'
|
|
||||||
: 'button-dark'
|
|
||||||
} text-[0.7rem] font-medium disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
||||||
>
|
>
|
||||||
Invert
|
Invert
|
||||||
</button>
|
</button>
|
||||||
@@ -266,18 +533,69 @@ export default function GamepadMappingSettings() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCapture(action)}
|
onClick={() => setCaptureAction(action)}
|
||||||
className={`${capture?.id === action.id ? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400' : 'button-dark'} text-[0.7rem] font-medium`}
|
className={`${
|
||||||
|
captureAction?.id === action.id
|
||||||
|
? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400'
|
||||||
|
: 'button-dark'
|
||||||
|
} text-[0.7rem] font-medium`}
|
||||||
>
|
>
|
||||||
{capture?.id === action.id ? 'Waiting…' : 'Capture'}
|
{captureAction?.id === action.id ? 'Waiting…' : 'Capture'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5 surface">
|
||||||
|
<p className="text-[0.7rem] text-slate-500">Diagnostics</p>
|
||||||
|
{!activePad ? (
|
||||||
|
<p className="text-[0.7rem] text-slate-400">No controller detected.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5 text-[0.7rem] text-slate-300">
|
||||||
|
<p className="text-slate-400">Raw axes</p>
|
||||||
|
<div className="grid grid-cols-2 gap-0.5">
|
||||||
|
{activePad.axes.map((value, index) => (
|
||||||
|
<span key={`axis-${index}`} className="font-mono text-slate-300">
|
||||||
|
A{index}: {NUMBER_FORMAT.format(value)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400">Raw buttons</p>
|
||||||
|
<div className="grid grid-cols-2 gap-0.5">
|
||||||
|
{activePad.buttons.map((btn, index) => (
|
||||||
|
<span key={`btn-${index}`} className="font-mono text-slate-300">
|
||||||
|
B{index}: {NUMBER_FORMAT.format(btn.value)} {btn.pressed ? '●' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{diagnostics?.outputs && (
|
||||||
|
<>
|
||||||
|
<p className="text-slate-400">Mapped outputs</p>
|
||||||
|
<div className="grid grid-cols-2 gap-0.5">
|
||||||
|
<span className="font-mono text-slate-300">
|
||||||
|
Drive: {NUMBER_FORMAT.format(diagnostics.outputs.driveVector.x)},{' '}
|
||||||
|
{NUMBER_FORMAT.format(diagnostics.outputs.driveVector.y)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-slate-300">
|
||||||
|
Camera: {NUMBER_FORMAT.format(diagnostics.outputs.cameraAxis)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-slate-300">
|
||||||
|
Main: {NUMBER_FORMAT.format(diagnostics.outputs.auxAxis.main)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-slate-300">
|
||||||
|
Side: {NUMBER_FORMAT.format(diagnostics.outputs.auxAxis.side)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,82 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useControlSystem } from '../ControlContext.jsx';
|
import { useControlSystem } from '../ControlContext.jsx';
|
||||||
import { clampUnit } from '../controlMath.js';
|
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { INPUT_SETTINGS_DEFAULTS, GAMEPAD_MAPPING_DEFAULT } from '../../settings/namespaces.js';
|
import { GAMEPAD_SETTINGS_DEFAULTS, GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
||||||
|
import {
|
||||||
|
computeGamepadOutputs,
|
||||||
|
createProfileForPad,
|
||||||
|
getPadSignature,
|
||||||
|
} from './gamepadBindings.js';
|
||||||
|
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||||
|
|
||||||
const SOURCE = 'gamepad';
|
const SOURCE = 'gamepad';
|
||||||
|
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||||
|
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
|
||||||
|
|
||||||
function applyDeadzone(value, amount) {
|
function areVectorsEqual(a, b) {
|
||||||
return Math.abs(value) < amount ? 0 : value;
|
return a && b && a.x === b.x && a.y === b.y && a.boost === b.boost;
|
||||||
}
|
}
|
||||||
|
|
||||||
function vectorsEqual(a, b) {
|
function areAuxEqual(a, b) {
|
||||||
return (
|
|
||||||
a &&
|
|
||||||
b &&
|
|
||||||
a.x === b.x &&
|
|
||||||
a.y === b.y &&
|
|
||||||
a.boost === b.boost
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function auxEqual(a, b) {
|
|
||||||
return a && b && a.main === b.main && a.side === b.side && a.vacuum === b.vacuum;
|
return a && b && a.main === b.main && a.side === b.side && a.vacuum === b.vacuum;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickActivePad(pads, activeSignature) {
|
||||||
|
if (!pads || pads.length === 0) return null;
|
||||||
|
if (activeSignature) {
|
||||||
|
const match = pads.find((pad) => pad.signature === activeSignature);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return pads[0];
|
||||||
|
}
|
||||||
|
|
||||||
export default function GamepadInputManager() {
|
export default function GamepadInputManager() {
|
||||||
const {
|
const {
|
||||||
|
state,
|
||||||
actions: {
|
actions: {
|
||||||
setMode,
|
setMode,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
nudgeServo,
|
setServoAngle,
|
||||||
runMacro,
|
runMacro,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
},
|
},
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||||
const { value: mapping } = useSettingsNamespace('gamepadMapping', GAMEPAD_MAPPING_DEFAULT);
|
'gamepad',
|
||||||
const gamepadSettings = inputSettings.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad;
|
GAMEPAD_SETTINGS_DEFAULTS,
|
||||||
const driveDeadzone = Math.min(Math.max(gamepadSettings.driveDeadzone ?? 0.2, 0), 0.8);
|
);
|
||||||
const cameraDeadzone = Math.min(Math.max(gamepadSettings.cameraDeadzone ?? 0.25, 0), 0.9);
|
const profileCacheRef = useRef(new Set());
|
||||||
const servoStep = gamepadSettings.servoStep ?? INPUT_SETTINGS_DEFAULTS.gamepad.servoStep;
|
const lastVectorRef = useRef(ZERO_VECTOR);
|
||||||
const auxReverseScale = gamepadSettings.auxReverseScale ?? INPUT_SETTINGS_DEFAULTS.gamepad.auxReverseScale;
|
const lastAuxRef = useRef(ZERO_AUX);
|
||||||
const driveReady = Boolean(mapping?.drive?.horizontal && mapping?.drive?.vertical);
|
|
||||||
const cameraReady = Boolean(mapping?.camera?.vertical);
|
|
||||||
const mainAxisReady = Boolean(mapping?.brushes?.mainAxis);
|
|
||||||
const sideAxisReady = Boolean(mapping?.brushes?.sideAxis);
|
|
||||||
const brushesReady = mainAxisReady || sideAxisReady;
|
|
||||||
const vacuumReady = Boolean(mapping?.buttons?.vacuum);
|
|
||||||
const allAuxReady = Boolean(mapping?.buttons?.allAux);
|
|
||||||
const auxButtonsReady = vacuumReady || allAuxReady;
|
|
||||||
const mainReverseReady = Boolean(mapping?.buttons?.mainReverse);
|
|
||||||
const sideReverseReady = Boolean(mapping?.buttons?.sideReverse);
|
|
||||||
const reverseButtonsReady = mainReverseReady || sideReverseReady;
|
|
||||||
const driveMacroReady = Boolean(mapping?.buttons?.drive);
|
|
||||||
const dockMacroReady = Boolean(mapping?.buttons?.dock);
|
|
||||||
const macrosReady = driveMacroReady || dockMacroReady;
|
|
||||||
const mappingReady =
|
|
||||||
driveReady || cameraReady || brushesReady || auxButtonsReady || reverseButtonsReady || macrosReady;
|
|
||||||
const rafRef = useRef(null);
|
|
||||||
const lastVectorRef = useRef({ x: 0, y: 0, boost: false });
|
|
||||||
const lastAuxRef = useRef({ main: 0, side: 0, vacuum: 0 });
|
|
||||||
const buttonStateRef = useRef(new Map());
|
|
||||||
const servoThrottleRef = useRef(0);
|
|
||||||
const reverseStateRef = useRef({ main: false, side: false });
|
const reverseStateRef = useRef({ main: false, side: false });
|
||||||
|
const buttonStateRef = useRef(new Map());
|
||||||
|
const lastServoAtRef = useRef(0);
|
||||||
|
const lastServoAngleRef = useRef(null);
|
||||||
|
|
||||||
|
const ensureProfile = useCallback(
|
||||||
|
(padState) => {
|
||||||
|
const signature = padState.signature ?? getPadSignature(padState);
|
||||||
|
if (gamepadSettings?.profiles?.[signature] || profileCacheRef.current.has(signature)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
profileCacheRef.current.add(signature);
|
||||||
|
saveGamepadSettings((prev) => {
|
||||||
|
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||||
|
if (current.profiles?.[signature]) return current;
|
||||||
|
const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||||
|
const nextProfile = createProfileForPad(padState, base);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
profiles: {
|
||||||
|
...(current.profiles ?? {}),
|
||||||
|
[signature]: nextProfile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[gamepadSettings?.profiles, saveGamepadSettings],
|
||||||
|
);
|
||||||
|
|
||||||
const handleButtonEdge = useCallback((key, pressed) => {
|
const handleButtonEdge = useCallback((key, pressed) => {
|
||||||
const prev = buttonStateRef.current.get(key) || false;
|
const prev = buttonStateRef.current.get(key) || false;
|
||||||
@@ -71,171 +84,164 @@ export default function GamepadInputManager() {
|
|||||||
return pressed && !prev;
|
return pressed && !prev;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getAxisValue = useCallback((pad, descriptor) => {
|
const handleCameraAxis = useCallback(
|
||||||
if (!descriptor) return 0;
|
(axisValue, calibration) => {
|
||||||
const raw = pad.axes?.[descriptor.index] ?? 0;
|
const config = state.camera?.config;
|
||||||
return descriptor.invert ? -raw : raw;
|
if (!config) return;
|
||||||
}, []);
|
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||||
|
const cameraMode = calibration?.cameraMode ?? 'absolute';
|
||||||
const getButtonValue = useCallback((pad, descriptor) => {
|
const sensitivity = Math.max(1, Math.min(180, calibration?.cameraSensitivity ?? 60));
|
||||||
if (!descriptor) return 0;
|
if (cameraMode === 'velocity') {
|
||||||
const btn = pad.buttons?.[descriptor.index];
|
const dt = Math.min(50, now - lastServoAtRef.current || 16);
|
||||||
if (!btn) return 0;
|
const delta = axisValue * sensitivity * (dt / 1000);
|
||||||
return typeof btn.value === 'number' ? btn.value : btn.pressed ? 1 : 0;
|
if (Math.abs(delta) < 0.01) return;
|
||||||
}, []);
|
const baseline =
|
||||||
|
typeof lastServoAngleRef.current === 'number'
|
||||||
const isButtonPressed = useCallback((pad, descriptor) => {
|
? lastServoAngleRef.current
|
||||||
if (!descriptor) return false;
|
: typeof state.camera?.angle === 'number'
|
||||||
const btn = pad.buttons?.[descriptor.index];
|
? state.camera.angle
|
||||||
if (!btn) return false;
|
: typeof config.homeAngle === 'number'
|
||||||
return btn.pressed || btn.value > 0.5;
|
? config.homeAngle
|
||||||
}, []);
|
: 0;
|
||||||
|
const nextAngle = baseline + delta;
|
||||||
const pollGamepads = useCallback(() => {
|
setServoAngle(nextAngle);
|
||||||
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
|
lastServoAngleRef.current = nextAngle;
|
||||||
|
lastServoAtRef.current = now;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pads = navigator.getGamepads();
|
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
|
||||||
const pad = pads && Array.from(pads).find(Boolean);
|
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
|
||||||
if (!pad) {
|
const home = typeof config.homeAngle === 'number' ? config.homeAngle : (min + max) / 2;
|
||||||
if (!vectorsEqual(lastVectorRef.current, { x: 0, y: 0, boost: false })) {
|
const angle =
|
||||||
lastVectorRef.current = { x: 0, y: 0, boost: false };
|
axisValue < 0
|
||||||
setDriveVector({ x: 0, y: 0, boost: false }, { source: SOURCE });
|
? home + axisValue * (home - min)
|
||||||
|
: home + axisValue * (max - home);
|
||||||
|
if (
|
||||||
|
typeof lastServoAngleRef.current === 'number' &&
|
||||||
|
Math.abs(lastServoAngleRef.current - angle) < 0.35 &&
|
||||||
|
now - lastServoAtRef.current < 80
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!auxEqual(lastAuxRef.current, { main: 0, side: 0, vacuum: 0 })) {
|
lastServoAtRef.current = now;
|
||||||
lastAuxRef.current = { main: 0, side: 0, vacuum: 0 };
|
lastServoAngleRef.current = angle;
|
||||||
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
|
setServoAngle(angle);
|
||||||
|
},
|
||||||
|
[setServoAngle, state.camera?.angle, state.camera?.config],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSignature = useMemo(
|
||||||
|
() => gamepadSettings?.activeSignature ?? null,
|
||||||
|
[gamepadSettings?.activeSignature],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeGamepadHub((hubState) => {
|
||||||
|
const activePad = pickActivePad(hubState.pads, activeSignature);
|
||||||
|
if (!activePad) {
|
||||||
|
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||||
|
lastVectorRef.current = ZERO_VECTOR;
|
||||||
|
setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||||
}
|
}
|
||||||
registerInputState(SOURCE, { connected: false, mappingReady });
|
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
|
||||||
|
lastAuxRef.current = ZERO_AUX;
|
||||||
|
setAuxMotors(ZERO_AUX);
|
||||||
|
}
|
||||||
|
buttonStateRef.current = new Map();
|
||||||
|
reverseStateRef.current = { main: false, side: false };
|
||||||
|
registerInputState(SOURCE, { connected: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const axisLX = driveReady
|
ensureProfile(activePad);
|
||||||
? clampUnit(applyDeadzone(getAxisValue(pad, mapping.drive.horizontal), driveDeadzone))
|
const signature = activePad.signature;
|
||||||
: 0;
|
const profile =
|
||||||
const axisLY = driveReady
|
gamepadSettings?.profiles?.[signature] ??
|
||||||
? clampUnit(applyDeadzone(-getAxisValue(pad, mapping.drive.vertical), driveDeadzone))
|
gamepadSettings?.defaults?.profile ??
|
||||||
: 0;
|
GAMEPAD_PROFILE_DEFAULT;
|
||||||
const vector = { x: axisLX, y: axisLY, boost: false };
|
const outputs = computeGamepadOutputs(activePad, profile);
|
||||||
if (!vectorsEqual(vector, lastVectorRef.current)) {
|
|
||||||
lastVectorRef.current = vector;
|
if (!areVectorsEqual(outputs.driveVector, lastVectorRef.current)) {
|
||||||
setDriveVector(vector, { source: SOURCE });
|
lastVectorRef.current = outputs.driveVector;
|
||||||
|
setDriveVector(outputs.driveVector, { source: SOURCE });
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainRaw = mainAxisReady ? getAxisValue(pad, mapping.brushes.mainAxis) : 0;
|
const auxSideScale = profile.calibration?.auxSideScale ?? 0.55;
|
||||||
const mainMagnitude = Math.round(Math.min(Math.abs(mainRaw), 1) * 127);
|
const mainMagnitude = Math.round(Math.min(Math.abs(outputs.auxAxis.main), 1) * 127);
|
||||||
|
const sideMagnitude = Math.round(Math.min(Math.abs(outputs.auxAxis.side), 1) * 127);
|
||||||
const main = reverseStateRef.current.main ? -mainMagnitude : mainMagnitude;
|
const main = reverseStateRef.current.main ? -mainMagnitude : mainMagnitude;
|
||||||
|
|
||||||
const sideRaw = sideAxisReady ? getAxisValue(pad, mapping.brushes.sideAxis) : 0;
|
|
||||||
const sideMagnitude = Math.round(Math.min(Math.abs(sideRaw), 1) * 127);
|
|
||||||
const side = reverseStateRef.current.side
|
const side = reverseStateRef.current.side
|
||||||
? -Math.round(sideMagnitude * auxReverseScale)
|
? -Math.round(sideMagnitude * auxSideScale)
|
||||||
: Math.round(sideMagnitude * auxReverseScale);
|
: Math.round(sideMagnitude * auxSideScale);
|
||||||
|
let aux = {
|
||||||
const vacuum = vacuumReady && isButtonPressed(pad, mapping.buttons.vacuum) ? 127 : 0;
|
main: outputs.auxAxis.main !== 0 ? main : 0,
|
||||||
let aux = { main: mainAxisReady ? main : 0, side: sideAxisReady ? side : 0, vacuum };
|
side: outputs.auxAxis.side !== 0 ? side : 0,
|
||||||
if (allAuxReady && isButtonPressed(pad, mapping.buttons.allAux)) {
|
vacuum: outputs.buttons.vacuum ? 127 : 0,
|
||||||
|
};
|
||||||
|
if (outputs.buttons.allAux) {
|
||||||
aux = { main: 127, side: 127, vacuum: 127 };
|
aux = { main: 127, side: 127, vacuum: 127 };
|
||||||
}
|
}
|
||||||
if (!auxEqual(aux, lastAuxRef.current)) {
|
if (!areAuxEqual(aux, lastAuxRef.current)) {
|
||||||
lastAuxRef.current = aux;
|
lastAuxRef.current = aux;
|
||||||
setAuxMotors(aux);
|
setAuxMotors(aux);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraAxis = cameraReady
|
if (outputs.buttons.mainReverse && handleButtonEdge('mainReverse', true)) {
|
||||||
? clampUnit(applyDeadzone(-getAxisValue(pad, mapping.camera.vertical), cameraDeadzone))
|
|
||||||
: 0;
|
|
||||||
const now = performance.now();
|
|
||||||
if (cameraReady && Math.abs(cameraAxis) > 0.25 && now - servoThrottleRef.current > 120) {
|
|
||||||
servoThrottleRef.current = now;
|
|
||||||
nudgeServo(cameraAxis > 0 ? servoStep : -servoStep);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
mainReverseReady &&
|
|
||||||
handleButtonEdge('toggle-main', isButtonPressed(pad, mapping.buttons.mainReverse))
|
|
||||||
) {
|
|
||||||
reverseStateRef.current.main = !reverseStateRef.current.main;
|
reverseStateRef.current.main = !reverseStateRef.current.main;
|
||||||
}
|
} else if (!outputs.buttons.mainReverse) {
|
||||||
if (
|
handleButtonEdge('mainReverse', false);
|
||||||
sideReverseReady &&
|
|
||||||
handleButtonEdge('toggle-side', isButtonPressed(pad, mapping.buttons.sideReverse))
|
|
||||||
) {
|
|
||||||
reverseStateRef.current.side = !reverseStateRef.current.side;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (outputs.buttons.sideReverse && handleButtonEdge('sideReverse', true)) {
|
||||||
driveMacroReady &&
|
reverseStateRef.current.side = !reverseStateRef.current.side;
|
||||||
handleButtonEdge(`macro-${mapping.buttons.drive.index}`, isButtonPressed(pad, mapping.buttons.drive))
|
} else if (!outputs.buttons.sideReverse) {
|
||||||
) {
|
handleButtonEdge('sideReverse', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
||||||
setMode('drive');
|
setMode('drive');
|
||||||
runMacro('drive-sequence');
|
runMacro('drive-sequence');
|
||||||
|
} else if (!outputs.buttons.driveMacro) {
|
||||||
|
handleButtonEdge('driveMacro', false);
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
dockMacroReady &&
|
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
||||||
handleButtonEdge(`macro-${mapping.buttons.dock.index}`, isButtonPressed(pad, mapping.buttons.dock))
|
|
||||||
) {
|
|
||||||
setMode('dock');
|
setMode('dock');
|
||||||
runMacro('seek-dock');
|
runMacro('seek-dock');
|
||||||
|
} else if (!outputs.buttons.dockMacro) {
|
||||||
|
handleButtonEdge('dockMacro', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(outputs.cameraAxis) > 0.001) {
|
||||||
|
handleCameraAxis(outputs.cameraAxis, profile.calibration);
|
||||||
}
|
}
|
||||||
|
|
||||||
registerInputState(SOURCE, {
|
registerInputState(SOURCE, {
|
||||||
connected: true,
|
connected: true,
|
||||||
mappingReady,
|
signature,
|
||||||
id: pad.id,
|
id: activePad.id,
|
||||||
index: pad.index,
|
index: activePad.index,
|
||||||
axes: [axisLX, axisLY, cameraAxis],
|
axes: activePad.axes,
|
||||||
reverse: { ...reverseStateRef.current },
|
buttons: activePad.buttons,
|
||||||
bindings: {
|
drive: outputs.driveVector,
|
||||||
drive: driveReady,
|
aux,
|
||||||
camera: cameraReady,
|
cameraAxis: outputs.cameraAxis,
|
||||||
brushes: brushesReady,
|
bindings: outputs.sources,
|
||||||
auxButtons: { vacuum: vacuumReady, allAux: allAuxReady },
|
});
|
||||||
reverseButtons: { main: mainReverseReady, side: sideReverseReady },
|
|
||||||
macros: { drive: driveMacroReady, dock: dockMacroReady },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
auxReverseScale,
|
activeSignature,
|
||||||
cameraDeadzone,
|
ensureProfile,
|
||||||
driveDeadzone,
|
gamepadSettings?.defaults?.profile,
|
||||||
getAxisValue,
|
gamepadSettings?.profiles,
|
||||||
getButtonValue,
|
|
||||||
handleButtonEdge,
|
handleButtonEdge,
|
||||||
isButtonPressed,
|
handleCameraAxis,
|
||||||
mapping.buttons?.allAux,
|
|
||||||
mapping.buttons?.dock,
|
|
||||||
mapping.buttons?.drive,
|
|
||||||
mapping.buttons?.mainReverse,
|
|
||||||
mapping.buttons?.sideReverse,
|
|
||||||
mapping.buttons?.vacuum,
|
|
||||||
mapping.camera?.vertical,
|
|
||||||
mapping.drive?.horizontal,
|
|
||||||
mapping.drive?.vertical,
|
|
||||||
mapping.brushes?.mainAxis,
|
|
||||||
mapping.brushes?.sideAxis,
|
|
||||||
mappingReady,
|
|
||||||
nudgeServo,
|
|
||||||
registerInputState,
|
registerInputState,
|
||||||
runMacro,
|
runMacro,
|
||||||
servoStep,
|
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setMode,
|
setMode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function loop() {
|
|
||||||
pollGamepads();
|
|
||||||
rafRef.current = requestAnimationFrame(loop);
|
|
||||||
}
|
|
||||||
rafRef.current = requestAnimationFrame(loop);
|
|
||||||
return () => {
|
|
||||||
if (rafRef.current) {
|
|
||||||
cancelAnimationFrame(rafRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [pollGamepads]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
const CURVE_EXPO = 1.6;
|
||||||
|
|
||||||
|
export function getPadSignature(pad) {
|
||||||
|
if (!pad) return 'unknown::none::0::0';
|
||||||
|
const id = pad.id || 'unknown';
|
||||||
|
const mapping = pad.mapping || 'none';
|
||||||
|
const axes = Array.isArray(pad.axes) ? pad.axes.length : 0;
|
||||||
|
const buttons = Array.isArray(pad.buttons) ? pad.buttons.length : 0;
|
||||||
|
return `${id}::${mapping}::${axes}::${buttons}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneProfile(profile) {
|
||||||
|
return JSON.parse(JSON.stringify(profile ?? {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProfileForPad(pad, baseProfile) {
|
||||||
|
const profile = cloneProfile(baseProfile);
|
||||||
|
profile.meta = {
|
||||||
|
id: pad?.id ?? 'Unknown',
|
||||||
|
mapping: pad?.mapping ?? 'none',
|
||||||
|
axes: pad?.axes?.length ?? 0,
|
||||||
|
buttons: pad?.buttons?.length ?? 0,
|
||||||
|
};
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampUnit(value) {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.max(-1, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCurve(value, curve) {
|
||||||
|
if (!value) return 0;
|
||||||
|
const abs = Math.abs(value);
|
||||||
|
if (curve === 'expo') {
|
||||||
|
return Math.sign(value) * Math.pow(abs, CURVE_EXPO);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAxisDeadzone(value, deadzone) {
|
||||||
|
const abs = Math.abs(value);
|
||||||
|
if (abs <= deadzone) return 0;
|
||||||
|
const scaled = (abs - deadzone) / (1 - deadzone);
|
||||||
|
return Math.sign(value) * scaled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRadialDeadzone(x, y, deadzone) {
|
||||||
|
const mag = Math.hypot(x, y);
|
||||||
|
if (mag <= deadzone) return { x: 0, y: 0 };
|
||||||
|
const scaled = (mag - deadzone) / (1 - deadzone);
|
||||||
|
const ratio = scaled / mag;
|
||||||
|
return { x: x * ratio, y: y * ratio };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAxis(padState, index) {
|
||||||
|
if (!padState || !Array.isArray(padState.axes)) return null;
|
||||||
|
const value = padState.axes[index];
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
return clampUnit(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readButton(padState, index) {
|
||||||
|
if (!padState || !Array.isArray(padState.buttons)) return null;
|
||||||
|
const btn = padState.buttons[index];
|
||||||
|
if (!btn) return null;
|
||||||
|
const value = typeof btn.value === 'number' ? btn.value : btn.pressed ? 1 : 0;
|
||||||
|
return {
|
||||||
|
pressed: Boolean(btn.pressed || value > 0.5),
|
||||||
|
value: Math.max(0, Math.min(1, value)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAxisSource(padState, sources = []) {
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) continue;
|
||||||
|
if (source.kind === 'axis') {
|
||||||
|
const value = readAxis(padState, source.index);
|
||||||
|
if (value === null) continue;
|
||||||
|
return { value: source.invert ? -value : value, source };
|
||||||
|
}
|
||||||
|
if (source.kind === 'buttonAxis') {
|
||||||
|
const button = readButton(padState, source.index);
|
||||||
|
if (!button) continue;
|
||||||
|
return { value: clampUnit(button.value), source };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { value: 0, source: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAxisPairSource(padState, sources = []) {
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source || source.kind !== 'axisPair') continue;
|
||||||
|
const rawX = readAxis(padState, source.x);
|
||||||
|
const rawY = readAxis(padState, source.y);
|
||||||
|
if (rawX === null || rawY === null) continue;
|
||||||
|
const x = source.invertX ? -rawX : rawX;
|
||||||
|
const y = source.invertY ? -rawY : rawY;
|
||||||
|
return { x, y, source };
|
||||||
|
}
|
||||||
|
return { x: 0, y: 0, source: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveButtonSource(padState, sources = []) {
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) continue;
|
||||||
|
if (source.kind === 'button') {
|
||||||
|
const btn = readButton(padState, source.index);
|
||||||
|
if (!btn) continue;
|
||||||
|
return { pressed: btn.pressed, source };
|
||||||
|
}
|
||||||
|
if (source.kind === 'axisButton') {
|
||||||
|
const value = readAxis(padState, source.index);
|
||||||
|
if (value === null) continue;
|
||||||
|
const direction = source.direction || 1;
|
||||||
|
const threshold = typeof source.threshold === 'number' ? source.threshold : 0.6;
|
||||||
|
return { pressed: value * direction > threshold, source };
|
||||||
|
}
|
||||||
|
if (source.kind === 'buttonAxis') {
|
||||||
|
const btn = readButton(padState, source.index);
|
||||||
|
if (!btn) continue;
|
||||||
|
return { pressed: btn.value > 0.5, source };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { pressed: false, source: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeGamepadOutputs(padState, profile) {
|
||||||
|
const bindings = profile?.bindings ?? {};
|
||||||
|
const calibration = profile?.calibration ?? {};
|
||||||
|
|
||||||
|
const driveBinding = bindings.drive ?? {};
|
||||||
|
const driveSource = resolveAxisPairSource(padState, driveBinding.sources);
|
||||||
|
let driveX = clampUnit(driveSource.x);
|
||||||
|
let driveY = clampUnit(driveSource.y);
|
||||||
|
const driveDeadzone = Math.min(Math.max(calibration.driveDeadzone ?? 0.18, 0), 0.8);
|
||||||
|
const driveCurved = applyRadialDeadzone(driveX, driveY, driveDeadzone);
|
||||||
|
driveX = applyCurve(driveCurved.x, calibration.driveCurve);
|
||||||
|
driveY = applyCurve(driveCurved.y, calibration.driveCurve);
|
||||||
|
|
||||||
|
const cameraBinding = bindings.cameraTilt ?? {};
|
||||||
|
const cameraSource = resolveAxisSource(padState, cameraBinding.sources);
|
||||||
|
const cameraDeadzone = Math.min(Math.max(calibration.cameraDeadzone ?? 0.08, 0), 0.8);
|
||||||
|
let cameraAxis = applyAxisDeadzone(clampUnit(cameraSource.value), cameraDeadzone);
|
||||||
|
cameraAxis = applyCurve(cameraAxis, calibration.cameraCurve);
|
||||||
|
|
||||||
|
const auxDeadzone = Math.min(Math.max(calibration.auxDeadzone ?? 0.05, 0), 0.6);
|
||||||
|
const mainBinding = bindings.mainBrush ?? {};
|
||||||
|
const mainSource = resolveAxisSource(padState, mainBinding.sources);
|
||||||
|
let mainAxis = applyAxisDeadzone(clampUnit(mainSource.value), auxDeadzone);
|
||||||
|
mainAxis = applyCurve(mainAxis, calibration.auxCurve);
|
||||||
|
|
||||||
|
const sideBinding = bindings.sideBrush ?? {};
|
||||||
|
const sideSource = resolveAxisSource(padState, sideBinding.sources);
|
||||||
|
let sideAxis = applyAxisDeadzone(clampUnit(sideSource.value), auxDeadzone);
|
||||||
|
sideAxis = applyCurve(sideAxis, calibration.auxCurve);
|
||||||
|
|
||||||
|
const vacuumSource = resolveButtonSource(padState, bindings.vacuum?.sources);
|
||||||
|
const allAuxSource = resolveButtonSource(padState, bindings.allAux?.sources);
|
||||||
|
const mainReverseSource = resolveButtonSource(padState, bindings.mainReverse?.sources);
|
||||||
|
const sideReverseSource = resolveButtonSource(padState, bindings.sideReverse?.sources);
|
||||||
|
const driveMacroSource = resolveButtonSource(padState, bindings.driveMacro?.sources);
|
||||||
|
const dockMacroSource = resolveButtonSource(padState, bindings.dockMacro?.sources);
|
||||||
|
|
||||||
|
return {
|
||||||
|
driveVector: { x: driveX, y: driveY, boost: false },
|
||||||
|
cameraAxis,
|
||||||
|
auxAxis: { main: mainAxis, side: sideAxis },
|
||||||
|
buttons: {
|
||||||
|
vacuum: vacuumSource.pressed,
|
||||||
|
allAux: allAuxSource.pressed,
|
||||||
|
mainReverse: mainReverseSource.pressed,
|
||||||
|
sideReverse: sideReverseSource.pressed,
|
||||||
|
driveMacro: driveMacroSource.pressed,
|
||||||
|
dockMacro: dockMacroSource.pressed,
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
drive: driveSource.source,
|
||||||
|
cameraTilt: cameraSource.source,
|
||||||
|
mainBrush: mainSource.source,
|
||||||
|
sideBrush: sideSource.source,
|
||||||
|
vacuum: vacuumSource.source,
|
||||||
|
allAux: allAuxSource.source,
|
||||||
|
mainReverse: mainReverseSource.source,
|
||||||
|
sideReverse: sideReverseSource.source,
|
||||||
|
driveMacro: driveMacroSource.source,
|
||||||
|
dockMacro: dockMacroSource.source,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getPadSignature } from './gamepadBindings.js';
|
||||||
|
|
||||||
|
const listeners = new Set();
|
||||||
|
let rafId = null;
|
||||||
|
let lastState = { pads: [], timestamp: 0 };
|
||||||
|
|
||||||
|
function readGamepads() {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const pads = navigator.getGamepads();
|
||||||
|
if (!pads) return [];
|
||||||
|
return Array.from(pads).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPadState(pad) {
|
||||||
|
return {
|
||||||
|
index: pad.index,
|
||||||
|
id: pad.id,
|
||||||
|
mapping: pad.mapping,
|
||||||
|
connected: pad.connected !== false,
|
||||||
|
timestamp: pad.timestamp,
|
||||||
|
axes: Array.from(pad.axes ?? []),
|
||||||
|
buttons: (pad.buttons ?? []).map((btn) => ({
|
||||||
|
pressed: Boolean(btn?.pressed),
|
||||||
|
value: typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0,
|
||||||
|
})),
|
||||||
|
signature: getPadSignature(pad),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateState() {
|
||||||
|
const pads = readGamepads().map(buildPadState);
|
||||||
|
lastState = {
|
||||||
|
pads,
|
||||||
|
timestamp: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||||
|
};
|
||||||
|
listeners.forEach((listener) => listener(lastState));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop() {
|
||||||
|
updateState();
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLoop() {
|
||||||
|
if (rafId) return;
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLoop() {
|
||||||
|
if (!rafId) return;
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeGamepadHub(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
startLoop();
|
||||||
|
listener(lastState);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
stopLoop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGamepadHubState() {
|
||||||
|
return lastState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGamepadHubState() {
|
||||||
|
const [state, setState] = useState(lastState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeGamepadHub(setState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -102,9 +102,9 @@ export const HELP_CONTENT = {
|
|||||||
type: 'gamepad',
|
type: 'gamepad',
|
||||||
title: 'Gamepad / joystick',
|
title: 'Gamepad / joystick',
|
||||||
items: [
|
items: [
|
||||||
'Gamepad controls are not mapped by default, this is because of how terribly inconsistent gamepad implementations are across browsers and devices.',
|
'Gamepad controls are fully configurable to handle inconsistent browser mappings.',
|
||||||
'You can map gamepad controls in Settings → Controller.',
|
'Use Settings → Controller to bind inputs, calibrate deadzones, and view diagnostics.',
|
||||||
'Use at your own risk, it may not be perfect depending on your setup.'
|
'If inputs behave oddly, the diagnostics panel will show raw axis/button values.'
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,32 +6,78 @@ export const INPUT_SETTINGS_DEFAULTS = {
|
|||||||
tiltSpeed: 80,
|
tiltSpeed: 80,
|
||||||
tiltIntervalMs: 110,
|
tiltIntervalMs: 110,
|
||||||
},
|
},
|
||||||
gamepad: {
|
};
|
||||||
driveDeadzone: 0.2,
|
|
||||||
cameraDeadzone: 0.25,
|
export const GAMEPAD_PROFILE_DEFAULT = {
|
||||||
servoStep: 2,
|
label: 'Default',
|
||||||
auxReverseScale: 0.55,
|
calibration: {
|
||||||
|
driveDeadzone: 0.18,
|
||||||
|
cameraDeadzone: 0.08,
|
||||||
|
auxDeadzone: 0.05,
|
||||||
|
driveCurve: 'linear',
|
||||||
|
cameraCurve: 'linear',
|
||||||
|
auxCurve: 'linear',
|
||||||
|
cameraMode: 'absolute',
|
||||||
|
cameraSensitivity: 60,
|
||||||
|
auxSideScale: 0.55,
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
drive: {
|
||||||
|
kind: 'axisPair',
|
||||||
|
sources: [{ kind: 'axisPair', x: 0, y: 1, invertX: false, invertY: true }],
|
||||||
|
},
|
||||||
|
cameraTilt: {
|
||||||
|
kind: 'axis',
|
||||||
|
sources: [
|
||||||
|
{ kind: 'axis', index: 3, invert: true },
|
||||||
|
{ kind: 'axis', index: 1, invert: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
mainBrush: {
|
||||||
|
kind: 'axis',
|
||||||
|
sources: [
|
||||||
|
{ kind: 'buttonAxis', index: 6 },
|
||||||
|
{ kind: 'axis', index: 2, invert: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sideBrush: {
|
||||||
|
kind: 'axis',
|
||||||
|
sources: [
|
||||||
|
{ kind: 'buttonAxis', index: 7 },
|
||||||
|
{ kind: 'axis', index: 5, invert: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
vacuum: {
|
||||||
|
kind: 'button',
|
||||||
|
sources: [{ kind: 'button', index: 0 }],
|
||||||
|
},
|
||||||
|
allAux: {
|
||||||
|
kind: 'button',
|
||||||
|
sources: [{ kind: 'button', index: 1 }],
|
||||||
|
},
|
||||||
|
mainReverse: {
|
||||||
|
kind: 'button',
|
||||||
|
sources: [{ kind: 'button', index: 4 }],
|
||||||
|
},
|
||||||
|
sideReverse: {
|
||||||
|
kind: 'button',
|
||||||
|
sources: [{ kind: 'button', index: 5 }],
|
||||||
|
},
|
||||||
|
driveMacro: {
|
||||||
|
kind: 'button',
|
||||||
|
sources: [{ kind: 'button', index: 2 }],
|
||||||
|
},
|
||||||
|
dockMacro: {
|
||||||
|
kind: 'button',
|
||||||
|
sources: [{ kind: 'button', index: 3 }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GAMEPAD_MAPPING_DEFAULT = {
|
export const GAMEPAD_SETTINGS_DEFAULTS = {
|
||||||
drive: {
|
activeSignature: null,
|
||||||
horizontal: null,
|
profiles: {},
|
||||||
vertical: null,
|
defaults: {
|
||||||
},
|
profile: GAMEPAD_PROFILE_DEFAULT,
|
||||||
camera: {
|
|
||||||
vertical: null,
|
|
||||||
},
|
|
||||||
brushes: {
|
|
||||||
mainAxis: null,
|
|
||||||
sideAxis: null,
|
|
||||||
},
|
|
||||||
buttons: {
|
|
||||||
allAux: null,
|
|
||||||
vacuum: null,
|
|
||||||
mainReverse: null,
|
|
||||||
sideReverse: null,
|
|
||||||
dock: null,
|
|
||||||
drive: null,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user