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