| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Qwopus Commander</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| html, body { width: 100%; height: 100%; overflow: hidden; background: #000; cursor: none; } |
| canvas { display: block; width: 100%; height: 100%; } |
| </style> |
| </head> |
| <body> |
| <canvas id="c"></canvas> |
| <script> |
| "use strict"; |
| |
| |
| const WORLD_W = 3000, WORLD_H = 3000; |
| const PLAYER_MAX_HP = 100; |
| const PLAYER_SPEED = 280; |
| const PLAYER_DRAG = 6; |
| const PLAYER_ACCEL = 1200; |
| const BULLET_SPEED = 600; |
| const BULLET_COOLDOWN = 0.12; |
| const DASH_SPEED = 900; |
| const DASH_DURATION = 0.12; |
| const DASH_COOLDOWN = 0.8; |
| const DASH_INVULN = 0.18; |
| const ARENA_MARGIN = 100; |
| |
| |
| const ENEMY_TYPES = { |
| grunt: { radius: 14, hp: 20, speed: 140, color: '#ff4466', glow: '#ff2244', points: 10, shoot: false, contactDmg: 10 }, |
| scout: { radius: 9, hp: 8, speed: 90, color: '#88ff44', glow: '#44ee22', points: 15, shoot: true, contactDmg: 6, shootCd: 1.1, range: 700, projSpeed: 280, projColor: '#88ff44', projRadius: 2.5 }, |
| runner: { radius: 10, hp: 10, speed: 420, color: '#ffaa22', glow: '#ff8800', points: 15, shoot: false, contactDmg: 30 }, |
| tank: { radius: 24, hp: 80, speed: 70, color: '#aa44ff', glow: '#8822dd', points: 30, shoot: false, contactDmg: 20 }, |
| shooter: { radius: 16, hp: 30, speed: 100, color: '#44ffaa', glow: '#22dd88', points: 25, shoot: true, contactDmg: 10, shootCd: 2.0, range: 400 } |
| }; |
| |
| |
| const BOSS_TYPES = { |
| sentinel: { name: 'SENTINEL', radius: 42, hp: 600, speed: 60, color: '#ff2288', glow: '#ff0066', points: 200, contactDmg: 30 }, |
| carrier: { name: 'CARRIER', radius: 52, hp: 1000, speed: 60, color: '#00ccff', glow: '#0088dd', points: 300, contactDmg: 25 }, |
| warden: { name: 'WARDEN', radius: 38, hp: 800, speed: 60, color: '#ff8800', glow: '#dd6600', points: 250, contactDmg: 35 } |
| }; |
| |
| |
| const POWERUP_TYPES = { |
| rapid: { color: '#00ffff', glow: '#0088ff', label: 'RAPID', duration: 12 }, |
| triple: { color: '#ff00ff', glow: '#880088', label: 'TRIPLE', duration: 12 }, |
| shield: { color: '#ffff00', glow: '#888800', label: 'SHIELD', duration: 0 } |
| }; |
| |
| |
| const canvas = document.getElementById('c'); |
| const ctx = canvas.getContext('2d'); |
| let W, H; |
| |
| function resize() { |
| W = canvas.width = window.innerWidth; |
| H = canvas.height = window.innerHeight; |
| if (chromaCanvas) { |
| chromaCanvas.width = W; |
| chromaCanvas.height = H; |
| } |
| } |
| |
| const chromaCanvas = document.createElement('canvas'); |
| const chromaCtx = chromaCanvas.getContext('2d'); |
| window.addEventListener('resize', resize); |
| resize(); |
| |
| |
| let audioCtx = null; |
| let droneNodes = null; |
| let droneGain = null; |
| let droneFilter = null; |
| let droneLfo = null; |
| |
| function initAudio() { |
| if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); |
| if (audioCtx.state === 'suspended') audioCtx.resume(); |
| if (!droneNodes) startDrone(); |
| } |
| |
| function startDrone() { |
| if (!audioCtx) return; |
| droneGain = audioCtx.createGain(); |
| droneGain.gain.setValueAtTime(0, audioCtx.currentTime); |
| droneGain.gain.linearRampToValueAtTime(0.025, audioCtx.currentTime + 2); |
| |
| droneFilter = audioCtx.createBiquadFilter(); |
| droneFilter.type = 'lowpass'; |
| droneFilter.frequency.setValueAtTime(120, audioCtx.currentTime); |
| droneFilter.Q.setValueAtTime(2, audioCtx.currentTime); |
| |
| droneLfo = audioCtx.createOscillator(); |
| droneLfo.frequency.setValueAtTime(0.15, audioCtx.currentTime); |
| const lfoGain = audioCtx.createGain(); |
| lfoGain.gain.setValueAtTime(40, audioCtx.currentTime); |
| droneLfo.connect(lfoGain); |
| lfoGain.connect(droneFilter.frequency); |
| droneLfo.start(); |
| |
| const osc1 = audioCtx.createOscillator(); |
| osc1.type = 'sawtooth'; |
| osc1.frequency.setValueAtTime(55, audioCtx.currentTime); |
| |
| const osc2 = audioCtx.createOscillator(); |
| osc2.type = 'sawtooth'; |
| osc2.frequency.setValueAtTime(58, audioCtx.currentTime); |
| |
| osc1.connect(droneFilter); |
| osc2.connect(droneFilter); |
| droneFilter.connect(droneGain); |
| droneGain.connect(audioCtx.destination); |
| |
| osc1.start(); |
| osc2.start(); |
| |
| droneNodes = { osc1, osc2, lfoGain }; |
| } |
| |
| function stopDrone() { |
| if (droneGain) { |
| droneGain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.5); |
| } |
| } |
| |
| function setDroneVolume(isBoss) { |
| if (!droneGain) return; |
| const target = isBoss ? 0.04 : 0.025; |
| droneGain.gain.linearRampToValueAtTime(target, audioCtx.currentTime + 1); |
| } |
| |
| function playSound(type) { |
| if (!audioCtx) return; |
| const now = audioCtx.currentTime; |
| const g = audioCtx.createGain(); |
| g.connect(audioCtx.destination); |
| |
| if (type === 'shoot') { |
| const o = audioCtx.createOscillator(); |
| o.type = 'sawtooth'; |
| o.frequency.setValueAtTime(880, now); |
| o.frequency.exponentialRampToValueAtTime(220, now + 0.08); |
| g.gain.setValueAtTime(0.12, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.08); |
| o.connect(g); o.start(now); o.stop(now + 0.08); |
| } else if (type === 'hit') { |
| const o = audioCtx.createOscillator(); |
| o.type = 'square'; |
| o.frequency.setValueAtTime(300, now); |
| o.frequency.exponentialRampToValueAtTime(100, now + 0.06); |
| g.gain.setValueAtTime(0.1, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.06); |
| o.connect(g); o.start(now); o.stop(now + 0.06); |
| } else if (type === 'explosion') { |
| const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.15, audioCtx.sampleRate); |
| const d = buf.getChannelData(0); |
| for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 2); |
| const s = audioCtx.createBufferSource(); |
| s.buffer = buf; |
| g.gain.setValueAtTime(0.18, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.15); |
| s.connect(g); s.start(now); |
| } else if (type === 'dash') { |
| const o = audioCtx.createOscillator(); |
| o.type = 'sine'; |
| o.frequency.setValueAtTime(400, now); |
| o.frequency.exponentialRampToValueAtTime(1200, now + 0.1); |
| g.gain.setValueAtTime(0.08, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.12); |
| o.connect(g); o.start(now); o.stop(now + 0.12); |
| } else if (type === 'hurt') { |
| const o = audioCtx.createOscillator(); |
| o.type = 'sawtooth'; |
| o.frequency.setValueAtTime(150, now); |
| o.frequency.exponentialRampToValueAtTime(60, now + 0.15); |
| g.gain.setValueAtTime(0.15, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.15); |
| o.connect(g); o.start(now); o.stop(now + 0.15); |
| } else if (type === 'enemyShoot') { |
| const o = audioCtx.createOscillator(); |
| o.type = 'triangle'; |
| o.frequency.setValueAtTime(500, now); |
| o.frequency.exponentialRampToValueAtTime(150, now + 0.1); |
| g.gain.setValueAtTime(0.06, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.1); |
| o.connect(g); o.start(now); o.stop(now + 0.1); |
| } else if (type === 'wave') { |
| const o = audioCtx.createOscillator(); |
| o.type = 'sawtooth'; |
| o.frequency.setValueAtTime(880, now); |
| o.frequency.exponentialRampToValueAtTime(220, now + 0.6); |
| g.gain.setValueAtTime(0.1, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.6); |
| o.connect(g); o.start(now); o.stop(now + 0.6); |
| } else if (type === 'powerup') { |
| const o = audioCtx.createOscillator(); |
| o.type = 'sine'; |
| o.frequency.setValueAtTime(600, now); |
| o.frequency.exponentialRampToValueAtTime(1200, now + 0.15); |
| g.gain.setValueAtTime(0.1, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.2); |
| o.connect(g); o.start(now); o.stop(now + 0.2); |
| } else if (type === 'combo') { |
| const o = audioCtx.createOscillator(); |
| o.type = 'sine'; |
| o.frequency.setValueAtTime(800, now); |
| o.frequency.exponentialRampToValueAtTime(1600, now + 0.08); |
| g.gain.setValueAtTime(0.06, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.1); |
| o.connect(g); o.start(now); o.stop(now + 0.1); |
| } else if (type === 'bossDeath') { |
| const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.5, audioCtx.sampleRate); |
| const d = buf.getChannelData(0); |
| for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.5); |
| const s = audioCtx.createBufferSource(); |
| s.buffer = buf; |
| const f = audioCtx.createBiquadFilter(); |
| f.type = 'lowpass'; |
| f.frequency.setValueAtTime(2000, now); |
| f.frequency.exponentialRampToValueAtTime(100, now + 0.5); |
| s.connect(f); f.connect(g); |
| g.gain.setValueAtTime(0.25, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.5); |
| s.start(now); |
| } else if (type === 'laser') { |
| const o = audioCtx.createOscillator(); |
| o.type = 'sawtooth'; |
| o.frequency.setValueAtTime(200, now); |
| o.frequency.linearRampToValueAtTime(400, now + 0.1); |
| g.gain.setValueAtTime(0.04, now); |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.15); |
| o.connect(g); o.start(now); o.stop(now + 0.15); |
| } |
| } |
| |
| |
| class Pool { |
| constructor(createFn, resetFn, initialSize) { |
| this.pool = []; |
| this.active = []; |
| this.createFn = createFn; |
| this.resetFn = resetFn; |
| for (let i = 0; i < (initialSize || 64); i++) this.pool.push(createFn()); |
| } |
| get() { |
| const obj = this.pool.length === 0 ? this.createFn() : this.pool.pop(); |
| this.active.push(obj); |
| return obj; |
| } |
| release(obj) { |
| const i = this.active.indexOf(obj); |
| if (i !== -1) this.active.splice(i, 1); |
| this.resetFn(obj); |
| this.pool.push(obj); |
| } |
| releaseAll() { |
| while (this.active.length > 0) { |
| const obj = this.active.pop(); |
| this.resetFn(obj); |
| this.pool.push(obj); |
| } |
| } |
| prune(predicateAlive) { |
| for (let i = this.active.length - 1; i >= 0; i--) { |
| if (!predicateAlive(this.active[i])) { |
| const obj = this.active.splice(i, 1)[0]; |
| this.resetFn(obj); |
| this.pool.push(obj); |
| } |
| } |
| } |
| forEach(fn) { for (let i = 0; i < this.active.length; i++) fn(this.active[i], i); } |
| get length() { return this.active.length; } |
| } |
| |
| |
| const particlePool = new Pool( |
| () => ({ x: 0, y: 0, vx: 0, vy: 0, life: 0, maxLife: 0, color: '#fff', size: 2, type: 'circle' }), |
| p => { p.life = 0; } |
| ); |
| |
| function spawnParticle(x, y, vx, vy, life, color, size, type) { |
| const p = particlePool.get(); |
| p.x = x; p.y = y; p.vx = vx; p.vy = vy; |
| p.life = life; p.maxLife = life; |
| p.color = color; p.size = size; p.type = type || 'circle'; |
| return p; |
| } |
| |
| function emitExplosion(x, y, color, count, speed, sizeRange) { |
| for (let i = 0; i < count; i++) { |
| const a = Math.random() * Math.PI * 2; |
| const s = Math.random() * speed; |
| const sz = sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]); |
| spawnParticle(x, y, Math.cos(a) * s, Math.sin(a) * s, 0.3 + Math.random() * 0.4, color, sz, 'circle'); |
| } |
| } |
| |
| function emitHitSparks(x, y, color, count) { |
| count = count || 5; |
| for (let i = 0; i < count; i++) { |
| const a = Math.random() * Math.PI * 2; |
| const s = 100 + Math.random() * 200; |
| spawnParticle(x, y, Math.cos(a) * s, Math.sin(a) * s, 0.15 + Math.random() * 0.15, |
| Math.random() > 0.5 ? '#fff' : color, 1 + Math.random() * 2, 'circle'); |
| } |
| } |
| |
| function emitDashTrail(x, y, color) { |
| spawnParticle(x + (Math.random() - 0.5) * 8, y + (Math.random() - 0.5) * 8, |
| (Math.random() - 0.5) * 20, (Math.random() - 0.5) * 20, |
| 0.2 + Math.random() * 0.2, color, 3 + Math.random() * 3, 'circle'); |
| } |
| |
| function emitThruster(x, y, angle) { |
| const a = angle + Math.PI + (Math.random() - 0.5) * 0.6; |
| const s = 30 + Math.random() * 40; |
| spawnParticle(x, y, Math.cos(a) * s, Math.sin(a) * s, |
| 0.1 + Math.random() * 0.15, Math.random() > 0.5 ? '#ff6622' : '#ffaa44', 2 + Math.random() * 2, 'circle'); |
| } |
| |
| |
| const bulletPool = new Pool( |
| () => ({ x: 0, y: 0, vx: 0, vy: 0, life: 0, owner: null, radius: 3, color: '#0ff', |
| trail: [], homing: false, turnRate: 0, angle: 0, isScout: false }), |
| b => { b.life = 0; b.trail = []; b.homing = false; b.isScout = false; } |
| ); |
| |
| function spawnBullet(x, y, angle, owner, color, speed, radius, homing, turnRate, isScout) { |
| const b = bulletPool.get(); |
| b.x = x; b.y = y; |
| b.vx = Math.cos(angle) * (speed || BULLET_SPEED); |
| b.vy = Math.sin(angle) * (speed || BULLET_SPEED); |
| b.owner = owner; |
| b.life = (owner === 'enemy') ? 3.5 : 2.0; |
| b.color = color || '#0ff'; |
| b.radius = radius || 3; |
| b.trail = []; |
| b.homing = homing || false; |
| b.turnRate = turnRate || 0; |
| b.angle = angle; |
| b.isScout = isScout || false; |
| return b; |
| } |
| |
| |
| const enemyPool = new Pool( |
| () => ({ x: 0, y: 0, vx: 0, vy: 0, type: 'grunt', hp: 10, maxHp: 10, radius: 10, |
| angle: 0, shootTimer: 0, flashTimer: 0, alive: true, gruntShootTimer: 0, |
| burstTimer: 0, armed: false, armedTelegraph: 0 }), |
| e => { e.alive = false; e.flashTimer = 0; e.gruntShootTimer = 0; e.armed = false; e.armedTelegraph = 0; } |
| ); |
| |
| function spawnEnemy(type, x, y) { |
| const def = ENEMY_TYPES[type]; |
| const e = enemyPool.get(); |
| e.x = x; e.y = y; e.vx = 0; e.vy = 0; |
| e.type = type; e.hp = def.hp; e.maxHp = def.hp; |
| e.radius = def.radius; e.angle = 0; |
| e.shootTimer = 0; e.flashTimer = 0; e.alive = true; |
| e.gruntShootTimer = Math.random() * 4; |
| e.burstTimer = 0; |
| e.armed = false; |
| e.armedTelegraph = 0; |
| return e; |
| } |
| |
| function spawnEnemyAtEdge(type) { |
| const side = Math.floor(Math.random() * 4); |
| let x, y; |
| if (side === 0) { x = Math.random() * WORLD_W; y = -ARENA_MARGIN; } |
| else if (side === 1) { x = WORLD_W + ARENA_MARGIN; y = Math.random() * WORLD_H; } |
| else if (side === 2) { x = Math.random() * WORLD_W; y = WORLD_H + ARENA_MARGIN; } |
| else { x = -ARENA_MARGIN; y = Math.random() * WORLD_H; } |
| return spawnEnemy(type, x, y); |
| } |
| |
| |
| const bossPool = new Pool( |
| () => ({ x: 0, y: 0, vx: 0, vy: 0, type: 'sentinel', hp: 100, maxHp: 100, radius: 40, |
| angle: 0, flashTimer: 0, alive: true, phase: 1, timer: 0, orbitAngle: 0, |
| laserAngle: 0, spawnMinionTimer: 0, missileTimer: 0, rapidFireTimer: 0, |
| burstTimer: 0, deathAnim: 0, deathPhase: 0 }), |
| b => { b.alive = false; b.flashTimer = 0; b.deathAnim = 0; } |
| ); |
| |
| function spawnBoss(type, x, y) { |
| const def = BOSS_TYPES[type]; |
| const b = bossPool.get(); |
| b.x = x; b.y = y; b.vx = 0; b.vy = 0; |
| b.type = type; b.hp = def.hp; b.maxHp = def.hp; |
| b.radius = def.radius; b.angle = 0; |
| b.flashTimer = 0; b.alive = true; b.phase = 1; b.timer = 0; |
| b.orbitAngle = Math.random() * Math.PI * 2; |
| b.laserAngle = 0; b.spawnMinionTimer = 3; b.missileTimer = 3; |
| b.rapidFireTimer = 0; b.burstTimer = 1.5; |
| b.deathAnim = 0; b.deathPhase = 0; |
| return b; |
| } |
| |
| |
| const telegraphPool = new Pool( |
| () => ({ x: 0, y: 0, timer: 0, maxTime: 0.4, radius: 0 }), |
| t => { t.timer = 0; } |
| ); |
| |
| function spawnTelegraph(x, y) { |
| const t = telegraphPool.get(); |
| t.x = x; t.y = y; t.timer = 0; t.maxTime = 0.4; t.radius = 0; |
| return t; |
| } |
| |
| |
| const powerupPool = new Pool( |
| () => ({ x: 0, y: 0, vx: 0, vy: 0, type: 'rapid', life: 0, flashTimer: 0, alive: true, radius: 10 }), |
| p => { p.alive = false; p.life = 0; p.flashTimer = 0; } |
| ); |
| |
| function spawnPowerUp(type, x, y) { |
| const p = powerupPool.get(); |
| p.x = x; p.y = y; p.vx = 0; p.vy = 0; |
| p.type = type; p.life = 15; p.flashTimer = 0; p.alive = true; |
| return p; |
| } |
| |
| |
| const dmgNumPool = new Pool( |
| () => ({ x: 0, y: 0, vy: 0, value: 0, life: 0, maxLife: 0, color: '#fff' }), |
| d => { d.life = 0; } |
| ); |
| |
| function spawnDmgNum(x, y, value, color) { |
| const d = dmgNumPool.get(); |
| d.x = x; d.y = y; d.vy = -60; |
| d.value = value; d.life = 0.8; d.maxLife = 0.8; |
| d.color = color || '#fff'; |
| return d; |
| } |
| |
| |
| const floatScorePool = new Pool( |
| () => ({ x: 0, y: 0, vy: 0, value: 0, life: 0, maxLife: 0, color: '#fff' }), |
| d => { d.life = 0; } |
| ); |
| |
| function spawnFloatScore(x, y, value, color) { |
| const d = floatScorePool.get(); |
| d.x = x; d.y = y; d.vy = -80; |
| d.value = value; d.life = 1.0; d.maxLife = 1.0; |
| d.color = color || '#fff'; |
| return d; |
| } |
| |
| |
| const keys = {}; |
| let mouseX = 0, mouseY = 0, mouseDown = false; |
| let mouseWorldX = 0, mouseWorldY = 0; |
| |
| window.addEventListener('keydown', e => { |
| keys[e.code] = true; |
| if (gameState === 'tutorial') { |
| gameState = 'playing'; |
| e.preventDefault(); |
| return; |
| } |
| if (e.code === 'KeyR' && gameState === 'gameover') restartGame(); |
| if (gameState === 'menu') { initAudio(); gameState = 'tutorial'; } |
| }); |
| window.addEventListener('keyup', e => { keys[e.code] = false; }); |
| canvas.addEventListener('mousemove', e => { mouseX = e.clientX; mouseY = e.clientY; }); |
| canvas.addEventListener('mousedown', e => { |
| if (e.button === 0) { |
| mouseDown = true; |
| initAudio(); |
| if (gameState === 'tutorial') { |
| gameState = 'playing'; |
| return; |
| } |
| if (gameState === 'menu') gameState = 'tutorial'; |
| } |
| }); |
| canvas.addEventListener('mouseup', e => { if (e.button === 0) mouseDown = false; }); |
| canvas.addEventListener('contextmenu', e => e.preventDefault()); |
| |
| |
| let gameState = 'menu'; |
| let score = 0; |
| let wave = 1; |
| let waveTimer = 0; |
| let waveBreakTimer = 0; |
| let waveActive = false; |
| let enemiesSpawned = 0; |
| let enemiesToSpawn = 0; |
| let spawnTimer = 0; |
| let hitstopTimer = 0; |
| let shakeX = 0, shakeY = 0, shakeIntensity = 0; |
| let damageFlash = 0; |
| let bossSpawned = false; |
| let killsThisWave = 0; |
| let totalKills = 0; |
| let gameTime = 0; |
| let timescale = 1.0; |
| let isBossWave = false; |
| let currentBossType = null; |
| |
| |
| let chromaTimer = 0; |
| let chromaFade = 0; |
| |
| |
| let comboCount = 0; |
| let comboTimer = 0; |
| let comboMultiplier = 1; |
| let lastComboLevel = 1; |
| |
| |
| let powerupRapidTimer = 0; |
| let powerupTripleTimer = 0; |
| let powerupShield = false; |
| |
| |
| let deathTimer = 0; |
| let deathFlash = 0; |
| |
| const player = { |
| x: WORLD_W / 2, y: WORLD_H / 2, |
| vx: 0, vy: 0, |
| angle: 0, |
| hp: PLAYER_MAX_HP, |
| shootCooldown: 0, |
| dashCooldown: 0, |
| dashTimer: 0, |
| dashInvuln: 0, |
| alive: true, |
| radius: 12 |
| }; |
| |
| const camera = { x: 0, y: 0 }; |
| |
| |
| const stars = []; |
| for (let i = 0; i < 300; i++) { |
| stars.push({ |
| x: Math.random() * WORLD_W * 1.5 - WORLD_W * 0.25, |
| y: Math.random() * WORLD_H * 1.5 - WORLD_H * 0.25, |
| size: Math.random() * 2 + 0.5, |
| brightness: Math.random() * 0.5 + 0.3, |
| twinkleSpeed: Math.random() * 2 + 1, |
| layer: Math.floor(Math.random() * 3) |
| }); |
| } |
| |
| |
| const nebulae = [ |
| { x: 500, y: 400, r: 400, color1: 'rgba(80,0,160,0.03)', color2: 'rgba(160,0,80,0.01)', angle: 0 }, |
| { x: 2200, y: 1800, r: 500, color1: 'rgba(0,80,160,0.03)', color2: 'rgba(0,160,80,0.01)', angle: 0.5 }, |
| { x: 1500, y: 800, r: 350, color1: 'rgba(160,80,0,0.03)', color2: 'rgba(160,0,0,0.01)', angle: -0.3 }, |
| { x: 800, y: 2400, r: 450, color1: 'rgba(0,160,160,0.02)', color2: 'rgba(0,80,0,0.01)', angle: 1 } |
| ]; |
| |
| |
| function startGame() { |
| gameState = 'playing'; |
| score = 0; wave = 1; waveTimer = 0; waveBreakTimer = 0; |
| waveActive = false; enemiesSpawned = 0; enemiesToSpawn = 0; |
| spawnTimer = 0; hitstopTimer = 0; shakeX = 0; shakeY = 0; |
| shakeIntensity = 0; damageFlash = 0; bossSpawned = false; |
| killsThisWave = 0; totalKills = 0; gameTime = 0; |
| isBossWave = false; currentBossType = null; |
| chromaTimer = 0; chromaFade = 0; |
| player.x = WORLD_W / 2; player.y = WORLD_H / 2; |
| player.vx = 0; player.vy = 0; player.hp = PLAYER_MAX_HP; |
| player.shootCooldown = 0; player.dashCooldown = 0; |
| player.dashTimer = 0; player.dashInvuln = 0; player.alive = true; |
| player.angle = 0; |
| bulletPool.releaseAll(); |
| enemyPool.releaseAll(); |
| bossPool.releaseAll(); |
| particlePool.releaseAll(); |
| dmgNumPool.releaseAll(); |
| powerupPool.releaseAll(); |
| floatScorePool.releaseAll(); |
| telegraphPool.releaseAll(); |
| comboCount = 0; comboTimer = 0; comboMultiplier = 1; lastComboLevel = 1; |
| powerupRapidTimer = 0; powerupTripleTimer = 0; powerupShield = false; |
| deathTimer = 0; deathFlash = 0; timescale = 1.0; |
| startWave(); |
| } |
| |
| function restartGame() { |
| startGame(); |
| } |
| |
| function getEnemiesToSpawn(waveNum) { |
| if (waveNum % 5 === 0) return 8 + waveNum; |
| if (waveNum === 1) return 14; |
| if (waveNum === 2) return 18; |
| if (waveNum === 3) return 22; |
| if (waveNum === 4) return 28; |
| return 8 + waveNum * 4; |
| } |
| |
| function startWave() { |
| waveActive = true; |
| waveBreakTimer = 0; |
| isBossWave = wave % 5 === 0; |
| currentBossType = null; |
| |
| if (isBossWave) { |
| currentBossType = ['sentinel', 'carrier', 'warden'][Math.floor((wave - 5) / 5) % 3]; |
| enemiesToSpawn = 8 + wave; |
| bossSpawned = false; |
| setDroneVolume(true); |
| } else { |
| enemiesToSpawn = getEnemiesToSpawn(wave); |
| bossSpawned = true; |
| setDroneVolume(false); |
| } |
| enemiesSpawned = 0; |
| spawnTimer = 0; |
| playSound('wave'); |
| } |
| |
| |
| function spawnWaveEnemy() { |
| if (isBossWave && enemiesSpawned >= enemiesToSpawn - 1) { |
| const edge = Math.floor(Math.random() * 4); |
| let bx, by; |
| if (edge === 0) { bx = player.x; by = -100; } |
| else if (edge === 1) { bx = player.x + 800; by = player.y; } |
| else if (edge === 2) { bx = player.x; by = player.y + 800; } |
| else { bx = player.x - 800; by = player.y; } |
| |
| spawnTelegraph(bx, by); |
| setTimeout(() => { |
| if (gameState === 'playing') spawnBoss(currentBossType, bx, by); |
| }, 400); |
| |
| enemiesSpawned = enemiesToSpawn; |
| bossSpawned = true; |
| return; |
| } |
| |
| const r = Math.random(); |
| let type; |
| |
| if (wave <= 2) { |
| type = r < 0.3 ? 'scout' : 'grunt'; |
| } else if (wave < 6) { |
| type = r < 0.3 ? 'scout' : r < 0.55 ? 'grunt' : r < 0.75 ? 'runner' : r < 0.9 ? 'tank' : 'shooter'; |
| } else { |
| type = r < 0.15 ? 'scout' : r < 0.35 ? 'grunt' : r < 0.5 ? 'runner' : r < 0.75 ? 'tank' : 'shooter'; |
| } |
| |
| const spawnDist = (W + H) * 0.5 + Math.random() * 200; |
| const angle = Math.random() * Math.PI * 2; |
| let x = player.x + Math.cos(angle) * spawnDist; |
| let y = player.y + Math.sin(angle) * spawnDist; |
| |
| x = Math.max(50, Math.min(WORLD_W - 50, x)); |
| y = Math.max(50, Math.min(WORLD_H - 50, y)); |
| |
| spawnTelegraph(x, y); |
| setTimeout(() => { |
| if (gameState === 'playing') spawnEnemy(type, x, y); |
| }, 400); |
| |
| enemiesSpawned++; |
| } |
| |
| |
| function updateBoss(b, dt) { |
| const dx = player.x - b.x; |
| const dy = player.y - b.y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| b.angle = Math.atan2(dy, dx); |
| |
| if (b.flashTimer > 0) b.flashTimer -= dt; |
| |
| if (b.type === 'sentinel') { |
| updateSentinel(b, dt, dist, dx, dy); |
| } else if (b.type === 'carrier') { |
| updateCarrier(b, dt, dist, dx, dy); |
| } else if (b.type === 'warden') { |
| updateWarden(b, dt, dist, dx, dy); |
| } |
| |
| b.vx *= 0.94; |
| b.vy *= 0.94; |
| b.x += b.vx * dt; |
| b.y += b.vy * dt; |
| } |
| |
| function updateSentinel(b, dt, dist, dx, dy) { |
| if (b.phase === 1 && b.hp <= b.maxHp * 0.5) { |
| b.phase = 2; |
| b.speedMult = 1.8; |
| } |
| |
| const speedMult = b.phase === 2 ? 1.8 : 1.0; |
| const spd = defSpeed(b) * speedMult; |
| |
| if (dist > 350) { |
| b.vx += (dx / dist) * spd * 3 * dt; |
| b.vy += (dy / dist) * spd * 3 * dt; |
| } else { |
| const circleAngle = b.angle + Math.PI / 2; |
| b.vx += Math.cos(circleAngle) * spd * 2 * dt; |
| b.vy += Math.sin(circleAngle) * spd * 2 * dt; |
| } |
| |
| b.burstTimer -= dt; |
| if (b.burstTimer <= 0) { |
| b.burstTimer = b.phase === 2 ? 1.2 : 1.5; |
| |
| if (b.phase === 2 && Math.random() < 0.5) { |
| for (let i = 0; i < 16; i++) { |
| const a = (Math.PI * 2 / 16) * i; |
| spawnBullet(b.x, b.y, a, 'enemy', defColor(b), 275, 5); |
| } |
| playSound('enemyShoot'); |
| } else { |
| const fanAngle = b.angle; |
| for (let i = 0; i < 8; i++) { |
| const a = fanAngle - 0.6 + (1.2 / 7) * i; |
| spawnBullet(b.x, b.y, a, 'enemy', defColor(b), 350, 5); |
| } |
| playSound('enemyShoot'); |
| } |
| } |
| } |
| |
| function updateCarrier(b, dt, dist, dx, dy) { |
| if (b.phase === 1 && b.hp <= b.maxHp * 0.3) { |
| b.phase = 2; |
| b.rapidFireTimer = 0; |
| } |
| |
| const midRange = 400; |
| if (dist > midRange + 100) { |
| b.vx += (dx / dist) * defSpeed(b) * 2 * dt; |
| b.vy += (dy / dist) * defSpeed(b) * 2 * dt; |
| } else if (dist < midRange - 100) { |
| b.vx -= (dx / dist) * defSpeed(b) * 2 * dt; |
| b.vy -= (dy / dist) * defSpeed(b) * 2 * dt; |
| } |
| |
| if (b.phase === 1) { |
| b.spawnMinionTimer -= dt; |
| if (b.spawnMinionTimer <= 0) { |
| b.spawnMinionTimer = 5; |
| const numMinions = 2 + Math.floor(Math.random() * 2); |
| for (let i = 0; i < numMinions; i++) { |
| const a = Math.random() * Math.PI * 2; |
| spawnEnemy('grunt', b.x + Math.cos(a) * b.radius, b.y + Math.sin(a) * b.radius); |
| emitExplosion(b.x + Math.cos(a) * b.radius, b.y + Math.sin(a) * b.radius, '#00ccff', 8, 60, [1, 3]); |
| } |
| } |
| |
| b.missileTimer -= dt; |
| if (b.missileTimer <= 0) { |
| b.missileTimer = 3; |
| for (let i = 0; i < 2; i++) { |
| const a = b.angle + (i === 0 ? -0.2 : 0.2); |
| spawnBullet(b.x, b.y, a, 'enemy', '#00ccff', 225, 6, true, 1.5); |
| } |
| playSound('enemyShoot'); |
| } |
| } else { |
| b.rapidFireTimer -= dt; |
| if (b.rapidFireTimer <= 0) { |
| b.rapidFireTimer = 0.15; |
| spawnBullet(b.x, b.y, b.angle, 'enemy', '#ff4444', 440, 4); |
| playSound('enemyShoot'); |
| } |
| } |
| } |
| |
| function updateWarden(b, dt, dist, dx, dy) { |
| if (b.phase === 1 && b.hp <= b.maxHp * 0.5) { |
| b.phase = 2; |
| } |
| |
| b.orbitAngle += dt * 0.4; |
| const orbitRadius = 350; |
| const targetX = player.x + Math.cos(b.orbitAngle) * orbitRadius; |
| const targetY = player.y + Math.sin(b.orbitAngle) * orbitRadius; |
| b.vx += (targetX - b.x) * 2 * dt; |
| b.vy += (targetY - b.y) * 2 * dt; |
| |
| b.laserAngle += dt * (b.phase === 2 ? 1.8 : 1.2); |
| |
| checkLaserHit(b, 0); |
| if (b.phase === 2) { |
| checkLaserHit(b, Math.PI / 2); |
| } |
| } |
| |
| function checkLaserHit(b, offset) { |
| if (player.dashInvuln > 0) return; |
| |
| const laserAngle = b.laserAngle + offset; |
| const laserLen = 600; |
| const ex = b.x + Math.cos(laserAngle) * laserLen; |
| const ey = b.y + Math.sin(laserAngle) * laserLen; |
| |
| const ldx = ex - b.x; |
| const ldy = ey - b.y; |
| const lLen2 = ldx * ldx + ldy * ldy; |
| let t = ((player.x - b.x) * ldx + (player.y - b.y) * ldy) / lLen2; |
| t = Math.max(0, Math.min(1, t)); |
| |
| const closestX = b.x + t * ldx; |
| const closestY = b.y + t * ldy; |
| const cdx = player.x - closestX; |
| const cdy = player.y - closestY; |
| const cDist = Math.sqrt(cdx * cdx + cdy * cdy); |
| |
| if (cDist < player.radius + 8) { |
| if (powerupShield) { |
| powerupShield = false; |
| emitExplosion(player.x, player.y, '#ffff00', 15, 100, [2, 4]); |
| playSound('hit'); |
| } else { |
| player.hp -= 25; |
| damageFlash = 0.2; |
| shakeIntensity = 4; |
| chromaTimer = 0.4; |
| chromaFade = 0.4; |
| playSound('hurt'); |
| playSound('laser'); |
| spawnDmgNum(player.x, player.y - 20, 25, '#f44'); |
| const kbAngle = Math.atan2(cdy, cdx); |
| player.vx += Math.cos(kbAngle) * 300; |
| player.vy += Math.sin(kbAngle) * 300; |
| comboCount = 0; comboTimer = 0; comboMultiplier = 1; |
| if (player.hp <= 0) killPlayer(); |
| } |
| } |
| } |
| |
| function defSpeed(b) { return BOSS_TYPES[b.type].speed; } |
| function defColor(b) { return BOSS_TYPES[b.type].color; } |
| |
| |
| function killBoss(b) { |
| b.alive = false; |
| const def = BOSS_TYPES[b.type]; |
| const points = Math.floor(def.points * comboMultiplier); |
| score += points; |
| totalKills++; |
| killsThisWave++; |
| |
| comboCount++; |
| comboTimer = 2.5; |
| const newMult = Math.min(4, 1 + (comboCount - 1) * 0.5); |
| if (newMult > comboMultiplier) { |
| comboMultiplier = newMult; |
| playSound('combo'); |
| spawnFloatScore(W / 2, H / 2 - 80, 'ร' + comboMultiplier.toFixed(1), '#0ff'); |
| } |
| |
| spawnFloatScore(b.x, b.y - b.radius - 20, '+' + points, '#ff0'); |
| |
| if (b.type === 'sentinel') { |
| for (let i = 0; i < 40; i++) { |
| const a = Math.random() * Math.PI * 2; |
| const s = 150 + Math.random() * 300; |
| const sz = 3 + Math.random() * 6; |
| spawnParticle(b.x, b.y, Math.cos(a) * s, Math.sin(a) * s, |
| 0.5 + Math.random() * 0.5, def.color, sz, 'circle'); |
| } |
| for (let i = 0; i < 20; i++) { |
| const a = Math.random() * Math.PI * 2; |
| const s = 50 + Math.random() * 100; |
| spawnParticle(b.x, b.y, Math.cos(a) * s, Math.sin(a) * s, |
| 0.8 + Math.random() * 0.5, '#fff', 4 + Math.random() * 4, 'circle'); |
| } |
| } else if (b.type === 'carrier') { |
| emitExplosion(b.x, b.y, '#00ccff', 50, 250, [2, 6]); |
| emitExplosion(b.x, b.y, '#fff', 25, 200, [3, 8]); |
| setTimeout(() => { |
| for (let i = 0; i < 30; i++) { |
| const a = Math.random() * Math.PI * 2; |
| const s = 100 + Math.random() * 100; |
| spawnParticle(b.x + Math.cos(a) * 80, b.y + Math.sin(a) * 80, |
| -Math.cos(a) * s, -Math.sin(a) * s, 0.3 + Math.random() * 0.3, '#00ccff', 2 + Math.random() * 3, 'circle'); |
| } |
| }, 200); |
| } else if (b.type === 'warden') { |
| for (let i = 0; i < 30; i++) { |
| const a = Math.random() * Math.PI * 2; |
| const s = 200 + Math.random() * 200; |
| spawnParticle(b.x, b.y, Math.cos(a) * s, Math.sin(a) * s, |
| 0.6 + Math.random() * 0.4, '#ff8800', 2 + Math.random() * 4, 'circle'); |
| } |
| for (let i = 0; i < 20; i++) { |
| const a = (Math.PI * 2 / 20) * i; |
| spawnParticle(b.x, b.y, Math.cos(a) * 150, Math.sin(a) * 150, |
| 0.5, '#ff4400', 4, 'circle'); |
| } |
| emitExplosion(b.x, b.y, '#fff', 15, 100, [3, 6]); |
| } |
| |
| shakeIntensity = 18; |
| hitstopTimer = 0.15; |
| playSound('bossDeath'); |
| |
| for (let i = 0; i < 2; i++) { |
| const types = ['rapid', 'triple', 'shield']; |
| const pType = types[Math.floor(Math.random() * types.length)]; |
| const a = Math.random() * Math.PI * 2; |
| spawnPowerUp(pType, b.x + Math.cos(a) * 30, b.y + Math.sin(a) * 30); |
| } |
| } |
| |
| |
| function drawEnemyShip(e) { |
| const def = ENEMY_TYPES[e.type]; |
| const flash = e.flashTimer > 0; |
| const r = e.radius; |
| |
| ctx.save(); |
| ctx.translate(e.x, e.y); |
| ctx.rotate(e.angle); |
| |
| let fillColor = flash ? '#fff' : def.color; |
| let glowColor = flash ? '#fff' : def.glow; |
| if (e.type === 'runner' && e.armed) { |
| glowColor = flash ? '#fff' : '#ff4400'; |
| fillColor = flash ? '#fff' : '#ff6622'; |
| } |
| |
| ctx.shadowColor = glowColor; |
| ctx.shadowBlur = flash ? 25 : 15; |
| |
| const outlineColor = '#fff'; |
| const outlineWidth = 1.2; |
| |
| if (e.type === 'grunt') { |
| ctx.fillStyle = fillColor; |
| ctx.beginPath(); |
| ctx.moveTo(r * 1.1, 0); |
| ctx.lineTo(-r * 0.5, -r * 0.5); |
| ctx.lineTo(-r * 0.5, r * 0.5); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(r * 0.2, -r * 0.3); |
| ctx.lineTo(-r * 0.9, -r * 1.0); |
| ctx.lineTo(-r * 0.3, -r * 0.4); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(r * 0.2, r * 0.3); |
| ctx.lineTo(-r * 0.9, r * 1.0); |
| ctx.lineTo(-r * 0.3, r * 0.4); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = outlineColor; |
| ctx.lineWidth = outlineWidth; |
| ctx.beginPath(); |
| ctx.moveTo(r * 1.1, 0); |
| ctx.lineTo(-r * 0.5, -r * 0.5); |
| ctx.lineTo(-r * 0.5, r * 0.5); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(r * 0.2, -r * 0.3); |
| ctx.lineTo(-r * 0.9, -r * 1.0); |
| ctx.lineTo(-r * 0.3, -r * 0.4); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(r * 0.2, r * 0.3); |
| ctx.lineTo(-r * 0.9, r * 1.0); |
| ctx.lineTo(-r * 0.3, r * 0.4); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#ff8844'; |
| ctx.shadowColor = '#ff8844'; |
| ctx.shadowBlur = 6; |
| ctx.beginPath(); |
| ctx.arc(-r * 0.5, 0, 2, 0, Math.PI * 2); |
| ctx.fill(); |
| |
| } else if (e.type === 'scout') { |
| const len = r * 2.5; |
| const wid = r * 0.5; |
| |
| ctx.fillStyle = fillColor; |
| ctx.beginPath(); |
| ctx.moveTo(len, 0); |
| ctx.lineTo(-r, -wid); |
| ctx.lineTo(-r, wid); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.5, -wid * 0.3); |
| ctx.lineTo(-r * 1.3, -wid * 1.2); |
| ctx.lineTo(-r * 1.0, -wid * 0.4); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.5, wid * 0.3); |
| ctx.lineTo(-r * 1.3, wid * 1.2); |
| ctx.lineTo(-r * 1.0, wid * 0.4); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = 'rgba(255,255,255,0.8)'; |
| ctx.lineWidth = outlineWidth; |
| ctx.beginPath(); |
| ctx.moveTo(len, 0); |
| ctx.lineTo(-r, -wid); |
| ctx.lineTo(-r, wid); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 4; |
| ctx.beginPath(); |
| ctx.arc(len * 0.65, 0, 1.5, 0, Math.PI * 2); |
| ctx.fill(); |
| |
| } else if (e.type === 'runner') { |
| const len = r * 2.0; |
| const wid = r * 0.7; |
| |
| if (e.armed) { |
| const pulse = 0.5 + 0.5 * Math.sin(gameTime * 12); |
| ctx.shadowColor = '#ff4400'; |
| ctx.shadowBlur = 15 + pulse * 20; |
| } |
| |
| ctx.fillStyle = fillColor; |
| ctx.beginPath(); |
| ctx.moveTo(len, 0); |
| ctx.lineTo(-r * 0.5, -wid); |
| ctx.lineTo(-r * 0.5, wid); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.2, -wid * 0.5); |
| ctx.lineTo(-r * 1.2, -wid * 1.3); |
| ctx.lineTo(-r * 0.8, -wid * 0.6); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.2, wid * 0.5); |
| ctx.lineTo(-r * 1.2, wid * 1.3); |
| ctx.lineTo(-r * 0.8, wid * 0.6); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = outlineColor; |
| ctx.lineWidth = outlineWidth; |
| ctx.beginPath(); |
| ctx.moveTo(len, 0); |
| ctx.lineTo(-r * 0.5, -wid); |
| ctx.lineTo(-r * 0.5, wid); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.strokeStyle = e.armed ? '#ff4400' : def.glow; |
| ctx.lineWidth = 1; |
| ctx.globalAlpha = 0.3; |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.5, -wid * 0.3); |
| ctx.lineTo(-r * 2.5, -wid * 0.1); |
| ctx.stroke(); |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.5, wid * 0.3); |
| ctx.lineTo(-r * 2.5, wid * 0.1); |
| ctx.stroke(); |
| ctx.globalAlpha = 1; |
| |
| } else if (e.type === 'tank') { |
| const hexR = r; |
| |
| ctx.fillStyle = fillColor; |
| ctx.beginPath(); |
| for (let i = 0; i < 6; i++) { |
| const a = (Math.PI * 2 / 6) * i - Math.PI / 6; |
| const px = Math.cos(a) * hexR; |
| const py = Math.sin(a) * hexR; |
| i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); |
| } |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.fillRect(hexR * 0.85, -3, hexR * 0.3, 3); |
| ctx.fillRect(hexR * 0.85, 0, hexR * 0.3, 3); |
| ctx.fillRect(-hexR * 0.85 - hexR * 0.3, -3, hexR * 0.3, 3); |
| ctx.fillRect(-hexR * 0.85 - hexR * 0.3, 0, hexR * 0.3, 3); |
| |
| ctx.strokeStyle = outlineColor; |
| ctx.lineWidth = outlineWidth; |
| ctx.beginPath(); |
| for (let i = 0; i < 6; i++) { |
| const a = (Math.PI * 2 / 6) * i - Math.PI / 6; |
| const px = Math.cos(a) * hexR; |
| const py = Math.sin(a) * hexR; |
| i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); |
| } |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#ff8844'; |
| ctx.shadowColor = '#ff8844'; |
| ctx.shadowBlur = 6; |
| ctx.beginPath(); |
| ctx.arc(-hexR * 0.9, -4, 2.5, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.beginPath(); |
| ctx.arc(-hexR * 0.9, 4, 2.5, 0, Math.PI * 2); |
| ctx.fill(); |
| |
| if (e.burstTimer > 0 && e.burstTimer < 0.4) { |
| const teleProgress = 1 - (e.burstTimer / 0.4); |
| const pulseAlpha = 0.3 + 0.4 * Math.sin(gameTime * 15); |
| ctx.strokeStyle = `rgba(170,68,255,${pulseAlpha})`; |
| ctx.lineWidth = 2; |
| ctx.shadowColor = '#aa44ff'; |
| ctx.shadowBlur = 10; |
| ctx.beginPath(); |
| ctx.arc(0, 0, hexR + 10 + teleProgress * 15, 0, Math.PI * 2); |
| ctx.stroke(); |
| ctx.shadowBlur = 0; |
| } |
| |
| } else if (e.type === 'shooter') { |
| const d = r; |
| |
| ctx.fillStyle = fillColor; |
| ctx.beginPath(); |
| ctx.moveTo(d, 0); |
| ctx.lineTo(0, -d * 0.7); |
| ctx.lineTo(-d, 0); |
| ctx.lineTo(0, d * 0.7); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = outlineColor; |
| ctx.lineWidth = outlineWidth; |
| ctx.beginPath(); |
| ctx.moveTo(d, 0); |
| ctx.lineTo(0, -d * 0.7); |
| ctx.lineTo(-d, 0); |
| ctx.lineTo(0, d * 0.7); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| const turretAngle = -e.angle; |
| ctx.save(); |
| ctx.rotate(turretAngle); |
| |
| ctx.fillStyle = fillColor; |
| ctx.strokeStyle = outlineColor; |
| ctx.lineWidth = 1; |
| ctx.beginPath(); |
| ctx.arc(d * 0.3, 0, 4, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.fillRect(d * 0.3 + 3, -1.5, 6, 3); |
| |
| ctx.restore(); |
| } |
| |
| ctx.shadowBlur = 0; |
| ctx.restore(); |
| |
| |
| if (e.type === 'tank') { |
| const barW = r * 2.5; |
| const barH = 4; |
| const barY = e.y - r - 10; |
| ctx.fillStyle = 'rgba(0,0,0,0.6)'; |
| ctx.fillRect(e.x - barW / 2, barY, barW, barH); |
| const hpRatio = e.hp / e.maxHp; |
| ctx.fillStyle = hpRatio > 0.5 ? '#4f4' : hpRatio > 0.25 ? '#ff4' : '#f44'; |
| ctx.fillRect(e.x - barW / 2, barY, barW * hpRatio, barH); |
| } |
| } |
| |
| |
| function drawBossShip(b) { |
| const def = BOSS_TYPES[b.type]; |
| const flash = b.flashTimer > 0; |
| |
| ctx.save(); |
| ctx.translate(b.x, b.y); |
| ctx.rotate(b.angle); |
| |
| ctx.shadowColor = flash ? '#fff' : def.glow; |
| ctx.shadowBlur = flash ? 30 : 18; |
| ctx.fillStyle = flash ? '#fff' : def.color; |
| |
| if (b.type === 'sentinel') { |
| const r = b.radius; |
| ctx.beginPath(); |
| for (let i = 0; i < 6; i++) { |
| const a = (Math.PI * 2 / 6) * i; |
| const px = Math.cos(a) * r; |
| const py = Math.sin(a) * r; |
| i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); |
| } |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| |
| ctx.strokeStyle = def.color; |
| ctx.lineWidth = 1; |
| ctx.shadowBlur = 8; |
| ctx.beginPath(); |
| ctx.arc(0, 0, r * 0.5, 0, Math.PI * 2); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 10; |
| for (let i = 0; i < 6; i++) { |
| const a = (Math.PI * 2 / 6) * i; |
| ctx.beginPath(); |
| ctx.arc(Math.cos(a) * r * 0.85, Math.sin(a) * r * 0.85, 3, 0, Math.PI * 2); |
| ctx.fill(); |
| } |
| |
| } else if (b.type === 'carrier') { |
| const r = b.radius; |
| ctx.beginPath(); |
| for (let i = 0; i < 8; i++) { |
| const a = (Math.PI * 2 / 8) * i; |
| const px = Math.cos(a) * r; |
| const py = Math.sin(a) * r; |
| i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); |
| } |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| |
| ctx.strokeStyle = def.color; |
| ctx.lineWidth = 1; |
| ctx.shadowBlur = 8; |
| ctx.beginPath(); |
| ctx.arc(0, 0, r * 0.55, 0, Math.PI * 2); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 8; |
| for (let i = 0; i < 4; i++) { |
| const a = (Math.PI * 2 / 4) * i; |
| const nx = Math.cos(a) * r * 1.05; |
| const ny = Math.sin(a) * r * 1.05; |
| ctx.fillRect(nx - 3, ny - 3, 6, 6); |
| } |
| |
| } else if (b.type === 'warden') { |
| const s = b.radius * 0.8; |
| ctx.fillRect(-s, -s, s * 2, s * 2); |
| |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 2; |
| ctx.strokeRect(-s, -s, s * 2, s * 2); |
| |
| ctx.strokeStyle = def.color; |
| ctx.lineWidth = 1; |
| ctx.shadowBlur = 8; |
| ctx.strokeRect(-s * 0.5, -s * 0.5, s, s); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 10; |
| for (let i = 0; i < 4; i++) { |
| const cx = (i % 2 === 0 ? -1 : 1) * s * 0.75; |
| const cy = (i < 2 ? -1 : 1) * s * 0.75; |
| ctx.beginPath(); |
| ctx.arc(cx, cy, 3, 0, Math.PI * 2); |
| ctx.fill(); |
| } |
| } |
| |
| ctx.shadowBlur = 0; |
| |
| const barW = b.radius * 2.5; |
| const barH = 5; |
| const barY = -b.radius - 14; |
| ctx.fillStyle = 'rgba(0,0,0,0.6)'; |
| ctx.fillRect(-barW / 2, barY, barW, barH); |
| const hpRatio = b.hp / b.maxHp; |
| ctx.fillStyle = hpRatio > 0.5 ? '#4f4' : hpRatio > 0.25 ? '#ff4' : '#f44'; |
| ctx.fillRect(-barW / 2, barY, barW * hpRatio, barH); |
| |
| ctx.restore(); |
| |
| if (b.type === 'warden') { |
| const laserLen = 600; |
| for (let beam = 0; beam < (b.phase === 2 ? 2 : 1); beam++) { |
| const offset = beam * Math.PI / 2; |
| const laserAngle = b.laserAngle + offset; |
| const ex = b.x + Math.cos(laserAngle) * laserLen; |
| const ey = b.y + Math.sin(laserAngle) * laserLen; |
| |
| ctx.strokeStyle = 'rgba(255,136,0,0.15)'; |
| ctx.lineWidth = 20; |
| ctx.shadowColor = '#ff8800'; |
| ctx.shadowBlur = 30; |
| ctx.beginPath(); |
| ctx.moveTo(b.x, b.y); |
| ctx.lineTo(ex, ey); |
| ctx.stroke(); |
| |
| ctx.strokeStyle = 'rgba(255,200,0,0.6)'; |
| ctx.lineWidth = 4; |
| ctx.beginPath(); |
| ctx.moveTo(b.x, b.y); |
| ctx.lineTo(ex, ey); |
| ctx.stroke(); |
| |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 1; |
| ctx.shadowBlur = 15; |
| ctx.beginPath(); |
| ctx.moveTo(b.x, b.y); |
| ctx.lineTo(ex, ey); |
| ctx.stroke(); |
| |
| ctx.shadowBlur = 0; |
| } |
| } |
| } |
| |
| |
| |
| function drawTutorialEnemy(type, sx, sy, angle, radius) { |
| const def = ENEMY_TYPES[type]; |
| const r = radius; |
| const flash = false; |
| |
| ctx.save(); |
| ctx.translate(sx, sy); |
| ctx.rotate(angle); |
| |
| let fillColor = flash ? '#fff' : def.color; |
| let glowColor = flash ? '#fff' : def.glow; |
| |
| ctx.shadowColor = glowColor; |
| ctx.shadowBlur = 15; |
| |
| const outlineColor = '#fff'; |
| const outlineWidth = 1.2; |
| |
| if (type === 'grunt') { |
| ctx.fillStyle = fillColor; |
| ctx.beginPath(); |
| ctx.moveTo(r * 1.1, 0); |
| ctx.lineTo(-r * 0.5, -r * 0.5); |
| ctx.lineTo(-r * 0.5, r * 0.5); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(r * 0.2, -r * 0.3); |
| ctx.lineTo(-r * 0.9, -r * 1.0); |
| ctx.lineTo(-r * 0.3, -r * 0.4); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(r * 0.2, r * 0.3); |
| ctx.lineTo(-r * 0.9, r * 1.0); |
| ctx.lineTo(-r * 0.3, r * 0.4); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = outlineColor; |
| ctx.lineWidth = outlineWidth; |
| ctx.beginPath(); |
| ctx.moveTo(r * 1.1, 0); |
| ctx.lineTo(-r * 0.5, -r * 0.5); |
| ctx.lineTo(-r * 0.5, r * 0.5); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(r * 0.2, -r * 0.3); |
| ctx.lineTo(-r * 0.9, -r * 1.0); |
| ctx.lineTo(-r * 0.3, -r * 0.4); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(r * 0.2, r * 0.3); |
| ctx.lineTo(-r * 0.9, r * 1.0); |
| ctx.lineTo(-r * 0.3, r * 0.4); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#ff8844'; |
| ctx.shadowColor = '#ff8844'; |
| ctx.shadowBlur = 6; |
| ctx.beginPath(); |
| ctx.arc(-r * 0.5, 0, 2, 0, Math.PI * 2); |
| ctx.fill(); |
| |
| } else if (type === 'scout') { |
| const len = r * 2.5; |
| const wid = r * 0.5; |
| |
| ctx.fillStyle = fillColor; |
| ctx.beginPath(); |
| ctx.moveTo(len, 0); |
| ctx.lineTo(-r, -wid); |
| ctx.lineTo(-r, wid); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.5, -wid * 0.3); |
| ctx.lineTo(-r * 1.3, -wid * 1.2); |
| ctx.lineTo(-r * 1.0, -wid * 0.4); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.5, wid * 0.3); |
| ctx.lineTo(-r * 1.3, wid * 1.2); |
| ctx.lineTo(-r * 1.0, wid * 0.4); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = 'rgba(255,255,255,0.8)'; |
| ctx.lineWidth = outlineWidth; |
| ctx.beginPath(); |
| ctx.moveTo(len, 0); |
| ctx.lineTo(-r, -wid); |
| ctx.lineTo(-r, wid); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 4; |
| ctx.beginPath(); |
| ctx.arc(len * 0.65, 0, 1.5, 0, Math.PI * 2); |
| ctx.fill(); |
| |
| } else if (type === 'runner') { |
| const len = r * 2.0; |
| const wid = r * 0.7; |
| |
| ctx.fillStyle = fillColor; |
| ctx.beginPath(); |
| ctx.moveTo(len, 0); |
| ctx.lineTo(-r * 0.5, -wid); |
| ctx.lineTo(-r * 0.5, wid); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.2, -wid * 0.5); |
| ctx.lineTo(-r * 1.2, -wid * 1.3); |
| ctx.lineTo(-r * 0.8, -wid * 0.6); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.2, wid * 0.5); |
| ctx.lineTo(-r * 1.2, wid * 1.3); |
| ctx.lineTo(-r * 0.8, wid * 0.6); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = outlineColor; |
| ctx.lineWidth = outlineWidth; |
| ctx.beginPath(); |
| ctx.moveTo(len, 0); |
| ctx.lineTo(-r * 0.5, -wid); |
| ctx.lineTo(-r * 0.5, wid); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.strokeStyle = def.glow; |
| ctx.lineWidth = 1; |
| ctx.globalAlpha = 0.3; |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.5, -wid * 0.3); |
| ctx.lineTo(-r * 2.5, -wid * 0.1); |
| ctx.stroke(); |
| ctx.beginPath(); |
| ctx.moveTo(-r * 0.5, wid * 0.3); |
| ctx.lineTo(-r * 2.5, wid * 0.1); |
| ctx.stroke(); |
| ctx.globalAlpha = 1; |
| |
| } else if (type === 'tank') { |
| const hexR = r; |
| |
| ctx.fillStyle = fillColor; |
| ctx.beginPath(); |
| for (let i = 0; i < 6; i++) { |
| const a = (Math.PI * 2 / 6) * i - Math.PI / 6; |
| const px = Math.cos(a) * hexR; |
| const py = Math.sin(a) * hexR; |
| i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); |
| } |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.fillRect(hexR * 0.85, -3, hexR * 0.3, 3); |
| ctx.fillRect(hexR * 0.85, 0, hexR * 0.3, 3); |
| ctx.fillRect(-hexR * 0.85 - hexR * 0.3, -3, hexR * 0.3, 3); |
| ctx.fillRect(-hexR * 0.85 - hexR * 0.3, 0, hexR * 0.3, 3); |
| |
| ctx.strokeStyle = outlineColor; |
| ctx.lineWidth = outlineWidth; |
| ctx.beginPath(); |
| for (let i = 0; i < 6; i++) { |
| const a = (Math.PI * 2 / 6) * i - Math.PI / 6; |
| const px = Math.cos(a) * hexR; |
| const py = Math.sin(a) * hexR; |
| i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); |
| } |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#ff8844'; |
| ctx.shadowColor = '#ff8844'; |
| ctx.shadowBlur = 6; |
| ctx.beginPath(); |
| ctx.arc(-hexR * 0.9, -4, 2.5, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.beginPath(); |
| ctx.arc(-hexR * 0.9, 4, 2.5, 0, Math.PI * 2); |
| ctx.fill(); |
| |
| } else if (type === 'shooter') { |
| const d = r; |
| |
| ctx.fillStyle = fillColor; |
| ctx.beginPath(); |
| ctx.moveTo(d, 0); |
| ctx.lineTo(0, -d * 0.7); |
| ctx.lineTo(-d, 0); |
| ctx.lineTo(0, d * 0.7); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = outlineColor; |
| ctx.lineWidth = outlineWidth; |
| ctx.beginPath(); |
| ctx.moveTo(d, 0); |
| ctx.lineTo(0, -d * 0.7); |
| ctx.lineTo(-d, 0); |
| ctx.lineTo(0, d * 0.7); |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| ctx.fillStyle = fillColor; |
| ctx.strokeStyle = outlineColor; |
| ctx.lineWidth = 1; |
| ctx.beginPath(); |
| ctx.arc(d * 0.3, 0, 4, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.fillRect(d * 0.3 + 3, -1.5, 6, 3); |
| } |
| |
| ctx.shadowBlur = 0; |
| ctx.restore(); |
| } |
| |
| |
| function drawTutorialBoss(type, sx, sy, angle, radius) { |
| const def = BOSS_TYPES[type]; |
| |
| ctx.save(); |
| ctx.translate(sx, sy); |
| ctx.rotate(angle); |
| |
| ctx.shadowColor = def.glow; |
| ctx.shadowBlur = 18; |
| ctx.fillStyle = def.color; |
| |
| if (type === 'sentinel') { |
| const r = radius; |
| ctx.beginPath(); |
| for (let i = 0; i < 6; i++) { |
| const a = (Math.PI * 2 / 6) * i; |
| const px = Math.cos(a) * r; |
| const py = Math.sin(a) * r; |
| i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); |
| } |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| |
| ctx.strokeStyle = def.color; |
| ctx.lineWidth = 1; |
| ctx.shadowBlur = 8; |
| ctx.beginPath(); |
| ctx.arc(0, 0, r * 0.5, 0, Math.PI * 2); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 10; |
| for (let i = 0; i < 6; i++) { |
| const a = (Math.PI * 2 / 6) * i; |
| ctx.beginPath(); |
| ctx.arc(Math.cos(a) * r * 0.85, Math.sin(a) * r * 0.85, 3, 0, Math.PI * 2); |
| ctx.fill(); |
| } |
| |
| } else if (type === 'carrier') { |
| const r = radius; |
| ctx.beginPath(); |
| for (let i = 0; i < 8; i++) { |
| const a = (Math.PI * 2 / 8) * i; |
| const px = Math.cos(a) * r; |
| const py = Math.sin(a) * r; |
| i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); |
| } |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| |
| ctx.strokeStyle = def.color; |
| ctx.lineWidth = 1; |
| ctx.shadowBlur = 8; |
| ctx.beginPath(); |
| ctx.arc(0, 0, r * 0.55, 0, Math.PI * 2); |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 8; |
| for (let i = 0; i < 4; i++) { |
| const a = (Math.PI * 2 / 4) * i; |
| const nx = Math.cos(a) * r * 1.05; |
| const ny = Math.sin(a) * r * 1.05; |
| ctx.fillRect(nx - 3, ny - 3, 6, 6); |
| } |
| |
| } else if (type === 'warden') { |
| const s = radius * 0.8; |
| ctx.fillRect(-s, -s, s * 2, s * 2); |
| |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 2; |
| ctx.strokeRect(-s, -s, s * 2, s * 2); |
| |
| ctx.strokeStyle = def.color; |
| ctx.lineWidth = 1; |
| ctx.shadowBlur = 8; |
| ctx.strokeRect(-s * 0.5, -s * 0.5, s, s); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 10; |
| for (let i = 0; i < 4; i++) { |
| const cx = (i % 2 === 0 ? -1 : 1) * s * 0.75; |
| const cy = (i < 2 ? -1 : 1) * s * 0.75; |
| ctx.beginPath(); |
| ctx.arc(cx, cy, 3, 0, Math.PI * 2); |
| ctx.fill(); |
| } |
| } |
| |
| ctx.shadowBlur = 0; |
| ctx.restore(); |
| } |
| |
| |
| function drawTutorialPowerup(type, sx, sy) { |
| const def = POWERUP_TYPES[type]; |
| |
| ctx.save(); |
| ctx.translate(sx, sy); |
| |
| ctx.shadowColor = def.glow; |
| ctx.shadowBlur = 12; |
| |
| ctx.fillStyle = def.color; |
| ctx.beginPath(); |
| ctx.moveTo(0, -10); |
| ctx.lineTo(10, 0); |
| ctx.lineTo(0, 10); |
| ctx.lineTo(-10, 0); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 1; |
| ctx.stroke(); |
| |
| ctx.shadowBlur = 0; |
| ctx.fillStyle = '#000'; |
| ctx.font = 'bold 8px monospace'; |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'middle'; |
| ctx.fillText(def.label[0], 0, 0); |
| |
| ctx.restore(); |
| } |
| |
| |
| function update(dt) { |
| if (gameState === 'menu' || gameState === 'tutorial' || gameState === 'gameover') return; |
| |
| const sDt = dt * timescale; |
| |
| if (gameState === 'dying') { |
| deathTimer -= dt; |
| deathFlash -= dt; |
| if (deathTimer <= 0) { |
| gameState = 'gameover'; |
| timescale = 1.0; |
| } |
| updateParticles(sDt); |
| updateCamera(sDt); |
| updateNebula(dt); |
| return; |
| } |
| |
| if (gameState !== 'playing') return; |
| |
| if (hitstopTimer > 0) { hitstopTimer -= sDt; return; } |
| |
| gameTime += sDt; |
| |
| if (chromaFade > 0) chromaFade -= sDt; |
| |
| if (comboTimer > 0) { |
| comboTimer -= sDt; |
| if (comboTimer <= 0) { |
| comboCount = 0; |
| comboMultiplier = 1; |
| lastComboLevel = 1; |
| } |
| } |
| |
| if (powerupRapidTimer > 0) powerupRapidTimer -= sDt; |
| if (powerupTripleTimer > 0) powerupTripleTimer -= sDt; |
| |
| let ax = 0, ay = 0; |
| if (keys['KeyW'] || keys['ArrowUp']) ay -= 1; |
| if (keys['KeyS'] || keys['ArrowDown']) ay += 1; |
| if (keys['KeyA'] || keys['ArrowLeft']) ax -= 1; |
| if (keys['KeyD'] || keys['ArrowRight']) ax += 1; |
| |
| const len = Math.sqrt(ax * ax + ay * ay); |
| if (len > 0) { ax /= len; ay /= len; } |
| |
| if (keys['ShiftLeft'] || keys['ShiftRight']) { |
| if (player.dashCooldown <= 0 && player.dashTimer <= 0) { |
| player.dashTimer = DASH_DURATION; |
| player.dashInvuln = DASH_INVULN; |
| player.dashCooldown = DASH_COOLDOWN; |
| playSound('dash'); |
| } |
| } |
| |
| |
| if (player.dashInvuln > 0) player.dashInvuln -= sDt; |
| |
| if (player.dashTimer > 0) { |
| player.dashTimer -= sDt; |
| let ddx = ax, ddy = ay; |
| if (len < 0.1) { ddx = Math.cos(player.angle); ddy = Math.sin(player.angle); } |
| player.vx += ddx * DASH_SPEED * sDt * 15; |
| player.vy += ddy * DASH_SPEED * sDt * 15; |
| emitDashTrail(player.x, player.y, '#0ff'); |
| } |
| |
| if (len > 0) { |
| player.vx += ax * PLAYER_ACCEL * sDt; |
| player.vy += ay * PLAYER_ACCEL * sDt; |
| const thrustAngle = player.angle + Math.PI; |
| for (let i = 0; i < 2; i++) emitThruster( |
| player.x + Math.cos(thrustAngle) * 14, |
| player.y + Math.sin(thrustAngle) * 14, |
| thrustAngle |
| ); |
| } |
| |
| player.vx -= player.vx * PLAYER_DRAG * sDt; |
| player.vy -= player.vy * PLAYER_DRAG * sDt; |
| |
| const spd = Math.sqrt(player.vx * player.vx + player.vy * player.vy); |
| if (spd > PLAYER_SPEED && player.dashTimer <= 0) { |
| player.vx = (player.vx / spd) * PLAYER_SPEED; |
| player.vy = (player.vy / spd) * PLAYER_SPEED; |
| } |
| |
| player.x += player.vx * sDt; |
| player.y += player.vy * sDt; |
| player.x = Math.max(player.radius, Math.min(WORLD_W - player.radius, player.x)); |
| player.y = Math.max(player.radius, Math.min(WORLD_H - player.radius, player.y)); |
| |
| mouseWorldX = mouseX + camera.x; |
| mouseWorldY = mouseY + camera.y; |
| player.angle = Math.atan2(mouseWorldY - player.y, mouseWorldX - player.x); |
| |
| const cd = powerupRapidTimer > 0 ? BULLET_COOLDOWN * 0.5 : BULLET_COOLDOWN; |
| player.shootCooldown -= sDt; |
| if (mouseDown && player.shootCooldown <= 0) { |
| player.shootCooldown = cd; |
| if (powerupTripleTimer > 0) { |
| const spread = 10 * Math.PI / 180; |
| spawnBullet(player.x + Math.cos(player.angle) * 18, player.y + Math.sin(player.angle) * 18, |
| player.angle - spread, 'player', '#ff00ff', BULLET_SPEED, 3); |
| spawnBullet(player.x + Math.cos(player.angle) * 18, player.y + Math.sin(player.angle) * 18, |
| player.angle, 'player', '#ff00ff', BULLET_SPEED, 3); |
| spawnBullet(player.x + Math.cos(player.angle) * 18, player.y + Math.sin(player.angle) * 18, |
| player.angle + spread, 'player', '#ff00ff', BULLET_SPEED, 3); |
| } else { |
| spawnBullet(player.x + Math.cos(player.angle) * 18, player.y + Math.sin(player.angle) * 18, |
| player.angle, 'player', '#0ff', BULLET_SPEED, 3); |
| } |
| playSound('shoot'); |
| } |
| |
| player.dashCooldown -= sDt; |
| |
| updateCamera(sDt); |
| |
| if (waveActive) { |
| if (enemiesSpawned < enemiesToSpawn) { |
| spawnTimer -= sDt; |
| if (spawnTimer <= 0) { |
| spawnWaveEnemy(); |
| if (wave === 1 && enemiesSpawned <= 3) { |
| spawnTimer = 0.25; |
| } else { |
| spawnTimer = Math.max(0.15, 0.6 - wave * 0.03); |
| } |
| } |
| } |
| |
| if (enemiesSpawned >= enemiesToSpawn && enemyPool.length === 0 && bossPool.length === 0) { |
| waveActive = false; |
| waveBreakTimer = 3.0; |
| } |
| } else { |
| waveBreakTimer -= sDt; |
| if (waveBreakTimer <= 0) { |
| wave++; |
| startWave(); |
| } |
| } |
| |
| enemyPool.forEach(e => { |
| const def = ENEMY_TYPES[e.type]; |
| const dx = player.x - e.x; |
| const dy = player.y - e.y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| e.angle = Math.atan2(dy, dx); |
| |
| if (e.flashTimer > 0) e.flashTimer -= sDt; |
| |
| |
| if (e.type === 'runner') { |
| e.armed = dist < 200; |
| } |
| |
| if (!def.shoot || e.type !== 'shooter') { |
| const vLen = Math.sqrt(e.vx * e.vx + e.vy * e.vy); |
| if (vLen > 1) { |
| e.angle = Math.atan2(e.vy, e.vx); |
| } else { |
| e.angle = Math.atan2(dy, dx); |
| } |
| } |
| |
| if (def.shoot && dist < def.range) { |
| if (dist < def.range * 0.6) { |
| e.vx -= (dx / dist) * def.speed * sDt; |
| e.vy -= (dy / dist) * def.speed * sDt; |
| } else if (dist > def.range * 0.8) { |
| e.vx += (dx / dist) * def.speed * sDt; |
| e.vy += (dy / dist) * def.speed * sDt; |
| } |
| |
| e.angle = Math.atan2(dy, dx); |
| |
| e.shootTimer -= sDt; |
| if (e.shootTimer <= 0) { |
| e.shootTimer = def.shootCd; |
| if (e.type === 'scout') { |
| spawnBullet(e.x, e.y, e.angle, 'enemy', def.projColor || def.color, |
| def.projSpeed || 280, def.projRadius || 2.5, false, 0, true); |
| } else { |
| spawnBullet(e.x, e.y, e.angle, 'enemy', def.color, 340, 5); |
| } |
| playSound('enemyShoot'); |
| } |
| } else { |
| if (dist > 0) { |
| e.vx += (dx / dist) * def.speed * sDt; |
| e.vy += (dy / dist) * def.speed * sDt; |
| } |
| } |
| |
| if (e.type === 'grunt' && !def.shoot) { |
| e.gruntShootTimer -= sDt; |
| if (e.gruntShootTimer <= 0) { |
| e.gruntShootTimer = 4; |
| if (dist < 400 && Math.random() < 0.5) { |
| spawnBullet(e.x, e.y, Math.atan2(dy, dx), 'enemy', '#ff6644', 240, 3.5); |
| playSound('enemyShoot'); |
| } |
| } |
| } |
| |
| |
| if (e.type === 'tank') { |
| e.burstTimer -= sDt; |
| if (e.burstTimer <= 0) { |
| e.burstTimer = 3.5; |
| for (let i = 0; i < 6; i++) { |
| const a = (Math.PI * 2 / 6) * i; |
| spawnBullet(e.x, e.y, a, 'enemy', '#aa44ff', 220, 4); |
| } |
| playSound('enemyShoot'); |
| } |
| } |
| |
| e.vx *= 0.95; |
| e.vy *= 0.95; |
| e.x += e.vx * sDt; |
| e.y += e.vy * sDt; |
| }); |
| |
| bossPool.forEach(b => { |
| if (!b.alive) return; |
| updateBoss(b, sDt); |
| |
| if (player.dashInvuln <= 0) { |
| const def = BOSS_TYPES[b.type]; |
| const dx = b.x - player.x; |
| const dy = b.y - player.y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| if (dist < b.radius + player.radius) { |
| const pushAngle = Math.atan2(dy, dx); |
| b.vx += Math.cos(pushAngle) * 300; |
| b.vy += Math.sin(pushAngle) * 300; |
| if (powerupShield) { |
| powerupShield = false; |
| emitExplosion(player.x, player.y, '#ffff00', 15, 100, [2, 4]); |
| playSound('hit'); |
| } else { |
| player.hp -= def.contactDmg; |
| damageFlash = 0.3; |
| shakeIntensity = 8; |
| chromaTimer = 0.4; |
| chromaFade = 0.4; |
| playSound('hurt'); |
| spawnDmgNum(player.x, player.y - 20, def.contactDmg, '#f44'); |
| const kbAngle = Math.atan2(dy, dx); |
| player.vx += Math.cos(kbAngle) * 350; |
| player.vy += Math.sin(kbAngle) * 350; |
| comboCount = 0; comboTimer = 0; comboMultiplier = 1; |
| if (player.hp <= 0) killPlayer(); |
| } |
| } |
| } |
| }); |
| |
| bulletPool.forEach(b => { |
| b.trail.push({ x: b.x, y: b.y }); |
| if (b.trail.length > 4) b.trail.shift(); |
| |
| b.x += b.vx * sDt; |
| b.y += b.vy * sDt; |
| b.life -= sDt; |
| |
| if (b.homing && b.owner === 'enemy') { |
| const dx = player.x - b.x; |
| const dy = player.y - b.y; |
| const targetAngle = Math.atan2(dy, dx); |
| let angleDiff = targetAngle - b.angle; |
| while (angleDiff > Math.PI) angleDiff -= Math.PI * 2; |
| while (angleDiff < -Math.PI) angleDiff += Math.PI * 2; |
| b.angle += angleDiff * b.turnRate * sDt; |
| b.vx = Math.cos(b.angle) * 180; |
| b.vy = Math.sin(b.angle) * 180; |
| } |
| }); |
| bulletPool.prune(b => b.life > 0); |
| |
| |
| bulletPool.forEach(b => { |
| if (b.owner !== 'player') return; |
| enemyPool.forEach(e => { |
| const dx = b.x - e.x; |
| const dy = b.y - e.y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| if (dist < b.radius + e.radius) { |
| b.life = 0; |
| e.hp -= 10; |
| e.flashTimer = 0.08; |
| spawnDmgNum(e.x + (Math.random() - 0.5) * 10, e.y - e.radius, 10, '#0ff'); |
| emitHitSparks(b.x, b.y, ENEMY_TYPES[e.type].color, 5); |
| playSound('hit'); |
| |
| if (e.hp <= 0) { |
| e.alive = false; |
| const def = ENEMY_TYPES[e.type]; |
| comboCount++; |
| comboTimer = 2.5; |
| const newMult = Math.min(4, 1 + (comboCount - 1) * 0.5); |
| if (newMult > comboMultiplier) { |
| comboMultiplier = newMult; |
| playSound('combo'); |
| spawnFloatScore(W / 2, H / 2 - 80, 'ร' + comboMultiplier.toFixed(1), '#0ff'); |
| } |
| const points = Math.floor(def.points * comboMultiplier); |
| score += points; |
| totalKills++; |
| killsThisWave++; |
| |
| emitExplosion(e.x, e.y, def.color, 20, 150, [2, 5]); |
| hitstopTimer = 0.03; |
| shakeIntensity = 3; |
| playSound('explosion'); |
| |
| const dropChance = 0.12; |
| if (Math.random() < dropChance) { |
| const types = ['rapid', 'triple', 'shield']; |
| const pType = types[Math.floor(Math.random() * types.length)]; |
| spawnPowerUp(pType, e.x, e.y); |
| } |
| } |
| } |
| }); |
| |
| |
| bossPool.forEach(boss => { |
| if (!boss.alive) return; |
| const dx = b.x - boss.x; |
| const dy = b.y - boss.y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| if (dist < b.radius + boss.radius) { |
| b.life = 0; |
| boss.hp -= 10; |
| boss.flashTimer = 0.08; |
| spawnDmgNum(boss.x + (Math.random() - 0.5) * 10, boss.y - boss.radius, 10, '#0ff'); |
| emitHitSparks(b.x, b.y, BOSS_TYPES[boss.type].color, 5); |
| playSound('hit'); |
| |
| if (boss.hp <= 0) { |
| killBoss(boss); |
| } |
| } |
| }); |
| }); |
| |
| |
| bossPool.prune(b => b.alive); |
| |
| |
| if (player.dashInvuln <= 0) { |
| bulletPool.forEach(b => { |
| if (b.owner !== 'enemy') return; |
| const dx = b.x - player.x; |
| const dy = b.y - player.y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| if (dist < b.radius + player.radius) { |
| b.life = 0; |
| const dmg = b.isScout ? 5 : 15; |
| if (powerupShield) { |
| powerupShield = false; |
| emitExplosion(player.x, player.y, '#ffff00', 15, 100, [2, 4]); |
| playSound('hit'); |
| } else { |
| player.hp -= dmg; |
| damageFlash = 0.25; |
| shakeIntensity = 5; |
| chromaTimer = 0.4; |
| chromaFade = 0.4; |
| playSound('hurt'); |
| spawnDmgNum(player.x, player.y - 20, dmg, '#f44'); |
| const kbAngle = Math.atan2(dy, dx); |
| player.vx += Math.cos(kbAngle) * 200; |
| player.vy += Math.sin(kbAngle) * 200; |
| comboCount = 0; comboTimer = 0; comboMultiplier = 1; |
| if (player.hp <= 0) killPlayer(); |
| } |
| } |
| }); |
| } |
| |
| |
| if (player.dashInvuln <= 0) { |
| enemyPool.forEach(e => { |
| const def = ENEMY_TYPES[e.type]; |
| const dx = e.x - player.x; |
| const dy = e.y - player.y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| if (dist < e.radius + player.radius) { |
| |
| if (e.type === 'runner') { |
| e.alive = false; |
| emitExplosion(e.x, e.y, '#ffaa22', 40, 300, [3, 7]); |
| emitExplosion(e.x, e.y, '#ff6600', 15, 200, [2, 5]); |
| emitExplosion(e.x, e.y, '#fff', 8, 150, [1, 3]); |
| playSound('explosion'); |
| shakeIntensity += 8; |
| hitstopTimer = 0.06; |
| |
| if (powerupShield) { |
| powerupShield = false; |
| emitExplosion(player.x, player.y, '#ffff00', 15, 100, [2, 4]); |
| playSound('hit'); |
| } else { |
| player.hp -= 30; |
| damageFlash = 0.3; |
| chromaTimer = 0.4; |
| chromaFade = 0.4; |
| playSound('hurt'); |
| spawnDmgNum(player.x, player.y - 20, 30, '#ff6600'); |
| const kbAngle = Math.atan2(dy, dx); |
| player.vx += Math.cos(kbAngle) * 400; |
| player.vy += Math.sin(kbAngle) * 400; |
| comboCount = 0; comboTimer = 0; comboMultiplier = 1; |
| } |
| |
| const aoeDx = player.x - e.x; |
| const aoeDy = player.y - e.y; |
| const aoeDist = Math.sqrt(aoeDx * aoeDx + aoeDy * aoeDy); |
| if (aoeDist < 80 && player.hp > 0) { |
| if (powerupShield) { |
| powerupShield = false; |
| } else { |
| player.hp -= 20; |
| spawnDmgNum(player.x + 15, player.y - 10, 20, '#ff4400'); |
| } |
| } |
| |
| if (player.hp <= 0) killPlayer(); |
| return; |
| } |
| |
| const pushAngle = Math.atan2(dy, dx); |
| e.vx += Math.cos(pushAngle) * 400; |
| e.vy += Math.sin(pushAngle) * 400; |
| if (powerupShield) { |
| powerupShield = false; |
| emitExplosion(player.x, player.y, '#ffff00', 15, 100, [2, 4]); |
| playSound('hit'); |
| } else { |
| player.hp -= def.contactDmg; |
| damageFlash = 0.3; |
| shakeIntensity = 8; |
| chromaTimer = 0.4; |
| chromaFade = 0.4; |
| playSound('hurt'); |
| spawnDmgNum(player.x, player.y - 20, def.contactDmg, '#f44'); |
| const kbAngle = Math.atan2(dy, dx); |
| player.vx += Math.cos(kbAngle) * 350; |
| player.vy += Math.sin(kbAngle) * 350; |
| comboCount = 0; comboTimer = 0; comboMultiplier = 1; |
| if (player.hp <= 0) killPlayer(); |
| } |
| } |
| }); |
| } |
| |
| |
| powerupPool.forEach(p => { |
| if (!p.alive) return; |
| p.life -= sDt; |
| if (p.flashTimer > 0) p.flashTimer -= sDt; |
| |
| const dx = player.x - p.x; |
| const dy = player.y - p.y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| |
| const magnetRange = 200; |
| if (dist < magnetRange) { |
| const strength = (1 - dist / magnetRange) * 300; |
| if (dist > 0) { |
| p.vx += (dx / dist) * strength * sDt; |
| p.vy += (dy / dist) * strength * sDt; |
| } |
| } |
| |
| p.vx *= 0.9; |
| p.vy *= 0.9; |
| p.x += p.vx * sDt; |
| p.y += p.vy * sDt; |
| |
| if (dist < player.radius + p.radius) { |
| p.alive = false; |
| playSound('powerup'); |
| emitExplosion(p.x, p.y, POWERUP_TYPES[p.type].color, 10, 80, [1, 3]); |
| |
| if (p.type === 'rapid') powerupRapidTimer = POWERUP_TYPES.rapid.duration; |
| else if (p.type === 'triple') powerupTripleTimer = POWERUP_TYPES.triple.duration; |
| else if (p.type === 'shield') powerupShield = true; |
| } |
| |
| if (p.life <= 0) p.alive = false; |
| }); |
| powerupPool.prune(p => p.alive); |
| |
| |
| enemyPool.prune(e => e.alive); |
| |
| |
| telegraphPool.forEach(t => { |
| t.timer += sDt; |
| const progress = t.timer / t.maxTime; |
| t.radius = 10 + progress * 40; |
| }); |
| telegraphPool.prune(t => t.timer < t.maxTime); |
| |
| |
| updateParticles(sDt); |
| |
| |
| dmgNumPool.forEach(d => { |
| d.y += d.vy * sDt; |
| d.vy *= 0.95; |
| d.life -= sDt; |
| }); |
| dmgNumPool.prune(d => d.life > 0); |
| |
| |
| floatScorePool.forEach(d => { |
| d.y += d.vy * sDt; |
| d.vy *= 0.95; |
| d.life -= sDt; |
| }); |
| floatScorePool.prune(d => d.life > 0); |
| |
| |
| if (shakeIntensity > 0) { |
| shakeX = (Math.random() - 0.5) * shakeIntensity; |
| shakeY = (Math.random() - 0.5) * shakeIntensity; |
| shakeIntensity *= Math.pow(0.01, sDt); |
| if (shakeIntensity < 0.5) { shakeIntensity = 0; shakeX = 0; shakeY = 0; } |
| } |
| |
| if (damageFlash > 0) damageFlash -= sDt; |
| updateNebula(dt); |
| } |
| |
| function killPlayer() { |
| player.alive = false; |
| player.hp = 0; |
| player.dashInvuln = 0; |
| player.dashTimer = 0; |
| gameState = 'dying'; |
| deathTimer = 1.5; |
| deathFlash = 0.5; |
| timescale = 0.2; |
| chromaTimer = 0.5; |
| chromaFade = 0.5; |
| emitExplosion(player.x, player.y, '#0ff', 40, 200, [2, 6]); |
| playSound('explosion'); |
| } |
| |
| function updateCamera(dt) { |
| const targetCX = player.x - W / 2; |
| const targetCY = player.y - H / 2; |
| camera.x += (targetCX - camera.x) * 5 * dt; |
| camera.y += (targetCY - camera.y) * 5 * dt; |
| } |
| |
| function updateParticles(dt) { |
| particlePool.forEach(p => { |
| p.x += p.vx * dt; |
| p.y += p.vy * dt; |
| p.vx *= 0.96; |
| p.vy *= 0.96; |
| p.life -= dt; |
| }); |
| particlePool.prune(p => p.life > 0); |
| } |
| |
| function updateNebula(dt) { |
| nebulae.forEach(n => { n.angle += dt * 0.02; }); |
| } |
| |
| |
| function render() { |
| chromaCtx.clearRect(0, 0, W, H); |
| chromaCtx.drawImage(canvas, 0, 0); |
| |
| ctx.clearRect(0, 0, W, H); |
| |
| ctx.fillStyle = '#050510'; |
| ctx.fillRect(0, 0, W, H); |
| |
| nebulae.forEach(n => { |
| const sx = n.x - camera.x * 0.1; |
| const sy = n.y - camera.y * 0.1; |
| const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, n.r); |
| grad.addColorStop(0, n.color1); |
| grad.addColorStop(1, n.color2); |
| ctx.save(); |
| ctx.translate(sx, sy); |
| ctx.rotate(n.angle); |
| ctx.scale(1, 0.7); |
| ctx.translate(-sx, -sy); |
| ctx.fillStyle = grad; |
| ctx.fillRect(sx - n.r, sy - n.r, n.r * 2, n.r * 2); |
| ctx.restore(); |
| }); |
| |
| stars.forEach(s => { |
| const parallax = [0.05, 0.15, 0.3][s.layer]; |
| const sx = s.x - camera.x * parallax; |
| const sy = s.y - camera.y * parallax; |
| const twinkle = 0.5 + 0.5 * Math.sin(gameTime * s.twinkleSpeed + s.x); |
| const alpha = s.brightness * twinkle; |
| ctx.fillStyle = `rgba(200,210,255,${alpha})`; |
| ctx.beginPath(); |
| ctx.arc(sx, sy, s.size, 0, Math.PI * 2); |
| ctx.fill(); |
| }); |
| |
| ctx.save(); |
| ctx.translate(shakeX, shakeY); |
| ctx.translate(-camera.x, -camera.y); |
| |
| ctx.strokeStyle = 'rgba(0,255,255,0.15)'; |
| ctx.lineWidth = 2; |
| ctx.strokeRect(0, 0, WORLD_W, WORLD_H); |
| |
| ctx.strokeStyle = 'rgba(0,255,255,0.03)'; |
| ctx.lineWidth = 1; |
| for (let x = 0; x <= WORLD_W; x += 200) { |
| ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, WORLD_H); ctx.stroke(); |
| } |
| for (let y = 0; y <= WORLD_H; y += 200) { |
| ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(WORLD_W, y); ctx.stroke(); |
| } |
| |
| |
| telegraphPool.forEach(t => { |
| const progress = t.timer / t.maxTime; |
| const alpha = (1 - progress) * 0.6; |
| ctx.strokeStyle = `rgba(255,100,100,${alpha})`; |
| ctx.lineWidth = 2; |
| ctx.shadowColor = '#ff4444'; |
| ctx.shadowBlur = 8; |
| ctx.beginPath(); |
| ctx.arc(t.x, t.y, t.radius, 0, Math.PI * 2); |
| ctx.stroke(); |
| ctx.shadowBlur = 0; |
| }); |
| |
| |
| particlePool.forEach(p => { |
| const alpha = Math.max(0, p.life / p.maxLife); |
| ctx.globalAlpha = alpha; |
| ctx.fillStyle = p.color; |
| ctx.shadowColor = p.color; |
| ctx.shadowBlur = 6; |
| ctx.beginPath(); |
| ctx.arc(p.x, p.y, p.size * alpha, 0, Math.PI * 2); |
| ctx.fill(); |
| }); |
| ctx.globalAlpha = 1; |
| ctx.shadowBlur = 0; |
| |
| |
| powerupPool.forEach(p => { |
| const def = POWERUP_TYPES[p.type]; |
| const alpha = p.life < 3 ? 0.4 + 0.6 * Math.abs(Math.sin(gameTime * 8)) : 1; |
| const flash = p.flashTimer > 0; |
| |
| ctx.save(); |
| ctx.translate(p.x, p.y); |
| ctx.globalAlpha = alpha; |
| |
| ctx.shadowColor = flash ? '#fff' : def.glow; |
| ctx.shadowBlur = flash ? 20 : 12; |
| |
| ctx.fillStyle = flash ? '#fff' : def.color; |
| ctx.beginPath(); |
| ctx.moveTo(0, -p.radius); |
| ctx.lineTo(p.radius, 0); |
| ctx.lineTo(0, p.radius); |
| ctx.lineTo(-p.radius, 0); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 1; |
| ctx.stroke(); |
| |
| ctx.shadowBlur = 0; |
| ctx.fillStyle = '#000'; |
| ctx.font = 'bold 8px monospace'; |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'middle'; |
| ctx.fillText(def.label[0], 0, 0); |
| |
| ctx.globalAlpha = 1; |
| ctx.restore(); |
| }); |
| |
| |
| bulletPool.forEach(b => { |
| if (b.isScout) { |
| if (b.trail.length > 1) { |
| ctx.strokeStyle = 'rgba(136,255,68,0.5)'; |
| ctx.lineWidth = 2; |
| ctx.shadowColor = '#44ee22'; |
| ctx.shadowBlur = 6; |
| ctx.beginPath(); |
| ctx.moveTo(b.trail[0].x, b.trail[0].y); |
| for (let i = 1; i < b.trail.length; i++) { |
| ctx.lineTo(b.trail[i].x, b.trail[i].y); |
| } |
| ctx.lineTo(b.x, b.y); |
| ctx.stroke(); |
| ctx.shadowBlur = 0; |
| } |
| |
| ctx.save(); |
| ctx.translate(b.x, b.y); |
| ctx.rotate(b.angle); |
| |
| ctx.fillStyle = b.color; |
| ctx.shadowColor = b.color; |
| ctx.shadowBlur = 12; |
| ctx.beginPath(); |
| ctx.moveTo(7, 0); |
| ctx.lineTo(-4, -2); |
| ctx.lineTo(-4, 2); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 6; |
| ctx.beginPath(); |
| ctx.arc(4, 0, 1.2, 0, Math.PI * 2); |
| ctx.fill(); |
| |
| ctx.shadowBlur = 0; |
| ctx.restore(); |
| } else { |
| if (b.trail.length > 1) { |
| ctx.strokeStyle = b.color; |
| ctx.lineWidth = b.radius; |
| ctx.shadowColor = b.color; |
| ctx.shadowBlur = 8; |
| ctx.lineCap = 'round'; |
| ctx.beginPath(); |
| ctx.moveTo(b.trail[0].x, b.trail[0].y); |
| for (let i = 1; i < b.trail.length; i++) { |
| ctx.lineTo(b.trail[i].x, b.trail[i].y); |
| } |
| ctx.lineTo(b.x, b.y); |
| ctx.stroke(); |
| ctx.shadowBlur = 0; |
| } |
| |
| ctx.fillStyle = b.color; |
| ctx.shadowColor = b.color; |
| ctx.shadowBlur = 10; |
| ctx.beginPath(); |
| ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.shadowBlur = 0; |
| } |
| }); |
| |
| |
| enemyPool.forEach(e => { |
| drawEnemyShip(e); |
| }); |
| |
| |
| bossPool.forEach(b => { |
| if (!b.alive) return; |
| drawBossShip(b); |
| }); |
| |
| |
| if (player.alive) { |
| ctx.save(); |
| ctx.translate(player.x, player.y); |
| ctx.rotate(player.angle); |
| |
| const isInvuln = player.dashInvuln > 0; |
| const alpha = isInvuln ? 0.4 + 0.3 * Math.sin(gameTime * 30) : 1; |
| ctx.globalAlpha = alpha; |
| |
| ctx.shadowColor = isInvuln ? '#fff' : '#0ff'; |
| ctx.shadowBlur = isInvuln ? 30 : 18; |
| |
| ctx.fillStyle = isInvuln ? '#fff' : '#0ff'; |
| ctx.beginPath(); |
| ctx.moveTo(18, 0); |
| ctx.lineTo(-12, -10); |
| ctx.lineTo(-6, 0); |
| ctx.lineTo(-12, 10); |
| ctx.closePath(); |
| ctx.fill(); |
| |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 1; |
| ctx.stroke(); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowBlur = 8; |
| ctx.beginPath(); |
| ctx.arc(4, 0, 3, 0, Math.PI * 2); |
| ctx.fill(); |
| |
| ctx.globalAlpha = 1; |
| ctx.shadowBlur = 0; |
| ctx.restore(); |
| |
| |
| if (powerupShield) { |
| ctx.strokeStyle = '#ffff00'; |
| ctx.shadowColor = '#ffff00'; |
| ctx.shadowBlur = 12; |
| ctx.lineWidth = 2; |
| ctx.globalAlpha = 0.6 + 0.3 * Math.sin(gameTime * 5); |
| ctx.beginPath(); |
| ctx.arc(player.x, player.y, player.radius + 10, 0, Math.PI * 2); |
| ctx.stroke(); |
| ctx.globalAlpha = 1; |
| ctx.shadowBlur = 0; |
| } |
| |
| |
| if (player.dashInvuln > 0) { |
| const invulnRatio = player.dashInvuln / DASH_INVULN; |
| const ringRadius = player.radius + 14 + (1 - invulnRatio) * 12; |
| const ringAlpha = invulnRatio * 0.55; |
| ctx.strokeStyle = `rgba(255,255,255,${ringAlpha})`; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 6 + invulnRatio * 14; |
| ctx.lineWidth = 2; |
| ctx.beginPath(); |
| ctx.arc(player.x, player.y, ringRadius, 0, Math.PI * 2); |
| ctx.stroke(); |
| ctx.shadowBlur = 0; |
| } |
| |
| |
| if (player.dashCooldown > 0) { |
| const cdRatio = player.dashCooldown / DASH_COOLDOWN; |
| ctx.strokeStyle = `rgba(0,255,255,${0.3 + 0.3 * (1 - cdRatio)})`; |
| ctx.lineWidth = 2; |
| ctx.beginPath(); |
| ctx.arc(player.x, player.y, player.radius + 6, -Math.PI / 2, -Math.PI / 2 + (1 - cdRatio) * Math.PI * 2); |
| ctx.stroke(); |
| } |
| } |
| |
| |
| dmgNumPool.forEach(d => { |
| const alpha = Math.max(0, d.life / d.maxLife); |
| ctx.globalAlpha = alpha; |
| ctx.fillStyle = d.color; |
| ctx.font = 'bold 14px monospace'; |
| ctx.textAlign = 'center'; |
| ctx.fillText(d.value, d.x, d.y); |
| }); |
| ctx.globalAlpha = 1; |
| |
| ctx.restore(); |
| |
| |
| if (damageFlash > 0) { |
| const alpha = damageFlash * 1.5; |
| const grad = ctx.createRadialGradient(W / 2, H / 2, W * 0.3, W / 2, H / 2, W * 0.8); |
| grad.addColorStop(0, 'rgba(255,0,0,0)'); |
| grad.addColorStop(1, `rgba(255,0,0,${Math.min(alpha, 0.4)})`); |
| ctx.fillStyle = grad; |
| ctx.fillRect(0, 0, W, H); |
| } |
| |
| |
| if (deathFlash > 0) { |
| ctx.fillStyle = `rgba(255,255,255,${Math.max(0, deathFlash * 2)})`; |
| ctx.fillRect(0, 0, W, H); |
| } |
| |
| |
| const margin = Math.min(W, H) * 0.02; |
| const hudFontSize = Math.max(14, Math.min(22, H * 0.022)); |
| |
| |
| if (bossPool.length > 0) { |
| const boss = bossPool.active[0]; |
| if (boss && boss.alive) { |
| const def = BOSS_TYPES[boss.type]; |
| const bossBarW = Math.min(600, W * 0.4); |
| const bossBarH = Math.max(6, H * 0.008); |
| const bossBarX = W / 2 - bossBarW / 2; |
| const bossBarY = margin + 2; |
| |
| ctx.fillStyle = def.color; |
| ctx.shadowColor = def.color; |
| ctx.shadowBlur = 8; |
| ctx.font = `bold ${Math.max(10, Math.min(14, H * 0.016))}px monospace`; |
| ctx.textAlign = 'center'; |
| ctx.fillText(`BOSS: ${def.name}`, W / 2, bossBarY - 8); |
| ctx.shadowBlur = 0; |
| |
| ctx.fillStyle = 'rgba(0,0,0,0.7)'; |
| ctx.fillRect(bossBarX - 2, bossBarY, bossBarW + 4, bossBarH + 4); |
| |
| const hpRatio = boss.hp / boss.maxHp; |
| ctx.fillStyle = hpRatio > 0.5 ? '#f44' : hpRatio > 0.25 ? '#ff4' : '#f00'; |
| ctx.shadowColor = ctx.fillStyle; |
| ctx.shadowBlur = 6; |
| ctx.fillRect(bossBarX, bossBarY + 2, bossBarW * hpRatio, bossBarH); |
| ctx.shadowBlur = 0; |
| } |
| } |
| |
| |
| ctx.fillStyle = '#0ff'; |
| ctx.shadowColor = '#0ff'; |
| ctx.shadowBlur = 6; |
| ctx.font = `bold ${hudFontSize}px monospace`; |
| ctx.textAlign = 'left'; |
| ctx.fillText(`SCORE ${score}`, margin, margin + hudFontSize); |
| ctx.fillText(`WAVE ${wave}`, margin, margin + hudFontSize * 2 + 4); |
| ctx.shadowBlur = 0; |
| |
| |
| if (comboCount > 0) { |
| const comboGlow = Math.min(comboMultiplier, 4); |
| const comboAlpha = 0.7 + 0.3 * Math.sin(gameTime * 6); |
| ctx.textAlign = 'center'; |
| const comboFontSize = Math.max(16, Math.min(26, H * 0.028)); |
| ctx.font = `bold ${comboFontSize}px monospace`; |
| |
| if (comboMultiplier >= 2) { |
| ctx.fillStyle = '#ff0'; |
| ctx.shadowColor = '#ff0'; |
| ctx.shadowBlur = 12 + comboGlow * 4; |
| } else { |
| ctx.fillStyle = comboMultiplier >= 4 ? '#ff0' : comboMultiplier >= 2 ? '#0ff' : '#aaa'; |
| ctx.shadowColor = ctx.fillStyle; |
| ctx.shadowBlur = 4 + comboGlow * 4; |
| } |
| ctx.globalAlpha = comboAlpha; |
| ctx.fillText(`ร${comboMultiplier.toFixed(1)} ${comboCount} COMBO`, W / 2, margin + hudFontSize + 2); |
| ctx.globalAlpha = 1; |
| ctx.shadowBlur = 0; |
| } |
| |
| |
| floatScorePool.forEach(d => { |
| const alpha = Math.max(0, d.life / d.maxLife); |
| ctx.globalAlpha = alpha; |
| ctx.fillStyle = d.color; |
| ctx.font = `bold ${Math.max(16, Math.min(24, H * 0.026))}px monospace`; |
| ctx.textAlign = 'center'; |
| ctx.shadowColor = d.color; |
| ctx.shadowBlur = 8; |
| ctx.fillText(d.value, d.x, d.y); |
| }); |
| ctx.globalAlpha = 1; |
| ctx.shadowBlur = 0; |
| |
| |
| const hpBarW = Math.min(220, W * 0.22); |
| const hpBarH = Math.max(12, H * 0.018); |
| const hpBarX = W - hpBarW - margin; |
| const hpBarY = margin + 2; |
| const hpRatio = player.hp / PLAYER_MAX_HP; |
| |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; |
| ctx.fillRect(hpBarX - 2, hpBarY - 2, hpBarW + 4, hpBarH + 4); |
| |
| ctx.fillStyle = hpRatio > 0.5 ? '#0f8' : hpRatio > 0.25 ? '#fa0' : '#f24'; |
| ctx.shadowColor = ctx.fillStyle; |
| ctx.shadowBlur = 8; |
| ctx.fillRect(hpBarX, hpBarY, hpBarW * hpRatio, hpBarH); |
| ctx.shadowBlur = 0; |
| |
| ctx.fillStyle = '#fff'; |
| ctx.font = `bold ${Math.max(8, Math.min(12, H * 0.013))}px monospace`; |
| ctx.textAlign = 'center'; |
| ctx.fillText(`${Math.ceil(player.hp)} / ${PLAYER_MAX_HP}`, hpBarX + hpBarW / 2, hpBarY + hpBarH - 2); |
| |
| |
| |
| const chipW = Math.max(60, Math.min(90, W * 0.08)); |
| const chipH = Math.max(14, H * 0.022); |
| const chipX = W - chipW - margin; |
| let chipY = hpBarY + hpBarH + 8; |
| |
| |
| |
| |
| const activePowerups = []; |
| if (powerupRapidTimer > 0) activePowerups.push({ type: 'rapid', ratio: powerupRapidTimer / POWERUP_TYPES.rapid.duration }); |
| if (powerupTripleTimer > 0) activePowerups.push({ type: 'triple', ratio: powerupTripleTimer / POWERUP_TYPES.triple.duration }); |
| if (powerupShield) activePowerups.push({ type: 'shield', ratio: 1 }); |
| |
| activePowerups.forEach(pu => { |
| const def = POWERUP_TYPES[pu.type]; |
| |
| |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; |
| ctx.fillRect(chipX, chipY, chipW, chipH); |
| |
| |
| const boxSize = chipH - 2; |
| ctx.fillStyle = def.color; |
| ctx.shadowColor = def.color; |
| ctx.shadowBlur = 6; |
| ctx.fillRect(chipX + 2, chipY + 1, boxSize, boxSize); |
| ctx.shadowBlur = 0; |
| |
| |
| ctx.fillStyle = '#fff'; |
| ctx.font = `bold ${Math.max(8, Math.min(11, H * 0.012))}px monospace`; |
| ctx.textAlign = 'left'; |
| ctx.textBaseline = 'middle'; |
| ctx.fillText(def.label.substring(0, 3), chipX + boxSize + 6, chipY + chipH / 2); |
| |
| |
| if (pu.type !== 'shield') { |
| ctx.fillStyle = def.color; |
| ctx.globalAlpha = 0.25; |
| ctx.fillRect(chipX + boxSize + 4, chipY + 2, (chipW - boxSize - 8) * pu.ratio, chipH - 4); |
| ctx.globalAlpha = 1; |
| } else { |
| |
| ctx.fillStyle = def.color; |
| ctx.font = `bold ${Math.max(10, Math.min(13, H * 0.014))}px monospace`; |
| ctx.textAlign = 'center'; |
| ctx.fillText('โ', chipX + boxSize + 8, chipY + chipH / 2); |
| } |
| |
| chipY += chipH + 4; |
| }); |
| |
| |
| |
| |
| |
| |
| |
| if (!waveActive && waveBreakTimer > 0) { |
| ctx.fillStyle = 'rgba(0,255,255,0.6)'; |
| ctx.font = `bold ${Math.max(18, Math.min(28, H * 0.035))}px monospace`; |
| ctx.textAlign = 'center'; |
| ctx.fillText(`NEXT WAVE IN ${Math.ceil(waveBreakTimer)}`, W / 2, H / 2 - 50); |
| } |
| |
| |
| if (waveActive && enemiesToSpawn > 0) { |
| const barY = H - margin - Math.max(6, H * 0.008); |
| const barW = W - margin * 2; |
| const barX = margin; |
| const progress = Math.min(1, enemiesSpawned / enemiesToSpawn); |
| const barColor = isBossWave ? '#ff00ff' : '#00ffff'; |
| |
| ctx.fillStyle = 'rgba(0,0,0,0.4)'; |
| ctx.fillRect(barX, barY, barW, Math.max(4, H * 0.005)); |
| |
| ctx.fillStyle = barColor; |
| ctx.shadowColor = barColor; |
| ctx.shadowBlur = 8; |
| ctx.fillRect(barX, barY, barW * progress, Math.max(4, H * 0.005)); |
| ctx.shadowBlur = 0; |
| } |
| |
| |
| ctx.strokeStyle = 'rgba(0,255,255,0.5)'; |
| ctx.lineWidth = 1; |
| const ch = 12; |
| ctx.beginPath(); |
| ctx.moveTo(mouseX - ch, mouseY); ctx.lineTo(mouseX - 4, mouseY); |
| ctx.moveTo(mouseX + 4, mouseY); ctx.lineTo(mouseX + ch, mouseY); |
| ctx.moveTo(mouseX, mouseY - ch); ctx.lineTo(mouseX, mouseY - 4); |
| ctx.moveTo(mouseX, mouseY + 4); ctx.lineTo(mouseX, mouseY + ch); |
| ctx.stroke(); |
| ctx.beginPath(); |
| ctx.arc(mouseX, mouseY, 2, 0, Math.PI * 2); |
| ctx.fillStyle = 'rgba(0,255,255,0.7)'; |
| ctx.fill(); |
| |
| |
| if (gameState === 'tutorial') { |
| renderTutorial(); |
| } |
| |
| |
| if (gameState === 'menu') { |
| ctx.fillStyle = 'rgba(5,5,16,0.88)'; |
| ctx.fillRect(0, 0, W, H); |
| |
| const titleSize = Math.max(26, Math.min(56, H * 0.06)); |
| const tagSize = Math.max(10, Math.min(14, H * 0.016)); |
| const bodySize = Math.max(11, Math.min(14, H * 0.016)); |
| const labelSize = Math.max(9, Math.min(12, H * 0.013)); |
| |
| let y = H * 0.18; |
| |
| |
| ctx.fillStyle = '#0ff'; |
| ctx.shadowColor = '#0ff'; |
| ctx.shadowBlur = 20; |
| ctx.font = `bold ${titleSize}px monospace`; |
| ctx.textAlign = 'center'; |
| ctx.fillText('QWOPUS COMMANDER', W / 2, y); |
| y += titleSize * 0.7; |
| |
| |
| ctx.shadowBlur = 0; |
| ctx.fillStyle = 'rgba(200,200,200,0.7)'; |
| ctx.font = `${tagSize}px monospace`; |
| ctx.fillText("built entirely on Jackrong's Qwopus 3.6 27B Q5_K_M", W / 2, y); |
| y += tagSize * 2.6; |
| |
| |
| const stats = [ |
| ['ITERATIONS', '9'], |
| ['MODEL TIME', '2 h 02 min'], |
| ['TOKENS GEN.', '303,537'], |
| ['THROUGHPUT', '41.5 tok/s'], |
| ['GAME SIZE', '3,125 lines โข 96 KB'], |
| ['DEPENDENCIES', '0 (no CDNs, no images, no audio files)'], |
| ['BEST TEMP', '0.8 - 1.0 (lower values trigger thinking loops)'], |
| ]; |
| |
| const colGap = Math.max(8, Math.min(14, W * 0.012)); |
| ctx.font = `bold ${labelSize}px monospace`; |
| const labelW = Math.max(...stats.map(s => ctx.measureText(s[0]).width)); |
| ctx.font = `${bodySize}px monospace`; |
| const valueW = Math.max(...stats.map(s => ctx.measureText(s[1]).width)); |
| const rowW = labelW + colGap + valueW; |
| const startX = (W - rowW) / 2; |
| |
| ctx.textBaseline = 'alphabetic'; |
| stats.forEach(([k, v]) => { |
| ctx.textAlign = 'right'; |
| ctx.fillStyle = '#666'; |
| ctx.font = `bold ${labelSize}px monospace`; |
| ctx.fillText(k, startX + labelW, y); |
| |
| ctx.textAlign = 'left'; |
| ctx.fillStyle = '#0ff'; |
| ctx.shadowColor = '#0ff'; |
| ctx.shadowBlur = 3; |
| ctx.font = `${bodySize}px monospace`; |
| ctx.fillText(v, startX + labelW + colGap, y); |
| ctx.shadowBlur = 0; |
| |
| y += bodySize * 1.55; |
| }); |
| |
| ctx.textAlign = 'center'; |
| y += bodySize * 0.6; |
| |
| |
| ctx.fillStyle = '#888'; |
| ctx.font = `italic ${bodySize}px monospace`; |
| ctx.fillText('A single 27B model wrote this game across 9 iterations.', W / 2, y); |
| y += bodySize * 1.4; |
| ctx.fillText('No human-written code. No external assets. RTX 5090, parallel=1, Q8 256K context.', W / 2, y); |
| y += bodySize * 2.4; |
| |
| |
| ctx.fillStyle = '#aaa'; |
| ctx.font = `${Math.max(11, Math.min(15, H * 0.018))}px monospace`; |
| ctx.fillText('WASD to move โข Mouse to aim โข Click to shoot โข Shift to dash', W / 2, y); |
| y += bodySize * 1.6; |
| |
| |
| const pulse = 0.6 + 0.4 * Math.sin(gameTime * 4); |
| ctx.fillStyle = `rgba(0,255,255,${0.6 + 0.4 * pulse})`; |
| ctx.shadowColor = '#0ff'; |
| ctx.shadowBlur = 10; |
| ctx.font = `bold ${Math.max(13, Math.min(18, H * 0.022))}px monospace`; |
| ctx.fillText('Click or press any key to start', W / 2, y); |
| ctx.shadowBlur = 0; |
| |
| |
| ctx.fillStyle = '#555'; |
| ctx.font = `${labelSize}px monospace`; |
| ctx.fillText('benchmark by Kyle Hessling', W / 2, H - Math.max(20, H * 0.025)); |
| } |
| |
| |
| if (gameState === 'gameover') { |
| ctx.fillStyle = 'rgba(5,5,16,0.8)'; |
| ctx.fillRect(0, 0, W, H); |
| |
| const goTitleSize = Math.max(28, Math.min(48, H * 0.055)); |
| ctx.fillStyle = '#f44'; |
| ctx.shadowColor = '#f44'; |
| ctx.shadowBlur = 20; |
| ctx.font = `bold ${goTitleSize}px monospace`; |
| ctx.textAlign = 'center'; |
| ctx.fillText('GAME OVER', W / 2, H / 2 - 80); |
| |
| ctx.shadowBlur = 0; |
| ctx.fillStyle = '#0ff'; |
| ctx.font = `bold ${Math.max(16, Math.min(28, H * 0.035))}px monospace`; |
| ctx.fillText(`SCORE: ${score}`, W / 2, H / 2 - 20); |
| |
| ctx.fillStyle = '#aaa'; |
| ctx.font = `${Math.max(12, Math.min(16, H * 0.02))}px monospace`; |
| ctx.fillText(`WAVE: ${wave} KILLS: ${totalKills} TIME: ${Math.floor(gameTime)}s`, W / 2, H / 2 + 20); |
| |
| ctx.fillStyle = '#888'; |
| ctx.font = `${Math.max(11, Math.min(14, H * 0.017))}px monospace`; |
| ctx.fillText('Press R to restart', W / 2, H / 2 + 60); |
| |
| ctx.fillStyle = '#555'; |
| ctx.font = `${Math.max(9, Math.min(11, H * 0.013))}px monospace`; |
| ctx.fillText('built on Qwopus 3.6 27B Q5_K_M โข benchmark by Kyle Hessling', W / 2, H - Math.max(20, H * 0.025)); |
| } |
| |
| |
| if (chromaFade > 0) { |
| const alpha = chromaFade * 0.5; |
| const offset = chromaFade * 6; |
| |
| ctx.globalAlpha = alpha; |
| ctx.globalCompositeOperation = 'screen'; |
| ctx.drawImage(chromaCanvas, -offset, 0); |
| ctx.drawImage(chromaCanvas, offset, 0); |
| ctx.globalCompositeOperation = 'source-over'; |
| ctx.globalAlpha = 1; |
| } |
| } |
| |
| |
| function renderTutorial() { |
| |
| ctx.fillStyle = 'rgba(5,5,16,0.88)'; |
| ctx.fillRect(0, 0, W, H); |
| |
| const margin = Math.min(W, H) * 0.03; |
| const titleSize = Math.max(24, Math.min(42, H * 0.045)); |
| const headingSize = Math.max(16, Math.min(22, H * 0.024)); |
| const bodySize = Math.max(12, Math.min(16, H * 0.016)); |
| const descSize = Math.max(11, Math.min(14, H * 0.013)); |
| const footerSize = Math.max(14, Math.min(18, H * 0.02)); |
| |
| ctx.textAlign = 'center'; |
| |
| let y = margin + titleSize + 10; |
| |
| |
| ctx.fillStyle = '#0ff'; |
| ctx.shadowColor = '#0ff'; |
| ctx.shadowBlur = 15; |
| ctx.font = `bold ${titleSize}px monospace`; |
| ctx.fillText('HOW TO PLAY', W / 2, y); |
| ctx.shadowBlur = 0; |
| y += titleSize + 20; |
| |
| |
| ctx.fillStyle = '#0ff'; |
| ctx.shadowColor = '#0ff'; |
| ctx.shadowBlur = 8; |
| ctx.font = `bold ${headingSize}px monospace`; |
| ctx.fillText('CONTROLS', W / 2, y); |
| ctx.shadowBlur = 0; |
| y += headingSize + 10; |
| |
| const controls = [ |
| { key: 'WASD / Arrows', desc: 'Move' }, |
| { key: 'Mouse', desc: 'Aim' }, |
| { key: 'Click', desc: 'Shoot' }, |
| { key: 'Shift', desc: 'Dash (brief invulnerability)' }, |
| { key: 'R', desc: 'Restart after game over' } |
| ]; |
| |
| ctx.font = `${bodySize}px monospace`; |
| controls.forEach(c => { |
| const keyW = ctx.measureText(c.key).width; |
| const descW = ctx.measureText(c.desc).width; |
| const gap = 20; |
| const totalW = keyW + gap + descW; |
| |
| const keyX = (W / 2) - (totalW / 2) + (keyW / 2); |
| const descX = (W / 2) + (totalW / 2) - (descW / 2); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.shadowColor = '#fff'; |
| ctx.shadowBlur = 4; |
| ctx.fillText(c.key, keyX, y); |
| ctx.shadowBlur = 0; |
| |
| ctx.fillStyle = '#aaa'; |
| ctx.fillText(c.desc, descX, y); |
| y += bodySize + 4; |
| }); |
| |
| y += 20; |
| |
| |
| ctx.fillStyle = '#ff4466'; |
| ctx.shadowColor = '#ff4466'; |
| ctx.shadowBlur = 8; |
| ctx.font = `bold ${headingSize}px monospace`; |
| ctx.fillText('ENEMIES', W / 2, y); |
| ctx.shadowBlur = 0; |
| y += headingSize + 15; |
| |
| const enemyTypes = ['grunt', 'scout', 'runner', 'tank', 'shooter']; |
| const enemyDescriptions = [ |
| 'Slow chaser, occasional weak shots', |
| 'Fast harasser, fires green darts', |
| 'KAMIKAZE โ explodes on contact', |
| 'Slow brute, 360ยฐ bullet burst', |
| 'Keeps distance, fires steadily' |
| ]; |
| const enemyLabels = [ |
| 'GRUNT', 'SCOUT', 'RUNNER', 'TANK', 'SHOOTER' |
| ]; |
| |
| const enemyRadius = Math.max(14, Math.min(22, H * 0.02)); |
| const iconWidth = enemyRadius * 2.4; |
| const gap1 = 16; |
| const gap2 = 18; |
| |
| ctx.textBaseline = 'middle'; |
| enemyTypes.forEach((type, i) => { |
| ctx.font = `bold ${descSize}px monospace`; |
| const labelW = ctx.measureText(enemyLabels[i]).width; |
| ctx.font = `${descSize - 1}px monospace`; |
| const descW = ctx.measureText(enemyDescriptions[i]).width; |
| |
| const totalW = iconWidth + gap1 + labelW + gap2 + descW; |
| const startX = (W / 2) - (totalW / 2); |
| |
| |
| drawTutorialEnemy(type, startX + iconWidth / 2, y, 0, enemyRadius); |
| |
| |
| ctx.textAlign = 'left'; |
| ctx.fillStyle = ENEMY_TYPES[type].color; |
| ctx.shadowColor = ENEMY_TYPES[type].color; |
| ctx.shadowBlur = 4; |
| ctx.font = `bold ${descSize}px monospace`; |
| ctx.fillText(enemyLabels[i], startX + iconWidth + gap1, y); |
| ctx.shadowBlur = 0; |
| |
| |
| ctx.fillStyle = '#888'; |
| ctx.font = `${descSize - 1}px monospace`; |
| ctx.fillText(enemyDescriptions[i], startX + iconWidth + gap1 + labelW + gap2, y); |
| |
| y += enemyRadius * 2 + 12; |
| }); |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'alphabetic'; |
| |
| y += 10; |
| |
| |
| ctx.fillStyle = '#ffff00'; |
| ctx.shadowColor = '#ffff00'; |
| ctx.shadowBlur = 8; |
| ctx.font = `bold ${headingSize}px monospace`; |
| ctx.fillText('POWER-UPS', W / 2, y); |
| ctx.shadowBlur = 0; |
| y += headingSize + 15; |
| |
| const puTypes = ['rapid', 'triple', 'shield']; |
| const puDescriptions = [ |
| 'Halves shoot cooldown โ 12s', |
| 'Three bullets in a spread โ 12s', |
| 'Blocks one hit, stays until used' |
| ]; |
| const puLabels = [ |
| 'RAPID FIRE', 'TRIPLE SHOT', 'SHIELD' |
| ]; |
| |
| const puIconWidth = 32; |
| const puGap1 = 16; |
| const puGap2 = 18; |
| |
| ctx.textBaseline = 'middle'; |
| puTypes.forEach((puType, i) => { |
| ctx.font = `bold ${descSize}px monospace`; |
| const labelW = ctx.measureText(puLabels[i]).width; |
| ctx.font = `${descSize - 1}px monospace`; |
| const descW = ctx.measureText(puDescriptions[i]).width; |
| |
| const totalW = puIconWidth + puGap1 + labelW + puGap2 + descW; |
| const startX = (W / 2) - (totalW / 2); |
| |
| drawTutorialPowerup(puType, startX + puIconWidth / 2, y); |
| |
| ctx.textAlign = 'left'; |
| ctx.fillStyle = POWERUP_TYPES[puType].color; |
| ctx.shadowColor = POWERUP_TYPES[puType].color; |
| ctx.shadowBlur = 4; |
| ctx.font = `bold ${descSize}px monospace`; |
| ctx.fillText(puLabels[i], startX + puIconWidth + puGap1, y); |
| ctx.shadowBlur = 0; |
| |
| ctx.fillStyle = '#888'; |
| ctx.font = `${descSize - 1}px monospace`; |
| ctx.fillText(puDescriptions[i], startX + puIconWidth + puGap1 + labelW + puGap2, y); |
| |
| y += 30; |
| }); |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'alphabetic'; |
| |
| y += 10; |
| |
| |
| ctx.fillStyle = '#ff0'; |
| ctx.shadowColor = '#ff0'; |
| ctx.shadowBlur = 4; |
| ctx.font = `${descSize}px monospace`; |
| ctx.fillText('BOSS (Sentinel/Carrier/Warden): Every 5 waves, unique attack patterns', W / 2, y); |
| ctx.shadowBlur = 0; |
| |
| |
| const footerY = H - margin - footerSize - 5; |
| const pulse = 0.5 + 0.5 * Math.sin(gameTime * 4); |
| ctx.fillStyle = `rgba(0,255,255,${0.5 + 0.5 * pulse})`; |
| ctx.shadowColor = '#0ff'; |
| ctx.shadowBlur = 10; |
| ctx.font = `bold ${footerSize}px monospace`; |
| ctx.fillText('Press any key or click to continue', W / 2, footerY); |
| ctx.shadowBlur = 0; |
| } |
| |
| |
| let lastTime = 0; |
| let accumulator = 0; |
| const FIXED_DT = 1 / 60; |
| |
| function gameLoop(timestamp) { |
| requestAnimationFrame(gameLoop); |
| |
| if (!lastTime) lastTime = timestamp; |
| let dt = (timestamp - lastTime) / 1000; |
| lastTime = timestamp; |
| |
| if (dt > 0.1) dt = 0.1; |
| |
| if (document.hidden) { |
| lastTime = timestamp; |
| return; |
| } |
| |
| accumulator += dt; |
| |
| let steps = 0; |
| while (accumulator >= FIXED_DT && steps < 4) { |
| update(FIXED_DT); |
| accumulator -= FIXED_DT; |
| steps++; |
| } |
| |
| render(); |
| } |
| |
| requestAnimationFrame(gameLoop); |
| </script> |
| </body> |
| </html> |