feat: 前端显式选择操控模式

This commit is contained in:
2026-04-11 02:33:48 +08:00
parent adb43efb12
commit 3d5a65c6ef
3 changed files with 253 additions and 19 deletions

View File

@@ -14,6 +14,8 @@ const {
activeSourceLabel,
commandLabel,
controlLimits,
controlInputMode,
controlInputModeLabel,
controlTuning,
commandValues,
gamepadActive,
@@ -24,6 +26,7 @@ const {
gamepadMapping,
gamepadName,
gamepadRightStick,
keyboardActive,
keyboardKeys,
keyboardTurbo,
lastServerMessage,
@@ -77,9 +80,14 @@ function stickOffset(value: number) {
<template>
<section class="feedback-shell" :class="{ compact }">
<div class="feedback-topline">
<div class="headline-stack">
<div class="source-chip" :class="activeSource">
{{ activeSourceLabel }}
</div>
<div class="input-chip">
{{ controlInputModeLabel }} mode
</div>
</div>
<div class="status-stack">
<span class="socket-chip" :class="socketState">{{ socketLabel }}</span>
<span class="server-text">{{ lastServerMessage }}</span>
@@ -115,8 +123,8 @@ function stickOffset(value: number) {
<p class="label">Keyboard</p>
<strong>{{ pressedKeysLabel }}</strong>
</div>
<span class="mode-chip" :class="{ hot: keyboardTurbo }">
{{ keyboardTurbo ? 'Turbo' : 'Normal' }}
<span class="mode-chip" :class="{ hot: controlInputMode === 'keyboard' && keyboardActive }">
{{ controlInputMode === 'keyboard' ? (keyboardTurbo ? 'Turbo' : 'Selected') : 'Standby' }}
</span>
</div>
@@ -138,13 +146,23 @@ function stickOffset(value: number) {
<p class="label">Gamepad</p>
<strong>{{ gamepadConnected ? gamepadName : 'Waiting for controller' }}</strong>
</div>
<span class="mode-chip" :class="{ hot: gamepadActive }">
{{ gamepadConnected ? `#${gamepadIndex}` : 'Offline' }}
<span class="mode-chip" :class="{ hot: controlInputMode === 'gamepad' && gamepadActive }">
{{
gamepadConnected
? controlInputMode === 'gamepad'
? 'Selected'
: 'Standby'
: 'Offline'
}}
</span>
</div>
<p class="subtle">
{{ gamepadConnected ? `mapping=${gamepadMapping || 'unknown'}` : 'Left stick drives, right stick turns, RB boosts, A stops.' }}
{{
gamepadConnected
? `#${gamepadIndex} / mapping=${gamepadMapping || 'unknown'}`
: 'Left stick drives, right stick turns, RB boosts, A stops.'
}}
</p>
<div class="sticks">
@@ -213,6 +231,11 @@ function stickOffset(value: number) {
align-items: start;
}
.headline-stack {
display: grid;
gap: 8px;
}
.status-stack {
display: grid;
justify-items: end;
@@ -221,6 +244,7 @@ function stickOffset(value: number) {
}
.source-chip,
.input-chip,
.socket-chip,
.mode-chip {
display: inline-flex;
@@ -255,6 +279,11 @@ function stickOffset(value: number) {
color: #cad3e8;
}
.input-chip {
background: rgba(123, 196, 255, 0.14);
color: #dff1ff;
}
.socket-chip {
background: rgba(40, 199, 111, 0.16);
color: #7ef0b5;

View File

@@ -4,7 +4,13 @@ import { computed } from 'vue'
import ControlFeedback from '@/components/ControlFeedback.vue'
import { useControlInterface } from '@/composables/useControlInterface'
const { controlTuning, resetControlTuning, setControlTuning } = useControlInterface()
const { controlInputMode, controlInputModeLabel, controlTuning, resetControlTuning, setControlInputMode, setControlTuning } =
useControlInterface()
const inputModes = [
{ id: 'keyboard', label: 'Keyboard', detail: 'Use W/S, A/D, Q/E, Shift, and Space.' },
{ id: 'gamepad', label: 'Gamepad', detail: 'Use the browser-detected controller only.' },
] as const
const forwardSpeed = computed({
get: () => controlTuning.value.forward,
@@ -39,6 +45,31 @@ const turboMultiplier = computed({
</button>
</div>
<section class="mode-panel">
<div class="mode-panel-head">
<div>
<p class="mode-eyebrow">Input Mode</p>
<p class="mode-copy">Only one local input mode can control the page at a time.</p>
</div>
<strong class="mode-current">{{ controlInputModeLabel }}</strong>
</div>
<div class="mode-toggle" role="radiogroup" aria-label="Control input mode">
<button
v-for="mode in inputModes"
:key="mode.id"
type="button"
class="mode-button"
:class="{ active: controlInputMode === mode.id }"
:aria-pressed="controlInputMode === mode.id"
@click="setControlInputMode(mode.id)"
>
<strong>{{ mode.label }}</strong>
<span>{{ mode.detail }}</span>
</button>
</div>
</section>
<div class="tuning-grid">
<label class="tuning-field">
<span>Forward</span>
@@ -72,11 +103,11 @@ const turboMultiplier = computed({
<code>Shift</code> turbo, <code>Space</code> stop.
</p>
<p class="hint subtle">
The values above update keyboard and browser gamepad output immediately and are saved in this browser.
Speed tuning is shared by both local input modes and is saved in this browser.
</p>
<p class="hint subtle">
Browser gamepad support is live here too: left stick drives, right stick turns,
<code>RB</code> boosts, <code>A</code> sends stop.
Browser gamepad mode uses the left stick to drive, the right stick to turn,
<code>RB</code> to boost, and <code>A</code> to send stop.
</p>
</section>
</template>
@@ -127,6 +158,96 @@ h2 {
font-size: 24px;
}
.mode-panel {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 18px;
background: rgba(7, 14, 26, 0.86);
border: 1px solid rgba(133, 147, 169, 0.18);
}
.mode-panel-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.mode-eyebrow {
margin: 0 0 4px;
color: #7bc4ff;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
font-weight: 700;
}
.mode-copy {
margin: 0;
color: #d5dbee;
line-height: 1.6;
}
.mode-current {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(123, 196, 255, 0.14);
color: #dff1ff;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mode-toggle {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.mode-button {
display: grid;
gap: 6px;
min-height: 84px;
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(133, 147, 169, 0.18);
background: rgba(10, 20, 37, 0.9);
color: #dfe7fb;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.mode-button strong {
font-size: 15px;
}
.mode-button span {
color: #96a5c3;
font-size: 13px;
line-height: 1.5;
}
.mode-button:hover {
border-color: rgba(123, 196, 255, 0.4);
transform: translateY(-1px);
}
.mode-button.active {
border-color: rgba(123, 196, 255, 0.6);
background: linear-gradient(135deg, rgba(91, 122, 255, 0.24), rgba(77, 212, 172, 0.2));
box-shadow: 0 10px 28px rgba(91, 122, 255, 0.18);
}
.mode-button.active span {
color: #d5e7ff;
}
.tuning-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -184,12 +305,17 @@ h2 {
align-items: start;
}
.mode-panel-head {
flex-direction: column;
}
.tuning-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.mode-toggle,
.tuning-grid {
grid-template-columns: 1fr;
}

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { buildControlWebSocketUrl } from '@/lib/api'
type SocketState = 'connecting' | 'open' | 'closed'
type ControlInputMode = 'keyboard' | 'gamepad'
type ControlSource = 'keyboard' | 'gamepad' | 'idle'
type CommandTuple = [number, number, number, number, number, number]
@@ -47,6 +48,7 @@ const DEFAULT_CONTROL_TUNING: ControlTuning = {
turn: 0.4,
turbo: 1.5,
}
const CONTROL_INPUT_MODE_STORAGE_KEY = 'robot-command-center.control-input-mode'
const CONTROL_TUNING_STORAGE_KEY = 'robot-command-center.control-tuning'
const MIN_AXIS_SPEED = 0.05
const MAX_AXIS_SPEED = 3
@@ -94,6 +96,10 @@ function normalizeControlTuning(raw?: Partial<ControlTuning>): ControlTuning {
}
}
function normalizeControlInputMode(raw: unknown): ControlInputMode {
return raw === 'gamepad' ? 'gamepad' : 'keyboard'
}
function loadPersistedControlTuning() {
if (typeof window === 'undefined') {
return DEFAULT_CONTROL_TUNING
@@ -118,6 +124,19 @@ function loadPersistedControlTuning() {
}
}
function loadPersistedControlInputMode() {
if (typeof window === 'undefined') {
return normalizeControlInputMode(null)
}
try {
return normalizeControlInputMode(window.localStorage.getItem(CONTROL_INPUT_MODE_STORAGE_KEY))
} catch {
return normalizeControlInputMode(null)
}
}
const controlInputMode = ref<ControlInputMode>(loadPersistedControlInputMode())
const initialControlTuning = loadPersistedControlTuning()
const forwardSpeed = ref(initialControlTuning.forward)
const strafeSpeed = ref(initialControlTuning.strafe)
@@ -165,6 +184,36 @@ function persistControlTuning() {
}
}
function persistControlInputMode() {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(CONTROL_INPUT_MODE_STORAGE_KEY, controlInputMode.value)
} catch {
// Ignore storage failures so mode switching still works for the current session.
}
}
function setControlInputMode(next: ControlInputMode) {
const resolved = normalizeControlInputMode(next)
const previous = controlInputMode.value
if (resolved === previous) {
return
}
controlInputMode.value = resolved
persistControlInputMode()
if (previous === 'keyboard') {
pressedKeys.value = new Set()
}
refreshSendLoop(true)
}
function setControlTuning(next: Partial<ControlTuning>) {
const resolved = normalizeControlTuning({
forward: next.forward ?? forwardSpeed.value,
@@ -262,22 +311,30 @@ function gamepadCommandValues(): CommandTuple {
return [lx, ly, 0, 0, 0, az]
}
function keyboardActive() {
function keyboardActiveRaw() {
return pressedKeys.value.size > 0
}
function gamepadActiveInternal() {
function keyboardActive() {
return controlInputMode.value === 'keyboard' && keyboardActiveRaw()
}
function gamepadActiveRaw() {
if (!gamepadConnected.value) {
return false
}
return !isZeroCommand(gamepadCommandValues()) || gamepadButtonPressed.value.some(Boolean)
}
function gamepadActiveInternal() {
return controlInputMode.value === 'gamepad' && gamepadActiveRaw()
}
function resolvedSource(): ControlSource {
if (keyboardActive()) {
if (controlInputMode.value === 'keyboard' && keyboardActiveRaw()) {
return 'keyboard'
}
if (gamepadActiveInternal()) {
if (controlInputMode.value === 'gamepad' && gamepadActiveRaw()) {
return 'gamepad'
}
return 'idle'
@@ -343,6 +400,9 @@ function handleKeydown(event: KeyboardEvent) {
if (!TRACKED_KEYS.includes(event.code)) {
return
}
if (controlInputMode.value !== 'keyboard') {
return
}
if (event.target instanceof HTMLElement) {
const tag = event.target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
@@ -361,6 +421,9 @@ function handleKeyup(event: KeyboardEvent) {
if (!TRACKED_KEYS.includes(event.code)) {
return
}
if (controlInputMode.value !== 'keyboard') {
return
}
event.preventDefault()
const next = new Set(pressedKeys.value)
@@ -382,7 +445,9 @@ function pollGamepadState() {
gamepadSupported.value = typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function'
if (!gamepadSupported.value) {
resetGamepadState()
if (controlInputMode.value === 'gamepad') {
refreshSendLoop()
}
return
}
@@ -392,8 +457,10 @@ function pollGamepadState() {
if (gamepadConnected.value) {
resetGamepadState()
lastGamepadSignature = ''
if (controlInputMode.value === 'gamepad') {
refreshSendLoop()
}
}
return
}
@@ -412,8 +479,10 @@ function pollGamepadState() {
gamepadMapping.value = pad.mapping || 'unknown'
gamepadAxes.value = axes
gamepadButtonPressed.value = buttons
if (controlInputMode.value === 'gamepad') {
refreshSendLoop()
}
}
function connectSocket() {
if (socket != null && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
@@ -528,6 +597,11 @@ const activeSourceLabel = computed(() => {
return 'Idle'
})
const controlInputModeLabel = computed(() => {
if (controlInputMode.value === 'gamepad') return 'Gamepad'
return 'Keyboard'
})
const commandValues = computed(() => {
const [lx, ly, lz, ax, ay, az] = resolvedCommandValues()
return { lx, ly, lz, ax, ay, az }
@@ -561,7 +635,9 @@ const keyboardKeys = computed<KeyFeedback[]>(() =>
})),
)
const keyboardTurbo = computed(() => pressedKeys.value.has('ShiftLeft') || pressedKeys.value.has('ShiftRight'))
const keyboardTurbo = computed(
() => controlInputMode.value === 'keyboard' && (pressedKeys.value.has('ShiftLeft') || pressedKeys.value.has('ShiftRight')),
)
const controlTuning = computed<ControlTuning>(() => ({
forward: forwardSpeed.value,
strafe: strafeSpeed.value,
@@ -601,6 +677,9 @@ export function useControlInterface() {
})
return {
controlInputMode,
controlInputModeLabel,
setControlInputMode,
socketState,
socketLabel,
lastServerMessage,