79ac9dad84
- Logistik: Musik-Player Playlist-Modus (5 Gruppen, 20 Tracks) - Heli: End-Screen auf .ggs-endscreen, Mission-Bilder, Voice-Lines, Briefing-Audio - Logistik: Layout-Tausch Auftraege+Fahrzeuge links, Karte rechts - Logistik: Tier-1-Staedte ausgebaut, Auto-Timescale-Badge - Logistik: Roadnet/Railnet Dijkstra-Routing, Balance-Updates - Atlas: 5 Atlas-Inbox-Nachrichten (Cards-Pattern, DALL-E-Key, Musik-Playlists) - Status-Updates Heli + Logistik Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4069 lines
172 KiB
HTML
4069 lines
172 KiB
HTML
<!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 Dauerschub) · <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
|
||
});
|
||
}
|
||
// 2–3 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 100–160 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 4–6, 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>
|