Merge branch 'main' of https://106.52.207.92:9103/limingjie/robot-command-center
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user