7986bb2eb6
- 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>
3303 lines
132 KiB
HTML
3303 lines
132 KiB
HTML
<!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>
|