← back to Games Agentabrams

games/asteroids/index.html

829 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Abrams Asteroids</title>
<style>
  :root {
    --neon: #00f0ff;
    --neon2: #ff2bd6;
    --accent: #b6ff3a;
    --bg0: #04060f;
    --bg1: #0a0f24;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body {
    width: 100%; height: 100%;
    overflow: hidden;
    background: var(--bg0);
    font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
    -webkit-user-select: none; user-select: none;
    -webkit-tap-highlight-color: transparent;
    touch-action: none;
  }
  #wrap {
    position: fixed; inset: 0;
    background:
      radial-gradient(ellipse at 50% 30%, #0d1636 0%, var(--bg0) 70%),
      var(--bg0);
  }
  canvas { position: absolute; inset: 0; display: block; }

  /* HUD */
  #hud {
    position: absolute; top: 0; left: 0; right: 0;
    display: flex; justify-content: space-between; align-items: flex-start;
    padding: 14px 18px;
    pointer-events: none;
    font-variant-numeric: tabular-nums;
    text-shadow: 0 0 8px currentColor;
    z-index: 5;
  }
  #hud .col { display: flex; flex-direction: column; gap: 4px; }
  #hud .col.right { align-items: flex-end; text-align: right; }
  .label { font-size: 11px; letter-spacing: 3px; color: #6f7bb5; text-shadow: none; }
  .val { font-size: 22px; font-weight: 700; letter-spacing: 1px; color: var(--neon); }
  .val.best { color: var(--accent); font-size: 16px; }
  #lives { display: flex; gap: 6px; margin-top: 2px; }
  #lives svg { filter: drop-shadow(0 0 4px var(--neon)); }
  #wave { color: var(--neon2); font-size: 16px; }

  /* Overlay */
  #overlay {
    position: absolute; inset: 0;
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    text-align: center;
    background: radial-gradient(ellipse at 50% 45%, rgba(6,10,28,.72) 0%, rgba(2,4,12,.94) 75%);
    z-index: 10;
    padding: 24px;
    backdrop-filter: blur(2px);
  }
  #overlay.hidden { display: none; }
  #title {
    font-size: clamp(34px, 9vw, 84px);
    font-weight: 800;
    letter-spacing: clamp(2px, 1.5vw, 12px);
    color: #eafcff;
    text-shadow: 0 0 18px var(--neon), 0 0 40px var(--neon), 0 0 4px #fff;
    line-height: 1;
  }
  #title .sub { display:block; font-size: clamp(13px,3.5vw,22px); letter-spacing: clamp(3px,2vw,16px); color: var(--neon2); text-shadow: 0 0 12px var(--neon2); margin-top: 10px; }
  #ovMsg { margin-top: 18px; font-size: clamp(14px,4vw,20px); color: #b9c6ff; min-height: 1.2em; text-shadow: 0 0 8px rgba(120,150,255,.4);}
  #ovScore { margin-top: 6px; font-size: clamp(13px,3.6vw,18px); color: var(--accent); }
  #playBtn {
    margin-top: 26px;
    pointer-events: auto; cursor: pointer;
    background: linear-gradient(180deg, rgba(0,240,255,.16), rgba(0,240,255,.04));
    border: 2px solid var(--neon);
    color: #eafcff;
    font-size: clamp(16px,4.5vw,22px); font-weight: 700; letter-spacing: 4px;
    padding: 14px 40px; border-radius: 40px;
    text-shadow: 0 0 10px var(--neon);
    box-shadow: 0 0 22px rgba(0,240,255,.35), inset 0 0 18px rgba(0,240,255,.18);
    transition: transform .08s ease, box-shadow .2s ease;
  }
  #playBtn:hover { box-shadow: 0 0 34px rgba(0,240,255,.6), inset 0 0 24px rgba(0,240,255,.28); }
  #playBtn:active { transform: scale(.96); }
  #hint {
    margin-top: 26px; font-size: clamp(11px,3vw,14px); color: #6f7bb5;
    letter-spacing: 1px; line-height: 1.9; max-width: 560px;
  }
  #hint b { color: var(--neon); font-weight: 700; }

  /* Toast / status pills */
  #statusPills {
    position: absolute; top: 56px; left: 50%; transform: translateX(-50%);
    display: flex; gap: 10px; z-index: 6; pointer-events: none;
  }
  .pill {
    font-size: 11px; letter-spacing: 2px; padding: 4px 10px; border-radius: 20px;
    border: 1px solid rgba(120,150,255,.4); color: #9fb0f0; background: rgba(10,16,40,.6);
    opacity: 0; transition: opacity .3s;
  }
  .pill.on { opacity: 1; }
  .pill.paused { color: var(--accent); border-color: var(--accent); }
  .pill.muted { color: var(--neon2); border-color: var(--neon2); }

  #centerToast {
    position: absolute; top: 42%; left: 50%; transform: translate(-50%,-50%);
    font-size: clamp(20px,7vw,48px); font-weight: 800; letter-spacing: 6px;
    color: #eafcff; text-shadow: 0 0 20px var(--neon2), 0 0 40px var(--neon2);
    z-index: 7; pointer-events: none; opacity: 0; transition: opacity .4s;
  }
  #centerToast.on { opacity: 1; }

  /* Touch controls */
  #touch { position: absolute; inset: 0; z-index: 8; display: none; pointer-events: none; }
  #touch.show { display: block; }
  .tbtn {
    position: absolute; pointer-events: auto;
    display: flex; align-items: center; justify-content: center;
    border-radius: 50%;
    border: 2px solid rgba(0,240,255,.55);
    background: rgba(8,14,36,.42);
    color: var(--neon); font-weight: 700; font-size: 13px; letter-spacing: 1px;
    box-shadow: 0 0 14px rgba(0,240,255,.22), inset 0 0 12px rgba(0,240,255,.12);
    backdrop-filter: blur(2px);
    -webkit-user-select: none; user-select: none;
    transition: background .08s, transform .08s;
  }
  .tbtn:active, .tbtn.active { background: rgba(0,240,255,.28); transform: scale(.94); }
  .tbtn svg { width: 44%; height: 44%; fill: var(--neon); filter: drop-shadow(0 0 4px var(--neon)); }
  #tLeft  { left: 20px;  bottom: 96px; width: 74px; height: 74px; }
  #tRight { left: 110px; bottom: 30px; width: 74px; height: 74px; }
  #tThrust{ right: 110px; bottom: 96px; width: 82px; height: 82px; }
  #tFire  { right: 20px;  bottom: 30px; width: 82px; height: 82px; border-color: var(--neon2); color: var(--neon2); box-shadow: 0 0 14px rgba(255,43,214,.3), inset 0 0 12px rgba(255,43,214,.14); }
  #tFire svg { fill: var(--neon2); filter: drop-shadow(0 0 4px var(--neon2)); }
  #tHyper { right: 24px; top: 84px; width: 56px; height: 56px; font-size: 11px; border-color: var(--accent); color: var(--accent); }
</style>
</head>
<body>
<div id="wrap">
  <canvas id="game"></canvas>

  <div id="hud">
    <div class="col">
      <span class="label">SCORE</span>
      <span class="val" id="score">0</span>
      <span class="val best" id="best">BEST 0</span>
    </div>
    <div class="col right">
      <span class="label">WAVE</span>
      <span class="val" id="wave">1</span>
      <span class="label" style="margin-top:6px;">SHIPS</span>
      <div id="lives"></div>
    </div>
  </div>

  <div id="statusPills">
    <span class="pill paused" id="pillPaused">PAUSED</span>
    <span class="pill muted" id="pillMuted">MUTED</span>
  </div>

  <div id="centerToast"></div>

  <!-- Touch controls -->
  <div id="touch">
    <div class="tbtn" id="tLeft"><svg viewBox="0 0 100 100"><path d="M65 15 L30 50 L65 85 Z"/></svg></div>
    <div class="tbtn" id="tRight"><svg viewBox="0 0 100 100"><path d="M35 15 L70 50 L35 85 Z"/></svg></div>
    <div class="tbtn" id="tThrust"><svg viewBox="0 0 100 100"><path d="M50 12 L80 78 L50 62 L20 78 Z"/></svg></div>
    <div class="tbtn" id="tFire"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="26"/></svg></div>
    <div class="tbtn" id="tHyper">HYP</div>
  </div>

  <div id="overlay">
    <div id="title">ABRAMS<span class="sub">ASTEROIDS</span></div>
    <div id="ovMsg">Clear the field. Survive the swarm.</div>
    <div id="ovScore"></div>
    <button id="playBtn">LAUNCH</button>
    <div id="hint">
      <b>&#9650; / W</b> Thrust &nbsp; <b>&#9664; &#9654; / A D</b> Rotate &nbsp; <b>SPACE</b> Fire<br>
      <b>SHIFT</b> Hyperspace &nbsp; <b>P</b> Pause &nbsp; <b>M</b> Mute &nbsp; <b>R</b> Restart<br>
      <span style="opacity:.75">Touch devices: use the on-screen pads.</span>
    </div>
  </div>
</div>

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

  // ---------- Setup ----------
  var canvas = document.getElementById("game");
  var ctx = canvas.getContext("2d");
  var wrap = document.getElementById("wrap");

  var W = 0, H = 0, DPR = 1;

  function resize() {
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    var r = wrap.getBoundingClientRect();
    W = Math.max(320, r.width);
    H = Math.max(240, r.height);
    canvas.width = Math.floor(W * DPR);
    canvas.height = Math.floor(H * DPR);
    canvas.style.width = W + "px";
    canvas.style.height = H + "px";
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  }
  window.addEventListener("resize", resize);
  resize();

  // ---------- Audio (Web Audio, procedural) ----------
  var Audio = (function () {
    var actx = null, master = null;
    var muted = false;
    function ensure() {
      if (actx) return;
      try {
        var AC = window.AudioContext || window.webkitAudioContext;
        actx = new AC();
        master = actx.createGain();
        master.gain.value = 0.35;
        master.connect(actx.destination);
      } catch (e) { actx = null; }
    }
    function resume() { if (actx && actx.state === "suspended") actx.resume(); }
    function tone(freq, dur, type, vol, slideTo) {
      if (muted || !actx) return;
      var t = actx.currentTime;
      var o = actx.createOscillator();
      var g = actx.createGain();
      o.type = type || "square";
      o.frequency.setValueAtTime(freq, t);
      if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), t + dur);
      g.gain.setValueAtTime(0.0001, t);
      g.gain.exponentialRampToValueAtTime(vol || 0.3, t + 0.008);
      g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
      o.connect(g); g.connect(master);
      o.start(t); o.stop(t + dur + 0.02);
    }
    function noise(dur, vol, freq) {
      if (muted || !actx) return;
      var t = actx.currentTime;
      var n = Math.floor(actx.sampleRate * dur);
      var buf = actx.createBuffer(1, n, actx.sampleRate);
      var d = buf.getChannelData(0);
      for (var i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
      var src = actx.createBufferSource(); src.buffer = buf;
      var flt = actx.createBiquadFilter();
      flt.type = "lowpass"; flt.frequency.value = freq || 1200;
      var g = actx.createGain();
      g.gain.setValueAtTime(vol || 0.4, t);
      g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
      src.connect(flt); flt.connect(g); g.connect(master);
      src.start(t);
    }
    return {
      init: function () { ensure(); resume(); },
      resume: resume,
      isMuted: function () { return muted; },
      toggleMute: function () { muted = !muted; return muted; },
      shoot: function () { tone(880, 0.09, "square", 0.14, 300); },
      thrust: function () { noise(0.05, 0.06, 500); },
      bangBig: function () { noise(0.32, 0.5, 700); tone(70, 0.3, "sawtooth", 0.2, 40); },
      bangMed: function () { noise(0.22, 0.4, 1000); tone(120, 0.2, "sawtooth", 0.15, 60); },
      bangSmall: function () { noise(0.14, 0.3, 1600); tone(200, 0.12, "square", 0.12, 90); },
      hyper: function () { tone(200, 0.35, "sine", 0.25, 1400); },
      death: function () { noise(0.6, 0.55, 500); tone(160, 0.6, "sawtooth", 0.3, 30); },
      wave: function () { tone(523, 0.12, "triangle", 0.2); setTimeout(function(){tone(784,0.16,"triangle",0.2);}, 120); },
      extraLife: function () { tone(660, 0.1, "triangle", 0.2); setTimeout(function(){tone(990,0.14,"triangle",0.22);}, 100); }
    };
  })();

  // ---------- Input ----------
  var keys = {};
  var touch = { left: false, right: false, thrust: false, fire: false };

  function down(code) {
    if (code === "KeyM") { toggleMute(); return; }
    if (code === "KeyP") { togglePause(); return; }
    if (code === "KeyR") { if (state === "play" || state === "over") restart(); return; }
    keys[code] = true;
  }
  function up(code) { keys[code] = false; }

  window.addEventListener("keydown", function (e) {
    var c = e.code;
    if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Space"].indexOf(c) >= 0) e.preventDefault();
    if (state !== "play") {
      if (c === "Space" || c === "Enter") {
        if (state === "start" || state === "over") { startGame(); return; }
      }
    }
    Audio.init();
    down(c);
  });
  window.addEventListener("keyup", function (e) { up(e.code); });

  // Touch buttons
  function bindTouch(id, prop) {
    var el = document.getElementById(id);
    function on(e) { e.preventDefault(); Audio.init(); touch[prop] = true; el.classList.add("active"); }
    function off(e) { e.preventDefault(); touch[prop] = false; el.classList.remove("active"); }
    el.addEventListener("touchstart", on, { passive: false });
    el.addEventListener("touchend", off, { passive: false });
    el.addEventListener("touchcancel", off, { passive: false });
    el.addEventListener("mousedown", on);
    el.addEventListener("mouseup", off);
    el.addEventListener("mouseleave", off);
  }
  bindTouch("tLeft", "left");
  bindTouch("tRight", "right");
  bindTouch("tThrust", "thrust");
  bindTouch("tFire", "fire");
  (function () {
    var el = document.getElementById("tHyper");
    function on(e) { e.preventDefault(); Audio.init(); doHyperspace(); el.classList.add("active"); }
    function off(e) { e.preventDefault(); el.classList.remove("active"); }
    el.addEventListener("touchstart", on, { passive: false });
    el.addEventListener("touchend", off, { passive: false });
    el.addEventListener("mousedown", on);
    el.addEventListener("mouseup", off);
  })();

  var isTouch = ("ontouchstart" in window) || navigator.maxTouchPoints > 0;
  if (isTouch) document.getElementById("touch").classList.add("show");

  // ---------- Game constants ----------
  var SHIP_R = 14;
  var TURN = 4.2;           // rad/sec
  var THRUST = 340;         // px/sec^2
  var FRICTION = 0.72;      // per sec (velocity retained ~)
  var MAX_SPEED = 460;
  var BULLET_SPEED = 560;
  var BULLET_LIFE = 0.85;
  var FIRE_COOLDOWN = 0.16;
  var INVULN_TIME = 2.4;
  var START_LIVES = 3;
  var EXTRA_LIFE_EVERY = 10000;

  var AST_SIZES = { big: 46, med: 26, small: 15 };
  var AST_SCORE = { big: 20, med: 50, small: 100 };
  var AST_SPEED = { big: 42, med: 66, small: 92 };

  // ---------- State ----------
  var state = "start"; // start | play | paused | over
  var ship, bullets, asteroids, particles;
  var score, best, lives, wave, fireTimer, hyperCooldown, nextExtra;
  var thrustSoundTimer = 0;

  best = parseInt(localStorage.getItem("abrams-asteroids-best") || "0", 10) || 0;

  function rand(a, b) { return a + Math.random() * (b - a); }
  function wrapPos(o) {
    if (o.x < 0) o.x += W; else if (o.x >= W) o.x -= W;
    if (o.y < 0) o.y += H; else if (o.y >= H) o.y -= H;
  }

  function makeShip() {
    return { x: W / 2, y: H / 2, a: -Math.PI / 2, vx: 0, vy: 0, r: SHIP_R, invuln: INVULN_TIME, thrusting: false, alive: true };
  }

  function makeAsteroid(x, y, size) {
    var speed = AST_SPEED[size] * rand(0.7, 1.35);
    var ang = rand(0, Math.PI * 2);
    var r = AST_SIZES[size];
    var verts = [], n = Math.floor(rand(9, 14));
    for (var i = 0; i < n; i++) {
      verts.push(rand(0.72, 1.15));
    }
    return {
      x: x, y: y, size: size, r: r,
      vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed,
      rot: rand(-1, 1), spin: rand(-0.9, 0.9),
      verts: verts, hue: rand(180, 300)
    };
  }

  function spawnWave(n) {
    asteroids = [];
    for (var i = 0; i < n; i++) {
      // spawn away from ship center
      var x, y, tries = 0;
      do {
        x = rand(0, W); y = rand(0, H);
        tries++;
      } while (ship && Math.hypot(x - ship.x, y - ship.y) < 180 && tries < 30);
      asteroids.push(makeAsteroid(x, y, "big"));
    }
  }

  function resetRound() {
    ship = makeShip();
    bullets = [];
    particles = [];
    fireTimer = 0;
    hyperCooldown = 0;
  }

  function startGame() {
    Audio.init();
    score = 0; lives = START_LIVES; wave = 1; nextExtra = EXTRA_LIFE_EVERY;
    resetRound();
    spawnWave(4);
    state = "play";
    hideOverlay();
    Audio.wave();
    lastT = performance.now();
    showToast("WAVE 1", 900);
  }

  function restart() {
    startGame();
  }

  function nextWave() {
    wave++;
    spawnWave(Math.min(4 + wave, 12));
    ship.invuln = Math.max(ship.invuln, 1.2);
    Audio.wave();
    showToast("WAVE " + wave, 900);
  }

  function gameOver() {
    state = "over";
    if (score > best) {
      best = score;
      localStorage.setItem("abrams-asteroids-best", String(best));
    }
    updateHud();
    showOverlay(
      "GAME OVER",
      "The field claimed your fleet.",
      "SCORE " + score + "   •   BEST " + best,
      "PLAY AGAIN"
    );
  }

  // ---------- Actions ----------
  function fire() {
    if (fireTimer > 0 || !ship || !ship.alive) return;
    if (bullets.length > 8) return;
    var nx = Math.cos(ship.a), ny = Math.sin(ship.a);
    bullets.push({
      x: ship.x + nx * ship.r, y: ship.y + ny * ship.r,
      vx: ship.vx + nx * BULLET_SPEED, vy: ship.vy + ny * BULLET_SPEED,
      life: BULLET_LIFE
    });
    fireTimer = FIRE_COOLDOWN;
    Audio.shoot();
  }

  function doHyperspace() {
    if (state !== "play" || !ship || !ship.alive || hyperCooldown > 0) return;
    hyperCooldown = 1.2;
    // small risk: 12% chance of misjump explosion
    ship.x = rand(40, W - 40);
    ship.y = rand(40, H - 40);
    ship.vx = 0; ship.vy = 0;
    ship.invuln = Math.max(ship.invuln, 0.8);
    Audio.hyper();
    for (var i = 0; i < 14; i++) spawnParticle(ship.x, ship.y, 200, "#00f0ff");
    if (Math.random() < 0.12) {
      killShip();
    }
  }

  function killShip() {
    if (!ship.alive) return;
    ship.alive = false;
    Audio.death();
    for (var i = 0; i < 26; i++) spawnParticle(ship.x, ship.y, 240, "#ff2bd6");
    lives--;
    updateHud();
    if (lives <= 0) {
      setTimeout(gameOver, 900);
    } else {
      setTimeout(function () {
        if (state !== "play") return;
        ship = makeShip();
      }, 900);
    }
  }

  function splitAsteroid(a, idx) {
    // score
    addScore(AST_SCORE[a.size]);
    // particles
    var col = "hsl(" + a.hue + ",90%,65%)";
    var pc = a.size === "big" ? 16 : a.size === "med" ? 11 : 7;
    for (var i = 0; i < pc; i++) spawnParticle(a.x, a.y, a.size === "big" ? 220 : 160, col);
    if (a.size === "big") Audio.bangBig();
    else if (a.size === "med") Audio.bangMed();
    else Audio.bangSmall();

    asteroids.splice(idx, 1);
    if (a.size === "big") {
      asteroids.push(makeAsteroid(a.x, a.y, "med"));
      asteroids.push(makeAsteroid(a.x, a.y, "med"));
    } else if (a.size === "med") {
      asteroids.push(makeAsteroid(a.x, a.y, "small"));
      asteroids.push(makeAsteroid(a.x, a.y, "small"));
    }
  }

  function addScore(n) {
    score += n;
    if (score >= nextExtra) {
      lives++;
      nextExtra += EXTRA_LIFE_EVERY;
      Audio.extraLife();
      showToast("EXTRA SHIP", 800);
    }
    updateHud();
  }

  function spawnParticle(x, y, spd, color) {
    var a = rand(0, Math.PI * 2);
    var s = rand(spd * 0.25, spd);
    particles.push({
      x: x, y: y, vx: Math.cos(a) * s, vy: Math.sin(a) * s,
      life: rand(0.4, 0.9), max: 0.9, color: color
    });
  }

  // ---------- Update ----------
  function update(dt) {
    if (state !== "play") return;

    // ship controls
    if (ship && ship.alive) {
      var left = keys["ArrowLeft"] || keys["KeyA"] || touch.left;
      var right = keys["ArrowRight"] || keys["KeyD"] || touch.right;
      var thr = keys["ArrowUp"] || keys["KeyW"] || touch.thrust;
      var shift = keys["ShiftLeft"] || keys["ShiftRight"];

      if (left) ship.a -= TURN * dt;
      if (right) ship.a += TURN * dt;

      ship.thrusting = !!thr;
      if (thr) {
        ship.vx += Math.cos(ship.a) * THRUST * dt;
        ship.vy += Math.sin(ship.a) * THRUST * dt;
        thrustSoundTimer -= dt;
        if (thrustSoundTimer <= 0) { Audio.thrust(); thrustSoundTimer = 0.06; }
      }
      // clamp speed
      var sp = Math.hypot(ship.vx, ship.vy);
      if (sp > MAX_SPEED) { ship.vx *= MAX_SPEED / sp; ship.vy *= MAX_SPEED / sp; }
      // friction
      var f = Math.pow(FRICTION, dt);
      ship.vx *= f; ship.vy *= f;

      ship.x += ship.vx * dt; ship.y += ship.vy * dt;
      wrapPos(ship);

      if (shift && hyperCooldown <= 0) doHyperspace();

      if (keys["Space"] || touch.fire) fire();

      if (ship.invuln > 0) ship.invuln -= dt;
    }

    if (fireTimer > 0) fireTimer -= dt;
    if (hyperCooldown > 0) hyperCooldown -= dt;

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

    // asteroids
    for (var j = 0; j < asteroids.length; j++) {
      var a = asteroids[j];
      a.x += a.vx * dt; a.y += a.vy * dt;
      a.rot += a.spin * dt;
      wrapPos(a);
    }

    // bullet vs asteroid
    for (var bi = bullets.length - 1; bi >= 0; bi--) {
      var bu = bullets[bi];
      for (var ai = asteroids.length - 1; ai >= 0; ai--) {
        var as = asteroids[ai];
        if ((bu.x - as.x) * (bu.x - as.x) + (bu.y - as.y) * (bu.y - as.y) < as.r * as.r) {
          bullets.splice(bi, 1);
          splitAsteroid(as, ai);
          break; // this bullet is gone; move to next bullet
        }
      }
    }

    // ship vs asteroid
    if (ship && ship.alive && ship.invuln <= 0) {
      for (var k = 0; k < asteroids.length; k++) {
        var ak = asteroids[k];
        var rr = ak.r + ship.r * 0.7;
        if ((ship.x - ak.x) * (ship.x - ak.x) + (ship.y - ak.y) * (ship.y - ak.y) < rr * rr) {
          killShip();
          break;
        }
      }
    }

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

    // wave clear
    if (asteroids.length === 0) nextWave();
  }

  // ---------- Render ----------
  var starField = null;
  function buildStars() {
    starField = [];
    var n = Math.floor((W * H) / 6500);
    for (var i = 0; i < n; i++) {
      starField.push({ x: Math.random() * W, y: Math.random() * H, r: Math.random() * 1.4 + 0.2, a: Math.random() * 0.6 + 0.2 });
    }
  }
  buildStars();
  var lastStarKey = W + "x" + H;

  function drawStars() {
    var key = W + "x" + H;
    if (key !== lastStarKey || !starField) { buildStars(); lastStarKey = key; }
    ctx.save();
    for (var i = 0; i < starField.length; i++) {
      var s = starField[i];
      ctx.globalAlpha = s.a;
      ctx.fillStyle = "#9fc7ff";
      ctx.beginPath();
      ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }

  function drawShip() {
    if (!ship || !ship.alive) return;
    if (ship.invuln > 0 && Math.floor(ship.invuln * 12) % 2 === 0) return; // blink
    ctx.save();
    ctx.translate(ship.x, ship.y);
    ctx.rotate(ship.a);
    // thrust flame
    if (ship.thrusting && Math.random() > 0.35) {
      ctx.beginPath();
      ctx.moveTo(-ship.r * 0.6, -5);
      ctx.lineTo(-ship.r * 1.5 - Math.random() * 8, 0);
      ctx.lineTo(-ship.r * 0.6, 5);
      ctx.strokeStyle = "#ff9b2b";
      ctx.lineWidth = 2;
      ctx.shadowBlur = 14; ctx.shadowColor = "#ff6a00";
      ctx.stroke();
    }
    // hull
    ctx.beginPath();
    ctx.moveTo(ship.r, 0);
    ctx.lineTo(-ship.r * 0.7, -ship.r * 0.75);
    ctx.lineTo(-ship.r * 0.4, 0);
    ctx.lineTo(-ship.r * 0.7, ship.r * 0.75);
    ctx.closePath();
    ctx.strokeStyle = "#eafcff";
    ctx.lineWidth = 2;
    ctx.shadowBlur = 12; ctx.shadowColor = "#00f0ff";
    ctx.stroke();
    ctx.restore();
  }

  function drawAsteroid(a) {
    ctx.save();
    ctx.translate(a.x, a.y);
    ctx.rotate(a.rot);
    ctx.beginPath();
    var n = a.verts.length;
    for (var i = 0; i < n; i++) {
      var ang = (i / n) * Math.PI * 2;
      var rr = a.r * a.verts[i];
      var px = Math.cos(ang) * rr, py = Math.sin(ang) * rr;
      if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
    }
    ctx.closePath();
    ctx.strokeStyle = "hsl(" + a.hue + ",85%,68%)";
    ctx.lineWidth = 2;
    ctx.shadowBlur = 12; ctx.shadowColor = "hsl(" + a.hue + ",90%,55%)";
    ctx.stroke();
    ctx.restore();
  }

  function drawBullets() {
    ctx.save();
    ctx.fillStyle = "#eafcff";
    ctx.shadowBlur = 10; ctx.shadowColor = "#00f0ff";
    for (var i = 0; i < bullets.length; i++) {
      var b = bullets[i];
      ctx.beginPath();
      ctx.arc(b.x, b.y, 2.4, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }

  function drawParticles() {
    ctx.save();
    for (var i = 0; i < particles.length; i++) {
      var p = particles[i];
      ctx.globalAlpha = Math.max(0, p.life / p.max);
      ctx.fillStyle = p.color;
      ctx.beginPath();
      ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }

  function render() {
    ctx.clearRect(0, 0, W, H);
    drawStars();
    if (state === "play" || state === "paused" || state === "over") {
      drawParticles();
      for (var i = 0; i < (asteroids ? asteroids.length : 0); i++) drawAsteroid(asteroids[i]);
      drawBullets();
      drawShip();
    }
  }

  // ---------- HUD / overlay ----------
  var elScore = document.getElementById("score");
  var elBest = document.getElementById("best");
  var elWave = document.getElementById("wave");
  var elLives = document.getElementById("lives");
  var overlay = document.getElementById("overlay");
  var ovTitle = document.getElementById("title");
  var ovMsg = document.getElementById("ovMsg");
  var ovScore = document.getElementById("ovScore");
  var playBtn = document.getElementById("playBtn");
  var pillPaused = document.getElementById("pillPaused");
  var pillMuted = document.getElementById("pillMuted");
  var centerToast = document.getElementById("centerToast");

  function shipIcon() {
    return '<svg width="16" height="16" viewBox="0 0 100 100"><path d="M85 50 L20 20 L38 50 L20 80 Z" fill="none" stroke="#00f0ff" stroke-width="8"/></svg>';
  }

  function updateHud() {
    elScore.textContent = score != null ? score : 0;
    elBest.textContent = "BEST " + best;
    elWave.textContent = wave != null ? wave : 1;
    var html = "";
    var n = Math.max(0, lives != null ? lives : 0);
    for (var i = 0; i < Math.min(n, 6); i++) html += shipIcon();
    if (n > 6) html += '<span style="color:#00f0ff;font-size:14px;align-self:center;">x' + n + '</span>';
    elLives.innerHTML = html;
  }

  function showOverlay(title, msg, sc, btn) {
    ovTitle.innerHTML = title === "ABRAMS" ? 'ABRAMS<span class="sub">ASTEROIDS</span>' :
      (title + '<span class="sub" style="visibility:hidden">.</span>');
    if (title === "GAME OVER") {
      ovTitle.innerHTML = 'GAME<span class="sub">OVER</span>';
    }
    ovMsg.textContent = msg || "";
    ovScore.textContent = sc || "";
    playBtn.textContent = btn || "LAUNCH";
    overlay.classList.remove("hidden");
  }
  function hideOverlay() { overlay.classList.add("hidden"); }

  playBtn.addEventListener("click", function () { Audio.init(); startGame(); });

  var toastTimer = null;
  function showToast(text, ms) {
    centerToast.textContent = text;
    centerToast.classList.add("on");
    if (toastTimer) clearTimeout(toastTimer);
    toastTimer = setTimeout(function () { centerToast.classList.remove("on"); }, ms || 800);
  }

  function togglePause() {
    if (state === "play") {
      state = "paused";
      pillPaused.classList.add("on");
    } else if (state === "paused") {
      state = "play";
      pillPaused.classList.remove("on");
      lastT = performance.now();
    }
  }
  function toggleMute() {
    var m = Audio.toggleMute();
    if (m) pillMuted.classList.add("on"); else pillMuted.classList.remove("on");
  }

  // ---------- Main loop ----------
  var lastT = performance.now();
  function loop(now) {
    var dt = (now - lastT) / 1000;
    lastT = now;
    if (dt > 0.05) dt = 0.05; // clamp to avoid huge steps
    if (dt < 0) dt = 0;

    update(dt);
    render();
    requestAnimationFrame(loop);
  }

  // init HUD + overlay
  score = 0; lives = START_LIVES; wave = 1;
  updateHud();
  showOverlay("ABRAMS", "Clear the field. Survive the swarm.", "", "LAUNCH");

  requestAnimationFrame(loop);
})();
</script>
</body>
</html>