Files
geograsim/App/sims/heli/start.html.bak-pre-christo-bulk
T
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

3247 lines
142 KiB
Plaintext

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Helikopter-Simulator · Einsatzvarianten & Szenarien</title>
<meta name="viewport" content="width=device-width,initial-scale=1, viewport-fit=cover">
<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:210px}
.bar{display:grid;grid-template-columns:64px 1fr 42px;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)}
.progress{margin-top:4px;height:6px;background:rgba(45,84,52,.12);border-radius:99px;overflow:hidden}
.progress>div{height:100%;background:var(--forest);width:0%;transition:width .15s}
.strikes{display:flex;gap:6px;align-items:center;margin-top:4px;font-size:11px;color:var(--slate)}
.dot{width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;opacity:.25;transition:opacity .2s}
.dot.hit{opacity:1}
.footer{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-top:12px;font-size:13px;color:var(--slate)}
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 28px;padding-bottom:max(22px,env(safe-area-inset-bottom));text-align:left;max-width:900px;width:96%;box-shadow:0 30px 60px -20px rgba(0,0,0,.35);border:1px solid rgba(45,84,52,.15);max-height: 94dvh;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}
.card .note{color:var(--slate-light);font-size:12px;margin-bottom:14px;font-style:italic}
.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)}
.scen-desc{margin-top:8px;padding:8px 10px;background:rgba(45,84,52,.06);border-left:3px solid var(--forest);border-radius:0 8px 8px 0;font-size:12px;color:var(--slate);line-height:1.5}
.slider-row{display:grid;grid-template-columns:140px 1fr 36px;gap:10px;align-items:center;margin-bottom:6px;font-size:12px}
.slider-row label{color:var(--slate)}
.slider-row input[type=range]{width:100%;accent-color:var(--forest);height:4px;cursor:pointer}
.slider-row .sval{text-align:right;font-weight:600;color:var(--ink);font-variant-numeric:tabular-nums}
.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.open .collapsible-body{display:block}
.collapsible.open .arrow{transform:rotate(90deg)}
.arrow{transition:transform .15s;display:inline-block}
.hv-diagram{display:flex;gap:14px;align-items:center;margin:12px 0;padding:10px 12px;background:rgba(255,255,255,.6);border-radius:8px;border:1px solid rgba(45,84,52,.1)}
.hv-diagram svg{flex:0 0 200px}
.hv-text{flex:1;font-size:12px}
.hv-text b{color:var(--forest)}
.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}
.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:420px;box-shadow:0 6px 16px -8px rgba(45,84,52,.3);z-index:3}
.mission b{color:var(--forest)}
.timer-big{position:absolute;top:14px;left:50%;transform:translateX(-50%);background:var(--red);color:var(--cream);font-weight:700;font-size:22px;padding:8px 18px;border-radius:12px;font-variant-numeric:tabular-nums;letter-spacing:.05em;box-shadow:0 10px 24px -10px rgba(200,92,74,.6);display:none;z-index:3}
.timer-big.show{display:block}
.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</h1>
<div class="sub">Co-Pilot im Training · Ziel: Raus aus der Startzone</div>
</div>
<span class="pill" id="scenPill">Lektion · Start</span>
</header>
<div class="stage" id="stage">
<canvas id="game" width="1400" height="760"></canvas>
<div class="timer-big" id="timerBig">00:00</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 class="progress"><div id="pRoute"></div></div>
</div>
<div></div>
<div class="panel">
<div class="wind">
<svg width="22" height="22" viewBox="0 0 22 22">
<circle cx="11" cy="11" r="10" fill="none" stroke="#7a9b76" stroke-width="1.2"/>
<g id="windArrow" style="transform-origin:11px 11px">
<path d="M11 3 L14 9 L11 7.5 L8 9 Z" fill="#c97a3f"/>
<line x1="11" y1="7" x2="11" y2="18" stroke="#c97a3f" stroke-width="1.5" stroke-linecap="round"/>
</g>
</svg>
<span id="vWind">Wind 0 kn</span>
</div>
<div style="margin-top:4px;font-size:11px;color:var(--forest)"><b>Ziel</b> <span id="vTarget">Raus aus Startzone</span></div>
<div style="margin-top:6px;font-size:11px;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 class="mission" id="missionBox"><b>Ziel:</b> Wähle Einsatzvariante & Szenario.</div>
<!-- Touch Controls (Tablet) — 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="overlay show" id="overlay">
<div class="card" id="card">
<span class="tag" id="cardTag">Einsatz wählen</span>
<h2 id="cardT">Bereit für den Einsatz?</h2>
<p id="cardP">Du bist <b>Co-Pilot*in</b> auf deinen ersten Einsätzen. Die Chefpilotin lässt dich den Start fliegen — das ist der heikelste Teil. Ziel: <b>raus aus der Startumgebung</b>. Dann übernimmt die Pilotin die Flugnavigation.<br><br>Bei <b>drei Berührungen</b> greift die Chefpilotin ein.</p>
<div class="sect">
<div class="sect-title">Einsatzvariante</div>
<div class="opts" id="heliOpts"></div>
</div>
<div class="sect">
<div class="sect-title">Szenario</div>
<div class="opts" id="scenOpts"></div>
<div class="scen-desc" id="scenDesc"></div>
</div>
<div class="sect" style="display:none">
<div class="sect-title">Hindernisse einstellen</div>
<div id="paramSliders"></div>
</div>
<div class="collapsible open" id="eduCollapse">
<div class="collapsible-head" id="eduHead">
<span>📚 Warum nicht einfach senkrecht starten?</span>
<span class="arrow">▶</span>
</div>
<div class="collapsible-body">
<p>Ein Hubschrauber kann zwar in der Luft stehen wie eine Kerze. Trotzdem startet er fast nie senkrecht. Warum?</p>
<div class="hv-diagram">
<svg viewBox="0 0 220 140" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="220" height="140" fill="#f5efe0"/>
<line x1="30" y1="115" x2="205" y2="115" stroke="#3d5165" stroke-width="1.5"/>
<line x1="30" y1="115" x2="30" y2="15" stroke="#3d5165" stroke-width="1.5"/>
<text x="118" y="132" font-size="9" fill="#3d5165" text-anchor="middle">Geschwindigkeit →</text>
<text x="14" y="65" font-size="9" fill="#3d5165" text-anchor="middle" transform="rotate(-90 14 65)">Höhe →</text>
<path d="M 30 115 L 30 30 Q 30 25 35 25 Q 80 28 75 60 Q 70 95 65 110 Q 60 115 30 115 Z" fill="#c85c4a" fill-opacity="0.55" stroke="#c85c4a" stroke-width="1"/>
<text x="48" y="68" font-size="9" fill="#5a1f15" font-weight="bold" text-anchor="middle">Gefahren-</text>
<text x="48" y="78" font-size="9" fill="#5a1f15" font-weight="bold" text-anchor="middle">zone</text>
<path d="M 38 115 L 38 30" stroke="#c85c4a" stroke-width="2" stroke-dasharray="4,3" fill="none"/>
<polygon points="38,25 33,33 43,33" fill="#c85c4a"/>
<text x="38" y="13" font-size="11" fill="#c85c4a" text-anchor="middle">⚠</text>
<path d="M 38 115 Q 110 115 145 90 Q 175 65 188 30" stroke="#2d5434" stroke-width="2.5" fill="none"/>
<polygon points="192,25 184,28 192,35" fill="#2d5434"/>
<text x="200" y="22" font-size="9" fill="#2d5434" font-weight="bold">✓</text>
<text x="38" y="125" font-size="7" fill="#3d5165" text-anchor="middle">langsam</text>
<text x="180" y="125" font-size="7" fill="#3d5165" text-anchor="middle">schnell</text>
</svg>
<div class="hv-text">
<p style="margin:0 0 8px"><b>Die "Todeskurve" (H/V-Diagramm)</b></p>
<p style="margin:0">Wenn der Motor in z. B. 50 m Höhe ausfällt und der Heli senkrecht steht, gibt es kein Zurück: Er fällt. Mit Vorwärtsfahrt kann der Rotor wie ein Windrad weiterdrehen (<b>Autorotation</b>) und der Pilot landet sanft.</p>
</div>
</div>
<p><b>Geschwindigkeit ist Sicherheit.</b> Deshalb fliegt man bei Rettungshubschraubern auf Krankenhausdächern ein <b>Kategorie-A-Profil</b>: schnell schräg nach vorne weg, dabei Geschwindigkeit aufbauen — auch wenn man dafür zuerst über die Dachkante hinaus muss.</p>
<p style="font-size:12px;color:var(--slate-light)"><b>Bonus:</b> Krankenhausdächer in der Stadt sind besonders heikel — kaum Notlandeflächen rundherum, Turbulenzen durch Häuser. Deshalb plant der Pilot eine bestimmte Abflugrichtung mit Notlandeoptionen entlang der Flugbahn (Park, Fluss, Sportplatz).</p>
</div>
</div>
<p class="note">Steuerung: <kbd>↑</kbd> flappen · <kbd>←</kbd><kbd>→</kbd> neigen · <kbd>R</kbd> Reset</p>
<button class="btn" id="btnStart">Starten</button>
</div>
</div>
</div>
<div class="footer">
<div><kbd>↑</kbd> flappen · <kbd>←</kbd><kbd>→</kbd> neigen · <kbd>R</kbd> Reset</div>
<div>Hubschrauber-Physik · Chefpilotin übernimmt bei 3 Berührungen</div>
</div>
</div>
<script src="scripts/funk-pille.js"></script>
<script>
(function(){
if (!window.HeliFunk) return;
// start.html laeuft direkt (nicht via Wrapper) — pages-relative Pfade.
HeliFunk.init('scripts', 'img');
})();
</script>
<script>
(() => {
const cvs = document.getElementById('game');
const ctx = cvs.getContext('2d');
const W = cvs.width, H = cvs.height;
// =========== SOUND ENGINE ===========
const SND_PATH = 'sounds/';
let audioCtx, masterGain;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.connect(audioCtx.destination);
masterGain.gain.value = 0.6;
} catch(e) { console.warn('AudioContext failed:', e); }
let muted = false;
window.toggleMute = function() {
muted = !muted;
masterGain.gain.value = muted ? 0 : 0.6;
document.getElementById('muteBtn').textContent = muted ? '🔇' : '🔊';
document.getElementById('muteBtn').style.opacity = muted ? '1' : '.6';
};
// Preload buffers
const buffers = {};
const soundFiles = ['heliFly','warning','thunder','bigPlane','smallPlane',
'birds1','birds2','birds3','birdCrash','crash1','crash2','crash3',
'crashBirds','crashBuilding','radio1','radio3','radio4'];
async function preloadSounds() {
for (const name of soundFiles) {
try {
const resp = await fetch(SND_PATH + name + '.mp3');
const buf = await resp.arrayBuffer();
buffers[name] = await audioCtx.decodeAudioData(buf);
} catch(e) { console.warn('Sound not loaded:', name); }
}
}
preloadSounds();
// Play one-shot sound
function playSound(name, vol=1) {
if (!audioCtx || !buffers[name]) return null;
const src = audioCtx.createBufferSource();
const gain = audioCtx.createGain();
src.buffer = buffers[name];
gain.gain.value = vol;
src.connect(gain);
gain.connect(masterGain);
src.start(0);
return {src, gain};
}
// Play random from array
function playRandom(names, vol=1) {
return playSound(names[Math.floor(Math.random()*names.length)], vol);
}
// Rotor loop with variable playback rate
let rotorSource = null, rotorGain = null;
function startRotor() {
if (!audioCtx || rotorSource || !buffers.heliFly) return;
rotorSource = audioCtx.createBufferSource();
rotorGain = audioCtx.createGain();
rotorSource.buffer = buffers.heliFly;
rotorSource.loop = true;
rotorSource.playbackRate.value = 0.8;
rotorGain.gain.value = 0.15;
rotorSource.connect(rotorGain);
rotorGain.connect(masterGain);
rotorSource.start(0);
}
function stopRotor() {
if (rotorSource) { rotorSource.stop(); rotorSource = null; }
}
function updateRotor(thrust, altitude) {
if (!audioCtx || !rotorSource) return;
// Playback rate: 0.8 idle → 1.3 full thrust
rotorSource.playbackRate.value = 0.8 + thrust * 0.5;
// Volume: louder with thrust, slight increase with altitude
rotorGain.gain.value = 0.12 + thrust * 0.18;
}
// Proximity-based ambient sounds (birds, planes, thunder)
// Track active ambient loops
const ambientSources = {};
function updateProximitySound(id, soundName, distance, maxDist=400) {
if (distance > maxDist) {
// Stop if too far
if (ambientSources[id]) {
ambientSources[id].gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.3);
setTimeout(() => { try{ambientSources[id].src.stop();}catch(e){} delete ambientSources[id]; }, 400);
}
return;
}
const vol = Math.max(0, 1 - distance / maxDist) * 0.5;
if (!ambientSources[id]) {
if (!buffers[soundName]) return;
const src = audioCtx.createBufferSource();
const gain = audioCtx.createGain();
src.buffer = buffers[soundName];
src.loop = true;
gain.gain.value = vol;
src.connect(gain);
gain.connect(masterGain);
src.start(0);
ambientSources[id] = {src, gain};
} else {
ambientSources[id].gain.gain.value = vol;
}
}
function stopAllAmbient() {
for (const id in ambientSources) {
try{ambientSources[id].src.stop();}catch(e){}
delete ambientSources[id];
}
}
// === FUNK-EFFEKT (Bandpass + Distortion → klingt wie Radio) ===
// Funk-Gain (kein Filter mehr, nur laut und klar)
const radioGain = audioCtx.createGain();
radioGain.gain.value = 4.8; // 80% von 6.0
radioGain.connect(masterGain);
// TTS Radio files mapping
const RADIO_FILES = {};
const radioFileList = [
'pilot_calm1','pilot_calm2','pilot_calm3','pilot_crane','pilot_antenna',
'pilot_gusts','pilot_gap','pilot_trees','pilot_flock','pilot_wind',
'pilot_narrow','pilot_powerline','pilot_chimney','pilot_time',
'pilot_fog1','pilot_fog2','pilot_fog3',
'strike1','strike2','strike3','strike4','strike5',
// takeover base-Files (takeover.mp3 etc.) sind kaputte 143-Byte-Platzhalter;
// Prefix-Match läuft zuverlässig über die _alloy/_echo-Varianten weiter unten.
'success','success2',
'ouch1','ouch2','ouch3',
'warn_haus','warn_haus2','warn_haus3',
'warn_baum','warn_baum2','warn_baum3',
'warn_vogel','warn_vogel2','warn_vogel3',
'warn_flugzeug','warn_flugzeug2','warn_flugzeug3',
'warn_sport','warn_sport2','warn_sport3',
'warn_gewitter',
'warn_strom','warn_strom2','warn_strom3',
'warn_kran','warn_kran2','warn_kran3',
'warn_nebel','warn_nebel2','warn_nebel3',
'warn_fabrik','warn_fabrik2','warn_fabrik3',
'warn_antenne','warn_antenne2','warn_antenne3',
'warn_schornstein','warn_schornstein2',
'warn_klima','warn_klima2',
'warn_blitz','warn_blitz2','warn_blitz3',
'warn_fuel','warn_fuel2',
'pilot_calm1_echo','pilot_calm2_echo','pilot_calm3_echo',
'strike1_echo','strike2_echo','strike3_echo','strike4_echo','strike5_echo',
'ouch1_echo','ouch2_echo','ouch3_echo','success2_echo',
'takeover_echo','takeover2_echo','takeover4_echo',
'pilot_calm1_alloy','pilot_calm2_alloy','pilot_calm3_alloy',
'strike1_alloy','strike2_alloy','strike3_alloy','strike4_alloy','strike5_alloy',
'ouch1_alloy','ouch2_alloy','ouch3_alloy','success2_alloy',
'takeover_alloy','takeover2_alloy','takeover4_alloy',
'tower1','tower2','tower3','tower4',
'traffic1','traffic2','traffic3','traffic4',
// Tower-Lehrmeisterin — pro Heli Begrüßung, Strike-Call, Chefpilot-Übernahme
'tower_start_c1','tower_start_c2','tower_start_c3','tower_start_c4','tower_start_c5','tower_start_c6','tower_start_c7','tower_start_c8','tower_start_c9','tower_start_c10','tower_start_c11','tower_start_c12','tower_start_c14','tower_start_c16','tower_start_ara',
'tower_strike_c1','tower_strike_c2','tower_strike_c3','tower_strike_c4','tower_strike_c5','tower_strike_c6','tower_strike_c7','tower_strike_c8','tower_strike_c9','tower_strike_c10','tower_strike_c11','tower_strike_c12','tower_strike_c14','tower_strike_c16','tower_strike_ara',
'tower_handover_c1','tower_handover_c2','tower_handover_c3','tower_handover_c4','tower_handover_c5','tower_handover_c6','tower_handover_c7','tower_handover_c8','tower_handover_c9','tower_handover_c10','tower_handover_c11','tower_handover_c12','tower_handover_c14','tower_handover_c16','tower_handover_ara',
// Ebene B: kontextuelle Lernhinweise (generisch, kein Heli-Name)
'tower_lesson_wind','tower_lesson_foehn','tower_lesson_fog','tower_lesson_thermic','tower_lesson_temperature',
'tower_lesson_powerline','tower_lesson_antenna','tower_lesson_crane','tower_lesson_chimney','tower_lesson_cable',
'tower_lesson_hover','tower_lesson_vortex','tower_lesson_torque','tower_lesson_downwash','tower_lesson_density',
'tower_lesson_crows','tower_lesson_gulls',
'tower_lesson_banking','tower_lesson_pilot','tower_lesson_autorot',
// Pilot-Situationsmeldungen (Nova-Stimme, Funk-Filter):
'pilot_wind_turn','pilot_bird_ahead','pilot_plane_ahead','pilot_sportplane_level',
'pilot_storm_detour','pilot_fog_thickens','pilot_time_up','pilot_too_high',
'pilot_three_strikes',
'pilot_strike_1','pilot_strike_2','pilot_strike_3','pilot_strike_4','pilot_strike_5',
// Heli-Flug-Intros (werden in game.html gespielt, aber vorsorglich preloaden
// ist harmlos — die flight-audio-engine nutzt ihre eigene cache)
'nav_heli_intro_c1','nav_heli_intro_c2','nav_heli_intro_c3','nav_heli_intro_c4',
'nav_heli_intro_c5','nav_heli_intro_c6','nav_heli_intro_c7','nav_heli_intro_c8',
'nav_heli_intro_c9','nav_heli_intro_c10','nav_heli_intro_c11','nav_heli_intro_c12',
'nav_heli_intro_c14','nav_heli_intro_c16','nav_heli_intro_ara',
// V1: Mission-Start-Audios pro Mission (Heli-Identifikation + Auftrag)
'mission_start_m1','mission_start_m2','mission_start_m3','mission_start_m4','mission_start_m5',
'mission_start_m6','mission_start_m7','mission_start_m8','mission_start_m9','mission_start_m10',
'mission_start_m11','mission_start_m12','mission_start_m13','mission_start_m14','mission_start_m15',
'mission_start_m16','mission_start_m17','mission_start_m18','mission_start_m19','mission_start_m20',
'mission_start_m21','mission_start_m22','mission_start_m23','mission_start_m24','mission_start_m25',
// V1: Start-Erfolg-Funkspruch (m/f)
'start_erfolg','start_erfolg_m'
];
async function preloadRadio() {
for (const name of radioFileList) {
try {
// Neue Dateien liegen mit r_-Praefix auf Platte (r_tower_start_c1.mp3),
// alte ohne (pilot_calm1.mp3). Erst r_, dann Legacy.
let resp = await fetch(SND_PATH + 'radio/r_' + name + '.mp3');
if (!resp.ok) resp = await fetch(SND_PATH + 'radio/' + name + '.mp3');
if (!resp.ok) continue;
const buf = await resp.arrayBuffer();
if (buf.byteLength < 500) continue;
RADIO_FILES[name] = await audioCtx.decodeAudioData(buf);
} catch(e) {}
}
}
preloadRadio();
// Separate channels: pilot vs background (tower/traffic)
// Pilot: Walkie-Talkie Funk-Effekt
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);
const bgRadioGain = audioCtx.createGain();
bgRadioGain.gain.value = 3.0;
bgRadioGain.connect(masterGain);
// ===========================================================
// SPEECH-PIPE (zentrale Sprach-Audio-Pipe fuer start.html)
// Regeln:
// - Sprach-Clips laufen SERIELL, nie zwei gleichzeitig
// - 400 ms Pause zwischen zwei Clips
// - Jeder Clip hat eine TTL (max. Wartezeit), danach verworfen
// - Prioritaeten: 1=low (Lesson), 2=normal (Pilot/Tower), 3=crash
// - Crash-Clips brechen den laufenden ab und leeren die Queue
// - Geraeusche (rotor, crash, birds, planes) laufen via playSound()
// auf eigenen Kanaelen und sind davon nicht betroffen
// ===========================================================
const SPEECH_GAP_MS = 10000;
const SPEECH_TTL_MS = 120000;
let speechCurrent = null; // {src, name}
let speechFreeAt = 0; // performance.now() ab wann naechster spielen darf
const speechQueue = []; // [{name, prio, until}]
function isInfoOpen() {
return document.querySelector('.overlay.show') !== null;
}
function isSpeechName(name) {
return name.startsWith('pilot_') || name.startsWith('warn_')
|| name.startsWith('mission_start_')
|| name.startsWith('start_erfolg')
|| name.startsWith('strike') || name.startsWith('takeover')
|| name.startsWith('tower') || name.startsWith('traffic')
|| 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 (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 = name + '_' + pilotVoice;
if (!RADIO_FILES[name] && !RADIO_FILES[voiceName]) return;
if (!isSpeechName(name)) {
// Geraeusch / nicht-Sprach: direkt spielen
_playRadioBuffer(name, false);
return;
}
const prio = priorityOf(name, opts);
// Crash-Prioritaet: laufenden Clip abbrechen, Queue leeren
if (prio === 3) {
if (speechCurrent) { try { speechCurrent.src.stop(); } catch(_){} speechCurrent = null; }
speechQueue.length = 0;
speechFreeAt = 0;
}
// Einreihen mit TTL und Sortierung nach Priority (stable: gleiche Prio
// behaelt FIFO).
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;
}
// veraltete Items wegwerfen
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) {
// V1-Audio-Filter: nur das Mission-Start-Audio durchlassen, alles andere
// (Pilot-Hinweise, Tower-Calls, Strikes, Ouch ...) bleibt fuer V2 im Code.
if (asSpeech && (window.HELI_AUDIO_SET||window.parent?.HELI_AUDIO_SET||'v1') === 'v1') {
const v1Pass = name === 'start_erfolg' || name === 'start_erfolg_m' ||
('r_'+name).startsWith('r_mission_start_');
if (!v1Pass) { speechCurrent = null; pumpSpeechQueue(); return; }
}
const voiceName = name + '_' + pilotVoice;
const buf = RADIO_FILES[voiceName] || RADIO_FILES[name];
if (!buf) { if (asSpeech) { speechCurrent = null; pumpSpeechQueue(); } return; }
const isPilotKind = name.startsWith('pilot_') || name.startsWith('warn_')
|| name.startsWith('strike') || name === 'success' || name === 'takeover'
|| name === 'ouch1' || name === 'ouch2' || name === 'ouch3';
const src = audioCtx.createBufferSource();
const fadeGain = audioCtx.createGain();
fadeGain.gain.value = 1;
src.buffer = buf;
src.connect(fadeGain);
fadeGain.connect(isPilotKind ? pilotBP : bgRadioGain);
src.start(0);
// Funk-Pille: nur fuer Sprach-Audios anzeigen
if (asSpeech && window.HeliFunk) {
try {
const heliKey = (new URLSearchParams(location.search)).get('helikey') || '';
const audioId = 'r_' + name; // Pipe-Namen ohne r_-Praefix; in Mapping mit r_
HeliFunk.show(audioId, { duration: buf.duration, heliKey: heliKey });
} catch(_){}
}
if (asSpeech) {
speechCurrent = { src, name, fadeGain };
src.onended = () => {
if (speechCurrent && speechCurrent.src === src) speechCurrent = null;
speechFreeAt = performance.now() + SPEECH_GAP_MS;
if (window.HeliFunk) HeliFunk.hide();
pumpSpeechQueue();
};
}
}
// Externer Cleanup: laufende Sprachausgabe per kurzem Fade-Out (180 ms)
// beenden statt hart abreissen — wird beim Phasen-Wechsel aufgerufen.
function clearSpeechPipe() {
if (window.HeliFunk) HeliFunk.hide();
if (speechCurrent) {
const cur = speechCurrent;
speechCurrent = null;
try {
const t = audioCtx.currentTime;
cur.fadeGain.gain.cancelScheduledValues(t);
cur.fadeGain.gain.setValueAtTime(cur.fadeGain.gain.value, t);
cur.fadeGain.gain.linearRampToValueAtTime(0, t + 0.18);
} catch(_){}
setTimeout(function(){ try { cur.src.stop(); } catch(_){} }, 220);
}
speechQueue.length = 0;
speechFreeAt = 0;
}
// Parent (game.html) signalisiert Phasen-Wechsel — Pipe sanft auslaufen lassen.
window.addEventListener('message', function(e){
if (e && e.data && e.data.type === 'heli_fade_audio') {
try { clearSpeechPipe(); } catch(_){}
}
});
// Pilot-Stimme: abwechselnd echo/alloy je nach Mission
let pilotVoice = 'echo'; // wird beim Start gesetzt
function setPilotVoice() {
// Gerade Mission-Nr: echo, ungerade: alloy
const missionCount = parseInt(localStorage.getItem('ggs_heli_missions') || '0');
pilotVoice = missionCount % 2 === 0 ? 'echo' : 'alloy';
localStorage.setItem('ggs_heli_missions', missionCount + 1);
}
// Spielt Primary-Clip; wenn Buffer fehlt, Fallback. Damit Tower-Audios
// auch greifen, bevor alle ElevenLabs-Files generiert sind.
function playRadioWithFallback(primary, fallback) {
if (RADIO_FILES[primary]) { playRadio(primary); return; }
const variants = [primary + '_echo', primary + '_alloy'];
for (const v of variants) { if (RADIO_FILES[v]) { playRadio(v); return; } }
if (fallback && (RADIO_FILES[fallback] || RADIO_FILES[fallback+'_echo'] || RADIO_FILES[fallback+'_alloy'])) {
playRadio(fallback);
}
}
let lastRadioPlayed = '';
function playRadioRandom(prefix) {
const matches = radioFileList.filter(f => f.startsWith(prefix) && f !== lastRadioPlayed);
if (matches.length === 0) {
const all = radioFileList.filter(f => f.startsWith(prefix));
if (all.length === 0) return;
lastRadioPlayed = all[0]; playRadio(all[0]); return;
}
const pick = matches[Math.floor(Math.random() * matches.length)];
lastRadioPlayed = pick;
playRadio(pick);
}
// =========== HELICOPTER VARIANTS ===========
const HELI_VARIANTS = {
oamtc: {
name: 'ÖAMTC Christophorus', short: 'ÖAMTC', tag: 'C1 Innsbruck',
bases: ['C1 Innsbruck','C5 Zams','C8 Nenzing','C4 Kitzbühel','C7 Lienz'],
body:'#e4b95c', belly:'#c97a3f', accent:'#c85c4a', text:'Christophorus'
},
ara: {
name: 'ARA Flugrettung', short: 'ARA', tag: 'RK1 Reutte',
bases: ['RK1 Reutte','RK2 Fresach'],
body:'#c85c4a', belly:'#a84a3b', accent:'#f5efe0', text:'ARA'
},
polizei: {
name: 'Polizei Libelle Tirol', short: 'Polizei', tag: 'Libelle Tirol',
bases: ['Libelle Tirol'],
body:'#3d5165', belly:'#2a3a4a', accent:'#e4b95c', text:'POLIZEI'
},
bundesheer: {
name: 'Bundesheer', short: 'Bundesheer', tag: 'Höttinger Au',
bases: ['Höttinger Au'],
body:'#5b7a5a', belly:'#445e45', accent:'#2d3a2d', text:'BUNDESHEER'
}
};
// =========== SCENARIOS ===========
const SCENARIOS = {
basis: { name: 'Basisstart', desc:'Ländlicher Landeplatz am Boden. Wenige Einfamilienhäuser, etwas Wald, freie Wiesen. Ruhige Umgebung zum Üben.' },
stadt: { name: 'Stadt · Krankenhaus', desc:'Start vom Spitaldach mitten in der Stadt. Dichte Häuserfronten, Antennenmast, Kran auf einer Baustelle. Gewitterzellen möglich.' },
natur: { name: 'Natur mit Bäumen', desc:'Lichtung zwischen sehr hohen Nadelbäumen. Viele Vogelschwärme, unruhiger Bergwind, enger Korridor zwischen den Wipfeln.' },
zeit: { name: 'Zeitdruck · Industrie', desc:'Notfalleinsatz im Industriegebiet. Fabrikhallen, Schornsteine, Hochspannungsleitungen. Knapp Zeit — aber wenige Hindernisse.' },
unuebersichtlich: { name:'Nebelwarnung', desc:'Dichte Nebelschwaden über der Landschaft, kaum Sicht. Hindernisse sind da, aber meist nicht zu erkennen.' }
};
// Per-scenario obstacle parameters — exposed as sliders in the start panel.
// Defaults can be overridden by user, later by an admin-defined level.
const SCENARIO_PARAMS = {
basis: {
houses: {min:0, max:14, def:8, label:'Häuser'},
trees_tall: {min:0, max:14, def:6, label:'Hohe Bäume'},
flocks: {min:0, max:8, def:2, label:'Vogelschwärme'},
airliners: {min:0, max:3, def:1, label:'Verkehrsflugzeuge'},
sportplanes: {min:0, max:4, def:2, label:'Sportflieger'},
storms: {min:0, max:4, def:1, label:'Gewitterzellen'}
},
stadt: {
antennas: {min:0, max:6, def:2, label:'Antennen'},
cranes: {min:0, max:6, def:2, label:'Baukräne'},
chimneys: {min:0, max:8, def:3, label:'Kamine'},
acs: {min:0, max:8, def:3, label:'Klimageräte'},
storms: {min:0, max:6, def:4, label:'Gewitterzellen'},
airliners: {min:0, max:5, def:2, label:'Verkehrsflugzeuge'},
sportplanes: {min:0, max:8, def:4, label:'Sportflieger'},
flocks: {min:0, max:10, def:4, label:'Vogelschwärme'}
},
natur: {
trees_tall: {min:1, max:30, def:18, label:'Hohe Bäume'},
flocks: {min:0, max:16, def:10, label:'Vogelschwärme'},
sportplanes: {min:0, max:3, def:1, label:'Sportflieger'},
storms: {min:0, max:4, def:2, label:'Gewitterzellen'}
},
zeit: {
factories: {min:1, max:6, def:4, label:'Fabriken'},
powerlines: {min:0, max:8, def:5, label:'Hochspannungsleitungen'},
sportplanes: {min:0, max:4, def:1, label:'Sportflieger'},
flocks: {min:0, max:6, def:2, label:'Vogelschwärme'},
storms: {min:0, max:4, def:1, label:'Gewitterzellen'},
time: {min:20, max:90, def:45, label:'Zeitlimit (s)'}
},
unuebersichtlich: {
fog_density: {min:10, max:80, def:48, label:'Nebeldichte'},
fog_alpha: {min:50, max:100, def:92, label:'Nebelstärke (%)'},
houses: {min:0, max:12, def:7, label:'Häuser'},
trees_tall: {min:0, max:14, def:7, label:'Hohe Bäume'},
sportplanes: {min:0, max:3, def:1, label:'Sportflieger'},
flocks: {min:0, max:6, def:2, label:'Vogelschwärme'},
storms: {min:0, max:4, def:2, label:'Gewitterzellen'}
}
};
const C = {
sky1:'#eaf1ea', sky2:'#d9e6dc',
cloud:'#f5efe0', stormCloud:'#6b7d8f', stormDark:'#3d5165',
hillFar:'#b8cfae', hillMid:'#8aab86', hillNear:'#5b7a5a',
mountain:'#5b7a5a', mountainDark:'#3d5a3d', mountainSnow:'#f5efe0',
ground:'#3d5165', groundDark:'#2f4150',
meadow:'#7a9b76', meadowDark:'#5b7a5a',
bldgFar:'#d4c8ae', bldgFarRoof:'#b8a885',
bldgMid:'#ece4d0', bldgMidRoof:'#a84a3b',
bldgNear:'#c85c4a', bldgNearRoof:'#8a3e31',
bldgNear2:'#f5efe0', bldgNear2Roof:'#3d5165',
win:'#3d5165', winLit:'#e4b95c',
glass:'#a8c3a0', rotor:'#1f2a24',
helipad:'#3d5165',
planeBody:'#f5efe0', planeAccent:'#c85c4a',
sportBody:'#e4b95c', sportAccent:'#c97a3f',
bird:'#2d5434',
treeTrunk:'#5b4a3a', treeDark:'#3d5a3d', treeMid:'#5b7a5a',
yellow:'#e4b95c', red:'#c85c4a', slate:'#3d5165',
forest:'#2d5434', cream:'#f5efe0', orange:'#c97a3f',
wall:'#f5efe0', wallShade:'#ece4d0', sage:'#7a9b76'
};
// Winter-Overlay: URL-Param ?winter=1 bei Ski-/Lawinen-Missionen.
// Überschreibt nur Natur-Farben (Himmel, Hügel, Berge, Wiesen, Bäume) — Gebäude/Himmel-Formen bleiben.
const _urlWinter = new URLSearchParams(window.location.search).get('winter') === '1';
if (_urlWinter) {
C.sky1 = '#e5eef5'; C.sky2 = '#cfd9e3';
C.hillFar = '#d8dce3'; C.hillMid = '#b8bec8'; C.hillNear = '#9aa3b0';
C.mountain = '#9aa3b0'; C.mountainDark = '#707a88'; C.mountainSnow = '#ffffff';
C.meadow = '#eaeff5'; C.meadowDark = '#d0d8e0';
C.treeDark = '#8aa3b5'; C.treeMid = '#b5c8d5';
C.ground = '#e8edf3'; C.groundDark = '#c8d0da';
}
const SKY_TOP = 40;
let GROUND_Y = 700; // scenario may adjust
let WORLD_W = 5200;
let EXIT_X = 4900;
const state = {
t:0, mode:'ready',
camX: 0,
selectedHeli: 'oamtc',
selectedScenario: 'stadt',
params: {}, // populated from SCENARIO_PARAMS defaults
heli:{
x: 380, y: 464, 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, flapTimer:0
},
keys:{}, prevKeys:{},
wind: 0, gust:0, turbulence: 0,
clouds:[],
bgFar:[], bgMid:[], bgNear:[],
obstacles:[], airTraffic:[], storms:[], trees:[],
houses:[], factories:[], powerlines:[],
rainDrops:[], lightningBolts:[], fogWisps:[],
radio:{messages:[], current:null, nextAt: 3 + Math.random()*4, shown:0},
valleyWalls:null,
fog: 0, snow:[],
timeLimit:null, timeLeft:null,
helipad:null, hospital:null, airport:null,
spawn:{x:380, y:464},
scenarioName:'',
autopilotT:0, shake:0
};
// Initialize params with defaults from SCENARIO_PARAMS
for(const scen of Object.keys(SCENARIO_PARAMS)){
state.params[scen] = {};
for(const key of Object.keys(SCENARIO_PARAMS[scen])){
state.params[scen][key] = SCENARIO_PARAMS[scen][key].def;
}
}
// Helper: read parameter for current scenario
function P(key, fallback){
const v = state.params[state.selectedScenario];
return v && key in v ? v[key] : fallback;
}
function genFlock(n){
const arr = [];
for(let i=0;i<n;i++){
arr.push({dx:(i-1)*22 + (Math.random()*10-5), dy:(Math.random()*16-8), phase:Math.random()*Math.PI*2});
}
return arr;
}
function fillBuildings(minW, maxW, minH, maxH, yBase, skipRange){
const arr = [];
let x = 10;
while(x < WORLD_W){
const w = minW + Math.random()*(maxW-minW);
const h = minH + Math.random()*(maxH-minH);
if(skipRange && x+w > skipRange[0] && x < skipRange[1]){
x = skipRange[1] + 10;
continue;
}
arr.push({ x, y: yBase - h, w, h });
x += w + 8 + Math.random()*20;
}
return arr;
}
// ========== SCENARIO BUILDERS ==========
// ===== Generic spawn helpers (parameter-driven, evenly distributed) =====
function evenlySpaced(count, startX, endX, jitter){
const out = [];
if(count <= 0) return out;
const span = endX - startX;
for(let i=0;i<count;i++){
const x = startX + (span * (i + 0.5) / count) + (Math.random()*2-1) * (jitter||0);
out.push(x);
}
return out;
}
function spawnAirliners(n){
// Verteilt n Airliner im Weltraum; Richtung zufällig (links↔rechts)
return evenlySpaced(n, 700, WORLD_W-200, 120).map(x => {
const dir = Math.random()<0.5 ? -1 : 1;
return {
kind:'airliner', x, y: 60 + Math.random()*40,
vx: dir * (65 + Math.random()*20), w:120, h:26
};
});
}
function spawnSportPlanes(n){
return evenlySpaced(n, 800, WORLD_W-200, 150).map(x => ({
kind:'sport', x, y: 200 + Math.random()*150,
vx: (Math.random()<0.5?-1:1) * (45 + Math.random()*20), w:56, h:18
}));
}
function spawnFlocks(n, sizeMin, sizeMax){
return evenlySpaced(n, 700, WORLD_W-200, 200).map(x => {
// 35% chance of high altitude, otherwise mid altitude
const high = Math.random() < 0.35;
const y = high ? 80 + Math.random()*150 : 280 + Math.random()*180;
return {
kind:'flock', x, y,
vx: (Math.random()<0.5?-1:1) * (22 + Math.random()*15),
members: genFlock(sizeMin + Math.floor(Math.random()*(sizeMax-sizeMin+1)))
};
});
}
function spawnStorms(n){
return evenlySpaced(n, 1000, WORLD_W-400, 200).map(x => ({
x, y: 180 + Math.random()*60, r: 95 + Math.random()*15,
lastFlash:0, nextFlash: 2+Math.random()*3, nextBolt: 3+Math.random()*4
}));
}
function spawnHouses(n){
return evenlySpaced(n, 700, WORLD_W-300, 120).map(x => ({
x, y: GROUND_Y, w:90, h:70, variant: Math.floor(Math.random()*3)
}));
}
function spawnTallTrees(n){
return evenlySpaced(n, 600, WORLD_W-200, 100).map(x => ({
x, y: GROUND_Y, s: 1.8 + Math.random()*1.0, collide:true, tall:true
}));
}
function spawnFactories(n){
return evenlySpaced(n, 900, WORLD_W-300, 200).map((x, i) => ({
x, y: GROUND_Y, w: 160 + Math.random()*80, h: 90 + Math.random()*40,
stacks: i % 2 === 0 ? [{dx:30, h:130+Math.random()*30},{dx:140, h:120+Math.random()*30}]
: [{dx:50, h:140+Math.random()*40}]
}));
}
function spawnPowerlines(n){
return evenlySpaced(n, 700, WORLD_W-300, 100).map(x => ({
x1: x-110, x2: x+110, y: 380 + Math.random()*70, pylonH: 150 + Math.random()*40
}));
}
function spawnCranes(n, skipRange){
const out = [];
const positions = evenlySpaced(n, 600, WORLD_W-300, 200);
for(const x of positions){
if(skipRange && x > skipRange[0] && x < skipRange[1]) continue;
out.push({kind:'crane', x, y:260, vertH:GROUND_Y-260,
armX1:x-90, armX2:x+110, armY:260});
}
return out;
}
function clearScene(){
state.clouds = []; state.bgFar = []; state.bgMid = []; state.bgNear = [];
state.obstacles = []; state.airTraffic = []; state.storms = []; state.trees = [];
state.houses = []; state.factories = []; state.powerlines = [];
state.rainDrops = []; state.lightningBolts = []; state.fogWisps = [];
state.fog = 0; state.snow = [];
state.timeLimit = null; state.timeLeft = null;
state.hospital = null;
state.turbulence = 0;
state.radio = {messages:[], current:null, nextAt: 4 + Math.random()*4, shown:0};
state.fogParams = {density: 0, alpha: 0};
}
function buildBasis(){
clearScene();
WORLD_W = 5200; EXIT_X = 4900; GROUND_Y = 700;
state.scenarioName = 'Basisstart';
for(let i=0;i<10;i++) state.clouds.push({x:Math.random()*WORLD_W, y:55+Math.random()*140, s:.5+Math.random()*.9, v:3+Math.random()*6});
state.houses = spawnHouses(P('houses', 6));
// Sparse background trees + parameter-controlled tall ones
for(let x=400; x<WORLD_W; x+=80+Math.random()*100){
state.trees.push({x, y:GROUND_Y, s:.6+Math.random()*.3, collide:false, bg:true});
}
state.trees.push(...spawnTallTrees(P('trees_tall', 4)));
state.helipad = {x: 260, y: GROUND_Y-4, w: 160, onGround: true};
state.airport = {x: WORLD_W-300, y: GROUND_Y-60};
state.spawn = {x: 340, y: GROUND_Y-18};
state.wind = (Math.random()-0.5) * 24;
state.airTraffic = [
...spawnAirliners(P('airliners', 1)),
...spawnSportPlanes(P('sportplanes', 2)),
...spawnFlocks(P('flocks', 2), 2, 3)
];
state.storms = spawnStorms(P('storms', 1));
state.radio.messages = [
{text:'Pilot: Wetterlage stabil, Hochdruck. VFR-Bedingungen, top. Standby.', audio:'pilot_calm1'},
{text:'Pilot: Wind aus Nordwest, zwei bis drei Beaufort. Kein Drama für den Kurs.', audio:'pilot_calm2'},
{text:'Pilot: Sicht über 15 Kilometer — wir sehen alles. Roger.', audio:'pilot_calm3'},
];
}
function buildStadt(){
clearScene();
WORLD_W = 5200; EXIT_X = 4900; GROUND_Y = 700;
state.scenarioName = 'Stadt · Krankenhaus';
for(let i=0;i<18;i++) state.clouds.push({x:Math.random()*WORLD_W, y:55+Math.random()*150, s:.5+Math.random()*.9, v:3+Math.random()*6});
state.bgFar = fillBuildings(70, 130, 60, 120, GROUND_Y-22, null);
state.bgMid = fillBuildings(60, 110, 90, 180, GROUND_Y-18, null);
state.bgNear = fillBuildings(50, 100, 70, 160, GROUND_Y-4, [150, 560]);
state.hospital = {x:220, y:480, w:320, h:220};
state.helipad = {x:300, y:478, w:160};
state.airport = {x: WORLD_W-300, y: GROUND_Y-60};
state.spawn = {x: 380, y: 464};
// Rooftop obstacles distributed across the world (some on hospital, some on other rooftops)
const obs = [];
// Antennas: first one always on hospital, rest distributed
const antPositions = evenlySpaced(P('antennas', 1), 250, WORLD_W-300, 150);
antPositions.forEach((x, i) => {
const y = (i === 0) ? 340 : (GROUND_Y - 240 - Math.random()*60);
obs.push({kind:'antenna', x, y, w:6, h: GROUND_Y - y});
});
// AC-Klimageraete frueher rausgenommen — zu oft unklar platziert,
// die Schornsteine reichen als Dach-Hindernis.
// Chimneys — nur auf echten Gebäudedächern.
// fillBuildings setzt b.y = top des Gebaeudes. Kamin sitzt direkt auf dem
// Dach: Kamin-Bottom = b.y, Kamin-Top = b.y - 32.
const chPositions = evenlySpaced(P('chimneys', 2), 350, WORLD_W-300, 200);
chPositions.forEach((x) => {
const b = state.bgNear.find(b => x >= b.x + 8 && x + 14 <= b.x + b.w - 8);
if (!b) return;
obs.push({kind:'chimney', x, y: b.y - 32, w:14, h:32});
});
// Cranes — distributed; skip the hospital area
obs.push(...spawnCranes(P('cranes', 1), [150, 560]));
state.obstacles = obs;
state.airTraffic = [
...spawnAirliners(P('airliners', 2)),
...spawnSportPlanes(P('sportplanes', 3)),
...spawnFlocks(P('flocks', 3), 2, 3)
];
state.storms = spawnStorms(P('storms', 2));
state.wind = -15 - Math.random() * 25;
state.radio.messages = [
{text:'Pilot: Kran rechts, 40 Meter. Mindestabstand, Copy?', audio:'warn_kran'},
{text:'Pilot: Antenne links, 120 Meter hoch. Umfliegen mit Puffer.', audio:'warn_antenne'},
{text:'Pilot: Lee-Turbulenz zwischen den Häusern — haltet euch fest.', audio:'pilot_gusts'},
{text:'Pilot: Urbaner Canyon. Über Dachkante bleiben. Roger.', audio:'pilot_gap'},
];
}
function buildNatur(){
clearScene();
WORLD_W = 5200; EXIT_X = 4900; GROUND_Y = 700;
state.scenarioName = 'Natur · Wald';
for(let i=0;i<12;i++) state.clouds.push({x:Math.random()*WORLD_W, y:55+Math.random()*140, s:.5+Math.random()*.9, v:3+Math.random()*6});
// Background canopy — many small trees
for(let x=30; x<WORLD_W; x+=Math.random()*50+30){
state.trees.push({x, y:GROUND_Y, s:.6+Math.random()*.3, collide:false, bg:true});
}
// Tall collidable trees from parameter
state.trees.push(...spawnTallTrees(P('trees_tall', 14)));
state.helipad = {x: 260, y: GROUND_Y-4, w: 140, onGround: true};
state.airport = {x: WORLD_W-300, y: GROUND_Y-60};
state.spawn = {x: 330, y: GROUND_Y-18};
state.airTraffic = [
...spawnFlocks(P('flocks', 10), 3, 4),
...spawnSportPlanes(P('sportplanes', 1))
];
state.storms = spawnStorms(P('storms', 2));
state.wind = 20 + Math.random() * 30;
state.turbulence = 1.4;
state.radio.messages = [
{text:'Pilot: Fichten bis 25 Meter — Puffer halten. Copy.', audio:'pilot_trees'},
{text:'Pilot: Vogelschwarm von West. Kurs anpassen, easy.', audio:'pilot_flock'},
{text:'Pilot: Windwurf im Tal — Böen drehen. Vorsicht.', audio:'pilot_wind'},
{text:'Pilot: Talenge voraus, Venturi-Effekt. Windseite halten. Over.', audio:'pilot_narrow'},
];
}
function buildZeit(){
clearScene();
WORLD_W = 4400; EXIT_X = 4100; GROUND_Y = 700;
state.scenarioName = 'Zeitdruck · Industrie';
for(let i=0;i<10;i++) state.clouds.push({x:Math.random()*WORLD_W, y:55+Math.random()*140, s:.5+Math.random()*.9, v:3+Math.random()*6});
state.bgFar = fillBuildings(80,130,40,90,GROUND_Y-22,null);
state.factories = spawnFactories(P('factories', 3));
state.powerlines = spawnPowerlines(P('powerlines', 4));
state.helipad = {x: 260, y: GROUND_Y-4, w: 160, onGround: true};
state.airport = {x: WORLD_W-300, y: GROUND_Y-60};
state.spawn = {x: 340, y: GROUND_Y-18};
state.airTraffic = [
...spawnSportPlanes(P('sportplanes', 1)),
...spawnFlocks(P('flocks', 2), 2, 3)
];
state.storms = spawnStorms(P('storms', 1));
state.wind = 10 + Math.random() * 20;
state.timeLimit = P('time', 45);
state.timeLeft = state.timeLimit;
state.radio.messages = [
{text:'Pilot: 110-kV-Freileitung voraus. Drüber, nicht drunter. Roger.', audio:'warn_strom'},
{text:'Pilot: Schornsteine als Festpunkte — visual reference.', audio:'warn_schornstein'},
{text:'Pilot: Thermik über den Hallen — Aufwinde, Achtung.', audio:'pilot_time'},
{text:'Pilot: Abwindzone links. Höher und nach rechts, copy.', audio:'warn_fabrik'},
];
}
function buildUnuebersichtlich(){
clearScene();
WORLD_W = 5200; EXIT_X = 4900; GROUND_Y = 700;
state.scenarioName = 'Nebelwarnung';
for(let i=0;i<10;i++) state.clouds.push({x:Math.random()*WORLD_W, y:55+Math.random()*200, s:.6+Math.random()*.9, v:3+Math.random()*6});
state.houses = spawnHouses(P('houses', 5));
// Sparse bg trees + tall ones
for(let x=200; x<WORLD_W; x+=120+Math.random()*100){
state.trees.push({x, y:GROUND_Y, s:.7+Math.random()*.3, collide:false, bg:true});
}
state.trees.push(...spawnTallTrees(P('trees_tall', 5)));
state.helipad = {x: 260, y: GROUND_Y-4, w: 160, onGround: true};
state.airport = {x: WORLD_W-300, y: GROUND_Y-60};
state.spawn = {x: 340, y: GROUND_Y-18};
state.airTraffic = [
...spawnSportPlanes(P('sportplanes', 1)),
...spawnFlocks(P('flocks', 2), 2, 3)
];
state.storms = spawnStorms(P('storms', 2));
state.wind = (Math.random()-0.5) * 20;
// Fog: nearly stationary in world space, dense, abundant
state.fogParams = {density: P('fog_density', 48), alpha: P('fog_alpha', 92)/100};
state.fogWisps = [];
// Distribute densely across the whole route (heli starts at ~340, exit at 4900).
// Place wisps in world coords from x=200 to x=WORLD_W-200 so >= half the flight is in fog.
for(let i=0; i<state.fogParams.density; i++){
const x = 200 + (WORLD_W - 400) * (i / state.fogParams.density)
+ (Math.random()*200 - 100);
const y = 50 + Math.random()*(GROUND_Y - 100);
spawnFogWisp(x, y);
}
// Add some extra to fill gaps and ensure overlap
for(let i=0; i<Math.floor(state.fogParams.density * 0.4); i++){
spawnFogWisp(200 + Math.random()*(WORLD_W-400), 50 + Math.random()*(GROUND_Y-100));
}
state.radio.messages = [
{text:'Pilot: Advektionsnebel unten. Sicht unter 500 Meter. Stay low.', audio:'pilot_fog1'},
{text:'Pilot: Referenzpunkt am Boden suchen — nicht am Horizont.', audio:'pilot_fog2'},
{text:'Pilot: Flat attitude, nicht blind in die Wolke. Copy?', audio:'pilot_fog3'},
{text:'Pilot: Nebel dichtet ein. Kurs halten — easy does it.', audio:'warn_nebel'},
];
}
// Spawn a fog wisp in WORLD coordinates with parallax close to houses (1.0).
// Wisps are essentially anchored to the world but with subtle relative drift.
function spawnFogWisp(worldX, worldY){
const baseAlpha = state.fogParams ? state.fogParams.alpha : 0.85;
state.fogWisps.push({
x: worldX != null ? worldX : Math.random()*WORLD_W,
y: worldY != null ? worldY : 50 + Math.random()*(GROUND_Y-100),
w: 320 + Math.random()*320,
h: 130 + Math.random()*120,
vx: -1.5 + Math.random()*3, // tiny world drift (almost still)
parallax: 0.96 + Math.random()*0.08, // 0.96-1.04: nearly locked to houses
alpha: baseAlpha * (0.8 + Math.random()*0.3),
wobble: Math.random()*Math.PI*2,
wobbleSpeed: 0.08 + Math.random()*0.12
});
}
const BUILDERS = {
basis: buildBasis, stadt: buildStadt,
natur: buildNatur, zeit: buildZeit, unuebersichtlich: buildUnuebersichtlich
};
function reset(){
stopRotor();
stopAllAmbient();
BUILDERS[state.selectedScenario]();
const h = state.heli;
h.x = state.spawn.x; h.y = state.spawn.y; h.vx=0; h.vy=0;
h.tilt=0; h.targetTilt=0;
h.onGround = true; h.fuel = 1; h.locked = true;
h.rotor=0; h.rotorSpeed=0; h.engineOn=false;
h.strikes=0; h.invul=0; h.flash=0; h.facing=1; h.flapTimer=0;
h.prevVy=0; h.prevVx=0;
state.t=0; state.mode='ready'; state.camX = 0; state.autopilotT=0; state.shake=0;
state.gHistory=[]; state.gSum=0; state.gCount=0; state.pathHistory=[];
document.getElementById('scenPill').textContent = state.scenarioName;
updateMissionBox();
showStartOverlay();
}
function updateMissionBox(){
const v = HELI_VARIANTS[state.selectedHeli];
const s = SCENARIOS[state.selectedScenario];
// Vom Wrapper übergebene Werte haben Vorrang vor den Default-Tags
const p = new URLSearchParams(location.search);
const heliNameParam = p.get('heliname');
const baseNameParam = p.get('basename');
const missionParam = p.get('mission');
const label = heliNameParam
? (baseNameParam ? `<b>${heliNameParam}</b> · ${baseNameParam}` : `<b>${heliNameParam}</b>`)
: `<b>${v.short}</b> · ${v.tag}`;
const second = missionParam ? missionParam : s.name;
document.getElementById('missionBox').innerHTML = `${label} · <b>${second}</b><br><span style="font-size:11px;color:var(--slate-light)">Ziel: Startzone nach rechts verlassen</span>`;
document.getElementById('vHeliId').textContent = heliNameParam
? (baseNameParam ? `${heliNameParam} · ${baseNameParam}` : heliNameParam)
: `${v.short} · ${v.tag}`;
}
function showStartOverlay(){
const v = HELI_VARIANTS[state.selectedHeli];
const s = SCENARIOS[state.selectedScenario];
document.getElementById('cardTag').textContent = 'Einsatz wählen';
document.getElementById('cardT').textContent = 'Bereit für den Einsatz?';
document.getElementById('cardP').innerHTML = `Du bist <b>Co-Pilot*in</b> auf deinen ersten Einsätzen. Die Chefpilotin lässt dich den Start fliegen — das ist der heikelste Teil. Ziel: <b>raus aus der Startumgebung</b>. Dann übernimmt die Pilotin die Flugnavigation.<br><br>Bei <b>drei Berührungen</b> greift die Chefpilotin ein.`;
document.getElementById('btnStart').textContent = 'Starten';
renderSelectors();
document.getElementById('overlay').classList.add('show');
}
function showResultOverlay(title, body, btn, tag){
document.getElementById('cardTag').textContent = tag || 'Einsatz';
document.getElementById('cardT').textContent = title;
document.getElementById('cardP').innerHTML = body;
document.getElementById('btnStart').textContent = btn;
renderSelectors();
document.getElementById('overlay').classList.add('show');
}
function hideOverlay(){
document.getElementById('overlay').classList.remove('show');
stopBriefingMusic();
}
// Briefing-Musik (Patagonia Stringmap v2) — laeuft nur solange das
// Info-Overlay sichtbar ist, fadet leise rein und wieder raus.
let briefingAudio = null;
function startBriefingMusic() {
if (!briefingAudio) {
briefingAudio = new Audio();
briefingAudio.loop = true;
briefingAudio.volume = 0;
const base = (document.querySelector('base')?.href || location.pathname.replace(/[^/]+$/, ''));
briefingAudio.src = 'assets/music/patagonia-stringmap-v2.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);
}
// MutationObserver: startet/stoppt Briefing-Musik automatisch, sobald
// das Overlay .show-Klasse bekommt oder verliert — unabhaengig davon,
// ob show/hide via showStartOverlay, showResultOverlay oder direkt aufgerufen wird.
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 renderSelectors(){
const hOpts = document.getElementById('heliOpts');
hOpts.innerHTML = '';
for(const key of Object.keys(HELI_VARIANTS)){
const v = HELI_VARIANTS[key];
const el = document.createElement('div');
el.className = 'opt' + (key === state.selectedHeli ? ' active' : '');
el.innerHTML = `<span class="swatch" style="background:${v.body}"></span>${v.short} <span style="opacity:.7;font-weight:400;margin-left:4px">${v.tag}</span>`;
el.onclick = () => { state.selectedHeli = key; renderSelectors(); updateMissionBox(); };
hOpts.appendChild(el);
}
const sOpts = document.getElementById('scenOpts');
sOpts.innerHTML = '';
for(const key of Object.keys(SCENARIOS)){
const s = SCENARIOS[key];
const el = document.createElement('div');
el.className = 'opt' + (key === state.selectedScenario ? ' active' : '');
el.textContent = s.name;
el.onclick = () => { state.selectedScenario = key; renderSelectors(); updateMissionBox(); };
sOpts.appendChild(el);
}
document.getElementById('scenDesc').textContent = SCENARIOS[state.selectedScenario].desc;
// Render parameter sliders for the active scenario
const slidersEl = document.getElementById('paramSliders');
slidersEl.innerHTML = '';
const cfg = SCENARIO_PARAMS[state.selectedScenario];
const cur = state.params[state.selectedScenario];
for(const key of Object.keys(cfg)){
const def = cfg[key];
const row = document.createElement('div');
row.className = 'slider-row';
row.innerHTML = `
<label>${def.label}</label>
<input type="range" min="${def.min}" max="${def.max}" value="${cur[key]}" data-key="${key}">
<span class="sval">${cur[key]}</span>
`;
const input = row.querySelector('input');
const valEl = row.querySelector('.sval');
input.addEventListener('input', () => {
cur[key] = parseInt(input.value, 10);
valEl.textContent = cur[key];
});
slidersEl.appendChild(row);
}
}
// Educational collapsible toggle
document.addEventListener('click', (e) => {
if(e.target.closest('#eduHead')){
document.getElementById('eduCollapse').classList.toggle('open');
}
});
addEventListener('keydown', e=>{
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
state.keys[e.key] = true;
if(e.key==='r' || e.key==='R') reset();
if(e.key==='m' || e.key==='M') toggleMute();
});
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; // max Auslenkung in px (etwas weniger als Base-Radius)
const DEAD = 12; // Totzone in der Mitte
let active = false, cx = 0, cy = 0, lastDx = 0, lastDy = 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))`;
lastDx = dx; lastDy = dy;
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(); });
})();
document.getElementById('btnStart').onclick = ()=>{
if(state.mode !== 'ready'){
reset();
}
state.mode = 'flying';
state.heli.engineOn = true;
if(state.timeLimit != null) state.timeLeft = state.timeLimit;
state.radio.nextAt = 2.5;
hideOverlay();
// Sound: start rotor + resume audio context + set voice
if (audioCtx.state === 'suspended') audioCtx.resume();
setPilotVoice();
startRotor();
// Tower: Abflug freigegeben + Wind-Info als Text
const windKn = Math.round(Math.abs(state.wind)/3);
const windDir = state.wind >= 0 ? 'West' : 'Ost';
state.radio.current = 'Tower: Abflug frei, Wind ' + windDir + ' ' + windKn + ' Knoten. Guten Flug.';
state.radio.shown = 0;
state.radio.nextAt = 5;
// Tower-Lehrmeisterin begrüßt den Co-Piloten beim Namen. Kein c1-Fallback
// mehr — sonst wird fuer ARA o.ae. faelschlich "Christophorus eins" gesagt.
setTimeout(function(){
const p = new URLSearchParams(location.search);
const _hk = p.get('helikey') || (p.get('heli') === 'ara' ? 'ara' : '');
if (_hk) playRadioWithFallback('tower_start_' + _hk, 'tower1');
else playRadio('tower1'); // generisches Tower-Tag, kein Heli-Name
}, 500);
};
// Preset-Modus: ?heli=...&scenario=...&autostart=1 — Heli/Szenario fix,
// Auswahl-UI ausblenden, aber Briefing und Bildungstext sichtbar lassen.
// Bearbeiter*in klickt auf „Starten", um loszufliegen (nicht automatisch).
(function(){
var params = new URLSearchParams(location.search);
if (params.get('autostart') !== '1') return;
var heli = params.get('heli') || 'oamtc';
var scenario = params.get('scenario') || 'basis';
if (HELI_VARIANTS[heli]) state.selectedHeli = heli;
if (SCENARIOS[scenario]) state.selectedScenario = scenario;
// Auswahl-Sektionen ausblenden (Heli, Szenario, Parameter)
var card = document.getElementById('card');
if (card) {
card.querySelectorAll('.sect').forEach(function(s){ s.style.display = 'none'; });
// Tag + Titel anpassen
var tag = document.getElementById('cardTag');
if (tag) tag.textContent = 'Start-Briefing';
var cardT = document.getElementById('cardT');
if (cardT) cardT.textContent = 'Abflug — lies dir das kurz durch';
}
// Szene im Hintergrund vorbereiten (damit Klick → sofort startet)
setTimeout(function(){ reset(); }, 100);
// V1-Audio: Mission-Start-Audio einmalig spielen (Heli + Auftrag + Freigabe)
var missionId = params.get('missionid') || '';
if (missionId) {
setTimeout(function(){
if (typeof playRadio === 'function') playRadio('mission_start_' + missionId);
}, 1200);
}
})();
function keyPressed(k){ return state.keys[k] && !state.prevKeys[k]; }
// ========== UPDATE ==========
function update(dt){
const h = state.heli;
// Inspect-Modus: Welt bleibt stehen, nur Rendern laeuft weiter
if (state.mode === 'inspect') return;
const overlay = document.getElementById('overlay').classList.contains('show');
// Wind: variabel, ändert sich langsam, gelegentlich umschlagend
if (!state.windTimer) state.windTimer = 0;
state.windTimer += dt;
if (state.windTimer > 8 + Math.random() * 12) {
state.windTimer = 0;
// Wind ändert sich: 70% leicht, 20% stark, 10% Umschlag
const r = Math.random();
if (r < 0.1) {
state.wind = -state.wind * (0.5 + Math.random()); // Umschlag
state.radio.current = 'Pilot: Wind dreht! Gegenhalten!';
state.radio.shown = 0;
state.radio.nextAt = 5;
playRadio('pilot_wind_turn');
} else if (r < 0.3) {
state.wind += (Math.random() - 0.5) * 30; // Starke Änderung
} else {
state.wind += (Math.random() - 0.5) * 10; // Leichte Änderung
}
state.wind = Math.max(-60, Math.min(60, state.wind));
}
// Wind jitter + turbulence
const turbScale = 1 + state.turbulence * 1.5;
state.gust += (Math.random()-0.5) * 40 * turbScale * dt;
state.gust *= 0.96;
const effectiveWind = state.wind + state.gust;
// Clouds always drift
for(const c of state.clouds){
c.x += (c.v + effectiveWind*0.02) * dt * 0.2;
if(c.x > WORLD_W+80) c.x = -80;
if(c.x < -80) c.x = WORLD_W+80;
}
// Fog wisps: nearly-stationary world-space drift
for(const wisp of state.fogWisps){
wisp.x += wisp.vx * dt;
wisp.wobble += wisp.wobbleSpeed * dt;
// Wrap within world bounds (very rare given tiny drift)
if(wisp.x > WORLD_W + 100) wisp.x = -100;
if(wisp.x < -200) wisp.x = WORLD_W + 100;
}
// Rain drops: spawn from storm clouds, fall to ground
for(const storm of state.storms){
// Spawn rate scales with closeness to viewport
const sx = storm.x - state.camX;
if(sx > -200 && sx < W+200){
const spawnCount = Math.min(5, Math.floor(80 * dt)); // ~80/s
for(let i=0;i<spawnCount;i++){
if(Math.random() < 1){
state.rainDrops.push({
x: storm.x + (Math.random()*storm.r*4 - storm.r*2),
y: storm.y + storm.r*0.4 + Math.random()*10,
vx: -20 + effectiveWind*0.3,
vy: 280 + Math.random()*80,
life: 1.8
});
}
}
}
}
// Update rain drops
for(let i=state.rainDrops.length-1; i>=0; i--){
const d = state.rainDrops[i];
d.x += d.vx * dt;
d.y += d.vy * dt;
d.life -= dt;
if(d.y > GROUND_Y - 5 || d.life <= 0){
state.rainDrops.splice(i, 1);
}
}
// Update lightning bolts (fade out)
for(let i=state.lightningBolts.length-1; i>=0; i--){
const b = state.lightningBolts[i];
b.life -= dt;
if(b.life <= 0) state.lightningBolts.splice(i, 1);
}
// Radio messages
if(state.radio.messages.length > 0){
state.radio.nextAt -= dt;
if(state.radio.current){
state.radio.shown += dt;
if(state.radio.shown > 5.5) state.radio.current = null;
}
if(state.radio.nextAt <= 0 && !state.radio.current && state.mode === 'flying'){
// Shuffle-Queue: jede Nachricht einmal, bevor wiederholt wird; nie zweimal hintereinander dieselbe
if (!state.radio.queue || state.radio.queueIdx >= state.radio.queue.length) {
const shuffled = state.radio.messages.slice().sort(() => Math.random() - 0.5);
if (state.radio.lastPlayed && shuffled.length > 1 && shuffled[0] === state.radio.lastPlayed) {
[shuffled[0], shuffled[1]] = [shuffled[1], shuffled[0]];
}
state.radio.queue = shuffled;
state.radio.queueIdx = 0;
}
const pick = state.radio.queue[state.radio.queueIdx++];
state.radio.lastPlayed = pick;
// Messages are now {text, audio} objects
if (typeof pick === 'object') {
state.radio.current = pick.text;
playRadio(pick.audio);
} else {
state.radio.current = pick;
}
state.radio.shown = 0;
state.radio.nextAt = 7 + Math.random()*6;
// Gelegentlich (15%) Background-Funk
if (Math.random() < 0.15) {
setTimeout(function(){ playRadioRandom(Math.random()<0.5 ? 'traffic' : 'tower'); }, 2500 + Math.random()*3000);
}
}
// === PROXIMITY WARNINGS (Pilot kündigt nahe Hindernisse an) ===
if (!state._lastProxWarn) state._lastProxWarn = 0;
if (state.t - state._lastProxWarn > 4) { // Max alle 4 Sekunden
let warned = false;
// Vögel in der Nähe
for (const a of state.airTraffic) {
if (warned) break;
const dist = Math.hypot(h.x - a.x, h.y - a.y);
if (a.kind === 'flock' && dist < 250) {
state.radio.current = 'Pilot: Vogelschwarm voraus!';
state.radio.shown = 0; state.radio.nextAt = 5;
playRadio('pilot_bird_ahead'); warned = true; state._lastProxWarn = state.t;
}
if (a.kind === 'airliner' && dist < 350) {
state.radio.current = 'Pilot: Verkehrsflugzeug im Anflug!';
state.radio.shown = 0; state.radio.nextAt = 5;
playRadio('pilot_plane_ahead'); warned = true; state._lastProxWarn = state.t;
}
if (a.kind === 'sport' && dist < 280) {
state.radio.current = 'Pilot: Sportflieger auf gleicher Höhe!';
state.radio.shown = 0; state.radio.nextAt = 5;
playRadio('pilot_sportplane_level'); warned = true; state._lastProxWarn = state.t;
}
}
// Gewitter in der Nähe
if (!warned) {
for (const s of state.storms) {
const dist = Math.hypot(h.x - s.x, h.y - s.y);
if (dist < s.r * 2.5) {
state.radio.current = 'Pilot: Gewitter voraus, umfliegen!';
state.radio.shown = 0; state.radio.nextAt = 5;
playRadio('pilot_storm_detour'); warned = true; state._lastProxWarn = state.t;
break;
}
}
}
// Nebel
if (!warned && state.fog > 0.3 && Math.random() < 0.02) {
state.radio.current = 'Pilot: Nebel wird dichter, vorsichtig!';
state.radio.shown = 0; state.radio.nextAt = 5;
playRadio('pilot_fog_thickens'); state._lastProxWarn = state.t;
}
}
}
if(overlay) { state.prevKeys = {...state.keys}; return; }
// Rotor
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 — climbs high and flies out
if(state.mode === 'autopilot'){
state.autopilotT += dt;
// Phase 1 (0-2s): steep climb, reduce forward motion
// Phase 2 (2-6s): continue climbing while accelerating forward out of frame
const phase = state.autopilotT;
let targetVy, targetVx, targetTilt;
if(phase < 2){
targetVy = -140; // strong climb
targetVx = 60; // gentle forward
targetTilt = 0.08;
} else {
targetVy = -90; // keep climbing
targetVx = 220; // accelerate out of frame
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;
// keep heli in frame by following
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);
// After 6 seconds, heli is well out of the start zone
if(state.autopilotT > 6){
state.mode = 'takeover';
stopAllAmbient();
// Im Standalone-Modus: Overlay zeigen. Im Game-Flow: weiter zur Karte
if (window.parent !== window) {
// Pilot hat übernommen → trotzdem weiter, aber 1 Stern
setTimeout(function(){ window.parent.postMessage({type:'heli_start_complete', stars:1, takeover:true, reason: state.takeoverShort || state.takeoverReason || 'Chefpilotin hat übernommen'}, '*'); }, 2000);
} else {
const reason = state.takeoverReason || 'Die Chefpilotin hat das Steuer übernommen und den Helikopter aus der Startzone geflogen.';
const short = state.takeoverShort ? ` · <b>${state.takeoverShort}</b>` : '';
showResultOverlay(
'🧑‍✈️ Die Pilotin übernimmt',
`${reason}<br><br>Kein Drama. Du bist Co-Pilot*in im Training, dafür fliegst du ja mit.<br><br>Szenario: <b>${state.scenarioName}</b>${short}.`,
'Nochmal versuchen',
'Chefpilotin'
);
}
}
state.prevKeys = {...state.keys};
return;
}
// Time limit countdown
if(state.timeLimit != null && state.mode === 'flying'){
state.timeLeft -= dt;
if(state.timeLeft <= 0){
state.timeLeft = 0;
state.mode = 'autopilot';
stopAllAmbient();
state.autopilotT = 0;
state.takeoverReason = 'Einsatzzeit überschritten. Bei zeitkritischen Notfällen (z. B. Reanimation, Lawine) ist jede Minute entscheidend — wenn das Zeitfenster reißt, übernimmt die Chefpilotin, um die Situation nicht zu verschlechtern.';
state.takeoverShort = 'Zeitlimit überschritten';
state.radio.current = 'Pilot: Zeit abgelaufen. Ich übernehme — Einsatzfenster verpasst.';
state.radio.shown = 0; state.radio.nextAt = 99;
playRadio('pilot_time_up');
h.invul = 999;
}
}
if(state.mode !== 'flying') { state.prevKeys = {...state.keys}; return; }
// LOCKED: heli stays fixed on pad until first directional input
if(h.locked){
h.x = state.spawn.x;
h.y = state.spawn.y;
h.vx = 0; h.vy = 0;
h.tilt = 0;
// Unlock on any directional key press, then fall through to physics so the flap takes effect
const wantUnlock = keyPressed('ArrowUp') || keyPressed('ArrowLeft') || keyPressed('ArrowRight');
if(wantUnlock){
h.locked = false;
// continue to physics block below
} else {
state.prevKeys = {...state.keys};
return;
}
}
// Flappy physics
const GRAVITY = 300;
const FLAP_IMPULSE = 200;
const FLAP_COOLDOWN = 0.16;
const MAX_VY_DOWN = 300, MAX_VY_UP = 280;
h.flapTimer += dt;
const wantFlap = keyPressed('ArrowUp') || (state.keys['ArrowUp'] && 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;
updateRotor(1.0, h.y); // Gas geben = volle Drehzahl
}
if(state.keys['ArrowLeft']) h.targetTilt = -0.32;
else if(state.keys['ArrowRight']) h.targetTilt = 0.32;
else h.targetTilt = 0;
h.tilt += (h.targetTilt - h.tilt) * Math.min(1, 6*dt);
h.vy += GRAVITY * dt;
h.vx += Math.sin(h.tilt) * 320 * dt + effectiveWind * dt;
// Storm downforce: rain pushes heli downward when underneath a storm cloud
let stormDown = 0;
for(const s of state.storms){
const dx = h.x - s.x;
const dy = h.y - s.y;
// Under cloud (within horizontal spread of triple-cloud, below cloud base)
if(Math.abs(dx) < s.r * 2.0 && dy > s.r * 0.3){
// Strength fades with horizontal distance from center
const distFactor = 1 - Math.min(1, Math.abs(dx) / (s.r * 2.0));
// Also fade with vertical distance from cloud base (strongest right under, weaker far below)
const vertFactor = Math.max(0.4, 1 - (dy - s.r*0.3) / 400);
stormDown += 220 * distFactor * vertFactor;
}
}
h.vy += stormDown * dt;
h.vy = Math.min(MAX_VY_DOWN, Math.max(-MAX_VY_UP, h.vy));
h.vx = Math.max(-280, Math.min(280, h.vx));
h.vx *= Math.pow(0.99, dt*60);
h.vy *= Math.pow(0.995, dt*60);
// Spritverbrauch: Physikalisches Modell analog landing.html
// Grundverbrauch 1x (Rotor gegen Schwerkraft)
// +0.8x bei aktivem Flap (Pitch hoch)
// +vy-Faktor bei Steigen (bis +1.0x), -Faktor bei Sinken (bis -0.5x)
// x altFactor (Hoehe, duenne Luft an der Decke)
const alt = Math.max(0, GROUND_Y - h.y - 14);
const altRatio = alt / (GROUND_Y - SKY_TOP);
const altFactor = 1 + altRatio * 0.5 + (altRatio > 0.9 ? 3.0 : 0);
let thrustMult = 1.0;
if (wantFlap) thrustMult += 0.8;
if (h.vy < -40) thrustMult += Math.min(1.0, -h.vy / 160);
else if (h.vy > 60) thrustMult = Math.max(0.4, thrustMult - 0.5);
h.fuel = Math.max(0.05, h.fuel - 0.005 * altFactor * thrustMult * dt);
// Pilot warnt bei extrem hohem Verbrauch (nur an der Decke)
if (!state._fuelWarned && h.fuel < 0.35 && altFactor > 1.8) {
state._fuelWarned = true;
state.radio.current = 'Pilot: Nicht so hoch, das braucht zu viel Sprit!';
state.radio.shown = 0; state.radio.nextAt = 6;
playRadio('pilot_too_high');
}
if (h.fuel > 0.5) state._fuelWarned = false;
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 / ground
const pad = state.helipad;
const hhw = 34, hhh = 14;
if(pad){
if(h.y + hhh >= pad.y && h.x > pad.x-10 && h.x < pad.x+pad.w+10 && h.vy >= 0){
if(h.vy > 220 && h.invul <= 0) strike('Harte Landung');
h.y = pad.y - hhh;
if(h.vy > 0) h.vy *= -0.15;
}
}
if(h.y + hhh >= GROUND_Y){
h.y = GROUND_Y - hhh;
if(h.invul <= 0) strike('Bodenberührung');
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); }
if(h.invul <= 0){
// Rooftop obstacles
for(const o of state.obstacles){
let hit = false, msg = '';
if(o.kind==='antenna' || o.kind==='chimney' || o.kind==='ac'){
if(rectOverlap(h.x-hhw, h.y-hhh, hhw*2, hhh*2, o.x, o.y, o.w, o.h)){
hit = true; msg = 'Berührung mit ' + labelOf(o.kind);
}
} else if(o.kind==='crane'){
if(rectOverlap(h.x-hhw, h.y-hhh, hhw*2, hhh*2, o.x-4, o.armY, 8, o.vertH)){ hit=true; msg='Kran-Mast'; }
else if(rectOverlap(h.x-hhw, h.y-hhh, hhw*2, hhh*2, o.armX1, o.armY-4, o.armX2-o.armX1, 8)){ hit=true; msg='Kranausleger'; }
}
if(hit){ strike(msg); break; }
}
// Near buildings
if(h.invul <= 0){
for(const b of state.bgNear){
if(rectOverlap(h.x-hhw+6, h.y-hhh+2, hhw*2-12, hhh*2-4, b.x, b.y, b.w, b.h)){ strike('Gebäude gestreift'); break; }
}
}
// Trees (collidable)
if(h.invul <= 0){
for(const t of state.trees){
if(!t.collide) continue;
const tw = 40*t.s, th = 80*t.s;
if(rectOverlap(h.x-hhw+8, h.y-hhh+4, hhw*2-16, hhh*2-8, t.x-tw/2, t.y-th, tw, th)){
strike('Baum gestreift'); break;
}
}
}
// Valley walls (polygon) — removed with Tal scenario
// Houses (single-family) — collide with box + roof
if(h.invul <= 0){
for(const hs of state.houses){
if(rectOverlap(h.x-hhw+6, h.y-hhh+2, hhw*2-12, hhh*2-4, hs.x-hs.w/2, hs.y-hs.h-20, hs.w, hs.h+20)){
strike('Haus gestreift'); break;
}
}
}
// Factory buildings + their smokestacks
if(h.invul <= 0){
for(const f of state.factories){
if(rectOverlap(h.x-hhw+6, h.y-hhh+2, hhw*2-12, hhh*2-4, f.x, f.y-f.h, f.w, f.h)){ strike('Fabrik gestreift'); break; }
let hit = false;
for(const st of f.stacks){
const sx = f.x + st.dx;
if(rectOverlap(h.x-hhw+4, h.y-hhh+2, hhw*2-8, hhh*2-4, sx-10, f.y-st.h, 20, st.h)){
strike('Schornstein gestreift'); hit = true; break;
}
}
if(hit) break;
}
}
// Power lines (thin horizontal obstacles) + pylons
if(h.invul <= 0){
for(const p of state.powerlines){
// Pylons
if(rectOverlap(h.x-hhw+4, h.y-hhh+2, hhw*2-8, hhh*2-4, p.x1-6, p.y, 12, p.pylonH)){ strike('Strommast'); break; }
if(rectOverlap(h.x-hhw+4, h.y-hhh+2, hhw*2-8, hhh*2-4, p.x2-6, p.y, 12, p.pylonH)){ strike('Strommast'); break; }
// Lines (3 parallel cables)
for(let li=0; li<3; li++){
const ly = p.y + 8 + li*8;
if(h.x > p.x1 && h.x < p.x2 && Math.abs(h.y - ly) < hhh){
strike('Hochspannungsleitung'); break;
}
}
}
}
// Air traffic
if(h.invul <= 0){
for(const a of state.airTraffic){
if(a.kind==='airliner' || a.kind==='sport'){
// Größerer Sicherheitsabstand: 1.6x Hitbox
const margin = a.kind==='airliner' ? 1.6 : 1.3;
if(rectOverlap(h.x-hhw, h.y-hhh, hhw*2, hhh*2, a.x-a.w*margin/2, a.y-a.h*margin/2, a.w*margin, a.h*margin)){
strike(a.kind==='airliner' ? 'Fast-Kollision Verkehrsflugzeug' : 'Sportflieger gestreift'); break;
}
} else if(a.kind==='flock'){
for(const m of a.members){
const bx = a.x + m.dx;
const by = a.y + m.dy + Math.sin(m.phase)*6;
if(rectOverlap(h.x-hhw, h.y-hhh, hhw*2, hhh*2, bx-10, by-6, 20, 12)){
strike('Vogelschlag'); break;
}
}
if(h.invul > 0) break;
}
}
}
// Storms
if(h.invul <= 0){
for(const s of state.storms){
const dx = h.x - s.x, dy = h.y - s.y;
if(dx*dx/((s.r*1.6)*(s.r*1.6)) + dy*dy/((s.r*0.6)*(s.r*0.6)) < 1){ strike('Gewitter'); break; }
}
}
// === PROXIMITY SOUNDS ===
// Birds: distance to each flock
state.airTraffic.forEach(function(a, i) {
if (a.kind === 'flock') {
const dist = Math.hypot(h.x - a.x, h.y - a.y);
updateProximitySound('flock_'+i, ['birds1','birds2','birds3'][i%3], dist, 350);
} else if (a.kind === 'airliner') {
const dist = Math.hypot(h.x - a.x, h.y - a.y);
updateProximitySound('plane_'+i, 'bigPlane', dist, 500);
} else if (a.kind === 'sport') {
const dist = Math.hypot(h.x - a.x, h.y - a.y);
updateProximitySound('sport_'+i, 'smallPlane', dist, 400);
}
});
// Storms: thunder proximity
state.storms.forEach(function(s, i) {
const dist = Math.hypot(h.x - s.x, h.y - s.y);
updateProximitySound('storm_'+i, 'thunder', dist, s.r * 3);
});
// Update rotor: hoch wenn Gas (ArrowUp), runter wenn nicht
const isThrusting = state.keys['ArrowUp'] ? 1.0 : 0.2;
updateRotor(isThrusting, h.y);
// Lightning bolts — strike if heli is in the active bolt's column
if(h.invul <= 0){
for(const b of state.lightningBolts){
if(b.life > 0.35){ // only during initial flash
// Approx collision: bolt is a vertical path with small horizontal tolerance
if(Math.abs(h.x - b.x) < 14 && h.y > b.topY && h.y < b.bottomY){
strike('Blitz getroffen'); break;
}
}
}
}
}
// Air traffic movement
for(const a of state.airTraffic){
if(a.kind==='airliner' || a.kind==='sport'){
a.x += a.vx * dt;
if(a.vx < 0 && a.x < -200) a.x = WORLD_W + 150;
if(a.vx > 0 && a.x > WORLD_W+200) a.x = -150;
} else if(a.kind==='flock'){
a.x += a.vx * dt;
if(a.vx < 0 && a.x < -120) a.x = WORLD_W + 100;
if(a.vx > 0 && a.x > WORLD_W+120) a.x = -100;
for(const m of a.members) m.phase += dt * 4;
}
}
for(const s of state.storms){
s.lastFlash += dt;
if(s.nextBolt == null) s.nextBolt = 3 + Math.random()*5;
s.nextBolt -= dt;
// Spawn a lightning bolt occasionally that actually reaches the ground
if(s.nextBolt <= 0){
s.nextBolt = 4 + Math.random()*6;
// Generate jagged path from cloud base to ground
const startX = s.x + (Math.random()*s.r*2.4 - s.r*1.2);
const topY = s.y + s.r*0.5;
const segments = [];
let cx = startX, cy = topY;
while(cy < GROUND_Y - 4){
const nx = cx + (Math.random()*30 - 15);
const ny = cy + 25 + Math.random()*25;
segments.push({x:nx, y:Math.min(ny, GROUND_Y-2)});
cx = nx; cy = ny;
}
state.lightningBolts.push({
x: startX, topY, bottomY: GROUND_Y-2,
path: [{x:startX, y:topY}, ...segments],
life: 0.55
});
}
if(s.lastFlash > s.nextFlash){ s.lastFlash = 0; s.nextFlash = 2 + Math.random()*3; }
}
state.shake = Math.max(0, state.shake - dt*3);
// Win
if(h.x > EXIT_X && (GROUND_Y - h.y) > 100){
state.mode = 'won';
stopAllAmbient();
// V1: ordentlicher Funkspruch zum Start-Erfolg (Pilot*in m/f).
const _hk = (new URLSearchParams(location.search)).get('helikey') || '';
// Ab 2026-05-31: alle Stuetzpunkte = Marlene (weibliche Stimme/Bild)
const _pg = 'f';
playRadio('start_erfolg' + (_pg === 'm' ? '_m' : '')); // V1
playRadio('success2'); // V2 (vom V1-Filter geblockt)
stopRotor();
var stars = calcStarRating();
if (window.parent !== window) {
// Im Game-Flow: kein Overlay, direkt weiter nach 3s
setTimeout(function(){ window.parent.postMessage({type:'heli_start_complete', stars:stars}, '*'); }, 3000);
return; // Kein showResultOverlay
}
const timeMsg = state.timeLimit != null ? ` · Rest-Zeit: <b>${state.timeLeft.toFixed(1)} s</b>` : '';
const starStr = '★'.repeat(stars) + '☆'.repeat(5-stars);
const avgG = state.gCount > 0 ? (state.gSum/state.gCount).toFixed(1) : '—';
const pilotComment = stars >= 5 ? 'Bilderbuchstart! So macht man das.'
: stars >= 4 ? 'Sehr sauber geflogen, Co-Pilot.'
: stars >= 3 ? 'Solide. Versuch näher an der Ideallinie zu bleiben.'
: stars >= 2 ? 'Ausbaufähig. Die Startkurve war etwas wild.'
: 'Naja. Üben wir nochmal.';
showResultOverlay(
'✅ Startzone verlassen — ' + starStr,
`${pilotComment}<br><br>Szenario: <b>${state.scenarioName}</b> · Zeit: <b>${state.t.toFixed(1)} s</b> · Berührungen: <b>${h.strikes}/3</b> · Ø G-Kraft: <b>${avgG} g</b>${timeMsg}<br><br><span style="font-size:18px">${starStr}</span> <span style="font-size:12px;color:var(--slate-light)">Startkurve</span>`,
'Nächster Start',
'Start geschafft'
);
}
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;
updateHUD();
state.prevKeys = {...state.keys};
}
function strike(msg){
const h = state.heli;
h.strikes += 1;
h.invul = 1.4;
h.flash = 1;
state.shake = 1;
h.vy = -150;
h.vx *= -0.4;
// Sound: crash + pilot warning for obstacle type
const ml = (msg||'').toLowerCase();
function rWarn(prefix) { playRadioRandom(prefix); }
if (ml.includes('vogel')) { playSound('birdCrash', 0.8); rWarn('warn_vogel'); }
else if (ml.includes('gebäude') || ml.includes('haus')) { playSound('crashBuilding', 0.7); rWarn('warn_haus'); }
else if (ml.includes('baum') || ml.includes('tanne')) { playRandom(['crash1','crash2','crash3'], 0.7); rWarn('warn_baum'); }
else if (ml.includes('kran')) { playRandom(['crash1','crash2'], 0.7); rWarn('warn_kran'); }
else if (ml.includes('antenne')) { playRandom(['crash1','crash2'], 0.7); rWarn('warn_antenne'); }
else if (ml.includes('schornstein') || ml.includes('kamin')) { playRandom(['crash1','crash2'], 0.7); rWarn('warn_schornstein'); }
else if (ml.includes('klima')) { playRandom(['crash1','crash2'], 0.7); rWarn('warn_klima'); }
else if (ml.includes('hochspannung') || ml.includes('strom')) { playRandom(['crash1','crash2','crash3'], 0.7); rWarn('warn_strom'); }
else if (ml.includes('fabrik')) { playRandom(['crash1','crash2'], 0.7); rWarn('warn_fabrik'); }
else if (ml.includes('verkehrsflugzeug')) { playRandom(['crash1','crash2'], 0.7); rWarn('warn_flugzeug'); }
else if (ml.includes('sportflieger')) { playRandom(['crash1','crash2'], 0.7); rWarn('warn_sport'); }
else if (ml.includes('gewitter') || ml.includes('blitz')) { playRandom(['crash1','crash2','crash3'], 0.7); rWarn('warn_blitz'); }
else { playRandom(['crash1','crash2','crash3'], 0.7); }
playSound('warning', 0.3);
// Pilot radio comment on strike — Text + Audio gekoppelt
const pilotReactions = [
{ text: 'Pilot: Vorsicht! Das war knapp.', audio: 'pilot_strike_1' },
{ text: 'Pilot: Auspassen, Co-Pilot.', audio: 'pilot_strike_2' },
{ text: 'Pilot: Zu nah dran!', audio: 'pilot_strike_3' },
{ text: 'Pilot: Konzentriert bleiben.', audio: 'pilot_strike_4' },
{ text: 'Pilot: Ruhig, ich bin daneben.', audio: 'pilot_strike_5' }
];
if(h.strikes < 3){
const pr = pilotReactions[Math.floor(Math.random()*pilotReactions.length)];
state.radio.current = pr.text + ' (' + msg + ')';
state.radio.shown = 0;
state.radio.nextAt = 7 + Math.random()*5;
playRadio(pr.audio);
// Nach Pause: Tower-Lehrmeisterin (Strike-Call) statt altem ouch/warn-Clip
const _p = new URLSearchParams(location.search);
const _helikey = _p.get('helikey') || (_p.get('heli') === 'ara' ? 'ara' : '');
setTimeout(function(){
if (_helikey) playRadioWithFallback('tower_strike_' + _helikey, '');
}, 2400);
}
if(h.strikes >= 3){
state.mode = 'autopilot';
stopAllAmbient();
state.autopilotT = 0;
h.invul = 999;
state.takeoverReason = 'Drei Bodenberührungen in Folge. Bei einer vierten droht der Absturz — das Risiko für Patient*in und Crew wäre zu hoch. Die Chefpilotin bringt den Helikopter aus der Gefahrenzone.';
state.takeoverShort = 'Zu viele Berührungen — Absturzgefahr';
state.radio.current = 'Pilot: Drei Berührungen. Ich übernehme — Absturzgefahr!';
playRadio('pilot_three_strikes');
// Nach Pilot-Ansage: Tower-Lehrmeisterin-Handover (personalisiert)
const _pH = new URLSearchParams(location.search);
const _helikeyH = _pH.get('helikey') || (_pH.get('heli') === 'ara' ? 'ara' : '');
setTimeout(function(){
if (_helikeyH) playRadioWithFallback('tower_handover_' + _helikeyH, '');
}, 2600);
state.radio.shown = 0;
state.radio.nextAt = 99;
}
}
function pointInPoly(x, y, poly){
let inside = false;
for(let i=0, j=poly.length-1; i<poly.length; j=i++){
const xi=poly[i].x, yi=poly[i].y, xj=poly[j].x, yj=poly[j].y;
if(((yi>y)!=(yj>y)) && (x < (xj-xi)*(y-yi)/(yj-yi)+xi)) inside = !inside;
}
return inside;
}
function rectOverlap(x1,y1,w1,h1,x2,y2,w2,h2){
return x1 < x2+w2 && x1+w1 > x2 && y1 < y2+h2 && y1+h1 > y2;
}
function labelOf(k){ return {antenna:'Antenne', chimney:'Kamin', ac:'Klimagerät'}[k] || k; }
// === IDEALE STARTKURVE ===
// Kategorie-A-Profil: schnell schräg weg, Geschwindigkeit aufbauen
function idealY(x) {
// Kategorie-A-Startprofil: erst steil schräg, dann abflachend
const spawnX = state.spawn?.x || 380;
const spawnY = state.spawn?.y || 464;
const progress = Math.max(0, (x - spawnX) / (EXIT_X - spawnX));
// Steil raus, dann abflachend (Kategorie-A)
const curve = Math.pow(progress, 0.4);
return spawnY - curve * 420 - Math.sin(progress * Math.PI * 0.6) * 50;
}
function calcStarRating() {
if (!state.pathHistory || state.pathHistory.length < 5) return 1;
let totalDev = 0;
state.pathHistory.forEach(p => {
const ideal = idealY(p.x);
totalDev += Math.abs(p.y - ideal);
});
const avgDev = totalDev / state.pathHistory.length;
// Großzügigere Bewertung — 1 Stern bekommt man fast immer
if (avgDev < 30) return 5;
if (avgDev < 60) return 4;
if (avgDev < 100) return 3;
if (avgDev < 160) return 2;
return 1;
}
function updateHUD(){
const h = state.heli;
const alt = Math.max(0, GROUND_Y - 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, (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
const speed = Math.sqrt(h.vx*h.vx + h.vy*h.vy) / 4;
document.getElementById('fSpd').style.width = Math.min(100, speed/2)+'%';
document.getElementById('vSpd').textContent = Math.round(speed) + ' km/h';
document.getElementById('fFuel').style.width = (h.fuel*100)+'%';
document.getElementById('vFuel').textContent = Math.round(h.fuel*100)+' %';
// Verbrauch l/min — Skalierung identisch zu landing.html
if (!state._fuelTrack) state._fuelTrack = { lastFuel: h.fuel, lastT: state.t };
const dtF = state.t - state._fuelTrack.lastT;
if (dtF > 0.25) {
const burn = Math.max(0, (state._fuelTrack.lastFuel - h.fuel) / dtF);
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;
}
const lpm = state._fuelLitersPerMin || 0;
const frEl = document.getElementById('fFuelRate');
const vrEl = document.getElementById('vFuelRate');
if (frEl) { frEl.style.width = Math.max(4, Math.min(100, (lpm/12)*100)) + '%';
frEl.style.background = lpm > 9 ? 'var(--red)' : lpm > 6 ? 'var(--orange)' : 'var(--sage)'; }
if (vrEl) vrEl.textContent = lpm.toFixed(1) + ' l/min';
document.getElementById('pRoute').style.width = Math.min(100, (h.x/EXIT_X)*100)+'%';
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';
const windKn = Math.round(Math.abs(state.wind)/3);
document.getElementById('vWind').textContent = 'Wind '+windKn+' kn';
const arrow = document.getElementById('windArrow');
if(arrow) arrow.style.transform = `rotate(${state.wind >= 0 ? 90 : -90}deg)`;
// G-Kraft
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;
if (state.mode === 'flying') {
state.gHistory.push(gForce);
if (state.gHistory.length > 210) state.gHistory.shift();
state.gSum += gForce; state.gCount++;
}
const gPct = Math.min(100, (gForce-1)*50);
const gCol = gForce > 2 ? '#c85c4a' : gForce > 1.5 ? '#c97a3f' : '#7a9b76';
document.getElementById('fG').style.width = gPct+'%';
document.getElementById('fG').style.background = gCol;
document.getElementById('vG').textContent = gForce.toFixed(1)+' g';
// Path tracking for star rating
if (state.mode === 'flying' && state.t > 0.5) {
state.pathHistory.push({x: h.x, y: h.y});
}
const tb = document.getElementById('timerBig');
if(state.timeLimit != null && state.mode === 'flying'){
tb.classList.add('show');
const sec = Math.max(0, state.timeLeft);
tb.textContent = sec.toFixed(1) + ' s';
tb.style.background = sec < 10 ? '#c85c4a' : '#2d5434';
} else {
tb.classList.remove('show');
}
}
// ========== RENDER ==========
function render(){
const shakeX = (state.shake > 0) ? (Math.random()-0.5)*state.shake*6 : 0;
const shakeY = (state.shake > 0) ? (Math.random()-0.5)*state.shake*6 : 0;
ctx.save();
ctx.translate(shakeX, shakeY);
const cx = state.camX;
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);
// Distant hills
drawHills(cx*0.2, 0.25, C.hillFar, 480);
drawHills(cx*0.4, 0.45, C.hillMid, 560);
// Far clouds
for(const c of state.clouds){
const sx = c.x - cx*0.35;
if(sx < -80 || sx > W+80) continue;
drawCloud(sx, c.y, c.s);
}
// Background buildings
for(const b of state.bgFar){
const sx = b.x - cx*0.55;
if(sx+b.w < 0 || sx > W) continue;
ctx.fillStyle = C.bldgFar; ctx.fillRect(sx, b.y, b.w, b.h);
ctx.fillStyle = C.bldgFarRoof; ctx.fillRect(sx-1, b.y-4, b.w+2, 5);
ctx.fillStyle = 'rgba(61,81,101,.45)';
for(let yy=b.y+14; yy<b.y+b.h-10; yy+=20){
for(let xx=sx+8; xx<sx+b.w-12; xx+=16) ctx.fillRect(xx, yy, 6, 8);
}
}
for(const b of state.bgMid){
const sx = b.x - cx*0.78;
if(sx+b.w < 0 || sx > W) continue;
ctx.fillStyle = C.bldgMid; ctx.fillRect(sx, b.y, b.w, b.h);
ctx.fillStyle = C.bldgMidRoof; ctx.fillRect(sx-2, b.y-6, b.w+4, 8);
ctx.fillStyle = C.win;
for(let yy=b.y+18; yy<b.y+b.h-14; yy+=24){
for(let xx=sx+12; xx<sx+b.w-16; xx+=20) ctx.fillRect(xx, yy, 8, 11);
}
ctx.fillStyle = 'rgba(61,81,101,.1)';
ctx.fillRect(sx+b.w-8, b.y, 8, b.h);
}
// Ground
const isMeadow = (state.selectedScenario === 'basis' || state.selectedScenario === 'natur' || state.selectedScenario === 'tal' || state.selectedScenario === 'zeit');
if(isMeadow){
ctx.fillStyle = C.meadow;
ctx.fillRect(0, GROUND_Y, W, H-GROUND_Y);
ctx.fillStyle = C.meadowDark;
ctx.fillRect(0, GROUND_Y, W, 4);
// grass tufts
ctx.strokeStyle = C.meadowDark; ctx.lineWidth = 1;
for(let x=-(cx%18); x<W; x+=18){
ctx.beginPath(); ctx.moveTo(x, GROUND_Y+4); ctx.lineTo(x-2, GROUND_Y+10); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x+6, GROUND_Y+4); ctx.lineTo(x+8, GROUND_Y+10); ctx.stroke();
}
} else {
ctx.fillStyle = C.hillNear; ctx.fillRect(0, GROUND_Y, W, H-GROUND_Y);
ctx.fillStyle = C.groundDark; ctx.fillRect(0, GROUND_Y, W, 6);
ctx.fillStyle = C.ground; ctx.fillRect(0, GROUND_Y+6, W, 40);
ctx.strokeStyle = C.cream; ctx.lineWidth=2;
ctx.setLineDash([14,12]);
ctx.lineDashOffset = cx * 0.9;
ctx.beginPath(); ctx.moveTo(0, GROUND_Y+26); ctx.lineTo(W, GROUND_Y+26); ctx.stroke();
ctx.setLineDash([]); ctx.lineDashOffset = 0;
}
// Near buildings (foreground)
for(const b of state.bgNear){
const sx = b.x - cx;
if(sx+b.w < 0 || sx > W) continue;
const twoTone = (Math.abs(b.x*0.1)%2 > 1);
const wallColor = twoTone ? C.bldgNear2 : C.bldgNear;
const roofColor = twoTone ? C.bldgNear2Roof : C.bldgNearRoof;
ctx.fillStyle = wallColor; ctx.fillRect(sx, b.y, b.w, b.h);
ctx.fillStyle = roofColor; ctx.fillRect(sx-3, b.y-7, b.w+6, 9);
ctx.fillStyle = 'rgba(0,0,0,.08)';
ctx.fillRect(sx+b.w-10, b.y, 10, b.h);
ctx.fillStyle = wallColor === C.bldgNear ? C.bldgNearRoof : C.win;
for(let yy=b.y+18; yy<b.y+b.h-18; yy+=26){
for(let xx=sx+14; xx<sx+b.w-18; xx+=22) ctx.fillRect(xx, yy, 10, 13);
}
ctx.fillStyle = C.slate;
ctx.fillRect(sx+b.w/2-7, b.y+b.h-20, 14, 20);
}
// Trees (background first, then foreground)
for(const t of state.trees) if(t.bg) drawTree(t.x - cx, t.y, t.s, true);
// Houses (single-family)
for(const hs of state.houses) drawHouse(hs.x - cx, hs.y, hs.w, hs.h, hs.variant);
// Factories with smokestacks
for(const f of state.factories) drawFactory(f.x - cx, f.y, f.w, f.h, f.stacks);
// Power lines
for(const p of state.powerlines) drawPowerline(p, cx);
// Foreground tall trees
for(const t of state.trees) if(!t.bg) drawTree(t.x - cx, t.y, t.s, false);
// Hospital (if present)
if(state.hospital) drawHospital(state.hospital, cx);
for(const o of state.obstacles) drawObstacle(o, cx);
if(state.airport) drawAirport(state.airport, cx);
if(state.helipad) drawHelipadMarking(state.helipad, cx);
drawExitMarker(EXIT_X - cx);
// Air traffic
for(const a of state.airTraffic){
const sx = a.x - cx;
if(a.kind==='airliner' || a.kind==='sport'){
if(sx < -200 || sx > W+200) continue;
if(a.kind==='airliner') drawAirliner(sx, a.y, a.vx<0);
else drawSportPlane(sx, a.y, a.vx<0);
} else if(a.kind==='flock'){
if(sx < -200 || sx > W+200) continue;
drawFlock(sx, a.y, a.members, a.vx<0);
}
}
// Storm clouds (just the cloud body now, no flash logic — bolts are separate)
for(const s of state.storms){
const sx = s.x - cx;
if(sx < -300 || sx > W+300) continue;
drawStormCloud(sx, s.y, s.r);
}
// Rain drops
if(state.rainDrops.length > 0){
ctx.strokeStyle = 'rgba(120,150,180,.65)';
ctx.lineWidth = 1.4;
ctx.lineCap = 'round';
ctx.beginPath();
for(const d of state.rainDrops){
const sx = d.x - cx;
if(sx < -10 || sx > W+10) continue;
ctx.moveTo(sx, d.y);
ctx.lineTo(sx - d.vx*0.018, d.y - d.vy*0.018);
}
ctx.stroke();
}
// Lightning bolts
for(const b of state.lightningBolts){
const sx = b.x - cx;
if(sx < -50 || sx > W+50) continue;
drawLightningBolt(b, cx);
}
// Startkurve-Sterne fliegen weg beim Start
if (state.mode === 'flying' && state.t < 1.5 && state.spawn) {
const fadeOut = 1 - state.t / 1.5;
const flyUp = state.t * 40;
ctx.save();
ctx.globalAlpha = fadeOut * 0.8;
ctx.font = '700 18px system-ui';
ctx.fillStyle = '#e4b95c';
ctx.textAlign = 'center';
ctx.fillText('★★★★★', state.heli.x - cx, state.heli.y - 40 - flyUp);
ctx.restore();
}
// Ideale Startkurve (verschwindet nach 3 Sekunden)
if (state.mode === 'flying' && state.t < 3 && state.spawn) {
const alpha = Math.max(0, 1 - state.t / 3);
ctx.save();
ctx.globalAlpha = alpha * 0.5;
ctx.strokeStyle = '#7a9b76';
ctx.lineWidth = 2;
ctx.setLineDash([8, 6]);
ctx.beginPath();
for (let px = state.spawn.x; px < EXIT_X; px += 8) {
const py = idealY(px);
px === state.spawn.x ? ctx.moveTo(px - cx, py) : ctx.lineTo(px - cx, py);
}
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = alpha * 0.35;
ctx.font = '600 11px system-ui';
ctx.fillStyle = '#2d5434';
ctx.fillText('Ideale Startkurve ✓', state.spawn.x + 60 - cx, idealY(state.spawn.x + 60) - 12);
ctx.restore();
}
drawHeli(state.heli, cx);
// Fog wisps (world coordinates with parallax close to houses)
if(state.fogWisps.length > 0){
for(const wisp of state.fogWisps){
const sx = wisp.x - cx * wisp.parallax;
if(sx + wisp.w/2 < -50 || sx - wisp.w/2 > W+50) continue;
drawFogWisp(wisp, sx);
}
}
// Radio message at bottom
if(state.radio.current){
const fadeIn = Math.min(1, state.radio.shown / 0.3);
const fadeOut = Math.min(1, (5.5 - state.radio.shown) / 0.5);
const alpha = Math.min(fadeIn, fadeOut);
drawRadioMessage(state.radio.current, alpha);
}
if (window.INSPECT_MODE) drawInspectOverlay(cx);
ctx.restore();
}
// ============================================================
// INSPECT OVERLAY (aktivieren per URL-Parameter ?inspect=1)
// Zeichnet Konturen + Labels um jedes platzierte Objekt. Rot wenn
// das Objekt "in der Luft" haengt (kein passendes Boden-/Dach-Ankerobjekt).
// Klick auf Canvas listet in der Konsole alle Objekte am Klickpunkt.
// ============================================================
function drawInspectOverlay(cx){
ctx.save();
ctx.font = '10px system-ui';
ctx.textBaseline = 'top';
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, w - 1, h - 1);
ctx.fillStyle = ok ? 'rgba(45,120,60,.85)' : 'rgba(220,60,50,1)';
ctx.fillRect(sx, sy - 12, Math.max(24, ctx.measureText(label).width + 6), 12);
ctx.fillStyle = '#fff';
ctx.fillText(label, sx + 3, sy - 11);
};
// Gebaeude (bgFar, bgMid, bgNear) — Boden = y+h, muss etwa auf GROUND_Y sein
const checkBuilding = (arr, parallax, label) => {
for (let i = 0; i < arr.length; i++) {
const b = arr[i];
const sx = b.x - cx * parallax;
if (sx + b.w < -5 || sx > W + 5) continue;
const floating = Math.abs((b.y + b.h) - GROUND_Y) > 10;
box(sx, b.y, b.w, b.h, label + '#' + i + (floating ? ' FLOAT!' : ''), !floating);
}
};
checkBuilding(state.bgFar || [], 0.55, 'bgFar');
checkBuilding(state.bgMid || [], 0.78, 'bgMid');
checkBuilding(state.bgNear || [], 1.00, 'bgNear');
// Haeuser (state.houses) — y=GROUND_Y (baseline), h fix 70
for (let i = 0; i < (state.houses || []).length; i++) {
const hs = state.houses[i];
const sx = hs.x - cx;
if (sx + hs.w < -5 || sx > W + 5) continue;
const floating = hs.y !== GROUND_Y;
box(sx - hs.w/2, hs.y - hs.h, hs.w, hs.h, 'house#' + i + (floating ? ' FLOAT!' : ''), !floating);
}
// Fabriken
for (let i = 0; i < (state.factories || []).length; i++) {
const f = state.factories[i];
const sx = f.x - cx;
if (sx + f.w < -5 || sx > W + 5) continue;
const floating = f.y !== GROUND_Y;
box(sx, f.y - f.h, f.w, f.h, 'factory#' + i + (floating ? ' FLOAT!' : ''), !floating);
}
// Obstacles (AC, Kamin, Antenne, Kran, etc.) — fuer AC + Kamin pruefen ob ein
// bgNear-Gebaeude drunter liegt. Antenne + Kran haben eigenen Fuss.
for (let i = 0; i < (state.obstacles || []).length; i++) {
const o = state.obstacles[i];
const sx = o.x - cx;
if (sx < -80 || sx > W + 80) continue;
let floating = false;
let label = o.kind + '#' + i + ' @x' + Math.round(o.x) + ',y' + Math.round(o.y);
if (o.kind === 'ac' || o.kind === 'chimney') {
const b = (state.bgNear || []).find(b => o.x >= b.x && o.x + (o.w||0) <= b.x + b.w);
if (!b) floating = true;
} else if (o.kind === 'antenna') {
// Antenne muss bis zum Boden reichen
if (o.y + o.h < GROUND_Y - 5) floating = true;
}
box(sx - (o.w||4)/2, o.y, o.w || 4, o.h || 20, label + (floating ? ' FLOAT!' : ''), !floating);
}
// Oben links Legende
ctx.fillStyle = 'rgba(0,0,0,.7)';
ctx.fillRect(8, 8, 240, 46);
ctx.fillStyle = '#fff';
ctx.font = '700 11px system-ui';
ctx.fillText('INSPECT (Klick -> Konsole)', 14, 14);
ctx.font = '10px system-ui';
ctx.fillStyle = '#9de8a6'; ctx.fillText('gruen = am Boden/Gebaeude', 14, 28);
ctx.fillStyle = '#ffb0a8'; ctx.fillText('rot = schwebt', 14, 41);
ctx.restore();
}
// Inspect-Mode: Overlay unterdruecken, Canvas direkt sichtbar, Maus-Drag
// scrollt die Welt, Klick auf Objekte oeffnet Info-Panel rechts.
if (new URLSearchParams(location.search).get('inspect') === '1') {
window.INSPECT_MODE = true;
// Warten bis reset() gelaufen ist, dann Overlay ausblenden + Heli einfrieren
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'; // nicht 'flying' → update() pausiert Physik
}, 80);
// Info-Panel + Button als HTML oben rechts
const panel = document.createElement('div');
panel.id = 'inspectPanel';
panel.style.cssText = 'position:fixed;top:12px;right:12px;width:300px;max-height: 80dvh;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</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">Maus ziehen = 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);
// Statistik in Panel-Count
function updateCount() {
const el = document.getElementById('inspectCount');
if (!el) return;
let floats = 0;
(state.obstacles||[]).forEach(o => {
if (o.kind === 'ac' || o.kind === 'chimney') {
const b = (state.bgNear||[]).find(b => o.x >= b.x && o.x + (o.w||0) <= b.x + b.w);
if (!b) floats++;
} else if (o.kind === 'antenna') {
if (o.y + o.h < GROUND_Y - 5) floats++;
}
});
(state.houses||[]).forEach(hs => { if (hs.y !== GROUND_Y) floats++; });
(state.factories||[]).forEach(f => { if (f.y !== GROUND_Y) floats++; });
(state.bgNear||[]).forEach(b => { if (Math.abs((b.y + b.h) - GROUND_Y) > 10) floats++; });
el.textContent = `${(state.obstacles||[]).length} Obstacles · ${(state.bgNear||[]).length} bgNear · ${(state.houses||[]).length} Haeuser · ${floats} schwebend`;
el.style.color = floats > 0 ? '#c85c4a' : '#2d5434';
}
setInterval(updateCount, 500);
// Neue Welt wuerfeln
document.getElementById('inspectReroll').addEventListener('click', () => {
reset();
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';
state.camX = 0;
document.getElementById('inspectHits').innerHTML = '';
}, 80);
});
// Maus-Drag zum Scrollen
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;
});
// Klick auf Canvas → Objekte unter Cursor ins Panel
canvas.addEventListener('click', (ev) => {
if (Math.abs(ev.movementX) > 2) return; // nicht bei Drag-Klick
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.bgNear || []).forEach((b, i) => {
if (wx >= b.x && wx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h)
hits.push({type: 'bgNear#' + i, x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.w), h: Math.round(b.h)});
});
(state.houses || []).forEach((hs, i) => {
if (wx >= hs.x - hs.w/2 && wx <= hs.x + hs.w/2 && sy >= hs.y - hs.h && sy <= hs.y)
hits.push({type: 'house#' + i, x: Math.round(hs.x), y: Math.round(hs.y), w: hs.w, h: hs.h});
});
(state.factories || []).forEach((f, i) => {
if (wx >= f.x && wx <= f.x + f.w && sy >= f.y - f.h && sy <= f.y)
hits.push({type: 'factory#' + i, x: Math.round(f.x), y: Math.round(f.y), w: Math.round(f.w), h: Math.round(f.h)});
});
(state.obstacles || []).forEach((o, i) => {
const w = o.w || 10, h = o.h || 20;
if (wx >= o.x - w/2 && wx <= o.x + w/2 && sy >= o.y && sy <= o.y + h) {
let floating = false;
if (o.kind === 'ac' || o.kind === 'chimney') {
const b = (state.bgNear||[]).find(b => o.x >= b.x && o.x + (o.w||0) <= b.x + b.w);
if (!b) floating = true;
}
hits.push({type: o.kind + '#' + i, x: Math.round(o.x), y: Math.round(o.y), w: w, h: h, floating});
}
});
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 => {
const col = h.floating ? '#c85c4a' : '#2d5434';
const badge = h.floating ? ' <b style="color:#c85c4a">SCHWEBT</b>' : '';
return `<div style="border-left:3px solid ${col};padding:4px 8px;margin-bottom:4px;background:rgba(0,0,0,.03)">
<b>${h.type}</b>${badge}<br>
<span style="color:#6b7d8f">x=${h.x}, y=${h.y}, w=${h.w}, h=${h.h}</span>
</div>`;
}).join('');
}
});
}
function drawRadioMessage(msg, alpha){
const y = H - 80;
const padX = 16, padY = 10;
ctx.font = '13px system-ui';
const w = ctx.measureText(msg).width + padX*2 + 28;
const x = (W - w)/2;
ctx.fillStyle = `rgba(31,42,36,${0.85*alpha})`;
ctx.beginPath();
if(ctx.roundRect) ctx.roundRect(x, y, w, 32, 16);
else ctx.rect(x, y, w, 32);
ctx.fill();
// Radio icon dot
ctx.fillStyle = `rgba(228,185,92,${alpha})`;
ctx.beginPath();
ctx.arc(x + 16, y + 16, 5, 0, Math.PI*2);
ctx.fill();
// Pulsing ring
const pulse = (Math.sin(performance.now()*0.006) + 1) * 0.5;
ctx.strokeStyle = `rgba(228,185,92,${alpha * (0.6 - pulse*0.4)})`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(x + 16, y + 16, 5 + pulse*4, 0, Math.PI*2);
ctx.stroke();
// Text
ctx.fillStyle = `rgba(245,239,224,${alpha})`;
ctx.font = '13px system-ui';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(msg, x + 28, y + 16);
}
function drawHills(offset, amp, color, baseY){
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(0, baseY);
for(let x=0; x<=W; x+=30){
const wx = x + offset;
const y = baseY - Math.sin(wx*0.008)*30*amp - Math.cos(wx*0.003)*20*amp;
ctx.lineTo(x, y);
}
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath(); ctx.fill();
}
function drawCloud(x,y,s){
ctx.fillStyle = C.cloud;
ctx.beginPath();
ctx.ellipse(x, y, 32*s, 14*s, 0, 0, Math.PI*2);
ctx.ellipse(x+20*s, y-6*s, 22*s, 12*s, 0, 0, Math.PI*2);
ctx.ellipse(x-18*s, y-3*s, 20*s, 10*s, 0, 0, Math.PI*2);
ctx.fill();
}
function drawStormCloud(x, y, r){
// Three overlapping cloud bodies for a fuller, wider storm front
const offsets = [
{dx: -r*0.9, dy: 6, sz: r*0.95},
{dx: 0, dy: -4, sz: r*1.10},
{dx: r*0.9, dy: 4, sz: r*0.95}
];
for(const o of offsets){
drawSingleCumulonimbus(x + o.dx, y + o.dy, o.sz);
}
}
function drawSingleCumulonimbus(x, y, r){
// dark cumulonimbus with multiple lobes
ctx.fillStyle = C.stormCloud;
ctx.beginPath();
ctx.ellipse(x, y, r, r*0.55, 0, 0, Math.PI*2);
ctx.ellipse(x-r*0.6, y+4, r*0.55, r*0.4, 0, 0, Math.PI*2);
ctx.ellipse(x+r*0.65, y+2, r*0.6, r*0.45, 0, 0, Math.PI*2);
ctx.ellipse(x-r*0.2, y-r*0.3, r*0.5, r*0.35, 0, 0, Math.PI*2);
ctx.ellipse(x+r*0.25, y-r*0.35, r*0.45, r*0.32, 0, 0, Math.PI*2);
ctx.fill();
// dark base
ctx.fillStyle = C.stormDark;
ctx.beginPath();
ctx.ellipse(x-r*0.2, y+r*0.4, r*0.7, r*0.25, 0, 0, Math.PI*2);
ctx.ellipse(x+r*0.3, y+r*0.45, r*0.6, r*0.22, 0, 0, Math.PI*2);
ctx.fill();
}
function drawLightningBolt(b, cx){
const flash = b.life / 0.55;
// initial bright flash on whole cloud area
if(b.life > 0.4){
ctx.fillStyle = `rgba(255,250,220,${(b.life-0.4)*2.5})`;
ctx.fillRect(0, 0, W, GROUND_Y);
}
// bolt itself
const path = b.path;
// outer glow
ctx.strokeStyle = `rgba(244,220,120,${0.4*flash})`;
ctx.lineWidth = 8;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(path[0].x - cx, path[0].y);
for(let i=1;i<path.length;i++) ctx.lineTo(path[i].x - cx, path[i].y);
ctx.stroke();
// inner bright bolt
ctx.strokeStyle = `rgba(255,250,220,${0.95*flash})`;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(path[0].x - cx, path[0].y);
for(let i=1;i<path.length;i++) ctx.lineTo(path[i].x - cx, path[i].y);
ctx.stroke();
// core
ctx.strokeStyle = `rgba(255,255,255,${flash})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(path[0].x - cx, path[0].y);
for(let i=1;i<path.length;i++) ctx.lineTo(path[i].x - cx, path[i].y);
ctx.stroke();
// ground impact glow
if(b.life > 0.3){
const last = path[path.length-1];
const grd = ctx.createRadialGradient(last.x - cx, last.y, 4, last.x - cx, last.y, 60);
grd.addColorStop(0, `rgba(255,250,220,${flash*0.9})`);
grd.addColorStop(1, 'rgba(255,250,220,0)');
ctx.fillStyle = grd;
ctx.beginPath(); ctx.arc(last.x - cx, last.y, 60, 0, Math.PI*2); ctx.fill();
}
}
function drawFogWisp(w, sx){
// Soft elliptical fog patch with gradient (sx = screen x after parallax)
const x = sx;
const y = w.y;
const yOff = Math.sin(w.wobble)*4;
const grd = ctx.createRadialGradient(x, y+yOff, w.w*0.1, x, y+yOff, w.w*0.5);
grd.addColorStop(0, `rgba(245,239,224,${w.alpha})`);
grd.addColorStop(0.5, `rgba(245,239,224,${w.alpha*0.7})`);
grd.addColorStop(1, 'rgba(245,239,224,0)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.ellipse(x, y+yOff, w.w*0.5, w.h*0.5, 0, 0, Math.PI*2);
ctx.fill();
// Additional softening lobes
ctx.fillStyle = `rgba(245,239,224,${w.alpha*0.35})`;
ctx.beginPath();
ctx.ellipse(x - w.w*0.18, y+yOff+8, w.w*0.3, w.h*0.35, 0, 0, Math.PI*2);
ctx.ellipse(x + w.w*0.22, y+yOff-6, w.w*0.32, w.h*0.38, 0, 0, Math.PI*2);
ctx.fill();
}
function drawHouse(x, y, w, h, variant){
if(x < -80 || x > W+80) return;
// foundation/wall
const wallColors = ['#f5efe0', '#ece4d0', '#e4b95c'];
const roofColors = ['#c85c4a', '#8a3e31', '#3d5165'];
const wall = wallColors[variant % wallColors.length];
const roof = roofColors[variant % roofColors.length];
ctx.fillStyle = wall;
ctx.fillRect(x - w/2, y - h, w, h);
// pitched roof
ctx.fillStyle = roof;
ctx.beginPath();
ctx.moveTo(x - w/2 - 4, y - h);
ctx.lineTo(x, y - h - 28);
ctx.lineTo(x + w/2 + 4, y - h);
ctx.closePath(); ctx.fill();
// chimney
ctx.fillStyle = roof;
ctx.fillRect(x + w/4, y - h - 22, 8, 18);
// door
ctx.fillStyle = '#5b4a3a';
ctx.fillRect(x - 8, y - 26, 16, 26);
// door knob
ctx.fillStyle = '#e4b95c';
ctx.beginPath(); ctx.arc(x + 5, y - 13, 1.2, 0, Math.PI*2); ctx.fill();
// windows
ctx.fillStyle = '#a8c3a0';
ctx.fillRect(x - w/2 + 8, y - h + 14, 16, 14);
ctx.fillRect(x + w/2 - 24, y - h + 14, 16, 14);
// window frames
ctx.strokeStyle = '#3d5165'; ctx.lineWidth = 1;
ctx.strokeRect(x - w/2 + 8, y - h + 14, 16, 14);
ctx.strokeRect(x + w/2 - 24, y - h + 14, 16, 14);
ctx.beginPath();
ctx.moveTo(x - w/2 + 16, y - h + 14); ctx.lineTo(x - w/2 + 16, y - h + 28);
ctx.moveTo(x + w/2 - 16, y - h + 14); ctx.lineTo(x + w/2 - 16, y - h + 28);
ctx.stroke();
// shadow side
ctx.fillStyle = 'rgba(0,0,0,.07)';
ctx.fillRect(x + w/2 - 6, y - h, 6, h);
}
function drawFactory(x, y, w, h, stacks){
if(x + w < -80 || x > W+80) return;
// main building
ctx.fillStyle = '#8a8474';
ctx.fillRect(x, y - h, w, h);
// sawtooth roof
ctx.fillStyle = '#5d5848';
const teeth = Math.floor(w / 30);
for(let i=0;i<teeth;i++){
ctx.beginPath();
ctx.moveTo(x + i*30, y - h);
ctx.lineTo(x + i*30 + 15, y - h - 14);
ctx.lineTo(x + i*30 + 30, y - h);
ctx.closePath(); ctx.fill();
}
// windows row
ctx.fillStyle = '#3d5165';
for(let xx = x + 12; xx < x + w - 16; xx += 22){
ctx.fillRect(xx, y - h*0.6, 14, 16);
}
// big door
ctx.fillStyle = '#3d5165';
ctx.fillRect(x + w*0.4, y - 40, 40, 40);
// shadow
ctx.fillStyle = 'rgba(0,0,0,.1)';
ctx.fillRect(x + w - 8, y - h, 8, h);
// smokestacks
for(const st of stacks){
const sx = x + st.dx;
// stack body
ctx.fillStyle = '#6b5d4d';
ctx.fillRect(sx - 10, y - st.h, 20, st.h);
// top band
ctx.fillStyle = '#c85c4a';
ctx.fillRect(sx - 12, y - st.h, 24, 6);
// smoke
const t = performance.now() * 0.0005;
ctx.fillStyle = 'rgba(180,170,160,.55)';
for(let i=0;i<3;i++){
const sy = y - st.h - 8 - i*16;
const sw = 12 + i*8 + Math.sin(t + st.dx + i)*3;
ctx.beginPath();
ctx.ellipse(sx + Math.sin(t*2 + i)*4, sy, sw, sw*0.7, 0, 0, Math.PI*2);
ctx.fill();
}
}
}
function drawPowerline(p, cx){
const x1 = p.x1 - cx, x2 = p.x2 - cx;
if(x2 < -50 || x1 > W+50) return;
// Pylons (lattice)
for(const px of [x1, x2]){
ctx.strokeStyle = '#5d5848';
ctx.lineWidth = 2;
// outline
ctx.beginPath();
ctx.moveTo(px - 12, p.y + p.pylonH);
ctx.lineTo(px - 4, p.y);
ctx.moveTo(px + 12, p.y + p.pylonH);
ctx.lineTo(px + 4, p.y);
ctx.stroke();
// lattice cross beams
ctx.lineWidth = 1;
for(let yy = p.y + 20; yy < p.y + p.pylonH; yy += 20){
const t = (yy - p.y) / p.pylonH;
const halfW = 4 + t*8;
ctx.beginPath();
ctx.moveTo(px - halfW, yy); ctx.lineTo(px + halfW, yy);
ctx.moveTo(px - halfW, yy); ctx.lineTo(px + halfW, yy + 10);
ctx.moveTo(px + halfW, yy); ctx.lineTo(px - halfW, yy + 10);
ctx.stroke();
}
// crossarms
ctx.lineWidth = 2;
for(let li=0; li<3; li++){
const ly = p.y + 8 + li*8;
ctx.beginPath();
ctx.moveTo(px - 16, ly); ctx.lineTo(px + 16, ly);
ctx.stroke();
}
}
// Cables (3 parallel, with sag)
ctx.strokeStyle = '#1f2a24'; ctx.lineWidth = 1.5;
for(let li=0; li<3; li++){
const ly = p.y + 8 + li*8;
const sag = 8;
ctx.beginPath();
ctx.moveTo(x1 + 16, ly);
ctx.quadraticCurveTo((x1+x2)/2, ly + sag, x2 - 16, ly);
ctx.stroke();
}
}
function drawTree(x, y, s, isBg){
if(x < -60 || x > W+60) return;
const alpha = isBg ? 0.6 : 1;
ctx.globalAlpha = alpha;
ctx.fillStyle = C.treeTrunk;
ctx.fillRect(x-3*s, y-22*s, 6*s, 22*s);
// Layered triangular crown (pine)
ctx.fillStyle = C.treeDark;
ctx.beginPath();
ctx.moveTo(x, y-80*s); ctx.lineTo(x-26*s, y-22*s); ctx.lineTo(x+26*s, y-22*s);
ctx.closePath(); ctx.fill();
ctx.fillStyle = C.treeMid;
ctx.beginPath();
ctx.moveTo(x, y-64*s); ctx.lineTo(x-22*s, y-26*s); ctx.lineTo(x+22*s, y-26*s);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x, y-46*s); ctx.lineTo(x-18*s, y-18*s); ctx.lineTo(x+18*s, y-18*s);
ctx.closePath(); ctx.fill();
ctx.globalAlpha = 1;
}
function drawAirliner(x, y, flipLeft){
ctx.save();
ctx.translate(x, y);
if(flipLeft) ctx.scale(-1,1);
ctx.fillStyle = C.planeBody;
ctx.beginPath();
ctx.moveTo(-58, 0); ctx.quadraticCurveTo(-60, -9, -40, -10);
ctx.lineTo(50, -8); ctx.quadraticCurveTo(62, -4, 60, 0);
ctx.quadraticCurveTo(62, 4, 50, 6); ctx.lineTo(-40, 8);
ctx.quadraticCurveTo(-60, 9, -58, 0); ctx.closePath(); ctx.fill();
ctx.fillStyle = C.planeAccent; ctx.fillRect(-50, -1, 100, 2);
ctx.fillStyle = C.slate;
for(let i=-40;i<40;i+=10) ctx.fillRect(i, -4, 5, 3);
ctx.fillStyle = C.glass;
ctx.beginPath(); ctx.moveTo(46, -6); ctx.quadraticCurveTo(58, -4, 58, 0); ctx.lineTo(46, 0); ctx.closePath(); ctx.fill();
ctx.fillStyle = C.planeBody;
ctx.beginPath(); ctx.moveTo(-5, 4); ctx.lineTo(-30, 22); ctx.lineTo(10, 22); ctx.lineTo(15, 6); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(-5, -4); ctx.lineTo(-30, -18); ctx.lineTo(5, -14); ctx.lineTo(10, -4); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(-52, -2); ctx.lineTo(-60, -16); ctx.lineTo(-40, -10); ctx.closePath(); ctx.fill();
ctx.fillStyle = C.planeAccent;
ctx.beginPath(); ctx.moveTo(-52, -2); ctx.lineTo(-58, -14); ctx.lineTo(-50, -10); ctx.closePath(); ctx.fill();
ctx.fillStyle = C.slate;
ctx.beginPath(); ctx.ellipse(-8, 16, 8, 4, 0, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
function drawSportPlane(x, y, flipLeft){
ctx.save();
ctx.translate(x, y);
if(flipLeft) ctx.scale(-1,1);
ctx.fillStyle = C.sportBody;
ctx.beginPath();
ctx.moveTo(-26, 0); ctx.quadraticCurveTo(-28, -6, -18, -7);
ctx.lineTo(18, -5); ctx.quadraticCurveTo(28, -2, 28, 0);
ctx.quadraticCurveTo(28, 2, 18, 5); ctx.lineTo(-18, 7);
ctx.quadraticCurveTo(-28, 6, -26, 0); ctx.closePath(); ctx.fill();
ctx.fillStyle = C.sportBody; ctx.fillRect(-18, -12, 38, 4);
ctx.strokeStyle = C.sportAccent; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(-12,-8); ctx.lineTo(-8,0); ctx.moveTo(14,-8); ctx.lineTo(10,0); ctx.stroke();
ctx.fillStyle = C.sportBody;
ctx.beginPath(); ctx.moveTo(-26,-2); ctx.lineTo(-32,-10); ctx.lineTo(-20,-6); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(-22, 0); ctx.lineTo(-34, 3); ctx.lineTo(-22, 4); ctx.closePath(); ctx.fill();
ctx.fillStyle = C.glass;
ctx.beginPath(); ctx.ellipse(6, -4, 8, 4, 0, 0, Math.PI*2); ctx.fill();
const flick = (Math.sin(performance.now()*0.1)+1)*0.5;
ctx.fillStyle = `rgba(31,42,36,${0.3+flick*0.35})`;
ctx.fillRect(26, -9, 2, 18);
ctx.fillStyle = 'rgba(31,42,36,.12)';
ctx.beginPath(); ctx.ellipse(27, 0, 1.5, 9, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = C.sportAccent; ctx.fillRect(-10, 0, 18, 2);
ctx.restore();
}
function drawFlock(ax, ay, members, flipLeft){
for(const m of members){
const x = ax + m.dx;
const y = ay + m.dy + Math.sin(m.phase)*6;
const flap = Math.sin(m.phase*2);
ctx.save();
ctx.translate(x,y);
if(flipLeft) ctx.scale(-1,1);
ctx.strokeStyle = C.bird; ctx.lineWidth = 2.5; ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(-10, 0);
ctx.quadraticCurveTo(-5, -6 - flap*3, 0, 0);
ctx.quadraticCurveTo(5, -6 - flap*3, 10, 0);
ctx.stroke();
ctx.fillStyle = C.bird;
ctx.beginPath(); ctx.arc(0, 0, 1.8, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
}
function drawHospital(b, cx){
const sx = b.x - cx;
if(sx+b.w < 0 || sx > W) return;
ctx.fillStyle = C.wall; ctx.fillRect(sx, b.y, b.w, b.h);
ctx.fillStyle = C.wallShade; ctx.fillRect(sx+b.w-40, b.y, 40, b.h);
ctx.fillStyle = C.slate; ctx.fillRect(sx-4, b.y-6, b.w+8, 8);
for(let yy=b.y+20; yy<b.y+b.h-20; yy+=34){
for(let xx=sx+16; xx<sx+b.w-20; xx+=40){
ctx.fillStyle = C.win; ctx.fillRect(xx, yy, 22, 16);
ctx.fillStyle = 'rgba(245,239,224,.2)'; ctx.fillRect(xx, yy, 22, 4);
}
}
ctx.fillStyle = C.wall; ctx.fillRect(sx+b.w/2-18, b.y+30, 36, 36);
ctx.fillStyle = C.red;
ctx.fillRect(sx+b.w/2-12, b.y+42, 24, 12);
ctx.fillRect(sx+b.w/2-6, b.y+36, 12, 24);
ctx.fillStyle = C.slate; ctx.fillRect(sx+b.w/2-24, b.y+b.h-40, 48, 40);
ctx.fillStyle = C.yellow; ctx.fillRect(sx+b.w/2-20, b.y+b.h-36, 40, 6);
}
function drawHelipadMarking(p, cx){
const sx = p.x - cx;
if(sx+p.w < 0 || sx > W) return;
const cxPad = sx + p.w/2;
const cyPad = p.y - 4;
// Pulsierende Orientierungs-Ringe (rhythmisch, damit der Startplatz auffällt) — v2
const period = 1.4;
const nowT = performance.now() / 1000;
// Expandierende Ringe
for (let k = 0; k < 3; k++) {
const tp = ((nowT + k * period/3) % period) / period;
const r = 35 + tp * 140;
const a = Math.max(0, 1 - tp);
ctx.strokeStyle = `rgba(232,131,58,${a})`; // orange, hoher Kontrast gegen Grün
ctx.lineWidth = 7;
ctx.beginPath(); ctx.arc(cxPad, cyPad, r, 0, Math.PI * 2); ctx.stroke();
}
// Blinkender Marker-Kreis direkt über dem Pad
const blink = 0.5 + 0.5 * Math.sin(nowT * 4);
ctx.fillStyle = `rgba(200,92,74,${0.35 + 0.35 * blink})`;
ctx.beginPath(); ctx.arc(cxPad, cyPad - 70, 14, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = C.helipad; ctx.fillRect(sx, p.y-4, p.w, 8);
ctx.strokeStyle = C.cream; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(cxPad, cyPad, 44, Math.PI, 0); ctx.stroke();
ctx.fillStyle = C.cream;
ctx.font = 'bold 44px system-ui';
ctx.textAlign='center'; ctx.textBaseline='alphabetic';
ctx.fillText('H', cxPad, p.y-10);
}
function drawObstacle(o, cx){
if(o.kind==='antenna'){
const sx = o.x - cx;
if(sx < -30 || sx > W+30) return;
ctx.fillStyle = C.slate; ctx.fillRect(sx, o.y, o.w, o.h);
ctx.fillStyle = C.red; ctx.fillRect(sx-2, o.y, o.w+4, 8);
ctx.strokeStyle = 'rgba(61,81,101,.35)'; ctx.lineWidth=1;
ctx.beginPath();
ctx.moveTo(sx+o.w/2, o.y+14); ctx.lineTo(sx-20, o.y+o.h);
ctx.moveTo(sx+o.w/2, o.y+14); ctx.lineTo(sx+o.w+20, o.y+o.h);
ctx.stroke();
} else if(o.kind==='ac'){
const sx = o.x - cx;
if(sx < -50 || sx > W+50) return;
// Klimageraet gruen eingefaerbt, damit es deutlich als Hindernis
// erkennbar ist (fruehere Thomas-Direktive).
ctx.fillStyle = C.sage; ctx.fillRect(sx, o.y, o.w, o.h);
ctx.fillStyle = C.forest;
for(let i=0;i<4;i++) ctx.fillRect(sx+4+i*9, o.y+4, 6, o.h-8);
ctx.strokeStyle = C.forest; ctx.lineWidth = 1;
ctx.strokeRect(sx+.5, o.y+.5, o.w-1, o.h-1);
} else if(o.kind==='chimney'){
const sx = o.x - cx;
if(sx < -40 || sx > W+40) return;
ctx.fillStyle = C.bldgMidRoof; ctx.fillRect(sx, o.y, o.w, o.h);
ctx.fillStyle = C.slate; ctx.fillRect(sx-2, o.y-3, o.w+4, 4);
} else if(o.kind==='crane'){
const sx = o.x - cx;
const sxArm1 = o.armX1 - cx, sxArm2 = o.armX2 - cx;
if(sxArm2 < 0 || sxArm1 > W) return;
ctx.fillStyle = C.slate; ctx.fillRect(sx-16, GROUND_Y-6, 32, 6);
ctx.fillStyle = C.yellow; ctx.fillRect(sx-4, o.armY, 8, o.vertH);
ctx.strokeStyle = C.orange; ctx.lineWidth=1;
for(let y=o.armY; y<o.armY+o.vertH; y+=16){
ctx.beginPath(); ctx.moveTo(sx-4,y); ctx.lineTo(sx+4,y+8); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx+4,y); ctx.lineTo(sx-4,y+8); ctx.stroke();
}
ctx.fillStyle = C.yellow; ctx.fillRect(sxArm1, o.armY-4, sxArm2-sxArm1, 8);
ctx.fillStyle = C.slate; ctx.fillRect(sxArm1, o.armY-14, 28, 10);
ctx.strokeStyle = C.slate; ctx.lineWidth=1.5;
const hookX = sxArm1 + (sxArm2-sxArm1)*0.75;
ctx.beginPath(); ctx.moveTo(hookX, o.armY); ctx.lineTo(hookX, o.armY+60); ctx.stroke();
ctx.fillStyle = C.slate; ctx.fillRect(hookX-6, o.armY+58, 12, 8);
}
}
function drawAirport(a, cx){
const sx = a.x - cx;
if(sx+300 < 0 || sx > W) return;
ctx.fillStyle = C.slate; ctx.fillRect(sx-40, GROUND_Y-4, 340, 6);
ctx.fillStyle = C.wall; ctx.fillRect(sx+60, a.y-40, 180, 60);
ctx.fillStyle = C.slate; ctx.fillRect(sx+60, a.y-46, 180, 8);
ctx.fillStyle = C.wallShade; ctx.fillRect(sx+30, a.y-110, 28, 120);
ctx.fillStyle = C.glass; ctx.fillRect(sx+24, a.y-126, 40, 20);
ctx.fillStyle = C.slate; ctx.fillRect(sx+22, a.y-130, 44, 6);
ctx.fillStyle = C.win;
for(let i=0;i<8;i++) ctx.fillRect(sx+75+i*20, a.y-22, 12, 14);
ctx.fillStyle = C.red;
ctx.fillRect(sx+44, a.y-140, 2, 12);
ctx.beginPath(); ctx.moveTo(sx+46, a.y-140); ctx.lineTo(sx+58, a.y-136); ctx.lineTo(sx+46, a.y-132); ctx.closePath(); ctx.fill();
ctx.fillStyle = C.forest;
ctx.font = 'bold 13px system-ui'; ctx.textAlign='center';
ctx.fillText('✈ ZIEL', sx+150, a.y+14);
}
function drawExitMarker(sx){
if(sx < -30 || sx > W+30) return;
ctx.strokeStyle = 'rgba(45,84,52,.35)';
ctx.setLineDash([6,8]); ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(sx, 80); ctx.lineTo(sx, GROUND_Y-40); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = C.forest;
ctx.font = 'bold 12px system-ui'; ctx.textAlign='left';
ctx.fillText('ANFLUG ↓', sx+8, 100);
}
// ========== HELICOPTER ==========
function drawHeli(h, cx){
const v = HELI_VARIANTS[state.selectedHeli];
const sx = h.x - cx;
ctx.save();
ctx.translate(sx, h.y);
const blinkVisible = h.invul > 0 ? (Math.floor(performance.now()/80) % 2 === 0) : true;
ctx.scale(h.facing, 1);
ctx.rotate(h.tilt * h.facing);
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 (small text/dot)
ctx.save();
if(h.facing === -1) ctx.scale(-1,1); // un-mirror so text reads correctly
ctx.fillStyle = v.accent;
ctx.font = 'bold 6px system-ui';
ctx.textAlign = 'center';
ctx.fillText(state.selectedHeli === 'oamtc' ? '★' :
state.selectedHeli === 'ara' ? '+' :
state.selectedHeli === 'polizei' ? '★' : '✚',
h.facing === -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();
if(state.mode === 'autopilot'){
ctx.save();
ctx.fillStyle = 'rgba(61,81,101,.9)';
ctx.fillRect(sx - 66, h.y - 60, 132, 24);
ctx.fillStyle = C.cream;
ctx.font = 'bold 12px system-ui';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('🧑‍✈️ Pilot übernimmt', sx, h.y - 48);
ctx.restore();
}
// Locked hint: pulsing "↑ STARTEN" above the heli
if(h.locked && state.mode === 'flying'){
const pulse = (Math.sin(performance.now()*0.005) + 1) * 0.5;
ctx.save();
ctx.fillStyle = `rgba(45,84,52,${0.85 + pulse*0.15})`;
const w = 110, hh = 28;
const lx = sx - w/2, ly = h.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);
// Small arrow indicator pointing down to heli
ctx.fillStyle = `rgba(45,84,52,${0.85 + pulse*0.15})`;
ctx.beginPath();
ctx.moveTo(sx - 6, ly + hh);
ctx.lineTo(sx + 6, ly + hh);
ctx.lineTo(sx, ly + hh + 7);
ctx.closePath(); ctx.fill();
ctx.restore();
}
}
let last = performance.now();
function loop(now){
const dt = Math.min(0.033, (now-last)/1000);
last = now;
update(dt);
render();
requestAnimationFrame(loop);
}
reset();
renderSelectors();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>