Created
July 11, 2026 14:33
-
-
Save senko/41ee802251cbb723088d73e48835d58a to your computer and use it in GitHub Desktop.
Flying simulator by Claude Opus 4.8
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.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