← back to Games Agentabrams

games/maze/index.html

693 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 Maze</title>
<style>
  :root {
    --bg: #05060c;
    --panel: rgba(10, 14, 28, 0.72);
    --wall: #1b2350;
    --wall-glow: #4b63ff;
    --floor: #090c1a;
    --player: #38ffd4;
    --player-glow: #16f0c8;
    --goal: #ff5cc8;
    --goal-glow: #ff2fb0;
    --trail: rgba(56, 255, 212, 0.16);
    --hint: #ffe45c;
    --text: #cfe0ff;
    --muted: #7488c2;
    --accent: #6c8bff;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
  html, body {
    width: 100%; height: 100%;
    background: var(--bg);
    color: var(--text);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
    overflow: hidden;
    overscroll-behavior: none;
    user-select: none;
    -webkit-user-select: none;
    touch-action: none;
  }
  #app {
    position: fixed; inset: 0;
    display: flex; flex-direction: column;
    background:
      radial-gradient(1200px 700px at 50% -10%, rgba(76, 99, 255, 0.16), transparent 60%),
      radial-gradient(900px 600px at 100% 110%, rgba(255, 92, 200, 0.10), transparent 60%),
      var(--bg);
  }
  header {
    display: flex; align-items: center; justify-content: space-between;
    gap: 8px; padding: 10px 14px;
    flex-wrap: wrap;
  }
  .title {
    font-weight: 800; letter-spacing: 2px; font-size: 17px;
    text-transform: uppercase;
    background: linear-gradient(90deg, var(--player), var(--accent), var(--goal));
    -webkit-background-clip: text; background-clip: text; color: transparent;
    text-shadow: 0 0 24px rgba(108, 139, 255, 0.35);
    white-space: nowrap;
  }
  .stats { display: flex; gap: 8px; flex-wrap: wrap; }
  .stat {
    background: var(--panel);
    border: 1px solid rgba(108, 139, 255, 0.22);
    border-radius: 10px;
    padding: 5px 10px;
    font-size: 12px;
    line-height: 1.15;
    min-width: 62px;
    text-align: center;
    backdrop-filter: blur(6px);
  }
  .stat b { display: block; font-size: 15px; color: #fff; font-variant-numeric: tabular-nums; margin-top: 1px; }
  .stat span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 1px; }
  #stage { flex: 1; position: relative; min-height: 0; }
  canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
  .toolbar {
    display: flex; gap: 8px; justify-content: center; align-items: center;
    padding: 8px 12px 4px; flex-wrap: wrap;
  }
  button.tool {
    background: var(--panel);
    border: 1px solid rgba(108, 139, 255, 0.28);
    color: var(--text);
    border-radius: 10px;
    padding: 8px 12px;
    font-size: 12px; font-weight: 600;
    letter-spacing: 0.5px;
    cursor: pointer;
    transition: transform .08s ease, border-color .15s ease, background .15s ease;
  }
  button.tool:hover { border-color: var(--accent); }
  button.tool:active { transform: scale(0.94); }
  button.tool.on { border-color: var(--player); color: var(--player); box-shadow: 0 0 14px rgba(56, 255, 212, 0.25); }
  /* D-pad */
  #dpad {
    display: grid;
    grid-template-columns: repeat(3, 60px);
    grid-template-rows: repeat(3, 60px);
    gap: 8px;
    justify-content: center;
    padding: 6px 0 16px;
    touch-action: none;
  }
  .dbtn {
    background: var(--panel);
    border: 1px solid rgba(108, 139, 255, 0.3);
    border-radius: 14px;
    color: var(--accent);
    font-size: 26px; font-weight: 700;
    display: flex; align-items: center; justify-content: center;
    cursor: pointer;
    transition: transform .06s ease, background .1s ease;
    user-select: none;
  }
  .dbtn:active, .dbtn.press { background: rgba(108, 139, 255, 0.28); transform: scale(0.9); color: #fff; }
  .dbtn.up { grid-column: 2; grid-row: 1; }
  .dbtn.left { grid-column: 1; grid-row: 2; }
  .dbtn.right { grid-column: 3; grid-row: 2; }
  .dbtn.down { grid-column: 2; grid-row: 3; }
  .dbtn.spacer { visibility: hidden; }
  #banner {
    position: absolute; left: 50%; top: 42%;
    transform: translate(-50%, -50%) scale(0.8);
    text-align: center; pointer-events: none;
    opacity: 0; transition: opacity .35s ease, transform .35s ease;
    z-index: 5;
  }
  #banner.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  #banner .big {
    font-size: clamp(28px, 8vw, 60px); font-weight: 900; letter-spacing: 2px;
    background: linear-gradient(90deg, var(--player), var(--goal));
    -webkit-background-clip: text; background-clip: text; color: transparent;
    text-shadow: 0 0 40px rgba(255, 92, 200, 0.4);
  }
  #banner .sub { color: var(--text); margin-top: 6px; font-size: clamp(13px, 3.5vw, 18px); }
  .hint-desktop { color: var(--muted); font-size: 11px; text-align: center; padding: 0 12px 4px; }
  @media (min-width: 760px) and (hover: hover) {
    #dpad { display: none; }
  }
  @media (max-width: 380px) {
    #dpad { grid-template-columns: repeat(3, 52px); grid-template-rows: repeat(3, 52px); }
  }
</style>
</head>
<body>
<div id="app">
  <header>
    <div class="title">Abrams Maze</div>
    <div class="stats">
      <div class="stat"><span>Level</span><b id="s-level">1</b></div>
      <div class="stat"><span>Time</span><b id="s-time">0.0</b></div>
      <div class="stat"><span>Best Time</span><b id="s-best">—</b></div>
      <div class="stat"><span>Top Level</span><b id="s-top">1</b></div>
    </div>
  </header>

  <div id="stage">
    <canvas id="c"></canvas>
    <div id="banner"><div class="big" id="banner-big">LEVEL UP</div><div class="sub" id="banner-sub"></div></div>
  </div>

  <div class="toolbar">
    <button class="tool" id="btn-hint">💡 Hint Path</button>
    <button class="tool on" id="btn-trail">🍞 Trail</button>
    <button class="tool" id="btn-sound">🔇 Sound</button>
    <button class="tool" id="btn-new">🎲 New Maze</button>
  </div>
  <div class="hint-desktop">Arrow keys / WASD to move · hold to glide · reach the pink goal</div>

  <div id="dpad">
    <div class="dbtn spacer"></div>
    <div class="dbtn up" data-dir="up">▲</div>
    <div class="dbtn spacer"></div>
    <div class="dbtn left" data-dir="left">◀</div>
    <div class="dbtn spacer"></div>
    <div class="dbtn right" data-dir="right">▶</div>
    <div class="dbtn spacer"></div>
    <div class="dbtn down" data-dir="down">▼</div>
    <div class="dbtn spacer"></div>
  </div>
</div>

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

  // ---------- Persistent state ----------
  var LS_KEY = "abrams-maze-v1";
  var store = { bestTime: null, topLevel: 1, sound: false, trail: true };
  try {
    var raw = localStorage.getItem(LS_KEY);
    if (raw) { var p = JSON.parse(raw); for (var k in p) if (p[k] !== undefined) store[k] = p[k]; }
  } catch (e) {}
  function save() { try { localStorage.setItem(LS_KEY, JSON.stringify(store)); } catch (e) {} }

  // ---------- DOM ----------
  var canvas = document.getElementById("c");
  var ctx = canvas.getContext("2d");
  var stage = document.getElementById("stage");
  var elLevel = document.getElementById("s-level");
  var elTime = document.getElementById("s-time");
  var elBest = document.getElementById("s-best");
  var elTop = document.getElementById("s-top");
  var banner = document.getElementById("banner");
  var bannerBig = document.getElementById("banner-big");
  var bannerSub = document.getElementById("banner-sub");

  // ---------- Audio (procedural, optional) ----------
  var actx = null;
  function ensureAudio() {
    if (!store.sound) return;
    if (!actx) {
      try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { actx = null; }
    }
    if (actx && actx.state === "suspended") actx.resume();
  }
  function beep(freq, dur, type, vol) {
    if (!store.sound || !actx) return;
    try {
      var o = actx.createOscillator(), g = actx.createGain();
      o.type = type || "sine";
      o.frequency.value = freq;
      g.gain.value = 0;
      o.connect(g); g.connect(actx.destination);
      var t = actx.currentTime;
      g.gain.setValueAtTime(0, t);
      g.gain.linearRampToValueAtTime(vol || 0.08, t + 0.008);
      g.gain.exponentialRampToValueAtTime(0.0001, t + (dur || 0.09));
      o.start(t); o.stop(t + (dur || 0.09) + 0.02);
    } catch (e) {}
  }
  function sfxStep() { beep(180 + Math.random() * 40, 0.05, "triangle", 0.04); }
  function sfxBump() { beep(90, 0.08, "square", 0.05); }
  function sfxWin() {
    if (!store.sound || !actx) return;
    var notes = [523, 659, 784, 1046];
    notes.forEach(function (f, i) { setTimeout(function () { beep(f, 0.16, "sine", 0.09); }, i * 90); });
  }

  // ---------- Maze generation (recursive backtracker) ----------
  // Each cell stores wall bits: 1=N,2=E,4=S,8=W (bit set means wall present)
  var maze = null; // {cols, rows, cells}
  var N = 1, E = 2, S = 4, W = 8;
  var DX = { 1: 0, 2: 1, 4: 0, 8: -1 };
  var DY = { 1: -1, 2: 0, 4: 1, 8: 0 };
  var OPP = { 1: 4, 2: 8, 4: 1, 8: 2 };

  function genMaze(cols, rows) {
    var cells = new Uint8Array(cols * rows);
    for (var i = 0; i < cells.length; i++) cells[i] = 15; // all walls
    var visited = new Uint8Array(cols * rows);
    var stack = [];
    var startIdx = 0;
    visited[startIdx] = 1;
    stack.push(startIdx);
    while (stack.length) {
      var cur = stack[stack.length - 1];
      var cx = cur % cols, cy = (cur / cols) | 0;
      var dirs = [N, E, S, W];
      // shuffle
      for (var s = dirs.length - 1; s > 0; s--) {
        var r = (Math.random() * (s + 1)) | 0;
        var tmp = dirs[s]; dirs[s] = dirs[r]; dirs[r] = tmp;
      }
      var moved = false;
      for (var d = 0; d < 4; d++) {
        var dir = dirs[d];
        var nx = cx + DX[dir], ny = cy + DY[dir];
        if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
        var nIdx = ny * cols + nx;
        if (visited[nIdx]) continue;
        cells[cur] &= ~dir;
        cells[nIdx] &= ~OPP[dir];
        visited[nIdx] = 1;
        stack.push(nIdx);
        moved = true;
        break;
      }
      if (!moved) stack.pop();
    }
    return { cols: cols, rows: rows, cells: cells };
  }

  function canMove(m, cx, cy, dir) {
    var idx = cy * m.cols + cx;
    return (m.cells[idx] & dir) === 0;
  }

  // BFS solution path from start to goal
  function solvePath(m, sx, sy, gx, gy) {
    var cols = m.cols, rows = m.rows;
    var prev = new Int32Array(cols * rows).fill(-1);
    var seen = new Uint8Array(cols * rows);
    var q = [sy * cols + sx];
    seen[q[0]] = 1;
    var head = 0;
    while (head < q.length) {
      var cur = q[head++];
      if (cur === gy * cols + gx) break;
      var cx = cur % cols, cy = (cur / cols) | 0;
      var dirs = [N, E, S, W];
      for (var d = 0; d < 4; d++) {
        var dir = dirs[d];
        if (m.cells[cur] & dir) continue;
        var nx = cx + DX[dir], ny = cy + DY[dir];
        if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
        var nIdx = ny * cols + nx;
        if (seen[nIdx]) continue;
        seen[nIdx] = 1; prev[nIdx] = cur; q.push(nIdx);
      }
    }
    var path = [];
    var t = gy * cols + gx;
    if (!seen[t]) return path;
    while (t !== -1) { path.push(t); t = prev[t]; }
    path.reverse();
    return path;
  }

  // ---------- Game state ----------
  var game = {
    level: 1,
    cols: 8, rows: 8,
    player: { cx: 0, cy: 0, x: 0, y: 0 }, // cx/cy = cell, x/y = pixel (animated)
    goal: { cx: 0, cy: 0 },
    startTime: 0,
    elapsed: 0,
    running: false,
    won: false,
    trail: {},          // "cx,cy" -> visit count
    hintPath: null,     // array of idx
    hintUntil: 0,
    cell: 24, ox: 0, oy: 0
  };

  function sizeForLevel(lvl) {
    var base = 7 + lvl; // grows each level
    return Math.min(base, 45);
  }

  function newLevel(lvl, isNewGame) {
    game.level = lvl;
    var sz = sizeForLevel(lvl);
    game.cols = sz; game.rows = sz;
    maze = genMaze(sz, sz);
    game.player.cx = 0; game.player.cy = 0;
    game.goal.cx = sz - 1; game.goal.cy = sz - 1;
    game.trail = {};
    game.trail["0,0"] = 1;
    game.hintPath = null;
    game.hintUntil = 0;
    game.won = false;
    game.running = true;
    game.elapsed = 0;
    game.startTime = performance.now();
    layout();
    // snap pixel pos to start cell
    game.player.x = game.ox + game.player.cx * game.cell + game.cell / 2;
    game.player.y = game.oy + game.player.cy * game.cell + game.cell / 2;
    elLevel.textContent = lvl;
    updateBanner(isNewGame);
  }

  function updateBanner(isNewGame) {
    if (isNewGame) return;
    bannerBig.textContent = "LEVEL " + game.level;
    bannerSub.textContent = game.cols + " × " + game.rows + " maze";
    banner.classList.add("show");
    setTimeout(function () { banner.classList.remove("show"); }, 1100);
  }

  // ---------- Layout / sizing ----------
  var dpr = 1;
  function layout() {
    dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
    var w = stage.clientWidth, h = stage.clientHeight;
    canvas.width = Math.floor(w * dpr);
    canvas.height = Math.floor(h * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    var pad = 14;
    var avail = Math.min(w, h) - pad * 2;
    game.cell = Math.max(6, Math.floor(avail / game.cols));
    var gridW = game.cell * game.cols;
    var gridH = game.cell * game.rows;
    game.ox = Math.floor((w - gridW) / 2);
    game.oy = Math.floor((h - gridH) / 2);
  }

  window.addEventListener("resize", function () {
    var pcx = game.player.cx, pcy = game.player.cy;
    layout();
    // keep player pixel aligned to its cell after resize
    game.player.x = game.ox + pcx * game.cell + game.cell / 2;
    game.player.y = game.oy + pcy * game.cell + game.cell / 2;
  });

  // ---------- Movement ----------
  var moveCooldown = 0;
  function tryMove(dir) {
    if (!game.running || game.won) return;
    var p = game.player;
    if (!canMove(maze, p.cx, p.cy, dir)) { sfxBump(); return; }
    p.cx += DX[dir]; p.cy += DY[dir];
    var key = p.cx + "," + p.cy;
    game.trail[key] = (game.trail[key] || 0) + 1;
    sfxStep();
    if (p.cx === game.goal.cx && p.cy === game.goal.cy) win();
  }

  var dirMap = {
    ArrowUp: N, KeyW: N, ArrowRight: E, KeyD: E,
    ArrowDown: S, KeyS: S, ArrowLeft: W, KeyA: W
  };
  var held = {}; // dir -> true
  var repeatTimer = 0;

  window.addEventListener("keydown", function (e) {
    var dir = dirMap[e.code];
    if (dir) {
      e.preventDefault();
      ensureAudio();
      if (!held[dir]) { held[dir] = true; tryMove(dir); repeatTimer = 0; }
    } else if (e.code === "Space") {
      e.preventDefault(); doHint();
    }
  });
  window.addEventListener("keyup", function (e) {
    var dir = dirMap[e.code];
    if (dir) held[dir] = false;
  });

  // D-pad + held-to-glide
  var dpad = document.getElementById("dpad");
  var dpadHeld = {};
  var dirWord = { up: N, down: S, left: W, right: E };
  function dpadPress(word, el) {
    ensureAudio();
    var dir = dirWord[word];
    if (!dpadHeld[dir]) { dpadHeld[dir] = true; tryMove(dir); }
    if (el) el.classList.add("press");
  }
  function dpadRelease(word, el) {
    dpadHeld[dirWord[word]] = false;
    if (el) el.classList.remove("press");
  }
  Array.prototype.forEach.call(dpad.querySelectorAll(".dbtn[data-dir]"), function (btn) {
    var word = btn.getAttribute("data-dir");
    btn.addEventListener("touchstart", function (e) { e.preventDefault(); dpadPress(word, btn); }, { passive: false });
    btn.addEventListener("touchend", function (e) { e.preventDefault(); dpadRelease(word, btn); }, { passive: false });
    btn.addEventListener("touchcancel", function (e) { dpadRelease(word, btn); });
    btn.addEventListener("mousedown", function (e) { e.preventDefault(); dpadPress(word, btn); });
    btn.addEventListener("mouseup", function () { dpadRelease(word, btn); });
    btn.addEventListener("mouseleave", function () { dpadRelease(word, btn); });
  });

  // Swipe on canvas as an alternate touch control
  var swipe = null;
  stage.addEventListener("touchstart", function (e) {
    if (e.target !== canvas) return;
    ensureAudio();
    var t = e.touches[0];
    swipe = { x: t.clientX, y: t.clientY };
  }, { passive: true });
  stage.addEventListener("touchmove", function (e) {
    if (!swipe) return;
    var t = e.touches[0];
    var dx = t.clientX - swipe.x, dy = t.clientY - swipe.y;
    var thresh = 26;
    if (Math.abs(dx) > thresh || Math.abs(dy) > thresh) {
      if (Math.abs(dx) > Math.abs(dy)) tryMove(dx > 0 ? E : W);
      else tryMove(dy > 0 ? S : N);
      swipe = { x: t.clientX, y: t.clientY };
    }
  }, { passive: true });
  stage.addEventListener("touchend", function () { swipe = null; });

  // ---------- Win / level up ----------
  function win() {
    game.won = true;
    game.running = false;
    game.elapsed = (performance.now() - game.startTime) / 1000;
    sfxWin();
    var t = game.elapsed;
    var newBest = (store.bestTime == null || t < store.bestTime);
    if (newBest) store.bestTime = t;
    if (game.level + 1 > store.topLevel) store.topLevel = game.level + 1;
    save();
    refreshHUD();
    bannerBig.textContent = "SOLVED!";
    bannerSub.textContent = "Level " + game.level + " · " + t.toFixed(2) + "s" + (newBest ? " · NEW BEST!" : "");
    banner.classList.add("show");
    setTimeout(function () {
      banner.classList.remove("show");
      setTimeout(function () { newLevel(game.level + 1, false); }, 250);
    }, 1400);
  }

  // ---------- Hint path ----------
  function doHint() {
    if (!game.running || game.won) return;
    ensureAudio();
    game.hintPath = solvePath(maze, game.player.cx, game.player.cy, game.goal.cx, game.goal.cy);
    game.hintUntil = performance.now() + 1600;
    beep(660, 0.12, "sine", 0.06);
  }

  // ---------- HUD ----------
  function refreshHUD() {
    elBest.textContent = store.bestTime == null ? "—" : store.bestTime.toFixed(2) + "s";
    elTop.textContent = store.topLevel;
  }

  // ---------- Buttons ----------
  var btnHint = document.getElementById("btn-hint");
  var btnTrail = document.getElementById("btn-trail");
  var btnSound = document.getElementById("btn-sound");
  var btnNew = document.getElementById("btn-new");

  btnHint.addEventListener("click", doHint);
  btnTrail.addEventListener("click", function () {
    store.trail = !store.trail; save();
    btnTrail.classList.toggle("on", store.trail);
  });
  btnSound.addEventListener("click", function () {
    store.sound = !store.sound; save();
    btnSound.classList.toggle("on", store.sound);
    btnSound.textContent = store.sound ? "🔊 Sound" : "🔇 Sound";
    if (store.sound) { ensureAudio(); beep(520, 0.1, "sine", 0.07); }
  });
  btnNew.addEventListener("click", function () { ensureAudio(); newLevel(game.level, false); });

  // init button visual states
  btnTrail.classList.toggle("on", store.trail);
  btnSound.classList.toggle("on", store.sound);
  btnSound.textContent = store.sound ? "🔊 Sound" : "🔇 Sound";

  // ---------- Render ----------
  function draw(now) {
    var w = stage.clientWidth, h = stage.clientHeight;
    ctx.clearRect(0, 0, w, h);

    var cell = game.cell, ox = game.ox, oy = game.oy, cols = game.cols, rows = game.rows;
    var gridW = cell * cols, gridH = cell * rows;

    // floor
    ctx.fillStyle = getVar("--floor");
    roundRect(ctx, ox - 4, oy - 4, gridW + 8, gridH + 8, 8);
    ctx.fill();

    // trail
    if (store.trail) {
      ctx.fillStyle = getVar("--trail");
      for (var key in game.trail) {
        var parts = key.split(","); var tx = +parts[0], ty = +parts[1];
        ctx.fillRect(ox + tx * cell + 1, oy + ty * cell + 1, cell - 2, cell - 2);
      }
    }

    // hint path
    if (game.hintPath && now < game.hintUntil) {
      var pulse = 0.35 + 0.35 * (0.5 + 0.5 * Math.sin(now / 120));
      ctx.save();
      ctx.strokeStyle = getVar("--hint");
      ctx.globalAlpha = pulse;
      ctx.lineWidth = Math.max(2, cell * 0.28);
      ctx.lineJoin = "round"; ctx.lineCap = "round";
      ctx.shadowColor = getVar("--hint");
      ctx.shadowBlur = 12;
      ctx.beginPath();
      for (var hi = 0; hi < game.hintPath.length; hi++) {
        var id = game.hintPath[hi];
        var hx = id % cols, hy = (id / cols) | 0;
        var px = ox + hx * cell + cell / 2, py = oy + hy * cell + cell / 2;
        if (hi === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
      }
      ctx.stroke();
      ctx.restore();
    } else if (game.hintPath && now >= game.hintUntil) {
      game.hintPath = null;
    }

    // walls
    ctx.save();
    ctx.strokeStyle = getVar("--wall-glow");
    ctx.shadowColor = getVar("--wall-glow");
    ctx.shadowBlur = Math.min(10, cell * 0.4);
    ctx.lineWidth = Math.max(1.4, cell * 0.09);
    ctx.lineCap = "round";
    ctx.beginPath();
    for (var y = 0; y < rows; y++) {
      for (var x = 0; x < cols; x++) {
        var c = maze.cells[y * cols + x];
        var x0 = ox + x * cell, y0 = oy + y * cell, x1 = x0 + cell, y1 = y0 + cell;
        if (c & N) { ctx.moveTo(x0, y0); ctx.lineTo(x1, y0); }
        if (c & W) { ctx.moveTo(x0, y0); ctx.lineTo(x0, y1); }
        if (c & E) { ctx.moveTo(x1, y0); ctx.lineTo(x1, y1); }
        if (c & S) { ctx.moveTo(x0, y1); ctx.lineTo(x1, y1); }
      }
    }
    ctx.stroke();
    ctx.restore();

    // goal
    var gx = ox + game.goal.cx * cell + cell / 2;
    var gy = oy + game.goal.cy * cell + cell / 2;
    var gr = cell * 0.3;
    var gpulse = 1 + 0.14 * Math.sin(now / 220);
    ctx.save();
    ctx.shadowColor = getVar("--goal-glow");
    ctx.shadowBlur = 22;
    ctx.fillStyle = getVar("--goal");
    star(ctx, gx, gy, gr * gpulse, gr * 0.45 * gpulse, 5, now / 900);
    ctx.fill();
    ctx.restore();

    // player (animate pixel toward its cell)
    var targetX = ox + game.player.cx * cell + cell / 2;
    var targetY = oy + game.player.cy * cell + cell / 2;
    game.player.x += (targetX - game.player.x) * 0.35;
    game.player.y += (targetY - game.player.y) * 0.35;
    var pr = cell * 0.30;
    ctx.save();
    ctx.shadowColor = getVar("--player-glow");
    ctx.shadowBlur = 20;
    var grad = ctx.createRadialGradient(game.player.x, game.player.y, 1, game.player.x, game.player.y, pr);
    grad.addColorStop(0, "#eafffb");
    grad.addColorStop(0.5, getVar("--player"));
    grad.addColorStop(1, getVar("--player-glow"));
    ctx.fillStyle = grad;
    ctx.beginPath();
    ctx.arc(game.player.x, game.player.y, pr, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();

    // live timer
    if (game.running && !game.won) {
      game.elapsed = (now - game.startTime) / 1000;
    }
    elTime.textContent = game.elapsed.toFixed(1);
  }

  function star(c, cx, cy, outer, inner, points, rot) {
    c.beginPath();
    for (var i = 0; i < points * 2; i++) {
      var rr = (i % 2 === 0) ? outer : inner;
      var a = rot + (i * Math.PI) / points;
      var px = cx + Math.cos(a) * rr, py = cy + Math.sin(a) * rr;
      if (i === 0) c.moveTo(px, py); else c.lineTo(px, py);
    }
    c.closePath();
  }
  function roundRect(c, x, y, w, h, r) {
    c.beginPath();
    c.moveTo(x + r, y);
    c.arcTo(x + w, y, x + w, y + h, r);
    c.arcTo(x + w, y + h, x, y + h, r);
    c.arcTo(x, y + h, x, y, r);
    c.arcTo(x, y, x + w, y, r);
    c.closePath();
  }
  var varCache = {};
  function getVar(name) {
    if (varCache[name]) return varCache[name];
    var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
    varCache[name] = v || "#fff";
    return varCache[name];
  }

  // ---------- Main loop (held-to-glide repeat) ----------
  var lastRepeat = 0;
  function loop(now) {
    // handle held movement (keyboard + dpad) with a steady cadence
    var anyHeld = false, heldDir = 0;
    for (var d in held) if (held[d]) { anyHeld = true; heldDir = +d; }
    if (!anyHeld) for (var d2 in dpadHeld) if (dpadHeld[d2]) { anyHeld = true; heldDir = +d2; }
    if (anyHeld && heldDir) {
      if (now - lastRepeat > 110) { tryMove(heldDir); lastRepeat = now; }
    }
    draw(now);
    requestAnimationFrame(loop);
  }

  // ---------- Boot ----------
  function boot() {
    refreshHUD();
    // Resume best progress: start at level 1 fresh each session, but keep records.
    newLevel(1, false);
    requestAnimationFrame(loop);
  }
  // ensure stage has size before first layout
  requestAnimationFrame(boot);
})();
</script>
</body>
</html>