← Flight Simulator results

Flight Simulator

GLM 5.3 Flash · full

Initial view of Flight Simulator

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

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

Generation prompt · exact text unavailable

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

Inspect original source 48,834 bytes · SHA-256 9cd7af8e98ee
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Apex Aero — Flight Simulator</title>
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  body { overflow:hidden; background:#000; font-family:'Segoe UI',Roboto,sans-serif; color:#cfe8ff; user-select:none; }
  #app { position:fixed; inset:0; }
  canvas#gl { display:block; }
  canvas#hud { position:absolute; inset:0; pointer-events:none; }

  /* ---------- Menus ---------- */
  .screen { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
    justify-content:center; background:radial-gradient(ellipse at 50% 30%, rgba(10,25,50,.85), rgba(2,6,14,.97)); z-index:20; }
  .hidden { display:none !important; }
  h1.logo { font-size:56px; letter-spacing:12px; font-weight:200; color:#eaf6ff;
    text-shadow:0 0 30px rgba(90,160,255,.7); margin-bottom:4px;}
  h1.logo b { font-weight:700; color:#5db3ff; }
  .sub { opacity:.65; letter-spacing:4px; font-size:13px; text-transform:uppercase; margin-bottom:36px; }
  .cards { display:flex; gap:22px; margin-bottom:28px; }
  .card { width:230px; padding:18px; border:1px solid rgba(110,170,255,.35); border-radius:10px;
    background:linear-gradient(180deg, rgba(20,40,75,.55), rgba(8,16,32,.7)); cursor:pointer;
    transition:.18s; }
  .card:hover { transform:translateY(-4px); border-color:#7fc0ff; box-shadow:0 8px 30px rgba(60,130,255,.25); }
  .card.sel { border-color:#ffd76a; box-shadow:0 0 24px rgba(255,215,106,.35); }
  .card h3 { font-size:17px; font-weight:600; color:#fff; margin-bottom:2px; }
  .card .role { font-size:11px; letter-spacing:2px; text-transform:uppercase; color:#8fc4ff; margin-bottom:10px; }
  .statbar { margin-top:6px; }
  .statbar label { font-size:10px; opacity:.7; letter-spacing:1px; display:flex; justify-content:space-between;}
  .track { height:4px; background:rgba(255,255,255,.12); border-radius:2px; overflow:hidden; margin:2px 0 7px;}
  .fill { height:100%; background:linear-gradient(90deg,#3f8fd9,#8fe0ff); border-radius:2px; }
  .sect { margin-bottom:22px; text-align:center; }
  .sect .lbl { font-size:11px; letter-spacing:3px; text-transform:uppercase; opacity:.6; margin-bottom:10px; }
  .modes { display:flex; gap:16px; justify-content:center; }
  .mode { padding:12px 26px; border:1px solid rgba(110,170,255,.35); border-radius:8px; cursor:pointer; transition:.15s; background:rgba(15,30,60,.5);}
  .mode:hover { border-color:#7fc0ff; }
  .mode.sel { border-color:#ffd76a; background:rgba(80,70,20,.45); box-shadow:0 0 18px rgba(255,215,106,.3); }
  .mode b { display:block; font-size:15px; color:#fff; }
  .mode span { font-size:11px; opacity:.65; }
  .bigbtn { margin-top:8px; padding:14px 64px; font-size:18px; letter-spacing:6px; cursor:pointer;
    border:none; border-radius:6px; color:#04101f; font-weight:700;
    background:linear-gradient(180deg,#9fd8ff,#4f9de8); box-shadow:0 6px 24px rgba(70,150,240,.45); transition:.15s;}
  .bigbtn:hover { filter:brightness(1.15); transform:translateY(-2px); }
  .keys { margin-top:26px; font-size:12px; opacity:.55; line-height:1.9; text-align:center; column-count:2; column-gap:44px;}
  kbd { background:rgba(255,255,255,.12); border-radius:4px; padding:1px 7px; font-family:inherit; color:#bfe0ff;}

  /* ---------- In-game overlays ---------- */
  #topmsg { position:absolute; top:14%; left:0; right:0; text-align:center; z-index:10; pointer-events:none;}
  #topmsg .m1 { font-size:34px; font-weight:300; letter-spacing:8px; color:#fff; text-shadow:0 0 18px rgba(120,190,255,.8);}
  #topmsg .m2 { font-size:14px; letter-spacing:3px; opacity:.8; margin-top:6px; color:#a8d4ff;}
  #pausebtn { position:absolute; top:14px; right:16px; z-index:11; padding:6px 16px; font-size:12px; letter-spacing:2px;
    background:rgba(10,25,50,.6); border:1px solid rgba(120,180,255,.4); color:#cfe8ff; border-radius:5px; cursor:pointer;}
  #result { z-index:25; }
  #result h2 { font-size:42px; font-weight:300; letter-spacing:8px; margin-bottom:14px; color:#fff;}
  #result .scoreline { font-size:16px; opacity:.85; margin:4px 0; }
  #loading { z-index:30; font-size:14px; letter-spacing:4px; }
</style>
</head>
<body>
<div id="app"></div>

<!-- ============ MAIN MENU ============ -->
<div class="screen" id="menu">
  <h1 class="logo">APEX <b>AERO</b></h1>
  <div class="sub">Physics-driven flight simulator</div>
  <div class="sect"><div class="lbl">Select Aircraft</div><div class="cards" id="fleet"></div></div>
  <div class="sect"><div class="lbl">Mission</div>
    <div class="modes" id="modes">
      <div class="mode sel" data-m="takeoff"><b>TAKEOFF &amp; LANDING</b><span>Precision flight · runway ops</span></div>
      <div class="mode" data-m="dogfight"><b>DOGFIGHT</b><span>High-G combat · air-to-air</span></div>
    </div>
  </div>
  <button class="bigbtn" id="startBtn">FLY</button>
  <div class="keys">
    <div><kbd>W</kbd>/<kbd>S</kbd> Pitch &nbsp; <kbd>A</kbd>/<kbd>D</kbd> Roll</div>
    <div><kbd>Q</kbd>/<kbd>E</kbd> Rudder (yaw)</div>
    <div><kbd>Shift</kbd>/<kbd>Ctrl</kbd> Throttle up / down</div>
    <div><kbd>G</kbd> Gear &nbsp; <kbd>B</kbd> Brakes &nbsp; <kbd>C</kbd> Camera</div>
    <div><kbd>Space</kbd> Fire guns (dogfight)</div>
    <div><kbd>Esc</kbd> Pause</div>
  </div>
</div>

<!-- ============ RESULT SCREEN ============ -->
<div class="screen hidden" id="result">
  <h2 id="resTitle">MISSION COMPLETE</h2>
  <div id="resBody" style="text-align:center; max-width:520px;"></div>
  <button class="bigbtn" id="againBtn">FLY AGAIN</button>
</div>

<div class="screen hidden" id="loading">GENERATING TERRAIN…</div>
<div id="topmsg"></div>
<button id="pausebtn" class="hidden">PAUSE</button>

<script src="https://unpkg.com/three@0.128.0/build/three.min.js"></script>
<script>
"use strict";
/* =====================================================================
   APEX AERO — single-file flight simulator
   Sections: math/noise · fleet defs · atmosphere · physics · terrain
             aircraft meshes · audio · input · HUD · missions · loop
===================================================================== */

/* ---------------- math helpers ---------------- */
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const V3=THREE.Vector3;
const Q=THREE.Quaternion;

// deterministic value noise + fbm
function makeNoise(seed){
  const p=new Uint8Array(512);
  let s=seed>>>0;
  const rnd=()=>{s^=s<<13;s>>>=0;s^=s>>17;s^=s<<5;s>>>=0;return s/4294967296;};
  const perm=[...Array(256).keys()];
  for(let i=255;i>0;i--){const j=Math.floor(rnd()*(i+1));[perm[i],perm[j]]=[perm[j],perm[i]];}
  for(let i=0;i<512;i++)p[i]=perm[i&255];
  const fade=t=>t*t*t*(t*(t*6-15)+10);
  const grad=(h,x,y)=>{switch(h&3){case 0:return x+y;case 1:return -x+y;case 2:return x-y;default:return -x-y;}};
  return function(x,y){
    const X=Math.floor(x)&255,Y=Math.floor(y)&255;
    x-=Math.floor(x); y-=Math.floor(y);
    const u=fade(x),v=fade(y);
    const a=p[p[X]+Y],b=p[p[X+1]+Y],c=p[p[X]+Y+1],d=p[p[X+1]+Y+1];
    return lerp(lerp(grad(a,x,y),grad(b,x-1,y),u),lerp(grad(c,x,y-1),grad(d,x-1,y-1),u),v)*0.7;
  };
}
function fbm(n,x,y,oct,lac,gain){
  let f=0,a=1,fr=1,tot=0;
  for(let i=0;i<oct;i++){f+=a*n(x*fr,y*fr);tot+=a;a*=gain;fr*=lac;}
  return f/tot;
}

/* ---------------- atmosphere ---------------- */
const RHO0=1.225, H_SCALE=8500, SOUND0=340;
function airDensity(h){ return RHO0*Math.exp(-Math.max(h,0)/H_SCALE); }
function speedOfSound(h){ return SOUND0*Math.sqrt(Math.max(.5,1-6.5e-3*h/288.15)); }

/* ---------------- fleet ---------------- */
// SI units. mass kg, thrust N, wing area m², CLmax etc.
const FLEET=[
 {
  name:"SF-88 \"Vortex\"", role:"Air-superiority fighter",
  desc:"Twin-engine delta fighter. Brutal climb, 9G airframe, twitchy roll.",
  stats:{spd:"Mach 1.6", twr:"1.18", turn:"★★★★★", size:"Small"},
  mass:9500, thrustMax:112000, thrustAB:true, wingArea:28, wingspan:9.5,
  CLalpha:5.2, CLmax:1.55, CD0:0.021, oswald:0.82,
  rollRate:5.2, pitchRate:2.6, yawRate:0.8,
  response:{roll:7,pitch:6,yaw:5},     // actuator responsiveness (1/s)
  maxG:9, gearH:1.9, vStallClean:62,
  color:0x5a6d84, accent:0x2b3746, scale:0.9,
 },
 {
  name:"T-7 \"Meridian\"", role:"Advanced trainer",
  desc:"Forgiving but honest. Balanced energy, clean stall behaviour.",
  stats:{spd:"Mach 0.9", twr:"0.72", turn:"★★★☆☆", size:"Medium"},
  mass:6500, thrustMax:46000, thrustAB:false, wingArea:19, wingspan:9.8,
  CLalpha:5.6, CLmax:1.5, CD0:0.023, oswald:0.8,
  rollRate:3.6, pitchRate:2.0, yawRate:0.7,
  response:{roll:5.5,pitch:4.5,yaw:4},
  maxG:7.5, gearH:1.7, vStallClean:58,
  color:0xd8dde2, accent:0x38506b, scale:0.85,
 },
 {
  name:"AC-40 \"Behemoth\"", role:"Heavy strike platform",
  desc:"Massive inertia. Slow hands, huge payload of momentum.",
  stats:{spd:"Mach 0.75", twr:"0.55", turn:"★☆☆☆☆", size:"Large"},
  mass:22000, thrustMax:118000, thrustAB:false, wingArea:52, wingspan:16.5,
  CLalpha:6.0, CLmax:1.7, CD0:0.026, oswald:0.78,
  rollRate:1.5, pitchRate:0.95, yawRate:0.45,
  response:{roll:2.6,pitch:2.6,yaw:2.2},
  maxG:5, gearH:2.6, vStallClean:66,
  color:0x49543f, accent:0x2c3327, scale:1.35,
 }
];

/* ---------------- world constants ---------------- */
const WORLD=9000;            // half-extent of terrain
const RUNWAY={x:0,z:3200,len:2600,wid:44,hdg:Math.PI}; // runway along -Z direction

/* ---------------- terrain ---------------- */
let terrainFn=null; // (x,z)->height
function buildTerrainFn(){
  const n1=makeNoise(1337), n2=makeNoise(777), n3=makeNoise(2024);
  return function(x,z){
    // gentle coastal falloff far east
    let base = fbm(n1,x*0.00016,z*0.00016,5,2.05,0.5);       // continents
    let ridge = Math.pow(Math.abs(fbm(n2,x*0.0006,z*0.0006,4,2.1,0.5)),1.4); // ridges
    let detail = fbm(n3,x*0.003,z*0.003,3,2.0,0.5);
    let h = base*900 + ridge*base*1400*(0.4+detail*0.6) + detail*40;
    h += 380*fbm(n2,x*0.00008,z*0.00008,2,2,0.5)+250;
    // flatten runway corridor
    const dx=x-RUNWAY.x, dz=z-RUNWAY.z;
    const along=dz/RUNWAY.len, lat=dx/RUNWAY.wid;
    const corr=Math.max(0,1-Math.max(Math.abs(along)-0.75,Math.abs(lat))*3);
    if(corr>0){ h=lerp(h,0.0,clamp(corr,0,1)); }
    return h;
  };
}

function buildTerrainMesh(){
  const SEG=196, geo=new THREE.PlaneGeometry(WORLD*2,WORLD*2,SEG,SEG);
  geo.rotateX(-Math.PI/2);
  const pos=geo.attributes.position, count=pos.count;
  const colA=new THREE.Color(0x3e5a33),colB=new THREE.Color(0x6b6f57),
        rock=new THREE.Color(0x5d5a54),snow=new THREE.Color(0xeef2f5),
        sand=new THREE.Color(0xb9a06b),sea=new THREE.Color(0x14435e);
  const colors=new Float32Array(count*3), c=new THREE.Color();
  for(let i=0;i<count;i++){
    const x=pos.getX(i), z=pos.getZ(i);
    const h=terrainFn(x,z);
    pos.setY(i,h);
    if(h<-2) c.copy(sea);
    else if(h<12) c.copy(sand);
    else if(h<420) c.copy(colA).lerp(colB,(h-12)/408);
    else if(h<900) c.copy(colB).lerp(rock,(h-420)/480);
    else c.copy(rock).lerp(snow,clamp((h-900)/500,0,1));
    c.convertSRGBToLinear();
    colors[i*3]=c.r;colors[i*3+1]=c.g;colors[i*3+2]=c.b;
  }
  geo.setAttribute('color',new THREE.BufferAttribute(colors,3));
  geo.computeVertexNormals();
  const mat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.94,metalness:0.02});
  const mesh=new THREE.Mesh(geo,mat);
  mesh.receiveShadow=false;
  scene.add(mesh);

  // sea plane
  const seaG=new THREE.PlaneGeometry(WORLD*2,WORLD*2);
  seaG.rotateX(-Math.PI/2);
  const seaM=new THREE.MeshStandardMaterial({color:0x175a7a,roughness:0.25,metalness:0.35,transparent:true,opacity:0.92});
  const seaMesh=new THREE.Mesh(seaG,seaM); seaMesh.position.y=-6; scene.add(seaMesh);

  // runway
  const rwG=new THREE.PlaneGeometry(RUNWAY.wid,RUNWAY.len);
  rwG.rotateX(-Math.PI/2);
  const rwM=new THREE.MeshStandardMaterial({color:0x2c2f33,roughness:0.9});
  const rw=new THREE.Mesh(rwG,rwM); rw.position.set(RUNWAY.x,0.12,RUNWAY.z); scene.add(rw);
  // centerline dashes
  const dashM=new THREE.MeshStandardMaterial({color:0xdddddd,roughness:0.8});
  for(let i=0;i<20;i++){
    const d=new THREE.Mesh(new THREE.PlaneGeometry(1.4,30),dashM);
    d.rotation.x=-Math.PI/2;
    d.position.set(RUNWAY.x,0.14,RUNWAY.z-RUNWAY.len/2+65+i*((RUNWAY.len-130)/19));
    scene.add(d);
  }
}

/* ---------------- sky & light ---------------- */
function buildSky(){
  scene.background=new THREE.Color(0x86b8e8);
  scene.fog=new THREE.Fog(0xa8cbe8,3500,16000);
  const hemi=new THREE.HemisphereLight(0xbfd9ff,0x54503c,0.75); scene.add(hemi);
  const sun=new THREE.DirectionalLight(0xfff2dc,1.35);
  sun.position.set(-0.4,0.8,-0.5).multiplyScalar(4000); scene.add(sun);
  scene.add(new THREE.AmbientLight(0x304050,0.25));
  // simple sun disc sprite via circle
  const sd=new THREE.Mesh(new THREE.CircleGeometry(260,32),new THREE.MeshBasicMaterial({color:0xfff6dd,fog:false}));
  sd.position.copy(sun.position).setLength(18000); sd.lookAt(0,0,0); scene.add(sd);
}

/* ---------------- renderer / scene globals ---------------- */
let renderer,scene,camera,hud,hudCtx;
let planeMesh=null,chaseCam=true,camPos=new V3();
function initGL(){
  renderer=new THREE.WebGLRenderer({antialias:true});
  renderer.setPixelRatio(Math.min(devicePixelRatio,1.75));
  renderer.setSize(innerWidth,innerHeight);
  renderer.outputEncoding=THREE.sRGBEncoding;
  renderer.toneMapping=THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure=1.05;
  document.getElementById('app').appendChild(renderer.domElement);
  renderer.domElement.id='gl';
  scene=new THREE.Scene();
  camera=new THREE.PerspectiveCamera(62,innerWidth/innerHeight,0.5,30000);
  hud=document.createElement('canvas'); hud.id='hud';
  hud.width=innerWidth; hud.height=innerHeight;
  document.getElementById('app').appendChild(hud);
  hudCtx=hud.getContext('2d');
  addEventListener('resize',()=>{
    camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
    renderer.setSize(innerWidth,innerHeight);
    hud.width=innerWidth; hud.height=innerHeight;
  });
}

/* ---------------- aircraft meshes (procedural, unique silhouettes) ---------------- */
function mat(c,r=0.55,m=0.35){return new THREE.MeshStandardMaterial({color:c,roughness:r,metalness:m});}
function buildFighter(def){ // twin-tail delta
  const g=new THREE.Group(), body=mat(def.color), dark=mat(def.accent,0.6,0.3);
  const fus=new THREE.Mesh(new THREE.CylinderGeometry(0.55,0.85,9.5,10),body);
  fus.rotation.x=Math.PI/2; g.add(fus);
  const nose=new THREE.Mesh(new THREE.ConeGeometry(0.55,3.4,10),dark);
  nose.rotation.x=-Math.PI/2; nose.position.z=-6.4; g.add(nose);
  const canopy=new THREE.Mesh(new THREE.SphereGeometry(0.62,12,10),mat(0x223a55,0.15,0.7));
  canopy.scale.set(0.85,0.7,2.1); canopy.position.set(0,0.42,-2.4); g.add(canopy);
  // delta wing
  const wShape=new THREE.Shape();
  wShape.moveTo(0,-3.2);wShape.lineTo(4.8,3.4);wShape.lineTo(0.5,3.8);wShape.lineTo(0,3.8);
  const wG=new THREE.ExtrudeGeometry(wShape,{depth:0.16,bevelEnabled:false});
  const wl=new THREE.Mesh(wG,body); wl.rotation.x=Math.PI/2; wl.position.y=0; g.add(wl);
  const wr=wl.clone(); wr.scale.x=-1; g.add(wr);
  // twin vertical tails
  for(const s of[-1,1]){
    const t=new THREE.Mesh(new THREE.BoxGeometry(0.14,2.0,2.6),dark);
    t.position.set(s*1.5,1.0,3.4); t.rotation.x=0.25; g.add(t);
  }
  // horizontal stabs
  for(const s of[-1,1]){
    const st=new THREE.Mesh(new THREE.BoxGeometry(2.0,0.12,1.4),dark);
    st.position.set(s*1.6,0,3.9); g.add(st);
  }
  // intakes + engines
  for(const s of[-1,1]){
    const it=new THREE.Mesh(new THREE.BoxGeometry(0.8,0.7,2.4),dark);
    it.position.set(s*1.05,-0.15,1.2); g.add(it);
    const noz=new THREE.Mesh(new THREE.CylinderGeometry(0.45,0.38,1.0,10),mat(0x333333,0.4,0.8));
    noz.rotation.x=Math.PI/2; noz.position.set(s*0.55,0,5.2); g.add(noz);
  }
  return {group:g,nozzles:g.children.filter(c=>c.geometry&&c.geometry.type==='CylinderGeometry')};
}
function buildTrainer(def){ // conventional jet trainer
  const g=new THREE.Group(), body=mat(def.color), dark=mat(def.accent,0.6,0.3);
  const fus=new THREE.Mesh(new THREE.CylinderGeometry(0.6,0.6,9.0,10),body);
  fus.rotation.x=Math.PI/2; g.add(fus);
  const nose=new THREE.Mesh(new THREE.SphereGeometry(0.6,12,10),dark);
  nose.scale.set(1,1,2.2); nose.position.z=-4.5; g.add(nose);
  const canopy=new THREE.Mesh(new THREE.SphereGeometry(0.66,12,10),mat(0x27405c,0.15,0.7));
  canopy.scale.set(0.9,0.8,2.4); canopy.position.set(0,0.5,-1.4); g.add(canopy);
  // straight tapered wings with slight dihedral
  for(const s of[-1,1]){
    const shp=new THREE.Shape();
    shp.moveTo(0,-2.4);shp.lineTo(4.9,3.0);shp.lineTo(4.9,3.7);shp.lineTo(0,3.6);
    const wg=new THREE.ExtrudeGeometry(shp,{depth:0.18,bevelEnabled:false});
    const w=new THREE.Mesh(wg,body); w.rotation.x=Math.PI/2; w.rotation.z=s*0.04;
    w.rotation.order='ZXY'; g.add(w); if(s>0)w.scale.x=-1;
  }
  const fin=new THREE.Mesh(new THREE.BoxGeometry(0.16,2.4,2.8),dark);
  fin.position.set(0,1.2,3.6); fin.rotation.x=0.32; g.add(fin);
  const stab=new THREE.Mesh(new THREE.BoxGeometry(4.2,0.14,1.3),dark);
  stab.position.set(0,0.1,4.2); g.add(stab);
  const noz=new THREE.Mesh(new THREE.CylinderGeometry(0.5,0.42,1.1,10),mat(0x333333,0.4,0.8));
  noz.rotation.x=Math.PI/2; noz.position.set(0,0,4.9); g.add(noz);
  return {group:g,nozzles:[noz]};
}
function buildHeavy(def){ // big twin-boom-ish strike platform
  const g=new THREE.Group(), body=mat(def.color), dark=mat(def.accent,0.65,0.25);
  const fus=new THREE.Mesh(new THREE.CylinderGeometry(1.0,1.2,13,12),body);
  fus.rotation.x=Math.PI/2; g.add(fus);
  const nose=new THREE.Mesh(new THREE.SphereGeometry(1.0,14,12),dark);
  nose.scale.set(1,1,2.4); nose.position.z=-6.5; g.add(nose);
  const cockpit=new THREE.Mesh(new THREE.SphereGeometry(0.85,12,10),mat(0x24384f,0.15,0.7));
  cockpit.scale.set(1,0.85,2.2); cockpit.position.set(0,0.75,-3.6); g.add(cockpit);
  // wide swept wings
  for(const s of[-1,1]){
    const shp=new THREE.Shape();
    shp.moveTo(0,-4.5);shp.lineTo(8.2,4.6);shp.lineTo(8.2,5.6);shp.lineTo(0,5.4);
    const wg=new THREE.ExtrudeGeometry(shp,{depth:0.34,bevelEnabled:false});
    const w=new THREE.Mesh(wg,body); w.rotation.x=Math.PI/2; g.add(w); if(s>0)w.scale.x=-1;
    // engine pods under wings
    for(const e of[3.2,6.2]){
      const pod=new THREE.Mesh(new THREE.CylinderGeometry(0.62,0.52,2.6,10),dark);
      pod.rotation.x=Math.PI/2; pod.position.set(s*e,-0.7,1.4); g.add(pod);
      const fan=new THREE.Mesh(new THREE.CircleGeometry(0.56,12),mat(0x111111,0.5,0.6));
      fan.position.set(s*e,-0.7,0.1); g.add(fan);
    }
  }
  const fin=new THREE.Mesh(new THREE.BoxGeometry(0.26,3.4,3.6),dark);
  fin.position.set(0,2.0,5.2); fin.rotation.x=0.35; g.add(fin);
  const stab=new THREE.Mesh(new THREE.BoxGeometry(7.0,0.2,1.9),dark);
  stab.position.set(0,0.2,6.0); g.add(stab);
  return {group:g,nozzles:[]};
}
const BUILDERS=[buildFighter,buildTrainer,buildHeavy];

/* ---------------- landing gear ---------------- */
function buildGear(def){
  const g=new THREE.Group(), m=mat(0x9aa2ab,0.4,0.7), ty=mat(0x181818,0.95,0);
  function leg(x,z,len){
    const l=new THREE.Mesh(new THREE.CylinderGeometry(0.09,0.09,len,6),m);
    l.position.set(x,-len/2,z); g.add(l);
    const wheel=new THREE.Mesh(new THREE.CylinderGeometry(len*0.36,len*0.36,0.28,12),ty);
    wheel.rotation.z=Math.PI/2; wheel.position.set(x,-len,z); g.add(wheel);
  }
  leg(-def.wingspan*0.18,0.6,def.gearH); leg(def.wingspan*0.18,0.6,def.gearH);
  leg(0,-def.wingspan*0.35,def.gearH*0.9);
  return g;
}

/* =====================================================================
   FLIGHT DYNAMICS MODEL
   Body frame: +x right, +y up, -z forward (three.js convention).
   Forces integrated in world space; angular rates in body space.
===================================================================== */
class Aircraft{
  constructor(def){
    this.def=def;
    this.pos=new V3(); this.vel=new V3();
    this.quat=new Q();
    // body-frame angular velocity (rad/s): x=pitch, y=yaw, z=roll
    this.w=new V3();
    this.throttle=0; this.rpm=0;
    this.gearDown=true; this.brakes=false;
    this.onGround=true; this.groundY=0;
    this.stalled=false; this.gLoad=1;
    this.aoa=0; this.beta=0; this.IAS=0; this.mach=0;
    this.alive=true;
    // moment of inertia approximation (box, kg·m²)
    const m=def.mass,L=def.wingspan;
    this.I=new V3(m*L*L/14, m*L*L/9, m*L*L/4.2); // pitch, yaw, roll axes
    this.elev=0;this.ail=0;this.rud=0; // smoothed control surfaces
  }
  get forward(){ return new V3(0,0,-1).applyQuaternion(this.quat); }
  get up(){ return new V3(0,1,0).applyQuaternion(this.quat); }
  get right(){ return new V3(1,0,0).applyQuaternion(this.quat); }

  update(dt,input){
    const d=this.def;
    if(!this.alive)return;

    /* --- engine: spool dynamics --- */
    const tgtRPM=input.throttle;
    const spool=(tgtRPM>this.rpm? (d.thrustAB?1.6:1.1) : 0.8)/d.response.yaw+0.55;
    this.rpm+=clamp(tgtRPM-this.rpm,-spool*dt,spool*dt);
    // thrust with altitude lapse & afterburner
    let thrust=this.rpm*d.thrustMax*(airDensity(this.pos.y)/RHO0);
    if(d.thrustAB&&this.rpm>0.95)thrust*=1.28;

    /* --- aero frame --- */
    const fwd=this.forward, up=this.up, right=this.right;
    const vAir=this.vel.clone().sub(WIND);
    const speed=vAir.length();
    const rho=airDensity(this.pos.y);
    this.IAS=speed*Math.sqrt(rho/RHO0);
    this.mach=speed/speedOfSound(this.pos.y);

    // angle of attack & sideslip
    const vB=vAir.clone().applyQuaternion(this.quat.clone().invert()); // body frame
    this.aoa=-Math.atan2(vB.y,-vB.z);
    this.beta=Math.atan2(vB.x,-vB.z);

    const q_dyn=0.5*rho*speed*speed; // dynamic pressure
    const S=d.wingArea;

    /* --- lift curve with stall --- */
    const aRad=this.aoa;
    const aStall=d.CLmax/d.CLalpha;
    let CL;
    if(Math.abs(aRad)<=aStall) CL=d.CLalpha*aRad;
    else { // post-stall: sharp but not instant loss
      const over=Math.abs(aRad)-aStall;
      CL=Math.sign(aRad)*(d.CLmax-over*2.2);
      CL=Math.max(CL,d.CLmax*0.35*Math.sign(aRad));
      this.stalled=Math.abs(CL)<d.CLmax*0.72&&speed>12;
    }
    if(!this.alive)CL=0;
    this.stalled=Math.abs(aRad)>aStall&&speed>10;
    CL*=Math.cos(this.beta)*Math.cos(this.beta); // slip reduces lift

    /* --- drag: parasite + induced + wave --- */
    let CD=d.CD0 + (CL*CL)/(Math.PI*d.oswald*(d.wingspan*d.wingspan/S));
    CD+=gearDrag(this)*0.02;
    if(this.mach>0.82)CD+=0.045*Math.pow(this.mach-0.82,2)*38; // wave drag rise
    if(this.stalled)CD+=0.06;
    const Fdrag=q_dyn*S*CD;

    /* --- forces (world space) --- */
    const F=new V3();
    F.addScaledVector(fwd,thrust);
    F.addScaledVector(vAir.clone().normalize(),-Fdrag);
    F.addScaledVector(up,q_dyn*S*CL);
    // sideforce from fuselage/slip
    F.addScaledVector(right,-q_dyn*S*0.35*this.beta);
    // gravity
    F.y-=d.mass*9.81;
    // ground normal handled below via contact

    /* --- control surfaces -> commanded rates (rate-command w/ damping) --- */
    const ctlEff=clamp(q_dyn/(rho*0.5*140*140),0,1.0); // authority grows w/ q
    const sur=this.surfaces;
    this.elev=lerp(this.elev,input.pitch,clamp(d.response.pitch*dt*3,0,1));
    this.ail =lerp(this.ail ,input.roll ,clamp(d.response.roll *dt*3,0,1));
    this.rud =lerp(this.rud ,input.yaw  ,clamp(d.response.yaw  *dt*3,0,1));

    const cmdP=this.ail *d.rollRate *ctlEff;
    // soft AoA limiter: elevator authority fades as alpha approaches stall
    const aStallL=d.CLmax/d.CLalpha;
    const aoaLim=clamp(1-Math.max(0,Math.abs(aRad)-aStallL*0.85)/(aStallL*0.6),0.12,1);
    const cmdQ=this.elev*d.pitchRate*ctlEff*aoaLim;
    const cmdR=this.rud *d.yawRate  *ctlEff;
    // first-order rate response toward command (actuator lag)
    const kRoll=clamp(d.response.roll*dt,0,1), kPitch=clamp(d.response.pitch*dt,0,1), kYaw=clamp(d.response.yaw*dt,0,1);
    this.w.z+=(cmdP-this.w.z)*kRoll;
    this.w.x+=(cmdQ-this.w.x)*kPitch;
    this.w.y+=(cmdR-this.w.y)*kYaw;

    // static stability: blend rate targets (relaxation, not accumulation)
    // trim AoA from required lift -> natural speed stability
    const CLreq=(d.mass*9.81)/Math.max(q_dyn*S,1);
    const aTrim=clamp(CLreq/d.CLalpha,-0.15,0.3);
    const stabP=-(aRad-aTrim)*clamp(q_dyn/(rho*20000),0,1.2)*1.6;
    const stabY=-this.beta*clamp(q_dyn/(rho*26000),0,1.4)*1.5;
    this.w.x+=((cmdQ+stabP)-this.w.x)*clamp(dt*3,0,1);
    this.w.y+=((cmdR+stabY)-this.w.y)*clamp(dt*3,0,1);
    // hard AoA governor: beyond stall alpha, pitch rate is driven back
    if(Math.abs(aRad)>aStallL*0.95){
      const ex=aRad-Math.sign(aRad)*aStallL*0.95;
      this.w.x=lerp(this.w.x,-Math.sign(aRad)*clamp(ex*8,0,1.2),clamp(dt*6,0,1));
    }

    // stall departure: random-ish roll/yaw break
    if(this.stalled&&speed>15){
      this.w.z+=(Math.sin(perfT*7)+Math.sin(perfT*13.7))*0.35*dt*30;
      this.w.x-=0.25*dt*10;
    }

    // integrate orientation
    const dq=new Q(this.w.x*dt/2,this.w.y*dt/2,this.w.z*dt/2,1).normalize();
    this.quat.multiply(dq).normalize();

    /* --- G-load calc (for HUD / limits) --- */
    const accAero=F.clone(); accAero.y-=d.mass*9.81; // minus gravity only
    this.gLoad=this.onGround?1:(accAero.dot(up)/(d.mass*9.81)||1);

    /* --- G-limit structural failure --- */
    if(Math.abs(this.gLoad)>d.maxG){ this.overG=(this.overG||0)+dt; if(this.overG>2.2)this.crash("STRUCTURAL FAILURE — exceeded "+d.maxG+"G"); }
    else this.overG=Math.max(0,(this.overG||0)-dt*0.5);

    /* --- integrate linear motion --- */
    this.vel.addScaledVector(F,dt/d.mass);

    /* --- ground interaction --- */
    const gY=terrainFn(this.pos.x,this.pos.z);
    const gearClearance=this.gearDown?d.gearH:d.gearH*0.45;
    const belly=gY+gearClearance;
    if(this.pos.y<=belly+0.05){
      const wasGround=this.onGround;
      this.onGround=true; this.groundY=gY;
      const upWorld=new V3(0,1,0);
      const slopeOK=true;
      const vertV=this.vel.y, hV=Math.hypot(this.vel.x,this.vel.z);
      const gearAligned=this.gearDown&&this.checkGear(belly);
      if(!wasGround){
        const sink=vertV;
        const attitudeBad=this.up.dot(upWorld)<0.86;
        if(sink<-8||attitudeBad||!gearAligned){ this.crash(gearAligned?(sink<-8?"HARD IMPACT — "+sink.toFixed(1)+" m/s descent":"CRASHED — terrain impact"):"GEAR-UP / BAD ATTITUDE LANDING"); return; }
        touchScore(hV,sink,hV);
      }
      this.pos.y=belly;
      if(this.vel.y<0)this.vel.y=0;
      // rolling friction + brakes
      const N=d.mass*9.81;
      let mu=0.02+(this.brakes?0.38:0);
      const fric=hV>0.01?mu*N:0;
      this.vel.addScaledVector(this.vel.clone().setY(0).normalize(),-fric*dt/d.mass);
      // level the plane on ground
      const eu=new THREE.Euler().setFromQuaternion(this.quat,'YXZ');
      eu.x=lerp(eu.x,0,clamp(dt*4,0,1)); eu.z=lerp(eu.z,0,clamp(dt*4,0,1));
      this.quat.setFromEuler(eu);
      this.w.multiplyScalar(clamp(1-dt*5,0,1));
    } else {
      this.onGround=false;
    }
    // terrain collision mid-air
    if(this.pos.y<gY+0.6&&!this.onGround){ this.crash("CONTROLLED FLIGHT INTO TERRAIN"); return; }

    this.pos.addScaledVector(this.vel,dt);
    // world bounds soft wall
    const r=Math.hypot(this.pos.x,this.pos.z);
    if(r>WORLD*0.94){ const s=WORLD*0.94/r; this.pos.x*=s;this.pos.z*=s;
      this.vel.reflect(new V3(-this.pos.x,0,-this.pos.z).normalize()).multiplyScalar(0.6); }
  }
  checkGear(bellyY){
    // upright & wings near-level required for a good touchdown
    return this.up.y>0.9;
  }
  crash(reason){
    this.alive=false;
    missionFail(reason);
  }
}
let WIND=new V3(), perfT=0;
function gearDrag(ac){return ac.gearDown?1:0;}

/* ---------------- bullets ---------------- */
const bullets=[];
const bulletGeo=new THREE.SphereGeometry(0.22,6,6);
const bulletMat=new THREE.MeshBasicMaterial({color:0xffd76a});
function fireBullet(ac){
  const m=new THREE.Mesh(bulletGeo,bulletMat);
  m.position.copy(ac.pos).addScaledVector(ac.forward,ac.def.wingspan*0.6);
  bullets.push({mesh:m,vel:ac.vel.clone().addScaledVector(ac.forward,900),life:3});
  scene.add(m);
}

/* =====================================================================
   AUDIO — procedural WebAudio, driven by physics state
===================================================================== */
let AC=null,nodes=null;
function initAudio(){
  if(AC)return;
  AC=new (window.AudioContext||window.webkitAudioContext)();
  const master=AC.createGain(); master.gain.value=0.7; master.connect(AC.destination);
  // engine: two detuned saw oscillators through lowpass (jet whine + rumble)
  const eg=AC.createGain(); eg.gain.value=0;
  const lp=AC.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=800;
  const o1=AC.createOscillator(),o2=AC.createOscillator();
  o1.type='sawtooth'; o2.type='sawtooth'; o2.detune.value=14;
  const oGain=AC.createGain(); oGain.gain.value=0.5;
  o1.connect(oGain);o2.connect(oGain);oGain.connect(lp);lp.connect(eg);eg.connect(master);
  o1.start();o2.start();
  // whine (high tone scaled with rpm)
  const wOsc=AC.createOscillator(); wOsc.type='triangle';
  const wg=AC.createGain(); wg.gain.value=0;
  wOsc.connect(wg);wg.connect(master);wOsc.start();
  // airflow noise: looped noise buffer -> bandpass
  const bufLen=AC.sampleRate*2, nb=AC.createBuffer(1,bufLen,AC.sampleRate);
  const dch=nb.getChannelData(0);
  for(let i=0;i<bufLen;i++)dch[i]=Math.random()*2-1;
  function noiseSrc(){const s=AC.createBufferSource();s.buffer=nb;s.loop=true;s.start();return s;}
  const wind=noiseSrc(), wf=AC.createBiquadFilter(); wf.type='bandpass'; wf.Q.value=0.7;
  const windG=AC.createGain(); windG.gain.value=0;
  wind.connect(wf);wf.connect(windG);windG.connect(master);
  // stall buffet: lowpass noise bursts
  const buff=noiseSrc(), bf=AC.createBiquadFilter(); bf.type='lowpass'; bf.frequency.value=140;
  const bg=AC.createGain(); bg.gain.value=0;
  buff.connect(bf);bf.connect(bg);bg.connect(master);
  // gun
  nodes={master,eg,lp,o1,o2,wOsc,wg,windG,wf,bg};
}
function updateAudio(ac){
  if(!nodes||!ac||!ac.alive)return;
  const t=AC.currentTime, rhoF=Math.sqrt(airDensity(ac.pos.y)/RHO0); // thin air = quieter engine
  const rpm=ac.rpm;
  nodes.o1.frequency.setTargetAtTime(40+rpm*95,t,0.08);
  nodes.o2.frequency.setTargetAtTime(41+rpm*97,t,0.08);
  nodes.lp.frequency.setTargetAtTime(300+rpm*2400,t,0.1);
  nodes.eg.gain.setTargetAtTime(rpm*0.24*rhoF+(rpm>0.95?0.06:0),t,0.1);
  nodes.wOsc.frequency.setTargetAtTime(600+rpm*1800+ac.mach*400,t,0.08);
  nodes.wg.gain.setTargetAtTime(rpm*0.05*rhoF,t,0.1);
  // aero noise ~ IAS^3-ish
  const qn=clamp((ac.IAS/320)**2,0,1.4);
  nodes.windG.gain.setTargetAtTime(qn*0.22*Math.min(1,rhoF+0.25),t,0.12);
  nodes.wf.frequency.setTargetAtTime(250+qn*1500+Math.abs(ac.aoa)*300,t,0.15);
  nodes.bg.gain.setTargetAtTime(ac.stalled?0.35:(Math.abs(ac.aoa)>0.16?0.12:0),t,0.06);
}
function gunSound(){
  if(!nodes)return;
  const g=AC.createGain(),o=AC.createOscillator();
  o.type='square';o.frequency.value=90+Math.random()*60;
  g.gain.setValueAtTime(0.18,AC.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+0.09);
  o.connect(g);g.connect(nodes.master);o.start();o.stop(AC.currentTime+0.1);
}

/* =====================================================================
   INPUT
===================================================================== */
const keys={};
addEventListener('keydown',e=>{
  keys[e.code]=true;
  if(e.code==='Escape')togglePause();
  if(['Space','ShiftLeft','ControlLeft','KeyW','KeyS'].includes(e.code))e.preventDefault();
});
addEventListener('keyup',e=>{keys[e.code]=false;});
function readInput(dt){
  const inp={pitch:0,roll:0,yaw:0,throttle:game.throttle,fire:false};
  if(keys.KeyW)inp.pitch+=1; if(keys.KeyS)inp.pitch-=1;
  if(keys.KeyA)inp.roll-=1;  if(keys.KeyD)inp.roll+=1;
  if(keys.KeyQ)inp.yaw+=1;   if(keys.KeyE)inp.yaw-=1;
  if(keys.ArrowUp)inp.pitch+=1; if(keys.ArrowDown)inp.pitch-=1;
  if(keys.ArrowLeft)inp.roll-=1; if(keys.ArrowRight)inp.roll+=1;
  if(keys.ShiftLeft||keys.ShiftRight)game.throttle=clamp(game.throttle+dt*0.45,0,1);
  if(keys.ControlLeft||keys.ControlRight)game.throttle=clamp(game.throttle-dt*0.55,0,1);
  if(keys.Space&&game.mode==='dogfight')inp.fire=true;
  if(keys.KeyG&&!game.gearLatch){game.gearLatch=true;player.gearDown=!player.gearDown;syncGear();}
  if(!keys.KeyG)game.gearLatch=false;
  player.brakes=!!keys.KeyB;
  return inp;
}

/* =====================================================================
   HUD (canvas overlay)
===================================================================== */
function drawHUD(ac){
  const c=hudCtx,W=hud.width,H=hud.height,cx=W/2,cy=H/2;
  c.clearRect(0,0,W,H);
  if(!ac||!ac.alive)return;
  c.shadowColor='rgba(0,15,35,0.85)';c.shadowBlur=4;c.shadowOffsetY=1;
  c.strokeStyle='rgba(140,220,255,0.9)';c.fillStyle='rgba(160,225,255,0.95)';
  c.lineWidth=1.5;c.font='13px Consolas,monospace';
  const gs=c.save();
  c.translate(cx,cy);
  // pitch ladder (rotate for roll)
  const eu=new THREE.Euler().setFromQuaternion(ac.quat,'YXZ');
  const pitch=eu.x, roll=-eu.z;
  c.rotate(roll);
  const ppd=H*0.9/(Math.PI*2)* -1; // px per rad
  for(let p=-80;p<=80;p+=10){
    {
      var yy=(p*Math.PI/180 - pitch)*ppd;
      if(Math.abs(yy)<H*0.42){
        c.globalAlpha=p%30===0?0.9:0.45;
        c.beginPath();
        const wid=p%30===0?70:36;
        c.moveTo(-wid,yy);c.lineTo(-14,yy);c.moveTo(14,yy);c.lineTo(wid,yy);
        c.stroke();
        if(p%30===0&&p!==0){c.fillText(p,wid+6,yy+4);c.fillText(p,-wid-26,yy+4);}
      }
    }
  }
  c.globalAlpha=1;
  c.rotate(-roll);
  // flight path marker
  if(ac.vel.length()>8){
    const vB=ac.vel.clone().applyQuaternion(ac.quat.clone().invert()).normalize();
    const fpx=vB.x/vB.z*-1*(H*0.85)/(Math.PI/2)*-1;
    const fpx_x=-vB.x/Math.max(0.2,-vB.z)*(H*0.85)/(Math.PI/2);
    const fpy=vB.y/Math.max(0.2,-vB.z)*(H*0.85)/(Math.PI/2);
    if(-vB.z>0){
      c.beginPath();c.arc(fpx_x,fpy,7,0,Math.PI*2);c.stroke();
      c.beginPath();c.moveTo(fpx_x-16,fpy);c.lineTo(fpx_x-7,fpy);c.moveTo(fpx_x+7,fpy);c.lineTo(fpx_x+16,fpy);c.moveTo(fpx_x,fpy-7);c.lineTo(fpx_x,fpy-12);c.stroke();
    }
  }
  // boresight
  c.beginPath();c.moveTo(-14,0);c.lineTo(-4,0);c.lineTo(0,5);c.lineTo(4,0);c.lineTo(14,0);c.stroke();
  c.restore();

  // left tape: airspeed ; right: altitude
  const ias=Math.round(ac.IAS*1.94384); // knots
  const alt=Math.round(ac.pos.y*3.28084); // ft
  tape(c,60,H,'IAS',ias,'kt',v=>v);
  tape(c,W-130,H,'ALT',alt,'ft',v=>Math.round(v/20)*20);
  // G meter / mach bottom left
  c.textAlign='left';
  c.fillText('G '+ac.gLoad.toFixed(1),60,H-64);
  c.fillText('M '+(ac.mach<0.05?'—':ac.mach.toFixed(2)),60,H-46);
  c.fillText(ac.stalled?'◤ STALL ◢':'',60,H-28);
  // throttle & gear bottom right
  c.textAlign='right';
  c.fillText('THR '+Math.round(game.throttle*100)+'%',W-60,H-64);
  c.fillText((player.rpm*100|0)+'% RPM',W-60,H-46);
  c.fillText(player.gearDown?'GEAR DOWN':'GEAR UP',W-60,H-28);
  // top center status
  c.textAlign='center';
  if(game.mode==='takeoff'){
    const m=mission;
    c.fillText(m.phase==='taxi'?'THROTTLE UP — ROTATE AT ~'+Math.round(player.def.vStallClean*1.15*1.94)+' KT':
      m.phase==='climb'?'CLIMB TO '+m.targetAlt+' M':'',cx,44);
  } else if(game.mode==='dogfight'){
    c.fillText('HOSTILES: '+enemies.filter(e=>e.ac.alive).length+'   KILLS: '+game.kills,cx,44);
  }
  // damage vignette on high G
  if(Math.abs(ac.gLoad)>ac.def.maxG*0.92){
    c.fillStyle='rgba(255,40,40,'+clamp((Math.abs(ac.gLoad)-ac.def.maxG*0.92)/ac.def.maxG,0,0.35)+')';
    c.fillRect(0,0,W,H);
  }
}
function tape(c,x,H,label,val,unit,res){
  const hMid=H/2;
  c.strokeStyle='rgba(140,220,255,0.85)';
  c.strokeRect(x,hMid-110,68,220);
  c.beginPath();c.moveTo(x,hMid);c.lineTo(x+10,hMid);c.moveTo(x+58,hMid);c.lineTo(x+68,hMid);c.stroke();
  c.fillStyle='rgba(200,240,255,1)';c.font='bold 17px Consolas,monospace';
  c.textAlign='center';c.fillText(val,x+34,hMid+6);
  c.font='11px Consolas,monospace';c.fillStyle='rgba(140,220,255,0.8)';
  c.fillText(label+' '+unit,x+34,hMid-118);
  // ticks
  const step=label==='IAS'?20:200;
  for(let d=-100;d<=100;d+=step){
    const tv=res(val+d);
    if(tv<0)continue;
    const yy=hMid-(d/step)*20;
    if(yy<hMid-108||yy>hMid+108)continue;
    c.fillRect(x+2,yy,6,1);
  }
}

/* =====================================================================
   ENEMY AI (dogfight)
===================================================================== */
const enemies=[];
function spawnEnemy(pos){
  const def=FLEET[0];
  const built=BUILDERS[0](def);
  built.group.traverse(o=>{if(o.material)o.material=o.material.clone();if(o.material&&'color'in o.material)o.material.color.setHex(0x7a3b3b);});
  scene.add(built.group);
  const ac=new Aircraft(def);
  ac.pos.copy(pos); ac.mesh=built.group; ac.isEnemy=true;
  ac.quat.identity();
  ac.vel.set(0,0,-160).applyQuaternion(ac.quat);
  ac.throttle=1;
  enemies.push({ac,mesh:built.group,skill:Math.random()*0.5+0.5,fireT:0});
}
function updateEnemy(e,dt){
  const ac=e.ac;
  if(!ac.alive){e.mesh.visible=false;return;}
  // pursue player with lead
  const toP=player.pos.clone().sub(ac.pos);
  const dist=toP.length();
  const dir=toP.normalize();
  // lead pursuit
  const tLead=dist/880;
  const aim=player.pos.clone().addScaledVector(player.vel,tLead).sub(ac.pos).normalize();
  if(dist<180)aim.negate(); // extend past
  // convert desired direction into body rates (simple proportional nav)
  const local=aim.clone().applyQuaternion(ac.quat.clone().invert());
  const inp={
    pitch:clamp(-local.y*3*e.skill,-1,1),
    roll: clamp(local.x*4*e.skill,-1,1),
    yaw:  clamp(-local.x*1.2,-1,1),
    throttle:dist<400?0.85:1,
  };
  ac.update(dt,{...inp,fire:false});
  // fire when roughly aligned & in range
  e.fireT-=dt;
  const fwdDot=ac.forward.dot(player.pos.clone().sub(ac.pos).normalize());
  if(dist<900&&fwdDot>0.9965&&e.fireT<=0){
    fireBullet(ac); gunSound(); e.fireT=0.12;
  }
  e.mesh.position.copy(ac.pos);
  e.mesh.quaternion.copy(ac.quat);
}
function bulletHits(){
  for(let i=bullets.length-1;i>=0;i--){
    const b=bullets[i];
    const p0=b.mesh.position.clone().addScaledVector(b.vel,-0.033); // prev frame pos
    const seg=b.mesh.position.clone().sub(p0), len2=seg.lengthSq();
    for(const e of enemies){
      if(!e.ac.alive)continue;
      const r=e.ac.def.wingspan*0.9;
      const t=clamp(p0.clone().sub(e.ac.pos).dot(seg)/Math.max(len2,1e-6),0,1);
      const closest=p0.clone().addScaledVector(seg,t);
      if(closest.distanceTo(e.ac.pos)<r){
        e.ac.alive=false; e.mesh.visible=false; game.kills++;
        scene.remove(b.mesh); bullets.splice(i,1);
        showMsg('TARGET DESTROYED','kills: '+game.kills+' / '+game.killsNeeded);
        return true;
      }
    }
  }
  return false;
}

/* =====================================================================
   MISSIONS
===================================================================== */
const game={mode:'takeoff',throttle:0,kills:0,killsNeeded:4,flying:false,paused:false,gearLatch:false};
let mission=null,player=null,rings=[],ringMeshes=[];

function startMission(fleetIdx,mode){
  initAudio();
  document.getElementById('menu').classList.add('hidden');
  document.getElementById('result').classList.add('hidden');
  document.getElementById('loading').classList.remove('hidden');
  setTimeout(()=>{
    if(!renderer)initGL();
    if(!terrainFn){ terrainFn=buildTerrainFn(); buildSky(); buildTerrainMesh(); }
    clearSceneEntities();
    const def=FLEET[fleetIdx];
    const built=BUILDERS[fleetIdx](def);
    scene.add(built.group);
    player=new Aircraft(def);
    player.mesh=built.group;
    player.gearMesh=buildGear(def); scene.add(player.gearMesh);
    syncGear();
    game.mode=mode; game.throttle=0; game.kills=0; game.paused=false; game.flying=true;
    topmsgEl.innerHTML='';
    if(mode==='takeoff'){
      // start at south end of runway, facing -Z
      player.pos.set(RUNWAY.x,def.gearH,RUNWAY.z+RUNWAY.len/2-120);
      player.quat.identity();
      mission={phase:'taxi',targetAlt:300,ringIdx:0,
        rings:[{z:RUNWAY.z-RUNWAY.len*0.55,y:120},{z:RUNWAY.z-RUNWAY.len*1.35,y:320}]};
      buildRings(mission.rings);
      showMsg('TAKEOFF','Full throttle · rotate ~'+Math.round(def.vStallClean*1.15*1.94)+' kt');
    } else {
      player.pos.set(RUNWAY.x,900,RUNWAY.z+600);
      player.quat.setFromEuler(new THREE.Euler(0,Math.PI*0.15,0));
      player.vel.set(0,0,-170).applyQuaternion(player.quat);
      game.throttle=1;player.throttle=1;
      for(let i=0;i<game.killsNeeded;i++){
        spawnEnemy(new V3(
          player.pos.x+(Math.random()-0.5)*1600,
          700+Math.random()*500,
          player.pos.z-1400-Math.random()*1200));
      }
      showMsg('DOGFIGHT',game.killsNeeded+' hostiles inbound');
    }
    camPos.copy(player.pos).addScaledVector(player.forward,18).addScaledVector(player.up,6);
    document.getElementById('loading').classList.add('hidden');
    document.getElementById('pausebtn').classList.remove('hidden');
    lastT=performance.now();
  },60);
}
function clearSceneEntities(){
  for(const b of bullets)scene.remove(b.mesh);
  bullets.length=0;
  for(const e of enemies)scene.remove(e.mesh);
  enemies.length=0;
  for(const r of ringMeshes)scene.remove(r);
  ringMeshes.length=0;
  if(player){scene.remove(player.mesh);scene.remove(player.gearMesh);}
  rings=[];
}
function buildRings(list){
  rings=list.map(r=>{
    const m=new THREE.Mesh(new THREE.TorusGeometry(60,4,10,36),
      new THREE.MeshBasicMaterial({color:0xffd76a}));
    m.position.set(RUNWAY.x,r.y,r.z);
    scene.add(m); ringMeshes.push(m);
    return r;
  });
}
function syncGear(){ if(player&&player.gearMesh)player.gearMesh.visible=player.gearDown; }

function touchScore(hSpeed,sink,hV){
  if(game.mode!=='takeoff')return;
  // score landing quality
  let grade='BUTTER',pts=100;
  if(sink<-4.5){grade='FIRM';pts=70;} if(sink<-6.5){grade='HARD';pts=40;}
  const onRwy=Math.abs(player.pos.x-RUNWAY.x)<RUNWAY.wid&&
              Math.abs(player.pos.z-RUNWAY.z)<RUNWAY.len/2;
  if(!onRwy){grade+=' — OFF RUNWAY';pts-=30;}
  showMsg('TOUCHDOWN '+grade,'sink '+(sink.toFixed(1))+' m/s · '+Math.round(hV*1.94384)+' kt');
}
let failReason=null;
function missionFail(reason){
  failReason=reason;
  setTimeout(()=>endMission(false),1400);
}
function endMission(win,data){
  game.flying=false;
  document.getElementById('pausebtn').classList.add('hidden');
  const r=document.getElementById('result');
  r.classList.remove('hidden');
  document.getElementById('resTitle').textContent=win?'MISSION COMPLETE':'MISSION FAILED';
  document.getElementById('resBody').innerHTML=data||failReason||'';
  updateAudio(null);
}
const topmsgEl=document.getElementById('topmsg');
let msgTimer=null;
function showMsg(a,b){
  topmsgEl.innerHTML='<div class="m1">'+a+'</div>'+(b?'<div class="m2">'+b+'</div>':'');
  clearTimeout(msgTimer);
  msgTimer=setTimeout(()=>topmsgEl.innerHTML='',5000);
}
function togglePause(){
  if(!game.flying)return;
  game.paused=!game.paused;
  document.getElementById('pausebtn').textContent=game.paused?'RESUME':'PAUSE';
  if(nodes)nodes.master.gain.value=game.paused?0.08:0.7;
}

/* ---------------- takeoff mission logic ---------------- */
function updateTakeoffMission(dt){
  const m=mission;
  if(!m)return;
  if(m.phase==='taxi'&&player.onGround===false&&player.pos.y>player.def.gearH+0.5)m.phase='climb';
  if(m.phase==='climb'){
    // pass through rings
    for(let i=m.ringIdx;i<rings.length;i++){
      const r=rings[i];
      if(Math.abs(player.pos.z-r.z)<45&&Math.abs(player.pos.y-r.y)<65&&Math.abs(player.pos.x-RUNWAY.x)<70){
        m.ringIdx++;
        ringMeshes[i].material.color.setHex(0x66ff88);
        showMsg('GATE '+m.ringIdx+'/'+rings.length,'climb performance nominal');
        break;
      }
    }
    if(m.ringIdx>=rings.length&&!m.done){
      m.done=true;
      endMission(true,'All gates cleared.<br><span class="scoreline">Aircraft: '+player.def.name+'</span>'+
        '<span class="scoreline">Climb rate at gate 2: '+player.vel.y.toFixed(1)+' m/s</span>'+
        '<span class="scoreline">IAS: '+Math.round(player.IAS*1.94384)+' kt @ '+Math.round(player.pos.y)+' m</span>');
    }
  }
}

/* =====================================================================
   CAMERA + MAIN LOOP
===================================================================== */
let lastT=performance.now(),camMode=0;
function updateCamera(dt){
  const idealTarget=new V3();
  if(camMode===0){ // chase
    const back=player.forward.clone().multiplyScalar(-(player.def.wingspan*2.6));
    const upOff=player.up.clone().multiplyScalar(player.def.wingspan*0.55);
    const want=player.pos.clone().add(back).add(upOff);
    camPos.lerp(want,clamp(dt*(player.onGround?8:4),0,1));
    camera.position.copy(camPos);
    idealTarget.copy(player.pos).addScaledVector(player.forward,50);
  } else { // cockpit-ish
    camera.position.copy(player.pos).addScaledVector(player.up,0.9).addScaledVector(player.forward,player.def.wingspan*0.28);
    idealTarget.copy(camera.position).addScaledVector(player.forward,100);
  }
  camera.up.copy(player.up);
  camera.lookAt(idealTarget);
  // FOV widens slightly with speed
  camera.fov=lerp(camera.fov,62+clamp(player.IAS-90,0,300)*0.03,clamp(dt*2,0,1));
  camera.updateProjectionMatrix();
}
addEventListener('keydown',e=>{if(e.code==='KeyC')camMode=(camMode+1)%2;});

function loop(now){
  requestAnimationFrame(loop);
  let dt=Math.min((now-lastT)/1000,0.05);
  lastT=now;
  perfT+=dt;
  if(!game.flying||game.paused){return;}
  const inp=readInput(dt);
  player.update(dt,{...inp,throttle:game.throttle});
  player.mesh.position.copy(player.pos);
  player.mesh.quaternion.copy(player.quat);

  // bullets
  for(let i=bullets.length-1;i>=0;i--){
    const b=bullets[i];
    b.life-=dt; b.mesh.position.addScaledVector(b.vel,dt);
    if(b.life<=0){scene.remove(b.mesh);bullets.splice(i,1);}
  }
  bulletHits();

  if(game.mode==='dogfight'){
    for(const e of enemies)updateEnemy(e,dt);
    // enemy collision with player = mutual destruction
    for(const e of enemies)
      if(e.ac.alive&&e.ac.pos.distanceTo(player.pos)<player.def.wingspan)
        {e.ac.crash('midair');player.crash('MIDAIR COLLISION');}
    if(player.alive&&enemies.every(e=>!e.ac.alive)){
      endMission(true,'Air superiority achieved.<br><span class="scoreline">Kills: '+game.kills+'</span>'+
        '<span class="scoreline">Max G pulled this sortie: '+maxG.toFixed(1)+'</span>');
    }
  } else updateTakeoffMission(dt);

  maxG=Math.max(maxG,Math.abs(player.gLoad));
  updateCamera(dt);
  renderer.render(scene,camera);
  drawHUD(player);
  updateAudio(player);
}
let maxG=1;

/* =====================================================================
   MENU WIRING
===================================================================== */
let selPlane=0,selMode='takeoff';
const fleetEl=document.getElementById('fleet');
FLEET.forEach((f,i)=>{
  const card=document.createElement('div');
  card.className='card'+(i===0?' sel':'');
  card.innerHTML=`<h3>${f.name}</h3><div class="role">${f.role}</div>
   <div style="font-size:11px;opacity:.6;line-height:1.5;margin-bottom:8px;">${f.desc}</div>
   ${statbar('Thrust/Weight',f.stats.twr,parseFloat(f.stats.twr)/1.25)}
   ${statbar('Top Speed',f.stats.spd,{'SF-88':0.95,'T-7':0.62,'AC-40':0.5}[f.name.split(' ')[0]]||0.6)}
   ${statbar('Agility',f.stats.turn,(f.rollRate/5.2))}
   ${statbar('Mass',(f.mass/1000)+' t',f.mass/23000)}`;
  card.onclick=()=>{selPlane=i;[...fleetEl.children].forEach((c,j)=>c.classList.toggle('sel',j===i));};
  fleetEl.appendChild(card);
});
function statbar(lbl,val,frac){
  return `<div class="statbar"><label><span>${lbl}</span><span>${val}</span></label>
  <div class="track"><div class="fill" style="width:${clamp(frac,0.06,1)*100}%"></div></div></div>`;
}
document.querySelectorAll('#modes .mode').forEach(el=>{
  el.onclick=()=>{selMode=el.dataset.m;
    document.querySelectorAll('#modes .mode').forEach(x=>x.classList.toggle('sel',x===el));};
});
document.getElementById('startBtn').onclick=()=>startMission(selPlane,selMode);
document.getElementById('againBtn').onclick=()=>{
  document.getElementById('menu').classList.remove('hidden');
  document.getElementById('result').classList.add('hidden');
};
document.getElementById('pausebtn').onclick=togglePause;
loop(performance.now());
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"fbd5a151-eacd-480a-bc56-10ff952e63cf","tokensIn":3411642,"tokensOut":38464,"tokensTotal":3450106,"cost":0.07916467,"turns":59,"toolCalls":58,"failedToolCalls":1,"timestamp":"2026-08-27T02:44:58.095Z"} -->