mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 16:22:48 +08:00
feat(learn-pfd): add pitch-based speed hold step and target speed zones
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user