Created
September 6, 2026 08:50
-
-
Save senko/17311dcfb9959c90428c71e4f1baf1a4 to your computer and use it in GitHub Desktop.
Flying simulator by Fable 5.1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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°</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>↑</kbd><kbd>↓</kbd> pitch <kbd>←</kbd><kbd>→</kbd> roll <kbd>A</kbd><kbd>D</kbd> rudder<br> | |
| <kbd>W</kbd><kbd>S</kbd> throttle <kbd>Space</kbd> guns <kbd>C</kbd> camera <kbd>I</kbd> invert pitch <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>↓</kbd> once you pass | |
| 120 km/h, and hunt down the enemy scouts. Each wave brings more of them.</p> | |
| <table> | |
| <tr><td><kbd>↑</kbd> <kbd>↓</kbd></td><td>Pitch (nose down / nose up)</td></tr> | |
| <tr><td><kbd>←</kbd> <kbd>→</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