Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save senko/41ee802251cbb723088d73e48835d58a to your computer and use it in GitHub Desktop.
Flying simulator by Claude Opus 4.8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>OPUS ACES — WW1 Dogfight</title>
<style>
:root { --ink:#f4e9c9; --accent:#e0b64a; --danger:#e05a3a; }
* { margin:0; padding:0; box-sizing:border-box; }
html,body { width:100%; height:100%; overflow:hidden; background:#0a0c10; }
body { font-family: "Courier New", ui-monospace, monospace; color:var(--ink); }
#game { position:fixed; inset:0; }
canvas { display:block; }
/* ---------- HUD ---------- */
#hud { position:fixed; inset:0; pointer-events:none; z-index:10;
text-shadow:0 1px 2px rgba(0,0,0,.8); letter-spacing:.5px; }
.panel { position:absolute; background:rgba(20,24,20,.32);
border:1px solid rgba(224,182,74,.35); border-radius:6px;
padding:8px 12px; backdrop-filter:blur(1px); }
#topleft { top:14px; left:14px; }
#topleft .big { font-size:22px; color:var(--accent); font-weight:bold; }
#topleft .sub { font-size:12px; opacity:.85; margin-top:2px; }
#radarWrap { top:14px; right:14px; padding:6px; }
#radar { display:block; border-radius:50%; }
#radarWrap .lbl { text-align:center; font-size:10px; opacity:.7; margin-top:4px; letter-spacing:2px; }
#bottomleft { bottom:14px; left:14px; width:210px; }
#bottomright { bottom:14px; right:14px; width:210px; text-align:right; }
.gauge { font-size:13px; margin:3px 0; display:flex; justify-content:space-between; }
.gauge b { color:var(--accent); font-weight:bold; }
.bar { height:12px; background:rgba(0,0,0,.45); border:1px solid rgba(224,182,74,.4);
border-radius:3px; margin-top:5px; overflow:hidden; }
.bar > i { display:block; height:100%; width:50%; transition:width .1s linear; }
#thrFill { background:linear-gradient(90deg,#6a8f4a,#b7d46a); }
#hpFill { background:linear-gradient(90deg,#e05a3a,#f0a03a); }
.barlabel { font-size:11px; opacity:.8; margin-top:6px; }
#crosshair { position:absolute; top:50%; left:50%; width:44px; height:44px;
transform:translate(-50%,-50%); }
#crosshair::before, #crosshair::after { content:""; position:absolute; background:rgba(224,182,74,.75); }
#crosshair::before { left:50%; top:0; width:1.5px; height:100%; transform:translateX(-50%); }
#crosshair::after { top:50%; left:0; height:1.5px; width:100%; transform:translateY(-50%); }
#chDot { position:absolute; top:50%; left:50%; width:6px; height:6px; border-radius:50%;
transform:translate(-50%,-50%); border:1.5px solid rgba(224,182,74,.9); }
#msg { position:absolute; top:32%; left:50%; transform:translate(-50%,-50%);
font-size:34px; color:var(--danger); font-weight:bold; text-align:center;
opacity:0; transition:opacity .25s; text-shadow:0 2px 8px rgba(0,0,0,.9); }
#msg .s { display:block; font-size:15px; color:var(--ink); opacity:.9; margin-top:8px; }
#hint { position:absolute; bottom:14px; left:50%; transform:translateX(-50%);
font-size:11px; opacity:.55; text-align:center; }
#flash { position:fixed; inset:0; background:#e05a3a; opacity:0; pointer-events:none; z-index:9; }
/* ---------- Start overlay ---------- */
#overlay { position:fixed; inset:0; z-index:20; display:flex; flex-direction:column;
align-items:center; justify-content:center; text-align:center;
background:radial-gradient(circle at 50% 35%, rgba(30,60,110,.55), rgba(8,10,14,.92));
backdrop-filter:blur(2px); }
#overlay h1 { font-size:64px; letter-spacing:8px; color:var(--accent);
text-shadow:0 3px 0 #7a4a12, 0 6px 18px rgba(0,0,0,.6); }
#overlay h2 { font-size:17px; letter-spacing:6px; opacity:.8; margin-top:4px; font-weight:normal; }
#overlay .keys { margin:26px 0; display:grid; grid-template-columns:auto auto; gap:6px 26px;
font-size:14px; text-align:left; }
#overlay .keys span:nth-child(odd){ color:var(--accent); text-align:right; font-weight:bold; }
#overlay .go { margin-top:8px; padding:12px 26px; font-size:16px; font-weight:bold;
border:2px solid var(--accent); border-radius:8px; color:var(--ink);
background:rgba(224,182,74,.12); animation:pulse 1.4s infinite; }
@keyframes pulse { 0%,100%{opacity:.55} 50%{opacity:1} }
#err { color:#ff8a6a; font-size:13px; max-width:520px; margin-top:14px; white-space:pre-wrap; }
</style>
</head>
<body>
<div id="game"></div>
<div id="hud">
<div id="topleft" class="panel">
<div class="big">SCORE <span id="score">0</span></div>
<div class="sub">ENEMIES DOWNED <span id="kills">0</span> · CRASHES <span id="deaths">0</span></div>
</div>
<div id="radarWrap" class="panel">
<canvas id="radar" width="150" height="150"></canvas>
<div class="lbl">RADAR</div>
</div>
<div id="crosshair"><div id="chDot"></div></div>
<div id="bottomleft" class="panel">
<div class="gauge"><span>SPEED</span><b><span id="spd">0</span> kn</b></div>
<div class="barlabel">THROTTLE <span id="thrPct">70</span>%</div>
<div class="bar"><i id="thrFill"></i></div>
</div>
<div id="bottomright" class="panel">
<div class="gauge"><span>ALT</span><b><span id="alt">0</span> ft</b></div>
<div class="barlabel">HULL <span id="hpPct">100</span>%</div>
<div class="bar"><i id="hpFill"></i></div>
</div>
<div id="msg"></div>
<div id="hint">W/S pitch · A/D roll · Q/E rudder · Shift/Ctrl throttle · SPACE fire · C camera · P pause</div>
</div>
<div id="flash"></div>
<div id="overlay">
<h1>OPUS ACES</h1>
<h2>WORLD WAR ONE DOGFIGHT</h2>
<div class="keys">
<span>W / S</span><span>pitch down / up (climb)</span>
<span>A / D</span><span>roll left / right</span>
<span>Q / E</span><span>rudder yaw</span>
<span>SHIFT / CTRL</span><span>throttle up / down (or R / F)</span>
<span>SPACE</span><span>fire machine guns</span>
<span>C</span><span>chase / cockpit camera</span>
<span>P</span><span>pause</span>
</div>
<div class="go">CLICK or PRESS ANY KEY TO TAKE OFF</div>
<div id="err"></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
"use strict";
(function () {
const overlay = document.getElementById('overlay');
const errBox = document.getElementById('err');
if (typeof THREE === 'undefined') {
errBox.textContent = "Could not load Three.js from the CDN.\nCheck your internet connection and reload.";
return;
}
try { main(); }
catch (e) {
errBox.textContent = "Error starting game:\n" + (e && e.stack ? e.stack : e);
console.error(e);
}
function main() {
// ============================================================= CONSTANTS
const WATER_Y = 0;
const T_SIZE = 6000; // terrain patch size (world units)
const T_SEG = 150; // terrain resolution
const T_CELL = T_SIZE / T_SEG;
const HORIZON = 0xcfe0ee;
const PITCH_RATE = 1.35, ROLL_RATE = 2.6, YAW_RATE = 0.7;
const G_ACCEL = 34;
const MAX_SPEED = 180, STALL = 20;
const BULLET_SPEED = 340, BULLET_LIFE = 2.2, HIT_R = 4.2;
const NUM_ENEMIES = 4;
const clamp = (v,a,b) => v < a ? a : v > b ? b : v;
const lerp = (a,b,t) => a + (b-a)*t;
// ============================================================= RENDERER / SCENE
const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
renderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById('game').appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(HORIZON, 900, 2800);
const camera = new THREE.PerspectiveCamera(62, window.innerWidth/window.innerHeight, 0.5, 5000);
camera.position.set(0, 150, 40);
// ---- sky dome (gradient) --------------------------------------------------
const skyGeo = new THREE.SphereGeometry(3600, 32, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite:false, fog:false,
uniforms: {
top: { value: new THREE.Color(0x2f66ad) },
bottom: { value: new THREE.Color(HORIZON) }
},
vertexShader: "varying vec3 vp; void main(){ vp=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }",
fragmentShader:
"varying vec3 vp; uniform vec3 top; uniform vec3 bottom;" +
"void main(){ float h = normalize(vp).y*0.5+0.5; vec3 c = mix(bottom, top, pow(clamp(h,0.0,1.0),0.55)); gl_FragColor = vec4(c,1.0); }"
});
const sky = new THREE.Mesh(skyGeo, skyMat);
scene.add(sky);
// ---- lights ---------------------------------------------------------------
const sun = new THREE.DirectionalLight(0xfff2d4, 1.05);
sun.position.set(0.5, 0.82, 0.3).multiplyScalar(1000);
scene.add(sun);
scene.add(new THREE.HemisphereLight(0xbcd3e8, 0x54683c, 0.62));
scene.add(new THREE.AmbientLight(0x404450, 0.35));
// visible sun disc
const sunDisc = new THREE.Mesh(new THREE.SphereGeometry(90, 16, 16),
new THREE.MeshBasicMaterial({ color:0xfff0c0, fog:false }));
scene.add(sunDisc);
// ---- water ----------------------------------------------------------------
const water = new THREE.Mesh(
new THREE.PlaneGeometry(T_SIZE*1.4, T_SIZE*1.4),
new THREE.MeshStandardMaterial({ color:0x1f6f8b, transparent:true, opacity:0.82,
roughness:0.28, metalness:0.15 }));
water.rotation.x = -Math.PI/2;
water.position.y = WATER_Y;
scene.add(water);
// ============================================================= TERRAIN (procedural, recentering)
function hash(x,z){ const n = Math.sin(x*127.1 + z*311.7) * 43758.5453; return n - Math.floor(n); }
function vnoise(x,z){
const xi=Math.floor(x), zi=Math.floor(z), xf=x-xi, zf=z-zi;
const tl=hash(xi,zi), tr=hash(xi+1,zi), bl=hash(xi,zi+1), br=hash(xi+1,zi+1);
const u=xf*xf*(3-2*xf), v=zf*zf*(3-2*zf);
return lerp(lerp(tl,tr,u), lerp(bl,br,u), v);
}
function fbm(x,z){ let a=0,amp=0.5,f=1; for(let i=0;i<5;i++){ a+=amp*vnoise(x*f,z*f); f*=2; amp*=0.5; } return a; }
function terrainHeight(x,z){
const f=0.0016;
let h = fbm(x*f, z*f); // 0..1
h = Math.pow(h, 1.55); // carve out valleys / water
return h*270 - 34; // ~ -34 .. 236
}
const _c = new THREE.Color();
function colorForHeight(h, out){
if (h < 3) _c.setHex(0xcabd86); // beach sand
else if (h < 70) _c.setHex(0x4c7838).lerp(_c.clone().setHex(0x6b9146), (h-3)/67);
else if (h < 150) _c.setHex(0x6b9146).lerp(_c.clone().setHex(0x7d6f56), (h-70)/80);
else if (h < 205) _c.setHex(0x7d6f56).lerp(_c.clone().setHex(0xb9b2a4), (h-150)/55);
else _c.setHex(0xf3f3f6); // snow caps
const j = (hash(h*3.1, h*1.7) - 0.5) * 0.08; // subtle noise
out[0] = clamp(_c.r + j,0,1); out[1] = clamp(_c.g + j,0,1); out[2] = clamp(_c.b + j,0,1);
}
const terrainGeo = new THREE.PlaneGeometry(T_SIZE, T_SIZE, T_SEG, T_SEG);
terrainGeo.rotateX(-Math.PI/2); // lie flat, +Y up
const tPos = terrainGeo.attributes.position;
const tColArr = new Float32Array(tPos.count * 3);
terrainGeo.setAttribute('color', new THREE.BufferAttribute(tColArr, 3));
const terrainMat = new THREE.MeshStandardMaterial({ vertexColors:true, flatShading:true,
roughness:0.95, metalness:0.0 });
const terrain = new THREE.Mesh(terrainGeo, terrainMat);
scene.add(terrain);
let terrainCellX = Infinity, terrainCellZ = Infinity;
const _col3 = [0,0,0];
function updateTerrain(px, pz){
const cx = Math.round(px / T_CELL) * T_CELL;
const cz = Math.round(pz / T_CELL) * T_CELL;
if (cx === terrainCellX && cz === terrainCellZ) return;
terrainCellX = cx; terrainCellZ = cz;
terrain.position.set(cx, 0, cz);
const arr = tPos.array;
for (let i=0; i<tPos.count; i++){
const wx = cx + arr[i*3];
const wz = cz + arr[i*3+2];
const h = terrainHeight(wx, wz);
arr[i*3+1] = h;
colorForHeight(h, _col3);
tColArr[i*3]=_col3[0]; tColArr[i*3+1]=_col3[1]; tColArr[i*3+2]=_col3[2];
}
tPos.needsUpdate = true;
terrainGeo.attributes.color.needsUpdate = true;
terrainGeo.computeVertexNormals();
}
// ---- clouds ---------------------------------------------------------------
const cloudMat = new THREE.MeshStandardMaterial({ color:0xffffff, roughness:1, metalness:0,
transparent:true, opacity:0.9 });
function makeCloud(){
const g = new THREE.Group();
const puffs = 4 + Math.floor(hash(g.uuid.length,3)*4);
for (let i=0;i<puffs;i++){
const r = 18 + Math.random()*26;
const m = new THREE.Mesh(new THREE.SphereGeometry(r, 7, 6), cloudMat);
m.position.set((Math.random()-0.5)*90, (Math.random()-0.5)*16, (Math.random()-0.5)*70);
m.scale.y = 0.6;
g.add(m);
}
return g;
}
for (let i=0;i<55;i++){
const c = makeCloud();
c.position.set((Math.random()-0.5)*5200, 160 + Math.random()*260, (Math.random()-0.5)*5200);
scene.add(c);
}
// ============================================================= BIPLANE FACTORY
function makeMat(hex, rough){ return new THREE.MeshStandardMaterial({ color:hex, roughness:rough!=null?rough:0.7, metalness:0.05, flatShading:true }); }
function createBiplane(pal){
const g = new THREE.Group();
const body = makeMat(pal.body), wing = makeMat(pal.wing), dark = makeMat(0x2a2a2e), acc = makeMat(pal.accent, 0.5);
// fuselage (nose points -Z)
const fus = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.62, 4.2, 10), body);
fus.rotation.x = Math.PI/2; g.add(fus);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.42, 1.0, 10), acc);
nose.rotation.x = -Math.PI/2; nose.position.z = -2.5; g.add(nose);
// engine hub + spinning propeller
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.16,0.16,0.3,8), dark);
hub.rotation.x = Math.PI/2; hub.position.z = -3.0; g.add(hub);
const prop = new THREE.Group();
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.16, 3.4, 0.06), dark);
prop.add(blade);
const blade2 = blade.clone(); blade2.rotation.z = Math.PI/2; prop.add(blade2);
prop.position.z = -3.05; g.add(prop);
// wings (top + bottom)
const topW = new THREE.Mesh(new THREE.BoxGeometry(7.2, 0.14, 1.35), wing);
topW.position.set(0, 1.15, -0.2); g.add(topW);
const botW = new THREE.Mesh(new THREE.BoxGeometry(6.6, 0.14, 1.25), wing);
botW.position.set(0, -0.25, -0.1); g.add(botW);
// struts
for (const sx of [-1.9, 1.9]){
const st = new THREE.Mesh(new THREE.BoxGeometry(0.07, 1.4, 0.07), dark);
st.position.set(sx, 0.45, -0.2); g.add(st);
}
// cabane centre strut + wire look
const cs = new THREE.Mesh(new THREE.BoxGeometry(0.08,1.4,0.08), dark);
cs.position.set(0,0.45,-0.2); g.add(cs);
// cockpit
const cp = new THREE.Mesh(new THREE.BoxGeometry(0.7,0.5,0.9), dark);
cp.position.set(0,0.55,0.5); g.add(cp);
// tail
const hStab = new THREE.Mesh(new THREE.BoxGeometry(2.6,0.1,0.9), wing);
hStab.position.set(0,0.1,1.95); g.add(hStab);
const vFin = new THREE.Mesh(new THREE.BoxGeometry(0.1,1.0,0.9), acc);
vFin.position.set(0,0.55,2.0); g.add(vFin);
// wheels
for (const wx of [-0.8, 0.8]){
const wl = new THREE.Mesh(new THREE.CylinderGeometry(0.34,0.34,0.14,10), dark);
wl.rotation.z = Math.PI/2; wl.position.set(wx,-0.95,-0.3); g.add(wl);
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.07,0.7,0.07), dark);
leg.position.set(wx,-0.6,-0.3); g.add(leg);
}
g.userData.prop = prop;
return g;
}
// ============================================================= PLAYER
const player = createBiplane({ body:0x2f5fa0, wing:0xe8ddb8, accent:0xd8b34a });
scene.add(player);
const plane = {
obj: player, speed: 95, throttle: 0.7, hp: 100, alive:true,
fireCd: 0, vel: new THREE.Vector3()
};
function resetPlayer(x, z){
const gy = terrainHeight(x, z);
player.position.set(x, Math.max(150, gy + 130), z); // never spawn inside terrain
player.quaternion.identity();
plane.speed = 95; plane.throttle = 0.7; plane.hp = 100; plane.alive = true; plane.fireCd = 0;
}
// pick a scenic first spawn: over land, facing toward higher ground
function findStart(){
let best = { x:0, z:0, score:-1 };
for (let i=0;i<500;i++){
const x = (Math.random()-0.5)*2600, z = (Math.random()-0.5)*2600;
const h = terrainHeight(x, z);
if (h < 25 || h > 150) continue; // coastal land, not underwater or peaks
const relief = Math.abs(terrainHeight(x+150, z+150) - h);
const s = h*0.5 + relief;
if (s > best.score) best = { x, z, score:s };
}
return best;
}
const start = findStart();
resetPlayer(start.x, start.z);
// face toward the higher of two flanking samples so hills sit ahead
{
const l = terrainHeight(start.x-200, start.z-400), r = terrainHeight(start.x+200, start.z-400);
player.rotateY(r > l ? -0.4 : 0.4);
}
// ============================================================= ENEMIES
const enemies = [];
function spawnEnemy(near){
const g = createBiplane({ body:0x8c2b24, wing:0x5f1a16, accent:0x1a1a1a });
scene.add(g);
const e = { obj:g, speed: 74 + Math.random()*18, hp:30, cooldown:1+Math.random()*2, state:'hunt', wander:new THREE.Vector3() };
placeEnemy(e, near);
enemies.push(e);
return e;
}
function placeEnemy(e, near){
const ang = Math.random()*Math.PI*2;
const dist = 600 + Math.random()*700;
const x = near.x + Math.cos(ang)*dist;
const z = near.z + Math.sin(ang)*dist;
const y = Math.max(terrainHeight(x,z)+80, near.y + (Math.random()-0.4)*120, 90);
e.obj.position.set(x, y, z);
e.obj.lookAt(near.x, near.y, near.z);
e.hp = 30; e.cooldown = 1.5 + Math.random()*2;
}
for (let i=0;i<NUM_ENEMIES;i++) spawnEnemy(player.position);
// ============================================================= BULLETS (pool)
const bulletGeo = new THREE.SphereGeometry(0.32, 6, 5);
const bulletMatP = new THREE.MeshBasicMaterial({ color:0xfff07a, fog:false });
const bulletMatE = new THREE.MeshBasicMaterial({ color:0xff6a3a, fog:false });
const bullets = [];
for (let i=0;i<200;i++){
const m = new THREE.Mesh(bulletGeo, bulletMatP);
m.visible = false; scene.add(m);
bullets.push({ mesh:m, vel:new THREE.Vector3(), life:0, active:false, fromPlayer:true });
}
function fireBullet(fromPos, dir, ownerVel, fromPlayer){
for (const b of bullets){
if (b.active) continue;
b.active = true; b.fromPlayer = fromPlayer; b.life = BULLET_LIFE;
b.mesh.material = fromPlayer ? bulletMatP : bulletMatE;
b.mesh.visible = true;
b.mesh.position.copy(fromPos);
b.vel.copy(dir).multiplyScalar(BULLET_SPEED).add(ownerVel);
return;
}
}
// ============================================================= EXPLOSIONS (pool)
const fireballs = [];
const fbGeo = new THREE.IcosahedronGeometry(1, 0);
for (let i=0;i<12;i++){
const m = new THREE.Mesh(fbGeo, new THREE.MeshBasicMaterial({ color:0xff8a2a, transparent:true, opacity:1, fog:false }));
m.visible = false; scene.add(m);
fireballs.push({ mesh:m, t:0, active:false, max:0.55 });
}
function explode(pos, big){
for (const f of fireballs){
if (f.active) continue;
f.active = true; f.t = 0; f.max = big ? 0.7 : 0.45;
f.mesh.visible = true; f.mesh.position.copy(pos);
f.mesh.scale.setScalar(1); f.mesh.material.opacity = 1;
f.big = big;
return;
}
}
// ============================================================= INPUT
const keys = {};
let started = false, paused = false, cockpit = false;
const GAME_KEYS = new Set(['KeyW','KeyS','KeyA','KeyD','KeyQ','KeyE','Space','ShiftLeft','ShiftRight',
'ControlLeft','ControlRight','KeyR','KeyF','ArrowUp','ArrowDown','ArrowLeft','ArrowRight']);
window.addEventListener('keydown', (e) => {
if (!started){ startGame(); return; }
if (e.code === 'KeyP'){ togglePause(); return; }
if (e.code === 'KeyC' && !e.repeat){ cockpit = !cockpit; return; }
keys[e.code] = true;
if (GAME_KEYS.has(e.code)) e.preventDefault();
});
window.addEventListener('keyup', (e) => { keys[e.code] = false; });
overlay.addEventListener('click', () => { if (!started) startGame(); });
window.addEventListener('blur', () => { for (const k in keys) keys[k]=false; });
function startGame(){
started = true;
overlay.style.display = 'none';
initAudio();
}
function togglePause(){
if (!started) return;
paused = !paused;
showMsg(paused ? 'PAUSED' : '', paused ? 'press P to resume' : '', paused);
if (!paused) hideMsg();
}
// ============================================================= AUDIO (WebAudio, optional)
let actx = null, engineGain = null, engineOsc = null, masterGain = null;
function initAudio(){
if (actx) return;
try {
actx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = actx.createGain(); masterGain.gain.value = 0.5; masterGain.connect(actx.destination);
engineOsc = actx.createOscillator(); engineOsc.type = 'sawtooth'; engineOsc.frequency.value = 55;
const lp = actx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value = 420;
engineGain = actx.createGain(); engineGain.gain.value = 0.0;
engineOsc.connect(lp); lp.connect(engineGain); engineGain.connect(masterGain);
engineOsc.start();
} catch(e){ actx = null; }
}
function sfxGun(){
if (!actx) return;
const o = actx.createOscillator(), g = actx.createGain();
o.type='square'; o.frequency.setValueAtTime(220, actx.currentTime);
o.frequency.exponentialRampToValueAtTime(90, actx.currentTime+0.05);
g.gain.setValueAtTime(0.18, actx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, actx.currentTime+0.08);
o.connect(g); g.connect(masterGain); o.start(); o.stop(actx.currentTime+0.09);
}
function sfxBoom(){
if (!actx) return;
const len = actx.sampleRate * 0.5;
const buf = actx.createBuffer(1, len, actx.sampleRate);
const d = buf.getChannelData(0);
for (let i=0;i<len;i++) d[i] = (Math.random()*2-1) * Math.pow(1 - i/len, 2);
const src = actx.createBufferSource(); src.buffer = buf;
const g = actx.createGain(); g.gain.value = 0.5;
const lp = actx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=900;
src.connect(lp); lp.connect(g); g.connect(masterGain); src.start();
}
// ============================================================= HUD refs
const el = {
score:document.getElementById('score'), kills:document.getElementById('kills'),
deaths:document.getElementById('deaths'), spd:document.getElementById('spd'),
thrPct:document.getElementById('thrPct'), thrFill:document.getElementById('thrFill'),
alt:document.getElementById('alt'), hpPct:document.getElementById('hpPct'),
hpFill:document.getElementById('hpFill'), msg:document.getElementById('msg'),
flash:document.getElementById('flash')
};
let score = 0, kills = 0, deaths = 0;
const radar = document.getElementById('radar').getContext('2d');
let msgTimer = 0;
function showMsg(title, sub, sticky){
el.msg.innerHTML = title + (sub ? '<span class="s">'+sub+'</span>' : '');
el.msg.style.opacity = title ? '1' : '0';
msgTimer = sticky ? Infinity : 1.6;
}
function hideMsg(){ el.msg.style.opacity = '0'; msgTimer = 0; }
let flashT = 0;
function hitFlash(){ flashT = 0.18; }
// ============================================================= HELPERS
const _fwd = new THREE.Vector3(), _tmpV = new THREE.Vector3(), _tmpV2 = new THREE.Vector3();
const _tmpM = new THREE.Matrix4(), _tmpQ = new THREE.Quaternion();
const UP = new THREE.Vector3(0,1,0);
function forwardOf(obj, out){ return out.set(0,0,-1).applyQuaternion(obj.quaternion); }
// ============================================================= MAIN LOOP
const clock = new THREE.Clock();
let shake = 0;
function animate(){
requestAnimationFrame(animate);
let dt = clock.getDelta();
if (dt > 0.05) dt = 0.05; // avoid big jumps after tab switch
if (!started || paused){ renderer.render(scene, camera); return; }
stepPlayer(dt);
updateTerrain(player.position.x, player.position.z);
checkGround(dt);
stepEnemies(dt);
stepBullets(dt);
stepFireballs(dt);
spinProps(dt);
updateCamera(dt);
updateHUD();
updateAudio();
// effects timers
if (msgTimer !== Infinity && msgTimer > 0){ msgTimer -= dt; if (msgTimer <= 0) hideMsg(); }
if (flashT > 0){ flashT -= dt; el.flash.style.opacity = (clamp(flashT/0.18,0,1)*0.5).toFixed(3); }
sky.position.copy(camera.position);
sunDisc.position.copy(camera.position).add(_tmpV.copy(sun.position).normalize().multiplyScalar(3200));
renderer.render(scene, camera);
}
// ---- player physics -------------------------------------------------------
function stepPlayer(dt){
if (!plane.alive) return;
const auth = clamp(plane.speed / 45, 0.22, 1);
let pitch = 0, roll = 0, yaw = 0;
if (keys.KeyS || keys.ArrowDown) pitch += 1; // pull up / climb
if (keys.KeyW || keys.ArrowUp) pitch -= 1; // push down / dive
if (keys.KeyA || keys.ArrowLeft) roll += 1; // bank left
if (keys.KeyD || keys.ArrowRight)roll -= 1;
if (keys.KeyQ) yaw += 1;
if (keys.KeyE) yaw -= 1;
player.rotateX(pitch * PITCH_RATE * auth * dt);
player.rotateZ(roll * ROLL_RATE * auth * dt);
player.rotateY(yaw * YAW_RATE * auth * dt);
// throttle
if (keys.ShiftLeft || keys.ShiftRight || keys.KeyR) plane.throttle += 0.45*dt;
if (keys.ControlLeft || keys.ControlRight || keys.KeyF) plane.throttle -= 0.45*dt;
plane.throttle = clamp(plane.throttle, 0, 1);
forwardOf(player, _fwd);
const target = 30 + plane.throttle * 95;
plane.speed += ((target - plane.speed) * 0.55 - G_ACCEL * _fwd.y) * dt;
plane.speed = clamp(plane.speed, 0, MAX_SPEED);
player.position.addScaledVector(_fwd, plane.speed * dt);
// extra sink when stalling
if (plane.speed < 28) player.position.y -= (28 - plane.speed) * 0.55 * dt;
plane.vel.copy(_fwd).multiplyScalar(plane.speed);
// firing
plane.fireCd -= dt;
if (keys.Space && plane.fireCd <= 0){
plane.fireCd = 0.085;
forwardOf(player, _fwd);
_tmpV.copy(player.position).addScaledVector(_fwd, 3.2);
// two guns slight offset
fireBullet(_tmpV, _fwd, plane.vel, true);
sfxGun();
}
// altitude ceiling
if (player.position.y > 620){ player.position.y = 620; if (plane.speed>34) plane.speed -= 20*dt; }
}
function checkGround(dt){
if (!plane.alive) return;
const gy = terrainHeight(player.position.x, player.position.z);
const floor = Math.max(gy, WATER_Y) + 2.5;
if (player.position.y < floor){
crashPlayer();
}
}
let respawnTimer = 0;
function crashPlayer(){
plane.alive = false;
explode(player.position, true); sfxBoom(); shake = 0.6; hitFlash();
deaths++;
player.visible = false;
showMsg('WRECKED', 'regrouping...', true);
respawnTimer = 1.6;
}
// ---- enemies --------------------------------------------------------------
function stepEnemies(dt){
// respawn player if dead
if (!plane.alive){
respawnTimer -= dt;
if (respawnTimer <= 0){
const a = Math.random()*Math.PI*2;
resetPlayer(player.position.x + Math.cos(a)*40, player.position.z + Math.sin(a)*40);
player.visible = true; hideMsg();
}
return;
}
for (const e of enemies){
const ep = e.obj.position;
const toPlayer = _tmpV.copy(player.position).sub(ep);
const dist = toPlayer.length();
// choose aim target
let aim = _tmpV2;
if (dist < 1500){
aim.copy(player.position);
// small lead
aim.addScaledVector(plane.vel, clamp(dist/BULLET_SPEED,0,1.2));
e.state = 'hunt';
} else {
if (e.wander.lengthSq() === 0 || ep.distanceTo(e.wander) < 120){
e.wander.set(player.position.x + (Math.random()-0.5)*900, 120+Math.random()*200,
player.position.z + (Math.random()-0.5)*900);
}
aim.copy(e.wander);
e.state = 'patrol';
}
// ground avoidance
const egy = terrainHeight(ep.x, ep.z);
if (ep.y < egy + 55){ aim.y = Math.max(aim.y, ep.y + 120); }
// turn toward aim
_tmpM.lookAt(ep, aim, UP);
_tmpQ.setFromRotationMatrix(_tmpM);
e.obj.quaternion.rotateTowards(_tmpQ, 1.15 * dt);
// move
forwardOf(e.obj, _fwd);
ep.addScaledVector(_fwd, e.speed * dt);
if (ep.y < egy + 6){ ep.y = egy + 6; } // don't sink through ground
// fire at player
e.cooldown -= dt;
if (dist < 460 && e.state === 'hunt'){
forwardOf(e.obj, _fwd);
const dirDot = _fwd.dot(toPlayer.normalize());
if (dirDot > 0.985 && e.cooldown <= 0){
e.cooldown = 0.9 + Math.random()*0.6;
_tmpV.copy(ep).addScaledVector(_fwd, 3.2);
const ev = _fwd.clone().multiplyScalar(e.speed);
fireBullet(_tmpV, _fwd, ev, false);
}
}
}
}
// ---- bullets --------------------------------------------------------------
function stepBullets(dt){
for (const b of bullets){
if (!b.active) continue;
b.life -= dt;
b.mesh.position.addScaledVector(b.vel, dt);
if (b.life <= 0){ b.active = false; b.mesh.visible = false; continue; }
if (b.fromPlayer){
for (const e of enemies){
if (b.mesh.position.distanceToSquared(e.obj.position) < HIT_R*HIT_R){
b.active = false; b.mesh.visible = false;
e.hp -= 10;
explode(b.mesh.position, false);
if (e.hp <= 0){
explode(e.obj.position, true); sfxBoom();
kills++; score += 100;
placeEnemy(e, player.position);
}
break;
}
}
} else if (plane.alive) {
if (b.mesh.position.distanceToSquared(player.position) < HIT_R*HIT_R){
b.active = false; b.mesh.visible = false;
plane.hp -= 8; hitFlash(); shake = Math.max(shake, 0.25);
explode(b.mesh.position, false);
if (plane.hp <= 0) crashPlayer();
}
}
// ground hit
if (b.mesh.position.y < Math.max(terrainHeight(b.mesh.position.x,b.mesh.position.z), WATER_Y)){
b.active = false; b.mesh.visible = false;
}
}
}
function stepFireballs(dt){
for (const f of fireballs){
if (!f.active) continue;
f.t += dt;
const k = f.t / f.max;
if (k >= 1){ f.active = false; f.mesh.visible = false; continue; }
const s = (f.big ? 10 : 5) * (0.3 + k*1.1);
f.mesh.scale.setScalar(s);
f.mesh.material.opacity = (1 - k);
f.mesh.material.color.setHex( k < 0.4 ? 0xffd24a : (k < 0.7 ? 0xff7a2a : 0x555555) );
}
}
function spinProps(dt){
player.userData.prop.rotation.z += (10 + plane.throttle*45) * dt;
for (const e of enemies) e.obj.userData.prop.rotation.z += 34*dt;
}
// ---- camera ---------------------------------------------------------------
const camPos = new THREE.Vector3();
function updateCamera(dt){
if (cockpit){
forwardOf(player, _fwd);
_tmpV.copy(player.position).addScaledVector(_fwd, 1.2); _tmpV.y += 0.6;
camera.position.lerp(_tmpV, clamp(dt*18,0,1));
_tmpV2.copy(player.position).addScaledVector(_fwd, 40);
camera.up.set(0,1,0).applyQuaternion(player.quaternion);
camera.lookAt(_tmpV2);
} else {
camPos.set(0, 2.6, 11).applyQuaternion(player.quaternion).add(player.position);
camera.position.lerp(camPos, clamp(dt*6,0,1));
camera.up.set(0,1,0);
_tmpV.copy(player.position).add(forwardOf(player,_tmpV2).multiplyScalar(6));
camera.lookAt(_tmpV);
}
if (shake > 0){
shake -= dt;
const s = shake*1.4;
camera.position.x += (Math.random()-0.5)*s;
camera.position.y += (Math.random()-0.5)*s;
camera.position.z += (Math.random()-0.5)*s;
}
}
// ---- HUD ------------------------------------------------------------------
function updateHUD(){
el.score.textContent = score;
el.kills.textContent = kills;
el.deaths.textContent = deaths;
el.spd.textContent = Math.round(plane.speed*1.9);
const tp = Math.round(plane.throttle*100);
el.thrPct.textContent = tp; el.thrFill.style.width = tp + '%';
el.alt.textContent = Math.max(0, Math.round((player.position.y)*3.3));
const hp = Math.max(0, Math.round(plane.hp));
el.hpPct.textContent = hp; el.hpFill.style.width = hp + '%';
drawRadar();
}
function drawRadar(){
const R = 75, range = 1600;
radar.clearRect(0,0,150,150);
radar.fillStyle = 'rgba(10,20,12,.55)';
radar.beginPath(); radar.arc(R,R,R,0,Math.PI*2); radar.fill();
radar.strokeStyle = 'rgba(224,182,74,.35)';
radar.beginPath(); radar.arc(R,R,R-1,0,Math.PI*2); radar.stroke();
radar.beginPath(); radar.arc(R,R,R*0.5,0,Math.PI*2); radar.stroke();
// sweep lines
radar.beginPath(); radar.moveTo(R,4); radar.lineTo(R,146); radar.moveTo(4,R); radar.lineTo(146,R); radar.stroke();
const inv = _tmpQ.copy(player.quaternion).invert();
for (const e of enemies){
_tmpV.copy(e.obj.position).sub(player.position).applyQuaternion(inv);
const dx = clamp(_tmpV.x/range, -1, 1) * (R-6);
const dz = clamp(_tmpV.z/range, -1, 1) * (R-6); // -z = ahead => up
radar.fillStyle = '#ff5a3a';
radar.beginPath(); radar.arc(R+dx, R+dz, 3.2, 0, Math.PI*2); radar.fill();
}
// player marker (nose up)
radar.fillStyle = '#8fd0ff';
radar.beginPath();
radar.moveTo(R, R-7); radar.lineTo(R-4.5, R+5); radar.lineTo(R+4.5, R+5); radar.closePath(); radar.fill();
}
// ---- audio update ---------------------------------------------------------
function updateAudio(){
if (!actx || !engineGain) return;
if (actx.state === 'suspended') actx.resume();
const rpm = plane.alive ? (0.3 + plane.throttle*0.7) : 0.15;
engineOsc.frequency.value = 45 + rpm*70 + plane.speed*0.25;
engineGain.gain.value = plane.alive ? 0.10 + rpm*0.06 : 0.02;
}
// ---- resize ---------------------------------------------------------------
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// prime the terrain so first frame looks right, then go
updateTerrain(player.position.x, player.position.z);
animate();
// expose a little for debugging
window.__opusAces = { plane, enemies, scene };
}
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment