feat(pfd): realistic styling, flight inertia, and improved training exercises

- Darken all tape backgrounds to match real A320 PFD (#16181f/#1c1e26)
- Speed/altitude readouts green (#19e34a) instead of cyan
- Attitude indicator: sky/ground gradients, W-shaped aircraft symbol
- Speed tape: cyan target zone (not red), VFE/min-speed red bands
- Altitude tape: ticks on left side toward attitude indicator
- Flight physics: 3x slower pitch (smoothed, tau 2s), halved roll rate,
  stronger speed-pitch coupling for realistic 150t inertia
- Pitch/bank exercises use normal flight ranges (±10° bank, ±3-4° pitch)
- Multi-phase speed exercise: explain → coarse hold 5s → fine hold 8s
- ATC messages emphasize small inputs, patience, and anticipation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-02-21 15:45:39 +01:00
parent 11ffd09e3b
commit febe4d907a
10 changed files with 690 additions and 61 deletions

View File

@@ -65,7 +65,7 @@ const tapeInnerHeight = computed(() => props.height - 4)
y="0"
:width="width"
:height="height"
fill="#8f9198"
fill="#16181f"
rx="1"
/>
<rect
@@ -73,27 +73,27 @@ const tapeInnerHeight = computed(() => props.height - 4)
:y="tapeInnerY"
:width="tapeInnerWidth"
:height="tapeInnerHeight"
fill="#93959c"
stroke="#d2d4da"
fill="#1c1e26"
stroke="#3a3d48"
stroke-width="0.8"
/>
<!-- Scrolling tape (clipped) -->
<g :clip-path="`url(#${clipId})`">
<g v-for="mark in marks" :key="mark.alt">
<!-- Tick mark -->
<!-- Tick mark (left side, toward attitude indicator) -->
<line
:x1="width - 10"
x1="1"
:y1="mark.y"
:x2="width - 1"
x2="10"
:y2="mark.y"
stroke="#f4f6fb"
stroke-width="1"
/>
<!-- Label -->
<!-- Label (right of ticks) -->
<text
v-if="mark.isLabel"
x="4"
x="14"
:y="mark.y + 4"
fill="#f4f6fb"
font-size="12"
@@ -112,14 +112,14 @@ const tapeInnerHeight = computed(() => props.height - 4)
:width="readoutBoxWidth"
:height="readoutBoxHeight"
fill="#02040b"
stroke="#2fe5ff"
stroke="#19e34a"
stroke-width="1.4"
rx="1.5"
/>
<text
:x="width / 2"
:y="centerY + 5"
fill="#2fe5ff"
fill="#19e34a"
font-size="14"
font-weight="bold"
text-anchor="middle"

View File

@@ -84,12 +84,12 @@ const dotRadius = computed(() => 4)
<circle :cx="cx" :cy="cy" :r="size * 0.46" />
</clipPath>
<linearGradient :id="skyGradId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#22a7eb" />
<stop offset="0%" stop-color="#0a4a8a" />
<stop offset="100%" stop-color="#22a7eb" />
</linearGradient>
<linearGradient :id="groundGradId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#b26113" />
<stop offset="100%" stop-color="#b26113" />
<stop offset="100%" stop-color="#5a2e08" />
</linearGradient>
</defs>
@@ -184,7 +184,7 @@ const dotRadius = computed(() => 4)
:fill="YELLOW"
/>
<!-- Fixed aircraft reference symbol -->
<!-- Fixed aircraft reference symbol (W-shape) -->
<g>
<!-- Left wing -->
<rect
@@ -195,6 +195,16 @@ const dotRadius = computed(() => 4)
:fill="YELLOW"
rx="1"
/>
<!-- Left wing tip (descending) -->
<line
:x1="cx - wingSpan - dotRadius"
:y1="cy"
:x2="cx - wingSpan - dotRadius"
:y2="cy + 5"
:stroke="YELLOW"
stroke-width="3"
stroke-linecap="round"
/>
<!-- Right wing -->
<rect
:x="cx + dotRadius"
@@ -204,6 +214,16 @@ const dotRadius = computed(() => 4)
:fill="YELLOW"
rx="1"
/>
<!-- Right wing tip (descending) -->
<line
:x1="cx + wingSpan + dotRadius"
:y1="cy"
:x2="cx + wingSpan + dotRadius"
:y2="cy + 5"
:stroke="YELLOW"
stroke-width="3"
stroke-linecap="round"
/>
<!-- Center dot -->
<circle
:cx="cx"

View File

@@ -89,7 +89,7 @@ const readoutBoxHeight = 18
y="0"
:width="width"
:height="height"
fill="#8f9198"
fill="#16181f"
rx="1"
/>
<rect
@@ -97,8 +97,8 @@ const readoutBoxHeight = 18
y="1.5"
:width="width - 3"
:height="height - 3"
fill="#94969d"
stroke="#d4d6dc"
fill="#1c1e26"
stroke="#3a3d48"
stroke-width="0.8"
/>

View File

@@ -8,10 +8,14 @@ const props = withDefaults(defineProps<{
speed: number
speedTrend?: number
targetRange?: SpeedTargetRange
vfeSpeed?: number
minSpeed?: number
width?: number
height?: number
}>(), {
speedTrend: 0,
vfeSpeed: 340,
minSpeed: 130,
width: 70,
height: 300,
})
@@ -96,7 +100,7 @@ const tapeInnerHeight = computed(() => props.height - 4)
y="0"
:width="width"
:height="height"
fill="#8f9198"
fill="#16181f"
rx="1"
/>
<rect
@@ -104,22 +108,59 @@ const tapeInnerHeight = computed(() => props.height - 4)
:y="tapeInnerY"
:width="tapeInnerWidth"
:height="tapeInnerHeight"
fill="#93959c"
stroke="#d2d4da"
fill="#1c1e26"
stroke="#3a3d48"
stroke-width="0.8"
/>
<!-- Scrolling tape (clipped) -->
<g :clip-path="`url(#${clipId})`">
<!-- Target hold zone -->
<!-- VFE overspeed band (red) -->
<rect
x="1"
:y="0"
:width="width - 2"
:height="Math.max(0, speedToY(vfeSpeed))"
fill="rgba(239, 40, 40, 0.45)"
/>
<line
v-if="speedToY(vfeSpeed) > 0"
x1="1"
:y1="speedToY(vfeSpeed)"
:x2="width - 1"
:y2="speedToY(vfeSpeed)"
stroke="#ef4444"
stroke-width="1.5"
/>
<!-- Minimum speed band (red) -->
<rect
v-if="speedToY(minSpeed) < height"
x="1"
:y="speedToY(minSpeed)"
:width="width - 2"
:height="Math.max(0, height - speedToY(minSpeed))"
fill="rgba(239, 40, 40, 0.45)"
/>
<line
v-if="speedToY(minSpeed) < height"
x1="1"
:y1="speedToY(minSpeed)"
:x2="width - 1"
:y2="speedToY(minSpeed)"
stroke="#ef4444"
stroke-width="1.5"
/>
<!-- Target hold zone (cyan) -->
<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)"
fill="rgba(45, 212, 255, 0.18)"
stroke="rgba(45, 229, 255, 0.8)"
stroke-width="1.2"
/>
<line
@@ -127,7 +168,7 @@ const tapeInnerHeight = computed(() => props.height - 4)
:y1="targetZone.top"
:x2="width - 1"
:y2="targetZone.top"
stroke="rgba(254, 202, 202, 0.95)"
stroke="rgba(45, 229, 255, 0.5)"
stroke-width="1"
/>
<line
@@ -135,7 +176,7 @@ const tapeInnerHeight = computed(() => props.height - 4)
:y1="targetZone.bottom"
:x2="width - 1"
:y2="targetZone.bottom"
stroke="rgba(254, 202, 202, 0.95)"
stroke="rgba(45, 229, 255, 0.5)"
stroke-width="1"
/>
</g>
@@ -171,7 +212,7 @@ const tapeInnerHeight = computed(() => props.height - 4)
:y1="centerY"
:x2="width / 2"
:y2="trendLineEndY"
stroke="#30e3ff"
stroke="#19e34a"
stroke-width="2"
opacity="0.8"
/>
@@ -184,14 +225,14 @@ const tapeInnerHeight = computed(() => props.height - 4)
:width="readoutBoxWidth"
:height="readoutBoxHeight"
fill="#02040b"
stroke="#2fe5ff"
stroke="#19e34a"
stroke-width="1.4"
rx="1.5"
/>
<text
:x="width / 2"
:y="centerY + 5"
fill="#2fe5ff"
fill="#19e34a"
font-size="15"
font-weight="bold"
text-anchor="middle"

View File

@@ -68,8 +68,8 @@ const channelShape = computed(() => {
<!-- Airbus-style VS channel -->
<polygon
:points="channelShape"
fill="#8f9198"
stroke="#d2d4da"
fill="#1c1e26"
stroke="#3a3d48"
stroke-width="0.8"
/>

View File

@@ -0,0 +1,79 @@
# PFD Realism & Training Improvements
## Context
Comparing the simulator PFD to the real Airbus A320 PFD reveals styling inaccuracies in the elements we use. Additionally, the flight physics react too quickly (no inertia), and the speed-hold exercise doesn't teach energy management effectively. Pitch/bank exercises use values outside normal flight envelopes.
## 1. PFD Styling Corrections
### Attitude Indicator
- Sky: gradient `#0a4a8a` (top) → `#22a7eb` (horizon) instead of flat `#22a7eb`
- Ground: gradient `#b26113` (horizon) → `#5a2e08` (bottom) instead of flat `#b26113`
- Aircraft symbol: make W-shaped (small droop at wing ends)
### Speed Tape
- Background: `#16181f` instead of `#93959c`
- Inner rect: `#1c1e26`, border `#3a3d48` instead of `#93959c`/`#d2d4da`
- Readout text/border: green `#19e34a` instead of cyan `#2fe5ff`
### Altitude Tape
- Same dark background as speed tape
- Readout: green instead of cyan
- Tick marks on LEFT side (toward attitude indicator), labels right of ticks
### Vertical Speed
- Channel fill: darken to match tape style
- Already uses green — correct
### Heading Indicator
- Background: dark (`#16181f`) instead of `#8f9198`/`#94969d`
- Inner: `#1c1e26` with border `#3a3d48`
- Yellow readout stays (correct for selected heading on Airbus)
## 2. Realistic Flight Handling
### Pitch Inertia
- `PITCH_RATE_PER_G_DELTA`: 3.5 → 1.2 (3x slower response)
- Add exponential smoothing on pitch rate (tau ~2s) so changes ramp up gradually
### Roll Inertia
- `MAX_ROLL_RATE`: 15 → 7 deg/s
- `ROLL_RETURN_RATE`: 5 → 2 deg/s
### Speed-Pitch Coupling
- `SPEED_PITCH_COUPLING`: 1.2 → 2.0 (pitch changes affect speed more, forcing planning)
## 3. Training Exercise Improvements
### Pitch/Bank: Normal Ranges
- `horizon_intro` bank target: -20° → -10° (tolerance 8°)
- `horizon_roll_right` bank target: 20° → 10° (tolerance 8°)
- `pitch_intro` target: 10° → 4° (tolerance 3°)
- `pitch_down` target: -5° → -3° (tolerance 3°)
- ATC messages emphasize small, gentle inputs
### Speed Tape: Correct Color Semantics
- Target hold zone: cyan/turquoise (NOT red — red = danger on real PFD)
- Add VFE (overspeed) red band at top (~340 kts)
- Add minimum speed red band at bottom (~130 kts)
- Red bands only visible when approaching dangerous speeds
### Speed Exercise: Multi-Phase
Split single `speed_hold_pitch` into 4 phases:
1. **`speed_explain`** — Explanation only. "Beim Airbus kontrollierst du Geschwindigkeit mit Pitch. Nase hoch = langsamer, Nase runter = schneller." Button: "Verstanden".
2. **`speed_set_throttle`** — Set throttle to 70% (existing phase, unchanged).
3. **`speed_hold_coarse`** — Coarse hold: speed 215-235 kts, hold 5s. Wide tolerance, first success.
4. **`speed_hold_fine`** — Fine hold: speed 225 ±5 kts, hold 8s. ATC emphasizes tiny corrections only.
Update `goalNextPhase` mapping accordingly:
- `speed_intro``speed_explain` (was → `speed_hold_pitch`)
- `speed_explain``speed_set_throttle` (new, button-driven)
- `speed_set_throttle``speed_hold_coarse` (new)
- `speed_hold_coarse``speed_hold_fine` (new)
- `speed_hold_fine``alt_intro` (was `speed_hold_pitch``alt_intro`)
Also update `mainPhaseIds` to include new speed phases for progress tracking.

View File

@@ -0,0 +1,453 @@
# PFD Realism & Training Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make the PFD visually match the real Airbus A320, add realistic flight inertia, and improve training exercises with normal-range targets and a multi-phase speed lesson.
**Architecture:** Pure frontend changes across 3 layers: SVG styling in Vue SFC components, physics constants + smoothing in the FBW composable, and phase data in the learn-pfd scenario definition + engine.
**Tech Stack:** Vue 3 SFC (SVG), TypeScript composables, Node test runner (tsx)
---
### Task 1: Darken Speed Tape colors
**Files:**
- Modify: `app/components/flightlab/pfd/PfdSpeedTape.vue`
**Step 1: Update background colors**
Change outer rect fill from `#8f9198` to `#16181f`.
Change inner rect fill from `#93959c` to `#1c1e26`, stroke from `#d2d4da` to `#3a3d48`.
**Step 2: Update readout colors to green**
Change readout box stroke from `#2fe5ff` to `#19e34a`.
Change readout text fill from `#2fe5ff` to `#19e34a`.
Change speed trend arrow stroke from `#30e3ff` to `#19e34a`.
**Step 3: Change target zone to cyan (not red)**
The target hold zone must NOT be red — red means danger on a real PFD. Change to cyan/turquoise:
- Target zone fill: `rgba(239, 68, 68, 0.22)``rgba(45, 212, 255, 0.18)`
- Target zone stroke: `rgba(248, 113, 113, 0.95)``rgba(45, 229, 255, 0.8)`
- Top/bottom lines: `rgba(254, 202, 202, 0.95)``rgba(45, 229, 255, 0.5)`
**Step 4: Add VFE (overspeed) and minimum speed red bands**
Add two new props: `vfeSpeed` (default 340) and `minSpeed` (default 130).
Add red bands above VFE and below minSpeed, rendered inside the clipped tape group (before the scrolling marks):
```html
<!-- Overspeed band (red, above VFE) -->
<rect
x="1"
:y="0"
:width="width - 2"
:height="Math.max(0, speedToY(vfeSpeed))"
fill="rgba(239, 40, 40, 0.45)"
/>
<line
x1="1" :y1="speedToY(vfeSpeed)"
:x2="width - 1" :y2="speedToY(vfeSpeed)"
stroke="#ef4444" stroke-width="1.5"
/>
<!-- Min speed band (red, below min) -->
<rect
x="1"
:y="speedToY(minSpeed)"
:width="width - 2"
:height="height - speedToY(minSpeed)"
fill="rgba(239, 40, 40, 0.45)"
/>
<line
x1="1" :y1="speedToY(minSpeed)"
:x2="width - 1" :y2="speedToY(minSpeed)"
stroke="#ef4444" stroke-width="1.5"
/>
```
The red bands only appear when they scroll into the visible tape area. At 220 kts neither will be visible, which is correct — they only show when approaching dangerous speeds.
**Step 4: Verify visually**
Run: `bun run dev`
Navigate to `/flightlab/medienstationen/learn-pfd`, advance to speed phase.
Confirm: dark background, green readout, visible target zone.
**Step 5: Commit**
```
git add app/components/flightlab/pfd/PfdSpeedTape.vue
git commit -m "style(pfd): darken speed tape and use green readout colors"
```
---
### Task 2: Darken Altitude Tape + fix tick side
**Files:**
- Modify: `app/components/flightlab/pfd/PfdAltitudeTape.vue`
**Step 1: Update background colors**
Same as speed tape: outer `#16181f`, inner `#1c1e26` with stroke `#3a3d48`.
**Step 2: Update readout colors to green**
Readout box stroke: `#2fe5ff``#19e34a`.
Readout text fill: `#2fe5ff``#19e34a`.
**Step 3: Move tick marks to LEFT side**
Change tick x1 from `width - 10` to `1`.
Change tick x2 from `width - 1` to `10`.
Move labels: x from `4` to `14`, keep `text-anchor="start"`.
**Step 4: Verify visually**
Confirm: dark background, green readout, ticks on left side with labels to the right of ticks.
**Step 5: Commit**
```
git add app/components/flightlab/pfd/PfdAltitudeTape.vue
git commit -m "style(pfd): darken altitude tape, green readout, ticks on left"
```
---
### Task 3: Darken Heading Indicator
**Files:**
- Modify: `app/components/flightlab/pfd/PfdHeadingIndicator.vue`
**Step 1: Update background colors**
Outer rect fill: `#8f9198``#16181f`.
Inner rect fill: `#94969d``#1c1e26`, stroke: `#d4d6dc``#3a3d48`.
**Step 2: Verify visually**
Yellow readout stays — already correct for Airbus selected heading.
**Step 3: Commit**
```
git add app/components/flightlab/pfd/PfdHeadingIndicator.vue
git commit -m "style(pfd): darken heading indicator background"
```
---
### Task 4: Darken Vertical Speed channel
**Files:**
- Modify: `app/components/flightlab/pfd/PfdVerticalSpeed.vue`
**Step 1: Update channel fill**
Channel polygon fill: `#8f9198``#1c1e26`, stroke: `#d2d4da``#3a3d48`.
**Step 2: Verify visually**
Green vector and labels should remain unchanged and visible against dark bg.
**Step 3: Commit**
```
git add app/components/flightlab/pfd/PfdVerticalSpeed.vue
git commit -m "style(pfd): darken vertical speed channel"
```
---
### Task 5: Add sky/ground gradients to Attitude Indicator
**Files:**
- Modify: `app/components/flightlab/pfd/PfdAttitudeIndicator.vue`
**Step 1: Update gradient stops**
Sky gradient: change both stops from `#22a7eb` to:
- stop offset="0%": `#0a4a8a`
- stop offset="100%": `#22a7eb`
Ground gradient: change both stops from `#b26113` to:
- stop offset="0%": `#b26113`
- stop offset="100%": `#5a2e08`
**Step 2: Make aircraft symbol W-shaped**
Replace the two wing rectangles and center dot with a path that has a slight droop at wing tips. Change the wing construction:
Current wings are flat rectangles at `cy`. Add small downward offsets at the outer wing ends to create the characteristic W-shape:
Replace left wing rect with a polygon:
```
points: `${cx - wingSpan - dotRadius},${cy + 3} ${cx - dotRadius - 2},${cy - wingThickness/2} ${cx - dotRadius},${cy - wingThickness/2} ${cx - dotRadius},${cy + wingThickness/2} ${cx - dotRadius - 2},${cy + wingThickness/2}`
```
Simpler approach: keep rects but add small "wing tip" lines descending from each wing end:
- Left tip: line from `(cx - wingSpan - dotRadius, cy)` to `(cx - wingSpan - dotRadius, cy + 5)`
- Right tip: line from `(cx + wingSpan + dotRadius, cy)` to `(cx + wingSpan + dotRadius, cy + 5)`
Both lines yellow, stroke-width 3, stroke-linecap round.
**Step 3: Verify visually**
Confirm gradient visible in sky (darker blue at top) and ground (darker brown at bottom). W-shape visible on aircraft symbol.
**Step 4: Commit**
```
git add app/components/flightlab/pfd/PfdAttitudeIndicator.vue
git commit -m "style(pfd): add sky/ground gradients and W-shaped aircraft symbol"
```
---
### Task 6: Add pitch inertia smoothing to FBW engine
**Files:**
- Modify: `shared/composables/flightlab/useAirbusFBW.ts`
**Step 1: Update constants**
```typescript
const MAX_ROLL_RATE = 7 // was 15
const ROLL_RETURN_RATE = 2 // was 5
const PITCH_RATE_PER_G_DELTA = 1.2 // was 3.5
const SPEED_PITCH_COUPLING = 2.0 // was 1.2
```
**Step 2: Add smoothed pitch rate state**
Add a `smoothedPitchRate` variable (initialized to 0) before the `tick` function:
```typescript
let smoothedPitchRate = 0
const PITCH_SMOOTH_TAU = 2.0 // seconds — exponential smoothing time constant
```
**Step 3: Apply exponential smoothing in tick**
In the pitch section of `tick()`, replace:
```typescript
const pitchRate = gDelta * PITCH_RATE_PER_G_DELTA
state.pitch += pitchRate * dt
```
With:
```typescript
const rawPitchRate = gDelta * PITCH_RATE_PER_G_DELTA
const alpha = 1 - Math.exp(-dt / PITCH_SMOOTH_TAU)
smoothedPitchRate += (rawPitchRate - smoothedPitchRate) * alpha
state.pitch += smoothedPitchRate * dt
```
**Step 4: Reset smoothedPitchRate in reset()**
Add `smoothedPitchRate = 0` to the `reset()` function.
**Step 5: Verify by running dev**
Run: `bun run dev`
Navigate to `/flightlab/medienstationen/learn-pfd`. Pull stick back — pitch should ramp up slowly over ~2-3 seconds instead of responding instantly.
**Step 6: Commit**
```
git add shared/composables/flightlab/useAirbusFBW.ts
git commit -m "feat(flightlab): add realistic inertia to pitch/roll/speed response"
```
---
### Task 7: Update pitch/bank exercise targets to normal ranges
**Files:**
- Modify: `shared/data/flightlab/learn-pfd.ts`
**Step 1: Update horizon_intro phase**
Change `interactionGoal` target from `-20` to `-10`, tolerance from `15` to `8`.
Update `atcMessage` to: `'Das hier ist der künstliche Horizont. Blau ist der Himmel, braun ist der Boden. Genau wie draußen. Beweg jetzt den Stick ganz sanft nach links — nur ein kleines bisschen. Im echten Flugzeug macht man immer nur kleine, sanfte Bewegungen.'`
Update `goalHint` to: `'Drück den Stick ganz leicht nach links. Nur ein kleiner Ausschlag reicht.'`
**Step 2: Update horizon_roll_right phase**
Change `interactionGoal` target from `20` to `10`, tolerance from `15` to `8`.
Update `atcMessage` to: `'Perfekt! Jetzt ganz sanft in die andere Richtung — Stick leicht nach rechts.'`
Update `goalHint` to: `'Stick ganz leicht nach rechts. Kleine, sanfte Bewegungen.'`
**Step 3: Update pitch_intro phase**
Change `interactionGoal` target from `10` to `4`, tolerance from `5` to `3`.
Update `atcMessage` to: `'Jetzt kommt was Wichtiges. Zieh den Stick ganz leicht nach hinten. Nur ein kleines bisschen. Das Besondere beim Airbus: Wenn du loslässt, hält er die Lage. Und das Flugzeug reagiert langsam — du musst immer vorausdenken.'`
Update `goalHint` to: `'Zieh den Stick nur ganz leicht nach hinten. Beim echten Airbus macht man nur minimale Bewegungen.'`
**Step 4: Update pitch_down phase**
Change `interactionGoal` target from `-5` to `-3`, tolerance from `5` to `3`.
Update `atcMessage` to: `'Gut gemacht! Jetzt drück den Stick ganz leicht nach vorne. Auch hier gilt: kleine Bewegungen, und Geduld — das Flugzeug braucht ein paar Sekunden bis es reagiert.'`
Update `goalHint` to: `'Stick ganz leicht nach vorne drücken und warten. Das Flugzeug reagiert mit Verzögerung.'`
**Step 5: Commit**
```
git add shared/data/flightlab/learn-pfd.ts
git commit -m "feat(flightlab): use normal-range targets for pitch/bank exercises"
```
---
### Task 8: Add multi-phase speed exercise
**Files:**
- Modify: `shared/data/flightlab/learn-pfd.ts`
- Modify: `shared/composables/flightlab/useLearnPfdEngine.ts`
**Step 1: Replace speed_hold_pitch with 3 new phases in learn-pfd.ts**
Remove the existing `speed_hold_pitch` phase object. Insert 3 new phase objects after `speed_intro`:
```typescript
// --- Phase 7a: Speed Explain ---
{
id: 'speed_explain',
atcMessage: 'Bevor wir loslegen, eine wichtige Regel: Beim Airbus kontrollierst du die Geschwindigkeit mit der Nase. Nase hoch — du wirst langsamer. Nase runter — du wirst schneller. Der Schub bleibt dabei immer gleich. Das nennt man Energie-Management.',
explanation: 'Im echten Flug hält der Autopilot den Schub konstant. Der Pilot steuert die Geschwindigkeit über den Pitch.',
visibleElements: ['attitude', 'speedTape'],
layoutMode: 'split',
buttons: [
{ id: 'understood', label: 'Verstanden', icon: 'mdi-check', next: 'speed_set_throttle', type: 'primary' },
],
},
// --- Phase 7b: Speed Set Throttle (existing speed_intro content, target stays) ---
// speed_intro already handles throttle setting — keep as is
// --- Phase 7c: Speed Hold Coarse ---
{
id: 'speed_hold_coarse',
atcMessage: 'Gut, Schub steht. Jetzt versuch die Geschwindigkeit in den türkisen Bereich zu bringen. Nase ganz leicht hoch oder runter — und dann warten. Das Flugzeug braucht mehrere Sekunden bis sich die Speed ändert.',
explanation: 'Erste Annäherung: Bringe die Speed grob in den Zielbereich.',
visibleElements: ['attitude', 'speedTape'],
layoutMode: 'split',
interactionGoal: { parameter: 'speed', target: 225, tolerance: 10, holdMs: 5000 },
speedTargetRange: { min: 215, max: 235 },
goalTimeoutMs: 30000,
goalHint: 'Korrigiere nur mit ganz kleinen Pitch-Bewegungen. Die Speed ändert sich langsam — hab Geduld.',
autoAdvanceAfterTTS: false,
buttons: [
{ id: 'skip_coarse', label: 'Weiter', icon: 'mdi-arrow-right', next: 'speed_hold_fine', type: 'primary' },
],
},
// --- Phase 7d: Speed Hold Fine ---
{
id: 'speed_hold_fine',
atcMessage: 'Sehr gut! Jetzt genauer. Halte die Geschwindigkeit möglichst exakt auf 225 Knoten. Nur winzige Korrekturen — und immer ein paar Sekunden warten bevor du nachkorrigierst.',
explanation: 'Feinsteuerung: Präzise Speed-Kontrolle mit minimalen Pitch-Änderungen.',
visibleElements: ['attitude', 'speedTape'],
layoutMode: 'split',
interactionGoal: { parameter: 'speed', target: 225, tolerance: 5, holdMs: 8000 },
speedTargetRange: { min: 220, max: 230 },
goalTimeoutMs: 40000,
goalHint: 'Ganz kleine Pitch-Korrekturen. Warte nach jeder Korrektur 3-4 Sekunden bevor du nachjustierst.',
autoAdvanceAfterTTS: false,
buttons: [
{ id: 'skip_fine', label: 'Weiter', icon: 'mdi-arrow-right', next: 'alt_intro', type: 'primary' },
],
},
```
**Step 2: Update speed_intro phase**
Change the `speed_intro` button's `next` from `'speed_hold_pitch'` to `'speed_explain'`.
**Step 3: Update goalNextPhase in useLearnPfdEngine.ts**
Replace:
```typescript
'speed_intro': 'speed_hold_pitch',
'speed_hold_pitch': 'alt_intro',
```
With:
```typescript
'speed_intro': 'speed_explain',
'speed_set_throttle': 'speed_hold_coarse',
'speed_hold_coarse': 'speed_hold_fine',
'speed_hold_fine': 'alt_intro',
```
Note: `speed_explain``speed_set_throttle` is button-driven (not goal-driven), so no entry needed in `goalNextPhase`. But `speed_set_throttle` is actually `speed_intro` with the throttle goal. Wait — looking at the existing flow:
- `speed_intro` has goal: throttle 70%. On goal met → `goalNextPhase['speed_intro']` fires.
- So `speed_intro` goal-advances to `speed_explain` (explanation screen).
- `speed_explain` has no goal, button-advances to `speed_set_throttle`.
Actually this is wrong — `speed_intro` IS the throttle-setting phase. The flow should be:
- `speed_intro` (throttle 70% goal) → goal met → `speed_explain`
- `speed_explain` (no goal, button) → `speed_hold_coarse`
- `speed_hold_coarse` (speed ±10, 5s) → goal met → `speed_hold_fine`
- `speed_hold_fine` (speed ±5, 8s) → goal met → `alt_intro`
Fix `speed_explain` button next to `'speed_hold_coarse'` (not `speed_set_throttle`).
Updated goalNextPhase:
```typescript
'speed_intro': 'speed_explain',
'speed_hold_coarse': 'speed_hold_fine',
'speed_hold_fine': 'alt_intro',
```
**Step 4: Update mainPhaseIds**
Replace `'speed_hold_pitch'` with `'speed_explain', 'speed_hold_coarse', 'speed_hold_fine'` in the `mainPhaseIds` array:
```typescript
const mainPhaseIds = ['welcome', 'horizon_intro', 'pitch_intro', 'speed_intro', 'speed_explain', 'speed_hold_coarse', 'speed_hold_fine', 'alt_intro', 'vs_intro', 'heading_intro', 'combined', 'free_practice', 'end']
```
**Step 5: Verify the full flow**
Run: `bun run dev`
Walk through all phases from welcome to end. Verify:
- Speed explain shows text + "Verstanden" button
- Coarse hold has wide band, 5s hold requirement
- Fine hold has narrow band, 8s hold requirement
- Progress bar advances smoothly through new phases
**Step 6: Commit**
```
git add shared/data/flightlab/learn-pfd.ts shared/composables/flightlab/useLearnPfdEngine.ts
git commit -m "feat(flightlab): multi-phase speed exercise with progressive difficulty"
```
---
### Task 9: Final visual verification
**Step 1: Full walkthrough**
Run: `bun run dev`
Navigate to `/flightlab/medienstationen/learn-pfd`.
Walk through every phase and verify:
- All tapes have dark backgrounds
- Green readouts on speed and altitude
- Yellow heading readout
- Attitude has sky/ground gradients
- Aircraft symbol has W-shape
- Flight physics feel sluggish and realistic
- Pitch/bank exercises use small targets
- Speed exercise teaches energy management in 3 steps
**Step 2: Commit any final tweaks**
If color adjustments needed for contrast/readability on dark backgrounds, adjust and commit.

View File

@@ -24,13 +24,14 @@ const MAX_PITCH_UP = 30
const MAX_PITCH_DOWN = -15
const MAX_BANK = 67
const BANK_NEUTRAL_LIMIT = 33
const MAX_ROLL_RATE = 15
const ROLL_RETURN_RATE = 5
const MAX_ROLL_RATE = 7
const ROLL_RETURN_RATE = 2
const MAX_G_PULL = 2.5
const MIN_G_PUSH = -1.0
const NEUTRAL_G = 1.0
const PITCH_RATE_PER_G_DELTA = 3.5
const PITCH_RATE_PER_G_DELTA = 1.2
const PITCH_SMOOTH_TAU = 2.0 // seconds — exponential smoothing time constant
const IDLE_THRUST = 2000
const MAX_THRUST = 50000
@@ -39,7 +40,7 @@ const MASS = 150000
const GRAVITY = 32.174
const KT_TO_FPS = 1.68781
const TRIM_PITCH_DEG = 2
const SPEED_PITCH_COUPLING = 1.2
const SPEED_PITCH_COUPLING = 2.0
const INITIAL_SPEED = 220
const INITIAL_ALTITUDE = 5000
@@ -63,6 +64,7 @@ export function useAirbusFBW() {
let animFrame: number | null = null
let lastTime: number | null = null
let running = false
let smoothedPitchRate = 0
function updateInput(newInput: Partial<StickInput>) {
if (newInput.pitch !== undefined) input.pitch = clamp(newInput.pitch, -1, 1)
@@ -84,6 +86,7 @@ export function useAirbusFBW() {
state.throttlePercent = 50
state.onGround = false
lastTime = null
smoothedPitchRate = 0
}
function tick(timestamp: number) {
@@ -126,8 +129,10 @@ export function useAirbusFBW() {
const currentG = speedFps > 50 ? 1 + (state.pitch * Math.PI / 180) * speedFps / GRAVITY * 0.01 : 1
const gDelta = targetG - currentG
const pitchRate = gDelta * PITCH_RATE_PER_G_DELTA
state.pitch += pitchRate * dt
const rawPitchRate = gDelta * PITCH_RATE_PER_G_DELTA
const alpha = 1 - Math.exp(-dt / PITCH_SMOOTH_TAU)
smoothedPitchRate += (rawPitchRate - smoothedPitchRate) * alpha
state.pitch += smoothedPitchRate * dt
state.pitch = clamp(state.pitch, MAX_PITCH_DOWN, MAX_PITCH_UP)
// --- Speed ---

View File

@@ -3,15 +3,16 @@ 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', 'speed_hold_pitch', 'alt_intro', 'vs_intro', 'heading_intro', 'combined', 'free_practice', 'end']
const mainPhaseIds = ['welcome', 'horizon_intro', 'pitch_intro', 'speed_intro', 'speed_explain', 'speed_hold_coarse', 'speed_hold_fine', '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': 'speed_hold_pitch',
'speed_hold_pitch': 'alt_intro',
'speed_intro': 'speed_explain',
'speed_hold_coarse': 'speed_hold_fine',
'speed_hold_fine': 'alt_intro',
'alt_intro': 'vs_intro',
'vs_intro': 'heading_intro',
'heading_intro': 'combined',

View File

@@ -16,13 +16,13 @@ const phases: LearnPfdPhase[] = [
// --- Phase 2: Horizon Intro (bank left) ---
{
id: 'horizon_intro',
atcMessage: 'Das hier ist der künstliche Horizont. Blau ist der Himmel, braun ist der Boden. Genau wie draußen. Beweg jetzt den Stick nach links — und schau was passiert.',
atcMessage: 'Das hier ist der künstliche Horizont. Blau ist der Himmel, braun ist der Boden. Genau wie draußen. Beweg jetzt den Stick ganz sanft nach links — nur ein kleines bisschen. Im echten Flugzeug macht man immer nur kleine, sanfte Bewegungen.',
explanation: 'Der künstliche Horizont zeigt dir die Lage des Flugzeugs im Raum.',
visibleElements: ['attitude'],
layoutMode: 'model-focus',
interactionGoal: { parameter: 'bankAngle', target: -20, tolerance: 15, holdMs: 1500 },
interactionGoal: { parameter: 'bankAngle', target: -10, tolerance: 8, holdMs: 1500 },
goalTimeoutMs: 20000,
goalHint: 'Versuch den Stick nach links zu drücken. Du solltest sehen, wie sich der Horizont dreht.',
goalHint: 'Drück den Stick ganz leicht nach links. Nur ein kleiner Ausschlag reicht.',
autoAdvanceAfterTTS: false,
buttons: [
{ id: 'skip_horizon', label: 'Weiter', icon: 'mdi-arrow-right', next: 'pitch_intro', type: 'primary' },
@@ -32,12 +32,12 @@ const phases: LearnPfdPhase[] = [
// --- Phase 3: Horizon Roll Right ---
{
id: 'horizon_roll_right',
atcMessage: 'Perfekt! Du hast das Flugzeug nach links geneigt. Jetzt andersrum — Stick nach rechts.',
atcMessage: 'Perfekt! Jetzt ganz sanft in die andere Richtung — Stick leicht nach rechts.',
visibleElements: ['attitude'],
layoutMode: 'model-focus',
interactionGoal: { parameter: 'bankAngle', target: 20, tolerance: 15, holdMs: 1500 },
interactionGoal: { parameter: 'bankAngle', target: 10, tolerance: 8, holdMs: 1500 },
goalTimeoutMs: 15000,
goalHint: 'Stick nach rechts drücken, bis der Horizont sich nach links neigt.',
goalHint: 'Stick ganz leicht nach rechts. Kleine, sanfte Bewegungen.',
buttons: [
{ id: 'skip_roll', label: 'Weiter', icon: 'mdi-arrow-right', next: 'pitch_intro', type: 'primary' },
],
@@ -46,13 +46,13 @@ const phases: LearnPfdPhase[] = [
// --- Phase 4: Pitch Intro (pitch up) ---
{
id: 'pitch_intro',
atcMessage: 'Jetzt kommt was Wichtiges. Zieh den Stick leicht nach hinten. Die Nase geht hoch. Und das Besondere beim Airbus: Wenn du loslässt, hält er die Lage. Probier es aus.',
atcMessage: 'Jetzt kommt was Wichtiges. Zieh den Stick ganz leicht nach hinten. Nur ein kleines bisschen. Das Besondere beim Airbus: Wenn du loslässt, hält er die Lage. Und das Flugzeug reagiert langsam — du musst immer vorausdenken.',
explanation: 'Beim Airbus steuert der Stick nicht direkt die Ruder, sondern wie stark das Flugzeug steigen oder sinken soll.',
visibleElements: ['attitude'],
layoutMode: 'model-focus',
interactionGoal: { parameter: 'pitch', target: 10, tolerance: 5, holdMs: 2000 },
interactionGoal: { parameter: 'pitch', target: 4, tolerance: 3, holdMs: 2000 },
goalTimeoutMs: 20000,
goalHint: 'Zieh den Stick langsam nach hinten. Du siehst die braune Fläche größer werden — das heißt, die Nase zeigt nach oben.',
goalHint: 'Zieh den Stick nur ganz leicht nach hinten. Beim echten Airbus macht man nur minimale Bewegungen.',
buttons: [
{ id: 'skip_pitch', label: 'Weiter', icon: 'mdi-arrow-right', next: 'pitch_down', type: 'primary' },
],
@@ -61,18 +61,18 @@ const phases: LearnPfdPhase[] = [
// --- Phase 5: Pitch Down ---
{
id: 'pitch_down',
atcMessage: 'Gut gemacht! Jetzt drück den Stick leicht nach vorne. Die Nase geht runter.',
atcMessage: 'Gut gemacht! Jetzt drück den Stick ganz leicht nach vorne. Auch hier gilt: kleine Bewegungen, und Geduld — das Flugzeug braucht ein paar Sekunden bis es reagiert.',
visibleElements: ['attitude'],
layoutMode: 'model-focus',
interactionGoal: { parameter: 'pitch', target: -5, tolerance: 5, holdMs: 1500 },
interactionGoal: { parameter: 'pitch', target: -3, tolerance: 3, holdMs: 1500 },
goalTimeoutMs: 15000,
goalHint: 'Stick nach vorne drücken. Die blaue Fläche wird größer.',
goalHint: 'Stick ganz leicht nach vorne drücken und warten. Das Flugzeug reagiert mit Verzögerung.',
buttons: [
{ id: 'skip_pitch_down', label: 'Weiter', icon: 'mdi-arrow-right', next: 'speed_intro', type: 'primary' },
],
},
// --- Phase 6: Speed Intro ---
// --- Phase 6: Speed Intro (set throttle) ---
{
id: 'speed_intro',
atcMessage: 'Links erscheint jetzt das Speed Tape. Erster Schritt: Setz den Schubhebel sauber auf siebzig Prozent.',
@@ -83,23 +83,53 @@ const phases: LearnPfdPhase[] = [
goalTimeoutMs: 20000,
goalHint: 'Schubhebel auf die 70%-Marke setzen und kurz halten.',
buttons: [
{ id: 'skip_speed', label: 'Weiter', icon: 'mdi-arrow-right', next: 'speed_hold_pitch', type: 'primary' },
{ id: 'skip_speed', label: 'Weiter', icon: 'mdi-arrow-right', next: 'speed_explain', type: 'primary' },
],
},
// --- Phase 7: Hold Speed With Pitch ---
// --- Phase 7a: Speed Explain ---
{
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.',
id: 'speed_explain',
atcMessage: 'Bevor wir loslegen, eine wichtige Regel: Beim Airbus kontrollierst du die Geschwindigkeit mit der Nase. Nase hoch — du wirst langsamer. Nase runter — du wirst schneller. Der Schub bleibt dabei immer gleich. Das nennt man Energie-Management.',
explanation: 'Im echten Flug hält der Autopilot den Schub konstant. Der Pilot steuert die Geschwindigkeit über den Pitch.',
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' },
{ id: 'understood', label: 'Verstanden', icon: 'mdi-check', next: 'speed_hold_coarse', type: 'primary' },
],
},
// --- Phase 7b: Speed Hold Coarse ---
{
id: 'speed_hold_coarse',
atcMessage: 'Gut, Schub steht. Jetzt versuch die Geschwindigkeit in den türkisen Bereich zu bringen. Nase ganz leicht hoch oder runter — und dann warten. Das Flugzeug braucht mehrere Sekunden bis sich die Speed ändert.',
explanation: 'Erste Annäherung: Bringe die Speed grob in den Zielbereich.',
visibleElements: ['attitude', 'speedTape'],
layoutMode: 'split',
interactionGoal: { parameter: 'speed', target: 225, tolerance: 10, holdMs: 5000 },
speedTargetRange: { min: 215, max: 235 },
goalTimeoutMs: 30000,
goalHint: 'Korrigiere nur mit ganz kleinen Pitch-Bewegungen. Die Speed ändert sich langsam — hab Geduld.',
autoAdvanceAfterTTS: false,
buttons: [
{ id: 'skip_coarse', label: 'Weiter', icon: 'mdi-arrow-right', next: 'speed_hold_fine', type: 'primary' },
],
},
// --- Phase 7c: Speed Hold Fine ---
{
id: 'speed_hold_fine',
atcMessage: 'Sehr gut! Jetzt genauer. Halte die Geschwindigkeit möglichst exakt auf 225 Knoten. Nur winzige Korrekturen — und immer ein paar Sekunden warten bevor du nachkorrigierst.',
explanation: 'Feinsteuerung: Präzise Speed-Kontrolle mit minimalen Pitch-Änderungen.',
visibleElements: ['attitude', 'speedTape'],
layoutMode: 'split',
interactionGoal: { parameter: 'speed', target: 225, tolerance: 5, holdMs: 8000 },
speedTargetRange: { min: 220, max: 230 },
goalTimeoutMs: 40000,
goalHint: 'Ganz kleine Pitch-Korrekturen. Warte nach jeder Korrektur 3-4 Sekunden bevor du nachjustierst.',
autoAdvanceAfterTTS: false,
buttons: [
{ id: 'skip_fine', label: 'Weiter', icon: 'mdi-arrow-right', next: 'alt_intro', type: 'primary' },
],
},