๐ŸŽฎArcadeLab

Star Blaster

by MagicStar57
1708 lines56.9 KB
โ–ถ Play
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Star Blaster</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    html, body {
      width: 100%; height: 100%;
      background: #000;
      display: flex; justify-content: center; align-items: center;
      overflow: hidden;
      font-family: monospace;
    }
    canvas { display: block; image-rendering: pixelated; outline: none; cursor: default; }
  </style>
</head>
<body>
<canvas id="c" tabindex="0"></canvas>
<script>
// โ”€โ”€ SETUP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const canvas = document.getElementById('c');
const ctx    = canvas.getContext('2d');
const W = 480, H = 640;
canvas.width  = W;
canvas.height = H;

function resize() {
  const s = Math.min(window.innerWidth / W, window.innerHeight / H);
  canvas.style.width  = (W * s) + 'px';
  canvas.style.height = (H * s) + 'px';
}
window.addEventListener('resize', resize);
resize();

// โ”€โ”€ SOUND ENGINE (Web Audio API, no external files) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const AudioContext = window.AudioContext || window.webkitAudioContext;
let audioCtx = null;

function getAudio() {
  if (!audioCtx) audioCtx = new AudioContext();
  if (audioCtx.state === 'suspended') audioCtx.resume();
  return audioCtx;
}

// Generic envelope helper
function playTone({ frequency = 440, type = 'square', gainPeak = 0.3,
                    attack = 0.005, decay = 0.1, sustainLevel = 0,
                    release = 0.05, duration = 0.15,
                    freqEnd = null, detune = 0 } = {}) {
  try {
    const ac  = getAudio();
    const osc = ac.createOscillator();
    const env = ac.createGain();
    osc.connect(env);
    env.connect(ac.destination);

    osc.type    = type;
    osc.frequency.setValueAtTime(frequency, ac.currentTime);
    if (freqEnd !== null)
      osc.frequency.linearRampToValueAtTime(freqEnd, ac.currentTime + duration);
    osc.detune.setValueAtTime(detune, ac.currentTime);

    const t = ac.currentTime;
    env.gain.setValueAtTime(0, t);
    env.gain.linearRampToValueAtTime(gainPeak, t + attack);
    env.gain.linearRampToValueAtTime(sustainLevel, t + attack + decay);
    env.gain.linearRampToValueAtTime(0, t + attack + decay + release);

    osc.start(t);
    osc.stop(t + duration + 0.05);
  } catch(e) {}
}

function playNoise({ gainPeak = 0.15, duration = 0.12, filterFreq = 800 } = {}) {
  try {
    const ac      = getAudio();
    const bufSize = ac.sampleRate * duration;
    const buffer  = ac.createBuffer(1, bufSize, ac.sampleRate);
    const data    = buffer.getChannelData(0);
    for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;

    const src    = ac.createBufferSource();
    src.buffer   = buffer;
    const filter = ac.createBiquadFilter();
    filter.type  = 'bandpass';
    filter.frequency.setValueAtTime(filterFreq, ac.currentTime);
    filter.Q.setValueAtTime(0.8, ac.currentTime);

    const env = ac.createGain();
    src.connect(filter);
    filter.connect(env);
    env.connect(ac.destination);

    const t = ac.currentTime;
    env.gain.setValueAtTime(gainPeak, t);
    env.gain.exponentialRampToValueAtTime(0.001, t + duration);
    src.start(t);
    src.stop(t + duration);
  } catch(e) {}
}

// โ”€โ”€ NAMED SOUND EFFECTS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function sfxShoot() {
  playTone({ frequency: 880, freqEnd: 440, type: 'square',
             gainPeak: 0.18, attack: 0.002, decay: 0.06,
             sustainLevel: 0, release: 0.02, duration: 0.09 });
}

function sfxEnemyHit() {
  // Punchy boom: low thud body + mid crack + noise tail
  try {
    const ac = getAudio();
    const t  = ac.currentTime;

    // Sub-bass thud โ€” pitch drops fast like a kick drum
    const osc1 = ac.createOscillator();
    const env1 = ac.createGain();
    osc1.connect(env1); env1.connect(ac.destination);
    osc1.type = 'sine';
    osc1.frequency.setValueAtTime(180, t);
    osc1.frequency.exponentialRampToValueAtTime(40, t + 0.18);
    env1.gain.setValueAtTime(0.55, t);
    env1.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
    osc1.start(t); osc1.stop(t + 0.25);

    // Mid crack layer
    const osc2 = ac.createOscillator();
    const env2 = ac.createGain();
    osc2.connect(env2); env2.connect(ac.destination);
    osc2.type = 'sawtooth';
    osc2.frequency.setValueAtTime(320, t);
    osc2.frequency.exponentialRampToValueAtTime(80, t + 0.1);
    env2.gain.setValueAtTime(0.3, t);
    env2.gain.exponentialRampToValueAtTime(0.001, t + 0.12);
    osc2.start(t); osc2.stop(t + 0.15);

    // Noise burst
    playNoise({ gainPeak: 0.25, duration: 0.14, filterFreq: 500 });
  } catch(e) {}
}

function sfxBossHit() {
  // Heavier boom thud
  try {
    const ac = getAudio();
    const t  = ac.currentTime;
    const osc = ac.createOscillator();
    const env = ac.createGain();
    osc.connect(env); env.connect(ac.destination);
    osc.type = 'sine';
    osc.frequency.setValueAtTime(140, t);
    osc.frequency.exponentialRampToValueAtTime(30, t + 0.25);
    env.gain.setValueAtTime(0.5, t);
    env.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
    osc.start(t); osc.stop(t + 0.32);
    playNoise({ gainPeak: 0.22, duration: 0.18, filterFreq: 280 });
  } catch(e) {}
}

function sfxPlayerHit() {
  playTone({ frequency: 120, freqEnd: 40, type: 'sawtooth',
             gainPeak: 0.4, attack: 0.005, decay: 0.18,
             sustainLevel: 0, release: 0.08, duration: 0.26 });
  playNoise({ gainPeak: 0.3, duration: 0.20, filterFreq: 200 });
}

function sfxBossDie() {
  stopMusic();
  currentTrack = 'win';
  playWinMusic();
}

function sfxGameOver() {
  stopMusic();
  currentTrack = 'lose';
  playLoseMusic();
}




function sfxEnemyBullet() {
  playTone({ frequency: 320, freqEnd: 160, type: 'square',
             gainPeak: 0.12, attack: 0.002, decay: 0.07,
             sustainLevel: 0, release: 0.02, duration: 0.09 });
}

function sfxLaser() {
  // Big descending zap โ€” scary and powerful
  try {
    const ac = getAudio();
    const t  = ac.currentTime;
    // Main beam tone โ€” high to low screech
    const osc = ac.createOscillator();
    const env = ac.createGain();
    osc.connect(env); env.connect(ac.destination);
    osc.type = 'sawtooth';
    osc.frequency.setValueAtTime(1200, t);
    osc.frequency.exponentialRampToValueAtTime(80, t + 0.35);
    env.gain.setValueAtTime(0.5, t);
    env.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
    osc.start(t); osc.stop(t + 0.45);
    // High crackle layer
    const osc2 = ac.createOscillator();
    const env2 = ac.createGain();
    osc2.connect(env2); env2.connect(ac.destination);
    osc2.type = 'square';
    osc2.frequency.setValueAtTime(2400, t);
    osc2.frequency.exponentialRampToValueAtTime(200, t + 0.2);
    env2.gain.setValueAtTime(0.2, t);
    env2.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
    osc2.start(t); osc2.stop(t + 0.3);
    // Noise burst
    playNoise({ gainPeak: 0.3, duration: 0.25, filterFreq: 1200 });
  } catch(e) {}
}


const LEVELS = [
  { enemyCount:  8, enemySpeed: 55,  fireRate: 2200, spawnDelay: 900  },
  { enemyCount: 12, enemySpeed: 70,  fireRate: 1800, spawnDelay: 750  },
  { enemyCount: 16, enemySpeed: 90,  fireRate: 1400, spawnDelay: 600  },
  { enemyCount: 22, enemySpeed: 110, fireRate: 1100, spawnDelay: 450  },
  { enemyCount: 30, enemySpeed: 140, fireRate:  800, spawnDelay: 300  },
];

// Boss config
const BOSS_MAX_HP   = 100;   // 100 hits to kill (each bullet = 1%)
const BOSS_W        = 100;
const BOSS_H        = 90;
const BOSS_FIRE_RATE  = 900;  // ms between triple shots
const LASER_INTERVAL  = 10000; // ms between laser charges
const LASER_WARN_AT   = 5000;  // ms before fire to speak warning
const LASER_CHARGE_MS = 2000;  // ms of targeting beam before firing
const LASER_FIRE_MS   = 3000;  // ms the fired beam stays visible
const BOSS_SPEED_X  = 130;
const BOSS_ENTRY_Y  = 130;   // final resting Y after entry

// โ”€โ”€ STATE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
let state, stars;

function initStars() {
  stars = [];
  for (let i = 0; i < 180; i++) {
    stars.push({
      x: Math.random() * W,
      y: Math.random() * H,
      r: Math.random() * 1.2 + 0.3,
      a: Math.random() * 0.7 + 0.3,
      speed: Math.random() * 1.2 + 0.4,
    });
  }
}

function freshState(startLevel) {
  return {
    phase: 'playing',
    level: startLevel || 1,
    score: 0,
    lives: 3,

    player:       { x: W/2, y: H - 80, w: 36, h: 48, invTimer: 0 },
    bullets:      [],
    enemyBullets: [],
    enemies:      [],
    particles:    [],

    spawnCount: 0,
    spawnTimer: 0,
    fireTimer:  0,
    playerFire: 0,

    // Boss
    boss: null,
    slowMo: 0,       // countdown frames of slow motion (boss death cinematic)

    banner:      '',
    bannerTimer: 0,
    overlay:     null,
  };
}

function cfg(s) { return LEVELS[s.level - 1]; }

// โ”€โ”€ INPUT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const keys = {};
const ARROW_KEYS = new Set(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' ']);

document.addEventListener('keydown', e => {
  keys[e.key] = true;
  if (ARROW_KEYS.has(e.key)) e.preventDefault();
  startMusic();
  if (e.key === 'm' || e.key === 'M') { toggleMute(); return; }

  if (inMenu) {
    const m = menuState;
    if (e.key === 'ArrowLeft'  || e.key === 'a' || e.key === 'A') m.selectedLevel = Math.max(1, m.selectedLevel - 1);
    if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') m.selectedLevel = Math.min(6, m.selectedLevel + 1);
    if (e.key === 'Enter' || e.key === ' ') startFromMenu(m.selectedLevel);
    return;
  }

  if ((e.key === 'r' || e.key === 'R') && state && state.overlay) restartGame();
});
document.addEventListener('keyup', e => { keys[e.key] = false; });

canvas.addEventListener('click', () => canvas.focus());
canvas.addEventListener('pointerdown', e => {
  canvas.focus();
  startMusic();
  if (inMenu) {
    // Detect click on a level card
    const scale  = canvas.offsetWidth / W;
    const cx     = e.offsetX / scale;
    const cy     = e.offsetY / scale;
    const picked = menuHitTest(cx, cy);
    if (picked !== null) { menuState.selectedLevel = picked; startFromMenu(picked); }
    return;
  }
  if (state && state.overlay) restartGame();
});

// โ”€โ”€ MENU STATE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
let menuState = { phase: 'menu', selectedLevel: 1 };
let inMenu = true;

function startFromMenu(level) {
  inMenu = false;
  state = freshState(level);
  spawnLevel();
}

function goToMenu() {
  inMenu = true;
  menuState.selectedLevel = 1;
  currentTrack = null;  // release any win/lose lock so music restarts
}

function restartGame() {
  goToMenu();
}

// โ”€โ”€ HELPERS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function rand(a, b)  { return a + Math.random() * (b - a); }
function randInt(a, b) { return Math.floor(rand(a, b + 1)); }

// โ”€โ”€ SPAWNING โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function spawnLevel() {
  const s = state;
  s.spawnCount  = 0;
  s.spawnTimer  = 0;
  s.fireTimer   = 0;
  s.boss        = null;
  s.enemyBullets = [];
  s.enemies      = [];
  s.banner      = 'LEVEL ' + s.level;
  s.bannerTimer = 120;
  s.phase       = 'playing';

  if (s.level === 6) {
    // Small delay then boss enters
    s.bossEntryTimer = 180; // frames before boss appears
  }
}

function spawnEnemy() {
  const s = state;
  s.spawnCount++;
  s.enemies.push({
    x: rand(30, W - 30), y: -30, w: 36, h: 44,
    vx: rand(-50, 50),
    vy: cfg(s).enemySpeed,
  });
}

function spawnBoss() {
  state.boss = {
    x: W / 2,
    y: -BOSS_H,
    w: BOSS_W,
    h: BOSS_H,
    vx: BOSS_SPEED_X,
    hp: BOSS_MAX_HP,
    fireTimer: 0,
    entering: true,
    flashTimer: 0,
    laserTimer: LASER_INTERVAL,  // counts down to next laser
    laserState: 'idle',          // 'idle' | 'charging' | 'firing'
    laserChargeTimer: 0,         // counts down during charge phase
    laserTargetX: 0,             // locked player X when laser fires
    warnedThisCycle: false,      // so we only speak once per cycle
    shotToggle: false,           // alternates between 3 and 4 bullet shots
  };
}

// โ”€โ”€ DRAWING โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function drawStars(scroll) {
  stars.forEach(st => {
    ctx.globalAlpha = st.a;
    ctx.fillStyle = '#fff';
    ctx.beginPath();
    ctx.arc(st.x, (st.y + scroll * st.speed) % H, st.r, 0, Math.PI * 2);
    ctx.fill();
  });
  ctx.globalAlpha = 1;
}

function drawPlayer(p) {
  if (p.invTimer > 0 && Math.floor(p.invTimer / 4) % 2 === 0) return;
  const x = p.x, y = p.y;

  // Engine glow
  ctx.save();
  ctx.globalAlpha = 0.7 + 0.3 * Math.sin(Date.now() / 80);
  const eg = ctx.createRadialGradient(x, y + 26, 0, x, y + 26, 14);
  eg.addColorStop(0, '#ff8800'); eg.addColorStop(1, 'transparent');
  ctx.fillStyle = eg;
  ctx.beginPath(); ctx.ellipse(x, y + 26, 8, 12, 0, 0, Math.PI * 2); ctx.fill();
  ctx.restore();

  ctx.fillStyle = '#005588';
  ctx.beginPath(); ctx.moveTo(x, y+10); ctx.lineTo(x-20, y+24); ctx.lineTo(x-14, y+24); ctx.closePath(); ctx.fill();
  ctx.beginPath(); ctx.moveTo(x, y+10); ctx.lineTo(x+20, y+24); ctx.lineTo(x+14, y+24); ctx.closePath(); ctx.fill();

  const bg = ctx.createLinearGradient(x-10, y-24, x+10, y+24);
  bg.addColorStop(0, '#00eeff'); bg.addColorStop(1, '#0077aa');
  ctx.fillStyle = bg;
  ctx.beginPath(); ctx.moveTo(x, y-24); ctx.lineTo(x-12, y+24); ctx.lineTo(x+12, y+24); ctx.closePath(); ctx.fill();

  ctx.fillStyle = 'rgba(180,255,255,0.85)';
  ctx.beginPath(); ctx.ellipse(x, y-4, 5, 9, 0, 0, Math.PI * 2); ctx.fill();
}

function drawEnemy(e) {
  const x = e.x, y = e.y;
  ctx.strokeStyle = '#ff6688'; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(x-5, y-22); ctx.lineTo(x-5, y-10); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(x+5, y-22); ctx.lineTo(x+5, y-10); ctx.stroke();
  ctx.fillStyle = '#ffaacc';
  ctx.beginPath(); ctx.arc(x-5, y-22, 3, 0, Math.PI*2); ctx.fill();
  ctx.beginPath(); ctx.arc(x+5, y-22, 3, 0, Math.PI*2); ctx.fill();

  ctx.fillStyle = '#880011';
  ctx.beginPath(); ctx.moveTo(x-8,y); ctx.lineTo(x-20,y+8); ctx.lineTo(x-18,y+18); ctx.closePath(); ctx.fill();
  ctx.beginPath(); ctx.moveTo(x+8,y); ctx.lineTo(x+20,y+8); ctx.lineTo(x+18,y+18); ctx.closePath(); ctx.fill();

  const eg = ctx.createLinearGradient(x, y-18, x, y+22);
  eg.addColorStop(0, '#ff4466'); eg.addColorStop(1, '#aa0022');
  ctx.fillStyle = eg;
  ctx.beginPath(); ctx.moveTo(x,y-18); ctx.lineTo(x-14,y+22); ctx.lineTo(x+14,y+22); ctx.closePath(); ctx.fill();

  ctx.fillStyle = '#ffff00';
  ctx.beginPath(); ctx.arc(x, y+4, 7, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = '#000';
  ctx.beginPath(); ctx.arc(x, y+4, 4, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = '#fff';
  ctx.beginPath(); ctx.arc(x-2, y+2, 1.5, 0, Math.PI*2); ctx.fill();
}

function drawBoss(b) {
  const x = b.x, y = b.y;
  const flash = b.flashTimer > 0;

  // Outer glow
  ctx.save();
  const pulseA = 0.3 + 0.2 * Math.sin(Date.now() / 150);
  const glow = ctx.createRadialGradient(x, y, 10, x, y, 80);
  glow.addColorStop(0, `rgba(255,0,60,${pulseA})`);
  glow.addColorStop(1, 'transparent');
  ctx.fillStyle = glow;
  ctx.beginPath(); ctx.arc(x, y, 80, 0, Math.PI*2); ctx.fill();
  ctx.restore();

  // Big antennae (3)
  ctx.strokeStyle = flash ? '#ffffff' : '#ff3366';
  ctx.lineWidth = 3;
  for (let i = -1; i <= 1; i++) {
    ctx.beginPath();
    ctx.moveTo(x + i * 20, y - 42);
    ctx.lineTo(x + i * 20, y - 20);
    ctx.stroke();
    ctx.fillStyle = flash ? '#fff' : '#ff88bb';
    ctx.beginPath(); ctx.arc(x + i * 20, y - 44, 5, 0, Math.PI*2); ctx.fill();
  }

  // Wings
  const wc = flash ? '#ffffff' : '#880011';
  ctx.fillStyle = wc;
  ctx.beginPath();
  ctx.moveTo(x - 20, y - 10);
  ctx.lineTo(x - 55, y + 10);
  ctx.lineTo(x - 50, y + 38);
  ctx.lineTo(x - 20, y + 30);
  ctx.closePath(); ctx.fill();
  ctx.beginPath();
  ctx.moveTo(x + 20, y - 10);
  ctx.lineTo(x + 55, y + 10);
  ctx.lineTo(x + 50, y + 38);
  ctx.lineTo(x + 20, y + 30);
  ctx.closePath(); ctx.fill();

  // Body
  const bodyGrad = ctx.createLinearGradient(x - 30, y - 38, x + 30, y + 42);
  bodyGrad.addColorStop(0, flash ? '#ffffff' : '#ff2255');
  bodyGrad.addColorStop(0.5, flash ? '#ffcccc' : '#cc0033');
  bodyGrad.addColorStop(1, flash ? '#ff8888' : '#660011');
  ctx.fillStyle = bodyGrad;
  ctx.beginPath();
  ctx.moveTo(x, y - 38);
  ctx.lineTo(x - 30, y - 10);
  ctx.lineTo(x - 28, y + 42);
  ctx.lineTo(x + 28, y + 42);
  ctx.lineTo(x + 30, y - 10);
  ctx.closePath(); ctx.fill();

  // Big eye
  ctx.fillStyle = flash ? '#ffffff' : '#ffff00';
  ctx.beginPath(); ctx.arc(x, y + 8, 18, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = '#000';
  ctx.beginPath(); ctx.arc(x, y + 8, 11, 0, Math.PI*2); ctx.fill();
  // Angry pupil
  const pupilShift = Math.sin(Date.now() / 200) * 4;
  ctx.fillStyle = flash ? '#ff0000' : '#ff0044';
  ctx.beginPath(); ctx.arc(x + pupilShift, y + 8, 6, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = '#fff';
  ctx.beginPath(); ctx.arc(x + pupilShift - 3, y + 4, 2.5, 0, Math.PI*2); ctx.fill();

  // Angry eyebrows
  ctx.strokeStyle = flash ? '#ffffff' : '#ff2244';
  ctx.lineWidth = 4;
  ctx.lineCap = 'round';
  ctx.beginPath(); ctx.moveTo(x - 18, y - 10); ctx.lineTo(x - 6, y - 4); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(x + 18, y - 10); ctx.lineTo(x + 6, y - 4); ctx.stroke();

  if (b.flashTimer > 0) b.flashTimer--;
}

function drawBossHealthBar(b) {
  if (!b) return;
  const pct    = b.hp / BOSS_MAX_HP;
  const barW   = W - 40;
  const barH   = 14;
  const barX   = 20;
  const barY   = 40;

  // Label
  ctx.fillStyle = '#ff4466';
  ctx.font = 'bold 12px monospace';
  ctx.textAlign = 'left';
  ctx.fillText('๐Ÿ‘พ ALIEN KING', barX, barY - 3);

  // Background
  ctx.fillStyle = '#330011';
  ctx.beginPath(); ctx.roundRect(barX, barY, barW, barH, 4); ctx.fill();

  // Fill โ€” color shifts redโ†’orangeโ†’yellow as boss gets low
  const r = 255;
  const g = Math.floor(pct * 180);
  const hpGrad = ctx.createLinearGradient(barX, 0, barX + barW * pct, 0);
  hpGrad.addColorStop(0, `rgb(${r},${g},0)`);
  hpGrad.addColorStop(1, `rgb(255,80,0)`);
  ctx.fillStyle = hpGrad;
  ctx.beginPath(); ctx.roundRect(barX, barY, barW * pct, barH, 4); ctx.fill();

  // Border
  ctx.strokeStyle = '#ff2244';
  ctx.lineWidth = 1.5;
  ctx.beginPath(); ctx.roundRect(barX, barY, barW, barH, 4); ctx.stroke();

  // Percentage text
  ctx.fillStyle = '#ffffff';
  ctx.font = 'bold 11px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(Math.ceil(pct * 100) + '%', barX + barW / 2, barY + barH - 2);
}

// โ”€โ”€ MENU โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const LEVEL_LABELS = ['1','2','3','4','5','6 ๐Ÿ’€'];
const LEVEL_DESCS  = [
  'Easy start',
  'Picking up',
  'Getting hot',
  'Danger zone',
  'Maximum chaos',
  'Boss Level',
];
const CARD_W = 64, CARD_H = 72, CARD_GAP = 10;
const CARDS_TOTAL_W = 6 * CARD_W + 5 * CARD_GAP;
const CARDS_X = (W - CARDS_TOTAL_W) / 2;
const CARDS_Y = 300;

function menuHitTest(cx, cy) {
  for (let i = 0; i < 6; i++) {
    const x = CARDS_X + i * (CARD_W + CARD_GAP);
    if (cx >= x && cx <= x + CARD_W && cy >= CARDS_Y && cy <= CARDS_Y + CARD_H) return i + 1;
  }
  return null;
}

function drawMenu() {
  const m = menuState;

  // Background
  ctx.fillStyle = '#000010';
  ctx.fillRect(0, 0, W, H);
  drawStars(scroll);

  // Title
  ctx.save();
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // Glow title
  ctx.shadowColor = '#00ffff';
  ctx.shadowBlur  = 24;
  ctx.fillStyle   = '#00ffff';
  ctx.font        = 'bold 52px monospace';
  ctx.fillText('STAR BLASTER', W / 2, 110);
  ctx.shadowBlur  = 0;

  ctx.fillStyle = '#aaffff';
  ctx.font      = '16px monospace';
  ctx.fillText('choose your starting level', W / 2, 160);

  // Draw a mini ship as decoration
  ctx.save();
  ctx.translate(W / 2, 220);
  ctx.scale(1.8, 1.8);
  ctx.fillStyle = '#005588';
  ctx.beginPath(); ctx.moveTo(0,10); ctx.lineTo(-20,24); ctx.lineTo(-14,24); ctx.closePath(); ctx.fill();
  ctx.beginPath(); ctx.moveTo(0,10); ctx.lineTo(20,24); ctx.lineTo(14,24); ctx.closePath(); ctx.fill();
  const bg2 = ctx.createLinearGradient(-10,-24,10,24);
  bg2.addColorStop(0,'#00eeff'); bg2.addColorStop(1,'#0077aa');
  ctx.fillStyle = bg2;
  ctx.beginPath(); ctx.moveTo(0,-24); ctx.lineTo(-12,24); ctx.lineTo(12,24); ctx.closePath(); ctx.fill();
  ctx.fillStyle = 'rgba(180,255,255,0.85)';
  ctx.beginPath(); ctx.ellipse(0,-4,5,9,0,0,Math.PI*2); ctx.fill();
  ctx.restore();

  // Level cards
  // Difficulty color ramp: green โ†’ yellow-green โ†’ yellow โ†’ orange โ†’ orange-red โ†’ red
  const DIFF_COLORS = [
    '#00cc44',  // 1 โ€” green
    '#88cc00',  // 2 โ€” yellow-green
    '#ddcc00',  // 3 โ€” yellow
    '#ff8800',  // 4 โ€” orange
    '#ff4400',  // 5 โ€” orange-red
    '#ff1133',  // 6 โ€” red
  ];
  const DIFF_DARK = [
    '#003311',  // 1
    '#223300',  // 2
    '#332e00',  // 3
    '#331a00',  // 4
    '#330d00',  // 5
    '#330008',  // 6
  ];

  for (let i = 0; i < 6; i++) {
    const lvl   = i + 1;
    const x     = CARDS_X + i * (CARD_W + CARD_GAP);
    const y     = CARDS_Y;
    const sel   = m.selectedLevel === lvl;
    const color = DIFF_COLORS[i];
    const dark  = DIFF_DARK[i];

    // Card background
    ctx.save();
    if (sel) {
      ctx.shadowColor = color;
      ctx.shadowBlur  = 20;
    }
    ctx.fillStyle = sel ? dark : '#0a0a1a';
    ctx.beginPath();
    ctx.roundRect(x, y, CARD_W, CARD_H, 8);
    ctx.fill();

    // Border โ€” full color when selected, dim tint when not
    ctx.strokeStyle = sel ? color : color + '55';
    ctx.lineWidth   = sel ? 2.5 : 1.5;
    ctx.beginPath();
    ctx.roundRect(x, y, CARD_W, CARD_H, 8);
    ctx.stroke();
    ctx.restore();

    // Level number โ€” full color always, brighter when selected
    ctx.fillStyle    = sel ? '#ffffff' : color;
    ctx.font         = `bold ${lvl === 6 ? 18 : 24}px monospace`;
    ctx.textAlign    = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(LEVEL_LABELS[i], x + CARD_W / 2, y + 28);

    // Description
    ctx.fillStyle = sel ? '#ffffff' : '#556677';
    ctx.font      = '9px monospace';
    const words   = LEVEL_DESCS[i].split(' ');
    let line = '', lineY = y + 50;
    for (const w of words) {
      const test = line ? line + ' ' + w : w;
      if (ctx.measureText(test).width > CARD_W - 8) {
        ctx.fillText(line, x + CARD_W / 2, lineY);
        line = w; lineY += 12;
      } else { line = test; }
    }
    if (line) ctx.fillText(line, x + CARD_W / 2, lineY);
  }

  // Instructions
  ctx.fillStyle    = '#445566';
  ctx.font         = '13px monospace';
  ctx.textAlign    = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText('โ† โ†’ to select   ENTER or click to start', W / 2, CARDS_Y + CARD_H + 36);

  // Mute hint bottom-left
  ctx.textAlign = 'left';
  ctx.fillStyle = musicMuted ? '#ff4444' : '#336644';
  ctx.fillText(musicMuted ? '๐Ÿ”‡ M: unmute' : '๐Ÿ”Š M: mute', 10, H - 10);

  ctx.restore();
}

function drawBossLaser(b, player) {
  if (b.laserState === 'idle') return;

  const x       = b.laserTargetX;
  const beamTop = b.y + BOSS_H / 2;  // bottom edge of boss

  if (b.laserState === 'charging') {
    const pulse          = 0.4 + 0.6 * Math.abs(Math.sin(Date.now() / 80));
    const chargeProgress = 1 - (b.laserChargeTimer / LASER_CHARGE_MS);

    // Dashed targeting line from boss down to bottom of screen
    ctx.save();
    ctx.setLineDash([8, 8]);
    ctx.lineDashOffset = -Date.now() / 30;
    ctx.strokeStyle = `rgba(255, 60, 60, ${pulse})`;
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(x, beamTop);
    ctx.lineTo(x, H);
    ctx.stroke();
    ctx.setLineDash([]);

    // Charge progress bar just below boss
    ctx.fillStyle = `rgba(255, 60, 60, 0.6)`;
    ctx.fillRect(x - 20, beamTop + 4, 40 * chargeProgress, 4);

    // Target diamond at bottom
    ctx.fillStyle = `rgba(255, 60, 60, ${pulse})`;
    ctx.beginPath();
    ctx.moveTo(x,      H - 20);
    ctx.lineTo(x - 10, H - 30);
    ctx.lineTo(x,      H - 40);
    ctx.lineTo(x + 10, H - 30);
    ctx.closePath();
    ctx.fill();
    ctx.restore();

  } else if (b.laserState === 'firing') {
    // Full beam from boss to bottom of screen
    const alpha = b.laserFireTimer / LASER_FIRE_MS;
    ctx.save();
    ctx.shadowColor = '#ff0000';
    ctx.shadowBlur  = 30;
    const grad = ctx.createLinearGradient(x - 22, 0, x + 22, 0);
    grad.addColorStop(0,   `rgba(255,0,0,0)`);
    grad.addColorStop(0.3, `rgba(255,80,80,${alpha})`);
    grad.addColorStop(0.5, `rgba(255,255,255,${alpha})`);
    grad.addColorStop(0.7, `rgba(255,80,80,${alpha})`);
    grad.addColorStop(1,   `rgba(255,0,0,0)`);
    ctx.fillStyle = grad;
    ctx.fillRect(x - 22, beamTop, 44, H - beamTop);
    ctx.shadowBlur = 0;
    ctx.restore();
  }
}


function drawBullet(b) {
  const g = ctx.createLinearGradient(b.x, b.y - 10, b.x, b.y + 4);
  g.addColorStop(0, '#ffffff'); g.addColorStop(1, '#00ffff');
  ctx.fillStyle = g;
  ctx.shadowColor = '#00ffff'; ctx.shadowBlur = 8;
  ctx.beginPath(); ctx.roundRect(b.x - 2, b.y - 10, 4, 14, 2); ctx.fill();
  ctx.shadowBlur = 0;
}

function drawEnemyBullet(b) {
  const g = ctx.createLinearGradient(b.x, b.y, b.x, b.y + 12);
  g.addColorStop(0, '#ff2244'); g.addColorStop(1, '#ffaacc');
  ctx.fillStyle = g;
  ctx.shadowColor = '#ff2244'; ctx.shadowBlur = 8;
  ctx.beginPath(); ctx.roundRect(b.x - 2, b.y, 4, 12, 2); ctx.fill();
  ctx.shadowBlur = 0;
}

function drawParticle(p) {
  ctx.globalAlpha = p.life / p.maxLife;
  ctx.fillStyle = p.color;
  ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (p.life / p.maxLife), 0, Math.PI*2); ctx.fill();
  ctx.globalAlpha = 1;
}

function drawHUD(s) {
  const hudY = s.boss ? 68 : 26;

  ctx.fillStyle = '#00ffff';
  ctx.font = 'bold 16px monospace';
  ctx.textAlign = 'left';
  ctx.fillText('SCORE: ' + s.score, 10, hudY);

  ctx.fillStyle = '#ffff00';
  ctx.textAlign = 'center';
  ctx.fillText('LEVEL ' + s.level, W / 2, hudY);

  for (let i = 0; i < s.lives; i++) {
    ctx.save();
    ctx.translate(W - 14 - i * 22, hudY - 8);
    ctx.scale(0.45, 0.45);
    ctx.fillStyle = '#00cfff';
    ctx.beginPath(); ctx.moveTo(0,-20); ctx.lineTo(-10,20); ctx.lineTo(10,20); ctx.closePath(); ctx.fill();
    ctx.restore();
  }

  // Mute indicator โ€” bottom-left corner
  ctx.font = '13px monospace';
  ctx.textAlign = 'left';
  ctx.fillStyle = musicMuted ? '#ff4444' : '#336644';
  ctx.fillText(musicMuted ? '๐Ÿ”‡ M: unmute' : '๐Ÿ”Š M: mute', 10, H - 10);
}

function drawBanner(s) {
  if (s.bannerTimer <= 0) return;
  const a = Math.min(1, s.bannerTimer / 30);
  ctx.globalAlpha = a;
  ctx.fillStyle = s.level === 6 ? '#ff4466' : '#00ffff';
  ctx.font = 'bold 40px monospace';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.shadowColor = s.level === 6 ? '#660011' : '#003366';
  ctx.shadowBlur = 14;
  ctx.fillText(s.banner, W / 2, H / 2);
  ctx.shadowBlur = 0;
  ctx.globalAlpha = 1;
  ctx.textBaseline = 'alphabetic';
}

function drawOverlay(s) {
  if (!s.overlay) return;
  ctx.fillStyle = 'rgba(0,0,0,0.78)';
  ctx.fillRect(0, 0, W, H);
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  ctx.font = 'bold 44px monospace';
  ctx.fillStyle = s.overlay.titleColor || '#ffffff';
  ctx.shadowColor = '#000'; ctx.shadowBlur = 10;
  ctx.fillText(s.overlay.title, W / 2, H / 2 - 80);

  ctx.font = '22px monospace';
  ctx.fillStyle = '#00ffff';
  ctx.fillText('FINAL SCORE: ' + s.score, W / 2, H / 2 - 20);

  if (Math.floor(Date.now() / 500) % 2 === 0) {
    ctx.font = 'bold 24px monospace';
    ctx.fillStyle = '#ffff00';
    ctx.fillText('[ CLICK OR PRESS R ]', W / 2, H / 2 + 60);
  }
  ctx.shadowBlur = 0;
  ctx.textBaseline = 'alphabetic';
}

// โ”€โ”€ EXPLOSION โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const EXPLOSION_COLORS     = ['#ffdd00','#ff8800','#ff3300','#ffffff','#ffaaaa'];
const EXPLOSION_COLORS_BIG = ['#ffdd00','#ff9900','#ff4400','#ffffff','#ffccaa','#ff6600','#ffff00'];

function explode(s, x, y, big, epic) {
  const count   = epic ? 120 : big ? 60 : 22;
  const colors  = (epic || big) ? EXPLOSION_COLORS_BIG : EXPLOSION_COLORS;
  const speedLo = epic ? 0.4 : big ? 1.5 : 1.5;
  const speedHi = epic ? 3.5 : big ? 8   : 5;
  const rLo     = epic ? 4   : big ? 3   : 2;
  const rHi     = epic ? 12  : big ? 8   : 5;
  const lifeLo  = epic ? 180 : big ? 50  : 30;
  const lifeHi  = epic ? 280 : big ? 70  : 50;

  for (let i = 0; i < count; i++) {
    const angle = rand(0, Math.PI * 2);
    const speed = rand(speedLo, speedHi);
    const life  = lifeLo + randInt(0, lifeHi - lifeLo);
    s.particles.push({
      x, y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      r: rand(rLo, rHi),
      color: colors[randInt(0, colors.length - 1)],
      life,
      maxLife: life,
    });
  }
}

// โ”€โ”€ RECT COLLISION โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function hit(ax, ay, aw, ah, bx, by, bw, bh) {
  return ax - aw/2 < bx + bw/2 &&
         ax + aw/2 > bx - bw/2 &&
         ay - ah/2 < by + bh/2 &&
         ay + ah/2 > by - bh/2;
}

function speakRobotic(text) {
  try {
    // Web Speech API for the actual words โ€” nothing beats real speech synthesis for clarity
    if (window.speechSynthesis) {
      window.speechSynthesis.cancel();
      const utt    = new SpeechSynthesisUtterance(text);
      utt.pitch    = 0.1;   // as low as possible
      utt.rate     = 0.8;   // slow and deliberate
      utt.volume   = 1.0;

      // Wait for voices to load, then pick the most robotic-sounding one
      const setVoice = () => {
        const voices = window.speechSynthesis.getVoices();
        // Prefer a male English voice with a mechanical quality
        const pick = voices.find(v => /david|mark|daniel|alex|google uk english male/i.test(v.name) && /en/i.test(v.lang))
                  || voices.find(v => /en/i.test(v.lang) && !v.name.includes('Female'))
                  || voices[0];
        if (pick) utt.voice = pick;
        window.speechSynthesis.speak(utt);
      };

      if (window.speechSynthesis.getVoices().length > 0) {
        setVoice();
      } else {
        window.speechSynthesis.onvoiceschanged = setVoice;
      }
    }

    // Simultaneously play a robotic buzz underneath to sell the robot feel
    const ac  = getAudio();
    const t   = ac.currentTime + 0.05;
    const dur = 1.2; // roughly matches "charging laser" duration

    const osc = ac.createOscillator();
    osc.type  = 'sawtooth';
    osc.frequency.setValueAtTime(90, t);
    osc.frequency.linearRampToValueAtTime(70, t + dur);

    const lfo = ac.createOscillator();
    lfo.frequency.value = 8; // 8Hz tremolo
    const lfoGain = ac.createGain();
    lfoGain.gain.value = 20;
    lfo.connect(lfoGain);
    lfoGain.connect(osc.frequency);

    const filter = ac.createBiquadFilter();
    filter.type = 'bandpass';
    filter.frequency.value = 300;
    filter.Q.value = 2;
    osc.connect(filter);

    const env = ac.createGain();
    filter.connect(env);
    env.connect(ac.destination);
    env.gain.setValueAtTime(0, t);
    env.gain.linearRampToValueAtTime(0.08, t + 0.05);
    env.gain.setValueAtTime(0.08, t + dur - 0.1);
    env.gain.linearRampToValueAtTime(0, t + dur);

    osc.start(t); osc.stop(t + dur + 0.1);
    lfo.start(t); lfo.stop(t + dur + 0.1);

  } catch(e) {}
}

// โ”€โ”€ UPDATE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
let scroll = 0;
let lastTime = 0;

function update(dt) {

  const s = state;
  if (s.phase === 'gameover' || s.phase === 'win') return;

  // During boss death cinematic โ€” only run particles and slow-mo, nothing else
  if (s.phase === 'bossdie') {
    if (s.slowMo > 0) { s.slowMo--; dt *= 0.15; }
    s.particles.forEach(p => { p.x += p.vx * dt * 60; p.y += p.vy * dt * 60; p.vy += 0.08 * dt * 60; p.life--; });
    s.particles = s.particles.filter(p => p.life > 0);
    return;
  }

  // Slow-motion during boss death cinematic
  if (s.slowMo > 0) {
    s.slowMo--;
    dt *= 0.15;
  }

  // Scroll only when not in boss level (or boss still entering)
  const isBossLevel = s.level === 6;
  const bossActive  = isBossLevel && s.boss && !s.boss.entering;
  if (!bossActive) scroll += 80 * dt;

  // โ”€โ”€ Player movement
  const p = s.player;
  const spd = 280;
  let dx = 0, dy = 0;
  if (keys['ArrowLeft']  || keys['a'] || keys['A']) dx -= spd;
  if (keys['ArrowRight'] || keys['d'] || keys['D']) dx += spd;
  if (keys['ArrowUp']    || keys['w'] || keys['W']) dy -= spd;
  if (keys['ArrowDown']  || keys['s'] || keys['S']) dy += spd;
  p.x = clamp(p.x + dx * dt, 22, W - 22);
  p.y = clamp(p.y + dy * dt, 40, H - 40);
  if (p.invTimer > 0) p.invTimer--;

  // โ”€โ”€ Player auto-fire (only while playing or levelclear)
  if (s.phase === 'playing') {
    s.playerFire -= dt * 1000;
    if (s.playerFire <= 0) {
      s.playerFire = 200;
      s.bullets.push({ x: p.x, y: p.y - 28, vy: -520 });
      sfxShoot();
    }
  }

  // โ”€โ”€ BOSS LEVEL LOGIC โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  if (isBossLevel) {
    // Only advance boss entry/logic while actively playing
    if (s.phase === 'playing') {
      if (!s.boss && s.bossEntryTimer > 0) {
        s.bossEntryTimer--;
        if (s.bossEntryTimer === 0) spawnBoss();
      }

      if (s.boss) {
        const b = s.boss;

        // Entry slide-in
        if (b.entering) {
          b.y = Math.min(b.y + 160 * dt, BOSS_ENTRY_Y);
          if (b.y >= BOSS_ENTRY_Y) b.entering = false;
        } else {
          // Horizontal patrol
          b.x += b.vx * dt;
          if (b.x > W - BOSS_W / 2 - 10) { b.x = W - BOSS_W / 2 - 10; b.vx = -Math.abs(b.vx); }
          if (b.x < BOSS_W / 2 + 10)     { b.x = BOSS_W / 2 + 10;     b.vx =  Math.abs(b.vx); }

          // Triple-shot fire
          b.fireTimer -= dt * 1000;
          if (b.fireTimer <= 0) {
            b.fireTimer = BOSS_FIRE_RATE;
            const bulletVy = 340;
            const spreadVx = 120;
            s.enemyBullets.push({ x: b.x,                 y: b.y + BOSS_H / 2, vy: bulletVy, vx: 0 });
            s.enemyBullets.push({ x: b.x - BOSS_W * 0.35, y: b.y + BOSS_H / 2, vy: bulletVy, vx: -spreadVx });
            s.enemyBullets.push({ x: b.x + BOSS_W * 0.35, y: b.y + BOSS_H / 2, vy: bulletVy, vx:  spreadVx });
            if (b.shotToggle) {
              s.enemyBullets.push({ x: b.x + BOSS_W * 0.35, y: b.y + BOSS_H / 2, vy: bulletVy, vx: spreadVx * 2 });
            }
            b.shotToggle = !b.shotToggle;
            sfxEnemyBullet();
          }

          // โ”€โ”€ Laser countdown
          if (b.laserState === 'idle') {
            b.laserTimer -= dt * 1000;

            // 10 seconds before firing โ€” speak warning
            if (!b.warnedThisCycle && b.laserTimer <= LASER_WARN_AT) {
              b.warnedThisCycle = true;
              speakRobotic('Charging laser.');
            }

            // Time to charge โ€” lock target onto player's current position
            if (b.laserTimer <= 0) {
              b.laserState       = 'charging';
              b.laserChargeTimer = LASER_CHARGE_MS;
              b.laserTargetX     = p.x; // locked โ€” player must move away from this X
            }
          } else if (b.laserState === 'charging') {
            b.laserChargeTimer -= dt * 1000;
            if (b.laserChargeTimer <= 0) {
              // FIRE โ€” instant kill if player is within beam width
              b.laserState = 'firing';
              b.laserFireTimer = LASER_FIRE_MS;
              const beamHalfW = 22;
              if (Math.abs(p.x - b.laserTargetX) < beamHalfW) {
                // instant kill โ€” bypass invTimer, lose all lives
                s.lives = 1;
                takeDamage(s);
              }
              sfxLaser();
            }
          } else if (b.laserState === 'firing') {
            b.laserFireTimer -= dt * 1000;
            // Kill player if they're still in the beam during the 3-second burn
            const beamHalfW = 22;
            if (p.invTimer === 0 && Math.abs(p.x - b.laserTargetX) < beamHalfW) {
              s.lives = 1;
              takeDamage(s);
            }
            if (b.laserFireTimer <= 0) {
              b.laserState      = 'idle';
              b.laserTimer      = LASER_INTERVAL;
              b.warnedThisCycle = false;
            }
          }

          // Bullet hits boss
          s.bullets = s.bullets.filter(bullet => {
            if (hit(bullet.x, bullet.y, 4, 14, b.x, b.y, b.w, b.h)) {
              b.hp = Math.max(0, b.hp - 2);
              b.flashTimer = 6;
              explode(s, bullet.x, bullet.y, false);
              s.score += 50;
              sfxBossHit();
              if (b.hp <= 0) {
                s.phase = 'bossdie';  // lock phase immediately โ€” no more damage/spawning
                s.slowMo = 300;
                explode(s, b.x,       b.y,       false, true);
                explode(s, b.x - 35,  b.y + 25,  false, true);
                explode(s, b.x + 35,  b.y - 15,  false, true);
                [200, 450, 750, 1100, 1500].forEach(delay => {
                  setTimeout(() => {
                    if (state === s && s.boss) {
                      explode(s, s.boss.x + rand(-50, 50), s.boss.y + rand(-40, 40), false, true);
                    }
                  }, delay);
                });
                sfxBossDie();
                s.boss = null;
                s.score += 5000;
                setTimeout(() => {
                  if (state === s) {
                    s.phase = 'win';
                    s.overlay = { title: '๐ŸŽ‰ YOU WIN! ๐ŸŽ‰', titleColor: '#ffff00' };
                  }
                }, 3800);
              }
              return false;
            }
            return true;
          });

          // Boss rams player โ€” only if player is vulnerable and game still playing
          if (s.phase === 'playing' && p.invTimer === 0 && s.boss &&
              hit(b.x, b.y, b.w - 10, b.h - 10, p.x, p.y, p.w - 8, p.h - 8)) {
            takeDamage(s);
          }
        }
      }
    }

    // Move boss bullets regardless (so they clear off screen)
    s.enemyBullets = s.enemyBullets.filter(b => {
      b.x += (b.vx || 0) * dt;
      b.y += b.vy * dt;
      return b.y < H + 20 && b.x > -20 && b.x < W + 20;
    });

  } else {
    // โ”€โ”€ NORMAL LEVEL LOGIC โ€” only while playing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    if (s.phase === 'playing') {
      if (s.spawnCount < cfg(s).enemyCount) {
        s.spawnTimer -= dt * 1000;
        if (s.spawnTimer <= 0) {
          s.spawnTimer = cfg(s).spawnDelay;
          spawnEnemy();
        }
      }

      if (s.enemies.length > 0) {
        s.fireTimer -= dt * 1000;
        if (s.fireTimer <= 0) {
          s.fireTimer = cfg(s).fireRate;
          const shooter = s.enemies[randInt(0, s.enemies.length - 1)];
          if (shooter) { s.enemyBullets.push({ x: shooter.x, y: shooter.y + 22, vy: 300 + s.level * 30, vx: 0 }); sfxEnemyBullet(); }
        }
      }
    }

    s.enemyBullets = s.enemyBullets.filter(b => {
      b.x += (b.vx || 0) * dt;
      b.y += b.vy * dt;
      return b.y < H + 20;
    });

    s.enemies = s.enemies.filter(e => {
      e.x += e.vx * dt; e.y += e.vy * dt;
      if (e.x < 20 || e.x > W - 20) e.vx *= -1;
      return e.y < H + 60;
    });

    // Bullet vs enemy โ€” only while playing
    if (s.phase === 'playing') {
      s.bullets = s.bullets.filter(b => {
        let hitsomething = false;
        s.enemies = s.enemies.filter(e => {
          if (!hitsomething && hit(b.x, b.y, 4, 14, e.x, e.y, e.w, e.h)) {
            hitsomething = true;
            explode(s, e.x, e.y, false);
            sfxEnemyHit();
            s.score += 100 * s.level;
            return false;
          }
          return true;
        });
        return !hitsomething;
      });

      // Level clear check
      if (s.spawnCount >= cfg(s).enemyCount && s.enemies.length === 0) {
        s.phase = 'levelclear';  // lock immediately before setTimeout fires
        s.score += 500;
        s.banner = 'LEVEL CLEAR! +500';
        s.bannerTimer = 120;
        const capturedState = s;
        setTimeout(() => {
          // Only advance if this exact state object is still active AND not gameover
          if (state === capturedState && capturedState.phase === 'levelclear') {
            capturedState.level++;
            spawnLevel();
          }
        }, 2000);
      }
    }
  }

  // โ”€โ”€ Move player bullets upward (shared)
  s.bullets = s.bullets.filter(b => { b.y += b.vy * dt; return b.y > -20; });

  // โ”€โ”€ Enemy bullet vs player โ€” only while actively playing (not levelclear/bossdie)
  if (s.phase === 'playing' && p.invTimer === 0) {
    // Use a flag so multiple hits in one frame only count once
    let hitThisFrame = false;

    s.enemyBullets = s.enemyBullets.filter(b => {
      if (!hitThisFrame && hit(b.x, b.y, 4, 12, p.x, p.y, p.w - 8, p.h - 8)) {
        hitThisFrame = true;
        takeDamage(s);
        return false;
      }
      return true;
    });

    // Normal enemy ram โ€” only if player not already hit this frame
    if (!isBossLevel && !hitThisFrame) {
      s.enemies = s.enemies.filter(e => {
        if (!hitThisFrame && hit(e.x, e.y, e.w, e.h, p.x, p.y, p.w - 8, p.h - 8)) {
          hitThisFrame = true;
          explode(s, e.x, e.y, false);
          takeDamage(s);
          return false;
        }
        return true;
      });
    }
  }

  // โ”€โ”€ Particles (run always so explosions finish playing out)
  s.particles.forEach(p => { p.x += p.vx * dt * 60; p.y += p.vy * dt * 60; p.vy += 0.08 * dt * 60; p.life--; });
  s.particles = s.particles.filter(p => p.life > 0);

  if (s.bannerTimer > 0) s.bannerTimer--;
}

function takeDamage(s) {
  s.lives--;
  s.player.invTimer = 120;
  explode(s, s.player.x, s.player.y, false);
  sfxPlayerHit();
  if (s.lives <= 0) {
    s.lives = 0;
    s.phase = 'gameover';
    s.overlay = { title: '๐Ÿ’€ GAME OVER', titleColor: '#ff4466' };
    sfxGameOver();
  }
}

// โ”€โ”€ DRAW โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function draw() {
  const s = state;

  ctx.fillStyle = '#000010';
  ctx.fillRect(0, 0, W, H);
  drawStars(scroll);

  s.particles.forEach(drawParticle);
  s.enemies.forEach(drawEnemy);
  if (s.boss) drawBoss(s.boss);
  if (s.boss) drawBossLaser(s.boss, s.player);
  s.bullets.forEach(drawBullet);
  s.enemyBullets.forEach(drawEnemyBullet);
  drawPlayer(s.player);

  // HUD โ€” boss bar first so score/lives sit below it
  if (s.boss) drawBossHealthBar(s.boss);
  drawHUD(s);
  drawBanner(s);
  drawOverlay(s);
}

// โ”€โ”€ LOOP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function loop(now) {
  const dt = Math.min((now - lastTime) / 1000, 0.05);
  lastTime = now;
  updateMusic();
  if (inMenu) {
    scroll += 40 * dt;
    drawMenu();
  } else {
    update(dt);
    draw();
  }
  requestAnimationFrame(loop);
}

// โ”€โ”€ MUSIC โ€” pure Web Audio API, no external libraries โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Self-contained sequencer: schedules notes ahead using AudioContext clock,
// works in any sandboxed iframe with no network access needed.

let musicStarted = false;
let musicMuted   = false;
let currentTrack = null;  // 'menu' | 'boss' | 'win' | 'lose'

// master gain for mute toggle
let musicGain = null;
// currently running scheduler interval
let musicScheduler = null;
// sequence state
let seqState = null;

// note name โ†’ frequency
const NOTE_FREQ = (()=>{
  const notes = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
  const map = {};
  for (let oct = 0; oct <= 8; oct++) {
    notes.forEach((n, i) => {
      const midi = (oct + 1) * 12 + i;
      map[n + oct] = 440 * Math.pow(2, (midi - 69) / 12);
    });
  }
  // aliases
  map['Bb0']=map['A#0']; map['Bb1']=map['A#1']; map['Bb2']=map['A#2'];
  map['Bb3']=map['A#3']; map['Bb4']=map['A#4']; map['Bb5']=map['A#5'];
  map['Eb3']=map['D#3']; map['Eb4']=map['D#4']; map['Eb5']=map['D#5'];
  map['Ab2']=map['G#2']; map['Ab3']=map['G#3'];
  return map;
})();

function midiNote(name) { return NOTE_FREQ[name] || 440; }

function getMusicAC() {
  return getAudio(); // reuse existing Web Audio context
}

function initMusicGain() {
  if (musicGain) return;
  const ac = getMusicAC();
  musicGain = ac.createGain();
  musicGain.gain.value = musicMuted ? 0 : 0.5;
  musicGain.connect(ac.destination);
}

function toggleMute() {
  musicMuted = !musicMuted;
  if (musicGain) musicGain.gain.value = musicMuted ? 0 : 0.5;
}

// Play a single note via Web Audio
function musicNote(freq, startTime, duration, type='triangle', gainVal=0.15, attack=0.04, release=0.1) {
  try {
    const ac = getMusicAC();
    initMusicGain();
    const osc = ac.createOscillator();
    const env = ac.createGain();
    osc.connect(env);
    env.connect(musicGain);
    osc.type = type;
    osc.frequency.value = freq;
    const end = startTime + duration;
    env.gain.setValueAtTime(0, startTime);
    env.gain.linearRampToValueAtTime(gainVal, startTime + attack);
    env.gain.setValueAtTime(gainVal, end - release);
    env.gain.linearRampToValueAtTime(0, end);
    osc.start(startTime);
    osc.stop(end + 0.05);
  } catch(e) {}
}

// Play noise burst (snare/hat)
function musicNoise(startTime, duration, gainVal=0.08, filterFreq=2000) {
  try {
    const ac = getMusicAC();
    initMusicGain();
    const bufSize = Math.floor(ac.sampleRate * duration);
    const buf = ac.createBuffer(1, bufSize, ac.sampleRate);
    const data = buf.getChannelData(0);
    for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;
    const src = ac.createBufferSource();
    src.buffer = buf;
    const filter = ac.createBiquadFilter();
    filter.type = 'highpass';
    filter.frequency.value = filterFreq;
    const env = ac.createGain();
    src.connect(filter); filter.connect(env); env.connect(musicGain);
    env.gain.setValueAtTime(gainVal, startTime);
    env.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
    src.start(startTime);
    src.stop(startTime + duration);
  } catch(e) {}
}

// Kick drum
function musicKick(startTime, gainVal=0.4) {
  try {
    const ac = getMusicAC();
    initMusicGain();
    const osc = ac.createOscillator();
    const env = ac.createGain();
    osc.connect(env); env.connect(musicGain);
    osc.type = 'sine';
    osc.frequency.setValueAtTime(150, startTime);
    osc.frequency.exponentialRampToValueAtTime(30, startTime + 0.15);
    env.gain.setValueAtTime(gainVal, startTime);
    env.gain.exponentialRampToValueAtTime(0.001, startTime + 0.25);
    osc.start(startTime); osc.stop(startTime + 0.3);
  } catch(e) {}
}

function stopMusic() {
  if (musicScheduler) { clearInterval(musicScheduler); musicScheduler = null; }
  seqState = null;
  currentTrack = null;
}

function startMusic() {
  if (musicStarted) return;
  musicStarted = true;
  try {
    const ac = getMusicAC();
    if (ac.state === 'suspended') ac.resume();
    initMusicGain();
    playMenuMusic();
  } catch(e) { musicStarted = false; }
}

// โ”€โ”€ MENU THEME โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Ambient Am โ€“ F โ€“ C โ€“ Em, slow arp, bass pulse, hi-hat shimmer
function playMenuMusic() {
  if (currentTrack === 'menu') return;
  stopMusic();
  currentTrack = 'menu';

  const BPM     = 90;
  const BEAT    = 60 / BPM;          // seconds per beat
  const BAR     = BEAT * 4;          // 4/4
  const LOOP    = BAR * 8;           // 8-bar loop

  // chord roots & notes per bar-pair
  const CHORDS = [
    ['A3','C4','E4'],  // bars 0-1: Am
    ['F3','A3','C4'],  // bars 2-3: F
    ['C3','E3','G3'],  // bars 4-5: C
    ['E3','G3','B3'],  // bars 6-7: Em
  ];
  const ARP = [
    ['A4','C5','E5','A5'],
    ['F4','A4','C5','F5'],
    ['C4','E4','G4','C5'],
    ['E4','G4','B4','E5'],
  ];
  const BASS = ['A2','F2','C2','E2'];

  const LOOKAHEAD = 0.12; // seconds ahead to schedule
  const TICK      = 50;   // ms scheduler interval

  const ac = getMusicAC();
  let nextBeat = ac.currentTime + 0.05;
  let beat = 0;
  const totalBeats = 32; // 8 bars ร— 4 beats

  musicScheduler = setInterval(() => {
    if (currentTrack !== 'menu') return;
    const now = ac.currentTime;
    while (nextBeat < now + LOOKAHEAD) {
      const bar      = Math.floor(beat / 4) % 8;
      const chordIdx = Math.floor(bar / 2);
      const beatInBar= beat % 4;

      // Pad chord โ€” on beat 0 of each chord change
      if (bar % 2 === 0 && beatInBar === 0) {
        CHORDS[chordIdx].forEach(n => {
          musicNote(midiNote(n), nextBeat, BAR * 1.8, 'triangle', 0.06, 0.5, 0.4);
        });
      }

      // Bass โ€” on beat 0 of chord change
      if (bar % 2 === 0 && beatInBar === 0) {
        musicNote(midiNote(BASS[chordIdx]), nextBeat, BAR * 1.6, 'triangle', 0.12, 0.08, 0.3);
      }

      // Hi-hat โ€” every beat
      musicNoise(nextBeat, 0.06, 0.03, 6000);

      // Arp โ€” 4 notes per beat (16th notes)
      const arpRow = ARP[chordIdx];
      for (let i = 0; i < 4; i++) {
        const t = nextBeat + i * (BEAT / 4);
        musicNote(midiNote(arpRow[i]), t, BEAT * 0.18, 'sine', 0.05, 0.01, 0.08);
      }

      beat = (beat + 1) % totalBeats;
      nextBeat += BEAT;
    }
  }, TICK);
}

// โ”€โ”€ BOSS THEME โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Panicky 178 BPM, driving kick/snare, alarm tritone, frantic lead
function playBossMusic() {
  if (currentTrack === 'boss') return;
  stopMusic();
  currentTrack = 'boss';

  const BPM      = 178;
  const BEAT     = 60 / BPM;
  const SIXTEENTH= BEAT / 4;

  // Bass line notes (16th-note grid, null = rest)
  const BASS_LINE = [
    'A1','A1', null,'C2', 'E2','D2', null,'C2',
    'A1',null, 'Bb1','A1','G1',null, 'F1','G1',
    'A1',null, 'E2','F2', 'E2','D2', null,'A1',
    'G1','A1', 'Bb1','B1',null,null, null,null,
  ];

  // Lead melody (16th-note grid)
  const LEAD_LINE = [
    'A4','C5','E5','D5', 'C5',null,'A4','G4',
    'A4','Bb4','A4','G4','F4',null,'G4','A4',
    'E5','D5','C5','B4', 'A4',null,'C5','E5',
    'F5','E5','D5','C5', 'B4','A4','G4','A4',
    'A5',null,'G5','F5', 'E5','D5','C5','B4',
    'A4',null,'G4','F4', 'E4',null,'G4','A4',
    'A4','C5','Eb5','E5','G5','F5','E5','D5',
    'C5','B4','Bb4','A4','G4',null,'A4','E4',
  ];

  // Kick pattern (16th grid, 1=kick)
  const KICK_PAT = [1,0,1,0, 0,1,0,0, 1,0,1,0, 0,1,0,0];
  // Snare pattern
  const SNARE_PAT= [0,0,0,1, 0,1,0,0, 0,0,1,0, 1,0,0,1];
  // Hat pattern (32nds packed as 16th pairs)
  const HAT_PAT  = [1,1,0,1, 1,1,0,1, 1,0,1,1, 1,0,1,1];

  const LOOKAHEAD = 0.12;
  const TICK = 30;
  const ac = getMusicAC();

  let nextStep = ac.currentTime + 0.05;
  let step = 0;
  const bassLen  = BASS_LINE.length;
  const leadLen  = LEAD_LINE.length;
  const drumLen  = KICK_PAT.length;

  musicScheduler = setInterval(() => {
    if (currentTrack !== 'boss') return;
    const now = ac.currentTime;
    while (nextStep < now + LOOKAHEAD) {
      const di = step % drumLen;
      const bi = step % bassLen;
      const li = step % leadLen;

      if (KICK_PAT[di])  musicKick(nextStep, 0.35);
      if (SNARE_PAT[di]) musicNoise(nextStep, 0.1, 0.12, 1500);
      if (HAT_PAT[di])   musicNoise(nextStep, 0.03, 0.05, 8000);

      if (BASS_LINE[bi]) {
        musicNote(midiNote(BASS_LINE[bi]), nextStep, SIXTEENTH * 0.8,
                  'sawtooth', 0.13, 0.005, 0.05);
      }

      if (LEAD_LINE[li]) {
        musicNote(midiNote(LEAD_LINE[li]), nextStep, SIXTEENTH * 0.7,
                  'sawtooth', 0.08, 0.005, 0.04);
      }

      // Alarm tritone on specific steps
      if (step % 8 === 0 && step > 15) {
        const alarmFreq = (Math.floor(step / 8) % 2 === 0) ? midiNote('A4') : midiNote('Eb5');
        musicNote(alarmFreq, nextStep, SIXTEENTH * 1.5, 'square', 0.06, 0.005, 0.05);
      }

      step++;
      nextStep += SIXTEENTH;
    }
  }, TICK);
}

// โ”€โ”€ WIN MUSIC โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function playWinMusic() {
  stopMusic();
  currentTrack = 'win';
  const ac = getMusicAC();
  initMusicGain();
  const t = ac.currentTime + 0.05;
  const BPM = 120, BEAT = 60 / BPM;

  // Triumphant major melody: C E G C B G E F A C B G E C
  const mel = ['C5','E5','G5','C6','B5','G5','E5','F5','A5','C6','B5','A5','G5','C5'];
  const dur = [1,1,1,2, 1,1,1,1, 1,2,1,1,1,3];
  let cursor = t;
  mel.forEach((n, i) => {
    musicNote(midiNote(n), cursor, dur[i]*BEAT*0.85, 'triangle', 0.14, 0.02, 0.15);
    cursor += dur[i] * BEAT;
  });

  // Harmony (thirds below)
  const harm = ['E4','G4','B4','E5','D5','B4','G4','A4','C5','E5','D5','C5','B4','E4'];
  cursor = t;
  harm.forEach((n, i) => {
    musicNote(midiNote(n), cursor, dur[i]*BEAT*0.8, 'sine', 0.07, 0.03, 0.2);
    cursor += dur[i] * BEAT;
  });

  // Bass hits
  const bass = ['C3','F3','C3','G3','C3','A3','F3','G3','C3'];
  const bdur  = [2,  2,   2,   2,   2,   2,   2,   2,   4  ];
  cursor = t;
  bass.forEach((n, i) => {
    musicNote(midiNote(n), cursor, bdur[i]*BEAT*0.7, 'triangle', 0.16, 0.04, 0.2);
    cursor += bdur[i] * BEAT;
  });

  // Tambourine
  for (let i = 0; i < 32; i++) {
    musicNoise(t + i * BEAT * 0.5, 0.06, 0.04, 5000);
  }

  // After win music ends, go back to menu music
  setTimeout(() => {
    if (currentTrack === 'win') { currentTrack = null; playMenuMusic(); }
  }, cursor - ac.currentTime + 500);
}

// โ”€โ”€ LOSE MUSIC โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function playLoseMusic() {
  stopMusic();
  currentTrack = 'lose';
  const ac = getMusicAC();
  initMusicGain();
  const t = ac.currentTime + 0.05;
  const BPM = 60, BEAT = 60 / BPM;

  // Descending minor: A G F E D C B A G F E A
  const mel = ['A4','G4','F4','E4','D4','C4','B3','A3','G3','F3','E3','A2'];
  const dur  = [1,   1,   1,   2,   1,   1,   1,   2,   1,   1,   2,   4  ];
  let cursor = t;
  mel.forEach((n, i) => {
    musicNote(midiNote(n), cursor, dur[i]*BEAT*0.85, 'sine', 0.12, 0.06, 0.4);
    cursor += dur[i] * BEAT;
  });

  // Cello harmony
  const cello = ['C3','Bb2','Ab2','G2','F2','E2'];
  const cdur   = [2,   2,    2,    2,   4,   4  ];
  cursor = t;
  cello.forEach((n, i) => {
    musicNote(midiNote(n), cursor, cdur[i]*BEAT*0.9, 'sawtooth', 0.06, 0.12, 0.5);
    cursor += cdur[i] * BEAT;
  });

  // Pad chords
  [['A2','C3','E3'],['D3','F3','A3'],['E2','G2','B2'],['A2','C3','E3']].forEach((ch, i) => {
    ch.forEach(n => musicNote(midiNote(n), t + i*4*BEAT, 3.8*BEAT, 'triangle', 0.04, 0.35, 0.5));
  });

  // Sparse kick
  for (let i = 0; i < 4; i++) {
    musicKick(t + i * 4 * BEAT, 0.25);
  }
}

// โ”€โ”€ MUSIC TRANSITIONS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function updateMusic() {
  if (!musicStarted) return;
  if (currentTrack === 'win' || currentTrack === 'lose') return;
  if (state && (state.phase === 'gameover' || state.phase === 'win' || state.phase === 'bossdie')) return;
  if (inMenu || (state && state.level < 6)) {
    if (currentTrack !== 'menu') playMenuMusic();
  } else if (state && state.level === 6) {
    if (currentTrack !== 'boss') playBossMusic();
  }
}

// โ”€โ”€ START โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
initStars();
state = freshState(1);
canvas.focus();
requestAnimationFrame(loop);
</script>
</body>
</html>

Game Source: Star Blaster

Creator: MagicStar57

Libraries: none

Complexity: complex (1708 lines, 56.9 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: star-blaster-magicstar57" to link back to the original. Then publish at arcadelab.ai/publish.