Flight Simulator
Qwen 3.8 · full
◈Preview unavailable — try playing the result
Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 76,400 bytes · SHA-256 1233d3c9ed97
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APEX AERO — Flight Simulator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--hud:#9ff2ea; --hud-dim:rgba(159,242,234,.55); --warn:#ff5f4a; --ok:#59ffa8;
--gold:#ffd166; --panel:rgba(6,14,20,.55); --line:rgba(159,242,234,.28);
--disp:'Orbitron','Rajdhani',sans-serif; --body:'Rajdhani','Segoe UI',sans-serif;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#04090e;color:#dfeef2;font-family:var(--body);-webkit-user-select:none;user-select:none}
canvas#gl{position:fixed;inset:0;display:block}
#vignette{position:fixed;inset:0;pointer-events:none;z-index:3;
background:radial-gradient(ellipse at center,transparent 52%,rgba(2,8,12,.55) 100%)}
#dmg{position:fixed;inset:0;pointer-events:none;z-index:4;opacity:0;transition:opacity .25s;
background:radial-gradient(ellipse at center,transparent 40%,rgba(255,30,10,.55) 100%)}
#flash{position:fixed;inset:0;pointer-events:none;z-index:5;opacity:0;background:#fff}
/* ============ HUD ============ */
#hud{position:fixed;inset:0;z-index:6;pointer-events:none;opacity:0;transition:opacity .4s}
#hud.on{opacity:1}
#hud .lbl{font-family:var(--disp);font-size:10px;letter-spacing:.22em;color:var(--hud-dim)}
#hud .val{font-family:var(--disp);font-weight:700;color:var(--hud);text-shadow:0 0 12px rgba(159,242,234,.35);line-height:1.05}
#hud .unit{font-family:var(--body);font-weight:600;font-size:11px;color:var(--hud-dim);letter-spacing:.08em}
#topbar{position:absolute;top:14px;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start;padding:0 22px}
#topbar .left,#topbar .right{display:flex;gap:26px;align-items:center}
#modeTag{font-family:var(--disp);font-size:12px;letter-spacing:.3em;color:var(--gold);text-shadow:0 0 14px rgba(255,209,102,.4)}
#aircraftTag{font-family:var(--disp);font-size:13px;letter-spacing:.18em;color:var(--hud)}
#score{font-family:var(--disp);font-size:22px;color:var(--gold);text-shadow:0 0 16px rgba(255,209,102,.45)}
#score .lbl{display:block;font-size:9px;text-align:right}
#fuelwrap{width:150px}
#fuelbar{height:8px;border:1px solid var(--line);background:rgba(0,0,0,.4);margin-top:4px;position:relative;overflow:hidden}
#fuelbar i{position:absolute;inset:0;width:100%;background:linear-gradient(90deg,#ff9d4a,#ffd166);transition:width .2s}
#fuelbar.low i{background:linear-gradient(90deg,#ff3b2a,#ff9d4a)}
#fuelbar::after{content:'';position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0 14px,rgba(4,9,14,.9) 14px 15px)}
#healthwrap{width:150px}
#healthbar{height:8px;border:1px solid var(--line);background:rgba(0,0,0,.4);margin-top:4px;position:relative;overflow:hidden}
#healthbar i{position:absolute;inset:0;width:100%;background:linear-gradient(90deg,#2affb0,#baff7a);transition:width .15s}
#healthbar.low i{background:linear-gradient(90deg,#ff3b2a,#ff9d4a)}
#healthbar::after{content:'';position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0 14px,rgba(4,9,14,.9) 14px 15px)}
#enginebox{display:flex;flex-direction:column;gap:2px;min-width:120px}
#throttlebar{width:120px;height:6px;border:1px solid var(--line);background:rgba(0,0,0,.4);margin-top:3px;position:relative}
#throttlebar i{position:absolute;left:0;top:0;bottom:0;width:100%;background:linear-gradient(90deg,#4ad0ff,#9ff2ea)}
#gbox{position:absolute;left:22px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;gap:10px;align-items:flex-start}
.gpill{display:flex;align-items:center;gap:8px;font-family:var(--disp);font-size:15px;color:var(--hud)}
.gpill b{width:34px;height:34px;border:1px solid var(--line);display:flex;align-items:center;justify-content:center;background:var(--panel);border-radius:4px}
.gpill.hot b{color:var(--warn);border-color:var(--warn);box-shadow:0 0 14px rgba(255,95,74,.5)}
#rightbox{position:absolute;right:22px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;align-items:flex-end;gap:14px;text-align:right}
#altcol .val{font-size:26px}
#spdcol .val{font-size:26px}
#vsi .val{font-size:18px}
#aoacol .val{font-size:18px}
#warnbox{position:absolute;left:50%;bottom:150px;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;align-items:center}
.warn{font-family:var(--disp);font-size:15px;letter-spacing:.28em;padding:5px 18px;border:1px solid currentColor;
background:rgba(0,0,0,.35);animation:blink 0.7s steps(2) infinite}
.warn.stall{color:var(--warn)} .warn.g{color:#ffb347} .warn.aoa{color:#ffd166} .warn.gear{color:var(--gold)} .warn.low{color:#ffb347}
@keyframes blink{50%{opacity:.25}}
#crosshair{position:absolute;left:50%;top:50%;width:0;height:0}
#crosshair::before{content:'';position:absolute;left:-1px;top:-1px;width:2px;height:2px;background:var(--hud);box-shadow:0 0 6px var(--hud)}
#crosshair .tick{position:absolute;background:var(--hud);opacity:.85;box-shadow:0 0 5px var(--hud)}
#crosshair .t{left:-14px;top:0;width:8px;height:1px} #crosshair .r{left:6px;top:0;width:8px;height:1px}
#crosshair .l{left:0;top:-14px;width:1px;height:8px} #crosshair .b{left:0;top:6px;width:1px;height:8px}
#apd{position:absolute;left:0;top:0;width:26px;height:26px;margin:-13px 0 0 -13px;opacity:0;transition:opacity .1s}
#apd svg{width:100%;height:100%}
#apd .lbl2{position:absolute;left:30px;top:8px;font-family:var(--disp);font-size:10px;color:var(--warn);letter-spacing:.1em}
#apd.locked{opacity:1} #apd.locked svg{filter:drop-shadow(0 0 6px var(--warn))}
#apd.locked .lbl2{color:var(--warn)}
#ads{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);display:flex;gap:10px;align-items:flex-end}
#ads .lbl{width:100%;text-align:center}
#adsbar{width:220px;height:4px;background:rgba(0,0,0,.5);border:1px solid var(--line);position:relative;margin-bottom:6px}
#adsbar i{position:absolute;top:0;bottom:0;background:var(--hud);box-shadow:0 0 8px var(--hud)}
#adsdot{position:absolute;top:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--warn);box-shadow:0 0 10px var(--warn)}
#adsval{font-family:var(--disp);font-size:16px;color:var(--hud)}
#targetInfo{position:absolute;left:50%;bottom:70px;transform:translateX(-50%);font-family:var(--disp);font-size:11px;letter-spacing:.2em;color:var(--warn);opacity:0;transition:opacity .2s}
#targetInfo.on{opacity:1}
#killfeed{position:absolute;right:22px;top:120px;display:flex;flex-direction:column;gap:5px;align-items:flex-end}
.kill{font-family:var(--disp);font-size:11px;letter-spacing:.12em;color:var(--gold);background:var(--panel);border:1px solid rgba(255,209,102,.35);padding:4px 10px;animation:killin .3s ease-out}
@keyframes killin{from{transform:translateX(30px);opacity:0}}
#toast{position:absolute;left:50%;top:110px;transform:translateX(-50%);font-family:var(--disp);font-size:13px;letter-spacing:.24em;color:var(--hud);background:var(--panel);border:1px solid var(--line);padding:8px 22px;opacity:0;transition:opacity .3s}
#toast.on{opacity:1}
#minimap{position:absolute;left:22px;bottom:22px;width:170px;height:170px;border:1px solid var(--line);background:rgba(4,10,15,.6);border-radius:4px;overflow:hidden}
#minimap canvas{width:100%;height:100%;display:block}
#controlsHint{position:absolute;left:50%;bottom:26px;transform:translateX(-50%);font-family:var(--body);font-weight:600;font-size:11px;letter-spacing:.14em;color:var(--hud-dim);white-space:nowrap}
#controlsHint b{color:var(--hud);font-weight:700}
#pauseOverlay{position:fixed;inset:0;z-index:8;display:none;align-items:center;justify-content:center;flex-direction:column;gap:18px;background:rgba(3,8,12,.72);backdrop-filter:blur(3px)}
#pauseOverlay.on{display:flex}
#pauseOverlay h2{font-family:var(--disp);font-size:34px;letter-spacing:.4em;color:var(--hud);text-shadow:0 0 24px rgba(159,242,234,.4)}
#pauseOverlay p{font-size:14px;letter-spacing:.18em;color:var(--hud-dim)}
/* ============ MENU ============ */
#menu{position:fixed;inset:0;z-index:10;display:flex;flex-direction:column;overflow-y:auto;
background:linear-gradient(160deg,rgba(4,10,16,.88),rgba(6,16,24,.72)),radial-gradient(ellipse at 70% 20%,rgba(30,90,120,.25),transparent 60%)}
#menu.hidden{display:none}
#menuTop{padding:42px 6vw 10px;display:flex;justify-content:space-between;align-items:flex-start;gap:20px}
#brand h1{font-family:var(--disp);font-weight:900;font-size:clamp(38px,6.4vw,76px);letter-spacing:.14em;line-height:.95;
background:linear-gradient(180deg,#eafffb 20%,#7fd8e8 60%,#2a7f96 100%);-webkit-background-clip:text;background-clip:text;color:transparent;
text-shadow:0 0 44px rgba(120,220,240,.25)}
#brand .sub{font-family:var(--disp);font-size:11px;letter-spacing:.5em;color:var(--hud-dim);margin-top:10px}
#brand .sub b{color:var(--gold)}
#topStats{display:flex;gap:26px;text-align:right}
#topStats .st .v{font-family:var(--disp);font-size:20px;color:var(--hud)}
#topStats .st .k{font-size:10px;letter-spacing:.24em;color:var(--hud-dim)}
#secTitle{font-family:var(--disp);font-size:12px;letter-spacing:.42em;color:var(--gold);margin:26px 6vw 14px;display:flex;align-items:center;gap:14px}
#secTitle::after{content:'';flex:1;height:1px;background:linear-gradient(90deg,rgba(255,209,102,.5),transparent)}
#cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;padding:0 6vw}
.card{position:relative;border:1px solid var(--line);background:var(--panel);backdrop-filter:blur(6px);padding:18px 18px 14px;cursor:pointer;overflow:hidden;transition:transform .18s,border-color .18s,box-shadow .18s}
.card:hover{transform:translateY(-3px);border-color:rgba(159,242,234,.6);box-shadow:0 12px 34px rgba(0,0,0,.45),0 0 24px rgba(159,242,234,.12)}
.card.sel{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold),0 0 30px rgba(255,209,102,.25)}
.card .role{font-family:var(--disp);font-size:9px;letter-spacing:.34em;color:var(--hud-dim)}
.card h3{font-family:var(--disp);font-size:21px;letter-spacing:.1em;color:#eafffb;margin:4px 0 2px}
.card .mfr{font-size:12px;letter-spacing:.14em;color:var(--hud-dim);margin-bottom:12px}
.card canvas{width:100%;height:118px;display:block;border:1px solid rgba(159,242,234,.15);background:radial-gradient(ellipse at 50% 120%,rgba(40,120,150,.25),rgba(4,10,16,.5));margin-bottom:12px}
#statsGrid{display:grid;grid-template-columns:1fr 1fr;gap:7px 14px}
.stat{display:flex;justify-content:space-between;align-items:baseline;font-size:12px;letter-spacing:.06em;color:#b9d8de;border-bottom:1px dotted rgba(159,242,234,.18);padding-bottom:2px}
.stat b{font-family:var(--disp);font-size:13px;color:var(--hud)}
.stat .u{font-size:10px;color:var(--hud-dim);margin-left:2px}
.card .desc{font-size:12.5px;line-height:1.45;color:#9fc3cc;margin-top:12px;min-height:36px}
.card .selTag{position:absolute;top:12px;right:12px;font-family:var(--disp);font-size:9px;letter-spacing:.2em;color:var(--gold);opacity:0}
.card.sel .selTag{opacity:1}
#modes{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:0 6vw}
@media(max-width:760px){#modes{grid-template-columns:1fr}}
.mode{border:1px solid var(--line);background:var(--panel);backdrop-filter:blur(6px);padding:18px;cursor:pointer;transition:transform .18s,border-color .18s}
.mode:hover{transform:translateY(-3px);border-color:rgba(159,242,234,.6)}
.mode.sel{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold),0 0 26px rgba(255,209,102,.22)}
.mode .mtag{font-family:var(--disp);font-size:9px;letter-spacing:.3em;color:var(--hud-dim)}
.mode h4{font-family:var(--disp);font-size:19px;letter-spacing:.12em;color:#eafffb;margin:5px 0 8px}
.mode p{font-size:12.5px;line-height:1.5;color:#9fc3cc}
.mode .obj{margin-top:10px;font-size:11.5px;color:var(--gold);letter-spacing:.06em}
#menuFoot{padding:26px 6vw 40px;display:flex;justify-content:space-between;align-items:center;gap:20px;flex-wrap:wrap}
#ctrls{font-size:11.5px;letter-spacing:.1em;color:var(--hud-dim);line-height:1.8}
#ctrls b{color:var(--hud)}
#launch{font-family:var(--disp);font-size:17px;letter-spacing:.3em;padding:18px 46px;background:linear-gradient(180deg,rgba(159,242,234,.16),rgba(159,242,234,.05));
border:1px solid var(--hud);color:var(--hud);cursor:pointer;transition:all .18s;text-shadow:0 0 14px rgba(159,242,234,.5)}
#launch:hover{background:linear-gradient(180deg,rgba(159,242,234,.3),rgba(159,242,234,.1));box-shadow:0 0 34px rgba(159,242,234,.35);transform:translateY(-2px)}
#launch:active{transform:translateY(0)}
#errbox{position:fixed;left:16px;bottom:16px;z-index:99;max-width:420px;background:rgba(60,8,4,.9);border:1px solid var(--warn);
color:#ffd9d2;font-size:12px;padding:10px 14px;display:none;font-family:monospace}
</style>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js" } }
</script>
</head>
<body>
<canvas id="gl"></canvas>
<div id="vignette"></div>
<div id="dmg"></div>
<div id="flash"></div>
<div id="hud">
<div id="topbar">
<div class="left">
<div id="modeTag">—</div>
<div id="aircraftTag">—</div>
</div>
<div class="right">
<div id="score">0<span class="lbl">SCORE</span></div>
<div id="fuelwrap"><div class="lbl">FUEL</div><div id="fuelbar"><i></i></div></div>
<div id="healthwrap"><div class="lbl">HULL</div><div id="healthbar"><i></i></div></div>
<div id="enginebox">
<div class="lbl">THROTTLE</div>
<div id="throttlebar"><i></i></div>
<div class="lbl" style="margin-top:5px">RPM <span id="rpm" class="val" style="font-size:13px">—</span></div>
</div>
</div>
</div>
<div id="gbox">
<div class="gpill" id="gpill"><b id="gval">1.0</b><span class="lbl">G-LOAD</span></div>
</div>
<div id="rightbox">
<div id="altcol"><div class="lbl">ALTITUDE</div><div class="val" id="altval">0</div><div class="unit">FT</div></div>
<div id="spdcol"><div class="lbl">AIRSPEED</div><div class="val" id="spdval">0</div><div class="unit">KT</div></div>
<div id="vsi"><div class="lbl">V/S</div><div class="val" id="vsival">0</div><div class="unit">FT/MIN</div></div>
<div id="aoacol"><div class="lbl">AOA</div><div class="val" id="aoaval">0.0</div><div class="unit">DEG</div></div>
</div>
<div id="crosshair"><i class="tick t"></i><i class="tick r"></i><i class="tick l"></i><i class="tick b"></i></div>
<div id="apd"><svg viewBox="0 0 26 26"><path d="M13 2 L13 8 M13 18 L13 24 M2 13 L8 13 M18 13 L24 13" stroke="#ff5f4a" stroke-width="2" fill="none"/></svg><span class="lbl2">AP</span></div>
<div id="ads"><div class="lbl">ANGLE-ON · TARGET</div><div id="adsbar"><i id="adsneedle"></i><span id="adsdot"></span></div><div id="adsval">—</div></div>
<div id="targetInfo">TARGET —</div>
<div id="warnbox"></div>
<div id="killfeed"></div>
<div id="toast"></div>
<div id="minimap"><canvas id="mm" width="340" height="340"></canvas></div>
<div id="controlsHint">—</div>
</div>
<div id="pauseOverlay"><h2>PAUSED</h2><p>PRESS ESC TO RESUME · M FOR MENU</p></div>
<div id="menu">
<div id="menuTop">
<div id="brand">
<h1>APEX AERO</h1>
<div class="sub">HIGH-FIDELITY FLIGHT SIMULATOR · <b>NEWTONIAN PHYSICS CORE</b></div>
</div>
<div id="topStats">
<div class="st"><div class="v" id="statAircraft">3</div><div class="k">AIRCRAFT</div></div>
<div class="st"><div class="v" id="statModes">2</div><div class="k">SCENARIOS</div></div>
<div class="st"><div class="v" id="statTerrain">∞</div><div class="k">TERRAIN</div></div>
</div>
</div>
<div id="secTitle">SELECT AIRCRAFT</div>
<div id="cards"></div>
<div id="secTitle">SELECT SCENARIO</div>
<div id="modes">
<div class="mode sel" data-mode="tl">
<div class="mtag">SCENARIO A · PRECISION FLIGHT</div>
<h4>TAKEOFF / LANDING</h4>
<p>Roll out, manage rotation speed and climb-out, then fly a traffic pattern and put the wheels down inside the landing zone. Every aircraft has its own stall speed, sink rate and runway demand.</p>
<div class="obj">OBJECTIVE: LIFT OFF · CLIMB 400 FT · LAND IN ZONE</div>
</div>
<div class="mode" data-mode="df">
<div class="mtag">SCENARIO B · COMBAT</div>
<h4>DOGFIGHT</h4>
<p>High-G turning, energy management and slipstream combat against AI fighters. Watch your G-load, your angle-of-attack and your speed — the physics will not forgive a lazy turn.</p>
<div class="obj">OBJECTIVE: DOWN 3 HOSTILES · SURVIVE</div>
</div>
</div>
<div id="menuFoot">
<div id="ctrls">
<b>MOUSE</b> pitch / roll · <b>Q/E</b> rudder · <b>W/S</b> throttle · <b>SPACE</b> fire / <b>SHIFT</b> boost ·
<b>G</b> gear · <b>B</b> brakes · <b>F</b> flap · <b>R</b> reset · <b>M</b> menu · <b>ESC</b> pause
</div>
<button id="launch">LAUNCH ▸</button>
</div>
</div>
<div id="errbox"></div>
<script type="module">
import * as THREE from 'three';
/* ================= utilities ================= */
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const KT=1.94384, FT=3.28084, D2R=0.0174533, R2D=57.2958;
const $=id=>document.getElementById(id);
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
function hash2(x,y){let h=Math.imul(x|0,374761393)+Math.imul(y|0,668265263);h=Math.imul(h^(h>>>13),1274126177);return((h^(h>>>16))>>>0)/4294967296}
function smooth(t){return t*t*(3-2*t)}
function fbm2(x,y,oct=5){let s=0,a=0.5,f=1,n=0;for(let i=0;i<oct;i++){s+=a*(hash2(Math.floor(x*f)+Math.sin(y*f)*100|0,Math.floor(y*f)))*2-0.5;a*=0.5;f*=2;n+=1}return s}
/* value-noise fBm with bilinear interpolation */
function vnoise(x,y){const xi=Math.floor(x),yi=Math.floor(y),xf=x-xi,yf=y-yi;
const a=hash2(xi,yi),b=hash2(xi+1,yi),c=hash2(xi,yi+1),d=hash2(xi+1,yi+1);
const u=smooth(xf),v=smooth(yf);
return lerp(lerp(a,b,u),lerp(c,d,u),v)}
function fbm(x,y,oct=5,lac=2,gain=0.5){let s=0,a=1,f=1,n=0;for(let i=0;i<oct;i++){s+=a*vnoise(x*f,y*f);n+=a;a*=gain;f*=lac}return s/n}
function ridge(x,y,oct=4){let s=0,a=0.55,f=1;for(let i=0;i<oct;i++){s+=a*(1-Math.abs(2*fbm(x*f+7.3,y*f+3.1,3)-1));a*=0.5;f*=2.1}return s}
/* ================= atmosphere (ISA) ================= */
const SEA_P=101325, SEA_RHO=1.225, G0=9.80665;
function airDensity(altM){const a=clamp(altM,0,12000);return SEA_RHO*Math.pow(1-2.25577e-5*a,4.2561)}
function seaLevelSpeedOfSound(){return 340.294}
/* ================= aircraft definitions ================= */
const AIRCRAFT={
falcon:{
id:'falcon',name:'VF-21 FALCON',mfr:'Meridian Dynamics · Multirole',role:'MULTIROLE FIGHTER',
desc:'Balanced all-rounder. Moderate wing loading, responsive roll, dependable energy handling across the envelope.',
mass:8200, wingArea:28, CLmax:1.65, CL0:0.06, CD0:0.022, AR:5.6, kOswald:0.82,
thrustMax:88000, fuelMax:1600,
ix:185000, iy:265000, iz:38000,
pEff:5.2, rEff:7.5, yEff:2.4, pDamp:3.4, rDamp:5.2, yDamp:1.6,
vStall:142, vMax:720,
colors:{body:0x8fa8b8,accent:0x2e6f86,dark:0x22303a,canopy:0x9fdcff},
stats:[['MAX SPD','720','KT'],['THRUST','88','kN'],['CL MAX','1.65',''],['WING LOAD','293','KG/M²'],['STALL','142','KT'],['TURN','MED',''],['RANGE','LONG','']]
},
tempest:{
id:'tempest',name:'TD-9 TEMPEST',mfr:'Kestrel Works · Heavy Interceptor',role:'HEAVY INTERCEPTOR',
desc:'A flying brick with a rocket heart. Enormous thrust, heavy wing loading — slow to turn, devastating in a dive.',
mass:14500, wingArea:42, CLmax:1.45, CL0:0.05, CD0:0.026, AR:4.4, kOswald:0.8,
thrustMax:172000, fuelMax:2600,
ix:340000, iy:520000, iz:70000,
pEff:3.4, rEff:4.6, yEff:1.6, pDamp:2.6, rDamp:4.0, yDamp:1.4,
vStall:178, vMax:820,
colors:{body:0x6d7f8a,accent:0x9a4a1e,dark:0x1c242c,canopy:0xbfe6ff},
stats:[['MAX SPD','820','KT'],['THRUST','172','kN'],['CL MAX','1.45',''],['WING LOAD','345','KG/M²'],['STALL','178','KT'],['TURN','HEAVY',''],['RANGE','LONG','']]
},
viper:{
id:'viper',name:'CV-3 VIPER',mfr:'Atlas Sport · High-Performance Trainer',role:'AGILE TRAINER',
desc:'Featherweight with a low stall and twitchy control response. The dogfighter scalpel — light, nimble, fragile.',
mass:3100, wingArea:19, CLmax:1.75, CL0:0.05, CD0:0.020, AR:6.4, kOswald:0.84,
thrustMax:46000, fuelMax:800,
ix:62000, iy:94000, iz:14000,
pEff:8.5, rEff:11.0, yEff:3.6, pDamp:4.2, rDamp:6.0, yDamp:1.9,
vStall:104, vMax:560,
colors:{body:0xc8ccd2,accent:0x1a7a4a,dark:0x2a343c,canopy:0xa8e8ff},
stats:[['MAX SPD','560','KT'],['THRUST','46','kN'],['CL MAX','1.75',''],['WING LOAD','163','KG/M²'],['STALL','104','KT'],['TURN','LIGHT',''],['RANGE','SHORT','']]
}
};
const AIR_LIST=['falcon','tempest','viper'];
/* ================= shared math state ================= */
const UP=new THREE.Vector3(0,1,0);
let _q1=new THREE.Quaternion(),_q2=new THREE.Quaternion(),_q3=new THREE.Quaternion();
let _v1=new THREE.Vector3(),_v2=new THREE.Vector3(),_v3=new THREE.Vector3(),_v4=new THREE.Vector3();
let _m1=new THREE.Matrix4();
/* ================= globals ================= */
let renderer,scene,camera,sun,skyMesh,water,cloudLayer;
let terrain=null, aircraft=null, mode=null, enemies=[], tracers=[], explosions=[], particles=[];
let menuScene=null,menuCam=null;
let locked=false, paused=false, menuOpen=true, running=false;
let score=0, kills=0, fuel=0, health=100, throttle=0.35, gearDown=false, brakes=false, flap=0, boost=false, boostT=0;
let input={pitch:0,roll:0,yaw:0,throttle:0.35,fire:false,boost:false,gear:false,brakes:false,flap:false,reset:false,menu:false};
let selAircraft='falcon', selMode='tl';
let lastT=performance.now(), hudAcc=0, adiT=0;
let groundContact=false, onRunway=false;
let worldSeed=1337;
const WORLD={chunk:240,res:48,range:9,radius:5};
let runways=[], lZones=[], spawnPoint=null;
let targetEnemy=null, apAngleOn=0, apLock=false;
let fireCd=0, shakeMag=0, shakeT=0;
let menuAircraftMesh=null, menuAngle=0;
window.addEventListener('error',e=>{const b=$('errbox');b.style.display='block';b.textContent='ERR: '+(e.message||e.error);});
/* ================= renderer & scene ================= */
function initRenderer(){
renderer=new THREE.WebGLRenderer({canvas:$('gl'),antialias:true,powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio,1.75));
renderer.setSize(innerWidth,innerHeight);
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=1.05;
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
scene=new THREE.Scene();
scene.fog=new THREE.Fog(0xbdd7e8,900,9500);
camera=new THREE.PerspectiveCamera(62,innerWidth/innerHeight,0.5,30000);
addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight)});
}
function makeSky(){
const geo=new THREE.SphereGeometry(15000,32,16);
const mat=new THREE.ShaderMaterial({
side:THREE.BackSide,depthWrite:false,fog:false,
uniforms:{top:{value:new THREE.Color(0x2f6fbe)},mid:{value:new THREE.Color(0x9cc4e4)},bot:{value:new THREE.Color(0xdcebf5)},
sunDir:{value:new THREE.Vector3(0.5,0.35,0.2).normalize()}},
vertexShader:`varying vec3 vW;void main(){vW=(modelMatrix*vec4(position,1.0)).xyz;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader:`varying vec3 vW;uniform vec3 top;uniform vec3 mid;uniform vec3 bot;uniform vec3 sunDir;
void main(){vec3 d=normalize(vW-cameraPosition);float h=clamp(d.y,-0.05,1.0);
vec3 c=mix(bot,mid,smoothstep(0.0,0.12,h));c=mix(c,top,smoothstep(0.12,0.65,h));
float s=max(dot(d,sunDir),0.0);c+=vec3(1.0,0.85,0.6)*pow(s,600.0)*3.0;c+=vec3(1.0,0.75,0.5)*pow(s,8.0)*0.12;
gl_FragColor=vec4(c,1.0);}`
});
skyMesh=new THREE.Mesh(geo,mat);skyMesh.frustumCulled=false;scene.add(skyMesh);
}
function makeLights(){
const hemi=new THREE.HemisphereLight(0xbfd9ee,0x3a4a3a,0.75);scene.add(hemi);
sun=new THREE.DirectionalLight(0xfff1dc,1.6);
sun.position.set(2400,1700,1100);sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
const s=sun.shadow.camera;s.left=-600;s.right=600;s.top=600;s.bottom=-600;s.near=200;s.far=8000;
sun.shadow.bias=-0.0004;
scene.add(sun);scene.add(sun.target);
}
function makeWater(){
water=new THREE.Mesh(new THREE.PlaneGeometry(30000,30000),
new THREE.MeshStandardMaterial({color:0x1d5a7a,metalness:0.65,roughness:0.22}));
water.rotation.x=-Math.PI/2;water.position.y=-2;scene.add(water);
}
function makeClouds(){
const n=46;cloudLayer=new THREE.Group();
const rnd=mulberry32(99);
const mat=new THREE.MeshBasicMaterial({color:0xffffff,transparent:true,opacity:0.5,depthWrite:false,fog:false});
const mat2=new THREE.MeshBasicMaterial({color:0xe8f0f8,transparent:true,opacity:0.42,depthWrite:false,fog:false});
for(let i=0;i<n;i++){
const m=(rnd()<0.5?mat:mat2);
const cl=new THREE.Mesh(new THREE.SphereGeometry(1,6,4),m);
const sx=120+rnd()*260,sy=10+rnd()*16,sz=60+rnd()*140;
cl.scale.set(sx,sy,sz);
cl.position.set((rnd()-0.5)*9000,1750+rnd()*1200,(rnd()-0.5)*9000);
cl.userData.drift=2+rnd()*4;
cloudLayer.add(cl);
}
scene.add(cloudLayer);
}
function updateSkyFollow(dt){
if(skyMesh)skyMesh.position.copy(camera.position);
if(cloudLayer)for(const c of cloudLayer.children){c.position.x+=c.userData.drift*dt;if(c.position.x>4600)c.position.x=-4600;}
const alt=camera.position.y;
const k=clamp(alt/4000,0,1);
if(skyMesh){const u=skyMesh.material.uniforms;
u.top.value.set(0x2f6fbe).lerp(new THREE.Color(0x0a1e4e),k*0.8);
u.bot.value.set(0xdcebf5).lerp(new THREE.Color(0xa8c8e8),k*0.5);
scene.fog.color.set(0xbdd7e8).lerp(new THREE.Color(0x9db8d8),k*0.5);
}
}
/* ================= procedural terrain ================= */
let flattenSpots=[];
const TL_ANCHOR={x:0,z:-2600,r:1500}, LZ_ANCHOR={x:900,z:-3300,r:1100};
function heightAt(x,z){
const nx=x*0.00035, nz=z*0.00035;
const cont=fbm(nx*0.5+11.2,nz*0.5+7.7,4);
const m=ridge(nx*1.4+3.3,nz*1.4+9.1,4);
const hills=fbm(nx*3.1+5.5,nz*3.1+2.2,4);
let h=(cont*60-30)+m*m*430+hills*26;
if(h<12)h*=0.55;
// coastal plain around the airfield anchors (gentle fade to flat)
for(const A of [TL_ANCHOR,LZ_ANCHOR]){
const dx=x-A.x,dz=z-A.z,d=Math.sqrt(dx*dx+dz*dz);
if(d<A.r){const k=smooth(clamp(d/A.r,0,1));h=lerp(6,h,k)}
}
for(const f of flattenSpots){
const dx=x-f.x,dz=z-f.z,d=Math.sqrt(dx*dx+dz*dz);
if(d<f.r){const t=smooth(clamp(1-d/f.r,0,1));h=lerp(h,f.h,t)}
}
return h;
}
function slopeAt(x,z){const e=6;const hx=heightAt(x+e,z)-heightAt(x-e,z),hz=heightAt(x,z+e)-heightAt(x,z-e);return Math.sqrt(hx*hx+hz*hz)/(2*e)}
function initTerrain(){
terrain={chunks:new Map(),lastUpdate:0};
}
function buildChunk(cx,cz){
const C=WORLD.chunk, R=WORLD.res;
const geo=new THREE.PlaneGeometry(C,C,R-1,R-1);
geo.rotateX(-Math.PI/2);
const pos=geo.attributes.position;
const col=new Float32Array(pos.count*3);
const c=new THREE.Color();
const mo=mulberry32(cx*73856093^cz*19349663);
for(let i=0;i<pos.count;i++){
const wx=cx*C+pos.getX(i), wz=cz*C+pos.getZ(i);
const h=heightAt(wx,wz);
pos.setY(i,h);
const s=slopeAt(wx,wz);
const wet=fbm(wx*0.002+40,wz*0.002+17,3);
const v=0.85+mo()*0.3;
if(h<4)c.setRGB(0.79*v,0.72*v,0.52*v);
else if(h<16)c.setRGB(0.42*v,0.58*v,0.30*v).multiplyScalar(lerp(1,0.8,wet));
else if(h<60)c.setRGB(0.27*v,0.44*v,0.24*v);
else if(h<150)c.setRGB(0.48*v,0.46*v,0.41*v);
else if(h<240)c.setRGB(0.58*v,0.57*v,0.53*v);
else c.setRGB(0.90*v,0.92*v,0.94*v);
if(s>0.55&&h>8)c.setRGB(0.45*v,0.43*v,0.40*v);
col[i*3]=c.r;col[i*3+1]=c.g;col[i*3+2]=c.b;
}
geo.computeVertexNormals();
geo.setAttribute('color',new THREE.BufferAttribute(col,3));
const mat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.95,metalness:0.02});
const mesh=new THREE.Mesh(geo,mat);
mesh.position.set(cx*C,0,cz*C);
mesh.receiveShadow=true;
scene.add(mesh);
terrain.chunks.set(cx+','+cz,mesh);
}
function removeChunk(key){
const m=terrain.chunks.get(key);
if(m){scene.remove(m);m.geometry.dispose();m.material.dispose();terrain.chunks.delete(key)}
}
function updateTerrain(t){
if(t-terrain.lastUpdate<0.4)return;
terrain.lastUpdate=t;
const px=aircraft?aircraft.group.position.x:camera.position.x;
const pz=aircraft?aircraft.group.position.z:camera.position.z;
const ccx=Math.round(px/WORLD.chunk), ccz=Math.round(pz/WORLD.chunk);
const need=new Set();
for(let dx=-WORLD.range;dx<=WORLD.range;dx++)for(let dz=-WORLD.range;dz<=WORLD.range;dz++){
if(dx*dx+dz*dz>WORLD.range*WORLD.range)continue;
need.add((ccx+dx)+','+(ccz+dz));
}
let budget=3;
for(const key of need){
if(!terrain.chunks.has(key)){if(budget<=0)break;buildChunk(...key.split(',').map(Number));budget--}
}
for(const key of terrain.chunks.keys()){
const [cx,cz]=key.split(',').map(Number);
if(!need.has(key))removeChunk(key);
}
}
function makeRunway(x,z,yaw,len=1500,w=46){
const g=new THREE.Group();
const y=heightAt(x,z)+0.6;
const road=new THREE.Mesh(new THREE.PlaneGeometry(w,len),
new THREE.MeshStandardMaterial({color:0x2a2e33,roughness:0.9}));
road.rotation.x=-Math.PI/2;road.receiveShadow=true;g.add(road);
const dashMat=new THREE.MeshBasicMaterial({color:0xd8d8d0});
for(let d=-len/2+30;d<len/2-30;d+=42){
const dash=new THREE.Mesh(new THREE.PlaneGeometry(1.4,16),dashMat);
dash.rotation.x=-Math.PI/2;dash.position.set(0,0.05,d);g.add(dash);
}
for(const s of[-1,1])for(let d=-len/2+12;d<len/2-12;d+=28){
const lt=new THREE.Mesh(new THREE.SphereGeometry(0.9,6,6),
new THREE.MeshBasicMaterial({color:s<0?0xffb347:0x66e0ff}));
lt.position.set(s*(w/2+2),1.2,d);g.add(lt);
}
for(let i=0;i<5;i++){
const lt=new THREE.Mesh(new THREE.SphereGeometry(1.1,6,6),new THREE.MeshBasicMaterial({color:0xfff2cc}));
lt.position.set(0,1.6,-len/2-20-i*18);g.add(lt);
}
g.position.set(x,y,z);g.rotation.y=yaw;
scene.add(g);
runways.push({x,z,y:yaw,len,w,heading:new THREE.Vector3(Math.sin(yaw),0,Math.cos(yaw))});
return g;
}
function makeLandZone(x,z,r=120){
const y=heightAt(x,z)+0.8;
const ring=new THREE.Mesh(new THREE.RingGeometry(r-6,r,48),
new THREE.MeshBasicMaterial({color:0xffd166,transparent:true,opacity:0.55,side:THREE.DoubleSide}));
ring.rotation.x=-Math.PI/2;ring.position.set(x,y,z);scene.add(ring);
for(let i=0;i<8;i++){
const a=i/8*Math.PI*2;
const p=new THREE.Mesh(new THREE.SphereGeometry(1.6,6,6),new THREE.MeshBasicMaterial({color:0xffd166}));
p.position.set(x+Math.cos(a)*r,y+2,z+Math.sin(a)*r);scene.add(p);
}
lZones.push({x,z,y,r});
}
function setupWorld(){
flattenSpots=[];runways=[];lZones=[];
for(const k of terrain.chunks.keys())removeChunk(k);
if(mode==='tl'){
spawnPoint=new THREE.Vector3(0,0,-2600);
const hy=heightAt(0,-2600);
flattenSpots.push({x:0,z:-2600,r:420,h:Math.max(hy,9)});
makeRunway(0,-2600,0);
const lz={x:900,z:-3300};
flattenSpots.push({x:lz.x,z:lz.z,r:300,h:Math.max(heightAt(lz.x,lz.z),9)});
makeLandZone(lz.x,lz.z,130);
}else{
spawnPoint=new THREE.Vector3(0,950,-3800);
}
}
function resetPlaneToSpawn(){
const a=aircraft;
if(mode==='tl'){
a.group.position.set(0,heightAt(0,-2600)+1.6,-2600);
a.quat.identity(); // +Z forward aligns with runway heading
}else{
a.group.position.copy(spawnPoint);
a.quat.identity();
}
a.vel.set(0,0,0);a.angVel.set(0,0,0);
a.gearDeployed=mode==='tl';a.gearT=mode==='tl'?1:0;a.flapT=0;
}
/* ================= aircraft 3D models ================= */
function matStd(color,rough=0.45,metal=0.55){return new THREE.MeshStandardMaterial({color,roughness:rough,metalness:metal})}
function buildAircraft(def,forMenu=false){
const g=new THREE.Group();
const C=def.colors;
const bodyM=matStd(C.body,0.42,0.6), accM=matStd(C.accent,0.5,0.4), darkM=matStd(C.dark,0.6,0.3);
const canopyM=new THREE.MeshStandardMaterial({color:C.canopy,roughness:0.08,metalness:0.9,transparent:true,opacity:0.92});
const heavy=def.id==='tempest', light=def.id==='viper';
const fusLen=heavy?16.5:14.5, fusR=heavy?1.55:1.25;
// fuselage
const fus=new THREE.Mesh(new THREE.CapsuleGeometry(fusR,fusLen,6,14),bodyM);
fus.rotation.x=Math.PI/2;fus.scale.set(1,0.72,1);fus.castShadow=true;g.add(fus);
// nose cone
const nose=new THREE.Mesh(new THREE.ConeGeometry(fusR*0.85,heavy?3.4:2.8,14),darkM);
nose.rotation.x=Math.PI/2;nose.position.z=fusLen/2+1.6;g.add(nose);
// canopy
const can=new THREE.Mesh(new THREE.SphereGeometry(fusR*0.8,14,10),canopyM);
can.scale.set(1,0.75,2.1);can.position.set(0,fusR*0.55,1.2);g.add(can);
// intake
const intake=new THREE.Mesh(new THREE.BoxGeometry(fusR*1.5,fusR*1.1,fusR*1.6),darkM);
intake.position.set(0,-fusR*0.15,fusLen/2+0.4);g.add(intake);
// tailpipe + afterburner glow
const pipe=new THREE.Mesh(new THREE.CylinderGeometry(fusR*0.5,fusR*0.62,2.6,12),darkM);
pipe.rotation.x=Math.PI/2;pipe.position.z=-fusLen/2-0.6;g.add(pipe);
const burner=new THREE.Mesh(new THREE.ConeGeometry(fusR*0.42,1.6,12),
new THREE.MeshBasicMaterial({color:0x66ccff,transparent:true,opacity:0.0}));
burner.rotation.x=-Math.PI/2;burner.position.z=-fusLen/2-2.2;g.add(burner);
// wings
const wingSpan=heavy?13.5:(light?10.5:12), wingChord=heavy?4.2:3.6, wingY=0.1, wingZ=-0.6;
const wingGeo=new THREE.BoxGeometry(1,0.34,wingChord);
for(const s of[-1,1]){
const wing=new THREE.Mesh(wingGeo,bodyM);
wing.scale.x=wingSpan/2-0.5;
wing.position.set(s*(wingSpan/4+0.5),wingY,wingZ);
wing.rotation.z=s*0.06; // slight dihedral
wing.castShadow=true;
const flapGroup=new THREE.Group();
const flap=new THREE.Mesh(new THREE.BoxGeometry(1,0.22,wingChord*0.32),accM);
flap.scale.x=wingSpan/2-0.6;
flapGroup.add(flap);
flapGroup.position.set(0,-0.12,-wingChord*0.62);
wing.add(flapGroup);
g.add(wing);
if(s<0)g.userData.flapsL=flapGroup; else g.userData.flapsR=flapGroup;
// wingtip light
const tip=new THREE.Mesh(new THREE.SphereGeometry(0.28,6,6),
new THREE.MeshBasicMaterial({color:s<0?0xff4444:0x44ff88}));
tip.position.set(s*(wingSpan/2-0.4),wingY+0.3,wingZ);g.add(tip);
}
// tail
const hstab=new THREE.Mesh(new THREE.BoxGeometry(heavy?6.4:5.2,0.28,2.4),bodyM);
hstab.position.set(0,0.5,-fusLen/2+0.6);g.add(hstab);
for(const s of[-1,1]){
const vstab=new THREE.Mesh(new THREE.BoxGeometry(0.26,heavy?3.6:2.9,2.6),accM);
vstab.position.set(s*(fusR*0.55),fusR*0.9,-fusLen/2+0.8);
vstab.rotation.y=s*0.12;g.add(vstab);
}
// rudder reference
const rudder=new THREE.Mesh(new THREE.BoxGeometry(0.22,heavy?2.6:2.2,1.6),darkM);
rudder.position.set(0,fusR*0.9+1.2,-fusLen/2+0.8);g.add(rudder);
g.userData.rudder=rudder;
// propeller (visual only, for trainer) or none
let prop=null;
if(light){
prop=new THREE.Group();
for(let i=0;i<3;i++){
const bl=new THREE.Mesh(new THREE.BoxGeometry(0.22,3.6,0.5),darkM);
bl.position.y=1.9;
const holder=new THREE.Group();holder.add(bl);holder.rotation.z=i*Math.PI*2/3;prop.add(holder);
}
prop.position.set(0,0,fusLen/2+3.2);g.add(prop);
}
// landing gear
const gearParts=[];
const gearMat=matStd(0x11151a,0.8,0.2), tireMat=matStd(0x0a0c0e,0.95,0);
const mkWheel=(x,y,z,r=0.9)=>{
const gr=new THREE.Group();
const strut=new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.12,1.1,8),gearMat);
strut.position.y=0.55;gr.add(strut);
const tire=new THREE.Mesh(new THREE.CylinderGeometry(r,r,r*0.5,12),tireMat);
tire.rotation.z=Math.PI/2;tire.position.y=-0.2;gr.add(tire);
gr.position.set(x,y,z);g.add(gr);gearParts.push(gr);
};
mkWheel(-fusR*0.9,-fusR*0.55,fusLen/4);
mkWheel(fusR*0.9,-fusR*0.55,fusLen/4);
mkWheel(0,-fusR*0.55,-fusLen/3);
g.userData.gearParts=gearParts;
g.userData.burner=burner;
g.userData.prop=prop;
g.userData.def=def;
if(!forMenu){g.traverse(o=>{if(o.isMesh)o.castShadow=true})}
return g;
}
function makeAircraft(id){
const def=AIRCRAFT[id];
const group=buildAircraft(def);
return {
def, group, pos:new THREE.Vector3(), quat:new THREE.Quaternion(),
vel:new THREE.Vector3(), angVel:new THREE.Vector3(),
gearDeployed:false, gearT:0, flapT:0,
fuel:def.fuelMax, health:100,
forward:new THREE.Vector3(0,0,1), right:new THREE.Vector3(1,0,0), upW:new THREE.Vector3(0,1,0)
};
}
function setGearVisual(a,deployed){
a.gearDeployed=deployed;
}
function setFlapVisual(a,flapDeg){
if(a.group.userData.flapsL)a.group.userData.flapsL.rotation.x=flapDeg*0.0174;
if(a.group.userData.flapsR)a.group.userData.flapsR.rotation.x=flapDeg*0.0174;
}
/* ================= FLIGHT PHYSICS CORE (Newtonian) =================
Forces: Lift (0.5·ρ·V²·S·CL), Drag (profile + induced), Thrust (rpm·power·ρ),
Weight. Attitude integrated via angular velocity with damping, control
authority scaled by airspeed, prograde stability and stall buffet.
============================================================= */
const PHYS={alphaStall:0.30, slope:(AIRCRAFT.falcon.CLmax-AIRCRAFT.falcon.CL0)/0.30};
function physStep(a,dt,input,isP){
const d=a.def, m=d.mass, S=d.wingArea;
a.forward.set(0,0,1).applyQuaternion(a.quat);
a.right.set(1,0,0).applyQuaternion(a.quat);
a.upW.set(0,1,0).applyQuaternion(a.quat);
const V=a.vel.length();
const rho=airDensity(Math.max(a.pos.y,0));
/* --- angle of attack & sideslip --- */
const vFwd=a.vel.dot(a.forward), vUp=a.vel.dot(a.upW), vRight=a.vel.dot(a.right);
let alpha=Math.atan2(vUp,Math.max(vFwd,0.01));
let beta=Math.atan2(vRight,Math.max(vFwd,0.01));
a.lastV=V; a.lastAlpha=alpha;
/* --- engine (rpm lags throttle -> realistic response) --- */
const rpmT=clamp(input.throttle,0,1);
a.rpm=lerp(a.rpm??0.35,rpmT,1-Math.exp(-dt*1.9));
const altFactor=clamp(rho/1.225,0.30,1);
const fuelOK=(isP? a.fuel>0 : true);
const boostF=(a.boostT>0)?1.35:1;
const thrust=fuelOK? a.rpm*d.thrustMax*altFactor*boostF : 0;
if(isP&&fuelOK){a.fuel=Math.max(0,a.fuel-(a.rpm*d.fuelMax*0.0011+(a.boostT>0?d.fuelMax*0.0012:0))*dt); a.boostT=Math.max(0,a.boostT-dt*0.5); if(a.boostT<=0)a.boostOn=false;}
/* --- lift / stall --- */
const vStall=d.vStall/KT;
const flapBonus=a.flapT*0.0085;
let CL=d.CL0+PHYS.slope*alpha;
let stalled=false;
if(alpha>PHYS.alphaStall){CL=d.CLmax-(alpha-PHYS.alphaStall)*PHYS.slope*2.6; stalled=V>vStall*0.55;}
if(V<vStall*0.92&&alpha>0.12){CL=Math.min(CL,d.CLmax*0.82); stalled=true;}
CL+=flapBonus;
a.lastCL=CL; a.lastStall=stalled;
const qDyn=0.5*rho*V*V;
const L=qDyn*S*CL;
/* --- drag: profile + induced + config --- */
let CD=d.CD0+(CL*CL)/(Math.PI*d.AR*d.kOswald);
CD+=a.gearT*0.022+a.flapT*0.0011;
const D=qDyn*S*CD;
a.lastCD=CD;
/* --- force sum --- */
_v1.copy(a.upW).multiplyScalar(L);
if(V>0.01)_v2.copy(a.vel).multiplyScalar(-D/V); else _v2.set(0,0,0);
_v3.copy(a.forward).multiplyScalar(thrust);
_v1.add(_v2).add(_v3);
_v1.y-=m*G0;
/* --- linear integration --- */
a.vel.addScaledVector(_v1,(dt/m));
a.pos.addScaledVector(a.vel,dt);
a.lastG=(_v1.dot(a.upW))/(m*G0);
/* --- angular dynamics --- */
let pR=a.angVel.dot(a.right), qR=a.angVel.dot(a.upW), rR=a.angVel.dot(a.forward);
const eff=clamp(V/(vStall*1.05),0.28,1.25)*(V<3?0.4:1);
const st=stalled?0.45:1;
qR+=input.pitch*d.pEff*eff*st*dt;
qR+=-alpha*1.1*dt; // prograde: nose chases relative wind
pR+=input.roll*d.rEff*eff*st*dt;
pR+=-beta*1.5*dt; // weathervane roll stability
rR+=input.yaw*d.yEff*eff*dt;
qR-=qR*d.pDamp*dt; pR-=pR*d.rDamp*dt; rR-=rR*d.yDamp*dt;
if(stalled){ // buffet
qR+=(Math.random()-0.5)*1.6*dt*3; pR+=(Math.random()-0.5)*1.4*dt*3;
if(isP)shakeMag=Math.max(shakeMag,0.25);
}
pR=clamp(pR,-2.7,2.7); qR=clamp(qR,-2.4,2.4); rR=clamp(rR,-1.8,1.8);
a.angVel.set(0,0,0).addScaledVector(a.right,pR).addScaledVector(a.upW,qR).addScaledVector(a.forward,rR);
_q1.setFromAxisAngle(_v1.set(1,0,0),pR*dt);
_q2.setFromAxisAngle(_v2.set(0,1,0),qR*dt);
_q3.setFromAxisAngle(_v3.set(0,0,1),rR*dt);
a.quat.multiply(_q1).multiply(_q2).multiply(_q3).normalize();
}
function groundStep(a,dt){
const d=a.def;
const gy=heightAt(a.pos.x,a.pos.z);
const sea=gy<-0.5;
const gearOff=1.35;
if(sea){ if(a.pos.y-gearOff<-1.5){ if(!a.dead){a.dead=true;explode(a.pos,true); if(a===aircraft)toast('LOST IN THE SEA');} a.pos.y=-9999; return;} }
const contact=!sea&&(a.pos.y-gearOff<=gy+0.06);
a.onGround=contact;
if(contact){
a.pos.y=gy+gearOff;
if(a.vel.y<0){
const impact=-a.vel.y; a.vel.y=0;
if(impact>16&&a.gearT<0.5){a.health-=(impact-16)*2.2; if(a===aircraft)shake(0.8,0.5);}
else if(impact>30){a.health-=(impact-30)*1.2;}
}
/* friction: runway vs rough ground, brakes */
let Vh=Math.hypot(a.vel.x,a.vel.z);
if(Vh>0.01){
const mu=(onRunwayAt(a.pos)?0.42:0.28)+(a.brake?0.95:0);
const nV=Math.max(0,Vh-(mu*G0+0.35)*dt);
const k=nV/Vh; a.vel.x*=k; a.vel.z*=k;
}
/* gear constrains attitude */
a.angVel.multiplyScalar(Math.max(0,1-7*dt));
/* liftoff: lift > weight */
const vStall=d.vStall/KT;
const L=0.5*airDensity(Math.max(a.pos.y,0))*a.vel.length()**2*d.wingArea*(d.CL0+PHYS.slope*clamp(a.lastAlpha,0,0.5)+a.flapT*0.0085);
if(L>d.mass*G0*0.96&&a.vel.length()>vStall*0.9){
a.pos.y+=0.02; a.vel.y=Math.max(a.vel.y,1.5);
if(a===aircraft){groundContact=false; toast('LIFT-OFF · +500'); score+=500; sfx('liftoff');}
}
}else{
if(a===aircraft&&groundContact&&a.vel.y<0&&a.pos.y-gearOff<=gy+0.2){
/* touchdown */
const impact=-a.vel.y; a.vel.y=0;
if(a.gearT<0.5){a.health-=28; toast('HARD GEAR IMPACT');}
else if(impact>18){a.health-=(impact-18)*2.5; shake(0.5,0.4);}
else if(impact<4){ if(mode==='tl'){score+=750; toast('SOFT TOUCHDOWN · +750'); sfx('goodland');} }
if(mode==='tl'&&inLandZone(a.pos)){score+=1000; missionComplete('PRECISION LANDING');}
}
}
if(a.health<=0&&!a.dead){a.dead=true;explode(a.pos,true); if(a===aircraft)toast('AIRCRAFT LOST');}
}
function isPlayer(a){return a===aircraft}
function onRunwayAt(p){for(const r of runways){const dx=p.x-r.x,dz=p.z-r.z;const along=dx*r.heading.x+dz*r.heading.z;if(Math.abs(along)>r.len/2+8)return false;const lat=dx*r.heading.z-dz*r.heading.x;if(Math.abs(lat)<r.w/2+4)return true}return false}
function inLandZone(p){for(const z of lZones){const dx=p.x-z.x,dz=p.z-z.z;if(dx*dx+dz*dz<z.r*z.r)return true}return false}
function shake(mag,t){shakeMag=Math.max(shakeMag,mag);shakeT=Math.max(shakeT,t)}
/* ================= AUDIO (synthesized, physics-linked) ================= */
let AC=null, master=null, engineGain=null, engineFilter=null, oscs=[], noiseGain=null, noiseFilter=null,
windGain=null, windFilter=null, ambGain=null, ambFilter=null, creakGain=null;
function initAudio(){
if(AC)return;
try{
AC=new (window.AudioContext||window.webkitAudioContext)();
}catch(e){return}
master=AC.createGain();master.gain.value=0.85;master.connect(AC.destination);
// noise buffer (2s white)
const nb=AC.createBuffer(1,AC.sampleRate*2,AC.sampleRate);
const nd=nb.getChannelData(0);for(let i=0;i<nd.length;i++)nd[i]=Math.random()*2-1;
const mkNoise=()=>{const s=AC.createBufferSource();s.buffer=nb;s.loop=true;s.start();return s};
// --- engine: 3 detuned saws -> lowpass -> gain ---
engineGain=AC.createGain();engineGain.gain.value=0;
engineFilter=AC.createBiquadFilter();engineFilter.type='lowpass';engineFilter.frequency.value=900;engineFilter.Q.value=1.4;
const sub=AC.createGain();sub.gain.value=0.5;
const o1=AC.createOscillator();o1.type='sawtooth';o1.frequency.value=60;
const o2=AC.createOscillator();o2.type='sawtooth';o2.frequency.value=61.5;
const o3=AC.createOscillator();o3.type='square';o3.frequency.value=122;
const o3g=AC.createGain();o3g.gain.value=0.25;
o1.connect(sub);o2.connect(sub);o3.connect(o3g);o3g.connect(sub);
sub.connect(engineFilter);engineFilter.connect(engineGain);engineGain.connect(master);
o1.start();o2.start();o3.start();oscs=[o1,o2,o3];
// --- aero whine: bandpass noise ---
noiseGain=AC.createGain();noiseGain.gain.value=0;
noiseFilter=AC.createBiquadFilter();noiseFilter.type='bandpass';noiseFilter.frequency.value=1200;noiseFilter.Q.value=0.8;
const n1=mkNoise();n1.connect(noiseFilter);noiseFilter.connect(noiseGain);noiseGain.connect(master);
// --- wind: lowpass noise ---
windGain=AC.createGain();windGain.gain.value=0;
windFilter=AC.createBiquadFilter();windFilter.type='lowpass';windFilter.frequency.value=320;
const n2=mkNoise();n2.connect(windFilter);windFilter.connect(windGain);windGain.connect(master);
// --- ambient: very low rumble ---
ambGain=AC.createGain();ambGain.gain.value=0.02;
ambFilter=AC.createBiquadFilter();ambFilter.type='lowpass';ambFilter.frequency.value=90;
const n3=mkNoise();n3.connect(ambFilter);ambFilter.connect(ambGain);ambGain.connect(master);
// --- gear creak: short filtered noise burst (triggered) ---
creakGain=AC.createGain();creakGain.gain.value=0;
const crF=AC.createBiquadFilter();crF.type='bandpass';crF.frequency.value=420;crF.Q.value=2;
const n4=mkNoise();n4.connect(crF);crF.connect(creakGain);creakGain.connect(master);
}
function gearSfx(){if(!AC)return;const t=AC.currentTime;creakGain.gain.cancelScheduledValues(t);creakGain.gain.setValueAtTime(0.35,t);creakGain.gain.exponentialRampToValueAtTime(0.001,t+0.5);}
function sfx(kind){
if(!AC)return;const t=AC.currentTime;
const tone=(f,dur,vol,type='sine',slide=0)=>{const o=AC.createOscillator(),g=AC.createGain();o.type=type;o.frequency.setValueAtTime(f,t);if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(30,f+slide),t+dur);g.gain.setValueAtTime(vol,t);g.gain.exponentialRampToValueAtTime(0.001,t+dur);o.connect(g);g.connect(master);o.start(t);o.stop(t+dur+0.02);};
const burst=(f,dur,vol,q=1)=>{const s=AC.createBufferSource();s.buffer=engineGain?nbBuf():null;const fl=AC.createBiquadFilter();fl.type='bandpass';fl.frequency.value=f;fl.Q.value=q;const g=AC.createGain();g.gain.setValueAtTime(vol,t);g.gain.exponentialRampToValueAtTime(0.001,t+dur);s.connect(fl);fl.connect(g);g.connect(master);s.start(t);s.stop(t+dur+0.02);};
if(kind==='shot'){tone(340,0.09,0.3,'square',-260);burst(900,0.07,0.22,1.2);}
if(kind==='boom'){tone(70,0.9,0.5,'sine',-40);burst(180,0.8,0.5,0.6);}
if(kind==='lock'){tone(1400,0.06,0.16,'square');}
if(kind==='liftoff'){tone(220,0.5,0.2,'sine',160);}
if(kind==='goodland'){tone(880,0.12,0.2);tone(1320,0.25,0.18);}
if(kind==='warn'){tone(660,0.14,0.18,'square');}
}
let _nbCache=null;
function nbBuf(){if(_nbCache)return _nbCache;const nb=AC.createBuffer(1,AC.sampleRate,AC.sampleRate);const d=nb.getChannelData(0);for(let i=0;i<d.length;i++)d[i]=Math.random()*2-1;_nbCache=nb;return nb}
function updateAudio(dt){
if(!AC||!aircraft)return;
const a=aircraft, V=a.lastV||0, m=aircraft.def.mass;
const t=AC.currentTime;
const rpm=a.rpm||0.35;
// engine: pitch & volume from rpm + altitude (air density thins -> thinner tone)
const rho=airDensity(Math.max(a.pos.y,0));
const densF=clamp(rho/1.225,0.3,1);
const fBase=42+rpm*95*densF;
oscs[0].frequency.setTargetAtTime(fBase,t,0.05);
oscs[1].frequency.setTargetAtTime(fBase*1.012,t,0.05);
oscs[2].frequency.setTargetAtTime(fBase*2.02,t,0.05);
engineFilter.frequency.setTargetAtTime(300+rpm*1900+densF*400,t,0.08);
const vol=aircraft.dead?0:clamp(0.05+rpm*0.5,0,0.6)*(a.onGround?0.8:1)*(a.fuel<=0?0.05:1);
engineGain.gain.setTargetAtTime(vol,t,0.09);
// aero whine: q_dyn
const qD=0.5*rho*V*V;
noiseFilter.frequency.setTargetAtTime(500+V*2.2,t,0.1);
noiseGain.gain.setTargetAtTime(clamp(qD/26000,0,0.4)*(aircraft.dead?0:1),t,0.12);
// wind: relative speed, stronger at altitude
windFilter.frequency.setTargetAtTime(180+V*1.1+Math.max(0,a.pos.y)*0.05,t,0.15);
windGain.gain.setTargetAtTime(clamp(V/520,0,0.5)*(aircraft.dead?0:1),t,0.15);
// ambient rumble: proximity to terrain
const gy=heightAt(a.pos.x,a.pos.z);
const prox=clamp(1-(a.pos.y-gy)/900,0,1);
ambGain.gain.setTargetAtTime(0.015+prox*0.05,t,0.3);
}
/* ================= HUD ================= */
let adi=null;
function initHUD(){
const c=document.createElement('canvas');
c.width=340;c.height=340;
c.style.cssText='position:absolute;left:50%;top:50%;width:340px;height:340px;transform:translate(-50%,-50%);opacity:.9;pointer-events:none';
$('hud').appendChild(c);
adi={canvas:c,ctx:c.getContext('2d')};
}
function drawADI(dt){
const {ctx}=adi, S=340, cx=S/2, cy=S/2;
const a=aircraft;
// pitch from velocity: angle between forward and vel
let pitch=0;
if(a.lastV>1){const vF=a.vel.dot(a.forward),vU=a.vel.dot(a.upW);pitch=Math.atan2(vU,Math.max(vF,0.01));}
const upW=_v1.set(0,1,0).applyQuaternion(a.quat);
const roll=Math.atan2(a.right.dot(UP),upW.dot(UP));
const pitchPx=pitch*R2D*3.4;
ctx.clearRect(0,0,S,S);
ctx.save();
ctx.translate(cx,cy);
ctx.beginPath();ctx.arc(0,0,168,0,7);ctx.clip();
// --- sky/ground ---
ctx.save();
ctx.rotate(-roll);
const g1=ctx.createLinearGradient(0,-200,0,0);g1.addColorStop(0,'#2f6fbe');g1.addColorStop(1,'#7fb2dd');
const g2=ctx.createLinearGradient(0,0,0,200);g2.addColorStop(0,'#7a5a3a');g2.addColorStop(1,'#4a3524');
ctx.fillStyle=g1;ctx.fillRect(-220,-260,440,260+pitchPx);
ctx.fillStyle=g2;ctx.fillRect(-220,pitchPx,440,260);
// pitch ladder
ctx.strokeStyle='rgba(220,245,255,.75)';ctx.fillStyle='rgba(220,245,255,.9)';
ctx.lineWidth=1.4;ctx.font='11px Orbitron, sans-serif';ctx.textAlign='left';
for(let p=-60;p<=60;p+=10){
if(p===0)continue;
const y=pitchPx-p*R2D*3.4;
if(y<-240||y>240)continue;
const w=p%30===0?56:34;
ctx.beginPath();ctx.moveTo(-w,y);ctx.lineTo(-14,y);ctx.moveTo(14,y);ctx.lineTo(w,y);ctx.stroke();
if(p%30===0){ctx.fillText(Math.abs(p),w+6,y+4);ctx.fillText(Math.abs(p),-w-6,y+4);}
}
ctx.restore();
// --- horizon line ---
ctx.strokeStyle='#9ff2ea';ctx.lineWidth=2;
ctx.beginPath();ctx.moveTo(cx-120,cy);ctx.lineTo(cx-24,cy);ctx.moveTo(cx+24,cy);ctx.lineTo(cx+120,cy);ctx.stroke();
// --- bank scale ---
ctx.save();ctx.translate(cx,cy);
ctx.strokeStyle='rgba(159,242,234,.8)';
for(let b=-60;b<=60;b+=30){
if(b===0)continue;
ctx.save();ctx.rotate(-b*R2D);
ctx.beginPath();ctx.moveTo(0,-118);ctx.lineTo(0,-104);ctx.stroke();
ctx.restore();
}
ctx.restore();
// --- bank pointer (triangle top) ---
ctx.save();ctx.translate(cx,cy);ctx.rotate(-roll);
ctx.fillStyle='#ffd166';
ctx.beginPath();ctx.moveTo(0,-118);ctx.lineTo(-7,-132);ctx.lineTo(7,-132);ctx.closePath();ctx.fill();
ctx.restore();
// --- fixed aircraft symbol ---
ctx.strokeStyle='#eafffb';ctx.lineWidth=3;
ctx.beginPath();
ctx.moveTo(cx-64,cy);ctx.lineTo(cx-22,cy);ctx.lineTo(cx-12,cy+8);
ctx.moveTo(cx+12,cy+8);ctx.lineTo(cx+22,cy);ctx.lineTo(cx+64,cy);ctx.stroke();
ctx.fillStyle='#eafffb';
ctx.beginPath();ctx.arc(cx-70,cy,3.4,0,7);ctx.fill();
ctx.beginPath();ctx.arc(cx+70,cy,3.4,0,7);ctx.fill();
// --- stall flash ---
if(a.lastStall){
ctx.fillStyle=`rgba(255,60,40,${0.25+0.2*Math.sin(performance.now()*0.02)})`;
ctx.fillRect(0,0,S,S);
}
ctx.restore();
// bezel ring
ctx.save();ctx.translate(cx,cy);
ctx.strokeStyle='rgba(159,242,234,.35)';ctx.lineWidth=1.5;
ctx.beginPath();ctx.arc(0,0,170,0,7);ctx.stroke();
ctx.restore();
}
function updateHUD(){
const a=aircraft;
const kt=a.lastV*KT, ft=Math.max(0,a.pos.y)*FT;
$('altval').textContent=Math.round(ft).toLocaleString();
$('spdval').textContent=Math.round(kt);
$('aoaval').textContent=(a.lastAlpha*R2D).toFixed(1);
$('vsival').textContent=Math.round(a.vel.y*FT*60);
$('gval').textContent=Math.abs(a.lastG||1).toFixed(1);
$('gpill').classList.toggle('hot',Math.abs(a.lastG||1)>6);
$('rpm').textContent=Math.round(2000+(a.rpm||0)*6500);
$('throttlebar').firstElementChild.style.width=(input.throttle*100).toFixed(0)+'%';
$('fuelbar').firstElementChild.style.width=(a.fuel/a.def.fuelMax*100).toFixed(0)+'%';
$('fuelbar').classList.toggle('low',a.fuel/a.def.fuelMax<0.2);
$('healthbar').firstElementChild.style.width=clamp(a.health,0,100)+'%';
$('healthbar').classList.toggle('low',a.health<35);
$('dmg').style.opacity=clamp((100-a.health)/130,0,0.9);
// warnings
let w='';
if(a.lastStall)w+='<div class="warn stall">STALL</div>';
if(Math.abs(a.lastG||1)>6)w+='<div class="warn g">G-LIMIT</div>';
if((a.lastAlpha*R2D)>18)w+='<div class="warn aoa">AOA</div>';
if(a.gearDeployed&&!a.onGround)w+='<div class="warn gear">GEAR DOWN</div>';
if(a.fuel/a.def.fuelMax<0.15)w+='<div class="warn low">LOW FUEL</div>';
if(a.health<35)w+='<div class="warn g">HULL CRITICAL</div>';
$('warnbox').innerHTML=w;
// AP / angle-on
if(mode==='df'&&targetEnemy&&!targetEnemy.dead){
_v1.copy(targetEnemy.pos).sub(a.pos);
const dist=_v1.length();
_v1.normalize();
const cos=_v1.dot(a.forward);
const off=Math.acos(clamp(cos,-1,1));
const rate=(targetEnemy.vel.clone().sub(a.vel)).dot(_v2.copy(a.forward).cross(_v1).normalize())/(dist||1);
apAngleOn=rate*off;
apLock=apLock?Math.abs(apAngleOn)<0.05:Math.abs(apAngleOn)<0.03;
$('apd').classList.toggle('locked',apLock);
const px=clamp(apAngleOn*900,-160,160);
$('adsneedle').style.left=`calc(50% + ${px}px - 11px)`;
$('adsdot').style.left=`calc(50% + ${px}px)`;
$('adsval').textContent=apLock?'LOCKED':(apAngleOn>0.001?'L':'R');
$('targetInfo').textContent=`TARGET · ${Math.round(dist*FT)} FT · ${Math.round(kt)} KT`;
$('targetInfo').classList.add('on');
}else{
$('apd').classList.remove('locked');$('targetInfo').classList.remove('on');
$('adsval').textContent='—';
}
drawMinimap();
}
function drawMinimap(){
const c=$('mm'),ctx=c.getContext('2d'),S=c.width,a=aircraft;
ctx.clearRect(0,0,S,S);
ctx.save();ctx.translate(S/2,S/2);
const scale=0.045;
// terrain tint
for(let i=0;i<24;i++){
const ang=i/24*Math.PI*2;
const gx=a.pos.x+Math.cos(ang)*1400, gz=a.pos.z+Math.sin(ang)*1400;
const h=heightAt(gx,gz);
ctx.fillStyle=h>120?'rgba(120,125,130,.14)':h>20?'rgba(80,110,70,.12)':'rgba(40,90,130,.10)';
ctx.beginPath();ctx.arc(Math.cos(ang)*1400*scale,Math.sin(ang)*1400*scale,26,0,7);ctx.fill();
}
// runways / zones
for(const r of runways){
const x=(r.x-a.pos.x)*scale,z=(r.z-a.pos.z)*scale;
ctx.save();ctx.translate(x,z);ctx.rotate(Math.atan2(r.heading.x,-r.heading.z));
ctx.fillStyle='rgba(220,220,210,.5)';ctx.fillRect(-r.w*scale/2,-r.len*scale/2,r.w*scale,r.len*scale);ctx.restore();
}
for(const z of lZones){
ctx.strokeStyle='rgba(255,209,102,.7)';ctx.lineWidth=1.5;
ctx.beginPath();ctx.arc((z.x-a.pos.x)*scale,(z.z-a.pos.z)*scale,z.r*scale,0,7);ctx.stroke();
}
// enemies
for(const e of enemies){
if(e.dead)continue;
const x=(e.pos.x-a.pos.x)*scale,z=(e.pos.z-a.pos.z)*scale;
if(Math.abs(x)>S/2-6||Math.abs(z)>S/2-6)continue;
ctx.save();ctx.translate(x,z);ctx.rotate(Math.atan2(e.forward.x,-e.forward.z));
ctx.fillStyle='#ff5f4a';ctx.beginPath();ctx.moveTo(0,-6);ctx.lineTo(4,5);ctx.lineTo(-4,5);ctx.closePath();ctx.fill();ctx.restore();
}
// self
ctx.fillStyle='#9ff2ea';ctx.beginPath();ctx.moveTo(0,-7);ctx.lineTo(5,6);ctx.lineTo(-5,6);ctx.closePath();ctx.fill();
ctx.restore();
// border ring
ctx.strokeStyle='rgba(159,242,234,.25)';ctx.lineWidth=1;
ctx.beginPath();ctx.arc(S/2,S/2,S/2-4,0,7);ctx.stroke();
}
/* ================= weapons & effects ================= */
const tracerGeo=new THREE.BufferGeometry();
const MAXT=64;
const tPos=new Float32Array(MAXT*2*3), tCol=new Float32Array(MAXT*2*3);
tracerGeo.setAttribute('position',new THREE.BufferAttribute(tPos,3));
tracerGeo.setAttribute('color',new THREE.BufferAttribute(tCol,3));
let tracerLines=null;
function initFX(){
tracerLines=new THREE.LineSegments(tracerGeo,new THREE.LineBasicMaterial({vertexColors:true,transparent:true,opacity:0.95}));
tracerLines.frustumCulled=false;scene.add(tracerLines);
}
function fireTracer(from,dir,speed,fromEnemy){
if(tracers.length>=MAXT)return;
tracers.push({pos:from.clone(),vel:dir.clone().multiplyScalar(speed),life:1.1,fromEnemy});
}
function updateTracers(dt){
for(let i=tracers.length-1;i>=0;i--){
const t=tracers[i];
t.life-=dt;t.pos.addScaledVector(t.vel,dt);
const gy=heightAt(t.pos.x,t.pos.z);
if(t.life<=0||t.pos.y<=gy+0.3||t.pos.y>12000){
spark(t.pos,1);
tracers.splice(i,1);
}
}
// hit test
for(let i=tracers.length-1;i>=0;i--){
const t=tracers[i];
let hit=false;
if(!t.fromEnemy&&targetEnemy&&!targetEnemy.dead){
const d=t.pos.distanceTo(targetEnemy.pos);
if(d<14){targetEnemy.health-=26+Math.random()*14;hit=true;shake(0.15,0.1);
if(targetEnemy.health<=0&&!targetEnemy.dead){targetEnemy.dead=true;explode(targetEnemy.pos,false);score+=1000;kills++;killMsg('HOSTILE DESTROYED · +1000');sfx('boom');spawnEnemy();}}
}
if(t.fromEnemy&&aircraft&&!aircraft.dead){
const d=t.pos.distanceTo(aircraft.pos);
if(d<12){aircraft.health-=14+Math.random()*8;hit=true;shake(0.3,0.2);
if(aircraft.health<=0&&!aircraft.dead){aircraft.dead=true;explode(aircraft.pos,true);toast('AIRCRAFT DESTROYED');}}
}
if(hit){spark(t.pos,2);tracers.splice(i,1);}
}
// buffer
let n=0;
for(const t of tracers){
const i=n*6;
tPos[i]=t.pos.x;tPos[i+1]=t.pos.y;tPos[i+2]=t.pos.z;
const tail=_v1.copy(t.pos).addScaledVector(t.vel,-0.05);
tPos[i+3]=tail.x;tPos[i+4]=tail.y;tPos[i+5]=tail.z;
const c=t.fromEnemy?1:0.35;
tCol[i]=c;tCol[i+1]=t.fromEnemy?0.2:0.95;tCol[i+2]=t.fromEnemy?0.15:0.9;
tCol[i+3]=c*0.7;tCol[i+4]=t.fromEnemy?0.05:0.3;tCol[i+5]=t.fromEnemy?0.02:0.3;
n++;
}
tracerGeo.attributes.position.needsUpdate=true;
tracerGeo.attributes.color.needsUpdate=true;
tracerGeo.setDrawRange(0,n*2);
tracerLines.visible=n>0;
}
function spark(p,k=1){
for(let i=0;i<6*k;i++){
particles.push({pos:p.clone(),vel:_v1.set(Math.random()-0.5,Math.random()-0.2,Math.random()-0.5).normalize().multiplyScalar(8+Math.random()*14).clone(),life:0.35+Math.random()*0.3,size:0.5,col:1});
}
}
function explode(p,big){
const n=big?46:30;
for(let i=0;i<n;i++){
const v=_v1.set(Math.random()-0.5,Math.random()-0.5,Math.random()-0.5).normalize().multiplyScalar((big?26:16)+Math.random()*(big?30:18));
particles.push({pos:p.clone(),vel:v,life:0.7+Math.random()*0.8,size:big?2.2:1.4,col:2});
}
explosions.push({pos:p.clone(),t:0,dur:big?1.6:0.9,scale:big?42:24});
const light=new THREE.PointLight(0xffa050,big?260:120,big?700:300);
light.position.copy(p);scene.add(light);
explosions.push({light,fade:1});
sfx('boom');shake(big?1.2:0.5,big?0.9:0.4);
const f=$('flash');f.style.opacity=big?0.8:0.35;setTimeout(()=>f.style.opacity=0,90);
}
function updateFX(dt){
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.life-=dt;p.pos.addScaledVector(p.vel,dt);
p.vel.y-=G0*0.5*dt;p.vel.multiplyScalar(1-1.2*dt);
if(p.life<=0)particles.splice(i,1);
}
for(let i=explosions.length-1;i>=0;i--){
const e=explosions[i];
if(e.t!==undefined){
e.t+=dt;
if(e.t>e.dur){
if(e.mesh){scene.remove(e.mesh);e.mesh.material.dispose();e.mesh.geometry.dispose();}
explosions.splice(i,1);
}else{
if(!e.mesh){
const m=new THREE.Sprite(new THREE.SpriteMaterial({map:fireballTex(),transparent:true,opacity:0.95,depthWrite:false}));
m.position.copy(e.pos);scene.add(m);e.mesh=m;
}
const k=e.t/e.dur;
e.mesh.scale.setScalar(e.scale*(0.4+k*1.3));
e.mesh.material.opacity=0.95*(1-k);
}
}else if(e.fade!==undefined){
e.fade-=dt*2.2;e.light.intensity=Math.max(0,e.light.intensity*0.86);
if(e.fade<=0){scene.remove(e.light);e.light.dispose&&e.light.dispose();explosions.splice(i,1);}
}
}
}
let _fireTex=null;
function fireballTex(){
if(_fireTex)return _fireTex;
const c=document.createElement('canvas');c.width=c.height=128;
const x=c.getContext('2d');
const g=x.createRadialGradient(64,64,4,64,64,64);
g.addColorStop(0,'rgba(255,255,230,1)');g.addColorStop(0.3,'rgba(255,180,80,0.9)');g.addColorStop(0.7,'rgba(200,60,20,0.5)');g.addColorStop(1,'rgba(60,20,10,0)');
x.fillStyle=g;x.fillRect(0,0,128,128);
_fireTex=new THREE.CanvasTexture(c);
return _fireTex;
}
let particlePoints=null,pPosArr=null,pColArr=null,pSizeArr=null;
const MAXP=400;
function initParticles(){
pPosArr=new Float32Array(MAXP*3);pColArr=new Float32Array(MAXP*3);pSizeArr=new Float32Array(MAXP);
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.BufferAttribute(pPosArr,3));
g.setAttribute('color',new THREE.BufferAttribute(pColArr,3));
particlePoints=new THREE.Points(g,new THREE.PointsMaterial({size:2.2,vertexColors:true,transparent:true,opacity:0.95,depthWrite:false,sizeAttenuation:true}));
particlePoints.frustumCulled=false;scene.add(particlePoints);
}
function updateParticlePoints(){
let n=0;
for(const p of particles){
const i=n*3;
pPosArr[i]=p.pos.x;pPosArr[i+1]=p.pos.y;pPosArr[i+2]=p.pos.z;
if(p.col===1){pColArr[i]=1;pColArr[i+1]=0.95;pColArr[i+2]=0.7;}
else{pColArr[i]=1;pColArr[i+1]=0.55-0.4*(1-p.life);pColArr[i+2]=0.15;}
pSizeArr[n]=p.size;
n++;if(n>=MAXP)break;
}
particlePoints.geometry.attributes.position.needsUpdate=true;
particlePoints.geometry.attributes.color.needsUpdate=true;
particlePoints.geometry.setDrawRange(0,n);
particlePoints.visible=n>0;
}
/* ================= AI enemies ================= */
function enemyDef(){
const base=AIRCRAFT.falcon;
return Object.assign({},base,{id:'hostile',name:'HOSTILE',mfr:'unknown',role:'HOSTILE',
colors:{body:0x7a4a4a,accent:0x8a2a1a,dark:0x241512,canopy:0xffb0a0}});
}
function spawnEnemy(){
const e=makeAircraft('falcon');
e.def=enemyDef();
e.group.userData.def=e.def;
// rebuild visuals with enemy colors
const old=e.group;e.group=buildAircraft(e.def);
e.pos.copy(aircraft.pos).add(_v1.set(Math.random()-0.5,Math.random()-0.2,Math.random()-0.5).normalize().multiplyScalar(1600+Math.random()*900));
e.pos.y=Math.max(e.pos.y,heightAt(e.pos.x,e.pos.z)+120);
e.group.position.copy(e.pos);
e.vel.copy(aircraft.vel).add(_v1.set(Math.random()-0.5,0,Math.random()-0.5).multiplyScalar(60));
e.health=100;e.rpm=0.8;
e.state='engage';e.think=0;e.fireCd=1+Math.random()*2;
scene.add(e.group);
enemies.push(e);
return e;
}
function aiThink(e,dt){
e.think-=dt;
const a=aircraft;
if(e.think>0)return;
e.think=0.12;
e.forward.set(0,0,1).applyQuaternion(e.quat);
if(a.dead||e.health<25){
e.state='disengage';
}else if(e.state==='disengage'&&e.health>60){
e.state='engage';
}
let desired;
if(e.state==='disengage'){
desired=_v2.copy(e.pos).add(_v1.copy(a.pos).sub(e.pos).normalize().multiplyScalar(-1)).add(_v3.set(0,1,0).multiplyScalar(80));
_v1.copy(desired).sub(e.pos).normalize();
}else{
const dist=e.pos.distanceTo(a.pos);
const lead=_v3.copy(a.vel).clone().multiplyScalar(clamp(dist/700,0,1.4));
_v1.copy(a.pos).add(lead).sub(e.pos).normalize();
// keep some speed: avoid pure pursuit at low energy
if(e.lastV<200&&dist<500){e.state='disengage';}
}
// control from local frame error
_q1.copy(e.quat).invert();
_v2.copy(_v1).applyQuaternion(_q1);
const pitchC=clamp(Math.atan2(_v2.y,Math.max(_v2.z,0.05))*2.2,-1,1);
const rollC=clamp(-Math.atan2(_v2.x,Math.max(_v2.z,0.05))*2.6,-1,1);
e.input={pitch:pitchC,roll:rollC,yaw:0,throttle:clamp(0.55+(e.lastV? clamp((260-e.lastV)/300,0,0.45):0.3),0.3,1),boost:e.lastV<220&&e.fuel>50};
// fire
e.fireCd-=dt;
const toT=_v3.copy(a.pos).sub(e.pos);
const d=toT.length();toT.normalize();
const cos=toT.dot(e.forward);
if(e.state==='engage'&&e.fireCd<=0&&d<1400&&d>180&&cos>0.995&&a.lastV>100){
// lead shot
const tt=d/820;
const aim=_v1.copy(a.pos).addScaledVector(a.vel,tt).sub(e.pos).normalize();
if(aim.dot(e.forward)>0.985){
for(let i=0;i<4;i++)fireTracer(_v2.copy(e.pos).addScaledVector(e.forward,10).add(_v4.set((Math.random()-0.5)*1.2,Math.random()*0.6,0)),aim,820,true);
e.fireCd=0.55+Math.random()*0.5;
}
}
}
function updateEnemies(dt){
for(let i=enemies.length-1;i>=0;i--){
const e=enemies[i];
if(e.dead){
e.deadT=(e.deadT||0)+dt;
e.group.position.y-=dt*8; // sink
if(e.deadT>3){scene.remove(e.group);e.group.traverse(o=>{if(o.isMesh){o.geometry.dispose();o.material.dispose();}});enemies.splice(i,1);}
continue;
}
aiThink(e,dt);
if(e.input.boost&&e.boostT<=0){e.boostT=2;}
physStep(e,dt,e.input,false);
groundStep(e,dt);
e.group.position.copy(e.pos);
e.group.quaternion.copy(e.quat);
// pick target
if(!targetEnemy&&enemies.length){
let best=null,bd=1e9;
for(const en of enemies){if(en.dead)continue;const d=en.pos.distanceTo(aircraft.pos);if(d<bd){bd=d;best=en;}}
targetEnemy=best;
}
}
if(targetEnemy&&(targetEnemy.dead||!enemies.includes(targetEnemy))){targetEnemy=null;}
}
/* ================= input ================= */
const keys=new Set();
function initInput(){
addEventListener('keydown',e=>{
if(e.code==='Space')e.preventDefault();
keys.add(e.code);
if(!running)return;
if(e.code==='KeyG'){if(aircraft){aircraft.gearDeployed=!aircraft.gearDeployed;gearSfx();}}
if(e.code==='KeyB'){aircraft.brake=!aircraft.brake;}
if(e.code==='KeyF'){if(aircraft){aircraft.flapOn=!aircraft.flapOn;}}
if(e.code==='KeyR'){resetRun();}
if(e.code==='KeyM'){backToMenu();}
});
addEventListener('keyup',e=>keys.delete(e.code));
addEventListener('mousemove',e=>{
if(!locked||!running)return;
const s=0.0022;
input.pitch+=-e.movementY*s;
input.roll+=e.movementX*s;
input.pitch=clamp(input.pitch,-1,1);input.roll=clamp(input.roll,-1,1);
});
document.addEventListener('pointerlockchange',()=>{
locked=document.pointerLockElement===$('gl');
if(!locked&&running&&!menuOpen){paused=true;$('pauseOverlay').classList.add('on');}
});
$('gl').addEventListener('click',()=>{
if(running&&!menuOpen&&!locked){$('gl').requestPointerLock();paused=false;$('pauseOverlay').classList.remove('on');}
});
$('pauseOverlay').addEventListener('click',()=>{
if(paused){$('gl').requestPointerLock();paused=false;$('pauseOverlay').classList.remove('on');}
});
}
function readInput(dt){
if(!running)return;
const a=aircraft;
input.pitch*=Math.exp(-6*dt);input.roll*=Math.exp(-6*dt);
if(keys.has('KeyQ')||keys.has('ArrowLeft'))input.yaw=1;
else if(keys.has('KeyE')||keys.has('ArrowRight'))input.yaw=-1;
else input.yaw*=Math.exp(-8*dt);
if(keys.has('KeyW')||keys.has('ArrowUp'))input.throttle=clamp(input.throttle+0.55*dt,0,1);
if(keys.has('KeyS')||keys.has('ArrowDown'))input.throttle=clamp(input.throttle-0.55*dt,0,1);
throttle=input.throttle;
a.boostT>0?0:(boost=keys.has('ShiftLeft')&&mode==='df');
if(boost&&a.boostT<=0&&keys.has('ShiftLeft'))a.boostT=2.6;
input.fire=keys.has('Space')&&mode==='df';
if(a.gearT!==undefined){a.gearT=lerp(a.gearT||0,a.gearDeployed?1:0,1-Math.exp(-4*dt));}
a.flapT=lerp(a.flapT||0,a.flapOn?1:0,1-Math.exp(-3*dt));
setFlapVisual(a,a.flapT*35);
// gear visual
if(a.group.userData.gearParts){
const t=a.gearT||0;
a.group.userData.gearParts.forEach((gr,i)=>{gr.position.y=(i<2?-0.55:-0.55)+t*0.95;});
}
// burner glow
const b=a.group.userData.burner;
if(b){const k=clamp((a.boostT>0?0.9:0)+a.rpm*0.25,0,1);b.material.opacity=k*0.85;}
if(a.group.userData.prop){a.group.userData.prop.rotation.z+=dt*(6+a.rpm*40);}
}
function resetRun(){
if(!aircraft)return;
resetPlaneToSpawn();
aircraft.health=100;aircraft.fuel=aircraft.def.fuelMax;
aircraft.gearDeployed=mode==='tl';aircraft.gearT=aircraft.gearDeployed?1:0;
if(mode==='df'){for(const e of enemies){if(!e.dead)spawnEnemyAt(e);} }
toast('RESET');
}
function spawnEnemyAt(e){
e.pos.copy(aircraft.pos).add(_v1.set(Math.random()-0.5,0.3,Math.random()-0.5).normalize().multiplyScalar(1800));
e.pos.y=Math.max(e.pos.y,heightAt(e.pos.x,e.pos.z)+150);
e.health=100;e.dead=false;e.state='engage';
e.group.position.copy(e.pos);
}
/* ================= menu ================= */
const menuPreviews=[];
function buildMenu(){
const wrap=$('cards');
for(const id of AIR_LIST){
const def=AIRCRAFT[id];
const card=document.createElement('div');
card.className='card'+(id===selAircraft?' sel':'');
card.dataset.id=id;
let stats='';
for(const [k,v,u] of def.stats)stats+=`<div class="stat"><span>${k}</span><b>${v}<span class="u">${u||''}</span></b></div>`;
card.innerHTML=`<div class="selTag">▸ SELECTED</div><div class="role">${def.role}</div><h3>${def.name}</h3><div class="mfr">${def.mfr}</div><canvas></canvas><div id="statsGrid">${stats}</div><div class="desc">${def.desc}</div>`;
card.addEventListener('click',()=>{
selAircraft=id;
for(const c of wrap.children)c.classList.toggle('sel',c.dataset.id===id);
});
wrap.appendChild(card);
const cv=card.querySelector('canvas');
const pr=new THREE.WebGLRenderer({canvas:cv,alpha:true,antialias:true});
pr.setPixelRatio(Math.min(devicePixelRatio,1.5));
const ms=new THREE.Scene();
const mc=new THREE.PerspectiveCamera(38,2,0.1,100);
mc.position.set(0,2.6,13.5);mc.lookAt(0,0.4,0);
const key=new THREE.DirectionalLight(0xffffff,2.2);key.position.set(4,6,5);ms.add(key);
ms.add(new THREE.HemisphereLight(0xbfd9ee,0x223040,1.1));
const m=buildAircraft(def,true);
m.position.set(0,0.4,0);m.rotation.y=-0.6;
ms.add(m);
menuPreviews.push({pr,ms,mc,m,card});
}
for(const el of document.querySelectorAll('.mode')){
el.addEventListener('click',()=>{
selMode=el.dataset.mode;
document.querySelectorAll('.mode').forEach(x=>x.classList.toggle('sel',x===el));
});
}
$('launch').addEventListener('click',startGame);
$('statAircraft').textContent=AIR_LIST.length;
$('statModes').textContent=2;
}
function animateMenuPreviews(){
if(!menuOpen)return;
menuAngle+=0.008;
for(const p of menuPreviews){
const w=p.card.clientWidth||300,h=118;
if(p.pr.getSize(new THREE.Vector2()).x!==w){p.pr.setSize(w,h,false);p.mc.aspect=w/h;p.mc.updateProjectionMatrix();}
p.m.rotation.y=-0.6+Math.sin(menuAngle)*0.5;
p.pr.render(p.ms,p.mc);
}
requestAnimationFrame(animateMenuPreviews);
}
/* ================= game flow ================= */
function toast(msg){
const t=$('toast');t.textContent=msg;t.classList.add('on');
clearTimeout(t._to);t._to=setTimeout(()=>t.classList.remove('on'),2600);
}
function killMsg(msg){
const d=document.createElement('div');d.className='kill';d.textContent=msg;
$('killfeed').appendChild(d);
setTimeout(()=>d.remove(),4200);
}
let climbDone=false, missionDone=false;
function missionComplete(title){
if(missionDone)return;
missionDone=true;
score+=2000;
toast(title+' · +2000 · FREE FLIGHT — PRESS M FOR MENU');
}
function startGame(){
menuOpen=false;paused=false;missionDone=false;climbDone=false;
$('menu').classList.add('hidden');
$('pauseOverlay').classList.remove('on');
initAudio();
if(AC&&AC.state==='suspended')AC.resume();
mode=selMode;
setupWorld();
// clear old
if(aircraft){scene.remove(aircraft.group);}
for(const e of enemies){scene.remove(e.group);}
enemies.length=0;tracers.length=0;particles.length=0;explosions.length=0;
targetEnemy=null;score=0;kills=0;
aircraft=makeAircraft(selAircraft);
scene.add(aircraft.group);
resetPlaneToSpawn();
aircraft.rpm=0.35;
throttle=input.throttle=0.35;
if(mode==='df'){for(let i=0;i<3;i++)spawnEnemy();}
$('hud').classList.add('on');
$('modeTag').textContent=mode==='tl'?'SCENARIO A · TAKEOFF/LANDING':'SCENARIO B · DOGFIGHT';
$('aircraftTag').textContent=aircraft.def.name;
$('controlsHint').innerHTML=mode==='tl'
?'<b>W/S</b> THROTTLE · <b>MOUSE</b> PITCH/ROLL · <b>F</b> FLAP · <b>G</b> GEAR · <b>B</b> BRAKE — ROTATE AT '+Math.round(aircraft.def.vStall*1.15)+' KT'
:'<b>MOUSE</b> AIM · <b>SPACE</b> FIRE · <b>SHIFT</b> BOOST · <b>W/S</b> THROTTLE · WATCH YOUR G';
running=true;
$('gl').requestPointerLock();
}
function backToMenu(){
running=false;menuOpen=true;paused=false;
document.exitPointerLock&&document.exitPointerLock();
$('menu').classList.remove('hidden');
$('hud').classList.remove('on');
$('pauseOverlay').classList.remove('on');
if(aircraft){scene.remove(aircraft.group);aircraft=null;}
for(const e of enemies){scene.remove(e.group);}
enemies.length=0;
animateMenuPreviews();
}
/* ================= main loop ================= */
function cameraFollow(dt){
const a=aircraft;
const back=_v1.copy(a.forward).multiplyScalar(-11.5);
const up=_v2.copy(a.upW).multiplyScalar(3.4);
const target=_v3.copy(a.pos).add(back).add(up);
// keep camera above terrain
const gy=heightAt(target.x,target.z);
if(target.y<gy+2)target.y=gy+2;
camera.position.lerp(target,1-Math.exp(-5.5*dt));
const tq=_q1.copy(a.quat);
camera.quaternion.slerp(tq,1-Math.exp(-7*dt));
// shake
if(shakeT>0){
shakeT-=dt;
const s=shakeMag*shakeT;
camera.position.x+=(Math.random()-0.5)*s;camera.position.y+=(Math.random()-0.5)*s;camera.position.z+=(Math.random()-0.5)*s;
if(shakeT<=0)shakeMag=0;
}
const fovT=62+clamp((a.lastV||0)/40,0,14)*(a.boostT>0?1.6:1);
camera.fov=lerp(camera.fov,fovT,1-Math.exp(-3*dt));
camera.updateProjectionMatrix();
}
function step(dt){
const a=aircraft;
// firing
if(mode==='df'&&input.fire&&!a.dead){
fireCd-=dt;
if(fireCd<=0){
fireCd=0.14;
const dir=_v1.copy(a.forward);
for(let i=0;i<3;i++){
const off=_v2.set((i-1)*0.9+(Math.random()-0.5)*0.3,(Math.random()-0.5)*0.3,0).applyQuaternion(a.quat);
fireTracer(_v3.copy(a.pos).addScaledVector(a.forward,10).add(off),dir,820,false);
}
sfx('shot');
shake(0.06,0.08);
}
}
physStep(a,dt,input,true);
groundStep(a,dt);
a.group.position.copy(a.pos);
a.group.quaternion.copy(a.quat);
updateEnemies(dt);
updateTracers(dt);
updateFX(dt);
updateParticlePoints();
// mission logic
if(mode==='tl'&&!a.dead){
const spawnY=heightAt(0,-2600);
if(!climbDone&&a.pos.y>spawnY+122){climbDone=true;score+=750;toast('CLIMB-OUT COMPLETE · +750');sfx('goodland');}
}
if(mode==='df'&&kills>=3&&!missionDone){missionComplete('ENGAGEMENT CLEAR');}
}
let frameId=0;
function frame(now){
frameId=requestAnimationFrame(frame);
let dt=(now-lastT)/1000;lastT=now;
if(dt>0.1)dt=0.1;
if(running&&!paused&&aircraft){
readInput(dt);
// fixed substeps for stable integration
let rem=dt;const h=1/120;
while(rem>1e-4){const s=Math.min(h,rem);step(s);rem-=s;}
updateTerrain(now/1000);
cameraFollow(dt);
updateSkyFollow(dt);
updateAudio(dt);
hudAcc+=dt;
drawADI(dt);
if(hudAcc>0.1){updateHUD();hudAcc=0;}
}else if(menuOpen){
updateSkyFollow(dt);
// idle slow orbit of the world as menu backdrop
camera.position.lerp(_v1.set(600,420,-2200),1-Math.exp(-0.5*dt));
camera.lookAt(0,120,-2600);
}
renderer.render(scene,camera);
}
/* ================= boot ================= */
initRenderer();
makeSky();makeLights();makeWater();makeClouds();
initTerrain();initFX();initParticles();initHUD();initInput();buildMenu();
// pre-build terrain around TL spawn so the backdrop is alive
flattenSpots=[{x:0,z:-2600,r:420,h:9}];
for(let dx=-2;dx<=2;dx++)for(let dz=-2;dz<=2;dz++)buildChunk(dx,dz);
camera.position.set(600,420,-2200);
camera.lookAt(0,120,-2600);
animateMenuPreviews();
lastT=performance.now();
frameId=requestAnimationFrame(frame);
window.__fx={get aircraft(){return aircraft},camera,terrain,runways,heightAt,flattenSpots,scene,physStep,groundStep,mode:()=>mode,score:()=>score,enemies};
</script>
</html>