17
Pac-Man
arrow keys to steer
idle
242 lines ยท vanilla
view source
// Pac-Man. 21x15 maze, dots + 4 power pellets, 3 ghosts. Manhattan-distance chase AI.
// Power pellet -> ghosts edible (blue) for 6s. Arrow keys steer. Click restarts.
const COLS = 21, ROWS = 15;
const MAZE = [
"#####################",
"#.........#.........#",
"#o###.###.#.###.###o#",
"#.#...............#.#",
"#.#.###.#####.###.#.#",
"#.....#.......#.....#",
"###.#.#.##-##.#.#.###",
" #.#.#.# #.#.#.# ",
"###.#.#.#####.#.#.###",
"#.....#.......#.....#",
"#.#.###.#####.###.#.#",
"#.#...............#.#",
"#o###.###.#.###.###o#",
"#.........#.........#",
"#####################",
];
const PAC_SPEED = 5.0, GHOST_SPEED = 4.2, FRIGHT_SPEED = 2.6, POWER_DURATION = 6;
const GHOST_COLORS = ["#ff3b30", "#00d1ff", "#ffb86b"];
let grid, cell, ox, oy, pac, ghosts;
let score, lives, dotsLeft, powerTimer, respawnTimer, elapsed;
let alive, won;
function wall(gx, gy, pac0) {
if (gy < 0 || gy >= ROWS) return true;
if (gx < 0 || gx >= COLS) return false; // tunnel
const c = grid[gy][gx];
return c === '#' || (pac0 && c === '-');
}
function setChar(gy, gx, ch) {
grid[gy] = grid[gy].slice(0, gx) + ch + grid[gy].slice(gx + 1);
}
function reset(full) {
grid = MAZE.slice();
dotsLeft = 0;
for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
const c = grid[y][x];
if (c === '.' || c === 'o') dotsLeft++;
}
pac = { gx: 10, gy: 11, x: 10.5, y: 11.5, dir: [0, 0], nextDir: [0, 0] };
ghosts = [9, 10, 11].map((gx, i) => ({
gx, gy: 7, x: gx + 0.5, y: 7.5, dir: [0, -1],
color: GHOST_COLORS[i], home: { gx, gy: 7 }, state: "chase",
}));
powerTimer = 0;
respawnTimer = 0;
if (full) { score = 0; lives = 3; alive = true; won = false; }
}
function layout(w, h) {
cell = Math.floor(Math.min((w - 16) / COLS, (h - 40) / ROWS));
if (cell < 8) cell = 8;
ox = Math.floor((w - cell * COLS) / 2);
oy = 24;
}
function init({ width, height }) { layout(width, height); reset(true); elapsed = 0; }
function atCenter(p) {
return Math.abs(p.x - p.gx - 0.5) < 0.08 && Math.abs(p.y - p.gy - 0.5) < 0.08;
}
function move(p, speed, dt, isPac) {
let nx = p.x + p.dir[0] * speed * dt;
let ny = p.y + p.dir[1] * speed * dt;
if (nx < -0.5) nx += COLS;
else if (nx > COLS + 0.5) nx -= COLS;
const lx = p.gx + p.dir[0], ly = p.gy + p.dir[1];
if (wall(lx, ly, isPac)) {
if (p.dir[0] !== 0) {
if ((p.dir[0] > 0 && nx > p.gx + 0.5) || (p.dir[0] < 0 && nx < p.gx + 0.5)) {
nx = p.gx + 0.5; p.dir = [0, 0];
}
}
if (p.dir[1] !== 0) {
if ((p.dir[1] > 0 && ny > p.gy + 0.5) || (p.dir[1] < 0 && ny < p.gy + 0.5)) {
ny = p.gy + 0.5; p.dir = [0, 0];
}
}
}
p.x = nx; p.y = ny;
p.gx = Math.max(0, Math.min(COLS - 1, Math.floor(p.x)));
p.gy = Math.floor(p.y);
}
function ghostChoose(g, tx, ty) {
const opts = [[1, 0], [-1, 0], [0, 1], [0, -1]];
let best = null, bestD = 1e9;
for (const d of opts) {
if (d[0] === -g.dir[0] && d[1] === -g.dir[1]) continue;
const nx = g.gx + d[0], ny = g.gy + d[1];
if (wall(nx, ny, false)) continue;
const dd = Math.abs(nx + 0.5 - tx) + Math.abs(ny + 0.5 - ty);
if (dd < bestD) { bestD = dd; best = d; }
}
g.dir = best || [-g.dir[0], -g.dir[1]];
}
function tick({ ctx, dt, width, height, input }) {
if (dt > 0.06) dt = 0.06;
elapsed += dt;
layout(width, height);
const clicks = input.consumeClicks();
if (!alive || won) {
if (clicks.length > 0) reset(true);
draw(ctx, width, height);
overlay(ctx, width, height);
return;
}
if (input.keyDown("ArrowUp")) pac.nextDir = [0, -1];
else if (input.keyDown("ArrowDown")) pac.nextDir = [0, 1];
else if (input.keyDown("ArrowLeft")) pac.nextDir = [-1, 0];
else if (input.keyDown("ArrowRight")) pac.nextDir = [1, 0];
if (respawnTimer > 0) { respawnTimer -= dt; draw(ctx, width, height); return; }
if ((pac.nextDir[0] || pac.nextDir[1]) && atCenter(pac)) {
const nx = pac.gx + pac.nextDir[0], ny = pac.gy + pac.nextDir[1];
if (!wall(nx, ny, true)) {
pac.dir = pac.nextDir;
pac.x = pac.gx + 0.5; pac.y = pac.gy + 0.5;
}
}
move(pac, PAC_SPEED, dt, true);
// Eat
const c = grid[pac.gy] && grid[pac.gy][pac.gx];
if (c === '.') { setChar(pac.gy, pac.gx, ' '); score += 10; dotsLeft--; }
else if (c === 'o') {
setChar(pac.gy, pac.gx, ' '); score += 50; dotsLeft--;
powerTimer = POWER_DURATION;
for (const g of ghosts) if (g.state !== "eaten") g.state = "fright";
}
if (dotsLeft <= 0) { won = true; return; }
if (powerTimer > 0) {
powerTimer -= dt;
if (powerTimer <= 0) {
for (const g of ghosts) if (g.state === "fright") g.state = "chase";
powerTimer = 0;
}
}
for (const g of ghosts) {
let speed = GHOST_SPEED, tx, ty;
if (g.state === "eaten") {
speed = GHOST_SPEED * 1.6;
tx = g.home.gx + 0.5; ty = g.home.gy + 0.5;
if (Math.abs(g.x - tx) < 0.2 && Math.abs(g.y - ty) < 0.2) {
g.state = "chase"; g.x = tx; g.y = ty; g.gx = g.home.gx; g.gy = g.home.gy;
g.dir = [0, -1]; continue;
}
} else if (g.state === "fright") {
speed = FRIGHT_SPEED;
tx = 2 * (g.gx + 0.5) - pac.x; ty = 2 * (g.gy + 0.5) - pac.y;
} else { tx = pac.x; ty = pac.y; }
if (atCenter(g)) ghostChoose(g, tx, ty);
move(g, speed, dt, false);
}
for (const g of ghosts) {
if (g.state === "eaten") continue;
if (Math.hypot(g.x - pac.x, g.y - pac.y) < 0.55) {
if (g.state === "fright") { g.state = "eaten"; score += 200; }
else {
lives--;
if (lives <= 0) { alive = false; }
else {
pac.gx = 10; pac.gy = 11; pac.x = 10.5; pac.y = 11.5;
pac.dir = [0, 0]; pac.nextDir = [0, 0];
for (const gh of ghosts) {
gh.x = gh.home.gx + 0.5; gh.y = gh.home.gy + 0.5;
gh.gx = gh.home.gx; gh.gy = gh.home.gy;
gh.dir = [0, -1]; gh.state = "chase";
}
powerTimer = 0; respawnTimer = 0.8;
}
break;
}
}
}
draw(ctx, width, height);
}
function draw(ctx, W, H) {
ctx.fillStyle = "#05050d"; ctx.fillRect(0, 0, W, H);
for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
const c = grid[y][x], px = ox + x * cell, py = oy + y * cell;
if (c === '#') {
ctx.fillStyle = "#1a44d8"; ctx.fillRect(px + 1, py + 1, cell - 2, cell - 2);
ctx.fillStyle = "#3360ff"; ctx.fillRect(px + 2, py + 2, cell - 4, cell - 4);
} else if (c === '-') {
ctx.fillStyle = "#ff9ccb"; ctx.fillRect(px + 1, py + cell / 2 - 1, cell - 2, 2);
} else if (c === '.') {
ctx.fillStyle = "#ffd699";
ctx.beginPath(); ctx.arc(px + cell / 2, py + cell / 2, Math.max(1, cell * 0.08), 0, Math.PI * 2); ctx.fill();
} else if (c === 'o') {
ctx.fillStyle = "#ffe680";
ctx.globalAlpha = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(elapsed * 6));
ctx.beginPath(); ctx.arc(px + cell / 2, py + cell / 2, Math.max(2, cell * 0.28), 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
}
}
// Pac
const pxC = ox + pac.x * cell, pyC = oy + pac.y * cell, pr = cell * 0.45;
let a = 0;
if (pac.dir[0] === -1) a = Math.PI;
else if (pac.dir[1] === -1) a = -Math.PI / 2;
else if (pac.dir[1] === 1) a = Math.PI / 2;
const m = 0.15 + 0.25 * (0.5 + 0.5 * Math.sin(elapsed * 18));
ctx.fillStyle = "#ffe93b";
ctx.beginPath(); ctx.moveTo(pxC, pyC);
ctx.arc(pxC, pyC, pr, a + m, a - m + Math.PI * 2);
ctx.closePath(); ctx.fill();
// Ghosts
for (const g of ghosts) {
const gx = ox + g.x * cell, gy = oy + g.y * cell, r = cell * 0.42;
let body = g.color;
if (g.state === "eaten") body = null;
else if (g.state === "fright") {
const flicker = powerTimer < 2 && Math.floor(elapsed * 8) % 2 === 0;
body = flicker ? "#ffffff" : "#3140ff";
}
if (body) {
ctx.fillStyle = body;
ctx.beginPath(); ctx.arc(gx, gy, r, Math.PI, 0);
ctx.lineTo(gx + r, gy + r);
const teeth = 4;
for (let i = 0; i < teeth; i++) {
const tx = gx + r - ((i + 1) * (2 * r) / teeth);
ctx.lineTo(tx, gy + r - ((i % 2 === 0) ? 0 : r * 0.35));
}
ctx.lineTo(gx - r, gy + r); ctx.closePath(); ctx.fill();
}
const eyeR = Math.max(1.5, r * 0.22), pupR = Math.max(1, r * 0.11), off = r * 0.32;
ctx.fillStyle = "#fff";
ctx.beginPath(); ctx.arc(gx - off, gy - r * 0.1, eyeR, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(gx + off, gy - r * 0.1, eyeR, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#06122b";
const pdx = g.dir[0] * eyeR * 0.5, pdy = g.dir[1] * eyeR * 0.5;
ctx.beginPath(); ctx.arc(gx - off + pdx, gy - r * 0.1 + pdy, pupR, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(gx + off + pdx, gy - r * 0.1 + pdy, pupR, 0, Math.PI * 2); ctx.fill();
}
// HUD
ctx.font = "13px system-ui, sans-serif";
ctx.textAlign = "left"; ctx.fillStyle = "#e8e8f0"; ctx.fillText(`Score ${score}`, 8, 16);
ctx.textAlign = "center";
if (powerTimer > 0) { ctx.fillStyle = "#9ad1ff"; ctx.fillText(`POWER ${powerTimer.toFixed(1)}s`, W / 2, 16); }
else { ctx.fillStyle = "#8a90a4"; ctx.fillText(`dots ${dotsLeft}`, W / 2, 16); }
ctx.textAlign = "right"; ctx.fillStyle = "#ffe93b"; ctx.fillText(`Lives ${lives}`, W - 8, 16);
}
function overlay(ctx, W, H) {
ctx.fillStyle = "rgba(5,5,15,0.72)"; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "#ffe93b"; ctx.textAlign = "center";
ctx.font = "bold 24px system-ui, sans-serif";
ctx.fillText(won ? "You cleared the maze" : "Game over", W / 2, H / 2 - 8);
ctx.font = "13px system-ui, sans-serif"; ctx.fillStyle = "#dfe2f0";
ctx.fillText(`Score ${score} โ click to restart`, W / 2, H / 2 + 16);
}
Comments (0)
Log in to comment.