slop tank steering

This commit is contained in:
legop3
2026-09-13 01:41:32 -04:00
parent 8e96c3cdae
commit 0f5a33c1de
15 changed files with 503 additions and 132 deletions
@@ -555,11 +555,18 @@ export default function GamepadInputManager() {
here and continue through their established absolute/velocity mapping.
*/
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 (
!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
@@ -31,6 +31,13 @@ const DIRECTION_GLYPHS = {
reverse: '',
};
const TANK_DIRECTION_GLYPHS = {
driveForward: ['up', 'up'],
driveBackward: ['down', 'down'],
driveLeft: ['down', 'up'],
driveRight: ['up', 'down'],
};
const COMPACT_BUTTON_NAMES = {
DUp: 'D↑',
DDown: 'D↓',
@@ -93,6 +100,12 @@ function compactSourceName(source, type) {
export function bindingForControllerAction(profile, actionId) {
const direct = profile?.bindings?.[actionId];
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];
if (!alias) return { binding: direct ?? null, direction: null };
return {
@@ -102,6 +115,16 @@ export function bindingForControllerAction(profile, actionId) {
}
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 source = binding?.sources?.[0];
if (!source) return '—';
@@ -4,16 +4,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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', () => {
const profile = { ...GAMEPAD_PROFILE_DEFAULT, promptStyle: 'playstation-dual-sense' };
const profile = { ...GAMEPAD_PROFILE_DEFAULT, promptStyle: 'playstation' };
const label = formatControllerBinding(profile, 'vacuum', {
id: 'Controller hidden by browser privacy mode',
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', () => {
@@ -22,7 +33,23 @@ test('falls back from a keyboard direction action to its controller axis', () =>
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', () => {
@@ -38,5 +65,5 @@ test('a direct digital aux binding takes priority over its analog fallback', ()
mapping: 'standard',
});
assert.equal(label, 'D-pad right');
assert.equal(label, 'D');
});
+93 -29
View File
@@ -1,6 +1,7 @@
// 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.
const CURVE_EXPO = 1.6;
const ABSOLUTE_CAMERA_DEADZONE = 0.01;
/*
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.
*/
export const GAMEPAD_BUTTON_ACTION_IDS = [
'tankCameraUp',
'tankCameraDown',
'vacuum',
'allAux',
'mainReverse',
@@ -72,11 +75,14 @@ export function resolveGamepadProfile(profile, defaults) {
...base,
...current,
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: {
...(base.calibration ?? {}),
...(current.calibration ?? {}),
/* Version one introduced an absolute camera default and a 250 wheel-speed ceiling. Apply
the corrected behavior to persisted profiles once while preserving every user binding. */
/* Profile upgrades retain personal response tuning except for defaults whose old values
caused broken camera behavior or imposed an unintended drive-speed ceiling. */
...(requiresBehaviorUpgrade
? {
cameraMode: base.calibration?.cameraMode,
@@ -85,18 +91,12 @@ export function resolveGamepadProfile(profile, defaults) {
: {}),
},
bindings: {
...(base.bindings ?? {}),
...(current.bindings ?? {}),
/* The old defaults listed unrelated live axes as fallbacks. Since those indices still exist
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. */
/* Version four intentionally replaces the old arbitrary default layout as one coherent
migration. Bindings are controller-local preferences, and the project does not retain
backwards compatibility with obsolete layouts; calibration and hardware metadata remain. */
...(requiresBehaviorUpgrade
? {
cameraTilt: base.bindings?.cameraTilt,
mainBrush: base.bindings?.mainBrush,
sideBrush: base.bindings?.sideBrush,
}
: {}),
? cloneProfile(base.bindings ?? {})
: { ...(base.bindings ?? {}), ...(current.bindings ?? {}) }),
},
};
}
@@ -232,20 +232,62 @@ 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 driveMode = calibration.driveMode === 'tank' ? 'tank' : 'single';
let driveX = 0;
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 cameraSource = resolveAxisSource(padState, cameraBinding.sources);
const cameraDeadzone = Math.min(Math.max(calibration.cameraDeadzone ?? 0.08, 0), 0.8);
const cameraSource = driveMode === 'tank'
? { 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);
cameraAxis = applyCurve(cameraAxis, calibration.cameraCurve);
const auxDeadzone = Math.min(Math.max(calibration.auxDeadzone ?? 0.05, 0), 0.6);
const mainBinding = bindings.mainBrush ?? {};
@@ -259,22 +301,44 @@ export function computeGamepadOutputs(padState, profile) {
sideAxis = applyCurve(sideAxis, calibration.auxCurve);
const buttonOutputs = Object.fromEntries(
GAMEPAD_BUTTON_ACTION_IDS.map((actionId) => [
actionId,
resolveButtonSource(padState, bindings[actionId]?.sources),
]),
GAMEPAD_BUTTON_ACTION_IDS.map((actionId) => {
/* D-pad vertical has two deliberate owners, one per steering mode. Suppressing the inactive
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 {
driveVector: { x: driveX, y: driveY, boost: false },
// Track values are diagnostic-only; the runtime continues consuming driveVector exclusively.
tankTracks,
cameraAxis,
auxAxis: { main: mainAxis, side: sideAxis },
buttons: Object.fromEntries(
Object.entries(buttonOutputs).map(([actionId, output]) => [actionId, output.pressed]),
),
sources: {
drive: driveSource.source,
cameraTilt: cameraSource.source,
...driveSources,
cameraTilt: driveMode === 'tank'
? buttonOutputs.tankCameraUp.source ?? buttonOutputs.tankCameraDown.source
: cameraSource.source,
mainBrush: mainSource.source,
sideBrush: sideSource.source,
...Object.fromEntries(
@@ -3,7 +3,11 @@
// Scope: Exercises pure binding behavior without mounting React or opening a real controller.
import assert from 'node:assert/strict';
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';
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);
});
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', () => {
const profile = resolveGamepadProfile({
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
bindings: {
hornHonk: {
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', () => {
const profile = resolveGamepadProfile({
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
bindings: {
laserToggle: {
kind: 'button',
@@ -60,8 +162,47 @@ test('profile resolution adds new actions without overwriting customized binding
kind: 'axisPair',
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.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);
});