79ac9dad84
- 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>
1498 lines
77 KiB
HTML
1498 lines
77 KiB
HTML
<!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>
|