Created
July 20, 2026 11:49
-
-
Save senko/c2337c99fd9646d56ac33b2a8187a2bd to your computer and use it in GitHub Desktop.
Flying simulator by Qwen 3.6 Max (preview)
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"> | |
| <link rel="icon" href="data:,"> | |
| <title>Sky Ace — WWI Biplane Dogfight</title> | |
| <style> | |
| * { margin:0; padding:0; box-sizing:border-box; } | |
| html,body { width:100%; height:100%; overflow:hidden; background:#87b7e0; | |
| font-family:"Courier New", monospace; color:#e8efd8; user-select:none; } | |
| #c { display:block; width:100%; height:100%; } | |
| .hud { position:fixed; z-index:5; pointer-events:none; text-shadow:1px 1px 2px rgba(0,0,0,.85); } | |
| #stats { top:14px; left:16px; font-size:15px; line-height:1.55em; letter-spacing:1px; } | |
| #stats b { color:#ffd76a; font-size:19px; } | |
| #gauges { left:16px; bottom:16px; width:230px; font-size:13px; } | |
| #gauges .row { display:flex; justify-content:space-between; margin-bottom:3px; letter-spacing:1px; } | |
| #gauges .big { font-size:26px; color:#ffd76a; } | |
| .bar { height:10px; background:rgba(0,0,0,.45); border:1px solid rgba(255,255,255,.35); margin-bottom:8px; } | |
| .bar > div { height:100%; width:50%; } | |
| #throttleFill { background:#6ab7ff; } | |
| #healthFill { background:#69d84f; } | |
| #radarWrap { right:16px; bottom:16px; text-align:center; } | |
| #radar { background:rgba(6,18,10,.55); border:2px solid rgba(140,220,150,.4); border-radius:50%; } | |
| #radarWrap .lbl { font-size:11px; letter-spacing:2px; margin-top:3px; opacity:.8; } | |
| #crosshair { left:50%; top:50%; transform:translate(-50%,-50%); width:46px; height:46px; | |
| border:2px solid rgba(230,240,210,.75); border-radius:50%; } | |
| #crosshair::after { content:""; position:absolute; left:50%; top:50%; width:4px; height:4px; | |
| transform:translate(-50%,-50%); background:rgba(230,240,210,.9); border-radius:50%; } | |
| #crosshair.lock { border-color:#ff4433; box-shadow:0 0 8px rgba(255,60,40,.7); } | |
| #crosshair.lock::after { background:#ff4433; } | |
| #lockDist { left:50%; top:calc(50% + 32px); transform:translateX(-50%); font-size:12px; | |
| color:#ff8a75; letter-spacing:1px; display:none; } | |
| #msg { top:12%; left:50%; transform:translateX(-50%); font-size:22px; letter-spacing:2px; | |
| color:#ffe9a8; opacity:0; transition:opacity .35s; white-space:nowrap; } | |
| #msg.show { opacity:1; } | |
| #warn { bottom:24%; left:50%; transform:translateX(-50%); font-size:26px; font-weight:bold; | |
| color:#ff5030; letter-spacing:3px; display:none; animation:blink .5s steps(2) infinite; } | |
| @keyframes blink { 50% { opacity:.15; } } | |
| #flash { position:fixed; inset:0; z-index:4; pointer-events:none; opacity:0; transition:opacity .6s; | |
| background:radial-gradient(ellipse at center, transparent 45%, rgba(190,0,0,.55) 100%); } | |
| #hint { top:14px; right:16px; font-size:11px; line-height:1.7em; text-align:right; opacity:.75; letter-spacing:1px; } | |
| #overlay { position:fixed; inset:0; z-index:10; display:flex; align-items:center; justify-content:center; | |
| background:radial-gradient(ellipse at center, rgba(20,28,38,.72), rgba(8,12,18,.92)); } | |
| #overlay.hidden { display:none; } | |
| .panel { text-align:center; padding:34px 54px; border:2px solid rgba(255,215,120,.5); | |
| background:rgba(16,22,30,.82); box-shadow:0 0 60px rgba(0,0,0,.6); max-width:640px; } | |
| .panel h1 { font-family:Georgia, serif; font-size:44px; color:#ffd76a; letter-spacing:6px; | |
| text-shadow:2px 2px 0 #7a2f14, 4px 4px 12px rgba(0,0,0,.8); margin-bottom:4px; } | |
| .panel .sub { font-family:Georgia, serif; font-style:italic; color:#c8b98a; font-size:15px; margin-bottom:22px; } | |
| .panel table { margin:0 auto 22px; font-size:13px; line-height:1.9em; letter-spacing:1px; } | |
| .panel td:first-child { color:#ffd76a; text-align:right; padding-right:14px; white-space:nowrap; } | |
| .panel td:last-child { text-align:left; color:#dfe8cf; } | |
| .panel .go { font-size:19px; color:#9fe89a; letter-spacing:2px; animation:blink 1.1s steps(2) infinite; } | |
| .panel .score-line { font-size:20px; color:#ffd76a; margin-bottom:18px; letter-spacing:2px; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="c"></canvas> | |
| <div id="stats" class="hud"> | |
| SCORE <b id="score">0</b><br> | |
| KILLS <b id="kills">0</b> ✕ <span id="deaths">0</span> | |
| </div> | |
| <div id="gauges" class="hud"> | |
| <div class="row"><span>SPEED</span><span class="big"><span id="speed">0</span> <small style="font-size:12px">km/h</small></span></div> | |
| <div class="row"><span>ALTITUDE</span><span class="big"><span id="alt">0</span> <small style="font-size:12px">m</small></span></div> | |
| <div class="row"><span>THROTTLE</span><span id="thrPct">70%</span></div> | |
| <div class="bar"><div id="throttleFill"></div></div> | |
| <div class="row"><span>HULL</span><span id="hpPct">100%</span></div> | |
| <div class="bar"><div id="healthFill"></div></div> | |
| </div> | |
| <div id="radarWrap" class="hud"> | |
| <canvas id="radar" width="150" height="150"></canvas> | |
| <div class="lbl">RADAR · 1 km</div> | |
| </div> | |
| <div id="crosshair" class="hud"></div> | |
| <div id="lockDist" class="hud"></div> | |
| <div id="msg" class="hud"></div> | |
| <div id="warn" class="hud">⚠ STALL</div> | |
| <div id="flash"></div> | |
| <div id="hint" class="hud"> | |
| W/S pitch · A/D roll · Q/E rudder<br> | |
| SHIFT/CTRL throttle · SPACE fire<br> | |
| C camera · M sound · P pause | |
| </div> | |
| <div id="overlay"> | |
| <div class="panel"> | |
| <h1>SKY ACE</h1> | |
| <div class="sub">— a Great War biplane dogfight —</div> | |
| <table> | |
| <tr><td>W / S</td><td>pitch down / up (push & pull)</td></tr> | |
| <tr><td>A / D</td><td>roll left / right</td></tr> | |
| <tr><td>Q / E</td><td>rudder left / right</td></tr> | |
| <tr><td>SHIFT / CTRL</td><td>throttle up / down</td></tr> | |
| <tr><td>SPACE</td><td>fire machine guns</td></tr> | |
| <tr><td>C · M · P</td><td>camera · sound · pause</td></tr> | |
| </table> | |
| <div class="score-line" id="finalScore" style="display:none"></div> | |
| <div class="go" id="goLine">PRESS ENTER TO FLY</div> | |
| </div> | |
| </div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script> | |
| 'use strict'; | |
| if (!window.THREE) { | |
| document.getElementById('goLine').textContent = 'Failed to load Three.js from CDN — check network'; | |
| throw new Error('THREE failed to load'); | |
| } | |
| // ============================== helpers ============================== | |
| const clamp = (v,a,b) => Math.max(a, Math.min(b, v)); | |
| const lerp = (a,b,t) => a + (b-a)*t; | |
| const rand = (a,b) => a + Math.random()*(b-a); | |
| const TAU = Math.PI*2; | |
| const V3 = (x=0,y=0,z=0) => new THREE.Vector3(x,y,z); | |
| const X_AXIS = V3(1,0,0), Y_AXIS = V3(0,1,0), Z_AXIS = V3(0,0,1); | |
| // ============================== terrain noise ============================== | |
| function hash2(x, z){ | |
| let n = Math.sin(x*127.1 + z*311.7) * 43758.5453123; | |
| return n - Math.floor(n); | |
| } | |
| function vnoise(x, z){ | |
| const xi = Math.floor(x), zi = Math.floor(z); | |
| const xf = x-xi, zf = z-zi; | |
| const u = xf*xf*(3-2*xf), v = zf*zf*(3-2*zf); | |
| const a = hash2(xi,zi), b = hash2(xi+1,zi), c = hash2(xi,zi+1), d = hash2(xi+1,zi+1); | |
| return a + (b-a)*u + (c-a)*v + (a-b-c+d)*u*v; | |
| } | |
| function fbm(x, z){ | |
| let f = 0, amp = 0.5, freq = 1; | |
| for (let i=0;i<5;i++){ f += amp*vnoise(x*freq, z*freq); freq *= 2.03; amp *= 0.5; } | |
| return f; | |
| } | |
| function terrainHeight(x, z){ | |
| const s = 1/430; | |
| let h = fbm(x*s+100, z*s+100); | |
| h = Math.pow(h, 1.55) * 155; | |
| let m = fbm(x*s*0.35+50, z*s*0.35+50); | |
| m = Math.max(0, m-0.52) * 2.1; | |
| const mountains = Math.pow(m, 1.4) * 360 * (0.35 + 0.65*fbm(x*s*2.2+9, z*s*2.2)); | |
| return h + mountains - 24; | |
| } | |
| function terrainNormalY(x, z){ | |
| const e = 6; | |
| const hx = terrainHeight(x+e,z) - terrainHeight(x-e,z); | |
| const hz = terrainHeight(x,z+e) - terrainHeight(x,z-e); | |
| return 1 / Math.sqrt(1 + (hx*hx + hz*hz) / (4*e*e)); | |
| } | |
| // ============================== three setup ============================== | |
| const WORLD = 4000, HALF = WORLD/2, BOUND = 1850; | |
| const canvas = document.getElementById('c'); | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias:true }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| const scene = new THREE.Scene(); | |
| scene.fog = new THREE.Fog(0xcfe4f7, 700, 2500); | |
| const camera = new THREE.PerspectiveCamera(70, window.innerWidth/window.innerHeight, 0.5, 7000); | |
| camera.position.set(0, 250, -60); | |
| const sunDir = V3(0.45, 0.72, 0.35).normalize(); | |
| scene.add(new THREE.HemisphereLight(0xbfd9ff, 0x5a6b3f, 0.85)); | |
| const sun = new THREE.DirectionalLight(0xfff1c8, 0.95); | |
| sun.position.copy(sunDir).multiplyScalar(1000); | |
| scene.add(sun); | |
| scene.add(new THREE.AmbientLight(0x404850, 0.35)); | |
| // sky dome | |
| const sky = new THREE.Mesh( | |
| new THREE.SphereGeometry(3200, 24, 14), | |
| new THREE.ShaderMaterial({ | |
| side: THREE.BackSide, depthWrite:false, fog:false, | |
| uniforms: { | |
| topColor: { value: new THREE.Color(0x2f6fd0) }, | |
| bottomColor: { value: new THREE.Color(0xcfe4f7) }, | |
| sunDir: { value: sunDir }, | |
| sunColor: { value: new THREE.Color(0xfff2b8) } | |
| }, | |
| vertexShader: ` | |
| varying vec3 vDir; | |
| void main(){ vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`, | |
| fragmentShader: ` | |
| uniform vec3 topColor, bottomColor, sunDir, sunColor; | |
| varying vec3 vDir; | |
| void main(){ | |
| vec3 d = normalize(vDir); | |
| float t = pow(max(d.y, 0.0), 0.62); | |
| vec3 col = mix(bottomColor, topColor, t); | |
| float s = max(dot(d, normalize(sunDir)), 0.0); | |
| col += sunColor * (pow(s, 90.0)*0.85 + pow(s, 7.0)*0.14); | |
| gl_FragColor = vec4(col, 1.0); | |
| }` | |
| }) | |
| ); | |
| scene.add(sky); | |
| // terrain | |
| const terrGeo = new THREE.PlaneGeometry(WORLD, WORLD, 150, 150); | |
| terrGeo.rotateX(-Math.PI/2); | |
| { | |
| const pos = terrGeo.attributes.position; | |
| for (let i=0;i<pos.count;i++) pos.setY(i, terrainHeight(pos.getX(i), pos.getZ(i))); | |
| terrGeo.computeVertexNormals(); | |
| const nrm = terrGeo.attributes.normal; | |
| const colors = new Float32Array(pos.count*3); | |
| const cSand = new THREE.Color(0xc9b97e), cGrass1 = new THREE.Color(0x578f34), | |
| cGrass2 = new THREE.Color(0x3a6b22), cRock = new THREE.Color(0x8a7d5f), | |
| cGray = new THREE.Color(0x93908a), cSnow = new THREE.Color(0xf4f6f8), | |
| tmp = new THREE.Color(); | |
| for (let i=0;i<pos.count;i++){ | |
| const x = pos.getX(i), z = pos.getZ(i), h = pos.getY(i); | |
| const slope = 1 - nrm.getY(i); | |
| if (h < 1.5) tmp.copy(cSand); | |
| else if (h < 75) tmp.copy(cGrass1).lerp(cGrass2, h/75 * 0.9 + fbm(x*0.013+7, z*0.013)*0.35); | |
| else if (h < 150) tmp.copy(cGrass2).lerp(cRock, (h-75)/75); | |
| else if (h < 235) tmp.copy(cRock).lerp(cGray, (h-150)/85); | |
| else tmp.copy(cGray).lerp(cSnow, clamp((h-235)/75, 0, 1)); | |
| if (slope > 0.32 && h > 3) tmp.lerp(cGray, clamp((slope-0.32)*3.2, 0, 0.9)); | |
| const j = 0.94 + hash2(x*3.7, z*3.7)*0.12; | |
| colors[i*3] = tmp.r*j; colors[i*3+1] = tmp.g*j; colors[i*3+2] = tmp.b*j; | |
| } | |
| terrGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| } | |
| scene.add(new THREE.Mesh(terrGeo, new THREE.MeshLambertMaterial({ vertexColors:true }))); | |
| // water | |
| const water = new THREE.Mesh( | |
| new THREE.PlaneGeometry(WORLD*1.5, WORLD*1.5).rotateX(-Math.PI/2), | |
| new THREE.MeshLambertMaterial({ color:0x2e6f95, transparent:true, opacity:0.82 }) | |
| ); | |
| water.position.y = 0; | |
| scene.add(water); | |
| // trees (instanced) | |
| { | |
| const MAX = 460; | |
| const trunkGeo = new THREE.CylinderGeometry(0.5, 0.8, 4, 5); | |
| const leafGeo = new THREE.ConeGeometry(3.4, 10, 7); | |
| const trunks = new THREE.InstancedMesh(trunkGeo, new THREE.MeshLambertMaterial({ color:0x6b4a2a }), MAX); | |
| const leaves = new THREE.InstancedMesh(leafGeo, new THREE.MeshLambertMaterial({ color:0x2f6b26 }), MAX); | |
| const m = new THREE.Matrix4(), q = new THREE.Quaternion(), s = V3(), p = V3(); | |
| let n = 0; | |
| for (let tries=0; tries<4000 && n<MAX; tries++){ | |
| const x = rand(-BOUND, BOUND), z = rand(-BOUND, BOUND); | |
| const h = terrainHeight(x, z); | |
| if (h < 3 || h > 125 || terrainNormalY(x, z) < 0.86) continue; | |
| const sc = rand(0.7, 1.9); | |
| q.setFromAxisAngle(Y_AXIS, rand(0, TAU)); | |
| s.set(sc, sc*rand(0.85, 1.3), sc); | |
| p.set(x, h + 2*sc, z); m.compose(p, q, s); trunks.setMatrixAt(n, m); | |
| p.set(x, h + (4+5)*sc*0.92, z); m.compose(p, q, s); leaves.setMatrixAt(n, m); | |
| n++; | |
| } | |
| trunks.count = leaves.count = n; | |
| scene.add(trunks, leaves); | |
| } | |
| // rocks (instanced) | |
| { | |
| const MAX = 150; | |
| const rocks = new THREE.InstancedMesh( | |
| new THREE.DodecahedronGeometry(2.2, 0), | |
| new THREE.MeshLambertMaterial({ color:0x8d8a82 }), MAX); | |
| const m = new THREE.Matrix4(), q = new THREE.Quaternion(), s = V3(), p = V3(); | |
| let n = 0; | |
| for (let tries=0; tries<1200 && n<MAX; tries++){ | |
| const x = rand(-BOUND, BOUND), z = rand(-BOUND, BOUND); | |
| const h = terrainHeight(x, z); | |
| if (h < 1) continue; | |
| const sc = rand(0.6, 3.4); | |
| q.setFromEuler(new THREE.Euler(rand(0,TAU), rand(0,TAU), rand(0,TAU))); | |
| s.set(sc, sc*rand(0.6,1.1), sc); | |
| p.set(x, h + sc*0.6, z); m.compose(p, q, s); rocks.setMatrixAt(n, m); | |
| n++; | |
| } | |
| rocks.count = n; | |
| scene.add(rocks); | |
| } | |
| // clouds | |
| const clouds = []; | |
| { | |
| const mat = new THREE.MeshLambertMaterial({ color:0xffffff, transparent:true, opacity:0.9 }); | |
| for (let i=0;i<24;i++){ | |
| const g = new THREE.Group(); | |
| const puffs = 3 + Math.floor(rand(0,4)); | |
| for (let j=0;j<puffs;j++){ | |
| const r = rand(14, 32); | |
| const puff = new THREE.Mesh(new THREE.SphereGeometry(r, 8, 6), mat); | |
| puff.position.set(rand(-38,38), rand(-6,8), rand(-26,26)); | |
| puff.scale.y = 0.45; | |
| g.add(puff); | |
| } | |
| g.position.set(rand(-1900,1900), rand(300,470), rand(-1900,1900)); | |
| scene.add(g); | |
| clouds.push(g); | |
| } | |
| } | |
| // ============================== particles ============================== | |
| const spriteTex = (() => { | |
| const cv = document.createElement('canvas'); cv.width = cv.height = 64; | |
| const g = cv.getContext('2d'); | |
| const grad = g.createRadialGradient(32,32,2, 32,32,30); | |
| grad.addColorStop(0, 'rgba(255,255,255,1)'); | |
| grad.addColorStop(0.55, 'rgba(255,255,255,.55)'); | |
| grad.addColorStop(1, 'rgba(255,255,255,0)'); | |
| g.fillStyle = grad; g.fillRect(0,0,64,64); | |
| return new THREE.CanvasTexture(cv); | |
| })(); | |
| const P_VERT = ` | |
| attribute float size; attribute float alpha; attribute vec3 pcolor; | |
| varying float vAlpha; varying vec3 vColor; | |
| void main(){ | |
| vAlpha = alpha; vColor = pcolor; | |
| vec4 mv = modelViewMatrix * vec4(position, 1.0); | |
| gl_PointSize = size * (320.0 / -mv.z); | |
| gl_Position = projectionMatrix * mv; | |
| }`; | |
| const P_FRAG = ` | |
| uniform sampler2D map; | |
| varying float vAlpha; varying vec3 vColor; | |
| void main(){ | |
| vec4 t = texture2D(map, gl_PointCoord); | |
| gl_FragColor = vec4(vColor, vAlpha * t.a); | |
| }`; | |
| function makePool(n, blending){ | |
| const geo = new THREE.BufferGeometry(); | |
| const pos = new Float32Array(n*3), col = new Float32Array(n*3); | |
| const siz = new Float32Array(n), alp = new Float32Array(n); | |
| for (let i=0;i<n;i++) pos[i*3+1] = -1e4; | |
| geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); | |
| geo.setAttribute('pcolor', new THREE.BufferAttribute(col, 3)); | |
| geo.setAttribute('size', new THREE.BufferAttribute(siz, 1)); | |
| geo.setAttribute('alpha', new THREE.BufferAttribute(alp, 1)); | |
| const mat = new THREE.ShaderMaterial({ | |
| uniforms:{ map:{ value:spriteTex } }, | |
| vertexShader:P_VERT, fragmentShader:P_FRAG, | |
| transparent:true, depthWrite:false, blending | |
| }); | |
| const pts = new THREE.Points(geo, mat); | |
| pts.frustumCulled = false; | |
| scene.add(pts); | |
| const vel = new Float32Array(n*3), life = new Float32Array(n), | |
| maxLife = new Float32Array(n), grow = new Float32Array(n); | |
| let cursor = 0; | |
| return { | |
| spawn(p, v, lf, sz, c, gr){ | |
| const i = cursor; cursor = (cursor+1) % n; | |
| pos[i*3]=p.x; pos[i*3+1]=p.y; pos[i*3+2]=p.z; | |
| vel[i*3]=v.x; vel[i*3+1]=v.y; vel[i*3+2]=v.z; | |
| life[i] = maxLife[i] = lf; siz[i] = sz; alp[i] = 1; | |
| col[i*3]=c.r; col[i*3+1]=c.g; col[i*3+2]=c.b; | |
| grow[i] = gr || 0; | |
| }, | |
| update(dt){ | |
| const drag = Math.max(0, 1 - 1.7*dt); | |
| for (let i=0;i<n;i++){ | |
| if (life[i] <= 0) continue; | |
| life[i] -= dt; | |
| if (life[i] <= 0){ pos[i*3+1] = -1e4; alp[i] = 0; continue; } | |
| pos[i*3] += vel[i*3]*dt; | |
| pos[i*3+1] += vel[i*3+1]*dt; | |
| pos[i*3+2] += vel[i*3+2]*dt; | |
| vel[i*3] *= drag; vel[i*3+2] *= drag; | |
| vel[i*3+1] = vel[i*3+1]*drag + 2.4*dt; | |
| alp[i] = life[i] / maxLife[i]; | |
| siz[i] += grow[i]*dt; | |
| } | |
| geo.attributes.position.needsUpdate = true; | |
| geo.attributes.pcolor.needsUpdate = true; | |
| geo.attributes.size.needsUpdate = true; | |
| geo.attributes.alpha.needsUpdate = true; | |
| } | |
| }; | |
| } | |
| const firePool = makePool(360, THREE.AdditiveBlending); | |
| const smokePool = makePool(360, THREE.NormalBlending); | |
| const C_FIRE = [new THREE.Color(0xffe9a0), new THREE.Color(0xffa030), new THREE.Color(0xff5a18), new THREE.Color(0xd83010)]; | |
| const C_SMOKE = [new THREE.Color(0x555555), new THREE.Color(0x777777), new THREE.Color(0x999999)]; | |
| function spawnExplosion(p, scale){ | |
| scale = scale || 1; | |
| firePool.spawn(p, V3(0,0,0), 0.13, 55*scale, new THREE.Color(0xfff6c0), 0); | |
| for (let i=0;i<44;i++){ | |
| const v = V3(rand(-1,1), rand(-0.6,1), rand(-1,1)).normalize().multiplyScalar(rand(8,44)*scale); | |
| firePool.spawn(p, v, rand(0.3,0.8), rand(7,17)*scale, C_FIRE[Math.floor(rand(0,C_FIRE.length))], 0); | |
| } | |
| for (let i=0;i<26;i++){ | |
| const v = V3(rand(-8,8), rand(2,14), rand(-8,8)).multiplyScalar(scale); | |
| smokePool.spawn(p, v, rand(0.9,2.0), rand(10,24)*scale, C_SMOKE[Math.floor(rand(0,C_SMOKE.length))], 9*scale); | |
| } | |
| } | |
| function spawnSmoke(p, velScale){ | |
| smokePool.spawn(p, V3(rand(-2,2), rand(1,4), rand(-2,2)).multiplyScalar(velScale||1), | |
| rand(0.7,1.5), rand(5,10), C_SMOKE[Math.floor(rand(0,C_SMOKE.length))], 7); | |
| } | |
| function spawnSpark(p){ | |
| for (let i=0;i<4;i++) | |
| firePool.spawn(p, V3(rand(-9,9), rand(2,12), rand(-9,9)), rand(0.15,0.35), rand(3,6), C_FIRE[1], 0); | |
| } | |
| // ============================== audio ============================== | |
| const AudioSys = { | |
| ctx:null, master:null, engGain:null, engOsc1:null, engOsc2:null, noiseBuf:null, muted:false, | |
| init(){ | |
| if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); return; } | |
| try { | |
| const AC = window.AudioContext || window.webkitAudioContext; | |
| this.ctx = new AC(); | |
| this.master = this.ctx.createGain(); | |
| this.master.gain.value = 0.55; | |
| this.master.connect(this.ctx.destination); | |
| // engine | |
| this.engGain = this.ctx.createGain(); this.engGain.gain.value = 0; | |
| const lp = this.ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 420; | |
| this.engOsc1 = this.ctx.createOscillator(); this.engOsc1.type = 'sawtooth'; this.engOsc1.frequency.value = 60; | |
| this.engOsc2 = this.ctx.createOscillator(); this.engOsc2.type = 'square'; this.engOsc2.frequency.value = 31; | |
| this.engOsc1.connect(lp); this.engOsc2.connect(lp); | |
| lp.connect(this.engGain); this.engGain.connect(this.master); | |
| this.engOsc1.start(); this.engOsc2.start(); | |
| // noise buffer for guns/explosions | |
| const len = this.ctx.sampleRate; | |
| this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate); | |
| const d = this.noiseBuf.getChannelData(0); | |
| for (let i=0;i<len;i++) d[i] = Math.random()*2-1; | |
| } catch(e){ console.warn('audio unavailable', e); } | |
| }, | |
| engine(throttle, speed, flying){ | |
| if (!this.ctx) return; | |
| const t = this.ctx.currentTime; | |
| const f = 48 + throttle*72 + speed*0.55; | |
| this.engOsc1.frequency.setTargetAtTime(f, t, 0.06); | |
| this.engOsc2.frequency.setTargetAtTime(f*0.5, t, 0.06); | |
| this.engGain.gain.setTargetAtTime((this.muted || !flying) ? 0 : 0.03 + throttle*0.055, t, 0.08); | |
| }, | |
| blast(dur, f0, f1, vol){ | |
| if (!this.ctx || this.muted) return; | |
| const t = this.ctx.currentTime; | |
| const src = this.ctx.createBufferSource(); src.buffer = this.noiseBuf; src.loop = true; | |
| const bp = this.ctx.createBiquadFilter(); bp.type = 'lowpass'; | |
| bp.frequency.setValueAtTime(f0, t); | |
| bp.frequency.exponentialRampToValueAtTime(Math.max(f1,20), t + dur); | |
| const g = this.ctx.createGain(); | |
| g.gain.setValueAtTime(vol, t); | |
| g.gain.exponentialRampToValueAtTime(0.001, t + dur); | |
| src.connect(bp); bp.connect(g); g.connect(this.master); | |
| src.start(t); src.stop(t + dur + 0.05); | |
| }, | |
| gun(){ this.blast(0.09, 1600, 300, 0.22); }, | |
| explosion(big){ this.blast(big ? 1.1 : 0.7, 900, 60, big ? 0.6 : 0.45); }, | |
| hit(){ | |
| if (!this.ctx || this.muted) return; | |
| const t = this.ctx.currentTime; | |
| const o = this.ctx.createOscillator(); o.type = 'square'; | |
| o.frequency.setValueAtTime(240, t); | |
| o.frequency.exponentialRampToValueAtTime(90, t + 0.12); | |
| const g = this.ctx.createGain(); | |
| g.gain.setValueAtTime(0.12, t); | |
| g.gain.exponentialRampToValueAtTime(0.001, t + 0.13); | |
| o.connect(g); g.connect(this.master); | |
| o.start(t); o.stop(t + 0.15); | |
| }, | |
| toggle(){ | |
| this.muted = !this.muted; | |
| if (this.master) this.master.gain.value = this.muted ? 0 : 0.55; | |
| return this.muted; | |
| } | |
| }; | |
| // ============================== biplane factory ============================== | |
| function makeBiplane(opts){ | |
| const g = new THREE.Group(); | |
| const wingMat = new THREE.MeshLambertMaterial({ color: opts.wing }); | |
| const bodyMat = new THREE.MeshLambertMaterial({ color: opts.body }); | |
| const woodMat = new THREE.MeshLambertMaterial({ color: 0x4a3520 }); | |
| const darkMat = new THREE.MeshLambertMaterial({ color: 0x222222 }); | |
| const accentMat = new THREE.MeshLambertMaterial({ color: opts.accent }); | |
| const add = (mesh, x,y,z, parent) => { mesh.position.set(x,y,z); (parent||g).add(mesh); return mesh; }; | |
| // fuselage: nose toward +Z | |
| const fuse = add(new THREE.Mesh(new THREE.CylinderGeometry(0.72, 1.05, 9.5, 8), bodyMat), 0, 0.55, 0.4); | |
| fuse.rotation.x = Math.PI/2; | |
| add(new THREE.Mesh(new THREE.ConeGeometry(0.72, 2.2, 8), bodyMat), 0, 0.55, -5.3).rotation.x = -Math.PI/2; // tail taper | |
| // engine cowl | |
| add(new THREE.Mesh(new THREE.CylinderGeometry(1.08, 1.0, 1.4, 8), accentMat), 0, 0.55, 4.9).rotation.x = Math.PI/2; | |
| // wings | |
| add(new THREE.Mesh(new THREE.BoxGeometry(26, 0.35, 3.4), wingMat), 0, 2.35, 0.4); // upper | |
| add(new THREE.Mesh(new THREE.BoxGeometry(24, 0.35, 3.0), wingMat), 0, 0.15, 0.2); // lower | |
| // struts | |
| for (const sx of [-4.2, 4.2]) for (const sz of [-0.9, 1.5]) | |
| add(new THREE.Mesh(new THREE.BoxGeometry(0.22, 2.1, 0.22), woodMat), sx, 1.25, sz); | |
| for (const sx of [-1.1, 1.1]) | |
| add(new THREE.Mesh(new THREE.BoxGeometry(0.18, 1.5, 0.18), woodMat), sx, 1.6, 0.9); | |
| // tail | |
| add(new THREE.Mesh(new THREE.BoxGeometry(7.2, 0.28, 2.1), wingMat), 0, 0.85, -5.6); | |
| add(new THREE.Mesh(new THREE.BoxGeometry(0.28, 2.5, 2.0), wingMat), 0, 1.9, -5.8); | |
| // pilot | |
| add(new THREE.Mesh(new THREE.SphereGeometry(0.55, 8, 6), new THREE.MeshLambertMaterial({ color:0x54402c })), 0, 1.62, -1.1); | |
| add(new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.22, 0.2), darkMat), 0, 1.68, -0.62); // goggles | |
| // landing gear | |
| for (const sx of [-1.9, 1.9]){ | |
| const wheel = add(new THREE.Mesh(new THREE.CylinderGeometry(1.05, 1.05, 0.5, 10), darkMat), sx, -1.85, 2.1); | |
| wheel.rotation.z = Math.PI/2; | |
| add(new THREE.Mesh(new THREE.BoxGeometry(0.16, 1.9, 0.16), woodMat), sx*0.7, -0.95, 2.1).rotation.z = sx > 0 ? 0.35 : -0.35; | |
| } | |
| // propeller + spinner + blur disc | |
| const prop = new THREE.Group(); | |
| prop.position.set(0, 0.55, 5.75); | |
| add(new THREE.Mesh(new THREE.BoxGeometry(0.55, 7.6, 0.3), woodMat), 0, 0, 0, prop); | |
| const blade2 = add(new THREE.Mesh(new THREE.BoxGeometry(0.55, 7.6, 0.3), woodMat), 0, 0, 0, prop); | |
| blade2.rotation.z = Math.PI/2; | |
| add(new THREE.Mesh(new THREE.ConeGeometry(0.75, 1.5, 8), accentMat), 0, 0, 0.55, prop).rotation.x = Math.PI/2; | |
| const disc = new THREE.Mesh( | |
| new THREE.CircleGeometry(3.9, 16), | |
| new THREE.MeshBasicMaterial({ color:0xd8c8a0, transparent:true, opacity:0, side:THREE.DoubleSide, depthWrite:false })); | |
| disc.position.z = 0.3; | |
| prop.add(disc); | |
| g.add(prop); | |
| // insignia | |
| if (opts.cross){ | |
| const cm = new THREE.MeshLambertMaterial({ color:0x1a1a1a }); | |
| for (const sx of [-8.5, 8.5]){ | |
| add(new THREE.Mesh(new THREE.BoxGeometry(5.4, 0.12, 1.5), cm), sx, 2.58, 0.4); | |
| add(new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.12, 5.4), cm), sx, 2.58, 0.4); | |
| } | |
| } else { | |
| const blue = new THREE.MeshLambertMaterial({ color:0x2a4a9a }); | |
| const red = new THREE.MeshLambertMaterial({ color:0xb03a2a }); | |
| for (const sx of [-8.5, 8.5]){ | |
| const b = add(new THREE.Mesh(new THREE.CircleGeometry(1.7, 12), blue), sx, 2.56, 0.4); b.rotation.x = -Math.PI/2; | |
| const r = add(new THREE.Mesh(new THREE.CircleGeometry(0.95, 12), red), sx, 2.58, 0.4); r.rotation.x = -Math.PI/2; | |
| } | |
| } | |
| return { group:g, prop, disc }; | |
| } | |
| // ============================== game state ============================== | |
| const S = { state:'menu', score:0, kills:0, deaths:0, camMode:0, msgT:0, boundT:0 }; | |
| const player = { | |
| plane: makeBiplane({ wing:0x9a8a4a, body:0x77683a, accent:0xb04a2a, cross:false }), | |
| pos: V3(), quat: new THREE.Quaternion(), vel: V3(), | |
| speed: 60, throttle: 0.7, health: 100, | |
| fireT: 0, gunSide: 1, dead: false, respawnT: 0, smokeT: 0, invuln: 0 | |
| }; | |
| scene.add(player.plane.group); | |
| function makeEnemy(){ | |
| const plane = makeBiplane({ wing:0xa63a2a, body:0x8f2f22, accent:0x2a2a2a, cross:true }); | |
| scene.add(plane.group); | |
| const e = { | |
| plane, pos: V3(), quat: new THREE.Quaternion(), | |
| speed: 62, health: 36, dead: false, respawnT: 0, | |
| burst: 0, cool: rand(1,3), fireT: 0, smokeT: 0, | |
| wanderDir: V3(0,0,1), wanderT: 0 | |
| }; | |
| spawnEnemyFar(e); | |
| return e; | |
| } | |
| function spawnEnemyFar(e){ | |
| const ang = rand(0, TAU), r = rand(900, 1500); | |
| const x = clamp(player.pos.x + Math.cos(ang)*r, -BOUND, BOUND); | |
| const z = clamp(player.pos.z + Math.sin(ang)*r, -BOUND, BOUND); | |
| const y = Math.max(terrainHeight(x, z) + 180, player.pos.y + rand(-60, 120)); | |
| e.pos.set(x, clamp(y, 120, 520), z); | |
| e.health = 36; e.dead = false; e.speed = 62; | |
| e.quat.setFromAxisAngle(Y_AXIS, rand(0, TAU)); | |
| e.burst = 0; e.cool = rand(1.5, 3); | |
| e.plane.group.visible = true; | |
| } | |
| const enemies = []; | |
| for (let i=0;i<4;i++) enemies.push(makeEnemy()); | |
| // bullets | |
| const bulletGeo = new THREE.BoxGeometry(0.4, 0.4, 5); | |
| const bulletMatP = new THREE.MeshBasicMaterial({ color:0xffe14a }); | |
| const bulletMatE = new THREE.MeshBasicMaterial({ color:0xff5a33 }); | |
| const bulletFree = [], bullets = []; | |
| function fireBullet(pos, dir, speed, friendly){ | |
| let mesh; | |
| if (bulletFree.length) mesh = bulletFree.pop(); | |
| else { | |
| mesh = new THREE.Mesh(bulletGeo, null); | |
| scene.add(mesh); | |
| } | |
| mesh.material = friendly ? bulletMatP : bulletMatE; | |
| mesh.visible = true; | |
| mesh.position.copy(pos); | |
| mesh.quaternion.setFromUnitVectors(Z_AXIS, dir); | |
| bullets.push({ mesh, vel: dir.clone().multiplyScalar(speed), life: 1.9, friendly }); | |
| } | |
| function killBullet(i){ | |
| const b = bullets[i]; | |
| b.mesh.visible = false; | |
| bulletFree.push(b.mesh); | |
| bullets.splice(i, 1); | |
| } | |
| // ============================== input ============================== | |
| const keys = Object.create(null); | |
| const GAME_KEYS = new Set(['KeyW','KeyS','KeyA','KeyD','KeyQ','KeyE','Space','ShiftLeft','ShiftRight', | |
| 'ControlLeft','ControlRight','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','KeyC','KeyM','KeyP','Enter','KeyR','KeyF']); | |
| window.addEventListener('keydown', e => { | |
| if (GAME_KEYS.has(e.code)) e.preventDefault(); | |
| if (e.repeat) { keys[e.code] = true; return; } | |
| keys[e.code] = true; | |
| if (e.code === 'Enter') onEnter(); | |
| if (e.code === 'KeyC') S.camMode = (S.camMode + 1) % 2; | |
| if (e.code === 'KeyM'){ const m = AudioSys.toggle(); showMsg(m ? 'SOUND OFF' : 'SOUND ON', 1.2); } | |
| if (e.code === 'KeyP' && (S.state === 'fly' || S.state === 'pause')) togglePause(); | |
| }); | |
| window.addEventListener('keyup', e => { keys[e.code] = false; }); | |
| window.addEventListener('blur', () => { for (const k in keys) keys[k] = false; }); | |
| // ============================== UI ============================== | |
| const el = id => document.getElementById(id); | |
| const ui = { | |
| score: el('score'), kills: el('kills'), deaths: el('deaths'), | |
| speed: el('speed'), alt: el('alt'), thrPct: el('thrPct'), hpPct: el('hpPct'), | |
| throttleFill: el('throttleFill'), healthFill: el('healthFill'), | |
| msg: el('msg'), warn: el('warn'), flash: el('flash'), | |
| crosshair: el('crosshair'), lockDist: el('lockDist'), | |
| overlay: el('overlay'), goLine: el('goLine'), finalScore: el('finalScore') | |
| }; | |
| const msgEl = ui.msg; | |
| function showMsg(text, secs){ | |
| msgEl.textContent = text; | |
| msgEl.classList.add('show'); | |
| S.msgT = secs || 2.4; | |
| } | |
| function damageFlash(){ | |
| ui.flash.style.transition = 'none'; | |
| ui.flash.style.opacity = 0.65; | |
| requestAnimationFrame(() => { | |
| ui.flash.style.transition = 'opacity .6s'; | |
| ui.flash.style.opacity = 0; | |
| }); | |
| } | |
| const overlayTitles = { | |
| menu: ['SKY ACE', '— a Great War biplane dogfight —'], | |
| dead: ['SHOT DOWN', '— the sky claims another —'], | |
| pause: ['PAUSED', '— catch your breath —'] | |
| }; | |
| function showOverlay(kind){ | |
| ui.overlay.classList.remove('hidden'); | |
| ui.overlay.querySelector('h1').textContent = overlayTitles[kind][0]; | |
| ui.overlay.querySelector('.sub').textContent = overlayTitles[kind][1]; | |
| if (kind === 'dead'){ | |
| ui.finalScore.style.display = 'block'; | |
| ui.finalScore.textContent = `SCORE ${S.score} · KILLS ${S.kills}`; | |
| ui.goLine.textContent = 'PRESS ENTER TO FLY AGAIN'; | |
| } else { | |
| ui.finalScore.style.display = 'none'; | |
| ui.goLine.textContent = kind === 'pause' ? 'PRESS P TO RESUME' : 'PRESS ENTER TO FLY'; | |
| } | |
| } | |
| function hideOverlay(){ ui.overlay.classList.add('hidden'); } | |
| function onEnter(){ | |
| AudioSys.init(); | |
| if (S.state === 'menu' || S.state === 'dead'){ | |
| if (S.state === 'dead'){ S.score = 0; S.kills = 0; S.deaths = 0; } | |
| resetPlayer(); | |
| hideOverlay(); | |
| S.state = 'fly'; | |
| showMsg('HUNT THE RED PLANES', 2.5); | |
| } | |
| } | |
| function togglePause(){ | |
| if (S.state === 'fly'){ S.state = 'pause'; showOverlay('pause'); } | |
| else if (S.state === 'pause'){ S.state = 'fly'; hideOverlay(); } | |
| } | |
| // ============================== player logic ============================== | |
| function resetPlayer(){ | |
| const x = rand(-300, 300), z = rand(-300, 300); | |
| player.pos.set(x, Math.max(terrainHeight(x, z) + 190, 200), z); | |
| player.quat.setFromAxisAngle(Y_AXIS, rand(0, TAU)); | |
| player.vel.set(0,0,0); | |
| player.speed = 62; player.throttle = 0.7; player.health = 100; | |
| player.dead = false; player.invuln = 2.5; player.smokeT = 0; | |
| player.plane.group.visible = true; | |
| snapCamera(); | |
| } | |
| function killPlayer(cause){ | |
| if (player.dead) return; | |
| player.dead = true; | |
| player.respawnT = 1.5; | |
| S.deaths++; | |
| spawnExplosion(player.pos, 1.6); | |
| AudioSys.explosion(true); | |
| showMsg(cause, 2); | |
| player.plane.group.visible = false; | |
| } | |
| const _fwd = V3(), _up = V3(), _dq = new THREE.Quaternion(), _desired = V3(); | |
| function updatePlayer(dt){ | |
| if (player.dead){ | |
| player.respawnT -= dt; | |
| if (player.respawnT <= 0 && S.state === 'fly') { S.state = 'dead'; showOverlay('dead'); } | |
| return; | |
| } | |
| player.invuln = Math.max(0, player.invuln - dt); | |
| // throttle | |
| if (keys.ShiftLeft || keys.ShiftRight) player.throttle = clamp(player.throttle + 0.45*dt, 0, 1); | |
| if (keys.ControlLeft || keys.ControlRight) player.throttle = clamp(player.throttle - 0.45*dt, 0, 1); | |
| // control inputs | |
| const pitchIn = (keys.KeyS || keys.ArrowDown ? 1 : 0) - (keys.KeyW || keys.ArrowUp ? 1 : 0); | |
| const rollIn = (keys.KeyD || keys.ArrowRight ? 1 : 0) - (keys.KeyA || keys.ArrowLeft ? 1 : 0); | |
| const yawIn = (keys.KeyE ? 1 : 0) - (keys.KeyQ ? 1 : 0); | |
| const authority = clamp(player.speed / 55, 0.35, 1.25); | |
| const pitchRate = 1.45 * authority, rollRate = 2.3 * authority, yawRate = 0.7 * authority; | |
| if (pitchIn) { _dq.setFromAxisAngle(X_AXIS, -pitchIn * pitchRate * dt); player.quat.multiply(_dq); } | |
| if (rollIn) { _dq.setFromAxisAngle(Z_AXIS, -rollIn * rollRate * dt); player.quat.multiply(_dq); } | |
| if (yawIn) { _dq.setFromAxisAngle(Y_AXIS, yawIn * yawRate * dt); player.quat.multiply(_dq); } | |
| _fwd.set(0,0,1).applyQuaternion(player.quat); | |
| // speed: thrust - drag - gravity along pitch | |
| player.speed += (player.throttle * 30 - 0.0021 * player.speed * player.speed - _fwd.y * 28) * dt; | |
| player.speed = clamp(player.speed, 0, 150); | |
| // stall: nose drops, plane sinks | |
| const stallK = clamp(player.speed / 26, 0, 1); | |
| if (player.speed < 26){ | |
| const k = (26 - player.speed) / 26; | |
| _dq.setFromAxisAngle(X_AXIS, k * 0.9 * dt); // nose down | |
| player.quat.multiply(_dq); | |
| _fwd.set(0,0,1).applyQuaternion(player.quat); | |
| } | |
| // velocity follows nose ("grip" scales with airspeed) | |
| _desired.copy(_fwd).multiplyScalar(player.speed); | |
| const grip = 1 - Math.exp(-4.2 * Math.max(stallK, 0.12) * dt); | |
| player.vel.lerp(_desired, grip); | |
| if (player.speed < 26) player.vel.y -= 22 * (1 - stallK) * dt; | |
| player.pos.addScaledVector(player.vel, dt); | |
| // bounds | |
| if (Math.abs(player.pos.x) > BOUND || Math.abs(player.pos.z) > BOUND){ | |
| player.pos.x = clamp(player.pos.x, -BOUND, BOUND); | |
| player.pos.z = clamp(player.pos.z, -BOUND, BOUND); | |
| S.boundT -= dt; | |
| if (S.boundT <= 0){ showMsg('TURN BACK — LEAVING THE FRONT', 2); S.boundT = 5; } | |
| } | |
| // terrain collision | |
| const groundH = terrainHeight(player.pos.x, player.pos.z); | |
| if (player.pos.y < groundH + 2.5){ | |
| killPlayer('CRASHED INTO THE GROUND'); | |
| return; | |
| } | |
| if (player.pos.y < 2) { player.pos.y = 2; } | |
| // damage smoke | |
| if (player.health < 45){ | |
| player.smokeT -= dt; | |
| if (player.smokeT <= 0){ | |
| player.smokeT = 0.09; | |
| spawnSmoke(player.pos.clone().addScaledVector(_fwd, 4), 1); | |
| } | |
| } | |
| // firing | |
| player.fireT -= dt; | |
| if (keys.Space && player.fireT <= 0){ | |
| player.fireT = 0.11; | |
| player.gunSide *= -1; | |
| const muzzle = V3(player.gunSide * 3.2, 0.3, 2.5).applyQuaternion(player.quat).add(player.pos); | |
| const dir = _fwd.clone().add(V3(rand(-0.008,0.008), rand(-0.008,0.008), rand(-0.008,0.008))).normalize(); | |
| fireBullet(muzzle, dir, 400 + player.speed * 0.6, true); | |
| AudioSys.gun(); | |
| } | |
| // apply to mesh + prop spin | |
| player.plane.group.position.copy(player.pos); | |
| player.plane.group.quaternion.copy(player.quat); | |
| player.plane.prop.rotation.z += (12 + player.throttle * 55) * dt; | |
| player.plane.disc.material.opacity = 0.06 + player.throttle * 0.22; | |
| } | |
| // ============================== enemy AI ============================== | |
| const _m4 = new THREE.Matrix4(), _tq = new THREE.Quaternion(), _toP = V3(), _lead = V3(), _edir = V3(); | |
| function updateEnemy(e, dt){ | |
| if (e.dead){ | |
| e.respawnT -= dt; | |
| if (e.respawnT <= 0) spawnEnemyFar(e); | |
| return; | |
| } | |
| const flying = S.state === 'fly' && !player.dead; | |
| const distP = e.pos.distanceTo(player.pos); | |
| const engage = flying && distP < 950; | |
| if (engage){ | |
| _toP.subVectors(player.pos, e.pos); | |
| _lead.copy(player.pos).addScaledVector(player.vel, clamp(distP / 430, 0, 2) * 0.55); | |
| _edir.subVectors(_lead, e.pos).normalize(); | |
| // fire control | |
| e.cool -= dt; | |
| if (e.burst <= 0 && e.cool <= 0 && distP < 460){ | |
| _edir.normalize(); | |
| const fwdE = V3(0,0,1).applyQuaternion(e.quat); | |
| if (fwdE.angleTo(_toP.clone().normalize()) < 0.16) e.burst = rand(0.7, 1.3); | |
| } | |
| if (e.burst > 0){ | |
| e.burst -= dt; | |
| if (e.burst <= 0) e.cool = rand(1.6, 3.2); | |
| e.fireT -= dt; | |
| if (e.fireT <= 0 && distP < 480){ | |
| e.fireT = 0.24; | |
| const muzzle = e.pos.clone().addScaledVector(V3(0,0,1).applyQuaternion(e.quat), 5.5); | |
| const dir = _lead.clone().sub(muzzle).normalize() | |
| .add(V3(rand(-0.035,0.035), rand(-0.035,0.035), rand(-0.035,0.035))).normalize(); | |
| fireBullet(muzzle, dir, 360 + e.speed * 0.5, false); | |
| AudioSys.gun(); | |
| } | |
| } | |
| } else { | |
| // wander | |
| e.wanderT -= dt; | |
| if (e.wanderT <= 0){ | |
| e.wanderT = rand(2.5, 5); | |
| const yaw = rand(0, TAU); | |
| e.wanderDir.set(Math.sin(yaw), rand(-0.18, 0.22), Math.cos(yaw)).normalize(); | |
| } | |
| _edir.copy(e.wanderDir); | |
| // steer home if far | |
| if (Math.abs(e.pos.x) > 1600 || Math.abs(e.pos.z) > 1600){ | |
| _edir.set(-e.pos.x, rand(-0.1,0.2), -e.pos.z).normalize(); | |
| } | |
| } | |
| // terrain avoidance | |
| const agl = e.pos.y - terrainHeight(e.pos.x, e.pos.z); | |
| if (agl < 70){ | |
| _edir.y = lerp(_edir.y, 0.55, clamp((70 - agl) / 70, 0, 1)); | |
| _edir.normalize(); | |
| } | |
| // stay under clouds / above death | |
| if (e.pos.y > 540) _edir.y = Math.min(_edir.y, -0.1); | |
| // steer toward desired direction (limited turn rate via slerp) | |
| _m4.lookAt(_edir, V3(0,0,0), Y_AXIS); | |
| _tq.setFromRotationMatrix(_m4); | |
| e.quat.slerp(_tq, 1 - Math.exp(-(engage ? 1.35 : 0.8) * dt)); | |
| _edir.set(0,0,1).applyQuaternion(e.quat); // actual forward | |
| e.speed = lerp(e.speed, 64 - _edir.y * 26, dt * 0.6); | |
| e.pos.addScaledVector(_edir, e.speed * dt); | |
| e.pos.x = clamp(e.pos.x, -BOUND, BOUND); | |
| e.pos.z = clamp(e.pos.z, -BOUND, BOUND); | |
| if (agl < 3){ // scraped the ground | |
| damageEnemy(e, 999); | |
| return; | |
| } | |
| // damage smoke | |
| if (e.health < 15){ | |
| e.smokeT -= dt; | |
| if (e.smokeT <= 0){ e.smokeT = 0.1; spawnSmoke(e.pos.clone(), 1); } | |
| } | |
| // ramming | |
| if (flying && distP < 8){ | |
| damageEnemy(e, 40); | |
| damagePlayer(35); | |
| } | |
| e.plane.group.position.copy(e.pos); | |
| e.plane.group.quaternion.copy(e.quat); | |
| e.plane.prop.rotation.z += 40 * dt; | |
| e.plane.disc.material.opacity = 0.18; | |
| } | |
| function damageEnemy(e, dmg){ | |
| if (e.dead) return; | |
| e.health -= dmg; | |
| if (e.health <= 0){ | |
| e.dead = true; | |
| e.respawnT = rand(4, 7); | |
| e.plane.group.visible = false; | |
| spawnExplosion(e.pos, 1.3); | |
| AudioSys.explosion(false); | |
| if (dmg !== 999){ // crashed by itself: no score | |
| S.score += 100; S.kills++; | |
| showMsg('ENEMY DOWN! +100', 1.8); | |
| } | |
| } | |
| } | |
| function damagePlayer(dmg){ | |
| if (player.dead || player.invuln > 0) return; | |
| player.health -= dmg; | |
| damageFlash(); | |
| AudioSys.hit(); | |
| if (player.health <= 0){ | |
| player.health = 0; | |
| killPlayer('SHOT DOWN'); | |
| } | |
| } | |
| // ============================== bullets ============================== | |
| function updateBullets(dt){ | |
| for (let i = bullets.length - 1; i >= 0; i--){ | |
| const b = bullets[i]; | |
| b.life -= dt; | |
| b.mesh.position.addScaledVector(b.vel, dt); | |
| const p = b.mesh.position; | |
| let dead = b.life <= 0; | |
| if (!dead && p.y < terrainHeight(p.x, p.z) + 0.5){ | |
| spawnSpark(p); | |
| dead = true; | |
| } | |
| if (!dead){ | |
| if (b.friendly){ | |
| for (const e of enemies){ | |
| if (!e.dead && p.distanceToSquared(e.pos) < 22){ | |
| damageEnemy(e, 12); | |
| spawnSpark(p); | |
| dead = true; | |
| break; | |
| } | |
| } | |
| } else if (!player.dead && player.invuln <= 0 && p.distanceToSquared(player.pos) < 20){ | |
| damagePlayer(6); | |
| spawnSpark(p); | |
| dead = true; | |
| } | |
| } | |
| if (dead) killBullet(i); | |
| } | |
| } | |
| // ============================== camera ============================== | |
| const _camTarget = V3(), _lookAt = V3(), _planeUp = V3(), _camUp = V3(); | |
| let camSnapped = false; | |
| function snapCamera(){ camSnapped = false; } | |
| function updateCamera(dt, tSec){ | |
| if (S.state === 'menu'){ | |
| const a = tSec * 0.12; | |
| _camTarget.set(player.pos.x + Math.sin(a)*46, player.pos.y + 14, player.pos.z + Math.cos(a)*46); | |
| camera.position.lerp(_camTarget, 1 - Math.exp(-2*dt)); | |
| camera.up.set(0,1,0); | |
| camera.lookAt(player.pos); | |
| return; | |
| } | |
| _fwd.set(0,0,1).applyQuaternion(player.quat); | |
| _planeUp.set(0,1,0).applyQuaternion(player.quat); | |
| if (S.camMode === 1 && !player.dead){ | |
| // cockpit | |
| _camTarget.copy(player.pos).addScaledVector(_planeUp, 1.55).addScaledVector(_fwd, 0.6); | |
| camera.position.copy(_camTarget); | |
| camera.up.copy(_planeUp); | |
| _lookAt.copy(player.pos).addScaledVector(_fwd, 60).addScaledVector(_planeUp, 0.6); | |
| camera.lookAt(_lookAt); | |
| return; | |
| } | |
| // chase | |
| _camTarget.copy(player.pos).addScaledVector(_fwd, -21).addScaledVector(_planeUp, 6.8); | |
| const k = camSnapped ? 1 - Math.exp(-5*dt) : 1; | |
| camera.position.lerp(_camTarget, k); | |
| camSnapped = true; | |
| _camUp.set(0,1,0).lerp(_planeUp, 0.38).normalize(); | |
| camera.up.lerp(_camUp, 1 - Math.exp(-3.5*dt)).normalize(); | |
| _lookAt.copy(player.pos).addScaledVector(_fwd, 16).addScaledVector(_planeUp, 1.6); | |
| camera.lookAt(_lookAt); | |
| } | |
| // ============================== HUD ============================== | |
| const radar = el('radar'), rctx = radar.getContext('2d'); | |
| function updateHUD(dt){ | |
| ui.score.textContent = S.score; | |
| ui.kills.textContent = S.kills; | |
| ui.deaths.textContent = S.deaths; | |
| ui.speed.textContent = Math.round(player.speed * 2.2); | |
| ui.alt.textContent = Math.max(0, Math.round(player.pos.y)); | |
| ui.thrPct.textContent = Math.round(player.throttle * 100) + '%'; | |
| ui.throttleFill.style.width = (player.throttle * 100) + '%'; | |
| const hp = Math.max(0, player.health); | |
| ui.hpPct.textContent = Math.round(hp) + '%'; | |
| ui.healthFill.style.width = hp + '%'; | |
| ui.healthFill.style.background = hp > 50 ? '#69d84f' : hp > 25 ? '#e8b83a' : '#e84f3a'; | |
| // warnings | |
| const agl = player.pos.y - terrainHeight(player.pos.x, player.pos.z); | |
| let warnText = null; | |
| if (!player.dead && S.state === 'fly'){ | |
| if (player.speed < 26) warnText = '⚠ STALL'; | |
| else if (agl < 45) warnText = '⚠ PULL UP'; | |
| } | |
| if (warnText){ ui.warn.textContent = warnText; ui.warn.style.display = 'block'; } | |
| else ui.warn.style.display = 'none'; | |
| // lock indicator | |
| let locked = false; | |
| if (!player.dead){ | |
| _fwd.set(0,0,1).applyQuaternion(player.quat); | |
| let best = 1e9; | |
| for (const e of enemies){ | |
| if (e.dead) continue; | |
| _toP.subVectors(e.pos, player.pos); | |
| const d = _toP.length(); | |
| if (d < 520 && _fwd.angleTo(_toP.normalize()) < 0.13 && d < best){ best = d; locked = true; } | |
| } | |
| if (locked){ | |
| ui.lockDist.style.display = 'block'; | |
| ui.lockDist.textContent = Math.round(best) + ' m'; | |
| } | |
| } | |
| ui.crosshair.classList.toggle('lock', locked); | |
| if (!locked) ui.lockDist.style.display = 'none'; | |
| // message timer | |
| if (S.msgT > 0){ | |
| S.msgT -= dt; | |
| if (S.msgT <= 0) msgEl.classList.remove('show'); | |
| } | |
| drawRadar(); | |
| } | |
| function drawRadar(){ | |
| const W = radar.width, cx = W/2, cy = W/2, R = W/2 - 6, RANGE = 1000; | |
| rctx.clearRect(0, 0, W, W); | |
| rctx.strokeStyle = 'rgba(140,220,150,.35)'; | |
| rctx.lineWidth = 1; | |
| rctx.beginPath(); rctx.arc(cx, cy, R, 0, TAU); rctx.stroke(); | |
| rctx.beginPath(); rctx.arc(cx, cy, R*0.5, 0, TAU); rctx.stroke(); | |
| rctx.beginPath(); rctx.moveTo(cx, cy-R); rctx.lineTo(cx, cy+R); | |
| rctx.moveTo(cx-R, cy); rctx.lineTo(cx+R, cy); rctx.stroke(); | |
| _fwd.set(0,0,1).applyQuaternion(player.quat); | |
| const psi = Math.atan2(_fwd.x, _fwd.z), cosP = Math.cos(psi), sinP = Math.sin(psi); | |
| for (const e of enemies){ | |
| if (e.dead) continue; | |
| const dx = e.pos.x - player.pos.x, dz = e.pos.z - player.pos.z; | |
| const rx = dx*cosP - dz*sinP, rz = dx*sinP + dz*cosP; | |
| const rr = Math.hypot(rx, rz); | |
| if (rr > RANGE) continue; | |
| const k = (rr / RANGE) * R; | |
| const x = cx + (rx / (rr || 1)) * k, y = cy - (rz / (rr || 1)) * k; | |
| rctx.fillStyle = '#ff4433'; | |
| rctx.beginPath(); rctx.arc(x, y, 3.2, 0, TAU); rctx.fill(); | |
| } | |
| // player arrow (points up) | |
| rctx.fillStyle = '#bfe8a0'; | |
| rctx.beginPath(); | |
| rctx.moveTo(cx, cy-6); rctx.lineTo(cx-4.5, cy+5); rctx.lineTo(cx+4.5, cy+5); | |
| rctx.closePath(); rctx.fill(); | |
| } | |
| // ============================== main loop ============================== | |
| resetPlayer(); | |
| S.state = 'menu'; | |
| showOverlay('menu'); | |
| const clock = new THREE.Clock(); | |
| let elapsed = 0; | |
| function animate(){ | |
| requestAnimationFrame(animate); | |
| const dt = Math.min(clock.getDelta(), 0.05); | |
| elapsed += dt; | |
| if (S.state === 'fly'){ | |
| updatePlayer(dt); | |
| for (const e of enemies) updateEnemy(e, dt); | |
| updateBullets(dt); | |
| AudioSys.engine(player.throttle, player.speed, !player.dead); | |
| } else if (S.state === 'menu'){ | |
| // idle spin of prop on the menu plane | |
| player.plane.prop.rotation.z += 30 * dt; | |
| player.plane.disc.material.opacity = 0.16; | |
| AudioSys.engine(0, 0, false); | |
| } else { | |
| AudioSys.engine(0, 0, false); | |
| } | |
| firePool.update(dt); | |
| smokePool.update(dt); | |
| // cloud drift | |
| for (const c of clouds){ | |
| c.position.x += 2.2 * dt; | |
| if (c.position.x > 2000) c.position.x = -2000; | |
| } | |
| // sky follows camera | |
| sky.position.set(camera.position.x, 0, camera.position.z); | |
| updateCamera(dt, elapsed); | |
| updateHUD(dt); | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // debug hook for headless tests | |
| window.game = { | |
| get state(){ return S.state; }, | |
| get score(){ return S.score; }, | |
| get kills(){ return S.kills; }, | |
| player, | |
| enemies, | |
| terrainHeight, | |
| start(){ onEnter(); }, | |
| teleportEnemyInFront(){ | |
| const e = enemies[0]; | |
| if (e.dead) spawnEnemyFar(e); | |
| _fwd.set(0,0,1).applyQuaternion(player.quat); | |
| e.pos.copy(player.pos).addScaledVector(_fwd, 260); | |
| e.pos.y = player.pos.y; | |
| e.quat.copy(player.quat); | |
| e.health = 36; | |
| }, | |
| killSelf(){ damagePlayer(999); } | |
| }; | |
| console.log('Sky Ace loaded — press ENTER to fly'); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment