Flight Simulator
Mimo v2.6 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 139,385 bytes · SHA-256 de7d9abe5bac
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/>
<title>APEX AERO — High-Fidelity Flight Simulator</title>
<style>
:root{
--bg:#05070a; --panel:rgba(7,11,16,.74); --panel2:rgba(10,15,21,.9);
--line:rgba(126,214,255,.16); --line2:rgba(126,214,255,.34);
--cy:#7fe3ff; --cy-dim:#4a93ad; --am:#ffb020; --rd:#ff4a3d; --gr:#57e39a;
--tx:#cfe4ee; --tx-dim:#7c93a1;
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,"Roboto Mono","DejaVu Sans Mono",monospace;
--sans:"Inter","SF Pro Display",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--tx);
font-family:var(--sans);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
user-select:none;-webkit-user-select:none;cursor:default}
#app{position:fixed;inset:0}
canvas#gl{position:absolute;inset:0;display:block;width:100%;height:100%}
canvas#hud{position:absolute;inset:0;pointer-events:none;z-index:6}
#skyTint{position:absolute;inset:0;pointer-events:none;z-index:2;
background:radial-gradient(120% 90% at 50% 40%,rgba(0,0,0,0) 45%,rgba(2,4,8,.55) 100%)}
.hidden{display:none!important}
.mono{font-family:var(--mono);font-variant-numeric:tabular-nums}
/* ---------- generic screens ---------- */
.screen{position:absolute;inset:0;z-index:20;display:flex;flex-direction:column;
backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px)}
.screen.dim{background:linear-gradient(180deg,rgba(3,5,8,.72),rgba(3,5,8,.86))}
.fadein{animation:fadein .45s cubic-bezier(.2,.7,.3,1) both}
@keyframes fadein{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@keyframes fadeout{to{opacity:0}}
.btn{font-family:var(--mono);letter-spacing:.14em;text-transform:uppercase;font-size:12px;
color:var(--tx);background:rgba(16,24,32,.8);border:1px solid var(--line2);
padding:12px 20px;cursor:pointer;transition:.18s;position:relative;overflow:hidden}
.btn:hover{background:rgba(127,227,255,.14);border-color:var(--cy);color:#fff;
box-shadow:0 0 24px rgba(127,227,255,.18) inset,0 0 18px rgba(127,227,255,.12)}
.btn.primary{background:linear-gradient(180deg,rgba(127,227,255,.22),rgba(127,227,255,.08));
border-color:var(--cy);color:#eafaff;font-size:13px;padding:14px 30px;font-weight:600}
.btn.primary:hover{background:linear-gradient(180deg,rgba(127,227,255,.4),rgba(127,227,255,.16));
box-shadow:0 0 40px rgba(127,227,255,.3)}
.btn.ghost{background:transparent;border-color:var(--line);color:var(--tx-dim)}
h1,h2,h3{font-weight:600}
/* ---------- menu ---------- */
#menu{justify-content:space-between;padding:26px 40px 18px}
#menu::before{content:"";position:absolute;inset:0;z-index:-1;
background:linear-gradient(105deg,rgba(3,6,10,.9) 0%,rgba(3,6,10,.55) 42%,rgba(3,6,10,.2) 70%,rgba(3,6,10,.7) 100%)}
.mTop{display:flex;justify-content:space-between;align-items:flex-start}
.brand h1{font-family:var(--mono);font-size:44px;letter-spacing:.30em;font-weight:700;
color:#fff;line-height:.95;text-shadow:0 0 34px rgba(127,227,255,.45)}
.brand h1 span{color:var(--cy)}
.brand .tag{font-family:var(--mono);font-size:11px;letter-spacing:.42em;color:var(--cy-dim);
margin-top:12px;text-transform:uppercase}
.brand .rule{width:180px;height:1px;background:linear-gradient(90deg,var(--cy),transparent);margin-top:14px}
.build{font-family:var(--mono);font-size:10px;letter-spacing:.2em;color:var(--tx-dim);text-align:right;line-height:1.9}
.build b{color:var(--cy);font-weight:500}
.mBody{display:grid;grid-template-columns:1.45fr .95fr;gap:26px;align-items:start;max-width:1440px;width:100%}
.mBody>*{min-width:0}
.sec{margin-bottom:16px}
.sec>h2{font-family:var(--mono);font-size:11px;letter-spacing:.3em;color:var(--cy);
text-transform:uppercase;margin-bottom:9px;display:flex;align-items:center;gap:10px}
.sec>h2::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)}
.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
.card{position:relative;background:var(--panel);border:1px solid var(--line);padding:12px 12px 10px;
cursor:pointer;transition:.2s;overflow:hidden}
.card::after{content:"";position:absolute;left:0;top:0;height:2px;width:0;background:var(--cy);
transition:.3s;box-shadow:0 0 12px var(--cy)}
.card:hover{border-color:var(--line2);background:rgba(13,20,28,.86);transform:translateY(-3px)}
.card.sel{border-color:var(--cy);background:rgba(11,20,28,.92);box-shadow:0 10px 40px rgba(0,0,0,.5),0 0 0 1px rgba(127,227,255,.25)}
.card.sel::after{width:100%}
.card .cName{font-family:var(--mono);font-size:14px;letter-spacing:.06em;color:#fff;margin-bottom:1px}
.card .cRole{font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--am);margin-bottom:7px}
.card .cDesc{font-size:10.5px;line-height:1.42;color:var(--tx-dim);margin-bottom:9px}
.stat{display:flex;align-items:center;gap:7px;margin-bottom:4px;font-family:var(--mono);font-size:9px;
letter-spacing:.08em;color:var(--tx-dim)}
.stat i{width:46px;font-style:normal}
.stat .bar{flex:1;height:5px;background:rgba(255,255,255,.07);position:relative;overflow:hidden;min-width:14px}
.stat .bar b{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--cy-dim),var(--cy));
box-shadow:0 0 8px rgba(127,227,255,.5)}
.card .cThumb{height:54px;margin:-2px -2px 8px;background:
radial-gradient(80% 120% at 50% 10%,rgba(127,227,255,.12),transparent 70%),rgba(0,0,0,.35);
border:1px solid rgba(255,255,255,.05);position:relative;overflow:hidden}
.card .cThumb svg{position:absolute;inset:0;width:100%;height:100%;opacity:.9}
.scards{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.sc{background:var(--panel);border:1px solid var(--line);padding:12px;cursor:pointer;transition:.2s;position:relative}
.sc:hover{border-color:var(--line2);transform:translateY(-3px)}
.sc.sel{border-color:var(--am);background:rgba(24,18,8,.7);box-shadow:0 0 0 1px rgba(255,176,32,.22)}
.sc .sIcon{font-size:17px;margin-bottom:4px}
.sc .sName{font-family:var(--mono);font-size:12px;letter-spacing:.08em;color:#fff;margin-bottom:5px}
.sc .sDesc{font-size:10.5px;line-height:1.42;color:var(--tx-dim)}
.sc .sTags{margin-top:7px;display:flex;gap:5px;flex-wrap:wrap}
.sc .sTags span{font-family:var(--mono);font-size:8.5px;letter-spacing:.08em;padding:2px 5px;
border:1px solid var(--line);color:var(--cy-dim)}
.mFoot{display:flex;align-items:center;gap:14px;margin-top:6px;flex-wrap:wrap}
.keys{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:5px 16px;
background:var(--panel);border:1px solid var(--line);padding:11px 14px;max-width:1000px;min-width:0}
.krow{display:flex;align-items:center;gap:8px;font-size:10.5px;color:var(--tx-dim)}
kbd{font-family:var(--mono);font-size:10px;padding:2px 6px;border:1px solid var(--line2);
background:rgba(255,255,255,.05);color:var(--cy);border-radius:2px;min-width:24px;text-align:center;
letter-spacing:.06em;white-space:nowrap}
.optRow{display:flex;gap:10px;align-items:center}
.tgl{font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;padding:9px 14px;border:1px solid var(--line);
background:rgba(0,0,0,.3);color:var(--tx-dim);cursor:pointer;transition:.15s;text-transform:uppercase}
.tgl.on{color:var(--cy);border-color:var(--line2);background:rgba(127,227,255,.1)}
/* ---------- loading / fatal ---------- */
#loading{z-index:60;background:#05070a;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px}
.ring{width:54px;height:54px;border:2px solid rgba(127,227,255,.15);border-top-color:var(--cy);
border-radius:50%;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
#loadStatus{font-family:var(--mono);font-size:11px;letter-spacing:.24em;color:var(--cy-dim);text-transform:uppercase}
.loadTitle{font-family:var(--mono);font-size:20px;letter-spacing:.5em;color:#fff}
#fatal{z-index:70;background:#05070a;padding:60px;align-items:center;justify-content:center;text-align:center}
#fatal h2{color:var(--rd);font-family:var(--mono);letter-spacing:.2em;margin-bottom:14px}
#fatal p{color:var(--tx-dim);max-width:640px;line-height:1.7;font-size:13px}
/* ---------- HUD DOM ---------- */
#hudDom{position:absolute;inset:0;z-index:7;pointer-events:none;font-family:var(--mono)}
#objPanel{position:absolute;left:26px;top:86px;width:328px;background:rgba(5,9,13,.62);
border-left:2px solid var(--cy);padding:12px 14px;backdrop-filter:blur(4px)}
#objPanel .ttl{font-size:11px;letter-spacing:.24em;color:var(--cy);margin-bottom:9px;text-transform:uppercase;
display:flex;justify-content:space-between}
#objPanel .ttl em{font-style:normal;color:var(--am)}
#objList{list-style:none}
#objList li{font-size:11.5px;line-height:1.5;color:var(--tx-dim);padding:4px 0 4px 20px;position:relative;
letter-spacing:.03em;transition:.2s}
#objList li::before{content:"";position:absolute;left:2px;top:8px;width:8px;height:8px;
border:1px solid var(--line2);transform:rotate(45deg)}
#objList li.done{color:var(--gr);opacity:.75}
#objList li.done::before{background:var(--gr);border-color:var(--gr);box-shadow:0 0 8px var(--gr)}
#objList li.act{color:#fff}
#objList li.act::before{border-color:var(--am);box-shadow:0 0 8px var(--am);animation:pulse 1.2s infinite}
@keyframes pulse{50%{opacity:.35}}
#statPanel{position:absolute;right:26px;top:86px;min-width:246px;background:rgba(5,9,13,.62);
border-right:2px solid var(--am);padding:12px 14px;text-align:right;backdrop-filter:blur(4px)}
#statPanel .row{display:flex;justify-content:space-between;gap:16px;font-size:11px;letter-spacing:.1em;
padding:3px 0;color:var(--tx-dim)}
#statPanel .row b{color:#fff;font-weight:500}
#statPanel .row.hl b{color:var(--am)}
.bar2{height:7px;background:rgba(255,255,255,.08);margin-top:5px;position:relative;overflow:hidden}
.bar2 b{position:absolute;inset:0 auto 0 0;background:linear-gradient(90deg,#ff4a3d,#ffb020 55%,#57e39a);
transition:.15s}
.bar2.cy b{background:linear-gradient(90deg,var(--cy-dim),var(--cy))}
#center{position:absolute;left:50%;top:16%;transform:translateX(-50%);text-align:center;width:640px}
#warn{font-family:var(--mono);font-size:30px;letter-spacing:.3em;color:var(--rd);font-weight:700;
text-shadow:0 0 22px rgba(255,60,40,.75);margin-bottom:10px;height:38px}
#warn.amber{color:var(--am);text-shadow:0 0 22px rgba(255,176,32,.7)}
#warn.green{color:var(--gr);text-shadow:0 0 22px rgba(87,227,154,.7)}
#toast{font-family:var(--mono);font-size:14px;letter-spacing:.2em;color:#fff;
text-shadow:0 2px 14px rgba(0,0,0,.9);opacity:0;transition:.35s;transform:translateY(6px)}
#toast.show{opacity:1;transform:none}
#sub{font-family:var(--mono);font-size:12.5px;letter-spacing:.16em;color:var(--cy);margin-top:8px;height:16px}
#hintBar{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);display:flex;gap:16px;
font-size:10px;letter-spacing:.14em;color:rgba(160,190,205,.62);background:rgba(4,7,10,.5);
padding:7px 16px;border:1px solid rgba(126,214,255,.09);white-space:nowrap}
#hintBar b{color:var(--cy);font-weight:500}
#gshade{position:absolute;inset:0;opacity:0;transition:opacity .12s;
background:radial-gradient(105% 105% at 50% 50%,transparent 42%,rgba(255,40,20,.55) 100%)}
#dmgFlash{position:absolute;inset:0;opacity:0;background:radial-gradient(100% 100% at 50% 50%,transparent 35%,rgba(255,50,30,.6) 100%);transition:opacity .25s}
/* ---------- pause / result ---------- */
#pause,#result{align-items:center;justify-content:center;gap:26px}
.panelBox{background:var(--panel2);border:1px solid var(--line2);padding:34px 40px;min-width:560px;
box-shadow:0 40px 120px rgba(0,0,0,.75),0 0 0 1px rgba(0,0,0,.6)}
.panelBox .ph{font-family:var(--mono);font-size:24px;letter-spacing:.34em;color:#fff;margin-bottom:6px}
.panelBox .ps{font-family:var(--mono);font-size:11px;letter-spacing:.2em;color:var(--cy-dim);margin-bottom:24px}
.rowBtns{display:flex;gap:12px;justify-content:center;margin-top:24px;flex-wrap:wrap}
#resGrade{font-family:var(--mono);font-size:64px;font-weight:700;letter-spacing:.1em;line-height:1;
color:var(--gr);text-shadow:0 0 44px rgba(87,227,154,.55);margin:8px 0 4px}
#resGrade.fail{color:var(--rd);text-shadow:0 0 44px rgba(255,74,61,.55)}
#resGrade.mid{color:var(--am);text-shadow:0 0 44px rgba(255,176,32,.5)}
.statsTbl{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:12px}
.statsTbl td{padding:8px 6px;border-bottom:1px solid rgba(255,255,255,.07);color:var(--tx-dim);letter-spacing:.08em}
.statsTbl td:last-child{text-align:right;color:#fff}
.ctrlTbl{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:11.5px}
.ctrlTbl td{padding:6px 8px;border-bottom:1px solid rgba(255,255,255,.06);color:var(--tx-dim)}
.ctrlTbl td:first-child{color:var(--cy);width:170px;letter-spacing:.08em}
@media (max-width:1180px){
.mBody{grid-template-columns:1fr}
.brand h1{font-size:38px}
.panelBox{min-width:88vw}
}
</style>
</head>
<body>
<div id="app">
<canvas id="gl"></canvas>
<div id="skyTint"></div>
<canvas id="hud"></canvas>
<div id="hudDom" class="hidden">
<div id="gshade"></div>
<div id="dmgFlash"></div>
<div id="objPanel"><div class="ttl"><span id="objTitle">MISSION</span><em id="objPhase">—</em></div><ul id="objList"></ul></div>
<div id="statPanel"></div>
<div id="center"><div id="warn"></div><div id="toast"></div><div id="sub"></div></div>
<div id="hintBar"></div>
</div>
<div id="menu" class="screen">
<div class="mTop">
<div class="brand">
<h1>APEX<span>AERO</span></h1>
<div class="tag">Newtonian six-degree-of-freedom flight simulator</div>
<div class="rule"></div>
</div>
<div class="build">
ENGINE <b>THREE.JS</b><br/>
ATMOSPHERE <b>ISA EXP DECAY</b><br/>
TERRAIN <b>PROCEDURAL / FBM+RIDGE</b><br/>
<span id="revInfo">BUILD —</span>
</div>
</div>
<div class="mBody">
<div>
<div class="sec"><h2>01 — Select aircraft</h2><div class="cards" id="acCards"></div></div>
<div class="sec"><h2>02 — Select scenario</h2><div class="scards" id="scCards"></div></div>
</div>
<div>
<div class="keys" id="keysGrid"></div>
<div class="mFoot" style="margin-top:16px">
<button class="btn primary" id="btnStart">▶ Start flight</button>
<div class="optRow">
<button class="tgl on" id="tglSound">◉ Sound on</button>
<button class="tgl on" id="tglMouse">◎ Mouse aim on</button>
</div>
</div>
<div id="selInfo" style="font-family:var(--mono);font-size:11px;letter-spacing:.1em;color:var(--tx-dim);margin-top:14px;line-height:1.9"></div>
</div>
</div>
</div>
<div id="loading" class="screen">
<div class="ring"></div>
<div class="loadTitle">APEX AERO</div>
<div id="loadStatus">booting…</div>
</div>
<div id="pause" class="screen dim hidden">
<div class="panelBox fadein">
<div class="ph">PAUSED</div>
<div class="ps">flight controls suspended</div>
<table class="ctrlTbl" id="ctrlTbl"></table>
<div class="rowBtns">
<button class="btn primary" id="btnResume">Resume</button>
<button class="btn" id="btnRestart">Restart</button>
<button class="btn ghost" id="btnHangar">Hangar</button>
</div>
</div>
</div>
<div id="result" class="screen dim hidden">
<div class="panelBox fadein" style="text-align:center">
<div class="ps" id="resSub" style="margin-bottom:8px">MISSION COMPLETE</div>
<div id="resGrade">A</div>
<div class="ph" id="resTitle" style="font-size:18px;letter-spacing:.2em">TEXT</div>
<table class="statsTbl" id="resStats" style="margin-top:20px;text-align:left"></table>
<div class="rowBtns">
<button class="btn primary" id="btnAgain">Fly again</button>
<button class="btn" id="btnHangar2">Hangar</button>
</div>
</div>
</div>
<div id="fatal" class="screen hidden">
<h2>RENDERER UNAVAILABLE</h2>
<p id="fatalMsg">Three.js could not be loaded. This simulator needs network access to the Three.js CDN on first load.</p>
</div>
</div>
<script type="importmap">
{"imports":{
"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
const st = document.getElementById('loadStatus');
import('three').then(T=>{
window.__THREE_REV = T.REVISION;
st.textContent = 'three.js r'+T.REVISION+' linked';
}).catch(e=>{
st.textContent = 'cdn unreachable: '+e.message;
window.__THREE_FAIL = e.message;
});
</script>
<script>
import('three').then((THREE)=>{
/* ══════════════════════════════════════════════════════════════════
APEX AERO — constants, utilities, procedural noise
══════════════════════════════════════════════════════════════════ */
const G = 9.80665, RHO0 = 1.225;
const KT = 1.94384, FT = 3.28084; // m/s→knots, m→feet
const clamp = (v,a,b)=> v<a?a:(v>b?b:v);
const lerp = (a,b,t)=> a+(b-a)*t;
const smoothstep = (e0,e1,x)=>{ const t=clamp((x-e0)/(e1-e0),0,1); return t*t*(3-2*t); };
const damp = (a,b,l,dt)=> lerp(a,b,1-Math.exp(-l*dt));
const sgn = v=> v<0?-1:1;
const rand = (a,b)=> a+Math.random()*(b-a);
const f0 = n=> (n<10?'0':'')+n;
function h2(x,y){
let n = Math.imul(x|0, 0x27d4eb2d) ^ Math.imul(y|0, 0x165667b1);
n = Math.imul(n ^ (n>>>15), 0x2545f491);
n ^= n>>>13; n = Math.imul(n, 0x27d4eb2d);
n ^= n>>>16;
return (n>>>0)/4294967296;
}
function vnoise(x,y){
const xi=Math.floor(x), yi=Math.floor(y), xf=x-xi, yf=y-yi;
const u=xf*xf*(3-2*xf), v=yf*yf*(3-2*yf);
const a=h2(xi,yi), b=h2(xi+1,yi), c=h2(xi,yi+1), d=h2(xi+1,yi+1);
return (a+(b-a)*u)*(1-v) + (c+(d-c)*u)*v; // 0..1
}
function fbm(x,y,oct){
let s=0, a=0.5, f=1, norm=0;
for(let i=0;i<oct;i++){ s += (vnoise(x*f,y*f)*2-1)*a; norm+=a; a*=0.5; f*=2.03; }
return s/norm; // -1..1
}
function ridged(x,y,oct){
let s=0, a=0.5, f=1, norm=0;
for(let i=0;i<oct;i++){ const n=1-Math.abs(vnoise(x*f,y*f)*2-1); s += n*n*a; norm+=a; a*=0.5; f*=2.07; }
return s/norm; // 0..1
}
/* ══════════════════════════════════════════════════════════════════
TERRAIN HEIGHT FIELD (shared by mesh building + collision)
══════════════════════════════════════════════════════════════════ */
const WORLD = { size:16000, half:8000, sea:0, runwayY:36, rwHalfW:40, rwHalfL:900 };
function plateauTerms(x,z){ // airfield dome + runway plateau → returns {h, p}
const dO = Math.hypot(x,z);
const af = 1 - smoothstep(900, 3800, dO);
const dx = Math.max(Math.abs(x)-95,0), dz = Math.max(Math.abs(z)-1050,0);
const dR = Math.hypot(dx,dz);
const p = smoothstep(0, 620, dR);
return {af, p};
}
function rawTerrain(x,z){
let h = 38 + fbm(x*0.00042+11.3, z*0.00042-7.9, 5)*72; // rolling base
h += fbm(x*0.00016+31.7, z*0.00016-12.3, 3)*96; // continental swell
const mMask = smoothstep(0.02, 0.5, fbm(x*0.00012-77.1, z*0.00012+41.9, 3));
const ridge = ridged(x*0.00035+9.2, z*0.00035-4.6, 5);
h += Math.pow(ridge, 2.2) * 820 * mMask; // mountain ranges
h += Math.pow(ridged(x*0.0009-3.3, z*0.0009+7.7, 3), 2.0) * 95 * (0.25+0.75*mMask);
const oDepth = smoothstep(2200, 5400, -x); // ocean to the west
h = h*(1-oDepth) + (-95)*oDepth;
const {af,p} = plateauTerms(x,z);
h = h*(1-0.86*af) + 36*0.86*af; // flatten for the airfield
h = 36*(1-p) + h*p; // runway plateau
return h;
}
function terrainHeight(x,z){
if(x<-9000||x>9000||z<-9000||z>9000) return -60;
return rawTerrain(x,z);
}
function terrainNormal(x,z,e){
e = e||10;
const hl=terrainHeight(x-e,z), hr=terrainHeight(x+e,z);
const hd=terrainHeight(x,z-e), hu=terrainHeight(x,z+e);
return new THREE.Vector3(hl-hr, 2*e, hd-hu).normalize();
}
const airfieldClear = (x,z)=> Math.hypot(x,z) < 1400;
/* ══════════════════════════════════════════════════════════════════
RENDERER / SCENE / SKY
══════════════════════════════════════════════════════════════════ */
const cvs = document.getElementById('gl');
const renderer = new THREE.WebGLRenderer({canvas:cvs, antialias:true, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio||1, 1.75));
renderer.setSize(innerWidth, innerHeight, false);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.02;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
const FOG_COL = new THREE.Color(0x9fbccd);
scene.fog = new THREE.FogExp2(FOG_COL, 0.000115);
scene.background = FOG_COL.clone();
const camera = new THREE.PerspectiveCamera(62, innerWidth/innerHeight, 0.6, 26000);
camera.position.set(0, 60, 400);
const SUN_DIR = new THREE.Vector3(0.46, 0.60, 0.34).normalize();
const sun = new THREE.DirectionalLight(0xfff2dc, 3.1);
sun.castShadow = true;
sun.shadow.mapSize.set(2048,2048);
sun.shadow.camera.near = 1; sun.shadow.camera.far = 3200;
sun.shadow.camera.left=-420; sun.shadow.camera.right=420;
sun.shadow.camera.top=420; sun.shadow.camera.bottom=-420;
sun.shadow.bias = -0.0006; sun.shadow.normalBias = 1.6;
scene.add(sun); scene.add(sun.target);
const hemi = new THREE.HemisphereLight(0xbcd8ea, 0x5a5240, 0.85);
scene.add(hemi);
const amb = new THREE.AmbientLight(0xffffff, 0.10);
scene.add(amb);
/* --- gradient sky dome ------------------------------------------------ */
const skyMat = new THREE.ShaderMaterial({
side:THREE.BackSide, depthWrite:false, fog:false,
uniforms:{
uZen:{value:new THREE.Color(0x2f6fb8)}, uHor:{value:new THREE.Color(0xa9c8da)},
uGnd:{value:new THREE.Color(0x6d7c80)}, uSun:{value:SUN_DIR.clone()},
uSunC:{value:new THREE.Color(0xfff0d0)}
},
vertexShader:`varying vec3 vD; void main(){ vD=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);} `,
fragmentShader:`
varying vec3 vD; uniform vec3 uZen,uHor,uGnd,uSun,uSunC;
void main(){
vec3 d=normalize(vD); float h=d.y;
vec3 col=mix(uHor,uZen,pow(clamp(h,0.0,1.0),0.55));
col=mix(col,uGnd,smoothstep(0.02,-0.10,h));
float sd=max(dot(d,uSun),0.0);
col+=uSunC*pow(sd,1400.0)*14.0;
col+=uSunC*pow(sd,14.0)*0.42;
col+=uSunC*pow(sd,3.0)*0.11;
gl_FragColor=vec4(col,1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(11000,40,22), skyMat);
sky.frustumCulled=false; scene.add(sky);
/* --- procedural detail texture for the ground ------------------------- */
function makeNoiseTex(size, base, amp, blob){
const c=document.createElement('canvas'); c.width=c.height=size;
const g=c.getContext('2d'); const im=g.createImageData(size,size);
for(let y=0;y<size;y++) for(let x=0;x<size;x++){
let v = fbm(x/size*blob, y/size*blob, 4);
let px = clamp(base + v*amp,0,1)*255;
const i=(y*size+x)*4;
im.data[i]=px; im.data[i+1]=px; im.data[i+2]=px; im.data[i+3]=255;
}
g.putImageData(im,0,0);
const t=new THREE.CanvasTexture(c);
t.wrapS=t.wrapT=THREE.RepeatWrapping;
return t;
}
/* ══════════════════════════════════════════════════════════════════
TERRAIN MESH (async, chunked, with progress)
══════════════════════════════════════════════════════════════════ */
const loadEl = document.getElementById('loadStatus');
const setLoad = (txt,pct)=>{ if(loadEl){ loadEl.textContent = txt + (pct!==undefined?' '+Math.round(pct*100)+'%':''); } };
const yieldFrame = ()=> new Promise(r=>setTimeout(r,0));
let terrainMesh=null, terrainCols=null;
function colorTerrain(h, n, x, z, out){
const slope = n.y;
const vj = (fbm(x*0.0016+5.1, z*0.0016-3.3, 3))*0.5+0.5; // patchiness
const dj = (fbm(x*0.006-9.7, z*0.006+4.2, 2))*0.5+0.5; // fine mottling
// palette
let r,g,b;
const sand=[0.79,0.72,0.55], grass=[0.30,0.44,0.22], grass2=[0.42,0.52,0.26];
const forest=[0.16,0.30,0.17], rock=[0.42,0.40,0.37], rock2=[0.33,0.32,0.31], snow=[0.93,0.95,0.97];
const mix3=(A,B,t)=>[lerp(A[0],B[0],t),lerp(A[1],B[1],t),lerp(A[2],B[2],t)];
let c;
if(h<2.0) c = mix3([0.55,0.52,0.42], sand, smoothstep(-14,2.0,h));
else c = mix3(sand, mix3(grass,grass2,vj), smoothstep(2.0,16,h));
c = mix3(c, forest, smoothstep(45,150,h)*0.85*smoothstep(0.55,0.8,slope+0.35));
c = mix3(c, mix3(rock,rock2,vj), smoothstep(0.86,0.62,slope));
c = mix3(c, rock2, smoothstep(240,420,h)*0.7);
c = mix3(c, snow, smoothstep(360,470,h)*smoothstep(0.62,0.82,slope));
if(h<-1.5) c = mix3(c,[0.14,0.20,0.22], smoothstep(-1.5,-24,h)); // sea bed
const dm = 0.86 + dj*0.30;
out[0]=c[0]*dm; out[1]=c[1]*dm; out[2]=c[2]*dm;
}
async function buildTerrain(){
const SEG=420, N=SEG+1;
const geo = new THREE.PlaneGeometry(WORLD.size, WORLD.size, SEG, SEG);
geo.rotateX(-Math.PI/2);
const pos = geo.attributes.position;
const cols = new Float32Array(N*N*3);
const tmp=[0,0,0];
const ROWS=28;
for(let r0=0;r0<N;r0+=ROWS){
for(let r=r0; r<Math.min(r0+ROWS,N); r++){
for(let i=0;i<N;i++){
const idx=r*N+i;
const x=pos.getX(idx), z=pos.getZ(idx);
const h=terrainHeight(x,z);
pos.setY(idx,h);
const e=14;
const hl=terrainHeight(x-e,z),hr=terrainHeight(x+e,z);
const hd=terrainHeight(x,z-e),hu=terrainHeight(x,z+e);
const n=new THREE.Vector3(hl-hr,2*e,hd-hu).normalize();
colorTerrain(h,n,x,z,tmp);
cols[idx*3]=tmp[0]; cols[idx*3+1]=tmp[1]; cols[idx*3+2]=tmp[2];
}
}
setLoad('sculpting terrain', Math.min(r0+ROWS,N)/N);
await yieldFrame();
}
geo.setAttribute('color', new THREE.BufferAttribute(cols,3));
geo.computeVertexNormals();
const tex = makeNoiseTex(256, 0.94, 0.16, 22);
tex.repeat.set(560,560);
const mat = new THREE.MeshStandardMaterial({
vertexColors:true, map:tex, roughness:0.97, metalness:0.0, dithering:true
});
terrainMesh = new THREE.Mesh(geo, mat);
terrainMesh.receiveShadow = true;
terrainMesh.castShadow = false;
terrainMesh.matrixAutoUpdate=false; terrainMesh.updateMatrix();
scene.add(terrainMesh);
}
/* ══════════════════════════════════════════════════════════════════
WATER
══════════════════════════════════════════════════════════════════ */
function buildWater(){
const g = new THREE.PlaneGeometry(30000,30000,80,80);
g.rotateX(-Math.PI/2);
const mat = new THREE.ShaderMaterial({
transparent:true, fog:false,
uniforms:{
uT:{value:0}, uSun:{value:SUN_DIR.clone()},
uDeep:{value:new THREE.Color(0x08222f)}, uShal:{value:new THREE.Color(0x0d4a5c)},
uSky:{value:new THREE.Color(0x9ec0d4)}, uFog:{value:FOG_COL.clone()},
uFogD:{value:scene.fog.density}, uSunC:{value:new THREE.Color(0xfff0d0)}
},
vertexShader:`
uniform float uT; varying vec3 vW; varying vec3 vN;
void main(){
vec3 p=position;
float w1=sin(p.x*0.011+uT*0.85)*0.14, w2=sin(p.z*0.0143-uT*1.05)*0.11;
float w3=sin((p.x+p.z)*0.0052+uT*0.55)*0.22;
p.y += w1+w2+w3;
float dx=0.14*0.011*cos(p.x*0.011+uT*0.85)+0.22*0.0052*cos((p.x+p.z)*0.0052+uT*0.55);
float dz=0.11*0.0143*cos(p.z*0.0143-uT*1.05)+0.22*0.0052*cos((p.x+p.z)*0.0052+uT*0.55);
vN=normalize(vec3(-dx,1.0,-dz));
vec4 wp=modelMatrix*vec4(p,1.0); vW=wp.xyz;
gl_Position=projectionMatrix*viewMatrix*wp;
}`,
fragmentShader:`
uniform float uT,uFogD; uniform vec3 uSun,uDeep,uShal,uSky,uFog,uSunC;
varying vec3 vW; varying vec3 vN;
void main(){
float r1=sin(vW.x*0.28+uT*2.1)*sin(vW.z*0.245-uT*1.8);
float r2=sin(vW.x*0.92-uT*3.1)*sin(vW.z*0.87+uT*2.6);
vec3 N=normalize(vN+vec3(r1*0.10+r2*0.05,0.0,r1*0.08-r2*0.05));
vec3 V=normalize(cameraPosition-vW);
float fres=pow(1.0-max(dot(N,V),0.0),3.5);
vec3 col=mix(uDeep,uShal,0.35+0.35*N.x);
col=mix(col,uSky,clamp(fres,0.0,1.0)*0.9);
vec3 H=normalize(uSun+V);
col+=uSunC*pow(max(dot(N,H),0.0),260.0)*2.6;
col+=uSunC*pow(max(dot(N,H),0.0),24.0)*0.16;
float d=length(cameraPosition-vW);
float f=1.0-exp(-uFogD*uFogD*d*d);
col=mix(col,uFog,clamp(f,0.0,1.0));
gl_FragColor=vec4(col,0.955);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
const m = new THREE.Mesh(g,mat);
m.position.y = 0; m.renderOrder = 1;
scene.add(m);
return m;
}
let water=null;
/* ══════════════════════════════════════════════════════════════════
AIRFIELD — runway, markings, buildings, lights
══════════════════════════════════════════════════════════════════ */
function makeRunwayTex(){
const W=160, H=W*30; // 1 px ≈ 0.5 m (60 m × 1800 m)
const c=document.createElement('canvas'); c.width=W; c.height=H;
const g=c.getContext('2d');
g.fillStyle='#26282c'; g.fillRect(0,0,W,H);
for(let i=0;i<9000;i++){ // asphalt grain
g.fillStyle='rgba('+(28+Math.random()*46|0)+','+(30+Math.random()*44|0)+','+(34+Math.random()*44|0)+',.55)';
g.fillRect(Math.random()*W, Math.random()*H, 1+Math.random()*3, 1+Math.random()*3);
}
const white='#e9edf0';
g.fillStyle=white;
g.fillRect(W*0.5-1, 0, 2, H); // centreline (≈1 m)
g.fillRect(2,0,2,H); g.fillRect(W-4,0,2,H); // edges
for(let y=26; y<H-26; y+=68) g.fillRect(W*0.5-1.4, y, 2.8, 40); // dashes 20 m
const threshold=(y0,dir)=>{
for(let i=0;i<6;i++){
const x = W*0.5 + (i<3? -1:1)*(10+ (i%3)*9);
g.fillRect(x-3, y0, 6, 44);
}
// touchdown zone markers
const ty = y0 + dir*150;
for(const o of [-26,26]){ g.fillRect(W*0.5+o-8, ty, 16, 26); g.fillRect(W*0.5+o-4, ty+dir*40, 8, 14); }
// runway designators
g.save();
g.translate(W*0.5, y0 + dir*70);
if(dir<0){ g.rotate(Math.PI); }
g.fillStyle=white; g.font='bold 44px monospace'; g.textAlign='center'; g.textBaseline='middle';
g.fillText(dir>0?'18':'36', 0, 0);
g.restore();
// piano keys
for(let i=0;i<8;i++){ const x=W*0.5+(i<4?-1:1)*(9+(i%4)*8); g.fillRect(x-3, y0+dir>0?0:0, 6, 0); }
};
threshold(6,1); threshold(H-6,-1);
for(let i=0;i<8;i++){ const x=14+i*6; g.fillRect(x,4,4,26); g.fillRect(x,H-30,4,26); }
const t=new THREE.CanvasTexture(c);
t.anisotropy=renderer.capabilities.getMaxAnisotropy();
t.colorSpace=THREE.SRGBColorSpace;
return t;
}
function buildAirfield(){
const grp = new THREE.Group();
const Y = WORLD.runwayY;
// asphalt strip
const rw = new THREE.Mesh(
new THREE.PlaneGeometry(60,1800),
new THREE.MeshStandardMaterial({map:makeRunwayTex(), roughness:0.92, metalness:0.02})
);
rw.rotation.x=-Math.PI/2; rw.position.set(0,Y+0.06,0);
rw.receiveShadow=true; grp.add(rw);
// shoulders
const shMat=new THREE.MeshStandardMaterial({color:0x4a4438, roughness:1});
for(const s of [-1,1]){
const sh=new THREE.Mesh(new THREE.PlaneGeometry(16,1840), shMat);
sh.rotation.x=-Math.PI/2; sh.position.set(s*38,Y+0.03,0); sh.receiveShadow=true; grp.add(sh);
}
// apron
const ap=new THREE.Mesh(new THREE.PlaneGeometry(220,300),
new THREE.MeshStandardMaterial({color:0x33363b, roughness:0.95}));
ap.rotation.x=-Math.PI/2; ap.position.set(-160,Y+0.04,-620); ap.receiveShadow=true; grp.add(ap);
const bMat=new THREE.MeshStandardMaterial({color:0x8d949a, roughness:0.75, metalness:0.25});
const dMat=new THREE.MeshStandardMaterial({color:0x5b6167, roughness:0.6, metalness:0.4});
const gMat=new THREE.MeshStandardMaterial({color:0x9fd4e8, roughness:0.15, metalness:0.5,
transparent:true, opacity:0.55, emissive:0x1a3a4a, emissiveIntensity:0.6});
// hangars
for(let i=0;i<3;i++){
const hg=new THREE.Group();
const body=new THREE.Mesh(new THREE.BoxGeometry(70,26,54), bMat);
body.position.y=13; body.castShadow=true; body.receiveShadow=true; hg.add(body);
const roof=new THREE.Mesh(new THREE.CylinderGeometry(27,27,70,18,1,false,0,Math.PI), dMat);
roof.rotation.z=Math.PI/2; roof.position.y=26; roof.castShadow=true; hg.add(roof);
hg.position.set(-200,Y,-720+i*105); grp.add(hg);
}
// control tower
const tw=new THREE.Group();
const t1=new THREE.Mesh(new THREE.BoxGeometry(26,34,26), bMat); t1.position.y=17; t1.castShadow=true; tw.add(t1);
const t2=new THREE.Mesh(new THREE.BoxGeometry(34,12,34), gMat); t2.position.y=40; t2.castShadow=true; tw.add(t2);
const t3=new THREE.Mesh(new THREE.BoxGeometry(20,6,20), dMat); t3.position.y=49; tw.add(t3);
const ant=new THREE.Mesh(new THREE.CylinderGeometry(0.4,0.4,14,6), dMat); ant.position.y=58; tw.add(ant);
tw.position.set(-95,Y,-760); grp.add(tw);
// runway edge lights
const lg=new THREE.SphereGeometry(0.55,8,6);
const lm=new THREE.MeshBasicMaterial({color:0xfff0b0});
const lights=new THREE.InstancedMesh(lg,lm,64);
const mtx=new THREE.Matrix4(); let k=0;
for(let i=0;i<32;i++){
const z=-880+i*56.8;
for(const s of [-1,1]){ mtx.makeTranslation(s*31.5,Y+0.45,z); lights.setMatrixAt(k++,mtx); }
}
lights.instanceMatrix.needsUpdate=true; grp.add(lights);
// threshold lights
const th=new THREE.InstancedMesh(lg,new THREE.MeshBasicMaterial({color:0xb0ffd0}),16);
let j=0;
for(const z of [-898,898]) for(let i=0;i<8;i++){ mtx.makeTranslation(-24+i*7,Y+0.45,z); th.setMatrixAt(j++,mtx); }
th.instanceMatrix.needsUpdate=true; grp.add(th);
scene.add(grp);
return grp;
}
/* ══════════════════════════════════════════════════════════════════
VEGETATION / ROCKS / CLOUDS
══════════════════════════════════════════════════════════════════ */
async function buildScatter(){
const treeGeo=new THREE.ConeGeometry(4.4,15,6,1);
treeGeo.translate(0,7.5,0);
const treeMat=new THREE.MeshStandardMaterial({color:0xffffff, roughness:0.95, flatShading:true});
const COUNT=2400;
const trees=new THREE.InstancedMesh(treeGeo,treeMat,COUNT);
trees.castShadow=true; trees.receiveShadow=true;
const m=new THREE.Matrix4(), q=new THREE.Quaternion(), sc=new THREE.Vector3(), p=new THREE.Vector3();
const col=new THREE.Color(); let n=0, guard=0;
while(n<COUNT && guard<COUNT*14){
guard++;
const a=Math.random()*Math.PI*2, r=Math.sqrt(Math.random())*5600;
const x=Math.cos(a)*r, z=Math.sin(a)*r;
if(airfieldClear(x,z)) continue;
const h=terrainHeight(x,z);
if(h<4||h>340) continue;
const nn=terrainNormal(x,z,8);
if(nn.y<0.88) continue;
const s=rand(0.55,1.7);
p.set(x,h-0.4,z); sc.set(s, s*rand(0.8,1.5), s);
q.setFromAxisAngle(new THREE.Vector3(0,1,0), Math.random()*6.28);
m.compose(p,q,sc); trees.setMatrixAt(n,m);
const t=Math.random();
col.setRGB(0.10+0.06*t, 0.24+0.12*t, 0.10+0.05*t);
trees.setColorAt(n,col);
n++;
if(guard%700===0){ setLoad('seeding forests', n/COUNT); await yieldFrame(); }
}
trees.count=n; trees.instanceMatrix.needsUpdate=true;
if(trees.instanceColor) trees.instanceColor.needsUpdate=true;
trees.frustumCulled=false;
scene.add(trees);
// rocks on the high slopes
const rockGeo=new THREE.DodecahedronGeometry(6,0);
const rocks=new THREE.InstancedMesh(rockGeo,new THREE.MeshStandardMaterial({color:0x6a6660,roughness:1,flatShading:true}),520);
rocks.castShadow=true; rocks.receiveShadow=true;
let k=0, g2=0;
while(k<520 && g2<6000){
g2++;
const a=Math.random()*6.283, r=Math.sqrt(Math.random())*6500;
const x=Math.cos(a)*r, z=Math.sin(a)*r;
const h=terrainHeight(x,z); if(h<180) continue;
const s=rand(0.6,2.6);
p.set(x,h-1,z); sc.set(s, s*rand(0.5,1.1), s);
q.setFromEuler(new THREE.Euler(Math.random(),Math.random()*6.28,Math.random()));
m.compose(p,q,sc); rocks.setMatrixAt(k++,m);
}
rocks.count=k; rocks.instanceMatrix.needsUpdate=true; rocks.frustumCulled=false;
scene.add(rocks);
setLoad('growing clouds',0.6);
await yieldFrame();
}
function makeCloudTex(){
const S=256, c=document.createElement('canvas'); c.width=c.height=S;
const g=c.getContext('2d');
g.clearRect(0,0,S,S);
for(let i=0;i<46;i++){
const x=S/2+rand(-70,70), y=S/2+rand(-46,46), r=rand(20,58);
const gr=g.createRadialGradient(x,y,0,x,y,r);
const a=rand(0.10,0.30);
gr.addColorStop(0,'rgba(255,255,255,'+a+')');
gr.addColorStop(1,'rgba(255,255,255,0)');
g.fillStyle=gr; g.beginPath(); g.arc(x,y,r,0,6.283); g.fill();
}
const t=new THREE.CanvasTexture(c); t.colorSpace=THREE.SRGBColorSpace;
return t;
}
function buildClouds(){
const tex=makeCloudTex();
const grp=new THREE.Group();
for(let i=0;i<110;i++){
const m=new THREE.SpriteMaterial({map:tex, transparent:true, depthWrite:false,
opacity:rand(0.30,0.72), color:new THREE.Color().setHSL(0.58,0.10,rand(0.86,1.0)), fog:false});
const s=new THREE.Sprite(m);
const a=Math.random()*6.283, r=600+Math.random()*7200;
s.position.set(Math.cos(a)*r, rand(650,1750), Math.sin(a)*r);
const w=rand(500,1350);
s.scale.set(w, w*rand(0.34,0.55), 1);
grp.add(s);
}
scene.add(grp);
return grp;
}
let clouds=null;
/* ══════════════════════════════════════════════════════════════════
AIRCRAFT SPECIFICATIONS (mathematically differentiated)
══════════════════════════════════════════════════════════════════ */
const SPEC = {
falcon: {
id:'falcon', name:'Falcon S-7', role:'Twin-engine jet interceptor', kind:'jet',
desc:'66 kN of thrust under 8 tonnes. 9 g airframe, 1 400 km/h top end — and a stall that arrives without warning.',
empty:6900, fuel:2700, S:30, b:10.4, c:3.2,
cl0:0.10, clA:4.8, aStall:0.255, cd0:0.021, kInd:0.052, cdAlpha:0.95, cyB:0.70,
cmAlpha:1.45, Cnb:0.15, Clb:-0.10,
thrust:66000, ab:true, spool:1.05, burn:0.088, burnAB:0.30, vMax:400, decay:0.30,
Ixx:9500, Iyy:80000, Izz:88000,
rollAcc:7.0, rollMax:4.6, pitchAcc:6.2, pitchMax:0.95, yawAcc:2.6, yawMax:0.55,
maxG:9, dmgG:11.5, gearH:1.65, flapCl:0.45, flapCd:0.048, flapMax:1, gearDrag:0.020,
brakes:0.55, propTorque:0, propYaw:0, cdGearUp:0,
gear:[{x:0,y:-1.5,z:-4.4,r:0.30,steer:1,brake:0},{x:-2.2,y:-1.6,z:0.5,r:0.34,steer:0,brake:1},{x:2.2,y:-1.6,z:0.5,r:0.34,steer:0,brake:1}],
body:[{x:0,y:-0.15,z:-7.0},{x:0,y:-0.05,z:4.2},{x:-5.2,y:-0.35,z:1.4},{x:5.2,y:-0.35,z:1.4},{x:0,y:-1.0,z:1.5}],
cam:{dist:17,h:4.6,fov:66}, spawnVS:5.5, colors:{body:0xaeb9c4, acc:0xff5a3c, wing:0x9aa6b2}
},
kestrel: {
id:'kestrel', name:'Kestrel 300', role:'Aerobatic prop trainer', kind:'prop', blades:3,
desc:'1.2 tonnes, 26 m/s stall and a roll rate of 370°/s. The most forgiving aircraft — until you stall it on purpose.',
empty:980, fuel:280, S:16.5, b:11.6, c:1.75,
cl0:0.25, clA:5.2, aStall:0.30, cd0:0.026, kInd:0.060, cdAlpha:0.80, cyB:0.85,
cmAlpha:1.05, Cnb:0.16, Clb:-0.14,
thrust:7200, ab:false, spool:2.4, burn:0.10, burnAB:0, vMax:132, decay:0.85,
Ixx:1150, Iyy:2500, Izz:4300,
rollAcc:13.0, rollMax:6.4, pitchAcc:9.5, pitchMax:1.55, yawAcc:3.4, yawMax:0.85,
maxG:8, dmgG:10, gearH:1.15, flapCl:0.55, flapCd:0.055, flapMax:1, gearDrag:0.026,
brakes:0.45, propTorque:0.00075, propYaw:0.00035, cdGearUp:0,
gear:[{x:0,y:-1.0,z:-2.5,r:0.24,steer:1,brake:0},{x:-1.55,y:-1.0,z:0.35,r:0.28,steer:0,brake:1},{x:1.55,y:-1.0,z:0.35,r:0.28,steer:0,brake:1}],
body:[{x:0,y:-0.45,z:-4.3},{x:0,y:0.15,z:3.4},{x:-5.8,y:-0.30,z:0.5},{x:5.8,y:-0.30,z:0.5},{x:0,y:-0.75,z:1.2}],
cam:{dist:13,h:3.4,fov:64}, spawnVS:2.6, colors:{body:0xe8e3d8, acc:0x1d6fd0, wing:0xdcd6c8}
},
atlas: {
id:'atlas', name:'Atlas C-9', role:'Four-engine tactical freighter', kind:'prop', blades:4,
desc:'38 tonnes and 150 m² of wing. It needs a kilometre of runway, it will not turn, and it refuses to hurry.',
empty:30000, fuel:8000, S:150, b:40, c:4.6,
cl0:0.28, clA:4.6, aStall:0.285, cd0:0.028, kInd:0.045, cdAlpha:0.70, cyB:0.62,
cmAlpha:1.75, Cnb:0.13, Clb:-0.12,
thrust:168000, ab:false, spool:0.75, burn:0.11, burnAB:0, vMax:142, decay:0.62,
Ixx:820000, Iyy:1500000, Izz:2050000,
rollAcc:2.1, rollMax:1.35, pitchAcc:1.7, pitchMax:0.36, yawAcc:0.9, yawMax:0.24,
maxG:3.6, dmgG:4.6, gearH:2.7, flapCl:0.62, flapCd:0.085, flapMax:1, gearDrag:0.032,
brakes:0.62, propTorque:0.00007, propYaw:0.00004, cdGearUp:0,
gear:[{x:0,y:-2.4,z:-6.2,r:0.55,steer:1,brake:0},{x:-3.4,y:-2.7,z:1.2,r:0.70,steer:0,brake:1},{x:3.4,y:-2.7,z:1.2,r:0.70,steer:0,brake:1}],
body:[{x:0,y:-1.2,z:-13.2},{x:0,y:0.4,z:13.0},{x:-20,y:-1.4,z:2.5},{x:20,y:-1.4,z:2.5},{x:0,y:-2.1,z:2.0}],
cam:{dist:38,h:11,fov:60}, spawnVS:7.5, colors:{body:0x767f7a, acc:0xd8a13a, wing:0x6a736e}
},
bandit: {
id:'bandit', name:'MiG-type bandit', role:'Hostile jet', kind:'jet',
desc:'Opposition aircraft.',
empty:7200, fuel:2200, S:31, b:10.6, c:3.3,
cl0:0.10, clA:4.7, aStall:0.25, cd0:0.024, kInd:0.055, cdAlpha:0.95, cyB:0.70,
cmAlpha:1.4, Cnb:0.15, Clb:-0.10,
thrust:58000, ab:true, spool:1.0, burn:0.09, burnAB:0.30, vMax:370, decay:0.32,
Ixx:9800, Iyy:82000, Izz:90000,
rollAcc:6.4, rollMax:4.2, pitchAcc:5.6, pitchMax:0.85, yawAcc:2.4, yawMax:0.5,
maxG:8.5, dmgG:11, gearH:1.7, flapCl:0.45, flapCd:0.05, flapMax:1, gearDrag:0.02,
brakes:0.5, propTorque:0, propYaw:0, cdGearUp:0,
gear:[{x:0,y:-1.55,z:-4.4,r:0.30,steer:1,brake:0},{x:-2.3,y:-1.65,z:0.5,r:0.34,steer:0,brake:1},{x:2.3,y:-1.65,z:0.5,r:0.34,steer:0,brake:1}],
body:[{x:0,y:-0.15,z:-7.0},{x:0,y:-0.05,z:4.3},{x:-5.3,y:-0.35,z:1.4},{x:5.3,y:-0.35,z:1.4},{x:0,y:-1.0,z:1.5}],
cam:{dist:17,h:4.6,fov:66}, spawnVS:5.5, colors:{body:0x5c6b62, acc:0xc23a2a, wing:0x53605a}
}
};
for(const k in SPEC){
const s = SPEC[k];
const Vref = Math.max(45, s.vMax*0.45);
const qref = 0.5*RHO0*Vref*Vref;
s.Cl_da = s.Ixx*s.rollAcc/(qref*s.S*s.b);
s.Clp = -s.Cl_da*2*Vref/(s.b*s.rollMax);
s.Cm_de = s.Iyy*s.pitchAcc/(qref*s.S*s.c);
s.Cmq = -s.Cm_de*2*Vref/(s.c*s.pitchMax);
s.Cn_dr = s.Izz*s.yawAcc/(qref*s.S*s.b);
s.Cnr = -s.Cn_dr*2*Vref/(s.b*s.yawMax);
const Vt = Math.max(40, s.vMax*0.55), qt = 0.5*RHO0*Vt*Vt;
const CLt = (s.empty + s.fuel*0.6)*G/(qt*s.S);
s.aTrim = clamp((CLt - s.cl0)/s.clA, -0.05, 0.16);
s.Cm0 = s.CmAlpha*s.aTrim;
s.mass0 = s.empty + s.fuel;
s.kGear = s.mass0*G/(3*0.13);
s.cGear = 0.55*2*Math.sqrt(s.kGear*s.mass0/3);
s.stall = Math.sqrt(2*s.mass0*G/(RHO0*s.S*(s.cl0+s.clA*s.aStall)));
s.stallFlap = Math.sqrt(2*s.mass0*G/(RHO0*s.S*(s.cl0+s.flapCl+s.clA*(s.aStall-0.05))));
}
/* ══════════════════════════════════════════════════════════════════
PROCEDURAL AIRCRAFT MODELS
══════════════════════════════════════════════════════════════════ */
const M = (c,met,rough)=> new THREE.MeshStandardMaterial({color:c, metalness:met===undefined?0.6:met, roughness:rough===undefined?0.34:rough});
function lathe(profile, seg){
const pts = profile.map(p=>new THREE.Vector2(Math.max(0.001,p[1]), p[0]));
const g = new THREE.LatheGeometry(pts, seg||20);
g.rotateX(-Math.PI/2); // +Y axis → -Z (forward)
return g;
}
function plate(pts, thick, bevel){ // planform polygon [[x(rear=pos), y?]] in (x,z): x=span, y=chord(rear+)
const sh = new THREE.Shape();
sh.moveTo(pts[0][0], pts[0][1]);
for(let i=1;i<pts.length;i++) sh.lineTo(pts[i][0], pts[i][1]);
sh.closePath();
const g = new THREE.ExtrudeGeometry(sh, {depth:thick, bevelEnabled:bevel!==false,
bevelThickness:thick*0.3, bevelSize:Math.min(thick*0.5,0.07), bevelSegments:1, steps:1});
g.translate(0,0,-thick/2);
g.rotateX(Math.PI/2); // shape y → world z (chord), extrude → thickness
g.translate(0, thick/2, 0);
return g;
}
function finGeo(pts, thick){ // pts [[z(chord, +rear), y(up)]]
const sh = new THREE.Shape();
sh.moveTo(pts[0][0], pts[0][1]);
for(let i=1;i<pts.length;i++) sh.lineTo(pts[i][0], pts[i][1]);
sh.closePath();
const g = new THREE.ExtrudeGeometry(sh, {depth:thick, bevelEnabled:false, steps:1});
g.translate(0,0,-thick/2);
g.rotateY(-Math.PI/2);
g.translate(thick/2,0,0);
return g;
}
function surfMesh(geo, mat, hinge, axis, host){
const holder = new THREE.Group();
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true;
holder.add(mesh);
holder.position.copy(hinge);
host.add(holder);
holder.userData.axis = axis;
return holder;
}
function addGearLegs(ac, s, host, mat){
const legs=[];
for(const g of s.gear){
const leg = new THREE.Group();
leg.position.set(g.x, g.y+g.r, g.z);
const strut = new THREE.Mesh(new THREE.CylinderGeometry(g.r*0.30, g.r*0.30, Math.abs(g.y)*0.8, 8), mat);
strut.position.y = Math.abs(g.y)*0.4 - g.r*0.2;
strut.castShadow=true; leg.add(strut);
const wm = new THREE.MeshStandardMaterial({color:0x181a1c, roughness:0.85, metalness:0.05});
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(g.r, g.r, g.r*0.85, 14), wm);
wheel.rotation.z = Math.PI/2; wheel.castShadow=true; leg.add(wheel);
const hub = new THREE.Mesh(new THREE.CylinderGeometry(g.r*0.45, g.r*0.45, g.r*0.95, 10), M(0x9aa3ab,0.8,0.4));
hub.rotation.z = Math.PI/2; leg.add(hub);
// hinge for retraction: rotate about local X (folds sideways/back)
const hinge = new THREE.Group();
hinge.position.set(g.x, g.y+g.r+Math.abs(g.y)*0.6, g.z);
leg.position.set(0, -(Math.abs(g.y)*0.6), 0);
hinge.add(leg);
hinge.userData = {up: g.x>=0 ? -1.4 : 1.4, isLeg:true};
host.add(hinge);
legs.push(hinge);
}
return legs;
}
function buildFalcon(colors, hostile){
const s = SPEC.falcon;
const g = new THREE.Group();
const cB = colors.body, cA = colors.acc, cW = colors.wing;
const body = new THREE.Mesh(lathe([
[7.0,0.05],[6.5,0.30],[5.4,0.60],[3.8,0.94],[1.6,1.12],[-1.0,1.16],
[-3.6,1.04],[-5.6,0.84],[-6.6,0.68],[-7.0,0.52]
], 22), M(cB,0.62,0.32));
body.castShadow=true; g.add(body);
const wmat = M(cW,0.6,0.36);
// delta wing
const wing = new THREE.Mesh(plate([[-5.2,1.95],[-5.2,0.7],[0,-2.5],[5.2,0.7],[5.2,1.95],[0,2.35]], 0.30), wmat);
wing.position.y=-0.28; wing.castShadow=true; g.add(wing);
// intakes
for(const sx of [-1,1]){
const it = new THREE.Mesh(new THREE.BoxGeometry(0.5,0.9,3.0), M(0x3a4046,0.4,0.6));
it.position.set(sx*1.15, -0.15, -1.4); g.add(it);
// wing-tip missile rail
const rl = new THREE.Mesh(new THREE.CylinderGeometry(0.16,0.16,3.2,8), M(0xd8dde2,0.4,0.5));
rl.rotation.x=Math.PI/2; rl.position.set(sx*5.15, -0.30, 0.9); g.add(rl);
}
// horizontal stabilators
const hst = new THREE.Mesh(plate([[-2.6,1.5],[-2.6,0.55],[0,-1.5],[2.6,0.55],[2.6,1.5],[0,1.85]], 0.20), wmat);
hst.position.set(0,0.25,5.4); hst.castShadow=true; g.add(hst);
// vertical fin
const fin = new THREE.Mesh(finGeo([[4.0,-0.2],[6.9,-0.2],[6.6,2.9],[4.9,2.9]], 0.22), M(cA,0.5,0.42));
fin.position.set(0,0.9,0); fin.castShadow=true; g.add(fin);
// canopy
const can = new THREE.Mesh(new THREE.SphereGeometry(1,16,12),
new THREE.MeshStandardMaterial({color:0x16323f, metalness:0.9, roughness:0.06, transparent:true, opacity:0.62}));
can.scale.set(0.78,0.62,2.1); can.position.set(0,0.95,-2.6); g.add(can);
// nozzles
const nozzMat = new THREE.MeshStandardMaterial({color:0x2a2c30, metalness:0.9, roughness:0.4, emissive:0xff4400, emissiveIntensity:0});
for(const sx of [-0.62,0.62]){
const nz = new THREE.Mesh(new THREE.CylinderGeometry(0.5,0.62,1.0,14,1,true), nozzMat);
nz.rotation.x=Math.PI/2; nz.position.set(sx,-0.1,6.6); g.add(nz);
}
// afterburner
const abMat = new THREE.MeshBasicMaterial({color:0xffb070, transparent:true, opacity:0.0, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide});
const ab = new THREE.Mesh(new THREE.ConeGeometry(0.7,4.4,14,1,true), abMat);
ab.rotation.x=-Math.PI/2; ab.position.set(0,-0.1,9.4); g.add(ab);
// control surfaces
const ailL = surfMesh(plate([[-1.55,0.0],[-1.55,-1.15],[-4.0,-0.55],[-4.0,0.5]],0.14,false), wmat, new THREE.Vector3(-1.7,-0.30,2.0),'x',g);
const ailR = surfMesh(plate([[4.0,0.5],[4.0,-0.55],[1.55,-1.15],[1.55,0.0]],0.14,false), wmat, new THREE.Vector3(1.7,-0.30,2.0),'x',g);
const rud = surfMesh(finGeo([[6.6,-0.1],[7.9,-0.1],[7.7,2.7],[6.6,2.7]],0.16), M(cA,0.5,0.42), new THREE.Vector3(0,0.9,0),'y',g);
const gearMat = M(0x8f979e,0.7,0.45);
const legs = addGearLegs(null, s, g, gearMat);
g.userData = {ail:[ailL,ailR], elev:[], rud, legs, props:[], nozzMat, ab, kind:'jet', span:s.b};
return g;
}
function buildKestrel(colors){
const s = SPEC.kestrel;
const g = new THREE.Group();
const cB=colors.body, cA=colors.acc, cW=colors.wing;
const body = new THREE.Mesh(lathe([
[4.2,0.06],[3.9,0.42],[3.2,0.66],[2.2,0.78],[0.6,0.80],[-1.2,0.74],
[-2.6,0.60],[-3.6,0.44],[-4.2,0.30]
],18), M(cB,0.35,0.42));
body.castShadow=true; g.add(body);
const cowl = new THREE.Mesh(new THREE.CylinderGeometry(0.82,0.72,0.9,18), M(cA,0.5,0.35));
cowl.rotation.x=Math.PI/2; cowl.position.z=-4.1; cowl.castShadow=true; g.add(cowl);
const wmat = M(cW,0.3,0.5);
const wing = new THREE.Mesh(plate([[-5.8,0.55],[-5.7,-0.35],[-4.6,-0.55],[0,-1.15],[4.6,-0.55],[5.7,-0.35],[5.8,0.55],[4.6,0.85],[0,1.0],[-4.6,0.85]],0.24), wmat);
wing.position.y=-0.42; wing.castShadow=true; g.add(wing);
const hst = new THREE.Mesh(plate([[-1.9,0.9],[-1.9,0.1],[0,-0.75],[1.9,0.1],[1.9,0.9],[0,1.05]],0.14), wmat);
hst.position.set(0,0.28,3.5); hst.castShadow=true; g.add(hst);
const fin = new THREE.Mesh(finGeo([[2.7,-0.5],[4.15,-0.5],[4.0,1.9],[3.3,1.9]],0.16), M(cA,0.3,0.5));
fin.position.set(0,0.7,0); fin.castShadow=true; g.add(fin);
const can = new THREE.Mesh(new THREE.SphereGeometry(1,14,10),
new THREE.MeshStandardMaterial({color:0x1a3542, metalness:0.85, roughness:0.07, transparent:true, opacity:0.6}));
can.scale.set(0.56,0.52,1.35); can.position.set(0,0.72,-1.1); g.add(can);
// spinner + propeller
const spinner = new THREE.Mesh(new THREE.ConeGeometry(0.30,0.75,14), M(cA,0.6,0.3));
spinner.rotation.x=-Math.PI/2; spinner.position.z=-4.75; g.add(spinner);
const propHub = new THREE.Group(); propHub.position.z=-4.6; g.add(propHub);
const bladeMat = M(0x1a1c1e,0.4,0.6);
for(let i=0;i<s.blades;i++){
const bl = new THREE.Mesh(new THREE.BoxGeometry(0.16,1.9,0.06), bladeMat);
bl.position.y=0.95; bl.castShadow=true;
const h = new THREE.Group(); h.rotation.z = i*6.283/s.blades; h.add(bl); propHub.add(h);
}
const disc = new THREE.Mesh(new THREE.CircleGeometry(1.95,24),
new THREE.MeshBasicMaterial({color:0xcfd8dd, transparent:true, opacity:0, side:THREE.DoubleSide, depthWrite:false}));
disc.position.z=-4.62; g.add(disc);
const exh = [];
for(const sx of [-1,1]){ const e=new THREE.Mesh(new THREE.CylinderGeometry(0.1,0.13,0.8,8), M(0x3a3a3a,0.8,0.5));
e.rotation.x=Math.PI/2; e.position.set(sx*0.6,-0.45,-3.2); g.add(e); }
const ailL = surfMesh(plate([[-1.6,0.0],[-1.6,-0.8],[-4.4,-0.5],[-4.4,0.25]],0.12,false), wmat, new THREE.Vector3(-1.7,-0.46,0.95),'x',g);
const ailR = surfMesh(plate([[4.4,0.25],[4.4,-0.5],[1.6,-0.8],[1.6,0.0]],0.12,false), wmat, new THREE.Vector3(1.7,-0.46,0.95),'x',g);
const elev = surfMesh(plate([[-1.85,0.0],[-1.85,-0.6],[1.85,-0.6],[1.85,0.0]],0.10,false), wmat, new THREE.Vector3(0,0.30,4.3),'x',g);
const rud = surfMesh(finGeo([[4.0,-0.4],[5.0,-0.4],[4.9,1.7],[4.0,1.7]],0.12), M(cA,0.3,0.5), new THREE.Vector3(0,0.7,0),'y',g);
const legs = addGearLegs(null, s, g, M(0x9aa3aa,0.7,0.45));
// wheel pants
for(const sx of [-1,1]){
const p = new THREE.Mesh(new THREE.SphereGeometry(0.42,12,10), M(cW,0.3,0.45));
p.scale.set(0.62,0.72,1.5); p.position.set(sx*1.55,-0.75,0.35); p.castShadow=true; g.add(p);
}
g.userData = {ail:[ailL,ailR], elev:[elev], rud, legs, props:[propHub], disc, nozzMat:null, ab:null, kind:'prop', span:s.b};
return g;
}
function buildAtlas(colors){
const s = SPEC.atlas;
const g = new THREE.Group();
const cB=colors.body, cA=colors.acc, cW=colors.wing;
const body = new THREE.Mesh(lathe([
[13.2,0.05],[12.6,0.55],[11.4,1.35],[9.6,1.85],[4.0,2.0],[-4.0,2.0],
[-8.5,1.85],[-11.0,1.45],[-12.6,0.95],[-13.2,0.55]
],22), M(cB,0.45,0.5));
body.castShadow=true; g.add(body);
const wmat = M(cW,0.45,0.5);
const wing = new THREE.Mesh(plate([[-20,3.4],[-19.6,0.6],[0,-3.0],[19.6,0.6],[20,3.4],[0,4.6]],0.55), wmat);
wing.position.y=2.5; wing.castShadow=true; g.add(wing);
const hst = new THREE.Mesh(plate([[-6.6,2.2],[-6.6,0.4],[0,-2.0],[6.6,0.4],[6.6,2.2],[0,3.0]],0.30), wmat);
hst.position.set(0,1.2,11.4); hst.castShadow=true; g.add(hst);
const fin = new THREE.Mesh(finGeo([[9.6,-1.0],[13.4,-1.0],[12.6,5.4],[10.6,5.4]],0.42), M(cA,0.4,0.5));
fin.position.set(0,1.6,0); fin.castShadow=true; g.add(fin);
const can = new THREE.Mesh(new THREE.SphereGeometry(1,14,10),
new THREE.MeshStandardMaterial({color:0x15303d, metalness:0.85, roughness:0.1, transparent:true, opacity:0.6}));
can.scale.set(1.5,0.8,2.4); can.position.set(0,1.35,-11.4); g.add(can);
const props=[], discs=[];
const nx=[-13.2,-5.6,5.6,13.2];
for(let i=0;i<4;i++){
const nac = new THREE.Mesh(new THREE.CylinderGeometry(0.95,0.8,4.6,14), M(0x565e63,0.6,0.42));
nac.rotation.x=Math.PI/2; nac.position.set(nx[i],2.1,-2.6); nac.castShadow=true; g.add(nac);
const sp = new THREE.Mesh(new THREE.ConeGeometry(0.55,1.4,14), M(cA,0.6,0.32));
sp.rotation.x=-Math.PI/2; sp.position.set(nx[i],2.1,-5.4); g.add(sp);
const hub = new THREE.Group(); hub.position.set(nx[i],2.1,-5.2); g.add(hub);
for(let k=0;k<4;k++){
const bl = new THREE.Mesh(new THREE.BoxGeometry(0.30,3.4,0.10), M(0x1c1e20,0.4,0.6));
bl.position.y=1.7; bl.castShadow=true;
const h=new THREE.Group(); h.rotation.z=k*1.5708; h.add(bl); hub.add(h);
}
props.push(hub);
const d = new THREE.Mesh(new THREE.CircleGeometry(3.6,24),
new THREE.MeshBasicMaterial({color:0xc9d3d8, transparent:true, opacity:0, side:THREE.DoubleSide, depthWrite:false}));
d.position.set(nx[i],2.1,-5.25); g.add(d); discs.push(d);
// engine exhaust stain / jet pipe
const p = new THREE.Mesh(new THREE.CylinderGeometry(0.4,0.45,1.2,10), M(0x33383c,0.7,0.5));
p.rotation.x=Math.PI/2; p.position.set(nx[i],1.9,-0.4); g.add(p);
}
const ailL = surfMesh(plate([[-3.4,0.6],[-3.4,-1.4],[-16,-0.4],[-16,1.6]],0.26,false), wmat, new THREE.Vector3(-3.6,2.3,4.0),'x',g);
const ailR = surfMesh(plate([[16,1.6],[16,-0.4],[3.4,-1.4],[3.4,0.6]],0.26,false), wmat, new THREE.Vector3(3.6,2.3,4.0),'x',g);
const elev = surfMesh(plate([[-6.5,0.6],[-6.5,-1.2],[6.5,-1.2],[6.5,0.6]],0.22,false), wmat, new THREE.Vector3(0,1.24,13.4),'x',g);
const rud = surfMesh(finGeo([[13.0,-0.8],[15.2,-0.8],[14.6,5.0],[13.0,5.0]],0.30), M(cA,0.4,0.5), new THREE.Vector3(0,1.6,0),'y',g);
const legs = addGearLegs(null, s, g, M(0x8b9299,0.7,0.45));
g.userData = {ail:[ailL,ailR], elev:[elev], rud, legs, props, discs, nozzMat:null, ab:null, kind:'prop', span:s.b};
return g;
}
function buildModel(specId, colors){
const s = SPEC[specId];
const c = colors || s.colors;
if(specId==='kestrel') return buildKestrel(c);
if(specId==='atlas') return buildAtlas(c);
return buildFalcon(c, specId==='bandit');
}
/* ══════════════════════════════════════════════════════════════════
AIRCRAFT INSTANCE
══════════════════════════════════════════════════════════════════ */
let AC_ID = 1;
function makeAircraft(specId, colors){
const spec = SPEC[specId];
const model = buildModel(specId, colors);
model.traverse(o=>{ if(o.isMesh){ o.castShadow=true; } });
scene.add(model);
return {
uid: AC_ID++, spec, id: specId, model,
pos: new THREE.Vector3(), vel: new THREE.Vector3(), quat: new THREE.Quaternion(),
omega: new THREE.Vector3(), // world-frame angular velocity
throttle:0, rpm:0, fuel:spec.fuel, brakes:true, gearDown:true, gearAnim:1,
flaps:0, flapAnim:0,
cmd:{pitch:0,roll:0,yaw:0}, surf:{pitch:0,roll:0,yaw:0},
onGround:false, wasGround:false, agl:0, ias:0, alt:0, vs:0, hdg:0, aoa:0, side:0,
gLoad:1, stallAmt:0, qbar:0, V:0, rpmPct:0, heat:0, dmg:0, hp:100, alive:true,
radius: spec.b*0.5+2, overG:0, gTimer:0, propSpin:0, abLevel:0,
_fwd:new THREE.Vector3(), _rgt:new THREE.Vector3(), _up:new THREE.Vector3(),
_t1:new THREE.Vector3(), _t2:new THREE.Vector3(), _t3:new THREE.Vector3(), _t4:new THREE.Vector3(),
kind: spec.kind, hostile:false, lastTouch:null
};
}
/* ══════════════════════════════════════════════════════════════════
WIND + EVENTS
══════════════════════════════════════════════════════════════════ */
let simT = 0;
const EV = {};
const emit = (n,a,b,c)=>{ if(EV[n]) EV[n](a,b,c); };
const _wv = new THREE.Vector3();
function getWind(p, t){
const altF = smoothstep(0, 1400, p.y);
const g = 0.4 + altF*9.0;
const gust = 1 + 0.35*Math.sin(t*0.9 + p.x*0.001) * altF;
return _wv.set(
(Math.sin(t*0.07 + p.z*0.00021)*0.7 + 0.35)*g*gust,
Math.sin(t*0.13 + p.x*0.0004)*g*0.16,
Math.cos(t*0.052 + p.x*0.00019)*g*gust
);
}
/* ══════════════════════════════════════════════════════════════════
SIX-DEGREE-OF-FREEDOM NEWTONIAN FLIGHT DYNAMICS
══════════════════════════════════════════════════════════════════ */
const _r = new THREE.Vector3(), _vp = new THREE.Vector3(), _wd = new THREE.Vector3(),
_wl = new THREE.Vector3(), _wq = new THREE.Quaternion(), _qi = new THREE.Quaternion(),
_wheelDir = new THREE.Vector3(), _latDir = new THREE.Vector3(), _ff = new THREE.Vector3();
function stepAircraft(ac, dt){
if(!ac.alive) return;
const s = ac.spec;
const m = s.empty + ac.fuel;
ac.mass = m;
/* --- actuator dynamics: control-surface lag + rate limit --- */
const slew = s.kind==='prop' ? 7.5 : 6.2;
const rate = (s.kind==='prop' ? 5.5 : 4.4)*dt;
for(const k of ['pitch','roll','yaw']){
const prev = ac.surf[k];
const tgt = clamp(ac.cmd[k], -1, 1);
let v = damp(prev, tgt, slew, dt);
ac.surf[k] = clamp(v, prev-rate, prev+rate);
}
ac.flapAnim = damp(ac.flapAnim, ac.flaps, 1.8, dt);
ac.gearAnim = damp(ac.gearAnim, ac.gearDown?1:0, 1.5, dt);
/* --- engine: spool, thrust, fuel --- */
ac.rpm = damp(ac.rpm, ac.throttle, s.spool, dt);
const rho = RHO0*Math.exp(-Math.max(0, ac.pos.y)/8500);
const sig = rho/RHO0;
ac.sig = sig; ac.rho = rho;
const fwd = ac._fwd.set(0,0,-1).applyQuaternion(ac.quat);
const rgt = ac._rgt.set(1,0,0).applyQuaternion(ac.quat);
const up = ac._up.set(0,1,0).applyQuaternion(ac.quat);
let thrust = 0; ac.abLevel = 0;
if(ac.fuel > 0){
thrust = s.thrust*ac.rpm*Math.pow(sig, 0.8);
const vf = clamp(ac.vel.length()/s.vMax, 0, 1);
thrust *= Math.max(s.kind==='prop' ? 0.06 : 0.34, 1 - s.decay*vf);
if(s.ab && ac.throttle > 0.92){
ac.abLevel = (ac.throttle-0.92)/0.08;
thrust *= 1 + ac.abLevel*0.42;
}
const burn = s.burn*ac.throttle + s.burnAB*ac.abLevel;
ac.fuel = Math.max(0, ac.fuel - burn*dt);
}
ac.thrust = thrust;
ac.rpmPct = ac.fuel>0 ? ac.rpm*(100 + ac.abLevel*8) : 0;
const gh = terrainHeight(ac.pos.x, ac.pos.z);
ac.agl = ac.pos.y - gh; ac.gh = gh;
/* ---------------- AERODYNAMICS ---------------- */
const Fa = ac._fa = ac._fa || new THREE.Vector3();
const Fg = ac._fg = ac._fg || new THREE.Vector3();
const Tg = ac._tg = ac._tg || new THREE.Vector3();
Fa.set(0,0,0); Fg.set(0,0,0); Tg.set(0,0,0);
let L=0, Mm=0, Nn=0;
ac.gLoad = 1; ac.stallAmt = 0; ac.aoa = 0; ac.side = 0; ac.qbar = 0;
const air = ac._t1.copy(ac.vel).sub(getWind(ac.pos, simT));
const V = air.length();
ac.V = V;
ac.ias = V*Math.sqrt(sig);
if(V > 0.9){
const vHat = ac._t3.copy(air).normalize();
const u = air.dot(fwd), vlat = air.dot(rgt), wdn = -air.dot(up);
const alpha = u > 0.5 ? Math.atan2(wdn, u) : (wdn>0?0.6:-0.6);
const beta = Math.asin(clamp(vlat/V, -1, 1));
const qbar = 0.5*rho*V*V;
ac.qbar = qbar; ac.aoa = alpha; ac.side = beta;
const flap = ac.flapAnim;
const aS = Math.max(0.12, s.aStall - flap*0.05);
const cl0 = s.cl0 + flap*s.flapCl;
const lin = cl0 + s.clA*alpha;
const pk = cl0 + s.clA*aS;
const aa = Math.abs(alpha);
let CL;
if(aa <= aS){ CL = lin; }
else{
const ex = clamp((aa-aS)/Math.max(0.3,(Math.PI/2 - aS)), 0, 1);
CL = sgn(lin||1)*pk*Math.max(0.14, Math.cos(ex*1.85));
ac.stallAmt = clamp((aa-aS)/0.13, 0, 1);
}
const ge = Math.exp(-Math.max(0, ac.agl)/(s.b*0.55)); // ground effect
CL *= (1 + 0.15*ge);
const CD = s.cd0 + flap*s.flapCd
+ ((ac.gearDown && ac.gearAnim>0.5) ? s.gearDrag : 0)
+ s.kInd*CL*CL*(1 - 0.40*ge)
+ s.cdAlpha*alpha*alpha;
const liftDir = ac._t4.copy(rgt).cross(vHat); // ⟂ velocity, in symmetry plane
if(liftDir.lengthSq() < 1e-6) liftDir.copy(up);
liftDir.normalize();
const Lmag = qbar*s.S*CL, Dmag = qbar*s.S*CD, Ymag = qbar*s.S*s.cyB*beta;
Fa.addScaledVector(liftDir, Lmag)
.addScaledVector(vHat, -Dmag)
.addScaledVector(rgt, -Ymag);
ac.gLoad = liftDir.dot(up)*Lmag/(m*G);
/* moments — aero axes (L roll, M pitch, N yaw) */
const p = ac.omega.dot(fwd);
const qc = ac.omega.dot(rgt);
const rr = -ac.omega.dot(up);
const Vd = Math.max(V, 6);
const ctrlEff = 1 - 0.55*ac.stallAmt;
let gFac = 1;
const nA = Math.abs(ac.gLoad);
if(nA > s.maxG*0.93) gFac = clamp(1 - (nA - s.maxG*0.93)/(s.maxG*0.30), 0.10, 1);
ac.gFac = gFac;
const de = ac.surf.pitch*gFac*ctrlEff;
const da = ac.surf.roll*ctrlEff;
const dr = ac.surf.yaw*ctrlEff;
const stallRoll = ac.stallAmt*0.10*Math.sin(simT*6.7 + ac.uid*2.1);
L += qbar*s.S*s.b*( s.Cl_da*da + s.Clp*(p*s.b/(2*Vd)) + s.Clb*beta + stallRoll );
Mm += qbar*s.S*s.c*( s.Cm_de*de + s.Cmq*(qc*s.c/(2*Vd)) - s.CmAlpha*(alpha - s.aTrim) + s.Cm0 );
Nn += qbar*s.S*s.b*( s.Cn_dr*dr + s.Cnr*(rr*s.b/(2*Vd)) + s.Cnb*beta );
/* propeller reactions */
if(s.propTorque){ L -= s.propTorque*thrust; Nn += s.propYaw*thrust*Math.max(0,alpha); }
/* structural over-G */
if(nA > s.maxG){
ac.overG += (nA - s.maxG)*dt*0.55;
ac.gTimer = Math.max(ac.gTimer, nA - s.maxG);
} else ac.overG = Math.max(0, ac.overG - dt*0.35);
if(ac.overG > 1 && ac.hp > 0){ emit('breakup', ac); }
}
/* ---------------- GROUND CONTACT ---------------- */
const gearActive = ac.gearDown && ac.gearAnim > 0.5;
let anyContact = false, minVn = 0, firstTouch = !ac.wasGround;
const steerMax = 0.42*(1 - smoothstep(4, 34, Math.abs(ac.vel.dot(fwd))));
const steerAng = ac.surf.yaw*steerMax;
if(gearActive && ac.agl < 5.5){
const mEff = m/3;
for(let i=0;i<s.gear.length;i++){
const g = s.gear[i];
_r.set(g.x, g.y, g.z).applyQuaternion(ac.quat);
const px = ac.pos.x+_r.x, py = ac.pos.y+_r.y, pz = ac.pos.z+_r.z;
const th = terrainHeight(px, pz);
const pen = (th + g.r) - py;
if(pen <= 0) continue;
anyContact = true;
// velocity of the contact point
_vp.copy(ac.omega).cross(_r).add(ac.vel);
const vn = _vp.y;
if(vn < minVn) minVn = vn;
let Fn = s.kGear*Math.min(pen, 0.6) - s.cGear*vn;
if(Fn < 0) Fn = 0;
Fg.y += Fn;
// wheel-frame friction
if(g.steer){ _wheelDir.copy(fwd).multiplyScalar(Math.cos(steerAng)).addScaledVector(rgt, Math.sin(steerAng)); }
else _wheelDir.copy(fwd);
_wheelDir.y = 0;
if(_wheelDir.lengthSq() < 1e-6) _wheelDir.set(0,0,-1);
_wheelDir.normalize();
_latDir.set(_wheelDir.z, 0, -_wheelDir.x);
const vLong = _vp.x*_wheelDir.x + _vp.z*_wheelDir.z;
const vLat = _vp.x*_latDir.x + _vp.z*_latDir.z;
const stopF = 0.6*mEff*Math.max(1/dt,0);
const muL = (ac.brakes && g.brake) ? s.brakes : 0.024;
let FL = -sgn(vLong)*Math.min(muL*Fn, Math.abs(vLong)*stopF);
let FT = -sgn(vLat) *Math.min(0.85*Fn, Math.abs(vLat)*stopF);
const mag = Math.hypot(FL,FT);
if(mag > 0.95*Fn && mag>0){ const k=0.95*Fn/mag; FL*=k; FT*=k; }
Fg.x += _wheelDir.x*FL + _latDir.x*FT;
Fg.z += _wheelDir.z*FL + _latDir.z*FT;
}
}
/* body-point collision (wings / tail / belly) */
let crashed = false, crashReason='';
if(ac.agl < 4 || !gearActive){
for(const bp of s.body){
_r.set(bp.x, bp.y, bp.z).applyQuaternion(ac.quat);
const py = ac.pos.y + _r.y;
const th = terrainHeight(ac.pos.x+_r.x, ac.pos.z+_r.z);
if(py < th - 0.06){ crashed = true; crashReason = 'terrain impact'; break; }
}
}
/* ground torque — recomputed properly with per-point lever arms */
if(anyContact){
Tg.set(0,0,0);
const mEff2 = m/3;
// recompute compactly for torque fidelity (forces already in Fg)
for(let i=0;i<s.gear.length;i++){
const g = s.gear[i];
_r.set(g.x, g.y, g.z).applyQuaternion(ac.quat);
const py = ac.pos.y+_r.y;
const th = terrainHeight(ac.pos.x+_r.x, ac.pos.z+_r.z);
const pen = (th + g.r) - py;
if(pen <= 0) continue;
_vp.copy(ac.omega).cross(_r).add(ac.vel);
let Fn = s.kGear*Math.min(pen,0.6) - s.cGear*_vp.y;
if(Fn < 0) Fn = 0;
const fLocal = _wd.set(0, Fn, 0);
// approximate friction at this point (same wheel frame logic)
if(g.steer){ _wheelDir.copy(fwd).multiplyScalar(Math.cos(steerAng)).addScaledVector(rgt, Math.sin(steerAng)); }
else _wheelDir.copy(fwd);
_wheelDir.y = 0;
if(_wheelDir.lengthSq() < 1e-6) _wheelDir.set(0,0,-1);
_wheelDir.normalize();
_latDir.set(_wheelDir.z,0,-_wheelDir.x);
const vLong = _vp.x*_wheelDir.x + _vp.z*_wheelDir.z;
const vLat = _vp.x*_latDir.x + _vp.z*_latDir.z;
const stopF = 0.6*mEff2*Math.max(1/dt,0);
const muL = (ac.brakes && g.brake) ? s.brakes : 0.024;
let FL = -sgn(vLong)*Math.min(muL*Fn, Math.abs(vLong)*stopF);
let FT = -sgn(vLat) *Math.min(0.85*Fn, Math.abs(vLat)*stopF);
const mag = Math.hypot(FL,FT);
if(mag > 0.95*Fn && mag>0){ const k=0.95*Fn/mag; FL*=k; FT*=k; }
fLocal.addScaledVector(_wheelDir, FL).addScaledVector(_latDir, FT);
// r × F
Tg.x += _r.y*fLocal.z - _r.z*fLocal.y;
Tg.y += _r.z*fLocal.x - _r.x*fLocal.z;
Tg.z += _r.x*fLocal.y - _r.y*fLocal.x;
}
L += Tg.dot(fwd);
Mm += Tg.dot(rgt);
Nn += -Tg.dot(up);
}
/* touchdown / liftoff bookkeeping */
if(firstTouch && anyContact){
const bank = Math.acos(clamp(up.y, -1, 1));
const info = {vs:minVn, bank:bank, ias:ac.ias, lat:ac.vel.x*0+0,
off:ac.pos.x, yaw:ac.hdg};
ac.lastTouch = info;
emit('touchdown', ac, info);
if(minVn < -s.spawnVS){ crashed = true; crashReason = 'excessive sink rate'; }
if(bank > 0.26){ crashed = true; crashReason = 'wing strike'; }
}
ac.wasGround = anyContact;
ac.onGround = anyContact;
/* ---------------- INTEGRATION ---------------- */
const F = _wd.copy(Fa).add(Fg);
F.y -= m*G;
F.addScaledVector(fwd, thrust);
ac.vel.addScaledVector(F, dt/m);
ac.pos.addScaledVector(ac.vel, dt);
/* angular: Euler's equations on principal axes */
const p = ac.omega.dot(fwd);
const qc = ac.omega.dot(rgt);
const rr = -ac.omega.dot(up);
const pd = (L - (s.Izz - s.Iyy)*qc*rr)/s.Ixx;
const qd = (Mm - (s.Ixx - s.Izz)*rr*p )/s.Iyy;
const rd = (Nn - (s.Iyy - s.Ixx)*p *qc)/s.Izz;
const p2 = p + pd*dt, q2 = qc + qd*dt, r2 = rr + rd*dt;
ac.omega.set(0,0,0)
.addScaledVector(fwd, p2)
.addScaledVector(rgt, q2)
.addScaledVector(up, -r2);
const wm = ac.omega.length();
if(wm > 9) ac.omega.multiplyScalar(9/wm);
_qi.copy(ac.quat).invert();
_wl.copy(ac.omega).applyQuaternion(_qi);
_wq.set(_wl.x*0.5*dt, _wl.y*0.5*dt, _wl.z*0.5*dt, 1);
ac.quat.multiply(_wq).normalize();
/* derived state */
ac.hdg = (Math.atan2(fwd.x, -fwd.z)*180/Math.PI + 360) % 360;
ac.vs = ac.vel.y;
ac.alt = ac.pos.y;
ac.gTimer = Math.max(0, ac.gTimer - dt*2);
if(crashed && ac.alive){ ac.alive = false; emit('crash', ac, crashReason); }
}
/* ══════════════════════════════════════════════════════════════════
MODEL SYNCHRONISATION / VISUALS
══════════════════════════════════════════════════════════════════ */
function syncModel(ac, dt){
ac.model.position.copy(ac.pos);
ac.model.quaternion.copy(ac.quat);
const u = ac.model.userData;
if(!u) return;
const roll = ac.surf.roll, pit = ac.surf.pitch, yaw = ac.surf.yaw;
if(u.ail && u.ail.length===2){
u.ail[0].rotation.x = roll*0.40;
u.ail[1].rotation.x = -roll*0.40;
} else if(u.ail && u.ail.length){
u.ail.forEach(a=>a.rotation.x = roll*0.35);
}
if(u.elev) for(const e of u.elev) e.rotation.x = pit*0.42;
if(u.rud) u.rud.rotation.y = yaw*0.45;
if(u.legs) for(const leg of u.legs) leg.rotation.x = (1-ac.gearAnim)*1.75;
if(u.props && u.props.length){
const spin = 2 + ac.rpm*(u.kind==='prop'?46:70);
for(const pr of u.props) pr.rotation.z += spin*dt;
if(u.disc) u.disc.material.opacity = clamp((ac.rpm-0.12)*0.42, 0, 0.34);
if(u.discs) for(const d of u.discs) d.material.opacity = clamp((ac.rpm-0.12)*0.40, 0, 0.32);
}
if(u.nozzMat) u.nozzMat.emissiveIntensity = ac.throttle*1.5 + ac.abLevel*2.6;
if(u.ab){
const on = ac.abLevel;
u.ab.material.opacity = on*(0.55 + 0.18*Math.sin(simT*47));
u.ab.scale.set(0.8+on*0.5, 0.7+on*0.7, 0.8+on*0.6);
}
ac.model.visible = ac.alive;
}
/* ══════════════════════════════════════════════════════════════════
GLOBAL STATE / INPUT
══════════════════════════════════════════════════════════════════ */
const state = {
screen:'loading', scenario:'takeoff', acId:'falcon', sound:true, mouseAim:true,
paused:false, playing:false, camMode:0, time:0, menuOrbit:0
};
const keys = Object.create(null);
const input = { mx:0, my:0, pitch:0, roll:0, yaw:0, fire:false, throttleDir:0 };
const KEY_HELP = [
['W / ↑','Pitch down'],['S / ↓','Pitch up (pull)'],
['A / ←','Roll left'],['D / →','Roll right'],
['Q / E','Rudder left / right'],['SHIFT / CTRL','Throttle up / down'],
['Z / X','Full / idle throttle'],['B','Wheel brakes (hold)'],
['G','Landing gear'],['F','Flaps 0 → half → full'],
['C','Cycle camera'],['SPACE','Fire guns'],
['P / ESC','Pause'],['M','Toggle mouse aim']
];
addEventListener('keydown', e=>{
if(e.repeat){ if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault(); return; }
keys[e.code] = true;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space','Tab'].includes(e.code)) e.preventDefault();
if(e.code==='Escape' || e.code==='KeyP'){ togglePause(); }
if(!state.playing || state.paused) return;
if(e.code==='KeyG' && player){ player.gearDown = !player.gearDown; toast(player.gearDown?'GEAR DOWN':'GEAR UP'); beep(520,0.07); }
if(e.code==='KeyF' && player){
player.flaps = player.flaps>=1?0:(player.flaps>=0.5?1:0.5);
toast('FLAPS '+Math.round(player.flaps*100)+'%'); beep(660,0.06);
}
if(e.code==='KeyC'){ state.camMode = (state.camMode+1)%3; toast(['CHASE VIEW','COCKPIT VIEW','CINEMA VIEW'][state.camMode]); }
if(e.code==='KeyB' && player){ player.brakes = !player.brakes; toast(player.brakes?'BRAKES ON':'BRAKES OFF'); }
if(e.code==='KeyZ' && player){ player.throttle = 1; }
if(e.code==='KeyX' && player){ player.throttle = 0; }
if(e.code==='KeyM'){ state.mouseAim = !state.mouseAim; const t=document.getElementById('tglMouse');
if(t){ t.classList.toggle('on', state.mouseAim); t.textContent = (state.mouseAim?'◎ Mouse aim on':'◎ Mouse aim off'); } }
});
addEventListener('keyup', e=>{ keys[e.code] = false; });
addEventListener('mousemove', e=>{
input.mx = clamp((e.clientX - innerWidth/2)/(innerWidth*0.42), -1, 1);
input.my = clamp((e.clientY - innerHeight/2)/(innerHeight*0.42), -1, 1);
});
addEventListener('blur', ()=>{ for(const k in keys) keys[k]=false; });
function updateControls(ac, dt){
let p = (keys.KeyS||keys.ArrowDown ?1:0) - (keys.KeyW||keys.ArrowUp?1:0);
let r = (keys.KeyD||keys.ArrowRight?1:0) - (keys.KeyA||keys.ArrowLeft?1:0);
let y = (keys.KeyE?1:0) - (keys.KeyQ?1:0);
if(state.mouseAim && document.pointerLockElement===null){
p += input.my; r += input.mx;
}
ac.cmd.pitch = clamp(p,-1,1);
ac.cmd.roll = clamp(r,-1,1);
ac.cmd.yaw = clamp(y,-1,1);
const th = (keys.ShiftLeft||keys.ShiftRight?1:0) - (keys.ControlLeft||keys.ControlRight?1:0);
if(th) ac.throttle = clamp(ac.throttle + th*0.55*dt, 0, 1);
input.fire = !!keys.Space;
}
/* ══════════════════════════════════════════════════════════════════
CAMERA
══════════════════════════════════════════════════════════════════ */
const cam = {
q:new THREE.Quaternion(), pos:new THREE.Vector3(), look:new THREE.Vector3(),
fov:66, up:new THREE.Vector3(0,1,0), shake:0, t:0
};
const _c1=new THREE.Vector3(), _c2=new THREE.Vector3(), _c3=new THREE.Vector3(), _cUp=new THREE.Vector3();
function updateCamera(ac, dt){
cam.t += dt;
const s = ac.spec;
let shakeAmp = ac.stallAmt*0.45 + ac.gTimer*0.9
+ clamp(ac.V/s.vMax,0,1)*0.10
+ (ac.onGround ? clamp(Math.abs(ac.vel.length())/60,0,1)*0.14 : 0)
+ ac.abLevel*0.06;
cam.shake = damp(cam.shake, shakeAmp, 6, dt);
const sh = cam.shake;
if(state.camMode===1){ // cockpit
_c1.set(0, 1.72, -1.1).applyQuaternion(ac.quat);
camera.position.copy(ac.pos).add(_c1);
camera.position.x += Math.sin(cam.t*63)*sh*0.16;
camera.position.y += Math.sin(cam.t*71+1.3)*sh*0.16;
camera.quaternion.copy(ac.quat);
_c3.set(Math.sin(cam.t*57)*sh*0.006, Math.sin(cam.t*83+2)*sh*0.006, 0);
camera.rotateX(_c3.y); camera.rotateY(_c3.x);
} else if(state.camMode===2){ // cinema / flyby
const off = ac.onGround ? 0 : 1;
_c1.set(11+off*4, 3.4, 7).applyQuaternion(ac.quat);
_c2.copy(ac.pos).add(_c1);
_c2.y = Math.max(_c2.y, terrainHeight(_c2.x,_c2.z)+1.6);
cam.pos = cam.pos.lerp(_c2, 1-Math.exp(-2.6*dt));
camera.position.copy(cam.pos);
cam.look.lerp(_c3.copy(ac.pos).addScaledVector(ac.vel, 0.22), 1-Math.exp(-6*dt));
camera.up.set(0,1,0);
camera.lookAt(cam.look);
} else { // chase
cam.q.slerp(ac.quat, 1-Math.exp(-3.2*dt));
_c1.set(0, s.cam.h, s.cam.dist).applyQuaternion(cam.q);
_c2.copy(ac.pos).add(_c1);
_c2.y = Math.max(_c2.y, terrainHeight(_c2.x,_c2.z)+1.8);
cam.pos = cam.pos.lerp(_c2, 1-Math.exp(-7.5*dt));
camera.position.copy(cam.pos);
_c3.copy(ac.pos).addScaledVector(ac.fwdCache || ac._fwd, 6).addScaledVector(ac.vel, 0.10);
cam.look.lerp(_c3, 1-Math.exp(-9*dt));
_cUp.set(0,1,0).lerp(ac._up, 0.55).normalize();
camera.up.lerp(_cUp, 1-Math.exp(-5*dt)).normalize();
camera.lookAt(cam.look);
}
camera.position.x += Math.sin(cam.t*59)*sh*0.20;
camera.position.y += Math.sin(cam.t*77+2.1)*sh*0.20;
camera.rotateZ(Math.sin(cam.t*67)*sh*0.010);
const fovT = s.cam.fov + clamp(ac.V/s.vMax,0,1)*15 + ac.abLevel*4;
cam.fov = damp(cam.fov, fovT, 2.4, dt);
if(Math.abs(camera.fov-cam.fov) > 0.02){ camera.fov = cam.fov; camera.updateProjectionMatrix(); }
sun.position.copy(ac.pos).addScaledVector(SUN_DIR, 1400);
sun.target.position.copy(ac.pos);
sun.target.updateMatrixWorld();
}
function resetCamera(ac){
cam.q.copy(ac.quat);
_c1.set(0, ac.spec.cam.h, ac.spec.cam.dist).applyQuaternion(ac.quat);
cam.pos.copy(ac.pos).add(_c1);
cam.look.copy(ac.pos);
camera.position.copy(cam.pos);
camera.up.set(0,1,0);
camera.lookAt(cam.look);
cam.fov = ac.spec.cam.fov; camera.fov = cam.fov; camera.updateProjectionMatrix();
}
/* ══════════════════════════════════════════════════════════════════
AUDIO — everything is driven by the physics state
══════════════════════════════════════════════════════════════════ */
const AudioSys = {
ctx:null, on:true, ready:false,
init(){
if(this.ctx) return;
try{
const AC = window.AudioContext || window.webkitAudioContext;
const ctx = this.ctx = new AC();
this.ctx = ctx;
const master = ctx.createGain(); master.gain.value = 0.9; master.connect(ctx.destination);
this.master = master;
const len = Math.floor(ctx.sampleRate*2);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
let last = 0;
for(let i=0;i<len;i++){ const w=Math.random()*2-1; last=(last+0.03*w)/1.03; d[i]=(w*0.65+last*2.2); }
this.noiseBuf = buf;
const mkNoise = ()=>{
const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true; src.start(); return src;
};
const chain = (node, g, dst)=>{ node.connect(g); g.connect(dst||master); return g; };
// engine tonal core
this.engOsc = ctx.createOscillator(); this.engOsc.type='sawtooth'; this.engOsc.frequency.value=60;
this.engOsc2 = ctx.createOscillator(); this.engOsc2.type='square'; this.engOsc2.frequency.value=120;
this.engGain = ctx.createGain(); this.engGain.gain.value=0;
const lp = ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=1600; lp.Q.value=0.6;
this.engOsc.connect(lp); this.engOsc2.connect(lp); lp.connect(this.engGain); this.engGain.connect(master);
// engine wash / prop noise
this.engN = mkNoise();
this.engNF = ctx.createBiquadFilter(); this.engNF.type='bandpass'; this.engNF.frequency.value=900; this.engNF.Q.value=1.1;
this.engNG = ctx.createGain(); this.engNG.gain.value=0;
this.engN.connect(this.engNF); this.engNF.connect(this.engNG); this.engNG.connect(master);
// airflow / wind over the airframe
this.windN = mkNoise();
this.windF = ctx.createBiquadFilter(); this.windF.type='bandpass'; this.windF.frequency.value=600; this.windF.Q.value=0.55;
this.windG = ctx.createGain(); this.windG.gain.value=0;
this.windN.connect(this.windF); this.windF.connect(this.windG); this.windG.connect(master);
// stall buffet
this.stN = mkNoise();
this.stF = ctx.createBiquadFilter(); this.stF.type='lowpass'; this.stF.frequency.value=230;
this.stG = ctx.createGain(); this.stG.gain.value=0;
this.stN.connect(this.stF); this.stF.connect(this.stG); this.stG.connect(master);
// high-G airframe groan
this.groan = ctx.createOscillator(); this.groan.type='sine'; this.groan.frequency.value=54;
this.groanG = ctx.createGain(); this.groanG.gain.value=0;
this.groan.connect(this.groanG); this.groanG.connect(master); this.groan.start();
// ground rumble (terrain proximity)
this.rumbN = mkNoise();
this.rumbF = ctx.createBiquadFilter(); this.rumbF.type='lowpass'; this.rumbF.frequency.value=90;
this.rumbG = ctx.createGain(); this.rumbG.gain.value=0;
this.rumbN.connect(this.rumbF); this.rumbF.connect(this.rumbG); this.rumbG.connect(master);
// stall warning tone
this.warn = ctx.createOscillator(); this.warn.type='square'; this.warn.frequency.value=940;
this.warnG = ctx.createGain(); this.warnG.gain.value=0;
this.warn.connect(this.warnG); this.warnG.connect(master); this.warn.start();
this.engOsc.start(); this.engOsc2.start();
this.ready = true;
}catch(e){ this.on=false; this.ready=false; }
},
setEnabled(v){
this.on = v;
if(this.master) this.master.gain.setTargetAtTime(v?0.9:0.0, this.ctx.currentTime, 0.05);
},
suspend(v){ if(this.ctx){ v?this.ctx.suspend():this.ctx.resume(); } },
update(ac){
if(!this.ready || !ac) return;
const t = this.ctx.currentTime;
const s = ac.spec, rpm = ac.rpm, V = ac.V, sig = ac.sig||1, flap = ac.flapAnim;
const jet = s.kind==='jet';
const f0 = jet ? (70 + rpm*240 + ac.abLevel*90) : (38 + rpm*96);
const set=(p,v,tc)=>{ if(typeof v==='number' && isFinite(v)) p.setTargetAtTime(v,t,tc||0.06); };
set(this.engOsc.frequency, f0*(0.94+0.06*sig));
set(this.engOsc2.frequency, f0*(jet?2.03:2.0)*(0.94+0.06*sig));
set(this.engGain.gain, this.on ? (jet?0.10:0.13)*rpm*(0.55+0.45*sig) : 0, 0.09);
set(this.engNF.frequency, jet ? 620 + rpm*2400 + ac.abLevel*900 : 260 + rpm*760);
set(this.engNG.gain, this.on ? (jet?0.055+0.16*rpm:0.035+0.11*rpm)*(0.5+0.5*sig) : 0, 0.10);
// wind / slipstream
const wAmt = clamp((V/88)*(V/88),0,1.2)*(0.72+0.28*sig) + flap*0.06;
set(this.windF.frequency, 240 + V*7.2);
set(this.windG.gain, this.on ? wAmt*0.30 : 0, 0.10);
// stall buffet
const buff = clamp(ac.stallAmt,0,1)*(0.20+clamp(V/170,0,1)*0.55);
set(this.stG.gain, this.on ? buff*0.42 : 0, 0.05);
set(this.stF.frequency, 170 + V*1.6);
// G groan
const gn = clamp((Math.abs(ac.gLoad)-3.6)/5,0,1);
set(this.groanG.gain, this.on ? gn*0.16 : 0, 0.12);
// ground rumble: low + fast over tall terrain
let mtn = 0;
for(let i=0;i<4;i++){
const a = i*1.5708;
mtn += terrainHeight(ac.pos.x+Math.cos(a)*340, ac.pos.z+Math.sin(a)*340);
}
mtn = clamp((mtn/4 - 40)/260, 0, 1);
const rg = clamp(1 - ac.agl/700,0,1) * clamp(V/70,0,1);
set(this.rumbG.gain, this.on ? rg*(0.05+mtn*0.16) : 0, 0.15);
// stall warbler
const warnOn = (ac.stallAmt>0.25 || (ac.agl<120 && ac.vs<-8 && !ac.onGround)) ? 1 : 0;
set(this.warnG.gain, this.on ? warnOn*0.035 : 0, 0.05);
set(this.warn.frequency, 780 + Math.sin(t*11)*260);
},
burst(opts){
if(!this.ready||!this.on) return;
const ctx=this.ctx, t=ctx.currentTime;
const src=ctx.createBufferSource(); src.buffer=this.noiseBuf;
src.playbackRate.value=opts.rate||1;
const f=ctx.createBiquadFilter(); f.type=opts.type||'bandpass';
f.frequency.setValueAtTime(opts.f0||900,t);
if(opts.f1) f.frequency.exponentialRampToValueAtTime(opts.f1, t+(opts.dur||0.2));
f.Q.value=opts.Q||1.0;
const g=ctx.createGain();
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(opts.gain||0.3, t+(opts.att||0.006));
g.gain.exponentialRampToValueAtTime(0.0001, t+(opts.dur||0.2));
src.connect(f); f.connect(g); g.connect(this.master);
src.start(t, Math.random()*1.5); src.stop(t+(opts.dur||0.2)+0.05);
if(opts.thump){
const o=ctx.createOscillator(); o.type='sine';
o.frequency.setValueAtTime(opts.thump,t);
o.frequency.exponentialRampToValueAtTime(Math.max(30,opts.thump*0.4), t+(opts.dur||0.2));
const og=ctx.createGain();
og.gain.setValueAtTime(opts.gain?opts.gain*0.8:0.3,t);
og.gain.exponentialRampToValueAtTime(0.0001,t+(opts.dur||0.2));
o.connect(og); og.connect(this.master); o.start(t); o.stop(t+(opts.dur||0.2)+0.05);
}
},
shot(){ this.burst({f0:1500,f1:420,dur:0.13,gain:0.22,Q:0.9,thump:150}); },
boom(dist){
const att = clamp(1 - (dist||0)/2600, 0.03, 1);
this.burst({f0:1400,f1:60,dur:1.4,gain:0.55*att,Q:0.6,att:0.01,thump:80,rate:0.6});
},
thud(dist){
const att = clamp(1 - (dist||0)/1400, 0.05, 1);
this.burst({f0:900,f1:140,dur:0.35,gain:0.4*att,Q:0.8,thump:120,rate:0.9});
}
};
function beep(freq, dur){
if(!AudioSys.ready || !AudioSys.on) return;
const ctx=AudioSys.ctx, t=ctx.currentTime;
const o=ctx.createOscillator(); o.type='square'; o.frequency.value=freq;
const g=ctx.createGain(); g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(0.05,t+0.01);
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(g); g.connect(AudioSys.master); o.start(t); o.stop(t+dur+0.02);
}
/* ══════════════════════════════════════════════════════════════════
WEAPONS & PROJECTILES
══════════════════════════════════════════════════════════════════ */
const bulletPool = [];
function initBullets(){
const geo = new THREE.BoxGeometry(0.26,0.26,12);
for(let i=0;i<150;i++){
const m = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
color:0xffc96a, transparent:true, opacity:0.95,
blending:THREE.AdditiveBlending, depthWrite:false}));
m.visible=false; m.frustumCulled=false; scene.add(m);
bulletPool.push({mesh:m, on:false, p:new THREE.Vector3(), prev:new THREE.Vector3(),
d:new THREE.Vector3(), sp:460, life:0, hostile:false});
}
}
const _bv = new THREE.Vector3(), _bs = new THREE.Vector3(), _bp = new THREE.Vector3();
function fireBullet(owner, hostile){
let b=null;
for(const x of bulletPool){ if(!x.on){ b=x; break; } }
if(!b) return;
const fwd = owner._fwd, rgt = owner._rgt, up = owner._up;
b.on = true; b.hostile = hostile;
b.p.copy(owner.pos).addScaledVector(fwd, owner.spec.b*0.32).addScaledVector(up, 0.1);
b.prev.copy(b.p);
b.d.copy(fwd);
b.d.x += (Math.random()-0.5)*0.011; b.d.y += (Math.random()-0.5)*0.011; b.d.z += (Math.random()-0.5)*0.011;
b.d.normalize();
b.sp = 470;
b.life = 3.0;
b.mesh.material.color.setHex(hostile ? 0xff6a4a : 0xffcf78);
b.mesh.visible = true;
b.mesh.position.copy(b.p);
b.mesh.lookAt(_bv.copy(b.p).add(b.d));
}
function segDist(a, b, p){
_bs.subVectors(b,a);
const l2 = Math.max(1e-6, _bs.lengthSq());
const t = clamp(_bp.subVectors(p,a).dot(_bs)/l2, 0, 1);
return _bs.multiplyScalar(t).add(a).distanceTo(p);
}
function updateBullets(dt){
for(const b of bulletPool){
if(!b.on) continue;
b.life -= dt;
b.prev.copy(b.p);
b.d.y -= 0.010*dt;
b.p.addScaledVector(b.d, b.sp*dt);
b.mesh.position.copy(b.p);
b.mesh.lookAt(_bv.copy(b.p).add(b.d));
let dead = b.life<=0;
if(!dead){
const th = terrainHeight(b.p.x, b.p.z);
if(b.p.y < th){ dead = true; spawnPuff(b.p, 0x9a8f7a, _bv.set(0,1.5,0), 1.2, 0.5, false); }
}
if(!dead){
if(b.hostile && player && player.alive){
const r = 3.4;
if(segDist(b.prev, b.p, player.pos) < r){
dead = true;
damage(player, 7, 'gunfire');
}
} else if(!b.hostile){
for(const e of enemies){
if(!e.alive) continue;
const r = e.spec.b*0.42;
if(segDist(b.prev, b.p, e.pos) < r){
dead = true;
e.hp -= 11; mis.hits++;
spawnPuff(b.p, 0xffd08a, _bv.set(0,1,0), 1.6, 0.35, true);
AudioSys.thud(e.pos.distanceTo(player.pos));
if(e.hp <= 0) killEnemy(e);
break;
}
}
}
}
if(dead){ b.on=false; b.mesh.visible=false; }
}
}
/* ══════════════════════════════════════════════════════════════════
PARTICLES / EXPLOSIONS
══════════════════════════════════════════════════════════════════ */
function makePuffTex(){
const S=64, c=document.createElement('canvas'); c.width=c.height=S;
const g=c.getContext('2d');
const gr=g.createRadialGradient(S/2,S/2,0,S/2,S/2,S/2);
gr.addColorStop(0,'rgba(255,255,255,1)');
gr.addColorStop(0.45,'rgba(255,255,255,0.55)');
gr.addColorStop(1,'rgba(255,255,255,0)');
g.fillStyle=gr; g.fillRect(0,0,S,S);
const t=new THREE.CanvasTexture(c); return t;
}
const puffs = [];
let puffTex = null;
function initParticles(){
puffTex = makePuffTex();
for(let i=0;i<200;i++){
const m = new THREE.SpriteMaterial({map:puffTex, transparent:true, depthWrite:false, opacity:0});
const s = new THREE.Sprite(m);
s.visible=false; s.frustumCulled=false; scene.add(s);
puffs.push({s, on:false, v:new THREE.Vector3(), life:0, max:1, size:1, grow:1, base:1, spin:0});
}
}
function spawnPuff(pos, color, vel, size, life, additive){
let p=null;
for(const x of puffs){ if(!x.on){ p=x; break; } }
if(!p) return;
p.on=true; p.life=life; p.max=life; p.size=size; p.grow=1.9;
p.base = additive?1.0:0.72;
p.v.copy(vel);
p.s.visible=true;
p.s.position.copy(pos);
p.s.scale.set(size,size,1);
p.s.material.color.setHex(color);
p.s.material.blending = additive ? THREE.AdditiveBlending : THREE.NormalBlending;
p.s.material.opacity = p.base;
}
function updateParticles(dt){
for(const p of puffs){
if(!p.on) continue;
p.life -= dt;
if(p.life<=0){ p.on=false; p.s.visible=false; continue; }
p.s.position.addScaledVector(p.v, dt);
p.v.multiplyScalar(1 - 1.4*dt);
p.v.y += (p.size<3? 1.4: -2.2)*dt;
const t = p.life/p.max;
const sc = p.size*(1 + (1-t)*p.grow);
p.s.scale.set(sc,sc,1);
p.s.material.opacity = p.base*t;
}
}
const boomLight = new THREE.PointLight(0xffa347, 0, 700, 2);
scene.add(boomLight);
function explode(pos, size, dist){
size = size||1;
for(let i=0;i<Math.round(20*size);i++){
const a=Math.random()*6.283, el2=Math.random()*3.14;
const sp=rand(6,34)*size;
const v=_bv.set(Math.cos(a)*Math.sin(el2)*sp, Math.cos(el2)*sp*0.8+6, Math.sin(a)*Math.sin(el2)*sp);
spawnPuff(pos, i%3===0?0xfff0c0:(i%3===1?0xff9a3c:0x30302f), v, rand(3,9)*size, rand(0.6,1.7)*(0.7+size*0.4), i%3!==2);
}
boomLight.position.copy(pos);
boomLight.intensity = 380*size;
AudioSys.boom(dist===undefined? camera.position.distanceTo(pos) : dist);
cam.shake = Math.min(1.6, cam.shake + clamp(320/Math.max(60,camera.position.distanceTo(pos)),0,1.4)*size);
}
function damage(ac, amount, cause){
if(!ac.alive) return;
ac.hp -= amount;
if(ac===player){
const f = el('dmgFlash');
f.style.opacity = Math.min(0.9, 0.35 + amount*0.03);
AudioSys.thud(0);
if(ac.hp <= 0){ ac.alive=false; explode(ac.pos, 1.4); emit('crash', ac, cause||'destroyed'); }
}
}
/* ══════════════════════════════════════════════════════════════════
MISSIONS
══════════════════════════════════════════════════════════════════ */
const mis = {
obj:[], lifted:false, landed:false, stopped:false, ended:false, endT:0,
best:null, kills:0, shots:0, hits:0, t0:0, total:4, tor:0, overrun:false, wreckT:0
};
function objItem(t){ return {t, done:false, act:false}; }
function setActive(){
let first = -1;
mis.obj.forEach((o,i)=>{ o.act=false; if(!o.done && first<0) first=i; });
if(first>=0) mis.obj[first].act = true;
}
function initMission(){
mis.kills=0; mis.shots=0; mis.hits=0; mis.best=null; mis.lifted=false;
mis.landed=false; mis.stopped=false; mis.ended=false; mis.endT=0; mis.t0=simT;
mis.tor=0; mis.overrun=false; mis.wreckT=0;
if(state.scenario==='takeoff'){
missionUI.title='PRECISION · TAKEOFF & LANDING';
mis.obj = [
objItem('Throttle > 95% and release the brakes'),
objItem('Accelerate to VR and rotate (S)'),
objItem('Positive rate — gear up (G)'),
objItem('Climb above 800 ft AGL, turn for approach'),
objItem('Final: touch down gently, on the centreline'),
objItem('Brake to a complete stop on the runway')
];
} else {
missionUI.title='COMBAT · AIR SUPERIORITY';
mis.obj = [
objItem('Destroy all '+mis.total+' bandits'),
objItem('Keep the airframe intact')
];
setActive();
missionUI.phase='GUNS FREE';
}
missionUI.items = mis.obj;
}
function updateMission(dt){
if(state.scenario==='takeoff') updateTakeoff(dt); else updateDogfight(dt);
}
function updateTakeoff(dt){
const a = player, s = a.spec;
const vr = Math.round(s.stall*1.15*KT);
const items = mis.obj;
items[0].done = a.throttle>0.95 && !a.brakes && a.onGround && !mis.lifted;
items[1].done = mis.lifted;
items[2].done = mis.lifted && !a.gearDown && a.agl>45;
items[3].done = mis.lifted && a.agl*FT>800;
items[4].done = mis.landed;
items[5].done = mis.stopped;
items[1].t = 'Accelerate to VR '+vr+' kt and rotate (hold S)';
setActive();
const idx = mis.obj.findIndex(o=>!o.done);
missionUI.phase = mis.stopped ? 'COMPLETE' : (idx<0?'—':('STEP '+(idx+1)+'/6'));
// liftoff detection
if(!mis.lifted && !a.onGround && a.agl>3 && a.vel.length()>10){
mis.lifted = true;
mis.tor = Math.max(0, 820 - a.pos.z);
toast('POSITIVE RATE', 'take-off run '+mis.tor.toFixed(0)+' m');
beep(880,0.1);
}
if(!mis.lifted && a.onGround && Math.abs(a.pos.z)>1040 && a.vel.length()<40){
failMission('Runway overrun');
}
if(!a.alive) return;
setStats(
'<div class="row"><span>AIRSPEED</span><b>'+Math.round(a.ias*KT)+' kt</b></div>'+
'<div class="row"><span>VR TARGET</span><b>'+vr+' kt</b></div>'+
'<div class="row"><span>ALT AGL</span><b>'+Math.round(a.agl*FT)+' ft</b></div>'+
'<div class="row"><span>V/S</span><b>'+(a.vs*FT*60>=0?'+':'')+Math.round(a.vs*FT*60)+' fpm</b></div>'+
'<div class="row'+(mis.lifted?'':' hl')+'"><span>BANK</span><b>'+Math.round(-Math.asin(clamp(a._rgt.y,-1,1))*R2D)+'°</b></div>'+
(mis.best?'<div class="row"><span>LAST LANDING</span><b>'+mis.best.grade+'</b></div>':'')+
(mis.lifted&&!mis.landed?'<div class="row"><span>APPROACH</span><b>'+Math.max(0,Math.round(Math.hypot(a.pos.x, a.pos.z)))+' m</b></div>':'')
);
}
function evaluateLanding(info){
const a = player;
const fpm = Math.max(0, -info.vs*FT*60);
const off = Math.abs(info.off);
const bank = info.bank*R2D;
const ias = info.ias*KT;
let grade, score;
if(fpm<=90 && off<10 && bank<5){ grade='BUTTER'; score=100; }
else if(fpm<=170 && off<18 && bank<8){ grade='GOOD'; score=82; }
else if(fpm<=280 && off<28 && bank<12){ grade='HARD'; score=58; }
else { grade='ROUGH'; score=30; }
mis.landed = true;
mis.best = {grade, fpm:Math.round(fpm), off:off.toFixed(1), bank:bank.toFixed(1), ias:Math.round(ias), score};
toast(grade+' LANDING', Math.round(fpm)+' fpm · '+off.toFixed(1)+' m off centreline');
beep(grade==='BUTTER'?1240:grade==='GOOD'?980:grade==='HARD'?620:360, 0.22);
}
function completeTakeoff(){
mis.stopped = true;
const a = player;
const b = mis.best;
const withinRwy = Math.abs(a.pos.z) < 950 && Math.abs(a.pos.x) < 45;
const rows = [
['Landing grade', b?b.grade:'—'],
['Touchdown rate', b?fpm2(b.fpm):'—'],
['Centreline offset', b?b.off+' m':'—'],
['Bank at touchdown', b?b.bank+'°':'—'],
['Touchdown speed', b?b.ias+' kt':'—'],
['Take-off run', mis.tor?mis.tor.toFixed(0)+' m':'—'],
['Stopped on runway', withinRwy?'YES':'NO'],
['Fuel remaining', Math.round(a.fuel)+' kg']
];
let grade='C';
if(b) grade = b.grade==='BUTTER'?'A+':b.grade==='GOOD'?'A':b.grade==='HARD'?'B':'C';
if(!withinRwy && grade.length===1 && grade!=='C') grade='B';
if(!b) grade='D';
endMission(true, grade, withinRwy?'CLEAN RECOVERY':'OFF-FIELD STOP', rows);
}
const fpm2 = v=>v+' fpm';
function updateDogfight(dt){
const a = player;
const alive = enemies.filter(e=>e.alive).length;
mis.obj[0].done = alive===0;
mis.obj[1].done = alive===0;
mis.obj[0].t = 'Destroy all '+mis.total+' bandits — '+mis.kills+'/'+mis.total+' down';
mis.obj[1].t = 'Keep the airframe intact — '+Math.max(0,Math.round(a.hp))+'% hull';
setActive();
missionUI.phase = alive===0?'SECURED':('CONTACTS '+alive);
setStats(
'<div class="row"><span>HULL</span><b>'+Math.max(0,Math.round(a.hp))+'%</b></div>'+
'<div class="bar2"><b style="width:'+clamp(a.hp,0,100)+'%"></b></div>'+
'<div class="row"><span>ROUNDS</span><b>'+a.ammo+'</b></div>'+
'<div class="row'+(a.gunHeat>70?' hl':'')+'"><span>GUN HEAT</span><b>'+Math.round(a.gunHeat)+'%</b></div>'+
'<div class="bar2 cy"><b style="width:'+clamp(a.gunHeat,0,100)+'%"></b></div>'+
'<div class="row"><span>BANDITS</span><b>'+mis.kills+' / '+mis.total+'</b></div>'+
'<div class="row"><span>HITS</span><b>'+mis.hits+' ('+(mis.shots?Math.round(mis.hits/mis.shots*100):0)+'%)</b></div>'+
'<div class="row"><span>ELAPSED</span><b>'+fmtTime(simT-mis.t0)+'</b></div>'
);
if(alive===0 && !mis.ended && mis.kills>0){ endMission(true, gradeDogfight(), 'AIRSPACE CLEAR', dogfightRows()); }
}
function fmtTime(t){
const m=Math.floor(t/60), s=Math.floor(t%60);
return m+':'+(s<10?'0':'')+s;
}
function dogfightRows(){
return [
['Bandits destroyed', mis.kills+' / '+mis.total],
['Rounds fired', String(mis.shots)],
['Rounds on target', mis.hits+' ('+(mis.shots?Math.round(mis.hits/mis.shots*100):0)+'%)'],
['Time on station', fmtTime(simT-mis.t0)],
['Hull remaining', Math.max(0,Math.round(player.hp))+'%'],
['Fuel remaining', Math.round(player.fuel)+' kg']
];
}
function gradeDogfight(){
const acc = mis.shots? mis.hits/mis.shots : 0;
const hp = player.hp;
if(mis.kills<mis.total) return 'C';
if(hp>70 && acc>0.16) return 'A+';
if(hp>55) return 'A';
if(hp>25) return 'B';
return 'C';
}
function killEnemy(e){
if(!e.alive) return;
e.alive=false;
mis.kills++;
explode(e.pos, 1.3);
spawnPuff(e.pos, 0x50504e, _bv.set(2,3,2), 14, 3.0, false);
setTimeout(()=>{ if(e.model) e.model.visible=false; }, 10);
toast('BANDIT DOWN', mis.kills+' / '+mis.total+' destroyed');
beep(1100,0.14);
}
function failMission(reason){
if(mis.ended) return;
mis.ended = true; mis.endT = 1.6;
mis.failReason = reason;
mis.failGrade = 'F';
}
function endMission(win, grade, title, rows){
if(mis.ended) return;
mis.ended = true; mis.endT = 0.6; mis.win = win;
mis.grade = grade; mis.title = title; mis.rows = rows;
}
function showResultScreen(win, grade, title, rows){
state.playing = false;
el('resSub').textContent = win?'MISSION COMPLETE':'MISSION FAILED';
const g = el('resGrade');
g.textContent = grade;
g.className = grade==='F'?'fail':(['A+','A','B'].includes(grade)?'':'mid');
el('resTitle').textContent = title;
el('resStats').innerHTML = rows.map(r=>'<tr><td>'+r[0]+'</td><td>'+r[1]+'</td></tr>').join('');
el('result').classList.remove('hidden');
el('hudDom').classList.add('hidden');
state.screen='result';
AudioSys.suspend(true);
}
/* ─── enemy artificial intelligence (same physics, different orders) ─── */
const _ai1=new THREE.Vector3(), _ai2=new THREE.Vector3(), _ai3=new THREE.Vector3(), _aiq=new THREE.Quaternion();
function aiControl(e, dt){
if(!e.alive) return;
e.brakes=false;
let pitch=0, rollT=0, yawT=0;
const tgt = (player && player.alive) ? player : null;
if(tgt){
_ai1.copy(tgt.pos).sub(e.pos);
const dist = _ai1.length();
_aiq.copy(e.quat).invert();
_ai2.copy(_ai1).applyQuaternion(_aiq);
const bearing = Math.atan2(_ai2.x, -_ai2.z);
const elev = Math.atan2(_ai2.y, Math.max(1,Math.hypot(_ai2.x,_ai2.z)));
const bank = -Math.asin(clamp(e._rgt.y,-1,1));
let desBank = clamp(bearing*2.1, -1.3, 1.3);
if(e.jink>0){ e.jink-=dt; desBank = clamp(-desBank*0.9 + Math.sin(simT*3.4+e.uid)*0.8, -1.3, 1.3); }
const rollRate = e.omega.dot(e._fwd);
rollT = clamp((desBank - bank)*1.6 - rollRate*0.38, -1, 1);
pitch = clamp(elev*2.0 + clamp((Math.abs(desBank)-0.22)*0.8, 0, 0.72), -1, 1);
if(e.agl < 280 && e.vs < 8){
pitch = Math.max(pitch, clamp((280-e.agl)/130, 0, 1));
rollT *= 0.25;
}
const want = e.spec.vMax*(0.60 + (dist>2000?0.22:0.06));
e.throttle = clamp(e.throttle + (e.V<want?0.5:-0.6)*dt, 0, 1);
if(e.agl < 80) e.throttle = 1;
const yawRate = -e.omega.dot(e._up);
yawT = clamp(bearing*1.3 - yawRate*0.5, -1, 1);
_ai3.copy(tgt.pos).sub(e.pos).normalize();
const cosA = _ai3.dot(e._fwd);
e.gunCd = (e.gunCd||0) - dt;
if(cosA > 0.9972 && dist < 1150 && dist > 70 && e.gunCd<=0 && mis.kills>=0){
e.gunCd = 0.17;
fireBullet(e, true);
mis.shots += 0;
if(dist < 2400) AudioSys.shot();
}
if(Math.random() < dt*0.4) e.jink = 1.7;
} else {
rollT = Math.sin(simT*0.4+e.uid)*0.15;
yawT = Math.sin(simT*0.23+e.uid)*0.3;
e.throttle = clamp(e.throttle + (e.spec.vMax*0.55 - e.V)*0.02, 0, 1);
}
if(e.stallAmt > 0.45){ pitch = -0.4; e.throttle = 1; rollT*=0.3; }
e.cmd.pitch = pitch; e.cmd.roll = rollT; e.cmd.yaw = yawT;
}
/* ══════════════════════════════════════════════════════════════════
MENU / FLOW
══════════════════════════════════════════════════════════════════ */
const SIL = {
falcon:'<path d="M50 4 L54 24 L88 42 L88 47 L55 38 L57 50 L66 55 L66 58 L50 54 L34 58 L34 55 L44 50 L46 38 L13 47 L13 42 L46 24 Z" fill="currentColor"/>',
kestrel:'<path d="M50 6 L53 22 L57 26 L86 28 L87 34 L57 33 L56 46 L64 52 L64 55 L50 51 L36 55 L36 52 L44 46 L43 33 L13 34 L14 28 L43 26 L47 22 Z" fill="currentColor"/>',
atlas:'<path d="M50 4 L54 20 L58 24 L92 26 L92 33 L58 32 L58 48 L68 54 L68 57 L50 52 L32 57 L32 54 L42 48 L42 32 L8 33 L8 26 L42 24 L46 20 Z" fill="currentColor"/><circle cx="26" cy="29" r="3.4" fill="#0a0f14"/><circle cx="74" cy="29" r="3.4" fill="#0a0f14"/><circle cx="38" cy="29" r="3" fill="#0a0f14"/><circle cx="62" cy="29" r="3" fill="#0a0f14"/>'
};
function bar(l, v, val){
return '<div class="stat"><i>'+l+'</i><span class="bar"><b style="width:'+Math.round(clamp(v,0,1)*100)+'%"></b></span>'+
'<span style="width:56px;text-align:right;color:#cfe4ee">'+val+'</span></div>';
}
function buildMenu(){
const list = Object.values(SPEC).filter(s=>s.id!=='bandit');
el('acCards').innerHTML = list.map(s=>(
'<div class="card'+(s.id===state.acId?' sel':'')+'" data-id="'+s.id+'">'+
'<div class="cThumb" style="color:'+(s.colors.acc? '#'+s.colors.acc.toString(16).padStart(6,'0'):CY)+'">'+SIL[s.id]+'</div>'+
'<div class="cName">'+s.name+'</div>'+
'<div class="cRole">'+s.role+'</div>'+
'<div class="cDesc">'+s.desc+'</div>'+
bar('SPEED', s.vMax/410, Math.round(s.vMax*3.6)+' km/h')+
bar('T/W', s.thrust/(s.mass0*G), (s.thrust/(s.mass0*G)).toFixed(2))+
bar('ROLL', s.rollMax/6.4, Math.round(s.rollMax*R2D)+'°/s')+
bar('STALL', clamp(1-(s.stall-22)/42,0.08,1), Math.round(s.stall*KT)+' kt')+
bar('MAX G', s.maxG/9, s.maxG.toFixed(1)+' g')+
'<div class="stat" style="margin-top:8px;color:#6d8794"><i>MTOW</i><span>'+ (s.mass0/1000).toFixed(1) +' t</span><i style="width:auto">wing '+s.S+' m²</i></div>'+
'</div>')).join('');
el('acCards').querySelectorAll('.card').forEach(c=>{
c.addEventListener('click', ()=>{
state.acId = c.dataset.id;
el('acCards').querySelectorAll('.card').forEach(x=>x.classList.toggle('sel', x.dataset.id===state.acId));
setMenuPlane(state.acId);
updateSelInfo();
beep(760,0.05);
});
});
const scs = [
{id:'takeoff', icon:'🛬', name:'TAKEOFF & LANDING', desc:'Runway departure, circuit and a precision landing. Manage VR rotation, climb rate, flap and gear schedules — and hit the numbers on final.',
tags:['PRECISION','WIND','SCORING','6 STEPS']},
{id:'dogfight', icon:'🎯', name:'AIR COMBAT PATROL', desc:'Four bandits, guns free. Sustained turn rate, energy management and G-limits decide who comes home. Radar, hull damage and gun overheat.',
tags:['4 BANDITS','HIGH-G','RADAR','GUNS']}
];
el('scCards').innerHTML = scs.map(s=>(
'<div class="sc'+(s.id===state.scenario?' sel':'')+'" data-id="'+s.id+'">'+
'<div class="sIcon">'+s.icon+'</div>'+
'<div class="sName">'+s.name+'</div>'+
'<div class="sDesc">'+s.desc+'</div>'+
'<div class="sTags">'+s.tags.map(t=>'<span>'+t+'</span>').join('')+'</div>'+
'</div>')).join('');
el('scCards').querySelectorAll('.sc').forEach(c=>{
c.addEventListener('click', ()=>{
state.scenario = c.dataset.id;
el('scCards').querySelectorAll('.sc').forEach(x=>x.classList.toggle('sel', x.dataset.id===state.scenario));
updateSelInfo(); beep(700,0.05);
});
});
el('keysGrid').innerHTML = KEY_HELP.map(k=>'<div class="krow"><kbd>'+k[0]+'</kbd>'+k[1]+'</div>').join('');
el('ctrlTbl').innerHTML = KEY_HELP.map(k=>'<tr><td>'+k[0]+'</td><td>'+k[1]+'</td></tr>').join('');
el('revInfo').textContent = 'THREE.JS r'+(window.__THREE_REV||'-');
updateSelInfo();
}
function updateSelInfo(){
const s = SPEC[state.acId];
const brief = state.scenario==='takeoff'
? 'BRIEFING — depart RWY 18 heading north. Rotate at '+Math.round(s.stall*1.15*KT)+' kt, gear up after positive rate, climb 800 ft, then fly a circuit back for a soft touchdown and a full-stop taxi.'
: 'BRIEFING — four jet bandits inbound to the north at 600 m. Guns free on contact. Watch your turn rate: pull below '+s.maxG.toFixed(0)+' g or the airframe starts letting go.';
el('selInfo').innerHTML = '<span style="color:'+CY+'">'+s.name.toUpperCase()+'</span> · MTOW '+(s.mass0/1000).toFixed(1)+
' t · thrust '+(s.thrust/1000).toFixed(0)+' kN · Vs '+Math.round(s.stall*KT)+' kt · Vmax '+Math.round(s.vMax*3.6)+
' km/h · limit '+s.maxG.toFixed(0)+' g<br/>'+brief;
}
let menuPlane = null;
function setMenuPlane(id){
if(menuPlane){ scene.remove(menuPlane.model); }
menuPlane = makeAircraft(id);
menuPlane.gearDown=false; menuPlane.gearAnim=0; menuPlane.throttle=1; menuPlane.rpm=1;
menuPlane.fuel = SPEC[id].fuel;
menuPlane.model.traverse(o=>{ if(o.isMesh){ o.castShadow=true; } });
}
function menuCam(dt){
state.menuOrbit += dt*0.05;
const a = state.menuOrbit;
const r = 700;
const x = Math.cos(a)*r, z = Math.sin(a)*r;
const y = terrainHeight(x,z) + 190 + Math.sin(a*0.8)*30;
camera.position.lerp(_m1.set(x,y,z), 1-Math.exp(-2.5*dt));
camera.up.set(0,1,0);
const tx = Math.cos(a+2.5)*260, tz = Math.sin(a+2.5)*260;
camera.lookAt(tx, terrainHeight(tx,tz)+90, tz);
camera.fov = 56; camera.updateProjectionMatrix();
if(menuPlane){
const pa = -a*2.4 + 1.2, R2 = 430;
const px = Math.cos(pa)*R2, pz = Math.sin(pa)*R2;
const py = terrainHeight(px,pz) + 230;
const tv = _m2.set(-Math.sin(pa), 0, Math.cos(pa));
menuPlane.pos.set(px,py,pz);
const yaw = Math.atan2(-tv.x, -tv.z);
menuPlane.quat.setFromAxisAngle(_m3.set(0,1,0), yaw);
const rollQ = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,0,-1), 0.42);
menuPlane.quat.multiply(rollQ);
menuPlane.vel.set(tv.x*95,0,tv.z*95);
menuPlane.surf.pitch=0.1; menuPlane.surf.roll=0.1; menuPlane.surf.yaw=0;
menuPlane.omega.set(0,0,0);
menuPlane.model.position.copy(menuPlane.pos);
menuPlane.model.quaternion.copy(menuPlane.quat);
syncModel(menuPlane, dt);
}
}
const _m1=new THREE.Vector3(), _m2=new THREE.Vector3(), _m3=new THREE.Vector3(), _m4=new THREE.Vector3();
function startGame(){
AudioSys.init();
if(AudioSys.ready) AudioSys.suspend(false);
AudioSys.setEnabled(state.sound);
// clear previous
if(player){ scene.remove(player.model); player=null; }
for(const e of enemies){ scene.remove(e.model); }
enemies = [];
for(const b of bulletPool){ b.on=false; b.mesh.visible=false; }
if(menuPlane){ menuPlane.model.visible=false; }
player = makeAircraft(state.acId);
player.hostile=false;
player.hp = 100;
player.ammo = 700; player.gunHeat = 0; player.gunCd = 0;
if(state.scenario==='takeoff'){
player.pos.set(0, WORLD.runwayY + player.spec.gearH - 0.10, 820);
player.quat.identity();
player.vel.set(0,0,0);
player.throttle = 0; player.brakes = true; player.gearDown=true;
} else {
player.pos.set(60, 640, 760);
player.quat.identity();
player.vel.set(0,0,-118);
player.throttle = 0.92; player.brakes=false; player.gearDown=false; player.gearAnim=0;
player.flaps = 0;
spawnBandits();
}
player.rpm = player.throttle*0.6;
state.playing = true; state.paused=false; state.screen='play'; state.time=0;
mis.ended=false; mis.win=false; mis.total = state.scenario==='dogfight'?4:0;
initMission();
el('menu').classList.add('hidden');
el('result').classList.add('hidden');
el('pause').classList.add('hidden');
el('hudDom').classList.remove('hidden');
el('dmgFlash').style.opacity = 0;
updateHintBar();
_objSig=''; _statSig=''; _warnSig='';
resetCamera(player);
toast(state.scenario==='takeoff'?'CLEARED FOR DEPARTURE':'GUNS FREE',
state.scenario==='takeoff'?'release brakes (B) — full throttle (SHIFT)':'bandits inbound to the north');
beep(520,0.09);
}
function spawnBandits(){
const cols = {body:0x5f6b63, acc:0xc4382a, wing:0x53605a};
for(let i=0;i<mis.total;i++){
const e = makeAircraft('bandit', cols);
e.hostile = true; e.hp = 90 + i*8; e.alive = true;
e.pos.set(rand(-760,760), 600 + rand(-140,150), -1750 - i*330);
e.quat.setFromAxisAngle(new THREE.Vector3(0,1,0), Math.PI + rand(-0.35,0.35));
e.vel.set(0,0,1).applyQuaternion(e.quat).multiplyScalar(130);
e.throttle = 0.85; e.rpm = 0.8; e.brakes=false; e.gearDown=false; e.gearAnim=0;
e.flaps=0; e.jink=0; e.gunCd=1.5+i*0.4;
e.fuel = SPEC.bandit.fuel;
enemies.push(e);
}
}
function togglePause(){
if(!state.playing || mis.ended) return;
state.paused = !state.paused;
el('pause').classList.toggle('hidden', !state.paused);
AudioSys.suspend(state.paused);
}
function backToHangar(){
state.playing=false; state.paused=false; state.screen='menu';
el('pause').classList.add('hidden');
el('result').classList.add('hidden');
el('hudDom').classList.add('hidden');
el('menu').classList.remove('hidden');
if(menuPlane) menuPlane.model.visible=true;
if(player){ scene.remove(player.model); player=null; }
for(const e of enemies) scene.remove(e.model);
enemies=[];
AudioSys.suspend(true);
}
function wireButtons(){
el('btnStart').addEventListener('click', startGame);
el('btnResume').addEventListener('click', togglePause);
el('btnRestart').addEventListener('click', ()=>{ el('pause').classList.add('hidden'); state.paused=false; startGame(); });
el('btnHangar').addEventListener('click', backToHangar);
el('btnAgain').addEventListener('click', ()=>{ startGame(); });
el('btnHangar2').addEventListener('click', backToHangar);
el('tglSound').addEventListener('click', ()=>{
state.sound = !state.sound;
const t = el('tglSound');
t.classList.toggle('on', state.sound);
t.textContent = state.sound?'◉ Sound on':'◌ Sound off';
AudioSys.setEnabled(state.sound);
});
el('tglMouse').addEventListener('click', ()=>{
state.mouseAim = !state.mouseAim;
const t = el('tglMouse');
t.classList.toggle('on', state.mouseAim);
t.textContent = state.mouseAim?'◎ Mouse aim on':'◎ Mouse aim off';
});
}
/* ══════════════════════════════════════════════════════════════════
EVENTS
══════════════════════════════════════════════════════════════════ */
EV.touchdown = (ac, info)=>{
if(ac!==player) return;
if(state.scenario==='takeoff' && mis.lifted && !mis.ended){
evaluateLanding(info);
}
if(ac.onGround) AudioSys.thud(ac.pos.distanceTo(camera.position));
};
EV.crash = (ac, reason)=>{
explode(ac.pos, ac===player?1.5:1.2);
spawnPuff(ac.pos, 0x3a3a38, _bv.set(0,4,0), 16, 3.4, false);
if(ac===player){
failMission(reason||('Airframe destroyed'));
mis.rows = [
['Cause', reason||'Destroyed'],
['Time airborne', fmtTime(simT-mis.t0)],
['Bandits down', state.scenario==='dogfight'?(mis.kills+' / '+mis.total):'—'],
['Position', Math.round(ac.pos.x)+' m E / '+Math.round(ac.pos.z)+' m N'],
['Altitude', Math.round(ac.agl*FT)+' ft AGL']
];
} else {
if(enemies.includes(ac) && ac.alive){ ac.alive=false; mis.kills++;
toast('BANDIT DOWN', mis.kills+' / '+mis.total+' destroyed'); }
}
};
EV.breakup = (ac)=>{
if(!ac.alive || ac.overGChecked) return;
ac.overGChecked = true;
ac.alive = false;
explode(ac.pos, 1.5);
if(ac===player){
failMission('Structural failure — G-limit exceeded');
mis.rows = [['Cause','Airframe broke up under G-load'],
['Peak over-G', (ac.gTimer+ac.spec.maxG).toFixed(1)+' g'],
['Time airborne', fmtTime(simT-mis.t0)]];
}
};
/* ══════════════════════════════════════════════════════════════════
MAIN LOOP
══════════════════════════════════════════════════════════════════ */
let acc = 0;
const FIXED = 1/120;
function updateWeapons(dt){
if(!player || !player.alive) return;
player.gunCd -= dt;
player.gunHeat = Math.max(0, player.gunHeat - dt*15);
if(state.scenario!=='dogfight') return;
const canFire = input.fire && player.ammo>0 && player.gunHeat<97 && player.gunCd<=0;
if(canFire){
player.gunCd = 0.062;
player.ammo--; mis.shots++;
player.gunHeat += 7.5;
fireBullet(player, false);
AudioSys.shot();
const fp = _w1.copy(player.pos).addScaledVector(player._fwd, player.spec.b*0.34);
spawnPuff(fp, 0xffdca0, _w1b.copy(player._fwd).multiplyScalar(6), 1.6, 0.07, true);
}
}
const _w1=new THREE.Vector3(), _w1b=new THREE.Vector3();
function updateDOM(dt){
if(_toastT>0){ _toastT -= dt; if(_toastT<=0) el('toast').classList.remove('show'); }
const dmg = el('dmgFlash');
const cur = parseFloat(dmg.style.opacity||0);
if(cur>0) dmg.style.opacity = Math.max(0, cur - dt*1.4);
if(player){
const gg = clamp((Math.abs(player.gLoad)-4)/7, 0, 1);
el('gshade').style.opacity = (gg*0.75).toFixed(2);
}
}
function updateWarnings(){
if(!player || !player.alive) return;
const a = player;
let w='', cls='';
if(a.stallAmt>0.30){ w='STALL'; cls=''; }
else if(a.agl<160 && a.vs<-6 && !a.onGround){ w='PULL UP'; cls=''; }
else if(a.gFac!==undefined && a.gFac<0.55){ w='G LIMIT'; cls='amber'; }
else if(a.gearDown && a.agl<700 && a.onGround===false && a.ias*KT>Math.round(a.spec.stall*KT*2.1) && state.scenario==='takeoff' && mis.lifted){ w='GEAR'; cls='amber'; }
else if(a.fuel < a.spec.fuel*0.1){ w='FUEL LOW'; cls='amber'; }
else if(state.scenario==='dogfight' && a.gunHeat>96){ w='GUN OVERHEAT'; cls='amber'; }
setWarn(w, cls);
}
function updateWarningsSafe(){ try{ updateWarnings(); }catch(e){} }
function loop(){
requestAnimationFrame(loop);
const dt = Math.min(clock.getDelta(), 0.06);
if(state.screen==='menu'){
menuCam(dt);
renderer.render(scene,camera);
return;
}
if(state.screen==='loading' || state.screen==='result'){
renderer.render(scene,camera);
return;
}
if(state.playing && !state.paused){
simT += dt;
state.time += dt;
if(player && player.alive) updateControls(player, dt);
acc += dt;
let steps = 0;
while(acc >= FIXED && steps < 10){
if(player) stepAircraft(player, FIXED);
for(const e of enemies){
if(!e.alive) continue;
aiControl(e, FIXED);
stepAircraft(e, FIXED);
}
acc -= FIXED; steps++;
}
if(acc > FIXED*10) acc = 0;
updateWeapons(dt);
updateBullets(dt);
updateParticles(dt);
if(player) syncModel(player, dt);
for(const e of enemies){
if(e.alive && e.hp<45 && Math.random()<dt*10){
spawnPuff(e.pos, 0x2c2c2c, _w1.set(rand(-2,2),rand(1,3),rand(-2,2)), rand(3,6), rand(1,2), false);
}
syncModel(e, dt);
}
if(player){
updateMission(dt);
updateCamera(player, dt);
drawHUD(player, dt);
AudioSys.update(player);
updateWarningsSafe();
updateDOM(dt);
renderObjectives();
}
// mission end timers
if(mis.ended){
if(player && !player.alive) mis.endT -= dt*0.55;
else mis.endT -= dt;
if(mis.endT <= 0 && state.playing){
if(mis.win) showResultScreen(true, mis.grade, mis.title, mis.rows||[]);
else showResultScreen(false, 'F', mis.failReason||'Mission failed', mis.rows||[['Result','FAILED']]);
}
}
boomLight.intensity = Math.max(0, boomLight.intensity - dt*900);
} else {
boomLight.intensity = Math.max(0, boomLight.intensity - dt*900);
updateParticles(dt);
if(state.screen==='play' && player) drawHUD(player, dt);
}
renderer.render(scene, camera);
}
const clock = new THREE.Clock();
/* ══════════════════════════════════════════════════════════════════
BOOT
══════════════════════════════════════════════════════════════════ */
function onResize(){
renderer.setSize(innerWidth, innerHeight, false);
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
resizeHud();
}
addEventListener('resize', onResize);
async function init(){
try{
resizeHud();
setLoad('carving terrain', 0.02);
await buildTerrain();
setLoad('flooded the coastline', 0.72); await yieldFrame();
water = buildWater();
setLoad('laying the runway', 0.78); await yieldFrame();
buildAirfield();
setLoad('seeding forests', 0.84); await buildScatter();
setLoad('growing clouds', 0.94); await yieldFrame();
clouds = buildClouds();
initBullets(); initParticles();
setMenuPlane(state.acId);
buildMenu(); wireButtons();
onResize();
setLoad('ready', 1);
state.screen='menu';
setTimeout(()=>{ el('loading').classList.add('hidden'); }, 260);
}catch(err){
el('loadStatus').textContent = 'init error: '+err.message;
console.error(err);
}
}
/* ══════════════════════════════════════════════════════════════════
HUD — canvas instruments + DOM panels
══════════════════════════════════════════════════════════════════ */
let player = null, enemies = [], particles = [];
const hudC = document.getElementById('hud');
const hg = hudC.getContext('2d');
let HW=innerWidth, HH=innerHeight, U=1;
const CY='#7fe3ff', AM='#ffb020', RD='#ff4a3d', GR='#57e39a', WH='#e8f4fa';
function resizeHud(){
const dpr = Math.min(devicePixelRatio||1, 2);
HW = innerWidth; HH = innerHeight;
hudC.width = Math.round(HW*dpr); hudC.height = Math.round(HH*dpr);
hudC.style.width = HW+'px'; hudC.style.height = HH+'px';
hg.setTransform(dpr,0,0,dpr,0,0);
U = clamp(Math.min(HW,HH)/760, 0.6, 1.6);
}
const R2D = 180/Math.PI;
function roundRect(x,y,w,h,r){
hg.beginPath();
hg.moveTo(x+r,y); hg.arcTo(x+w,y,x+w,y+h,r); hg.arcTo(x+w,y+h,x,y+h,r);
hg.arcTo(x,y+h,x,y,r); hg.arcTo(x,y,x+w,y,r); hg.closePath();
}
function txt(s,x,y,size,color,align,bold){
hg.font = (bold?'700 ':'500 ')+Math.round(size)+'px ui-monospace, Menlo, Consolas, monospace';
hg.fillStyle=color; hg.textAlign=align||'left'; hg.textBaseline='middle';
hg.fillText(s,x,y);
}
const _hv = new THREE.Vector3();
function drawAttitude(ac){
const cx = HW/2, cy = HH*0.46, R = 116*U;
const pitch = Math.asin(clamp(ac._fwd.y,-1,1));
const bank = -Math.asin(clamp(ac._rgt.y,-1,1));
const PXD = 3.1*U;
hg.save();
hg.beginPath(); hg.arc(cx,cy,R,0,6.2832); hg.clip();
hg.translate(cx,cy); hg.rotate(-bank);
hg.translate(0, pitch*R2D*PXD);
const big = R*4;
hg.fillStyle='rgba(46,110,168,0.16)'; hg.fillRect(-big,-big-1,big*2,big+1);
hg.fillStyle='rgba(122,84,34,0.22)'; hg.fillRect(-big,1,big*2,big);
hg.strokeStyle='rgba(232,244,250,0.92)'; hg.lineWidth=1.6*U;
hg.beginPath(); hg.moveTo(-big,0); hg.lineTo(big,0); hg.stroke();
hg.lineWidth=1.1*U; hg.strokeStyle='rgba(232,244,250,0.55)';
for(let d=-60; d<=60; d+=5){
if(d===0) continue;
const y = -d*PXD;
const major = d%10===0;
const len = major? 30*U : 14*U;
const gap = major? 26*U : 14*U;
hg.beginPath(); hg.moveTo(gap,y); hg.lineTo(gap+len,y); hg.stroke();
hg.beginPath(); hg.moveTo(-gap-len,y); hg.lineTo(-gap,y); hg.stroke();
if(major){
txt(String(Math.abs(d)), gap+len+6*U, y, 10*U, 'rgba(232,244,250,0.85)','left');
txt(String(Math.abs(d)), -gap-len-6*U, y, 10*U, 'rgba(232,244,250,0.85)','right');
}
}
hg.restore();
hg.save(); hg.translate(cx,cy);
hg.strokeStyle='rgba(232,244,250,0.5)'; hg.lineWidth=1.4*U;
for(const a of [-60,-45,-30,-20,-10,0,10,20,30,45,60]){
const r1 = R+4*U, r2 = R + (a%30===0? 13*U: 8*U);
const ang = -a*Math.PI/180 - Math.PI/2;
hg.beginPath();
hg.moveTo(Math.cos(ang)*r1, Math.sin(ang)*r1);
hg.lineTo(Math.cos(ang)*r2, Math.sin(ang)*r2);
hg.stroke();
}
hg.rotate(bank);
hg.fillStyle=CY;
hg.beginPath(); hg.moveTo(0,-R-4*U); hg.lineTo(-6*U,-R+8*U); hg.lineTo(6*U,-R+8*U); hg.closePath(); hg.fill();
hg.restore();
hg.save(); hg.translate(cx,cy);
hg.strokeStyle=AM; hg.lineWidth=2.2*U;
hg.beginPath(); hg.moveTo(-46*U,0); hg.lineTo(-14*U,0); hg.lineTo(-7*U,7*U); hg.stroke();
hg.beginPath(); hg.moveTo(46*U,0); hg.lineTo(14*U,0); hg.lineTo(7*U,7*U); hg.stroke();
hg.fillStyle=AM; hg.beginPath(); hg.arc(0,0,2.6*U,0,6.283); hg.fill();
hg.restore();
const v = _hv.copy(ac.vel).sub(getWind(ac.pos, simT));
const vf=v.dot(ac._fwd), vr=v.dot(ac._rgt), vu=v.dot(ac._up);
if(vf > 4){
const ya = Math.atan2(vr,vf)*R2D*PXD, pa = Math.atan2(vu,vf)*R2D*PXD;
let fx = ya, fy = -pa;
const dd = Math.hypot(fx,fy), maxd = R*1.35;
if(dd > maxd){ fx = fx/dd*maxd; fy = fy/dd*maxd; }
hg.save(); hg.translate(cx+fx, cy+fy);
hg.strokeStyle=GR; hg.lineWidth=1.7*U;
hg.beginPath(); hg.arc(0,0,7*U,0,6.283); hg.stroke();
hg.beginPath(); hg.moveTo(-13*U,0); hg.lineTo(-7*U,0);
hg.moveTo(13*U,0); hg.lineTo(7*U,0);
hg.moveTo(0,-7*U); hg.lineTo(0,-12*U); hg.stroke();
hg.restore();
}
hg.strokeStyle='rgba(127,227,255,0.35)'; hg.lineWidth=1.2*U;
hg.beginPath(); hg.arc(cx,cy,R,0,6.2832); hg.stroke();
}
function drawTape(x, val, unit, pxPer, step, fmt, side, halfH){
const w = 92*U;
hg.save();
hg.fillStyle='rgba(5,9,13,0.50)';
roundRect(x-w/2, HH*0.46-halfH, w, halfH*2, 3*U); hg.fill();
hg.clip();
const top = val + halfH/pxPer, bot = val - halfH/pxPer;
for(let vv = Math.ceil(bot/step)*step; vv <= top; vv += step){
if(vv < 0) continue;
const y = HH*0.46 - (vv-val)*pxPer;
const maj = (Math.round(vv) % (step*2) === 0);
const tx = side<0 ? x+w/2-6*U : x-w/2+6*U;
const lx = side<0 ? x+w/2-16*U : x-w/2+16*U;
hg.strokeStyle='rgba(232,244,250,0.55)'; hg.lineWidth=1.2*U;
hg.beginPath(); hg.moveTo(tx,y); hg.lineTo(tx-(side<0?9*U:-9*U),y); hg.stroke();
if(maj) txt(fmt(vv), lx, y, 12*U, 'rgba(232,244,250,0.9)', side<0?'right':'left');
}
hg.restore();
const bh = 26*U;
hg.fillStyle='rgba(5,9,13,0.88)';
roundRect(x-w/2, HH*0.46-bh/2, w, bh, 3*U); hg.fill();
hg.strokeStyle=CY; hg.lineWidth=1.4*U; hg.stroke();
txt(fmt(val), x, HH*0.46+0.5*U, 17*U, WH, 'center', true);
txt(unit, side<0? x-w/2+5*U : x+w/2-5*U, HH*0.46+bh/2+11*U, 9.5*U, 'rgba(127,227,255,0.75)', side<0?'left':'right');
}
function drawHeading(ac){
const w = 430*U, x = HW/2, y = 44*U, h = 30*U;
hg.save();
hg.fillStyle='rgba(5,9,13,0.50)';
roundRect(x-w/2, y-h/2, w, h, 3*U); hg.fill(); hg.clip();
const pxDeg = w/90;
for(let d=-45; d<=45; d+=5){
const vv = (Math.round(ac.hdg/5)*5 + d + 720)%360;
const ddx = ((vv - ac.hdg + 540)%360) - 180;
const px = x + ddx*pxDeg;
if(Math.abs(ddx) > 48) continue;
const maj = vv%30===0;
hg.strokeStyle='rgba(232,244,250,0.6)'; hg.lineWidth=1.2*U;
hg.beginPath(); hg.moveTo(px, y-h/2+3*U); hg.lineTo(px, y-h/2+(maj?11*U:7*U)); hg.stroke();
if(maj){
const lbl = vv===0?'N':(vv===90?'E':(vv===180?'S':(vv===270?'W':String(vv/10))));
txt(lbl, px, y+h/2-9*U, 11.5*U, vv%90===0?CY:'rgba(232,244,250,0.9)', 'center', vv%90===0);
}
}
hg.restore();
hg.fillStyle=CY;
hg.beginPath(); hg.moveTo(x, y+h/2+7*U); hg.lineTo(x-5*U, y+h/2+1*U); hg.lineTo(x+5*U, y+h/2+1*U); hg.closePath(); hg.fill();
const hd = Math.round(((ac.hdg%360)+360)%360);
hg.fillStyle='rgba(5,9,13,0.9)';
roundRect(x-30*U, y-11*U, 60*U, 22*U, 3*U); hg.fill();
hg.strokeStyle=CY; hg.lineWidth=1.3*U; hg.stroke();
txt(String(hd), x, y, 14*U, WH, 'center', true);
}
function drawEngine(ac){
const s = ac.spec;
const x = 30*U, base = HH - 46*U;
const bars = [
{l:'THR', v:ac.throttle, c: ac.abLevel>0?AM:CY, extra: ac.abLevel>0?'AB '+Math.round(ac.throttle*100)+'%':Math.round(ac.throttle*100)+'%'},
{l:'RPM', v:clamp(ac.rpmPct/100,0,1.1), c: GR, extra: Math.round(ac.rpmPct)+'%'},
{l:'FUEL',v:ac.fuel/s.fuel, c: ac.fuel/s.fuel<0.15?RD:'#8fd0ff', extra: Math.round(ac.fuel)+' kg'}
];
bars.forEach((b,i)=>{
const bx = x + i*48*U, bh = 96*U, by = base - bh - 24*U;
hg.fillStyle='rgba(5,9,13,0.55)';
roundRect(bx, by, 26*U, bh, 2*U); hg.fill();
hg.fillStyle=b.c;
const fh = clamp(b.v,0,1)*bh;
hg.globalAlpha = 0.85;
hg.fillRect(bx+2*U, by+bh-fh+2*U, 22*U, Math.max(0,fh-4*U));
hg.globalAlpha = 1;
if(b.v>1){ hg.fillStyle=AM; hg.fillRect(bx+2*U, by, 22*U, 4*U); }
hg.strokeStyle='rgba(126,214,255,0.35)'; hg.lineWidth=1*U;
roundRect(bx, by, 26*U, bh, 2*U); hg.stroke();
txt(b.l, bx+13*U, by-9*U, 9.5*U, 'rgba(127,227,255,0.85)','center');
txt(b.extra || '', bx+13*U, by+bh+9*U, 9.5*U, b.c,'center');
});
const chips = [];
chips.push({t:'GEAR '+(ac.gearDown?'DN':'UP'), c: ac.gearDown?GR:AM, on:ac.gearDown||ac.gearAnim<0.98});
chips.push({t:'FLAP '+Math.round(ac.flaps*100), c: ac.flaps>0?CY:'#9fb0bd', on:ac.flaps>0});
chips.push({t:'BRK', c: ac.brakes?AM:'#9fb0bd', on:ac.brakes});
chips.forEach((c2,i)=>{
const bx = x + i*58*U, by = base - 6*U;
hg.fillStyle = c2.on ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.25)';
roundRect(bx, by, 52*U, 17*U, 2*U); hg.fill();
hg.strokeStyle = c2.on? c2.c : 'rgba(255,255,255,0.12)'; hg.lineWidth=1.1*U; hg.stroke();
txt(c2.t, bx+26*U, by+9*U, 9.5*U, c2.on?c2.c:'rgba(150,170,185,0.55)','center');
});
}
function drawLimits(ac){
const x = HW/2 - 268*U, y = HH - 62*U;
const aoa = ac.aoa*R2D;
const g = ac.gLoad;
const meter = (label, frac, col, extra)=>{
hg.fillStyle='rgba(5,9,13,0.55)';
roundRect(x, y, 122*U, 15*U, 2*U); hg.fill();
hg.fillStyle=col;
hg.globalAlpha=0.75;
hg.fillRect(x+2*U, y+2*U, clamp(frac,0,1)*118*U, 11*U);
hg.globalAlpha=1;
txt(label+' '+extra, x+6*U, y+8*U, 10*U, '#eaf6ff','left',true);
};
const aoaCol = Math.abs(aoa) > ac.spec.aStall*R2D ? RD : (Math.abs(aoa) > ac.spec.aStall*R2D*0.8 ? AM : GR);
meter('AoA', Math.abs(aoa)/25, aoaCol, aoa.toFixed(1)+'°');
const gLim = ac.spec.maxG;
const gCol = Math.abs(g)>gLim*0.97 ? RD : (Math.abs(g)>gLim*0.8?AM:'#8fd0ff');
meter('G', Math.abs(g)/gLim, gCol, g.toFixed(1)+' / '+gLim.toFixed(1));
txt('VSI '+(ac.vs*FT*60>=0?'+':'')+Math.round(ac.vs*FT*60)+' fpm', x+136*U, y+8*U, 10.5*U,
(ac.vs<-4 && ac.agl<150) ? AM : 'rgba(200,225,240,0.9)','left');
txt('AGL '+Math.max(0,Math.round(ac.agl*FT))+' ft', x+136*U, y-8*U, 10.5*U, 'rgba(200,225,240,0.85)','left');
}
function drawRadar(ac){
const R = 84*U, cx = HW-130*U, cy = HH-104*U, range = 4200;
hg.save();
hg.fillStyle='rgba(5,9,13,0.55)';
hg.beginPath(); hg.arc(cx,cy,R,0,6.283); hg.fill();
hg.strokeStyle='rgba(127,227,255,0.4)'; hg.lineWidth=1.3*U; hg.stroke();
for(const rr of [0.33,0.66,1]){
hg.strokeStyle='rgba(127,227,255,0.16)'; hg.lineWidth=1*U;
hg.beginPath(); hg.arc(cx,cy,R*rr,0,6.283); hg.stroke();
}
hg.beginPath(); hg.moveTo(cx-R,cy); hg.lineTo(cx+R,cy);
hg.moveTo(cx,cy-R); hg.lineTo(cx,cy+R); hg.stroke();
const sweep = (simT*1.4)%6.283;
const grad = hg.createLinearGradient(cx,cy, cx+Math.cos(sweep)*R, cy+Math.sin(sweep)*R);
grad.addColorStop(0,'rgba(127,227,255,0)'); grad.addColorStop(1,'rgba(127,227,255,0.5)');
hg.strokeStyle=grad; hg.lineWidth=2*U;
hg.beginPath(); hg.moveTo(cx,cy); hg.lineTo(cx+Math.cos(sweep)*R, cy+Math.sin(sweep)*R); hg.stroke();
const h = -ac.hdg*Math.PI/180;
const cs = Math.cos(h), sn = Math.sin(h);
const put = (dx, dz, col, sz)=>{
let nx = dx/range, nz = dz/range;
const rx = nx*cs - nz*sn, rz = nx*sn + nz*cs;
let px = rx, pz = rz;
const d = Math.hypot(px,pz);
if(d>1){ px=px/d; pz=pz/d; }
hg.fillStyle=col;
hg.beginPath(); hg.arc(cx+px*R, cy+pz*R, sz*U, 0, 6.283); hg.fill();
};
for(const e of enemies){ if(e.alive) put(e.pos.x-ac.pos.x, e.pos.z-ac.pos.z, RD, 4); }
for(const b of bulletPool){ if(b.on && b.hostile) put(b.p.x-ac.pos.x, b.p.z-ac.pos.z, AM, 1.6); }
hg.fillStyle=WH;
hg.beginPath(); hg.moveTo(cx,cy-6*U); hg.lineTo(cx-4.5*U,cy+5*U); hg.lineTo(cx+4.5*U,cy+5*U); hg.closePath(); hg.fill();
txt('RADAR 4 km', cx, cy+R+13*U, 9.5*U, 'rgba(127,227,255,0.75)','center');
hg.restore();
}
function drawTargets(ac){
const v = new THREE.Vector3();
for(const e of enemies){
if(!e.alive) continue;
v.copy(e.pos).project(camera);
if(v.z > 1 || v.z < -1) continue;
const sx = (v.x*0.5+0.5)*HW, sy = (-v.y*0.5+0.5)*HH;
const dist = ac.pos.distanceTo(e.pos);
const sz = clamp(1500/dist, 14, 52)*U;
if(sx<-140||sx>HW+140||sy<-140||sy>HH+140) continue;
hg.strokeStyle=RD; hg.lineWidth=1.7*U;
const h2 = sz/2;
hg.beginPath();
hg.moveTo(sx-h2, sy-h2*0.6); hg.lineTo(sx-h2, sy-h2); hg.lineTo(sx-h2*0.6, sy-h2);
hg.moveTo(sx+h2*0.6, sy-h2); hg.lineTo(sx+h2, sy-h2); hg.lineTo(sx+h2, sy-h2*0.6);
hg.moveTo(sx+h2, sy+h2*0.6); hg.lineTo(sx+h2, sy+h2); hg.lineTo(sx+h2*0.6, sy+h2);
hg.moveTo(sx-h2*0.6, sy+h2); hg.lineTo(sx-h2, sy+h2); hg.lineTo(sx-h2, sy+h2*0.6);
hg.stroke();
txt('BANDIT '+(dist/1000).toFixed(2)+' km', sx, sy+h2+12*U, 10*U, 'rgba(255,90,70,0.95)','center');
if(dist < 2400){
hg.strokeStyle='rgba(255,90,70,0.3)'; hg.lineWidth=1*U;
hg.beginPath(); hg.arc(sx,sy,h2*1.6,0,6.283); hg.stroke();
}
}
}
function drawGunsight(ac){
const cx=HW/2, cy=HH*0.46;
hg.strokeStyle='rgba(127,227,255,0.8)'; hg.lineWidth=1.4*U;
hg.beginPath(); hg.arc(cx,cy,42*U,0,6.283); hg.stroke();
hg.strokeStyle='rgba(127,227,255,0.55)';
hg.beginPath();
hg.moveTo(cx-72*U,cy); hg.lineTo(cx-54*U,cy);
hg.moveTo(cx+72*U,cy); hg.lineTo(cx+54*U,cy);
hg.moveTo(cx,cy-54*U); hg.lineTo(cx,cy-42*U);
hg.moveTo(cx,cy+42*U); hg.lineTo(cx,cy+54*U);
hg.stroke();
txt('GUNNERY', cx, cy+72*U, 9.5*U, 'rgba(127,227,255,0.7)','center');
}
function drawLandingGuidance(ac){
const dx = ac.pos.x;
const dist = -ac.pos.z;
const x = HW/2, y = HH-108*U;
hg.save();
hg.fillStyle='rgba(5,9,13,0.5)';
roundRect(x-136*U, y-17*U, 272*U, 34*U, 3*U); hg.fill();
hg.strokeStyle='rgba(127,227,255,0.3)'; hg.lineWidth=1*U; hg.stroke();
hg.strokeStyle='rgba(232,244,250,0.4)';
hg.beginPath(); hg.moveTo(x-114*U,y+7*U); hg.lineTo(x+114*U,y+7*U); hg.stroke();
hg.fillStyle=Math.abs(dx)>60?RD:CY;
const px = clamp(x - dx*(114/60)*U, x-114*U, x+114*U);
hg.beginPath(); hg.moveTo(px,y+7*U); hg.lineTo(px-4.5*U,y+13*U); hg.lineTo(px+4.5*U,y+13*U); hg.closePath(); hg.fill();
txt('RWY 18 '+Math.max(0,Math.round(dist))+' m to threshold · '+Math.round(Math.abs(dx))+' m off centreline',
x, y-7*U, 11*U, Math.abs(dx)>60?AM:'rgba(232,244,250,0.9)','center');
hg.restore();
}
function drawHUD(ac, dt){
hg.clearRect(0,0,HW,HH);
if(!ac || !ac.alive) return;
drawAttitude(ac);
drawTape(HW/2-252*U, ac.ias*KT, 'KTS', 0.62*U, 20, vv=>String(Math.round(vv)), -1, 132*U);
drawTape(HW/2+252*U, ac.alt*FT, 'FT', 0.30*U, 200, vv=>String(Math.round(vv)), 1, 132*U);
drawHeading(ac);
drawEngine(ac);
drawLimits(ac);
if(state.scenario==='dogfight'){ drawRadar(ac); drawGunsight(ac); drawTargets(ac); }
else drawLandingGuidance(ac);
txt(Math.round(ac.V*3.6)+' km/h true', HW/2-252*U, HH*0.46+142*U, 10*U, 'rgba(200,225,240,0.75)','center');
txt(Math.round(ac.alt*FT)+' ft MSL', HW/2+252*U, HH*0.46+142*U, 10*U, 'rgba(200,225,240,0.75)','center');
}
/* ─── DOM panels ─── */
const el = id=>document.getElementById(id);
let _objSig='', _statSig='', _warnSig='', _toastT=0;
const missionUI = { title:'', phase:'', items:[] };
function renderObjectives(){
const sig = missionUI.title+'|'+missionUI.phase+'|'+missionUI.items.map(i=>(i.done?1:0)+(i.act?2:0)+i.t).join(';');
if(sig===_objSig) return;
_objSig = sig;
el('objTitle').textContent = missionUI.title;
el('objPhase').textContent = missionUI.phase;
el('objList').innerHTML = missionUI.items.map(i=>
'<li class="'+(i.done?'done':(i.act?'act':''))+'">'+i.t+'</li>').join('');
}
function setStats(html){
if(html===_statSig) return;
_statSig = html;
el('statPanel').innerHTML = html;
}
function setWarn(text, cls){
const sig = text+'|'+(cls||'');
if(sig===_warnSig) return;
_warnSig = sig;
const w = el('warn');
w.textContent = text;
w.className = cls||'';
}
function toast(msg, sub){
const t = el('toast');
t.textContent = msg;
t.classList.add('show');
_toastT = 2.4;
if(sub !== undefined) el('sub').textContent = sub;
}
function updateHintBar(){
el('hintBar').innerHTML = state.scenario==='dogfight'
? '<span><b>SPACE</b> guns</span><span><b>SHIFT/CTRL</b> throttle</span><span><b>W A S D</b> pitch / roll</span><span><b>Q E</b> rudder</span><span><b>C</b> camera</span><span><b>ESC</b> pause</span>'
: '<span><b>SHIFT</b> throttle</span><span><b>S</b> pull up at VR</span><span><b>B</b> brakes</span><span><b>G</b> gear</span><span><b>F</b> flaps</span><span><b>W A S D</b> fly</span><span><b>ESC</b> pause</span>';
}
init();
loop();
window.__APEX = {
get player(){ return player; },
get enemies(){ return enemies; },
state, mis, SPEC, start:startGame, cam, missionUI
};
}).catch(err=>{
const l = document.getElementById('loadStatus');
if(l) l.textContent = 'failed to start: '+(err && err.message);
const f = document.getElementById('fatal');
if(f) f.classList.remove('hidden');
const lo = document.getElementById('loading');
if(lo) lo.classList.add('hidden');
console.error(err);
});
</script>
</body>
</html>