632 lines
16 KiB
TypeScript
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()),
|
|
}
|
|
}
|