mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-05-15 03:25:40 +08:00
dont log users id on transmission
This commit is contained in:
@@ -25,8 +25,21 @@
|
||||
<span class="text-xs text-white/40 tabular-nums">{{ engine.progress.value }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Right: Session + Controls -->
|
||||
<!-- Right: Auto-Advance Toggle + Session + Controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Auto-Advance Toggle -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<v-switch
|
||||
:model-value="engine.autoAdvanceEnabled.value"
|
||||
density="compact"
|
||||
hide-details
|
||||
color="cyan"
|
||||
class="auto-advance-toggle"
|
||||
@update:model-value="engine.toggleAutoAdvance()"
|
||||
/>
|
||||
<span class="text-xs text-white/40 hidden sm:inline">Sim Auto</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" />
|
||||
@@ -79,6 +92,23 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- SimCondition Status Bar -->
|
||||
<div
|
||||
v-if="engine.autoAdvanceEnabled.value && engine.hasSimConditions.value"
|
||||
class="shrink-0 border-b border-white/5 bg-[#0b1328]/60 px-4 py-2"
|
||||
>
|
||||
<div class="mx-auto max-w-screen-lg flex items-center gap-2">
|
||||
<template v-if="engine.conditionsMet.value">
|
||||
<v-icon icon="mdi-check-circle" size="16" class="text-emerald-400" />
|
||||
<span class="text-xs text-emerald-300">Bedingungen erfuellt</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-progress-circular indeterminate size="14" width="2" color="cyan" />
|
||||
<span class="text-xs text-white/40">Warte auf Sim-Bedingungen...</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 flex flex-col justify-center px-4 py-6 sm:py-10">
|
||||
<div class="mx-auto w-full max-w-screen-sm space-y-6">
|
||||
@@ -88,19 +118,37 @@
|
||||
<p class="text-sm text-amber-200">Pausiert - der Instructor setzt gleich fort</p>
|
||||
</div>
|
||||
|
||||
<!-- Help Message Banner -->
|
||||
<div
|
||||
v-if="engine.showingHelpMessage.value && engine.helpMessageText.value"
|
||||
class="rounded-2xl border border-amber-400/30 bg-amber-500/10 p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<v-icon icon="mdi-lightbulb" size="22" class="text-amber-300 mt-0.5 shrink-0" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-amber-200 leading-relaxed">{{ engine.helpMessageText.value }}</p>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
color="amber"
|
||||
class="mt-2 -ml-2"
|
||||
@click="engine.dismissHelpMessage()"
|
||||
>
|
||||
Verstanden
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ATC Message Area -->
|
||||
<div
|
||||
v-if="currentPhase"
|
||||
class="rounded-3xl border border-white/10 bg-[#0b1328]/90 p-6 sm:p-8 shadow-xl shadow-cyan-500/5"
|
||||
>
|
||||
<!-- Speaking indicator -->
|
||||
<!-- Voice Animation -->
|
||||
<div v-if="audio.isSpeaking.value" class="mb-4 flex items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="h-3 w-0.5 animate-pulse rounded-full bg-cyan-400" style="animation-delay: 0ms" />
|
||||
<span class="h-4 w-0.5 animate-pulse rounded-full bg-cyan-400" style="animation-delay: 150ms" />
|
||||
<span class="h-2 w-0.5 animate-pulse rounded-full bg-cyan-400" style="animation-delay: 300ms" />
|
||||
<span class="h-5 w-0.5 animate-pulse rounded-full bg-cyan-400" style="animation-delay: 100ms" />
|
||||
<span class="h-3 w-0.5 animate-pulse rounded-full bg-cyan-400" style="animation-delay: 250ms" />
|
||||
<div class="voice-bars flex items-end gap-[3px]">
|
||||
<span v-for="i in 8" :key="i" class="voice-bar" :style="{ animationDelay: `${(i - 1) * 80}ms` }" />
|
||||
</div>
|
||||
<span class="text-xs text-cyan-300/70">Spricht...</span>
|
||||
</div>
|
||||
@@ -110,11 +158,36 @@
|
||||
{{ currentPhase.atcMessage }}
|
||||
</p>
|
||||
|
||||
<!-- Explanation -->
|
||||
<p v-if="currentPhase.explanation" class="mt-4 text-sm text-white/50 leading-relaxed border-t border-white/5 pt-4">
|
||||
<v-icon icon="mdi-information-outline" size="14" class="mr-1 text-cyan-400/50" />
|
||||
{{ currentPhase.explanation }}
|
||||
</p>
|
||||
<!-- Replay + Details row -->
|
||||
<div class="mt-4 flex items-center gap-2 border-t border-white/5 pt-4">
|
||||
<!-- Replay button -->
|
||||
<v-btn
|
||||
v-if="audio.canReplay.value"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="cyan"
|
||||
prepend-icon="mdi-replay"
|
||||
class="-ml-2"
|
||||
:disabled="audio.isSpeaking.value"
|
||||
@click="audio.replayLastMessage()"
|
||||
>
|
||||
Nochmal anhoeren
|
||||
</v-btn>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<!-- More details button -->
|
||||
<v-btn
|
||||
v-if="currentPhase.explanation || currentPhase.simConditions || currentPhase.instructorNote"
|
||||
size="small"
|
||||
variant="text"
|
||||
class="text-white/40 -mr-2"
|
||||
prepend-icon="mdi-information-outline"
|
||||
@click="showDetails = true"
|
||||
>
|
||||
Mehr Details
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End screen -->
|
||||
@@ -146,6 +219,51 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Details Dialog -->
|
||||
<v-dialog v-model="showDetails" max-width="440">
|
||||
<v-card class="rounded-2xl bg-[#0b1328] border border-white/10">
|
||||
<v-card-title class="text-base font-semibold pt-5 px-5 flex items-center gap-2">
|
||||
<v-icon icon="mdi-information-outline" size="20" class="text-cyan-400" />
|
||||
Details
|
||||
</v-card-title>
|
||||
<v-card-text class="px-5 space-y-4">
|
||||
<!-- Explanation -->
|
||||
<div v-if="currentPhase?.explanation">
|
||||
<p class="text-xs text-white/40 uppercase tracking-wider mb-1">Erklaerung</p>
|
||||
<p class="text-sm text-white/80 leading-relaxed">{{ currentPhase.explanation }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sim Conditions -->
|
||||
<div v-if="currentPhase?.simConditions">
|
||||
<p class="text-xs text-white/40 uppercase tracking-wider mb-1">Sim-Bedingungen</p>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(cond, idx) in currentPhase.simConditions.conditions"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2 text-sm text-white/70"
|
||||
>
|
||||
<v-icon icon="mdi-chevron-right" size="14" class="text-cyan-400/50" />
|
||||
<span>{{ formatCondition(cond) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/30 mt-1">
|
||||
Logik: {{ currentPhase.simConditions.logic === 'AND' ? 'Alle muessen zutreffen' : 'Mindestens eine muss zutreffen' }}
|
||||
</p>
|
||||
</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 />
|
||||
<v-btn variant="text" class="text-white/50" @click="showDetails = false">Schliessen</v-btn>
|
||||
</v-card-actions>
|
||||
</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">
|
||||
@@ -169,7 +287,7 @@ 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 } from '~~/shared/data/flightlab/types'
|
||||
import type { FlightLabButton, SimCondition } from '~~/shared/data/flightlab/types'
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
useHead({ title: 'FlightLab - Dein erster Start' })
|
||||
@@ -180,6 +298,7 @@ const sync = useFlightLabSync()
|
||||
|
||||
const joinCode = ref('')
|
||||
const showRestartConfirm = ref(false)
|
||||
const showDetails = ref(false)
|
||||
|
||||
const currentPhase = computed(() => engine.currentPhase.value)
|
||||
|
||||
@@ -194,6 +313,65 @@ const allSoundIds = computed(() => {
|
||||
return [...ids]
|
||||
})
|
||||
|
||||
// --- Condition formatting for Details dialog ---
|
||||
const conditionLabels: Record<string, string> = {
|
||||
AIRSPEED_INDICATED: 'Geschwindigkeit',
|
||||
GROUND_VELOCITY: 'Bodengeschwindigkeit',
|
||||
VERTICAL_SPEED: 'Steigrate',
|
||||
PLANE_ALTITUDE: 'Hoehe',
|
||||
PLANE_PITCH_DEGREES: 'Neigung',
|
||||
TURB_ENG_N1_1: 'Triebwerk 1 (N1)',
|
||||
TURB_ENG_N1_2: 'Triebwerk 2 (N1)',
|
||||
SIM_ON_GROUND: 'Am Boden',
|
||||
GEAR_HANDLE_POSITION: 'Fahrwerk',
|
||||
FLAPS_HANDLE_INDEX: 'Klappen-Stufe',
|
||||
BRAKE_PARKING_POSITION: 'Parkbremse',
|
||||
AUTOPILOT_MASTER: 'Autopilot',
|
||||
}
|
||||
|
||||
const conditionUnits: Record<string, string> = {
|
||||
AIRSPEED_INDICATED: 'Knoten',
|
||||
GROUND_VELOCITY: 'Knoten',
|
||||
VERTICAL_SPEED: 'ft/min',
|
||||
PLANE_ALTITUDE: 'Fuss',
|
||||
PLANE_PITCH_DEGREES: 'Grad',
|
||||
TURB_ENG_N1_1: '%',
|
||||
TURB_ENG_N1_2: '%',
|
||||
FLAPS_HANDLE_INDEX: '',
|
||||
}
|
||||
|
||||
const operatorLabels: Record<string, string> = {
|
||||
'>': 'groesser als',
|
||||
'<': 'kleiner als',
|
||||
'>=': 'mindestens',
|
||||
'<=': 'hoechstens',
|
||||
'==': 'gleich',
|
||||
'!=': 'ungleich',
|
||||
}
|
||||
|
||||
function formatCondition(cond: SimCondition): string {
|
||||
const label = conditionLabels[cond.variable] || cond.variable
|
||||
const op = operatorLabels[cond.operator] || cond.operator
|
||||
const unit = conditionUnits[cond.variable] || ''
|
||||
|
||||
if (typeof cond.value === 'boolean') {
|
||||
if (cond.variable === 'SIM_ON_GROUND') {
|
||||
return cond.value ? `${label}: Ja` : `${label}: Nein (in der Luft)`
|
||||
}
|
||||
if (cond.variable === 'GEAR_HANDLE_POSITION') {
|
||||
return cond.value ? `${label}: Ausgefahren` : `${label}: Eingefahren`
|
||||
}
|
||||
if (cond.variable === 'BRAKE_PARKING_POSITION') {
|
||||
return cond.value ? `${label}: Angezogen` : `${label}: Geloest`
|
||||
}
|
||||
return `${label}: ${cond.value ? 'An' : 'Aus'}`
|
||||
}
|
||||
|
||||
return `${label} ${op} ${cond.value}${unit ? ' ' + unit : ''}`
|
||||
}
|
||||
|
||||
// --- Button handling ---
|
||||
|
||||
function getButtonColor(type?: string) {
|
||||
switch (type) {
|
||||
case 'primary': return 'primary'
|
||||
@@ -226,12 +404,18 @@ async function handleJoin() {
|
||||
function handleRestart() {
|
||||
showRestartConfirm.value = false
|
||||
audio.stopAllSounds()
|
||||
audio.clearReplayCache()
|
||||
engine.restart()
|
||||
if (sync.isConnected.value) {
|
||||
sync.sendParticipantAction('restart', 'restart', 'welcome')
|
||||
}
|
||||
}
|
||||
|
||||
// --- Help message TTS callback ---
|
||||
engine.setOnHelpMessage(async (text: string) => {
|
||||
await audio.speakAtcMessage(text, { speed: 0.85, readability: 5 })
|
||||
})
|
||||
|
||||
// Watch phase changes to trigger TTS + sounds
|
||||
watch(() => engine.currentPhaseId.value, async (newId, oldId) => {
|
||||
if (!newId || newId === oldId) return
|
||||
@@ -282,7 +466,48 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
engine.cleanup()
|
||||
audio.dispose()
|
||||
sync.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Voice animation bars */
|
||||
.voice-bars {
|
||||
height: 24px;
|
||||
}
|
||||
.voice-bar {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to top, rgba(34, 211, 238, 0.6), rgba(34, 211, 238, 1));
|
||||
animation: voice-bounce 0.6s ease-in-out infinite alternate;
|
||||
}
|
||||
.voice-bar:nth-child(1) { height: 8px; }
|
||||
.voice-bar:nth-child(2) { height: 14px; }
|
||||
.voice-bar:nth-child(3) { height: 6px; }
|
||||
.voice-bar:nth-child(4) { height: 18px; }
|
||||
.voice-bar:nth-child(5) { height: 10px; }
|
||||
.voice-bar:nth-child(6) { height: 16px; }
|
||||
.voice-bar:nth-child(7) { height: 7px; }
|
||||
.voice-bar:nth-child(8) { height: 12px; }
|
||||
|
||||
@keyframes voice-bounce {
|
||||
0% { transform: scaleY(0.3); opacity: 0.5; }
|
||||
100% { transform: scaleY(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Auto-advance toggle compact styling */
|
||||
.auto-advance-toggle :deep(.v-switch__track) {
|
||||
height: 18px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.auto-advance-toggle :deep(.v-switch__thumb) {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
.auto-advance-toggle :deep(.v-selection-control) {
|
||||
min-height: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -126,7 +126,7 @@ export default defineEventHandler(async (event) => {
|
||||
sessionId?: string;
|
||||
}>(event);
|
||||
|
||||
const user = await requireUserSession(event);
|
||||
// const user = await requireUserSession(event);
|
||||
|
||||
const rawSessionId = typeof body?.sessionId === "string"
|
||||
? body.sessionId.trim()
|
||||
@@ -222,11 +222,9 @@ export default defineEventHandler(async (event) => {
|
||||
ttsProvider
|
||||
};
|
||||
|
||||
// await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
|
||||
|
||||
try {
|
||||
await TransmissionLog.create({
|
||||
user: user._id,
|
||||
// user: user._id,
|
||||
role: "atc",
|
||||
channel: "say",
|
||||
direction: "outgoing",
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { defineEventHandler, getRequestURL } from 'h3'
|
||||
import { requireUserSession } from '../utils/auth'
|
||||
import {defineEventHandler, getRequestURL} from 'h3'
|
||||
import {requireUserSession} from '../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const url = getRequestURL(event)
|
||||
if (!url.pathname.startsWith('/api/')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname.startsWith('/api/service/')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname.startsWith('/api/bridge/')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/decision-flows/runtime') {
|
||||
return
|
||||
}
|
||||
if (event.node.req.method === 'OPTIONS') {
|
||||
return
|
||||
}
|
||||
await requireUserSession(event)
|
||||
const url = getRequestURL(event)
|
||||
if (!url.pathname.startsWith('/api/')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname.startsWith('/api/atc/say')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname.startsWith('/api/service/')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname.startsWith('/api/bridge/')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/decision-flows/runtime') {
|
||||
return
|
||||
}
|
||||
if (event.node.req.method === 'OPTIONS') {
|
||||
return
|
||||
}
|
||||
await requireUserSession(event)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// shared/composables/flightlab/useFlightLabAudio.ts
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { FlightLabSound } from '../../data/flightlab/types'
|
||||
import { getReadabilityProfile, createNoiseGenerators } from '../../utils/radioEffects'
|
||||
import type { PizzicatoLite as PizzicatoLiteType } from '../../utils/pizzicatoLite'
|
||||
@@ -13,6 +13,10 @@ export function useFlightLabAudio() {
|
||||
let speechQueue: Promise<void> = Promise.resolve()
|
||||
let pizzicato: PizzicatoLiteType | null = null
|
||||
|
||||
// --- Replay Cache ---
|
||||
const lastSpokenAudio = ref<{ base64: string; mime: string; readability: number; text: string } | null>(null)
|
||||
const canReplay = computed(() => lastSpokenAudio.value !== null && !isSpeaking.value)
|
||||
|
||||
function getContext(): AudioContext {
|
||||
if (!audioContext.value) {
|
||||
audioContext.value = new AudioContext()
|
||||
@@ -125,6 +129,13 @@ export function useFlightLabAudio() {
|
||||
})
|
||||
|
||||
if (res.success && res.audio?.base64) {
|
||||
// Cache for replay
|
||||
lastSpokenAudio.value = {
|
||||
base64: res.audio.base64,
|
||||
mime: res.audio.mime,
|
||||
readability: options?.readability ?? 5,
|
||||
text,
|
||||
}
|
||||
await playWithRadioEffects(res.audio.base64, res.audio.mime, options?.readability ?? 5)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -179,6 +190,31 @@ export function useFlightLabAudio() {
|
||||
stopNoise.forEach((fn: () => void) => fn())
|
||||
}
|
||||
|
||||
async function replayLastMessage(): Promise<void> {
|
||||
if (!lastSpokenAudio.value || isSpeaking.value) return
|
||||
const { base64, mime, readability } = lastSpokenAudio.value
|
||||
return new Promise((resolve) => {
|
||||
speechQueue = speechQueue.then(async () => {
|
||||
isSpeaking.value = true
|
||||
try {
|
||||
await playWithRadioEffects(base64, mime, readability)
|
||||
} catch (e) {
|
||||
console.error('[FlightLabAudio] Replay error:', e)
|
||||
} finally {
|
||||
isSpeaking.value = false
|
||||
resolve()
|
||||
}
|
||||
}).catch(() => {
|
||||
isSpeaking.value = false
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function clearReplayCache() {
|
||||
lastSpokenAudio.value = null
|
||||
}
|
||||
|
||||
function stopAllSounds() {
|
||||
for (const [id] of activeSounds.value) {
|
||||
stopAmbientSound(id)
|
||||
@@ -200,8 +236,12 @@ export function useFlightLabAudio() {
|
||||
|
||||
return {
|
||||
isSpeaking,
|
||||
canReplay,
|
||||
lastSpokenAudio,
|
||||
preloadSounds,
|
||||
speakAtcMessage,
|
||||
replayLastMessage,
|
||||
clearReplayCache,
|
||||
handlePhaseSounds,
|
||||
playAmbientSound,
|
||||
stopAmbientSound,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// shared/composables/flightlab/useFlightLabEngine.ts
|
||||
import { ref, computed } from 'vue'
|
||||
import type { FlightLabPhase, FlightLabScenario, FlightLabButton } from '../../data/flightlab/types'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { FlightLabPhase, FlightLabScenario, FlightLabButton, FlightLabTelemetryState, SimConditionGroup, SimCondition } from '../../data/flightlab/types'
|
||||
|
||||
export function useFlightLabEngine(scenario: FlightLabScenario) {
|
||||
const currentPhaseId = ref(scenario.phases[0]?.id ?? '')
|
||||
@@ -8,6 +8,20 @@ export function useFlightLabEngine(scenario: FlightLabScenario) {
|
||||
const history = ref<Array<{ phaseId: string; buttonId: string; timestamp: number }>>([])
|
||||
const startedAt = ref(Date.now())
|
||||
|
||||
// --- Auto-Advance / SimConnect ---
|
||||
const autoAdvanceEnabled = ref(false)
|
||||
const currentTelemetry = ref<FlightLabTelemetryState | null>(null)
|
||||
const showingHelpMessage = ref(false)
|
||||
const helpMessageText = ref<string | null>(null)
|
||||
const conditionsMet = ref(false)
|
||||
|
||||
let conditionInterval: ReturnType<typeof setInterval> | null = null
|
||||
let helpTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let helpMessageSpoken = false
|
||||
|
||||
// Callback for TTS when help message triggers
|
||||
let onHelpMessage: ((text: string) => void) | null = null
|
||||
|
||||
const phasesMap = computed(() => {
|
||||
const map = new Map<string, FlightLabPhase>()
|
||||
for (const phase of scenario.phases) {
|
||||
@@ -38,6 +52,120 @@ export function useFlightLabEngine(scenario: FlightLabScenario) {
|
||||
|
||||
const isFinished = computed(() => currentPhaseId.value === 'end')
|
||||
|
||||
/** Whether the current phase has sim conditions that can be monitored */
|
||||
const hasSimConditions = computed(() => {
|
||||
const phase = currentPhase.value
|
||||
return !!(phase?.simConditions && phase.simConditionNextPhase)
|
||||
})
|
||||
|
||||
// --- Condition Evaluation ---
|
||||
|
||||
function evaluateCondition(condition: SimCondition, telemetry: FlightLabTelemetryState): boolean {
|
||||
const actual = telemetry[condition.variable]
|
||||
const expected = condition.value
|
||||
|
||||
switch (condition.operator) {
|
||||
case '>': return (actual as number) > (expected as number)
|
||||
case '<': return (actual as number) < (expected as number)
|
||||
case '>=': return (actual as number) >= (expected as number)
|
||||
case '<=': return (actual as number) <= (expected as number)
|
||||
case '==': return actual === expected
|
||||
case '!=': return actual !== expected
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateConditions(group: SimConditionGroup, telemetry: FlightLabTelemetryState): boolean {
|
||||
if (group.logic === 'AND') {
|
||||
return group.conditions.every(c => evaluateCondition(c, telemetry))
|
||||
} else {
|
||||
return group.conditions.some(c => evaluateCondition(c, telemetry))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Condition Monitoring ---
|
||||
|
||||
function startConditionMonitoring() {
|
||||
stopConditionMonitoring()
|
||||
|
||||
const phase = currentPhase.value
|
||||
if (!phase?.simConditions || !phase.simConditionNextPhase) return
|
||||
|
||||
conditionsMet.value = false
|
||||
showingHelpMessage.value = false
|
||||
helpMessageText.value = null
|
||||
helpMessageSpoken = false
|
||||
|
||||
// Check every 500ms
|
||||
conditionInterval = setInterval(() => {
|
||||
if (!autoAdvanceEnabled.value || isPaused.value) return
|
||||
if (!currentTelemetry.value || !phase.simConditions) return
|
||||
|
||||
const met = evaluateConditions(phase.simConditions, currentTelemetry.value)
|
||||
conditionsMet.value = met
|
||||
|
||||
if (met && phase.simConditionNextPhase) {
|
||||
stopConditionMonitoring()
|
||||
goToPhase(phase.simConditionNextPhase)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// Help timeout
|
||||
const timeoutMs = phase.simConditionTimeoutMs ?? 20000
|
||||
if (phase.simConditionHelpMessage) {
|
||||
helpTimeout = setTimeout(() => {
|
||||
if (!conditionsMet.value && autoAdvanceEnabled.value) {
|
||||
showingHelpMessage.value = true
|
||||
helpMessageText.value = phase.simConditionHelpMessage ?? null
|
||||
if (!helpMessageSpoken && helpMessageText.value && onHelpMessage) {
|
||||
helpMessageSpoken = true
|
||||
onHelpMessage(helpMessageText.value)
|
||||
}
|
||||
}
|
||||
}, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
function stopConditionMonitoring() {
|
||||
if (conditionInterval) {
|
||||
clearInterval(conditionInterval)
|
||||
conditionInterval = null
|
||||
}
|
||||
if (helpTimeout) {
|
||||
clearTimeout(helpTimeout)
|
||||
helpTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function dismissHelpMessage() {
|
||||
showingHelpMessage.value = false
|
||||
helpMessageText.value = null
|
||||
}
|
||||
|
||||
// --- Telemetry ---
|
||||
|
||||
function updateTelemetry(data: FlightLabTelemetryState) {
|
||||
currentTelemetry.value = { ...data, timestamp: Date.now() }
|
||||
}
|
||||
|
||||
function toggleAutoAdvance() {
|
||||
autoAdvanceEnabled.value = !autoAdvanceEnabled.value
|
||||
if (autoAdvanceEnabled.value && hasSimConditions.value) {
|
||||
startConditionMonitoring()
|
||||
} else {
|
||||
stopConditionMonitoring()
|
||||
conditionsMet.value = false
|
||||
showingHelpMessage.value = false
|
||||
helpMessageText.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function setOnHelpMessage(fn: (text: string) => void) {
|
||||
onHelpMessage = fn
|
||||
}
|
||||
|
||||
// --- Phase navigation ---
|
||||
|
||||
function selectOption(button: FlightLabButton) {
|
||||
if (isPaused.value) return
|
||||
history.value.push({
|
||||
@@ -51,15 +179,31 @@ export function useFlightLabEngine(scenario: FlightLabScenario) {
|
||||
|
||||
function goToPhase(phaseId: string) {
|
||||
if (phasesMap.value.has(phaseId)) {
|
||||
stopConditionMonitoring()
|
||||
conditionsMet.value = false
|
||||
showingHelpMessage.value = false
|
||||
helpMessageText.value = null
|
||||
currentPhaseId.value = phaseId
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for phase changes to start condition monitoring
|
||||
watch(currentPhaseId, () => {
|
||||
if (autoAdvanceEnabled.value && hasSimConditions.value) {
|
||||
startConditionMonitoring()
|
||||
}
|
||||
})
|
||||
|
||||
function restart() {
|
||||
stopConditionMonitoring()
|
||||
currentPhaseId.value = scenario.phases[0]?.id ?? ''
|
||||
isPaused.value = false
|
||||
history.value = []
|
||||
startedAt.value = Date.now()
|
||||
conditionsMet.value = false
|
||||
showingHelpMessage.value = false
|
||||
helpMessageText.value = null
|
||||
currentTelemetry.value = null
|
||||
}
|
||||
|
||||
function pause() { isPaused.value = true }
|
||||
@@ -81,6 +225,10 @@ export function useFlightLabEngine(scenario: FlightLabScenario) {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
stopConditionMonitoring()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentPhaseId,
|
||||
@@ -91,6 +239,13 @@ export function useFlightLabEngine(scenario: FlightLabScenario) {
|
||||
isFinished,
|
||||
startedAt,
|
||||
scenario,
|
||||
// Auto-Advance State
|
||||
autoAdvanceEnabled,
|
||||
currentTelemetry,
|
||||
showingHelpMessage,
|
||||
helpMessageText,
|
||||
conditionsMet,
|
||||
hasSimConditions,
|
||||
// Actions
|
||||
selectOption,
|
||||
goToPhase,
|
||||
@@ -99,5 +254,12 @@ export function useFlightLabEngine(scenario: FlightLabScenario) {
|
||||
resume,
|
||||
skipForward,
|
||||
skipBack,
|
||||
// Auto-Advance Actions
|
||||
updateTelemetry,
|
||||
toggleAutoAdvance,
|
||||
dismissHelpMessage,
|
||||
evaluateConditions,
|
||||
setOnHelpMessage,
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,17 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
{ id: 'engine-spool', action: 'play', volume: 0.5, loop: false },
|
||||
{ id: 'engine-cruise', action: 'play', volume: 0.4, loop: true },
|
||||
],
|
||||
simConditions: {
|
||||
conditions: [
|
||||
{ variable: 'TURB_ENG_N1_1', operator: '>=', value: 85 },
|
||||
{ variable: 'TURB_ENG_N1_2', operator: '>=', value: 85 },
|
||||
{ variable: 'BRAKE_PARKING_POSITION', operator: '==', value: false },
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
simConditionTimeoutMs: 20000,
|
||||
simConditionHelpMessage: 'Schub-Hebel nach vorne schieben bis N1 bei etwa 85 Prozent. Dann die Parkbremse loesen.',
|
||||
simConditionNextPhase: 'takeoff_roll',
|
||||
},
|
||||
{
|
||||
id: 'engines_loud_comfort',
|
||||
@@ -184,6 +195,16 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
{ id: 'runway-rumble', action: 'play', volume: 0.5, loop: true },
|
||||
{ id: 'wind-low', action: 'play', volume: 0.3, loop: true },
|
||||
],
|
||||
simConditions: {
|
||||
conditions: [
|
||||
{ variable: 'AIRSPEED_INDICATED', operator: '>=', value: 140 },
|
||||
{ variable: 'SIM_ON_GROUND', operator: '==', value: true },
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
simConditionTimeoutMs: 45000,
|
||||
simConditionHelpMessage: 'Warte bis die Geschwindigkeit 140 Knoten erreicht. Schau auf den Speed-Tape links am Display.',
|
||||
simConditionNextPhase: 'rotation',
|
||||
},
|
||||
{
|
||||
id: 'roll_rumble_explain',
|
||||
@@ -228,6 +249,16 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
{ id: 'wind-low', action: 'crossfade', volume: 0.0 },
|
||||
{ id: 'wind-high', action: 'play', volume: 0.35, loop: true },
|
||||
],
|
||||
simConditions: {
|
||||
conditions: [
|
||||
{ variable: 'SIM_ON_GROUND', operator: '==', value: false },
|
||||
{ variable: 'PLANE_PITCH_DEGREES', operator: '>', value: 5 },
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
simConditionTimeoutMs: 15000,
|
||||
simConditionHelpMessage: 'Sidestick sanft nach hinten ziehen bis die Nase hochgeht.',
|
||||
simConditionNextPhase: 'gear_retract',
|
||||
},
|
||||
{
|
||||
id: 'rotation_belly_explain',
|
||||
@@ -252,6 +283,15 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
sounds: [
|
||||
{ id: 'gear-retract', action: 'play', volume: 0.6, loop: false },
|
||||
],
|
||||
simConditions: {
|
||||
conditions: [
|
||||
{ variable: 'GEAR_HANDLE_POSITION', operator: '==', value: false },
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
simConditionTimeoutMs: 10000,
|
||||
simConditionHelpMessage: 'Fahrwerk-Hebel nach oben schieben. Der Hebel ist links neben dem Mitteldisplay.',
|
||||
simConditionNextPhase: 'climb',
|
||||
},
|
||||
{
|
||||
id: 'gear_explain',
|
||||
@@ -277,6 +317,16 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
{ id: 'engine-cruise', action: 'crossfade', volume: 0.3 },
|
||||
{ id: 'wind-high', action: 'crossfade', volume: 0.25 },
|
||||
],
|
||||
simConditions: {
|
||||
conditions: [
|
||||
{ variable: 'PLANE_ALTITUDE', operator: '>', value: 2000 },
|
||||
{ variable: 'VERTICAL_SPEED', operator: '>', value: 1000 },
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
simConditionTimeoutMs: 30000,
|
||||
simConditionHelpMessage: 'Steigrate erhoehen auf etwa 2000 Fuss pro Minute. Sidestick leicht nach hinten halten.',
|
||||
simConditionNextPhase: 'climb_high',
|
||||
},
|
||||
{
|
||||
id: 'climb_height_info',
|
||||
@@ -313,6 +363,15 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
{ id: 'almost_there', label: 'Fast geschafft!', icon: 'mdi-flag-checkered', next: 'leveloff', type: 'primary' },
|
||||
],
|
||||
sounds: [],
|
||||
simConditions: {
|
||||
conditions: [
|
||||
{ variable: 'PLANE_ALTITUDE', operator: '>=', value: 9800 },
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
simConditionTimeoutMs: 60000,
|
||||
simConditionHelpMessage: 'Weiter steigen bis 10.000 Fuss. Halte die Nase leicht oben.',
|
||||
simConditionNextPhase: 'leveloff',
|
||||
},
|
||||
|
||||
// --- Phase 8: Level-off & Debrief ---
|
||||
@@ -332,6 +391,17 @@ export const takeoffEddf: FlightLabScenario = {
|
||||
{ id: 'wind-high', action: 'crossfade', volume: 0.15 },
|
||||
{ id: 'chime', action: 'play', volume: 0.3, loop: false },
|
||||
],
|
||||
simConditions: {
|
||||
conditions: [
|
||||
{ variable: 'VERTICAL_SPEED', operator: '<', value: 500 },
|
||||
{ variable: 'VERTICAL_SPEED', operator: '>', value: -500 },
|
||||
{ variable: 'PLANE_ALTITUDE', operator: '>=', value: 9500 },
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
simConditionTimeoutMs: 20000,
|
||||
simConditionHelpMessage: 'Nase etwas senken zum Geradeausflug. Steigrate auf nahe Null bringen.',
|
||||
simConditionNextPhase: 'debrief',
|
||||
},
|
||||
{
|
||||
id: 'debrief',
|
||||
|
||||
@@ -16,6 +16,40 @@ export interface FlightLabButton {
|
||||
instructorAlert?: string
|
||||
}
|
||||
|
||||
// --- MSFS SimConnect Telemetry ---
|
||||
|
||||
/** MSFS2020 SimConnect variable state — keys match SimConnect naming */
|
||||
export interface FlightLabTelemetryState {
|
||||
AIRSPEED_INDICATED: number // knots
|
||||
GROUND_VELOCITY: number // knots
|
||||
VERTICAL_SPEED: number // feet per minute
|
||||
PLANE_ALTITUDE: number // feet MSL
|
||||
PLANE_PITCH_DEGREES: number // degrees
|
||||
TURB_ENG_N1_1: number // percent (0-100), engine 1
|
||||
TURB_ENG_N1_2: number // percent (0-100), engine 2
|
||||
SIM_ON_GROUND: boolean
|
||||
GEAR_HANDLE_POSITION: boolean // true = down, false = up
|
||||
FLAPS_HANDLE_INDEX: number // 0-4 for A320
|
||||
BRAKE_PARKING_POSITION: boolean // true = set, false = released
|
||||
AUTOPILOT_MASTER: boolean
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export type SimConditionOperator = '>' | '<' | '>=' | '<=' | '==' | '!='
|
||||
|
||||
export interface SimCondition {
|
||||
variable: keyof FlightLabTelemetryState
|
||||
operator: SimConditionOperator
|
||||
value: number | boolean
|
||||
}
|
||||
|
||||
export interface SimConditionGroup {
|
||||
conditions: SimCondition[]
|
||||
logic: 'AND' | 'OR'
|
||||
}
|
||||
|
||||
// --- Phase ---
|
||||
|
||||
export interface FlightLabPhase {
|
||||
id: string
|
||||
atcMessage: string
|
||||
@@ -24,8 +58,18 @@ export interface FlightLabPhase {
|
||||
sounds?: FlightLabSound[]
|
||||
instructorNote?: string
|
||||
autoAdvanceAfterTTS?: boolean
|
||||
/** SimConnect conditions for auto-advance (when sim data available) */
|
||||
simConditions?: SimConditionGroup
|
||||
/** How long to wait (ms) before showing help if conditions not met. Default 20000 */
|
||||
simConditionTimeoutMs?: number
|
||||
/** Help message spoken via TTS when timeout reached */
|
||||
simConditionHelpMessage?: string
|
||||
/** Phase to advance to when conditions are met */
|
||||
simConditionNextPhase?: string
|
||||
}
|
||||
|
||||
// --- Scenario ---
|
||||
|
||||
export interface FlightLabScenario {
|
||||
id: string
|
||||
title: string
|
||||
@@ -38,6 +82,8 @@ export interface FlightLabScenario {
|
||||
phases: FlightLabPhase[]
|
||||
}
|
||||
|
||||
// --- Session / WebSocket ---
|
||||
|
||||
export type FlightLabRole = 'instructor' | 'participant'
|
||||
|
||||
export interface FlightLabSessionState {
|
||||
|
||||
Reference in New Issue
Block a user