feat: 前端增加控制反馈
This commit is contained in:
488
frontend/src/components/ControlFeedback.vue
Normal file
488
frontend/src/components/ControlFeedback.vue
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { useControlInterface } from '@/composables/useControlInterface'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
compact?: boolean
|
||||||
|
}>(), {
|
||||||
|
compact: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeSource,
|
||||||
|
activeSourceLabel,
|
||||||
|
commandLabel,
|
||||||
|
commandValues,
|
||||||
|
gamepadActive,
|
||||||
|
gamepadButtons,
|
||||||
|
gamepadConnected,
|
||||||
|
gamepadIndex,
|
||||||
|
gamepadLeftStick,
|
||||||
|
gamepadMapping,
|
||||||
|
gamepadName,
|
||||||
|
gamepadRightStick,
|
||||||
|
keyboardKeys,
|
||||||
|
keyboardTurbo,
|
||||||
|
lastServerMessage,
|
||||||
|
pressedKeysLabel,
|
||||||
|
socketLabel,
|
||||||
|
socketState,
|
||||||
|
} = useControlInterface()
|
||||||
|
|
||||||
|
const keyClusters = computed(() => {
|
||||||
|
const lookup = new Map(keyboardKeys.value.map((entry) => [entry.code, entry]))
|
||||||
|
return [
|
||||||
|
lookup.get('KeyW'),
|
||||||
|
lookup.get('KeyA'),
|
||||||
|
lookup.get('KeyS'),
|
||||||
|
lookup.get('KeyD'),
|
||||||
|
lookup.get('KeyQ'),
|
||||||
|
lookup.get('KeyE'),
|
||||||
|
lookup.get('ShiftLeft'),
|
||||||
|
lookup.get('Space'),
|
||||||
|
].filter((entry): entry is NonNullable<typeof entry> => entry != null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const commandBars = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Forward',
|
||||||
|
value: commandValues.value.lx,
|
||||||
|
max: 1.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Strafe',
|
||||||
|
value: commandValues.value.ly,
|
||||||
|
max: 0.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Turn',
|
||||||
|
value: commandValues.value.az,
|
||||||
|
max: 0.8,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function meterPosition(value: number, max: number) {
|
||||||
|
const normalized = Math.max(-1, Math.min(1, value / max))
|
||||||
|
return `${50 + normalized * 45}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stickOffset(value: number) {
|
||||||
|
return `${value * 22}px`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="feedback-shell" :class="{ compact }">
|
||||||
|
<div class="feedback-topline">
|
||||||
|
<div class="source-chip" :class="activeSource">
|
||||||
|
{{ activeSourceLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="status-stack">
|
||||||
|
<span class="socket-chip" :class="socketState">{{ socketLabel }}</span>
|
||||||
|
<span class="server-text">{{ lastServerMessage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="command-strip">
|
||||||
|
<div
|
||||||
|
v-for="bar in commandBars"
|
||||||
|
:key="bar.label"
|
||||||
|
class="command-card"
|
||||||
|
>
|
||||||
|
<div class="command-head">
|
||||||
|
<span>{{ bar.label }}</span>
|
||||||
|
<strong>{{ bar.value.toFixed(2) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="command-meter">
|
||||||
|
<span class="center-line" />
|
||||||
|
<span class="command-dot" :style="{ left: meterPosition(bar.value, bar.max) }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feedback-grid" :class="{ compact }">
|
||||||
|
<section class="feedback-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<p class="label">Keyboard</p>
|
||||||
|
<strong>{{ pressedKeysLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
<span class="mode-chip" :class="{ hot: keyboardTurbo }">
|
||||||
|
{{ keyboardTurbo ? 'Turbo' : 'Normal' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-grid">
|
||||||
|
<span
|
||||||
|
v-for="key in keyClusters"
|
||||||
|
:key="key.code"
|
||||||
|
class="key-chip"
|
||||||
|
:class="{ active: key.pressed, wide: key.code === 'Space' || key.code === 'ShiftLeft' }"
|
||||||
|
>
|
||||||
|
{{ key.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="feedback-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<p class="label">Gamepad</p>
|
||||||
|
<strong>{{ gamepadConnected ? gamepadName : 'Waiting for controller' }}</strong>
|
||||||
|
</div>
|
||||||
|
<span class="mode-chip" :class="{ hot: gamepadActive }">
|
||||||
|
{{ gamepadConnected ? `#${gamepadIndex}` : 'Offline' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="subtle">
|
||||||
|
{{ gamepadConnected ? `mapping=${gamepadMapping || 'unknown'}` : 'Left stick drives, right stick turns, RB boosts, A stops.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="sticks">
|
||||||
|
<div class="stick-card">
|
||||||
|
<span>Left stick</span>
|
||||||
|
<div class="stick-pad">
|
||||||
|
<span class="crosshair crosshair-x" />
|
||||||
|
<span class="crosshair crosshair-y" />
|
||||||
|
<span
|
||||||
|
class="stick-dot"
|
||||||
|
:style="{
|
||||||
|
transform: `translate(${stickOffset(gamepadLeftStick.x)}, ${stickOffset(gamepadLeftStick.y)})`,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stick-card">
|
||||||
|
<span>Right stick</span>
|
||||||
|
<div class="stick-pad">
|
||||||
|
<span class="crosshair crosshair-x" />
|
||||||
|
<span class="crosshair crosshair-y" />
|
||||||
|
<span
|
||||||
|
class="stick-dot accent"
|
||||||
|
:style="{
|
||||||
|
transform: `translate(${stickOffset(gamepadRightStick.x)}, ${stickOffset(gamepadRightStick.y)})`,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-grid">
|
||||||
|
<span
|
||||||
|
v-for="button in gamepadButtons"
|
||||||
|
:key="button.label"
|
||||||
|
class="button-chip"
|
||||||
|
:class="{ active: button.pressed }"
|
||||||
|
>
|
||||||
|
{{ button.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!compact" class="summary">
|
||||||
|
Outgoing command: {{ commandLabel }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feedback-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-shell.compact {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-topline {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-stack {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-chip,
|
||||||
|
.socket-chip,
|
||||||
|
.mode-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-chip {
|
||||||
|
background: rgba(78, 224, 168, 0.16);
|
||||||
|
color: #86f0c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-chip.keyboard {
|
||||||
|
background: rgba(91, 122, 255, 0.18);
|
||||||
|
color: #d3dcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-chip.gamepad {
|
||||||
|
background: rgba(255, 176, 87, 0.18);
|
||||||
|
color: #ffd8a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-chip.idle {
|
||||||
|
background: rgba(133, 147, 169, 0.16);
|
||||||
|
color: #cad3e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket-chip {
|
||||||
|
background: rgba(40, 199, 111, 0.16);
|
||||||
|
color: #7ef0b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket-chip.connecting,
|
||||||
|
.socket-chip.closed {
|
||||||
|
background: rgba(255, 176, 87, 0.18);
|
||||||
|
color: #ffd29b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-text {
|
||||||
|
max-width: 320px;
|
||||||
|
color: #aeb9d2;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card,
|
||||||
|
.feedback-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(7, 14, 26, 0.86);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-head,
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-head span,
|
||||||
|
.label {
|
||||||
|
color: #8d99b3;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-head strong,
|
||||||
|
.card-head strong {
|
||||||
|
color: #f6f8fc;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-meter {
|
||||||
|
position: relative;
|
||||||
|
height: 34px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, rgba(255, 99, 99, 0.12), rgba(255, 255, 255, 0.05), rgba(78, 224, 168, 0.14));
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(222, 232, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 30% 30%, #fdfefe, #63e6a9 62%, #2d8e68 100%);
|
||||||
|
box-shadow: 0 0 16px rgba(99, 230, 169, 0.38);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-grid.compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle,
|
||||||
|
.summary {
|
||||||
|
margin: 0;
|
||||||
|
color: #8d99b3;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-chip {
|
||||||
|
background: rgba(133, 147, 169, 0.14);
|
||||||
|
color: #cad3e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-chip.hot {
|
||||||
|
background: rgba(255, 176, 87, 0.18);
|
||||||
|
color: #ffd29b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-grid,
|
||||||
|
.button-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-chip,
|
||||||
|
.button-chip {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(10, 20, 37, 0.9);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||||
|
color: #dfe7fb;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-chip.wide {
|
||||||
|
min-width: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-chip.active,
|
||||||
|
.button-chip.active {
|
||||||
|
background: linear-gradient(135deg, rgba(91, 122, 255, 0.28), rgba(77, 212, 172, 0.28));
|
||||||
|
border-color: rgba(123, 196, 255, 0.6);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 8px 24px rgba(91, 122, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticks {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stick-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stick-card span {
|
||||||
|
color: #aeb9d2;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stick-pad {
|
||||||
|
position: relative;
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||||
|
background: radial-gradient(circle at center, rgba(91, 122, 255, 0.12), rgba(4, 8, 15, 0.95));
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(222, 232, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair-x {
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
height: 1px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair-y {
|
||||||
|
top: 14px;
|
||||||
|
bottom: 14px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stick-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin: -9px 0 0 -9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 30% 30%, #f8fdff, #63e6a9 58%, #2a7e5f 100%);
|
||||||
|
box-shadow: 0 0 18px rgba(99, 230, 169, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stick-dot.accent {
|
||||||
|
background: radial-gradient(circle at 30% 30%, #fffaf4, #ffb057 58%, #b06d21 100%);
|
||||||
|
box-shadow: 0 0 18px rgba(255, 176, 87, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.command-strip,
|
||||||
|
.feedback-grid,
|
||||||
|
.sticks {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-stack {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-topline,
|
||||||
|
.command-head,
|
||||||
|
.card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-text {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,188 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import ControlFeedback from '@/components/ControlFeedback.vue'
|
||||||
|
|
||||||
import { buildControlWebSocketUrl } from '@/lib/api'
|
|
||||||
|
|
||||||
const TRACKED_KEYS = new Set(['KeyW', 'KeyS', 'KeyA', 'KeyD', 'KeyQ', 'KeyE', 'ShiftLeft', 'ShiftRight', 'Space'])
|
|
||||||
|
|
||||||
const pressedKeys = ref<Set<string>>(new Set())
|
|
||||||
const socketState = ref<'connecting' | 'open' | 'closed'>('connecting')
|
|
||||||
const lastServerMessage = ref('waiting')
|
|
||||||
|
|
||||||
let socket: WebSocket | null = null
|
|
||||||
let sendTimer: number | null = null
|
|
||||||
let reconnectTimer: number | null = null
|
|
||||||
let manualClose = false
|
|
||||||
|
|
||||||
function commandValues() {
|
|
||||||
const keys = pressedKeys.value
|
|
||||||
const turbo = keys.has('ShiftLeft') || keys.has('ShiftRight') ? 1.5 : 1.0
|
|
||||||
|
|
||||||
let lx = 0
|
|
||||||
let ly = 0
|
|
||||||
let az = 0
|
|
||||||
|
|
||||||
if (keys.has('KeyW')) lx += 0.8
|
|
||||||
if (keys.has('KeyS')) lx -= 0.8
|
|
||||||
if (keys.has('KeyA')) ly += 0.15
|
|
||||||
if (keys.has('KeyD')) ly -= 0.15
|
|
||||||
if (keys.has('KeyQ')) az += 0.4
|
|
||||||
if (keys.has('KeyE')) az -= 0.4
|
|
||||||
|
|
||||||
if (keys.has('Space')) {
|
|
||||||
lx = 0
|
|
||||||
ly = 0
|
|
||||||
az = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return [lx * turbo, ly * turbo, 0, 0, 0, az * turbo]
|
|
||||||
}
|
|
||||||
|
|
||||||
function packCommand(values: number[]) {
|
|
||||||
const buffer = new ArrayBuffer(24)
|
|
||||||
const view = new DataView(buffer)
|
|
||||||
values.forEach((value, index) => view.setFloat32(index * 4, value, true))
|
|
||||||
return buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
function isZeroCommand(values: number[]) {
|
|
||||||
return values.every((value) => value === 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendCurrentCommand() {
|
|
||||||
if (socket == null || socket.readyState !== WebSocket.OPEN) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
socket.send(packCommand(commandValues()))
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSendLoop() {
|
|
||||||
if (sendTimer != null) {
|
|
||||||
window.clearInterval(sendTimer)
|
|
||||||
sendTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshSendLoop() {
|
|
||||||
const values = commandValues()
|
|
||||||
stopSendLoop()
|
|
||||||
sendCurrentCommand()
|
|
||||||
if (isZeroCommand(values)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendTimer = window.setInterval(() => {
|
|
||||||
sendCurrentCommand()
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCommands() {
|
|
||||||
pressedKeys.value = new Set()
|
|
||||||
refreshSendLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectSocket() {
|
|
||||||
if (socket != null && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manualClose = false
|
|
||||||
socketState.value = 'connecting'
|
|
||||||
socket = new WebSocket(buildControlWebSocketUrl())
|
|
||||||
socket.binaryType = 'arraybuffer'
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
|
||||||
socketState.value = 'open'
|
|
||||||
lastServerMessage.value = 'connected'
|
|
||||||
refreshSendLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
if (typeof event.data === 'string') {
|
|
||||||
lastServerMessage.value = event.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
socketState.value = 'closed'
|
|
||||||
stopSendLoop()
|
|
||||||
socket = null
|
|
||||||
if (manualClose) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (reconnectTimer != null) {
|
|
||||||
window.clearTimeout(reconnectTimer)
|
|
||||||
}
|
|
||||||
reconnectTimer = window.setTimeout(() => {
|
|
||||||
connectSocket()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnectSocket() {
|
|
||||||
manualClose = true
|
|
||||||
stopSendLoop()
|
|
||||||
if (reconnectTimer != null) {
|
|
||||||
window.clearTimeout(reconnectTimer)
|
|
||||||
reconnectTimer = null
|
|
||||||
}
|
|
||||||
socket?.close()
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
|
||||||
if (!TRACKED_KEYS.has(event.code)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.target instanceof HTMLElement) {
|
|
||||||
const tag = event.target.tagName
|
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
const next = new Set(pressedKeys.value)
|
|
||||||
next.add(event.code)
|
|
||||||
pressedKeys.value = next
|
|
||||||
refreshSendLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyup(event: KeyboardEvent) {
|
|
||||||
if (!TRACKED_KEYS.has(event.code)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
const next = new Set(pressedKeys.value)
|
|
||||||
next.delete(event.code)
|
|
||||||
pressedKeys.value = next
|
|
||||||
refreshSendLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
const pressedKeyLabel = computed(() => Array.from(pressedKeys.value).sort().join(', ') || 'none')
|
|
||||||
const socketLabel = computed(() => {
|
|
||||||
if (socketState.value === 'open') return 'ws open'
|
|
||||||
if (socketState.value === 'connecting') return 'connecting'
|
|
||||||
return 'reconnecting'
|
|
||||||
})
|
|
||||||
const commandLabel = computed(() => {
|
|
||||||
const [lx, ly, _lz, _ax, _ay, az] = commandValues()
|
|
||||||
return `lx=${lx.toFixed(2)} ly=${ly.toFixed(2)} az=${az.toFixed(2)}`
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
connectSocket()
|
|
||||||
window.addEventListener('keydown', handleKeydown)
|
|
||||||
window.addEventListener('keyup', handleKeyup)
|
|
||||||
window.addEventListener('blur', clearCommands)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('keydown', handleKeydown)
|
|
||||||
window.removeEventListener('keyup', handleKeyup)
|
|
||||||
window.removeEventListener('blur', clearCommands)
|
|
||||||
disconnectSocket()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -190,27 +7,20 @@ onUnmounted(() => {
|
|||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Control</p>
|
<p class="eyebrow">Control</p>
|
||||||
<h2>Web Control</h2>
|
<h2>Control Feedback</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="{ warm: socketState !== 'open' }">{{ socketLabel }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid">
|
<ControlFeedback />
|
||||||
<div class="stat-card">
|
|
||||||
<span>Pressed</span>
|
|
||||||
<strong>{{ pressedKeyLabel }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span>Command</span>
|
|
||||||
<strong>{{ commandLabel }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Keyboard mapping: <code>W/S</code> forward-back, <code>A/D</code> lateral, <code>Q/E</code> turn,
|
Keyboard mapping: <code>W/S</code> forward-back, <code>A/D</code> strafe, <code>Q/E</code> turn,
|
||||||
<code>Shift</code> turbo, <code>Space</code> stop.
|
<code>Shift</code> turbo, <code>Space</code> stop.
|
||||||
</p>
|
</p>
|
||||||
<p class="hint subtle">Server: {{ lastServerMessage }}</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.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -241,45 +51,6 @@ h2 {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(40, 199, 111, 0.16);
|
|
||||||
color: #63e6a9;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.warm {
|
|
||||||
background: rgba(255, 176, 87, 0.16);
|
|
||||||
color: #ffcf97;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(7, 14, 26, 0.78);
|
|
||||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #8d99b3;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card strong {
|
|
||||||
font-size: 18px;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #d5dbee;
|
color: #d5dbee;
|
||||||
@@ -289,10 +60,4 @@ h2 {
|
|||||||
.hint.subtle {
|
.hint.subtle {
|
||||||
color: #96a5c3;
|
color: #96a5c3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import ControlFeedback from '@/components/ControlFeedback.vue'
|
||||||
import { buildVideoFrameUrl, fetchVideoStatus } from '@/lib/api'
|
import { buildVideoFrameUrl, fetchVideoStatus } from '@/lib/api'
|
||||||
import type { VideoStatus } from '@/types'
|
import type { VideoStatus } from '@/types'
|
||||||
|
|
||||||
@@ -164,6 +165,9 @@ watch([currentFps, canRequestFrames], () => {
|
|||||||
<div v-else class="video-placeholder">
|
<div v-else class="video-placeholder">
|
||||||
{{ placeholderText }}
|
{{ placeholderText }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="video-overlay">
|
||||||
|
<ControlFeedback compact />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
@@ -250,6 +254,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-shell {
|
.video-shell {
|
||||||
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: 1px solid rgba(133, 147, 169, 0.28);
|
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||||
@@ -278,6 +283,32 @@ h2 {
|
|||||||
#02050d;
|
#02050d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 14px 14px 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
padding-top: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-overlay :deep(.feedback-shell) {
|
||||||
|
pointer-events: auto;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(180deg, rgba(4, 8, 15, 0.08), rgba(4, 8, 15, 0.72));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-overlay :deep(.command-card),
|
||||||
|
.video-overlay :deep(.feedback-card) {
|
||||||
|
background: rgba(6, 12, 22, 0.74);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-overlay :deep(.summary) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -364,6 +395,11 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.video-overlay {
|
||||||
|
inset: auto 10px 10px 10px;
|
||||||
|
padding-top: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
494
frontend/src/composables/useControlInterface.ts
Normal file
494
frontend/src/composables/useControlInterface.ts
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { buildControlWebSocketUrl } from '@/lib/api'
|
||||||
|
|
||||||
|
type SocketState = 'connecting' | 'open' | 'closed'
|
||||||
|
type ControlSource = 'keyboard' | 'gamepad' | 'idle'
|
||||||
|
type CommandTuple = [number, number, number, number, number, number]
|
||||||
|
|
||||||
|
type KeyFeedback = {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
pressed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonFeedback = {
|
||||||
|
label: string
|
||||||
|
pressed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACKED_KEYS = ['KeyW', 'KeyS', 'KeyA', 'KeyD', 'KeyQ', 'KeyE', 'ShiftLeft', 'ShiftRight', 'Space']
|
||||||
|
const KEY_LABELS: Record<string, string> = {
|
||||||
|
KeyW: 'W',
|
||||||
|
KeyS: 'S',
|
||||||
|
KeyA: 'A',
|
||||||
|
KeyD: 'D',
|
||||||
|
KeyQ: 'Q',
|
||||||
|
KeyE: 'E',
|
||||||
|
ShiftLeft: 'Shift',
|
||||||
|
ShiftRight: 'Shift',
|
||||||
|
Space: 'Stop',
|
||||||
|
}
|
||||||
|
const GAMEPAD_BUTTON_LABELS = ['A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Back', 'Start', 'LS', 'RS']
|
||||||
|
|
||||||
|
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 pressedKeys = ref<Set<string>>(new Set())
|
||||||
|
const socketState = ref<SocketState>('connecting')
|
||||||
|
const lastServerMessage = ref('waiting')
|
||||||
|
const gamepadSupported = ref(false)
|
||||||
|
const gamepadConnected = ref(false)
|
||||||
|
const gamepadName = ref('No gamepad detected')
|
||||||
|
const gamepadIndex = ref<number | null>(null)
|
||||||
|
const gamepadMapping = ref('')
|
||||||
|
const gamepadAxes = ref<number[]>([0, 0, 0, 0])
|
||||||
|
const gamepadButtonPressed = ref<boolean[]>(Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false))
|
||||||
|
const activeSource = ref<ControlSource>('idle')
|
||||||
|
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let sendTimer: number | null = null
|
||||||
|
let reconnectTimer: number | null = null
|
||||||
|
let gamepadTimer: number | null = null
|
||||||
|
let manualClose = false
|
||||||
|
let consumerCount = 0
|
||||||
|
let lastGamepadSignature = ''
|
||||||
|
let lastCommandSignature = ''
|
||||||
|
|
||||||
|
function normalizeAxis(raw: number) {
|
||||||
|
if (Math.abs(raw) < GAMEPAD_DEADZONE) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const sign = raw >= 0 ? 1 : -1
|
||||||
|
return sign * ((Math.abs(raw) - GAMEPAD_DEADZONE) / (1 - GAMEPAD_DEADZONE))
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundValue(value: number) {
|
||||||
|
return Math.round(value * 1000) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
function packCommand(values: CommandTuple) {
|
||||||
|
const buffer = new ArrayBuffer(24)
|
||||||
|
const view = new DataView(buffer)
|
||||||
|
values.forEach((value, index) => view.setFloat32(index * 4, value, true))
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZeroCommand(values: CommandTuple) {
|
||||||
|
return values.every((value) => Math.abs(value) < 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandSignature(values: CommandTuple, source: ControlSource) {
|
||||||
|
return `${source}:${values.map((value) => value.toFixed(3)).join(',')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeTurnAxis() {
|
||||||
|
const axis2 = normalizeAxis(gamepadAxes.value[2] ?? 0)
|
||||||
|
const axis3 = normalizeAxis(gamepadAxes.value[3] ?? 0)
|
||||||
|
return Math.abs(axis2) >= Math.abs(axis3) ? axis2 : axis3
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardCommandValues(): CommandTuple {
|
||||||
|
const keys = pressedKeys.value
|
||||||
|
const turbo = keys.has('ShiftLeft') || keys.has('ShiftRight') ? TURBO_MULTIPLIER : 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('Space')) {
|
||||||
|
return ZERO_COMMAND
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
roundValue(lx * turbo),
|
||||||
|
roundValue(ly * turbo),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
roundValue(az * turbo),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function gamepadCommandValues(): CommandTuple {
|
||||||
|
if (!gamepadConnected.value) {
|
||||||
|
return ZERO_COMMAND
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttons = gamepadButtonPressed.value
|
||||||
|
const turbo = buttons[5] ? TURBO_MULTIPLIER : 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)
|
||||||
|
|
||||||
|
return [lx, ly, 0, 0, 0, az]
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardActive() {
|
||||||
|
return pressedKeys.value.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function gamepadActiveInternal() {
|
||||||
|
if (!gamepadConnected.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !isZeroCommand(gamepadCommandValues()) || gamepadButtonPressed.value.some(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedSource(): ControlSource {
|
||||||
|
if (keyboardActive()) {
|
||||||
|
return 'keyboard'
|
||||||
|
}
|
||||||
|
if (gamepadActiveInternal()) {
|
||||||
|
return 'gamepad'
|
||||||
|
}
|
||||||
|
return 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedCommandValues(): CommandTuple {
|
||||||
|
const source = resolvedSource()
|
||||||
|
activeSource.value = source
|
||||||
|
if (source === 'keyboard') {
|
||||||
|
return keyboardCommandValues()
|
||||||
|
}
|
||||||
|
if (source === 'gamepad') {
|
||||||
|
return gamepadCommandValues()
|
||||||
|
}
|
||||||
|
return ZERO_COMMAND
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSendLoop() {
|
||||||
|
if (sendTimer != null) {
|
||||||
|
window.clearInterval(sendTimer)
|
||||||
|
sendTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCurrentCommand() {
|
||||||
|
if (socket == null || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.send(packCommand(resolvedCommandValues()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSendLoop(force = false) {
|
||||||
|
const source = resolvedSource()
|
||||||
|
const values = resolvedCommandValues()
|
||||||
|
const signature = commandSignature(values, source)
|
||||||
|
|
||||||
|
if (!force && signature === lastCommandSignature) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastCommandSignature = signature
|
||||||
|
|
||||||
|
stopSendLoop()
|
||||||
|
if (socket == null || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCurrentCommand()
|
||||||
|
if (isZeroCommand(values)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTimer = window.setInterval(() => {
|
||||||
|
sendCurrentCommand()
|
||||||
|
}, COMMAND_SEND_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearKeyboardCommands() {
|
||||||
|
pressedKeys.value = new Set()
|
||||||
|
refreshSendLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!TRACKED_KEYS.includes(event.code)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.target instanceof HTMLElement) {
|
||||||
|
const tag = event.target.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const next = new Set(pressedKeys.value)
|
||||||
|
next.add(event.code)
|
||||||
|
pressedKeys.value = next
|
||||||
|
refreshSendLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyup(event: KeyboardEvent) {
|
||||||
|
if (!TRACKED_KEYS.includes(event.code)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const next = new Set(pressedKeys.value)
|
||||||
|
next.delete(event.code)
|
||||||
|
pressedKeys.value = next
|
||||||
|
refreshSendLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGamepadState() {
|
||||||
|
gamepadConnected.value = false
|
||||||
|
gamepadName.value = 'No gamepad detected'
|
||||||
|
gamepadIndex.value = null
|
||||||
|
gamepadMapping.value = ''
|
||||||
|
gamepadAxes.value = [0, 0, 0, 0]
|
||||||
|
gamepadButtonPressed.value = Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollGamepadState() {
|
||||||
|
gamepadSupported.value = typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function'
|
||||||
|
if (!gamepadSupported.value) {
|
||||||
|
resetGamepadState()
|
||||||
|
refreshSendLoop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = Array.from(navigator.getGamepads()).find((entry): entry is Gamepad => entry != null)
|
||||||
|
|
||||||
|
if (pad == null) {
|
||||||
|
if (gamepadConnected.value) {
|
||||||
|
resetGamepadState()
|
||||||
|
lastGamepadSignature = ''
|
||||||
|
refreshSendLoop()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const axes = Array.from({ length: 4 }, (_, index) => roundValue(normalizeAxis(pad.axes[index] ?? 0)))
|
||||||
|
const buttons = GAMEPAD_BUTTON_LABELS.map((_, index) => Boolean(pad.buttons[index]?.pressed))
|
||||||
|
const signature = `${pad.index}:${pad.id}:${pad.mapping}:${axes.join(',')}:${buttons.map((pressed) => (pressed ? '1' : '0')).join('')}`
|
||||||
|
|
||||||
|
if (signature === lastGamepadSignature) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastGamepadSignature = signature
|
||||||
|
gamepadConnected.value = true
|
||||||
|
gamepadName.value = pad.id || 'Unnamed gamepad'
|
||||||
|
gamepadIndex.value = pad.index
|
||||||
|
gamepadMapping.value = pad.mapping || 'unknown'
|
||||||
|
gamepadAxes.value = axes
|
||||||
|
gamepadButtonPressed.value = buttons
|
||||||
|
refreshSendLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSocket() {
|
||||||
|
if (socket != null && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manualClose = false
|
||||||
|
socketState.value = 'connecting'
|
||||||
|
socket = new WebSocket(buildControlWebSocketUrl())
|
||||||
|
socket.binaryType = 'arraybuffer'
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
socketState.value = 'open'
|
||||||
|
lastServerMessage.value = 'control link live'
|
||||||
|
refreshSendLoop(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
lastServerMessage.value = event.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
socketState.value = 'closed'
|
||||||
|
stopSendLoop()
|
||||||
|
socket = null
|
||||||
|
if (manualClose) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (reconnectTimer != null) {
|
||||||
|
window.clearTimeout(reconnectTimer)
|
||||||
|
}
|
||||||
|
reconnectTimer = window.setTimeout(() => {
|
||||||
|
connectSocket()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectSocket() {
|
||||||
|
manualClose = true
|
||||||
|
stopSendLoop()
|
||||||
|
if (reconnectTimer != null) {
|
||||||
|
window.clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
|
socket?.close()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGamepadLoop() {
|
||||||
|
if (gamepadTimer != null) {
|
||||||
|
window.clearInterval(gamepadTimer)
|
||||||
|
}
|
||||||
|
pollGamepadState()
|
||||||
|
gamepadTimer = window.setInterval(() => {
|
||||||
|
pollGamepadState()
|
||||||
|
}, COMMAND_SEND_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopGamepadLoop() {
|
||||||
|
if (gamepadTimer != null) {
|
||||||
|
window.clearInterval(gamepadTimer)
|
||||||
|
gamepadTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachGlobalListeners() {
|
||||||
|
connectSocket()
|
||||||
|
startGamepadLoop()
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
window.addEventListener('keyup', handleKeyup)
|
||||||
|
window.addEventListener('blur', clearKeyboardCommands)
|
||||||
|
window.addEventListener('gamepadconnected', pollGamepadState)
|
||||||
|
window.addEventListener('gamepaddisconnected', pollGamepadState)
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachGlobalListeners() {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
window.removeEventListener('keyup', handleKeyup)
|
||||||
|
window.removeEventListener('blur', clearKeyboardCommands)
|
||||||
|
window.removeEventListener('gamepadconnected', pollGamepadState)
|
||||||
|
window.removeEventListener('gamepaddisconnected', pollGamepadState)
|
||||||
|
clearKeyboardCommands()
|
||||||
|
stopGamepadLoop()
|
||||||
|
disconnectSocket()
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountConsumer() {
|
||||||
|
consumerCount += 1
|
||||||
|
if (consumerCount === 1) {
|
||||||
|
attachGlobalListeners()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmountConsumer() {
|
||||||
|
consumerCount = Math.max(consumerCount - 1, 0)
|
||||||
|
if (consumerCount === 0) {
|
||||||
|
detachGlobalListeners()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketLabel = computed(() => {
|
||||||
|
if (socketState.value === 'open') return 'ws open'
|
||||||
|
if (socketState.value === 'connecting') return 'connecting'
|
||||||
|
return 'reconnecting'
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSourceLabel = computed(() => {
|
||||||
|
if (activeSource.value === 'keyboard') return 'Keyboard'
|
||||||
|
if (activeSource.value === 'gamepad') return 'Gamepad'
|
||||||
|
return 'Idle'
|
||||||
|
})
|
||||||
|
|
||||||
|
const commandValues = computed(() => {
|
||||||
|
const [lx, ly, lz, ax, ay, az] = resolvedCommandValues()
|
||||||
|
return { lx, ly, lz, ax, ay, az }
|
||||||
|
})
|
||||||
|
|
||||||
|
const commandLabel = computed(() => {
|
||||||
|
const { lx, ly, az } = commandValues.value
|
||||||
|
return `lx=${lx.toFixed(2)} ly=${ly.toFixed(2)} az=${az.toFixed(2)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const commandMagnitude = computed(() => {
|
||||||
|
const { lx, ly, az } = commandValues.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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pressedKeysLabel = computed(() => Array.from(pressedKeys.value).sort().join(', ') || 'none')
|
||||||
|
|
||||||
|
const keyboardKeys = computed<KeyFeedback[]>(() =>
|
||||||
|
TRACKED_KEYS.map((code) => ({
|
||||||
|
code,
|
||||||
|
label: KEY_LABELS[code] ?? code,
|
||||||
|
pressed: pressedKeys.value.has(code),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyboardTurbo = computed(() => pressedKeys.value.has('ShiftLeft') || pressedKeys.value.has('ShiftRight'))
|
||||||
|
|
||||||
|
const gamepadButtons = computed<ButtonFeedback[]>(() =>
|
||||||
|
GAMEPAD_BUTTON_LABELS.map((label, index) => ({
|
||||||
|
label,
|
||||||
|
pressed: gamepadButtonPressed.value[index] ?? false,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const gamepadLeftStick = computed(() => ({
|
||||||
|
x: gamepadAxes.value[0] ?? 0,
|
||||||
|
y: gamepadAxes.value[1] ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const gamepadRightStick = computed(() => ({
|
||||||
|
x: activeTurnAxis(),
|
||||||
|
y: gamepadAxes.value[3] ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function useControlInterface() {
|
||||||
|
onMounted(() => {
|
||||||
|
mountConsumer()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unmountConsumer()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
socketState,
|
||||||
|
socketLabel,
|
||||||
|
lastServerMessage,
|
||||||
|
activeSource,
|
||||||
|
activeSourceLabel,
|
||||||
|
commandValues,
|
||||||
|
commandLabel,
|
||||||
|
commandMagnitude,
|
||||||
|
pressedKeysLabel,
|
||||||
|
keyboardKeys,
|
||||||
|
keyboardTurbo,
|
||||||
|
keyboardActive: computed(() => keyboardActive()),
|
||||||
|
gamepadSupported,
|
||||||
|
gamepadConnected,
|
||||||
|
gamepadName,
|
||||||
|
gamepadIndex,
|
||||||
|
gamepadMapping,
|
||||||
|
gamepadButtons,
|
||||||
|
gamepadLeftStick,
|
||||||
|
gamepadRightStick,
|
||||||
|
gamepadAxes,
|
||||||
|
gamepadActive: computed(() => gamepadActiveInternal()),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user