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,223 +70,532 @@ 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}`;
|
const list = acc[action.section] || (acc[action.section] = []);
|
||||||
|
list.push(action);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePath(mapping, path, updater) {
|
function pickActivePad(pads, activeSignature) {
|
||||||
const [group, key] = path;
|
if (!pads || pads.length === 0) return null;
|
||||||
const next = { ...(mapping ?? GAMEPAD_MAPPING_DEFAULT) };
|
if (activeSignature) {
|
||||||
next[group] = { ...(next[group] ?? GAMEPAD_MAPPING_DEFAULT[group]) };
|
const match = pads.find((pad) => pad.signature === activeSignature);
|
||||||
next[group][key] = updater(next[group][key]);
|
if (match) return match;
|
||||||
return next;
|
}
|
||||||
|
return pads[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotBaseline(pad) {
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function GamepadMappingSettings() {
|
||||||
const gamepadConnected = useGamepad();
|
const hubState = useGamepadHubState();
|
||||||
const { value: mapping, save, reset } = useSettingsNamespace('gamepadMapping', GAMEPAD_MAPPING_DEFAULT);
|
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||||
const { value: inputSettings, save: saveInputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
'gamepad',
|
||||||
const gamepadTuning = inputSettings.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad;
|
GAMEPAD_SETTINGS_DEFAULTS,
|
||||||
const [capture, setCapture] = useState(null);
|
);
|
||||||
const axisBaselineRef = useRef(null);
|
const [captureAction, setCaptureAction] = useState(null);
|
||||||
|
const baselineRef = useRef(null);
|
||||||
|
const grouped = useMemo(() => groupActions(ACTIONS), []);
|
||||||
|
|
||||||
const updateTuning = (patch) => {
|
const activePad = useMemo(
|
||||||
saveInputSettings((prev) => ({
|
() => pickActivePad(hubState.pads, gamepadSettings.activeSignature),
|
||||||
...prev,
|
[hubState.pads, gamepadSettings.activeSignature],
|
||||||
gamepad: { ...(prev.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad), ...patch },
|
);
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
const activeSignature = activePad?.signature ?? null;
|
||||||
axisBaselineRef.current = null;
|
const activeProfile = useMemo(() => {
|
||||||
}, [capture]);
|
if (!activeSignature) {
|
||||||
|
return gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||||
useEffect(() => {
|
}
|
||||||
if (!capture) return undefined;
|
return (
|
||||||
let raf;
|
gamepadSettings?.profiles?.[activeSignature] ??
|
||||||
const scan = () => {
|
gamepadSettings?.defaults?.profile ??
|
||||||
const pads = navigator.getGamepads?.();
|
GAMEPAD_PROFILE_DEFAULT
|
||||||
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] = []);
|
|
||||||
list.push(action);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getStored = (action) => {
|
|
||||||
const [group, key] = action.path;
|
|
||||||
return mapping?.[group]?.[key] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getValueLabel = (action) => {
|
|
||||||
const stored = getStored(action);
|
|
||||||
return action.type === 'axis' ? formatAxis(stored) : formatButton(stored);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = (action) => {
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
};
|
}, [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>
|
||||||
<div className="space-y-0.5">
|
{hubState.pads.length === 0 ? (
|
||||||
<SliderField
|
<p className="text-[0.7rem] text-slate-400">No controller detected.</p>
|
||||||
label="Drive deadzone"
|
) : (
|
||||||
description="Ignore small drive stick movement"
|
<div className="flex items-center justify-between gap-0.5 text-xs">
|
||||||
min={0}
|
<select
|
||||||
max={0.6}
|
value={activeSignature ?? ''}
|
||||||
step={0.01}
|
onChange={(event) => setActiveSignature(event.target.value)}
|
||||||
value={gamepadTuning.driveDeadzone ?? 0.2}
|
className="flex-1 rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.75rem] text-slate-100"
|
||||||
onChange={(driveDeadzone) => updateTuning({ driveDeadzone })}
|
>
|
||||||
/>
|
{hubState.pads.map((pad) => (
|
||||||
<SliderField
|
<option key={pad.signature} value={pad.signature}>
|
||||||
label="Camera deadzone"
|
{pad.id || 'Unknown controller'}
|
||||||
description="Tilt stick sensitivity"
|
</option>
|
||||||
min={0}
|
))}
|
||||||
max={0.6}
|
</select>
|
||||||
step={0.01}
|
<span className="text-[0.7rem] text-slate-400">{activePad?.mapping ?? 'unknown'}</span>
|
||||||
value={gamepadTuning.cameraDeadzone ?? 0.25}
|
|
||||||
onChange={(cameraDeadzone) => updateTuning({ cameraDeadzone })}
|
|
||||||
/>
|
|
||||||
<SliderField
|
|
||||||
label="Servo step (deg)"
|
|
||||||
description="Degrees per camera tick"
|
|
||||||
min={0.5}
|
|
||||||
max={6}
|
|
||||||
step={0.25}
|
|
||||||
value={gamepadTuning.servoStep ?? 2}
|
|
||||||
onChange={(servoStep) => updateTuning({ servoStep })}
|
|
||||||
/>
|
|
||||||
<SliderField
|
|
||||||
label="Side reverse multiplier"
|
|
||||||
description="Scale when reversing the side brush"
|
|
||||||
min={0.3}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={gamepadTuning.auxReverseScale ?? 0.55}
|
|
||||||
onChange={(auxReverseScale) => updateTuning({ auxReverseScale })}
|
|
||||||
/>
|
|
||||||
</div>
|
</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">
|
||||||
|
<SliderField
|
||||||
|
label="Drive deadzone"
|
||||||
|
description="Ignore small drive stick drift"
|
||||||
|
min={0}
|
||||||
|
max={0.6}
|
||||||
|
step={0.01}
|
||||||
|
value={activeProfile.calibration?.driveDeadzone ?? 0.18}
|
||||||
|
onChange={(value) => updateCalibration({ driveDeadzone: value })}
|
||||||
|
/>
|
||||||
|
<SliderField
|
||||||
|
label="Camera deadzone"
|
||||||
|
description="Ignore small camera tilt drift"
|
||||||
|
min={0}
|
||||||
|
max={0.4}
|
||||||
|
step={0.01}
|
||||||
|
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
|
||||||
|
onChange={(value) => updateCalibration({ cameraDeadzone: value })}
|
||||||
|
/>
|
||||||
|
<SliderField
|
||||||
|
label="Aux deadzone"
|
||||||
|
description="Ignore small trigger noise"
|
||||||
|
min={0}
|
||||||
|
max={0.4}
|
||||||
|
step={0.01}
|
||||||
|
value={activeProfile.calibration?.auxDeadzone ?? 0.05}
|
||||||
|
onChange={(value) => updateCalibration({ auxDeadzone: value })}
|
||||||
|
/>
|
||||||
|
<SliderField
|
||||||
|
label="Side brush scale"
|
||||||
|
description="Scale side brush output"
|
||||||
|
min={0.3}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={activeProfile.calibration?.auxSideScale ?? 0.55}
|
||||||
|
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 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) => {
|
||||||
<div key={action.id} className="surface-muted flex items-center justify-between text-xs">
|
const binding = activeProfile.bindings?.[action.id];
|
||||||
<div>
|
const source = binding?.sources?.[0] ?? null;
|
||||||
<p className="font-semibold text-slate-100">{action.label}</p>
|
return (
|
||||||
<p className="text-[0.65rem] text-slate-400">{getValueLabel(action)}</p>
|
<div key={action.id} className="surface-muted flex items-center justify-between text-xs">
|
||||||
</div>
|
<div>
|
||||||
<div className="flex items-center gap-0.5">
|
<p className="font-semibold text-slate-100">{action.label}</p>
|
||||||
{action.type === 'axis' && (
|
<p className="text-[0.65rem] text-slate-400">{formatSource(source)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{action.kind === 'axisPair' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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)}
|
||||||
|
className="button-dark text-[0.7rem] font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Invert
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => handleClear(action)} className="button-dark text-[0.7rem]">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!getStored(action)}
|
onClick={() => setCaptureAction(action)}
|
||||||
onClick={() => handleInvert(action)}
|
|
||||||
className={`${
|
className={`${
|
||||||
getStored(action)?.invert
|
captureAction?.id === action.id
|
||||||
? 'px-0.5 py-0.5 bg-amber-400 text-amber-900 hover:bg-amber-300'
|
? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400'
|
||||||
: 'button-dark'
|
: 'button-dark'
|
||||||
} text-[0.7rem] font-medium disabled:opacity-50 disabled:cursor-not-allowed`}
|
} text-[0.7rem] font-medium`}
|
||||||
>
|
>
|
||||||
Invert
|
{captureAction?.id === action.id ? 'Waiting…' : 'Capture'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<button type="button" onClick={() => handleClear(action)} className="button-dark text-[0.7rem]">
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCapture(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`}
|
|
||||||
>
|
|
||||||
{capture?.id === action.id ? 'Waiting…' : 'Capture'}
|
|
||||||
</button>
|
|
||||||
</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;
|
||||||
return;
|
lastServoAtRef.current = now;
|
||||||
}
|
return;
|
||||||
const pads = navigator.getGamepads();
|
|
||||||
const pad = pads && Array.from(pads).find(Boolean);
|
|
||||||
if (!pad) {
|
|
||||||
if (!vectorsEqual(lastVectorRef.current, { x: 0, y: 0, boost: false })) {
|
|
||||||
lastVectorRef.current = { x: 0, y: 0, boost: false };
|
|
||||||
setDriveVector({ x: 0, y: 0, boost: false }, { source: SOURCE });
|
|
||||||
}
|
}
|
||||||
if (!auxEqual(lastAuxRef.current, { main: 0, side: 0, vacuum: 0 })) {
|
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
|
||||||
lastAuxRef.current = { main: 0, side: 0, vacuum: 0 };
|
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
|
||||||
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
|
const home = typeof config.homeAngle === 'number' ? config.homeAngle : (min + max) / 2;
|
||||||
|
const angle =
|
||||||
|
axisValue < 0
|
||||||
|
? 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;
|
||||||
}
|
}
|
||||||
registerInputState(SOURCE, { connected: false, mappingReady });
|
lastServoAtRef.current = now;
|
||||||
return;
|
lastServoAngleRef.current = angle;
|
||||||
}
|
setServoAngle(angle);
|
||||||
|
},
|
||||||
|
[setServoAngle, state.camera?.angle, state.camera?.config],
|
||||||
|
);
|
||||||
|
|
||||||
const axisLX = driveReady
|
const activeSignature = useMemo(
|
||||||
? clampUnit(applyDeadzone(getAxisValue(pad, mapping.drive.horizontal), driveDeadzone))
|
() => gamepadSettings?.activeSignature ?? null,
|
||||||
: 0;
|
[gamepadSettings?.activeSignature],
|
||||||
const axisLY = driveReady
|
);
|
||||||
? clampUnit(applyDeadzone(-getAxisValue(pad, mapping.drive.vertical), driveDeadzone))
|
|
||||||
: 0;
|
|
||||||
const vector = { x: axisLX, y: axisLY, boost: false };
|
|
||||||
if (!vectorsEqual(vector, lastVectorRef.current)) {
|
|
||||||
lastVectorRef.current = vector;
|
|
||||||
setDriveVector(vector, { source: SOURCE });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainRaw = mainAxisReady ? getAxisValue(pad, mapping.brushes.mainAxis) : 0;
|
useEffect(() => {
|
||||||
const mainMagnitude = Math.round(Math.min(Math.abs(mainRaw), 1) * 127);
|
return subscribeGamepadHub((hubState) => {
|
||||||
const main = reverseStateRef.current.main ? -mainMagnitude : mainMagnitude;
|
const activePad = pickActivePad(hubState.pads, activeSignature);
|
||||||
|
if (!activePad) {
|
||||||
|
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||||
|
lastVectorRef.current = ZERO_VECTOR;
|
||||||
|
setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const sideRaw = sideAxisReady ? getAxisValue(pad, mapping.brushes.sideAxis) : 0;
|
ensureProfile(activePad);
|
||||||
const sideMagnitude = Math.round(Math.min(Math.abs(sideRaw), 1) * 127);
|
const signature = activePad.signature;
|
||||||
const side = reverseStateRef.current.side
|
const profile =
|
||||||
? -Math.round(sideMagnitude * auxReverseScale)
|
gamepadSettings?.profiles?.[signature] ??
|
||||||
: Math.round(sideMagnitude * auxReverseScale);
|
gamepadSettings?.defaults?.profile ??
|
||||||
|
GAMEPAD_PROFILE_DEFAULT;
|
||||||
|
const outputs = computeGamepadOutputs(activePad, profile);
|
||||||
|
|
||||||
const vacuum = vacuumReady && isButtonPressed(pad, mapping.buttons.vacuum) ? 127 : 0;
|
if (!areVectorsEqual(outputs.driveVector, lastVectorRef.current)) {
|
||||||
let aux = { main: mainAxisReady ? main : 0, side: sideAxisReady ? side : 0, vacuum };
|
lastVectorRef.current = outputs.driveVector;
|
||||||
if (allAuxReady && isButtonPressed(pad, mapping.buttons.allAux)) {
|
setDriveVector(outputs.driveVector, { source: SOURCE });
|
||||||
aux = { main: 127, side: 127, vacuum: 127 };
|
}
|
||||||
}
|
|
||||||
if (!auxEqual(aux, lastAuxRef.current)) {
|
|
||||||
lastAuxRef.current = aux;
|
|
||||||
setAuxMotors(aux);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cameraAxis = cameraReady
|
const auxSideScale = profile.calibration?.auxSideScale ?? 0.55;
|
||||||
? clampUnit(applyDeadzone(-getAxisValue(pad, mapping.camera.vertical), cameraDeadzone))
|
const mainMagnitude = Math.round(Math.min(Math.abs(outputs.auxAxis.main), 1) * 127);
|
||||||
: 0;
|
const sideMagnitude = Math.round(Math.min(Math.abs(outputs.auxAxis.side), 1) * 127);
|
||||||
const now = performance.now();
|
const main = reverseStateRef.current.main ? -mainMagnitude : mainMagnitude;
|
||||||
if (cameraReady && Math.abs(cameraAxis) > 0.25 && now - servoThrottleRef.current > 120) {
|
const side = reverseStateRef.current.side
|
||||||
servoThrottleRef.current = now;
|
? -Math.round(sideMagnitude * auxSideScale)
|
||||||
nudgeServo(cameraAxis > 0 ? servoStep : -servoStep);
|
: Math.round(sideMagnitude * auxSideScale);
|
||||||
}
|
let aux = {
|
||||||
|
main: outputs.auxAxis.main !== 0 ? main : 0,
|
||||||
|
side: outputs.auxAxis.side !== 0 ? side : 0,
|
||||||
|
vacuum: outputs.buttons.vacuum ? 127 : 0,
|
||||||
|
};
|
||||||
|
if (outputs.buttons.allAux) {
|
||||||
|
aux = { main: 127, side: 127, vacuum: 127 };
|
||||||
|
}
|
||||||
|
if (!areAuxEqual(aux, lastAuxRef.current)) {
|
||||||
|
lastAuxRef.current = aux;
|
||||||
|
setAuxMotors(aux);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (outputs.buttons.mainReverse && handleButtonEdge('mainReverse', true)) {
|
||||||
mainReverseReady &&
|
reverseStateRef.current.main = !reverseStateRef.current.main;
|
||||||
handleButtonEdge('toggle-main', isButtonPressed(pad, mapping.buttons.mainReverse))
|
} else if (!outputs.buttons.mainReverse) {
|
||||||
) {
|
handleButtonEdge('mainReverse', false);
|
||||||
reverseStateRef.current.main = !reverseStateRef.current.main;
|
}
|
||||||
}
|
|
||||||
if (
|
|
||||||
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);
|
||||||
setMode('drive');
|
}
|
||||||
runMacro('drive-sequence');
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
dockMacroReady &&
|
|
||||||
handleButtonEdge(`macro-${mapping.buttons.dock.index}`, isButtonPressed(pad, mapping.buttons.dock))
|
|
||||||
) {
|
|
||||||
setMode('dock');
|
|
||||||
runMacro('seek-dock');
|
|
||||||
}
|
|
||||||
|
|
||||||
registerInputState(SOURCE, {
|
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
||||||
connected: true,
|
setMode('drive');
|
||||||
mappingReady,
|
runMacro('drive-sequence');
|
||||||
id: pad.id,
|
} else if (!outputs.buttons.driveMacro) {
|
||||||
index: pad.index,
|
handleButtonEdge('driveMacro', false);
|
||||||
axes: [axisLX, axisLY, cameraAxis],
|
}
|
||||||
reverse: { ...reverseStateRef.current },
|
|
||||||
bindings: {
|
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
||||||
drive: driveReady,
|
setMode('dock');
|
||||||
camera: cameraReady,
|
runMacro('seek-dock');
|
||||||
brushes: brushesReady,
|
} else if (!outputs.buttons.dockMacro) {
|
||||||
auxButtons: { vacuum: vacuumReady, allAux: allAuxReady },
|
handleButtonEdge('dockMacro', false);
|
||||||
reverseButtons: { main: mainReverseReady, side: sideReverseReady },
|
}
|
||||||
macros: { drive: driveMacroReady, dock: dockMacroReady },
|
|
||||||
},
|
if (Math.abs(outputs.cameraAxis) > 0.001) {
|
||||||
|
handleCameraAxis(outputs.cameraAxis, profile.calibration);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerInputState(SOURCE, {
|
||||||
|
connected: true,
|
||||||
|
signature,
|
||||||
|
id: activePad.id,
|
||||||
|
index: activePad.index,
|
||||||
|
axes: activePad.axes,
|
||||||
|
buttons: activePad.buttons,
|
||||||
|
drive: outputs.driveVector,
|
||||||
|
aux,
|
||||||
|
cameraAxis: outputs.cameraAxis,
|
||||||
|
bindings: outputs.sources,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
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