Created
July 17, 2026 07:07
-
-
Save senko/179dc00e882c5f8253c55552466df95c to your computer and use it in GitHub Desktop.
Flying simulator by Kimi K3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>CRIMSON SKIES — WW1 Dogfight</title> | |
| <style> | |
| html,body{margin:0;padding:0;overflow:hidden;background:#0a0e14;height:100%; | |
| font-family:'Courier New',Courier,monospace;-webkit-user-select:none;user-select:none;} | |
| canvas.game{display:block;} | |
| .hud{position:absolute;color:#cfe6b0;text-shadow:0 0 4px #000,0 0 2px #000; | |
| letter-spacing:1px;pointer-events:none;z-index:5;} | |
| #topbar{top:10px;left:50%;transform:translateX(-50%);font-size:15px;text-align:center; | |
| background:rgba(8,12,8,.45);padding:5px 18px;border:1px solid rgba(160,200,130,.35);border-radius:3px;} | |
| #topbar b{color:#ffe9a8;} | |
| #inst{left:14px;bottom:14px;font-size:14px;line-height:1.5;background:rgba(8,12,8,.45); | |
| padding:8px 12px;border:1px solid rgba(160,200,130,.35);border-radius:3px;} | |
| #inst .v{color:#ffe9a8;display:inline-block;min-width:64px;text-align:right;} | |
| .bar{width:150px;height:9px;border:1px solid rgba(160,200,130,.6);display:inline-block; | |
| vertical-align:middle;margin-left:6px;background:rgba(0,0,0,.5);} | |
| .bar>i{display:block;height:100%;background:#9fd06a;width:50%;} | |
| #hpwrap{left:50%;bottom:16px;transform:translateX(-50%);font-size:12px;text-align:center;} | |
| #hpbar{width:240px;height:12px;border:1px solid rgba(200,220,170,.7);background:rgba(0,0,0,.55);} | |
| #hpbar>i{display:block;height:100%;width:100%;background:linear-gradient(90deg,#d84b35,#e8c33c,#9fd06a);} | |
| #radar{right:14px;bottom:14px;width:150px;height:150px;border:1px solid rgba(160,200,130,.5); | |
| border-radius:50%;background:rgba(6,10,6,.55);} | |
| #cross{left:50%;top:50%;transform:translate(-50%,-50%);width:44px;height:44px;opacity:.9;} | |
| #msg{left:50%;top:16%;transform:translateX(-50%);font-size:20px;color:#ffe9a8;text-align:center; | |
| opacity:0;transition:opacity .3s;white-space:nowrap;} | |
| #sub{left:50%;top:21%;transform:translateX(-50%);font-size:13px;color:#cfe6b0;text-align:center; | |
| opacity:0;transition:opacity .3s;} | |
| #vignette{position:absolute;inset:0;pointer-events:none;z-index:4;opacity:0; | |
| background:radial-gradient(ellipse at center,transparent 42%,rgba(190,20,10,.75) 100%);} | |
| #hitmark{left:50%;top:50%;transform:translate(-50%,-50%) rotate(45deg);font-size:26px;color:#fff; | |
| opacity:0;font-weight:bold;} | |
| #lead{position:absolute;width:14px;height:14px;border:1.5px solid #6ef3ff;transform:translate(-50%,-50%) rotate(45deg); | |
| display:none;z-index:5;pointer-events:none;box-shadow:0 0 6px rgba(110,243,255,.8);} | |
| .emark{position:absolute;color:#ff6a55;font-size:11px;transform:translate(-50%,-50%); | |
| text-shadow:0 0 4px #000;pointer-events:none;z-index:5;display:none;white-space:nowrap;text-align:center;} | |
| .emark .d{font-size:9px;color:#ffb0a0;} | |
| #overlay{position:absolute;inset:0;background:rgba(4,8,12,.82);z-index:20;display:flex; | |
| flex-direction:column;align-items:center;justify-content:center;color:#dfe9c8;cursor:pointer;} | |
| #overlay h1{font-size:46px;margin:0 0 4px;color:#e8c33c;letter-spacing:8px;text-shadow:0 0 18px rgba(232,195,60,.4);} | |
| #overlay h2{font-size:14px;margin:0 0 26px;color:#9fb886;letter-spacing:4px;font-weight:normal;} | |
| #overlay .card{border:1px solid rgba(160,200,130,.4);background:rgba(10,16,10,.6);padding:18px 26px; | |
| font-size:14px;line-height:1.9;border-radius:4px;} | |
| #overlay .card td{padding:1px 10px;} | |
| #overlay .card td:first-child{color:#ffe9a8;text-align:right;} | |
| #overlay .go{margin-top:26px;font-size:18px;color:#fff;border:1px solid #e8c33c;padding:10px 34px; | |
| letter-spacing:3px;animation:pulse 1.6s infinite;border-radius:3px;} | |
| #overlay .score{margin-top:14px;font-size:16px;color:#ffe9a8;} | |
| @keyframes pulse{0%,100%{background:rgba(232,195,60,.08);}50%{background:rgba(232,195,60,.3);}} | |
| #paused{left:50%;top:42%;transform:translateX(-50%);font-size:30px;color:#fff;display:none;letter-spacing:6px;} | |
| #err{position:absolute;top:6px;left:8px;color:#f66;font-size:12px;z-index:30;white-space:pre-wrap;max-width:60%;} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="err"></div> | |
| <div id="topbar" class="hud">SCORE <b id="score">0</b> · WAVE <b id="wave">–</b> · BANDITS <b id="foes">0</b> · <span id="clk"></span></div> | |
| <div id="inst" class="hud"> | |
| SPD <span class="v" id="spd">0</span> km/h<br> | |
| ALT <span class="v" id="alt">0</span> m<br> | |
| THR <span class="bar"><i id="thr"></i></span><br> | |
| ENG <span class="v" id="eng" style="min-width:40px">OK</span> | |
| </div> | |
| <div id="hpwrap" class="hud">AIRFRAME<div id="hpbar"><i id="hp"></i></div></div> | |
| <canvas id="radar" class="hud" width="150" height="150"></canvas> | |
| <div id="cross" class="hud"> | |
| <svg width="44" height="44" viewBox="0 0 44 44"> | |
| <circle cx="22" cy="22" r="14" fill="none" stroke="#cfe6b0" stroke-width="1.4" opacity=".85"/> | |
| <line x1="22" y1="2" x2="22" y2="12" stroke="#cfe6b0" stroke-width="1.6"/> | |
| <line x1="22" y1="32" x2="22" y2="42" stroke="#cfe6b0" stroke-width="1.6"/> | |
| <line x1="2" y1="22" x2="12" y2="22" stroke="#cfe6b0" stroke-width="1.6"/> | |
| <line x1="32" y1="22" x2="42" y2="22" stroke="#cfe6b0" stroke-width="1.6"/> | |
| <circle cx="22" cy="22" r="1.6" fill="#cfe6b0"/> | |
| </svg> | |
| </div> | |
| <div id="lead"></div> | |
| <div id="markers"></div> | |
| <div id="msg" class="hud"></div> | |
| <div id="sub" class="hud"></div> | |
| <div id="hitmark" class="hud">✕</div> | |
| <div id="vignette"></div> | |
| <div id="paused" class="hud">— PAUSED —</div> | |
| <div id="overlay"> | |
| <h1>CRIMSON SKIES</h1> | |
| <h2>· 1917 · WESTERN FRONT ·</h2> | |
| <div class="card"> | |
| <table> | |
| <tr><td>↓ / ↑</td><td>pull up / push down</td></tr> | |
| <tr><td>← / →</td><td>roll (bank to turn)</td></tr> | |
| <tr><td>A / D</td><td>rudder</td></tr> | |
| <tr><td>W / S</td><td>throttle (SHIFT = full, CTRL = cut)</td></tr> | |
| <tr><td>SPACE</td><td>machine guns</td></tr> | |
| <tr><td>C · P · R</td><td>camera · pause · restart</td></tr> | |
| </table> | |
| </div> | |
| <div class="go" id="gobtn">CLICK TO FLY</div> | |
| <div class="score" id="finalscore"></div> | |
| <div style="margin-top:10px;font-size:11px;color:#7b9070">throttle up · lift off past 95 km/h · beware the stall · balloons are fair game</div> | |
| </div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script> | |
| if(!window.THREE){document.write('<script src="https://unpkg.com/three@0.128.0/build/three.min.js"><\/script>');} | |
| </script> | |
| <script> | |
| (function(){ | |
| 'use strict'; | |
| if(!window.THREE){document.getElementById('err').textContent='Failed to load three.js from CDN.';return;} | |
| /* ============================== utils ============================== */ | |
| const clamp=(v,a,b)=>v<a?a:(v>b?b:v); | |
| const lerp=(a,b,t)=>a+(b-a)*t; | |
| const rand=(a,b)=>a+Math.random()*(b-a); | |
| const sstep=(a,b,x)=>{const t=clamp((x-a)/(b-a),0,1);return t*t*(3-2*t);}; | |
| const TAU=Math.PI*2; | |
| const V3=(x,y,z)=>new THREE.Vector3(x,y,z); | |
| const Q=()=>new THREE.Quaternion(); | |
| const tmpV1=V3(),tmpV2=V3(),tmpV3=V3(),tmpV4=V3(),tmpQ=Q(); | |
| /* ============================== noise / terrain height ============================== */ | |
| function hash2(x,y){const n=Math.sin(x*127.1+y*311.7)*43758.5453123;return n-Math.floor(n);} | |
| function vnoise(x,y){ | |
| const xi=Math.floor(x),yi=Math.floor(y),xf=x-xi,yf=y-yi; | |
| const a=hash2(xi,yi),b=hash2(xi+1,yi),c=hash2(xi,yi+1),d=hash2(xi+1,yi+1); | |
| const u=xf*xf*(3-2*xf),v=yf*yf*(3-2*yf); | |
| return a*(1-u)*(1-v)+b*u*(1-v)+c*(1-u)*v+d*u*v; | |
| } | |
| function fbm(x,y){ | |
| let s=0,amp=1,f=1,norm=0; | |
| for(let i=0;i<5;i++){s+=vnoise(x*f,y*f)*amp;norm+=amp;amp*=0.5;f*=2.03;} | |
| return s/norm; | |
| } | |
| const AF={x:0,z:0,h:14,r:300}; // airfield | |
| function heightAt(x,z){ | |
| let h=(fbm(x*0.00055+13.7,z*0.00055+7.31)-0.42)*430; | |
| h+=Math.max(0,fbm(x*0.0021+3.1,z*0.0021+9.7)-0.55)*160; | |
| const r=Math.hypot(x,z); | |
| h=lerp(h,-20,sstep(2900,3900,r)); // fall to sea | |
| const d=Math.hypot(x-AF.x,z-AF.z); | |
| h=lerp(AF.h,h,sstep(AF.r*0.5,AF.r,d)); // flatten airfield | |
| return h; | |
| } | |
| /* ============================== audio (procedural) ============================== */ | |
| const AudioSys={ | |
| ctx:null,on:false, | |
| init(){ | |
| if(this.ctx)return; | |
| try{this.ctx=new (window.AudioContext||window.webkitAudioContext)();}catch(e){return;} | |
| const c=this.ctx; | |
| this.master=c.createGain();this.master.gain.value=0.45;this.master.connect(c.destination); | |
| // engine drone | |
| this.engFilter=c.createBiquadFilter();this.engFilter.type='lowpass';this.engFilter.frequency.value=420; | |
| this.engGain=c.createGain();this.engGain.gain.value=0; | |
| this.engOsc=c.createOscillator();this.engOsc.type='sawtooth';this.engOsc.frequency.value=48; | |
| this.engOsc2=c.createOscillator();this.engOsc2.type='square';this.engOsc2.frequency.value=24; | |
| const g2=c.createGain();g2.gain.value=0.6; | |
| this.engOsc.connect(this.engFilter);this.engOsc2.connect(g2);g2.connect(this.engFilter); | |
| this.engFilter.connect(this.engGain);this.engGain.connect(this.master); | |
| this.engOsc.start();this.engOsc2.start(); | |
| // wind | |
| const len=c.sampleRate;const buf=c.createBuffer(1,len,c.sampleRate); | |
| const d=buf.getChannelData(0);for(let i=0;i<len;i++)d[i]=Math.random()*2-1; | |
| this.noiseBuf=buf; | |
| this.windSrc=c.createBufferSource();this.windSrc.buffer=buf;this.windSrc.loop=true; | |
| this.windFilter=c.createBiquadFilter();this.windFilter.type='bandpass';this.windFilter.frequency.value=650;this.windFilter.Q.value=0.6; | |
| this.windGain=c.createGain();this.windGain.gain.value=0; | |
| this.windSrc.connect(this.windFilter);this.windFilter.connect(this.windGain);this.windGain.connect(this.master); | |
| this.windSrc.start(); | |
| this.on=true; | |
| }, | |
| engine(thr,speed,alive){ | |
| if(!this.on)return;const t=this.ctx.currentTime; | |
| const f=(alive?30:8)+thr*85+speed*0.25; | |
| this.engOsc.frequency.setTargetAtTime(f,t,0.09); | |
| this.engOsc2.frequency.setTargetAtTime(f*0.5,t,0.09); | |
| this.engGain.gain.setTargetAtTime(alive?0.045+thr*0.08:0.012,t,0.1); | |
| this.windGain.gain.setTargetAtTime(clamp(speed/75,0,1)*0.11,t,0.15); | |
| }, | |
| _burst(dur,type,freq,vol,sweepTo){ | |
| if(!this.on)return;const c=this.ctx,t=c.currentTime; | |
| const s=c.createBufferSource();s.buffer=this.noiseBuf; | |
| const f=c.createBiquadFilter();f.type=type;f.frequency.setValueAtTime(freq,t); | |
| if(sweepTo)f.frequency.exponentialRampToValueAtTime(Math.max(30,sweepTo),t+dur); | |
| const g=c.createGain();g.gain.setValueAtTime(vol,t); | |
| g.gain.exponentialRampToValueAtTime(0.001,t+dur); | |
| s.connect(f);f.connect(g);g.connect(this.master); | |
| s.start(t,Math.random());s.stop(t+dur+0.02); | |
| }, | |
| gun(){this._burst(0.07,'highpass',900,0.16);}, | |
| gunFar(dist){this._burst(0.06,'bandpass',700,clamp(90/dist,0,0.12));}, | |
| hitTick(){if(!this.on)return;const c=this.ctx,t=c.currentTime; | |
| const o=c.createOscillator();o.type='square';o.frequency.value=1300; | |
| const g=c.createGain();g.gain.setValueAtTime(0.08,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.06); | |
| o.connect(g);g.connect(this.master);o.start(t);o.stop(t+0.07);}, | |
| boom(dist){const v=clamp(420/dist,0.06,0.55);this._burst(0.9,'lowpass',520,v,55); | |
| if(!this.on)return;const c=this.ctx,t=c.currentTime; | |
| const o=c.createOscillator();o.type='sine';o.frequency.setValueAtTime(130,t); | |
| o.frequency.exponentialRampToValueAtTime(28,t+0.7); | |
| const g=c.createGain();g.gain.setValueAtTime(v*0.9,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.8); | |
| o.connect(g);g.connect(this.master);o.start(t);o.stop(t+0.85);}, | |
| }; | |
| /* ============================== renderer / scene ============================== */ | |
| const renderer=new THREE.WebGLRenderer({antialias:true,powerPreference:'high-performance'}); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio,1.6)); | |
| renderer.setSize(window.innerWidth,window.innerHeight); | |
| renderer.outputEncoding=THREE.sRGBEncoding; | |
| renderer.domElement.className='game'; | |
| document.body.appendChild(renderer.domElement); | |
| const scene=new THREE.Scene(); | |
| scene.background=new THREE.Color(0x8ab8d8); | |
| scene.fog=new THREE.Fog(0xa8c4d8,800,4600); | |
| const camera=new THREE.PerspectiveCamera(70,window.innerWidth/window.innerHeight,0.1,14000); | |
| camera.position.set(0,60,-120); | |
| const hemi=new THREE.HemisphereLight(0xcfe4ff,0x5c6b48,0.95);scene.add(hemi); | |
| const sun=new THREE.DirectionalLight(0xfff1d8,0.85);sun.position.set(600,900,300);scene.add(sun); | |
| // sun billboard | |
| function makeGlowTexture(inner,outer){ | |
| const cv=document.createElement('canvas');cv.width=cv.height=128; | |
| const g=cv.getContext('2d'); | |
| const gr=g.createRadialGradient(64,64,4,64,64,64); | |
| gr.addColorStop(0,inner);gr.addColorStop(1,outer); | |
| g.fillStyle=gr;g.fillRect(0,0,128,128); | |
| const t=new THREE.CanvasTexture(cv);return t; | |
| } | |
| const glowTex=makeGlowTexture('rgba(255,255,255,1)','rgba(255,255,255,0)'); | |
| const sunSpr=new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex,color:0xfff3cf,blending:THREE.AdditiveBlending,depthWrite:false,fog:false})); | |
| sunSpr.scale.set(900,900,1);sunSpr.position.set(5200,4200,2600);scene.add(sunSpr); | |
| /* ============================== terrain ============================== */ | |
| const TSIZE=8400,TSEG=176; | |
| (function buildTerrain(){ | |
| const geo=new THREE.PlaneGeometry(TSIZE,TSIZE,TSEG,TSEG); | |
| geo.rotateX(-Math.PI/2); | |
| const pos=geo.attributes.position; | |
| const colors=new Float32Array(pos.count*3); | |
| const col=new THREE.Color(); | |
| for(let i=0;i<pos.count;i++){ | |
| const x=pos.getX(i),z=pos.getZ(i); | |
| const h=heightAt(x,z); | |
| pos.setY(i,h); | |
| // slope estimate | |
| const s=Math.abs(heightAt(x+14,z)-h)/14+Math.abs(heightAt(x,z+14)-h)/14; | |
| const n=fbm(x*0.012+51.3,z*0.012+17.9); | |
| if(h<2.5){col.setRGB(0.72,0.66,0.47);} | |
| else if(h<9){col.setRGB(0.55,0.55,0.33);} | |
| else if(h<150){col.setRGB(0.30+n*0.10,0.40+n*0.08,0.16+n*0.05);} | |
| else if(h<235){col.setRGB(0.38+n*0.06,0.35+n*0.05,0.30);} | |
| else{col.setRGB(0.82,0.84,0.88);} | |
| if(s>0.62&&h>8){col.lerp(new THREE.Color(0.42,0.40,0.36),clamp((s-0.62)*2,0,0.85));} | |
| // patchy fields | |
| if(h>=9&&h<150&&n>0.62){col.lerp(new THREE.Color(0.55,0.48,0.22),0.45);} | |
| colors[i*3]=col.r;colors[i*3+1]=col.g;colors[i*3+2]=col.b; | |
| } | |
| geo.setAttribute('color',new THREE.BufferAttribute(colors,3)); | |
| geo.computeVertexNormals(); | |
| const mesh=new THREE.Mesh(geo,new THREE.MeshLambertMaterial({vertexColors:true})); | |
| scene.add(mesh); | |
| })(); | |
| // sea | |
| (function(){ | |
| const geo=new THREE.PlaneGeometry(36000,36000,1,1);geo.rotateX(-Math.PI/2); | |
| const sea=new THREE.Mesh(geo,new THREE.MeshPhongMaterial({color:0x1e4d6b,shininess:120,specular:0x99ccee,transparent:true,opacity:0.94})); | |
| sea.position.y=0.4;scene.add(sea); | |
| })(); | |
| // trees (instanced cones) | |
| (function(){ | |
| const N=850; | |
| const geo=new THREE.ConeGeometry(2.6,11,6); | |
| const mat=new THREE.MeshLambertMaterial({color:0x27451e}); | |
| const inst=new THREE.InstancedMesh(geo,mat,N); | |
| const dummy=new THREE.Object3D();let placed=0,guard=0; | |
| while(placed<N&&guard++<20000){ | |
| const x=rand(-3300,3300),z=rand(-3300,3300); | |
| const h=heightAt(x,z); | |
| if(h<7||h>150)continue; | |
| if(Math.hypot(x-AF.x,z-AF.z)<AF.r*1.1)continue; | |
| dummy.position.set(x,h+4.2,z); | |
| const s=rand(0.7,1.7);dummy.scale.set(s,s*rand(0.9,1.3),s); | |
| dummy.rotation.y=rand(0,TAU); | |
| dummy.updateMatrix();inst.setMatrixAt(placed++,dummy.matrix); | |
| } | |
| inst.count=placed;inst.instanceMatrix.needsUpdate=true; | |
| scene.add(inst); | |
| })(); | |
| // airfield: runway, hangars, tower, windsock | |
| (function(){ | |
| const g=new THREE.Group(); | |
| const runway=new THREE.Mesh(new THREE.BoxGeometry(22,0.5,260),new THREE.MeshLambertMaterial({color:0x3c3f43})); | |
| runway.position.set(AF.x,AF.h+0.25,AF.z);g.add(runway); | |
| const stripe=new THREE.Mesh(new THREE.BoxGeometry(1.2,0.55,240),new THREE.MeshLambertMaterial({color:0xb9bdc2})); | |
| stripe.position.set(AF.x,AF.h+0.28,AF.z);g.add(stripe); | |
| const hangMat=new THREE.MeshLambertMaterial({color:0x5c6248}); | |
| const roofMat=new THREE.MeshLambertMaterial({color:0x424736}); | |
| for(let i=0;i<3;i++){ | |
| const hg=new THREE.Group(); | |
| const base=new THREE.Mesh(new THREE.BoxGeometry(16,6,13),hangMat);base.position.y=3;hg.add(base); | |
| const roof=new THREE.Mesh(new THREE.CylinderGeometry(6.6,6.6,16,10),roofMat); | |
| roof.rotation.z=Math.PI/2;roof.position.y=5.2;hg.add(roof); | |
| hg.position.set(AF.x-34,AF.h,AF.z-70+i*55);g.add(hg); | |
| } | |
| const tower=new THREE.Mesh(new THREE.BoxGeometry(5,14,5),new THREE.MeshLambertMaterial({color:0x74644c})); | |
| tower.position.set(AF.x+30,AF.h+7,AF.z+60);g.add(tower); | |
| const cab=new THREE.Mesh(new THREE.BoxGeometry(7,4,7),new THREE.MeshLambertMaterial({color:0x8a7a5c})); | |
| cab.position.set(AF.x+30,AF.h+16,AF.z+60);g.add(cab); | |
| const sock=new THREE.Mesh(new THREE.ConeGeometry(1.1,5,8),new THREE.MeshLambertMaterial({color:0xd8622a})); | |
| sock.rotation.z=Math.PI/2;sock.position.set(AF.x+14,AF.h+8,AF.z-40);g.add(sock); | |
| const pole=new THREE.Mesh(new THREE.CylinderGeometry(0.15,0.15,8,6),new THREE.MeshLambertMaterial({color:0x999999})); | |
| pole.position.set(AF.x+11,AF.h+4,AF.z-40);g.add(pole); | |
| scene.add(g); | |
| })(); | |
| // clouds | |
| const clouds=[]; | |
| (function(){ | |
| const mat=new THREE.MeshLambertMaterial({color:0xffffff,transparent:true,opacity:0.82,depthWrite:false}); | |
| for(let i=0;i<42;i++){ | |
| const grp=new THREE.Group(); | |
| const n=3+Math.floor(rand(0,4)); | |
| for(let j=0;j<n;j++){ | |
| const r=rand(14,40); | |
| const s=new THREE.Mesh(new THREE.SphereGeometry(r,7,6),mat); | |
| s.position.set(rand(-50,50),rand(-8,8),rand(-28,28)); | |
| s.scale.y=0.55;grp.add(s); | |
| } | |
| grp.position.set(rand(-3400,3400),rand(330,640),rand(-3400,3400)); | |
| scene.add(grp); | |
| clouds.push({g:grp,v:rand(2,6)}); | |
| } | |
| })(); | |
| /* ============================== particles ============================== */ | |
| const PARTICLE_TEX=glowTex; | |
| class Particles{ | |
| constructor(n){ | |
| this.pool=[]; | |
| for(let i=0;i<n;i++){ | |
| const m=new THREE.SpriteMaterial({map:PARTICLE_TEX,color:0xffffff,transparent:true,depthWrite:false}); | |
| const s=new THREE.Sprite(m);s.visible=false;scene.add(s); | |
| this.pool.push({s:s,life:0,max:1,vel:V3(),grow:0,drag:1,grav:0,o0:1}); | |
| } | |
| } | |
| spawn(o){ | |
| for(const p of this.pool){ | |
| if(p.life>0)continue; | |
| p.s.visible=true;p.s.position.copy(o.pos); | |
| p.vel.copy(o.vel||tmpV1.set(0,0,0)); | |
| p.life=p.max=o.life||1; | |
| p.grow=o.grow||0;p.drag=o.drag!==undefined?o.drag:1;p.grav=o.grav||0; | |
| p.o0=o.opacity!==undefined?o.opacity:0.9; | |
| p.s.material.color.setHex(o.color); | |
| p.s.material.blending=o.add?THREE.AdditiveBlending:THREE.NormalBlending; | |
| p.s.material.opacity=p.o0; | |
| const sc=o.size||2;p.s.scale.set(sc,sc,1); | |
| p.size0=sc; | |
| return p; | |
| } | |
| return null; | |
| } | |
| update(dt){ | |
| for(const p of this.pool){ | |
| if(p.life<=0)continue; | |
| p.life-=dt; | |
| if(p.life<=0){p.s.visible=false;continue;} | |
| p.vel.multiplyScalar(Math.pow(p.drag,dt*60)); | |
| p.vel.y+=p.grav*dt; | |
| p.s.position.addScaledVector(p.vel,dt); | |
| const t=p.life/p.max; | |
| p.s.material.opacity=p.o0*t; | |
| const sc=p.size0+p.grow*(1-t); | |
| p.s.scale.set(sc,sc,1); | |
| } | |
| } | |
| } | |
| const particles=new Particles(420); | |
| function explosion(pos,scale){ | |
| scale=scale||1; | |
| for(let i=0;i<16;i++){ | |
| particles.spawn({pos:pos,vel:V3(rand(-1,1),rand(-0.4,1),rand(-1,1)).multiplyScalar(rand(8,26)*scale), | |
| life:rand(0.3,0.75),color:i%3?0xff7a22:0xffd277,size:rand(4,9)*scale,grow:20*scale,add:true,drag:0.92,opacity:1}); | |
| } | |
| for(let i=0;i<12;i++){ | |
| particles.spawn({pos:pos,vel:V3(rand(-1,1),rand(0.2,1.4),rand(-1,1)).multiplyScalar(rand(4,12)*scale), | |
| life:rand(1.0,2.2),color:0x2a2a2a,size:rand(6,10)*scale,grow:16*scale,drag:0.94,grav:2,opacity:0.75}); | |
| } | |
| particles.spawn({pos:pos,vel:V3(0,0,0),life:0.16,color:0xfff2cc,size:30*scale,grow:130*scale,add:true,opacity:1}); | |
| AudioSys.boom(camera.position.distanceTo(pos)); | |
| } | |
| function smokePuff(pos,vel,dark,size,life){ | |
| particles.spawn({pos:pos,vel:vel,life:life||rand(0.8,1.6),color:dark?0x1c1c1c:0x555555, | |
| size:size||rand(1.6,2.6),grow:rand(4,7),drag:0.96,grav:rand(1,3),opacity:dark?0.8:0.55}); | |
| } | |
| /* ============================== biplane model ============================== */ | |
| function buildBiplane(c){ | |
| const g=new THREE.Group(); | |
| const fusMat=new THREE.MeshLambertMaterial({color:c.fus}); | |
| const wingMat=new THREE.MeshLambertMaterial({color:c.wing}); | |
| const darkMat=new THREE.MeshLambertMaterial({color:0x232323}); | |
| const woodMat=new THREE.MeshLambertMaterial({color:0x6b4a26}); | |
| const fus=new THREE.Mesh(new THREE.CylinderGeometry(0.34,0.15,5.4,10),fusMat); | |
| fus.rotation.x=Math.PI/2;g.add(fus); | |
| const cowl=new THREE.Mesh(new THREE.CylinderGeometry(0.40,0.34,0.8,10),darkMat); | |
| cowl.rotation.x=Math.PI/2;cowl.position.z=2.7;g.add(cowl); | |
| const hump=new THREE.Mesh(new THREE.SphereGeometry(0.32,8,6),fusMat); | |
| hump.position.set(0,0.30,-0.3);g.add(hump); | |
| const uw=new THREE.Mesh(new THREE.BoxGeometry(8.6,0.1,1.4),wingMat);uw.position.set(0,1.18,0.55);g.add(uw); | |
| const lw=new THREE.Mesh(new THREE.BoxGeometry(7.0,0.1,1.3),wingMat);lw.position.set(0,-0.30,0.55);g.add(lw); | |
| const strutGeo=new THREE.CylinderGeometry(0.045,0.045,1.5,5); | |
| for(const sx of[-2.7,2.7])for(const sz of[0.1,1.0]){ | |
| const st=new THREE.Mesh(strutGeo,darkMat);st.position.set(sx,0.44,sz);g.add(st); | |
| } | |
| for(const sx of[-0.42,0.42]){ | |
| const st=new THREE.Mesh(new THREE.CylinderGeometry(0.045,0.045,0.95,5),darkMat); | |
| st.position.set(sx,0.5,0.6);g.add(st); | |
| } | |
| const hs=new THREE.Mesh(new THREE.BoxGeometry(2.7,0.08,0.95),wingMat);hs.position.set(0,0.06,-2.55);g.add(hs); | |
| const vs=new THREE.Mesh(new THREE.BoxGeometry(0.09,1.15,0.95),wingMat);vs.position.set(0,0.5,-2.55);g.add(vs); | |
| const accent=new THREE.Mesh(new THREE.BoxGeometry(0.11,0.5,0.97),new THREE.MeshLambertMaterial({color:c.accent})); | |
| accent.position.set(0,0.62,-2.55);g.add(accent); | |
| for(const sx of[-0.17,0.17]){ | |
| const gun=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.05,1.2,6),darkMat); | |
| gun.rotation.x=Math.PI/2;gun.position.set(sx,0.44,1.7);g.add(gun); | |
| } | |
| const prop=new THREE.Group(); | |
| const b1=new THREE.Mesh(new THREE.BoxGeometry(0.20,2.5,0.07),woodMat);prop.add(b1); | |
| const b2=b1.clone();b2.rotation.z=Math.PI/2;prop.add(b2); | |
| const hub=new THREE.Mesh(new THREE.SphereGeometry(0.13,6,6),darkMat);prop.add(hub); | |
| prop.position.z=3.15;g.add(prop); | |
| for(const sx of[-0.85,0.85]){ | |
| const leg=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.05,1.05,5),darkMat); | |
| leg.position.set(sx,-0.75,1.25);leg.rotation.z=sx>0?0.42:-0.42;g.add(leg); | |
| const wheel=new THREE.Mesh(new THREE.CylinderGeometry(0.34,0.34,0.2,10),darkMat); | |
| wheel.rotation.z=Math.PI/2;wheel.position.set(sx*1.45,-1.22,1.25);g.add(wheel); | |
| } | |
| const skid=new THREE.Mesh(new THREE.BoxGeometry(0.08,0.3,0.5),woodMat); | |
| skid.position.set(0,-0.25,-2.6);g.add(skid); | |
| g.userData.prop=prop; | |
| return g; | |
| } | |
| /* ============================== bullets ============================== */ | |
| const bullets=[]; | |
| (function(){ | |
| const geo=new THREE.BoxGeometry(0.14,0.14,3.4); | |
| for(let i=0;i<160;i++){ | |
| const mat=new THREE.MeshBasicMaterial({color:0xffe28a,blending:THREE.AdditiveBlending,transparent:true,opacity:0.95,fog:false}); | |
| const m=new THREE.Mesh(geo,mat);m.visible=false;scene.add(m); | |
| bullets.push({mesh:m,active:false,vel:V3(),life:0,dmg:0,from:null}); | |
| } | |
| })(); | |
| function fireBullet(from,origin,dir,speed,dmg,color){ | |
| for(const b of bullets){ | |
| if(b.active)continue; | |
| b.active=true;b.mesh.visible=true; | |
| b.mesh.position.copy(origin); | |
| b.vel.copy(dir).multiplyScalar(speed).addScaledVector(from.vel,0.85); | |
| b.life=1.35;b.dmg=dmg;b.from=from; | |
| b.mesh.material.color.setHex(color); | |
| b.mesh.lookAt(tmpV1.copy(origin).add(b.vel)); | |
| return; | |
| } | |
| } | |
| function updateBullets(dt){ | |
| for(const b of bullets){ | |
| if(!b.active)continue; | |
| b.life-=dt; | |
| if(b.life<=0){b.active=false;b.mesh.visible=false;continue;} | |
| b.mesh.position.addScaledVector(b.vel,dt); | |
| const p=b.mesh.position; | |
| if(p.y<heightAt(p.x,p.z)+0.3){ | |
| b.active=false;b.mesh.visible=false; | |
| particles.spawn({pos:p,life:0.4,color:0x9a8a6a,size:1.6,grow:5,vel:V3(0,3,0),opacity:0.8}); | |
| continue; | |
| } | |
| if(b.from===player){ | |
| let hit=false; | |
| for(const e of enemies){ | |
| if(!e.alive)continue; | |
| if(p.distanceToSquared(e.pos)<20){ | |
| e.damage(b.dmg,player);hitMark();AudioSys.hitTick();hit=true;break; | |
| } | |
| } | |
| if(!hit)for(const bl of balloons){ | |
| if(!bl.alive)continue; | |
| if(p.distanceToSquared(bl.pos)<58){ | |
| bl.damage(b.dmg);hitMark();AudioSys.hitTick();hit=true;break; | |
| } | |
| } | |
| if(hit){b.active=false;b.mesh.visible=false;} | |
| }else if(b.from&&b.from!==player){ | |
| if(player.alive&&p.distanceToSquared(player.pos)<16){ | |
| player.damage(b.dmg,b.from); | |
| b.active=false;b.mesh.visible=false; | |
| } | |
| } | |
| } | |
| } | |
| /* ============================== plane (shared physics) ============================== */ | |
| const GRAV=9.8,THRUST=17.5,LIFT=0.0085,STALL=21,GROUND_H=1.55; | |
| class Plane{ | |
| constructor(colors,isPlayer){ | |
| this.mesh=buildBiplane(colors); | |
| scene.add(this.mesh); | |
| this.pos=this.mesh.position; | |
| this.q=this.mesh.quaternion; | |
| this.vel=V3(); | |
| this.isPlayer=!!isPlayer; | |
| this.throttle=0;this.pitchIn=0;this.rollIn=0;this.yawIn=0; | |
| this.hp=100;this.maxHp=100; | |
| this.alive=true;this.wreck=false;this.removeMe=false; | |
| this.onGround=false; | |
| this.gunCd=0;this.gunSide=1;this.smokeAcc=0;this.fireAcc=0; | |
| this.lastHitBy=null;this.lastHitT=-99; | |
| this.spinRate=0;this.wreckVy=0; | |
| this.speed=0;this.fwdSpeed=0; | |
| this.scoreValue=100; | |
| } | |
| fwd(out){return (out||tmpV2).set(0,0,1).applyQuaternion(this.q);} | |
| up(out){return (out||tmpV3).set(0,1,0).applyQuaternion(this.q);} | |
| right(out){return (out||tmpV4).set(1,0,0).applyQuaternion(this.q);} | |
| place(x,y,z,heading){ | |
| this.pos.set(x,y,z); | |
| this.mesh.rotation.set(0,heading,0); | |
| this.vel.set(0,0,0); | |
| } | |
| damage(n,from){ | |
| if(!this.alive)return; | |
| this.hp-=n; | |
| this.lastHitBy=from;this.lastHitT=nowT; | |
| if(this.isPlayer){shake=Math.min(0.9,shake+0.3);vignHit=Math.min(1,vignHit+0.45);hitTimer=0;} | |
| if(this.hp<=0){this.hp=0;this.kill(from);} | |
| } | |
| kill(from){ | |
| if(!this.alive)return; | |
| this.alive=false;this.wreck=true; | |
| this.throttle=0; | |
| this.spinRate=rand(2,4.5)*(Math.random()<0.5?-1:1); | |
| if(this.isPlayer){ | |
| onPlayerDown(from); | |
| }else{ | |
| const credit=from===player||(this.lastHitBy===player&&nowT-this.lastHitT<8); | |
| if(credit){addScore(this.scoreValue,this.isAce?'ACE DOWN! +'+this.scoreValue:'BANDIT DOWN! +'+this.scoreValue);} | |
| else showMsg('BANDIT CRASHED'); | |
| } | |
| } | |
| updatePhysics(dt){ | |
| if(this.removeMe)return; | |
| const fwd=this.fwd(V3._a||(V3._a=V3())); | |
| const up=this.up(V3._b||(V3._b=V3())); | |
| const right=this.right(V3._c||(V3._c=V3())); | |
| this.speed=this.vel.length(); | |
| this.fwdSpeed=this.vel.dot(fwd); | |
| if(this.wreck){ | |
| // ballistic fall with spin | |
| this.vel.y-=GRAV*dt; | |
| this.vel.multiplyScalar(Math.pow(0.995,dt*60)); | |
| this.pos.addScaledVector(this.vel,dt); | |
| tmpQ.setFromEuler(new THREE.Euler(0.4*dt,0.3*dt,this.spinRate*dt)); | |
| this.q.multiply(tmpQ); | |
| this.fireAcc+=dt; | |
| if(this.fireAcc>0.05){ | |
| this.fireAcc=0; | |
| particles.spawn({pos:this.pos,life:0.5,color:0xff7722,size:3,grow:8,add:true,vel:V3(rand(-2,2),rand(-1,2),rand(-2,2))}); | |
| smokePuff(this.pos,V3(rand(-2,2),rand(2,5),rand(-2,2)),true,3,1.8); | |
| } | |
| const h=heightAt(this.pos.x,this.pos.z); | |
| if(this.pos.y<h+1||this.pos.y<0.6){ | |
| explosion(this.pos,1.4); | |
| scene.remove(this.mesh);this.removeMe=true;this.wreck=false; | |
| } | |
| return; | |
| } | |
| // --- rotation --- | |
| const auth=clamp(this.speed/24,0,1); | |
| const pitchRate=this.pitchIn*1.55*(0.30+0.70*auth); | |
| const rollRate=this.rollIn*2.7*(0.25+0.75*auth); | |
| let yawRate=this.yawIn*0.6; | |
| yawRate+=-right.y*1.25*auth; // coordinated banked turn | |
| let stallPitch=0; | |
| if(!this.onGround&&this.fwdSpeed<STALL&&this.fwdSpeed>=0){ | |
| stallPitch=(STALL-this.fwdSpeed)/STALL*0.85*up.y>=0?(STALL-this.fwdSpeed)/STALL*0.7:0; | |
| if(this.isPlayer&&this.fwdSpeed<STALL*0.9)stallWarn=true; | |
| } | |
| tmpQ.setFromEuler(new THREE.Euler( | |
| (-pitchRate+stallPitch*0.9)*dt, | |
| yawRate*dt, | |
| -rollRate*dt | |
| )); | |
| this.q.multiply(tmpQ); | |
| // --- forces --- | |
| const thrustAcc=this.throttle*THRUST; | |
| const dragAcc=0.045*this.speed+0.0035*this.speed*this.speed; | |
| let liftAcc=LIFT*this.fwdSpeed*Math.abs(this.fwdSpeed); | |
| if(this.fwdSpeed<STALL)liftAcc*=Math.max(0,this.fwdSpeed/STALL); | |
| liftAcc=Math.min(liftAcc,28); | |
| const acc=tmpV1.set(0,-GRAV,0) | |
| .addScaledVector(fwd,thrustAcc) | |
| .addScaledVector(up,liftAcc); | |
| if(this.speed>0.01)acc.addScaledVector(tmpV2.copy(this.vel).normalize(),-dragAcc); | |
| this.vel.addScaledVector(acc,dt); | |
| // velocity aligns to nose (weathervane) — this is what makes turns feel right | |
| let alignK=2.7*clamp(this.speed/26,0,1); | |
| if(this.fwdSpeed<STALL)alignK*=clamp(this.fwdSpeed/STALL,0.12,1); | |
| const sp=this.vel.length(); | |
| this.vel.lerp(tmpV2.copy(fwd).multiplyScalar(sp),clamp(alignK*dt,0,1)); | |
| if(this.onGround){ | |
| // ground roll | |
| const h=heightAt(this.pos.x,this.pos.z); | |
| this.pos.y=h+GROUND_H; | |
| this.vel.y=0; | |
| // level out | |
| const heading=Math.atan2(fwd.x,fwd.z); | |
| tmpQ.setFromEuler(new THREE.Euler(0,heading,0)); | |
| this.q.slerp(tmpQ,clamp(3.5*dt,0,1)); | |
| this.vel.multiplyScalar(Math.pow(0.9985,dt*60)); // rolling resistance | |
| if(this.speed>27&&this.pitchIn>0.35){ | |
| this.onGround=false;this.vel.y=2.5; | |
| if(this.isPlayer)showSub(''); | |
| } | |
| this.pos.addScaledVector(this.vel,dt); | |
| this.pos.y=heightAt(this.pos.x,this.pos.z)+GROUND_H; | |
| }else{ | |
| this.pos.addScaledVector(this.vel,dt); | |
| // terrain contact | |
| const h=heightAt(this.pos.x,this.pos.z); | |
| if(this.pos.y<h+GROUND_H-0.3){ | |
| const sink=-this.vel.y; | |
| const level=up.y; | |
| if(this.isPlayer&&sink<5.5&&level>0.90&&this.speed<48&&h>1){ | |
| // soft landing! | |
| this.onGround=true;this.pos.y=h+GROUND_H;this.vel.y=0; | |
| showMsg('TOUCHDOWN');setTimeout(()=>{},0); | |
| }else{ | |
| this.pos.y=h+1.0; | |
| this.crash(); | |
| return; | |
| } | |
| } | |
| if(this.pos.y<0.8){this.pos.y=0.8;this.crash();return;} | |
| } | |
| // damage smoke | |
| if(this.alive&&this.hp<this.maxHp*0.55){ | |
| this.smokeAcc+=dt; | |
| const rate=this.hp<this.maxHp*0.28?0.03:0.07; | |
| if(this.smokeAcc>rate){ | |
| this.smokeAcc=0; | |
| smokePuff(tmpV1.copy(this.pos).addScaledVector(fwd,-2),V3(rand(-1,1),rand(0,2),rand(-1,1)).addScaledVector(this.vel,0.25),this.hp<this.maxHp*0.28,2,1.4); | |
| if(this.hp<this.maxHp*0.28&&Math.random()<0.4) | |
| particles.spawn({pos:this.pos,life:0.3,color:0xff8830,size:1.8,grow:4,add:true,vel:V3(rand(-2,2),rand(0,2),rand(-2,2))}); | |
| } | |
| } | |
| } | |
| crash(){ | |
| if(!this.alive)return; | |
| explosion(this.pos,1.5); | |
| this.alive=false;this.wreck=false; | |
| scene.remove(this.mesh);this.removeMe=true; | |
| if(this.isPlayer)onPlayerDown(null,true); | |
| else{ | |
| const credit=this.lastHitBy===player&&nowT-this.lastHitT<8; | |
| if(credit)addScore(this.scoreValue,'BANDIT DOWN! +'+this.scoreValue); | |
| else showMsg('BANDIT CRASHED'); | |
| } | |
| } | |
| fireGuns(targetDir){ | |
| const fwd=targetDir||this.fwd(tmpV2); | |
| const right=this.right(tmpV3); | |
| const up=this.up(tmpV4); | |
| this.gunSide*=-1; | |
| const origin=V3._d||(V3._d=V3()); | |
| origin.copy(this.pos).addScaledVector(right,0.17*this.gunSide).addScaledVector(up,0.44).addScaledVector(fwd,2.6); | |
| const spread=this.isPlayer?0.006:0.014; | |
| const dir=V3._e||(V3._e=V3()); | |
| dir.copy(fwd); | |
| dir.x+=rand(-spread,spread);dir.y+=rand(-spread,spread);dir.z+=rand(-spread,spread); | |
| dir.normalize(); | |
| fireBullet(this,origin,dir,this.isPlayer?265:235,this.isPlayer?9:3.4+wave*0.25,this.isPlayer?0xffe28a:0xff6a4a); | |
| particles.spawn({pos:origin,life:0.06,color:0xffd277,size:1.6,grow:6,add:true,vel:V3(0,0,0)}); | |
| if(this.isPlayer)AudioSys.gun(); | |
| else AudioSys.gunFar(camera.position.distanceTo(this.pos)); | |
| } | |
| } | |
| /* ============================== observation balloons ============================== */ | |
| const balloons=[]; | |
| function spawnBalloons(){ | |
| for(const b of balloons){scene.remove(b.mesh);} | |
| balloons.length=0; | |
| for(let i=0;i<3;i++){ | |
| let x,z,tries=0; | |
| do{x=rand(-2600,2600);z=rand(-2600,2600);tries++;} | |
| while(tries<40&&(heightAt(x,z)<5||Math.hypot(x-AF.x,z-AF.z)<500)); | |
| const y=heightAt(x,z)+rand(170,230); | |
| const g=new THREE.Group(); | |
| const env=new THREE.Mesh(new THREE.SphereGeometry(6,12,10),new THREE.MeshLambertMaterial({color:0xb8b0a0})); | |
| env.scale.set(1,1.25,1);g.add(env); | |
| const band=new THREE.Mesh(new THREE.SphereGeometry(6.06,12,3,0,TAU,Math.PI*0.42,Math.PI*0.16),new THREE.MeshLambertMaterial({color:0x8c3a2a})); | |
| g.add(band); | |
| const basket=new THREE.Mesh(new THREE.BoxGeometry(1.6,1.4,1.6),new THREE.MeshLambertMaterial({color:0x5c4a30})); | |
| basket.position.y=-10;g.add(basket); | |
| const rope=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.05,3.4,4),new THREE.MeshLambertMaterial({color:0x333333})); | |
| rope.position.y=-8;g.add(rope); | |
| g.position.set(x,y,z); | |
| scene.add(g); | |
| const b={mesh:g,pos:g.position,hp:30,alive:true,done:false,vy:0}; | |
| b.damage=function(n){ | |
| if(!b.alive)return; | |
| b.hp-=n; | |
| if(b.hp<=0){b.alive=false;b.vy=-6;addScore(50,'BALLOON DESTROYED! +50');} | |
| }; | |
| balloons.push(b); | |
| } | |
| } | |
| function updateBalloons(dt){ | |
| for(const b of balloons){ | |
| if(b.alive||b.done)continue; | |
| b.vy=Math.max(b.vy-14*dt,-22); | |
| b.pos.y+=b.vy*dt; | |
| if(Math.random()<0.35) | |
| particles.spawn({pos:tmpV1.copy(b.pos).add(V3(rand(-4,4),rand(-4,4),rand(-4,4))),life:0.5,color:0xff7722,size:3,grow:6,add:true,vel:V3(0,4,0)}); | |
| if(Math.random()<0.25)smokePuff(b.pos,V3(0,6,0),true,4,1.6); | |
| if(b.pos.y<heightAt(b.pos.x,b.pos.z)+4){ | |
| explosion(b.pos,1.6); | |
| scene.remove(b.mesh);b.done=true; | |
| } | |
| } | |
| } | |
| /* ============================== enemy AI ============================== */ | |
| const enemies=[]; | |
| class Enemy extends Plane{ | |
| constructor(ace){ | |
| super(ace?{fus:0x7a1a12,wing:0x3a3a3a,accent:0xe8c33c}:{fus:0x5a6148,wing:0x49523c,accent:0x8a2a1a},false); | |
| this.isAce=!!ace; | |
| this.maxHp=this.hp=ace?95:52+wave*5; | |
| this.scoreValue=ace?250:100; | |
| this.evadeT=0;this.thinkAcc=rand(0,0.3); | |
| this.skill=ace?1.5:lerp(0.75,1.15,Math.random())*(1+wave*0.05); | |
| } | |
| think(dt){ | |
| if(!this.alive)return; | |
| this.thinkAcc-=dt; | |
| const p=player; | |
| // terrain avoidance (checked every frame — cheap) | |
| const fwd=this.fwd(tmpV2); | |
| tmpV1.copy(this.pos).addScaledVector(this.vel,2.6); | |
| const hAhead=heightAt(tmpV1.x,tmpV1.z); | |
| const hHere=heightAt(this.pos.x,this.pos.z); | |
| if(tmpV1.y<hAhead+38||this.pos.y<hHere+20){ | |
| this.pitchIn=1;this.throttle=1; | |
| this.rollIn=clamp(this.right(tmpV3).y*3,-1,1); // level wings | |
| this.yawIn=0; | |
| return; | |
| } | |
| if(this.thinkAcc<=0){ | |
| this.thinkAcc=0.12; | |
| if(!p.alive){this.mode='patrol';} | |
| else if(this.hp<this.maxHp*0.3&&Math.random()<0.01)this.evadeT=rand(2.5,4); | |
| if(this.evadeT>0)this.mode='evade'; | |
| else this.mode='chase'; | |
| } | |
| this.evadeT-=dt; | |
| let aim; | |
| if(this.mode==='evade'||!p.alive){ | |
| if(!this._ev||this.thinkAcc===0.12&&Math.random()<0.2){ | |
| this._ev=V3(this.pos.x-p.pos.x,0,this.pos.z-p.pos.z).normalize() | |
| .multiplyScalar(400).add(V3(rand(-200,200),0,rand(-200,200))); | |
| this._ev.y=Math.max(heightAt(this._ev.x,this._ev.z)+120,this.pos.y+rand(-30,60)); | |
| if(!p.alive)this._ev.set(AF.x+rand(-800,800),rand(200,350),AF.z+rand(-800,800)); | |
| } | |
| aim=this._ev; | |
| }else{ | |
| aim=tmpV3.copy(p.pos).addScaledVector(p.vel,clamp(this.pos.distanceTo(p.pos)/320,0,1)*0.55); | |
| } | |
| // desired direction in local space | |
| tmpV1.copy(aim).sub(this.pos).normalize(); | |
| tmpQ.copy(this.q).invert(); | |
| tmpV1.applyQuaternion(tmpQ); // +Z fwd local | |
| const lx=tmpV1.x,ly=tmpV1.y,lz=tmpV1.z; | |
| const aggr=this.skill; | |
| this.yawIn=clamp(lx*1.4*aggr,-1,1); | |
| this.rollIn=clamp(-lx*2.1*aggr,-1,1); | |
| if(lz<0){ // target behind: hard reverser | |
| this.rollIn=lx>=0?-1:1; | |
| this.pitchIn=clamp(Math.abs(ly)>0.25?ly*2.5:0.85,-1,1); | |
| }else{ | |
| this.pitchIn=clamp(ly*2.6*aggr,-1,1); | |
| } | |
| // separation from other bandits | |
| for(const o of enemies){ | |
| if(o===this||!o.alive)continue; | |
| const d2=this.pos.distanceToSquared(o.pos); | |
| if(d2<900&&d2>0.01){ | |
| const side=this.right(tmpV4).dot(tmpV1.copy(o.pos).sub(this.pos).normalize()); | |
| this.rollIn=clamp(this.rollIn+(side>0?0.6:-0.6),-1,1); | |
| this.pitchIn=clamp(this.pitchIn-0.25,-1,1); | |
| break; | |
| } | |
| } | |
| const dist=this.pos.distanceTo(p.pos); | |
| this.throttle=dist>160?1:0.6; | |
| // fire solution | |
| if(p.alive&&this.mode==='chase'&&dist<400){ | |
| const t=dist/235; | |
| tmpV1.copy(p.pos).addScaledVector(p.vel,t).sub(this.pos).normalize(); | |
| const cosA=tmpV1.dot(this.fwd(tmpV2)); | |
| this.gunCd-=dt; | |
| if(cosA>0.988&&this.gunCd<=0){ | |
| this.fireGuns(tmpV1); | |
| this.gunCd=this.isAce?0.10:0.15+rand(0,0.1); | |
| } | |
| } | |
| } | |
| } | |
| function spawnWave(){ | |
| wave++; | |
| const n=Math.min(2+wave,7); | |
| for(let i=0;i<n;i++){ | |
| const ace=wave>=3&&Math.random()<0.22; | |
| const e=new Enemy(ace); | |
| const ang=rand(0,TAU),r=rand(900,1500); | |
| const x=player.pos.x+Math.cos(ang)*r,z=player.pos.z+Math.sin(ang)*r; | |
| const y=Math.max(heightAt(x,z)+160,rand(230,420)); | |
| e.place(x,y,z,Math.atan2(player.pos.x-x,player.pos.z-z)); | |
| e.vel.copy(e.fwd(V3())).multiplyScalar(34); | |
| e.throttle=1; | |
| enemies.push(e); | |
| } | |
| waveClearT=0; | |
| spawnBalloons(); | |
| showMsg('WAVE '+wave+' — '+(n)+' BANDITS INBOUND'); | |
| waveActive=true; | |
| } | |
| /* ============================== game state ============================== */ | |
| let state='menu',wave=0,score=0,nowT=0,waveActive=false,waveClearT=0; | |
| let shake=0,vignHit=0,hitTimer=99,stallWarn=false,seaMsgT=0; | |
| const clock=new THREE.Clock(); | |
| const player=new Plane({fus:0x8f1f1f,wing:0xd8c9a3,accent:0x2a2a2a},true); | |
| player.mesh.visible=false; | |
| function resetPlayer(airborne){ | |
| player.alive=true;player.wreck=false;player.removeMe=false;player.onGround=!airborne; | |
| player.hp=player.maxHp=100; | |
| player.throttle=airborne?0.85:0; | |
| player.pitchIn=player.rollIn=player.yawIn=0; | |
| if(!player.mesh.parent)scene.add(player.mesh); | |
| player.mesh.visible=true; | |
| if(airborne){ | |
| player.place(AF.x,AF.h+150,AF.z-500,0); | |
| player.vel.set(0,0,34); | |
| }else{ | |
| player.place(AF.x,AF.h+1.32,AF.z-105,0); | |
| player.vel.set(0,0,0); | |
| showSub('THROTTLE UP (W) — ROTATE AT 95 KM/H'); | |
| } | |
| } | |
| function clearWorld(){ | |
| for(const e of enemies){scene.remove(e.mesh);} | |
| enemies.length=0; | |
| for(const b of bullets){b.active=false;b.mesh.visible=false;} | |
| for(const p of particles.pool){p.life=0;p.s.visible=false;} | |
| } | |
| function startGame(airborne){ | |
| clearWorld(); | |
| score=0;wave=0;waveActive=false;waveClearT=0; | |
| updateScore(); | |
| resetPlayer(!!airborne); | |
| state='play'; | |
| document.getElementById('overlay').style.display='none'; | |
| document.getElementById('wave').textContent='–'; | |
| document.getElementById('foes').textContent='0'; | |
| showMsg('PATROL THE FRONT'); | |
| setTimeout(()=>{if(state==='play'&&!waveActive)spawnWave();},2600); | |
| } | |
| function onPlayerDown(from,crashed){ | |
| explosion(player.pos,1.6); | |
| shake=1.2; | |
| showMsg(crashed?'CRASHED!':'SHOT DOWN!'); | |
| setTimeout(()=>{ | |
| if(state!=='play')return; | |
| state='over'; | |
| const ov=document.getElementById('overlay'); | |
| ov.style.display='flex'; | |
| document.querySelector('#overlay h1').textContent='SHOT DOWN'; | |
| document.querySelector('#overlay h2').textContent=crashed?'· AUGERED IN ·':'· '+('WAVE '+wave)+' ·'; | |
| document.getElementById('gobtn').textContent='FLY AGAIN (R)'; | |
| document.getElementById('finalscore').textContent='FINAL SCORE: '+score+' · WAVE '+wave; | |
| },1500); | |
| } | |
| function addScore(n,msg){ | |
| score+=n;updateScore(); | |
| if(msg)showMsg(msg); | |
| } | |
| function updateScore(){document.getElementById('score').textContent=score;} | |
| /* ============================== input ============================== */ | |
| const keys={}; | |
| window.addEventListener('keydown',e=>{ | |
| if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code))e.preventDefault(); | |
| keys[e.code]=true; | |
| if(e.repeat)return; | |
| if(e.code==='KeyC')cockpit=!cockpit; | |
| if(e.code==='KeyP'&&state==='play')paused=!paused,document.getElementById('paused').style.display=paused?'block':'none'; | |
| if(e.code==='KeyR'&&(state==='over'||state==='play')){AudioSys.init();startGame(false);} | |
| }); | |
| window.addEventListener('keyup',e=>{keys[e.code]=false;}); | |
| let cockpit=false,paused=false; | |
| function readInput(dt){ | |
| if(!player.alive)return; | |
| player.pitchIn=(keys['ArrowDown']?1:0)-(keys['ArrowUp']?1:0); | |
| player.rollIn=(keys['ArrowRight']?1:0)-(keys['ArrowLeft']?1:0); | |
| player.yawIn=(keys['KeyD']?1:0)-(keys['KeyA']?1:0); | |
| const rate=keys['ShiftLeft']||keys['ShiftRight']?3:0.55; | |
| if(keys['KeyW']||keys['ShiftLeft']||keys['ShiftRight'])player.throttle=clamp(player.throttle+rate*dt,0,1); | |
| if(keys['KeyS']||keys['ControlLeft']||keys['ControlRight'])player.throttle=clamp(player.throttle-rate*dt,0,1); | |
| if(keys['Space']&&player.alive){ | |
| player.gunCd-=dt; | |
| if(player.gunCd<=0){player.fireGuns();player.gunCd=0.085;} | |
| }else player.gunCd=Math.min(player.gunCd,0.02); | |
| } | |
| /* ============================== HUD ============================== */ | |
| const el={ | |
| spd:document.getElementById('spd'),alt:document.getElementById('alt'), | |
| thr:document.getElementById('thr'),hp:document.getElementById('hp'), | |
| eng:document.getElementById('eng'), | |
| msg:document.getElementById('msg'),sub:document.getElementById('sub'), | |
| wave:document.getElementById('wave'),foes:document.getElementById('foes'), | |
| vign:document.getElementById('vignette'),hit:document.getElementById('hitmark'), | |
| lead:document.getElementById('lead'),clk:document.getElementById('clk'), | |
| radar:document.getElementById('radar').getContext('2d'), | |
| }; | |
| let msgT=0,subT=0; | |
| function showMsg(t){el.msg.textContent=t;el.msg.style.opacity=1;msgT=2.4;} | |
| function showSub(t){el.sub.textContent=t;el.sub.style.opacity=t?1:0;subT=t?5:0;} | |
| function hitMark(){el.hit.style.opacity=1;setTimeout(()=>el.hit.style.opacity=0,90);} | |
| const markerPool=[]; | |
| (function(){ | |
| const c=document.getElementById('markers'); | |
| for(let i=0;i<10;i++){ | |
| const d=document.createElement('div');d.className='emark'; | |
| d.innerHTML='◆<br><span class="d"></span>'; | |
| c.appendChild(d);markerPool.push(d); | |
| } | |
| })(); | |
| function updateMarkers(){ | |
| let i=0; | |
| for(const e of enemies){ | |
| if(!e.alive||i>=markerPool.length)continue; | |
| const d=player.pos.distanceTo(e.pos); | |
| if(d>2200)continue; | |
| tmpV1.copy(e.pos);tmpV1.y+=7;tmpV1.project(camera); | |
| const m=markerPool[i++]; | |
| if(tmpV1.z>1||tmpV1.x<-1.05||tmpV1.x>1.05||tmpV1.y<-1.05||tmpV1.y>1.05){m.style.display='none';continue;} | |
| m.style.display='block'; | |
| m.style.left=((tmpV1.x*0.5+0.5)*window.innerWidth)+'px'; | |
| m.style.top=((-tmpV1.y*0.5+0.5)*window.innerHeight)+'px'; | |
| m.style.color=e.isAce?'#ffb02a':'#ff6a55'; | |
| m.lastChild.textContent=Math.round(d)+'m'; | |
| } | |
| for(;i<markerPool.length;i++)markerPool[i].style.display='none'; | |
| // lead marker | |
| let best=null,bd=1e9; | |
| if(player.alive)for(const e of enemies){ | |
| if(!e.alive)continue; | |
| const d=player.pos.distanceTo(e.pos); | |
| if(d<bd&&d<1200){bd=d;best=e;} | |
| } | |
| if(best){ | |
| const t=bd/265; | |
| tmpV1.copy(best.pos).addScaledVector(best.vel,t).project(camera); | |
| if(tmpV1.z<1&&Math.abs(tmpV1.x)<1&&Math.abs(tmpV1.y)<1){ | |
| el.lead.style.display='block'; | |
| el.lead.style.left=((tmpV1.x*0.5+0.5)*window.innerWidth)+'px'; | |
| el.lead.style.top=((-tmpV1.y*0.5+0.5)*window.innerHeight)+'px'; | |
| }else el.lead.style.display='none'; | |
| }else el.lead.style.display='none'; | |
| } | |
| function updateRadar(){ | |
| const g=el.radar,W=150,R=70; | |
| g.clearRect(0,0,W,W); | |
| g.save();g.translate(75,75); | |
| g.strokeStyle='rgba(160,200,130,.35)'; | |
| g.beginPath();g.arc(0,0,R*0.5,0,TAU);g.stroke(); | |
| g.beginPath();g.arc(0,0,R,0,TAU);g.stroke(); | |
| const fwd=player.fwd(tmpV1); | |
| const heading=Math.atan2(fwd.x,fwd.z); | |
| const range=900; | |
| function blip(x,z,color,size){ | |
| const dx=x-player.pos.x,dz=z-player.pos.z; | |
| const d=Math.hypot(dx,dz); | |
| if(d>range)return; | |
| const a=Math.atan2(dx,dz)-heading; | |
| const rr=d/range*R; | |
| g.fillStyle=color; | |
| g.beginPath();g.arc(Math.sin(a)*rr,-Math.cos(a)*rr,size,0,TAU);g.fill(); | |
| } | |
| for(const e of enemies)if(e.alive)blip(e.pos.x,e.pos.z,e.isAce?'#ffb02a':'#ff5040',3); | |
| for(const b of balloons)if(b.alive)blip(b.pos.x,b.pos.z,'#e8e8e8',2.5); | |
| // player wedge | |
| g.fillStyle='#9fd06a'; | |
| g.beginPath();g.moveTo(0,-6);g.lineTo(4,5);g.lineTo(-4,5);g.closePath();g.fill(); | |
| g.restore(); | |
| } | |
| function updateHUD(dt){ | |
| el.spd.textContent=Math.round(player.speed*3.6); | |
| el.alt.textContent=Math.round(Math.max(0,player.pos.y-Math.max(heightAt(player.pos.x,player.pos.z),0))); | |
| el.thr.style.width=(player.throttle*100)+'%'; | |
| const hpp=player.hp/player.maxHp; | |
| el.hp.style.width=(hpp*100)+'%'; | |
| el.hp.style.filter=hpp<0.3?'saturate(2) brightness(1.2)':'none'; | |
| el.eng.textContent=stallWarn?'STALL!':'OK'; | |
| el.eng.style.color=stallWarn?'#ff5040':'#9fd06a'; | |
| el.foes.textContent=enemies.filter(e=>e.alive).length; | |
| el.wave.textContent=waveActive?wave:'–'; | |
| const t=Math.floor(nowT); | |
| el.clk.textContent=String(Math.floor(t/60)).padStart(2,'0')+':'+String(t%60).padStart(2,'0'); | |
| // vignette | |
| vignHit=Math.max(0,vignHit-dt*1.4); | |
| hitTimer+=dt; | |
| const lowHp=hpp<0.3?(0.25+0.15*Math.sin(nowT*6)):0; | |
| el.vign.style.opacity=clamp(vignHit+lowHp,0,1); | |
| // regen | |
| if(player.alive&&hitTimer>6&&player.hp<player.maxHp)player.hp=Math.min(player.maxHp,player.hp+3.5*dt); | |
| if(msgT>0){msgT-=dt;if(msgT<=0)el.msg.style.opacity=0;} | |
| if(subT>0){subT-=dt;if(subT<=0)el.sub.style.opacity=0;} | |
| } | |
| /* ============================== camera ============================== */ | |
| function updateCamera(dt){ | |
| const fwd=player.fwd(V3._f||(V3._f=V3())); | |
| const up=player.up(V3._g||(V3._g=V3())); | |
| if(cockpit&&player.alive){ | |
| player.mesh.visible=false; | |
| camera.position.copy(player.pos).addScaledVector(fwd,0.6).addScaledVector(up,1.35); | |
| camera.up.copy(up); | |
| tmpV1.copy(player.pos).addScaledVector(fwd,30); | |
| camera.lookAt(tmpV1); | |
| }else{ | |
| if(player.mesh!==undefined&&!player.mesh.visible&&player.alive)player.mesh.visible=true; | |
| tmpV1.set(0,3.4,-12.5).applyQuaternion(player.q).add(player.pos); | |
| const k=1-Math.pow(0.0015,dt); | |
| camera.position.lerp(tmpV1,k); | |
| camera.up.copy(up).lerp(V3(0,1,0),0.55).normalize(); | |
| tmpV2.copy(player.pos).addScaledVector(fwd,22).addScaledVector(up,1.2); | |
| camera.lookAt(tmpV2); | |
| } | |
| if(shake>0){ | |
| shake=Math.max(0,shake-dt*1.8); | |
| camera.position.x+=rand(-1,1)*shake*0.5; | |
| camera.position.y+=rand(-1,1)*shake*0.5; | |
| camera.position.z+=rand(-1,1)*shake*0.5; | |
| } | |
| const fovT=cockpit?75:70+clamp(player.speed-30,0,40)*0.28; | |
| if(Math.abs(camera.fov-fovT)>0.1){camera.fov=lerp(camera.fov,fovT,clamp(3*dt,0,1));camera.updateProjectionMatrix();} | |
| camera.updateMatrixWorld(true); | |
| } | |
| /* ============================== main loop ============================== */ | |
| let frameNo=0; | |
| function loop(){ | |
| requestAnimationFrame(loop); | |
| let dt=Math.min(clock.getDelta(),0.05); | |
| if(paused)dt=0; | |
| nowT+=dt; | |
| frameNo++; | |
| stallWarn=false; | |
| if(dt>0){ | |
| if(state==='play'||state==='over'){ | |
| readInput(dt); | |
| player.updatePhysics(dt); | |
| // prop spin | |
| player.mesh.userData.prop.rotation.z+=(3+player.throttle*42)*dt; | |
| for(const e of enemies){ | |
| e.think(dt); | |
| e.updatePhysics(dt); | |
| e.mesh.userData.prop.rotation.z+=(3+e.throttle*42)*dt; | |
| } | |
| for(let i=enemies.length-1;i>=0;i--)if(enemies[i].removeMe)enemies.splice(i,1); | |
| // mid-air collisions | |
| if(player.alive)for(const e of enemies){ | |
| if(!e.alive)continue; | |
| if(player.pos.distanceToSquared(e.pos)<36){ | |
| const from=e; | |
| player.damage(55,from);e.damage(80,player); | |
| tmpV1.copy(player.pos).sub(e.pos).normalize(); | |
| player.vel.addScaledVector(tmpV1,12);e.vel.addScaledVector(tmpV1,-12); | |
| } | |
| } | |
| updateBullets(dt); | |
| updateBalloons(dt); | |
| particles.update(dt); | |
| // wave logic | |
| if(state==='play'&&waveActive&&enemies.every(e=>!e.alive&&!e.wreck)){ | |
| waveActive=false;waveClearT=0; | |
| addScore(250,'WAVE CLEAR! +250'); | |
| } | |
| if(state==='play'&&!waveActive){ | |
| waveClearT+=dt; | |
| if(waveClearT>3.2)spawnWave(); | |
| } | |
| // out to sea warning | |
| if(Math.hypot(player.pos.x,player.pos.z)>3300&&nowT-seaMsgT>6){ | |
| seaMsgT=nowT;showSub('OPEN SEA AHEAD — TURN BACK'); | |
| } | |
| AudioSys.engine(player.throttle,player.speed,player.alive); | |
| }else{ | |
| // menu: slow orbit around the parked plane | |
| const a=nowT*0.12; | |
| camera.position.set(AF.x+Math.cos(a)*90,AF.h+34,AF.z+Math.sin(a)*90); | |
| camera.up.set(0,1,0); | |
| camera.lookAt(AF.x,AF.h+6,AF.z); | |
| camera.updateMatrixWorld(true); | |
| particles.update(dt); | |
| } | |
| // clouds drift | |
| for(const c of clouds){ | |
| c.g.position.x+=c.v*dt; | |
| if(c.g.position.x>3600)c.g.position.x=-3600; | |
| } | |
| } | |
| if(state!=='menu')updateCamera(dt||0.0001); | |
| updateHUD(dt); | |
| updateRadar(); | |
| updateMarkers(); | |
| renderer.render(scene,camera); | |
| } | |
| /* ============================== boot ============================== */ | |
| window.addEventListener('resize',()=>{ | |
| camera.aspect=window.innerWidth/window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth,window.innerHeight); | |
| }); | |
| document.getElementById('overlay').addEventListener('click',()=>{ | |
| AudioSys.init(); | |
| if(AudioSys.ctx&&AudioSys.ctx.state==='suspended')AudioSys.ctx.resume(); | |
| document.querySelector('#overlay h1').textContent='CRIMSON SKIES'; | |
| startGame(false); | |
| }); | |
| // debug / test hooks | |
| const params=new URLSearchParams(location.search); | |
| window.__game={ | |
| get state(){return state;},get score(){return score;},get wave(){return wave;}, | |
| player:player,enemies:enemies,balloons:balloons,bullets:bullets, | |
| start:(air)=>{startGame(!!air);},heightAt:heightAt, | |
| }; | |
| if(params.get('auto')==='1'){ | |
| startGame(params.get('air')==='1'); | |
| }else{ | |
| // parked on the runway as a menu backdrop | |
| player.mesh.visible=true; | |
| player.place(AF.x,AF.h+GROUND_H,AF.z-105,0); | |
| player.onGround=true; | |
| } | |
| loop(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment