Files
robot-command-center/frontend/src/composables/useControlInterface.ts

632 lines
16 KiB
TypeScript

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
}
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<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 COMMAND_SEND_INTERVAL_MS = 50
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<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')
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>): 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<ControlTuning>)
} 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
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 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<ControlTuning>) {
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)
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') ? turboMultiplier.value : 1
let lx = 0
let ly = 0
let az = 0
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
}
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] ? turboMultiplier.value : 1
if (buttons[0]) {
return ZERO_COMMAND
}
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]
}
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
const limits = controlLimits.value
return Math.min(
1,
Math.max(
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),
),
)
})
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 controlTuning = computed<ControlTuning>(() => ({
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<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,
controlTuning,
controlLimits,
setControlTuning,
resetControlTuning,
pressedKeysLabel,
keyboardKeys,
keyboardTurbo,
keyboardActive: computed(() => keyboardActive()),
gamepadSupported,
gamepadConnected,
gamepadName,
gamepadIndex,
gamepadMapping,
gamepadButtons,
gamepadLeftStick,
gamepadRightStick,
gamepadAxes,
gamepadActive: computed(() => gamepadActiveInternal()),
}
}