Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 11, 2026 14:33
Show Gist options
  • Select an option

  • Save senko/3ef22a9eba617852c0391e092243bf1f to your computer and use it in GitHub Desktop.

Select an option

Save senko/3ef22a9eba617852c0391e092243bf1f to your computer and use it in GitHub Desktop.
Flying simulator by GPT-5.6 Sol
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<link rel="icon" href="data:," />
<title>Sky Aces — 3D Dogfight</title>
<style>
:root { --ink:#182328; --paper:#f3e5bd; --gold:#efbd58; --red:#bd4b39; --blue:#6e9cac; }
* { box-sizing:border-box }
html,body { width:100%; height:100%; margin:0; overflow:hidden; background:#79aebc; font-family:"Trebuchet MS",sans-serif; user-select:none; touch-action:none }
#game { position:fixed; inset:0 }
#game canvas { width:100%; height:100%; display:block }
#grain { position:fixed; inset:0; z-index:4; pointer-events:none; box-shadow:inset 0 0 150px rgba(8,23,28,.45); background:repeating-linear-gradient(0deg,rgba(255,255,255,.012) 0 1px,rgba(0,0,0,.016) 1px 3px) }
#hud { position:fixed; inset:0; z-index:6; pointer-events:none; color:#fff2ca; text-transform:uppercase; font-weight:900; letter-spacing:.1em; text-shadow:0 2px rgba(0,0,0,.65) }
.panel { position:absolute; top:20px; padding:11px 14px; border:2px solid rgba(244,230,191,.82); background:rgba(20,31,36,.7); box-shadow:5px 5px rgba(10,20,24,.18); backdrop-filter:blur(5px) }
#instruments { left:20px; width:235px }
#combat { right:20px; width:180px }
.row { display:flex; justify-content:space-between; gap:20px; margin:4px 0; font-size:11px }
.row b { color:white; font-size:13px }
.bar { height:8px; margin-top:7px; border:1px solid rgba(255,255,255,.6); background:rgba(255,255,255,.14) }
.bar i { display:block; width:100%; height:100%; background:#88ae71; transition:width .12s,background .2s }
#warning { position:absolute; top:105px; left:50%; transform:translateX(-50%); color:#ffe29b; font-size:17px; opacity:0; animation:blink .45s steps(2) infinite }
#toast { position:absolute; top:19%; left:50%; transform:translateX(-50%); font:700 clamp(22px,4vw,44px) Georgia,serif; letter-spacing:.08em; opacity:0; transition:opacity .2s }
#reticle { position:absolute; left:50%; top:50%; width:52px; height:52px; transform:translate(-50%,-50%); border:1px solid rgba(255,240,190,.7); border-radius:50% }
#reticle:before,#reticle:after { content:""; position:absolute; background:rgba(255,240,190,.75) }
#reticle:before { width:76px; height:1px; left:-13px; top:25px }
#reticle:after { width:1px; height:76px; left:25px; top:-13px }
#target { position:absolute; width:34px; height:34px; display:none; transform:translate(-50%,-50%); border:2px solid rgba(202,76,53,.9); box-shadow:0 0 8px rgba(255,129,80,.45) }
#target span { position:absolute; top:38px; left:50%; transform:translateX(-50%); white-space:nowrap; font-size:9px; color:#ffd1a8 }
#radarWrap { position:absolute; right:20px; bottom:20px; width:170px; height:170px; padding:5px; border:2px solid rgba(244,230,191,.78); border-radius:50%; background:rgba(20,31,36,.58) }
#radar { width:100%; height:100% }
#help { position:absolute; left:20px; bottom:20px; padding:8px 11px; background:rgba(20,31,36,.5); border-left:3px solid var(--gold); font-size:9px; line-height:1.8 }
#overlay,#gameOver { position:fixed; inset:0; z-index:20; display:grid; place-items:center; color:var(--ink); background:radial-gradient(circle at 50% 35%,rgba(90,140,149,.16),rgba(11,25,30,.74)) }
#gameOver { display:none }
.card { width:min(610px,calc(100vw - 32px)); padding:38px 42px 34px; text-align:center; border:6px double #27363a; background-color:#e9d8ad; background-image:radial-gradient(rgba(49,63,61,.1) .8px,transparent .8px); background-size:7px 7px; box-shadow:12px 14px rgba(10,20,24,.3); transform:rotate(-.3deg) }
.eyebrow { margin-bottom:7px; font-size:11px; font-weight:900; letter-spacing:.3em; text-transform:uppercase }
h1 { margin:0; font:700 clamp(50px,10vw,94px)/.88 Georgia,serif; letter-spacing:-.06em; text-transform:uppercase; text-shadow:3px 3px #c59e58 }
.subtitle { display:flex; align-items:center; gap:12px; margin:16px 0 20px; font-size:12px; font-weight:900; letter-spacing:.24em; text-transform:uppercase }
.subtitle:before,.subtitle:after { content:""; flex:1; height:2px; background:var(--ink) }
.controls { display:grid; grid-template-columns:repeat(4,1fr); gap:7px; margin:20px 0 26px }
.control { padding:9px 3px; border:1px solid rgba(23,32,39,.42); font-size:9px; font-weight:900; text-transform:uppercase }
kbd { display:block; width:max-content; min-width:38px; margin:0 auto 6px; padding:5px 7px; color:var(--paper); background:var(--ink); border-radius:3px; box-shadow:0 3px #6d756d; font:900 12px/1 monospace }
button { padding:13px 28px 12px; border:0; color:#fff5dc; background:var(--red); box-shadow:0 5px #713025; font:900 13px/1 "Trebuchet MS",sans-serif; letter-spacing:.16em; text-transform:uppercase; cursor:pointer }
button:hover { filter:brightness(1.08); transform:translateY(-1px) }
button:active { transform:translateY(4px); box-shadow:0 1px #713025 }
#mobile { position:fixed; z-index:12; inset:auto 0 15px; display:none; justify-content:space-between; padding:0 14px; pointer-events:none }
.mobileGroup { display:grid; grid-template-columns:repeat(3,54px); gap:7px; pointer-events:auto }
.touch { width:54px; height:54px; padding:0; border:2px solid rgba(255,244,214,.8); border-radius:50%; background:rgba(23,32,39,.6); box-shadow:none; font-size:16px; backdrop-filter:blur(4px) }
.touch.fire { background:rgba(173,57,41,.75); font-size:9px }
@keyframes blink { 50% { opacity:.3 } }
@media(pointer:coarse),(max-width:760px) {
#mobile { display:flex }
#help { display:none }
.panel { top:9px; padding:7px 9px }
#instruments { left:9px; width:170px }
#combat { right:9px; width:125px }
.row { font-size:8px; gap:8px }.row b{font-size:10px}
#warning { top:80px; font-size:13px }
#radarWrap { width:110px; height:110px; right:10px; bottom:85px }
.card { padding:27px 16px 24px }.controls{grid-template-columns:repeat(2,1fr)}
}
</style>
</head>
<body>
<div id="game"></div><div id="grain"></div>
<div id="hud" hidden>
<div id="instruments" class="panel">
<div class="row"><span>Airspeed</span><b><span id="speed">0</span> kt</b></div>
<div class="row"><span>Altitude AGL</span><b><span id="altitude">0</span> m</b></div>
<div class="row"><span>Throttle</span><b><span id="throttle">0</span>%</b></div>
<div class="bar"><i id="health"></i></div>
</div>
<div id="combat" class="panel">
<div class="row"><span>Score</span><b id="score">00000</b></div>
<div class="row"><span>Aces</span><b id="kills">0</b></div>
<div class="row"><span>Bandits</span><b id="bandits">0</b></div>
</div>
<div id="warning">⚠ Stall warning</div><div id="toast">Enemy down!</div>
<div id="reticle"></div><div id="target"><span id="range"></span></div>
<div id="radarWrap"><canvas id="radar" width="320" height="320"></canvas></div>
<div id="help">W dive / S climb · A/D roll · Q/E rudder<br>Shift/Ctrl throttle · Space guns · P pause</div>
</div>
<div id="overlay">
<div class="card">
<div class="eyebrow">No. 11 Squadron presents</div><h1>Sky Aces</h1>
<div class="subtitle">3D dogfight simulator</div>
<p>Keep your speed through the turns, use the horizon, and drive the enemy from the valley.</p>
<div class="controls">
<div class="control"><kbd>W / S</kbd>Pitch</div><div class="control"><kbd>A / D</kbd>Roll</div>
<div class="control"><kbd>Q / E</kbd>Rudder</div><div class="control"><kbd>Space</kbd>Fire guns</div>
</div>
<button id="start">Start engine</button>
</div>
</div>
<div id="gameOver"><div class="card"><div class="eyebrow">Combat report</div><h1 style="font-size:clamp(42px,8vw,72px)">Aircraft Lost</h1><div class="subtitle">The squadron remembers</div><p id="final"></p><button id="restart">Fly again</button></div></div>
<div id="mobile">
<div class="mobileGroup"><button class="touch" data-key="KeyA">↶</button><button class="touch" data-key="KeyW">▼</button><button class="touch" data-key="KeyD">↷</button><i></i><button class="touch" data-key="KeyS">▲</button></div>
<div class="mobileGroup"><button class="touch" data-key="ControlLeft">−</button><button class="touch fire" data-key="Space">Fire</button><button class="touch" data-key="ShiftLeft">+</button></div>
</div>
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.166.1/build/three.module.js';
const $ = id => document.getElementById(id);
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)), rand=(a,b)=>a+Math.random()*(b-a);
const keys=Object.create(null), UP=new THREE.Vector3(0,1,0), FORWARD=new THREE.Vector3(0,0,-1), RIGHT=new THREE.Vector3(1,0,0);
const game={state:'menu',time:0,score:0,kills:0,planes:[],bullets:[],particles:[],spawnTimer:0,toast:0,shake:0};
let scene,camera,renderer,player,last=performance.now(),audio,engineOsc,engineGain;
function terrainHeight(x,z){
const rolling=24*Math.sin(x*.0031)+19*Math.cos(z*.0037)+12*Math.sin((x+z)*.006);
const ridges=55*Math.pow(Math.max(0,Math.sin(x*.0011+z*.00055-1)),3);
const valley=-28*Math.exp(-Math.pow((z-x*.13)/460,2));
return 38+rolling+ridges+valley;
}
function initScene(){
scene=new THREE.Scene(); scene.background=new THREE.Color(0x88b8c2); scene.fog=new THREE.FogExp2(0x9abfc0,.00034);
camera=new THREE.PerspectiveCamera(67,innerWidth/innerHeight,.15,4200);
renderer=new THREE.WebGLRenderer({antialias:true,powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio,2)); renderer.setSize(innerWidth,innerHeight); renderer.outputColorSpace=THREE.SRGBColorSpace;
renderer.toneMapping=THREE.ACESFilmicToneMapping; renderer.toneMappingExposure=1.05; $('game').appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xc9edf2,0x5c5538,2.1));
const sun=new THREE.DirectionalLight(0xffe2aa,2.8); sun.position.set(-600,900,-400); scene.add(sun);
makeTerrain(); makeSky(); reset();
addEventListener('resize',resize); requestAnimationFrame(loop);
}
function makeTerrain(){
const size=6200, seg=150, geo=new THREE.PlaneGeometry(size,size,seg,seg); geo.rotateX(-Math.PI/2);
const pos=geo.attributes.position, colors=[];
for(let i=0;i<pos.count;i++){
const x=pos.getX(i),z=pos.getZ(i),h=terrainHeight(x,z); pos.setY(i,h);
const high=clamp((h-55)/105,0,1), noise=.06*Math.sin(x*.03+z*.017);
const low=new THREE.Color(0x496443), top=new THREE.Color(0x8a7953); low.lerp(top,high+noise); colors.push(low.r,low.g,low.b);
}
geo.setAttribute('color',new THREE.Float32BufferAttribute(colors,3)); geo.computeVertexNormals();
const mat=new THREE.MeshLambertMaterial({vertexColors:true,flatShading:false}); const land=new THREE.Mesh(geo,mat); scene.add(land);
const grid=new THREE.GridHelper(size,80,0x8e9864,0x6f8158); grid.position.y=36; grid.material.transparent=true; grid.material.opacity=.08; scene.add(grid);
}
function makeSky(){
const sun=new THREE.Mesh(new THREE.SphereGeometry(52,20,12),new THREE.MeshBasicMaterial({color:0xffe2a0})); sun.position.set(-900,900,-1500); scene.add(sun);
const cloudMat=new THREE.MeshLambertMaterial({color:0xf4ead2,transparent:true,opacity:.66,depthWrite:false});
for(let i=0;i<38;i++){
const g=new THREE.Group(); const count=Math.floor(rand(3,7));
for(let j=0;j<count;j++){ const puff=new THREE.Mesh(new THREE.IcosahedronGeometry(rand(18,38),1),cloudMat); puff.scale.set(rand(1,2.2),rand(.5,.9),rand(.8,1.5)); puff.position.set(j*rand(22,38),rand(-8,9),rand(-12,12)); g.add(puff); }
g.position.set(rand(-2800,2800),rand(310,780),rand(-2800,2800)); g.scale.setScalar(rand(.7,1.45)); scene.add(g);
}
}
function biplaneModel(team){
const g=new THREE.Group(), enemy=team==='enemy';
const bodyMat=new THREE.MeshStandardMaterial({color:enemy?0xa33d2f:0xb89243,roughness:.75,metalness:.05});
const wingMat=new THREE.MeshStandardMaterial({color:enemy?0xc4543d:0xd7bf79,roughness:.9});
const dark=new THREE.MeshStandardMaterial({color:0x263236,roughness:.8}), white=new THREE.MeshStandardMaterial({color:0xe9dfc3});
const fus=new THREE.Mesh(new THREE.CylinderGeometry(.34,.48,3.15,10),bodyMat); fus.rotation.x=Math.PI/2; fus.position.z=.05; g.add(fus);
const nose=new THREE.Mesh(new THREE.CylinderGeometry(.37,.34,.5,12),dark); nose.rotation.x=Math.PI/2; nose.position.z=-1.65; g.add(nose);
const wingGeo=new THREE.BoxGeometry(4.6,.12,.82);
const lower=new THREE.Mesh(wingGeo,wingMat); lower.position.set(0,-.18,-.1); g.add(lower);
const upper=new THREE.Mesh(wingGeo,wingMat); upper.position.set(0,.72,-.05); g.add(upper);
for(const x of [-1.55,1.55]){for(const z of [-.3,.2]){const strut=new THREE.Mesh(new THREE.BoxGeometry(.055,.9,.055),dark);strut.position.set(x,.27,z);strut.rotation.z=x>0?-.08:.08;g.add(strut)}}
const tail=new THREE.Mesh(new THREE.BoxGeometry(1.65,.08,.55),wingMat); tail.position.set(0,.05,1.48); g.add(tail);
const fin=new THREE.Mesh(new THREE.BoxGeometry(.08,.72,.64),wingMat); fin.position.set(0,.35,1.43); g.add(fin);
const cockpit=new THREE.Mesh(new THREE.SphereGeometry(.31,12,8),dark); cockpit.scale.set(.8,.75,1); cockpit.position.set(0,.33,.2); g.add(cockpit);
const prop=new THREE.Group(), blade=new THREE.Mesh(new THREE.BoxGeometry(.08,2.15,.08),new THREE.MeshStandardMaterial({color:0x5d4932})); prop.add(blade); prop.position.z=-1.95; g.add(prop); g.userData.prop=prop;
for(const x of [-1,1]){const wheel=new THREE.Mesh(new THREE.TorusGeometry(.25,.055,8,14),dark); wheel.rotation.y=Math.PI/2;wheel.position.set(x*.47,-.62,-.2);g.add(wheel);const leg=new THREE.Mesh(new THREE.BoxGeometry(.04,.55,.04),dark);leg.position.set(x*.35,-.39,-.15);leg.rotation.z=x*.36;g.add(leg)}
const markMat=enemy?white:dark; for(const x of [-1.5,1.5]){const mark=new THREE.Mesh(new THREE.CylinderGeometry(.15,.15,.125,18),markMat);mark.rotation.z=Math.PI/2;mark.position.set(x,.79,-.05);g.add(mark)}
g.scale.setScalar(1.7); return g;
}
function makePlane(team,pos,heading=0){
const mesh=biplaneModel(team); mesh.position.copy(pos); mesh.quaternion.setFromAxisAngle(UP,heading); scene.add(mesh);
const f=FORWARD.clone().applyQuaternion(mesh.quaternion);
return {team,mesh,velocity:f.multiplyScalar(team==='player'?78:rand(62,82)),angular:new THREE.Vector3(),throttle:team==='player'?.76:rand(.72,.96),health:100,alive:true,cooldown:rand(0,.5),death:0,smoke:0,flash:0,aiBias:rand(-.08,.08)};
}
function reset(){
for(const p of game.planes)scene.remove(p.mesh); for(const b of game.bullets)scene.remove(b.mesh); for(const p of game.particles)scene.remove(p.mesh);
game.planes=[];game.bullets=[];game.particles=[];game.time=game.score=game.kills=game.spawnTimer=game.toast=game.shake=0;
player=makePlane('player',new THREE.Vector3(0,225,220),0); game.planes.push(player);
for(let i=0;i<6;i++)spawnEnemy(i<3); camera.position.set(0,235,250); updateHUD();
}
function spawnEnemy(near=false){
const a=rand(0,Math.PI*2),d=near?rand(420,800):rand(850,1450),x=player.mesh.position.x+Math.cos(a)*d,z=player.mesh.position.z+Math.sin(a)*d;
const y=Math.max(150,terrainHeight(x,z)+rand(150,420)), heading=Math.atan2(-(player.mesh.position.x-x),-(player.mesh.position.z-z))+rand(-.4,.4);
game.planes.push(makePlane('enemy',new THREE.Vector3(x,y,z),heading));
}
function start(){reset();game.state='playing';$('overlay').style.display='none';$('gameOver').style.display='none';$('hud').hidden=false;last=performance.now();startAudio()}
function end(){if(game.state!=='playing')return;game.state='over';$('final').textContent=`Score ${Math.floor(game.score).toLocaleString()} · ${game.kills} enemy aircraft downed`;setTimeout(()=>$('gameOver').style.display='grid',650)}
function startAudio(){
try{audio??=new AudioContext();if(audio.state==='suspended')audio.resume();if(engineOsc)return;engineOsc=audio.createOscillator();engineGain=audio.createGain();engineOsc.type='sawtooth';engineOsc.frequency.value=55;engineGain.gain.value=.025;engineOsc.connect(engineGain).connect(audio.destination);engineOsc.start()}catch(e){}
}
function gunSound(){if(!audio)return;const o=audio.createOscillator(),g=audio.createGain();o.type='square';o.frequency.value=rand(55,80);g.gain.setValueAtTime(.035,audio.currentTime);g.gain.exponentialRampToValueAtTime(.001,audio.currentTime+.045);o.connect(g).connect(audio.destination);o.start();o.stop(audio.currentTime+.05)}
function axes(p){return{forward:FORWARD.clone().applyQuaternion(p.mesh.quaternion),up:UP.clone().applyQuaternion(p.mesh.quaternion),right:RIGHT.clone().applyQuaternion(p.mesh.quaternion)}}
function physics(p,input,dt){
const ax=axes(p),speed=p.velocity.length(),authority=clamp((speed-18)/48,.18,1);
p.angular.x+=(input.pitch*1.25*authority-p.angular.x*2.5)*dt;
p.angular.y+=(input.yaw*.72*authority-p.angular.y*2.7)*dt;
p.angular.z+=(input.roll*1.75*authority-p.angular.z*2.2)*dt;
p.angular.x=clamp(p.angular.x,-1.1,1.1);p.angular.y=clamp(p.angular.y,-.7,.7);p.angular.z=clamp(p.angular.z,-1.5,1.5);
const dq=new THREE.Quaternion().setFromEuler(new THREE.Euler(p.angular.x*dt,p.angular.y*dt,p.angular.z*dt,'XYZ'));p.mesh.quaternion.multiply(dq).normalize();
const a=axes(p),forwardSpeed=p.velocity.dot(a.forward),stall=clamp((forwardSpeed-24)/32,0,1);
p.velocity.addScaledVector(a.forward,39*p.throttle*dt);
if(speed>1)p.velocity.addScaledVector(p.velocity,-.0057*speed*dt);
const lift=.00515*forwardSpeed*forwardSpeed*stall;p.velocity.addScaledVector(a.up,lift*dt);
const side=p.velocity.dot(a.right);p.velocity.addScaledVector(a.right,-side*1.7*dt);
p.velocity.y-=22*dt;p.mesh.position.addScaledVector(p.velocity,dt);
p.cooldown-=dt;p.flash-=dt;p.mesh.userData.prop.rotation.z+=speed*dt*.25;
if(p.health<45&&p.alive){p.smoke-=dt;if(p.smoke<=0){p.smoke=p.health<20?.035:.075;particle('smoke',p.mesh.position,p.velocity.clone().multiplyScalar(-.04),rand(1.2,2),rand(2.2,4))}}
}
function playerControl(dt){
if(!player.alive)return;
const input={pitch:(keys.KeyS||keys.ArrowDown?1:0)-(keys.KeyW||keys.ArrowUp?1:0),roll:(keys.KeyA||keys.ArrowLeft?1:0)-(keys.KeyD||keys.ArrowRight?1:0),yaw:(keys.KeyQ?1:0)-(keys.KeyE?1:0)};
if(keys.ShiftLeft||keys.ShiftRight)player.throttle+=.42*dt;if(keys.ControlLeft||keys.ControlRight)player.throttle-=.42*dt;player.throttle=clamp(player.throttle,.15,1);
if(keys.Space)shoot(player);physics(player,input,dt);
}
function enemyControl(p,dt){
if(!p.alive){physics(p,{pitch:.08,yaw:.25,roll:.35},dt);return}
const here=p.mesh.position,ground=terrainHeight(here.x,here.z),to=player.mesh.position.clone().addScaledVector(player.velocity,clamp(here.distanceTo(player.mesh.position)/420,.2,1.4)).sub(here).normalize();
if(here.y-ground<105||p.velocity.y<-20&&here.y-ground<180)to.set(0,.75,-.45).applyQuaternion(p.mesh.quaternion).normalize();
const local=to.clone().applyQuaternion(p.mesh.quaternion.clone().invert());
const input={pitch:clamp(local.y*3,-1,1),yaw:clamp(-local.x*2.2,-1,1),roll:clamp(-local.x*3-p.angular.z*.3,-1,1)};
p.throttle=here.distanceTo(player.mesh.position)>600?1:.78;physics(p,input,dt);
const f=axes(p).forward,range=here.distanceTo(player.mesh.position);if(player.alive&&range<520&&f.dot(to)>.993)shoot(p);
}
const bulletGeo=new THREE.CylinderGeometry(.045,.045,2.4,5), bulletMatPlayer=new THREE.MeshBasicMaterial({color:0xffed8a}),bulletMatEnemy=new THREE.MeshBasicMaterial({color:0xff7854});
function shoot(p){
if(p.cooldown>0||!p.alive)return;p.cooldown=p.team==='player'?.105:rand(.15,.24);const a=axes(p),dir=a.forward.clone().addScaledVector(a.right,rand(-.006,.006)).addScaledVector(a.up,rand(-.006,.006)).normalize();
const mesh=new THREE.Mesh(bulletGeo,p.team==='player'?bulletMatPlayer:bulletMatEnemy);mesh.position.copy(p.mesh.position).addScaledVector(dir,4);mesh.quaternion.setFromUnitVectors(UP,dir);scene.add(mesh);
game.bullets.push({team:p.team,mesh,velocity:p.velocity.clone().addScaledVector(dir,470),life:1.45,prev:mesh.position.clone()});p.flash=.05;if(p.team==='player')gunSound();
}
function segmentDistanceSq(point,a,b){const ab=b.clone().sub(a),t=clamp(point.clone().sub(a).dot(ab)/Math.max(.001,ab.lengthSq()),0,1);return point.distanceToSquared(a.addScaledVector(ab,t))}
function updateBullets(dt){
for(let i=game.bullets.length-1;i>=0;i--){const b=game.bullets[i];b.prev.copy(b.mesh.position);b.mesh.position.addScaledVector(b.velocity,dt);b.life-=dt;let hit=false;
for(const p of game.planes){if(!p.alive||p.team===b.team)continue;if(segmentDistanceSq(p.mesh.position,b.prev.clone(),b.mesh.position.clone())<18){damage(p,rand(15,24),b.team);hit=true;break}}
const q=b.mesh.position;if(q.y<terrainHeight(q.x,q.z)){particle('spark',q,new THREE.Vector3(0,rand(8,20),0),.35,1);hit=true}
if(hit||b.life<=0){scene.remove(b.mesh);game.bullets.splice(i,1)}}
}
const smokeGeo=new THREE.IcosahedronGeometry(1,1), sparkGeo=new THREE.IcosahedronGeometry(1,0);
function particle(type,position,velocity,life,size){
const smoke=type==='smoke',mesh=new THREE.Mesh(smoke?smokeGeo:sparkGeo,new THREE.MeshBasicMaterial({color:smoke?0x424542:(type==='fire'?0xff7b32:0xffd36c),transparent:true,opacity:smoke?.35:1,depthWrite:!smoke}));mesh.position.copy(position);mesh.scale.setScalar(size);scene.add(mesh);game.particles.push({type,mesh,velocity:velocity.clone().add(new THREE.Vector3(rand(-3,3),rand(-2,3),rand(-3,3))),life,max:life});
}
function explode(pos,power=1){for(let i=0;i<18*power;i++){const v=new THREE.Vector3(rand(-1,1),rand(-.4,1),rand(-1,1)).normalize().multiplyScalar(rand(12,70)*power);particle(i%3?'fire':'spark',pos,v,rand(.25,.8),rand(.15,.5)*power)}for(let i=0;i<7*power;i++)particle('smoke',pos,new THREE.Vector3(rand(-8,8),rand(3,15),rand(-8,8)),rand(1.3,2.8),rand(2.5,5)*power)}
function updateParticles(dt){for(let i=game.particles.length-1;i>=0;i--){const p=game.particles[i];p.mesh.position.addScaledVector(p.velocity,dt);p.velocity.y+=(p.type==='smoke'?2:-16)*dt;p.velocity.multiplyScalar(Math.pow(p.type==='smoke'?.7:.92,dt));p.life-=dt;p.mesh.material.opacity=clamp(p.life/p.max,0,1)*(p.type==='smoke'?.35:1);if(p.type==='smoke')p.mesh.scale.multiplyScalar(1+dt*.3);if(p.life<=0){scene.remove(p.mesh);p.mesh.material.dispose();game.particles.splice(i,1)}}}
function damage(p,amount,team){if(!p.alive)return;p.health-=amount;explode(p.mesh.position,.25);if(p.team==='player')game.shake=5;if(p.health<=0){p.health=0;p.alive=false;explode(p.mesh.position,1.2);game.shake=10;if(p.team==='enemy'&&team==='player'){game.kills++;game.score+=1000;showToast(['Enemy down!','Good shooting!','Confirmed kill!'][Math.floor(Math.random()*3)])}}}
function showToast(t){$('toast').textContent=t;$('toast').style.opacity=1;game.toast=1.5}
function collisions(dt){
for(const p of game.planes){if(!p.alive)p.death+=dt;const q=p.mesh.position,ground=terrainHeight(q.x,q.z);if(q.y<ground+3){if(p.alive){damage(p,200,'terrain');explode(q,1.4)}q.y=ground+2;p.velocity.multiplyScalar(.25);if(p===player)end()}}
for(let i=0;i<game.planes.length;i++)for(let j=i+1;j<game.planes.length;j++){const a=game.planes[i],b=game.planes[j];if(a.alive&&b.alive&&a.mesh.position.distanceToSquared(b.mesh.position)<34){damage(a,150,b.team);damage(b,150,a.team)}}
if(!player.alive&&player.death>1.8)end();
}
function updateCamera(dt){
const a=axes(player),desired=player.mesh.position.clone().addScaledVector(a.forward,-23).addScaledVector(a.up,7);const t=1-Math.pow(.015,dt);camera.position.lerp(desired,t);
if(game.shake>.1)camera.position.add(new THREE.Vector3(rand(-game.shake,game.shake),rand(-game.shake,game.shake),rand(-game.shake,game.shake)).multiplyScalar(.08));
camera.up.lerp(a.up,.06).normalize();camera.lookAt(player.mesh.position.clone().addScaledVector(a.forward,28));game.shake*=Math.pow(.04,dt);
}
function update(dt){
if(game.state!=='playing')return;game.time+=dt;game.score+=dt*7;playerControl(dt);for(const p of game.planes)if(p.team==='enemy')enemyControl(p,dt);updateBullets(dt);updateParticles(dt);collisions(dt);
for(let i=game.planes.length-1;i>=0;i--){const p=game.planes[i];if(p.team==='enemy'&&(!p.alive&&p.death>3.5||p.mesh.position.distanceToSquared(player.mesh.position)>6500000)){scene.remove(p.mesh);game.planes.splice(i,1)}}
const alive=game.planes.filter(p=>p.team==='enemy'&&p.alive).length;game.spawnTimer-=dt;if(alive<6&&game.spawnTimer<=0){spawnEnemy();game.spawnTimer=2.5}
if(game.toast>0&&(game.toast-=dt)<=0)$('toast').style.opacity=0;updateCamera(dt);updateHUD();if(engineOsc){engineOsc.frequency.setTargetAtTime(42+player.throttle*42,audio.currentTime,.1);engineGain.gain.setTargetAtTime(game.state==='playing'?.018+player.throttle*.018:.001,audio.currentTime,.1)}
}
function updateHUD(){
if(!player)return;const speed=Math.round(player.velocity.length()*1.3),q=player.mesh.position,alt=Math.max(0,Math.round(q.y-terrainHeight(q.x,q.z))),alive=game.planes.filter(p=>p.team==='enemy'&&p.alive);
$('speed').textContent=speed;$('altitude').textContent=alt;$('throttle').textContent=Math.round(player.throttle*100);$('score').textContent=String(Math.floor(game.score)).padStart(5,'0');$('kills').textContent=game.kills;$('bandits').textContent=alive.length;
$('health').style.width=`${player.health}%`;$('health').style.background=player.health<30?'#bd4b39':player.health<60?'#efbd58':'#88ae71';
const stall=speed<62,ground=alt<45&&player.velocity.y<0;$('warning').textContent=ground?'⚠ Pull up':stall?'⚠ Stall warning':'';$('warning').style.opacity=stall||ground?1:0;
updateTarget(alive);drawRadar(alive);
}
function updateTarget(enemies){
let nearest=null,best=Infinity;for(const e of enemies){const d=e.mesh.position.distanceToSquared(player.mesh.position);if(d<best){best=d;nearest=e}}
const el=$('target');if(!nearest){el.style.display='none';return}const v=nearest.mesh.position.clone().project(camera);if(v.z<1&&Math.abs(v.x)<1.08&&Math.abs(v.y)<1.08){el.style.display='block';el.style.left=`${(v.x*.5+.5)*innerWidth}px`;el.style.top=`${(-v.y*.5+.5)*innerHeight}px`;$('range').textContent=`${Math.round(Math.sqrt(best))} m`}else el.style.display='none';
}
function drawRadar(enemies){
const c=$('radar'),x=c.getContext('2d'),w=c.width;x.clearRect(0,0,w,w);x.strokeStyle='rgba(244,230,191,.2)';x.lineWidth=2;for(const r of [50,100,145]){x.beginPath();x.arc(w/2,w/2,r,0,Math.PI*2);x.stroke()}x.beginPath();x.moveTo(w/2,10);x.lineTo(w/2,w-10);x.moveTo(10,w/2);x.lineTo(w-10,w/2);x.stroke();
const yaw=new THREE.Vector3(0,0,-1).applyQuaternion(player.mesh.quaternion);const angle=Math.atan2(yaw.x,-yaw.z),ca=Math.cos(-angle),sa=Math.sin(-angle);x.fillStyle='#fff0b7';x.beginPath();x.moveTo(160,147);x.lineTo(153,169);x.lineTo(167,169);x.fill();
for(const e of enemies){const dx=e.mesh.position.x-player.mesh.position.x,dz=e.mesh.position.z-player.mesh.position.z,rx=(dx*ca-dz*sa)*.12,rz=(dx*sa+dz*ca)*.12;x.fillStyle='#e45f43';x.beginPath();x.arc(clamp(160+rx,10,310),clamp(160+rz,10,310),5,0,Math.PI*2);x.fill()}
}
function resize(){camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);renderer.setPixelRatio(Math.min(devicePixelRatio,2))}
function loop(now){const dt=Math.min(.033,Math.max(0,(now-last)/1000));last=now;update(dt);renderer.render(scene,camera);requestAnimationFrame(loop)}
addEventListener('keydown',e=>{if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code))e.preventDefault();keys[e.code]=true;if((e.code==='Enter'||e.code==='Space')&&game.state!=='playing')start();if(e.code==='KeyP'&&game.state==='playing')game.state='paused';else if(e.code==='KeyP'&&game.state==='paused'){game.state='playing';last=performance.now()}});
addEventListener('keyup',e=>keys[e.code]=false);addEventListener('blur',()=>{for(const k in keys)keys[k]=false});
document.querySelectorAll('[data-key]').forEach(b=>{const k=b.dataset.key,down=e=>{e.preventDefault();keys[k]=true},up=e=>{e.preventDefault();keys[k]=false};b.addEventListener('pointerdown',down);b.addEventListener('pointerup',up);b.addEventListener('pointercancel',up);b.addEventListener('pointerleave',up)});
$('start').onclick=start;$('restart').onclick=start;initScene();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment