feat(learn-pfd): add pitch-based speed hold step and target speed zones

This commit is contained in:
itsrubberduck
2026-02-21 00:08:16 +01:00
parent 7cfe21c490
commit 06f277e3ee
7 changed files with 116 additions and 23 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { PfdElement } from '~~/shared/data/flightlab/types'
import type { PfdElement, PfdSpeedTargetRange } from '~~/shared/data/flightlab/types'
const props = withDefaults(defineProps<{
pitch: number
@@ -9,9 +9,11 @@ const props = withDefaults(defineProps<{
altitude: number
verticalSpeed: number
visibleElements: PfdElement[]
speedTargetRange?: PfdSpeedTargetRange | null
scale?: number
}>(), {
scale: 1,
speedTargetRange: null,
})
const attSize = computed(() => 280 * props.scale)
@@ -82,6 +84,7 @@ const headingWidth = computed(() => attSize.value + tapeWidth.value * 2)
>
<FlightlabPfdSpeedTape
:speed="speed"
:target-range="speedTargetRange ?? undefined"
:width="tapeWidth"
:height="attSize"
/>

View File

@@ -1,7 +1,13 @@
<script setup lang="ts">
interface SpeedTargetRange {
min: number
max: number
}
const props = withDefaults(defineProps<{
speed: number
speedTrend?: number
targetRange?: SpeedTargetRange
width?: number
height?: number
}>(), {
@@ -20,6 +26,23 @@ function speedToY(spd: number): number {
return centerY.value + (props.speed - spd) * pixelsPerKnot
}
const targetZone = computed(() => {
if (!props.targetRange) return null
const min = Math.min(props.targetRange.min, props.targetRange.max)
const max = Math.max(props.targetRange.min, props.targetRange.max)
const minY = speedToY(min)
const maxY = speedToY(max)
const top = Math.min(minY, maxY)
const bottom = Math.max(minY, maxY)
return {
min,
max,
top,
bottom,
height: Math.max(2, bottom - top),
}
})
const visibleRange = computed(() => {
const halfVisible = props.height / 2 / pixelsPerKnot + 10
return {
@@ -75,6 +98,35 @@ const readoutBoxWidth = computed(() => props.width - 8)
<!-- Scrolling tape (clipped) -->
<g :clip-path="`url(#${clipId})`">
<!-- Target hold zone -->
<g v-if="targetZone">
<rect
x="1"
:y="targetZone.top"
:width="width - 2"
:height="targetZone.height"
fill="rgba(239, 68, 68, 0.22)"
stroke="rgba(248, 113, 113, 0.95)"
stroke-width="1.2"
/>
<line
x1="1"
:y1="targetZone.top"
:x2="width - 1"
:y2="targetZone.top"
stroke="rgba(254, 202, 202, 0.95)"
stroke-width="1"
/>
<line
x1="1"
:y1="targetZone.bottom"
:x2="width - 1"
:y2="targetZone.bottom"
stroke="rgba(254, 202, 202, 0.95)"
stroke-width="1"
/>
</g>
<g v-for="mark in marks" :key="mark.spd">
<!-- Tick mark -->
<line

View File

@@ -213,6 +213,7 @@
:speed="fbw.state.speed"
:altitude="fbw.state.altitude"
:vertical-speed="fbw.state.verticalSpeed"
:speed-target-range="speedTargetRange"
:visible-elements="engine.visibleElements.value"
:scale="pfdScale"
/>
@@ -395,13 +396,14 @@ const fullscreenEvents = ['fullscreenchange', 'webkitfullscreenchange', 'mozfull
let initialSpeechTimeout: ReturnType<typeof setTimeout> | null = null
// --- Sidebar phase IDs (main phases only) ---
const sidebarPhaseIds = ['welcome', 'horizon_intro', 'pitch_intro', 'speed_intro', 'alt_intro', 'vs_intro', 'heading_intro', 'combined', 'free_practice', 'end']
const sidebarPhaseIds = ['welcome', 'horizon_intro', 'pitch_intro', 'speed_intro', 'speed_hold_pitch', 'alt_intro', 'vs_intro', 'heading_intro', 'combined', 'free_practice', 'end']
const phaseLabels: Record<string, string> = {
welcome: 'Willkommen',
horizon_intro: 'Horizont',
pitch_intro: 'Pitch',
speed_intro: 'Speed Tape',
speed_intro: 'Schub 70%',
speed_hold_pitch: 'Speed halten',
alt_intro: 'Altitude Tape',
vs_intro: 'Vertical Speed',
heading_intro: 'Heading',
@@ -411,6 +413,8 @@ const phaseLabels: Record<string, string> = {
}
const currentPhase = computed(() => engine.currentPhase.value)
const speedTargetRange = computed(() => currentPhase.value?.speedTargetRange ?? null)
const isPitchSpeedHoldPhase = computed(() => engine.currentPhaseId.value === 'speed_hold_pitch')
// --- PFD Scale (responsive) ---
const pfdScale = computed(() => {
@@ -436,6 +440,10 @@ const layoutGridStyle = computed(() => {
// --- Goal description for sidebar ---
const goalDescription = computed(() => {
if (engine.currentPhaseId.value === 'speed_hold_pitch') {
return 'Geschwindigkeit im roten Band halten (nur mit Pitch bei 70% Schub)'
}
const goal = currentPhase.value?.interactionGoal
if (!goal) return ''
const labels: Record<string, string> = {
@@ -445,8 +453,10 @@ const goalDescription = computed(() => {
speed: 'Geschwindigkeit',
altitude: 'Höhe',
verticalSpeed: 'Steigrate',
throttlePercent: 'Schub',
}
return `${labels[goal.parameter] || goal.parameter}: ${goal.target}${goal.tolerance})`
const unit = goal.parameter === 'throttlePercent' ? '%' : ''
return `${labels[goal.parameter] || goal.parameter}: ${goal.target}${unit}${goal.tolerance}${unit})`
})
// --- Phase stepper helpers ---
@@ -531,13 +541,16 @@ sync.onStickInput((data) => {
fbw.updateInput({
pitch: data.pitch,
roll: data.roll,
throttle: data.throttle,
throttle: isPitchSpeedHoldPhase.value ? 0.7 : data.throttle,
})
})
// --- TTS on phase change ---
watch(() => engine.currentPhaseId.value, async (newId, oldId) => {
if (!newId || newId === oldId) return
if (newId === 'speed_hold_pitch') {
fbw.updateInput({ throttle: 0.7 })
}
if (initialSpeechTimeout) {
clearTimeout(initialSpeechTimeout)
initialSpeechTimeout = null

View File

@@ -38,7 +38,8 @@ const DRAG_COEFFICIENT = 0.03
const MASS = 150000
const GRAVITY = 32.174
const KT_TO_FPS = 1.68781
const SPEED_PITCH_COUPLING = 0.5
const TRIM_PITCH_DEG = 2
const SPEED_PITCH_COUPLING = 1.2
const INITIAL_SPEED = 220
const INITIAL_ALTITUDE = 5000
@@ -131,9 +132,9 @@ export function useAirbusFBW() {
// --- Speed ---
const speedFps2 = state.speed * KT_TO_FPS
const pitchRad = state.pitch * Math.PI / 180
const trimmedPitchRad = (state.pitch - TRIM_PITCH_DEG) * Math.PI / 180
const drag = DRAG_COEFFICIENT * speedFps2 * speedFps2
const climbPenalty = Math.sin(pitchRad) * MASS * GRAVITY * SPEED_PITCH_COUPLING
const climbPenalty = Math.sin(trimmedPitchRad) * MASS * GRAVITY * SPEED_PITCH_COUPLING
const netForce = thrust - drag - climbPenalty
const acceleration = netForce / MASS
const speedDelta = (acceleration / KT_TO_FPS) * dt

View File

@@ -3,14 +3,15 @@ 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 mainPhaseIds = ['welcome', 'horizon_intro', 'pitch_intro', 'speed_intro', 'speed_hold_pitch', 'alt_intro', 'vs_intro', 'heading_intro', 'combined', 'free_practice', 'end']
const goalNextPhase: Record<string, string> = {
'horizon_intro': 'horizon_roll_right',
'horizon_roll_right': 'pitch_intro',
'pitch_intro': 'pitch_down',
'pitch_down': 'speed_intro',
'speed_intro': 'alt_intro',
'speed_intro': 'speed_hold_pitch',
'speed_hold_pitch': 'alt_intro',
'alt_intro': 'vs_intro',
'vs_intro': 'heading_intro',
'heading_intro': 'combined',

View File

@@ -75,19 +75,35 @@ const phases: LearnPfdPhase[] = [
// --- Phase 6: Speed Intro ---
{
id: 'speed_intro',
atcMessage: 'Links erscheint jetzt das Speed Tape. Das zeigt dir wie schnell du fliegst, in Knoten. Schieb den Schubhebel auf etwa siebzig Prozent nach vorne — und schau was mit der Geschwindigkeit passiert.',
explanation: 'Das Speed Tape zeigt die angezeigte Fluggeschwindigkeit (IAS) in Knoten.',
atcMessage: 'Links erscheint jetzt das Speed Tape. Erster Schritt: Setz den Schubhebel sauber auf siebzig Prozent.',
explanation: 'Wir stabilisieren zuerst den Schub auf 70%, bevor wir Geschwindigkeit über Pitch kontrollieren.',
visibleElements: ['attitude', 'speedTape'],
layoutMode: 'split',
interactionGoal: { parameter: 'speed', target: 280, tolerance: 30, holdMs: 2000 },
interactionGoal: { parameter: 'throttlePercent', target: 70, tolerance: 4, holdMs: 1500 },
goalTimeoutMs: 20000,
goalHint: 'Schieb den Schubhebel bis ungefähr siebzig Prozent. Die Zahl auf dem Speed Tape sollte steigen.',
goalHint: 'Schubhebel auf die 70%-Marke setzen und kurz halten.',
buttons: [
{ id: 'skip_speed', label: 'Weiter', icon: 'mdi-arrow-right', next: 'alt_intro', type: 'primary' },
{ id: 'skip_speed', label: 'Weiter', icon: 'mdi-arrow-right', next: 'speed_hold_pitch', type: 'primary' },
],
},
// --- Phase 7: Altitude Intro ---
// --- Phase 7: Hold Speed With Pitch ---
{
id: 'speed_hold_pitch',
atcMessage: 'Jetzt kommt der wichtige Teil: Halte die Geschwindigkeit im roten Bereich. Der Schub bleibt bei siebzig Prozent — du steuerst das nur mit Pitch. Nase leicht hoch macht langsamer, Nase leicht runter macht schneller.',
explanation: 'Energie-Management: Mit konstantem Schub kontrollierst du die Geschwindigkeit über den Pitchwinkel.',
visibleElements: ['attitude', 'speedTape'],
layoutMode: 'split',
interactionGoal: { parameter: 'speed', target: 225, tolerance: 8, holdMs: 3000 },
speedTargetRange: { min: 217, max: 233 },
goalTimeoutMs: 25000,
goalHint: 'Halte den Schub bei 70%. Korrigiere nur mit Pitch, bis die Speed-Zahl im roten Band bleibt.',
buttons: [
{ id: 'skip_speed_hold', label: 'Weiter', icon: 'mdi-arrow-right', next: 'alt_intro', type: 'primary' },
],
},
// --- Phase 8: Altitude Intro ---
{
id: 'alt_intro',
atcMessage: 'Rechts kommt jetzt das Altitude Tape. Das zeigt deine Höhe in Fuß. Zieh den Stick leicht nach hinten — du steigst, und die Höhe nimmt zu.',
@@ -102,7 +118,7 @@ const phases: LearnPfdPhase[] = [
],
},
// --- Phase 8: Vertical Speed Intro ---
// --- Phase 9: Vertical Speed Intro ---
{
id: 'vs_intro',
atcMessage: 'Neben der Höhe siehst du jetzt die Vertical Speed Anzeige. Die zeigt dir, wie schnell du steigst oder sinkst. Plus heißt steigen, minus heißt sinken. Probier beides aus.',
@@ -117,7 +133,7 @@ const phases: LearnPfdPhase[] = [
],
},
// --- Phase 9: Heading Intro ---
// --- Phase 10: Heading Intro ---
{
id: 'heading_intro',
atcMessage: 'Unten erscheint jetzt das Heading. Das ist dein Kompass — es zeigt in welche Richtung du fliegst. Neig das Flugzeug nach rechts und beobachte, wie sich das Heading ändert.',
@@ -132,7 +148,7 @@ const phases: LearnPfdPhase[] = [
],
},
// --- Phase 10: Combined ---
// --- Phase 11: Combined ---
{
id: 'combined',
atcMessage: 'Sehr gut! Jetzt hast du alle Instrumente vor dir. Das ist das komplette PFD. Nimm dir einen Moment und spiel damit. Steig auf 8000 Fuß.',
@@ -146,7 +162,7 @@ const phases: LearnPfdPhase[] = [
],
},
// --- Phase 11: Free Practice ---
// --- Phase 12: Free Practice ---
{
id: 'free_practice',
atcMessage: 'Du hast das PFD verstanden. Ab jetzt kannst du frei üben. Experimentier mit dem Stick und dem Schubhebel. Schau dir an, wie alles zusammenhängt.',
@@ -157,7 +173,7 @@ const phases: LearnPfdPhase[] = [
],
},
// --- Phase 12: End ---
// --- Phase 13: End ---
{
id: 'end',
atcMessage: 'Fantastisch! Du kannst jetzt das PFD eines Airbus A320 lesen. Horizont, Geschwindigkeit, Höhe, Steigrate und Heading — alles klar.',

View File

@@ -120,9 +120,14 @@ export type FlightLabWSEvent =
export type PfdElement = 'attitude' | 'speedTape' | 'altitudeTape' | 'verticalSpeed' | 'heading'
export type PfdLayoutMode = 'model-focus' | 'split' | 'pfd-focus'
export interface PfdSpeedTargetRange {
min: number
max: number
}
export interface PfdInteractionGoal {
/** What to check: pitch angle, altitude, heading, speed, bank */
parameter: 'pitch' | 'altitude' | 'heading' | 'speed' | 'bankAngle' | 'verticalSpeed'
/** What to check: pitch angle, altitude, heading, speed, bank, thrust */
parameter: 'pitch' | 'altitude' | 'heading' | 'speed' | 'bankAngle' | 'verticalSpeed' | 'throttlePercent'
/** Target value */
target: number
/** Acceptable deviation (+/-) */
@@ -142,6 +147,8 @@ export interface LearnPfdPhase extends FlightLabPhase {
goalTimeoutMs?: number
/** Hint spoken via TTS if user struggles */
goalHint?: string
/** Optional highlighted target range on speed tape */
speedTargetRange?: PfdSpeedTargetRange
}
export interface LearnPfdScenario {