【单机离线版】手写一个玻珠棋(波子棋)HTML游戏:870行纯前端代码,把中国跳棋的"对称跳"用一个DFS吃透

一、我为什么要写这个
家里小孩迷上了跳棋,可我翻遍手机应用商店,装上的不是要登录就是塞满广告。断网就打不开,一局玩到一半还蹦弹窗。烦。开发的棋牌游戏只是用来娱乐与开发学习,请勿用于非法用途。
于是我想,跳棋规则不复杂,干脆自己写一个。要求就三条:一个文件、双击能开、断网照跑。不引框架,不连 CDN,源码摊开谁都能看懂。
真动手才发现,跳棋最有意思的不是走法,是跳法。相邻跳、隔一颗跳、连跳……一开始我写了一堆 if 分支,越写越乱。后来想通一件事:这些跳法本质上是同一件事——以某颗棋子为对称中心,跳到它的镜像位置。想通这层,几十行分支塌缩成一个 DFS。这篇就围绕这个展开。
下载链接:https://download.csdn.net/download/qq616491978/93113633
二、先把规则理清楚
代码里实现的规则,我用自己的话说一遍,不照搬任何棋谱。
2.1 棋盘长什么样
棋盘是 6×6 的方格,但只用到两个相对的角。左上角一个三角是红方(电脑),右下角一个三角是蓝方(玩家),各 6 颗子,按 1、2、3 三层铺开。
6×6 方格,两个相对三角阵地:
R . . . . . R = 红方(电脑)本营
R R . . . . . = 空格
R R R . . . B = 蓝方(玩家)本营
. . . B B B
. . . . B B
. . . . . B
整块背板在 CSS 里旋转了 45°,看起来就是一个上下左右对称的菱形。棋子是带高光的玻璃珠。

2.2 棋子怎么走
两种走法:
| 单步 | 沿上下左右走一格到相邻空格,不能斜走 |
| 跳跃 | 以某颗同行/同列的棋子为对称中心,跳到它另一侧的镜像空格 |
跳跃有个硬约束:从起点到中心、从中心到落点,中间不能夹别的棋子。也就是整条线路上除了被跳过的那颗子,不能有第二颗。这一条禁掉了"隔山跳"。
一次操作可以连着跳好几步,只要每一跳都合法。
2.3 怎么算赢
把对方初始三角的 6 个格子全占了,就赢。另外有个防磨蹭的规则:第 20、25、30 轮时,离开本营的棋子分别要凑够 2、3、6 颗,凑不够的一方判消极失败。
三、代码怎么组织的
一个 HTML 文件,三块:
【单机离线版】玻珠棋(波子棋).html
├── <style> → CSS,约 165 行,管菱形棋盘和玻璃珠质感
├── <body> HTML → 骨架,约 40 行,状态栏 + 棋盘 + 两个遮罩
└── <script> → 逻辑,约 650 行,重点
├── 常量(ROWS/COLS、DIRS 四方向)
├── BoardManager → 棋盘数据与本营判定
├── RulesValidator → 走法生成、胜负判定
├── AIController → 电脑走法评分
├── UIRenderer → DOM 渲染与动画
└── GameController → 流程调度、模式切换
一句话总结:数据和视图分开,grid 二维数组是唯一真相,DOM 只是它的投影。
四、数据结构怎么设计
4.1 棋盘就是一个二维数组
const ROWS = 6, COLS = 6;
const PLAYER = 'player', AI = 'ai';
grid[r][c] 要么是 'player'、'ai',要么是 null。初始化时直接手摆两个三角:
init() {
const grid = [];
for (let r = 0; r < ROWS; r++) {
grid[r] = [];
for (let c = 0; c < COLS; c++) {
grid[r][c] = null;
}
}
// 电脑(红方):左上顶点三角
grid[0][0] = AI;
grid[1][0] = AI; grid[0][1] = AI;
grid[2][0] = AI; grid[1][1] = AI; grid[0][2] = AI;
// 玩家(蓝方):右下顶点三角(镜像)
grid[5][5] = PLAYER;
grid[4][5] = PLAYER; grid[5][4] = PLAYER;
grid[3][5] = PLAYER; grid[4][4] = PLAYER; grid[5][3] = PLAYER;
return grid;
}
4.2 本营用曼哈顿距离判,不硬编码
哪些格属于本营?我没有把 6 个坐标写死,而是用它到顶点的曼哈顿距离:
baseCells(who) {
const cells = [];
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
if (who === AI) {
if (r + c <= 2) cells.push({r, c}); // 红方:左上三角
} else {
if ((ROWS – 1 – r) + (COLS – 1 – c) <= 2) cells.push({r, c}); // 蓝方:右下三角
}
}
return cells;
}
红方距 (0,0) 的距离 r+c ≤ 2,蓝方距 (5,5) 的距离 (5-r)+(5-c) ≤ 2。要改棋盘大小,只动常量就行。
4.3 方向向量
const DIRS = [
{dr:–1, dc:0}, // 上
{dr:1, dc:0}, // 下
{dr:0, dc:–1}, // 左
{dr:0, dc:1}, // 右
];
只有四个正交方向。跳棋不能斜走,这个数组从源头上就把斜向排除了。
五、CSS 怎么把方格拼成菱形
5.1 旋转 45° 是关键
传统跳棋盘是菱形,但用 DOM 摆菱形很别扭。我的偷懒办法:正常摆一个正方形网格,然后把整个背板转 45°。
.board-wrapper {
background: var(–board-bg);
border-radius: 28px;
padding: 40px 40px;
transform: rotate(45deg);
transform-origin: center center;
margin: 110px auto;
}
转完之后正方形的四个角朝上下左右,视觉上就是对称菱形。margin: 110px 是留白——旋转后对角线比边长大了 √2 倍,不留空间会顶到上下文字。
5.2 视觉层级
- .cell::before:灰色小圆点,是棋盘上的落子位
- .cell.valid-move::before:绿色大圆点,合法落点提示
- .cell.selected::after:金色描边,选中反馈
- .piece.player / .piece.ai:蓝/红玻璃珠

5.3 玻璃珠靠径向渐变
.piece.player {
background: radial-gradient(circle at 35% 30%, #b8dffb, var(–blue) 55%, #2c5f8a 100%);
box-shadow: 0 3px 8px rgba(0,0,0,0.2),
inset 0 -2px 4px rgba(0,0,0,0.2),
inset 0 2px 3px rgba(255,255,255,0.5);
}
渐变的圆心偏到左上 35% 30%,模拟光从左上打下来的高光。内阴影一亮一暗,珠子就有了立体感。
5.4 移动端适配
@media (max-width: 400px) {
:root { –cell-size: 40px; }
.piece { width: 28px; height: 28px; }
}
小屏把格子和棋子缩一号,一个 CSS 变量搞定。
六、核心逻辑——一个 DFS 统一三种跳法
这是全文最花功夫的地方。
6.1 走法的入口
getValidMoves(grid, r, c) {
const who = grid[r][c];
if (!who) return [];
const moves = [];
// 1) 单步走法
const neighbors = BoardManager.getNeighbors(r, c);
for (const nb of neighbors) {
if (grid[nb.r][nb.c] === null) {
moves.push({ from:{r,c}, to:{r:nb.r, c:nb.c}, path:[] });
}
}
// 2) 连跳/对称跳走法 —— DFS
const visited = new Set();
visited.add(`${r},${c}`);
this._dfsJumps(grid, r, c, who, [], visited, moves, {r, c});
return moves;
}
单步很平凡:四个邻居里挑空的。跳跃全交给 _dfsJumps。
6.2 对称跳——把三种跳合成一种
我最初的版本写了"相邻跳"和"隔子跳"两套分支,还得单独处理连跳,改一处忘一处。后来发现它们是同一个式子:
落点 = 2 × 中心 − 起点
以任意一颗同行或同列的棋子当"对称中心",把起点关于它做镜像反射,就是落点。相邻跳只是中心贴着起点的特例。
_dfsJumps(grid, r, c, who, path, visited, out, origin) {
// 遍历棋盘上所有其他格作为"对称中心" mid
for (let mr = 0; mr < ROWS; mr++) {
for (let mc = 0; mc < COLS; mc++) {
if (mr === r && mc === c) continue;
if (grid[mr][mc] === null) continue; // 中心必须有子
const dr = mr – r, dc = mc – c;
if (dr !== 0 && dc !== 0) continue; // 只允许同行/同列
const toR = mr + dr, toC = mc + dc; // 镜像落点
if (toR < 0 || toR >= ROWS || toC < 0 || toC >= COLS) continue;
if (grid[toR][toC] !== null) continue; // 落点必须空
// 线路必须干净:起点→中心、中心→落点 之间不能有子
if (!this._pathClear(grid, r, c, mr, mc)) continue;
if (!this._pathClear(grid, mr, mc, toR, toC)) continue;
const key = `${toR},${toC}`;
if (visited.has(key)) continue; // 防止绕圈
visited.add(key);
const newPath = […path, {r: mr, c: mc}];
out.push({ from: {r: origin.r, c: origin.c}, to: {r: toR, c: toC}, path: newPath });
this._dfsJumps(grid, toR, toC, who, newPath, visited, out, origin); // 继续连跳
}
}
}
dr !== 0 && dc !== 0 这一行卡掉了斜向对称跳。origin 始终是整条连跳的原始起点,所以哪怕跳了五步,落子时也知道棋子是从哪儿出发的。递归 + visited 天然把连跳串起来,还不会绕死循环。

6.3 线路清洁检查——禁掉隔山跳
第一版对称跳有个 bug:只要同一列上有颗子,隔多远都能跳过去。三连子 (5,5)(4,5)(3,5),(5,5) 居然能直接跳到 (1,5),中间压根不管。
补上这个函数就好了:
_pathClear(grid, r1, c1, r2, c2) {
const dr = Math.sign(r2 – r1), dc = Math.sign(c2 – c1);
let r = r1 + dr, c = c1 + dc;
while (r !== r2 || c !== c2) {
if (grid[r][c] !== null) return false;
r += dr; c += dc;
}
return true;
}
从一端逐格走到另一端,中间碰到任何棋子就返回 false。起点到中心、中心到落点各查一遍,隔山跳就没了。相邻跳因为两端之间没有格子,while 一次都不进,自然放行。
6.4 连跳的样子
连跳就是 DFS 递归展开的结果。选中起跳子后,所有能到达的落点(含多步)都会高亮出来。

七、渲染和交互怎么配合
7.1 坐标不靠 getBoundingClientRect
背板转了 45°,浏览器测出来的元素矩形会被旋转干扰。我干脆不测量,直接用布局时算好的坐标:
_cellOffset(r, c) {
const cell = this.cellEls[r][c];
if (!cell) return { left: 0, top: 0 };
return { left: parseFloat(cell.style.left), top: parseFloat(cell.style.top) };
}
摆格子时 left/top 是我自己写进去的,读回来最准,旋转多少度都不影响。
7.2 点击流程
点击格子
├─ 是当前行动方的棋子 → 选中,算合法走法,高亮
├─ 已选中 + 点到合法落点 → 执行移动
└─ 其它 → 取消选中
对应代码里 onCellClick 就这三条分支。判断"是不是当前行动方的棋子"用的是 clicked === this.currentTurn,这一改让双人模式白拿——红方玩家也能用鼠标操作了。
7.3 落子单点收口
不管是人走、电脑走、还是双人里的另一方走,最后都汇到 applyMove:
async applyMove(move, who) {
this.locked = true;
this.selected = null;
this.validMoves = [];
UIRenderer.clearHighlights();
const {from, to, path} = move;
await UIRenderer.animateMove(from.r, from.c, to.r, to.c, path);
this.grid[from.r][from.c] = null;
this.grid[to.r][to.c] = who;
if (RulesValidator.checkWin(this.grid, who)) {
this.declareWin(who);
return;
}
this.endTurn(who);
}
动画、改数据、判胜、交回合,一条龙。谁调它都走同一套,不会有"人走了会判胜、电脑走了忘判胜"这种漏。
八、怎么判胜负
8.1 占满对方三角
checkWin(grid, who) {
const targets = BoardManager.targetCells(who);
for (const t of targets) {
if (grid[t.r][t.c] !== who) return false;
}
return true;
}
targetCells 返回对方本营那 6 格,逐格看是不是自己的子。全中就赢。

8.2 强制出营防磨蹭
checkForcedOut(round) {
const need = {20:2, 25:3, 30:6}[round];
if (!need) return;
const pOut = BoardManager.leftBaseCount(this.grid, PLAYER);
const aOut = BoardManager.leftBaseCount(this.grid, AI);
// ……离营不达标的一方判负
}
只在第 20/25/30 轮触发,用一个字面量对象查表,比一串 if 清爽。
九、两种对战模式
开局先弹一个遮罩,让你选电脑对战还是双人对战:
init() {
UIRenderer.init();
this.grid = BoardManager.init();
UIRenderer.buildBoard((r, c) => this.onCellClick(r, c));
UIRenderer.renderPieces(this.grid);
UIRenderer.el.restartBtn.addEventListener('click', () => this.restart());
document.getElementById('modePveBtn').addEventListener('click', () => this.startGame(this.MODE_PVE));
document.getElementById('modePvpBtn').addEventListener('click', () => this.startGame(this.MODE_PVP));
this.showModeSelect();
}
两种模式共用全部落子逻辑,唯一的分叉在 endTurn:人机模式轮到电脑时自动 setTimeout 调 AI,双人模式就停下来等另一个人点。


电脑的走法评分很朴素:落点离对方阵地越近加分越多,能连跳额外加分,快占满目标时再补一刀。够打,但不难赢——所以我才补了双人模式。
十、几个值得记住的点
- 能用数学式子统一的分支,就别写成一堆 if。对称跳那个 2×中心−起点 是全篇的转折。
- 本营、目标格这类"位置集合"用距离公式生成,别硬编码坐标,改棋盘尺寸时省心。
- 视图旋转后,坐标从布局数据里读,别信 getBoundingClientRect。
- 落子逻辑单点收口,人和电脑走同一条路,漏洞少一半。
十一、还可以做什么
更聪明的电脑。现在只看一步的贪心评分,可以加个浅层 minimax,或者对连跳链做前瞻。
六角满盘。当前只用了两个三角,扩成标准的六角星盘、支持 2~6 人,baseCells 和 targetCells 的距离公式改改就能撑起来。
残局分享。把 grid 序列化成一段字符串,就能存档和复盘。
十二、关于这篇文章
完整 HTML 源码随文附上,存成 .html 双击就能跑,约 870 行,关键函数都有中文注释。 下载链接:https://download.csdn.net/download/qq616491978/93113633




