mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
slop tank steering
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
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
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<!-- site-metadata:inject -->
|
<!-- site-metadata:inject -->
|
||||||
<!-- analytics:inject -->
|
<!-- analytics:inject -->
|
||||||
<script type="module" crossorigin src="/assets/index-DZVbaoV6.js"></script>
|
<script type="module" crossorigin src="/assets/index-BSetI9nD.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DJhimuQc.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DJhimuQc.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ research how midis can be played easier with drag and drop, auto selection based
|
|||||||
and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging
|
and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging
|
||||||
```
|
```
|
||||||
1. setting to disable replay popups in spectator settings menu
|
1. setting to disable replay popups in spectator settings menu
|
||||||
2. improve controller support for bignuts700
|
2. add admin ui for VIP and private requests instead of only through discord
|
||||||
3. add admin ui for VIP and private requests instead of only through discord
|
3. make roverd self update checkout to main branch
|
||||||
4. automated rover needs help system
|
4. fix this:
|
||||||
5. make roverd self update checkout to main branch
|
|
||||||
6. fix this:
|
|
||||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||||
|
|||||||
@@ -28,6 +28,24 @@ function SettingsGroupLabel({ children }) {
|
|||||||
return <p className="mx-auto w-full max-w-lg text-sm font-semibold text-white">{children}</p>;
|
return <p className="mx-auto w-full max-w-lg text-sm font-semibold text-white">{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function physicalInputKey(source) {
|
||||||
|
/* Inversion and activation thresholds describe how an input is interpreted, not which physical
|
||||||
|
control it is. Ignoring those fields ensures Axis 3 cannot silently own both a tank track and
|
||||||
|
camera tilt merely because one binding happens to be inverted. */
|
||||||
|
if (source?.kind === 'axis' || source?.kind === 'axisButton') return `axis:${source.index}`;
|
||||||
|
if (source?.kind === 'button' || source?.kind === 'buttonAxis') return `button:${source.index}`;
|
||||||
|
return JSON.stringify(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourcesUseSamePhysicalInput(left, right) {
|
||||||
|
if (!left || !right) return false;
|
||||||
|
return physicalInputKey(left) === physicalInputKey(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionDriveMode(actionId) {
|
||||||
|
return ACTIONS.find((action) => action.id === actionId)?.driveMode ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function CurveField({ label, value, onChange }) {
|
function CurveField({ label, value, onChange }) {
|
||||||
return (
|
return (
|
||||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||||
@@ -136,19 +154,15 @@ export default function GamepadMappingSettings() {
|
|||||||
const [actionFilter, setActionFilter] = useState('');
|
const [actionFilter, setActionFilter] = useState('');
|
||||||
const baselineRef = useRef(null);
|
const baselineRef = useRef(null);
|
||||||
const captureCandidateRef = useRef(null);
|
const captureCandidateRef = useRef(null);
|
||||||
const grouped = useMemo(() => {
|
|
||||||
const query = actionFilter.trim().toLowerCase();
|
|
||||||
const visibleActions = query
|
|
||||||
? ACTIONS.filter((action) => `${action.label} ${action.section}`.toLowerCase().includes(query))
|
|
||||||
: ACTIONS;
|
|
||||||
return groupActions(visibleActions);
|
|
||||||
}, [actionFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/* The Controller tab is a diagnostic surface. Locking for its entire mounted lifetime makes
|
/* The settings panel remains a live control surface so operators can tune calibration while
|
||||||
calibration and casual input testing safe, not only the brief moment a binding is captured. */
|
driving and immediately feel the result. Only capture owns the controller lock: without
|
||||||
return acquireControllerControlLock('controller-settings');
|
that narrow guard, pressing the input being assigned could also drive a wheel, start a
|
||||||
}, []);
|
motor, or toggle rover hardware before the new binding is saved. */
|
||||||
|
if (!captureAction) return undefined;
|
||||||
|
return acquireControllerControlLock('controller-binding-capture');
|
||||||
|
}, [captureAction]);
|
||||||
|
|
||||||
const activePad = useMemo(
|
const activePad = useMemo(
|
||||||
() => pickActivePad(hubState.pads, gamepadSettings.activeInstanceKey),
|
() => pickActivePad(hubState.pads, gamepadSettings.activeInstanceKey),
|
||||||
@@ -166,6 +180,19 @@ export default function GamepadMappingSettings() {
|
|||||||
);
|
);
|
||||||
return resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
return resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
||||||
}, [activeSignature, gamepadSettings?.defaults?.profile, gamepadSettings?.profiles]);
|
}, [activeSignature, gamepadSettings?.defaults?.profile, gamepadSettings?.profiles]);
|
||||||
|
const driveMode = activeProfile.calibration?.driveMode === 'tank' ? 'tank' : 'single';
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const query = actionFilter.trim().toLowerCase();
|
||||||
|
/* Only the active steering scheme is shown. Keeping inactive track/stick bindings out of the
|
||||||
|
mapping list prevents operators from tuning controls that currently have no runtime effect. */
|
||||||
|
const modeActions = ACTIONS.filter(
|
||||||
|
(action) => !action.driveMode || action.driveMode === driveMode,
|
||||||
|
);
|
||||||
|
const visibleActions = query
|
||||||
|
? modeActions.filter((action) => `${action.label} ${action.section}`.toLowerCase().includes(query))
|
||||||
|
: modeActions;
|
||||||
|
return groupActions(visibleActions);
|
||||||
|
}, [actionFilter, driveMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activePad || !activeSignature) return;
|
if (!activePad || !activeSignature) return;
|
||||||
@@ -219,12 +246,16 @@ export default function GamepadMappingSettings() {
|
|||||||
current.profiles?.[activeSignature] ?? current?.defaults?.profile,
|
current.profiles?.[activeSignature] ?? current?.defaults?.profile,
|
||||||
GAMEPAD_PROFILE_DEFAULT,
|
GAMEPAD_PROFILE_DEFAULT,
|
||||||
);
|
);
|
||||||
const descriptorKey = JSON.stringify(descriptor);
|
|
||||||
const bindingsWithoutConflict = Object.fromEntries(
|
const bindingsWithoutConflict = Object.fromEntries(
|
||||||
Object.entries(baseProfile.bindings ?? {}).map(([actionId, binding]) => {
|
Object.entries(baseProfile.bindings ?? {}).map(([actionId, binding]) => {
|
||||||
if (actionId === captureAction.id) return [actionId, binding];
|
if (actionId === captureAction.id) return [actionId, binding];
|
||||||
|
const otherMode = actionDriveMode(actionId);
|
||||||
|
const captureMode = actionDriveMode(captureAction.id);
|
||||||
|
/* Opposing mode-only actions may intentionally reuse a physical input because runtime
|
||||||
|
never activates them together. Common actions still conflict with both modes. */
|
||||||
|
if (captureMode && otherMode && captureMode !== otherMode) return [actionId, binding];
|
||||||
const sources = (binding?.sources ?? []).filter(
|
const sources = (binding?.sources ?? []).filter(
|
||||||
(source) => JSON.stringify(source) !== descriptorKey,
|
(source) => !sourcesUseSamePhysicalInput(source, descriptor),
|
||||||
);
|
);
|
||||||
return [actionId, { ...binding, sources }];
|
return [actionId, { ...binding, sources }];
|
||||||
}),
|
}),
|
||||||
@@ -417,6 +448,11 @@ export default function GamepadMappingSettings() {
|
|||||||
const outputs = diagnostics?.outputs;
|
const outputs = diagnostics?.outputs;
|
||||||
if (!outputs) return false;
|
if (!outputs) return false;
|
||||||
if (actionId === 'drive') return Math.hypot(outputs.driveVector.x, outputs.driveVector.y) > 0.01;
|
if (actionId === 'drive') return Math.hypot(outputs.driveVector.x, outputs.driveVector.y) > 0.01;
|
||||||
|
if (actionId === 'tankLeft') return Math.abs(outputs.tankTracks?.left ?? 0) > 0.01;
|
||||||
|
if (actionId === 'tankRight') return Math.abs(outputs.tankTracks?.right ?? 0) > 0.01;
|
||||||
|
if (actionId === 'tankCameraUp' || actionId === 'tankCameraDown') {
|
||||||
|
return Boolean(outputs.buttons[actionId]);
|
||||||
|
}
|
||||||
if (actionId === 'cameraTilt') return Math.abs(outputs.cameraAxis) > 0.01;
|
if (actionId === 'cameraTilt') return Math.abs(outputs.cameraAxis) > 0.01;
|
||||||
if (actionId === 'mainBrush') return Math.abs(outputs.auxAxis.main) > 0.01;
|
if (actionId === 'mainBrush') return Math.abs(outputs.auxAxis.main) > 0.01;
|
||||||
if (actionId === 'sideBrush') return Math.abs(outputs.auxAxis.side) > 0.01;
|
if (actionId === 'sideBrush') return Math.abs(outputs.auxAxis.side) > 0.01;
|
||||||
@@ -500,6 +536,25 @@ export default function GamepadMappingSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-xs leading-snug text-white">Override this only when the browser reports the controller incorrectly.</p>
|
<p className="mt-0.5 text-xs leading-snug text-white">Override this only when the browser reports the controller incorrectly.</p>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
|
||||||
|
<span className="min-w-0 font-semibold text-white">Steering mode</span>
|
||||||
|
<select
|
||||||
|
value={driveMode}
|
||||||
|
onChange={(event) => {
|
||||||
|
updateCalibration({ driveMode: event.target.value });
|
||||||
|
setCaptureAction(null);
|
||||||
|
}}
|
||||||
|
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||||
|
>
|
||||||
|
<option value="single">Single stick</option>
|
||||||
|
<option value="tank">Tank sticks</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs leading-snug text-white">
|
||||||
|
Tank mode controls the left and right wheels with separate stick axes.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
<SliderField
|
<SliderField
|
||||||
label="Drive deadzone"
|
label="Drive deadzone"
|
||||||
description="Ignore small drive stick drift"
|
description="Ignore small drive stick drift"
|
||||||
@@ -532,20 +587,27 @@ export default function GamepadMappingSettings() {
|
|||||||
value={activeProfile.calibration?.turboSpeed ?? 500}
|
value={activeProfile.calibration?.turboSpeed ?? 500}
|
||||||
onChange={(value) => updateCalibration({ turboSpeed: value })}
|
onChange={(value) => updateCalibration({ turboSpeed: value })}
|
||||||
/>
|
/>
|
||||||
<SliderField
|
{driveMode === 'single' && (
|
||||||
label="Camera deadzone"
|
<>
|
||||||
description="Ignore small camera tilt drift"
|
{/* Analog-only settings are hidden in tank mode because its two D-pad camera
|
||||||
min={0}
|
directions are digital velocity inputs. The values remain saved for when the
|
||||||
max={0.4}
|
operator returns to single-stick steering. */}
|
||||||
step={0.01}
|
<SliderField
|
||||||
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
|
label="Velocity camera deadzone"
|
||||||
onChange={(value) => updateCalibration({ cameraDeadzone: value })}
|
description="Absolute mode always uses a 0.01 deadzone"
|
||||||
/>
|
min={0}
|
||||||
<CurveField
|
max={0.4}
|
||||||
label="Camera response"
|
step={0.01}
|
||||||
value={activeProfile.calibration?.cameraCurve ?? 'linear'}
|
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
|
||||||
onChange={(value) => updateCalibration({ cameraCurve: value })}
|
onChange={(value) => updateCalibration({ cameraDeadzone: value })}
|
||||||
/>
|
/>
|
||||||
|
<CurveField
|
||||||
|
label="Camera response"
|
||||||
|
value={activeProfile.calibration?.cameraCurve ?? 'linear'}
|
||||||
|
onChange={(value) => updateCalibration({ cameraCurve: value })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<SliderField
|
<SliderField
|
||||||
label="Aux deadzone"
|
label="Aux deadzone"
|
||||||
description="Ignore small trigger noise"
|
description="Ignore small trigger noise"
|
||||||
@@ -578,22 +640,24 @@ export default function GamepadMappingSettings() {
|
|||||||
value={activeProfile.calibration?.precisionSpeed ?? 100}
|
value={activeProfile.calibration?.precisionSpeed ?? 100}
|
||||||
onChange={(value) => updateCalibration({ precisionSpeed: value })}
|
onChange={(value) => updateCalibration({ precisionSpeed: value })}
|
||||||
/>
|
/>
|
||||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
{driveMode === 'single' && (
|
||||||
{/* Camera mode is styled like the sliders so calibration controls read as one group
|
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||||
even though this specific setting is a select instead of a range input. */}
|
{/* Camera mode is styled like the sliders so calibration controls read as one group
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
|
even though this specific setting is a select instead of a range input. */}
|
||||||
<span className="min-w-0 font-semibold text-white">Camera mode</span>
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
|
||||||
<select
|
<span className="min-w-0 font-semibold text-white">Camera mode</span>
|
||||||
value={activeProfile.calibration?.cameraMode ?? 'velocity'}
|
<select
|
||||||
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
|
value={activeProfile.calibration?.cameraMode ?? 'velocity'}
|
||||||
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
|
||||||
>
|
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||||
<option value="absolute">Absolute</option>
|
>
|
||||||
<option value="velocity">Velocity</option>
|
<option value="absolute">Absolute</option>
|
||||||
</select>
|
<option value="velocity">Velocity</option>
|
||||||
</div>
|
</select>
|
||||||
<p className="mt-0.5 text-xs leading-snug text-white">Absolute maps stick to angle; velocity moves over time.</p>
|
</div>
|
||||||
</label>
|
<p className="mt-0.5 text-xs leading-snug text-white">Absolute maps stick to angle; velocity moves over time.</p>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<SliderField
|
<SliderField
|
||||||
label="Camera sensitivity"
|
label="Camera sensitivity"
|
||||||
description="Velocity mode degrees per second"
|
description="Velocity mode degrees per second"
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ export const ACTIONS = [
|
|||||||
kind: 'axisPair',
|
kind: 'axisPair',
|
||||||
section: 'Driving',
|
section: 'Driving',
|
||||||
invertDefaults: { invertX: false, invertY: true },
|
invertDefaults: { invertX: false, invertY: true },
|
||||||
|
driveMode: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tankLeft',
|
||||||
|
label: 'Left track',
|
||||||
|
kind: 'axis',
|
||||||
|
section: 'Driving',
|
||||||
|
invertDefaults: { invert: true },
|
||||||
|
driveMode: 'tank',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tankRight',
|
||||||
|
label: 'Right track',
|
||||||
|
kind: 'axis',
|
||||||
|
section: 'Driving',
|
||||||
|
invertDefaults: { invert: true },
|
||||||
|
driveMode: 'tank',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cameraTilt',
|
id: 'cameraTilt',
|
||||||
@@ -17,7 +34,10 @@ export const ACTIONS = [
|
|||||||
kind: 'axis',
|
kind: 'axis',
|
||||||
section: 'Camera',
|
section: 'Camera',
|
||||||
invertDefaults: { invert: true },
|
invertDefaults: { invert: true },
|
||||||
|
driveMode: 'single',
|
||||||
},
|
},
|
||||||
|
{ id: 'tankCameraUp', label: 'Camera up', kind: 'button', section: 'Camera', driveMode: 'tank' },
|
||||||
|
{ id: 'tankCameraDown', label: 'Camera down', kind: 'button', section: 'Camera', driveMode: 'tank' },
|
||||||
{
|
{
|
||||||
id: 'mainBrush',
|
id: 'mainBrush',
|
||||||
label: 'Main brush',
|
label: 'Main brush',
|
||||||
@@ -32,12 +52,12 @@ export const ACTIONS = [
|
|||||||
section: 'Brushes',
|
section: 'Brushes',
|
||||||
invertDefaults: { invert: false },
|
invertDefaults: { invert: false },
|
||||||
},
|
},
|
||||||
{ id: 'vacuum', label: 'Vacuum', kind: 'button', section: 'Aux buttons' },
|
{ id: 'vacuum', label: 'Vacuum only', kind: 'button', section: 'Aux buttons' },
|
||||||
{ id: 'allAux', label: 'All aux', kind: 'button', section: 'Aux buttons' },
|
{ id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
|
||||||
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
|
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
|
||||||
{ id: 'sideReverse', label: 'Side 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: 'driveMacro', label: 'Drive / undock sequence', kind: 'button', section: 'Mode controls' },
|
||||||
{ id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' },
|
{ id: 'dockMacro', label: 'Manual docking assist', kind: 'button', section: 'Mode controls' },
|
||||||
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
|
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
|
||||||
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
|
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
|
||||||
{ id: 'boostModifier', label: 'Turbo modifier', kind: 'button', section: 'Driving' },
|
{ id: 'boostModifier', label: 'Turbo modifier', kind: 'button', section: 'Driving' },
|
||||||
@@ -46,10 +66,10 @@ export const ACTIONS = [
|
|||||||
{ id: 'micPtt', label: 'Microphone push to talk', kind: 'button', section: 'Audio and chat' },
|
{ id: 'micPtt', label: 'Microphone push to talk', kind: 'button', section: 'Audio and chat' },
|
||||||
{ id: 'chatFocus', label: 'Focus chat', kind: 'button', section: 'Audio and chat' },
|
{ id: 'chatFocus', label: 'Focus chat', kind: 'button', section: 'Audio and chat' },
|
||||||
{ id: 'videoFilterCycle', label: 'Cycle video filter', kind: 'button', section: 'Camera' },
|
{ id: 'videoFilterCycle', label: 'Cycle video filter', kind: 'button', section: 'Camera' },
|
||||||
{ id: 'songNoteUp', label: 'Song note up', kind: 'button', section: 'Audio and chat' },
|
{ id: 'songNoteUp', label: 'Play higher note', kind: 'button', section: 'Audio and chat', driveMode: 'single' },
|
||||||
{ id: 'songNoteDown', label: 'Song note down', kind: 'button', section: 'Audio and chat' },
|
{ id: 'songNoteDown', label: 'Play lower note', kind: 'button', section: 'Audio and chat', driveMode: 'single' },
|
||||||
{ id: 'homeAssistantOn', label: 'Next room control on', kind: 'button', section: 'Room controls' },
|
{ id: 'homeAssistantOn', label: 'Turn next room control on', kind: 'button', section: 'Room controls' },
|
||||||
{ id: 'homeAssistantOff', label: 'Next room control off', kind: 'button', section: 'Room controls' },
|
{ id: 'homeAssistantOff', label: 'Turn next room control off', kind: 'button', section: 'Room controls' },
|
||||||
/* Digital aux actions provide exact parity with the keyboard help surface. They coexist with
|
/* Digital aux actions provide exact parity with the keyboard help surface. They coexist with
|
||||||
analog brush controls so each operator can choose proportional triggers or discrete buttons. */
|
analog brush controls so each operator can choose proportional triggers or discrete buttons. */
|
||||||
{ id: 'auxMainForward', label: 'Main brush forward', kind: 'button', section: 'Aux buttons' },
|
{ id: 'auxMainForward', label: 'Main brush forward', kind: 'button', section: 'Aux buttons' },
|
||||||
|
|||||||
@@ -555,11 +555,18 @@ export default function GamepadInputManager() {
|
|||||||
here and continue through their established absolute/velocity mapping.
|
here and continue through their established absolute/velocity mapping.
|
||||||
*/
|
*/
|
||||||
const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis);
|
const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis);
|
||||||
|
/* Tank mode's camera input is a pair of direction buttons rather than a position-bearing
|
||||||
|
analog axis. Always interpret those buttons as velocity commands; absolute mode would
|
||||||
|
incorrectly jump directly to a servo endpoint on every D-pad press. The saved analog
|
||||||
|
camera preference remains untouched and resumes when single-stick steering is selected. */
|
||||||
|
const cameraCalibration = profile.calibration?.driveMode === 'tank'
|
||||||
|
? { ...profile.calibration, cameraMode: 'velocity' }
|
||||||
|
: profile.calibration;
|
||||||
if (
|
if (
|
||||||
!handledAsPtzZoom &&
|
!handledAsPtzZoom &&
|
||||||
(profile.calibration?.cameraMode === 'velocity' || Math.abs(outputs.cameraAxis) > 0.001)
|
(cameraCalibration?.cameraMode === 'velocity' || Math.abs(outputs.cameraAxis) > 0.001)
|
||||||
) {
|
) {
|
||||||
handleCameraAxis(outputs.cameraAxis, profile.calibration);
|
handleCameraAxis(outputs.cameraAxis, cameraCalibration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Raw values remain in the dedicated hub used by diagnostics. The shared reducer only
|
/* Raw values remain in the dedicated hub used by diagnostics. The shared reducer only
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ const DIRECTION_GLYPHS = {
|
|||||||
reverse: '−',
|
reverse: '−',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TANK_DIRECTION_GLYPHS = {
|
||||||
|
driveForward: ['up', 'up'],
|
||||||
|
driveBackward: ['down', 'down'],
|
||||||
|
driveLeft: ['down', 'up'],
|
||||||
|
driveRight: ['up', 'down'],
|
||||||
|
};
|
||||||
|
|
||||||
const COMPACT_BUTTON_NAMES = {
|
const COMPACT_BUTTON_NAMES = {
|
||||||
DUp: 'D↑',
|
DUp: 'D↑',
|
||||||
DDown: 'D↓',
|
DDown: 'D↓',
|
||||||
@@ -93,6 +100,12 @@ function compactSourceName(source, type) {
|
|||||||
export function bindingForControllerAction(profile, actionId) {
|
export function bindingForControllerAction(profile, actionId) {
|
||||||
const direct = profile?.bindings?.[actionId];
|
const direct = profile?.bindings?.[actionId];
|
||||||
if (direct?.sources?.length) return { binding: direct, direction: null };
|
if (direct?.sources?.length) return { binding: direct, direction: null };
|
||||||
|
if (profile?.calibration?.driveMode === 'tank' && actionId === 'cameraUp') {
|
||||||
|
return { binding: profile?.bindings?.tankCameraUp ?? null, direction: null };
|
||||||
|
}
|
||||||
|
if (profile?.calibration?.driveMode === 'tank' && actionId === 'cameraDown') {
|
||||||
|
return { binding: profile?.bindings?.tankCameraDown ?? null, direction: null };
|
||||||
|
}
|
||||||
const alias = ACTION_ALIASES[actionId];
|
const alias = ACTION_ALIASES[actionId];
|
||||||
if (!alias) return { binding: direct ?? null, direction: null };
|
if (!alias) return { binding: direct ?? null, direction: null };
|
||||||
return {
|
return {
|
||||||
@@ -102,6 +115,16 @@ export function bindingForControllerAction(profile, actionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatControllerBinding(profile, actionId, controller) {
|
export function formatControllerBinding(profile, actionId, controller) {
|
||||||
|
if (profile?.calibration?.driveMode === 'tank' && TANK_DIRECTION_GLYPHS[actionId]) {
|
||||||
|
const leftSource = profile?.bindings?.tankLeft?.sources?.[0];
|
||||||
|
const rightSource = profile?.bindings?.tankRight?.sources?.[0];
|
||||||
|
if (!leftSource || !rightSource) return '—';
|
||||||
|
const type = controllerType(controller, profile?.promptStyle);
|
||||||
|
const [leftDirection, rightDirection] = TANK_DIRECTION_GLYPHS[actionId];
|
||||||
|
/* A tank movement is inherently a two-input gesture. Showing both compact stick directions
|
||||||
|
makes help labels accurate without spelling out controller model names or raw axis numbers. */
|
||||||
|
return `${compactSourceName(leftSource, type)} ${DIRECTION_GLYPHS[leftDirection]} + ${compactSourceName(rightSource, type)} ${DIRECTION_GLYPHS[rightDirection]}`;
|
||||||
|
}
|
||||||
const { binding, direction } = bindingForControllerAction(profile, actionId);
|
const { binding, direction } = bindingForControllerAction(profile, actionId);
|
||||||
const source = binding?.sources?.[0];
|
const source = binding?.sources?.[0];
|
||||||
if (!source) return '—';
|
if (!source) return '—';
|
||||||
|
|||||||
@@ -4,16 +4,27 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
import { GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
||||||
import { formatControllerBinding } from './controllerLabels.js';
|
import { describeController, formatControllerBinding } from './controllerLabels.js';
|
||||||
|
|
||||||
test('uses the manually selected PlayStation button family', () => {
|
test('uses the manually selected PlayStation button family', () => {
|
||||||
const profile = { ...GAMEPAD_PROFILE_DEFAULT, promptStyle: 'playstation-dual-sense' };
|
const profile = { ...GAMEPAD_PROFILE_DEFAULT, promptStyle: 'playstation' };
|
||||||
const label = formatControllerBinding(profile, 'vacuum', {
|
const label = formatControllerBinding(profile, 'vacuum', {
|
||||||
id: 'Controller hidden by browser privacy mode',
|
id: 'Controller hidden by browser privacy mode',
|
||||||
mapping: 'standard',
|
mapping: 'standard',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(label, 'X');
|
assert.equal(label, '○');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recognizes the exact Linux DualSense browser identifier', () => {
|
||||||
|
const controller = {
|
||||||
|
id: '054c-0ce6-Sony Interactive Entertainment DualSense Wireless Controller',
|
||||||
|
mapping: 'standard',
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(describeController(controller).description, 'Sony DualSense (PS5)');
|
||||||
|
assert.equal(formatControllerBinding(GAMEPAD_PROFILE_DEFAULT, 'vacuum', controller), '○');
|
||||||
|
assert.equal(formatControllerBinding(GAMEPAD_PROFILE_DEFAULT, 'allAux', controller), '×');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('falls back from a keyboard direction action to its controller axis', () => {
|
test('falls back from a keyboard direction action to its controller axis', () => {
|
||||||
@@ -22,7 +33,23 @@ test('falls back from a keyboard direction action to its controller axis', () =>
|
|||||||
mapping: 'standard',
|
mapping: 'standard',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(label, 'Left stick ↑');
|
assert.equal(label, 'LS ↑');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tank steering prompts show both track directions compactly', () => {
|
||||||
|
const profile = {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT,
|
||||||
|
calibration: {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT.calibration,
|
||||||
|
driveMode: 'tank',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const controller = { id: 'Xbox Wireless Controller', mapping: 'standard' };
|
||||||
|
|
||||||
|
assert.equal(formatControllerBinding(profile, 'driveForward', controller), 'LS ↑ + RS ↑');
|
||||||
|
assert.equal(formatControllerBinding(profile, 'driveLeft', controller), 'LS ↓ + RS ↑');
|
||||||
|
assert.equal(formatControllerBinding(profile, 'cameraUp', controller), 'D↑');
|
||||||
|
assert.equal(formatControllerBinding(profile, 'cameraDown', controller), 'D↓');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a direct digital aux binding takes priority over its analog fallback', () => {
|
test('a direct digital aux binding takes priority over its analog fallback', () => {
|
||||||
@@ -38,5 +65,5 @@ test('a direct digital aux binding takes priority over its analog fallback', ()
|
|||||||
mapping: 'standard',
|
mapping: 'standard',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(label, 'D-pad right');
|
assert.equal(label, 'D→');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Gamepad Bindings
|
// Gamepad Bindings
|
||||||
// Purpose: Defines default gamepad axis/button-to-action mappings and lookup helpers. Scope: Supplies binding metadata for gamepad input manager and settings UI.
|
// Purpose: Defines default gamepad axis/button-to-action mappings and lookup helpers. Scope: Supplies binding metadata for gamepad input manager and settings UI.
|
||||||
const CURVE_EXPO = 1.6;
|
const CURVE_EXPO = 1.6;
|
||||||
|
const ABSOLUTE_CAMERA_DEADZONE = 0.01;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Binary actions share one resolver so the runtime, settings UI, diagnostics, and adaptive
|
Binary actions share one resolver so the runtime, settings UI, diagnostics, and adaptive
|
||||||
@@ -8,6 +9,8 @@ const CURVE_EXPO = 1.6;
|
|||||||
controller-local and does not add controller concepts to the shared command pipeline.
|
controller-local and does not add controller concepts to the shared command pipeline.
|
||||||
*/
|
*/
|
||||||
export const GAMEPAD_BUTTON_ACTION_IDS = [
|
export const GAMEPAD_BUTTON_ACTION_IDS = [
|
||||||
|
'tankCameraUp',
|
||||||
|
'tankCameraDown',
|
||||||
'vacuum',
|
'vacuum',
|
||||||
'allAux',
|
'allAux',
|
||||||
'mainReverse',
|
'mainReverse',
|
||||||
@@ -72,11 +75,14 @@ export function resolveGamepadProfile(profile, defaults) {
|
|||||||
...base,
|
...base,
|
||||||
...current,
|
...current,
|
||||||
behaviorVersion: base.behaviorVersion,
|
behaviorVersion: base.behaviorVersion,
|
||||||
|
/* Old detector-specific prompt values are invalid for the replacement library. Returning to
|
||||||
|
automatic detection ensures a previously selected workaround cannot mask the real device. */
|
||||||
|
promptStyle: requiresBehaviorUpgrade ? base.promptStyle : current.promptStyle ?? base.promptStyle,
|
||||||
calibration: {
|
calibration: {
|
||||||
...(base.calibration ?? {}),
|
...(base.calibration ?? {}),
|
||||||
...(current.calibration ?? {}),
|
...(current.calibration ?? {}),
|
||||||
/* Version one introduced an absolute camera default and a 250 wheel-speed ceiling. Apply
|
/* Profile upgrades retain personal response tuning except for defaults whose old values
|
||||||
the corrected behavior to persisted profiles once while preserving every user binding. */
|
caused broken camera behavior or imposed an unintended drive-speed ceiling. */
|
||||||
...(requiresBehaviorUpgrade
|
...(requiresBehaviorUpgrade
|
||||||
? {
|
? {
|
||||||
cameraMode: base.calibration?.cameraMode,
|
cameraMode: base.calibration?.cameraMode,
|
||||||
@@ -85,18 +91,12 @@ export function resolveGamepadProfile(profile, defaults) {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
bindings: {
|
bindings: {
|
||||||
...(base.bindings ?? {}),
|
/* Version four intentionally replaces the old arbitrary default layout as one coherent
|
||||||
...(current.bindings ?? {}),
|
migration. Bindings are controller-local preferences, and the project does not retain
|
||||||
/* The old defaults listed unrelated live axes as fallbacks. Since those indices still exist
|
backwards compatibility with obsolete layouts; calibration and hardware metadata remain. */
|
||||||
on a standard pad, they were not real fallbacks and could make one stick own two actions.
|
|
||||||
Reset only these three version-one defaults; every explicitly digital binding survives. */
|
|
||||||
...(requiresBehaviorUpgrade
|
...(requiresBehaviorUpgrade
|
||||||
? {
|
? cloneProfile(base.bindings ?? {})
|
||||||
cameraTilt: base.bindings?.cameraTilt,
|
: { ...(base.bindings ?? {}), ...(current.bindings ?? {}) }),
|
||||||
mainBrush: base.bindings?.mainBrush,
|
|
||||||
sideBrush: base.bindings?.sideBrush,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -232,20 +232,62 @@ export function computeGamepadOutputs(padState, profile) {
|
|||||||
const bindings = profile?.bindings ?? {};
|
const bindings = profile?.bindings ?? {};
|
||||||
const calibration = profile?.calibration ?? {};
|
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 driveDeadzone = Math.min(Math.max(calibration.driveDeadzone ?? 0.18, 0), 0.8);
|
||||||
const driveCurved = applyRadialDeadzone(driveX, driveY, driveDeadzone);
|
const driveMode = calibration.driveMode === 'tank' ? 'tank' : 'single';
|
||||||
driveX = applyCurve(driveCurved.x, calibration.driveCurve);
|
let driveX = 0;
|
||||||
driveY = applyCurve(driveCurved.y, calibration.driveCurve);
|
let driveY = 0;
|
||||||
|
let driveSources;
|
||||||
|
let tankTracks = null;
|
||||||
|
|
||||||
|
if (driveMode === 'tank') {
|
||||||
|
const leftSource = resolveAxisSource(padState, bindings.tankLeft?.sources);
|
||||||
|
const rightSource = resolveAxisSource(padState, bindings.tankRight?.sources);
|
||||||
|
/* Each track gets its own axial deadzone and response curve before mixing. Applying a radial
|
||||||
|
deadzone to two independent throttles would make one track's drift or movement change the
|
||||||
|
activation threshold of the other, which is especially unpleasant during slow pivots. */
|
||||||
|
const leftTrack = applyCurve(
|
||||||
|
applyAxisDeadzone(clampUnit(leftSource.value), driveDeadzone),
|
||||||
|
calibration.driveCurve,
|
||||||
|
);
|
||||||
|
const rightTrack = applyCurve(
|
||||||
|
applyAxisDeadzone(clampUnit(rightSource.value), driveDeadzone),
|
||||||
|
calibration.driveCurve,
|
||||||
|
);
|
||||||
|
/* The shared drive mixer later computes left = forward + turn and right = forward - turn.
|
||||||
|
This inverse transform therefore preserves the requested track values exactly while keeping
|
||||||
|
tank-controller knowledge out of ControlContext and the rover command transport. */
|
||||||
|
driveX = clampUnit((leftTrack - rightTrack) / 2);
|
||||||
|
driveY = clampUnit((leftTrack + rightTrack) / 2);
|
||||||
|
tankTracks = { left: leftTrack, right: rightTrack };
|
||||||
|
driveSources = { tankLeft: leftSource.source, tankRight: rightSource.source };
|
||||||
|
} else {
|
||||||
|
const driveBinding = bindings.drive ?? {};
|
||||||
|
const driveSource = resolveAxisPairSource(padState, driveBinding.sources);
|
||||||
|
const driveCurved = applyRadialDeadzone(
|
||||||
|
clampUnit(driveSource.x),
|
||||||
|
clampUnit(driveSource.y),
|
||||||
|
driveDeadzone,
|
||||||
|
);
|
||||||
|
driveX = applyCurve(driveCurved.x, calibration.driveCurve);
|
||||||
|
driveY = applyCurve(driveCurved.y, calibration.driveCurve);
|
||||||
|
driveSources = { drive: driveSource.source };
|
||||||
|
}
|
||||||
|
|
||||||
const cameraBinding = bindings.cameraTilt ?? {};
|
const cameraBinding = bindings.cameraTilt ?? {};
|
||||||
const cameraSource = resolveAxisSource(padState, cameraBinding.sources);
|
const cameraSource = driveMode === 'tank'
|
||||||
const cameraDeadzone = Math.min(Math.max(calibration.cameraDeadzone ?? 0.08, 0), 0.8);
|
? { value: 0, source: null }
|
||||||
|
: resolveAxisSource(padState, cameraBinding.sources);
|
||||||
|
/* Absolute mode maps the stick directly across the servo's physical range. Its center needs
|
||||||
|
only a tiny noise guard; applying the velocity deadzone there creates a visibly unresponsive
|
||||||
|
band around the home angle and makes small position corrections feel delayed. */
|
||||||
|
const configuredCameraDeadzone = Math.min(
|
||||||
|
Math.max(calibration.cameraDeadzone ?? 0.08, 0),
|
||||||
|
0.8,
|
||||||
|
);
|
||||||
|
const cameraDeadzone = calibration.cameraMode === 'absolute'
|
||||||
|
? ABSOLUTE_CAMERA_DEADZONE
|
||||||
|
: configuredCameraDeadzone;
|
||||||
let cameraAxis = applyAxisDeadzone(clampUnit(cameraSource.value), cameraDeadzone);
|
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 auxDeadzone = Math.min(Math.max(calibration.auxDeadzone ?? 0.05, 0), 0.6);
|
||||||
const mainBinding = bindings.mainBrush ?? {};
|
const mainBinding = bindings.mainBrush ?? {};
|
||||||
@@ -259,22 +301,44 @@ export function computeGamepadOutputs(padState, profile) {
|
|||||||
sideAxis = applyCurve(sideAxis, calibration.auxCurve);
|
sideAxis = applyCurve(sideAxis, calibration.auxCurve);
|
||||||
|
|
||||||
const buttonOutputs = Object.fromEntries(
|
const buttonOutputs = Object.fromEntries(
|
||||||
GAMEPAD_BUTTON_ACTION_IDS.map((actionId) => [
|
GAMEPAD_BUTTON_ACTION_IDS.map((actionId) => {
|
||||||
actionId,
|
/* D-pad vertical has two deliberate owners, one per steering mode. Suppressing the inactive
|
||||||
resolveButtonSource(padState, bindings[actionId]?.sources),
|
owner here lets both recommended layouts coexist in one controller profile without a
|
||||||
]),
|
camera press also playing a song note after switching to tank steering. */
|
||||||
|
const inactiveForMode =
|
||||||
|
(driveMode === 'tank' && (actionId === 'songNoteUp' || actionId === 'songNoteDown')) ||
|
||||||
|
(driveMode === 'single' && (actionId === 'tankCameraUp' || actionId === 'tankCameraDown'));
|
||||||
|
return [
|
||||||
|
actionId,
|
||||||
|
inactiveForMode
|
||||||
|
? { pressed: false, source: null }
|
||||||
|
: resolveButtonSource(padState, bindings[actionId]?.sources),
|
||||||
|
];
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (driveMode === 'tank') {
|
||||||
|
/* Direction buttons form the signed equivalent of the single analog camera axis. Opposing
|
||||||
|
presses cancel to zero, providing an immediate and deterministic stop for velocity mode. */
|
||||||
|
cameraAxis = Number(buttonOutputs.tankCameraUp.pressed) -
|
||||||
|
Number(buttonOutputs.tankCameraDown.pressed);
|
||||||
|
}
|
||||||
|
cameraAxis = applyCurve(cameraAxis, calibration.cameraCurve);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
driveVector: { x: driveX, y: driveY, boost: false },
|
driveVector: { x: driveX, y: driveY, boost: false },
|
||||||
|
// Track values are diagnostic-only; the runtime continues consuming driveVector exclusively.
|
||||||
|
tankTracks,
|
||||||
cameraAxis,
|
cameraAxis,
|
||||||
auxAxis: { main: mainAxis, side: sideAxis },
|
auxAxis: { main: mainAxis, side: sideAxis },
|
||||||
buttons: Object.fromEntries(
|
buttons: Object.fromEntries(
|
||||||
Object.entries(buttonOutputs).map(([actionId, output]) => [actionId, output.pressed]),
|
Object.entries(buttonOutputs).map(([actionId, output]) => [actionId, output.pressed]),
|
||||||
),
|
),
|
||||||
sources: {
|
sources: {
|
||||||
drive: driveSource.source,
|
...driveSources,
|
||||||
cameraTilt: cameraSource.source,
|
cameraTilt: driveMode === 'tank'
|
||||||
|
? buttonOutputs.tankCameraUp.source ?? buttonOutputs.tankCameraDown.source
|
||||||
|
: cameraSource.source,
|
||||||
mainBrush: mainSource.source,
|
mainBrush: mainSource.source,
|
||||||
sideBrush: sideSource.source,
|
sideBrush: sideSource.source,
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
// Scope: Exercises pure binding behavior without mounting React or opening a real controller.
|
// Scope: Exercises pure binding behavior without mounting React or opening a real controller.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { computeGamepadOutputs, resolveGamepadProfile } from './gamepadBindings.js';
|
import {
|
||||||
|
advanceCameraAngle,
|
||||||
|
computeGamepadOutputs,
|
||||||
|
resolveGamepadProfile,
|
||||||
|
} from './gamepadBindings.js';
|
||||||
import { GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
import { GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
||||||
|
|
||||||
function pad({ axes = [0, 0, 0, 0], pressed = [], values = {} } = {}) {
|
function pad({ axes = [0, 0, 0, 0], pressed = [], values = {} } = {}) {
|
||||||
@@ -25,8 +29,105 @@ test('radial drive deadzone removes drift and rescales real movement', () => {
|
|||||||
assert.ok(moving.driveVector.y > 0.49 && moving.driveVector.y < 0.51);
|
assert.ok(moving.driveVector.y > 0.49 && moving.driveVector.y < 0.51);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tank steering preserves independent left and right wheel requests', () => {
|
||||||
|
const tankProfile = {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT,
|
||||||
|
calibration: {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT.calibration,
|
||||||
|
driveMode: 'tank',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const forward = computeGamepadOutputs(pad({ axes: [0, -1, 0, -1] }), tankProfile);
|
||||||
|
assert.deepEqual(forward.tankTracks, { left: 1, right: 1 });
|
||||||
|
assert.deepEqual(forward.driveVector, { x: 0, y: 1, boost: false });
|
||||||
|
|
||||||
|
const pivotRight = computeGamepadOutputs(pad({ axes: [0, -1, 0, 1] }), tankProfile);
|
||||||
|
assert.deepEqual(pivotRight.tankTracks, { left: 1, right: -1 });
|
||||||
|
assert.deepEqual(pivotRight.driveVector, { x: 1, y: 0, boost: false });
|
||||||
|
|
||||||
|
const leftOnly = computeGamepadOutputs(pad({ axes: [0, -1, 0, 0] }), tankProfile);
|
||||||
|
assert.deepEqual(leftOnly.driveVector, { x: 0.5, y: 0.5, boost: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tank steering applies deadzone and remapping to each track independently', () => {
|
||||||
|
const tankProfile = {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT,
|
||||||
|
calibration: {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT.calibration,
|
||||||
|
driveMode: 'tank',
|
||||||
|
driveDeadzone: 0.2,
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT.bindings,
|
||||||
|
tankLeft: { kind: 'axis', sources: [{ kind: 'axis', index: 0, invert: false }] },
|
||||||
|
tankRight: { kind: 'axis', sources: [{ kind: 'axis', index: 2, invert: true }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The left track is inside its own deadzone while the remapped right track reaches full output;
|
||||||
|
movement on one side must not pull the other side through a shared radial threshold. */
|
||||||
|
const output = computeGamepadOutputs(pad({ axes: [0.1, 0, -1, 0] }), tankProfile);
|
||||||
|
assert.deepEqual(output.tankTracks, { left: 0, right: 1 });
|
||||||
|
assert.deepEqual(output.driveVector, { x: -0.5, y: 0.5, boost: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tank camera buttons form one signed camera axis without playing song notes', () => {
|
||||||
|
const tankProfile = {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT,
|
||||||
|
calibration: {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT.calibration,
|
||||||
|
driveMode: 'tank',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const up = computeGamepadOutputs(pad({ pressed: [12] }), tankProfile);
|
||||||
|
assert.equal(up.cameraAxis, 1);
|
||||||
|
assert.equal(up.buttons.tankCameraUp, true);
|
||||||
|
assert.equal(up.buttons.songNoteUp, false);
|
||||||
|
|
||||||
|
const down = computeGamepadOutputs(pad({ pressed: [13] }), tankProfile);
|
||||||
|
assert.equal(down.cameraAxis, -1);
|
||||||
|
assert.equal(down.buttons.tankCameraDown, true);
|
||||||
|
assert.equal(down.buttons.songNoteDown, false);
|
||||||
|
|
||||||
|
const cancelled = computeGamepadOutputs(pad({ pressed: [12, 13] }), tankProfile);
|
||||||
|
assert.equal(cancelled.cameraAxis, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-stick mode keeps analog camera and song buttons separate', () => {
|
||||||
|
const output = computeGamepadOutputs(
|
||||||
|
pad({ axes: [0, 0, 0, -1], pressed: [12] }),
|
||||||
|
GAMEPAD_PROFILE_DEFAULT,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(output.cameraAxis, 1);
|
||||||
|
assert.equal(output.buttons.songNoteUp, true);
|
||||||
|
assert.equal(output.buttons.tankCameraUp, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommended standard-layout buttons resolve to the intended rover actions', () => {
|
||||||
|
const output = computeGamepadOutputs(
|
||||||
|
pad({ pressed: [0, 2, 4, 5, 9, 10, 15] }),
|
||||||
|
GAMEPAD_PROFILE_DEFAULT,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(output.buttons.allAux, true);
|
||||||
|
assert.equal(output.buttons.vacuum, false);
|
||||||
|
assert.equal(output.buttons.hornHonk, true);
|
||||||
|
assert.equal(output.buttons.headlightToggle, true);
|
||||||
|
assert.equal(output.buttons.laserToggle, true);
|
||||||
|
assert.equal(output.buttons.driveMacro, true);
|
||||||
|
assert.equal(output.buttons.slowModifier, true);
|
||||||
|
assert.equal(output.buttons.homeAssistantOn, true);
|
||||||
|
assert.equal(output.buttons.mainReverse, false);
|
||||||
|
assert.equal(output.buttons.sideReverse, false);
|
||||||
|
assert.equal(output.buttons.boostModifier, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('button chords require every constituent input', () => {
|
test('button chords require every constituent input', () => {
|
||||||
const profile = resolveGamepadProfile({
|
const profile = resolveGamepadProfile({
|
||||||
|
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
|
||||||
bindings: {
|
bindings: {
|
||||||
hornHonk: {
|
hornHonk: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
@@ -44,6 +145,7 @@ test('button chords require every constituent input', () => {
|
|||||||
|
|
||||||
test('multiple button sources behave as alternatives instead of first-source-only fallbacks', () => {
|
test('multiple button sources behave as alternatives instead of first-source-only fallbacks', () => {
|
||||||
const profile = resolveGamepadProfile({
|
const profile = resolveGamepadProfile({
|
||||||
|
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
|
||||||
bindings: {
|
bindings: {
|
||||||
laserToggle: {
|
laserToggle: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
@@ -60,8 +162,47 @@ test('profile resolution adds new actions without overwriting customized binding
|
|||||||
kind: 'axisPair',
|
kind: 'axisPair',
|
||||||
sources: [{ kind: 'axisPair', x: 2, y: 3, invertX: true, invertY: false }],
|
sources: [{ kind: 'axisPair', x: 2, y: 3, invertX: true, invertY: false }],
|
||||||
};
|
};
|
||||||
const resolved = resolveGamepadProfile({ bindings: { drive: customDrive } }, GAMEPAD_PROFILE_DEFAULT);
|
const resolved = resolveGamepadProfile({
|
||||||
|
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
|
||||||
|
bindings: { drive: customDrive },
|
||||||
|
}, GAMEPAD_PROFILE_DEFAULT);
|
||||||
|
|
||||||
assert.deepEqual(resolved.bindings.drive, customDrive);
|
assert.deepEqual(resolved.bindings.drive, customDrive);
|
||||||
assert.ok(resolved.bindings.hornHonk);
|
assert.ok(resolved.bindings.hornHonk);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('profile upgrade discards detector-specific prompt values', () => {
|
||||||
|
const resolved = resolveGamepadProfile({
|
||||||
|
behaviorVersion: 2,
|
||||||
|
promptStyle: 'playstation-dual-sense',
|
||||||
|
}, GAMEPAD_PROFILE_DEFAULT);
|
||||||
|
|
||||||
|
assert.equal(resolved.behaviorVersion, GAMEPAD_PROFILE_DEFAULT.behaviorVersion);
|
||||||
|
assert.equal(resolved.promptStyle, 'auto');
|
||||||
|
assert.deepEqual(resolved.bindings.allAux, GAMEPAD_PROFILE_DEFAULT.bindings.allAux);
|
||||||
|
assert.deepEqual(resolved.bindings.headlightToggle, GAMEPAD_PROFILE_DEFAULT.bindings.headlightToggle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('absolute camera mode always uses its fixed 0.01 deadzone', () => {
|
||||||
|
const profile = {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT,
|
||||||
|
calibration: {
|
||||||
|
...GAMEPAD_PROFILE_DEFAULT.calibration,
|
||||||
|
cameraMode: 'absolute',
|
||||||
|
cameraDeadzone: 0.4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const inside = computeGamepadOutputs(pad({ axes: [0, 0, 0, -0.005] }), profile);
|
||||||
|
const outside = computeGamepadOutputs(pad({ axes: [0, 0, 0, -0.02] }), profile);
|
||||||
|
assert.equal(inside.cameraAxis, 0);
|
||||||
|
assert.ok(outside.cameraAxis > 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('velocity camera accumulation clamps at the servo limit and reverses immediately', () => {
|
||||||
|
const atUpperLimit = advanceCameraAngle(45, 1, 180, 50, { min: -45, max: 45 });
|
||||||
|
const reversing = advanceCameraAngle(atUpperLimit, -1, 180, 50, { min: -45, max: 45 });
|
||||||
|
|
||||||
|
assert.equal(atUpperLimit, 45);
|
||||||
|
assert.equal(reversing, 36);
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ export const INPUT_SETTINGS_DEFAULTS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const GAMEPAD_PROFILE_DEFAULT = {
|
export const GAMEPAD_PROFILE_DEFAULT = {
|
||||||
behaviorVersion: 2,
|
behaviorVersion: 4,
|
||||||
label: 'Default',
|
label: 'Default',
|
||||||
promptStyle: 'auto',
|
promptStyle: 'auto',
|
||||||
calibration: {
|
calibration: {
|
||||||
|
// Steering mode changes only how controller axes are interpreted. Both modes still emit the
|
||||||
|
// same normalized drive vector consumed by the shared rover control pipeline.
|
||||||
|
driveMode: 'single',
|
||||||
driveDeadzone: 0.18,
|
driveDeadzone: 0.18,
|
||||||
cameraDeadzone: 0.08,
|
cameraDeadzone: 0.08,
|
||||||
auxDeadzone: 0.05,
|
auxDeadzone: 0.05,
|
||||||
@@ -33,10 +36,32 @@ export const GAMEPAD_PROFILE_DEFAULT = {
|
|||||||
kind: 'axisPair',
|
kind: 'axisPair',
|
||||||
sources: [{ kind: 'axisPair', x: 0, y: 1, invertX: false, invertY: true }],
|
sources: [{ kind: 'axisPair', x: 0, y: 1, invertX: false, invertY: true }],
|
||||||
},
|
},
|
||||||
|
// Tank steering treats the two vertical stick axes as independent wheel throttles. These
|
||||||
|
// remain separate bindings so controllers with unusual layouts can capture and invert each
|
||||||
|
// track without affecting the conventional single-stick mapping above.
|
||||||
|
tankLeft: {
|
||||||
|
kind: 'axis',
|
||||||
|
sources: [{ kind: 'axis', index: 1, invert: true }],
|
||||||
|
},
|
||||||
|
tankRight: {
|
||||||
|
kind: 'axis',
|
||||||
|
sources: [{ kind: 'axis', index: 3, invert: true }],
|
||||||
|
},
|
||||||
cameraTilt: {
|
cameraTilt: {
|
||||||
kind: 'axis',
|
kind: 'axis',
|
||||||
sources: [{ kind: 'axis', index: 3, invert: true }],
|
sources: [{ kind: 'axis', index: 3, invert: true }],
|
||||||
},
|
},
|
||||||
|
// Tank mode consumes both stick Y axes for driving, so its existing camera axis is exposed as
|
||||||
|
// two independently remappable buttons. Runtime combines them into the same signed camera
|
||||||
|
// value used by the analog single-stick binding; no camera-specific command path is added.
|
||||||
|
tankCameraUp: {
|
||||||
|
kind: 'button',
|
||||||
|
sources: [{ kind: 'button', index: 12 }],
|
||||||
|
},
|
||||||
|
tankCameraDown: {
|
||||||
|
kind: 'button',
|
||||||
|
sources: [{ kind: 'button', index: 13 }],
|
||||||
|
},
|
||||||
mainBrush: {
|
mainBrush: {
|
||||||
kind: 'axis',
|
kind: 'axis',
|
||||||
sources: [{ kind: 'buttonAxis', index: 6 }],
|
sources: [{ kind: 'buttonAxis', index: 6 }],
|
||||||
@@ -47,23 +72,23 @@ export const GAMEPAD_PROFILE_DEFAULT = {
|
|||||||
},
|
},
|
||||||
vacuum: {
|
vacuum: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 0 }],
|
sources: [{ kind: 'button', index: 1 }],
|
||||||
},
|
},
|
||||||
allAux: {
|
allAux: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 1 }],
|
sources: [{ kind: 'button', index: 0 }],
|
||||||
},
|
},
|
||||||
mainReverse: {
|
mainReverse: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 4 }],
|
sources: [],
|
||||||
},
|
},
|
||||||
sideReverse: {
|
sideReverse: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 5 }],
|
sources: [],
|
||||||
},
|
},
|
||||||
driveMacro: {
|
driveMacro: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 2 }],
|
sources: [{ kind: 'button', index: 9 }],
|
||||||
},
|
},
|
||||||
dockMacro: {
|
dockMacro: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
@@ -71,51 +96,53 @@ export const GAMEPAD_PROFILE_DEFAULT = {
|
|||||||
},
|
},
|
||||||
headlightToggle: {
|
headlightToggle: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 9 }],
|
sources: [{ kind: 'button', index: 4 }],
|
||||||
},
|
},
|
||||||
laserToggle: {
|
laserToggle: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [],
|
sources: [{ kind: 'button', index: 5 }],
|
||||||
},
|
},
|
||||||
boostModifier: {
|
boostModifier: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 10 }],
|
// Full-stick driving already reaches the rover's 500-unit limit, so a default turbo button
|
||||||
|
// would claim a useful physical control without changing output.
|
||||||
|
sources: [],
|
||||||
},
|
},
|
||||||
slowModifier: {
|
slowModifier: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 11 }],
|
sources: [{ kind: 'button', index: 10 }],
|
||||||
},
|
},
|
||||||
hornHonk: {
|
hornHonk: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 12 }],
|
sources: [{ kind: 'button', index: 2 }],
|
||||||
},
|
},
|
||||||
micPtt: {
|
micPtt: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 13 }],
|
sources: [],
|
||||||
},
|
},
|
||||||
videoFilterCycle: {
|
videoFilterCycle: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 14 }],
|
sources: [],
|
||||||
},
|
},
|
||||||
chatFocus: {
|
chatFocus: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [{ kind: 'button', index: 15 }],
|
sources: [],
|
||||||
},
|
},
|
||||||
songNoteUp: {
|
songNoteUp: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [],
|
sources: [{ kind: 'button', index: 12 }],
|
||||||
},
|
},
|
||||||
songNoteDown: {
|
songNoteDown: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [],
|
sources: [{ kind: 'button', index: 13 }],
|
||||||
},
|
},
|
||||||
homeAssistantOn: {
|
homeAssistantOn: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [],
|
sources: [{ kind: 'button', index: 15 }],
|
||||||
},
|
},
|
||||||
homeAssistantOff: {
|
homeAssistantOff: {
|
||||||
kind: 'button',
|
kind: 'button',
|
||||||
sources: [],
|
sources: [{ kind: 'button', index: 14 }],
|
||||||
},
|
},
|
||||||
/* These direct digital aux actions mirror the keyboard contract exactly. They start empty
|
/* These direct digital aux actions mirror the keyboard contract exactly. They start empty
|
||||||
because the analog trigger/stick defaults above are friendlier on a controller, but users
|
because the analog trigger/stick defaults above are friendlier on a controller, but users
|
||||||
|
|||||||
Reference in New Issue
Block a user