← back to Games Agentabrams

games/missile/MissileCommand.aa

887 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>MISSILE COMMAND</title>
<style>
  html, body {
    margin: 0;
    padding: 0;
    width: 100%;
    height: 100%;
    background: #0a0e14;
    overflow: hidden;
    touch-action: none;
    -webkit-user-select: none;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
  }
  #wrap {
    position: fixed;
    inset: 0;
    display: block;
    background: #0a0e14;
  }
  canvas {
    display: block;
    width: 100%;
    height: 100%;
    background: #0a0e14;
    cursor: crosshair;
  }
</style>
</head>
<body>
<div id="wrap">
  <canvas id="game"></canvas>
</div>
<script>
(function () {
  "use strict";

  function boot() {
    var canvas = document.getElementById("game");
    if (!canvas || !canvas.getContext) return;
    var ctx = canvas.getContext("2d");
    if (!ctx) return;

    var BEST_KEY = "abrams-missile-best";
    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; }

    // Logical design resolution; we scale to fit the iframe.
    var W = 900, H = 600;
    var dpr = 1;

    function resize() {
      var cw = window.innerWidth || document.documentElement.clientWidth || W;
      var ch = window.innerHeight || document.documentElement.clientHeight || H;
      dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
      canvas.width = Math.max(1, Math.floor(cw * dpr));
      canvas.height = Math.max(1, Math.floor(ch * dpr));
      canvas.style.width = cw + "px";
      canvas.style.height = ch + "px";
      W = cw;
      H = ch;
    }

    window.addEventListener("resize", resize, { passive: true });
    resize();

    // ---- Audio (lazy) ----
    var audioCtx = null;
    var audioOK = true;
    function ensureAudio() {
      if (audioCtx || !audioOK) return;
      try {
        var AC = window.AudioContext || window.webkitAudioContext;
        if (!AC) { audioOK = false; return; }
        audioCtx = new AC();
      } catch (e) { audioOK = false; audioCtx = null; }
    }
    function blip(type, freq, dur, vol) {
      if (!audioOK) return;
      try {
        ensureAudio();
        if (!audioCtx) return;
        if (audioCtx.state === "suspended" && audioCtx.resume) { audioCtx.resume(); }
        var o = audioCtx.createOscillator();
        var g = audioCtx.createGain();
        o.type = type;
        o.frequency.setValueAtTime(freq, audioCtx.currentTime);
        g.gain.setValueAtTime(vol, audioCtx.currentTime);
        g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
        o.connect(g);
        g.connect(audioCtx.destination);
        o.start();
        o.stop(audioCtx.currentTime + dur + 0.02);
      } catch (e) { /* never throw */ }
    }
    function sfxLaunch() { blip("square", 660, 0.12, 0.06); }
    function sfxExplode() {
      blip("sawtooth", 180, 0.30, 0.09);
      blip("triangle", 90, 0.35, 0.06);
    }
    function sfxCityHit() { blip("sawtooth", 70, 0.5, 0.12); }

    // ---- Game state ----
    var STATE_START = 0, STATE_PLAY = 1, STATE_WAVEEND = 2, STATE_OVER = 3;
    var state = STATE_START;

    var cities = [];      // {x, alive}
    var bases = [];       // {x, ammo, maxAmmo}
    var enemies = [];     // incoming missiles
    var friendlies = [];  // counter-missiles
    var blasts = [];      // explosions
    var particles = [];

    var wave = 1;
    var score = 0;
    var waveEnemiesTotal = 0;
    var waveEnemiesSpawned = 0;
    var spawnTimer = 0;
    var spawnInterval = 1.2;
    var waveEndTimer = 0;
    var bonusStep = 0;
    var bonusCityIndex = 0;
    var bonusAmmoBase = 0;
    var bonusAmmoLeft = 0;
    var flashTimer = 0;

    var GROUND_H = 70; // reserved from bottom in logical coords (relative to H)

    function groundY() { return H - 64; }

    function cityBaseCount() { return 6; }

    function setupField() {
      cities = [];
      bases = [];
      var n = cityBaseCount();
      // 3 bases + 6 cities arranged: base, 2 cities, base, 2 cities, base? classic: bases at far L, mid, far R
      // We'll spread bases and cities across width.
      var margin = W * 0.06;
      var usable = W - margin * 2;
      // base positions: left, center, right
      var basePositions = [0.5 / 10, 5 / 10, 9.5 / 10];
      // city positions between bases
      var cityFractions = [1.7 / 10, 2.6 / 10, 3.5 / 10, 6.5 / 10, 7.4 / 10, 8.3 / 10];
      for (var i = 0; i < basePositions.length; i++) {
        bases.push({ x: margin + basePositions[i] * usable, ammo: 10, maxAmmo: 10 });
      }
      for (var j = 0; j < cityFractions.length; j++) {
        cities.push({ x: margin + cityFractions[j] * usable, alive: true });
      }
    }

    function repositionField() {
      // Recompute x positions on resize while preserving alive/ammo.
      var margin = W * 0.06;
      var usable = W - margin * 2;
      var basePositions = [0.5 / 10, 5 / 10, 9.5 / 10];
      var cityFractions = [1.7 / 10, 2.6 / 10, 3.5 / 10, 6.5 / 10, 7.4 / 10, 8.3 / 10];
      for (var i = 0; i < bases.length && i < basePositions.length; i++) {
        bases[i].x = margin + basePositions[i] * usable;
      }
      for (var j = 0; j < cities.length && j < cityFractions.length; j++) {
        cities[j].x = margin + cityFractions[j] * usable;
      }
    }

    function startWave(w) {
      wave = w;
      enemies = [];
      friendlies = [];
      blasts = [];
      particles = [];
      waveEnemiesTotal = 8 + Math.floor(w * 2.5);
      waveEnemiesSpawned = 0;
      spawnTimer = 0.5;
      spawnInterval = Math.max(0.35, 1.3 - w * 0.07);
      // refill ammo
      for (var i = 0; i < bases.length; i++) {
        bases[i].ammo = bases[i].maxAmmo;
      }
      state = STATE_PLAY;
    }

    function newGame() {
      score = 0;
      wave = 1;
      setupField();
      startWave(1);
    }

    function aliveCities() {
      var c = 0;
      for (var i = 0; i < cities.length; i++) if (cities[i].alive) c++;
      return c;
    }

    function enemySpeed() {
      return (28 + wave * 5) * (H / 600);
    }

    function spawnEnemy() {
      var speed = enemySpeed() * (0.8 + Math.random() * 0.5);
      var sx = Math.random() * W;
      // target a city or base
      var targets = [];
      for (var i = 0; i < cities.length; i++) if (cities[i].alive) targets.push(cities[i].x);
      for (var j = 0; j < bases.length; j++) targets.push(bases[j].x);
      if (targets.length === 0) targets = [W / 2];
      var tx = targets[Math.floor(Math.random() * targets.length)] + (Math.random() - 0.5) * 40;
      var ty = groundY();
      var canSplit = wave >= 3 && Math.random() < Math.min(0.5, 0.15 + wave * 0.03);
      var smart = wave >= 5 && Math.random() < Math.min(0.4, (wave - 4) * 0.06);
      enemies.push({
        x: sx, y: -10,
        tx: tx, ty: ty,
        sx: sx, sy: -10,
        speed: speed,
        split: canSplit,
        splitY: H * (0.35 + Math.random() * 0.2),
        smart: smart,
        trail: [],
        dead: false
      });
    }

    function spawnSplit(e) {
      var count = 2 + (Math.random() < 0.3 ? 1 : 0);
      for (var i = 0; i < count; i++) {
        var speed = e.speed * (0.9 + Math.random() * 0.3);
        var targets = [];
        for (var c = 0; c < cities.length; c++) if (cities[c].alive) targets.push(cities[c].x);
        for (var b = 0; b < bases.length; b++) targets.push(bases[b].x);
        if (targets.length === 0) targets = [W / 2];
        var tx = targets[Math.floor(Math.random() * targets.length)] + (Math.random() - 0.5) * 60;
        enemies.push({
          x: e.x, y: e.y,
          tx: tx, ty: groundY(),
          sx: e.x, sy: e.y,
          speed: speed,
          split: false,
          splitY: 0,
          smart: false,
          trail: [],
          dead: false
        });
      }
    }

    function launchAt(px, py) {
      // choose nearest base with ammo
      var best_i = -1, bestD = Infinity;
      for (var i = 0; i < bases.length; i++) {
        if (bases[i].ammo <= 0) continue;
        var d = Math.abs(bases[i].x - px);
        if (d < bestD) { bestD = d; best_i = i; }
      }
      if (best_i < 0) return; // no ammo anywhere
      var b = bases[best_i];
      b.ammo--;
      var ox = b.x;
      var oy = groundY();
      var dx = px - ox, dy = py - oy;
      var dist = Math.sqrt(dx * dx + dy * dy) || 1;
      var spd = 620 * (H / 600);
      friendlies.push({
        x: ox, y: oy,
        vx: dx / dist * spd,
        vy: dy / dist * spd,
        tx: px, ty: py,
        trail: [],
        dead: false
      });
      sfxLaunch();
    }

    function addBlast(x, y, maxR) {
      blasts.push({ x: x, y: y, r: 4, maxR: maxR, growing: true, life: 0 });
      sfxExplode();
    }

    function addParticles(x, y, color, n) {
      for (var i = 0; i < n; i++) {
        var a = Math.random() * Math.PI * 2;
        var s = 30 + Math.random() * 120;
        particles.push({
          x: x, y: y,
          vx: Math.cos(a) * s,
          vy: Math.sin(a) * s - 30,
          life: 0.5 + Math.random() * 0.6,
          maxLife: 1,
          color: color
        });
      }
    }

    function destroyCity(c) {
      if (!c.alive) return;
      c.alive = false;
      addParticles(c.x, groundY() - 8, "#ff5a7a", 26);
      flashTimer = 0.35;
      sfxCityHit();
    }

    // ---- Input ----
    function pointerToLogical(clientX, clientY) {
      var rect = canvas.getBoundingClientRect();
      var x = (clientX - rect.left) / rect.width * W;
      var y = (clientY - rect.top) / rect.height * H;
      return { x: x, y: y };
    }

    function handleFire(clientX, clientY) {
      ensureAudio();
      if (state === STATE_START) { newGame(); return; }
      if (state === STATE_OVER) { newGame(); return; }
      if (state === STATE_WAVEEND) { return; }
      if (state !== STATE_PLAY) return;
      var p = pointerToLogical(clientX, clientY);
      if (p.y > groundY() - 6) p.y = groundY() - 6;
      launchAt(p.x, p.y);
    }

    canvas.addEventListener("mousedown", function (e) {
      e.preventDefault();
      handleFire(e.clientX, e.clientY);
    });

    canvas.addEventListener("touchstart", function (e) {
      e.preventDefault();
      if (e.changedTouches && e.changedTouches.length) {
        var t = e.changedTouches[0];
        handleFire(t.clientX, t.clientY);
      }
    }, { passive: false });

    window.addEventListener("keydown", function (e) {
      if (e.key === " " || e.key === "Enter") {
        if (state === STATE_START || state === STATE_OVER) newGame();
      }
    });

    // ---- Update ----
    function update(dt) {
      flashTimer = Math.max(0, flashTimer - dt);

      if (state === STATE_PLAY) {
        // spawn
        if (waveEnemiesSpawned < waveEnemiesTotal) {
          spawnTimer -= dt;
          if (spawnTimer <= 0) {
            spawnEnemy();
            waveEnemiesSpawned++;
            spawnTimer = spawnInterval * (0.6 + Math.random() * 0.8);
          }
        }
      }

      // update enemies
      var gy = groundY();
      for (var i = enemies.length - 1; i >= 0; i--) {
        var e = enemies[i];
        if (e.dead) { enemies.splice(i, 1); continue; }
        var dx = e.tx - e.x, dy = e.ty - e.y;
        var dd = Math.sqrt(dx * dx + dy * dy) || 1;
        var step = e.speed * dt;
        // smart bombs slightly weave / retarget toward alive cities
        e.x += dx / dd * step;
        e.y += dy / dd * step;

        e.trail.push({ x: e.x, y: e.y });
        if (e.trail.length > 16) e.trail.shift();

        // split
        if (e.split && e.y >= e.splitY) {
          e.split = false;
          spawnSplit(e);
        }

        if (e.y >= e.ty - 2 || e.y >= gy) {
          // hit ground: destroy nearest city/base if near
          e.dead = true;
          addParticles(e.x, gy - 4, "#ff8a5a", 10);
          // check city hits
          for (var c = 0; c < cities.length; c++) {
            if (cities[c].alive && Math.abs(cities[c].x - e.x) < W * 0.035) {
              destroyCity(cities[c]);
            }
          }
          for (var b = 0; b < bases.length; b++) {
            if (Math.abs(bases[b].x - e.x) < W * 0.03) {
              bases[b].ammo = 0; // knocked out for the wave visually
            }
          }
          enemies.splice(i, 1);
        }
      }

      // update friendlies
      for (var f = friendlies.length - 1; f >= 0; f--) {
        var fm = friendlies[f];
        if (fm.dead) { friendlies.splice(f, 1); continue; }
        fm.x += fm.vx * dt;
        fm.y += fm.vy * dt;
        fm.trail.push({ x: fm.x, y: fm.y });
        if (fm.trail.length > 22) fm.trail.shift();
        var ddx = fm.tx - fm.x, ddy = fm.ty - fm.y;
        // reached target when passing it
        if (ddx * fm.vx + ddy * fm.vy <= 0) {
          fm.dead = true;
          addBlast(fm.tx, fm.ty, Math.max(46, W * 0.06));
          friendlies.splice(f, 1);
        }
      }

      // update blasts
      for (var bl = blasts.length - 1; bl >= 0; bl--) {
        var B = blasts[bl];
        B.life += dt;
        if (B.growing) {
          B.r += (B.maxR) * dt * 3.2;
          if (B.r >= B.maxR) { B.r = B.maxR; B.growing = false; }
        } else {
          B.r -= B.maxR * dt * 1.9;
          if (B.r <= 0) { blasts.splice(bl, 1); continue; }
        }
        // destroy enemies inside blast
        for (var ei = enemies.length - 1; ei >= 0; ei--) {
          var en = enemies[ei];
          var edx = en.x - B.x, edy = en.y - B.y;
          if (edx * edx + edy * edy <= B.r * B.r) {
            en.dead = true;
            score += 25;
            addParticles(en.x, en.y, "#7dffea", 8);
            enemies.splice(ei, 1);
          }
        }
      }

      // update particles
      for (var p = particles.length - 1; p >= 0; p--) {
        var pt = particles[p];
        pt.life -= dt;
        if (pt.life <= 0) { particles.splice(p, 1); continue; }
        pt.vy += 140 * dt;
        pt.x += pt.vx * dt;
        pt.y += pt.vy * dt;
      }

      // check wave end / game over
      if (state === STATE_PLAY) {
        if (aliveCities() === 0) {
          // let remaining blasts fade a touch, then game over
          state = STATE_OVER;
          if (score > best) {
            best = score;
            try { localStorage.setItem(BEST_KEY, String(best)); } catch (e2) {}
          }
        } else if (waveEnemiesSpawned >= waveEnemiesTotal && enemies.length === 0 && friendlies.length === 0) {
          // wave clear -> bonus phase
          state = STATE_WAVEEND;
          waveEndTimer = 0;
          bonusStep = 0;
          bonusCityIndex = 0;
          bonusAmmoLeft = 0;
          for (var bb = 0; bb < bases.length; bb++) bonusAmmoLeft += bases[bb].ammo;
          bonusAmmoBase = bonusAmmoLeft;
        }
      }

      if (state === STATE_WAVEEND) {
        waveEndTimer += dt;
        // tally city bonus (100 each) then ammo bonus (10 each)
        // simple timed tally for feel
        if (bonusStep === 0 && waveEndTimer > 0.4) {
          bonusStep = 1;
        } else if (bonusStep === 1) {
          if (waveEndTimer > 0.5 + bonusCityIndex * 0.12) {
            if (bonusCityIndex < cities.length) {
              if (cities[bonusCityIndex].alive) { score += 100; blip("sine", 880, 0.08, 0.05); }
              bonusCityIndex++;
            } else {
              bonusStep = 2;
              waveEndTimer = 0;
            }
          }
        } else if (bonusStep === 2) {
          if (waveEndTimer > 0.05 && bonusAmmoLeft > 0) {
            if (waveEndTimer > 0.02) {
              score += 5;
              bonusAmmoLeft--;
              waveEndTimer = 0;
              blip("sine", 1200, 0.04, 0.04);
            }
          } else if (bonusAmmoLeft <= 0) {
            bonusStep = 3;
            waveEndTimer = 0;
          }
        } else if (bonusStep === 3) {
          if (waveEndTimer > 1.0) {
            startWave(wave + 1);
          }
        }
      }
    }

    // ---- Draw ----
    var starField = null;
    function buildStars() {
      starField = [];
      var n = 70;
      for (var i = 0; i < n; i++) {
        starField.push({
          x: Math.random(),
          y: Math.random() * 0.75,
          s: Math.random() * 1.6 + 0.3,
          tw: Math.random() * Math.PI * 2
        });
      }
    }
    buildStars();

    var tGlobal = 0;

    function drawBackground() {
      // gradient sky
      var g = ctx.createLinearGradient(0, 0, 0, H);
      g.addColorStop(0, "#0a0e14");
      g.addColorStop(0.6, "#0b1220");
      g.addColorStop(1, "#0d1a2b");
      ctx.fillStyle = g;
      ctx.fillRect(0, 0, W, H);

      // stars
      for (var i = 0; i < starField.length; i++) {
        var st = starField[i];
        var a = 0.4 + 0.5 * Math.abs(Math.sin(tGlobal * 1.5 + st.tw));
        ctx.fillStyle = "rgba(180,220,255," + a.toFixed(3) + ")";
        ctx.fillRect(st.x * W, st.y * H, st.s, st.s);
      }
    }

    function drawGround() {
      var gy = groundY();
      // ground glow line
      ctx.save();
      ctx.strokeStyle = "#1de9b6";
      ctx.shadowColor = "#1de9b6";
      ctx.shadowBlur = 14;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(0, gy + 10);
      ctx.lineTo(W, gy + 10);
      ctx.stroke();
      ctx.restore();

      // ground fill
      ctx.fillStyle = "#08131c";
      ctx.fillRect(0, gy + 10, W, H - (gy + 10));
    }

    function drawCity(c) {
      var gy = groundY();
      var cw = W * 0.05;
      var bx = c.x - cw / 2;
      if (!c.alive) {
        // rubble
        ctx.save();
        ctx.fillStyle = "#20303c";
        for (var r = 0; r < 4; r++) {
          var rw = cw * (0.2 + Math.random() * 0.1);
          ctx.fillRect(bx + r * (cw / 4), gy - 4 - Math.random() * 5, rw, 5 + Math.random() * 4);
        }
        ctx.restore();
        return;
      }
      ctx.save();
      ctx.shadowColor = "#36e0ff";
      ctx.shadowBlur = 12;
      // skyline: a few towers
      var towers = [
        { w: cw * 0.20, h: 20 },
        { w: cw * 0.16, h: 32 },
        { w: cw * 0.22, h: 26 },
        { w: cw * 0.16, h: 16 }
      ];
      var tx = bx;
      var hue = "#2ad4ff";
      for (var t = 0; t < towers.length; t++) {
        var tw = towers[t].w;
        var th = towers[t].h * (H / 600);
        ctx.fillStyle = hue;
        ctx.globalAlpha = 0.9;
        ctx.fillRect(tx, gy - th, tw, th);
        tx += tw + cw * 0.02;
      }
      ctx.globalAlpha = 1;
      ctx.restore();
      // little windows
      ctx.fillStyle = "rgba(255,255,180,0.5)";
      var wx = bx + 3;
      for (var wxi = 0; wxi < 5; wxi++) {
        ctx.fillRect(wx + wxi * (cw / 6), gy - 10, 1.5, 1.5);
      }
    }

    function drawBase(b) {
      var gy = groundY();
      ctx.save();
      var lowAmmo = b.ammo <= 0;
      var col = lowAmmo ? "#5a6b78" : "#ffd23f";
      ctx.shadowColor = col;
      ctx.shadowBlur = lowAmmo ? 0 : 14;
      ctx.fillStyle = col;
      // triangular battery
      ctx.beginPath();
      var bw = W * 0.03;
      ctx.moveTo(b.x - bw, gy + 8);
      ctx.lineTo(b.x + bw, gy + 8);
      ctx.lineTo(b.x, gy - 16);
      ctx.closePath();
      ctx.fill();
      ctx.restore();

      // ammo count
      ctx.fillStyle = lowAmmo ? "#7a8792" : "#fff3c4";
      ctx.font = "bold " + Math.round(11 * (H / 600) + 6) + "px monospace";
      ctx.textAlign = "center";
      ctx.textBaseline = "top";
      ctx.fillText(String(b.ammo), b.x, gy + 12);
    }

    function drawEnemies() {
      for (var i = 0; i < enemies.length; i++) {
        var e = enemies[i];
        // trail
        ctx.save();
        var col = e.smart ? "#ff4de0" : "#ff5a5a";
        ctx.strokeStyle = col;
        ctx.shadowColor = col;
        ctx.shadowBlur = 8;
        ctx.lineWidth = 1.6;
        ctx.beginPath();
        ctx.moveTo(e.sx, e.sy);
        for (var t = 0; t < e.trail.length; t++) {
          ctx.lineTo(e.trail[t].x, e.trail[t].y);
        }
        ctx.lineTo(e.x, e.y);
        ctx.stroke();
        // head
        ctx.fillStyle = "#fff";
        ctx.beginPath();
        ctx.arc(e.x, e.y, e.smart ? 3.4 : 2.6, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
      }
    }

    function drawFriendlies() {
      for (var i = 0; i < friendlies.length; i++) {
        var f = friendlies[i];
        ctx.save();
        ctx.strokeStyle = "#7dffea";
        ctx.shadowColor = "#7dffea";
        ctx.shadowBlur = 10;
        ctx.lineWidth = 1.8;
        ctx.beginPath();
        if (f.trail.length) {
          ctx.moveTo(f.trail[0].x, f.trail[0].y);
          for (var t = 1; t < f.trail.length; t++) ctx.lineTo(f.trail[t].x, f.trail[t].y);
          ctx.lineTo(f.x, f.y);
        }
        ctx.stroke();
        // target marker
        ctx.strokeStyle = "rgba(125,255,234,0.5)";
        ctx.shadowBlur = 0;
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(f.tx - 5, f.ty); ctx.lineTo(f.tx + 5, f.ty);
        ctx.moveTo(f.tx, f.ty - 5); ctx.lineTo(f.tx, f.ty + 5);
        ctx.stroke();
        ctx.restore();
      }
    }

    function drawBlasts() {
      for (var i = 0; i < blasts.length; i++) {
        var B = blasts[i];
        var hueT = (tGlobal * 6 + i) % 3;
        var color = hueT < 1 ? "#fff6a8" : (hueT < 2 ? "#7dffea" : "#ff8ad4");
        ctx.save();
        ctx.globalCompositeOperation = "lighter";
        // outer ring
        ctx.strokeStyle = color;
        ctx.shadowColor = color;
        ctx.shadowBlur = 20;
        ctx.lineWidth = 3;
        ctx.beginPath();
        ctx.arc(B.x, B.y, B.r, 0, Math.PI * 2);
        ctx.stroke();
        // inner fill
        var grad = ctx.createRadialGradient(B.x, B.y, 0, B.x, B.y, B.r);
        grad.addColorStop(0, "rgba(255,255,255,0.5)");
        grad.addColorStop(0.5, "rgba(125,255,234,0.25)");
        grad.addColorStop(1, "rgba(125,255,234,0)");
        ctx.fillStyle = grad;
        ctx.beginPath();
        ctx.arc(B.x, B.y, B.r, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
      }
    }

    function drawParticles() {
      for (var i = 0; i < particles.length; i++) {
        var p = particles[i];
        var a = Math.max(0, Math.min(1, p.life));
        ctx.save();
        ctx.globalAlpha = a;
        ctx.fillStyle = p.color;
        ctx.shadowColor = p.color;
        ctx.shadowBlur = 6;
        ctx.fillRect(p.x, p.y, 2.2, 2.2);
        ctx.restore();
      }
    }

    function drawHUD() {
      ctx.save();
      ctx.textBaseline = "top";
      var fs = Math.round(14 * (H / 600) + 6);
      ctx.font = "bold " + fs + "px monospace";
      ctx.textAlign = "left";
      ctx.fillStyle = "#7dffea";
      ctx.shadowColor = "#7dffea";
      ctx.shadowBlur = 8;
      ctx.fillText("SCORE " + score, 14, 12);
      ctx.textAlign = "center";
      ctx.fillStyle = "#ffd23f";
      ctx.shadowColor = "#ffd23f";
      ctx.fillText("WAVE " + wave, W / 2, 12);
      ctx.textAlign = "right";
      ctx.fillStyle = "#ff8ad4";
      ctx.shadowColor = "#ff8ad4";
      ctx.fillText("BEST " + best, W - 14, 12);
      ctx.restore();
    }

    function drawTitleText(lines, subLines) {
      ctx.save();
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      var cy = H * 0.4;
      var big = Math.round(Math.min(W * 0.075, 54));
      ctx.font = "bold " + big + "px monospace";
      // letter-spaced uppercase title with glow
      for (var li = 0; li < lines.length; li++) {
        var txt = lines[li];
        ctx.fillStyle = "#7dffea";
        ctx.shadowColor = "#36e0ff";
        ctx.shadowBlur = 24;
        drawSpaced(txt.toUpperCase(), W / 2, cy + li * (big * 1.2), big * 0.18);
      }
      ctx.shadowBlur = 0;
      var sm = Math.round(Math.min(W * 0.03, 18));
      ctx.font = "bold " + sm + "px monospace";
      ctx.fillStyle = "#ffd23f";
      var sy = cy + lines.length * (big * 1.2) + 24;
      for (var si = 0; si < subLines.length; si++) {
        ctx.shadowColor = "#ffd23f";
        ctx.shadowBlur = 10;
        ctx.fillText(subLines[si], W / 2, sy + si * (sm * 1.6));
      }
      ctx.restore();
    }

    function drawSpaced(text, cx, cy, extra) {
      // measure with tracking
      var widths = [];
      var total = 0;
      for (var i = 0; i < text.length; i++) {
        var w = ctx.measureText(text[i]).width;
        widths.push(w);
        total += w + (i < text.length - 1 ? extra : 0);
      }
      var x = cx - total / 2;
      for (var j = 0; j < text.length; j++) {
        ctx.textAlign = "left";
        ctx.fillText(text[j], x, cy);
        x += widths[j] + extra;
      }
      ctx.textAlign = "center";
    }

    function draw() {
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      drawBackground();

      // field
      var i;
      for (i = 0; i < cities.length; i++) drawCity(cities[i]);
      for (i = 0; i < bases.length; i++) drawBase(bases[i]);
      drawGround();

      drawEnemies();
      drawFriendlies();
      drawBlasts();
      drawParticles();

      // city-hit flash
      if (flashTimer > 0) {
        ctx.save();
        ctx.fillStyle = "rgba(255,60,110," + (flashTimer * 0.6).toFixed(3) + ")";
        ctx.fillRect(0, 0, W, H);
        ctx.restore();
      }

      drawHUD();

      if (state === STATE_START) {
        drawTitleText(["Missile", "Command"], ["TAP / CLICK TO DEFEND", "PROTECT YOUR 6 CITIES"]);
      } else if (state === STATE_OVER) {
        drawTitleText(["Game", "Over"], ["SCORE " + score, "TAP TO PLAY AGAIN"]);
      } else if (state === STATE_WAVEEND) {
        ctx.save();
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        var big = Math.round(Math.min(W * 0.05, 34));
        ctx.font = "bold " + big + "px monospace";
        ctx.fillStyle = "#ffd23f";
        ctx.shadowColor = "#ffd23f";
        ctx.shadowBlur = 18;
        drawSpaced("WAVE " + wave + " CLEAR", W / 2, H * 0.4, big * 0.14);
        ctx.shadowBlur = 8;
        var sm = Math.round(Math.min(W * 0.028, 16));
        ctx.font = "bold " + sm + "px monospace";
        ctx.fillStyle = "#7dffea";
        ctx.shadowColor = "#7dffea";
        ctx.fillText("BONUS TALLY...", W / 2, H * 0.4 + big + 20);
        ctx.restore();
      }
    }

    // ---- Loop ----
    var last = 0;
    var running = true;
    function frame(now) {
      if (!running) return;
      if (!last) last = now;
      var dt = (now - last) / 1000;
      last = now;
      if (dt > 0.05) dt = 0.05; // clamp
      tGlobal += dt;

      // keep field positions in sync with size
      repositionField();

      try {
        update(dt);
        draw();
      } catch (err) {
        // never let a frame throw a pageerror
        running = false;
      }
      window.requestAnimationFrame(frame);
    }

    window.requestAnimationFrame(frame);
  }

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