Created
July 11, 2026 14:33
-
-
Save senko/3ef22a9eba617852c0391e092243bf1f to your computer and use it in GitHub Desktop.
Flying simulator by GPT-5.6 Sol
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,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