逛 52pojie 的爱盘(down.52pojie.cn,专门放破解工具的下载站)时,随手点了个不存在的路径,本该跳 404,结果屏幕上出现了一盘六边形棋子和一只猫——这就是它的 404 彩蛋:圈小猫,就是当年微信上疯传过的「围住神经猫」。

你没看错,一个下载站的 404 页面里藏了个完整游戏。我扒了它的源码,发现这玩意儿值得单独拎出来讲讲:它不是随便糊的 demo,猫的走法里有真算法。

原版是怎么做的

F12 看网络请求,两个文件撑起整个游戏:

  • phaser.min.js —— Phaser 2 游戏引擎,863KB,负责渲染、动画、交互
  • catch-the-cat.js —— 78KB 的游戏逻辑,webpack 打包,GitHub 上能找到原始项目(ganlvtech/phaser-catch-the-cat)

棋盘是 11×11 的六边形格子,猫开局在正中间,你每点一个格子就把它变成墙,然后猫走一步。猫走到棋盘边缘算它赢(你输),猫周围六格全是墙或边界算你赢。开局随机刷 8 堵墙,纯粹为了每局不一样。

猫的皮肤是 SVG 画的,每帧一张图,三个朝向各 5 帧,走起路来有连贯的迈步动画——原版在这些细节上挺讲究。

猫的 AI 才是灵魂

大部分复刻版图省事,猫就往「离边缘最近的方向」跑,这种贪心猫很好围。但 52pojie 这个版本的猫明显更贼,反编译 catch-the-cat.js 能看到它的真实思路,分两层:

第一层,算距离。 每次轮到猫走,先从棋盘边缘所有格子做一次 BFS 广度优先搜索(BFS 就是"一圈一圈向外扩散"的遍历方式),算出每个格子到边缘的最短步数。这等于猫每一步都重新看一遍全场局势。

第二层,数路。 光找最短路不够——最短的路可能只有一条,你一堵就断。所以它还给每个格子算了一个 routesCount:从这格出发、沿"距离递减"方向走到边缘的路径总条数。最后猫的决策是:在所有能让它离边缘更近的方向里,挑路径条数最多的那个走。

翻译成人话:这只猫不仅想跑出去,还想给自己留最多的退路。这就是为什么它经常不按直觉走位——你以为堵住了它唯一的路,它早就绕去另一条更宽的通道了。

我把它改写成了一个独立 HTML

原版要跑 Phaser 引擎 + SVG 素材 + webpack 打包,文件加起来近 1MB。其实这游戏的核心就是那 30 行寻路算法,渲染完全可以用原生 Canvas 画,猫直接用 emoji 🐱,一个文件搞定,零依赖:

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>圈小猫</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{background:#f7f7f7;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;display:flex;flex-direction:column;align-items:center;min-height:100vh;padding:16px}
h1{font-size:20px;color:#333;margin-bottom:4px}
#status{font-size:14px;color:#666;margin-bottom:12px;min-height:20px}
canvas{max-width:96vw;touch-action:manipulation}
#btns{margin-top:14px;display:flex;gap:10px}
button{padding:8px 22px;border:none;border-radius:6px;background:#4a90d9;color:#fff;font-size:14px;cursor:pointer}
button:hover{background:#3a7bc0}
.hint{margin-top:10px;font-size:12px;color:#999}
</style>
</head>
<body>
<h1>🐱 圈小猫</h1>
<div id="status">点击圆点筑墙,围住小猫</div>
<canvas id="c"></canvas>
<div id="btns"><button onclick="game.reset()">重新开局</button></div>
<div class="hint">灵感来自 52pojie 爱盘 404 页彩蛋 · 猫 AI:BFS + 路径计数</div>
<script>
(function(){
"use strict";

// ---------- 参数 ----------
var W = 11, H = 11;          // 棋盘列数(奇偶行交错)、行数
var R = 22;                  // 六边形外接圆半径
var DX = 2 * R, DY = R * Math.sqrt(3);
var WALL_COLOR = "#7a8ba0";
var EMPTY_COLOR = "#d6dee8";
var CAT = "🐱", CAT_CAUGHT = "🙀";

var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
var statusBar = document.getElementById("status");

// ---------- 棋盘 ----------
var blocks = [];   // blocks[i][j] = true 是墙
var cat = { i: 0, j: 0 };
var state = "playing";  // playing / win / lose
var scale = 1;

function init() {
  var dpr = window.devicePixelRatio || 1;
  var boardW = (6.5 + 2 * W) * R;
  var boardH = (6 + Math.sqrt(3) * H) * R;
  canvas.width = boardW * dpr;
  canvas.height = boardH * dpr;
  canvas.style.width = Math.min(boardW, window.innerWidth * 0.96) + "px";
  canvas.style.height = "auto";
  scale = dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  canvas.addEventListener("click", onClick);
}

// 六边形邻居(方向 0-5, 与原版一致):
// 偶数行: [i-1,j] [i-1,j-1] [i,j-1] [i+1,j] [i,j+1] [i-1,j+1]
// 奇数行: [i-1,j] [i,j-1]   [i+1,j-1] [i+1,j] [i+1,j+1] [i,j+1]
function neighbours(i, j) {
  var s = { i: i - 1, j: j };
  var a = { i: i + 1, j: j };
  var n, o, r, q;
  if ((j & 1) === 0) {
    n = { i: i - 1, j: j - 1 };
    o = { i: i,     j: j - 1 };
    r = { i: i - 1, j: j + 1 };
    q = { i: i,     j: j + 1 };
  } else {
    n = { i: i,     j: j - 1 };
    o = { i: i + 1, j: j - 1 };
    r = { i: i,     j: j + 1 };
    q = { i: i + 1, j: j + 1 };
  }
  return [s, n, o, a, q, r];
}

function getBlock(i, j) {
  return i >= 0 && i < W && j >= 0 && j < H ? blocks[i][j] : null;
}

function pos(i, j) {
  return {
    x: 3 * R + ((j & 1) === 0 ? R : DX) + i * DX,
    y: 3 * R + R + j * DY
  };
}

// ---------- 猫 AI ----------
// 与原版模块26相同: BFS 算每个格子到边缘的最短距离,
// routesCount = 从该格通往边缘的递减路径条数, 猫选 routesCount 最大的下降方向
function solve(blocksData, ci, cj) {
  var dist = [];
  var queue = [];
  var i, j;
  for (i = 0; i < W; i++) {
    dist[i] = [];
    for (j = 0; j < H; j++) {
      dist[i][j] = Infinity;
      if (isEdge(i, j) && !blocksData[i][j]) {
        dist[i][j] = 0;
        queue.push({ i: i, j: j });
      }
    }
  }
  while (queue.length > 0) {
    var cur = queue.shift();
    var nb = neighbours(cur.i, cur.j);
    for (var k = 0; k < 6; k++) {
      var n = nb[k];
      if (n.i < 0 || n.i >= W || n.j < 0 || n.j >= H) continue;
      if (blocksData[n.i][n.j]) continue;
      if (isEdge(n.i, n.j)) continue;
      if (dist[n.i][n.j] > dist[cur.i][cur.j] + 1) {
        dist[n.i][n.j] = dist[cur.i][cur.j] + 1;
        queue.push(n);
      }
    }
  }
  // routesCount 记忆化
  var routes = [];
  for (i = 0; i < W; i++) routes[i] = [];
  function countRoutes(i, j) {
    if (routes[i][j] !== undefined) return routes[i][j];
    var res;
    if (isEdge(i, j)) {
      res = 1;
    } else {
      res = 0;
      var nb = neighbours(i, j);
      for (var k = 0; k < 6; k++) {
        var n = nb[k];
        if (n.i < 0 || n.i >= W || n.j < 0 || n.j >= H) continue;
        if (blocksData[n.i][n.j]) continue;
        if (dist[n.i][n.j] < dist[i][j]) {
          res += countRoutes(n.i, n.j);
        }
      }
    }
    routes[i][j] = res;
    return res;
  }
  countRoutes(ci, cj);

  // 选: 距离递减方向中 routesCount 最大者
  var best = -1, bestCount = -1;
  var nb = neighbours(ci, cj);
  for (var k = 0; k < 6; k++) {
    var n = nb[k];
    if (n.i < 0 || n.i >= W || n.j < 0 || n.j >= H) continue;
    if (blocksData[n.i][n.j]) continue;
    if (dist[n.i][n.j] < dist[ci][cj]) {
      var c = countRoutes(n.i, n.j);
      if (c > bestCount) { bestCount = c; best = k; }
    }
  }
  return best;
}

function isEdge(i, j) {
  return i <= 0 || i >= W - 1 || j <= 0 || j >= H - 1;
}

// ---------- 游戏流程 ----------
var anim = null; // 猫移动补间

function reset() {
  blocks = [];
  for (var i = 0; i < W; i++) {
    blocks[i] = [];
    for (var j = 0; j < H; j++) blocks[i][j] = false;
  }
  cat.i = Math.floor(W / 2);
  cat.j = Math.floor(H / 2);
  state = "playing";
  // 随机 8 堵初始墙
  var placed = 0;
  while (placed < 8) {
    var ri = Math.floor(W * Math.random());
    var rj = Math.floor(H * Math.random());
    if ((ri !== cat.i || rj !== cat.j) && !blocks[ri][rj]) {
      blocks[ri][rj] = true;
      placed++;
    }
  }
  setStatus("点击圆点筑墙,围住小猫");
  anim = null;
  draw();
}

function isCaught() {
  var nb = neighbours(cat.i, cat.j);
  for (var k = 0; k < 6; k++) {
    var b = getBlock(nb[k].i, nb[k].j);
    if (b !== null && !b) return false;
  }
  return true;
}

function isEscaped() {
  return isEdge(cat.i, cat.j);
}

function onClick(ev) {
  if (state !== "playing") { reset(); return; }
  var rect = canvas.getBoundingClientRect();
  var x = (ev.clientX - rect.left) / rect.width * (canvas.width / scale);
  var y = (ev.clientY - rect.top) / rect.height * (canvas.height / scale);
  // 找最近的格子
  var best = null, bestD = Infinity;
  for (var i = 0; i < W; i++) {
    for (var j = 0; j < H; j++) {
      var p = pos(i, j);
      var d = (p.x - x) * (p.x - x) + (p.y - y) * (p.y - y);
      if (d < bestD) { bestD = d; best = { i: i, j: j }; }
    }
  }
  if (!best || bestD > R * R * 1.2) return;
  if (best.i === cat.i && best.j === cat.j) { setStatus("不能点猫所在的位置"); return; }
  if (blocks[best.i][best.j]) { setStatus("这里已经是墙了"); return; }

  blocks[best.i][best.j] = true;
  draw();

  if (isCaught()) {
    state = "win";
    setStatus("🎉 猫已无路可走,你赢了!点击任意处重开");
    draw();
    return;
  }
  catStep();
}

function catStep() {
  var dir = solve(blocks, cat.i, cat.j);
  if (dir < 0) {
    state = "win";
    setStatus("🎉 猫认输了,你赢了!点击任意处重开");
    draw();
    return;
  }
  var nb = neighbours(cat.i, cat.j);
  var target = nb[dir];
  var from = pos(cat.i, cat.j);
  var to = pos(target.i, target.j);
  cat.i = target.i;
  cat.j = target.j;
  // 移动动画(120ms 补间)
  anim = { from: from, to: to, t0: performance.now(), dur: 120 };
  requestAnimationFrame(animFrame);
}

function animFrame(now) {
  if (!anim) return;
  var t = (now - anim.t0) / anim.dur;
  if (t >= 1) {
    anim = null;
    if (isEscaped()) {
      state = "lose";
      setStatus("😿 猫跑到地图边缘逃走了,你输了!点击任意处重开");
    } else if (isCaught()) {
      state = "win";
      setStatus("🎉 猫被围住了,你赢了!点击任意处重开");
    } else {
      setStatus("再接再厉,继续围!");
    }
    draw();
    return;
  }
  // 中间帧: 棋盘 + 猫在插值位置
  ctx.clearRect(0, 0, canvas.width / scale, canvas.height / scale);
  for (var i = 0; i < W; i++) {
    for (var j = 0; j < H; j++) {
      var p = pos(i, j);
      hexPath(p.x, p.y, 0.9 * R);
      ctx.fillStyle = blocks[i][j] ? WALL_COLOR : EMPTY_COLOR;
      ctx.fill();
    }
  }
  drawCatGlyph(anim.from.x + (anim.to.x - anim.from.x) * t, anim.from.y + (anim.to.y - anim.from.y) * t);
  requestAnimationFrame(animFrame);
}

// ---------- 渲染 ----------
function hexPath(x, y, r) {
  ctx.beginPath();
  for (var k = 0; k < 6; k++) {
    var a = Math.PI / 6 + k * Math.PI / 3;
    var px = x + r * Math.cos(a);
    var py = y + r * Math.sin(a);
    if (k === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
  }
  ctx.closePath();
}

function draw() {
  ctx.clearRect(0, 0, canvas.width / scale, canvas.height / scale);
  for (var i = 0; i < W; i++) {
    for (var j = 0; j < H; j++) {
      var p = pos(i, j);
      hexPath(p.x, p.y, 0.9 * R);
      ctx.fillStyle = blocks[i][j] ? WALL_COLOR : EMPTY_COLOR;
      ctx.fill();
    }
  }
  var cp = pos(cat.i, cat.j);
  drawCatGlyph(cp.x, cp.y);
}

function drawCatGlyph(x, y) {
  ctx.font = (2 * R) + "px serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(state === "win" ? CAT_CAUGHT : CAT, x, y + 2);
}

function setStatus(t) { statusBar.textContent = t; }

// ---------- 启动 ----------
init();
reset();
window.game = { reset: reset };
})();
</script>
</body>
</html>

保存成 cat.html 双击就能玩,文件大小不到 6KB。

几个复刻时的关键点:

  • 邻居表不能搞错:六边形网格奇偶行的邻居偏移不同,代码里 neighbours() 的分支就是原版的 getNeighbours,方向 0-5 的顺序也对齐了
  • routesCount 有记忆化:路径计数是递归定义(某格的路径数 = 所有距离递减邻居的路径数之和),不加缓存会重复计算,加一个 routes 数组存结果就行
  • 猫被围死的判定:所有邻居是墙或出界;猫赢的判定:走到边缘一圈

想玩原版

直接访问 https://down.52pojie.cn/ 下随便一个不存在的路径,比如 https://down.52pojie.cn/xxx,404 页里就是游戏本体。或者 GitHub 搜 ganlvtech/phaser-catch-the-cat,原项目还在。

这种藏在 404 里的小游戏,算是最老派的程序员浪漫了。