逛 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 里的小游戏,算是最老派的程序员浪漫了。