Files
geograsim/App/sims/heli/start.html
T
Adminator 7986bb2eb6 Heli: Treibstoff kann leer werden (1★-Takeover) + Live-Score fuer Tag-Farbe + Aktuell-Status/Phase
- start.html: Sprit-Floor 0.05->0; Treibstoff-leer loest Chefpilotin-Takeover aus (stars:1)
- game.html: GGS_LIVE_STATE liefert progressiven score (Routing/Start/Landung/Gesamt) -> farbige Tag-Balken
- teacher.html: Phasen-Map + Status-Spalte fuer mission/briefing/unterwegs (kein leeres "...")

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:48:26 +02:00

3303 lines
132 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<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}
/* Höhenwarnung — oberster Bildbereich = dünne Luft, Verbrauch x8 (Top-Flug = Spritaus). */
.alt-warn-bar{position:absolute;top:0;left:0;right:0;height:34px;background:linear-gradient(180deg,#c8423a 0%,rgba(200,66,58,0) 100%);pointer-events:none;opacity:0;transition:opacity .25s ease-out;z-index:5;color:#fff;font-weight:800;font-size:.85rem;letter-spacing:.05em;text-align:center;padding-top:6px;text-shadow:0 1px 2px rgba(0,0,0,.5)}
.alt-warn-bar.on{opacity:1;animation:altPulse 0.7s ease-in-out infinite}
.alt-warn-bar::before{content:"⚠ ZU HOCH — VERBRAUCH ×8"}
@keyframes altPulse{0%,100%{filter:brightness(1)}50%{filter:brightness(1.5)}}
.fuel-warn{animation:fuelBlink 0.5s steps(2) infinite}
@keyframes fuelBlink{0%,100%{background:#c8423a;box-shadow:0 0 6px rgba(200,66,58,.7)}50%{background:#e87a55}}
.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="alt-warn-bar" id="altWarnBar"></div>
<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.
// imagesBaseUrl='.' weil speakers.json image-Pfade schon mit 'img/speakers/...' beginnen.
HeliFunk.init('scripts', '.');
})();
</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() {
// Cache-Buster: damit Browser nach Audio-Regen nicht alte MP3s aus dem Cache zieht
const _v = '?_v=2026-06-01-c';
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' + _v);
if (!resp.ok) resp = await fetch(SND_PATH + 'radio/' + name + '.mp3' + _v);
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 {
// Harden: helikey aus URL, sonst parent-iframe (window.HELI_KEY),
// sonst leer -> funk-pille faellt auf Marlene + "Tower" ohne Stadt zurueck.
const params = new URLSearchParams(location.search);
const heliKey = params.get('helikey') || params.get('heliKey') || window.HELI_KEY || '';
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: seit 2026-05-31 immer Marlene (weiblich). Keine Echo/Alloy-Rotation
// mehr — sonst spielt das alte male-Legacy-MP3 statt der neuen r_-Version.
let pilotVoice = ''; // leer => voiceName matched nicht, fallback auf RADIO_FILES[name]
function setPilotVoice() {
const missionCount = parseInt(localStorage.getItem('ggs_heli_missions') || '0');
localStorage.setItem('ggs_heli_missions', missionCount + 1);
pilotVoice = ''; // Marlene only
}
// 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: 'Notarzt-Heli', short: 'Notarzt-Heli', tag: 'C1 Innsbruck',
bases: ['C1 Innsbruck','C5 Zams','C8 Nenzing','C4 Kitzbühel','C7 Lienz'],
body:'#e4b95c', belly:'#c97a3f', accent:'#c85c4a', text:'Notarzt-Heli'
},
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 "Innsbruck OE-XVB" 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) {
// 'tower' als prefix matched auch tower_sea_/tower_hw_/tower_strike_ etc. — daher
// explizit auf tower1-4 (generic) einschraenken. Sonst Mission-Mismatch (z.B.
// Eisenstadt-Audio in Bregenz-Mission).
setTimeout(function(){
if (Math.random() < 0.5) { playRadioRandom('traffic'); }
else { playRadio('tower' + (Math.floor(Math.random()*4)+1)); }
}, 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);
// Verbrauchsfaktor: Grundverbrauch 1x + sanfter Anstieg 0.5x mit der Hoehe.
// Im obersten 10% explodiert der Faktor auf x8 (duenne Luft, Rotor schaufelt
// ins Leere). Voller Top-Flug von Start bis Exit fuehrt zwingend zum Sprit-Aus.
const altFactor = 1 + altRatio * 0.5 + (altRatio > 0.9 ? 6.5 : 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, h.fuel - 0.005 * altFactor * thrustMult * dt);
// Treibstoff leer → Chefpilotin übernimmt (wie 3 Berührungen): schlechteste
// Start-Bewertung (1★). Nur einmal auslösen und nicht, wenn ohnehin schon
// ein Takeover läuft.
if (h.fuel <= 0 && state.mode !== 'autopilot' && !state._fuelEmpty) {
state._fuelEmpty = true;
state.mode = 'autopilot';
stopAllAmbient();
state.autopilotT = 0;
h.invul = 999;
state.takeoverReason = 'Treibstoff leer — die Chefpilotin bringt den Helikopter sicher zurück. Beim nächsten Start früher auf den Verbrauch achten und nicht zu hoch steigen (dünne Luft = ×8 Verbrauch).';
state.takeoverShort = 'Treibstoff leer';
state.radio.current = 'Pilot: Tank leer! Ich übernehme.';
state.radio.shown = 0; state.radio.nextAt = 99;
try { playRadio('warn_fuel'); } catch(_){}
}
// Visuelle Höhenwarnung: roter Pulse oben + Fuel-HUD blinkt
// Greift sobald altRatio > 0.9 (= im obersten 10 % der Bildhöhe).
var inDangerZone = altRatio > 0.9;
var warnEl = document.getElementById('altWarnBar');
if (warnEl) warnEl.classList.toggle('on', inDangerZone);
var fuelEl = document.getElementById('fFuel');
if (fuelEl) fuelEl.classList.toggle('fuel-warn', inDangerZone);
// Pilot-Funk: sofort beim ersten Eintritt in die Decken-Zone warnen
// (statt erst bei Tank-Notstand) — Schueler*innen verstehen so direkt warum
// der Verbrauch explodiert.
if (inDangerZone && !state._highAltWarned) {
state._highAltWarned = true;
state.radio.current = 'Pilot: Zu hoch! Dünne Luft, Verbrauch ×8 — sofort runter!';
state.radio.shown = 0; state.radio.nextAt = 6;
playRadio('pilot_too_high');
}
// Re-armieren wenn deutlich unter der Decke (Hysterese vermeidet Spam)
if (state._highAltWarned && altRatio < 0.75) state._highAltWarned = false;
// Zusätzlich Tank-kritisch-Warnung (alte Logik)
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>