qwopus-commander / index.html
KyleHessling1's picture
Add BEST TEMP row to menu stats panel
417b59a verified
<!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";
// โ”€โ”€โ”€ CONSTANTS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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;
// Enemy definitions
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 }
};
// Boss definitions
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 }
};
// Power-up definitions
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 }
};
// โ”€โ”€โ”€ CANVAS SETUP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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();
// โ”€โ”€โ”€ AUDIO โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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);
}
}
// โ”€โ”€โ”€ OBJECT POOLS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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; }
}
// โ”€โ”€โ”€ PARTICLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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');
}
// โ”€โ”€โ”€ BULLET โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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;
}
// โ”€โ”€โ”€ ENEMY โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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);
}
// โ”€โ”€โ”€ BOSS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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;
}
// โ”€โ”€โ”€ SPAWN TELEGRAPH โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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;
}
// โ”€โ”€โ”€ POWER-UP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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;
}
// โ”€โ”€โ”€ DAMAGE NUMBER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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;
}
// โ”€โ”€โ”€ FLOATING SCORE (Combo) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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;
}
// โ”€โ”€โ”€ INPUT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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());
// โ”€โ”€โ”€ GAME STATE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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;
// Chromatic aberration
let chromaTimer = 0;
let chromaFade = 0;
// Combo system
let comboCount = 0;
let comboTimer = 0;
let comboMultiplier = 1;
let lastComboLevel = 1;
// Power-ups
let powerupRapidTimer = 0;
let powerupTripleTimer = 0;
let powerupShield = false;
// Death slow-mo
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 };
// โ”€โ”€โ”€ STAR FIELD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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)
});
}
// โ”€โ”€โ”€ NEBULA โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 }
];
// โ”€โ”€โ”€ GAME START / RESTART โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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');
}
// โ”€โ”€โ”€ SPAWNING โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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++;
}
// โ”€โ”€โ”€ BOSS BEHAVIOR โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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; }
// โ”€โ”€โ”€ BOSS DEATH ANIMATIONS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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);
}
}
// โ”€โ”€โ”€ DRAW ENEMY SHIP SHAPES (world-space, used in game) โ”€โ”€โ”€โ”€โ”€
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();
// Tank HP bar
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);
}
}
// โ”€โ”€โ”€ DRAW BOSS SHIP SHAPES (world-space, used in game) โ”€โ”€โ”€โ”€โ”€โ”€
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;
}
}
}
// โ”€โ”€โ”€ TUTORIAL ENEMY DRAWER (screen-space, for tutorial) โ”€โ”€โ”€โ”€โ”€โ”€
// Draws enemy ship shapes at screen coordinates without camera transform
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();
}
// โ”€โ”€โ”€ TUTORIAL BOSS DRAWER (screen-space) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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();
}
// โ”€โ”€โ”€ TUTORIAL POWER-UP DRAWER (screen-space) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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();
}
// โ”€โ”€โ”€ UPDATE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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');
}
}
// โ”€โ”€ CRITICAL FIX: always decrement dashInvuln, even after dash motion ends โ”€โ”€
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;
// Runner armed detection (within 200px)
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');
}
}
}
// Tank burst attack
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);
// Collision: player bullets vs enemies
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);
}
}
}
});
// Player bullets vs bosses
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);
}
}
});
});
// CRITICAL FIX: Prune dead bosses so wave can end
bossPool.prune(b => b.alive);
// Collision: enemy bullets vs player
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();
}
}
});
}
// Collision: enemies vs player
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) {
// Runner KAMIKAZE: explode on contact
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();
}
}
});
}
// Power-up update
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);
// Remove dead enemies
enemyPool.prune(e => e.alive);
// Telegraph update
telegraphPool.forEach(t => {
t.timer += sDt;
const progress = t.timer / t.maxTime;
t.radius = 10 + progress * 40;
});
telegraphPool.prune(t => t.timer < t.maxTime);
// Particles
updateParticles(sDt);
// Damage numbers
dmgNumPool.forEach(d => {
d.y += d.vy * sDt;
d.vy *= 0.95;
d.life -= sDt;
});
dmgNumPool.prune(d => d.life > 0);
// Floating score
floatScorePool.forEach(d => {
d.y += d.vy * sDt;
d.vy *= 0.95;
d.life -= sDt;
});
floatScorePool.prune(d => d.life > 0);
// Screen shake
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; });
}
// โ”€โ”€โ”€ RENDER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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();
}
// Telegraph rings
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;
});
// Particles
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;
// Power-ups
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();
});
// Bullets
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;
}
});
// Enemies โ€” draw ship shapes
enemyPool.forEach(e => {
drawEnemyShip(e);
});
// Bosses โ€” draw ship shapes
bossPool.forEach(b => {
if (!b.alive) return;
drawBossShip(b);
});
// Player
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();
// Shield ring
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;
}
// โ”€โ”€ Visual polish: dash-invuln radial ring โ”€โ”€
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;
}
// Dash cooldown indicator
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();
}
}
// Damage numbers
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(); // end world transform
// Damage flash
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);
}
// Death flash
if (deathFlash > 0) {
ctx.fillStyle = `rgba(255,255,255,${Math.max(0, deathFlash * 2)})`;
ctx.fillRect(0, 0, W, H);
}
// โ”€โ”€โ”€ RESPONSIVE HUD โ”€โ”€โ”€
const margin = Math.min(W, H) * 0.02;
const hudFontSize = Math.max(14, Math.min(22, H * 0.022));
// Boss HP bar (top-center)
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;
}
}
// Score & Wave
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;
// Combo display
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;
}
// Floating score numbers
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;
// HP bar (right-anchored, responsive)
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);
// โ”€โ”€โ”€ FIX: Power-up chips stack VERTICALLY below HP bar โ”€โ”€โ”€
// Each chip is right-anchored under the HP bar, all within safe area [margin, margin] to [W - margin, H - margin]
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;
// Sanity: chipX + chipW = W - margin, so right edge is exactly at safe area boundary
// chipY starts below HP bar, each subsequent chip is chipH + 4 below the previous
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];
// Background
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(chipX, chipY, chipW, chipH);
// Left colored indicator box (small)
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;
// Label (1-2 letters)
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);
// Duration bar (alpha overlay behind the chip content, as a progress fill)
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 {
// Shield: draw a small shield icon
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;
});
// Sanity check: last chipY + chipH should be within safe area
// chipY starts at hpBarY + hpBarH + 8, max 3 chips, each chipH+4 apart
// max chipY = hpBarY + hpBarH + 8 + 2*(chipH+4) + chipH = ~margin + 2*H*0.018 + 8 + 2*(H*0.022+4) + H*0.022
// On 1440x900: ~18 + 32 + 8 + 60 + 20 = 138, well within 900-18=882 safe area.
// Wave break indicator
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);
}
// Wave progress bar (bottom, raised from edge)
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;
}
// Crosshair
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();
// โ”€โ”€โ”€ TUTORIAL SCREEN โ”€โ”€โ”€
if (gameState === 'tutorial') {
renderTutorial();
}
// Menu screen
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;
// Title
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;
// Tagline
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;
// Stats panel
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;
// Subtitle
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;
// Controls
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;
// Start prompt (pulsing)
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;
// Footer credit
ctx.fillStyle = '#555';
ctx.font = `${labelSize}px monospace`;
ctx.fillText('benchmark by Kyle Hessling', W / 2, H - Math.max(20, H * 0.025));
}
// Game over screen
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));
}
// Chromatic aberration overlay
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;
}
}
// โ”€โ”€โ”€ TUTORIAL RENDER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function renderTutorial() {
// Dark overlay
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'; // Global centering for tutorial
let y = margin + titleSize + 10;
// Title
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;
// โ”€โ”€โ”€ Section 1: Controls โ”€โ”€โ”€
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;
// โ”€โ”€โ”€ Section 2: Enemies โ”€โ”€โ”€
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);
// Icon
drawTutorialEnemy(type, startX + iconWidth / 2, y, 0, enemyRadius);
// Label
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;
// Description
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;
// โ”€โ”€โ”€ Section 3: Power-ups โ”€โ”€โ”€
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;
// Boss note
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;
// Footer
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;
}
// โ”€โ”€โ”€ GAME LOOP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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>