88e3c09e5c
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>
2760 lines
155 KiB
HTML
2760 lines
155 KiB
HTML
<!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 0–100-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 (0–100), 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>
|