← Flight Simulator results

Flight Simulator

Grok 4.7 · full

◈Preview unavailable — try playing the result

Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 88,933 bytes · SHA-256 568ca2145216
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APEX AERO — Flight Dynamics Simulator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
  :root {
    --bg: #07090d;
    --amber: #e7a44a;
    --amber-2: #ffd59a;
    --cyan: #9dffc8;
    --danger: #ff5a5a;
    --ink: #e8eef6;
    --muted: #93a0b5;
    --panel: rgba(6, 10, 16, 0.78);
    --line: rgba(231, 164, 74, 0.35);
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #05070a; color: var(--ink); font-family: "Barlow Condensed", "Segoe UI", sans-serif; }
  #view { position: fixed; inset: 0; display: block; width: 100%; height: 100%; }
  #hud {
    position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4;
  }
  #vignette {
    position: fixed; inset: 0; pointer-events: none; z-index: 5;
    box-shadow: inset 0 0 180px 40px rgba(0,0,0,0.45);
    transition: box-shadow 0.08s linear;
  }
  #boot {
    position: fixed; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center;
    background: radial-gradient(ellipse at 50% 30%, #1a2433, #07090d 70%);
    letter-spacing: 0.42em; font-size: 14px; color: var(--amber);
  }
  .overlay {
    position: fixed; z-index: 10; pointer-events: none;
  }
  .overlay > * { pointer-events: auto; }
  #topbar {
    top: 0; left: 0; right: 0; display: flex; justify-content: space-between; align-items: flex-end;
    padding: 18px 28px 10px; background: linear-gradient(to bottom, rgba(0,0,0,0.72), transparent);
  }
  .brand { display: flex; flex-direction: column; gap: 2px; }
  .brand h1 {
    font-weight: 700; font-size: 34px; letter-spacing: 0.34em; line-height: 1;
    background: linear-gradient(90deg, #fff, var(--amber-2) 40%, var(--amber));
    -webkit-background-clip: text; background-clip: text; color: transparent;
  }
  .brand small { color: var(--muted); letter-spacing: 0.28em; font-size: 12px; font-weight: 500; }
  .brand-meta { text-align: right; color: var(--muted); letter-spacing: 0.18em; font-size: 13px; font-family: "Share Tech Mono", monospace; }
  #sheet {
    left: 0; right: 0; bottom: 0; padding: 14px 22px 18px;
    background: linear-gradient(to top, rgba(4,6,10,0.92) 40%, rgba(4,6,10,0.55) 78%, transparent);
  }
  .sheet-inner { display: grid; grid-template-columns: 1.5fr 0.85fr 220px; gap: 16px; align-items: stretch; max-width: 1280px; margin: 0 auto; }
  .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
  .card {
    background: var(--panel); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
    padding: 12px 12px 10px; cursor: pointer; backdrop-filter: blur(8px);
    transition: border 0.15s, transform 0.15s, background 0.15s;
    min-height: 168px;
  }
  .card:hover { transform: translateY(-2px); border-color: rgba(231,164,74,0.45); }
  .card.sel { border-color: var(--amber); box-shadow: 0 0 0 1px rgba(231,164,74,0.35), 0 10px 30px rgba(0,0,0,0.35); background: rgba(28, 20, 8, 0.72); }
  .card .role { font-family: "Share Tech Mono", monospace; font-size: 10px; letter-spacing: 0.16em; color: var(--amber); }
  .card h3 { font-size: 22px; letter-spacing: 0.08em; margin: 2px 0 6px; }
  .stats { display: grid; gap: 4px; }
  .stat { display: grid; grid-template-columns: 62px 1fr 58px; gap: 6px; align-items: center; font-size: 12px; color: var(--muted); letter-spacing: 0.06em; }
  .bar { height: 4px; background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; }
  .bar span { display: block; height: 100%; background: linear-gradient(90deg, #8a6a32, var(--amber)); border-radius: 4px; }
  .stat b { font-weight: 500; color: var(--ink); font-family: "Share Tech Mono", monospace; font-size: 11px; text-align: right; white-space: nowrap; }
  .modes { display: flex; flex-direction: column; gap: 8px; }
  .mode {
    text-align: left; background: var(--panel); color: var(--ink); border: 1px solid rgba(255,255,255,0.08);
    border-radius: 10px; padding: 10px 12px; cursor: pointer; backdrop-filter: blur(8px);
  }
  .mode.sel { border-color: var(--cyan); box-shadow: 0 0 0 1px rgba(157,255,200,0.25); }
  .mode strong { display: block; letter-spacing: 0.14em; font-size: 14px; }
  .mode span { display: block; color: var(--muted); font-size: 13px; line-height: 1.25; margin-top: 2px; }
  .launch-col { display: flex; flex-direction: column; gap: 8px; justify-content: stretch; }
  button.launch {
    flex: 1; border: 0; border-radius: 10px; cursor: pointer;
    background: linear-gradient(180deg, #f0c27a, #c4842d);
    color: #1a1004; font-family: inherit; font-weight: 700; letter-spacing: 0.22em; font-size: 18px;
    box-shadow: 0 8px 24px rgba(196, 132, 45, 0.35);
  }
  button.launch:hover { filter: brightness(1.06); }
  .keys {
    font-family: "Share Tech Mono", monospace; font-size: 10px; color: var(--muted); line-height: 1.45;
    background: rgba(0,0,0,0.35); border-radius: 8px; padding: 8px; letter-spacing: 0.02em;
  }
  .keys b { color: var(--amber-2); font-weight: 500; }
  #pause, #debrief {
    position: fixed; inset: 0; z-index: 12; display: none; align-items: center; justify-content: center;
    background: rgba(4,6,10,0.55); backdrop-filter: blur(6px);
  }
  .modal {
    width: min(520px, calc(100% - 32px)); background: rgba(8,12,18,0.92);
    border: 1px solid var(--line); border-radius: 14px; padding: 26px 26px 20px;
    box-shadow: 0 30px 80px rgba(0,0,0,0.5);
  }
  .modal h2 { letter-spacing: 0.2em; font-size: 28px; }
  .modal p { color: var(--muted); margin: 8px 0 16px; line-height: 1.4; }
  .row { display: flex; gap: 8px; }
  .row button, .modal button {
    flex: 1; padding: 12px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1);
    background: rgba(255,255,255,0.04); color: var(--ink); font-family: inherit; letter-spacing: 0.14em;
    font-size: 15px; cursor: pointer;
  }
  .row button.primary, .modal button.primary { background: linear-gradient(180deg, #f0c27a, #c4842d); color: #1a1004; border: 0; font-weight: 700; }
  .grades { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 14px; }
  .grades div { background: rgba(255,255,255,0.03); border-radius: 8px; padding: 8px 10px; }
  .grades label { display: block; color: var(--muted); font-size: 11px; letter-spacing: 0.14em; }
  .grades strong { font-family: "Share Tech Mono", monospace; font-size: 16px; color: var(--cyan); }
  #warn-banner {
    position: fixed; top: 18%; left: 50%; transform: translateX(-50%); z-index: 6; pointer-events: none;
    font-family: "Share Tech Mono", monospace; letter-spacing: 0.28em; font-size: 22px; color: var(--danger);
    text-shadow: 0 0 12px rgba(255,60,60,0.8); opacity: 0;
  }
  #warn-banner.on { opacity: 1; animation: blink 0.45s steps(2) infinite; }
  @keyframes blink { 50% { opacity: 0.25; } }
  #help {
    position: fixed; top: 16px; right: 16px; z-index: 7; display: none;
    background: rgba(6,10,16,0.8); border: 1px solid rgba(157,255,200,0.25); color: var(--cyan);
    font-family: "Share Tech Mono", monospace; font-size: 11px; padding: 8px 10px; border-radius: 8px;
    letter-spacing: 0.06em; line-height: 1.5;
  }
  @media (max-width: 980px) {
    .sheet-inner { grid-template-columns: 1fr; max-height: 52vh; overflow: auto; }
    .brand h1 { font-size: 26px; }
  }
</style>
</head>
<body>
<canvas id="view"></canvas>
<canvas id="hud"></canvas>
<div id="vignette"></div>
<div id="warn-banner">STALL</div>
<div id="help">MOUSE stick · W/S throttle · A/D rudder · Q/E roll · Arrows pitch/roll · G gear · F flaps · B brake · [ ] trim · C cam · M mute · L SAS · Space guns · R restart · Esc pause · H help</div>
<div id="boot">INITIALIZING FLIGHT MODEL</div>

<div class="overlay" id="topbar">
  <div class="brand">
    <h1>APEX AERO</h1>
    <small>NEWTONIAN FLIGHT DYNAMICS LABORATORY</small>
  </div>
  <div class="brand-meta">SEA LEVEL ρ 1.225 · g 9.806 · ISA ATMOSPHERE<br>LIFT · INDUCED DRAG · INERTIA · DENSITY ALTITUDE</div>
</div>
<div class="overlay" id="sheet">
  <div class="sheet-inner">
    <div class="cards" id="cards"></div>
    <div class="modes" id="modes"></div>
    <div class="launch-col">
      <button class="launch" id="launch">LAUNCH</button>
      <div class="keys">
        <b>W/S</b> throttle &nbsp; <b>MOUSE</b> stick<br>
        <b>A/D</b> rudder &nbsp; <b>Q/E</b> roll<br>
        <b>G</b> gear &nbsp; <b>F</b> flaps &nbsp; <b>B</b> speedbrake<br>
        <b>[ ]</b> trim &nbsp; <b>C</b> camera &nbsp; <b>SPACE</b> guns<br>
        <b>L</b> stability assist &nbsp; <b>H</b> help overlay
      </div>
    </div>
  </div>
</div>

<div id="pause">
  <div class="modal">
    <h2>PAUSED</h2>
    <p>Physics frozen. Air-mass, inertia and control state are held.</p>
    <div class="row">
      <button class="primary" id="resume">RESUME</button>
      <button id="abort">ABORT TO RAMP</button>
    </div>
  </div>
</div>
<div id="debrief">
  <div class="modal">
    <h2 id="db-title">DEBRIEF</h2>
    <p id="db-copy"></p>
    <div class="grades" id="db-grades"></div>
    <div class="row">
      <button class="primary" id="retry">FLY AGAIN</button>
      <button id="tomenu">RETURN TO RAMP</button>
    </div>
  </div>
</div>

<script>
const $ = (id) => document.getElementById(id);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
const KT = 1.943844;
const FT = 3.28084;

const AIRCRAFT = {
  falcon: {
    id: "falcon", name: "APEX FALCON", role: "AIR SUPERIORITY",
    mass: 9400, thrust: 108000, abBoost: 1.42, altPower: 0.28,
    wingArea: 31, span: 10.6, clAlpha: 4.9, clMax: 1.45, cd0: 0.019, oswald: 0.8,
    stallAoA: 16.5 * DEG, cyBeta: -0.85,
    inertia: { x: 32000, y: 46000, z: 14000 },
    ctrl: { x: 72000, y: 28000, z: 90000 },
    stab: { x: 90000, y: 52000, z: 18000 },
    damp: { x: 22000, y: 18000, z: 14000 },
    gearH: 2.15, vne: 400, maxG: 9.0, minG: -3.2,
    afterburner: true, kind: "low-bypass turbofan",
    colors: { primary: 0x8fa4b8, accent: 0xc9843a, dark: 0x2a3340, glass: 0x9fd0e6 }
  },
  hauler: {
    id: "hauler", name: "STRATUS HAULER", role: "HEAVY LIFT TRANSPORT",
    mass: 52000, thrust: 175000, abBoost: 1, altPower: 0.62,
    wingArea: 152, span: 38, clAlpha: 5.5, clMax: 1.85, cd0: 0.03, oswald: 0.72,
    stallAoA: 15 * DEG, cyBeta: -1.05,
    inertia: { x: 620000, y: 780000, z: 240000 },
    ctrl: { x: 210000, y: 140000, z: 160000 },
    stab: { x: 480000, y: 360000, z: 90000 },
    damp: { x: 180000, y: 140000, z: 90000 },
    gearH: 4.6, vne: 165, maxG: 2.6, minG: -1.0,
    afterburner: false, kind: "turboprop",
    colors: { primary: 0xb7a06a, accent: 0xd8d2c4, dark: 0x4a4034, glass: 0xc5ddd4 }
  },
  dart: {
    id: "dart", name: "VIPER DART", role: "HIGH-ALT INTERCEPTOR",
    mass: 6800, thrust: 98000, abBoost: 1.62, altPower: 0.18,
    wingArea: 20, span: 8.2, clAlpha: 3.8, clMax: 1.02, cd0: 0.013, oswald: 0.78,
    stallAoA: 13 * DEG, cyBeta: -0.55,
    inertia: { x: 14000, y: 18000, z: 5200 },
    ctrl: { x: 48000, y: 16000, z: 62000 },
    stab: { x: 28000, y: 18000, z: 6000 },
    damp: { x: 7000, y: 6000, z: 3200 },
    gearH: 1.7, vne: 340, maxG: 8.2, minG: -2.6,
    afterburner: true, kind: "turbojet + afterburner",
    colors: { primary: 0x2c3138, accent: 0xc43a3a, dark: 0x15171b, glass: 0xffb0a0 }
  }
};

const SCENARIOS = {
  pattern: {
    id: "pattern", title: "PRECISION PATTERN",
    blurb: "Rotate, climb, fly the circuit, and plant it on the numbers. Weight, flaps and density altitude all count."
  },
  dogfight: {
    id: "dogfight", title: "BANDIT INTERCEPT",
    blurb: "Three bandits. Manage energy, angle of attack and G. The airframe will depart if you ask too much."
  }
};

let selectedCraft = "falcon";
let selectedScenario = "pattern";

function stallSpeed(cfg, flaps = 0) {
  const cl = cfg.clMax * (1 + 0.42 * flaps);
  return Math.sqrt((2 * cfg.mass * 9.806) / (1.225 * cfg.wingArea * cl));
}
function tw(cfg) { return cfg.thrust / (cfg.mass * 9.806); }
function wingLoad(cfg) { return cfg.mass / cfg.wingArea; }

function renderMenu() {
  const cards = $("cards");
  cards.innerHTML = "";
  for (const id of ["falcon", "hauler", "dart"]) {
    const c = AIRCRAFT[id];
    const el = document.createElement("div");
    el.className = "card" + (id === selectedCraft ? " sel" : "");
    el.innerHTML = `
      <div class="role">${c.role}</div>
      <h3>${c.name}</h3>
      <div class="stats">
        ${stat("T/W", tw(c), tw(c).toFixed(2), 2.0)}
        ${stat("W/S", wingLoad(c) / 400, Math.round(wingLoad(c)) + "", 1)}
        ${stat("STALL", stallSpeed(c) / 90, Math.round(stallSpeed(c) * KT) + " kt", 1)}
        ${stat("VNE", c.vne / 520, Math.round(c.vne * KT) + " kt", 1)}
        ${stat("AGILITY", (c.ctrl.z / c.inertia.z) / 12, (c.ctrl.z / c.inertia.z).toFixed(1), 1)}
      </div>`;
    el.onclick = () => { selectedCraft = id; renderMenu(); onSelectCraft(); };
    cards.appendChild(el);
  }
  const modes = $("modes");
  modes.innerHTML = "";
  for (const id of ["pattern", "dogfight"]) {
    const s = SCENARIOS[id];
    const b = document.createElement("button");
    b.className = "mode" + (id === selectedScenario ? " sel" : "");
    b.innerHTML = `<strong>${s.title}</strong><span>${s.blurb}</span>`;
    b.onclick = () => { selectedScenario = id; renderMenu(); };
    modes.appendChild(b);
  }
}
function stat(label, t, text) {
  return `<div class="stat"><span>${label}</span><div class="bar"><span style="width:${clamp(t, 0, 1) * 100}%"></span></div><b>${text}</b></div>`;
}

/* ---------- noise / atmosphere ---------- */
function hash2(ix, iz) {
  let n = Math.imul(ix | 0, 374761393) + Math.imul(iz | 0, 668265263);
  n = Math.imul(n ^ (n >>> 13), 1274126177);
  return ((n ^ (n >>> 16)) >>> 0) / 4294967295;
}
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function vnoise(x, z) {
  const ix = Math.floor(x), iz = Math.floor(z);
  const fx = x - ix, fz = z - iz;
  const u = fade(fx), v = fade(fz);
  const a = hash2(ix, iz), b = hash2(ix + 1, iz), c = hash2(ix, iz + 1), d = hash2(ix + 1, iz + 1);
  return a * (1 - u) * (1 - v) + b * u * (1 - v) + c * (1 - u) * v + d * u * v;
}
function fbm(x, z) {
  let h = 0, a = 0.5, f = 1, s = 0;
  for (let i = 0; i < 6; i++) {
    h += a * vnoise(x * f, z * f);
    s += a; a *= 0.5; f *= 2.07;
  }
  return h / s;
}
function terrainHeight(x, z) {
  const wx = fbm(x * 0.00022 + 2.2, z * 0.00022 + 8.0);
  const wz = fbm(x * 0.00022 + 19.0, z * 0.00022 + 3.4);
  let h = (fbm(x * 0.00018 + wx * 1.4, z * 0.00018 + wz * 1.4) - 0.46) * 1650;
  h += (fbm(x * 0.0009 + 4, z * 0.0009) - 0.5) * 120;
  h += (fbm(x * 0.003, z * 0.003) - 0.5) * 18;
  const coast = fbm(x * 0.00008 + 1, z * 0.00008) ;
  h -= (coast - 0.35) * 220;
  h -= 30;
  // broad airfield bowl — force the field down so ridges cannot swallow the strip
  const dx = x / 1400;
  const dz = (z - 700) / 2200;
  const bowl = dx * dx + dz * dz;
  if (bowl < 1.15) {
    const sm = clamp(1 - bowl, 0, 1);
    const w = sm * sm * (3 - 2 * sm);
    const field = 1.05 + bowl * 26;
    h = h * (1 - w) + field * w;
  }
  const ax = Math.abs(x);
  if (ax < 80 && z > -80 && z < 1750) {
    const edge = clamp((ax - 36) / 44, 0, 1);
    let zend = 1;
    if (z < 40) zend = clamp((z + 80) / 120, 0, 1);
    else if (z > 1600) zend = clamp((1750 - z) / 150, 0, 1);
    const f = (1 - edge) * zend;
    const s = f * f * (3 - 2 * f);
    h = lerp(h, 1.08, s);
  }
  return h;
}
function terrainNormal(x, z) {
  const e = 6;
  const hL = terrainHeight(x - e, z), hR = terrainHeight(x + e, z);
  const hD = terrainHeight(x, z - e), hU = terrainHeight(x, z + e);
  const n = new THREE.Vector3(hL - hR, 2 * e, hD - hU);
  return n.normalize();
}
function airDensity(y) { return 1.225 * Math.exp(-Math.max(0, y) / 8500); }
function isaTemp(y) { return 288.15 - 0.0065 * clamp(y, 0, 11000); }
function speedOfSound(y) { return Math.sqrt(1.4 * 287.05 * isaTemp(y)); }
function windAt(y) {
  const m = 2.5 + Math.max(0, y) * 0.0035;
  return new THREE.Vector3(m, 0, m * 0.22);
}

/* ---------- audio (procedural, physics-linked) ---------- */
const audio = {
  ctx: null, master: null, ready: false, muted: false,
  engGain: null, engFilter: null, whine: null, whineGain: null,
  windGain: null, windFilter: null, stallOsc: null, stallGain: null,
  lfo: null
};
function initAudio() {
  if (audio.ready) return;
  const ctx = new (window.AudioContext || window.webkitAudioContext)();
  audio.ctx = ctx;
  const master = ctx.createGain();
  master.gain.value = 0.55;
  const comp = ctx.createDynamicsCompressor();
  comp.threshold.value = -18;
  comp.knee.value = 18;
  comp.ratio.value = 3.2;
  master.connect(comp).connect(ctx.destination);
  audio.master = master;

  const len = ctx.sampleRate * 2;
  const buf = ctx.createBuffer(1, len, ctx.sampleRate);
  const d = buf.getChannelData(0);
  let b0 = 0;
  for (let i = 0; i < len; i++) {
    const w = Math.random() * 2 - 1;
    b0 = 0.986 * b0 + 0.014 * w;
    d[i] = b0 * 4.2;
  }
  const windBuf = ctx.createBuffer(1, len, ctx.sampleRate);
  const wd = windBuf.getChannelData(0);
  for (let i = 0; i < len; i++) wd[i] = Math.random() * 2 - 1;

  const src = ctx.createBufferSource();
  src.buffer = buf; src.loop = true;
  const bp = ctx.createBiquadFilter();
  bp.type = "bandpass"; bp.frequency.value = 280; bp.Q.value = 0.85;
  const eg = ctx.createGain(); eg.gain.value = 0.0001;
  src.connect(bp).connect(eg).connect(master);
  src.start();
  audio.engFilter = bp; audio.engGain = eg;

  const osc = ctx.createOscillator();
  osc.type = "sawtooth"; osc.frequency.value = 90;
  const og = ctx.createGain(); og.gain.value = 0.0001;
  const olp = ctx.createBiquadFilter(); olp.type = "lowpass"; olp.frequency.value = 900;
  osc.connect(olp).connect(og).connect(master);
  osc.start();
  audio.whine = osc; audio.whineGain = og;

  const wsrc = ctx.createBufferSource();
  wsrc.buffer = windBuf; wsrc.loop = true;
  const hp = ctx.createBiquadFilter();
  hp.type = "highpass"; hp.frequency.value = 700;
  const wg = ctx.createGain(); wg.gain.value = 0.0001;
  wsrc.connect(hp).connect(wg).connect(master);
  wsrc.start();
  audio.windGain = wg; audio.windFilter = hp;

  const so = ctx.createOscillator();
  so.type = "square"; so.frequency.value = 310;
  const sg = ctx.createGain(); sg.gain.value = 0;
  so.connect(sg).connect(master);
  so.start();
  audio.stallOsc = so; audio.stallGain = sg;
  audio.ready = true;
}
function setMuted(m) {
  audio.muted = m;
  if (audio.master) audio.master.gain.value = m ? 0 : 0.55;
}
function blip(freq, dur, type, vol) {
  if (!audio.ready || audio.muted) return;
  const ctx = audio.ctx;
  const o = ctx.createOscillator();
  const g = ctx.createGain();
  o.type = type || "sine";
  o.frequency.value = freq;
  g.gain.setValueAtTime(vol || 0.08, ctx.currentTime);
  g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);
  o.connect(g).connect(audio.master);
  o.start(); o.stop(ctx.currentTime + dur + 0.02);
}
function noiseBurst(dur, vol, freq) {
  if (!audio.ready || audio.muted) return;
  const ctx = audio.ctx;
  const len = Math.floor(ctx.sampleRate * dur);
  const buf = ctx.createBuffer(1, len, ctx.sampleRate);
  const d = buf.getChannelData(0);
  for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
  const s = ctx.createBufferSource();
  s.buffer = buf;
  const f = ctx.createBiquadFilter();
  f.type = "bandpass"; f.frequency.value = freq || 400; f.Q.value = 0.6;
  const g = ctx.createGain(); g.gain.value = vol || 0.4;
  s.connect(f).connect(g).connect(audio.master);
  s.start();
}
function updateAudio(ship, dt) {
  if (!audio.ready) return;
  const ctx = audio.ctx;
  const t = ctx.currentTime;
  const rho = airDensity(ship.pos.y);
  const rpm = ship.rpm;
  const thr = ship.throttle;
  const spd = ship.airspeed;
  const bpF = 140 + rpm * 920 + thr * 180;
  audio.engFilter.frequency.setTargetAtTime(bpF, t, 0.05);
  const eg = (0.04 + rpm * 0.22 + thr * 0.08) * (0.55 + 0.45 * Math.sqrt(rho / 1.225));
  audio.engGain.gain.setTargetAtTime(audio.muted ? 0 : eg, t, 0.08);
  audio.whine.frequency.setTargetAtTime(70 + rpm * 340 + (ship.ab ? 80 : 0), t, 0.05);
  audio.whineGain.gain.setTargetAtTime(audio.muted ? 0 : 0.012 + rpm * 0.04, t, 0.08);
  const wind = clamp(spd / 280, 0, 1);
  audio.windGain.gain.setTargetAtTime(audio.muted ? 0 : wind * wind * 0.28 + ship.aoaAbs * 0.05, t, 0.1);
  audio.windFilter.frequency.setTargetAtTime(500 + spd * 2.2, t, 0.1);
  const stalling = ship.airborne && ship.aoaAbs > ship.cfg.stallAoA * 0.82 && spd > 25;
  const pulse = stalling ? (0.5 + 0.5 * Math.sin(performance.now() * 0.04)) * 0.045 : 0;
  audio.stallGain.gain.setTargetAtTime(audio.muted ? 0 : pulse, t, 0.03);
  if (stalling) audio.stallOsc.frequency.setTargetAtTime(240 + ship.aoaAbs * 400, t, 0.05);
}

/* ---------- three scene ---------- */
let renderer, scene, camera, sun, hemi;
const world = new THREE.Group();
let skyMesh, waterMesh, terrainGroup, treeMesh, runwayGroup;
const CHUNK = 1000, SEG = 28, GRID = 9, HALF = 4;
let chunkOrigin = { x: 99999, z: 99999 };
const tmp = {
  v: new THREE.Vector3(), v2: new THREE.Vector3(), v3: new THREE.Vector3(),
  q: new THREE.Quaternion(), q2: new THREE.Quaternion(),
  e: new THREE.Euler(), m: new THREE.Matrix4(), c: new THREE.Color()
};

function bootThree() {
  const canvas = $("view");
  renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance" });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.6));
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.05;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  scene = new THREE.Scene();
  scene.fog = new THREE.FogExp2(0xb7c5d0, 0.00012);
  camera = new THREE.PerspectiveCamera(62, window.innerWidth / window.innerHeight, 0.15, 40000);
  camera.position.set(-40, 18, -30);

  hemi = new THREE.HemisphereLight(0xc5d7ee, 0x6a5a40, 0.72);
  scene.add(hemi);
  sun = new THREE.DirectionalLight(0xfff1d6, 1.55);
  sun.position.set(-400, 2200, 1400);
  sun.castShadow = true;
  sun.shadow.mapSize.set(2048, 2048);
  sun.shadow.camera.near = 20;
  sun.shadow.camera.far = 2800;
  const s = 700;
  sun.shadow.camera.left = -s; sun.shadow.camera.right = s;
  sun.shadow.camera.top = s; sun.shadow.camera.bottom = -s;
  sun.shadow.bias = -0.00025;
  scene.add(sun);
  scene.add(sun.target);

  buildSky();
  buildWater();
  buildTerrain();
  buildTrees();
  buildAirport();
  buildParticles();
  scene.add(world);
  window.addEventListener("resize", onResize);
}

function onResize() {
  if (!renderer) return;
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  const hud = $("hud");
  hud.width = Math.floor(window.innerWidth * Math.min(devicePixelRatio, 1.6));
  hud.height = Math.floor(window.innerHeight * Math.min(devicePixelRatio, 1.6));
}

function buildSky() {
  const geo = new THREE.SphereGeometry(18000, 32, 18);
  const mat = new THREE.ShaderMaterial({
    side: THREE.BackSide,
    depthWrite: false,
    fog: false,
    uniforms: { uSun: { value: new THREE.Vector3(0.25, 0.82, 0.45).normalize() } },
    vertexShader: `
      varying vec3 vDir;
      void main() {
        vDir = position;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }`,
    fragmentShader: `
      varying vec3 vDir;
      uniform vec3 uSun;
      void main() {
        vec3 dir = normalize(vDir);
        float h = dir.y;
        vec3 zenith = vec3(0.16, 0.38, 0.78);
        vec3 mid = vec3(0.45, 0.66, 0.90);
        vec3 hor = vec3(0.78, 0.82, 0.84);
        vec3 col = mix(hor, mid, smoothstep(0.0, 0.18, h));
        col = mix(col, zenith, smoothstep(0.12, 0.7, h));
        col = mix(vec3(0.55, 0.58, 0.60), col, smoothstep(-0.25, 0.02, h));
        float sunD = pow(max(dot(dir, normalize(uSun)), 0.0), 180.0);
        float sunG = pow(max(dot(dir, normalize(uSun)), 0.0), 8.0);
        col += vec3(1.0, 0.86, 0.62) * sunD * 1.8;
        col += vec3(1.0, 0.72, 0.42) * sunG * 0.18;
        gl_FragColor = vec4(col, 1.0);
      }`
  });
  skyMesh = new THREE.Mesh(geo, mat);
  skyMesh.frustumCulled = false;
  scene.add(skyMesh);
}

function buildWater() {
  const geo = new THREE.PlaneGeometry(24000, 24000, 80, 80);
  geo.rotateX(-Math.PI / 2);
  const mat = new THREE.ShaderMaterial({
    transparent: true,
    depthWrite: false,
    fog: false,
    uniforms: {
      uTime: { value: 0 },
      uShallow: { value: new THREE.Color(0x1a7ea0) },
      uDeep: { value: new THREE.Color(0x06324a) }
    },
    vertexShader: `
      uniform float uTime;
      varying vec3 vW;
      varying float vWave;
      void main() {
        vec3 p = position;
        float w = sin(p.x * 0.02 + uTime * 0.8) * 0.35 + sin(p.z * 0.015 - uTime * 0.6) * 0.28;
        p.y += w;
        vec4 wp = modelMatrix * vec4(p, 1.0);
        vW = wp.xyz; vWave = w;
        gl_Position = projectionMatrix * viewMatrix * wp;
      }`,
    fragmentShader: `
      uniform vec3 uShallow; uniform vec3 uDeep; uniform float uTime;
      varying vec3 vW; varying float vWave;
      void main() {
        float spark = sin(vW.x * 0.11 + uTime * 1.4) * sin(vW.z * 0.09 - uTime);
        vec3 col = mix(uDeep, uShallow, 0.45 + vWave * 0.4 + spark * 0.08);
        col += vec3(0.65, 0.8, 0.85) * smoothstep(0.82, 1.0, spark) * 0.35;
        gl_FragColor = vec4(col, 0.78);
      }`
  });
  waterMesh = new THREE.Mesh(geo, mat);
  waterMesh.position.y = 0.05;
  waterMesh.renderOrder = 2;
  scene.add(waterMesh);
}

function buildTerrain() {
  terrainGroup = new THREE.Group();
  const mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.94, metalness: 0.02 });
  for (let i = 0; i < GRID * GRID; i++) {
    const geo = new THREE.PlaneGeometry(CHUNK, CHUNK, SEG, SEG);
    geo.rotateX(-Math.PI / 2);
    const colors = new Float32Array(geo.attributes.position.count * 3);
    geo.setAttribute("color", new THREE.BufferAttribute(colors, 3));
    const mesh = new THREE.Mesh(geo, mat);
    mesh.receiveShadow = true;
    mesh.castShadow = false;
    terrainGroup.add(mesh);
  }
  scene.add(terrainGroup);
}

function colorFor(h, slope, n) {
  const sand = tmp.c.set(0x000000);
  let col;
  if (h < 0.4) col = [0.45, 0.42, 0.32];
  else if (h < 3.2) col = [0.72, 0.66, 0.46];
  else if (h < 90) col = [0.22, 0.38, 0.18];
  else if (h < 260) col = [0.18, 0.32, 0.16];
  else if (h < 520) col = [0.38, 0.36, 0.33];
  else col = [0.82, 0.84, 0.86];
  if (slope < 0.72 && h > 4 && h < 560) {
    const rock = [0.42, 0.40, 0.38];
    const k = clamp((0.72 - slope) / 0.4, 0, 1);
    col = col.map((v, i) => lerp(v, rock[i], k));
  }
  if (h < 0) col = col.map(v => v * 0.45);
  const mott = 0.92 + hash2(Math.floor(n.x * 0.05), Math.floor(n.z * 0.05)) * 0.12;
  return col.map(v => v * mott);
}

function rebuildChunk(mesh, cx, cz) {
  mesh.position.set((cx + 0.5) * CHUNK, 0, (cz + 0.5) * CHUNK);
  const pos = mesh.geometry.attributes.position;
  const col = mesh.geometry.attributes.color;
  for (let i = 0; i < pos.count; i++) {
    const wx = mesh.position.x + pos.getX(i);
    const wz = mesh.position.z + pos.getZ(i);
    const h = terrainHeight(wx, wz);
    pos.setY(i, h);
  }
  pos.needsUpdate = true;
  mesh.geometry.computeVertexNormals();
  const nor = mesh.geometry.attributes.normal;
  for (let i = 0; i < pos.count; i++) {
    const wx = mesh.position.x + pos.getX(i);
    const wz = mesh.position.z + pos.getZ(i);
    const h = pos.getY(i);
    const slope = nor.getY(i);
    const rgb = colorFor(h, slope, { x: wx, z: wz });
    col.setXYZ(i, rgb[0], rgb[1], rgb[2]);
  }
  col.needsUpdate = true;
}

function updateTerrain(px, pz) {
  const cx = Math.floor(px / CHUNK);
  const cz = Math.floor(pz / CHUNK);
  if (cx === chunkOrigin.x && cz === chunkOrigin.z) return;
  chunkOrigin.x = cx; chunkOrigin.z = cz;
  let i = 0;
  for (let gz = -HALF; gz <= HALF; gz++) {
    for (let gx = -HALF; gx <= HALF; gx++) {
      rebuildChunk(terrainGroup.children[i++], cx + gx, cz + gz);
    }
  }
  rebuildTrees(px, pz);
}

function buildTrees() {
  const geo = new THREE.ConeGeometry(3.2, 14, 6);
  geo.translate(0, 7, 0);
  const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1, vertexColors: false });
  treeMesh = new THREE.InstancedMesh(geo, mat, 1800);
  treeMesh.castShadow = true;
  treeMesh.receiveShadow = true;
  treeMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
  scene.add(treeMesh);
  const dummy = new THREE.Object3D();
  for (let i = 0; i < 1800; i++) {
    dummy.position.set(0, -9999, 0);
    dummy.updateMatrix();
    treeMesh.setMatrixAt(i, dummy.matrix);
    treeMesh.setColorAt(i, new THREE.Color(0x1d4a28));
  }
  treeMesh.count = 1800;
}

function rebuildTrees(px, pz) {
  const dummy = new THREE.Object3D();
  let n = 0;
  const minX = px - 2200, maxX = px + 2200, minZ = pz - 2200, maxZ = pz + 2200;
  const step = 42;
  const x0 = Math.floor(minX / step) * step;
  const z0 = Math.floor(minZ / step) * step;
  for (let z = z0; z < maxZ && n < 1800; z += step) {
    for (let x = x0; x < maxX && n < 1800; x += step) {
      const hsh = hash2(Math.floor(x / step), Math.floor(z / step));
      if (hsh > 0.18) continue;
      if (Math.abs(x) < 80 && z > -200 && z < 2000) continue;
      const h = terrainHeight(x, z);
      if (h < 4 || h > 240) continue;
      const slope = terrainNormal(x, z).y;
      if (slope < 0.82) continue;
      const s = 0.7 + hsh * 1.5;
      dummy.position.set(x, h, z);
      dummy.scale.set(s, 0.8 + hsh * 1.3, s);
      dummy.rotation.y = hsh * 6.28;
      dummy.updateMatrix();
      treeMesh.setMatrixAt(n, dummy.matrix);
      const g = 0.25 + hsh * 0.35;
      treeMesh.setColorAt(n, tmp.c.setRGB(0.08 + hsh * 0.08, g, 0.08));
      n++;
    }
  }
  for (let i = n; i < 1800; i++) {
    dummy.position.set(0, -9999, 0);
    dummy.scale.set(1, 1, 1);
    dummy.updateMatrix();
    treeMesh.setMatrixAt(i, dummy.matrix);
  }
  treeMesh.instanceMatrix.needsUpdate = true;
  if (treeMesh.instanceColor) treeMesh.instanceColor.needsUpdate = true;
}

function makeRunwayTexture() {
  const c = document.createElement("canvas");
  c.width = 256; c.height = 1024;
  const g = c.getContext("2d");
  g.fillStyle = "#2c3036";
  g.fillRect(0, 0, 256, 1024);
  g.strokeStyle = "#d8d4cc";
  g.lineWidth = 6;
  g.setLineDash([28, 22]);
  g.beginPath(); g.moveTo(128, 20); g.lineTo(128, 1004); g.stroke();
  g.setLineDash([]);
  g.lineWidth = 8;
  g.strokeRect(10, 8, 236, 1008);
  g.fillStyle = "#efeae2";
  g.font = "bold 84px sans-serif";
  g.save();
  g.translate(128, 180);
  g.fillText("36", -48, 20);
  g.restore();
  g.save();
  g.translate(128, 860);
  g.rotate(Math.PI);
  g.fillText("18", -48, 20);
  g.restore();
  for (let i = 0; i < 8; i++) {
    g.fillRect(70, 240 + i * 18, 116, 8);
  }
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.anisotropy = 8;
  return tex;
}

function buildAirport() {
  runwayGroup = new THREE.Group();
  const tex = makeRunwayTexture();
  const deck = new THREE.Mesh(
    new THREE.PlaneGeometry(40, 1600),
    new THREE.MeshStandardMaterial({ map: tex, roughness: 0.82, metalness: 0.05 })
  );
  deck.rotation.x = -Math.PI / 2;
  deck.position.set(0, 1.42, 800);
  deck.receiveShadow = true;
  runwayGroup.add(deck);
  const shoulder = new THREE.Mesh(
    new THREE.PlaneGeometry(90, 1680),
    new THREE.MeshStandardMaterial({ color: 0x6d6a60, roughness: 1 })
  );
  shoulder.rotation.x = -Math.PI / 2;
  shoulder.position.set(0, 1.28, 800);
  runwayGroup.add(shoulder);

  function box(w, h, d, x, y, z, color, rotY) {
    const m = new THREE.Mesh(
      new THREE.BoxGeometry(w, h, d),
      new THREE.MeshStandardMaterial({ color, roughness: 0.75, metalness: 0.15 })
    );
    m.position.set(x, y, z);
    m.castShadow = true; m.receiveShadow = true;
    if (rotY) m.rotation.y = rotY;
    runwayGroup.add(m);
    return m;
  }
  box(28, 8, 46, 78, 5.2, 280, 0x8d9298);
  box(18, 14, 18, 96, 8.2, 360, 0x6e7680);
  box(6, 22, 6, 108, 12, 360, 0xb7c0c8);
  const cab = new THREE.Mesh(
    new THREE.BoxGeometry(8, 4, 8),
    new THREE.MeshStandardMaterial({ color: 0x8ec7e6, roughness: 0.15, metalness: 0.4, transparent: true, opacity: 0.75 })
  );
  cab.position.set(108, 24.5, 360);
  runwayGroup.add(cab);
  box(40, 7, 22, -86, 4.6, 640, 0x9aa0a6);
  box(16, 5, 60, -70, 3.6, 820, 0x7c7568);

  for (let i = 0; i < 28; i++) {
    const z = 40 + i * 56;
    for (const side of [-1, 1]) {
      const l = new THREE.Mesh(
        new THREE.BoxGeometry(0.6, 0.4, 1.4),
        new THREE.MeshStandardMaterial({ color: 0xfff2c4, emissive: 0xffcc66, emissiveIntensity: 0.9 })
      );
      l.position.set(side * 22, 1.45, z);
      runwayGroup.add(l);
    }
  }
  papiMeshes = [];
  for (let i = 0; i < 4; i++) {
    const m = new THREE.Mesh(
      new THREE.BoxGeometry(1.1, 0.7, 1.1),
      new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xff2200, emissiveIntensity: 1.4 })
    );
    m.position.set(26 + i * 3.2, 2.1, 150);
    runwayGroup.add(m);
    papiMeshes.push(m);
  }
  scene.add(runwayGroup);

  clouds = new THREE.Group();
  const cmat = new THREE.MeshLambertMaterial({ color: 0xf4f7fb, transparent: true, opacity: 0.55, depthWrite: false });
  for (let i = 0; i < 18; i++) {
    const g = new THREE.Group();
    const n = 4 + (i % 4);
    for (let k = 0; k < n; k++) {
      const s = new THREE.Mesh(new THREE.SphereGeometry(40 + (k % 3) * 22, 10, 8), cmat);
      s.position.set((k - n / 2) * 36, (k % 2) * 8, (k % 3) * 12);
      s.scale.y = 0.45;
      g.add(s);
    }
    const ang = (i / 18) * Math.PI * 2;
    const rad = 1800 + (i % 5) * 700;
    g.position.set(Math.cos(ang) * rad, 900 + (i % 4) * 180, Math.sin(ang) * rad);
    clouds.add(g);
  }
  scene.add(clouds);
}
let papiMeshes = [];
let clouds = null;

/* ---------- aircraft meshes ---------- */
function metal(color, rough, met) {
  return new THREE.MeshStandardMaterial({ color, roughness: rough ?? 0.42, metalness: met ?? 0.62 });
}
function addExhaust(group, x, y, z, scale) {
  const flame = new THREE.Mesh(
    new THREE.ConeGeometry(0.28 * scale, 1.6 * scale, 8),
    new THREE.MeshBasicMaterial({ color: 0xff8844, transparent: true, opacity: 0.85, blending: THREE.AdditiveBlending, depthWrite: false })
  );
  flame.rotation.x = Math.PI / 2;
  flame.position.set(x, y, z);
  flame.userData.exhaust = true;
  flame.userData.base = 1.6 * scale;
  group.add(flame);
  const nozzle = new THREE.Mesh(new THREE.CylinderGeometry(0.32 * scale, 0.4 * scale, 0.5 * scale, 10), metal(0x222222, 0.5, 0.8));
  nozzle.rotation.x = Math.PI / 2;
  nozzle.position.set(x, y, z + 0.4 * scale);
  group.add(nozzle);
}

function buildAircraft(id) {
  const cfg = AIRCRAFT[id];
  const g = new THREE.Group();
  g.userData.id = id;
  const P = cfg.colors.primary, A = cfg.colors.accent, D = cfg.colors.dark;
  if (id === "falcon") {
    const fuse = new THREE.Mesh(new THREE.CapsuleGeometry(0.72, 6.2, 6, 12), metal(P, 0.38, 0.7));
    fuse.rotation.x = Math.PI / 2;
    fuse.position.set(0, 0.15, 0.2);
    fuse.castShadow = true;
    g.add(fuse);
    const nose = new THREE.Mesh(new THREE.ConeGeometry(0.55, 2.3, 14), metal(P, 0.35, 0.75));
    nose.rotation.x = Math.PI / 2;
    nose.position.set(0, 0.05, 4.5);
    nose.castShadow = true;
    g.add(nose);
    const canopy = new THREE.Mesh(new THREE.SphereGeometry(0.62, 16, 12), new THREE.MeshStandardMaterial({ color: cfg.colors.glass, roughness: 0.08, metalness: 0.2, transparent: true, opacity: 0.55 }));
    canopy.scale.set(0.85, 0.7, 1.35);
    canopy.position.set(0, 0.85, 1.5);
    g.add(canopy);
    const wing = new THREE.Mesh(new THREE.BoxGeometry(10.4, 0.12, 2.5), metal(D, 0.45, 0.55));
    wing.position.set(0, 0.02, -0.2);
    wing.castShadow = true;
    g.add(wing);
    const lwing = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.08, 1.3), metal(A, 0.4, 0.5));
    lwing.position.set(-4.6, 0.05, -0.7);
    lwing.rotation.y = 0.35;
    g.add(lwing);
    const rwing = lwing.clone(); rwing.position.x = 4.6; rwing.rotation.y = -0.35;
    g.add(rwing);
    const tail = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.8, 1.3), metal(D, 0.4, 0.6));
    tail.position.set(0, 1.15, -3.3);
    g.add(tail);
    const hstab = new THREE.Mesh(new THREE.BoxGeometry(3.6, 0.08, 1.0), metal(D, 0.45, 0.55));
    hstab.position.set(0, 0.35, -3.4);
    g.add(hstab);
    const intake = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.55, 1.4), metal(0x1a1c20, 0.5, 0.7));
    intake.position.set(0, -0.35, 0.6);
    g.add(intake);
    addExhaust(g, 0, 0.05, -3.7, 1.15);
    g.userData.wingL = new THREE.Vector3(-5.1, 0, -0.2);
    g.userData.wingR = new THREE.Vector3(5.1, 0, -0.2);
    g.userData.nose = 5.4;
  } else if (id === "hauler") {
    const fuse = new THREE.Mesh(new THREE.BoxGeometry(2.5, 2.6, 12), metal(P, 0.62, 0.25));
    fuse.position.set(0, 0.7, 0);
    fuse.castShadow = true;
    g.add(fuse);
    const nose = new THREE.Mesh(new THREE.BoxGeometry(2.3, 2.2, 2.2), metal(P, 0.6, 0.3));
    nose.position.set(0, 0.7, 6.6);
    g.add(nose);
    const radome = new THREE.Mesh(new THREE.SphereGeometry(0.9, 12, 10), metal(D, 0.5, 0.4));
    radome.scale.z = 1.4;
    radome.position.set(0, 0.85, 8.0);
    g.add(radome);
    const canopy = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.7, 1.8), new THREE.MeshStandardMaterial({ color: cfg.colors.glass, roughness: 0.1, metalness: 0.15, transparent: true, opacity: 0.6 }));
    canopy.position.set(0, 2.15, 5.4);
    g.add(canopy);
    const wing = new THREE.Mesh(new THREE.BoxGeometry(22, 0.28, 3.2), metal(A, 0.55, 0.3));
    wing.position.set(0, 2.0, 0.4);
    wing.castShadow = true;
    g.add(wing);
    const tail = new THREE.Mesh(new THREE.BoxGeometry(0.22, 4.2, 2.4), metal(D, 0.55, 0.35));
    tail.position.set(0, 3.3, -5.6);
    g.add(tail);
    const hstab = new THREE.Mesh(new THREE.BoxGeometry(8.5, 0.16, 1.6), metal(D, 0.5, 0.35));
    hstab.position.set(0, 5.2, -5.5);
    g.add(hstab);
    for (const side of [-1, 1]) {
      for (const z of [1.6, -1.2]) {
        const nac = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.62, 2.2, 10), metal(0x3a3530, 0.5, 0.55));
        nac.rotation.x = Math.PI / 2;
        nac.position.set(side * (3.2 + (z < 0 ? 0.4 : 0)), 1.55, z);
        g.add(nac);
        const prop = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.08, 0.22), metal(0x222222, 0.4, 0.7));
        prop.position.set(nac.position.x, nac.position.y, z + 1.15);
        prop.userData.prop = true;
        g.add(prop);
        const prop2 = prop.clone();
        prop2.rotation.z = Math.PI / 2;
        prop2.userData.prop = true;
        g.add(prop2);
      }
    }
    g.userData.wingL = new THREE.Vector3(-10.5, 2.0, 0.2);
    g.userData.wingR = new THREE.Vector3(10.5, 2.0, 0.2);
    g.userData.nose = 8.6;
    g.scale.setScalar(0.72);
  } else {
    const fuse = new THREE.Mesh(new THREE.CapsuleGeometry(0.55, 7.4, 5, 12), metal(P, 0.28, 0.82));
    fuse.rotation.x = Math.PI / 2;
    fuse.castShadow = true;
    g.add(fuse);
    const nose = new THREE.Mesh(new THREE.ConeGeometry(0.38, 2.6, 12), metal(P, 0.25, 0.85));
    nose.rotation.x = Math.PI / 2;
    nose.position.set(0, 0, 5.2);
    g.add(nose);
    const wing = new THREE.Mesh(new THREE.BoxGeometry(8.6, 0.1, 3.6), metal(D, 0.32, 0.7));
    wing.position.set(0, -0.05, -0.6);
    wing.castShadow = true;
    g.add(wing);
    const stripe = new THREE.Mesh(new THREE.BoxGeometry(8.7, 0.12, 0.28), metal(A, 0.35, 0.5));
    stripe.position.set(0, 0.02, -0.2);
    g.add(stripe);
    const canard = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.06, 0.55), metal(A, 0.3, 0.6));
    canard.position.set(0, 0.05, 2.5);
    g.add(canard);
    for (const side of [-1, 1]) {
      const fin = new THREE.Mesh(new THREE.BoxGeometry(0.08, 1.15, 1.15), metal(D, 0.3, 0.7));
      fin.position.set(side * 1.15, 0.7, -2.7);
      fin.rotation.z = side * -0.45;
      g.add(fin);
    }
    const canopy = new THREE.Mesh(new THREE.SphereGeometry(0.42, 14, 10), new THREE.MeshStandardMaterial({ color: cfg.colors.glass, roughness: 0.05, metalness: 0.3, transparent: true, opacity: 0.5 }));
    canopy.scale.set(0.8, 0.55, 1.5);
    canopy.position.set(0, 0.48, 2.0);
    g.add(canopy);
    addExhaust(g, -0.42, -0.05, -3.9, 0.85);
    addExhaust(g, 0.42, -0.05, -3.9, 0.85);
    g.userData.wingL = new THREE.Vector3(-4.2, 0, -1.2);
    g.userData.wingR = new THREE.Vector3(4.2, 0, -1.2);
    g.userData.nose = 6.4;
  }
  const navL = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), new THREE.MeshBasicMaterial({ color: 0xff3030 }));
  const navR = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), new THREE.MeshBasicMaterial({ color: 0x30ff60 }));
  navL.position.copy(g.userData.wingL);
  navR.position.copy(g.userData.wingR);
  g.add(navL, navR);
  g.traverse(o => { if (o.isMesh) o.castShadow = true; });
  return g;
}

let showcase = [];
function buildShowcase() {
  showcase.forEach(s => world.remove(s.mesh));
  showcase = [];
  const spots = [
    { id: "falcon", x: -26 },
    { id: "hauler", x: 8 },
    { id: "dart", x: 32 }
  ];
  for (const s of spots) {
    const mesh = buildAircraft(s.id);
    mesh.position.set(s.x, terrainHeight(s.x, 480) + AIRCRAFT[s.id].gearH * (s.id === "hauler" ? 0.72 : 1), 480);
    world.add(mesh);
    showcase.push({ id: s.id, mesh, baseX: s.x });
  }
}

/* ---------- particles ---------- */
const PCOUNT = 1400;
let particles, pPos, pCol, pSize, pAlpha, pLive;
function buildParticles() {
  pPos = new Float32Array(PCOUNT * 3);
  pCol = new Float32Array(PCOUNT * 3);
  pSize = new Float32Array(PCOUNT);
  pAlpha = new Float32Array(PCOUNT);
  pLive = [];
  for (let i = 0; i < PCOUNT; i++) {
    pPos[i * 3 + 1] = -9999;
    pLive.push(null);
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute("position", new THREE.BufferAttribute(pPos, 3));
  geo.setAttribute("color", new THREE.BufferAttribute(pCol, 3));
  geo.setAttribute("aSize", new THREE.BufferAttribute(pSize, 1));
  geo.setAttribute("aAlpha", new THREE.BufferAttribute(pAlpha, 1));
  const mat = new THREE.ShaderMaterial({
    transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, fog: false,
    vertexShader: `
      attribute float aSize; attribute float aAlpha; attribute vec3 color;
      varying vec3 vColor; varying float vAlpha;
      void main() {
        vColor = color; vAlpha = aAlpha;
        vec4 mv = modelViewMatrix * vec4(position, 1.0);
        gl_PointSize = aSize * (280.0 / max(1.0, -mv.z));
        gl_Position = projectionMatrix * mv;
      }`,
    fragmentShader: `
      varying vec3 vColor; varying float vAlpha;
      void main() {
        vec2 d = gl_PointCoord - 0.5;
        float r = length(d);
        if (r > 0.5) discard;
        float a = smoothstep(0.5, 0.05, r) * vAlpha;
        gl_FragColor = vec4(vColor, a);
      }`
  });
  particles = new THREE.Points(geo, mat);
  particles.frustumCulled = false;
  scene.add(particles);
}
function spawnParticle(pos, vel, life, color, size) {
  for (let i = 0; i < PCOUNT; i++) {
    if (pLive[i]) continue;
    pLive[i] = { vel: vel.clone(), life, max: life, size };
    pPos[i * 3] = pos.x; pPos[i * 3 + 1] = pos.y; pPos[i * 3 + 2] = pos.z;
    pCol[i * 3] = color.r; pCol[i * 3 + 1] = color.g; pCol[i * 3 + 2] = color.b;
    pSize[i] = size; pAlpha[i] = 1;
    return;
  }
}
function updateParticles(dt) {
  for (let i = 0; i < PCOUNT; i++) {
    const p = pLive[i];
    if (!p) continue;
    p.life -= dt;
    if (p.life <= 0) {
      pLive[i] = null;
      pPos[i * 3 + 1] = -9999;
      pAlpha[i] = 0;
      continue;
    }
    pPos[i * 3] += p.vel.x * dt;
    pPos[i * 3 + 1] += p.vel.y * dt;
    pPos[i * 3 + 2] += p.vel.z * dt;
    p.vel.y -= 4 * dt;
    pAlpha[i] = p.life / p.max;
    pSize[i] = p.size * (0.6 + 0.6 * (p.life / p.max));
  }
  particles.geometry.attributes.position.needsUpdate = true;
  particles.geometry.attributes.color.needsUpdate = true;
  particles.geometry.attributes.aSize.needsUpdate = true;
  particles.geometry.attributes.aAlpha.needsUpdate = true;
}
const COL = {
  white: new THREE.Color(1, 1, 1),
  orange: new THREE.Color(1, 0.45, 0.1),
  yellow: new THREE.Color(1, 0.85, 0.4),
  dust: new THREE.Color(0.7, 0.62, 0.45),
  cyan: new THREE.Color(0.5, 1, 0.8),
  red: new THREE.Color(1, 0.3, 0.2)
};
function burst(pos, color, n, speed, life, size) {
  for (let i = 0; i < n; i++) {
    const v = new THREE.Vector3(Math.random() - 0.5, Math.random() * 0.6, Math.random() - 0.5).normalize().multiplyScalar(speed * (0.4 + Math.random()));
    spawnParticle(pos, v, life * (0.5 + Math.random()), color, size * (0.6 + Math.random()));
  }
}

/* ---------- flight state ---------- */
const keys = {};
const mouse = { x: 0, y: 0, locked: false };
let ship = null;
let bandits = [];
let tracers = [];
let mode = "menu"; // menu | fly | pause | debrief
let camMode = 0; // 0 chase, 1 cockpit, 2 tower, 3 orbit
let sas = true;
let mission = null;
let clock = 0;
let shake = 0;
let lastCrash = 0;
const UP = new THREE.Vector3(0, 1, 0);
const FWD = new THREE.Vector3(0, 0, 1);
const RIGHT = new THREE.Vector3(1, 0, 0);

function makeShip(id, pos, heading) {
  const cfg = AIRCRAFT[id];
  const mesh = buildAircraft(id);
  world.add(mesh);
  const s = {
    id, cfg, mesh,
    pos: pos.clone(),
    vel: new THREE.Vector3(),
    quat: new THREE.Quaternion().setFromAxisAngle(UP, heading || 0),
    omega: new THREE.Vector3(), // body rates rad/s
    throttle: 0,
    rpm: 0.08,
    flaps: 0,
    gear: true,
    brake: false,
    ab: false,
    trim: 0,
    aoa: 0, aos: 0, aoaAbs: 0,
    airspeed: 0, tas: 0, ias: 0, mach: 0,
    g: 1, alt: pos.y, agl: 0, vs: 0,
    airborne: false, crashed: false, onGround: true,
    runwayHdg: 0,
    fuel: 1,
    heat: 0.2,
    groundedFor: 0,
    fireCd: 0,
    hp: 100,
    smokeT: 0,
    alive: true,
    lastContact: null
  };
  applyPose(s);
  return s;
}

function applyPose(s) {
  s.mesh.position.copy(s.pos);
  s.mesh.quaternion.copy(s.quat);
  s.mesh.traverse(o => {
    if (o.userData && o.userData.exhaust) {
      const k = s.ab ? 2.6 : (0.15 + s.throttle * 1.4);
      o.scale.set(0.6 + s.throttle * 0.6, k, 0.6 + s.throttle * 0.6);
      o.material.opacity = 0.25 + s.throttle * 0.7;
      o.material.color.set(s.ab ? 0x88ccff : 0xff7722);
    }
    if (o.userData && o.userData.prop) {
      o.rotation.z += s.rpm * 1.8;
    }
  });
}

function bodyAxis(s, local, out) {
  return out.copy(local).applyQuaternion(s.quat);
}

function integrateShip(s, dt, input, isPlayer) {
  if (!s.alive) {
    s.pos.y -= 2 * dt;
    return;
  }
  const cfg = s.cfg;
  dt = Math.min(dt, 0.033);
  const rho = airDensity(s.pos.y);
  const wind = s.onGround ? new THREE.Vector3() : windAt(s.pos.y);
  const vAir = tmp.v.copy(s.vel).sub(wind);
  const speed = vAir.length();
  s.airspeed = speed;
  s.tas = speed;
  s.ias = speed * Math.sqrt(rho / 1.225);
  s.mach = speed / speedOfSound(s.pos.y);
  s.vs = s.vel.y;
  s.alt = s.pos.y;

  const fwd = bodyAxis(s, FWD, tmp.v2);
  const right = bodyAxis(s, RIGHT, tmp.v3);
  const up = tmp.v.clone().crossVectors(right, fwd).normalize(); // body up? wait right x forward = up in RH if forward is +Z and right is +X? 
  // THREE.js: +X right, +Y up, +Z toward viewer by default... we treat local +Z as nose (forward).
  // right (1,0,0) × forward (0,0,1) = (0*1 - 0*0, 0*0 - 1*1, 1*0 - 0*0) = (0, -1, 0). That's down.
  // So body-up should be forward × right, or we store local up as (0,1,0).
  const bup = bodyAxis(s, UP, new THREE.Vector3());

  let aoa = 0, aos = 0;
  if (speed > 1.5) {
    const vn = vAir.clone().normalize();
    const along = vn.dot(fwd);
    const side = vn.dot(right);
    const vert = vn.dot(bup);
    aoa = Math.atan2(vert, Math.max(0.05, along)); // positive aoa = wind from below relative? 
    // If nose above velocity, velocity is below nose, vert component of vn on bup is negative if nose is pitched up... 
    // Standard: alpha = angle between chord (fwd) and relative wind. Relative wind is -vAir.
    // alpha positive (nose up) when -vAir has a component along -bup? Let's recompute carefully.
    aos = Math.atan2(side, Math.max(0.05, Math.abs(along)));
  }
  // Recompute AoA properly
  if (speed > 1.2) {
    const rel = vAir.clone().negate(); // direction air comes FROM? No: aerodynamic alpha uses velocity vector vs body x.
    // alpha = atan2(w_body, u_body) where velocity in body frame.
    const u = vAir.dot(fwd);
    const w = vAir.dot(bup); // positive w means velocity has upward component (aircraft descending relative to nose? )
    // If aircraft pitches nose up, velocity still forward, body-up rotates, vAir · bup becomes negative (velocity is "below" the nose).
    // Conventional alpha positive when nose is above the velocity vector, i.e. when vAir · bup < 0.
    aoa = Math.atan2(-w, Math.max(0.2, u));
    aos = Math.atan2(vAir.dot(right), Math.max(0.2, u));
  }
  s.aoa = aoa;
  s.aos = aos;
  s.aoaAbs = Math.abs(aoa);

  // controls
  let pitchIn = 0, rollIn = 0, yawIn = 0;
  if (isPlayer) {
    const sens = 1;
    // +pitchIn = stick forward = nose down; +rollIn = stick right
    pitchIn = clamp((mouse.y * 1.15) + (keys.ArrowDown ? 1 : 0) - (keys.ArrowUp ? 1 : 0), -1, 1);
    rollIn = clamp((mouse.x * 1.15) + (keys.ArrowRight ? 1 : 0) - (keys.ArrowLeft ? 1 : 0) + (keys.KeyE ? 1 : 0) - (keys.KeyQ ? 1 : 0), -1, 1);
    yawIn = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
    if (keys.KeyW) s.throttle = clamp(s.throttle + dt * 0.35, 0, 1);
    if (keys.KeyS) s.throttle = clamp(s.throttle - dt * 0.35, 0, 1);
    s.ab = !!(cfg.afterburner && (keys.ShiftLeft || keys.ShiftRight) && s.throttle > 0.85);
  } else if (input) {
    pitchIn = input.pitch; rollIn = input.roll; yawIn = input.yaw;
    s.throttle = input.throttle;
    s.ab = !!(cfg.afterburner && input.ab);
    s.flaps = input.flaps || 0;
    s.gear = input.gear != null ? input.gear : s.gear;
  }
  if (isPlayer) {
    // gear/flaps handled by key events
  }

  // RPM lags throttle (engine spool)
  const targetRpm = 0.12 + s.throttle * 0.78 + (s.ab ? 0.12 : 0);
  const spool = s.throttle > s.rpm ? 0.85 : 0.45;
  s.rpm += (targetRpm - s.rpm) * (1 - Math.exp(-spool * dt * 2.4));
  s.heat += ((s.ab ? 1.3 : s.throttle) - s.heat) * dt * 0.35;
  s.heat = clamp(s.heat, 0, 1.4);
  if (s.ab) s.fuel = Math.max(0, s.fuel - dt * 0.012);
  else s.fuel = Math.max(0, s.fuel - dt * (0.0012 + s.throttle * 0.004));
  if (s.fuel <= 0) { s.throttle = 0; s.ab = 0; }

  // thrust along nose, density lapse
  const lapse = Math.pow(rho / 1.225, cfg.altPower);
  let thrust = cfg.thrust * s.rpm * lapse;
  if (s.ab) thrust *= cfg.abBoost;
  if (cfg.kind === "turboprop") {
    // static thrust exists; efficiency rises as the prop bites
    thrust *= 0.62 + 0.38 * clamp(speed / 80, 0, 1);
  } else {
    thrust *= 1 - clamp((s.mach - 0.9) * 0.35, 0, 0.22);
  }
  const thrustForce = fwd.clone().multiplyScalar(thrust * (s.fuel > 0 ? 1 : 0));

  // aero forces in stability axes
  const q = 0.5 * rho * speed * speed;
  const flaps = s.flaps;
  const cla = cfg.clAlpha;
  const stall = cfg.stallAoA * (1 + 0.12 * flaps);
  let cl = cla * aoa;
  const absA = Math.abs(aoa);
  if (absA > stall) {
    const over = absA - stall;
    const sign = Math.sign(aoa) || 1;
    const peak = cla * stall;
    cl = sign * peak * Math.max(0.35, 1 - over * 2.4);
  }
  cl += 0.35 * flaps;
  cl = clamp(cl, -cfg.clMax * 1.1, cfg.clMax * (1 + 0.45 * flaps));
  const AR = (cfg.span * cfg.span) / cfg.wingArea;
  const clClamped = cl;
  const cdi = (clClamped * clClamped) / (Math.PI * AR * cfg.oswald);
  let cd = cfg.cd0 * (1 + 0.35 * flaps + (s.gear && s.airborne ? 0.22 : 0) + (s.brake ? 0.55 : 0)) + cdi;
  // wave drag
  if (s.mach > 0.85) cd += 0.08 * Math.pow(s.mach - 0.85, 2) * 8;
  const cy = cfg.cyBeta * aos;

  // lift direction: perpendicular to velocity, in the plane of bup and velocity, roughly "up" from velocity
  let liftDir = new THREE.Vector3();
  let dragDir = new THREE.Vector3();
  let sideDir = new THREE.Vector3();
  if (speed > 1.2) {
    dragDir.copy(vAir).normalize().negate();
    // lift is perpendicular to vAir, biased toward body up
    liftDir.crossVectors(vAir, right);
    if (liftDir.lengthSq() < 1e-6) liftDir.copy(bup);
    liftDir.normalize();
    // ensure liftDir points generally with body up
    if (liftDir.dot(bup) < 0) liftDir.negate();
    sideDir.crossVectors(fwd, bup).normalize(); // should be ~ right
    // side force opposes sideslip: if aos positive (wind from right, vAir·right > 0), weathervane side force toward -right? 
    // cyBeta is negative. Force = cy * q * S * sideDir, with sideDir = right. So negative cy pushes left when aos positive. Good.
  } else {
    dragDir.set(0, 0, 0);
    liftDir.copy(bup);
    sideDir.copy(right);
  }
  const lift = q * cfg.wingArea * cl;
  const drag = q * cfg.wingArea * cd;
  const side = q * cfg.wingArea * cy;
  const liftForce = liftDir.multiplyScalar(lift);
  const dragForce = dragDir.multiplyScalar(drag);
  const sideForce = sideDir.multiplyScalar(side);

  // gravity
  const grav = new THREE.Vector3(0, -9.806 * cfg.mass, 0);
  const force = new THREE.Vector3().add(thrustForce).add(liftForce).add(dragForce).add(sideForce).add(grav);

  // ground interaction
  const gh = terrainHeight(s.pos.x, s.pos.z);
  const gearClear = cfg.gearH * (s.gear ? 1 : 0.35) * (s.id === "hauler" ? 0.72 : 1);
  const groundY = gh + gearClear;
  s.agl = s.pos.y - gh;
  const wasGround = s.onGround;
  const contact = s.groundedFor < 90 && ((s.pos.y <= groundY + 1.5 && s.vel.y < 6) || (s.groundedFor < 1.2 && s.vel.y < 8 && s.agl < 6));
  if (contact) {
    s.groundedFor += dt;
    const nrm = terrainNormal(s.pos.x, s.pos.z);
    const pen = groundY - s.pos.y;
    if (pen > 0) s.pos.y = groundY;
    const vn = s.vel.dot(nrm);
    if (vn < 0) {
      const impact = -vn;
      s.vel.addScaledVector(nrm, -vn * (impact > 6 ? 0.15 : 1.0));
      if (impact > 9 && isPlayer) {
        crash(s, "HARD IMPACT");
      } else if (impact > 3.5 && isPlayer) {
        shake = Math.max(shake, impact * 0.04);
        noiseBurst(0.25, 0.35, 120);
        mission && mission.onHard && mission.onHard(impact);
      }
    }
    // friction
    const tangent = s.vel.clone().addScaledVector(nrm, -s.vel.dot(nrm));
    const brakeF = s.brake ? 0.85 : (s.throttle < 0.15 ? 0.08 : 0.02);
    const mu = s.gear ? brakeF : 0.35;
    tangent.multiplyScalar(Math.max(0, 1 - mu * 8 * dt));
    // rolling resistance + wheel steering while slow
    s.vel.copy(tangent).addScaledVector(nrm, Math.max(0, s.vel.dot(nrm)));
    if (!s.airborne || s.agl < gearClear + 1.2) {
      if (speed < 70 && isPlayer) {
        const steer = yawIn * dt * (2.2 + s.throttle);
        s.quat.multiply(new THREE.Quaternion().setFromAxisAngle(UP, -steer));
      }
      // nosewheel tracks the runway heading; rudder authority grows with speed
      if (speed > 8) {
        const hdg = headingOf(s);
        const want = hdg + (-yawIn) * 0.012;
        const back = want * Math.exp(-dt * (speed < 40 ? 6 : 1.5));
        if (Math.abs(yawIn) < 0.05) {
          s.quat.copy(attitudeQuat(back, pitchAngle(s), 0));
        }
      }
    }
    // normal force cancels gravity component
    const nForce = Math.max(0, -grav.dot(nrm) - force.dot(nrm));
    force.addScaledVector(nrm, nForce * 0.0); // already projected velocity; positional constraint handles it
    s.onGround = s.groundedFor < 90;
    s.airborne = !s.onGround;
    s._vs = stallSpeed(cfg, flaps);
  } else {
    s.onGround = false;
    s.airborne = true;
    s.groundedFor = 99;
  }

  // integrate translation
  const acc = force.multiplyScalar(1 / cfg.mass);
  s.vel.addScaledVector(acc, dt);
  s.pos.addScaledVector(s.vel, dt);

  // moments — body frame
  const qDyn = q;
  const b = cfg.span;
  const cbar = cfg.wingArea / cfg.span;
  const iasF = rho * speed; // dynamic pressure already in qDyn; moment ~ q * S * c
  const elev = pitchIn + s.trim;
  const ail = rollIn;
  const rud = yawIn;
  // stability: restoring moment proportional to aoa, aos, and rate damping
  const Cm = -cfg.stab.x * aoa * 0.000015 + cfg.ctrl.x * elev * 0.000012;
  const Cl = -cfg.stab.z * 0 * 0 + cfg.ctrl.z * ail * 0.00001 - cfg.stab.z * (s.omega.z) * 0;
  // Use direct torque model (N·m), scaled by dynamic pressure so controls fade at low speed and bite at high speed
  const qScale = clamp(qDyn / 4000, 0.08, 6);
  const rate = s.omega;
  // +Mx (about +X right) pitches nose down; +My (about +Y) yaws left; +Mz (about +Z) rolls left
  let Mx = cfg.ctrl.x * elev * qScale - cfg.stab.x * aoa * qScale - cfg.damp.x * rate.x * (0.4 + qScale);
  let My = -cfg.ctrl.y * rud * qScale - cfg.stab.y * aos * qScale - cfg.damp.y * rate.y * (0.4 + qScale);
  let Mz = -cfg.ctrl.z * ail * qScale - cfg.damp.z * rate.z * (0.35 + qScale);
  // dihedral: positive sideslip (wind from right) rolls right wing down (positive Mz is roll left, so negative)
  Mz += -cfg.stab.z * aos * qScale * 0.35;
  // SAS: extra damping and attitude hold when stick near center
  if (isPlayer && sas && s.airborne) {
    Mx += -rate.x * cfg.inertia.x * 1.6;
    Mz += -rate.z * cfg.inertia.z * 2.2;
    if (Math.abs(pitchIn) < 0.08) {
      // hold pitch attitude loosely via extra damping only
    }
    if (Math.abs(rollIn) < 0.08) {
      // wings level tendency
      const roll = rollAngle(s);
      Mz += -roll * cfg.inertia.z * 1.4;
    }
  }
  // gear holds attitude until dynamic pressure gives the elevator authority to rotate
  if (s.onGround) {
    const hold = pitchAngle(s);
    const rotateSpd = stallSpeed(cfg, flaps) * 0.9;
    const auth = clamp((speed - rotateSpd * 0.55) / Math.max(12, rotateSpd * 0.5), 0, 1);
    // below rotation speed the gear pins the attitude; above it the stick can raise the nose
    const pin = (1 - auth);
    Mx += -hold * cfg.inertia.x * 6 * pin;
    Mx += cfg.ctrl.x * Math.min(0, elev) * qScale * 0.8 * pin; // nose-down still bites (don't wheelie early)
    My *= 0.35;
    Mz *= 0.15;
    rate.z *= Math.exp(-6 * dt);
    if (auth < 0.35) rate.x *= Math.exp(-8 * dt * pin);
  }
  // structural limit — departure if overloaded
  const gLoad = acc.dot(bup) / 9.806 + 1; // rough, overwritten below
  s.g = (lift / (cfg.mass * 9.806)) * Math.sign(cl || 1) + (s.onGround ? 1 : 0) * 0;
  if (!s.onGround) s.g = liftForce.length() / (cfg.mass * 9.806) * (cl >= 0 ? 1 : -1);
  else s.g = 1;

  rate.x += (Mx / cfg.inertia.x) * dt;
  rate.y += (My / cfg.inertia.y) * dt;
  rate.z += (Mz / cfg.inertia.z) * dt;
  // clamp rates
  rate.x = clamp(rate.x, -2.4, 2.4);
  rate.y = clamp(rate.y, -1.2, 1.2);
  rate.z = clamp(rate.z, -3.2, 3.2);

  // integrate quaternion from body rates. Body axes: x pitch? 
  // Our torque Mx is pitch (about body right / X), My yaw (about body up / Y), Mz roll (about body forward / Z).
  const wBody = new THREE.Vector3(rate.x, rate.y, rate.z);
  const wWorld = wBody.clone().applyQuaternion(s.quat);
  const wq = new THREE.Quaternion(wWorld.x, wWorld.y, wWorld.z, 0);
  const dq = wq.multiply(s.quat);
  s.quat.x += dq.x * dt * 0.5;
  s.quat.y += dq.y * dt * 0.5;
  s.quat.z += dq.z * dt * 0.5;
  s.quat.w += dq.w * dt * 0.5;
  s.quat.normalize();

  // keep from tunneling into terrain
  const gh2 = terrainHeight(s.pos.x, s.pos.z);
  if (s.pos.y < gh2 + 0.4) {
    if (s.vel.length() > 30 && !s.onGround) crash(s, "TERRAIN");
    s.pos.y = gh2 + 0.4;
    if (s.vel.y < 0) s.vel.y = 0;
  }

  // VNE overspeed stress
  if (s.ias > cfg.vne && isPlayer) {
    s.heat = Math.min(1.4, s.heat + dt * 0.4);
    shake = Math.max(shake, (s.ias - cfg.vne) * 0.01);
    if (s.ias > cfg.vne * 1.12) crash(s, "OVERSPEED");
  }
  if (Math.abs(s.g) > cfg.maxG * 1.15 && s.airborne && isPlayer) {
    shake = Math.max(shake, 0.4);
    if (Math.abs(s.g) > cfg.maxG * 1.45) crash(s, "OVERSTRESS");
  }

  if (s.onGround && speed > (s._vs || 40) * 1.02 && pitchAngle(s) > 0.14 && s.groundedFor > 0.5) {
    s.onGround = false;
    s.airborne = true;
    s.groundedFor = 99;
  }
  if (s.onGround) {
    const vr = stallSpeed(cfg, flaps) * 0.92;
    const maxP = lerp(0.012, 0.28, clamp((speed - vr * 0.72) / (vr * 0.28), 0, 1));
    const pit = clamp(pitchAngle(s), -0.03, maxP);
    let hdg = s.runwayHdg || 0;
    if (Math.abs(yawIn) > 0.2 && speed < 55) hdg = headingOf(s);
    s.runwayHdg = hdg;
    s.quat.copy(attitudeQuat(hdg, pit, 0));
    s.omega.x *= 0.2;
    s.omega.z *= 0.05;
  }
  applyPose(s);

  // vapor / exhaust particles
  if (isPlayer || s.hp < 100) {
    s.smokeT -= dt;
    if (s.smokeT <= 0 && s.throttle > 0.3) {
      s.smokeT = 0.03;
      const back = fwd.clone().multiplyScalar(-6);
      const p = s.pos.clone().add(back);
      const vel = s.vel.clone().addScaledVector(fwd, -8).add(new THREE.Vector3((Math.random()-0.5)*2, (Math.random()-0.5), (Math.random()-0.5)*2));
      spawnParticle(p, vel, 0.6, s.ab ? COL.cyan : COL.orange, s.ab ? 8 : 4);
    }
    if (s.airborne && Math.abs(aoa) > stall * 0.75 && speed > 40 && s.smokeT < 0.02) {
      const p = s.pos.clone().add(bup.clone().multiplyScalar(-0.2));
      spawnParticle(p, s.vel.clone().add(new THREE.Vector3((Math.random()-0.5)*6, 2, (Math.random()-0.5)*6)), 0.8, COL.white, 10);
    }
  }
  s._fwd = fwd;
  s._bup = bup;
  s._right = right;
  s._lift = lift;
  s._q = qDyn;
  s._stall = stall;
}

function attitudeQuat(heading, pitch, roll) {
  const cp = Math.cos(pitch), sp = Math.sin(pitch);
  const f = new THREE.Vector3(Math.sin(heading) * cp, sp, Math.cos(heading) * cp);
  const up = new THREE.Vector3(0, 1, 0);
  const r = new THREE.Vector3().crossVectors(up, f);
  if (r.lengthSq() < 1e-6) r.set(1, 0, 0);
  r.normalize();
  if (roll) r.applyAxisAngle(f, roll);
  const u = new THREE.Vector3().crossVectors(f, r).normalize();
  return new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(r, u, f));
}
function rollAngle(s) {
  const bup = new THREE.Vector3(0, 1, 0).applyQuaternion(s.quat);
  const right = new THREE.Vector3(1, 0, 0).applyQuaternion(s.quat);
  // roll = angle of right axis vs horizon
  const proj = right.clone();
  proj.y = 0;
  if (proj.lengthSq() < 1e-6) return bup.y < 0 ? Math.PI : 0;
  proj.normalize();
  const sign = Math.sign(right.y || 1);
  return Math.atan2(right.y, proj.dot(new THREE.Vector3(1, 0, 0).applyQuaternion(s.quat).setY(0).normalize() || new THREE.Vector3(1,0,0)));
}
function pitchAngle(s) {
  const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(s.quat);
  return Math.asin(clamp(fwd.y, -1, 1));
}
function headingOf(s) {
  const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(s.quat);
  return Math.atan2(fwd.x, fwd.z);
}

function crash(s, reason) {
  if (s.crashed) return;
  s.crashed = true;
  s.alive = false;
  shake = 1.2;
  noiseBurst(0.6, 0.7, 90);
  burst(s.pos, COL.orange, 40, 18, 1.2, 14);
  burst(s.pos, COL.red, 20, 12, 0.8, 8);
  if (s === ship) {
    setTimeout(() => endMission(false, reason), 700);
  }
}

/* ---------- bandits ---------- */
function spawnBandits() {
  bandits.forEach(b => world.remove(b.mesh));
  bandits = [];
  const types = ["falcon", "dart", "falcon"];
  for (let i = 0; i < 3; i++) {
    const ang = (i / 3) * Math.PI * 2 + 0.4;
    const r = 900 + i * 280;
    const p = new THREE.Vector3(Math.sin(ang) * r, 420 + i * 80, 800 + Math.cos(ang) * r);
    const b = makeShip(types[i], p, ang + Math.PI);
    b.throttle = 0.7;
    b.vel.copy(new THREE.Vector3(0, 0, 1).applyQuaternion(b.quat)).multiplyScalar(140);
    b.airborne = true;
    b.gear = false;
    b.hp = 100;
    b.role = i === 0 ? "lead" : "wing";
    b.aiT = i * 0.7;
    bandits.push(b);
  }
}

function updateBandit(b, dt) {
  if (!b.alive) {
    b.pos.addScaledVector(b.vel, dt);
    b.vel.y -= 12 * dt;
    b.quat.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), dt * 0.8));
    const gh = terrainHeight(b.pos.x, b.pos.z);
    if (b.pos.y < gh + 1) {
      b.pos.y = gh + 1;
      b.vel.multiplyScalar(0.2);
      if (!b._boom) { b._boom = true; burst(b.pos, COL.orange, 30, 16, 1, 12); noiseBurst(0.4, 0.4, 100); }
    }
    applyPose(b);
    return;
  }
  b.aiT += dt;
  const toPlayer = ship.pos.clone().sub(b.pos);
  const dist = toPlayer.length();
  const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(b.quat);
  const desired = toPlayer.clone().normalize();
  // lead pursuit
  const lead = ship.vel.clone().multiplyScalar(clamp(dist / 280, 0.2, 2.2));
  desired.copy(ship.pos).add(lead).sub(b.pos).normalize();
  // if too close and not facing, break
  const facing = fwd.dot(toPlayer.clone().normalize());
  let pitch = 0, roll = 0, yaw = 0;
  const bup = new THREE.Vector3(0, 1, 0).applyQuaternion(b.quat);
  const right = new THREE.Vector3(1, 0, 0).applyQuaternion(b.quat);
  const errY = desired.dot(bup);
  const errX = desired.dot(right);
  pitch = clamp(-errY * 2.2, -1, 1);
  roll = clamp(errX * 2.4, -1, 1);
  yaw = clamp(errX * 0.4, -1, 1);
  // energy fight: if slow, unload and go for speed
  let thr = 0.82;
  let ab = b.cfg.afterburner && (dist > 600 || b.airspeed < 160);
  if (b.airspeed < 90) { pitch = clamp(pitch, -0.2, 0.3); thr = 1; ab = true; }
  if (b.pos.y < 180) pitch = Math.min(pitch, -0.15);
  if (b.pos.y > 2800) pitch = Math.max(pitch, 0.3);
  // shoot
  if (facing > 0.92 && dist < 700 && dist > 40) {
    fire(b, false);
  }
  integrateShip(b, dt, { pitch, roll, yaw, throttle: thr, ab, flaps: 0, gear: false }, false);
  if (b.hp <= 0) {
    b.alive = false;
    burst(b.pos, COL.orange, 24, 14, 1, 10);
    noiseBurst(0.35, 0.45, 140);
    mission && mission.onKill && mission.onKill();
  }
}

/* ---------- weapons ---------- */
function fire(s, isPlayer) {
  if (s.fireCd > 0 || !s.alive) return;
  s.fireCd = isPlayer ? 0.09 : 0.18;
  const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(s.quat);
  const right = new THREE.Vector3(1, 0, 0).applyQuaternion(s.quat);
  const muzzle = s.pos.clone().add(fwd.clone().multiplyScalar(6)).add(right.clone().multiplyScalar((Math.random() > 0.5 ? 1 : -1) * 1.2));
  const spread = isPlayer ? 0.008 : 0.02;
  const dir = fwd.clone().add(new THREE.Vector3((Math.random()-0.5)*spread, (Math.random()-0.5)*spread, (Math.random()-0.5)*spread)).normalize();
  tracers.push({
    pos: muzzle,
    vel: s.vel.clone().addScaledVector(dir, 920),
    life: 1.3,
    owner: s,
    player: isPlayer
  });
  if (isPlayer) blip(180 + Math.random() * 40, 0.05, "square", 0.05);
  spawnParticle(muzzle, dir.clone().multiplyScalar(20), 0.15, COL.yellow, 6);
}
function updateTracers(dt) {
  for (let i = tracers.length - 1; i >= 0; i--) {
    const t = tracers[i];
    t.life -= dt;
    t.vel.y -= 3 * dt;
    t.pos.addScaledVector(t.vel, dt);
    spawnParticle(t.pos, new THREE.Vector3(), 0.12, t.player ? COL.yellow : COL.red, 3.5);
    if (t.life <= 0 || t.pos.y < terrainHeight(t.pos.x, t.pos.z)) {
      tracers.splice(i, 1);
      continue;
    }
    const targets = t.player ? bandits : [ship];
    for (const o of targets) {
      if (!o || !o.alive) continue;
      if (o.pos.distanceTo(t.pos) < (o.id === "hauler" ? 8 : 5)) {
        o.hp -= t.player ? 12 : 6;
        burst(t.pos, COL.orange, 6, 8, 0.3, 5);
        blip(90, 0.08, "sawtooth", 0.06);
        tracers.splice(i, 1);
        if (o === ship && o.hp <= 0) crash(ship, "SHOT DOWN");
        break;
      }
    }
  }
}

/* ---------- missions ---------- */
function startMission() {
  initAudio();
  if (audio.ctx && audio.ctx.state === "suspended") audio.ctx.resume();
  bandits.forEach(b => world.remove(b.mesh));
  bandits = [];
  tracers = [];
  if (ship) world.remove(ship.mesh);
  showcase.forEach(s => { s.mesh.visible = false; });
  const id = selectedCraft;
  const cfg = AIRCRAFT[id];
  if (selectedScenario === "pattern") {
    const p = new THREE.Vector3(0, terrainHeight(0, 120) + cfg.gearH * (id === "hauler" ? 0.75 : 1) + 0.2, 120);
    ship = makeShip(id, p, 0);
    ship.gear = true;
    ship.throttle = 0;
    ship.runwayHdg = 0;
    ship.onGround = true;
    ship.airborne = false;
    mission = {
      type: "pattern",
      phase: "takeoff",
      t: 0,
      rotated: false,
      maxAgl: 0,
      touchV: 0,
      touchX: 0,
      centerline: 0,
      bounced: false,
      airborneTime: 0,
      score: 0,
      notes: [],
      onHard(impact) {
        this.notes.push("Hard contact " + impact.toFixed(1) + " m/s");
        this.bounced = true;
      }
    };
  } else {
    const p = new THREE.Vector3(-200, 520, -100);
    ship = makeShip(id, p, 0.4);
    ship.gear = false;
    ship.throttle = 0.75;
    ship.rpm = 0.8;
    ship.vel.set(40, 0, 180);
    ship.airborne = true;
    ship.onGround = false;
    ship.groundedFor = 99;
    ship.hp = 100;
    spawnBandits();
    mission = {
      type: "dogfight",
      t: 0,
      kills: 0,
      hits: 0,
      startHp: 100,
      onKill() { this.kills++; blip(520, 0.12, "sine", 0.08); }
    };
  }
  camMode = 0;
  sas = true;
  mode = "fly";
  clock = 0;
  $("topbar").style.display = "none";
  $("sheet").style.display = "none";
  $("debrief").style.display = "none";
  $("pause").style.display = "none";
  $("help").style.display = "block";
  updateTerrain(ship.pos.x, ship.pos.z);
}

function endMission(success, reason) {
  if (mode === "debrief") return;
  mode = "debrief";
  const grades = [];
  let title = success ? "MISSION COMPLETE" : "MISSION FAILED";
  let copy = reason || "";
  if (mission && mission.type === "pattern") {
    const vs = Math.abs(mission.touchV || ship.vel.y);
    const cx = Math.abs(mission.touchX ?? 99);
    const cl = mission.centerline ?? 99;
    grades.push(["TOUCHDOWN VS", (mission.touchV ? mission.touchV.toFixed(1) : "—") + " m/s"]);
    grades.push(["CENTERLINE", (cl < 90 ? cl.toFixed(1) : "—") + " m"]);
    grades.push(["THRESHOLD", (mission.touchZ != null ? Math.round(mission.touchZ) : "—") + " m"]);
    grades.push(["AIR TIME", mission.airborneTime.toFixed(0) + " s"]);
    if (!success && !reason) copy = "Sortie ended.";
    if (success) {
      title = "GREASED IT";
      copy = "Pattern flown. Review the numbers — a transport and a dart do not share a landing." + (mission.notes.length ? " " + mission.notes.join(". ") : "");
    }
  } else if (mission && mission.type === "dogfight") {
    grades.push(["KILLS", (mission.kills || 0) + " / 3"]);
    grades.push(["HULL", Math.max(0, Math.round(ship.hp)) + "%"]);
    grades.push(["FUEL", Math.round(ship.fuel * 100) + "%"]);
    grades.push(["TIME", mission.t.toFixed(0) + " s"]);
    if (success) copy = "Sky clear. Energy management decided it.";
    else copy = reason || "You departed the fight.";
  }
  $("db-title").textContent = title;
  $("db-copy").textContent = copy;
  $("db-grades").innerHTML = grades.map(g => `<div><label>${g[0]}</label><strong>${g[1]}</strong></div>`).join("");
  $("debrief").style.display = "flex";
  $("help").style.display = "none";
}

function updateMission(dt) {
  if (!mission || !ship.alive) return;
  mission.t += dt;
  if (mission.type === "pattern") {
    if (ship.airborne) {
      mission.airborneTime += dt;
      mission.maxAgl = Math.max(mission.maxAgl, ship.agl);
      if (!mission.rotated && ship.agl > 15) mission.rotated = true;
      mission.phase = ship.pos.z < 1600 ? "climb" : (ship.pos.x > 400 || headingOf(ship) < -0.4 ? "downwind" : "circuit");
    }
    // landing detection: was airborne, now on ground, past runway midpoint-ish or anywhere on runway
    if (mission.airborneTime > 8 && ship.onGround && ship.airspeed < 40 && ship.agl < AIRCRAFT[ship.id].gearH + 3) {
      if (mission.touchZ == null) {
        mission.touchZ = ship.pos.z;
        mission.touchX = ship.pos.x;
        mission.touchV = Math.abs(ship.vs);
        mission.centerline = Math.abs(ship.pos.x);
      }
      if (ship.airspeed < 12) {
        const onRwy = Math.abs(ship.pos.x) < 30 && ship.pos.z > 40 && ship.pos.z < 1700;
        const gentle = mission.touchV < 4.2;
        const centered = mission.centerline < 14;
        endMission(onRwy && gentle, onRwy ? (gentle ? "" : "Firm arrival — sink rate high for this weight.") : "Off the runway.");
      }
    }
    if (mission.t > 420) endMission(false, "Fuel and patience expired.");
  } else {
    const alive = bandits.filter(b => b.alive).length;
    if (mission.kills >= 3 || alive === 0) endMission(true, "");
    if (mission.t > 360) endMission(false, "Bandits disengaged. Time.");
    if (ship.fuel <= 0 && ship.airspeed < 40) endMission(false, "Fuel starvation.");
  }
}

/* ---------- camera ---------- */
const camPos = new THREE.Vector3();
const camLook = new THREE.Vector3();
let orbitA = 0.6;
function updateCamera(dt) {
  if (!ship) return;
  const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(ship.quat);
  const bup = new THREE.Vector3(0, 1, 0).applyQuaternion(ship.quat);
  const right = new THREE.Vector3(1, 0, 0).applyQuaternion(ship.quat);
  let desired = new THREE.Vector3();
  let look = ship.pos.clone().add(fwd.clone().multiplyScalar(12));
  const chaseDist = ship.id === "hauler" ? 38 : 22;
  const chaseUp = ship.id === "hauler" ? 10 : 6;
  if (camMode === 0) {
    desired.copy(ship.pos).addScaledVector(fwd, -chaseDist).addScaledVector(bup, chaseUp).add(new THREE.Vector3(0, 2, 0));
  } else if (camMode === 1) {
    desired.copy(ship.pos).addScaledVector(fwd, ship.id === "hauler" ? 4.2 : 2.4).addScaledVector(bup, ship.id === "hauler" ? 1.7 : 0.85);
    look.copy(ship.pos).addScaledVector(fwd, 80).addScaledVector(bup, -1.5);
  } else if (camMode === 2) {
    desired.set(90, terrainHeight(90, 40) + 28, 40);
    look.copy(ship.pos);
  } else {
    orbitA += dt * 0.15;
    desired.copy(ship.pos).add(new THREE.Vector3(Math.sin(orbitA) * 40, 14, Math.cos(orbitA) * 40));
    look.copy(ship.pos);
  }
  const k = 1 - Math.exp(-dt * (camMode === 1 ? 18 : 4.5));
  camPos.lerp(desired, camMode === 2 ? 1 : k);
  camLook.lerp(look, camMode === 2 ? 1 : k);
  // collision pull-in
  const gh = terrainHeight(camPos.x, camPos.z);
  if (camPos.y < gh + 2) camPos.y = gh + 2;
  const sh = shake;
  camera.position.copy(camPos);
  if (sh > 0) camera.position.add(new THREE.Vector3((Math.random()-0.5)*sh*2, (Math.random()-0.5)*sh*2, (Math.random()-0.5)*sh));
  camera.lookAt(camLook);
  camera.fov = camMode === 1 ? 70 : 62;
  camera.updateProjectionMatrix();
  sun.position.set(ship.pos.x - 900, ship.pos.y + 1400, ship.pos.z + 600);
  sun.target.position.copy(ship.pos);
  skyMesh.position.copy(ship.pos);
  waterMesh.position.x = ship.pos.x;
  waterMesh.position.z = ship.pos.z;
}

/* ---------- HUD ---------- */
const hud = $("hud");
function resizeHud() {
  const dpr = Math.min(devicePixelRatio || 1, 1.6);
  hud.width = Math.floor(window.innerWidth * dpr);
  hud.height = Math.floor(window.innerHeight * dpr);
  hud.style.width = "100%";
  hud.style.height = "100%";
}
resizeHud();
window.addEventListener("resize", resizeHud);

function drawHUD() {
  const ctx = hud.getContext("2d");
  const w = hud.width, h = hud.height;
  ctx.clearRect(0, 0, w, h);
  if (mode !== "fly" && mode !== "pause") return;
  if (!ship) return;
  const dpr = w / window.innerWidth;
  ctx.save();
  ctx.scale(dpr, dpr);
  const W = window.innerWidth, H = window.innerHeight;
  ctx.strokeStyle = "rgba(157,255,200,0.85)";
  ctx.fillStyle = "rgba(157,255,200,0.9)";
  ctx.lineWidth = 1.4;
  ctx.font = "13px Share Tech Mono, monospace";
  const cx = W / 2, cy = H / 2 + 10;

  // pitch ladder
  const pitch = pitchAngle(ship);
  const roll = Math.atan2(
    new THREE.Vector3(1, 0, 0).applyQuaternion(ship.quat).y,
    new THREE.Vector3(0, 1, 0).applyQuaternion(ship.quat).y
  );
  ctx.save();
  ctx.translate(cx, cy);
  ctx.rotate(-roll);
  ctx.beginPath();
  ctx.arc(0, 0, 150, 0, Math.PI * 2);
  ctx.globalAlpha = 0.25;
  ctx.stroke();
  ctx.globalAlpha = 1;
  const ppx = 7.2; // px per degree
  for (let deg = -30; deg <= 40; deg += 5) {
    const y = (pitch * RAD - deg) * ppx;
    if (Math.abs(y) > 160) continue;
    const major = deg % 10 === 0;
    const len = major ? 54 : 26;
    ctx.beginPath();
    ctx.moveTo(-len, y);
    ctx.lineTo(deg === 0 ? -8 : -len + (major ? 54 : 26), y);
    ctx.moveTo(8, y);
    ctx.lineTo(len, y);
    ctx.strokeStyle = deg < 0 ? "rgba(255,140,90,0.85)" : "rgba(157,255,200,0.9)";
    ctx.stroke();
    if (major) {
      ctx.fillStyle = ctx.strokeStyle;
      ctx.font = "11px Share Tech Mono, monospace";
      ctx.fillText(String(Math.abs(deg)), len + 4, y + 4);
      ctx.fillText(String(Math.abs(deg)), -len - 22, y + 4);
    }
  }
  ctx.restore();

  // waterline / FPV
  const fpvX = cx + clamp(ship.aos * RAD, -25, 25) * 3.2;
  const fpvY = cy + clamp(ship.aoa * RAD, -25, 25) * 3.2;
  ctx.strokeStyle = "rgba(231,196,110,0.95)";
  ctx.beginPath();
  ctx.arc(fpvX, fpvY, 8, 0, Math.PI * 2);
  ctx.moveTo(fpvX - 16, fpvY); ctx.lineTo(fpvX - 8, fpvY);
  ctx.moveTo(fpvX + 8, fpvY); ctx.lineTo(fpvX + 16, fpvY);
  ctx.moveTo(fpvX, fpvY + 8); ctx.lineTo(fpvX, fpvY + 14);
  ctx.stroke();

  // gun piper
  ctx.strokeStyle = "rgba(255,90,90,0.8)";
  ctx.beginPath();
  ctx.arc(cx, cy - 8, 4, 0, Math.PI * 2);
  ctx.stroke();

  // left tape — airspeed
  tape(ctx, 36, cy - 110, 70, 220, "IAS", Math.round(ship.ias * KT), "KT", 20);
  tape(ctx, W - 106, cy - 110, 70, 220, "ALT", Math.round(ship.alt * FT), "FT", 100, true);
  // VS
  ctx.fillStyle = "rgba(157,255,200,0.9)";
  ctx.font = "12px Share Tech Mono, monospace";
  ctx.fillText("VS " + (ship.vs >= 0 ? "+" : "") + Math.round(ship.vs * FT) + " fpm", W - 118, cy + 130);

  // bottom cluster
  const baseY = H - 78;
  ctx.fillStyle = "rgba(6,10,16,0.55)";
  roundRect(ctx, 24, baseY - 8, 280, 62, 8);
  ctx.fill();
  ctx.fillStyle = "#e8eef6";
  ctx.font = "12px Share Tech Mono, monospace";
  ctx.fillText(ship.cfg.name, 36, baseY + 10);
  ctx.fillStyle = "#9dffc8";
  ctx.fillText("THR " + Math.round(ship.throttle * 100) + "%   RPM " + Math.round(ship.rpm * 100), 36, baseY + 28);
  ctx.fillText((ship.gear ? "GEAR DOWN" : "GEAR UP") + "   FLAP " + Math.round(ship.flaps * 100) + "   " + (sas ? "SAS" : "SAS OFF"), 36, baseY + 44);

  // throttle bar
  ctx.strokeStyle = "rgba(231,164,74,0.8)";
  ctx.strokeRect(W / 2 - 80, H - 36, 160, 8);
  ctx.fillStyle = ship.ab ? "#ff6633" : "#e7a44a";
  ctx.fillRect(W / 2 - 80, H - 36, 160 * ship.throttle, 8);

  // right status
  ctx.textAlign = "left";
  const lines = [
    "AOA " + (ship.aoa * RAD).toFixed(1) + "°",
    "G " + ship.g.toFixed(2),
    "MACH " + ship.mach.toFixed(2),
    "HDG " + pad3(Math.round(((headingOf(ship) * RAD) % 360 + 360) % 360)),
    "AGL " + Math.round(ship.agl * FT) + " ft",
    "FUEL " + Math.round(ship.fuel * 100) + "%",
    "EGT " + Math.round(420 + ship.heat * 380) + "°"
  ];
  ctx.font = "13px Share Tech Mono, monospace";
  lines.forEach((ln, i) => {
    ctx.fillStyle = i === 0 && ship.aoaAbs > ship.cfg.stallAoA * 0.8 ? "#ff5a5a" : "#9dffc8";
    ctx.fillText(ln, W - 150, 78 + i * 18);
  });

  // mission text
  ctx.fillStyle = "rgba(231,196,110,0.95)";
  ctx.font = "14px Share Tech Mono, monospace";
  ctx.textAlign = "center";
  let mt = "";
  if (mission && mission.type === "pattern") {
    mt = ship.onGround && mission.airborneTime < 1 ? "HOLD BRAKES · SET POWER · ROTATE AT " + Math.round(stallSpeed(ship.cfg, ship.flaps) * KT * 1.15) + " KT"
      : ship.airborne ? "PATTERN — CLIMB, TURN DOWNWIND, LAND RUNWAY 36 (NORTH)" : "ON THE SURFACE";
    if (mission.airborneTime > 8 && ship.airborne) mt = "CONFIGURE — GEAR, FLAPS, AIM THE THRESHOLD";
  } else if (mission) {
    const alive = bandits.filter(b => b.alive).length;
    mt = "BANDITS " + alive + "  ·  KILLS " + mission.kills + "  ·  HULL " + Math.max(0, Math.round(ship.hp));
  }
  ctx.fillText(mt, W / 2, 36);

  // radar for dogfight
  if (mission && mission.type === "dogfight") {
    const rx = 92, ry = H - 150, rr = 58;
    ctx.beginPath();
    ctx.arc(rx, ry, rr, 0, Math.PI * 2);
    ctx.strokeStyle = "rgba(157,255,200,0.4)";
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(rx, ry - rr); ctx.lineTo(rx, ry + rr);
    ctx.moveTo(rx - rr, ry); ctx.lineTo(rx + rr, ry);
    ctx.stroke();
    const hdg = headingOf(ship);
    bandits.forEach(b => {
      if (!b.alive) return;
      const dx = b.pos.x - ship.pos.x;
      const dz = b.pos.z - ship.pos.z;
      const dist = Math.hypot(dx, dz);
      const ang = Math.atan2(dx, dz) - hdg;
      const sc = clamp(dist / 2500, 0, 1) * (rr - 6);
      const x = rx + Math.sin(ang) * sc;
      const y = ry - Math.cos(ang) * sc;
      ctx.fillStyle = "#ff5a5a";
      ctx.fillRect(x - 3, y - 3, 6, 6);
    });
    ctx.fillStyle = "#9dffc8";
    ctx.beginPath();
    ctx.moveTo(rx, ry - 6); ctx.lineTo(rx - 4, ry + 4); ctx.lineTo(rx + 4, ry + 4); ctx.closePath();
    ctx.fill();
  }

  // stall / gear warning
  const stalling = ship.airborne && ship.aoaAbs > ship.cfg.stallAoA * 0.88 && ship.airspeed > 20;
  $("warn-banner").className = stalling ? "on" : "";
  $("warn-banner").textContent = stalling ? "STALL" : (ship.airborne && ship.gear && ship.ias > ship.cfg.vne * 0.45 ? "GEAR" : "");
  if (ship.airborne && ship.gear && ship.ias * KT > 220) {
    $("warn-banner").className = "on";
    $("warn-banner").textContent = "GEAR";
  }
  const gWarn = Math.abs(ship.g) > ship.cfg.maxG * 0.92;
  if (gWarn) {
    $("warn-banner").className = "on";
    $("warn-banner").textContent = "OVER G";
  }
  $("vignette").style.boxShadow = stalling
    ? "inset 0 0 160px 50px rgba(180,20,20,0.45)"
    : (Math.abs(ship.g) > 5 ? "inset 0 0 200px 70px rgba(0,0,0,0.55)" : "inset 0 0 180px 40px rgba(0,0,0,0.45)");

  ctx.restore();
}
function pad3(n) { return String(n).padStart(3, "0"); }
function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
}
function tape(ctx, x, y, w, h, label, value, unit) {
  ctx.fillStyle = "rgba(6,10,16,0.45)";
  roundRect(ctx, x, y, w, h, 6);
  ctx.fill();
  ctx.strokeStyle = "rgba(157,255,200,0.45)";
  ctx.strokeRect(x, y, w, h);
  ctx.fillStyle = "#9dffc8";
  ctx.font = "11px Share Tech Mono, monospace";
  ctx.textAlign = "center";
  ctx.fillText(label, x + w / 2, y + 16);
  ctx.font = "22px Share Tech Mono, monospace";
  ctx.fillStyle = "#f4f7fb";
  ctx.fillText(String(value), x + w / 2, y + h / 2 + 6);
  ctx.font = "11px Share Tech Mono, monospace";
  ctx.fillStyle = "#e7a44a";
  ctx.fillText(unit, x + w / 2, y + h - 12);
  ctx.textAlign = "left";
}

/* ---------- input ---------- */
window.addEventListener("keydown", (e) => {
  keys[e.code] = true;
  if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) e.preventDefault();
  if (e.repeat) return;
  if (e.code === "KeyH") $("help").style.display = $("help").style.display === "none" ? "block" : "none";
  if (e.code === "KeyM") setMuted(!audio.muted);
  if (mode === "fly") {
    if (e.code === "KeyC") camMode = (camMode + 1) % 4;
    if (e.code === "KeyL") sas = !sas;
    if (e.code === "KeyG") { ship.gear = !ship.gear; blip(ship.gear ? 220 : 160, 0.12, "sine", 0.07); }
    if (e.code === "KeyF") { ship.flaps = ship.flaps >= 1 ? 0 : +(ship.flaps + 0.5).toFixed(2); blip(300 + ship.flaps * 80, 0.08, "triangle", 0.05); }
    if (e.code === "KeyB") ship.brake = !ship.brake;
    if (e.code === "BracketLeft") ship.trim = clamp(ship.trim - 0.05, -0.4, 0.4);
    if (e.code === "BracketRight") ship.trim = clamp(ship.trim + 0.05, -0.4, 0.4);
    if (e.code === "Escape") {
      mode = "pause";
      $("pause").style.display = "flex";
    }
    if (e.code === "KeyR") startMission();
  } else if (mode === "pause" && e.code === "Escape") {
    mode = "fly";
    $("pause").style.display = "none";
  }
});
window.addEventListener("keyup", (e) => { keys[e.code] = false; if (e.code === "KeyB") { /* toggle, ignore */ } });
window.addEventListener("mousedown", () => {
  if (mode === "menu") return;
  const c = $("view");
  if (c.requestPointerLock) c.requestPointerLock();
});
document.addEventListener("pointerlockchange", () => {
  mouse.locked = document.pointerLockElement === $("view");
});
window.addEventListener("mousemove", (e) => {
  if (mode !== "fly") return;
  if (mouse.locked) {
    mouse.x = clamp(mouse.x + e.movementX * 0.0022, -1, 1);
    mouse.y = clamp(mouse.y + e.movementY * 0.0022, -1, 1);
  } else {
    mouse.x = clamp((e.clientX / window.innerWidth) * 2 - 1, -1, 1);
    mouse.y = clamp((e.clientY / window.innerHeight) * 2 - 1, -1, 1);
  }
});
// spring stick back when no mouse movement? gentle center if not locked
setInterval(() => {
  if (mode === "fly" && !mouse.locked) {
    // leave absolute mouse mapping
  } else if (mode === "fly" && mouse.locked) {
    mouse.x *= 0.92;
    mouse.y *= 0.92;
  }
}, 40);

$("launch").onclick = () => startMission();
$("resume").onclick = () => { mode = "fly"; $("pause").style.display = "none"; };
$("abort").onclick = () => toMenu();
$("retry").onclick = () => startMission();
$("tomenu").onclick = () => toMenu();

function toMenu() {
  mode = "menu";
  if (ship) { world.remove(ship.mesh); ship = null; }
  bandits.forEach(b => world.remove(b.mesh));
  bandits = [];
  showcase.forEach(s => { s.mesh.visible = true; });
  $("topbar").style.display = "flex";
  $("sheet").style.display = "block";
  $("pause").style.display = "none";
  $("debrief").style.display = "none";
  $("help").style.display = "none";
  $("warn-banner").className = "";
  if (document.exitPointerLock) document.exitPointerLock();
  updateTerrain(0, 600);
}

function onSelectCraft() {
  showcase.forEach(s => {
    s.mesh.traverse(o => {
      if (o.isMesh && o.material && o.material.emissive) {
        /* noop */
      }
    });
  });
}

/* ---------- loop ---------- */
let last = performance.now();
function frame(now) {
  requestAnimationFrame(frame);
  const dt = Math.min(0.05, (now - last) / 1000);
  last = now;
  if (waterMesh) waterMesh.material.uniforms.uTime.value = now * 0.001;
  if (mode === "menu") {
    const t = now * 0.001;
    showcase.forEach((s, i) => {
      const cfg = AIRCRAFT[s.id];
      const gy = terrainHeight(s.baseX, 480) + cfg.gearH * (s.id === "hauler" ? 0.72 : 1);
      s.mesh.position.y = gy + Math.sin(t * 1.2 + i) * 0.15;
      s.mesh.rotation.y = Math.sin(t * 0.35 + i) * 0.25 + (s.id === selectedCraft ? 0.4 : -0.2);
      const sc = s.id === selectedCraft ? 1.05 : 0.92;
      const base = s.id === "hauler" ? 0.72 : 1;
      s.mesh.scale.setScalar(base * sc);
    });
    camera.position.set(Math.sin(t * 0.12) * 22, 22, 400);
    camera.lookAt(2, 4, 520);
    skyMesh.position.copy(camera.position);
    sun.target.position.set(0, 0, 500);
    renderer.render(scene, camera);
    return;
  }
  if (mode === "fly") {
    clock += dt;
    if (keys.Space) fire(ship, true);
    if (ship.fireCd > 0) ship.fireCd -= dt;
    integrateShip(ship, dt, null, true);
    bandits.forEach(b => { if (b.fireCd > 0) b.fireCd -= dt; updateBandit(b, dt); });
    updateTracers(dt);
    updateMission(dt);
    updateAudio(ship, dt);
    if (ship.onGround && ship.vel.length() > 8 && Math.random() < 0.4) {
      const p = ship.pos.clone();
      p.y = terrainHeight(p.x, p.z) + 0.3;
      spawnParticle(p, new THREE.Vector3((Math.random()-0.5)*4, 2 + Math.random()*3, (Math.random()-0.5)*4), 0.7, COL.dust, 8);
    }
    // PAPI: 3° glide
    if (papiMeshes.length) {
      const dz = 150 - ship.pos.z;
      const glide = dz > 20 ? Math.atan2(ship.pos.y - 2, dz) * RAD : 3;
      papiMeshes.forEach((m, i) => {
        const thr = 2.5 + i * 0.35;
        const red = glide < thr;
        m.material.emissive.set(red ? 0xff2200 : 0xffffff);
        m.material.color.set(red ? 0xff2200 : 0xffffff);
      });
    }
  }
  shake *= Math.exp(-dt * 3.5);
  updateParticles(dt);
  if (ship) updateTerrain(ship.pos.x, ship.pos.z);
  updateCamera(dt);
  if (clouds) {
    clouds.children.forEach((c, i) => { c.position.x += Math.sin(i) * dt * 1.5; });
  }
  renderer.render(scene, camera);
  if (mode === "fly" || mode === "pause") drawHUD();
}
function boot() {
  bootThree();
  buildShowcase();
  renderMenu();
  updateTerrain(0, 600);
  $("boot").style.display = "none";
  requestAnimationFrame(frame);
}
boot();
</script>
</body>
</html>