Flight Simulator
Grok 4.7 · 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 88,933 bytes · SHA-256 568ca2145216
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APEX AERO — Flight Dynamics Simulator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
:root {
--bg: #07090d;
--amber: #e7a44a;
--amber-2: #ffd59a;
--cyan: #9dffc8;
--danger: #ff5a5a;
--ink: #e8eef6;
--muted: #93a0b5;
--panel: rgba(6, 10, 16, 0.78);
--line: rgba(231, 164, 74, 0.35);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #05070a; color: var(--ink); font-family: "Barlow Condensed", "Segoe UI", sans-serif; }
#view { position: fixed; inset: 0; display: block; width: 100%; height: 100%; }
#hud {
position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4;
}
#vignette {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
box-shadow: inset 0 0 180px 40px rgba(0,0,0,0.45);
transition: box-shadow 0.08s linear;
}
#boot {
position: fixed; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse at 50% 30%, #1a2433, #07090d 70%);
letter-spacing: 0.42em; font-size: 14px; color: var(--amber);
}
.overlay {
position: fixed; z-index: 10; pointer-events: none;
}
.overlay > * { pointer-events: auto; }
#topbar {
top: 0; left: 0; right: 0; display: flex; justify-content: space-between; align-items: flex-end;
padding: 18px 28px 10px; background: linear-gradient(to bottom, rgba(0,0,0,0.72), transparent);
}
.brand { display: flex; flex-direction: column; gap: 2px; }
.brand h1 {
font-weight: 700; font-size: 34px; letter-spacing: 0.34em; line-height: 1;
background: linear-gradient(90deg, #fff, var(--amber-2) 40%, var(--amber));
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.brand small { color: var(--muted); letter-spacing: 0.28em; font-size: 12px; font-weight: 500; }
.brand-meta { text-align: right; color: var(--muted); letter-spacing: 0.18em; font-size: 13px; font-family: "Share Tech Mono", monospace; }
#sheet {
left: 0; right: 0; bottom: 0; padding: 14px 22px 18px;
background: linear-gradient(to top, rgba(4,6,10,0.92) 40%, rgba(4,6,10,0.55) 78%, transparent);
}
.sheet-inner { display: grid; grid-template-columns: 1.5fr 0.85fr 220px; gap: 16px; align-items: stretch; max-width: 1280px; margin: 0 auto; }
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.card {
background: var(--panel); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
padding: 12px 12px 10px; cursor: pointer; backdrop-filter: blur(8px);
transition: border 0.15s, transform 0.15s, background 0.15s;
min-height: 168px;
}
.card:hover { transform: translateY(-2px); border-color: rgba(231,164,74,0.45); }
.card.sel { border-color: var(--amber); box-shadow: 0 0 0 1px rgba(231,164,74,0.35), 0 10px 30px rgba(0,0,0,0.35); background: rgba(28, 20, 8, 0.72); }
.card .role { font-family: "Share Tech Mono", monospace; font-size: 10px; letter-spacing: 0.16em; color: var(--amber); }
.card h3 { font-size: 22px; letter-spacing: 0.08em; margin: 2px 0 6px; }
.stats { display: grid; gap: 4px; }
.stat { display: grid; grid-template-columns: 62px 1fr 58px; gap: 6px; align-items: center; font-size: 12px; color: var(--muted); letter-spacing: 0.06em; }
.bar { height: 4px; background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; }
.bar span { display: block; height: 100%; background: linear-gradient(90deg, #8a6a32, var(--amber)); border-radius: 4px; }
.stat b { font-weight: 500; color: var(--ink); font-family: "Share Tech Mono", monospace; font-size: 11px; text-align: right; white-space: nowrap; }
.modes { display: flex; flex-direction: column; gap: 8px; }
.mode {
text-align: left; background: var(--panel); color: var(--ink); border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; padding: 10px 12px; cursor: pointer; backdrop-filter: blur(8px);
}
.mode.sel { border-color: var(--cyan); box-shadow: 0 0 0 1px rgba(157,255,200,0.25); }
.mode strong { display: block; letter-spacing: 0.14em; font-size: 14px; }
.mode span { display: block; color: var(--muted); font-size: 13px; line-height: 1.25; margin-top: 2px; }
.launch-col { display: flex; flex-direction: column; gap: 8px; justify-content: stretch; }
button.launch {
flex: 1; border: 0; border-radius: 10px; cursor: pointer;
background: linear-gradient(180deg, #f0c27a, #c4842d);
color: #1a1004; font-family: inherit; font-weight: 700; letter-spacing: 0.22em; font-size: 18px;
box-shadow: 0 8px 24px rgba(196, 132, 45, 0.35);
}
button.launch:hover { filter: brightness(1.06); }
.keys {
font-family: "Share Tech Mono", monospace; font-size: 10px; color: var(--muted); line-height: 1.45;
background: rgba(0,0,0,0.35); border-radius: 8px; padding: 8px; letter-spacing: 0.02em;
}
.keys b { color: var(--amber-2); font-weight: 500; }
#pause, #debrief {
position: fixed; inset: 0; z-index: 12; display: none; align-items: center; justify-content: center;
background: rgba(4,6,10,0.55); backdrop-filter: blur(6px);
}
.modal {
width: min(520px, calc(100% - 32px)); background: rgba(8,12,18,0.92);
border: 1px solid var(--line); border-radius: 14px; padding: 26px 26px 20px;
box-shadow: 0 30px 80px rgba(0,0,0,0.5);
}
.modal h2 { letter-spacing: 0.2em; font-size: 28px; }
.modal p { color: var(--muted); margin: 8px 0 16px; line-height: 1.4; }
.row { display: flex; gap: 8px; }
.row button, .modal button {
flex: 1; padding: 12px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.04); color: var(--ink); font-family: inherit; letter-spacing: 0.14em;
font-size: 15px; cursor: pointer;
}
.row button.primary, .modal button.primary { background: linear-gradient(180deg, #f0c27a, #c4842d); color: #1a1004; border: 0; font-weight: 700; }
.grades { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 14px; }
.grades div { background: rgba(255,255,255,0.03); border-radius: 8px; padding: 8px 10px; }
.grades label { display: block; color: var(--muted); font-size: 11px; letter-spacing: 0.14em; }
.grades strong { font-family: "Share Tech Mono", monospace; font-size: 16px; color: var(--cyan); }
#warn-banner {
position: fixed; top: 18%; left: 50%; transform: translateX(-50%); z-index: 6; pointer-events: none;
font-family: "Share Tech Mono", monospace; letter-spacing: 0.28em; font-size: 22px; color: var(--danger);
text-shadow: 0 0 12px rgba(255,60,60,0.8); opacity: 0;
}
#warn-banner.on { opacity: 1; animation: blink 0.45s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0.25; } }
#help {
position: fixed; top: 16px; right: 16px; z-index: 7; display: none;
background: rgba(6,10,16,0.8); border: 1px solid rgba(157,255,200,0.25); color: var(--cyan);
font-family: "Share Tech Mono", monospace; font-size: 11px; padding: 8px 10px; border-radius: 8px;
letter-spacing: 0.06em; line-height: 1.5;
}
@media (max-width: 980px) {
.sheet-inner { grid-template-columns: 1fr; max-height: 52vh; overflow: auto; }
.brand h1 { font-size: 26px; }
}
</style>
</head>
<body>
<canvas id="view"></canvas>
<canvas id="hud"></canvas>
<div id="vignette"></div>
<div id="warn-banner">STALL</div>
<div id="help">MOUSE stick · W/S throttle · A/D rudder · Q/E roll · Arrows pitch/roll · G gear · F flaps · B brake · [ ] trim · C cam · M mute · L SAS · Space guns · R restart · Esc pause · H help</div>
<div id="boot">INITIALIZING FLIGHT MODEL</div>
<div class="overlay" id="topbar">
<div class="brand">
<h1>APEX AERO</h1>
<small>NEWTONIAN FLIGHT DYNAMICS LABORATORY</small>
</div>
<div class="brand-meta">SEA LEVEL ρ 1.225 · g 9.806 · ISA ATMOSPHERE<br>LIFT · INDUCED DRAG · INERTIA · DENSITY ALTITUDE</div>
</div>
<div class="overlay" id="sheet">
<div class="sheet-inner">
<div class="cards" id="cards"></div>
<div class="modes" id="modes"></div>
<div class="launch-col">
<button class="launch" id="launch">LAUNCH</button>
<div class="keys">
<b>W/S</b> throttle <b>MOUSE</b> stick<br>
<b>A/D</b> rudder <b>Q/E</b> roll<br>
<b>G</b> gear <b>F</b> flaps <b>B</b> speedbrake<br>
<b>[ ]</b> trim <b>C</b> camera <b>SPACE</b> guns<br>
<b>L</b> stability assist <b>H</b> help overlay
</div>
</div>
</div>
</div>
<div id="pause">
<div class="modal">
<h2>PAUSED</h2>
<p>Physics frozen. Air-mass, inertia and control state are held.</p>
<div class="row">
<button class="primary" id="resume">RESUME</button>
<button id="abort">ABORT TO RAMP</button>
</div>
</div>
</div>
<div id="debrief">
<div class="modal">
<h2 id="db-title">DEBRIEF</h2>
<p id="db-copy"></p>
<div class="grades" id="db-grades"></div>
<div class="row">
<button class="primary" id="retry">FLY AGAIN</button>
<button id="tomenu">RETURN TO RAMP</button>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
const KT = 1.943844;
const FT = 3.28084;
const AIRCRAFT = {
falcon: {
id: "falcon", name: "APEX FALCON", role: "AIR SUPERIORITY",
mass: 9400, thrust: 108000, abBoost: 1.42, altPower: 0.28,
wingArea: 31, span: 10.6, clAlpha: 4.9, clMax: 1.45, cd0: 0.019, oswald: 0.8,
stallAoA: 16.5 * DEG, cyBeta: -0.85,
inertia: { x: 32000, y: 46000, z: 14000 },
ctrl: { x: 72000, y: 28000, z: 90000 },
stab: { x: 90000, y: 52000, z: 18000 },
damp: { x: 22000, y: 18000, z: 14000 },
gearH: 2.15, vne: 400, maxG: 9.0, minG: -3.2,
afterburner: true, kind: "low-bypass turbofan",
colors: { primary: 0x8fa4b8, accent: 0xc9843a, dark: 0x2a3340, glass: 0x9fd0e6 }
},
hauler: {
id: "hauler", name: "STRATUS HAULER", role: "HEAVY LIFT TRANSPORT",
mass: 52000, thrust: 175000, abBoost: 1, altPower: 0.62,
wingArea: 152, span: 38, clAlpha: 5.5, clMax: 1.85, cd0: 0.03, oswald: 0.72,
stallAoA: 15 * DEG, cyBeta: -1.05,
inertia: { x: 620000, y: 780000, z: 240000 },
ctrl: { x: 210000, y: 140000, z: 160000 },
stab: { x: 480000, y: 360000, z: 90000 },
damp: { x: 180000, y: 140000, z: 90000 },
gearH: 4.6, vne: 165, maxG: 2.6, minG: -1.0,
afterburner: false, kind: "turboprop",
colors: { primary: 0xb7a06a, accent: 0xd8d2c4, dark: 0x4a4034, glass: 0xc5ddd4 }
},
dart: {
id: "dart", name: "VIPER DART", role: "HIGH-ALT INTERCEPTOR",
mass: 6800, thrust: 98000, abBoost: 1.62, altPower: 0.18,
wingArea: 20, span: 8.2, clAlpha: 3.8, clMax: 1.02, cd0: 0.013, oswald: 0.78,
stallAoA: 13 * DEG, cyBeta: -0.55,
inertia: { x: 14000, y: 18000, z: 5200 },
ctrl: { x: 48000, y: 16000, z: 62000 },
stab: { x: 28000, y: 18000, z: 6000 },
damp: { x: 7000, y: 6000, z: 3200 },
gearH: 1.7, vne: 340, maxG: 8.2, minG: -2.6,
afterburner: true, kind: "turbojet + afterburner",
colors: { primary: 0x2c3138, accent: 0xc43a3a, dark: 0x15171b, glass: 0xffb0a0 }
}
};
const SCENARIOS = {
pattern: {
id: "pattern", title: "PRECISION PATTERN",
blurb: "Rotate, climb, fly the circuit, and plant it on the numbers. Weight, flaps and density altitude all count."
},
dogfight: {
id: "dogfight", title: "BANDIT INTERCEPT",
blurb: "Three bandits. Manage energy, angle of attack and G. The airframe will depart if you ask too much."
}
};
let selectedCraft = "falcon";
let selectedScenario = "pattern";
function stallSpeed(cfg, flaps = 0) {
const cl = cfg.clMax * (1 + 0.42 * flaps);
return Math.sqrt((2 * cfg.mass * 9.806) / (1.225 * cfg.wingArea * cl));
}
function tw(cfg) { return cfg.thrust / (cfg.mass * 9.806); }
function wingLoad(cfg) { return cfg.mass / cfg.wingArea; }
function renderMenu() {
const cards = $("cards");
cards.innerHTML = "";
for (const id of ["falcon", "hauler", "dart"]) {
const c = AIRCRAFT[id];
const el = document.createElement("div");
el.className = "card" + (id === selectedCraft ? " sel" : "");
el.innerHTML = `
<div class="role">${c.role}</div>
<h3>${c.name}</h3>
<div class="stats">
${stat("T/W", tw(c), tw(c).toFixed(2), 2.0)}
${stat("W/S", wingLoad(c) / 400, Math.round(wingLoad(c)) + "", 1)}
${stat("STALL", stallSpeed(c) / 90, Math.round(stallSpeed(c) * KT) + " kt", 1)}
${stat("VNE", c.vne / 520, Math.round(c.vne * KT) + " kt", 1)}
${stat("AGILITY", (c.ctrl.z / c.inertia.z) / 12, (c.ctrl.z / c.inertia.z).toFixed(1), 1)}
</div>`;
el.onclick = () => { selectedCraft = id; renderMenu(); onSelectCraft(); };
cards.appendChild(el);
}
const modes = $("modes");
modes.innerHTML = "";
for (const id of ["pattern", "dogfight"]) {
const s = SCENARIOS[id];
const b = document.createElement("button");
b.className = "mode" + (id === selectedScenario ? " sel" : "");
b.innerHTML = `<strong>${s.title}</strong><span>${s.blurb}</span>`;
b.onclick = () => { selectedScenario = id; renderMenu(); };
modes.appendChild(b);
}
}
function stat(label, t, text) {
return `<div class="stat"><span>${label}</span><div class="bar"><span style="width:${clamp(t, 0, 1) * 100}%"></span></div><b>${text}</b></div>`;
}
/* ---------- noise / atmosphere ---------- */
function hash2(ix, iz) {
let n = Math.imul(ix | 0, 374761393) + Math.imul(iz | 0, 668265263);
n = Math.imul(n ^ (n >>> 13), 1274126177);
return ((n ^ (n >>> 16)) >>> 0) / 4294967295;
}
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function vnoise(x, z) {
const ix = Math.floor(x), iz = Math.floor(z);
const fx = x - ix, fz = z - iz;
const u = fade(fx), v = fade(fz);
const a = hash2(ix, iz), b = hash2(ix + 1, iz), c = hash2(ix, iz + 1), d = hash2(ix + 1, iz + 1);
return a * (1 - u) * (1 - v) + b * u * (1 - v) + c * (1 - u) * v + d * u * v;
}
function fbm(x, z) {
let h = 0, a = 0.5, f = 1, s = 0;
for (let i = 0; i < 6; i++) {
h += a * vnoise(x * f, z * f);
s += a; a *= 0.5; f *= 2.07;
}
return h / s;
}
function terrainHeight(x, z) {
const wx = fbm(x * 0.00022 + 2.2, z * 0.00022 + 8.0);
const wz = fbm(x * 0.00022 + 19.0, z * 0.00022 + 3.4);
let h = (fbm(x * 0.00018 + wx * 1.4, z * 0.00018 + wz * 1.4) - 0.46) * 1650;
h += (fbm(x * 0.0009 + 4, z * 0.0009) - 0.5) * 120;
h += (fbm(x * 0.003, z * 0.003) - 0.5) * 18;
const coast = fbm(x * 0.00008 + 1, z * 0.00008) ;
h -= (coast - 0.35) * 220;
h -= 30;
// broad airfield bowl — force the field down so ridges cannot swallow the strip
const dx = x / 1400;
const dz = (z - 700) / 2200;
const bowl = dx * dx + dz * dz;
if (bowl < 1.15) {
const sm = clamp(1 - bowl, 0, 1);
const w = sm * sm * (3 - 2 * sm);
const field = 1.05 + bowl * 26;
h = h * (1 - w) + field * w;
}
const ax = Math.abs(x);
if (ax < 80 && z > -80 && z < 1750) {
const edge = clamp((ax - 36) / 44, 0, 1);
let zend = 1;
if (z < 40) zend = clamp((z + 80) / 120, 0, 1);
else if (z > 1600) zend = clamp((1750 - z) / 150, 0, 1);
const f = (1 - edge) * zend;
const s = f * f * (3 - 2 * f);
h = lerp(h, 1.08, s);
}
return h;
}
function terrainNormal(x, z) {
const e = 6;
const hL = terrainHeight(x - e, z), hR = terrainHeight(x + e, z);
const hD = terrainHeight(x, z - e), hU = terrainHeight(x, z + e);
const n = new THREE.Vector3(hL - hR, 2 * e, hD - hU);
return n.normalize();
}
function airDensity(y) { return 1.225 * Math.exp(-Math.max(0, y) / 8500); }
function isaTemp(y) { return 288.15 - 0.0065 * clamp(y, 0, 11000); }
function speedOfSound(y) { return Math.sqrt(1.4 * 287.05 * isaTemp(y)); }
function windAt(y) {
const m = 2.5 + Math.max(0, y) * 0.0035;
return new THREE.Vector3(m, 0, m * 0.22);
}
/* ---------- audio (procedural, physics-linked) ---------- */
const audio = {
ctx: null, master: null, ready: false, muted: false,
engGain: null, engFilter: null, whine: null, whineGain: null,
windGain: null, windFilter: null, stallOsc: null, stallGain: null,
lfo: null
};
function initAudio() {
if (audio.ready) return;
const ctx = new (window.AudioContext || window.webkitAudioContext)();
audio.ctx = ctx;
const master = ctx.createGain();
master.gain.value = 0.55;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -18;
comp.knee.value = 18;
comp.ratio.value = 3.2;
master.connect(comp).connect(ctx.destination);
audio.master = master;
const len = ctx.sampleRate * 2;
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
let b0 = 0;
for (let i = 0; i < len; i++) {
const w = Math.random() * 2 - 1;
b0 = 0.986 * b0 + 0.014 * w;
d[i] = b0 * 4.2;
}
const windBuf = ctx.createBuffer(1, len, ctx.sampleRate);
const wd = windBuf.getChannelData(0);
for (let i = 0; i < len; i++) wd[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
src.buffer = buf; src.loop = true;
const bp = ctx.createBiquadFilter();
bp.type = "bandpass"; bp.frequency.value = 280; bp.Q.value = 0.85;
const eg = ctx.createGain(); eg.gain.value = 0.0001;
src.connect(bp).connect(eg).connect(master);
src.start();
audio.engFilter = bp; audio.engGain = eg;
const osc = ctx.createOscillator();
osc.type = "sawtooth"; osc.frequency.value = 90;
const og = ctx.createGain(); og.gain.value = 0.0001;
const olp = ctx.createBiquadFilter(); olp.type = "lowpass"; olp.frequency.value = 900;
osc.connect(olp).connect(og).connect(master);
osc.start();
audio.whine = osc; audio.whineGain = og;
const wsrc = ctx.createBufferSource();
wsrc.buffer = windBuf; wsrc.loop = true;
const hp = ctx.createBiquadFilter();
hp.type = "highpass"; hp.frequency.value = 700;
const wg = ctx.createGain(); wg.gain.value = 0.0001;
wsrc.connect(hp).connect(wg).connect(master);
wsrc.start();
audio.windGain = wg; audio.windFilter = hp;
const so = ctx.createOscillator();
so.type = "square"; so.frequency.value = 310;
const sg = ctx.createGain(); sg.gain.value = 0;
so.connect(sg).connect(master);
so.start();
audio.stallOsc = so; audio.stallGain = sg;
audio.ready = true;
}
function setMuted(m) {
audio.muted = m;
if (audio.master) audio.master.gain.value = m ? 0 : 0.55;
}
function blip(freq, dur, type, vol) {
if (!audio.ready || audio.muted) return;
const ctx = audio.ctx;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = type || "sine";
o.frequency.value = freq;
g.gain.setValueAtTime(vol || 0.08, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);
o.connect(g).connect(audio.master);
o.start(); o.stop(ctx.currentTime + dur + 0.02);
}
function noiseBurst(dur, vol, freq) {
if (!audio.ready || audio.muted) return;
const ctx = audio.ctx;
const len = Math.floor(ctx.sampleRate * dur);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
const s = ctx.createBufferSource();
s.buffer = buf;
const f = ctx.createBiquadFilter();
f.type = "bandpass"; f.frequency.value = freq || 400; f.Q.value = 0.6;
const g = ctx.createGain(); g.gain.value = vol || 0.4;
s.connect(f).connect(g).connect(audio.master);
s.start();
}
function updateAudio(ship, dt) {
if (!audio.ready) return;
const ctx = audio.ctx;
const t = ctx.currentTime;
const rho = airDensity(ship.pos.y);
const rpm = ship.rpm;
const thr = ship.throttle;
const spd = ship.airspeed;
const bpF = 140 + rpm * 920 + thr * 180;
audio.engFilter.frequency.setTargetAtTime(bpF, t, 0.05);
const eg = (0.04 + rpm * 0.22 + thr * 0.08) * (0.55 + 0.45 * Math.sqrt(rho / 1.225));
audio.engGain.gain.setTargetAtTime(audio.muted ? 0 : eg, t, 0.08);
audio.whine.frequency.setTargetAtTime(70 + rpm * 340 + (ship.ab ? 80 : 0), t, 0.05);
audio.whineGain.gain.setTargetAtTime(audio.muted ? 0 : 0.012 + rpm * 0.04, t, 0.08);
const wind = clamp(spd / 280, 0, 1);
audio.windGain.gain.setTargetAtTime(audio.muted ? 0 : wind * wind * 0.28 + ship.aoaAbs * 0.05, t, 0.1);
audio.windFilter.frequency.setTargetAtTime(500 + spd * 2.2, t, 0.1);
const stalling = ship.airborne && ship.aoaAbs > ship.cfg.stallAoA * 0.82 && spd > 25;
const pulse = stalling ? (0.5 + 0.5 * Math.sin(performance.now() * 0.04)) * 0.045 : 0;
audio.stallGain.gain.setTargetAtTime(audio.muted ? 0 : pulse, t, 0.03);
if (stalling) audio.stallOsc.frequency.setTargetAtTime(240 + ship.aoaAbs * 400, t, 0.05);
}
/* ---------- three scene ---------- */
let renderer, scene, camera, sun, hemi;
const world = new THREE.Group();
let skyMesh, waterMesh, terrainGroup, treeMesh, runwayGroup;
const CHUNK = 1000, SEG = 28, GRID = 9, HALF = 4;
let chunkOrigin = { x: 99999, z: 99999 };
const tmp = {
v: new THREE.Vector3(), v2: new THREE.Vector3(), v3: new THREE.Vector3(),
q: new THREE.Quaternion(), q2: new THREE.Quaternion(),
e: new THREE.Euler(), m: new THREE.Matrix4(), c: new THREE.Color()
};
function bootThree() {
const canvas = $("view");
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.6));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
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.FogExp2(0xb7c5d0, 0.00012);
camera = new THREE.PerspectiveCamera(62, window.innerWidth / window.innerHeight, 0.15, 40000);
camera.position.set(-40, 18, -30);
hemi = new THREE.HemisphereLight(0xc5d7ee, 0x6a5a40, 0.72);
scene.add(hemi);
sun = new THREE.DirectionalLight(0xfff1d6, 1.55);
sun.position.set(-400, 2200, 1400);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 20;
sun.shadow.camera.far = 2800;
const s = 700;
sun.shadow.camera.left = -s; sun.shadow.camera.right = s;
sun.shadow.camera.top = s; sun.shadow.camera.bottom = -s;
sun.shadow.bias = -0.00025;
scene.add(sun);
scene.add(sun.target);
buildSky();
buildWater();
buildTerrain();
buildTrees();
buildAirport();
buildParticles();
scene.add(world);
window.addEventListener("resize", onResize);
}
function onResize() {
if (!renderer) return;
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
const hud = $("hud");
hud.width = Math.floor(window.innerWidth * Math.min(devicePixelRatio, 1.6));
hud.height = Math.floor(window.innerHeight * Math.min(devicePixelRatio, 1.6));
}
function buildSky() {
const geo = new THREE.SphereGeometry(18000, 32, 18);
const mat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
fog: false,
uniforms: { uSun: { value: new THREE.Vector3(0.25, 0.82, 0.45).normalize() } },
vertexShader: `
varying vec3 vDir;
void main() {
vDir = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
varying vec3 vDir;
uniform vec3 uSun;
void main() {
vec3 dir = normalize(vDir);
float h = dir.y;
vec3 zenith = vec3(0.16, 0.38, 0.78);
vec3 mid = vec3(0.45, 0.66, 0.90);
vec3 hor = vec3(0.78, 0.82, 0.84);
vec3 col = mix(hor, mid, smoothstep(0.0, 0.18, h));
col = mix(col, zenith, smoothstep(0.12, 0.7, h));
col = mix(vec3(0.55, 0.58, 0.60), col, smoothstep(-0.25, 0.02, h));
float sunD = pow(max(dot(dir, normalize(uSun)), 0.0), 180.0);
float sunG = pow(max(dot(dir, normalize(uSun)), 0.0), 8.0);
col += vec3(1.0, 0.86, 0.62) * sunD * 1.8;
col += vec3(1.0, 0.72, 0.42) * sunG * 0.18;
gl_FragColor = vec4(col, 1.0);
}`
});
skyMesh = new THREE.Mesh(geo, mat);
skyMesh.frustumCulled = false;
scene.add(skyMesh);
}
function buildWater() {
const geo = new THREE.PlaneGeometry(24000, 24000, 80, 80);
geo.rotateX(-Math.PI / 2);
const mat = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
fog: false,
uniforms: {
uTime: { value: 0 },
uShallow: { value: new THREE.Color(0x1a7ea0) },
uDeep: { value: new THREE.Color(0x06324a) }
},
vertexShader: `
uniform float uTime;
varying vec3 vW;
varying float vWave;
void main() {
vec3 p = position;
float w = sin(p.x * 0.02 + uTime * 0.8) * 0.35 + sin(p.z * 0.015 - uTime * 0.6) * 0.28;
p.y += w;
vec4 wp = modelMatrix * vec4(p, 1.0);
vW = wp.xyz; vWave = w;
gl_Position = projectionMatrix * viewMatrix * wp;
}`,
fragmentShader: `
uniform vec3 uShallow; uniform vec3 uDeep; uniform float uTime;
varying vec3 vW; varying float vWave;
void main() {
float spark = sin(vW.x * 0.11 + uTime * 1.4) * sin(vW.z * 0.09 - uTime);
vec3 col = mix(uDeep, uShallow, 0.45 + vWave * 0.4 + spark * 0.08);
col += vec3(0.65, 0.8, 0.85) * smoothstep(0.82, 1.0, spark) * 0.35;
gl_FragColor = vec4(col, 0.78);
}`
});
waterMesh = new THREE.Mesh(geo, mat);
waterMesh.position.y = 0.05;
waterMesh.renderOrder = 2;
scene.add(waterMesh);
}
function buildTerrain() {
terrainGroup = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.94, metalness: 0.02 });
for (let i = 0; i < GRID * GRID; i++) {
const geo = new THREE.PlaneGeometry(CHUNK, CHUNK, SEG, SEG);
geo.rotateX(-Math.PI / 2);
const colors = new Float32Array(geo.attributes.position.count * 3);
geo.setAttribute("color", new THREE.BufferAttribute(colors, 3));
const mesh = new THREE.Mesh(geo, mat);
mesh.receiveShadow = true;
mesh.castShadow = false;
terrainGroup.add(mesh);
}
scene.add(terrainGroup);
}
function colorFor(h, slope, n) {
const sand = tmp.c.set(0x000000);
let col;
if (h < 0.4) col = [0.45, 0.42, 0.32];
else if (h < 3.2) col = [0.72, 0.66, 0.46];
else if (h < 90) col = [0.22, 0.38, 0.18];
else if (h < 260) col = [0.18, 0.32, 0.16];
else if (h < 520) col = [0.38, 0.36, 0.33];
else col = [0.82, 0.84, 0.86];
if (slope < 0.72 && h > 4 && h < 560) {
const rock = [0.42, 0.40, 0.38];
const k = clamp((0.72 - slope) / 0.4, 0, 1);
col = col.map((v, i) => lerp(v, rock[i], k));
}
if (h < 0) col = col.map(v => v * 0.45);
const mott = 0.92 + hash2(Math.floor(n.x * 0.05), Math.floor(n.z * 0.05)) * 0.12;
return col.map(v => v * mott);
}
function rebuildChunk(mesh, cx, cz) {
mesh.position.set((cx + 0.5) * CHUNK, 0, (cz + 0.5) * CHUNK);
const pos = mesh.geometry.attributes.position;
const col = mesh.geometry.attributes.color;
for (let i = 0; i < pos.count; i++) {
const wx = mesh.position.x + pos.getX(i);
const wz = mesh.position.z + pos.getZ(i);
const h = terrainHeight(wx, wz);
pos.setY(i, h);
}
pos.needsUpdate = true;
mesh.geometry.computeVertexNormals();
const nor = mesh.geometry.attributes.normal;
for (let i = 0; i < pos.count; i++) {
const wx = mesh.position.x + pos.getX(i);
const wz = mesh.position.z + pos.getZ(i);
const h = pos.getY(i);
const slope = nor.getY(i);
const rgb = colorFor(h, slope, { x: wx, z: wz });
col.setXYZ(i, rgb[0], rgb[1], rgb[2]);
}
col.needsUpdate = true;
}
function updateTerrain(px, pz) {
const cx = Math.floor(px / CHUNK);
const cz = Math.floor(pz / CHUNK);
if (cx === chunkOrigin.x && cz === chunkOrigin.z) return;
chunkOrigin.x = cx; chunkOrigin.z = cz;
let i = 0;
for (let gz = -HALF; gz <= HALF; gz++) {
for (let gx = -HALF; gx <= HALF; gx++) {
rebuildChunk(terrainGroup.children[i++], cx + gx, cz + gz);
}
}
rebuildTrees(px, pz);
}
function buildTrees() {
const geo = new THREE.ConeGeometry(3.2, 14, 6);
geo.translate(0, 7, 0);
const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1, vertexColors: false });
treeMesh = new THREE.InstancedMesh(geo, mat, 1800);
treeMesh.castShadow = true;
treeMesh.receiveShadow = true;
treeMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.add(treeMesh);
const dummy = new THREE.Object3D();
for (let i = 0; i < 1800; i++) {
dummy.position.set(0, -9999, 0);
dummy.updateMatrix();
treeMesh.setMatrixAt(i, dummy.matrix);
treeMesh.setColorAt(i, new THREE.Color(0x1d4a28));
}
treeMesh.count = 1800;
}
function rebuildTrees(px, pz) {
const dummy = new THREE.Object3D();
let n = 0;
const minX = px - 2200, maxX = px + 2200, minZ = pz - 2200, maxZ = pz + 2200;
const step = 42;
const x0 = Math.floor(minX / step) * step;
const z0 = Math.floor(minZ / step) * step;
for (let z = z0; z < maxZ && n < 1800; z += step) {
for (let x = x0; x < maxX && n < 1800; x += step) {
const hsh = hash2(Math.floor(x / step), Math.floor(z / step));
if (hsh > 0.18) continue;
if (Math.abs(x) < 80 && z > -200 && z < 2000) continue;
const h = terrainHeight(x, z);
if (h < 4 || h > 240) continue;
const slope = terrainNormal(x, z).y;
if (slope < 0.82) continue;
const s = 0.7 + hsh * 1.5;
dummy.position.set(x, h, z);
dummy.scale.set(s, 0.8 + hsh * 1.3, s);
dummy.rotation.y = hsh * 6.28;
dummy.updateMatrix();
treeMesh.setMatrixAt(n, dummy.matrix);
const g = 0.25 + hsh * 0.35;
treeMesh.setColorAt(n, tmp.c.setRGB(0.08 + hsh * 0.08, g, 0.08));
n++;
}
}
for (let i = n; i < 1800; i++) {
dummy.position.set(0, -9999, 0);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
treeMesh.setMatrixAt(i, dummy.matrix);
}
treeMesh.instanceMatrix.needsUpdate = true;
if (treeMesh.instanceColor) treeMesh.instanceColor.needsUpdate = true;
}
function makeRunwayTexture() {
const c = document.createElement("canvas");
c.width = 256; c.height = 1024;
const g = c.getContext("2d");
g.fillStyle = "#2c3036";
g.fillRect(0, 0, 256, 1024);
g.strokeStyle = "#d8d4cc";
g.lineWidth = 6;
g.setLineDash([28, 22]);
g.beginPath(); g.moveTo(128, 20); g.lineTo(128, 1004); g.stroke();
g.setLineDash([]);
g.lineWidth = 8;
g.strokeRect(10, 8, 236, 1008);
g.fillStyle = "#efeae2";
g.font = "bold 84px sans-serif";
g.save();
g.translate(128, 180);
g.fillText("36", -48, 20);
g.restore();
g.save();
g.translate(128, 860);
g.rotate(Math.PI);
g.fillText("18", -48, 20);
g.restore();
for (let i = 0; i < 8; i++) {
g.fillRect(70, 240 + i * 18, 116, 8);
}
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8;
return tex;
}
function buildAirport() {
runwayGroup = new THREE.Group();
const tex = makeRunwayTexture();
const deck = new THREE.Mesh(
new THREE.PlaneGeometry(40, 1600),
new THREE.MeshStandardMaterial({ map: tex, roughness: 0.82, metalness: 0.05 })
);
deck.rotation.x = -Math.PI / 2;
deck.position.set(0, 1.42, 800);
deck.receiveShadow = true;
runwayGroup.add(deck);
const shoulder = new THREE.Mesh(
new THREE.PlaneGeometry(90, 1680),
new THREE.MeshStandardMaterial({ color: 0x6d6a60, roughness: 1 })
);
shoulder.rotation.x = -Math.PI / 2;
shoulder.position.set(0, 1.28, 800);
runwayGroup.add(shoulder);
function box(w, h, d, x, y, z, color, rotY) {
const m = new THREE.Mesh(
new THREE.BoxGeometry(w, h, d),
new THREE.MeshStandardMaterial({ color, roughness: 0.75, metalness: 0.15 })
);
m.position.set(x, y, z);
m.castShadow = true; m.receiveShadow = true;
if (rotY) m.rotation.y = rotY;
runwayGroup.add(m);
return m;
}
box(28, 8, 46, 78, 5.2, 280, 0x8d9298);
box(18, 14, 18, 96, 8.2, 360, 0x6e7680);
box(6, 22, 6, 108, 12, 360, 0xb7c0c8);
const cab = new THREE.Mesh(
new THREE.BoxGeometry(8, 4, 8),
new THREE.MeshStandardMaterial({ color: 0x8ec7e6, roughness: 0.15, metalness: 0.4, transparent: true, opacity: 0.75 })
);
cab.position.set(108, 24.5, 360);
runwayGroup.add(cab);
box(40, 7, 22, -86, 4.6, 640, 0x9aa0a6);
box(16, 5, 60, -70, 3.6, 820, 0x7c7568);
for (let i = 0; i < 28; i++) {
const z = 40 + i * 56;
for (const side of [-1, 1]) {
const l = new THREE.Mesh(
new THREE.BoxGeometry(0.6, 0.4, 1.4),
new THREE.MeshStandardMaterial({ color: 0xfff2c4, emissive: 0xffcc66, emissiveIntensity: 0.9 })
);
l.position.set(side * 22, 1.45, z);
runwayGroup.add(l);
}
}
papiMeshes = [];
for (let i = 0; i < 4; i++) {
const m = new THREE.Mesh(
new THREE.BoxGeometry(1.1, 0.7, 1.1),
new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xff2200, emissiveIntensity: 1.4 })
);
m.position.set(26 + i * 3.2, 2.1, 150);
runwayGroup.add(m);
papiMeshes.push(m);
}
scene.add(runwayGroup);
clouds = new THREE.Group();
const cmat = new THREE.MeshLambertMaterial({ color: 0xf4f7fb, transparent: true, opacity: 0.55, depthWrite: false });
for (let i = 0; i < 18; i++) {
const g = new THREE.Group();
const n = 4 + (i % 4);
for (let k = 0; k < n; k++) {
const s = new THREE.Mesh(new THREE.SphereGeometry(40 + (k % 3) * 22, 10, 8), cmat);
s.position.set((k - n / 2) * 36, (k % 2) * 8, (k % 3) * 12);
s.scale.y = 0.45;
g.add(s);
}
const ang = (i / 18) * Math.PI * 2;
const rad = 1800 + (i % 5) * 700;
g.position.set(Math.cos(ang) * rad, 900 + (i % 4) * 180, Math.sin(ang) * rad);
clouds.add(g);
}
scene.add(clouds);
}
let papiMeshes = [];
let clouds = null;
/* ---------- aircraft meshes ---------- */
function metal(color, rough, met) {
return new THREE.MeshStandardMaterial({ color, roughness: rough ?? 0.42, metalness: met ?? 0.62 });
}
function addExhaust(group, x, y, z, scale) {
const flame = new THREE.Mesh(
new THREE.ConeGeometry(0.28 * scale, 1.6 * scale, 8),
new THREE.MeshBasicMaterial({ color: 0xff8844, transparent: true, opacity: 0.85, blending: THREE.AdditiveBlending, depthWrite: false })
);
flame.rotation.x = Math.PI / 2;
flame.position.set(x, y, z);
flame.userData.exhaust = true;
flame.userData.base = 1.6 * scale;
group.add(flame);
const nozzle = new THREE.Mesh(new THREE.CylinderGeometry(0.32 * scale, 0.4 * scale, 0.5 * scale, 10), metal(0x222222, 0.5, 0.8));
nozzle.rotation.x = Math.PI / 2;
nozzle.position.set(x, y, z + 0.4 * scale);
group.add(nozzle);
}
function buildAircraft(id) {
const cfg = AIRCRAFT[id];
const g = new THREE.Group();
g.userData.id = id;
const P = cfg.colors.primary, A = cfg.colors.accent, D = cfg.colors.dark;
if (id === "falcon") {
const fuse = new THREE.Mesh(new THREE.CapsuleGeometry(0.72, 6.2, 6, 12), metal(P, 0.38, 0.7));
fuse.rotation.x = Math.PI / 2;
fuse.position.set(0, 0.15, 0.2);
fuse.castShadow = true;
g.add(fuse);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.55, 2.3, 14), metal(P, 0.35, 0.75));
nose.rotation.x = Math.PI / 2;
nose.position.set(0, 0.05, 4.5);
nose.castShadow = true;
g.add(nose);
const canopy = new THREE.Mesh(new THREE.SphereGeometry(0.62, 16, 12), new THREE.MeshStandardMaterial({ color: cfg.colors.glass, roughness: 0.08, metalness: 0.2, transparent: true, opacity: 0.55 }));
canopy.scale.set(0.85, 0.7, 1.35);
canopy.position.set(0, 0.85, 1.5);
g.add(canopy);
const wing = new THREE.Mesh(new THREE.BoxGeometry(10.4, 0.12, 2.5), metal(D, 0.45, 0.55));
wing.position.set(0, 0.02, -0.2);
wing.castShadow = true;
g.add(wing);
const lwing = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.08, 1.3), metal(A, 0.4, 0.5));
lwing.position.set(-4.6, 0.05, -0.7);
lwing.rotation.y = 0.35;
g.add(lwing);
const rwing = lwing.clone(); rwing.position.x = 4.6; rwing.rotation.y = -0.35;
g.add(rwing);
const tail = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.8, 1.3), metal(D, 0.4, 0.6));
tail.position.set(0, 1.15, -3.3);
g.add(tail);
const hstab = new THREE.Mesh(new THREE.BoxGeometry(3.6, 0.08, 1.0), metal(D, 0.45, 0.55));
hstab.position.set(0, 0.35, -3.4);
g.add(hstab);
const intake = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.55, 1.4), metal(0x1a1c20, 0.5, 0.7));
intake.position.set(0, -0.35, 0.6);
g.add(intake);
addExhaust(g, 0, 0.05, -3.7, 1.15);
g.userData.wingL = new THREE.Vector3(-5.1, 0, -0.2);
g.userData.wingR = new THREE.Vector3(5.1, 0, -0.2);
g.userData.nose = 5.4;
} else if (id === "hauler") {
const fuse = new THREE.Mesh(new THREE.BoxGeometry(2.5, 2.6, 12), metal(P, 0.62, 0.25));
fuse.position.set(0, 0.7, 0);
fuse.castShadow = true;
g.add(fuse);
const nose = new THREE.Mesh(new THREE.BoxGeometry(2.3, 2.2, 2.2), metal(P, 0.6, 0.3));
nose.position.set(0, 0.7, 6.6);
g.add(nose);
const radome = new THREE.Mesh(new THREE.SphereGeometry(0.9, 12, 10), metal(D, 0.5, 0.4));
radome.scale.z = 1.4;
radome.position.set(0, 0.85, 8.0);
g.add(radome);
const canopy = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.7, 1.8), new THREE.MeshStandardMaterial({ color: cfg.colors.glass, roughness: 0.1, metalness: 0.15, transparent: true, opacity: 0.6 }));
canopy.position.set(0, 2.15, 5.4);
g.add(canopy);
const wing = new THREE.Mesh(new THREE.BoxGeometry(22, 0.28, 3.2), metal(A, 0.55, 0.3));
wing.position.set(0, 2.0, 0.4);
wing.castShadow = true;
g.add(wing);
const tail = new THREE.Mesh(new THREE.BoxGeometry(0.22, 4.2, 2.4), metal(D, 0.55, 0.35));
tail.position.set(0, 3.3, -5.6);
g.add(tail);
const hstab = new THREE.Mesh(new THREE.BoxGeometry(8.5, 0.16, 1.6), metal(D, 0.5, 0.35));
hstab.position.set(0, 5.2, -5.5);
g.add(hstab);
for (const side of [-1, 1]) {
for (const z of [1.6, -1.2]) {
const nac = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.62, 2.2, 10), metal(0x3a3530, 0.5, 0.55));
nac.rotation.x = Math.PI / 2;
nac.position.set(side * (3.2 + (z < 0 ? 0.4 : 0)), 1.55, z);
g.add(nac);
const prop = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.08, 0.22), metal(0x222222, 0.4, 0.7));
prop.position.set(nac.position.x, nac.position.y, z + 1.15);
prop.userData.prop = true;
g.add(prop);
const prop2 = prop.clone();
prop2.rotation.z = Math.PI / 2;
prop2.userData.prop = true;
g.add(prop2);
}
}
g.userData.wingL = new THREE.Vector3(-10.5, 2.0, 0.2);
g.userData.wingR = new THREE.Vector3(10.5, 2.0, 0.2);
g.userData.nose = 8.6;
g.scale.setScalar(0.72);
} else {
const fuse = new THREE.Mesh(new THREE.CapsuleGeometry(0.55, 7.4, 5, 12), metal(P, 0.28, 0.82));
fuse.rotation.x = Math.PI / 2;
fuse.castShadow = true;
g.add(fuse);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.38, 2.6, 12), metal(P, 0.25, 0.85));
nose.rotation.x = Math.PI / 2;
nose.position.set(0, 0, 5.2);
g.add(nose);
const wing = new THREE.Mesh(new THREE.BoxGeometry(8.6, 0.1, 3.6), metal(D, 0.32, 0.7));
wing.position.set(0, -0.05, -0.6);
wing.castShadow = true;
g.add(wing);
const stripe = new THREE.Mesh(new THREE.BoxGeometry(8.7, 0.12, 0.28), metal(A, 0.35, 0.5));
stripe.position.set(0, 0.02, -0.2);
g.add(stripe);
const canard = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.06, 0.55), metal(A, 0.3, 0.6));
canard.position.set(0, 0.05, 2.5);
g.add(canard);
for (const side of [-1, 1]) {
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.08, 1.15, 1.15), metal(D, 0.3, 0.7));
fin.position.set(side * 1.15, 0.7, -2.7);
fin.rotation.z = side * -0.45;
g.add(fin);
}
const canopy = new THREE.Mesh(new THREE.SphereGeometry(0.42, 14, 10), new THREE.MeshStandardMaterial({ color: cfg.colors.glass, roughness: 0.05, metalness: 0.3, transparent: true, opacity: 0.5 }));
canopy.scale.set(0.8, 0.55, 1.5);
canopy.position.set(0, 0.48, 2.0);
g.add(canopy);
addExhaust(g, -0.42, -0.05, -3.9, 0.85);
addExhaust(g, 0.42, -0.05, -3.9, 0.85);
g.userData.wingL = new THREE.Vector3(-4.2, 0, -1.2);
g.userData.wingR = new THREE.Vector3(4.2, 0, -1.2);
g.userData.nose = 6.4;
}
const navL = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), new THREE.MeshBasicMaterial({ color: 0xff3030 }));
const navR = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), new THREE.MeshBasicMaterial({ color: 0x30ff60 }));
navL.position.copy(g.userData.wingL);
navR.position.copy(g.userData.wingR);
g.add(navL, navR);
g.traverse(o => { if (o.isMesh) o.castShadow = true; });
return g;
}
let showcase = [];
function buildShowcase() {
showcase.forEach(s => world.remove(s.mesh));
showcase = [];
const spots = [
{ id: "falcon", x: -26 },
{ id: "hauler", x: 8 },
{ id: "dart", x: 32 }
];
for (const s of spots) {
const mesh = buildAircraft(s.id);
mesh.position.set(s.x, terrainHeight(s.x, 480) + AIRCRAFT[s.id].gearH * (s.id === "hauler" ? 0.72 : 1), 480);
world.add(mesh);
showcase.push({ id: s.id, mesh, baseX: s.x });
}
}
/* ---------- particles ---------- */
const PCOUNT = 1400;
let particles, pPos, pCol, pSize, pAlpha, pLive;
function buildParticles() {
pPos = new Float32Array(PCOUNT * 3);
pCol = new Float32Array(PCOUNT * 3);
pSize = new Float32Array(PCOUNT);
pAlpha = new Float32Array(PCOUNT);
pLive = [];
for (let i = 0; i < PCOUNT; i++) {
pPos[i * 3 + 1] = -9999;
pLive.push(null);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pPos, 3));
geo.setAttribute("color", new THREE.BufferAttribute(pCol, 3));
geo.setAttribute("aSize", new THREE.BufferAttribute(pSize, 1));
geo.setAttribute("aAlpha", new THREE.BufferAttribute(pAlpha, 1));
const mat = new THREE.ShaderMaterial({
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, fog: false,
vertexShader: `
attribute float aSize; attribute float aAlpha; attribute vec3 color;
varying vec3 vColor; varying float vAlpha;
void main() {
vColor = color; vAlpha = aAlpha;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * (280.0 / max(1.0, -mv.z));
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
varying vec3 vColor; varying float vAlpha;
void main() {
vec2 d = gl_PointCoord - 0.5;
float r = length(d);
if (r > 0.5) discard;
float a = smoothstep(0.5, 0.05, r) * vAlpha;
gl_FragColor = vec4(vColor, a);
}`
});
particles = new THREE.Points(geo, mat);
particles.frustumCulled = false;
scene.add(particles);
}
function spawnParticle(pos, vel, life, color, size) {
for (let i = 0; i < PCOUNT; i++) {
if (pLive[i]) continue;
pLive[i] = { vel: vel.clone(), life, max: life, size };
pPos[i * 3] = pos.x; pPos[i * 3 + 1] = pos.y; pPos[i * 3 + 2] = pos.z;
pCol[i * 3] = color.r; pCol[i * 3 + 1] = color.g; pCol[i * 3 + 2] = color.b;
pSize[i] = size; pAlpha[i] = 1;
return;
}
}
function updateParticles(dt) {
for (let i = 0; i < PCOUNT; i++) {
const p = pLive[i];
if (!p) continue;
p.life -= dt;
if (p.life <= 0) {
pLive[i] = null;
pPos[i * 3 + 1] = -9999;
pAlpha[i] = 0;
continue;
}
pPos[i * 3] += p.vel.x * dt;
pPos[i * 3 + 1] += p.vel.y * dt;
pPos[i * 3 + 2] += p.vel.z * dt;
p.vel.y -= 4 * dt;
pAlpha[i] = p.life / p.max;
pSize[i] = p.size * (0.6 + 0.6 * (p.life / p.max));
}
particles.geometry.attributes.position.needsUpdate = true;
particles.geometry.attributes.color.needsUpdate = true;
particles.geometry.attributes.aSize.needsUpdate = true;
particles.geometry.attributes.aAlpha.needsUpdate = true;
}
const COL = {
white: new THREE.Color(1, 1, 1),
orange: new THREE.Color(1, 0.45, 0.1),
yellow: new THREE.Color(1, 0.85, 0.4),
dust: new THREE.Color(0.7, 0.62, 0.45),
cyan: new THREE.Color(0.5, 1, 0.8),
red: new THREE.Color(1, 0.3, 0.2)
};
function burst(pos, color, n, speed, life, size) {
for (let i = 0; i < n; i++) {
const v = new THREE.Vector3(Math.random() - 0.5, Math.random() * 0.6, Math.random() - 0.5).normalize().multiplyScalar(speed * (0.4 + Math.random()));
spawnParticle(pos, v, life * (0.5 + Math.random()), color, size * (0.6 + Math.random()));
}
}
/* ---------- flight state ---------- */
const keys = {};
const mouse = { x: 0, y: 0, locked: false };
let ship = null;
let bandits = [];
let tracers = [];
let mode = "menu"; // menu | fly | pause | debrief
let camMode = 0; // 0 chase, 1 cockpit, 2 tower, 3 orbit
let sas = true;
let mission = null;
let clock = 0;
let shake = 0;
let lastCrash = 0;
const UP = new THREE.Vector3(0, 1, 0);
const FWD = new THREE.Vector3(0, 0, 1);
const RIGHT = new THREE.Vector3(1, 0, 0);
function makeShip(id, pos, heading) {
const cfg = AIRCRAFT[id];
const mesh = buildAircraft(id);
world.add(mesh);
const s = {
id, cfg, mesh,
pos: pos.clone(),
vel: new THREE.Vector3(),
quat: new THREE.Quaternion().setFromAxisAngle(UP, heading || 0),
omega: new THREE.Vector3(), // body rates rad/s
throttle: 0,
rpm: 0.08,
flaps: 0,
gear: true,
brake: false,
ab: false,
trim: 0,
aoa: 0, aos: 0, aoaAbs: 0,
airspeed: 0, tas: 0, ias: 0, mach: 0,
g: 1, alt: pos.y, agl: 0, vs: 0,
airborne: false, crashed: false, onGround: true,
runwayHdg: 0,
fuel: 1,
heat: 0.2,
groundedFor: 0,
fireCd: 0,
hp: 100,
smokeT: 0,
alive: true,
lastContact: null
};
applyPose(s);
return s;
}
function applyPose(s) {
s.mesh.position.copy(s.pos);
s.mesh.quaternion.copy(s.quat);
s.mesh.traverse(o => {
if (o.userData && o.userData.exhaust) {
const k = s.ab ? 2.6 : (0.15 + s.throttle * 1.4);
o.scale.set(0.6 + s.throttle * 0.6, k, 0.6 + s.throttle * 0.6);
o.material.opacity = 0.25 + s.throttle * 0.7;
o.material.color.set(s.ab ? 0x88ccff : 0xff7722);
}
if (o.userData && o.userData.prop) {
o.rotation.z += s.rpm * 1.8;
}
});
}
function bodyAxis(s, local, out) {
return out.copy(local).applyQuaternion(s.quat);
}
function integrateShip(s, dt, input, isPlayer) {
if (!s.alive) {
s.pos.y -= 2 * dt;
return;
}
const cfg = s.cfg;
dt = Math.min(dt, 0.033);
const rho = airDensity(s.pos.y);
const wind = s.onGround ? new THREE.Vector3() : windAt(s.pos.y);
const vAir = tmp.v.copy(s.vel).sub(wind);
const speed = vAir.length();
s.airspeed = speed;
s.tas = speed;
s.ias = speed * Math.sqrt(rho / 1.225);
s.mach = speed / speedOfSound(s.pos.y);
s.vs = s.vel.y;
s.alt = s.pos.y;
const fwd = bodyAxis(s, FWD, tmp.v2);
const right = bodyAxis(s, RIGHT, tmp.v3);
const up = tmp.v.clone().crossVectors(right, fwd).normalize(); // body up? wait right x forward = up in RH if forward is +Z and right is +X?
// THREE.js: +X right, +Y up, +Z toward viewer by default... we treat local +Z as nose (forward).
// right (1,0,0) × forward (0,0,1) = (0*1 - 0*0, 0*0 - 1*1, 1*0 - 0*0) = (0, -1, 0). That's down.
// So body-up should be forward × right, or we store local up as (0,1,0).
const bup = bodyAxis(s, UP, new THREE.Vector3());
let aoa = 0, aos = 0;
if (speed > 1.5) {
const vn = vAir.clone().normalize();
const along = vn.dot(fwd);
const side = vn.dot(right);
const vert = vn.dot(bup);
aoa = Math.atan2(vert, Math.max(0.05, along)); // positive aoa = wind from below relative?
// If nose above velocity, velocity is below nose, vert component of vn on bup is negative if nose is pitched up...
// Standard: alpha = angle between chord (fwd) and relative wind. Relative wind is -vAir.
// alpha positive (nose up) when -vAir has a component along -bup? Let's recompute carefully.
aos = Math.atan2(side, Math.max(0.05, Math.abs(along)));
}
// Recompute AoA properly
if (speed > 1.2) {
const rel = vAir.clone().negate(); // direction air comes FROM? No: aerodynamic alpha uses velocity vector vs body x.
// alpha = atan2(w_body, u_body) where velocity in body frame.
const u = vAir.dot(fwd);
const w = vAir.dot(bup); // positive w means velocity has upward component (aircraft descending relative to nose? )
// If aircraft pitches nose up, velocity still forward, body-up rotates, vAir · bup becomes negative (velocity is "below" the nose).
// Conventional alpha positive when nose is above the velocity vector, i.e. when vAir · bup < 0.
aoa = Math.atan2(-w, Math.max(0.2, u));
aos = Math.atan2(vAir.dot(right), Math.max(0.2, u));
}
s.aoa = aoa;
s.aos = aos;
s.aoaAbs = Math.abs(aoa);
// controls
let pitchIn = 0, rollIn = 0, yawIn = 0;
if (isPlayer) {
const sens = 1;
// +pitchIn = stick forward = nose down; +rollIn = stick right
pitchIn = clamp((mouse.y * 1.15) + (keys.ArrowDown ? 1 : 0) - (keys.ArrowUp ? 1 : 0), -1, 1);
rollIn = clamp((mouse.x * 1.15) + (keys.ArrowRight ? 1 : 0) - (keys.ArrowLeft ? 1 : 0) + (keys.KeyE ? 1 : 0) - (keys.KeyQ ? 1 : 0), -1, 1);
yawIn = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
if (keys.KeyW) s.throttle = clamp(s.throttle + dt * 0.35, 0, 1);
if (keys.KeyS) s.throttle = clamp(s.throttle - dt * 0.35, 0, 1);
s.ab = !!(cfg.afterburner && (keys.ShiftLeft || keys.ShiftRight) && s.throttle > 0.85);
} else if (input) {
pitchIn = input.pitch; rollIn = input.roll; yawIn = input.yaw;
s.throttle = input.throttle;
s.ab = !!(cfg.afterburner && input.ab);
s.flaps = input.flaps || 0;
s.gear = input.gear != null ? input.gear : s.gear;
}
if (isPlayer) {
// gear/flaps handled by key events
}
// RPM lags throttle (engine spool)
const targetRpm = 0.12 + s.throttle * 0.78 + (s.ab ? 0.12 : 0);
const spool = s.throttle > s.rpm ? 0.85 : 0.45;
s.rpm += (targetRpm - s.rpm) * (1 - Math.exp(-spool * dt * 2.4));
s.heat += ((s.ab ? 1.3 : s.throttle) - s.heat) * dt * 0.35;
s.heat = clamp(s.heat, 0, 1.4);
if (s.ab) s.fuel = Math.max(0, s.fuel - dt * 0.012);
else s.fuel = Math.max(0, s.fuel - dt * (0.0012 + s.throttle * 0.004));
if (s.fuel <= 0) { s.throttle = 0; s.ab = 0; }
// thrust along nose, density lapse
const lapse = Math.pow(rho / 1.225, cfg.altPower);
let thrust = cfg.thrust * s.rpm * lapse;
if (s.ab) thrust *= cfg.abBoost;
if (cfg.kind === "turboprop") {
// static thrust exists; efficiency rises as the prop bites
thrust *= 0.62 + 0.38 * clamp(speed / 80, 0, 1);
} else {
thrust *= 1 - clamp((s.mach - 0.9) * 0.35, 0, 0.22);
}
const thrustForce = fwd.clone().multiplyScalar(thrust * (s.fuel > 0 ? 1 : 0));
// aero forces in stability axes
const q = 0.5 * rho * speed * speed;
const flaps = s.flaps;
const cla = cfg.clAlpha;
const stall = cfg.stallAoA * (1 + 0.12 * flaps);
let cl = cla * aoa;
const absA = Math.abs(aoa);
if (absA > stall) {
const over = absA - stall;
const sign = Math.sign(aoa) || 1;
const peak = cla * stall;
cl = sign * peak * Math.max(0.35, 1 - over * 2.4);
}
cl += 0.35 * flaps;
cl = clamp(cl, -cfg.clMax * 1.1, cfg.clMax * (1 + 0.45 * flaps));
const AR = (cfg.span * cfg.span) / cfg.wingArea;
const clClamped = cl;
const cdi = (clClamped * clClamped) / (Math.PI * AR * cfg.oswald);
let cd = cfg.cd0 * (1 + 0.35 * flaps + (s.gear && s.airborne ? 0.22 : 0) + (s.brake ? 0.55 : 0)) + cdi;
// wave drag
if (s.mach > 0.85) cd += 0.08 * Math.pow(s.mach - 0.85, 2) * 8;
const cy = cfg.cyBeta * aos;
// lift direction: perpendicular to velocity, in the plane of bup and velocity, roughly "up" from velocity
let liftDir = new THREE.Vector3();
let dragDir = new THREE.Vector3();
let sideDir = new THREE.Vector3();
if (speed > 1.2) {
dragDir.copy(vAir).normalize().negate();
// lift is perpendicular to vAir, biased toward body up
liftDir.crossVectors(vAir, right);
if (liftDir.lengthSq() < 1e-6) liftDir.copy(bup);
liftDir.normalize();
// ensure liftDir points generally with body up
if (liftDir.dot(bup) < 0) liftDir.negate();
sideDir.crossVectors(fwd, bup).normalize(); // should be ~ right
// side force opposes sideslip: if aos positive (wind from right, vAir·right > 0), weathervane side force toward -right?
// cyBeta is negative. Force = cy * q * S * sideDir, with sideDir = right. So negative cy pushes left when aos positive. Good.
} else {
dragDir.set(0, 0, 0);
liftDir.copy(bup);
sideDir.copy(right);
}
const lift = q * cfg.wingArea * cl;
const drag = q * cfg.wingArea * cd;
const side = q * cfg.wingArea * cy;
const liftForce = liftDir.multiplyScalar(lift);
const dragForce = dragDir.multiplyScalar(drag);
const sideForce = sideDir.multiplyScalar(side);
// gravity
const grav = new THREE.Vector3(0, -9.806 * cfg.mass, 0);
const force = new THREE.Vector3().add(thrustForce).add(liftForce).add(dragForce).add(sideForce).add(grav);
// ground interaction
const gh = terrainHeight(s.pos.x, s.pos.z);
const gearClear = cfg.gearH * (s.gear ? 1 : 0.35) * (s.id === "hauler" ? 0.72 : 1);
const groundY = gh + gearClear;
s.agl = s.pos.y - gh;
const wasGround = s.onGround;
const contact = s.groundedFor < 90 && ((s.pos.y <= groundY + 1.5 && s.vel.y < 6) || (s.groundedFor < 1.2 && s.vel.y < 8 && s.agl < 6));
if (contact) {
s.groundedFor += dt;
const nrm = terrainNormal(s.pos.x, s.pos.z);
const pen = groundY - s.pos.y;
if (pen > 0) s.pos.y = groundY;
const vn = s.vel.dot(nrm);
if (vn < 0) {
const impact = -vn;
s.vel.addScaledVector(nrm, -vn * (impact > 6 ? 0.15 : 1.0));
if (impact > 9 && isPlayer) {
crash(s, "HARD IMPACT");
} else if (impact > 3.5 && isPlayer) {
shake = Math.max(shake, impact * 0.04);
noiseBurst(0.25, 0.35, 120);
mission && mission.onHard && mission.onHard(impact);
}
}
// friction
const tangent = s.vel.clone().addScaledVector(nrm, -s.vel.dot(nrm));
const brakeF = s.brake ? 0.85 : (s.throttle < 0.15 ? 0.08 : 0.02);
const mu = s.gear ? brakeF : 0.35;
tangent.multiplyScalar(Math.max(0, 1 - mu * 8 * dt));
// rolling resistance + wheel steering while slow
s.vel.copy(tangent).addScaledVector(nrm, Math.max(0, s.vel.dot(nrm)));
if (!s.airborne || s.agl < gearClear + 1.2) {
if (speed < 70 && isPlayer) {
const steer = yawIn * dt * (2.2 + s.throttle);
s.quat.multiply(new THREE.Quaternion().setFromAxisAngle(UP, -steer));
}
// nosewheel tracks the runway heading; rudder authority grows with speed
if (speed > 8) {
const hdg = headingOf(s);
const want = hdg + (-yawIn) * 0.012;
const back = want * Math.exp(-dt * (speed < 40 ? 6 : 1.5));
if (Math.abs(yawIn) < 0.05) {
s.quat.copy(attitudeQuat(back, pitchAngle(s), 0));
}
}
}
// normal force cancels gravity component
const nForce = Math.max(0, -grav.dot(nrm) - force.dot(nrm));
force.addScaledVector(nrm, nForce * 0.0); // already projected velocity; positional constraint handles it
s.onGround = s.groundedFor < 90;
s.airborne = !s.onGround;
s._vs = stallSpeed(cfg, flaps);
} else {
s.onGround = false;
s.airborne = true;
s.groundedFor = 99;
}
// integrate translation
const acc = force.multiplyScalar(1 / cfg.mass);
s.vel.addScaledVector(acc, dt);
s.pos.addScaledVector(s.vel, dt);
// moments — body frame
const qDyn = q;
const b = cfg.span;
const cbar = cfg.wingArea / cfg.span;
const iasF = rho * speed; // dynamic pressure already in qDyn; moment ~ q * S * c
const elev = pitchIn + s.trim;
const ail = rollIn;
const rud = yawIn;
// stability: restoring moment proportional to aoa, aos, and rate damping
const Cm = -cfg.stab.x * aoa * 0.000015 + cfg.ctrl.x * elev * 0.000012;
const Cl = -cfg.stab.z * 0 * 0 + cfg.ctrl.z * ail * 0.00001 - cfg.stab.z * (s.omega.z) * 0;
// Use direct torque model (N·m), scaled by dynamic pressure so controls fade at low speed and bite at high speed
const qScale = clamp(qDyn / 4000, 0.08, 6);
const rate = s.omega;
// +Mx (about +X right) pitches nose down; +My (about +Y) yaws left; +Mz (about +Z) rolls left
let Mx = cfg.ctrl.x * elev * qScale - cfg.stab.x * aoa * qScale - cfg.damp.x * rate.x * (0.4 + qScale);
let My = -cfg.ctrl.y * rud * qScale - cfg.stab.y * aos * qScale - cfg.damp.y * rate.y * (0.4 + qScale);
let Mz = -cfg.ctrl.z * ail * qScale - cfg.damp.z * rate.z * (0.35 + qScale);
// dihedral: positive sideslip (wind from right) rolls right wing down (positive Mz is roll left, so negative)
Mz += -cfg.stab.z * aos * qScale * 0.35;
// SAS: extra damping and attitude hold when stick near center
if (isPlayer && sas && s.airborne) {
Mx += -rate.x * cfg.inertia.x * 1.6;
Mz += -rate.z * cfg.inertia.z * 2.2;
if (Math.abs(pitchIn) < 0.08) {
// hold pitch attitude loosely via extra damping only
}
if (Math.abs(rollIn) < 0.08) {
// wings level tendency
const roll = rollAngle(s);
Mz += -roll * cfg.inertia.z * 1.4;
}
}
// gear holds attitude until dynamic pressure gives the elevator authority to rotate
if (s.onGround) {
const hold = pitchAngle(s);
const rotateSpd = stallSpeed(cfg, flaps) * 0.9;
const auth = clamp((speed - rotateSpd * 0.55) / Math.max(12, rotateSpd * 0.5), 0, 1);
// below rotation speed the gear pins the attitude; above it the stick can raise the nose
const pin = (1 - auth);
Mx += -hold * cfg.inertia.x * 6 * pin;
Mx += cfg.ctrl.x * Math.min(0, elev) * qScale * 0.8 * pin; // nose-down still bites (don't wheelie early)
My *= 0.35;
Mz *= 0.15;
rate.z *= Math.exp(-6 * dt);
if (auth < 0.35) rate.x *= Math.exp(-8 * dt * pin);
}
// structural limit — departure if overloaded
const gLoad = acc.dot(bup) / 9.806 + 1; // rough, overwritten below
s.g = (lift / (cfg.mass * 9.806)) * Math.sign(cl || 1) + (s.onGround ? 1 : 0) * 0;
if (!s.onGround) s.g = liftForce.length() / (cfg.mass * 9.806) * (cl >= 0 ? 1 : -1);
else s.g = 1;
rate.x += (Mx / cfg.inertia.x) * dt;
rate.y += (My / cfg.inertia.y) * dt;
rate.z += (Mz / cfg.inertia.z) * dt;
// clamp rates
rate.x = clamp(rate.x, -2.4, 2.4);
rate.y = clamp(rate.y, -1.2, 1.2);
rate.z = clamp(rate.z, -3.2, 3.2);
// integrate quaternion from body rates. Body axes: x pitch?
// Our torque Mx is pitch (about body right / X), My yaw (about body up / Y), Mz roll (about body forward / Z).
const wBody = new THREE.Vector3(rate.x, rate.y, rate.z);
const wWorld = wBody.clone().applyQuaternion(s.quat);
const wq = new THREE.Quaternion(wWorld.x, wWorld.y, wWorld.z, 0);
const dq = wq.multiply(s.quat);
s.quat.x += dq.x * dt * 0.5;
s.quat.y += dq.y * dt * 0.5;
s.quat.z += dq.z * dt * 0.5;
s.quat.w += dq.w * dt * 0.5;
s.quat.normalize();
// keep from tunneling into terrain
const gh2 = terrainHeight(s.pos.x, s.pos.z);
if (s.pos.y < gh2 + 0.4) {
if (s.vel.length() > 30 && !s.onGround) crash(s, "TERRAIN");
s.pos.y = gh2 + 0.4;
if (s.vel.y < 0) s.vel.y = 0;
}
// VNE overspeed stress
if (s.ias > cfg.vne && isPlayer) {
s.heat = Math.min(1.4, s.heat + dt * 0.4);
shake = Math.max(shake, (s.ias - cfg.vne) * 0.01);
if (s.ias > cfg.vne * 1.12) crash(s, "OVERSPEED");
}
if (Math.abs(s.g) > cfg.maxG * 1.15 && s.airborne && isPlayer) {
shake = Math.max(shake, 0.4);
if (Math.abs(s.g) > cfg.maxG * 1.45) crash(s, "OVERSTRESS");
}
if (s.onGround && speed > (s._vs || 40) * 1.02 && pitchAngle(s) > 0.14 && s.groundedFor > 0.5) {
s.onGround = false;
s.airborne = true;
s.groundedFor = 99;
}
if (s.onGround) {
const vr = stallSpeed(cfg, flaps) * 0.92;
const maxP = lerp(0.012, 0.28, clamp((speed - vr * 0.72) / (vr * 0.28), 0, 1));
const pit = clamp(pitchAngle(s), -0.03, maxP);
let hdg = s.runwayHdg || 0;
if (Math.abs(yawIn) > 0.2 && speed < 55) hdg = headingOf(s);
s.runwayHdg = hdg;
s.quat.copy(attitudeQuat(hdg, pit, 0));
s.omega.x *= 0.2;
s.omega.z *= 0.05;
}
applyPose(s);
// vapor / exhaust particles
if (isPlayer || s.hp < 100) {
s.smokeT -= dt;
if (s.smokeT <= 0 && s.throttle > 0.3) {
s.smokeT = 0.03;
const back = fwd.clone().multiplyScalar(-6);
const p = s.pos.clone().add(back);
const vel = s.vel.clone().addScaledVector(fwd, -8).add(new THREE.Vector3((Math.random()-0.5)*2, (Math.random()-0.5), (Math.random()-0.5)*2));
spawnParticle(p, vel, 0.6, s.ab ? COL.cyan : COL.orange, s.ab ? 8 : 4);
}
if (s.airborne && Math.abs(aoa) > stall * 0.75 && speed > 40 && s.smokeT < 0.02) {
const p = s.pos.clone().add(bup.clone().multiplyScalar(-0.2));
spawnParticle(p, s.vel.clone().add(new THREE.Vector3((Math.random()-0.5)*6, 2, (Math.random()-0.5)*6)), 0.8, COL.white, 10);
}
}
s._fwd = fwd;
s._bup = bup;
s._right = right;
s._lift = lift;
s._q = qDyn;
s._stall = stall;
}
function attitudeQuat(heading, pitch, roll) {
const cp = Math.cos(pitch), sp = Math.sin(pitch);
const f = new THREE.Vector3(Math.sin(heading) * cp, sp, Math.cos(heading) * cp);
const up = new THREE.Vector3(0, 1, 0);
const r = new THREE.Vector3().crossVectors(up, f);
if (r.lengthSq() < 1e-6) r.set(1, 0, 0);
r.normalize();
if (roll) r.applyAxisAngle(f, roll);
const u = new THREE.Vector3().crossVectors(f, r).normalize();
return new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(r, u, f));
}
function rollAngle(s) {
const bup = new THREE.Vector3(0, 1, 0).applyQuaternion(s.quat);
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(s.quat);
// roll = angle of right axis vs horizon
const proj = right.clone();
proj.y = 0;
if (proj.lengthSq() < 1e-6) return bup.y < 0 ? Math.PI : 0;
proj.normalize();
const sign = Math.sign(right.y || 1);
return Math.atan2(right.y, proj.dot(new THREE.Vector3(1, 0, 0).applyQuaternion(s.quat).setY(0).normalize() || new THREE.Vector3(1,0,0)));
}
function pitchAngle(s) {
const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(s.quat);
return Math.asin(clamp(fwd.y, -1, 1));
}
function headingOf(s) {
const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(s.quat);
return Math.atan2(fwd.x, fwd.z);
}
function crash(s, reason) {
if (s.crashed) return;
s.crashed = true;
s.alive = false;
shake = 1.2;
noiseBurst(0.6, 0.7, 90);
burst(s.pos, COL.orange, 40, 18, 1.2, 14);
burst(s.pos, COL.red, 20, 12, 0.8, 8);
if (s === ship) {
setTimeout(() => endMission(false, reason), 700);
}
}
/* ---------- bandits ---------- */
function spawnBandits() {
bandits.forEach(b => world.remove(b.mesh));
bandits = [];
const types = ["falcon", "dart", "falcon"];
for (let i = 0; i < 3; i++) {
const ang = (i / 3) * Math.PI * 2 + 0.4;
const r = 900 + i * 280;
const p = new THREE.Vector3(Math.sin(ang) * r, 420 + i * 80, 800 + Math.cos(ang) * r);
const b = makeShip(types[i], p, ang + Math.PI);
b.throttle = 0.7;
b.vel.copy(new THREE.Vector3(0, 0, 1).applyQuaternion(b.quat)).multiplyScalar(140);
b.airborne = true;
b.gear = false;
b.hp = 100;
b.role = i === 0 ? "lead" : "wing";
b.aiT = i * 0.7;
bandits.push(b);
}
}
function updateBandit(b, dt) {
if (!b.alive) {
b.pos.addScaledVector(b.vel, dt);
b.vel.y -= 12 * dt;
b.quat.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), dt * 0.8));
const gh = terrainHeight(b.pos.x, b.pos.z);
if (b.pos.y < gh + 1) {
b.pos.y = gh + 1;
b.vel.multiplyScalar(0.2);
if (!b._boom) { b._boom = true; burst(b.pos, COL.orange, 30, 16, 1, 12); noiseBurst(0.4, 0.4, 100); }
}
applyPose(b);
return;
}
b.aiT += dt;
const toPlayer = ship.pos.clone().sub(b.pos);
const dist = toPlayer.length();
const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(b.quat);
const desired = toPlayer.clone().normalize();
// lead pursuit
const lead = ship.vel.clone().multiplyScalar(clamp(dist / 280, 0.2, 2.2));
desired.copy(ship.pos).add(lead).sub(b.pos).normalize();
// if too close and not facing, break
const facing = fwd.dot(toPlayer.clone().normalize());
let pitch = 0, roll = 0, yaw = 0;
const bup = new THREE.Vector3(0, 1, 0).applyQuaternion(b.quat);
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(b.quat);
const errY = desired.dot(bup);
const errX = desired.dot(right);
pitch = clamp(-errY * 2.2, -1, 1);
roll = clamp(errX * 2.4, -1, 1);
yaw = clamp(errX * 0.4, -1, 1);
// energy fight: if slow, unload and go for speed
let thr = 0.82;
let ab = b.cfg.afterburner && (dist > 600 || b.airspeed < 160);
if (b.airspeed < 90) { pitch = clamp(pitch, -0.2, 0.3); thr = 1; ab = true; }
if (b.pos.y < 180) pitch = Math.min(pitch, -0.15);
if (b.pos.y > 2800) pitch = Math.max(pitch, 0.3);
// shoot
if (facing > 0.92 && dist < 700 && dist > 40) {
fire(b, false);
}
integrateShip(b, dt, { pitch, roll, yaw, throttle: thr, ab, flaps: 0, gear: false }, false);
if (b.hp <= 0) {
b.alive = false;
burst(b.pos, COL.orange, 24, 14, 1, 10);
noiseBurst(0.35, 0.45, 140);
mission && mission.onKill && mission.onKill();
}
}
/* ---------- weapons ---------- */
function fire(s, isPlayer) {
if (s.fireCd > 0 || !s.alive) return;
s.fireCd = isPlayer ? 0.09 : 0.18;
const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(s.quat);
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(s.quat);
const muzzle = s.pos.clone().add(fwd.clone().multiplyScalar(6)).add(right.clone().multiplyScalar((Math.random() > 0.5 ? 1 : -1) * 1.2));
const spread = isPlayer ? 0.008 : 0.02;
const dir = fwd.clone().add(new THREE.Vector3((Math.random()-0.5)*spread, (Math.random()-0.5)*spread, (Math.random()-0.5)*spread)).normalize();
tracers.push({
pos: muzzle,
vel: s.vel.clone().addScaledVector(dir, 920),
life: 1.3,
owner: s,
player: isPlayer
});
if (isPlayer) blip(180 + Math.random() * 40, 0.05, "square", 0.05);
spawnParticle(muzzle, dir.clone().multiplyScalar(20), 0.15, COL.yellow, 6);
}
function updateTracers(dt) {
for (let i = tracers.length - 1; i >= 0; i--) {
const t = tracers[i];
t.life -= dt;
t.vel.y -= 3 * dt;
t.pos.addScaledVector(t.vel, dt);
spawnParticle(t.pos, new THREE.Vector3(), 0.12, t.player ? COL.yellow : COL.red, 3.5);
if (t.life <= 0 || t.pos.y < terrainHeight(t.pos.x, t.pos.z)) {
tracers.splice(i, 1);
continue;
}
const targets = t.player ? bandits : [ship];
for (const o of targets) {
if (!o || !o.alive) continue;
if (o.pos.distanceTo(t.pos) < (o.id === "hauler" ? 8 : 5)) {
o.hp -= t.player ? 12 : 6;
burst(t.pos, COL.orange, 6, 8, 0.3, 5);
blip(90, 0.08, "sawtooth", 0.06);
tracers.splice(i, 1);
if (o === ship && o.hp <= 0) crash(ship, "SHOT DOWN");
break;
}
}
}
}
/* ---------- missions ---------- */
function startMission() {
initAudio();
if (audio.ctx && audio.ctx.state === "suspended") audio.ctx.resume();
bandits.forEach(b => world.remove(b.mesh));
bandits = [];
tracers = [];
if (ship) world.remove(ship.mesh);
showcase.forEach(s => { s.mesh.visible = false; });
const id = selectedCraft;
const cfg = AIRCRAFT[id];
if (selectedScenario === "pattern") {
const p = new THREE.Vector3(0, terrainHeight(0, 120) + cfg.gearH * (id === "hauler" ? 0.75 : 1) + 0.2, 120);
ship = makeShip(id, p, 0);
ship.gear = true;
ship.throttle = 0;
ship.runwayHdg = 0;
ship.onGround = true;
ship.airborne = false;
mission = {
type: "pattern",
phase: "takeoff",
t: 0,
rotated: false,
maxAgl: 0,
touchV: 0,
touchX: 0,
centerline: 0,
bounced: false,
airborneTime: 0,
score: 0,
notes: [],
onHard(impact) {
this.notes.push("Hard contact " + impact.toFixed(1) + " m/s");
this.bounced = true;
}
};
} else {
const p = new THREE.Vector3(-200, 520, -100);
ship = makeShip(id, p, 0.4);
ship.gear = false;
ship.throttle = 0.75;
ship.rpm = 0.8;
ship.vel.set(40, 0, 180);
ship.airborne = true;
ship.onGround = false;
ship.groundedFor = 99;
ship.hp = 100;
spawnBandits();
mission = {
type: "dogfight",
t: 0,
kills: 0,
hits: 0,
startHp: 100,
onKill() { this.kills++; blip(520, 0.12, "sine", 0.08); }
};
}
camMode = 0;
sas = true;
mode = "fly";
clock = 0;
$("topbar").style.display = "none";
$("sheet").style.display = "none";
$("debrief").style.display = "none";
$("pause").style.display = "none";
$("help").style.display = "block";
updateTerrain(ship.pos.x, ship.pos.z);
}
function endMission(success, reason) {
if (mode === "debrief") return;
mode = "debrief";
const grades = [];
let title = success ? "MISSION COMPLETE" : "MISSION FAILED";
let copy = reason || "";
if (mission && mission.type === "pattern") {
const vs = Math.abs(mission.touchV || ship.vel.y);
const cx = Math.abs(mission.touchX ?? 99);
const cl = mission.centerline ?? 99;
grades.push(["TOUCHDOWN VS", (mission.touchV ? mission.touchV.toFixed(1) : "—") + " m/s"]);
grades.push(["CENTERLINE", (cl < 90 ? cl.toFixed(1) : "—") + " m"]);
grades.push(["THRESHOLD", (mission.touchZ != null ? Math.round(mission.touchZ) : "—") + " m"]);
grades.push(["AIR TIME", mission.airborneTime.toFixed(0) + " s"]);
if (!success && !reason) copy = "Sortie ended.";
if (success) {
title = "GREASED IT";
copy = "Pattern flown. Review the numbers — a transport and a dart do not share a landing." + (mission.notes.length ? " " + mission.notes.join(". ") : "");
}
} else if (mission && mission.type === "dogfight") {
grades.push(["KILLS", (mission.kills || 0) + " / 3"]);
grades.push(["HULL", Math.max(0, Math.round(ship.hp)) + "%"]);
grades.push(["FUEL", Math.round(ship.fuel * 100) + "%"]);
grades.push(["TIME", mission.t.toFixed(0) + " s"]);
if (success) copy = "Sky clear. Energy management decided it.";
else copy = reason || "You departed the fight.";
}
$("db-title").textContent = title;
$("db-copy").textContent = copy;
$("db-grades").innerHTML = grades.map(g => `<div><label>${g[0]}</label><strong>${g[1]}</strong></div>`).join("");
$("debrief").style.display = "flex";
$("help").style.display = "none";
}
function updateMission(dt) {
if (!mission || !ship.alive) return;
mission.t += dt;
if (mission.type === "pattern") {
if (ship.airborne) {
mission.airborneTime += dt;
mission.maxAgl = Math.max(mission.maxAgl, ship.agl);
if (!mission.rotated && ship.agl > 15) mission.rotated = true;
mission.phase = ship.pos.z < 1600 ? "climb" : (ship.pos.x > 400 || headingOf(ship) < -0.4 ? "downwind" : "circuit");
}
// landing detection: was airborne, now on ground, past runway midpoint-ish or anywhere on runway
if (mission.airborneTime > 8 && ship.onGround && ship.airspeed < 40 && ship.agl < AIRCRAFT[ship.id].gearH + 3) {
if (mission.touchZ == null) {
mission.touchZ = ship.pos.z;
mission.touchX = ship.pos.x;
mission.touchV = Math.abs(ship.vs);
mission.centerline = Math.abs(ship.pos.x);
}
if (ship.airspeed < 12) {
const onRwy = Math.abs(ship.pos.x) < 30 && ship.pos.z > 40 && ship.pos.z < 1700;
const gentle = mission.touchV < 4.2;
const centered = mission.centerline < 14;
endMission(onRwy && gentle, onRwy ? (gentle ? "" : "Firm arrival — sink rate high for this weight.") : "Off the runway.");
}
}
if (mission.t > 420) endMission(false, "Fuel and patience expired.");
} else {
const alive = bandits.filter(b => b.alive).length;
if (mission.kills >= 3 || alive === 0) endMission(true, "");
if (mission.t > 360) endMission(false, "Bandits disengaged. Time.");
if (ship.fuel <= 0 && ship.airspeed < 40) endMission(false, "Fuel starvation.");
}
}
/* ---------- camera ---------- */
const camPos = new THREE.Vector3();
const camLook = new THREE.Vector3();
let orbitA = 0.6;
function updateCamera(dt) {
if (!ship) return;
const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(ship.quat);
const bup = new THREE.Vector3(0, 1, 0).applyQuaternion(ship.quat);
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(ship.quat);
let desired = new THREE.Vector3();
let look = ship.pos.clone().add(fwd.clone().multiplyScalar(12));
const chaseDist = ship.id === "hauler" ? 38 : 22;
const chaseUp = ship.id === "hauler" ? 10 : 6;
if (camMode === 0) {
desired.copy(ship.pos).addScaledVector(fwd, -chaseDist).addScaledVector(bup, chaseUp).add(new THREE.Vector3(0, 2, 0));
} else if (camMode === 1) {
desired.copy(ship.pos).addScaledVector(fwd, ship.id === "hauler" ? 4.2 : 2.4).addScaledVector(bup, ship.id === "hauler" ? 1.7 : 0.85);
look.copy(ship.pos).addScaledVector(fwd, 80).addScaledVector(bup, -1.5);
} else if (camMode === 2) {
desired.set(90, terrainHeight(90, 40) + 28, 40);
look.copy(ship.pos);
} else {
orbitA += dt * 0.15;
desired.copy(ship.pos).add(new THREE.Vector3(Math.sin(orbitA) * 40, 14, Math.cos(orbitA) * 40));
look.copy(ship.pos);
}
const k = 1 - Math.exp(-dt * (camMode === 1 ? 18 : 4.5));
camPos.lerp(desired, camMode === 2 ? 1 : k);
camLook.lerp(look, camMode === 2 ? 1 : k);
// collision pull-in
const gh = terrainHeight(camPos.x, camPos.z);
if (camPos.y < gh + 2) camPos.y = gh + 2;
const sh = shake;
camera.position.copy(camPos);
if (sh > 0) camera.position.add(new THREE.Vector3((Math.random()-0.5)*sh*2, (Math.random()-0.5)*sh*2, (Math.random()-0.5)*sh));
camera.lookAt(camLook);
camera.fov = camMode === 1 ? 70 : 62;
camera.updateProjectionMatrix();
sun.position.set(ship.pos.x - 900, ship.pos.y + 1400, ship.pos.z + 600);
sun.target.position.copy(ship.pos);
skyMesh.position.copy(ship.pos);
waterMesh.position.x = ship.pos.x;
waterMesh.position.z = ship.pos.z;
}
/* ---------- HUD ---------- */
const hud = $("hud");
function resizeHud() {
const dpr = Math.min(devicePixelRatio || 1, 1.6);
hud.width = Math.floor(window.innerWidth * dpr);
hud.height = Math.floor(window.innerHeight * dpr);
hud.style.width = "100%";
hud.style.height = "100%";
}
resizeHud();
window.addEventListener("resize", resizeHud);
function drawHUD() {
const ctx = hud.getContext("2d");
const w = hud.width, h = hud.height;
ctx.clearRect(0, 0, w, h);
if (mode !== "fly" && mode !== "pause") return;
if (!ship) return;
const dpr = w / window.innerWidth;
ctx.save();
ctx.scale(dpr, dpr);
const W = window.innerWidth, H = window.innerHeight;
ctx.strokeStyle = "rgba(157,255,200,0.85)";
ctx.fillStyle = "rgba(157,255,200,0.9)";
ctx.lineWidth = 1.4;
ctx.font = "13px Share Tech Mono, monospace";
const cx = W / 2, cy = H / 2 + 10;
// pitch ladder
const pitch = pitchAngle(ship);
const roll = Math.atan2(
new THREE.Vector3(1, 0, 0).applyQuaternion(ship.quat).y,
new THREE.Vector3(0, 1, 0).applyQuaternion(ship.quat).y
);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-roll);
ctx.beginPath();
ctx.arc(0, 0, 150, 0, Math.PI * 2);
ctx.globalAlpha = 0.25;
ctx.stroke();
ctx.globalAlpha = 1;
const ppx = 7.2; // px per degree
for (let deg = -30; deg <= 40; deg += 5) {
const y = (pitch * RAD - deg) * ppx;
if (Math.abs(y) > 160) continue;
const major = deg % 10 === 0;
const len = major ? 54 : 26;
ctx.beginPath();
ctx.moveTo(-len, y);
ctx.lineTo(deg === 0 ? -8 : -len + (major ? 54 : 26), y);
ctx.moveTo(8, y);
ctx.lineTo(len, y);
ctx.strokeStyle = deg < 0 ? "rgba(255,140,90,0.85)" : "rgba(157,255,200,0.9)";
ctx.stroke();
if (major) {
ctx.fillStyle = ctx.strokeStyle;
ctx.font = "11px Share Tech Mono, monospace";
ctx.fillText(String(Math.abs(deg)), len + 4, y + 4);
ctx.fillText(String(Math.abs(deg)), -len - 22, y + 4);
}
}
ctx.restore();
// waterline / FPV
const fpvX = cx + clamp(ship.aos * RAD, -25, 25) * 3.2;
const fpvY = cy + clamp(ship.aoa * RAD, -25, 25) * 3.2;
ctx.strokeStyle = "rgba(231,196,110,0.95)";
ctx.beginPath();
ctx.arc(fpvX, fpvY, 8, 0, Math.PI * 2);
ctx.moveTo(fpvX - 16, fpvY); ctx.lineTo(fpvX - 8, fpvY);
ctx.moveTo(fpvX + 8, fpvY); ctx.lineTo(fpvX + 16, fpvY);
ctx.moveTo(fpvX, fpvY + 8); ctx.lineTo(fpvX, fpvY + 14);
ctx.stroke();
// gun piper
ctx.strokeStyle = "rgba(255,90,90,0.8)";
ctx.beginPath();
ctx.arc(cx, cy - 8, 4, 0, Math.PI * 2);
ctx.stroke();
// left tape — airspeed
tape(ctx, 36, cy - 110, 70, 220, "IAS", Math.round(ship.ias * KT), "KT", 20);
tape(ctx, W - 106, cy - 110, 70, 220, "ALT", Math.round(ship.alt * FT), "FT", 100, true);
// VS
ctx.fillStyle = "rgba(157,255,200,0.9)";
ctx.font = "12px Share Tech Mono, monospace";
ctx.fillText("VS " + (ship.vs >= 0 ? "+" : "") + Math.round(ship.vs * FT) + " fpm", W - 118, cy + 130);
// bottom cluster
const baseY = H - 78;
ctx.fillStyle = "rgba(6,10,16,0.55)";
roundRect(ctx, 24, baseY - 8, 280, 62, 8);
ctx.fill();
ctx.fillStyle = "#e8eef6";
ctx.font = "12px Share Tech Mono, monospace";
ctx.fillText(ship.cfg.name, 36, baseY + 10);
ctx.fillStyle = "#9dffc8";
ctx.fillText("THR " + Math.round(ship.throttle * 100) + "% RPM " + Math.round(ship.rpm * 100), 36, baseY + 28);
ctx.fillText((ship.gear ? "GEAR DOWN" : "GEAR UP") + " FLAP " + Math.round(ship.flaps * 100) + " " + (sas ? "SAS" : "SAS OFF"), 36, baseY + 44);
// throttle bar
ctx.strokeStyle = "rgba(231,164,74,0.8)";
ctx.strokeRect(W / 2 - 80, H - 36, 160, 8);
ctx.fillStyle = ship.ab ? "#ff6633" : "#e7a44a";
ctx.fillRect(W / 2 - 80, H - 36, 160 * ship.throttle, 8);
// right status
ctx.textAlign = "left";
const lines = [
"AOA " + (ship.aoa * RAD).toFixed(1) + "°",
"G " + ship.g.toFixed(2),
"MACH " + ship.mach.toFixed(2),
"HDG " + pad3(Math.round(((headingOf(ship) * RAD) % 360 + 360) % 360)),
"AGL " + Math.round(ship.agl * FT) + " ft",
"FUEL " + Math.round(ship.fuel * 100) + "%",
"EGT " + Math.round(420 + ship.heat * 380) + "°"
];
ctx.font = "13px Share Tech Mono, monospace";
lines.forEach((ln, i) => {
ctx.fillStyle = i === 0 && ship.aoaAbs > ship.cfg.stallAoA * 0.8 ? "#ff5a5a" : "#9dffc8";
ctx.fillText(ln, W - 150, 78 + i * 18);
});
// mission text
ctx.fillStyle = "rgba(231,196,110,0.95)";
ctx.font = "14px Share Tech Mono, monospace";
ctx.textAlign = "center";
let mt = "";
if (mission && mission.type === "pattern") {
mt = ship.onGround && mission.airborneTime < 1 ? "HOLD BRAKES · SET POWER · ROTATE AT " + Math.round(stallSpeed(ship.cfg, ship.flaps) * KT * 1.15) + " KT"
: ship.airborne ? "PATTERN — CLIMB, TURN DOWNWIND, LAND RUNWAY 36 (NORTH)" : "ON THE SURFACE";
if (mission.airborneTime > 8 && ship.airborne) mt = "CONFIGURE — GEAR, FLAPS, AIM THE THRESHOLD";
} else if (mission) {
const alive = bandits.filter(b => b.alive).length;
mt = "BANDITS " + alive + " · KILLS " + mission.kills + " · HULL " + Math.max(0, Math.round(ship.hp));
}
ctx.fillText(mt, W / 2, 36);
// radar for dogfight
if (mission && mission.type === "dogfight") {
const rx = 92, ry = H - 150, rr = 58;
ctx.beginPath();
ctx.arc(rx, ry, rr, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(157,255,200,0.4)";
ctx.stroke();
ctx.beginPath();
ctx.moveTo(rx, ry - rr); ctx.lineTo(rx, ry + rr);
ctx.moveTo(rx - rr, ry); ctx.lineTo(rx + rr, ry);
ctx.stroke();
const hdg = headingOf(ship);
bandits.forEach(b => {
if (!b.alive) return;
const dx = b.pos.x - ship.pos.x;
const dz = b.pos.z - ship.pos.z;
const dist = Math.hypot(dx, dz);
const ang = Math.atan2(dx, dz) - hdg;
const sc = clamp(dist / 2500, 0, 1) * (rr - 6);
const x = rx + Math.sin(ang) * sc;
const y = ry - Math.cos(ang) * sc;
ctx.fillStyle = "#ff5a5a";
ctx.fillRect(x - 3, y - 3, 6, 6);
});
ctx.fillStyle = "#9dffc8";
ctx.beginPath();
ctx.moveTo(rx, ry - 6); ctx.lineTo(rx - 4, ry + 4); ctx.lineTo(rx + 4, ry + 4); ctx.closePath();
ctx.fill();
}
// stall / gear warning
const stalling = ship.airborne && ship.aoaAbs > ship.cfg.stallAoA * 0.88 && ship.airspeed > 20;
$("warn-banner").className = stalling ? "on" : "";
$("warn-banner").textContent = stalling ? "STALL" : (ship.airborne && ship.gear && ship.ias > ship.cfg.vne * 0.45 ? "GEAR" : "");
if (ship.airborne && ship.gear && ship.ias * KT > 220) {
$("warn-banner").className = "on";
$("warn-banner").textContent = "GEAR";
}
const gWarn = Math.abs(ship.g) > ship.cfg.maxG * 0.92;
if (gWarn) {
$("warn-banner").className = "on";
$("warn-banner").textContent = "OVER G";
}
$("vignette").style.boxShadow = stalling
? "inset 0 0 160px 50px rgba(180,20,20,0.45)"
: (Math.abs(ship.g) > 5 ? "inset 0 0 200px 70px rgba(0,0,0,0.55)" : "inset 0 0 180px 40px rgba(0,0,0,0.45)");
ctx.restore();
}
function pad3(n) { return String(n).padStart(3, "0"); }
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function tape(ctx, x, y, w, h, label, value, unit) {
ctx.fillStyle = "rgba(6,10,16,0.45)";
roundRect(ctx, x, y, w, h, 6);
ctx.fill();
ctx.strokeStyle = "rgba(157,255,200,0.45)";
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = "#9dffc8";
ctx.font = "11px Share Tech Mono, monospace";
ctx.textAlign = "center";
ctx.fillText(label, x + w / 2, y + 16);
ctx.font = "22px Share Tech Mono, monospace";
ctx.fillStyle = "#f4f7fb";
ctx.fillText(String(value), x + w / 2, y + h / 2 + 6);
ctx.font = "11px Share Tech Mono, monospace";
ctx.fillStyle = "#e7a44a";
ctx.fillText(unit, x + w / 2, y + h - 12);
ctx.textAlign = "left";
}
/* ---------- input ---------- */
window.addEventListener("keydown", (e) => {
keys[e.code] = true;
if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) e.preventDefault();
if (e.repeat) return;
if (e.code === "KeyH") $("help").style.display = $("help").style.display === "none" ? "block" : "none";
if (e.code === "KeyM") setMuted(!audio.muted);
if (mode === "fly") {
if (e.code === "KeyC") camMode = (camMode + 1) % 4;
if (e.code === "KeyL") sas = !sas;
if (e.code === "KeyG") { ship.gear = !ship.gear; blip(ship.gear ? 220 : 160, 0.12, "sine", 0.07); }
if (e.code === "KeyF") { ship.flaps = ship.flaps >= 1 ? 0 : +(ship.flaps + 0.5).toFixed(2); blip(300 + ship.flaps * 80, 0.08, "triangle", 0.05); }
if (e.code === "KeyB") ship.brake = !ship.brake;
if (e.code === "BracketLeft") ship.trim = clamp(ship.trim - 0.05, -0.4, 0.4);
if (e.code === "BracketRight") ship.trim = clamp(ship.trim + 0.05, -0.4, 0.4);
if (e.code === "Escape") {
mode = "pause";
$("pause").style.display = "flex";
}
if (e.code === "KeyR") startMission();
} else if (mode === "pause" && e.code === "Escape") {
mode = "fly";
$("pause").style.display = "none";
}
});
window.addEventListener("keyup", (e) => { keys[e.code] = false; if (e.code === "KeyB") { /* toggle, ignore */ } });
window.addEventListener("mousedown", () => {
if (mode === "menu") return;
const c = $("view");
if (c.requestPointerLock) c.requestPointerLock();
});
document.addEventListener("pointerlockchange", () => {
mouse.locked = document.pointerLockElement === $("view");
});
window.addEventListener("mousemove", (e) => {
if (mode !== "fly") return;
if (mouse.locked) {
mouse.x = clamp(mouse.x + e.movementX * 0.0022, -1, 1);
mouse.y = clamp(mouse.y + e.movementY * 0.0022, -1, 1);
} else {
mouse.x = clamp((e.clientX / window.innerWidth) * 2 - 1, -1, 1);
mouse.y = clamp((e.clientY / window.innerHeight) * 2 - 1, -1, 1);
}
});
// spring stick back when no mouse movement? gentle center if not locked
setInterval(() => {
if (mode === "fly" && !mouse.locked) {
// leave absolute mouse mapping
} else if (mode === "fly" && mouse.locked) {
mouse.x *= 0.92;
mouse.y *= 0.92;
}
}, 40);
$("launch").onclick = () => startMission();
$("resume").onclick = () => { mode = "fly"; $("pause").style.display = "none"; };
$("abort").onclick = () => toMenu();
$("retry").onclick = () => startMission();
$("tomenu").onclick = () => toMenu();
function toMenu() {
mode = "menu";
if (ship) { world.remove(ship.mesh); ship = null; }
bandits.forEach(b => world.remove(b.mesh));
bandits = [];
showcase.forEach(s => { s.mesh.visible = true; });
$("topbar").style.display = "flex";
$("sheet").style.display = "block";
$("pause").style.display = "none";
$("debrief").style.display = "none";
$("help").style.display = "none";
$("warn-banner").className = "";
if (document.exitPointerLock) document.exitPointerLock();
updateTerrain(0, 600);
}
function onSelectCraft() {
showcase.forEach(s => {
s.mesh.traverse(o => {
if (o.isMesh && o.material && o.material.emissive) {
/* noop */
}
});
});
}
/* ---------- loop ---------- */
let last = performance.now();
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
if (waterMesh) waterMesh.material.uniforms.uTime.value = now * 0.001;
if (mode === "menu") {
const t = now * 0.001;
showcase.forEach((s, i) => {
const cfg = AIRCRAFT[s.id];
const gy = terrainHeight(s.baseX, 480) + cfg.gearH * (s.id === "hauler" ? 0.72 : 1);
s.mesh.position.y = gy + Math.sin(t * 1.2 + i) * 0.15;
s.mesh.rotation.y = Math.sin(t * 0.35 + i) * 0.25 + (s.id === selectedCraft ? 0.4 : -0.2);
const sc = s.id === selectedCraft ? 1.05 : 0.92;
const base = s.id === "hauler" ? 0.72 : 1;
s.mesh.scale.setScalar(base * sc);
});
camera.position.set(Math.sin(t * 0.12) * 22, 22, 400);
camera.lookAt(2, 4, 520);
skyMesh.position.copy(camera.position);
sun.target.position.set(0, 0, 500);
renderer.render(scene, camera);
return;
}
if (mode === "fly") {
clock += dt;
if (keys.Space) fire(ship, true);
if (ship.fireCd > 0) ship.fireCd -= dt;
integrateShip(ship, dt, null, true);
bandits.forEach(b => { if (b.fireCd > 0) b.fireCd -= dt; updateBandit(b, dt); });
updateTracers(dt);
updateMission(dt);
updateAudio(ship, dt);
if (ship.onGround && ship.vel.length() > 8 && Math.random() < 0.4) {
const p = ship.pos.clone();
p.y = terrainHeight(p.x, p.z) + 0.3;
spawnParticle(p, new THREE.Vector3((Math.random()-0.5)*4, 2 + Math.random()*3, (Math.random()-0.5)*4), 0.7, COL.dust, 8);
}
// PAPI: 3° glide
if (papiMeshes.length) {
const dz = 150 - ship.pos.z;
const glide = dz > 20 ? Math.atan2(ship.pos.y - 2, dz) * RAD : 3;
papiMeshes.forEach((m, i) => {
const thr = 2.5 + i * 0.35;
const red = glide < thr;
m.material.emissive.set(red ? 0xff2200 : 0xffffff);
m.material.color.set(red ? 0xff2200 : 0xffffff);
});
}
}
shake *= Math.exp(-dt * 3.5);
updateParticles(dt);
if (ship) updateTerrain(ship.pos.x, ship.pos.z);
updateCamera(dt);
if (clouds) {
clouds.children.forEach((c, i) => { c.position.x += Math.sin(i) * dt * 1.5; });
}
renderer.render(scene, camera);
if (mode === "fly" || mode === "pause") drawHUD();
}
function boot() {
bootThree();
buildShowcase();
renderMenu();
updateTerrain(0, 600);
$("boot").style.display = "none";
requestAnimationFrame(frame);
}
boot();
</script>
</body>
</html>