Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 20, 2026 11:49
Show Gist options
  • Select an option

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

Select an option

Save senko/c2337c99fd9646d56ac33b2a8187a2bd to your computer and use it in GitHub Desktop.
Flying simulator by Qwen 3.6 Max (preview)
<!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> &nbsp;✕ <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 &amp; 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