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 = { 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>(new Set()) const socketState = ref('connecting') const lastServerMessage = ref('waiting') const gamepadSupported = ref(false) const gamepadConnected = ref(false) const gamepadName = ref('No gamepad detected') const gamepadIndex = ref(null) const gamepadMapping = ref('') const gamepadAxes = ref([0, 0, 0, 0]) const gamepadButtonPressed = ref(Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false)) const activeSource = ref('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 { 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) } 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) { 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(() => 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(() => ({ 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(() => 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()), } }