pew-pew / game.js
jedisct1's picture
Upload updated game page
7920a65 verified
Raw
History Blame Contribute Delete
23.7 kB
// ─── Star Defender ───────────────────────────────────────────────
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
// ─── DOM refs ────────────────────────────────────────────────────
const startScreen = document.getElementById('start-screen');
const gameOverScreen = document.getElementById('game-over-screen');
const pauseScreen = document.getElementById('pause-screen');
const finalScoreEl = document.getElementById('final-score');
const finalLevelEl = document.getElementById('final-level');
const startBtn = document.getElementById('start-btn');
const restartBtn = document.getElementById('restart-btn');
// ─── Game state ──────────────────────────────────────────────────
let gameState = 'menu'; // menu | playing | paused | gameover
let score = 0;
let lives = 7;
let level = 1;
let highScore = 0;
// ─── Sound effects (Web Audio API) ───────────────────────────────
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') audioCtx.resume();
}
const sfx = {
// High-pitched "pew" shot
shoot(freq = 880) {
if (!audioCtx) return;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = 'square';
o.frequency.setValueAtTime(freq, audioCtx.currentTime);
o.frequency.exponentialRampToValueAtTime(freq * 0.35, audioCtx.currentTime + 0.1);
g.gain.setValueAtTime(0.08, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.12);
o.connect(g).connect(audioCtx.destination);
o.start(); o.stop(audioCtx.currentTime + 0.12);
},
// Alien shot β€” lower pitch
alienShot() {
if (!audioCtx) return;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = 'sawtooth';
o.frequency.setValueAtTime(220, audioCtx.currentTime);
o.frequency.exponentialRampToValueAtTime(100, audioCtx.currentTime + 0.15);
g.gain.setValueAtTime(0.04, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
o.connect(g).connect(audioCtx.destination);
o.start(); o.stop(audioCtx.currentTime + 0.15);
},
// Explosion β€” noise burst
explode() {
if (!audioCtx) return;
const len = audioCtx.sampleRate * 0.2;
const buf = audioCtx.createBuffer(1, len, audioCtx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = audioCtx.createBufferSource();
const g = audioCtx.createGain();
const f = audioCtx.createBiquadFilter();
f.type = 'lowpass'; f.frequency.value = 800;
src.buffer = buf;
g.gain.setValueAtTime(0.18, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2);
src.connect(f).connect(g).connect(audioCtx.destination);
src.start();
},
// Power-up pickup β€” ascending chirp
powerup() {
if (!audioCtx) return;
[523, 659, 784].forEach((f, i) => {
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = 'sine';
o.frequency.value = f;
g.gain.setValueAtTime(0, audioCtx.currentTime + i * 0.07);
g.gain.linearRampToValueAtTime(0.1, audioCtx.currentTime + i * 0.07 + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + i * 0.07 + 0.14);
o.connect(g).connect(audioCtx.destination);
o.start(audioCtx.currentTime + i * 0.07);
o.stop(audioCtx.currentTime + i * 0.07 + 0.14);
});
},
// Player hit β€” low rumble
hit() {
if (!audioCtx) return;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(120, audioCtx.currentTime);
o.frequency.exponentialRampToValueAtTime(40, audioCtx.currentTime + 0.3);
g.gain.setValueAtTime(0.15, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
o.connect(g).connect(audioCtx.destination);
o.start(); o.stop(audioCtx.currentTime + 0.3);
},
// Level up β€” ascending arpeggio
levelUp() {
if (!audioCtx) return;
[440, 554, 659, 880].forEach((f, i) => {
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = 'square';
o.frequency.value = f;
g.gain.setValueAtTime(0, audioCtx.currentTime + i * 0.1);
g.gain.linearRampToValueAtTime(0.07, audioCtx.currentTime + i * 0.1 + 0.03);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + i * 0.1 + 0.2);
o.connect(g).connect(audioCtx.destination);
o.start(audioCtx.currentTime + i * 0.1);
o.stop(audioCtx.currentTime + i * 0.1 + 0.2);
});
}
};
// ─── Star background ────────────────────────────────────────────
const stars = [];
for (let i = 0; i < 120; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H,
r: Math.random() * 1.5 + 0.3,
speed: Math.random() * 0.4 + 0.1,
twinkle: Math.random() * Math.PI * 2
});
}
// ─── Player ──────────────────────────────────────────────────────
const player = {
x: W / 2,
y: H - 50,
w: 44,
h: 32,
speed: 5,
cooldown: 0,
invincible: 0,
thrust: 0 // visual oscillation
};
// ─── Input ───────────────────────────────────────────────────────
const keys = {};
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') e.preventDefault();
if (e.code === 'KeyP' && gameState === 'playing') {
gameState = 'paused';
pauseScreen.style.display = 'flex';
} else if (e.code === 'KeyP' && gameState === 'paused') {
gameState = 'playing';
pauseScreen.style.display = 'none';
}
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
startBtn.addEventListener('click', startGame);
restartBtn.addEventListener('click', startGame);
// ─── Aliens ──────────────────────────────────────────────────────
let aliens = [];
let alienDir = 1;
let alienStepTimer = 0;
let alienStepInterval = 40; // frames between row shifts
// Alien types with pixel-art style colors
const ALIEN_TYPES = [
{ color: '#ff6b6b', points: 30 }, // row 0 β€” red, high value
{ color: '#ffd93d', points: 20 }, // row 1 β€” yellow
{ color: '#6bff9e', points: 10 }, // row 2 β€” green
];
function spawnAliens() {
aliens = [];
const rows = Math.min(3 + Math.floor(level / 3), 5);
const cols = Math.min(8 + Math.floor(level / 2), 14);
const spacingX = 44;
const spacingY = 40;
const offsetX = (W - cols * spacingX) / 2;
const offsetY = 60;
for (let r = 0; r < rows; r++) {
const type = r % ALIEN_TYPES.length;
for (let c = 0; c < cols; c++) {
aliens.push({
x: offsetX + c * spacingX,
y: offsetY + r * spacingY,
w: 34,
h: 28,
alive: true,
type,
frame: 0 // animation frame
});
}
}
alienDir = 1;
alienStepTimer = 0;
alienStepInterval = Math.max(15, 50 - level * 3);
}
// ─── Bullets ─────────────────────────────────────────────────────
let playerBullets = [];
let alienBullets = [];
// ─── Particles ───────────────────────────────────────────────────
let particles = [];
// ─── Score pop-ups ───────────────────────────────────────────────
let scorePopups = [];
function addScorePopup(x, y, text, color = '#fff') {
scorePopups.push({ x, y, text, color, life: 1, decay: 0.02 });
}
// ─── Screen flash ────────────────────────────────────────────────
let screenFlash = 0;
function flashScreen(color = 'rgba(255,255,255,0.15)', intensity = 1) {
screenFlash = { color, intensity, life: 1, decay: 0.06 };
}
function explode(x, y, color, count = 18) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 4 + 1;
particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1,
decay: Math.random() * 0.025 + 0.012,
r: Math.random() * 3.5 + 1.5,
color
});
}
}
// ─── Power-ups ───────────────────────────────────────────────────
let powerups = [];
function spawnPowerup(x, y) {
const types = ['rapid', 'shield', 'triple'];
const type = types[Math.floor(Math.random() * types.length)];
powerups.push({ x, y, type, vy: 1.5, size: 14, w: 28, h: 28 });
}
let rapidTimer = 0;
let shieldActive = false;
let shieldTimer = 0;
let tripleTimer = 0;
// ─── UI drawing ──────────────────────────────────────────────────
function drawHUD() {
// Score
ctx.fillStyle = '#fff';
ctx.font = 'bold 18px "Segoe UI", sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`SCORE ${score}`, 16, 30);
// Level
ctx.textAlign = 'center';
ctx.fillText(`LEVEL ${level}`, W / 2, 30);
// Lives (draw ships)
ctx.textAlign = 'right';
for (let i = 0; i < lives; i++) {
const lx = W - 20 - i * 32;
drawMiniShip(lx, 24);
}
}
function drawMiniShip(x, y) {
ctx.fillStyle = '#5b9aff';
ctx.beginPath();
ctx.moveTo(x, y - 10);
ctx.lineTo(x - 10, y + 6);
ctx.lineTo(x + 10, y + 6);
ctx.closePath();
ctx.fill();
}
// ─── Drawing helpers ─────────────────────────────────────────────
function drawPlayer() {
if (player.invincible > 0 && Math.floor(player.invincible / 4) % 2 === 0) return;
const px = player.x;
const py = player.y;
// Thrust flame
player.thrust += 0.15;
const flameH = 8 + Math.sin(player.thrust) * 4;
ctx.fillStyle = '#ff9933';
ctx.beginPath();
ctx.moveTo(px - 6, py + 16);
ctx.lineTo(px, py + 16 + flameH);
ctx.lineTo(px + 6, py + 16);
ctx.closePath();
ctx.fill();
// Body
ctx.fillStyle = shieldActive ? '#33ffcc' : '#5b9aff';
ctx.beginPath();
ctx.moveTo(px, py - 16);
ctx.lineTo(px - 22, py + 16);
ctx.lineTo(px - 6, py + 12);
ctx.lineTo(px + 6, py + 12);
ctx.lineTo(px + 22, py + 16);
ctx.closePath();
ctx.fill();
// Cockpit
ctx.fillStyle = '#aaddff';
ctx.beginPath();
ctx.arc(px, py - 2, 4, 0, Math.PI * 2);
ctx.fill();
// Wing highlights
ctx.fillStyle = '#88bbee';
ctx.beginPath();
ctx.moveTo(px - 20, py + 10);
ctx.lineTo(px - 10, py);
ctx.lineTo(px - 14, py + 8);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(px + 20, py + 10);
ctx.lineTo(px + 10, py);
ctx.lineTo(px + 14, py + 8);
ctx.closePath();
ctx.fill();
}
function drawAlien(a) {
const t = ALIEN_TYPES[a.type];
const cx = a.x;
const cy = a.y;
// Gentle bob
const bob = Math.sin(Date.now() / 300 + a.x) * 1.5;
ctx.fillStyle = t.color;
// Body shape varies by type
if (a.type === 0) {
// Red β€” bulky with antennae
ctx.beginPath();
ctx.arc(cx, cy + bob, 12, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(cx - 6, cy - 3 + bob, 4, 4);
ctx.fillRect(cx + 2, cy - 3 + bob, 4, 4);
// Antennae
ctx.fillStyle = t.color;
ctx.fillRect(cx - 7, cy - 14 + bob, 2, 6);
ctx.fillRect(cx + 5, cy - 14 + bob, 2, 6);
} else if (a.type === 1) {
// Yellow β€” crab-like
ctx.beginPath();
ctx.ellipse(cx, cy + bob, 14, 8, 0, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(cx - 5, cy - 3 + bob, 4, 4);
ctx.fillRect(cx + 1, cy - 3 + bob, 4, 4);
// Claws
ctx.fillStyle = t.color;
ctx.fillRect(cx - 18, cy - 2 + bob, 5, 4);
ctx.fillRect(cx + 13, cy - 2 + bob, 5, 4);
} else {
// Green β€” saucer-like
ctx.beginPath();
ctx.ellipse(cx, cy + bob, 14, 6, 0, 0, Math.PI * 2);
ctx.fill();
// Dome
ctx.fillStyle = '#aaffcc';
ctx.beginPath();
ctx.arc(cx, cy - 3 + bob, 7, Math.PI, 0);
ctx.fill();
// Eyes
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(cx - 5, cy - 2 + bob, 3, 3);
ctx.fillRect(cx + 2, cy - 2 + bob, 3, 3);
}
}
function drawPowerup(p) {
const symbols = { rapid: 'R', shield: 'S', triple: 'T' };
const colors = { rapid: '#ff6644', shield: '#33ffcc', triple: '#ffcc33' };
const c = colors[p.type];
ctx.fillStyle = c;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#0a0a2e';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(symbols[p.type], p.x, p.y);
}
// ─── Start / restart ─────────────────────────────────────────────
function startGame() {
initAudio();
score = 0;
lives = 7;
level = 1;
player.x = W / 2;
player.cooldown = 0;
player.invincible = 0;
rapidTimer = 0;
shieldTimer = 0;
tripleTimer = 0;
shieldActive = false;
playerBullets = [];
alienBullets = [];
particles = [];
powerups = [];
startScreen.style.display = 'none';
gameOverScreen.style.display = 'none';
pauseScreen.style.display = 'none';
spawnAliens();
gameState = 'playing';
}
function gameOver() {
gameState = 'gameover';
if (score > highScore) highScore = score;
finalScoreEl.textContent = `Score: ${score}`;
finalLevelEl.textContent = `Level: ${level}`;
gameOverScreen.style.display = 'flex';
}
// ─── Collision ───────────────────────────────────────────────────
function rectCollide(a, b) {
return (
a.x - a.w / 2 < b.x + b.w / 2 &&
a.x + a.w / 2 > b.x - b.w / 2 &&
a.y - a.h / 2 < b.y + b.h / 2 &&
a.y + a.h / 2 > b.y - b.h / 2
);
}
// ─── Update ──────────────────────────────────────────────────────
function update() {
if (gameState !== 'playing') return;
// Player movement
if (keys['ArrowLeft']) player.x -= player.speed;
if (keys['ArrowRight']) player.x += player.speed;
if (keys['ArrowUp']) player.y -= player.speed;
if (keys['ArrowDown']) player.y += player.speed;
// Horizontal wrap-around
if (player.x < -player.w / 2) player.x = W + player.w / 2;
if (player.x > W + player.w / 2) player.x = -player.w / 2;
// Clamp vertical
player.y = Math.max(player.h / 2 + 10, Math.min(H - player.h / 2, player.y));
// Shooting
const shootRate = rapidTimer > 0 ? 6 : 14;
if (player.cooldown > 0) player.cooldown--;
if (keys['Space'] && player.cooldown === 0) {
player.cooldown = shootRate;
sfx.shoot();
if (tripleTimer > 0) {
playerBullets.push({ x: player.x, y: player.y - 18, vx: 0, vy: -8, w: 3, h: 12 });
playerBullets.push({ x: player.x, y: player.y - 14, vx: -2.5, vy: -8, w: 3, h: 12 });
playerBullets.push({ x: player.x, y: player.y - 14, vx: 2.5, vy: -8, w: 3, h: 12 });
} else {
playerBullets.push({ x: player.x, y: player.y - 18, vx: 0, vy: -8, w: 3, h: 12 });
}
}
// Invincibility timer
if (player.invincible > 0) player.invincible--;
// Power-up timers
if (rapidTimer > 0) rapidTimer--;
if (shieldTimer > 0) { shieldTimer--; if (shieldTimer === 0) shieldActive = false; }
if (tripleTimer > 0) tripleTimer--;
// Player bullets
playerBullets.forEach(b => {
b.x += b.vx;
b.y += b.vy;
});
playerBullets = playerBullets.filter(b => b.y > -10 && b.x > -10 && b.x < W + 10);
// Alien movement (step-down)
alienStepTimer++;
const aliveAliens = aliens.filter(a => a.alive);
// Speed up slightly as aliens die
const aliveRatio = aliveAliens.length / Math.max(1, aliens.length);
const effectiveInterval = Math.max(10, Math.floor(alienStepInterval * aliveRatio));
let hitEdge = false;
aliveAliens.forEach(a => { a.x += alienDir * 0.3; });
aliveAliens.forEach(a => {
if (a.x < 20 || a.x > W - 20) hitEdge = true;
});
if (hitEdge || alienStepTimer >= effectiveInterval) {
alienDir *= -1;
if (alienStepTimer >= effectiveInterval) alienStepTimer = 0;
aliveAliens.forEach(a => {
a.x += alienDir * 0.3; // flip and move
a.y += 12;
a.frame = (a.frame + 1) % 2;
});
}
// Alien shooting
aliveAliens.forEach(a => {
if (Math.random() < 0.0005 + level * 0.00015) {
sfx.alienShot();
alienBullets.push({
x: a.x, y: a.y + 12,
vx: 0,
vy: 2.5,
w: 6, h: 6
});
}
});
// Alien bullets
alienBullets.forEach(b => {
b.x += b.vx;
b.y += b.vy;
});
alienBullets = alienBullets.filter(b => b.y < H + 10);
// Power-ups
powerups.forEach(p => { p.y += p.vy; });
powerups = powerups.filter(p => p.y < H + 20);
// ─── Collisions ───
// Player bullets vs aliens
playerBullets.forEach(b => {
aliens.forEach(a => {
if (!a.alive) return;
if (rectCollide(b, a)) {
a.alive = false;
b.y = -999; // kill bullet
score += ALIEN_TYPES[a.type].points;
explode(a.x, a.y, ALIEN_TYPES[a.type].color, 22);
addScorePopup(a.x, a.y - 12, `+${ALIEN_TYPES[a.type].points}`, ALIEN_TYPES[a.type].color);
sfx.explode();
// Chance to drop power-up
if (Math.random() < 0.15) spawnPowerup(a.x, a.y);
}
});
});
// Alien bullets vs player
if (player.invincible <= 0) {
alienBullets.forEach(b => {
if (rectCollide(b, player)) {
b.y = 9999; // kill bullet
if (shieldActive) {
shieldActive = false;
shieldTimer = 0;
explode(b.x, b.y, '#33ffcc', 10);
flashScreen('rgba(51,255,204,0.1)');
} else {
lives--;
player.invincible = 150;
explode(player.x, player.y, '#ff4444', 30);
flashScreen('rgba(255,68,68,0.12)');
sfx.hit();
if (lives <= 0) gameOver();
}
}
});
}
// Aliens reaching player
aliveAliens.forEach(a => {
if (a.y > H - 40) gameOver();
});
// Power-ups vs player
powerups.forEach(p => {
if (rectCollide(p, player)) {
p.y = 9999;
if (p.type === 'rapid') rapidTimer = 300;
if (p.type === 'shield') { shieldActive = true; shieldTimer = 360; }
if (p.type === 'triple') tripleTimer = 240;
explode(p.x, p.y, '#ffffff', 8);
flashScreen('rgba(255,255,255,0.08)');
sfx.powerup();
}
});
// ─── Particles ───
particles.forEach(p => {
p.x += p.vx;
p.y += p.vy;
p.life -= p.decay;
p.vy += 0.04; // gravity
});
particles = particles.filter(p => p.life > 0);
// Next level
if (aliens.every(a => !a.alive)) {
level++;
spawnAliens();
flashScreen('rgba(100,180,255,0.12)');
sfx.levelUp();
// Bonus life every 2 levels
if (level % 2 === 0) lives = Math.min(lives + 1, 7);
}
}
// ─── Render ──────────────────────────────────────────────────────
function render() {
// Background
ctx.fillStyle = '#0a0a2e';
ctx.fillRect(0, 0, W, H);
// Stars
stars.forEach(s => {
s.y += s.speed;
s.twinkle += 0.03;
if (s.y > H) { s.y = 0; s.x = Math.random() * W; }
const alpha = 0.5 + Math.sin(s.twinkle) * 0.3;
ctx.fillStyle = `rgba(200,210,255,${alpha})`;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
});
if (gameState === 'menu') return;
// Player
drawPlayer();
// Aliens
aliens.filter(a => a.alive).forEach(drawAlien);
// Player bullets
ctx.fillStyle = '#66ccff';
playerBullets.forEach(b => {
// Glow trail
const grad = ctx.createLinearGradient(b.x, b.y, b.x, b.y + 16);
grad.addColorStop(0, 'rgba(100,200,255,0.8)');
grad.addColorStop(1, 'rgba(100,200,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(b.x - 2, b.y + 2, 4, 16);
// Bullet core
ctx.fillStyle = '#aaddff';
ctx.fillRect(b.x - 1.5, b.y - 6, 3, 12);
});
// Alien bullets
alienBullets.forEach(b => {
// Glow
ctx.fillStyle = 'rgba(255,85,85,0.25)';
ctx.beginPath();
ctx.arc(b.x, b.y, 8, 0, Math.PI * 2);
ctx.fill();
// Core
ctx.fillStyle = '#ff5555';
ctx.beginPath();
ctx.arc(b.x, b.y, 3, 0, Math.PI * 2);
ctx.fill();
// Bright center
ctx.fillStyle = '#ffaaaa';
ctx.beginPath();
ctx.arc(b.x, b.y, 1.5, 0, Math.PI * 2);
ctx.fill();
});
// Power-ups
powerups.forEach(drawPowerup);
// Particles
particles.forEach(p => {
ctx.globalAlpha = p.life;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
// Power-up status indicators
let statusX = 16;
const statusY = H - 24;
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
if (rapidTimer > 0) {
ctx.fillStyle = '#ff6644';
ctx.fillText(`RAPID ${Math.ceil(rapidTimer / 60)}`, statusX, statusY);
statusX += 80;
}
if (shieldActive) {
ctx.fillStyle = '#33ffcc';
ctx.fillText(`SHIELD ${Math.ceil(shieldTimer / 60)}`, statusX, statusY);
statusX += 90;
}
if (tripleTimer > 0) {
ctx.fillStyle = '#ffcc33';
ctx.fillText(`TRIPLE ${Math.ceil(tripleTimer / 60)}`, statusX, statusY);
}
// HUD
drawHUD();
// Screen flash overlay
if (screenFlash && screenFlash.life > 0) {
ctx.fillStyle = screenFlash.color;
ctx.globalAlpha = screenFlash.intensity * screenFlash.life;
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
screenFlash.life -= screenFlash.decay;
}
// Score popups
scorePopups.forEach(p => {
ctx.globalAlpha = p.life;
ctx.fillStyle = p.color;
ctx.font = 'bold 14px "Orbitron", monospace';
ctx.textAlign = 'center';
ctx.fillText(p.text, p.x, p.y);
p.y -= 1;
p.life -= p.decay;
});
scorePopups = scorePopups.filter(p => p.life > 0);
}
// ─── Game loop ───────────────────────────────────────────────────
function loop() {
update();
render();
requestAnimationFrame(loop);
}
loop();