From ef8f1a1991d8f75c52539f62913a46c167923f80 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Fri, 20 Feb 2026 23:01:16 +0100 Subject: [PATCH] feat(engine): add learn-pfd engine composable with goal evaluation Composable manages phase navigation, interaction goal monitoring with hold-time validation, progress tracking, and hint system for the learn-pfd medienstation. Co-Authored-By: Claude Opus 4.6 --- .../flightlab/useLearnPfdEngine.ts | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 shared/composables/flightlab/useLearnPfdEngine.ts diff --git a/shared/composables/flightlab/useLearnPfdEngine.ts b/shared/composables/flightlab/useLearnPfdEngine.ts new file mode 100644 index 0000000..fd73967 --- /dev/null +++ b/shared/composables/flightlab/useLearnPfdEngine.ts @@ -0,0 +1,287 @@ +// shared/composables/flightlab/useLearnPfdEngine.ts +import { ref, computed, watch } from 'vue' +import type { LearnPfdPhase, LearnPfdScenario, FlightLabButton, PfdInteractionGoal, PfdElement, PfdLayoutMode } from '../../data/flightlab/types' +import type { FlightState } from './useAirbusFBW' + +const mainPhaseIds = ['welcome', 'horizon_intro', 'pitch_intro', 'speed_intro', 'alt_intro', 'vs_intro', 'heading_intro', 'combined', 'free_practice', 'end'] + +const goalNextPhase: Record = { + 'horizon_intro': 'horizon_roll_right', + 'horizon_roll_right': 'pitch_intro', + 'pitch_intro': 'pitch_down', + 'pitch_down': 'speed_intro', + 'speed_intro': 'alt_intro', + 'alt_intro': 'vs_intro', + 'vs_intro': 'heading_intro', + 'heading_intro': 'combined', + 'combined': 'free_practice', +} + +function evaluateGoal(goal: PfdInteractionGoal, state: FlightState): boolean { + const actual = state[goal.parameter] + if (typeof actual !== 'number') return false + return Math.abs(actual - goal.target) <= goal.tolerance +} + +function computeGoalProgress(goal: PfdInteractionGoal, state: FlightState): number { + const actual = state[goal.parameter] + if (typeof actual !== 'number') return 0 + const distance = Math.abs(actual - goal.target) + if (distance <= goal.tolerance) return 1 + // Scale progress: at 10x tolerance distance we're at 0, at tolerance we're at 1 + const maxDistance = goal.tolerance * 10 + return Math.max(0, 1 - (distance - goal.tolerance) / maxDistance) +} + +export function useLearnPfdEngine(scenario: LearnPfdScenario, flightState: FlightState) { + const currentPhaseId = ref(scenario.phases[0]?.id ?? '') + const isPaused = ref(false) + const history = ref>([]) + const startedAt = ref(Date.now()) + + // --- Goal evaluation state --- + const goalMet = ref(false) + const goalProgress = ref(0) + const holdStartTime = ref(null) + const showingGoalHint = ref(false) + const goalHintText = ref(null) + + let goalInterval: ReturnType | null = null + let goalHintTimeout: ReturnType | null = null + let goalHintSpoken = false + + // Callbacks + let onGoalHintCallback: ((text: string) => void) | null = null + let isAutoAdvanceBlocked: (() => boolean) | null = null + + // --- Maps & computed --- + + const phasesMap = computed(() => { + const map = new Map() + for (const phase of scenario.phases) { + map.set(phase.id, phase) + } + return map + }) + + const currentPhase = computed(() => phasesMap.value.get(currentPhaseId.value) ?? null) + + const isFinished = computed(() => currentPhaseId.value === 'end') + + const progress = computed(() => { + const idx = mainPhaseIds.indexOf(currentPhaseId.value) + if (idx === -1) { + // We're in a sub-phase, find the closest main phase from history + for (let i = history.value.length - 1; i >= 0; i--) { + const entry = history.value[i] + if (!entry) continue + const mainIdx = mainPhaseIds.indexOf(entry.phaseId) + if (mainIdx !== -1) return Math.round((mainIdx / (mainPhaseIds.length - 1)) * 100) + } + return 0 + } + return Math.round((idx / (mainPhaseIds.length - 1)) * 100) + }) + + // --- Convenience getters --- + + const visibleElements = computed(() => { + return currentPhase.value?.visibleElements ?? [] + }) + + const layoutMode = computed(() => { + return currentPhase.value?.layoutMode ?? 'model-focus' + }) + + // --- Goal Monitoring --- + + function startGoalMonitoring() { + stopGoalMonitoring() + + const phase = currentPhase.value + if (!phase?.interactionGoal) return + + const goal = phase.interactionGoal + const holdMs = goal.holdMs ?? 2000 + const timeoutMs = phase.goalTimeoutMs ?? 15000 + + goalMet.value = false + goalProgress.value = 0 + holdStartTime.value = null + showingGoalHint.value = false + goalHintText.value = null + goalHintSpoken = false + + goalInterval = setInterval(() => { + if (isPaused.value) return + + const met = evaluateGoal(goal, flightState) + goalProgress.value = computeGoalProgress(goal, flightState) + + if (met) { + if (holdStartTime.value === null) { + holdStartTime.value = Date.now() + } + + const held = Date.now() - holdStartTime.value + if (held >= holdMs) { + goalMet.value = true + if (isAutoAdvanceBlocked?.()) return + stopGoalMonitoring() + + const nextPhase = goalNextPhase[currentPhaseId.value] + if (nextPhase) { + goToPhase(nextPhase) + } + } + } else { + // User left tolerance, reset hold timer + holdStartTime.value = null + goalMet.value = false + } + }, 200) + + // Hint timeout + if (phase.goalHint) { + goalHintTimeout = setTimeout(() => { + if (!goalMet.value) { + showingGoalHint.value = true + goalHintText.value = phase.goalHint ?? null + if (!goalHintSpoken && goalHintText.value && onGoalHintCallback) { + goalHintSpoken = true + onGoalHintCallback(goalHintText.value) + } + } + }, timeoutMs) + } + } + + function stopGoalMonitoring() { + if (goalInterval) { + clearInterval(goalInterval) + goalInterval = null + } + if (goalHintTimeout) { + clearTimeout(goalHintTimeout) + goalHintTimeout = null + } + } + + // --- Phase navigation --- + + function selectOption(button: FlightLabButton) { + if (isPaused.value) return + history.value.push({ + phaseId: currentPhaseId.value, + buttonId: button.id, + timestamp: Date.now(), + }) + goToPhase(button.next) + return button + } + + function goToPhase(phaseId: string) { + if (phasesMap.value.has(phaseId)) { + stopGoalMonitoring() + goalMet.value = false + goalProgress.value = 0 + holdStartTime.value = null + showingGoalHint.value = false + goalHintText.value = null + currentPhaseId.value = phaseId + } + } + + // Watch for phase changes to start goal monitoring + watch(currentPhaseId, () => { + const phase = currentPhase.value + if (phase?.interactionGoal) { + startGoalMonitoring() + } + }) + + function skipForward() { + const idx = mainPhaseIds.indexOf(currentPhaseId.value) + const next = mainPhaseIds[idx + 1] + if (idx >= 0 && next) { + goToPhase(next) + } + } + + function skipBack() { + const idx = mainPhaseIds.indexOf(currentPhaseId.value) + const prev = mainPhaseIds[idx - 1] + if (idx > 0 && prev) { + goToPhase(prev) + } + } + + function restart() { + stopGoalMonitoring() + currentPhaseId.value = scenario.phases[0]?.id ?? '' + isPaused.value = false + history.value = [] + startedAt.value = Date.now() + goalMet.value = false + goalProgress.value = 0 + holdStartTime.value = null + showingGoalHint.value = false + goalHintText.value = null + } + + function pause() { isPaused.value = true } + function resume() { isPaused.value = false } + + function cleanup() { + stopGoalMonitoring() + } + + // --- Callback setters --- + + function onGoalHint(fn: (text: string) => void) { + onGoalHintCallback = fn + } + + function setAutoAdvanceBlocker(fn: () => boolean) { + isAutoAdvanceBlocked = fn + } + + function dismissGoalHint() { + showingGoalHint.value = false + goalHintText.value = null + } + + return { + // State + currentPhaseId, + currentPhase, + isPaused, + history, + progress, + isFinished, + startedAt, + scenario, + // Goal state + goalMet, + goalProgress, + holdStartTime, + showingGoalHint, + goalHintText, + // Convenience getters + visibleElements, + layoutMode, + // Actions + selectOption, + goToPhase, + restart, + pause, + resume, + skipForward, + skipBack, + dismissGoalHint, + // Callback setters + onGoalHint, + setAutoAdvanceBlocker, + cleanup, + } +}