← back to Games Agentabrams
games/chomp/index.html
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">🔊</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">▲</button><button class="sp"></button>
<button data-dir="left">◀</button><button class="sp"></button><button data-dir="right">▶</button>
<button class="sp"></button><button data-dir="down">▼</button><button class="sp"></button>
</div>
<div class="hint">Arrow keys / WASD to steer · 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 ? "🔇" : "🔊";
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>