← back to Games Agentabrams

games/chomp/AbramsChomp.aa

856 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>Abrams Chomp</title>
<style>
  :root {
    --bg:#0a0e14; --grid:#070a10; --wall:#2563eb; --wall-glow:#60a5fa;
    --player:#fde047; --pellet:#f5d7a1; --power:#fbbf24; --ink:#e5e7eb;
  }
  * { box-sizing:border-box; margin:0; padding:0; }
  html,body { height:100%; }
  body {
    background:radial-gradient(1200px 600px at 50% -10%, #16202e, var(--bg));
    color:var(--ink); font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
    display:flex; flex-direction:column; align-items:center; justify-content:center;
    min-height:100%; gap:10px; padding:12px; overscroll-behavior:none; touch-action:none;
  }
  h1 { font-size:14px; letter-spacing:.28em; text-transform:uppercase; color:#93c5fd; font-weight:600; }
  .hud { display:flex; gap:18px; font-variant-numeric:tabular-nums; font-size:13px; color:#9aa4b2; }
  .hud b { color:var(--ink); font-weight:600; }
  #wrap { position:relative; }
  canvas { background:var(--grid); border-radius:12px; box-shadow:0 10px 40px rgba(0,0,0,.5); display:block; }
  .overlay {
    position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
    gap:10px; text-align:center; background:rgba(8,12,18,.78); border-radius:12px; backdrop-filter:blur(2px);
  }
  .overlay.hide { display:none; }
  .big { font-size:22px; font-weight:700; letter-spacing:.02em; }
  .sub { font-size:13px; color:#9aa4b2; max-width:300px; }
  .tag { font-size:11px; color:#93c5fd; letter-spacing:.2em; text-transform:uppercase; }
  button.play {
    margin-top:4px; padding:10px 22px; border:0; border-radius:999px; cursor:pointer;
    background:linear-gradient(180deg,#facc15,#eab308); color:#1a1500; font-weight:700; font-size:14px;
  }
  .btns { position:absolute; top:8px; right:10px; display:flex; gap:6px; z-index:5; }
  .btns button { width:30px; height:30px; border-radius:8px; border:1px solid #2a3441; background:#141b25; color:#cbd5e1; cursor:pointer; font-size:14px; }
  .hint { font-size:11px; color:#5b6673; }
  /* D-pad */
  #dpad {
    display:grid; grid-template-columns:repeat(3,52px); grid-template-rows:repeat(3,52px);
    gap:6px; margin-top:4px; touch-action:none;
  }
  #dpad button {
    border:1px solid #223; border-radius:10px; background:#111926; color:#93c5fd;
    font-size:20px; cursor:pointer; -webkit-user-select:none; user-select:none; touch-action:none;
  }
  #dpad button:active { background:#1b2740; }
  #dpad .sp { visibility:hidden; }
  @media (hover:hover) and (pointer:fine) { #dpad { display:none; } }
</style>
</head>
<body>
  <h1>Abrams Chomp</h1>
  <div class="hud">
    <span>Score <b id="score">0</b></span>
    <span>Lives <b id="lives">3</b></span>
    <span>Best <b id="best">0</b></span>
    <span>Lvl <b id="level">1</b></span>
  </div>
  <div id="wrap">
    <canvas id="c" width="448" height="496"></canvas>
    <div class="btns"><button id="mute" title="Sound">&#128266;</button></div>
    <div id="overlay" class="overlay">
      <div class="tag">Neon Maze Chomper</div>
      <div class="big" id="ovTitle">ABRAMS CHOMP</div>
      <div class="sub" id="ovSub">Eat every pellet. Grab a power pellet to hunt the ghosts. Arrow keys / WASD or the D-pad.</div>
      <button class="play" id="playBtn">PLAY</button>
    </div>
  </div>
  <div id="dpad">
    <button class="sp"></button><button data-dir="up">&#9650;</button><button class="sp"></button>
    <button data-dir="left">&#9664;</button><button class="sp"></button><button data-dir="right">&#9654;</button>
    <button class="sp"></button><button data-dir="down">&#9660;</button><button class="sp"></button>
  </div>
  <div class="hint">Arrow keys / WASD to steer &middot; P to pause</div>

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

  // ---- Maze layout (hand-authored ASCII) ----
  // # wall, . pellet, o power pellet, space empty, - ghost house door, T tunnel row cell
  // Grid is 28 wide x 31 tall (classic proportions).
  var MAZE = [
    "############################",
    "#............##............#",
    "#.####.#####.##.#####.####.#",
    "#o####.#####.##.#####.####o#",
    "#.####.#####.##.#####.####.#",
    "#..........................#",
    "#.####.##.########.##.####.#",
    "#.####.##.########.##.####.#",
    "#......##....##....##......#",
    "######.##### ## #####.######",
    "######.##### ## #####.######",
    "######.##          ##.######",
    "######.## ###--### ##.######",
    "######.## #      # ##.######",
    "T     .   #      #   .     T",
    "######.## #      # ##.######",
    "######.## ######## ##.######",
    "######.##          ##.######",
    "######.## ######## ##.######",
    "######.## ######## ##.######",
    "#............##............#",
    "#.####.#####.##.#####.####.#",
    "#.####.#####.##.#####.####.#",
    "#o..##.......  .......##..o#",
    "###.##.##.########.##.##.###",
    "###.##.##.########.##.##.###",
    "#......##....##....##......#",
    "#.##########.##.##########.#",
    "#.##########.##.##########.#",
    "#..........................#",
    "############################"
  ];

  var ROWS = MAZE.length, COLS = MAZE[0].length;
  var TILE = 16;

  var canvas = document.getElementById("c");
  var ctx = canvas.getContext("2d");
  canvas.width = COLS * TILE;   // 448
  canvas.height = ROWS * TILE;  // 496

  // ---- responsive scaling to fit iframe ----
  function fit() {
    try {
      var wrap = document.getElementById("wrap");
      var pad = 24;
      var maxW = Math.max(160, (window.innerWidth || canvas.width) - pad);
      var availH = (window.innerHeight || canvas.height) - 200; // leave room for hud/dpad
      var maxH = Math.max(160, availH);
      var scale = Math.min(maxW / canvas.width, maxH / canvas.height, 1.4);
      if (!isFinite(scale) || scale <= 0) scale = 1;
      canvas.style.width = Math.round(canvas.width * scale) + "px";
      canvas.style.height = Math.round(canvas.height * scale) + "px";
    } catch (e) { /* no-op */ }
  }
  window.addEventListener("resize", fit);

  // ---- grid state ----
  var grid = [];       // char per cell (walls/paths)
  var pellets = [];     // 0 none, 1 pellet, 2 power
  var pelletCount = 0;

  function isWall(c, r) {
    if (r < 0 || r >= ROWS) return true;
    if (c < 0 || c >= COLS) return false; // horizontal tunnels are open
    return grid[r][c] === "#";
  }

  function buildGrid() {
    grid = [];
    pellets = [];
    pelletCount = 0;
    for (var r = 0; r < ROWS; r++) {
      var gr = [], pr = [];
      var line = MAZE[r];
      for (var c = 0; c < COLS; c++) {
        var ch = line[c] || " ";
        var pel = 0;
        if (ch === ".") { pel = 1; pelletCount++; }
        else if (ch === "o") { pel = 2; pelletCount++; }
        gr.push(ch === "#" ? "#" : (ch === "-" ? "-" : " "));
        pr.push(pel);
      }
      grid.push(gr);
      pellets.push(pr);
    }
  }

  // ---- entities ----
  var DIRS = {
    up:    { x: 0, y: -1 },
    down:  { x: 0, y: 1 },
    left:  { x: -1, y: 0 },
    right: { x: 1, y: 0 },
    none:  { x: 0, y: 0 }
  };

  var player = null;
  var ghosts = [];
  var GHOST_COLORS = ["#ef4444", "#f9a8d4", "#22d3ee", "#fb923c"];
  var GHOST_NAMES = ["blinky", "pinky", "inky", "clyde"];

  function makePlayer() {
    return {
      c: 13, r: 23,
      px: 13 * TILE, py: 23 * TILE,
      dir: "left", next: "left",
      speed: 96,   // px / second
      mouth: 0
    };
  }

  // ghost home center (in tiles) and spawn positions inside the house
  var HOME = { c: 13, r: 14 };
  var GHOST_SPAWNS = [
    { c: 13, r: 11 }, // starts outside on top
    { c: 13, r: 14 },
    { c: 12, r: 14 },
    { c: 14, r: 14 }
  ];

  function makeGhost(i) {
    var sp = GHOST_SPAWNS[i];
    return {
      name: GHOST_NAMES[i],
      color: GHOST_COLORS[i],
      c: sp.c, r: sp.r,
      px: sp.c * TILE, py: sp.r * TILE,
      dir: (i === 0 ? "left" : "up"),
      speed: 84 + (level - 1) * 4,   // px / second, ramps with level
      state: "scatter",     // scatter | chase | frightened | eaten
      inHouse: i !== 0,
      releaseAt: i * 2.2,   // seconds staggered release
      scatterTarget: [
        { c: COLS - 2, r: 0 }, { c: 1, r: 0 },
        { c: COLS - 2, r: ROWS - 1 }, { c: 1, r: ROWS - 1 }
      ][i]
    };
  }

  // ---- game state ----
  var state = "menu"; // menu | play | paused | dead | over | win
  var score = 0, lives = 3, level = 1, best = 0;
  var frightTimer = 0;    // seconds remaining of frightened mode
  var ghostEatChain = 0;  // for 200/400/800/1600 scoring
  var modeTimer = 0;      // scatter/chase alternation
  var modePhase = 0;
  var levelElapsed = 0;   // for staggered ghost release
  var deathAnim = 0;
  var readyTimer = 0;

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

  // scatter/chase schedule (seconds): scatter, chase, scatter, chase...
  var MODE_SCHEDULE = [7, 20, 7, 20, 5, 20, 5, 99999];

  // ---- audio (lazy, must never throw) ----
  var audioCtx = null, muted = false;
  function ensureAudio() {
    if (audioCtx) return;
    try {
      var AC = window.AudioContext || window.webkitAudioContext;
      if (AC) audioCtx = new AC();
    } catch (e) { audioCtx = null; }
  }
  function blip(freq, dur, type, vol) {
    if (muted || !audioCtx) return;
    try {
      var o = audioCtx.createOscillator();
      var g = audioCtx.createGain();
      o.type = type || "square";
      o.frequency.value = freq;
      g.gain.value = vol || 0.05;
      o.connect(g); g.connect(audioCtx.destination);
      var t = audioCtx.currentTime;
      g.gain.setValueAtTime(g.gain.value, t);
      g.gain.exponentialRampToValueAtTime(0.0001, t + (dur || 0.08));
      o.start(t); o.stop(t + (dur || 0.08));
    } catch (e) { /* ignore */ }
  }

  // ---- helpers ----
  // c/r are the entity's CURRENT tile (the tile its top-left snaps to at a center).
  // px/py are the top-left pixel; the entity is "centered" when px==c*TILE & py==r*TILE.
  function canMove(c, r, dir, isGhost, allowDoor) {
    var d = DIRS[dir];
    var nc = c + d.x, nr = r + d.y;
    if (nr < 0 || nr >= ROWS) return false;
    // tunnel columns
    if (nc < 0 || nc >= COLS) return true;
    var cell = grid[nr][nc];
    if (cell === "#") return false;
    if (cell === "-") return !!allowDoor; // door only for ghosts entering/leaving
    return true;
  }

  function opposite(dir) {
    return { up: "down", down: "up", left: "right", right: "left", none: "none" }[dir];
  }

  // ---- input ----
  var keymap = {
    ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right",
    w: "up", s: "down", a: "left", d: "right",
    W: "up", S: "down", A: "left", D: "right"
  };
  window.addEventListener("keydown", function (e) {
    var k = e.key;
    if (k === " " || k === "ArrowUp" || k === "ArrowDown" || k === "ArrowLeft" || k === "ArrowRight") {
      e.preventDefault();
    }
    if (k === "p" || k === "P") { togglePause(); return; }
    if ((k === "Enter" || k === " ") && (state === "menu" || state === "over" || state === "win")) {
      startGame(); return;
    }
    var dir = keymap[k];
    if (dir && player) { player.next = dir; ensureAudio(); }
  }, { passive: false });

  function setDir(dir) {
    if (player && (state === "play")) { player.next = dir; }
    ensureAudio();
  }
  var dpad = document.getElementById("dpad");
  if (dpad) {
    dpad.querySelectorAll("button[data-dir]").forEach(function (b) {
      var dir = b.getAttribute("data-dir");
      b.addEventListener("touchstart", function (e) { e.preventDefault(); setDir(dir); }, { passive: false });
      b.addEventListener("mousedown", function (e) { e.preventDefault(); setDir(dir); });
    });
  }

  document.getElementById("mute").addEventListener("click", function () {
    muted = !muted;
    this.innerHTML = muted ? "&#128263;" : "&#128266;";
    ensureAudio();
  });

  document.getElementById("playBtn").addEventListener("click", function () {
    ensureAudio(); startGame();
  });

  function togglePause() {
    if (state === "play") { state = "paused"; showOverlay("PAUSED", "Press P to resume.", false); }
    else if (state === "paused") { hideOverlay(); state = "play"; }
  }

  // ---- overlay ----
  var overlay = document.getElementById("overlay");
  function showOverlay(title, sub, showBtn) {
    document.getElementById("ovTitle").textContent = title;
    document.getElementById("ovSub").textContent = sub;
    document.getElementById("playBtn").style.display = showBtn ? "" : "none";
    overlay.classList.remove("hide");
  }
  function hideOverlay() { overlay.classList.add("hide"); }

  // ---- lifecycle ----
  function resetPositions() {
    player = makePlayer();
    ghosts = [makeGhost(0), makeGhost(1), makeGhost(2), makeGhost(3)];
    frightTimer = 0; ghostEatChain = 0;
    modeTimer = 0; modePhase = 0; levelElapsed = 0;
    readyTimer = 1.1;
  }

  function newLevel() {
    buildGrid();
    resetPositions();
  }

  function startGame() {
    score = 0; lives = 3; level = 1;
    buildGrid();
    resetPositions();
    state = "play";
    hideOverlay();
    updateHUD();
  }

  function loseLife() {
    lives--;
    updateHUD();
    if (lives <= 0) {
      state = "over";
      if (score > best) {
        best = score;
        try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) {}
      }
      updateHUD();
      showOverlay("GAME OVER", "Score " + score + "  ·  Best " + best + ". Press PLAY to try again.", true);
    } else {
      resetPositions();
      state = "play";
    }
  }

  function winLevel() {
    level++;
    if (score > best) {
      best = score;
      try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) {}
    }
    updateHUD();
    newLevel();
    state = "play";
  }

  function updateHUD() {
    document.getElementById("score").textContent = score;
    document.getElementById("lives").textContent = lives;
    document.getElementById("best").textContent = best;
    document.getElementById("level").textContent = level;
  }

  // ---- movement logic ----
  // Move an entity `dist` pixels along its direction, snapping to tile centers so it
  // can only turn / re-decide at a center, and never overshoots into a wall.
  function moveEntity(ent, isGhost, dt) {
    var sp = ent.speed;
    if (isGhost && ent.state === "frightened") sp *= 0.62;
    if (isGhost && ent.state === "eaten") sp *= 2.0;

    // speed is in pixels/second; clamp dt so a stall can't teleport across the maze
    var remaining = sp * Math.min(dt, 0.05);
    var guard = 0;
    while (remaining > 0.0001 && guard++ < 20) {
      var atCenter = (ent.px === ent.c * TILE) && (ent.py === ent.r * TILE);

      if (atCenter) {
        // Decision point: choose direction.
        if (!isGhost) {
          if (player.next && canMove(ent.c, ent.r, player.next, false, false)) {
            ent.dir = player.next;
          }
          if (!canMove(ent.c, ent.r, ent.dir, false, false)) {
            ent.dir = "none";
          }
        } else {
          chooseGhostDir(ent);
        }
      }

      if (ent.dir === "none") break;

      var d = DIRS[ent.dir];
      // How far until the next tile boundary in this direction:
      var targetC = ent.c + d.x, targetR = ent.r + d.y;

      // If currently at center, verify we can enter the next tile.
      if (atCenter) {
        var allowDoor = isGhost && (ent.state === "eaten" || ent.inHouse);
        if (!canMove(ent.c, ent.r, ent.dir, isGhost, allowDoor)) {
          ent.dir = "none";
          break;
        }
      }

      var targetPx = targetC * TILE, targetPy = targetR * TILE;
      var dxToTarget = targetPx - ent.px, dyToTarget = targetPy - ent.py;
      var distToCenter = Math.abs(dxToTarget) + Math.abs(dyToTarget); // straight-line along one axis

      if (distToCenter <= remaining) {
        // arrive exactly at the next tile center
        ent.px = targetPx; ent.py = targetPy;
        ent.c = targetC; ent.r = targetR;
        remaining -= distToCenter;
        tunnelWrap(ent);
      } else {
        ent.px += d.x * remaining;
        ent.py += d.y * remaining;
        remaining = 0;
      }
    }
  }

  function tunnelWrap(ent) {
    var w = COLS * TILE;
    if (ent.c < 0) { ent.c = COLS - 1; ent.px = ent.c * TILE; }
    else if (ent.c >= COLS) { ent.c = 0; ent.px = ent.c * TILE; }
  }

  function ghostTarget(g) {
    if (g.state === "eaten") return { c: HOME.c, r: HOME.r - 3 }; // return to house entrance
    if (g.state === "frightened") return null; // random
    if (g.state === "scatter") return g.scatterTarget;

    // chase — per-ghost AI
    if (g.name === "blinky") {
      return { c: player.c, r: player.r };
    } else if (g.name === "pinky") {
      var d = DIRS[player.dir];
      return { c: player.c + d.x * 4, r: player.r + d.y * 4 };
    } else if (g.name === "inky") {
      var blinky = ghosts[0];
      var d2 = DIRS[player.dir];
      var ax = player.c + d2.x * 2, ay = player.r + d2.y * 2;
      return { c: ax + (ax - blinky.c), r: ay + (ay - blinky.r) };
    } else { // clyde
      var dist = Math.abs(g.c - player.c) + Math.abs(g.r - player.r);
      if (dist > 8) return { c: player.c, r: player.r };
      return g.scatterTarget;
    }
  }

  function chooseGhostDir(g) {
    g.px = g.c * TILE; g.py = g.r * TILE;

    // handle leaving / entering house
    if (g.inHouse) {
      // bob toward door and out
      if (g.releaseAt <= levelElapsed) {
        // move up out of the house through the door
        var above = "up";
        if (canMove(g.c, g.r, above, true, true)) {
          // steer toward door column (13/14)
          if (g.c < 13 && canMove(g.c, g.r, "right", true, true)) { g.dir = "right"; return; }
          if (g.c > 14 && canMove(g.c, g.r, "left", true, true)) { g.dir = "left"; return; }
          g.dir = "up";
          if (g.r <= 11) { g.inHouse = false; }
          return;
        }
      }
      // idle bob
      g.dir = (g.r <= 13) ? "down" : "up";
      return;
    }

    if (g.state === "eaten" && g.c >= 12 && g.c <= 15 && g.r >= 11 && g.r <= 12) {
      // reached house entrance: revive
      g.state = (modePhase % 2 === 0) ? "scatter" : "chase";
      g.inHouse = false;
      g.speed = 84 + (level - 1) * 4;
    }

    var target = ghostTarget(g);
    var options = ["up", "left", "down", "right"];
    var best = null, bestDist = Infinity;
    var back = opposite(g.dir);

    // frightened: pick a random valid non-reverse direction
    if (g.state === "frightened") {
      var valid = [];
      for (var i = 0; i < options.length; i++) {
        var dir = options[i];
        if (dir === back) continue;
        if (canMove(g.c, g.r, dir, true, false)) valid.push(dir);
      }
      if (valid.length === 0) {
        if (canMove(g.c, g.r, back, true, false)) g.dir = back;
      } else {
        g.dir = valid[(Math.random() * valid.length) | 0];
      }
      return;
    }

    for (var j = 0; j < options.length; j++) {
      var dr = options[j];
      if (dr === back) continue;
      var allowDoor = (g.state === "eaten");
      if (!canMove(g.c, g.r, dr, true, allowDoor)) continue;
      var nd = DIRS[dr];
      var nc = g.c + nd.x, nr = g.r + nd.y;
      var dx = nc - target.c, dy = nr - target.r;
      var dist = dx * dx + dy * dy;
      // priority order up>left>down>right resolves ties naturally by iteration
      if (dist < bestDist) { bestDist = dist; best = dr; }
    }
    if (best) g.dir = best;
    else if (canMove(g.c, g.r, back, true, g.state === "eaten")) g.dir = back;
  }

  // ---- update ----
  function update(dt) {
    if (readyTimer > 0) {
      readyTimer -= dt;
      player.mouth += dt * 8;
      return;
    }

    levelElapsed += dt;

    // scatter/chase phase machine (only affects non-frightened, non-eaten)
    if (frightTimer <= 0) {
      modeTimer += dt;
      if (modeTimer >= MODE_SCHEDULE[Math.min(modePhase, MODE_SCHEDULE.length - 1)]) {
        modeTimer = 0; modePhase++;
        var newMode = (modePhase % 2 === 0) ? "scatter" : "chase";
        for (var i = 0; i < ghosts.length; i++) {
          if (ghosts[i].state === "scatter" || ghosts[i].state === "chase") {
            ghosts[i].state = newMode;
            // reverse on mode switch (classic behavior)
            ghosts[i].dir = opposite(ghosts[i].dir);
          }
        }
      }
    }

    // fright timer
    if (frightTimer > 0) {
      frightTimer -= dt;
      if (frightTimer <= 0) {
        frightTimer = 0;
        for (var g = 0; g < ghosts.length; g++) {
          if (ghosts[g].state === "frightened") {
            ghosts[g].state = (modePhase % 2 === 0) ? "scatter" : "chase";
          }
        }
      }
    }

    // player move
    moveEntity(player, false, dt);
    player.mouth += dt * 10;

    // eat pellet
    if (player.c >= 0 && player.c < COLS && player.r >= 0 && player.r < ROWS) {
      var p = pellets[player.r][player.c];
      if (p === 1) {
        pellets[player.r][player.c] = 0; pelletCount--; score += 10;
        updateHUD(); blip(520, 0.04, "square", 0.03);
      } else if (p === 2) {
        pellets[player.r][player.c] = 0; pelletCount--; score += 50;
        updateHUD();
        // power!
        frightTimer = Math.max(2.5, 7 - (level - 1) * 0.5);
        ghostEatChain = 0;
        for (var k = 0; k < ghosts.length; k++) {
          if (ghosts[k].state === "scatter" || ghosts[k].state === "chase") {
            ghosts[k].state = "frightened";
            ghosts[k].dir = opposite(ghosts[k].dir);
          }
        }
        blip(180, 0.2, "sawtooth", 0.05);
      }
    }

    // ghosts move + collisions
    for (var gi = 0; gi < ghosts.length; gi++) {
      var gh = ghosts[gi];
      moveEntity(gh, true, dt);
      // collision (tile-level, tolerant)
      var close = Math.abs(gh.px - player.px) < TILE * 0.7 && Math.abs(gh.py - player.py) < TILE * 0.7;
      if (close) {
        if (gh.state === "frightened") {
          ghostEatChain++;
          var pts = 200 * Math.pow(2, Math.min(ghostEatChain - 1, 3));
          score += pts; updateHUD();
          gh.state = "eaten"; gh.inHouse = false;
          blip(880, 0.12, "square", 0.06);
        } else if (gh.state !== "eaten") {
          // player dies
          blip(120, 0.5, "sawtooth", 0.06);
          state = "dead"; deathAnim = 0;
          return;
        }
      }
    }

    // win check
    if (pelletCount <= 0) {
      state = "win"; blip(660, 0.3, "triangle", 0.05);
    }
  }

  function updateDeath(dt) {
    deathAnim += dt;
    if (deathAnim > 1.1) { loseLife(); }
  }

  function updateWin(dt) {
    deathAnim += dt;
    if (deathAnim > 0.9) { deathAnim = 0; winLevel(); }
  }

  // ---- draw ----
  function draw() {
    ctx.fillStyle = "#070a10";
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // walls
    ctx.lineWidth = 2;
    for (var r = 0; r < ROWS; r++) {
      for (var c = 0; c < COLS; c++) {
        var cell = grid[r][c];
        if (cell === "#") {
          drawWall(c, r);
        } else if (cell === "-") {
          ctx.fillStyle = "#f9a8d4";
          ctx.fillRect(c * TILE + 2, r * TILE + TILE / 2 - 1, TILE - 4, 2);
        }
      }
    }

    // pellets
    for (var r2 = 0; r2 < ROWS; r2++) {
      for (var c2 = 0; c2 < COLS; c2++) {
        var pp = pellets[r2][c2];
        if (pp === 1) {
          ctx.fillStyle = "#f5d7a1";
          ctx.beginPath();
          ctx.arc(c2 * TILE + TILE / 2, r2 * TILE + TILE / 2, 2, 0, Math.PI * 2);
          ctx.fill();
        } else if (pp === 2) {
          var pulse = 3 + Math.sin(Date.now() / 180) * 1.6;
          ctx.fillStyle = "#fbbf24";
          ctx.shadowColor = "#fbbf24"; ctx.shadowBlur = 8;
          ctx.beginPath();
          ctx.arc(c2 * TILE + TILE / 2, r2 * TILE + TILE / 2, pulse, 0, Math.PI * 2);
          ctx.fill();
          ctx.shadowBlur = 0;
        }
      }
    }

    // ghosts
    for (var gi = 0; gi < ghosts.length; gi++) drawGhost(ghosts[gi]);

    // player
    drawPlayer();

    // ready text
    if (readyTimer > 0 && state === "play") {
      ctx.fillStyle = "#fde047";
      ctx.font = "bold 16px -apple-system, sans-serif";
      ctx.textAlign = "center";
      ctx.fillText("READY!", canvas.width / 2, 17 * TILE + 4);
    }
  }

  function drawWall(c, r) {
    var x = c * TILE, y = r * TILE;
    ctx.fillStyle = "#0e2a5e";
    // inset rounded block with neon border
    ctx.strokeStyle = "#3b82f6";
    ctx.shadowColor = "#60a5fa"; ctx.shadowBlur = 4;
    ctx.strokeRect(x + 2.5, y + 2.5, TILE - 5, TILE - 5);
    ctx.shadowBlur = 0;
  }

  function drawPlayer() {
    var cx = player.px + TILE / 2, cy = player.py + TILE / 2;
    var rad = TILE / 2 - 1.5;
    var m = 0;
    if (state === "dead") {
      // shrink/fade mouth open
      m = Math.min(Math.PI, deathAnim * Math.PI * 1.6);
    } else {
      m = (Math.abs(Math.sin(player.mouth)) * 0.28 + 0.06) * Math.PI;
    }
    var ang = { up: -Math.PI / 2, down: Math.PI / 2, left: Math.PI, right: 0, none: 0 }[player.dir] || 0;

    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(ang);
    ctx.fillStyle = "#fde047";
    ctx.shadowColor = "#fde047"; ctx.shadowBlur = 8;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.arc(0, 0, rad, m, Math.PI * 2 - m);
    ctx.closePath();
    ctx.fill();
    ctx.shadowBlur = 0;
    ctx.restore();
  }

  function drawGhost(g) {
    var x = g.px, y = g.py;
    var cx = x + TILE / 2, cy = y + TILE / 2;
    var rad = TILE / 2 - 1.5;
    var body;
    var eyesVisible = true;

    if (g.state === "eaten") {
      body = null; // only eyes
    } else if (g.state === "frightened") {
      var blink = frightTimer < 2 && (Math.floor(frightTimer * 6) % 2 === 0);
      body = blink ? "#e5e7eb" : "#1e3a8a";
      eyesVisible = false;
    } else {
      body = g.color;
    }

    if (body) {
      ctx.fillStyle = body;
      ctx.shadowColor = body; ctx.shadowBlur = 6;
      ctx.beginPath();
      ctx.arc(cx, cy - 1, rad, Math.PI, 0);
      ctx.lineTo(cx + rad, cy + rad - 1);
      // wavy skirt
      var feet = 3, step = (rad * 2) / feet;
      for (var f = 0; f < feet; f++) {
        var fx = cx + rad - step * f;
        ctx.lineTo(fx - step / 2, cy + rad - 4);
        ctx.lineTo(fx - step, cy + rad - 1);
      }
      ctx.closePath();
      ctx.fill();
      ctx.shadowBlur = 0;
    }

    // eyes
    if (g.state === "frightened") {
      // scared face
      ctx.fillStyle = "#fde047";
      ctx.fillRect(cx - 4, cy - 2, 2, 2);
      ctx.fillRect(cx + 2, cy - 2, 2, 2);
      ctx.strokeStyle = "#fde047"; ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(cx - 4, cy + 4);
      ctx.lineTo(cx - 1, cy + 2);
      ctx.lineTo(cx + 1, cy + 4);
      ctx.lineTo(cx + 4, cy + 2);
      ctx.stroke();
    } else {
      var d = DIRS[g.dir];
      ctx.fillStyle = "#ffffff";
      ctx.beginPath(); ctx.arc(cx - 3, cy - 1, 2.4, 0, Math.PI * 2); ctx.fill();
      ctx.beginPath(); ctx.arc(cx + 3, cy - 1, 2.4, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = "#1e3a8a";
      ctx.beginPath(); ctx.arc(cx - 3 + d.x * 1.2, cy - 1 + d.y * 1.2, 1.2, 0, Math.PI * 2); ctx.fill();
      ctx.beginPath(); ctx.arc(cx + 3 + d.x * 1.2, cy - 1 + d.y * 1.2, 1.2, 0, Math.PI * 2); ctx.fill();
    }
  }

  // ---- main loop ----
  var last = 0;
  function loop(ts) {
    try {
      if (!last) last = ts;
      var dt = Math.min(0.05, (ts - last) / 1000);
      last = ts;

      if (state === "play") update(dt);
      else if (state === "dead") updateDeath(dt);
      else if (state === "win") updateWin(dt);

      if (grid.length) draw();
    } catch (e) {
      // keep the loop alive; never let the iframe throw
    }
    requestAnimationFrame(loop);
  }

  // ---- boot ----
  function boot() {
    buildGrid();
    resetPositions();
    fit();
    updateHUD();
    state = "menu";
    showOverlay("ABRAMS CHOMP",
      "Eat every pellet. Grab a power pellet to hunt the ghosts. Arrow keys / WASD or the D-pad.", true);
    requestAnimationFrame(loop);
  }

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