← back to Games Agentabrams

games/invaders/NeonInvaders.aa

892 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>NEON INVADERS</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body {
    width: 100%; height: 100%;
    background: #05070c;
    overflow: hidden;
    touch-action: none;
    -webkit-user-select: none; user-select: none;
    -webkit-tap-highlight-color: transparent;
    font-family: "Courier New", ui-monospace, monospace;
  }
  #wrap {
    position: fixed; inset: 0;
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    background:
      radial-gradient(ellipse at 50% 30%, #101a2b 0%, #0a0e14 55%, #05070c 100%);
  }
  #title {
    color: #39ff14;
    font-weight: bold;
    letter-spacing: 0.42em;
    text-transform: uppercase;
    font-size: clamp(14px, 3.6vw, 26px);
    text-shadow: 0 0 8px #39ff14, 0 0 18px rgba(57,255,20,.5);
    padding: 8px 0 6px;
    text-align: center;
  }
  #stage {
    position: relative;
    border: 1px solid rgba(57,255,20,.28);
    box-shadow: 0 0 22px rgba(57,255,20,.15), inset 0 0 40px rgba(10,20,40,.6);
    background: #060a12;
    border-radius: 6px;
    overflow: hidden;
  }
  canvas { display: block; touch-action: none; }
  #touch {
    position: absolute; inset: 0;
    display: none;
    pointer-events: none;
  }
  .tbtn {
    position: absolute;
    pointer-events: auto;
    display: flex; align-items: center; justify-content: center;
    color: #7ffcff;
    border: 1px solid rgba(127,252,255,.4);
    background: rgba(10,20,35,.35);
    border-radius: 12px;
    font-size: 26px; font-weight: bold;
    text-shadow: 0 0 6px #7ffcff;
    backdrop-filter: blur(2px);
    user-select: none;
  }
  .tbtn:active { background: rgba(127,252,255,.22); }
  #bLeft  { left: 12px;  bottom: 14px; width: 62px; height: 62px; }
  #bRight { left: 88px;  bottom: 14px; width: 62px; height: 62px; }
  #bFire  { right: 14px; bottom: 14px; width: 78px; height: 78px;
            color:#ff4fd8; border-color:rgba(255,79,216,.5); text-shadow:0 0 6px #ff4fd8; }
  #hint {
    color: rgba(160,200,220,.55);
    font-size: clamp(9px, 1.9vw, 12px);
    letter-spacing: .12em;
    padding: 8px 6px 10px;
    text-align: center;
  }
</style>
</head>
<body>
  <div id="wrap">
    <div id="title">Neon Invaders</div>
    <div id="stage">
      <canvas id="c"></canvas>
      <div id="touch">
        <div class="tbtn" id="bLeft">&#9664;</div>
        <div class="tbtn" id="bRight">&#9654;</div>
        <div class="tbtn" id="bFire">FIRE</div>
      </div>
    </div>
    <div id="hint">&larr; &rarr; / A D MOVE &nbsp;&bull;&nbsp; SPACE FIRE &nbsp;&bull;&nbsp; P PAUSE</div>
  </div>

<script>
(function () {
  "use strict";

  var BEST_KEY = "abrams-invaders-best";

  function boot() {
    var canvas = document.getElementById("c");
    var ctx = canvas.getContext("2d");
    var stage = document.getElementById("stage");
    var touchLayer = document.getElementById("touch");

    // Logical resolution (game world). Canvas is scaled to fit.
    var W = 480, H = 640;
    var DPR = 1;

    function fitCanvas() {
      // Available space inside the wrap, minus title/hint chrome.
      var availW = Math.min(window.innerWidth - 12, 520);
      var availH = window.innerHeight - 120;
      if (availW < 200) availW = 200;
      if (availH < 300) availH = 300;
      var scale = Math.min(availW / W, availH / H);
      if (!isFinite(scale) || scale <= 0) scale = 1;
      var cssW = Math.round(W * scale);
      var cssH = Math.round(H * scale);
      DPR = Math.min(window.devicePixelRatio || 1, 2);
      canvas.style.width = cssW + "px";
      canvas.style.height = cssH + "px";
      canvas.width = Math.round(W * DPR);
      canvas.height = Math.round(H * DPR);
      stage.style.width = cssW + "px";
      stage.style.height = cssH + "px";
      ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    }

    // ---- Audio (lazy) ----
    var actx = null, muted = false;
    function ensureAudio() {
      if (actx || muted) return;
      try {
        var AC = window.AudioContext || window.webkitAudioContext;
        if (!AC) { muted = true; return; }
        actx = new AC();
      } catch (e) { muted = true; actx = null; }
    }
    function blip(type, freq, dur, vol) {
      if (!actx) return;
      try {
        if (actx.state === "suspended") { actx.resume().catch(function(){}); }
        var o = actx.createOscillator();
        var g = actx.createGain();
        o.type = type;
        o.frequency.value = freq;
        var t = actx.currentTime;
        g.gain.setValueAtTime(0.0001, t);
        g.gain.exponentialRampToValueAtTime(vol, t + 0.008);
        g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
        o.connect(g); g.connect(actx.destination);
        o.start(t); o.stop(t + dur + 0.02);
      } catch (e) { /* never throw from audio */ }
    }
    function sShoot()  { blip("square", 880, 0.09, 0.05); }
    function sHit()    { blip("triangle", 320, 0.10, 0.06); }
    function sBoom()   { blip("sawtooth", 120, 0.32, 0.08); }
    function sUfo()    { blip("sine", 1200, 0.14, 0.05); }
    function sStep(i)  { blip("square", 200 + (i % 4) * 30, 0.05, 0.03); }

    // ---- Game state ----
    var STATE = { MENU: 0, PLAY: 1, WAVE: 2, OVER: 3, PAUSED: 4 };
    var state = STATE.MENU;

    var best = 0;
    try {
      var stored = localStorage.getItem(BEST_KEY);
      if (stored != null) { var n = parseInt(stored, 10); if (!isNaN(n)) best = n; }
    } catch (e) { best = 0; }

    var score = 0, lives = 3, wave = 1;

    var player, bullets, bombs, invaders, bunkers, ufo, particles;
    var invDir, invStepTimer, invStepInterval, invDropPending;
    var fireCooldown, ufoTimer, stepIndex;
    var flashTimer = 0;

    var keys = {};
    var touch = { left: false, right: false, fire: false };

    // ---- Entity setup ----
    function makePlayer() {
      return { x: W / 2, y: H - 46, w: 40, h: 18, speed: 260, cool: 0 };
    }

    function makeBunkers() {
      var arr = [];
      var count = 4;
      var bw = 58, bh = 40;
      var gap = (W - count * bw) / (count + 1);
      var by = H - 130;
      for (var b = 0; b < count; b++) {
        var bx = gap + b * (bw + gap);
        var cellsX = 7, cellsY = 5;
        var cw = bw / cellsX, ch = bh / cellsY;
        var cells = [];
        for (var yy = 0; yy < cellsY; yy++) {
          for (var xx = 0; xx < cellsX; xx++) {
            // carve an arch shape
            var isNotch = (yy >= 3) && (xx >= 2 && xx <= 4);
            if (isNotch) continue;
            cells.push({
              x: bx + xx * cw, y: by + yy * ch,
              w: cw, h: ch, hp: 3
            });
          }
        }
        arr.push({ cells: cells });
      }
      return arr;
    }

    function buildInvaders() {
      invaders = [];
      var cols = 8, rows = 5;
      var cellW = 44, cellH = 34;
      var gridW = cols * cellW;
      var startX = (W - gridW) / 2 + cellW / 2;
      var startY = 70;
      for (var r = 0; r < rows; r++) {
        for (var col = 0; col < cols; col++) {
          var type = r === 0 ? 2 : (r < 3 ? 1 : 0);
          invaders.push({
            x: startX + col * cellW,
            y: startY + r * cellH,
            w: 28, h: 20,
            type: type,
            alive: true,
            row: r, col: col
          });
        }
      }
      invDir = 1;
      invStepTimer = 0;
      invStepInterval = Math.max(0.18, 0.75 - (wave - 1) * 0.06);
      invDropPending = false;
      stepIndex = 0;
    }

    function startGame() {
      score = 0; lives = 3; wave = 1;
      player = makePlayer();
      bullets = []; bombs = []; particles = [];
      bunkers = makeBunkers();
      ufo = null;
      fireCooldown = 0;
      ufoTimer = rand(8, 16);
      buildInvaders();
      state = STATE.PLAY;
    }

    function nextWave() {
      wave++;
      player.x = W / 2;
      bullets = []; bombs = [];
      bunkers = makeBunkers();
      ufo = null;
      ufoTimer = rand(8, 16);
      buildInvaders();
      state = STATE.PLAY;
      flashTimer = 0.6;
    }

    function rand(a, b) { return a + Math.random() * (b - a); }

    // ---- Particles ----
    function burst(x, y, color, n) {
      for (var i = 0; i < n; i++) {
        var a = Math.random() * Math.PI * 2;
        var sp = rand(30, 160);
        particles.push({
          x: x, y: y,
          vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
          life: rand(0.3, 0.7), max: 0.7, color: color
        });
      }
    }

    // ---- Collision ----
    function overlap(ax, ay, aw, ah, bx, by, bw, bh) {
      return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
    }

    // ---- Update ----
    function update(dt) {
      if (state !== STATE.PLAY) {
        // still animate particles on OVER for a moment
        updateParticles(dt);
        return;
      }

      if (flashTimer > 0) flashTimer -= dt;

      // player movement
      var mv = 0;
      if (keys.left || touch.left) mv -= 1;
      if (keys.right || touch.right) mv += 1;
      player.x += mv * player.speed * dt;
      var half = player.w / 2;
      if (player.x < half) player.x = half;
      if (player.x > W - half) player.x = W - half;

      // firing
      if (player.cool > 0) player.cool -= dt;
      if ((keys.fire || touch.fire) && player.cool <= 0) {
        bullets.push({ x: player.x, y: player.y - 12, w: 3, h: 14, vy: -520 });
        player.cool = 0.34;
        sShoot();
      }

      // bullets
      for (var i = bullets.length - 1; i >= 0; i--) {
        var bl = bullets[i];
        bl.y += bl.vy * dt;
        if (bl.y + bl.h < 0) { bullets.splice(i, 1); continue; }
      }

      // invader movement (grid step)
      updateInvaders(dt);

      // bombs (from invaders)
      for (var j = bombs.length - 1; j >= 0; j--) {
        var bo = bombs[j];
        bo.y += bo.vy * dt;
        bo.t = (bo.t || 0) + dt;
        if (bo.y > H) { bombs.splice(j, 1); continue; }
      }

      // UFO
      updateUfo(dt);

      // collisions
      handleCollisions();

      updateParticles(dt);

      // win condition
      var anyAlive = false;
      for (var k = 0; k < invaders.length; k++) { if (invaders[k].alive) { anyAlive = true; break; } }
      if (!anyAlive) {
        state = STATE.WAVE;
        waveDelay = 1.4;
      }
    }

    var waveDelay = 0;

    function updateInvaders(dt) {
      var aliveList = [];
      var minX = 1e9, maxX = -1e9, maxY = -1e9;
      for (var i = 0; i < invaders.length; i++) {
        var inv = invaders[i];
        if (!inv.alive) continue;
        aliveList.push(inv);
        if (inv.x - inv.w / 2 < minX) minX = inv.x - inv.w / 2;
        if (inv.x + inv.w / 2 > maxX) maxX = inv.x + inv.w / 2;
        if (inv.y + inv.h / 2 > maxY) maxY = inv.y + inv.h / 2;
      }
      if (aliveList.length === 0) return;

      // Speed scales up as fewer remain.
      var frac = aliveList.length / invaders.length;
      var interval = invStepInterval * (0.35 + 0.65 * frac);
      invStepTimer += dt;
      if (invStepTimer >= interval) {
        invStepTimer = 0;
        stepIndex++;
        sStep(stepIndex);

        var stepX = 12;
        var willHit = (invDir > 0 && maxX + stepX >= W - 6) ||
                      (invDir < 0 && minX - stepX <= 6);
        if (willHit) {
          invDir *= -1;
          for (var d = 0; d < aliveList.length; d++) aliveList[d].y += 18;
        } else {
          for (var m = 0; m < aliveList.length; m++) aliveList[m].x += stepX * invDir;
        }

        // reached player level -> game over
        for (var q = 0; q < aliveList.length; q++) {
          if (aliveList[q].y + aliveList[q].h / 2 >= player.y - 6) {
            loseAllLives();
            return;
          }
        }
      }

      // random bomb drops from bottom-most invaders per column
      var dropChance = (0.35 + wave * 0.12) * dt;
      if (Math.random() < dropChance && aliveList.length) {
        // find a random column's lowest alive invader
        var shooter = aliveList[(Math.random() * aliveList.length) | 0];
        // ensure it's the lowest in its column
        var lowest = shooter;
        for (var c = 0; c < aliveList.length; c++) {
          if (aliveList[c].col === shooter.col && aliveList[c].y > lowest.y) lowest = aliveList[c];
        }
        bombs.push({ x: lowest.x, y: lowest.y + 12, w: 5, h: 12, vy: rand(170, 210) + wave * 8, t: 0 });
      }
    }

    function updateUfo(dt) {
      if (ufo) {
        ufo.x += ufo.vx * dt;
        if ((ufo.vx > 0 && ufo.x > W + 40) || (ufo.vx < 0 && ufo.x < -40)) {
          ufo = null;
        }
      } else {
        ufoTimer -= dt;
        if (ufoTimer <= 0) {
          var fromLeft = Math.random() < 0.5;
          ufo = {
            x: fromLeft ? -30 : W + 30,
            y: 42, w: 40, h: 16,
            vx: fromLeft ? 90 : -90,
            value: [50, 100, 150, 300][(Math.random() * 4) | 0]
          };
          ufoTimer = rand(12, 22);
        }
      }
    }

    function updateParticles(dt) {
      for (var i = particles.length - 1; i >= 0; i--) {
        var p = particles[i];
        p.life -= dt;
        if (p.life <= 0) { particles.splice(i, 1); continue; }
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.vy += 120 * dt;
      }
    }

    function damageBunkerAt(x, y, w, h) {
      for (var b = 0; b < bunkers.length; b++) {
        var cells = bunkers[b].cells;
        for (var i = cells.length - 1; i >= 0; i--) {
          var cl = cells[i];
          if (overlap(x, y, w, h, cl.x, cl.y, cl.w, cl.h)) {
            cl.hp--;
            burst(cl.x + cl.w / 2, cl.y + cl.h / 2, "#39ff14", 4);
            if (cl.hp <= 0) cells.splice(i, 1);
            return true;
          }
        }
      }
      return false;
    }

    function handleCollisions() {
      // player bullets
      for (var i = bullets.length - 1; i >= 0; i--) {
        var bl = bullets[i];
        var bx = bl.x - bl.w / 2, by = bl.y;

        // bunkers
        if (damageBunkerAt(bx, by, bl.w, bl.h)) { bullets.splice(i, 1); continue; }

        // ufo
        if (ufo && overlap(bx, by, bl.w, bl.h, ufo.x - ufo.w / 2, ufo.y - ufo.h / 2, ufo.w, ufo.h)) {
          addScore(ufo.value);
          burst(ufo.x, ufo.y, "#ff4fd8", 22);
          sUfo();
          ufo = null;
          bullets.splice(i, 1);
          continue;
        }

        // invaders
        var hitInv = false;
        for (var k = 0; k < invaders.length; k++) {
          var inv = invaders[k];
          if (!inv.alive) continue;
          if (overlap(bx, by, bl.w, bl.h, inv.x - inv.w / 2, inv.y - inv.h / 2, inv.w, inv.h)) {
            inv.alive = false;
            addScore((inv.type + 1) * 10);
            burst(inv.x, inv.y, invColor(inv.type), 16);
            sHit();
            hitInv = true;
            break;
          }
        }
        if (hitInv) { bullets.splice(i, 1); continue; }
      }

      // enemy bombs
      for (var j = bombs.length - 1; j >= 0; j--) {
        var bo = bombs[j];
        var ox = bo.x - bo.w / 2, oy = bo.y;

        if (damageBunkerAt(ox, oy, bo.w, bo.h)) { bombs.splice(j, 1); continue; }

        if (overlap(ox, oy, bo.w, bo.h, player.x - player.w / 2, player.y - player.h / 2, player.w, player.h)) {
          bombs.splice(j, 1);
          playerHit();
          continue;
        }
      }
    }

    function invColor(type) {
      return type === 2 ? "#ff4fd8" : (type === 1 ? "#7ffcff" : "#39ff14");
    }

    function addScore(v) {
      score += v;
      if (score > best) {
        best = score;
        try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) { /* ignore */ }
      }
    }

    function playerHit() {
      lives--;
      burst(player.x, player.y, "#ffd23f", 26);
      sBoom();
      flashTimer = 0.35;
      if (lives <= 0) {
        gameOver();
      } else {
        player.x = W / 2;
        player.cool = 0.6;
      }
    }

    function loseAllLives() {
      lives = 0;
      burst(player.x, player.y, "#ffd23f", 30);
      sBoom();
      gameOver();
    }

    function gameOver() {
      state = STATE.OVER;
    }

    // ---- Render ----
    function draw() {
      ctx.clearRect(0, 0, W, H);

      // starfield backdrop
      drawStars();

      if (flashTimer > 0 && (state === STATE.PLAY || state === STATE.WAVE)) {
        ctx.fillStyle = "rgba(255,210,63," + Math.min(0.25, flashTimer * 0.4) + ")";
        ctx.fillRect(0, 0, W, H);
      }

      if (state === STATE.MENU) {
        drawMenu();
        drawParticles();
        return;
      }

      // bunkers
      for (var b = 0; b < bunkers.length; b++) {
        var cells = bunkers[b].cells;
        for (var i = 0; i < cells.length; i++) {
          var cl = cells[i];
          var a = cl.hp / 3;
          ctx.fillStyle = "rgba(57,255,20," + (0.35 + a * 0.55) + ")";
          ctx.fillRect(cl.x + 0.5, cl.y + 0.5, cl.w - 1, cl.h - 1);
        }
      }

      // invaders
      for (var k = 0; k < invaders.length; k++) {
        if (invaders[k].alive) drawInvader(invaders[k]);
      }

      // ufo
      if (ufo) drawUfo(ufo);

      // bullets
      ctx.save();
      ctx.shadowBlur = 8; ctx.shadowColor = "#ffffff";
      ctx.fillStyle = "#ffffff";
      for (var m = 0; m < bullets.length; m++) {
        var bl = bullets[m];
        ctx.fillRect(bl.x - bl.w / 2, bl.y, bl.w, bl.h);
      }
      ctx.restore();

      // bombs
      ctx.save();
      ctx.shadowBlur = 8; ctx.shadowColor = "#ff4fd8";
      for (var n = 0; n < bombs.length; n++) {
        var bo = bombs[n];
        ctx.fillStyle = ((bo.t * 20) | 0) % 2 ? "#ff4fd8" : "#ffb0ec";
        ctx.fillRect(bo.x - bo.w / 2, bo.y, bo.w, bo.h);
      }
      ctx.restore();

      // player
      if (state === STATE.PLAY || state === STATE.WAVE || state === STATE.PAUSED) drawPlayer();

      drawParticles();
      drawHUD();

      if (state === STATE.OVER) drawGameOver();
      if (state === STATE.PAUSED) drawPaused();
      if (state === STATE.WAVE) drawWaveBanner();
    }

    var stars = [];
    function initStars() {
      stars = [];
      for (var i = 0; i < 60; i++) {
        stars.push({ x: Math.random() * W, y: Math.random() * H, s: Math.random() * 1.6 + 0.4, t: Math.random() * 6.28 });
      }
    }
    function drawStars() {
      for (var i = 0; i < stars.length; i++) {
        var st = stars[i];
        var tw = 0.4 + 0.6 * Math.abs(Math.sin(st.t + performance.now() / 900));
        ctx.fillStyle = "rgba(120,180,220," + (tw * 0.5) + ")";
        ctx.fillRect(st.x, st.y, st.s, st.s);
      }
    }

    function drawPlayer() {
      var px = player.x, py = player.y, w = player.w, h = player.h;
      ctx.save();
      ctx.shadowBlur = 12; ctx.shadowColor = "#39ff14";
      ctx.fillStyle = "#39ff14";
      // cannon body
      ctx.beginPath();
      ctx.moveTo(px - w / 2, py + h / 2);
      ctx.lineTo(px + w / 2, py + h / 2);
      ctx.lineTo(px + w / 2, py + h / 2 - 6);
      ctx.lineTo(px + 8, py + h / 2 - 6);
      ctx.lineTo(px + 4, py - h / 2 + 2);
      ctx.lineTo(px - 4, py - h / 2 + 2);
      ctx.lineTo(px - 8, py + h / 2 - 6);
      ctx.lineTo(px - w / 2, py + h / 2 - 6);
      ctx.closePath();
      ctx.fill();
      ctx.fillRect(px - 2, py - h / 2 - 4, 4, 6);
      ctx.restore();
    }

    function drawInvader(inv) {
      var x = inv.x - inv.w / 2, y = inv.y - inv.h / 2, w = inv.w, h = inv.h;
      var col = invColor(inv.type);
      var frame = stepIndex % 2;
      ctx.save();
      ctx.shadowBlur = 8; ctx.shadowColor = col;
      ctx.fillStyle = col;
      var px = w / 8, py = h / 5;
      // simple 8x5 sprite bitmaps
      var A = [
        [0,0,1,0,0,1,0,0],
        [0,1,1,1,1,1,1,0],
        [1,1,0,1,1,0,1,1],
        [1,1,1,1,1,1,1,1],
        frame ? [0,1,0,0,0,0,1,0] : [1,0,1,0,0,1,0,1]
      ];
      for (var ry = 0; ry < 5; ry++) {
        for (var rx = 0; rx < 8; rx++) {
          if (A[ry][rx]) ctx.fillRect(x + rx * px, y + ry * py, px + 0.5, py + 0.5);
        }
      }
      ctx.restore();
    }

    function drawUfo(u) {
      ctx.save();
      ctx.shadowBlur = 12; ctx.shadowColor = "#ff4fd8";
      ctx.fillStyle = "#ff4fd8";
      ctx.beginPath();
      ctx.ellipse(u.x, u.y, u.w / 2, u.h / 2, 0, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = "#ffd23f";
      ctx.beginPath();
      ctx.ellipse(u.x, u.y - 3, u.w / 4, u.h / 3, 0, Math.PI, 0);
      ctx.fill();
      ctx.restore();
    }

    function drawParticles() {
      for (var i = 0; i < particles.length; i++) {
        var p = particles[i];
        var a = Math.max(0, p.life / p.max);
        ctx.globalAlpha = a;
        ctx.fillStyle = p.color;
        ctx.fillRect(p.x - 1.5, p.y - 1.5, 3, 3);
      }
      ctx.globalAlpha = 1;
    }

    function drawHUD() {
      ctx.save();
      ctx.font = "bold 15px 'Courier New', monospace";
      ctx.textBaseline = "top";
      ctx.fillStyle = "#39ff14";
      ctx.shadowBlur = 6; ctx.shadowColor = "#39ff14";
      ctx.textAlign = "left";
      ctx.fillText("SCORE " + pad(score, 5), 12, 10);
      ctx.textAlign = "center";
      ctx.fillStyle = "#7ffcff"; ctx.shadowColor = "#7ffcff";
      ctx.fillText("WAVE " + wave, W / 2, 10);
      ctx.textAlign = "right";
      ctx.fillStyle = "#ffd23f"; ctx.shadowColor = "#ffd23f";
      ctx.fillText("BEST " + pad(best, 5), W - 12, 10);
      ctx.restore();

      // lives
      for (var i = 0; i < lives; i++) {
        var lx = 20 + i * 26, ly = H - 22;
        ctx.save();
        ctx.shadowBlur = 6; ctx.shadowColor = "#39ff14";
        ctx.fillStyle = "#39ff14";
        ctx.beginPath();
        ctx.moveTo(lx - 8, ly + 6);
        ctx.lineTo(lx + 8, ly + 6);
        ctx.lineTo(lx + 4, ly - 4);
        ctx.lineTo(lx - 4, ly - 4);
        ctx.closePath();
        ctx.fill();
        ctx.fillRect(lx - 1, ly - 8, 2, 4);
        ctx.restore();
      }
    }

    function drawMenu() {
      centerText("NEON INVADERS", W / 2, H / 2 - 70, "bold 34px 'Courier New', monospace", "#39ff14", 8, 0.12);
      centerText("DEFEND THE GRID", W / 2, H / 2 - 30, "14px 'Courier New', monospace", "#7ffcff", 4, 0.3);
      var blink = (Math.floor(performance.now() / 500) % 2) === 0;
      if (blink) centerText("PRESS SPACE / TAP TO START", W / 2, H / 2 + 30, "bold 15px 'Courier New', monospace", "#ffd23f", 6, 0.1);
      centerText("BEST  " + pad(best, 5), W / 2, H / 2 + 80, "14px 'Courier New', monospace", "#ff4fd8", 4, 0.2);
    }

    function drawGameOver() {
      ctx.fillStyle = "rgba(5,7,12,.6)";
      ctx.fillRect(0, 0, W, H);
      centerText("GAME OVER", W / 2, H / 2 - 50, "bold 34px 'Courier New', monospace", "#ff4fd8", 10, 0.1);
      centerText("SCORE  " + pad(score, 5), W / 2, H / 2, "16px 'Courier New', monospace", "#39ff14", 5, 0.15);
      centerText("BEST   " + pad(best, 5), W / 2, H / 2 + 26, "16px 'Courier New', monospace", "#ffd23f", 5, 0.15);
      var blink = (Math.floor(performance.now() / 500) % 2) === 0;
      if (blink) centerText("PRESS SPACE / TAP TO RESTART", W / 2, H / 2 + 70, "bold 14px 'Courier New', monospace", "#7ffcff", 5, 0.08);
    }

    function drawPaused() {
      ctx.fillStyle = "rgba(5,7,12,.55)";
      ctx.fillRect(0, 0, W, H);
      centerText("PAUSED", W / 2, H / 2 - 10, "bold 30px 'Courier New', monospace", "#7ffcff", 8, 0.2);
      centerText("PRESS P TO RESUME", W / 2, H / 2 + 30, "13px 'Courier New', monospace", "#ffd23f", 4, 0.1);
    }

    function drawWaveBanner() {
      centerText("WAVE " + wave + " CLEARED", W / 2, H / 2 - 10, "bold 26px 'Courier New', monospace", "#39ff14", 8, 0.14);
    }

    function centerText(txt, x, y, font, color, glow, spacing) {
      ctx.save();
      ctx.font = font;
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.shadowBlur = glow || 0;
      ctx.shadowColor = color;
      ctx.fillStyle = color;
      if (spacing) {
        // manual letter spacing
        var total = 0, i;
        var widths = [];
        for (i = 0; i < txt.length; i++) {
          var wch = ctx.measureText(txt[i]).width + spacing * 20;
          widths.push(wch); total += wch;
        }
        var cx = x - total / 2;
        ctx.textAlign = "left";
        for (i = 0; i < txt.length; i++) {
          ctx.fillText(txt[i], cx, y);
          cx += widths[i];
        }
      } else {
        ctx.fillText(txt, x, y);
      }
      ctx.restore();
    }

    function pad(n, len) {
      var s = String(Math.max(0, n | 0));
      while (s.length < len) s = "0" + s;
      return s;
    }

    // ---- Input ----
    function onKeyDown(e) {
      var k = e.key;
      if (k === "ArrowLeft" || k === "a" || k === "A") { keys.left = true; e.preventDefault(); }
      else if (k === "ArrowRight" || k === "d" || k === "D") { keys.right = true; e.preventDefault(); }
      else if (k === " " || k === "Spacebar" || k === "ArrowUp") {
        keys.fire = true; e.preventDefault();
        ensureAudio();
        if (state === STATE.MENU || state === STATE.OVER) startGame();
      }
      else if (k === "p" || k === "P") {
        if (state === STATE.PLAY) state = STATE.PAUSED;
        else if (state === STATE.PAUSED) state = STATE.PLAY;
      }
      else if (k === "m" || k === "M") { muted = !muted; }
    }
    function onKeyUp(e) {
      var k = e.key;
      if (k === "ArrowLeft" || k === "a" || k === "A") keys.left = false;
      else if (k === "ArrowRight" || k === "d" || k === "D") keys.right = false;
      else if (k === " " || k === "Spacebar" || k === "ArrowUp") keys.fire = false;
    }

    window.addEventListener("keydown", onKeyDown, { passive: false });
    window.addEventListener("keyup", onKeyUp);

    // Touch controls
    function bindTouch(el, key) {
      function down(ev) {
        ev.preventDefault();
        ensureAudio();
        touch[key] = true;
        if (key === "fire" && (state === STATE.MENU || state === STATE.OVER)) startGame();
      }
      function up(ev) { ev.preventDefault(); touch[key] = false; }
      el.addEventListener("touchstart", down, { passive: false });
      el.addEventListener("touchend", up, { passive: false });
      el.addEventListener("touchcancel", up, { passive: false });
      el.addEventListener("mousedown", down);
      el.addEventListener("mouseup", up);
      el.addEventListener("mouseleave", up);
    }
    bindTouch(document.getElementById("bLeft"), "left");
    bindTouch(document.getElementById("bRight"), "right");
    bindTouch(document.getElementById("bFire"), "fire");

    // Tap on canvas also starts / fires on touch devices
    canvas.addEventListener("touchstart", function (ev) {
      ev.preventDefault();
      ensureAudio();
      if (state === STATE.MENU || state === STATE.OVER) startGame();
    }, { passive: false });
    canvas.addEventListener("mousedown", function () {
      ensureAudio();
      if (state === STATE.MENU || state === STATE.OVER) startGame();
    });

    // Show touch controls when a touch is detected
    function enableTouchUI() {
      touchLayer.style.display = "block";
      window.removeEventListener("touchstart", enableTouchUI);
    }
    window.addEventListener("touchstart", enableTouchUI, { passive: true });
    if (("ontouchstart" in window) || (navigator.maxTouchPoints > 0)) {
      touchLayer.style.display = "block";
    }

    window.addEventListener("resize", fitCanvas);

    // ---- Loop ----
    var last = performance.now();
    function frame(now) {
      var dt = (now - last) / 1000;
      last = now;
      if (dt > 0.05) dt = 0.05; // clamp for tab-switch spikes

      if (state === STATE.WAVE) {
        updateParticles(dt);
        waveDelay -= dt;
        if (waveDelay <= 0) nextWave();
      } else {
        update(dt);
      }

      draw();
      requestAnimationFrame(frame);
    }

    // ---- Init ----
    initStars();
    fitCanvas();
    // initialize placeholder arrays for menu particle draws
    particles = [];
    bullets = []; bombs = []; invaders = []; bunkers = []; player = makePlayer();
    requestAnimationFrame(frame);
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", boot);
  } else {
    boot();
  }
})();
</script>
</body>
</html>