17

Pac-Man

arrow keys to steer

The 1980 Namco arcade classic on a hand-crafted 21x15 maze. Steer Pac with the arrow keys, gobble every dot to clear the board, and avoid the three ghosts โ€” Blinky, Inky, and Clyde โ€” who chase you with a simple Manhattan-distance heuristic: at every intersection each ghost picks the legal neighbor tile that minimizes , never reversing on itself. Grab one of the four flashing power pellets in the corners and the tables turn: ghosts go blue and edible for six seconds, fleeing by maximizing the same distance metric, and eating one is worth 200 points. An eyes-only ghost returns to the pen to respawn. The row-7 tunnel wraps left-to-right. Three lives, click to restart on game over.

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.