mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
Translate learn page and add audio speed control
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
</button>
|
||||
<span class="brand">OpenSquawk</span>
|
||||
<span class="sep">|</span>
|
||||
<span class="mode">Lernwelt</span>
|
||||
<span class="mode">Training</span>
|
||||
</div>
|
||||
|
||||
<div class="hud-right">
|
||||
@@ -20,22 +20,34 @@
|
||||
<!-- Quick ATC: Voice & Level -->
|
||||
<div class="chip inline">
|
||||
<v-icon size="16" class="text-accent">mdi-radio-handheld</v-icon>
|
||||
<span class="ml-1">L {{ cfg.radioLevel }}</span>
|
||||
<input type="range" min="1" max="5" step="1" v-model.number="cfg.radioLevel"/>
|
||||
<span class="ml-1">Level {{ cfg.radioLevel }}</span>
|
||||
<input type="range" min="1" max="5" step="1" v-model.number="cfg.radioLevel" aria-label="Radio level"/>
|
||||
</div>
|
||||
<div class="chip inline">
|
||||
<v-icon size="16" class="text-accent">mdi-speedometer</v-icon>
|
||||
<span class="ml-1">Speed {{ cfg.audioSpeed.toFixed(1) }}x</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.7"
|
||||
max="1.3"
|
||||
step="0.1"
|
||||
v-model.number="cfg.audioSpeed"
|
||||
aria-label="ATC audio speed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="btn ghost" @click="panel='progress'">
|
||||
<v-icon size="18">mdi-chart-line</v-icon>
|
||||
Fortschritt
|
||||
Progress
|
||||
</button>
|
||||
|
||||
<!-- ATC Einstellungen -->
|
||||
<button class="btn ghost" @click="showSettings=true" title="ATC-Einstellungen">
|
||||
<button class="btn ghost" @click="showSettings=true" title="ATC settings">
|
||||
<v-icon size="18">mdi-tune</v-icon>
|
||||
ATC
|
||||
</button>
|
||||
|
||||
<button class="btn ghost" @click="resetAll" title="Zurücksetzen">
|
||||
<button class="btn ghost" @click="resetAll" title="Reset">
|
||||
<v-icon size="18">mdi-refresh</v-icon>
|
||||
Reset
|
||||
</button>
|
||||
@@ -49,16 +61,16 @@
|
||||
<div class="panel hero-panel" :style="worldTiltStyle" @mousemove="tilt">
|
||||
<div class="hero-left">
|
||||
<div class="eyebrow">ALPHA BUILD · TRAINING</div>
|
||||
<h1 class="h1">ATC Lernwelt</h1>
|
||||
<p class="muted">Geführte Pfade · Missionen · XP · Abzeichen. Lokal gespeichert.</p>
|
||||
<h1 class="h1">ATC Training World</h1>
|
||||
<p class="muted">Guided paths · missions · XP · badges. Stored locally.</p>
|
||||
<div class="actions">
|
||||
<button class="btn primary" @click="panel='hub'">
|
||||
<v-icon>mdi-play</v-icon>
|
||||
Losfliegen
|
||||
Start Training
|
||||
</button>
|
||||
<button class="btn ghost" @click="panel='progress'">
|
||||
<v-icon>mdi-progress-check</v-icon>
|
||||
Fortschritt
|
||||
Progress
|
||||
</button>
|
||||
<button class="btn ghost" @click="testBeep">
|
||||
<v-icon>mdi-volume-high</v-icon>
|
||||
@@ -86,7 +98,7 @@
|
||||
</div>
|
||||
<div class="card-title">{{ d.title }}</div>
|
||||
<div class="card-sub muted">{{ d.sub }}</div>
|
||||
<button class="btn mini soft" @click="startDaily(d)">Annehmen</button>
|
||||
<button class="btn mini soft" @click="startDaily(d)">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +110,7 @@
|
||||
<main v-if="panel==='hub'" class="container" role="main">
|
||||
<div class="hub-head">
|
||||
<h2 class="h2">Mission Hub</h2>
|
||||
<div class="muted">Starte mit ICAO Alphabet & Zahlen, dann Basics, Ground …</div>
|
||||
<div class="muted">Start with the ICAO alphabet & numbers, then basics, ground, and beyond.</div>
|
||||
</div>
|
||||
|
||||
<div class="tiles">
|
||||
@@ -128,7 +140,7 @@
|
||||
</button>
|
||||
<button class="btn soft" @click="quickContinue(m.id)">
|
||||
<v-icon size="18">mdi-skip-next</v-icon>
|
||||
Weiter
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +149,7 @@
|
||||
</main>
|
||||
|
||||
<!-- MODULE -->
|
||||
<section v-if="panel==='module' && current" class="container play" aria-label="Modul">
|
||||
<section v-if="panel==='module' && current" class="container play" aria-label="Module">
|
||||
<div class="play-head">
|
||||
<div class="crumbs">
|
||||
<button class="link" @click="panel='hub'">
|
||||
@@ -168,7 +180,7 @@
|
||||
{{ l.title }}
|
||||
</div>
|
||||
<div class="chip" :class="{ ok: bestScore(current.id,l.id)>=80 }">
|
||||
{{ bestScore(current.id, l.id) ? bestScore(current.id, l.id) + '%' : 'neu' }}
|
||||
{{ bestScore(current.id, l.id) ? bestScore(current.id, l.id) + '%' : 'new' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted small">{{ l.desc }}</div>
|
||||
@@ -186,12 +198,12 @@
|
||||
<div class="scenario-sub">{{ scenario.radioCall }}</div>
|
||||
</div>
|
||||
<div class="scenario-item">
|
||||
<span class="scenario-label">Flugplatz</span>
|
||||
<span class="scenario-label">Airport</span>
|
||||
<div class="scenario-value">{{ scenario.airport.icao }}</div>
|
||||
<div class="scenario-sub">{{ scenario.airport.name }}</div>
|
||||
</div>
|
||||
<div class="scenario-item wide">
|
||||
<span class="scenario-label">Frequenzen</span>
|
||||
<span class="scenario-label">Frequencies</span>
|
||||
<div class="freq-chips">
|
||||
<button
|
||||
v-for="freq in scenario.frequencies"
|
||||
@@ -224,7 +236,7 @@
|
||||
{{ targetPhrase }}
|
||||
</div>
|
||||
<div v-if="audioContentHidden" class="audio-note muted small">
|
||||
Audio Challenge aktiv – zuerst anhören.
|
||||
Audio Challenge active – listen first.
|
||||
</div>
|
||||
</div>
|
||||
<div class="row wrap">
|
||||
@@ -245,11 +257,11 @@
|
||||
@click="revealAudioContent"
|
||||
>
|
||||
<v-icon size="16">mdi-eye</v-icon>
|
||||
Anzeigen
|
||||
Show
|
||||
</button>
|
||||
<button class="btn ghost mini" type="button" @click="rollScenario(true)">
|
||||
<v-icon size="16">mdi-dice-5</v-icon>
|
||||
Würfeln
|
||||
Roll
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,7 +284,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="label">Deine Readback</div>
|
||||
<div class="label">Your Readback</div>
|
||||
<div class="panel readback-panel">
|
||||
<div class="cloze">
|
||||
<template v-for="(segment, idx) in activeLesson.readback" :key="segment.type === 'field' ? `f-${segment.key}` : `t-${idx}`">
|
||||
@@ -294,7 +306,7 @@
|
||||
<v-icon v-if="fieldPass(segment.key)" size="16" class="blank-status ok">mdi-check</v-icon>
|
||||
<v-icon v-else-if="fieldHasAnswer(segment.key)" size="16" class="blank-status warn">mdi-alert</v-icon>
|
||||
<small v-if="result" class="blank-feedback">
|
||||
Soll: {{ fieldExpectedValue(segment.key) }}
|
||||
Expected: {{ fieldExpectedValue(segment.key) }}
|
||||
</small>
|
||||
</label>
|
||||
</template>
|
||||
@@ -303,7 +315,7 @@
|
||||
<div class="row wrap controls">
|
||||
<button class="btn primary" type="button" :disabled="evaluating" @click="evaluate">
|
||||
<v-icon size="18">mdi-check</v-icon>
|
||||
Prüfen
|
||||
Check
|
||||
</button>
|
||||
<button class="btn soft" type="button" @click="clearAnswers">
|
||||
<v-icon size="18">mdi-eraser</v-icon>
|
||||
@@ -311,20 +323,29 @@
|
||||
</button>
|
||||
<button class="btn ghost" type="button" @click="fillSolution">
|
||||
<v-icon size="18">mdi-auto-fix</v-icon>
|
||||
Autotext
|
||||
Autofill
|
||||
</button>
|
||||
<button
|
||||
v-if="result"
|
||||
class="btn soft"
|
||||
type="button"
|
||||
@click="goToNextLesson"
|
||||
>
|
||||
<v-icon size="18">mdi-arrow-right-bold</v-icon>
|
||||
{{ nextLesson ? 'Next Lesson' : 'Back to Hub' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="result" class="score">
|
||||
<div class="score-num">{{ result.score }}%</div>
|
||||
<div class="muted small">
|
||||
Felder korrekt: {{ result.hits }}/{{ activeLesson.fields.length }} · Ähnlichkeit: {{ Math.round(result.sim * 100) }}%
|
||||
Fields correct: {{ result.hits }}/{{ activeLesson.fields.length }} · Similarity: {{ Math.round(result.sim * 100) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="result" class="field-checks">
|
||||
<div v-for="field in result.fields" :key="field.key" class="field-check" :class="{ ok: field.pass }">
|
||||
<div class="field-name">{{ field.label }}</div>
|
||||
<div class="field-answer">{{ field.answer || '—' }}</div>
|
||||
<div class="field-expected">Soll: {{ field.expected }}</div>
|
||||
<div class="field-expected">Expected: {{ field.expected }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,8 +353,8 @@
|
||||
</section>
|
||||
|
||||
<!-- PROGRESS -->
|
||||
<section v-if="panel==='progress'" class="container" aria-label="Fortschritt">
|
||||
<h2 class="h2">Gesamtfortschritt</h2>
|
||||
<section v-if="panel==='progress'" class="container" aria-label="Progress">
|
||||
<h2 class="h2">Overall Progress</h2>
|
||||
<div class="progress-grid">
|
||||
<div v-for="m in modules" :key="m.id" class="panel">
|
||||
<div class="row between">
|
||||
@@ -353,13 +374,13 @@
|
||||
<!-- SETTINGS DIALOG -->
|
||||
<v-dialog v-model="showSettings" max-width="720">
|
||||
<div class="panel dialog">
|
||||
<h3 class="h3">ATC Einstellungen</h3>
|
||||
<h3 class="h3">ATC Settings</h3>
|
||||
|
||||
<div class="settings">
|
||||
<div class="set-row">
|
||||
<div class="set-info">
|
||||
<span>Browser-TTS (Web Speech)</span>
|
||||
<small class="muted">Läuft offline, startet schneller und spart unsere API-Anfragen.</small>
|
||||
<small class="muted">Works offline, starts faster, and saves our API calls.</small>
|
||||
</div>
|
||||
<v-switch v-model="cfg.tts" color="cyan" hide-details inset/>
|
||||
</div>
|
||||
@@ -367,38 +388,53 @@
|
||||
<div class="set-row">
|
||||
<div class="set-info">
|
||||
<span>Audio Challenge</span>
|
||||
<small class="muted">Blendet Zieltext & Infos, bis du sie manuell einblendest.</small>
|
||||
<small class="muted">Hides target text & details until you reveal them manually.</small>
|
||||
</div>
|
||||
<v-switch v-model="cfg.audioChallenge" color="cyan" hide-details inset/>
|
||||
</div>
|
||||
|
||||
<div class="set-row">
|
||||
<span>Funk-Level (1..5)</span>
|
||||
<span>Radio level (1..5)</span>
|
||||
<v-slider v-model="cfg.radioLevel" :min="1" :max="5" :step="1" color="cyan" thumb-label/>
|
||||
</div>
|
||||
|
||||
<div class="set-row">
|
||||
<div class="set-info">
|
||||
<span>ATC audio speed</span>
|
||||
<small class="muted">Fine-tune the playback speed for ATC voice lines.</small>
|
||||
</div>
|
||||
<v-slider
|
||||
v-model="cfg.audioSpeed"
|
||||
:min="0.7"
|
||||
:max="1.3"
|
||||
:step="0.1"
|
||||
color="cyan"
|
||||
thumb-label
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="set-row">
|
||||
<span>Voice</span>
|
||||
<v-text-field v-model="cfg.voice" placeholder="alloy" hide-details density="compact" variant="outlined"/>
|
||||
</div>
|
||||
|
||||
<div class="set-row">
|
||||
<span>Probe-TTS</span>
|
||||
<span>Test TTS</span>
|
||||
<div class="row">
|
||||
<button class="btn soft" @click="say('Frankfurt Ground, Lufthansa one two three, request taxi.')">
|
||||
<v-icon>mdi-volume-high</v-icon>
|
||||
Probe
|
||||
Test
|
||||
</button>
|
||||
<button class="btn ghost" @click="stopAudio">
|
||||
<v-icon>mdi-stop</v-icon>
|
||||
Stopp
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row end">
|
||||
<button class="btn soft" @click="showSettings=false">Schließen</button>
|
||||
<button class="btn soft" @click="showSettings=false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</v-dialog>
|
||||
@@ -1014,17 +1050,17 @@ const modules = ref<ModuleDef[]>([
|
||||
lessons: [
|
||||
{
|
||||
id: 'icao-alphabet',
|
||||
title: 'ICAO Alphabet & Zahlen',
|
||||
desc: 'Buchstaben und Ziffern sauber buchstabieren',
|
||||
title: 'ICAO Alphabet & Numbers',
|
||||
desc: 'Spell letters and digits clearly',
|
||||
keywords: ['Alphabet', 'Numbers', 'Normalize'],
|
||||
hints: [
|
||||
'Buchstabiere jeden Buchstaben mit dem ICAO-Namen (z. B. Delta, Lima, Hotel).',
|
||||
'Ziffern im Funk: tree, fower, fife, niner.'
|
||||
'Spell each letter with the ICAO name (e.g. Delta, Lima, Hotel).',
|
||||
'Digits on the radio: tree, fower, fife, niner.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
key: 'letters',
|
||||
label: 'Buchstaben',
|
||||
label: 'Letters',
|
||||
expected: scenario => scenario.callsignNato,
|
||||
placeholder: 'Delta Lima Hotel',
|
||||
width: 'xl',
|
||||
@@ -1032,7 +1068,7 @@ const modules = ref<ModuleDef[]>([
|
||||
},
|
||||
{
|
||||
key: 'digits',
|
||||
label: 'Zahlen',
|
||||
label: 'Digits',
|
||||
expected: scenario => scenario.flightNumberWords,
|
||||
placeholder: 'one two three',
|
||||
width: 'lg',
|
||||
@@ -1040,7 +1076,7 @@ const modules = ref<ModuleDef[]>([
|
||||
}
|
||||
],
|
||||
readback: [
|
||||
{ type: 'text', text: 'Rufezeichen: ' },
|
||||
{ type: 'text', text: 'Callsign: ' },
|
||||
{ type: 'field', key: 'letters', width: 'lg' },
|
||||
{ type: 'text', text: ' · ' },
|
||||
{ type: 'field', key: 'digits', width: 'md' }
|
||||
@@ -1048,21 +1084,21 @@ const modules = ref<ModuleDef[]>([
|
||||
defaultFrequency: 'DEL',
|
||||
phrase: scenario => `${scenario.callsignNato} ${scenario.flightNumberWords}`,
|
||||
info: scenario => [
|
||||
`Kennzeichen: ${scenario.callsign}`,
|
||||
`Airline Call: ${scenario.radioCall}`,
|
||||
`Registration: ${scenario.callsign}`,
|
||||
`Airline call: ${scenario.radioCall}`,
|
||||
`ICAO: ${scenario.callsignNato}`,
|
||||
`Flightnummer gesprochen: ${scenario.flightNumberWords}`
|
||||
`Flight number spoken: ${scenario.flightNumberWords}`
|
||||
],
|
||||
generate: createBaseScenario
|
||||
},
|
||||
{
|
||||
id: 'atis',
|
||||
title: 'ATIS verstehen',
|
||||
desc: 'Kennung und Kerndaten aus der ATIS ziehen',
|
||||
keywords: ['ATIS', 'Wetter'],
|
||||
title: 'Understand ATIS',
|
||||
desc: 'Extract the identifier and core data from the ATIS',
|
||||
keywords: ['ATIS', 'Weather'],
|
||||
hints: [
|
||||
'ATIS-Kennung als einzelnen Buchstaben merken.',
|
||||
'Reihenfolge: Runway – Wind – Sicht – Temperatur – Taupunkt – QNH.'
|
||||
'Remember the ATIS designator as a single letter.',
|
||||
'Order: runway – wind – visibility – temperature – dew point – QNH.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1074,7 +1110,7 @@ const modules = ref<ModuleDef[]>([
|
||||
scenario.atisCode.toLowerCase(),
|
||||
`Information ${scenario.atisCode}`
|
||||
],
|
||||
placeholder: 'Buchstabe',
|
||||
placeholder: 'Letter',
|
||||
width: 'xs',
|
||||
threshold: 0.9
|
||||
},
|
||||
@@ -1098,21 +1134,21 @@ const modules = ref<ModuleDef[]>([
|
||||
},
|
||||
{
|
||||
key: 'atis-visibility',
|
||||
label: 'Sicht',
|
||||
label: 'Visibility',
|
||||
expected: scenario => scenario.atisSummary.visibility,
|
||||
alternatives: scenario => [scenario.visibility, scenario.visibilityWords],
|
||||
width: 'sm'
|
||||
},
|
||||
{
|
||||
key: 'atis-temp',
|
||||
label: 'Temperatur',
|
||||
label: 'Temperature',
|
||||
expected: scenario => scenario.atisSummary.temperature,
|
||||
alternatives: scenario => [scenario.temperatureWords],
|
||||
width: 'sm'
|
||||
},
|
||||
{
|
||||
key: 'atis-dew',
|
||||
label: 'Taupunkt',
|
||||
label: 'Dew point',
|
||||
expected: scenario => scenario.atisSummary.dewpoint,
|
||||
alternatives: scenario => [scenario.dewpointWords],
|
||||
width: 'sm'
|
||||
@@ -1144,19 +1180,19 @@ const modules = ref<ModuleDef[]>([
|
||||
defaultFrequency: 'ATIS',
|
||||
phrase: scenario => scenario.atisText,
|
||||
info: () => [
|
||||
'Notiere Kennung, Runway, Wind, Sichtweite, Temperatur, Taupunkt und QNH.',
|
||||
'Sicht: vier Ziffern oder 9999 für ≥10 km · QNH als vierstellige Zahl.'
|
||||
'Take note of the designator, runway, wind, visibility, temperature, dew point, and QNH.',
|
||||
'Visibility: four digits or 9999 for ≥10 km · QNH as a four-digit number.'
|
||||
],
|
||||
generate: createBaseScenario
|
||||
},
|
||||
{
|
||||
id: 'metar',
|
||||
title: 'METAR entschlüsseln',
|
||||
desc: 'Rohe METAR-Werte extrahieren',
|
||||
title: 'Decode METAR',
|
||||
desc: 'Extract raw METAR values.',
|
||||
keywords: ['METAR', 'Weather'],
|
||||
hints: [
|
||||
'METAR blockweise lesen: Wind – Sicht – Wolken – Temperatur – QNH.',
|
||||
'Der Temperaturblock hat die Form 18/10, Minuswerte beginnen mit M.'
|
||||
'Read METARs in blocks: wind – visibility – clouds – temperature – QNH.',
|
||||
'The temperature block looks like 18/10, negative values start with M.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1172,7 +1208,7 @@ const modules = ref<ModuleDef[]>([
|
||||
},
|
||||
{
|
||||
key: 'metar-vis',
|
||||
label: 'Sicht',
|
||||
label: 'Visibility',
|
||||
expected: scenario => scenario.metarSegments.visibility,
|
||||
alternatives: scenario => [scenario.visibility],
|
||||
placeholder: '9999',
|
||||
@@ -1199,9 +1235,9 @@ const modules = ref<ModuleDef[]>([
|
||||
readback: [
|
||||
{ type: 'text', text: 'Wind ' },
|
||||
{ type: 'field', key: 'metar-wind', width: 'md' },
|
||||
{ type: 'text', text: ', Sicht ' },
|
||||
{ type: 'text', text: ', visibility ' },
|
||||
{ type: 'field', key: 'metar-vis', width: 'sm' },
|
||||
{ type: 'text', text: ', Temperatur ' },
|
||||
{ type: 'text', text: ', temperature ' },
|
||||
{ type: 'field', key: 'metar-temp', width: 'md' },
|
||||
{ type: 'text', text: ', QNH ' },
|
||||
{ type: 'field', key: 'metar-qnh', width: 'sm' }
|
||||
@@ -1210,18 +1246,18 @@ const modules = ref<ModuleDef[]>([
|
||||
phrase: scenario => scenario.metar,
|
||||
info: scenario => [
|
||||
`METAR: ${scenario.metar}`,
|
||||
`Interpretation: Wind ${scenario.metarSegments.wind}, Sicht ${scenario.visibilityWords}, Temperatur ${scenario.temperature}°C, QNH ${scenario.qnh}`
|
||||
`Interpretation: Wind ${scenario.metarSegments.wind}, visibility ${scenario.visibilityWords}, temperature ${scenario.temperature}°C, QNH ${scenario.qnh}`
|
||||
],
|
||||
generate: createBaseScenario
|
||||
},
|
||||
{
|
||||
id: 'radio-check',
|
||||
title: 'Radio Check',
|
||||
desc: 'Lesbarkeit melden',
|
||||
desc: 'Report readability',
|
||||
keywords: ['Ground', 'Comms'],
|
||||
hints: [
|
||||
'Antwort besteht aus "Readability" plus Zahl.',
|
||||
'Beende den Check immer mit deinem Callsign.'
|
||||
'Reply with "Readability" plus the number.',
|
||||
'Always end the check with your callsign.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1238,7 +1274,7 @@ const modules = ref<ModuleDef[]>([
|
||||
},
|
||||
{
|
||||
key: 'rc-readability',
|
||||
label: 'Lesbarkeit',
|
||||
label: 'Readability',
|
||||
expected: scenario => scenario.readabilityWord,
|
||||
alternatives: scenario => [scenario.readability.toString(), scenario.readabilityWord],
|
||||
placeholder: 'five',
|
||||
@@ -1254,8 +1290,8 @@ const modules = ref<ModuleDef[]>([
|
||||
defaultFrequency: 'GND',
|
||||
phrase: scenario => `${scenario.airport.name} Ground, ${scenario.radioCall}, radio check on ${scenario.groundFreq}.`,
|
||||
info: scenario => [
|
||||
`Frequenz: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`,
|
||||
`Erwartete Antwort: Readability ${scenario.readability} (${scenario.readabilityWord})`
|
||||
`Frequency: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`,
|
||||
`Expected answer: readability ${scenario.readability} (${scenario.readabilityWord})`
|
||||
],
|
||||
generate: createBaseScenario
|
||||
}
|
||||
@@ -1264,17 +1300,17 @@ const modules = ref<ModuleDef[]>([
|
||||
{
|
||||
id: 'arc',
|
||||
title: 'ARC Decision Tree',
|
||||
subtitle: 'Vom Clearance Call bis Departure',
|
||||
subtitle: 'From the clearance call to departure',
|
||||
art: gradientArt(['#f97316', '#fb923c', '#0f172a']),
|
||||
lessons: [
|
||||
{
|
||||
id: 'clearance-contact',
|
||||
title: 'Delivery: Erstkontakt',
|
||||
desc: 'Clearance Delivery kontaktieren',
|
||||
title: 'Delivery: Initial contact',
|
||||
desc: 'Call clearance delivery',
|
||||
keywords: ['Delivery', 'Clearance'],
|
||||
hints: [
|
||||
'Reihenfolge: Einheit, Callsign, ATIS, Ziel, Stand, Request.',
|
||||
'ATIS als einzelner Buchstabe sprechen.'
|
||||
'Order: unit, callsign, ATIS, destination, stand, request.',
|
||||
'Say the ATIS as a single letter.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1319,7 +1355,7 @@ const modules = ref<ModuleDef[]>([
|
||||
phrase: scenario => `${scenario.airport.city} Delivery, ${scenario.radioCall}, information ${scenario.atisCode}, IFR to ${scenario.destination.city}, stand ${scenario.stand}, request clearance.`,
|
||||
info: scenario => [
|
||||
`ATIS ${scenario.atisCode}`,
|
||||
`Ziel: ${scenario.destination.city} (${scenario.destination.icao})`,
|
||||
`Destination: ${scenario.destination.city} (${scenario.destination.icao})`,
|
||||
`Stand: ${scenario.stand}`
|
||||
],
|
||||
generate: createBaseScenario
|
||||
@@ -1327,11 +1363,11 @@ const modules = ref<ModuleDef[]>([
|
||||
{
|
||||
id: 'clearance-readback',
|
||||
title: 'Clearance Readback',
|
||||
desc: 'Freigabe komplett zurücklesen',
|
||||
desc: 'Read back the clearance completely',
|
||||
keywords: ['Delivery', 'Readback'],
|
||||
hints: [
|
||||
'Reihenfolge merken: Ziel – SID – Runway – Altitude – Squawk.',
|
||||
'Zahlen bei Altitude und Squawk sauber ausschreiben.'
|
||||
'Remember the sequence: destination – SID – runway – altitude – squawk.',
|
||||
'Spell out the altitude and squawk digits clearly.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1393,11 +1429,11 @@ const modules = ref<ModuleDef[]>([
|
||||
{
|
||||
id: 'pushback',
|
||||
title: 'Push & Start Readback',
|
||||
desc: 'Pushback-Freigabe bestätigen',
|
||||
desc: 'Confirm the pushback clearance',
|
||||
keywords: ['Ground', 'Pushback'],
|
||||
hints: [
|
||||
'Bahnrichtung und QNH übernehmen, Callsign ans Ende.',
|
||||
'QNH darf als Zahl oder "QNH xxxx" stehen.'
|
||||
'Repeat the runway direction and QNH, callsign at the end.',
|
||||
'You may say QNH as a number or as "QNH xxxx".'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1445,11 +1481,11 @@ const modules = ref<ModuleDef[]>([
|
||||
{
|
||||
id: 'taxi',
|
||||
title: 'Taxi Readback',
|
||||
desc: 'Taxi-Freigabe mit Hold Short',
|
||||
desc: 'Read back the taxi clearance with the hold short instruction',
|
||||
keywords: ['Ground', 'Taxi'],
|
||||
hints: [
|
||||
'Route wie gehört wiederholen, inklusive Hold Short.',
|
||||
'Am Ende wieder Callsign.'
|
||||
'Repeat the route as given, including the hold short.',
|
||||
'Finish with the callsign again.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1498,19 +1534,19 @@ const modules = ref<ModuleDef[]>([
|
||||
defaultFrequency: 'GND',
|
||||
phrase: scenario => `${scenario.radioCall}, taxi to runway ${scenario.runway} via ${scenario.taxiRoute}, hold short runway ${scenario.runway}.`,
|
||||
info: scenario => [
|
||||
`Taxi-Route: ${scenario.taxiRoute}`,
|
||||
`Hold Short: ${scenario.runway}`
|
||||
`Taxi route: ${scenario.taxiRoute}`,
|
||||
`Hold short: ${scenario.runway}`
|
||||
],
|
||||
generate: createBaseScenario
|
||||
},
|
||||
{
|
||||
id: 'lineup',
|
||||
title: 'Line-up Clearance',
|
||||
desc: 'Aufrollen und warten bestätigen',
|
||||
desc: 'Confirm line up and wait',
|
||||
keywords: ['Tower', 'Line Up'],
|
||||
hints: [
|
||||
'Runway wiederholen, dann "line up and wait".',
|
||||
'Callsign am Ende setzen.'
|
||||
'Repeat the runway, then say "line up and wait".',
|
||||
'Place the callsign at the end.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1542,18 +1578,18 @@ const modules = ref<ModuleDef[]>([
|
||||
phrase: scenario => `${scenario.radioCall}, line up and wait runway ${scenario.runway}.`,
|
||||
info: scenario => [
|
||||
`Tower: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`,
|
||||
`Line-up: runway ${scenario.runway}`
|
||||
`Line up: runway ${scenario.runway}`
|
||||
],
|
||||
generate: createBaseScenario
|
||||
},
|
||||
{
|
||||
id: 'takeoff',
|
||||
title: 'Takeoff Clearance',
|
||||
desc: 'Takeoff-Freigabe bestätigen',
|
||||
desc: 'Acknowledge the takeoff clearance',
|
||||
keywords: ['Tower', 'Departure'],
|
||||
hints: [
|
||||
'Reihenfolge: Runway – cleared for takeoff – Callsign.',
|
||||
'Windangabe kann entfallen, wenn nicht gefordert.'
|
||||
'Order: runway – cleared for takeoff – callsign.',
|
||||
'Wind call can be omitted if not stated.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1592,16 +1628,16 @@ const modules = ref<ModuleDef[]>([
|
||||
{
|
||||
id: 'departure-handoff',
|
||||
title: 'Departure Handoff',
|
||||
desc: 'Auf Departure wechseln',
|
||||
desc: 'Switch to departure',
|
||||
keywords: ['Departure', 'Handoff'],
|
||||
hints: [
|
||||
'Frequenz exakt wiederholen, optional als Zahl oder gesprochen.',
|
||||
'Callsign am Ende anhängen.'
|
||||
'Repeat the frequency exactly, either as digits or spoken.',
|
||||
'Add the callsign at the end.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
key: 'dep-freq',
|
||||
label: 'Frequenz',
|
||||
label: 'Frequency',
|
||||
expected: scenario => scenario.departureFreq,
|
||||
alternatives: scenario => [
|
||||
scenario.departureFreq,
|
||||
@@ -1632,18 +1668,18 @@ const modules = ref<ModuleDef[]>([
|
||||
phrase: scenario => `${scenario.radioCall}, contact departure ${scenario.departureFreq}.`,
|
||||
info: scenario => [
|
||||
`Departure: ${scenario.departureFreq} (${scenario.frequencyWords.DEP})`,
|
||||
`Tower Handoff nach Start.`
|
||||
`Tower handoff after departure.`
|
||||
],
|
||||
generate: createBaseScenario
|
||||
},
|
||||
{
|
||||
id: 'departure-checkin',
|
||||
title: 'Departure Check-in',
|
||||
desc: 'Erstmeldung bei Departure',
|
||||
desc: 'First call with departure',
|
||||
keywords: ['Departure', 'Check-in'],
|
||||
hints: [
|
||||
'Einheit ansprechen, dann Callsign nennen.',
|
||||
'Altitude und SID exakt wie gehört wiederholen.'
|
||||
'Address the unit, then state the callsign.',
|
||||
'Repeat the altitude and SID exactly as given.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1694,11 +1730,11 @@ const modules = ref<ModuleDef[]>([
|
||||
{
|
||||
id: 'climb-instruction',
|
||||
title: 'Climb & Direct',
|
||||
desc: 'Steigauftrag komplett zurücklesen',
|
||||
desc: 'Read back the climb instruction completely',
|
||||
keywords: ['Departure', 'Climb'],
|
||||
hints: [
|
||||
'Mit "Climb" beginnen, dann Höhe und gegebenenfalls Direct.',
|
||||
'Callsign am Ende wiederholen.'
|
||||
'Start with "Climb", then the altitude and any direct.',
|
||||
'Repeat the callsign at the end.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -1749,16 +1785,16 @@ const modules = ref<ModuleDef[]>([
|
||||
{
|
||||
id: 'center-handoff',
|
||||
title: 'Center Handoff',
|
||||
desc: 'Übergabe an Center bestätigen',
|
||||
desc: 'Acknowledge the handoff to center',
|
||||
keywords: ['Center', 'Handoff'],
|
||||
hints: [
|
||||
'Frequenz exakt wiedergeben (mit oder ohne Dezimalpunkt).',
|
||||
'Callsign am Ende platzieren.'
|
||||
'Repeat the frequency exactly (with or without the decimal).',
|
||||
'Place the callsign at the end.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
key: 'ctr-freq',
|
||||
label: 'Frequenz',
|
||||
label: 'Frequency',
|
||||
expected: scenario => scenario.centerFreq,
|
||||
alternatives: scenario => [
|
||||
scenario.centerFreq,
|
||||
@@ -1789,7 +1825,7 @@ const modules = ref<ModuleDef[]>([
|
||||
phrase: scenario => `${scenario.radioCall}, contact center ${scenario.centerFreq}.`,
|
||||
info: scenario => [
|
||||
`Center: ${scenario.centerFreq} (${scenario.frequencyWords.CTR})`,
|
||||
`Nächste Einheit: Center`
|
||||
`Next unit: Center`
|
||||
],
|
||||
generate: createBaseScenario
|
||||
}
|
||||
@@ -1934,6 +1970,7 @@ if (isClient) {
|
||||
resetAudioReveal()
|
||||
})
|
||||
watch(() => cfg.value.radioLevel, markConfigDirty)
|
||||
watch(() => cfg.value.audioSpeed, markConfigDirty)
|
||||
watch(() => cfg.value.voice, markConfigDirty)
|
||||
}
|
||||
|
||||
@@ -2029,6 +2066,12 @@ function fieldExpectedValue(key: string): string {
|
||||
|
||||
const targetPhrase = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.phrase(scenario.value) : ''))
|
||||
const lessonInfo = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.info(scenario.value) : []))
|
||||
const nextLesson = computed(() => {
|
||||
if (!current.value || !activeLesson.value) return null
|
||||
const lessons = current.value.lessons
|
||||
const index = lessons.findIndex(lesson => lesson.id === activeLesson.value?.id)
|
||||
return index >= 0 && index < lessons.length - 1 ? lessons[index + 1] : null
|
||||
})
|
||||
|
||||
watch(activeLesson, lesson => {
|
||||
if (lesson) {
|
||||
@@ -2158,6 +2201,18 @@ function selectLesson(lesson: Lesson) {
|
||||
activeLesson.value = lesson
|
||||
}
|
||||
|
||||
function goToNextLesson() {
|
||||
stopAudio()
|
||||
if (nextLesson.value) {
|
||||
activeLesson.value = nextLesson.value
|
||||
return
|
||||
}
|
||||
panel.value = 'hub'
|
||||
activeLesson.value = null
|
||||
current.value = null
|
||||
result.value = null
|
||||
}
|
||||
|
||||
function bestScore(modId: string, lesId: string) {
|
||||
return progress.value[modId]?.[lesId]?.best || 0
|
||||
}
|
||||
@@ -2185,9 +2240,9 @@ function avgScore(modId: string) {
|
||||
}
|
||||
|
||||
const dailies = ref([
|
||||
{ id: 'd1', title: '3 Readbacks ≥80%', sub: 'Belohnung: +50 XP', reward: 50 },
|
||||
{ id: 'd2', title: '1 Modul starten', sub: 'Belohnung: +20 XP', reward: 20 },
|
||||
{ id: 'd3', title: 'Zielphrase abspielen', sub: 'Belohnung: +10 XP', reward: 10 }
|
||||
{ id: 'd1', title: '3 readbacks ≥80%', sub: 'Reward: +50 XP', reward: 50 },
|
||||
{ id: 'd2', title: 'Start one module', sub: 'Reward: +20 XP', reward: 20 },
|
||||
{ id: 'd3', title: 'Play a target phrase', sub: 'Reward: +10 XP', reward: 10 }
|
||||
])
|
||||
|
||||
function startDaily(daily: { id: string; reward: number; title: string }) {
|
||||
@@ -2280,7 +2335,8 @@ async function say(text: string) {
|
||||
if (!trimmed) return
|
||||
|
||||
const rateBase = 0.9 + (cfg.value.radioLevel - 3) * 0.12
|
||||
const normalizedRate = Math.min(1.5, Math.max(0.6, rateBase))
|
||||
const rateAdjusted = rateBase * (cfg.value.audioSpeed || 1)
|
||||
const normalizedRate = Math.min(1.6, Math.max(0.6, rateAdjusted))
|
||||
|
||||
const hasBrowserTts = cfg.value.tts && typeof window !== 'undefined' && 'speechSynthesis' in window
|
||||
|
||||
|
||||
@@ -64,6 +64,11 @@ function sanitizeConfig(input: Partial<LearnConfig> | undefined): LearnConfig |
|
||||
config.radioLevel = Math.min(5, Math.max(1, level))
|
||||
}
|
||||
|
||||
if (typeof input.audioSpeed === 'number' && Number.isFinite(input.audioSpeed)) {
|
||||
const rounded = Math.round(input.audioSpeed * 10) / 10
|
||||
config.audioSpeed = Math.min(1.3, Math.max(0.7, rounded))
|
||||
}
|
||||
|
||||
if (typeof input.voice === 'string') {
|
||||
config.voice = input.voice.slice(0, 120)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const learnProfileSchema = new mongoose.Schema<LearnProfileDocument>(
|
||||
radioLevel: { type: Number, default: 4, min: 1, max: 5 },
|
||||
voice: { type: String, default: '', trim: true },
|
||||
audioChallenge: { type: Boolean, default: false },
|
||||
audioSpeed: { type: Number, default: 1, min: 0.5, max: 2 },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface LearnConfig {
|
||||
radioLevel: number
|
||||
voice: string
|
||||
audioChallenge: boolean
|
||||
audioSpeed: number
|
||||
}
|
||||
|
||||
export interface LearnState {
|
||||
@@ -23,6 +24,7 @@ export const LEARN_CONFIG_DEFAULTS: LearnConfig = {
|
||||
radioLevel: 4,
|
||||
voice: '',
|
||||
audioChallenge: false,
|
||||
audioSpeed: 1,
|
||||
}
|
||||
|
||||
export function createDefaultLearnConfig(): LearnConfig {
|
||||
|
||||
Reference in New Issue
Block a user