← 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>