← Flight Simulator results

Flight Simulator

Mimo v2.6 Pro · full

Initial view of Flight Simulator

Browser check: External resource blocked. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

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

Generation prompt · exact text unavailable

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

Inspect original source 130,338 bytes · SHA-256 20f289ff8ef8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>APEX AERO — High-Fidelity Flight Simulator</title>
<style>
:root{
  --bg:#05070c; --panel:rgba(10,16,26,.72); --line:rgba(120,200,255,.20);
  --acc:#5fd8ff; --acc2:#ffb45c; --bad:#ff4d63; --good:#7dff9b; --txt:#dce8f4; --dim:#7f92a8;
  --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Roboto Mono",monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--txt);
  font-family:"Segoe UI",Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}
canvas{display:block}
#app{position:fixed;inset:0;z-index:1}
#hud{position:fixed;inset:0;z-index:6;pointer-events:none}
#vig{position:fixed;inset:0;z-index:5;pointer-events:none;opacity:0;transition:opacity .12s linear;
  background:radial-gradient(ellipse at center,rgba(0,0,0,0) 35%,rgba(0,0,0,.55) 72%,rgba(0,0,0,.95) 100%)}
#ui{position:fixed;inset:0;z-index:10;pointer-events:none}
.screen{position:absolute;inset:0;display:flex;flex-direction:column;pointer-events:auto}
.hidden{display:none !important}

/* ---------- MENU ---------- */
#menu{padding:26px 34px;gap:16px;
  background:linear-gradient(90deg,rgba(4,7,12,.94) 0%,rgba(4,7,12,.78) 46%,rgba(4,7,12,.12) 100%)}
.brand{display:flex;align-items:baseline;gap:18px}
.logo{font-size:38px;letter-spacing:.30em;font-weight:800;line-height:1;
  background:linear-gradient(180deg,#eaf7ff,#5fd8ff 62%,#1a86b8);-webkit-background-clip:text;background-clip:text;color:transparent;
  text-shadow:0 0 34px rgba(95,216,255,.18)}
.logo span{font-weight:200;opacity:.95;-webkit-text-fill-color:#a6dcf5}
.sub{font:11px/1 var(--mono);letter-spacing:.22em;color:var(--dim)}
.cols{display:flex;gap:18px;flex:1;min-height:0;max-width:1180px}
.panel{background:var(--panel);border:1px solid var(--line);backdrop-filter:blur(9px);
  padding:14px 16px;overflow:auto;box-shadow:0 18px 50px rgba(0,0,0,.45)}
.panel.fleet{width:430px;display:flex;flex-direction:column;gap:10px}
.panel.specs{width:400px;display:flex;flex-direction:column;gap:12px}
h2{font:600 11px/1 var(--mono);letter-spacing:.24em;color:var(--acc);opacity:.85;margin-bottom:8px}
.card{border:1px solid rgba(120,200,255,.13);background:rgba(255,255,255,.02);padding:11px 13px;cursor:pointer;
  transition:.16s;display:flex;gap:12px;align-items:center}
.card:hover{background:rgba(95,216,255,.07);border-color:rgba(120,200,255,.34)}
.card.on{background:linear-gradient(90deg,rgba(95,216,255,.16),rgba(95,216,255,.03));border-color:var(--acc);
  box-shadow:inset 0 0 22px rgba(95,216,255,.10)}
.card svg{flex:0 0 96px;height:44px;opacity:.95}
.card .nm{font-size:15px;font-weight:650;letter-spacing:.04em}
.card .rl{font:10px/1.5 var(--mono);letter-spacing:.16em;color:var(--dim);margin-top:3px}
.bars{display:flex;flex-direction:column;gap:5px;margin-top:2px}
.bar{display:grid;grid-template-columns:78px 1fr 52px;align-items:center;gap:8px;font:10px/1 var(--mono);letter-spacing:.1em}
.bar i{color:var(--dim);font-style:normal}
.bar u{height:5px;background:rgba(255,255,255,.08);position:relative;text-decoration:none;display:block}
.bar u b{position:absolute;inset:0 auto 0 0;background:linear-gradient(90deg,#2b8fbf,var(--acc));display:block}
.bar em{font-style:normal;text-align:right;color:#bcd6e8}
.mission{border:1px solid rgba(120,200,255,.13);padding:11px 13px;cursor:pointer;transition:.16s;margin-bottom:8px}
.mission:hover{background:rgba(95,216,255,.07)}
.mission.on{border-color:var(--acc2);background:linear-gradient(90deg,rgba(255,180,92,.15),rgba(255,180,92,.02))}
.mission .t{font-size:14px;font-weight:650}
.mission .d{font:10px/1.6 var(--mono);color:var(--dim);letter-spacing:.06em;margin-top:4px}
.big{margin-top:auto;font:700 14px/1 var(--mono);letter-spacing:.26em;padding:16px;color:#04121a;cursor:pointer;border:0;
  background:linear-gradient(90deg,#5fd8ff,#9be8ff);transition:.15s}
.big:hover{filter:brightness(1.12);box-shadow:0 0 30px rgba(95,216,255,.35)}
.foot{font:10px/1 var(--mono);letter-spacing:.14em;color:var(--dim)}
.foot b,kbd{color:#a9c6da;font-weight:600}
kbd{border:1px solid rgba(120,200,255,.25);padding:1px 5px;border-radius:3px;font-family:var(--mono);font-size:10px}

/* ---------- MODALS ---------- */
.modal{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(3,6,11,.72);backdrop-filter:blur(4px)}
.\boxed{width:min(640px,92vw);max-height:86vh;overflow:auto;background:rgba(9,15,25,.95);border:1px solid var(--line);
  padding:26px 30px;box-shadow:0 30px 80px rgba(0,0,0,.6)}
.box h1{font-size:22px;letter-spacing:.18em;font-weight:700;margin-bottom:4px}
.box .tag{font:10px/1 var(--mono);letter-spacing:.22em;color:var(--acc);margin-bottom:18px}
.box p{font:12.5px/1.85 var(--mono);color:#b6c9da;letter-spacing:.02em;margin-bottom:14px}
.box p b{color:#fff}
.btns{display:flex;gap:10px;margin-top:20px;flex-wrap:wrap}
.btn{font:600 11px/1 var(--mono);letter-spacing:.2em;padding:13px 20px;border:1px solid var(--line);
  background:rgba(255,255,255,.04);color:var(--txt);cursor:pointer;transition:.15s}
.btn:hover{background:rgba(95,216,255,.14);border-color:var(--acc)}
.btn.p{background:linear-gradient(90deg,#5fd8ff,#9be8ff);color:#04121a;border:0}
table.kc{width:100%;border-collapse:collapse;font:11.5px/2 var(--mono);letter-spacing:.06em}
table.kc td{border-bottom:1px solid rgba(120,200,255,.10);color:#b6c9da}
table.kc td:first-child{color:#fff;width:170px}
table.kc td:last-child{color:var(--dim);text-align:right}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:8px 26px}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:6px 24px;font:12px/2 var(--mono);margin-bottom:10px}
.stats span{color:var(--dim)}
.stats b{float:right;color:#fff;font-weight:600}
#loading{align-items:center;justify-content:center;gap:18px;background:#05070c}
#loading .lbar{width:340px;height:3px;background:rgba(255,255,255,.10);position:relative}
#loading .lbar b{position:absolute;inset:0 auto 0 0;width:0;background:var(--acc);display:block;transition:width .2s}
#loading .ltxt{font:11px/1 var(--mono);letter-spacing:.3em;color:var(--dim)}
.grade{font-size:64px;font-weight:800;letter-spacing:.06em;line-height:1;margin:6px 0 16px;
  background:linear-gradient(180deg,#fff,var(--acc));-webkit-background-clip:text;background-clip:text;color:transparent}
#err{position:fixed;inset:0;z-index:99;display:flex;align-items:center;justify-content:center;background:#05070c;
  font:13px/2 var(--mono);color:#ff8090;text-align:center;padding:40px}
@media (max-width:900px){.cols{flex-direction:column}.panel.fleet,.panel.specs{width:100%}}
</style>
</head>
<body>
<div id="app"></div>
<canvas id="hud"></canvas>
<div id="vig"></div>
<div id="ui">

  <div id="menu" class="screen">
    <div class="brand">
      <div class="logo">APEX<span>AERO</span></div>
      <div class="sub">6-DOF NEWTONIAN FLIGHT DYNAMICS · PROCEDURAL WORLD</div>
    </div>
    <div class="cols">
      <div class="panel fleet">
        <h2>01 — SELECT AIRFRAME</h2>
        <div id="cards"></div>
      </div>
      <div class="panel specs">
        <h2>02 — PERFORMANCE ENVELOPE</h2>
        <div id="specbars"></div>
        <h2 style="margin-top:12px">03 — SELECT MISSION</h2>
        <div id="missions"></div>
        <button id="start" class="big">START ENGINE</button>
      </div>
    </div>
    <div class="foot">
      <kbd>W</kbd><kbd>S</kbd> PITCH &nbsp; <kbd>A</kbd><kbd>D</kbd> ROLL &nbsp; <kbd>Q</kbd><kbd>E</kbd> RUDDER &nbsp;
      <kbd>SHIFT</kbd><kbd>CTRL</kbd> THROTTLE &nbsp; <kbd>SPACE</kbd> GUNS &nbsp; <kbd>X</kbd> MISSILE &nbsp; <kbd>H</kbd> FULL CONTROL LIST
    </div>
  </div>

  <div id="loading" class="screen hidden">
    <div class="ltxt" id="ltxt">GENERATING TERRAIN</div>
    <div class="lbar"><b id="lbar"></b></div>
  </div>

  <div id="brief" class="screen hidden">
    <div class="modal"><div class="box">
      <h1 id="bfTitle">MISSION</h1>
      <div class="tag" id="bfTag">BRIEFING</div>
      <div id="bfBody"></div>
      <div class="btns">
        <button class="btn p" id="bfGo">LAUNCH</button>
        <button class="btn" id="bfBack">HANGAR</button>
      </div>
    </div></div>
  </div>

  <div id="pause" class="screen hidden">
    <div class="modal"><div class="box">
      <h1>PAUSED</h1>
      <div class="tag">SIMULATION HOLD</div>
      <div id="pcBody"></div>
      <div class="btns">
        <button class="btn p" id="pcResume">RESUME</button>
        <button class="btn" id="pcRestart">RESTART</button>
        <button class="btn" id="pcKeys">CONTROLS</button>
        <button class="btn" id="pcQuit">ABORT TO HANGAR</button>
      </div>
    </div></div>
  </div>

  <div id="help" class="screen hidden">
    <div class="modal"><div class="box">
      <h1>CONTROLS</h1>
      <div class="tag">KEYBOARD · MOUSE · GAMEPAD</div>
      <div class="grid2" id="helpBody"></div>
      <div class="btns"><button class="btn p" id="hpClose">CLOSE</button></div>
    </div></div>
  </div>

  <div id="debrief" class="screen hidden">
    <div class="modal"><div class="box">
      <h1 id="dbTitle">MISSION COMPLETE</h1>
      <div class="tag" id="dbTag">DEBRIEF</div>
      <div class="grade" id="dbGrade">A</div>
      <div class="stats" id="dbStats"></div>
      <div class="btns">
        <button class="btn p" id="dbRetry">FLY AGAIN</button>
        <button class="btn" id="dbHangar">HANGAR</button>
      </div>
    </div></div>
  </div>

</div>
<div id="err" class="hidden"></div>

<script>
window.__apexMain = function(THREE){
"use strict";

/* ======================================================================
   APEX AERO — high fidelity flight simulator
   Custom 6-DOF Newtonian aerodynamic model + procedural world
   ====================================================================== */

/* ---------------------------------------------------------------- utils */
const G0 = 9.80665, RHO0 = 1.225, GAMMA = 1.4, R_AIR = 287.0588;
const D2R = Math.PI/180, R2D = 180/Math.PI;
const clamp = (v,a,b)=> v<a?a:(v>b?b:v);
const lerp  = (a,b,t)=> a+(b-a)*t;
const sstep = (e0,e1,x)=>{ const t = clamp((x-e0)/(e1-e0||1e-9),0,1); return t*t*(3-2*t); };
const wrapPi= (a)=>{ while(a> Math.PI) a-=2*Math.PI; while(a<-Math.PI) a+=2*Math.PI; return a; };
const wrap360=(a)=>{ a%=360; return a<0?a+360:a; };
const fmt = (n,d)=> (n).toFixed(d===undefined?0:d);
const rnd = (a,b)=> a+Math.random()*(b-a);

/* ------------------------------------------------------- value noise/fbm */
function hash2(i,j){
  let n = (Math.imul(i,374761393) + Math.imul(j,668265263)) | 0;
  n = Math.imul(n ^ (n>>>13), 1274126177);
  return ((n ^ (n>>>16))>>>0) / 4294967296;
}
function vnoise(x,y){
  const xi = Math.floor(x), yi = Math.floor(y);
  const xf = x-xi, yf = y-yi;
  const u = xf*xf*(3-2*xf), v = yf*yf*(3-2*yf);
  const a = hash2(xi,yi), b = hash2(xi+1,yi), c = hash2(xi,yi+1), d = hash2(xi+1,yi+1);
  return lerp(lerp(a,b,u), lerp(c,d,u), v)*2-1;   // [-1,1]
}
function fbm(x,y,oct,gain){
  let s=0, a=1, f=1, n=0;
  gain = gain||0.5;
  for(let i=0;i<oct;i++){ s += a*vnoise(x*f, y*f); n += a; a*=gain; f*=2.03; }
  return s/n;
}
function ridged(x,y,oct){
  let s=0, a=1, f=1, n=0;
  for(let i=0;i<oct;i++){ const v = 1-Math.abs(vnoise(x*f,y*f)); s += a*v*v; n += a; a*=0.5; f*=2.11; }
  return s/n;   // 0..1, sharp crests
}

/* ============================ TERRAIN ==================================
   Single deterministic height field shared by renderer + physics + AI.   */
const TER = {
  size: 40000, seg: 512, sea: 0,
  run: { x0:-1300, x1:1300, z0:-30, z1:30, elev:0, len:2600, wid:60 },
  ready:false
};

function terrainRaw(x, z){
  const s = 1/1000;
  // continent / coastline : +x = ocean
  let cont = fbm(x*s*0.085 + 3.1, z*s*0.085 - 7.7, 4);
  cont += (1 - sstep(3500, 13500, x))*0.55;            // keep west land mass
  cont += sstep(13000, 3500, Math.hypot(x, z))*0.75;   // airfield basin is land
  const land = sstep(-0.12, 0.34, cont);               // 0 sea .. 1 land
  // rolling plains + hills
  const plains = fbm(x*s*0.55+21.5, z*s*0.55+9.2, 4)*130 + fbm(x*s*1.9-4.1, z*s*1.9+2.4, 3)*34;
  // ridged mountains, masked to inland
  const m = ridged(x*s*0.22-11.3, z*s*0.22+5.9, 5);
  const mm = sstep(0.30, 0.72, fbm(x*s*0.13+31.7, z*s*0.13-12.3, 3)*0.5+0.5) * land;
  const mtn = Math.pow(m, 2.35) * 2350 * mm;
  // valleys carved near the airfield circuit
  let h = lerp(-95 + plains*0.15, 55 + plains, land) + mtn;
  // gentle dunes near the coast
  h += (1-land) * fbm(x*s*2.6, z*s*2.6, 2) * 8;
  return h;
}

function terrainHeight(x, z){
  let h = terrainRaw(x, z);
  const r = TER.run;
  // airfield plateau blend
  const px = clamp((Math.abs(x) - (r.len*0.5)) / 1500, 0, 1);
  const pz = clamp((Math.abs(z) - 320) / 1500, 0, 1);
  let b = Math.max(px, pz); b = b*b*(3-2*b);
  h = lerp(r.elev, h, b);
  return h;
}
function terrainNormal(x, z, out){
  const d = 24;
  const hx1 = terrainHeight(x+d, z), hx0 = terrainHeight(x-d, z);
  const hz1 = terrainHeight(x, z+d), hz0 = terrainHeight(x, z-d);
  out = out || new THREE.Vector3();
  out.set(hx0-hx1, 2*d, hz0-hz1).normalize();
  return out;
}
function onRunway(x, z, pad){
  const r = TER.run; pad = pad||0;
  return x > r.x0-pad && x < r.x1+pad && Math.abs(z) < r.wid*0.5+pad;
}

/* --------------------------------------------------------- atmosphere ISA */
function atmosphere(alt){
  const h = clamp(alt, -400, 24000);
  const T = 288.15 - 0.0065*h;
  const p = 101325 * Math.pow(T/288.15, 5.2561);
  const rho = p/(R_AIR*T);
  return { T, p, rho, a: Math.sqrt(GAMMA*R_AIR*T), sigma: rho/RHO0 };
}

/* -------------------------------------------------------------- wind field */
const WIND = { base: 5.5, dir: 210, gust: 1, turb: 0 };
function windAt(pos, t, out){
  out = out || new THREE.Vector3();
  const wd = WIND.dir*D2R;
  const b = WIND.base * (0.7 + 0.5*fbm(pos.x*0.00007, pos.z*0.00007 + t*0.004, 2)*0.5+0.5);
  // gradient wind: stronger with altitude, veered
  const alt = Math.max(0, pos.y - TER.run.elev);
  const shear = 1 + Math.min(alt/3000, 1)*1.5;
  out.set(Math.cos(wd)*b*shear, 0, Math.sin(wd)*b*shear);
  // orographic / thermal turbulence (more near terrain & in mountains)
  const agl = pos.y - terrainHeight(pos.x, pos.z);
  const rough = sstep(300, 30, agl)*1.0 + WIND.turb;
  const g = 0.6 + WIND.gust*0.5;
  out.x += fbm(pos.x*0.0013 + t*0.35, pos.z*0.0013, 2) * rough * g * 7.0;
  out.y += fbm(pos.x*0.0011 - t*0.29, pos.z*0.0011 + 5.0, 2) * rough * g * 5.5;
  out.z += fbm(pos.x*0.0014 + 9.0, pos.z*0.0014 - t*0.31, 2) * rough * g * 7.0;
  return out;
}

/* ========================= GEOMETRY HELPERS ========================== */
/* airfoil loop: array of [xc (0=LE .. 1=TE), yc]  (thickness fractions)  */
function airfoilLoop(n, thick, camber){
  camber = camber||0;
  const up = [], lo = [];
  for(let i=0;i<=n;i++){
    const x = i/n;
    const yc = Math.sin(Math.PI*Math.pow(x,0.70))*(1-x*0.5)*thick*0.5 + camber*thick*Math.sin(Math.PI*x);
    up.push([x, yc]); lo.push([x, -yc + 2*camber*thick*Math.sin(Math.PI*x)]);
  }
  return up.concat(lo.reverse());
}
function stationPts(st, loop){
  const out = [];
  for(let i=0;i<loop.length;i++){
    const xc = loop[i][0], yc = loop[i][1];
    out.push(new THREE.Vector3(st.x, (st.y||0) + yc*st.c, st.zLE + xc*st.c));
  }
  return out;
}
function loft(stations, loop, mirror){
  const rings = stations.map(function(s){ return stationPts({x: mirror?-s.x:s.x, zLE:s.zLE, c:s.c, y:s.y||0}, loop); });
  const pos = [];
  function P(v){ pos.push(v.x, v.y, v.z); }
  for(let i=0;i<rings.length-1;i++){
    const A = rings[i], B = rings[i+1];
    for(let j=0;j<A.length;j++){
      const k = (j+1)%A.length;
      if(mirror){ P(A[j]); P(B[j]); P(A[k]); P(A[k]); P(B[j]); P(B[k]); }
      else      { P(A[j]); P(A[k]); P(B[j]); P(A[k]); P(B[k]); P(B[j]); }
    }
  }
  return pos;
}
function loftGeom(stations, t, camber){
  const loop = airfoilLoop(5, t, camber);
  const pos = loft(stations, loop, false).concat(loft(stations, loop, true));
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.computeVertexNormals();
  return g;
}
/* fuselage: lathe along -Z (nose forward). pts = [[zFromTail, radius]] */
function fuselageGeom(pts, wide){
  const v = pts.map(function(p){ return new THREE.Vector2(Math.max(0.002,p[1]), p[0]); });
  const g = new THREE.LatheGeometry(v, 18);
  g.rotateX(-Math.PI/2);
  if(wide && wide!==1) g.scale(wide, 1, 1);
  return g;
}
function mat(color, rough, metal, opts){
  return new THREE.MeshStandardMaterial(Object.assign({
    color: color, roughness: rough===undefined?0.45:rough, metalness: metal===undefined?0.35:metal,
    side: THREE.DoubleSide
  }, opts||{}));
}

/* ============================ THE FLEET ==============================
   All numbers are SI and feed the aerodynamic model directly.          */
const FLEET = [
{
  id:'kestrel', name:'KESTREL T-6', role:'PISTON SPORT / TRAINER',
  desc:'Light taildragger. Gentle, honest, forgiving stall break. The airframe for precision circuits.',
  color:0xe4e9ee, trim:0x1d6fa5,
  mass:1180, massMax:1420, fuel:150, burn:0.008,
  S:15.8, b:11.0, c:1.44,
  CL0:0.20, CLa:5.10, aStall:0.245, flapCL:0.45,
  CD0:0.0295, e:0.80,
  Cm0:0.045, Cma:0.95, Cmq:13.0, Cmde:0.20,
  Clb:0.075, Clp:0.55, Clda:0.090, Cldr:0.014,
  Cnb:0.115, Cnr:0.22, Cndr:0.055, Cnda:0.016,
  CYb:0.55,
  Ixx:1300, Iyy:2200, Izz:780,
  eng:{ type:'prop', power:265000, eta:0.80, Vprop:58, Tstatic:3800, spool:0.55, idle:0.16, redline:2700, torque:0.030, pfactor:0.020 },
  lim:{ G:6.0, Vne:118, Vs0:25.5, Vr:33, gearSpeed:72 },
  gear:[[0,-1.32,-2.05,1],[-1.34,-1.22,0.32,0],[1.34,-1.22,0.32,0]],
  brakeMu:0.55, steer:0.60, ctrl:{p:1.0, r:1.0, y:1.0}, dmg:60,
  stats:{spd:0.30, thr:0.26, lft:0.55, agi:0.66, wgt:0.14, stl:0.24}, hud:'#8ef0ff'
},
{
  id:'talon', name:'TALON XR-9', role:'TWIN-ENGINE AIR-SUPERIORITY FIGHTER',
  desc:'Relaxed static stability, carefree handling, 9G airframe. Fast, unforgiving, lethal in a knife fight.',
  color:0x5a626c, trim:0xffb45c,
  mass:10500, massMax:17800, fuel:3200, burn:0.55,
  S:40.0, b:12.0, c:3.30,
  CL0:0.06, CLa:4.20, aStall:0.32, flapCL:0.35,
  CD0:0.021, e:0.82,
  Cm0:0.030, Cma:0.62, Cmq:8.0, Cmde:0.14,
  Clb:0.055, Clp:0.62, Clda:0.105, Cldr:0.010,
  Cnb:0.130, Cnr:0.30, Cndr:0.048, Cnda:0.030,
  CYb:0.85,
  Ixx:28000, Iyy:62000, Izz:15000,
  eng:{ type:'jet', power:142000, wet:222000, eta:1, Vprop:0, Tstatic:142000, spool:1.6, idle:0.06, redline:100, torque:0, pfactor:0 },
  lim:{ G:9.0, Vne:340, Vs0:58, Vr:72, gearSpeed:130 },
  gear:[[0,-1.55,-3.30,1],[-1.60,-1.55,0.60,0],[1.60,-1.55,0.60,0]],
  brakeMu:0.65, steer:0.35, ctrl:{p:1.0, r:1.0, y:1.0}, dmg:120,
  stats:{spd:0.98, thr:1.00, lft:0.40, agi:1.00, wgt:0.55, stl:0.86}, hud:'#ffd08a'
},
{
  id:'atlas', name:'ATLAS C-4', role:'FOUR-ENGINE TURBOPROP LIFTER',
  desc:'58 tonnes of inertia. Huge wing, docile stall, savage momentum. Runway length is not a suggestion.',
  color:0x8d9a86, trim:0x2f3a2c,
  mass:58000, massMax:70000, fuel:14000, burn:0.55,
  S:180, b:43.0, c:4.30,
  CL0:0.32, CLa:5.40, aStall:0.28, flapCL:0.85,
  CD0:0.032, e:0.80,
  Cm0:0.070, Cma:1.25, Cmq:16.0, Cmde:0.17,
  Clb:0.090, Clp:0.95, Clda:0.125, Cldr:0.018,
  Cnb:0.145, Cnr:0.32, Cndr:0.052, Cnda:0.040,
  CYb:0.80,
  Ixx:620000, Iyy:1150000, Izz:470000,
  eng:{ type:'prop', power:14000000, eta:0.82, Vprop:82, Tstatic:170000, spool:1.5, idle:0.12, redline:1300, torque:0.012, pfactor:0.008 },
  lim:{ G:3.0, Vne:180, Vs0:44, Vr:58, gearSpeed:105 },
  gear:[[0,-3.30,-7.20,1],[-2.60,-3.10,1.20,0],[2.60,-3.10,1.20,0]],
  brakeMu:0.45, steer:0.25, ctrl:{p:1.0, r:1.0, y:1.0}, dmg:220,
  stats:{spd:0.46, thr:0.55, lft:0.95, agi:0.16, wgt:1.00, stl:0.42}, hud:'#b6f0a0'
}
];
FLEET.forEach(function(a){ a.AR = a.b*a.b/a.S; });

/* enemy airframe (dogfight) */
const AGGRESSOR = Object.assign({}, FLEET[1], {
  id:'aggr', name:'BANDIT V-4', color:0x6b3f3f, trim:0xff4d63,
  mass:9200, dmg:100, hud:'#ff8090'
});

const MISSIONS = [
  { id:'circuit', name:'PRECISION FLIGHT / CIRCUIT & FIELD',
    sub:'TAKEOFF / WAYPOINT CIRCUIT / LANDING / FULL STOP',
    tag:'MISSION 01 / FLIGHT QUALIFICATION',
    body:'<p><b>RWY 09</b> - 2 600 m asphalt. Density altitude is computed live; hot and high means a longer run.</p>'
       + '<p><b>01</b> Release the parking brake, advance throttle, rotate at <b>Vr</b>.<br>'
       + '<b>02</b> Climb to 300 m and fly the four smoke gates in order.<br>'
       + '<b>03</b> Fly a left circuit, land on <b>RWY 09</b>, touch down in the first 800 m and stop.</p>'
       + '<p>Scored on rotation speed, gate accuracy, touchdown vertical speed, centreline offset and stop distance.</p>' },
  { id:'dogfight', name:'DOGFIGHT / FOX & HOUND',
    sub:'HIGH-G MANOEUVERING / GUNS & MISSILES / THREE BANDITS',
    tag:'MISSION 02 / AIR COMBAT',
    body:'<p>Three <b>BANDIT V-4</b> interceptors, 12 km east over the ridge line. They fly the full 6-DOF model and will exploit your energy state.</p>'
       + '<p><b>SPACE</b> - 30 mm cannon (lead-computing reticle). <b>X</b> - IR missile, requires tone.<br>'
       + '<b>T</b> - cycle target. <b>F</b> - flaps. <b>G</b> - gear. <b>C</b> - camera.</p>'
       + '<p>Mind your <b>corner speed</b>. Pulling 9G bleeds energy faster than the engine can return it, and a slow fighter is a dead one. '
       + 'Wingtip vortices and slipstream from other aircraft buffet the airframe.</p>' }
];

/* ============================ AUDIO =================================
   Fully synthesised: engine, airflow, buffet, ground, weapons, alerts  */
const AudioSys = {
  ctx:null, on:true, ready:false, _al:null,
  init:function(){
    if(this.ready) return;
    const AC = window.AudioContext || window.webkitAudioContext; if(!AC) return;
    const c = this.ctx = new AC();
    this.master = c.createGain(); this.master.gain.value = 0.85; this.master.connect(c.destination);
    this.engGain = c.createGain(); this.engGain.gain.value = 0;
    this.engLP = c.createBiquadFilter(); this.engLP.type='lowpass'; this.engLP.frequency.value=900;
    this.engGain.connect(this.engLP); this.engLP.connect(this.master);
    this.osc = []; this.og = [];
    [1,2,3,4.02,6.03].forEach(function(h,i){
      const o = c.createOscillator(); o.type = i===0?'sawtooth':'square'; o.frequency.value = 60*h;
      const g = c.createGain(); g.gain.value = [0.55,0.3,0.2,0.1,0.06][i];
      o.connect(g); g.connect(this.engGain); o.start(); this.osc.push(o); this.og.push(g);
    }, this);
    this.whine = c.createOscillator(); this.whine.type='sine'; this.whine.frequency.value=800;
    this.whineG = c.createGain(); this.whineG.gain.value=0;
    this.whine.connect(this.whineG); this.whineG.connect(this.master); this.whine.start();
    const len = c.sampleRate*2, buf = c.createBuffer(1, len, c.sampleRate), d = buf.getChannelData(0);
    let last = 0;
    for(let i=0;i<len;i++){ const w = Math.random()*2-1; last = last*0.94 + w*0.06; d[i] = w*0.55 + last*1.4; }
    this.noiseBuf = buf;
    const mkNoise = function(){ const s = c.createBufferSource(); s.buffer = buf; s.loop = true; s.start(); return s; };
    this.windSrc = mkNoise();
    this.windBP = c.createBiquadFilter(); this.windBP.type='bandpass'; this.windBP.frequency.value=420; this.windBP.Q.value=1.4;
    this.windGain = c.createGain(); this.windGain.gain.value = 0;
    this.windSrc.connect(this.windBP); this.windBP.connect(this.windGain); this.windGain.connect(this.master);
    this.whiSrc = mkNoise();
    this.whiBP = c.createBiquadFilter(); this.whiBP.type='bandpass'; this.whiBP.frequency.value=2400; this.whiBP.Q.value=8;
    this.whiGain = c.createGain(); this.whiGain.gain.value = 0;
    this.whiSrc.connect(this.whiBP); this.whiBP.connect(this.whiGain); this.whiGain.connect(this.master);
    this.rumSrc = mkNoise();
    this.rumLP = c.createBiquadFilter(); this.rumLP.type='lowpass'; this.rumLP.frequency.value=95;
    this.rumGain = c.createGain(); this.rumGain.gain.value = 0;
    this.rumSrc.connect(this.rumLP); this.rumLP.connect(this.rumGain); this.rumGain.connect(this.master);
    this.alertGain = c.createGain(); this.alertGain.gain.value = 0; this.alertGain.connect(this.master);
    this.alertOsc = c.createOscillator(); this.alertOsc.type='square'; this.alertOsc.frequency.value=760;
    this.alertOsc.connect(this.alertGain); this.alertOsc.start();
    this.ready = true;
  },
  resume:function(){ if(this.ctx && this.ctx.state==='suspended') this.ctx.resume(); },
  set:function(v){ this.on = v; if(this.master) this.master.gain.value = v?0.85:0; },
  engine:function(rpm, load, sigma, wet){
    if(!this.ready) return;
    const t = this.ctx.currentTime, H = [1,2,3,4.02,6.03];
    const f0 = 24 + rpm*118 + load*22;
    for(let i=0;i<this.osc.length;i++){
      this.osc[i].frequency.setTargetAtTime(f0*H[i], t, 0.05);
      this.og[i].gain.setTargetAtTime([0.5,0.26,0.17,0.09,0.05][i]*(0.5+load*0.6), t, 0.08);
    }
    this.engGain.gain.setTargetAtTime(0.17*(0.35+rpm*0.8)*(0.55+load*0.6)*Math.sqrt(sigma), t, 0.08);
    this.engLP.frequency.setTargetAtTime(500 + rpm*1800 + load*600, t, 0.1);
    this.whine.frequency.setTargetAtTime(400 + rpm*2600, t, 0.06);
    this.whineG.gain.setTargetAtTime((wet?0.055:0.014)*rpm*Math.sqrt(sigma), t, 0.08);
  },
  airflow:function(q, aoa, buffet, mach){
    if(!this.ready) return;
    const t = this.ctx.currentTime;
    this.windBP.frequency.setTargetAtTime(300 + q*0.55 + aoa*900, t, 0.1);
    this.windBP.Q.setTargetAtTime(0.9 + aoa*3.5, t, 0.1);
    this.windGain.gain.setTargetAtTime(Math.min(0.42, q*0.00030), t, 0.08);
    this.whiBP.frequency.setTargetAtTime(1500 + q*1.1 + mach*2200, t, 0.1);
    this.whiGain.gain.setTargetAtTime(Math.min(0.14, q*0.00008 + aoa*aoa*0.13), t, 0.1);
    this.rumGain.gain.setTargetAtTime(Math.max(this.rumGain.gain.value*0.55, Math.min(0.5, buffet*0.5)), t, 0.06);
    this.rumLP.frequency.setTargetAtTime(60 + q*0.05, t, 0.1);
  },
  ground:function(speed, rumble){
    if(!this.ready) return;
    const t = this.ctx.currentTime;
    this.rumLP.frequency.setTargetAtTime(70 + speed*1.6, t, 0.1);
    this.rumGain.gain.setTargetAtTime(Math.max(this.rumGain.gain.value*0.5, Math.min(0.55, rumble)), t, 0.06);
  },
  alert:function(kind){
    if(!this.ready) return;
    const t = this.ctx.currentTime;
    if(this._al){ clearInterval(this._al); this._al = null; }
    if(!kind){ this.alertGain.gain.setTargetAtTime(0, t, 0.05); return; }
    const cfg = { stall:[880,1,0.35], gpws:[560,2,0.5], lock:[1300,2,0.25], warn:[420,1,0.6], fire:[300,3,0.5] }[kind] || [880,1,0.3];
    this.alertOsc.frequency.setTargetAtTime(cfg[0], t, 0.01);
    const self = this; let on = false;
    var tick = function(){ on = !on; self.alertGain.gain.setTargetAtTime(on?cfg[2]:0, self.ctx.currentTime, 0.012); };
    tick(); this._al = setInterval(tick, 1000/cfg[1]/2);
  },
  oneshot:function(kind, vol){
    if(!this.ready) return;
    const c = this.ctx, t = c.currentTime;
    const s = c.createBufferSource(); s.buffer = this.noiseBuf; s.loop = true;
    const f = c.createBiquadFilter(); const g = c.createGain(); vol = vol||1;
    if(kind==='gun'){ f.type='bandpass'; f.frequency.value=1100; f.Q.value=0.7;
      g.gain.setValueAtTime(0.30*vol, t); g.gain.exponentialRampToValueAtTime(0.0005, t+0.09); s.start(t); s.stop(t+0.12); }
    else if(kind==='boom'){ f.type='lowpass'; f.frequency.setValueAtTime(900,t); f.frequency.exponentialRampToValueAtTime(60, t+1.1);
      g.gain.setValueAtTime(0.85*vol, t); g.gain.exponentialRampToValueAtTime(0.0005, t+1.3); s.start(t); s.stop(t+1.4); }
    else if(kind==='whoosh'){ f.type='bandpass'; f.frequency.setValueAtTime(300,t); f.frequency.exponentialRampToValueAtTime(2400, t+0.4);
      g.gain.setValueAtTime(0.22*vol, t); g.gain.exponentialRampToValueAtTime(0.0005, t+0.55); s.start(t); s.stop(t+0.6); }
    else if(kind==='thud'){ f.type='lowpass'; f.frequency.value=180;
      g.gain.setValueAtTime(0.7*vol, t); g.gain.exponentialRampToValueAtTime(0.0005, t+0.35); s.start(t); s.stop(t+0.4); }
    else if(kind==='click'){ f.type='highpass'; f.frequency.value=2200;
      g.gain.setValueAtTime(0.25*vol, t); g.gain.exponentialRampToValueAtTime(0.0005, t+0.05); s.start(t); s.stop(t+0.06); }
    else return;
    s.connect(f); f.connect(g); g.connect(this.master);
  }
};

/* ========================= AIRCRAFT MODELS =========================== */
function geomFrom(pos){
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.computeVertexNormals();
  return g;
}
function finGeom(stations, t, camber){
  return geomFrom(loft(stations, airfoilLoop(5, t, camber||0), false));
}
function mesh(g, m){ return new THREE.Mesh(g, m); }

function buildAircraft(spec){
  const M = {
    body : mat(spec.color, 0.42, 0.42),
    trim : mat(spec.trim, 0.45, 0.35),
    dark : mat(0x15181d, 0.7, 0.25),
    metal: mat(0x8b929c, 0.28, 0.9),
    glass: mat(0x9fd8ff, 0.06, 0.95, {transparent:true, opacity:0.42}),
    rubber: mat(0x0c0d10, 0.9, 0.02),
    glow : new THREE.MeshBasicMaterial({color:0xffa040, transparent:true, opacity:0.0, blending:THREE.AdditiveBlending, depthWrite:false})
  };
  const G = new THREE.Group();
  const P = { pivots:{}, props:[], gear:[], wheels:[], lights:[], M:M };

  function hinge(px,py,pz, geom, matl, axis){
    const piv = new THREE.Object3D(); piv.position.set(px,py,pz);
    const mm = mesh(geom, matl); piv.add(mm); G.add(piv);
    return piv;
  }
  function wheel(r, w, px, py, pz, strutTo){
    const gg = new THREE.CylinderGeometry(r, r, w, 14);
    gg.rotateZ(Math.PI/2);
    const m = mesh(gg, M.rubber); m.position.set(px, py, pz);
    const hub = mesh(new THREE.CylinderGeometry(r*0.45, r*0.45, w*1.05, 10).rotateZ(Math.PI/2), M.metal);
    hub.position.copy(m.position);
    G.add(m); G.add(hub); P.wheels.push(m, hub);
    if(strutTo){
      const a = new THREE.Vector3(strutTo[0], strutTo[1], strutTo[2]);
      const b = new THREE.Vector3(px, py, pz);
      const len = a.distanceTo(b), mid = a.clone().add(b).multiplyScalar(0.5);
      const st = mesh(new THREE.CylinderGeometry(r*0.22, r*0.22, len, 8), M.metal);
      st.position.copy(mid);
      st.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), b.clone().sub(a).normalize());
      G.add(st); P.gear.push(st, m, hub);
    }
  }
  function prop(radius, blades, px,py,pz, hubR){
    const grp = new THREE.Group(); grp.position.set(px,py,pz);
    const bg = new THREE.BoxGeometry(radius*0.16, radius*0.95, 0.055);
    bg.translate(0, radius*0.55, 0);
    for(let i=0;i<blades;i++){
      const b = mesh(bg, M.dark); b.rotation.z = i*Math.PI*2/blades; b.rotation.y = 0.32;
      grp.add(b);
    }
    grp.add(mesh(new THREE.ConeGeometry(hubR||radius*0.13, radius*0.3, 12).rotateX(-Math.PI/2), M.metal));
    const disc = mesh(new THREE.CircleGeometry(radius, 22), new THREE.MeshBasicMaterial({
      color:0xc9d6e0, transparent:true, opacity:0.0, side:THREE.DoubleSide, depthWrite:false}));
    grp.add(disc);
    G.add(grp); P.props.push({grp:grp, disc:disc});
    return grp;
  }

  if(spec.id==='kestrel'){
    /* --- KESTREL T-6 : low wing taildragger ------------------------------ */
    G.add(mesh(fuselageGeom([
      [-3.35,0.015],[-2.7,0.11],[-1.9,0.22],[-1.0,0.31],[-0.1,0.375],[0.7,0.40],
      [1.5,0.385],[2.1,0.34],[2.55,0.30],[3.0,0.245],[3.4,0.155],[3.62,0.05],[3.68,0.01]
    ], 1.08), M.body));
    // canopy
    const cn = mesh(new THREE.SphereGeometry(0.36, 16, 10), M.glass);
    cn.scale.set(0.9, 0.85, 1.9); cn.position.set(0, 0.30, 0.35); G.add(cn);
    // wing
    const wingSt = [
      {x:0.0,  zLE:-0.72, c:1.80, t:0.15, y:-0.22},
      {x:1.9,  zLE:-0.52, c:1.55, t:0.14, y:-0.10},
      {x:5.5,  zLE:-0.02, c:1.05, t:0.12, y: 0.34}
    ];
    G.add(mesh(loftGeom(wingSt, 0.15, 0.02), M.body));
    P.pivots.ailL = hinge(-4.5, 0.28, 0.72, new THREE.BoxGeometry(1.7, 0.05, 0.34), M.trim, 'x');
    P.pivots.ailR = hinge( 4.5, 0.28, 0.72, new THREE.BoxGeometry(1.7, 0.05, 0.34), M.trim, 'x');
    // tail plane + fin
    G.add(mesh(loftGeom([
      {x:0, zLE:2.25, c:1.0, t:0.11, y:0.05},{x:1.85, zLE:2.5, c:0.7, t:0.10, y:0.12}
    ], 0.11, 0.02), M.body));
    G.add(mesh(finGeom([
      {x:0, zLE:2.0, c:1.25, t:0.13, y:0.1},{x:1.5, zLE:2.5, c:0.8, t:0.11, y:0.1}
    ], 0.13, 0.02).rotateZ(Math.PI/2), M.trim));
    P.pivots.elev = hinge(0, 0.06, 2.75, new THREE.BoxGeometry(3.5, 0.05, 0.3), M.trim);
    P.pivots.rud  = hinge(0, 1.2, 2.9, new THREE.BoxGeometry(0.05, 1.3, 0.3), M.trim);
    // engine + prop
    P.propAxle = new THREE.Object3D(); P.propAxle.position.set(0, 0.02, -3.62); G.add(P.propAxle);
    prop(1.35, 3, 0, 0, 0, 0.16);
    G.remove(P.props[P.props.length-1].grp); P.propAxle.add(P.props[P.props.length-1].grp);
    // gear
    wheel(0.33, 0.20, -1.34, -1.05, 0.30, [-0.42,-0.30,0.18]);
    wheel(0.33, 0.20,  1.34, -1.05, 0.30, [ 0.42,-0.30,0.18]);
    wheel(0.20, 0.13,  0.0,  -1.14, -2.75, [0,-0.35,-2.55]);
    P.steerMesh = P.wheels[P.wheels.length-2];
    spec.hitR = 4.5;
  }
  else if(spec.id==='talon' || spec.id==='aggr'){
    /* --- TALON XR-9 / BANDIT : twin tail fighter ------------------------ */
    G.add(mesh(fuselageGeom([
      [-5.0,0.02],[-4.2,0.32],[-3.2,0.60],[-1.8,0.85],[-0.2,0.98],[1.4,0.95],
      [2.8,0.82],[4.2,0.62],[5.6,0.42],[7.0,0.24],[8.2,0.10],[8.6,0.02]
    ], 0.86), M.body));
    // chines / strakes
    const chine = mesh(new THREE.BoxGeometry(2.4, 0.1, 5.4), M.trim);
    chine.position.set(0, -0.05, -3.2); chine.scale.set(1, 1, 1); G.add(chine);
    // canopy
    const cn = mesh(new THREE.SphereGeometry(0.5, 18, 12), M.glass);
    cn.scale.set(0.75, 0.7, 2.3); cn.position.set(0, 0.62, -2.2); G.add(cn);
    const hd = mesh(new THREE.SphereGeometry(0.45, 12, 8), M.dark);
    hd.scale.set(0.7, 0.6, 0.5); hd.position.set(0, 0.45, -1.3); G.add(hd);
    // swept wing (cranked delta)
    G.add(mesh(loftGeom([
      {x:0.0, zLE:-2.6, c:5.2, t:0.06, y:0.0},
      {x:3.2, zLE: 0.1, c:3.0, t:0.05, y:0.12},
      {x:6.0, zLE: 1.9, c:1.2, t:0.04, y:0.22}
    ], 0.05, 0.0), M.body));
    // leading edge extensions
    const lex = mesh(new THREE.BoxGeometry(1.5, 0.07, 3.2), M.trim);
    lex.position.set(0, 0.1, -3.4); G.add(lex);
    // horizontal tail
    G.add(mesh(loftGeom([
      {x:0, zLE:3.9, c:2.6, t:0.05, y:0.05},{x:2.7, zLE:5.0, c:1.3, t:0.04, y:0.12}
    ], 0.05, 0.0), M.body));
    P.pivots.elev = hinge(0, 0.06, 5.7, new THREE.BoxGeometry(5.0, 0.06, 0.55), M.trim);
    P.pivots.elevL = P.pivots.elev; P.pivots.elevR = P.pivots.elev;
    // canted twin fins
    [-1,1].forEach(function(s){
      const f = finGeom([
        {x:0, zLE:3.4, c:3.1, t:0.06, y:0},{x:1.9, zLE:4.7, c:1.7, t:0.05, y:0}
      ], 0.06, 0).rotateZ(Math.PI/2);
      const fm = mesh(f, M.trim);
      fm.position.set(s*1.15, 0.35, 0); fm.rotation.z = s*0.28; G.add(fm);
    });
    P.pivots.rud = hinge(0, 1.4, 5.0, new THREE.BoxGeometry(0.06, 2.0, 0.5), M.body);
    P.pivots.rud.position.set(0, 1.4, 5.2);
    P.pivots.ailL = hinge(-4.6, 0.16, 1.9, new THREE.BoxGeometry(2.4, 0.06, 0.7), M.trim);
    P.pivots.ailR = hinge( 4.6, 0.16, 1.9, new THREE.BoxGeometry(2.4, 0.06, 0.7), M.trim);
    // intakes + nozzles
    [-1,1].forEach(function(s){
      const inl = mesh(new THREE.BoxGeometry(0.55, 0.7, 2.6), M.dark);
      inl.position.set(s*0.9, -0.1, -1.2); G.add(inl);
      const nz = mesh(new THREE.CylinderGeometry(0.42, 0.36, 0.9, 14).rotateX(Math.PI/2), M.metal);
      nz.position.set(s*0.62, -0.02, 5.1); G.add(nz);
      const fl = mesh(new THREE.ConeGeometry(0.36, 2.4, 12, 1, true).rotateX(Math.PI/2), M.glow);
      fl.position.set(s*0.62, -0.02, 6.2); G.add(fl); P['flame'+(s>0?'R':'L')] = fl;
    });
    // gear
    wheel(0.42, 0.26, 0, -1.35, -3.3, [0,-0.35,-3.0]);
    wheel(0.55, 0.34, -1.6, -1.3, 0.6, [-0.6,-0.35,0.5]);
    wheel(0.55, 0.34,  1.6, -1.3, 0.6, [ 0.6,-0.35,0.5]);
    spec.hitR = 6.5;
  }
  else {
    /* --- ATLAS C-4 : high wing four engine lifter ------------------------ */
    G.add(mesh(fuselageGeom([
      [-8.4,0.02],[-7.4,0.5],[-5.5,1.25],[-3.0,1.85],[0,2.15],[3.5,2.2],[6.5,2.05],
      [9.0,1.7],[11.0,1.15],[12.4,0.55],[13.0,0.05]
    ], 1.15), M.body));
    const nose = mesh(new THREE.SphereGeometry(1.5, 16, 12), M.trim);
    nose.scale.set(1.0, 0.9, 1.3); nose.position.set(0, 0.5, -9.4); G.add(nose);
    // flight deck glass
    const cn = mesh(new THREE.BoxGeometry(2.2, 0.7, 1.2), M.glass);
    cn.position.set(0, 1.35, -8.4); G.add(cn);
    // high wing
    G.add(mesh(loftGeom([
      {x:0.0,  zLE:-2.6, c:4.6, t:0.16, y:1.85},
      {x:8.0,  zLE:-1.9, c:4.0, t:0.15, y:2.15},
      {x:21.5, zLE:-0.7, c:2.6, t:0.13, y:2.85}
    ], 0.16, 0.04), M.body));
    // tail boom + T tail
    G.add(mesh(loftGeom([
      {x:0, zLE:6.2, c:6.2, t:0.11, y:0.2},{x:4.4, zLE:7.8, c:3.4, t:0.10, y:0.5}
    ], 0.11, 0.03), M.body));
    G.add(mesh(finGeom([
      {x:0, zLE:6.4, c:6.6, t:0.14, y:0.5},{x:5.4, zLE:8.6, c:3.6, t:0.12, y:0.5}
    ], 0.14, 0.03).rotateZ(Math.PI/2), M.trim));
    P.pivots.elev = hinge(0, 6.0, 10.6, new THREE.BoxGeometry(8.2, 0.09, 0.7), M.trim);
    P.pivots.elevL = P.pivots.elev; P.pivots.elevR = P.pivots.elev;
    P.pivots.rud  = hinge(0, 3.4, 10.2, new THREE.BoxGeometry(0.09, 4.4, 0.8), M.body);
    P.pivots.ailL = hinge(-15.5, 2.8, 1.0, new THREE.BoxGeometry(8.5, 0.09, 0.9), M.trim);
    P.pivots.ailR = hinge( 15.5, 2.8, 1.0, new THREE.BoxGeometry(8.5, 0.09, 0.9), M.trim);
    // four turboprops
    [-6.6,-2.2,2.2,6.6].forEach(function(px){
      const nac = mesh(fuselageGeom([[-1.6,0.02],[-1.0,0.36],[0.4,0.5],[1.9,0.46],[3.0,0.28],[3.4,0.05]], 1.0), M.body);
      nac.position.set(px, 1.6, -2.4); G.add(nac);
      const ax = new THREE.Object3D(); ax.position.set(px, 1.6, -4.05); G.add(ax);
      const before = P.props.length;
      prop(2.15, 4, 0, 0, 0, 0.2);
      const pg = P.props[P.props.length-1].grp;
      G.remove(pg); ax.add(pg);
      P.propAxles = P.propAxles || []; P.propAxles.push(ax);
      void before;
    });
    // gear pods + gear
    [-1,1].forEach(function(s){
      const pod = mesh(fuselageGeom([[-1.5,0.02],[-0.5,0.5],[1.2,0.62],[2.6,0.4],[3.1,0.05]], 1.0), M.body);
      pod.position.set(s*2.1, -1.4, 0.4); G.add(pod);
      wheel(0.62, 0.4, s*1.55, -3.0, 1.2, [s*2.1,-1.7,1.0]);
      wheel(0.62, 0.4, s*2.6, -3.0, 1.2, [s*2.1,-1.7,1.0]);
    });
    wheel(0.6, 0.38, 0, -2.9, -7.4, [0,-1.9,-7.0]);
    spec.hitR = 15;
  }
  // nav lights
  const mkLight = function(x,y,z,col){
    const m = mesh(new THREE.SphereGeometry(0.09, 8, 6), new THREE.MeshBasicMaterial({color:col}));
    m.position.set(x,y,z); G.add(m); P.lights.push(m);
  };
  mkLight(-spec.b*0.5, 0.2, 0.2, 0xff3344);
  mkLight( spec.b*0.5, 0.2, 0.2, 0x33ff66);
  mkLight(0, spec.id==='atlas'?6:1.4, spec.id==='atlas'?10:3.0, 0xffffff);

  P.group = G;
  P.setGear = function(t){ // 0 = up, 1 = down
    const vis = t > 0.02;
    P.gear.forEach(function(m){ m.visible = vis; });
    P.wheels.forEach(function(m){ m.visible = vis; });
    P.gearT = t;
  };
  P.update = function(dt, ctl, rpm, wet, throttle){
    // control surfaces
    if(P.pivots.elev)  P.pivots.elev.rotation.x  = -ctl.elev*0.42;
    if(P.pivots.ailL)  P.pivots.ailL.rotation.x  =  ctl.ail*0.35;
    if(P.pivots.ailR)  P.pivots.ailR.rotation.x  = -ctl.ail*0.35;
    if(P.pivots.rud)   P.pivots.rud.rotation.y   = -ctl.rud*0.42;
    // prop spin
    P.props.forEach(function(p){
      p.grp.rotation.z -= dt*rpm*(spec.eng.type==='jet'?30:44);
      p.disc.material.opacity = Math.min(0.24, rpm*0.3);
      p.disc.material.rotation = p.grp.rotation.z*0.3;
    });
    if(P.flameL){
      const w = wet? 1 : 0;
      P.flameL.material.opacity = 0.10 + throttle*0.25 + w*0.45;
      P.flameL.scale.set(1, 1, 0.5 + throttle*0.7 + w*0.9);
      P.flameR.material.opacity = P.flameL.material.opacity;
      P.flameR.scale.copy(P.flameL.scale);
    }
  };
  return P;
}

/* ==================== 6-DOF FLIGHT DYNAMICS MODEL ====================
   Body frame:  +X right (starboard)   +Y up   +Z aft   =>  forward = -Z
   Rates:       wx = pitch-up rate    wy = yaw-left rate   wz = roll-left rate
   State integrated in world frame (position/velocity), body frame (q, omega)
   ===================================================================== */
class Aircraft {
  constructor(spec, x, y, z, hdg, opts){
    opts = opts||{};
    this.spec = spec;
    this.isPlayer = !!opts.isPlayer;
    this.name = opts.name || spec.name;
    this.team = opts.team || 'blue';
    this.dryMass = spec.mass - spec.fuel;
    this.mass = spec.mass;
    this.fuel = spec.fuel;
    this.pos = new THREE.Vector3(x, y, z);
    this.vel = new THREE.Vector3(0,0,0);
    this.quat = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, -hdg||0, 0, 'YXZ'));
    this.omega = new THREE.Vector3();
    this.act = {elev:0, ail:0, rud:0};
    this.ctl = {pitch:0, roll:0, yaw:0, throttle:0, brake:false, parking:true, flaps:0, gear:1, wet:false};
    this.rpm = 0; this.egt = 20; this.oil = 20; this.stress = 0;
    this.gearPos = 1; this.flapPos = 0;
    this.hp = spec.dmg; this.maxHp = spec.dmg;
    this.gearDown = true;
    this.onGround = true; this.grounded = 0;
    this.gLoad = 1; this.gLat = 0;
    this.aoa = 0; this.beta = 0; this.V = 0; this.mach = 0; this.qbar = 0;
    this.stall = 0; this.buffet = 0; this.gearVel = 0;
    this.fire = 0; this.smoke = 0;
    this.alive = true; this.time = 0;
    this.turb = new THREE.Vector3();
    this.turbM = new THREE.Vector3();
    this.telem = {};
    this.wingDrop = 0;
    this.lastVS = 0; this.touchVS = 0; this.touchFpm = 0;
    this.model = null;
    if(this.ctl.parking===undefined) this.ctl.parking = true;
  }
  fwd(out){ out = out||new THREE.Vector3(); return out.set(0,0,-1).applyQuaternion(this.quat); }
  up(out){ out = out||new THREE.Vector3(); return out.set(0,1,0).applyQuaternion(this.quat); }
  right(out){ out = out||new THREE.Vector3(); return out.set(1,0,0).applyQuaternion(this.quat); }

  heading(){ return wrap360(Math.atan2(this.fwd().x, -this.fwd().z)*R2D); }
  pitchAng(){ return Math.asin(clamp(this.fwd().y,-1,1))*R2D; }
  rollAng(){ const u = this.up(), r = this.right(); return Math.atan2(-r.y, u.y)*R2D; }

  /* -------------------------------------------------- engine + actuators */
  updateEngine(dt){
    const e = this.spec.eng, c = this.ctl;
    let target = e.idle + clamp(c.throttle,0,1)*(1-e.idle);
    if(this.fuel <= 0) target = 0;
    const tau = e.spool*(0.45 + 0.55*(1-this.rpm));
    this.rpm += (target - this.rpm)*(1-Math.exp(-dt/Math.max(0.05,tau)));
    // exhaust gas temperature & thermal stress
    const load = clamp(c.throttle,0,1)*(c.wet?1.35:1);
    const tgt = 20 + this.rpm*430 + load*260 + (c.wet?330:0);
    this.egt += (tgt - this.egt)*(1-Math.exp(-dt*(tgt>this.egt?0.10:0.04)));
    if(this.egt > 830) this.stress += (this.egt-830)*dt*0.02; else this.stress = Math.max(0, this.stress - dt*0.05);
    // actuator lag (control surface response time)
    const a = 1-Math.exp(-dt/0.055);
    this.act.elev += (clamp(c.pitch,-1,1)*this.spec.ctrl.p - this.act.elev)*a;
    this.act.ail  += (clamp(c.roll ,-1,1)*this.spec.ctrl.r - this.act.ail )*a;
    this.act.rud  += (clamp(c.yaw  ,-1,1)*this.spec.ctrl.y - this.act.rud )*a;
    // gear / flap actuation
    this.gearPos += ((c.gear>0.5?1:0) - this.gearPos)*(1-Math.exp(-dt/1.1));
    this.flapPos += (c.flaps*0.5 - this.flapPos)*(1-Math.exp(-dt/0.7));
    if(this.fuel > 0){
      const burn = this.spec.burn*6*(0.22+0.78*clamp(c.throttle,0,1))*(c.wet?2.1:1)*dt;
      this.fuel = Math.max(0, this.fuel - burn);
      this.mass = this.dryMass + this.fuel;
    }
  }
  thrust(V, rho, sigma){
    const e = this.spec.eng;
    const p = e.power*this.rpm*sigma;
    if(this.fuel <= 0) return 0;
    let T;
    if(e.type==='prop'){
      T = Math.min(e.eta*p/Math.max(V, e.Vprop*0.55), e.Tstatic*sigma*this.rpm);
    } else {
      T = e.power*this.rpm*Math.pow(sigma,0.86)*(1 - 0.30*clamp(V/320,0,1));
      if(this.ctl.wet && this.spec.eng.wet) T += (this.spec.eng.wet-this.spec.eng.power)*this.rpm*Math.pow(sigma,0.86);
    }
    return Math.max(0, T);
  }

  /* ------------------------------------------------------- aero + forces */
  step(dt, world){
    if(!this.alive){ this.deadStep(dt, world); return; }
    this.time += dt;
    this.updateEngine(dt);
    const sp = this.spec;
    const atm = atmosphere(this.pos.y);
    const rho = atm.rho, sigma = atm.sigma;
    const wind = windAt(this.pos, this.time, this._w || (this._w = new THREE.Vector3()));
    const vAir = this.vel.clone().sub(wind);
    const V = vAir.length();
    this.V = V; this.mach = V/atm.a; this.qbar = 0.5*rho*V*V;
    const qi = this.quat.clone().conjugate();
    const vb = vAir.clone().applyQuaternion(qi);
    const alpha = V>1 ? Math.atan2(-vb.y, Math.max(-vb.z, 0.0001)) : 0;
    const beta  = V>1 ? Math.asin(clamp(vb.x/V, -1, 1)) : 0;
    this.aoa = alpha; this.beta = beta;

    const F = new THREE.Vector3(0, -this.mass*G0, 0);     // gravity
    const M = new THREE.Vector3();
    const fwdW = this.fwd(), upW = this.up(), rightW = this.right();

    /* ---------- aerodynamic coefficients ---------- */
    const flap = this.flapPos;
    const aS = sp.aStall - flap*0.03;
    const sig = sstep(aS, aS+0.14, Math.abs(alpha));            // stall blend
    this.stall = sig;
    const machLoss = 1 - 0.55*sstep(0.86, 1.06, this.mach);
    let CL = (1-sig)*(sp.CL0 + sp.CLa*alpha*machLoss + flap*sp.flapCL)
           +  sig*(Math.sin(2*alpha)*0.95 + Math.sign(alpha)*0.25*Math.sin(Math.abs(alpha)*2.2));
    // ground effect (within one wingspan of the surface)
    const agl = Math.max(0, this.pos.y - terrainHeight(this.pos.x, this.pos.z));
    const ge = sstep(sp.b*1.0, sp.b*0.15, agl);
    CL *= (1 + ge*0.16);
    const CDi = CL*CL/(Math.PI*sp.e*sp.AR)*(1-ge*0.62);
    const wave = 0.22*Math.pow(Math.max(0, this.mach-0.74), 1.8);
    const CD = sp.CD0 + CDi + wave
             + flap*flap*sp.flapCL*0.06 + flap*0.022
             + (this.gearPos>0.05 ? 0.021*this.gearPos : 0)
             + sig*Math.sin(alpha)*Math.sin(alpha)*0.9;
    const CY = -sp.CYb*beta;
    const qS = this.qbar*sp.S;

    /* ---------- aerodynamic force in wind axes ---------- */
    if(V > 0.5){
      const vDir = vAir.clone().multiplyScalar(1/V);
      const liftDir = new THREE.Vector3().crossVectors(rightW, vDir).normalize();
      if(!isFinite(liftDir.x)) liftDir.copy(upW);
      F.addScaledVector(liftDir, qS*CL);
      F.addScaledVector(vDir, -qS*CD);
      F.addScaledVector(rightW, qS*CY);
    }

    /* ---------- propulsion ---------- */
    const T = this.thrust(V, rho, sigma);
    F.addScaledVector(fwdW, T);
    const powerOut = sp.eng.power*this.rpm*sigma;
    // propeller torque reaction + P-factor (yaw with power at high alpha)
    M.z += sp.eng.torque*0.20*powerOut*(sp.eng.type==='prop'?1:0);
    M.y += sp.eng.pfactor*1.0*powerOut*clamp(alpha, 0, 0.5)*(sp.eng.type==='prop'?1:0);

    /* ---------- stability & control moments (body axes) ---------- */
    const b = sp.b, c = sp.c, w = this.omega;
    const bV = b/Math.max(V, 8), cV = c/Math.max(V, 8);
    const glim = 1 - sstep(sp.lim.G*0.85, sp.lim.G*1.05, Math.abs(this.gLoad));   // carefree G limiter
    M.x += qS*c*( sp.Cmde*this.act.elev*(0.35+0.65*glim) - sp.Cma*alpha - sp.Cmq*cV*w.x );
    M.y += qS*b*( -sp.Cnb*beta - sp.Cnr*bV*w.y - sp.Cndr*this.act.rud + sp.Cnda*this.act.ail*0.35 );
    M.z += qS*b*( sp.Clb*beta - sp.Clp*bV*w.z - sp.Clda*this.act.ail + sp.Cldr*this.act.rud );
    // stall asymmetry -> wing drop / spin tendency
    this.wingDrop += (vnoise(this.time*0.7, this.pos.y*0.01) - this.wingDrop)*Math.min(1, dt*3);
    M.z += qS*b*sig*0.055*(this.wingDrop + this.act.ail*0.35);
    M.x -= qS*c*sig*0.05*Math.abs(w.x);

    /* ---------- external turbulence / slipstream ---------- */
    F.add(this.turb); this.turb.multiplyScalar(Math.max(0, 1-dt*2.5));
    M.add(this.turbM); this.turbM.multiplyScalar(Math.max(0, 1-dt*2.5));

    /* ---------- integration (linear) ---------- */
    this.vel.addScaledVector(F, dt/this.mass);
    const disp = this.vel.clone().multiplyScalar(dt);
    this.pos.add(disp);

    /* ---------- rotational dynamics: I*w' = M - w x (Iw) ---------- */
    const I = new THREE.Vector3(sp.Ixx, sp.Iyy, sp.Izz);
    const L = new THREE.Vector3(w.x*I.x, w.y*I.y, w.z*I.z);
    const gyro = new THREE.Vector3().crossVectors(w, L);
    w.x += (M.x - gyro.x)/I.x*dt;
    w.y += (M.y - gyro.y)/I.y*dt;
    w.z += (M.z - gyro.z)/I.z*dt;
    w.multiplyScalar(Math.max(0, 1 - dt*0.02));          // tiny inertial damping of rate noise
    const dq = new THREE.Quaternion(w.x*dt*0.5, w.y*dt*0.5, w.z*dt*0.5, 1);
    this.quat.multiply(dq).normalize();

    /* ---------- ground contact ---------- */
    this.groundContact(dt);

    /* ---------- derived telemetry ---------- */
    const G = this.gLoad;
    void G;
    this.buffet = clamp(sig*0.9 + Math.abs(this.gLoad)*0.035*(Math.abs(this.gLoad)>sp.lim.G*0.7?1:0.15)
                       + ge*0.0 + (this.onGround? this.V*0.006 : 0), 0, 1.2);
    this.lastVS = this.vel.y;
    // structural stress
    if(Math.abs(this.gLoad) > sp.lim.G){
      this.stress += (Math.abs(this.gLoad)-sp.lim.G)*dt*0.35;
    }
    if(V > sp.lim.Vne) this.stress += (V-sp.lim.Vne)*dt*0.25;
    if(this.stress > 100) this.damage(400, 'structural failure');
    if(this.egt > 900) this.damage(dt*14, 'engine fire');
    if(this.fire > 0) this.fire = Math.max(0, this.fire - dt*0.15);
    if(this.hp < this.maxHp*0.4) this.smoke = 1; else if(this.hp > this.maxHp*0.5) this.smoke = Math.max(0, this.smoke-dt);
    return { alpha, beta, V, rho, sigma, T, CL, CD, agl };
  }
  deadStep(dt, world){
    // tumbling wreck
    this.vel.y -= G0*dt*0.6;
    this.pos.addScaledVector(this.vel, dt);
    this.omega.multiplyScalar(0.995);
    const dq = new THREE.Quaternion(this.omega.x*dt*0.5, this.omega.y*dt*0.5, this.omega.z*dt*0.5, 1);
    this.quat.multiply(dq).normalize();
    const h = terrainHeight(this.pos.x, this.pos.z);
    if(this.pos.y < h){ this.pos.y = h; this.vel.multiplyScalar(0.2); }
    void world;
  }
  groundContact(dt){
    const sp = this.spec;
    let contact = 0, maxPen = 0;
    const n = this._n || (this._n = new THREE.Vector3());
    const wp = this._wp || (this._wp = new THREE.Vector3());
    const rel = this._rel || (this._rel = new THREE.Vector3());
    for(let i=0;i<sp.gear.length;i++){
      const g = sp.gear[i];
      wp.set(g[0], g[1], g[2]).applyQuaternion(this.quat).add(this.pos);
      const h = terrainHeight(wp.x, wp.z);
      const pen = h - wp.y;
      if(pen <= 0) continue;
      contact++;
      maxPen = Math.max(maxPen, pen);
      terrainNormal(wp.x, wp.z, n);
      // point velocity = v + w x r
      const r = wp.clone().sub(this.pos);
      const wr = new THREE.Vector3().crossVectors(this.omega, r);
      // convert body-frame rotation to world for contact velocity
      const wrW = wr.applyQuaternion(this.quat);
      rel.copy(this.vel).add(wrW);
      const vn = rel.dot(n);
      const kN = this.mass*G0/(sp.gear.length*0.045);
      const cN = 0.85*Math.sqrt(kN*this.mass/sp.gear.length);
      let FN = kN*pen - cN*vn;
      FN = clamp(FN, 0, this.mass*G0*22);
      const F = n.clone().multiplyScalar(FN);
      // tyre friction in the ground plane
      const vt = rel.clone().addScaledVector(n, -vn);
      const vLen = vt.length();
      if(vLen > 0.001){
        // rolling axis: horizontal projection of body forward (steered for nose wheel)
        let roll = this.fwd().clone().addScaledVector(n, -this.fwd().dot(n));
        if(g[3]===1) roll.applyAxisAngle(n, -this.act.rud*sp.steer*(this.onGround?1:0));
        roll.normalize();
        const lat = new THREE.Vector3().crossVectors(n, roll).normalize();
        const vRoll = vt.dot(roll), vLat = vt.dot(lat);
        const mu = (g[3]===1?0.7:1.0);
        const N = FN;
        // lateral grip (spring-damped, saturating at mu*N)
        let flat = -clamp(vLat*2200*(this.mass/1200), -1, 1)*N*mu*1.2;
        flat = clamp(flat, -N*mu*1.2, N*mu*1.2);
        // rolling resistance + brakes
        const brake = (this.ctl.brake||this.ctl.parking) ? 1 : 0;
        const muRoll = 0.022 + brake*sp.brakeMu*(this.ctl.parking?1.1:1);
        let froll = 0;
        if(Math.abs(vRoll) > 0.02) froll = -Math.sign(vRoll)*N*muRoll;
        else if(brake) froll = -vRoll*8000*(this.mass/1200);
        else froll = -vRoll*300*(this.mass/1200);
        froll = clamp(froll, -N*mu*1.3, N*mu*1.3);
        F.addScaledVector(lat, flat).addScaledVector(roll, froll);
        void vLen;
      }
      // apply force + torque about CG
      const tq = new THREE.Vector3().crossVectors(r, F).applyQuaternion(this.quat.clone().conjugate());
      this.vel.addScaledVector(F, dt/this.mass);
      const I = new THREE.Vector3(sp.Ixx, sp.Iyy, sp.Izz);
      this.omega.x += tq.x/I.x*dt;
      this.omega.y += tq.y/I.y*dt;
      this.omega.z += tq.z/I.z*dt;
      this.touchVS = Math.min(this.touchVS, vn);
    }
    const wasGround = this.onGround;
    this.onGround = contact > 0;
    this.grounded = contact;
    if(this.onGround && !wasGround){
      this.touchFpm = this.touchVS*196.85;
      this.touchVS = 0;
      if(this.isPlayer) Game.onTouchdown(this.touchFpm);
    }
    if(!this.onGround) this.touchVS = 0;
    void maxPen;
  }
  damage(n, cause){
    if(!this.alive) return;
    this.hp -= n;
    this.fire = Math.max(this.fire, n>20?1:0.4);
    if(this.hp <= 0){ this.hp = 0; this.destroy(cause); }
  }
  destroy(cause){
    if(!this.alive) return;
    this.alive = false;
    this.deathCause = cause || 'destroyed';
    this.omega.set(rnd(-1.4,1.4), rnd(-0.8,0.8), rnd(-1.6,1.6));
    this.vel.multiplyScalar(0.85);
    FX.explosion(this.pos.clone(), this.spec.hitR*1.4);
    AudioSys.oneshot('boom', 0.9);
    if(Game && Game.onKill) Game.onKill(this);
  }
  /* turbulence from another aircraft's wake / slipstream */
  wake(src, strength){
    const d = this.pos.clone().sub(src.pos);
    const s = src.fwd();
    const along = d.dot(s);
    if(along < 0 || along > 260) return;
    const lateral = d.clone().addScaledVector(s, -along).length();
    const rad = 12 + along*0.13;
    if(lateral > rad) return;
    const k = strength*(1-lateral/rad)*(1-along/260);
    const n1 = vnoise(this.time*3.1, 4.2), n2 = vnoise(this.time*2.7, 9.1), n3 = vnoise(this.time*3.6, 13.7);
    this.turb.add(new THREE.Vector3(n1, n2*1.4, n3).multiplyScalar(k*this.mass*0.9));
    this.turbM.add(new THREE.Vector3(n2, n3, n1*1.5).multiplyScalar(k*this.mass*2.2));
    this.buffet = Math.min(1.2, this.buffet + k*1.4);
  }
}

/* ======================= FX / WEAPONS ================================ */
function softTex(col, hard){
  const c = document.createElement('canvas'); c.width = c.height = 128;
  const x = c.getContext('2d');
  const g = x.createRadialGradient(64,64,4,64,64,62);
  g.addColorStop(0, col); g.addColorStop(hard?0.35:0.45, col.replace('1)','0.55)'));
  g.addColorStop(1, 'rgba(0,0,0,0)');
  x.fillStyle = g; x.fillRect(0,0,128,128);
  return new THREE.CanvasTexture(c);
}

const FX = {
  parts:[], sprites:[], lines:[], scene:null,
  init:function(scene){
    this.scene = scene;
    this.texBoom = softTex('rgba(255,235,180,1)', true);
    this.texSmoke = softTex('rgba(180,185,195,1)', false);
    this.texFire = softTex('rgba(255,150,60,1)', true);
  },
  spawnSprite:function(tex, pos, size, life, grow, col, grav){
    let s = this.sprites.pop();
    if(!s){
      s = new THREE.Sprite(new THREE.SpriteMaterial({map:this.texSmoke, transparent:true, depthWrite:false}));
      this.scene.add(s);
    }
    s.material.map = tex; s.material.color.set(col===undefined?0xffffff:col);
    s.material.blending = THREE.NormalBlending; s.material.opacity = 1;
    s.visible = true;
    s.position.copy(pos); s.scale.set(size, size, 1);
    this.lines.push({t:'s', s:s, life:life, max:life, grow:grow, grav:grav||0});
  },
  explosion:function(pos, size){
    size = size||6;
    const s = this.spawnSprite(this.texBoom, pos, size*0.8, 0.45, size*3.4, 0xffd9a0);
    this.parts.push({t:'flash', s:s.s});
    for(let i=0;i<10;i++){
      this.spawnSprite(this.texSmoke, pos.clone().add(new THREE.Vector3(rnd(-1,1),rnd(-1,1),rnd(-1,1)).multiplyScalar(size*0.4)),
        size*rnd(0.5,1.0), rnd(1.2,2.6), size*rnd(0.8,1.6), 0x2a2c30, rnd(-2,2));
    }
    for(let i=0;i<5;i++){
      this.spawnSprite(this.texFire, pos.clone().add(new THREE.Vector3(rnd(-1,1),rnd(-1,1),rnd(-1,1)).multiplyScalar(size*0.3)),
        size*rnd(0.35,0.7), rnd(0.3,0.6), size*rnd(0.6,1.2), 0xff9040);
    }
  },
  smoke:function(pos, size){
    this.spawnSprite(this.texSmoke, pos, size, 1.6, size*1.5, 0x303236, 0.5);
  },
  hit:function(pos){
    this.spawnSprite(this.texFire, pos, 1.6, 0.18, 3.0, 0xfff2c0);
  },
  tracer:function(a, b, col){
    const g = new THREE.BufferGeometry().setFromPoints([a, b]);
    const m = new THREE.LineBasicMaterial({color:col||0xffd070, transparent:true, opacity:0.95, blending:THREE.AdditiveBlending, depthWrite:false});
    const l = new THREE.Line(g, m); this.scene.add(l);
    this.lines.push({t:'l', s:l, life:0.10, max:0.10, geo:g});
  },
  flash:function(pos, size, col){
    this.spawnSprite(this.texFire, pos, size, 0.07, size*2, col||0xffd080);
  },
  update:function(dt){
    for(let i=this.lines.length-1;i>=0;i--){
      const o = this.lines[i];
      o.life -= dt;
      const k = Math.max(0, o.life/o.max);
      if(o.t==='s'){
        o.s.material.opacity = k*0.9;
        const g = 1 + (1-k)*o.grow;
        o.s.scale.set(o.s.scale.x*(1+dt*o.grow*0.6), o.s.scale.y*(1+dt*o.grow*0.6), 1);
        o.s.position.y += o.grav*dt;
        void g;
        if(o.life <= 0){ o.s.visible = false; this.sprites.push(o.s); this.lines.splice(i,1); }
      } else {
        o.s.material.opacity = k;
        if(o.life <= 0){ this.scene.remove(o.s); o.geo.dispose(); o.s.material.dispose(); this.lines.splice(i,1); }
      }
    }
  }
};

const Weapons = {
  bullets:[], missiles:[], pool:[], mpool:[], scene:null, aircrafts:[],
  init:function(scene){
    this.scene = scene;
    const bg = new THREE.BoxGeometry(0.16, 0.16, 5.5);
    const bm = new THREE.MeshBasicMaterial({color:0xffd88a, blending:THREE.AdditiveBlending, transparent:true, depthWrite:false});
    for(let i=0;i<220;i++){
      const m = new THREE.Mesh(bg, bm); m.visible = false; scene.add(m); this.pool.push(m);
    }
  },
  fireGun:function(ac, spread){
    if(!ac.alive) return;
    const sp = ac.spec;
    const f = ac.fwd(), r = ac.right(), u = ac.up();
    for(let i=0;i<2;i++){
      const m = this.pool.pop();
      if(!m) return;
      const off = (i? 1: -1)*(sp.hitR*0.45);
      const p = ac.pos.clone().addScaledVector(r, off).addScaledVector(f, sp.hitR*0.9).addScaledVector(u, -0.2);
      const dir = f.clone().addScaledVector(r, rnd(-spread,spread)).addScaledVector(u, rnd(-spread,spread)).normalize();
      this.bullets.push({
        pos:p, vel:dir.multiplyScalar(940).add(ac.vel), life:2.2, dmg:7, owner:ac, m:m, team:ac.team
      });
      m.visible = true; m.position.copy(p);
    }
    FX.flash(ac.pos.clone().addScaledVector(f, sp.hitR*0.9), 1.6, 0xffe0a0);
    AudioSys.oneshot('gun', 0.5);
  },
  fireMissile:function(ac, target, scene){
    if(!ac.alive || !target) return;
    let m = this.mpool.pop();
    const g = new THREE.Group();
    const body = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 2.6, 8).rotateX(Math.PI/2), new THREE.MeshStandardMaterial({color:0xd8dde3, roughness:0.5}));
    const nose = new THREE.Mesh(new THREE.ConeGeometry(0.09, 0.5, 8).rotateX(Math.PI/2), new THREE.MeshStandardMaterial({color:0xffb45c}));
    nose.position.z = 1.55;
    g.add(body); g.add(nose);
    this.scene.add(g);
    const f = ac.fwd();
    this.missiles.push({
      pos: ac.pos.clone().addScaledVector(f, ac.spec.hitR).add(new THREE.Vector3(0,-0.6,0)),
      vel: f.clone().multiplyScalar(120).add(ac.vel), target:target, owner:ac, life:14, g:g,
      trail:[], speed:0, team:ac.team
    });
    AudioSys.oneshot('whoosh', 0.7);
    void m; void scene;
    return true;
  },
  update:function(dt, list){
    this.aircrafts = list;
    // ---- bullets
    for(let i=this.bullets.length-1;i>=0;i--){
      const b = this.bullets[i];
      b.vel.y -= G0*dt;
      b.pos.addScaledVector(b.vel, dt);
      b.life -= dt;
      b.m.position.copy(b.pos);
      b.m.lookAt(b.pos.clone().add(b.vel));
      let hit = false;
      for(let j=0;j<list.length;j++){
        const a = list[j];
        if(a===b.owner || !a.alive) continue;
        if(a.team === b.owner.team) continue;
        const d = a.pos.distanceTo(b.pos);
        if(d < a.spec.hitR){
          a.damage(b.dmg, 'guns');
          FX.hit(b.pos.clone());
          hit = true;
          if(b.owner.isPlayer) Game.onHit(a, b.dmg);
          break;
        }
      }
      const gh = terrainHeight(b.pos.x, b.pos.z);
      if(hit || b.life <= 0 || b.pos.y < gh){
        if(b.pos.y < gh && !hit) FX.hit(b.pos.clone().setY(gh+0.4));
        b.m.visible = false; this.pool.push(b.m); this.bullets.splice(i,1);
      }
    }
    // ---- missiles
    for(let i=this.missiles.length-1;i>=0;i--){
      const s = this.missiles[i];
      s.life -= dt;
      const tgt = s.target;
      let alive = s.life > 0;
      if(tgt && tgt.alive){
        const to = tgt.pos.clone().sub(s.pos);
        const dist = to.length();
        const cur = s.vel.clone().normalize();
        const want = to.normalize();
        const ax = new THREE.Vector3().crossVectors(cur, want);
        const ang = Math.acos(clamp(cur.dot(want), -1, 1));
        const maxTurn = 42*D2R*dt;
        if(ang > 1e-4 && ax.lengthSq() > 1e-8){
          const q = new THREE.Quaternion().setFromAxisAngle(ax.normalize(), Math.min(ang, maxTurn));
          s.vel.copy(cur.applyQuaternion(q));
        }
        s.speed = Math.min(760, s.speed + 320*dt*1.6);
        s.vel.setLength(Math.max(160, s.speed));
        if(dist < 14){
          tgt.damage(65, 'missile');
          FX.explosion(s.pos.clone(), 9);
          AudioSys.oneshot('boom', 0.8);
          if(s.owner && s.owner.isPlayer) Game.onHit(tgt, 65);
          alive = false;
        }
      }
      s.pos.addScaledVector(s.vel, dt);
      if(alive && s.pos.y < terrainHeight(s.pos.x, s.pos.z)){
        FX.explosion(s.pos.clone(), 7); AudioSys.oneshot('boom', 0.5); alive = false;
      }
      s.g.position.copy(s.pos);
      s.g.lookAt(s.pos.clone().add(s.vel));
      if(Math.random() < dt*70) FX.smoke(s.pos.clone(), 1.6);
      if(!alive){
        this.scene.remove(s.g); this.missiles.splice(i,1);
      }
    }
  }
};

/* ============================ AI PILOTS ==============================
   Bank-to-turn autopilot on the same 6-DOF model: rolls the lift vector
   onto the target then commands a load factor through the AoA loop.    */
class Pilot {
  constructor(ac, skill){
    this.ac = ac; this.skill = skill===undefined?0.8:skill;
    this.mode = 'engage'; this.timer = 0; this.wander = new THREE.Vector3();
    this.gunTimer = 0; this.missileTimer = rnd(6, 14); this.evade = 0;
  }
  update(dt, world){
    const ac = this.ac; if(!ac.alive) return;
    const sp = ac.spec;
    const T = this.target;
    let goal = null;
    const agl = ac.pos.y - terrainHeight(ac.pos.x, ac.pos.z);
    const V = Math.max(ac.V, 20);

    if(T && T.alive){
      const d = T.pos.clone().sub(ac.pos);
      const dist = d.length();
      this.evade = Math.max(0, this.evade - dt);
      // lead pursuit with gravity/energy bias
      const tLead = clamp(dist/820, 0.05, 1.6);
      goal = T.pos.clone().addScaledVector(T.vel, tLead);
      goal.y += dist*0.02*(1-this.skill);
      // energy management: extend when slow
      if(ac.V < sp.lim.Vs0*1.35 && dist < 500){
        goal = ac.pos.clone().addScaledVector(ac.fwd(), 3000); goal.y = ac.pos.y + 250;
      }
      // break turn when threatened from behind
      if(dist < 900 && this.evade <= 0 && Math.random() < dt*0.35*(1.3-this.skill)){
        this.evade = rnd(1.5, 3.2);
      }
      if(this.evade > 0){
        goal = ac.pos.clone().addScaledVector(ac.right(), 2200).addScaledVector(ac.up(), 500).addScaledVector(ac.fwd(), 900);
      }
      // guns
      this.gunTimer -= dt;
      const aim = ac.fwd(), to = d.clone().normalize();
      const off = Math.acos(clamp(aim.dot(to), -1, 1));
      if(dist < 1100 && dist > 60 && off < 3.0*D2R + (1-this.skill)*2.5*D2R && this.gunTimer <= 0){
        Weapons.fireGun(ac, 0.006 + (1-this.skill)*0.010);
        this.gunTimer = 0.06;
      }
      // missiles
      this.missileTimer -= dt;
      if(this.missileTimer <= 0 && dist > 1200 && dist < 6500 && off < 8*D2R && T.isPlayer!==undefined){
        Weapons.fireMissile(ac, T);
        this.missileTimer = rnd(18, 30);
      }
    } else {
      goal = this.patrol(world);
    }
    // ---- terrain avoidance
    const sink = ac.vel.y;
    if(agl < 220 - sink*4 && sink < 6){
      goal = ac.pos.clone().addScaledVector(ac.fwd(), 1600);
      goal.y = terrainHeight(ac.pos.x + ac.fwd().x*1600, ac.pos.z + ac.fwd().z*1600) + 420;
    }
    // ---- bank-to-turn controller
    const qi = ac.quat.clone().conjugate();
    const f = ac.fwd();
    const to = goal.clone().sub(ac.pos).normalize();
    const ang = Math.acos(clamp(f.dot(to), -1, 1));
    const axW = new THREE.Vector3().crossVectors(f, to);
    const axB = axW.lengthSq() > 1e-9 ? axW.normalize().applyQuaternion(qi) : new THREE.Vector3();

    // bank command: put the lift vector on the target
    const bankCur = ac.rollAng()*D2R;
    let bankCmd = clamp(-axB.z*ang*2.6*(0.7+this.skill*0.5), -1.32, 1.32);
    if(agl < 260) bankCmd = clamp(bankCmd, -0.5, 0.5);
    const pR = -ac.omega.z;
    ac.ctl.roll = clamp(1.35*(bankCmd - bankCur) - 0.30*pR, -1, 1);

    // load factor command -> AoA hold
    let nCmd = clamp(0.35 + 3.4*ang*(0.7+this.skill*0.5), 0.2, sp.lim.G*0.94);
    if(this.evade > 0) nCmd = sp.lim.G*0.97;
    if(agl < 200) nCmd = Math.max(nCmd, sp.lim.G*0.85);
    const qbar = Math.max(ac.qbar, 200);
    let aCmd = nCmd*ac.mass*G0/(qbar*sp.S*Math.max(sp.CLa, 0.1));
    aCmd = clamp(aCmd + 0.03, -0.25, sp.aStall*0.93);
    const pitchIn = clamp(3.2*(aCmd - ac.aoa) - 0.55*ac.omega.x, -1, 1);
    ac.ctl.pitch = pitchIn;
    // coordinated flight
    ac.ctl.yaw = clamp(2.4*ac.beta*R2D*0.06 + 0.2*Math.sin(ac.time*0.7), -0.6, 0.6);
    // throttle
    ac.ctl.throttle = (T && T.alive && ac.V < (T.V+40)) ? 1 : (ac.V > sp.lim.Vne*0.85 ? 0.35 : 0.95);
    ac.ctl.wet = (this.evade > 0) || (T && T.alive && ac.V < T.V - 30);
    ac.ctl.gear = 0; ac.ctl.flaps = 0;
    void world; void V;
  }
  patrol(world){
    this.timer -= 0.016;
    if(!this.goal || this.timer <= 0){
      this.timer = rnd(6, 12);
      this.goal = new THREE.Vector3(rnd(-14000, 14000), rnd(600, 3200), rnd(-14000, 14000));
      this.goal.y = Math.max(this.goal.y, terrainHeight(this.goal.x, this.goal.z) + 500);
    }
    return this.goal;
  }
}

/* ============================ WORLD ================================= */
const World = {
  init:function(container){
    const r = this.renderer = new THREE.WebGLRenderer({antialias:true, powerPreference:'high-performance'});
    r.setPixelRatio(Math.min(window.devicePixelRatio||1, 1.8));
    r.setSize(window.innerWidth, window.innerHeight);
    r.outputEncoding = THREE.sRGBEncoding;
    r.toneMapping = THREE.ACESFilmicToneMapping;
    r.toneMappingExposure = 1.05;
    container.appendChild(r.domElement);
    const s = this.scene = new THREE.Scene();
    s.fog = new THREE.FogExp2(0x9db8cf, 0.000042);
    this.camera = new THREE.PerspectiveCamera(62, window.innerWidth/window.innerHeight, 0.6, 60000);

    const sunDir = new THREE.Vector3(-0.42, 0.55, 0.72).normalize();
    this.sunDir = sunDir;
    const sun = new THREE.DirectionalLight(0xfff2dd, 2.35);
    sun.position.copy(sunDir).multiplyScalar(4000); s.add(sun);
    s.add(new THREE.HemisphereLight(0xbcd8f5, 0x4a4a38, 0.85));
    const amb = new THREE.AmbientLight(0x88a0b8, 0.22); s.add(amb);

    // ---- sky dome
    const skyMat = new THREE.ShaderMaterial({
      side: THREE.BackSide, depthWrite:false, fog:false,
      uniforms:{ uSun:{value:sunDir} },
      vertexShader: 'varying vec3 vDir; void main(){ vDir = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }',
      fragmentShader: [
        'varying vec3 vDir; uniform vec3 uSun;',
        'void main(){',
        '  vec3 d = normalize(vDir);',
        '  vec3 zen = vec3(0.16,0.34,0.66); vec3 hor = vec3(0.74,0.84,0.94); vec3 gnd = vec3(0.42,0.46,0.50);',
        '  vec3 col = mix(hor, zen, pow(max(d.y,0.0), 0.52));',
        '  if(d.y < 0.0) col = mix(hor, gnd, pow(min(-d.y,1.0), 0.42));',
        '  float s = max(dot(d, normalize(uSun)), 0.0);',
        '  col += vec3(1.0,0.86,0.62)*pow(s, 700.0)*4.0;',
        '  col += vec3(1.0,0.80,0.55)*pow(s, 6.0)*0.30;',
        '  col += vec3(1.0,0.55,0.30)*pow(max(0.0, 1.0-abs(d.y)*3.4), 3.0)*0.08;',
        '  gl_FragColor = vec4(col, 1.0);',
        '}'
      ].join('\n')
    });
    this.sky = new THREE.Mesh(new THREE.SphereGeometry(30000, 32, 20), skyMat);
    this.sky.frustumCulled = false;
    s.add(this.sky);

    // ---- water
    this.waterMat = new THREE.ShaderMaterial({
      transparent:true, fog:false,
      uniforms:{ uT:{value:0}, uSun:{value:sunDir}, uCam:{value:new THREE.Vector3()} },
      vertexShader: [
        'varying vec3 vW;','void main(){ vW = (modelMatrix*vec4(position,1.0)).xyz;',
        'gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }'
      ].join('\n'),
      fragmentShader: [
        'varying vec3 vW; uniform float uT; uniform vec3 uSun; uniform vec3 uCam;',
        'void main(){',
        '  float h1 = sin(vW.x*0.031 + uT*1.15)*cos(vW.z*0.024 - uT*0.83);',
        '  float h2 = sin(vW.x*0.0071 - uT*0.42)*cos(vW.z*0.0093 + uT*0.31);',
        '  vec3 n = normalize(vec3(0.055*h1 + 0.03*h2, 1.0, 0.048*h1 - 0.025*h2));',
        '  vec3 v = normalize(uCam - vW);',
        '  float fres = pow(1.0 - max(dot(n,v),0.0), 4.0);',
        '  vec3 deep = vec3(0.035,0.13,0.19); vec3 sky = vec3(0.42,0.58,0.78);',
        '  vec3 col = mix(deep, sky, clamp(0.30 + fres*0.85, 0.0, 1.0));',
        '  vec3 h = normalize(normalize(uSun) + v);',
        '  col += vec3(1.0,0.95,0.85)*pow(max(dot(n,h),0.0), 220.0)*1.8;',
        '  gl_FragColor = vec4(col, 0.90);',
        '}'
      ].join('\n')
    });
    const wgeo = new THREE.PlaneGeometry(90000, 90000, 1, 1).rotateX(-Math.PI/2);
    this.water = new THREE.Mesh(wgeo, this.waterMat);
    this.water.position.y = TER.sea;
    s.add(this.water);

    // ---- clouds
    const ct = (function(){
      const c = document.createElement('canvas'); c.width = c.height = 128;
      const x = c.getContext('2d');
      for(let i=0;i<26;i++){
        const px = 64+rnd(-34,34), py = 64+rnd(-26,26), rr = rnd(12,32);
        const g = x.createRadialGradient(px,py,1,px,py,rr);
        g.addColorStop(0,'rgba(255,255,255,0.42)'); g.addColorStop(1,'rgba(255,255,255,0)');
        x.fillStyle = g; x.beginPath(); x.arc(px,py,rr,0,7); x.fill();
      }
      return new THREE.CanvasTexture(c);
    })();
    this.clouds = [];
    const cm = new THREE.SpriteMaterial({map:ct, transparent:true, opacity:0.85, depthWrite:false, fog:true});
    for(let i=0;i<180;i++){
      const sp = new THREE.Sprite(cm.clone());
      const sz = rnd(500, 1600);
      sp.scale.set(sz, sz*0.45, 1);
      sp.position.set(rnd(-19000,19000), rnd(1000, 3400), rnd(-19000,19000));
      sp.material.opacity = rnd(0.25, 0.7);
      s.add(sp); this.clouds.push(sp);
    }
    window.addEventListener('resize', function(){
      World.camera.aspect = window.innerWidth/window.innerHeight;
      World.camera.updateProjectionMatrix();
      World.renderer.setSize(window.innerWidth, window.innerHeight);
      HUD.resize();
    });
  },

  /* ---------------- procedural terrain mesh ---------------- */
  buildTerrain:function(onProg, done){
    const N = TER.seg, S = TER.size, half = S/2, step = S/N;
    // airfield elevation: the natural ground at the field centre
    TER.run.elev = clamp(terrainRaw(0, 0), 25, 260);
    TER.run.elev = Math.round(TER.run.elev);
    const g = new THREE.PlaneGeometry(S, S, N, N);
    g.rotateX(-Math.PI/2);
    const pos = g.attributes.position;
    const H = new Float32Array((N+1)*(N+1));
    const COL = new Float32Array(pos.count*3);
    const uv = g.attributes.uv;
    const tuv = new Float32Array(pos.count*2);
    let row = 0;
    const self = this;
    function fillRows(){
      const t0 = performance.now();
      while(row <= N && performance.now()-t0 < 22){
        for(let i=0;i<=N;i++){
          const idx = row*(N+1)+i;
          const x = -half + i*step, z = -half + row*step;
          const h = terrainHeight(x, z);
          H[idx] = h;
          pos.setY(idx, h);
          tuv[idx*2] = uv.getX(idx)*S/70; tuv[idx*2+1] = uv.getY(idx)*S/70;
        }
        row++;
      }
      if(row <= N){ onProg(row/(N+1)*0.7); requestAnimationFrame(fillRows); return; }
      // ---- colour pass (needs neighbours for slope)
      for(let j=0;j<=N;j++){
        for(let i=0;i<=N;i++){
          const idx = j*(N+1)+i;
          const h = H[idx];
          const hx = H[j*(N+1)+Math.min(N,i+1)] - H[j*(N+1)+Math.max(0,i-1)];
          const hz = H[Math.min(N,j+1)*(N+1)+i] - H[Math.max(0,j-1)*(N+1)+i];
          const slope = Math.min(1, Math.hypot(hx, hz)/(2*step)*1.35);
          const n1 = vnoise(i*0.31, j*0.31)*0.5 + 0.5;
          let r, gg, b;
          const snow = sstep(1250, 1750, h + n1*180) * sstep(0.72, 0.34, slope);
          const grass = sstep(0.0, 260, h) * (1-snow);
          const sand = (1-sstep(2, 60, h));
          // rock
          let rr = 0.36+n1*0.10, rg = 0.34+n1*0.09, rb = 0.31+n1*0.08;
          // grass
          const gr = 0.22+n1*0.16, gb2 = 0.34+n1*0.16, bb = 0.14+n1*0.10;
          // dry high meadow
          const dry = sstep(600, 1100, h);
          r = lerp(rr, lerp(gr, 0.44, dry), grass*(1-slope));
          gg = lerp(rg, lerp(gb2, 0.42, dry), grass*(1-slope));
          b = lerp(rb, lerp(bb, 0.24, dry), grass*(1-slope));
          // sand / shingle
          r = lerp(r, 0.66+n1*0.10, sand*0.9); gg = lerp(gg, 0.60+n1*0.10, sand*0.9); b = lerp(b, 0.44+n1*0.08, sand*0.9);
          // rock on steep faces
          const rk = sstep(0.42, 0.78, slope);
          r = lerp(r, rr, rk); gg = lerp(gg, rg, rk); b = lerp(b, rb, rk);
          // snow cap
          r = lerp(r, 0.90, snow); gg = lerp(gg, 0.93, snow); b = lerp(b, 0.98, snow);
          // airfield apron: dry grass + gravel
          COL[idx*3] = r; COL[idx*3+1] = gg; COL[idx*3+2] = b;
        }
        if(j % 64 === 0) onProg(0.7 + (j/N)*0.28);
      }
      g.setAttribute('color', new THREE.Float32BufferAttribute(COL, 3));
      g.setAttribute('uv2', new THREE.Float32BufferAttribute(tuv, 2));
      g.computeVertexNormals();

      // ---- detail texture
      const tc = document.createElement('canvas'); tc.width = tc.height = 256;
      const cx = tc.getContext('2d');
      const img = cx.createImageData(256, 256);
      for(let y=0;y<256;y++) for(let x=0;x<256;x++){
        const v = fbm(x*0.055, y*0.055, 4)*0.5+0.5;
        const g2 = 150 + v*105;
        const o = (y*256+x)*4;
        img.data[o] = img.data[o+1] = img.data[o+2] = g2; img.data[o+3] = 255;
      }
      cx.putImageData(img, 0, 0);
      const dtex = new THREE.CanvasTexture(tc);
      dtex.wrapS = dtex.wrapT = THREE.RepeatWrapping;
      dtex.repeat.set(520, 520);
      const tmat = new THREE.MeshStandardMaterial({
        vertexColors:true, map:dtex, roughness:0.94, metalness:0.02, side:THREE.DoubleSide
      });
      const m = self.terrain = new THREE.Mesh(g, tmat);
      m.frustumCulled = false;
      self.scene.add(m);
      self.buildAirfield();
      self.buildScenery();
      onProg(1);
      done();
    }
    requestAnimationFrame(fillRows);
  },

  buildAirfield:function(){
    const r = TER.run, S = this.scene;
    // ---- runway slab + painted markings
    const c = document.createElement('canvas'); c.width = 2048; c.height = 128;
    const x = c.getContext('2d');
    x.fillStyle = '#33363b'; x.fillRect(0,0,2048,128);
    for(let i=0;i<26000;i++){                                    // asphalt grain
      x.fillStyle = 'rgba('+(40+Math.random()*45|0)+','+(42+Math.random()*44|0)+','+(46+Math.random()*44|0)+',0.5)';
      x.fillRect(Math.random()*2048, Math.random()*128, 3, 3);
    }
    x.fillStyle = '#e8ecef';
    x.fillRect(0, 8, 2048, 4); x.fillRect(0, 116, 2048, 4);      // edge lines
    for(let i=0;i<27;i++) x.fillRect(60 + i*74, 61, 40, 6);       // centreline dashes
    for(let k=0;k<8;k++){ x.fillRect(18+k*13, 16, 9, 34); x.fillRect(2021-k*13, 78, 9, 34); }
    for(let k=0;k<3;k++){ x.fillRect(150+k*26, 22, 12, 26); x.fillRect(150+k*26, 80, 12, 26);
                          x.fillRect(1860+k*26, 22, 12, 26); x.fillRect(1860+k*26, 80, 12, 26); }
    x.save(); x.translate(320, 64); x.rotate(-Math.PI/2); x.fillStyle='#e8ecef';
    x.font = 'bold 74px sans-serif'; x.textAlign='center'; x.textBaseline='middle'; x.fillText('09', 0, 0); x.restore();
    x.save(); x.translate(1728, 64); x.rotate(Math.PI/2); x.fillStyle='#e8ecef';
    x.font = 'bold 74px sans-serif'; x.textAlign='center'; x.textBaseline='middle'; x.fillText('27', 0, 0); x.restore();
    const rtex = new THREE.CanvasTexture(c);
    rtex.anisotropy = 4;
    const rw = new THREE.Mesh(new THREE.PlaneGeometry(r.len, r.wid).rotateX(-Math.PI/2),
      new THREE.MeshStandardMaterial({map:rtex, roughness:0.85, metalness:0.02}));
    rw.position.set(0, r.elev+0.06, 0); S.add(rw);
    this.runway = rw;
    // shoulders / apron
    const ap = new THREE.Mesh(new THREE.PlaneGeometry(r.len+560, 320).rotateX(-Math.PI/2),
      new THREE.MeshStandardMaterial({color:0x6a6252, roughness:0.98}));
    ap.position.set(0, r.elev+0.02, 130); S.add(ap);
    // edge lights
    const lg = new THREE.SphereGeometry(0.55, 6, 5);
    for(let i=0;i<=44;i++){
      const px = -r.len/2 + i*(r.len/44);
      [-1,1].forEach(function(s2){
        const m = new THREE.Mesh(lg, new THREE.MeshBasicMaterial({color: s2<0?0xffe9b0:0xffe9b0}));
        m.position.set(px, r.elev+0.5, s2*(r.wid/2+3));
        S.add(m);
      });
    }
  },

  buildScenery:function(){
    const S = this.scene, r = TER.run, y = r.elev;
    const conc = mat(0xb9bec4, 0.85, 0.05), glass = mat(0x9fd8ff, 0.05, 0.9, {transparent:true, opacity:0.45});
    // control tower
    const tw = new THREE.Group(); tw.position.set(-260, y, 250);
    const shaft = new THREE.Mesh(new THREE.CylinderGeometry(7, 9, 34, 12), conc); shaft.position.y = 17;
    const cab = new THREE.Mesh(new THREE.CylinderGeometry(13, 11, 8, 12), glass); cab.position.y = 38;
    const roof = new THREE.Mesh(new THREE.CylinderGeometry(14, 14, 1.4, 12), conc); roof.position.y = 42.5;
    tw.add(shaft); tw.add(cab); tw.add(roof); S.add(tw);
    // hangars
    for(let i=0;i<3;i++){
      const hg = new THREE.Group(); hg.position.set(-820 + i*300, y, 230);
      const b1 = new THREE.Mesh(new THREE.BoxGeometry(120, 22, 70), conc); b1.position.y = 11;
      const rf = new THREE.Mesh(new THREE.CylinderGeometry(36, 36, 120, 14, 1, true, 0, Math.PI), conc);
      rf.rotation.z = Math.PI/2; rf.position.y = 22;
      hg.add(b1); hg.add(rf); S.add(hg);
    }
    // windsock
    const ws = new THREE.Mesh(new THREE.CylinderGeometry(0.4,0.4,12,6), conc); ws.position.set(150, y+6, 120); S.add(ws);
    // scattered rocks + trees for scale cues
    const rockG = new THREE.ConeGeometry(1, 1, 6);
    const rockM = mat(0x5b5348, 0.95, 0.02);
    const treeG = new THREE.ConeGeometry(1, 1, 7), treeM = mat(0x2c3d24, 0.95, 0.0);
    const trunkM = mat(0x3a2b1d, 0.95, 0);
    const trunkG = new THREE.CylinderGeometry(0.12, 0.18, 1, 5);
    const inst = 260;
    for(let i=0;i<inst;i++){
      const px = rnd(-16000, 16000), pz = rnd(-16000, 16000);
      const h = terrainHeight(px, pz);
      if(h < 2 || h > 1450) continue;
      if(Math.abs(px) < 1400 && Math.abs(pz) < 500) continue;
      const n = terrainNormal(px, pz, new THREE.Vector3());
      const slope = 1-n.y;
      const sz = rnd(6, 26) * (slope>0.3? 1.5:1);
      const useTree = slope < 0.22 && h < 900 && Math.random() < 0.7;
      const m = new THREE.Mesh(useTree?treeG:rockG, useTree?treeM:rockM);
      m.position.set(px, h + sz*0.5, pz); m.scale.set(sz*(useTree?0.35:0.6), sz, sz*(useTree?0.35:0.6));
      m.rotation.y = rnd(0, 6.28);
      S.add(m);
      if(useTree){
        const t = new THREE.Mesh(trunkG, trunkM);
        t.position.set(px, h + sz*0.1, pz); t.scale.set(sz*0.3, sz*0.35, sz*0.3); S.add(t);
      }
    }
  },

  update:function(dt, cam){
    this.sky.position.copy(cam.position);
    this.waterMat.uniforms.uT.value += dt;
    this.waterMat.uniforms.uCam.value.copy(cam.position);
  }
};

/* ============================ CAMERA ================================ */
const Cam = {
  mode:0, names:['CHASE','COCKPIT','FLY-BY','TOWER'],
  pos:new THREE.Vector3(), look:new THREE.Vector3(), shake:0, flyPos:new THREE.Vector3(), flyT:0,
  set:function(m){ this.mode = (m+this.names.length)%this.names.length; },
  update:function(dt, ac, cam){
    const s = Math.max(1, ac.spec.hitR/4.5);
    this.shake = Math.max(0, this.shake - dt*3) + ac.buffet*0.02;
    const f = ac.fwd(), u = ac.up(), r = ac.right();
    if(this.mode===0){
      const back = 15*s + ac.V*0.055, up = 4.4*s + Math.abs(ac.aoa)*6;
      const tgt = ac.pos.clone().addScaledVector(f, -back).addScaledVector(u, up).addScaledVector(r, 0);
      this.pos.lerp(tgt, 1-Math.exp(-dt*4.2));
      const lk = ac.pos.clone().addScaledVector(f, 22*s).addScaledVector(u, 1.5);
      this.look.lerp(lk, 1-Math.exp(-dt*7));
      cam.position.copy(this.pos);
      cam.up.set(0,1,0);
      cam.lookAt(this.look);
    } else if(this.mode===1){
      const off = new THREE.Vector3(0, ac.spec.hitR*0.30, -ac.spec.hitR*0.35).applyQuaternion(ac.quat);
      cam.position.copy(ac.pos).add(off);
      cam.quaternion.copy(ac.quat);
      if(ac.isPlayer) cam.rotateX(-ac.aoa*0.25);
    } else if(this.mode===2){
      this.flyT -= dt;
      const d = cam.position.distanceTo(ac.pos);
      if(this.flyT <= 0 || d > 2600){
        this.flyT = 5;
        this.flyPos.copy(ac.pos).add(new THREE.Vector3(rnd(-700,700), rnd(-120,220), rnd(-700,700)));
        const gh = terrainHeight(this.flyPos.x, this.flyPos.z) + 30;
        if(this.flyPos.y < gh) this.flyPos.y = gh;
      }
      cam.position.lerp(this.flyPos, 1-Math.exp(-dt*2));
      cam.up.set(0,1,0);
      cam.lookAt(ac.pos);
    } else {
      const tp = new THREE.Vector3(-260, TER.run.elev+42, 250);
      cam.position.lerp(tp, 1-Math.exp(-dt*3));
      cam.up.set(0,1,0);
      cam.lookAt(ac.pos);
    }
    if(this.shake > 0.001){
      cam.position.x += (Math.random()-0.5)*this.shake*1.6;
      cam.position.y += (Math.random()-0.5)*this.shake*1.6;
    }
  }
};

/* ============================== HUD ================================= */
const HUD = {
  cv:null, ctx:null, W:0, H:0, t:0,
  init:function(){
    this.cv = document.getElementById('hud');
    this.ctx = this.cv.getContext('2d');
    this.resize();
  },
  resize:function(){
    const d = Math.min(window.devicePixelRatio||1, 2);
    this.W = window.innerWidth; this.H = window.innerHeight;
    this.cv.width = this.W*d; this.cv.height = this.H*d;
    this.cv.style.width = this.W+'px'; this.cv.style.height = this.H+'px';
    this.ctx.setTransform(d,0,0,d,0,0);
  },
  project:function(v, cam){
    const p = v.clone().project(cam);
    return { x:(p.x*0.5+0.5)*this.W, y:(-p.y*0.5+0.5)*this.H, z:p.z, ok: p.z < 1 && p.z > -1 };
  },
  dirWorld:function(hdg, pitchDeg){
    const p = pitchDeg*D2R, cp = Math.cos(p);
    return new THREE.Vector3(Math.sin(hdg)*cp, Math.sin(p), -Math.cos(hdg)*cp);
  },
  line:function(x1,y1,x2,y2,c,w){ const g=this.ctx; g.beginPath(); g.moveTo(x1,y1); g.lineTo(x2,y2);
    g.strokeStyle=c; g.lineWidth=w||1; g.stroke(); },
  txt:function(s,x,y,c,size,align,weight){ const g=this.ctx; g.fillStyle=c;
    g.font=(weight||'')+' '+ (size||13)+'px ui-monospace,Menlo,Consolas,monospace';
    g.textAlign=align||'left'; g.textBaseline='middle'; g.fillText(s,x,y); },

  draw:function(dt, cam){
    const g = this.ctx, ac = Game.player;
    g.clearRect(0,0,this.W,this.H);
    if(!ac) return;
    this.t += dt;
    const sp = ac.spec, col = sp.hud, warn = '#ffb45c', bad = '#ff5566';
    const CX = this.W*0.5, CY = this.H*0.52;
    const ias = ac.V*1.94384, kias = Math.sqrt(Math.max(0,atmosphere(ac.pos.y).sigma))*ac.V*1.94384;
    const altFt = ac.pos.y*3.28084, aglFt = (ac.pos.y - terrainHeight(ac.pos.x, ac.pos.z))*3.28084;
    g.lineJoin = 'round'; g.lineCap = 'round';

    /* ---------------- pitch ladder ---------------- */
    const hdg = ac.heading()*D2R;
    g.save(); g.globalAlpha = 0.92;
    for(let th=-40; th<=70; th+=5){
      if(th===0) continue;
      const a = this.project(Cam.pos? cam.position.clone().add(this.dirWorld(hdg, th).multiplyScalar(4000)) : this.dirWorld(hdg, th), cam);
      const b = this.project(cam.position.clone().add(this.dirWorld(hdg, th+2).multiplyScalar(4000)), cam);
      if(!a.ok || !b.ok) continue;
      const ux = b.x-a.x, uy = b.y-a.y, ul = Math.hypot(ux,uy)||1;
      const rx = -uy/ul, ry = ux/ul;
      const L = th%10===0 ? 46 : 22, gap = 15;
      const c1x = a.x + rx*gap, c1y = a.y + ry*gap, c2x = a.x - rx*gap, c2y = a.y - ry*gap;
      this.line(c1x, c1y, c1x+rx*L, c1y+ry*L, col, 1.4);
      this.line(c2x, c2y, c2x-rx*L, c2y-ry*L, col, 1.4);
      if(th%10===0){
        const s = th>0?1:-1;
        this.line(a.x+rx*L, a.y+ry*L, a.x+rx*L - rx*6 + ux*0.16*s, a.y+ry*L - ry*6 + uy*0.16*s, col, 1.4);
        this.line(a.x-rx*L, a.y-ry*L, a.x-rx*L + rx*6 + ux*0.16*s, a.y-ry*L + ry*6 + uy*0.16*s, col, 1.4);
        this.txt(Math.abs(th), a.x - rx*(L+16), a.y - ry*(L+16), col, 11, 'center');
      }
    }
    // horizon
    const h0 = this.project(cam.position.clone().add(this.dirWorld(hdg, 0).multiplyScalar(4000)), cam);
    const h1 = this.project(cam.position.clone().add(this.dirWorld(hdg, 2).multiplyScalar(4000)), cam);
    if(h0.ok && h1.ok){
      const ux = h1.x-h0.x, uy = h1.y-h0.y, ul = Math.hypot(ux,uy)||1;
      const rx = -uy/ul, ry = ux/ul;
      this.line(h0.x+rx*22, h0.y+ry*22, h0.x+rx*340, h0.y+ry*340, col, 1.6);
      this.line(h0.x-rx*22, h0.y-ry*22, h0.x-rx*340, h0.y-ry*340, col, 1.6);
    }
    g.restore();

    /* ---------------- boresight + flight path marker ---------------- */
    g.strokeStyle = col; g.lineWidth = 1.5;
    g.beginPath(); g.moveTo(CX-13,CY); g.lineTo(CX-4,CY); g.moveTo(CX+4,CY); g.lineTo(CX+13,CY);
    g.moveTo(CX,CY-4); g.lineTo(CX,CY-13); g.stroke();
    g.beginPath(); g.arc(CX, CY, 3, 0, 7); g.stroke();
    if(ac.V > 6){
      const vdir = ac.vel.clone().normalize();
      const fp = this.project(cam.position.clone().add(vdir.multiplyScalar(4000)), cam);
      if(fp.ok){
        g.strokeStyle = col; g.lineWidth = 1.6;
        g.beginPath(); g.arc(fp.x, fp.y, 8, 0, 7); g.stroke();
        g.beginPath();
        g.moveTo(fp.x-8, fp.y); g.lineTo(fp.x-17, fp.y);
        g.moveTo(fp.x+8, fp.y); g.lineTo(fp.x+17, fp.y);
        g.moveTo(fp.x, fp.y-8); g.lineTo(fp.x, fp.y-15); g.stroke();
      }
    }

    /* ---------------- roll arc ---------------- */
    const rr = 74, ry0 = CY - 108;
    g.strokeStyle = col; g.globalAlpha = 0.55; g.lineWidth = 1.3;
    g.beginPath(); g.arc(CX, ry0, rr, Math.PI*1.25, Math.PI*1.75); g.stroke();
    [[0,10],[30,7],[60,7],[90,10],[-30,7],[-60,7],[-90,10]].forEach(function(a){
      const t = (a[0]-90)*D2R;
      const x1 = CX + Math.cos(t)*rr, y1 = ry0 + Math.sin(t)*rr;
      const x2 = CX + Math.cos(t)*(rr-a[1]), y2 = ry0 + Math.sin(t)*(rr-a[1]);
      HUD.line(x1,y1,x2,y2, col, 1.3);
    });
    g.globalAlpha = 1;
    const bank = -ac.rollAng()*D2R;
    const px = CX + Math.sin(bank)*rr, py = ry0 - Math.cos(bank)*rr;
    g.fillStyle = col; g.beginPath();
    g.moveTo(px, py); g.lineTo(px-6, py-11); g.lineTo(px+6, py-11); g.closePath(); g.fill();
    if(Math.abs(ac.rollAng()) > 1){
      g.save(); g.translate(CX, ry0); g.rotate(-bank);
      g.strokeStyle = col; g.beginPath(); g.moveTo(0,-rr+2); g.lineTo(-6,-rr+13); g.lineTo(6,-rr+13); g.closePath();
      g.lineWidth=1.4; g.stroke(); g.restore();
    }

    /* ---------------- speed tape (left) ---------------- */
    const TX = 84, TY = CY, TH = 250;
    g.globalAlpha = 0.85;
    g.strokeStyle = col; g.lineWidth = 1.2;
    g.beginPath(); g.moveTo(TX+30, TY-TH/2); g.lineTo(TX+30, TY-18); g.moveTo(TX+30, TY+18); g.lineTo(TX+30, TY+TH/2); g.stroke();
    for(let v = Math.floor((kias-60)/10)*10; v < kias+70; v += 10){
      if(v < 0) continue;
      const y = TY - (v - kias)*2.4;
      if(Math.abs(y-TY) > TH/2) continue;
      this.line(TX+30, y, TX+38, y, col, 1.2);
      this.txt(String(v), TX+44, y, col, 12, 'left');
    }
    // Vs / Vne markers
    const vs = sp.lim.Vs0*1.94384*(ac.ctl.flaps?0.88:1), vne = sp.lim.Vne*1.94384;
    [[vs, bad],[vne, bad],[sp.lim.Vr*1.94384, warn]].forEach(function(m){
      const y = TY - (m[0]-kias)*2.4;
      if(Math.abs(y-TY) < TH/2) HUD.line(TX+22, y, TX+30, y, m[1], 2.2);
    });
    g.globalAlpha = 1;
    g.fillStyle = 'rgba(4,10,16,0.72)';
    g.fillRect(TX-38, TY-15, 108, 30);
    g.strokeStyle = col; g.lineWidth = 1.4; g.strokeRect(TX-38, TY-15, 108, 30);
    this.txt(fmt(kias), TX+16, TY, '#dff6ff', 20, 'center', 'bold');
    this.txt('KTS', TX-32, TY-24, col, 10);
    this.txt('M '+ac.mach.toFixed(2), TX-32, TY+24, col, 10);
    this.txt('TAS '+fmt(ias)+' m/s', TX-38, TY+TH/2+18, col, 10);

    /* ---------------- altitude tape (right) ---------------- */
    const AX = this.W-84;
    g.globalAlpha = 0.85;
    g.beginPath(); g.moveTo(AX-30, TY-TH/2); g.lineTo(AX-30, TY-18); g.moveTo(AX-30, TY+18); g.lineTo(AX-30, TY+TH/2); g.stroke();
    for(let v = Math.floor((altFt-400)/200)*200; v < altFt+500; v += 200){
      const y = TY - (v - altFt)*0.055;
      if(Math.abs(y-TY) > TH/2) continue;
      this.line(AX-38, y, AX-30, y, col, 1.2);
      this.txt(String(Math.max(0,v)), AX-44, y, col, 12, 'right');
    }
    g.globalAlpha = 1;
    g.fillStyle = 'rgba(4,10,16,0.72)';
    g.fillRect(AX-70, TY-15, 108, 30);
    g.strokeStyle = col; g.lineWidth = 1.4; g.strokeRect(AX-70, TY-15, 108, 30);
    this.txt(fmt(altFt), AX-16, TY, '#dff6ff', 20, 'center', 'bold');
    this.txt('ALT FT', AX+32, TY-24, col, 10, 'right');
    this.txt(fmt(ac.pos.y)+' m', AX+32, TY+24, col, 10, 'right');
    // VSI
    const vs2 = clamp(ac.vel.y/8, -1, 1);
    g.strokeStyle = col; g.lineWidth = 1.2;
    g.beginPath(); g.moveTo(AX+34, TY-TH/2); g.lineTo(AX+34, TY+TH/2); g.stroke();
    for(let i=-4;i<=4;i++){ this.line(AX+34, TY-i*28, AX+40, TY-i*28, col, 1.1); }
    g.fillStyle = col;
    g.beginPath(); g.moveTo(AX+34, TY - vs2*112); g.lineTo(AX+48, TY - vs2*112 - 6); g.lineTo(AX+48, TY - vs2*112 + 6); g.closePath(); g.fill();
    this.txt('VS '+fmt(ac.vel.y*196.85)+' fpm', AX+34, TY+TH/2+18, col, 10, 'right');
    // radar altitude
    g.fillStyle = aglFt < 500 ? warn : col;
    this.txt('R '+fmt(Math.max(0,aglFt)), AX+34, TY-TH/2-16, g.fillStyle, 13, 'right');

    /* ---------------- heading tape (top) ---------------- */
    const HY = 42, hd = ac.heading();
    g.globalAlpha = 0.85; g.strokeStyle = col; g.lineWidth = 1.2;
    g.beginPath(); g.moveTo(CX-170, HY+14); g.lineTo(CX-24, HY+14); g.moveTo(CX+24, HY+14); g.lineTo(CX+170, HY+14); g.stroke();
    for(let d0 = -50; d0 <= 50; d0 += 5){
      const v = wrap360(Math.round((hd+d0)/5)*5);
      const x = CX - (wrapPi((v-hd)*D2R)*R2D)*3.2;
      if(Math.abs(x-CX) > 175) continue;
      const big = v%10===0;
      this.line(x, HY+14, x, HY+14-(big?9:5), col, 1.2);
      if(big) this.txt(v===0?'N':(v===90?'E':(v===180?'S':(v===270?'W':(''+v).padStart(3,'0')))), x, HY, col, 12, 'center');
    }
    g.globalAlpha = 1;
    g.fillStyle = col; g.beginPath();
    g.moveTo(CX, HY+22); g.lineTo(CX-6, HY+32); g.lineTo(CX+6, HY+32); g.closePath(); g.fill();
    this.txt(''+Math.round(hd), CX, HY+24, '#dff6ff', 13, 'center', 'bold');

    /* ---------------- engine / systems block ---------------- */
    const BY = this.H-64, BX = 60;
    const bar = function(x, y, w, h, v, c, label, val){
      g.fillStyle = 'rgba(255,255,255,0.10)'; g.fillRect(x, y, w, h);
      g.fillStyle = c; g.fillRect(x, y, w*clamp(v,0,1), h);
      g.strokeStyle = col; g.lineWidth = 1; g.globalAlpha = 0.6; g.strokeRect(x, y, w, h); g.globalAlpha = 1;
      HUD.txt(label, x, y-9, col, 9);
      HUD.txt(val, x+w, y-9, '#dff6ff', 9, 'right');
    };
    const rpmN = ac.rpm;
    bar(BX, BY, 130, 8, ac.ctl.throttle, col, 'THR', fmt(ac.ctl.throttle*100)+'%');
    bar(BX, BY+30, 130, 8, rpmN, rpmN>0.98?warn:col, sp.eng.type==='prop'?'RPM':'N1', fmt(rpmN*100)+'%');
    bar(BX, BY+60, 130, 8, ac.egt/950, ac.egt>830?bad:(ac.egt>700?warn:col), 'EGT', fmt(ac.egt)+'C');
    bar(BX+160, BY, 130, 8, ac.fuel/sp.fuel, ac.fuel<sp.fuel*0.12?bad:col, 'FUEL', fmt(ac.fuel)+' kg');
    bar(BX+160, BY+30, 130, 8, ac.stress/100, ac.stress>60?bad:col, 'AIRFRAME', fmt(100-ac.stress)+'%');
    bar(BX+160, BY+60, 130, 8, ac.hp/ac.maxHp, ac.hp<ac.maxHp*0.4?bad:col, 'DAMAGE', fmt(ac.hp/ac.maxHp*100)+'%');
    // G + AoA
    const GX = this.W-250;
    this.txt('G', GX, BY+2, col, 10);
    this.txt(ac.gLoad.toFixed(2), GX, BY+18, Math.abs(ac.gLoad)>sp.lim.G*0.85?bad:'#dff6ff', 24, 'left', 'bold');
    this.txt('/ '+sp.lim.G.toFixed(1), GX+58, BY+20, col, 11);
    this.txt('AOA', GX+110, BY+2, col, 10);
    const aoaDeg = ac.aoa*R2D;
    this.txt(aoaDeg.toFixed(1)+'°', GX+110, BY+18, aoaDeg > sp.aStall*R2D*0.85 ? bad : '#dff6ff', 20, 'left', 'bold');
    // AoA bar with stall band
    g.fillStyle = 'rgba(255,255,255,0.10)'; g.fillRect(GX+110, BY+34, 130, 8);
    g.fillStyle = bad; g.fillRect(GX+110 + 130*0.72, BY+34, 130*0.28, 8);
    g.fillStyle = warn; g.fillRect(GX+110 + 130*0.55, BY+34, 130*0.17, 8);
    g.fillStyle = col; g.fillRect(GX+110, BY+34, 130*clamp(aoaDeg/25,0,1), 8);
    g.strokeStyle = col; g.lineWidth = 1; g.globalAlpha = 0.6; g.strokeRect(GX+110, BY+34, 130, 8); g.globalAlpha = 1;
    // G meter arc
    g.strokeStyle = col; g.lineWidth = 1.4;
    g.beginPath(); g.arc(GX+28, BY+22, 22, Math.PI*0.75, Math.PI*2.25); g.stroke();
    const ga = clamp(ac.gLoad/(sp.lim.G*1.2), -1, 1);
    const th2 = Math.PI*0.75 + (ga*0.5+0.5)*Math.PI*1.5;
    g.beginPath(); g.moveTo(GX+28, BY+22); g.lineTo(GX+28+Math.cos(th2)*18, BY+22+Math.sin(th2)*18); g.stroke();

    /* ---------------- gear / flap / brake status ---------------- */
    const SX = this.W/2 - 92, SY = this.H-40;
    const chips = [
      ['GEAR', ac.gearPos>0.9, ac.gearPos>0.05 && ac.gearPos<0.95],
      ['FLAP', ac.flapPos>0.05, false],
      ['BRK', (ac.ctl.brake||ac.ctl.parking), false],
      ['AB', !!ac.ctl.wet, false]
    ];
    chips.forEach(function(c2, i){
      const x = SX + i*66;
      g.fillStyle = c2[1] ? (c2[2]?warn:'rgba(120,220,255,0.22)') : 'rgba(255,255,255,0.05)';
      g.fillRect(x, SY, 58, 20);
      g.strokeStyle = c2[1] ? (c2[2]?warn:col) : 'rgba(255,255,255,0.16)'; g.lineWidth = 1;
      g.strokeRect(x, SY, 58, 20);
      HUD.txt(c2[0], x+29, SY+10, c2[1] ? '#dff6ff' : 'rgba(190,210,225,0.45)', 10, 'center');
    });

    /* ---------------- warnings ---------------- */
    let wmsg = [];
    if(ac.stall > 0.25) wmsg.push(['STALL', bad]);
    if(kias > vne) wmsg.push(['OVERSPEED', bad]);
    if(ac.egt > 830) wmsg.push(['ENGINE HOT', bad]);
    if(ac.stress > 60) wmsg.push(['AIRFRAME STRESS', warn]);
    if(aglFt < 300 && ac.vel.y < -6 && ac.gearPos < 0.9 && !ac.onGround) wmsg.push(['PULL UP — GEAR UP', bad]);
    else if(aglFt < 300 && ac.vel.y < -14 && !ac.onGround) wmsg.push(['PULL UP', bad]);
    if(ac.fuel <= 0.5) wmsg.push(['FUEL EMPTY', bad]);
    if(Game.missileWarn > 0) wmsg.push(['MISSILE LAUNCH', bad]);
    wmsg.forEach(function(m2, i){
      const blink = (Math.floor(HUD.t*5)%2)===0;
      if(!blink && m2[0]==='STALL') return;
      HUD.txt(m2[0], CX, CY+126+i*20, m2[1], 15, 'center', 'bold');
    });

    /* ---------------- mission overlay ---------------- */
    if(Game.objective) this.txt(Game.objective, CX, 74, '#dff6ff', 13, 'center', 'bold');
    if(Game.subObj) this.txt(Game.subObj, CX, 94, col, 11, 'center');
    if(Game.msg && Game.msgT > 0){
      g.globalAlpha = Math.min(1, Game.msgT);
      this.txt(Game.msg, CX, this.H*0.30, '#ffd08a', 16, 'center', 'bold');
      g.globalAlpha = 1;
    }
    if(Game.score !== undefined) this.txt('SCORE '+Game.score, this.W-24, 24, col, 12, 'right');

    /* ---------------- combat: target boxes + gunsight ---------------- */
    if(Game.mission==='dogfight'){
      let locked = false;
      Game.enemies.forEach(function(e){
        if(!e.alive) return;
        const p = HUD.project(e.pos.clone(), cam);
        const d = e.pos.distanceTo(ac.pos);
        if(!p.ok) return;
        const s = clamp(5200/Math.max(d,60), 10, 130);
        const isT = (Game.target === e);
        g.strokeStyle = isT ? '#ff5566' : 'rgba(255,120,140,0.6)';
        g.lineWidth = isT ? 2 : 1.2;
        const x0 = p.x-s/2, y0 = p.y-s/2;
        g.beginPath();
        g.moveTo(x0, y0+s*0.28); g.lineTo(x0, y0); g.lineTo(x0+s*0.28, y0);
        g.moveTo(x0+s-s*0.28, y0); g.lineTo(x0+s, y0); g.lineTo(x0+s, y0+s*0.28);
        g.moveTo(x0+s, y0+s-s*0.28); g.lineTo(x0+s, y0+s); g.lineTo(x0+s-s*0.28, y0+s);
        g.moveTo(x0+s*0.28, y0+s); g.lineTo(x0, y0+s); g.lineTo(x0, y0+s-s*0.28);
        g.stroke();
        HUD.txt(fmt(d/1000,1)+'km', p.x+s/2+6, p.y-s/2+6, isT?'#ff8090':'rgba(255,120,140,0.7)', 10);
        HUD.txt(e.name, p.x+s/2+6, p.y-s/2+18, isT?'#ff8090':'rgba(255,120,140,0.7)', 9);
        if(isT){
          // lead reticle
          const lead = e.pos.clone().addScaledVector(e.vel, d/940);
          const lp = HUD.project(lead, cam);
          if(lp.ok){
            g.strokeStyle = '#ff8090'; g.lineWidth = 1.3;
            g.beginPath(); g.arc(lp.x, lp.y, 7, 0, 7); g.stroke();
            HUD.line(p.x, p.y, lp.x, lp.y, 'rgba(255,128,144,0.5)', 1);
          }
          HUD.txt('TGT '+fmt(d)+' m   CLOSING '+fmt((ac.vel.clone().sub(e.vel).dot(ac.fwd())))+' m/s', CX-200, this.H*0.30+26, '#ff8090', 11);
        }
        // missile tone
        if(Game.missileTone > 0 && isT) locked = true;
      }, this);
      // weapon status
      this.txt('GUN '+Game.gunHeat+'%', this.W-24, 44, Game.gunHeat>90?bad:col, 11, 'right');
      this.txt('MSL '+Game.missiles+'  '+(Game.missileTone>0.6?'TONE':'SEEK'), this.W-24, 60, Game.missileTone>0.6?bad:col, 11, 'right');
      if(locked){
        g.strokeStyle = bad; g.lineWidth = 2;
        g.beginPath(); g.arc(CX, CY, 30, 0, 7); g.stroke();
        this.txt('LOCK', CX, CY+46, bad, 12, 'center', 'bold');
      }
    }

    /* ---------------- navigation aid ---------------- */
    if(Game.nav){
      const n = Game.nav;
      const p = HUD.project(n.pos.clone(), cam);
      const d = n.pos.distanceTo(ac.pos);
      const bearing = wrap360(Math.atan2(n.pos.x-ac.pos.x, -(n.pos.z-ac.pos.z))*R2D);
      const rel = wrapPi((bearing - ac.heading())*D2R);
      // steer pointer
      g.save(); g.translate(CX, this.H-104); g.strokeStyle = n.col||col; g.lineWidth = 2;
      g.rotate(clamp(rel, -1.4, 1.4));
      g.beginPath(); g.moveTo(0,-14); g.lineTo(0,10); g.moveTo(-6,4); g.lineTo(0,10); g.lineTo(6,4); g.stroke();
      g.restore();
      this.txt(n.label+'  '+fmt(d/1000,2)+' km', CX, this.H-128, n.col||col, 12, 'center', 'bold');
      if(p.ok && d > 120){
        g.strokeStyle = n.col||col; g.lineWidth = 1.4;
        g.beginPath(); g.moveTo(p.x-9,p.y); g.lineTo(p.x+9,p.y); g.moveTo(p.x,p.y-9); g.lineTo(p.x,p.y+9); g.stroke();
        g.globalAlpha = 0.5; g.beginPath(); g.arc(p.x, p.y, 13, 0, 7); g.stroke(); g.globalAlpha = 1;
      }
    }
    void aglFt;
  }
};

/* ============================== INPUT =============================== */
const Input = {
  keys:{}, mx:0, my:0, mouseFlight:true, stick:{x:0,y:0}, gamepad:null,
  init:function(){
    window.addEventListener('keydown', function(e){
      if(e.repeat) { return; }
      Input.keys[e.code] = true;
      Input.key(e.code, true);
      if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Tab'].indexOf(e.code)>=0) e.preventDefault();
    });
    window.addEventListener('keyup', function(e){ Input.keys[e.code] = false; Input.key(e.code, false); });
    window.addEventListener('mousemove', function(e){
      if(document.pointerLockElement){
        Input.mx += e.movementX*0.0035; Input.my += e.movementY*0.0035;
        Input.mx = clamp(Input.mx, -1.2, 1.2); Input.my = clamp(Input.my, -1.2, 1.2);
      }
    });
    document.addEventListener('pointerlockchange', function(){
      if(!document.pointerLockElement){ Input.mx = Input.my = 0; }
    });
  },
  key:function(code, down){
    if(!down) return;
    if(Game.state!=='flying' && code!=='KeyH' && code!=='Escape') return;
    if(code==='KeyG'){ Game.player.ctl.gear = Game.player.ctl.gear>0.5?0:1; AudioSys.oneshot('click',0.4); }
    if(code==='KeyF'){ Game.player.ctl.flaps = (Game.player.ctl.flaps+1)%3; AudioSys.oneshot('click',0.4); }
    if(code==='KeyC'){ Cam.set(Cam.mode+1); Game.say('CAMERA: '+Cam.names[Cam.mode], 1.4); }
    if(code==='KeyT'){ Game.cycleTarget(); }
    if(code==='KeyP'){ Game.player.ctl.parking = !Game.player.ctl.parking; Game.say(Game.player.ctl.parking?'PARKING BRAKE SET':'PARKING BRAKE RELEASED', 1.5); AudioSys.oneshot('click',0.5); }
    if(code==='KeyM'){ Input.mouseFlight = !Input.mouseFlight; Game.say('MOUSE FLIGHT '+(Input.mouseFlight?'ON':'OFF'), 1.4); }
    if(code==='KeyK'){ AudioSys.set(!AudioSys.on); Game.say('AUDIO '+(AudioSys.on?'ON':'MUTED'), 1.2); }
    if(code==='KeyH'){ UI.toggleHelp(); }
    if(code==='KeyX'){ Game.launchMissile(); }
    if(code==='Escape'){ if(Game.state==='flying') UI.pause(); else if(Game.state==='paused') UI.resume(); }
  },
  update:function(dt, ac){
    const k = this.keys;
    let p = 0, r = 0, y = 0;
    if(k['KeyS']||k['ArrowDown']) p += 1;
    if(k['KeyW']||k['ArrowUp'])   p -= 1;
    if(k['KeyD']||k['ArrowRight'])r += 1;
    if(k['KeyA']||k['ArrowLeft']) r -= 1;
    if(k['KeyE']) y += 1;
    if(k['KeyQ']) y -= 1;
    // mouse virtual stick (self centring spring)
    if(this.mouseFlight){
      const s = 1-Math.exp(-dt*2.2);
      this.stick.x += (this.mx - this.stick.x)*s;
      this.stick.y += (this.my - this.stick.y)*s;
      if(!document.pointerLockElement){ this.mx *= Math.exp(-dt*3); this.my *= Math.exp(-dt*3); }
      r += clamp(this.stick.x, -1, 1)*0.85;
      p += clamp(this.stick.y, -1, 1)*0.85;
    }
    // gamepad
    const gp = navigator.getGamepads ? navigator.getGamepads()[0] : null;
    if(gp){
      const dz = function(v){ return Math.abs(v)<0.12?0:v; };
      r += dz(gp.axes[0]||0); p += dz(gp.axes[1]||0); y += dz(gp.axes[2]||0);
      if(gp.buttons[7] && gp.buttons[7].pressed) ac.ctl.throttle = gp.buttons[7].value;
    }
    ac.ctl.pitch = clamp(p, -1, 1);
    ac.ctl.roll  = clamp(r, -1, 1);
    ac.ctl.yaw   = clamp(y, -1, 1);
    if(k['ShiftLeft']||k['ShiftRight']) ac.ctl.throttle = clamp(ac.ctl.throttle + dt*0.55, 0, 1);
    if(k['ControlLeft']||k['ControlRight']) ac.ctl.throttle = clamp(ac.ctl.throttle - dt*0.55, 0, 1);
    ac.ctl.brake = !!k['KeyB'];
    if(Game.mission==='dogfight'){
      if(k['Space']) Game.fireGuns();
    }
  },
  lock:function(){
    if(this.mouseFlight && document.body.requestPointerLock) document.body.requestPointerLock();
  }
};

/* ============================== GAME ================================ */
const Game = {
  state:'menu', mission:null, spec:null, player:null, enemies:[], aircrafts:[],
  gates:[], gateI:0, phase:0, score:0, msg:'', msgT:0, objective:'', subObj:'', nav:null,
  target:null, gunHeat:0, missiles:6, missileTone:0, missileWarn:0, t:0, acc:0,
  stats:null, landed:false, touchInfo:null, results:null,

  say:function(m, t){ this.msg = m; this.msgT = t||2.5; },

  spawn:function(spec, x, y, z, hdg, opts){
    const ac = new Aircraft(spec, x, y, z, hdg, opts);
    ac.model = buildAircraft(spec);
    World.scene.add(ac.model.group);
    ac.shadow = new THREE.Mesh(new THREE.CircleGeometry(1, 18).rotateX(-Math.PI/2),
      new THREE.MeshBasicMaterial({color:0x000000, transparent:true, opacity:0.32, depthWrite:false}));
    World.scene.add(ac.shadow);
    this.aircrafts.push(ac);
    return ac;
  },
  clear:function(){
    this.aircrafts.forEach(function(a){
      if(a.model){ World.scene.remove(a.model.group); }
      if(a.shadow){ World.scene.remove(a.shadow); }
    });
    this.aircrafts = []; this.enemies = []; this.player = null;
    this.gates.forEach(function(g){ World.scene.remove(g.mesh); World.scene.remove(g.beam); });
    this.gates = []; this.gateI = 0; this.target = null;
  },

  start:function(mid){
    this.clear();
    this.mission = mid; this.phase = 0; this.score = 0; this.t = 0; this.landed = false;
    this.touchInfo = null; this.gunHeat = 0; this.missiles = 6; this.missileTone = 0; this.missileWarn = 0;
    this.stats = { maxV:0, maxAlt:0, maxG:1, maxMach:0, kills:0, hits:0, startT:0 };
    const sp = this.spec, r = TER.run, hd90 = Math.PI/2;
    if(mid==='circuit'){
      let gy = 0; sp.gear.forEach(function(p){ gy = Math.min(gy, p[1]); });
      const y = r.elev - gy + 0.06;
      this.player = this.spawn(sp, r.x0 + 120, y, 0, hd90, {isPlayer:true, team:'blue', name:sp.name});
      this.player.ctl.parking = true; this.player.ctl.throttle = 0;
      this.player.ctl.gear = 1; this.player.ctl.flaps = 0;
      // circuit gates (left hand circuit, land RWY 09)
      const G = [
        [ 2600, 300, -1200], [ 800, 360, -2900], [-2400, 340, -1800], [-2800, 300, 900]
      ];
      for(let i=0;i<G.length;i++) this.addGate(G[i][0], G[i][1], G[i][2], i);
      this.objective = 'TAKEOFF — RWY 09';
      this.subObj = 'RELEASE PARKING BRAKE [P], THROTTLE UP [SHIFT]';
    } else {
      const y = 1500;
      this.player = this.spawn(sp, -3000, y, 1200, hd90, {isPlayer:true, team:'blue', name:sp.name});
      this.player.vel.copy(this.player.fwd().multiplyScalar(230));
      this.player.ctl.throttle = 0.8; this.player.rpm = 0.8; this.player.ctl.gear = 0;
      const names = ['BANDIT 1-1','BANDIT 1-2','BANDIT 1-3'];
      for(let i=0;i<3;i++){
        const ang = -0.5 + i*0.5;
        const e = this.spawn(AGGRESSOR,
          7000 + Math.cos(ang)*1500, 1400 + i*450, 800 + Math.sin(ang)*2600,
          hd90 + Math.PI, {team:'red', name:names[i]});
        e.vel.copy(e.fwd().multiplyScalar(250));
        e.ctl.throttle = 0.9; e.rpm = 0.9;
        e.hp = e.maxHp = 90;
        e.pilot = new Pilot(e, 0.55 + i*0.16);
        e.pilot.target = this.player;
        this.enemies.push(e);
      }
      this.target = this.enemies[0];
      this.objective = 'ENGAGE — 3 HOSTILES';
      this.subObj = 'T TARGET · SPACE GUNS · X MISSILE';
    }
    this.stats.startT = this.t;
    this.state = 'flying';
    UI.show(null);
    Cam.set(0);
    this.say(mid==='circuit'?'CLEARED FOR TAKEOFF RWY 09':'WEAPONS FREE', 3);
  },

  addGate:function(x, y, z, i){
    const g = new THREE.Group();
    const torus = new THREE.Mesh(new THREE.TorusGeometry(60, 2.0, 8, 28),
      new THREE.MeshBasicMaterial({color:0xffa040, transparent:true, opacity:0.85}));
    g.add(torus);
    const beam = new THREE.Mesh(new THREE.CylinderGeometry(4, 4, 400, 8, 1, true),
      new THREE.MeshBasicMaterial({color:0xffa040, transparent:true, opacity:0.18, depthWrite:false, side:THREE.DoubleSide}));
    beam.position.set(x, y-200, z);
    World.scene.add(g); World.scene.add(beam);
    g.position.set(x, y, z);
    g.lookAt(x+1, y, z);
    this.gates.push({pos:new THREE.Vector3(x,y,z), r:62, mesh:g, beam:beam, hit:false, i:i});
  },

  cycleTarget:function(){
    const alive = this.enemies.filter(function(e){ return e.alive; });
    if(!alive.length){ this.target = null; return; }
    const idx = alive.indexOf(this.target);
    this.target = alive[(idx+1)%alive.length];
    this.say('TARGET: '+this.target.name, 1.2);
  },
  fireGuns:function(){
    if(this.gunHeat >= 100) return;
    Weapons.fireGun(this.player, 0.0035);
    this.gunHeat = Math.min(102, this.gunHeat + 2.4);
  },
  launchMissile:function(){
    if(this.mission!=='dogfight'){ this.say('NO WEAPONS FITTED', 1.5); return; }
    if(this.missiles <= 0){ this.say('WINCHESTER — NO MISSILES', 1.5); return; }
    if(this.missileTone < 0.6){ this.say('NO TONE', 1.2); return; }
    Weapons.fireMissile(this.player, this.target);
    this.missiles--;
    this.say('FOX TWO', 1.4);
  },
  onHit:function(tgt, dmg){ this.score += Math.round(dmg*2); this.stats.hits++; },
  onKill:function(ac){
    if(Game.mission!=='dogfight') return;
    if(ac.team==='red'){ Game.score += 1200; Game.stats.kills++;
      Game.say(ac.name+' SPLASHED', 2.5);
      if(Game.target===ac) Game.cycleTarget(); }
  },
  onTouchdown:function(fpm){
    this.touchInfo = { fpm:fpm, gs:this.player.V, x:this.player.pos.x, z:this.player.pos.z, t:this.t };
    this.say((Math.abs(fpm)<180?'SMOOTH TOUCHDOWN':'FIRM TOUCHDOWN')+'  '+fmt(fpm)+' fpm', 3);
    if(Math.abs(fpm) > 2200) this.player.damage(500, 'hard landing');
  },

  /* ------------------------------- update --------------------------- */
  update:function(dt){
    this.t += dt;
    this.msgT -= dt;
    const ac = this.player;
    Input.update(dt, ac);
    if(!ac) return;
    // fixed-step 6-DOF integration (120 Hz)
    this.acc += Math.min(dt, 0.06);
    const h = 1/120;
    let n = 0;
    while(this.acc >= h && n < 8){
      for(let i=0;i<this.aircrafts.length;i++) this.aircrafts[i].step(h, this);
      Weapons.update(h, this.aircrafts);
      this.acc -= h; n++;
    }
    // slipstream / vortex encounters
    for(let i=0;i<this.aircrafts.length;i++){
      const a = this.aircrafts[i];
      for(let j=0;j<this.aircrafts.length;j++){
        if(i===j) continue;
        a.wake(this.aircrafts[j], 0.9 + this.aircrafts[j].spec.hitR*0.05);
      }
    }
    // AI
    for(let i=0;i<this.enemies.length;i++) if(this.enemies[i].pilot) this.enemies[i].pilot.update(dt, this);
    FX.update(dt);
    // ground impact for airframe points
    for(let i=0;i<this.aircrafts.length;i++){
      const a = this.aircrafts[i];
      if(!a.alive || a.onGround) continue;
      const pts = [[0,0,-a.spec.hitR],[0,0,a.spec.hitR*0.85],[a.spec.b*0.5,0,0],[-a.spec.b*0.5,0,0],[0,a.spec.hitR*0.4,0]];
      for(let k=0;k<pts.length;k++){
        const w = new THREE.Vector3(pts[k][0], pts[k][1], pts[k][2]).applyQuaternion(a.quat).add(a.pos);
        if(w.y < terrainHeight(w.x, w.z)){
          FX.explosion(a.pos.clone(), a.spec.hitR);
          AudioSys.oneshot('boom', 1);
          a.alive = false; a.hp = 0;
          if(a.isPlayer) this.endMission(false, 'TERRAIN IMPACT');
          else if(Game.mission==='dogfight'){ this.stats.kills++; this.score += 800; this.say(a.name+' FLEW INTO THE GROUND', 2.5); }
          break;
        }
      }
    }
    // model transforms
    for(let i=0;i<this.aircrafts.length;i++){
      const a = this.aircrafts[i], m = a.model;
      if(!m) continue;
      m.group.position.copy(a.pos); m.group.quaternion.copy(a.quat);
      m.update(Math.min(dt, 0.05), a.act, a.rpm*(a.fuel>0?1:0), !!a.ctl.wet, a.ctl.throttle);
      m.setGear(a.gearPos);
      if(a.shadow){
        const gy = terrainHeight(a.pos.x, a.pos.z);
        const agl = a.pos.y - gy;
        a.shadow.position.set(a.pos.x, gy+0.4, a.pos.z);
        const s = a.spec.hitR*(1.0 + Math.min(agl, 400)/500);
        a.shadow.scale.set(s*1.3, 1, s);
        a.shadow.material.opacity = 0.34/(1 + agl/90) * (a.alive?1:0.5);
        a.shadow.visible = agl < 700;
      }
    }
    // stats
    this.stats.maxV = Math.max(this.stats.maxV, ac.V);
    this.stats.maxAlt = Math.max(this.stats.maxAlt, ac.pos.y);
    this.stats.maxG = Math.max(this.stats.maxG, Math.abs(ac.gLoad));
    this.stats.maxMach = Math.max(this.stats.maxMach, ac.mach);
    // gun cooling
    this.gunHeat = Math.max(0, this.gunHeat - dt*22);
    // mission logic
    this.missionUpdate(dt, ac);
    // audio
    this.audioUpdate(dt, ac);
    // camera
    Cam.update(dt, ac, World.camera);
    World.update(dt, World.camera);
  },

  audioUpdate:function(dt, ac){
    const atm = atmosphere(ac.pos.y);
    AudioSys.engine(ac.rpm, ac.ctl.throttle, atm.sigma, !!ac.ctl.wet);
    AudioSys.airflow(ac.qbar, Math.abs(ac.aoa), ac.buffet, ac.mach);
    if(ac.onGround) AudioSys.ground(ac.V, clamp(ac.V*0.011, 0, 0.5));
    // alerts
    let al = null;
    if(ac.alive){
      const agl = ac.pos.y - terrainHeight(ac.pos.x, ac.pos.z);
      if(this.missileWarn > 0) al = 'warn';
      else if(ac.stall > 0.35) al = 'stall';
      else if(agl < 250 && ac.vel.y < -12 && !ac.onGround) al = 'gpws';
      else if(ac.egt > 860) al = 'fire';
    }
    if(al !== this._alert){ AudioSys.alert(al); this._alert = al; }
    this.missileWarn = Math.max(0, this.missileWarn - dt);
  },

  missionUpdate:function(dt, ac){
    if(this.mission==='circuit'){
      const r = TER.run;
      if(this.phase===0){
        if(!ac.ctl.parking){ this.phase = 1; this.subObj = 'THROTTLE UP — ROTATE AT '+fmt(r0(ac.spec.lim.Vr))+' KTS'; this.say('ROLLING', 1.5); }
      } else if(this.phase===1){
        this.subObj = 'ROTATE AT '+fmt(r0(ac.spec.lim.Vr))+' KTS [S = PULL]';
        if(ac.pos.y - r.elev > 8 && ac.V > ac.spec.lim.Vs0*1.05){
          this.phase = 2; this.say('AIRBORNE — GEAR UP [G], CLIMB 300 m', 3);
          this.objective = 'FLY THE CIRCUIT — GATE 1 OF 4';
          this.nav = this.gates[0]; this.nav.label = 'GATE 1'; this.nav.col = '#ffb45c';
        }
        if(this.t > 3 && ac.V < 2 && !ac.onGround){ /* noop */ }
      } else if(this.phase===2){
        this.subObj = 'GATES '+(this.gateI)+' OF 4';
        const g = this.gates[this.gateI];
        if(g && !g.hit && g.pos.distanceTo(ac.pos) < g.r){
          g.hit = true; this.gateI++;
          this.score += 250;
          this.say('GATE '+this.gateI+' — GOOD', 1.6);
          g.mesh.traverse(function(o){ if(o.material) o.material.color.set(0x50ff90); });
          if(this.gateI < this.gates.length){
            this.nav = this.gates[this.gateI];
            this.nav.label = 'GATE '+(this.gateI+1); this.nav.col = '#ffb45c';
            this.objective = 'FLY THE CIRCUIT — GATE '+(this.gateI+1)+' OF 4';
          } else {
            this.phase = 3;
            this.objective = 'LAND ON RWY 09 — FULL STOP';
            this.subObj = 'FLAPS [F], GEAR [G], TOUCH DOWN ZONE 1';
            this.nav = { pos:new THREE.Vector3(r.x0-900, r.elev, 0), label:'RWY 09', col:'#7dff9b' };
            this.say('CLEAR TO LAND RWY 09', 3);
          }
        }
      } else if(this.phase===3){
        if(this.touchInfo) this.subObj = 'BRAKES [B] — STOP ON THE RUNWAY   TD '+fmt(this.touchInfo.fpm)+' fpm';
        else this.subObj = 'FLY THE GLIDE — TOUCH DOWN ZONE 1';
        if(ac.onGround && ac.V < 3 && onRunway(ac.pos.x, ac.pos.z, 40) && this.touchInfo){
          this.phase = 4;
          this.score += Math.max(0, 1200 - Math.abs(this.touchInfo.fpm)*0.35
             + (Math.abs(ac.pos.z) < 12 ? 300 : 0)
             + (Math.abs(this.touchInfo.x) < 400 ? 300 : 0));
          this.endMission(true, 'MISSION COMPLETE');
        }
        if(ac.onGround && !this.touchInfo && this.t > 6){
          this.say('UNCOMMANDED LANDING', 2);
          this.endMission(false, 'LANDED SHORT OF THE CIRCUIT');
        }
      }
    } else if(this.mission==='dogfight'){
      const alive = this.enemies.filter(function(e){ return e.alive; });
      this.subObj = alive.length? alive.length+' HOSTILE'+(alive.length>1?'S':'')+' AIRBORNE' : 'AREA CLEAR';
      // missile tone
      const t = this.target;
      if(t && t.alive){
        const d = t.pos.distanceTo(ac.pos);
        const off = Math.acos(clamp(ac.fwd().dot(t.pos.clone().sub(ac.pos).normalize()), -1, 1));
        const want = (d > 400 && d < 7000 && off < 22*D2R) ? 1 : 0;
        this.missileTone += (want - this.missileTone)*(1-Math.exp(-dt*(want?1.6:4)));
        // incoming missile warning
        for(let i=0;i<Weapons.missiles.length;i++){
          const m = Weapons.missiles[i];
          if(m.target === ac && m.owner && m.owner.team==='red') this.missileWarn = 0.5;
        }
      } else this.missileTone = 0;
      if(!alive.length) this.endMission(true, 'ALL HOSTILES DESTROYED');
      if(!ac.alive) this.endMission(false, ac.deathCause ? ('AIRCRAFT LOST — '+String(ac.deathCause).toUpperCase()) : 'AIRCRAFT LOST');
    }
    if(!ac.alive && this.mission==='circuit') this.endMission(false, 'AIRFRAME DESTROYED');
    void dt;
    function r0(v){ return v*1.94384; }
  },

  endMission:function(win, reason){
    if(this.state!=='flying') return;
    this.state = 'debrief';
    if(document.exitPointerLock) document.exitPointerLock();
    const s = this.stats, ac = this.player;
    let grade = 'C', sc = this.score;
    if(this.mission==='circuit'){
      if(win){
        const fpm = Math.abs(this.touchInfo.fpm);
        sc += fpm < 150 ? 500 : (fpm < 350 ? 300 : 100);
        grade = sc > 2100 ? 'S' : (sc > 1700 ? 'A' : (sc > 1200 ? 'B' : 'C'));
      } else grade = 'F';
    } else {
      grade = win ? (s.kills>=3 && s.hits>10 ? 'S' : 'A') : (s.kills>=2 ? 'B' : 'F');
      sc = this.score;
    }
    this.score = Math.round(sc);
    this.results = {
      win:win, reason:reason, grade:grade, score:Math.round(sc),
      rows:[
        ['RESULT', reason],
        ['FLIGHT TIME', fmt(this.t/60)+':'+('0'+fmt(this.t%60)).slice(-2)],
        ['MAX SPEED', fmt(s.maxV*1.94384)+' kts  (M '+s.maxMach.toFixed(2)+')'],
        ['MAX ALTITUDE', fmt(s.maxAlt)+' m'],
        ['MAX LOAD', s.maxG.toFixed(2)+' G'],
        ['TOUCHDOWN', this.touchInfo ? fmt(this.touchInfo.fpm)+' fpm' : '—'],
        ['GATES', this.mission==='circuit' ? (this.gateI+' / 4') : '—'],
        ['KILLS', this.mission==='dogfight' ? (s.kills+' / 3') : '—'],
        ['DAMAGE TAKEN', fmt(100 - (ac? ac.hp/ac.maxHp*100:0))+'%'],
        ['SCORE', String(Math.round(sc))]
      ]
    };
    UI.debrief();
  }
};

/* =============================== UI ================================= */
const UI = {
  el:{}, sel:0, selMission:0, helpOpen:false,
  init:function(){
    const ids = ['menu','loading','brief','pause','help','debrief','cards','specbars','missions',
                 'bfTitle','bfTag','bfBody','dbTitle','dbTag','dbGrade','dbStats','pcBody','helpBody',
                 'lbar','ltxt'];
    ids.forEach(function(i){ UI.el[i] = document.getElementById(i); });
    const sil = {
      kestrel:'<path d="M10 30 L40 29 L44 13 L52 13 L50 29 L96 28 L112 25 L114 30 L112 35 L96 32 L50 31 L52 47 L44 47 L44 31 L40 31 L10 33 Z"/>',
      talon:'<path d="M8 30 L52 28 L72 7 L80 7 L74 28 L108 27 L114 30 L108 33 L74 32 L80 53 L72 53 L52 32 L8 33 Z"/>',
      atlas:'<path d="M8 28 L46 27 L50 5 L62 5 L60 27 L86 26 L90 4 L112 3 L114 30 L112 57 L90 56 L86 34 L60 33 L62 55 L50 55 L46 33 L8 32 Z"/>'
    };
    // ---- airframe cards
    FLEET.forEach(function(a, i){
      const d = document.createElement('div');
      d.className = 'card' + (i===0?' on':'');
      d.innerHTML = '<svg viewBox="0 0 120 60" fill="rgba(150,215,255,0.8)">'+sil[a.id]+'</svg>'
        + '<div><div class="nm">'+a.name+'</div><div class="rl">'+a.role+'</div></div>';
      d.onclick = function(){ UI.select(i); };
      UI.el.cards.appendChild(d);
    });
    // ---- mission list
    MISSIONS.forEach(function(m, i){
      const d = document.createElement('div');
      d.className = 'mission' + (i===0?' on':'');
      d.innerHTML = '<div class="t">'+m.name+'</div><div class="d">'+m.sub+'</div>';
      d.onclick = function(){
        UI.selMission = i;
        Array.prototype.forEach.call(UI.el.missions.children, function(c, j){ c.className = 'mission'+(j===i?' on':''); });
      };
      UI.el.missions.appendChild(d);
    });
    // ---- controls table
    const rows = [
      ['W / S  (or &uarr;/&darr;)', 'Pitch — stick forward / back', 'MOUSE', 'Virtual stick (M to disable)'],
      ['A / D  (or &larr;/&rarr;)', 'Roll — stick left / right', 'SHIFT / CTRL', 'Throttle up / down'],
      ['Q / E', 'Rudder — yaw left / right', 'SPACE', 'Fire cannon'],
      ['B (hold)', 'Wheel brakes', 'X', 'Launch IR missile (needs tone)'],
      ['P', 'Parking brake', 'T', 'Cycle target'],
      ['G', 'Landing gear', 'C', 'Camera: chase / cockpit / fly-by / tower'],
      ['F', 'Flaps (up / takeoff / full)', 'ESC', 'Pause'],
      ['H', 'This panel', 'K', 'Mute audio']
    ];
    UI.el.helpBody.innerHTML = '<table class="kc">' + rows.map(function(r){
      return '<tr><td>'+r[0]+'</td><td style="text-align:left">'+r[1]+'</td>'
           + '<td>'+r[2]+'</td><td style="text-align:left">'+r[3]+'</td></tr>';
    }).join('') + '</table>';
    UI.el.pcBody.innerHTML = '<p style="font:12px/2 var(--mono);color:#b6c9da">'
      + 'W/S PITCH · A/D ROLL · Q/E RUDDER · SHIFT/CTRL THROTTLE<br>'
      + 'B BRAKES · P PARKING BRAKE · G GEAR · F FLAPS · C CAMERA<br>'
      + 'SPACE GUNS · X MISSILE · T TARGET · H CONTROLS · K AUDIO</p>';
    document.getElementById('start').onclick = function(){
      AudioSys.init(); AudioSys.resume();
      UI.selMission = UI.selMission;
      const m = MISSIONS[UI.selMission];
      UI.el.bfTitle.textContent = m.name;
      UI.el.bfTag.textContent = m.tag;
      UI.el.bfBody.innerHTML = m.body + '<p style="color:#8ea6bb">AIRFRAME: <b style="color:#fff">'
        + FLEET[UI.sel].name + '</b> — ' + FLEET[UI.sel].desc + '</p>';
      UI.show('brief');
    };
    document.getElementById('bfGo').onclick = function(){
      AudioSys.resume();
      Game.spec = FLEET[UI.sel];
      UI.show(null);
      Game.start(MISSIONS[UI.selMission].id);
      Input.lock();
    };
    document.getElementById('bfBack').onclick = function(){ UI.show('menu'); };
    document.getElementById('pcResume').onclick = function(){ UI.resume(); };
    document.getElementById('pcRestart').onclick = function(){ UI.resume(); Game.start(Game.mission); Input.lock(); };
    document.getElementById('pcKeys').onclick = function(){ UI.toggleHelp(true); };
    document.getElementById('pcQuit').onclick = function(){ UI.toHangar(); };
    document.getElementById('hpClose').onclick = function(){ UI.toggleHelp(false); };
    document.getElementById('dbRetry').onclick = function(){ Game.start(Game.mission); Input.lock(); };
    document.getElementById('dbHangar').onclick = function(){ UI.toHangar(); };
    document.getElementById('app').addEventListener('click', function(){
      if(Game.state==='flying'){ Input.lock(); AudioSys.resume(); }
    });
    this.select(0);
  },
  select:function(i){
    this.sel = i;
    Array.prototype.forEach.call(this.el.cards.children, function(c, j){ c.className = 'card'+(j===i?' on':''); });
    this.buildBars(FLEET[i]);
    Game.menuSpec = FLEET[i];
    Game.buildMenuModel && Game.buildMenuModel();
  },
  buildBars:function(a){
    const rows = [
      ['TOP SPEED', a.stats.spd], ['THRUST', a.stats.thr], ['LIFT', a.stats.lft],
      ['AGILITY', a.stats.agi], ['MASS', a.stats.wgt], ['STALL SPD', a.stats.stl]
    ];
    const extra = '<div class="bar"><i>MASS</i><u><b style="width:'+ (a.stats.wgt*100) +'%;background:linear-gradient(90deg,#8a5a2b,#ffb45c)"></b></u><em>'+fmt(a.mass)+' kg</em></div>';
    void extra;
    this.el.specbars.innerHTML = rows.map(function(r){
      return '<div class="bar"><i>'+r[0]+'</i><u><b style="width:'+(r[1]*100).toFixed(0)+'%"></b></u><em>'
        + (r[1]*100|0)+'</em></div>';
    }).join('')
    + '<div style="font:10px/1.9 var(--mono);color:#8ea6bb;letter-spacing:.08em;margin-top:8px">'
    + 'WING '+a.S+' m&sup2; &nbsp; SPAN '+a.b+' m &nbsp; AR '+a.AR.toFixed(1)+'<br>'
    + 'CL<sub>max</sub> '+(a.CL0+a.CLa*a.aStall).toFixed(2)+' &nbsp; V<sub>S</sub> '+fmt(a.lim.Vs0*1.94384)+' kts &nbsp; V<sub>NE</sub> '+fmt(a.lim.Vne*1.94384)+' kts<br>'
    + 'POWER '+(a.eng.power/1000).toFixed(0)+' kW &nbsp; '+(a.eng.type==='jet'?'TURBOFAN':'TURBOPROP/PISTON')+' &nbsp; G LIMIT '+a.lim.G.toFixed(1)+'<br>'
    + 'STATIC T/W '+(a.eng.Tstatic/(a.mass*G0)).toFixed(2)+' &nbsp; WING LOAD '+(a.mass/a.S).toFixed(0)+' kg/m&sup2;</div>';
  },
  show:function(id){
    ['menu','loading','brief','pause','help','debrief'].forEach(function(k){
      UI.el[k].classList.add('hidden');
    });
    if(id) UI.el[id].classList.remove('hidden');
    document.getElementById('menu').style.pointerEvents = (id==='menu') ? 'auto' : 'none';
  },
  pause:function(){
    if(Game.state!=='flying') return;
    Game.state = 'paused';
    if(document.exitPointerLock) document.exitPointerLock();
    this.show('pause');
  },
  resume:function(){
    if(Game.state!=='paused') return;
    this.show(null); Game.state = 'flying'; this.helpOpen = false;
  },
  toggleHelp:function(force){
    const on = force===undefined ? !this.helpOpen : force;
    this.helpOpen = on;
    if(on){ this.el.help.classList.remove('hidden'); }
    else {
      this.el.help.classList.add('hidden');
      if(Game.state==='paused') this.el.pause.classList.remove('hidden');
    }
  },
  debrief:function(){
    const r = Game.results;
    this.el.dbTitle.textContent = r.win ? 'MISSION COMPLETE' : 'MISSION FAILED';
    this.el.dbTag.textContent = r.reason;
    this.el.dbGrade.textContent = r.grade;
    this.el.dbGrade.style.color = r.grade==='F' ? '#ff4d63' : '';
    this.el.dbGrade.style.webkitTextFillColor = '';
    this.el.dbStats.innerHTML = r.rows.map(function(x){
      return '<div><span>'+x[0]+'</span><b>'+x[1]+'</b></div>';
    }).join('');
    this.show('debrief');
  },
  toHangar:function(){
    Game.clear();
    Game.state = 'menu';
    Game.mission = null; Game.player = null;
    HUD.ctx.clearRect(0,0,HUD.W,HUD.H);
    this.show('menu');
    Game.buildMenuModel();
  }
};

/* ======================= MENU MODEL + MAIN LOOP ===================== */
Game.buildMenuModel = function(){
  if(Game.menuModel){ World.scene.remove(Game.menuModel.group); World.scene.remove(Game.menuShadow); }
  const sp = Game.menuSpec || FLEET[0];
  const m = Game.menuModel = buildAircraft(sp);
  m.group.position.set(0, TER.run.elev + 1.5, 0);
  m.group.rotation.y = -2.3;
  World.scene.add(m.group);
  Game.menuShadow = new THREE.Mesh(new THREE.CircleGeometry(sp.hitR*1.3, 18).rotateX(-Math.PI/2),
    new THREE.MeshBasicMaterial({color:0x000000, transparent:true, opacity:0.3, depthWrite:false}));
  Game.menuShadow.position.set(0, TER.run.elev + 0.4, 0);
  World.scene.add(Game.menuShadow);
  m.setGear(1);
};

let lastT = performance.now(), menuT = 0;
function loop(now){
  requestAnimationFrame(loop);
  const dt = Math.min(0.05, (now-lastT)/1000); lastT = now;
  const cam = World.camera;
  if(Game.state==='flying'){
    Game.update(dt);
  } else if(Game.state==='paused' || Game.state==='debrief'){
    Game.stats && (Game.stats.idle = 0);
  } else if(Game.state==='menu' || Game.state==='brief'){
    menuT += dt*0.075;
    const R = 34;
    cam.position.set(Math.cos(menuT)*R, TER.run.elev + 9 + Math.sin(menuT*0.7)*2.5, Math.sin(menuT)*R);
    cam.up.set(0,1,0);
    cam.lookAt(0, TER.run.elev + 2.4, 0);
    if(Game.menuModel){
      Game.menuModel.update(dt, {elev:0, ail:0, rud:0}, 0, false, 0);
    }
    World.update(dt, cam);
    if(TER.ready) FX.update(dt);
  }
  if(Game.state==='flying' || Game.state==='paused'){
    HUD.draw(dt, cam);
    const v = document.getElementById('vig');
    const gload = Game.player ? Math.abs(Game.player.gLoad) : 0;
    const gl = Game.player ? Game.player.spec.lim.G : 9;
    const black = clamp((gload - gl*0.62)/(gl*0.45), 0, 1)*0.85
                + clamp((Game.player? Game.player.buffet:0)-0.6, 0, 1)*0.25;
    v.style.opacity = black.toFixed(2);
    const cs = World.renderer.domElement.style;
    cs.filter = black > 0.35 ? 'contrast(1.05) saturate('+(1-black*0.8)+')' : '';
  } else {
    document.getElementById('vig').style.opacity = 0;
    World.renderer.domElement.style.filter = '';
  }
  World.renderer.render(World.scene, cam);
}

/* ================================ BOOT ============================== */
function boot(){
  try{
    World.init(document.getElementById('app'));
    HUD.init();
    FX.init(World.scene);
    Weapons.init(World.scene);
    Input.init();
    UI.init();
    Game.buildMenuModel();
    UI.show('loading');
    UI.el.ltxt.textContent = 'GENERATING 40 x 40 KM TERRAIN';
    requestAnimationFrame(loop);
    World.buildTerrain(function(p){
      UI.el.lbar.style.width = (p*100).toFixed(0)+'%';
      if(p > 0.7) UI.el.ltxt.textContent = 'BUILDING AIRFIELD & SCENERY';
    }, function(){
      TER.ready = true;
      UI.el.ltxt.textContent = 'READY';
      setTimeout(function(){ UI.show('menu'); }, 220);
    });
    window.addEventListener('blur', function(){ if(Game.state==='flying') UI.pause(); });
  }catch(err){
    document.getElementById('err').classList.remove('hidden');
    document.getElementById('err').innerHTML = 'SIMULATION ERROR<br>' + (err && err.message ? err.message : err);
    throw err;
  }
}
boot();
};

/* --------------------------- three.js loader ------------------------ */
(function(){
  const SRC = [
    'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js',
    'https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js',
    'https://unpkg.com/three@0.128.0/build/three.min.js'
  ];
  (function tryNext(i){
    if(i >= SRC.length){
      const e = document.getElementById('err');
      e.classList.remove('hidden');
      e.innerHTML = 'COULD NOT LOAD THE THREE.JS RENDERER<br>AN INTERNET CONNECTION IS REQUIRED FOR THE 3D ENGINE (CDN).';
      return;
    }
    const s = document.createElement('script');
    s.src = SRC[i];
    s.onload = function(){ if(window.THREE) window.__apexMain(window.THREE); else tryNext(i+1); };
    s.onerror = function(){ tryNext(i+1); };
    document.head.appendChild(s);
  })(0);
})();
</script>
</body>
</html>