Skip to content

Instantly share code, notes, and snippets.

@senko
Created September 6, 2026 08:50
Show Gist options
  • Select an option

  • Save senko/17311dcfb9959c90428c71e4f1baf1a4 to your computer and use it in GitHub Desktop.

Select an option

Save senko/17311dcfb9959c90428c71e4f1baf1a4 to your computer and use it in GitHub Desktop.
Flying simulator by Fable 5.1
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sopwith Skies - 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%3Ccircle cx='16' cy='16' r='15' fill='%231f3a8a'/%3E%3Ccircle cx='16' cy='16' r='10' fill='%23f0f0f0'/%3E%3Ccircle cx='16' cy='16' r='5' fill='%23c02020'/%3E%3C/svg%3E">
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #000;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif; color: #eee; }
#c { display: block; width: 100%; height: 100%; }
.hud { position: absolute; pointer-events: none; user-select: none; }
#panel { left: 16px; top: 16px; background: rgba(0,0,0,.38); padding: 10px 14px; border-radius: 6px;
font-size: 14px; line-height: 1.55; min-width: 190px; font-variant-numeric: tabular-nums; }
#panel .row { display: flex; justify-content: space-between; gap: 12px; }
#panel .lbl { color: #aab; letter-spacing: .06em; font-size: 11px; }
.bar { height: 8px; background: rgba(255,255,255,.15); border-radius: 4px; margin: 2px 0 6px; overflow: hidden; }
.bar > div { height: 100%; width: 50%; }
#thr { background: #f0b040; } #hp { background: #40d060; }
#score { right: 16px; top: 16px; text-align: right; background: rgba(0,0,0,.38); padding: 10px 14px;
border-radius: 6px; font-size: 14px; line-height: 1.55; min-width: 140px; }
#score b { font-size: 22px; }
#cross { left: 50%; top: 50%; width: 26px; height: 26px; margin: -13px 0 0 -13px; border: 2px solid rgba(255,255,255,.75);
border-radius: 50%; box-sizing: border-box; }
#cross::after { content: ""; position: absolute; left: 50%; top: 50%; width: 4px; height: 4px; margin: -2px 0 0 -2px;
background: #fff; border-radius: 50%; }
#lead { width: 14px; height: 14px; margin: -7px 0 0 -7px; border: 2px solid rgba(255,230,80,.9); border-radius: 50%;
box-sizing: border-box; display: none; }
#msg { left: 0; right: 0; top: 18%; text-align: center; font-size: 28px; font-weight: 600;
text-shadow: 0 2px 8px #000; opacity: 0; transition: opacity .4s; }
#warn { left: 0; right: 0; bottom: 22%; text-align: center; font-size: 22px; font-weight: 700; color: #ff5050;
text-shadow: 0 2px 6px #000; display: none; }
#vig { left: 0; top: 0; right: 0; bottom: 0; box-shadow: inset 0 0 160px rgba(255,0,0,.9); opacity: 0; }
#markers > div { position: absolute; width: 28px; height: 28px; margin: -14px 0 0 -14px; border: 2px solid rgba(255,80,60,.9);
box-sizing: border-box; font-size: 11px; color: #ffb0a0; text-align: center; white-space: nowrap; text-shadow: 0 1px 3px #000; }
#markers > div::after { content: attr(data-d); position: absolute; top: 100%; left: 50%; transform: translateX(-50%); margin-top: 2px; }
#markers > div.off { border: none; }
#markers > div.off::before { content: "\25B2"; position: absolute; left: 50%; top: 50%; margin: -10px 0 0 -8px;
font-size: 18px; color: rgba(255,80,60,.9); transform: rotate(var(--rot)); line-height: 20px; }
#hit { left: 50%; top: 50%; width: 40px; height: 40px; margin: -20px 0 0 -20px; opacity: 0; }
#hit::before, #hit::after { content: ""; position: absolute; left: 0; top: 50%; width: 100%; height: 2px; background: #fff; }
#hit::before { transform: rotate(45deg); } #hit::after { transform: rotate(-45deg); }
#radar { position: absolute; right: 16px; bottom: 16px; width: 150px; height: 150px; border-radius: 50%;
background: rgba(0,20,0,.5); border: 2px solid rgba(120,255,120,.4); pointer-events: none; }
#help { left: 16px; bottom: 16px; font-size: 12px; color: #bbc; line-height: 1.6; background: rgba(0,0,0,.35);
padding: 8px 12px; border-radius: 6px; }
#help kbd { background: #333; border: 1px solid #666; border-radius: 3px; padding: 0 5px; font-size: 11px; color: #fff; }
#overlay { position: absolute; left: 0; top: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: flex-start;
padding-left: 5vw; box-sizing: border-box; background: rgba(0,0,0,.3); }
@media (max-width: 900px) { #overlay { justify-content: center; padding-left: 0; } }
#overlay .box { background: rgba(20,24,30,.92); padding: 28px 36px; border-radius: 10px; max-width: 560px;
box-shadow: 0 10px 40px rgba(0,0,0,.6); border: 1px solid rgba(255,255,255,.1); }
#overlay h1 { margin: 0 0 4px; font-size: 34px; letter-spacing: .04em; }
#overlay h2 { margin: 0 0 16px; font-size: 15px; font-weight: 400; color: #aab; }
#overlay p { margin: 6px 0; font-size: 14px; line-height: 1.6; }
#overlay kbd { background: #333; border: 1px solid #666; border-radius: 3px; padding: 1px 6px; font-size: 12px; }
#overlay button { margin-top: 16px; font-size: 16px; padding: 10px 26px; background: #e0a030; border: none; border-radius: 6px;
cursor: pointer; font-weight: 600; color: #221; }
#overlay button:hover { background: #f0b848; }
#overlay table { border-collapse: collapse; margin: 6px 0; font-size: 13px; }
#overlay td { padding: 2px 10px 2px 0; }
#stats { color: #ffd070; font-size: 18px; margin-bottom: 8px; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="panel" class="hud">
<div class="row"><span class="lbl">SPEED</span><span id="spd">0 km/h</span></div>
<div class="row"><span class="lbl">ALTITUDE</span><span id="alt">0 m</span></div>
<div class="row"><span class="lbl">HEADING</span><span id="hdg">000&deg;</span></div>
<div class="lbl">THROTTLE</div><div class="bar"><div id="thr"></div></div>
<div class="lbl">AIRFRAME</div><div class="bar"><div id="hp"></div></div>
</div>
<div id="score" class="hud">
<div>SCORE <b id="sc">0</b></div>
<div>WAVE <span id="wave">1</span></div>
<div>ENEMIES <span id="enemies">0</span></div>
</div>
<div id="help" class="hud">
<kbd>&uarr;</kbd><kbd>&darr;</kbd> pitch &nbsp; <kbd>&larr;</kbd><kbd>&rarr;</kbd> roll &nbsp; <kbd>A</kbd><kbd>D</kbd> rudder<br>
<kbd>W</kbd><kbd>S</kbd> throttle &nbsp; <kbd>Space</kbd> guns &nbsp; <kbd>C</kbd> camera &nbsp; <kbd>I</kbd> invert pitch &nbsp; <kbd>M</kbd> sound
</div>
<div id="cross" class="hud"></div>
<div id="lead" class="hud"></div>
<div id="hit" class="hud"></div>
<div id="markers" class="hud" style="left:0;top:0;right:0;bottom:0;overflow:hidden"></div>
<div id="msg" class="hud"></div>
<div id="warn" class="hud">STALL</div>
<div id="vig" class="hud"></div>
<canvas id="radar" width="150" height="150"></canvas>
<div id="overlay">
<div class="box">
<h1>SOPWITH SKIES</h1>
<h2>A WW1 biplane dogfight over the Western Front</h2>
<div id="stats" style="display:none"></div>
<p id="intro">You start on the runway. Throttle up with <kbd>W</kbd>, pull back with <kbd>&darr;</kbd> once you pass
120&nbsp;km/h, and hunt down the enemy scouts. Each wave brings more of them.</p>
<table>
<tr><td><kbd>&uarr;</kbd> <kbd>&darr;</kbd></td><td>Pitch (nose down / nose up)</td></tr>
<tr><td><kbd>&larr;</kbd> <kbd>&rarr;</kbd></td><td>Roll</td></tr>
<tr><td><kbd>A</kbd> <kbd>D</kbd></td><td>Rudder (yaw / ground steering)</td></tr>
<tr><td><kbd>W</kbd> <kbd>S</kbd></td><td>Throttle up / down (<kbd>S</kbd> at idle = brakes)</td></tr>
<tr><td><kbd>Space</kbd></td><td>Fire twin Vickers guns</td></tr>
<tr><td><kbd>C</kbd> <kbd>I</kbd> <kbd>M</kbd></td><td>Chase / cockpit camera, invert pitch, mute</td></tr>
</table>
<button id="startBtn">Take off</button>
<p style="color:#889;font-size:12px;margin-top:10px">Tip: keep your speed above 110 km/h in turns or you will stall.
Land back on the airfield gently to repair.</p>
</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';
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const lerp = (a, b, t) => a + (b - a) * t;
const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const rand = (a, b) => a + Math.random() * (b - a);
const V3 = (x = 0, y = 0, z = 0) => new THREE.Vector3(x, y, z);
const AX_X = V3(1, 0, 0), AX_Y = V3(0, 1, 0), AX_NZ = V3(0, 0, -1), WORLD_UP = V3(0, 1, 0);
function hash2(ix, iz) {
let n = Math.imul(ix, 374761393) + Math.imul(iz, 668265263);
n = Math.imul(n ^ (n >>> 13), 1274126177);
n ^= n >>> 16;
return (n >>> 0) / 4294967296;
}
function valueNoise(x, z) {
const ix = Math.floor(x), iz = Math.floor(z);
const fx = x - ix, fz = z - iz;
const sx = fx * fx * (3 - 2 * fx), sz = fz * fz * (3 - 2 * fz);
const a = hash2(ix, iz), b = hash2(ix + 1, iz), c = hash2(ix, iz + 1), d = hash2(ix + 1, iz + 1);
return lerp(lerp(a, b, sx), lerp(c, d, sx), sz) * 2 - 1;
}
function fbm(x, z, oct = 5) {
let sum = 0, amp = 1, freq = 1, norm = 0;
for (let i = 0; i < oct; i++) {
sum += valueNoise(x * freq + i * 17.3, z * freq - i * 9.1) * amp;
norm += amp; amp *= 0.5; freq *= 2.1;
}
return sum / norm;
}
// ---------------------------------------------------------------------------
// World constants
// ---------------------------------------------------------------------------
const WORLD = 6000, HALF = WORLD / 2, SEG = 240, STEP = WORLD / SEG;
const WATER_Y = 0, AIRFIELD_Y = 14, AIRFIELD_R = 360;
const G = 9.81;
const BOUND = 2650;
// Flight model constants
const LIFT_K = 0.009, DRAG_K = 0.0038, IND_K = 0.5, SIDE_K = 2.0;
const MAX_THRUST = 12.5, PITCH_RATE = 1.35, ROLL_RATE = 2.7, YAW_RATE = 0.7, ALIGN_K = 5.0, ROLL_STAB = 0.35;
const STALL_A = 0.30, WHEEL_H = 1.5, GROUND_PITCH = 0.12;
const BULLET_SPEED = 280;
// ---------------------------------------------------------------------------
// Renderer / scene
// ---------------------------------------------------------------------------
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
const scene = new THREE.Scene();
const HORIZON = new THREE.Color(0xcfdbe6);
scene.fog = new THREE.Fog(HORIZON, 900, 4600);
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.5, 9000);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
const hemi = new THREE.HemisphereLight(0xc8dcff, 0x6a7a55, 1.15);
scene.add(hemi);
scene.add(new THREE.AmbientLight(0xffffff, 0.25));
const SUN_DIR = V3(0.45, 0.7, 0.35).normalize();
const sun = new THREE.DirectionalLight(0xfff2dc, 1.4);
sun.position.copy(SUN_DIR).multiplyScalar(100);
scene.add(sun);
// Sky dome
const skyMat = new THREE.ShaderMaterial({
uniforms: {
top: { value: new THREE.Color(0x3d7ec9) },
bottom: { value: HORIZON.clone() },
exp: { value: 0.75 },
},
vertexShader: `varying vec3 vW; void main(){ vec4 w = modelMatrix * vec4(position,1.0); vW = w.xyz - cameraPosition;
gl_Position = projectionMatrix * viewMatrix * w; }`,
fragmentShader: `uniform vec3 top; uniform vec3 bottom; uniform float exp; varying vec3 vW;
void main(){ float h = normalize(vW).y; float t = pow(max(h + 0.02, 0.0), exp);
gl_FragColor = vec4(mix(bottom, top, clamp(t, 0.0, 1.0)), 1.0);
#include <colorspace_fragment>
}`,
side: THREE.BackSide, depthWrite: false, fog: false,
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(6000, 24, 12), skyMat);
sky.renderOrder = -10;
scene.add(sky);
function radialTexture(inner, outer, stops) {
const c = document.createElement('canvas'); c.width = c.height = 128;
const g = c.getContext('2d');
const grd = g.createRadialGradient(64, 64, 0, 64, 64, 64);
for (const [t, col] of stops) grd.addColorStop(t, col);
g.fillStyle = grd; g.fillRect(0, 0, 128, 128);
const tex = new THREE.CanvasTexture(c);
return tex;
}
const sunTex = radialTexture(0, 64, [[0, 'rgba(255,250,230,1)'], [0.25, 'rgba(255,240,200,0.9)'], [0.5, 'rgba(255,220,150,0.25)'], [1, 'rgba(255,220,150,0)']]);
const sunSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: sunTex, fog: false, depthWrite: false, depthTest: true, transparent: true }));
sunSprite.scale.set(900, 900, 1);
sunSprite.renderOrder = -9;
scene.add(sunSprite);
// ---------------------------------------------------------------------------
// Terrain
// ---------------------------------------------------------------------------
const heights = new Float32Array((SEG + 1) * (SEG + 1));
function terrainFn(x, z) {
const d = Math.hypot(x, z);
const n = fbm(x * 0.00085, z * 0.00085, 5);
const ridge = 1 - Math.abs(fbm(x * 0.0004 + 100, z * 0.0004 - 50, 3));
const amp = 70 + 300 * smoothstep(600, 2800, d);
let h = n * amp + 10;
if (h > 0) h *= 1 + 0.9 * ridge * smoothstep(900, 2800, d);
h += 12;
return lerp(AIRFIELD_Y, h, smoothstep(AIRFIELD_R - 40, AIRFIELD_R + 380, d));
}
for (let j = 0; j <= SEG; j++) for (let i = 0; i <= SEG; i++)
heights[j * (SEG + 1) + i] = terrainFn(-HALF + i * STEP, -HALF + j * STEP);
function getHeight(x, z) {
const fx = clamp((x + HALF) / STEP, 0, SEG - 1e-6), fz = clamp((z + HALF) / STEP, 0, SEG - 1e-6);
const ix = Math.floor(fx), iz = Math.floor(fz), tx = fx - ix, tz = fz - iz;
const i00 = iz * (SEG + 1) + ix;
const h00 = heights[i00], h10 = heights[i00 + 1], h01 = heights[i00 + SEG + 1], h11 = heights[i00 + SEG + 2];
return lerp(lerp(h00, h10, tx), lerp(h01, h11, tx), tz);
}
function groundLevel(x, z) { return Math.max(getHeight(x, z), WATER_Y); }
// max height difference across +-10 m; small values mean ground flat enough to roll on
function groundRoughness(x, z) {
const h = getHeight(x, z);
return Math.max(Math.abs(getHeight(x + 10, z) - h), Math.abs(getHeight(x - 10, z) - h),
Math.abs(getHeight(x, z + 10) - h), Math.abs(getHeight(x, z - 10) - h));
}
function buildTerrain() {
const n = (SEG + 1) * (SEG + 1);
const pos = new Float32Array(n * 3), col = new Float32Array(n * 3);
const c = new THREE.Color();
const C_SAND = new THREE.Color().setRGB(0.78, 0.72, 0.52, THREE.SRGBColorSpace);
const C_GRASS = new THREE.Color().setRGB(0.36, 0.58, 0.24, THREE.SRGBColorSpace);
const C_GRASS2 = new THREE.Color().setRGB(0.22, 0.44, 0.17, THREE.SRGBColorSpace);
const C_BROWN = new THREE.Color().setRGB(0.48, 0.42, 0.26, THREE.SRGBColorSpace);
const C_ROCK = new THREE.Color().setRGB(0.46, 0.44, 0.42, THREE.SRGBColorSpace);
const C_SNOW = new THREE.Color().setRGB(0.94, 0.95, 0.98, THREE.SRGBColorSpace);
const C_FIELD = new THREE.Color().setRGB(0.62, 0.6, 0.3, THREE.SRGBColorSpace);
for (let j = 0; j <= SEG; j++) for (let i = 0; i <= SEG; i++) {
const k = j * (SEG + 1) + i;
const x = -HALF + i * STEP, z = -HALF + j * STEP, h = heights[k];
pos[k * 3] = x; pos[k * 3 + 1] = h; pos[k * 3 + 2] = z;
const hx = heights[j * (SEG + 1) + Math.min(i + 1, SEG)] - heights[j * (SEG + 1) + Math.max(i - 1, 0)];
const hz = heights[Math.min(j + 1, SEG) * (SEG + 1) + i] - heights[Math.max(j - 1, 0) * (SEG + 1) + i];
const slope = Math.hypot(hx, hz) / (2 * STEP);
if (h < 3) c.copy(C_SAND);
else if (h < 90) c.copy(C_GRASS).lerp(C_GRASS2, smoothstep(10, 90, h));
else if (h < 230) c.copy(C_GRASS2).lerp(C_BROWN, smoothstep(90, 230, h));
else if (h < 330) c.copy(C_BROWN).lerp(C_ROCK, smoothstep(230, 330, h));
else c.copy(C_ROCK).lerp(C_SNOW, smoothstep(330, 420, h));
if (h > 40) c.lerp(C_ROCK, smoothstep(0.35, 0.8, slope));
// farmland patches near the airfield
const patch = valueNoise(x * 0.004 + 7, z * 0.004 + 3);
if (h > 5 && h < 60 && slope < 0.15 && patch > 0.35) c.lerp(C_FIELD, 0.6);
const v = 0.92 + 0.16 * hash2(i, j);
col[k * 3] = c.r * v; col[k * 3 + 1] = c.g * v; col[k * 3 + 2] = c.b * v;
}
const idx = new Uint32Array(SEG * SEG * 6);
let p = 0;
for (let j = 0; j < SEG; j++) for (let i = 0; i < SEG; i++) {
const a = j * (SEG + 1) + i, b = a + 1, cc = a + SEG + 1, d = cc + 1;
idx[p++] = a; idx[p++] = cc; idx[p++] = b;
idx[p++] = b; idx[p++] = cc; idx[p++] = d;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
geo.setIndex(new THREE.BufferAttribute(idx, 1));
geo.computeVertexNormals();
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true }));
scene.add(mesh);
const water = new THREE.Mesh(new THREE.PlaneGeometry(WORLD, WORLD),
new THREE.MeshPhongMaterial({ color: 0x2f6fa8, shininess: 90, specular: 0x88aacc, transparent: true, opacity: 0.9 }));
water.rotation.x = -Math.PI / 2; water.position.y = WATER_Y;
scene.add(water);
}
buildTerrain();
// Airfield: runway, hangars, houses
function buildAirfield() {
const y = AIRFIELD_Y;
const runway = new THREE.Mesh(new THREE.PlaneGeometry(36, 620),
new THREE.MeshLambertMaterial({ color: 0x4a4a48 }));
runway.rotation.x = -Math.PI / 2; runway.position.set(0, y + 0.3, 0);
scene.add(runway);
const stripe = new THREE.MeshLambertMaterial({ color: 0xe8e8e0 });
for (let z = -280; z <= 280; z += 40) {
const s = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 18), stripe);
s.rotation.x = -Math.PI / 2; s.position.set(0, y + 0.5, z);
scene.add(s);
}
for (const side of [-1, 1]) {
const e = new THREE.Mesh(new THREE.PlaneGeometry(1.5, 620), stripe);
e.rotation.x = -Math.PI / 2; e.position.set(side * 16, y + 0.5, 0); scene.add(e);
}
const hangarMat = new THREE.MeshLambertMaterial({ color: 0x6f6a5a });
const roofMat = new THREE.MeshLambertMaterial({ color: 0x4d3f33 });
for (let i = 0; i < 3; i++) {
const h = new THREE.Mesh(new THREE.BoxGeometry(22, 7, 16), hangarMat);
h.position.set(-55, y + 3.5, -60 + i * 40); scene.add(h);
const r = new THREE.Mesh(new THREE.CylinderGeometry(11, 11, 16, 14), roofMat);
r.rotation.x = Math.PI / 2; r.position.set(-55, y + 7, -60 + i * 40);
r.scale.set(1, 1, 0.45); scene.add(r);
}
const tower = new THREE.Mesh(new THREE.BoxGeometry(6, 12, 6), hangarMat);
tower.position.set(40, y + 6, 120); scene.add(tower);
const towerTop = new THREE.Mesh(new THREE.BoxGeometry(8, 3, 8), new THREE.MeshLambertMaterial({ color: 0x3a4a5a }));
towerTop.position.set(40, y + 13.5, 120); scene.add(towerTop);
// windsock pole
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 8), new THREE.MeshLambertMaterial({ color: 0xdddddd }));
pole.position.set(30, y + 4, -200); scene.add(pole);
const sock = new THREE.Mesh(new THREE.ConeGeometry(0.8, 4, 8), new THREE.MeshLambertMaterial({ color: 0xff6020 }));
sock.rotation.z = Math.PI / 2; sock.position.set(32.5, y + 8, -200); scene.add(sock);
// Villages: houses on flat land
const wallMat = new THREE.MeshLambertMaterial({ color: 0xd8cfb8 });
const hroofMat = new THREE.MeshLambertMaterial({ color: 0x9a4a3a });
const houseGeo = new THREE.BoxGeometry(1, 1, 1);
const roofGeo = new THREE.ConeGeometry(0.85, 0.6, 4);
const walls = new THREE.InstancedMesh(houseGeo, wallMat, 260);
const roofs = new THREE.InstancedMesh(roofGeo, hroofMat, 260);
const m = new THREE.Object3D();
let count = 0;
for (let v = 0; v < 14 && count < 260; v++) {
let cx, cz, h;
for (let tries = 0; tries < 40; tries++) {
cx = rand(-2300, 2300); cz = rand(-2300, 2300); h = getHeight(cx, cz);
const slope = Math.abs(getHeight(cx + 30, cz) - h) + Math.abs(getHeight(cx, cz + 30) - h);
if (h > 6 && h < 120 && slope < 3 && Math.hypot(cx, cz) > AIRFIELD_R + 140) break;
h = -1;
}
if (h < 0) continue;
for (let k = 0; k < 20 && count < 260; k++) {
const x = cx + rand(-90, 90), z = cz + rand(-90, 90), hh = getHeight(x, z);
if (hh < 3) continue;
const w = rand(6, 11), d = rand(6, 11), ht = rand(4, 7), rot = rand(0, Math.PI);
m.position.set(x, hh + ht / 2, z); m.rotation.set(0, rot, 0); m.scale.set(w, ht, d); m.updateMatrix();
walls.setMatrixAt(count, m.matrix);
m.position.y = hh + ht + 1.4; m.rotation.set(0, rot + Math.PI / 4, 0); m.scale.set(Math.max(w, d), 4.5, Math.max(w, d)); m.updateMatrix();
roofs.setMatrixAt(count, m.matrix);
count++;
}
}
walls.count = roofs.count = count;
scene.add(walls, roofs);
}
buildAirfield();
// Trees
function buildTrees() {
const N = 5000;
const trunk = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.35, 0.5, 3, 5), new THREE.MeshLambertMaterial({ color: 0x5a3d22 }), N);
const canopy = new THREE.InstancedMesh(new THREE.ConeGeometry(3.2, 9, 6), new THREE.MeshLambertMaterial({ color: 0x24512a, flatShading: true }), N);
const m = new THREE.Object3D();
let count = 0;
for (let tries = 0; tries < N * 4 && count < N; tries++) {
const x = rand(-HALF + 50, HALF - 50), z = rand(-HALF + 50, HALF - 50), h = getHeight(x, z);
const dens = fbm(x * 0.002 + 40, z * 0.002 + 20, 3);
if (h < 4 || h > 200 || Math.hypot(x, z) < AIRFIELD_R + 60 || dens < 0.05) continue;
const slope = Math.abs(getHeight(x + 15, z) - h) + Math.abs(getHeight(x, z + 15) - h);
if (slope > 8) continue;
const s = rand(0.8, 1.6);
m.position.set(x, h + 1.5 * s, z); m.scale.set(s, s, s); m.rotation.set(0, 0, 0); m.updateMatrix();
trunk.setMatrixAt(count, m.matrix);
m.position.set(x, h + 3 * s + 4.5 * s, z); m.updateMatrix();
canopy.setMatrixAt(count, m.matrix);
count++;
}
trunk.count = canopy.count = count;
scene.add(trunk, canopy);
}
buildTrees();
// Clouds
const clouds = [];
function buildClouds() {
const mat = new THREE.MeshLambertMaterial({ color: 0xffffff, emissive: 0x9aa6b4 });
const sph = new THREE.SphereGeometry(1, 10, 7);
for (let i = 0; i < 55; i++) {
const g = new THREE.Group();
const n = 4 + Math.floor(Math.random() * 5);
for (let k = 0; k < n; k++) {
const s = new THREE.Mesh(sph, mat);
const r = rand(25, 60);
s.scale.set(r, r * rand(0.45, 0.7), r);
s.position.set(rand(-60, 60), rand(-6, 10), rand(-35, 35));
g.add(s);
}
g.position.set(rand(-HALF, HALF), rand(380, 800), rand(-HALF, HALF));
scene.add(g);
clouds.push(g);
}
}
buildClouds();
// ---------------------------------------------------------------------------
// Aircraft model
// ---------------------------------------------------------------------------
function buildPlaneMesh(style) {
const g = new THREE.Group();
const M = c => new THREE.MeshLambertMaterial({ color: c });
const bodyM = M(style.body), wingM = M(style.wing), darkM = M(0x2a2a2a), metalM = M(0x777777);
const add = (geo, mat, x, y, z, rx = 0, ry = 0, rz = 0) => {
const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); m.rotation.set(rx, ry, rz); g.add(m); return m;
};
// fuselage front & tapered rear (forward is -Z)
add(new THREE.BoxGeometry(1.0, 1.0, 3.0), bodyM, 0, 0, -0.8);
const rear = new THREE.BoxGeometry(0.95, 0.95, 2.9);
const rp = rear.attributes.position;
for (let i = 0; i < rp.count; i++) if (rp.getZ(i) > 0) { rp.setX(i, rp.getX(i) * 0.35); rp.setY(i, rp.getY(i) * 0.4 + 0.1); }
rear.computeVertexNormals();
add(rear, bodyM, 0, 0, 2.15);
add(new THREE.CylinderGeometry(0.56, 0.5, 0.7, 12), darkM, 0, 0, -2.6, Math.PI / 2);
// propeller
const prop = new THREE.Group(); prop.position.set(0, 0, -3.0);
const hub = new THREE.Mesh(new THREE.SphereGeometry(0.17, 8, 6), metalM); prop.add(hub);
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.14, 2.3, 0.05), M(0x6b4a2a)); prop.add(blade);
const disc = new THREE.Mesh(new THREE.CircleGeometry(1.15, 24),
new THREE.MeshBasicMaterial({ color: 0x222222, transparent: true, opacity: 0.0, side: THREE.DoubleSide, depthWrite: false }));
prop.add(disc);
g.add(prop);
// wings
add(new THREE.BoxGeometry(9.0, 0.1, 1.5), wingM, 0, 1.25, -0.6);
add(new THREE.BoxGeometry(8.2, 0.1, 1.4), wingM, 0, -0.38, -0.5);
// struts
const strut = new THREE.CylinderGeometry(0.04, 0.04, 1.63, 5);
for (const x of [-3.3, 3.3]) for (const z of [-1.15, -0.05]) add(strut, darkM, x, 0.43, z);
for (const x of [-0.5, 0.5]) for (const z of [-1.1, -0.1]) add(new THREE.CylinderGeometry(0.035, 0.035, 0.8, 5), darkM, x, 0.9, z);
// tail
add(new THREE.BoxGeometry(3.2, 0.08, 1.1), wingM, 0, 0.2, 2.95);
add(new THREE.BoxGeometry(0.08, 1.2, 1.2), style.fin ? M(style.fin) : bodyM, 0, 0.8, 3.0);
// cockpit & pilot
add(new THREE.BoxGeometry(0.7, 0.25, 0.8), darkM, 0, 0.55, -0.1);
const pilot = add(new THREE.SphereGeometry(0.22, 8, 6), M(0xc9a27a), 0, 0.8, 0.0);
add(new THREE.BoxGeometry(0.46, 0.12, 0.3), darkM, 0, 0.86, -0.1); // goggles
add(new THREE.BoxGeometry(0.7, 0.35, 0.05), new THREE.MeshLambertMaterial({ color: 0xbbddee, transparent: true, opacity: 0.5 }), 0, 0.72, -0.5, -0.4);
// guns
add(new THREE.BoxGeometry(0.09, 0.09, 0.9), darkM, 0.28, 0.56, -1.5);
add(new THREE.BoxGeometry(0.09, 0.09, 0.9), darkM, -0.28, 0.56, -1.5);
// landing gear
const wheelGeo = new THREE.CylinderGeometry(0.36, 0.36, 0.2, 12);
for (const x of [-0.95, 0.95]) {
add(wheelGeo, darkM, x, -1.15, -1.0, 0, 0, Math.PI / 2);
add(new THREE.CylinderGeometry(0.04, 0.04, 1.1, 5), metalM, x * 0.55, -0.8, -1.0, 0, 0, x > 0 ? 0.9 : -0.9);
add(new THREE.CylinderGeometry(0.04, 0.04, 1.0, 5), metalM, x * 0.55, -0.75, -0.3, 0.6, 0, x > 0 ? 0.8 : -0.8);
}
add(new THREE.CylinderGeometry(0.04, 0.04, 1.9, 5), metalM, 0, -1.15, -1.0, 0, 0, Math.PI / 2);
add(new THREE.BoxGeometry(0.06, 0.5, 0.06), metalM, 0, -0.5, 3.2, 0.3); // tail skid
// markings
if (style.marking === 'roundel') {
const ring = (r, c, x, y, z, flip) => {
const m = new THREE.Mesh(new THREE.CircleGeometry(r, 20), M(c));
m.rotation.x = flip ? Math.PI / 2 : -Math.PI / 2; m.position.set(x, y, z); g.add(m);
};
for (const x of [-3.2, 3.2]) {
ring(0.55, 0x1f3a8a, x, 1.306, -0.6); ring(0.36, 0xf0f0f0, x, 1.309, -0.6); ring(0.17, 0xc02020, x, 1.312, -0.6);
ring(0.55, 0x1f3a8a, x, -0.436, -0.5, true); ring(0.36, 0xf0f0f0, x, -0.439, -0.5, true); ring(0.17, 0xc02020, x, -0.442, -0.5, true);
}
} else {
const cross = (x, y, z, flip) => {
const a = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.02, 0.3), M(0x111111)); a.position.set(x, y, z); g.add(a);
const b = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.02, 1.0), M(0x111111)); b.position.set(x, y, z); g.add(b);
const w = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.015, 0.5), M(0xf4f4f4)); w.position.set(x, y - 0.004, z); g.add(w);
const w2 = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.015, 1.2), M(0xf4f4f4)); w2.position.set(x, y - 0.004, z); g.add(w2);
};
for (const x of [-3.2, 3.2]) { cross(x, 1.31, -0.6); cross(x, -0.44, -0.5, true); }
}
g.userData.prop = prop; g.userData.disc = disc; g.userData.pilot = pilot;
g.userData.mats = [bodyM, wingM];
return g;
}
// ---------------------------------------------------------------------------
// Particles
// ---------------------------------------------------------------------------
const puffTex = radialTexture(0, 64, [[0, 'rgba(255,255,255,1)'], [0.4, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]);
class Particles {
constructor(n, additive) {
this.n = n; this.next = 0;
this.pos = new Float32Array(n * 3); this.vel = new Float32Array(n * 3);
this.col = new Float32Array(n * 3); this.size = new Float32Array(n); this.alpha = new Float32Array(n);
this.life = new Float32Array(n); this.maxLife = new Float32Array(n); this.size0 = new Float32Array(n);
this.grow = new Float32Array(n); this.alpha0 = new Float32Array(n); this.grav = new Float32Array(n); this.drag = new Float32Array(n);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(this.pos, 3).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('aColor', new THREE.BufferAttribute(this.col, 3).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('aSize', new THREE.BufferAttribute(this.size, 1).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('aAlpha', new THREE.BufferAttribute(this.alpha, 1).setUsage(THREE.DynamicDrawUsage));
const mat = new THREE.ShaderMaterial({
uniforms: { uTex: { value: puffTex }, uScale: { value: window.innerHeight * 0.5 } },
vertexShader: `attribute float aSize; attribute float aAlpha; attribute vec3 aColor; uniform float uScale;
varying float vA; varying vec3 vC;
void main(){ vA = aAlpha; vC = aColor; vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * uScale / max(-mv.z, 1.0); gl_Position = projectionMatrix * mv; }`,
fragmentShader: `uniform sampler2D uTex; varying float vA; varying vec3 vC;
void main(){ float a = texture2D(uTex, gl_PointCoord).a * vA; if (a < 0.01) discard; gl_FragColor = vec4(vC, a);
#include <colorspace_fragment>
}`,
transparent: true, depthWrite: false, blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
});
this.mat = mat;
this.points = new THREE.Points(geo, mat);
this.points.frustumCulled = false;
scene.add(this.points);
}
emit(p, v, life, size, grow, r, g, b, alpha = 1, grav = 0, drag = 0) {
const i = this.next; this.next = (this.next + 1) % this.n;
this.pos[i * 3] = p.x; this.pos[i * 3 + 1] = p.y; this.pos[i * 3 + 2] = p.z;
this.vel[i * 3] = v.x; this.vel[i * 3 + 1] = v.y; this.vel[i * 3 + 2] = v.z;
this.col[i * 3] = r; this.col[i * 3 + 1] = g; this.col[i * 3 + 2] = b;
this.life[i] = life; this.maxLife[i] = life; this.size0[i] = size; this.size[i] = size; this.grow[i] = grow;
this.alpha0[i] = alpha; this.alpha[i] = alpha; this.grav[i] = grav; this.drag[i] = drag;
}
update(dt) {
const { pos, vel, life, maxLife, size, size0, grow, alpha, alpha0, grav, drag } = this;
for (let i = 0; i < this.n; i++) {
if (life[i] <= 0) continue;
life[i] -= dt;
if (life[i] <= 0) { size[i] = 0; alpha[i] = 0; continue; }
const t = 1 - life[i] / maxLife[i];
vel[i * 3 + 1] -= grav[i] * dt;
const d = 1 - drag[i] * dt;
vel[i * 3] *= d; vel[i * 3 + 1] *= d; vel[i * 3 + 2] *= d;
pos[i * 3] += vel[i * 3] * dt; pos[i * 3 + 1] += vel[i * 3 + 1] * dt; pos[i * 3 + 2] += vel[i * 3 + 2] * dt;
size[i] = size0[i] * (1 + grow[i] * t);
alpha[i] = alpha0[i] * (1 - t * t);
}
const g = this.points.geometry;
g.attributes.position.needsUpdate = true; g.attributes.aSize.needsUpdate = true; g.attributes.aAlpha.needsUpdate = true;
g.attributes.aColor.needsUpdate = true;
}
}
const fireP = new Particles(500, true);
const smokeP = new Particles(2500, false);
window.addEventListener('resize', () => { fireP.mat.uniforms.uScale.value = smokeP.mat.uniforms.uScale.value = window.innerHeight * 0.5; });
const _t1 = V3(), _t2 = V3(), _t3 = V3();
function explosion(p, big = 1) {
for (let i = 0; i < 40 * big; i++) {
_t1.set(rand(-1, 1), rand(-1, 1), rand(-1, 1)).normalize().multiplyScalar(rand(4, 28) * big);
const hot = Math.random();
fireP.emit(p, _t1, rand(0.4, 1.1), rand(6, 14) * big, 1.5, 1, 0.45 + 0.5 * hot, 0.1 + 0.3 * hot, 1, 6, 1.2);
}
fireP.emit(p, _t1.set(0, 0, 0), 0.25, 60 * big, 1.5, 1, 0.9, 0.6, 1, 0, 0);
for (let i = 0; i < 30 * big; i++) {
_t1.set(rand(-1, 1), rand(0, 1.2), rand(-1, 1)).normalize().multiplyScalar(rand(3, 16) * big);
const k = rand(0.1, 0.35);
smokeP.emit(p, _t1, rand(1.5, 3.5), rand(10, 22) * big, 2.5, k, k, k, 0.9, -1, 1.0);
}
for (let i = 0; i < 16; i++) { // debris
_t1.set(rand(-1, 1), rand(0.2, 1), rand(-1, 1)).normalize().multiplyScalar(rand(15, 40) * big);
fireP.emit(p, _t1, rand(1, 2.5), 2.5, 0, 1, 0.6, 0.2, 1, 25, 0.3);
}
playExplosion(p);
}
// ---------------------------------------------------------------------------
// Bullets
// ---------------------------------------------------------------------------
const MAX_BULLETS = 600;
const bulletMesh = new THREE.InstancedMesh(new THREE.BoxGeometry(0.2, 0.2, 3.2), new THREE.MeshBasicMaterial({ color: 0xfff0b0 }), MAX_BULLETS);
bulletMesh.frustumCulled = false;
scene.add(bulletMesh);
const bullets = [];
const glowPos = new Float32Array(MAX_BULLETS * 3);
const glowGeo = new THREE.BufferGeometry();
glowGeo.setAttribute('position', new THREE.BufferAttribute(glowPos, 3).setUsage(THREE.DynamicDrawUsage));
const bulletGlow = new THREE.Points(glowGeo, new THREE.PointsMaterial({ map: puffTex, color: 0xffd080, size: 2.6, transparent: true,
depthWrite: false, blending: THREE.AdditiveBlending }));
bulletGlow.frustumCulled = false;
scene.add(bulletGlow);
const _dummy = new THREE.Object3D();
const _fwdZ = V3(0, 0, 1);
function segPointDistSq(a, b, p) {
_t1.subVectors(b, a);
const l2 = _t1.lengthSq();
let t = l2 > 0 ? _t2.subVectors(p, a).dot(_t1) / l2 : 0;
t = clamp(t, 0, 1);
_t3.copy(a).addScaledVector(_t1, t);
return _t3.distanceToSquared(p);
}
// ---------------------------------------------------------------------------
// Planes
// ---------------------------------------------------------------------------
const ENEMY_STYLES = [
{ body: 0xb02828, wing: 0xb02828, fin: 0xeeeeee },
{ body: 0x3c4a5c, wing: 0x8a8f7a, fin: 0x3c4a5c },
{ body: 0x2a6a3a, wing: 0x7a7f4a, fin: 0xd8d0a0 },
{ body: 0xd8b040, wing: 0x505050, fin: 0x202020 },
{ body: 0x7a3a8a, wing: 0xcfcfcf, fin: 0x7a3a8a },
];
const shadowGeo = new THREE.CircleGeometry(4.5, 14);
const planes = [];
class Plane {
constructor(isPlayer, style) {
this.isPlayer = isPlayer;
this.mesh = buildPlaneMesh(style);
scene.add(this.mesh);
this.pos = this.mesh.position; this.quat = this.mesh.quaternion;
this.vel = V3();
this.fwd = V3(0, 0, -1); this.up = V3(0, 1, 0); this.right = V3(1, 0, 0);
this.throttle = 0; this.ctl = { pitch: 0, roll: 0, yaw: 0 };
this.agility = isPlayer ? 1 : 0.85; this.thrust = isPlayer ? MAX_THRUST : MAX_THRUST * 1.08;
this.maxHealth = isPlayer ? 100 : 50; this.health = this.maxHealth;
this.alive = true; this.dying = false; this.removed = false; this.deadTimer = 0;
this.onGround = false; this.groundSpeed = 0; this.heading = 0; this.brake = false;
this.speed = 0; this.alpha = 0; this.stalled = false;
this.gunTimer = 0; this.smokeTimer = 0; this.firing = false; this.propAngle = 0;
this.mode = 'attack'; this.modeTimer = 0; this.evadeDir = 1; this.decisionTimer = Math.random(); this.burst = 0; this.burstPause = 0;
this.shadow = new THREE.Mesh(shadowGeo, new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.3, depthWrite: false }));
this.shadow.rotation.x = -Math.PI / 2;
scene.add(this.shadow);
planes.push(this);
}
updateAxes() {
this.fwd.set(0, 0, -1).applyQuaternion(this.quat);
this.up.set(0, 1, 0).applyQuaternion(this.quat);
this.right.set(1, 0, 0).applyQuaternion(this.quat);
}
remove() {
this.removed = true; this.mesh.visible = false; this.shadow.visible = false;
}
}
function liftCoefficient(a) {
const CL0 = 0.25, slope = 5.0;
const aa = Math.abs(a);
if (aa <= STALL_A) return CL0 + slope * a;
const f = Math.max(0.3, 1 - (aa - STALL_A) / 0.3);
return CL0 + slope * STALL_A * Math.sign(a) * f;
}
const _euler = new THREE.Euler();
const _q = new THREE.Quaternion(), _acc = V3(), _vdir = V3(), _lift = V3(), _axis = V3();
function updateFlight(p, dt) {
p.updateAxes();
const { fwd, up, right, vel } = p;
if (p.onGround) {
// ground roll: tail-dragger sitting nose-up, steered by rudder
let a = p.throttle * p.thrust - DRAG_K * p.groundSpeed * p.groundSpeed - 0.9 - (p.brake ? 7 : 0);
if (p.groundSpeed <= 0 && a < 0) a = 0;
p.groundSpeed = Math.max(0, p.groundSpeed + a * dt);
p.heading -= p.ctl.yaw * clamp(0.4 + p.groundSpeed * 0.04, 0, 1.4) * dt * Math.min(1, p.groundSpeed / 3);
p.quat.setFromEuler(_euler.set(GROUND_PITCH, p.heading, 0, 'YXZ'));
p.updateAxes();
// heading 0 = facing -Z; heading grows counter-clockwise (three.js Y rotation)
vel.set(-Math.sin(p.heading) * p.groundSpeed, 0, -Math.cos(p.heading) * p.groundSpeed);
p.pos.addScaledVector(vel, dt);
const gh = getHeight(p.pos.x, p.pos.z);
p.pos.y = gh + WHEEL_H;
p.speed = p.groundSpeed; p.alpha = GROUND_PITCH; p.stalled = false;
const lift = LIFT_K * p.groundSpeed * p.groundSpeed * liftCoefficient(GROUND_PITCH + p.ctl.pitch * 0.08);
if (lift > G * 1.02 && p.ctl.pitch > -0.5) { p.onGround = false; vel.y = 1.5; }
if (p.groundSpeed > 8 && (gh < WATER_Y + 0.5 || groundRoughness(p.pos.x, p.pos.z) > 2.5)) destroyPlane(p, 'ground');
return;
}
const speed = vel.length();
p.speed = speed;
if (speed > 0.05) _vdir.copy(vel).divideScalar(speed); else _vdir.copy(fwd);
const vf = vel.dot(fwd), vu = vel.dot(up), vr = vel.dot(right);
const alpha = Math.atan2(-vu, Math.max(vf, 2));
p.alpha = alpha;
p.stalled = Math.abs(alpha) > STALL_A && speed > 8;
const CL = liftCoefficient(alpha);
const qd = speed * speed;
_lift.copy(up).addScaledVector(_vdir, -up.dot(_vdir));
if (_lift.lengthSq() > 1e-6) _lift.normalize();
_acc.set(0, -G, 0);
_acc.addScaledVector(fwd, p.throttle * p.thrust);
_acc.addScaledVector(_vdir, -DRAG_K * qd * (1 + IND_K * CL * CL));
_acc.addScaledVector(_lift, LIFT_K * qd * CL);
_acc.addScaledVector(right, -vr * SIDE_K);
// soft world boundary
const bx = Math.abs(p.pos.x) - BOUND, bz = Math.abs(p.pos.z) - BOUND;
if (bx > 0) _acc.x -= Math.sign(p.pos.x) * Math.min(bx * 0.12, 30);
if (bz > 0) _acc.z -= Math.sign(p.pos.z) * Math.min(bz * 0.12, 30);
vel.addScaledVector(_acc, dt);
p.pos.addScaledVector(vel, dt);
// control surfaces
const eff = clamp(speed / 36, 0, 1.25) * p.agility;
const q = p.quat;
_q.setFromAxisAngle(AX_X, p.ctl.pitch * PITCH_RATE * eff * dt); q.multiply(_q);
let rollIn = p.ctl.roll;
if (Math.abs(rollIn) < 0.05) rollIn = clamp(right.y * ROLL_STAB, -0.3, 0.3);
_q.setFromAxisAngle(AX_NZ, rollIn * ROLL_RATE * eff * dt); q.multiply(_q);
_q.setFromAxisAngle(AX_Y, -p.ctl.yaw * YAW_RATE * eff * dt); q.multiply(_q);
// aerodynamic alignment: nose follows the velocity vector
if (speed > 3) {
_axis.crossVectors(fwd, _vdir);
const s = _axis.length();
if (s > 1e-5) {
const ang = Math.atan2(s, fwd.dot(_vdir));
const rate = ALIGN_K * clamp(speed / 30, 0.15, 1);
_axis.divideScalar(s);
_q.setFromAxisAngle(_axis, Math.min(ang, ang * rate * dt)); q.premultiply(_q);
}
}
q.normalize();
p.updateAxes();
// terrain / water contact
const gh = groundLevel(p.pos.x, p.pos.z);
if (p.pos.y - WHEEL_H <= gh) {
const onAirfield = Math.hypot(p.pos.x, p.pos.z) < AIRFIELD_R - 10;
const flat = gh > WATER_Y + 0.5 && groundRoughness(p.pos.x, p.pos.z) < 1.5;
const bank = Math.abs(p.right.y), pitch = Math.asin(clamp(p.fwd.y, -1, 1));
if (p.alive && p.isPlayer && (onAirfield || flat) && vel.y > -6 && bank < 0.3 && pitch > -0.15 && pitch < 0.4 && speed < 65) {
p.onGround = true;
p.groundSpeed = Math.hypot(vel.x, vel.z);
p.heading = Math.atan2(-vel.x, -vel.z);
p.pos.y = gh + WHEEL_H;
if (onAirfield && p.health < p.maxHealth) { p.health = p.maxHealth; message('Landed. Airframe repaired.'); }
else message(onAirfield ? 'Landed.' : 'Field landing. Repairs only at the airfield.');
} else destroyPlane(p, 'ground');
}
}
function fireGuns(p, dt) {
p.gunTimer -= dt;
if (p.gunTimer > 0) return;
p.gunTimer = p.isPlayer ? 1 / 14 : 1 / 9;
const spread = p.isPlayer ? 0.007 : 0.035;
for (const side of [-0.28, 0.28]) {
const muzzle = _t1.set(side, 0.56, -2.0).applyQuaternion(p.quat).add(p.pos).clone();
const dir = _t2.copy(p.fwd).addScaledVector(p.right, rand(-spread, spread)).addScaledVector(p.up, rand(-spread, spread)).normalize();
if (bullets.length < MAX_BULLETS)
bullets.push({ p: muzzle, v: p.vel.clone().addScaledVector(dir, BULLET_SPEED), life: 1.7, owner: p, dmg: p.isPlayer ? 10 : 3 });
fireP.emit(muzzle, p.vel, 0.06, 2.2, 0, 1, 0.8, 0.4, 1, 0, 0);
}
playGun(p);
}
function updateBullets(dt) {
const p0 = _bp0;
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
p0.copy(b.p);
b.p.addScaledVector(b.v, dt);
b.life -= dt;
let dead = b.life <= 0;
if (!dead && b.p.y <= groundLevel(b.p.x, b.p.z)) {
dead = true;
const water = b.p.y <= WATER_Y + 0.5;
smokeP.emit(b.p, _t2.set(0, 3, 0), 0.8, 3, 2, water ? 0.8 : 0.55, water ? 0.85 : 0.45, water ? 0.9 : 0.3, 0.7, 0, 0);
}
if (!dead) {
for (const t of planes) {
if (t === b.owner || t.removed || !t.alive || t.isPlayer === b.owner.isPlayer) continue;
if (segPointDistSq(p0, b.p, t.pos) < 5.0 * 5.0) {
dead = true;
hitPlane(t, b.dmg, b.owner);
for (let k = 0; k < 4; k++) {
_t2.set(rand(-1, 1), rand(-1, 1), rand(-1, 1)).multiplyScalar(12).add(t.vel);
fireP.emit(b.p, _t2, 0.3, 1.5, 0, 1, 0.9, 0.5, 1, 10, 0);
}
break;
}
}
}
if (dead) { bullets[i] = bullets[bullets.length - 1]; bullets.pop(); }
}
for (let i = 0; i < bullets.length; i++) {
const b = bullets[i];
_dummy.position.copy(b.p);
_t2.copy(b.v).normalize();
_dummy.quaternion.setFromUnitVectors(_fwdZ, _t2);
_dummy.updateMatrix();
bulletMesh.setMatrixAt(i, _dummy.matrix);
glowPos[i * 3] = b.p.x; glowPos[i * 3 + 1] = b.p.y; glowPos[i * 3 + 2] = b.p.z;
}
bulletMesh.count = bullets.length;
bulletMesh.instanceMatrix.needsUpdate = true;
glowGeo.setDrawRange(0, bullets.length);
glowGeo.attributes.position.needsUpdate = true;
}
const _bp0 = V3();
function hitPlane(t, dmg, by) {
if (!t.alive) return;
t.health -= dmg;
if (t.isPlayer) { vigFlash = 1; playHit(); game.stats.playerHits++; }
else if (by.isPlayer) { hitFlash = 1; }
if (t.health <= 0) {
t.health = 0;
startDying(t);
if (t.isPlayer) { message('You have been shot down!'); }
else if (by.isPlayer) { game.score += 100; game.kills++; game.stats.enemyShot++; message(['Enemy scout down!', 'Splash one!', 'Got him!'][Math.floor(Math.random() * 3)]); }
}
}
function startDying(p) {
p.alive = false; p.dying = true; p.throttle = 0; p.onGround = false;
p.ctl.roll = rand(0.3, 0.7) * (Math.random() < 0.5 ? -1 : 1); p.ctl.pitch = -0.35; p.ctl.yaw = 0;
p.firing = false;
p.deadTimer = 0;
for (const m of p.mesh.userData.mats) m.color.multiplyScalar(0.5);
explosion(p.pos, 0.5);
}
function destroyPlane(p, cause) {
if (p.removed) return;
const wasAlive = p.alive;
if (!p.isPlayer && p.alive) game.stats[cause === 'collision' ? 'enemyCollide' : 'enemyCrash']++;
p.alive = false; p.dying = false;
explosion(p.pos, 1.2);
p.remove();
if (p.isPlayer) {
if (wasAlive) message(cause === 'collision' ? 'Mid-air collision!' : 'You crashed into the ground!');
game.playerDead = true; game.deadTimer = 0;
}
}
// ---------------------------------------------------------------------------
// AI
// ---------------------------------------------------------------------------
const _local = V3(), _aim = V3(), _ahead = V3();
function updateAI(e, dt, player) {
const ctl = e.ctl;
e.decisionTimer -= dt; e.modeTimer -= dt;
const toPlayer = _t1.subVectors(player.pos, e.pos);
const dist = toPlayer.length();
const playerAlive = player.alive;
if (e.decisionTimer <= 0) {
e.decisionTimer = 0.5;
if (e.mode === 'attack' && playerAlive) {
// is the player behind me and close?
const behind = player.fwd.dot(toPlayer) < -0.85 * dist && dist < 350;
if (behind && Math.random() < 0.45) { e.mode = 'evade'; e.modeTimer = rand(2, 3.5); e.evadeDir = Math.random() < 0.5 ? -1 : 1; }
else if (dist < 110 && e.fwd.dot(toPlayer) > 0.7 * dist) { e.mode = 'extend'; e.modeTimer = rand(2.5, 4); e.evadeDir = Math.random() < 0.5 ? -1 : 1; }
}
if (e.mode !== 'attack' && e.modeTimer <= 0) e.mode = 'attack';
}
// aim point with lead
const t = dist / BULLET_SPEED;
_aim.copy(player.pos).addScaledVector(player.vel, t).addScaledVector(e.vel, -t);
let target = _aim;
if (!playerAlive || e.mode === 'patrol') {
target = _t2.set(Math.sin(game.time * 0.1) * 800, 350, Math.cos(game.time * 0.1) * 800);
}
// out of bounds -> head home
if (Math.abs(e.pos.x) > BOUND || Math.abs(e.pos.z) > BOUND) target = _t2.set(0, 400, 0);
if (e.pos.y > 1100) target = _t2.copy(e.pos).addScaledVector(e.fwd, 300).setY(e.pos.y - 300);
// terrain avoidance
let ground = groundLevel(e.pos.x, e.pos.z);
for (const s of [1.2, 2.5, 4]) {
_ahead.copy(e.pos).addScaledVector(e.vel, s);
ground = Math.max(ground, groundLevel(_ahead.x, _ahead.z));
}
const agl = e.pos.y - ground;
let throttle = 1;
if (agl < 110) {
ctl.roll = clamp(e.right.y * 3, -1, 1);
ctl.pitch = e.up.y > 0.2 ? 1 : 0.1;
ctl.yaw = 0;
} else if (e.mode === 'evade') {
ctl.roll = e.evadeDir * clamp((0.95 - Math.abs(e.right.y)) * 5, 0, 1);
ctl.pitch = 0.85; ctl.yaw = 0;
} else if (e.mode === 'extend') {
ctl.roll = clamp(e.right.y * 3 + e.evadeDir * 0.3, -1, 1);
ctl.pitch = 0.3; ctl.yaw = 0;
} else {
_local.copy(target).sub(e.pos);
const dx = _local.dot(e.right), dy = _local.dot(e.up), dz = _local.dot(e.fwd);
const d = _local.length() || 1;
const off = Math.acos(clamp(dz / d, -1, 1));
if (off < 0.35) {
ctl.pitch = clamp(dy / d * 5, -0.6, 1);
ctl.roll = clamp(dx / d * 4 + e.right.y * 0.8, -1, 1);
ctl.yaw = clamp(dx / d * 3, -1, 1);
} else {
const around = Math.atan2(dx, dy);
ctl.roll = clamp(around * 1.6, -1, 1);
ctl.pitch = Math.abs(around) < 1.3 ? clamp(off * 2.2, 0.1, 1) : 0.15;
ctl.yaw = 0;
}
// avoid ramming
if (playerAlive && dist < 45 && dz > 0) { ctl.pitch = -0.6; ctl.roll = 0; }
if (playerAlive && dist < 160 && dz > 0 && e.vel.dot(toPlayer) / dist - player.vel.dot(toPlayer) / dist > 12) throttle = 0.55;
}
if (e.speed < 34) { throttle = 1; if (agl > 110) ctl.pitch = Math.min(ctl.pitch, 0.4); }
e.throttle = throttle;
// guns
e.firing = false;
if (playerAlive && e.mode === 'attack' && dist < 360) {
_local.copy(_aim).sub(e.pos);
const off = Math.acos(clamp(_local.dot(e.fwd) / (_local.length() || 1), -1, 1));
if (off < 0.07) {
e.burst -= dt;
if (e.burst <= 0) { e.burst = rand(0.7, 1.4); e.burstPause = rand(0.5, 1.2); }
e.firing = e.burstPause <= 0;
if (e.burstPause > 0) e.burstPause -= dt;
}
}
}
// ---------------------------------------------------------------------------
// Audio
// ---------------------------------------------------------------------------
let actx = null, master = null, engineOsc = null, engineOsc2 = null, engineGain = null, engineFilter = null, windGain = null, noiseBuf = null;
let muted = false;
function initAudio() {
if (actx) return;
try {
actx = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) { return; }
master = actx.createGain(); master.gain.value = muted ? 0 : 0.6; master.connect(actx.destination);
noiseBuf = actx.createBuffer(1, actx.sampleRate * 2, actx.sampleRate);
const d = noiseBuf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
engineOsc = actx.createOscillator(); engineOsc.type = 'sawtooth'; engineOsc.frequency.value = 40;
engineOsc2 = actx.createOscillator(); engineOsc2.type = 'square'; engineOsc2.frequency.value = 41;
engineFilter = actx.createBiquadFilter(); engineFilter.type = 'lowpass'; engineFilter.frequency.value = 300;
engineGain = actx.createGain(); engineGain.gain.value = 0;
engineOsc.connect(engineFilter); engineOsc2.connect(engineFilter); engineFilter.connect(engineGain); engineGain.connect(master);
engineOsc.start(); engineOsc2.start();
const wind = actx.createBufferSource(); wind.buffer = noiseBuf; wind.loop = true;
const wf = actx.createBiquadFilter(); wf.type = 'bandpass'; wf.frequency.value = 700; wf.Q.value = 0.5;
windGain = actx.createGain(); windGain.gain.value = 0;
wind.connect(wf); wf.connect(windGain); windGain.connect(master); wind.start();
}
function updateAudio(p, dt) {
if (!actx) return;
if (actx.state === 'suspended') actx.resume();
const thr = p.removed ? 0 : p.throttle;
const rpm = p.removed ? 0 : (0.3 + thr * 0.7) * (p.alive ? 1 : 0.4);
engineOsc.frequency.setTargetAtTime(28 + rpm * 60, actx.currentTime, 0.1);
engineOsc2.frequency.setTargetAtTime(28.5 + rpm * 60, actx.currentTime, 0.1);
engineFilter.frequency.setTargetAtTime(150 + rpm * 600, actx.currentTime, 0.1);
engineGain.gain.setTargetAtTime(p.removed ? 0 : 0.06 + thr * 0.14, actx.currentTime, 0.1);
windGain.gain.setTargetAtTime(p.removed ? 0 : clamp((p.speed - 15) / 90, 0, 0.5) * 0.5, actx.currentTime, 0.2);
}
function noiseBurst(dur, gain, filterType, freq, q = 1) {
if (!actx) return;
const src = actx.createBufferSource(); src.buffer = noiseBuf;
const f = actx.createBiquadFilter(); f.type = filterType; f.frequency.value = freq; f.Q.value = q;
const g = actx.createGain();
const t = actx.currentTime;
g.gain.setValueAtTime(gain, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(f); f.connect(g); g.connect(master);
src.start(t, Math.random()); src.stop(t + dur);
}
function playGun(p) {
if (!actx) return;
const d = p.isPlayer ? 0 : p.pos.distanceTo(player.pos);
if (d > 250) return;
noiseBurst(0.07, p.isPlayer ? 0.35 : 0.12 * (1 - d / 250), 'bandpass', 1800, 0.7);
}
function playExplosion(pos) {
if (!actx) return;
const d = pos.distanceTo(camera.position);
noiseBurst(1.4, clamp(1 - d / 900, 0.05, 1) * 0.9, 'lowpass', 220, 0.5);
}
function playHit() { noiseBurst(0.09, 0.4, 'highpass', 2500, 1); }
// ---------------------------------------------------------------------------
// Game state, input
// ---------------------------------------------------------------------------
const keys = {};
const game = { state: 'menu', score: 0, kills: 0, wave: 0, time: 0, playerDead: false, deadTimer: 0, waveTimer: 0, invert: false, cockpit: false,
stats: { enemyCrash: 0, enemyShot: 0, enemyCollide: 0, playerHits: 0 } };
let vigFlash = 0, hitFlash = 0, msgTimer = 0;
let player = null;
function spawnPlayer() {
player = new Plane(true, { body: 0x6b6f3a, wing: 0x8a8556, fin: 0x6b6f3a, marking: 'roundel' });
player.onGround = true; player.heading = 0; player.groundSpeed = 0;
player.pos.set(0, AIRFIELD_Y + WHEEL_H, 250);
player.quat.setFromEuler(new THREE.Euler(GROUND_PITCH, 0, 0, 'YXZ'));
player.updateAxes();
}
function spawnWave(n) {
const base = player.alive ? player.pos : V3(0, 300, 0);
for (let i = 0; i < n; i++) {
const e = new Plane(false, ENEMY_STYLES[(game.wave + i) % ENEMY_STYLES.length]);
const ang = rand(0, Math.PI * 2), d = rand(1300, 1900);
let x = clamp(base.x + Math.cos(ang) * d, -BOUND + 200, BOUND - 200), z = clamp(base.z + Math.sin(ang) * d, -BOUND + 200, BOUND - 200);
const y = Math.max(groundLevel(x, z) + 200, base.y + rand(50, 250), 250);
e.pos.set(x, y, z);
const dir = V3().subVectors(base, e.pos).setY(0).normalize();
e.quat.setFromUnitVectors(AX_NZ, dir);
e.updateAxes();
e.vel.copy(dir).multiplyScalar(50);
e.throttle = 1;
}
}
function resetGame() {
for (const p of planes) { scene.remove(p.mesh); scene.remove(p.shadow); }
planes.length = 0; bullets.length = 0;
game.score = 0; game.kills = 0; game.wave = 0; game.playerDead = false; game.waveTimer = 2; game.time = 0;
spawnPlayer();
}
function startGame() {
resetGame();
game.state = 'playing';
document.getElementById('overlay').style.display = 'none';
initAudio();
message('Throttle up (W) and take off!');
}
function endGame() {
game.state = 'dead';
const ov = document.getElementById('overlay');
ov.style.display = 'flex';
document.getElementById('intro').style.display = 'none';
document.getElementById('stats').style.display = 'block';
document.getElementById('stats').textContent = `Shot down. Score ${game.score}, ${game.kills} kills, reached wave ${game.wave}.`;
document.getElementById('startBtn').textContent = 'Fly again';
}
function message(text) {
const m = document.getElementById('msg');
m.textContent = text; m.style.opacity = 1; msgTimer = 2.8;
}
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
initAudio();
if (e.code === 'Enter' && game.state !== 'playing') startGame();
if (e.code === 'KeyR' && game.state !== 'menu') startGame();
if (e.code === 'KeyC') game.cockpit = !game.cockpit;
if (e.code === 'KeyI') { game.invert = !game.invert; message(game.invert ? 'Pitch inverted: Up = nose up' : 'Pitch normal: Down = nose up'); }
if (e.code === 'KeyM') { muted = !muted; if (master) master.gain.value = muted ? 0 : 0.6; message(muted ? 'Sound off' : 'Sound on'); }
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
window.addEventListener('blur', () => { for (const k in keys) keys[k] = false; });
document.getElementById('startBtn').addEventListener('click', startGame);
function readInput(dt) {
const p = player;
let pitch = 0, roll = 0, yaw = 0;
if (keys.ArrowUp) pitch -= 1;
if (keys.ArrowDown) pitch += 1;
if (game.invert) pitch = -pitch;
if (keys.ArrowLeft) roll -= 1;
if (keys.ArrowRight) roll += 1;
if (keys.KeyA) yaw -= 1;
if (keys.KeyD) yaw += 1;
// smooth control inputs
const k = 1 - Math.exp(-dt * 10);
p.ctl.pitch += (pitch - p.ctl.pitch) * k;
p.ctl.roll += (roll - p.ctl.roll) * k;
p.ctl.yaw += (yaw - p.ctl.yaw) * k;
if (keys.KeyW) p.throttle = Math.min(1, p.throttle + dt * 0.6);
if (keys.KeyS) p.throttle = Math.max(0, p.throttle - dt * 0.6);
p.brake = !!keys.KeyS && p.throttle <= 0.001;
p.firing = !!keys.Space;
if (window.__game && window.__game.autopilot) window.__game.autopilot(p, dt);
}
// ---------------------------------------------------------------------------
// Camera, HUD, radar
// ---------------------------------------------------------------------------
const camPos = V3(0, 40, 120), camLook = V3(), camUp = V3(0, 1, 0);
function updateCamera(dt) {
if (game.state === 'menu') {
const t = game.time * 0.25;
camPos.set(Math.sin(t) * 40, AIRFIELD_Y + 10, 250 + Math.cos(t) * 40);
camera.position.copy(camPos);
camera.up.set(0, 1, 0);
_t1.set(0, AIRFIELD_Y + 2, 248);
_t2.subVectors(_t1, camPos).setY(0).normalize();
_t3.set(-_t2.z, 0, _t2.x); // camera-right on the ground plane
camera.lookAt(_t1.addScaledVector(_t3, -16));
camera.fov = 60; camera.updateProjectionMatrix();
return;
}
const p = player;
const k = 1 - Math.exp(-dt * 6);
if (game.cockpit && !p.removed) {
_t1.set(0, 1.12, -0.15).applyQuaternion(p.quat).add(p.pos);
camera.position.copy(_t1);
camera.quaternion.copy(p.quat);
camPos.copy(_t1);
camera.fov = 80; camera.updateProjectionMatrix();
p.mesh.userData.pilot.visible = false;
return;
}
p.mesh.userData.pilot.visible = true;
camera.fov = 70 + clamp((p.speed - 40) / 40, 0, 1) * 8; camera.updateProjectionMatrix();
if (p.removed) {
// orbit the wreck
_t1.set(Math.sin(game.time) * 45, 18, Math.cos(game.time) * 45).add(p.pos);
_t1.y = Math.max(_t1.y, groundLevel(_t1.x, _t1.z) + 4);
camPos.lerp(_t1, k);
camera.position.copy(camPos);
camera.up.set(0, 1, 0);
camera.lookAt(p.pos);
return;
}
_t2.copy(WORLD_UP).lerp(p.up, 0.55).normalize();
_t1.copy(p.pos).addScaledVector(p.fwd, -17).addScaledVector(_t2, 5);
const g = groundLevel(_t1.x, _t1.z) + 2;
if (_t1.y < g) _t1.y = g;
camPos.lerp(_t1, k);
camera.position.copy(camPos);
camLook.copy(p.pos).addScaledVector(p.fwd, 25);
camUp.lerp(_t2, k).normalize();
camera.up.copy(camUp);
camera.lookAt(camLook);
}
const el = id => document.getElementById(id);
const hudEl = { spd: el('spd'), alt: el('alt'), hdg: el('hdg'), thr: el('thr'), hp: el('hp'), sc: el('sc'), wave: el('wave'), enemies: el('enemies'),
msg: el('msg'), warn: el('warn'), vig: el('vig'), hit: el('hit'), lead: el('lead'), markers: el('markers') };
const radarCtx = el('radar').getContext('2d');
const markerPool = [];
for (let i = 0; i < 12; i++) { const d = document.createElement('div'); d.style.display = 'none'; hudEl.markers.appendChild(d); markerPool.push(d); }
const _proj = V3(), _view = V3();
function updateHUD(dt) {
const p = player;
hudEl.spd.textContent = `${Math.round(p.speed * 3.6)} km/h`;
hudEl.alt.textContent = `${Math.round(p.pos.y)} m`;
let hdg = Math.round((Math.atan2(p.fwd.x, -p.fwd.z) * 180 / Math.PI + 360) % 360);
hudEl.hdg.textContent = String(hdg).padStart(3, '0') + '°';
hudEl.thr.style.width = `${p.throttle * 100}%`;
hudEl.hp.style.width = `${Math.max(0, p.health / p.maxHealth) * 100}%`;
hudEl.hp.style.background = p.health > 50 ? '#40d060' : p.health > 25 ? '#f0b040' : '#e04040';
hudEl.sc.textContent = game.score;
hudEl.wave.textContent = game.wave;
const alive = planes.filter(q => !q.isPlayer && q.alive);
hudEl.enemies.textContent = alive.length;
const outside = Math.abs(p.pos.x) > BOUND || Math.abs(p.pos.z) > BOUND;
hudEl.warn.textContent = outside ? 'TURN BACK - LEAVING THE FRONT' : 'STALL';
hudEl.warn.style.display = ((p.stalled && !p.onGround) || outside) && p.alive ? 'block' : 'none';
vigFlash = Math.max(0, vigFlash - dt * 2.5); hudEl.vig.style.opacity = vigFlash;
hitFlash = Math.max(0, hitFlash - dt * 4); hudEl.hit.style.opacity = hitFlash;
if (msgTimer > 0) { msgTimer -= dt; if (msgTimer <= 0) hudEl.msg.style.opacity = 0; }
// enemy markers
const W = window.innerWidth, H = window.innerHeight;
let mi = 0, nearest = null, nearestD = 1e9;
for (const e of alive) {
if (mi >= markerPool.length) break;
const d = e.pos.distanceTo(p.pos);
if (d < nearestD) { nearestD = d; nearest = e; }
_view.copy(e.pos).applyMatrix4(camera.matrixWorldInverse);
const behind = _view.z > -1;
let x = 0, y = 0;
if (!behind) { _proj.copy(e.pos).project(camera); x = _proj.x; y = _proj.y; }
const onScreen = !behind && Math.abs(x) < 1 && Math.abs(y) < 1;
const m = markerPool[mi++];
m.style.display = 'block';
if (onScreen) {
m.className = ''; m.dataset.d = `${Math.round(d)} m`;
m.style.left = `${(x + 1) / 2 * W}px`; m.style.top = `${(1 - y) / 2 * H}px`;
} else {
// direction on screen toward the target, then clamp to the screen edge; arrow points that way
if (behind) { x = _view.x; y = _view.y; if (Math.abs(x) + Math.abs(y) < 1e-3) { x = 0; y = -1; } }
const s = 1 / Math.max(Math.abs(x), Math.abs(y), 1e-6);
x *= s; y *= s;
const px = clamp((x + 1) / 2 * W, 30, W - 30), py = clamp((1 - y) / 2 * H, 30, H - 30);
m.className = 'off'; m.dataset.d = '';
m.style.left = `${px}px`; m.style.top = `${py}px`;
m.style.setProperty('--rot', `${Math.atan2(x, y) * 180 / Math.PI}deg`);
}
}
for (; mi < markerPool.length; mi++) markerPool[mi].style.display = 'none';
// lead indicator for the nearest enemy
if (nearest && nearestD < 600 && p.alive) {
const t = nearestD / BULLET_SPEED;
_proj.copy(nearest.pos).addScaledVector(nearest.vel, t).addScaledVector(p.vel, -t).project(camera);
if (_proj.z < 1 && Math.abs(_proj.x) < 1 && Math.abs(_proj.y) < 1) {
hudEl.lead.style.display = 'block';
hudEl.lead.style.left = `${(_proj.x + 1) / 2 * W}px`; hudEl.lead.style.top = `${(1 - _proj.y) / 2 * H}px`;
} else hudEl.lead.style.display = 'none';
} else hudEl.lead.style.display = 'none';
// radar
const c = radarCtx, R = 75, scale = R / 1600;
c.clearRect(0, 0, 150, 150);
c.strokeStyle = 'rgba(120,255,120,0.25)'; c.lineWidth = 1;
c.beginPath(); c.arc(R, R, R * 0.5, 0, Math.PI * 2); c.stroke();
c.beginPath(); c.moveTo(R, 0); c.lineTo(R, 150); c.moveTo(0, R); c.lineTo(150, R); c.stroke();
const fx = p.fwd.x, fz = p.fwd.z, fl = Math.hypot(fx, fz) || 1;
const hx = fx / fl, hz = fz / fl, rx = -hz, rz = hx;
const dot = (wx, wz, col, size) => {
const dx = wx - p.pos.x, dz = wz - p.pos.z;
const fwdC = dx * hx + dz * hz, rightC = dx * rx + dz * rz;
let px = rightC * scale, py = -fwdC * scale;
const l = Math.hypot(px, py);
if (l > R - 6) { px *= (R - 6) / l; py *= (R - 6) / l; }
c.fillStyle = col; c.beginPath(); c.arc(R + px, R + py, size, 0, Math.PI * 2); c.fill();
};
dot(0, 0, 'rgba(120,200,255,0.9)', 4);
for (const e of alive) dot(e.pos.x, e.pos.z, '#ff5040', 3.5);
c.fillStyle = '#fff'; c.beginPath(); c.moveTo(R, R - 6); c.lineTo(R - 4, R + 4); c.lineTo(R + 4, R + 4); c.closePath(); c.fill();
}
// ---------------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------------
const clock = new THREE.Clock();
spawnPlayer();
function updatePlane(p, dt) {
if (p.removed) return;
if (p.alive) {
if (p.isPlayer) readInput(dt); else updateAI(p, dt, player);
} else if (p.dying) {
p.deadTimer += dt;
p.smokeTimer -= dt;
if (p.smokeTimer <= 0) {
p.smokeTimer = 0.035;
_t1.set(0, 0.3, -1.5).applyQuaternion(p.quat).add(p.pos);
_t2.copy(p.vel).multiplyScalar(0.2).add(_t3.set(rand(-2, 2), rand(1, 4), rand(-2, 2)));
smokeP.emit(_t1, _t2, rand(1.8, 3), rand(3, 5), 3, 0.12, 0.11, 0.1, 0.85, -0.5, 0.6);
if (Math.random() < 0.5) fireP.emit(_t1, _t2, 0.35, 3, 0.5, 1, 0.5, 0.15, 1, 0, 0);
}
}
const sub = 2;
for (let i = 0; i < sub; i++) {
updateFlight(p, dt / sub);
if (p.removed) return;
}
if (p.firing && p.alive) fireGuns(p, dt);
// damaged smoke trail
if (p.alive && p.health < p.maxHealth * 0.45) {
p.smokeTimer -= dt;
if (p.smokeTimer <= 0) {
p.smokeTimer = 0.07;
_t1.set(0, 0.2, -1.8).applyQuaternion(p.quat).add(p.pos);
_t2.copy(p.vel).multiplyScalar(0.15).add(_t3.set(rand(-1, 1), rand(1, 3), rand(-1, 1)));
smokeP.emit(_t1, _t2, rand(1.2, 2.2), rand(2, 3.5), 2.5, 0.3, 0.3, 0.3, 0.7, -0.5, 0.5);
}
}
// propeller
const rpm = p.alive ? 0.3 + p.throttle : 0.05;
p.propAngle += rpm * 60 * dt;
p.mesh.userData.prop.rotation.z = p.propAngle;
p.mesh.userData.disc.material.opacity = clamp((rpm - 0.4) * 0.5, 0, 0.3);
// blob shadow
const gh = groundLevel(p.pos.x, p.pos.z);
const agl = p.pos.y - gh;
if (agl < 220) {
p.shadow.visible = true;
p.shadow.position.set(p.pos.x, gh + 0.35, p.pos.z);
p.shadow.material.opacity = 0.35 * clamp(1 - agl / 220, 0, 1);
const sc = 1 + agl / 80;
p.shadow.scale.set(sc, sc, 1);
} else p.shadow.visible = false;
}
function simulate(dt) {
game.time += dt;
if (game.state !== 'menu') {
for (const p of planes) updatePlane(p, dt);
// mid-air collisions
if (player.alive) for (const e of planes) {
if (e.isPlayer || e.removed || !e.alive) continue;
if (e.pos.distanceToSquared(player.pos) < 7 * 7) { destroyPlane(e, 'collision'); destroyPlane(player, 'collision'); }
}
updateBullets(dt);
// waves
const aliveEnemies = planes.filter(q => !q.isPlayer && q.alive).length;
if (aliveEnemies === 0 && player.alive) {
game.waveTimer -= dt;
if (game.waveTimer <= 0) {
game.wave++;
spawnWave(Math.min(2 + game.wave, 7));
game.waveTimer = 4;
message(`Wave ${game.wave}: ${Math.min(2 + game.wave, 7)} enemy scouts inbound`);
if (game.wave > 1) game.score += 250;
}
}
// clean removed enemies
for (let i = planes.length - 1; i >= 0; i--) {
const p = planes[i];
if (p.removed && !p.isPlayer) { scene.remove(p.mesh); scene.remove(p.shadow); planes.splice(i, 1); }
}
if (game.playerDead && game.state === 'playing') {
game.deadTimer += dt;
if (game.deadTimer > 2.5) endGame();
}
}
fireP.update(dt); smokeP.update(dt);
for (const cl of clouds) { cl.position.x += 2.5 * dt; if (cl.position.x > HALF) cl.position.x = -HALF; }
}
function loop() {
requestAnimationFrame(loop);
const dt = Math.min(clock.getDelta(), 0.05);
simulate(dt);
if (game.state !== 'menu') { updateHUD(dt); updateAudio(player, dt); }
updateCamera(dt);
sky.position.copy(camera.position);
sunSprite.position.copy(camera.position).addScaledVector(SUN_DIR, 5000);
renderer.render(scene, camera);
}
loop();
// debug / test hook
window.__game = { game, keys, planes, bullets, get player() { return player; }, startGame, getHeight, THREE, simulate, spawnWave, updateHUD, updateCamera, camera, autopilot: null };
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment