feat: 前端增加控制反馈
This commit is contained in:
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