Flight Simulator
muse spark 1.2 · 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 47,980 bytes · SHA-256 fdaaf22b1735
<!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 href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Orbitron:wght@500;700;900&display=swap" rel="stylesheet">
<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>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--cyan:#00e5ff;--orange:#ff6a00;--glass:rgba(10,18,30,.82);--line:rgba(0,229,255,.22)}
html,body{width:100%;height:100%;overflow:hidden;background:#02070f;font-family:'JetBrains Mono',monospace;color:#cfe8ff}
canvas#canvas{display:block;width:100%;height:100%}
/* top bar */
#topbar{position:absolute;top:0;left:0;right:0;height:42px;background:linear-gradient(180deg,rgba(6,18,32,.95),rgba(6,18,32,.72));border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 18px;z-index:20;backdrop-filter:blur(10px)}
#brand{font-family:'Orbitron',sans-serif;font-weight:900;letter-spacing:.16em;font-size:13px;display:flex;align-items:center;gap:10px}
#brand b{color:var(--cyan);text-shadow:0 0 12px rgba(0,229,255,.7)}
#brand span{color:#7a8da8;font-weight:700;font-size:10px;letter-spacing:.22em}
#topnav{display:flex;gap:8px;align-items:center}
.pill{font-size:10px;letter-spacing:.14em;padding:7px 12px;border:1px solid var(--line);background:rgba(0,229,255,.06);color:#8ec9ff;cursor:pointer;transition:.2s}
.pill.active{background:var(--cyan);color:#001019;border-color:var(--cyan);box-shadow:0 0 14px rgba(0,229,255,.5)}
.pill:hover{border-color:var(--cyan)}
/* layout */
#hud{position:absolute;inset:42px 0 0 0;pointer-events:none}
#leftPanel{position:absolute;left:14px;top:14px;width:250px;pointer-events:auto}
#rightPanel{position:absolute;right:14px;top:14px;width:268px;pointer-events:auto}
#bottomHUD{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);display:flex;gap:10px;pointer-events:none}
.glass{background:var(--glass);border:1px solid var(--line);backdrop-filter:blur(12px);box-shadow:0 8px 28px rgba(0,0,0,.45)}
.card{padding:12px}
.card h3{font-family:'Orbitron',sans-serif;font-size:9px;letter-spacing:.18em;color:var(--cyan);margin-bottom:8px;display:flex;align-items:center;gap:6px}
.card h3 i{width:6px;height:6px;background:var(--cyan);display:inline-block;box-shadow:0 0 8px var(--cyan)}
.divider{height:1px;background:linear-gradient(90deg,transparent,rgba(0,229,255,.35),transparent);margin:8px 0}
/* aircraft select */
.aircraft{border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.03);padding:8px 10px;display:flex;gap:10px;align-items:center;cursor:pointer;transition:.18s;margin-bottom:6px}
.aircraft:hover{border-color:rgba(0,229,255,.4);background:rgba(0,229,255,.08)}
.aircraft.sel{border-color:var(--cyan);background:rgba(0,229,255,.14);box-shadow:inset 0 0 0 1px rgba(0,229,255,.2)}
.aircraft .thumb{width:46px;height:32px;background:#0a1420;border:1px solid rgba(255,255,255,.07);display:grid;place-items:center;font-size:16px}
.aircraft .info{flex:1}
.aircraft .name{font-family:'Orbitron',sans-serif;font-size:10px;letter-spacing:.1em;color:#e6f3ff}
.aircraft .role{font-size:9px;color:#6b84a0;letter-spacing:.08em}
.miniBar{height:3px;background:rgba(255,255,255,.08);margin-top:4px;position:relative;overflow:hidden}
.miniBar i{position:absolute;left:0;top:0;bottom:0;background:var(--cyan)}
.statsGrid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px}
.stat{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);padding:6px 7px}
.stat label{font-size:8px;letter-spacing:.14em;color:#6b84a0;display:block}
.stat b{font-size:11px;color:#e6f3ff}
/* controls */
.kbdRow{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
.kbd{font-size:9px;padding:4px 6px;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.12);border-bottom-width:2px;color:#b9d6f2;min-width:26px;text-align:center}
.kbd.on{background:var(--cyan);color:#001019;border-color:#00b8cc;box-shadow:0 0 10px rgba(0,229,255,.4)}
/* HUD elements */
.hudBox{padding:10px 12px;min-width:148px}
.hudBox h4{font-size:8px;letter-spacing:.16em;color:#6b84a0}
.hudBox .val{font-family:'Orbitron',sans-serif;font-size:18px;color:#fff;letter-spacing:.04em}
.hudBox .unit{font-size:9px;color:#6b84a0;letter-spacing:.12em}
.bar{height:6px;background:rgba(255,255,255,.08);margin-top:6px;position:relative;overflow:hidden;border:1px solid rgba(255,255,255,.06)}
.barFill{position:absolute;left:0;top:0;bottom:0;transition:width .12s}
#thrFill{background:linear-gradient(90deg,#ff6a00,#ffd000)}
#spdFill{background:var(--cyan)}
#altFill{background:#8a5cff}
.vsi{width:3px;height:54px;background:rgba(255,255,255,.06);position:relative;border:1px solid rgba(255,255,255,.08)}
.vsi i{position:absolute;left:0;right:0;height:3px;background:var(--cyan);transform:translateY(-50%);box-shadow:0 0 6px var(--cyan)}
/* center reticle */
#reticle{position:absolute;left:50%;top:52%;transform:translate(-50%,-50%);width:120px;height:120px;pointer-events:none;opacity:.9}
#reticle svg{width:100%;height:100%;overflow:visible}
#statusLine{position:absolute;left:50%;top:12px;transform:translateX(-50%);display:flex;gap:8px;pointer-events:none}
.badge{font-size:9px;letter-spacing:.14em;padding:6px 10px;border:1px solid var(--line);background:rgba(0,0,0,.4);color:#8ec9ff}
.badge.warn{border-color:rgba(255,80,60,.6);color:#ff6b5a;background:rgba(60,12,8,.6);animation:blink .8s infinite alternate}
.badge.good{border-color:rgba(60,255,140,.5);color:#5cff9a}
@keyframes blink{from{opacity:1}to{opacity:.55}}
/* runway marker */
#runwayHUD{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);text-align:center;pointer-events:none}
#runwayHUD b{font-family:'Orbitron',sans-serif;font-size:10px;letter-spacing:.2em;color:var(--cyan)}
/* overlay */
#overlay{position:absolute;inset:42px 0 0 0;background:rgba(2,8,18,.58);backdrop-filter:blur(4px);display:grid;place-items:center;z-index:10}
#overlayBox{width:min(560px,92vw);background:rgba(8,18,30,.92);border:1px solid rgba(0,229,255,.3);box-shadow:0 20px 60px rgba(0,0,0,.6),0 0 0 1px rgba(0,229,255,.12) inset;padding:22px}
#overlayBox h2{font-family:'Orbitron',sans-serif;letter-spacing:.14em;font-size:18px;color:#fff}
#overlayBox h2 em{color:var(--cyan);font-style:normal}
#overlayBox p{font-size:11px;line-height:1.6;color:#8fb0cc;margin:8px 0 14px}
.btn{font-family:'Orbitron',sans-serif;letter-spacing:.14em;font-size:11px;padding:11px 18px;border:1px solid var(--cyan);background:var(--cyan);color:#001019;cursor:pointer;box-shadow:0 0 18px rgba(0,229,255,.45);transition:.18s}
.btn:hover{filter:brightness(1.08);transform:translateY(-1px)}
.btn.ghost{background:transparent;color:var(--cyan)}
.btnRow{display:flex;gap:8px;flex-wrap:wrap}
.missionGrid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:10px 0}
.mCard{border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.03);padding:10px;cursor:pointer;transition:.18s}
.mCard.sel{border-color:var(--cyan);background:rgba(0,229,255,.1)}
.mCard h4{font-family:'Orbitron',sans-serif;font-size:10px;letter-spacing:.1em;color:#e6f3ff}
.mCard p{font-size:9px;color:#6b84a0;margin:4px 0 0;line-height:1.4}
#attitude{width:84px;height:84px;border-radius:50%;background:radial-gradient(circle at 50% 30%,#1a3a5a,#07111d);border:1px solid rgba(0,229,255,.35);position:relative;overflow:hidden;box-shadow:inset 0 0 18px rgba(0,0,0,.6)}
#attHorizon{position:absolute;left:-20%;right:-20%;height:50%;background:linear-gradient(180deg,#3b6e2a 0%,#2a4a1a 100%);top:50%;transform-origin:50% 0}
#attPitch{position:absolute;left:0;right:0;top:50%;height:1px;background:rgba(255,255,255,.7)}
#attRoll{position:absolute;left:50%;top:50%;width:56px;height:2px;background:var(--cyan);transform:translate(-50%,-50%);box-shadow:0 0 8px var(--cyan)}
#telemetry{font-size:9px;line-height:1.7;color:#7f9bb8}
#telemetry b{color:#cfe8ff}
@media(max-width:980px){#leftPanel{width:210px}#rightPanel{width:210px}#bottomHUD{bottom:6px;gap:6px}.hudBox{min-width:110px;padding:8px 9px}.hudBox .val{font-size:14px}}
@media(max-width:720px){#leftPanel,#rightPanel{position:static}#hud{display:flex;flex-direction:column;gap:10px;overflow:auto;padding:10px}#reticle{display:none}#bottomHUD{position:static;transform:none;justify-content:center;flex-wrap:wrap}}
</style>
</head>
<body>
<div id="topbar">
<div id="brand"><span style="width:10px;height:10px;background:var(--cyan);display:inline-block;transform:skewX(-12deg);box-shadow:0 0 10px var(--cyan)"></span><b>APEX</b> AERO <span>FLIGHT SYSTEMS v2.4</span></div>
<div id="topnav">
<button class="pill active" id="btnFree">FREE FLIGHT</button>
<button class="pill" id="btnLanding">LANDING TRIAL</button>
<button class="pill" id="btnDogfight">DOGFIGHT</button>
<button class="pill" id="btnReset" style="border-color:rgba(255,106,0,.4);color:#ffb07a">↺ RESET</button>
</div>
</div>
<canvas id="canvas"></canvas>
<div id="hud">
<div id="leftPanel" class="glass card">
<h3><i></i> AIRFRAME SELECT</h3>
<div id="fleet"></div>
<div class="divider"></div>
<div style="display:flex;gap:8px;align-items:center">
<div id="attitude"><div id="attHorizon"></div><div id="attPitch"></div><div id="attRoll"></div></div>
<div id="telemetry"></div>
</div>
<div class="divider"></div>
<h3><i></i> CONTROLS</h3>
<div style="font-size:9px;color:#6b84a0;letter-spacing:.08em">PITCH / ROLL / YAW / THROTTLE</div>
<div class="kbdRow" id="kbdRow">
<span class="kbd" data-k="KeyW">W ↑</span><span class="kbd" data-k="KeyS">S ↓</span><span class="kbd" data-k="KeyA">A ←</span><span class="kbd" data-k="KeyD">D →</span><span class="kbd" data-k="KeyQ">Q YAW</span><span class="kbd" data-k="KeyE">E YAW</span><span class="kbd" data-k="ShiftLeft">SHIFT +THR</span><span class="kbd" data-k="ControlLeft">CTRL −THR</span><span class="kbd" data-k="KeyG">G GEAR</span><span class="kbd" data-k="Space">SPACE FIRE</span><span class="kbd" data-k="KeyB">B BRAKE</span>
</div>
<div style="font-size:8px;color:#4a6380;margin-top:6px">Mouse drag = pitch & roll • Scroll = throttle</div>
</div>
<div id="rightPanel" class="glass card">
<h3><i></i> MISSION & ENVIRONMENT</h3>
<div id="missionInfo" style="font-size:10px;line-height:1.6;color:#8fb0cc"></div>
<div class="divider"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<div class="stat"><label>WIND</label><b id="windVal">12 kt NW</b></div>
<div class="stat"><label>VISIBILITY</label><b>22 km</b></div>
<div class="stat"><label>AIR DENSITY</label><b id="rhoVal">1.02 kg/m³</b></div>
<div class="stat"><label>STALL MARGIN</label><b id="stallVal" style="color:#5cff9a">SAFE</b></div>
</div>
<div class="divider"></div>
<div style="font-size:9px;letter-spacing:.12em;color:#6b84a0">AUDIO</div>
<label style="display:flex;align-items:center;gap:8px;margin-top:6px;font-size:10px;color:#8fb0cc"><input type="checkbox" id="audioToggle" checked> Engine & aero linked to physics</label>
<div class="divider"></div>
<button class="btn ghost" id="btnPause" style="width:100%;padding:8px">⏸ PAUSE (P)</button>
</div>
<div id="statusLine">
<span class="badge" id="modeBadge">● FREE FLIGHT</span>
<span class="badge" id="gearBadge">GEAR DOWN</span>
<span class="badge" id="stallBadge" style="display:none">⚠ STALL</span>
</div>
<div id="reticle">
<svg viewBox="0 0 120 120">
<circle cx="60" cy="60" r="42" fill="none" stroke="rgba(0,229,255,.9)" stroke-width="1.2" stroke-dasharray="6 6"/>
<circle cx="60" cy="60" r="2.5" fill="#00e5ff"/>
<path d="M60 18 L60 30 M60 90 L60 102 M18 60 L30 60 M90 60 L102 60" stroke="#00e5ff" stroke-width="1.2"/>
<path d="M38 38 L46 46 M74 38 L66 46 M38 82 L46 74 M74 82 L66 74" stroke="rgba(0,229,255,.6)" stroke-width="1"/>
<text x="60" y="14" text-anchor="middle" fill="#00e5ff" font-size="6" letter-spacing="2">APEX HUD</text>
</svg>
</div>
<div id="runwayHUD"><b id="runwayText">ALIGN — RUNWAY 27L • 0.9 km</b><div style="width:120px;height:2px;background:linear-gradient(90deg,transparent,var(--cyan),transparent);margin:4px auto"></div></div>
<div id="bottomHUD">
<div class="glass hudBox">
<h4>AIRSPEED — KTS</h4>
<div class="val" id="spdVal">184</div><div class="unit">Vne 520 • Vs 92</div>
<div class="bar"><div class="barFill" id="spdFill" style="width:42%"></div></div>
</div>
<div class="glass hudBox">
<h4>ALTITUDE — FT MSL</h4>
<div class="val" id="altVal">1,240</div><div class="unit">AGL <span id="aglVal">840</span> ft • QNH 1013</div>
<div class="bar"><div class="barFill" id="altFill" style="width:30%"></div></div>
</div>
<div class="glass hudBox" style="min-width:170px">
<h4>ENGINE & THRUST</h4>
<div style="display:flex;align-items:baseline;gap:8px"><div class="val" id="thrVal">72%</div><span class="unit" id="rpmVal">6,840 RPM</span></div>
<div class="bar"><div class="barFill" id="thrFill" style="width:72%"></div></div>
<div style="display:flex;gap:6px;margin-top:6px"><span class="badge" id="heatBadge" style="font-size:8px;padding:3px 6px">HEAT NOMINAL</span><span class="badge" id="aoaBadge" style="font-size:8px;padding:3px 6px">AoA 4.2°</span></div>
</div>
<div class="glass hudBox" style="min-width:90px;display:flex;gap:8px;align-items:center">
<div class="vsi"><i id="vsiNeedle" style="top:50%"></i></div>
<div>
<h4>VERTICAL SPEED</h4>
<div class="val" id="vsVal" style="font-size:14px">+340</div><div class="unit">ft/min</div>
<div style="font-size:8px;color:#6b84a0;margin-top:4px">HDG <b id="hdgVal" style="color:#cfe8ff">274°</b></div>
</div>
</div>
</div>
</div>
<div id="overlay">
<div id="overlayBox">
<div style="font-size:9px;letter-spacing:.2em;color:var(--cyan)">APEX AERO — HIGH-FIDELITY SIMULATOR</div>
<h2>TAKE COMMAND <em>OF THE SKY</em></h2>
<p>Full Newtonian flight model: lift ∝ ½ρV²S·Cl(α), drag = Cd₀ + k·Cl², thrust & weight-inertia, density falloff with altitude, and critical AoA stall. Three airframes, procedural alpine-coastal world, and two mission profiles.</p>
<div class="missionGrid">
<div class="mCard sel" data-m="free"><h4>◎ FREE FLIGHT</h4><p>Explore the 12 km terrain. Practice energy management and stalls.</p></div>
<div class="mCard" data-m="landing"><h4>⬢ LANDING TRIAL</h4><p>Precision approach to Runway 27L. Manage speed, sink rate & AoA.</p></div>
<div class="mCard" data-m="dogfight"><h4>⬣ DOGFIGHT</h4><p>High-G pursuit — lead the pipper, manage energy, stay fast.</p></div>
<div class="mCard" style="opacity:.7;cursor:default"><h4>◈ PHYSICS NOTE</h4><p>Rho drops ~10%/1000m. Induced drag rises with Cl². Inertia is rate-limited.</p></div>
</div>
<div class="btnRow">
<button class="btn" id="btnFly">▶ ENGAGE — FLY NOW</button>
<button class="btn ghost" id="btnHow">HOW TO FLY</button>
</div>
<div id="howText" style="display:none;margin-top:10px;font-size:10px;line-height:1.6;color:#8fb0cc;border-left:2px solid var(--cyan);padding-left:10px">W/S pitch, A/D roll, Q/E yaw, Shift/Ctrl or Mouse-wheel for throttle. Mouse-drag to fly. Space fires (dogfight). G lowers gear, B air-brake. Keep AoA < 14°, speed > 1.2×Vs on approach. Stall buffet + wing drop is simulated. Atmospheric density & drag are live on HUD.</div>
</div>
</div>
<script type="module">
import * as THREE from 'three';
// ---------- CONFIG ----------
const FLEET = [
{ id:'viper', name:'VF-11 VIPER', role:'Air Superiority', color:'#00e5ff', accent:'#00b8cc',
maxThrust: 88000, weight: 8200, wingArea: 28, cd0:0.022, k:0.065, clAlpha:5.2, clMax:1.45, stallAoA:15, rollRate:1.9, pitchRate:1.2, icon:'◈' },
{ id:'talon', name:'AT-9 TALON', role:'Strike / Heavy', color:'#ff6a00', accent:'#cc5200',
maxThrust: 125000, weight: 13500, wingArea: 34, cd0:0.028, k:0.075, clAlpha:4.6, clMax:1.32, stallAoA:13, rollRate:1.05, pitchRate:0.85, icon:'⬢' },
{ id:'swift', name:'XR-7 SWIFT', role:'Interceptor — Agile', color:'#8a5cff', accent:'#6a3fe0',
maxThrust: 64000, weight: 6100, wingArea: 22, cd0:0.018, k:0.055, clAlpha:5.8, clMax:1.55, stallAoA:16.5, rollRate:2.6, pitchRate:1.55, icon:'⬣' },
];
let selectedIdx = 0, mission='free', paused=false, gearDown=true, brake=false;
// ---------- THREE SETUP ----------
const canvas = document.getElementById('canvas');
const renderer = new THREE.WebGLRenderer({canvas, antialias:true, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0a1a2e, 0.00042);
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 60000);
const clock = new THREE.Clock();
scene.background = new THREE.Color(0x4a7fb5);
// sky gradient via large inverted sphere
const skyGeo = new THREE.SphereGeometry(28000, 32, 32);
const skyMat = new THREE.ShaderMaterial({
side:THREE.BackSide,
vertexShader:`varying vec3 vP;void main(){vP=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,
fragmentShader:`varying vec3 vP;void main(){
float h = normalize(vP).y;
vec3 top = vec3(0.05,0.12,0.32);
vec3 mid = vec3(0.32,0.62,0.92);
vec3 hor = vec3(0.78,0.86,0.92);
float t = clamp((h+0.25)/0.85,0.,1.);
vec3 c = mix(hor, mix(mid, top, smoothstep(0.,1.,t)), smoothstep(0.,0.9,t));
// sun
float sun = pow(max(0., dot(normalize(vP), normalize(vec3(0.6,0.45,0.2)))), 420.)*1.2;
c += vec3(1.,0.92,0.62)*sun;
gl_FragColor = vec4(c,1.);
}`
});
scene.add(new THREE.Mesh(skyGeo, skyMat));
// lights
scene.add(new THREE.HemisphereLight(0xcfe8ff, 0x0a1a2a, 1.15));
const sun = new THREE.DirectionalLight(0xfff2d6, 2.2); sun.position.set(6000,4200,1200); sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048); sun.shadow.camera.near=100; sun.shadow.camera.far=12000;
sun.shadow.camera.left=-4000; sun.shadow.camera.right=4000; sun.shadow.camera.top=4000; sun.shadow.camera.bottom=-4000;
scene.add(sun);
scene.add(new THREE.DirectionalLight(0x6ab0ff, 0.35).translateX(-4000));
// ---------- TERRAIN procedural FBM ----------
function fbm(x,y){
let v=0, amp=1, freq=0.0009;
for(let i=0;i<5;i++){
v += Math.sin(x*freq*1.3)*Math.cos(y*freq*0.9)*amp;
v += Math.sin((x+y)*freq*0.7)*amp*0.5;
amp*=0.48; freq*=2.1;
}
// ridges
let r = Math.abs(Math.sin(x*0.00062)*Math.cos(y*0.00058))*1.2;
// coastline falloff: make south side low
let coast = Math.max(0, (y+5200)/8200); // 0 south coast, 1 north
v = v*420 + r*620 + coast*160 - 80;
// valley
v -= Math.exp(-Math.pow((x-900)/1800,2))*160;
return v;
}
const T_SIZE=14000, T_SEG=180;
const tGeo = new THREE.PlaneGeometry(T_SIZE,T_SIZE,T_SEG,T_SEG);
const posAttr = tGeo.attributes.position;
const colors=[];
const cLow=new THREE.Color(0x0f3a1e), cMid=new THREE.Color(0x6b7a3a), cRock=new THREE.Color(0x8a8f96), cSnow=new THREE.Color(0xeef4ff), cSand=new THREE.Color(0xc8b98a);
let minH=1e9,maxH=-1e9;
for(let i=0;i<posAttr.count;i++){
const x=posAttr.getX(i), y=posAttr.getY(i);
const h=fbm(x,y);
posAttr.setZ(i,h);
minH=Math.min(minH,h); maxH=Math.max(maxH,h);
}
tGeo.computeVertexNormals();
// color by height / slope
for(let i=0;i<posAttr.count;i++){
const h=posAttr.getZ(i);
const nY=tGeo.attributes.normal.getY(i);
let c=new THREE.Color();
if(h<18) c.copy(cSand).lerp(cLow, Math.max(0,(h+10)/28));
else if(h<220) c.copy(cLow).lerp(cMid, (h-18)/202);
else if(h<520) c.copy(cMid).lerp(cRock, (h-220)/300);
else if(h<720) c.copy(cRock);
else c.copy(cRock).lerp(cSnow, Math.min(1,(h-720)/300));
if(nY<0.72) c.lerp(cRock, 0.42);
colors.push(c.r,c.g,c.b);
}
tGeo.setAttribute('color', new THREE.Float32BufferAttribute(colors,3));
const tMat = new THREE.MeshStandardMaterial({vertexColors:true, roughness:0.92, metalness:0.02});
const terrain = new THREE.Mesh(tGeo, tMat); terrain.rotation.x=-Math.PI/2; terrain.receiveShadow=true; scene.add(terrain);
// water plane south
const water = new THREE.Mesh(new THREE.PlaneGeometry(16000,6000), new THREE.MeshStandardMaterial({color:0x0e3a5a, roughness:0.22, metalness:0.35, transparent:true, opacity:0.92}));
water.rotation.x=-Math.PI/2; water.position.set(0,-2,-5800); scene.add(water);
// fog shroud under water for depth
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(16000,6000), new THREE.MeshBasicMaterial({color:0x0a1f33, transparent:true, opacity:0.35})));
scene.children[scene.children.length-1].rotation.x=-Math.PI/2; scene.children[scene.children.length-1].position.set(0,1,-5800);
// Runway
const runwayGroup = new THREE.Group();
const rwLen=2200, rwWid=82;
const rwMesh = new THREE.Mesh(new THREE.PlaneGeometry(rwLen, rwWid), new THREE.MeshStandardMaterial({color:0x1a2530, roughness:0.88}));
rwMesh.rotation.x=-Math.PI/2; rwMesh.position.set(0,0.9, 900); rwMesh.receiveShadow=true; runwayGroup.add(rwMesh);
// markings
for(let i=-rwLen/2+140;i<rwLen/2;i+=180){
const m=new THREE.Mesh(new THREE.PlaneGeometry(46,4), new THREE.MeshBasicMaterial({color:0xeef4ff}));
m.rotation.x=-Math.PI/2; m.position.set(i,1.1,900); runwayGroup.add(m);
}
const thr = new THREE.Mesh(new THREE.PlaneGeometry(10, rwWid*0.98), new THREE.MeshBasicMaterial({color:0xffffff}));
thr.rotation.x=-Math.PI/2; thr.position.set(-rwLen/2+16,1.2,900); runwayGroup.add(thr);
const thr2=thr.clone(); thr2.position.x=rwLen/2-16; runwayGroup.add(thr2);
// lights
for(let i=-rwLen/2;i<=rwLen/2;i+=140){
const l=new THREE.Mesh(new THREE.SphereGeometry(2.2,6,6), new THREE.MeshBasicMaterial({color:0x8ad0ff}));
l.position.set(i,1.5,900-rwWid/2-4); runwayGroup.add(l);
const l2=l.clone(); l2.position.z=900+rwWid/2+4; runwayGroup.add(l2);
}
// PAPI
for(let k=0;k<4;k++){ const p=new THREE.Mesh(new THREE.BoxGeometry(7,4,7), new THREE.MeshStandardMaterial({color:k<2?0xffffff:0xff3b30, emissive:k<2?0xffffff:0xff1a1a, emissiveIntensity:0.9})); p.position.set(-rwLen/2+260+k*12,3,900+54); runwayGroup.add(p); }
scene.add(runwayGroup);
// approach lights
for(let i=1;i<=7;i++){ const al=new THREE.Mesh(new THREE.BoxGeometry(6,1,6), new THREE.MeshBasicMaterial({color:0xffffff})); al.position.set(-rwLen/2 - i*120,0.6,900); scene.add(al); }
// scattered trees/buildings for scale (instanced-ish simple)
const deco=new THREE.Group();
for(let i=0;i<140;i++){
const x=(Math.random()-0.5)*12000, z=(Math.random()-0.5)*11000;
const h=fbm(x,z);
if(h<30 || h>680) continue;
if(Math.abs(x)<1200 && Math.abs(z-900)<600) continue;
const s= 4+Math.random()*8;
const trunk=new THREE.Mesh(new THREE.CylinderGeometry(s*0.18,s*0.24,s*1.2,6), new THREE.MeshStandardMaterial({color:0x3d2a16}));
trunk.position.set(x,h+0.6,z); scene.add(trunk);
const top=new THREE.Mesh(new THREE.ConeGeometry(s*0.9,s*2.2,6), new THREE.MeshStandardMaterial({color:0x1e4a22}));
top.position.set(x,h+1.2,z); scene.add(top);
}
scene.add(deco);
// ---------- AIRCRAFT VISUAL ----------
function makeAircraftVisual(spec){
const g=new THREE.Group();
const fusMat=new THREE.MeshStandardMaterial({color:spec.color, roughness:0.4, metalness:0.55, emissive:spec.color, emissiveIntensity:0.08});
const darkMat=new THREE.MeshStandardMaterial({color:0x0a121c, roughness:0.7, metalness:0.2});
// fuselage
const body=new THREE.Mesh(new THREE.CapsuleGeometry(2.1,16,4,12), fusMat); body.rotation.z=Math.PI/2; body.position.x=1.2; g.add(body);
const nose=new THREE.Mesh(new THREE.ConeGeometry(2.1,6,12), fusMat); nose.rotation.z=-Math.PI/2; nose.position.x=12.2; g.add(nose);
const cockpit=new THREE.Mesh(new THREE.CapsuleGeometry(1.25,4.5,4,10), new THREE.MeshStandardMaterial({color:0x0c1a2e, roughness:0.15, metalness:0.85, transparent:true, opacity:0.92})); cockpit.rotation.z=Math.PI/2; cockpit.position.set(4.2,1.1,0); g.add(cockpit);
// wings
const wingGeo=new THREE.BoxGeometry(8,0.32,13);
const wingL=new THREE.Mesh(wingGeo, fusMat); wingL.position.set(-0.2,0,6.9); wingL.rotation.y=0.32; g.add(wingL);
const wingR=wingL.clone(); wingR.position.z=-6.9; wingR.rotation.y=-0.32; g.add(wingR);
// tail
const vTail=new THREE.Mesh(new THREE.BoxGeometry(4.2,4.8,0.38), fusMat); vTail.position.set(-7.2,2.1,0); vTail.rotation.y=0.45; g.add(vTail);
const hTail=new THREE.Mesh(new THREE.BoxGeometry(3.4,0.26,5.2), fusMat); hTail.position.set(-7.4,0.2,0); g.add(hTail);
// engine nozzles
const noz=new THREE.Mesh(new THREE.CylinderGeometry(1.05,1.35,1.6,12), darkMat); noz.rotation.z=Math.PI/2; noz.position.set(-8.8,0,0); g.add(noz);
const glow=new THREE.Mesh(new THREE.CircleGeometry(1.15,16), new THREE.MeshBasicMaterial({color:0xff7a18, transparent:true, opacity:0.0})); glow.rotation.y=Math.PI; glow.position.set(-9.62,0,0); glow.name='glow'; g.add(glow);
// gear
const gear=new THREE.Group(); gear.name='gear';
const strutMat=new THREE.MeshStandardMaterial({color:0xc8d3e0});
const wheelMat=new THREE.MeshStandardMaterial({color:0x111820});
function leg(x,z){ const s=new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.12,2.6,6), strutMat); s.position.set(x,-1.55,z); const w=new THREE.Mesh(new THREE.CylinderGeometry(0.52,0.52,0.32,10), wheelMat); w.rotation.z=Math.PI/2; w.position.set(x,-2.85,z); gear.add(s,w); }
leg(2,0); leg(-2,2.0); leg(-2,-2.0); g.add(gear);
g.scale.set(1.15,1.15,1.15);
return g;
}
let planeMesh = makeAircraftVisual(FLEET[selectedIdx]);
scene.add(planeMesh);
// Enemy for dogfight
let enemy=null, enemyGroup=null;
function spawnEnemy(){
if(enemyGroup) scene.remove(enemyGroup);
enemyGroup = makeAircraftVisual({color:0xff3b3b, accent:0x8a0000}); enemyGroup.scale.set(1.05,1.05,1.05);
// recolor
enemyGroup.traverse(o=>{ if(o.isMesh && o.material && o.material.color) { if(o.material.color.getHex()!==0x0a121c && o.material.color.getHex()!==0x0c1a2e) o.material.color.setHex(0xff3b3b);} });
enemy={ pos:new THREE.Vector3(900,420,-1200), vel:new THREE.Vector3(-80,0,30), hp:100 };
enemyGroup.position.copy(enemy.pos); scene.add(enemyGroup);
}
// bullets
const bullets=[]; const bulletGeo=new THREE.SphereGeometry(1.1,6,6); const bulletMat=new THREE.MeshBasicMaterial({color:0xffff6a});
// ---------- PHYSICS STATE ----------
const state={
pos:new THREE.Vector3(0, 380, 2100), // start upwind of runway
vel:new THREE.Vector3(72, 0, -36), // m/s (~140kt)
quat:new THREE.Quaternion(),
euler:new THREE.Euler(0, Math.PI, 0, 'YXZ'), // yaw pi so nose points -Z
angVel:new THREE.Vector3(0,0,0),
throttle:0.62,
aoa:3, vs:0, ias:0, alt:0, agl:0, hdg:180, rho:1.225,
heat:0, gLoad:1
};
let time=0;
function getHeightAt(x,z){
// bilinear approx from FBM (cheap but ok)
return fbm(x,z);
}
// ---------- INPUT ----------
const keys={};
addEventListener('keydown',e=>{
keys[e.code]=true; updateKbd();
if(e.code==='KeyP'){ paused=!paused; document.getElementById('overlay').style.display= paused?'grid':'none'; }
if(e.code==='KeyG'){ gearDown=!gearDown; }
if(e.code==='Space'){ fireBullet(); e.preventDefault(); }
});
addEventListener('keyup',e=>{ keys[e.code]=false; updateKbd(); });
function updateKbd(){
document.querySelectorAll('.kbd').forEach(k=>k.classList.toggle('on', !!keys[k.dataset.k]));
}
let mouseDown=false, mouseX=0, mouseY=0, joyRoll=0, joyPitch=0;
canvas.addEventListener('mousedown',e=>{ mouseDown=true; mouseX=e.clientX; mouseY=e.clientY; });
addEventListener('mouseup',()=>{ mouseDown=false; joyRoll*=0.4; joyPitch*=0.4; });
canvas.addEventListener('mousemove',e=>{
if(!mouseDown) return;
const dx=(e.clientX-mouseX)/220, dy=(e.clientY-mouseY)/220;
joyRoll = THREE.MathUtils.clamp(dx*1.1, -1,1);
joyPitch= THREE.MathUtils.clamp(-dy*1.1, -1,1);
});
canvas.addEventListener('wheel',e=>{
state.throttle = THREE.MathUtils.clamp(state.throttle - e.deltaY*0.0005, 0,1);
},{passive:true});
canvas.addEventListener('contextmenu',e=>e.preventDefault());
// ---------- UI Fleet ----------
const fleetEl=document.getElementById('fleet');
function renderFleet(){
fleetEl.innerHTML='';
FLEET.forEach((f,i)=>{
const d=document.createElement('div'); d.className='aircraft'+(i===selectedIdx?' sel':'');
d.innerHTML=`<div class="thumb" style="color:${f.color}">${f.icon}</div><div class="info"><div class="name">${f.name}</div><div class="role">${f.role}</div><div class="miniBar"><i style="width:${Math.round(f.maxThrust/1500)}%;background:${f.color}"></i></div></div>`;
d.onclick=()=>{ selectedIdx=i; scene.remove(planeMesh); planeMesh=makeAircraftVisual(FLEET[i]); scene.add(planeMesh); renderFleet(); refreshStats(); };
fleetEl.appendChild(d);
});
}
renderFleet();
function refreshStats(){
const f=FLEET[selectedIdx];
document.getElementById('telemetry').innerHTML=
`<b>${f.name}</b><br>Thrust ${ (f.maxThrust/1000).toFixed(0)} kN<br>Weight ${f.weight} kg<br>Cl max ${f.clMax}<br>Stall ${f.stallAoA}°`;
}
refreshStats();
// mission cards in overlay
document.querySelectorAll('.mCard[data-m]').forEach(c=>{
c.addEventListener('click',()=>{
document.querySelectorAll('.mCard[data-m]').forEach(x=>x.classList.remove('sel')); c.classList.add('sel'); mission=c.dataset.m;
document.getElementById('modeBadge').textContent='● '+mission.toUpperCase(); syncModeBtns();
});
});
function syncModeBtns(){
document.querySelectorAll('#topnav .pill').forEach(b=>b.classList.remove('active'));
if(mission==='free') btnFree.classList.add('active'); else if(mission==='landing') btnLanding.classList.add('active'); else btnDogfight.classList.add('active');
const info=document.getElementById('missionInfo');
if(mission==='free') info.innerHTML=`<b style="color:var(--cyan)">FREE FLIGHT</b><br>Explore ridges, valleys & coastline. Try a stall: pull up, cut throttle, watch AoA & buffet. Use terrain for energy practice.`;
else if(mission==='landing') info.innerHTML=`<b style="color:#ffd000">LANDING TRIAL — RWY 27L</b><br>Downwind at 180 kt. Reduce to 145 kt on final, −3° glide, flare at 20 ft. Gear down, keep AoA < 10°.<br><span style="color:#5cff9a">+200 pts perfect touchdown (vs −10 fpm, on centreline).</span>`;
else info.innerHTML=`<b style="color:#ff6a5a">DOGFIGHT — HIGH-G PURSUIT</b><br>Enemy ahead. Lead the pipper (green box). Machine guns (SPACE) — 1200 rpm. Manage heat & energy: tight turns bleed speed fast.`;
if(mission==='dogfight' && !enemy) spawnEnemy(); if(mission!=='dogfight' && enemyGroup){ scene.remove(enemyGroup); enemyGroup=null; enemy=null; }
}
// top nav
const btnFree=document.getElementById('btnFree'), btnLanding=document.getElementById('btnLanding'), btnDogfight=document.getElementById('btnDogfight');
btnFree.onclick=()=>{ mission='free'; syncModeBtns(); };
btnLanding.onclick=()=>{ mission='landing'; syncModeBtns(); };
btnDogfight.onclick=()=>{ mission='dogfight'; syncModeBtns(); };
document.getElementById('btnReset').onclick=resetFlight;
document.getElementById('btnPause').onclick=()=>{ paused=!paused; document.getElementById('btnPause').textContent= paused?'▶ RESUME (P)':'⏸ PAUSE (P)'; };
document.getElementById('btnFly').onclick=()=>{ document.getElementById('overlay').style.display='none'; paused=false; };
document.getElementById('btnHow').onclick=()=>{
const t=document.getElementById('howText'); t.style.display=t.style.display==='none'?'block':'none';
};
function resetFlight(){
state.pos.set(0, 360, 2100); state.vel.set(74,0,-38); state.euler.set(0,Math.PI,0); state.quat.setFromEuler(state.euler); state.angVel.set(0,0,0); state.throttle=0.62; state.heat=0;
joyRoll=0; joyPitch=0;
if(mission==='dogfight') spawnEnemy();
}
// ---------- AUDIO (WebAudio linked to physics) ----------
let audioCtx, engGain, windGain, osc, windNode, audioOn=true;
document.getElementById('audioToggle').addEventListener('change',e=>audioOn=e.target.checked);
function ensureAudio(){
if(audioCtx) return;
audioCtx=new (window.AudioContext||window.webkitAudioContext)();
// engine: saw + lowpass whose freq tracks throttle/rho
osc=audioCtx.createOscillator(); osc.type='sawtooth'; osc.frequency.value=62;
const filt=audioCtx.createBiquadFilter(); filt.type='lowpass'; filt.frequency.value=900; filt.Q.value=1.2;
engGain=audioCtx.createGain(); engGain.gain.value=0;
osc.connect(filt).connect(engGain).connect(audioCtx.destination); osc.start();
// wind: filtered noise
const bufferSize=audioCtx.sampleRate*1; const buf=audioCtx.createBuffer(1,audioCtx.sampleRate*1,audioCtx.sampleRate);
const ch=buf.getChannelData(0); for(let i=0;i<ch.length;i++) ch[i]=Math.random()*2-1;
const src=audioCtx.createBufferSource(); src.buffer=buf; src.loop=true;
const bp=audioCtx.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=1200; bp.Q.value=0.6;
windGain=audioCtx.createGain(); windGain.gain.value=0;
windNode=bp; src.connect(bp).connect(windGain).connect(audioCtx.destination); src.start();
// expose filter for update
engGain._filt=filt;
windGain._bp=bp;
}
addEventListener('pointerdown',()=>{ ensureAudio(); if(audioCtx && audioCtx.state==='suspended') audioCtx.resume(); },{once:true});
// ---------- PHYSICS LOOP ----------
const G=9.81, RHO0=1.225, SCALE_H=8500;
let score=0, landed=false;
function physics(dt){
if(paused) return;
const spec=FLEET[selectedIdx];
time+=dt;
// inputs -> target angular rates
const pitchIn = (keys.KeyW||keys.ArrowUp?1:0) - (keys.KeyS||keys.ArrowDown?1:0) + joyPitch;
const rollIn = (keys.KeyD||keys.ArrowRight?1:0) - (keys.KeyA||keys.ArrowLeft?1:0) + joyRoll;
const yawIn = (keys.KeyE?1:0) - (keys.KeyQ?1:0);
if(keys.ShiftLeft||keys.ShiftRight) state.throttle=THREE.MathUtils.clamp(state.throttle+dt*0.55,0,1);
if(keys.ControlLeft||keys.ControlRight) state.throttle=THREE.MathUtils.clamp(state.throttle-dt*0.55,0,1);
if(keys.KeyB) brake=true; else brake=false;
// air density at altitude (m)
const altM = state.pos.y; // world Y is altitude (we use ~1 unit = 1m scaled)
const rho = RHO0 * Math.exp(-Math.max(0,altM)/SCALE_H);
state.rho=rho;
// IAS and CAS
const spd = state.vel.length(); // m/s
const iasKts = spd * 1.94384 * Math.sqrt(rho/RHO0);
state.ias = iasKts;
// AoA approx: angle between velocity and forward vector
const fwd=new THREE.Vector3(0,0,-1).applyQuaternion(state.quat);
const velN = spd>1? state.vel.clone().normalize(): fwd.clone();
let aoaDeg = THREE.MathUtils.radToDeg(Math.acos(THREE.MathUtils.clamp(fwd.dot(velN),-1,1)));
// sign by pitch component
const up=new THREE.Vector3(0,1,0).applyQuaternion(state.quat);
if(velN.dot(up) < fwd.dot(up)) aoaDeg*=-1; // crude
aoaDeg = THREE.MathUtils.clamp(aoaDeg, -8, 26);
state.aoa = aoaDeg;
const absAoA=Math.abs(aoaDeg);
// lift coefficient
let Cl = spec.clAlpha * THREE.MathUtils.degToRad(aoaDeg);
// stall curve: drop after stallAoA
if(absAoA > spec.stallAoA){
const excess=absAoA-spec.stallAoA; Cl = spec.clMax * Math.cos(THREE.MathUtils.degToRad(excess*6)) * Math.sign(Cl);
Cl*= Math.max(0.55, 1 - excess*0.06);
} else {
Cl = THREE.MathUtils.clamp(Cl, -spec.clMax, spec.clMax);
}
const q = 0.5 * rho * spd*spd; // dynamic pressure
const liftMag = q * spec.wingArea * Cl;
// drag coefficient
const Cd = spec.cd0 + spec.k * Cl*Cl + (brake?0.08:0) + (gearDown?0.018:0);
const dragMag = q * spec.wingArea * Cd;
const thrustMag = spec.maxThrust * (0.12 + 0.88*state.throttle) * (0.55 + 0.45* rho/RHO0); // thrust falls slightly with altitude
// forces
const liftDir = new THREE.Vector3().crossVectors(velN, new THREE.Vector3(1,0,0).applyQuaternion(state.quat)).normalize();
// better lift dir: perpendicular to velocity in plane of wings (approx up)
const wingUp = up.clone();
// project wingUp onto plane perpendicular to vel
const liftDir2 = wingUp.clone().sub(velN.clone().multiplyScalar(wingUp.dot(velN))).normalize();
const lift = liftDir2.multiplyScalar(liftMag);
const drag = velN.clone().multiplyScalar(-dragMag);
const thrust = fwd.clone().multiplyScalar(thrustMag);
const weight = new THREE.Vector3(0, -spec.weight * G, 0);
// sum -> accel
const mass = spec.weight;
const accel = new THREE.Vector3().add(lift).add(drag).add(thrust).add(weight).divideScalar(mass);
// integrate velocity with inertia (momentum carry-over already via integration)
state.vel.add(accel.clone().multiplyScalar(dt));
// ground collision / friction
const gh=getHeightAt(state.pos.x, state.pos.z);
const agl = state.pos.y - gh;
state.agl = agl;
if(agl < 1.6){
// on ground
state.pos.y = gh + 1.6;
if(state.vel.y < 0) state.vel.y *= -0.12; // bounce damped
state.vel.y = Math.max(state.vel.y, -0.35);
// friction
const gndFric = gearDown? 0.92 : 0.98;
state.vel.x *= gndFric** (dt*60);
state.vel.z *= gndFric** (dt*60);
// check landing scoring
if(mission==='landing' && !landed){
const onRunway = Math.abs(state.pos.z-900)< rwWid/2+8 && Math.abs(state.pos.x)< rwLen/2;
const vsFpm = state.vs;
const kts = iasKts;
if(onRunway && Math.abs(vsFpm)< 420 && kts>78 && kts<175 && Math.abs(state.aoa)<12){
score+= Math.max(10, Math.round(320 - Math.abs(vsFpm)*0.45 - Math.abs(state.pos.z-900)*2));
landed=true; flashBadge(`GREAT LANDING +${score} pts`, 'good');
setTimeout(()=>landed=false, 3200);
} else if(onRunway && agl<2){
flashBadge('HARD LANDING — CHECK VS & AoA','warn');
}
}
}
// integrate position
state.pos.add(state.vel.clone().multiplyScalar(dt));
// angular dynamics: rate-limited with inertia & damping
const maxRoll = spec.rollRate, maxPitch = spec.pitchRate;
const targetRollRate = rollIn * maxRoll * (0.35 + 0.65* Math.min(1, spd/38));
const targetPitchRate= pitchIn * maxPitch * (0.45 + 0.55* Math.min(1, spd/42));
const targetYawRate = yawIn * 0.55 + rollIn*0.22* Math.min(1, spd/55); // adverse yaw
// angular acceleration with inertia
const angAccel=new THREE.Vector3(
(targetPitchRate - state.angVel.x)* 1.9,
(targetYawRate - state.angVel.y)* 1.4,
(targetRollRate - state.angVel.z)* 2.2
);
// stall reduces control authority & adds buffet
const stallFactor = absAoA > spec.stallAoA ? 0.28 : 1;
angAccel.multiplyScalar(stallFactor);
state.angVel.add(angAccel.multiplyScalar(dt));
// damping
state.angVel.multiplyScalar(0.985);
// buffet at high AoA
if(absAoA > spec.stallAoA -2){
state.angVel.x += (Math.random()-0.5)*0.6*dt*6;
state.angVel.z += (Math.random()-0.5)*0.9*dt*6;
}
// apply to euler
state.euler.x += state.angVel.x * dt;
state.euler.y += state.angVel.y * dt;
state.euler.z += state.angVel.z * dt;
// auto roll leveling slight
state.euler.z *= 0.999;
state.euler.x = THREE.MathUtils.clamp(state.euler.x, -0.55, 0.62);
state.quat.setFromEuler(state.euler);
// vertical speed
state.vs = state.vel.y * 196.85; // m/s to ft/min
state.alt = state.pos.y * 3.28084;
state.hdg = (THREE.MathUtils.radToDeg(state.euler.y) + 360) %360;
// heat
state.heat = THREE.MathUtils.clamp(state.heat + (state.throttle>0.88? dt*18 : -dt*22), 0,100);
// world bounds wrap
if(state.pos.x < -T_SIZE/2) state.pos.x+=T_SIZE-300;
if(state.pos.x > T_SIZE/2) state.pos.x-=T_SIZE-300;
if(state.pos.z < -T_SIZE/2) state.pos.z+=T_SIZE-300;
if(state.pos.z > T_SIZE/2) state.pos.z-=T_SIZE-300;
// enemy AI
if(mission==='dogfight' && enemy){
// simple pursuit + evasive
const toPlayer = state.pos.clone().sub(enemy.pos);
const dist=toPlayer.length();
const desiredVel = toPlayer.normalize().multiplyScalar(82 + Math.sin(time*0.7)*16);
enemy.vel.lerp(desiredVel, dt*0.55);
// altitude wander
enemy.vel.y += Math.sin(time*1.1 + enemy.pos.x*0.002)* 6 * dt;
enemy.pos.add(enemy.vel.clone().multiplyScalar(dt));
// keep above terrain
const eh=getHeightAt(enemy.pos.x, enemy.pos.z)+ 90;
if(enemy.pos.y < eh) { enemy.pos.y=eh; enemy.vel.y=Math.abs(enemy.vel.y)+6; }
enemyGroup.position.copy(enemy.pos);
// orient to velocity
const look=new THREE.Vector3().copy(enemy.vel).normalize();
if(look.length()>0.01) enemyGroup.quaternion.setFromUnitVectors(new THREE.Vector3(0,0,-1), look);
// collision with bullets
for(let i=bullets.length-1;i>=0;i--){
if(bullets[i].position.distanceTo(enemy.pos)< 18){
scene.remove(bullets[i]); bullets.splice(i,1);
enemy.hp-= 26; flashBadge('HIT!','good');
if(enemy.hp<=0){ score+=150; flashBadge('SPLASH! +150','good'); spawnEnemy(); }
}
}
// enemy firing? just visual
}
// bullets update
for(let i=bullets.length-1;i>=0;i--){
const b=bullets[i]; b.userData.v.add(new THREE.Vector3(0, -1.2*dt,0)); b.position.add(b.userData.v.clone().multiplyScalar(dt));
b.userData.life-=dt; if(b.userData.life<=0 || b.position.y < getHeightAt(b.position.x,b.position.z)+2){ scene.remove(b); bullets.splice(i,1); }
}
}
function fireBullet(){
if(paused) return;
const fwd=new THREE.Vector3(0,0,-1).applyQuaternion(state.quat);
const p=new THREE.Mesh(bulletGeo, bulletMat);
p.position.copy(state.pos).add(fwd.clone().multiplyScalar(12)).add(new THREE.Vector3(0,0.4,0));
p.userData={ v: fwd.clone().multiplyScalar(420).add(state.vel), life: 2.2 };
scene.add(p); bullets.push(p);
// recoil & heat
state.vel.add(fwd.clone().multiplyScalar(-0.6));
// audio blip
if(audioCtx && audioOn){
const o=audioCtx.createOscillator(); const g=audioCtx.createGain();
o.frequency.value=880; o.type='square'; g.gain.value=0.12; o.connect(g).connect(audioCtx.destination);
o.start(); g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime+0.09); o.stop(audioCtx.currentTime+0.1);
}
}
let badgeTimeout;
function flashBadge(txt, cls){
const b=document.getElementById('stallBadge'); // reuse
b.textContent=txt; b.className='badge '+(cls||'warn'); b.style.display='inline-block'; b.style.background= cls==='good'?'rgba(12,60,28,.7)':'rgba(60,12,8,.6)';
clearTimeout(badgeTimeout); badgeTimeout=setTimeout(()=>b.style.display='none', 1800);
}
// ---------- CAMERA ----------
let camMode=0; // 0 chase, 1 cockpit
canvas.addEventListener('dblclick',()=>{ camMode= camMode===0?1:0; });
function updateCamera(dt){
const fwd=new THREE.Vector3(0,0,-1).applyQuaternion(state.quat);
const up=new THREE.Vector3(0,1,0).applyQuaternion(state.quat);
if(camMode===0){
const chaseOffset = fwd.clone().multiplyScalar(-42).add(up.clone().multiplyScalar(11));
const desired = state.pos.clone().add(chaseOffset);
camera.position.lerp(desired, 1 - Math.pow(0.001, dt));
camera.lookAt(state.pos.clone().add(fwd.clone().multiplyScalar(220)));
} else {
camera.position.copy(state.pos).add(up.clone().multiplyScalar(1.1)).add(fwd.clone().multiplyScalar(3.2));
camera.lookAt(state.pos.clone().add(fwd.clone().multiplyScalar(500)));
}
}
// ---------- HUD UPDATE ----------
function updateHUD(){
document.getElementById('spdVal').textContent = Math.round(state.ias);
document.getElementById('altVal').textContent = Math.round(state.alt).toLocaleString();
document.getElementById('aglVal').textContent = Math.max(0,Math.round(state.agl*3.28));
document.getElementById('thrVal').textContent = Math.round(state.throttle*100)+'%';
document.getElementById('rpmVal').textContent = Math.round(2100 + state.throttle*7200).toLocaleString()+' RPM';
document.getElementById('vsVal').textContent = (state.vs>0?'+':'')+Math.round(state.vs);
document.getElementById('hdgVal').textContent = Math.round(state.hdg)+'°';
document.getElementById('thrFill').style.width = (state.throttle*100)+'%';
document.getElementById('spdFill').style.width = THREE.MathUtils.clamp((state.ias/260)*100,4,100)+'%';
document.getElementById('altFill').style.width = THREE.MathUtils.clamp((state.alt/9000)*100,2,100)+'%';
document.getElementById('rhoVal').textContent = state.rho.toFixed(2)+' kg/m³';
const vsiTop = THREE.MathUtils.clamp(50 - (state.vs/2200)*44, 6,94);
document.getElementById('vsiNeedle').style.top = vsiTop+'%';
document.getElementById('aoaBadge').textContent = 'AoA '+state.aoa.toFixed(1)+'°';
document.getElementById('gearBadge').textContent = gearDown?'GEAR DOWN':'GEAR UP';
document.getElementById('gearBadge').style.borderColor = gearDown?'rgba(0,229,255,.35)':'rgba(255,180,0,.5)';
document.getElementById('gearBadge').style.color = gearDown?'#8ec9ff':'#ffcf6a';
const f=FLEET[selectedIdx];
const isStall = Math.abs(state.aoa) > f.stallAoA -0.6 && state.ias < 155;
document.getElementById('stallBadge').style.display = isStall?'inline-block':'none';
document.getElementById('stallVal').textContent = isStall? 'STALL!': (Math.abs(state.aoa)>10?'ON EDGE':'SAFE');
document.getElementById('stallVal').style.color = isStall?'#ff5a4a': (Math.abs(state.aoa)>10?'#ffd24a':'#5cff9a');
document.getElementById('heatBadge').textContent = state.heat>82?'HEAT HIGH': state.heat>58?'HEAT WARM':'HEAT NOMINAL';
document.getElementById('heatBadge').style.color = state.heat>82?'#ff5a4a': state.heat>58?'#ffb84a':'#8ec9ff';
document.getElementById('heatBadge').style.borderColor = state.heat>82?'rgba(255,60,60,.6)':'rgba(0,229,255,.22)';
// attitude
document.getElementById('attHorizon').style.transform=`translateY(${-state.euler.x*32}px) rotate(${ -THREE.MathUtils.radToDeg(state.euler.z)}deg)`;
document.getElementById('attRoll').style.transform=`translate(-50%,-50%) rotate(${ -THREE.MathUtils.radToDeg(state.euler.z)}deg)`;
// runway distance
const distToThr = Math.hypot(state.pos.x - (-rwLen/2), state.pos.z-900);
const dkm=(distToThr/1000).toFixed(1);
document.getElementById('runwayText').textContent = mission==='landing'? `RWY 27L • ${dkm} km • HDG ${Math.round(state.hdg)}°` : `HDG ${Math.round(state.hdg)}° • IAS ${Math.round(state.ias)} kt`;
// aircraft glow & gear visual
const glow=planeMesh.getObjectByName('glow'); if(glow) glow.material.opacity = 0.18 + state.throttle*0.62;
const gear=planeMesh.getObjectByName('gear'); if(gear) gear.visible = gearDown || state.agl<6;
// audio link
if(audioCtx && audioOn){
const base = 55 + state.throttle*210 + state.ias*0.22;
try{ osc.frequency.setTargetAtTime(base, audioCtx.currentTime, 0.08); engGain.gain.setTargetAtTime(0.06 + state.throttle*0.18, audioCtx.currentTime, 0.1);
engGain._filt.frequency.setTargetAtTime(500 + state.throttle*1400 + state.ias*6, audioCtx.currentTime, 0.12);
windGain.gain.setTargetAtTime(Math.min(0.22, Math.max(0, (state.ias-70)/420)*0.22), audioCtx.currentTime, 0.18);
windGain._bp.frequency.setTargetAtTime(700 + state.ias*3.2, audioCtx.currentTime, 0.2);
}catch{}
}
}
// ---------- RENDER LOOP ----------
function onResize(){
const w=innerWidth, h=innerHeight;
renderer.setSize(w,h,false);
camera.aspect=w/h; camera.updateProjectionMatrix();
}
addEventListener('resize', onResize); onResize();
syncModeBtns();
let last=performance.now();
function animate(){
requestAnimationFrame(animate);
const now=performance.now(); let dt=(now-last)/1000; last=now; dt=Math.min(dt,0.032);
physics(dt);
// sync mesh
planeMesh.position.copy(state.pos);
planeMesh.quaternion.copy(state.quat);
updateCamera(dt);
updateHUD();
renderer.render(scene,camera);
}
animate();
// keyboard hints for gear/brake
addEventListener('keydown',e=>{
if(e.code==='KeyG'){ ensureAudio(); }
});
// initial camera
camera.position.set(0, 560, 2400); camera.lookAt(0,120,0);
</script>
</body>
</html>