refactor(flightlab): remove instructor flow and simplify takeoff controls

This commit is contained in:
itsrubberduck
2026-02-15 16:32:20 +01:00
parent 08808b73b7
commit 7a1f35bc90
4 changed files with 20 additions and 583 deletions

View File

@@ -99,10 +99,6 @@
Starten
</v-btn>
</NuxtLink>
<NuxtLink to="/flightlab/takeoff/instructor" class="text-center text-xs text-white/40 transition hover:text-cyan-300">
<v-icon icon="mdi-account-tie" size="14" class="mr-1" />
Instructor Panel
</NuxtLink>
</div>
</div>
</div>

View File

@@ -8,20 +8,13 @@
/>
</div>
<!-- Sidebar Toggle Button -->
<button
class="fixed left-0 top-1/2 -translate-y-1/2 z-40 flex h-12 w-6 items-center justify-center rounded-r-lg border border-l-0 border-white/10 bg-[#0b1328]/95 backdrop-blur text-white/40 hover:text-white/80 hover:bg-[#0b1328] transition-all duration-300"
:class="{ 'left-[300px]': sidebarOpen }"
@click="sidebarOpen = !sidebarOpen"
<!-- Sidebar + Attached Toggle -->
<div
class="fixed left-0 top-0 bottom-0 z-40 flex transition-transform duration-300"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-[300px]'"
>
<v-icon :icon="sidebarOpen ? 'mdi-chevron-left' : 'mdi-chevron-right'" size="16" />
</button>
<!-- Sidebar -->
<Transition name="sidebar">
<aside
v-show="sidebarOpen"
class="fixed left-0 top-0 bottom-0 z-30 w-[300px] border-r border-white/5 bg-[#070d1a]/98 backdrop-blur-xl flex flex-col pt-6"
class="w-[300px] border-r border-white/5 bg-[#070d1a]/98 backdrop-blur-xl flex flex-col pt-6"
>
<!-- Sidebar Header -->
<div class="px-4 mb-4">
@@ -148,7 +141,13 @@
</p>
</div>
</aside>
</Transition>
<button
class="flex h-12 w-6 self-center items-center justify-center rounded-r-lg border border-l-0 border-white/10 bg-[#0b1328]/95 backdrop-blur text-white/40 hover:text-white/80 hover:bg-[#0b1328] transition-colors duration-300"
@click="sidebarOpen = !sidebarOpen"
>
<v-icon :icon="sidebarOpen ? 'mdi-chevron-left' : 'mdi-chevron-right'" size="16" />
</button>
</div>
<!-- Header Bar -->
<header class="shrink-0 border-b border-white/5 bg-[#070d1a]/90 backdrop-blur px-4 py-3 mt-1 relative z-20">
@@ -164,10 +163,10 @@
</div>
</div>
<!-- Right: Auto-Advance Toggle + Session + Controls -->
<!-- Right: Auto-Advance Toggle -->
<div class="flex items-center gap-2">
<!-- Auto-Advance Toggle -->
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5" title="Wechselt automatisch erst weiter, wenn die Sim-Bedingungen erfüllt sind.">
<v-switch
:model-value="engine.autoAdvanceEnabled.value"
density="compact"
@@ -176,58 +175,8 @@
class="auto-advance-toggle"
@update:model-value="engine.toggleAutoAdvance()"
/>
<span class="text-xs text-white/40 hidden sm:inline">Sim Auto</span>
<span class="text-xs text-white/40 hidden sm:inline">Auto bei Sim-Bedingungen</span>
</div>
<!-- Session indicator -->
<div v-if="sync.isConnected.value" class="flex items-center gap-1.5 rounded-full bg-emerald-500/10 border border-emerald-400/30 px-3 py-1">
<div class="h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
<span class="text-xs text-emerald-300">{{ sync.sessionCode.value }}</span>
</div>
<!-- Session input (when not connected) -->
<div v-else class="flex items-center gap-1">
<input
v-model="joinCode"
type="text"
maxlength="4"
placeholder="CODE"
class="w-16 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-center text-xs font-mono uppercase text-white/80 placeholder:text-white/30 focus:border-cyan-400/50 focus:outline-none"
@keyup.enter="handleJoin"
/>
<v-btn
v-if="joinCode.length === 4"
size="x-small"
variant="flat"
color="primary"
class="rounded-lg"
@click="handleJoin"
>
Join
</v-btn>
</div>
<!-- Sidebar toggle (mobile) -->
<v-btn
icon
size="x-small"
variant="text"
class="text-white/30 hover:text-white/70 sm:hidden"
@click="sidebarOpen = !sidebarOpen"
>
<v-icon icon="mdi-menu" size="18" />
</v-btn>
<!-- Restart -->
<v-btn
icon
size="x-small"
variant="text"
class="text-white/30 hover:text-white/70"
@click="showRestartConfirm = true"
>
<v-icon icon="mdi-refresh" size="18" />
</v-btn>
</div>
</div>
</header>
@@ -235,14 +184,6 @@
<!-- Main Content -->
<main class="flex-1 flex flex-col justify-center px-4 py-6 sm:py-10 transition-all duration-300" :class="{ 'sm:ml-[300px]': sidebarOpen }">
<div class="mx-auto w-full max-w-screen-sm space-y-6">
<!-- Paused overlay -->
<Transition name="fade-slide">
<div v-if="engine.isPaused.value" class="rounded-2xl border border-amber-400/30 bg-amber-500/10 p-4 text-center">
<v-icon icon="mdi-pause-circle" size="24" class="text-amber-300 mb-1" />
<p class="text-sm text-amber-200">Pausiert - der Instructor setzt gleich fort</p>
</div>
</Transition>
<!-- Help Message Banner -->
<Transition name="fade-slide">
<div
@@ -321,7 +262,7 @@
<!-- More details button -->
<v-btn
v-if="currentPhase.explanation || currentPhase.simConditions || currentPhase.instructorNote"
v-if="currentPhase.explanation || currentPhase.simConditions"
size="small"
variant="text"
class="text-white/40 -mr-2"
@@ -399,11 +340,6 @@
</div>
</div>
<!-- Instructor Note -->
<div v-if="currentPhase?.instructorNote">
<p class="text-xs text-white/40 uppercase tracking-wider mb-1">Instructor-Hinweis</p>
<p class="text-sm text-white/60 leading-relaxed italic">{{ currentPhase.instructorNote }}</p>
</div>
</v-card-text>
<v-card-actions class="px-5 pb-5">
<v-spacer />
@@ -412,20 +348,6 @@
</v-card>
</v-dialog>
<!-- Restart confirmation dialog -->
<v-dialog v-model="showRestartConfirm" max-width="340">
<v-card class="rounded-2xl bg-[#0b1328] border border-white/10">
<v-card-title class="text-base font-semibold pt-5 px-5">Nochmal von vorne?</v-card-title>
<v-card-text class="text-sm text-white/60 px-5">
Der aktuelle Fortschritt geht verloren.
</v-card-text>
<v-card-actions class="px-5 pb-5">
<v-spacer />
<v-btn variant="text" class="text-white/50" @click="showRestartConfirm = false">Abbrechen</v-btn>
<v-btn color="primary" variant="flat" class="rounded-xl" @click="handleRestart">Neustart</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
@@ -434,7 +356,6 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { takeoffEddf } from '~~/shared/data/flightlab/takeoff-eddf'
import { useFlightLabEngine } from '~~/shared/composables/flightlab/useFlightLabEngine'
import { useFlightLabAudio } from '~~/shared/composables/flightlab/useFlightLabAudio'
import { useFlightLabSync } from '~~/shared/composables/flightlab/useFlightLabSync'
import type { FlightLabButton, SimCondition, FlightLabTelemetryState } from '~~/shared/data/flightlab/types'
import { useAuthStore } from '~/stores/auth'
@@ -443,20 +364,17 @@ useHead({ title: 'FlightLab - Dein erster Start' })
const engine = useFlightLabEngine(takeoffEddf)
const audio = useFlightLabAudio()
const sync = useFlightLabSync()
const authStore = useAuthStore()
const joinCode = ref('')
const showRestartConfirm = ref(false)
const showDetails = ref(false)
const sidebarOpen = ref(false)
const sidebarOpen = ref(true)
// --- Direct Bridge Telemetry Polling (solo mode, no WS session) ---
let telemetryPollInterval: ReturnType<typeof setInterval> | null = null
function startTelemetryPolling() {
stopTelemetryPolling()
if (!authStore.accessToken || sync.isConnected.value) return
if (!authStore.accessToken) return
telemetryPollInterval = setInterval(async () => {
try {
const res = await $fetch<{ telemetry: FlightLabTelemetryState | null }>('/api/flightlab/telemetry', {
@@ -478,7 +396,7 @@ function stopTelemetryPolling() {
// Start polling when phase has simConditions (solo mode only)
watch(() => engine.currentPhase.value, (phase) => {
if (phase?.simConditions && !sync.isConnected.value) {
if (phase?.simConditions) {
startTelemetryPolling()
} else {
stopTelemetryPolling()
@@ -563,9 +481,6 @@ function getPhaseStepClass(phaseId: string, idx: number): string {
function handlePhaseJump(phaseId: string) {
engine.goToPhase(phaseId)
if (sync.isConnected.value) {
sync.sendParticipantAction(engine.currentPhaseId.value, 'jump', phaseId)
}
}
// --- SimBridge Condition Helpers ---
@@ -688,33 +603,6 @@ function getButtonColor(type?: string) {
function handleButtonPress(btn: FlightLabButton) {
if (engine.isPaused.value || audio.isSpeaking.value) return
engine.selectOption(btn)
if (sync.isConnected.value) {
sync.sendParticipantAction(engine.currentPhaseId.value, btn.id, btn.next)
}
}
async function handleJoin() {
if (joinCode.value.length !== 4) return
try {
await sync.joinSession(joinCode.value)
// Switch to WS-based telemetry
stopTelemetryPolling()
if (authStore.user?.id) {
sync.subscribeTelemetry(authStore.user.id)
}
} catch (e) {
console.error('[FlightLab] Join failed:', e)
}
}
function handleRestart() {
showRestartConfirm.value = false
audio.stopAllSounds()
audio.clearReplayCache()
engine.restart()
if (sync.isConnected.value) {
sync.sendParticipantAction('restart', 'restart', 'welcome')
}
}
// --- Help message TTS callback ---
@@ -735,29 +623,6 @@ watch(() => engine.currentPhaseId.value, async (newId, oldId) => {
}
})
// Listen for telemetry from WebSocket (bridge → server → here)
sync.onTelemetry((data: any) => {
engine.updateTelemetry(data as FlightLabTelemetryState)
})
// Listen for instructor commands via WebSocket
sync.onStateChange((state) => {
if (state.currentPhaseId !== engine.currentPhaseId.value) {
engine.goToPhase(state.currentPhaseId)
}
engine.isPaused.value = state.isPaused
})
sync.onInstructorMessage(async (text, withRadioEffect) => {
if (withRadioEffect) {
await audio.speakAtcMessage(text, { speed: 0.9, readability: 5 })
}
})
sync.onError((msg) => {
console.warn('[FlightLab] WS error:', msg)
})
onMounted(async () => {
await audio.preloadSounds(allSoundIds.value)
@@ -774,7 +639,6 @@ onBeforeUnmount(() => {
stopTelemetryPolling()
engine.cleanup()
audio.dispose()
sync.disconnect()
})
</script>
@@ -784,16 +648,6 @@ onBeforeUnmount(() => {
box-shadow: 0 0 12px rgba(34, 211, 238, 0.4), 0 0 4px rgba(34, 211, 238, 0.2);
}
/* ═══════ Sidebar transition ═══════ */
.sidebar-enter-active,
.sidebar-leave-active {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-enter-from,
.sidebar-leave-to {
transform: translateX(-100%);
}
.sidebar-scroll::-webkit-scrollbar {
width: 4px;
}

View File

@@ -1,413 +0,0 @@
<template>
<div class="min-h-screen bg-[#070d1a] text-white">
<!-- Header -->
<header class="shrink-0 border-b border-white/5 bg-[#070d1a]/90 backdrop-blur px-4 py-3">
<div class="mx-auto flex max-w-screen-2xl items-center justify-between gap-4">
<div class="flex items-center gap-3">
<NuxtLink to="/flightlab" class="text-white/40 transition hover:text-white/80">
<v-icon icon="mdi-arrow-left" size="20" />
</NuxtLink>
<v-icon icon="mdi-account-tie" size="20" class="text-cyan-400/70" />
<span class="text-sm font-medium text-white/70">Instructor Panel</span>
</div>
<div class="flex items-center gap-3">
<!-- Session Code -->
<div class="rounded-xl border border-cyan-400/30 bg-cyan-500/10 px-4 py-2 text-center">
<p class="text-[10px] uppercase tracking-widest text-cyan-300/60">Session</p>
<p class="text-2xl font-bold font-mono tracking-[0.2em] text-cyan-200">{{ sync.sessionCode.value || '----' }}</p>
</div>
<!-- Connection status -->
<div class="flex items-center gap-2 rounded-full px-3 py-1.5" :class="participantConnected ? 'bg-emerald-500/10 border border-emerald-400/30' : 'bg-white/5 border border-white/10'">
<div class="h-2 w-2 rounded-full" :class="participantConnected ? 'bg-emerald-400 animate-pulse' : 'bg-white/30'" />
<span class="text-xs" :class="participantConnected ? 'text-emerald-300' : 'text-white/40'">
{{ participantConnected ? 'Teilnehmer verbunden' : 'Warte auf Teilnehmer...' }}
</span>
</div>
</div>
</div>
</header>
<div class="mx-auto max-w-screen-2xl p-4 sm:p-6">
<div class="grid gap-6 lg:grid-cols-[1fr_420px]">
<!-- Left: Participant Mirror -->
<div class="space-y-4">
<!-- Current phase info -->
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/90 p-6">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-widest text-white/40">Aktuelle Phase</span>
<span class="rounded-full bg-cyan-500/10 border border-cyan-400/30 px-2.5 py-0.5 text-xs font-mono text-cyan-300">
{{ engine.currentPhaseId.value }}
</span>
</div>
<div class="flex items-center gap-2">
<div class="h-1.5 w-32 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-cyan-400 transition-all duration-500"
:style="{ width: `${engine.progress.value}%` }"
/>
</div>
<span class="text-xs text-white/40 tabular-nums">{{ engine.progress.value }}%</span>
</div>
</div>
<!-- ATC Message -->
<p v-if="currentPhase" class="text-base leading-relaxed text-white/80">
{{ currentPhase.atcMessage }}
</p>
<!-- Explanation -->
<p v-if="currentPhase?.explanation" class="mt-3 text-sm text-white/40 border-t border-white/5 pt-3">
{{ currentPhase.explanation }}
</p>
<!-- Participant buttons (read-only mirror) -->
<div v-if="currentPhase" class="mt-4 flex flex-wrap gap-2">
<v-chip
v-for="btn in currentPhase.buttons"
:key="btn.id"
size="small"
:color="btn.type === 'primary' ? 'cyan' : btn.type === 'comfort' ? 'amber' : 'default'"
variant="outlined"
:prepend-icon="btn.icon"
>
{{ btn.label }}
</v-chip>
</div>
<!-- Instructor note -->
<div v-if="currentPhase?.instructorNote" class="mt-4 rounded-xl border border-amber-400/20 bg-amber-500/5 p-3">
<div class="flex items-start gap-2">
<v-icon icon="mdi-lightbulb-outline" size="16" class="mt-0.5 text-amber-300/70" />
<p class="text-sm text-amber-200/80">{{ currentPhase.instructorNote }}</p>
</div>
</div>
</div>
<!-- Anxiety alerts -->
<div v-if="anxietyAlerts.length > 0" class="rounded-2xl border border-red-400/20 bg-red-500/5 p-4">
<div class="mb-2 flex items-center gap-2">
<v-icon icon="mdi-alert-circle" size="18" class="text-red-400" />
<span class="text-sm font-semibold text-red-300">Angst-Signale</span>
</div>
<div class="space-y-1.5">
<div v-for="alert in anxietyAlerts" :key="alert.timestamp" class="flex items-center gap-2 text-sm text-red-200/70">
<span class="text-xs text-red-400/50 tabular-nums">{{ formatTime(alert.timestamp) }}</span>
<span>{{ alert.message }}</span>
</div>
</div>
</div>
<!-- Participant log -->
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/90 p-4">
<h3 class="mb-3 text-xs uppercase tracking-widest text-white/40">Teilnehmer-Verlauf</h3>
<div class="max-h-60 space-y-1.5 overflow-y-auto">
<div v-if="engine.history.value.length === 0" class="text-sm text-white/30 italic">Noch keine Aktionen</div>
<div
v-for="(entry, idx) in [...engine.history.value].reverse()"
:key="idx"
class="flex items-center gap-2 text-sm"
>
<span class="text-xs text-white/30 tabular-nums">{{ formatTime(entry.timestamp) }}</span>
<span class="rounded bg-white/5 px-1.5 py-0.5 font-mono text-xs text-cyan-300/70">{{ entry.phaseId }}</span>
<v-icon icon="mdi-arrow-right" size="12" class="text-white/20" />
<span class="text-white/60">{{ entry.buttonId }}</span>
</div>
</div>
</div>
</div>
<!-- Right: Instructor Controls -->
<div class="space-y-4">
<!-- Phase Control -->
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/90 p-4">
<h3 class="mb-3 text-xs uppercase tracking-widest text-white/40">Phasen-Steuerung</h3>
<div class="flex gap-2 mb-3">
<v-btn
variant="outlined"
size="small"
class="flex-1 rounded-xl border-white/10"
prepend-icon="mdi-skip-previous"
:disabled="!canSkipBack"
@click="handleSkipBack"
>
Zurück
</v-btn>
<v-btn
variant="outlined"
size="small"
class="flex-1 rounded-xl border-white/10"
append-icon="mdi-skip-next"
:disabled="!canSkipForward"
@click="handleSkipForward"
>
Weiter
</v-btn>
</div>
<!-- Phase selector -->
<v-select
v-model="selectedPhaseId"
:items="mainPhases"
item-title="label"
item-value="id"
label="Zu Phase springen"
variant="outlined"
density="compact"
hide-details
class="mb-3"
@update:model-value="handleGoToPhase"
/>
<!-- Pause/Resume -->
<v-btn
:color="engine.isPaused.value ? 'success' : 'warning'"
variant="flat"
block
class="rounded-xl"
:prepend-icon="engine.isPaused.value ? 'mdi-play' : 'mdi-pause'"
@click="handlePauseToggle"
>
{{ engine.isPaused.value ? 'Fortsetzen' : 'Pausieren' }}
</v-btn>
</div>
<!-- Custom Message -->
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/90 p-4">
<h3 class="mb-3 text-xs uppercase tracking-widest text-white/40">Nachricht an Teilnehmer</h3>
<v-textarea
v-model="customMessage"
variant="outlined"
density="compact"
rows="2"
placeholder="Nachricht eingeben..."
hide-details
class="mb-3"
/>
<div class="flex items-center gap-3">
<v-checkbox
v-model="withRadioEffect"
label="Mit Funkeffekt"
density="compact"
hide-details
class="flex-1"
/>
<v-btn
color="primary"
variant="flat"
size="small"
class="rounded-xl"
:disabled="!customMessage.trim()"
prepend-icon="mdi-send"
@click="handleSendMessage"
>
Senden
</v-btn>
</div>
</div>
<!-- Restart -->
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/90 p-4">
<v-btn
color="error"
variant="outlined"
block
class="rounded-xl"
prepend-icon="mdi-refresh"
@click="showRestartConfirm = true"
>
Szenario neustarten
</v-btn>
</div>
<!-- Elapsed time -->
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/60 p-4 text-center">
<p class="text-xs text-white/30 uppercase tracking-widest mb-1">Laufzeit</p>
<p class="text-2xl font-mono text-white/60 tabular-nums">{{ elapsedTime }}</p>
</div>
</div>
</div>
</div>
<!-- Restart dialog -->
<v-dialog v-model="showRestartConfirm" max-width="340">
<v-card class="rounded-2xl bg-[#0b1328] border border-white/10">
<v-card-title class="text-base font-semibold pt-5 px-5">Szenario neustarten?</v-card-title>
<v-card-text class="text-sm text-white/60 px-5">
Der gesamte Fortschritt wird zurückgesetzt. Der Teilnehmer startet bei Phase 1.
</v-card-text>
<v-card-actions class="px-5 pb-5">
<v-spacer />
<v-btn variant="text" class="text-white/50" @click="showRestartConfirm = false">Abbrechen</v-btn>
<v-btn color="error" variant="flat" class="rounded-xl" @click="handleRestart">Neustart</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { takeoffEddf } from '~~/shared/data/flightlab/takeoff-eddf'
import { useFlightLabEngine } from '~~/shared/composables/flightlab/useFlightLabEngine'
import { useFlightLabSync } from '~~/shared/composables/flightlab/useFlightLabSync'
definePageMeta({ layout: false, middleware: ['require-auth'] })
useHead({ title: 'FlightLab - Instructor Panel' })
// Guard: skip all logic on server
if (!import.meta.client) throw new Error('Client-only page')
const engine = useFlightLabEngine(takeoffEddf)
const sync = useFlightLabSync()
const customMessage = ref('')
const withRadioEffect = ref(true)
const showRestartConfirm = ref(false)
const selectedPhaseId = ref(engine.currentPhaseId.value)
const participantConnected = ref(false)
const elapsedTime = ref('00:00')
let elapsedTimer: ReturnType<typeof setInterval> | null = null
const currentPhase = computed(() => engine.currentPhase.value)
const mainPhaseIds = ['welcome', 'briefing', 'runway', 'engines_pre', 'engines_spool', 'takeoff_roll', 'rotation', 'gear_retract', 'climb', 'climb_high', 'leveloff', 'debrief', 'end']
const mainPhases = mainPhaseIds.map(id => ({
id,
label: getPhaseLabel(id),
}))
const canSkipBack = computed(() => {
const idx = mainPhaseIds.indexOf(engine.currentPhaseId.value)
return idx > 0
})
const canSkipForward = computed(() => {
const idx = mainPhaseIds.indexOf(engine.currentPhaseId.value)
return idx >= 0 && idx < mainPhaseIds.length - 1
})
// Track anxiety signals
const anxietyAlerts = computed(() => {
const alerts: Array<{ message: string; timestamp: number }> = []
for (const entry of engine.history.value) {
const phase = takeoffEddf.phases.find(p => p.id === entry.phaseId)
if (phase) {
const btn = phase.buttons.find(b => b.id === entry.buttonId)
if (btn?.instructorAlert) {
alerts.push({ message: btn.instructorAlert, timestamp: entry.timestamp })
}
}
}
return alerts
})
function getPhaseLabel(id: string): string {
const labels: Record<string, string> = {
welcome: 'Willkommen',
briefing: 'Briefing',
runway: 'Startbahn',
engines_pre: 'Triebwerke (Vorbereitung)',
engines_spool: 'Triebwerke (Schub)',
takeoff_roll: 'Startlauf',
rotation: 'Abheben',
gear_retract: 'Fahrwerk einfahren',
climb: 'Steigflug',
climb_high: 'Steigflug (hoch)',
leveloff: 'Level-off',
debrief: 'Nachbesprechung',
end: 'Ende',
}
return labels[id] || id
}
function formatTime(ts: number): string {
const d = new Date(ts)
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`
}
function updateElapsed() {
const diff = Math.floor((Date.now() - engine.startedAt.value) / 1000)
const m = Math.floor(diff / 60).toString().padStart(2, '0')
const s = (diff % 60).toString().padStart(2, '0')
elapsedTime.value = `${m}:${s}`
}
function handlePauseToggle() {
if (engine.isPaused.value) {
engine.resume()
sync.sendInstructorCommand('resume')
} else {
engine.pause()
sync.sendInstructorCommand('pause')
}
}
function handleSkipBack() {
engine.skipBack()
selectedPhaseId.value = engine.currentPhaseId.value
sync.sendInstructorCommand('goto', engine.currentPhaseId.value)
}
function handleSkipForward() {
engine.skipForward()
selectedPhaseId.value = engine.currentPhaseId.value
sync.sendInstructorCommand('goto', engine.currentPhaseId.value)
}
function handleGoToPhase(phaseId: string) {
engine.goToPhase(phaseId)
sync.sendInstructorCommand('goto', phaseId)
}
function handleSendMessage() {
if (!customMessage.value.trim()) return
sync.sendInstructorMessage(customMessage.value.trim(), withRadioEffect.value)
customMessage.value = ''
}
function handleRestart() {
showRestartConfirm.value = false
engine.restart()
selectedPhaseId.value = 'welcome'
sync.sendInstructorCommand('restart')
}
// Sync: listen for participant actions
sync.onStateChange((state) => {
if (state.currentPhaseId !== engine.currentPhaseId.value) {
engine.goToPhase(state.currentPhaseId)
selectedPhaseId.value = state.currentPhaseId
}
participantConnected.value = state.participantConnected
// Sync history
engine.history.value = state.history
})
sync.onPeerJoined((role) => {
if (role === 'participant') participantConnected.value = true
})
sync.onPeerLeft((role) => {
if (role === 'participant') participantConnected.value = false
})
onMounted(async () => {
// Create session
try {
await sync.createSession('takeoff-eddf')
} catch (e) {
console.error('[Instructor] WS connect failed:', e)
}
// Start elapsed timer
elapsedTimer = setInterval(updateElapsed, 1000)
})
onBeforeUnmount(() => {
if (elapsedTimer) clearInterval(elapsedTimer)
sync.disconnect()
})
</script>

View File

@@ -9,7 +9,7 @@ export function useFlightLabEngine(scenario: FlightLabScenario) {
const startedAt = ref(Date.now())
// --- Auto-Advance / SimConnect ---
const autoAdvanceEnabled = ref(false)
const autoAdvanceEnabled = ref(true)
const currentTelemetry = ref<FlightLabTelemetryState | null>(null)
const showingHelpMessage = ref(false)
const helpMessageText = ref<string | null>(null)