Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 11, 2026 14:33
Show Gist options
  • Select an option

  • Save senko/59db5ee958eb7c5a3039ef5cba57ccf8 to your computer and use it in GitHub Desktop.

Select an option

Save senko/59db5ee958eb7c5a3039ef5cba57ccf8 to your computer and use it in GitHub Desktop.
Flying simulator by Claude Fable 5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Aces over Flanders — 1917</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Graduate&family=Special+Elite&display=swap" rel="stylesheet">
<style>
:root{
--paper:#e9dfc6;
--paper-dk:#d8cba8;
--ink:#241c12;
--ink-soft:rgba(36,28,18,.72);
--brass:#b3873c;
--brass-dk:#7a5a24;
--red:#9c2b1f;
--cream:#f4ecd7;
--olive:#5c6134;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden;background:#0e0c08}
body{font-family:'Special Elite', 'Courier New', monospace; color:var(--ink); user-select:none; -webkit-user-select:none; cursor:crosshair}
canvas{display:block}
/* ---------- film grain + grading ---------- */
#grain{position:fixed;inset:-50%;width:200%;height:200%;pointer-events:none;z-index:40;opacity:.055;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)'/%3E%3C/svg%3E");
animation:grain 0.9s steps(4) infinite}
@keyframes grain{0%{transform:translate(0,0)}25%{transform:translate(-2%,1%)}50%{transform:translate(1%,-2%)}75%{transform:translate(-1%,-1%)}100%{transform:translate(2%,2%)}}
#vignette{position:fixed;inset:0;pointer-events:none;z-index:30;
background:radial-gradient(ellipse at center, transparent 55%, rgba(46,30,12,.38) 100%)}
#hitflash{position:fixed;inset:0;pointer-events:none;z-index:31;opacity:0;
background:radial-gradient(ellipse at center, transparent 30%, rgba(140,20,8,.55) 100%);
transition:opacity .5s ease-out}
#lowhp{position:fixed;inset:0;pointer-events:none;z-index:31;opacity:0;
background:radial-gradient(ellipse at center, transparent 45%, rgba(120,15,5,.45) 100%)}
.pulsing{animation:lowpulse 1.4s ease-in-out infinite}
@keyframes lowpulse{0%,100%{opacity:.15}50%{opacity:.6}}
/* ---------- HUD ---------- */
#hud{position:fixed;inset:0;pointer-events:none;z-index:20;display:none}
#crosshair{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);opacity:.85}
/* instrument cluster, bottom-left */
#cluster{position:absolute;left:18px;bottom:14px;display:flex;align-items:flex-end;gap:12px}
.gauge{position:relative;width:98px;height:98px;border-radius:50%;
background:
radial-gradient(circle at 35% 30%, #f2ead2 0%, #e3d7b6 55%, #cdbd94 100%);
box-shadow:
0 0 0 3px var(--brass),
0 0 0 5px var(--brass-dk),
0 4px 14px rgba(0,0,0,.55),
inset 0 2px 8px rgba(255,255,255,.5),
inset 0 -4px 10px rgba(90,60,20,.35)}
.gauge::before{content:"";position:absolute;inset:7px;border-radius:50%;
background:repeating-conic-gradient(var(--ink-soft) 0deg 1.6deg, transparent 1.6deg 30deg);
-webkit-mask:radial-gradient(closest-side, transparent 72%, #000 74%, #000 94%, transparent 96%);
mask:radial-gradient(closest-side, transparent 72%, #000 74%, #000 94%, transparent 96%);opacity:.75}
.gauge .val{position:absolute;left:0;right:0;top:56%;text-align:center;
font-family:'Graduate',serif;font-size:17px;letter-spacing:1px;color:var(--ink)}
.gauge .lbl{position:absolute;left:0;right:0;top:24%;text-align:center;
font-size:9px;letter-spacing:2px;color:var(--ink-soft);text-transform:uppercase}
.needle{position:absolute;left:50%;top:50%;width:3px;height:40px;margin-left:-1.5px;
background:linear-gradient(var(--red) 0 70%, var(--ink) 70% 100%);
transform-origin:50% 100%;transform:translateY(-100%) rotate(0deg);border-radius:2px 2px 0 0;
box-shadow:0 0 3px rgba(0,0,0,.4)}
.pin{position:absolute;left:50%;top:50%;width:9px;height:9px;margin:-4.5px 0 0 -4.5px;border-radius:50%;
background:radial-gradient(circle at 35% 30%, #d8b264, var(--brass-dk));box-shadow:0 1px 2px rgba(0,0,0,.5)}
#compassCard{position:absolute;inset:10px;border-radius:50%;
font-family:'Graduate',serif;font-size:12px;color:var(--ink)}
#compassCard span{position:absolute;left:50%;top:50%;width:16px;margin-left:-8px;text-align:center}
#compassCard .n{color:var(--red)}
#lubber{position:absolute;left:50%;top:5px;margin-left:-4px;width:0;height:0;
border-left:4px solid transparent;border-right:4px solid transparent;border-top:8px solid var(--red)}
/* throttle quadrant */
#throttleBox{width:34px;height:98px;position:relative;border-radius:6px;
background:linear-gradient(#e6dabb, #cdbd94);
box-shadow:0 0 0 2px var(--brass),0 0 0 4px var(--brass-dk),0 4px 12px rgba(0,0,0,.5),inset 0 2px 6px rgba(255,255,255,.4)}
#throttleBox .slot{position:absolute;left:50%;top:10px;bottom:10px;width:5px;margin-left:-2.5px;border-radius:3px;
background:linear-gradient(#3a2f1d,#241c12);box-shadow:inset 0 1px 3px #000}
#throttleHandle{position:absolute;left:50%;width:24px;height:10px;margin-left:-12px;border-radius:3px;
background:linear-gradient(#d8b264,#8a6526);box-shadow:0 2px 4px rgba(0,0,0,.5);bottom:12px}
#throttleBox .lbl{position:absolute;bottom:-16px;left:0;right:0;text-align:center;font-size:8px;letter-spacing:1px;color:var(--cream);opacity:.8}
/* right cluster: airframe / guns / victories */
#status{position:absolute;right:18px;bottom:16px;width:210px;padding:10px 12px 12px;border-radius:4px;
background:linear-gradient(160deg, rgba(233,223,198,.92), rgba(205,189,148,.92));
box-shadow:0 0 0 2px var(--brass),0 0 0 4px var(--brass-dk),0 5px 16px rgba(0,0,0,.5)}
#status .row{margin-bottom:8px}
#status .row:last-child{margin-bottom:0}
#status .tag{display:flex;justify-content:space-between;font-size:10px;letter-spacing:2px;color:var(--ink-soft);
text-transform:uppercase;margin-bottom:3px}
.bar{height:11px;border:1px solid var(--ink);background:rgba(36,28,18,.15);position:relative;overflow:hidden}
.bar .fill{position:absolute;inset:1px auto 1px 1px;width:100%;transition:width .15s}
.bar .seg{position:absolute;inset:0;background:repeating-linear-gradient(90deg, transparent 0 9%, rgba(36,28,18,.55) 9% 10%)}
#hpFill{background:linear-gradient(90deg,#5c6134,#7c8148)}
#heatFill{background:linear-gradient(90deg,#b3873c,#9c2b1f)}
#victories{font-family:'Graduate',serif;font-size:22px;letter-spacing:2px;color:var(--ink)}
#hostiles{font-size:11px;color:var(--ink-soft);letter-spacing:1px}
/* banner + warnings */
#banner{position:absolute;left:50%;top:22%;transform:translateX(-50%) scale(1);
font-family:'Graduate',serif;font-size:38px;letter-spacing:8px;color:var(--red);
text-shadow:0 2px 0 rgba(244,236,215,.6), 0 3px 10px rgba(0,0,0,.4);
opacity:0;white-space:nowrap}
#banner.stamp{animation:stamp 2.2s ease-out forwards}
@keyframes stamp{0%{opacity:0;transform:translateX(-50%) scale(2.2) rotate(-6deg)}
12%{opacity:1;transform:translateX(-50%) scale(1) rotate(-2deg)}
75%{opacity:1}100%{opacity:0;transform:translateX(-50%) scale(1) rotate(-2deg)}}
#warning{position:absolute;left:50%;top:60%;transform:translateX(-50%);
font-family:'Graduate',serif;font-size:20px;letter-spacing:6px;color:var(--cream);
text-shadow:0 0 12px rgba(156,43,31,.9), 0 2px 4px #000;opacity:0}
#warning.on{opacity:1;animation:blink .5s step-end infinite}
@keyframes blink{50%{opacity:.25}}
/* enemy pointer + target box */
#arrow{position:absolute;width:0;height:0;opacity:0;
border-top:9px solid transparent;border-bottom:9px solid transparent;border-left:16px solid var(--red);
filter:drop-shadow(0 0 4px rgba(0,0,0,.7))}
#target{position:absolute;width:46px;height:46px;opacity:0;transform:translate(-50%,-50%)}
#target .c{position:absolute;width:12px;height:12px;border:2px solid rgba(156,43,31,.9)}
#target .c1{left:0;top:0;border-right:none;border-bottom:none}
#target .c2{right:0;top:0;border-left:none;border-bottom:none}
#target .c3{left:0;bottom:0;border-right:none;border-top:none}
#target .c4{right:0;bottom:0;border-left:none;border-top:none}
#target .dist{position:absolute;top:100%;left:50%;transform:translateX(-50%);margin-top:3px;
font-size:11px;color:var(--cream);text-shadow:0 1px 3px #000;letter-spacing:1px;white-space:nowrap}
#hint{position:absolute;left:20px;top:16px;font-size:11px;letter-spacing:1px;color:rgba(244,236,215,.75);
text-shadow:0 1px 3px #000;line-height:1.7}
/* ---------- overlays (start / dead) ---------- */
.overlay{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;
background:
radial-gradient(ellipse at 50% 40%, rgba(60,44,20,.25), rgba(20,14,6,.82) 90%)}
.poster{position:relative;width:min(680px, 92vw);max-height:92vh;overflow:auto;padding:44px 52px 38px;text-align:center;
background:
linear-gradient(170deg, #eee3c8 0%, #e2d5b0 45%, #d3c298 100%);
box-shadow:0 24px 80px rgba(0,0,0,.8), inset 0 0 60px rgba(122,90,36,.25);
color:var(--ink)}
.poster::before{content:"";position:absolute;inset:10px;border:2px solid var(--ink);pointer-events:none}
.poster::after{content:"";position:absolute;inset:15px;border:1px solid var(--ink);pointer-events:none}
.eyebrow{font-size:12px;letter-spacing:6px;text-transform:uppercase;color:var(--ink-soft);margin-bottom:14px}
h1{font-family:'Graduate',serif;font-size:clamp(34px,6.4vw,58px);line-height:1.05;letter-spacing:4px;margin-bottom:4px}
h1 .red{color:var(--red)}
.sub{font-size:14px;letter-spacing:3px;margin-bottom:18px;color:var(--ink-soft)}
.rule{display:flex;align-items:center;gap:12px;margin:14px 0 18px;color:var(--brass-dk)}
.rule::before,.rule::after{content:"";flex:1;height:1px;background:var(--ink-soft);opacity:.5}
.controls{display:grid;grid-template-columns:1fr 1fr;gap:6px 30px;text-align:left;font-size:13px;
max-width:480px;margin:0 auto 20px}
.controls div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px dotted rgba(36,28,18,.35);padding:3px 0}
kbd{font-family:'Graduate',serif;font-size:11px;background:var(--ink);color:var(--cream);
padding:2px 7px;border-radius:2px;letter-spacing:1px;white-space:nowrap}
.cta{display:inline-block;margin-top:6px;font-family:'Graduate',serif;font-size:18px;letter-spacing:4px;
color:var(--cream);background:var(--red);padding:12px 30px;box-shadow:0 4px 0 #5e170f, 0 8px 18px rgba(0,0,0,.4);
animation:bob 2s ease-in-out infinite;cursor:pointer}
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
.fine{margin-top:16px;font-size:11px;color:var(--ink-soft);letter-spacing:1px}
#deadOverlay{display:none}
#deadTitle{color:var(--red)}
#deadScore{font-family:'Graduate',serif;font-size:24px;letter-spacing:3px;margin:10px 0 4px}
.stamp2{position:absolute;top:26px;right:30px;transform:rotate(12deg);
font-family:'Graduate',serif;font-size:13px;letter-spacing:3px;color:var(--red);
border:3px double var(--red);padding:6px 10px;opacity:.85}
</style>
</head>
<body>
<div id="grain"></div>
<div id="vignette"></div>
<div id="hitflash"></div>
<div id="lowhp"></div>
<!-- ======================= HUD ======================= -->
<div id="hud">
<svg id="crosshair" width="72" height="72" viewBox="0 0 72 72">
<circle cx="36" cy="36" r="17" fill="none" stroke="#f4ecd7" stroke-width="1.4" opacity=".9"/>
<circle cx="36" cy="36" r="1.8" fill="#f4ecd7"/>
<line x1="36" y1="8" x2="36" y2="20" stroke="#f4ecd7" stroke-width="1.4"/>
<line x1="36" y1="52" x2="36" y2="64" stroke="#f4ecd7" stroke-width="1.4"/>
<line x1="8" y1="36" x2="20" y2="36" stroke="#f4ecd7" stroke-width="1.4"/>
<line x1="52" y1="36" x2="64" y2="36" stroke="#f4ecd7" stroke-width="1.4"/>
</svg>
<div id="hint">SPACE — fire &nbsp;·&nbsp; W/S — pitch &nbsp;·&nbsp; A/D — roll &nbsp;·&nbsp; ↑/↓ — throttle &nbsp;·&nbsp; C — camera &nbsp;·&nbsp; M — sound</div>
<div id="cluster">
<div class="gauge" id="gSpd">
<div class="lbl">Airspeed</div>
<div class="needle" id="spdNeedle"></div><div class="pin"></div>
<div class="val"><span id="spdVal">0</span></div>
</div>
<div class="gauge" id="gAlt">
<div class="lbl">Altitude</div>
<div class="needle" id="altNeedle"></div><div class="pin"></div>
<div class="val"><span id="altVal">0</span></div>
</div>
<div class="gauge" id="gCmp">
<div class="lbl" style="top:16%">Compass</div>
<div id="lubber"></div>
<div id="compassCard">
<span class="n" style="transform:translateY(-34px)">N</span>
<span style="transform:rotate(90deg) translateY(-34px)">E</span>
<span style="transform:rotate(180deg) translateY(-34px)">S</span>
<span style="transform:rotate(270deg) translateY(-34px)">W</span>
</div>
<div class="pin"></div>
</div>
<div id="throttleBox">
<div class="slot"></div>
<div id="throttleHandle"></div>
<div class="lbl">THR</div>
</div>
</div>
<div id="status">
<div class="row">
<div class="tag"><span>Airframe</span><span id="hpPct">100%</span></div>
<div class="bar"><div class="fill" id="hpFill"></div><div class="seg"></div></div>
</div>
<div class="row">
<div class="tag"><span>Vickers ·303</span><span id="heatLbl">READY</span></div>
<div class="bar"><div class="fill" id="heatFill" style="width:0%"></div><div class="seg"></div></div>
</div>
<div class="row" style="display:flex;justify-content:space-between;align-items:baseline">
<span id="victories">✕ 0</span>
<span id="hostiles">HOSTILES 0</span>
</div>
</div>
<div id="banner"></div>
<div id="warning"></div>
<div id="arrow"></div>
<div id="target"><div class="c c1"></div><div class="c c2"></div><div class="c c3"></div><div class="c c4"></div><div class="dist"></div></div>
</div>
<!-- ======================= START ======================= -->
<div class="overlay" id="startOverlay">
<div class="poster">
<div class="stamp2">FORM B-104</div>
<div class="eyebrow">Royal Flying Corps · No. 56 Squadron</div>
<h1>ACES <span class="red">OVER</span><br>FLANDERS</h1>
<div class="sub">— THE SPRING OFFENSIVE · 1917 —</div>
<div class="rule">✦</div>
<div class="controls">
<div><span>Pitch (stick)</span><kbd>W / S</kbd></div>
<div><span>Roll</span><kbd>A / D &nbsp;or&nbsp; ← / →</kbd></div>
<div><span>Rudder</span><kbd>Q / E</kbd></div>
<div><span>Throttle</span><kbd>↑ / ↓</kbd></div>
<div><span>Fire guns</span><kbd>SPACE</kbd></div>
<div><span>Camera</span><kbd>C</kbd></div>
<div><span>Invert pitch</span><kbd>I</kbd></div>
<div><span>Sound</span><kbd>M</kbd></div>
</div>
<div style="font-size:12px;color:var(--ink-soft);margin-bottom:16px;letter-spacing:1px">
W pushes the nose down, S pulls it up — as the stick commands.<br>
Watch your speed: stall her and she will spin into the mud.
</div>
<div class="cta" id="startBtn">CLICK TO TAKE OFF</div>
<div class="fine">Bring down the enemy scouts patrolling the front line. Good hunting.</div>
</div>
</div>
<!-- ======================= DEAD ======================= -->
<div class="overlay" id="deadOverlay">
<div class="poster">
<div class="stamp2">CASUALTY WIRE</div>
<div class="eyebrow">Field Report · Western Front</div>
<h1 id="deadTitle">SHOT DOWN</h1>
<div class="sub" id="deadReason">— LOST OVER THE LINES —</div>
<div class="rule">✦</div>
<div id="deadScore">CONFIRMED VICTORIES: 0</div>
<div style="font-size:13px;color:var(--ink-soft);margin-bottom:18px">A replacement machine is being wheeled from the hangar.</div>
<div class="cta" id="respawnBtn">PRESS R TO REJOIN THE PATROL</div>
</div>
</div>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';
/* ============================================================
ACES OVER FLANDERS — a single-file WW1 dogfight sim
============================================================ */
// ------------------------------------------------ constants
const G = 9.8;
const MAX_THRUST = 15; // m/s^2 at full throttle
const V_MAX = 62; // level top speed, m/s
const V_REF = 29; // speed at which lift = gravity
const V_STALL = 16;
const DRAG_K = MAX_THRUST / (V_MAX * V_MAX);
const CEILING = 1500;
const BULLET_SPEED = 240;
const WORLD_R = 3400; // soft combat boundary
const rand = (a, b) => a + Math.random() * (b - a);
const clamp = THREE.MathUtils.clamp;
const damp = (a, b, l, dt) => THREE.MathUtils.lerp(a, b, 1 - Math.exp(-l * dt));
// ------------------------------------------------ renderer / scene
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xded3b6, 700, 4200);
const camera = new THREE.PerspectiveCamera(62, innerWidth / innerHeight, 0.5, 9000);
const hemi = new THREE.HemisphereLight(0xcad5e0, 0x6b6248, 0.55);
scene.add(hemi);
const SUN_DIR = new THREE.Vector3(0.45, 0.42, 0.55).normalize();
const sun = new THREE.DirectionalLight(0xffeecf, 2.4);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = -180; sun.shadow.camera.right = 180;
sun.shadow.camera.top = 180; sun.shadow.camera.bottom = -180;
sun.shadow.camera.near = 50; sun.shadow.camera.far = 1200;
sun.shadow.bias = -0.002;
scene.add(sun, sun.target);
// sky dome with gradient + sun glow
const sky = new THREE.Mesh(
new THREE.SphereGeometry(6500, 24, 12),
new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false,
uniforms: {
topC: { value: new THREE.Color(0x6f93b4) },
horC: { value: new THREE.Color(0xe6dcbb) },
sunD: { value: SUN_DIR },
},
vertexShader: `varying vec3 vDir; void main(){ vDir = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
uniform vec3 topC, horC, sunD; varying vec3 vDir;
void main(){
vec3 d = normalize(vDir);
float h = clamp(d.y * 1.35, 0.0, 1.0);
vec3 col = mix(horC, topC, pow(h, 0.75));
float s = clamp(dot(d, sunD), 0.0, 1.0);
col += vec3(1.0, 0.85, 0.55) * pow(s, 400.0) * 1.4; // disc
col += vec3(1.0, 0.88, 0.65) * pow(s, 8.0) * 0.14; // haze
gl_FragColor = vec4(col, 1.0);
}`,
})
);
scene.add(sky);
// ------------------------------------------------ noise / terrain
function makeNoise(seed) {
let s = seed >>> 0 || 1;
const rnd = () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296;
const p = new Uint8Array(256).map((_, i) => i);
for (let i = 255; i > 0; i--) { const j = (rnd() * (i + 1)) | 0; [p[i], p[j]] = [p[j], p[i]]; }
const perm = new Uint8Array(512); for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
const val = new Float32Array(256); for (let i = 0; i < 256; i++) val[i] = rnd() * 2 - 1;
return (x, y) => {
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
const h = (X, Y) => val[perm[(perm[X & 255] + Y) & 255]];
const a = h(xi, yi), b = h(xi + 1, yi), c = h(xi, yi + 1), d = h(xi + 1, yi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
};
}
const noise2 = makeNoise(1917);
function fbm(x, y, oct = 4) {
let a = 0, amp = 1, f = 1, n = 0;
for (let i = 0; i < oct; i++) { a += noise2(x * f, y * f) * amp; n += amp; amp *= .5; f *= 2; }
return a / n;
}
// distance from the diagonal front line (runs through the map)
const frontDist = (x, z) => Math.abs(x * 0.94 - z * 0.34);
function terrainHeight(x, z) {
let h = fbm(x * 0.0012 + 7, z * 0.0012 + 3, 4) * 90
+ fbm(x * 0.006 + 2, z * 0.006 + 9, 3) * 10
+ fbm(x * 0.0004 + 5, z * 0.0004 + 1, 2) * 45;
const d = frontDist(x, z);
const band = Math.exp(-(d * d) / (2 * 150 * 150)); // the churned no-man's-land
if (band > 0.02) {
const crater = fbm(x * 0.02 + 11, z * 0.02 + 5, 3);
h -= band * (10 + crater * 14 + Math.max(0, crater) * 12);
}
return h;
}
const TER_SIZE = 9000, TER_SEG = 240;
{
const geo = new THREE.PlaneGeometry(TER_SIZE, TER_SIZE, TER_SEG, TER_SEG);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
const heights = new Float32Array(pos.count);
for (let i = 0; i < pos.count; i++) {
const h = terrainHeight(pos.getX(i), pos.getZ(i));
heights[i] = h;
pos.setY(i, h);
}
// patchwork-field vertex colours
const fieldCols = [0x687d3e, 0x77894a, 0x5b7036, 0x8d9052, 0x9c9458, 0x6f8340].map(c => new THREE.Color(c));
const mud = new THREE.Color(0x6b5232), mudDark = new THREE.Color(0x4a3823);
const colors = new Float32Array(pos.count * 3);
const c = new THREE.Color();
const step = TER_SIZE / TER_SEG, row = TER_SEG + 1;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), z = pos.getZ(i);
const cell = fbm(x * 0.0016 + 31, z * 0.0016 + 17, 2);
c.copy(fieldCols[Math.abs(Math.floor((cell + 1) * 3.1)) % fieldCols.length]);
c.offsetHSL(0, 0, fbm(x * 0.02, z * 0.02, 2) * 0.045); // texture
const band = Math.exp(-(frontDist(x, z) ** 2) / (2 * 165 * 165));
if (band > 0.03) {
const m = fbm(x * 0.03 + 4, z * 0.03 + 8, 2) > 0.05 ? mudDark : mud;
c.lerp(m, Math.min(1, band * 1.35));
}
// darken slopes
const xi = i % row, zi = (i / row) | 0;
const hx = heights[Math.min(xi + 1, TER_SEG) + zi * row] - heights[Math.max(xi - 1, 0) + zi * row];
const hz = heights[xi + Math.min(zi + 1, TER_SEG) * row] - heights[xi + Math.max(zi - 1, 0) * row];
const slope = Math.min(1, Math.hypot(hx, hz) / (2 * step) * 2.2);
c.multiplyScalar(1 - slope * 0.45);
colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.computeVertexNormals();
const terrain = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 1, metalness: 0 }));
terrain.receiveShadow = true;
scene.add(terrain);
}
// trees (instanced cones), keep clear of the front line
{
const geo = new THREE.ConeGeometry(2.8, 10, 6);
geo.translate(0, 4, 0);
const mat = new THREE.MeshStandardMaterial({ color: 0x39512c, roughness: 1, flatShading: true });
const trees = new THREE.InstancedMesh(geo, mat, 700);
const m = new THREE.Matrix4(), q = new THREE.Quaternion(), sc = new THREE.Vector3(), tp = new THREE.Vector3();
const col = new THREE.Color();
let n = 0, tries = 0;
while (n < 700 && tries++ < 6000) {
const x = rand(-3800, 3800), z = rand(-3800, 3800);
if (Math.exp(-(frontDist(x, z) ** 2) / (2 * 165 * 165)) > 0.12) continue;
const s = rand(0.7, 1.7);
tp.set(x, terrainHeight(x, z) - 0.5, z);
q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), rand(0, Math.PI * 2));
sc.set(s, s * rand(0.9, 1.3), s);
m.compose(tp, q, sc);
trees.setMatrixAt(n, m);
trees.setColorAt(n, col.setHSL(0.28 + rand(-0.03, 0.03), 0.35, 0.2 + rand(0, 0.08)));
n++;
}
trees.count = n;
trees.frustumCulled = false;
trees.castShadow = true;
trees.instanceMatrix.needsUpdate = true;
if (trees.instanceColor) trees.instanceColor.needsUpdate = true;
scene.add(trees);
}
// clouds — lumpy flat-shaded blobs, they drift and give cover
const clouds = [];
{
const mat = new THREE.MeshStandardMaterial({ color: 0xf4f1e8, roughness: 1, flatShading: true,
emissive: 0xcfc9ba, emissiveIntensity: 0.55 });
for (let i = 0; i < 26; i++) {
const g = new THREE.Group();
const blobs = 3 + (Math.random() * 4 | 0);
for (let b = 0; b < blobs; b++) {
const s = rand(22, 55);
const mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(s, 0), mat);
mesh.position.set(rand(-70, 70), rand(-12, 12), rand(-40, 40));
mesh.scale.y = 0.45;
g.add(mesh);
}
g.position.set(rand(-4200, 4200), rand(520, 900), rand(-4200, 4200));
g.userData.drift = rand(2, 6);
scene.add(g);
clouds.push(g);
}
}
// ------------------------------------------------ plane model
function buildBiplane(scheme) {
const g = new THREE.Group();
const M = (c, r = 0.85) => new THREE.MeshStandardMaterial({ color: c, roughness: r, metalness: 0.05 });
const add = (geo, mat, x, y, z, rx = 0, ry = 0, rz = 0) => {
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z); mesh.rotation.set(rx, ry, rz);
mesh.castShadow = true;
g.add(mesh); return mesh;
};
const fus = M(scheme.fus), wing = M(scheme.wing), acc = M(scheme.accent);
add(new THREE.BoxGeometry(0.9, 1.0, 4.6), fus, 0, 0, -0.4); // fuselage
add(new THREE.CylinderGeometry(0.52, 0.58, 1.2, 8), M(0x54463a, 0.5), 0, 0.02, 2.2, Math.PI / 2); // cowl
const wG = new THREE.BoxGeometry(7.6, 0.14, 1.55);
add(wG, wing, 0, -0.42, 0.55); // lower wing
add(new THREE.BoxGeometry(8.4, 0.14, 1.6), wing, 0, 1.18, 0.65); // upper wing
const strut = new THREE.BoxGeometry(0.08, 1.6, 0.08);
for (const sx of [-2.9, 2.9, -0.65, 0.65]) {
add(strut, acc, sx, 0.38, 0.25); add(strut, acc, sx, 0.38, 0.95);
}
add(new THREE.BoxGeometry(2.7, 0.1, 1.05), wing, 0, 0.12, -2.75); // stabiliser
const elev = add(new THREE.BoxGeometry(2.7, 0.08, 0.5), wing, 0, 0.12, -3.5); // elevator
add(new THREE.BoxGeometry(0.09, 1.05, 0.85), acc, 0, 0.62, -2.9); // fin
const rud = add(new THREE.BoxGeometry(0.09, 1.15, 0.55), acc, 0, 0.55, -3.55); // rudder
add(new THREE.SphereGeometry(0.26, 8, 6), M(0x8a6c48), 0, 0.62, -0.7); // pilot
add(new THREE.BoxGeometry(0.7, 0.3, 0.05), M(0xcfd8da, 0.2), 0, 0.72, 0.15); // windscreen
add(new THREE.BoxGeometry(0.1, 0.12, 0.9), M(0x2c2c2c, 0.4), 0.22, 0.55, 1.3); // gun
// undercarriage
const wheelG = new THREE.CylinderGeometry(0.36, 0.36, 0.18, 10);
add(wheelG, M(0x2a241c, 0.6), -0.85, -1.15, 0.9, 0, 0, Math.PI / 2);
add(wheelG, M(0x2a241c, 0.6), 0.85, -1.15, 0.9, 0, 0, Math.PI / 2);
add(new THREE.BoxGeometry(1.8, 0.08, 0.08), acc, 0, -1.12, 0.9);
add(new THREE.BoxGeometry(0.08, 0.75, 0.08), acc, -0.6, -0.78, 0.9, 0, 0, 0.35);
add(new THREE.BoxGeometry(0.08, 0.75, 0.08), acc, 0.6, -0.78, 0.9, 0, 0, -0.35);
// propeller + blur disc
const prop = new THREE.Group(); prop.position.set(0, 0.02, 2.85);
const blade = new THREE.BoxGeometry(0.14, 2.1, 0.06);
prop.add(new THREE.Mesh(blade, M(0x4a3520, 0.6)));
const b2 = new THREE.Mesh(blade, M(0x4a3520, 0.6)); b2.rotation.z = Math.PI / 2; prop.add(b2);
prop.add(new THREE.Mesh(new THREE.ConeGeometry(0.17, 0.4, 8), M(0x6b6b6b, 0.35)).rotateX(Math.PI / 2));
g.add(prop);
const disc = new THREE.Mesh(new THREE.CircleGeometry(1.1, 20),
new THREE.MeshBasicMaterial({ color: 0xd8d2c0, transparent: true, opacity: 0.16, side: THREE.DoubleSide, depthWrite: false }));
disc.position.set(0, 0.02, 2.86);
g.add(disc);
// markings
if (scheme.roundel) {
const mk = (r, c) => new THREE.MeshBasicMaterial({ color: c, side: THREE.DoubleSide });
for (const sx of [-2.9, 2.9]) {
let y = 1.26;
for (const [r, c] of [[0.62, 0x1d3a6e], [0.42, 0xe8e0cc], [0.2, 0x9c2b1f]]) {
const d = new THREE.Mesh(new THREE.CircleGeometry(r, 20), mk(r, c));
d.rotation.x = -Math.PI / 2; d.position.set(sx, y += 0.004, 0.65);
g.add(d);
}
}
} else {
const dark = new THREE.MeshStandardMaterial({ color: 0x1c1c1c, roughness: 0.9 });
for (const sx of [-3.1, 3.1]) {
const a = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.03, 0.3), dark);
a.position.set(sx, 1.27, 0.65); g.add(a);
const b = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.03, 1.0), dark);
b.position.set(sx, 1.27, 0.65); g.add(b);
}
}
g.userData = { prop, disc, elev, rud };
return g;
}
const PLAYER_SCHEME = { fus: 0x6b6f3a, wing: 0xb59a63, accent: 0x54462e, roundel: true };
const ENEMY_SCHEMES = [
{ fus: 0x9c2b1f, wing: 0xa8412f, accent: 0x2c2620, roundel: false }, // the red one
{ fus: 0x4a5a68, wing: 0x5d6d78, accent: 0x2c2620, roundel: false },
{ fus: 0x71603a, wing: 0x86754a, accent: 0x2c2620, roundel: false },
];
// ------------------------------------------------ particles
function makeParticleTexture() {
const cv = document.createElement('canvas'); cv.width = cv.height = 64;
const ctx = cv.getContext('2d');
const gr = ctx.createRadialGradient(32, 32, 2, 32, 32, 31);
gr.addColorStop(0, 'rgba(255,255,255,1)');
gr.addColorStop(0.4, 'rgba(255,255,255,.6)');
gr.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = gr; ctx.fillRect(0, 0, 64, 64);
return new THREE.CanvasTexture(cv);
}
const partTex = makeParticleTexture();
class ParticlePool {
constructor(count, blending) {
this.items = [];
for (let i = 0; i < count; i++) {
const s = new THREE.Sprite(new THREE.SpriteMaterial({
map: partTex, transparent: true, depthWrite: false, blending,
fog: blending === THREE.NormalBlending,
}));
s.visible = false;
scene.add(s);
this.items.push({ s, vel: new THREE.Vector3(), life: 0, ttl: 1, s0: 1, s1: 2, o0: 1, c0: new THREE.Color(), c1: new THREE.Color(), grav: 0, drag: 0 });
}
this.head = 0;
}
spawn(pos, vel, ttl, s0, s1, c0, c1, o0 = 1, grav = 0, drag = 0) {
const p = this.items[this.head]; this.head = (this.head + 1) % this.items.length;
p.s.visible = true;
p.s.position.copy(pos);
p.vel.copy(vel); p.life = 0; p.ttl = ttl;
p.s0 = s0; p.s1 = s1; p.o0 = o0;
p.c0.set(c0); p.c1.set(c1); p.grav = grav; p.drag = drag;
}
update(dt) {
for (const p of this.items) {
if (!p.s.visible) continue;
p.life += dt;
if (p.life >= p.ttl) { p.s.visible = false; continue; }
const t = p.life / p.ttl;
p.vel.y += p.grav * dt;
if (p.drag) p.vel.multiplyScalar(Math.max(0, 1 - p.drag * dt));
p.s.position.addScaledVector(p.vel, dt);
const sc = THREE.MathUtils.lerp(p.s0, p.s1, t);
p.s.scale.set(sc, sc, 1);
p.s.material.opacity = p.o0 * (1 - t * t);
p.s.material.color.copy(p.c0).lerp(p.c1, t);
}
}
}
const firePool = new ParticlePool(140, THREE.AdditiveBlending);
const smokePool = new ParticlePool(220, THREE.NormalBlending);
function explosion(pos, big = 1) {
for (let i = 0; i < 16 * big; i++) {
const v = new THREE.Vector3(rand(-1, 1), rand(-0.4, 1.2), rand(-1, 1)).normalize().multiplyScalar(rand(8, 30) * big);
firePool.spawn(pos, v, rand(0.35, 0.9), rand(3, 6) * big, rand(0.5, 2), 0xffd890, 0x992200, 1, -4, 1.6);
}
for (let i = 0; i < 14 * big; i++) {
const v = new THREE.Vector3(rand(-1, 1), rand(0.2, 1.4), rand(-1, 1)).multiplyScalar(rand(4, 14) * big);
smokePool.spawn(pos, v, rand(1.6, 3.4), rand(4, 8) * big, rand(14, 26) * big, 0x554a3c, 0x22201c, 0.75, 2, 1.2);
}
const d = pos.distanceTo(player.group.position);
sfx.boom(clamp(1 - d / 1600, 0, 1));
if (d < 220) state.shake = Math.max(state.shake, (1 - d / 220) * 0.9);
}
// ------------------------------------------------ bullets
const bullets = [];
{
const geo = new THREE.BoxGeometry(0.1, 0.1, 2.2);
const mat = new THREE.MeshBasicMaterial({ color: 0xffca6a, fog: false });
for (let i = 0; i < 220; i++) {
const m = new THREE.Mesh(geo, mat);
m.visible = false; scene.add(m);
bullets.push({ m, vel: new THREE.Vector3(), ttl: 0, owner: null, dmg: 10 });
}
}
let bulletHead = 0;
const _bdir = new THREE.Vector3();
function fireBullet(owner, muzzle, dir, spread, dmg) {
const b = bullets[bulletHead]; bulletHead = (bulletHead + 1) % bullets.length;
b.m.visible = true;
b.m.position.copy(muzzle);
_bdir.copy(dir)
.addScaledVector(new THREE.Vector3(rand(-1, 1), rand(-1, 1), rand(-1, 1)), spread)
.normalize();
b.vel.copy(owner.vel).addScaledVector(_bdir, BULLET_SPEED);
b.m.lookAt(_bdir.add(b.m.position));
b.ttl = 1.7; b.owner = owner; b.dmg = dmg;
}
// ------------------------------------------------ audio
class SoundKit {
constructor() { this.ok = false; this.muted = false; }
start() {
if (this.ok) { this.ctx.resume(); return; }
try {
const ctx = this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.master = ctx.createGain(); this.master.gain.value = 0.5;
this.master.connect(ctx.destination);
// shared noise buffer
const len = ctx.sampleRate;
this.noiseBuf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = this.noiseBuf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
// engine: two detuned saws -> lowpass, gain hammered by a slow square LFO (putt-putt)
this.engGain = ctx.createGain(); this.engGain.gain.value = 0;
this.engLP = ctx.createBiquadFilter(); this.engLP.type = 'lowpass'; this.engLP.frequency.value = 300;
const o1 = ctx.createOscillator(), o2 = ctx.createOscillator();
o1.type = 'sawtooth'; o2.type = 'sawtooth';
o1.frequency.value = 52; o2.frequency.value = 43;
o1.connect(this.engLP); o2.connect(this.engLP);
this.engLP.connect(this.engGain); this.engGain.connect(this.master);
const lfo = ctx.createOscillator(); lfo.type = 'square'; lfo.frequency.value = 21;
this.lfoGain = ctx.createGain(); this.lfoGain.gain.value = 0;
lfo.connect(this.lfoGain); this.lfoGain.connect(this.engGain.gain);
o1.start(); o2.start(); lfo.start();
this.engO1 = o1; this.engO2 = o2; this.engLFO = lfo;
// wind
this.windGain = ctx.createGain(); this.windGain.gain.value = 0;
const wind = ctx.createBufferSource(); wind.buffer = this.noiseBuf; wind.loop = true;
const wbp = ctx.createBiquadFilter(); wbp.type = 'bandpass'; wbp.frequency.value = 600; wbp.Q.value = 0.4;
wind.connect(wbp); wbp.connect(this.windGain); this.windGain.connect(this.master);
wind.start();
this.ok = true;
} catch (e) { /* no audio, carry on silently */ }
}
setEngine(throttle, speed) {
if (!this.ok) return;
const t = this.ctx.currentTime;
const f = 36 + throttle * 46 + speed * 0.12;
this.engO1.frequency.setTargetAtTime(f, t, 0.1);
this.engO2.frequency.setTargetAtTime(f * 0.81, t, 0.1);
this.engLP.frequency.setTargetAtTime(200 + throttle * 520, t, 0.1);
this.engLFO.frequency.setTargetAtTime(14 + throttle * 14, t, 0.2);
const g = 0.05 + throttle * 0.13;
this.engGain.gain.setTargetAtTime(g, t, 0.08);
this.lfoGain.gain.setTargetAtTime(g * 0.45, t, 0.08);
this.windGain.gain.setTargetAtTime(clamp((speed - 25) / 60, 0, 1) * 0.05, t, 0.2);
}
burst(vol, freq, dur, type = 'bandpass', q = 1) {
if (!this.ok || this.muted) return;
const ctx = this.ctx, t = ctx.currentTime;
const src = ctx.createBufferSource(); src.buffer = this.noiseBuf;
src.playbackRate.value = rand(0.8, 1.2);
const fl = ctx.createBiquadFilter(); fl.type = type; fl.frequency.value = freq; fl.Q.value = q;
const gn = ctx.createGain();
gn.gain.setValueAtTime(vol, t);
gn.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(fl); fl.connect(gn); gn.connect(this.master);
src.start(t, rand(0, 0.5), dur + 0.05);
src.stop(t + dur + 0.1);
}
gun() { this.burst(0.28, rand(750, 950), 0.07, 'bandpass', 0.8); }
enemyGun(a) { this.burst(0.12 * a, rand(500, 700), 0.08, 'bandpass', 0.8); }
boom(a) { if (a > 0.02) this.burst(0.85 * a, 120, rand(0.7, 1.1), 'lowpass'); }
hit() {
if (!this.ok || this.muted) return;
const ctx = this.ctx, t = ctx.currentTime;
const o = ctx.createOscillator(); o.type = 'square';
o.frequency.setValueAtTime(640, t); o.frequency.exponentialRampToValueAtTime(180, t + 0.08);
const g = ctx.createGain(); g.gain.setValueAtTime(0.1, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.09);
o.connect(g); g.connect(this.master); o.start(t); o.stop(t + 0.1);
}
thud() { this.burst(0.3, 200, 0.12, 'lowpass'); }
setMuted(m) { this.muted = m; if (this.ok) this.master.gain.value = m ? 0 : 0.5; }
}
const sfx = new SoundKit();
// ------------------------------------------------ planes
function makePlane(scheme, isPlayer) {
const group = buildBiplane(scheme);
scene.add(group);
return {
group, isPlayer,
vel: new THREE.Vector3(),
input: { pitch: 0, roll: 0, yaw: 0 },
throttle: 0.8,
hp: isPlayer ? 100 : 30, maxHp: isPlayer ? 100 : 30,
alive: true, falling: false, fallT: 0,
heat: 0, jammed: false, fireCd: 0, smokeT: 0,
ai: isPlayer ? null : {
state: 'patrol', thinkT: rand(0, 0.5), wp: new THREE.Vector3(),
burstT: 0, pauseT: rand(0.5, 2), evadeT: 0, evadeDir: rand(0, Math.PI * 2),
},
};
}
const player = makePlane(PLAYER_SCHEME, true);
const enemies = [];
const pendingSpawns = [];
function resetPlayer() {
player.hp = player.maxHp; player.alive = true; player.falling = false;
player.heat = 0; player.jammed = false;
player.group.visible = true;
const x = rand(-600, -200), z = rand(-2100, -1700);
player.group.position.set(x, Math.max(terrainHeight(x, z) + 240, 260), z);
player.group.lookAt(0, player.group.position.y + 40, 0);
player.throttle = 0.85;
player.vel.set(0, 0, 1).applyQuaternion(player.group.quaternion).multiplyScalar(45);
}
function spawnEnemy() {
const e = makePlane(ENEMY_SCHEMES[Math.random() * ENEMY_SCHEMES.length | 0], false);
const ang = rand(0, Math.PI * 2), r = rand(1400, 2200);
const x = clamp(player.group.position.x + Math.cos(ang) * r, -WORLD_R, WORLD_R);
const z = clamp(player.group.position.z + Math.sin(ang) * r, -WORLD_R, WORLD_R);
e.group.position.set(x, Math.max(terrainHeight(x, z) + rand(150, 400), 200), z);
e.group.rotation.y = rand(0, Math.PI * 2);
e.vel.set(0, 0, 1).applyQuaternion(e.group.quaternion).multiplyScalar(40);
e.ai.wp.set(rand(-1600, 1600), rand(180, 480), rand(-1600, 1600));
enemies.push(e);
}
// ------------------------------------------------ flight physics (shared)
const V1 = new THREE.Vector3(), V2 = new THREE.Vector3(), V3 = new THREE.Vector3(), V4 = new THREE.Vector3();
function flyPhysics(p, dt) {
const q = p.group.quaternion;
const fwd = V1.set(0, 0, 1).applyQuaternion(q);
const up = V2.set(0, 1, 0).applyQuaternion(q);
const speed = p.vel.length();
const ctl = clamp((speed - 6) / 22, 0.12, 1);
// stick — rates scale with airspeed
p.group.rotateX(-p.input.pitch * 1.75 * ctl * dt);
p.group.rotateZ(-p.input.roll * 2.9 * ctl * dt);
p.group.rotateY( p.input.yaw * 0.8 * ctl * dt);
// stall: the nose drops and she wallows
const stall = clamp(1 - speed / V_STALL, 0, 1);
if (stall > 0) {
p.group.rotateX(stall * 0.9 * dt);
p.group.rotateZ(Math.sin(perfT * 7 + p.group.id) * stall * 0.4 * dt);
}
// forces
const acc = V3.set(0, 0, 0);
let thrust = p.throttle * MAX_THRUST;
if (p.group.position.y > CEILING) thrust *= clamp(1 - (p.group.position.y - CEILING) / 500, 0, 1);
acc.addScaledVector(fwd, thrust);
acc.y -= G;
acc.addScaledVector(up, G * Math.min((speed / V_REF) ** 2, 1.7)); // lift
acc.addScaledVector(p.vel, -DRAG_K * speed); // drag
acc.addScaledVector(p.vel, -0.05 * Math.abs(p.input.pitch) * ctl); // turn bleed
p.vel.addScaledVector(acc, dt);
// aerodynamic alignment: velocity chases the nose
const ns = p.vel.length();
if (ns > 0.5) {
const rate = clamp(ns * 0.09, 0.5, 3.2);
V4.copy(p.vel).divideScalar(ns).lerp(fwd, Math.min(1, rate * dt)).normalize();
p.vel.copy(V4).multiplyScalar(ns);
}
p.group.position.addScaledVector(p.vel, dt);
// dressing: prop, control surfaces, engine smoke when wounded
const ud = p.group.userData;
ud.prop.rotation.z += (6 + p.throttle * 50) * dt;
ud.disc.material.opacity = 0.05 + p.throttle * 0.13;
ud.elev.rotation.x = damp(ud.elev.rotation.x, -p.input.pitch * 0.4, 10, dt);
ud.rud.rotation.y = damp(ud.rud.rotation.y, -p.input.yaw * 0.5, 10, dt);
if (p.hp < p.maxHp * 0.4 && !p.falling) {
p.smokeT -= dt;
if (p.smokeT <= 0) {
p.smokeT = 0.07;
V4.set(0, 0.2, 2).applyQuaternion(q).add(p.group.position);
smokePool.spawn(V4, V3.set(rand(-2, 2), rand(1, 3), rand(-2, 2)), rand(1, 2), 1.5, 6, 0x333029, 0x1c1a17, 0.6, 1, 0.5);
}
}
}
function planeGuns(p, dt, spread, dmg) {
p.fireCd -= dt;
if (p.fireCd > 0) return false;
p.fireCd = p.isPlayer ? 0.09 : 0.14;
const q = p.group.quaternion;
const fwd = V1.set(0, 0, 1).applyQuaternion(q);
const right = V2.set(1, 0, 0).applyQuaternion(q);
for (const side of [-0.35, 0.35]) {
V3.copy(p.group.position).addScaledVector(fwd, 3.4).addScaledVector(right, side);
V3.y += 0.4;
fireBullet(p, V3, fwd, spread, dmg);
}
// muzzle flash
V3.copy(p.group.position).addScaledVector(fwd, 3.6);
firePool.spawn(V3, V4.copy(p.vel), 0.06, 1.6, 0.4, 0xffe6a0, 0xff8830, 1);
return true;
}
// ------------------------------------------------ enemy AI
const _t = new THREE.Vector3(), _loc = new THREE.Vector3(), _pred = new THREE.Vector3();
function updateEnemy(e, dt) {
if (e.falling) return;
const ai = e.ai;
const toPlayer = player.alive ? e.group.position.distanceTo(player.group.position) : 1e9;
ai.thinkT -= dt;
if (ai.thinkT <= 0) {
ai.thinkT = rand(0.35, 0.7);
if (ai.evadeT > 0) ai.state = 'evade';
else if (player.alive && toPlayer < 950) ai.state = 'attack';
else ai.state = 'patrol';
if (ai.state === 'attack' && e.hp < e.maxHp * 0.35 && Math.random() < 0.25) {
ai.evadeT = rand(2, 4); ai.evadeDir = rand(0, Math.PI * 2); ai.state = 'evade';
}
}
ai.evadeT = Math.max(0, ai.evadeT - dt);
// pick a target point
if (ai.state === 'attack') {
const tof = toPlayer / BULLET_SPEED;
_pred.copy(player.group.position).addScaledVector(player.vel, tof * 0.85);
_t.copy(_pred);
if (toPlayer < 90) { // don't ram — slide off
_t.addScaledVector(V2.set(1, 0.2, 0).applyQuaternion(e.group.quaternion), 120);
}
e.throttle = toPlayer > 350 ? 1 : 0.75;
} else if (ai.state === 'evade') {
_t.set(e.group.position.x + Math.cos(ai.evadeDir) * 600,
e.group.position.y + 60,
e.group.position.z + Math.sin(ai.evadeDir) * 600);
e.throttle = 1;
} else {
if (e.group.position.distanceTo(ai.wp) < 180)
ai.wp.set(rand(-1600, 1600), rand(180, 480), rand(-1600, 1600));
_t.copy(ai.wp);
e.throttle = 0.75;
}
// terrain avoidance overrides everything
const gh = terrainHeight(e.group.position.x, e.group.position.z);
const agl = e.group.position.y - gh;
const avoid = agl < 80;
if (avoid) _t.set(e.group.position.x, gh + 220, e.group.position.z).addScaledVector(V1.set(0, 0, 1).applyQuaternion(e.group.quaternion), 200);
// steer: local error -> bank & pull
_loc.copy(_t);
e.group.worldToLocal(_loc);
const yawErr = Math.atan2(_loc.x, Math.max(_loc.z, 0.001) + Math.abs(_loc.x) * (_loc.z < 0 ? 1 : 0));
const behind = _loc.z < 0;
const pitchErr = Math.atan2(_loc.y, Math.hypot(_loc.x, _loc.z));
const right = V2.set(1, 0, 0).applyQuaternion(e.group.quaternion);
const bank = Math.asin(clamp(-right.y, -1, 1));
let desiredBank = clamp(yawErr * 1.9, -1.35, 1.35);
if (behind) desiredBank = Math.sign(yawErr || 1) * 1.35;
if (avoid) desiredBank = 0;
e.input.roll = clamp((desiredBank - bank) * 2.4, -1, 1);
e.input.pitch = avoid ? 1 : clamp(pitchErr * 2.2 + Math.abs(bank) * 0.5 - (behind ? -0.3 : 0), -0.55, 1);
e.input.yaw = clamp(yawErr * 0.4, -1, 1);
// guns
if (ai.state === 'attack' && player.alive) {
const fwd = V1.set(0, 0, 1).applyQuaternion(e.group.quaternion);
_pred.sub(e.group.position).normalize();
const aim = fwd.dot(_pred);
ai.pauseT -= dt;
if (ai.pauseT <= 0) { ai.burstT = rand(0.7, 1.3); ai.pauseT = rand(1.6, 2.8); }
if (ai.burstT > 0) {
ai.burstT -= dt;
if (toPlayer < 520 && aim > 0.992) {
if (planeGuns(e, dt, 0.032, 6)) sfx.enemyGun(clamp(1 - toPlayer / 700, 0.1, 1));
}
}
}
}
// ------------------------------------------------ damage / death
function hitPlane(p, dmg, from) {
if (!p.alive || p.falling) return;
p.hp -= dmg;
V4.copy(p.group.position);
firePool.spawn(V4, V3.set(rand(-4, 4), rand(-2, 4), rand(-4, 4)), 0.25, 1.6, 0.3, 0xffdd99, 0xcc5510, 1);
if (p.isPlayer) {
flashHit();
sfx.thud();
state.shake = Math.max(state.shake, 0.25);
if (from && from.ai) { /* attacker feels bold */ }
} else {
sfx.hit();
if (p.ai && Math.random() < 0.3) { p.ai.evadeT = rand(1.5, 3); p.ai.evadeDir = rand(0, Math.PI * 2); }
}
if (p.hp <= 0) {
if (p.isPlayer) killPlayer('SHOT DOWN', '— BROUGHT DOWN BY ENEMY GUNS —');
else killEnemy(p);
}
}
function killEnemy(e) {
e.alive = false; e.falling = true; e.fallT = rand(2.5, 4.5);
e.throttle = 0.25;
state.score++;
updateScoreHud();
explosion(e.group.position, 0.7);
const msgs = ['ENEMY DOWN', 'SCOUT DESTROYED', 'GOOD SHOOTING', 'ANOTHER ONE FALLS'];
banner(state.score === 5 ? 'YOU ARE AN ACE!' : msgs[Math.random() * msgs.length | 0]);
pendingSpawns.push(state.time + rand(4, 7));
}
function killPlayer(title, reason) {
if (!player.alive) return;
player.alive = false;
player.group.visible = false;
explosion(player.group.position, 1.6);
state.shake = 1;
state.mode = 'dead';
document.getElementById('deadTitle').textContent = title;
document.getElementById('deadReason').textContent = reason;
document.getElementById('deadScore').textContent = 'CONFIRMED VICTORIES: ' + state.score;
setTimeout(() => { document.getElementById('deadOverlay').style.display = 'flex'; }, 900);
}
// ------------------------------------------------ ambient flak over the front
let flakT = 0;
function updateFlak(dt) {
flakT -= dt;
if (flakT > 0) return;
flakT = rand(0.8, 2.4);
const p = player.group.position;
// a point near the front line, in the middle distance
const along = rand(-2000, 2000);
const x = along * 0.34 + rand(-200, 200), z = along * 0.94 + rand(-200, 200);
if (Math.hypot(x - p.x, z - p.z) > 2400) return;
V4.set(x, terrainHeight(x, z) + rand(180, 520), z);
firePool.spawn(V4, V3.set(0, 0, 0), 0.18, 5, 1, 0xffe0b0, 0xa04010, 0.9);
smokePool.spawn(V4, V3.set(rand(-2, 2), rand(0, 2), rand(-2, 2)), rand(2.5, 4), 5, 13, 0x2e2a24, 0x1a1815, 0.7, 1, 0.4);
sfx.boom(clamp(1 - V4.distanceTo(p) / 2600, 0, 1) * 0.35);
}
// ------------------------------------------------ input
const keys = {};
let pitchSign = 1; // sim convention: ↓/pull-back = nose up
addEventListener('keydown', (e) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
keys[e.code] = true;
if (e.code === 'KeyM') { sfx.setMuted(!sfx.muted); }
if (e.code === 'KeyI') { pitchSign *= -1; banner(pitchSign === 1 ? 'PITCH: STICK' : 'PITCH: INVERTED'); }
if (e.code === 'KeyC') { camMode = (camMode + 1) % 2; }
if (e.code === 'KeyR' && state.mode === 'dead') restart();
if ((e.code === 'Space' || e.code === 'Enter') && state.mode === 'start') startGame();
});
addEventListener('keyup', (e) => keys[e.code] = false);
function readPlayerInput(dt) {
const i = player.input;
const tgtPitch = ((keys['KeyS'] ? 1 : 0) - (keys['KeyW'] ? 1 : 0)) * pitchSign;
const tgtRoll = (keys['ArrowLeft'] || keys['KeyA'] ? 1 : 0) - (keys['ArrowRight'] || keys['KeyD'] ? 1 : 0);
const tgtYaw = (keys['KeyE'] ? 1 : 0) - (keys['KeyQ'] ? 1 : 0);
i.pitch = damp(i.pitch, tgtPitch, 8, dt);
i.roll = damp(i.roll, tgtRoll, 8, dt);
i.yaw = damp(i.yaw, tgtYaw, 8, dt);
if (keys['ArrowUp']) player.throttle = clamp(player.throttle + dt * 0.55, 0.15, 1);
if (keys['ArrowDown']) player.throttle = clamp(player.throttle - dt * 0.55, 0.15, 1);
// guns + heat
if (player.heat > 0) player.heat = Math.max(0, player.heat - 26 * dt);
if (player.jammed && player.heat < 55) { player.jammed = false; heatLbl.textContent = 'READY'; }
if (keys['Space'] && player.alive && !player.jammed) {
if (planeGuns(player, dt, 0.012, 10)) {
sfx.gun();
player.heat += 5.5;
if (player.heat >= 100) { player.jammed = true; heatLbl.textContent = 'JAMMED!'; }
}
}
}
// ------------------------------------------------ HUD
const hud = document.getElementById('hud');
const spdNeedle = document.getElementById('spdNeedle'), spdVal = document.getElementById('spdVal');
const altNeedle = document.getElementById('altNeedle'), altVal = document.getElementById('altVal');
const compassCard = document.getElementById('compassCard');
const throttleHandle = document.getElementById('throttleHandle');
const hpFill = document.getElementById('hpFill'), hpPct = document.getElementById('hpPct');
const heatFill = document.getElementById('heatFill'), heatLbl = document.getElementById('heatLbl');
const victoriesEl = document.getElementById('victories'), hostilesEl = document.getElementById('hostiles');
const bannerEl = document.getElementById('banner'), warningEl = document.getElementById('warning');
const arrowEl = document.getElementById('arrow'), targetEl = document.getElementById('target');
const targetDist = targetEl.querySelector('.dist');
const hitflash = document.getElementById('hitflash'), lowhp = document.getElementById('lowhp');
function banner(text) {
bannerEl.textContent = text;
bannerEl.classList.remove('stamp');
void bannerEl.offsetWidth;
bannerEl.classList.add('stamp');
}
function flashHit() {
hitflash.style.transition = 'none'; hitflash.style.opacity = '1';
requestAnimationFrame(() => { hitflash.style.transition = 'opacity .5s ease-out'; hitflash.style.opacity = '0'; });
}
function updateScoreHud() {
victoriesEl.textContent = '✕ ' + state.score;
}
let hudFrame = 0;
const _proj = new THREE.Vector3();
function updateHud() {
if (++hudFrame % 3) return;
const p = player;
const speed = p.vel.length(), kmh = speed * 3.6;
const alt = Math.max(0, p.group.position.y);
spdNeedle.style.transform = `translateY(-100%) rotate(${-120 + clamp(kmh / 240, 0, 1) * 240}deg)`;
altNeedle.style.transform = `translateY(-100%) rotate(${-120 + clamp(alt / 1600, 0, 1) * 240}deg)`;
spdVal.textContent = Math.round(kmh);
altVal.textContent = Math.round(alt);
const fwd = V1.set(0, 0, 1).applyQuaternion(p.group.quaternion);
const heading = Math.atan2(fwd.x, -fwd.z);
compassCard.style.transform = `rotate(${-heading * 180 / Math.PI}deg)`;
throttleHandle.style.bottom = (12 + p.throttle * 62) + 'px';
hpFill.style.width = Math.max(0, p.hp / p.maxHp * 100) + '%';
hpPct.textContent = Math.max(0, Math.round(p.hp)) + '%';
heatFill.style.width = clamp(p.heat, 0, 100) + '%';
if (!p.jammed) heatLbl.textContent = p.heat > 70 ? 'HOT' : 'READY';
const liveEnemies = enemies.filter(e => e.alive).length;
hostilesEl.textContent = 'HOSTILES ' + liveEnemies;
// warnings
let warn = '';
if (speed < V_STALL + 2 && p.alive) warn = 'STALL';
else if (Math.hypot(p.group.position.x, p.group.position.z) > WORLD_R) warn = 'RETURN TO THE FRONT';
if (warn) { warningEl.textContent = warn; warningEl.classList.add('on'); }
else warningEl.classList.remove('on');
// low hp vignette
if (p.hp < 35 && p.alive) lowhp.classList.add('pulsing');
else { lowhp.classList.remove('pulsing'); lowhp.style.opacity = 0; }
// nearest enemy pointer / target box
let nearest = null, nd = 1e9;
for (const e of enemies) {
if (!e.alive) continue;
const d = e.group.position.distanceTo(p.group.position);
if (d < nd) { nd = d; nearest = e; }
}
if (!nearest) { arrowEl.style.opacity = 0; targetEl.style.opacity = 0; return; }
_proj.copy(nearest.group.position).project(camera);
const behind = _proj.z > 1;
const onScreen = !behind && Math.abs(_proj.x) < 0.95 && Math.abs(_proj.y) < 0.92;
const w = innerWidth, h = innerHeight;
if (onScreen) {
arrowEl.style.opacity = 0;
targetEl.style.opacity = 0.9;
targetEl.style.left = (_proj.x * 0.5 + 0.5) * w + 'px';
targetEl.style.top = (-_proj.y * 0.5 + 0.5) * h + 'px';
targetDist.textContent = Math.round(nd) + ' m';
} else {
targetEl.style.opacity = 0;
let dx = _proj.x, dy = _proj.y;
if (behind) { dx = -dx; dy = -dy; }
const len = Math.hypot(dx, dy) || 1;
dx /= len; dy /= len;
const s = 0.86 / Math.max(Math.abs(dx), Math.abs(dy));
const px = (dx * s * 0.5 + 0.5) * w, py = (-dy * s * 0.5 + 0.5) * h;
arrowEl.style.opacity = 0.95;
arrowEl.style.left = (px - 8) + 'px';
arrowEl.style.top = (py - 9) + 'px';
arrowEl.style.transform = `rotate(${Math.atan2(-dy, dx)}rad)`;
}
}
// ------------------------------------------------ camera
let camMode = 0;
const camPos = new THREE.Vector3(0, 300, -2200);
const lookTarget = new THREE.Vector3();
function updateCamera(dt) {
const p = player;
const q = p.group.quaternion;
const fwd = V1.set(0, 0, 1).applyQuaternion(q);
const up = V2.set(0, 1, 0).applyQuaternion(q);
if (camMode === 0) {
V3.copy(p.group.position).addScaledVector(fwd, -15).addScaledVector(up, 4.5);
camPos.x = damp(camPos.x, V3.x, 5, dt);
camPos.y = damp(camPos.y, V3.y, 5, dt);
camPos.z = damp(camPos.z, V3.z, 5, dt);
} else {
camPos.copy(p.group.position).addScaledVector(fwd, 1.2).addScaledVector(up, 1.35);
}
// never sink under the terrain
const gh = terrainHeight(camPos.x, camPos.z);
if (camPos.y < gh + 2.5) camPos.y = gh + 2.5;
camera.position.copy(camPos);
if (state.shake > 0.001) {
camera.position.x += rand(-1, 1) * state.shake;
camera.position.y += rand(-1, 1) * state.shake;
state.shake *= Math.exp(-4 * dt);
}
lookTarget.copy(p.group.position).addScaledVector(fwd, camMode === 0 ? 12 : 60);
camera.up.set(0, 1, 0).lerp(up, camMode === 0 ? 0.32 : 0.9).normalize();
camera.lookAt(lookTarget);
const speed = p.vel.length();
camera.fov = damp(camera.fov, 60 + clamp(speed - 30, 0, 40) * 0.28, 3, dt);
camera.updateProjectionMatrix();
sky.position.set(camera.position.x, 0, camera.position.z);
}
// ------------------------------------------------ game state / loop
const state = { mode: 'start', score: 0, time: 0, shake: 0 };
function startGame() {
document.getElementById('startOverlay').style.display = 'none';
hud.style.display = 'block';
sfx.start();
resetPlayer();
while (enemies.length < 3) spawnEnemy();
state.mode = 'play';
}
document.getElementById('startOverlay').addEventListener('click', startGame);
function restart() {
document.getElementById('deadOverlay').style.display = 'none';
resetPlayer();
state.mode = 'play';
}
document.getElementById('deadOverlay').addEventListener('click', () => { if (state.mode === 'dead') restart(); });
const clock = new THREE.Clock();
let perfT = 0;
function updateBullets(dt) {
for (const b of bullets) {
if (!b.m.visible) continue;
b.ttl -= dt;
if (b.ttl <= 0) { b.m.visible = false; continue; }
b.m.position.addScaledVector(b.vel, dt);
const bp = b.m.position;
// terrain
if (bp.y < 400 && bp.y < terrainHeight(bp.x, bp.z)) {
smokePool.spawn(bp, V3.set(0, 3, 0), 0.7, 1.5, 4, 0x8b7a5c, 0x5c5040, 0.7);
b.m.visible = false; continue;
}
// planes
if (b.owner !== player && player.alive &&
bp.distanceToSquared(player.group.position) < 5.5 * 5.5) {
hitPlane(player, b.dmg, b.owner);
b.m.visible = false; continue;
}
if (b.owner === player) {
for (const e of enemies) {
if (!e.alive) continue;
if (bp.distanceToSquared(e.group.position) < 6.5 * 6.5) {
hitPlane(e, b.dmg, player);
b.m.visible = false; break;
}
}
}
}
}
function updateFalling(e, dt) {
e.fallT -= dt;
e.input.roll = 1; e.input.pitch = -0.35; e.input.yaw = 0;
e.smokeT -= dt;
if (e.smokeT <= 0) {
e.smokeT = 0.05;
smokePool.spawn(e.group.position, V3.set(rand(-2, 2), rand(0, 3), rand(-2, 2)), rand(1.5, 2.5), 2.5, 8, 0x2e2a24, 0x1a1815, 0.75, 1.5, 0.4);
firePool.spawn(e.group.position, V4.copy(e.vel).multiplyScalar(0.3), 0.3, 2.5, 0.6, 0xffc070, 0xa03000, 0.9);
}
const gh = terrainHeight(e.group.position.x, e.group.position.z);
if (e.group.position.y < gh + 2 || e.fallT <= 0) {
e.group.position.y = Math.max(e.group.position.y, gh + 1);
explosion(e.group.position, 1.2);
scene.remove(e.group);
enemies.splice(enemies.indexOf(e), 1);
}
}
renderer.setAnimationLoop(() => {
const dt = Math.min(clock.getDelta(), 0.033);
perfT += dt;
if (state.mode === 'play' || state.mode === 'dead') {
state.time += dt;
// player
if (player.alive) {
readPlayerInput(dt);
flyPhysics(player, dt);
const gh = terrainHeight(player.group.position.x, player.group.position.z);
if (player.group.position.y < gh + 1.8)
killPlayer('CRASHED', '— FLEW INTO THE MUD OF FLANDERS —');
sfx.setEngine(player.throttle, player.vel.length());
} else {
sfx.setEngine(0, 0);
}
// enemies
const maxEnemies = Math.min(3 + Math.floor(state.score / 3), 6);
for (let i = pendingSpawns.length - 1; i >= 0; i--) {
if (state.time >= pendingSpawns[i]) { pendingSpawns.splice(i, 1); if (enemies.length < maxEnemies) spawnEnemy(); }
}
if (enemies.length < maxEnemies && Math.random() < dt * 0.1) spawnEnemy();
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
if (e.falling) { flyPhysics(e, dt); updateFalling(e, dt); continue; }
updateEnemy(e, dt);
flyPhysics(e, dt);
const gh = terrainHeight(e.group.position.x, e.group.position.z);
if (e.group.position.y < gh + 1.8) {
explosion(e.group.position, 1);
scene.remove(e.group); enemies.splice(i, 1);
pendingSpawns.push(state.time + rand(4, 7));
continue;
}
// mid-air collision with player
if (player.alive && e.group.position.distanceToSquared(player.group.position) < 49) {
killEnemy(e);
killPlayer('MID-AIR COLLISION', '— TANGLED WITH AN ENEMY SCOUT —');
}
}
updateBullets(dt);
updateFlak(dt);
updateHud();
}
// ambient
for (const c of clouds) {
c.position.x += c.userData.drift * dt;
if (c.position.x > 4600) c.position.x = -4600;
}
firePool.update(dt);
smokePool.update(dt);
// sun shadow follows the player
sun.position.copy(player.group.position).addScaledVector(SUN_DIR, 600);
sun.target.position.copy(player.group.position);
updateCamera(dt);
renderer.render(scene, camera);
});
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// idle attract-mode: drift the camera gently over the front before takeoff
camera.position.set(-300, 320, -2400);
camera.lookAt(0, 100, 0);
resetPlayer();
player.group.visible = true;
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment