Flight Simulator
GLM 5.3 Flash · full

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 & 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 <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 <kbd>B</kbd> Brakes <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"} -->