Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>ORBITAL</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet"> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:'Orbitron',sans-serif;color:#fff;user-select:none} | |
| canvas{display:block;position:fixed;top:0;left:0} | |
| .screen{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:10;pointer-events:none;opacity:0;transition:opacity .4s} | |
| .screen.active{opacity:1;pointer-events:auto} | |
| #title-screen .logo{font-size:clamp(48px,10vw,120px);font-weight:900;letter-spacing:.15em;text-shadow:0 0 40px #0af,0 0 80px #06f,0 0 120px #04a;margin-bottom:20px} | |
| #title-screen .subtitle{font-size:clamp(12px,2vw,18px);opacity:.7;margin-bottom:30px} | |
| #title-screen .prompt{font-size:clamp(14px,2vw,20px);animation:pulse 2s ease-in-out infinite} | |
| #title-screen .controls{font-size:clamp(10px,1.5vw,14px);opacity:.5;margin-top:30px;text-align:center;line-height:1.8} | |
| #title-screen .highscore-display{font-size:clamp(12px,1.5vw,16px);opacity:.6;margin-top:15px} | |
| #hud{position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5} | |
| #hud-top{position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;align-items:flex-start} | |
| #hud-left,#hud-right{display:flex;flex-direction:column;gap:6px} | |
| #hud-right{text-align:right} | |
| .hud-label{font-size:10px;opacity:.5;text-transform:uppercase;letter-spacing:.1em} | |
| .hud-value{font-size:16px;font-weight:700} | |
| .hud-bar{width:120px;height:8px;background:rgba(255,255,255,.1);border-radius:4px;overflow:hidden} | |
| .hud-bar-fill{height:100%;border-radius:4px;transition:width .15s} | |
| .health-fill{background:linear-gradient(90deg,#f44,#ff4)} | |
| .fuel-fill{background:linear-gradient(90deg,#4af,#4f8)} | |
| #combo-display{font-size:24px;font-weight:900;color:#ff4;text-shadow:0 0 20px #fa0} | |
| #wave-display{font-size:20px;font-weight:700;color:#0af;text-shadow:0 0 15px #06f} | |
| #score-display{font-size:20px;font-weight:700;text-shadow:0 0 10px #fff} | |
| #upgrades-display{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);display:flex;gap:8px} | |
| .upgrade-icon{width:32px;height:32px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:14px;border:1px solid rgba(255,255,255,.2);background:rgba(0,0,0,.5)} | |
| #radar{position:absolute;bottom:10px;right:10px;width:80px;height:80px;border-radius:50%;border:1px solid rgba(255,255,255,.15);background:rgba(0,0,0,.3)} | |
| #mute-btn{position:absolute;top:10px;right:10px;pointer-events:auto;cursor:pointer;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.2);color:#fff;padding:4px 8px;border-radius:4px;font-size:12px;font-family:inherit} | |
| #wave-card{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:15;text-align:center;pointer-events:none;opacity:0;transition:opacity .3s} | |
| #wave-card.active{opacity:1} | |
| #wave-card .wave-num{font-size:clamp(36px,6vw,72px);font-weight:900;text-shadow:0 0 30px #0af,0 0 60px #06f} | |
| #wave-card .wave-sub{font-size:clamp(12px,2vw,18px);opacity:.6;margin-top:8px} | |
| #upgrade-screen .upgrade-title{font-size:clamp(20px,3vw,36px);font-weight:700;margin-bottom:30px;text-shadow:0 0 20px #0af} | |
| #upgrade-cards{display:flex;gap:20px;flex-wrap:wrap;justify-content:center} | |
| .upgrade-card{width:140px;padding:20px 15px;background:rgba(0,0,0,.7);border:1px solid rgba(255,255,255,.15);border-radius:10px;cursor:pointer;text-align:center;transition:all .2s;pointer-events:auto} | |
| .upgrade-card:hover{border-color:#0af;box-shadow:0 0 20px rgba(0,170,255,.3);transform:translateY(-4px)} | |
| .upgrade-card .uc-icon{font-size:28px;margin-bottom:8px} | |
| .upgrade-card .uc-name{font-size:12px;font-weight:700;margin-bottom:4px} | |
| .upgrade-card .uc-desc{font-size:9px;opacity:.5;line-height:1.4} | |
| #gameover-screen .go-title{font-size:clamp(36px,6vw,72px);font-weight:900;color:#f44;text-shadow:0 0 40px #f00;margin-bottom:20px} | |
| #gameover-screen .go-stats{font-size:clamp(12px,1.5vw,16px);line-height:2;text-align:center;margin-bottom:20px;opacity:.8} | |
| #gameover-screen .go-new{font-size:clamp(14px,2vw,20px);color:#ff4;text-shadow:0 0 20px #fa0;margin-bottom:20px} | |
| #gameover-screen .go-prompt{font-size:clamp(12px,1.5vw,16px);opacity:.5;animation:pulse 2s ease-in-out infinite} | |
| #pause-screen .pause-title{font-size:clamp(36px,6vw,72px);font-weight:900;text-shadow:0 0 30px #fff} | |
| #pause-screen .pause-prompt{font-size:clamp(12px,1.5vw,16px);opacity:.5;margin-top:15px} | |
| @keyframes pulse{0%,100%{opacity:.5}50%{opacity:1}} | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game-canvas"></canvas> | |
| <div id="hud" class="screen"> | |
| <div id="hud-top"> | |
| <div id="hud-left"> | |
| <div><span class="hud-label">Health</span><div class="hud-bar"><div id="health-bar" class="hud-bar-fill health-fill" style="width:100%"></div></div></div> | |
| <div><span class="hud-label">Fuel</span><div class="hud-bar"><div id="fuel-bar" class="hud-bar-fill fuel-fill" style="width:100%"></div></div></div> | |
| <div id="upgrades-display"></div> | |
| </div> | |
| <div id="hud-center" style="text-align:center"> | |
| <div id="wave-display">WAVE 1</div> | |
| <div id="combo-display"></div> | |
| </div> | |
| <div id="hud-right"> | |
| <div id="score-display">0</div> | |
| <div><span class="hud-label">High Score</span><div class="hud-value" id="highscore-hud">0</div></div> | |
| </div> | |
| </div> | |
| <div id="radar"><canvas id="radar-canvas" width="80" height="80"></canvas></div> | |
| <button id="mute-btn">MUTE</button> | |
| </div> | |
| <div id="wave-card"><div class="wave-num" id="wave-num-text"></div><div class="wave-sub" id="wave-sub-text"></div></div> | |
| <div id="title-screen" class="screen active"> | |
| <div class="logo">ORBITAL</div> | |
| <div class="subtitle">Gravity is your weapon</div> | |
| <div class="prompt">CLICK or ENTER to launch</div> | |
| <div class="highscore-display" id="highscore-title"></div> | |
| <div class="controls">W/UP thrust | A/D or LEFT/RIGHT rotate | SPACE/CLICK fire | SHIFT boost | ESC pause</div> | |
| </div> | |
| <div id="upgrade-screen" class="screen"> | |
| <div class="upgrade-title">CHOOSE UPGRADE</div> | |
| <div id="upgrade-cards"></div> | |
| </div> | |
| <div id="gameover-screen" class="screen"> | |
| <div class="go-title">GAME OVER</div> | |
| <div class="go-stats" id="go-stats"></div> | |
| <div class="go-new" id="go-new" style="display:none">NEW HIGH SCORE!</div> | |
| <div class="go-prompt">CLICK or ENTER to restart</div> | |
| </div> | |
| <div id="pause-screen" class="screen"> | |
| <div class="pause-title">PAUSED</div> | |
| <div class="pause-prompt">ESC to resume</div> | |
| </div> | |
| <script> | |
| var canvas, ctx, radarCanvas, radarCtx; | |
| var W, H, DPR; | |
| var audioCtx, masterGain, isMuted = false; | |
| var gameState = 'TITLE'; | |
| var score = 0, highScore = 0, combo = 0, comboTimer = 0; | |
| var wave = 0, waveTimer = 0, waveDelay = 3; | |
| var gameTime = 0, maxSpeed = 0, totalKills = 0; | |
| var lastTime = 0, dt = 0, slowMo = 1, hitStop = 0; | |
| var screenShake = {x:0,y:0,intensity:0,duration:0}; | |
| var keys = {}, mouse = {x:0,y:0,down:false}; | |
| var camera = {x:0,y:0,zoom:1,targetZoom:1,shakeX:0,shakeY:0}; | |
| var bullets = [], particles = [], enemies = [], pickups = [], popups = [], shockwaves = []; | |
| var stars = [], nebulae = []; | |
| var celestialBodies = []; | |
| var ship = null; | |
| var blackHole = null; | |
| var titleShip = null; | |
| (function init() { | |
| canvas = document.getElementById('game-canvas'); | |
| ctx = canvas.getContext('2d'); | |
| radarCanvas = document.getElementById('radar-canvas'); | |
| radarCtx = radarCanvas.getContext('2d'); | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| highScore = parseInt(localStorage.getItem('orbital_hs') || '0'); | |
| document.getElementById('highscore-hud').textContent = highScore; | |
| document.getElementById('highscore-title').textContent = highScore ? 'HIGH SCORE: ' + highScore : ''; | |
| setupInput(); | |
| setupAudio(); | |
| generateStars(); | |
| generateNebulae(); | |
| titleShip = {x:W/2,y:H/2+100,vx:200,vy:-100,angle:0,trail:[]}; | |
| requestAnimationFrame(loop); | |
| })(); | |
| function resize() { | |
| DPR = window.devicePixelRatio || 1; | |
| W = window.innerWidth; | |
| H = window.innerHeight; | |
| canvas.width = W * DPR; | |
| canvas.height = H * DPR; | |
| canvas.style.width = W + 'px'; | |
| canvas.style.height = H + 'px'; | |
| ctx.setTransform(DPR, 0, 0, DPR, 0, 0); | |
| } | |
| function setupInput() { | |
| window.addEventListener('keydown', function(e) { | |
| keys[e.code] = true; | |
| if (e.code === 'Enter' || e.code === 'Space') { | |
| if (gameState === 'TITLE') startGame(); | |
| else if (gameState === 'GAMEOVER') restartGame(); | |
| } | |
| if (e.code === 'Escape') { | |
| if (gameState === 'PLAYING') { gameState = 'PAUSED'; showScreen('pause-screen'); } | |
| else if (gameState === 'PAUSED') { gameState = 'PLAYING'; showScreen(null); } | |
| } | |
| }); | |
| window.addEventListener('keyup', function(e) { keys[e.code] = false; }); | |
| canvas.addEventListener('mousemove', function(e) { mouse.x = e.clientX; mouse.y = e.clientY; }); | |
| canvas.addEventListener('mousedown', function(e) { | |
| mouse.down = true; | |
| if (gameState === 'TITLE') startGame(); | |
| else if (gameState === 'GAMEOVER') restartGame(); | |
| }); | |
| canvas.addEventListener('mouseup', function() { mouse.down = false; }); | |
| document.getElementById('mute-btn').addEventListener('click', function() { | |
| isMuted = !isMuted; | |
| masterGain.gain.value = isMuted ? 0 : 1; | |
| this.textContent = isMuted ? 'UNMUTE' : 'MUTE'; | |
| }); | |
| } | |
| function setupAudio() { | |
| try { | |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| masterGain = audioCtx.createGain(); | |
| masterGain.gain.value = 1; | |
| masterGain.connect(audioCtx.destination); | |
| } catch(e) { audioCtx = null; } | |
| } | |
| function playSound(type) { | |
| if (!audioCtx || isMuted) return; | |
| try { | |
| var t = audioCtx.currentTime, osc, g; | |
| if (type === 'thrust') { | |
| osc = audioCtx.createOscillator(); g = audioCtx.createGain(); | |
| osc.type = 'sawtooth'; osc.frequency.value = 60; | |
| g.gain.setValueAtTime(0.04, t); g.gain.exponentialRampToValueAtTime(0.001, t+0.15); | |
| osc.connect(g); g.connect(masterGain); osc.start(t); osc.stop(t+0.15); | |
| } else if (type === 'fire') { | |
| osc = audioCtx.createOscillator(); g = audioCtx.createGain(); | |
| osc.type = 'square'; osc.frequency.setValueAtTime(880, t); osc.frequency.exponentialRampToValueAtTime(220, t+0.1); | |
| g.gain.setValueAtTime(0.08, t); g.gain.exponentialRampToValueAtTime(0.001, t+0.1); | |
| osc.connect(g); g.connect(masterGain); osc.start(t); osc.stop(t+0.1); | |
| } else if (type === 'explosion') { | |
| var buf = audioCtx.createBuffer(1, audioCtx.sampleRate*0.3, audioCtx.sampleRate); | |
| var d = buf.getChannelData(0); | |
| for (var i=0;i<d.length;i++) d[i]=(Math.random()*2-1)*Math.exp(-i/(audioCtx.sampleRate*0.08)); | |
| var src = audioCtx.createBufferSource(); src.buffer = buf; | |
| g = audioCtx.createGain(); g.gain.value = 0.15; | |
| src.connect(g); g.connect(masterGain); src.start(t); | |
| } else if (type === 'pickup') { | |
| osc = audioCtx.createOscillator(); g = audioCtx.createGain(); | |
| osc.type = 'sine'; osc.frequency.setValueAtTime(523, t); osc.frequency.setValueAtTime(659, t+0.08); osc.frequency.setValueAtTime(784, t+0.16); | |
| g.gain.setValueAtTime(0.08, t); g.gain.exponentialRampToValueAtTime(0.001, t+0.3); | |
| osc.connect(g); g.connect(masterGain); osc.start(t); osc.stop(t+0.3); | |
| } else if (type === 'wave') { | |
| osc = audioCtx.createOscillator(); g = audioCtx.createGain(); | |
| osc.type = 'sawtooth'; osc.frequency.setValueAtTime(110, t); osc.frequency.linearRampToValueAtTime(220, t+0.5); | |
| g.gain.setValueAtTime(0.1, t); g.gain.exponentialRampToValueAtTime(0.001, t+0.6); | |
| osc.connect(g); g.connect(masterGain); osc.start(t); osc.stop(t+0.6); | |
| } else if (type === 'boss') { | |
| osc = audioCtx.createOscillator(); g = audioCtx.createGain(); | |
| osc.type = 'sawtooth'; osc.frequency.setValueAtTime(55, t); osc.frequency.linearRampToValueAtTime(33, t+1); | |
| g.gain.setValueAtTime(0.12, t); g.gain.exponentialRampToValueAtTime(0.001, t+1.2); | |
| osc.connect(g); g.connect(masterGain); osc.start(t); osc.stop(t+1.2); | |
| } else if (type === 'death') { | |
| osc = audioCtx.createOscillator(); g = audioCtx.createGain(); | |
| osc.type = 'sawtooth'; osc.frequency.setValueAtTime(440, t); osc.frequency.exponentialRampToValueAtTime(55, t+1.5); | |
| g.gain.setValueAtTime(0.15, t); g.gain.exponentialRampToValueAtTime(0.001, t+1.5); | |
| osc.connect(g); g.connect(masterGain); osc.start(t); osc.stop(t+1.5); | |
| } else if (type === 'graze') { | |
| osc = audioCtx.createOscillator(); g = audioCtx.createGain(); | |
| osc.type = 'sine'; osc.frequency.value = 1200; | |
| g.gain.setValueAtTime(0.03, t); g.gain.exponentialRampToValueAtTime(0.001, t+0.05); | |
| osc.connect(g); g.connect(masterGain); osc.start(t); osc.stop(t+0.05); | |
| } | |
| } catch(e) {} | |
| } | |
| function showScreen(id) { | |
| var screens = ['title-screen','upgrade-screen','gameover-screen','pause-screen']; | |
| for (var i=0;i<screens.length;i++) { | |
| var el = document.getElementById(screens[i]); | |
| if (id === screens[i]) el.classList.add('active'); | |
| else el.classList.remove('active'); | |
| } | |
| var hud = document.getElementById('hud'); | |
| if (id === 'title-screen') hud.classList.remove('active'); | |
| else if (id === null) hud.classList.add('active'); | |
| else hud.classList.toggle('active', id !== 'title-screen'); | |
| } | |
| function startGame() { | |
| if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume(); | |
| score = 0; combo = 0; comboTimer = 0; wave = 0; gameTime = 0; maxSpeed = 0; totalKills = 0; | |
| slowMo = 1; hitStop = 0; | |
| bullets.length = 0; particles.length = 0; enemies.length = 0; pickups.length = 0; | |
| popups.length = 0; shockwaves.length = 0; | |
| celestialBodies.length = 0; blackHole = null; | |
| ship = {x:W/2+200,y:H/2+100,vx:-120,vy:0,angle:-Math.PI/2,health:200,maxHealth:200,fuel:100,maxFuel:100, | |
| fireRate:0.15,fireTimer:0,boostFuel:3,boostActive:false, | |
| upgrades:{spread:0,rapid:0,shield:0,overdrive:0},trail:[],invuln:5,alive:true}; | |
| camera.x = W/2; camera.y = H/2; camera.zoom = 1; camera.targetZoom = 1; | |
| buildSolarSystem(); | |
| gameState = 'PLAYING'; | |
| showScreen(null); | |
| nextWave(); | |
| } | |
| function restartGame() { | |
| if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume(); | |
| startGame(); | |
| } | |
| function buildSolarSystem() { | |
| celestialBodies.push({x:W/2,y:H/2,mass:8000,radius:50,type:'star',orbitAngle:0,orbitDist:0,orbitSpeed:0,pulse:0}); | |
| var pd = [{mass:1200,radius:28,orbitDist:220,orbitSpeed:0.3,color:'#4af'}, | |
| {mass:2000,radius:38,orbitDist:340,orbitSpeed:0.2,color:'#f84'}, | |
| {mass:600,radius:20,orbitDist:160,orbitSpeed:0.5,color:'#4f8'}, | |
| {mass:3000,radius:48,orbitDist:440,orbitSpeed:0.15,color:'#a4f'}]; | |
| for (var i=0;i<pd.length;i++) { | |
| var p = pd[i]; | |
| celestialBodies.push({x:W/2+Math.cos(i*1.5)*p.orbitDist,y:H/2+Math.sin(i*1.5)*p.orbitDist, | |
| mass:p.mass,radius:p.radius,type:'planet',orbitAngle:i*1.5,orbitDist:p.orbitDist, | |
| orbitSpeed:p.orbitSpeed,color:p.color,pulse:Math.random()*Math.PI*2}); | |
| } | |
| } | |
| function nextWave() { | |
| wave++; | |
| waveTimer = waveDelay; | |
| showWaveCard(wave); | |
| playSound('wave'); | |
| if (wave % 5 === 0) { | |
| if (wave === 10) { spawnBlackHole(); playSound('boss'); } | |
| else { spawnBoss(); playSound('boss'); } | |
| } else { spawnWaveEnemies(wave); } | |
| } | |
| function showWaveCard(n) { | |
| var el = document.getElementById('wave-card'); | |
| document.getElementById('wave-num-text').textContent = 'WAVE ' + n; | |
| document.getElementById('wave-sub-text').textContent = n % 5 === 0 ? 'BOSS WAVE' : ''; | |
| el.classList.add('active'); | |
| setTimeout(function() { el.classList.remove('active'); }, 2000); | |
| } | |
| function spawnWaveEnemies(w) { | |
| var count = 2 + w; | |
| for (var i=0;i<count;i++) { | |
| var type = 'drone'; | |
| if (w >= 3 && Math.random() < 0.3) type = 'mine'; | |
| if (w >= 5 && Math.random() < 0.2) type = 'gunship'; | |
| spawnEnemy(type); | |
| } | |
| } | |
| function spawnBoss() { | |
| enemies.push({type:'boss',x:W/2+(Math.random()-0.5)*200,y:-80,vx:0,vy:0, | |
| health:80+wave*20,maxHealth:80+wave*20,radius:40,fireRate:0.8,fireTimer:0,alive:true,angle:0,speed:0.5}); | |
| } | |
| function spawnBlackHole() { | |
| blackHole = {x:W/2,y:H/2-100,mass:15000,radius:60,life:30,pulse:0,health:200,maxHealth:200}; | |
| for (var i=0;i<8;i++) spawnEnemy('drone'); | |
| } | |
| function spawnEnemy(type) { | |
| var side = Math.floor(Math.random()*4), x, y; | |
| if (side===0) { x=-30; y=Math.random()*H; } | |
| else if (side===1) { x=W+30; y=Math.random()*H; } | |
| else if (side===2) { x=Math.random()*W; y=-30; } | |
| else { x=Math.random()*W; y=H+30; } | |
| if (type==='drone') enemies.push({type:'drone',x:x,y:y,vx:0,vy:0,health:1,maxHealth:1,radius:12,alive:true,angle:0,speed:1.5}); | |
| else if (type==='mine') enemies.push({type:'mine',x:x,y:y,vx:0,vy:0,health:1,maxHealth:1,radius:14,alive:true,angle:0,speed:0.8,accel:0}); | |
| else if (type==='gunship') enemies.push({type:'gunship',x:x,y:y,vx:0,vy:0,health:4,maxHealth:4,radius:22,alive:true,angle:0,speed:0.6,fireRate:1.2,fireTimer:0}); | |
| } | |
| function spawnPickup(x, y) { | |
| var types = ['fuel','shield','spread','rapid','bomb','overdrive']; | |
| var weights = [30,15,15,15,10,15]; | |
| var total = 0; | |
| for (var i=0;i<weights.length;i++) total += weights[i]; | |
| var r = Math.random()*total, cum = 0, chosen = 'fuel'; | |
| for (var i=0;i<types.length;i++) { cum += weights[i]; if (r < cum) { chosen = types[i]; break; } } | |
| var bodyIdx = 1 + Math.floor(Math.random()*(celestialBodies.length-1)); | |
| var body = celestialBodies[bodyIdx]; | |
| var angle = Math.random()*Math.PI*2; | |
| var dist = body.orbitDist ? body.orbitDist + body.radius + 30 + Math.random()*40 : 200; | |
| pickups.push({type:chosen,x:body.x+Math.cos(angle)*dist,y:body.y+Math.sin(angle)*dist, | |
| radius:10,alive:true,orbitBody:bodyIdx,orbitAngle:angle,orbitDist:dist}); | |
| } | |
| function applyGravity(obj, bodies) { | |
| for (var i=0;i<bodies.length;i++) { | |
| var b = bodies[i]; | |
| var dx = b.x - obj.x, dy = b.y - obj.y; | |
| var distSq = dx*dx + dy*dy; | |
| var softDist = distSq + b.radius*b.radius*4; | |
| var force = b.mass / softDist; | |
| obj.vx += (dx/softDist) * force * dt * 300; | |
| obj.vy += (dy/softDist) * force * dt * 300; | |
| } | |
| } | |
| function spawnParticle(x, y, vx, vy, color, life) { | |
| particles.push({x:x,y:y,vx:vx,vy:vy,color:color,life:life,maxLife:life}); | |
| } | |
| function addPopup(text, x, y) { | |
| popups.push({text:text,x:x,y:y,life:1.5}); | |
| } | |
| function addShockwave(x, y, maxRadius) { | |
| shockwaves.push({x:x,y:y,radius:0,maxRadius:maxRadius,speed:maxRadius/0.5,life:0.5}); | |
| } | |
| function update() { | |
| if (gameState !== 'PLAYING') return; | |
| var effectiveDt = dt * slowMo; | |
| if (hitStop > 0) { hitStop -= effectiveDt; return; } | |
| gameTime += effectiveDt; | |
| if (slowMo < 1) slowMo = Math.min(1, slowMo + effectiveDt * 0.5); | |
| if (screenShake.duration > 0) { | |
| screenShake.duration -= effectiveDt; | |
| camera.shakeX = (Math.random()-0.5) * screenShake.intensity; | |
| camera.shakeY = (Math.random()-0.5) * screenShake.intensity; | |
| screenShake.intensity *= 0.95; | |
| } else { camera.shakeX = 0; camera.shakeY = 0; } | |
| for (var i=0;i<celestialBodies.length;i++) { | |
| var b = celestialBodies[i]; | |
| if (b.type === 'planet') { | |
| b.orbitAngle += b.orbitSpeed * effectiveDt * 0.3; | |
| b.x = W/2 + Math.cos(b.orbitAngle) * b.orbitDist; | |
| b.y = H/2 + Math.sin(b.orbitAngle) * b.orbitDist; | |
| b.pulse += effectiveDt * 2; | |
| } else if (b.type === 'star') { b.pulse += effectiveDt * 3; } | |
| } | |
| if (blackHole) { | |
| blackHole.life -= effectiveDt; | |
| blackHole.pulse += effectiveDt * 4; | |
| if (blackHole.life <= 0) { | |
| for (var i=0;i<80;i++) spawnParticle(blackHole.x,blackHole.y,(Math.random()-0.5)*600,(Math.random()-0.5)*600,'#fff',1); | |
| addShockwave(blackHole.x,blackHole.y,300); | |
| screenShake.intensity = 20; screenShake.duration = 1; hitStop = 0.3; | |
| playSound('explosion'); blackHole = null; | |
| } | |
| } | |
| if (ship && ship.alive) updateShip(effectiveDt); | |
| // Bullets | |
| for (var i=bullets.length-1;i>=0;i--) { | |
| var b = bullets[i]; | |
| applyGravity(b, celestialBodies); | |
| if (blackHole) applyGravity(b, [blackHole]); | |
| b.x += b.vx * effectiveDt; b.y += b.vy * effectiveDt; b.life -= effectiveDt; | |
| if (b.life <= 0 || b.x<-100 || b.x>W+100 || b.y<-100 || b.y>H+100) { bullets.splice(i,1); continue; } | |
| if (b.owner === 'player') { | |
| for (var j=enemies.length-1;j>=0;j--) { | |
| var e = enemies[j]; if (!e.alive) continue; | |
| var dx = b.x-e.x, dy = b.y-e.y; | |
| if (dx*dx+dy*dy < (b.radius+e.radius)*(b.radius+e.radius)) { | |
| e.health -= b.damage; | |
| spawnParticle(b.x,b.y,(Math.random()-0.5)*200,(Math.random()-0.5)*200,'#ff4',0.3); | |
| bullets.splice(i,1); | |
| if (e.health <= 0) killEnemy(e); | |
| break; | |
| } | |
| } | |
| } else if (b.owner === 'enemy') { | |
| if (ship && ship.alive) { | |
| var dx = b.x-ship.x, dy = b.y-ship.y; | |
| if (dx*dx+dy*dy < (b.radius+12)*(b.radius+12)) { | |
| if (ship.invuln <= 0) { ship.health -= b.damage; screenShake.intensity=5; screenShake.duration=0.2; playSound('graze'); } | |
| bullets.splice(i,1); | |
| if (ship.health <= 0) killShip(); | |
| } | |
| } | |
| } | |
| } | |
| // Enemies | |
| for (var i=enemies.length-1;i>=0;i--) { | |
| var e = enemies[i]; | |
| if (!e.alive) { enemies.splice(i,1); continue; } | |
| applyGravity(e, celestialBodies); | |
| if (blackHole) applyGravity(e, [blackHole]); | |
| if (ship && ship.alive) { | |
| var sdx = ship.x-e.x, sdy = ship.y-e.y; | |
| var sdist = Math.sqrt(sdx*sdx+sdy*sdy); | |
| if (e.type==='drone') { e.vx+=(sdx/sdist)*e.speed*60*effectiveDt; e.vy+=(sdy/sdist)*e.speed*60*effectiveDt; } | |
| else if (e.type==='mine') { | |
| if (sdist<250) e.accel=Math.min(1,e.accel+effectiveDt*2); | |
| e.vx+=(sdx/sdist)*e.speed*e.accel*60*effectiveDt; e.vy+=(sdy/sdist)*e.speed*e.accel*60*effectiveDt; | |
| } else if (e.type==='gunship') { | |
| e.vx+=(sdx/sdist)*e.speed*30*effectiveDt; e.vy+=(sdy/sdist)*e.speed*30*effectiveDt; | |
| e.fireTimer -= effectiveDt; | |
| if (e.fireTimer <= 0) { | |
| e.fireTimer = e.fireRate; | |
| var ba = Math.atan2(sdy,sdx); | |
| bullets.push({x:e.x+Math.cos(ba)*e.radius,y:e.y+Math.sin(ba)*e.radius, | |
| vx:Math.cos(ba)*300,vy:Math.sin(ba)*300,radius:4,damage:10,life:3,owner:'enemy',color:'#f44'}); | |
| } | |
| } else if (e.type==='boss') { | |
| e.vx+=(sdx/sdist)*e.speed*20*effectiveDt; e.vy+=(sdy/sdist)*e.speed*20*effectiveDt; | |
| e.fireTimer -= effectiveDt; | |
| if (e.fireTimer <= 0) { | |
| e.fireTimer = 0.5; | |
| for (var a=0;a<8;a++) { | |
| var aa = a*Math.PI/4+gameTime; | |
| bullets.push({x:e.x+Math.cos(aa)*e.radius,y:e.y+Math.sin(aa)*e.radius, | |
| vx:Math.cos(aa)*200,vy:Math.sin(aa)*200,radius:5,damage:15,life:3,owner:'enemy',color:'#f84'}); | |
| } | |
| } | |
| } | |
| } | |
| e.x += e.vx*effectiveDt; e.y += e.vy*effectiveDt; e.angle = Math.atan2(e.vy,e.vx); | |
| // Screen wrapping for enemies | |
| if (e.x < -100) e.x = W+100; | |
| if (e.x > W+100) e.x = -100; | |
| if (e.y < -100) e.y = H+100; | |
| if (e.y > H+100) e.y = -100; | |
| if (ship && ship.alive) { | |
| var dx = e.x-ship.x, dy = e.y-ship.y; | |
| if (dx*dx+dy*dy < (e.radius+12)*(e.radius+12)) { | |
| if (ship.invuln <= 0) { ship.health-=10; screenShake.intensity=8; screenShake.duration=0.3; playSound('graze'); | |
| spawnParticle(e.x,e.y,(Math.random()-0.5)*300,(Math.random()-0.5)*300,'#f44',0.5); } | |
| if (e.type!=='gunship' && e.type!=='boss') { e.alive=false; killEnemy(e); } | |
| if (ship.health <= 0) killShip(); | |
| } | |
| } | |
| for (var ci=0;ci<celestialBodies.length;ci++) { | |
| var cb = celestialBodies[ci]; | |
| var dx2 = e.x-cb.x, dy2 = e.y-cb.y; | |
| if (dx2*dx2+dy2*dy2 < (e.radius+cb.radius)*(e.radius+cb.radius)) { e.alive=false; killEnemy(e); break; } | |
| } | |
| } | |
| // Pickups | |
| for (var i=pickups.length-1;i>=0;i--) { | |
| var p = pickups[i]; if (!p.alive) { pickups.splice(i,1); continue; } | |
| var body = celestialBodies[p.orbitBody]; | |
| p.orbitAngle += 0.5*effectiveDt; | |
| p.x = body.x+Math.cos(p.orbitAngle)*p.orbitDist; p.y = body.y+Math.sin(p.orbitAngle)*p.orbitDist; | |
| if (ship && ship.alive) { | |
| var dx = p.x-ship.x, dy = p.y-ship.y; | |
| if (dx*dx+dy*dy < (p.radius+15)*(p.radius+15)) { | |
| applyPickup(p.type); p.alive=false; playSound('pickup'); | |
| spawnParticle(p.x,p.y,(Math.random()-0.5)*200,(Math.random()-0.5)*200,'#4f8',0.5); | |
| } | |
| } | |
| } | |
| for (var i=particles.length-1;i>=0;i--) { | |
| var p = particles[i]; | |
| p.x+=p.vx*effectiveDt; p.y+=p.vy*effectiveDt; p.vx*=0.97; p.vy*=0.97; p.life-=effectiveDt; | |
| if (p.life<=0) particles.splice(i,1); | |
| } | |
| for (var i=shockwaves.length-1;i>=0;i--) { | |
| var s = shockwaves[i]; s.radius+=s.speed*effectiveDt; s.life-=effectiveDt; | |
| if (s.life<=0) shockwaves.splice(i,1); | |
| } | |
| for (var i=popups.length-1;i>=0;i--) { | |
| var p = popups[i]; p.y-=40*effectiveDt; p.life-=effectiveDt; | |
| if (p.life<=0) popups.splice(i,1); | |
| } | |
| if (comboTimer > 0) { comboTimer -= effectiveDt; if (comboTimer <= 0) combo = 0; } | |
| if (enemies.length === 0 && !blackHole) { | |
| waveTimer -= effectiveDt; | |
| if (waveTimer <= 0) { | |
| if (wave % 5 === 0) showUpgradeScreen(); | |
| else nextWave(); | |
| } | |
| } | |
| updateHUD(); | |
| } | |
| function updateShip(dt) { | |
| var rotSpeed = 3; | |
| if (keys['ArrowLeft'] || keys['KeyA']) ship.angle -= rotSpeed*dt; | |
| if (keys['ArrowRight'] || keys['KeyD']) ship.angle += rotSpeed*dt; | |
| var thrusting = false; | |
| if ((keys['ArrowUp'] || keys['KeyW']) && ship.fuel > 0) { | |
| var thrust = ship.boostActive ? 800 : 400; | |
| ship.vx += Math.cos(ship.angle)*thrust*dt; ship.vy += Math.sin(ship.angle)*thrust*dt; | |
| ship.fuel = Math.max(0, ship.fuel - 25*dt); thrusting = true; | |
| if (Math.random() < 0.3) playSound('thrust'); | |
| for (var i=0;i<2;i++) { | |
| spawnParticle(ship.x-Math.cos(ship.angle)*15,ship.y-Math.sin(ship.angle)*15, | |
| -Math.cos(ship.angle)*200+(Math.random()-0.5)*100, | |
| -Math.sin(ship.angle)*200+(Math.random()-0.5)*100, | |
| ship.boostActive?'#f84':'#4af',0.4); | |
| } | |
| } | |
| ship.boostActive = keys['ShiftLeft'] || keys['ShiftRight']; | |
| if (ship.boostActive && ship.boostFuel > 0) ship.boostFuel = Math.max(0, ship.boostFuel-dt); | |
| else if (!ship.boostActive) ship.boostFuel = Math.min(3, ship.boostFuel+dt*0.3); | |
| if (!thrusting) ship.fuel = Math.min(ship.maxFuel, ship.fuel+5*dt); | |
| if (ship.upgrades.overdrive > 0) ship.fuel = Math.min(ship.maxFuel, ship.fuel+3*ship.upgrades.overdrive*dt); | |
| applyGravity(ship, celestialBodies); | |
| if (blackHole) applyGravity(ship, [blackHole]); | |
| ship.x += ship.vx*dt; ship.y += ship.vy*dt; | |
| // Screen wrapping | |
| if (ship.x < -50) ship.x = W+50; | |
| if (ship.x > W+50) ship.x = -50; | |
| if (ship.y < -50) ship.y = H+50; | |
| if (ship.y > H+50) ship.y = -50; | |
| var speed = Math.sqrt(ship.vx*ship.vx+ship.vy*ship.vy); | |
| if (speed > maxSpeed) maxSpeed = speed; | |
| ship.trail.push({x:ship.x,y:ship.y,t:gameTime}); | |
| while (ship.trail.length > 60) ship.trail.shift(); | |
| if (ship.invuln > 0) ship.invuln -= dt; | |
| ship.fireTimer -= dt; | |
| var rate = ship.fireRate * Math.pow(0.7, ship.upgrades.rapid); | |
| if ((keys['Space'] || mouse.down) && ship.fireTimer <= 0) { | |
| ship.fireTimer = rate; fireBullet(); playSound('fire'); | |
| } | |
| for (var i=0;i<celestialBodies.length;i++) { | |
| var b = celestialBodies[i]; | |
| var dx = ship.x-b.x, dy = ship.y-b.y; | |
| var dist = Math.sqrt(dx*dx+dy*dy); | |
| if (dist < 12+b.radius) { | |
| if (ship.invuln<=0){ship.health-=10;screenShake.intensity=8;screenShake.duration=0.3;playSound('graze');} | |
| // Bounce ship away | |
| var overlap = (12+b.radius) - dist; | |
| ship.x += (dx/dist)*overlap*2; | |
| ship.y += (dy/dist)*overlap*2; | |
| ship.vx += (dx/dist)*200; | |
| ship.vy += (dy/dist)*200; | |
| if(ship.health<=0)killShip(); break; | |
| } | |
| } | |
| if (blackHole) { | |
| var dx = ship.x-blackHole.x, dy = ship.y-blackHole.y; | |
| var dist = Math.sqrt(dx*dx+dy*dy); | |
| if (dist < 12+blackHole.radius) { | |
| if (ship.invuln<=0){ship.health-=10;screenShake.intensity=8;screenShake.duration=0.3;playSound('graze');} | |
| var overlap = (12+blackHole.radius) - dist; | |
| ship.x += (dx/dist)*overlap*2; | |
| ship.y += (dy/dist)*overlap*2; | |
| ship.vx += (dx/dist)*200; | |
| ship.vy += (dy/dist)*200; | |
| if(ship.health<=0)killShip(); | |
| } | |
| } | |
| camera.x += (ship.x-camera.x)*0.08; camera.y += (ship.y-camera.y)*0.08; | |
| } | |
| function fireBullet() { | |
| var bx = ship.x+Math.cos(ship.angle)*18, by = ship.y+Math.sin(ship.angle)*18; | |
| if (ship.upgrades.spread > 0) { | |
| for (var i=0;i<1+ship.upgrades.spread;i++) { | |
| var sa = ship.angle+(i-ship.upgrades.spread/2)*0.15; | |
| bullets.push({x:bx,y:by,vx:Math.cos(sa)*600+ship.vx*0.3,vy:Math.sin(sa)*600+ship.vy*0.3, | |
| radius:3,damage:1,life:2,owner:'player',color:'#4ff'}); | |
| } | |
| } else { | |
| bullets.push({x:bx,y:by,vx:Math.cos(ship.angle)*600+ship.vx*0.3,vy:Math.sin(ship.angle)*600+ship.vy*0.3, | |
| radius:3,damage:1,life:2,owner:'player',color:'#4ff'}); | |
| } | |
| } | |
| function killEnemy(e) { | |
| e.alive = false; totalKills++; | |
| var pts = (e.type==='boss'?500:e.type==='gunship'?100:e.type==='mine'?50:25)*(1+combo*0.5); | |
| score += Math.floor(pts); combo++; comboTimer = 3; | |
| addPopup(Math.floor(pts), e.x, e.y); | |
| addShockwave(e.x,e.y,e.type==='boss'?200:80); | |
| screenShake.intensity = e.type==='boss'?15:5; screenShake.duration = e.type==='boss'?0.5:0.15; hitStop = e.type==='boss'?0.2:0.05; | |
| playSound('explosion'); | |
| var pCount = e.type==='boss'?40:15; | |
| for (var i=0;i<pCount;i++) spawnParticle(e.x,e.y,(Math.random()-0.5)*400,(Math.random()-0.5)*400,e.type==='boss'?'#f84':'#f44',0.8); | |
| if (Math.random() < 0.3) spawnPickup(e.x, e.y); | |
| if (enemies.length === 0 && !blackHole) waveTimer = waveDelay; | |
| } | |
| function killShip() { | |
| if (!ship.alive) return; | |
| ship.alive = false; slowMo = 0.2; hitStop = 0.5; | |
| screenShake.intensity = 25; screenShake.duration = 1; playSound('death'); | |
| for (var i=0;i<60;i++) spawnParticle(ship.x,ship.y,(Math.random()-0.5)*500,(Math.random()-0.5)*500,'#ff4',1.5); | |
| addShockwave(ship.x,ship.y,200); | |
| setTimeout(function() { gameState = 'GAMEOVER'; showGameOver(); }, 2000); | |
| } | |
| function applyPickup(type) { | |
| if (type==='fuel') { ship.fuel=Math.min(ship.maxFuel,ship.fuel+40); addPopup('+FUEL',ship.x,ship.y-30); } | |
| else if (type==='shield') { ship.health=Math.min(ship.maxHealth,ship.health+30); ship.invuln=5; addPopup('+SHIELD',ship.x,ship.y-30); } | |
| else if (type==='spread') { ship.upgrades.spread=Math.min(3,ship.upgrades.spread+1); addPopup('SPREAD+',ship.x,ship.y-30); } | |
| else if (type==='rapid') { ship.upgrades.rapid=Math.min(3,ship.upgrades.rapid+1); addPopup('RAPID+',ship.x,ship.y-30); } | |
| else if (type==='bomb') { | |
| addShockwave(ship.x,ship.y,350); | |
| for (var i=enemies.length-1;i>=0;i--) { | |
| var e = enemies[i]; | |
| var dx = e.x-ship.x, dy = e.y-ship.y; | |
| var dist = Math.sqrt(dx*dx+dy*dy); | |
| if (dist < 250) { e.alive = false; killEnemy(e); } | |
| } | |
| addPopup('BOOM!',ship.x,ship.y-30); | |
| } | |
| else if (type==='overdrive') { ship.upgrades.overdrive=Math.min(3,ship.upgrades.overdrive+1); addPopup('OVERDRIVE+',ship.x,ship.y-30); } | |
| } | |
| function updateHUD() { | |
| if (!ship) return; | |
| document.getElementById('health-bar').style.width = (ship.health/ship.maxHealth*100)+'%'; | |
| document.getElementById('fuel-bar').style.width = (ship.fuel/ship.maxFuel*100)+'%'; | |
| document.getElementById('wave-display').textContent = 'WAVE ' + wave; | |
| document.getElementById('score-display').textContent = score; | |
| document.getElementById('combo-display').textContent = combo > 1 ? 'x' + combo : ''; | |
| var ud = document.getElementById('upgrades-display'); | |
| ud.innerHTML = ''; | |
| if (ship.upgrades.spread > 0) ud.innerHTML += '<div class="upgrade-icon" title="Spread">S</div>'; | |
| if (ship.upgrades.rapid > 0) ud.innerHTML += '<div class="upgrade-icon" title="Rapid">R</div>'; | |
| if (ship.upgrades.overdrive > 0) ud.innerHTML += '<div class="upgrade-icon" title="Overdrive">O</div>'; | |
| document.getElementById('highscore-hud').textContent = highScore; | |
| } | |
| function showGameOver() { | |
| showScreen('gameover-screen'); | |
| var newHS = score > highScore; | |
| if (newHS) { highScore = score; localStorage.setItem('orbital_hs', highScore); } | |
| document.getElementById('go-stats').innerHTML = | |
| 'Time: ' + Math.floor(gameTime) + 's<br>Wave: ' + wave + '<br>Kills: ' + totalKills + | |
| '<br>Max Speed: ' + Math.floor(maxSpeed) + '<br>Score: ' + score; | |
| document.getElementById('go-new').style.display = newHS ? 'block' : 'none'; | |
| } | |
| function showUpgradeScreen() { | |
| gameState = 'UPGRADE'; | |
| showScreen('upgrade-screen'); | |
| var allUpgrades = [ | |
| {id:'spread',icon:'S',name:'SPREAD SHOT',desc:'Fire multiple projectiles'}, | |
| {id:'rapid',icon:'R',name:'RAPID FIRE',desc:'Increase fire rate'}, | |
| {id:'overdrive',icon:'O',name:'OVERDRIVE',desc:'Faster fuel regen'}, | |
| {id:'health',icon:'H',name:'REPAIR',desc:'+30 health'}, | |
| {id:'fuel',icon:'F',name:'FUEL CELL',desc:'Full fuel restore'}, | |
| {id:'boost',icon:'B',name:'BOOST',desc:'Restore boost charge'} | |
| ]; | |
| var cards = document.getElementById('upgrade-cards'); | |
| cards.innerHTML = ''; | |
| var choices = []; | |
| var pool = allUpgrades.slice(); | |
| for (var i=0;i<3;i++) { | |
| var idx = Math.floor(Math.random()*pool.length); | |
| choices.push(pool.splice(idx,1)[0]); | |
| } | |
| for (var i=0;i<choices.length;i++) { | |
| var c = choices[i]; | |
| var card = document.createElement('div'); | |
| card.className = 'upgrade-card'; | |
| card.innerHTML = '<div class="uc-icon">' + c.icon + '</div><div class="uc-name">' + c.name + '</div><div class="uc-desc">' + c.desc + '</div>'; | |
| card.addEventListener('click', (function(ch) { | |
| return function() { applyUpgradeCard(ch); }; | |
| })(c)); | |
| cards.appendChild(card); | |
| } | |
| } | |
| function applyUpgradeCard(c) { | |
| if (c.id === 'spread') ship.upgrades.spread = Math.min(3, ship.upgrades.spread+1); | |
| else if (c.id === 'rapid') ship.upgrades.rapid = Math.min(3, ship.upgrades.rapid+1); | |
| else if (c.id === 'overdrive') ship.upgrades.overdrive = Math.min(3, ship.upgrades.overdrive+1); | |
| else if (c.id === 'health') ship.health = Math.min(ship.maxHealth, ship.health+30); | |
| else if (c.id === 'fuel') ship.fuel = ship.maxFuel; | |
| else if (c.id === 'boost') ship.boostFuel = 3; | |
| gameState = 'PLAYING'; | |
| showScreen(null); | |
| nextWave(); | |
| } | |
| // ─── RENDERING ─── | |
| function generateStars() { | |
| stars = []; | |
| for (var i=0;i<300;i++) { | |
| stars.push({x:Math.random()*2000-500,y:Math.random()*2000-500, | |
| size:Math.random()*2+0.5,brightness:Math.random(),twinkleSpeed:Math.random()*3+1, | |
| layer:Math.floor(Math.random()*3)}); | |
| } | |
| } | |
| function generateNebulae() { | |
| nebulae = []; | |
| for (var i=0;i<5;i++) { | |
| nebulae.push({x:Math.random()*W,y:Math.random()*H,radius:Math.random()*200+100, | |
| color:['#1a0a3a','#0a1a3a','#1a0a2a','#0a2a1a','#2a0a1a'][i],opacity:0.08}); | |
| } | |
| } | |
| function render() { | |
| ctx.clearRect(0,0,W,H); | |
| // Background gradient | |
| var bg = ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)); | |
| bg.addColorStop(0,'#0a0a1a'); bg.addColorStop(1,'#000005'); | |
| ctx.fillStyle = bg; ctx.fillRect(0,0,W,H); | |
| // Nebulae | |
| for (var i=0;i<nebulae.length;i++) { | |
| var n = nebulae[i]; | |
| var ng = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,n.radius); | |
| ng.addColorStop(0,n.color); ng.addColorStop(1,'transparent'); | |
| ctx.fillStyle = ng; ctx.fillRect(n.x-n.radius,n.y-n.radius,n.radius*2,n.radius*2); | |
| } | |
| // Stars with parallax | |
| for (var i=0;i<stars.length;i++) { | |
| var s = stars[i]; | |
| var px = (s.x - camera.x*0.01*s.layer) % W; | |
| var py = (s.y - camera.y*0.01*s.layer) % H; | |
| if (px < 0) px += W; if (py < 0) py += H; | |
| var twinkle = 0.5 + 0.5*Math.sin(gameTime*s.twinkleSpeed); | |
| ctx.globalAlpha = s.brightness * twinkle; | |
| ctx.fillStyle = '#fff'; | |
| ctx.beginPath(); ctx.arc(px,py,s.size,0,Math.PI*2); ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1; | |
| if (gameState === 'TITLE') { | |
| renderTitleScene(); | |
| return; | |
| } | |
| // Camera transform | |
| ctx.save(); | |
| ctx.translate(camera.shakeX, camera.shakeY); | |
| // Gravity field lines | |
| renderGravityField(); | |
| // Celestial bodies | |
| for (var i=0;i<celestialBodies.length;i++) renderCelestial(celestialBodies[i]); | |
| // Black hole | |
| if (blackHole) renderBlackHole(blackHole); | |
| // Ship trail | |
| if (ship && ship.trail.length > 1) { | |
| ctx.beginPath(); | |
| ctx.moveTo(ship.trail[0].x, ship.trail[0].y); | |
| for (var i=1;i<ship.trail.length;i++) { | |
| ctx.lineTo(ship.trail[i].x, ship.trail[i].y); | |
| } | |
| ctx.strokeStyle = 'rgba(0,170,255,0.3)'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| } | |
| // Ship | |
| if (ship && ship.alive) renderShip(); | |
| // Trajectory prediction | |
| if (ship && ship.alive) renderTrajectory(); | |
| // Enemies | |
| for (var i=0;i<enemies.length;i++) renderEnemy(enemies[i]); | |
| // Bullets | |
| for (var i=0;i<bullets.length;i++) renderBullet(bullets[i]); | |
| // Pickups | |
| for (var i=0;i<pickups.length;i++) renderPickup(pickups[i]); | |
| // Particles | |
| ctx.globalCompositeOperation = 'lighter'; | |
| for (var i=0;i<particles.length;i++) { | |
| var p = particles[i]; | |
| var alpha = p.life / p.maxLife; | |
| ctx.globalAlpha = alpha; | |
| ctx.fillStyle = p.color; | |
| ctx.beginPath(); ctx.arc(p.x,p.y,2+alpha*2,0,Math.PI*2); ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| // Shockwaves | |
| for (var i=0;i<shockwaves.length;i++) { | |
| var s = shockwaves[i]; | |
| var alpha = s.life / 0.5; | |
| ctx.strokeStyle = 'rgba(255,255,255,' + alpha*0.5 + ')'; | |
| ctx.lineWidth = 3*alpha; | |
| ctx.beginPath(); ctx.arc(s.x,s.y,s.radius,0,Math.PI*2); ctx.stroke(); | |
| } | |
| // Popups | |
| for (var i=0;i<popups.length;i++) { | |
| var p = popups[i]; | |
| ctx.globalAlpha = p.life / 1.5; | |
| ctx.fillStyle = '#ff4'; | |
| ctx.font = 'bold 16px Orbitron'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(p.text, p.x, p.y); | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.restore(); | |
| // Radar | |
| renderRadar(); | |
| } | |
| function renderTitleScene() { | |
| // Draw a mini solar system for the title | |
| var star = {x:W/2,y:H/2,mass:8000,radius:50,type:'star',pulse:gameTime*3}; | |
| renderCelestial(star); | |
| var planet = {x:W/2+150*Math.cos(gameTime*0.3),y:H/2+150*Math.sin(gameTime*0.3), | |
| mass:1200,radius:28,type:'planet',color:'#4af',pulse:gameTime*2}; | |
| renderCelestial(planet); | |
| if (titleShip) { | |
| applyGravity(titleShip, [star, planet]); | |
| titleShip.x += titleShip.vx*dt; titleShip.y += titleShip.vy*dt; | |
| titleShip.angle = Math.atan2(titleShip.vy, titleShip.vx); | |
| titleShip.trail.push({x:titleShip.x,y:titleShip.y}); | |
| while (titleShip.trail.length > 40) titleShip.trail.shift(); | |
| if (titleShip.trail.length > 1) { | |
| ctx.beginPath(); ctx.moveTo(titleShip.trail[0].x,titleShip.trail[0].y); | |
| for (var i=1;i<titleShip.trail.length;i++) ctx.lineTo(titleShip.trail[i].x,titleShip.trail[i].y); | |
| ctx.strokeStyle = 'rgba(0,170,255,0.3)'; ctx.lineWidth = 2; ctx.stroke(); | |
| } | |
| ctx.save(); | |
| ctx.translate(titleShip.x, titleShip.y); | |
| ctx.rotate(titleShip.angle); | |
| ctx.fillStyle = '#4af'; | |
| ctx.beginPath(); ctx.moveTo(12,0); ctx.lineTo(-8,-6); ctx.lineTo(-8,6); ctx.closePath(); ctx.fill(); | |
| ctx.restore(); | |
| // Reset if too far | |
| var dx = titleShip.x-W/2, dy = titleShip.y-H/2; | |
| if (dx*dx+dy*dy > 100000) { | |
| titleShip.x = W/2; titleShip.y = H/2+100; | |
| titleShip.vx = 200; titleShip.vy = -100; | |
| titleShip.trail = []; | |
| } | |
| } | |
| } | |
| function renderGravityField() { | |
| ctx.strokeStyle = 'rgba(0,100,255,0.04)'; | |
| ctx.lineWidth = 1; | |
| var spacing = 60; | |
| for (var x=0;x<W;x+=spacing) { | |
| ctx.beginPath(); | |
| for (var y=0;y<H;y+=10) { | |
| var fx = 0, fy = 0; | |
| for (var i=0;i<celestialBodies.length;i++) { | |
| var b = celestialBodies[i]; | |
| var dx = b.x-x, dy = b.y-y; | |
| var d2 = dx*dx+dy*dy+b.radius*b.radius*4; | |
| var f = b.mass/d2; | |
| fx += (dx/d2)*f*500; fy += (dy/d2)*f*500; | |
| } | |
| if (blackHole) { | |
| var dx = blackHole.x-x, dy = blackHole.y-y; | |
| var d2 = dx*dx+dy*dy+blackHole.radius*blackHole.radius*4; | |
| var f = blackHole.mass/d2; | |
| fx += (dx/d2)*f*500; fy += (dy/d2)*f*500; | |
| } | |
| if (y===0) ctx.moveTo(x+fx,y+fy); | |
| else ctx.lineTo(x+fx,y+fy); | |
| } | |
| ctx.stroke(); | |
| } | |
| } | |
| function renderCelestial(b) { | |
| if (b.type === 'star') { | |
| var pulse = 1 + 0.1*Math.sin(b.pulse); | |
| var r = b.radius * pulse; | |
| // Outer glow | |
| var g1 = ctx.createRadialGradient(b.x,b.y,r*0.5,b.x,b.y,r*4); | |
| g1.addColorStop(0,'rgba(255,200,50,0.3)'); g1.addColorStop(1,'transparent'); | |
| ctx.fillStyle = g1; ctx.beginPath(); ctx.arc(b.x,b.y,r*4,0,Math.PI*2); ctx.fill(); | |
| // Core | |
| var g2 = ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,r); | |
| g2.addColorStop(0,'#fff'); g2.addColorStop(0.3,'#ffa'); g2.addColorStop(1,'#f80'); | |
| ctx.fillStyle = g2; ctx.beginPath(); ctx.arc(b.x,b.y,r,0,Math.PI*2); ctx.fill(); | |
| } else { | |
| var pulse = 1 + 0.05*Math.sin(b.pulse); | |
| var r = b.radius * pulse; | |
| // Atmospheric rim | |
| var g1 = ctx.createRadialGradient(b.x,b.y,r*0.8,b.x,b.y,r*1.5); | |
| g1.addColorStop(0,b.color); g1.addColorStop(1,'transparent'); | |
| ctx.fillStyle = g1; ctx.beginPath(); ctx.arc(b.x,b.y,r*1.5,0,Math.PI*2); ctx.fill(); | |
| // Body | |
| var g2 = ctx.createRadialGradient(b.x-r*0.3,b.y-r*0.3,0,b.x,b.y,r); | |
| g2.addColorStop(0,'#fff'); g2.addColorStop(0.5,b.color); g2.addColorStop(1,'#111'); | |
| ctx.fillStyle = g2; ctx.beginPath(); ctx.arc(b.x,b.y,r,0,Math.PI*2); ctx.fill(); | |
| } | |
| } | |
| function renderBlackHole(bh) { | |
| // Accretion disk | |
| ctx.save(); | |
| ctx.translate(bh.x, bh.y); | |
| ctx.rotate(bh.pulse*0.2); | |
| for (var i=0;i<3;i++) { | |
| var r = bh.radius + 20 + i*15; | |
| ctx.strokeStyle = 'rgba(100,0,255,' + (0.3-i*0.08) + ')'; | |
| ctx.lineWidth = 8-i*2; | |
| ctx.beginPath(); | |
| ctx.ellipse(0,0,r,r*0.3,0,0,Math.PI*2); | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| // Core | |
| var g = ctx.createRadialGradient(bh.x,bh.y,0,bh.x,bh.y,bh.radius); | |
| g.addColorStop(0,'#000'); g.addColorStop(0.7,'#100020'); g.addColorStop(1,'#400080'); | |
| ctx.fillStyle = g; | |
| ctx.beginPath(); ctx.arc(bh.x,bh.y,bh.radius,0,Math.PI*2); ctx.fill(); | |
| // Lensing ring | |
| ctx.strokeStyle = 'rgba(150,100,255,0.5)'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.arc(bh.x,bh.y,bh.radius+5,0,Math.PI*2); ctx.stroke(); | |
| } | |
| function renderShip() { | |
| ctx.save(); | |
| ctx.translate(ship.x, ship.y); | |
| ctx.rotate(ship.angle); | |
| if (ship.invuln > 0 && Math.sin(gameTime*20) > 0) { ctx.globalAlpha = 0.5; } | |
| // Ship body | |
| ctx.fillStyle = '#4af'; | |
| ctx.beginPath(); ctx.moveTo(14,0); ctx.lineTo(-10,-7); ctx.lineTo(-6,0); ctx.lineTo(-10,7); ctx.closePath(); ctx.fill(); | |
| // Cockpit | |
| ctx.fillStyle = '#fff'; | |
| ctx.beginPath(); ctx.arc(4,0,2,0,Math.PI*2); ctx.fill(); | |
| // Engine glow | |
| if (keys['ArrowUp'] || keys['KeyW']) { | |
| ctx.globalCompositeOperation = 'lighter'; | |
| var eg = ctx.createRadialGradient(-10,0,0,-10,0,15); | |
| eg.addColorStop(0,ship.boostActive?'#f84':'#4af'); eg.addColorStop(1,'transparent'); | |
| ctx.fillStyle = eg; ctx.beginPath(); ctx.arc(-10,0,15,0,Math.PI*2); ctx.fill(); | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| // Shield indicator | |
| if (ship.invuln > 0) { | |
| ctx.strokeStyle = 'rgba(0,255,200,0.5)'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.arc(0,0,18,0,Math.PI*2); ctx.stroke(); | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.restore(); | |
| } | |
| function renderTrajectory() { | |
| var px = ship.x, py = ship.y, pvx = ship.vx, pvy = ship.vy; | |
| ctx.setLineDash([3,6]); | |
| ctx.strokeStyle = 'rgba(0,170,255,0.4)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(px,py); | |
| for (var i=0;i<30;i++) { | |
| var tvx = pvx, tvy = pvy; | |
| for (var j=0;j<celestialBodies.length;j++) { | |
| var b = celestialBodies[j]; | |
| var dx = b.x-px, dy = b.y-py; | |
| var d2 = dx*dx+dy*dy+b.radius*b.radius*4; | |
| var f = b.mass/d2; | |
| tvx += (dx/d2)*f*0.016*60; tvy += (dy/d2)*f*0.016*60; | |
| } | |
| if (blackHole) { | |
| var dx = blackHole.x-px, dy = blackHole.y-py; | |
| var d2 = dx*dx+dy*dy+blackHole.radius*blackHole.radius*4; | |
| var f = blackHole.mass/d2; | |
| tvx += (dx/d2)*f*0.016*60; tvy += (dy/d2)*f*0.016*60; | |
| } | |
| px += tvx*0.016; py += tvy*0.016; pvx = tvx; pvy = tvy; | |
| ctx.lineTo(px,py); | |
| } | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| function renderEnemy(e) { | |
| ctx.save(); | |
| ctx.translate(e.x, e.y); | |
| if (e.type === 'drone') { | |
| ctx.rotate(e.angle); | |
| ctx.fillStyle = '#f44'; | |
| ctx.beginPath(); ctx.moveTo(12,0); ctx.lineTo(-6,-5); ctx.lineTo(-6,5); ctx.closePath(); ctx.fill(); | |
| } else if (e.type === 'mine') { | |
| ctx.fillStyle = '#f84'; | |
| var pulse = 1 + 0.2*Math.sin(gameTime*5); | |
| ctx.beginPath(); ctx.arc(0,0,e.radius*pulse,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle = '#fa0'; | |
| ctx.beginPath(); ctx.arc(0,0,e.radius*0.4,0,Math.PI*2); ctx.fill(); | |
| } else if (e.type === 'gunship') { | |
| ctx.rotate(e.angle); | |
| ctx.fillStyle = '#a44'; | |
| ctx.beginPath(); ctx.moveTo(22,0); ctx.lineTo(-14,-10); ctx.lineTo(-10,0); ctx.lineTo(-14,10); ctx.closePath(); ctx.fill(); | |
| // HP bar | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; | |
| ctx.fillRect(-15,-18,30,4); | |
| ctx.fillStyle = '#f44'; | |
| ctx.fillRect(-15,-18,30*(e.health/e.maxHealth),4); | |
| } else if (e.type === 'boss') { | |
| ctx.rotate(gameTime*0.3); | |
| ctx.fillStyle = '#f84'; | |
| for (var i=0;i<6;i++) { | |
| var a = i*Math.PI/3; | |
| ctx.beginPath(); | |
| ctx.moveTo(0,0); | |
| ctx.lineTo(Math.cos(a)*e.radius, Math.sin(a)*e.radius); | |
| ctx.lineTo(Math.cos(a+0.3)*e.radius*0.6, Math.sin(a+0.3)*e.radius*0.6); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| // HP bar | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; | |
| ctx.fillRect(-30,-50,60,6); | |
| ctx.fillStyle = '#f84'; | |
| ctx.fillRect(-30,-50,60*(e.health/e.maxHealth),6); | |
| } | |
| ctx.restore(); | |
| } | |
| function renderBullet(b) { | |
| ctx.globalCompositeOperation = 'lighter'; | |
| ctx.fillStyle = b.color; | |
| ctx.beginPath(); ctx.arc(b.x,b.y,b.radius,0,Math.PI*2); ctx.fill(); | |
| // Glow | |
| var g = ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,b.radius*3); | |
| g.addColorStop(0,b.color); g.addColorStop(1,'transparent'); | |
| ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x,b.y,b.radius*3,0,Math.PI*2); ctx.fill(); | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| function renderPickup(p) { | |
| ctx.save(); | |
| ctx.translate(p.x, p.y); | |
| var pulse = 1 + 0.15*Math.sin(gameTime*4); | |
| var colors = {fuel:'#4f8',shield:'#4ff',spread:'#f44',rapid:'#ff4',bomb:'#f84',overdrive:'#a4f'}; | |
| var c = colors[p.type] || '#fff'; | |
| // Glow | |
| ctx.globalCompositeOperation = 'lighter'; | |
| var g = ctx.createRadialGradient(0,0,0,0,0,p.radius*2.5); | |
| g.addColorStop(0,c); g.addColorStop(1,'transparent'); | |
| ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0,0,p.radius*2.5,0,Math.PI*2); ctx.fill(); | |
| ctx.globalCompositeOperation = 'source-over'; | |
| // Icon | |
| ctx.fillStyle = c; | |
| ctx.beginPath(); ctx.arc(0,0,p.radius*pulse,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle = '#000'; | |
| ctx.font = 'bold 10px Orbitron'; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| var labels = {fuel:'F',shield:'S',spread:'SP',rapid:'RP',bomb:'B',overdrive:'O'}; | |
| ctx.fillText(labels[p.type]||'?',0,0); | |
| ctx.restore(); | |
| } | |
| function renderRadar() { | |
| if (gameState !== 'PLAYING') return; | |
| radarCtx.clearRect(0,0,80,80); | |
| radarCtx.fillStyle = 'rgba(0,0,0,0.3)'; | |
| radarCtx.beginPath(); radarCtx.arc(40,40,38,0,Math.PI*2); radarCtx.fill(); | |
| // Ship | |
| if (ship) { | |
| var sx = 40 + (ship.x-W/2)/W*70; | |
| var sy = 40 + (ship.y-H/2)/H*70; | |
| sx = Math.max(5,Math.min(75,sx)); sy = Math.max(5,Math.min(75,sy)); | |
| radarCtx.fillStyle = '#4af'; | |
| radarCtx.beginPath(); radarCtx.arc(sx,sy,2,0,Math.PI*2); radarCtx.fill(); | |
| } | |
| // Enemies | |
| for (var i=0;i<enemies.length;i++) { | |
| var e = enemies[i]; | |
| var ex = 40+(e.x-W/2)/W*70, ey = 40+(e.y-H/2)/H*70; | |
| ex = Math.max(5,Math.min(75,ex)); ey = Math.max(5,Math.min(75,ey)); | |
| radarCtx.fillStyle = '#f44'; | |
| radarCtx.beginPath(); radarCtx.arc(ex,ey,1.5,0,Math.PI*2); radarCtx.fill(); | |
| } | |
| // Pickups | |
| for (var i=0;i<pickups.length;i++) { | |
| var p = pickups[i]; | |
| var px = 40+(p.x-W/2)/W*70, py = 40+(p.y-H/2)/H*70; | |
| px = Math.max(5,Math.min(75,px)); py = Math.max(5,Math.min(75,py)); | |
| radarCtx.fillStyle = '#4f8'; | |
| radarCtx.beginPath(); radarCtx.arc(px,py,1.5,0,Math.PI*2); radarCtx.fill(); | |
| } | |
| } | |
| // ─── GAME LOOP ─── | |
| function loop(timestamp) { | |
| dt = Math.min((timestamp - lastTime) / 1000, 0.05); | |
| lastTime = timestamp; | |
| update(); | |
| render(); | |
| requestAnimationFrame(loop); | |
| } | |
| </script> | |
| </body> | |
| </html> | |