From 08057baf0c4af2e6ebbc93948ec6042910dbb54b Mon Sep 17 00:00:00 2001
From: Mock
Date: Thu, 9 Apr 2026 19:34:33 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=A0=E4=BA=86=204=20=E4=B8=AA?=
=?UTF-8?q?=E5=8F=AF=E8=B0=83=E9=A1=B9=EF=BC=9AForward=E3=80=81Strafe?=
=?UTF-8?q?=E3=80=81Turn=E3=80=81Turbo?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/components/ControlFeedback.vue | 19 +-
frontend/src/components/ControlPanel.vue | 134 ++++++++++++++
.../src/composables/useControlInterface.ts | 173 ++++++++++++++++--
3 files changed, 304 insertions(+), 22 deletions(-)
diff --git a/frontend/src/components/ControlFeedback.vue b/frontend/src/components/ControlFeedback.vue
index 45c3f45..b45420e 100644
--- a/frontend/src/components/ControlFeedback.vue
+++ b/frontend/src/components/ControlFeedback.vue
@@ -13,6 +13,8 @@ const {
activeSource,
activeSourceLabel,
commandLabel,
+ controlLimits,
+ controlTuning,
commandValues,
gamepadActive,
gamepadButtons,
@@ -48,17 +50,17 @@ const commandBars = computed(() => [
{
label: 'Forward',
value: commandValues.value.lx,
- max: 1.2,
+ max: controlLimits.value.forward,
},
{
label: 'Strafe',
value: commandValues.value.ly,
- max: 0.4,
+ max: controlLimits.value.strafe,
},
{
label: 'Turn',
value: commandValues.value.az,
- max: 0.8,
+ max: controlLimits.value.turn,
},
])
@@ -101,6 +103,11 @@ function stickOffset(value: number) {
+
+ Tuning: fwd {{ controlTuning.forward.toFixed(2) }} m/s, strafe {{ controlTuning.strafe.toFixed(2) }} m/s,
+ turn {{ controlTuning.turn.toFixed(2) }} rad/s, turbo x{{ controlTuning.turbo.toFixed(2) }}
+
+
@@ -183,7 +190,7 @@ function stickOffset(value: number) {
-
+
Outgoing command: {{ commandLabel }}
@@ -350,6 +357,10 @@ function stickOffset(value: number) {
line-height: 1.6;
}
+.summary.accent {
+ color: #aeb9d2;
+}
+
.mode-chip {
background: rgba(133, 147, 169, 0.14);
color: #cad3e8;
diff --git a/frontend/src/components/ControlPanel.vue b/frontend/src/components/ControlPanel.vue
index c7ba0b1..014daa0 100644
--- a/frontend/src/components/ControlPanel.vue
+++ b/frontend/src/components/ControlPanel.vue
@@ -1,5 +1,30 @@
@@ -9,6 +34,35 @@ import ControlFeedback from '@/components/ControlFeedback.vue'
Control
Control Feedback
+
+
+
+
+
+
+
+
+
+
+
@@ -17,6 +71,9 @@ import ControlFeedback from '@/components/ControlFeedback.vue'
Keyboard mapping: W/S forward-back, A/D strafe, Q/E turn,
Shift turbo, Space stop.
+
+ The values above update keyboard and browser gamepad output immediately and are saved in this browser.
+
Browser gamepad support is live here too: left stick drives, right stick turns,
RB boosts, A sends stop.
@@ -37,6 +94,25 @@ import ControlFeedback from '@/components/ControlFeedback.vue'
align-items: start;
}
+.reset-button {
+ border: 1px solid rgba(133, 147, 169, 0.28);
+ background: rgba(10, 20, 37, 0.88);
+ color: #dfe7fb;
+ border-radius: 999px;
+ min-height: 36px;
+ padding: 0 14px;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+
+.reset-button:hover {
+ border-color: rgba(123, 196, 255, 0.48);
+ color: #ffffff;
+}
+
.eyebrow {
margin: 0 0 4px;
color: #ffb057;
@@ -51,6 +127,47 @@ h2 {
font-size: 24px;
}
+.tuning-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.tuning-field {
+ display: grid;
+ gap: 8px;
+ padding: 12px;
+ border-radius: 16px;
+ background: rgba(7, 14, 26, 0.86);
+ border: 1px solid rgba(133, 147, 169, 0.18);
+}
+
+.tuning-field span,
+.tuning-field small {
+ color: #aeb9d2;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.tuning-field input {
+ width: 100%;
+ min-height: 42px;
+ border-radius: 12px;
+ border: 1px solid rgba(133, 147, 169, 0.24);
+ background: rgba(10, 20, 37, 0.96);
+ color: #f6f8fc;
+ padding: 0 12px;
+ font-size: 16px;
+ font-weight: 700;
+}
+
+.tuning-field input:focus {
+ outline: none;
+ border-color: rgba(123, 196, 255, 0.62);
+ box-shadow: 0 0 0 3px rgba(91, 122, 255, 0.18);
+}
+
.hint {
margin: 0;
color: #d5dbee;
@@ -60,4 +177,21 @@ h2 {
.hint.subtle {
color: #96a5c3;
}
+
+@media (max-width: 960px) {
+ .panel-head {
+ flex-direction: column;
+ align-items: start;
+ }
+
+ .tuning-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 640px) {
+ .tuning-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend/src/composables/useControlInterface.ts b/frontend/src/composables/useControlInterface.ts
index 066e1af..192e0e0 100644
--- a/frontend/src/composables/useControlInterface.ts
+++ b/frontend/src/composables/useControlInterface.ts
@@ -17,6 +17,13 @@ type ButtonFeedback = {
pressed: boolean
}
+type ControlTuning = {
+ forward: number
+ strafe: number
+ turn: number
+ turbo: number
+}
+
const TRACKED_KEYS = ['KeyW', 'KeyS', 'KeyA', 'KeyD', 'KeyQ', 'KeyE', 'ShiftLeft', 'ShiftRight', 'Space']
const KEY_LABELS: Record = {
KeyW: 'W',
@@ -33,11 +40,18 @@ const GAMEPAD_BUTTON_LABELS = ['A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Back
const ZERO_COMMAND: CommandTuple = [0, 0, 0, 0, 0, 0]
const GAMEPAD_DEADZONE = 0.14
-const TURBO_MULTIPLIER = 1.5
const COMMAND_SEND_INTERVAL_MS = 50
-const KEYBOARD_FORWARD_SPEED = 0.8
-const KEYBOARD_STRAFE_SPEED = 0.15
-const KEYBOARD_TURN_SPEED = 0.4
+const DEFAULT_CONTROL_TUNING: ControlTuning = {
+ forward: 0.8,
+ strafe: 0.15,
+ turn: 0.4,
+ turbo: 1.5,
+}
+const CONTROL_TUNING_STORAGE_KEY = 'robot-command-center.control-tuning'
+const MIN_AXIS_SPEED = 0.05
+const MAX_AXIS_SPEED = 3
+const MIN_TURBO_MULTIPLIER = 1
+const MAX_TURBO_MULTIPLIER = 3
const pressedKeys = ref>(new Set())
const socketState = ref('connecting')
@@ -51,6 +65,65 @@ const gamepadAxes = ref([0, 0, 0, 0])
const gamepadButtonPressed = ref(Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false))
const activeSource = ref('idle')
+function clampValue(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value))
+}
+
+function sanitizeAxisSpeed(value: unknown, fallback: number) {
+ const numericValue = typeof value === 'number' ? value : Number(value)
+ if (!Number.isFinite(numericValue)) {
+ return fallback
+ }
+ return roundValue(clampValue(numericValue, MIN_AXIS_SPEED, MAX_AXIS_SPEED))
+}
+
+function sanitizeTurboMultiplier(value: unknown, fallback: number) {
+ const numericValue = typeof value === 'number' ? value : Number(value)
+ if (!Number.isFinite(numericValue)) {
+ return fallback
+ }
+ return roundValue(clampValue(numericValue, MIN_TURBO_MULTIPLIER, MAX_TURBO_MULTIPLIER))
+}
+
+function normalizeControlTuning(raw?: Partial): ControlTuning {
+ return {
+ forward: sanitizeAxisSpeed(raw?.forward, DEFAULT_CONTROL_TUNING.forward),
+ strafe: sanitizeAxisSpeed(raw?.strafe, DEFAULT_CONTROL_TUNING.strafe),
+ turn: sanitizeAxisSpeed(raw?.turn, DEFAULT_CONTROL_TUNING.turn),
+ turbo: sanitizeTurboMultiplier(raw?.turbo, DEFAULT_CONTROL_TUNING.turbo),
+ }
+}
+
+function loadPersistedControlTuning() {
+ if (typeof window === 'undefined') {
+ return DEFAULT_CONTROL_TUNING
+ }
+
+ let raw: string | null = null
+
+ try {
+ raw = window.localStorage.getItem(CONTROL_TUNING_STORAGE_KEY)
+ } catch {
+ return DEFAULT_CONTROL_TUNING
+ }
+
+ if (raw == null) {
+ return DEFAULT_CONTROL_TUNING
+ }
+
+ try {
+ return normalizeControlTuning(JSON.parse(raw) as Partial)
+ } catch {
+ return DEFAULT_CONTROL_TUNING
+ }
+}
+
+const initialControlTuning = loadPersistedControlTuning()
+const forwardSpeed = ref(initialControlTuning.forward)
+const strafeSpeed = ref(initialControlTuning.strafe)
+const turnSpeed = ref(initialControlTuning.turn)
+const turboMultiplier = ref(initialControlTuning.turbo)
+
let socket: WebSocket | null = null
let sendTimer: number | null = null
let reconnectTimer: number | null = null
@@ -72,6 +145,54 @@ function roundValue(value: number) {
return Math.round(value * 1000) / 1000
}
+function persistControlTuning() {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ try {
+ window.localStorage.setItem(
+ CONTROL_TUNING_STORAGE_KEY,
+ JSON.stringify({
+ forward: forwardSpeed.value,
+ strafe: strafeSpeed.value,
+ turn: turnSpeed.value,
+ turbo: turboMultiplier.value,
+ }),
+ )
+ } catch {
+ // Ignore storage failures so tuning still works for the current session.
+ }
+}
+
+function setControlTuning(next: Partial) {
+ const resolved = normalizeControlTuning({
+ forward: next.forward ?? forwardSpeed.value,
+ strafe: next.strafe ?? strafeSpeed.value,
+ turn: next.turn ?? turnSpeed.value,
+ turbo: next.turbo ?? turboMultiplier.value,
+ })
+ const changed =
+ resolved.forward !== forwardSpeed.value ||
+ resolved.strafe !== strafeSpeed.value ||
+ resolved.turn !== turnSpeed.value ||
+ resolved.turbo !== turboMultiplier.value
+
+ forwardSpeed.value = resolved.forward
+ strafeSpeed.value = resolved.strafe
+ turnSpeed.value = resolved.turn
+ turboMultiplier.value = resolved.turbo
+ persistControlTuning()
+
+ if (changed) {
+ refreshSendLoop(true)
+ }
+}
+
+function resetControlTuning() {
+ setControlTuning(DEFAULT_CONTROL_TUNING)
+}
+
function packCommand(values: CommandTuple) {
const buffer = new ArrayBuffer(24)
const view = new DataView(buffer)
@@ -95,18 +216,18 @@ function activeTurnAxis() {
function keyboardCommandValues(): CommandTuple {
const keys = pressedKeys.value
- const turbo = keys.has('ShiftLeft') || keys.has('ShiftRight') ? TURBO_MULTIPLIER : 1
+ const turbo = keys.has('ShiftLeft') || keys.has('ShiftRight') ? turboMultiplier.value : 1
let lx = 0
let ly = 0
let az = 0
- if (keys.has('KeyW')) lx += KEYBOARD_FORWARD_SPEED
- if (keys.has('KeyS')) lx -= KEYBOARD_FORWARD_SPEED
- if (keys.has('KeyA')) ly += KEYBOARD_STRAFE_SPEED
- if (keys.has('KeyD')) ly -= KEYBOARD_STRAFE_SPEED
- if (keys.has('KeyQ')) az += KEYBOARD_TURN_SPEED
- if (keys.has('KeyE')) az -= KEYBOARD_TURN_SPEED
+ if (keys.has('KeyW')) lx += forwardSpeed.value
+ if (keys.has('KeyS')) lx -= forwardSpeed.value
+ if (keys.has('KeyA')) ly += strafeSpeed.value
+ if (keys.has('KeyD')) ly -= strafeSpeed.value
+ if (keys.has('KeyQ')) az += turnSpeed.value
+ if (keys.has('KeyE')) az -= turnSpeed.value
if (keys.has('Space')) {
return ZERO_COMMAND
@@ -128,15 +249,15 @@ function gamepadCommandValues(): CommandTuple {
}
const buttons = gamepadButtonPressed.value
- const turbo = buttons[5] ? TURBO_MULTIPLIER : 1
+ const turbo = buttons[5] ? turboMultiplier.value : 1
if (buttons[0]) {
return ZERO_COMMAND
}
- const lx = roundValue(-normalizeAxis(gamepadAxes.value[1] ?? 0) * KEYBOARD_FORWARD_SPEED * turbo)
- const ly = roundValue(-normalizeAxis(gamepadAxes.value[0] ?? 0) * KEYBOARD_STRAFE_SPEED * turbo)
- const az = roundValue(-activeTurnAxis() * KEYBOARD_TURN_SPEED * turbo)
+ const lx = roundValue(-normalizeAxis(gamepadAxes.value[1] ?? 0) * forwardSpeed.value * turbo)
+ const ly = roundValue(-normalizeAxis(gamepadAxes.value[0] ?? 0) * strafeSpeed.value * turbo)
+ const az = roundValue(-activeTurnAxis() * turnSpeed.value * turbo)
return [lx, ly, 0, 0, 0, az]
}
@@ -419,12 +540,13 @@ const commandLabel = computed(() => {
const commandMagnitude = computed(() => {
const { lx, ly, az } = commandValues.value
+ const limits = controlLimits.value
return Math.min(
1,
Math.max(
- Math.abs(lx) / KEYBOARD_FORWARD_SPEED,
- Math.abs(ly) / KEYBOARD_STRAFE_SPEED,
- Math.abs(az) / KEYBOARD_TURN_SPEED,
+ Math.abs(lx) / Math.max(limits.forward, MIN_AXIS_SPEED),
+ Math.abs(ly) / Math.max(limits.strafe, MIN_AXIS_SPEED),
+ Math.abs(az) / Math.max(limits.turn, MIN_AXIS_SPEED),
),
)
})
@@ -440,6 +562,17 @@ const keyboardKeys = computed(() =>
)
const keyboardTurbo = computed(() => pressedKeys.value.has('ShiftLeft') || pressedKeys.value.has('ShiftRight'))
+const controlTuning = computed(() => ({
+ forward: forwardSpeed.value,
+ strafe: strafeSpeed.value,
+ turn: turnSpeed.value,
+ turbo: turboMultiplier.value,
+}))
+const controlLimits = computed(() => ({
+ forward: roundValue(forwardSpeed.value * turboMultiplier.value),
+ strafe: roundValue(strafeSpeed.value * turboMultiplier.value),
+ turn: roundValue(turnSpeed.value * turboMultiplier.value),
+}))
const gamepadButtons = computed(() =>
GAMEPAD_BUTTON_LABELS.map((label, index) => ({
@@ -476,6 +609,10 @@ export function useControlInterface() {
commandValues,
commandLabel,
commandMagnitude,
+ controlTuning,
+ controlLimits,
+ setControlTuning,
+ resetControlTuning,
pressedKeysLabel,
keyboardKeys,
keyboardTurbo,