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