Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 17, 2026 07:07
Show Gist options
  • Select an option

  • Save senko/179dc00e882c5f8253c55552466df95c to your computer and use it in GitHub Desktop.

Select an option

Save senko/179dc00e882c5f8253c55552466df95c to your computer and use it in GitHub Desktop.
Flying simulator by Kimi K3
<!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> &nbsp;·&nbsp; WAVE <b id="wave">–</b> &nbsp;·&nbsp; BANDITS <b id="foes">0</b> &nbsp;·&nbsp; <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