Files
geograsim/App/sims/heli/landing.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

4069 lines
172 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">
<title>Helikopter-Simulator · Bergrettung</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root{
--cream:#f5efe0; --cream-2:#ece4d0;
--forest:#2d5434; --sage:#7a9b76; --sage-light:#a8c3a0;
--slate:#3d5165; --slate-light:#6b7d8f;
--orange:#c97a3f; --red:#c85c4a; --yellow:#e4b95c;
--ink:#1f2a24;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0;background:var(--cream);color:var(--ink);font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.wrap{max-width:1440px;margin:0 auto;padding:18px}
header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:14px;flex-wrap:wrap}
h1{margin:0;font-size:22px;font-weight:700;color:var(--forest);letter-spacing:-.01em}
.sub{color:var(--slate-light);font-size:13px}
.stage{position:relative;border-radius:18px;overflow:hidden;background:var(--cream-2);box-shadow:0 2px 0 rgba(0,0,0,.04),0 20px 40px -20px rgba(45,84,52,.25)}
canvas{display:block;width:100%;height:auto;background:#eaf1ea}
.hud{position:absolute;inset:14px 14px auto 14px;display:grid;grid-template-columns:auto 1fr auto;gap:12px;pointer-events:none}
.panel{background:rgba(245,239,224,.92);backdrop-filter:blur(6px);border:1px solid rgba(45,84,52,.12);border-radius:12px;padding:10px 12px;font-size:12px;line-height:1.4;color:var(--ink);box-shadow:0 6px 16px -8px rgba(45,84,52,.3)}
.bars{display:grid;gap:6px;min-width:230px}
.bar{display:grid;grid-template-columns:64px 1fr 50px;align-items:center;gap:8px;font-size:11px;color:var(--slate)}
.bar .track{height:8px;background:rgba(45,84,52,.12);border-radius:99px;overflow:hidden}
.bar .fill{height:100%;border-radius:99px;transition:width .12s linear}
.bar .fill#fFuelRate{transition:width .6s ease-out, background .3s linear}
.bar .val{text-align:right;font-variant-numeric:tabular-nums;color:var(--ink);font-weight:600}
.wind{display:flex;align-items:center;gap:10px;font-weight:600;color:var(--forest)}
.strikes{display:flex;gap:6px;align-items:center;margin-top:4px;font-size:11px;color:var(--slate)}
.dot{width:12px;height:12px;border-radius:50%;border:1.5px solid var(--red);background:transparent;transition:background .2s}
.dot.hit{background:var(--red);box-shadow:0 0 8px rgba(200,92,74,.5)}
kbd{background:#fff;border:1px solid #d8d0bc;border-bottom-width:2px;border-radius:6px;padding:2px 8px;font-family:inherit;font-size:12px;color:var(--ink)}
.pill{display:inline-flex;align-items:center;gap:6px;background:var(--sage-light);color:var(--forest);padding:4px 10px;border-radius:99px;font-weight:700;font-size:11px;letter-spacing:.04em;text-transform:uppercase}
.overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:rgba(61,81,101,.55);backdrop-filter:blur(3px);z-index:5;padding:20px;overflow-y:auto}
.overlay.show{display:flex}
.card{background:var(--cream);border-radius:18px;padding:22px 24px;text-align:left;max-width:680px;width:100%;box-shadow:0 30px 60px -20px rgba(0,0,0,.35);border:1px solid rgba(45,84,52,.15);max-height:90vh;overflow-y:auto}
.card h2{margin:0 0 4px;color:var(--forest);font-size:20px}
.card .tag{display:inline-block;background:var(--sage-light);color:var(--forest);font-size:11px;font-weight:700;padding:2px 8px;border-radius:99px;letter-spacing:.04em;text-transform:uppercase;margin-bottom:10px}
.card p{margin:0 0 12px;color:var(--slate);font-size:14px;line-height:1.5}
.sect{margin-top:14px}
.sect-title{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--forest);margin-bottom:8px}
.opts{display:flex;flex-wrap:wrap;gap:6px}
.opt{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:10px;font-size:12px;font-weight:600;cursor:pointer;border:1.5px solid transparent;background:rgba(45,84,52,.08);color:var(--ink);transition:all .15s;user-select:none}
.opt:hover{background:rgba(45,84,52,.14)}
.opt.active{background:var(--forest);color:var(--cream);border-color:var(--forest)}
.opt .swatch{width:12px;height:12px;border-radius:50%;border:1.5px solid rgba(31,42,36,.2)}
.level-card{flex:1;min-width:170px;padding:12px 14px;border-radius:12px;border:2px solid rgba(45,84,52,.15);background:rgba(255,255,255,.55);cursor:pointer;transition:all .15s;user-select:none}
.level-card:hover{border-color:var(--sage);background:rgba(255,255,255,.85)}
.level-card.active{border-color:var(--forest);background:var(--forest);color:var(--cream)}
.level-card.active .lc-desc{color:rgba(245,239,224,.9)}
.level-card .lc-num{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--orange);margin-bottom:4px}
.level-card.active .lc-num{color:var(--yellow)}
.level-card .lc-title{font-size:15px;font-weight:700;margin-bottom:6px}
.level-card .lc-desc{font-size:12px;line-height:1.4;color:var(--slate)}
.level-row{display:flex;gap:10px;flex-wrap:wrap}
.collapsible{margin-top:14px;border:1px solid rgba(45,84,52,.18);border-radius:10px;overflow:hidden}
.collapsible-head{padding:10px 14px;background:rgba(45,84,52,.08);cursor:pointer;display:flex;justify-content:space-between;align-items:center;font-size:13px;font-weight:700;color:var(--forest);user-select:none}
.collapsible-head:hover{background:rgba(45,84,52,.14)}
.collapsible-body{padding:14px;display:none;font-size:13px;color:var(--slate);line-height:1.55}
.collapsible-body p{margin:0 0 10px}
.collapsible-body p:last-child{margin-bottom:0}
.collapsible-body ol{margin:0 0 10px;padding-left:22px}
.collapsible-body ol li{margin-bottom:6px}
.collapsible.open .collapsible-body{display:block}
.collapsible.open .arrow{transform:rotate(90deg)}
.arrow{transition:transform .15s;display:inline-block}
.btn{background:var(--forest);color:var(--cream);border:none;padding:11px 22px;border-radius:10px;font-weight:700;font-size:13px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;margin-top:16px}
.btn:hover{background:#234027}
.mission{position:absolute;left:14px;bottom:14px;background:rgba(245,239,224,.92);border:1px solid rgba(45,84,52,.12);border-radius:12px;padding:10px 14px;font-size:12px;color:var(--slate);max-width:480px;box-shadow:0 6px 16px -8px rgba(45,84,52,.3);z-index:3}
.mission b{color:var(--forest)}
.dir-indicator{position:absolute;bottom:14px;background:rgba(245,239,224,.85);border:1.5px solid rgba(45,84,52,.2);border-radius:10px;padding:8px 16px;font-weight:700;font-size:14px;color:var(--slate-light);transition:all .2s;z-index:2;pointer-events:none;letter-spacing:.04em}
.dir-indicator.dir-west{right:calc(50% + 6px)}
.dir-indicator.dir-east{left:calc(50% + 6px)}
.dir-indicator.active{background:var(--forest);color:var(--cream);border-color:var(--forest);box-shadow:0 4px 14px -4px rgba(45,84,52,.6);transform:scale(1.06)}
.dir-indicator.dim{opacity:0.35}
.phase-box{position:absolute;top:14px;left:50%;transform:translateX(-50%);background:rgba(245,239,224,.96);border:2px solid var(--forest);border-radius:14px;padding:8px 18px;font-size:13px;color:var(--ink);font-weight:600;box-shadow:0 8px 20px -8px rgba(45,84,52,.4);z-index:3;text-align:center;min-width:280px}
.phase-box .ph-num{display:block;font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--orange);margin-bottom:2px}
.phase-box .ph-title{font-size:14px;font-weight:700;color:var(--forest)}
.phase-box .ph-bar{margin-top:6px;height:6px;background:rgba(45,84,52,.15);border-radius:99px;overflow:hidden}
.phase-box .ph-bar > div{height:100%;background:var(--forest);width:0%;transition:width .15s;border-radius:99px}
.phase-box.urgent{border-color:var(--red);animation:pulse 1.2s infinite}
@keyframes pulse{0%,100%{box-shadow:0 8px 20px -8px rgba(200,92,74,.4)}50%{box-shadow:0 8px 28px -4px rgba(200,92,74,.7)}}
.footer{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-top:12px;font-size:13px;color:var(--slate)}
.joy-base{position:relative;width:120px;height:120px;border-radius:50%;background:rgba(245,239,224,.78);border:2px solid rgba(45,84,52,.35);box-shadow:0 8px 22px -6px rgba(45,84,52,.35), inset 0 0 0 8px rgba(255,255,255,.18);touch-action:none;-webkit-user-select:none;user-select:none}
.joy-base.active{background:rgba(245,239,224,.95)}
.joy-axis-h{position:absolute;top:50%;left:14px;right:14px;height:1px;background:rgba(45,84,52,.18);transform:translateY(-50%)}
.joy-axis-v{position:absolute;left:50%;top:14px;bottom:14px;width:1px;background:rgba(45,84,52,.18);transform:translateX(-50%)}
.joy-stick{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:54px;height:54px;border-radius:50%;background:radial-gradient(circle at 30% 30%,#3d6a44,#2d5434);box-shadow:0 4px 10px rgba(0,0,0,.35), inset 0 -3px 6px rgba(0,0,0,.25), inset 0 3px 5px rgba(255,255,255,.2);transition:transform .14s cubic-bezier(.2,.8,.4,1);pointer-events:none}
.joy-base.active .joy-stick{transition:none}
@media(pointer:coarse){#touchControls{display:block !important}}
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1>🚁 Helikopter-Simulator · Bergrettung</h1>
<div class="sub">Co-Pilot im Gebirgseinsatz · Flugretter abseilen, Patient bergen, sicher zurück ins Tal</div>
</div>
<span class="pill" id="scenPill">Lektion · Bergrettung</span>
</header>
<div class="stage" id="stage">
<canvas id="game" width="1400" height="760"></canvas>
<div class="phase-box" id="phaseBox" style="display:none">
<span class="ph-num" id="phNum">Phase 1 / 3</span>
<span class="ph-title" id="phTitle">Anflug zum Patienten</span>
<div class="ph-bar"><div id="phBar"></div></div>
</div>
<div class="hud">
<div class="panel bars">
<div class="bar"><span>Höhe</span><div class="track"><div class="fill" id="fAlt" style="background:var(--sage);width:0%"></div></div><span class="val" id="vAlt">0 m</span></div>
<div class="bar"><span>Sinken</span><div class="track"><div class="fill" id="fVy" style="background:var(--orange);width:0%"></div></div><span class="val" id="vVy">0 m/s</span></div>
<div class="bar"><span>Speed</span><div class="track"><div class="fill" id="fSpd" style="background:var(--slate);width:0%"></div></div><span class="val" id="vSpd">0 km/h</span></div>
<div class="bar"><span>Treibst.</span><div class="track"><div class="fill" id="fFuel" style="background:var(--red);width:100%"></div></div><span class="val" id="vFuel">100 %</span></div>
<div class="bar"><span>Verbrauch</span><div class="track"><div class="fill" id="fFuelRate" style="background:var(--orange);width:0%"></div></div><span class="val" id="vFuelRate">— l/min</span></div>
<div class="bar"><span>G-Kraft</span><div class="track"><div class="fill" id="fG" style="background:var(--yellow);width:10%"></div></div><span class="val" id="vG">1.0 g</span></div>
<div class="strikes">Berührungen: <span class="dot" id="d1"></span><span class="dot" id="d2"></span><span class="dot" id="d3"></span> <span style="margin-left:auto" id="vStrikes">0 / 3</span></div>
<div id="strikeLog" style="font-size:11px;color:var(--slate);margin-top:6px;max-width:240px;display:none"></div>
</div>
<div></div>
<div class="panel" id="compassPanel">
<div style="font-size:12px;line-height:1.55;color:var(--slate);min-width:160px">
<div><b style="color:var(--orange)">Wind</b> <span id="vWind">— kn</span></div>
<div style="margin-top:2px"><b style="color:var(--forest)">Ziel</b> <span id="vTarget"></span></div>
<div style="margin-top:4px;color:var(--slate-light)" id="vHeliId"></div>
<div id="muteBtn" onclick="toggleMute()" style="margin-top:6px;cursor:pointer;font-size:16px;text-align:center;pointer-events:auto;opacity:.6;user-select:none" title="Ton ein/aus (M)">🔊</div>
</div>
</div>
</div>
<div class="mission" id="missionBox"><b>Ziel:</b> Wähle Schwierigkeitsstufe & Helikopter.</div>
<!-- Touch Controls — Joystick -->
<div id="touchControls" style="position:absolute;bottom:18px;right:18px;z-index:4;display:none;pointer-events:auto">
<div id="joyBase" class="joy-base">
<div class="joy-axis-h"></div>
<div class="joy-axis-v"></div>
<div id="joyStick" class="joy-stick"></div>
</div>
</div>
<div class="dir-indicator dir-west" id="dirWest">← Westen</div>
<div class="dir-indicator dir-east" id="dirEast">Osten →</div>
<div class="overlay show" id="overlay">
<div class="card" id="card">
<span class="tag" id="cardTag">Bergrettung · Briefing</span>
<h2 id="cardT">Notruf aus dem Gebirge</h2>
<p id="cardP">Eine verletzte Person liegt an einem Felsband. Du bist <b>Co-Pilot</b> und fliegst die heikle Bergung. Drei Phasen warten auf dich:</p>
<ol style="margin:0 0 14px;padding-left:22px;color:var(--slate);font-size:13px;line-height:1.6">
<li><b>Anflug & Abseilen:</b> Schwebeflug über dem Patienten halten, der Flugretter wird abgeseilt.</li>
<li><b>Warteschleife:</b> Während der Retter versorgt, kreist du in sicherem Abstand. <i>Nicht stehenbleiben — frisst Treibstoff!</i></li>
<li><b>Aufnahme & Ausflug:</b> Erneut Position halten, beide hochziehen, sicher zur Klinik fliegen.</li>
</ol>
<div class="sect">
<div class="sect-title">Schwierigkeitsstufe</div>
<div class="level-row" id="levelOpts"></div>
</div>
<div class="sect">
<div class="sect-title">Helikopter</div>
<div class="opts" id="heliOpts"></div>
</div>
<div class="collapsible" id="adminCollapse">
<div class="collapsible-head" id="adminHead">
<span>⚙️ Erweiterte Parameter (Admin)</span>
<span class="arrow"></span>
</div>
<div class="collapsible-body">
<p style="margin-bottom:12px;font-size:12px">Diese Werte überschreiben die Standard-Einstellungen des gewählten Levels. Bei Level-Wechsel werden sie zurückgesetzt.</p>
<div id="adminSliders"></div>
</div>
</div>
<div class="collapsible" id="eduCollapse">
<div class="collapsible-head" id="eduHead">
<span>📚 Wie funktioniert eine Bergrettung mit der Winde?</span>
<span class="arrow"></span>
</div>
<div class="collapsible-body">
<p>Bei einem Bergunfall kann der Hubschrauber meist <b>nicht landen</b> — der Hang ist zu steil, Geröll oder Schnee zu instabil. Stattdessen arbeitet die Crew mit einer <b>Rettungswinde</b> und einem 20 bis 90 Meter langen Tau.</p>
<ol>
<li><b>Stationärer Schwebeflug:</b> Der Pilot bringt den Heli neben (nicht direkt über!) den Patienten. Direkt darüber wäre der Rotorabwind zu gefährlich für den Verletzten.</li>
<li><b>Flugretter abseilen:</b> Der ausgebildete Bergretter wird zum Patienten heruntergelassen, versorgt ihn und bereitet ihn für den Transport vor (Bergesack, Trage oder Rettungsschlinge).</li>
<li><b>Warteschleife:</b> In der Schwebe verbraucht der Heli sehr viel Treibstoff und der Pilot muss höchst konzentriert arbeiten. Deshalb fliegt er meist eine Warteschleife — oft talabwärts — bis der Retter „bereit" funkt.</li>
<li><b>Zweiter Anflug:</b> Erneuter Schwebeflug, Winde herunterlassen, Retter und Patient hochziehen.</li>
<li><b>Ausflug:</b> Talwärts beschleunigen — das gibt sofort Höhenreserve und Sicherheit.</li>
</ol>
<p><b>Was es im Gebirge so schwierig macht:</b> Der Wind ist nicht konstant! An der <b>Luvseite</b> (windzugewandt) gibt es Aufwinde, an der <b>Leeseite</b> dagegen tückische Abwinde und Walzen. In engen Tälern wird der Wind beschleunigt (Düseneffekt). Dazu kommen Wolken, die plötzlich die Sicht nehmen, und versteckte Hindernisse wie <b>Seilbahnkabel</b>.</p>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;align-items:center">
<button class="btn" id="startBtn">Einsatz starten</button>
</div>
</div>
</div>
</div>
<div class="footer">
<div><kbd></kbd>/<kbd>W</kbd> Schub nach oben (tippen für Impuls, halten für Dauer­schub) · <kbd></kbd>/<kbd></kbd> oder <kbd>A</kbd>/<kbd>D</kbd> Neigen · <kbd>R</kbd> Neustart</div>
<div>Bei <b>3 Berührungen</b> übernimmt der Chefpilot.</div>
</div>
</div>
<script>
(function(){
const cv = document.getElementById('game');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
// =========== SOUND ENGINE ===========
const SND_PATH = 'sounds/';
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const masterGain = audioCtx.createGain();
masterGain.connect(audioCtx.destination);
masterGain.gain.value = 0.6;
let muted = false;
window.toggleMute = function() {
muted = !muted;
masterGain.gain.value = muted ? 0 : 0.6;
var btn = document.getElementById('muteBtn');
if(btn){btn.textContent = muted ? '🔇' : '🔊'; btn.style.opacity = muted ? '1' : '.6';}
};
const buffers = {};
const soundFiles = ['heliFly','warning','crash1','crash2','crash3','crashBuilding','birdCrash','birds1','birds2','birds3'];
const radioFiles = ['pilot_calm1','pilot_calm2','pilot_calm3','strike1','strike2','strike3','ouch1','ouch2','ouch3',
'success2','takeover','takeover2','takeover4','tower1','tower2',
'pilot_calm1_echo','pilot_calm2_echo','pilot_calm3_echo','strike1_echo','strike2_echo','ouch1_echo','ouch2_echo','success2_echo','takeover_echo',
'pilot_calm1_alloy','pilot_calm2_alloy','pilot_calm3_alloy','strike1_alloy','strike2_alloy','ouch1_alloy','ouch2_alloy','success2_alloy','takeover_alloy',
// Pro Heli eigene Tower-Meldungen (approach = Einsatzort, extract = Klinik-Freigabe)
'tower_approach_c1','tower_approach_c2','tower_approach_c3','tower_approach_c4','tower_approach_c5','tower_approach_c6','tower_approach_c7','tower_approach_c8','tower_approach_c9','tower_approach_c10','tower_approach_c11','tower_approach_c12','tower_approach_c14','tower_approach_c16','tower_approach_ara',
'tower_extract_c1','tower_extract_c2','tower_extract_c3','tower_extract_c4','tower_extract_c5','tower_extract_c6','tower_extract_c7','tower_extract_c8','tower_extract_c9','tower_extract_c10','tower_extract_c11','tower_extract_c12','tower_extract_c14','tower_extract_c16','tower_extract_ara',
// Autobahn-Varianten (ARA + bundesweite Helis, die Autobahn fliegen)
'tower_hw_approach_ara','tower_hw_extract_ara',
'tower_hw_approach_c6','tower_hw_extract_c6',
'tower_hw_approach_c9','tower_hw_extract_c9',
'tower_hw_approach_c10','tower_hw_extract_c10',
'tower_hw_approach_c11','tower_hw_extract_c11',
'tower_hw_approach_c12','tower_hw_extract_c12',
// Seerettung-Varianten (ARA + bundesweite Helis, die See fliegen)
'tower_sea_approach_ara','tower_sea_extract_ara',
'tower_sea_approach_c6','tower_sea_extract_c6',
'tower_sea_approach_c9','tower_sea_extract_c9',
'tower_sea_approach_c10','tower_sea_extract_c10',
'tower_sea_approach_c11','tower_sea_extract_c11',
'tower_sea_approach_c16','tower_sea_extract_c16',
// Pilot-Calls im Zielanflug (ICAO-Protokoll: Adressat + Rufzeichen + Anliegen)
'pilot_call_c1','pilot_call_c2','pilot_call_c3','pilot_call_c4','pilot_call_c5','pilot_call_c6','pilot_call_c7','pilot_call_c8','pilot_call_c9','pilot_call_c10','pilot_call_c11','pilot_call_c12','pilot_call_c14','pilot_call_c16','pilot_call_ara',
// Tower-Geografie-Antworten je Mission
'tower_geo_m1','tower_geo_m2','tower_geo_m3','tower_geo_m4','tower_geo_m5','tower_geo_m6','tower_geo_m7','tower_geo_m8','tower_geo_m9',
'tower_geo_m10','tower_geo_m11','tower_geo_m12','tower_geo_m13','tower_geo_m14','tower_geo_m15','tower_geo_m16','tower_geo_m17','tower_geo_m18','tower_geo_m19','tower_geo_m20','tower_geo_m21','tower_geo_m22','tower_geo_m23','tower_geo_m24','tower_geo_m25',
// Landing-Phasen-Ansagen (Retter/Pilot, szenario-neutral)
'land_approach','land_winch_down','land_rescuer_down','land_rescuer_ready','land_hook_connected','land_lift_success','land_clinic_ahead','land_disembark'];
async function preloadSounds() {
for (const name of soundFiles) {
try { const r = await fetch(SND_PATH+name+'.mp3'); buffers[name] = await audioCtx.decodeAudioData(await r.arrayBuffer()); } catch(e) {}
}
for (const name of radioFiles) {
try {
// Neue Dateien liegen mit r_-Praefix auf Platte (r_tower_geo_m1.mp3),
// alte ohne (pilot_calm1.mp3). Erst r_, dann Legacy.
let r = await fetch(SND_PATH+'radio/r_'+name+'.mp3');
if (!r.ok) r = await fetch(SND_PATH+'radio/'+name+'.mp3');
if (!r.ok) continue;
const buf = await r.arrayBuffer();
if (buf.byteLength < 500) continue;
buffers['r_'+name] = await audioCtx.decodeAudioData(buf);
} catch(e) {}
}
}
preloadSounds();
function playSound(name, vol=1) {
if (!buffers[name]) return;
const src = audioCtx.createBufferSource(), gain = audioCtx.createGain();
src.buffer = buffers[name]; gain.gain.value = vol;
src.connect(gain); gain.connect(masterGain); src.start(0);
}
function playRandom(names, vol=1) { playSound(names[Math.floor(Math.random()*names.length)], vol); }
// Walkie-Talkie Funk
const pilotBP = audioCtx.createBiquadFilter();
pilotBP.type = 'bandpass'; pilotBP.frequency.value = 1800; pilotBP.Q.value = 3;
const pilotDist = audioCtx.createWaveShaper();
const dCurve = new Float32Array(256);
for (let i=0;i<256;i++){const x=(i/128)-1;dCurve[i]=Math.sign(x)*Math.pow(Math.abs(x),0.6);}
pilotDist.curve = dCurve;
const pilotGain = audioCtx.createGain(); pilotGain.gain.value = 2.8; // 80% von 3.5
pilotBP.connect(pilotDist); pilotDist.connect(pilotGain); pilotGain.connect(masterGain);
let pilotVoice = Math.random() < 0.5 ? 'echo' : 'alloy';
let lastRadio = '';
// ===========================================================
// SPEECH-PIPE (analog start.html, zentrale Pipe fuer Sprach-Funk)
// Regeln: seriell, 400ms Pause, TTL 10s, Prioritaeten low/normal/crash.
// ===========================================================
const SPEECH_GAP_MS = 400;
const SPEECH_TTL_MS = 10000;
let speechCurrent = null;
let speechFreeAt = 0;
const speechQueue = [];
function isInfoOpen() {
return document.querySelector('.overlay.show') !== null;
}
function isSpeechName(name) {
return name.startsWith('pilot_') || name.startsWith('warn_')
|| name.startsWith('strike') || name.startsWith('takeover')
|| name.startsWith('tower') || name.startsWith('traffic')
|| name.startsWith('land_')
|| name === 'success' || name === 'success2'
|| name === 'ouch1' || name === 'ouch2' || name === 'ouch3';
}
function priorityOf(name, opts) {
if (opts && opts.priority === 'crash') return 3;
if (opts && opts.priority === 'low') return 1;
if (opts && opts.priority === true) return 3; // legacy boolean
if (name.startsWith('strike') || name.startsWith('ouch')
|| name === 'takeover' || name.startsWith('tower_handover_')
|| name.startsWith('tower_strike_')) return 3;
if (name.startsWith('tower_lesson_')) return 1;
return 2;
}
function playRadio(name, opts) {
opts = opts || {};
if (isInfoOpen()) return;
const voiceName = 'r_'+name+'_'+pilotVoice;
const fallback = 'r_'+name;
if (!buffers[voiceName] && !buffers[fallback]) return;
if (!isSpeechName(name)) {
_playRadioBuffer(name, false);
return;
}
const prio = priorityOf(name, opts);
if (prio === 3) {
if (speechCurrent) { try { speechCurrent.src.stop(); } catch(_){} speechCurrent = null; }
speechQueue.length = 0;
speechFreeAt = 0;
}
const item = { name, prio, until: performance.now() + SPEECH_TTL_MS };
let inserted = false;
for (let i = 0; i < speechQueue.length; i++) {
if (speechQueue[i].prio < prio) { speechQueue.splice(i, 0, item); inserted = true; break; }
}
if (!inserted) speechQueue.push(item);
pumpSpeechQueue();
}
function pumpSpeechQueue() {
if (speechCurrent) return;
const now = performance.now();
if (now < speechFreeAt) {
setTimeout(pumpSpeechQueue, Math.max(40, speechFreeAt - now));
return;
}
while (speechQueue.length && speechQueue[0].until < now) speechQueue.shift();
if (!speechQueue.length) return;
const item = speechQueue.shift();
_playRadioBuffer(item.name, true);
}
function _playRadioBuffer(name, asSpeech) {
const voiceName = 'r_'+name+'_'+pilotVoice;
const fallback = 'r_'+name;
const buf = buffers[voiceName] || buffers[fallback];
if (!buf) { if (asSpeech) { speechCurrent = null; pumpSpeechQueue(); } return; }
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(pilotBP);
src.start(0);
if (asSpeech) {
speechCurrent = { src, name };
src.onended = () => {
if (speechCurrent && speechCurrent.src === src) speechCurrent = null;
speechFreeAt = performance.now() + SPEECH_GAP_MS;
pumpSpeechQueue();
};
}
}
function clearSpeechPipe() {
if (speechCurrent) { try { speechCurrent.src.stop(); } catch(_){} speechCurrent = null; }
speechQueue.length = 0;
speechFreeAt = 0;
}
function playRadioRandom(prefix, opts) {
const matches = radioFiles.filter(f=>f.startsWith(prefix) && f !== lastRadio);
if (!matches.length) return;
const pick = matches[Math.floor(Math.random()*matches.length)];
lastRadio = pick; playRadio(pick, opts);
}
// Spielt primary-Audio; falls Buffer nicht geladen (z.B. Sea-Variante für
// Helis, deren Audio noch nicht generiert wurde), fällt auf fallback zurück.
function playRadioOrFallback(primary, fallback, opts) {
const voiceName = 'r_'+primary+'_'+pilotVoice;
const fb = 'r_'+primary;
if (buffers[voiceName] || buffers[fb]) { playRadio(primary, opts); return; }
playRadio(fallback, opts);
}
// Rotor
let rotorSrc = null, rotorGn = null;
function startRotor() {
if (rotorSrc || !buffers.heliFly) return;
rotorSrc = audioCtx.createBufferSource(); rotorGn = audioCtx.createGain();
rotorSrc.buffer = buffers.heliFly; rotorSrc.loop = true;
rotorSrc.playbackRate.value = 1.0; rotorGn.gain.value = 0.32;
rotorSrc.connect(rotorGn); rotorGn.connect(masterGain); rotorSrc.start(0);
}
function stopRotor() { if(rotorSrc){try{rotorSrc.stop();}catch(e){} rotorSrc=null;} }
// Extra-Rotor für Zweit-Helis (secondHeli Polizei + bgHelis Presse/Polizei)
// Leiser, leicht andere Tonhöhe — klingt wie zweites Triebwerk im Hintergrund.
const extraRotors = []; // [{src, gn}]
function startExtraRotor(pitch, vol) {
if (!buffers.heliFly) return null;
const src = audioCtx.createBufferSource();
const gn = audioCtx.createGain();
src.buffer = buffers.heliFly; src.loop = true;
src.playbackRate.value = pitch;
gn.gain.value = vol;
src.connect(gn); gn.connect(masterGain); src.start(0);
const entry = { src, gn };
extraRotors.push(entry);
return entry;
}
function stopAllExtraRotors() {
for (const e of extraRotors) { try { e.src.stop(); } catch(_){} }
extraRotors.length = 0;
}
// Proximity-Vögel: Geraeusch nur wenn ein Schwarm nah am Heli ist.
// Regeln: (1) nie zwei gleichzeitig, (2) mindestens 15 s Abstand zwischen
// dem Ende des einen und dem Start des naechsten (15..20 s Cooldown).
let proxBirdSrc = null, proxBirdNextAt = 0;
function maybePlayProximityBird() {
if (!state || !state.birds || !state.heli) return;
if (proxBirdSrc) return; // Regel 1: laeuft schon einer
const now = audioCtx.currentTime;
if (now < proxBirdNextAt) return; // Regel 2: Cooldown
const h = state.heli;
let near = false;
for (const b of state.birds) {
const dx = b.x - h.x, dy = b.y - h.y;
if (dx*dx + dy*dy < 420*420) { near = true; break; }
}
if (!near) return;
const scen = window.HELI_SCEN || 'berg-fels';
const pool = scen === 'wasser' ? ['birds2','birds3']
: scen === 'autobahn' ? []
: ['birds1','birds2','birds3'];
if (!pool.length) return;
const name = pool[Math.floor(Math.random()*pool.length)];
const buf = buffers[name];
if (!buf) return;
const src = audioCtx.createBufferSource();
const gn = audioCtx.createGain();
src.buffer = buf;
src.playbackRate.value = 0.92 + Math.random()*0.16;
gn.gain.value = 0.26 + Math.random()*0.12;
src.connect(gn); gn.connect(masterGain);
proxBirdSrc = src;
src.onended = () => {
if (proxBirdSrc === src) proxBirdSrc = null;
// 15..20 s Stille nach dem Clip bevor der naechste starten darf
proxBirdNextAt = audioCtx.currentTime + 15 + Math.random()*5;
};
src.start(0);
}
function stopProximityBirds() {
if (proxBirdSrc) { try { proxBirdSrc.stop(); } catch(_){} proxBirdSrc = null; }
proxBirdNextAt = 0;
}
// Debug-Dump: komplette Momentaufnahme bei Spielende — hilft Bug-Ursachen
// zu rekonstruieren, wenn z.B. Strikes ohne sichtbare Berührung passieren.
function buildDebugDump(outcome, reason) {
const h = state.heli || {};
const p = state.patient || {};
const scen = window.HELI_SCEN || 'default';
return {
outcome: outcome,
reason: reason || '',
scen: scen,
timeSec: Math.round((state.t || 0) * 10) / 10,
phase: state.phase,
strikes: (state.strikeLog || []).map(function(s){
return { msg: s.msg, t: Math.round(s.t * 10)/10 };
}),
heli: {
x: Math.round(h.x), y: Math.round(h.y),
vx: Math.round(h.vx), vy: Math.round(h.vy),
fuel: Math.round((h.fuel||0) * 100),
strikesTotal: h.strikes
},
patient: { x: Math.round(p.x), y: Math.round(p.y), zone: p.zone, rescued: !!p.rescued },
wind: state.windBase * state.windDir,
level: state.selectedLevel,
birds: (state.birds||[]).length,
ships: (state.ships||[]).length,
gantries: state.highway ? state.highway.gantries.length : 0
};
}
// Silence-Hammer: stoppt Rotor, aktuelle Funk-Quelle und fadet den Master-Gain nach unten.
// Wird bei winGame/loseGame aufgerufen — nach dem Spiel ist Ruhe.
function silenceAll() {
stopRotor();
stopAllExtraRotors();
stopProximityBirds();
clearSpeechPipe();
try {
masterGain.gain.cancelScheduledValues(audioCtx.currentTime);
masterGain.gain.setValueAtTime(masterGain.gain.value, audioCtx.currentTime);
masterGain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.5);
} catch(e) {}
}
// ============================================================
// KONSTANTEN
// ============================================================
const C = {
sky1:'#eaf1ea', sky2:'#d9e6dc',
cloud:'#f5efe0', stormCloud:'#6b7d8f',
mountain:'#5b7a5a', mountainDark:'#3d5a3d', mountainSnow:'#f5efe0',
mountainShade:'#3d5a3d', mountainRock:'#7a8a72',
valleyGrass:'#7a9b76', valleyDark:'#5b7a5a',
cream:'#f5efe0', forest:'#2d5434', orange:'#c97a3f',
red:'#c85c4a', yellow:'#e4b95c', slate:'#3d5165',
sage:'#7a9b76', sageLight:'#a8c3a0',
glass:'#a8c3a0', rotor:'#1f2a24',
helipad:'#3d5165',
bird:'#2d5434',
treeTrunk:'#5b4a3a', treeDark:'#3d5a3d',
cable:'#3d5165', cableStation:'#8a3e31',
rope:'#5b4a3a', basket:'#c97a3f'
};
// Szenario-Paletten: überschreiben Natur-Farben je nach Mission-Typ.
// Aufruf: ?scen=berg-schnee|wasser|autobahn|schlucht|tal-dorf
(function applyScenarioPalette(){
const scen = new URLSearchParams(window.location.search).get('scen') || 'berg-fels';
window.HELI_SCEN = scen;
if (scen === 'berg-schnee') {
C.sky1='#e5eef5'; C.sky2='#cfd9e3';
C.mountain='#9aa3b0'; C.mountainDark='#707a88'; C.mountainSnow='#ffffff';
C.mountainShade='#707a88'; C.mountainRock='#8a93a0';
C.valleyGrass='#eaeff5'; C.valleyDark='#d0d8e0';
C.treeDark='#8aa3b5';
} else if (scen === 'wasser') {
C.sky1='#dfecf3'; C.sky2='#c5d7e2';
C.mountain='#6b8a95'; C.mountainDark='#4a6b75'; C.mountainSnow='#eaf1f4';
C.valleyGrass='#6fa0ba'; C.valleyDark='#4a7c8a';
C.treeDark='#4a7c8a';
} else if (scen === 'autobahn') {
C.sky1='#e8ebe5'; C.sky2='#d0d5ca';
C.mountain='#9a9a8a'; C.mountainDark='#6b6b5d'; C.mountainSnow='#d5d5c8';
C.valleyGrass='#8a8c78'; C.valleyDark='#5d5f50';
C.treeDark='#4a5040';
} else if (scen === 'schlucht') {
C.sky1='#d8dcd2'; C.sky2='#b8bead';
C.mountain='#5a5048'; C.mountainDark='#3a322b'; C.mountainSnow='#8a7a66';
C.mountainShade='#3a322b'; C.mountainRock='#6b5e54';
C.valleyGrass='#5b7a5a'; C.valleyDark='#3d5a3d';
} else if (scen === 'tal-dorf') {
C.sky1='#efe8d4'; C.sky2='#d8cfb8';
C.mountain='#7a8a72'; C.mountainDark='#5b6b5a'; C.mountainSnow='#e8e0cc';
C.valleyGrass='#a8a085'; C.valleyDark='#7a7050';
}
// 'berg-fels' = default (keine Änderung)
// Header + Pill + Briefing-Tag je Szenario umtexten
const L = (scen === 'autobahn')
? { title:'Straßenrettung', sub:'Co-Pilot auf der Autobahn · Direkte Landung im Sperrbereich, Patient bergen, sicher zur Klinik', pill:'Lektion · Autobahn', tag:'Straßenrettung · Briefing' }
: (scen === 'wasser')
? { title:'Seerettung', sub:'Co-Pilot auf dem Wasser · Flugretter abseilen, Patient bergen, sicher zur Uferklinik', pill:'Lektion · Wasser', tag:'Seerettung · Briefing' }
: (scen === 'tal-dorf')
? { title:'Ortseinsatz', sub:'Co-Pilot über bebautem Gebiet · Flugretter abseilen, Patient bergen, sicher zur Klinik', pill:'Lektion · Tal', tag:'Ortseinsatz · Briefing' }
: { title:'Bergrettung', sub:'Co-Pilot im Gebirgseinsatz · Flugretter abseilen, Patient bergen, sicher zurück ins Tal', pill:'Lektion · Bergrettung', tag:'Bergrettung · Briefing' };
window.HELI_SCEN_LABELS = L;
document.addEventListener('DOMContentLoaded', function(){
document.title = 'Helikopter-Simulator · ' + L.title;
const h1 = document.querySelector('header h1');
const sub = document.querySelector('header .sub');
const pill = document.getElementById('scenPill');
const tag = document.getElementById('cardTag');
if (h1) h1.textContent = '🚁 Helikopter-Simulator · ' + L.title;
if (sub) sub.textContent = L.sub;
if (pill) pill.textContent = L.pill;
if (tag) tag.textContent = L.tag;
});
})();
const HELI_VARIANTS = {
oamtc: {label:'ÖAMTC', body:'#e4b95c', belly:'#c97a3f', accent:'#c85c4a', name:'Christophorus 8'},
ara: {label:'ARA', body:'#c85c4a', belly:'#a84a3b', accent:'#f5efe0', name:'RK-1'},
polizei: {label:'Polizei',body:'#3d5165',belly:'#2a3a4a', accent:'#e4b95c', name:'Libelle'},
bundesheer:{label:'Bundesheer',body:'#5b7a5a',belly:'#3d5a3d',accent:'#e4b95c',name:'Alouette'},
presse: {label:'Presse',body:'#7a6a5a',belly:'#5a4c3f', accent:'#f5efe0', name:'News-Heli'}
};
const LEVEL_PRESETS = {
1: {
name: 'Einsteiger',
desc: 'Sanfte Physik, große Toleranz, ruhige Berge.',
// Physics
gravity: 200, flapImpulse: 160, vyDamp: 0.985, vxDamp: 0.985,
// Wind
windBase: 8, windVariation: 0.10, windFieldStrength: 0.0,
// Hover
hoverRadius: 75, hoverTime: 2.5,
pendulumStrength: 0,
cables: 0, clouds: 1, passingClouds: 0, secondHeli: false, birds: 1,
rescuerWorkTime: 22,
mountainRoughness: 'gentle',
gustChance: 0
},
2: {
name: 'Fortgeschritten',
desc: 'Lokale Aufwinde, eine Seilbahn, leichtes Pendel, Wolken.',
gravity: 240, flapImpulse: 180, vyDamp: 0.99, vxDamp: 0.99,
windBase: 14, windVariation: 0.35, windFieldStrength: 0.5,
hoverRadius: 50, hoverTime: 3.5,
pendulumStrength: 0.4,
cables: 1, clouds: 3, passingClouds: 1, secondHeli: false, birds: 1,
rescuerWorkTime: 32,
mountainRoughness: 'medium',
gustChance: 0.25
},
3: {
name: 'Profi',
desc: 'Volle Turbulenz, Seilbahnen, zweiter Heli, Wolken — aber mit Platz zum Manövrieren.',
gravity: 270, flapImpulse: 195, vyDamp: 0.992, vxDamp: 0.99,
windBase: 20, windVariation: 0.7, windFieldStrength: 0.85,
hoverRadius: 38, hoverTime: 4.0,
pendulumStrength: 0.8,
cables: 2, clouds: 4, passingClouds: 2, secondHeli: true, birds: 2,
rescuerWorkTime: 40,
mountainRoughness: 'medium', // not 'rugged' — keep room to fly
gustChance: 0.5
}
};
// World layout
let WORLD_W = 5000;
let GROUND_Y = 700;
let EXIT_X = 4800; // safe zone (clinic) on the right
const SKY_TOP = 0;
// ============================================================
// STATE
// ============================================================
const state = {
t: 0, mode: 'ready', // ready | flying | autopilot | won | lost
camX: 0, shake: 0,
selectedHeli: 'oamtc',
selectedLevel: 1,
level: LEVEL_PRESETS[1],
heli: {
x: 200, y: GROUND_Y - 18, vx: 0, vy: 0,
tilt: 0, targetTilt: 0,
rotor: 0, rotorSpeed: 0, engineOn: false,
onGround: true, fuel: 1, locked: true,
strikes: 0, invul: 0, flash: 0, facing: 1
},
keys: {}, prevKeys: {},
// World contents
mountains: null, // {peaks:[], heightFn:(x)=>y}
patient: null, // {x, y, rescued:false}
rescuer: null, // {x, y, state:'inHeli'|'descending'|'working'|'ready'|'ascending'}
cables: [], // [{x1,y1,x2,y2, st1, st2}]
clouds: [], // static decorative
passingClouds: [], // moving cloud that obscures
birds: [], // [{x, y, vx, phase}]
secondHeli: null, // {x, y, dir, ...}
bgHelis: [], // Hintergrund-Helis (Autobahn: Presse/Polizei, keine Kollision)
// Wind & weather
windBase: 12, // negative = from right (east wind)
windDir: -1, // -1: from right, +1: from left
gust: 0, gustTimer: 0,
// Phase state machine
phase: 'idle', // idle|approach1|hover_drop|rescuer_working|approach2|hover_pickup|extract|won
phaseProgress: 0, // 0..1 for hover phases
rescuerTimer: 0, // counts up during 'rescuer_working'
// Winch / load
winch: { extending: false, length: 0, maxLength: 0, target: null },
load: { // pendulum at end of winch
angle: 0, angVel: 0,
hasRescuer: false, hasPatient: false
},
// Radio
radio: { messages: [], current: null, nextAt: 4 + Math.random()*3, shown: 0 },
// Misc
autopilotT: 0,
helipadEnd: { x: EXIT_X - 80, y: 0, w: 160 }, // y set after mountains generated
intermediatePad: null,
intermediateRequired: false,
disembark: null
};
// ============================================================
// HELPER
// ============================================================
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
function lerp(a, b, t){ return a + (b - a) * t; }
// Smoothed pseudo-noise (deterministic per session ok)
function noise1(x){
const i = Math.floor(x), f = x - i;
const a = Math.sin(i * 12.9898) * 43758.5453;
const b = Math.sin((i+1) * 12.9898) * 43758.5453;
const va = (a - Math.floor(a));
const vb = (b - Math.floor(b));
const u = f*f*(3-2*f);
return va*(1-u) + vb*u;
}
// ============================================================
// BERGGENERIERUNG
// ============================================================
function genMountains(roughness, patientZone){
const peaks = [];
let cfg;
if(roughness === 'gentle'){
cfg = { count: 2, hMin: 240, hMax: 340, wMin: 800, wMax: 1100, jitter: 30 };
} else if(roughness === 'medium'){
cfg = { count: 3, hMin: 300, hMax: 420, wMin: 600, wMax: 850, jitter: 50 };
} else { // rugged
cfg = { count: 3, hMin: 360, hMax: 480, wMin: 500, wMax: 750, jitter: 70 };
}
const xStart = WORLD_W * 0.18;
const xEnd = WORLD_W * 0.82;
const span = xEnd - xStart;
for(let i=0;i<cfg.count;i++){
const x = xStart + span * (i + 0.5) / cfg.count + (Math.random()*2-1) * cfg.jitter;
const h = cfg.hMin + Math.random() * (cfg.hMax - cfg.hMin);
const w = cfg.wMin + Math.random() * (cfg.wMax - cfg.wMin);
peaks.push({ x, h, w });
}
// Garantierter Berg in der Patientenzone (sonst landet der Patient ggf. neben den Bergen)
if(patientZone === 'west'){
peaks.push({
x: WORLD_W * (0.10 + Math.random() * 0.08), // 0.10-0.18
h: 240 + Math.random() * 100, // 240-340
w: 600 + Math.random() * 200
});
} else {
peaks.push({
x: WORLD_W * (0.82 + Math.random() * 0.08), // 0.82-0.90
h: 240 + Math.random() * 100,
w: 600 + Math.random() * 200
});
}
const heightFn = (x) => {
let h = 0;
for(const p of peaks){
const d = Math.abs(x - p.x);
if(d < p.w){
const c = Math.cos(d / p.w * Math.PI / 2);
const contrib = p.h * c * c;
if(contrib > h) h = contrib;
}
}
h += 30 + noise1(x * 0.003) * 25;
h += noise1(x * 0.02) * (roughness === 'rugged' ? 12 : roughness === 'medium' ? 8 : 4);
return h;
};
return { peaks, heightFn, cfg };
}
function findPatientSpot(mountains, zone){
// Find a spot in the chosen zone (west or east)
let xMin, xMax;
if(zone === 'west'){
xMin = WORLD_W * 0.06; xMax = WORLD_W * 0.22;
} else {
xMin = WORLD_W * 0.78; xMax = WORLD_W * 0.94;
}
const candidates = [];
for(let x = xMin; x < xMax; x += 18){
const h = mountains.heightFn(x);
if(h < 160 || h > 380) continue;
const slope = (mountains.heightFn(x+8) - mountains.heightFn(x-8)) / 16;
// Clear airspace within 250 px
let clear = true;
for(let dx = -250; dx <= 250; dx += 25){
if(dx === 0) continue;
if(mountains.heightFn(x + dx) > h + 60){ clear = false; break; }
}
if(!clear) continue;
const score = (1 - Math.abs(slope) * 0.4);
candidates.push({ x, h, slope, score });
}
if(!candidates.length){
// Fallback: nimm den höchsten Punkt der Zone und setze Patient auf das tatsächliche Terrain
let best = { x: (xMin + xMax) / 2, h: 0 };
for(let x = xMin; x < xMax; x += 20){
const h = mountains.heightFn(x);
if(h > best.h) best = { x, h };
}
// Patient liegt auf dem Boden, egal wie hoch der Hügel ist
return { x: best.x, y: GROUND_Y - best.h, slope: 0, zone };
}
candidates.sort((a,b) => b.score - a.score);
const top = candidates.slice(0, Math.max(5, Math.floor(candidates.length * 0.25)));
const c = top[Math.floor(Math.random() * top.length)];
return { x: c.x, y: GROUND_Y - c.h, slope: c.slope, zone };
}
function findHeliSpawn(mountains){
// Find lowest terrain in the middle of the world for a safe spawn (Tal)
let bestX = WORLD_W * 0.5, bestH = Infinity;
for(let x = WORLD_W * 0.40; x < WORLD_W * 0.60; x += 30){
const h = mountains.heightFn(x);
if(h < bestH){ bestH = h; bestX = x; }
}
// Cruise altitude: between y=100 and y=180, but at least 150 px above local terrain
const terrainTopY = GROUND_Y - bestH;
const heliY = Math.max(100, Math.min(180, terrainTopY - 150));
return { x: bestX, y: heliY };
}
function distPointToSegment(px, py, x1, y1, x2, y2){
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx*dx + dy*dy;
if(len2 < 1) return Math.sqrt((px-x1)*(px-x1) + (py-y1)*(py-y1));
let t = ((px - x1)*dx + (py - y1)*dy) / len2;
t = clamp(t, 0, 1);
const cx = x1 + t*dx, cy = y1 + t*dy;
return Math.sqrt((px-cx)*(px-cx) + (py-cy)*(py-cy));
}
function genCables(mountains, count){
if(count <= 0) return [];
const cables = [];
const safetyR = 420;
const px = state.patient.x;
const py = state.patient.y;
const hoverY = py - 90;
const spawnX = WORLD_W * 0.5;
for(let i=0;i<count;i++){
let cable = null;
for(let attempt=0; attempt<50; attempt++){
// Random horizontal center within the flight corridor
const cableCenter = 700 + Math.random() * (WORLD_W - 1400);
// Avoid patient zone
if(Math.abs(cableCenter - px) < safetyR + 250) continue;
// Avoid clinic zone
if(cableCenter > EXIT_X - 350) continue;
// Avoid heli spawn (so player has clear takeoff area)
if(Math.abs(cableCenter - spawnX) < 350) continue;
// Cable span 400-650 px
const halfLen = 200 + Math.random() * 125;
const x1 = cableCenter - halfLen;
const x2 = cableCenter + halfLen;
if(Math.min(x1, x2) < 350 || Math.max(x1, x2) > WORLD_W - 280) continue;
// Endpoints far enough from patient zone
if(Math.abs(x1 - px) < safetyR || Math.abs(x2 - px) < safetyR) continue;
const y1 = GROUND_Y - mountains.heightFn(x1) - 20;
const y2 = GROUND_Y - mountains.heightFn(x2) - 20;
// Segment must be far enough from patient AND hover target
const dPat = distPointToSegment(px, py, x1, y1, x2, y2);
const dHov = distPointToSegment(px, hoverY, x1, y1, x2, y2);
if(dPat < safetyR || dHov < safetyR) continue;
// No overlap with existing cables
let overlap = false;
for(const ec of cables){
if(distPointToSegment(cableCenter, (y1+y2)/2, ec.x1, ec.y1, ec.x2, ec.y2) < 280){
overlap = true; break;
}
}
if(overlap) continue;
cable = {
x1, y1, x2, y2,
st1: { x: x1, y: y1, w: 28, h: 36 },
st2: { x: x2, y: y2, w: 28, h: 36 }
};
break;
}
if(cable) cables.push(cable);
}
return cables;
}
function genBirds(count){
const out = [];
// Vögel werden überall im Einsatzgebiet gespawnt, auch rund um den Patient:
// Vogelschlag im Anflug ist Teil des Realismus (bes. Wasserrettung: Möwen).
for(let i=0;i<count;i++){
const x = 200 + Math.random() * (WORLD_W - 400);
const y = 100 + Math.random() * 320;
out.push({
x, y,
vx: (Math.random() < 0.5 ? -1 : 1) * (25 + Math.random() * 18),
members: [
{ dx: -16, dy: -3, phase: 0 },
{ dx: 0, dy: 0, phase: 1 },
{ dx: 16, dy: -3, phase: 2 }
]
});
}
return out;
}
function genClouds(count){
const out = [];
for(let i=0;i<count;i++){
out.push({
x: Math.random() * WORLD_W,
y: 40 + Math.random() * 120,
s: 0.5 + Math.random() * 0.7,
v: 2 + Math.random() * 4
});
}
return out;
}
function genPassingClouds(count){
// Larger clouds that drift across slowly and obscure visibility when heli is inside
const out = [];
for(let i=0;i<count;i++){
out.push({
x: -300 - Math.random() * 800,
y: 120 + Math.random() * 200,
s: 1.6 + Math.random() * 0.6,
v: 12 + Math.random() * 8,
density: 0.6 + Math.random() * 0.2
});
}
return out;
}
function genBgHelisHighway(){
// Zwei Hintergrund-Helis (Presse + Polizei) patrouillieren hoch über
// der Autobahn, keine Kollision, nur Deko mit leichter Rotor-Animation.
const out = [];
const variants = ['presse', 'polizei'];
for (let i = 0; i < 2; i++) {
const side = i === 0 ? 0.15 : 0.75; // links/rechts der Welt
out.push({
x: WORLD_W * side + (Math.random()-0.5)*200,
y: 120 + Math.random()*60, // hoch am Himmel
vx: (i === 0 ? 28 : -26) + (Math.random()-0.5)*6,
vy: 0,
dir: i === 0 ? 1 : -1,
facing: i === 0 ? 1 : -1,
tilt: 0,
rotor: Math.random() * 6.28,
rotorSpeed: 100,
variant: variants[i],
patrolMin: WORLD_W * (side - 0.12),
patrolMax: WORLD_W * (side + 0.12),
alpha: 0.75 // leicht transparent, Hintergrund-Feeling
});
}
return out;
}
function genSecondHeli(){
// Patrol on the side opposite or far from patient
const px = state.patient ? state.patient.x : WORLD_W * 0.5;
const safety = 500;
let pMin, pMax;
if(px < WORLD_W * 0.5){
// patient on left → patrol on right
pMin = Math.max(px + safety, WORLD_W * 0.55);
pMax = WORLD_W * 0.85;
} else {
// patient on right → patrol on left
pMin = WORLD_W * 0.12;
pMax = Math.min(px - safety, WORLD_W * 0.45);
}
if(pMax - pMin < 200){ pMin = WORLD_W * 0.12; pMax = WORLD_W * 0.32; }
return {
x: (pMin + pMax) / 2, y: 200,
vx: 35, vy: 0, dir: 1,
patrolMin: pMin, patrolMax: pMax,
rotor: 0, rotorSpeed: 100,
tilt: 0, facing: 1,
invul: 0, flash: 0
};
}
// ============================================================
// SZENARIO-SPEZIFISCHE GENERATOREN
// ============================================================
function genShips(patientX){
// Kleine Boote treiben auf der Wasserlinie, ein paar hohe Segelschiffe
// mit großem Mast fungieren zusätzlich als Flughindernisse.
const out = [];
const n = 3 + Math.floor(Math.random() * 3);
for (let i = 0; i < n; i++) {
let x;
for (let t = 0; t < 20; t++) {
x = 300 + Math.random() * (WORLD_W - 600);
if (Math.abs(x - patientX) > 400) break;
}
out.push({
x,
y: GROUND_Y - 2, // Rumpf sitzt direkt auf der Wasserlinie
vx: (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 6),
w: 60 + Math.random() * 40,
kind: Math.random() < 0.35 ? 'sail' : 'motor',
mastH: 30 + Math.random() * 12
});
}
// 23 große Segelschiffe mit hohem Mast (echtes Hindernis)
const tall = 2 + Math.floor(Math.random() * 2);
for (let i = 0; i < tall; i++) {
let x;
for (let t = 0; t < 20; t++) {
x = 400 + Math.random() * (WORLD_W - 800);
if (Math.abs(x - patientX) > 550) break;
}
out.push({
x,
y: GROUND_Y - 2,
vx: (Math.random() < 0.5 ? -1 : 1) * (5 + Math.random() * 4),
w: 95 + Math.random() * 30,
kind: 'tall-sail',
mastH: 115 + Math.random() * 25 // Mast ragt deutlich über den Heli-Flugbereich
});
}
return out;
}
function genHighway(patient){
// Autobahn-Layout: Sperrzone zwischen zwei Polizeisperren, 3 Schilderbrücken,
// Landezone in einem Segment zwischen Gantries, Unfallstelle 120 px versetzt
const sperrLeft = 900;
const sperrRight = 4100;
const gantries = [
{ x: 1500, w: 36, topY: GROUND_Y - 170, botY: GROUND_Y - 80 },
{ x: 2400, w: 36, topY: GROUND_Y - 170, botY: GROUND_Y - 80 },
{ x: 3300, w: 36, topY: GROUND_Y - 170, botY: GROUND_Y - 80 }
];
// Landezone zufällig in einem der 4 Segmente (links von 1. Gantry bis rechts von letzter)
const segmentStarts = [sperrLeft, 1600, 2500, 3400];
const segmentEnds = [1460, 2360, 3260, sperrRight];
const segIdx = Math.floor(Math.random() * segmentStarts.length);
const segStart = segmentStarts[segIdx], segEnd = segmentEnds[segIdx];
const zoneW = 220;
const zoneX = segStart + 40 + Math.random() * (segEnd - segStart - zoneW - 80);
// Unfallstelle 100160 px vor oder hinter der Landezone — aber im selben Segment
let crashX = zoneX + zoneW/2 + (Math.random() < 0.5 ? -1 : 1) * (110 + Math.random()*40);
crashX = Math.max(segStart + 60, Math.min(segEnd - 60, crashX));
// Gaffer kommen von beiden Sperren Richtung Unfall — mehrere, mit
// unterschiedlichen Tempi und kleinen Pausen (random pause-Phase).
const gawkers = [];
const leftCount = 3 + Math.floor(Math.random()*2); // 3..4
const rightCount = 3 + Math.floor(Math.random()*2);
for (let i = 0; i < leftCount; i++) {
gawkers.push({
x: sperrLeft + 20 + i*22 + Math.random()*8,
vx: 8 + Math.random()*6,
targetX: crashX - 40 - Math.random()*40,
pauseT: 0,
nextPause: 3 + Math.random()*4
});
}
for (let i = 0; i < rightCount; i++) {
gawkers.push({
x: sperrRight - 20 - i*22 - Math.random()*8,
vx: -(8 + Math.random()*6),
targetX: crashX + 40 + Math.random()*40,
pauseT: 0,
nextPause: 3 + Math.random()*4
});
}
// Fahrzeuge
const vehicles = [];
for (let i = 0; i < 3; i++) vehicles.push({ x: sperrLeft - 80 - i*70, y: GROUND_Y - 22, w: 56, color: ['#c85c4a','#3d5165','#7a8a72'][i] });
for (let i = 0; i < 3; i++) vehicles.push({ x: sperrRight + 30 + i*70, y: GROUND_Y - 22, w: 56, color: ['#e4b95c','#5b7a5a','#c97a3f'][i] });
vehicles.push({ x: crashX - 35, y: GROUND_Y - 20, w: 60, color: '#c85c4a', smashed: true });
vehicles.push({ x: crashX + 25, y: GROUND_Y - 20, w: 56, color: '#e4b95c', smashed: true });
// Einsatzwagen — Polizei oder Rettung — kommt aus einer Sperre, faehrt
// langsam zur Unfallstelle (durch die Sperrzone), bleibt kurz stehen,
// dann wieder zurueck. Animation laeuft im update-Tick.
const fromLeft = Math.random() < 0.5;
const emergencyVehicle = {
kind: Math.random() < 0.5 ? 'polizei' : 'rettung',
x: fromLeft ? sperrLeft + 30 : sperrRight - 30,
vx: fromLeft ? 28 : -28,
targetX: crashX + (fromLeft ? -40 : 40),
y: GROUND_Y - 22,
w: 64,
state: 'driving', // 'driving' | 'parked' | 'leaving'
stateT: 0,
blink: 0
};
return {
sperrLeft, sperrRight,
gantries,
landingZone: { x: zoneX, y: GROUND_Y - 8, w: zoneW, h: 8 },
crashSite: { x: crashX, y: GROUND_Y },
gawkers, vehicles, emergencyVehicle,
touchdown: { progress: 0, complete: false }
};
}
// ============================================================
// SZENE BAUEN
// ============================================================
function buildScene(){
// Szenario-Flags (aus URL-Param scen=…) — steuern Sonderlogik (Wasser treibt Patient, Downwash, Möwen, Autobahn-Landung)
state.isWater = (window.HELI_SCEN === 'wasser');
state.isHighway = (window.HELI_SCEN === 'autobahn');
const flat = state.isWater || state.isHighway;
// Zone zuerst entscheiden, damit Mountains einen garantierten Berg darin haben
const zone = Math.random() < 0.5 ? 'west' : 'east';
state.mountains = genMountains(state.level.mountainRoughness, zone);
if (flat) {
// Bei Wasser / Autobahn: Terrain komplett flach — Bergkuppen ergeben keinen Sinn
state.mountains.heightFn = () => 0;
state.mountains.peaks = [];
}
if (flat) {
// Patient weit genug vom Startpunkt entfernt platzieren (Heli spawnt
// bei x≈400), damit ein echter Flug zum Einsatzort nötig ist.
const px = WORLD_W * (0.5 + Math.random() * 0.25);
state.patient = { x: px, y: GROUND_Y - 4, zone: zone, rescued: false };
} else {
state.patient = { ...findPatientSpot(state.mountains, zone), rescued: false };
}
// Wasserrettung: Patient treibt langsam mit der Strömung nach Osten/Westen
if (state.isWater) {
state.patient.drift = (Math.random() < 0.5 ? -1 : 1) * (6 + Math.random() * 4);
// Schiffe auf dem See — treibend, mit Sicherheitsabstand zum Patient
state.ships = genShips(state.patient.x);
} else {
state.patient.drift = 0;
state.ships = [];
}
// Autobahn-Geometrie: Sperrzone, Schilderbrücken, Landezone, Gaffer, Fahrzeuge
if (state.isHighway) {
state.highway = genHighway(state.patient);
// Patient auf Unfallstelle verankern
state.patient.x = state.highway.crashSite.x;
state.patient.y = GROUND_Y - 4;
}
state.cables = state.isWater || state.isHighway ? [] : genCables(state.mountains, state.level.cables);
// Seerettung: Möwen-Schwärme als Hauptgefahr — doppelte Dichte gewünscht
// Mehr sichtbare Voegel — Audio wird proximity-basiert getriggert,
// also stoert die hoehere Anzahl das Hoeren nicht.
const birdCount = state.isWater ? (state.level.birds * 3 + 6)
: state.isHighway ? Math.max(1, state.level.birds)
: (state.level.birds * 2 + 3);
state.birds = genBirds(birdCount);
state.clouds = genClouds(state.level.clouds + 4);
state.passingClouds = genPassingClouds(state.level.passingClouds);
state.secondHeli = state.level.secondHeli ? genSecondHeli() : null;
// Autobahn: zusätzlicher Himmelsverkehr (Presse + Polizei) im Hintergrund
state.bgHelis = state.isHighway ? genBgHelisHighway() : [];
state.windBase = state.level.windBase;
// Wind direction comes FROM either west (-1, blows east, base wind positive) or east (+1, blows west, base wind negative)
// Following meteo convention: state.windDir = -1 means wind FROM east TO west (heli pushed west)
state.windDir = Math.random() < 0.5 ? -1 : 1;
state.gust = 0; state.gustTimer = 2 + Math.random() * 3;
// Place clinic (always at far east end on flat ground)
const endX = EXIT_X - 60;
// Bei Wasser/Autobahn: Klinik als richtiges Gebäude, Helipad auf dem Dach (80 px hoch)
const clinicY = (state.isWater || state.isHighway) ? GROUND_Y - 80 : GROUND_Y - state.mountains.heightFn(endX) - 4;
// Dachbreite großzügig bei flat-Szenen, damit Anflug nicht frustrierend wird
const clinicW = (state.isWater || state.isHighway) ? 240 : 160;
state.helipadEnd = { x: endX - clinicW/2, y: clinicY, w: clinicW };
// Autobahn: Asphalt endet vor der Klinik — sperrRight ist Ende der Sperrzone,
// das Asphaltband zeichnet bis asphaltEnd, dann Grasland bis zur Klinik
if (state.isHighway) {
state._asphaltEndX = state.highway ? state.highway.sperrRight + 180 : endX - 400;
}
// Intermediate landing for Level 2 (50% chance)
state.intermediatePad = null;
state.intermediateRequired = false;
if(state.selectedLevel === 2 && Math.random() < 0.5){
state.intermediateRequired = true;
// 50% chance: at clinic, 50% chance: random valley spot
if(Math.random() < 0.5){
state.intermediatePad = { ...state.helipadEnd, atClinic: true };
} else {
// Find a random flat valley spot, away from patient and clinic
const px = state.patient.x;
let candidates = [];
for(let x = 400; x < WORLD_W - 400; x += 80){
// Skip patient zone and clinic zone
if(Math.abs(x - px) < 500) continue;
if(x > endX - 200) continue;
// Check terrain low/flat
const h0 = state.mountains.heightFn(x);
const slope = (state.mountains.heightFn(x+30) - state.mountains.heightFn(x-30)) / 60;
if(h0 < 90 && Math.abs(slope) < 0.3) candidates.push({ x, h: h0 });
}
if(candidates.length){
const c = candidates[Math.floor(Math.random() * candidates.length)];
state.intermediatePad = {
x: c.x - 70, y: GROUND_Y - c.h - 4, w: 140, atClinic: false
};
} else {
// No valid spot — disable intermediate
state.intermediateRequired = false;
}
}
}
// Reset heli — starts mid-air, hovering, locked
const h = state.heli;
const spawn = findHeliSpawn(state.mountains);
h.x = spawn.x;
h.y = spawn.y;
h.vx = 0; h.vy = 0; h.tilt = 0; h.targetTilt = 0;
h.rotor = 0; h.rotorSpeed = 100; h.engineOn = true; // already running (just arrived)
h.onGround = false; h.fuel = 1; h.locked = true;
h.strikes = 0; h.invul = 0; h.flash = 0;
// Heli faces toward patient initially (so user knows direction)
h.facing = (state.patient.zone === 'east') ? 1 : -1;
h.fuelEmergency = false;
h.flapTimer = 0;
h.controlT = 0;
h.landed = null;
// Disembark animation state (after final landing)
state.disembark = null;
// Reset phase
state.phase = 'idle';
state.phaseProgress = 0;
state.rescuerTimer = 0;
state.winch = { extending: false, length: 0, maxLength: 0, target: null };
state.load = { angle: 0, angVel: 0, hasRescuer: false, hasPatient: false };
state.rescuer = { x: 0, y: 0, state: 'inHeli' };
state.t = 0; state.shake = 0;
// Camera centers on heli at start
state.camX = clamp(h.x - W / 2, 0, WORLD_W - W);
// Build radio briefing
const dir = state.patient.zone === 'east' ? 'Osten' : 'Westen';
const windFromDir = state.windDir < 0 ? 'Osten' : 'Westen';
const windKn = Math.round(state.windBase * 0.7);
state.radio.messages = [
'Pilot: Halte sie noch ruhig — gleich übernimmst du.',
`Pilot: Wir kreisen, warten auf Anweisung.`,
'Tower: Schöne Linie, Co-Pilot. Konzentriert bleiben.'
];
// Phase-specific messages added on demand
state.radio.briefing = `Tower: Verletzter im ${dir}. Wind aus ${windFromDir}, ${windKn} Knoten.${state.intermediateRequired ? ' Zwischenlandung möglich.' : ''}`;
state.radio.current = null;
state.radio.nextAt = 1.5; // first generic message after a short delay
state.radio.shown = 0;
state.radio.briefingShown = false;
state.mode = 'flying';
if (audioCtx.state === 'suspended') audioCtx.resume();
startRotor();
// (Proximity-Vogelrufe werden im update()-Tick geprüft, keine Ambient-Schleife mehr)
// Zusätzliche Rotoren für sichtbare Fremdhelis
if (state.secondHeli) startExtraRotor(1.08, 0.14);
if (state.bgHelis && state.bgHelis.length) {
for (let i = 0; i < state.bgHelis.length; i++) {
startExtraRotor(0.92 + i*0.12, 0.10);
}
}
setTimeout(function(){ playRadioRandom('tower'); }, 500);
setMission();
setPhase('approach1');
}
function setMission(){
const v = HELI_VARIANTS[state.selectedHeli];
const mb = document.getElementById('missionBox');
let msg = '';
const dir = state.patient ? (state.patient.zone === 'east' ? 'Osten' : 'Westen') : '';
switch(state.phase){
case 'approach1':
msg = state.heli.locked
? `<b>Briefing:</b> Drücke <kbd>↑</kbd> oder ←/→, um die Steuerung zu übernehmen.`
: `<b>Phase 1:</b> Patient liegt im <b>${dir}</b>. Schwebeflug halten — ${state.level.hoverTime.toFixed(0)} s im Zielkreis.`;
break;
case 'hover_drop':
msg = `<b>Schwebeflug halten!</b> Bleib im Kreis, der Flugretter wird abgeseilt.`;
break;
case 'rescuer_working':
if(state.intermediateRequired && state.intermediatePad){
const ip = state.intermediatePad;
const ipDir = ip.x < state.heli.x ? 'Westen' : 'Osten';
const where = ip.atClinic ? 'an der <b>Klinik</b>' : `beim <b>Rauchsignal im ${ipDir}</b>`;
msg = `<b>Phase 2:</b> Zwischenlanden ${where}. Wartezeit am Boden.`;
} else {
msg = `<b>Phase 2:</b> Warteschleife fliegen — nicht stehenbleiben (Treibstoff!).`;
}
break;
case 'approach2':
msg = `<b>Phase 3:</b> Zurück zum Patienten. Erneut Schwebeflug halten.`;
break;
case 'hover_pickup':
msg = `<b>Aufnahme!</b> Ruhig halten, Last wird hochgezogen.`;
break;
case 'extract':
msg = `<b>Ausflug:</b> Sanft auf das <b>Klinik-Helipad</b> landen — keine harte Landung!`;
break;
default:
msg = '<b>Ziel:</b> Wähle Schwierigkeit & Helikopter, dann starte den Einsatz.';
}
mb.innerHTML = msg;
document.getElementById('vHeliId').textContent = `${v.label} · ${v.name}`;
}
function setPhase(newPhase){
state.phase = newPhase;
state.phaseProgress = 0;
const pb = document.getElementById('phaseBox');
const phNum = document.getElementById('phNum');
const phTitle = document.getElementById('phTitle');
const hw = state.isHighway;
const map = hw ? {
approach1: ['Phase 1 / 3', 'Landeanflug zur Sperrzone'],
hover_drop: ['Phase 2 / 3', 'Am Boden — Sanitäter bringen Patient'],
extract: ['Phase 3 / 3', 'Ausflug zur Klinik'],
} : {
approach1: ['Phase 1 / 3', 'Anflug zum Patienten'],
hover_drop: ['Phase 1 / 3', 'Schwebeflug · Retter abseilen'],
rescuer_working: ['Phase 2 / 3', 'Warteschleife — Retter versorgt'],
approach2: ['Phase 3 / 3', 'Anflug zur Aufnahme'],
hover_pickup: ['Phase 3 / 3', 'Schwebeflug · Last aufnehmen'],
extract: ['Phase 3 / 3', 'Ausflug zur Klinik'],
};
if(map[newPhase]){
pb.style.display = 'block';
phNum.textContent = map[newPhase][0];
phTitle.textContent = map[newPhase][1];
pb.classList.toggle('urgent', newPhase === 'hover_drop' || newPhase === 'hover_pickup');
} else {
pb.style.display = 'none';
}
setMission();
// Pro Phase lehrreicher Funkspruch (Tower-Frau + Pilot, versetzt).
// Himmelsrichtung des Patienten dynamisch einsetzen.
const dir = state.patient && state.patient.zone === 'east' ? 'Osten' : 'Westen';
const scen = window.HELI_SCEN || 'berg-fels';
const hwScene = scen === 'autobahn';
const waterScene = scen === 'wasser';
const schluchtScene = scen === 'schlucht';
// Heli-Name + Key aus URL-Param (vom Wrapper gesetzt)
const urlP = new URLSearchParams(location.search);
const heliName = urlP.get('heliname') || 'Crew';
const heliKey = urlP.get('helikey') || 'c1';
if (newPhase === 'approach1') {
// Zielanflug-Funkprotokoll:
// 1. Pilot ruft Tower (Standard: Adressat, Rufzeichen, Anliegen)
// 2. Tower bestaetigt + Geografie-Lernhinweis (mission-spezifisch)
// 3. Fallback: bestehende approach/hw_approach/sea_approach Audios
const missionKey = urlP.get('missionid') || (urlP.get('mission') || '').match(/^m\d+/)?.[0] || '';
const fallbackApproach = 'tower_approach_' + heliKey;
const approachAudio = hwScene ? 'tower_hw_approach_' + heliKey
: waterScene ? 'tower_sea_approach_' + heliKey
: fallbackApproach;
// Pilot-Call zuerst (falls neues Audio vorhanden, sonst nur Text)
playRadioOrFallback('pilot_call_' + heliKey, 'pilot_call_c1');
// Text-Blase mit korrektem Towerort als Variable
var towerCity = ({c1:'Innsbruck',c2:'Wien',c3:'Wien',c4:'Innsbruck',c5:'Innsbruck',
c6:'Salzburg',c7:'Klagenfurt',c8:'Bregenz',c9:'Wien',c10:'Linz',
c11:'Klagenfurt',c12:'Graz',c14:'Graz',c16:'Wien',ara:'Bregenz'})[heliKey] || 'Tower';
pushRadio('Pilot: ' + towerCity + ' Tower, ' + heliName + ', Einsatzort erreicht, im Endanflug.');
// Tower-Antwort: Geo-Lernstoff wenn verfuegbar, sonst szenario-spezifische Fallback-Ansage
setTimeout(function(){
if (missionKey) {
playRadioOrFallback('tower_geo_' + missionKey, approachAudio);
} else {
playRadioOrFallback(approachAudio, fallbackApproach);
}
if (hwScene) {
pushRadio('Tower: ' + heliName + ', ' + towerCity + ' Tower, Roger. Sperrzone auf der Autobahn ist aktiv, Landezone markiert, Fahrbahn frei. Strecke gehört dir.');
} else if (waterScene) {
pushRadio('Tower: ' + heliName + ', ' + towerCity + ' Tower, Roger. Patient liegt im ' + dir + ', Wasserlage im Blick. Bergung freigegeben, wir sind bei dir.');
} else if (schluchtScene) {
pushRadio('Tower: ' + heliName + ', ' + towerCity + ' Tower, Roger. Patient im ' + dir + ', enge Schlucht — Düseneffekt zwischen den Wänden. Windseite halten, Bergung freigegeben.');
} else {
pushRadio('Tower: ' + heliName + ', ' + towerCity + ' Tower, Roger. Patient liegt im ' + dir + ', gut sechzig Meter unter euch. Bergung freigegeben, viel Erfolg.');
}
}, 4000);
// Pilot-Fachhinweis mit Abstand, damit sich die Audios nicht überlappen
setTimeout(function(){
var extra = waterScene
? 'nicht direkt über dem Patienten schweben — Rotorabwind drückt ihn unter Wasser. Daneben halten.'
: schluchtScene
? 'Eng hier — Düseneffekt zwischen den Felswänden. Windseite halten.'
: hwScene
? 'Sinkrate unter zehn halten, grüner Rechteck-Marker ist die Zielzone. Sanft aufsetzen.'
: 'Halte daneben, nicht direkt drüber. Rotorabwind wäre Steinschlag für den Patienten.';
pushRadio('Pilot: Co-Pilot, Zielanflug. ' + extra);
}, 12000);
}
else if (newPhase === 'hover_drop') {
playRadio('land_winch_down');
pushRadio('Pilot: Position halten. Ich seile den Flugretter ab — Maschine ruhig, keine hektischen Korrekturen.');
}
else if (newPhase === 'rescuer_working') {
setTimeout(function(){
playRadio('land_rescuer_down');
pushRadio('Retter: Bin unten beim Patienten. Versorgung läuft — flieg jetzt eine Warteschleife. Over.');
}, 500);
setTimeout(function(){
pushRadio('Pilot: Warteschleife, Co-Pilot! Im Schwebeflug frisst der Heli rund sechs Liter pro Minute — doppelt so viel wie im Vorwärtsflug. Jede Minute über dem Patienten kostet uns Reichweite. Kreise lieber niedrig ' + (waterScene ? 'übers offene Wasser' : 'im Tal') + ', das spart Sprit.');
}, 6500);
}
else if (newPhase === 'approach2') {
setTimeout(function(){
playRadio('land_rescuer_ready');
pushRadio('Retter: Patient stabilisiert, bereit zur Aufnahme. Bitte rasch kommen — seine Atmung wird flacher!');
}, 500);
setTimeout(function(){
pushRadio('Pilot: Roger Retter. Zweiter Anflug, Himmelsrichtung ' + dir + '. Patient muss dringend in die Klinik. Konzentriert bleiben.');
}, 6000);
}
else if (newPhase === 'hover_pickup') {
playRadio('land_hook_connected');
pushRadio('Pilot: Position halten. Retter und Patient am Seil — langsam hochziehen, keine Böe jetzt.');
}
else if (newPhase === 'extract') {
playRadio('land_lift_success'); // "Patient an Bord — Kurs Klinik"
const fallbackExtract = 'tower_extract_' + heliKey;
const extractAudio = hwScene ? 'tower_hw_extract_' + heliKey
: waterScene ? 'tower_sea_extract_' + heliKey
: fallbackExtract;
setTimeout(function(){
playRadioOrFallback(extractAudio, fallbackExtract);
pushRadio('Tower: ' + heliName + ', Direktfreigabe zur Klinik. Kurs ' + (dir === 'Osten' ? 'Westen' : 'Osten') + ', etwa 12 Kilometer. Patient hat oberste Priorität.');
}, 3500);
setTimeout(function(){
pushRadio('Pilot: Roger Tower. Sanft fliegen, Co-Pilot — harte Böen übertragen sich direkt auf den Patienten am Haken. Kurs Klinik.');
}, 6500);
}
}
function pushRadio(msg){
state.radio.current = msg;
state.radio.shown = 0;
state.radio.nextAt = 6 + Math.random()*3;
}
// ============================================================
// LOKALES WINDFELD
// ============================================================
// Returns {x, y} wind force at world position (px, py)
function localWind(px, py){
const baseX = state.windBase * state.windDir; // px/s tendency
let wx = baseX, wy = 0;
const fieldStrength = state.level.windFieldStrength;
if(fieldStrength > 0 && state.mountains){
const groundH = state.mountains.heightFn(px);
const groundY = GROUND_Y - groundH;
const altAbove = groundY - py; // positive = above ground
// Influence falls off with altitude
const proximity = clamp(1 - altAbove / 280, 0, 1);
if(proximity > 0){
// Slope at this x
const slope = (state.mountains.heightFn(px+10) - state.mountains.heightFn(px-10)) / 20;
// Sign of slope: positive = uphill toward right
// Wind blowing right-to-left (windDir=-1) hits left-facing slopes (slope > 0) → upwind
const facingWind = (slope * state.windDir) < 0;
if(facingWind){
// Luvseite: upward push, slight horizontal acceleration
wy -= 50 * proximity * Math.min(1, Math.abs(slope)) * fieldStrength;
wx *= (1 + 0.4 * proximity * fieldStrength);
} else {
// Leeseite: downdraft + slow turbulence
wy += 70 * proximity * Math.min(1, Math.abs(slope)) * fieldStrength;
wx *= (1 - 0.3 * proximity * fieldStrength);
// Slow turbulence — Zyklus ~20 s statt ~2 s
const turb = Math.sin(performance.now() * 0.0003 + px * 0.0015) * 25;
wx += turb * proximity * fieldStrength;
wy += Math.sin(performance.now() * 0.0005 + px * 0.002) * 20 * proximity * fieldStrength;
}
}
}
// Add gust
wx += state.gust * state.windDir;
return { x: wx, y: wy };
}
// ============================================================
// STEUERUNG (flappy-style: Impulse auf Tastendruck)
// ============================================================
document.addEventListener('keydown', e => {
state.keys[e.key] = true;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
if(e.key === 'r' || e.key === 'R'){
if(state.mode !== 'ready') showOverlay();
}
if(e.key === 'm' || e.key === 'M') toggleMute();
});
document.addEventListener('keyup', e => { state.keys[e.key] = false; });
// Touch controls — Joystick (Kreis mit Stick in der Mitte, alle Richtungen)
(function bindJoystick(){
const base = document.getElementById('joyBase');
const stick = document.getElementById('joyStick');
if (!base || !stick) return;
const RADIUS = 40;
const DEAD = 12;
let active = false, cx = 0, cy = 0;
function updateKeys(dx, dy) {
state.keys['ArrowUp'] = dy < -DEAD;
state.keys['ArrowDown'] = dy > DEAD;
state.keys['ArrowLeft'] = dx < -DEAD;
state.keys['ArrowRight'] = dx > DEAD;
}
function setStick(dx, dy) {
const len = Math.sqrt(dx*dx + dy*dy);
if (len > RADIUS) { dx = dx * RADIUS / len; dy = dy * RADIUS / len; }
stick.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
updateKeys(dx, dy);
}
function reset() {
active = false;
base.classList.remove('active');
stick.style.transform = 'translate(-50%, -50%)';
state.keys['ArrowUp'] = state.keys['ArrowDown'] = state.keys['ArrowLeft'] = state.keys['ArrowRight'] = false;
}
function start(clientX, clientY) {
active = true;
base.classList.add('active');
const r = base.getBoundingClientRect();
cx = r.left + r.width/2;
cy = r.top + r.height/2;
setStick(clientX - cx, clientY - cy);
}
function move(clientX, clientY) {
if (!active) return;
setStick(clientX - cx, clientY - cy);
}
base.addEventListener('touchstart', e => { e.preventDefault(); const t = e.touches[0]; start(t.clientX, t.clientY); }, {passive:false});
base.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; move(t.clientX, t.clientY); }, {passive:false});
base.addEventListener('touchend', e => { e.preventDefault(); reset(); }, {passive:false});
base.addEventListener('touchcancel',e => { e.preventDefault(); reset(); }, {passive:false});
base.addEventListener('mousedown', e => { start(e.clientX, e.clientY); });
window.addEventListener('mousemove',e => { move(e.clientX, e.clientY); });
window.addEventListener('mouseup', () => { if (active) reset(); });
})();
function keyDown(name){
return !!state.keys[name];
}
function keyPressed(name){
return state.keys[name] && !state.prevKeys[name];
}
// Combined helpers (Arrow + WASD)
function kUp() { return keyDown('ArrowUp') || keyDown('w') || keyDown('W'); }
function kLeft() { return keyDown('ArrowLeft') || keyDown('a') || keyDown('A'); }
function kRight() { return keyDown('ArrowRight') || keyDown('d') || keyDown('D'); }
function pUp() { return keyPressed('ArrowUp') || keyPressed('w') || keyPressed('W'); }
function pLeft() { return keyPressed('ArrowLeft') || keyPressed('a') || keyPressed('A'); }
function pRight() { return keyPressed('ArrowRight') || keyPressed('d') || keyPressed('D'); }
// ============================================================
// UPDATE (flappy-style aus dem Original übernommen)
// ============================================================
function update(dt){
// Inspect-Modus: Welt bleibt stehen, nur Rendern laeuft weiter
if (state.mode === 'inspect') { state.prevKeys = { ...state.keys }; return; }
// Solange das Einsatz-Briefing-Overlay sichtbar ist, friert alles ein —
// kein Fuel-Verbrauch, kein Timer. Läuft erst nach Klick auf „Einsatz starten".
const ov = document.getElementById('overlay');
if (ov && ov.classList.contains('show')) {
state.prevKeys = { ...state.keys };
return;
}
if(state.mode !== 'flying' && state.mode !== 'autopilot'){
state.prevKeys = { ...state.keys };
return;
}
const h = state.heli;
// Rotor (wie Original)
if(h.engineOn) h.rotorSpeed += (60 - h.rotorSpeed) * Math.min(1, 1.6 * dt);
else h.rotorSpeed *= Math.pow(0.6, dt);
h.rotor += h.rotorSpeed * dt;
h.invul = Math.max(0, h.invul - dt);
h.flash = Math.max(0, h.flash - dt * 2);
// Autopilot takeover
if(state.mode === 'autopilot'){
state.autopilotT += dt;
const phase = state.autopilotT;
let targetVy, targetVx, targetTilt;
if(phase < 2){ targetVy = -140; targetVx = 60; targetTilt = 0.08; }
else { targetVy = -90; targetVx = 220; targetTilt = 0.28; }
h.vy += (targetVy - h.vy) * Math.min(1, 1.8 * dt);
h.vx += (targetVx - h.vx) * Math.min(1, 1.2 * dt);
h.targetTilt = targetTilt;
h.tilt += (h.targetTilt - h.tilt) * Math.min(1, 4 * dt);
h.x += h.vx * dt; h.y += h.vy * dt;
h.facing = 1;
const desired = Math.max(0, Math.min(WORLD_W - W, h.x - W * 0.33));
state.camX += (desired - state.camX) * Math.min(1, 4 * dt);
state.t += dt;
state.prevKeys = { ...state.keys };
updateHUD();
return;
}
// ===== LANDED: Heli steht still am Helipad, ignoriert Wind =====
if(h.landed){
const pad = h.landed;
h.x = pad.x + pad.w / 2;
h.y = pad.y - 14;
h.vx = 0; h.vy = 0; h.tilt = 0;
h.rotorSpeed = 100;
h.engineOn = true;
h.flapTimer = (h.flapTimer || 0) + dt;
// Treibstoff verbraucht sich am Boden langsamer
h.fuel = Math.max(0, h.fuel - 0.004 * dt);
// Phase-Logik & Funk laufen weiter
updatePhase(dt);
updateRadio(dt);
// Wenn Phase Win/Lose ausgelöst hat, hier raus
if(state.mode !== 'flying'){
state.t += dt;
state.prevKeys = { ...state.keys };
updateHUD();
return;
}
// Disembark blockiert Take-Off; sonst kann mit ↑ wieder gestartet werden
if(!state.disembark && pUp()){
h.landed = null;
h.vy = -state.level.flapImpulse;
h.flapTimer = 0;
}
// Kameranachführung
const offset = h.facing > 0 ? 0.4 : 0.6;
const desired = clamp(h.x - W * offset, 0, WORLD_W - W);
state.camX += (desired - state.camX) * Math.min(1, 4 * dt);
state.t += dt;
state.prevKeys = { ...state.keys };
updateHUD();
return;
}
// LOCKED: Heli schwebt in der Mitte, wartet auf erste Eingabe
if(h.locked){
// Heli stays still in mid-air (mock-hover, no gravity)
h.vx = 0; h.vy = 0; h.tilt = 0;
h.rotorSpeed = 100; // already at full RPM (just arrived)
h.engineOn = true;
const wantUnlock = pUp() || pLeft() || pRight();
if(wantUnlock){
h.locked = false;
h.controlT = 0;
// Trigger briefing radio immediately
if(!state.radio.briefingShown){
pushRadio(state.radio.briefing);
state.radio.briefingShown = true;
}
} else {
state.prevKeys = { ...state.keys };
return;
}
}
// controlT ramps from 0 to 1 over 1.5 s after unlock — sanfte Übernahme
h.controlT = Math.min(1, (h.controlT || 0) + dt / 1.5);
const handover = h.controlT;
h.engineOn = true;
// Flappy physics — Werte pro Level
const GRAVITY = state.level.gravity;
const FLAP_IMPULSE = state.level.flapImpulse;
const FLAP_COOLDOWN = 0.16;
const MAX_VY_DOWN = 300, MAX_VY_UP = 280;
h.flapTimer = (h.flapTimer || 0) + dt;
const wantFlap = pUp() || (kUp() && h.flapTimer > FLAP_COOLDOWN);
if(wantFlap){
h.vy = Math.max(h.vy - FLAP_IMPULSE, -MAX_VY_UP);
h.flapTimer = 0;
h.vx += Math.sin(h.tilt) * 50;
}
if(kLeft()) h.targetTilt = -0.32;
else if(kRight()) h.targetTilt = 0.32;
else h.targetTilt = 0;
h.tilt += (h.targetTilt - h.tilt) * Math.min(1, 6 * dt);
h.vy += GRAVITY * dt * handover;
// Wind: lokales Windfeld (auch graduell)
const wind = localWind(h.x, h.y);
h.vx += Math.sin(h.tilt) * 320 * dt + wind.x * dt * handover;
h.vy += wind.y * dt * handover;
// Caps
h.vy = Math.min(MAX_VY_DOWN, Math.max(-MAX_VY_UP, h.vy));
h.vx = Math.max(-280, Math.min(280, h.vx));
// Damping (level-spezifisch — Level 1 dämpft stärker → ruhiger)
h.vx *= Math.pow(state.level.vxDamp, dt * 60);
h.vy *= Math.pow(state.level.vyDamp, dt * 60);
// Fuel — physikalisches Modell:
// - Grundverbrauch (Rotor gegen Schwerkraft, Schwebeflug): 1.0x
// - Aktiver Flap (Pitch hoch, Schub): +0.8x
// - Steigflug (vy stark negativ, d.h. nach oben): bis +1.0x
// - Sinkflug / Gleiten (vy positiv, faellt mit Schwerkraft): bis -0.5x
// - Hoehe (y < 200 = duenne Luft am oberen Rand): bis 2x zusaetzlich
let thrustMult = 1.0;
if (wantFlap) thrustMult += 0.8;
if (h.vy < -40) thrustMult += Math.min(1.0, -h.vy / 160); // Steigen kostet
else if (h.vy > 60) thrustMult = Math.max(0.4, thrustMult - 0.5); // Sinken spart
const altMult = h.y < 200 ? (1 + (200 - Math.max(50, h.y)) / 150) : 1;
h.fuel = Math.max(0, h.fuel - 0.004 * altMult * thrustMult * dt);
if(h.fuel < 0.05 && state.mode === 'flying' && !h.fuelEmergency){
h.fuelEmergency = true;
pushRadio('Pilot: Sprit kritisch. Ich übernehm — Co-Pilot, kein Drama. Heading home.');
state.mode = 'autopilot';
state.autopilotT = 0;
h.invul = 999;
setTimeout(() => loseGame('Treibstoff aufgebraucht'), 2400);
}
h.x += h.vx * dt;
h.y += h.vy * dt;
if(h.vx < -40) h.facing = -1;
else if(h.vx > 40) h.facing = 1;
// ===== Helipad-Landungen =====
const hhh = 14;
const pe = state.helipadEnd;
const pi = state.intermediatePad;
const pads = [pe];
if(pi && !pi.atClinic) pads.push(pi);
let touchedPad = null;
for(const pad of pads){
// Landung nur bei aktivem Sinken (mind. 12 px/s) und wenn der Heli
// tatsaechlich AUF Pad-Hoehe ist (nicht weit drunter durchfliegt).
const onPadY = (h.y + hhh >= pad.y) && (h.y + hhh <= pad.y + 18);
if(onPadY && h.x > pad.x - 10 && h.x < pad.x + pad.w + 10 && h.vy > 12){
if(h.vy > 200 && h.invul <= 0){
strike('Harte Landung am Helipad');
h.y = pad.y - hhh;
h.vy = -120;
} else {
// Sanftes Aufsetzen — als gelandet registrieren
h.y = pad.y - hhh;
h.vx = 0; h.vy = 0;
touchedPad = pad;
}
break;
}
}
if(touchedPad && !h.landed){
h.landed = touchedPad;
onSoftLanding(touchedPad);
}
// Berg-/Bodenkollision (außer auf Helipad)
const groundH = state.mountains ? state.mountains.heightFn(h.x) : 0;
const groundY = GROUND_Y - groundH;
if(h.y + hhh >= groundY && !h.landed){
const onPad = pads.some(p => h.x > p.x - 10 && h.x < p.x + p.w + 10);
// Klinik-Anflug-Toleranz bei flat-Szenen: im weiten Umkreis um das Klinikgebäude
// zählt weicher Bodenkontakt nicht als Strike (Grasland)
const pe2 = state.helipadEnd;
const nearClinic = (state.isWater || state.isHighway) && pe2
&& h.x > pe2.x - 120 && h.x < pe2.x + pe2.w + 120;
if(!onPad){
if(h.invul <= 0 && !(nearClinic && Math.abs(h.vy) < 140)) {
strike(groundH > 60 ? 'Bergflanke berührt' : 'Bodenberührung');
}
h.y = groundY - hhh;
h.vy = -120;
}
}
if(h.y < SKY_TOP){ h.y = SKY_TOP; h.vy = Math.max(0, h.vy); }
if(h.x < 30){ h.x = 30; h.vx = Math.max(0, h.vx); }
if(h.x > WORLD_W - 30){ h.x = WORLD_W - 30; h.vx = Math.min(0, h.vx); }
// Hinderniskollisionen
if(h.invul <= 0){
for(const c of state.cables){
if(segNearPoint(c.x1, c.y1, c.x2, c.y2, h.x, h.y, 14)){
strike('Seilbahnkabel!'); break;
}
if(rectHit(c.st1, h.x, h.y, 14) || rectHit(c.st2, h.x, h.y, 14)){
strike('Seilbahnstation'); break;
}
}
if(h.invul <= 0){
for(const b of state.birds){
for(const m of b.members){
const bx = b.x + m.dx, by = b.y + m.dy;
// Hitbox realistisch: nah am Rotor
if(Math.abs(bx - h.x) < 16 && Math.abs(by - h.y) < 14){
strike('Vogelschlag'); break;
}
}
if(h.invul > 0) break;
}
}
if(h.invul <= 0 && state.secondHeli){
const s = state.secondHeli;
if(Math.abs(s.x - h.x) < 50 && Math.abs(s.y - h.y) < 30){
strike('Anderer Heli zu nah');
}
}
// === Hohe Segelschiffe im See: Mastkollision ===
if (h.invul <= 0 && state.ships) {
for (const s of state.ships) {
if (s.kind !== 'tall-sail') continue;
const mastWorldX = s.x + s.w * 0.45;
const topY = s.y - s.mastH;
// Mast-Kollision (schmaler Zylinder) + Segel-Zone (breiter)
if (h.x > s.x - 6 && h.x < s.x + s.w + 6 && h.y > topY - 8 && h.y < s.y - 4) {
// Segel-Bereich — leichter Schaden
if (Math.abs(h.x - mastWorldX) < 10) {
strike('Segelmast gestreift!');
} else if (h.y > topY && h.y < topY + s.mastH * 0.35) {
strike('Segel zerfetzt!');
}
break;
}
}
}
// === Autobahn: Gantries, Gaffer, außerhalb Sperrzone ===
if (h.invul <= 0 && state.highway) {
const hw = state.highway;
// Schilderbrücken: Rechteckkollision
for (const g of hw.gantries) {
if (h.x > g.x - g.w/2 - 6 && h.x < g.x + g.w/2 + 6 &&
h.y > g.topY - 8 && h.y < g.botY + 8) {
strike('Schilderbrücke gestreift!');
break;
}
}
// Außerhalb Sperrzone landen → abgewiesen
if (h.invul <= 0 && h.y > GROUND_Y - 30) {
if (h.x < hw.sperrLeft + 20 || h.x > hw.sperrRight - 20) {
strike('Landung außerhalb der Sperrzone!');
}
}
// Frueh-Warnung: Heli noch hoch (im Schwebeflug), Mensch oder Einsatzwagen
// direkt unter dem Heli → Pilot warnt, kein Strike noch.
if (h.y > GROUND_Y - 200 && h.y < GROUND_Y - 60) {
let warnObj = null;
for (const g of hw.gawkers) {
if (Math.abs(g.x - h.x) < 30) { warnObj = 'people'; break; }
}
if (!warnObj && hw.emergencyVehicle && hw.emergencyVehicle.x > -1000) {
const ev = hw.emergencyVehicle;
if (h.x > ev.x - 8 && h.x < ev.x + ev.w + 8) warnObj = 'emergency';
}
if (warnObj && (state.t - (state._lastHwWarn||0)) > 4) {
state._lastHwWarn = state.t;
if (warnObj === 'people') {
pushRadio('Pilot: Achtung — Menschen unter dem Heli! Position halten oder seitlich versetzen.');
} else {
pushRadio('Pilot: Einsatzwagen unter uns! Höher oder seitlich, kein Druck nach unten.');
}
}
}
// Gaffer in Rotorreichweite (echter Strike)
if (h.invul <= 0 && h.y > GROUND_Y - 80) {
for (const g of hw.gawkers) {
if (Math.abs(g.x - h.x) < 35 && h.y > GROUND_Y - 70) {
strike('Mensch im Rotor-Bereich!');
break;
}
}
}
// Fahrzeuge (Stau/Wrack/Einsatzwagen) in Rotorreichweite
if (h.invul <= 0 && h.y > GROUND_Y - 50) {
for (const v of hw.vehicles) {
if (h.x > v.x - 8 && h.x < v.x + v.w + 8 && h.y > v.y - 15) {
strike(v.smashed ? 'Fahrzeugwrack!' : 'Fahrzeug gestreift!');
break;
}
}
if (hw.emergencyVehicle && hw.emergencyVehicle.x > -1000) {
const ev = hw.emergencyVehicle;
if (h.x > ev.x - 8 && h.x < ev.x + ev.w + 8 && h.y > ev.y - 15) {
strike('Einsatzwagen gestreift!');
}
}
}
}
}
// Wind-Böen
state.gustTimer -= dt;
if(state.gustTimer <= 0){
if(Math.random() < state.level.gustChance){
state.gust = (Math.random() * 30 + 15) * (Math.random() < 0.5 ? -1 : 1);
pushRadio('Pilot: Böe von der Seite. Gegensteuern, easy.');
} else {
state.gust = 0;
}
state.gustTimer = 4 + Math.random() * 4;
}
state.gust *= 0.96;
// Bewegung von Vögeln, zweitem Heli, Wolken
for(const b of state.birds){
b.x += b.vx * dt;
if(b.vx < 0 && b.x < -100) b.x = WORLD_W + 80;
if(b.vx > 0 && b.x > WORLD_W + 100) b.x = -80;
for(const m of b.members) m.phase += dt * 5;
}
// Proximity-Vogelrufe pruefen (einmal pro Tick, billig)
maybePlayProximityBird();
// Schiffe treiben (Wasserrettung)
if (state.ships) {
for (const s of state.ships) {
s.x += s.vx * dt;
if (s.vx < 0 && s.x < -120) s.x = WORLD_W + 100;
if (s.vx > 0 && s.x > WORLD_W + 120) s.x = -100;
}
}
// Gaffer schleichen auf Unfallstelle zu (Autobahn) — mit Gehpausen
if (state.highway) {
for (const g of state.highway.gawkers) {
if (g.pauseT > 0) {
g.pauseT -= dt;
continue;
}
const dx = g.targetX - g.x;
if (Math.abs(dx) > 5) {
g.x += Math.sign(dx) * Math.abs(g.vx) * dt;
g.nextPause -= dt;
if (g.nextPause <= 0) {
g.pauseT = 1.5 + Math.random() * 2.5;
g.nextPause = 4 + Math.random() * 5;
}
}
}
// Einsatzwagen — fährt zur Unfallstelle, parkt, fährt wieder weg
const ev = state.highway.emergencyVehicle;
if (ev) {
ev.blink += dt * 6;
if (ev.state === 'driving') {
const dx = ev.targetX - ev.x;
if (Math.abs(dx) > 5) ev.x += Math.sign(dx) * Math.abs(ev.vx) * dt;
else { ev.state = 'parked'; ev.stateT = 0; }
} else if (ev.state === 'parked') {
ev.stateT += dt;
if (ev.stateT > 18) { ev.state = 'leaving'; ev.vx = -ev.vx; }
} else if (ev.state === 'leaving') {
ev.x += ev.vx * dt;
if (ev.x < state.highway.sperrLeft - 100 || ev.x > state.highway.sperrRight + 100) {
// verschwindet — nichts mehr zu tun
ev.x = -9999;
}
}
}
}
if(state.secondHeli){
const s = state.secondHeli;
s.x += s.vx * dt;
if(s.x < s.patrolMin){ s.x = s.patrolMin; s.vx = Math.abs(s.vx); s.dir = 1; }
if(s.x > s.patrolMax){ s.x = s.patrolMax; s.vx = -Math.abs(s.vx); s.dir = -1; }
s.rotor += dt * 25;
}
// Hintergrund-Helis (Autobahn) — patrouillieren, keine Kollision
if(state.bgHelis && state.bgHelis.length){
for (const s of state.bgHelis) {
s.x += s.vx * dt;
if(s.x < s.patrolMin){ s.x = s.patrolMin; s.vx = Math.abs(s.vx); s.dir = 1; s.facing = 1; }
if(s.x > s.patrolMax){ s.x = s.patrolMax; s.vx = -Math.abs(s.vx); s.dir = -1; s.facing = -1; }
s.rotor += dt * 25;
}
}
for(const c of state.clouds){
c.x += c.v * dt;
if(c.x > WORLD_W + 100) c.x = -100;
}
for(const c of state.passingClouds){
c.x += c.v * dt;
if(c.x > WORLD_W + 400) c.x = -400 - Math.random() * 600;
}
updatePhase(dt);
updateWinch(dt);
updateRadio(dt);
// Camera follows heli, offset based on facing direction (look-ahead)
const camOffset = h.facing > 0 ? 0.4 : 0.6;
const desired = clamp(h.x - W * camOffset, 0, WORLD_W - W);
state.camX += (desired - state.camX) * Math.min(1, 4 * dt);
state.shake = Math.max(0, state.shake - dt * 3);
state.t += dt;
state.prevKeys = { ...state.keys };
updateHUD();
}
function rectHit(r, hx, hy, pad){
return hx > r.x - pad && hx < r.x + r.w + pad &&
hy > r.y - pad && hy < r.y + r.h + pad;
}
function segNearPoint(x1, y1, x2, y2, px, py, tol){
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx*dx + dy*dy;
if(len2 < 1) return false;
let t = ((px - x1)*dx + (py - y1)*dy) / len2;
t = clamp(t, 0, 1);
const cx = x1 + t*dx, cy = y1 + t*dy;
const ddx = px - cx, ddy = py - cy;
return ddx*ddx + ddy*ddy < tol*tol;
}
// ============================================================
// PHASEN
// ============================================================
function updatePhase(dt){
const h = state.heli;
const p = state.patient;
if(!p) return;
// === AUTOBAHN: echte Landung statt Winde ===
if (state.isHighway && state.phase === 'approach1') {
const lz = state.highway.landingZone;
const inZone = h.x >= lz.x && h.x <= lz.x + lz.w;
const sinkRate = h.vy; // positiv = Sinken (Canvas-Y zeigt nach unten)
const sideSpeed = Math.abs(h.vx);
// Coach-Hinweise per Funk (ein Mal, wenn zum ersten Mal sinkt / zu schnell / außerhalb)
if (!state._hwHint && h.y > GROUND_Y - 150) {
state._hwHint = true;
pushRadio('Pilot: Okay, Anflug zur grünen Zone. Schub halten, sanft absinken — Sinkrate unter zehn. Copy?');
}
if (h.y > GROUND_Y - 18) {
if (inZone && sinkRate < 170 && sideSpeed < 110) {
// Touchdown erfolgreich (Toleranz großzügig, damit Kinder nicht frustrieren)
h.landed = { x: lz.x, y: GROUND_Y - 4, w: lz.w, fromHighway: true };
h.y = GROUND_Y - 14; h.vx = 0; h.vy = 0; h.tilt = 0;
state.phaseProgress = 0;
setPhase('hover_drop');
pushRadio('Tower: Roger, down and safe. Sanitäter auf dem Weg — standby.');
} else if (!inZone && h.y > GROUND_Y - 10) {
strike('Außerhalb Landezone!');
h.vy = -80;
} else if (sinkRate > 220 || sideSpeed > 150) {
strike('Zu hartes Aufsetzen — Fahrwerk!');
h.vy = -60;
}
}
return;
}
if (state.isHighway && state.phase === 'hover_drop') {
// Bodenaufenthalt: 22 s warten, Sanitäter tragen Patient ans Heli
state.phaseProgress += dt / 22;
if (state.phaseProgress >= 0.5 && !p.rescued) {
p.rescued = true;
pushRadio('Retter: Patient ist an Bord, fest verzurrt. Ready to lift.');
}
if (state.phaseProgress >= 1) {
// Heli bleibt auf dem Asphalt landed — Spieler drückt ↑ für Takeoff.
// Die landed-Logik kümmert sich um pUp() → h.landed = null.
setPhase('extract');
pushRadio('Pilot: Patient an Bord. Bitte Abflug Richtung Klinik — Schub geben, Pfeiltaste oben!');
h.invul = 3; // kurze Sicherheit, falls der Spieler sofort loslegt
}
return;
}
if (state.isHighway && state.phase === 'approach2') {
// Für Autobahn brauchen wir keine zweite Annäherung — skip direkt zu extract
setPhase('extract');
return;
}
// === Wasserrettung: Patient treibt mit der Strömung, solange kein Retter
// bei ihm ist. Sobald der Rettungsschwimmer am Patient arbeitet, hält
// er ihn fest — und driftet mit, damit sie nicht auseinander treiben. ===
if (state.isWater && !p.rescued && p.drift) {
const rescuerAtPatient = state.rescuer && (state.rescuer.state === 'working' || state.rescuer.state === 'ready');
if (!rescuerAtPatient) {
p.x += p.drift * dt;
p.x = Math.max(300, Math.min(WORLD_W - 300, p.x));
}
// Retter folgt dem Patienten (hält ihn fest)
if (rescuerAtPatient && state.rescuer) {
state.rescuer.x = p.x;
}
}
// Downwash-Strike NUR im ersten Anflug (Retter abseilen) — beim zweiten
// Anflug (hover_pickup) ist der Retter bereits im Wasser und hält Patient,
// da darf der Heli tiefer gehen.
if (state.isWater && h.invul <= 0 &&
(state.phase === 'approach1' || state.phase === 'hover_drop')) {
const dx = h.x - p.x, dy = h.y - p.y;
const horDist = Math.abs(dx);
if (horDist < 55 && dy > -45 && dy < 0) {
strike('Downwash — Patient untergetaucht!');
}
}
if(state.phase === 'approach1' || state.phase === 'hover_drop'){
const dx = h.x - p.x;
const dy = h.y - (p.y - 90);
const dist = Math.sqrt(dx*dx + dy*dy);
const inZone = dist < state.level.hoverRadius;
if(inZone){
if(state.phase === 'approach1'){
setPhase('hover_drop');
state.winch.target = p;
state.winch.maxLength = (p.y - 14) - h.y;
state.winch.extending = true;
state.load.hasRescuer = true;
}
state.phaseProgress += dt / state.level.hoverTime;
if(state.phaseProgress >= 1 && state.winch.length >= state.winch.maxLength * 0.95){
state.phaseProgress = 1;
state.rescuer.x = p.x;
state.rescuer.y = p.y - 4;
state.rescuer.state = 'working';
state.load.hasRescuer = false;
state.winch.extending = false;
state.rescuerTimer = 0;
setPhase('rescuer_working');
}
} else if(state.phase === 'hover_drop'){
state.phaseProgress = Math.max(0, state.phaseProgress - dt * 0.3);
}
}
else if(state.phase === 'rescuer_working'){
const dx = h.x - p.x, dy = h.y - p.y;
const dist = Math.sqrt(dx*dx + dy*dy);
const speed = Math.sqrt(h.vx*h.vx + h.vy*h.vy);
if(state.intermediateRequired && state.intermediatePad){
// Timer counts only when landed at intermediate pad
const ip = state.intermediatePad;
const padToCheck = ip.atClinic ? state.helipadEnd : ip;
const isLandedAtPad = h.landed && (h.landed === padToCheck ||
(Math.abs(h.landed.x - padToCheck.x) < 5 && Math.abs(h.landed.y - padToCheck.y) < 5));
if(isLandedAtPad){
state.rescuerTimer += dt;
} else {
// Periodic reminder
if(Math.random() < dt * 0.12){
const dir = padToCheck.x < state.patient.x ? 'Westen' : 'Osten';
const where = ip.atClinic ? 'an der Klinik' : `beim Rauchsignal im ${dir}`;
pushRadio(`Pilot: Zwischenlandung ${where} — kurz aussetzen, Sprit sparen. Standby.`);
}
}
} else {
// Normal: timer counts down regardless
state.rescuerTimer += dt;
if(dist < 220 && speed < 50 && Math.random() < dt * 0.25){
pushRadio('Pilot: Nicht stehenbleiben, das frisst Sprit. Warteschleife, Copy?');
}
if(dist > 1100 && Math.random() < dt * 0.15){
pushRadio('Retter: Bleib in der Nähe — gleich gehts weiter. Standby.');
}
}
const remaining = state.level.rescuerWorkTime - state.rescuerTimer;
state.phaseProgress = clamp(state.rescuerTimer / state.level.rescuerWorkTime, 0, 1);
if(remaining <= 0){
state.rescuer.state = 'ready';
setPhase('approach2');
}
}
else if(state.phase === 'approach2' || state.phase === 'hover_pickup'){
const dx = h.x - p.x;
const dy = h.y - (p.y - 90);
const dist = Math.sqrt(dx*dx + dy*dy);
// Pickup nur aus Schwebeflug: ein gelandeter Heli darf die Pickup-Phase
// nicht automatisch triggern (z.B. wenn atClinic-Intermediate zufaellig
// auf Hover-Achse liegt).
const inZone = !h.landed && dist < state.level.hoverRadius;
if(inZone){
if(state.phase === 'approach2'){
setPhase('hover_pickup');
state.winch.target = p;
state.winch.maxLength = (p.y - 14) - h.y;
state.winch.extending = true;
}
state.phaseProgress += dt / state.level.hoverTime;
// Phase A: Seil ist unten, Hoverzeit erreicht → Rescuer+Patient
// ankoppeln und Seil hochziehen. Patient gilt noch NICHT als gerettet.
if(state.phaseProgress >= 1 && state.winch.length >= state.winch.maxLength * 0.95 && !state.load.hasPatient){
state.load.hasRescuer = true;
state.load.hasPatient = true;
state.rescuer.state = 'ascending';
state.winch.extending = false;
pushRadio('Pilot: Haken eingeklinkt — Seil wird eingezogen. Schwebeflug halten!');
}
} else if(state.phase === 'hover_pickup'){
state.phaseProgress = Math.max(0, state.phaseProgress - dt * 0.3);
}
// Phase B: Last ist am Seil und Seil ist eingezogen → jetzt erst
// Patient als gerettet markieren und zur Extract-Phase wechseln.
if(state.phase === 'hover_pickup' && state.load.hasPatient && state.winch.length < 8){
state.patient.rescued = true;
state.winch.length = 0;
setPhase('extract');
}
}
else if(state.phase === 'extract'){
// Disembark animation: triggered by soft landing at clinic
if(state.disembark){
state.disembark.t += dt;
if(state.disembark.t > state.disembark.duration){
state.disembark = null;
winGame();
}
}
// Hint if heli flies past clinic
const ep = state.helipadEnd;
if(h.x > ep.x + ep.w + 200 && Math.random() < dt * 0.1){
pushRadio('Tower: Klinik-Helipad hinter dir, Captain. Dreh um.');
}
}
}
// Triggered when heli softly lands on a pad
function onSoftLanding(pad){
const ep = state.helipadEnd;
const ip = state.intermediatePad;
if(state.phase === 'extract' && pad === ep){
// Final landing — start disembark animation
state.disembark = { t: 0, duration: 6.0, x: pad.x + pad.w / 2, y: pad.y };
playRadio('land_disembark');
pushRadio('Tower: Saubere Landung, Captain. Patient steigt aus — nice work.');
} else if(state.phase === 'rescuer_working' && state.intermediateRequired){
// Landed at intermediate pad
const padToCheck = ip && ip.atClinic ? ep : ip;
if(padToCheck && Math.abs(pad.x - padToCheck.x) < 5){
pushRadio('Pilot: Down and safe. Wir warten auf den Funkspruch, standby.');
}
} else if(pad === ep && state.phase !== 'extract'){
// Landed at clinic too early
pushRadio('Tower: Zu früh, Captain — Patient ist noch nicht an Bord!');
}
}
function updateWinch(dt){
const w = state.winch;
// Dynamic max length while in hover phases: track the patient
if(w.target && (state.phase === 'hover_drop' || state.phase === 'hover_pickup')){
const targetY = w.target.y - 14;
w.maxLength = Math.max(20, targetY - state.heli.y);
}
if(w.extending){
w.length = Math.min(w.maxLength, w.length + 60 * dt);
} else {
w.length = Math.max(0, w.length - 70 * dt);
}
const hasLoad = state.load.hasRescuer || state.load.hasPatient;
if(state.level.pendulumStrength > 0 && hasLoad && w.length > 5){
const h = state.heli;
const g = 600;
const L = Math.max(20, w.length);
const drive = h.vx * 0.02;
const angAcc = (-g / L) * Math.sin(state.load.angle) - drive * Math.cos(state.load.angle) / L;
state.load.angVel += angAcc * dt * state.level.pendulumStrength;
state.load.angVel *= 0.99;
state.load.angle += state.load.angVel * dt;
if(Math.abs(state.load.angle) > 1.0) state.load.angle = Math.sign(state.load.angle) * 1.0;
// Load collision with terrain (only during extract)
if(state.phase === 'extract' && h.invul <= 0){
const lx = h.x + Math.sin(state.load.angle) * L;
const ly = h.y + Math.cos(state.load.angle) * L;
const groundH = state.mountains.heightFn(lx);
const groundY = GROUND_Y - groundH;
if(ly > groundY - 4){
strike('Last am Hang!');
}
}
} else {
state.load.angle *= 0.9;
state.load.angVel *= 0.9;
}
}
function updateRadio(dt){
state.radio.nextAt -= dt;
if(!state.radio.current && state.radio.nextAt <= 0 && state.radio.messages.length){
state.radio.current = state.radio.messages[Math.floor(Math.random() * state.radio.messages.length)];
state.radio.shown = 0;
state.radio.nextAt = 8 + Math.random() * 5;
}
if(state.radio.current){
state.radio.shown += dt;
if(state.radio.shown > 4.5) state.radio.current = null;
}
}
function strike(msg){
const h = state.heli;
// Guard: nach 3. Berührung keine weiteren Strikes mehr — verhindert
// mehrfaches loseGame()-Auslösen und mehrfachen Ergebnis-Text
if (h.strikes >= 3) return;
h.strikes += 1;
h.invul = 1.5;
h.flash = 1;
state.shake = 1;
h.vy = -120;
h.vx *= -0.4;
if (!state.strikeLog) state.strikeLog = [];
state.strikeLog.push({ msg: msg, t: state.t });
if (state.strikeLog.length > 5) state.strikeLog.shift();
pushRadio(`Pilot: Achtung — ${msg}.`, { priority: true });
playRadioRandom('ouch', { priority: true });
playRandom(['crash1','crash2','crash3'], 0.6);
playSound('warning', 0.3);
if(h.strikes >= 3){
state.mode = 'autopilot';
state.autopilotT = 0;
h.invul = 999;
pushRadio('Pilot: Ich übernehm, Co-Pilot. Kopf hoch — nächstes Mal läuft\'s.');
playRadioRandom('takeover');
setTimeout(() => loseGame(msg), 2200);
}
}
function winGame(){
state.mode = 'won';
playRadio('success2');
stopRotor();
silenceAll();
var stars = Math.max(1, 5 - state.heli.strikes);
if (window.parent !== window) {
const dbg = buildDebugDump('won', '');
console.log('[HELI DEBUG won]', dbg);
setTimeout(function(){ window.parent.postMessage({type:'heli_landing_complete', stars:stars, debug: dbg}, '*'); }, 3000);
} else {
showResultOverlay('✅ Bergung geglückt',
`Saubere Arbeit, Co-Pilot! Patient sicher in der Klinik.<br>Zeit: <b>${state.t.toFixed(1)} s</b> · Berührungen: <b>${state.heli.strikes}/3</b>`,
'Nochmal');
}
}
function loseGame(reason){
if (state._lostTriggered) return; // Dedup: nur einmal senden
state._lostTriggered = true;
state.mode = 'lost';
const dbg = buildDebugDump('lost', reason);
console.log('[HELI DEBUG lost]', dbg);
silenceAll(); // alle Sounds aus
if (window.parent !== window) {
setTimeout(function(){ window.parent.postMessage({type:'heli_landing_complete', stars:1, failed:true, reason: reason || 'Übernahme durch Chefpilot', strikes: dbg.strikes, debug: dbg}, '*'); }, 2000);
} else {
showResultOverlay('❌ Einsatz abgebrochen',
`Der Chefpilot hat übernommen. Grund: <b>${reason}</b>.<br>Zeit: <b>${state.t.toFixed(1)} s</b>`,
'Erneut versuchen');
}
}
// ============================================================
// RENDER
// ============================================================
function render(){
const shakeX = state.shake > 0 ? (Math.random()-0.5)*state.shake*5 : 0;
const shakeY = state.shake > 0 ? (Math.random()-0.5)*state.shake*5 : 0;
ctx.save();
ctx.translate(shakeX, shakeY);
// Sky
const g = ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0, C.sky1);
g.addColorStop(1, C.sky2);
ctx.fillStyle = g;
ctx.fillRect(-20, 0, W+40, H);
const cx = state.camX;
// Far mountain silhouette (parallax 0.4) — bei Wasser/Autobahn keine Berge
if (!state.isWater && !state.isHighway) {
drawDistantRange(cx * 0.4, '#b8cfae', 480);
drawDistantRange(cx * 0.6, '#8aab86', 540);
} else if (state.isWater) {
// Wasser: weit entfernte Uferlinie (flacher Horizont) am gegenüberliegenden Ufer
drawDistantRange(cx * 0.4, '#9db8b4', 560);
}
// Static clouds (parallax)
for(const c of state.clouds){
const sx = c.x - cx * 0.5;
if(sx < -100 || sx > W + 100) continue;
drawCloud(sx, c.y, c.s, 1);
}
// Mountains (main, full parallax)
drawMountains(cx);
// Hintergrund-Helis (Autobahn): hoch am Himmel, vor Bergen/Wolken, vor Patient
if(state.bgHelis && state.bgHelis.length){
for (const b of state.bgHelis) drawBgHeli(b, cx);
}
// End helipad (clinic) marker
drawClinic(cx);
// Cables
for(const c of state.cables){
drawCable(c, cx);
}
// Patient
if(state.patient) drawPatient(cx);
// Rescuer (when on ground)
if(state.rescuer && state.rescuer.state === 'working'){
drawPersonOnGround(state.rescuer.x - cx, state.rescuer.y, '#3d5165', '#e4b95c');
} else if(state.rescuer && state.rescuer.state === 'ready'){
drawPersonOnGround(state.rescuer.x - cx, state.rescuer.y, '#3d5165', '#c85c4a');
}
// Hover target ring
if((state.phase === 'approach1' || state.phase === 'hover_drop' ||
state.phase === 'approach2' || state.phase === 'hover_pickup') && state.patient){
drawHoverRing(cx);
}
// Birds
for(const b of state.birds) drawBirds(b, cx);
// Second heli
if(state.secondHeli) drawSecondHeli(cx);
// Intermediate landing pad with smoke signal (Level 2)
if(state.intermediatePad && state.intermediateRequired){
drawIntermediatePad(cx);
}
// Heli + winch
drawHeli(state.heli, state.selectedHeli, cx);
drawWinchAndLoad(cx);
// Disembark animation (after final landing)
if(state.disembark) drawDisembark(cx);
// Passing clouds (foreground, can obscure)
for(const c of state.passingClouds){
const sx = c.x - cx * 0.85;
if(sx < -200 || sx > W + 200) continue;
drawCloud(sx, c.y, c.s, c.density);
}
// Wind indicator overlay (small arrow at heli pos)
drawLocalWindIndicator(cx);
// Radio overlay
drawRadio();
// Autopilot label
if(state.mode === 'autopilot'){
const sx = state.heli.x - cx;
ctx.fillStyle = 'rgba(61,81,101,.92)';
ctx.fillRect(sx - 70, state.heli.y - 60, 140, 24);
ctx.fillStyle = C.cream;
ctx.font = 'bold 12px system-ui';
ctx.textAlign = 'center';
ctx.fillText('🧑‍✈️ Pilot übernimmt', sx, state.heli.y - 44);
}
// Locked hint
if(state.heli.locked && state.mode === 'flying'){
const pulse = (Math.sin(performance.now()*0.005) + 1) * 0.5;
const sx = state.heli.x - cx;
ctx.fillStyle = `rgba(45,84,52,${0.85 + pulse*0.15})`;
const w = 130, hh = 28;
const lx = sx - w/2, ly = state.heli.y - 70 - pulse*4;
if(ctx.roundRect){ ctx.beginPath(); ctx.roundRect(lx, ly, w, hh, 14); ctx.fill(); }
else ctx.fillRect(lx, ly, w, hh);
ctx.fillStyle = C.cream;
ctx.font = 'bold 13px system-ui';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('↑ STARTEN', sx, ly + hh/2);
}
if (window.INSPECT_MODE) drawInspectOverlay(cx);
ctx.restore();
}
// ============================================================
// INSPECT OVERLAY — aktivieren per ?inspect=1
// Konturen um alle Objekte, Maus-Drag scrollt, Klick oeffnet Panel rechts.
// ============================================================
function drawInspectOverlay(cx){
ctx.save();
ctx.font = '10px system-ui';
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
const box = (sx, sy, w, h, label, ok) => {
ctx.strokeStyle = ok ? 'rgba(45,150,70,.9)' : 'rgba(220,60,50,1)';
ctx.lineWidth = ok ? 1 : 2;
ctx.strokeRect(sx + 0.5, sy + 0.5, Math.max(2, w - 1), Math.max(2, h - 1));
ctx.fillStyle = ok ? 'rgba(45,120,60,.85)' : 'rgba(220,60,50,1)';
const tw = Math.max(24, ctx.measureText(label).width + 6);
ctx.fillRect(sx, sy - 12, tw, 12);
ctx.fillStyle = '#fff';
ctx.fillText(label, sx + 3, sy - 11);
};
// Patient
if (state.patient) {
const p = state.patient;
const sx = p.x - cx;
box(sx - 8, p.y - 16, 16, 16, 'patient' + (p.rescued?' ✓':''), !!state.patient);
}
// Kliniksehelipad
if (state.helipadEnd) {
const ep = state.helipadEnd;
box(ep.x - cx, ep.y, ep.w, 10, 'helipad', true);
}
if (state.intermediatePad && state.intermediateRequired) {
const ip = state.intermediatePad;
box(ip.x - cx, ip.y, ip.w, 10, 'intermediatePad' + (ip.atClinic?'(atClinic)':''), true);
}
// Voegel
(state.birds || []).forEach((b, i) => {
const sx = b.x - cx;
if (sx < -60 || sx > W + 60) return;
box(sx - 20, b.y - 8, 40, 16, 'bird#' + i, true);
});
// Schiffe (Wasserszene)
(state.ships || []).forEach((s, i) => {
const sx = s.x - cx;
if (sx < -80 || sx > W + 80) return;
const tall = s.kind === 'tall-sail';
const h = tall ? (s.mastH || 60) + 20 : 20;
const yTop = tall ? s.y - (s.mastH || 60) : s.y - 8;
box(sx, yTop, s.w || 40, h, (tall?'tallShip#':'ship#') + i, true);
});
// Autobahn-Teile
if (state.highway) {
const hw = state.highway;
(hw.gantries || []).forEach((g, i) => {
const sx = g.x - cx;
if (sx < -100 || sx > W + 100) return;
box(sx - g.w/2, g.topY, g.w, g.botY - g.topY + 8, 'gantry#' + i, true);
});
(hw.vehicles || []).forEach((v, i) => {
const sx = v.x - cx;
if (sx < -60 || sx > W + 60) return;
box(sx - 15, v.y - 10, 30, 14, 'vehicle#' + i, true);
});
(hw.gawkers || []).forEach((g, i) => {
const sx = g.x - cx;
if (sx < -30 || sx > W + 30) return;
box(sx - 4, g.y - 14, 8, 14, 'gawker#' + i, true);
});
}
// Kabel (Seilbahnen etc.)
(state.cables || []).forEach((c, i) => {
const sx1 = c.x1 - cx, sx2 = c.x2 - cx;
if (sx2 < 0 || sx1 > W) return;
ctx.strokeStyle = 'rgba(220,60,50,0.8)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sx1, c.y1); ctx.lineTo(sx2, c.y2); ctx.stroke();
ctx.fillStyle = 'rgba(220,60,50,1)'; ctx.fillRect(sx1 - 1, c.y1 - 12, 70, 12);
ctx.fillStyle = '#fff'; ctx.fillText('cable#' + i, sx1 + 2, c.y1 - 11);
});
// SecondHeli + bgHelis
if (state.secondHeli) {
const s = state.secondHeli;
box(s.x - cx - 30, s.y - 20, 60, 30, 'secondHeli', true);
}
(state.bgHelis || []).forEach((s, i) => {
box(s.x - cx - 30, s.y - 20, 60, 30, 'bgHeli#' + i + '(' + s.variant + ')', true);
});
// Heli selbst (Referenz)
if (state.heli) {
const sx = state.heli.x - cx;
box(sx - 30, state.heli.y - 15, 60, 30, 'HELI(me)', true);
}
// Legende oben links
ctx.fillStyle = 'rgba(0,0,0,.7)';
ctx.fillRect(8, 8, 260, 32);
ctx.fillStyle = '#fff';
ctx.font = '700 11px system-ui';
ctx.fillText('INSPECT · Drag=scrollen, Klick=Objekt', 14, 14);
ctx.font = '10px system-ui';
ctx.fillStyle = '#9de8a6'; ctx.fillText('gruen=ok', 14, 28);
ctx.fillStyle = '#ffb0a8'; ctx.fillText('rot=Problem/Hindernis', 80, 28);
ctx.restore();
}
// Inspect-Mode aktivieren
if (new URLSearchParams(location.search).get('inspect') === '1') {
window.INSPECT_MODE = true;
setTimeout(() => {
const ov = document.getElementById('overlay');
if (ov) ov.classList.remove('show');
if (state.heli) { state.heli.locked = true; state.heli.vx = 0; state.heli.vy = 0; }
state.mode = 'inspect';
}, 120);
const panel = document.createElement('div');
panel.id = 'inspectPanel';
panel.style.cssText = 'position:fixed;top:12px;right:12px;width:300px;max-height:80vh;overflow:auto;'
+ 'background:#fff;border:1px solid #2d5434;border-radius:10px;padding:12px 14px;font-family:system-ui;'
+ 'font-size:12px;color:#1f2a24;box-shadow:0 10px 30px rgba(0,0,0,.25);z-index:9999';
panel.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<b style="color:#2d5434">INSPECT landing</b>
<button id="inspectReroll" style="padding:4px 10px;background:#c97a3f;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:11px">🎲 Neue Welt</button>
</div>
<div style="font-size:11px;color:#3d5165;margin-bottom:6px">Drag=scrollen · Klick=Objekt</div>
<div id="inspectCount" style="font-size:11px;color:#6b7d8f;margin-bottom:6px">—</div>
<div id="inspectHits" style="font-size:11px"></div>
`;
document.body.appendChild(panel);
function updateCount() {
const el = document.getElementById('inspectCount');
if (!el) return;
el.textContent = `${(state.birds||[]).length} Voegel · ${(state.ships||[]).length} Schiffe · ${(state.cables||[]).length} Kabel · ${(state.bgHelis||[]).length} bgHelis · Scen: ${window.HELI_SCEN || '?'}`;
}
setInterval(updateCount, 500);
document.getElementById('inspectReroll').addEventListener('click', () => {
location.reload();
});
let dragging = false, lastX = 0;
canvas.addEventListener('mousedown', (ev) => { dragging = true; lastX = ev.clientX; });
window.addEventListener('mouseup', () => { dragging = false; });
canvas.addEventListener('mousemove', (ev) => {
if (!dragging) return;
const rect = canvas.getBoundingClientRect();
const scale = canvas.width / rect.width;
state.camX = Math.max(0, Math.min(WORLD_W - W, state.camX - (ev.clientX - lastX) * scale));
lastX = ev.clientX;
});
canvas.addEventListener('click', (ev) => {
if (Math.abs(ev.movementX) > 2) return;
const rect = canvas.getBoundingClientRect();
const sx = (ev.clientX - rect.left) * (canvas.width / rect.width);
const sy = (ev.clientY - rect.top) * (canvas.height / rect.height);
const wx = sx + state.camX;
const hits = [];
(state.birds || []).forEach((b, i) => {
if (Math.abs(wx - b.x) < 20 && Math.abs(sy - b.y) < 10)
hits.push({type: 'bird#' + i, x: Math.round(b.x), y: Math.round(b.y), vx: Math.round(b.vx)});
});
(state.ships || []).forEach((s, i) => {
if (wx >= s.x && wx <= s.x + (s.w||40) && sy >= s.y - (s.mastH||0) && sy <= s.y + 20)
hits.push({type: s.kind + '#' + i, x: Math.round(s.x), y: Math.round(s.y), mastH: s.mastH||0});
});
if (state.highway) {
(state.highway.gantries || []).forEach((g, i) => {
if (Math.abs(wx - g.x) < g.w/2 + 10 && sy >= g.topY - 10 && sy <= g.botY + 10)
hits.push({type: 'gantry#' + i, x: Math.round(g.x), topY: Math.round(g.topY), w: g.w});
});
(state.highway.vehicles || []).forEach((v, i) => {
if (Math.abs(wx - v.x) < 20 && Math.abs(sy - v.y) < 15)
hits.push({type: 'vehicle#' + i, x: Math.round(v.x), y: Math.round(v.y)});
});
}
(state.cables || []).forEach((c, i) => {
const t = (wx - c.x1) / (c.x2 - c.x1);
if (t < 0 || t > 1) return;
const cy = c.y1 + t * (c.y2 - c.y1);
if (Math.abs(sy - cy) < 8) hits.push({type: 'cable#' + i, y1: c.y1, y2: c.y2});
});
if (state.secondHeli) {
const s = state.secondHeli;
if (Math.abs(wx - s.x) < 30 && Math.abs(sy - s.y) < 20)
hits.push({type: 'secondHeli', x: Math.round(s.x), y: Math.round(s.y)});
}
(state.bgHelis || []).forEach((s, i) => {
if (Math.abs(wx - s.x) < 30 && Math.abs(sy - s.y) < 20)
hits.push({type: 'bgHeli#' + i + '(' + s.variant + ')', x: Math.round(s.x), y: Math.round(s.y)});
});
const elH = document.getElementById('inspectHits');
if (!hits.length) {
elH.innerHTML = '<div style="color:#6b7d8f;font-style:italic">nichts an x=' + Math.round(wx) + ', y=' + Math.round(sy) + '</div>';
} else {
elH.innerHTML = hits.map(h => {
return `<div style="border-left:3px solid #2d5434;padding:4px 8px;margin-bottom:4px;background:rgba(0,0,0,.03)">
<b>${h.type}</b><br>
<span style="color:#6b7d8f">${JSON.stringify(h).slice(0,120)}</span>
</div>`;
}).join('');
}
});
}
function drawDistantRange(offsetX, color, baseY){
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(0, H);
let lastY = H;
const step = 30;
for(let x=-step; x<W+step; x+=step){
const wx = x + offsetX * 0.5;
const y = baseY + Math.sin(wx * 0.004) * 60 + Math.sin(wx * 0.0015) * 40 + noise1(wx * 0.005) * 30;
if(x === -step) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
lastY = y;
}
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath(); ctx.fill();
}
function drawMountains(cx){
if(!state.mountains) return;
// Bei Wasser- und Autobahn-Szenarien: keine Berge zeichnen, nur flacher Boden
if (state.isWater || state.isHighway) {
ctx.fillStyle = C.valleyGrass;
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
ctx.fillStyle = C.valleyDark;
ctx.fillRect(0, GROUND_Y, W, 4);
if (state.isWater) {
// Wellen
const t = performance.now() / 1000;
ctx.strokeStyle = 'rgba(255,255,255,.55)';
ctx.lineWidth = 1.4;
for (let band = 0; band < 3; band++) {
const yBase = GROUND_Y + 8 + band * 14;
const phase = t * (1.2 + band * 0.4) + band * 1.1;
ctx.beginPath();
for (let x = 0; x <= W; x += 8) {
const y = yBase + Math.sin((x/60) + phase) * 2.5;
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
// Schiffe
if (state.ships) for (const s of state.ships) drawShip(s, cx);
}
if (state.isHighway) {
drawHighway(cx);
}
return;
}
ctx.fillStyle = C.mountain;
ctx.beginPath();
ctx.moveTo(-10, H);
const step = 6;
for(let sx = -step; sx < W + step; sx += step){
const wx = sx + cx;
const groundH = state.mountains.heightFn(wx);
const y = GROUND_Y - groundH;
ctx.lineTo(sx, y);
}
ctx.lineTo(W + 10, H);
ctx.closePath();
ctx.fill();
// Snow caps (where height > 380)
ctx.fillStyle = C.mountainSnow;
for(let sx = -step; sx < W + step; sx += step){
const wx = sx + cx;
const groundH = state.mountains.heightFn(wx);
if(groundH > 380){
const y = GROUND_Y - groundH;
const intensity = Math.min(1, (groundH - 380) / 80);
ctx.fillRect(sx, y, step + 1, 8 * intensity);
}
}
// Shadow on lee side (right side of peaks)
ctx.fillStyle = 'rgba(31,42,36,.18)';
for(const p of state.mountains.peaks){
const sx = p.x - cx;
if(sx < -200 || sx > W + 200) continue;
ctx.beginPath();
ctx.moveTo(sx, GROUND_Y - p.h);
for(let dx = 5; dx < p.w; dx += 8){
const wx = p.x + dx;
const groundH = state.mountains.heightFn(wx);
ctx.lineTo(wx - cx, GROUND_Y - groundH);
}
ctx.lineTo(sx + p.w, GROUND_Y);
ctx.lineTo(sx, GROUND_Y);
ctx.closePath(); ctx.fill();
}
// Ground line
ctx.fillStyle = C.valleyGrass;
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
ctx.fillStyle = C.valleyDark;
ctx.fillRect(0, GROUND_Y, W, 4);
}
function drawIntermediatePad(cx){
const p = state.intermediatePad;
if(!p || p.atClinic) return; // smoke at clinic is rendered via drawClinic
const sx = p.x - cx;
if(sx < -200 || sx > W + 100) return;
// Pad surface
ctx.fillStyle = C.helipad;
ctx.fillRect(sx, p.y, p.w, 6);
ctx.fillStyle = C.cream;
ctx.font = 'bold 16px system-ui';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('H', sx + p.w/2, p.y - 9);
ctx.strokeStyle = C.cream; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(sx + p.w/2, p.y - 9, 14, 0, Math.PI*2); ctx.stroke();
// Smoke flare next to pad
drawSmokeFlare(sx + p.w + 12, p.y);
}
function drawSmokeFlare(sx, baseY){
// Flare stick
ctx.fillStyle = '#c85c4a';
ctx.fillRect(sx - 1, baseY - 18, 3, 18);
// Smoke plume rising
const t = performance.now() * 0.001;
for(let i=0; i<8; i++){
const a = 0.55 - i * 0.06;
const dy = -i * 11 - (t * 25) % 11;
const dx = Math.sin(t * 1.8 + i * 0.7) * 4;
const r = 5 + i * 1.5;
ctx.fillStyle = `rgba(245,239,224,${a})`;
ctx.beginPath();
ctx.arc(sx + dx, baseY - 14 + dy, r, 0, Math.PI*2);
ctx.fill();
}
// Bright orange near base
ctx.fillStyle = 'rgba(231,143,80,.7)';
ctx.beginPath();
ctx.arc(sx, baseY - 16, 3, 0, Math.PI*2);
ctx.fill();
}
function drawDisembark(cx){
const d = state.disembark;
const sx = d.x - cx;
const baseY = d.y - 4;
// Two figures walk from heli toward clinic entrance (right of pad)
const progress = clamp(d.t / d.duration, 0, 1);
const walkOffset = progress * 60; // walk 60 px to the right
const bob = Math.sin(d.t * 8) * 1.5; // walking bob
// Rescuer (front)
const rx = sx + 14 + walkOffset;
drawPersonOnGround(rx, baseY + bob, '#3d5165', '#e4b95c');
// Patient (helped, slightly behind)
const px = sx + 24 + walkOffset;
drawPersonOnGround(px, baseY - bob, '#c85c4a', '#e4b95c');
// Optional: small label
if(d.t < 1.5){
ctx.fillStyle = 'rgba(45,84,52,.85)';
ctx.font = 'bold 11px system-ui';
ctx.textAlign = 'center';
ctx.fillText('Patient verlässt den Hubschrauber', sx + 30, baseY - 30);
}
}
function drawClinic(cx){
const p = state.helipadEnd;
const sx = p.x - cx;
if(sx < -200 || sx > W + 100) return;
// Wasser-Szene: Uferstrand vor der Klinik, damit sichtbar wird wo der See endet
if (state.isWater) {
const beachStart = sx - 220;
const beachEnd = sx + p.w + 80;
// Sand-Streifen
ctx.fillStyle = '#e8d9b4';
ctx.beginPath();
ctx.moveTo(beachStart, GROUND_Y);
ctx.quadraticCurveTo((beachStart + beachEnd)/2, GROUND_Y - 6, beachEnd, GROUND_Y);
ctx.lineTo(W + 100, GROUND_Y);
ctx.lineTo(W + 100, H);
ctx.lineTo(beachStart, H);
ctx.closePath(); ctx.fill();
// Wiesenboden hinter Strand
ctx.fillStyle = '#88aa7e';
ctx.fillRect(sx - 80, GROUND_Y - 2, p.w + 200, H - GROUND_Y);
}
// Autobahn-Szene: kleine Zugangsstraße vom Asphalt-Ende zur Klinik
if (state.isHighway) {
const asphaltEndScreen = (state._asphaltEndX || 0) - cx;
// Zufahrtsstraße zwischen Autobahnende und Klinikgebäude (grau)
if (asphaltEndScreen > -100 && asphaltEndScreen < W + 50) {
ctx.fillStyle = '#8a8e94';
ctx.fillRect(asphaltEndScreen + 50, GROUND_Y + 6, (sx - (asphaltEndScreen + 50)), 4);
}
// Grasland rund um die Klinik (deckt eventuelle Reste)
ctx.fillStyle = '#88aa7e';
ctx.fillRect(sx - 100, GROUND_Y - 2, p.w + 200, H - GROUND_Y);
}
// Building below the helipad
ctx.fillStyle = '#f5efe0';
ctx.fillRect(sx, p.y, p.w, GROUND_Y - p.y);
ctx.fillStyle = '#c85c4a';
ctx.fillRect(sx, p.y, p.w, 8);
// Cross
ctx.fillStyle = '#c85c4a';
ctx.fillRect(sx + p.w/2 - 4, p.y + 18, 8, 24);
ctx.fillRect(sx + p.w/2 - 12, p.y + 26, 24, 8);
// Helipad line
ctx.fillStyle = C.helipad;
ctx.fillRect(sx, p.y - 3, p.w, 4);
ctx.fillStyle = C.cream;
ctx.font = 'bold 16px system-ui';
ctx.textAlign = 'center';
ctx.fillText('H', sx + p.w/2, p.y + 12);
// Label
ctx.fillStyle = C.slate;
ctx.font = 'bold 11px system-ui';
ctx.fillText('KLINIK', sx + p.w/2, p.y + 60);
// Smoke signal if intermediate landing is at clinic
if(state.intermediatePad && state.intermediatePad.atClinic && state.intermediateRequired
&& (state.phase === 'rescuer_working' || state.phase === 'approach1' || state.phase === 'hover_drop')){
drawSmokeFlare(sx + p.w + 12, p.y);
}
}
function drawCable(c, cx){
const x1 = c.x1 - cx, y1 = c.y1;
const x2 = c.x2 - cx, y2 = c.y2;
if(Math.max(x1, x2) < -50 || Math.min(x1, x2) > W + 50) return;
// Stations
ctx.fillStyle = C.cableStation;
ctx.fillRect(c.st1.x - cx - c.st1.w/2, c.st1.y, c.st1.w, c.st1.h);
ctx.fillRect(c.st2.x - cx - c.st2.w/2, c.st2.y, c.st2.w, c.st2.h);
ctx.fillStyle = '#5b4a3a';
ctx.fillRect(c.st1.x - cx - c.st1.w/2 - 2, c.st1.y - 4, c.st1.w + 4, 5);
ctx.fillRect(c.st2.x - cx - c.st2.w/2 - 2, c.st2.y - 4, c.st2.w + 4, 5);
// Cable (slight catenary)
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2 + 12;
ctx.strokeStyle = C.cable; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(midX, midY, x2, y2);
ctx.stroke();
// Highlight pixel
ctx.strokeStyle = 'rgba(245,239,224,.3)'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x1, y1 - 1);
ctx.quadraticCurveTo(midX, midY - 1, x2, y2 - 1);
ctx.stroke();
}
function drawHighwayVehicle(sx, baseY, w, color, smashed){
// 2D-Seitenansicht eines PKW: Karosserie + Dach + zwei Räder + Fenster
const bodyH = 10, roofH = 9;
const bodyY = baseY - 8 - bodyH;
const roofY = bodyY - roofH + 1;
const darken = (c, amt) => c; // Platzhalter — wir nutzen feste Schatten
ctx.save();
if (smashed) {
// Leicht gekippt zum Zeichen: Rotation vom Zentrum
ctx.translate(sx + w/2, baseY - 6);
ctx.rotate(-0.15 + Math.random()*0.04);
ctx.translate(-w/2, -(baseY - 6 - (baseY - bodyY)));
sx = 0;
}
// Karosserie (Hauptkörper)
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(sx + 3, bodyY + bodyH);
ctx.lineTo(sx + 3, bodyY + 2);
ctx.lineTo(sx + 8, bodyY);
ctx.lineTo(sx + w - 8, bodyY);
ctx.lineTo(sx + w - 3, bodyY + 2);
ctx.lineTo(sx + w - 3, bodyY + bodyH);
ctx.closePath(); ctx.fill();
// Dach/Fahrgastzelle (schmaler, höher)
if (!smashed || Math.random() < 0.7) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(sx + w * 0.22, bodyY);
ctx.lineTo(sx + w * 0.30, roofY);
ctx.lineTo(sx + w * 0.70, roofY);
ctx.lineTo(sx + w * 0.78, bodyY);
ctx.closePath(); ctx.fill();
// Fenster (hellblau)
ctx.fillStyle = smashed ? '#3d5165' : '#a8c3d6';
ctx.fillRect(sx + w * 0.32, roofY + 2, w * 0.16, roofH - 4);
ctx.fillRect(sx + w * 0.52, roofY + 2, w * 0.16, roofH - 4);
} else {
// Dach komplett eingedrückt: nur Wanne sichtbar
ctx.fillStyle = '#1f2a24';
ctx.fillRect(sx + w * 0.25, bodyY - 2, w * 0.5, 4);
}
// Kotflügel-Linie
ctx.fillStyle = 'rgba(0,0,0,.2)';
ctx.fillRect(sx + 3, bodyY + bodyH - 2, w - 6, 2);
// Räder (zwei dunkle Kreise)
ctx.fillStyle = '#1f2a24';
ctx.beginPath(); ctx.arc(sx + w * 0.20, baseY - 4, 4.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx + w * 0.80, baseY - 4, 4.5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#6b7280';
ctx.beginPath(); ctx.arc(sx + w * 0.20, baseY - 4, 2, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx + w * 0.80, baseY - 4, 2, 0, Math.PI*2); ctx.fill();
if (smashed) {
// Motorhaube aufgerissen (Dreieck)
ctx.fillStyle = '#3a2a20';
ctx.beginPath();
ctx.moveTo(sx + 4, bodyY + 2);
ctx.lineTo(sx + w * 0.25, bodyY - 4);
ctx.lineTo(sx + w * 0.22, bodyY + bodyH);
ctx.closePath(); ctx.fill();
// Splitter
ctx.fillStyle = '#8a8a8a';
for (let i = 0; i < 4; i++) {
ctx.fillRect(sx - 6 + i * 10, baseY - 2, 2, 2);
}
}
ctx.restore();
}
function drawShip(s, cx){
const sx = s.x - cx;
if (sx < -80 || sx > W + 80) return;
const hullY = s.y;
// Rumpf: längliches Trapez unten breit, oben schmal
ctx.fillStyle = '#f5efe0';
ctx.beginPath();
ctx.moveTo(sx + 4, hullY);
ctx.lineTo(sx + s.w - 4, hullY);
ctx.lineTo(sx + s.w - 10, hullY + 10);
ctx.lineTo(sx + 10, hullY + 10);
ctx.closePath(); ctx.fill();
// Wasserlinie
ctx.fillStyle = '#3d5165';
ctx.fillRect(sx + 6, hullY + 9, s.w - 12, 1.5);
if (s.kind === 'sail' || s.kind === 'tall-sail') {
// Segelboot: vertikaler Mast + zwei dreieckige Segel
const mastX = sx + s.w * 0.45;
const mastH = s.mastH || 38;
const mastTop = hullY - mastH;
const sailW = s.kind === 'tall-sail' ? 42 : 18;
ctx.strokeStyle = '#5b4a3a'; ctx.lineWidth = s.kind === 'tall-sail' ? 2 : 1.5;
ctx.beginPath(); ctx.moveTo(mastX, hullY); ctx.lineTo(mastX, mastTop); ctx.stroke();
// Großsegel rechts vom Mast
ctx.fillStyle = '#f5efe0';
ctx.strokeStyle = '#8a8672'; ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(mastX, mastTop + 2);
ctx.lineTo(mastX + sailW, hullY - 2);
ctx.lineTo(mastX, hullY - 2);
ctx.closePath(); ctx.fill(); ctx.stroke();
// Fock links vom Mast (kleiner)
ctx.fillStyle = '#e8e4d8';
ctx.beginPath();
ctx.moveTo(mastX, mastTop + Math.max(6, mastH * 0.15));
ctx.lineTo(mastX - Math.max(12, sailW * 0.6), hullY - 2);
ctx.lineTo(mastX, hullY - 2);
ctx.closePath(); ctx.fill(); ctx.stroke();
// Warnflagge oben auf hohen Segelschiffen
if (s.kind === 'tall-sail') {
ctx.fillStyle = '#c85c4a';
ctx.fillRect(mastX + 1, mastTop, 10, 5);
}
} else {
// Motorboot: Kabine + Außenbordmotor-Andeutung
ctx.fillStyle = '#c85c4a';
ctx.fillRect(sx + s.w * 0.25, hullY - 8, s.w * 0.4, 8);
ctx.fillStyle = '#3d5165';
ctx.fillRect(sx + s.w * 0.28, hullY - 7, s.w * 0.15, 5); // Fenster
ctx.fillRect(sx + s.w * 0.48, hullY - 7, s.w * 0.15, 5);
ctx.fillStyle = '#1f2a24';
ctx.fillRect(sx + s.w - 8, hullY - 3, 4, 6); // Motor am Heck
}
}
function drawHighway(cx){
const hw = state.highway;
if (!hw) return;
// Asphalt-Band endet vor der Klinik, danach Grasland
const asphaltTop = GROUND_Y - 4;
const asphaltEndWorld = state._asphaltEndX || (WORLD_W - 400);
const asphaltEndScreen = asphaltEndWorld - cx;
// Grasland unter der ganzen Szene (zuerst, damit Asphalt oben drauf liegt)
ctx.fillStyle = '#88aa7e';
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
// Asphalt-Band nur bis asphaltEndScreen
if (asphaltEndScreen > 0) {
const asphWidth = Math.min(asphaltEndScreen, W);
ctx.fillStyle = '#4a4e54';
ctx.fillRect(0, asphaltTop, asphWidth, H - asphaltTop);
// Fahrbahnmarkierungen (mittlere Striche)
ctx.strokeStyle = '#e8c547';
ctx.lineWidth = 2;
ctx.setLineDash([18, 14]);
ctx.beginPath();
ctx.moveTo(0, GROUND_Y + 6);
ctx.lineTo(asphWidth, GROUND_Y + 6);
ctx.stroke();
ctx.setLineDash([]);
// Leitplanken nur über dem Asphalt
ctx.fillStyle = '#a8afb5';
ctx.fillRect(0, asphaltTop - 2, asphWidth, 2);
ctx.fillRect(0, GROUND_Y + 20, asphWidth, 2);
// Autobahn-Ausfahrt-Kurve am Ende
if (asphaltEndScreen < W) {
ctx.fillStyle = '#4a4e54';
ctx.beginPath();
ctx.moveTo(asphaltEndScreen, asphaltTop - 2);
ctx.lineTo(asphaltEndScreen + 50, GROUND_Y + 20);
ctx.lineTo(asphaltEndScreen, GROUND_Y + 20);
ctx.closePath(); ctx.fill();
}
}
// Polizeisperren (Blaulicht-Fahrzeuge und Sperrbalken)
for (const pos of [hw.sperrLeft, hw.sperrRight]) {
const sx = pos - cx;
if (sx > -40 && sx < W + 40) {
// Fahrzeug
ctx.fillStyle = '#3d5165';
ctx.fillRect(sx - 20, GROUND_Y - 22, 40, 18);
// Blaulicht-Blink
const blink = Math.floor(performance.now() / 300) % 2 === 0;
ctx.fillStyle = blink ? '#4a7c8a' : '#c85c4a';
ctx.fillRect(sx - 14, GROUND_Y - 28, 28, 6);
// Sperrbalken über Fahrbahn
ctx.fillStyle = '#c85c4a';
ctx.fillRect(sx - 24, GROUND_Y - 4, 48, 3);
ctx.fillStyle = '#f5efe0';
ctx.fillRect(sx - 22, GROUND_Y - 3, 8, 1.5);
ctx.fillRect(sx - 6, GROUND_Y - 3, 8, 1.5);
ctx.fillRect(sx + 10, GROUND_Y - 3, 8, 1.5);
}
}
// Schilderbrücken (Gantries): zwei Pfeiler + querer Balken
for (const g of hw.gantries) {
const sx = g.x - cx;
if (sx < -100 || sx > W + 100) continue;
// Linker Pfeiler
ctx.fillStyle = '#7a8289';
ctx.fillRect(sx - g.w/2 - 6, g.botY, 6, GROUND_Y - g.botY - 4);
// Rechter Pfeiler (nahe, weiter unten)
ctx.fillRect(sx + g.w/2, g.botY, 6, GROUND_Y - g.botY - 4);
// Querbalken
ctx.fillStyle = '#5d656b';
ctx.fillRect(sx - g.w/2 - 6, g.topY, g.w + 12, g.botY - g.topY);
// Schilder
ctx.fillStyle = '#3a6b3e';
ctx.fillRect(sx - g.w/2 + 2, g.topY + 8, g.w/2 - 4, (g.botY - g.topY) - 16);
ctx.fillRect(sx + 2, g.topY + 8, g.w/2 - 4, (g.botY - g.topY) - 16);
}
// Fahrzeuge (Stau + Unfallwrack)
for (const v of hw.vehicles) {
const sx = v.x - cx;
if (sx < -80 || sx > W + 80) continue;
drawHighwayVehicle(sx, GROUND_Y - 2, v.w, v.color, v.smashed);
}
// Gaffer (mit leichtem Geh-Schwingen wenn nicht in Pause)
for (let i = 0; i < hw.gawkers.length; i++) {
const g = hw.gawkers[i];
const sx = g.x - cx;
if (sx < -20 || sx > W + 20) continue;
const walking = g.pauseT <= 0;
const wob = walking ? Math.sin(performance.now() * 0.012 + i * 0.7) * 0.6 : 0;
ctx.fillStyle = '#c97a3f';
ctx.fillRect(sx - 2 + wob, GROUND_Y - 14, 4, 10);
ctx.fillStyle = '#e4b95c';
ctx.beginPath(); ctx.arc(sx + wob, GROUND_Y - 17, 3, 0, Math.PI*2); ctx.fill();
}
// Einsatzwagen (Polizei oder Rettung) mit Blaulicht-Blink
if (hw.emergencyVehicle && hw.emergencyVehicle.x > -1000) {
const ev = hw.emergencyVehicle;
const sx = ev.x - cx;
if (sx > -100 && sx < W + 100) {
const baseY = ev.y;
const isPolizei = ev.kind === 'polizei';
const body = isPolizei ? '#3d5165' : '#c85c4a';
const trim = isPolizei ? '#e4b95c' : '#f5efe0';
// Karosserie
ctx.fillStyle = body;
ctx.fillRect(sx, baseY, ev.w, 16);
ctx.fillStyle = trim;
ctx.fillRect(sx + 2, baseY + 5, ev.w - 4, 4);
// Dach
ctx.fillStyle = body;
ctx.fillRect(sx + 12, baseY - 8, ev.w - 24, 8);
// Fenster
ctx.fillStyle = '#a8c3a0';
ctx.fillRect(sx + 14, baseY - 6, ev.w - 28, 5);
// Blaulicht / Rotlicht (blinkend)
const phase = (Math.sin(ev.blink) + 1) * 0.5;
const lightL = isPolizei ? `rgba(60,120,255,${0.5 + 0.5*phase})` : `rgba(220,60,50,${0.5 + 0.5*phase})`;
const lightR = isPolizei ? `rgba(220,60,50,${0.5 + 0.5*(1-phase)})` : `rgba(232,181,92,${0.5 + 0.5*(1-phase)})`;
ctx.fillStyle = lightL;
ctx.fillRect(sx + 18, baseY - 12, 8, 4);
ctx.fillStyle = lightR;
ctx.fillRect(sx + ev.w - 26, baseY - 12, 8, 4);
// Räder
ctx.fillStyle = '#1f2a24';
ctx.beginPath(); ctx.arc(sx + 12, baseY + 16, 5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx + ev.w - 12, baseY + 16, 5, 0, Math.PI*2); ctx.fill();
// Label
ctx.fillStyle = '#fff';
ctx.font = 'bold 7px system-ui';
ctx.textAlign = 'center';
ctx.fillText(isPolizei ? 'POLIZEI' : 'RETTUNG', sx + ev.w/2, baseY + 13);
}
}
// Landezone als Rechteck — Farbe signalisiert aktuelle Sinkrate
const lz = hw.landingZone;
const lzSx = lz.x - cx;
const blink = 0.5 + 0.5 * Math.sin(performance.now() * 0.004);
const hx = state.heli.x, hy = state.heli.y;
const overZone = hx >= lz.x && hx <= lz.x + lz.w && hy < GROUND_Y - 10;
const sinkRate = state.heli.vy;
let lzR = 74, lzG = 124, lzB = 78; // default: grün
let lzLabel = 'LANDEZONE';
if (overZone) {
if (sinkRate > 220) { lzR=200; lzG=92; lzB=74; lzLabel='ZU HART!'; }
else if (sinkRate > 170) { lzR=232; lzG=181; lzB=92; lzLabel='SANFTER!'; }
else if (sinkRate > 40) { lzR=74; lzG=124; lzB=78; lzLabel='GUTES TEMPO'; }
else { lzLabel='ANFLUG — TIEFER'; }
}
ctx.fillStyle = `rgba(${lzR},${lzG},${lzB},${0.35 + 0.35 * blink})`;
ctx.fillRect(lzSx, GROUND_Y - 22, lz.w, 22);
ctx.strokeStyle = `rgba(${lzR},${lzG},${lzB},0.95)`;
ctx.lineWidth = 3;
ctx.strokeRect(lzSx, GROUND_Y - 22, lz.w, 22);
ctx.fillStyle = '#2d5434';
ctx.font = 'bold 12px system-ui';
ctx.textAlign = 'center';
ctx.fillText(lzLabel, lzSx + lz.w/2, GROUND_Y - 6);
// Sinkrate-Anzeige neben dem Heli, solange er über der Zone schwebt
if (overZone) {
const hsx = hx - cx;
const sv = Math.max(0, Math.min(1, sinkRate / 220)); // 0..1 (0 = still, 1 = zu schnell)
const barX = hsx + 28, barY = hy - 20;
ctx.fillStyle = 'rgba(245,239,224,.85)';
ctx.fillRect(barX, barY, 10, 50);
ctx.fillStyle = sinkRate > 220 ? '#c85c4a' : sinkRate > 170 ? '#e4b95c' : '#4a7c4e';
const h2 = sv * 50;
ctx.fillRect(barX, barY + 50 - h2, 10, h2);
ctx.fillStyle = '#3d5165';
ctx.font = 'bold 10px system-ui';
ctx.textAlign = 'left';
ctx.fillText('Sink', barX + 13, barY + 10);
ctx.fillText(Math.round(sinkRate/10) + ' m/s', barX + 13, barY + 24);
}
}
function drawPatient(cx){
const p = state.patient;
const sx = p.x - cx;
if(sx < -50 || sx > W + 50) return;
if(!p.rescued){
// Patient lying down
ctx.fillStyle = '#c85c4a';
ctx.fillRect(sx - 8, p.y - 6, 16, 5); // body
ctx.fillStyle = '#e4b95c';
ctx.fillRect(sx - 10, p.y - 7, 4, 4); // helmet/head
// Smoke flare
const flareY = p.y - 14;
ctx.fillStyle = '#c85c4a';
ctx.fillRect(sx + 12, flareY, 3, 14);
// Smoke plume
const t = performance.now() * 0.001;
for(let i=0; i<5; i++){
const a = 0.5 - i * 0.08;
const dy = -i * 8 - (t * 30) % 8;
const dx = Math.sin(t * 2 + i) * 3;
ctx.fillStyle = `rgba(200,92,74,${a})`;
ctx.beginPath();
ctx.arc(sx + 13 + dx, flareY + dy, 4 + i, 0, Math.PI*2);
ctx.fill();
}
}
}
function drawPersonOnGround(sx, y, body, head){
ctx.fillStyle = body;
ctx.fillRect(sx - 3, y - 12, 6, 10);
ctx.fillStyle = head;
ctx.fillRect(sx - 3, y - 16, 6, 4);
}
function drawHoverRing(cx){
// Autobahn hat keinen Schwebeflug, nur echte Landung → kein Hover-Ring
if (state.isHighway) return;
const p = state.patient;
const sx = p.x - cx;
const targetY = p.y - 90;
const r = state.level.hoverRadius;
const inProgress = state.phase === 'hover_drop' || state.phase === 'hover_pickup';
const color = inProgress ? '#2d5434' : '#c97a3f';
const alpha = 0.6 + Math.sin(performance.now()*0.005) * 0.2;
ctx.strokeStyle = color;
ctx.globalAlpha = alpha;
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.arc(sx, targetY, r, 0, Math.PI*2);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
// Crosshair
ctx.strokeStyle = color; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(sx - 8, targetY); ctx.lineTo(sx + 8, targetY);
ctx.moveTo(sx, targetY - 8); ctx.lineTo(sx, targetY + 8);
ctx.stroke();
// Label
ctx.fillStyle = color;
ctx.font = 'bold 10px system-ui';
ctx.textAlign = 'center';
ctx.fillText('SCHWEBEFLUG-ZIEL', sx, targetY - r - 6);
// Progress arc when in zone
if(inProgress && state.phaseProgress > 0){
ctx.strokeStyle = '#2d5434';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(sx, targetY, r + 6, -Math.PI/2, -Math.PI/2 + state.phaseProgress * Math.PI*2);
ctx.stroke();
}
}
function drawBirds(b, cx){
const sx = b.x - cx;
if(sx < -100 || sx > W + 100) return;
for(const m of b.members){
const px = sx + m.dx;
const py = b.y + m.dy + Math.sin(m.phase) * 2;
ctx.strokeStyle = C.bird;
ctx.lineWidth = 1.5;
const flap = Math.sin(m.phase) * 0.4;
ctx.beginPath();
ctx.moveTo(px - 6, py + flap*3);
ctx.lineTo(px, py - flap*2);
ctx.lineTo(px + 6, py + flap*3);
ctx.stroke();
}
}
function drawBgHeli(b, cx){
const sx = b.x - cx;
if(sx < -200 || sx > W + 200) return;
const prevAlpha = ctx.globalAlpha;
ctx.globalAlpha = b.alpha;
b.tilt = clamp(b.vx * 0.001, -0.12, 0.12);
drawHeli(b, b.variant, cx);
// Label
ctx.fillStyle = 'rgba(61,81,101,.6)';
ctx.font = '9px system-ui';
ctx.textAlign = 'center';
ctx.fillText(b.variant === 'presse' ? 'PRESSE' : 'POLIZEI', sx, b.y + 30);
ctx.globalAlpha = prevAlpha;
}
function drawSecondHeli(cx){
const s = state.secondHeli;
const sx = s.x - cx;
if(sx < -120 || sx > W + 120) return;
// tilt slightly into direction of motion
s.tilt = clamp(s.vx * 0.001, -0.15, 0.15);
s.facing = s.dir >= 0 ? 1 : -1;
s.rotor += 0.4;
s.rotorSpeed = 100;
drawHeli(s, 'polizei', cx);
// Label
ctx.fillStyle = 'rgba(61,81,101,.7)';
ctx.font = '9px system-ui';
ctx.textAlign = 'center';
ctx.fillText('POLIZEI', sx, s.y + 30);
}
function drawHeli(heli, variantKey, cx){
const h = heli;
const v = HELI_VARIANTS[variantKey];
const sx = h.x - cx;
ctx.save();
ctx.translate(sx, h.y);
const blinkVisible = (h.invul || 0) > 0 ? (Math.floor(performance.now()/80) % 2 === 0) : true;
ctx.scale(h.facing || 1, 1);
ctx.rotate((h.tilt || 0) * (h.facing || 1));
if(!blinkVisible) ctx.globalAlpha = 0.35;
// shadow
ctx.fillStyle = 'rgba(31,42,36,.08)';
ctx.beginPath(); ctx.ellipse(4, 22, 40, 5, 0, 0, Math.PI*2); ctx.fill();
// skids
ctx.strokeStyle = C.rotor; ctx.lineWidth = 2.5; ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(-26, 18); ctx.lineTo(26, 18);
ctx.moveTo(-18, 18); ctx.lineTo(-18, 10);
ctx.moveTo(18, 18); ctx.lineTo(18, 10);
ctx.stroke();
// tail boom
ctx.fillStyle = v.body;
ctx.beginPath();
ctx.moveTo(-6, -2); ctx.lineTo(-50, -4); ctx.lineTo(-52, 2); ctx.lineTo(-6, 6);
ctx.closePath(); ctx.fill();
// tail fin
ctx.fillStyle = v.belly;
ctx.beginPath();
ctx.moveTo(-50, -4); ctx.lineTo(-58, -14); ctx.lineTo(-50, 2);
ctx.closePath(); ctx.fill();
const rpm = h.rotorSpeed;
// tail rotor face-on
ctx.save();
ctx.translate(-54, -6);
const discAlpha = 0.10 + Math.min(0.25, rpm*0.006);
ctx.fillStyle = `rgba(31,42,36,${discAlpha})`;
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = 'rgba(31,42,36,.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI*2); ctx.stroke();
ctx.rotate(h.rotor * 0.6);
const bladeAlpha = rpm > 30 ? 0.45 : 0.85;
ctx.fillStyle = `rgba(31,42,36,${bladeAlpha})`;
ctx.fillRect(-8, -0.9, 16, 1.8);
ctx.fillRect(-0.9, -8, 1.8, 16);
ctx.fillStyle = v.accent;
ctx.beginPath(); ctx.arc(0, 0, 1.5, 0, Math.PI*2); ctx.fill();
ctx.restore();
// body
ctx.fillStyle = v.body;
ctx.beginPath();
ctx.moveTo(-12, -12);
ctx.quadraticCurveTo(0, -18, 20, -10);
ctx.quadraticCurveTo(30, -4, 28, 10);
ctx.lineTo(-6, 12);
ctx.quadraticCurveTo(-14, 10, -14, 2);
ctx.closePath(); ctx.fill();
ctx.fillStyle = v.belly;
ctx.beginPath();
ctx.moveTo(-10, 6); ctx.quadraticCurveTo(0, 14, 20, 10); ctx.lineTo(28, 10); ctx.lineTo(-6, 12);
ctx.closePath(); ctx.fill();
ctx.fillStyle = C.glass;
ctx.beginPath();
ctx.moveTo(6, -10); ctx.quadraticCurveTo(22, -10, 26, -2); ctx.lineTo(26, 6); ctx.lineTo(6, 6);
ctx.closePath(); ctx.fill();
ctx.fillStyle = 'rgba(245,239,224,.35)';
ctx.beginPath(); ctx.moveTo(10,-8); ctx.quadraticCurveTo(18,-9,22,-4); ctx.lineTo(10,-4); ctx.closePath(); ctx.fill();
if(h.flash > 0){
ctx.fillStyle = `rgba(200,92,74,${h.flash*0.5})`;
ctx.beginPath();
ctx.moveTo(-12, -12);
ctx.quadraticCurveTo(0, -18, 20, -10);
ctx.quadraticCurveTo(30, -4, 28, 10);
ctx.lineTo(-6, 12);
ctx.quadraticCurveTo(-14, 10, -14, 2);
ctx.closePath(); ctx.fill();
}
// Accent stripe (variant color)
ctx.fillStyle = v.accent;
ctx.fillRect(-6, 0, 18, 3);
// Variant identifier mark on body
ctx.save();
if((h.facing || 1) === -1) ctx.scale(-1,1);
ctx.fillStyle = v.accent;
ctx.font = 'bold 6px system-ui';
ctx.textAlign = 'center';
ctx.fillText(variantKey === 'oamtc' ? '★' :
variantKey === 'ara' ? '+' :
variantKey === 'polizei' ? '★' : '✚',
(h.facing || 1) === -1 ? 0 : 6, -2);
ctx.restore();
ctx.fillStyle = C.rotor;
ctx.fillRect(6, -16, 4, 6);
// main rotor edge-on flicker
ctx.save();
ctx.translate(8, -17);
if(rpm > 20){
const f1 = Math.sin(performance.now()*0.09);
const f2 = Math.sin(performance.now()*0.23);
const flick = (f1 + f2) * 0.5;
const alpha = 0.45 + Math.abs(flick)*0.35;
ctx.fillStyle = 'rgba(31,42,36,.10)';
ctx.beginPath(); ctx.ellipse(0, 0, 50, 1.2, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = `rgba(31,42,36,${alpha})`;
ctx.fillRect(-50, -0.8, 100, 1.6);
ctx.fillStyle = `rgba(31,42,36,${alpha*0.45})`;
ctx.fillRect(-48, -2.2 + Math.sin(performance.now()*0.32)*0.6, 96, 0.7);
ctx.fillStyle = `rgba(31,42,36,${alpha*0.35})`;
ctx.fillRect(-46, 1.2 + Math.sin(performance.now()*0.27)*0.5, 92, 0.6);
} else if(rpm > 6){
ctx.fillStyle = 'rgba(31,42,36,.35)';
ctx.fillRect(-48, -1, 96, 2);
const off = Math.sin(h.rotor)*35;
ctx.fillStyle = 'rgba(31,42,36,.6)';
ctx.fillRect(off-16, -0.8, 32, 1.6);
} else {
ctx.fillStyle = C.rotor;
ctx.fillRect(-48, -1, 96, 2);
ctx.fillRect(-50, -1.5, 6, 3);
ctx.fillRect(44, -1.5, 6, 3);
}
ctx.fillStyle = v.accent;
ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI*2); ctx.fill();
ctx.restore();
ctx.globalAlpha = 1;
ctx.restore();
}
function drawWinchAndLoad(cx){
const w = state.winch;
if(w.length < 2) return;
const h = state.heli;
const sx = h.x - cx;
const angle = state.load.angle;
// Rope endpoint
const ropeX = sx + Math.sin(angle) * w.length;
const ropeY = h.y + Math.cos(angle) * w.length;
// Rope
ctx.strokeStyle = C.rope;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(sx, h.y + 8);
ctx.lineTo(ropeX, ropeY);
ctx.stroke();
// Load: rescuer (alone going down) or rescuer+patient (coming up)
if(state.load.hasRescuer && !state.load.hasPatient){
// Rescuer alone, hanging from harness
ctx.fillStyle = '#3d5165';
ctx.fillRect(ropeX - 3, ropeY, 6, 10);
ctx.fillStyle = '#e4b95c';
ctx.fillRect(ropeX - 3, ropeY - 4, 6, 4);
} else if(state.load.hasRescuer && state.load.hasPatient){
// Bergesack / rescue basket with two people
ctx.fillStyle = C.basket;
ctx.fillRect(ropeX - 7, ropeY, 14, 12);
ctx.fillStyle = '#5b4a3a';
ctx.fillRect(ropeX - 8, ropeY + 10, 16, 2);
// Two heads
ctx.fillStyle = '#e4b95c';
ctx.fillRect(ropeX - 6, ropeY - 3, 4, 4);
ctx.fillStyle = '#c85c4a';
ctx.fillRect(ropeX + 2, ropeY - 3, 4, 4);
}
}
function drawCloud(sx, y, s, alpha){
ctx.fillStyle = `rgba(245,239,224,${alpha || 1})`;
ctx.beginPath();
ctx.arc(sx, y, 22 * s, 0, Math.PI*2);
ctx.arc(sx + 18*s, y - 4, 24 * s, 0, Math.PI*2);
ctx.arc(sx + 38*s, y, 20 * s, 0, Math.PI*2);
ctx.arc(sx + 22*s, y + 8, 22 * s, 0, Math.PI*2);
ctx.fill();
}
function drawLocalWindIndicator(cx){
const h = state.heli;
const w = localWind(h.x, h.y);
const mag = Math.sqrt(w.x*w.x + w.y*w.y);
if(mag < 5) return;
const sx = h.x - cx + 30;
const sy = h.y - 30;
const len = Math.min(40, mag * 0.6);
const ang = Math.atan2(w.y, w.x);
const ex = sx + Math.cos(ang) * len;
const ey = sy + Math.sin(ang) * len;
// Color: green for upward (helpful), red for downward (dangerous)
const color = w.y < -10 ? '#2d5434' : w.y > 10 ? '#c85c4a' : '#c97a3f';
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx, sy); ctx.lineTo(ex, ey);
ctx.stroke();
// Arrow head
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(ang - 0.4) * 6, ey - Math.sin(ang - 0.4) * 6);
ctx.lineTo(ex - Math.cos(ang + 0.4) * 6, ey - Math.sin(ang + 0.4) * 6);
ctx.closePath(); ctx.fill();
}
function drawRadio(){
if(!state.radio.current) return;
const msg = state.radio.current;
const fadeIn = Math.min(1, state.radio.shown / 0.3);
const fadeOut = state.radio.shown > 3.7 ? Math.max(0, 1 - (state.radio.shown - 3.7)/0.5) : 1;
const alpha = fadeIn * fadeOut;
ctx.save();
ctx.globalAlpha = alpha;
ctx.font = '13px system-ui';
const padding = 12;
const metrics = ctx.measureText(msg);
const tw = Math.min(W - 80, metrics.width + padding * 2);
const th = 30;
const rx = (W - tw) / 2;
const ry = H - 70;
ctx.fillStyle = 'rgba(31,42,36,.85)';
if(ctx.roundRect){ ctx.beginPath(); ctx.roundRect(rx, ry, tw, th, 8); ctx.fill(); }
else ctx.fillRect(rx, ry, tw, th);
ctx.fillStyle = C.cream;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(msg, W/2, ry + th/2);
ctx.restore();
}
// ============================================================
// HUD
// ============================================================
function updateHUD(){
const h = state.heli;
const groundH = state.mountains ? state.mountains.heightFn(h.x) : 0;
const groundY = GROUND_Y - groundH;
const alt = Math.max(0, groundY - h.y - 14);
document.getElementById('fAlt').style.width = Math.min(100, alt/6) + '%';
document.getElementById('vAlt').textContent = Math.round(alt/4) + ' m';
const vyPct = Math.max(0, Math.min(100, (Math.abs(h.vy)/300) * 100));
document.getElementById('fVy').style.width = vyPct + '%';
document.getElementById('vVy').textContent = (h.vy > 0 ? '↓' : '↑') + Math.abs(Math.round(h.vy/4)) + ' m/s';
// Speed (px/s → km/h, wie in start.html)
const speed = Math.sqrt(h.vx*h.vx + h.vy*h.vy) / 4;
const fSpdEl = document.getElementById('fSpd');
const vSpdEl = document.getElementById('vSpd');
if (fSpdEl) fSpdEl.style.width = Math.min(100, speed/2) + '%';
if (vSpdEl) vSpdEl.textContent = Math.round(speed) + ' km/h';
document.getElementById('fFuel').style.width = (h.fuel*100) + '%';
document.getElementById('vFuel').textContent = Math.round(h.fuel*100) + ' %';
// Spritverbrauch in Liter/Minute anzeigen (Tank ≈ 700 L bei Full, H135 real 180-240 kg/h)
// Der interne h.fuel wird pro Sekunde um ca. 0.004-0.01 * altMult reduziert
// Grobe Annäherung: aktueller Verbrauch anhand der letzten Fuel-Delta
if (!state._fuelTrack) state._fuelTrack = { lastFuel: h.fuel, lastT: state.t };
const dtT = state.t - state._fuelTrack.lastT;
if (dtT > 0.25) {
const burn = Math.max(0, (state._fuelTrack.lastFuel - h.fuel) / dtT);
// Skalierungsfaktor 1000 kalibriert auf H135-Realwerte:
// Boden (burn≈0.004/s) → ~4 l/min · Flug (0.005) → 5 · Vollschub (0.010) → 10.
const raw = burn * 1000;
state._fuelLitersPerMin = (state._fuelLitersPerMin == null)
? raw
: state._fuelLitersPerMin * 0.85 + raw * 0.15;
state._fuelTrack.lastFuel = h.fuel;
state._fuelTrack.lastT = state.t;
}
// Balken: 0..12 l/min (typisch 46, Vollschub bis ~10), mit Mindestbreite 4%
const lpm = state._fuelLitersPerMin || 0;
const fr = document.getElementById('fFuelRate');
const vr = document.getElementById('vFuelRate');
if (fr) {
fr.style.width = Math.max(4, Math.min(100, (lpm / 12) * 100)) + '%';
fr.style.background = lpm > 9 ? 'var(--red)' : lpm > 6 ? 'var(--orange)' : 'var(--sage)';
}
if (vr) vr.textContent = lpm.toFixed(1) + ' l/min';
// Strike-Log (nur sichtbar wenn schon Strike(s))
const slEl = document.getElementById('strikeLog');
if (slEl && state.strikeLog && state.strikeLog.length) {
slEl.style.display = 'block';
slEl.innerHTML = '<b>Berührungs-Log:</b><br>' +
state.strikeLog.map(s => `• ${s.msg} <span style="opacity:.6">(${s.t.toFixed(0)}s)</span>`).join('<br>');
}
// Fuel bar color warning
const fFuelEl = document.getElementById('fFuel');
if(fFuelEl) fFuelEl.style.background = h.fuel < 0.15 ? 'var(--red)' : h.fuel < 0.3 ? 'var(--orange)' : 'var(--red)';
// G-Kraft (Beschleunigungs-Magnitude, EMA-geglättet)
const dvx = h.vx - (h.prevVx||0), dvy = h.vy - (h.prevVy||0);
const accel = Math.sqrt(dvx*dvx + dvy*dvy);
const gForce = 1 + accel / 120;
h.prevVx = h.vx; h.prevVy = h.vy;
const gPct = Math.min(100, (gForce-1)*50);
const gCol = gForce > 2 ? 'var(--red)' : gForce > 1.5 ? 'var(--orange)' : 'var(--sage)';
const fGEl = document.getElementById('fG');
const vGEl = document.getElementById('vG');
if (fGEl) { fGEl.style.width = gPct + '%'; fGEl.style.background = gCol; }
if (vGEl) vGEl.textContent = gForce.toFixed(1) + ' g';
document.getElementById('d1').className = 'dot' + (h.strikes >= 1 ? ' hit' : '');
document.getElementById('d2').className = 'dot' + (h.strikes >= 2 ? ' hit' : '');
document.getElementById('d3').className = 'dot' + (h.strikes >= 3 ? ' hit' : '');
document.getElementById('vStrikes').textContent = h.strikes + ' / 3';
// === Wind ===
const w = localWind(h.x, h.y);
const wMag = Math.round(Math.sqrt(w.x*w.x + w.y*w.y) / 3);
// Wind name by direction it COMES FROM (meteo convention)
const windFrom = state.windDir < 0 ? 'Osten' : 'Westen';
const vWindEl = document.getElementById('vWind');
if(vWindEl) vWindEl.textContent = `aus ${windFrom}, ${wMag} kn`;
// === Ziel ===
let target = null, targetLabel = '—';
if(state.phase === 'approach1' || state.phase === 'hover_drop'){
target = state.patient;
targetLabel = `Patient ${state.patient.zone === 'east' ? '→ Osten' : '← Westen'}`;
} else if(state.phase === 'rescuer_working'){
if(state.intermediateRequired && state.intermediatePad){
target = state.intermediatePad.atClinic ? state.helipadEnd : state.intermediatePad;
const dir = target.x < h.x ? '← Westen' : '→ Osten';
const where = state.intermediatePad.atClinic ? 'Klinik' : 'Rauchsignal';
targetLabel = `${where} ${dir}`;
} else {
targetLabel = 'Warteschleife';
}
} else if(state.phase === 'approach2' || state.phase === 'hover_pickup'){
target = state.patient;
const dir = state.patient.x < h.x ? '← Westen' : '→ Osten';
targetLabel = `Patient ${dir}`;
} else if(state.phase === 'extract'){
target = state.helipadEnd;
const dir = target.x < h.x ? '← Westen' : '→ Osten';
targetLabel = `Klinik ${dir}`;
}
const vTargetEl = document.getElementById('vTarget');
if(vTargetEl) vTargetEl.textContent = targetLabel;
// Bottom direction arrows
const dirWestEl = document.getElementById('dirWest');
const dirEastEl = document.getElementById('dirEast');
if(dirWestEl && dirEastEl){
let targetX = null;
if(target) targetX = target.x + (target.w ? target.w/2 : 0);
if(targetX !== null){
const targetEast = targetX > h.x;
dirEastEl.classList.toggle('active', targetEast);
dirEastEl.classList.toggle('dim', !targetEast);
dirWestEl.classList.toggle('active', !targetEast);
dirWestEl.classList.toggle('dim', targetEast);
} else {
dirEastEl.classList.remove('active'); dirEastEl.classList.add('dim');
dirWestEl.classList.remove('active'); dirWestEl.classList.add('dim');
}
}
// Phase progress bar
const phBar = document.getElementById('phBar');
if(phBar) phBar.style.width = Math.round(state.phaseProgress * 100) + '%';
}
// ============================================================
// UI
// ============================================================
// Returns a deep clone of the current level preset
function cloneLevel(num){
return JSON.parse(JSON.stringify(LEVEL_PRESETS[num]));
}
// Admin parameters exposed as sliders
const ADMIN_PARAMS = [
{ key: 'gravity', label: 'Schwerkraft', min: 100, max: 350, step: 10 },
{ key: 'flapImpulse', label: 'Auftrieb pro Druck', min: 100, max: 240, step: 5 },
{ key: 'windBase', label: 'Großwetter-Wind', min: 0, max: 35, step: 1 },
{ key: 'windFieldStrength', label: 'Lokales Windfeld', min: 0, max: 1.2, step: 0.1 },
{ key: 'gustChance', label: 'Böen-Häufigkeit', min: 0, max: 1.0, step: 0.1 },
{ key: 'pendulumStrength', label: 'Pendel-Stärke', min: 0, max: 1.5, step: 0.1 },
{ key: 'hoverRadius', label: 'Hover-Toleranz (px)', min: 20, max: 100, step: 2 },
{ key: 'hoverTime', label: 'Hover-Zeit (s)', min: 1.0, max: 6.0, step: 0.5 },
{ key: 'rescuerWorkTime', label: 'Wartezeit Retter (s)', min: 10, max: 60, step: 2 },
{ key: 'birds', label: 'Vogelschwärme', min: 0, max: 5, step: 1 },
{ key: 'cables', label: 'Seilbahnen', min: 0, max: 3, step: 1 },
{ key: 'passingClouds', label: 'Ziehende Wolken', min: 0, max: 4, step: 1 }
];
function renderSelectors(){
const heliOpts = document.getElementById('heliOpts');
if(heliOpts){
heliOpts.innerHTML = '';
for(const key of Object.keys(HELI_VARIANTS)){
const v = HELI_VARIANTS[key];
const el = document.createElement('div');
el.className = 'opt' + (state.selectedHeli === key ? ' active' : '');
el.innerHTML = `<span class="swatch" style="background:${v.body}"></span>${v.label}`;
el.onclick = () => { state.selectedHeli = key; renderSelectors(); };
heliOpts.appendChild(el);
}
}
const levelOpts = document.getElementById('levelOpts');
if(levelOpts){
levelOpts.innerHTML = '';
for(const lv of [1, 2, 3]){
const preset = LEVEL_PRESETS[lv];
const el = document.createElement('div');
el.className = 'level-card' + (state.selectedLevel === lv ? ' active' : '');
el.innerHTML = `
<div class="lc-num">Level ${lv}</div>
<div class="lc-title">${preset.name}</div>
<div class="lc-desc">${preset.desc}</div>
`;
el.onclick = () => {
state.selectedLevel = lv;
state.level = cloneLevel(lv); // fresh clone, resets any admin tweaks
renderSelectors();
};
levelOpts.appendChild(el);
}
}
renderAdminSliders();
}
function renderAdminSliders(){
const container = document.getElementById('adminSliders');
if(!container) return;
container.innerHTML = '';
for(const p of ADMIN_PARAMS){
const val = state.level[p.key];
const row = document.createElement('div');
row.className = 'slider-row';
row.innerHTML = `
<label>${p.label}</label>
<input type="range" min="${p.min}" max="${p.max}" step="${p.step}" value="${val}" data-key="${p.key}">
<span class="sval">${p.step < 1 ? Number(val).toFixed(1) : val}</span>
`;
const slider = row.querySelector('input');
const display = row.querySelector('.sval');
slider.oninput = (e) => {
const v = parseFloat(e.target.value);
state.level[p.key] = v;
display.textContent = p.step < 1 ? v.toFixed(1) : v;
};
container.appendChild(row);
}
}
function showOverlay(){
state.mode = 'ready';
document.getElementById('overlay').classList.add('show');
// Reset card to briefing
const L = window.HELI_SCEN_LABELS || { tag:'Bergrettung · Briefing', notruf:'Notruf aus dem Gebirge' };
const notruf = (window.HELI_SCEN === 'autobahn') ? 'Notruf von der Autobahn'
: (window.HELI_SCEN === 'wasser') ? 'Notruf vom Wasser'
: (window.HELI_SCEN === 'tal-dorf') ? 'Notruf aus dem Ort'
: 'Notruf aus dem Gebirge';
document.getElementById('cardTag').textContent = L.tag;
document.getElementById('cardT').textContent = notruf;
// The full briefing card is in HTML; nothing to restore
document.getElementById('startBtn').textContent = 'Einsatz starten';
document.getElementById('phaseBox').style.display = 'none';
renderSelectors();
}
function hideOverlay(){
document.getElementById('overlay').classList.remove('show');
}
// ============================================================
// BRIEFING-MUSIK (Patagonia Stringmap v1) — Landeanflug-Briefing
// ============================================================
let briefingAudio = null;
function startBriefingMusic() {
if (!briefingAudio) {
briefingAudio = new Audio();
briefingAudio.loop = true;
briefingAudio.volume = 0;
briefingAudio.src = 'assets/music/patagonia-stringmap-v1.mp3';
}
briefingAudio.play().catch(()=>{});
const t0 = performance.now();
const step = () => {
if (!briefingAudio) return;
const k = Math.min(1, (performance.now() - t0) / 300);
briefingAudio.volume = 0.35 * k;
if (k < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
function stopBriefingMusic() {
if (!briefingAudio) return;
const a = briefingAudio;
const t0 = performance.now();
const v0 = a.volume;
const step = () => {
const 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);
}
setTimeout(() => {
const ov = document.getElementById('overlay');
if (!ov) return;
let wasShown = ov.classList.contains('show');
if (wasShown) startBriefingMusic();
new MutationObserver(() => {
const now = ov.classList.contains('show');
if (now && !wasShown) startBriefingMusic();
if (!now && wasShown) stopBriefingMusic();
wasShown = now;
}).observe(ov, { attributes: true, attributeFilter: ['class'] });
}, 150);
function showResultOverlay(title, desc, btnText){
const ov = document.getElementById('overlay');
const card = document.getElementById('card');
card.innerHTML = `
<span class="tag">Ergebnis</span>
<h2>${title}</h2>
<p>${desc}</p>
<p style="font-size:12px;color:var(--slate-light)">Du kannst Schwierigkeitsstufe und Helikopter neu wählen.</p>
<div class="sect">
<div class="sect-title">Schwierigkeitsstufe</div>
<div class="level-row" id="levelOpts"></div>
</div>
<div class="sect">
<div class="sect-title">Helikopter</div>
<div class="opts" id="heliOpts"></div>
</div>
<div class="collapsible" id="adminCollapse">
<div class="collapsible-head" id="adminHead">
<span>⚙️ Erweiterte Parameter (Admin)</span>
<span class="arrow">▶</span>
</div>
<div class="collapsible-body">
<p style="margin-bottom:12px;font-size:12px">Werte überschreiben Level-Standard. Bei Level-Wechsel werden sie zurückgesetzt.</p>
<div id="adminSliders"></div>
</div>
</div>
<button class="btn" id="startBtn">${btnText}</button>
`;
document.getElementById('phaseBox').style.display = 'none';
ov.classList.add('show');
renderSelectors();
document.getElementById('adminHead').onclick = () => {
document.getElementById('adminCollapse').classList.toggle('open');
};
document.getElementById('startBtn').onclick = () => {
hideOverlay();
buildScene();
};
}
// Collapsible (briefing)
const eduHead = document.getElementById('eduHead');
if(eduHead){
eduHead.onclick = () => {
document.getElementById('eduCollapse').classList.toggle('open');
};
}
const adminHead = document.getElementById('adminHead');
if(adminHead){
adminHead.onclick = () => {
document.getElementById('adminCollapse').classList.toggle('open');
};
}
// ============================================================
// PRESET-MODUS: Wenn aus game.html aufgerufen (?preset=1),
// Auswahl-UI durch Mission-Briefing ersetzen, Level+Heli vorbelegen.
// ============================================================
(function applyPresetMode(){
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('preset') !== '1') return;
const presetHeli = urlParams.get('heli') || 'oamtc';
const presetLevel = Math.max(1, Math.min(3, parseInt(urlParams.get('level'), 10) || 2));
const missionTitle = urlParams.get('mission') || 'Bergrettung';
const heliName = urlParams.get('heliname') || 'Rettungshubschrauber';
const scen = window.HELI_SCEN || 'berg-fels';
const scenInfo = {
'berg-schnee': {tag:'Berg · Schnee', desc:'Verschneite Piste, kein sicherer Landeplatz. Rettung mit der <b>Winde</b>. Achte auf Schneetreiben — der Rotor wirbelt den Pulverschnee auf und nimmt kurz die Sicht.'},
'berg-fels': {tag:'Berg · Fels', desc:'Steile Felswand, kein Landeplatz. Rettung mit der <b>Winde</b>. Achte auf Fallwinde an der Leeseite — die können den Heli nach unten drücken.'},
'wasser': {tag:'Wasser · See', desc:'Rettung über dem Wasser mit der <b>Winde</b>. Der Rotorabwind erzeugt Wellen — der Schwebeflug muss höher als normal sein, damit der Patient nicht untergetaucht wird.'},
'schlucht': {tag:'Schlucht', desc:'Enge Felswände links und rechts. Rettung mit der <b>Winde</b>. In schmalen Schluchten verstärken sich Winde (Düseneffekt) — vorsichtig einfliegen.'},
'autobahn': {tag:'Autobahn', desc:'Polizei hat beide Richtungen gesperrt — hier wird <b>direkt auf dem Asphalt</b> gelandet, keine Winde. Ziel: <b>grüne Landezone</b> zwischen den Schilderbrücken. Sanft absinken (Sinkrate unter 10 m/s), nicht gegen Leitplanke oder Schilderbrücke fliegen.'},
'tal-dorf': {tag:'Tal · Ort', desc:'Enge Bebauung — Rettung mit der <b>Winde</b>. Antennen, Strommasten und Dächer verlangen besondere Sorgfalt beim Schwebeflug.'},
}[scen] || {tag:'Einsatz', desc:'Rettung mit der <b>Winde</b>.'};
if (HELI_VARIANTS[presetHeli]) state.selectedHeli = presetHeli;
if (LEVEL_PRESETS[presetLevel]) {
state.selectedLevel = presetLevel;
state.level = cloneLevel(presetLevel);
}
const levelName = LEVEL_PRESETS[presetLevel].name;
const card = document.getElementById('card');
if (!card) return;
card.innerHTML = `
<span class="tag">${scenInfo.tag} · Einsatz-Briefing</span>
<h2>${missionTitle}</h2>
<p>Du fliegst den <b>${heliName}</b>. ${scenInfo.desc}</p>
<div style="background:rgba(255,255,255,.6);border-radius:10px;padding:14px 16px;margin:14px 0;font-size:14px;line-height:1.5">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px 18px">
<div><b>Szenario:</b> Windenbergung</div>
<div><b>Schwierigkeit:</b> ${levelName}</div>
<div><b>Treibstoff:</b> voll (rund 90 min Reichweite)</div>
<div><b>Verbrauch Schwebeflug:</b> ~6 Liter/Minute</div>
<div style="grid-column:1/-1"><b>Hinweis Treibstoff:</b> Schwebeflug frisst Treibstoff. Wenn der Retter am Boden arbeitet, <b>Warteschleife</b> fliegen!</div>
</div>
</div>
<ol style="margin:0 0 14px;padding-left:22px;color:var(--slate);font-size:13px;line-height:1.6">
<li><b>Anflug & Abseilen:</b> Schwebeflug über dem Patienten halten, der Flugretter wird abgeseilt.</li>
<li><b>Warteschleife:</b> Während der Retter versorgt, kreist du in sicherem Abstand — spart Treibstoff.</li>
<li><b>Aufnahme & Ausflug:</b> Erneut Position halten, hochziehen, sicher zur Klinik.</li>
</ol>
<div style="background:rgba(45,84,52,.08);border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:13px;line-height:1.5;color:var(--slate)">
<b>Steuerung:</b> <kbd style="background:#fff;border:1px solid #ccc;border-radius:4px;padding:1px 6px;font-family:inherit">↑</kbd> Schub,
<kbd style="background:#fff;border:1px solid #ccc;border-radius:4px;padding:1px 6px;font-family:inherit">←</kbd>/<kbd style="background:#fff;border:1px solid #ccc;border-radius:4px;padding:1px 6px;font-family:inherit">→</kbd> Neigen.
<b>Kurz antippen</b> für feine Impulse, <b>gedrückt halten</b> für Dauerschub — so navigierst du präzise zum Patienten.
</div>
<div style="display:flex;justify-content:flex-end">
<button class="btn" id="startBtn">Einsatz starten</button>
</div>
`;
})();
document.getElementById('startBtn').onclick = () => {
hideOverlay();
buildScene();
};
// ============================================================
// LOOP
// ============================================================
let last = performance.now();
function loop(now){
const dt = Math.min(0.033, (now - last) / 1000);
last = now;
update(dt);
render();
requestAnimationFrame(loop);
}
// Init
state.level = cloneLevel(state.selectedLevel);
renderSelectors();
// Build a preview scene so something is visible behind the overlay
buildScene();
state.mode = 'ready';
state.heli.locked = true;
requestAnimationFrame(loop);
})();
</script>
</body>
</html>