← Flight Simulator results

Flight Simulator

Grok 4.5 · composer

Initial view of Flight Simulator

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 56,308 bytes · SHA-256 b046caf8f0d6
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Apex Aero — Flight Simulator</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
/* ── css: base ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
  width: 100%; height: 100%;
  overflow: hidden;
  background: #02060e;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  color: #e8f1ff;
  user-select: none;
}
#app { width: 100%; height: 100%; position: relative; }
#c { display: block; width: 100%; height: 100%; background: #000; }
/* ── css: screens ── */
.screen {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  background: radial-gradient(ellipse at 50% 30%, rgba(20,50,90,0.85), rgba(2,6,14,0.95));
  z-index: 20;
  backdrop-filter: blur(4px);
}
.screen.hidden, .hidden { display: none !important; }
.panel {
  width: min(920px, 94vw);
  padding: 36px 40px 28px;
  border: 1px solid rgba(120,180,255,0.25);
  border-radius: 16px;
  background: linear-gradient(160deg, rgba(12,24,48,0.92), rgba(6,12,28,0.96));
  box-shadow: 0 24px 80px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.06);
}
.panel.compact { width: min(420px, 90vw); text-align: center; }
.panel h1 {
  font-size: 42px; letter-spacing: 0.22em;
  font-weight: 300; text-align: center;
  background: linear-gradient(90deg, #7ec8ff, #ffffff, #9ad8ff);
  -webkit-background-clip: text; background-clip: text; color: transparent;
  margin-bottom: 4px;
}
.tagline {
  text-align: center; color: #7a9bc0; letter-spacing: 0.18em;
  font-size: 12px; text-transform: uppercase; margin-bottom: 28px;
}
.section { margin-bottom: 22px; }
.section h2 {
  font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
  color: #6a8fb8; margin-bottom: 10px; font-weight: 600;
}
.card-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.card {
  cursor: pointer; padding: 14px 12px;
  border-radius: 10px;
  border: 1px solid rgba(100,150,220,0.2);
  background: rgba(20,40,70,0.45);
  transition: border-color 0.15s, transform 0.15s, background 0.15s;
  text-align: left;
}
.card:hover { border-color: rgba(140,190,255,0.55); transform: translateY(-2px); }
.card.selected {
  border-color: #5eb0ff;
  background: linear-gradient(160deg, rgba(40,90,150,0.55), rgba(20,50,90,0.65));
  box-shadow: 0 0 0 1px rgba(94,176,255,0.35), 0 8px 24px rgba(30,100,200,0.2);
}
.card .name { font-size: 15px; font-weight: 600; margin-bottom: 4px; color: #eaf3ff; }
.card .sub { font-size: 11px; color: #89a8c8; line-height: 1.4; }
.card .stats { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
.card .chip {
  font-size: 10px; padding: 2px 7px; border-radius: 999px;
  background: rgba(80,140,220,0.18); color: #a8c8e8; letter-spacing: 0.04em;
}
.cta {
  display: block; width: 100%; margin-top: 10px;
  padding: 14px 20px; border: none; border-radius: 10px;
  background: linear-gradient(90deg, #1a6fd4, #2a9bff);
  color: #fff; font-size: 14px; font-weight: 700; letter-spacing: 0.18em;
  cursor: pointer; transition: filter 0.15s, opacity 0.15s;
}
.cta:hover:not(:disabled) { filter: brightness(1.12); }
.cta:disabled { opacity: 0.35; cursor: not-allowed; }
.cta.secondary {
  background: transparent;
  border: 1px solid rgba(120,170,230,0.35);
  color: #a8c8e8; margin-top: 10px;
}
.help {
  margin-top: 18px; text-align: center; color: #5e7a9a;
  font-size: 11px; line-height: 1.7;
}
.help kbd {
  display: inline-block; padding: 1px 6px; margin: 0 1px;
  border: 1px solid rgba(100,140,190,0.35); border-radius: 4px;
  background: rgba(30,50,80,0.5); color: #9ab8d8; font-family: inherit; font-size: 10px;
}
#end-msg { color: #9ab8d8; margin: 12px 0 20px; line-height: 1.5; }
/* ── css: hud ── */
#hud {
  position: absolute; inset: 0; pointer-events: none; z-index: 10;
  font-variant-numeric: tabular-nums;
}
.hud-top, .hud-bottom {
  position: absolute; left: 0; right: 0;
  display: flex; justify-content: space-between; padding: 18px 28px;
  gap: 16px;
}
.hud-top { top: 0; }
.hud-bottom { bottom: 0; }
.hud-block {
  min-width: 110px;
  padding: 8px 12px;
  background: rgba(4,12,24,0.45);
  border: 1px solid rgba(100,170,255,0.18);
  border-radius: 8px;
  backdrop-filter: blur(6px);
}
.hud-block.center { text-align: center; }
.hud-block.right { text-align: right; }
.hud-block .label {
  font-size: 10px; letter-spacing: 0.16em; color: #6a98c8; margin-bottom: 2px;
}
.hud-block .value {
  font-size: 22px; font-weight: 300; color: #d8ecff; line-height: 1.1;
}
.hud-block .value span { font-size: 11px; color: #6a98c8; margin-left: 3px; }
.hud-mid {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
}
#hud-attitude {
  width: 90px; height: 90px; position: relative;
  border-radius: 50%; overflow: hidden;
  border: 2px solid rgba(140,190,255,0.45);
  box-shadow: 0 0 20px rgba(40,100,200,0.25), inset 0 0 12px rgba(0,0,0,0.4);
  background: #000;
}
#horizon-ball {
  position: absolute; left: -20px; top: -20px; width: 130px; height: 130px;
  transition: none;
}
#horizon-sky {
  height: 50%; background: linear-gradient(#1a4a8a, #3a7cc0);
}
#horizon-ground {
  height: 50%; background: linear-gradient(#6b4a1a, #3a2a0a);
}
#horizon-line {
  position: absolute; left: 0; right: 0; top: 50%; height: 2px;
  background: #e8d080; transform: translateY(-50%);
}
.att-marker {
  position: absolute; left: 50%; top: 50%; width: 28px; height: 2px;
  background: #ff6060; transform: translate(-50%, -50%);
  box-shadow: -18px 0 0 #ff6060, 18px 0 0 #ff6060;
  z-index: 2;
}
.att-marker::before {
  content: ""; position: absolute; left: 50%; top: 50%;
  width: 6px; height: 6px; border-radius: 50%; background: #ff6060;
  transform: translate(-50%, -50%);
}
#stall-warn {
  position: absolute; top: 22%; left: 50%; transform: translateX(-50%);
  color: #ff4444; font-size: 22px; font-weight: 800; letter-spacing: 0.35em;
  text-shadow: 0 0 12px rgba(255,40,40,0.8);
  animation: pulse 0.5s ease-in-out infinite alternate;
}
@keyframes pulse { from { opacity: 0.5; } to { opacity: 1; } }
#mission-banner {
  position: absolute; top: 14%; left: 50%; transform: translateX(-50%);
  color: #b8d8ff; font-size: 13px; letter-spacing: 0.12em;
  text-align: center; max-width: 60%; text-shadow: 0 2px 8px #000;
}
#crosshair {
  position: absolute; left: 50%; top: 46%;
  transform: translate(-50%, -50%);
  font-size: 28px; color: rgba(160,220,255,0.7);
  text-shadow: 0 0 8px rgba(80,160,255,0.5);
  font-weight: 200;
}
#score {
  position: absolute; top: 18px; right: 28px;
  font-size: 16px; letter-spacing: 0.15em; color: #a8d4ff;
  padding: 6px 12px;
  background: rgba(4,12,24,0.45);
  border: 1px solid rgba(100,170,255,0.18);
  border-radius: 8px;
}
.hud-warn .value { color: #ff6a6a !important; }
</style>
</head>
<body>
<div id="app">
  <canvas id="c"></canvas>
  <div id="overlay" class="screen">
    <div class="panel">
      <h1>APEX AERO</h1>
      <p class="tagline">High-fidelity flight dynamics</p>
      <div class="section">
        <h2>Select Aircraft</h2>
        <div id="aircraft-list" class="card-row"></div>
      </div>
      <div class="section">
        <h2>Select Mission</h2>
        <div id="mission-list" class="card-row"></div>
      </div>
      <button id="btn-start" class="cta" disabled="">START FLIGHT</button>
      <div class="help">W/S Pitch · A/D Roll · Q/E Yaw · Shift/Ctrl Throttle · 1–5 Throttle presets · Space Fire · Esc Menu</div>
    </div>
  </div>
  <div id="hud" class="hidden">
    <div class="hud-top">
      <div class="hud-block">
        <div class="label">AIRSPEED</div>
        <div id="hud-speed" class="value">0 <span>kts</span></div>
      </div>
      <div class="hud-block center">
        <div class="label">ALTITUDE</div>
        <div id="hud-alt" class="value">0 <span>ft</span></div>
      </div>
      <div class="hud-block right">
        <div class="label">THROTTLE</div>
        <div id="hud-throttle" class="value">0<span>%</span></div>
      </div>
    </div>
    <div class="hud-mid">
      <div id="hud-attitude">
        <div id="horizon-ball">
          <div id="horizon-sky"></div>
          <div id="horizon-ground"></div>
          <div id="horizon-line"></div>
        </div>
        <div class="att-marker"></div>
      </div>
    </div>
    <div class="hud-bottom">
      <div class="hud-block">
        <div class="label">HEADING</div>
        <div id="hud-hdg" class="value">000°</div>
      </div>
      <div class="hud-block">
        <div class="label">AoA</div>
        <div id="hud-aoa" class="value">0.0°</div>
      </div>
      <div class="hud-block">
        <div class="label">G-FORCE</div>
        <div id="hud-g" class="value">1.0</div>
      </div>
      <div class="hud-block">
        <div class="label">V/S</div>
        <div id="hud-vs" class="value">0 <span>fpm</span></div>
      </div>
    </div>
    <div id="stall-warn" class="warn hidden">STALL</div>
    <div id="mission-banner"></div>
    <div id="crosshair" class="hidden">+</div>
    <div id="score" class="hidden">SCORE 0</div>
  </div>
  <div id="pause" class="screen hidden">
    <div class="panel compact">
      <h1>PAUSED</h1>
      <button id="btn-resume" class="cta">RESUME</button>
      <button id="btn-menu" class="cta secondary">MAIN MENU</button>
    </div>
  </div>
  <div id="end" class="screen hidden">
    <div class="panel compact">
      <h1 id="end-title">MISSION COMPLETE</h1>
      <p id="end-msg"></p>
      <button id="btn-again" class="cta">FLY AGAIN</button>
      <button id="btn-end-menu" class="cta secondary">MAIN MENU</button>
    </div>
  </div>
</div>
<script>
'use strict';

// ── const: AIRCRAFT ──
const AIRCRAFT = {
  falcon: {
    id: 'falcon',
    name: 'Falcon F-12',
    sub: 'Lightweight air superiority fighter. High thrust-to-weight, sharp response.',
    mass: 9800,
    wingArea: 28,
    wingspan: 10.5,
    thrustMax: 155000,
    maxSpeed: 720,
    cruiseSpeed: 280,
    Cl0: 0.28,
    ClAlpha: 5.0,
    Cd0: 0.022,
    K: 0.065,
    stallAoA: 16 * Math.PI / 180,
    inert: { pitch: 18000, roll: 9000, yaw: 28000 },
    ctrl: { pitch: 2.4, roll: 3.6, yaw: 1.5 },
    damp: { pitch: 4.5, roll: 5.5, yaw: 3.2 },
    color: 0x4a90c8,
    accent: 0xc8e0f8,
    type: 'fighter'
  },
  atlas: {
    id: 'atlas',
    name: 'Atlas C-40',
    sub: 'Heavy transport. High lift, sluggish roll, long runway needs.',
    mass: 52000,
    wingArea: 145,
    wingspan: 38,
    thrustMax: 300000,
    maxSpeed: 280,
    cruiseSpeed: 180,
    Cl0: 0.42,
    ClAlpha: 5.4,
    Cd0: 0.038,
    K: 0.045,
    stallAoA: 13 * Math.PI / 180,
    inert: { pitch: 220000, roll: 160000, yaw: 340000 },
    ctrl: { pitch: 1.0, roll: 0.9, yaw: 0.75 },
    damp: { pitch: 8.0, roll: 10.0, yaw: 7.0 },
    color: 0x6a7a88,
    accent: 0xc8c4a8,
    type: 'transport'
  },
  comet: {
    id: 'comet',
    name: 'Comet XT',
    sub: 'Aerobatic racer. Extreme maneuverability, thin stall margin.',
    mass: 720,
    wingArea: 10.5,
    wingspan: 7.2,
    thrustMax: 11000,
    maxSpeed: 310,
    cruiseSpeed: 160,
    Cl0: 0.22,
    ClAlpha: 5.6,
    Cd0: 0.028,
    K: 0.08,
    stallAoA: 14 * Math.PI / 180,
    inert: { pitch: 900, roll: 450, yaw: 1200 },
    ctrl: { pitch: 3.8, roll: 5.4, yaw: 2.5 },
    damp: { pitch: 3.0, roll: 3.5, yaw: 2.8 },
    color: 0xd43030,
    accent: 0xffe060,
    type: 'racer'
  }
};

// ── const: MISSIONS ──
const MISSIONS = {
  takeoff: {
    id: 'takeoff',
    name: 'Precision Flight',
    sub: 'Takeoff, circuit, and landing. Manage AoA, speed and descent.',
    chips: ['Runway', 'Circuits', 'Landing']
  },
  dogfight: {
    id: 'dogfight',
    name: 'Dogfight',
    sub: 'High-G pursuit against hostiles. Lead the target and fire.',
    chips: ['Combat', 'Inertia', 'High-G']
  }
};

// ── const: RHO0 ──
const RHO0 = 1.225;

// ── const: G ──
const G = 9.81;

// ── const: FT ──
const FT = 3.28084;

// ── const: KTS ──
const KTS = 1.94384;

// ── function: createInput ──
function createInput() {
  const keys = Object.create(null);
  const state = {
    pitch: 0, roll: 0, yaw: 0, throttle: 0.3, fire: false,
    _throtTarget: 0.3
  };
  window.addEventListener('keydown', (e) => {
    keys[e.code] = true;
    if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
  });
  window.addEventListener('keyup', (e) => { keys[e.code] = false; });
  function update(dt) {
    const p = (keys.KeyS || keys.ArrowDown ? 1 : 0) - (keys.KeyW || keys.ArrowUp ? 1 : 0);
    const r = (keys.KeyD || keys.ArrowRight ? 1 : 0) - (keys.KeyA || keys.ArrowLeft ? 1 : 0);
    const y = (keys.KeyE ? 1 : 0) - (keys.KeyQ ? 1 : 0);
    state.pitch += (p - state.pitch) * Math.min(1, dt * 8);
    state.roll += (r - state.roll) * Math.min(1, dt * 8);
    state.yaw += (y - state.yaw) * Math.min(1, dt * 8);
    if (keys.ShiftLeft || keys.ShiftRight) state._throtTarget = Math.min(1, state._throtTarget + dt * 0.45);
    if (keys.ControlLeft || keys.ControlRight) state._throtTarget = Math.max(0, state._throtTarget - dt * 0.45);
    if (keys.Digit1) state._throtTarget = 0;
    if (keys.Digit2) state._throtTarget = 0.25;
    if (keys.Digit3) state._throtTarget = 0.5;
    if (keys.Digit4) state._throtTarget = 0.75;
    if (keys.Digit5) state._throtTarget = 1;
    state.throttle += (state._throtTarget - state.throttle) * Math.min(1, dt * 2.2);
    state.fire = !!(keys.Space);
    return state;
  }
  function setThrottle(t) {
    state._throtTarget = t;
    state.throttle = t;
  }
  return { update, keys, state, setThrottle };
}

// ── function: airDensity ──
function airDensity(alt) {
  // ISA troposphere approximation
  const T = Math.max(180, 288.15 - 0.0065 * alt);
  const p = 101325 * Math.pow(T / 288.15, 5.2561);
  return p / (287.05 * T);
}

// ── function: createAircraftState ──
function createAircraftState(spec, pos, heading) {
  const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, heading, 0, 'YXZ'));
  return {
    spec,
    position: pos.clone(),
    velocity: new THREE.Vector3(),
    angularVel: new THREE.Vector3(),
    quat: q,
    throttle: 0.3,
    onGround: true,
    stalled: false,
    gForce: 1,
    aoa: 0,
    airspeed: 0,
    altitude: pos.y,
    heading: heading,
    gearDown: true,
    lastAccel: new THREE.Vector3(),
    smokeTimer: 0,
    alive: true
  };
}

// ── function: integrateFlight ──
function integrateFlight(ac, input, det, dt, opts) {
  if (!ac.alive) return;
  const s = ac.spec;
  const rho = airDensity(Math.max(0, ac.position.y));
  const qAtt = ac.quat;
  const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(qAtt);
  const up = new THREE.Vector3(0, 1, 0).applyQuaternion(qAtt);
  const right = new THREE.Vector3(1, 0, 0).applyQuaternion(qAtt);

  ac.throttle = input.throttle;
  const thrAlt = Math.max(0.25, rho / RHO0);
  const thrust = s.thrustMax * ac.throttle * thrAlt;

  const vel = ac.velocity;
  const speed = vel.length();
  ac.airspeed = speed;

  let aoa = 0;
  let beta = 0;
  if (speed > 1) {
    const vBody = vel.clone().applyQuaternion(qAtt.clone().invert());
    aoa = Math.atan2(-vBody.y, -vBody.z);
    beta = Math.atan2(vBody.x, -vBody.z);
  }
  ac.aoa = aoa;

  let Cl = s.Cl0 + s.ClAlpha * aoa;
  const stalled = Math.abs(aoa) > s.stallAoA;
  ac.stalled = stalled && speed > 15 && !ac.onGround;
  if (stalled) {
    const over = (Math.abs(aoa) - s.stallAoA) / (s.stallAoA + 0.01);
    Cl *= Math.max(0.1, 1 - over * 1.6);
  }
  Cl = THREE.MathUtils.clamp(Cl, -1.6, 1.8);

  const Cd = s.Cd0 + s.K * Cl * Cl + (stalled ? 0.4 * Math.min(1, Math.abs(aoa)) : 0)
    + (ac.gearDown ? 0.018 : 0);

  const qDyn = 0.5 * rho * speed * speed;
  const liftMag = qDyn * s.wingArea * Cl;
  const dragMag = qDyn * s.wingArea * Cd;

  const vDir = speed > 0.5 ? vel.clone().normalize() : forward.clone();
  const liftDir = new THREE.Vector3().crossVectors(right, vDir);
  if (liftDir.lengthSq() > 1e-6) liftDir.normalize();
  else liftDir.copy(up);
  if (liftDir.dot(up) < 0) liftDir.negate();

  const force = new THREE.Vector3();
  force.addScaledVector(forward, thrust);
  if (speed > 0.5) {
    force.addScaledVector(liftDir, liftMag);
    force.addScaledVector(vDir, -dragMag);
  }
  force.y -= s.mass * G;

  const groundY = det ? det(ac.position.x, ac.position.z) : 0;
  let agl = ac.position.y - groundY;
  ac.altitude = ac.position.y;

  const canFly = liftMag >= s.mass * G * 0.98 && speed > 25;
  const onGr = agl < 1.65 && vel.y <= 3.0 && !canFly;
  ac.onGround = onGr;

  if (onGr) {
    ac.position.y = groundY + 1.5;
    agl = 1.5;
    if (vel.y < 0) vel.y = 0;
    const mu = speed < 3 ? 0.05 : 0.018;
    const nForce = Math.max(0, s.mass * G - Math.max(0, liftMag));
    if (speed > 0.05) force.addScaledVector(vDir, -mu * nForce);
    else if (ac.throttle < 0.08) vel.set(0, 0, 0);
    force.x -= vel.x * s.mass * 0.5;

    const eul = new THREE.Euler().setFromQuaternion(qAtt, 'YXZ');
    eul.z *= Math.max(0, 1 - dt * 6);
    const vRot = Math.sqrt((2 * s.mass * G) / (Math.max(rho, 0.5) * s.wingArea * (s.Cl0 + s.ClAlpha * 0.15)));
    if (speed < vRot * 0.8) eul.x *= Math.max(0, 1 - dt * 2.5);
    else eul.x = THREE.MathUtils.clamp(eul.x, -0.06, 0.3);
    qAtt.setFromEuler(eul);
    ac.angularVel.z *= 0.35;
  }

  if (agl < 1.5 && vel.y < -16) ac.alive = false;
  if (!onGr && agl < 0) {
    if (speed > 40 && Math.abs(vel.y) > 12) ac.alive = false;
    ac.position.y = groundY + 1.5;
    vel.y = Math.max(vel.y, -1) * 0.2;
  }

  const accel = force.clone().multiplyScalar(1 / s.mass);
  ac.lastAccel.copy(accel);
  vel.addScaledVector(accel, dt);
  if (vel.length() > s.maxSpeed * 1.35) vel.setLength(s.maxSpeed * 1.35);
  ac.position.addScaledVector(vel, dt);

  const gy2 = det ? det(ac.position.x, ac.position.z) : 0;
  if (ac.position.y < gy2 + 1.5) {
    if (canFly) {
      ac.position.y = gy2 + 1.55;
      if (vel.y < 0.2) vel.y = 1.2;
      ac.onGround = false;
    } else {
      ac.position.y = gy2 + 1.5;
      if (vel.y < 0) vel.y = 0;
    }
  }

  const i = ac.angularVel;
  const qDynN = Math.min(1.25, qDyn / 7500 + 0.1);
  const auth = Math.min(1, Math.max(0.12, qDynN));
  const stallFactor = stalled ? 0.35 : (Math.abs(aoa) > s.stallAoA * 0.7 ? 0.6 : 1);
  let cp = input.pitch * s.ctrl.pitch * auth * stallFactor;
  let cr = input.roll * s.ctrl.roll * auth * (stalled ? 0.5 : 1);
  let cy = input.yaw * s.ctrl.yaw * auth;

  if (onGr) {
    cr *= 0.05;
    const vRot = Math.sqrt((2 * s.mass * G) / (Math.max(rho, 0.5) * s.wingArea * (s.Cl0 + s.ClAlpha * 0.15)));
    cp *= speed > vRot * 0.75 ? 0.85 : 0.2;
    if (speed > 1.5) {
      const steer = input.yaw * dt * (0.9 + Math.min(1.5, speed * 0.04)) * s.ctrl.yaw;
      const rot = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -steer);
      qAtt.premultiply(rot);
      const flatFwd = new THREE.Vector3(0, 0, -1).applyQuaternion(qAtt);
      flatFwd.y = 0;
      if (flatFwd.lengthSq() > 0.01) {
        flatFwd.normalize();
        const spd = Math.hypot(vel.x, vel.z);
        vel.x = flatFwd.x * spd;
        vel.z = flatFwd.z * spd;
      }
    }
    cy *= 0.25;
  }

  const pitchStab = 2.8 + (stalled ? 1.5 : 0);
  i.x += (cp - i.x * s.damp.pitch * 0.16 - aoa * pitchStab) * dt * 14;
  i.y += (cy - i.y * s.damp.yaw * 0.13 - beta * 2.5) * dt * 11;
  i.z += (cr - i.z * s.damp.roll * 0.15) * dt * 16;

  i.x = THREE.MathUtils.clamp(i.x, -2.8, 2.8);
  i.y = THREE.MathUtils.clamp(i.y, -1.8, 1.8);
  i.z = THREE.MathUtils.clamp(i.z, -3.8, 3.8);

  const hx = i.x * dt * 0.5;
  const hy = i.y * dt * 0.5;
  const hz = i.z * dt * 0.5;
  const qw = qAtt.w, qx = qAtt.x, qy = qAtt.y, qz = qAtt.z;
  qAtt.w = qw + (-qx * hx - qy * hy - qz * hz);
  qAtt.x = qx + (qw * hx + qy * hz - qz * hy);
  qAtt.y = qy + (qw * hy - qx * hz + qz * hx);
  qAtt.z = qz + (qw * hz + qx * hy - qy * hx);
  qAtt.normalize();

  const e = new THREE.Euler().setFromQuaternion(qAtt, 'YXZ');
  ac.heading = e.y;
  ac.gForce = THREE.MathUtils.clamp(
    1 + (liftMag / (s.mass * G) - 1) * 0.85 + accel.y / G * 0.15,
    -2, 11
  );

  if (!ac.onGround && ac.altitude - gy2 > 40) ac.gearDown = false;
  if (ac.altitude - gy2 < 50 && opts && opts.autoGear) ac.gearDown = true;
}

// ── function: hash2 ──
function hash2(x, z) {
  let n = Math.sin(x * 127.1 + z * 311.7) * 43758.5453;
  return n - Math.floor(n);
}

// ── function: smoothNoise ──
function smoothNoise(x, z) {
  const xi = Math.floor(x), zi = Math.floor(z);
  const xf = x - xi, zf = z - zi;
  const u = xf * xf * (3 - 2 * xf);
  const v = zf * zf * (3 - 2 * zf);
  const a = hash2(xi, zi);
  const b = hash2(xi + 1, zi);
  const c = hash2(xi, zi + 1);
  const d = hash2(xi + 1, zi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

// ── function: fbm ──
function fbm(x, z) {
  let a = 0, f = 1, amp = 1, sum = 0;
  for (let i = 0; i < 5; i++) {
    sum += smoothNoise(x * f, z * f) * amp;
    a += amp;
    amp *= 0.5;
    f *= 2.05;
  }
  return sum / a;
}

// ── function: terrainHeight ──
function terrainHeight(x, z) {
  // Flat runway strip around origin + rolling hills/mountains beyond
  const d = Math.hypot(x, z);
  const runway = Math.max(0, 1 - Math.abs(x) / 70) * Math.max(0, 1 - Math.abs(z) / 1400);
  const coast = 1 - THREE.MathUtils.smoothstep(900, 2800, d);
  const hills = fbm(x * 0.0011, z * 0.0011);
  const ridged = 1 - Math.abs(fbm(x * 0.00055 + 20, z * 0.00055) * 2 - 1);
  let h = hills * 90 + ridged * ridged * 280;
  h *= THREE.MathUtils.smoothstep(120, 600, Math.abs(x) + Math.abs(z) * 0.3);
  // ocean drop
  if (d > 2200) h -= (d - 2200) * 0.08;
  h = Math.max(h, -40);
  // flatten for airfield
  h = h * (1 - runway * 0.98);
  return h;
}

// ── function: createTerrain ──
function createTerrain(scene) {
  const size = 6000;
  const seg = 200;
  const geo = new THREE.PlaneGeometry(size, size, seg, seg);
  geo.rotateX(-Math.PI / 2);
  const pos = geo.attributes.position;
  const colors = new Float32Array(pos.count * 3);
  const c = new THREE.Color();
  for (let i = 0; i < pos.count; i++) {
    const x = pos.getX(i);
    const z = pos.getZ(i);
    const h = terrainHeight(x, z);
    pos.setY(i, h);
    if (h < 0.5) c.setRGB(0.18, 0.35, 0.48);
    else if (h < 35) c.setRGB(0.30 + h * 0.002, 0.48, 0.22);
    else if (h < 120) c.setRGB(0.40, 0.42, 0.24);
    else if (h < 200) c.setRGB(0.50, 0.42, 0.34);
    else c.setRGB(0.92, 0.94, 0.96);
    if (Math.abs(x) < 90 && Math.abs(z) < 1250 && h < 4) {
      c.setRGB(0.38, 0.40, 0.42);
    }
    colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b;
  }
  geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
  geo.computeVertexNormals();
  const mat = new THREE.MeshStandardMaterial({
    vertexColors: true,
    roughness: 0.9,
    metalness: 0.04
  });
  const mesh = new THREE.Mesh(geo, mat);
  mesh.receiveShadow = true;
  scene.add(mesh);

  const rw = new THREE.Group();
  const asphalt = new THREE.Mesh(
    new THREE.BoxGeometry(42, 0.5, 1900),
    new THREE.MeshStandardMaterial({ color: 0x4a4e56, roughness: 0.92, metalness: 0.05 })
  );
  asphalt.position.y = 0.35;
  asphalt.receiveShadow = true;
  rw.add(asphalt);
  const edgeMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
  for (const sx of [-20, 20]) {
    const edge = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.55, 1900), edgeMat);
    edge.position.set(sx, 0.44, 0);
    rw.add(edge);
  }
  const lineMat = new THREE.MeshBasicMaterial({ color: 0xffe060 });
  for (let i = -16; i <= 16; i++) {
    const dash = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.58, 24), lineMat);
    dash.position.set(0, 0.45, i * 55);
    rw.add(dash);
  }
  for (let s = -1; s <= 1; s += 2) {
    for (let i = 0; i < 10; i++) {
      const bar = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.58, 36), lineMat);
      bar.position.set(-15 + i * 3.3, 0.45, s * 880);
      rw.add(bar);
    }
  }
  const lightGeo = new THREE.BoxGeometry(0.8, 0.7, 0.8);
  const amber = new THREE.MeshStandardMaterial({ color: 0xffcc44, emissive: 0xffaa22, emissiveIntensity: 2 });
  const blue = new THREE.MeshStandardMaterial({ color: 0x66aaff, emissive: 0x3388ff, emissiveIntensity: 1.5 });
  for (let i = -18; i <= 18; i++) {
    for (const sx of [-22, 22]) {
      const l = new THREE.Mesh(lightGeo, i % 2 === 0 ? amber : blue);
      l.position.set(sx, 0.6, i * 48);
      rw.add(l);
    }
  }
  scene.add(rw);

  const bmat = new THREE.MeshStandardMaterial({ color: 0x9aa0aa, roughness: 0.7 });
  const glass = new THREE.MeshStandardMaterial({ color: 0x4a98d0, metalness: 0.5, roughness: 0.25, emissive: 0x1a4060, emissiveIntensity: 0.4 });
  const towerBase = new THREE.Mesh(new THREE.BoxGeometry(20, 12, 20), bmat);
  towerBase.position.set(90, 6, -180);
  const towerTop = new THREE.Mesh(new THREE.BoxGeometry(26, 7, 26), glass);
  towerTop.position.set(90, 15.5, -180);
  const hangar = new THREE.Mesh(new THREE.BoxGeometry(60, 16, 40), new THREE.MeshStandardMaterial({ color: 0x7a8088, roughness: 0.85 }));
  hangar.position.set(120, 8, 200);
  const hangar2 = hangar.clone();
  hangar2.position.set(120, 8, 280);
  scene.add(towerBase, towerTop, hangar, hangar2);

  const trunkMat = new THREE.MeshStandardMaterial({ color: 0x5a3a20 });
  const leafMat = new THREE.MeshStandardMaterial({ color: 0x2f7a2c, roughness: 0.9 });
  for (let i = 0; i < 80; i++) {
    const tx = (Math.random() < 0.5 ? -1 : 1) * (70 + Math.random() * 220);
    const tz = (Math.random() - 0.5) * 1600;
    const th = terrainHeight(tx, tz);
    if (th < 1 || th > 50) continue;
    const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.5, 4, 5), trunkMat);
    trunk.position.set(tx, th + 2, tz);
    const leaf = new THREE.Mesh(new THREE.ConeGeometry(3 + Math.random() * 2, 7, 6), leafMat);
    leaf.position.set(tx, th + 7, tz);
    scene.add(trunk, leaf);
  }

  return { heightAt: terrainHeight, mesh };
}

// ── function: makePlaneMesh ──
function makePlaneMesh(spec) {
  const g = new THREE.Group();
  const bodyMat = new THREE.MeshStandardMaterial({
    color: spec.color, metalness: 0.45, roughness: 0.38, emissive: spec.color, emissiveIntensity: 0.08
  });
  const accentMat = new THREE.MeshStandardMaterial({
    color: spec.accent, metalness: 0.35, roughness: 0.42, emissive: spec.accent, emissiveIntensity: 0.12
  });
  const darkMat = new THREE.MeshStandardMaterial({ color: 0x111318, metalness: 0.6, roughness: 0.4 });
  const glassMat = new THREE.MeshStandardMaterial({
    color: 0xa8d8ff, metalness: 0.9, roughness: 0.08, transparent: true, opacity: 0.65, emissive: 0x224466, emissiveIntensity: 0.25
  });

  if (spec.type === 'fighter') {
    const fus = new THREE.Mesh(new THREE.CapsuleGeometry(0.55, 4.2, 4, 10), bodyMat);
    fus.rotation.x = Math.PI / 2;
    fus.position.z = 0.2;
    g.add(fus);
    const nose = new THREE.Mesh(new THREE.ConeGeometry(0.45, 1.6, 10), bodyMat);
    nose.rotation.x = -Math.PI / 2;
    nose.position.z = -2.6;
    g.add(nose);
    const wing = new THREE.Mesh(new THREE.BoxGeometry(7.2, 0.12, 2.0), bodyMat);
    wing.position.set(0, -0.1, 0.3);
    g.add(wing);
    const wingTipL = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.08, 0.5), accentMat);
    wingTipL.position.set(-3.6, -0.1, 0.3);
    const wingTipR = wingTipL.clone(); wingTipR.position.x = 3.6;
    g.add(wingTipL, wingTipR);
    // leading edge accent stripe
    const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.5, 3.5), accentMat);
    stripe.position.set(0, 0.2, 0);
    g.add(stripe);
    const stab = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.08, 0.7), bodyMat);
    stab.position.set(0, 0.1, 2.2);
    g.add(stab);
    const fin = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.4, 1.0), accentMat);
    fin.position.set(0, 0.8, 2.0);
    g.add(fin);
    const canopy = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8, 0, Math.PI * 2, 0, Math.PI / 2), glassMat);
    canopy.position.set(0, 0.45, -0.6);
    canopy.scale.set(1, 0.7, 1.4);
    g.add(canopy);
    const intakeL = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.4, 1.2), darkMat);
    intakeL.position.set(-0.55, -0.35, 0.5);
    const intakeR = intakeL.clone(); intakeR.position.x = 0.55;
    g.add(intakeL, intakeR);
    const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.4, 0.4, 10), darkMat);
    exhaust.rotation.x = Math.PI / 2;
    exhaust.position.z = 2.6;
    g.add(exhaust);
    // afterburner glow
    const glow = new THREE.Mesh(
      new THREE.SphereGeometry(0.35, 8, 8),
      new THREE.MeshBasicMaterial({ color: 0x66ccff })
    );
    glow.position.z = 2.9;
    glow.name = 'exhaustGlow';
    g.add(glow);
  } else if (spec.type === 'transport') {
    const fus = new THREE.Mesh(new THREE.CapsuleGeometry(1.1, 8.5, 6, 12), bodyMat);
    fus.rotation.x = Math.PI / 2;
    g.add(fus);
    const wing = new THREE.Mesh(new THREE.BoxGeometry(16, 0.18, 2.4), bodyMat);
    wing.position.set(0, 0.3, 0.5);
    g.add(wing);
    const engL = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 1.6, 10), darkMat);
    engL.rotation.x = Math.PI / 2;
    engL.position.set(-4.5, -0.3, 0.3);
    const engR = engL.clone(); engR.position.x = 4.5;
    g.add(engL, engR);
    const stab = new THREE.Mesh(new THREE.BoxGeometry(5, 0.12, 1.2), bodyMat);
    stab.position.set(0, 0.4, 4.5);
    g.add(stab);
    const fin = new THREE.Mesh(new THREE.BoxGeometry(0.15, 2.4, 1.5), accentMat);
    fin.position.set(0, 1.5, 4.3);
    g.add(fin);
    const cock = new THREE.Mesh(new THREE.SphereGeometry(0.7, 10, 8, 0, Math.PI * 2, 0, Math.PI / 2), glassMat);
    cock.position.set(0, 0.8, -4.0);
    cock.scale.set(1.2, 0.8, 1.5);
    g.add(cock);
    // livery stripe
    const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.6, 8), accentMat);
    stripe.position.set(0, 0.3, 0);
    g.add(stripe);
  } else {
    const fus = new THREE.Mesh(new THREE.CapsuleGeometry(0.35, 2.6, 4, 8), bodyMat);
    fus.rotation.x = Math.PI / 2;
    g.add(fus);
    const wing = new THREE.Mesh(new THREE.BoxGeometry(6.0, 0.1, 1.1), accentMat);
    wing.position.set(0, 0, 0.1);
    g.add(wing);
    const wing2 = new THREE.Mesh(new THREE.BoxGeometry(4.5, 0.08, 0.8), bodyMat);
    wing2.position.set(0, 0.7, 0.1);
    g.add(wing2);
    for (const sx of [-1.5, 1.5]) {
      const st = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.7, 0.05), darkMat);
      st.position.set(sx, 0.35, 0.1);
      g.add(st);
    }
    const stab = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.06, 0.55), bodyMat);
    stab.position.set(0, 0.15, 1.5);
    g.add(stab);
    const fin = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.85, 0.7), accentMat);
    fin.position.set(0, 0.5, 1.45);
    g.add(fin);
    const propHub = new THREE.Mesh(new THREE.SphereGeometry(0.18, 8, 8), darkMat);
    propHub.position.z = -1.7;
    g.add(propHub);
    const prop = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.08, 0.12), darkMat);
    prop.position.z = -1.8;
    prop.name = 'prop';
    g.add(prop);
    const wheelL = new THREE.Mesh(new THREE.TorusGeometry(0.18, 0.06, 6, 12), darkMat);
    wheelL.position.set(-0.5, -0.55, 0.2);
    wheelL.rotation.y = Math.PI / 2;
    const wheelR = wheelL.clone(); wheelR.position.x = 0.5;
    g.add(wheelL, wheelR);
  }

  g.traverse((o) => {
    if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; }
  });
  const scale = spec.type === 'transport' ? 2.4 : spec.type === 'fighter' ? 2.2 : 1.8;
  g.scale.setScalar(scale);
  return g;
}

// ── function: makeEnemyMesh ──
function makeEnemyMesh() {
  const g = new THREE.Group();
  const mat = new THREE.MeshStandardMaterial({ color: 0x304028, metalness: 0.5, roughness: 0.4 });
  const accent = new THREE.MeshStandardMaterial({ color: 0xc04020, metalness: 0.4, roughness: 0.45 });
  const fus = new THREE.Mesh(new THREE.CapsuleGeometry(0.5, 3.5, 4, 8), mat);
  fus.rotation.x = Math.PI / 2;
  g.add(fus);
  const wing = new THREE.Mesh(new THREE.BoxGeometry(6.0, 0.1, 1.6), mat);
  g.add(wing);
  const fin = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.1, 0.8), accent);
  fin.position.set(0, 0.7, 1.6);
  g.add(fin);
  g.scale.setScalar(1.6);
  return g;
}

// ── function: makeBullet ──
function makeBullet(isPlayer) {
  const m = new THREE.Mesh(
    new THREE.SphereGeometry(0.35, 6, 6),
    new THREE.MeshBasicMaterial({ color: isPlayer ? 0xffee66 : 0xff4422 })
  );
  return m;
}

// ── function: makeExplosion ──
function makeExplosion(scene, pos) {
  const parts = [];
  for (let i = 0; i < 18; i++) {
    const m = new THREE.Mesh(
      new THREE.SphereGeometry(0.4 + Math.random() * 0.6, 5, 5),
      new THREE.MeshBasicMaterial({ color: Math.random() > 0.5 ? 0xff6622 : 0xffcc44 })
    );
    m.position.copy(pos);
    const v = new THREE.Vector3(
      (Math.random() - 0.5) * 40,
      Math.random() * 30,
      (Math.random() - 0.5) * 40
    );
    scene.add(m);
    parts.push({ m, v, life: 0.8 + Math.random() * 0.5 });
  }
  return parts;
}

// ── function: createCameraRig ──
function createCameraRig(camera) {
  const smoothedPos = new THREE.Vector3();
  const smoothedLook = new THREE.Vector3();
  let init = false;
  function update(ac, dt) {
    const back = new THREE.Vector3(0, 0, 1).applyQuaternion(ac.quat);
    const bodyUp = new THREE.Vector3(0, 1, 0).applyQuaternion(ac.quat);
    const up = new THREE.Vector3(0, 1, 0).lerp(bodyUp, 0.35).normalize();
    // Closer cinematic chase
    const desired = ac.position.clone()
      .addScaledVector(back, 11)
      .addScaledVector(up, 3.2);
    const minY = terrainHeight(desired.x, desired.z) + 1.8;
    if (desired.y < minY) desired.y = minY;
    const look = ac.position.clone()
      .add(new THREE.Vector3(0, 0, -1).applyQuaternion(ac.quat).multiplyScalar(12))
      .addScaledVector(up, 1.0);
    if (!init) {
      smoothedPos.copy(desired);
      smoothedLook.copy(look);
      init = true;
    }
    const k = 1 - Math.exp(-dt * 6.5);
    smoothedPos.lerp(desired, k);
    smoothedLook.lerp(look, k);
    camera.position.copy(smoothedPos);
    camera.lookAt(smoothedLook);
  }
  function reset() { init = false; }
  return { update, reset };
}

// ── function: updateHUD ──
function updateHUD(ac) {
  const spd = document.getElementById('hud-speed');
  const alt = document.getElementById('hud-alt');
  const thr = document.getElementById('hud-throttle');
  const hdg = document.getElementById('hud-hdg');
  const aoa = document.getElementById('hud-aoa');
  const g = document.getElementById('hud-g');
  const vs = document.getElementById('hud-vs');
  const kts = Math.round(ac.airspeed * KTS);
  const feet = Math.round(Math.max(0, ac.altitude) * FT);
  const thrP = Math.round(ac.throttle * 100);
  let headingDeg = (-ac.heading * 180 / Math.PI) % 360;
  if (headingDeg < 0) headingDeg += 360;
  spd.innerHTML = kts + ' <span>kts</span>';
  alt.innerHTML = feet + ' <span>ft</span>';
  thr.innerHTML = thrP + '<span>%</span>';
  hdg.textContent = String(Math.round(headingDeg)).padStart(3, '0') + '°';
  aoa.textContent = (ac.aoa * 180 / Math.PI).toFixed(1) + '°';
  g.textContent = ac.gForce.toFixed(1);
  vs.innerHTML = Math.round(ac.velocity.y * FT * 60) + ' <span>fpm</span>';

  const e = new THREE.Euler().setFromQuaternion(ac.quat, 'YXZ');
  const pitchDeg = e.x * 180 / Math.PI;
  const rollDeg = -e.z * 180 / Math.PI;
  const ball = document.getElementById('horizon-ball');
  ball.style.transform = 'translateY(' + (pitchDeg * 1.1) + 'px) rotate(' + rollDeg + 'deg)';

  const stall = document.getElementById('stall-warn');
  if (ac.stalled && !ac.onGround) stall.classList.remove('hidden');
  else stall.classList.add('hidden');

  const stallSpeedApprox = Math.sqrt((2 * ac.spec.mass * G) / (RHO0 * ac.spec.wingArea * (ac.spec.Cl0 + ac.spec.ClAlpha * ac.spec.stallAoA * 0.7)));
  if (ac.airspeed < stallSpeedApprox * 1.15 && !ac.onGround && ac.airspeed > 5) spd.parentElement.classList.add('hud-warn');
  else spd.parentElement.classList.remove('hud-warn');
}

// ── function: populateMenu ──
function populateMenu() {
  const aList = document.getElementById('aircraft-list');
  const mList = document.getElementById('mission-list');
  aList.innerHTML = '';
  mList.innerHTML = '';
  Object.values(AIRCRAFT).forEach((a) => {
    const d = document.createElement('div');
    d.className = 'card';
    d.dataset.id = a.id;
    d.innerHTML = '<div class="name">' + a.name + '</div><div class="sub">' + a.sub + '</div>' +
      '<div class="stats">' +
      '<span class="chip">Mass ' + (a.mass / 1000).toFixed(1) + 't</span>' +
      '<span class="chip">Thrust ' + Math.round(a.thrustMax / 1000) + 'kN</span>' +
      '<span class="chip">Vmax ' + Math.round(a.maxSpeed * KTS) + 'kts</span>' +
      '</div>';
    aList.appendChild(d);
  });
  Object.values(MISSIONS).forEach((m) => {
    const d = document.createElement('div');
    d.className = 'card';
    d.dataset.id = m.id;
    d.innerHTML = '<div class="name">' + m.name + '</div><div class="sub">' + m.sub + '</div>' +
      '<div class="stats">' + m.chips.map((c) => '<span class="chip">' + c + '</span>').join('') + '</div>';
    mList.appendChild(d);
  });
}

// ── function: createTakeoffMission ──
function createTakeoffMission(game) {
  const state = {
    phase: 'hold',
    timer: 0,
    message: 'Throttle up (Shift) and take off. Climb to 1,500 ft.',
    touchdowns: 0
  };
  function reset() {
    state.phase = 'hold';
    state.timer = 0;
    state.message = 'Throttle up (Shift) and take off. Climb to 1,500 ft.';
    state.touchdowns = 0;
    game.ac.position.set(0, terrainHeight(0, 820) + 1.5, 820);
    game.ac.velocity.set(0, 0, 0);
    game.ac.quat.setFromEuler(new THREE.Euler(0, 0, 0, 'YXZ'));
    game.ac.angularVel.set(0, 0, 0);
    game.ac.gearDown = true;
    game.ac.alive = true;
    game.input.setThrottle(0.35);
  }
  function update(dt) {
    const ac = game.ac;
    const altFt = ac.altitude * FT;
    const spd = ac.airspeed * KTS;
    const onRunway = Math.abs(ac.position.x) < 55 && Math.abs(ac.position.z) < 1200;

    if (!ac.alive) {
      game.endMission(false, 'Hard landing / crash. Manage descent rate and flare.');
      return;
    }

    if (state.phase === 'hold' || state.phase === 'roll') {
      if (spd > 20) state.phase = 'roll';
      if (!ac.onGround && altFt > 50) {
        state.phase = 'climb';
        state.message = 'Positive rate. Climb to 1,500 ft, then fly a circuit.';
      }
      if (ac.onGround && ac.position.z < -900 && spd < 8) {
        game.endMission(false, 'Runway overrun. Rotate earlier with more speed/power.');
      }
    } else if (state.phase === 'climb') {
      if (altFt > 1500) {
        state.phase = 'pattern';
        state.message = 'Pattern altitude. Enter downwind and land on the runway.';
        state.timer = 0;
      }
    } else if (state.phase === 'pattern') {
      state.timer += dt;
      if (ac.onGround && onRunway && spd < 90) {
        state.phase = 'landed';
        const rate = Math.abs(ac.velocity.y);
        if (rate < 2.5) {
          game.endMission(true, 'Greased it! Touchdown ' + spd.toFixed(0) + ' kts, sink ' + (rate * FT).toFixed(0) + ' ft/s.');
        } else if (rate < 6) {
          game.endMission(true, 'Acceptable landing. Sink rate ' + (rate * FT).toFixed(0) + ' ft/s.');
        } else {
          game.endMission(false, 'Heavy landing destroyed the gear.');
        }
      }
      if (state.timer > 320) {
        game.endMission(false, 'Time expired. Complete the circuit sooner.');
      }
    }
    document.getElementById('mission-banner').textContent = state.message +
      '  |  ' + Math.round(altFt) + ' ft · ' + Math.round(spd) + ' kts';
  }
  return { reset, update, id: 'takeoff' };
}

// ── function: createDogfightMission ──
function createDogfightMission(game) {
  const enemies = [];
  const bullets = [];
  let score = 0;
  let spawnTimer = 0;
  let fireCooldown = 0;
  let wave = 0;

  function spawnEnemy() {
    const ac = game.ac;
    const ang = Math.random() * Math.PI * 2;
    const dist = 400 + Math.random() * 280;
    const pos = ac.position.clone().add(new THREE.Vector3(
      Math.cos(ang) * dist,
      40 + Math.random() * 100,
      Math.sin(ang) * dist
    ));
    if (pos.y < 120) pos.y = 120 + Math.random() * 80;
    const mesh = makeEnemyMesh();
    mesh.position.copy(pos);
    game.scene.add(mesh);
    enemies.push({
      mesh, pos,
      vel: new THREE.Vector3((Math.random() - 0.5) * 30, 0, (Math.random() - 0.5) * 30),
      hp: 3,
      turn: 0.4 + Math.random() * 0.3,
      speed: 50 + Math.random() * 30,
      fireCd: 2 + Math.random() * 2,
      alive: true
    });
  }

  function reset() {
    enemies.forEach((e) => game.scene.remove(e.mesh));
    enemies.length = 0;
    bullets.forEach((b) => game.scene.remove(b.mesh));
    bullets.length = 0;
    score = 0;
    spawnTimer = 0;
    fireCooldown = 0;
    wave = 0;
    document.getElementById('score').textContent = 'SCORE 0';
    document.getElementById('score').classList.remove('hidden');
    document.getElementById('crosshair').classList.remove('hidden');

    game.ac.position.set(0, 420, 0);
    game.ac.velocity.set(0, 0, -90);
    game.ac.quat.setFromEuler(new THREE.Euler(0, 0, 0, 'YXZ'));
    game.ac.angularVel.set(0, 0, 0);
    game.ac.gearDown = false;
    game.ac.alive = true;
    game.ac._hits = 0;
    game.input.setThrottle(0.7);
    for (let i = 0; i < 3; i++) spawnEnemy();
    document.getElementById('mission-banner').textContent =
      'Engage hostiles. High-G turns bleed energy — watch airspeed.';
  }

  function fireBullet(origin, dir, isPlayer) {
    const mesh = makeBullet(isPlayer);
    mesh.position.copy(origin);
    game.scene.add(mesh);
    bullets.push({
      mesh,
      vel: dir.clone().normalize().multiplyScalar(isPlayer ? 450 : 300),
      life: 2.0,
      isPlayer
    });
  }

  function update(dt) {
    const ac = game.ac;
    if (!ac.alive) {
      game.endMission(false, 'Aircraft destroyed. Final score: ' + score);
      return;
    }
    // Keep dogfight above ground death spiral-ish
    if (ac.position.y < 30 && ac.velocity.y < -5) {
      // soft floor boost to keep combat plane in fight
      ac.velocity.y += 25 * dt;
    }

    fireCooldown = Math.max(0, fireCooldown - dt);
    if (game.input.state.fire && fireCooldown <= 0) {
      fireCooldown = 0.1;
      const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(ac.quat);
      const origin = ac.position.clone().add(fwd.clone().multiplyScalar(8));
      fireBullet(origin, fwd, true);
    }

    for (const e of enemies) {
      if (!e.alive) continue;
      const toPlayer = ac.position.clone().sub(e.pos);
      const dist = toPlayer.length();
      const desired = toPlayer.normalize().multiplyScalar(e.speed);
      // Lead intercept soft
      desired.add(ac.velocity.clone().multiplyScalar(0.15));
      e.vel.lerp(desired, dt * e.turn);
      if (e.pos.y < 100) e.vel.y += 25 * dt;
      if (e.pos.y > 650) e.vel.y -= 18 * dt;
      e.pos.addScaledVector(e.vel, dt);
      e.mesh.position.copy(e.pos);
      e.mesh.lookAt(e.pos.clone().add(e.vel));

      e.fireCd -= dt;
      if (e.fireCd <= 0 && dist < 380) {
        const dir = ac.position.clone().sub(e.pos).normalize();
        const aim = e.vel.clone().normalize();
        if (dir.dot(aim) > 0.94 && dist > 40) {
          fireBullet(e.pos.clone(), dir, false);
          e.fireCd = 1.4 + Math.random() * 1.5;
        } else {
          e.fireCd = 0.4;
        }
      }
    }

    for (let i = bullets.length - 1; i >= 0; i--) {
      const b = bullets[i];
      b.life -= dt;
      b.mesh.position.addScaledVector(b.vel, dt);
      if (b.life <= 0) {
        game.scene.remove(b.mesh);
        bullets.splice(i, 1);
        continue;
      }
      if (b.isPlayer) {
        for (const e of enemies) {
          if (!e.alive) continue;
          if (b.mesh.position.distanceTo(e.pos) < 7) {
            e.hp--;
            game.scene.remove(b.mesh);
            bullets.splice(i, 1);
            if (e.hp <= 0) {
              e.alive = false;
              game.scene.remove(e.mesh);
              game.explosions.push(...makeExplosion(game.scene, e.pos.clone()));
              score += 100;
              document.getElementById('score').textContent = 'SCORE ' + score;
            }
            break;
          }
        }
      } else if (b.mesh.position.distanceTo(ac.position) < 5.5) {
        game.scene.remove(b.mesh);
        bullets.splice(i, 1);
        ac.velocity.add(new THREE.Vector3(
          (Math.random() - 0.5) * 8,
          (Math.random() - 0.5) * 6,
          (Math.random() - 0.5) * 8
        ));
        ac._hits = (ac._hits || 0) + 1;
        if (ac._hits >= 6) {
          ac.alive = false;
          game.explosions.push(...makeExplosion(game.scene, ac.position.clone()));
        }
      }
    }

    spawnTimer += dt;
    const alive = enemies.filter((e) => e.alive).length;
    if (alive === 0) {
      wave++;
      if (wave >= 4) {
        game.endMission(true, 'Airspace clear! Score: ' + score);
        return;
      }
      for (let i = 0; i < 2 + wave; i++) spawnEnemy();
      document.getElementById('mission-banner').textContent =
        'Wave ' + (wave + 1) + ' inbound. Score ' + score;
    } else if (spawnTimer > 28 && alive < 4) {
      spawnTimer = 0;
      spawnEnemy();
    }

    document.getElementById('mission-banner').textContent =
      'Hostiles ' + alive + ' · Wave ' + (wave + 1) + '/4 · Score ' + score +
      '  |  G ' + ac.gForce.toFixed(1) + ' · ' + Math.round(ac.airspeed * KTS) + ' kts · Hits ' +
      (ac._hits || 0) + '/6';
  }

  function cleanup() {
    enemies.forEach((e) => game.scene.remove(e.mesh));
    enemies.length = 0;
    bullets.forEach((b) => game.scene.remove(b.mesh));
    bullets.length = 0;
    document.getElementById('score').classList.add('hidden');
    document.getElementById('crosshair').classList.add('hidden');
  }

  return { reset, update, cleanup, id: 'dogfight' };
}

// ── function: createSky ──
function createSky(scene) {
  scene.background = new THREE.Color(0x87b8e0);
  scene.fog = new THREE.Fog(0xa8cce8, 600, 4200);

  const hemi = new THREE.HemisphereLight(0xd0e8ff, 0x5a4a28, 0.85);
  scene.add(hemi);
  const sun = new THREE.DirectionalLight(0xfff2d8, 1.35);
  sun.position.set(400, 800, 200);
  sun.castShadow = true;
  sun.shadow.mapSize.set(2048, 2048);
  sun.shadow.camera.near = 10;
  sun.shadow.camera.far = 2500;
  sun.shadow.camera.left = -600;
  sun.shadow.camera.right = 600;
  sun.shadow.camera.top = 600;
  sun.shadow.camera.bottom = -600;
  scene.add(sun);

  const amb = new THREE.AmbientLight(0x406080, 0.35);
  scene.add(amb);

  // Gradient sky dome
  const skyGeo = new THREE.SphereGeometry(4500, 32, 16);
  const skyMat = new THREE.ShaderMaterial({
    side: THREE.BackSide,
    depthWrite: false,
    uniforms: {
      topColor: { value: new THREE.Color(0x1a4a8a) },
      midColor: { value: new THREE.Color(0x6eb0e0) },
      botColor: { value: new THREE.Color(0xc8dce8) }
    },
    vertexShader: [
      'varying vec3 vWPos;',
      'void main(){',
      '  vec4 wp = modelMatrix * vec4(position,1.0);',
      '  vWPos = wp.xyz;',
      '  gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);',
      '}'
    ].join('\n'),
    fragmentShader: [
      'uniform vec3 topColor, midColor, botColor;',
      'varying vec3 vWPos;',
      'void main(){',
      '  float h = normalize(vWPos).y;',
      '  vec3 col = mix(botColor, midColor, smoothstep(-0.15, 0.25, h));',
      '  col = mix(col, topColor, smoothstep(0.2, 0.9, h));',
      '  gl_FragColor = vec4(col, 1.0);',
      '}'
    ].join('\n')
  });
  const sky = new THREE.Mesh(skyGeo, skyMat);
  scene.add(sky);

  // Sun disc
  const sunMesh = new THREE.Mesh(
    new THREE.SphereGeometry(50, 16, 16),
    new THREE.MeshBasicMaterial({ color: 0xfff2c8 })
  );
  sunMesh.position.set(1200, 900, 600);
  scene.add(sunMesh);

  // Clouds
  const cloudMat = new THREE.MeshStandardMaterial({
    color: 0xffffff, transparent: true, opacity: 0.5, roughness: 1, metalness: 0, side: THREE.DoubleSide
  });
  for (let i = 0; i < 50; i++) {
    const c = new THREE.Mesh(new THREE.PlaneGeometry(100 + Math.random() * 180, 35 + Math.random() * 50), cloudMat);
    c.position.set(
      (Math.random() - 0.5) * 5000,
      300 + Math.random() * 400,
      (Math.random() - 0.5) * 5000
    );
    c.rotation.y = Math.random() * Math.PI;
    c.rotation.x = -Math.PI / 2 + (Math.random() - 0.5) * 0.25;
    scene.add(c);
  }
  return { sun, sky };
}

// ── function: createGame ──
function createGame() {
  const canvas = document.getElementById('c');
  const renderer = new THREE.WebGLRenderer({
    canvas,
    antialias: true,
    powerPreference: 'high-performance',
    preserveDrawingBuffer: true
  });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.shadowMap.enabled = true;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.15;

  const scene = new THREE.Scene();
  createSky(scene);
  const terrain = createTerrain(scene);

  const camera = new THREE.PerspectiveCamera(68, window.innerWidth / window.innerHeight, 0.5, 8000);
  const camRig = createCameraRig(camera);
  const input = createInput();

  const game = {
    renderer, scene, camera, terrain, camRig, input,
    ac: null,
    planeMesh: null,
    mission: null,
    mode: 'menu',
    selectedAircraft: null,
    selectedMission: null,
    explosions: [],
    clock: new THREE.Clock(),
    endMission: null
  };

  game.endMission = function (success, msg) {
    if (game.mode !== 'fly') return;
    game.mode = 'end';
    document.getElementById('hud').classList.add('hidden');
    document.getElementById('end').classList.remove('hidden');
    document.getElementById('end-title').textContent = success ? 'MISSION COMPLETE' : 'MISSION FAILED';
    document.getElementById('end-msg').textContent = msg;
    if (game.mission && game.mission.cleanup) game.mission.cleanup();
  };

  function attachPlane(spec) {
    if (game.planeMesh) {
      scene.remove(game.planeMesh);
      game.planeMesh = null;
    }
    game.planeMesh = makePlaneMesh(spec);
    scene.add(game.planeMesh);
    game.ac = createAircraftState(spec, new THREE.Vector3(0, 2, 800), 0);
  }

  function startFlight() {
    if (!game.selectedAircraft || !game.selectedMission) return;
    attachPlane(AIRCRAFT[game.selectedAircraft]);
    if (game.mission && game.mission.cleanup) game.mission.cleanup();
    if (game.selectedMission === 'takeoff') game.mission = createTakeoffMission(game);
    else game.mission = createDogfightMission(game);
    game.mission.reset();
    camRig.reset();
    game.mode = 'fly';
    game.ac._hits = 0;
    document.getElementById('overlay').classList.add('hidden');
    document.getElementById('pause').classList.add('hidden');
    document.getElementById('end').classList.add('hidden');
    document.getElementById('hud').classList.remove('hidden');
    game.clock.start();
  }

  function toMenu() {
    game.mode = 'menu';
    if (game.mission && game.mission.cleanup) game.mission.cleanup();
    game.mission = null;
    document.getElementById('hud').classList.add('hidden');
    document.getElementById('pause').classList.add('hidden');
    document.getElementById('end').classList.add('hidden');
    document.getElementById('overlay').classList.remove('hidden');
    document.getElementById('score').classList.add('hidden');
    document.getElementById('crosshair').classList.add('hidden');
  }

  populateMenu();
  const btnStart = document.getElementById('btn-start');
  function refreshStart() {
    btnStart.disabled = !(game.selectedAircraft && game.selectedMission);
  }
  document.getElementById('aircraft-list').addEventListener('click', (e) => {
    const card = e.target.closest('.card');
    if (!card) return;
    document.querySelectorAll('#aircraft-list .card').forEach((c) => c.classList.remove('selected'));
    card.classList.add('selected');
    game.selectedAircraft = card.dataset.id;
    refreshStart();
  });
  document.getElementById('mission-list').addEventListener('click', (e) => {
    const card = e.target.closest('.card');
    if (!card) return;
    document.querySelectorAll('#mission-list .card').forEach((c) => c.classList.remove('selected'));
    card.classList.add('selected');
    game.selectedMission = card.dataset.id;
    refreshStart();
  });
  btnStart.addEventListener('click', startFlight);
  document.getElementById('btn-resume').addEventListener('click', () => {
    game.mode = 'fly';
    document.getElementById('pause').classList.add('hidden');
    document.getElementById('hud').classList.remove('hidden');
  });
  document.getElementById('btn-menu').addEventListener('click', toMenu);
  document.getElementById('btn-again').addEventListener('click', startFlight);
  document.getElementById('btn-end-menu').addEventListener('click', toMenu);

  window.addEventListener('keydown', (e) => {
    if (e.code === 'Escape') {
      if (game.mode === 'fly') {
        game.mode = 'pause';
        document.getElementById('pause').classList.remove('hidden');
      } else if (game.mode === 'pause') {
        game.mode = 'fly';
        document.getElementById('pause').classList.add('hidden');
      }
    }
  });

  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  camera.position.set(50, 22, 140);
  camera.lookAt(0, 2, 0);

  function tick() {
    requestAnimationFrame(tick);
    const rawDt = Math.min(0.05, game.clock.getDelta());

    if (game.mode === 'menu') {
      const t = performance.now() * 0.00015;
      camera.position.x = Math.sin(t) * 100 + 30;
      camera.position.y = 22 + Math.sin(t * 0.7) * 3;
      camera.position.z = Math.cos(t) * 100 + 90;
      camera.lookAt(0, 3, 0);
      renderer.render(scene, camera);
      return;
    }

    if (game.mode === 'fly' && game.ac) {
      const inp = input.update(rawDt);
      integrateFlight(game.ac, inp, terrain.heightAt, rawDt, {
        autoGear: game.selectedMission === 'takeoff'
      });
      game.planeMesh.position.copy(game.ac.position);
      game.planeMesh.quaternion.copy(game.ac.quat);
      const prop = game.planeMesh.getObjectByName('prop');
      if (prop) prop.rotation.z += game.ac.throttle * 40 * rawDt + game.ac.airspeed * 0.05 * rawDt;
      const glow = game.planeMesh.getObjectByName('exhaustGlow');
      if (glow) {
        const t = game.ac.throttle;
        glow.scale.setScalar(0.4 + t * 1.4);
        glow.material.color.setHSL(0.55 - t * 0.45, 1, 0.45 + t * 0.25);
        glow.visible = t > 0.05;
      }

      camRig.update(game.ac, rawDt);
      updateHUD(game.ac);
      if (game.mission) game.mission.update(rawDt);

      for (let i = game.explosions.length - 1; i >= 0; i--) {
        const p = game.explosions[i];
        p.life -= rawDt;
        p.m.position.addScaledVector(p.v, rawDt);
        p.v.y -= 15 * rawDt;
        p.m.scale.multiplyScalar(0.96);
        if (p.life <= 0) {
          scene.remove(p.m);
          game.explosions.splice(i, 1);
        }
      }
    }

    renderer.render(scene, camera);
  }

  const firstA = document.querySelector('#aircraft-list .card');
  const firstM = document.querySelector('#mission-list .card');
  if (firstA) { firstA.classList.add('selected'); game.selectedAircraft = firstA.dataset.id; }
  if (firstM) { firstM.classList.add('selected'); game.selectedMission = firstM.dataset.id; }
  refreshStart();

  tick();
  return game;
}

// ── main ──
window.game = createGame();
</script>
</body>
</html>
<!-- agent-meta {"model":"x-ai/grok-4.5","provider":"openrouter","persona":"composer","sessionId":"f574a2fc-5636-419b-af27-ca845d4a603c","tokensIn":2350045,"tokensOut":60094,"tokensTotal":2410139,"turns":52,"toolCalls":68,"failedToolCalls":1,"timestamp":"2026-07-17T17:26:57.324Z"} -->