← Flight Simulator results

Flight Simulator

Deepseek v4.1 Flash · full

◈Preview unavailable — try playing the result

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

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

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

Generation prompt · exact text unavailable

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

Inspect original source 111,890 bytes · SHA-256 f52e8cd9af16
<!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 — Flight Simulator</title>
<style>
:root{
  --cy:#3ad7ff; --cy-dim:rgba(58,215,255,.35); --am:#ffb648; --rd:#ff4d5e; --gn:#5df08a;
  --bg0:#04070c; --bg1:#0a1220; --bg2:#101c2e;
  --txt:#dce9f5; --txt-dim:#7d93ab;
  --panel:rgba(10,18,30,.82);
  --mono:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
  --sans:"Inter","Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:var(--bg0);color:var(--txt);font-family:var(--sans);
  -webkit-font-smoothing:antialiased;user-select:none}
canvas{display:block}
#scene{position:fixed;inset:0;width:100%;height:100%;z-index:1}
#hud{position:fixed;inset:0;width:100%;height:100%;z-index:5;pointer-events:none}

/* ---------- overlays ---------- */
.overlay{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;
  background:
    radial-gradient(1200px 700px at 20% 0%, rgba(58,215,255,.13), transparent 60%),
    radial-gradient(900px 600px at 85% 100%, rgba(255,182,72,.10), transparent 60%),
    linear-gradient(160deg,#04070c 0%,#081221 45%,#050a12 100%);
  overflow:auto;padding:22px}
.overlay.hidden{display:none}
.overlay::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:-1;
  background-image:linear-gradient(rgba(58,215,255,.05) 1px,transparent 1px),
  linear-gradient(90deg,rgba(58,215,255,.05) 1px,transparent 1px);
  background-size:44px 44px;mask-image:radial-gradient(closest-side at 50% 40%,#000 40%,transparent 100%)}
.panel{width:min(1180px,100%);background:var(--panel);border:1px solid rgba(58,215,255,.16);
  border-radius:14px;padding:24px 26px 26px;backdrop-filter:blur(9px);
  box-shadow:0 30px 90px rgba(0,0,0,.65),inset 0 1px 0 rgba(255,255,255,.05)}
header.top{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;flex-wrap:wrap;
  border-bottom:1px solid rgba(58,215,255,.14);padding-bottom:14px;margin-bottom:18px}
h1{font-size:34px;letter-spacing:.22em;font-weight:800;
  background:linear-gradient(92deg,#eaf8ff,#3ad7ff 45%,#ffb648);-webkit-background-clip:text;background-clip:text;color:transparent}
h1 span{font-weight:300;letter-spacing:.34em;font-size:16px;display:block;margin-top:5px;
  background:none;-webkit-text-fill-color:#7d93ab;color:#7d93ab}
.sub{font-size:11.5px;color:var(--txt-dim);letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)}
.sec-title{font-family:var(--mono);font-size:11px;letter-spacing:.24em;color:var(--cy);
  margin:20px 0 10px;display:flex;align-items:center;gap:10px}
.sec-title::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--cy-dim),transparent)}

.grid-fleet{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.card{position:relative;background:linear-gradient(170deg,rgba(16,28,46,.9),rgba(8,14,24,.9));
  border:1px solid rgba(120,160,200,.16);border-radius:11px;padding:14px 14px 12px;cursor:pointer;
  transition:.18s transform,.18s border-color,.18s box-shadow;overflow:hidden}
.card:hover{transform:translateY(-3px);border-color:rgba(58,215,255,.5);box-shadow:0 14px 34px rgba(0,0,0,.55)}
.card.sel{border-color:var(--cy);box-shadow:0 0 0 1px var(--cy),0 14px 40px rgba(58,215,255,.18)}
.card.sel::after{content:"";position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,var(--cy),transparent)}
.card h3{font-size:15px;letter-spacing:.06em;margin-bottom:2px}
.card .role{font-family:var(--mono);font-size:10px;color:var(--am);letter-spacing:.16em;text-transform:uppercase}
.card p{font-size:11.5px;color:var(--txt-dim);line-height:1.5;margin:9px 0 11px;min-height:52px}
.card .sil{position:absolute;right:8px;top:8px;width:86px;height:52px;opacity:.55}
.stat{display:flex;align-items:center;gap:8px;margin:3.5px 0;font-family:var(--mono);font-size:9.5px;color:var(--txt-dim)}
.stat b{width:34px;font-weight:500;letter-spacing:.1em;color:#9fb6cc}
.bar{flex:1;height:5px;background:rgba(255,255,255,.07);border-radius:3px;overflow:hidden}
.bar i{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,#1d7fa8,var(--cy))}
.bar.am i{background:linear-gradient(90deg,#96631c,var(--am))}
.bar.rd i{background:linear-gradient(90deg,#7a2331,#ff6b7a)}
.rowscen{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.scen{background:rgba(12,22,36,.75);border:1px solid rgba(120,160,200,.16);border-radius:10px;padding:12px 13px;
  cursor:pointer;transition:.15s;display:flex;gap:11px;align-items:flex-start}
.scen:hover{border-color:rgba(58,215,255,.45);background:rgba(18,32,52,.85)}
.scen.sel{border-color:var(--cy);background:rgba(20,44,66,.8)}
.scen .ic{font-size:19px;line-height:1.1;filter:drop-shadow(0 0 8px rgba(58,215,255,.5))}
.scen h4{font-size:13px;letter-spacing:.04em;margin-bottom:3px}
.scen p{font-size:11px;color:var(--txt-dim);line-height:1.45}
.kb{display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:6px 16px;font-size:11.5px}
.kb div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px dotted rgba(120,160,200,.18);padding:4px 0}
.kb kbd{font-family:var(--mono);font-size:10.5px;background:rgba(58,215,255,.1);border:1px solid rgba(58,215,255,.28);
  border-radius:4px;padding:1px 6px;color:#bfe9f8}
.kb span{color:var(--txt-dim)}
.actions{display:flex;gap:12px;align-items:center;margin-top:22px;flex-wrap:wrap}
button.btn{font-family:var(--sans);font-size:13px;letter-spacing:.16em;text-transform:uppercase;font-weight:700;
  padding:13px 34px;border-radius:8px;border:1px solid rgba(58,215,255,.5);cursor:pointer;color:#04121a;
  background:linear-gradient(180deg,#63e2ff,#22b6e0);box-shadow:0 10px 26px rgba(38,190,230,.28);transition:.16s}
button.btn:hover{filter:brightness(1.1);transform:translateY(-1px)}
button.btn.ghost{background:transparent;color:#9fd7ea;border-color:rgba(120,160,200,.36);box-shadow:none}
button.btn.ghost:hover{background:rgba(58,215,255,.09)}
.note{font-size:11px;color:var(--txt-dim);font-family:var(--mono)}
#toast{position:fixed;left:50%;top:78px;transform:translateX(-50%);z-index:12;display:flex;flex-direction:column;
  gap:6px;align-items:center;pointer-events:none}
.toast{font-family:var(--mono);font-size:12.5px;letter-spacing:.1em;padding:8px 16px;border-radius:6px;
  background:rgba(6,14,22,.78);border:1px solid rgba(58,215,255,.32);color:#c9ecfa;
  animation:tin .3s ease-out;text-transform:uppercase}
.toast.warn{border-color:rgba(255,77,94,.6);color:#ffc9cf}
.toast.good{border-color:rgba(93,240,138,.5);color:#c6ffdb}
@keyframes tin{from{opacity:0;transform:translateY(-8px)}to{opacity:1}}
#msgBox{position:fixed;left:50%;bottom:120px;transform:translateX(-50%);z-index:11;text-align:center;
  pointer-events:none;font-family:var(--mono);font-size:20px;letter-spacing:.2em;color:#ffb648;
  text-shadow:0 0 18px rgba(255,182,72,.6);opacity:0;transition:.3s}
#msgBox.show{opacity:1}
.debrief .row{display:flex;justify-content:space-between;font-family:var(--mono);font-size:12.5px;
  padding:7px 2px;border-bottom:1px dotted rgba(120,160,200,.18)}
.debrief .row b{color:var(--cy);font-weight:500}
.debrief h2{font-size:22px;letter-spacing:.16em;margin-bottom:4px}
.debrief .verdict{font-family:var(--mono);font-size:12px;letter-spacing:.2em;text-transform:uppercase;color:var(--am);margin-bottom:16px}
#camlabel{position:fixed;right:16px;top:12px;z-index:6;font-family:var(--mono);font-size:10.5px;letter-spacing:.18em;
  color:rgba(190,225,240,.7);pointer-events:none}
#loadbar{position:fixed;inset:0;z-index:30;background:#04070c;display:flex;flex-direction:column;
  align-items:center;justify-content:center;gap:16px;transition:opacity .5s}
#loadbar .t{font-family:var(--mono);font-size:12px;letter-spacing:.3em;color:var(--cy)}
#loadbar .bar2{width:280px;height:3px;background:rgba(255,255,255,.08);overflow:hidden;border-radius:2px}
#loadbar .bar2 i{display:block;height:100%;width:30%;background:var(--cy);animation:lb 1.2s infinite ease-in-out}
@keyframes lb{0%{margin-left:-30%}100%{margin-left:100%}}
#err{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:40;display:none;max-width:560px;
  background:rgba(30,8,12,.94);border:1px solid rgba(255,77,94,.5);border-radius:10px;padding:18px 22px;
  font-family:var(--mono);font-size:12px;line-height:1.6;color:#ffd7dc}
@media (max-width:900px){
  .grid-fleet,.rowscen{grid-template-columns:1fr}
  h1{font-size:24px}.panel{padding:16px}
}
</style>
</head>
<body>
<canvas id="scene"></canvas>
<canvas id="hud"></canvas>
<div id="camlabel"></div>
<div id="toast"></div>
<div id="msgBox"></div>

<div id="menu" class="overlay">
  <div class="panel">
    <header class="top">
      <h1>APEX AERO<span>FLIGHT DYNAMICS SIMULATOR</span></h1>
      <div class="sub">Newtonian rigid-body &nbsp;·&nbsp; 6-DoF &nbsp;·&nbsp; Procedural Terrain</div>
    </header>

    <div class="sec-title">01 — SELECT AIRCRAFT</div>
    <div class="grid-fleet" id="fleet"></div>

    <div class="sec-title">02 — SCENARIO</div>
    <div class="rowscen" id="scenarios"></div>

    <div class="sec-title">03 — CONTROLS</div>
    <div class="kb">
      <div><span>Pitch</span><kbd>W / S</kbd></div>
      <div><span>Roll</span><kbd>A / D</kbd></div>
      <div><span>Rudder (yaw)</span><kbd>Q / E</kbd></div>
      <div><span>Throttle</span><kbd>&#8679; / &#8679;Ctrl</kbd></div>
      <div><span>Afterburner</span><kbd>Tab</kbd></div>
      <div><span>Guns</span><kbd>Space</kbd></div>
      <div><span>Landing gear</span><kbd>G</kbd></div>
      <div><span>Flaps</span><kbd>F</kbd></div>
      <div><span>Wheel brakes</span><kbd>B</kbd></div>
      <div><span>Airbrake</span><kbd>X</kbd></div>
      <div><span>Camera</span><kbd>C</kbd></div>
      <div><span>Trim reset</span><kbd>T</kbd></div>
      <div><span>Pause</span><kbd>Esc</kbd></div>
      <div><span>Restart flight</span><kbd>R</kbd></div>
    </div>

    <div class="actions">
      <button class="btn" id="startBtn">Launch Flight</button>
      <button class="btn ghost" id="soundBtn">Audio: On</button>
      <div class="note" id="hint">Click canvas to lock the mouse for analog stick control.</div>
    </div>
  </div>
</div>

<div id="pause" class="overlay hidden">
  <div class="panel" style="max-width:620px">
    <h1 style="font-size:24px">PAUSED</h1>
    <div class="sec-title" style="margin-top:14px">FLIGHT DATA</div>
    <div id="pauseStats" style="font-family:var(--mono);font-size:12px;line-height:1.9;color:var(--txt-dim)"></div>
    <div class="actions">
      <button class="btn" id="resumeBtn">Resume</button>
      <button class="btn ghost" id="restartBtn">Restart</button>
      <button class="btn ghost" id="menuBtn">Hangar</button>
    </div>
  </div>
</div>

<div id="debrief" class="overlay hidden">
  <div class="panel debrief" style="max-width:640px">
    <h2 id="dbTitle">MISSION COMPLETE</h2>
    <div class="verdict" id="dbVerdict"></div>
    <div id="dbRows"></div>
    <div class="actions">
      <button class="btn" id="dbAgain">Fly Again</button>
      <button class="btn ghost" id="dbMenu">Hangar</button>
    </div>
  </div>
</div>

<div id="loadbar"><div class="t">GENERATING TERRAIN</div><div class="bar2"><i></i></div></div>
<div id="err"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
"use strict";
/* ============================================================================
   APEX AERO — single file flight simulator
   Architecture:
     0. math kernel (no dependencies, plain JS)
     1. procedural terrain field
     2. rigid-body 6-DoF flight physics
     3. three.js world / meshes
     4. AI, weapons, audio, HUD, camera, game loop
   ========================================================================== */
const DEG = Math.PI/180, RAD = 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 smoothstep=(e0,e1,x)=>{const t=clamp((x-e0)/(e1-e0),0,1);return t*t*(3-2*t);};
const sign=Math.sign, rnd=(a,b)=>a+Math.random()*(b-a);
const TAU=Math.PI*2;

/* ---------------- 0. math kernel ---------------- */
const V3=(x=0,y=0,z=0)=>({x,y,z});
const vadd=(a,b)=>V3(a.x+b.x,a.y+b.y,a.z+b.z);
const vsub=(a,b)=>V3(a.x-b.x,a.y-b.y,a.z-b.z);
const vmul=(a,s)=>V3(a.x*s,a.y*s,a.z*s);
const vdot=(a,b)=>a.x*b.x+a.y*b.y+a.z*b.z;
const vcrs=(a,b)=>V3(a.y*b.z-a.z*b.y, a.z*b.x-a.x*b.z, a.x*b.y-a.y*b.x);
const vlen=a=>Math.hypot(a.x,a.y,a.z);
const vnrm=a=>{const l=vlen(a)||1e-9;return V3(a.x/l,a.y/l,a.z/l);};
const vclampL=(a,m)=>{const l=vlen(a);return l>m?vmul(a,m/l):V3(a.x,a.y,a.z);};

const qID=()=>({x:0,y:0,z:0,w:1});
function qmul(a,b){return{
  w:a.w*b.w-a.x*b.x-a.y*b.y-a.z*b.z,
  x:a.w*b.x+a.x*b.w+a.y*b.z-a.z*b.y,
  y:a.w*b.y-a.x*b.z+a.y*b.w+a.z*b.x,
  z:a.w*b.z+a.x*b.y-a.y*b.x+a.z*b.w};}
function qrot(q,v){ // rotate vector by quaternion (body->world)
  const tx=2*(q.y*v.z-q.z*v.y),
        ty=2*(q.z*v.x-q.x*v.z),
        tz=2*(q.x*v.y-q.y*v.x);
  return V3(v.x+q.w*tx+(q.y*tz-q.z*ty),
            v.y+q.w*ty+(q.z*tx-q.x*tz),
            v.z+q.w*tz+(q.x*ty-q.y*tx));}
function qrotInv(q,v){ // world->body
  return qrot({x:-q.x,y:-q.y,z:-q.z,w:q.w},v);}
function qnrm(q){const l=Math.hypot(q.x,q.y,q.z,q.w)||1e-9;return{x:q.x/l,y:q.y/l,z:q.z/l,w:q.w/l};}
function qaxis(ax,ang){const h=ang*0.5,s=Math.sin(h);return{x:ax.x*s,y:ax.y*s,z:ax.z*s,w:Math.cos(h)};}
// body rates integration: dq = 0.5 * q * (0,wx,wy,wz)
function qint(q,w,dt){
  const dq={x:w.x*dt*0.5,y:w.y*dt*0.5,z:w.z*dt*0.5,w:0};
  const n=qmul(q,dq);
  return qnrm({x:q.x+n.x,y:q.y+n.y,z:q.z+n.z,w:q.w+n.w});
}
function qslerp(a,b,t){
  let d=a.x*b.x+a.y*b.y+a.z*b.z+a.w*b.w, bb=b;
  if(d<0){d=-d;bb={x:-b.x,y:-b.y,z:-b.z,w:-b.w};}
  if(d>0.9995) return qnrm(lerp3q(a,bb,t));
  const th=Math.acos(d), s=Math.sin(th), s0=Math.sin((1-t)*th)/s, s1=Math.sin(t*th)/s;
  return qnrm({x:a.x*s0+bb.x*s1,y:a.y*s0+bb.y*s1,z:a.z*s0+bb.z*s1,w:a.w*s0+bb.w*s1});
}
function lerp3q(a,b,t){return{x:lerp(a.x,b.x,t),y:lerp(a.y,b.y,t),z:lerp(a.z,b.z,t),w:lerp(a.w,b.w,t)};}
function qFromEuler(x,y,z,out){ // yaw-pitch-roll (YXZ) -> quaternion
  const cy=Math.cos(y*.5),sy=Math.sin(y*.5),cp=Math.cos(x*.5),sp=Math.sin(x*.5),cr=Math.cos(z*.5),sr=Math.sin(z*.5);
  return {x:cy*sp*cr+sy*cp*sr, y:sy*cp*cr-cy*sp*sr, z:cy*cp*sr-sy*sp*cr, w:cy*cp*cr+sy*sp*sr};}
function eulerFromQ(q){ // returns {pitch(x),yaw(y),roll(z)}
  const sinp=2*(q.w*q.x-q.y*q.z);
  const pitch=Math.asin(clamp(sinp,-1,1));
  const yaw=Math.atan2(2*(q.w*q.y+q.x*q.z),1-2*(q.x*q.x+q.y*q.y));
  const roll=Math.atan2(2*(q.w*q.z+q.x*q.y),1-2*(q.x*q.x+q.z*q.z));
  return {pitch,yaw,roll};}

/* ---------------- 1. procedural terrain field ---------------- */
function hash2i(ix,iy,s){
  let h=(ix|0)*374761393+(iy|0)*668265263+(s|0)*362437;
  h=(h^(h>>13))*1274126177; h^=h>>16;
  return (h>>>0)/4294967295;
}
function vnoise(x,y,s){
  const ix=Math.floor(x),iy=Math.floor(y),fx=x-ix,fy=y-iy;
  const ux=fx*fx*(3-2*fx), uy=fy*fy*(3-2*fy);
  const a=hash2i(ix,iy,s),b=hash2i(ix+1,iy,s),c=hash2i(ix,iy+1,s),d=hash2i(ix+1,iy+1,s);
  return lerp(lerp(a,b,ux),lerp(c,d,ux),uy);
}
function fbm(x,y,oct,s){
  let a=0.5,f=1,sum=0,norm=0;
  for(let i=0;i<oct;i++){sum+=a*vnoise(x*f,y*f,s+i*17);norm+=a;a*=0.5;f*=2.03;}
  return sum/norm;
}
function ridged(x,y,oct,s){
  let a=0.5,f=1,sum=0,norm=0;
  for(let i=0;i<oct;i++){
    const n=1-Math.abs(vnoise(x*f,y*f,s+i*31)*2-1);
    sum+=a*n*n;norm+=a;a*=0.5;f*=2.07;}
  return sum/norm;
}
const WORLD={size:38000, base:{x:-1500,z:2200}, baseElev:78, runwayLen:3000, runwayW:60};
function terrainHeight(x,z){
  const cont=fbm(x*0.000072,z*0.000072,4,101);        // continental masses (~14 km cells)
  const mask=smoothstep(0.40,0.62,cont);
  const rd=ridged(x*0.00021,z*0.00021,5,7);           // mountain ranges (~4.8 km cells)
  const det=fbm(x*0.00085,z*0.00085,4,53);            // ~1.2 km relief
  let h=(cont-0.485)*2400;
  h+=rd*1080*mask;
  h+=(det-0.5)*46*mask;
  // airbase plateau + guaranteed landmass shelf around the field
  const dx=x-WORLD.base.x, dz=z-WORLD.base.z, d=Math.hypot(dx,dz);
  const shelf=smoothstep(4000,13000,d);
  h=lerp(Math.max(h,52),h,shelf);
  const f=smoothstep(1500,3400,d);
  h=lerp(WORLD.baseElev,h,f);
  return h;
}
function terrainNormal(x,z){
  const e=8;
  const hL=terrainHeight(x-e,z),hR=terrainHeight(x+e,z),hD=terrainHeight(x,z-e),hU=terrainHeight(x,z+e);
  return vnrm(V3(hL-hR,2*e,hD-hU));
}
// runway plate: flat slab at baseElev, returns height if inside footprint else null
function runwayHeight(x,z){
  const rx=x-WORLD.base.x, rz=z-WORLD.base.z;
  const L=WORLD.runwayLen*0.5+60, W=WORLD.runwayW*0.5+14;
  if(Math.abs(rx)<W && Math.abs(rz)<L) return WORLD.baseElev+0.35;
  return null;
}
function groundHeight(x,z){ const r=runwayHeight(x,z); return r!==null?r:terrainHeight(x,z); }
function groundNormal(x,z){ const r=runwayHeight(x,z); return r!==null?V3(0,1,0):terrainNormal(x,z); }
const BASE_ELEV = WORLD.baseElev;
function isRunway(x,z){
  const rx=x-WORLD.base.x, rz=z-WORLD.base.z;
  return Math.abs(rx)<WORLD.runwayW*0.5 && Math.abs(rz)<WORLD.runwayLen*0.5;
}
/* ISA atmosphere: density ratio vs altitude (m) */
function airDensity(alt){
  const h=clamp(alt,0,20000);
  if(h<11000){const t=1-2.25577e-5*h;return 1.225*Math.pow(t,4.2559);}
  return 0.3639*Math.exp(-(h-11000)/6341.6);
}
const SND=Math.sqrt(1.4*287*288.15); // speed of sound at SL
function speedOfSound(alt){return SND;} // simplified constant

/* ---------------- 2. fleet definitions ---------------- */
// All SI units. Moments use standard non-dimensional coefficients with
// my own sign convention: +x pitch(NOSE UP), +y yaw(NOSE LEFT), +z roll(LEFT WING DOWN)
const FLEET=[
{
  id:"falcon", name:"Aero-Falcon SF-1", role:"Light Prop Trainer", hue:0xeef2f7, accent:0xffa726,
  desc:"Forgiving high-lift airframe with a piston powerplant. Slow, light and eager to fly — the perfect trainer for learning energy management.",
  mass:1150, S:16.2, b:10.4, c:1.72, AR:6.7,
  CL0:0.26, CLalpha:4.7, aStall:16*DEG, CLflap:0.34,
  CD0:0.033, e:0.79, cdFlap:0.036, cdGear:0.024, cdAir:0.09,
  CmAlpha:0.95, Cm0:0.022, CmDe:0.22, CmQ:9.0,
  ClDa:0.095, ClP:0.40, ClBeta:0.055,
  CnBeta:0.135, CnDr:0.062, CnR:0.17,
  Ixx:1250, Iyy:2700, Izz:4000,
  maxThrust:6400, jet:false, prop:true, afterburner:0,
  rpmLo:620, rpmHi:2750, spoolUp:2.2, spoolDn:3.0,
  maxRoll:4.6, maxPitch:2.2, maxYaw:0.65,
  lagP:0.10, lagR:0.065, lagY:0.11,
  gLim:6.0, gNeg:-2.6, gearH:1.55, span:10.4, len:7.5,
  rotateSpd:26, vApp:32, vMax:120,
  camDist:15, camUp:4.2,
  perf:{spd:38, lift:78, agil:62, dur:60}
},
{
  id:"vanguard", name:"Vanguard VX-9", role:"Twin-Engine Air Superiority", hue:0x9aa6b4, accent:0x3ad7ff,
  desc:"Delta-winged, fly-by-wire-less fighter with a pair of low-bypass turbofans and reheat. Devastating instantaneous turn rate, but it bleeds energy fast.",
  mass:9800, S:38.0, b:11.2, c:3.6, AR:3.3,
  CL0:0.09, CLalpha:3.35, aStall:22*DEG, CLflap:0.30,
  CD0:0.0235, e:0.72, cdFlap:0.030, cdGear:0.028, cdAir:0.11,
  CmAlpha:0.88, Cm0:0.016, CmDe:0.30, CmQ:8.0,
  ClDa:0.062, ClP:0.50, ClBeta:0.070,
  CnBeta:0.125, CnDr:0.050, CnR:0.20,
  Ixx:11000, Iyy:52000, Izz:60000,
  maxThrust:82000, jet:true, prop:false, afterburner:128000,
  rpmLo:3200, rpmHi:9800, spoolUp:3.4, spoolDn:2.4,
  maxRoll:6.4, maxPitch:2.6, maxYaw:0.55,
  lagP:0.075, lagR:0.055, lagY:0.09,
  gLim:9.0, gNeg:-3.5, gearH:2.1, span:11.2, len:16.2,
  rotateSpd:58, vApp:70, vMax:340,
  camDist:26, camUp:6.5,
  perf:{spd:94, lift:64, agil:88, dur:72}
},
{
  id:"titan", name:"Titan C-4 Atlas", role:"Heavy Strategic Transport", hue:0x6f7c8a, accent:0xffb648,
  desc:"Four-turboprop giant. Enormous inertia, long runway requirements and glacial roll response — but it carries the weight of the world.",
  mass:70000, S:180.0, b:40.4, c:5.6, AR:9.1,
  CL0:0.30, CLalpha:4.4, aStall:15*DEG, CLflap:0.55,
  CD0:0.029, e:0.80, cdFlap:0.075, cdGear:0.032, cdAir:0.06,
  CmAlpha:1.05, Cm0:0.03, CmDe:0.21, CmQ:11.0,
  ClDa:0.075, ClP:0.44, ClBeta:0.05,
  CnBeta:0.16, CnDr:0.075, CnR:0.19,
  Ixx:820000, Iyy:2600000, Izz:3100000,
  maxThrust:245000, jet:false, prop:true, afterburner:0,
  rpmLo:900, rpmHi:1250, spoolUp:5.0, spoolDn:6.0,
  maxRoll:0.95, maxPitch:0.85, maxYaw:0.42,
  lagP:0.30, lagR:0.38, lagY:0.34,
  gLim:2.5, gNeg:-1.0, gearH:3.4, span:40.4, len:36.0,
  rotateSpd:62, vApp:80, vMax:180,
  camDist:70, camUp:18,
  perf:{spd:30, lift:46, agil:22, dur:96}
}];

/* ============================================================================
   3. RIGID BODY AIRCRAFT (6-DoF) — no rendering dependency
   ========================================================================== */
class Aircraft{
  constructor(spec){
    const s=spec;
    this.spec=s;
    this.pos=V3(0,0,0); this.vel=V3(0,0,0); this.q=qID(); this.omega=V3(0,0,0);
    this.ctrl={pitch:0,roll:0,yaw:0,throttle:0,ab:0,gear:1,flaps:0,brakes:0,airbrake:0,fire:false};
    this.act={pt:0,rl:0,yw:0};
    this.rpm=s.rpmLo; this.thrustNow=0; this.alpha=0; this.beta=0; this.mach=0;
    this.gLoad=1; this.qbar=0; this.rho=1.225; this.V=0; this.stalled=false; this.stallAmt=0;
    this.onGround=true; this.grounded=true; this.gearUp=false; this.gearAnim=1;
    this.alive=true; this.health=1; this.smoke=0; this.fireT=0; this.gunHeat=0;
    this.impact=0; this.bank=0; this.pitchAng=0; this.heading=0; this.vspeed=0;
    this.rollRate=0; this.pitchRate=0; this.aoaDeg=0;
    this.airborneTime=0; this.alphaTrim=0;
    this.fx={exhaust:0,contrail:0};
    this.gunCool=0; this.weaponAmmo=99999; this.hitFlash=0; this.crashed=false;
    this.crashReason=''; this.gLimit=false; this.team='blue'; this.uid='';
    this.airborneTime=0; this.loadFactor=1;
  }
  reset(x,z,hdg,airborne,alt){
    this.pos=V3(x,groundHeight(x,z)+(airborne?alt:this.spec.gearH+0.02),z);
    this.q=qFromEuler(0,hdg,0);
    this.vel=airborne? qrot(this.q,V3(0,0,-this.spec.vApp*1.45)) : V3(0,0,0);
    this.omega=V3(0,0,0); this.ctrl.throttle=airborne?0.72:0;
    this.ctrl.gear=1; this.ctrl.flaps=airborne?0:0.35;
    this.act={pt:0,rl:0,yw:0};
    this.rpm=this.spec.rpmLo; this.thrustNow=0; this.alive=true; this.health=1; this.smoke=0;
    this.onGround=!airborne; this.gearUp=false; this.gearAnim=1; this.airborneTime=0;
    this.crashed=false; this.crashReason=''; this.gLimit=false; this.grounded=!airborne;
    this.lastGround=!airborne; this.impact=0; this.smoke=0; this.health=1;
  }
  /* ---- one physics substep ---- */
  step(dt,env){
    const s=this.spec, c=this.ctrl;
    /* ---------- engine ---------- */
    let thrCmd=c.throttle;
    const tgtRpm=s.rpmLo+(s.rpmHi-s.rpmLo)*thrCmd;
    const tau=(tgtRpm>this.rpm? s.spoolUp : s.spoolDn);
    this.rpm += (tgtRpm-this.rpm)*clamp(dt/tau,0,1);
    const rpmFrac=(this.rpm-s.rpmLo)/(s.rpmHi-s.rpmLo);
    const sigma=airDensity(this.pos.y)/1.225;
    let thrust=s.maxThrust*(0.06+0.94*rpmFrac)*Math.pow(sigma,0.78);
    if(s.jet && c.ab>0.5 && rpmFrac>0.9) thrust=s.afterburner*Math.pow(sigma,0.85)*(0.6+0.4*rpmFrac);
    if(s.prop){ // propeller efficiency falls with airspeed
      const vf=clamp(this.V/(s.vMax*1.45),0,0.9);
      thrust*= (1-0.72*vf);
    }
    this.thrustNow=thrust;
    /* ---------- air data ---------- */
    const vb=qrotInv(this.q,this.vel);
    const V=vlen(vb); this.V=V;
    const upAir=Math.max(1.2,-vb.z);
    const alpha=Math.atan2(-vb.y,upAir);   // + = nose above flight path
    this.rho=airDensity(this.pos.y);
    const dyn=0.5*this.rho*V*V;
    this.qbar=dyn;
    this.mach=V/SND;
    const beta=V>3?Math.asin(clamp(vb.x/V,-1,1)):0;
    this.alpha=alpha; this.beta=beta; this.aoaDeg=alpha*RAD;
    /* ---------- lift / drag ---------- */
    const flapK=c.flaps, gearK=(1-this.gearAnim)*1, airK=c.airbrake;
    const stallA=s.aStall*(1-0.10*flapK);
    const clMax=s.CL0+s.CLalpha*stallA+s.CLflap*flapK;
    let CL;
    const aAbs=Math.min(Math.abs(alpha),2.6), sg=alpha<0?-1:1;
    if(aAbs<=stallA){ CL=s.CL0+s.CLflap*flapK+s.CLalpha*alpha; }
    else{
      const over=aAbs-stallA;
      CL=sg*clMax*(0.90*Math.exp(-over*2.1)+0.13);
    }
    this.stalled = aAbs>stallA && V>6;
    this.stallAmt = clamp((aAbs-stallA)/(25*DEG),0,1);
    let CD=s.CD0+s.cdFlap*flapK+s.cdGear*gearK+s.cdAir*airK
           + 0.006*(Math.abs(this.act.pt)+Math.abs(this.act.rl)+Math.abs(this.act.yw))
           + 1.05*beta*beta;
    CD+= (CL*CL)/(Math.PI*s.AR*s.e);
    if(this.mach>0.72) CD*= 1+6.5*Math.pow(this.mach-0.72,2);
    // G-limited lift (structural / physiological limit)
    const W=s.mass*9.80665;
    let liftF=dyn*s.S*CL;
    const gLimForce=s.gLim*W, gMax=liftF, gNegForce=s.gNeg*W;
    if(liftF>gLimForce){ CL=gLimForce/(dyn*s.S+1e-6); liftF=gLimForce; this.gLimit=true; } else this.gLimit=false;
    if(liftF<gNegForce){ CL=gNegForce/(dyn*s.S+1e-6); liftF=gNegForce; }
    /* ---------- forces in body frame ---------- */
    const d=V>1?vmul(vb,1/V):V3(0,0,-1);
    let liftDir=vsub(V3(0,1,0),vmul(d,d.y));
    liftDir=vlen(liftDir)<1e-4?V3(0,1,0):vnrm(liftDir);
    const sideDir=vcrs(liftDir,d);
    const dragF=dyn*s.S*CD;
    const sideF=dyn*s.S*0.85*beta;
    let Fb=V3(
      sideDir.x*sideF + dragF*(-d.x),
      liftDir.y*liftF + dragF*(-d.y),
      liftDir.z*liftF + dragF*(-d.z)
    );
    Fb.z -= thrust; // thrust along -Z (forward)
    // load factor = non-gravitational force along body-up axis / weight
    const upB=V3(0,1,0);
    this.gLoad=vdot(Fb,upB)/W;
    this.loadFactor=this.gLoad;
    // gravity (body frame)
    const gBody=qrotInv(this.q,V3(0,-W,0));
    Fb=vadd(Fb,gBody);
    /* ---------- moments ---------- */
    const b=s.b, cc=s.c;
    const qhat=this.omega.x*cc/(2*Math.max(V,12));
    const phat=this.omega.z*b/(2*Math.max(V,12));
    const rhat=this.omega.y*b/(2*Math.max(V,12));
    let Mx=dyn*s.S*cc*(s.Cm0 - s.CmAlpha*alpha + s.CmDe*this.act.pt - s.CmQ*qhat);
    let Mz=dyn*s.S*b*(-s.ClDa*this.act.rl - s.ClP*phat + s.ClBeta*beta);
    let My=dyn*s.S*b*(-s.CnBeta*beta - s.CnR*rhat - s.CnDr*this.act.yw);
    /* engine torque reaction / P-factor roll (props) */
    if(s.prop) Mz += (this.rpm/s.rpmHi)*s.mass*0.9;
    /* ---------- control surface lag ---------- */
    const lp=clamp(dt/s.lagP,0,1), lr=clamp(dt/s.lagR,0,1), ly=clamp(dt/s.lagY,0,1);
    this.act.pt += (c.pitch-this.act.pt)*lp;
    this.act.rl += (c.roll -this.act.rl )*lr;
    this.act.yw += (c.yaw  -this.act.yw )*ly;
    /* ---------- ground contact ---------- */
    const gh=groundHeight(this.pos.x,this.pos.z);
    const gearDown=this.gearAnim>0.5;
    const clear=s.gearH*(gearDown?1:0.42);
    const pen=gh+clear-this.pos.y;
    this.grounded=false;
    let groundTorque=V3(0,0,0);
    if(pen>0){
      this.grounded=true; this.onGround=true;
      const n=groundNormal(this.pos.x,this.pos.z);
      const up=qrot(this.q,V3(0,1,0));
      const vn=vdot(this.vel,n);
      this.impact=Math.max(0,-vn);
      this.pos.y=gh+clear;
      if(vn<0) this.vel=vsub(this.vel,vmul(n,vn*1.0));
      // tangential friction
      const vt=vsub(this.vel,vmul(n,vdot(this.vel,n)));
      const vtL=vlen(vt), mu=0.018+c.brakes*0.55+(gearDown?0:0.85)+ (isRunway(this.pos.x,this.pos.z)?0:0.06);
      const dv=mu*9.80665*dt;
      if(vtL>dv){ this.vel=vsub(this.vel,vmul(vt,dv/vtL)); } else { this.vel=vmul(n,vdot(this.vel,n)); }
      // ---- crash checks ----
      const tilt=Math.acos(clamp(vdot(up,n),-1,1));
      if(this.impact>5.5){ this.crashed=true; this.crashReason='HARD IMPACT '+this.impact.toFixed(1)+' m/s'; }
      else if(tilt>26*DEG){ this.crashed=true; this.crashReason='AIRFRAME TILTED ON GROUND'; }
      else if(!gearDown && this.V>4 && this.airborneTime>0.3){ this.crashed=true; this.crashReason='GEAR-UP CONTACT'; }
      else if(Math.abs(this.bank)>27*DEG && this.V>8){ this.crashed=true; this.crashReason='WING STRIKE'; }
      // ---- ground attitude spring ----
      const eu=eulerFromQ(this.q);
      const rotSpeed=Math.max(s.rotateSpd,26);
      const canRotate=V>rotSpeed*0.82;
      const pitchTgt=canRotate? c.pitch*13*DEG*clamp((V-rotSpeed*0.72)/(rotSpeed*0.45),0,1):0;
      const rollErr=-eu.roll+(this.pos.x-WORLD.base.x)*0;
      const Kp=42, Kd=9;
      groundTorque=V3(
        (pitchTgt-eu.pitch)*Kp*s.Izz - this.omega.x*Kd*s.Izz,
        (-this.omega.y*3.0 - c.yaw*2.4*clamp(V/16,0,1))*s.Iyy,
        (rollErr)*Kp*0.85*s.Ixx - this.omega.z*Kd*0.9*s.Ixx
      );
      Mx+=groundTorque.x; My+=groundTorque.y; Mz+=groundTorque.z;
      this.airborneTime=0;
    } else {
      this.onGround=false;
      this.airborneTime+=dt;
    }
    if(this.lastGround!==undefined) this.justLanded = this.lastGround===false;
    this.lastGround=this.grounded;
    this.groundContact = this.grounded;
    /* ---------- integrate linear ---------- */
    const Fw=qrot(this.q,Fb);
    this.vel=vadd(this.vel,vmul(Fw,dt/s.mass));
    this.pos=vadd(this.pos,vmul(this.vel,dt));
    /* ---------- integrate angular ---------- */
    const Iom=V3(s.Ixx*this.omega.x,s.Iyy*this.omega.y,s.Izz*this.omega.z);
    const gy=vcrs(this.omega,Iom);
    const wdot=V3((Mx-gy.x)/s.Ixx,(My-gy.y)/s.Iyy,(Mz-gy.z)/s.Izz);
    this.omega=vadd(this.omega,vmul(wdot,dt));
    this.omega=V3(clamp(this.omega.x,-s.maxPitch,s.maxPitch),
                  clamp(this.omega.y,-s.maxYaw,s.maxYaw),
                  clamp(this.omega.z,-s.maxRoll,s.maxRoll));
    this.q=qint(this.q,this.omega,dt);
    /* ---------- derived telemetry ---------- */
    const upv=qrot(this.q,V3(0,1,0));
    const fwd=qrot(this.q,V3(0,0,-1));
    this.vspeed=this.vel.y;
    this.pitchAng=Math.asin(clamp(vdot(fwd,V3(0,1,0)),-1,1));
    this.bank=Math.atan2(vdot(qrot(this.q,V3(1,0,0)),V3(0,1,0)),vdot(upv,V3(0,1,0)));
    this.heading=(Math.atan2(fwd.x,-fwd.z)*RAD+360)%360;
    this.rollRate=this.omega.z*RAD; this.pitchRate=this.omega.x*RAD;
    return this;
  }
}

/* ============================================================================
   4. AI PILOT
   ========================================================================== */
class AIPilot{
  constructor(ac,skill){
    this.ac=ac; this.skill=clamp(skill||0.7,0.3,1);
    this.mode='pursue'; this.modeT=0; this.evadeDir=1; this.jitter={p:0,r:0,y:0};
    this.noiseT=0; this.fired=0;
  }
  update(dt,ctx){
    const ac=this.ac, s=ac.spec, c=ac.ctrl, tg=ctx.target;
    const fwd=qrot(ac.q,V3(0,0,-1)), up=qrot(ac.q,V3(0,1,0)), right=qrot(ac.q,V3(1,0,0));
    const altAgl=ac.pos.y-groundHeight(ac.pos.x,ac.pos.z);
    let ctrl={pitch:0,roll:0,yaw:0,throttle:1,ab:0,gear:0,flaps:0,brakes:0,airbrake:0,fire:false};
    if(!tg||!tg.alive){
      // patrol: level out, gentle bank
      this.patrol(dt,ac,ctrl,altAgl); this.apply(ac,ctrl,dt); return;
    }
    const toV=vsub(tg.pos,ac.pos), dist=vlen(toV);
    const tgFwd=qrot(tg.q,V3(0,0,-1));
    const los=vnrm(toV);
    const aspect=vdot(tgFwd,vmul(los,-1));         // 1 = target nose on us
    const myAspect=vdot(fwd,los);                  // 1 = we are pointed at them
    this.modeT+=dt;
    // ---- decide mode ----
    if(this.mode==='pursue'){
      if((aspect>0.86 && dist<900) || dist<230){ this.mode='break'; this.modeT=0; this.evadeDir=Math.random()<0.5?-1:1; }
    } else if(this.mode==='break'){
      if(this.modeT>3.4+Math.random()*2 || dist>1900 || altAgl<220){ this.mode='pursue'; this.modeT=0; }
    }
    // ---- ground avoidance overrides all ----
    const sinkWarn = ac.vel.y<-8 ? 180 : 110;
    if(altAgl<sinkWarn){
      const pullUp=clamp((sinkWarn-altAgl)/sinkWarn,0,1);
      ctrl.pitch=clamp(pullUp*1.4,0,1); ctrl.roll=0; ctrl.throttle=1;
      // level wings
      const bankA=Math.atan2(vdot(right,V3(0,1,0)),vdot(up,V3(0,1,0)));
      ctrl.roll=clamp(-bankA*1.8, -1,1);
      ctrl.pitch=Math.max(ctrl.pitch, clamp(-ac.pitchAng*3, 0, 1.4));
      if(altAgl<70&&ac.vel.y<0) ctrl.pitch=1;
      this.apply(ac,ctrl,dt); return;
    }
    if(this.mode==='pursue'){
      // lead the target
      const bs=1100, tof=dist/bs;
      const lead=vadd(tg.pos,vmul(tg.vel,tof*0.95));
      let dir=vnrm(vsub(lead,ac.pos));
      if(myAspect<0.2 && dist<260) dir=vnrm(vadd(dir,vmul(right,0.4)));
      this.steer(ac,dir,ctrl,dist,tg);
      ctrl.throttle = dist>1400?1: (dist>600?0.92:0.78);
      ctrl.ab = (dist>1800||ac.V<s.vMax*0.55)?1:0;
      // gun solution
      const tofG=dist/bs;
      const aimPoint=vadd(tg.pos,vmul(tg.vel,tofG));
      const aimDir=vnrm(vsub(aimPoint,ac.pos));
      const cone=Math.acos(clamp(vdot(fwd,vmul(aimDir,-1))*0+ vdot(fwd,aimDir),-1,1));
      ctrl.fire = (cone<4.2*DEG && dist<1150 && ac.airborneTime>1.2);
    } else {
      // defensive break
      ctrl.throttle=1; ctrl.ab=1;
      const bankTarget=this.evadeDir*1.15;
      const bankA=Math.atan2(vdot(right,V3(0,1,0)),vdot(up,V3(0,1,0)));
      ctrl.roll=clamp((bankTarget-bankA)*2.2-ac.omega.z*0.35,-1,1);
      ctrl.pitch=clamp(0.85+Math.sin(this.modeT*1.4)*0.5*(1-this.skill*0.3),0,1);
      ctrl.fire=false;
    }
    this.apply(ac,ctrl,dt);
  }
  patrol(dt,ac,ctrl,agl){
    const bankA=Math.atan2(vdot(qrot(ac.q,V3(1,0,0)),V3(0,1,0)),vdot(qrot(ac.q,V3(0,1,0)),V3(0,1,0)));
    ctrl.roll=clamp(-bankA*1.5, -1,1);
    const targetAlt=agl<500?0.6:(agl>1400?-0.3:0);
    ctrl.pitch=clamp(targetAlt - ac.pitchAng*2.4, -1,1);
    ctrl.throttle=0.7; ctrl.yaw=0;
  }
  steer(ac,dirW,ctrl,dist,tg){
    const dirB=qrotInv(ac.q,dirW);
    const yawErr=Math.atan2(dirB.x,-dirB.z);
    const pitchErr=Math.atan2(dirB.y,-dirB.z);
    const s=ac.spec;
    // bank-to-turn
    let maxBank=1.35*(0.6+0.4*this.skill);
    const dBank=clamp(-yawErr*2.4,-maxBank,maxBank);
    const bankA=Math.atan2(vdot(qrot(ac.q,V3(1,0,0)),V3(0,1,0)),vdot(qrot(ac.q,V3(0,1,0)),V3(0,1,0)));
    let rollCmd=(dBank-bankA)*1.9 - ac.omega.z*0.30;
    let pitchCmd=pitchErr*2.2 + 0.02 - ac.omega.x*0.18;
    // scale pitch pull against bank (gravity compensation)
    pitchCmd += Math.cos(bankA)*0.10;
    ctrl.roll=clamp(rollCmd,-1,1);
    ctrl.pitch=clamp(pitchCmd,-1,1);
    ctrl.yaw=clamp(yawErr*0.55 - ac.omega.y*0.25,-0.7,0.7);
  }
  apply(ac,ctrl,dt){
    // reaction lag + noise so the AI doesn't feel robotic
    const k=clamp(dt/(0.09+0.10*(1-this.skill)),0,1);
    this.noiseT+=dt;
    const n=(t,f)=>Math.sin(t*9.1+f)*0.5+Math.sin(t*17.3+f*2)*0.3;
    const amp=0.05*(1-this.skill)+0.02;
    ac.ctrl.pitch += (clamp(ctrl.pitch+n(this.noiseT,1)*amp,-1,1)-ac.ctrl.pitch)*k;
    ac.ctrl.roll  += (clamp(ctrl.roll +n(this.noiseT,3)*amp,-1,1)-ac.ctrl.roll )*k;
    ac.ctrl.yaw   += (clamp(ctrl.yaw  +n(this.noiseT,5)*amp*0.6,-1,1)-ac.ctrl.yaw )*k;
    ac.ctrl.throttle += (ctrl.throttle-ac.ctrl.throttle)*clamp(dt/0.5,0,1);
    ac.ctrl.ab=ctrl.ab; ac.ctrl.fire=ctrl.fire&&ac.weaponAmmo>0;
  }
}

/* ============================================================================
   5. WEAPONS
   ========================================================================== */
class Weapons{
  constructor(){ this.list=[]; this.tracers=[]; }
  fire(shooter,dir){
    if(shooter.gunCool>0) return false;
    shooter.gunCool=0.075;
    const off=V3(0,0,0);
    for(let i=0;i<2;i++){
      const side=(i===0?1:-1);
      const muzzle=vadd(shooter.pos,
        vadd(qrot(shooter.q,V3(side*shooter.spec.b*0.16,-1.1,-shooter.spec.len*0.5)),
             vmul(shooter.vel,0.0)));
      const spread=(Math.random()-0.5)*0.006;
      const d=vnrm(vadd(dir,vmul(qrot(shooter.q,V3(1,0,0)),spread)));
      this.list.push({pos:muzzle,vel:vadd(shooter.vel,vmul(d,1050)),life:3.0,owner:shooter,dmg:0.085,
                      trail:[muzzle]});
    }
    this.tracers.push({t:0});
    return true;
  }
  step(dt,aircraftList){
    const out=[];
    for(let i=this.list.length-1;i>=0;i--){
      const b=this.list[i];
      b.life-=dt;
      const old={x:b.pos.x,y:b.pos.y,z:b.pos.z};
      b.vel.y-=9.80665*dt*0.55;
      const spdNow=vlen(b.vel);
      b.vel=vmul(b.vel,Math.max(0,1-0.00016*spdNow*dt));
      b.pos=vadd(b.pos,vmul(b.vel,dt));
      b.trail.push({x:b.pos.x,y:b.pos.y,z:b.pos.z});
      if(b.trail.length>7) b.trail.shift();
      let dead=b.life<=0, hitTarget=null;
      if(!dead){
        // segment-sphere test against every aircraft (prevents tunnelling)
        const seg=vsub(b.pos,old);
        const segLen2=Math.max(1e-6,vdot(seg,seg));
        for(const ac of aircraftList){
          if(ac===b.owner||!ac.alive) continue;
          const rad=Math.max(3.5,ac.spec.b*0.30);
          const w=vsub(ac.pos,old);
          const t=clamp(vdot(w,seg)/segLen2,0,1);
          const closest=vadd(old,vmul(seg,t));
          if(vlen(vsub(ac.pos,closest))<rad+1.0){ dead=true; hitTarget=ac; break; }
        }
      }
      if(!dead){
        const gh=groundHeight(b.pos.x,b.pos.z);
        if(b.pos.y<gh){ dead=true; out.push({hit:true,ground:true,pos:b.pos}); }
      }
      if(dead){
        if(hitTarget) out.push({hit:true,target:hitTarget,dmg:b.dmg});
        (this.deadTracers=this.deadTracers||[]).push(b.trail);
        this.list.splice(i,1);
      }
    }
    if(this.deadTracers&&this.deadTracers.length>26) this.deadTracers.splice(0,this.deadTracers.length-26);
    return out;
  }
}

/* ============================================================================
   6. PROCEDURAL AUDIO ENGINE (WebAudio, no assets)
   ========================================================================== */
const Aero={
  ctx:null, master:null, on:true, started:false, voices:new Map(), noiseBuf:null,
  init(){
    if(this.ctx) return;
    const AC=window.AudioContext||window.webkitAudioContext;
    if(!AC) {this.on=false;return;}
    this.ctx=new AC();
    const comp=this.ctx.createDynamicsCompressor();
    comp.threshold.value=-14; comp.knee.value=12; comp.ratio.value=4; comp.attack.value=0.004; comp.release.value=0.2;
    this.master=this.ctx.createGain(); this.master.gain.value=0.0;
    this.master.connect(comp); comp.connect(this.ctx.destination);
    // noise buffer
    const len=this.ctx.sampleRate*3, buf=this.ctx.createBuffer(1,len,this.ctx.sampleRate), d=buf.getChannelData(0);
    let l=0;
    for(let i=0;i<len;i++){ const w=Math.random()*2-1; l=(l+w*0.35)*0.94; d[i]=clamp(w*0.7+l*0.6,-1,1); }
    this.noiseBuf=buf;
    // ambient wind bed (global)
    this.windSrc=this.ctx.createBufferSource(); this.windSrc.buffer=buf; this.windSrc.loop=true;
    this.windFilt=this.ctx.createBiquadFilter(); this.windFilt.type='lowpass'; this.windFilt.frequency.value=500;
    this.windGain=this.ctx.createGain(); this.windGain.gain.value=0;
    this.windSrc.connect(this.windFilt); this.windFilt.connect(this.windGain); this.windGain.connect(this.master);
    this.windSrc.start();
    // airflow roar (higher band)
    this.roarSrc=this.ctx.createBufferSource(); this.roarSrc.buffer=buf; this.roarSrc.loop=true;
    this.roarFilt=this.ctx.createBiquadFilter(); this.roarFilt.type='bandpass'; this.roarFilt.frequency.value=1400; this.roarFilt.Q.value=0.6;
    this.roarGain=this.ctx.createGain(); this.roarGain.gain.value=0;
    this.roarSrc.connect(this.roarFilt); this.roarFilt.connect(this.roarGain); this.roarGain.connect(this.master);
    this.roarSrc.start();
    this.started=true;
  },
  voice(id){
    if(!this.ctx) return null;
    let v=this.voices.get(id);
    if(v) return v;
    const c=this.ctx;
    const out=c.createGain(); out.gain.value=0.0; out.connect(this.master);
    const lp=c.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=900; lp.Q.value=2.0;
    lp.connect(out);
    const o1=c.createOscillator(); o1.type='sawtooth'; o1.frequency.value=60;
    const o2=c.createOscillator(); o2.type='square';   o2.frequency.value=61;
    const g1=c.createGain(); g1.gain.value=0.5;
    const g2=c.createGain(); g2.gain.value=0.22;
    o1.connect(g1); g1.connect(lp); o2.connect(g2); g2.connect(lp);
    // turbine / prop chop noise
    const ns=c.createBufferSource(); ns.buffer=this.noiseBuf; ns.loop=true;
    const bp=c.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=1800; bp.Q.value=1.4;
    const ng=c.createGain(); ng.gain.value=0.0;
    ns.connect(bp); bp.connect(ng); ng.connect(out);
    // sub rumble
    const o3=c.createOscillator(); o3.type='sine'; o3.frequency.value=30;
    const g3=c.createGain(); g3.gain.value=0.0;
    o3.connect(g3); g3.connect(out);
    o1.start(); o2.start(); ns.start(); o3.start();
    v={out,lp,o1,o2,o3,g1,g2,ng,g3,bp};
    this.voices.set(id,v);
    return v;
  },
  updateFlight(ac,dt){
    if(!this.ctx||!this.on) return;
    if(!ac||!isFinite(ac.V)||!isFinite(ac.rpm)||!isFinite(ac.pos.y)) return;
    const v=this.voice(ac.uid||'player');
    if(!v) return;
    const s=ac.spec;
    const rpmF=(ac.rpm-s.rpmLo)/(s.rpmHi-s.rpmLo);
    const t=this.ctx.currentTime, tc=0.06;
    const rhoF=clamp(airDensity(ac.pos.y)/1.225,0.15,1);
    if(s.jet){
      const base=42+rpmF*118;
      v.o1.frequency.setTargetAtTime(base,t,tc);
      v.o2.frequency.setTargetAtTime(base*2.02,t,tc);
      v.o2.type='sawtooth';
      v.lp.frequency.setTargetAtTime(500+rpmF*3200+ac.V*2.2,t,tc);
      v.g1.gain.setTargetAtTime(0.26+rpmF*0.30,t,tc);
      v.g2.gain.setTargetAtTime(0.10+rpmF*0.26,t,tc);
      v.bp.frequency.setTargetAtTime(2200+rpmF*5200,t,tc);
      v.ng.gain.setTargetAtTime((0.03+rpmF*0.14)*rhoF*(ac.ctrl.ab>0.5?1.7:1),t,tc);
      v.g3.gain.setTargetAtTime(0.05+rpmF*0.22,t,tc);
    } else {
      const base=s.rpmLo+rpmF*(s.rpmHi-s.rpmLo);
      const f=base/60*2.1;
      v.o1.type='sawtooth'; v.o2.type='square';
      v.o1.frequency.setTargetAtTime(f,t,tc);
      v.o2.frequency.setTargetAtTime(f*2.0,t,tc);
      v.lp.frequency.setTargetAtTime(320+rpmF*1500+ac.V*1.1,t,tc);
      v.g1.gain.setTargetAtTime(0.22+rpmF*0.26,t,tc);
      v.g2.gain.setTargetAtTime(0.08+rpmF*0.20,t,tc);
      v.bp.frequency.setTargetAtTime(180+rpmF*900,t,tc);
      v.bp.Q.value=3.2;
      v.ng.gain.setTargetAtTime((0.05+rpmF*0.22)*rhoF,t,tc);
      v.g3.gain.setTargetAtTime(0.03+rpmF*0.10,t,tc);
    }
    const dist=this.camDistTo?this.camDistTo:0;
    const near=clamp(1-(dist/900),0.03,1);
    v.out.gain.setTargetAtTime(clamp((0.16+rpmF*0.5)*near,0,1)*(this.on?1:0),t,0.08);
    // global wind / airflow
    const spdF=clamp(ac.V/300,0,1.6);
    if(this.windGain){
      this.windGain.gain.setTargetAtTime(clamp(spdF*spdF*0.20,0,0.5)*near,t,0.1);
      this.windFilt.frequency.setTargetAtTime(320+spdF*1400,t,0.1);
      this.roarGain.gain.setTargetAtTime(clamp(spdF*spdF*0.16*Math.min(1,1+Math.abs(ac.alpha)*4)+ac.stallAmt*0.25,0,0.6)*near,t,0.08);
      this.roarFilt.frequency.setTargetAtTime(700+spdF*2600,t,0.1);
    }
  },
  burst(type,vol){
    if(!this.ctx||!this.on) return;
    const c=this.ctx,t=c.currentTime;
    const src=c.createBufferSource(); src.buffer=this.noiseBuf; src.loop=true;
    src.playbackRate.value=type==='gun'?1.4:0.7;
    src.start(t);
    const f=c.createBiquadFilter();
    const g=c.createGain();
    src.connect(f); f.connect(g); g.connect(this.master);
    const v=(vol||0.3);
    if(type==='gun'){ f.type='bandpass'; f.frequency.value=1500; f.Q.value=0.9;
      g.gain.setValueAtTime(v,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.09); src.stop(t+0.12); }
    else if(type==='boom'){ f.type='lowpass'; f.frequency.setValueAtTime(1600,t); f.frequency.exponentialRampToValueAtTime(60,t+1.6);
      g.gain.setValueAtTime(v,t); g.gain.exponentialRampToValueAtTime(0.001,t+1.8); src.stop(t+1.9); }
    else if(type==='touch'){ f.type='bandpass'; f.frequency.value=260; f.Q.value=1.2;
      g.gain.setValueAtTime(v,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.35); src.stop(t+0.4); }
    else if(type==='alarm'){ f.type='bandpass'; f.frequency.value=900; f.Q.value=8;
      g.gain.setValueAtTime(v,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.3); src.stop(t+0.35); }
    else { f.type='bandpass'; f.frequency.value=1200; f.Q.value=1;
      g.gain.setValueAtTime(v,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.2); src.stop(t+0.25); }
    src.start(t);
  },
  stallTone(on){
    if(!this.ctx||!this.on) return;
    const t=this.ctx.currentTime;
    if(on&&!this._stallOsc){
      const o=this.ctx.createOscillator(); o.type='square'; o.frequency.value=880;
      const lfo=this.ctx.createOscillator(); lfo.type='square'; lfo.frequency.value=6;
      const lg=this.ctx.createGain(); lg.gain.value=0.5; lfo.connect(lg);
      const g=this.ctx.createGain(); g.gain.value=0.0;
      lg.connect(g.gain);
      o.connect(g); g.connect(this.master); o.start(); lfo.start();
      g.gain.value=0.06; this._stallOsc={o,lfo,g};
    } else if(!on&&this._stallOsc){
      const {o,lfo,g}=this._stallOsc; g.gain.value=0;
      try{o.stop();lfo.stop();}catch(e){}
      this._stallOsc=null;
    }
  },
  setMaster(v){ if(this.master) this.master.gain.setTargetAtTime(v,this.ctx.currentTime,0.25); }
};

/* ============================================================================
   7. RENDER WORLD (three.js)
   ========================================================================== */
let renderer,scene,camera,sun,sunDir,terrainMesh,waterMesh,cloudGroup,worldGroup;
const SUN_DIR=vnrm(V3(0.42,0.60,0.68));
const SKY_TOP=new THREE.Color(0x2c6ec4), SKY_MID=new THREE.Color(0x9cc4e8), SKY_BOT=new THREE.Color(0xdfd6c2);
const FOG_COLOR=new THREE.Color(0xa9c5dc);

function initRenderer(){
  renderer=new THREE.WebGLRenderer({canvas:document.getElementById('scene'),antialias:true,logarithmicDepthBuffer:false,powerPreference:'high-performance'});
  renderer.setPixelRatio(Math.min(devicePixelRatio,1.75));
  renderer.setSize(innerWidth,innerHeight);
  renderer.outputEncoding=THREE.sRGBEncoding;
  renderer.toneMapping=THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure=1.06;
  renderer.shadowMap.enabled=true;
  renderer.shadowMap.type=THREE.PCFSoftShadowMap;
  scene=new THREE.Scene();
  scene.fog=new THREE.FogExp2(FOG_COLOR.getHex(),0.000075);
  camera=new THREE.PerspectiveCamera(60,innerWidth/innerHeight,0.8,60000);
}
function makeSky(){
  const g=new THREE.SphereGeometry(34000,32,20);
  const m=new THREE.ShaderMaterial({
    side:THREE.BackSide, depthWrite:false,
    uniforms:{top:{value:SKY_TOP},mid:{value:SKY_MID},bot:{value:SKY_BOT},
      sunDir:{value:new THREE.Vector3(SUN_DIR.x,SUN_DIR.y,SUN_DIR.z)},
      sunCol:{value:new THREE.Color(0xfff3d6)}},
    vertexShader:`varying vec3 vD; void main(){ vD=normalize(position); gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);} `,
    fragmentShader:`
      uniform vec3 top,mid,bot,sunDir,sunCol; varying vec3 vD;
      void main(){
        vec3 d=normalize(vD); float h=d.y;
        vec3 c=mix(mid,top,pow(smoothstep(0.02,0.75,h),0.85));
        c=mix(bot,c,smoothstep(-0.10,0.06,h));
        float sd=max(dot(d,normalize(sunDir)),0.0);
        c+=sunCol*pow(sd,1500.0)*2.2;
        c+=sunCol*pow(sd,28.0)*0.22;
        c+=sunCol*pow(sd,3.0)*0.05;
        gl_FragColor=vec4(c,1.0);
      }`
  });
  const sky=new THREE.Mesh(g,m); sky.frustumCulled=false; scene.add(sky);
}
function makeLights(){
  const hemi=new THREE.HemisphereLight(0xbcd8f0,0x4a4436,0.48); scene.add(hemi);
  sun=new THREE.DirectionalLight(0xfff2dc,1.85);
  sun.position.set(SUN_DIR.x*4000,SUN_DIR.y*4000,SUN_DIR.z*4000);
  sun.castShadow=true;
  sun.shadow.mapSize.set(2048,2048);
  const d=260; const c=sun.shadow.camera;
  c.left=-d;c.right=d;c.top=d;c.bottom=-d;c.near=1;c.far=12000;
  sun.shadow.bias=-0.0006; sun.shadow.normalBias=0.6;
  scene.add(sun); scene.add(sun.target);
  scene.add(new THREE.AmbientLight(0x5a6b7d,0.32));
}
function noiseTexture(size,contrast){
  const cv=document.createElement('canvas'); cv.width=cv.height=size;
  const cx=cv.getContext('2d'); const img=cx.createImageData(size,size);
  for(let y=0;y<size;y++)for(let x=0;x<size;x++){
    const i=(y*size+x)*4;
    let v=fbm(x*0.09,y*0.09,4,900)*0.7+fbm(x*0.4,y*0.4,3,31)*0.3;
    v=0.5+(v-0.5)*contrast;
    const c=clamp(v,0,1)*255;
    img.data[i]=c;img.data[i+1]=c;img.data[i+2]=c;img.data[i+3]=255;
  }
  cx.putImageData(img,0,0);
  const t=new THREE.CanvasTexture(cv); t.wrapS=t.wrapT=THREE.RepeatWrapping; return t;
}
function buildTerrain(){
  const SIZE=WORLD.size, SEG=352;
  const geo=new THREE.PlaneGeometry(SIZE,SIZE,SEG,SEG);
  geo.rotateX(-Math.PI/2);
  const pos=geo.attributes.position;
  const colors=new Float32Array(pos.count*3);
  const col=new THREE.Color();
  const cSand=new THREE.Color(0xc4b189), cGrass=new THREE.Color(0x4f7238),
        cGrass2=new THREE.Color(0x8aa35c), cRock=new THREE.Color(0x6d675e),
        cRock2=new THREE.Color(0x9c9285), cSnow=new THREE.Color(0xf2f7fb), cDeep=new THREE.Color(0x2c4b58),
        cArid=new THREE.Color(0xa89364), cAlp=new THREE.Color(0x5e7a4e);
  const heights=new Float32Array(pos.count);
  for(let i=0;i<pos.count;i++){
    const x=pos.getX(i), z=pos.getZ(i);
    const h=terrainHeight(x,z);
    heights[i]=h;
    pos.setY(i,h);
  }
  const nrm={x:0,y:1,z:0};
  for(let i=0;i<pos.count;i++){
    const x=pos.getX(i), z=pos.getZ(i), h=heights[i];
    const e=120;
    const slope=Math.min(1,(Math.abs(terrainHeight(x+e,z)-terrainHeight(x-e,z))/(2*e)+
                            Math.abs(terrainHeight(x,z+e)-terrainHeight(x,z-e))/(2*e))*0.6);
    const nz=fbm(x*0.0009,z*0.0009,3,77);
    const nz2=fbm(x*0.007,z*0.007,2,21);
    if(h<2.5){ col.copy(cSand).lerp(cDeep,clamp((2.5-h)/14,0,1)); }
    else {
      const biome=fbm(x*0.00016,z*0.00016,3,313);
      col.copy(cGrass).lerp(cGrass2,clamp(nz*1.5,0,1));
      col.lerp(cAlp,clamp((h-160)/380,0,1)*0.55);
      col.lerp(cArid,smoothstep(0.44,0.60,biome)*clamp(1-slope*1.6,0,1)*0.7);
      col.lerp(cRock,clamp((slope-0.22)*2.0,0,1));
      col.lerp(cRock2,clamp(nz2*0.65,0,0.5));
      const snowLine=900+nz*420;
      const snow=clamp((h-snowLine)/300,0,1)*clamp(1-(slope-0.5)*1.7,0,1);
      col.lerp(cSnow,snow);
      // airbase apron: gravel/plaza
      const dd=Math.hypot(x-WORLD.base.x,z-WORLD.base.z);
      if(dd<380) col.lerp(new THREE.Color(0x7d7468),clamp((380-dd)/320,0,0.8));
    }
    const shade=0.88+nz2*0.24;
    colors[i*3]=col.r*shade; colors[i*3+1]=col.g*shade; colors[i*3+2]=col.b*shade;
  }
  geo.setAttribute('color',new THREE.BufferAttribute(colors,3));
  geo.computeVertexNormals();
  const tex=noiseTexture(256,1.5);
  tex.repeat.set(260,260);
  const mat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.96,metalness:0.0,map:tex});
  terrainMesh=new THREE.Mesh(geo,mat);
  terrainMesh.receiveShadow=true;
  terrainMesh.matrixAutoUpdate=false;
  scene.add(terrainMesh);
}
function buildWater(){
  const g=new THREE.PlaneGeometry(90000,90000,1,1); g.rotateX(-Math.PI/2);
  const m=new THREE.MeshStandardMaterial({color:0x1b4a63,roughness:0.14,metalness:0.55,transparent:true,opacity:0.93});
  waterMesh=new THREE.Mesh(g,m); waterMesh.position.y=0.0; waterMesh.receiveShadow=false;
  waterMesh.matrixAutoUpdate=false; scene.add(waterMesh);
}
function buildClouds(){
  cloudGroup=new THREE.Group();
  const size=256, cv=document.createElement('canvas'); cv.width=cv.height=size;
  const cx=cv.getContext('2d');
  for(let i=0;i<26;i++){
    const px=Math.random()*size, py=Math.random()*size, r=18+Math.random()*46;
    const g=cx.createRadialGradient(px,py,0,px,py,r);
    const a=0.05+Math.random()*0.07;
    g.addColorStop(0,`rgba(255,255,255,${a})`); g.addColorStop(1,'rgba(255,255,255,0)');
    cx.fillStyle=g; cx.beginPath(); cx.arc(px,py,r,0,TAU); cx.fill();
  }
  const tex=new THREE.CanvasTexture(cv); tex.wrapS=tex.wrapT=THREE.RepeatWrapping;
  const m=new THREE.MeshBasicMaterial({map:tex,transparent:true,depthWrite:false,fog:false,opacity:0.85,
    blending:THREE.NormalBlending,side:THREE.DoubleSide});
  for(let i=0;i<70;i++){
    const w=1200+Math.random()*2600;
    const g2=new THREE.PlaneGeometry(w,w*0.7); g2.rotateX(-Math.PI/2);
    const mesh=new THREE.Mesh(g2,m);
    mesh.position.set((Math.random()-0.5)*30000, 900+Math.random()*2400, (Math.random()-0.5)*30000);
    mesh.rotation.y=Math.random()*TAU;
    mesh.renderOrder=2;
    cloudGroup.add(mesh);
  }
  scene.add(cloudGroup);
}
/* ---------------- runway + airbase ---------------- */
let runwayGroup;
function buildAirbase(){
  runwayGroup=new THREE.Group();
  const L=WORLD.runwayLen, W=WORLD.runwayW;
  const cv=document.createElement('canvas'); cv.width=256; cv.height=2048;
  const g=cv.getContext('2d');
  g.fillStyle='#2b2e33'; g.fillRect(0,0,256,2048);
  for(let i=0;i<9000;i++){
    g.fillStyle=`rgba(${120+Math.random()*60},${120+Math.random()*60},${125+Math.random()*60},${Math.random()*0.09})`;
    g.fillRect(Math.random()*256,Math.random()*2048,2,2);
  }
  g.fillStyle='#e8e6e0';
  for(let y=60;y<2048-60;y+=90) g.fillRect(124,y,8,52);
  // threshold bars
  for(let i=0;i<8;i++){ g.fillRect(30+i*26,2020-150,14,110); g.fillRect(30+i*26,130,14,110); }
  g.fillRect(6,0,6,2048); g.fillRect(244,0,6,2048);
  const tex=new THREE.CanvasTexture(cv); tex.anisotropy=8;
  const rmat=new THREE.MeshStandardMaterial({map:tex,roughness:0.85,metalness:0.0});
  const rw=new THREE.Mesh(new THREE.BoxGeometry(W,0.7,L),rmat);
  rw.position.set(WORLD.base.x,WORLD.baseElev+0.05,WORLD.base.z);
  rw.receiveShadow=true; runwayGroup.add(rw);
  // shoulders
  const smat=new THREE.MeshStandardMaterial({color:0x6d6659,roughness:1});
  const shL=new THREE.Mesh(new THREE.BoxGeometry(9,0.4,L),smat);
  shL.position.set(WORLD.base.x-W/2-4.5,WORLD.baseElev-0.12,WORLD.base.z); runwayGroup.add(shL);
  const shR=shL.clone(); shR.position.x=WORLD.base.x+W/2+4.5; runwayGroup.add(shR);
  // taxiway + apron
  const apronMat=new THREE.MeshStandardMaterial({color:0x585349,roughness:0.95});
  const apron=new THREE.Mesh(new THREE.BoxGeometry(300,0.4,220),apronMat);
  apron.position.set(WORLD.base.x+W/2+170,WORLD.baseElev-0.1,WORLD.base.z-260);
  apron.receiveShadow=true; runwayGroup.add(apron);
  // hangar
  const hangarMat=new THREE.MeshStandardMaterial({color:0x8b949c,roughness:0.6,metalness:0.5});
  for(let i=0;i<3;i++){
    const hg=new THREE.Group();
    const body=new THREE.Mesh(new THREE.BoxGeometry(70,20,52),hangarMat);
    body.position.y=10; body.castShadow=true; body.receiveShadow=true; hg.add(body);
    const roof=new THREE.Mesh(new THREE.CylinderGeometry(27,27,70,14,1,false,0,Math.PI),hangarMat);
    roof.rotation.z=Math.PI/2; roof.position.y=20; roof.castShadow=true; hg.add(roof);
    hg.position.set(WORLD.base.x+W/2+150,WORLD.baseElev,WORLD.base.z-380+i*110);
    runwayGroup.add(hg);
  }
  // control tower
  const tw=new THREE.Group();
  const tmat=new THREE.MeshStandardMaterial({color:0xcfd6db,roughness:0.6,metalness:0.2});
  const base=new THREE.Mesh(new THREE.BoxGeometry(16,46,16),tmat); base.position.y=23; base.castShadow=true; tw.add(base);
  const cab=new THREE.Mesh(new THREE.BoxGeometry(26,11,26),new THREE.MeshStandardMaterial({color:0x2b3d4a,roughness:0.15,metalness:0.7}));
  cab.position.y=51; cab.castShadow=true; tw.add(cab);
  const cap=new THREE.Mesh(new THREE.BoxGeometry(30,2.5,30),tmat); cap.position.y=57; tw.add(cap);
  tw.position.set(WORLD.base.x-W/2-190,WORLD.baseElev,WORLD.base.z+180); runwayGroup.add(tw);
  // approach lights
  const lmat=new THREE.MeshStandardMaterial({color:0xffffff,emissive:0xffdd99,emissiveIntensity:1.4,roughness:0.4});
  for(let i=0;i<14;i++){
    const p=new THREE.Mesh(new THREE.BoxGeometry(1.4,0.5,1.4),lmat);
    p.position.set(WORLD.base.x+ (i<7?-6:6), WORLD.baseElev+0.4, WORLD.base.z-L/2-40-i*32);
    runwayGroup.add(p);
  }
  scene.add(runwayGroup);
}

/* ============================================================================
   8. AIRCRAFT MESHES
   ========================================================================== */
function MAT(color,metal,rough,extra){
  return new THREE.MeshStandardMaterial(Object.assign({color:color,metalness:metal===undefined?0.35:metal,
    roughness:rough===undefined?0.45:rough,side:THREE.DoubleSide},extra||{}));
}
function wingGeo(span,rootC,tipC,sweep,thick,dihedral){
  const y1=thick*0.5,y2=-thick*0.5,ty=Math.tan(dihedral||0)*span;
  const P=[
    [0,y1,-rootC*0.5],[span,y1+ty,-tipC*0.5+sweep],[span,y1+ty,tipC*0.5+sweep],[0,y1,rootC*0.5],
    [0,y2,-rootC*0.5],[span,y2+ty,-tipC*0.5+sweep],[span,y2+ty,tipC*0.5+sweep],[0,y2,rootC*0.5]];
  const tri=[0,1,2, 0,2,3,  4,6,5, 4,7,6,  0,1,5, 0,5,4,  3,2,6, 3,6,7,  1,2,6, 1,6,5,  0,4,7, 0,7,3];
  const arr=new Float32Array(tri.length*3);
  for(let i=0;i<tri.length;i++){const p=P[tri[i]];arr[i*3]=p[0];arr[i*3+1]=p[1];arr[i*3+2]=p[2];}
  const g=new THREE.BufferGeometry();
  g.setAttribute('position',new THREE.BufferAttribute(arr,3));
  g.computeVertexNormals();
  return g;
}
function addTo(parent,geo,mat,x,y,z,rx,ry,rz,cast){
  const m=new THREE.Mesh(geo,mat);
  m.position.set(x||0,y||0,z||0);
  m.rotation.set(rx||0,ry||0,rz||0);
  m.castShadow=cast!==false; m.receiveShadow=false;
  parent.add(m); return m;
}
function cylI(rt,rb,h,seg){ const g=new THREE.CylinderGeometry(rt,rb,h,seg||14); return g; }
function buildFalcon(sp){
  const g=new THREE.Group(), anim={elev:[],ail:[],rud:[],gear:[],props:[],ab:[]};
  const body=MAT(sp.hue,0.26,0.44), dark=MAT(0x2a2f38,0.55,0.4), acc=MAT(sp.accent,0.2,0.5), glass=MAT(0x0e1c26,0.1,0.08,{transparent:true,opacity:0.62});
  // fuselage
  addTo(g,cylI(0.52,0.66,4.4,14),body,0,0,0.55,Math.PI/2);
  const nose=addTo(g,new THREE.ConeGeometry(0.66,1.7,14),body,0,0,-2.75,-Math.PI/2);
  addTo(g,cylI(0.28,0.5,2.6,12),body,0,0.05,3.7,Math.PI/2);
  // spinner + prop
  addTo(g,cylI(0.16,0.3,0.5,10),dark,0,0,-3.72,Math.PI/2);
  const prop=new THREE.Group(); prop.position.set(0,0,-3.95); g.add(prop);
  for(let i=0;i<3;i++){
    const bl=addTo(prop,new THREE.BoxGeometry(0.14,1.55,0.05),dark,0,0,0,0,0,0);
    bl.position.y=0; bl.geometry.translate(0,0.85,0);
    bl.rotation.z=i*TAU/3;
  }
  anim.props.push(prop);
  // canopy
  const cg=new THREE.SphereGeometry(0.62,14,10); cg.scale(1,0.85,2.5);
  addTo(g,cg,glass,0,0.42,-0.75);
  // wings (low)
  const wg=wingGeo(5.2,1.85,1.15,0.18,0.2,0.06);
  const wr=addTo(g,wg,body,0,-0.28,-0.35); wr.geometry=wg;
  const wl=addTo(g,wg,body,0,-0.28,-0.35); wl.scale.x=-1;
  // fuselage cheat-line stripes
  addTo(g,new THREE.BoxGeometry(0.05,0.12,4.6),acc,0.56,0.12,0.5);
  addTo(g,new THREE.BoxGeometry(0.05,0.12,4.6),acc,-0.56,0.12,0.5);
  // ailerons (hinged on outer wing panels)
  [[3.0,1],[-3.0,-1]].forEach(([x,dir])=>{
    const a=addTo(g,wingGeo(1.5,0.55,0.45,0.03,0.10,0),acc,x,-0.30,0.98);
    a.scale.x=dir; anim.ail.push({m:a,dir:dir});
  });
  // tail
  const hg=wingGeo(1.85,1.0,0.7,0.12,0.14,0.02);
  addTo(g,hg,body,0,0.12,3.5); const hl=addTo(g,hg,body,0,0.12,3.5); hl.scale.x=-1;
  const e1=addTo(g,wingGeo(0.95,0.5,0.45,0.02,0.09,0),acc,0.9,0.12,4.05); anim.elev.push({m:e1,dir:1});
  const e2=addTo(g,wingGeo(0.95,0.5,0.45,0.02,0.09,0),acc,-0.9,0.12,4.05); e2.scale.x=-1; anim.elev.push({m:e2,dir:-1});
  const finG=wingGeo(1.75,1.3,0.75,0.5,0.13,0);
  const fin=addTo(g,finG,body,0,0.38,3.9,0,0,Math.PI/2); fin.position.set(0,0.38,3.9);
  const rud=addTo(g,wingGeo(1.5,0.62,0.6,0.05,0.1,0),acc,0,0.5,4.72,0,0,Math.PI/2);
  anim.rud.push({m:rud,dir:1});
  // gear
  const gm=MAT(0x1b1f25,0.8,0.35);
  function leg(x,z,h,len){
    const gg=new THREE.Group(); gg.position.set(x,0,z);
    addTo(gg,cylI(0.07,0.07,h,8),gm,0,-h/2,0);
    const wh=addTo(gg,cylI(0.28,0.28,0.18,12),MAT(0x111418,0.2,0.8),0,-h,0,0,0,Math.PI/2);
    g.add(gg); return gg;
  }
  anim.gear.push(leg(1.55,-0.5,1.5),leg(-1.55,-0.5,1.5),leg(0,-3.0,1.45));
  g.userData.cockpit=new THREE.Vector3(0,0.62,-0.6);
  g.userData.span=sp.b; g.userData.anim=anim;
  return {group:g,anim};
}
function buildVanguard(sp){
  const g=new THREE.Group(), anim={elev:[],ail:[],rud:[],gear:[],props:[],ab:[]};
  const body=MAT(0x79828c,0.62,0.36), body2=MAT(0x5f6873,0.6,0.4), dark=MAT(0x272c33,0.7,0.35),
        acc=MAT(sp.accent,0.4,0.35,{emissive:new THREE.Color(sp.accent),emissiveIntensity:0.12}),
        glass=MAT(0x0c1a24,0.15,0.06,{transparent:true,opacity:0.5});
  // fuselage
  addTo(g,cylI(1.05,1.35,6.0,16),body,0,0,1.2,Math.PI/2);
  addTo(g,cylI(0.62,1.05,4.0,16),body,0,0,-2.8,Math.PI/2);
  const nc=addTo(g,new THREE.ConeGeometry(0.62,4.4,16),body2,0,0,-7.0,-Math.PI/2);
  addTo(g,cylI(1.0,1.35,4.4,16),body2,0,0,4.5,Math.PI/2);
  // chines / LERX
  addTo(g,new THREE.BoxGeometry(2.6,0.16,7.0),body2,1.35,-0.42,-2.2,0.06);
  addTo(g,new THREE.BoxGeometry(2.6,0.16,7.0),body2,-1.35,-0.42,-2.2,0.06);
  // canopy
  const cg=new THREE.SphereGeometry(0.95,16,10); cg.scale(0.85,0.62,3.3);
  addTo(g,cg,glass,0,0.78,-2.3);
  // intakes
  addTo(g,new THREE.BoxGeometry(1.5,1.5,3.8),dark,1.45,-0.35,-0.4);
  addTo(g,new THREE.BoxGeometry(1.5,1.5,3.8),dark,-1.45,-0.35,-0.4);
  // delta wing
  const wg=wingGeo(5.6,6.6,1.1,3.3,0.3,0.02);
  const wr=addTo(g,wg,body,1.1,-0.35,0.9);
  const wl=addTo(g,wg,body,1.1,-0.35,0.9); wl.scale.x=-1; wl.position.x=-1.1;
  addTo(g,new THREE.BoxGeometry(0.5,0.1,3.4),acc,3.6,-0.26,2.6,0,0.6);
  addTo(g,new THREE.BoxGeometry(0.5,0.1,3.4),acc,-3.6,-0.26,2.6,0,-0.6);
  // flaperons
  [[2.6],[-2.6]].forEach(([x])=>{
    const a=addTo(g,wingGeo(2.4,1.05,0.8,0.9,0.14,0),acc,Math.abs(x)*0.85+(x>0?0:0),-0.45,4.0);
    a.position.x=x>0?2.9:-2.9; a.scale.x=x>0?1:-1; anim.ail.push({m:a,dir:x>0?1:-1});
  });
  // twin fins
  [[2.0,-0.18],[-2.0,0.18]].forEach(([x,rz])=>{
    const f=addTo(g,wingGeo(3.1,3.4,1.6,0.9,0.18,0),body2,x,0.7,4.4,0,0,(Math.PI/2)+rz);
    f.position.set(x,0.9,4.4);
  });
  const rud=addTo(g,wingGeo(2.4,1.0,0.9,0.1,0.14,0),acc,0,0.0,0,0,0,0);
  g.add(rud); rud.position.set(0,1.6,6.3); rud.scale.y=0; // clean single rudder at centerline (visual only)
  rud.visible=false;
  // horizontal stabs
  const hs=wingGeo(2.5,2.2,1.0,1.1,0.18,0.06);
  addTo(g,hs,body2,0.9,0.1,5.4); const h2=addTo(g,hs,body2,-0.9,0.1,5.4); h2.scale.x=-1;
  const el1=addTo(g,wingGeo(2.2,0.7,0.6,0.1,0.12,0),acc,1.9,0.1,6.6); anim.elev.push({m:el1,dir:1});
  const el2=addTo(g,wingGeo(2.2,0.7,0.6,0.1,0.12,0),acc,1.9,0.1,6.6); el2.scale.x=-1; el2.position.x=-1.9;
  anim.elev.push({m:el2,dir:-1});
  // nozzles
  [1.0,-1.0].forEach(x=>{
    addTo(g,cylI(0.62,0.72,1.5,16),dark,x,0.0,7.1,Math.PI/2);
    const flame=new THREE.Mesh(new THREE.ConeGeometry(0.6,3.2,14,1,true),
      new THREE.MeshBasicMaterial({color:0x8fd8ff,transparent:true,opacity:0.85,blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.DoubleSide}));
    flame.rotation.x=Math.PI/2; flame.position.set(x,0,8.9); flame.scale.setScalar(0.001);
    g.add(flame); anim.ab.push(flame);
  });
  // gear
  const gm=MAT(0x22262c,0.75,0.35);
  function leg(x,z,h){
    const gg=new THREE.Group(); gg.position.set(x,0.2,z);
    addTo(gg,cylI(0.14,0.16,h,10),gm,0,-h/2,0);
    addTo(gg,cylI(0.42,0.42,0.26,14),MAT(0x121418,0.2,0.85),0,-h,0,0,0,Math.PI/2);
    g.add(gg); return gg;
  }
  anim.gear.push(leg(1.9,-0.6,2.15),leg(-1.9,-0.6,2.15),leg(0,-4.4,2.05));
  g.userData.cockpit=new THREE.Vector3(0,0.95,-2.1);
  g.userData.span=sp.b; g.userData.anim=anim;
  return {group:g,anim};
}
function buildTitan(sp){
  const g=new THREE.Group(), anim={elev:[],ail:[],rud:[],gear:[],props:[],ab:[]};
  const body=MAT(0x8d9aa5,0.45,0.45), dark=MAT(0x2b3138,0.6,0.45), acc=MAT(0xffb648,0.25,0.5),
        glass=MAT(0x0c1a24,0.1,0.08,{transparent:true,opacity:0.55});
  // fuselage
  addTo(g,cylI(3.0,3.2,22,20),body,0,0,3,Math.PI/2);
  addTo(g,new THREE.ConeGeometry(3.0,7,20),body,0,0,-12.5,-Math.PI/2);
  addTo(g,cylI(1.5,3.0,9,18),body,0,0.6,18.5,Math.PI/2);
  // cockpit glazing
  addTo(g,new THREE.BoxGeometry(2.2,1.3,3.6),glass,0,1.7,-16.4);
  // high wing
  const wg=wingGeo(20.2,6.2,3.8,1.4,0.75,0.035);
  addTo(g,wg,body,0,2.6,-1.2); const wl=addTo(g,wg,body,0,2.6,-1.2); wl.scale.x=-1;
  addTo(g,new THREE.BoxGeometry(40,0.4,0.6),acc,0,2.5,2.4);
  // 4 nacelles + props
  [-14.5,-7.2,7.2,14.5].forEach(x=>{
    const n=new THREE.Group(); n.position.set(x,2.25,-2.2); g.add(n);
    addTo(n,cylI(1.05,1.25,6.0,14),dark,0,0,0,Math.PI/2);
    addTo(n,cylI(0.6,1.05,1.4,14),dark,0,0,-3.6,Math.PI/2);
    const prop=new THREE.Group(); prop.position.set(0,0,-4.4); n.add(prop);
    for(let i=0;i<4;i++){
      const bl=addTo(prop,new THREE.BoxGeometry(0.4,2.4,0.09),MAT(0x1b1f25,0.3,0.6),0,1.2,0,0,0,i*TAU/4);
      bl.geometry=bl.geometry.clone(); bl.geometry.translate(0,1.2,0); bl.position.y=0; bl.rotation.z=i*TAU/4;
    }
    anim.props.push(prop);
  });
  // ailerons
  [[5.6],[-5.6]].forEach(x=>{
    const a=addTo(g,wingGeo(4.5,2.4,1.8,0.3,0.3,0),acc,x*1.0,2.55,4.8);
    a.position.x=x[0]>0?16.0:-16.0; a.scale.x=x[0]>0?1:-1; anim.ail.push({m:a,dir:x[0]>0?1:-1});
  });
  // T-tail
  const fin=addTo(g,wingGeo(7.4,5.6,3.2,2.6,0.5,0),body,0,2.5,26.5,0,0,Math.PI/2);
  fin.position.set(0,2.6,24.5);
  const rud=addTo(g,wingGeo(5.4,2.6,2.6,0.2,0.45,0),acc,0,0,0,0,0,Math.PI/2);
  rud.position.set(0,10.4,30.9); anim.rud.push({m:rud,dir:1});
  const hs=wingGeo(8.6,4.4,2.4,1.6,0.4,0.02);
  addTo(g,hs,body,0,10.2,28.6); const h2=addTo(g,hs,body,0,10.2,28.6); h2.scale.x=-1;
  const e1=addTo(g,wingGeo(4.0,1.4,1.0,0.1,0.3,0),acc,4.4,10.2,30.7); anim.elev.push({m:e1,dir:1});
  const e2=addTo(g,wingGeo(4.0,1.4,1.0,0.1,0.3,0),acc,-4.4,10.2,30.7); e2.scale.x=-1; anim.elev.push({m:e2,dir:-1});
  // gear
  const gm=MAT(0x23282e,0.7,0.4);
  function leg(x,z,h,twin){
    const gg=new THREE.Group(); gg.position.set(x,0,z);
    addTo(gg,cylI(0.24,0.28,h,10),gm,0,-h/2,0);
    if(twin){ addTo(gg,cylI(0.85,0.85,0.4,14),MAT(0x14171b,0.2,0.85),-0.55,-h,0,0,0,Math.PI/2);
              addTo(gg,cylI(0.85,0.85,0.4,14),MAT(0x14171b,0.2,0.85),0.55,-h,0,0,0,Math.PI/2);
              addTo(gg,new THREE.BoxGeometry(3.2,0.5,0.5),gm,0,-h+0.5,0); }
    else addTo(gg,cylI(0.7,0.7,0.4,14),MAT(0x14171b,0.2,0.85),0,-h,0,0,0,Math.PI/2);
    g.add(gg); return gg;
  }
  anim.gear.push(leg(5.4,2.0,3.3,true),leg(-5.4,2.0,3.3,true),leg(0,-11.0,3.1,false));
  g.userData.cockpit=new THREE.Vector3(0,1.85,-15.0);
  g.userData.span=sp.b; g.userData.anim=anim;
  return {group:g,anim};
}
const BUILDERS={falcon:buildFalcon,vanguard:buildVanguard,titan:buildTitan};
function buildAircraft(spec,isEnemy){
  const b=BUILDERS[spec.id](spec);
  const g=b.group;
  if(isEnemy){
    g.traverse(o=>{ if(o.isMesh&&o.material&&o.material.color){
      o.material=o.material.clone(); o.material.color.lerp(new THREE.Color(0x6b1f2a),0.55); }});
  }
  scene.add(g);
  return b;
}
function syncMesh(mesh,ac){
  mesh.position.set(ac.pos.x,ac.pos.y,ac.pos.z);
  mesh.quaternion.set(ac.q.x,ac.q.y,ac.q.z,ac.q.w);
  const anim=mesh.userData.anim;
  if(!anim) return;
  const sp=ac.spec;
  const t=performance.now()*0.001;
  anim.props.forEach((p,i)=>{ p.rotation.z += (ac.rpm/sp.rpmHi)*0.55+0.06; });
  anim.ail.forEach(o=>{ o.m.rotation.x = -ac.act.rl*0.42*o.dir; });
  anim.elev.forEach(o=>{ o.m.rotation.x = ac.act.pt*0.38*o.dir; });
  anim.rud.forEach(o=>{ o.m.rotation.y = -ac.act.yw*0.42; });
  const gearT=ac.gearAnim;
  anim.gear.forEach((lg,i)=>{ lg.rotation.x = (1-gearT)*(sp.id==='falcon'?1.5:(sp.id==='vanguard'?1.65:1.4)); });
  anim.ab.forEach((f,i)=>{
    const on=(ac.ctrl.ab>0.5&&ac.rpm>sp.rpmLo+(sp.rpmHi-sp.rpmLo)*0.85)?1:0;
    const s=on*(0.75+Math.random()*0.35);
    f.scale.set(s||0.001,s||0.001,s||0.001);
    f.visible=s>0.01;
  });
}

/* ============================================================================
   9. FX (particles, contrails, explosions)
   ========================================================================== */
const FX={
  N:1400, i:0, pts:null, pos:null, col:null, vel:null, life:null, max:null, size:null, tex:null,
  init(){
    const geo=new THREE.BufferGeometry();
    this.pos=new Float32Array(this.N*3); this.col=new Float32Array(this.N*3);
    this.vel=new Float32Array(this.N*3); this.life=new Float32Array(this.N);
    this.max=new Float32Array(this.N); this.grav=new Float32Array(this.N);
    this.drag=new Float32Array(this.N); this.bright=new Float32Array(this.N);
    for(let i=0;i<this.N;i++){ this.pos[i*3+1]=-9999; }
    geo.setAttribute('position',new THREE.BufferAttribute(this.pos,3));
    geo.setAttribute('color',new THREE.BufferAttribute(this.col,3));
    const cv=document.createElement('canvas'); cv.width=cv.height=64;
    const c=cv.getContext('2d'); const g=c.createRadialGradient(32,32,0,32,32,32);
    g.addColorStop(0,'rgba(255,255,255,1)'); g.addColorStop(0.35,'rgba(255,255,255,0.42)');
    g.addColorStop(1,'rgba(255,255,255,0)');
    c.fillStyle=g; c.fillRect(0,0,64,64);
    this.tex=new THREE.CanvasTexture(cv);
    const m=new THREE.PointsMaterial({size:14,map:this.tex,vertexColors:true,transparent:true,
      depthWrite:false,blending:THREE.AdditiveBlending,sizeAttenuation:true,opacity:0.95});
    this.pts=new THREE.Points(geo,m); this.pts.frustumCulled=false; scene.add(this.pts);
  },
  spawn(x,y,z,vx,vy,vz,r,g,b,life,size,grav,drag){
    const i=this.i=(this.i+1)%this.N;
    this.pos[i*3]=x;this.pos[i*3+1]=y;this.pos[i*3+2]=z;
    this.vel[i*3]=vx;this.vel[i*3+1]=vy;this.vel[i*3+2]=vz;
    this.col[i*3]=r;this.col[i*3+1]=g;this.col[i*3+2]=b;
    this.life[i]=life; this.max[i]=life; this.grav[i]=grav; this.drag[i]=drag;
    this.bright[i]=Math.max(r,g,b);
  },
  smoke(p,vel,hot){
    const jx=(Math.random()-0.5)*1.6,jy=(Math.random()-0.5)*1.6,jz=(Math.random()-0.5)*1.6;
    const v=vmul(vel,0.18);
    if(hot) this.spawn(p.x,p.y,p.z,v.x+jx,v.y+jy+2,v.z+jz,1.5,0.75,0.22,0.7,0.5,-1.0,1.1);
    else this.spawn(p.x,p.y,p.z,v.x+jx,v.y+jy+1.6,v.z+jz,0.42,0.42,0.44,2.6,1.4,0.6,0.55);
  },
  explode(p,scale){
    const s=scale||1;
    for(let i=0;i<52;i++){
      const d=vnrm(V3(Math.random()*2-1,Math.random()*2-1,Math.random()*2-1));
      const sp=(8+Math.random()*34)*s;
      this.spawn(p.x,p.y,p.z,d.x*sp,d.y*sp,d.z*sp,2.4,1.1+Math.random()*0.8,0.25,
                 0.5+Math.random()*0.6,(2.4+Math.random()*2)*s,2.5,1.6);
    }
    for(let i=0;i<30;i++){
      const d=vnrm(V3(Math.random()*2-1,Math.random()*2-1,Math.random()*2-1));
      const sp=(3+Math.random()*12)*s;
      this.spawn(p.x,p.y,p.z,d.x*sp,d.y*sp+4,d.z*sp,0.35,0.33,0.32,2.4,3.2,0.8,0.7);
    }
  },
  update(dt){
    if(!this.pts) return;
    const P=this.pos,V=this.vel,L=this.life,M=this.max,C=this.col;
    for(let i=0;i<this.N;i++){
      if(L[i]<=0) continue;
      L[i]-=dt;
      if(L[i]<=0){ P[i*3+1]=-9999; C[i*3]=C[i*3+1]=C[i*3+2]=0; continue; }
      const dg=Math.max(0,1-this.drag[i]*dt);
      V[i*3]*=dg; V[i*3+1]*=dg; V[i*3+2]*=dg;
      V[i*3+1]-=this.grav[i]*dt;
      P[i*3]+=V[i*3]*dt; P[i*3+1]+=V[i*3+1]*dt; P[i*3+2]+=V[i*3+2]*dt;
      const f=L[i]/M[i];
      const idx=i*3;
      const fade=Math.pow(f,1.4);
      C[idx]=C[idx]*fade; C[idx+1]=C[idx+1]*fade; C[idx+2]=C[idx+2]*fade;
    }
    this.pts.geometry.attributes.position.needsUpdate=true;
    this.pts.geometry.attributes.color.needsUpdate=true;
  }
};

/* ============================================================================
   10. GAME STATE
   ========================================================================== */
const G={
  state:'menu', scenario:'free', spec:FLEET[0], player:null, pMesh:null,
  enemies:[], enemyMeshes:[], aiPilots:[], weapons:new Weapons(),
  t:0, kills:0, shots:0, hits:0, deaths:0,
  waypoints:[], wpIndex:0, phase:'', phaseT:0,
  score:0, touchdownVS:0, touchdownOff:0, msg2:'', msgT:0,
  tracers:[],
  followQ:qID()
};
function toast(text,kind,ms){
  const d=document.createElement('div'); d.className='toast'+(kind?' '+kind:''); d.textContent=text;
  const T=document.getElementById('toast'); T.appendChild(d);
  setTimeout(()=>{ d.style.transition='opacity .3s'; d.style.opacity='0'; setTimeout(()=>d.remove(),320); },ms||2200);
}
function centerMsg(text,ms){ const m=document.getElementById('msgBox'); m.textContent=text; m.classList.add('show');
  clearTimeout(G._mt); G._mt=setTimeout(()=>m.classList.remove('show'),ms||2400); }

function startGame(spec,scenario){
  G.spec=spec; G.scenario=scenario; G.t=0; G.kills=0; G.shots=0; G.hits=0; G.score=0;
  G.weapons.list.length=0; G.weapons.tracers.length=0;
  // clear enemies
  G.enemyMeshes.forEach(m=>scene.remove(m));
  G.enemies=[]; G.enemyMeshes=[]; G.aiPilots=[];
  if(!G.player){ G.player=new Aircraft(spec); G.player.uid='player'; }
  if(G.pMesh) scene.remove(G.pMesh);
  const b=buildAircraft(spec,false); G.pMesh=b.group; G.pMesh.userData.anim=b.anim;
  // place
  const bx=WORLD.base.x, bz=WORLD.base.z;
  if(scenario==='takeoff'){
    G.player.spec=spec;
    G.player.reset(bx, bz+WORLD.runwayLen*0.38, 0, false, 0);
    G.player.ctrl.throttle=0; G.player.ctrl.gear=1; G.player.gearAnim=1; G.player.ctrl.flaps=0.55;
    G.phase='TAKEOFF'; G.phaseT=0;
    G.waypoints=makeWaypoints();
    G.wpIndex=0;
    centerMsg('CLEARED FOR TAKEOFF',2600);
  } else if(scenario==='dogfight'){
    G.player.spec=spec;
    G.player.reset(bx+900, bz-3000, Math.atan2(-1,0.4), true, 900+Math.random()*600);
    G.player.ctrl.throttle=0.95; G.player.ctrl.gear=0; G.player.gearAnim=0; G.player.ctrl.flaps=0;
    G.phase='ENGAGE'; G.waypoints=[];
    const n= spec.mass>50000?2:3;
    for(let i=0;i<n;i++){
      const foe=new Aircraft(FLEET[1]);
      foe.uid='foe'+i; foe.team='red'; foe.health=1;
      foe.reset(bx+rnd(-1800,1800), bz+rnd(-4200,-2600), Math.atan2(1,-0.3), true, 1100+Math.random()*900);
      foe.ctrl.throttle=0.9; foe.ctrl.gear=0; foe.gearAnim=0;
      const mb=buildAircraft(FLEET[1],true);
      G.enemies.push(foe); G.enemyMeshes.push(mb.group); G.enemyMeshes[G.enemyMeshes.length-1].userData.anim=mb.anim;
      G.aiPilots.push(new AIPilot(foe,0.55+i*0.12));
    }
    centerMsg('ENGAGE HOSTILES',2200);
  } else {
    G.player.spec=spec;
    G.player.reset(bx+200, bz+WORLD.runwayLen*0.6, 0, true, 700+Math.random()*400);
    G.player.ctrl.throttle=0.85; G.player.ctrl.gear=0; G.player.gearAnim=0;
    G.phase='FREE'; G.waypoints=[];
    centerMsg('FREE FLIGHT — ALT '+(G.player.pos.y|0)+' m',2000);
  }
  G.player.crashed=false; G.player.gunCool=0; G.player.weaponAmmo=99999;
  G.player.hitFlash=0; G.player.health=1;
  G._wpMeshes=(G._wpMeshes||[]).map(m=>{scene.remove(m);return null;});
  G._endPending=false; G._wasAir=false; G.maxAlt=0; G.maxSpd=0;
  G._wpMeshes=G.waypoints.map(w=>{
    const t=new THREE.Mesh(new THREE.TorusGeometry(85,3.5,8,32),
      new THREE.MeshBasicMaterial({color:0x53e0ff,transparent:true,opacity:0.75}));
    t.position.set(w.x,w.y,w.z); t.lookAt(new THREE.Vector3(bx,w.y,bz));
    scene.add(t); return t;
  });
  document.getElementById('menu').classList.add('hidden');
  document.getElementById('debrief').classList.add('hidden');
  document.getElementById('pause').classList.add('hidden');
  G.state='flying';
  Aero.init(); Aero.setMaster(Aero.on?0.9:0);
  if(Aero.ctx&&Aero.ctx.state==='suspended') Aero.ctx.resume();
  last=performance.now();
}
function makeWaypoints(){
  const bx=WORLD.base.x,bz=WORLD.base.z;
  return [
    {x:bx+4200,y:WORLD.baseElev+900,z:bz-2600,name:'WP1'},
    {x:bx-3200,y:WORLD.baseElev+1450,z:bz-4600,name:'WP2'},
    {x:bx-3800,y:WORLD.baseElev+700,z:bz+2600,name:'WP3'}
  ];
}
function endScenario(){
  if(G.state!=='flying') return;
  G.state='debrief';
  const d=document.getElementById('debrief');
  const rows=document.getElementById('dbRows');
  const p=G.player;
  let html='',title='',verdict='';
  if(G.scenario==='takeoff'){
    title='PRECISION FLIGHT DEBRIEF'; verdict = 'LANDED';
    const off=G.touchdownOff, vs=G.touchdownVS;
    let score=100;
    score-= clamp(off*1.2,0,40);
    score-= clamp(Math.abs(vs-0.8)*3.2,0,35);
    score+= G.wpIndex>=G.waypoints.length?15:-20;
    G.score=Math.max(0,Math.round(score));
    html=row('Waypoints',''+G.wpIndex+' / '+G.waypoints.length)
      +row('Touchdown vertical speed',vs.toFixed(2)+' m/s ('+(vs*196.85).toFixed(0)+' fpm)')
      +row('Centerline deviation',off.toFixed(1)+' m')
      +row('Landing roll','on runway')
      +row('Score',G.score+' / 100');
    verdict = G.score>85?'PERFECT LANDING':(G.score>65?'ACCEPTABLE':(G.score>40?'ROUGH':'CRASH LANDING'));
  } else if(G.scenario==='dogfight'){
    title='COMBAT DEBRIEF';
    const acc=G.shots?Math.round(G.hits/G.shots*100):0;
    G.score=Math.round(G.kills*100+acc*0.6);
    html=row('Hostiles destroyed',G.kills+' / '+G.enemies.length)
      +row('Rounds fired',G.shots)
      +row('Rounds on target',G.hits)
      +row('Accuracy',acc+'%')
      +row('Airframe',(p.health*100).toFixed(0)+'%')
      +row('Score',G.score+'');
    verdict = p.alive? 'MISSION SUCCESS':'AIRCRAFT LOST';
  } else {
    title='FLIGHT DEBRIEF'; verdict=p.alive?'FLIGHT COMPLETE':'AIRCRAFT LOST';
    html=row('Duration',G.t.toFixed(0)+' s')+row('Max altitude',(G.maxAlt||0).toFixed(0)+' m')
      +row('Max speed',((G.maxSpd||0)*1.94384).toFixed(0)+' kt');
  }
  if(p.crashed) verdict=p.crashReason||'CRASHED';
  document.getElementById('dbTitle').textContent=title;
  document.getElementById('dbVerdict').textContent=verdict;
  rows.innerHTML=html;
  d.classList.remove('hidden');
  Aero.stallTone(false);
  Aero.setMaster(Aero.on?0.35:0);
}
function row(a,b){ return '<div class="row"><span>'+a+'</span><b>'+b+'</b></div>'; }

/* ============================================================================
   11. INPUT
   ========================================================================== */
const KEY={};
const Input={
  stickX:0,stickY:0,throttle:0,mouseLock:false,useMouse:false,
  init(){
    addEventListener('keydown',e=>{
      if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space','Tab',' '].includes(e.key)) e.preventDefault();
      KEY[e.code]=true;
      if(e.code==='Escape'){ if(G.state==='flying') pauseGame(); else if(G.state==='paused') resumeGame(); }
      if(G.state!=='flying') return;
      if(e.code==='KeyC'){ G.camMode=((G.camMode||0)+1)%4; toast('CAMERA: '+['CHASE','COCKPIT','ORBIT','TRACK'][G.camMode]); }
      if(e.code==='KeyG'){ G.player.gearUp=!G.player.gearUp; toast(G.player.gearUp?'GEAR UP':'GEAR DOWN',null,1200); }
      if(e.code==='KeyF'){ G.player.ctrl.flaps = G.player.ctrl.flaps>0.05?0:(G.player.spec.id==='titan'?0.8:0.6); toast('FLAPS '+Math.round(G.player.ctrl.flaps*100)+'%',null,1200); }
      if(e.code==='KeyR'){ startGame(G.spec,G.scenario); }
      if(e.code==='KeyT'){ G.player.ctrl.pitch=G.player.ctrl.roll=G.player.ctrl.yaw=0; }
      if(e.code==='KeyM'){ toggleSound(); }
    });
    addEventListener('keyup',e=>{ KEY[e.code]=false; });
    addEventListener('blur',()=>{ for(const k in KEY) KEY[k]=false; });
    const cv=document.getElementById('scene');
    cv.addEventListener('mousedown',()=>{ if(G.state==='flying'){ cv.requestPointerLock&&cv.requestPointerLock(); if(KEY['ShiftLeft']){} } });
    cv.addEventListener('mousemove',e=>{
      if(document.pointerLockElement===cv&&G.state==='flying'){
        this.stickX=clamp(this.stickX+e.movementX*0.0022,-1,1);
        this.stickY=clamp(this.stickY-e.movementY*0.0026,-1,1);
      }
    });
    addEventListener('wheel',e=>{
      if(G.state==='flying'){ G.player.ctrl.throttle=clamp(G.player.ctrl.throttle-Math.sign(e.deltaY)*0.06,0,1); }
    },{passive:true});
    addEventListener('resize',()=>{
      if(!renderer) return;
      renderer.setSize(innerWidth,innerHeight); camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
      const h=document.getElementById('hud'); h.width=innerWidth*Math.min(devicePixelRatio,2); h.height=innerHeight*Math.min(devicePixelRatio,2);
      h.style.width=innerWidth+'px'; h.style.height=innerHeight+'px';
    });
  },
  read(dt){
    const ac=G.player, c=ac.ctrl;
    // keyboard axes
    let kp=(KEY['KeyS']||KEY['ArrowDown']?1:0)-(KEY['KeyW']||KEY['ArrowUp']?1:0); // +1 = nose down
    let kr=(KEY['KeyD']||KEY['ArrowRight']?1:0)-(KEY['KeyA']||KEY['ArrowLeft']?1:0); // +1 = roll right
    let ky=(KEY['KeyE']?1:0)-(KEY['KeyQ']?1:0); // +1 = yaw right
    if(kp===0&&kr===0){
      kp=-this.stickY; kr=this.stickX;
      this.stickX*=Math.pow(0.985,dt*60); this.stickY*=Math.pow(0.985,dt*60);
    }
    const target={pitch:clamp(-kp,-1,1),roll:clamp(kr,-1,1),yaw:clamp(-ky,-1,1)};
    const rate=3.4; // stick travel speed (1/s)
    c.pitch += clamp(target.pitch-c.pitch,-rate*dt,rate*dt);
    c.roll  += clamp(target.roll -c.roll ,-rate*dt*1.4,rate*dt*1.4);
    c.yaw   += clamp(target.yaw  -c.yaw  ,-rate*dt*0.8,rate*dt*0.8);
    // throttle
    if(KEY['ShiftLeft']||KEY['ShiftRight']) c.throttle=clamp(c.throttle+dt*0.5,0,1);
    if(KEY['ControlLeft']||KEY['ControlRight']) c.throttle=clamp(c.throttle-dt*0.5,0,1);
    c.ab = KEY['Tab']?1:0;
    c.airbrake = KEY['KeyX']?1:0;
    c.brakes = KEY['KeyB']?1:0;
    c.fire = !!KEY['Space'];
    // gear animation
    const tgt = ac.gearUp?0:1;
    ac.gearAnim += clamp(tgt-ac.gearAnim,-dt*0.22,dt*0.22);
  }
};

/* ============================================================================
   12. CAMERA
   ========================================================================== */
const Cam={
  pos:new THREE.Vector3(), look:new THREE.Vector3(), q:new THREE.Quaternion(),
  orbitA:0, orbitE:0.25, inited:false, shake:0, followQ:{x:0,y:0,z:0,w:1},
  update(dt){
    const ac=G.player, sp=ac.spec;
    if(!this.inited){ this.q.set(ac.q.x,ac.q.y,ac.q.z,ac.q.w); this.inited=true; }
    const mode=G.camMode||0;
    const qT=new THREE.Quaternion(ac.q.x,ac.q.y,ac.q.z,ac.q.w);
    if(mode===1){ // cockpit
      const cp=(G.pMesh&&G.pMesh.userData.cockpit)||{y:0.8,z:-1};
      const off=qrot(ac.q,V3(0,cp.y,cp.z));
      this.pos.set(ac.pos.x+off.x,ac.pos.y+off.y,ac.pos.z+off.z);
      this.q.copy(qT);
    } else if(mode===2){ // orbit
      this.orbitA+=dt*0.18;
      const d=sp.camDist*1.5;
      this.pos.set(ac.pos.x+Math.cos(this.orbitA)*d, ac.pos.y+sp.camUp+(mode===2?4:0), ac.pos.z+Math.sin(this.orbitA)*d);
      this.q.copy(qT);
    } else if(mode===3){ // track (side)
      this.followQ.slerp? null:null;
      const tq=new THREE.Quaternion(this.followQ.x,this.followQ.y,this.followQ.z,this.followQ.w);
      this.followQ=qslerp(this.followQ,{x:ac.q.x,y:ac.q.y,z:ac.q.z,w:ac.q.w},clamp(dt*1.2,0,1));
      const fq=this.followQ;
      const off=qrot(fq,V3(sp.camDist*1.35,sp.camUp*0.6,sp.camDist*0.9));
      this.pos.set(ac.pos.x+off.x,ac.pos.y+off.y,ac.pos.z+off.z);
      this.q.copy(new THREE.Quaternion(fq.x,fq.y,fq.z,fq.w));
    } else { // chase
      this.followQ=qslerp(this.followQ,{x:ac.q.x,y:ac.q.y,z:ac.q.z,w:ac.q.w},clamp(dt*2.6,0,1));
      const fq=this.followQ;
      const d=sp.camDist*1.8*(1+clamp(ac.V/(sp.vMax*1.4),0,0.35));
      const off=qrot(fq,V3(0,sp.camUp*1.7,d));
      const want=new THREE.Vector3(ac.pos.x+off.x,ac.pos.y+off.y,ac.pos.z+off.z);
      const k=clamp(dt*6.5,0,1);
      this.pos.lerp(want,k);
      this.q.copy(new THREE.Quaternion(fq.x,fq.y,fq.z,fq.w));
    }
    // look target
    const fwd=qrot(ac.q,V3(0,0,-1));
    const lookP=new THREE.Vector3(ac.pos.x+fwd.x*sp.len*2.2,ac.pos.y+fwd.y*sp.len*2.2,ac.pos.z+fwd.z*sp.len*2.2);
    this.look.lerp(lookP,clamp(dt*8,0,1));
    if(mode===2){ this.look.lerp(new THREE.Vector3(ac.pos.x,ac.pos.y,ac.pos.z),clamp(dt*10,0,1)); }
    // shake
    const sh=Math.min(1,ac.stallAmt*1.2+Math.abs(ac.gLoad)*0.045+(ac.ctrl.fire&&ac.weaponAmmo>0?0.28:0)
                     +clamp((ac.impact||0)*0.16,0,0.7));
    this.shake += (sh-this.shake)*clamp(dt*3,0,1);
    // apply
    let px=this.pos.x,py=this.pos.y,pz=this.pos.z;
    if(this.shake>0.001){
      const t=performance.now()*0.001, s=this.shake*(mode===1?0.30:0.55);
      px+=(Math.sin(t*57)*0.5+Math.sin(t*93)*0.5)*s;
      py+=(Math.sin(t*71+1.3)*0.5+Math.sin(t*37)*0.5)*s;
      pz+=(Math.sin(t*83+2.1)*0.5+Math.sin(t*47)*0.5)*s;
    }
    camera.position.set(px,py,pz);
    camera.up.set(0,1,0);
    if(mode===1||mode===3||mode===0){ camera.quaternion.copy(this.q); }
    else camera.lookAt(this.look);
    if(mode===0||mode===3){ camera.lookAt(this.look); }
    Aero.camDistTo=camera.position.distanceTo(new THREE.Vector3(ac.pos.x,ac.pos.y,ac.pos.z));
  }
};

/* ============================================================================
   13. HUD
   ========================================================================== */
const HUD={cv:null,ctx:null,dpr:1,
  init(){
    this.cv=document.getElementById('hud'); this.ctx=this.cv.getContext('2d');
    this.resize();
  },
  resize(){
    this.dpr=Math.min(devicePixelRatio,2);
    this.cv.width=innerWidth*this.dpr; this.cv.height=innerHeight*this.dpr;
    this.cv.style.width=innerWidth+'px'; this.cv.style.height=innerHeight+'px';
  },
  clear(){
    this.ctx.setTransform(this.dpr,0,0,this.dpr,0,0);
    this.ctx.clearRect(0,0,innerWidth,innerHeight);
  },
  draw(){
    if(G.state!=='flying'&&G.state!=='paused'){ this.clear(); return; }
    const c=this.ctx,hw=innerWidth,hh=innerHeight;
    this.clear();
    c.shadowColor='rgba(4,12,20,0.85)'; c.shadowBlur=3.5;
    c.lineJoin='round'; c.lineCap='round';
    const ac=G.player,sp=ac.spec;
    const cx=hw*0.5,cy=hh*0.47;
    const ppd=hh/58;
    const hudRoll=-ac.bank;
    const GRN='rgba(126,240,196,0.92)', GRN2='rgba(126,240,196,0.45)';
    // ---------------- pitch ladder ----------------
    c.save();
    c.beginPath(); c.rect(0,0,hw,hh); c.clip();
    c.translate(cx,cy+ac.pitchAng*RAD*ppd);
    c.rotate(hudRoll);
    c.lineWidth=1.6; c.strokeStyle=GRN; c.fillStyle=GRN;
    c.font='11px '+getComputedStyle(document.body).getPropertyValue('--mono');
    c.textAlign='center'; c.textBaseline='middle';
    // horizon
    c.beginPath(); c.moveTo(-hw,0); c.lineTo(-18,0); c.moveTo(18,0); c.lineTo(hw,0); c.stroke();
    for(let p=-90;p<=90;p+=5){
      if(p===0) continue;
      const y=-p*ppd;
      if(Math.abs(y)>hh*1.2) continue;
      const maj=(p%10===0);
      const w= maj? (p<0? 62:70) : 26;
      c.globalAlpha=maj?0.95:0.6;
      c.beginPath();
      if(p<0){ // below horizon: dashed
        for(let sx=-w;sx<w;sx+=13){ c.moveTo(sx,y); c.lineTo(sx+7,y); }
        if(maj){ c.moveTo(w,y); c.lineTo(w-8,y+0); }
      } else {
        for(let sx=-w;sx<w;sx+=13){ c.moveTo(sx,y); c.lineTo(sx+7,y); }
      }
      c.stroke();
      if(maj){ c.globalAlpha=0.85;
        c.fillText(Math.abs(p), -w-16, y); c.fillText(Math.abs(p), w+16, y); }
      c.globalAlpha=1;
      // vertical ticks at ends of major lines
      if(maj){
        c.beginPath(); c.moveTo(-w,y); c.lineTo(-w,y+ (p>0?-8:8)); c.moveTo(w,y); c.lineTo(w,y+(p>0?-8:8)); c.stroke();
      }
    }
    c.restore();
    // ---------------- fixed aircraft symbol ----------------
    c.strokeStyle=GRN; c.lineWidth=2.3;
    c.beginPath();
    c.moveTo(cx-60,cy); c.lineTo(cx-22,cy); c.lineTo(cx-14,cy+8); c.moveTo(cx+60,cy); c.lineTo(cx+22,cy); c.lineTo(cx+14,cy+8);
    c.moveTo(cx-5,cy); c.lineTo(cx+5,cy);
    c.stroke();
    // ---------------- flight path marker ----------------
    if(ac.V>8){
      const vd=vnrm(ac.vel);
      const fpaE=Math.asin(clamp(vd.y,-1,1)), fpaA=Math.atan2(vd.x,-vd.z);
      let dh=((fpaA-Math.atan2(qrot(ac.q,V3(0,0,-1)).x, -qrot(ac.q,V3(0,0,-1)).z))*RAD);
      while(dh>180)dh-=360; while(dh<-180)dh+=360;
      const fv=(fpaE-ac.pitchAng)*RAD;
      const fx=cx+dh*ppd*1.0, fy=cy-fv*ppd;
      c.strokeStyle='rgba(180,255,220,0.9)'; c.lineWidth=1.6;
      c.beginPath(); c.arc(fx,fy,7,0,TAU); c.moveTo(fx-13,fy); c.lineTo(fx-7,fy);
      c.moveTo(fx+13,fy); c.lineTo(fx+7,fy); c.moveTo(fx,fy-13); c.lineTo(fx,fy-7); c.stroke();
    }
    // ---------------- roll indicator ----------------
    const rr=hh*0.30;
    c.save(); c.translate(cx,cy); c.strokeStyle=GRN2; c.lineWidth=1.4;
    c.beginPath(); c.arc(0,0,rr,-Math.PI*0.72,-Math.PI*0.28); c.stroke();
    for(let a=-60;a<=60;a+=15){
      const rad=-Math.PI/2+a*DEG;
      const l=(a%30===0)?11:6;
      c.beginPath();
      c.moveTo(Math.cos(rad)*rr,Math.sin(rad)*rr);
      c.lineTo(Math.cos(rad)*(rr+l),Math.sin(rad)*(rr+l)); c.stroke();
    }
    // bank pointer
    c.rotate(hudRoll); c.strokeStyle='rgba(255,190,90,0.95)'; c.lineWidth=2.2;
    c.beginPath(); c.moveTo(0,-rr-3); c.lineTo(-7,-rr+8); c.lineTo(7,-rr+8); c.closePath(); c.stroke();
    c.restore();
    // ---------------- speed tape ----------------
    const kt=ac.V*1.94384, alt=ac.pos.y*3.28084, vs=ac.vspeed*196.85;
    this.tape(cx-hw*0.30,cy,kt,20,kt,'KT',GRN);
    this.tape(cx+hw*0.30,cy,alt,500,alt,'FT',GRN);
    // ---------------- heading tape ----------------
    c.save(); c.translate(cx,hh*0.075);
    c.strokeStyle=GRN2; c.lineWidth=1.2; c.fillStyle=GRN;
    c.font='11px monospace'; c.textAlign='center';
    const pxDeg=hw/95;
    c.beginPath(); c.moveTo(-hw*0.34,0); c.lineTo(hw*0.34,0); c.stroke();
    for(let d=-50;d<=50;d+=5){
      const hd=Math.round(ac.heading/5)*5+d;
      const x=(hd-ac.heading)*pxDeg;
      if(Math.abs(x)>hw*0.34) continue;
      const maj=(hd%10===0);
      c.beginPath(); c.moveTo(x,0); c.lineTo(x,maj?10:5); c.stroke();
      if(hd%30===0){
        const lab=((hd%360)+360)%360/10||0;
        c.fillText(('0'+Math.round(lab)).slice(-2),x,24);
      }
    }
    c.strokeStyle='rgba(255,190,90,0.95)'; c.lineWidth=2;
    c.beginPath(); c.moveTo(0,-2); c.lineTo(-6,-11); c.lineTo(6,-11); c.closePath(); c.stroke();
    c.fillStyle=GRN; c.font='bold 13px monospace';
    c.fillText(('00'+Math.round(ac.heading)).slice(-3)+'°',0,-16);
    c.restore();
    // ---------------- data block ----------------
    c.font='12px monospace'; c.textAlign='left';
    const bx=hw*0.055, by=hh*0.70, lh=17;
    const put=(i,k,v,col)=>{
      c.fillStyle='rgba(150,180,200,0.62)'; c.fillText(k,bx,by+i*lh);
      c.fillStyle=col||'rgba(200,240,255,0.95)'; c.fillText(v,bx+52,by+i*lh);
    };
    put(0,'THR ',Math.round(ac.ctrl.throttle*100)+'%'+(ac.ctrl.ab>0.5?' AB':''),ac.ctrl.ab>0.5?'#ffb648':null);
    put(1,'RPM ',Math.round(ac.rpm)+'');
    put(2,'G   ',ac.gLoad.toFixed(1)+(ac.gLimit?' LIM':''));
    put(3,'AOA ',ac.aoaDeg.toFixed(1)+'°');
    put(4,'MACH',ac.mach.toFixed(2));
    put(5,'V/S ',vs.toFixed(0)+' fpm');
    put(6,'AGL ',(ac.pos.y-groundHeight(ac.pos.x,ac.pos.z)).toFixed(0)+' m');
    // throttle bar
    c.fillStyle='rgba(255,255,255,0.10)'; c.fillRect(bx,by+7*lh+6,150,7);
    c.fillStyle=ac.ctrl.ab>0.5?'#ffb648':'#3ad7ff'; c.fillRect(bx,by+7*lh+6,150*ac.ctrl.throttle,7);
    // status flags
    c.font='bold 11px monospace'; c.textAlign='center';
    const flags=[];
    if(ac.gearAnim>0.5) flags.push(['GEAR','#7fe3ff']);
    if(ac.ctrl.flaps>0.05) flags.push(['FLAP','#7fe3ff']);
    if(ac.ctrl.brakes>0.5) flags.push(['BRAKE','#ffb648']);
    if(ac.ctrl.airbrake>0.5) flags.push(['SPD BRK','#ffb648']);
    flags.forEach((f,i)=>{ c.fillStyle=f[1]; c.fillText(f[0],cx-120+i*88,hh*0.90); });
    // ---------------- warnings ----------------
    const warn=(txt,col,y,blink)=>{
      if(blink && (performance.now()%700<350)) return;
      c.fillStyle=col; c.font='bold 20px monospace'; c.textAlign='center';
      c.fillText(txt,cx,y);
    };
    if(ac.stalled) warn('▲ STALL','#ff5a6a',cy-120,true);
    if(ac.vspeed<-25 && ac.pos.y-groundHeight(ac.pos.x,ac.pos.z)<400) warn('▼ PULL UP','#ff9a3c',cy+140,true);
    else if(ac.vspeed<-30) warn('▼ SINK RATE','#ffb648',cy+140,false);
    Aero.stallTone(!!ac.stalled);
    // ---------------- waypoint / target ----------------
    if(G.waypoints.length&&G.wpIndex<G.waypoints.length){
      const wp=G.waypoints[G.wpIndex];
      const d=Math.hypot(wp.x-ac.pos.x,wp.y-ac.pos.y,wp.z-ac.pos.z);
      this.marker(wp.x,wp.y,wp.z,wp.name+'  '+(d/1000).toFixed(1)+'km','#53e0ff',c,cx,cy);
    }
    if(G.scenario==='dogfight'){
      // gun cross
      c.strokeStyle='rgba(255,170,60,0.9)'; c.lineWidth=1.6;
      c.beginPath(); c.arc(cx,cy,26,0,TAU); c.moveTo(cx-38,cy); c.lineTo(cx-30,cy);
      c.moveTo(cx+38,cy); c.lineTo(cx+30,cy); c.moveTo(cx,cy-38); c.lineTo(cx,cy-30);
      c.moveTo(cx,cy+38); c.lineTo(cx,cy+30); c.stroke();
      let near=null,nd=1e9;
      for(const e of G.enemies){ if(!e.alive) continue;
        const d=vlen(vsub(e.pos,ac.pos)); if(d<nd){nd=d;near=e;} }
      if(near){
        this.marker(near.pos.x,near.pos.y,near.pos.z,'HOSTILE '+nd.toFixed(0)+'m','#ff6b7a',c,cx,cy);
        const lead=vadd(near.pos,vmul(vsub(near.vel,ac.vel),-nd/1050*-1));
        c.fillStyle='rgba(255,110,120,0.95)'; c.font='bold 11px monospace'; c.textAlign='center';
      }
      // damage bars
      c.textAlign='left'; c.font='11px monospace';
      G.enemies.forEach((e,i)=>{
        const y=hh*0.80+i*15;
        c.fillStyle='rgba(255,255,255,0.12)'; c.fillRect(hw-190,y,110,6);
        c.fillStyle=e.alive?(e.health>0.5?'#5df08a':'#ffb648'):'#444';
        c.fillRect(hw-190,y,110*Math.max(0,e.health),6);
        c.fillStyle='rgba(190,215,235,0.7)'; c.fillText('RED-'+(i+1),hw-240,y+6);
      });
      if(G.player.health<1){
        c.fillStyle='rgba(255,255,255,0.12)'; c.fillRect(hw-190,hh*0.80+G.enemies.length*15+8,110,6);
        c.fillStyle='#3ad7ff'; c.fillRect(hw-190,hh*0.80+G.enemies.length*15+8,110*G.player.health,6);
        c.fillStyle='rgba(190,215,235,0.7)'; c.fillText('AIRFRAME',hw-262,hh*0.80+G.enemies.length*15+14);
      }
    }
    // ---------------- radar ----------------
    this.radar(c,hw-110,hh-110,78,ac);
    // ---------------- objective banner ----------------
    c.textAlign='center'; c.font='bold 13px monospace';
    c.fillStyle='rgba(120,220,255,0.85)';
    let obj='';
    if(G.scenario==='takeoff'){
      obj = G.phase==='TAKEOFF'?'TAKEOFF — FULL POWER, ROTATE AT '+(sp.rotateSpd*1.94384).toFixed(0)+'KT'
        : G.phase==='NAVIGATE'? 'WAYPOINT '+(G.wpIndex+1)+' OF '+G.waypoints.length
        : 'RETURN TO BASE — RUNWAY ALIGNED 000°';
    } else if(G.scenario==='dogfight') obj='DESTROY ALL HOSTILES — '+G.kills+'/'+G.enemies.length;
    if(obj) c.fillText(obj,cx,hh-24);
    // boundary
    const rad=Math.hypot(ac.pos.x,ac.pos.z);
    if(rad>17000) warn('◄ RETURN TO AREA ►','#ffb648',hh*0.20,true);
  },
  tape(x,cy,val,minor,valUnit,unit,col){
    const c=this.ctx,hh=innerHeight;
    const h=hh*0.30, pxPer=hh/58*(minor/ (unit==='FT'?1000:20))*(unit==='FT'?1:1);
    const scale = unit==='KT'? 6.2 : (unit==='FT'? 0.0062 : 6.2);
    c.save(); c.translate(x,cy);
    c.strokeStyle=col; c.fillStyle=col; c.lineWidth=1.4;
    c.font='11px monospace';
    const step=unit==='KT'?10:(unit==='FT'?200:10);
    const lo=Math.floor((val-h/(hh/58*scale*1))/step)*step;
    const degPerPx= (hh/58)/(hh*0.30);
    for(let v=Math.round(val/(step)) *step - step*22; v<=val+step*22; v+=step){
      const y=-(v-val)*(hh/(unit==='FT'?19000:200));
      if(Math.abs(y)>h*1.6) continue;
      const maj=(v%(step*2)===0);
      c.beginPath(); c.moveTo(0,y); c.lineTo(maj?14:8,y); c.stroke();
      if(maj){ c.textAlign='left'; if(x<innerWidth/2) c.fillText(v,18,y+4); else c.fillText(v,-48,y+4); }
    }
    c.textAlign='center';
    const curTxt= unit==='KT'? Math.round(val)+'' : Math.round(val)+'';
    c.fillStyle='rgba(6,16,24,0.55)'; 
    if(x<innerWidth/2){ c.fillRect(-1,-11,86,22); c.strokeRect(-1,-11,86,22);} 
    else { c.fillRect(-85,-11,86,22); c.strokeRect(-85,-11,86,22);}
    c.strokeStyle=col;
    c.fillStyle='rgba(200,245,255,0.98)'; c.font='bold 14px monospace';
    c.fillText(curTxt,x<innerWidth/2?42:-42,0);
    c.font='10px monospace'; c.fillStyle='rgba(150,190,210,0.8)';
    c.fillText(unit,x<innerWidth/2?42:-42,18);
    c.restore();
  },
  marker(wx,wy,wz,label,col,c,cx,cy){
    const v=new THREE.Vector3(wx,wy,wz);
    const p=v.clone().project(camera);
    const x=(p.x*0.5+0.5)*innerWidth, y=(-p.y*0.5+0.5)*innerHeight;
    c.strokeStyle=col; c.fillStyle=col; c.lineWidth=1.6; c.font='11px monospace';
    if(p.z>1||p.z<-1||x<0||x>innerWidth||y<0||y>innerHeight){
      // off screen arrow
      const dx=x-cx, dy=y-cy, a=Math.atan2(dy,dx);
      const rr=Math.min(innerWidth,innerHeight)*0.36;
      const ax=cx+Math.cos(a)*rr, ay=cy+Math.sin(a)*rr;
      c.save(); c.translate(ax,ay); c.rotate(a+Math.PI/2);
      c.beginPath(); c.moveTo(0,10); c.lineTo(-8,-4); c.lineTo(8,-4); c.closePath(); c.stroke();
      c.restore();
      return;
    }
    const s=26;
    c.beginPath();
    c.moveTo(x-s,y-s+6); c.lineTo(x-s,y-s); c.lineTo(x-s+6,y-s);
    c.moveTo(x+s-6,y-s); c.lineTo(x+s,y-s); c.lineTo(x+s,y-s+6);
    c.moveTo(x+s,y+s-6); c.lineTo(x+s,y+s); c.lineTo(x+s-6,y+s);
    c.moveTo(x-s+6,y+s); c.lineTo(x-s,y+s); c.lineTo(x-s,y+s-6);
    c.stroke();
    c.textAlign='center'; c.fillText(label,x,y+s+13);
  },
  radar(c,x,y,r,ac){
    c.save(); c.translate(x,y);
    c.fillStyle='rgba(6,16,24,0.45)'; c.beginPath(); c.arc(0,0,r,0,TAU); c.fill();
    c.strokeStyle='rgba(120,200,230,0.35)'; c.lineWidth=1;
    c.beginPath(); c.arc(0,0,r,0,TAU); c.moveTo(-r,0); c.lineTo(r,0); c.moveTo(0,-r); c.lineTo(0,r); c.stroke();
    c.beginPath(); c.arc(0,0,r*0.5,0,TAU); c.stroke();
    const scalePx=r/9000;
    const hdg=-ac.heading*DEG;
    const rot=(dx,dz)=>{
      const ca=Math.cos(-hdg+Math.PI), sa=Math.sin(-hdg+Math.PI);
      return [ (dx*ca - dz*sa)*scalePx, (dx*sa + dz*ca)*scalePx ];
    };
    // base marker
    { const dx=WORLD.base.x-ac.pos.x, dz=WORLD.base.z-ac.pos.z;
      const [px,py]=rot(dx,dz);
      if(Math.hypot(px,py)<r){ c.fillStyle='#5df08a'; c.fillRect(px-3,py-3,6,6); } }
    // enemies
    G.enemies.forEach(e=>{
      if(!e.alive) return;
      const dx=e.pos.x-ac.pos.x, dz=e.pos.z-ac.pos.z;
      const [px,py]=rot(dx,dz);
      if(Math.hypot(px,py)<r){ c.fillStyle='#ff6b7a'; c.beginPath(); c.arc(px,py,3.4,0,TAU); c.fill(); }
    });
    // aircraft at center
    c.fillStyle='#3ad7ff'; c.beginPath(); c.moveTo(0,-5); c.lineTo(-4,5); c.lineTo(4,5); c.closePath(); c.fill();
    c.fillStyle='rgba(150,190,210,0.65)'; c.font='10px monospace'; c.textAlign='center';
    c.fillText('RNG 9km',0,r-6);
    c.restore();
  }
};

/* ============================================================================
   14. GAME LOOP
   ========================================================================== */
let last=0;
function postStep(dt){
  const ac=G.player,sp=ac.spec;
  G.maxAlt=Math.max(G.maxAlt||0,ac.pos.y);
  G.maxSpd=Math.max(G.maxSpd||0,ac.V);
  // ---- weapons fire ----
  ac.gunCool=Math.max(0,(ac.gunCool||0)-dt);
  ac.hitFlash=Math.max(0,(ac.hitFlash||0)-dt*4);
  if(ac.ctrl.fire && ac.weaponAmmo>0 && !ac.crashed && ac.gunHeat<1){
    const fwd=qrot(ac.q,V3(0,0,-1));
    if(G.weapons.fire(ac,fwd)){
      G.shots++; ac.gunHeat+=0.030; ac.gunCool=0.075;
      Aero.burst('gun',0.20);
      Cam.shake=Math.max(Cam.shake,0.35);
    }
  }
  ac.gunHeat=Math.max(0,ac.gunHeat-dt*0.35);
  // ---- bullets ----
  const res=G.weapons.step(dt,[ac].concat(G.enemies));
  for(const r of res){
    if(r.hit&&r.target){
      G.hits++;
      Aero.burst('alarm',0.12);
      if(r.target===ac){ ac.health-=r.dmg; toast('HIT — AIRFRAME '+(ac.health*100).toFixed(0)+'%','warn',1200); }
      else { r.target.health-=r.dmg*3.2; }
      if(r.target.health<=0 && r.target.alive){
        r.target.alive=false;
        FX.explode(r.target.pos,1.4);
        Aero.burst('boom',0.5);
        if(r.target===ac){ G.deaths++; setTimeout(()=>endScenario(),1400); }
        else { G.kills++; toast('SPLASH ONE HOSTILE','good'); }
      }
    }
  }
  // ---- enemy meshes / smoke ----
  G.enemies.forEach((e,i)=>{
    const m=G.enemyMeshes[i];
    m.visible=e.alive;
    if(e.alive){
      syncMesh(m,e);
      if(e.health<0.6&&Math.random()<0.5) FX.smoke(e.pos,e.vel,false);
      if(e.health<0.3&&Math.random()<0.35) FX.smoke(e.pos,e.vel,true);
      if(e.crashed){ e.alive=false; FX.explode(e.pos,1.2); Aero.burst('boom',0.35); m.visible=false; }
    }
  });
  // ---- player smoke ----
  if(ac.health<0.7&&Math.random()<0.6) FX.smoke(ac.pos,ac.vel, false);
  if(ac.health<0.4&&Math.random()<0.5) FX.smoke(ac.pos,ac.vel, true);
  if(ac.crashed){
    if(!G._crashed){ G._crashed=1; FX.explode(ac.pos,2.2); Aero.burst('boom',0.7); Cam.shake=1.4;
      setTimeout(()=>{ endScenario(); G._crashed=0; },1800); }
    G.pMesh.visible=false;
  } else { G.pMesh.visible=!(G.camMode===1); syncMesh(G.pMesh,ac); }
  // ---- contrails / wingtip vortices at high G ----
  if(!ac.crashed && Math.abs(ac.gLoad)>5 && ac.V>120 && ac.pos.y>200){
    const r=qrot(ac.q,V3(sp.b*0.5,0,0)), l=qrot(ac.q,V3(-sp.b*0.5,0,0));
    FX.spawn(ac.pos.x+r.x,ac.pos.y+r.y,ac.pos.z+r.z,ac.vel.x*0.2,ac.vel.y*0.2,ac.vel.z*0.2,0.5,0.62,0.75,1.1,0.7,0.1,0.8);
    FX.spawn(ac.pos.x+l.x,ac.pos.y+l.y,ac.pos.z+l.z,ac.vel.x*0.2,ac.vel.y*0.2,ac.vel.z*0.2,0.5,0.62,0.75,1.1,0.7,0.1,0.8);
  }
  // ---- scenario logic ----
  const agl=ac.pos.y-groundHeight(ac.pos.x,ac.pos.z);
  const wasAir=G._wasAir; G._wasAir=!ac.grounded;
  if(G.scenario==='takeoff'){
    if(G.phase==='TAKEOFF'){
      if(agl>25&&ac.vspeed>1){ G.phase='NAVIGATE'; centerMsg('WAYPOINT 1 — CLIMB & NAVIGATE'); }
    } else if(G.phase==='NAVIGATE'){
      const wp=G.waypoints[G.wpIndex];
      if(wp){
        const d=Math.hypot(wp.x-ac.pos.x,wp.y-ac.pos.y,wp.z-ac.pos.z);
        if(d<115){ G.wpIndex++; if(G.wpIndex>=G.waypoints.length){ G.phase='RETURN'; centerMsg('RETURN TO BASE — LAND ON RUNWAY 000'); }
          else centerMsg('WAYPOINT '+(G.wpIndex+1)); }
      }
    } else if(G.phase==='RETURN'){
      if(ac.grounded && wasAir && isRunway(ac.pos.x,ac.pos.z) && ac.V>2){
        G.touchdownVS=ac.impact; G.touchdownOff=Math.abs(ac.pos.x-WORLD.base.x);
        G.phase='LANDED'; Aero.burst('touch',0.4);
        setTimeout(()=>endScenario(),1100);
      }
    }
  } else if(G.scenario==='dogfight'){
    if(!G._endPending && G.enemies.length && G.enemies.every(e=>!e.alive)){ G._endPending=true; setTimeout(()=>endScenario(),1300); }
  }
  if(G.scenario==='takeoff' && ac.crashed && !G._endPending){ G._endPending=true; }
  // ---- audio ----
  Aero.updateFlight(ac,dt);
  // ---- waypoint ring visuals ----
  G._wpMeshes.forEach((m,i)=>{ if(!m) return;
    m.visible = i>=G.wpIndex && G.scenario==='takeoff';
    if(m.visible){ m.rotation.z += dt*0.6; const s=1+Math.sin(performance.now()*0.002)*0.05; m.scale.set(s,s,s);}
  });
}
function loop(){
  requestAnimationFrame(loop);
  const now=performance.now();
  let dt=(now-last)/1000; last=now;
  if(dt>0.08) dt=0.08;
  if(G.state==='flying'){
    G.t+=dt;
    Input.read(dt);
    for(const ai of G.aiPilots) ai.update(dt,{target:G.player});
    const N=Math.min(10,Math.max(1,Math.ceil(dt/0.005)));
    const h=dt/N;
    for(let i=0;i<N;i++){
      G.player.step(h);
      for(const e of G.enemies) if(e.alive) e.step(h);
    }
    postStep(dt);
    FX.update(dt);
  } else if(G.state==='paused'||G.state==='debrief'){
    FX.update(dt*0.35);
  }
  if(renderer){
    // sun follows player for shadow coverage
    const p=G.player?G.player.pos:{x:0,y:0,z:0};
    sun.position.set(p.x+SUN_DIR.x*900,p.y+SUN_DIR.y*900,p.z+SUN_DIR.z*900);
    sun.target.position.set(p.x,p.y,p.z); sun.target.updateMatrixWorld();
    if(camera.position.y<groundHeight(camera.position.x,camera.position.z)+1.5 && G.state==='flying'){
      camera.position.y=groundHeight(camera.position.x,camera.position.z)+1.5;
    }
    if(G.state==='flying'||G.state==='paused') Cam.update(G.state==='paused'?0:dt);
    renderer.render(scene,camera);
  }
  HUD.draw();
}
/* ---------------- pause / sound / UI ---------------- */
function pauseGame(){
  G.state='paused';
  const ac=G.player;
  document.getElementById('pauseStats').innerHTML=
    'ALT '+ac.pos.y.toFixed(0)+' m &nbsp;|&nbsp; IAS '+ac.V.toFixed(0)+' m/s ('+(ac.V*1.94384).toFixed(0)+' kt)<br>'+
    'MACH '+ac.mach.toFixed(2)+' &nbsp;|&nbsp; G '+ac.gLoad.toFixed(1)+' &nbsp;|&nbsp; AOA '+ac.aoaDeg.toFixed(1)+'°<br>'+
    'THROTTLE '+Math.round(ac.ctrl.throttle*100)+'% &nbsp;|&nbsp; RPM '+Math.round(ac.rpm)+'<br>'+
    'HDG '+ac.heading.toFixed(0)+'° &nbsp;|&nbsp; V/S '+ac.vspeed.toFixed(1)+' m/s'+
    (ac.stalled?'<br><span style="color:#ff5a6a">STALL — LOWER AOA / ADD POWER</span>':'');
  document.getElementById('pause').classList.remove('hidden');
  Aero.setMaster(Aero.on?0.25:0);
}
function resumeGame(){
  G.state='flying'; last=performance.now();
  document.getElementById('pause').classList.add('hidden');
  Aero.setMaster(Aero.on?0.9:0);
}
function toggleSound(){
  Aero.on=!Aero.on; Aero.init();
  Aero.setMaster(Aero.on?0.9:0);
  if(!Aero.on) Aero.stallTone(false);
  document.getElementById('soundBtn').textContent='Audio: '+(Aero.on?'On':'Off');
}

/* ============================================================================
   15. MENU / BOOT
   ========================================================================== */
const SCENARIOS=[
  {id:'takeoff',ic:'🛫',name:'Takeoff & Landing',desc:'Full-power runway departure, waypoint navigation and a precision return to the numbers. Scored on touchdown sink rate and centerline accuracy.'},
  {id:'dogfight',ic:'⚔️',name:'Dogfight',desc:'Air-combat maneuvering against adaptive hostiles. Manage energy, angle of attack and G-loading while gunning for the kill.'},
  {id:'free',ic:'🌄',name:'Free Flight',desc:'Unrestricted flight over 38,000 km² of procedural mountains, coasts and valleys. Pure airmanship, no objectives.'}
];
function silhouette(id,col){
  const a=col;
  if(id==='falcon') return `<svg class="sil" viewBox="0 0 120 70"><g fill="none" stroke="${a}" stroke-width="2"><path d="M8 35 L44 33 L52 30 L112 30 L118 35 L112 40 L52 40 L44 37 Z"/><path d="M50 33 L38 12 M50 37 L38 58"/><path d="M12 35 L4 33 L4 37 Z"/><circle cx="6" cy="35" r="6"/></g></svg>`;
  if(id==='vanguard') return `<svg class="sil" viewBox="0 0 120 70"><g fill="none" stroke="${a}" stroke-width="2"><path d="M6 35 L40 32 L50 28 L100 28 L116 32 L116 38 L100 42 L50 42 L40 38 Z"/><path d="M52 30 L30 6 L42 8 L58 28 M52 40 L30 64 L42 62 L58 42"/><path d="M96 30 L108 14 M96 40 L108 56"/></g></svg>`;
  return `<svg class="sil" viewBox="0 0 120 70"><g fill="none" stroke="${a}" stroke-width="2"><path d="M4 35 L36 32 L46 29 L104 29 L116 32 L116 38 L104 41 L46 41 L36 38 Z"/><path d="M24 30 L24 8 L30 8 L30 30 M84 30 L84 8 L90 8 L90 30"/><circle cx="27" cy="12" r="5"/><circle cx="87" cy="12" r="5"/><path d="M110 30 L118 22 M110 40 L118 48"/></g></svg>`;
}
function buildMenu(){
  const fl=document.getElementById('fleet');
  fl.innerHTML=FLEET.map((f,i)=>`
    <div class="card${i===0?' sel':''}" data-i="${i}">
      ${silhouette(f.id,'#'+new THREE.Color(f.accent).getHexString())}
      <h3>${f.name}</h3>
      <div class="role">${f.role}</div>
      <p>${f.desc}</p>
      <div class="stat"><b>SPD</b><div class="bar"><i style="width:${f.perf.spd}%"></i></div></div>
      <div class="stat"><b>LIFT</b><div class="bar"><i style="width:${f.perf.lift}%"></i></div></div>
      <div class="stat"><b>AGIL</b><div class="bar am"><i style="width:${f.perf.agil}%"></i></div></div>
      <div class="stat"><b>STRUC</b><div class="bar rd"><i style="width:${f.perf.dur}%"></i></div></div>
      <div class="stat" style="margin-top:8px"><b>MAX</b><span style="font-size:9.5px">${(f.vMax*1.94384).toFixed(0)} kt &nbsp; ${(f.mass/1000).toFixed(1)} t &nbsp; T/W ${(f.maxThrust/(f.mass*9.81)).toFixed(2)}</span></div>
    </div>`).join('');
  fl.querySelectorAll('.card').forEach(el=>{
    el.addEventListener('click',()=>{
      fl.querySelectorAll('.card').forEach(x=>x.classList.remove('sel'));
      el.classList.add('sel'); G.spec=FLEET[+el.dataset.i];
      Aero.init(); if(Aero.ctx&&Aero.ctx.state==='suspended') Aero.ctx.resume(); Aero.burst('ui',0.10);
    });
  });
  const sc=document.getElementById('scenarios');
  sc.innerHTML=SCENARIOS.map((s,i)=>`
    <div class="scen${i===1?' sel':''}" data-id="${s.id}">
      <div class="ic">${s.ic}</div><div><h4>${s.name}</h4><p>${s.desc}</p></div>
    </div>`).join('');
  sc.querySelectorAll('.scen').forEach(el=>{
    el.addEventListener('click',()=>{
      sc.querySelectorAll('.scen').forEach(x=>x.classList.remove('sel'));
      el.classList.add('sel'); G.scenario=el.dataset.id;
    });
  });
  document.getElementById('startBtn').addEventListener('click',()=>{
    Aero.init(); if(Aero.ctx&&Aero.ctx.state==='suspended') Aero.ctx.resume();
    startGame(G.spec,G.scenario);
    setTimeout(()=>{ const cv=document.getElementById('scene'); cv.requestPointerLock&&cv.requestPointerLock(); },120);
  });
  document.getElementById('soundBtn').addEventListener('click',e=>{ toggleSound(); });
  document.getElementById('resumeBtn').addEventListener('click',resumeGame);
  document.getElementById('restartBtn').addEventListener('click',()=>startGame(G.spec,G.scenario));
  document.getElementById('menuBtn').addEventListener('click',()=>{
    G.state='menu'; document.getElementById('pause').classList.add('hidden');
    document.getElementById('menu').classList.remove('hidden'); Aero.setMaster(0);
  });
  document.getElementById('dbAgain').addEventListener('click',()=>startGame(G.spec,G.scenario));
  document.getElementById('dbMenu').addEventListener('click',()=>{
    G.state='menu'; document.getElementById('debrief').classList.add('hidden');
    document.getElementById('menu').classList.remove('hidden'); Aero.setMaster(0);
  });
  document.addEventListener('pointerlockchange',()=>{
    Input.mouseLock=!!document.pointerLockElement;
    document.getElementById('hint').textContent = Input.mouseLock? 'Mouse: analog stick — ESC to release.' : 'Click canvas to lock the mouse for analog stick control.';
  });
}
function boot(){
  const err=document.getElementById('err');
  if(typeof THREE==='undefined'){
    document.getElementById('loadbar').style.display='none';
    err.style.display='block';
    err.innerHTML='<b>THREE.JS COULD NOT BE LOADED</b><br><br>The 3D engine is fetched from a CDN '+
      '(cdnjs three.js r128). This environment blocked the request. Serve the file over HTTP with network '+
      'access, or place three.min.js beside it and update the &lt;script&gt; tag.<br><br>'+
      'All flight-dynamics code is independent of the renderer and can be inspected/tested directly '+
      '(e.g. <i>new Aircraft(FLEET[0])</i>).';
    buildMenuFallback();
    return;
  }
  initRenderer(); makeSky(); makeLights(); buildTerrain(); buildWater(); buildClouds(); buildAirbase();
  FX.init(); HUD.init(); Input.init(); buildMenu();
  // default aircraft preview
  G.player=new Aircraft(FLEET[0]); G.player.uid='player';
  G.player.reset(WORLD.base.x,WORLD.base.z+WORLD.runwayLen*0.35,0,false,0);
  const pb=buildAircraft(FLEET[0],false); G.pMesh=pb.group; G.pMesh.userData.anim=pb.anim;
  syncMesh(G.pMesh,G.player);
  addEventListener('resize',()=>{ HUD.resize(); });
  setTimeout(()=>{ const l=document.getElementById('loadbar'); l.style.opacity='0'; setTimeout(()=>l.style.display='none',520); },260);
  last=performance.now();
  loop();
}
function buildMenuFallback(){
  const fl=document.getElementById('fleet');
  fl.innerHTML=FLEET.map((f,i)=>`<div class="card${i===0?' sel':''}" data-i="${i}"><h3>${f.name}</h3>
    <div class="role">${f.role}</div><p>${f.desc}</p></div>`).join('');
  fl.querySelectorAll('.card').forEach(el=>el.addEventListener('click',()=>{
    fl.querySelectorAll('.card').forEach(x=>x.classList.remove('sel')); el.classList.add('sel'); G.spec=FLEET[+el.dataset.i];}));
  const sc=document.getElementById('scenarios');
  sc.innerHTML=SCENARIOS.map((s,i)=>`<div class="scen${i===1?' sel':''}" data-id="${s.id}"><div class="ic">${s.ic}</div>
    <div><h4>${s.name}</h4><p>${s.desc}</p></div></div>`).join('');
  sc.querySelectorAll('.scen').forEach(el=>el.addEventListener('click',()=>{
    sc.querySelectorAll('.scen').forEach(x=>x.classList.remove('sel')); el.classList.add('sel'); G.scenario=el.dataset.id;}));
  document.getElementById('startBtn').textContent='Requires three.js';
}
window.addEventListener('load',()=>setTimeout(boot,40));
if(document.readyState==='complete') setTimeout(boot,40);
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"843d07f6-9d6b-4b42-a069-5a91ca88369b","tokensIn":24044952,"tokensOut":187144,"tokensTotal":24232096,"cost":0.22681723200000006,"turns":97,"toolCalls":96,"failedToolCalls":0,"timestamp":"2026-09-10T21:30:02.217Z"} -->