gamepad ripout

This commit is contained in:
legop3
2026-01-25 17:15:57 -05:00
parent b6b1bee1dd
commit 689d914b4e
10 changed files with 1198 additions and 556 deletions
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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DefZtORt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bg0Bo3-n.css">
<script type="module" crossorigin src="/assets/index-DWCUBkjO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D-qq3X_S.css">
</head>
<body>
<div id="root"></div>
+534 -216
View File
@@ -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 { GAMEPAD_MAPPING_DEFAULT, INPUT_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
function useGamepad() {
const [connected, setConnected] = useState(false);
useEffect(() => {
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;
}
import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import {
computeGamepadOutputs,
createProfileForPad,
} from '../controls/inputs/gamepadBindings.js';
import { useGamepadHubState } from '../controls/inputs/gamepadHub.js';
const NUMBER_FORMAT = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
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: 'cameraVertical', label: 'Camera vertical', type: 'axis', section: 'Camera joystick', path: ['camera', 'vertical'] },
{ id: 'mainAxis', label: 'Main brush axis', type: 'axis', section: 'Brush analog', path: ['brushes', 'mainAxis'] },
{ id: 'sideAxis', label: 'Side brush axis', type: 'axis', section: 'Brush analog', path: ['brushes', 'sideAxis'] },
{ id: 'mainReverse', label: 'Toggle main reverse', type: 'button', section: 'Brush toggles', path: ['buttons', 'mainReverse'] },
{ 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: 'drive', label: 'Drive macro button', type: 'button', section: 'Mode buttons', path: ['buttons', 'drive'] },
{ id: 'dock', label: 'Dock macro button', type: 'button', section: 'Mode buttons', path: ['buttons', 'dock'] },
{
id: 'drive',
label: 'Drive stick',
kind: 'axisPair',
section: 'Driving',
invertDefaults: { invertX: false, invertY: true },
},
{
id: 'cameraTilt',
label: 'Camera tilt',
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 }) {
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">
<span className="font-semibold text-slate-100">{label}</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) {
if (!value) return 'Unassigned';
return `Axis ${value.index}${value.invert ? ' (invert)' : ''}`;
function formatSource(source) {
if (!source) return 'Unassigned';
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) {
if (!value) return 'Unassigned';
return `Button ${value.index}`;
function groupActions(actions) {
return actions.reduce((acc, action) => {
const list = acc[action.section] || (acc[action.section] = []);
list.push(action);
return acc;
}, {});
}
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;
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];
}
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() {
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 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 updateTuning = (patch) => {
saveInputSettings((prev) => ({
...prev,
gamepad: { ...(prev.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad), ...patch },
}));
};
const activePad = useMemo(
() => pickActivePad(hubState.pads, gamepadSettings.activeSignature),
[hubState.pads, gamepadSettings.activeSignature],
);
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] = []);
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,
})),
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 (
<section className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between">
<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">
{gamepadConnected
? 'Move sticks/axes with a big sweep or press buttons when capturing.'
: 'Connect a controller to configure.'}
{activePad ? 'Move sticks or press buttons to bind' : 'Connect a controller to configure.'}
</p>
{capture && (
<p className="mt-0 text-[0.7rem] text-emerald-400">Capturing {capture.label}</p>
{captureAction && (
<p className="mt-0 text-[0.7rem] text-emerald-400">Capturing {captureAction.label}</p>
)}
</div>
<button type="button" onClick={() => reset()} className="button-dark text-xs">
Clear all
</button>
</div>
<div className="space-y-0.5">
<div className="space-y-0.5 surface">
<p className="text-[0.7rem] text-slate-500">Sensitivity &amp; feel</p>
<div className="space-y-0.5">
<SliderField
label="Drive deadzone"
description="Ignore small drive stick movement"
min={0}
max={0.6}
step={0.01}
value={gamepadTuning.driveDeadzone ?? 0.2}
onChange={(driveDeadzone) => updateTuning({ driveDeadzone })}
/>
<SliderField
label="Camera deadzone"
description="Tilt stick sensitivity"
min={0}
max={0.6}
step={0.01}
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 className="space-y-0.5 surface">
<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">
<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 className="space-y-0.5">
{Object.entries(grouped).map(([section, actions]) => (
<div key={section} className="space-y-0.5 surface">
<p className="text-[0.7rem] text-slate-500">{section}</p>
<div className="space-y-0.5">
{actions.map((action) => (
<div key={action.id} className="surface-muted flex items-center justify-between text-xs">
<div>
<p className="font-semibold text-slate-100">{action.label}</p>
<p className="text-[0.65rem] text-slate-400">{getValueLabel(action)}</p>
</div>
<div className="flex items-center gap-0.5">
{action.type === 'axis' && (
{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>
<p className="font-semibold text-slate-100">{action.label}</p>
<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
type="button"
disabled={!getStored(action)}
onClick={() => handleInvert(action)}
onClick={() => setCaptureAction(action)}
className={`${
getStored(action)?.invert
? 'px-0.5 py-0.5 bg-amber-400 text-amber-900 hover:bg-amber-300'
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 disabled:opacity-50 disabled:cursor-not-allowed`}
} text-[0.7rem] font-medium`}
>
Invert
{captureAction?.id === action.id ? 'Waiting…' : 'Capture'}
</button>
)}
<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 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>
);
}
+197 -191
View File
@@ -1,69 +1,82 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useControlSystem } from '../ControlContext.jsx';
import { clampUnit } from '../controlMath.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 ZERO_VECTOR = { x: 0, y: 0, boost: false };
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
function applyDeadzone(value, amount) {
return Math.abs(value) < amount ? 0 : value;
function areVectorsEqual(a, b) {
return a && b && a.x === b.x && a.y === b.y && a.boost === b.boost;
}
function vectorsEqual(a, b) {
return (
a &&
b &&
a.x === b.x &&
a.y === b.y &&
a.boost === b.boost
);
}
function auxEqual(a, b) {
function areAuxEqual(a, b) {
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() {
const {
state,
actions: {
setMode,
setDriveVector,
setAuxMotors,
nudgeServo,
setServoAngle,
runMacro,
registerInputState,
},
} = useControlSystem();
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
const { value: mapping } = useSettingsNamespace('gamepadMapping', GAMEPAD_MAPPING_DEFAULT);
const gamepadSettings = inputSettings.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad;
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 servoStep = gamepadSettings.servoStep ?? INPUT_SETTINGS_DEFAULTS.gamepad.servoStep;
const auxReverseScale = gamepadSettings.auxReverseScale ?? INPUT_SETTINGS_DEFAULTS.gamepad.auxReverseScale;
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 { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
'gamepad',
GAMEPAD_SETTINGS_DEFAULTS,
);
const profileCacheRef = useRef(new Set());
const lastVectorRef = useRef(ZERO_VECTOR);
const lastAuxRef = useRef(ZERO_AUX);
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 prev = buttonStateRef.current.get(key) || false;
@@ -71,171 +84,164 @@ export default function GamepadInputManager() {
return pressed && !prev;
}, []);
const getAxisValue = useCallback((pad, descriptor) => {
if (!descriptor) return 0;
const raw = pad.axes?.[descriptor.index] ?? 0;
return descriptor.invert ? -raw : raw;
}, []);
const getButtonValue = useCallback((pad, descriptor) => {
if (!descriptor) return 0;
const btn = pad.buttons?.[descriptor.index];
if (!btn) return 0;
return typeof btn.value === 'number' ? btn.value : btn.pressed ? 1 : 0;
}, []);
const isButtonPressed = useCallback((pad, descriptor) => {
if (!descriptor) return false;
const btn = pad.buttons?.[descriptor.index];
if (!btn) return false;
return btn.pressed || btn.value > 0.5;
}, []);
const pollGamepads = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
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 });
const handleCameraAxis = useCallback(
(axisValue, calibration) => {
const config = state.camera?.config;
if (!config) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const cameraMode = calibration?.cameraMode ?? 'absolute';
const sensitivity = Math.max(1, Math.min(180, calibration?.cameraSensitivity ?? 60));
if (cameraMode === 'velocity') {
const dt = Math.min(50, now - lastServoAtRef.current || 16);
const delta = axisValue * sensitivity * (dt / 1000);
if (Math.abs(delta) < 0.01) return;
const baseline =
typeof lastServoAngleRef.current === 'number'
? lastServoAngleRef.current
: typeof state.camera?.angle === 'number'
? state.camera.angle
: typeof config.homeAngle === 'number'
? config.homeAngle
: 0;
const nextAngle = baseline + delta;
setServoAngle(nextAngle);
lastServoAngleRef.current = nextAngle;
lastServoAtRef.current = now;
return;
}
if (!auxEqual(lastAuxRef.current, { main: 0, side: 0, vacuum: 0 })) {
lastAuxRef.current = { main: 0, side: 0, vacuum: 0 };
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
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 });
return;
}
lastServoAtRef.current = now;
lastServoAngleRef.current = angle;
setServoAngle(angle);
},
[setServoAngle, state.camera?.angle, state.camera?.config],
);
const axisLX = driveReady
? clampUnit(applyDeadzone(getAxisValue(pad, mapping.drive.horizontal), driveDeadzone))
: 0;
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 activeSignature = useMemo(
() => gamepadSettings?.activeSignature ?? null,
[gamepadSettings?.activeSignature],
);
const mainRaw = mainAxisReady ? getAxisValue(pad, mapping.brushes.mainAxis) : 0;
const mainMagnitude = Math.round(Math.min(Math.abs(mainRaw), 1) * 127);
const main = reverseStateRef.current.main ? -mainMagnitude : mainMagnitude;
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 });
}
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;
const sideMagnitude = Math.round(Math.min(Math.abs(sideRaw), 1) * 127);
const side = reverseStateRef.current.side
? -Math.round(sideMagnitude * auxReverseScale)
: Math.round(sideMagnitude * auxReverseScale);
ensureProfile(activePad);
const signature = activePad.signature;
const profile =
gamepadSettings?.profiles?.[signature] ??
gamepadSettings?.defaults?.profile ??
GAMEPAD_PROFILE_DEFAULT;
const outputs = computeGamepadOutputs(activePad, profile);
const vacuum = vacuumReady && isButtonPressed(pad, mapping.buttons.vacuum) ? 127 : 0;
let aux = { main: mainAxisReady ? main : 0, side: sideAxisReady ? side : 0, vacuum };
if (allAuxReady && isButtonPressed(pad, mapping.buttons.allAux)) {
aux = { main: 127, side: 127, vacuum: 127 };
}
if (!auxEqual(aux, lastAuxRef.current)) {
lastAuxRef.current = aux;
setAuxMotors(aux);
}
if (!areVectorsEqual(outputs.driveVector, lastVectorRef.current)) {
lastVectorRef.current = outputs.driveVector;
setDriveVector(outputs.driveVector, { source: SOURCE });
}
const cameraAxis = cameraReady
? 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);
}
const auxSideScale = profile.calibration?.auxSideScale ?? 0.55;
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 side = reverseStateRef.current.side
? -Math.round(sideMagnitude * auxSideScale)
: 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 (
mainReverseReady &&
handleButtonEdge('toggle-main', isButtonPressed(pad, mapping.buttons.mainReverse))
) {
reverseStateRef.current.main = !reverseStateRef.current.main;
}
if (
sideReverseReady &&
handleButtonEdge('toggle-side', isButtonPressed(pad, mapping.buttons.sideReverse))
) {
reverseStateRef.current.side = !reverseStateRef.current.side;
}
if (outputs.buttons.mainReverse && handleButtonEdge('mainReverse', true)) {
reverseStateRef.current.main = !reverseStateRef.current.main;
} else if (!outputs.buttons.mainReverse) {
handleButtonEdge('mainReverse', false);
}
if (
driveMacroReady &&
handleButtonEdge(`macro-${mapping.buttons.drive.index}`, isButtonPressed(pad, mapping.buttons.drive))
) {
setMode('drive');
runMacro('drive-sequence');
}
if (
dockMacroReady &&
handleButtonEdge(`macro-${mapping.buttons.dock.index}`, isButtonPressed(pad, mapping.buttons.dock))
) {
setMode('dock');
runMacro('seek-dock');
}
if (outputs.buttons.sideReverse && handleButtonEdge('sideReverse', true)) {
reverseStateRef.current.side = !reverseStateRef.current.side;
} else if (!outputs.buttons.sideReverse) {
handleButtonEdge('sideReverse', false);
}
registerInputState(SOURCE, {
connected: true,
mappingReady,
id: pad.id,
index: pad.index,
axes: [axisLX, axisLY, cameraAxis],
reverse: { ...reverseStateRef.current },
bindings: {
drive: driveReady,
camera: cameraReady,
brushes: brushesReady,
auxButtons: { vacuum: vacuumReady, allAux: allAuxReady },
reverseButtons: { main: mainReverseReady, side: sideReverseReady },
macros: { drive: driveMacroReady, dock: dockMacroReady },
},
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
setMode('drive');
runMacro('drive-sequence');
} else if (!outputs.buttons.driveMacro) {
handleButtonEdge('driveMacro', false);
}
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
setMode('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, {
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,
cameraDeadzone,
driveDeadzone,
getAxisValue,
getButtonValue,
activeSignature,
ensureProfile,
gamepadSettings?.defaults?.profile,
gamepadSettings?.profiles,
handleButtonEdge,
isButtonPressed,
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,
handleCameraAxis,
registerInputState,
runMacro,
servoStep,
setAuxMotors,
setDriveVector,
setMode,
]);
useEffect(() => {
function loop() {
pollGamepads();
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, [pollGamepads]);
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,
},
};
}
+82
View File
@@ -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;
}
+3 -3
View File
@@ -102,9 +102,9 @@ export const HELP_CONTENT = {
type: 'gamepad',
title: 'Gamepad / joystick',
items: [
'Gamepad controls are not mapped by default, this is because of how terribly inconsistent gamepad implementations are across browsers and devices.',
'You can map gamepad controls in Settings → Controller.',
'Use at your own risk, it may not be perfect depending on your setup.'
'Gamepad controls are fully configurable to handle inconsistent browser mappings.',
'Use Settings → Controller to bind inputs, calibrate deadzones, and view diagnostics.',
'If inputs behave oddly, the diagnostics panel will show raw axis/button values.'
],
},
],
+70 -24
View File
@@ -6,32 +6,78 @@ export const INPUT_SETTINGS_DEFAULTS = {
tiltSpeed: 80,
tiltIntervalMs: 110,
},
gamepad: {
driveDeadzone: 0.2,
cameraDeadzone: 0.25,
servoStep: 2,
auxReverseScale: 0.55,
};
export const GAMEPAD_PROFILE_DEFAULT = {
label: 'Default',
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 = {
drive: {
horizontal: null,
vertical: null,
},
camera: {
vertical: null,
},
brushes: {
mainAxis: null,
sideAxis: null,
},
buttons: {
allAux: null,
vacuum: null,
mainReverse: null,
sideReverse: null,
dock: null,
drive: null,
export const GAMEPAD_SETTINGS_DEFAULTS = {
activeSignature: null,
profiles: {},
defaults: {
profile: GAMEPAD_PROFILE_DEFAULT,
},
};