mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
replacing the joystick for mobile!
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DJTuj0vY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-_P0iSe8G.css">
|
||||
<script type="module" crossorigin src="/assets/index-ChzD0G0n.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B564BVSz.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,76 +1,155 @@
|
||||
// Floating Joystick
|
||||
// Purpose: Defines the Floating Joystick module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
// Floating Drive Pad
|
||||
// Purpose: Provides a one-thumb mobile drive surface that emits keyboard-style fixed drive intents.
|
||||
// Scope: Owns pointer tracking, fixed overlay positioning, and active 3x3 drive-zone feedback.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { clampUnit } from '../../controls/controlMath.js';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const JOYSTICK_DEADZONE = 0.08;
|
||||
const STRAIGHT_GATE_MIN_Y = 0.28;
|
||||
const STRAIGHT_GATE_MIN_X = 0.10;
|
||||
const STRAIGHT_GATE_Y_RATIO = 0.24;
|
||||
const STEERING_CURVE = 1.25;
|
||||
const THROTTLE_CURVE = 1.08;
|
||||
const PAD_MARGIN = 12;
|
||||
const CELL_COUNT = 3;
|
||||
const DEFAULT_PAD_SIZE = 180;
|
||||
|
||||
function applySignedCurve(value, exponent) {
|
||||
if (!value) return 0;
|
||||
return Math.sign(value) * Math.pow(Math.abs(value), exponent);
|
||||
const DRIVE_CELLS = [
|
||||
{ id: 'forward-left', label: 'fwd left', col: 0, row: 0, actions: ['driveForward', 'driveLeft'] },
|
||||
{ id: 'forward', label: 'fwd', col: 1, row: 0, actions: ['driveForward'] },
|
||||
{ id: 'forward-right', label: 'fwd right', col: 2, row: 0, actions: ['driveForward', 'driveRight'] },
|
||||
{ id: 'left', label: 'left', col: 0, row: 1, actions: ['driveLeft'] },
|
||||
{ id: 'stop', label: 'stop', col: 1, row: 1, actions: [] },
|
||||
{ id: 'right', label: 'right', col: 2, row: 1, actions: ['driveRight'] },
|
||||
{ id: 'back-left', label: 'back left', col: 0, row: 2, actions: ['driveBackward', 'driveLeft'] },
|
||||
{ id: 'back', label: 'back', col: 1, row: 2, actions: ['driveBackward'] },
|
||||
{ id: 'back-right', label: 'back right', col: 2, row: 2, actions: ['driveBackward', 'driveRight'] },
|
||||
];
|
||||
|
||||
const DRIVE_CELL_BY_POSITION = DRIVE_CELLS.reduce((lookup, cell) => {
|
||||
lookup[`${cell.row}:${cell.col}`] = cell;
|
||||
return lookup;
|
||||
}, {});
|
||||
|
||||
const STOP_CELL = DRIVE_CELLS.find((cell) => cell.id === 'stop');
|
||||
|
||||
function clamp(value, min, max) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function applyRadialDeadzone(x, y) {
|
||||
const magnitude = Math.hypot(x, y);
|
||||
if (magnitude <= JOYSTICK_DEADZONE) return { x: 0, y: 0 };
|
||||
|
||||
// Rescaling the remaining travel keeps the joystick from feeling like it loses
|
||||
// range after the deadzone. A thumb that reaches the outer ring still sends a
|
||||
// full-strength command, but small center noise is ignored.
|
||||
const scaledMagnitude = (magnitude - JOYSTICK_DEADZONE) / (1 - JOYSTICK_DEADZONE);
|
||||
const ratio = scaledMagnitude / magnitude;
|
||||
function getViewportSize() {
|
||||
if (typeof window === 'undefined') return { width: DEFAULT_PAD_SIZE, height: DEFAULT_PAD_SIZE };
|
||||
return {
|
||||
x: clampUnit(x * ratio),
|
||||
y: clampUnit(y * ratio),
|
||||
width: window.innerWidth || DEFAULT_PAD_SIZE,
|
||||
height: window.innerHeight || DEFAULT_PAD_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function applyStraightGate(x, y) {
|
||||
const absY = Math.abs(y);
|
||||
const absX = Math.abs(x);
|
||||
if (absY < STRAIGHT_GATE_MIN_Y) return x;
|
||||
function getPadSize(container) {
|
||||
if (!container) return DEFAULT_PAD_SIZE;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const viewport = getViewportSize();
|
||||
const availableWidth = Math.max(96, Math.min(rect.width, viewport.width - PAD_MARGIN * 2));
|
||||
|
||||
const gate = Math.min(0.45, Math.max(STRAIGHT_GATE_MIN_X, absY * STRAIGHT_GATE_Y_RATIO));
|
||||
if (absX <= gate) return 0;
|
||||
|
||||
// Once the thumb clearly leaves the straight-ahead corridor, compress only the
|
||||
// part that was reserved for accidental drift. This avoids a hard steering jump
|
||||
// at the corridor edge while preserving full left/right authority.
|
||||
return Math.sign(x) * ((absX - gate) / (1 - gate));
|
||||
// The floating grid should match the visible drive card width instead of being a
|
||||
// fixed global overlay size. That keeps the active pad visually connected to the
|
||||
// card and prevents it from feeling oversized in portrait split-column layouts.
|
||||
return Math.round(availableWidth);
|
||||
}
|
||||
|
||||
function shapeDriveVector(rawX, rawY) {
|
||||
const deadzoned = applyRadialDeadzone(clampUnit(rawX), clampUnit(rawY));
|
||||
const gatedX = applyStraightGate(deadzoned.x, deadzoned.y);
|
||||
function clampPadCenter(clientX, clientY, padSize) {
|
||||
const viewport = getViewportSize();
|
||||
const halfPad = padSize / 2;
|
||||
const minX = Math.min(viewport.width - halfPad, halfPad + PAD_MARGIN);
|
||||
const maxX = Math.max(halfPad + PAD_MARGIN, viewport.width - halfPad - PAD_MARGIN);
|
||||
const minY = Math.min(viewport.height - halfPad, halfPad + PAD_MARGIN);
|
||||
const maxY = Math.max(halfPad + PAD_MARGIN, viewport.height - halfPad - PAD_MARGIN);
|
||||
|
||||
// Steering gets a stronger curve than throttle because accidental horizontal
|
||||
// drift is the main problem when driving straight on glass. Intentional turns
|
||||
// still reach full output as the thumb approaches the edge of the ring.
|
||||
// The floating pad is fixed to the viewport instead of the card so overflow-hidden
|
||||
// containers cannot clip it. Clamping keeps the visible 3x3 target usable even when
|
||||
// the driver starts near the edge of a landscape phone screen.
|
||||
return {
|
||||
x: clampUnit(applySignedCurve(gatedX, STEERING_CURVE)),
|
||||
y: clampUnit(applySignedCurve(deadzoned.y, THROTTLE_CURVE)),
|
||||
boost: false,
|
||||
x: clamp(clientX, minX, maxX),
|
||||
y: clamp(clientY, minY, maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export default function FloatingJoystick({ disabled, radius, onMove, onStop }) {
|
||||
function cellFromPointer(activePad, clientX, clientY) {
|
||||
const padSize = activePad?.size || DEFAULT_PAD_SIZE;
|
||||
const halfPad = padSize / 2;
|
||||
const cellSize = padSize / CELL_COUNT;
|
||||
const localX = clamp(clientX - activePad.center.x + halfPad, 0, padSize - 1);
|
||||
const localY = clamp(clientY - activePad.center.y + halfPad, 0, padSize - 1);
|
||||
const col = clamp(Math.floor(localX / cellSize), 0, CELL_COUNT - 1);
|
||||
const row = clamp(Math.floor(localY / cellSize), 0, CELL_COUNT - 1);
|
||||
return DRIVE_CELL_BY_POSITION[`${row}:${col}`] || STOP_CELL;
|
||||
}
|
||||
|
||||
function FloatingPadOverlay({ center, size, activeCellId }) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const halfPad = size / 2;
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[1000]" aria-hidden="true">
|
||||
<div
|
||||
className="absolute grid grid-cols-3 grid-rows-3 overflow-hidden rounded-lg border-2 border-cyan-300/80 bg-slate-950/90 shadow-2xl shadow-cyan-950/50 backdrop-blur-sm"
|
||||
style={{
|
||||
height: size,
|
||||
left: center.x - halfPad,
|
||||
top: center.y - halfPad,
|
||||
width: size,
|
||||
}}
|
||||
>
|
||||
{DRIVE_CELLS.map((cell) => {
|
||||
const active = cell.id === activeCellId;
|
||||
const isStop = cell.id === 'stop';
|
||||
const baseClass =
|
||||
'flex items-center justify-center border border-slate-700/80 px-1 text-center text-xs font-semibold leading-tight';
|
||||
const activeClass = active
|
||||
? isStop
|
||||
? 'bg-rose-500 text-white'
|
||||
: 'bg-cyan-300 text-slate-950'
|
||||
: isStop
|
||||
? 'bg-slate-900 text-slate-300'
|
||||
: 'bg-slate-800/80 text-slate-200';
|
||||
return (
|
||||
<div key={cell.id} className={`${baseClass} ${activeClass}`}>
|
||||
{cell.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default function FloatingJoystick({ disabled, onCellChange, onStop }) {
|
||||
const containerRef = useRef(null);
|
||||
const pointerIdRef = useRef(null);
|
||||
const baseRef = useRef({ x: 0, y: 0 });
|
||||
const [visual, setVisual] = useState({ active: false, base: { x: 0, y: 0 }, knob: { x: 0, y: 0 } });
|
||||
const activePadRef = useRef(null);
|
||||
const activeCellIdRef = useRef(null);
|
||||
const [activePad, setActivePad] = useState(null);
|
||||
|
||||
const stopTracking = useCallback(() => {
|
||||
pointerIdRef.current = null;
|
||||
setVisual({ active: false, base: { x: 0, y: 0 }, knob: { x: 0, y: 0 } });
|
||||
activeCellIdRef.current = null;
|
||||
setActivePad(null);
|
||||
onStop?.();
|
||||
}, [onStop]);
|
||||
|
||||
const updateActiveCell = useCallback(
|
||||
(event) => {
|
||||
const currentPad = activePadRef.current;
|
||||
if (!currentPad) return;
|
||||
const cell = cellFromPointer(currentPad, event.clientX, event.clientY);
|
||||
if (activeCellIdRef.current !== cell.id) {
|
||||
activeCellIdRef.current = cell.id;
|
||||
onCellChange?.(cell);
|
||||
}
|
||||
setActivePad({
|
||||
...currentPad,
|
||||
activeCellId: cell.id,
|
||||
});
|
||||
},
|
||||
[onCellChange],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event) => {
|
||||
if (disabled) return;
|
||||
@@ -78,46 +157,39 @@ export default function FloatingJoystick({ disabled, radius, onMove, onStop }) {
|
||||
event.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
baseRef.current = { x, y };
|
||||
|
||||
pointerIdRef.current = event.pointerId;
|
||||
const size = getPadSize(container);
|
||||
const center = clampPadCenter(event.clientX, event.clientY, size);
|
||||
activePadRef.current = { center, size, activeCellId: STOP_CELL.id };
|
||||
container.setPointerCapture?.(event.pointerId);
|
||||
setVisual({ active: true, base: { x, y }, knob: { x: 0, y: 0 } });
|
||||
|
||||
// Starting in the center mirrors the old floating joystick behavior: putting
|
||||
// a thumb down establishes the control origin, and movement after that chooses
|
||||
// a direction. This prevents accidental drive commands from a simple touch.
|
||||
activeCellIdRef.current = STOP_CELL.id;
|
||||
setActivePad(activePadRef.current);
|
||||
onCellChange?.(STOP_CELL);
|
||||
},
|
||||
[disabled],
|
||||
[disabled, onCellChange],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event) => {
|
||||
if (disabled || pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const currentX = event.clientX - rect.left;
|
||||
const currentY = event.clientY - rect.top;
|
||||
const dx = currentX - baseRef.current.x;
|
||||
const dy = currentY - baseRef.current.y;
|
||||
const distance = Math.min(Math.hypot(dx, dy), radius);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const knobX = Math.cos(angle) * distance;
|
||||
const knobY = Math.sin(angle) * distance;
|
||||
const vector = shapeDriveVector(knobX / radius, -knobY / radius);
|
||||
setVisual((prev) => ({ ...prev, knob: { x: knobX, y: knobY } }));
|
||||
onMove?.(vector);
|
||||
updateActiveCell(event);
|
||||
},
|
||||
[disabled, onMove, radius],
|
||||
[disabled, updateActiveCell],
|
||||
);
|
||||
|
||||
const handlePointerEnd = useCallback(
|
||||
(event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
const container = containerRef.current;
|
||||
container?.releasePointerCapture?.(event.pointerId);
|
||||
const pointerId = event.pointerId;
|
||||
stopTracking();
|
||||
containerRef.current?.releasePointerCapture?.(pointerId);
|
||||
},
|
||||
[stopTracking],
|
||||
);
|
||||
@@ -125,54 +197,43 @@ export default function FloatingJoystick({ disabled, radius, onMove, onStop }) {
|
||||
useEffect(() => {
|
||||
if (!disabled) return undefined;
|
||||
|
||||
// Defer the disabled cleanup out of the effect body so React's set-state-in-effect
|
||||
// lint rule is satisfied while still clearing the active visual shortly after
|
||||
// rover access is lost.
|
||||
// Defer visual cleanup so this component stays compatible with the repo's strict
|
||||
// React hook lint rules while still guaranteeing that losing rover access stops
|
||||
// any active mobile drive gesture.
|
||||
const timer = setTimeout(stopTracking, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [disabled, stopTracking]);
|
||||
|
||||
const heightClass = 'h-full';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="presentation"
|
||||
className={`relative w-full ${heightClass} select-none overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900/70 text-slate-100 shadow-md`}
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerLeave={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{!visual.active && (
|
||||
<div className="absolute inset-x-0 top-0 flex flex-col items-center gap-0 text-center pt-0.5">
|
||||
<span className="font-semibold text-slate-200">Joystick area</span>
|
||||
<span className="text-sm text-slate-300">Touch and hold to use the joystick</span>
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="presentation"
|
||||
className="relative flex h-full min-h-[10rem] w-full select-none items-center justify-center overflow-hidden text-slate-100"
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (pointerIdRef.current === event.pointerId) stopTracking();
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="pointer-events-none flex flex-col items-center gap-0.5 text-center">
|
||||
<span className="text-sm font-semibold text-slate-100">drive pad</span>
|
||||
<span className="px-2 text-xs leading-tight text-slate-300">
|
||||
hold and drag for keyboard-style driving
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{visual.active && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 bg-cyan-400/10 outline outline-2 outline-cyan-400/60 [clip-path:circle(50%)]"
|
||||
style={{
|
||||
height: radius * 2,
|
||||
left: visual.base.x,
|
||||
top: visual.base.y,
|
||||
width: radius * 2,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute h-12 w-12 -translate-x-1/2 -translate-y-1/2 bg-cyan-300/80 shadow-lg [clip-path:circle(50%)]"
|
||||
style={{
|
||||
left: visual.base.x + visual.knob.x,
|
||||
top: visual.base.y + visual.knob.y,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{activePad ? (
|
||||
<FloatingPadOverlay
|
||||
center={activePad.center}
|
||||
size={activePad.size}
|
||||
activeCellId={activePad.activeCellId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// Mobile Controls Content
|
||||
// Purpose: Defines the Mobile Controls Content module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
import { clampUnit } from '../../controls/controlMath.js';
|
||||
import { normalizeKeymapEntries } from '../../controls/keymapUtils.js';
|
||||
import {
|
||||
computeKeyboardDriveVector,
|
||||
getKeyboardDriveSpeedOptions,
|
||||
resolveKeyboardSpeeds,
|
||||
} from '../../controls/inputs/driveIntent.js';
|
||||
import DriveDockAction, { useDriveDockState } from '../DriveDockAction/index.jsx';
|
||||
import NightVisionControl from '../NightVisionControl/index.jsx';
|
||||
import HornControl from '../HornControl/index.jsx';
|
||||
@@ -12,129 +17,143 @@ import FloatingJoystick from './FloatingJoystick.jsx';
|
||||
import MobileAuxButton from './MobileAuxButton.jsx';
|
||||
import {
|
||||
SOURCE,
|
||||
JOYSTICK_RADIUS,
|
||||
JOYSTICK_SEND_INTERVAL_MS,
|
||||
JOYSTICK_SMOOTHING,
|
||||
DRIVE_PAD_REPEAT_MS,
|
||||
DRIVE_PAD_SPEED_MODES,
|
||||
AUX_ZERO,
|
||||
AUX_ALL_FORWARD,
|
||||
AUX_ALL_BACKWARD,
|
||||
} from './constants.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
|
||||
function firstTokenForAction(keymap, actionId) {
|
||||
const bindingSet = keymap?.[actionId];
|
||||
if (!bindingSet || bindingSet.size === 0) return null;
|
||||
return bindingSet.values().next().value ?? null;
|
||||
}
|
||||
|
||||
function getSpeedModeConfig(speedMode) {
|
||||
return DRIVE_PAD_SPEED_MODES.find((mode) => mode.id === speedMode) || DRIVE_PAD_SPEED_MODES[1];
|
||||
}
|
||||
|
||||
function MobileJoystickPanel({ layout }) {
|
||||
const {
|
||||
state: { roverId },
|
||||
state: { roverId, keymap: rawKeymap },
|
||||
actions: { setDriveVector, registerInputState },
|
||||
} = useControlSystem();
|
||||
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
|
||||
const expandAction = dockedNotDriving || driveDockState.dockingInProgress;
|
||||
const disabled = !roverId;
|
||||
const joystickRadius = JOYSTICK_RADIUS;
|
||||
const smoothing = JOYSTICK_SMOOTHING;
|
||||
const smoothedVectorRef = useRef({ x: 0, y: 0, boost: false });
|
||||
const pendingVectorRef = useRef(null);
|
||||
const sendTimerRef = useRef(null);
|
||||
const lastSentAtRef = useRef(0);
|
||||
const [speedMode, setSpeedMode] = useState('normal');
|
||||
const speedModeRef = useRef('normal');
|
||||
const activeCellRef = useRef(null);
|
||||
const repeatTimerRef = useRef(null);
|
||||
const keymap = useMemo(() => normalizeKeymapEntries(rawKeymap), [rawKeymap]);
|
||||
const keyboardSpeeds = useMemo(() => resolveKeyboardSpeeds(inputSettings), [inputSettings]);
|
||||
|
||||
const clearPendingSend = useCallback(() => {
|
||||
if (!sendTimerRef.current) return;
|
||||
clearTimeout(sendTimerRef.current);
|
||||
sendTimerRef.current = null;
|
||||
const clearRepeatTimer = useCallback(() => {
|
||||
if (!repeatTimerRef.current) return;
|
||||
clearInterval(repeatTimerRef.current);
|
||||
repeatTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const sendMobileDriveVector = useCallback(
|
||||
(vector, lastEvent = 'move') => {
|
||||
lastSentAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
setDriveVector(vector, { source: SOURCE });
|
||||
registerInputState(SOURCE, { vector, lastEvent });
|
||||
const buildVirtualKeyTokens = useCallback(
|
||||
(cell, modeId = speedModeRef.current) => {
|
||||
const tokens = new Set();
|
||||
const speedModeConfig = getSpeedModeConfig(modeId);
|
||||
const actionIds = [
|
||||
...(Array.isArray(cell?.actions) ? cell.actions : []),
|
||||
speedModeConfig.modifierAction,
|
||||
].filter(Boolean);
|
||||
|
||||
actionIds.forEach((actionId) => {
|
||||
const token = firstTokenForAction(keymap, actionId);
|
||||
if (token) tokens.add(token);
|
||||
});
|
||||
|
||||
return tokens;
|
||||
},
|
||||
[registerInputState, setDriveVector],
|
||||
[keymap],
|
||||
);
|
||||
|
||||
const flushPendingDriveVector = useCallback(() => {
|
||||
const pending = pendingVectorRef.current;
|
||||
pendingVectorRef.current = null;
|
||||
sendTimerRef.current = null;
|
||||
if (!pending || disabled) return;
|
||||
sendMobileDriveVector(pending, 'move');
|
||||
}, [disabled, sendMobileDriveVector]);
|
||||
const sendDriveCell = useCallback(
|
||||
(cell, lastEvent = 'move', modeId = speedModeRef.current) => {
|
||||
if (disabled) return;
|
||||
const tokens = buildVirtualKeyTokens(cell, modeId);
|
||||
const vector = computeKeyboardDriveVector(tokens, keymap);
|
||||
const speedOptions = getKeyboardDriveSpeedOptions(tokens, keymap, keyboardSpeeds);
|
||||
|
||||
const queueDriveVector = useCallback(
|
||||
(vector) => {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const elapsed = now - lastSentAtRef.current;
|
||||
|
||||
if (elapsed >= JOYSTICK_SEND_INTERVAL_MS) {
|
||||
clearPendingSend();
|
||||
pendingVectorRef.current = null;
|
||||
sendMobileDriveVector(vector, 'move');
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep only the newest shaped vector while the send gate is closed. This gives
|
||||
// the rover a stable command rhythm without letting an older thumb position
|
||||
// overwrite the driver's latest correction when the timer fires.
|
||||
pendingVectorRef.current = vector;
|
||||
if (sendTimerRef.current) return;
|
||||
sendTimerRef.current = setTimeout(
|
||||
flushPendingDriveVector,
|
||||
Math.max(0, JOYSTICK_SEND_INTERVAL_MS - elapsed),
|
||||
);
|
||||
// Mobile deliberately routes through the keyboard vector/speed helpers. The
|
||||
// thumb pad only chooses which virtual keys are down, so changes to keyboard
|
||||
// drive behavior automatically stay matched here.
|
||||
setDriveVector(vector, { source: SOURCE, speedOptions });
|
||||
registerInputState(SOURCE, {
|
||||
keys: Array.from(tokens),
|
||||
vector,
|
||||
activeCell: cell?.id ?? 'stop',
|
||||
speedMode: modeId,
|
||||
lastEvent,
|
||||
});
|
||||
},
|
||||
[clearPendingSend, flushPendingDriveVector, sendMobileDriveVector],
|
||||
[
|
||||
buildVirtualKeyTokens,
|
||||
disabled,
|
||||
keyboardSpeeds,
|
||||
keymap,
|
||||
registerInputState,
|
||||
setDriveVector,
|
||||
],
|
||||
);
|
||||
|
||||
const stopDrivePad = useCallback(
|
||||
(lastEvent = 'stop') => {
|
||||
clearRepeatTimer();
|
||||
activeCellRef.current = null;
|
||||
sendDriveCell({ id: 'stop', actions: [] }, lastEvent, 'normal');
|
||||
},
|
||||
[clearRepeatTimer, sendDriveCell],
|
||||
);
|
||||
|
||||
const startRepeatTimer = useCallback(() => {
|
||||
if (repeatTimerRef.current) return;
|
||||
repeatTimerRef.current = setInterval(() => {
|
||||
if (!activeCellRef.current) return;
|
||||
sendDriveCell(activeCellRef.current, 'repeat');
|
||||
}, DRIVE_PAD_REPEAT_MS);
|
||||
}, [sendDriveCell]);
|
||||
|
||||
const handleCellChange = useCallback(
|
||||
(cell) => {
|
||||
if (disabled) return;
|
||||
activeCellRef.current = cell;
|
||||
sendDriveCell(cell, 'move');
|
||||
startRepeatTimer();
|
||||
},
|
||||
[disabled, sendDriveCell, startRepeatTimer],
|
||||
);
|
||||
|
||||
const handleSpeedModeChange = useCallback(
|
||||
(nextMode) => {
|
||||
setSpeedMode(nextMode);
|
||||
speedModeRef.current = nextMode;
|
||||
if (activeCellRef.current) {
|
||||
sendDriveCell(activeCellRef.current, 'speed', nextMode);
|
||||
}
|
||||
},
|
||||
[sendDriveCell],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearPendingSend();
|
||||
}, [clearPendingSend]);
|
||||
return () => clearRepeatTimer();
|
||||
}, [clearRepeatTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) return;
|
||||
|
||||
// Losing the rover assignment or docking into a non-driving state must leave no
|
||||
// delayed drive command behind. A queued non-zero vector sent after disable would
|
||||
// be especially confusing because the visible joystick has already disappeared.
|
||||
clearPendingSend();
|
||||
pendingVectorRef.current = null;
|
||||
smoothedVectorRef.current = { x: 0, y: 0, boost: false };
|
||||
}, [clearPendingSend, disabled]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(vector = {}) => {
|
||||
if (disabled) return;
|
||||
const next = {
|
||||
x: clampUnit(vector.x ?? 0),
|
||||
y: clampUnit(vector.y ?? 0),
|
||||
boost: Boolean(vector.boost),
|
||||
};
|
||||
const applied =
|
||||
smoothing > 0
|
||||
? {
|
||||
x: smoothedVectorRef.current.x + (next.x - smoothedVectorRef.current.x) * (1 - smoothing),
|
||||
y: smoothedVectorRef.current.y + (next.y - smoothedVectorRef.current.y) * (1 - smoothing),
|
||||
boost: next.boost,
|
||||
}
|
||||
: next;
|
||||
|
||||
// This light low-pass filter removes high-frequency thumb tremor after the
|
||||
// joystick has already applied its deadzone and straight corridor. It is kept
|
||||
// small so the one-thumb control still responds quickly when the driver commits
|
||||
// to a turn or releases back toward center.
|
||||
smoothedVectorRef.current = applied;
|
||||
queueDriveVector(applied);
|
||||
},
|
||||
[disabled, queueDriveVector, smoothing],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (disabled) return;
|
||||
const zero = { x: 0, y: 0, boost: false };
|
||||
clearPendingSend();
|
||||
pendingVectorRef.current = null;
|
||||
smoothedVectorRef.current = zero;
|
||||
sendMobileDriveVector(zero, 'stop');
|
||||
}, [clearPendingSend, disabled, sendMobileDriveVector]);
|
||||
stopDrivePad('disabled');
|
||||
}, [disabled, stopDrivePad]);
|
||||
|
||||
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
|
||||
const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
|
||||
@@ -148,13 +167,41 @@ function MobileJoystickPanel({ layout }) {
|
||||
compactHeightClass="min-h-[5rem]"
|
||||
/>
|
||||
{!expandAction ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<FloatingJoystick
|
||||
disabled={disabled}
|
||||
radius={joystickRadius}
|
||||
onMove={handleMove}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900/70 text-slate-100 shadow-md">
|
||||
<div className="grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950/80 p-0.5">
|
||||
{DRIVE_PAD_SPEED_MODES.map((mode) => {
|
||||
const active = speedMode === mode.id;
|
||||
const speedValue =
|
||||
mode.id === 'precision'
|
||||
? keyboardSpeeds.precisionSpeed
|
||||
: mode.id === 'turbo'
|
||||
? keyboardSpeeds.turboSpeed
|
||||
: keyboardSpeeds.baseSpeed;
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
className={`min-h-9 rounded-md px-1 text-xs font-semibold ${
|
||||
active
|
||||
? 'bg-cyan-300 text-slate-950'
|
||||
: 'bg-slate-800 text-slate-200'
|
||||
}`}
|
||||
onClick={() => handleSpeedModeChange(mode.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="block leading-tight">{mode.label}</span>
|
||||
<span className="block font-mono text-[0.7rem] leading-tight">{speedValue}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<FloatingJoystick
|
||||
disabled={disabled}
|
||||
onCellChange={handleCellChange}
|
||||
onStop={() => stopDrivePad('stop')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
// constants
|
||||
// Purpose: Defines the constants module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
export const SOURCE = 'mobile-joystick';
|
||||
// The visual base ring is drawn from this same value, so the driver's thumb reaches
|
||||
// "full stick" when the UI also shows the knob at the edge. The previous math used
|
||||
// a larger invisible radius than the drawn ring, which made intentional full turns
|
||||
// require more thumb travel than the control appeared to promise.
|
||||
export const JOYSTICK_RADIUS = 64;
|
||||
export const SOURCE = 'mobile-drive-pad';
|
||||
|
||||
// Mobile pointer events can arrive much faster than the rover command loop can usefully
|
||||
// react. Sending at a steady cadence keeps tiny thumb jitter and browser event bursts
|
||||
// from turning into uneven wheel commands while still feeling live under one thumb.
|
||||
export const JOYSTICK_SEND_INTERVAL_MS = 90;
|
||||
export const JOYSTICK_SMOOTHING = 0.15;
|
||||
// The mobile pad repeats the current fixed keyboard-style command while the thumb is
|
||||
// held down. The repeat keeps delayed rover/network paths fed with the same intent
|
||||
// without turning touch jitter into analog speed changes.
|
||||
export const DRIVE_PAD_REPEAT_MS = 100;
|
||||
export const DRIVE_PAD_SPEED_MODES = [
|
||||
{ id: 'precision', label: 'precision', modifierAction: 'slowModifier' },
|
||||
{ id: 'normal', label: 'normal', modifierAction: null },
|
||||
{ id: 'turbo', label: 'turbo', modifierAction: 'boostModifier' },
|
||||
];
|
||||
export const AUX_ZERO = { main: 0, side: 0, vacuum: 0 };
|
||||
export const AUX_ALL_FORWARD = { main: 127, side: 127, vacuum: 127 };
|
||||
export const AUX_ALL_BACKWARD = { main: -127, side: -127, vacuum: -127 };
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
SONG_NOTE_RANGE,
|
||||
SONG_REPEAT_MS,
|
||||
} from '../constants.js';
|
||||
import {
|
||||
bindingActive,
|
||||
computeKeyboardAuxMotors,
|
||||
computeKeyboardDriveVector,
|
||||
getKeyboardDriveSpeedOptions,
|
||||
resolveKeyboardSpeeds,
|
||||
} from './driveIntent.js';
|
||||
|
||||
const SOURCE = 'keyboard';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
@@ -44,12 +51,6 @@ function nextVideoFilter(value) {
|
||||
return VIDEO_FILTER_SEQUENCE[(currentIndex + 1) % VIDEO_FILTER_SEQUENCE.length];
|
||||
}
|
||||
|
||||
function clampSpeed(value, fallback) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(0, Math.min(500, num));
|
||||
}
|
||||
|
||||
function clampTiltInterval(value, fallback) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
@@ -80,61 +81,6 @@ function shouldIgnoreEvent(event) {
|
||||
return isTextInputElement(event?.target);
|
||||
}
|
||||
|
||||
function bindingActive(bindingSet, keys) {
|
||||
if (!bindingSet || bindingSet.size === 0) return false;
|
||||
for (const key of keys) {
|
||||
if (bindingSet.has(key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function computeDriveVector(keys, keymap) {
|
||||
const forward = bindingActive(keymap.driveForward, keys);
|
||||
const backward = bindingActive(keymap.driveBackward, keys);
|
||||
const left = bindingActive(keymap.driveLeft, keys);
|
||||
const right = bindingActive(keymap.driveRight, keys);
|
||||
const boost = bindingActive(keymap.boostModifier, keys);
|
||||
const slow = bindingActive(keymap.slowModifier, keys);
|
||||
|
||||
let y = 0;
|
||||
if (forward && !backward) y = 1;
|
||||
else if (backward && !forward) y = -1;
|
||||
|
||||
let x = 0;
|
||||
if (left && !right) x = -1;
|
||||
else if (right && !left) x = 1;
|
||||
|
||||
const scale = slow ? 0.4 : 1;
|
||||
return {
|
||||
x: x * scale,
|
||||
y: y * scale,
|
||||
boost: boost && !slow,
|
||||
};
|
||||
}
|
||||
|
||||
function computeAuxMotors(keys, keymap) {
|
||||
const allForward = bindingActive(keymap.auxAllForward, keys);
|
||||
if (allForward) {
|
||||
return { main: 127, side: 127, vacuum: 127 };
|
||||
}
|
||||
const main = bindingActive(keymap.auxMainForward, keys)
|
||||
? 127
|
||||
: bindingActive(keymap.auxMainReverse, keys)
|
||||
? -127
|
||||
: 0;
|
||||
const side = bindingActive(keymap.auxSideForward, keys)
|
||||
? 127
|
||||
: bindingActive(keymap.auxSideReverse, keys)
|
||||
? -70
|
||||
: 0;
|
||||
const vacuum = bindingActive(keymap.auxVacuumFast, keys)
|
||||
? 127
|
||||
: bindingActive(keymap.auxVacuumSlow, keys)
|
||||
? 50
|
||||
: 0;
|
||||
return { main, side, vacuum };
|
||||
}
|
||||
|
||||
export default function KeyboardInputManager() {
|
||||
const {
|
||||
state,
|
||||
@@ -170,14 +116,8 @@ export default function KeyboardInputManager() {
|
||||
return tokens;
|
||||
}, [keymap]);
|
||||
const keyboardSpeeds = useMemo(() => {
|
||||
const defaults = INPUT_SETTINGS_DEFAULTS.keyboard;
|
||||
const current = inputSettings?.keyboard ?? {};
|
||||
return {
|
||||
baseSpeed: clampSpeed(current.baseSpeed, defaults.baseSpeed),
|
||||
turboSpeed: clampSpeed(current.turboSpeed, defaults.turboSpeed),
|
||||
precisionSpeed: clampSpeed(current.precisionSpeed, defaults.precisionSpeed),
|
||||
};
|
||||
}, [inputSettings?.keyboard]);
|
||||
return resolveKeyboardSpeeds(inputSettings);
|
||||
}, [inputSettings]);
|
||||
const servoRepeatMs = useMemo(() => {
|
||||
const defaults = INPUT_SETTINGS_DEFAULTS.keyboard;
|
||||
const current = inputSettings?.keyboard ?? {};
|
||||
@@ -205,12 +145,9 @@ export default function KeyboardInputManager() {
|
||||
|
||||
const driveFromKeys = useCallback(() => {
|
||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||
const slowActive = bindingActive(keymap.slowModifier, tokensSnapshot);
|
||||
const speedOptions = slowActive
|
||||
? { baseSpeed: keyboardSpeeds.precisionSpeed, boostSpeed: keyboardSpeeds.precisionSpeed }
|
||||
: { baseSpeed: keyboardSpeeds.baseSpeed, boostSpeed: keyboardSpeeds.turboSpeed };
|
||||
const vector = computeDriveVector(tokensSnapshot, keymap);
|
||||
const aux = computeAuxMotors(tokensSnapshot, keymap);
|
||||
const speedOptions = getKeyboardDriveSpeedOptions(tokensSnapshot, keymap, keyboardSpeeds);
|
||||
const vector = computeKeyboardDriveVector(tokensSnapshot, keymap);
|
||||
const aux = computeKeyboardAuxMotors(tokensSnapshot, keymap);
|
||||
if (
|
||||
vector.x !== lastVectorRef.current.x ||
|
||||
vector.y !== lastVectorRef.current.y ||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Drive Intent Helpers
|
||||
// Purpose: Shares keyboard-style drive intent math between physical keyboard input and mobile thumb controls.
|
||||
// Scope: Converts active key tokens into normalized drive vectors, auxiliary motor values, and speed options.
|
||||
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
|
||||
export function clampKeyboardSpeed(value, fallback) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(0, Math.min(500, num));
|
||||
}
|
||||
|
||||
export function resolveKeyboardSpeeds(inputSettings) {
|
||||
const defaults = INPUT_SETTINGS_DEFAULTS.keyboard;
|
||||
const current = inputSettings?.keyboard ?? {};
|
||||
return {
|
||||
baseSpeed: clampKeyboardSpeed(current.baseSpeed, defaults.baseSpeed),
|
||||
turboSpeed: clampKeyboardSpeed(current.turboSpeed, defaults.turboSpeed),
|
||||
precisionSpeed: clampKeyboardSpeed(current.precisionSpeed, defaults.precisionSpeed),
|
||||
};
|
||||
}
|
||||
|
||||
export function bindingActive(bindingSet, keys) {
|
||||
if (!bindingSet || bindingSet.size === 0) return false;
|
||||
for (const key of keys) {
|
||||
if (bindingSet.has(key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function computeKeyboardDriveVector(keys, keymap) {
|
||||
const forward = bindingActive(keymap.driveForward, keys);
|
||||
const backward = bindingActive(keymap.driveBackward, keys);
|
||||
const left = bindingActive(keymap.driveLeft, keys);
|
||||
const right = bindingActive(keymap.driveRight, keys);
|
||||
const boost = bindingActive(keymap.boostModifier, keys);
|
||||
const slow = bindingActive(keymap.slowModifier, keys);
|
||||
|
||||
let y = 0;
|
||||
if (forward && !backward) y = 1;
|
||||
else if (backward && !forward) y = -1;
|
||||
|
||||
let x = 0;
|
||||
if (left && !right) x = -1;
|
||||
else if (right && !left) x = 1;
|
||||
|
||||
// The keyboard precision modifier intentionally reduces the vector as well as the
|
||||
// speed cap. Mobile precision mode uses the same path so thumb driving feels like
|
||||
// holding the same keyboard modifier, not like a separate mobile-only tune.
|
||||
const scale = slow ? 0.4 : 1;
|
||||
return {
|
||||
x: x * scale,
|
||||
y: y * scale,
|
||||
boost: boost && !slow,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeKeyboardAuxMotors(keys, keymap) {
|
||||
const allForward = bindingActive(keymap.auxAllForward, keys);
|
||||
if (allForward) {
|
||||
return { main: 127, side: 127, vacuum: 127 };
|
||||
}
|
||||
const main = bindingActive(keymap.auxMainForward, keys)
|
||||
? 127
|
||||
: bindingActive(keymap.auxMainReverse, keys)
|
||||
? -127
|
||||
: 0;
|
||||
const side = bindingActive(keymap.auxSideForward, keys)
|
||||
? 127
|
||||
: bindingActive(keymap.auxSideReverse, keys)
|
||||
? -70
|
||||
: 0;
|
||||
const vacuum = bindingActive(keymap.auxVacuumFast, keys)
|
||||
? 127
|
||||
: bindingActive(keymap.auxVacuumSlow, keys)
|
||||
? 50
|
||||
: 0;
|
||||
return { main, side, vacuum };
|
||||
}
|
||||
|
||||
export function getKeyboardDriveSpeedOptions(keys, keymap, keyboardSpeeds) {
|
||||
const slowActive = bindingActive(keymap.slowModifier, keys);
|
||||
return slowActive
|
||||
? { baseSpeed: keyboardSpeeds.precisionSpeed, boostSpeed: keyboardSpeeds.precisionSpeed }
|
||||
: { baseSpeed: keyboardSpeeds.baseSpeed, boostSpeed: keyboardSpeeds.turboSpeed };
|
||||
}
|
||||
Reference in New Issue
Block a user