Files
geograsim/App/sims/heli/game.html
T
Adminator 79ac9dad84 Atlas: Deploy-Buendel vor Staustufen-Sprint
- Logistik: Musik-Player Playlist-Modus (5 Gruppen, 20 Tracks)
- Heli: End-Screen auf .ggs-endscreen, Mission-Bilder, Voice-Lines, Briefing-Audio
- Logistik: Layout-Tausch Auftraege+Fahrzeuge links, Karte rechts
- Logistik: Tier-1-Staedte ausgebaut, Auto-Timescale-Badge
- Logistik: Roadnet/Railnet Dijkstra-Routing, Balance-Updates
- Atlas: 5 Atlas-Inbox-Nachrichten (Cards-Pattern, DALL-E-Key, Musik-Playlists)
- Status-Updates Heli + Logistik

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:05:13 +02:00

1498 lines
77 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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:100vh;overflow:hidden}
/* Phase container */
.phase{display:none;width:100%;height:100vh;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:4px solid var(--forest);padding:12px 16px;font-size:1.35rem;line-height:1.5;flex-shrink:0;z-index:1000}
.plan-instruction h3{font-size:1.3rem;font-weight:800;color:var(--forest);margin-bottom:.3rem}
.plan-instruction .dir{font-size:2.2rem;margin-right:.4rem}
.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}
.flight-bar h2{font-size:.85rem;font-weight:800;color:var(--forest);flex:1}
.speed-btns button{padding:.3rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:5px;background:#fff;font-size:.7rem;font-weight:700;cursor:pointer}
.speed-btns button.on{background:var(--forest);color:var(--cream);border-color:var(--forest)}
#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}
</style>
</head>
<body>
<!-- PHASE 1: Auftragswahl -->
<div class="phase active" id="phase-mission">
<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>
<!-- PHASE 2: Routenplanung -->
<div class="phase" id="phase-plan">
<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)"></span>
<div class="speed-btns">
<button class="on" onclick="setFlightSpeed(1)">1×</button>
<button onclick="setFlightSpeed(2)">2×</button>
<button onclick="setFlightSpeed(4)">4×</button>
</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>
// === FLIGHT AUDIO ENGINE ===
var flightAudioCtx = null;
function initFlightAudio() {
if (flightAudioCtx) return;
try { flightAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {}
}
var flightAudioCache = {};
// === Speech-Pipe fuer Kartenflug ===
// Regeln (analog start/landing): seriell, 400 ms Pause, TTL 10 s, kein
// Doppelt-Einreihen.
var FLIGHT_GAP_MS = 400;
var FLIGHT_TTL_MS = 10000;
var flightAudioQueue = []; // [{path, until}]
var flightAudioCurrent = null;
var flightAudioBusyUntilMs = 0;
async function playFlightAudio(path) {
if (!flightAudioCtx) return;
if (document.querySelector('.overlay.show')) return;
// Dedupe
if (flightAudioQueue.some(function(it){ return it.path === path; })) return;
flightAudioQueue.push({ path: path, until: performance.now() + FLIGHT_TTL_MS });
_flightAudioPump();
}
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.onended = function(){
flightAudioCurrent = null;
flightAudioBusyUntilMs = performance.now() + FLIGHT_GAP_MS;
_flightAudioPump();
};
src.start(0);
} catch(e) {
console.warn('Flight audio fail:', path, e);
flightAudioCurrent = null;
_flightAudioPump();
}
}
// Beim Phasenwechsel komplette Flight-Pipe leeren
function clearFlightSpeechPipe() {
if (flightAudioCurrent) { try { flightAudioCurrent.stop(); } catch(_){} flightAudioCurrent = null; }
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) {
playFlightAudio('sounds/radio/' + name + '.mp3');
}
</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,
};
// =========================================================
// WAYPOINTS + POIs — von PHP aus der DB injiziert
// =========================================================
/*__DB_WAYPOINTS__*/
// =========================================================
// MISSIONS (Auswahl von 3 pro Spiel)
// =========================================================
// Basis → Heli-Typ + Startszene
// Hubschrauber-Zuordnung je Basis — fest gekoppelt an echten Stützpunkt.
// Ist NICHT vom Bearbeiter wählbar (Thomas-Direktive).
// Hubschrauber-Zuordnung je Basis — fest gekoppelt an echten Stützpunkt.
// Ist NICHT vom Bearbeiter wählbar (Thomas-Direktive).
// heliKey: stabile ID für TTS-Dateinamen (r_tower_approach_<key>.mp3)
var BASE_CONFIG = {
nenzing: {heli:'oamtc', heliKey:'c8', heliName:'ÖAMTC Christophorus 8', heliModel:'Airbus H135', scenario:'basis'},
hohenems: {heli:'ara', heliKey:'ara', heliName:'ARA Flugrettung RK-1', heliModel:'Airbus H145', scenario:'basis'},
innsbruck: {heli:'oamtc', heliKey:'c1', heliName:'ÖAMTC Christophorus 1', heliModel:'Airbus H135', scenario:'stadt'},
hall: {heli:'oamtc', heliKey:'c1', heliName:'ÖAMTC Christophorus 1', heliModel:'Airbus H135', scenario:'basis'},
zams_c5: {heli:'oamtc', heliKey:'c5', heliName:'ÖAMTC Christophorus 5', heliModel:'Airbus H135', scenario:'basis'},
kitzbuehel: {heli:'oamtc', heliKey:'c4', heliName:'ÖAMTC Christophorus 4', heliModel:'Airbus H135', scenario:'basis'},
lienz: {heli:'oamtc', heliKey:'c7', heliName:'ÖAMTC Christophorus 7', heliModel:'Airbus H135', scenario:'basis'},
// Österreich-weit
krems_c2: {heli:'oamtc', heliKey:'c2', heliName:'ÖAMTC Christophorus 2', heliModel:'Airbus H135', scenario:'basis'},
wr_neustadt_c3: {heli:'oamtc', heliKey:'c3', heliName:'ÖAMTC Christophorus 3', heliModel:'Airbus H135', scenario:'basis'},
salzburg_c6: {heli:'oamtc', heliKey:'c6', heliName:'ÖAMTC Christophorus 6', heliModel:'Airbus H135', scenario:'stadt'},
wien_c9: {heli:'oamtc', heliKey:'c9', heliName:'ÖAMTC Christophorus 9', heliModel:'Airbus H135', scenario:'stadt'},
linz_c10: {heli:'oamtc', heliKey:'c10', heliName:'ÖAMTC Christophorus 10', heliModel:'Airbus H135', scenario:'basis'},
klagenfurt_c11: {heli:'oamtc', heliKey:'c11', heliName:'ÖAMTC Christophorus 11', heliModel:'Airbus H135', scenario:'basis'},
graz_c12: {heli:'oamtc', heliKey:'c12', heliName:'ÖAMTC Christophorus 12', heliModel:'Airbus H135', scenario:'stadt'},
niederoeblarn_c14:{heli:'oamtc', heliKey:'c14', heliName:'ÖAMTC Christophorus 14', heliModel:'Airbus H135', scenario:'basis'},
oberwart_c16: {heli:'oamtc', heliKey:'c16', heliName:'ÖAMTC Christophorus 16', heliModel:'Airbus H135', scenario:'basis'},
};
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:'hohenems', target:'verkehr_a14',
waypoints:['hohenems','goetzis','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:'brand',
waypoints:['nenzing','buers','brand'], 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:'hohenems', target:'bregenz',
waypoints:['hohenems','dornbirn','bregenz'], theme:'water', icon:'🌊', region:'Vorarlberg'},
{id:'m6', title:'Canyoning Mellau', desc:'Canyoning-Unfall im Bregenzerwald. Verletzte Person in Schlucht.', difficulty:'medium', base:'hohenems', target:'mellau',
waypoints:['hohenems','dornbirn','egg','bezau','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:'kufstein',
waypoints:['innsbruck','hall','schwaz','jenbach','woergl','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 am Dachstein-Südwandsteig, Person in Spalte.', difficulty:'hard', base:'niederoeblarn_c14', target:'berg_dachstein',
waypoints:['niederoeblarn_c14','bad_aussee','dachstein','berg_dachstein'], theme:'avalanche', icon:'❄️', region:'Steiermark', winter:true},
{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','klagenfurt','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: [],
});
// 30-s-Ping waehrend des Einsatzes
if (heliAssessment.runTimer) clearInterval(heliAssessment.runTimer);
heliAssessment.runTimer = setInterval(assessmentPing, 30000);
}
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});
}
// 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;
}
}
// 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
// Beim Zurueckkehren zur Auftragswahl: alle Audios sofort aus.
if (p === 'mission') stopAllGameAudio();
// Beim Wechsel weg vom Kartenflug: Flight-Pipe leeren (sonst quaken
// alte Richtungs-/Intro-Audios in die Landing-Phase rein).
if (p === 'landing' || p === 'results') clearFlightSpeechPipe();
// 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();
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() {
// 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+'.png) 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] || {heliName:'Hubschrauber', scenario:'basis'};
var heliLine = cfg.heliName + (cfg.heliModel ? ' · '+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;});
game.route = game.mission.waypoints;
// 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;
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; // Zaehlt nur falsche Klicks (richtige nicht)
game.planLog = []; // Letzte 3 Hinweise zur Anzeige
game.planTakeover = false; // wird true bei 15 falschen Versuchen
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 = [];
// POIs aus DB (von PHP injiziert als DB_POIS)
if (typeof DB_POIS !== 'undefined') {
DB_POIS.forEach(function(p) {
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');
});
}
// Alle Mission-Waypoints als kleine neutrale Kreise auf die Karte — der
// Spieler sieht, wo die Klick-Ziele liegen, muss aber immer noch richtige
// Reihenfolge und Himmelsrichtung erkennen.
for (var wi = 1; wi < game.route.length; wi++) {
var wp = WP[game.route[wi]];
if (!wp) continue;
var dot = L.marker([wp.lat, wp.lon], {
icon: L.divIcon({
className: '',
html: '<div style="width:14px;height:14px;border-radius:50%;background:rgba(200,92,74,.25);border:2px solid rgba(200,92,74,.7);box-shadow:0 1px 3px rgba(0,0,0,.15)"></div>',
iconSize: [14,14], iconAnchor: [7,7]
}),
interactive: false,
zIndexOffset: -10 // unter den bestehenden POIs
}).addTo(planMap);
planLayers.push(dot);
}
// 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);
// Fit to route area
var allPts = game.route.map(function(id){var w=WP[id];return w?[w.lat,w.lon]:null;}).filter(Boolean);
planMap.fitBounds(L.latLngBounds(allPts).pad(0.2));
// Info-Panel füllen
var cfg = BASE_CONFIG[game.mission.base] || {};
document.getElementById('pi-mission').textContent = game.mission.title;
document.getElementById('pi-heli').textContent = cfg.heliName || '—';
document.getElementById('pi-wp').textContent = game.route.length;
document.getElementById('pi-region').textContent = game.mission.region || '—';
showPlanInstruction();
}
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] || {heliName:'Hubschrauber', scenario:'basis'};
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.';
document.getElementById('planInstruction').innerHTML =
'<h3 class="plan-ok">✅ Route vollständig — Einsatz-Briefing</h3>'
+'<div style="display:grid;grid-template-columns:1fr 1fr;gap:.6rem .9rem;margin:.6rem 0 1rem;font-size:1.2rem">'
+'<div><strong>Einsatz:</strong> '+game.mission.title+'</div>'
+'<div><strong>Hubschrauber:</strong> '+cfg.heliName+'</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:1.15rem;line-height:1.5;border-left:3px solid var(--forest);padding-left:.8rem;margin-bottom:1rem">'+szenarioInfo+'</p>'
+'<button class="btn-big" onclick="setPhase(\'start\')" style="background:#c85c4a;font-size:1.3rem;padding:1rem 3rem">🚁 Start freigeben</button>';
document.getElementById('planProgress').textContent = 'Briefing';
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)+' finden</h3>'
+'<p>Pilot: „Von <strong>'+fromWP.name+'</strong> fliegen wir <strong>'+dirName+'</strong>, etwa <strong>'+dist.toFixed(0)+' km</strong>. Suche <strong>'+toWP.name+'</strong> auf der Karte und klicke darauf."</p>';
document.getElementById('planProgress').textContent = 'Wegpunkt '+idx+'/'+( route.length-1);
// Bei aktivem Fehler-Log diesen wieder einhängen, damit er nicht wegen
// innerHTML-Reset verloren geht.
if (game.planLog && game.planLog.length) renderPlanErrorLog();
}
function onPlanClick(e) {
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++;
// Counter pro Wegpunkt zuruecksetzen — 15 Versuche je Wegpunkt
game.planAttempts = 0;
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++;
initFlightAudio();
// letzter Wegpunkt erreicht → "Route vollstaendig", sonst einfach "weiter"
playRadioAudio(game.planIndex >= game.route.length ? 'r_nav_plan_done' : 'r_nav_plan_correct');
showPlanInstruction();
} else {
// Falsch — Audio je nach Distanz (nahe <10 km vs weit)
game.planAttempts++;
var bearing = calcBearing(e.latlng.lat, e.latlng.lng, targetWP.lat, targetWP.lon);
var dir = bearingToName(bearing);
initFlightAudio();
playRadioAudio(dist < 10 ? 'r_nav_plan_wrong_near' : 'r_nav_plan_wrong_far');
// Hinweis-Log: nur die letzten 3 anzeigen, durchnummeriert
game.planLog.push({ n: game.planAttempts, name: targetWP.name, dir: dir, dist: dist });
if (game.planLog.length > 3) 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)';
}
}
}
// 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'};
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.heliName || '');
var params = 'heli=' + encodeURIComponent(cfg.heli)
+ '&scenario=' + encodeURIComponent(cfg.scenario)
+ '&helikey=' + encodeURIComponent(cfg.heliKey || '')
+ '&heliname=' + encodeURIComponent(cfg.heliName || '')
+ '&basename=' + encodeURIComponent(baseName)
+ '&mission=' + encodeURIComponent(game.mission.title || '')
+ '&autostart=1' + winterParam;
document.getElementById('startFrame').src = (window.HELI_BASE || '') + 'start.html?' + params;
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 Chefpilot übernommen</h2>'
+ '<p style="color:var(--slate);font-size:1.2rem;max-width:560px">'
+ (opts.reason ? opts.reason + '. ' : '')
+ 'Der Chefpilot 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
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();
playRadioAudio('r_nav_route_check');
setTimeout(function() {
// Schritt 2: Pilot sagt die Route an (Text + generischer Funk-Sound)
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();
// 16-Richtungen-Name wird auf 8 Grob-Audio-Files gemappt
playNavAudio(bearingToAudio8(introBearing));
// Schritt 3: Nach 3 s ersten Segment-Flug starten
setTimeout(flyToNext, 3000);
}, 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;
// Nur Richtungs-Funk (deutsche Aufnahmen). Ortsnamen-Audio ist deaktiviert,
// weil die aktuellen TTS-Files englisch ausgesprochen werden — stattdessen
// steht der Ortsname nur im Text. Sobald wir saubere deutsche TTS haben,
// hier wieder aktivieren.
initFlightAudio();
// SPARSAM: pro Segment nur die Richtungsansage.
// Ausnahme: im ersten Segment (flightIndex===1) einmalig ein heli-spezifisches
// Flug-Intro mit Geografie-Lernstoff statt generischer Floskel.
if (!game._navAudioPlayed) game._navAudioPlayed = {};
var segBearing = calcBearing(fromWP.lat, fromWP.lon, toWP.lat, toWP.lon);
if (game.flightIndex === 1 && !game._navAudioPlayed.intro) {
game._navAudioPlayed.intro = true;
var introCfg = BASE_CONFIG[game.mission.base] || {};
var introKey = introCfg.heliKey || '';
if (introKey) {
playRadioAudio('r_nav_heli_intro_' + introKey);
setTimeout(function(){ playNavAudio(bearingToAudio8(segBearing)); }, 15000);
} else {
playNavAudio(bearingToAudio8(segBearing));
}
} else {
playNavAudio(bearingToAudio8(segBearing));
}
// Einmaliger Kontext-Hinweis pro Flug: nur bei spaeteren Segmenten (>=3),
// damit erst die Routenansagen Zeit hatten, und nur mit 40 % Chance.
if (game.flightIndex >= 3 && Math.random() < 0.4) {
var fuelFrac = Math.max(0, 1 - game.flightIndex / Math.max(1, game.route.length));
var ctx = null;
if (fuelFrac < 0.35 && !game._navAudioPlayed.fuel) { ctx = 'r_nav_fuel_quarter'; game._navAudioPlayed.fuel = true; }
else if (fuelFrac < 0.55 && !game._navAudioPlayed.fuel) { ctx = 'r_nav_fuel_half'; game._navAudioPlayed.fuel = true; }
else if (!game._navAudioPlayed.alt && game.mission) {
if (game.mission.theme === 'ski' || game.mission.theme === 'avalanche' || game.mission.theme === 'cliff') {
ctx = 'r_nav_altitude_mountain'; game._navAudioPlayed.alt = true;
} else if (game.mission.theme === 'traffic') {
ctx = 'r_nav_altitude_valley'; game._navAudioPlayed.alt = true;
}
}
if (ctx) setTimeout(function(){ playRadioAudio(ctx); }, 4500);
}
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;
document.querySelectorAll('.speed-btns button').forEach(function(b){b.classList.toggle('on', parseInt(b.textContent)===s);});
}
// =========================================================
// PHASE 5: LANDUNG
// =========================================================
function initLandingPhase() {
var cfg = BASE_CONFIG[game.mission.base] || {heli:'oamtc', heliName:'Rettungshubschrauber'};
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(cfg.heliName || 'Rettungshubschrauber')
+ '&level=' + lv
+ '&scen=' + scen
+ '&mission=' + encodeURIComponent(game.mission.title)
+ '&missionid=' + encodeURIComponent(game.mission.id || '');
document.getElementById('landingFrame').src = (window.HELI_BASE || '') + 'landing.html' + params;
window.addEventListener('message', onLandingMessage);
setTimeout(function() {
if (game.phase === 'landing') { game.landingScore = 3; setPhase('results'); }
}, 120000);
}
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
// =========================================================
function initResultsPhase() {
var planPct = game.route.length > 1 ? Math.round(game.planCorrect / (game.route.length-1) * 100) : 100;
var planStars = planPct >= 90 ? 5 : planPct >= 70 ? 4 : planPct >= 50 ? 3 : planPct >= 30 ? 2 : 1;
// Pilot-Uebernahme nach 15 Fehlversuchen → keine Sterne fuer die Zielsuche
if (game.planTakeover) planStars = 0;
var totalStars = Math.round((planStars + game.startScore + game.landingScore) / 3);
game.totalStars = totalStars;
// Assessment-Hook 3: Mission-Ende (mit Sterne + Dauer)
assessmentComplete();
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 — der Chefpilot 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 der Chefpilot 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;
}
} else {
cls = 'failed';
icon = '🧑‍✈️';
title = 'Chefpilot hat übernommen';
subtitle = game.mission.title + ' · ' + game.mission.region;
comment = 'Der Chefpilot musste ans Steuer'
+ (game.failReason ? ' — ' + game.failReason + '.' : '.')
+ ' Der Patient wurde trotzdem gerettet. Beim nächsten Mal schaffst du das selbst.';
}
// Cockpit-Link: BASE_PATH + /sim immer (Demo-Modus zeigt dort die
// freien Module an, nicht die Landing). HELI_API_BASE faellt auf '/api'
// zurueck, wenn nicht gesetzt — dann leitet /sim auf localhost/sim,
// was vor dem Wrapper auf /sim.html zeigt.
var apiBase = window.HELI_API_BASE || '/api';
var cockpitHref = apiBase.replace(/\/api\/?$/,'') + '/sim';
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">' + '★'.repeat(planStars) + '</div><div class="l">Routenplanung</div></div>'
+ '<div class="ggs-endscreen-kpi"><div class="n">' + '★'.repeat(game.startScore) + '</div><div class="l">Start</div></div>'
+ '<div class="ggs-endscreen-kpi"><div class="n">' + '★'.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">🔧 Debug-Daten anzeigen</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>'
);
}
// =========================================================
// 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];
}
// =========================================================
// INIT
// =========================================================
setPhase('mission');
</script>
</body>
</html>