Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 25, 2026 20:52
Show Gist options
  • Select an option

  • Save senko/62b681a72e9e7371915ce2707a83ea67 to your computer and use it in GitHub Desktop.

Select an option

Save senko/62b681a72e9e7371915ce2707a83ea67 to your computer and use it in GitHub Desktop.
Biplane dogfight game by Opus 5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>Ace of the Marne — WW1 Dogfight</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230e0f0c'/%3E%3Cg fill='%23d8b45c'%3E%3Crect x='3' y='12' width='26' height='2.6'/%3E%3Crect x='6' y='18' width='20' height='2.2'/%3E%3Crect x='14.6' y='6' width='2.8' height='20'/%3E%3Crect x='11' y='24' width='10' height='2'/%3E%3C/g%3E%3C/svg%3E">
<style>
:root{
--brass:#d8b45c; --brass-dim:#8d7436; --ink:#0e0f0c; --linen:#e6dcc3;
--blood:#c2402f; --olive:#5a6141; --panel:rgba(14,15,12,.55);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;height:100%;overflow:hidden;background:#0b0d10;
font-family:"Courier New",Courier,monospace;color:var(--linen);
-webkit-font-smoothing:antialiased;user-select:none;cursor:default}
canvas{display:block;position:absolute;inset:0}
#hud{position:absolute;inset:0;pointer-events:none;overflow:hidden}
.hidden{display:none!important}
/* ---------- gunsight ---------- */
#sight{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:230px;height:230px;opacity:.9}
#sight .ring{fill:none;stroke:#ffe9a8;stroke-opacity:.55;stroke-width:1.4}
#sight .tick{stroke:#ffe9a8;stroke-opacity:.75;stroke-width:1.6}
#sight .dot{fill:#ffe9a8;fill-opacity:.9}
#leadpip{position:absolute;width:34px;height:34px;margin:-17px 0 0 -17px;
border:2px solid rgba(255,90,60,.95);border-radius:50%;
box-shadow:0 0 8px rgba(255,90,60,.6);opacity:0;transition:opacity .1s}
#leadpip::after{content:'';position:absolute;left:50%;top:50%;width:3px;height:3px;
margin:-1.5px 0 0 -1.5px;background:rgba(255,120,90,1);border-radius:50%}
#hitmark{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:44px;height:44px;opacity:0}
#hitmark div{position:absolute;width:14px;height:2px;background:#fff2c0;
box-shadow:0 0 6px #ffd36a}
/* ---------- panels ---------- */
.panel{position:absolute;background:linear-gradient(180deg,rgba(20,22,17,.72),rgba(12,13,10,.72));
border:1px solid rgba(216,180,92,.35);border-radius:3px;padding:7px 10px;
box-shadow:0 2px 14px rgba(0,0,0,.5), inset 0 0 22px rgba(0,0,0,.35);
backdrop-filter:blur(2px)}
.lbl{font-size:9px;letter-spacing:.18em;color:var(--brass-dim);text-transform:uppercase}
.val{font-size:26px;line-height:1.05;color:var(--brass);font-weight:700;letter-spacing:.02em;
text-shadow:0 0 10px rgba(216,180,92,.35)}
.val small{font-size:11px;color:var(--brass-dim);font-weight:400;letter-spacing:.1em}
.sub{font-size:11px;color:#b9ad8d}
#p-speed{left:22px;bottom:96px;min-width:132px}
#p-alt{right:22px;bottom:96px;min-width:150px;text-align:right}
#p-eng{left:22px;bottom:22px;min-width:132px}
#p-guns{right:22px;bottom:22px;min-width:150px;text-align:right}
#p-score{left:50%;top:14px;transform:translateX(-50%);text-align:center;min-width:250px}
.bar{height:7px;background:rgba(0,0,0,.55);border:1px solid rgba(216,180,92,.28);
margin-top:4px;position:relative;overflow:hidden;border-radius:2px}
.bar i{display:block;height:100%;width:0%;transition:width .08s linear}
#thr-b i{background:linear-gradient(90deg,#7a8f3f,#cfe07a)}
#heat-b i{background:linear-gradient(90deg,#c9a13c,#e8622f)}
#hp-b i{background:linear-gradient(90deg,#7a2f28,#c85a3c)}
#ammo-b i{background:linear-gradient(90deg,#8a7a3a,#d8c072)}
/* ---------- compass ---------- */
#compass{position:absolute;left:50%;top:64px;transform:translateX(-50%);
width:min(520px,60vw);height:30px;overflow:hidden;
-webkit-mask-image:linear-gradient(90deg,transparent,#000 18%,#000 82%,transparent);
mask-image:linear-gradient(90deg,transparent,#000 18%,#000 82%,transparent)}
#compass-strip{position:absolute;top:0;left:0;height:100%;will-change:transform}
#compass-strip span{position:absolute;top:0;font-size:11px;color:var(--brass);
transform:translateX(-50%);letter-spacing:.08em}
#compass-strip b{position:absolute;top:19px;width:1px;height:8px;background:rgba(216,180,92,.6)}
#compass::after{content:'';position:absolute;left:50%;top:0;width:2px;height:12px;
background:var(--blood);transform:translateX(-50%)}
/* ---------- warnings ---------- */
#warn{position:absolute;left:50%;top:110px;transform:translateX(-50%);
display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
.wl{font-size:12px;letter-spacing:.22em;padding:4px 12px;border-radius:2px;
border:1px solid currentColor;background:rgba(0,0,0,.45);opacity:0}
.wl.on{opacity:1;animation:blink .6s steps(2,end) infinite}
@keyframes blink{50%{opacity:.28}}
.w-stall{color:#ffb03a}.w-ground{color:#ff4a32}.w-six{color:#ff4a32}.w-ammo{color:#ffe08a}
.w-g{color:#ff8a3a}
/* ---------- indicators ---------- */
.blip{position:absolute;width:26px;height:26px;margin:-13px 0 0 -13px;
opacity:0;will-change:transform}
.blip svg{width:100%;height:100%;display:block}
.tgtbox{position:absolute;width:0;height:0;opacity:0;will-change:transform}
.tgtbox i{position:absolute;width:9px;height:9px;border:2px solid rgba(255,105,70,.9)}
.tgtbox .d{position:absolute;left:0;top:20px;transform:translateX(-50%);
font-size:10px;color:#ff9b7a;letter-spacing:.1em;white-space:nowrap;text-shadow:0 0 6px #000}
/* ---------- stick indicator ---------- */
#stick{position:absolute;right:26px;top:50%;transform:translateY(-50%);
width:74px;height:74px;border:1px solid rgba(216,180,92,.3);border-radius:50%;
background:rgba(0,0,0,.3)}
#stick::before,#stick::after{content:'';position:absolute;background:rgba(216,180,92,.18)}
#stick::before{left:0;right:0;top:50%;height:1px}
#stick::after{top:0;bottom:0;left:50%;width:1px}
#stick-k{position:absolute;left:50%;top:50%;width:11px;height:11px;margin:-5.5px 0 0 -5.5px;
border-radius:50%;background:var(--brass);box-shadow:0 0 8px rgba(216,180,92,.7)}
/* ---------- overlays ---------- */
#vig{position:absolute;inset:0;pointer-events:none;
background:radial-gradient(ellipse at center,transparent 42%,rgba(0,0,0,.55) 100%)}
#redflash{position:absolute;inset:0;pointer-events:none;opacity:0;
background:radial-gradient(ellipse at center,transparent 42%,rgba(150,20,10,.8) 100%)}
#oil{position:absolute;inset:0;pointer-events:none;opacity:0;mix-blend-mode:multiply}
/* ---------- screens ---------- */
.screen{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:radial-gradient(ellipse at center,rgba(10,12,9,.82),rgba(6,7,5,.96));
pointer-events:auto;z-index:20}
.card{max-width:min(860px,92vw);padding:28px 34px;text-align:center;
border:1px solid rgba(216,180,92,.3);border-radius:4px;
background:linear-gradient(180deg,rgba(24,26,20,.9),rgba(12,14,10,.95));
box-shadow:0 20px 70px rgba(0,0,0,.7)}
h1{font-size:34px;letter-spacing:.26em;color:var(--brass);font-weight:700;margin-bottom:2px;
text-shadow:0 0 22px rgba(216,180,92,.3)}
h2{font-size:13px;letter-spacing:.34em;color:#9c8f6d;font-weight:400;margin-bottom:20px}
.keys{display:grid;grid-template-columns:repeat(auto-fit,minmax(252px,1fr));gap:6px 26px;
text-align:left;margin:16px 0 18px;font-size:12px;color:#c8bd9d}
.keys>div{display:flex;gap:10px;align-items:baseline;white-space:nowrap}
.keys b{flex:0 0 86px;color:var(--brass);font-weight:700}
.hint{font-size:12px;color:#8e845f;line-height:1.7;margin-top:6px}
.go{display:inline-block;margin-top:12px;padding:11px 30px;font-size:14px;letter-spacing:.22em;
border:1px solid var(--brass);color:var(--brass);border-radius:3px;cursor:pointer;
background:rgba(216,180,92,.06);transition:.15s;font-family:inherit}
.go:hover{background:rgba(216,180,92,.18);box-shadow:0 0 22px rgba(216,180,92,.25)}
.big{font-size:52px;color:var(--brass);letter-spacing:.05em;margin:6px 0}
.dead{color:var(--blood)}
#banner{position:absolute;left:50%;top:26%;transform:translateX(-50%);text-align:center;
opacity:0;pointer-events:none}
#banner .t{font-size:30px;letter-spacing:.3em;color:var(--brass);
text-shadow:0 0 26px rgba(0,0,0,.9),0 0 12px rgba(216,180,92,.4)}
#banner .s{font-size:12px;letter-spacing:.24em;color:#c8bd9d;margin-top:6px}
#msg{position:absolute;left:50%;bottom:150px;transform:translateX(-50%);text-align:center;
font-size:12px;letter-spacing:.2em;color:#c8bd9d;opacity:0;text-shadow:0 0 8px #000}
#fps{position:absolute;left:22px;top:14px;font-size:10px;color:#6f6a52;letter-spacing:.1em}
#kills{position:absolute;right:22px;top:14px;font-size:10px;color:#6f6a52;letter-spacing:.1em;
text-align:right;line-height:1.7}
#boot{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
flex-direction:column;gap:14px;background:#0b0d10;z-index:30;color:var(--brass);
font-size:13px;letter-spacing:.3em}
.spin{width:26px;height:26px;border:2px solid rgba(216,180,92,.2);
border-top-color:var(--brass);border-radius:50%;animation:sp 1s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
@media (max-width:720px){
.val{font-size:19px}.panel{padding:5px 8px}
#p-speed,#p-eng{left:10px}#p-alt,#p-guns{right:10px}
#stick{display:none}
}
</style>
</head>
<body>
<script>
// Early error capture so even module load failures are visible to tests.
window.__errors = [];
window.addEventListener('error', function (e) {
window.__errors.push(String((e && (e.message || e.error)) || 'error'));
});
window.addEventListener('unhandledrejection', function (e) {
window.__errors.push('unhandledrejection: ' + String(e.reason));
});
</script>
<div id="hud">
<div id="vig"></div>
<div id="redflash"></div>
<div id="oil"></div>
<svg id="sight" viewBox="0 0 230 230">
<circle class="ring" cx="115" cy="115" r="86"/>
<circle class="ring" cx="115" cy="115" r="44"/>
<circle class="dot" cx="115" cy="115" r="1.8"/>
<line class="tick" x1="115" y1="12" x2="115" y2="30"/>
<line class="tick" x1="115" y1="200" x2="115" y2="218"/>
<line class="tick" x1="12" y1="115" x2="30" y2="115"/>
<line class="tick" x1="200" y1="115" x2="218" y2="115"/>
<line class="tick" x1="115" y1="66" x2="115" y2="74" stroke-opacity=".5"/>
<line class="tick" x1="115" y1="156" x2="115" y2="164" stroke-opacity=".5"/>
</svg>
<div id="leadpip"></div>
<div id="hitmark">
<div style="left:0;top:8px;transform:rotate(45deg)"></div>
<div style="right:0;top:8px;transform:rotate(-45deg)"></div>
<div style="left:0;bottom:8px;transform:rotate(-45deg)"></div>
<div style="right:0;bottom:8px;transform:rotate(45deg)"></div>
</div>
<div id="compass"><div id="compass-strip"></div></div>
<div id="warn">
<div class="wl w-stall" id="w-stall">STALL</div>
<div class="wl w-ground" id="w-ground">PULL UP</div>
<div class="wl w-six" id="w-six">ON YOUR SIX</div>
<div class="wl w-g" id="w-g">AIRFRAME</div>
<div class="wl w-ammo" id="w-ammo">LOW AMMO</div>
</div>
<div class="panel" id="p-speed">
<div class="lbl">Airspeed</div>
<div class="val"><span id="v-kph">0</span><small> km/h</small></div>
<div class="sub"><span id="v-ms">0</span> m/s &middot; AoA <span id="v-aoa">0</span>&deg;</div>
</div>
<div class="panel" id="p-alt">
<div class="lbl">Altitude</div>
<div class="val"><span id="v-alt">0</span><small> m</small></div>
<div class="sub">AGL <span id="v-agl">0</span> &middot; VS <span id="v-vs">0</span></div>
</div>
<div class="panel" id="p-eng">
<div class="lbl">Throttle / Engine</div>
<div class="val"><span id="v-thr">0</span><small> %</small></div>
<div class="bar" id="thr-b"><i></i></div>
<div class="lbl" style="margin-top:6px">Airframe</div>
<div class="bar" id="hp-b"><i style="width:100%"></i></div>
</div>
<div class="panel" id="p-guns">
<div class="lbl">Vickers .303</div>
<div class="val"><span id="v-ammo">500</span><small> rds</small></div>
<div class="bar" id="ammo-b"><i style="width:100%"></i></div>
<div class="lbl" style="margin-top:6px">Breech heat</div>
<div class="bar" id="heat-b"><i></i></div>
</div>
<div class="panel" id="p-score">
<div class="lbl">Patrol</div>
<div class="val" style="font-size:20px">
<span id="v-score">0</span><small> pts</small>
<span style="color:#6f6a52">|</span>
<small>WAVE</small> <span id="v-wave">1</span>
<span style="color:#6f6a52">|</span>
<small>FOE</small> <span id="v-foes">0</span>
</div>
</div>
<div id="stick"><div id="stick-k"></div></div>
<div id="fps">-- fps</div>
<div id="kills">KILLS 0<br>STREAK 0<br>BEST 0</div>
<div id="banner"><div class="t"></div><div class="s"></div></div>
<div id="msg"></div>
</div>
<div class="screen" id="scr-start">
<div class="card">
<h1>ACE OF THE MARNE</h1>
<h2>WESTERN FRONT &mdash; SPRING 1917</h2>
<div class="hint">
You are airborne over the lines in a Sopwith scout. Hostile jastas are inbound in waves.<br>
Keep your speed up, watch your six, and don't fly into the ground.
</div>
<div class="keys">
<div><b>MOUSE</b> <span>stick &mdash; pitch / roll</span></div>
<div><b>LMB / SPACE</b> <span>fire Vickers</span></div>
<div><b>W / S</b> <span>throttle up / down</span></div>
<div><b>A / D</b> <span>rudder left / right</span></div>
<div><b>RMB hold</b> <span>look around</span></div>
<div><b>C</b> <span>cycle camera</span></div>
<div><b>N</b> <span>mouse / keyboard stick</span></div>
<div><b>ARROWS</b> <span>stick in keyboard mode</span></div>
<div><b>I</b> <span>invert pitch axis</span></div>
<div><b>K</b> <span>auto rudder coordination</span></div>
<div><b>[ / ]</b> <span>mouse sensitivity</span></div>
<div><b>X</b> <span>centre the stick</span></div>
<div><b>Z</b> <span>self-centring stick</span></div>
<div><b>M / F</b> <span>mute &middot; fullscreen</span></div>
<div><b>P / ESC</b> <span>pause</span></div>
</div>
<div class="hint" id="start-note">
Pitch is <b id="inv-state">SIM (mouse back = nose up)</b> &mdash; press <b>I</b> to change.
</div>
<button class="go" id="btn-start">TAKE OFF</button>
</div>
</div>
<div class="screen hidden" id="scr-pause">
<div class="card">
<h1 style="font-size:26px">PATROL SUSPENDED</h1>
<h2>engine idling</h2>
<div class="keys">
<div><b>MOUSE</b> <span>stick</span></div><div><b>LMB / SPACE</b> <span>fire Vickers</span></div>
<div><b>W / S</b> <span>throttle</span></div><div><b>A / D</b> <span>rudder</span></div>
<div><b>RMB hold</b> <span>look around</span></div><div><b>C</b> <span>cycle camera</span></div>
<div><b>N</b> <span>control mode</span></div><div><b>I</b> <span>invert pitch</span></div>
<div><b>K</b> <span>auto rudder</span></div><div><b>[ / ]</b> <span>sensitivity</span></div>
<div><b>Z</b> <span>self-centring stick</span></div><div><b>X</b> <span>centre the stick</span></div>
</div>
<div class="hint" id="pause-cfg"></div>
<button class="go" id="btn-resume">RESUME</button>
</div>
</div>
<div class="screen hidden" id="scr-dead">
<div class="card">
<h1 class="dead" style="font-size:28px" id="dead-title">SHOT DOWN</h1>
<h2 id="dead-sub">the squadron will note your absence</h2>
<div class="big"><span id="dead-score">0</span> <small style="font-size:14px">PTS</small></div>
<div class="hint">
<span id="dead-kills">0</span> confirmed victories &middot; reached wave <span id="dead-wave">1</span>
</div>
<button class="go" id="btn-respawn">BACK INTO THE AIR</button>
</div>
</div>
<div id="boot"><div class="spin"></div><div>WARMING THE ROTARY&hellip;</div></div>
<script>
// Classic scripts run before deferred modules, so this timer is armed before the
// game module is even fetched.
setTimeout(function () {
if (window.__sim) return;
var b = document.getElementById('boot');
if (!b) return;
b.innerHTML = '<div style="max-width:540px;text-align:center;line-height:1.9">' +
'COULD NOT START' +
'<div style="font-size:11px;letter-spacing:.08em;color:#8e845f;margin-top:12px">' +
'This page loads three.js from a CDN, so it needs a working internet connection ' +
'the first time you open it. Check your connection and reload.' +
(window.__errors && window.__errors.length
? '<br><br><span style="color:#c2402f">' + String(window.__errors[0]).slice(0, 220) + '</span>'
: '') +
'</div></div>';
}, 15000);
</script>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';
/* ==========================================================================
0. CONFIG
========================================================================== */
const CFG = {
seed: 20250725,
world: 10000, // terrain extent (m)
seg: 384, // terrain grid resolution
fogDensity: 0.000105,
maxAlive: 6, // simultaneous hostiles in the air
fixed: 1 / 120, // physics timestep (s)
};
const AERO = {
mass: 720, S: 22, span: 8.8, chord: 1.45,
Ipitch: 2100, Iyaw: 3200, Iroll: 2200, // about body x, y, z
CL0: 0.12, CLa: 5.0, aStall: 0.26,
CD0: 0.045, kInd: 0.055,
thrust: 1900,
Cm0: 0.028, Cm_de: 0.60, Cm_a: 1.5, Cm_q: 12.0, // Cm0 trims for hands-off cruise
stickForceQ: 1700, // dynamic pressure above which the stick goes heavy
Cl_da: 0.08, Cl_p: 0.45, Cl_b: 0.06,
Cn_dr: 0.09, Cn_r: 0.9, Cn_b: 0.13,
CY_b: 0.6,
gyro: 0.09, torqueRoll: 0.055,
stallRoll: 0.038, stallYaw: 0.014,
};
const GUN = {
rpm: 1150, // twin Vickers, combined
muzzle: 590, // m/s
spread: 0.0032, // rad
ammo: 500,
heatPerShot: 0.030, // ~3s of continuous fire before the breech jams
cool: 0.25, // per second
damage: 5.5,
range: 1600,
};
/* ==========================================================================
1. SMALL MATH HELPERS
========================================================================== */
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const lerp = (a, b, t) => a + (b - a) * t;
const smoothstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); return t * t * (3 - 2 * t); };
const TAU = Math.PI * 2;
const DEG = 180 / Math.PI;
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
const rng = mulberry32(CFG.seed);
const rand = (a = 1, b) => b === undefined ? rng() * a : a + rng() * (b - a);
const randSym = (a) => (rng() * 2 - 1) * a;
const pick = (arr) => arr[(rng() * arr.length) | 0];
/* --- gradient noise --------------------------------------------------- */
const PERM = new Uint8Array(512);
(function () {
const r = mulberry32(CFG.seed ^ 0x9e3779b9);
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) { const j = (r() * (i + 1)) | 0; const t = p[i]; p[i] = p[j]; p[j] = t; }
for (let i = 0; i < 512; i++) PERM[i] = p[i & 255];
})();
const G2 = [[1, 1], [-1, 1], [1, -1], [-1, -1], [1, 0], [-1, 0], [0, 1], [0, -1]];
const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
function perlin2(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
const X = xi & 255, Y = yi & 255;
const xf = x - xi, yf = y - yi;
const u = fade(xf), v = fade(yf);
const aa = PERM[X + PERM[Y]], ba = PERM[X + 1 + PERM[Y]];
const ab = PERM[X + PERM[Y + 1]], bb = PERM[X + 1 + PERM[Y + 1]];
const d = (h, dx, dy) => { const g = G2[h & 7]; return g[0] * dx + g[1] * dy; };
const x1 = lerp(d(aa, xf, yf), d(ba, xf - 1, yf), u);
const x2 = lerp(d(ab, xf, yf - 1), d(bb, xf - 1, yf - 1), u);
return lerp(x1, x2, v) * 1.42; // ~[-1,1]
}
function fbm(x, y, oct) {
let a = 1, f = 1, s = 0, n = 0;
for (let i = 0; i < oct; i++) { s += a * perlin2(x * f, y * f); n += a; a *= 0.5; f *= 2.03; }
return s / n;
}
function ridged(x, y, oct) {
let a = 1, f = 1, s = 0, n = 0;
for (let i = 0; i < oct; i++) { s += a * (1 - Math.abs(perlin2(x * f, y * f))); n += a; a *= 0.5; f *= 2.07; }
return s / n;
}
/* ==========================================================================
2. RENDERER / SCENE
========================================================================== */
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.insertBefore(renderer.domElement, document.getElementById('hud'));
const scene = new THREE.Scene();
const SKY_TOP = new THREE.Color(0x2f6ec0);
const SKY_HORIZON = new THREE.Color(0xbfd0d8);
const FOG_COLOR = new THREE.Color(0xb9c8ce);
scene.fog = new THREE.FogExp2(FOG_COLOR.getHex(), CFG.fogDensity);
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.4, 24000);
camera.position.set(0, 900, 0);
const SUN_DIR = new THREE.Vector3(0.42, 0.56, 0.72).normalize();
const sun = new THREE.DirectionalLight(0xfff0d4, 3.0);
sun.position.copy(SUN_DIR).multiplyScalar(1000);
scene.add(sun);
scene.add(new THREE.HemisphereLight(0xbcd6f0, 0x63694c, 1.9));
scene.add(new THREE.AmbientLight(0xdfe6ee, 0.35));
/* --- sky dome ---------------------------------------------------------- */
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, depthTest: false, fog: false,
uniforms: {
top: { value: SKY_TOP }, horizon: { value: SKY_HORIZON },
sunDir: { value: SUN_DIR }, sunCol: { value: new THREE.Color(0xfff3d0) },
},
vertexShader: `
varying vec3 vDir;
void main(){
vDir = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
}`,
fragmentShader: `
uniform vec3 top, horizon, sunCol; uniform vec3 sunDir;
varying vec3 vDir;
void main(){
vec3 d = normalize(vDir);
float h = clamp(d.y, -1.0, 1.0);
float t = pow(clamp(h,0.0,1.0), 0.55);
vec3 col = mix(horizon, top, t);
col = mix(col, horizon*0.86, smoothstep(0.0,-0.35,h)); // below horizon
float sd = max(dot(d, normalize(sunDir)), 0.0);
col += sunCol * pow(sd, 420.0) * 3.0; // disc
col += sunCol * pow(sd, 8.0) * 0.22; // bloom
col += sunCol * pow(sd, 2.0) * 0.05 * (1.0 - t); // horizon warmth
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(6000, 32, 20), skyMat);
sky.renderOrder = -1000;
sky.frustumCulled = false;
scene.add(sky);
/* ==========================================================================
3. TERRAIN
========================================================================== */
const WORLD = CFG.world, HALF = WORLD / 2, SEG = CFG.seg, STEP = WORLD / SEG;
const HEIGHT = new Float32Array((SEG + 1) * (SEG + 1));
function rawHeight(x, z) {
const s = 1 / 2400;
const wx = x + 420 * fbm(x * s * 0.45 + 11.3, z * s * 0.45 + 7.1, 2);
const wz = z + 420 * fbm(x * s * 0.45 - 4.7, z * s * 0.45 + 2.9, 2);
let h = 305 + 152 * fbm(wx * s, wz * s, 5);
const mask = smoothstep(0.05, 0.62, fbm(x * s * 0.3 + 55, z * s * 0.3 - 23, 2));
const r = Math.max(0, ridged(wx * s * 0.62 + 31, wz * s * 0.62 - 17, 4));
h += 980 * Math.pow(r, 2.1) * mask;
h += 34 * fbm(x * s * 4.5, z * s * 4.5, 3);
h += 9 * fbm(x * s * 15, z * s * 15, 2);
// river valleys carved along the crests of a ridged field
const riv = ridged(x * s * 0.85 - 61, z * s * 0.85 + 44, 3);
h -= 215 * smoothstep(0.77, 0.97, riv) * smoothstep(860, 250, h);
return h;
}
// "The front": a winding scar of churned earth running roughly north-south.
function frontMask(x, z) {
const wander = 1500 * fbm(z * 0.00028 + 91.7, 4.2, 3);
const width = 220 + 150 * fbm(z * 0.0011 - 12.0, 8.8, 2);
return 1 - smoothstep(width * 0.45, width, Math.abs(x - wander));
}
for (let iz = 0; iz <= SEG; iz++) {
for (let ix = 0; ix <= SEG; ix++) {
const x = -HALF + ix * STEP, z = -HALF + iz * STEP;
let h = rawHeight(x, z);
const f = frontMask(x, z);
if (f > 0) h = lerp(h, h * 0.94 - 6 + 9 * fbm(x * 0.02, z * 0.02, 2), f); // shell-torn
// taper the rim down into the sea so the world edge reads as coastline
const edge = Math.max(Math.abs(x), Math.abs(z)) / HALF;
h = lerp(h, -170, smoothstep(0.925, 1.0, edge));
HEIGHT[iz * (SEG + 1) + ix] = h;
}
}
function heightAt(x, z) {
const fx = clamp((x + HALF) / STEP, 0, SEG - 0.0001);
const fz = clamp((z + HALF) / STEP, 0, SEG - 0.0001);
const ix = fx | 0, iz = fz | 0, tx = fx - ix, tz = fz - iz;
const w = SEG + 1, i = iz * w + ix;
const h00 = HEIGHT[i], h10 = HEIGHT[i + 1], h01 = HEIGHT[i + w], h11 = HEIGHT[i + w + 1];
return lerp(lerp(h00, h10, tx), lerp(h01, h11, tx), tz);
}
/* --- terrain mesh + vertex colours ------------------------------------- */
const terGeo = new THREE.PlaneGeometry(WORLD, WORLD, SEG, SEG);
terGeo.rotateX(-Math.PI / 2);
{
const pos = terGeo.attributes.position;
for (let i = 0; i < pos.count; i++) pos.setY(i, HEIGHT[i]);
pos.needsUpdate = true;
}
terGeo.computeVertexNormals();
// Farmland is drawn as jittered rectangular parcels. Thresholding smooth noise
// instead would band into contour rings, which reads as a topographic map.
function ihash(i, j) {
let h = Math.imul(i, 374761393) + Math.imul(j, 668265263) | 0;
h = Math.imul(h ^ h >>> 13, 1274126177);
return ((h ^ h >>> 16) >>> 0) / 4294967296;
}
function parcelAt(x, z) {
const w = 0.00055;
const wx = x + 330 * fbm(x * w + 5.1, z * w - 2.3, 3) + 55 * fbm(x * 0.0034 + 1.1, z * 0.0034 + 4.2, 2);
const wz = z + 330 * fbm(x * w - 7.7, z * w + 3.9, 3) + 55 * fbm(x * 0.0034 - 6.5, z * 0.0034 - 2.7, 2);
const S = 145;
const cx = Math.floor(wx / S), cz = Math.floor(wz / S);
return { a: ihash(cx, cz), b: ihash(cx + 733, cz - 291) };
}
{
const pos = terGeo.attributes.position, nor = terGeo.attributes.normal;
const col = new Float32Array(pos.count * 3);
const c = new THREE.Color(), tmp = new THREE.Color();
const FIELDS = [0x6f8140, 0x7e8a48, 0x8b8149, 0x62753a, 0x958650, 0x6d6440, 0x788652, 0x86773f];
const fieldCols = FIELDS.map(h => new THREE.Color(h));
const rock = new THREE.Color(0x7c776d), snow = new THREE.Color(0xdfe4e6);
const marsh = new THREE.Color(0x516046), scar = new THREE.Color(0x6a5c47);
const high = new THREE.Color(0x79745a), wood = new THREE.Color(0x3f5730);
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
const slope = 1 - nor.getY(i);
const par = parcelAt(x, z);
c.copy(fieldCols[(par.a * FIELDS.length) | 0]);
// woodland in patches, following a separate low-frequency field
const forest = smoothstep(0.15, 0.55, fbm(x * 0.0011 + 21.7, z * 0.0011 - 8.4, 3)) * (0.35 + par.b * 0.65);
c.lerp(wood, forest * 0.8);
c.lerp(marsh, smoothstep(70, 6, y)); // damp low ground
c.lerp(high, smoothstep(520, 830, y));
c.lerp(rock, clamp(smoothstep(0.24, 0.58, slope) + smoothstep(780, 1000, y) * 0.7, 0, 1));
c.lerp(snow, smoothstep(1040, 1300, y) * smoothstep(0.6, 0.28, slope));
const f = frontMask(x, z) * smoothstep(640, 430, y);
if (f > 0.01) c.lerp(scar, f * 0.8);
// gentle mottling — the main low-level speed cue, but subtle enough
// that it doesn't turn the countryside into camouflage
const m = 0.955 + 0.09 * (fbm(x * 0.06, z * 0.06, 2) * 0.5 + 0.5) + (par.b - 0.5) * 0.08;
c.multiplyScalar(m);
col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b;
}
terGeo.setAttribute('color', new THREE.BufferAttribute(col, 3));
}
// A tiled detail texture. Vertex colours can't carry anything finer than the ~26m
// vertex spacing, so without this the ground goes smooth and featureless at low
// level — and featureless ground destroys the sense of speed.
function groundDetailTexture() {
const S = 256;
const tile = (x, y, per) => {
const xi = Math.floor(x), yi = Math.floor(y);
const tx = x - xi, ty = y - yi;
const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty);
const h = (i, j) => ihash(((i % per) + per) % per, ((j % per) + per) % per);
return lerp(lerp(h(xi, yi), h(xi + 1, yi), sx), lerp(h(xi, yi + 1), h(xi + 1, yi + 1), sx), sy);
};
const cv = document.createElement('canvas');
cv.width = cv.height = S;
const ctx = cv.getContext('2d');
const img = ctx.createImageData(S, S);
const oct = [[8, 0.42], [16, 0.26], [32, 0.18], [64, 0.14]];
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
let v = 0, w = 0;
for (const [per, amp] of oct) { v += amp * tile(x / S * per, y / S * per, per); w += amp; }
v /= w;
const t = clamp(Math.round(196 + v * 62), 0, 255);
const i = (y * S + x) * 4;
img.data[i] = img.data[i + 1] = img.data[i + 2] = t;
img.data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(WORLD / 58, WORLD / 58);
tex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
return tex;
}
const terrain = new THREE.Mesh(terGeo, new THREE.MeshLambertMaterial({
vertexColors: true,
map: groundDetailTexture(),
color: new THREE.Color(1.22, 1.22, 1.22), // compensate for the darkening detail map
}));
terrain.matrixAutoUpdate = false;
scene.add(terrain);
const water = new THREE.Mesh(
new THREE.PlaneGeometry(WORLD * 2.6, WORLD * 2.6),
new THREE.MeshPhongMaterial({ color: 0x27485c, shininess: 110, specular: 0x9fc8e0 })
);
water.rotation.x = -Math.PI / 2;
water.position.y = 0;
water.matrixAutoUpdate = false;
water.updateMatrix();
scene.add(water);
/* ==========================================================================
4. BILLBOARD PARTICLE SYSTEM (clouds, smoke, fire, sparks)
========================================================================== */
function puffTexture(kind) {
const S = 128, cv = document.createElement('canvas');
cv.width = cv.height = S;
const g = cv.getContext('2d');
if (kind === 'spark') {
const gr = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
gr.addColorStop(0, 'rgba(255,255,255,1)');
gr.addColorStop(0.25, 'rgba(255,220,140,.9)');
gr.addColorStop(1, 'rgba(255,140,40,0)');
g.fillStyle = gr; g.fillRect(0, 0, S, S);
} else {
const gr = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
gr.addColorStop(0, 'rgba(255,255,255,1)');
gr.addColorStop(0.42, 'rgba(255,255,255,.82)');
gr.addColorStop(0.72, 'rgba(255,255,255,.28)');
gr.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = gr; g.fillRect(0, 0, S, S);
// break the perfect circle up a little
g.globalCompositeOperation = 'destination-out';
const r2 = mulberry32(kind === 'cloud' ? 7 : 13);
for (let i = 0; i < 26; i++) {
const a = r2() * TAU, d = 22 + r2() * 40, rr = 6 + r2() * 17;
const gx = S / 2 + Math.cos(a) * d, gy = S / 2 + Math.sin(a) * d;
const g2 = g.createRadialGradient(gx, gy, 0, gx, gy, rr);
g2.addColorStop(0, 'rgba(0,0,0,' + (0.16 + r2() * 0.3) + ')');
g2.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = g2; g.beginPath(); g.arc(gx, gy, rr, 0, TAU); g.fill();
}
}
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
class Billboards {
constructor(max, texture, { additive = false, fadeNear = 1e9, fadeFar = 1e9, order = 2 } = {}) {
this.max = max; this.count = 0;
const g = new THREE.BufferGeometry();
this.aPos = new Float32Array(max * 4 * 3);
this.aCorner = new Float32Array(max * 4 * 2);
this.aSize = new Float32Array(max * 4);
this.aAlpha = new Float32Array(max * 4);
this.aRot = new Float32Array(max * 4);
this.aCol = new Float32Array(max * 4 * 3);
const idx = new Uint32Array(max * 6);
const cs = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (let i = 0; i < max; i++) {
for (let v = 0; v < 4; v++) {
this.aCorner[(i * 4 + v) * 2] = cs[v][0];
this.aCorner[(i * 4 + v) * 2 + 1] = cs[v][1];
}
const o = i * 4;
idx.set([o, o + 1, o + 2, o, o + 2, o + 3], i * 6);
}
g.setAttribute('position', new THREE.BufferAttribute(this.aPos, 3));
g.setAttribute('corner', new THREE.BufferAttribute(this.aCorner, 2));
g.setAttribute('psize', new THREE.BufferAttribute(this.aSize, 1));
g.setAttribute('palpha', new THREE.BufferAttribute(this.aAlpha, 1));
g.setAttribute('prot', new THREE.BufferAttribute(this.aRot, 1));
g.setAttribute('pcolor', new THREE.BufferAttribute(this.aCol, 3));
g.setIndex(new THREE.BufferAttribute(idx, 1));
g.setDrawRange(0, 0);
g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e7);
const mat = new THREE.ShaderMaterial({
transparent: true, depthWrite: false, depthTest: true,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
uniforms: {
map: { value: texture },
fogCol: { value: FOG_COLOR },
fadeNear: { value: fadeNear }, fadeFar: { value: fadeFar },
},
vertexShader: `
attribute vec2 corner; attribute float psize, palpha, prot; attribute vec3 pcolor;
uniform float fadeNear, fadeFar;
varying vec2 vUv; varying vec3 vCol; varying float vA, vFade;
void main(){
vUv = corner*0.5+0.5; vCol = pcolor; vA = palpha;
vec4 mv = modelViewMatrix * vec4(position,1.0);
float c = cos(prot), s = sin(prot);
mv.xy += vec2(corner.x*c - corner.y*s, corner.x*s + corner.y*c) * psize;
float d = -mv.z;
vFade = 1.0 - clamp((d - fadeNear)/(fadeFar - fadeNear), 0.0, 1.0);
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
uniform sampler2D map; uniform vec3 fogCol;
varying vec2 vUv; varying vec3 vCol; varying float vA, vFade;
void main(){
vec4 t = texture2D(map, vUv);
float a = t.a * vA * (0.12 + 0.88*vFade);
if (a < 0.004) discard;
vec3 col = mix(fogCol, vCol * t.rgb, vFade);
gl_FragColor = vec4(col, a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
});
this.mesh = new THREE.Mesh(g, mat);
this.mesh.frustumCulled = false;
this.mesh.renderOrder = order;
this.geo = g;
}
write(i, x, y, z, size, alpha, rot, r, gg, b) {
const p = i * 4;
for (let v = 0; v < 4; v++) {
const j = p + v;
this.aPos[j * 3] = x; this.aPos[j * 3 + 1] = y; this.aPos[j * 3 + 2] = z;
this.aSize[j] = size; this.aAlpha[j] = alpha; this.aRot[j] = rot;
this.aCol[j * 3] = r; this.aCol[j * 3 + 1] = gg; this.aCol[j * 3 + 2] = b;
}
}
commit(n) {
this.count = Math.min(n, this.max);
this.geo.setDrawRange(0, this.count * 6);
for (const k of ['position', 'psize', 'palpha', 'prot', 'pcolor']) this.geo.attributes[k].needsUpdate = true;
}
}
/* --- static clouds ------------------------------------------------------ */
const cloudTex = puffTexture('cloud');
const clouds = new Billboards(560, cloudTex, { fadeNear: 3000, fadeFar: 15000, order: 1 });
scene.add(clouds.mesh);
{
let n = 0;
const lit = new THREE.Color(0xfdfaf2), shade = new THREE.Color(0x9fadc0);
const c = new THREE.Color();
const addCluster = (cx, cy, cz, scale, puffs) => {
for (let i = 0; i < puffs && n < clouds.max; i++) {
const a = rand(TAU), rr = Math.pow(rng(), 0.55);
const px = cx + Math.cos(a) * rr * scale * 2.0;
const pz = cz + Math.sin(a) * rr * scale * 1.7;
const py = cy + randSym(scale * 0.30) - rr * scale * 0.22;
const s = scale * (0.62 + rng() * 0.72);
const up = clamp((py - cy) / (scale * 0.5) * 0.5 + 0.55, 0, 1);
c.copy(shade).lerp(lit, up * up);
clouds.write(n++, px, py, pz, s, 0.62, rand(TAU), c.r, c.g, c.b);
}
};
for (let i = 0; i < 40; i++) {
addCluster(randSym(HALF * 0.95), rand(950, 2300), randSym(HALF * 0.95), rand(70, 165), 7 + (rng() * 6 | 0));
}
for (let i = 0; i < 20; i++) { // distant horizon bank
const a = rand(TAU), d = rand(HALF * 1.1, HALF * 1.6);
addCluster(Math.cos(a) * d, rand(900, 1800), Math.sin(a) * d, rand(260, 460), 6);
}
clouds.commit(n);
}
/* --- dynamic particle pools -------------------------------------------- */
const smokeTex = puffTexture('smoke'), sparkTex = puffTexture('spark');
const smokePool = new Billboards(620, smokeTex, { fadeNear: 4000, fadeFar: 14000, order: 3 });
const firePool = new Billboards(300, smokeTex, { additive: true, order: 4 });
const sparkPool = new Billboards(420, sparkTex, { additive: true, order: 5 });
scene.add(smokePool.mesh, firePool.mesh, sparkPool.mesh);
const parts = { smoke: [], fire: [], spark: [] };
const _pfree = { smoke: [], fire: [], spark: [] };
function emit(kind, x, y, z, vx, vy, vz, size, grow, life, r, g, b, alpha) {
const arr = parts[kind];
if (arr.length >= (kind === 'smoke' ? 600 : kind === 'fire' ? 290 : 400)) return;
const p = _pfree[kind].pop() || {};
p.x = x; p.y = y; p.z = z; p.vx = vx; p.vy = vy; p.vz = vz;
p.size = size; p.grow = grow; p.life = life; p.max = life;
p.r = r; p.g = g; p.b = b; p.a = alpha; p.rot = rand(TAU); p.spin = randSym(0.7);
arr.push(p);
}
function updateParticles(dt) {
for (const kind of ['smoke', 'fire', 'spark']) {
const arr = parts[kind], pool = kind === 'smoke' ? smokePool : kind === 'fire' ? firePool : sparkPool;
let n = 0;
for (let i = arr.length - 1; i >= 0; i--) {
const p = arr[i];
p.life -= dt;
if (p.life <= 0) { _pfree[kind].push(p); arr[i] = arr[arr.length - 1]; arr.pop(); continue; }
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
const drag = kind === 'spark' ? 1.6 : 0.85;
const f = Math.max(0, 1 - drag * dt);
p.vx *= f; p.vz *= f;
p.vy = kind === 'spark' ? p.vy * f - 9.8 * dt : p.vy * f + (kind === 'smoke' ? 3.2 : 5.5) * dt;
p.size += p.grow * dt;
p.rot += p.spin * dt;
const t = p.life / p.max;
const a = kind === 'fire' ? p.a * t * t : kind === 'spark' ? p.a * t : p.a * Math.min(1, t * 2.6) * (0.25 + 0.75 * t);
if (n < pool.max) pool.write(n++, p.x, p.y, p.z, p.size, a, p.rot, p.r, p.g, p.b);
}
pool.commit(n);
}
}
const _smokeC = { light: [0.62, 0.62, 0.6], dark: [0.13, 0.12, 0.11], fire: [1.0, 0.55, 0.16] };
function explosion(x, y, z, scale = 1) {
// brief, restrained flash — additive puffs stack, and ACES rolls a hot core to
// white, so the read comes from the smoke rather than from fireball brightness
for (let i = 0; i < 12; i++) {
emit('fire', x + randSym(8 * scale), y + randSym(8 * scale), z + randSym(8 * scale),
randSym(16 * scale), rand(2, 14) * scale, randSym(16 * scale),
3.5 * scale + rand(4 * scale), 9 * scale, rand(0.22, 0.55),
1.0, 0.32 + rand(0.14), 0.05, 0.20);
}
for (let i = 0; i < 24; i++) {
const d = i < 10 ? 3 : 8; // an inner core of smoke plus an outer billow
emit('smoke', x + randSym(d * scale), y + randSym(d * scale), z + randSym(d * scale),
randSym(11 * scale), rand(1, 8) * scale, randSym(11 * scale),
5 * scale + rand(8 * scale), 10 * scale, rand(2.4, 6.0),
0.13, 0.12, 0.115, 0.95);
}
for (let i = 0; i < 26; i++) {
const s = 22 * scale;
emit('spark', x, y, z, randSym(s), rand(-6, 26), randSym(s), 1.2 + rand(1.6), -0.4,
rand(0.5, 1.4), 1.0, 0.72, 0.28, 1.0);
}
}
/* ==========================================================================
5. BIPLANE MODEL
========================================================================== */
function mergeGeos(list) {
const geos = list.map(g => g.index ? g.toNonIndexed() : g);
let total = 0;
for (const g of geos) total += g.attributes.position.count;
const pos = new Float32Array(total * 3), nor = new Float32Array(total * 3), uv = new Float32Array(total * 2);
let o = 0;
for (const g of geos) {
const p = g.attributes.position, nn = g.attributes.normal, u = g.attributes.uv;
pos.set(p.array.subarray(0, p.count * 3), o * 3);
if (nn) nor.set(nn.array.subarray(0, p.count * 3), o * 3);
if (u) uv.set(u.array.subarray(0, p.count * 2), o * 2);
o += p.count;
}
const out = new THREE.BufferGeometry();
out.setAttribute('position', new THREE.BufferAttribute(pos, 3));
out.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
out.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
out.computeBoundingSphere();
return out;
}
function wingGeo(span, chord, thick, tipChord = 0.6) {
const g = new THREE.BoxGeometry(span, thick, chord, 10, 1, 1);
const p = g.attributes.position;
for (let i = 0; i < p.count; i++) {
const t = clamp((Math.abs(p.getX(i)) / (span * 0.5) - 0.52) / 0.48, 0, 1);
const s = 1 - (1 - tipChord) * t * t;
p.setZ(i, p.getZ(i) * s);
p.setY(i, p.getY(i) * (1 - 0.4 * t));
}
g.computeVertexNormals();
return g;
}
// A single wing panel running from `inner` to `outer` on the +x side, tapered at the
// outboard end only. Used for the upper wing so it can have a centre-section cutout —
// which the Camel really had, and without it the top wing blanks out the cockpit view.
function panelGeo(inner, outer, chord, thick, tipChord = 0.6) {
const len = outer - inner;
const g = new THREE.BoxGeometry(len, thick, chord, 8, 1, 1);
const p = g.attributes.position;
for (let i = 0; i < p.count; i++) {
const u = (p.getX(i) + len / 2) / len; // 0 inboard → 1 outboard
const t = clamp((u - 0.58) / 0.42, 0, 1);
const s = 1 - (1 - tipChord) * t * t;
p.setZ(i, p.getZ(i) * s);
p.setY(i, p.getY(i) * (1 - 0.4 * t));
}
g.computeVertexNormals();
g.translate((inner + outer) / 2, 0, 0);
return g;
}
const SCHEMES = {
player: { fabric: 0xa8925c, metal: 0x585a53, dark: 0x33302a, mark: 'roundel', tail: true },
red: { fabric: 0x9e3226, metal: 0x4a4c46, dark: 0x2b2925, mark: 'cross' },
blue: { fabric: 0x3a5580, metal: 0x4a4c46, dark: 0x2b2925, mark: 'cross' },
green: { fabric: 0x4a6234, metal: 0x4a4c46, dark: 0x2b2925, mark: 'cross' },
mustard: { fabric: 0x9c8038, metal: 0x4a4c46, dark: 0x2b2925, mark: 'cross' },
};
const MAT_CACHE = new Map();
function lam(hex, extra) {
const key = hex + '|' + JSON.stringify(extra || {});
if (!MAT_CACHE.has(key)) MAT_CACHE.set(key, new THREE.MeshLambertMaterial(Object.assign({ color: hex }, extra)));
return MAT_CACHE.get(key);
}
const PROTO = new Map();
function biplaneProto(schemeName) {
if (PROTO.has(schemeName)) return PROTO.get(schemeName);
const S = SCHEMES[schemeName];
const B = {}; // material key -> [geometry]
const put = (key, geo, x = 0, y = 0, z = 0, rx = 0, ry = 0, rz = 0) => {
geo.applyMatrix4(new THREE.Matrix4().compose(
new THREE.Vector3(x, y, z),
new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)),
new THREE.Vector3(1, 1, 1)));
(B[key] = B[key] || []).push(geo);
};
const box = (w, h, d) => new THREE.BoxGeometry(w, h, d);
const cyl = (r1, r2, h, s = 8) => new THREE.CylinderGeometry(r1, r2, h, s);
// fuselage: tapered, slightly flattened
const fus = cyl(0.30, 0.56, 4.7, 9);
fus.scale(1, 1, 0.92); fus.rotateX(Math.PI / 2);
put('fabric', fus, 0, 0, 0.35);
put('fabric', box(0.5, 0.34, 1.5), 0, 0.42, 0.35); // turtledeck
put('metal', cyl(0.58, 0.60, 0.62, 12).rotateX(Math.PI / 2), 0, 0, -2.28); // cowl
put('dark', cyl(0.42, 0.44, 0.16, 12).rotateX(Math.PI / 2), 0, 0, -2.60);
// wings — upper is two panels with a centre cutout, so the pilot can see up
put('fabric', panelGeo(0.62, 4.45, 1.42, 0.13), 0, 1.28, -0.28);
// rotate rather than mirror: scale(-1,1,1) would reverse the winding order
put('fabric', panelGeo(0.62, 4.45, 1.42, 0.13).rotateY(Math.PI), 0, 1.28, -0.28);
put('fabric', box(1.34, 0.115, 0.22), 0, 1.283, -0.90); // centre leading edge
put('fabric', wingGeo(8.4, 1.34, 0.12), 0, -0.32, 0.30); // lower
put('fabric', wingGeo(2.95, 0.78, 0.09, 0.72), 0, 0.10, 2.32); // tailplane
put('fabric', box(0.10, 1.02, 0.80), 0, 0.58, 2.42); // fin
put('fabric', box(0.09, 0.86, 0.52), 0, 0.52, 2.90); // rudder
// struts
for (const sx of [-1, 1]) {
for (const [ox, oz] of [[2.55, -0.55], [2.55, 0.45], [1.35, -0.55], [1.35, 0.45]]) {
put('dark', box(0.08, 1.66, 0.10), sx * ox, 0.48, oz + 0.02, 0, 0, sx * 0.03);
}
put('dark', box(0.07, 1.42, 0.09), sx * 0.46, 0.62, -0.42, 0, 0, sx * 0.14); // cabane
put('dark', box(0.07, 1.42, 0.09), sx * 0.46, 0.62, 0.30, 0, 0, sx * 0.14);
// landing gear
put('dark', box(0.08, 1.02, 0.09), sx * 0.52, -0.80, -0.85, 0.30, 0, sx * 0.34);
put('dark', box(0.08, 1.02, 0.09), sx * 0.52, -0.80, -0.15, -0.24, 0, sx * 0.34);
put('dark', cyl(0.33, 0.33, 0.15, 11).rotateZ(Math.PI / 2), sx * 0.86, -1.28, -0.52);
}
put('dark', cyl(0.05, 0.05, 1.75, 6).rotateZ(Math.PI / 2), 0, -1.28, -0.52); // axle
put('dark', box(0.10, 0.42, 0.10), 0, -0.20, 2.86); // tailskid
// cockpit + pilot
put('dark', cyl(0.34, 0.34, 0.26, 10), 0, 0.52, 0.62);
put('dark', box(0.52, 0.26, 0.05), 0, 0.70, -0.02, 0.18, 0, 0); // instrument board
put('dark', box(0.62, 0.30, 0.06), 0, 0.66, 0.34, -0.32, 0, 0); // windscreen frame
put('pilot', new THREE.SphereGeometry(0.17, 10, 8), 0, 0.74, 0.72);
put('metal', box(0.30, 0.13, 0.05), 0, 0.77, 0.58); // goggles
for (const sx of [-1, 1]) { // twin Vickers
put('metal', box(0.085, 0.085, 0.95), sx * 0.19, 0.66, -1.28); // barrel + jacket
put('dark', box(0.15, 0.17, 0.52), sx * 0.19, 0.68, -0.44); // breech
put('metal', box(0.11, 0.05, 0.16), sx * 0.19, 0.79, -0.30); // cocking handle
}
// markings
const disc = (r, seg = 18) => new THREE.CircleGeometry(r, seg);
if (S.mark === 'roundel') {
for (const sx of [-1, 1]) {
for (const [c, r] of [['mBlue', 0.52], ['mWhite', 0.35], ['mRed', 0.18]]) {
put(c, disc(r), sx * 2.75, 1.28 + 0.075 + (r === 0.52 ? 0 : r === 0.35 ? 0.002 : 0.004), -0.28, -Math.PI / 2);
put(c, disc(r), sx * 2.55, -0.32 - 0.065 - (r === 0.52 ? 0 : r === 0.35 ? 0.002 : 0.004), 0.30, Math.PI / 2);
}
for (const [c, r] of [['mBlue', 0.40], ['mWhite', 0.27], ['mRed', 0.14]]) {
put(c, disc(r), sx * (0.47 + (r === 0.40 ? 0 : r === 0.27 ? 0.004 : 0.008)), 0.06, 0.95, 0, sx * Math.PI / 2);
}
}
// rudder stripes
put('mBlue', box(0.095, 0.86, 0.17), 0, 0.52, 2.72);
put('mWhite', box(0.095, 0.86, 0.17), 0, 0.52, 2.90);
put('mRed', box(0.095, 0.86, 0.17), 0, 0.52, 3.08);
} else {
const cross = (l, w) => [box(l, 0.012, w), box(w, 0.012, l)];
for (const sx of [-1, 1]) {
for (const g of cross(1.0, 0.30)) put('mBlack', g, sx * 2.75, 1.355, -0.28);
for (const g of cross(0.95, 0.28)) put('mBlack', g, sx * 2.55, -0.386, 0.30);
for (const g of [box(0.012, 0.72, 0.24), box(0.012, 0.24, 0.72)])
put('mBlack', g, sx * 0.475, 0.06, 0.95);
}
for (const g of [box(0.10, 0.62, 0.20), box(0.10, 0.20, 0.62)]) put('mBlack', g, 0, 0.52, 2.90);
}
const mats = {
fabric: lam(S.fabric), metal: lam(S.metal), dark: lam(S.dark),
pilot: lam(0x6b4a34), mBlue: lam(0x1d3b78), mWhite: lam(0xd8d2c2),
mRed: lam(0x9c2320), mBlack: lam(0x15130f),
};
const merged = [];
for (const k in B) merged.push({ geo: mergeGeos(B[k]), mat: mats[k] || lam(0xff00ff) });
// rigging wires
const wp = [];
const W = (a, b) => wp.push(a[0], a[1], a[2], b[0], b[1], b[2]);
for (const sx of [-1, 1]) {
W([sx * 2.55, 1.20, -0.80], [sx * 1.35, -0.26, 0.20]);
W([sx * 1.35, 1.20, -0.80], [sx * 2.55, -0.26, 0.20]);
W([sx * 2.55, 1.20, 0.35], [sx * 1.35, -0.26, -0.60]);
W([sx * 4.10, 1.22, -0.28], [sx * 0.55, 0.72, -0.55]);
W([sx * 3.90, -0.30, 0.30], [sx * 0.55, 0.10, 1.10]);
W([sx * 1.30, 0.14, 2.30], [0, 0.95, 2.45]);
}
const wireGeo = new THREE.BufferGeometry();
wireGeo.setAttribute('position', new THREE.Float32BufferAttribute(wp, 3));
const proto = { merged, wireGeo, scheme: S };
PROTO.set(schemeName, proto);
return proto;
}
const propDiscTex = (function () {
const S = 128, cv = document.createElement('canvas'); cv.width = cv.height = S;
const g = cv.getContext('2d');
const gr = g.createRadialGradient(S / 2, S / 2, S * 0.05, S / 2, S / 2, S / 2);
gr.addColorStop(0, 'rgba(40,36,30,.75)');
gr.addColorStop(0.55, 'rgba(60,54,44,.20)');
gr.addColorStop(0.86, 'rgba(90,82,66,.34)');
gr.addColorStop(1, 'rgba(90,82,66,0)');
g.fillStyle = gr; g.fillRect(0, 0, S, S);
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return t;
})();
function buildPlaneMesh(schemeName) {
const proto = biplaneProto(schemeName);
const root = new THREE.Group();
for (const m of proto.merged) root.add(new THREE.Mesh(m.geo, m.mat));
const wires = new THREE.LineSegments(proto.wireGeo,
new THREE.LineBasicMaterial({ color: 0x2a2620, transparent: true, opacity: 0.75 }));
root.add(wires);
const prop = new THREE.Group();
prop.position.set(0, 0, -2.72);
const blade = new THREE.BoxGeometry(0.17, 2.5, 0.045);
blade.translate(0, 0, 0);
const bm = lam(0x4a3a22);
const b1 = new THREE.Mesh(blade, bm);
const b2 = new THREE.Mesh(blade.clone().rotateZ(Math.PI / 2), bm);
prop.add(b1, b2);
prop.add(new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.11, 0.3, 8).rotateX(Math.PI / 2), lam(0x3a352c)));
const disc = new THREE.Mesh(new THREE.CircleGeometry(1.3, 24),
new THREE.MeshBasicMaterial({ map: propDiscTex, transparent: true, depthWrite: false, opacity: 0 }));
disc.position.z = -0.02;
prop.add(disc);
root.add(prop);
root.userData.prop = prop;
root.userData.blades = [b1, b2];
root.userData.disc = disc;
return root;
}
/* ==========================================================================
6. FLIGHT PHYSICS
========================================================================== */
const FWD = new THREE.Vector3(0, 0, -1);
const UP = new THREE.Vector3(0, 1, 0);
const RIGHT = new THREE.Vector3(1, 0, 0);
const _a1 = new THREE.Vector3(), _a2 = new THREE.Vector3(), _a3 = new THREE.Vector3();
const _vb = new THREE.Vector3(), _vn = new THREE.Vector3(), _fb = new THREE.Vector3();
const _liftD = new THREE.Vector3(), _spanD = new THREE.Vector3();
const _qi = new THREE.Quaternion(), _dq = new THREE.Quaternion();
function liftCoef(a) {
const aa = Math.abs(a);
const lin = AERO.CL0 + AERO.CLa * a;
const flat = 1.05 * Math.sin(2 * a);
const bl = smoothstep(AERO.aStall, AERO.aStall + 0.22, aa);
return lin * (1 - bl) + flat * 0.52 * bl;
}
function groundAt(x, z) { return Math.max(heightAt(x, z), 0); }
class Aircraft {
constructor(scheme, team, isPlayer = false) {
this.scheme = scheme; this.team = team; this.isPlayer = isPlayer;
this.pos = new THREE.Vector3();
this.quat = new THREE.Quaternion();
this.vel = new THREE.Vector3();
this.ang = new THREE.Vector3();
this.ctl = { e: 0, a: 0, r: 0, t: 0.85 };
this.hp = 100; this.alive = true; this.dying = false; this.dead = false;
this.rpm = 0.85; this.engine = 1; this.jam = 0; this.dieSpin = undefined;
this.thrustMul = 1;
this.heat = 0; this.ammo = GUN.ammo; this.fireAcc = 0; this.firing = false;
this.alpha = 0; this.beta = 0; this.speed = 0; this.gforce = 1;
this.stallT = 0; this.smokeAcc = 0; this.dieT = 0; this.propAngle = 0;
this.mesh = buildPlaneMesh(scheme);
this.radius = 4.6;
this.ai = null;
scene.add(this.mesh);
}
get forward() { return _a1.copy(FWD).applyQuaternion(this.quat); }
reset(pos, headingQuat, speed) {
this.pos.copy(pos); this.quat.copy(headingQuat);
this.vel.copy(FWD).applyQuaternion(this.quat).multiplyScalar(speed);
this.ang.set(0, 0, 0);
this.hp = 100; this.alive = true; this.dying = false; this.dead = false;
this.engine = 1; this.rpm = 0.85; this.heat = 0; this.ammo = GUN.ammo;
this.jam = 0; this.dieT = 0; this.dieSpin = undefined; this.firing = false;
this.ctl.e = this.ctl.a = this.ctl.r = 0; this.ctl.t = 0.85;
this.mesh.visible = true;
}
remove() { this.dead = true; this.mesh.visible = false; }
}
function stepAircraft(p, dt) {
if (p.dead) return;
const c = p.ctl;
// ---- engine -------------------------------------------------------
const wanted = p.engine > 0 ? clamp(c.t, 0, 1) : 0;
p.rpm += (wanted - p.rpm) * Math.min(1, dt * 1.6);
p.propAngle += p.rpm * 62 * dt;
// ---- airflow ------------------------------------------------------
_qi.copy(p.quat).invert();
_vb.copy(p.vel).applyQuaternion(_qi);
const V = p.vel.length();
p.speed = V;
const alpha = V > 0.4 ? Math.atan2(-_vb.y, -_vb.z) : 0;
const beta = V > 0.4 ? Math.atan2(_vb.x, -_vb.z) : 0;
p.alpha = alpha; p.beta = beta;
const rho = 1.225 * Math.exp(-Math.max(0, p.pos.y) / 8500);
const qd = 0.5 * rho * V * V;
const qS = qd * AERO.S;
const cl = liftCoef(alpha);
const cd = AERO.CD0 + AERO.kInd * cl * cl
+ 1.15 * (1 - Math.cos(2 * alpha)) * 0.5
+ 0.55 * Math.abs(Math.sin(beta));
const L = qS * cl, D = qS * cd;
p.gforce = L / (AERO.mass * 9.81);
// lift acts perpendicular to the relative wind, in the wind/body-up plane
_fb.set(0, 0, 0);
if (V > 0.4) {
_vn.copy(_vb).normalize();
_spanD.crossVectors(_vn, UP);
if (_spanD.lengthSq() < 1e-8) _spanD.copy(RIGHT); else _spanD.normalize();
_liftD.crossVectors(_spanD, _vn).normalize();
_fb.addScaledVector(_liftD, L);
_fb.addScaledVector(_vn, -D);
_fb.x -= qS * AERO.CY_b * beta;
}
const densR = rho / 1.225;
const thrust = AERO.thrust * (p.thrustMul || 1) * p.rpm * densR * (1 - 0.35 * clamp(V / 110, 0, 1));
_fb.z -= thrust;
_fb.applyQuaternion(p.quat);
p.vel.addScaledVector(_fb, dt / AERO.mass);
p.vel.y -= 9.81 * dt;
p.pos.addScaledVector(p.vel, dt);
// ---- moments ------------------------------------------------------
const Vs = Math.max(V, 11);
const b = AERO.span, ch = AERO.chord;
const aSt = clamp(alpha, -1.2, 1.2);
// stick forces rise with dynamic pressure — without this you can yank 12g out of
// a dive with a flick of the wrist
const stickForce = 1 / (1 + 0.38 * Math.max(0, qd / AERO.stickForceQ - 1));
let tx = qS * ch * (AERO.Cm0 + AERO.Cm_de * c.e * stickForce
- AERO.Cm_a * aSt - AERO.Cm_q * (p.ang.x * ch / (2 * Vs)));
let ty = qS * b * (-AERO.Cn_dr * c.r - AERO.Cn_b * beta - AERO.Cn_r * (p.ang.y * b / (2 * Vs)));
let tz = qS * b * (-AERO.Cl_da * c.a + AERO.Cl_b * beta - AERO.Cl_p * (p.ang.z * b / (2 * Vs)));
// rotary-engine character: gyroscopic precession + torque roll
ty -= AERO.gyro * AERO.Iyaw * p.ang.x * p.rpm;
tz += AERO.torqueRoll * AERO.Iroll * p.rpm;
// wing drop: at the stall one wing lets go before the other, so the nose
// swings and a wing falls. Unloading the stick recovers it — no spin lockout.
if (Math.abs(alpha) > AERO.aStall) {
if (!p.wingDrop) p.wingDrop = rng() < 0.5 ? -1 : 1;
const st = smoothstep(AERO.aStall, AERO.aStall + 0.25, Math.abs(alpha));
tz += p.wingDrop * st * qS * b * AERO.stallRoll;
ty += p.wingDrop * st * qS * b * AERO.stallYaw;
} else if (p.stallT <= 0) p.wingDrop = 0;
p.ang.x += tx / AERO.Ipitch * dt;
p.ang.y += ty / AERO.Iyaw * dt;
p.ang.z += tz / AERO.Iroll * dt;
p.ang.multiplyScalar(Math.max(0, 1 - 0.12 * dt));
if (p.ang.lengthSq() > 144) p.ang.setLength(12);
_dq.set(p.ang.x * dt * 0.5, p.ang.y * dt * 0.5, p.ang.z * dt * 0.5, 1);
p.quat.multiply(_dq).normalize();
// stall bookkeeping
p.stallT = Math.abs(alpha) > AERO.aStall && V > 3 ? p.stallT + dt : Math.max(0, p.stallT - dt * 2);
}
/* ==========================================================================
7. BULLETS
========================================================================== */
const BMAX = 520;
const bulletMesh = new THREE.InstancedMesh(
new THREE.BoxGeometry(0.1, 0.1, 1),
new THREE.MeshBasicMaterial({ toneMapped: false }), BMAX);
bulletMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
bulletMesh.setColorAt(0, new THREE.Color(1, 1, 1));
bulletMesh.frustumCulled = false;
bulletMesh.count = 0;
scene.add(bulletMesh);
const bullets = [];
const bulletFree = [];
const _bm = new THREE.Matrix4(), _bq = new THREE.Quaternion(), _bs = new THREE.Vector3();
const _bc = new THREE.Color();
function spawnBullet(owner, origin, dir, tracer) {
if (bullets.length >= BMAX) return;
const t = bulletFree.pop() || { p: new THREE.Vector3(), prev: new THREE.Vector3(), v: new THREE.Vector3() };
t.p.copy(origin); t.prev.copy(origin);
t.v.copy(dir).multiplyScalar(GUN.muzzle).add(owner.vel);
t.life = 2.6; t.owner = owner; t.team = owner.team; t.tracer = tracer;
bullets.push(t);
}
function segPointDist2(a, b, c) {
const abx = b.x - a.x, aby = b.y - a.y, abz = b.z - a.z;
const acx = c.x - a.x, acy = c.y - a.y, acz = c.z - a.z;
const ab2 = abx * abx + aby * aby + abz * abz;
let t = ab2 > 1e-9 ? (acx * abx + acy * aby + acz * abz) / ab2 : 0;
t = t < 0 ? 0 : t > 1 ? 1 : t;
const dx = acx - abx * t, dy = acy - aby * t, dz = acz - abz * t;
return dx * dx + dy * dy + dz * dz;
}
function stepBullets(dt) {
for (let i = bullets.length - 1; i >= 0; i--) {
const t = bullets[i];
t.prev.copy(t.p);
t.p.addScaledVector(t.v, dt);
t.v.y -= 9.81 * dt;
t.life -= dt;
let done = t.life <= 0;
if (!done) {
for (const pl of planes) {
if (pl.dead || pl.team === t.team || pl === t.owner) continue;
if (segPointDist2(t.prev, t.p, pl.pos) < pl.radius * pl.radius) {
hitPlane(pl, t, GUN.damage);
done = true; break;
}
}
}
if (!done && t.p.y < groundAt(t.p.x, t.p.z)) {
if (rng() < 0.35) emit('smoke', t.p.x, t.p.y, t.p.z, randSym(2), rand(1, 5), randSym(2),
0.9, 3.0, rand(0.4, 0.9), 0.42, 0.38, 0.32, 0.55);
done = true;
}
if (done) { bulletFree.push(t); bullets[i] = bullets[bullets.length - 1]; bullets.pop(); }
}
}
function drawBullets() {
let n = 0;
for (const t of bullets) {
if (n >= BMAX) break;
const len = t.tracer ? 7 : 2.2;
_bq.setFromUnitVectors(FWD, _a1.copy(t.v).normalize().negate());
_bs.set(1, 1, len);
_bm.compose(t.p, _bq, _bs);
bulletMesh.setMatrixAt(n, _bm);
if (t.tracer) _bc.setRGB(1.0, 0.42, 0.10); else _bc.setRGB(0.26, 0.24, 0.20);
bulletMesh.setColorAt(n, _bc);
n++;
}
bulletMesh.count = n;
bulletMesh.instanceMatrix.needsUpdate = true;
if (bulletMesh.instanceColor) bulletMesh.instanceColor.needsUpdate = true;
}
const _gunO = new THREE.Vector3(), _gunD = new THREE.Vector3();
function tryFire(p, dt) {
p.heat = Math.max(0, p.heat - GUN.cool * dt);
if (p.jam > 0) { p.jam -= dt; p.firing = false; }
if (!p.firing || p.ammo <= 0 || p.dying || p.dead || p.jam > 0) { p.fireAcc = 0; return; }
p.fireAcc += dt;
const interval = 60 / GUN.rpm;
while (p.fireAcc >= interval && p.ammo > 0) {
p.fireAcc -= interval;
p.ammo--;
p.heat = Math.min(1.35, p.heat + GUN.heatPerShot);
if (p.heat >= 1) { p.jam = 2.6; p.firing = false; if (p.isPlayer) flash('BREECH JAMMED — GUNS COOLING'); break; }
const fw = _gunD.copy(FWD).applyQuaternion(p.quat);
_gunO.copy(p.pos).addScaledVector(fw, 2.4);
_gunO.y += 0.35;
const sp = GUN.spread * (p.isPlayer ? 1 : 1 + (1 - (p.ai ? p.ai.skill : 1)) * 2.5);
fw.x += randSym(sp); fw.y += randSym(sp); fw.z += randSym(sp);
fw.normalize();
spawnBullet(p, _gunO, fw, (p.shotN = (p.shotN || 0) + 1) % 3 === 0);
if (p.isPlayer) { sfx.gun(); shake(0.14); }
else sfx.gunFar(p.pos);
emit('smoke', _gunO.x, _gunO.y, _gunO.z, p.vel.x * 0.4, p.vel.y * 0.4 + 1, p.vel.z * 0.4,
0.28, 1.6, 0.16, 0.7, 0.68, 0.6, 0.35);
}
}
function hitPlane(pl, bullet, dmg) {
if (pl.dying || pl.dead) return;
pl.hp -= dmg;
for (let i = 0; i < 3; i++)
emit('spark', bullet.p.x, bullet.p.y, bullet.p.z,
randSym(9) + pl.vel.x * 0.5, randSym(9) + pl.vel.y * 0.5, randSym(9) + pl.vel.z * 0.5,
0.7 + rand(0.7), -0.2, rand(0.18, 0.4), 1.0, 0.8, 0.35, 1.0);
emit('smoke', bullet.p.x, bullet.p.y, bullet.p.z, randSym(3), rand(0, 4), randSym(3),
0.7, 2.2, 0.5, 0.5, 0.48, 0.44, 0.5);
if (bullet.owner === player) {
hitFeedback();
pl.ai && (pl.ai.threat = 2.5);
}
if (pl.isPlayer) {
playerHurt(dmg);
}
if (pl.hp <= 0) killPlane(pl, bullet.owner);
else if (pl.hp < 45 && rng() < 0.22) pl.engine = Math.max(0.25, pl.engine - 0.3);
}
function killPlane(pl, by) {
if (pl.dying) return;
pl.dying = true; pl.dieT = 0; pl.engine = 0; pl.hp = 0;
pl.ang.z += randSym(1.6); pl.ang.x += rand(-0.2, 0.7);
if (by === player && !pl.isPlayer) {
game.kills++; game.streak++; game.best = Math.max(game.best, game.streak);
const pts = Math.round(100 * (1 + game.streak * 0.12));
game.score += pts;
flash('VICTORY CONFIRMED +' + pts + (game.streak > 1 ? ' ×' + game.streak : ''));
sfx.kill();
}
if (pl.isPlayer) { game.streak = 0; }
}
/* ==========================================================================
8. ENEMY AI
========================================================================== */
const _d = new THREE.Vector3(), _toT = new THREE.Vector3(), _aim = new THREE.Vector3();
const _rw = new THREE.Vector3(), _uw = new THREE.Vector3();
function bankAngle(p) {
_rw.copy(RIGHT).applyQuaternion(p.quat);
_uw.copy(UP).applyQuaternion(p.quat);
return Math.atan2(-_rw.y, _uw.y);
}
function limitAlpha(p, e) {
const a = p.alpha;
if (e > 0 && a > AERO.aStall * 0.78) e *= 1 - 0.9 * smoothstep(AERO.aStall * 0.78, AERO.aStall * 1.08, a);
if (e < 0 && a < -AERO.aStall * 0.78) e *= 1 - 0.9 * smoothstep(AERO.aStall * 0.78, AERO.aStall * 1.08, -a);
return e;
}
// Roll the lift vector onto the target, then pull. `autoLevel` (0..1) blends in a
// wings-level command — it must stay 0 while pursuing, or the aircraft rolls upright
// the moment its nose crosses the target and drops out of the turn.
function steerTo(p, dirWorld, autoLevel) {
_qi.copy(p.quat).invert();
_d.copy(dirWorld).normalize().applyQuaternion(_qi);
const off = Math.atan2(Math.hypot(_d.x, _d.y), -_d.z);
const rollErr = Math.atan2(_d.x, _d.y);
const rollRate = -p.ang.z;
let a = clamp(2.4 * rollErr - 0.5 * rollRate, -1, 1) * smoothstep(0.02, 0.13, off);
if (autoLevel > 0) {
const w = autoLevel >= 1 ? 1 : autoLevel * (1 - smoothstep(0.05, 0.35, off));
if (w > 0.001) a = lerp(a, clamp(-1.7 * bankAngle(p) - 0.45 * rollRate, -1, 1), w);
}
let e = limitAlpha(p, clamp(2.6 * off * Math.cos(rollErr) - 0.5 * p.ang.x, -1, 1));
const r = clamp(1.4 * p.beta + 0.25 * p.ang.y, -1, 1);
p.ctl.a = a; p.ctl.e = e; p.ctl.r = r;
return off;
}
function newAI(skill) {
return {
state: 'ENGAGE', t: 0, burst: 0, trigger: 0, threat: 0, skill,
jitter: new THREE.Vector3(), jt: 0, slot: false, orbitPhase: rand(TAU),
};
}
function aiUpdate(p, dt) {
const ai = p.ai;
ai.t += dt; ai.jt += dt; ai.threat = Math.max(0, ai.threat - dt);
// aim error is angular, not a fixed distance — a metres-based offset makes them
// wander uselessly at long range while being harmless up close
if (ai.jt > 0.5) {
ai.jt = 0;
ai.jitter.set(randSym(1), randSym(1), randSym(1)).multiplyScalar((1 - ai.skill) * 0.038 + 0.004);
}
const gh = groundAt(p.pos.x, p.pos.z);
const agl = p.pos.y - gh;
_a2.copy(p.pos).addScaledVector(p.vel, 3.0);
const aglAhead = _a2.y - groundAt(_a2.x, _a2.z);
const lowDanger = agl < 130 || aglAhead < 90;
const highDanger = p.pos.y > 3200;
const outside = Math.max(Math.abs(p.pos.x), Math.abs(p.pos.z)) > HALF * 0.86;
// ---- ground / boundary override -----------------------------------
if (lowDanger) {
_d.copy(p.vel); _d.y = 0;
if (_d.lengthSq() < 1) _d.copy(FWD).applyQuaternion(p.quat), _d.y = 0;
_d.normalize().multiplyScalar(0.42).add(_a3.set(0, 1, 0));
steerTo(p, _d, 1);
p.ctl.e = limitAlpha(p, Math.max(p.ctl.e, 0.55));
p.ctl.t = 1;
p.firing = false;
return;
}
if (outside) {
_d.set(-p.pos.x, 0, -p.pos.z).normalize();
_d.y = highDanger ? -0.25 : 0.05;
steerTo(p, _d, 0.8);
p.ctl.t = 1; p.firing = false;
return;
}
const tgt = (player && !player.dead && !player.dying) ? player : null;
if (!tgt) {
_d.set(-p.pos.x, (900 - p.pos.y) * 0.5, -p.pos.z).normalize();
steerTo(p, _d, 0.8); p.ctl.t = 0.7; p.firing = false; return;
}
_toT.subVectors(tgt.pos, p.pos);
const dist = _toT.length();
// ---- state machine -------------------------------------------------
if (ai.state === 'BREAK' && ai.t > ai.dur) { ai.state = 'EXTEND'; ai.t = 0; ai.dur = rand(2.5, 4.5); }
else if (ai.state === 'EXTEND' && (ai.t > ai.dur || dist > 1400)) { ai.state = 'ENGAGE'; ai.t = 0; }
else if (ai.state === 'ENGAGE' || ai.state === 'ATTACK') {
const overshoot = dist < 95;
const beingHit = ai.threat > 0 && rng() < 0.5 * dt * 8;
if ((overshoot || beingHit) && ai.t > 1.2) {
ai.state = 'BREAK'; ai.t = 0; ai.dur = rand(1.8, 3.4);
ai.breakDir = rng() < 0.5 ? -1 : 1; ai.breakUp = rng() < 0.45 ? 1 : -1;
}
}
let throttle = 1;
if (ai.state === 'BREAK') {
_a3.copy(FWD).applyQuaternion(p.quat);
_d.copy(_a3);
_rw.copy(RIGHT).applyQuaternion(p.quat);
_d.addScaledVector(_rw, 1.5 * ai.breakDir);
_d.y += 0.55 * ai.breakUp;
steerTo(p, _d.normalize(), 0);
p.firing = false;
throttle = 1;
} else if (ai.state === 'EXTEND') {
_d.subVectors(p.pos, tgt.pos).normalize();
_d.y = clamp(_d.y + 0.22, -0.3, 0.5);
steerTo(p, _d.normalize(), 0.5);
p.firing = false;
} else if (!ai.slot) {
// no attack slot: orbit the fight at stand-off range waiting for an opening
ai.orbitPhase += dt * 0.30;
const R = 620;
_aim.set(tgt.pos.x + Math.cos(ai.orbitPhase) * R, tgt.pos.y + 160, tgt.pos.z + Math.sin(ai.orbitPhase) * R);
_d.subVectors(_aim, p.pos).normalize();
steerTo(p, _d, 0.35);
p.firing = false;
throttle = dist < 400 ? 0.55 : 0.9;
} else {
// pursuit with lead
const closing = Math.max(60, p.speed);
const tof = clamp(dist / (GUN.muzzle + closing * 0.4), 0, 1.4);
_aim.copy(tgt.pos).addScaledVector(tgt.vel, tof).addScaledVector(ai.jitter, dist);
_d.subVectors(_aim, p.pos).normalize();
const off = steerTo(p, _d, 0);
_a3.copy(FWD).applyQuaternion(p.quat);
const noseOff = _a3.angleTo(_a2.copy(_toT).normalize());
throttle = dist < 150 ? 0.45 : dist < 300 ? 0.8 : 1;
// trigger discipline
ai.burst -= dt;
const canShoot = noseOff < 0.10 && dist > 50 && dist < 600 && off < 0.3;
if (canShoot && ai.burst < -0.25 * (1 - ai.skill) - 0.1) {
ai.burst = rand(0.35, 0.75) * (0.6 + ai.skill * 0.8);
ai.state = 'ATTACK';
}
p.firing = ai.burst > 0 && canShoot && p.ammo > 0;
if (p.ammo <= 0) p.ammo = GUN.ammo; // hostile ground crew is generous
}
// ---- separation from other aircraft --------------------------------
for (const o of planes) {
if (o === p || o.dead) continue;
const dx = p.pos.x - o.pos.x, dy = p.pos.y - o.pos.y, dz = p.pos.z - o.pos.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 < 6400 && d2 > 1) {
const f = (1 - Math.sqrt(d2) / 80) * 0.55;
p.ctl.a = clamp(p.ctl.a + (dx > 0 ? f : -f), -1, 1);
p.ctl.e = clamp(p.ctl.e + (dy > 0 ? f * 0.5 : -f * 0.5), -1, 1);
}
}
// energy management: don't hang on the prop
if (p.speed < 30) { throttle = 1; p.ctl.e = Math.min(p.ctl.e, 0.15); }
if (p.pos.y > 2600) throttle = Math.min(throttle, 0.8);
p.ctl.t += (throttle - p.ctl.t) * Math.min(1, dt * 2.2);
}
function updateAISlots() {
const live = planes.filter(p => p.ai && !p.dead && !p.dying);
if (!player || player.dead || player.dying) { live.forEach(p => p.ai.slot = false); return; }
live.sort((a, b) => a.pos.distanceToSquared(player.pos) - b.pos.distanceToSquared(player.pos));
live.forEach((p, i) => p.ai.slot = i < 2);
}
/* ==========================================================================
9. AUDIO (fully procedural — no assets)
========================================================================== */
const sfx = {
ready: false, muted: false, ctx: null,
init() {
if (this.ready) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const ctx = this.ctx = new AC();
this.master = ctx.createGain(); this.master.gain.value = 0.55;
this.master.connect(ctx.destination);
// noise source shared by wind / exhaust
const len = ctx.sampleRate * 2;
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const dt = buf.getChannelData(0);
for (let i = 0; i < len; i++) dt[i] = Math.random() * 2 - 1;
this.noise = buf;
// engine: two detuned saws + exhaust noise
this.eng = ctx.createGain(); this.eng.gain.value = 0;
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900; lp.Q.value = 1.1;
this.eng.connect(lp); lp.connect(this.master);
this.o1 = ctx.createOscillator(); this.o1.type = 'sawtooth';
this.o2 = ctx.createOscillator(); this.o2.type = 'square';
this.g1 = ctx.createGain(); this.g1.gain.value = 0.55;
this.g2 = ctx.createGain(); this.g2.gain.value = 0.22;
this.o1.connect(this.g1); this.o2.connect(this.g2);
this.g1.connect(this.eng); this.g2.connect(this.eng);
this.o1.start(); this.o2.start();
const ex = ctx.createBufferSource(); ex.buffer = buf; ex.loop = true;
this.exF = ctx.createBiquadFilter(); this.exF.type = 'bandpass';
this.exF.frequency.value = 220; this.exF.Q.value = 0.8;
this.exG = ctx.createGain(); this.exG.gain.value = 0;
ex.connect(this.exF); this.exF.connect(this.exG); this.exG.connect(this.master); ex.start();
// wind
const wn = ctx.createBufferSource(); wn.buffer = buf; wn.loop = true;
this.wF = ctx.createBiquadFilter(); this.wF.type = 'lowpass'; this.wF.frequency.value = 700;
this.wG = ctx.createGain(); this.wG.gain.value = 0;
wn.connect(this.wF); this.wF.connect(this.wG); this.wG.connect(this.master); wn.start();
// distant enemy engines
this.foeG = ctx.createGain(); this.foeG.gain.value = 0;
const fo = ctx.createOscillator(); fo.type = 'sawtooth'; fo.frequency.value = 90;
const foF = ctx.createBiquadFilter(); foF.type = 'lowpass'; foF.frequency.value = 420;
fo.connect(foF); foF.connect(this.foeG); this.foeG.connect(this.master); fo.start();
this.foeOsc = fo;
this.ready = true;
},
resume() { if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); },
setMuted(m) { this.muted = m; if (this.master) this.master.gain.value = m ? 0 : 0.55; },
engineState(rpm, speed, alive) {
if (!this.ready) return;
const t = this.ctx.currentTime;
const f = 42 + rpm * 122 + speed * 0.22;
this.o1.frequency.setTargetAtTime(f, t, 0.05);
this.o2.frequency.setTargetAtTime(f * 0.503, t, 0.05);
this.eng.gain.setTargetAtTime(alive ? 0.055 + rpm * 0.10 : 0.0, t, 0.08);
this.exG.gain.setTargetAtTime(alive ? 0.018 + rpm * 0.045 : 0.004, t, 0.08);
this.exF.frequency.setTargetAtTime(160 + rpm * 320, t, 0.08);
const w = clamp((speed - 18) / 70, 0, 1);
this.wG.gain.setTargetAtTime(0.012 + w * w * 0.16, t, 0.12);
this.wF.frequency.setTargetAtTime(320 + w * 1500, t, 0.12);
},
foeState(dist, closing) {
if (!this.ready) return;
const t = this.ctx.currentTime;
const g = dist > 900 ? 0 : (1 - dist / 900) * 0.09;
this.foeG.gain.setTargetAtTime(g, t, 0.15);
this.foeOsc.frequency.setTargetAtTime(88 * (1 + clamp(closing / 340, -0.35, 0.35)), t, 0.1);
},
burst(dur, freq, q, gain, type) {
if (!this.ready || this.muted || this.ctx.state !== 'running') return;
const ctx = this.ctx, t = ctx.currentTime;
const s = ctx.createBufferSource(); s.buffer = this.noise;
s.playbackRate.value = 0.7 + Math.random() * 0.7;
const f = ctx.createBiquadFilter(); f.type = type || 'bandpass';
f.frequency.value = freq; f.Q.value = q;
const g = ctx.createGain();
g.gain.setValueAtTime(gain, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(f); f.connect(g); g.connect(this.master);
s.start(t); s.stop(t + dur + 0.02);
},
gun() { this.burst(0.09, 1500 + Math.random() * 900, 1.1, 0.30); },
gunFar(pos) {
if (!this.ready || this.muted || this.ctx.state !== 'running') return;
const d = pos.distanceTo(camera.position);
if (d > 700) return;
this.burst(0.1, 900, 1.4, 0.11 * (1 - d / 700));
},
boom(scale, dist) {
if (!this.ready || this.muted || this.ctx.state !== 'running') return;
const att = clamp(1 - dist / 2400, 0, 1);
if (att <= 0) return;
this.burst(0.9 * scale, 260, 0.6, 0.55 * att, 'lowpass');
const ctx = this.ctx, t = ctx.currentTime;
const o = ctx.createOscillator(); o.type = 'sine';
o.frequency.setValueAtTime(120, t);
o.frequency.exponentialRampToValueAtTime(28, t + 0.6 * scale);
const g = ctx.createGain();
g.gain.setValueAtTime(0.5 * att, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.75 * scale);
o.connect(g); g.connect(this.master); o.start(t); o.stop(t + 0.8 * scale);
},
hit() { this.burst(0.13, 2600, 3.0, 0.26); },
kill() { this.burst(0.4, 700, 1.0, 0.2); },
};
/* ==========================================================================
10. GAME STATE
========================================================================== */
const planes = [];
const game = {
wave: 1, score: 0, kills: 0, streak: 0, best: 0, lives: 3,
waveRemaining: 0, betweenWaves: 0, spawnCd: 0, downT: 0, atEdge: false,
running: false, paused: false, over: false,
camMode: 0, freeLook: false,
};
const player = new Aircraft('player', 'ally', true);
planes.push(player);
player.jam = 0;
const FOE_SCHEMES = ['red', 'blue', 'green', 'mustard'];
FOE_SCHEMES.forEach(s => biplaneProto(s)); // warm the geometry cache
function spawnEnemy() {
const scheme = pick(FOE_SCHEMES);
// prefer an idle airframe in the chosen scheme, else recycle any idle one
let p = planes.find(q => q.dead && q.ai && q.scheme === scheme)
|| planes.find(q => q.dead && q.ai);
if (!p) { p = new Aircraft(scheme, 'axis'); planes.push(p); }
// Hostiles arrive already at merge range, with a height advantage. Enemy scouts
// also out-power the Camel — with identical performance a tail chase never closes
// and fights stall out at 600m.
const a = rand(TAU), dist = rand(480, 950);
const pos = new THREE.Vector3(
player.pos.x + Math.cos(a) * dist,
clamp(player.pos.y + rand(70, 340), 420, 2400),
player.pos.z + Math.sin(a) * dist);
pos.x = clamp(pos.x, -HALF * 0.78, HALF * 0.78);
pos.z = clamp(pos.z, -HALF * 0.78, HALF * 0.78);
pos.y = Math.max(pos.y, groundAt(pos.x, pos.z) + 320);
const q = new THREE.Quaternion();
const look = new THREE.Matrix4().lookAt(pos, player.pos, UP);
q.setFromRotationMatrix(look);
p.reset(pos, q, rand(48, 57));
p.ai = newAI(clamp(0.35 + game.wave * 0.07, 0.35, 0.95));
p.thrustMul = 1.14 + p.ai.skill * 0.16;
p.jam = 0;
return p;
}
function startWave(n) {
game.wave = n;
game.waveRemaining = Math.min(2 + n, 14);
game.waveAlive = 0;
banner('WAVE ' + n, game.waveRemaining + ' HOSTILE SCOUTS INBOUND');
}
function resetGame() {
game.score = 0; game.kills = 0; game.streak = 0; game.best = 0; game.lives = 3;
game.over = false; game.betweenWaves = 0; game.downT = 0; game.spawnCd = 0;
for (const p of planes) if (p !== player) p.remove();
for (const b of bullets) bulletFree.push(b);
bullets.length = 0;
for (const k of ['smoke', 'fire', 'spark']) { _pfree[k].push(...parts[k]); parts[k].length = 0; }
respawnPlayer();
startWave(1);
}
function respawnPlayer() {
const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, rand(TAU), 0));
const pos = new THREE.Vector3(randSym(900), 950, randSym(900));
player.reset(pos, q, 48);
player.firing = false;
ctlState.stick.set(0, 0);
ctlState.throttle = 0.85;
game.downT = 0;
damageFx.oil = 0; damageFx.red = 0;
camState.pos.copy(player.pos).addScaledVector(_a1.copy(FWD).applyQuaternion(player.quat), -14);
camState.pos.y += 3;
camState.quat.copy(player.quat);
}
/* ==========================================================================
11. INPUT
========================================================================== */
const keys = Object.create(null);
const ctlState = {
stick: new THREE.Vector2(0, 0),
throttle: 0.85, rudder: 0,
sens: 0.0022, invert: false, mouseMode: true, autoRudder: true,
autoCentre: true, mouseIdle: 0,
lookYaw: 0, lookPitch: 0,
};
addEventListener('keydown', e => {
const k = e.key.toLowerCase();
if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' '].includes(k)) e.preventDefault();
if (keys[k]) return;
keys[k] = true;
switch (k) {
case 'p': case 'escape': if (game.running) togglePause(); break;
case 'c': game.camMode = (game.camMode + 1) % 3; flash(['CHASE', 'COCKPIT', 'FLYBY'][game.camMode] + ' VIEW'); break;
case 'm': sfx.setMuted(!sfx.muted); flash(sfx.muted ? 'SOUND OFF' : 'SOUND ON'); break;
case 'n': ctlState.mouseMode = !ctlState.mouseMode;
flash(ctlState.mouseMode ? 'MOUSE STICK' : 'KEYBOARD STICK');
if (ctlState.mouseMode && game.running && !game.paused) lockPointer(); break;
case 'i': ctlState.invert = !ctlState.invert; updateInvertLabel(); flash('PITCH AXIS ' + (ctlState.invert ? 'INVERTED' : 'NORMAL')); break;
case 'k': ctlState.autoRudder = !ctlState.autoRudder; flash('AUTO RUDDER ' + (ctlState.autoRudder ? 'ON' : 'OFF')); break;
case 'z': ctlState.autoCentre = !ctlState.autoCentre;
flash('SELF-CENTRING STICK ' + (ctlState.autoCentre ? 'ON' : 'OFF')); break;
case 'x': ctlState.stick.set(0, 0); flash('STICK CENTRED'); break;
case '[': ctlState.sens = Math.max(0.0006, ctlState.sens * 0.8); flash('SENSITIVITY ' + (ctlState.sens * 1000).toFixed(1)); break;
case ']': ctlState.sens = Math.min(0.008, ctlState.sens * 1.25); flash('SENSITIVITY ' + (ctlState.sens * 1000).toFixed(1)); break;
case 'f': toggleFullscreen(); break;
case 'r': if (game.over) { hide('scr-dead'); resetGame(); game.running = true; } break;
}
});
addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; game.freeLook = false; });
const cv = renderer.domElement;
cv.addEventListener('mousedown', e => {
if (!game.running || game.paused) return;
if (e.button === 0) { player.firing = true; if (!document.pointerLockElement) lockPointer(); }
if (e.button === 2) game.freeLook = true;
});
addEventListener('mouseup', e => {
if (e.button === 0) player.firing = false;
if (e.button === 2) { game.freeLook = false; ctlState.lookYaw = ctlState.lookPitch = 0; }
});
cv.addEventListener('contextmenu', e => e.preventDefault());
addEventListener('mousemove', e => {
if (!game.running || game.paused) return;
const dx = e.movementX || 0, dy = e.movementY || 0;
if (game.freeLook) {
ctlState.lookYaw = clamp(ctlState.lookYaw - dx * 0.004, -Math.PI * 0.95, Math.PI * 0.95);
ctlState.lookPitch = clamp(ctlState.lookPitch - dy * 0.004, -1.2, 1.2);
return;
}
// deliberately not requiring pointer lock: if a lock request is refused (no
// user gesture after an Esc-pause, say) the player must still be able to fly
if (!ctlState.mouseMode) return;
ctlState.mouseIdle = 0;
ctlState.stick.x = clamp(ctlState.stick.x + dx * ctlState.sens, -1, 1);
ctlState.stick.y = clamp(ctlState.stick.y + dy * ctlState.sens, -1, 1);
});
addEventListener('wheel', e => {
if (!game.running) return;
ctlState.throttle = clamp(ctlState.throttle - Math.sign(e.deltaY) * 0.08, 0, 1);
}, { passive: true });
function lockPointer() { if (cv.requestPointerLock) cv.requestPointerLock(); }
addEventListener('pointerlockchange', () => {
if (!document.pointerLockElement && game.running && !game.paused && !game.over && ctlState.mouseMode) togglePause();
});
function toggleFullscreen() {
if (!document.fullscreenElement) document.documentElement.requestFullscreen && document.documentElement.requestFullscreen();
else document.exitFullscreen && document.exitFullscreen();
}
function readPlayerInput(dt) {
if (keys['w']) ctlState.throttle = clamp(ctlState.throttle + dt * 0.55, 0, 1);
if (keys['s']) ctlState.throttle = clamp(ctlState.throttle - dt * 0.55, 0, 1);
if (keys['shift']) ctlState.throttle = 1;
if (keys['control']) ctlState.throttle = 0;
if (ctlState.mouseMode) {
ctlState.mouseIdle += dt;
if (ctlState.autoCentre && ctlState.mouseIdle > 0.09) {
// roll returns to neutral briskly so a flick sets a bank and holds it;
// pitch washes out slowly, behaving like trim during a sustained turn
ctlState.stick.x *= Math.max(0, 1 - 2.4 * dt);
ctlState.stick.y *= Math.max(0, 1 - 0.30 * dt);
}
} else {
const rate = 2.2;
let sx = 0, sy = 0;
if (keys['arrowleft']) sx -= 1; if (keys['arrowright']) sx += 1;
if (keys['arrowup']) sy -= 1; if (keys['arrowdown']) sy += 1;
ctlState.stick.x = clamp(ctlState.stick.x + (sx ? sx * rate * dt : -clamp(ctlState.stick.x, -1, 1) * rate * dt), -1, 1);
ctlState.stick.y = clamp(ctlState.stick.y + (sy ? sy * rate * dt : -clamp(ctlState.stick.y, -1, 1) * rate * dt), -1, 1);
}
let rud = 0;
if (keys['a']) rud -= 1;
if (keys['d']) rud += 1;
ctlState.rudder += (rud - ctlState.rudder) * Math.min(1, dt * 6);
const dying = player.dying || player.dead;
const authority = dying ? 0.15 : 1;
player.ctl.a = clamp(ctlState.stick.x, -1, 1) * authority;
const pitch = ctlState.invert ? -ctlState.stick.y : ctlState.stick.y;
player.ctl.e = clamp(pitch, -1, 1) * authority;
let r = ctlState.rudder;
if (ctlState.autoRudder && Math.abs(rud) < 0.05 && !dying) r = clamp(1.3 * player.beta, -0.65, 0.65);
player.ctl.r = r * authority;
player.ctl.t = dying ? 0 : ctlState.throttle;
if (keys[' '] && !dying) player.firing = true;
else if (!keys[' '] && !mouseDown()) player.firing = false;
}
let _mouseDown = false;
addEventListener('mousedown', e => { if (e.button === 0) _mouseDown = true; });
addEventListener('mouseup', e => { if (e.button === 0) _mouseDown = false; });
function mouseDown() { return _mouseDown; }
/* ==========================================================================
12. CAMERA
========================================================================== */
const camState = {
pos: new THREE.Vector3(0, 950, 30),
quat: new THREE.Quaternion(),
shake: 0, flyby: new THREE.Vector3(), flybyT: 0,
};
const _camTarget = new THREE.Vector3(), _camQ = new THREE.Quaternion(), _look = new THREE.Matrix4();
const _off = new THREE.Vector3(), _tmpQ = new THREE.Quaternion();
function shake(amount) { camState.shake = Math.min(1.4, camState.shake + amount); }
function updateCamera(dt) {
const p = player;
const spd = p.speed;
const wantFov = 66 + 24 * clamp((spd - 25) / 70, 0, 1) + camState.shake * 4;
camera.fov += (wantFov - camera.fov) * Math.min(1, dt * 4);
const mode = p.dying || p.dead ? 2 : game.camMode;
const near = mode === 1 ? 0.15 : 0.6;
if (camera.near !== near) { camera.near = near; }
camera.updateProjectionMatrix();
if (mode === 1) {
// Cockpit. The eye sits forward of the true pilot position: at this fuselage
// scale anything within a metre of the eye fills the screen, so sitting in the
// actual seat means staring at cabane struts and gun breeches.
_off.set(0, 0.90, -0.55).applyQuaternion(p.quat);
camState.pos.copy(p.pos).add(_off);
camState.quat.copy(p.quat);
const vib = p.rpm * 0.0016 + camState.shake * 0.02;
_tmpQ.setFromEuler(new THREE.Euler(randSym(vib), randSym(vib), randSym(vib * 0.6)));
camState.quat.multiply(_tmpQ);
} else if (mode === 2 && (p.dying || p.dead)) {
// crash / kill cam — orbit the doomed machine
camState.flybyT += dt;
const a = camState.flybyT * 0.5;
_camTarget.set(Math.cos(a) * 34, 12, Math.sin(a) * 34).add(p.pos);
camState.pos.lerp(_camTarget, Math.min(1, dt * 3));
_look.lookAt(camState.pos, p.pos, UP);
camState.quat.setFromRotationMatrix(_look);
} else if (mode === 2) {
// flyby — a fixed point the aircraft sweeps past
if (camState.flybyT <= 0 || camState.flyby.distanceTo(p.pos) > 420) {
camState.flybyT = 4;
_off.copy(p.vel).setLength(190).add(p.pos);
camState.flyby.set(_off.x + randSym(90), Math.max(_off.y + randSym(60), groundAt(_off.x, _off.z) + 30), _off.z + randSym(90));
}
camState.flybyT -= dt;
camState.pos.copy(camState.flyby);
_look.lookAt(camState.pos, p.pos, UP);
camState.quat.setFromRotationMatrix(_look);
} else {
// chase with orientation lag — the swing is what sells the speed
_camQ.copy(p.quat);
camState.quat.slerp(_camQ, Math.min(1, dt * 7));
_off.set(0, 2.4, 11.5).applyQuaternion(camState.quat);
_camTarget.copy(p.pos).add(_off);
const gh = groundAt(_camTarget.x, _camTarget.z) + 9;
if (_camTarget.y < gh) _camTarget.y = gh;
camState.pos.lerp(_camTarget, Math.min(1, dt * 9));
_camTarget.copy(p.pos).addScaledVector(_a1.copy(FWD).applyQuaternion(p.quat), 9);
_off.copy(UP).applyQuaternion(camState.quat);
_look.lookAt(camState.pos, _camTarget, _off);
camState.quat.setFromRotationMatrix(_look);
}
camera.position.copy(camState.pos);
camera.quaternion.copy(camState.quat);
if (game.freeLook && mode !== 2) {
_tmpQ.setFromEuler(new THREE.Euler(ctlState.lookPitch, ctlState.lookYaw, 0, 'YXZ'));
camera.quaternion.multiply(_tmpQ);
}
if (camState.shake > 0.001) {
const s = camState.shake;
camera.position.x += randSym(s * 0.35);
camera.position.y += randSym(s * 0.35);
camera.position.z += randSym(s * 0.35);
_tmpQ.setFromEuler(new THREE.Euler(randSym(s * 0.012), randSym(s * 0.012), randSym(s * 0.02)));
camera.quaternion.multiply(_tmpQ);
camState.shake = Math.max(0, camState.shake - dt * 2.6);
}
sky.position.copy(camera.position);
}
/* ==========================================================================
13. HUD
========================================================================== */
const $ = id => document.getElementById(id);
const el = {
kph: $('v-kph'), ms: $('v-ms'), aoa: $('v-aoa'), alt: $('v-alt'), agl: $('v-agl'),
vs: $('v-vs'), thr: $('v-thr'), thrB: $('thr-b').firstElementChild,
hpB: $('hp-b').firstElementChild, ammo: $('v-ammo'), ammoB: $('ammo-b').firstElementChild,
heatB: $('heat-b').firstElementChild, score: $('v-score'), wave: $('v-wave'), foes: $('v-foes'),
strip: $('compass-strip'), stick: $('stick-k'), fps: $('fps'), kills: $('kills'),
wStall: $('w-stall'), wGround: $('w-ground'), wSix: $('w-six'), wAmmo: $('w-ammo'), wG: $('w-g'),
banner: $('banner'), bannerT: $('banner').querySelector('.t'), bannerS: $('banner').querySelector('.s'),
msg: $('msg'), pip: $('leadpip'), hitmark: $('hitmark'), red: $('redflash'), oil: $('oil'),
sight: $('sight'), stickBox: $('stick'),
};
// compass ribbon
{
const s = el.strip, PX = 4.2; // px per degree
for (let d = -360; d <= 720; d += 10) {
const nd = ((d % 360) + 360) % 360;
const t = document.createElement(nd % 90 === 0 ? 'span' : nd % 30 === 0 ? 'span' : 'b');
t.style.left = (d * PX) + 'px';
if (t.tagName === 'SPAN') t.textContent = nd === 0 ? 'N' : nd === 90 ? 'E' : nd === 180 ? 'S' : nd === 270 ? 'W' : String(nd);
s.appendChild(t);
}
el.stripPX = PX;
}
const blips = [];
for (let i = 0; i < 8; i++) {
const d = document.createElement('div');
d.className = 'blip';
d.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 2 L20 20 L12 15 L4 20 Z" fill="rgba(255,90,60,.9)" stroke="rgba(0,0,0,.6)" stroke-width="1"/></svg>';
$('hud').appendChild(d); blips.push(d);
}
const boxes = [];
for (let i = 0; i < 8; i++) {
const d = document.createElement('div');
d.className = 'tgtbox';
d.innerHTML = '<i style="left:-16px;top:-16px;border-right:0;border-bottom:0"></i>' +
'<i style="right:-16px;top:-16px;border-left:0;border-bottom:0"></i>' +
'<i style="left:-16px;bottom:-16px;border-right:0;border-top:0"></i>' +
'<i style="right:-16px;bottom:-16px;border-left:0;border-top:0"></i>' +
'<div class="d"></div>';
$('hud').appendChild(d); boxes.push(d);
}
let msgT = 0, bannerT = 0;
function flash(text) { el.msg.textContent = text; el.msg.style.opacity = '1'; msgT = 2.2; }
function banner(t, s) { el.bannerT.textContent = t; el.bannerS.textContent = s || ''; el.banner.style.opacity = '1'; bannerT = 3.0; }
function show(id) { $(id).classList.remove('hidden'); }
function hide(id) { $(id).classList.add('hidden'); }
function updateInvertLabel() {
const e = $('inv-state');
if (e) e.textContent = ctlState.invert ? 'NORMAL (mouse forward = nose up)' : 'SIM (mouse back = nose up)';
}
const damageFx = { oil: 0, red: 0, hitT: 0 };
function playerHurt(dmg) {
damageFx.red = Math.min(1, damageFx.red + dmg * 0.05);
shake(0.5);
sfx.hit();
}
function hitFeedback() { damageFx.hitT = 0.16; sfx.hit(); }
const _sp = new THREE.Vector3();
function project(v) {
_sp.copy(v).applyMatrix4(camera.matrixWorldInverse);
const behind = _sp.z > -0.05;
_sp.applyMatrix4(camera.projectionMatrix);
return {
x: (_sp.x * 0.5 + 0.5) * innerWidth,
y: (-_sp.y * 0.5 + 0.5) * innerHeight,
behind, ndx: _sp.x, ndy: _sp.y,
};
}
let hudAcc = 0, fpsAcc = 0, fpsN = 0;
const _lead = new THREE.Vector3(), _fwdW = new THREE.Vector3();
function updateHUD(dt) {
const p = player;
hudAcc = Math.max(0, hudAcc + dt);
const slow = hudAcc > 0.07;
if (slow) hudAcc = 0;
// projection below needs the camera matrices this frame, not last frame's
camera.updateMatrixWorld();
camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
const gh = groundAt(p.pos.x, p.pos.z);
const agl = p.pos.y - gh;
if (slow) {
el.kph.textContent = Math.round(p.speed * 3.6);
el.ms.textContent = p.speed.toFixed(0);
el.aoa.textContent = (p.alpha * DEG).toFixed(0);
el.alt.textContent = Math.round(p.pos.y);
el.agl.textContent = Math.round(Math.max(0, agl));
el.vs.textContent = (p.vel.y >= 0 ? '+' : '') + p.vel.y.toFixed(0);
el.thr.textContent = Math.round(ctlState.throttle * 100);
el.thrB.style.width = (ctlState.throttle * 100) + '%';
el.hpB.style.width = clamp(p.hp, 0, 100) + '%';
el.ammo.textContent = p.ammo;
el.ammoB.style.width = (p.ammo / GUN.ammo * 100) + '%';
el.heatB.style.width = clamp(p.heat * 100, 0, 100) + '%';
el.score.textContent = game.score;
el.wave.textContent = game.wave;
const foes = planes.filter(q => q.ai && !q.dead && !q.dying).length;
el.foes.textContent = foes + (game.waveRemaining > 0 ? ' (+' + game.waveRemaining + ')' : '');
el.kills.innerHTML = 'KILLS ' + game.kills + '<br>STREAK ' + game.streak +
'<br>BEST ' + game.best + '<br>MACHINES ' + Math.max(0, game.lives);
}
// heading ribbon
_fwdW.copy(FWD).applyQuaternion(p.quat);
let hdg = Math.atan2(_fwdW.x, -_fwdW.z) * DEG;
if (hdg < 0) hdg += 360;
el.strip.style.transform = 'translateX(' + (el.strip.parentElement.clientWidth / 2 - hdg * el.stripPX) + 'px)';
// stick indicator
el.stick.style.transform = 'translate(' + (ctlState.stick.x * 30) + 'px,' + (ctlState.stick.y * 30) + 'px)';
// warnings
const stalling = p.stallT > 0.12 && !p.dead;
const groundWarn = agl < 130 && p.vel.y < -4 && !p.dead;
el.wStall.classList.toggle('on', stalling);
el.wGround.classList.toggle('on', groundWarn);
el.wAmmo.classList.toggle('on', p.ammo < 90 && !p.dead);
el.wG.classList.toggle('on', Math.abs(p.gforce) > 6.2);
if (groundWarn && !p.dying) shake(dt * 0.5);
// threat check + blips + boxes
let sixThreat = false, bi = 0, bx = 0;
const targets = planes.filter(q => q.ai && !q.dead);
targets.sort((a, b) => a.pos.distanceToSquared(p.pos) - b.pos.distanceToSquared(p.pos));
let nearest = null, nearestD = 1e9;
for (const e of targets) {
const d = e.pos.distanceTo(p.pos);
if (d < nearestD) { nearestD = d; nearest = e; }
if (!e.dying && d < 420) {
_a1.subVectors(p.pos, e.pos).normalize();
_a2.copy(FWD).applyQuaternion(e.quat);
if (_a2.dot(_a1) > 0.94) sixThreat = true;
}
const s = project(e.pos);
const onScreen = !s.behind && s.x > 8 && s.x < innerWidth - 8 && s.y > 8 && s.y < innerHeight - 8;
if (onScreen && bx < boxes.length && d < 2400) {
const b = boxes[bx++];
const sc = clamp(110 / Math.max(d, 45), 0.4, 2.6);
b.style.transform = 'translate(' + s.x + 'px,' + s.y + 'px) scale(' + sc + ')';
b.style.opacity = String(clamp(1.15 - d / 2400, 0.25, 1));
const lab = b.lastElementChild;
lab.style.transform = 'translateX(-50%) scale(' + (1 / sc) + ')';
lab.textContent = Math.round(d) + 'm' + (e.dying ? ' ✝' : '');
} else if (bi < blips.length && d < 3000 && !e.dying) {
const bl = blips[bi++];
let x = s.x, y = s.y;
if (s.behind) { x = innerWidth - x; y = innerHeight - y; }
const cx = innerWidth / 2, cy = innerHeight / 2;
let dx = x - cx, dy = y - cy;
const m = Math.max(Math.abs(dx) / (cx - 46), Math.abs(dy) / (cy - 46), 0.001);
if (m > 1 || s.behind) { dx /= m; dy /= m; }
bl.style.transform = 'translate(' + (cx + dx) + 'px,' + (cy + dy) + 'px) rotate(' + (Math.atan2(dy, dx) * DEG + 90) + 'deg)';
bl.style.opacity = '0.92';
}
}
for (let i = bi; i < blips.length; i++) blips[i].style.opacity = '0';
for (let i = bx; i < boxes.length; i++) boxes[i].style.opacity = '0';
el.wSix.classList.toggle('on', sixThreat && !p.dead);
// lead pip
if (nearest && !nearest.dying && nearestD < 900 && !p.dead) {
const tof = nearestD / (GUN.muzzle + Math.max(0, p.speed) * 0.4);
_lead.copy(nearest.pos).addScaledVector(_a1.subVectors(nearest.vel, p.vel), tof);
_lead.y -= 0.5 * 9.81 * tof * tof;
const s = project(_lead);
if (!s.behind) {
el.pip.style.transform = 'translate(' + s.x + 'px,' + s.y + 'px)';
el.pip.style.opacity = '1';
} else el.pip.style.opacity = '0';
} else el.pip.style.opacity = '0';
// damage overlays
const dmg = 1 - clamp(p.hp, 0, 100) / 100;
damageFx.oil = lerp(damageFx.oil, p.dead ? 0 : smoothstep(0.55, 1, dmg), Math.min(1, dt * 2));
el.oil.style.opacity = String(damageFx.oil * 0.85);
if (damageFx.oil > 0.01 && !el.oil.style.backgroundImage) {
el.oil.style.backgroundImage = 'radial-gradient(circle at 22% 30%, rgba(40,28,14,.9) 0 6%, transparent 12%),' +
'radial-gradient(circle at 68% 22%, rgba(30,20,10,.85) 0 4%, transparent 10%),' +
'radial-gradient(circle at 44% 72%, rgba(35,24,12,.8) 0 7%, transparent 14%),' +
'radial-gradient(circle at 82% 64%, rgba(28,18,9,.85) 0 5%, transparent 11%),' +
'radial-gradient(circle at 12% 82%, rgba(38,26,13,.75) 0 4%, transparent 9%)';
}
damageFx.red = Math.max(0, damageFx.red - dt * 1.6);
el.red.style.opacity = String(damageFx.red * 0.5);
damageFx.hitT = Math.max(0, damageFx.hitT - dt);
el.hitmark.style.opacity = String(damageFx.hitT > 0 ? 1 : 0);
el.sight.style.opacity = String(game.camMode === 1 ? 0.75 : 0.9);
if (msgT > 0) { msgT -= dt; if (msgT <= 0) el.msg.style.opacity = '0'; }
if (bannerT > 0) { bannerT -= dt; if (bannerT <= 0) el.banner.style.opacity = '0'; }
fpsAcc = Math.max(0, fpsAcc + dt); fpsN++;
if (fpsAcc > 0.5) {
const f = fpsN / fpsAcc;
el.fps.textContent = f.toFixed(0) + ' fps';
if (f < 34 && renderer.getPixelRatio() > 1) renderer.setPixelRatio(1);
fpsAcc = 0; fpsN = 0;
}
}
/* ==========================================================================
14. VISUAL SYNC + WORLD RULES
========================================================================== */
function syncMesh(p) {
if (p.dead) { p.mesh.visible = false; return; }
p.mesh.visible = true;
p.mesh.position.copy(p.pos);
p.mesh.quaternion.copy(p.quat);
const ud = p.mesh.userData;
ud.prop.rotation.z = p.propAngle;
const blur = clamp((p.rpm - 0.25) / 0.4, 0, 1);
ud.blades[0].visible = ud.blades[1].visible = blur < 0.98;
ud.blades[0].material.opacity = 1;
ud.disc.material.opacity = blur * 0.55;
// hide own aircraft's near geometry in cockpit view? keep it — the cowl sells the view
}
function planeSmoke(p, dt) {
const dmg = 1 - clamp(p.hp, 0, 100) / 100;
if (dmg < 0.3 && !p.dying) return;
const rate = p.dying ? 34 : dmg < 0.55 ? 7 : 18;
p.smokeAcc += dt * rate;
while (p.smokeAcc >= 1) {
p.smokeAcc -= 1;
_a1.copy(FWD).applyQuaternion(p.quat).multiplyScalar(2.0).add(p.pos);
const dark = p.dying ? 0.10 : lerp(0.55, 0.16, smoothstep(0.4, 0.9, dmg));
emit('smoke', _a1.x + randSym(0.6), _a1.y + randSym(0.6), _a1.z + randSym(0.6),
p.vel.x * 0.35 + randSym(2), p.vel.y * 0.35 + rand(0, 3), p.vel.z * 0.35 + randSym(2),
1.6 + rand(1.4), p.dying ? 7 : 4.2, p.dying ? rand(2.4, 4.6) : rand(1.2, 2.6),
dark, dark * 0.96, dark * 0.92, p.dying ? 0.85 : 0.55);
if (p.dying && rng() < 0.35) {
emit('fire', _a1.x, _a1.y, _a1.z, p.vel.x * 0.4, p.vel.y * 0.4 + 2, p.vel.z * 0.4,
1.4 + rand(1.4), 2.5, rand(0.18, 0.42), 1, 0.55, 0.16, 0.9);
}
}
}
function crash(p) {
const dist = p.pos.distanceTo(camera.position);
explosion(p.pos.x, Math.max(p.pos.y, groundAt(p.pos.x, p.pos.z) + 2), p.pos.z, p.isPlayer ? 1.5 : 1.3);
sfx.boom(1, dist);
shake(p.isPlayer ? 1.3 : clamp(1 - dist / 900, 0, 1) * 0.9);
if (p.isPlayer) playerDown();
p.remove();
}
function playerDown() {
if (game.downT > 0) return;
game.streak = 0;
game.lives--;
game.downT = 3.4;
}
function worldRules(dt) {
for (const p of planes) {
if (p.dead) continue;
// ground / water impact
const gy = groundAt(p.pos.x, p.pos.z);
if (p.pos.y <= gy + 2.4) { p.pos.y = gy + 2.4; crash(p); continue; }
if (p.dying) {
p.dieT += dt;
// dead engine, controls streaming — a slow smoking spiral down
if (p.dieSpin === undefined) p.dieSpin = randSym(0.5) + (rng() < 0.5 ? -0.25 : 0.25);
p.ctl.t = 0;
p.ctl.e = clamp(0.14 + Math.sin(p.dieT * 0.9) * 0.1, -1, 1);
p.ctl.a = clamp(p.dieSpin, -1, 1);
p.ctl.r = 0;
p.firing = false;
if (p.dieT > 18) { explosion(p.pos.x, p.pos.y, p.pos.z, 1.2); sfx.boom(1, p.pos.distanceTo(camera.position)); if (p.isPlayer) playerDown(); p.remove(); }
}
}
// structural stress. The AIRFRAME light comes on at 6.2g, well before this
// bites at 7g, so over-pulling in a dive is a warned-about mistake.
if (!player.dead && !player.dying) {
const g = Math.abs(player.gforce);
if (g > 7) {
player.hp -= (g - 7) * 7.5 * dt;
if (player.hp <= 0) { flash('WINGS FAILED'); killPlane(player, null); }
}
}
// soft world boundary — shepherd the player back rather than hitting a wall
let atEdge = false;
if (!player.dead && !player.dying) {
const edge = Math.max(Math.abs(player.pos.x), Math.abs(player.pos.z));
if (edge > HALF * 0.78) {
atEdge = true;
if (edge > HALF * 0.88) {
_a1.set(-player.pos.x, 0, -player.pos.z).normalize();
_a2.copy(FWD).applyQuaternion(player.quat);
const side = _a2.x * _a1.z - _a2.z * _a1.x;
player.ctl.a = clamp(player.ctl.a + (side > 0 ? -0.5 : 0.5), -1, 1);
}
}
}
if (atEdge !== game.atEdge) el.wGround.textContent = atEdge ? 'RETURN TO THE FRONT' : 'PULL UP';
game.atEdge = atEdge;
// wave management
const aliveFoes = planes.filter(q => q.ai && !q.dead && !q.dying).length;
if (!game.over) {
if (game.waveRemaining > 0 && aliveFoes < Math.min(CFG.maxAlive, 2 + Math.floor(game.wave / 2))) {
game.spawnCd = (game.spawnCd || 0) - dt;
if (game.spawnCd <= 0) {
spawnEnemy();
game.waveRemaining--;
game.spawnCd = rand(1.2, 3.0);
}
}
if (game.waveRemaining === 0 && aliveFoes === 0 && planes.every(q => !q.ai || q.dead)) {
if (game.betweenWaves <= 0) {
game.betweenWaves = 5;
const bonus = 250 * game.wave;
game.score += bonus;
banner('WAVE ' + game.wave + ' CLEARED', '+' + bonus + ' PTS · reinforcements sighted');
} else {
game.betweenWaves -= dt;
if (game.betweenWaves <= 0) { startWave(game.wave + 1); game.betweenWaves = 0; }
}
}
}
// player down → crash cam → respawn, or end of patrol when out of machines
if (game.downT > 0) {
game.downT -= dt;
if (game.downT <= 0) {
if (game.lives > 0) {
respawnPlayer();
banner('NEW MACHINE', game.lives + ' spare' + (game.lives === 1 ? '' : 's') + ' left on the field');
} else {
game.over = true;
$('dead-score').textContent = game.score;
$('dead-kills').textContent = game.kills;
$('dead-wave').textContent = game.wave;
show('scr-dead');
if (document.pointerLockElement) document.exitPointerLock();
}
}
}
}
/* ==========================================================================
15. MAIN LOOP
========================================================================== */
let acc = 0, last = performance.now(), loopOn = true;
let slotTick = 0;
function physicsStep(dt) {
if (--slotTick <= 0) { updateAISlots(); slotTick = 12; }
for (const p of planes) {
if (p.dead) continue;
if (p.ai && !p.dying) aiUpdate(p, dt);
stepAircraft(p, dt);
tryFire(p, dt);
}
stepBullets(dt);
}
function frame(now) {
if (!loopOn) return;
requestAnimationFrame(frame);
const raw = clamp((now - last) / 1000, 0, 0.1);
last = now;
if (game.running && !game.paused) {
readPlayerInput(raw);
acc += raw;
let n = 0;
while (acc >= CFG.fixed && n < 6) { physicsStep(CFG.fixed); acc -= CFG.fixed; n++; }
if (n >= 6) acc = 0;
worldRules(raw);
for (const p of planes) if (!p.dead) planeSmoke(p, raw);
}
for (const p of planes) syncMesh(p);
updateParticles(game.paused ? 0 : raw);
drawBullets();
updateCamera(game.paused ? 0.0001 : raw);
updateHUD(raw);
if (sfx.ready) {
sfx.engineState(player.rpm, player.speed, !player.dying && !player.dead && game.running);
let nd = 1e9, closing = 0;
for (const p of planes) if (p.ai && !p.dead) {
const d = p.pos.distanceTo(camera.position);
if (d < nd) { nd = d; closing = _a1.subVectors(p.vel, player.vel).dot(_a2.subVectors(player.pos, p.pos).normalize()); }
}
sfx.foeState(nd, closing);
}
renderer.render(scene, camera);
}
function togglePause() {
game.paused = !game.paused;
if (game.paused) {
show('scr-pause');
$('pause-cfg').innerHTML = 'Pitch axis: <b>' + (ctlState.invert ? 'NORMAL' : 'SIM') + '</b> &middot; ' +
'Auto rudder: <b>' + (ctlState.autoRudder ? 'ON' : 'OFF') + '</b> &middot; ' +
'Self-centring: <b>' + (ctlState.autoCentre ? 'ON' : 'OFF') + '</b> &middot; ' +
'Sensitivity: <b>' + (ctlState.sens * 1000).toFixed(1) + '</b> &middot; ' +
'Stick: <b>' + (ctlState.mouseMode ? 'MOUSE' : 'KEYBOARD') + '</b>';
if (document.pointerLockElement) document.exitPointerLock();
} else {
hide('scr-pause');
if (ctlState.mouseMode) lockPointer();
}
}
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
/* ==========================================================================
16. BOOT
========================================================================== */
function startGame() {
hide('scr-start'); hide('scr-dead'); hide('scr-pause');
sfx.init(); sfx.resume();
resetGame();
game.running = true; game.paused = false;
last = performance.now(); acc = 0;
if (ctlState.mouseMode) lockPointer();
}
$('btn-start').addEventListener('click', startGame);
$('btn-resume').addEventListener('click', () => { if (game.paused) togglePause(); });
$('btn-respawn').addEventListener('click', () => { hide('scr-dead'); resetGame(); game.running = true; sfx.resume(); if (ctlState.mouseMode) lockPointer(); });
updateInvertLabel();
respawnPlayer();
startWave(1);
camState.pos.copy(player.pos).add(new THREE.Vector3(0, 3, 14));
updateCamera(0.016);
renderer.render(scene, camera);
$('boot').style.display = 'none';
requestAnimationFrame(frame);
/* ==========================================================================
17. TEST HOOKS
========================================================================== */
window.__sim = {
ready: true,
THREE, scene, camera, renderer,
game, planes, player, bullets, CFG, AERO, GUN, ctlState, keys,
heightAt, groundAt, spawnEnemy, explosion,
start() { startGame(); },
pauseLoop() { loopOn = false; },
resumeLoop() { if (!loopOn) { loopOn = true; last = performance.now(); requestAnimationFrame(frame); } },
step(n = 1, dt = CFG.fixed) {
for (let i = 0; i < n; i++) { physicsStep(dt); worldRules(dt); for (const p of planes) if (!p.dead) planeSmoke(p, dt); }
},
// camSteps > 1 lets the spring-follow chase camera settle before a screenshot
renderOnce(camSteps = 1) {
for (const p of planes) syncMesh(p);
drawBullets();
updateParticles(0.016);
for (let i = 0; i < camSteps; i++) updateCamera(0.016);
updateHUD(0.2);
renderer.render(scene, camera);
},
setInput(o) { Object.assign(ctlState, o || {}); },
clearHudState() { damageFx.red = 0; damageFx.oil = 0; damageFx.hitT = 0; bannerT = 0; msgT = 0;
el.banner.style.opacity = '0'; el.msg.style.opacity = '0'; },
setCtl(o) { Object.assign(player.ctl, o || {}); },
stepInput(dt) { readPlayerInput(dt); },
togglePause() { togglePause(); },
state() {
const v = x => ({ x: x.x, y: x.y, z: x.z });
return {
pos: v(player.pos), vel: v(player.vel), ang: v(player.ang),
quat: { x: player.quat.x, y: player.quat.y, z: player.quat.z, w: player.quat.w },
speed: player.speed, alpha: player.alpha, beta: player.beta, g: player.gforce,
hp: player.hp, ammo: player.ammo, heat: player.heat, alive: !player.dying && !player.dead,
agl: player.pos.y - groundAt(player.pos.x, player.pos.z),
bullets: bullets.length, foes: planes.filter(p => p.ai && !p.dead && !p.dying).length,
score: game.score, kills: game.kills, wave: game.wave, over: game.over,
particles: parts.smoke.length + parts.fire.length + parts.spark.length,
};
},
teleport(x, y, z, hdgRad = 0, speed = 48) {
const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, hdgRad, 0));
player.reset(new THREE.Vector3(x, y, z), q, speed);
},
placeFoeAhead(dist = 150) {
const p = spawnEnemy();
_a1.copy(FWD).applyQuaternion(player.quat);
p.pos.copy(player.pos).addScaledVector(_a1, dist);
p.quat.copy(player.quat);
p.vel.copy(player.vel);
p.ai.slot = false;
return p;
},
errors: window.__errors,
};
window.dispatchEvent(new Event('sim-ready'));
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment