mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-05-13 01:46:08 +08:00
feat(stick-input): add touchscreen stick-input page and WS session to learn-pfd
- learn-pfd.vue now creates a WebSocket session on mount and displays the 4-char session code in the header bar for touchscreen connection - New stick-input.vue page at /flightlab/medienstationen/stick-input with touch-based sidestick (spring-loaded 2D pad) and throttle (vertical slider). Joins session by code, sends input at 30Hz. - Stick input page added to medienstationen index as second card. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stick Input Card -->
|
||||
<div class="group relative overflow-hidden rounded-3xl border border-white/10 bg-[#0b1328]/90 shadow-xl shadow-cyan-500/5 transition hover:border-amber-400/30 hover:shadow-amber-500/15">
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-amber-500/5 via-transparent to-transparent opacity-0 transition group-hover:opacity-100" />
|
||||
<div class="relative z-10 p-6">
|
||||
<div class="mb-5 flex items-start justify-between">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-2xl border border-amber-400/40 bg-amber-500/10">
|
||||
<v-icon icon="mdi-gamepad-variant" size="30" class="text-amber-300" />
|
||||
</div>
|
||||
<span class="rounded-full border border-cyan-400/40 bg-cyan-500/10 px-3 py-1 text-xs font-medium text-cyan-300">
|
||||
Input
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold">Stick Input</h3>
|
||||
<p class="mb-4 text-sm text-white/60 leading-relaxed">
|
||||
Touchscreen-Controller für Sidestick und Schubhebel.
|
||||
Öffne diese Seite auf einem zweiten Gerät.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<NuxtLink to="/flightlab/medienstationen/stick-input">
|
||||
<v-btn
|
||||
color="amber"
|
||||
variant="flat"
|
||||
size="large"
|
||||
block
|
||||
class="rounded-xl font-semibold"
|
||||
prepend-icon="mdi-gamepad-variant"
|
||||
>
|
||||
Öffnen
|
||||
</v-btn>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coming Soon Card -->
|
||||
<div class="flex items-center justify-center rounded-3xl border border-dashed border-white/10 bg-[#0b1328]/40 p-6">
|
||||
<div class="text-center">
|
||||
|
||||
@@ -127,7 +127,25 @@
|
||||
<span class="text-sm font-medium text-white/70">{{ engine.scenario.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Session Code for touchscreen connection -->
|
||||
<div v-if="sync.sessionCode.value" class="flex items-center gap-2 rounded-lg border border-cyan-400/20 bg-cyan-500/10 px-3 py-1.5">
|
||||
<v-icon icon="mdi-gamepad-variant" size="16" class="text-cyan-400/70" />
|
||||
<span class="text-xs text-white/50">Input:</span>
|
||||
<code class="text-sm font-mono font-bold text-cyan-300 tracking-widest">{{ sync.sessionCode.value }}</code>
|
||||
<v-icon
|
||||
v-if="sync.isConnected.value"
|
||||
icon="mdi-wifi"
|
||||
size="14"
|
||||
class="text-emerald-400"
|
||||
title="Verbunden"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="wsConnected" class="flex items-center gap-1.5 text-xs text-white/30">
|
||||
<v-icon icon="mdi-loading" size="14" class="animate-spin text-cyan-400/50" />
|
||||
Session wird erstellt...
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
v-if="canFullscreen"
|
||||
size="small"
|
||||
@@ -371,6 +389,7 @@ const sidebarOpen = ref(true)
|
||||
const pageRoot = ref<HTMLElement | null>(null)
|
||||
const isFullscreen = ref(false)
|
||||
const canFullscreen = ref(false)
|
||||
const wsConnected = ref(false)
|
||||
const fullscreenEvents = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
|
||||
|
||||
let initialSpeechTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -532,7 +551,7 @@ watch(() => engine.currentPhaseId.value, async (newId, oldId) => {
|
||||
})
|
||||
|
||||
// --- Lifecycle ---
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// Start FBW physics
|
||||
fbw.start()
|
||||
|
||||
@@ -547,6 +566,14 @@ onMounted(() => {
|
||||
window.addEventListener('keydown', onGlobalKeydown)
|
||||
}
|
||||
|
||||
// Create WebSocket session for stick input
|
||||
try {
|
||||
await sync.createSession('learn-pfd')
|
||||
wsConnected.value = true
|
||||
} catch (e) {
|
||||
console.warn('[learn-pfd] WS session creation failed:', e)
|
||||
}
|
||||
|
||||
// Speak initial welcome
|
||||
const phase = engine.currentPhase.value
|
||||
if (phase?.atcMessage) {
|
||||
@@ -570,6 +597,7 @@ onBeforeUnmount(() => {
|
||||
clearTimeout(initialSpeechTimeout)
|
||||
initialSpeechTimeout = null
|
||||
}
|
||||
sync.disconnect()
|
||||
fbw.cleanup()
|
||||
engine.cleanup()
|
||||
audio.dispose()
|
||||
|
||||
278
app/pages/flightlab/medienstationen/stick-input.vue
Normal file
278
app/pages/flightlab/medienstationen/stick-input.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="h-screen bg-[#070d1a] text-white flex flex-col overflow-hidden select-none touch-none">
|
||||
<!-- Header -->
|
||||
<header class="shrink-0 border-b border-white/5 bg-[#070d1a]/90 backdrop-blur px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<v-icon icon="mdi-gamepad-variant" size="20" class="text-cyan-400/70" />
|
||||
<span class="text-sm font-medium text-white/70">Stick Input</span>
|
||||
</div>
|
||||
|
||||
<!-- Connection status -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-2.5 w-2.5 rounded-full"
|
||||
:class="isConnected ? 'bg-emerald-400 animate-pulse' : 'bg-red-400/50'"
|
||||
/>
|
||||
<span class="text-xs" :class="isConnected ? 'text-emerald-300' : 'text-white/40'">
|
||||
{{ isConnected ? 'Verbunden' : 'Nicht verbunden' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Connection screen -->
|
||||
<div v-if="!isConnected" class="flex-1 flex items-center justify-center p-6">
|
||||
<div class="w-full max-w-sm space-y-6 text-center">
|
||||
<div class="h-20 w-20 rounded-full border border-cyan-400/30 bg-cyan-500/10 flex items-center justify-center mx-auto">
|
||||
<v-icon icon="mdi-wifi" size="36" class="text-cyan-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-2">Verbindung herstellen</h2>
|
||||
<p class="text-sm text-white/50">
|
||||
Gib den 4-stelligen Code ein, der auf dem PFD-Bildschirm angezeigt wird.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<v-text-field
|
||||
v-model="sessionCodeInput"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
placeholder="CODE"
|
||||
maxlength="4"
|
||||
class="session-code-input"
|
||||
:error-messages="connectionError"
|
||||
hide-details="auto"
|
||||
@keydown.enter="connectToSession"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="large"
|
||||
block
|
||||
class="rounded-xl font-semibold"
|
||||
:loading="isConnecting"
|
||||
:disabled="sessionCodeInput.length < 4"
|
||||
@click="connectToSession"
|
||||
>
|
||||
Verbinden
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stick + Throttle controls -->
|
||||
<div v-else class="flex-1 flex gap-2 p-3">
|
||||
<!-- Throttle (left side, vertical slider) -->
|
||||
<div class="w-20 flex flex-col items-center gap-2">
|
||||
<span class="text-[10px] uppercase tracking-widest text-white/30">Thrust</span>
|
||||
<div
|
||||
ref="throttleTrack"
|
||||
class="flex-1 w-16 rounded-2xl border border-white/10 bg-[#0b1328]/90 relative overflow-hidden cursor-pointer"
|
||||
@pointerdown="onThrottlePointerDown"
|
||||
@pointermove="onThrottlePointerMove"
|
||||
@pointerup="onThrottlePointerUp"
|
||||
@pointercancel="onThrottlePointerUp"
|
||||
>
|
||||
<!-- Fill -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-amber-500/80 to-amber-400/40 transition-[height] duration-75"
|
||||
:style="{ height: `${throttle * 100}%` }"
|
||||
/>
|
||||
<!-- Handle -->
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 w-12 h-3 rounded-full bg-white/80 border border-white/30 shadow-lg"
|
||||
:style="{ bottom: `calc(${throttle * 100}% - 6px)` }"
|
||||
/>
|
||||
<!-- Label -->
|
||||
<div class="absolute bottom-2 left-0 right-0 text-center">
|
||||
<span class="text-xs font-mono font-bold text-white/70">{{ Math.round(throttle * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidestick (center, 2D pad) -->
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<span class="text-[10px] uppercase tracking-widest text-white/30">Sidestick</span>
|
||||
<div
|
||||
ref="stickPad"
|
||||
class="flex-1 w-full rounded-2xl border border-white/10 bg-[#0b1328]/90 relative overflow-hidden cursor-pointer"
|
||||
@pointerdown="onStickPointerDown"
|
||||
@pointermove="onStickPointerMove"
|
||||
@pointerup="onStickPointerUp"
|
||||
@pointercancel="onStickPointerUp"
|
||||
>
|
||||
<!-- Crosshair -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute left-1/2 top-0 bottom-0 w-px bg-white/5" />
|
||||
<div class="absolute top-1/2 left-0 right-0 h-px bg-white/5" />
|
||||
</div>
|
||||
|
||||
<!-- Axis labels -->
|
||||
<div class="absolute top-2 left-1/2 -translate-x-1/2 text-[10px] text-white/20">PUSH (Nose Down)</div>
|
||||
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 text-[10px] text-white/20">PULL (Nose Up)</div>
|
||||
<div class="absolute left-2 top-1/2 -translate-y-1/2 text-[10px] text-white/20 -rotate-90 origin-center">LEFT</div>
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-white/20 rotate-90 origin-center">RIGHT</div>
|
||||
|
||||
<!-- Dead zone circle -->
|
||||
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full border border-white/5" />
|
||||
|
||||
<!-- Stick position indicator -->
|
||||
<div
|
||||
class="absolute w-14 h-14 rounded-full border-2 transition-transform duration-75"
|
||||
:class="stickActive ? 'border-cyan-400 bg-cyan-500/20 shadow-lg shadow-cyan-500/20' : 'border-white/20 bg-white/5'"
|
||||
:style="{
|
||||
left: `calc(${(stickX + 1) / 2 * 100}% - 28px)`,
|
||||
top: `calc(${(stickY + 1) / 2 * 100}% - 28px)`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 m-auto w-4 h-4 rounded-full"
|
||||
:class="stickActive ? 'bg-cyan-400' : 'bg-white/30'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current values -->
|
||||
<div class="flex gap-4 text-xs font-mono text-white/40">
|
||||
<span>Pitch: {{ stickY > 0 ? '+' : '' }}{{ stickY.toFixed(2) }}</span>
|
||||
<span>Roll: {{ stickX > 0 ? '+' : '' }}{{ stickX.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { useFlightLabSync } from '~~/shared/composables/flightlab/useFlightLabSync'
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
useHead({ title: 'FlightLab - Stick Input' })
|
||||
|
||||
const sync = useFlightLabSync()
|
||||
|
||||
// --- Connection state ---
|
||||
const sessionCodeInput = ref('')
|
||||
const isConnecting = ref(false)
|
||||
const isConnected = ref(false)
|
||||
const connectionError = ref('')
|
||||
|
||||
// --- Stick state ---
|
||||
const stickX = ref(0) // -1 (left) to +1 (right) = roll
|
||||
const stickY = ref(0) // -1 (forward/push/nose down) to +1 (back/pull/nose up)
|
||||
const throttle = ref(0) // 0 (idle) to 1 (TOGA)
|
||||
const stickActive = ref(false)
|
||||
const throttleActive = ref(false)
|
||||
|
||||
// --- Refs ---
|
||||
const stickPad = ref<HTMLElement | null>(null)
|
||||
const throttleTrack = ref<HTMLElement | null>(null)
|
||||
|
||||
// --- Send interval ---
|
||||
let sendInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function connectToSession() {
|
||||
if (sessionCodeInput.value.length < 4) return
|
||||
isConnecting.value = true
|
||||
connectionError.value = ''
|
||||
|
||||
try {
|
||||
await sync.joinSession(sessionCodeInput.value, 'participant')
|
||||
isConnected.value = true
|
||||
|
||||
// Start sending stick input at 30Hz
|
||||
sendInterval = setInterval(() => {
|
||||
sync.sendStickInput({
|
||||
pitch: stickY.value,
|
||||
roll: stickX.value,
|
||||
throttle: throttle.value,
|
||||
})
|
||||
}, 33)
|
||||
} catch (e) {
|
||||
connectionError.value = 'Verbindung fehlgeschlagen. Code korrekt?'
|
||||
console.error('[stick-input] Connection failed:', e)
|
||||
} finally {
|
||||
isConnecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stick touch handling ---
|
||||
function onStickPointerDown(e: PointerEvent) {
|
||||
stickActive.value = true
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateStickPosition(e)
|
||||
}
|
||||
|
||||
function onStickPointerMove(e: PointerEvent) {
|
||||
if (!stickActive.value) return
|
||||
updateStickPosition(e)
|
||||
}
|
||||
|
||||
function onStickPointerUp(_e: PointerEvent) {
|
||||
stickActive.value = false
|
||||
// Return to center (spring-loaded like real sidestick)
|
||||
stickX.value = 0
|
||||
stickY.value = 0
|
||||
}
|
||||
|
||||
function updateStickPosition(e: PointerEvent) {
|
||||
const pad = stickPad.value
|
||||
if (!pad) return
|
||||
const rect = pad.getBoundingClientRect()
|
||||
// Normalize to -1..+1
|
||||
const rawX = ((e.clientX - rect.left) / rect.width) * 2 - 1
|
||||
const rawY = ((e.clientY - rect.top) / rect.height) * 2 - 1
|
||||
// Clamp
|
||||
stickX.value = Math.max(-1, Math.min(1, rawX))
|
||||
// Y is inverted: top of pad = forward (nose down = negative pitch input)
|
||||
// bottom of pad = pull back (nose up = positive pitch input)
|
||||
stickY.value = Math.max(-1, Math.min(1, -rawY))
|
||||
}
|
||||
|
||||
// --- Throttle touch handling ---
|
||||
function onThrottlePointerDown(e: PointerEvent) {
|
||||
throttleActive.value = true
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateThrottlePosition(e)
|
||||
}
|
||||
|
||||
function onThrottlePointerMove(e: PointerEvent) {
|
||||
if (!throttleActive.value) return
|
||||
updateThrottlePosition(e)
|
||||
}
|
||||
|
||||
function onThrottlePointerUp(_e: PointerEvent) {
|
||||
throttleActive.value = false
|
||||
// Throttle stays where you leave it (not spring-loaded)
|
||||
}
|
||||
|
||||
function updateThrottlePosition(e: PointerEvent) {
|
||||
const track = throttleTrack.value
|
||||
if (!track) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
// Bottom = 0, top = 1
|
||||
const rawThrottle = 1 - (e.clientY - rect.top) / rect.height
|
||||
throttle.value = Math.max(0, Math.min(1, rawThrottle))
|
||||
}
|
||||
|
||||
// --- Cleanup ---
|
||||
onBeforeUnmount(() => {
|
||||
if (sendInterval) {
|
||||
clearInterval(sendInterval)
|
||||
sendInterval = null
|
||||
}
|
||||
sync.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.session-code-input :deep(input) {
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user