Files
geograsim/App/sims/heli/game.html
T
Adminator 88e3c09e5c Per-Sim leistung_pct: alle 14 Sims senden das eine Leistungs-% (0-100)
Jede Sim liefert jetzt selbst leistung_pct in GGS_LIVE_STATE UND im Submit
(Fundament fuer das einheitliche %-System) — kein Rueckrechnen aus Sternen
mehr, keine 0-3-Sonderfaelle. Formeln je Sim wie vereinbart:
- klima/fluss/kofferdetektiv/heli: = vorhandener 0-100-score
- weltkueche: richtige/(richtige+falsche)*100 (Fehler in %)
- busfahrt/fluggesellschaft: topoPunkte/(Auftraege*3)*100
- farmer: gewichteter totalScore; energiemanager: ausgegl./gesamt -15/Blackout
- eu-werkstatt: firstTry/Schritte -5/Muelleimer; sonnensystem: Erstversuch-Quote
- tourismustal: Ziele+Zufriedenheit+Natur gewichtet; tourismusregion: gewichteter score
- logistik: Kontostand->% (sendet erstmals eine Kennzahl, auch an /api/assessment)
Teacher _leistungPct + live.php extractScore bevorzugen bereits diesen Wert.
Additive Aenderungen, bestehende Logik/Sterne unveraendert; alle 15 Dateien
syntaktisch geprueft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 12:35:56 +02:00

2760 lines
155 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>GeoGraSim — 🚁 Helikopter-Navigation</title>
<link rel="icon" type="image/png" href="../../favicon-96x96.png">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="../../assets/fonts/inter.css">
<link rel="stylesheet" href="../../assets/css/design-system.css">
<style>
:root{--cream:#f5efe0;--forest:#2d5434;--sage:#7a9b76;--slate:#3d5165;--orange:#c97a3f;--red:#c85c4a;--ink:#1f2a24}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',system-ui,sans-serif;background:var(--cream);color:var(--ink);height:100dvh;overflow:hidden}
/* Phase container */
.phase{display:none;width:100%;height:100dvh;position:absolute;inset:0}
.phase.active{display:flex;flex-direction:column}
/* === PHASE 1: Auftragswahl === */
#phase-mission{align-items:center;justify-content:center;padding:1.5rem}
.mission-header{text-align:center;margin-bottom:1.5rem}
.mission-header h1{font-size:1.4rem;font-weight:900;color:var(--forest)}
.mission-header p{font-size:.82rem;color:var(--slate)}
.mission-cards{display:flex;gap:1rem;flex-wrap:wrap;justify-content:center;max-width:900px}
.mission-card{background:#fff;border-radius:14px;width:270px;overflow:hidden;cursor:pointer;border:2.5px solid rgba(0,0,0,.06);transition:all .2s;box-shadow:0 4px 16px rgba(0,0,0,.06)}
.mission-card:hover{transform:translateY(-4px);border-color:var(--sage);box-shadow:0 8px 30px rgba(45,84,52,.15)}
.mission-card img{width:100%;height:120px;object-fit:cover}
.mission-img{height:120px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}
.mission-img .emoji{font-size:3.2rem;filter:drop-shadow(0 2px 4px rgba(0,0,0,.2));z-index:2;position:relative}
.mission-img::after{content:'';position:absolute;inset:0;background:radial-gradient(circle at 70% 30%,rgba(255,255,255,.3),transparent 60%)}
.mi-ski{background:linear-gradient(135deg,#cfe0ec 0%,#7a9bb8 100%)}
.mi-traffic{background:linear-gradient(135deg,#e8c9a8 0%,#c97a3f 100%)}
.mi-cliff{background:linear-gradient(135deg,#d0c4a8 0%,#8a7050 100%)}
.mi-water{background:linear-gradient(135deg,#b8dce6 0%,#4a7c8a 100%)}
.mi-canyon{background:linear-gradient(135deg,#c5dcc5 0%,#5a8a5e 100%)}
.mi-hike{background:linear-gradient(135deg,#c8b89a 0%,#8a6a50 100%)}
.mi-medical{background:linear-gradient(135deg,#e8c8c8 0%,#a85858 100%)}
.mi-avalanche{background:linear-gradient(135deg,#f0f2f5 0%,#8a98a8 100%)}
.mission-card-body{padding:.8rem}
.mission-card-body h3{font-size:.85rem;font-weight:800;color:var(--forest);margin-bottom:.3rem}
.mission-card-body p{font-size:.7rem;color:var(--slate);margin-bottom:.4rem;line-height:1.4}
.mission-card-body .tags{display:flex;gap:.3rem;flex-wrap:wrap}
.mtag{font-size:.58rem;font-weight:700;padding:2px 7px;border-radius:4px}
.mtag-easy{background:#dceadd;color:#3a6b3e}
.mtag-medium{background:#f2eacc;color:#8a7020}
.mtag-hard{background:#f2ddd8;color:#8a3a2a}
.mtag-info{background:#dae8ec;color:#4a7c8a}
/* === PHASE 2: Routenplanung === */
#phase-plan{flex-direction:column;padding:.8rem;overflow-y:auto}
.plan-bar{background:#fff;padding:.5rem 1rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);flex-shrink:0;flex-wrap:wrap;z-index:1000;border-radius:10px 10px 0 0}
.plan-bar h2{font-size:.9rem;font-weight:800;color:var(--forest);flex:1}
.plan-instruction{background:#fff;border-left:3px solid var(--forest);padding:8px 12px;font-size:.95rem;line-height:1.4;flex-shrink:0;z-index:1000}
.plan-instruction h3{font-size:.95rem;font-weight:800;color:var(--forest);margin-bottom:.15rem}
.plan-instruction h3.plan-ok{font-size:1.05rem;margin-bottom:.4rem}
.plan-instruction p{margin:0}
.plan-instruction .dir{font-size:1.4rem;margin-right:.3rem;vertical-align:middle}
.plan-ok{color:var(--sage);font-weight:700}
.plan-err{color:var(--red);font-weight:700}
.plan-frame{border:3px solid var(--forest);border-radius:12px;overflow:hidden;flex:1;min-height:350px;position:relative}
#planMap{width:100%;height:100%;min-height:350px}
.plan-info{background:#fff;border-radius:0 0 10px 10px;padding:.8rem 1rem;font-size:.78rem;color:var(--slate);display:flex;gap:1rem;flex-wrap:wrap;border-top:2px solid var(--forest)}
.wp-set{width:24px;height:24px;background:#fff;border:2.5px solid var(--forest);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:800;color:var(--forest);box-shadow:0 2px 5px rgba(0,0,0,.25)}
.wp-set.start{background:var(--orange);border-color:#b06020;color:#fff;position:relative;z-index:2}
/* Pulsierende Ringe um den Startplatz auf der Routenplanungs-Karte */
.start-pulse-wrap{position:relative;width:120px;height:120px;display:flex;align-items:center;justify-content:center;pointer-events:none}
.start-pulse-ring{position:absolute;left:50%;top:50%;width:24px;height:24px;margin-left:-12px;margin-top:-12px;border-radius:50%;border:3px solid rgba(200,92,74,.9);opacity:0;animation:start-pulse 1.8s ease-out infinite;pointer-events:none;z-index:1}
.start-pulse-ring-2{animation-delay:.6s}
.start-pulse-ring-3{animation-delay:1.2s}
@keyframes start-pulse{
0% {transform:scale(1); opacity:.95}
80% {transform:scale(4.5); opacity:0}
100% {transform:scale(4.5); opacity:0}
}
.start-pulse-wrap .wp-set.start{pointer-events:auto}
.wp-set.target{background:var(--red);border-color:#a04030;color:#fff}
/* === PHASE 3/5: Start/Landung (iframe) === */
#phase-start, #phase-landing{padding:0}
#phase-start iframe, #phase-landing iframe{width:100%;height:100%;border:none}
/* === PHASE 4: Kartenflug === */
#phase-flight{flex-direction:column}
.flight-bar{background:#fff;padding:.4rem 1rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);flex-shrink:0;z-index:1000;flex-wrap:wrap}
.flight-bar h2{font-size:.85rem;font-weight:800;color:var(--forest)}
/* Hindernis-Hinweise im Flug (V3.2) */
.flight-hazards{display:inline-flex;align-items:center;gap:.4rem;background:#fff8e6;border:1px solid #e8c547;border-radius:6px;padding:.25rem .55rem;font-size:.72rem;color:#7a6020}
.flight-hazards .haz-label{font-weight:700;color:#8a5e10;margin-right:.2rem}
.flight-hazards .haz-item{cursor:help;border-bottom:1px dashed rgba(138,94,16,.5);padding:0 2px;transition:background .15s}
.flight-hazards .haz-item:hover{background:rgba(232,197,71,.25);border-bottom-color:#8a5e10}
#flightMap{flex:1}
.flight-info{position:absolute;bottom:12px;left:12px;background:rgba(255,255,255,.95);border-radius:10px;padding:14px 18px;box-shadow:0 4px 16px rgba(0,0,0,.12);z-index:1000;font-size:1.56rem;line-height:1.4;max-width:600px}
/* === PHASE 6: Auswertung === */
#phase-results{align-items:center;justify-content:center;padding:2rem}
.results-card{background:#fff;border-radius:16px;padding:2rem;max-width:500px;width:90%;text-align:center;box-shadow:0 8px 30px rgba(0,0,0,.1)}
.results-card h2{font-size:1.3rem;font-weight:900;color:var(--forest);margin-bottom:.5rem}
.results-stars{font-size:2rem;margin:.5rem 0}
.results-stats{display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin:1rem 0;text-align:left}
.rstat{background:var(--cream);border-radius:8px;padding:.5rem .7rem}
.rstat .rl{font-size:.6rem;color:var(--slate)}
.rstat .rv{font-size:1rem;font-weight:800;color:var(--forest)}
.btn-big{padding:.7rem 2rem;background:var(--forest);color:var(--cream);border:none;border-radius:10px;font-weight:800;font-size:.9rem;cursor:pointer;margin-top:1rem}
.btn-big:hover{background:#1a3a1e}
.btn-outline{background:transparent;color:var(--forest);border:2px solid var(--forest);margin-left:.5rem}
/* Karten-Link (Atlas-Spielhilfe) — sichtbar in Auftragswahl + Routenplanung */
.heli-map-link{position:absolute;top:.6rem;right:.6rem;z-index:1500;display:inline-flex;align-items:center;gap:.3rem;background:#fff;color:var(--forest);text-decoration:none;border:1.5px solid var(--forest);border-radius:8px;padding:.35rem .55rem;font-size:.75rem;font-weight:700;box-shadow:0 2px 6px rgba(0,0,0,.08)}
.heli-map-link:hover{background:var(--forest);color:#fff}
/* Hauptmenue-Link in Auftragswahl (oben links neben Mission-Badge) */
.heli-home-link{position:absolute;top:.6rem;left:4.5rem;z-index:1500;display:inline-flex;align-items:center;gap:.3rem;background:#fff;color:var(--forest);text-decoration:none;border:1.5px solid var(--forest);border-radius:8px;padding:.35rem .55rem;font-size:.75rem;font-weight:700;box-shadow:0 2px 6px rgba(0,0,0,.08)}
.heli-home-link:hover{background:var(--forest);color:#fff}
/* Mission-ID-Badge — fix oben links, sichtbar in allen Phasen ab Auswahl */
#missionBadge{position:fixed;top:.5rem;left:.5rem;z-index:9000;background:rgba(31,75,55,.92);color:#f5efe0;font-weight:800;font-size:.78rem;letter-spacing:.05em;padding:.3rem .55rem;border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,.18);pointer-events:none;font-family:system-ui,sans-serif}
/* === Onboarding-Modal === */
.heli-onb-overlay{position:fixed;inset:0;background:rgba(31,75,55,.62);display:flex;align-items:center;justify-content:center;z-index:9300;padding:16px;backdrop-filter:blur(3px)}
.heli-onb-overlay[hidden]{display:none}
.heli-onb-card{background:#fff;border-radius:16px;padding:22px 26px 18px;max-width:580px;width:100%;max-height:92vh;overflow-y:auto;box-shadow:0 16px 48px rgba(20,32,26,.35);animation:heliOnbPop .45s cubic-bezier(.18,1.4,.6,1)}
@keyframes heliOnbPop{from{transform:scale(.92);opacity:0}to{transform:scale(1);opacity:1}}
.heli-onb-dots{display:flex;gap:6px;justify-content:center;margin-bottom:12px}
.heli-onb-dots .dot{width:9px;height:9px;border-radius:50%;background:rgba(31,75,55,.16);transition:all .25s}
.heli-onb-dots .dot.done{background:var(--sage)}
.heli-onb-dots .dot.active{background:var(--forest);transform:scale(1.35)}
.heli-onb-title{margin:0 0 12px;color:var(--forest);font-size:1.3rem;font-weight:800;text-align:center}
.heli-onb-visual{background:linear-gradient(180deg,#e3eef2 0%,#f5f3ea 100%);border-radius:12px;margin:0 -4px 12px;padding:14px;min-height:160px;display:flex;align-items:center;justify-content:center}
.heli-onb-visual svg{width:100%;max-width:480px;height:auto}
.heli-onb-text{font-size:.94rem;line-height:1.55;color:var(--ink);margin-bottom:18px}
.heli-onb-text b{color:var(--forest)}
.heli-onb-text ul{margin:.4rem 0 .4rem 1.2rem;padding:0}
.heli-onb-text li{margin:.2rem 0}
.heli-onb-mentor{display:flex;align-items:center;gap:12px;background:rgba(45,84,52,.06);border-radius:10px;padding:8px 12px;margin-bottom:14px}
.heli-onb-mentor img{width:48px;height:48px;border-radius:50%;object-fit:cover;border:2px solid var(--forest);flex-shrink:0;background:#fff}
.heli-onb-mentor .who{font-size:.78rem;line-height:1.3}
.heli-onb-mentor .who b{color:var(--forest);font-size:.85rem;display:block;margin-bottom:1px}
.heli-onb-actions{display:flex;justify-content:space-between;gap:10px;margin-top:6px;flex-wrap:wrap}
.heli-onb-btn{padding:.55rem 1.2rem;border-radius:8px;font-weight:700;font-size:.88rem;border:none;cursor:pointer;transition:all .15s}
.heli-onb-btn-primary{background:var(--forest);color:#fff}
.heli-onb-btn-primary:hover{background:#1a3a1e}
.heli-onb-btn-secondary{background:transparent;color:var(--forest);border:1.5px solid var(--forest)}
.heli-onb-btn-secondary:hover{background:rgba(45,84,52,.06)}
/* Spielhilfe-Re-Open-Button in der Einsatzzentrale */
.heli-help-link{position:absolute;top:.6rem;right:11rem;z-index:1500;display:inline-flex;align-items:center;gap:.3rem;background:#fff;color:var(--forest);border:1.5px solid var(--forest);border-radius:8px;padding:.35rem .55rem;font-size:.75rem;font-weight:700;box-shadow:0 2px 6px rgba(0,0,0,.08);cursor:pointer;font-family:inherit}
.heli-help-link:hover{background:var(--forest);color:#fff}
/* === Glossar-Begriffe (Tooltip/Popup) === */
.heli-glossar-term{cursor:help;border-bottom:1px dashed rgba(45,84,52,.5);text-decoration:none;color:inherit}
.heli-glossar-term:hover{border-bottom-color:var(--forest);background:rgba(45,84,52,.06)}
.heli-glossar-popup{position:fixed;z-index:9999;background:#fff;border:1px solid #e2ddd1;border-radius:10px;box-shadow:0 8px 30px rgba(20,32,26,.25);padding:12px 14px;max-width:340px;min-width:240px;font-size:.85rem;line-height:1.45;color:var(--ink);animation:heliGlossarPop .2s ease-out}
@keyframes heliGlossarPop{from{transform:translateY(-4px);opacity:0}to{transform:translateY(0);opacity:1}}
.heli-glossar-title{font-weight:800;color:var(--forest);margin-bottom:4px;font-size:.95rem}
.heli-glossar-text{color:var(--ink)}
.heli-glossar-link{display:inline-block;margin-top:8px;color:var(--forest);text-decoration:none;font-weight:700;font-size:.78rem}
.heli-glossar-link:hover{text-decoration:underline}
</style>
</head>
<body>
<!-- Mission-ID-Badge — wird nach Mission-Auswahl gefuellt -->
<div id="missionBadge" style="display:none"></div>
<!-- PHASE 1: Auftragswahl -->
<div class="phase active" id="phase-mission">
<a id="heliHomeLink" class="heli-home-link" href="#" title="Zurück zur Modul-Auswahl (Cockpit)">← Hauptmenü</a>
<button type="button" class="heli-help-link" onclick="startHeliOnboarding(true)" title="Spielhilfe erneut anzeigen">❓ Spielhilfe</button>
<a id="heliMapLinkMission" class="heli-map-link" href="#" target="_blank" rel="noopener" title="Übersichtskarte aller Stützpunkte und Missionen">📍 Übersichtskarte</a>
<div class="mission-header">
<h1>🚁 Einsatzzentrale</h1>
<p>Wähle deinen Einsatz — der Hubschrauber wartet.</p>
</div>
<div class="mission-cards" id="missionCards"></div>
</div>
<!-- Onboarding-Modal (4 Schritte, einmalig pro Browser) -->
<div class="heli-onb-overlay" id="heliOnbOverlay" hidden role="dialog" aria-labelledby="heliOnbTitle">
<div class="heli-onb-card">
<div class="heli-onb-dots" id="heliOnbDots"></div>
<h2 class="heli-onb-title" id="heliOnbTitle">Willkommen!</h2>
<div class="heli-onb-mentor" id="heliOnbMentor">
<img id="heliOnbMentorImg" alt="Marlene">
<div class="who"><b id="heliOnbMentorName">Pilotin Marlene</b><span id="heliOnbMentorRole">deine erfahrene Kollegin</span></div>
</div>
<div class="heli-onb-visual" id="heliOnbVisual"></div>
<div class="heli-onb-text" id="heliOnbText"></div>
<div class="heli-onb-actions">
<button class="heli-onb-btn heli-onb-btn-secondary" type="button" id="heliOnbBack" onclick="prevHeliOnboarding()" style="display:none">← Zurück</button>
<button class="heli-onb-btn heli-onb-btn-secondary" type="button" id="heliOnbSkip" onclick="skipHeliOnboarding()">Überspringen</button>
<button class="heli-onb-btn heli-onb-btn-primary" type="button" id="heliOnbNext" onclick="nextHeliOnboarding()">Weiter →</button>
</div>
</div>
</div>
<!-- PHASE 2: Routenplanung -->
<div class="phase" id="phase-plan">
<a id="heliMapLinkPlan" class="heli-map-link" href="#" target="_blank" rel="noopener" title="Übersichtskarte aller Stützpunkte und Missionen">📍 Übersichtskarte</a>
<div class="plan-bar">
<h2>📍 Routenplanung</h2>
<span id="planProgress" style="font-size:.72rem;color:var(--slate)">Wegpunkt 1/5</span>
</div>
<div class="plan-instruction" id="planInstruction"></div>
<div class="plan-frame"><div id="planMap"></div></div>
<div class="plan-info" id="planInfo">
<div><strong>Mission:</strong> <span id="pi-mission"></span></div>
<div><strong>Hubschrauber:</strong> <span id="pi-heli"></span></div>
<div><strong>Wegpunkte:</strong> <span id="pi-wp"></span></div>
<div><strong>Region:</strong> <span id="pi-region"></span></div>
</div>
</div>
<!-- PHASE 3: Start -->
<div class="phase" id="phase-start">
<iframe id="startFrame" src=""></iframe>
</div>
<!-- PHASE 4: Kartenflug -->
<div class="phase" id="phase-flight">
<div class="flight-bar">
<h2>🗺️ Navigation</h2>
<span id="flightStatus" style="font-size:.72rem;color:var(--slate);margin-right:auto"></span>
<!-- Hindernis-Hinweise im Flug (V3.2): aufklappbarer Mini-Hinweis,
immer sichtbar; Klick auf Begriff → Glossar-Popup -->
<div class="flight-hazards" id="flightHazards" title="Achte im Flug auf diese Hindernisse">
<span class="haz-label">⚠ Hindernisse:</span>
<span class="heli-glossar-term haz-item" data-glossar="vogelschlag" title="Mehr erfahren">🦅 Vögel</span>
<span class="heli-glossar-term haz-item" data-glossar="luftverkehr" title="Mehr erfahren">✈ Luftverkehr</span>
<span class="heli-glossar-term haz-item" data-glossar="gewitter-flug" title="Mehr erfahren">⛈ Gewitter</span>
</div>
</div>
<div id="flightMap" style="position:relative">
<div class="flight-info" id="flightInfo"></div>
</div>
</div>
<!-- PHASE 5: Landung -->
<div class="phase" id="phase-landing">
<iframe id="landingFrame" src=""></iframe>
</div>
<!-- PHASE 6: Auswertung -->
<div class="phase" id="phase-results">
<div class="results-card" id="resultsCard"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// Karten-Link (Atlas-Spielhilfe) — Pfad aus HELI_API_BASE ableiten,
// damit /heli-uebersicht unter dem korrekten BASE_PATH lebt.
// MUSS in DOMContentLoaded laufen, weil HELI_API_BASE erst durch die
// PHP-Wrapper-Injection (DB_WAYPOINTS-Marker) weiter unten gesetzt wird.
document.addEventListener('DOMContentLoaded', function(){
var basePath = (window.HELI_API_BASE || '/api').replace(/\/api\/?$/, '');
var href = basePath + '/heli-uebersicht';
['heliMapLinkMission','heliMapLinkPlan'].forEach(function(id){
var el = document.getElementById(id);
if (el) el.href = href;
});
// Hauptmenue-Link auf das Schueler-Cockpit (/schueler) — Modul-Auswahl der
// eingeloggten Schueler:in. /sim war alte Demo-Shell und fuehrte ins Leere.
var home = document.getElementById('heliHomeLink');
if (home) home.href = basePath + '/schueler';
});
// === FLIGHT AUDIO ENGINE ===
var flightAudioCtx = null;
function initFlightAudio() {
if (flightAudioCtx) return;
try { flightAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {}
}
var flightAudioCache = {};
// === Audio-Whitelist ===
// Default ist seit 2026-05-31 'v3' — volles Audio-Set freigeschaltet
// (V1-Reduktion war zu knapp, hat r_wp_geo_*, r_land_*, r_nav_heli_intro_*
// etc. blockiert, sodass das Drehbuch leer war). Mit 'v1' bleibt die
// alte Minimal-Whitelist nutzbar.
if (typeof window.HELI_AUDIO_SET === 'undefined') window.HELI_AUDIO_SET = 'v3';
function isV1Audio(name) {
if (!name) return false;
if (window.HELI_AUDIO_SET === 'v3') return true; // alles durchlassen
return name.startsWith('r_mission_geo_')
|| name.startsWith('r_mission_start_')
|| name.startsWith('r_mission_intro_')
|| name.startsWith('r_nachtrag_')
|| name.startsWith('r_caution_')
|| name === 'r_patient_aboard'
|| name === 'r_kurs_klinik'
|| name === 'r_mission_success'
|| name === 'r_mission_abort'
|| name === 'r_nav_plan_correct'
|| name === 'r_nav_plan_done'
|| name === 'r_nav_plan_wrong_far'
|| name === 'r_nav_plan_wrong_near';
}
// === Speech-Pipe fuer Kartenflug ===
// Regeln (analog start/landing): seriell, 10 s Pause zwischen Audios,
// TTL 120 s (Queue darf nicht waehrend des Wartens verfallen), kein
// Doppelt-Einreihen.
var FLIGHT_GAP_MS = 10000;
var FLIGHT_TTL_MS = 120000;
var flightAudioQueue = []; // [{path, until}]
var flightAudioCurrent = null;
var flightAudioBusyUntilMs = 0;
async function playFlightAudio(path, opts) {
if (!flightAudioCtx) return;
if (document.querySelector('.overlay.show')) return;
// V1-Filter: Audio-ID aus Pfad extrahieren, nur Whitelist durchlassen
if (window.HELI_AUDIO_SET === 'v1') {
var v1Id = (path||'').replace(/^.*\//,'').replace(/\.mp3$/,'');
if (!isV1Audio(v1Id)) return;
}
opts = opts || {};
// priority:'now' — laufendes Audio per kurzem Fade-Out abbrechen, Queue
// leeren, neues Audio sofort spielen (10-s-Gap umgehen). Fuer Fail-Klick-
// Hinweise: Feedback muss zeitlich beim Klick liegen, nicht 10 s spaeter.
if (opts.priority === 'now') {
if (flightAudioCurrent) {
var cur = flightAudioCurrent;
flightAudioCurrent = null;
try {
var fg = cur._fadeGain;
if (fg) {
var t = flightAudioCtx.currentTime;
fg.gain.cancelScheduledValues(t);
fg.gain.setValueAtTime(fg.gain.value, t);
fg.gain.linearRampToValueAtTime(0, t + 0.12);
}
} catch(_){}
setTimeout(function(){ try { cur.stop(); } catch(_){} }, 150);
}
flightAudioQueue.length = 0;
flightAudioBusyUntilMs = 0;
}
// Dedupe
if (flightAudioQueue.some(function(it){ return it.path === path; })) return;
flightAudioQueue.push({ path: path, until: performance.now() + FLIGHT_TTL_MS });
// Kurzer Delay bei priority:'now', damit der vorherige Source sauber stoppt.
if (opts.priority === 'now') setTimeout(_flightAudioPump, 160);
else _flightAudioPump();
}
// True wenn weder etwas spielt noch in der Queue ist und kein Gap aussteht.
function isFlightPipeIdle() {
return !flightAudioCurrent
&& flightAudioQueue.length === 0
&& performance.now() >= flightAudioBusyUntilMs;
}
// True wenn nichts mehr SPIELT und nichts mehr in der Queue wartet.
// Im Gegensatz zu isFlightPipeIdle wird der 10-s-Gap NICHT abgewartet —
// fuer Phasen-Wechsel reicht es, wenn die Stimme verklungen ist.
function isFlightPipeSpoken() {
return !flightAudioCurrent && flightAudioQueue.length === 0;
}
async function _flightAudioPump() {
if (flightAudioCurrent) return;
if (!flightAudioCtx) return;
var now = performance.now();
if (now < flightAudioBusyUntilMs) {
setTimeout(_flightAudioPump, Math.max(40, flightAudioBusyUntilMs - now));
return;
}
// veraltete Items wegwerfen
while (flightAudioQueue.length && flightAudioQueue[0].until < now) flightAudioQueue.shift();
if (!flightAudioQueue.length) return;
if (flightAudioCtx.state === 'suspended') flightAudioCtx.resume();
var item = flightAudioQueue.shift();
var path = item.path;
var fullPath = (window.HELI_BASE || '') + path;
try {
if (!flightAudioCache[path]) {
var resp = await fetch(fullPath);
if (!resp.ok) { flightAudioCurrent = null; _flightAudioPump(); return; }
var buf = await resp.arrayBuffer();
if (buf.byteLength < 500) { flightAudioCurrent = null; _flightAudioPump(); return; }
flightAudioCache[path] = await flightAudioCtx.decodeAudioData(buf);
}
var buf2 = flightAudioCache[path];
var src = flightAudioCtx.createBufferSource();
var bp = flightAudioCtx.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=1800; bp.Q.value=2;
var gain = flightAudioCtx.createGain(); gain.gain.value = 2.5;
src.buffer = buf2;
src.connect(bp); bp.connect(gain); gain.connect(flightAudioCtx.destination);
flightAudioCurrent = src;
src._fadeGain = gain;
// Funk-Pille: Audio-ID aus Pfad extrahieren (.../r_xyz.mp3 → r_xyz),
// heliKey aus Mission-Konfiguration (fuer pilot/tower Zuordnung).
try {
var audioId = path.replace(/^.*\//,'').replace(/\.mp3$/,'');
var heliKey = (game && game.mission && BASE_CONFIG[game.mission.base] && BASE_CONFIG[game.mission.base].heliKey) || '';
if (window.HeliFunk) HeliFunk.show(audioId, { duration: buf2.duration, heliKey: heliKey });
} catch(_){}
src.onended = function(){
if (flightAudioCurrent === src) flightAudioCurrent = null;
flightAudioBusyUntilMs = performance.now() + FLIGHT_GAP_MS;
if (window.HeliFunk) HeliFunk.hide();
_flightAudioPump();
};
src.start(0);
} catch(e) {
console.warn('Flight audio fail:', path, e);
flightAudioCurrent = null;
_flightAudioPump();
}
}
// Beim Phasenwechsel komplette Flight-Pipe leeren — laufende Ausgabe per
// kurzem Fade-Out (180 ms) beenden statt hart abreissen.
function clearFlightSpeechPipe() {
if (window.HeliFunk) HeliFunk.hide();
if (flightAudioCurrent) {
var cur = flightAudioCurrent;
flightAudioCurrent = null;
try {
var fg = cur._fadeGain;
if (fg && flightAudioCtx) {
var t = flightAudioCtx.currentTime;
fg.gain.cancelScheduledValues(t);
fg.gain.setValueAtTime(fg.gain.value, t);
fg.gain.linearRampToValueAtTime(0, t + 0.18);
}
} catch(_){}
setTimeout(function(){ try { cur.stop(); } catch(_){} }, 220);
}
flightAudioQueue.length = 0;
flightAudioBusyUntilMs = 0;
}
// Map wp_key to audio file
function playWaypointAudio(wpKey) {
var key = wpKey.replace(/[^a-z0-9_]/gi, '').toLowerCase();
playFlightAudio('sounds/nav/ort_' + key + '.mp3');
}
function playNavAudio(name) {
playFlightAudio('sounds/nav/' + name + '.mp3');
}
// Spielt einen Clip aus sounds/radio/<name>.mp3 — fuer die Tower-Lehrmeisterin
// waehrend Planung und Kartenflug. Laeuft durch denselben Bandpass wie alle
// Funksprueche, damit es nach Funk klingt.
function playRadioAudio(name, opts) {
playFlightAudio('sounds/radio/' + name + '.mp3', opts);
}
</script>
<script>
// =========================================================
// GAME STATE
// =========================================================
var game = {
phase: null, // wird per setPhase('mission') initial aktiviert
mission: null,
route: null,
planIndex: 0,
planCorrect: 0,
startScore: 0, // 0-5 sterne
landingScore: 0,
flightSpeed: 1,
flightIndex: 0,
totalStars: 0,
stationCallsign: null, // zufaellig aus Pool gezogenes Kennzeichen (z.B. "OE-XEN"), pro Mission stabil
};
// Live-View für Lehrkräfte: kompakter State-Snapshot (max ~1 KB).
// Wird vom Plattform-live-client.js alle ~4 s an /api/live gesendet.
window.GGS_LIVE_STATE = function () {
if (!game) return null;
var m = game.mission || {};
var routeLen = (game.route || []).length;
var phase = game.phase || null;
// Phasen-Reihenfolge: mission → plan → briefing → start → flight → approach → landing → results
// Start-Score erst zeigen, wenn Start-Minigame durch ist (ab 'flight').
// Landung-Score erst zeigen, wenn Landung durch ist ('results').
var POST_START = ['flight','approach','landing','results'];
var startDone = phase && POST_START.indexOf(phase) >= 0;
var landingDone = phase === 'results';
// In der Auftragswahl/Briefing (keine aktive Mission) das letzte Ergebnis zeigen.
var atSelect = (!phase || phase === 'mission' || phase === 'briefing');
var lr = (atSelect && game.lastResult) ? game.lastResult : null;
return {
phase: phase,
missionId: m.id || (lr ? lr.missionId : null),
missionTitle: m.title || (lr ? lr.missionTitle : null),
heliKey: (m.heliKey || (m.base && BASE_CONFIG[m.base] && BASE_CONFIG[m.base].heliKey)) || null,
heliName: (m.base ? displayHeliName(m.base) : null),
stationCallsign: game.stationCallsign || null,
planIndex: game.planIndex || 0,
planTotal: routeLen ? routeLen - 1 : 0,
planCorrect: game.planCorrect || 0,
planAttempts: game.planAttempts || 0,
startScore: startDone ? (game.startScore || 0) : (lr ? lr.startScore : null),
landingScore: landingDone ? (game.landingScore || 0) : (lr ? lr.landingScore : null),
totalStars: landingDone ? (game.totalStars || 0) : (lr ? lr.totalStars : null),
lastResult: lr ? true : false,
lastFailed: lr ? !!lr.failed : false,
failed: !!game.missionFailed,
missionStartedAt: game.missionStartedAt || null,
// Laufender 0100-Score für die Ampelfarbe im Lehrer-Live (Tag-Balken).
// Progressiv: Routing-Genauigkeit, dann Ø mit Start-/Landungssternen; am Ende
// die Gesamtsterne. In der Auftragswahl (mission/briefing) kein Score.
score: (function(){
if (!phase || phase === 'mission' || phase === 'briefing') return null;
if (landingDone && game.totalStars != null) return Math.round(Math.max(0,Math.min(5,game.totalStars))/5*100);
var parts = [];
// Routing = KLICK-GENAUIGKEIT (richtige / alle Klicks), nicht Fortschritt.
// 0 Klicks → kein Score (grau), nicht rot: der Anfang ist kein Fehler.
var clicks = (game.planCorrect||0) + (game.planAttempts||0);
if (clicks > 0) parts.push(Math.round((game.planCorrect||0) / clicks * 100));
if (startDone && game.startScore != null) parts.push(Math.max(0,Math.min(5,game.startScore))/5*100);
if (landingDone && game.landingScore != null) parts.push(Math.max(0,Math.min(5,game.landingScore))/5*100);
if (!parts.length) return null;
return Math.round(parts.reduce(function(a,b){return a+b;},0)/parts.length);
})(),
// Gleicher Wert wie score (0100), als vereinheitlichte Leistungs-Kennzahl.
leistung_pct: (function(){
if (!phase || phase === 'mission' || phase === 'briefing') return null;
if (landingDone && game.totalStars != null) return Math.round(Math.max(0,Math.min(5,game.totalStars))/5*100);
var parts = [];
var clicks = (game.planCorrect||0) + (game.planAttempts||0);
if (clicks > 0) parts.push(Math.round((game.planCorrect||0) / clicks * 100));
if (startDone && game.startScore != null) parts.push(Math.max(0,Math.min(5,game.startScore))/5*100);
if (landingDone && game.landingScore != null) parts.push(Math.max(0,Math.min(5,game.landingScore))/5*100);
if (!parts.length) return null;
return Math.round(parts.reduce(function(a,b){return a+b;},0)/parts.length);
})(),
};
};
// =========================================================
// WAYPOINTS + POIs — von PHP aus der DB injiziert
// =========================================================
/*__DB_WAYPOINTS__*/
// =========================================================
// MISSIONS (Auswahl von 3 pro Spiel)
// =========================================================
// Basis → Heli-Typ + Startszene
// ======================================================================
// HELI-STATIONS — wird aus window.HELI_STATIONS (heli-stations.json)
// aufgebaut. Rechtssichere Anzeige ohne Markennamen.
//
// Architektur: Beim Mission-Start zieht drawCallsign(stationKey) zufaellig
// ein Kennzeichen aus dem Stations-Pool — analog zum realen Rotations-
// Prinzip (Helis werden je nach Wartung zwischen Stuetzpunkten getauscht).
// Dieses Kennzeichen wird in game.stationCallsign cached und ueberall in
// Anzeige + Audio referenziert. Bei naechster Mission wird neu gezogen.
//
// Siehe: docs/konzept-heli-kennzeichen.md + data/heli-stations.json
// ======================================================================
var BASE_CONFIG = (function(){
var out = {};
var stations = (window.HELI_STATIONS && window.HELI_STATIONS.stations) || {};
var pools = (window.HELI_STATIONS && window.HELI_STATIONS.pools) || {};
Object.keys(stations).forEach(function(key){
var s = stations[key];
var pool = pools[s.pool] || {};
out[key] = {
heli: 'oamtc', // legacy iframe-Parameter (wird umgebaut)
heliKey: s._legacyHeliKey || key, // legacy fuer Audio-Filenamen + pilot-by-helikey
heliModel: pool.model || 'Hubschrauber',
heliModelShort: pool.modelShort || 'Heli',
scenario: s.scenario || 'basis',
stationName: s.stationName || key,
stationRegion: s.region || '',
org: s.org || 'notarzt',
orgLabel: s.orgLabel || 'Rettungshubschrauber',
orgColor: s.orgColor || '#d4a017',
pool: s.pool,
_admin: s._admin || {},
};
});
return out;
})();
// Aus Pool ein Kennzeichen ziehen — pro Mission stabil, beim naechsten
// Einsatz neu. Cached in game.stationCallsign.
function drawCallsign(stationKey) {
if (game.stationCallsign) return game.stationCallsign;
var stations = (window.HELI_STATIONS && window.HELI_STATIONS.stations) || {};
var pools = (window.HELI_STATIONS && window.HELI_STATIONS.pools) || {};
var s = stations[stationKey];
if (!s) return null;
var pool = pools[s.pool];
if (!pool || !pool.callsigns || !pool.callsigns.length) return null;
var pick = pool.callsigns[Math.floor(Math.random() * pool.callsigns.length)];
game.stationCallsign = pick;
return pick;
}
// Anzeige-Name fuer den Heli einer Station:
// 1. Wenn Admin _realOperator + _realFleetName freigegeben → realer Markenname
// 2. Sonst → "Stationsname (OE-XEN)" mit gezogenem Pool-Kennzeichen
function displayHeliName(stationKey) {
var cfg = BASE_CONFIG[stationKey];
if (!cfg) return 'Hubschrauber';
// Override: Admin hat Marken-Bezeichnung freigegeben
if (cfg._admin && cfg._admin.realOperatorApproved && cfg._admin.realFleetNameApproved
&& cfg._admin.realOperator && cfg._admin.realFleetName) {
return cfg._admin.realOperator + ' ' + cfg._admin.realFleetName;
}
// Default: Stationsname + gezogenes Pool-Kennzeichen
var callsign = drawCallsign(stationKey);
return callsign
? cfg.stationName + ' (' + callsign + ')'
: cfg.stationName;
}
// Nur das Kennzeichen (z.B. fuer kompakte Stellen wie Funk)
function displayHeliCallsign(stationKey) {
var cfg = BASE_CONFIG[stationKey];
if (!cfg) return '';
if (cfg._admin && cfg._admin.realOperatorApproved && cfg._admin.realFleetNameApproved
&& cfg._admin.realFleetName) {
return cfg._admin.realFleetName;
}
return drawCallsign(stationKey) || '';
}
var ALL_MISSIONS = [
{id:'m1', title:'Skiunfall Damüls', desc:'Skifahrer*in hat sich das Bein gebrochen. Bergrettung im Skigebiet Damüls-Mellau angefordert.', difficulty:'medium', base:'nenzing', target:'bergrettung_muels',
waypoints:['nenzing','thueringen','zwischenwasser','damuels','bergrettung_muels'], theme:'ski', icon:'⛷️', region:'Vorarlberg', winter:true},
{id:'m2', title:'Verkehrsunfall A14', desc:'Schwerer Auffahrunfall auf der Rheintal-Autobahn bei Rankweil. Mehrere Verletzte.', difficulty:'easy', base:'nenzing', target:'verkehr_a14',
waypoints:['nenzing','feldkirch','rankweil','verkehr_a14'], theme:'traffic', icon:'🚗', region:'Vorarlberg'},
{id:'m3', title:'Bergrettung Brand', desc:'Kletterunfall im Brandnertal (Rätikon). Person in steiler Felswand eingeschlossen.', difficulty:'medium', base:'nenzing', target:'felswand_raetikon',
waypoints:['nenzing','buers','brand','felswand_raetikon'], theme:'cliff', icon:'🧗', region:'Vorarlberg'},
{id:'m4', title:'Skiunfall Sölden', desc:'Schwerer Skiunfall im Skigebiet Sölden. Patient*in muss in die Klinik nach Innsbruck.', difficulty:'medium', base:'innsbruck', target:'skiunfall_soelden',
waypoints:['innsbruck','telfs','oetz','soelden','skiunfall_soelden'], theme:'ski', icon:'⛷️', region:'Tirol', winter:true},
{id:'m5', title:'Seerettung Bregenz', desc:'Person im Bodensee in Not. Unterkühlung droht, schnelle Rettung nötig.', difficulty:'easy', base:'nenzing', target:'bodensee_bregenz',
waypoints:['nenzing','dornbirn','bregenz','bodensee_bregenz'], theme:'water', icon:'🌊', region:'Vorarlberg'},
{id:'m6', title:'Canyoning Mellau', desc:'Canyoning-Unfall im Bregenzerwald. Verletzte Person in Schlucht.', difficulty:'medium', base:'nenzing', target:'schlucht_mellau',
waypoints:['nenzing','dornbirn','egg','bezau','mellau','schlucht_mellau'], theme:'canyon', icon:'🏞️', region:'Vorarlberg'},
{id:'m7', title:'Wanderunfall Kufstein', desc:'Wanderunfall am Kaisergebirge. Hubschrauber für Bergung angefordert.', difficulty:'easy', base:'innsbruck', target:'kaiser_kufstein',
waypoints:['innsbruck','hall','schwaz','jenbach','woergl','kufstein','kaiser_kufstein'], theme:'hike', icon:'🥾', region:'Tirol'},
{id:'m8', title:'Notfall Gaschurn', desc:'Akuter medizinischer Notfall im hinteren Montafon. Langer Flug durchs enge Tal.', difficulty:'hard', base:'nenzing', target:'gaschurn',
waypoints:['nenzing','bludenz','schruns','stgallenk','gaschurn'], theme:'medical', icon:'🚑', region:'Vorarlberg'},
{id:'m9', title:'Lawineneinsatz Mayrhofen', desc:'Lawinenabgang im Zillertal. Vermisste Person unter Schneemassen.', difficulty:'hard', base:'innsbruck', target:'mayrhofen',
waypoints:['innsbruck','hall','schwaz','jenbach','zell_ziller','mayrhofen'], theme:'avalanche', icon:'❄️', region:'Tirol', winter:true},
// ===== WIEN =====
{id:'m10', title:'Verkehrsunfall A2 Baden', desc:'Massenkarambolage auf der Südautobahn bei Baden. Beide Fahrtrichtungen gesperrt.', difficulty:'easy', base:'wien_c9', target:'verkehr_a2_baden',
waypoints:['wien_c9','wien','moedling','baden','verkehr_a2_baden'], theme:'traffic', icon:'🚗', region:'Wien'},
{id:'m11', title:'Sog in der Neuen Donau', desc:'Schwimmer*in von Sog erfasst. Einsatzort nahe Donauinsel Wien.', difficulty:'medium', base:'wien_c9', target:'see_donau_wien',
waypoints:['wien_c9','wien_donau','see_donau_wien'], theme:'water', icon:'🌊', region:'Wien'},
// ===== NIEDERÖSTERREICH =====
{id:'m12', title:'Kletterunfall Schneeberg', desc:'Bergsteiger*in am Nordgrat eingeschlossen, Nordwand-Route.', difficulty:'hard', base:'wr_neustadt_c3', target:'berg_schneeberg',
waypoints:['wr_neustadt_c3','wr_neustadt','puchberg','schneeberg','berg_schneeberg'], theme:'cliff', icon:'🧗', region:'Niederösterreich'},
{id:'m13', title:'Wanderunfall Rax', desc:'Wanderer*in am Raxplateau gestürzt. Unwegsames Gelände.', difficulty:'medium', base:'wr_neustadt_c3', target:'berg_rax',
waypoints:['wr_neustadt_c3','wr_neustadt','puchberg','rax','berg_rax'], theme:'hike', icon:'🥾', region:'Niederösterreich'},
// ===== BURGENLAND =====
{id:'m14', title:'Surfer Neusiedler See', desc:'Kitesurfer*in gekentert vor Podersdorf. Sturm mit Böen bis 8 bft.', difficulty:'easy', base:'oberwart_c16', target:'see_neusiedl',
waypoints:['oberwart_c16','mattersburg','eisenstadt','podersdorf','see_neusiedl'], theme:'water', icon:'🌊', region:'Burgenland'},
// ===== OBERÖSTERREICH =====
{id:'m15', title:'Verkehrsunfall A1 Sattledt', desc:'LKW-Unfall auf der Westautobahn, Knoten Sattledt. Mehrere Verletzte.', difficulty:'medium', base:'linz_c10', target:'verkehr_a1_sattledt',
waypoints:['linz_c10','wels','sattledt','verkehr_a1_sattledt'], theme:'traffic', icon:'🚗', region:'Oberösterreich'},
{id:'m16', title:'Boot in Not Traunsee', desc:'Segelboot gekentert vor Gmunden. Patient*in mit Unterkühlung.', difficulty:'easy', base:'linz_c10', target:'see_traun',
waypoints:['linz_c10','wels','gmunden','traunsee','see_traun'], theme:'water', icon:'🌊', region:'Oberösterreich'},
// ===== SALZBURG =====
{id:'m17', title:'LKW-Unfall A10 Tauern', desc:'Schwerer LKW-Unfall auf der Tauernautobahn vor dem Tunnelportal.', difficulty:'medium', base:'salzburg_c6', target:'verkehr_a10_tauern',
waypoints:['salzburg_c6','hallein','golling','flachau','verkehr_a10_tauern'], theme:'traffic', icon:'🚗', region:'Salzburg'},
{id:'m18', title:'Seerettung Wolfgangsee', desc:'Taucher*in in Not vor St. Wolfgang. Tiefer See, schnelle Bergung.', difficulty:'easy', base:'salzburg_c6', target:'see_wolfgang',
waypoints:['salzburg_c6','st_wolfgang','see_wolfgang'], theme:'water', icon:'🌊', region:'Salzburg'},
{id:'m19', title:'Absturz Großglockner', desc:'Alpinist*in in der Gipfelflanke abgestürzt. 3798 m Höhe, dünne Luft.', difficulty:'hard', base:'salzburg_c6', target:'berg_glockner',
waypoints:['salzburg_c6','zell_see','kaprun','heiligenblut','grossglockner','berg_glockner'], theme:'avalanche', icon:'❄️', region:'Salzburg', winter:true},
// ===== STEIERMARK =====
{id:'m20', title:'Dachstein Südwand', desc:'Absturz im Dachstein-Südwand-Klettersteig, Person hängt in der Wand.', difficulty:'hard', base:'niederoeblarn_c14', target:'berg_dachstein',
waypoints:['niederoeblarn_c14','bad_aussee','dachstein','berg_dachstein'], theme:'cliff', icon:'🧗', region:'Steiermark'},
{id:'m21', title:'Alpinunfall Hochschwab', desc:'Bergsteiger*in im Hochschwab-Massiv verletzt. Wasserreiche Karstlandschaft.', difficulty:'medium', base:'graz_c12', target:'berg_hochschwab',
waypoints:['graz_c12','bruck_mur','hochschwab','berg_hochschwab'], theme:'cliff', icon:'🧗', region:'Steiermark'},
{id:'m22', title:'Unfall A9 Bosruck', desc:'Auffahrunfall Pyhrn-Autobahn vor dem Bosrucktunnel.', difficulty:'medium', base:'graz_c12', target:'verkehr_a9_pyhrn',
waypoints:['graz_c12','bruck_mur','leoben','verkehr_a9_pyhrn'], theme:'traffic', icon:'🚗', region:'Steiermark'},
// ===== KÄRNTEN =====
{id:'m23', title:'Badegast Wörthersee', desc:'Badegast in Not vor Pörtschach. Hohe Wassertemperatur, Erschöpfung.', difficulty:'easy', base:'klagenfurt_c11', target:'see_woerth',
waypoints:['klagenfurt_c11','klagenfurt','poertschach','see_woerth'], theme:'water', icon:'🌊', region:'Kärnten'},
{id:'m24', title:'Glockner Südseite', desc:'Absturz Südseite Großglockner, Heiligenblut-Aufstieg. Vereist.', difficulty:'hard', base:'klagenfurt_c11', target:'berg_glockner',
waypoints:['klagenfurt_c11','villach','spittal','heiligenblut','berg_glockner'], theme:'avalanche', icon:'❄️', region:'Kärnten', winter:true},
{id:'m25', title:'Unfall A2 Klagenfurt', desc:'Schwerer Auffahrunfall Südautobahn bei Klagenfurt-Ost.', difficulty:'easy', base:'klagenfurt_c11', target:'verkehr_a2_klagenfurt',
waypoints:['klagenfurt_c11','maria_saal','verkehr_a2_klagenfurt'], theme:'traffic', icon:'🚗', region:'Kärnten'},
];
// =========================================================
// PHASE MANAGEMENT
// =========================================================
// =========================================================
// ASSESSMENT-DISPATCH (Integrations-Vertrag §6.3 / §6.4)
// =========================================================
// Schickt Kennzahlen an /api/assessment.php. Schlaegt still fehl, wenn keine
// Session da ist (Demo-Modus) oder das API nicht erreichbar ist — blockiert
// den Spielablauf nie.
var heliAssessment = {
startMs: null, // Zeitstempel Mission-Start
runTimer: null, // 30-s-Ping-Intervall
phases: [], // bisher besuchte Phasen ({name, t})
};
function assessmentSend(payload) {
try {
// Demo-Modus: kein session-Cookie, API blockt -> schweigend verzichten
if (!window.HELI_SESSION_ID) return;
var url = (window.HELI_API_BASE || '/api') + '/assessment';
fetch(url, {
method: 'POST',
headers: {'Content-Type':'application/json'},
credentials: 'include',
body: JSON.stringify(payload),
}).catch(function(){});
} catch(e) {}
}
function assessmentStart() {
if (!game.mission) return;
heliAssessment.startMs = Date.now();
heliAssessment.phases = [];
assessmentSend({
simId: 'heli',
processLog: {
phase: 'started',
missionId: game.mission.id,
missionTitle: game.mission.title,
difficulty: game.mission.difficulty,
forcedLevel: window.HELI_FORCED_LEVEL || null,
mode: window.HELI_SESSION_MODE || 'free',
},
duration: 0,
completedPhases: [],
});
// Kein 30-s-Zwischen-Ping mehr an /assessment — der Live-Zustand läuft über
// den Heartbeat (live_sessions). Früher blähte das die assessments-Tabelle auf.
if (heliAssessment.runTimer) clearInterval(heliAssessment.runTimer);
}
function assessmentPing() {
if (!heliAssessment.startMs || !game.mission) return;
var dt = Date.now() - heliAssessment.startMs;
assessmentSend({
simId: 'heli',
processLog: {
phase: 'running',
missionId: game.mission.id,
currentPhase: game.phase,
planCorrect: game.planCorrect || 0,
routeLength: (game.route || []).length,
startScore: game.startScore || null,
landingScore: game.landingScore || null,
},
duration: dt,
completedPhases: heliAssessment.phases.map(function(p){return p.name;}),
});
}
function assessmentComplete() {
if (!heliAssessment.startMs || !game.mission) return;
var dt = Date.now() - heliAssessment.startMs;
var planPct = game.route && game.route.length > 1
? Math.round((game.planCorrect || 0) / (game.route.length - 1) * 100) : 0;
assessmentSend({
simId: 'heli',
processLog: {
phase: 'completed',
missionId: game.mission.id,
failed: !!game.missionFailed,
failReason: game.failReason || '',
},
results: {
missionId: game.mission.id,
sterneGesamt: game.totalStars || 0,
planPct: planPct,
startScore: game.startScore || 0,
landingScore: game.landingScore || 0,
dauerMin: Math.round(dt / 60000),
},
duration: dt,
completedPhases: heliAssessment.phases.map(function(p){return p.name;}),
});
if (heliAssessment.runTimer) { clearInterval(heliAssessment.runTimer); heliAssessment.runTimer = null; }
}
function assessmentPhaseEntered(phaseName) {
if (!heliAssessment.startMs) return;
heliAssessment.phases.push({name: phaseName, t: Date.now() - heliAssessment.startMs});
}
// Mission-Ende → progress.php (analog Busfahrt/Fluggesellschaft). Schreibt
// player_progress (XP/Level/best_stars) + student_results. Das ist der
// Submit-Hook, den der Lehrer-Drilldown auswertet — assessmentComplete
// landet nur in der assessments-Tabelle ohne Spiegel.
function submitHeliMission() {
if (!window.HELI_SESSION_ID || !game.mission) return;
var apiBase = window.HELI_API_BASE || '/api';
var url = apiBase + '/progress';
var dt = heliAssessment.startMs ? (Date.now() - heliAssessment.startMs) : 0;
var planTotal = (game.route || []).length > 1 ? (game.route.length - 1) : 0;
var planPct = planTotal > 0 ? Math.round((game.planCorrect || 0) / planTotal * 100) : 0;
var planStars = computePlanStars();
var _tim = game.wpTiming || [];
var planBeforeRed = _tim.filter(function(x){return x===2;}).length; // Treffer vor rotem Hint (Bonus)
var stars = game.totalStars || 0;
var score = stars * 20;
try {
fetch(url, {
method: 'POST',
headers: {'Content-Type':'application/json'},
credentials: 'include',
body: JSON.stringify({
sim_id: 'heli',
action: 'submit_assessment',
data: {
level: window.HELI_FORCED_LEVEL || null,
stars: stars,
score: score,
leistung_pct: Math.max(0, Math.min(100, Math.round(score))),
duration_ms: dt,
completed: !game.missionFailed,
results: {
missionId: game.mission.id,
missionTitle: game.mission.title,
difficulty: game.mission.difficulty,
region: game.mission.region,
heliKey: game.mission.heliKey || null,
heliName: displayHeliName(game.mission.base),
stationCallsign: game.stationCallsign || null,
stationName: (BASE_CONFIG[game.mission.base] || {}).stationName || null,
heliModel: (BASE_CONFIG[game.mission.base] || {}).heliModel || null,
totalStars: stars,
planStars: planStars,
planCorrect: game.planCorrect || 0,
planTotal: planTotal,
planPct: planPct,
planBeforeRed: planBeforeRed, // Treffer VOR dem roten Hint (Bonus)
planTiming: _tim.join(''), // je WP: 2=vor · 1=während · 0=nach
startScore: game.startScore || 0,
landingScore: game.landingScore || 0,
startTakeover: !!game.startTakeover,
planTakeover: !!game.planTakeover,
failed: !!game.missionFailed,
failReason: game.failReason || '',
}
}
})
}).catch(function(){});
} catch (e) {}
}
// Stoppt alle Audios des laufenden Einsatzes: Flight-Funk, Start- und
// Landing-iframes (durch Zuruecksetzen der src), zugehoerige Timer.
// Wichtig beim Zurueckkehren zur Einsatzzentrale.
function stopAllGameAudio() {
// Flight-Audio-Kontext schließen (stoppt laufende BufferSources)
try {
if (flightAudioCtx && flightAudioCtx.state !== 'closed') {
flightAudioCtx.close();
}
} catch(e) {}
flightAudioCtx = null;
flightAudioCache = {};
// iframe-Sources leeren — stoppt alle AudioContexts innerhalb der iframes
var sf = document.getElementById('startFrame');
var lf = document.getElementById('landingFrame');
if (sf) sf.src = 'about:blank';
if (lf) lf.src = 'about:blank';
// Flight-Interval abbrechen, falls noch am Laufen
if (typeof flightInterval !== 'undefined' && flightInterval) {
clearInterval(flightInterval);
flightInterval = null;
}
if (game && game.startFallbackTimer) {
clearTimeout(game.startFallbackTimer);
game.startFallbackTimer = null;
}
}
// Hubschrauber-Rotor-Loop fuer die Kartenflug-Phase. Schlaeft sonst.
var heliRotorAudio = null;
function startHeliRotor() {
if (!heliRotorAudio) {
heliRotorAudio = new Audio();
heliRotorAudio.loop = true;
heliRotorAudio.volume = 0;
heliRotorAudio.src = (window.HELI_BASE || '') + 'sounds/heliFly.mp3';
}
heliRotorAudio.play().catch(function(){});
var t0 = performance.now();
function step() {
if (!heliRotorAudio) return;
var k = Math.min(1, (performance.now() - t0) / 400);
heliRotorAudio.volume = 0.45 * k;
if (k < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function stopHeliRotor() {
if (!heliRotorAudio) return;
var a = heliRotorAudio;
var v0 = a.volume;
var t0 = performance.now();
function step() {
var k = Math.min(1, (performance.now() - t0) / 300);
a.volume = v0 * (1 - k);
if (k < 1) requestAnimationFrame(step);
else { try { a.pause(); } catch(e) {} }
}
requestAnimationFrame(step);
}
// Hintergrund-Musik fuer die Briefing-Phasen. Eine einzige Audio-Instanz,
// die je nach Phase ein anderes Stueck spielt. Fade-in/-out 300 ms.
var heliMusicAudio = null;
function playHeliMusic(file) {
if (!heliMusicAudio) {
heliMusicAudio = new Audio();
heliMusicAudio.loop = true;
heliMusicAudio.volume = 0;
}
var base = (window.HELI_BASE || '') + 'assets/music/';
var src = base + file;
if (heliMusicAudio.src !== src && !heliMusicAudio.src.endsWith(file)) {
heliMusicAudio.src = src;
}
heliMusicAudio.play().catch(function(){});
// Fade-in
var t0 = performance.now();
function step() {
if (!heliMusicAudio) return;
var k = Math.min(1, (performance.now() - t0) / 300);
heliMusicAudio.volume = 0.35 * k;
if (k < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function stopHeliMusic() {
if (!heliMusicAudio) return;
var a = heliMusicAudio;
var t0 = performance.now();
var v0 = a.volume;
function step() {
var k = Math.min(1, (performance.now() - t0) / 300);
a.volume = v0 * (1 - k);
if (k < 1) requestAnimationFrame(step);
else { try { a.pause(); } catch(e) {} }
}
requestAnimationFrame(step);
}
function setPhase(p) {
if (game.phase === p) return; // Guard: keine Doppelinitialisierung
// Phasen-Wechsel: laufende Audios per Fade-Out beenden — sowohl in der
// Flight-Pipe als auch in den iframes (start/landing). Das verhindert
// hart abgeschnittene Stimmen beim Wechsel zwischen Planung, Start,
// Anflug, Landung und Auswertung.
clearFlightSpeechPipe();
try {
var sf = document.getElementById('startFrame');
var lf = document.getElementById('landingFrame');
if (sf && sf.contentWindow) sf.contentWindow.postMessage({type:'heli_fade_audio'}, '*');
if (lf && lf.contentWindow) lf.contentWindow.postMessage({type:'heli_fade_audio'}, '*');
} catch(_){}
// Beim Zurueckkehren zur Auftragswahl: alle Audios sofort aus, Badge weg.
if (p === 'mission') {
stopAllGameAudio();
var mb = document.getElementById('missionBadge');
if (mb) mb.style.display = 'none';
}
// Musik: in Briefing-Phasen (mission, results) sanft; ausserhalb stoppen.
if (p === 'mission') playHeliMusic('patagonia-stringmap-v1.mp3');
else if (p === 'results') playHeliMusic('patagonia-stringmap-v1.mp3');
else stopHeliMusic();
// Hubschrauber-Rotor-Loop nur im Kartenflug — sonst aus.
if (p === 'flight') startHeliRotor();
else stopHeliRotor();
game.phase = p;
// Assessment: Phasen-Uebergang protokollieren (erst ab Planungs-Phase,
// damit die Auftragswahl selbst nicht als Phase zaehlt).
if (p !== 'mission' && p !== 'results') assessmentPhaseEntered(p);
document.querySelectorAll('.phase').forEach(function(el) { el.classList.toggle('active', el.id === 'phase-' + p); });
if (p === 'mission') initMissionPhase();
if (p === 'plan') initPlanPhase();
if (p === 'start') initStartPhase();
if (p === 'flight') initFlightPhase();
if (p === 'landing') initLandingPhase();
if (p === 'results') initResultsPhase();
}
// =========================================================
// PHASE 1: AUFTRAGSWAHL
// =========================================================
// Lehrer-Level (1/2/3) -> Mission-Schwierigkeit-Mapping (siehe Atlas-Bestaetigung)
function missionsForLevel(lv) {
var diff = lv === 1 ? 'easy' : lv === 3 ? 'hard' : 'medium';
return ALL_MISSIONS.filter(function(m){ return m.difficulty === diff; });
}
function initMissionPhase() {
// Review-Tool: Direkt-Start einer bestimmten Mission (URL ?mission=mX)
// Wird von /heli-missionen verlinkt — nur fuer interne Inhalts-Review.
// Klick-Button statt Auto-Start, damit Browser-AudioContext per User-Gesture
// freigeschaltet wird (sonst spielt das Mission-Intro stumm).
if (window.HELI_FORCED_MISSION) {
var forced = ALL_MISSIONS.find(function(m){ return m.id === window.HELI_FORCED_MISSION; });
if (forced) {
var cards = document.getElementById('missionCards');
if (cards) cards.innerHTML = '<div class="mission-card" style="grid-column:1/-1;text-align:center;padding:40px;cursor:pointer" onclick="window.__heliReviewStart()">'
+ '<h3>🔍 Review-Modus</h3>'
+ '<p style="font-weight:700;color:var(--forest);font-size:1.1rem;margin:8px 0">' + forced.title + '</p>'
+ '<p style="color:var(--slate);font-size:.85rem">' + forced.region + ' · Mission ' + forced.id.toUpperCase() + '</p>'
+ '<button class="btn-big" style="background:var(--forest);font-size:1rem;padding:.7rem 1.6rem;margin-top:1.2rem">🎙 Routenplanung starten</button>'
+ '</div>';
// Onboarding-Overlay nicht zeigen im Review-Modus
try { var ovl = document.getElementById('heliOnbOverlay'); if (ovl) ovl.hidden = true; } catch(_){}
// Klick auf die Card aktiviert Audio + startet Mission
window.__heliReviewStart = function() {
// AudioContext muss per User-Gesture freigeschaltet werden
try {
if (!flightAudioCtx) { initFlightAudio(); }
if (flightAudioCtx && flightAudioCtx.state === 'suspended') flightAudioCtx.resume();
} catch(_){}
selectMission(forced.id);
};
return;
}
}
// teacher_started: Mission aus Level-Pool direkt auswaehlen, Auswahl ueberspringen
if (window.HELI_SESSION_MODE === 'teacher_started' && window.HELI_FORCED_LEVEL) {
var pool = missionsForLevel(window.HELI_FORCED_LEVEL);
if (pool.length) {
var pick = pool[Math.floor(Math.random() * pool.length)];
// Kurz informieren, dann automatisch starten — kein Klick noetig.
var cards = document.getElementById('missionCards');
if (cards) cards.innerHTML = '<div class="mission-card" style="grid-column:1/-1;text-align:center;padding:40px">'
+ '<h3>🚁 Impuls von der Lehrkraft</h3>'
+ '<p style="margin:8px 0 16px;color:var(--slate)">Level ' + window.HELI_FORCED_LEVEL + ' — Einsatz wird vorbereitet ...</p>'
+ '<p style="font-weight:700;color:var(--forest)">' + pick.title + '</p>'
+ '<p style="color:var(--slate)">' + pick.region + '</p>'
+ '</div>';
setTimeout(function(){ selectMission(pick.id); }, 1800);
return;
}
}
// free: zufaellige 3 Missionen zur Auswahl
var shuffled = ALL_MISSIONS.sort(function(){return Math.random()-0.5;}).slice(0,3);
var html = '';
shuffled.forEach(function(m) {
var diffLabel = m.difficulty==='easy'?'Einfach':m.difficulty==='medium'?'Mittel':'Schwer';
var diffClass = 'mtag-'+m.difficulty;
html += '<div class="mission-card" onclick="selectMission(\''+m.id+'\')">';
// Alle 9 Missionen haben jetzt ein eigenes DALL-E-Bild
var imgBase = (window.HELI_BASE || '') + 'assets/missions/';
html += '<div class="mission-img" style="background:url('+imgBase+m.id+'.webp) center/cover no-repeat;background-color:var(--cream)"></div>';
html += '<div class="mission-card-body">';
html += '<h3>🚁 '+m.title+'</h3>';
html += '<p>'+m.desc+'</p>';
var cfg = BASE_CONFIG[m.base] || {stationName:'Stützpunkt', heliModel:'Hubschrauber', orgLabel:'Rettungshubschrauber', scenario:'basis'};
// Anzeige in Auftragswahl: nur Stuetzpunkt + Organisationstyp + Modell
// (Pool-Kennzeichen wird erst beim Mission-Start gezogen → in Phase 2 sichtbar)
var heliLine = cfg.stationName + ' · ' + cfg.orgLabel + ' (' + (cfg.heliModelShort || cfg.heliModel) + ')';
html += '<p style="font-size:.65rem;color:var(--slate)">'+heliLine+' · '+m.region+'</p>';
html += '<div class="tags"><span class="mtag '+diffClass+'">'+diffLabel+'</span><span class="mtag mtag-info">'+m.waypoints.length+' Wegpunkte</span></div>';
html += '</div></div>';
});
document.getElementById('missionCards').innerHTML = html;
}
function selectMission(id) {
game.mission = ALL_MISSIONS.find(function(m){return m.id===id;});
// Live-View „Spielt seit" zeigt Zeit DER AKTUELLEN MISSION, nicht
// der Module-Session insgesamt. Stempel beim Mission-Start.
game.missionStartedAt = Date.now();
// Route filtern: redundante Endpunkte (zweitletzter und letzter Wegpunkt
// weniger als 2 km auseinander) zusammenfassen — der Bearbeiter klickt
// sonst denselben Ort zweimal an. Mission-Target bleibt logisch erhalten.
var rawRoute = game.mission.waypoints;
var filtered = [];
var lastIdx = rawRoute.length - 1;
for (var ri = 0; ri < rawRoute.length; ri++) {
var prevWP = ri > 0 ? WP[rawRoute[ri-1]] : null;
var curWP = WP[rawRoute[ri]];
if (prevWP && curWP) {
var dKm = calcDistance(prevWP.lat, prevWP.lon, curWP.lat, curWP.lon);
// Zielwegpunkt (Mission-Target) NIE ausfiltern — sonst endet die Route am
// Zwischenstop statt am Unfallort (Bug M2: verkehr_a14 wurde geschluckt).
// Stattdessen den vorigen Zwischenstop entfernen, wenn er zu nah am Ziel ist.
// Distanz auf 1.0 km gesenkt (vorher 2.0): bei M6 wurde mellau gepoppt
// weil mellau→schlucht_mellau 1.4 km ist — Mellau ist aber didaktisch wichtig.
if (dKm < 1.0 && ri !== lastIdx) continue;
if (dKm < 1.0 && ri === lastIdx && filtered.length > 0) {
filtered.pop(); // letzter Zwischenstop war zu nah am Ziel → weg damit
}
}
filtered.push(rawRoute[ri]);
}
game.route = filtered;
// Mission-ID-Badge oben links sichtbar machen — Format "M14"
var badge = document.getElementById('missionBadge');
if (badge && game.mission) {
badge.textContent = game.mission.id.toUpperCase();
badge.style.display = 'block';
}
// Mission-Zustand frisch — keine Altlasten von voriger Mission
game.missionFailed = false;
game.failReason = '';
game.startTakeover = false;
game.startTakeoverReason = '';
game.planTakeover = false;
game.startScore = 3;
game.landingScore = 3;
game.totalStars = 0;
// Pool-Kennzeichen pro Mission neu ziehen (echtes Rotations-Prinzip)
game.stationCallsign = null;
drawCallsign(game.mission.base);
game.landingDebug = null;
// Assessment-Hook 1: Mission-Start
assessmentStart();
setPhase('plan');
}
// =========================================================
// PHASE 2: ROUTENPLANUNG
// =========================================================
var planMap = null, planLayers = [];
function initPlanPhase() {
game.planIndex = 1; // Start ist gesetzt, erster zu findender WP ist Index 1
game.planCorrect = 0;
game.planAttempts = 0; // Globaler Fehlklick-Zaehler pro Mission (max 15)
game.planLog = []; // Letzte 5 Hinweise zur Anzeige (FIFO)
game.planTakeover = false; // wird true bei 15 falschen Versuchen
game.planPhaseAudioDone = false; // wird true bei Engine phase-end-Event
hintedWaypoints = {}; // Reset der Marker-Hints fuer neue Mission
game.wpRevealAt = {}; // je Wegpunkt: Zeitpunkt, ab dem der rote Hint einblendet
game.wpTiming = []; // je gefundenem Wegpunkt: 2=vor Rot · 1=während · 0=nach Rot
// V3-Engine (2026-06-01): Audio-Pipe wird von Tool-generierter Timeline
// gesteuert. Sequenzielle Sprach-Pipe + parallel UI-Sound-Layer.
// Engine spielt Mission-Intro, alle Wegpunkt-Audios, Plan-Done in
// deterministischer Reihenfolge. Events triggern Sichtbar-/Erreicht-Logik.
initFlightAudio();
// Platzhalter-Text, bis Mission-Intro fertig + erster Wegpunkt-Hint via Engine kommt
var pi = document.getElementById('planInstruction');
if (pi) pi.innerHTML = '<h3 style="opacity:.7">🎙 Tower meldet sich gleich …</h3>'
+ '<p style="opacity:.6;font-style:italic">Bitte zuerst Briefing anhören.</p>';
document.getElementById('planProgress').textContent = 'Tower-Briefing';
if (game.mission && game.mission.id) {
startHeliAudioEngineForPlan();
}
if (!planMap) {
planMap = L.map('planMap', {zoomControl:false}).setView([47.2, 10.5], 9);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:'© OpenStreetMap', maxZoom:16
}).addTo(planMap);
L.control.zoom({position:'topright'}).addTo(planMap);
L.control.scale({position:'bottomleft',metric:true,imperial:false}).addTo(planMap);
planMap.on('click', onPlanClick);
}
setTimeout(function(){planMap.invalidateSize();}, 100);
// Clear
planLayers.forEach(function(l){planMap.removeLayer(l);});
planLayers = [];
// Polizei-Hubschrauber-Standorte (real, aber nicht spielbar als Rettung)
// Position aus DB (geo_waypoints.hohenems): Flugplatz LOIH, lat:47.38440 lon:9.69930.
// Vorher falsche Hardcode-Position (47.33833) → ca. 5 km zu weit suedlich.
var POLICE_BASES = [
{ key:'hohenems_polizei', name:'Polizeihubschrauber Vorarlberg', lat:47.38440, lon:9.69930,
ort:'Flugplatz Hohenems-Dornbirn (LOIH)', heli:'Eurocopter EC135',
info:'Bundespolizei-Flugeinsatzstelle Vorarlberg. Aufgaben: Fahndung, Suchflüge, Verkehrsüberwachung, Bergrettungs-Unterstützung. <b>Kein Patiententransport mit Winde</b> — das ist Aufgabe des Notarzthubschraubers in Nenzing (OE-XEN).' }
];
POLICE_BASES.forEach(function(pb) {
var m = L.marker([pb.lat, pb.lon], {
icon: L.divIcon({
className:'',
html:'<div style="width:26px;height:26px;background:#2255aa;border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;color:#fff;box-shadow:0 2px 6px rgba(0,0,0,.3);cursor:pointer" title="Polizeihubschrauber">🚁</div>',
iconSize:[26,26], iconAnchor:[13,13]
}),
zIndexOffset: 200
}).addTo(planMap);
var imgBase = (window.HELI_BASE || '') + 'assets/';
m.bindPopup(
'<div style="min-width:240px;font-family:system-ui">'
+ '<img src="'+imgBase+'heli-ec135.webp" alt="EC135" style="width:100%;max-width:240px;border-radius:6px;margin-bottom:6px;background:#eef">'
+ '<div style="font-weight:700;color:#2d5434;margin-bottom:2px">'+pb.name+'</div>'
+ '<div style="font-size:11px;color:#3d5165;margin-bottom:4px">'+pb.ort+' · '+pb.heli+'</div>'
+ '<div style="font-size:11px;line-height:1.45;color:#1f2a24">'+pb.info+'</div>'
+ '</div>',
{ maxWidth: 280, className: 'police-popup' }
);
planLayers.push(m);
});
// POIs aus DB (von PHP injiziert als DB_POIS)
if (typeof DB_POIS !== 'undefined') {
DB_POIS.forEach(function(p) {
// Hohenems wird oben als Polizei-POI gezeichnet — DB-Marker ueberspringen
if (p.key === 'hohenems') return;
if (p.type === 'peak') {
var m = L.marker([p.lat,p.lon],{icon:L.divIcon({className:'',html:'<div style="width:20px;height:20px;background:#fff;border:2px solid #5a8a5e;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;box-shadow:0 1px 3px rgba(0,0,0,.2)">🏔️</div>',iconSize:[20,20],iconAnchor:[10,10]}),interactive:false,zIndexOffset:-100}).addTo(planMap);
planLayers.push(m);
var lbl = L.marker([p.lat,p.lon],{icon:L.divIcon({className:'',html:'<div style="font-size:7px;font-weight:600;color:#5a8a5e;white-space:nowrap;background:rgba(220,234,221,.85);padding:1px 4px;border-radius:3px">'+p.name+(p.info?' '+p.info:'')+'</div>',iconSize:[100,12],iconAnchor:[-12,6]}),interactive:false,zIndexOffset:-100}).addTo(planMap);
planLayers.push(lbl);
} else if (p.type === 'city') {
var m = L.marker([p.lat,p.lon],{icon:L.divIcon({className:'',html:'<div style="width:18px;height:18px;background:#fff;border:2px solid #4a7c8a;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;box-shadow:0 1px 3px rgba(0,0,0,.2)">🏙️</div>',iconSize:[18,18],iconAnchor:[9,9]}),interactive:false,zIndexOffset:-50}).addTo(planMap);
planLayers.push(m);
var lbl = L.marker([p.lat,p.lon],{icon:L.divIcon({className:'',html:'<div style="font-size:8px;font-weight:700;color:#1f4e5a;white-space:nowrap;background:rgba(218,232,236,.85);padding:1px 5px;border-radius:3px">'+p.name+'</div>',iconSize:[80,14],iconAnchor:[-10,7]}),interactive:false,zIndexOffset:-50}).addTo(planMap);
planLayers.push(lbl);
} else if (p.type === 'base') {
var m = L.marker([p.lat,p.lon],{icon:L.divIcon({className:'',html:'<div style="width:22px;height:22px;background:#fff;border:2px solid #d4a017;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 1px 3px rgba(0,0,0,.2)">🚁</div>',iconSize:[22,22],iconAnchor:[11,11]}),interactive:false,zIndexOffset:-30}).addTo(planMap);
planLayers.push(m);
var lbl = L.marker([p.lat,p.lon],{icon:L.divIcon({className:'',html:'<div style="font-size:7px;font-weight:700;color:#9a7800;white-space:nowrap;background:rgba(255,255,255,.85);padding:1px 4px;border-radius:3px;border-left:3px solid #d4a017">'+p.name+'</div>',iconSize:[80,12],iconAnchor:[-14,6]}),interactive:false,zIndexOffset:-30}).addTo(planMap);
planLayers.push(lbl);
}
});
}
// Schlechtwettergebiete (zufällig platziert, halbtransparente graue Kreise)
var weatherZones = [];
for (var wi=0; wi<2+Math.floor(Math.random()*2); wi++) {
var allPtsFlat = game.route.map(function(id){return WP[id];}).filter(Boolean);
var midIdx = Math.floor(Math.random() * allPtsFlat.length);
var midWP = allPtsFlat[midIdx];
var wLat = midWP.lat + (Math.random()-0.5)*0.15;
var wLon = midWP.lon + (Math.random()-0.5)*0.3;
var wRadius = 4000 + Math.random()*8000;
var wCircle = L.circle([wLat, wLon], {radius:wRadius, color:'#6b7d8f', fillColor:'#6b7d8f', fillOpacity:0.12, weight:1, dashArray:'6,4'}).addTo(planMap);
planLayers.push(wCircle);
var wLabel = L.marker([wLat, wLon],{icon:L.divIcon({className:'',html:'<div style="font-size:13px;color:#3d5165;font-weight:800;text-align:center;text-shadow:0 1px 2px rgba(255,255,255,.8)">⛈️ Schlechtwetter</div>',iconSize:[140,20],iconAnchor:[70,10]}),interactive:false}).addTo(planMap);
planLayers.push(wLabel);
}
// 16-teilige Kompass-Windrose mit Live-Zeiger, der vom letzten Wegpunkt aus
// dem Mauszeiger folgt — Schüler sehen die Himmelsrichtung, in die sie gerade
// zeigen wollen, direkt.
if (!document.getElementById('compassOverlay')) {
var compass = document.createElement('div');
compass.id = 'compassOverlay';
// 16 Labels: vier Haupt-, vier Halb- (NO/SO/SW/NW) groß, acht Zwischen-
// Abkürzungen klein. Zeiger (roter Pfeil) rotiert via JS.
var labels = '';
var lbl16 = [
{t:'N', ang:0, size:11, col:'#c85c4a', weight:900},
{t:'NNO', ang:22.5, size:6, col:'#8a8a8a', weight:600},
{t:'NO', ang:45, size:7, col:'#6b7d8f', weight:700},
{t:'ONO', ang:67.5, size:6, col:'#8a8a8a', weight:600},
{t:'O', ang:90, size:9, col:'#6b7d8f', weight:700},
{t:'OSO', ang:112.5,size:6, col:'#8a8a8a', weight:600},
{t:'SO', ang:135, size:7, col:'#6b7d8f', weight:700},
{t:'SSO', ang:157.5,size:6, col:'#8a8a8a', weight:600},
{t:'S', ang:180, size:9, col:'#6b7d8f', weight:700},
{t:'SSW', ang:202.5,size:6, col:'#8a8a8a', weight:600},
{t:'SW', ang:225, size:7, col:'#6b7d8f', weight:700},
{t:'WSW', ang:247.5,size:6, col:'#8a8a8a', weight:600},
{t:'W', ang:270, size:9, col:'#6b7d8f', weight:700},
{t:'WNW', ang:292.5,size:6, col:'#8a8a8a', weight:600},
{t:'NW', ang:315, size:7, col:'#6b7d8f', weight:700},
{t:'NNW', ang:337.5,size:6, col:'#8a8a8a', weight:600},
];
lbl16.forEach(function(l){
var rad = l.ang * Math.PI/180;
// Text liegt auf Radius 40 (innerhalb des 48er-Kreises)
var lx = 50 + Math.sin(rad) * 40;
var ly = 50 - Math.cos(rad) * 40 + l.size*0.35; // baseline-Korrektur
labels += '<text x="'+lx.toFixed(1)+'" y="'+ly.toFixed(1)+'" text-anchor="middle" font-size="'+l.size+'" font-weight="'+l.weight+'" fill="'+l.col+'">'+l.t+'</text>';
});
// Tick-Marks fuer alle 16 Richtungen
var ticks = '';
for (var t=0; t<16; t++) {
var ang = t * 22.5 * Math.PI/180;
var outer = t%4===0 ? 48 : 45;
var inner = t%2===0 ? 43 : 44;
var x1 = 50 + Math.sin(ang) * inner, y1 = 50 - Math.cos(ang) * inner;
var x2 = 50 + Math.sin(ang) * outer, y2 = 50 - Math.cos(ang) * outer;
ticks += '<line x1="'+x1.toFixed(1)+'" y1="'+y1.toFixed(1)+'" x2="'+x2.toFixed(1)+'" y2="'+y2.toFixed(1)+'" stroke="#2d5434" stroke-width="'+(t%4===0?1.2:0.6)+'" opacity="'+(t%4===0?.9:.5)+'"/>';
}
compass.innerHTML =
'<svg viewBox="0 0 100 100" width="150" height="150">'
+ '<circle cx="50" cy="50" r="48" fill="rgba(255,255,255,.95)" stroke="#2d5434" stroke-width="1.5"/>'
+ ticks
+ labels
// Fixe Kompassnadel: Rot zeigt Nord, Grau Süd. Bewegt sich nicht.
+ '<g id="compassNeedle">'
+ '<polygon points="50,10 46,50 54,50" fill="#c85c4a"/>'
+ '<polygon points="50,90 46,50 54,50" fill="#bbb"/>'
+ '</g>'
// Kurspfeil (gold/gelb) — zeigt vom letzten Wegpunkt zur Mausposition.
// Pfeilspitze auf Radius 36, breiter Keil mit klarer Richtungsform,
// deutlich unterscheidbar von der roten Nordnadel.
+ '<g id="courseArrow" style="transform-origin:50px 50px;transition:transform .08s linear;opacity:0">'
+ '<line x1="50" y1="50" x2="50" y2="22" stroke="#c97a3f" stroke-width="2.5" stroke-linecap="round"/>'
+ '<polygon points="50,14 44,26 56,26" fill="#c97a3f" stroke="#2d5434" stroke-width="0.4"/>'
+ '</g>'
+ '<circle cx="50" cy="50" r="3" fill="#2d5434"/>'
+ '<text id="compassAbbrev" x="50" y="64" text-anchor="middle" font-size="8" font-weight="800" fill="#c97a3f" opacity="0">N</text>'
+ '</svg>';
compass.style.cssText = 'position:absolute;top:100px;right:10px;z-index:1000;pointer-events:none;opacity:.92';
document.getElementById('planMap').appendChild(compass);
}
// Maus-Tracking: Pfeil zeigt vom letzten bekannten Wegpunkt (game.planIndex-1)
// zur aktuellen Mausposition auf der Karte.
if (!planMap._compassBound) {
planMap._compassBound = true;
planMap.on('mousemove', function(e){
var route = game.route;
if (!route || game.planIndex < 1) return;
var fromKey = route[game.planIndex - 1];
var fromWP = WP[fromKey];
if (!fromWP) return;
var bearing = calcBearing(fromWP.lat, fromWP.lon, e.latlng.lat, e.latlng.lng);
var arrow = document.getElementById('courseArrow');
var abbrev = document.getElementById('compassAbbrev');
if (arrow) {
arrow.style.transform = 'rotate(' + bearing.toFixed(1) + 'deg)';
arrow.style.opacity = '1';
}
if (abbrev) {
abbrev.textContent = bearingToAbbrev(bearing);
abbrev.setAttribute('opacity', '1');
}
});
planMap.on('mouseout', function(){
var arrow = document.getElementById('courseArrow');
var abbrev = document.getElementById('compassAbbrev');
if (arrow) arrow.style.opacity = '0';
if (abbrev) abbrev.setAttribute('opacity', '0');
});
}
// V3-Engine (2026-06-01): Mission-Waypoints werden NICHT mehr alle vorab
// sichtbar gemacht. Pro wp-sichtbar-Event (nach dem zugehoerigen Audio)
// wird der jeweils naechste Wegpunkt als Marker hinzugefuegt — siehe
// showWaypointHint(wpKey) unten + startHeliAudioEngineForPlan onEvent.
// Vorteil: schnelle Schueler*innen koennen vorab klicken, muessen aber
// den Ort raten (Geo-Wissen gefordert). Audio laeuft trotzdem zu Ende.
// Show start marker mit pulsierenden Orientierungs-Ringen
var startWP = WP[game.route[0]];
var startM = L.marker([startWP.lat, startWP.lon], {
icon: L.divIcon({
className:'',
html:'<div class="start-pulse-wrap">'
+ '<span class="start-pulse-ring"></span>'
+ '<span class="start-pulse-ring start-pulse-ring-2"></span>'
+ '<span class="start-pulse-ring start-pulse-ring-3"></span>'
+ '<div class="wp-set start">🚁</div>'
+ '</div>',
iconSize:[120,120], iconAnchor:[60,60]
}),
interactive:false,
zIndexOffset: 1000
}).addTo(planMap);
planLayers.push(startM);
// Cinematic-Zoom (2026-06-02): Erst Vollbild ganz Oesterreich, dann waehrend
// mission_intro Audio (ca. 8-11 s) smooth auf Mission-Bounds zoomen.
// Schueler*innen sehen erst raeumlichen Kontext, dann Fokus. Klicks
// waehrend Zoom blockiert (sonst Race mit halbgezoomter Karte).
var allPts = game.route.map(function(id){var w=WP[id];return w?[w.lat,w.lon]:null;}).filter(Boolean);
var missionBounds = L.latLngBounds(allPts).pad(0.2);
// Oesterreich-Gesamt-Bounds (Boden- bis Neusiedler See, Nordtirol bis Karawanken)
var austriaBounds = L.latLngBounds([[46.4, 9.5], [49.0, 17.2]]);
planMap.fitBounds(austriaBounds, { animate: false, padding: [10, 10] });
game.zoomLocked = true;
// Smooth Fly nach kurzer Verzoegerung (Tiles laden, invalidateSize greift)
setTimeout(function(){
planMap.flyToBounds(missionBounds, { duration: 7, easeLinearity: 0.35, padding: [40, 40] });
}, 250);
// Klicks erst wieder zulassen, wenn Zoom + kurzer Puffer durch
setTimeout(function(){ game.zoomLocked = false; }, 7500);
// Info-Panel füllen
var cfg = BASE_CONFIG[game.mission.base] || {};
document.getElementById('pi-mission').textContent = game.mission.title;
document.getElementById('pi-heli').textContent = displayHeliName(game.mission.base);
document.getElementById('pi-wp').textContent = game.route.length;
document.getElementById('pi-region').textContent = game.mission.region || '—';
// V3-Engine: showPlanInstruction wird NICHT mehr direkt gerufen.
// Engine feuert 'wp-sichtbar'-Event nach Mission-Intro + WP-Geo-Audio,
// erst dann zeigt sich der Wegpunkt-Hint in der Instruction-Box.
initFlightAudio();
}
function showPlanInstruction() {
var idx = game.planIndex;
var route = game.route;
if (idx >= route.length) {
// Alle Wegpunkte gefunden — Einsatz-Briefing vor dem Start
var cfg = BASE_CONFIG[game.mission.base] || {stationName:'Stützpunkt', scenario:'basis'};
var heliDisplayName = displayHeliName(game.mission.base);
var startWP = WP[route[0]], endWP = WP[route[route.length-1]];
var totalKm = 0;
for (var i=1; i<route.length; i++) {
var a = WP[route[i-1]], b = WP[route[i]];
if (a && b) totalKm += calcDistance(a.lat,a.lon,b.lat,b.lon);
}
var szenarioInfo = (cfg.scenario === 'stadt')
? '<strong>Start vom Krankenhausdach</strong> mitten in der Stadt. Kategorie-A-Profil: schräg nach vorne abheben, dabei Geschwindigkeit aufbauen. Hindernisse rundum — Antennen, Kräne, Häuserfronten. Notlandeoptionen entlang der Flugbahn planen.'
: '<strong>Start vom Flugplatz</strong> — offener Bereich, Kategorie-B-Profil: gerader Steigflug, Sichtflug. Wenig Hindernisse, dafür volle Aufmerksamkeit auf Wetter und Funk.';
// Autobahn-Einsatz: zusaetzlicher Sicherheits-Hinweis fuer die Landung.
// Vorher nur als Audio in der Landing-Phase — Thomas-Feedback 2026-05-31:
// muss im Briefing stehen, bevor wir losfliegen.
var isAutobahnMission = (game.mission.theme === 'traffic') ||
(game.mission.target && game.mission.target.indexOf('verkehr_') === 0);
var autobahnHinweis = isAutobahnMission
? '<div style="margin-top:.6rem;padding:.5rem .7rem;background:#fff3e0;border-left:3px solid var(--orange);font-size:.92rem;line-height:1.4"><strong>⚠ Autobahn-Landung:</strong> Vor dem Aufsetzen <b>Sichtprüfung der Landezone</b>. Nur landen, wenn keine <b>Personen oder Fahrzeuge</b> im Aufsetzbereich sind — Gaffer und Einsatzkräfte zirkulieren oft direkt am Unfallort. Bei Bedarf nochmal kreisen.</div>'
: '';
document.getElementById('planInstruction').innerHTML =
'<h3 class="plan-ok">✅ Route vollständig — Einsatz-Briefing</h3>'
+'<div style="display:grid;grid-template-columns:1fr 1fr;gap:.3rem .9rem;margin:.4rem 0 .7rem;font-size:.95rem">'
+'<div><strong>Einsatz:</strong> '+game.mission.title+'</div>'
+'<div><strong>Hubschrauber:</strong> '+heliDisplayName+'</div>'
+'<div><strong>Start:</strong> '+(startWP?startWP.name:'—')+'</div>'
+'<div><strong>Ziel:</strong> '+(endWP?endWP.name:'—')+'</div>'
+'<div><strong>Wegpunkte:</strong> '+route.length+'</div>'
+'<div><strong>Flugdistanz:</strong> '+totalKm.toFixed(1)+' km</div>'
+'</div>'
+'<p style="font-size:.95rem;line-height:1.4;border-left:3px solid var(--forest);padding-left:.7rem;margin-bottom:.8rem">'+szenarioInfo+'</p>'
+autobahnHinweis
+'<button id="startReleaseBtn" class="btn-big" style="background:#c85c4a;font-size:1.1rem;padding:.8rem 2.4rem;margin-top:.6rem" disabled>🚁 Startfreigabe in Kürze</button>';
document.getElementById('planProgress').textContent = 'Briefing';
// V3.2 (2026-06-01): Startfreigabe-Button erst aktiv nach phase-end-Event
// der Engine. Heisst: r_nav_plan_done muss komplett ausgespielt sein.
// setEnabled wird vom Engine-Callback startHeliAudioEngineForPlan/onEvent
// bei type='phase-end' gerufen (siehe enableStartReleaseButton).
initFlightAudio();
var btn = document.getElementById('startReleaseBtn');
if (btn) {
btn.disabled = true;
btn.textContent = '🎙 Audio läuft noch ...';
btn.onclick = null;
}
// Falls Engine schon phase-end gemeldet hatte (z.B. wenn letzter WP nach
// Plan-Done geklickt wird — sollte selten passieren), sofort freigeben.
if (game.planPhaseAudioDone) enableStartReleaseButton();
return;
}
var fromWP = WP[route[idx-1]];
var toWP = WP[route[idx]];
var bearing = calcBearing(fromWP.lat, fromWP.lon, toWP.lat, toWP.lon);
var dist = calcDistance(fromWP.lat, fromWP.lon, toWP.lat, toWP.lon);
var dirName = bearingToName(bearing);
var dirEmoji = bearingToEmoji(bearing);
document.getElementById('planInstruction').innerHTML =
'<h3><span class="dir">'+dirEmoji+'</span> Wegpunkt '+idx+': <strong>'+toWP.name+'</strong></h3>'
+'<p>Von '+fromWP.name+' Richtung <strong>'+dirName+'</strong>, ca. <strong>'+dist.toFixed(0)+' km</strong> — auf der Karte anklicken.</p>';
document.getElementById('planProgress').textContent = 'Wegpunkt '+idx+'/'+( route.length-1);
// V3-Engine: Audio wird NICHT mehr hier direkt getriggert. Die Engine
// spielt den geo-Audio entsprechend der Timeline. showPlanInstruction
// wird vom Engine-Event 'wp-sichtbar' aufgerufen.
if (game.planLog && game.planLog.length) renderPlanErrorLog();
}
function onPlanClick(e) {
if (game.zoomLocked) return; // waehrend Cinematic-Zoom keine Klicks
if (game.planIndex >= game.route.length) return;
var targetWP = WP[game.route[game.planIndex]];
var dist = calcDistance(e.latlng.lat, e.latlng.lng, targetWP.lat, targetWP.lon);
// 1.5 km Radius akzeptieren — fordernd, aber machbar
if (dist < 1.5) {
// Richtig!
game.planCorrect++;
// Timing-Stufe dieses Wegpunkts festhalten: 2 = VOR dem roten Hint (Bonus,
// reine Navigation), 1 = WÄHREND der 10-s-Einblendung, 0 = NACH voller Anzeige.
(function(){
var wpKey = game.route[game.planIndex];
var revealAt = (game.wpRevealAt || {})[wpKey];
var tier;
if (revealAt == null) tier = 2; // Hint noch nicht gestartet
else if (Date.now() - revealAt < 10000) tier = 1; // während der Einblendung
else tier = 0; // roter Punkt schon voll da
(game.wpTiming = game.wpTiming || []).push(tier);
})();
// Fehlversuche-Zaehler bleibt GLOBAL pro Mission (kein Reset bei richtig).
// Aber die Hinweisliste verschwindet — beim naechsten Wegpunkt sieht man
// einen sauberen Stand, alte Fail-Hinweise lenken nicht mehr ab.
game.planLog = [];
var m = L.marker([targetWP.lat, targetWP.lon], {
icon: L.divIcon({className:'', html:'<div class="wp-set">'+game.planIndex+'</div>', iconSize:[24,24], iconAnchor:[12,12]})
}).addTo(planMap);
planLayers.push(m);
// Linie vom vorherigen
var prevWP = WP[game.route[game.planIndex-1]];
var line = L.polyline([[prevWP.lat, prevWP.lon],[targetWP.lat, targetWP.lon]], {color:'#c85c4a', weight:3, dashArray:'8,6'}).addTo(planMap);
planLayers.push(line);
game.planIndex++;
// V3-Engine: UI-Klick-OK sofort + Pre-Click an Engine.
// isLast=true triggert Skip-To-Plan-Done (laufendes Audio abbrechen,
// alle r_wp_geo aus Queue, direkt r_nav_plan_done). Nutzer kennt
// die Route auswendig und will keine Wegpunkt-Audios mehr.
var isLast = (game.planIndex >= game.route.length);
if (window.heliAudioEngine) {
window.heliAudioEngine.notifyClick(game.route[game.planIndex-1], true, { isLast: isLast });
}
showPlanInstruction();
// V3.3: Nach jedem Klick prüfen, ob Startfreigabe jetzt möglich ist
// (Audio kann längst durch sein und nur noch der letzte Klick fehlte).
checkStartReleaseReady();
} else {
// Falsch — UI-Fail-Sound sofort + Sprach-Hinweis dahinter (in Pipe)
game.planAttempts++;
var bearing = calcBearing(e.latlng.lat, e.latlng.lng, targetWP.lat, targetWP.lon);
var dir = bearingToName(bearing);
if (window.heliAudioEngine) {
window.heliAudioEngine.notifyClick(game.route[game.planIndex], false);
}
initFlightAudio();
playRadioAudio(dist < 10 ? 'r_nav_plan_wrong_near' : 'r_nav_plan_wrong_far',
{ priority: 'now' });
// Hinweis-Log: nur die letzten 5 anzeigen, durchnummeriert (FIFO)
game.planLog.push({ n: game.planAttempts, name: targetWP.name, dir: dir, dist: dist });
if (game.planLog.length > 5) game.planLog.shift();
renderPlanErrorLog();
// Nach 15 falschen Versuchen uebernimmt der Pilot — Routenplanung
// gilt als gescheitert (keine Sterne fuer die Zielsuche).
if (game.planAttempts >= 15) {
game.planTakeover = true;
// Restliche Wegpunkte automatisch markieren, damit der Heli die Route hat
while (game.planIndex < game.route.length) {
var auto = WP[game.route[game.planIndex]];
if (auto) {
var prev = WP[game.route[game.planIndex-1]];
var lineAuto = L.polyline([[prev.lat, prev.lon],[auto.lat, auto.lon]], {color:'#8a8a8a', weight:2, dashArray:'4,4', opacity:0.6}).addTo(planMap);
var dotAuto = L.marker([auto.lat, auto.lon], {
icon: L.divIcon({className:'', html:'<div class="wp-set" style="background:#8a8a8a;color:#fff">'+game.planIndex+'</div>', iconSize:[24,24], iconAnchor:[12,12]}),
interactive:false
}).addTo(planMap);
planLayers.push(lineAuto, dotAuto);
}
game.planIndex++;
}
document.getElementById('planInstruction').innerHTML =
'<h3 style="color:var(--orange)">🧑‍✈️ Pilot übernimmt die Routenplanung</h3>'
+ '<p>Nach 15 Versuchen führt der Pilot die Karte selbst weiter — keine Sterne für die Zielsuche.</p>'
+ '<button class="btn-big" onclick="setPhase(\'start\')" style="background:var(--orange);font-size:1.2rem;padding:.9rem 2.4rem">🚁 Weiter zum Start</button>';
document.getElementById('planProgress').textContent = 'Briefing (Pilot)';
checkStartReleaseReady();
}
}
}
// Rendert die letzten max. 3 Fehlermeldungen, durchnummeriert.
function renderPlanErrorLog() {
var box = document.getElementById('planErrorLog');
if (!box) {
box = document.createElement('div');
box.id = 'planErrorLog';
box.style.cssText = 'margin-top:.6rem;display:grid;gap:.3rem';
var inst = document.getElementById('planInstruction');
if (inst) inst.appendChild(box);
}
var remaining = Math.max(0, 15 - game.planAttempts);
box.innerHTML = game.planLog.map(function(e){
return '<p class="plan-err" style="margin:0">'
+ '<b>#'+e.n+'</b> ❌ Nicht ganz! '+e.name+' liegt '+e.dir
+ ' von hier ('+e.dist.toFixed(0)+' km entfernt). Versuch es nochmal.</p>';
}).join('')
+ '<p style="font-size:.78rem;color:'+(remaining<=3?'var(--orange)':'var(--slate)')+';margin:.2rem 0 0">'
+ 'Versuch '+game.planAttempts+'/15 · noch '+remaining+' bis zum Pilot-Eingriff</p>';
}
// =========================================================
// PHASE 3: START
// =========================================================
function initStartPhase() {
var cfg = BASE_CONFIG[game.mission.base] || {heli:'oamtc', scenario:'basis', stationName:'Stützpunkt'};
var winterParam = game.mission.winter ? '&winter=1' : '';
var baseWP = (typeof WP !== 'undefined' && WP[game.mission.base]) ? WP[game.mission.base] : null;
var baseName = baseWP ? baseWP.name : (cfg.stationName || '');
var heliDisplayName = displayHeliName(game.mission.base);
var params = 'heli=' + encodeURIComponent(cfg.heli)
+ '&scenario=' + encodeURIComponent(cfg.scenario)
+ '&helikey=' + encodeURIComponent(cfg.heliKey || '')
+ '&heliname=' + encodeURIComponent(heliDisplayName)
+ '&callsign=' + encodeURIComponent(game.stationCallsign || '')
+ '&basename=' + encodeURIComponent(baseName)
+ '&mission=' + encodeURIComponent(game.mission.title || '')
+ '&missionid=' + encodeURIComponent(game.mission.id || '')
+ '&autostart=1' + winterParam;
// Cache-Buster: iPad-Safari cacht iframe-HTML aggressiv; bei jedem Phasen-Eintritt frisch laden.
document.getElementById('startFrame').src = (window.HELI_BASE || '') + 'start.html?' + params + '&_cb=' + Date.now();
window.addEventListener('message', onStartMessage);
// Fallback nur greifen, wenn der Spieler 120 s NACH Phasen-Start kein Signal aus dem iframe liefert
if (game.startFallbackTimer) clearTimeout(game.startFallbackTimer);
game.startFallbackTimer = setTimeout(function() {
if (game.phase === 'start') {
game.startScore = 3;
showFlightTransition(function() {});
setTimeout(function() { setPhase('flight'); }, 200);
}
}, 120000);
}
function showFlightTransition(done, opts) {
opts = opts || {};
var o = document.createElement('div');
o.id = 'flightTransition';
o.style.cssText = 'position:fixed;inset:0;background:var(--cream);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:9999;font-family:Inter,sans-serif;text-align:center;padding:2rem';
if (opts.takeover) {
o.innerHTML =
'<div style="font-size:3.5rem;margin-bottom:1rem">🧑‍✈️</div>'
+ '<h2 style="color:var(--orange);font-size:1.8rem;margin-bottom:.6rem">Abflug durch Chefpilotin übernommen</h2>'
+ '<p style="color:var(--slate);font-size:1.2rem;max-width:560px">'
+ (opts.reason ? opts.reason + '. ' : '')
+ 'Die Chefpilotin hat den Heli sicher aus der Startzone geflogen — wir sind unterwegs.</p>'
+ '<div style="margin-top:1.5rem;font-size:.9rem;color:var(--slate);opacity:.6">Karte wird geladen …</div>';
} else {
o.innerHTML =
'<div style="font-size:3.5rem;margin-bottom:1rem">🚁</div>'
+ '<h2 style="color:var(--forest);font-size:1.8rem;margin-bottom:.6rem">Abflug erfolgreich</h2>'
+ '<p style="color:var(--slate);font-size:1.2rem;max-width:500px">Wir begeben uns auf den Weg laut Navigationsplanung.</p>'
+ '<div style="margin-top:1.5rem;font-size:.9rem;color:var(--slate);opacity:.6">Karte wird geladen …</div>';
}
document.body.appendChild(o);
initFlightAudio();
playRadioAudio(opts.takeover ? 'r_nav_departure_takeover' : 'r_nav_departure_ok');
setTimeout(function() { o.remove(); done(); }, opts.takeover ? 3200 : 2500);
}
function onStartMessage(e) {
if (e.data && e.data.type === 'heli_start_complete') {
game.startScore = e.data.stars || 3;
// Start-Uebernahme ist KEIN Mission-Misserfolg — der Penalty kommt
// ueber den startScore (0 Sterne wenn der Pilot uebernehmen musste).
// missionFailed wird nur bei Landing-Crash gesetzt.
game.startTakeover = !!e.data.takeover;
game.startTakeoverReason = e.data.reason || '';
window.removeEventListener('message', onStartMessage);
if (game.startFallbackTimer) { clearTimeout(game.startFallbackTimer); game.startFallbackTimer = null; }
showFlightTransition(function() {}, { takeover: !!e.data.takeover, reason: e.data.reason });
setTimeout(function() { setPhase('flight'); }, e.data.takeover ? 400 : 200);
}
}
// =========================================================
// PHASE 4: KARTENFLUG
// =========================================================
var flightMap = null, flightLayers = [], heliMarker = null, flightInterval = null;
function initFlightPhase() {
game.flightIndex = 0;
game.flightSpeed = 1;
game._navAudioPlayed = {}; // pro Flug frisch
game.flightPhaseAudioDone = false;
// V3.2 (2026-06-01): Engine-driven Audio fuer Flug-Phase analog Plan.
// Spielt mission_geo, heli_intro, altitude, fuel, weather, im_zielgebiet
// in der vom Tool definierten Reihenfolge. Animation laeuft erstmal
// wie bisher (Stufe 2 koppelt Animations-Dauer an Audio-Dauer).
initFlightAudio();
if (game.mission && game.mission.id) {
startHeliAudioEngineForFlight();
}
if (!flightMap) {
flightMap = L.map('flightMap', {zoomControl:false}).setView([47.2, 10.5], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:'© OpenStreetMap', maxZoom:16
}).addTo(flightMap);
L.control.zoom({position:'topright'}).addTo(flightMap);
L.control.scale({position:'bottomleft',metric:true,imperial:false}).addTo(flightMap);
}
setTimeout(function(){flightMap.invalidateSize();}, 100);
flightLayers.forEach(function(l){flightMap.removeLayer(l);});
flightLayers = [];
// Draw full route
var allPts = game.route.map(function(id){var w=WP[id];return w?[w.lat,w.lon]:null;}).filter(Boolean);
var routeLine = L.polyline(allPts, {color:'#2d5434', weight:3, dashArray:'6,4', opacity:.4}).addTo(flightMap);
flightLayers.push(routeLine);
// WP markers
game.route.forEach(function(id,i) {
var w = WP[id]; if(!w) return;
var cls = i===0?'wp-set start':i===game.route.length-1?'wp-set target':'wp-set';
var label = i===0?'🚁':i===game.route.length-1?'🎯':i;
var m = L.marker([w.lat,w.lon],{icon:L.divIcon({className:'',html:'<div class="'+cls+'">'+label+'</div>',iconSize:[24,24],iconAnchor:[12,12]})}).addTo(flightMap);
flightLayers.push(m);
});
// Heli marker
var startWP = WP[game.route[0]];
heliMarker = L.marker([startWP.lat, startWP.lon], {
icon: L.divIcon({className:'', html:'<div style="font-size:28px;filter:drop-shadow(0 2px 4px rgba(0,0,0,.4))">🚁</div>', iconSize:[32,32], iconAnchor:[16,16]}),
zIndexOffset: 2000
}).addTo(flightMap);
flightLayers.push(heliMarker);
flightMap.fitBounds(L.latLngBounds(allPts).pad(0.15));
// Start flying
startFlight();
}
function startFlight() {
game.flightIndex = 0;
// 1) Karte kurz stehen lassen (Heli am Startpunkt sichtbar), dann
// 2) Routen-Überblick ansagen/anzeigen, dann
// 3) ersten Segment-Flug starten.
var startWP = WP[game.route[0]];
var endWP = WP[game.route[game.route.length - 1]];
// Gegend-Labels: info-Spalte der Zwischen-WPs (z.B. "Walgau", "Laternsertal")
var regions = [];
for (var i = 1; i < game.route.length - 1; i++) {
var w = WP[game.route[i]];
if (w && w.info && regions.indexOf(w.info) === -1) regions.push(w.info);
}
// Gesamtstrecke
var totalKm = 0;
for (var j = 1; j < game.route.length; j++) {
var a = WP[game.route[j-1]], b = WP[game.route[j]];
if (a && b) totalKm += calcDistance(a.lat, a.lon, b.lat, b.lon);
}
var regionText = regions.length ? regions.join(', ') : (game.mission.region || 'die Region');
// Grobe Gesamtrichtung Start → Ziel (Luftlinie) für Intro
var introBearing = (startWP && endWP) ? calcBearing(startWP.lat, startWP.lon, endWP.lat, endWP.lon) : 0;
var introDirName = bearingToName(introBearing); // z.B. 'nach Südwesten'
var intro = 'Pilot: Flug von ' + (startWP ? startWP.name : '—')
+ ' ' + introDirName + ' zum Einsatzort. Wir überfliegen ' + regionText + '. '
+ 'Gesamtstrecke etwa ' + Math.round(totalKm) + ' Kilometer.';
// Schritt 1: Karte zeigen + kurze "Route wird geprueft"-Ansage — 1,5 s
document.getElementById('flightInfo').innerHTML =
'<p style="font-weight:700;color:var(--forest)">🗺️ Route wird geprüft …</p>'
+ '<p style="font-size:.85rem;color:var(--slate);margin-top:.3rem">Startpunkt: '+(startWP?startWP.name:'—')+'</p>';
initFlightAudio();
// Kein "route_check"-Floskel-Audio mehr — Text reicht.
setTimeout(function() {
// Schritt 2: Pilot sagt die Route an (nur Text, kein doppeltes Richtungs-Audio
// — die Richtungsansage uebernimmt das erste Segment in flyToNext).
document.getElementById('flightInfo').innerHTML =
'<p style="font-weight:700;color:var(--forest)">'+intro+'</p>'
+ '<p style="font-size:.85rem;color:var(--slate);margin-top:.3rem">'
+ (startWP?startWP.name:'—')+' → '+(endWP?endWP.name:'—')
+ '</p>';
initFlightAudio();
// Schritt 3: Nach 1,5 s ersten Segment-Flug starten
setTimeout(flyToNext, 1500);
}, 1500);
}
function flyToNext() {
game.flightIndex++;
if (game.flightIndex >= game.route.length) {
// Ziel erreicht — direkt zur Landung
document.getElementById('flightInfo').innerHTML = '<h3 style="color:var(--forest)">🎯 Zielgebiet erreicht!</h3><p>Landeanflug wird eingeleitet...</p>';
setTimeout(function(){ setPhase('landing'); }, 2000);
return;
}
var fromWP = WP[game.route[game.flightIndex-1]];
var toWP = WP[game.route[game.flightIndex]];
var dist = calcDistance(fromWP.lat, fromWP.lon, toWP.lat, toWP.lon);
var flyTime = Math.max(5000, (dist / 67.5) * 60000) / game.flightSpeed; // 67,5 km/h — knapp unter zwei Drittel der echten Reisegeschwindigkeit, Ansagen passen noch
document.getElementById('flightStatus').textContent = 'Segment '+game.flightIndex+'/'+(game.route.length-1);
// Pilot-Kommentar: sinnvoll, pro Station variiert
var dirName = bearingToName(calcBearing(fromWP.lat,fromWP.lon,toWP.lat,toWP.lon));
var toKey = game.route[game.flightIndex];
// Abwechselnd geo/kids pro Segment — so mischt sich Geografie und Alltag
var kind = (game.flightIndex % 2 === 0) ? 'geo' : 'kids';
var wpInfo = getWaypointFact(toKey, kind) || getWaypointInfo(toWP.name);
var comment = 'Pilot: Kurs '+dirName+', nächster Wegpunkt '+toWP.name+'. '+dist.toFixed(0)+' km.';
if (wpInfo) comment += ' ' + wpInfo;
// V3.2 (2026-06-01): Richtungs-Audios (dir_nord.mp3 etc.) deaktiviert.
// Alte maennliche Funkstimme (sounds/nav/*.mp3) kollidierte mit der
// V3-Engine, die jetzt alle Flug-Audios timeline-basiert spielt.
// Wenn wir Richtungs-Hinweise zurueck wollen, kommen sie ueber die Engine
// als regulaere Audio-Slots (z.B. r_nav_dir_nord). Der Text-Pilot-Kommentar
// bleibt als visueller Info-Block sichtbar.
initFlightAudio();
if (!game._navAudioPlayed) game._navAudioPlayed = {};
// playNavAudio(bearingToAudio8(...)) — abgeschaltet
document.getElementById('flightInfo').innerHTML =
'<p style="font-weight:700;color:var(--forest)">'+comment+'</p>'
+'<p style="font-size:.7rem;color:var(--slate);margin-top:.3rem">'+fromWP.name+' → '+toWP.name+' · '+dist.toFixed(1)+' km</p>';
// Draw active segment
var segLine = L.polyline([[fromWP.lat,fromWP.lon],[toWP.lat,toWP.lon]], {color:'#c97a3f', weight:4}).addTo(flightMap);
flightLayers.push(segLine);
// Animate heli — zügig
var steps = Math.max(20, Math.round(flyTime / 60));
var step = 0;
var latStep = (toWP.lat - fromWP.lat) / steps;
var lonStep = (toWP.lon - fromWP.lon) / steps;
if (flightInterval) clearInterval(flightInterval);
flightInterval = setInterval(function() {
step++;
var lat = fromWP.lat + latStep * step;
var lon = fromWP.lon + lonStep * step;
heliMarker.setLatLng([lat, lon]);
flightMap.panTo([lat, lon], {animate:true, duration:0.2});
if (step >= steps) {
clearInterval(flightInterval);
flightInterval = null;
setTimeout(flyToNext, 1500 / game.flightSpeed);
}
}, 40 / game.flightSpeed);
}
function setFlightSpeed(s) {
game.flightSpeed = s;
}
// =========================================================
// PHASE 5: LANDUNG
// =========================================================
function initLandingPhase() {
var cfg = BASE_CONFIG[game.mission.base] || {heli:'oamtc', stationName:'Stützpunkt'};
var heliDisplayName = displayHeliName(game.mission.base);
var levelByDifficulty = {easy:1, medium:2, hard:3};
var lv = levelByDifficulty[game.mission.difficulty] || 2;
// Szenario je Mission (bestimmt Umgebung + Briefing-Text in landing.html)
var missionScenarios = {
// Gebirge
m1: 'berg-schnee', m4: 'berg-schnee', m9: 'berg-schnee',
m3: 'berg-fels', m7: 'berg-fels',
m6: 'schlucht', m8: 'tal-dorf',
m12:'berg-fels', m13:'berg-fels',
m19:'berg-schnee',m20:'berg-schnee',m24:'berg-schnee',
m21:'berg-fels',
// Wasser
m5: 'wasser', m11:'wasser', m14:'wasser', m16:'wasser', m18:'wasser', m23:'wasser',
// Autobahn
m2: 'autobahn', m10:'autobahn', m15:'autobahn', m17:'autobahn', m22:'autobahn', m25:'autobahn'
};
var scen = missionScenarios[game.mission.id] || 'berg-fels';
var params = '?preset=1'
+ '&heli=' + encodeURIComponent(cfg.heli)
+ '&helikey=' + encodeURIComponent(cfg.heliKey || 'c1')
+ '&heliname=' + encodeURIComponent(heliDisplayName)
+ '&callsign=' + encodeURIComponent(game.stationCallsign || '')
+ '&level=' + lv
+ '&scen=' + scen
+ '&mission=' + encodeURIComponent(game.mission.title)
+ '&missionid=' + encodeURIComponent(game.mission.id || '');
// Cache-Buster: iPad-Safari cacht iframe-HTML aggressiv; bei jedem Phasen-Eintritt frisch laden.
document.getElementById('landingFrame').src = (window.HELI_BASE || '') + 'landing.html' + params + '&_cb=' + Date.now();
window.addEventListener('message', onLandingMessage);
// Sicherheits-Timeout: nach 5 Minuten Landing-Phase wird der Einsatz als
// gescheitert beendet (Patient nicht zur Klinik gebracht). Frueher 120 s
// mit auto-success — das hat bei Autobahn-Missionen zu falschen Erfolgen
// gefuehrt, wenn der Bearbeiter in der Landezone wartete.
setTimeout(function() {
if (game.phase === 'landing') {
game.landingScore = 1;
game.missionFailed = true;
game.failReason = 'Zeit überschritten — Patient nicht zur Klinik gebracht';
setPhase('results');
}
}, 300000);
}
function onLandingMessage(e) {
if (e.data && e.data.type === 'heli_landing_complete') {
game.landingScore = e.data.stars || 3;
if (e.data.failed) {
game.missionFailed = true;
game.failReason = e.data.reason || '';
}
game.landingDebug = e.data.debug || null;
if (game.landingDebug) console.log('[HELI DEBUG from landing]', game.landingDebug);
window.removeEventListener('message', onLandingMessage);
setPhase('results');
}
}
// =========================================================
// PHASE 6: AUSWERTUNG
// =========================================================
// Routenplanungs-Sterne aus dem TIMING (nicht mehr nur Genauigkeit):
// je Wegpunkt 2=vor rotem Hint (Bonus) · 1=während der 10-s-Einblendung · 0=danach.
// Ø-Punkte → 5/4/3/2/1★. Chefpilot-Übernahme (15 Fehlklicks) bleibt 0★.
// Fallback auf die alte Genauigkeits-Wertung, falls kein Timing erfasst wurde.
function computePlanStars() {
if (game.planTakeover) return 0;
var t = game.wpTiming || [];
if (!t.length) {
var pct = (game.route && game.route.length > 1) ? Math.round((game.planCorrect||0) / (game.route.length-1) * 100) : 100;
return pct >= 90 ? 5 : pct >= 70 ? 4 : pct >= 50 ? 3 : pct >= 30 ? 2 : 1;
}
var avg = t.reduce(function(a,b){ return a+b; }, 0) / t.length;
return avg >= 1.6 ? 5 : avg >= 1.1 ? 4 : avg >= 0.6 ? 3 : avg > 0 ? 2 : 1;
}
function initResultsPhase() {
var planStars = computePlanStars();
var totalStars = Math.round((planStars + game.startScore + game.landingScore) / 3);
game.totalStars = totalStars;
// Snapshot des zuletzt abgeschlossenen Einsatzes — damit die Lehrer-Ansicht
// „Aktuell" in der Auftragswahl (zwischen zwei Missionen) noch das letzte
// Ergebnis (Start/Landung/Gesamt) zeigt statt leerer Spalten.
game.lastResult = {
missionId: game.mission ? game.mission.id : null,
missionTitle: game.mission ? game.mission.title : null,
planStars: planStars,
startScore: game.startScore || 0,
landingScore: game.landingScore || 0,
totalStars: totalStars,
failed: !!game.missionFailed,
};
// Assessment-Hook 3: Mission-Ende (mit Sterne + Dauer)
assessmentComplete();
// Submit ans Lehrer-Cockpit (player_progress + student_results)
submitHeliMission();
var starStr = '★'.repeat(totalStars) + '☆'.repeat(5-totalStars);
var success = !game.missionFailed;
var icon, title, subtitle, comment, cls;
if (success) {
cls = 'success';
icon = '🚁';
title = 'Einsatz abgeschlossen';
subtitle = game.mission.title + ' · ' + game.mission.region;
var baseComment = totalStars >= 5 ? 'Ausgezeichnet — Naturtalent.' :
totalStars >= 4 ? 'Sehr gut — die Chefpilotin ist beeindruckt.' :
totalStars >= 3 ? 'Solide Arbeit. Weiter üben!' :
totalStars >= 2 ? 'Ausbaufähig, aber du lernst schnell.' :
'Übung macht den Meister!';
// Falls Pilot im Start uebernommen hat: differenziert kommentieren
if (game.startTakeover) {
comment = 'Beim Abflug musste die Chefpilotin kurz ans Steuer'
+ (game.startTakeoverReason ? ' (' + game.startTakeoverReason + ')' : '')
+ ' — die Landung hast du dann sauber gemeistert. ' + baseComment;
} else if (game.planTakeover) {
comment = 'Routenplanung hat der Pilot übernommen. Den Flug und die Landung hast du gut bewältigt.';
} else {
comment = baseComment;
}
// Bonus-Feedback: Ziele, die VOR der roten Einblendung getroffen wurden.
var planBeforeRed = (game.wpTiming||[]).filter(function(x){return x===2;}).length;
if (planBeforeRed > 0 && !game.planTakeover) {
comment += ' 🎯 Bonus: ' + planBeforeRed + ' Ziel' + (planBeforeRed>1?'e':'')
+ ' vor der roten Einblendung getroffen — top navigiert!';
}
} else {
cls = 'failed';
icon = '🧑‍✈️';
title = 'Chefpilotin hat übernommen';
subtitle = game.mission.title + ' · ' + game.mission.region;
comment = 'Die Chefpilotin musste ans Steuer'
+ (game.failReason ? ' — ' + game.failReason + '.' : '.')
+ ' Der Patient wurde trotzdem gerettet. Beim nächsten Mal schaffst du das selbst.';
}
// Cockpit-Link: BASE_PATH + /schueler — das ist das Schüler-Cockpit
// (Modul-Karten, freigegebene Module). /sim ist eine alte Demo-Shell
// und führt ins Leere, daher hier explizit /schueler.
var apiBase = window.HELI_API_BASE || '/api';
var cockpitHref = apiBase.replace(/\/api\/?$/,'') + '/schueler';
var html =
'<div class="ggs-endscreen ' + cls + '">'
+ '<div class="ggs-endscreen-icon">' + icon + '</div>'
+ '<div class="ggs-endscreen-title">' + title + '</div>'
+ '<div class="ggs-endscreen-subtitle">' + subtitle + '</div>'
+ '<div class="ggs-endscreen-body">'
+ '<div style="font-size:2.4rem;letter-spacing:.25rem;color:var(--ggs-gold,#c97a3f);text-align:center;margin:.4rem 0 1rem">' + starStr + '</div>'
+ '<div class="ggs-endscreen-kpi-grid">'
+ '<div class="ggs-endscreen-kpi"><div class="n">' + planPct + '%</div><div class="l">Navigation</div></div>'
+ '<div class="ggs-endscreen-kpi"><div class="n stars" style="font-size:.95rem;letter-spacing:.06em">' + '★'.repeat(planStars) + '</div><div class="l">Routenplanung</div></div>'
+ '<div class="ggs-endscreen-kpi"><div class="n stars" style="font-size:.95rem;letter-spacing:.06em">' + '★'.repeat(game.startScore) + '</div><div class="l">Start</div></div>'
+ '<div class="ggs-endscreen-kpi"><div class="n stars" style="font-size:.95rem;letter-spacing:.06em">' + '★'.repeat(game.landingScore) + '</div><div class="l">Landung</div></div>'
+ '</div>'
+ '<p style="margin-top:1rem;font-weight:600;color:' + (success ? 'var(--ggs-moss,#2d5434)' : 'var(--ggs-coral,#c85c4a)') + '">' + comment + '</p>'
+ buildDebugPanelHtml()
+ '</div>'
+ '<div class="ggs-endscreen-actions">'
+ '<a href="' + cockpitHref + '" class="ggs-btn">🏠 Zurück zum Cockpit</a>'
+ '<button class="ggs-btn ggs-btn-primary" onclick="setPhase(\'mission\')">↻ Nächster Einsatz</button>'
+ '</div>'
+ '</div>';
document.getElementById('resultsCard').innerHTML = html;
}
function buildDebugPanelHtml() {
var d = game.landingDebug;
if (!d) return '';
var strikesHtml = d.strikes && d.strikes.length
? d.strikes.map(function(s){ return '<li>'+s.msg+' <span style="opacity:.6">('+s.t+'s)</span></li>'; }).join('')
: '<li style="color:var(--slate);opacity:.7">keine Berührungen</li>';
return (
'<details style="margin-top:1.2rem;text-align:left;background:#fafafa;border:1px solid #e0e0e0;border-radius:8px;padding:.6rem .9rem;font-size:.78rem">'
+ '<summary style="cursor:pointer;color:var(--slate);font-weight:700">🔧 Technische Flugdaten</summary>'
+ '<div style="margin-top:.5rem;color:var(--ink)">'
+ '<b>Ergebnis:</b> '+d.outcome+(d.reason?' — '+d.reason:'')+'<br>'
+ '<b>Szenario:</b> '+d.scen+' · <b>Level:</b> '+d.level+' · <b>Zeit:</b> '+d.timeSec+' s<br>'
+ '<b>Phase beim Ende:</b> '+d.phase+'<br>'
+ '<b>Heli:</b> x='+d.heli.x+', y='+d.heli.y+' · vx='+d.heli.vx+', vy='+d.heli.vy+' · Fuel '+d.heli.fuel+'%<br>'
+ '<b>Patient:</b> x='+d.patient.x+', y='+d.patient.y+' · Zone '+d.patient.zone+' · gerettet='+d.patient.rescued+'<br>'
+ '<b>Objekte:</b> '+d.birds+' Vögel, '+d.ships+' Schiffe'+(d.gantries?', '+d.gantries+' Gantries':'')+'<br>'
+ '<b>Berührungen ('+d.heli.strikesTotal+'/3):</b>'
+ '<ul style="margin:.3rem 0 0;padding-left:1.2rem">'+strikesHtml+'</ul>'
+ '</div>'
+ '</details>'
);
}
// =========================================================
// V3: Wegpunkt-Marker progressiv enthuellen
// =========================================================
// Wird vom Engine-Event 'wp-sichtbar' gerufen — fuegt einen kleinen Marker
// auf der Plan-Karte hinzu fuer den aktuellen Wegpunkt. Vor dem Audio-Ende
// gibt es KEINEN Marker — schneller Klick = Geo-Wissen erforderlich.
var hintedWaypoints = {}; // wpKey → Marker
// V3.1 (2026-06-01): Marker erscheinen erst NACH dem Wegpunkt-Audio mit
// Fade-In ueber X Sekunden. Vor Audio-Ende ist der Marker nicht da —
// schnelle Schueler*innen muessen den Ort raten.
function showWaypointHint(wpKey, fadeInMs) {
if (!planMap || !wpKey) return;
if (hintedWaypoints[wpKey]) return; // schon sichtbar
var wp = WP[wpKey];
if (!wp) return;
fadeInMs = fadeInMs || 0;
var dot = L.marker([wp.lat, wp.lon], {
icon: L.divIcon({
className: '',
html: '<div class="wp-hint-marker" style="width:18px;height:18px;border-radius:50%;background:rgba(200,92,74,.45);border:2.5px solid rgba(200,92,74,.95);box-shadow:0 2px 5px rgba(0,0,0,.25);opacity:0;transition:opacity ' + (fadeInMs/1000) + 's ease-in"></div>',
iconSize: [18,18], iconAnchor: [9,9]
}),
interactive: false,
zIndexOffset: 50
}).addTo(planMap);
planLayers.push(dot);
hintedWaypoints[wpKey] = dot;
// Fade-In im naechsten Tick anstossen, damit CSS-Transition greift
setTimeout(function(){
var el = dot.getElement();
if (el) {
var inner = el.querySelector('.wp-hint-marker');
if (inner) inner.style.opacity = '1';
}
}, 30);
}
function clearWaypointHints() {
hintedWaypoints = {};
}
// Startfreigabe-Button aktivieren (gerufen vom Engine phase-end-Event)
// V3.3: Audio-Ende allein reicht NICHT — alle Wegpunkte (inkl. Ziel)
// müssen angeklickt sein, sonst kann jemand mit ungeklicktem Ziel starten.
function enableStartReleaseButton() {
game.planPhaseAudioDone = true;
checkStartReleaseReady();
}
// Prüft beide Bedingungen (Audio durch + alle Wegpunkte gesetzt) und
// aktualisiert den Startfreigabe-Button entsprechend.
function checkStartReleaseReady() {
var btn = document.getElementById('startReleaseBtn');
if (!btn) return;
var routeLen = (game.route || []).length;
var allClicked = routeLen > 0 && game.planIndex >= routeLen;
var pilotTookOver = !!game.planTakeover;
var audioDone = !!game.planPhaseAudioDone;
if ((allClicked || pilotTookOver) && audioDone) {
btn.disabled = false;
btn.textContent = '🚁 Startfreigabe';
btn.onclick = function(){ setPhase('start'); };
} else if (audioDone && !allClicked) {
var missing = Math.max(0, routeLen - game.planIndex);
btn.disabled = true;
btn.textContent = '🚁 Noch ' + missing + ' Wegpunkt' + (missing > 1 ? 'e' : '') + ' anklicken';
btn.onclick = null;
} else {
btn.disabled = true;
btn.textContent = '🚁 Startfreigabe in Kürze';
btn.onclick = null;
}
}
// =========================================================
// V3 AUDIO-ENGINE (Plan-Phase: Tool-Timeline-driven)
// =========================================================
function startHeliAudioEngineForPlan() {
if (!window.HeliAudioEngine) {
console.warn('[heli-engine] HeliAudioEngine nicht geladen — fallback alte Audio-Logik');
if (game.mission && game.mission.id) {
playFlightAudio('sounds/radio/r_mission_intro_' + game.mission.id + '.mp3');
}
return;
}
if (window.heliAudioEngine) {
try { window.heliAudioEngine.stop(); } catch(_){}
}
var basePath = (window.HELI_BASE || '/geograsim/App/sims/heli/') + 'sounds/';
var apiBase = window.HELI_API_BASE || '/geograsim/App/api';
window.heliAudioEngine = new HeliAudioEngine({
missionId: game.mission.id,
basePath: basePath,
apiBase: apiBase,
onAudioStart: function(slot, dur) {
// Funk-Pille einblenden mit dem aktuellen Audio
try {
var heliKey = (game.mission && BASE_CONFIG[game.mission.base] && BASE_CONFIG[game.mission.base].heliKey) || '';
if (window.HeliFunk) HeliFunk.show(slot.audioId, { duration: dur, heliKey: heliKey });
} catch(_){}
},
onAudioEnd: function() {
if (window.HeliFunk) HeliFunk.hide();
},
onEvent: function(ev) {
// V3.1: Sequenz pro Wegpunkt:
// wp-sichtbar (Audio-Start) → nur Instruction-Box updaten
// wp-marker-reveal (Audio-Ende) → Marker Fade-In auf Karte
// wp-erreicht (Klick erkannt) → naechster Audio-Slot startet
if (ev.type === 'wp-sichtbar') {
if (game.route && game.route[game.planIndex] === ev.waypoint) {
showPlanInstruction();
}
}
else if (ev.type === 'wp-marker-reveal') {
// Ab jetzt blendet der rote Zielpunkt ein → Zeitpunkt merken für die
// Timing-Wertung (Klick davor = Bonus, während der 10 s = mittel, danach = niedrig).
if (!game.wpRevealAt) game.wpRevealAt = {};
if (game.wpRevealAt[ev.waypoint] == null) game.wpRevealAt[ev.waypoint] = Date.now();
showWaypointHint(ev.waypoint, ev.fadeInMs || 10000);
}
else if (ev.type === 'phase-end') {
// Alle Audios durch (inkl. r_nav_plan_done) → Startfreigabe freischalten
enableStartReleaseButton();
}
// wp-erreicht: kein Visual hier (Marker wird in onPlanClick als
// nummerierter Set-Marker gesetzt)
},
});
window.heliAudioEngine.loadAndStart('plan').catch(function(err){
console.warn('[heli-engine] startPhase failed', err);
// Audio konnte nicht starten (Ton aus, Ladefehler, Autoplay blockiert) →
// die Wegpunkt-Anweisung trotzdem sofort als Card zeigen, sonst steht
// ein tonloser Spieler ohne Hinweis da. Bleibt bis zum Fund stehen.
if (game.phase === 'plan' && game.planIndex < game.route.length) showPlanInstruction();
});
// Sicherheitsnetz unabhängig vom Audio: falls das wp-sichtbar-Event nach kurzer
// Zeit nicht gekommen ist (z. B. stummes Abspielen ohne Events), zeigen wir die
// Anweisung selbst an — jede Audio-Meldung hat so ihren sichtbaren „Untertitel".
clearTimeout(window._heliPlanCardFallback);
window._heliPlanCardFallback = setTimeout(function(){
var pi = document.getElementById('planInstruction');
if (game.phase === 'plan' && game.planIndex < game.route.length && pi && !pi.innerHTML.trim()) {
showPlanInstruction();
}
}, 5000);
}
// =========================================================
// V3.2 AUDIO-ENGINE (Flug-Phase: analog Plan, Stufe 1)
// =========================================================
// Stufe 1: Spielt die Flug-Timeline sequenziell ab.
// Audios sind NICHT an Wegpunkt-Position auf der Karte gekoppelt (das
// kommt in Stufe 3). Die Animation laeuft mit der bestehenden Geschwindig-
// keit. Wegpunkt-Audios in der Flug-Phase haben kein Click-Gating (Plan
// hatte das, Flug nicht — User klickt im Flug nichts).
function startHeliAudioEngineForFlight() {
if (!window.HeliAudioEngine) {
console.warn('[heli-engine] HeliAudioEngine nicht geladen — fallback alte Audio-Logik');
setTimeout(function(){
if (game.phase === 'flight' && game.mission && game.mission.id) {
playFlightAudio('sounds/radio/r_mission_geo_' + game.mission.id + '.mp3');
}
}, 1500);
return;
}
if (window.heliAudioEngine) {
try { window.heliAudioEngine.stop(); } catch(_){}
}
var basePath = (window.HELI_BASE || '/geograsim/App/sims/heli/') + 'sounds/';
var apiBase = window.HELI_API_BASE || '/geograsim/App/api';
window.heliAudioEngine = new HeliAudioEngine({
missionId: game.mission.id,
basePath: basePath,
apiBase: apiBase,
onAudioStart: function(slot, dur) {
try {
var heliKey = (game.mission && BASE_CONFIG[game.mission.base] && BASE_CONFIG[game.mission.base].heliKey) || '';
if (window.HeliFunk) HeliFunk.show(slot.audioId, { duration: dur, heliKey: heliKey });
} catch(_){}
},
onAudioEnd: function() {
if (window.HeliFunk) HeliFunk.hide();
},
onEvent: function(ev) {
// Flug-Phase: kein Wegpunkt-Klick → wp-sichtbar/wp-marker-reveal
// werden ignoriert. Nur phase-end markieren wir, falls Game darauf
// reagieren will (z.B. fuer Stufe 2: Animation-Ende-Sync).
if (ev.type === 'phase-end') {
game.flightPhaseAudioDone = true;
}
},
});
// V3.2 Stufe 2 (2026-06-01, revidiert): Heli wird NUR verlangsamt wenn Audio
// laenger als natuerliche Flugzeit ist (Audio passt sonst nicht rein).
// Wenn Audio kuerzer ist als der Flug → Heli bleibt bei natuerlicher
// Geschwindigkeit, Audio endet einfach frueher. Kein Beschleunigen.
window.heliAudioEngine.loadTimeline().then(function(){
try {
var audioTotalSec = window.heliAudioEngine.getPhaseTotalTime('flug');
var naturalSec = computeNaturalFlightDuration();
if (audioTotalSec > naturalSec && naturalSec > 2) {
// Audio braucht laenger → Heli verlangsamen
var newSpeed = naturalSec / audioTotalSec;
game.flightSpeed = Math.max(0.4, newSpeed); // nicht zu langsam
console.log('[heli] flightSpeed = ' + game.flightSpeed.toFixed(2)
+ ' (audio=' + audioTotalSec.toFixed(1) + 's > natural=' + naturalSec.toFixed(1) + 's → langsamer)');
} else {
// Audio kurz genug → keine Anpassung
game.flightSpeed = 1.0;
console.log('[heli] flightSpeed = 1.0 (audio=' + audioTotalSec.toFixed(1) + 's <= natural=' + naturalSec.toFixed(1) + 's → keine Anpassung)');
}
} catch(e) { console.warn('flightSpeed-Adjust fehlgeschlagen', e); }
return window.heliAudioEngine.startPhase('flug');
}).catch(function(err){
console.warn('[heli-engine] Flug startPhase failed', err);
});
}
// Berechnet die natuerliche Flug-Animations-Dauer aus Wegpunkt-Distanzen
// (analog Logik in flyToNext) + Phasen-Gaps. Liefert Sekunden bei flightSpeed=1.
function computeNaturalFlightDuration() {
if (!game.route) return 0;
var totalMs = 0;
for (var i = 1; i < game.route.length; i++) {
var from = WP[game.route[i-1]], to = WP[game.route[i]];
if (!from || !to) continue;
var dist = calcDistance(from.lat, from.lon, to.lat, to.lon);
var segMs = Math.max(5000, (dist / 67.5) * 60000);
totalMs += segMs;
}
// Inter-Segment-Gap (siehe flyToNext: setTimeout 1500/flightSpeed)
// + initiale 3000ms (Route-Check + Pilot-Ansage)
totalMs += 1500 * Math.max(0, game.route.length - 2) + 3000;
return totalMs / 1000;
}
// =========================================================
// WAYPOINT INFO (Pilot-Kommentare pro Ort)
// =========================================================
function getWaypointFact(wpKey, kind) {
// Zieht Fakten aus der DB-Injection `HELI_FACTS` (pro WP × kind)
if (!window.HELI_FACTS || !HELI_FACTS[wpKey]) return null;
var list = HELI_FACTS[wpKey][kind];
if (!list || list.length === 0) {
// Fallback: wenn gewünschter Kind fehlt, versuche das andere
var other = kind === 'geo' ? 'kids' : 'geo';
list = HELI_FACTS[wpKey][other];
if (!list || list.length === 0) return null;
}
return list[Math.floor(Math.random() * list.length)];
}
function getWaypointInfo(name) {
var infos = {
'Dornbirn': 'Größte Stadt Vorarlbergs mit über 50.000 Einwohnern.',
'Bregenz': 'Die Seebühne am Bodensee ist weltweit einzigartig.',
'Feldkirch': 'Mittelalterliche Grenzstadt mit der Schattenburg.',
'Bludenz': 'Tor zu drei Alpentälern: Montafon, Klostertal, Brandnertal.',
'Rankweil': 'Wallfahrtsort mit der Basilika auf dem Liebfrauenberg.',
'Götzis': 'Bekannt für das Hypomeeting, eines der besten Leichtathletik-Events der Welt.',
'Schruns': 'Hauptort des Montafons, zwischen Verwallgruppe und Rätikon.',
'Damüls': 'Eines der schneereichsten Dörfer Europas.',
'Bezau': 'Bregenzerwald-Gemeinde mit berühmter Käsetradition.',
'Egg': 'Das Tor zum Bregenzerwald.',
'Mellau': 'Skigebiet und Canyoning-Paradies.',
'Brand': 'Rätikon-Gemeinde am Fuße der Zimba.',
'Innsbruck': 'Landeshauptstadt Tirols, 3× Olympia-Stadt.',
'Hall in Tirol': 'Mittelalterliche Münzstadt am Inn.',
'Schwaz': 'War im 16. Jahrhundert eine der größten Bergbaustädte Europas.',
'Jenbach': 'Ausgangspunkt der Achensee-Dampfzahnradbahn.',
'Wörgl': 'Großer Bahnhof am Zusammenfluss von Inn und Brixentaler Ache.',
'Kufstein': 'Grenzstadt zu Deutschland mit der berühmten Festung.',
'Telfs': 'Das Schleicherlaufen ist UNESCO-Kulturerbe.',
'Imst': 'Schemenlaufen — einer der ältesten Fasnachtsbräuche der Alpen.',
'Landeck': 'Am Zusammenfluss von Inn und Sanna.',
'Ötz': 'Namensgeber des Ötztals, wo 1991 Ötzi gefunden wurde.',
'Sölden': 'Eines der größten Gletscherskigebiete der Alpen.',
'Mayrhofen': 'Am Ende des Zillertals mit dem berühmten Penken.',
'Zell am Ziller': 'Am Eingang zum Zillertal, umgeben von den Tuxer Alpen.',
'St. Gallenkirch': 'Montafoner Bergdorf mit Silvretta-Panorama.',
'Gaschurn': 'Ausgangspunkt zur Silvretta-Hochalpenstraße.',
};
return infos[name] || null;
}
// =========================================================
// HELPERS
// =========================================================
function calcBearing(lat1,lon1,lat2,lon2) {
var dLon = (lon2-lon1)*Math.PI/180;
var y = Math.sin(dLon)*Math.cos(lat2*Math.PI/180);
var x = Math.cos(lat1*Math.PI/180)*Math.sin(lat2*Math.PI/180)-Math.sin(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.cos(dLon);
return ((Math.atan2(y,x)*180/Math.PI)+360)%360;
}
function calcDistance(lat1,lon1,lat2,lon2) {
var R=6371, dLat=(lat2-lat1)*Math.PI/180, dLon=(lon2-lon1)*Math.PI/180;
var a=Math.sin(dLat/2)**2+Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2;
return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a));
}
// 16-teilige Windrose — 22.5° pro Sektor.
function bearingToName(b) {
var names = ['Norden','Nord-Nordosten','Nordosten','Ost-Nordosten',
'Osten','Ost-Südosten','Südosten','Süd-Südosten',
'Süden','Süd-Südwesten','Südwesten','West-Südwesten',
'Westen','West-Nordwesten','Nordwesten','Nord-Nordwesten'];
var norm = ((b % 360) + 360) % 360;
var idx = Math.round(norm / 22.5) % 16;
return 'nach ' + names[idx];
}
// Abkürzung (N, NNO, NO, …) für Kompass-Anzeige.
function bearingToAbbrev(b) {
var abbr = ['N','NNO','NO','ONO','O','OSO','SO','SSO',
'S','SSW','SW','WSW','W','WNW','NW','NNW'];
var norm = ((b % 360) + 360) % 360;
return abbr[Math.round(norm / 22.5) % 16];
}
function bearingToEmoji(b) {
if(b<22.5||b>=337.5) return '⬆️';
if(b<67.5) return '↗️';
if(b<112.5) return '➡️';
if(b<157.5) return '↘️';
if(b<202.5) return '⬇️';
if(b<247.5) return '↙️';
if(b<292.5) return '⬅️';
return '↖️';
}
// Reduziert einen Bearing auf einen der 8 grob-Himmelsrichtungs-Audio-Namen,
// damit die 16 Feinrichtungen auf die vorhandenen dir_*.mp3 gemappt werden.
function bearingToAudio8(b) {
var norm = ((b % 360) + 360) % 360;
var idx = Math.round(norm / 45) % 8;
return ['dir_nord','dir_nordost','dir_ost','dir_suedost','dir_sued','dir_suedwest','dir_west','dir_nordwest'][idx];
}
// =========================================================
// ONBOARDING (4 Schritte, einmalig pro Browser via localStorage)
// =========================================================
var HELI_ONBOARDING_STEPS = [
{
title: 'Willkommen im Team 🚁',
visual: '<div style="position:relative;width:100%;height:200px;background:linear-gradient(180deg,#cfe0ec 0%,#e3eef2 60%,#c5dcc5 100%);border-radius:8px;overflow:hidden">'
// SVG-Bergpanorama im Hintergrund
+ '<svg viewBox="0 0 480 200" xmlns="http://www.w3.org/2000/svg" style="position:absolute;inset:0;width:100%;height:100%">'
+ '<polygon points="0,200 60,110 110,150 170,90 230,135 290,80 360,125 420,100 480,145 480,200" fill="#a8b9a0" opacity=".7"/>'
+ '<polygon points="0,200 70,135 140,160 210,120 280,150 360,130 430,160 480,140 480,200" fill="#7a9b76" opacity=".85"/>'
// Stuetzpunkte: Notarzt gold, ARA rot, Polizei blau, BH gruen
+ '<g>'
// Notarzt (gold) — 9 sichtbar
+ '<g transform="translate(40,140)"><circle r="6" fill="#fff" stroke="#d4a017" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
+ '<g transform="translate(85,148)"><circle r="6" fill="#fff" stroke="#d4a017" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
+ '<g transform="translate(135,142)"><circle r="6" fill="#fff" stroke="#d4a017" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
+ '<g transform="translate(180,128)"><circle r="6" fill="#fff" stroke="#d4a017" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
+ '<g transform="translate(310,150)"><circle r="6" fill="#fff" stroke="#d4a017" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
+ '<g transform="translate(410,140)"><circle r="6" fill="#fff" stroke="#d4a017" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
// ARA (rot) — 2
+ '<g transform="translate(108,135)"><circle r="6" fill="#fff" stroke="#cc3333" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
+ '<g transform="translate(345,138)"><circle r="6" fill="#fff" stroke="#cc3333" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
// Polizei (blau) — 2
+ '<g transform="translate(160,155)"><circle r="6" fill="#fff" stroke="#2255aa" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
+ '<g transform="translate(385,135)"><circle r="6" fill="#fff" stroke="#2255aa" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
// BH (gruen) — 2
+ '<g transform="translate(220,142)"><circle r="6" fill="#fff" stroke="#228833" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
+ '<g transform="translate(450,150)"><circle r="6" fill="#fff" stroke="#228833" stroke-width="1.5"/><text y="3" text-anchor="middle" font-size="8">🚁</text></g>'
+ '</g>'
// Pfad vom Heli zum Patient
+ '<path d="M 250 100 Q 320 115 380 158" stroke="#c85c4a" stroke-width="2.4" stroke-dasharray="6 4" fill="none"/>'
+ '<circle cx="383" cy="160" r="6" fill="#c85c4a"/>'
+ '<text x="383" y="180" text-anchor="middle" font-size="10" fill="#c85c4a" font-weight="700">Verletzte Person</text>'
// Legende fuer Organisations-Farben
+ '<g font-size="8" font-family="system-ui">'
+ '<circle cx="15" cy="190" r="3.5" fill="#fff" stroke="#d4a017" stroke-width="1.5"/><text x="22" y="193" fill="#7a5f00" font-weight="700">Notarzt</text>'
+ '<circle cx="80" cy="190" r="3.5" fill="#fff" stroke="#cc3333" stroke-width="1.5"/><text x="87" y="193" fill="#882222" font-weight="700">ARA</text>'
+ '<circle cx="125" cy="190" r="3.5" fill="#fff" stroke="#2255aa" stroke-width="1.5"/><text x="132" y="193" fill="#1a3a77" font-weight="700">Polizei</text>'
+ '<circle cx="180" cy="190" r="3.5" fill="#fff" stroke="#228833" stroke-width="1.5"/><text x="187" y="193" fill="#145522" font-weight="700">Bundesheer</text>'
+ '</g>'
+ '</svg>'
// Heli-SVG zentral oben drauf — dieselbe Notarzt-Variante (orange/gold) wie
// im Zielanflug (landing.html drawHeli). Macht die Spiel-Karte/Onboarding-Bild
// konsistent mit dem Heli den der Spieler im Spiel sieht.
+ '<svg viewBox="-80 -45 160 90" xmlns="http://www.w3.org/2000/svg" '
+ 'style="position:absolute;left:50%;top:40%;transform:translate(-50%,-40%);width:220px;height:auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,.25))">'
// Main rotor disc + Bewegungslinie (oben)
+ '<ellipse cx="6" cy="-22" rx="50" ry="3" fill="rgba(31,42,36,.15)"/>'
+ '<line x1="-44" y1="-22" x2="56" y2="-22" stroke="rgba(31,42,36,.55)" stroke-width="2"/>'
+ '<line x1="6" y1="-28" x2="6" y2="-16" stroke="rgba(31,42,36,.55)" stroke-width="2"/>'
+ '<line x1="6" y1="-22" x2="6" y2="-12" stroke="#3d5165" stroke-width="1.6"/>'
// Tail boom
+ '<path d="M -6 -2 L -50 -4 L -52 2 L -6 6 Z" fill="#e4b95c"/>'
// Tail fin
+ '<path d="M -50 -4 L -58 -14 L -50 2 Z" fill="#c97a3f"/>'
// Tail rotor
+ '<circle cx="-54" cy="-6" r="8" fill="rgba(31,42,36,.18)" stroke="rgba(31,42,36,.3)" stroke-width="1"/>'
+ '<line x1="-62" y1="-6" x2="-46" y2="-6" stroke="rgba(31,42,36,.7)" stroke-width="1.8"/>'
+ '<line x1="-54" y1="-14" x2="-54" y2="2" stroke="rgba(31,42,36,.7)" stroke-width="1.8"/>'
+ '<circle cx="-54" cy="-6" r="1.5" fill="#c85c4a"/>'
// Body (Hauptrumpf)
+ '<path d="M -12 -12 Q 0 -18 20 -10 Q 30 -4 28 10 L -6 12 Q -14 10 -14 2 Z" fill="#e4b95c"/>'
// Belly
+ '<path d="M -10 6 Q 0 14 20 10 L 28 10 L -6 12 Z" fill="#c97a3f"/>'
// Cockpit-Glas
+ '<path d="M 6 -10 Q 22 -10 26 -2 L 26 6 L 6 6 Z" fill="#a8c3a0"/>'
+ '<path d="M 10 -8 Q 18 -9 22 -4 L 10 -4 Z" fill="rgba(245,239,224,.45)"/>'
// Accent stripe
+ '<rect x="-6" y="0" width="18" height="3" fill="#c85c4a"/>'
// Stern (Notarzt-Variante)
+ '<text x="3" y="3" text-anchor="middle" font-family="system-ui" font-weight="bold" font-size="6" fill="#c85c4a">★</text>'
// Skids
+ '<line x1="-26" y1="18" x2="26" y2="18" stroke="#1f2a24" stroke-width="2.5" stroke-linecap="round"/>'
+ '<line x1="-18" y1="18" x2="-18" y2="10" stroke="#1f2a24" stroke-width="2"/>'
+ '<line x1="18" y1="18" x2="18" y2="10" stroke="#1f2a24" stroke-width="2"/>'
// Schatten
+ '<ellipse cx="4" cy="24" rx="40" ry="4" fill="rgba(31,42,36,.12)"/>'
+ '</svg>'
+ '</div>',
text: 'Hi! Ich bin <b>Marlene</b>, eine der Pilot*innen hier. Schön, dass du bei uns anfängst. Ich schule dich in den nächsten Einsätzen ein — du sitzt als <span class="heli-glossar-term" data-glossar="co-pilot">Co-Pilot*in</span> am Steuer, ich sitze daneben und passe auf, dass nichts schiefgeht.'
+ '<br><br>Du fliegst <span class="heli-glossar-term" data-glossar="notarzt-hubschrauber"><b>Notarzt-Hubschrauber-Einsätze</b></span> in ganz Österreich. Es gibt vier Betreiber-Gruppen, die jeweils unterschiedliche Aufgaben haben:'
+ '<br><br>'
+ '<table style="font-size:.88rem;line-height:1.45;border-spacing:0;width:100%">'
+ '<tr><td style="padding:2px 8px 2px 0"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#d4a017"></span> <span class="heli-glossar-term" data-glossar="notarzt-hubschrauber"><b>Notarzt</b></span></td><td>14 <span class="heli-glossar-term" data-glossar="stuetzpunkt">Stützpunkte</span>, größter Betreiber</td></tr>'
+ '<tr><td style="padding:2px 8px 2px 0"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#cc3333"></span> <span class="heli-glossar-term" data-glossar="bergrettungs-hubschrauber"><b>Bergrettung</b></span></td><td>v.a. im Westen, Spezialisten für hohe Lagen</td></tr>'
+ '<tr><td style="padding:2px 8px 2px 0"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#2255aa"></span> <span class="heli-glossar-term" data-glossar="polizei-hubschrauber"><b>Polizei</b></span></td><td>Fahndung, Suchflüge, kein Patiententransport</td></tr>'
+ '<tr><td style="padding:2px 8px 2px 0"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#228833"></span> <span class="heli-glossar-term" data-glossar="bundesheer-hubschrauber"><b>Bundesheer</b></span></td><td>Such- und Katastrophenhilfe</td></tr>'
+ '</table>'
+ '<br>Auf der <b>📍 Übersichtskarte</b> siehst du alle Stützpunkte mit ihrer Färbung.'
+ '<br><br>Jeder Hubschrauber in Österreich hat ein amtliches <span class="heli-glossar-term" data-glossar="heli-kennzeichen"><b>Luftfahrt-Kennzeichen</b></span> wie <code style="background:#f5efe0;padding:1px 5px;border-radius:3px;font-size:.88rem">OE-XEN</code> — das ist sozusagen das Nummernschild. Im Spiel bekommst du beim Mission-Start ein zufälliges Kennzeichen aus dem Pool zugewiesen — das ist realistisch, weil in der Praxis die Crews ihre Hubschrauber je nach Wartung tauschen. Mehr dazu im Glossar-Eintrag <a href="{{BASE_PATH}}/glossar?term=heli-kennzeichen" target="_blank" rel="noopener" style="color:var(--forest);font-weight:700;text-decoration:underline">„Heli-Kennzeichen" ↗</a>.'
+ '<br><br><b>Was du dabei lernst:</b> Wo die Stützpunkte liegen, welche Städte, Berge, Seen und Täler in welchem Bundesland sind, und wie eine echte Rettungs-Mission abläuft.'
},
{
title: 'So läuft jede Mission bei uns ab',
visual: '<svg viewBox="0 0 480 170" xmlns="http://www.w3.org/2000/svg">'
// 5 Phasen als Bubbles mit Pfeilen
+ '<g font-family="system-ui" font-size="9" text-anchor="middle">'
// Bubble 1 — Auftrag
+ '<circle cx="45" cy="55" r="30" fill="#c97a3f" opacity=".18"/>'
+ '<text x="45" y="50" font-size="22">📋</text>'
+ '<text x="45" y="98" font-weight="700" fill="#2d5434">Auftrag</text>'
+ '<text x="45" y="110" fill="#3d5165">wählen</text>'
// Pfeil 1→2
+ '<path d="M 78 55 L 112 55" stroke="#5a8a5e" stroke-width="2" fill="none" marker-end="url(#arrowg)"/>'
// Bubble 2 — Route
+ '<circle cx="145" cy="55" r="30" fill="#5a8a5e" opacity=".22"/>'
+ '<text x="145" y="50" font-size="22">📍</text>'
+ '<text x="145" y="98" font-weight="700" fill="#2d5434">Route</text>'
+ '<text x="145" y="110" fill="#3d5165">planen</text>'
+ '<path d="M 178 55 L 212 55" stroke="#5a8a5e" stroke-width="2" fill="none" marker-end="url(#arrowg)"/>'
// Bubble 3 — Start
+ '<circle cx="245" cy="55" r="30" fill="#4a7c8a" opacity=".22"/>'
+ '<text x="245" y="50" font-size="22">🛫</text>'
+ '<text x="245" y="98" font-weight="700" fill="#2d5434">Start</text>'
+ '<text x="245" y="110" fill="#3d5165">Cockpit</text>'
+ '<path d="M 278 55 L 312 55" stroke="#5a8a5e" stroke-width="2" fill="none" marker-end="url(#arrowg)"/>'
// Bubble 4 — Navigation
+ '<circle cx="345" cy="55" r="30" fill="#7a9b76" opacity=".22"/>'
+ '<text x="345" y="50" font-size="22">🗺️</text>'
+ '<text x="345" y="98" font-weight="700" fill="#2d5434">Navi-</text>'
+ '<text x="345" y="110" fill="#3d5165">gation</text>'
+ '<path d="M 378 55 L 412 55" stroke="#5a8a5e" stroke-width="2" fill="none" marker-end="url(#arrowg)"/>'
// Bubble 5 — Landung
+ '<circle cx="445" cy="55" r="30" fill="#c85c4a" opacity=".22"/>'
+ '<text x="445" y="50" font-size="22">🛬</text>'
+ '<text x="445" y="98" font-weight="700" fill="#2d5434">Landung</text>'
+ '<text x="445" y="110" fill="#3d5165">+ Auswertung</text>'
+ '</g>'
+ '<defs><marker id="arrowg" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#5a8a5e"/></marker></defs>'
+ '<text x="240" y="158" text-anchor="middle" font-size="9" fill="#3d5165" font-style="italic">~ 5 8 Minuten pro Einsatz</text>'
+ '</svg>',
text: 'Jede Mission besteht aus <b>5 Phasen</b>:<ul>'
+ '<li><b>📋 Auftrag wählen</b> — wir bekommen drei Einsätze vorgeschlagen. Such einen aus.</li>'
+ '<li><span class="heli-glossar-term" data-glossar="routenplanung"><b>📍 Route planen</b></span> — der <span class="heli-glossar-term" data-glossar="tower">Tower</span> funkt uns den nächsten <span class="heli-glossar-term" data-glossar="wegpunkt">Wegpunkt</span> an („Weiter über Schwaz und Jenbach"). Du klickst ihn auf der Karte. Hier brauchst du dein Geographie-Wissen.</li>'
+ '<li><b>🛫 Start</b> — Cockpit-Ansicht. Du hebst sauber ab und drehst den Heli auf den richtigen Kurs.</li>'
+ '<li><b>🗺️ Navigation</b> — wir fliegen entlang deiner geplanten Route, du steuerst.</li>'
+ '<li><b>🛬 Landung</b> — Wind, Sinkrate, Landezone frei? Sanft aufsetzen.</li></ul>'
+ 'Am Ende gibt\'s die <b>🏆 Auswertung</b> — wir besprechen, was gut war und was beim nächsten Mal besser geht.'
},
{
title: '📍 Routenplanung — unser Geo-Kernstück',
visual: '<svg viewBox="0 0 480 170" xmlns="http://www.w3.org/2000/svg">'
+ '<rect x="0" y="0" width="480" height="170" fill="#e8f0e6"/>'
// Karte mit Wegpunkten
+ '<g font-family="system-ui" font-size="9">'
// Start
+ '<circle cx="80" cy="115" r="10" fill="#c97a3f" stroke="#b06020" stroke-width="2"/>'
+ '<text x="80" y="119" text-anchor="middle" font-size="9" fill="#fff" font-weight="800">S</text>'
+ '<text x="80" y="142" text-anchor="middle" font-weight="700" fill="#2d5434">Innsbruck</text>'
+ '<text x="80" y="153" text-anchor="middle" fill="#3d5165">Stützpunkt</text>'
// WP1 ✓
+ '<circle cx="170" cy="90" r="10" fill="#fff" stroke="#5a8a5e" stroke-width="2.5"/>'
+ '<text x="170" y="94" text-anchor="middle" font-weight="800" fill="#5a8a5e">1</text>'
+ '<text x="170" y="115" text-anchor="middle" fill="#5a8a5e" font-weight="700">Schwaz ✓</text>'
// WP2 aktiv (pulsierend simuliert)
+ '<circle cx="260" cy="65" r="14" fill="none" stroke="#c85c4a" stroke-width="2" opacity=".5"/>'
+ '<circle cx="260" cy="65" r="10" fill="#fff" stroke="#c85c4a" stroke-width="2.5"/>'
+ '<text x="260" y="69" text-anchor="middle" font-weight="800" fill="#c85c4a">2</text>'
+ '<text x="260" y="90" text-anchor="middle" fill="#c85c4a" font-weight="700">Jenbach?</text>'
+ '<text x="260" y="100" text-anchor="middle" fill="#3d5165">(nächster)</text>'
// WP3, WP4 grau
+ '<circle cx="350" cy="50" r="9" fill="#f5f3ea" stroke="#a8b9a0" stroke-width="2"/>'
+ '<text x="350" y="54" text-anchor="middle" font-size="8" fill="#a8b9a0">3</text>'
+ '<circle cx="420" cy="65" r="9" fill="#c85c4a" stroke="#a04030" stroke-width="2"/>'
+ '<text x="420" y="69" text-anchor="middle" font-size="8" fill="#fff" font-weight="800">Z</text>'
+ '<text x="420" y="90" text-anchor="middle" fill="#a04030" font-weight="700">Mayrhofen</text>'
// Pfadlinien
+ '<line x1="80" y1="115" x2="170" y2="90" stroke="#5a8a5e" stroke-width="1.6" stroke-dasharray="3 3"/>'
+ '<line x1="170" y1="90" x2="260" y2="65" stroke="#c85c4a" stroke-width="1.6" stroke-dasharray="3 3"/>'
+ '</g>'
+ '<text x="240" y="165" text-anchor="middle" font-size="9" fill="#3d5165" font-style="italic">Tower sagt den nächsten Ort an — du klickst ihn auf der Karte.</text>'
+ '</svg>',
text: 'Bei der <span class="heli-glossar-term" data-glossar="routenplanung">Routenplanung</span> funkt uns die <b>Tower-Lotsin</b> (vom <span class="heli-glossar-term" data-glossar="tower">Tower</span>) jeden <span class="heli-glossar-term" data-glossar="wegpunkt">Wegpunkt</span> an: „<i>Weiter über Jenbach</i>". Du klickst ihn auf der Karte.'
+ '<br><br>💡 <b>Wichtig zum Klicken:</b> Der nächste Wegpunkt erscheint <b>nicht sofort</b> — er wird dir als <b style="color:#c85c4a">roter Kreis</b> langsam eingeblendet. Erst wenn er gut sichtbar ist, klickst du ihn an. Wenn du die Route schon kennst, darfst du auch vorher klicken (Pre-Click) — du musst dir den Geo-Hinweis aber trotzdem anhören.'
+ '<br><br><b>Hilfen, die du jederzeit nutzen kannst:</b><ul>'
+ '<li>Über jedem POI auf der Karte steht der Name, wenn du draufzeigst.</li>'
+ '<li>Oben rechts findest du <b>📍 Übersichtskarte</b> — dort siehst du in Ruhe alle <span class="heli-glossar-term" data-glossar="stuetzpunkt">Stützpunkte</span>, Städte und Berge, bevor wir losfliegen.</li></ul>'
+ '<b>Warum die Route oft Umwege macht:</b> Wir folgen Tälern, weichen hohen Bergen aus und nehmen die schnellste sichere Route ins Krankenhaus. Wenn du unsere Strecken nachvollziehst, lernst du gleich, wie die Region aufgebaut ist.'
},
{
title: '⚠ Hindernisse im Flug',
visual: '<svg viewBox="0 0 480 170" xmlns="http://www.w3.org/2000/svg">'
+ '<rect x="0" y="0" width="480" height="170" fill="#e3eef2"/>'
// Bergpanorama
+ '<polygon points="0,170 80,110 160,140 240,90 320,130 400,100 480,135 480,170" fill="#7a9b76" opacity=".7"/>'
// MEIN Notarzt-Heli zentral (orange/gold) — derselbe Stil wie Step 1 + Zielanflug
+ '<g transform="translate(240,80) scale(.9)">'
+ '<ellipse cx="6" cy="-22" rx="50" ry="2.5" fill="rgba(31,42,36,.15)"/>'
+ '<line x1="-44" y1="-22" x2="56" y2="-22" stroke="rgba(31,42,36,.55)" stroke-width="2"/>'
+ '<path d="M -6 -2 L -50 -4 L -52 2 L -6 6 Z" fill="#e4b95c"/>'
+ '<path d="M -50 -4 L -58 -14 L -50 2 Z" fill="#c97a3f"/>'
+ '<circle cx="-54" cy="-6" r="7" fill="rgba(31,42,36,.18)" stroke="rgba(31,42,36,.3)"/>'
+ '<path d="M -12 -12 Q 0 -18 20 -10 Q 30 -4 28 10 L -6 12 Q -14 10 -14 2 Z" fill="#e4b95c"/>'
+ '<path d="M -10 6 Q 0 14 20 10 L 28 10 L -6 12 Z" fill="#c97a3f"/>'
+ '<path d="M 6 -10 Q 22 -10 26 -2 L 26 6 L 6 6 Z" fill="#a8c3a0"/>'
+ '<rect x="-6" y="0" width="18" height="3" fill="#c85c4a"/>'
+ '<text x="3" y="3" text-anchor="middle" font-family="system-ui" font-weight="bold" font-size="5" fill="#c85c4a">★</text>'
+ '<line x1="-26" y1="18" x2="26" y2="18" stroke="#1f2a24" stroke-width="2" stroke-linecap="round"/>'
+ '</g>'
// Hindernis 1: Vögel (links)
+ '<g transform="translate(60,55)">'
+ '<path d="M 0 8 Q 5 0 10 8 Q 15 0 20 8" stroke="#3d5165" stroke-width="1.5" fill="none"/>'
+ '<path d="M 25 12 Q 30 4 35 12 Q 40 4 45 12" stroke="#3d5165" stroke-width="1.5" fill="none"/>'
+ '<text x="22" y="32" text-anchor="middle" font-size="9" fill="#3d5165" font-weight="700">Vögel</text>'
+ '</g>'
// Hindernis 2: Polizei-Heli (Mitte oben — blau, kleiner, statt Drohne)
+ '<g transform="translate(345,40) scale(.55)">'
+ '<ellipse cx="6" cy="-22" rx="50" ry="2.5" fill="rgba(31,42,36,.15)"/>'
+ '<line x1="-44" y1="-22" x2="56" y2="-22" stroke="rgba(31,42,36,.55)" stroke-width="2.5"/>'
+ '<path d="M -6 -2 L -50 -4 L -52 2 L -6 6 Z" fill="#3d5165"/>'
+ '<path d="M -50 -4 L -58 -14 L -50 2 Z" fill="#2a3a4a"/>'
+ '<circle cx="-54" cy="-6" r="7" fill="rgba(31,42,36,.18)" stroke="rgba(31,42,36,.3)"/>'
+ '<path d="M -12 -12 Q 0 -18 20 -10 Q 30 -4 28 10 L -6 12 Q -14 10 -14 2 Z" fill="#3d5165"/>'
+ '<path d="M -10 6 Q 0 14 20 10 L 28 10 L -6 12 Z" fill="#2a3a4a"/>'
+ '<path d="M 6 -10 Q 22 -10 26 -2 L 26 6 L 6 6 Z" fill="#a8c3a0"/>'
+ '<rect x="-6" y="0" width="18" height="3" fill="#e4b95c"/>'
+ '<text x="3" y="3" text-anchor="middle" font-family="system-ui" font-weight="bold" font-size="5" fill="#e4b95c">★</text>'
+ '</g>'
+ '<text x="345" y="75" text-anchor="middle" font-size="9" fill="#2255aa" font-weight="700">Polizei-Heli</text>'
// Hindernis 3: Gewitterwolke (rechts)
+ '<g transform="translate(430,40)">'
+ '<ellipse cx="0" cy="0" rx="28" ry="13" fill="#3d5165" opacity=".75"/>'
+ '<ellipse cx="-12" cy="-6" rx="13" ry="8" fill="#3d5165" opacity=".8"/>'
+ '<ellipse cx="14" cy="-4" rx="11" ry="7" fill="#3d5165" opacity=".75"/>'
+ '<path d="M -2 8 L -6 22 L 0 18 L -3 28" stroke="#e8c547" stroke-width="2" fill="none"/>'
+ '<text x="0" y="46" text-anchor="middle" font-size="9" fill="#3d5165" font-weight="700">Gewitter</text>'
+ '</g>'
+ '<text x="240" y="160" text-anchor="middle" font-size="9" fill="#3d5165" font-style="italic">Augen auf — du teilst dir den Luftraum mit anderen</text>'
+ '</svg>',
text: 'Beim Flug bist du nicht allein in der Luft. Drei Hindernis-Gruppen, auf die wir immer achten:'
+ '<br><br><ul>'
+ '<li><span class="heli-glossar-term" data-glossar="vogelschlag"><b>🦅 Vögel (Vogelschlag)</b></span> — gefährlich für Rotor und Cockpitscheibe. Besonders über Seen (Möwen), bei Dämmerung (Zugvögel) und an Berghängen (Greifvögel auf Aufwinden). Bei Sichtung kurz Höhe oder Kurs ändern.</li>'
+ '<li><span class="heli-glossar-term" data-glossar="luftverkehr"><b>✈ Anderer Luftverkehr</b></span> — andere Helis, Flugzeuge, Drohnen, Segelflieger, Paragleiter. Abstand halten über <span class="heli-glossar-term" data-glossar="tower">Funk</span> und Sicht. Als Notarzt-Heli haben wir Vorflugrecht, andere müssen ausweichen.</li>'
+ '<li><span class="heli-glossar-term" data-glossar="gewitter-flug"><b>⛈ Gewitter</b></span> — lebensgefährlich für Helis (Sturmböen, Hagel, Sicht-Verlust, Blitz). Mindestens <b>10 15 km Abstand</b> halten. Bei plötzlichem Gewitter: sofort landen, Patient später holen.</li>'
+ '</ul>'
+ '<br><b>Tipp:</b> Klick auf jeden der unterstrichenen Begriffe — Marlene erklärt sie dir kurz. Im <b>📖 Glossar</b> findest du die ausführlichen Versionen.'
},
{
title: '🏆 Bewertung & wenn etwas schiefläuft',
visual: '<svg viewBox="0 0 480 160" xmlns="http://www.w3.org/2000/svg">'
+ '<g font-family="system-ui" font-size="10" text-anchor="middle">'
// 3 Sternen-Säulen
+ '<rect x="40" y="50" width="100" height="80" rx="8" fill="#fff" stroke="#2d5434" stroke-width="1.5"/>'
+ '<text x="90" y="40" font-weight="700" fill="#2d5434">📍 Route</text>'
+ '<text x="90" y="80" font-size="20" fill="#c97a3f">★★★★★</text>'
+ '<text x="90" y="105" font-size="8" fill="#3d5165">Wegpunkte richtig</text>'
+ '<text x="90" y="118" font-size="8" fill="#3d5165">in richtiger Reihenfolge</text>'
+ '<rect x="190" y="50" width="100" height="80" rx="8" fill="#fff" stroke="#2d5434" stroke-width="1.5"/>'
+ '<text x="240" y="40" font-weight="700" fill="#2d5434">🛫 Start</text>'
+ '<text x="240" y="80" font-size="20" fill="#c97a3f">★★★★★</text>'
+ '<text x="240" y="105" font-size="8" fill="#3d5165">Abhebe-Manöver</text>'
+ '<text x="240" y="118" font-size="8" fill="#3d5165">+ Ausrichtung</text>'
+ '<rect x="340" y="50" width="100" height="80" rx="8" fill="#fff" stroke="#2d5434" stroke-width="1.5"/>'
+ '<text x="390" y="40" font-weight="700" fill="#2d5434">🛬 Landung</text>'
+ '<text x="390" y="80" font-size="20" fill="#c97a3f">★★★★★</text>'
+ '<text x="390" y="105" font-size="8" fill="#3d5165">Wind, Sinkrate,</text>'
+ '<text x="390" y="118" font-size="8" fill="#3d5165">Landezone frei</text>'
// Mittelwert-Pfeil
+ '<path d="M 240 135 L 240 148" stroke="#c97a3f" stroke-width="2" marker-end="url(#arrowo)"/>'
+ '<text x="240" y="158" font-weight="800" fill="#c97a3f">⌀ Gesamtbewertung 1 5 ★</text>'
+ '</g>'
+ '<defs><marker id="arrowo" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#c97a3f"/></marker></defs>'
+ '</svg>',
text: 'Wir bewerten drei Bereiche: <b>📍 Routenplanung</b>, <b>🛫 Start</b>, <b>🛬 Landung</b> — jeweils 1 5 Sterne. Aus dem Mittelwert ergibt sich deine Gesamtbewertung.'
+ '<br><br><b>Und wenn du Fehler machst?</b> Keine Sorge — ich sitze ja neben dir. Bei der Routenplanung darfst du <b>15 falsche Wegpunkt-Klicks</b> ausprobieren. Bei mehr greife ich ein und plane den Rest. Beim Start oder bei der Landung übernehme ich, wenn ein Manöver zu riskant wird. <b>Den Patient bringen wir in jedem Fall sicher ins Krankenhaus</b> — du sammelst nur weniger Sterne und kannst es im nächsten Einsatz selbst besser machen.'
+ '<br><br>📍 Diese Einschulung kannst du übrigens jederzeit oben über den <b>❓ Spielhilfe</b>-Button nochmal aufrufen. Und die <b>📍 Übersichtskarte</b> ist deine beste Vorbereitung — schau dir Stützpunkte, Städte und Berge vorher in Ruhe an.'
+ '<br><br>Bereit? Such dir deinen ersten Einsatz aus. 💪'
}
];
var heliOnbStep = 0;
function startHeliOnboarding(forced) {
try {
if (!forced && localStorage.getItem('heli.onboardingDone') === '1') return;
} catch (_) {}
heliOnbStep = 0;
renderHeliOnboardingStep();
document.getElementById('heliOnbOverlay').hidden = false;
}
function renderHeliOnboardingStep() {
var s = HELI_ONBOARDING_STEPS[heliOnbStep];
document.getElementById('heliOnbTitle').textContent = s.title;
// Mentor-Bild bei Bedarf setzen (data-loaded flag, weil <img src=""> als
// Seiten-URL aufgeloest wird und !img.src somit nie greift)
var img = document.getElementById('heliOnbMentorImg');
if (img && !img.dataset.loaded) {
var base = window.HELI_BASE || (location.pathname.replace(/\/[^\/]*$/,'') + '/');
img.src = base + 'img/speakers/pilot_b_f.png';
img.dataset.loaded = '1';
}
// Platzhalter aufloesen: {{HELI_BASE}} -> /geograsim/App/sims/heli/
// {{BASE_PATH}} -> /geograsim/App
var hb = window.HELI_BASE || (location.pathname.replace(/\/[^\/]*$/,'') + '/');
var bp = (window.HELI_API_BASE || '/api').replace(/\/api\/?$/, '');
function resolveHeliPlaceholders(html) {
if (!html) return '';
return html.split('{{HELI_BASE}}').join(hb).split('{{BASE_PATH}}').join(bp);
}
document.getElementById('heliOnbVisual').innerHTML = resolveHeliPlaceholders(s.visual || '');
document.getElementById('heliOnbText').innerHTML = resolveHeliPlaceholders(s.text || '');
var dots = HELI_ONBOARDING_STEPS.map(function(_, i){
var cls = i < heliOnbStep ? 'done' : i === heliOnbStep ? 'active' : '';
return '<div class="dot ' + cls + '"></div>';
}).join('');
document.getElementById('heliOnbDots').innerHTML = dots;
var isLast = (heliOnbStep === HELI_ONBOARDING_STEPS.length - 1);
document.getElementById('heliOnbNext').textContent = isLast ? 'Los geht\'s 🚁' : 'Weiter →';
// Zurück-Button erst ab Schritt 2 zeigen.
var back = document.getElementById('heliOnbBack');
if (back) back.style.display = heliOnbStep > 0 ? '' : 'none';
}
function nextHeliOnboarding() {
if (heliOnbStep < HELI_ONBOARDING_STEPS.length - 1) {
heliOnbStep++;
renderHeliOnboardingStep();
} else {
skipHeliOnboarding();
}
}
function prevHeliOnboarding() {
if (heliOnbStep > 0) { heliOnbStep--; renderHeliOnboardingStep(); }
}
function skipHeliOnboarding() {
document.getElementById('heliOnbOverlay').hidden = true;
try { localStorage.setItem('heli.onboardingDone', '1'); } catch (_) {}
}
window.startHeliOnboarding = startHeliOnboarding;
window.nextHeliOnboarding = nextHeliOnboarding;
window.prevHeliOnboarding = prevHeliOnboarding;
window.skipHeliOnboarding = skipHeliOnboarding;
// Plattform-Convention: GGS_TUTORIAL (siehe live-client.js).
window.GGS_TUTORIAL = { simId: 'heli', title: 'Helikopter-Navigation', replay: function() { startHeliOnboarding(true); } };
// =========================================================
// GLOSSAR: Loader + Click-Popup (analog Energiemanager)
// =========================================================
(function(){
var HELI_GLOSSAR_API = (window.HELI_API_BASE || '/api') + '/glossar.php';
var HELI_GLOSSAR_PAGE = ((window.HELI_API_BASE || '/api').replace(/\/api\/?$/, '')) + '/glossar';
var HELI_GLOSSAR_CACHE = {};
// Lokales Fallback fuer Onboarding-Begriffe ohne DB-Eintrag (in Reihenfolge ergaenzbar).
var HELI_GLOSSAR_FALLBACK = {
'heli-kennzeichen': { title:'Heli-Kennzeichen', text:'Amtliches Luftfahrt-Kennzeichen (OE-XEN, OE-BXP usw.). Im Spiel wird beim Mission-Start zufaellig eines aus dem Pool gezogen — analog zur realen Rotation der Crews.' },
'notarzt-hubschrauber': { title:'Notarzt-Hubschrauber', text:'Spezialisiert auf medizinische Akut-Einsaetze. Crew: Pilot:in + Notaerzt:in + Sanitaeter:in. Holt Patient:innen vom Notfallort, fliegt sie ins naechstgelegene geeignete Krankenhaus.' },
'bergrettungs-hubschrauber': { title:'Bergrettungs-Hubschrauber', text:'Auf alpine Rettungen spezialisiert: Winde-Einsaetze in steilem Gelaende, Schluchten, Lawinensuchen. Crew enthaelt oft Bergretter:innen + Flugretter:innen.' },
'polizei-hubschrauber': { title:'Polizei-Hubschrauber', text:'Aufgaben: Fahndung, Suchfluege, Verkehrsueberwachung, Bergrettungs-Unterstuetzung mit Waermebildkamera (FLIR). KEIN Patiententransport mit Winde — das machen Notarzt- und Bergrettungs-Helis.' },
'bundesheer-hubschrauber': { title:'Bundesheer-Hubschrauber', text:'Mittlerer Transport (Black Hawk), leichter Transport (AB212), bald neue AW169 \"Lion\". Such- und Katastrophenhilfe, Unterstuetzung ziviler Stellen bei Grossschadensereignissen.' },
'wegpunkt': { title:'Wegpunkt', text:'Ein definierter Punkt entlang der Flugroute (Ort, Berg, Tal, geografische Markierung). Die Crew fliegt von Wegpunkt zu Wegpunkt — so wird die Route nachvollziehbar und sicher. In der Realitaet sind Wegpunkte oft GPS-Koordinaten.' },
'tower': { title:'Tower', text:'Flugverkehrs-Kontrollstelle am Flughafen. Lotsen koordinieren Starts, Landungen und Ueberflug-Genehmigungen. Im Funk: \"Hier Tower [Stadt]\". Auch Notarzt-Helis sprechen mit dem Tower — Sicherheit und Koordination.' },
'routenplanung': { title:'Routenplanung', text:'Vor jedem Einsatz wird die Anflugroute geplant: Wegpunkte in der richtigen Reihenfolge, Beruecksichtigung von Wetter, Bergketten, Hindernissen, Krankenhaus-Standort. Im Spiel klickst du die Wegpunkte auf der Karte.' },
'stuetzpunkt': { title:'Stuetzpunkt', text:'Heimatbasis eines Rettungshubschraubers. Hier startet die Crew, wartet auf Einsaetze, der Heli wird gewartet und betankt. Oesterreich hat 14 Notarzt-Stuetzpunkte, 3 Bergrettungs-, 7 Polizei- und 4 Bundesheer-Standorte.' },
'co-pilot': { title:'Co-Pilot:in', text:'Zweite Pilot:in im Cockpit. Unterstuetzt die Pilot:in bei Navigation, Funk und Steuerung. Im Training (wie in dieser Simulation) lernen Co-Pilot:innen, selbststaendig Manoever zu fliegen — die erfahrene Pilot:in sitzt daneben und greift ein wenn noetig.' },
'vogelschlag': { title:'Vogelschlag', text:'Kollision Vogel ↔ Hubschrauber. Gefahr: Rotor- oder Scheiben-Schaden. Besonders riskant ueber Seen (Moewen), bei Daemmerung (Zugvoegel) und an Berghaengen (Greifvoegel an Aufwinden).' },
'luftverkehr': { title:'Luftverkehr', text:'Alle anderen Luftfahrzeuge — andere Helis, Flugzeuge, Drohnen, Segelflieger, Paragleiter. Abstand via Funk + Sicht + Transponder. Notarzt-Helis haben Vorflugrecht.' },
'gewitter-flug': { title:'Gewitter im Flug', text:'Lebensgefaehrlich fuer Helis: Boenfront, Hagel, Sicht-Verlust, Blitzschlag. Mindestens 10-15 km Abstand! Bei ploetzlichem Gewitter: sofort landen.' }
};
function heliLoadGlossar() {
fetch(HELI_GLOSSAR_API + '?module=heli')
.then(function(r){ return r.ok ? r.json() : []; })
.then(function(arr){
if (Array.isArray(arr)) arr.forEach(function(e){
HELI_GLOSSAR_CACHE[e.key_slug] = { title:e.title, text:e.short||e.text||'' };
});
})
.catch(function(){ /* still — Fallback greift */ });
}
function heliResolveGlossar(key) {
return HELI_GLOSSAR_CACHE[key] || HELI_GLOSSAR_FALLBACK[key] || null;
}
var _heliPopup = null;
document.addEventListener('click', function(e){
if (_heliPopup && !_heliPopup.contains(e.target)) { _heliPopup.remove(); _heliPopup = null; }
var el = e.target.closest('[data-glossar]');
if (!el) return;
var key = el.dataset.glossar;
var entry = heliResolveGlossar(key);
if (!entry) return;
e.preventDefault();
e.stopPropagation();
if (_heliPopup) { _heliPopup.remove(); _heliPopup = null; }
var popup = document.createElement('div');
popup.className = 'heli-glossar-popup';
popup.innerHTML = '<div class="heli-glossar-title">' + entry.title + '</div>'
+ '<div class="heli-glossar-text">' + entry.text + '</div>'
+ '<a class="heli-glossar-link" href="' + HELI_GLOSSAR_PAGE + '?term=' + encodeURIComponent(key) + '" target="_blank" rel="noopener">Voller Glossar-Eintrag →</a>';
document.body.appendChild(popup);
_heliPopup = popup;
var rect = el.getBoundingClientRect();
var pw = popup.offsetWidth;
popup.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8)) + 'px';
popup.style.top = (rect.bottom + 8) + 'px';
});
heliLoadGlossar();
})();
// =========================================================
// INIT
// =========================================================
setPhase('mission');
// Onboarding einmalig zeigen (nach setPhase, sodass DOM bereit ist)
startHeliOnboarding(false);
// Funk-Pille + Audio-Engine V3 dynamisch nachladen (HELI_BASE garantiert gesetzt).
(function(){
var base = window.HELI_BASE || (location.pathname.replace(/\/[^\/]*$/,'') + '/');
// Funk-Pille
var s = document.createElement('script');
s.src = base + 'scripts/funk-pille.js';
s.onload = function(){
// Image-Base muss OHNE 'img/' enden, weil speakers.json image-Pfade
// schon mit 'img/speakers/...' beginnen. Sonst doppeltes img/img/ → 404.
if (window.HeliFunk) HeliFunk.init(base + 'scripts', base.replace(/\/$/, ''));
};
document.head.appendChild(s);
// Audio-Engine V3
var s2 = document.createElement('script');
s2.src = base + 'scripts/audio-engine.js';
document.head.appendChild(s2);
})();
</script>
</body>
</html>