← Flight Simulator results

Flight Simulator

Claude haiku 5.5 · full

Initial view of Flight Simulator

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · de5cb62838
This prompt is designed to be given to an AI model or a development team to establish a clear, high-fidelity goal for building the simulator. It emphasizes technical constraints and the critical importance of realistic physics.

***

# 🚀 Three.js Flight Simulator Prompt: Apex Aero

## 🎯 Project Goal
Design and develop a high-fidelity, physically accurate 3D flight simulator implemented entirely in HTML using **Three.js** (or a compatible framework like Babylon.js) and pure JavaScript/TypeScript. The core experience must prioritize realistic aerodynamics, inertial forces, and challenging gameplay across diverse scenarios.

## 🛠️ Technical Stack & Core Requirements
1.  **Engine:** Three.js (mandatory for all rendering and scene management).
2.  **Language:** JavaScript/TypeScript.
3.  **Physics Engine:** Implement a custom, highly detailed Newtonian physics model that simulates flight dynamics realistically (not just simple linear movement).
4.  **Input:** Mouse/Keyboard controls for pitch, roll, yaw, throttle, and thrust management.

## ✨ Feature Breakdown

### 1. Aircraft Selection & Differentiation (The Fleet)
Implement a minimum of **three distinct aircraft models**. These planes must possess mathematically differentiated attributes that directly affect flight physics:

*   **Attribute Examples:** Max Speed, Thrust/Engine Power, Lift Coefficient, Drag Profile, Turning Radius, Weight, and Maneuverability Response.
*   **Visuals:** Each plane must have a unique, high-quality 3D model.

### 2. Terrain Generation (The World)
*   **Procedural Generation:** The environment must be procedurally generated to create vast, varied, and visually stunning landscapes (mountains, valleys, coastlines).
*   **Detail:** Implement complex geometry with accurate heightmaps and texture mapping to ensure realistic visual scale and topographical awareness for the pilot.
*   **Collision Detection:** Terrain interaction must accurately reflect ground friction and altitude constraints.

### 3. Gameplay Scenarios (The Missions)
The game must include two primary, distinct gameplay modes:

#### A. Takeoff/Landing Scenario (Precision Flight)
*   Focus on managing altitude, airspeed, and engine power precisely.
*   Requires accurate simulation of lift-off physics, climb rates, descent control, stall limits, and runway requirements based on the chosen aircraft's weight and performance characteristics.

#### B. Dogfight Scenario (High-Speed Maneuvering)
*   Focus on high-G maneuvering, managing speed, angle of attack, and inertia in a dynamic combat environment.
*   Requires accurate simulation of slipstream effects, banking dynamics, and the physical limits of the aircraft during aggressive turns and evasive maneuvers.

## 🔬 Physics & Fidelity (The Critical Focus)
**This is the single most important requirement.** The game must feel *physically accurate* and challenging.

1.  **Aerodynamics:** Implement realistic drag calculation (profile drag vs. induced drag), lift generation based on angle of attack, and realistic air density effects based on altitude.
2.  **Inertia & Momentum:** Aircraft movement must account for inertia. Sudden changes in direction or thrust must result in realistic lag, pitch/roll response time, and momentum carry-over.
3.  **Engine Response:** Throttle input must accurately translate to force, RPM changes, engine heat/stress (optional visual feedback), and realistic acceleration rates based on the plane's power-to-weight ratio.
4.  **Atmospheric Effects:** Altitude must affect air density and performance; this effect must be consistent across all scenarios.

## 🎧 Audio Immersion
Sound design is mandatory for full immersion. The audio experience must be dynamic and directly linked to the physics calculations.

*   **Engine Sound:** Engine noise, pitch, and volume must change realistically based on throttle input, RPM, acceleration, and altitude (accounting for air density).
*   **Aerodynamic Sounds:** Implement realistic sound effects for airflow over the wings and control surfaces (e.g., whistling or roaring based on speed/angle of attack).
*   **Environmental Audio:** Include dynamic ambient sounds that reflect the environment (e.g., wind noise increasing at higher altitudes, distant rumble of mountains).

## 🎨 Aesthetic & User Experience
*   **Visual Style:** Cinematic, high-definition realism. Use modern lighting techniques (PBR materials) to enhance the visual beauty of the procedural terrain.
*   **UI/HUD:** The Heads-Up Display (HUD) must clearly display critical flight data (Altitude, Airspeed, Pitch, Roll, Engine Status) in a clean, intuitive format.

## ✅ Deliverables Summary
The result should be an interactive application demonstrating:
1.  A fully functional Three.js scene with procedurally generated terrain.
2.  Three selectable aircraft with distinct physics profiles.
3.  Functional controls for realistic flight dynamics.
4.  Working Takeoff/Landing and Dogfight scenarios, demonstrating the accurate interaction of the defined physics models.
Inspect original source 83,417 bytes · SHA-256 225663d5c8f3
<!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 Simulator</title>
<style>
  :root { --hud: #9dffb0; --hud-dim: rgba(157,255,176,0.45); --warn: #ff5a4a; --panel: rgba(8,14,20,0.72); }
  * { box-sizing: border-box; }
  html, body { margin:0; height:100%; overflow:hidden; background:#000; font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color:#e8f1f7; user-select:none; }
  canvas#gl { position:fixed; inset:0; width:100%; height:100%; display:block; }

  /* ---------- MENU ---------- */
  #menu { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; background: radial-gradient(ellipse at center, rgba(10,20,35,0.55), rgba(0,0,0,0.85)); z-index:20; overflow:auto; }
  #menu.hidden, #pause.hidden, #result.hidden { display:none; }
  .panel { background: var(--panel); border:1px solid rgba(255,255,255,0.12); border-radius:14px; padding:28px 34px; max-width:980px; width:92%; backdrop-filter: blur(8px); box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
  h1 { margin:0 0 4px; font-size:34px; letter-spacing:4px; font-weight:300; }
  h1 span { color:#ffb13b; font-weight:700; }
  .sub { color:#9fb3c4; margin:0 0 22px; font-size:14px; }
  h2 { font-size:12px; letter-spacing:3px; text-transform:uppercase; color:#8fa7ba; margin:20px 0 10px; font-weight:600; }
  .cards { display:grid; grid-template-columns: repeat(3,1fr); gap:12px; }
  .card { background: rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); border-radius:10px; padding:14px; cursor:pointer; transition: all .15s; }
  .card:hover { background: rgba(255,255,255,0.08); }
  .card.sel { border-color:#ffb13b; box-shadow: 0 0 0 1px #ffb13b inset; background: rgba(255,177,59,0.08); }
  .card h3 { margin:0 0 4px; font-size:16px; }
  .card .role { font-size:11px; text-transform:uppercase; letter-spacing:2px; color:#ffb13b; }
  .card p { font-size:12.5px; color:#b6c6d3; margin:8px 0; line-height:1.4; }
  .stats { display:grid; grid-template-columns:1fr 1fr; gap:3px 10px; font-size:11.5px; color:#9fb3c4; }
  .stats b { color:#e8f1f7; font-weight:500; }
  .bars { margin-top:8px; }
  .bar-row { display:flex; align-items:center; gap:6px; font-size:10px; color:#8fa7ba; }
  .bar-row .lbl { width:58px; }
  .bar-row .track { flex:1; height:5px; background:rgba(255,255,255,0.1); border-radius:3px; overflow:hidden; }
  .bar-row .fill { height:100%; background:linear-gradient(90deg,#ffb13b,#ff6b3b); }
  .missions { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
  .mission { border:1px solid rgba(255,255,255,0.1); border-radius:10px; padding:14px; cursor:pointer; background: rgba(255,255,255,0.03); }
  .mission.sel { border-color:#5ec8ff; background: rgba(94,200,255,0.08); }
  .mission h3 { margin:0 0 6px; font-size:15px; color:#5ec8ff; }
  .mission p { margin:0; font-size:12.5px; color:#b6c6d3; line-height:1.45; }
  .row { display:flex; gap:12px; align-items:center; justify-content:space-between; margin-top:24px; flex-wrap:wrap; }
  button { font: inherit; font-size:14px; padding:11px 22px; border-radius:8px; border:0; cursor:pointer; color:#1b1202; background:#ffb13b; font-weight:600; letter-spacing:.5px; }
  button:hover { filter:brightness(1.08); }
  button.ghost { background: transparent; color:#e8f1f7; border:1px solid rgba(255,255,255,0.25); font-weight:500; }
  .controls { font-size:12px; color:#9fb3c4; line-height:1.7; columns:2; }
  .controls kbd { display:inline-block; min-width:22px; padding:1px 6px; border:1px solid rgba(255,255,255,0.3); border-bottom-width:2px; border-radius:4px; font-family: monospace; color:#e8f1f7; font-size:11px; text-align:center; }

  /* ---------- HUD ---------- */
  #hud { position:fixed; inset:0; pointer-events:none; z-index:10; font-family: "Consolas", "Menlo", "Courier New", monospace; color:var(--hud); text-shadow: 0 0 4px rgba(0,0,0,0.9); }
  #hud.hidden { display:none; }
  .tape { position:absolute; top:50%; transform:translateY(-50%); width:150px; height:260px; border:1px solid var(--hud-dim); background: rgba(0,10,5,0.35); overflow:hidden; }
  #spdTape { left:24px; }
  #altTape { right:24px; }
  .tape .lbl { position:absolute; top:4px; left:0; right:0; text-align:center; font-size:11px; letter-spacing:2px; color:var(--hud-dim); }
  .tape .val { position:absolute; top:50%; left:0; right:0; transform:translateY(-50%); text-align:center; font-size:26px; font-weight:700; background: rgba(0,0,0,0.45); padding:4px 0; border-top:1px solid var(--hud-dim); border-bottom:1px solid var(--hud-dim); }
  .tape .sub2 { position:absolute; bottom:6px; left:0; right:0; text-align:center; font-size:12px; color:var(--hud); }
  .tape .ticks { position:absolute; top:0; bottom:0; width:100%; font-size:11px; color:var(--hud-dim); }
  .tape .ticks div { position:absolute; left:0; right:0; text-align:center; }
  #spdTape .ticks { right:0; }
  #altTape .ticks { right:0; }
  .gauge { position:absolute; left:24px; bottom:24px; width:200px; font-size:12px; }
  .gauge .r { display:flex; justify-content:space-between; margin-bottom:3px; }
  .gbar { height:8px; border:1px solid var(--hud-dim); position:relative; margin-bottom:10px; }
  .gbar .f { position:absolute; left:0; top:0; bottom:0; background:var(--hud); width:0%; }
  #rightInfo { position:absolute; right:24px; bottom:24px; width:200px; font-size:12px; text-align:right; }
  #rightInfo .r { margin-bottom:4px; }
  #horizonWrap { position:absolute; left:50%; top:50%; width:260px; height:260px; transform:translate(-50%,-50%); }
  #horizonBox { position:absolute; inset:0; overflow:hidden; border-radius:50%; }
  #horizonInner { position:absolute; left:-50%; top:-50%; width:200%; height:200%; transform-origin:50% 50%; }
  #sky { position:absolute; left:0; right:0; top:0; height:50%; background: linear-gradient(rgba(60,120,200,0.0), rgba(60,120,200,0.0)); }
  #pitchLadder { position:absolute; left:0; right:0; top:50%; height:0; }
  #pitchLadder .rung { position:absolute; left:50%; transform:translateX(-50%); width:120px; text-align:center; font-size:11px; color:var(--hud); }
  #pitchLadder .rung .ln { height:1px; background:var(--hud); margin:0 auto; width:70px; }
  #bankArc { position:absolute; left:50%; top:-6px; width:160px; height:80px; transform:translateX(-50%); }
  #aimCross { position:absolute; left:50%; top:50%; width:40px; height:40px; transform:translate(-50%,-50%); }
  #aimCross::before, #aimCross::after { content:""; position:absolute; background:var(--hud); }
  #aimCross::before { left:50%; top:0; width:1px; height:14px; transform:translateX(-50%); box-shadow:0 26px 0 var(--hud); }
  #aimCross::after { top:50%; left:0; height:1px; width:14px; transform:translateY(-50%); box-shadow: 26px 0 0 var(--hud); }
  #flightPath { position:absolute; width:18px; height:18px; border:1px solid var(--hud); border-radius:50%; transform:translate(-50%,-50%); }
  #flightPath::before, #flightPath::after { content:""; position:absolute; background:var(--hud); }
  #flightPath::before { left:-10px; top:8px; width:8px; height:1px; }
  #flightPath::after { right:-10px; top:8px; width:8px; height:1px; }
  #hdgStrip { position:absolute; top:14px; left:50%; transform:translateX(-50%); width:420px; height:36px; border:1px solid var(--hud-dim); background: rgba(0,10,5,0.35); overflow:hidden; }
  #hdgStrip .ticks { position:absolute; top:0; bottom:0; left:0; right:0; }
  #hdgStrip .ticks span { position:absolute; top:6px; font-size:11px; transform:translateX(-50%); color:var(--hud); }
  #hdgStrip .ticks i { position:absolute; bottom:0; width:1px; height:6px; background:var(--hud-dim); }
  #hdgStrip .cur { position:absolute; left:50%; top:0; bottom:0; width:1px; background:#ffe06b; }
  #hdgVal { position:absolute; top:52px; left:50%; transform:translateX(-50%); font-size:18px; font-weight:700; }
  #vsVal { position:absolute; }
  #warn { position:absolute; top:28%; left:50%; transform:translateX(-50%); font-size:30px; font-weight:800; color:var(--warn); letter-spacing:4px; display:none; animation: blink .4s steps(2,start) infinite; }
  #msg { position:absolute; bottom:80px; left:50%; transform:translateX(-50%); font-size:16px; text-align:center; color:#fff; min-width:400px; letter-spacing:1px; }
  #obj { position:absolute; top:70px; left:24px; font-size:13px; line-height:1.6; max-width:420px; }
  #obj .t { color:#ffe06b; letter-spacing:2px; font-size:11px; }
  #score { position:absolute; top:70px; right:24px; text-align:right; font-size:13px; line-height:1.6; }
  #throttleBox { position:absolute; left:50%; bottom:20px; transform:translateX(-50%); width:320px; font-size:11px; }
  #throttleBox .track { height:6px; border:1px solid var(--hud-dim); position:relative; }
  #throttleBox .fill { position:absolute; left:0; top:0; bottom:0; background:var(--hud); width:0; }
  #throttleBox .lbl { display:flex; justify-content:space-between; margin-bottom:3px; }
  #blackout { position:fixed; inset:0; pointer-events:none; z-index:9; background: radial-gradient(ellipse at center, rgba(0,0,0,0) 30%, rgba(0,0,0,1) 85%); opacity:0; transition: opacity .08s linear; }
  #redout { position:fixed; inset:0; pointer-events:none; z-index:8; background: radial-gradient(ellipse at center, rgba(0,0,0,0) 40%, rgba(255,40,40,0.7) 100%); opacity:0; }
  #damage { position:fixed; inset:0; pointer-events:none; z-index:8; background: rgba(255,0,0,0.0); transition: background .25s; }
  .tgt { position:absolute; width:46px; height:46px; border:1px solid #ff5a4a; transform:translate(-50%,-50%); display:none; }
  .tgt .tl { position:absolute; left:-2px; top:-18px; font-size:10px; white-space:nowrap; color:#ff8a7a; }
  @keyframes blink { 50% { opacity:0; } }

  /* ---------- PAUSE ---------- */
  #pause { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.5); z-index:25; }
  #result { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.55); z-index:26; }
  #result .panel { text-align:center; }
  #result h1 { font-size:28px; }
  #result .big { font-size:54px; font-weight:700; color:#ffb13b; margin:10px 0; }
  #result .lines { text-align:left; display:inline-block; font-size:14px; color:#b6c6d3; line-height:1.8; }
  #startHint { position:fixed; left:50%; bottom:14px; transform:translateX(-50%); z-index:5; font-size:11px; color:#8fa7ba; }
</style>
</head>
<body>
<canvas id="gl"></canvas>

<div id="menu">
  <div class="panel">
    <h1>APEX <span>AERO</span></h1>
    <p class="sub">Newtonian flight model · altitude-dependent air density · lift, induced drag and stall physics</p>

    <h2>Select Aircraft</h2>
    <div class="cards" id="planeCards"></div>

    <h2>Select Mission</h2>
    <div class="missions" id="missionCards">
      <div class="mission sel" data-mission="takeoff">
        <h3>Precision Flight — Takeoff &amp; Landing</h3>
        <p>Start on the runway. Manage throttle, rotation speed and climb to 1,000 m. Circle back and touch down on the centerline with a gentle sink rate, within the touchdown zone, before the aircraft stops.</p>
      </div>
      <div class="mission" data-mission="dogfight">
        <h3>Dogfight — High-G Maneuvering</h3>
        <p>Hostile fighters are vectoring in at 1–2 km. Use energy, turn rate and gun solutions to shoot them down. Exceed the airframe's G limit and the aircraft breaks apart. Blackout and red-out effects follow hard turns.</p>
      </div>
    </div>

    <div class="row">
      <div class="controls">
        <div><kbd>W</kbd><kbd>S</kbd> pitch (nose down / up)</div>
        <div><kbd>A</kbd><kbd>D</kbd> roll left / right</div>
        <div><kbd>Q</kbd><kbd>E</kbd> rudder (yaw)</div>
        <div><kbd>Shift</kbd> / <kbd>Ctrl</kbd> throttle up / down</div>
        <div><kbd>X</kbd> idle · <kbd>Z</kbd> full throttle</div>
        <div><kbd>F</kbd> flaps · <kbd>G</kbd> gear · <kbd>B</kbd> brakes</div>
        <div><kbd>Space</kbd> gun (dogfight) · <kbd>C</kbd> camera</div>
        <div><kbd>M</kbd> mouse stick · <kbd>P</kbd> / <kbd>Esc</kbd> pause · <kbd>R</kbd> restart</div>
      </div>
      <button id="startBtn">START FLIGHT</button>
    </div>
  </div>
</div>

<div id="hud" class="hidden">
  <div id="hdgStrip"><div class="ticks" id="hdgTicks"></div><div class="cur"></div></div>
  <div id="hdgVal">000°</div>

  <div class="tape" id="spdTape"><div class="lbl">AIRSPEED KT</div><div class="ticks" id="spdTicks"></div><div class="val" id="spdVal">0</div><div class="sub2" id="machVal">M 0.00</div></div>
  <div class="tape" id="altTape"><div class="lbl">ALTITUDE FT</div><div class="ticks" id="altTicks"></div><div class="val" id="altVal">0</div><div class="sub2" id="vsVal">VS 0</div></div>

  <div id="horizonWrap">
    <div id="horizonBox"><div id="horizonInner"><div id="sky"></div><div id="pitchLadder"></div></div></div>
    <div id="flightPath" style="left:50%; top:50%;"></div>
    <div id="aimCross" style="left:50%; top:50%; opacity:0.6;"></div>
  </div>

  <div class="gauge" id="gaugeL">
    <div class="r"><span>ENGINE RPM</span><span id="rpmVal">0%</span></div>
    <div class="gbar"><div class="f" id="rpmBar"></div></div>
    <div class="r"><span>EGT</span><span id="tempVal">0%</span></div>
    <div class="gbar"><div class="f" id="tempBar"></div></div>
    <div class="r"><span>AoA</span><span id="aoaVal">0.0°</span></div>
    <div class="gbar"><div class="f" id="aoaBar"></div></div>
    <div class="r"><span>FLAPS / GEAR</span><span id="cfgVal">UP / DN</span></div>
    <div class="r"><span>DAMAGE</span><span id="dmgVal">0%</span></div>
  </div>

  <div id="rightInfo">
    <div class="r">LOAD <b id="gVal">1.0 G</b></div>
    <div class="r">BANK <span id="bankVal">0°</span></div>
    <div class="r">PITCH <span id="pitchVal">0°</span></div>
    <div class="r">SLIP <span id="slipVal">0</span></div>
    <div class="r">DENSITY <span id="densVal">100%</span></div>
    <div class="r">AIR TEMP <span id="tmpVal">15°C</span></div>
  </div>

  <div id="obj"></div>
  <div id="score"></div>
  <div id="warn">STALL</div>
  <div id="msg"></div>

  <div id="throttleBox">
    <div class="lbl"><span>THROTTLE</span><span id="thrVal">0%</span></div>
    <div class="track"><div class="fill" id="thrBar"></div></div>
  </div>
  <div class="tgt" id="tgt"><div class="tl" id="tgtLbl"></div></div>
</div>

<div id="blackout"></div>
<div id="redout"></div>
<div id="damage"></div>

<div id="pause" class="hidden">
  <div class="panel" style="text-align:center; width:340px;">
    <h1 style="font-size:24px;">PAUSED</h1>
    <div class="row" style="flex-direction:column;">
      <button id="resumeBtn" style="width:100%;">RESUME</button>
      <button id="restartBtn" class="ghost" style="width:100%;">RESTART MISSION</button>
      <button id="menuBtn" class="ghost" style="width:100%;">AIRCRAFT / MISSION MENU</button>
    </div>
  </div>
</div>

<div id="result" class="hidden">
  <div class="panel" id="resultPanel"></div>
</div>

<div id="startHint">Click START FLIGHT · Mouse stick: press M after takeoff · Audio starts with the first click</div>

<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';

// ============================================================
//  CONSTANTS & UTILITIES
// ============================================================
const G = 9.80665;
const RHO0 = 1.225;
const T0 = 288.15;
const KT = 1.94384;   // m/s -> knots
const FT = 3.28084;   // m -> ft
const DEG = Math.PI / 180;
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); return t * t * (3 - 2 * t); };
const rand = (a, b) => a + Math.random() * (b - a);

// Runway geometry (world): centerline along -Z, threshold at z=+250, far end z=-1850
const RWY = { x0: -30, x1: 30, zNear: 300, zFar: -1900, elev: 2.0 };
const WORLD_RADIUS = 7600;

// ============================================================
//  FLEET — mathematically differentiated aircraft
// ============================================================
// mass kg, S wing area m², AR aspect ratio, CL0 zero-alpha lift, CLa lift slope /rad,
// aStall stall AoA rad, CD0 parasite drag, e Oswald efficiency, Tmax max thrust N,
// pitchPow/pitchStab/pitchDamp: pitch authority, static stability, damping
// rollPow/rollDamp, yawPow/yawStab/yawDamp, maxRate [pitch,roll,yaw] rad/s,
// spool: engine spool time constant s, maxG: structural limit, gearH: gear clearance m
const FLEET = {
  skylark: {
    name: 'Skylark T-40', role: 'Trainer', key: 'skylark',
    desc: 'Forgiving high-wing trainer. Low stall speed and generous control authority, but limited power.',
    mass: 1100, S: 16, AR: 7, CL0: 0.30, CLa: 5.0, aStall: 0.28, CD0: 0.045, e: 0.80,
    Tmax: 4200, gearCD: 0.012, flapCL: 0.35, flapCD: 0.03,
    pitchPow: 2.2, pitchStab: 2.6, pitchDamp: 1.4, rollPow: 3.0, rollDamp: 2.2,
    yawPow: 1.1, yawStab: 1.4, yawDamp: 1.2, maxRate: [1.2, 2.2, 1.0],
    spool: 1.5, maxG: 5.0, gearH: 1.2, color: 0xf2c230, accent: 0x222831, span: 11,
    stats: [['Max speed', '~185 kt'], ['Stall', '~50 kt'], ['Thrust/Weight', '0.39'], ['Empty mass', '1.1 t']],
    bars: { Speed: 0.35, Climb: 0.45, Agility: 0.6, Stall: 0.95 }
  },
  falcon: {
    name: 'Falcon F-9', role: 'Interceptor', key: 'falcon',
    desc: 'Swept-delta fighter. Excellent turn rate and high-G tolerance; needs energy management.',
    mass: 8000, S: 30, AR: 3.5, CL0: 0.20, CLa: 4.4, aStall: 0.30, CD0: 0.024, e: 0.75,
    Tmax: 52000, gearCD: 0.015, flapCL: 0.45, flapCD: 0.035,
    pitchPow: 3.6, pitchStab: 3.2, pitchDamp: 2.0, rollPow: 5.2, rollDamp: 3.6,
    yawPow: 1.8, yawStab: 2.4, yawDamp: 1.8, maxRate: [1.8, 3.4, 1.6],
    spool: 1.0, maxG: 9.0, gearH: 1.4, color: 0x8a949e, accent: 0x3d4650, span: 14,
    stats: [['Max speed', '~610 kt'], ['Stall', '~70 kt'], ['Thrust/Weight', '0.66'], ['Empty mass', '8.0 t']],
    bars: { Speed: 0.8, Climb: 0.7, Agility: 0.9, Stall: 0.5 }
  },
  raptor: {
    name: 'Raptor X-H', role: 'Heavy Jet', key: 'raptor',
    desc: 'Heavy strike jet. Enormous thrust and a dependable wing, but sluggish in roll and heavy in the turn.',
    mass: 20000, S: 60, AR: 4.0, CL0: 0.25, CLa: 4.8, aStall: 0.26, CD0: 0.028, e: 0.80,
    Tmax: 190000, gearCD: 0.02, flapCL: 0.55, flapCD: 0.05,
    pitchPow: 2.4, pitchStab: 2.8, pitchDamp: 1.8, rollPow: 2.4, rollDamp: 2.4,
    yawPow: 1.4, yawStab: 2.0, yawDamp: 1.6, maxRate: [0.9, 1.6, 0.9],
    spool: 2.4, maxG: 6.5, gearH: 1.6, color: 0x2e3b2c, accent: 0x6b5c3e, span: 22,
    stats: [['Max speed', '~800 kt'], ['Stall', '~115 kt'], ['Thrust/Weight', '0.97'], ['Empty mass', '20 t']],
    bars: { Speed: 0.95, Climb: 0.6, Agility: 0.35, Stall: 0.8 }
  }
};
// Enemy model: falcon with red livery
const ENEMY_KEY = 'falcon';

// ============================================================
//  NOISE & TERRAIN
// ============================================================
function hash2(ix, iz) {
  let n = (Math.imul(ix, 374761393) + Math.imul(iz, 668265263)) | 0;
  n = Math.imul(n ^ (n >>> 13), 1274126177);
  return ((n ^ (n >>> 16)) >>> 0) / 4294967295;
}
function vnoise(x, z) {
  const xi = Math.floor(x), zi = Math.floor(z);
  const fx = x - xi, fz = z - zi;
  const u = fx * fx * (3 - 2 * fx), v = fz * fz * (3 - 2 * fz);
  const a = hash2(xi, zi), b = hash2(xi + 1, zi), c = hash2(xi, zi + 1), d = hash2(xi + 1, zi + 1);
  return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm(x, z, oct) {
  let s = 0, a = 0.5, f = 1, norm = 0;
  for (let i = 0; i < oct; i++) { s += a * vnoise(x * f, z * f); norm += a; a *= 0.5; f *= 2.03; }
  return s / norm;
}

// Procedural heightfield (meters). Runway flattened, mountains suppressed near field.
const TERRAIN_HALF = 8000;
function terrainH(x, z) {
  const r = Math.hypot(x, z);
  const c = fbm(x * 0.00022 + 11, z * 0.00022 + 7, 4);
  let base = (c - 0.44) * 900;
  base = lerp(35, base, smooth(1200, 2800, r));
  const mtnMask = smooth(2600, 4300, r) * smooth(0.42, 0.62, fbm(x * 0.00035 + 3, z * 0.00035 + 5, 3));
  const rn = fbm(x * 0.00085, z * 0.00085, 5);
  const ridge = 1 - Math.abs(rn * 2 - 1);
  const mtn = mtnMask * ridge * ridge * 1500;
  const detail = (fbm(x * 0.006, z * 0.006, 3) - 0.5) * 35;
  let h = base + mtn + detail;
  // Runway flattening (x in [-30,30], z in [-1950, 330])
  const dx = Math.max(0, Math.abs(x) - 45);
  const dz = Math.max(0, z - 380, -2000 - z);
  const d = Math.hypot(dx, dz);
  const w = 1 - smooth(10, 120, d);
  h = lerp(h, RWY.elev, w);
  return h;
}

function buildTerrain() {
  const size = TERRAIN_HALF * 2, seg = 320;
  const geo = new THREE.PlaneGeometry(size, size, seg, seg);
  geo.rotateX(-Math.PI / 2);
  const pos = geo.attributes.position;
  for (let i = 0; i < pos.count; i++) {
    pos.setY(i, terrainH(pos.getX(i), pos.getZ(i)));
  }
  geo.computeVertexNormals();
  const nrm = geo.attributes.normal;
  const col = new Float32Array(pos.count * 3);
  const cSand = [0.80, 0.74, 0.52], cGrass = [0.33, 0.52, 0.22], cForest = [0.17, 0.34, 0.14];
  const cRock = [0.45, 0.41, 0.36], cSnow = [0.95, 0.96, 0.98], cDirt = [0.42, 0.36, 0.26];
  const mix = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
  for (let i = 0; i < pos.count; i++) {
    const y = pos.getY(i), slope = 1 - nrm.getY(i);
    let c = y < 4 ? cSand : mix(cSand, cGrass, smooth(4, 14, y));
    c = mix(c, cForest, smooth(60, 260, y) * 0.6 * (1 - smooth(0.25, 0.5, slope)));
    c = mix(c, cRock, smooth(0.22, 0.45, slope));
    c = mix(c, cDirt, smooth(420, 620, y) * 0.4 * (1 - smooth(0.2, 0.4, slope)));
    c = mix(c, cRock, smooth(700, 900, y) * 0.7);
    c = mix(c, cSnow, smooth(1100, 1400, y) * (0.5 + 0.5 * (1 - slope)));
    col[i * 3] = c[0]; col[i * 3 + 1] = c[1]; col[i * 3 + 2] = c[2];
  }
  geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
  const mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92, metalness: 0.0, flatShading: false });
  const mesh = new THREE.Mesh(geo, mat);
  mesh.receiveShadow = false;
  scene.add(mesh);

  // Sea
  const seaGeo = new THREE.PlaneGeometry(size * 1.2, size * 1.2);
  seaGeo.rotateX(-Math.PI / 2);
  const sea = new THREE.Mesh(seaGeo, new THREE.MeshStandardMaterial({ color: 0x1d5f80, roughness: 0.12, metalness: 0.1, transparent: true, opacity: 0.92 }));
  sea.position.y = -0.5;
  scene.add(sea);
  return mesh;
}

// Runway with markings
function buildRunway() {
  const cv = document.createElement('canvas');
  cv.width = 128; cv.height = 1024;
  const g = cv.getContext('2d');
  g.fillStyle = '#3a3d40'; g.fillRect(0, 0, 128, 1024);
  g.fillStyle = '#e9e9e9';
  // Centerline dashes (texture runs from far end (top) to threshold (bottom))
  for (let y = 0; y < 1024; y += 64) g.fillRect(62, y + 8, 4, 36);
  // Threshold stripes near threshold (bottom)
  for (let x = 10; x < 128; x += 20) g.fillRect(x, 930, 10, 80);
  // Edge lines
  g.fillRect(4, 0, 3, 1024); g.fillRect(121, 0, 3, 1024);
  // Touchdown zone aiming bars
  for (let i = 0; i < 3; i++) { g.fillRect(24, 740 - i * 40, 22, 14); g.fillRect(82, 740 - i * 40, 22, 14); }
  const tex = new THREE.CanvasTexture(cv);
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.anisotropy = 8;
  const len = RWY.zNear - RWY.zFar; // 2250
  const geo = new THREE.PlaneGeometry(60, len);
  geo.rotateX(-Math.PI / 2);
  const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ map: tex, roughness: 0.75, metalness: 0 }));
  mesh.position.set(0, RWY.elev + 0.12, (RWY.zNear + RWY.zFar) / 2);
  scene.add(mesh);
}

// ============================================================
//  RENDERER / SCENE / SKY / CLOUDS
// ============================================================
const canvas = document.getElementById('gl');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;

const scene = new THREE.Scene();
const HORIZON = new THREE.Color(0xbfd9ec);
scene.fog = new THREE.Fog(HORIZON, 3500, 15000);
const camera = new THREE.PerspectiveCamera(62, window.innerWidth / window.innerHeight, 0.5, 30000);

// Sky dome gradient
{
  const skyMat = new THREE.ShaderMaterial({
    side: THREE.BackSide, depthWrite: false, fog: false,
    uniforms: { top: { value: new THREE.Color(0x2c6fbf) }, hor: { value: HORIZON }, sunDir: { value: new THREE.Vector3(0.4, 0.5, -0.5).normalize() } },
    vertexShader: `varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
    fragmentShader: `uniform vec3 top; uniform vec3 hor; uniform vec3 sunDir; varying vec3 vDir;
      void main(){ float h = clamp(vDir.y, 0.0, 1.0);
        vec3 c = mix(hor, top, pow(h, 0.55));
        float s = max(dot(normalize(vDir), normalize(sunDir)), 0.0);
        c += vec3(1.0,0.85,0.6) * pow(s, 220.0) * 1.2 + vec3(1.0,0.8,0.5)*pow(s,8.0)*0.10;
        gl_FragColor = vec4(c,1.0); }`
  });
  scene.add(new THREE.Mesh(new THREE.SphereGeometry(20000, 32, 16), skyMat));
}

// Lights (PBR-friendly)
const hemi = new THREE.HemisphereLight(0xcfe8ff, 0x3a4a2a, 1.1);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff1dc, 2.6);
sun.position.set(4000, 6000, -3000);
scene.add(sun);

// Soft radial texture for cloud puffs
function makePuffTexture() {
  const c = document.createElement('canvas'); c.width = c.height = 128;
  const g = c.getContext('2d');
  const grd = g.createRadialGradient(64, 64, 4, 64, 64, 64);
  grd.addColorStop(0, 'rgba(255,255,255,0.95)');
  grd.addColorStop(0.5, 'rgba(255,255,255,0.45)');
  grd.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = grd; g.fillRect(0, 0, 128, 128);
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}
const clouds = [];
function buildClouds() {
  const tex = makePuffTexture();
  for (let i = 0; i < 110; i++) {
    const grp = new THREE.Group();
    const n = 4 + Math.floor(Math.random() * 4);
    const scale = rand(260, 620);
    for (let k = 0; k < n; k++) {
      const m = new THREE.SpriteMaterial({ map: tex, color: 0xffffff, transparent: true, opacity: rand(0.55, 0.9), depthWrite: false, fog: true });
      const s = new THREE.Sprite(m);
      s.position.set(rand(-1, 1) * scale, rand(-0.2, 0.25) * scale, rand(-1, 1) * scale * 0.6);
      s.scale.setScalar(scale * rand(0.7, 1.1));
      grp.add(s);
    }
    const a = Math.random() * Math.PI * 2, d = rand(1200, 7000);
    grp.position.set(Math.cos(a) * d, rand(650, 1500), Math.sin(a) * d);
    scene.add(grp);
    clouds.push(grp);
  }
}

// Background build
const terrainMesh = buildTerrain();
buildRunway();
buildClouds();

// Wake of the terrain; smoke/dust particles & tracers
const fxGroup = new THREE.Group();
scene.add(fxGroup);

// ============================================================
//  AIRCRAFT MODELS (procedural Three.js meshes, nose = +X, up = +Y, right = +Z)
// ============================================================
function fuselageGeo(L, R) {
  const pts = [];
  const N = 30;
  for (let i = 0; i <= N; i++) {
    const t = i / N;
    const r = R * Math.pow(Math.sin(Math.PI * Math.pow(t, 0.8)), 0.55);
    pts.push(new THREE.Vector2(Math.max(r, 0.001), -L / 2 + L * t));
  }
  const g = new THREE.LatheGeometry(pts, 28);
  g.rotateZ(-Math.PI / 2);   // lathe axis -> body X
  return g;
}
function wingGeo(span, rc, tc, tipLeadX, thick) {
  const s = new THREE.Shape();
  s.moveTo(rc * 0.5, 0);
  s.lineTo(tipLeadX, span);
  s.lineTo(tipLeadX - tc, span);
  s.lineTo(-rc * 0.5, 0);
  s.closePath();
  const g = new THREE.ExtrudeGeometry(s, { depth: thick, bevelEnabled: false });
  g.rotateX(Math.PI / 2);
  g.translate(0, thick / 2, 0);
  return g;
}
function finGeo(height, rc, tc, tipLeadX, thick) {
  const s = new THREE.Shape();
  s.moveTo(rc * 0.5, 0);
  s.lineTo(tipLeadX, height);
  s.lineTo(tipLeadX - tc, height);
  s.lineTo(-rc * 0.5, 0);
  s.closePath();
  const g = new THREE.ExtrudeGeometry(s, { depth: thick, bevelEnabled: false });
  g.translate(0, 0, -thick / 2);
  return g;
}
function mirrorPair(parent, geo, mat, x, y, z = 0, sym = true) {
  const a = new THREE.Mesh(geo, mat);
  a.position.set(x, y, z);
  a.castShadow = false;
  parent.add(a);
  if (sym) {
    const b = new THREE.Mesh(geo, mat);
    b.position.set(x, y, -z);
    b.scale.z = -1;
    parent.add(b);
  }
  return a;
}
function gearWheel(parent, x, y, z, leg = true) {
  const wheelMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 });
  const legMat = new THREE.MeshStandardMaterial({ color: 0x9aa0a6, metalness: 0.6, roughness: 0.4 });
  const w = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.32, 0.22, 16), wheelMat);
  w.rotation.z = Math.PI / 2;
  w.position.set(x, y + 0.32, z);
  parent.add(w);
  if (leg) {
    const l = new THREE.Mesh(new THREE.BoxGeometry(0.1, Math.abs(y) - 0.3, 0.1), legMat);
    l.position.set(x, (y + 0.3 + 0) / 2 + 0.25, z);
    parent.add(l);
  }
}
function makeMats(color, accent) {
  return {
    body: new THREE.MeshStandardMaterial({ color, roughness: 0.42, metalness: 0.25 }),
    accent: new THREE.MeshStandardMaterial({ color: accent, roughness: 0.5, metalness: 0.2, side: THREE.DoubleSide }),
    glass: new THREE.MeshPhysicalMaterial({ color: 0x0b1a2a, roughness: 0.05, metalness: 0.0, clearcoat: 1, transmission: 0.0, side: THREE.DoubleSide }),
    dark: new THREE.MeshStandardMaterial({ color: 0x1c1f24, roughness: 0.6, metalness: 0.3 }),
    metal: new THREE.MeshStandardMaterial({ color: 0xb8bec6, roughness: 0.25, metalness: 0.9 })
  };
}

function buildSkylark(group, spec) {
  const m = makeMats(spec.color, spec.accent);
  const fus = new THREE.Mesh(fuselageGeo(8.4, 0.78), m.body);
  group.add(fus);
  // Stripe
  const stripe = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.12, 1.56), m.accent);
  stripe.position.set(0.8, 0.1, 0);
  group.add(stripe);
  // Canopy (bubble)
  const can = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 14, 0, Math.PI * 2, 0, Math.PI / 2), m.glass);
  can.scale.set(1.7, 0.7, 0.92);
  can.position.set(1.0, 0.7, 0);
  group.add(can);
  // High straight wing
  mirrorPair(group, wingGeo(5.6, 1.9, 1.3, 0.0, 0.14), m.body, 0.6, 0.72);
  // Struts
  mirrorPair(group, new THREE.BoxGeometry(0.08, 0.7, 0.08), m.metal, 0.4, -0.05, 1.8);
  // Horizontal tail
  mirrorPair(group, wingGeo(2.5, 1.1, 0.7, -0.3, 0.08), m.body, -3.6, 0.15);
  // Vertical fin
  group.add(new THREE.Mesh(finGeo(1.7, 1.6, 0.8, -0.2, 0.1), m.body).translateX(-3.7).translateY(0.6));
  // Fin stripe
  const fs = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.13), m.accent);
  fs.position.set(-4.0, 1.1, 0);
  group.add(fs);
  // Propeller
  const prop = new THREE.Group();
  prop.position.set(4.2, 0, 0);
  const spin = new THREE.Mesh(new THREE.ConeGeometry(0.42, 0.9, 20), m.metal);
  spin.rotation.z = -Math.PI / 2; spin.position.x = 0.4;
  prop.add(spin);
  const blade1 = new THREE.Mesh(new THREE.BoxGeometry(0.06, 2.6, 0.18), m.dark);
  const blade2 = blade1.clone();
  blade2.rotation.x = Math.PI;
  blade1.position.x = 0.2; blade2.position.x = 0.2;
  prop.add(blade1, blade2);
  group.add(prop);
  // Gear (tricycle, fixed)
  gearWheel(group, -0.6, -spec.gearH, 1.1);
  gearWheel(group, -0.6, -spec.gearH, -1.1);
  gearWheel(group, 3.3, -spec.gearH, 0, true);
  return { prop, propSpeed: 1 };
}

function buildFalcon(group, spec) {
  const m = makeMats(spec.color, spec.accent);
  group.add(new THREE.Mesh(fuselageGeo(16.0, 0.95), m.body));
  // Cockpit canopy
  const can = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 14, 0, Math.PI * 2, 0, Math.PI / 2), m.glass);
  can.scale.set(2.6, 0.75, 0.82);
  can.position.set(3.4, 0.7, 0);
  group.add(can);
  // Intake under nose
  const intake = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.9, 1.5), m.dark);
  intake.position.set(1.8, -0.4, 0);
  group.add(intake);
  // Swept delta wing
  mirrorPair(group, wingGeo(5.5, 9.0, 1.4, -2.2, 0.16), m.body, 1.3, -0.15);
  // Winglet accent
  mirrorPair(group, wingGeo(1.0, 1.4, 0.8, -0.2, 0.06), m.accent, -2.5, 1.2, 4.9);
  // Twin canted fins
  const finL = new THREE.Mesh(finGeo(3.2, 4.2, 2.2, -1.3, 0.2), m.body);
  finL.position.set(-5.4, 0.7, 1.1); finL.rotation.x = -0.22; group.add(finL);
  const finR = finL.clone(); finR.position.z = -1.1; finR.rotation.x = 0.22; group.add(finR);
  // Tail stab
  mirrorPair(group, wingGeo(2.6, 2.8, 1.1, -1.3, 0.1), m.body, -6.6, 0.2);
  // Engine exhaust
  const nozzle = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.85, 0.6, 20, 1, true), m.dark);
  nozzle.rotation.z = Math.PI / 2; nozzle.position.set(-8.4, 0, 0);
  group.add(nozzle);
  // Gear
  gearWheel(group, -1.0, -spec.gearH, 1.6);
  gearWheel(group, -1.0, -spec.gearH, -1.6);
  gearWheel(group, 4.8, -spec.gearH, 0);
  return { prop: null };
}

function buildRaptor(group, spec) {
  const m = makeMats(spec.color, spec.accent);
  group.add(new THREE.Mesh(fuselageGeo(22.0, 1.25), m.body));
  // Glass nose & canopy
  const can = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 14, 0, Math.PI * 2, 0, Math.PI / 2), m.glass);
  can.scale.set(2.8, 0.9, 1.1);
  can.position.set(5.5, 0.9, 0);
  group.add(can);
  // Big wing
  mirrorPair(group, wingGeo(10.5, 5.2, 2.6, -0.3, 0.2), m.body, 0.5, -0.45);
  // Twin engine nacelles on wing
  for (const z of [-3.2, 3.2]) {
    const nac = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.7, 6.0, 20, 1, true), m.dark);
    nac.rotation.z = Math.PI / 2;
    nac.position.set(-1.6, -1.1, z);
    group.add(nac);
    const inner = new THREE.Mesh(new THREE.CylinderGeometry(0.62, 0.62, 0.2, 18), m.accent);
    inner.rotation.z = Math.PI / 2; inner.position.set(-4.6, -1.1, z);
    group.add(inner);
  }
  // Twin tail fins
  for (const z of [-1.8, 1.8]) {
    const f = new THREE.Mesh(finGeo(5.0, 6.0, 3.0, -2.4, 0.22), m.body);
    f.position.set(-7.2, 1.2, z);
    if (z < 0) f.rotation.x = -0.1; else f.rotation.x = 0.1;
    group.add(f);
  }
  // Horizontal tail
  mirrorPair(group, wingGeo(5.6, 3.2, 1.6, -0.6, 0.12), m.body, -8.2, 0.1);
  // Gear
  gearWheel(group, -1.2, -spec.gearH, 2.4);
  gearWheel(group, -1.2, -spec.gearH, -2.4);
  gearWheel(group, 6.2, -spec.gearH, 0);
  return { prop: null };
}

function buildAircraft(key) {
  const spec = FLEET[key];
  const group = new THREE.Group();
  let extra;
  if (key === 'skylark') extra = buildSkylark(group, spec);
  else if (key === 'falcon') extra = buildFalcon(group, spec);
  else extra = buildRaptor(group, spec);
  group.traverse(o => { if (o.isMesh) { o.castShadow = false; o.receiveShadow = false; } });
  return { group, prop: extra.prop || null, spec };
}

// ============================================================
//  FLIGHT MODEL — rigid-body 6DOF, body-frame rates, quaternion attitude
//  Body axes: X forward (nose), Y up, Z right (starboard).
//  Angular rate vector w = (p roll about X [+ = right wing down],
//                           r yaw about Y [+ = nose left],
//                           q pitch about Z [+ = nose up]).
// ============================================================
const VMAX = { skylark: 95, falcon: 330, raptor: 410 };      // m/s, speed where thrust ~ drag
const FUEL0 = { skylark: 300, falcon: 3000, raptor: 8000 };  // kg
const SFC = { skylark: 8e-5, falcon: 3.5e-5, raptor: 3.2e-5 }; // kg per N·s
const LEN = { skylark: 8.4, falcon: 16, raptor: 22 };
// Tricycle gear: nose ahead of CG, mmains aft of CG (CG must sit between them for ground stability)
const GEAR = {
  skylark: { nx: 3.3, mx: -0.6, mz: 1.1 },
  falcon:  { nx: 4.8, mx: -1.0, mz: 1.6 },
  raptor:  { nx: 6.2, mx: -1.2, mz: 2.4 }
};
const IDLE = 0.18;
const PHYS_DT = 1 / 120;
const GRAV = new THREE.Vector3(0, -G, 0);

function groundH(x, z) { return Math.max(terrainH(x, z), 0); }

function newAirframe(key) {
  const spec = FLEET[key];
  return {
    key, spec, L: LEN[key], c: spec.S / spec.span,
    fuel: FUEL0[key], p: new THREE.Vector3(), v: new THREE.Vector3(),
    q: new THREE.Quaternion(), w: new THREE.Vector3(),
    thr: 0, rpm: 0, dE: 0, dA: 0, dR: 0, flap: 0, gearDown: true, brake: false,
    damage: 0, onGround: false, alpha: 0, beta: 0, V: 0, rho: RHO0, alt: 0,
    n: 1, thrust: 0, stall: false, impact: 0, crashed: false, crashReason: '',
    touchdowns: 0, lastVS: 0, lastLat: 0, lastDist: 0, wasAir: false, buffet: 0,
    lift: 0, drag: 0, mass: spec.mass + FUEL0[key], gLoad: 1
  };
}

const _va = new THREE.Vector3(), _vb = new THREE.Vector3(), _f = new THREE.Vector3();
const _u = new THREE.Vector3(), _r = new THREE.Vector3(), _vh = new THREE.Vector3();
const _Ld = new THREE.Vector3(), _Fa = new THREE.Vector3(), _F = new THREE.Vector3();
const _M = new THREE.Vector3(), _Mg = new THREE.Vector3(), _Fg = new THREE.Vector3();
const _qInv = new THREE.Quaternion(), _dq = new THREE.Quaternion(), _ax = new THREE.Vector3();
const _rp = new THREE.Vector3(), _P = new THREE.Vector3(), _vP = new THREE.Vector3();
const _wW = new THREE.Vector3(), _tmp = new THREE.Vector3(), _Fw = new THREE.Vector3();

// ctl = { pitch, roll, yaw, thr, brake }  (pitch +1 = nose up, roll +1 = right, yaw +1 = nose left, thr 0..1)
function stepAirframe(a, dt, ctl, wind) {
  const s = a.spec;
  const fuelOk = a.fuel > 0 && a.damage < 100 && !a.crashed;
  const m = s.mass + a.fuel;
  a.mass = m;
  const b = s.span, c = a.c, L = a.L;
  const Ix = m * (0.30 * b) * (0.30 * b);
  const Iy = m * (0.27 * L) * (0.27 * L);
  const Iz = m * (0.26 * L) * (0.26 * L);

  // ---- control surface actuators (first-order lag, heavier = slower)
  const tau = 0.06 + 0.00002 * m;
  const ka = 1 - Math.exp(-dt / tau);
  a.dE += (clamp(ctl.pitch, -1, 1) - a.dE) * ka;
  a.dA += (clamp(ctl.roll, -1, 1) - a.dA) * ka;
  a.dR += (clamp(ctl.yaw, -1, 1) - a.dR) * ka;

  // ---- engine spool: rpm lags throttle
  const rpmTgt = fuelOk ? (ctl.thr > 0.01 ? IDLE + (1 - IDLE) * ctl.thr : IDLE) : 0;
  a.rpm += (rpmTgt - a.rpm) * (1 - Math.exp(-dt / (s.spool * 0.5)));
  a.thr = ctl.thr;

  // ---- atmosphere (ISA troposphere): density falls with altitude
  const alt = Math.max(0, a.p.y);
  const Tair = T0 - 0.0065 * alt;
  const rho = RHO0 * Math.pow(Math.max(Tair, 200) / T0, 4.256);
  a.rho = rho; a.alt = alt;

  // ---- air-relative velocity in body axes
  _va.copy(a.v).sub(wind);
  _qInv.copy(a.q).invert();
  _vb.copy(_va).applyQuaternion(_qInv);
  const V = Math.max(_va.length(), 0.5);
  const alpha = Math.atan2(-_vb.y, _vb.x);
  const beta = Math.asin(clamp(_vb.z / V, -1, 1));
  a.V = V; a.alpha = alpha; a.beta = beta;
  const qdyn = 0.5 * rho * V * V;
  const dyn = qdyn * s.S;
  a.qdyn = qdyn;

  _f.set(1, 0, 0).applyQuaternion(a.q);
  _u.set(0, 1, 0).applyQuaternion(a.q);
  _r.set(0, 0, 1).applyQuaternion(a.q);
  _vh.copy(_va).divideScalar(V);

  // ---- lift / drag coefficients with smooth Abramov stall blend
  const aS = s.aStall, Mk = 22;
  const e1 = Math.exp(-Mk * (alpha - aS)), e2 = Math.exp(Mk * (alpha + aS));
  const sig = (1 + e1 + e2) / ((1 + e1) * (1 + e2));
  const sa = Math.sin(alpha), ca = Math.cos(alpha);
  const flapF = a.flap * 0.5;
  const clLin = s.CL0 + s.CLa * alpha + s.flapCL * flapF;
  const CL = (1 - sig) * clLin + sig * 2 * sa * ca;
  const cdi = CL * CL / (Math.PI * s.AR * s.e);
  const CD = (1 - sig) * (s.CD0 + cdi) + sig * (s.CD0 + 2 * sa * sa)
           + s.flapCD * flapF + (a.gearDown ? s.gearCD : 0)
           + (a.brake && a.onGround ? 0.03 : 0);
  a.lift = dyn * CL; a.drag = dyn * CD;

  // Lift is perpendicular to air velocity, in the plane containing body up
  const ud = _u.dot(_vh);
  _Ld.copy(_u).addScaledVector(_vh, -ud);
  const Lm = _Ld.length();
  if (Lm > 1e-6) _Ld.divideScalar(Lm); else _Ld.set(0, 1, 0);

  _Fa.set(0, 0, 0);
  _Fa.addScaledVector(_Ld, dyn * CL);
  _Fa.addScaledVector(_vh, -dyn * CD);
  // Side force opposing sideslip
  _Fa.addScaledVector(_r, -dyn * (0.4 + 0.1 * s.yawStab) * beta);

  // Thrust: propeller/jet lapse with density and airspeed
  const sf = Math.max(0.05, 1 - 0.3 * Math.pow(V / VMAX[a.key], 2));
  const powF = 1 - 0.5 * smooth(60, 100, a.damage);
  const Tn = fuelOk ? s.Tmax * a.rpm * Math.pow(rho / RHO0, 0.75) * sf * powF : 0;
  a.thrust = Tn;
  a.fuel = Math.max(0, a.fuel - SFC[a.key] * Tn * dt);
  _Fa.addScaledVector(_f, Tn);

  // Load factor (non-gravitational specific force along body up)
  const nNow = _Fa.dot(_u) / (m * G);
  a.n = nNow; a.gLoad = nNow;

  // ---- aerodynamic moments (body axes), scaled by dynamic pressure
  const Vs = Math.max(V, 25);
  const p = a.w.x, rr = a.w.y, qq = a.w.z;
  const Mx = dyn * b * (s.rollPow * 0.08 * a.dA - 0.06 * beta) - dyn * b * b * (s.rollDamp * 0.5) / (2 * Vs) * p;
  const Mz = dyn * c * (s.pitchPow * 0.10 * a.dE - s.pitchStab * 0.06 * alpha) - dyn * c * c * (s.pitchDamp * 12) / (2 * Vs) * qq;
  const My = dyn * b * (s.yawPow * 0.06 * a.dR - s.yawStab * 0.04 * beta) - dyn * b * b * (s.yawDamp * 0.3) / (2 * Vs) * rr;
  _M.set(Mx, My, Mz);

  // ---- ground contact: three spring-damper gear points
  const gr = GEAR[a.key];
  const gearH = a.gearDown ? s.gearH : 0.45;
  const m3 = m / 3;
  const wn = 14, zeta = 0.7;
  const K = m3 * wn * wn, Cd = 2 * zeta * Math.sqrt(K * m3);
  _Fg.set(0, 0, 0); _Mg.set(0, 0, 0);
  _wW.copy(a.w).applyQuaternion(a.q);
  let nContact = 0, impact = 0;
  const pts = [[gr.nx, -gearH, 0], [gr.mx, -gearH, -gr.mz], [gr.mx, -gearH, gr.mz]];
  for (let i = 0; i < 3; i++) {
    _rp.set(pts[i][0], pts[i][1], pts[i][2]).applyQuaternion(a.q);
    _P.copy(a.p).add(_rp);
    const pen = groundH(_P.x, _P.z) - _P.y;
    if (pen > 0) {
      _vP.copy(_wW).cross(_rp).add(a.v);
      const vn = _vP.y;
      let N = K * pen - Cd * vn;
      if (N < 0) N = 0;
      nContact++;
      if (-vn > impact) impact = -vn;
      let fx = 0, fz = 0;
      const hs = Math.hypot(_vP.x, _vP.z);
      if (hs > 1e-3 && N > 0) {
        const mu = a.brake ? 0.55 : 0.04;
        const Ff = Math.min(mu * N, m3 * hs / dt * 0.5);
        fx = -_vP.x / hs * Ff; fz = -_vP.z / hs * Ff;
      }
      _Fw.set(fx, N, fz);
      _Fg.add(_Fw);
      _tmp.copy(_rp).cross(_Fw);
      _Mg.add(_tmp);
    }
  }
  // Nosewheel steering + ground yaw damping
  const onG = nContact > 0;
  if (onG) {
    _Mg.applyQuaternion(_qInv);
    _M.add(_Mg);
    _M.y += a.dR * Iy * 1.5 * clamp(V / 12, 0, 1) - rr * Iy * 2.0;
  }

  // ---- Touchdown / impact bookkeeping
  if (onG && !a.onGround && a.wasAir !== false) {
    a.touchdowns++;
    a.lastVS = impact;
    a.lastLat = a.p.x;
    a.lastDist = a.p.z;
    if (impact > 2.5) a.damage += (impact - 2.5) * 4;
    if (impact > 7.0) { a.crashed = true; a.crashReason = 'Hard landing - airframe destroyed'; }
  }
  if (onG) a.impact = impact;
  a.wasAir = !onG;
  a.onGround = onG;

  // Flipped on ground -> crash
  if (onG && _u.y < 0.15) { a.crashed = true; a.crashReason = 'Rolled over on ground'; }

  // ---- angular dynamics: w_dot = I^-1 (M - w x Iw)
  const Ixw = Ix * p, Iyw = Iy * rr, Izw = Iz * qq;
  const wdx = (_M.x - (rr * Izw - qq * Iyw)) / Ix;
  const wdy = (_M.y - (qq * Ixw - p * Izw)) / Iy;
  const wdz = (_M.z - (p * Iyw - rr * Ixw)) / Iz;
  a.w.x = clamp(a.w.x + wdx * dt, -8, 8);
  a.w.y = clamp(a.w.y + wdy * dt, -8, 8);
  a.w.z = clamp(a.w.z + wdz * dt, -8, 8);

  // Orientation update: body-frame rotation increment
  const wm = a.w.length();
  if (wm > 1e-9) {
    _ax.copy(a.w).divideScalar(wm);
    _dq.setFromAxisAngle(_ax, wm * dt);
    a.q.multiply(_dq).normalize();
  }

  // Ground pitch limiter: gear geometry is simplified, so keep the nose
  // within a plausible attitude range while in contact with the runway.
  if (onG) {
    const fz = _f.set(1, 0, 0).applyQuaternion(a.q);
    const pitchNow = Math.asin(clamp(fz.y, -1, 1));
    const lim = [-5 * DEG, 12 * DEG];
    const tgtP = clamp(pitchNow, lim[0], lim[1]);
    if (tgtP !== pitchNow) {
      _dq.setFromAxisAngle(_ax.set(0, 0, 1), tgtP - pitchNow);
      a.q.multiply(_dq).normalize();
      if (a.w.z * (tgtP - pitchNow) < 0) a.w.z = 0;
    }
  }

  // ---- linear dynamics
  _F.copy(_Fa).add(_Fg).addScaledVector(GRAV, m);
  a.v.addScaledVector(_F, dt / m);
  a.p.addScaledVector(a.v, dt);

  // Sanity: never sink through the terrain
  const gy = groundH(a.p.x, a.p.z);
  if (a.p.y < gy - 6) { a.p.y = gy - 6; }

  // ---- structural load damage
  const an = Math.abs(nNow);
  if (an > s.maxG * 1.5) a.damage += 30 * dt;
  else if (an > s.maxG) a.damage += (an - s.maxG) * 2 * dt;
  if (a.damage >= 100 && !a.crashed) { a.crashed = true; a.crashReason = 'Airframe failure under load'; }

  // Stall detection and buffet intensity
  a.stall = !onG && alpha > aS * 0.92;
  a.buffet = smooth(aS * 0.8, aS * 1.05, Math.abs(alpha)) * (onG ? 0 : 1);

  // Attitude helpers for HUD
  const f = _f, u = _u, r = _r;
  a.pitch = Math.asin(clamp(f.y, -1, 1));
  a.bank = Math.atan2(-r.y, u.y);
  a.hdg = ((Math.atan2(f.x, -f.z) / DEG) + 360) % 360;
  a.climb = a.v.y;
}

function airframeFromSpawn(a, x, y, z, hdgDeg, speed, climbRate = 0) {
  a.p.set(x, y, z);
  a.touchdowns = 0;
  const hdg = hdgDeg * DEG;
  // heading: forward = (sin h, 0, -cos h) -> compass 0 = north = -Z
  a.q.setFromAxisAngle(_ax.set(0, 1, 0), Math.PI / 2 - hdg);
  a.v.set(Math.sin(hdg) * speed, climbRate, -Math.cos(hdg) * speed);
  a.w.set(0, 0, 0);
  a.rpm = 0; a.dE = a.dA = a.dR = 0;
}

// ============================================================
//  AUDIO — Web Audio synthesis driven by physics state
// ============================================================
const AudioSys = {
  ctx: null, ready: false,
  init() {
    if (this.ready) return;
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return;
    const ctx = this.ctx = new AC();
    this.master = ctx.createGain(); this.master.gain.value = 0.7;
    const comp = ctx.createDynamicsCompressor(); comp.threshold.value = -18;
    this.master.connect(comp); comp.connect(ctx.destination);

    // Shared noise buffer (2s)
    const nb = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
    const d = nb.getChannelData(0);
    let last = 0;
    for (let i = 0; i < d.length; i++) { const w = Math.random() * 2 - 1; last = (last + 0.02 * w) / 1.02; d[i] = last * 3.5; }
    this.noise = nb;
    const mkNoise = (filterType, freq, q) => {
      const src = ctx.createBufferSource(); src.buffer = nb; src.loop = true;
      const f = ctx.createBiquadFilter(); f.type = filterType; f.frequency.value = freq; f.Q.value = q;
      src.connect(f);
      return { src, f };
    };

    // ENGINE: sawtooth-ish harmonic stack through lowpass, with noise
    this.eng = ctx.createGain(); this.eng.gain.value = 0;
    this.engF = ctx.createBiquadFilter(); this.engF.type = 'lowpass'; this.engF.frequency.value = 800; this.engF.Q.value = 2;
    this.engOsc1 = ctx.createOscillator(); this.engOsc1.type = 'sawtooth';
    this.engOsc2 = ctx.createOscillator(); this.engOsc2.type = 'square';
    this.engOsc2.detune.value = 6;
    const g2 = ctx.createGain(); g2.gain.value = 0.35;
    this.engOsc2.connect(g2); g2.connect(this.engF);
    this.engOsc1.connect(this.engF);
    this.engF.connect(this.eng);
    this.engNoiseN = mkNoise('bandpass', 400, 0.7);
    this.engNoiseG = ctx.createGain(); this.engNoiseG.gain.value = 0.4;
    this.engNoiseN.f.connect(this.engNoiseG); this.engNoiseG.connect(this.eng);
    this.engNoiseN.src.start();
    this.eng.connect(this.master);
    this.engOsc1.start(); this.engOsc2.start();

    // WIND: bandpass noise whose gain & cutoff rise with airspeed and altitude
    this.wind = mkNoise('bandpass', 500, 0.6);
    this.windG = ctx.createGain(); this.windG.gain.value = 0;
    this.wind.f.connect(this.windG); this.windG.connect(this.master);
    this.wind.src.start();

    // AERO WHISTLE: high-pass noise, scaled by airspeed & AoA
    this.aero = mkNoise('highpass', 1200, 1.0);
    this.aeroG = ctx.createGain(); this.aeroG.gain.value = 0;
    this.aero.f.connect(this.aeroG); this.aeroG.connect(this.master);
    this.aero.src.start();

    // BUFFET: low-frequency wobble when stalling
    this.buf = mkNoise('lowpass', 45, 0.5);
    this.bufG = ctx.createGain(); this.bufG.gain.value = 0;
    this.buf.f.connect(this.bufG); this.bufG.connect(this.master);
    this.buf.src.start();

    // SURFACE / GEAR RUMBLE on ground (rolling)
    this.roll = mkNoise('bandpass', 180, 0.8);
    this.rollG = ctx.createGain(); this.rollG.gain.value = 0;
    this.roll.f.connect(this.rollG); this.rollG.connect(this.master);
    this.roll.src.start();

    // Distant mountain rumble: very low drone, subtle
    this.rumble = mkNoise('lowpass', 90, 0.3);
    this.rumbleG = ctx.createGain(); this.rumbleG.gain.value = 0.0;
    this.rumble.f.connect(this.rumbleG); this.rumbleG.connect(this.master);
    this.rumble.src.start();

    // Gunfire: noise burst tied to trigger
    this.gunG = ctx.createGain(); this.gunG.gain.value = 0;
    this.gunF = ctx.createBiquadFilter(); this.gunF.type = 'lowpass'; this.gunF.frequency.value = 2400;
    this.gunF.connect(this.gunG); this.gunG.connect(this.master);
    this.gunSrc = ctx.createBufferSource(); this.gunSrc.buffer = nb; this.gunSrc.loop = true;
    this.gunSrc.connect(this.gunF); this.gunSrc.start();

    // Warning tone (stall / overspeed / gear)
    this.warnOsc = ctx.createOscillator(); this.warnOsc.type = 'square'; this.warnOsc.frequency.value = 880;
    this.warnG = ctx.createGain(); this.warnG.gain.value = 0;
    this.warnOsc.connect(this.warnG); this.warnG.connect(this.master); this.warnOsc.start();

    this.ready = true;
  },
  resume() { if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); },
  set(p, t, k = 0.05) {
    if (!this.ready) return;
    p.linearRampToValueAtTime(t, this.ctx.currentTime + k);
  },
  // One-shot thump (explosion / impact)
  boom(amp = 0.8, dur = 1.6) {
    if (!this.ready) return;
    const ctx = this.ctx, t = ctx.currentTime;
    const src = ctx.createBufferSource(); src.buffer = this.noise;
    const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(900, t); f.frequency.exponentialRampToValueAtTime(40, t + dur);
    const g = ctx.createGain(); g.gain.setValueAtTime(amp, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
    src.connect(f); f.connect(g); g.connect(this.master);
    src.start(t, Math.random()); src.stop(t + dur);
  },
  // Hit ping / chirp
  ping(freq = 1400) {
    if (!this.ready) return;
    const ctx = this.ctx, t = ctx.currentTime;
    const o = ctx.createOscillator(); o.type = 'sine';
    o.frequency.setValueAtTime(freq, t); o.frequency.exponentialRampToValueAtTime(freq * 0.5, t + 0.25);
    const g = ctx.createGain(); g.gain.setValueAtTime(0.25, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
    o.connect(g); g.connect(this.master); o.start(t); o.stop(t + 0.35);
  },
  // Gear / flap clunk
  clunk() {
    if (!this.ready) return;
    const ctx = this.ctx, t = ctx.currentTime;
    const src = ctx.createBufferSource(); src.buffer = this.noise;
    const f = ctx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 300; f.Q.value = 2;
    const g = ctx.createGain(); g.gain.setValueAtTime(0.5, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
    src.connect(f); f.connect(g); g.connect(this.master); src.start(t, Math.random() * 1.5); src.stop(t + 0.3);
  },
  // Per-frame mix update driven by physics + environment
  update(a, env) {
    if (!this.ready) return;
    const s = a.spec;
    const rpm = a.rpm;
    const rho = a.rho / RHO0;
    const V = a.V;
    const vsp = clamp(V / VMAX[a.key], 0, 1.2);
    // Engine pitch rises with rpm; density lowers pitch slightly and reduces volume at altitude
    const base = (a.key === 'skylark' ? 70 : a.key === 'falcon' ? 95 : 55) + 130 * rpm;
    const pitch = base * (0.85 + 0.15 * rho) * (1 + 0.04 * clamp(a.thrust / s.Tmax, 0, 1));
    this.set(this.engOsc1.frequency, pitch, 0.08);
    this.set(this.engOsc2.frequency, pitch * 0.5, 0.08);
    this.set(this.engF.frequency, 300 + 1800 * rpm * (0.6 + 0.4 * rho), 0.1);
    const engVol = (a.rpm < 0.02 ? 0 : (0.05 + 0.25 * rpm) * (0.45 + 0.55 * rho)) * (a.damage > 90 ? 0.4 : 1);
    this.set(this.eng.gain, engVol, 0.08);
    this.set(this.engNoiseG.gain, 0.15 + 0.35 * rpm * rho, 0.1);
    this.set(this.engNoiseN.f.frequency, 250 + 500 * rpm, 0.1);

    // Wind: grows with airspeed, rises further with altitude (thinner air = more buffet-like wash)
    const windVol = clamp(0.05 + 0.35 * vsp * vsp + 0.07 * clamp(a.alt / 3000, 0, 1), 0, 0.5);
    this.set(this.windG.gain, windVol * (env.cockpit ? 0.45 : 1), 0.15);
    this.set(this.wind.f.frequency, 350 + 1500 * vsp, 0.15);

    // Aero whistle: airflow over wing / canopy, stronger near stall and high speed
    const aeroAmt = clamp(vsp * vsp * 0.5 + Math.abs(a.alpha) * 0.6, 0, 1);
    const aeroVol = a.onGround ? 0 : aeroAmt * 0.22 * rho;
    this.set(this.aeroG.gain, aeroVol, 0.1);
    this.set(this.aero.f.frequency, 1000 + 2800 * vsp, 0.15);

    // Buffet when near stall
    this.set(this.bufG.gain, a.buffet * 0.5 * (0.5 + 0.5 * Math.sin(performance.now() * 0.06)), 0.05);

    // Ground roll rumble
    const roll = a.onGround ? clamp(V / 40, 0, 1) * 0.35 : 0;
    this.set(this.rollG.gain, roll, 0.1);
    this.set(this.roll.f.frequency, 120 + 200 * clamp(V / 70, 0, 1), 0.1);

    // Distant mountains rumble + wind pitch by altitude
    this.set(this.rumbleG.gain, 0.03 + 0.04 * clamp(env.nearMountain || 0, 0, 1), 0.5);

    // Warning tone
    const warnOn = (a.stall ? 1 : 0) + (a.damage > 60 ? 0.5 : 0);
    this.set(this.warnG.gain, warnOn > 0 && (Math.floor(performance.now() / 180) % 2 === 0) ? 0.06 : 0, 0.02);
  }
};

// ============================================================
//  INPUT
// ============================================================
const keys = {};
const mouse = { x: 0, y: 0, active: false, dx: 0, dy: 0, fire: false };
let mouseStick = false;
addEventListener('keydown', e => {
  if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Tab'].includes(e.code)) e.preventDefault();
  keys[e.code] = true;
  if (e.code === 'KeyM' && game.state === 'flying') mouseStick = !mouseStick;
  if ((e.code === 'KeyP' || e.code === 'Escape') && game.state === 'flying') togglePause();
  else if (e.code === 'KeyP' && game.state === 'paused') togglePause();
  if (e.code === 'KeyR' && game.state !== 'menu') startMission(game.key, game.mission);
  if (e.code === 'KeyF' && game.state === 'flying') { const a = game.ac; a.flap = (a.flap + 1) % 3; AudioSys.clunk(); }
  if (e.code === 'KeyG' && game.state === 'flying') { const a = game.ac; if (a.spec.key !== 'x') { a.gearDown = !a.gearDown; AudioSys.clunk(); } }
  if (e.code === 'KeyC' && game.state === 'flying') game.camMode = (game.camMode + 1) % 3;
  if (e.code === 'KeyX' && game.state === 'flying') game.ac.thr = 0;
  if (e.code === 'KeyZ' && game.state === 'flying') game.ac.thr = 1;
  if (e.code === 'Escape' && game.state === 'menu') {}
});
addEventListener('keyup', e => { keys[e.code] = false; });
addEventListener('mousemove', e => {
  mouse.x = e.clientX; mouse.y = e.clientY;
  if (mouseStick) { mouse.dx += e.movementX || 0; mouse.dy += e.movementY || 0; }
});
addEventListener('mousedown', e => { if (e.button === 0 && game.state === 'flying') mouse.fire = true; });
addEventListener('mouseup', e => { if (e.button === 0) mouse.fire = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; mouse.fire = false; });

// ============================================================
//  GAME STATE, CONTROLS, ENEMIES, WEAPONS, MISSIONS
// ============================================================
const game = {
  state: 'menu', key: 'skylark', mission: 'takeoff', ac: null, mesh: null, prop: null,
  camMode: 0, time: 0, phase: 0, enemies: [], bullets: [], expl: [], ammo: 0, kills: 0,
  gunCD: 0, msgT: 0, msgText: '', endCountdown: 0, ended: false, endSuccess: false,
  lastTD: 0, camInit: false, wind: new THREE.Vector3(), hitFlash: 0, landNote: '', landPts: 0,
  startTime: 0, missionTime: 0, menuT: 0
};
const camPos = new THREE.Vector3(), camLook = new THREE.Vector3();
const CAM_COCKPIT_Q = new THREE.Quaternion().setFromRotationMatrix(
  new THREE.Matrix4().makeBasis(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0), new THREE.Vector3(-1, 0, 0)));
const _bod = new THREE.Vector3(), _dir = new THREE.Vector3(), _qi = new THREE.Quaternion();
const _ff = new THREE.Vector3(), _tt = new THREE.Vector3();
const TGT_RANGE_MAX = 2500;

function setMsg(text, dur = 4) { game.msgText = text; game.msgT = dur; }

// ---- control mapping: keyboard digital inputs passed through actuator lag in physics
let stickX = 0, stickY = 0;
function readControls(dt, a) {
  let pitch = (keys.KeyS ? 1 : 0) - (keys.KeyW ? 1 : 0);
  let roll = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
  let yaw = (keys.KeyQ ? 1 : 0) - (keys.KeyE ? 1 : 0);
  if (mouseStick) {
    const cx = innerWidth / 2, cy = innerHeight / 2;
    const tx = clamp((mouse.x - cx) / (innerWidth * 0.3), -1, 1);
    const ty = clamp((cy - mouse.y) / (innerHeight * 0.3), -1, 1);
    roll = clamp(roll + tx, -1, 1); pitch = clamp(pitch + ty, -1, 1);
  }
  if (keys.ShiftLeft || keys.ShiftRight) a.thr = Math.min(1, a.thr + dt * 0.6);
  if (keys.ControlLeft || keys.ControlRight) a.thr = Math.max(0, a.thr - dt * 0.6);
  a.brake = !!keys.KeyB;
  return { pitch, roll, yaw, thr: a.thr, brake: a.brake };
}

// ---- enemy AI: pure-pursuit using the same physics model
function enemyAI(e, target, dt) {
  const a = e.a;
  _dir.copy(target.p).sub(a.p);
  const dist = _dir.length();
  _dir.divideScalar(Math.max(dist, 1));
  _qi.copy(a.q).invert();
  _bod.copy(_dir).applyQuaternion(_qi);
  const pitchErr = Math.atan2(_bod.y, _bod.x);
  const yawErr = Math.atan2(-_bod.z, _bod.x);
  const bankT = clamp(-yawErr * 1.5, -1.25, 1.25);
  const roll = clamp(3.0 * (bankT - a.bank) - 0.6 * a.w.x, -1, 1);
  let pitch = clamp(3.6 * pitchErr - 1.0 * a.w.z, -1, 1);
  const agl = a.p.y - groundH(a.p.x, a.p.z);
  if (agl < 260) pitch = Math.max(pitch, clamp((260 - agl) / 120, 0, 1));
  const yaw = clamp(yawErr * 0.4, -0.4, 0.4);
  const thr = a.V < 115 ? 1 : (dist > 900 ? 1 : 0.75);
  a.thr = thr;
  e.bodyDir = _bod.x;
  e.dist = dist;
  return { pitch, roll, yaw, thr, brake: false };
}

function spawnExplosion(pos, maxS, life = 1.6) {
  const m = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12),
    new THREE.MeshBasicMaterial({ color: 0xffa040, transparent: true, opacity: 1, depthWrite: false }));
  m.position.copy(pos);
  scene.add(m);
  game.expl.push({ mesh: m, t: 0, life, maxS });
}

function killEnemy(e) {
  if (e.dead) return;
  e.dead = true;
  e.mesh.visible = false;
  game.kills++;
  spawnExplosion(e.a.p, 30, 2.0);
  AudioSys.boom(0.7, 1.6);
  const left = game.enemies.filter(x => !x.dead).length;
  setMsg(left ? `SPLASH ONE — ${left} HOSTILE${left > 1 ? 'S' : ''} REMAINING` : 'ALL HOSTILES DOWN', 3);
}

function fireBullet(owner, origin, vel, life = 2.4) {
  const mesh = new THREE.Mesh(BULLET_GEO, owner === 'player' ? BULLET_MAT_P : BULLET_MAT_E);
  mesh.position.copy(origin);
  scene.add(mesh);
  game.bullets.push({ p: origin.clone(), v: vel.clone(), life, owner, mesh });
}
const BULLET_GEO = new THREE.BoxGeometry(0.25, 0.25, 4.0);
const BULLET_MAT_P = new THREE.MeshBasicMaterial({ color: 0xffe27a });
const BULLET_MAT_E = new THREE.MeshBasicMaterial({ color: 0xff5a4a });

function stepBullets(dt) {
  const a = game.ac;
  for (let i = game.bullets.length - 1; i >= 0; i--) {
    const b = game.bullets[i];
    b.p.addScaledVector(b.v, dt);
    b.life -= dt;
    b.mesh.position.copy(b.p);
    b.mesh.lookAt(_tt.copy(b.p).add(b.v));
    let remove = b.life <= 0 || b.p.y < groundH(b.p.x, b.p.z);
    if (!remove && b.owner === 'player') {
      for (const e of game.enemies) {
        if (e.dead) continue;
        if (b.p.distanceTo(e.a.p) < 9) {
          e.hp -= 3.4; e.flash = 0.15; remove = true;
          AudioSys.ping(1800);
          if (e.hp <= 0) killEnemy(e);
          break;
        }
      }
    } else if (!remove && b.owner !== 'player' && !a.crashed) {
      if (b.p.distanceTo(a.p) < 8) {
        a.damage += 0.7; game.hitFlash = 0.35; remove = true;
        AudioSys.ping(420);
      }
    }
    if (remove) { scene.remove(b.mesh); game.bullets.splice(i, 1); }
  }
}

function spawnPlayerFire(a, dt) {
  game.gunCD -= dt;
  const fire = keys.Space || mouse.fire;
  if (!fire || game.mission !== 'dogfight' || game.ammo <= 0 || a.crashed) return;
  if (game.gunCD > 0) return;
  game.gunCD = 1 / 14;
  game.ammo--;
  _ff.set(1, 0, 0).applyQuaternion(a.q);
  const origin = a.p.clone().addScaledVector(_ff, 8);
  const vel = a.v.clone().addScaledVector(_ff, 900);
  fireBullet('player', origin, vel);
  AudioSys.gunShot && AudioSys.gunShot();
}

function enemyFire(e, dt) {
  const a = game.ac;
  e.fireCD = (e.fireCD || 0) - dt;
  if (e.dead || a.crashed || e.fireCD > 0) return;
  if (e.dist > 900 || e.bodyDir < Math.cos(7 * DEG)) return;
  e.fireCD = 0.16;
  _ff.set(1, 0, 0).applyQuaternion(e.a.q);
  const origin = e.a.p.clone().addScaledVector(_ff, 8);
  const vel = e.a.v.clone().addScaledVector(_ff, 900);
  fireBullet('enemy', origin, vel);
}

// ---- Mission setup
function clearWorld() {
  if (game.mesh) scene.remove(game.mesh);
  for (const e of game.enemies) scene.remove(e.mesh);
  for (const b of game.bullets) scene.remove(b.mesh);
  for (const x of game.expl) scene.remove(x.mesh);
  game.enemies = []; game.bullets = []; game.expl = [];
}

function makeEnemy(x, y, z, hdgSpeedTarget) {
  const a = newAirframe('falcon');
  const dx = -x, dz = 1100 - z;
  const hdg = Math.atan2(dx, -dz) / DEG;
  airframeFromSpawn(a, x, y, z, (hdg + 360) % 360, 185);
  a.rpm = 0.85; a.thr = 0.9; a.gearDown = false; a.flap = 0;
  a.onGround = false; a.wasAir = true; a.damage = 0;
  const built = buildAircraft('falcon');
  built.group.traverse(o => {
    if (o.isMesh && o.material && o.material.color && o.material.color.getHex() === FLEET.falcon.color) {
      o.material = o.material.clone(); o.material.color.set(0x9a2420);
    }
  });
  scene.add(built.group);
  return { a, mesh: built.group, hp: 100, dead: false, fireCD: 0, flash: 0, dist: 9999, bodyDir: 0 };
}

function startMission(key, mission) {
  AudioSys.init(); AudioSys.resume();
  clearWorld();
  game.key = key; game.mission = mission;
  const built = buildAircraft(key);
  game.mesh = built.group; game.prop = built.prop;
  scene.add(game.mesh);
  const a = newAirframe(key);
  game.ac = a;
  game.phase = 0; game.kills = 0; game.time = 0; game.ended = false; game.endCountdown = 0;
  game.lastTD = 0; game.camInit = false; game.hitFlash = 0; game.gunCD = 0; game.landNote = '';
  game.missionTime = 0;
  game.ammo = mission === 'dogfight' ? 800 : 0;
  if (mission === 'takeoff') {
    airframeFromSpawn(a, 0, RWY.elev + a.spec.gearH, 260, 0, 0);
    a.gearDown = true; a.flap = 1; a.onGround = true; a.wasAir = false;
    a.thr = 0;
    setMsg('CLEARED FOR TAKEOFF — SHIFT TO ADVANCE THROTTLE', 5);
  } else {
    airframeFromSpawn(a, 0, 650, 1100, 0, 190);
    a.rpm = 0.85; a.thr = 0.85; a.gearDown = false; a.flap = 0;
    a.onGround = false; a.wasAir = true;
    const spawns = [[-900, 760, -700], [950, 820, -900], [-1500, 900, -200], [1350, 780, -350]];
    for (const s of spawns) game.enemies.push(makeEnemy(s[0], s[1], s[2]));
    setMsg('HOSTILES INBOUND — ENGAGE AT WILL', 5);
  }
  game.state = 'flying';
  document.getElementById('menu').classList.add('hidden');
  document.getElementById('hud').classList.remove('hidden');
  document.getElementById('pause').classList.add('hidden');
  document.getElementById('result').classList.add('hidden');
}

function togglePause() {
  if (game.state === 'flying') {
    game.state = 'paused';
    document.getElementById('pause').classList.remove('hidden');
  } else if (game.state === 'paused') {
    game.state = 'flying';
    document.getElementById('pause').classList.add('hidden');
  }
}

function toMenu() {
  game.state = 'menu';
  document.getElementById('pause').classList.add('hidden');
  document.getElementById('result').classList.add('hidden');
  document.getElementById('hud').classList.add('hidden');
  document.getElementById('menu').classList.remove('hidden');
}

function endMission(success, reason) {
  if (game.ended) return;
  game.ended = true;
  game.endSuccess = success;
  game.endReason = reason;
  game.endCountdown = success ? 2.0 : 2.8;
  if (!success) {
    spawnExplosion(game.ac.p, 40, 2.4);
    AudioSys.boom(1.0, 2.2);
    game.mesh.visible = false;
  }
}

function evaluateLanding(a) {
  const z = a.p.z, x = a.p.x, vs = a.lastVS, hdg = a.hdg;
  let pts = 100; const notes = [];
  // Touchdown zone: first 300 m past the threshold (z 300 -> 0)
  if (z > 320) { pts -= 30; notes.push('SHORT OF RUNWAY'); }
  else if (z < -300) { pts -= Math.min(30, (-300 - z) / 40); notes.push('LONG LANDING'); }
  else notes.push('ON TOUCHDOWN ZONE');
  if (Math.abs(x) > 14) { pts -= Math.min(30, Math.abs(x) - 14); notes.push('OFF CENTERLINE'); }
  const hd = Math.min(Math.abs(hdg), 360 - Math.abs(hdg));
  if (hd > 25) { pts -= 15; notes.push('WRONG HEADING'); }
  if (vs > 1.5) pts -= Math.min(40, (vs - 1.5) * 20);
  if (vs > 3.2) notes.push('HARD TOUCHDOWN');
  pts = Math.max(0, Math.round(pts));
  game.landPts = pts;
  game.landNote = `TOUCHDOWN ${vs.toFixed(1)} m/s · ${notes.join(' · ')}`;
  setMsg(game.landNote, 6);
  return pts;
}

function missionStep(dt) {
  const a = game.ac;
  if (game.ended) {
    game.endCountdown -= dt;
    if (game.endCountdown <= 0) showResult();
    return;
  }
  if (a.crashed) { endMission(false, a.crashReason || 'Aircraft lost'); return; }
  game.missionTime += dt;
  if (Math.hypot(a.p.x, a.p.z) > 7300) {
    setMsg('LEAVING OPERATIONAL AREA — TURN BACK NOW', 1);
    a.damage += 1.5 * dt;
  }
  if (game.mission === 'takeoff') {
    const agl = a.p.y - groundH(a.p.x, a.p.z);
    if (game.phase === 0 && !a.onGround && agl > 300) {
      game.phase = 1;
      setMsg('CLIMB COMPLETE — TURN BACK, LAND ON HEADING 000 FROM THE SOUTH THRESHOLD', 6);
    }
    if (a.touchdowns > game.lastTD) {
      game.lastTD = a.touchdowns;
      if (game.phase === 1) {
        game.landPts = evaluateLanding(a);
        game.phase = 2;
      }
    }
    if (game.phase === 2 && a.onGround && a.V < 3) {
      endMission(true, 'Landing complete');
    }
  } else {
    const alive = game.enemies.filter(e => !e.dead).length;
    if (alive === 0) endMission(true, 'All hostiles destroyed');
  }
}

function physicsStep(dt) {
  game.time += dt;
  game.wind.set(1.6 * Math.sin(game.time * 0.21) + 0.8, 0, 1.8 * Math.cos(game.time * 0.13));
  if (game.state !== 'flying') return;
  const a = game.ac;
  if (!a.crashed) {
    const ctl = readControls(dt, a);
    stepAirframe(a, dt, ctl, game.wind);
    spawnPlayerFire(a, dt);
  }
  if (game.mission === 'dogfight') {
    for (const e of game.enemies) {
      if (e.dead) continue;
      if (e.a.crashed) { killEnemy(e); continue; }
      const ctl = enemyAI(e, a, dt);
      stepAirframe(e.a, dt, ctl, game.wind);
      if (e.a.crashed || e.a.onGround) { if (!e.dead) spawnExplosion(e.a.p, 25); killEnemy(e); continue; }
      enemyFire(e, dt);
    }
  }
  stepBullets(dt);
  missionStep(dt);
  game.msgT -= dt;
}

// ============================================================
//  MENU BUILD
// ============================================================
let selPlane = 'falcon', selMission = 'takeoff';
function buildMenu() {
  const wrap = document.getElementById('planeCards');
  wrap.innerHTML = '';
  for (const key of Object.keys(FLEET)) {
    const s = FLEET[key];
    const c = document.createElement('div');
    c.className = 'card' + (key === selPlane ? ' sel' : '');
    c.dataset.key = key;
    const bars = Object.entries(s.bars).map(([k, v]) =>
      `<div class="bar-row"><span class="lbl">${k}</span><div class="track"><div class="fill" style="width:${v * 100}%"></div></div></div>`).join('');
    c.innerHTML = `<div class="role">${s.role}</div><h3>${s.name}</h3><p>${s.desc}</p>
      <div class="stats">${s.stats.map(([a, b]) => `<span>${a}</span><b>${b}</b>`).join('')}</div>
      <div class="bars">${bars}</div>`;
    c.addEventListener('click', () => {
      selPlane = key;
      wrap.querySelectorAll('.card').forEach(x => x.classList.toggle('sel', x.dataset.key === key));
    });
    wrap.appendChild(c);
  }
  document.querySelectorAll('.mission').forEach(m => {
    m.addEventListener('click', () => {
      selMission = m.dataset.mission;
      document.querySelectorAll('.mission').forEach(x => x.classList.toggle('sel', x === m));
    });
  });
  document.getElementById('startBtn').addEventListener('click', () => { startMission(selPlane, selMission); });
  document.getElementById('resumeBtn').addEventListener('click', togglePause);
  document.getElementById('restartBtn').addEventListener('click', () => startMission(game.key, game.mission));
  document.getElementById('menuBtn').addEventListener('click', toMenu);
}

function showResult() {
  game.state = 'done';
  document.getElementById('hud').classList.add('hidden');
  const p = document.getElementById('resultPanel');
  const t = game.missionTime;
  const mins = Math.floor(t / 60), secs = Math.floor(t % 60).toString().padStart(2, '0');
  let title, big, lines;
  if (game.mission === 'takeoff') {
    if (game.endSuccess) {
      const g = game.landPts >= 90 ? 'PERFECT' : game.landPts >= 70 ? 'GOOD' : game.landPts >= 45 ? 'FIRM' : 'ROUGH';
      title = 'MISSION COMPLETE'; big = `${game.landPts}/100 · ${g}`;
    } else { title = 'MISSION FAILED'; big = 'AIRCRAFT LOST'; }
    lines = `<div>Result: ${game.endSuccess ? 'Landed and stopped on the runway' : game.endReason}</div>
      <div>Flight time: ${mins}:${secs}</div><div>Landing: ${game.landNote || 'n/a'}</div>`;
  } else {
    if (game.endSuccess) { title = 'VICTORY'; big = 'ALL HOSTILES DOWN'; }
    else { title = 'MISSION FAILED'; big = 'AIRCRAFT LOST'; }
    lines = `<div>Result: ${game.endSuccess ? 'Air superiority achieved' : game.endReason}</div>
      <div>Kills: ${game.kills} / ${game.enemies.length}</div>
      <div>Engagement time: ${mins}:${secs}</div><div>Rounds remaining: ${game.ammo}</div>`;
  }
  p.innerHTML = `<h1>${title}</h1><div class="big">${big}</div><div class="lines">${lines}</div>
    <div class="row" style="justify-content:center;gap:14px;margin-top:24px;">
      <button id="againBtn">FLY AGAIN</button><button class="ghost" id="backBtn">AIRCRAFT / MISSION MENU</button></div>`;
  document.getElementById('result').classList.remove('hidden');
  document.getElementById('againBtn').onclick = () => startMission(game.key, game.mission);
  document.getElementById('backBtn').onclick = toMenu;
}

// Gun sound (noise burst through the gun filter)
AudioSys.gunShot = function () {
  if (!this.ready) return;
  const ctx = this.ctx, t = ctx.currentTime;
  const src = ctx.createBufferSource(); src.buffer = this.noise;
  const f = ctx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 1100; f.Q.value = 0.8;
  const g = ctx.createGain(); g.gain.setValueAtTime(0.18, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.05);
  src.connect(f); f.connect(g); g.connect(this.master); src.start(t, Math.random()); src.stop(t + 0.06);
};

// ============================================================
//  CAMERA
// ============================================================
function updateCamera(dt, a) {
  const mode = game.camMode;
  _ff.set(1, 0, 0).applyQuaternion(a.q);
  _u.set(0, 1, 0).applyQuaternion(a.q);
  if (mode === 1) {
    // Cockpit: at the canopy, looking along the nose
    _tmp.copy(a.p).addScaledVector(_ff, 1.6).addScaledVector(_u, 1.1);
    camPos.copy(_tmp);
    camLook.copy(a.p).addScaledVector(_ff, 300).addScaledVector(_u, 1.1);
    camera.up.copy(_u);
    camera.position.copy(camPos);
    camera.lookAt(camLook);
    game.mesh.visible = false;
    return;
  }
  game.mesh.visible = !a.crashed;
  if (mode === 0) {
    const dist = 26 + 6 * clamp(a.V / 200, 0, 1), height = 7.5;
    _tmp.copy(a.p).addScaledVector(_ff, -dist).addScaledVector(_u, height);
    const k = game.camInit ? 1 - Math.exp(-dt * 6) : 1;
    camPos.lerp(_tmp, k);
    camLook.copy(a.p).addScaledVector(_ff, 22);
    game.camInit = true;
  } else {
    // External orbit camera - slow orbit about the aircraft
    const t = game.time * 0.08;
    _tmp.set(Math.cos(t) * 55, 18, Math.sin(t) * 55).add(a.p);
    camPos.copy(_tmp);
    camLook.copy(a.p);
    game.camInit = true;
  }
  camera.up.set(0, 1, 0);
  camera.position.copy(camPos);
  camera.lookAt(camLook);
}

// ============================================================
//  HUD
// ============================================================
const $ = id => document.getElementById(id);
const tapePool = { spd: [], alt: [] };
function renderTape(id, pool, value, pxPerUnit, step, bigStep, fmt) {
  const box = $(id);
  const h = box.clientHeight || 260;
  const span = Math.ceil((h / 2) / pxPerUnit / step) * step + step;
  const base = Math.round(value / step) * step;
  let n = 0;
  for (let v = base - span; v <= base + span; v += step) {
    if (v < 0) continue;
    let el = pool[n];
    if (!el) { el = document.createElement('div'); pool.push(el); box.querySelector('.ticks').appendChild(el); }
    el.style.display = 'block';
    el.style.top = (h / 2 - (v - value) * pxPerUnit) + 'px';
    el.textContent = (v % bigStep === 0) ? fmt(v) : '';
    el.style.transform = 'translateY(-50%)';
    n++;
  }
  for (let i = n; i < pool.length; i++) pool[i].style.display = 'none';
}
// Tapes: pixels per unit
const SPD_PX = 1.6, ALT_PX = 0.22;
function updateHUD(a, dt) {
  const kts = a.V * KT;
  $('spdVal').textContent = Math.round(kts);
  $('machVal').textContent = 'M ' + (a.V / 340.3).toFixed(2);
  const altFt = Math.max(0, a.alt * FT);
  $('altVal').textContent = Math.round(altFt);
  $('vsVal').textContent = 'VS ' + Math.round(a.climb * FT * 60 / 100) * 100;
  renderTape('spdTape', tapePool.spd, kts, SPD_PX, 10, 20, v => v);
  renderTape('altTape', tapePool.alt, altFt, ALT_PX, 20, 100, v => v);
  // Heading strip
  const hd = a.hdg || 0;
  let ht = '';
  for (let d = -60; d <= 60; d += 5) {
    const h = ((Math.round(hd / 5) * 5 + d) % 360 + 360) % 360;
    const x = 210 + (d + (hd - Math.round(hd / 5) * 5)) * 6;
    const lbl = { 0: 'N', 90: 'E', 180: 'S', 270: 'W' }[h] || (h % 30 === 0 ? String(h / 10).padStart(2, '0') : '');
    ht += `<i style="left:${x}px"></i>`;
    if (lbl) ht += `<span style="left:${x}px">${lbl}</span>`;
  }
  $('hdgTicks').innerHTML = ht;
  $('hdgVal').textContent = String(Math.round(hd) % 360).padStart(3, '0') + '°';
  // Horizon: pitch ladder & bank
  const pitchDeg = a.pitch / DEG, bankDeg = a.bank / DEG;
  const pxPerDeg = 6;
  const hi = $('horizonInner');
  hi.style.transform = `rotate(${-bankDeg}deg)`;
  const off = pitchDeg * pxPerDeg;
  const top = 50 + (off / 260) * 100;
  hi.style.background = `linear-gradient(to bottom, rgba(70,130,200,0.28) 0, rgba(70,130,200,0.28) ${top - 0.01}%, rgba(90,60,30,0.32) ${top}%, rgba(90,60,30,0.32) 100%)`;
  let lad = '';
  for (let d = -30; d <= 30; d += 5) {
    if (d === 0) continue;
    const y = -(d - pitchDeg) * pxPerDeg;
    const w = d % 10 === 0 ? 70 : 36;
    lad += `<div style="position:absolute;left:50%;top:${y}px;width:${w}px;margin-left:${-w / 2}px;height:1px;background:#9dffb0"></div>`;
    if (d % 10 === 0) lad += `<div style="position:absolute;left:50%;top:${y - 7}px;transform:translateX(-50%);font-size:10px;color:#9dffb0">${Math.abs(d)}</div>`;
  }
  $('pitchLadder').innerHTML = lad;
  // Horizon line at 0 pitch
  $('pitchLadder').style.top = (130) + 'px';
  $('pitchLadder').style.height = '0px';
  $('pitchLadder').style.width = '100%';
  $('pitchLadder').style.transform = 'translateY(0)';

  $('pitchVal').textContent = pitchDeg.toFixed(1) + '°';
  $('bankVal').textContent = bankDeg.toFixed(0) + '°';
  $('gVal').textContent = a.n.toFixed(2) + ' G';
  const slip = a.beta / DEG;
  $('slipVal').textContent = Math.abs(slip) < 0.5 ? 'CTR' : (slip > 0 ? 'L ' : 'R ') + Math.abs(slip).toFixed(1);
  $('densVal').textContent = Math.round(a.rho / RHO0 * 100) + '%';
  const tC = (T0 - 0.0065 * a.alt) - 273.15;
  $('tmpVal').textContent = Math.round(tC) + '°C';

  // Engine & gauges
  $('rpmVal').textContent = Math.round(a.rpm * 100) + '%';
  $('rpmBar').style.width = (a.rpm * 100) + '%';
  const egt = clamp(a.rpm * 0.8 + a.thr * 0.1 + (a.damage / 100) * 0.2, 0, 1);
  $('tempVal').textContent = Math.round(egt * 100) + '%';
  $('tempBar').style.width = (egt * 100) + '%';
  $('tempBar').style.background = egt > 0.9 ? '#ff5a4a' : '';
  const aoaDeg = a.alpha / DEG;
  $('aoaVal').textContent = aoaDeg.toFixed(1) + '°';
  $('aoaBar').style.width = clamp((aoaDeg + 5) / 30, 0, 1) * 100 + '%';
  $('aoaBar').style.background = a.stall ? '#ff5a4a' : '';
  $('cfgVal').textContent = `FLP ${['UP', '½', 'FULL'][a.flap]} / ${a.gearDown ? 'DN' : 'UP'}`;
  $('dmgVal').textContent = Math.round(a.damage) + '%';
  $('thrVal').textContent = Math.round(a.thr * 100) + '%';
  $('thrBar').style.width = (a.thr * 100) + '%';

  // Warnings
  const agl = a.p.y - groundH(a.p.x, a.p.z);
  let warn = '';
  if (a.stall) warn = 'STALL';
  else if (agl < 90 && a.climb < -4 && !a.onGround) warn = 'PULL UP';
  else if (a.V * KT > 600 && game.mission === 'takeoff') warn = 'OVERSPEED';
  else if (a.damage > 70) warn = 'STRUCTURAL DAMAGE';
  const wEl = $('warn');
  wEl.style.display = warn ? 'block' : 'none';
  wEl.textContent = warn;
  // Objectives panel
  const obj = $('obj'), sc = $('score');
  if (game.mission === 'takeoff') {
    const ph = game.phase === 0 ? 'CLIMB TO 1,000 FT AGL' : game.phase === 1 ? 'TURN BACK · LAND HDG 000 · TOUCHDOWN ZONE NEAR THRESHOLD' : 'ROLL OUT AND STOP';
    obj.innerHTML = `<div class="t">OBJECTIVE</div>${ph}`;
    sc.innerHTML = `<div class="t" style="color:#ffe06b;letter-spacing:2px;font-size:11px">FUEL</div>${Math.round(a.fuel / FUEL0[a.key] * 100)}%`;
  } else {
    const alive = game.enemies.filter(e => !e.dead).length;
    obj.innerHTML = `<div class="t">OBJECTIVE</div>DESTROY ${alive} HOSTILE${alive === 1 ? '' : 'S'}`;
    sc.innerHTML = `<div class="t" style="color:#ffe06b;letter-spacing:2px;font-size:11px">KILLS</div>${game.kills}<br><span style="color:#ffe06b;font-size:11px;letter-spacing:2px">AMMO</span> ${game.ammo}`;
  }
  $('msg').textContent = game.msgT > 0 ? game.msgText : '';

  // Gear / flap markers + vertical speed tape is in altTape

  // Blackout / redout by sustained load
  const gN = a.n;
  const bo = gN > 4.5 ? clamp((gN - 4.5) / 3.5, 0, 1) : 0;
  const ro = gN < -1.5 ? clamp((-gN - 1.5) / 2, 0, 1) : 0;
  $('blackout').style.opacity = bo;
  $('redout').style.opacity = ro;
  $('damage').style.background = `rgba(255,0,0,${game.hitFlash > 0 ? 0.25 : 0})`;
  game.hitFlash = Math.max(0, game.hitFlash - dt);

  // Enemy target markers (dogfight)
  const tgt = $('tgt');
  tgt.style.display = 'none';
  if (game.mission === 'dogfight') {
    let best = null, bestD = 1e9;
    for (const e of game.enemies) {
      if (e.dead) continue;
      _ff.copy(e.a.p).project(camera);
      if (_ff.z > 1 || _ff.z < -1) continue;
      const d = e.a.p.distanceTo(a.p);
      if (d < bestD) { bestD = d; best = { e, pr: _ff.clone() }; }
    }
    if (best && bestD < TGT_RANGE_MAX) {
      const sx = (best.pr.x * 0.5 + 0.5) * innerWidth;
      const sy = (-best.pr.y * 0.5 + 0.5) * innerHeight;
      tgt.style.display = 'block';
      tgt.style.left = sx + 'px'; tgt.style.top = sy + 'px';
      $('tgtLbl').textContent = `HOSTILE ${Math.round(bestD)} m · ${Math.round(best.e.hp)}%`;
    }
  }
}

// ============================================================
//  RENDER / FRAME LOOP
// ============================================================
function syncMeshes(dt) {
  const a = game.ac;
  if (game.state === 'flying' || game.state === 'paused' || game.state === 'done') {
    game.mesh.position.copy(a.p);
    game.mesh.quaternion.copy(a.q);
    if (game.prop) game.prop.rotation.x += dt * (60 * a.rpm + 4);
  }
  for (const e of game.enemies) {
    if (e.dead) continue;
    e.mesh.position.copy(e.a.p);
    e.mesh.quaternion.copy(e.a.q);
    if (e.flash > 0) e.flash -= dt;
  }
  // Expl
  for (let i = game.expl.length - 1; i >= 0; i--) {
    const x = game.expl[i];
    x.t += dt;
    const k = x.t / x.life;
    x.mesh.scale.setScalar(Math.max(0.1, x.maxS * Math.sqrt(k)));
    x.mesh.material.opacity = Math.max(0, 1 - k);
    if (k >= 1) { scene.remove(x.mesh); game.expl.splice(i, 1); }
  }
  // Clouds drift with wind
  for (const c of clouds) {
    c.position.x += game.wind.x * dt * 0.5;
    c.position.z += game.wind.z * dt * 0.5;
    if (c.position.x > 8000) c.position.x -= 16000;
    if (c.position.x < -8000) c.position.x += 16000;
    if (c.position.z > 8000) c.position.z -= 16000;
    if (c.position.z < -8000) c.position.z += 16000;
  }
}

let lastT = performance.now(), acc = 0, frameN = 0;
function frame(now) {
  requestAnimationFrame(frame);
  let dt = Math.min(0.1, (now - lastT) / 1000);
  lastT = now;
  frameN++;
  if (game.state === 'flying') {
    acc += dt;
    let steps = 0;
    while (acc >= PHYS_DT && steps < 40) { physicsStep(PHYS_DT); acc -= PHYS_DT; steps++; }
    if (steps >= 40) acc = 0;
  } else if (game.state === 'menu') {
    game.menuT += dt;
    // Idle orbit camera for menu
    const t = game.menuT * 0.05;
    camera.position.set(Math.cos(t) * 900 + 0, 260, Math.sin(t) * 900 - 1400);
    camera.lookAt(0, 90, -1300);
  }
  if (game.state === 'flying' || game.state === 'paused' || game.state === 'done') {
    const a = game.ac;
    syncMeshes(dt);
    if (game.state !== 'paused') updateCamera(dt, a);
    if (game.state === 'flying') {
      if (game.ended && game.endSuccess === false) { /* nothing */ }
      updateHUD(a, dt);
      AudioSys.update(a, { cockpit: game.camMode === 1, nearMountain: smooth(400, 1400, a.alt) * 0.8 });
    }
  } else {
    syncMeshes(dt);
  }
  // Keep the sky following the camera
  renderer.render(scene, camera);
}

function onResize() {
  renderer.setSize(innerWidth, innerHeight, false);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
}
addEventListener('resize', onResize);
onResize();

buildMenu();
window.__dbg = { game, keys };
// Initial menu camera setup
camera.position.set(0, 260, -200);
// Start loop
requestAnimationFrame(frame);
// Tie up loose state
game.ac = newAirframe(selPlane);
game.key = selPlane; game.mission = selMission;
game.mesh = buildAircraft(selPlane).group;
scene.add(game.mesh);
game.mesh.visible = false;
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"94a550d3-24f9-4fe7-a5c9-42202430eca1","tokensIn":2128792,"tokensOut":230332,"tokensTotal":2359124,"cost":0.20988695499999996,"turns":32,"toolCalls":40,"failedToolCalls":0,"timestamp":"2026-10-08T03:29:17.511Z"} -->