刚开始做这个项目时,我本来只想把大学时写的一个五子棋控制台程序加个界面,结果一路迭代到了“五子棋3.0”。从能用键盘敲坐标下棋的版本,到鼠标点一点就能联机对战,这中间踩过的坑、重构过的代码、调出来的经验,我觉得比项目本身更有价值。这篇文章不聊虚的,就从“五子棋3.0”这个版本切入,说说我为什么做这个项目、核心模块怎么选型、AI评分系统怎么一步步调出来,以及联机、悔棋、判定这些功能背后的实现思路。如果你正在学前端、想找一个能串起 UI、算法、网络通信的完整练手项目,或者单纯想知道一个“小游戏”能做成什么深度,这篇应该能给你点实在的东西。
1. 从 1.0 到 3.0:这个项目到底想解决什么问题
1.1 三个版本背后的需求变化
“五子棋3.0”不是一拍脑袋想出来的版本号。它对应的是一条从“能跑”到“好用”再到“有可玩性”的演进路径。
1.0 版本是纯命令行程序。棋盘用二维数组在终端里打印,白子黑子用字符表示,玩家轮流输入行列坐标。它的痛点非常明显:操作不直观、没有任何交互反馈、代码也基本没有模块化,规则判断和主循环全部堆在 main 方法里。这个版本的意义在于让我把“五子棋的基本规则”从业务逻辑上理顺了:15x15 棋盘、黑先白后、五连获胜、平局判定。
2.0 版本加上了图形界面,用的是最基本的表格布局。每一格是一个单元格,点击时把棋子画上去。这个版本比 1.0 好用太多,但代码上还是“界面和业务逻辑混在一起”,棋盘状态、胜负判断、界面刷新全部耦合。到后期想加 AI 对战,我明显感觉到改不动了,每次因为 AI 要落子,就得反向去更新界面组件,数据流非常混乱。
3.0 的目标就是彻底解决这些问题:把界面、规则引擎、AI 决策、网络通信分成独立模块;游戏模式从“双人同屏”扩展到“人机对战”和“局域网联机”;同时把棋局数据记录下来,支持悔棋和复盘。
有人说,一个五子棋做个 3.0 是不是小题大做。我的体会是,正是这种“看起来简单”的项目,才能把架构问题暴露得干干净净。如果你一上来就去写一个复杂的后台管理系统,大概率会被业务细节淹没,根本分不清哪些设计是为了解决真实问题。五子棋规则固定、功能边界清晰,反而适合拿来打磨代码结构。
1.2 3.0 版本的功能需求拆解
我先把 3.0 的功能列表列出来,然后按优先级排了一下:
| 优先级 | 功能点 | 说明 |
|---|---|---|
| P0 | 人机对战 | AI 至少能下出有章法的棋,不是随便乱走 |
| P0 | 双人对战 | 同一设备上黑白双方轮流落子 |
| P0 | 胜负自动判定 | 落子后立即检测是否五连,并高亮获胜棋子 |
| P1 | 悔棋 | 支持回退一步或两步 |
| P1 | 局域网联机 | 两台设备在同一个网络内可以创建/加入房间对战 |
| P1 | 难度选择 | 简单/普通/困难,对应 AI 的策略深度 |
| P2 | 棋局导出与复盘 | 记录每一步落子坐标,支持回放 |
| P2 | 音效与动效 | 落子音效、获胜动画 |
P0 是核心,没有这些这个项目根本不叫“五子棋”;P1 是 3.0 相对 2.0 的明显增量,尤其是联机功能,直接决定了项目的天花板;P2 属于锦上添花。
关于 P1,说句实在话,联机功能在开发量和调错成本上,比前面所有功能加一起还要多。光是网络同步的边界情况就够喝一壶的:一方断线怎么办、双方同时落子怎么办、房间里的“先后手”怎么分配。我建议如果时间不够,P1 里的“复盘和棋局导出”可以先砍掉,联机保留,因为联机带来的架构提升是复盘比不了的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心模块设计与关键技术选型
2.1 棋盘渲染方案:Canvas 还是 DOM 网格
2.0 版本用表格(table)绘制的棋盘,有一个很现实的问题:当棋子数量变多,频繁操作 DOM 会导致明显的卡顿,而且对高分屏适配不友好。这次我直接考虑两个方案:Canvas 和 SVG。
SVG 的优点是每个元素都是独立节点,方便监听点击事件和做动画;缺点是元素数量多时性能一般,15x15 的棋盘加上棋子不过两百多个节点,其实也还好。但我最终还是选了 Canvas,原因是这个项目后续要录制对局回放,Canvas 可以直接把整一帧渲染成图片或视频帧,不需要额外处理 DOM 结构。另一个原因是我对画笔类 API 更熟,绘制线条、圆、渐变都比较顺手。
网格坐标换算是 Canvas 版本最容易出错的地方。我用的参数是:棋盘边距 padding = 40,棋盘格间距 cellSize = 50,棋盘实际像素宽度就是 padding * 2 + cellSize * 14 = 780。这里注意是乘 14 而不是 15,因为 15 条线只有 14 个间隔。
javascript复制const BOARD_SIZE = 15;
const CELL_SIZE = 50;
const PADDING = 40;
const CANVAS_SIZE = PADDING * 2 + CELL_SIZE * (BOARD_SIZE - 1);
const baseX = (col) => PADDING + col * CELL_SIZE;
const baseY = (row) => PADDING + row * CELL_SIZE;
鼠标点击时,要把屏幕坐标换算成格子坐标:
javascript复制canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const col = Math.round((x - PADDING) / CELL_SIZE);
const row = Math.round((y - PADDING) / CELL_SIZE);
// 再判断 col/row 是否在 0-14 范围内,以及该位置是否为空
});
如果不做 scaleX/scaleY 的换算,在 CSS 尺寸和 Canvas 属性尺寸不一致时,点击落子的位置会偏。这里算是第一个必踩的坑。
2.2 胜负判定算法:从全盘扫描到增量判断
初学者最容易写的判断逻辑是:每次落子后遍历整个棋盘,对每一个有棋子的点,向四个方向扫描看能不能凑齐五个。这个方案在 15x15 的棋盘上性能完全够,问题在于代码可读性差,而且后续如果想做“哪五颗棋获胜”的高亮,需要额外记录位置信息。
我采用的做法是增量判断:每次只从当前落子点出发,沿水平、垂直、主对角线、副对角线四个方向分别统计连续同色棋子的数量。如果两边加起来的大于等于 5,就说明这步棋获胜了。
javascript复制function checkWin(board, row, col, player) {
const directions = [[1, 0], [0, 1], [1, 1], [1, -1]];
for (const [dx, dy] of directions) {
let count = 1;
const winPos = [[row, col]];
// 正方向延伸
for (let i = 1; i < 5; i++) {
const nr = row + dx * i, nc = col + dy * i;
if (nr < 0 || nr >= 15 || nc < 0 || nc >= 15 || board[nr][nc] !== player) break;
count++;
winPos.push([nr, nc]);
}
// 反方向延伸
for (let i = 1; i < 5; i++) {
const nr = row - dx * i, nc = col - dy * i;
if (nr < 0 || nr >= 15 || nc < 0 || nc >= 15 || board[nr][nc] !== player) break;
count++;
winPos.push([nr, nc]);
}
if (count >= 5) return winPos;
}
return null;
}
这个函数的返回值有两个作用:true/false 判断是否获胜,以及获胜棋子的坐标列表。拿到坐标列表后,我可以在画布上把这些棋子的外圈描成红色,这样玩家一眼就能看出是哪五颗棋连成了线。
判断胜负还有一个隐含问题:禁手规则。传统五子棋里黑棋要限制三三禁手、四四禁手和长连,但在休闲对局里,直接应用禁手会大大增加规则复杂度。3.0 版本里我做了个折中:默认不禁手,但在房间设置里可以选择“标准规则”,开启后黑棋不能下出双三、双四、长连。禁手判断的核心是“下完这一步后,棋盘上是否出现了两种以上达到活三或冲四的棋型”,这个逻辑放在落子合法性检测里,和胜负判断分开。
2.3 AI 引擎的架构:三层评分体系
3.0 版本的 AI 是一个独立模块,不直接引用任何界面代码。它的输入是当前棋盘状态和当前轮到谁下棋,输出是一个坐标。
常见的五子棋 AI 有几种实现路径:
| 方案 | 强度 | 性能开销 | 实现难度 |
|---|---|---|---|
| 随机落子 | 极弱 | 低 | 极低 |
| 评分表法 | 中等 | 低 | 中等 |
| 极大极小搜索 + Alpha-Beta 剪枝 | 较强 | 高 | 高 |
| 蒙特卡洛树搜索(MCTS) | 强 | 很高 | 极高 |
考虑到 3.0 定位是“浏览器里能流畅跑”,而且还要兼容移动端,我选了“评分表法 + 少量随机扰动 + 分层策略”。它的核心思想是:对每一个空位,分别评估“如果我下这里,对我方棋形的帮助有多大”和“如果对手下这里,对对手棋形的帮助有多大”,两者加权求和,得分最高的位置就是 AI 的落子点。
实际的 AI 模块拆成了三个部分:
- 棋型识别:把棋盘上以某个位置为中心的四个方向上的棋子序列,转换成一个“棋型字符串”,比如
01110表示黑棋、白棋、白棋、白棋、黑棋。 - 棋型评分:把棋型字符串映射到分数。连五给最高分,活四次之,冲四再次之,然后是活三、眠三、活二、眠二。
- 策略决策:结合当前游戏阶段(开局、中盘、残局)和难度设置,计算每个空位的总分,选出最优点。
这个三层结构的好处是每层可以独立测试。调 AI 强度的时候,不需要改棋型识别代码,只要改策略决策层的权重参数就够了。
3. 实操:AI 评分系统的实现细节
3.1 棋型识别与打分表
棋型识别是整个 AI 的地基。我在代码里维护了一张打分表,这里展示关键部分:
javascript复制const SCORE_TABLE = {
'11111': 1000000, // 连五
'011110': 50000, // 活四
'011112': 5000, // 冲四(右侧被封)
'211110': 5000, // 冲四(左侧被封)
'010110': 4000, // 冲四(跳四)
'011010': 4000, // 冲四(跳四)
'001110': 1500, // 活三(普通)
'011100': 1500, // 活三(普通)
'010110': 1200, // 跳活三(中间空一格)
'010010': 500, // 活二
'001100': 400, // 活二(另一种形态)
'001112': 300, // 眠三(一端被封)
'211100': 300, // 眠三
'000100': 100, // 边缘的单个棋子,影响很小
};
这个表里的 1 代表当前要评估的一方棋子,0 代表空位,2 代表对手棋子或边界。要注意同一个棋型可能有多种字符串表示,比如“活三”可以从左边读成 001110,也可以从右边读成 011100,在识别时必须把所有旋转和镜像形态映射到同一个键。
为什么冲四只给 5000 分,而活四给 50000 分?原因是:冲四看起来“只差一步”,但对手下一步必然会堵你,你有进攻的主动性,但形成不了“连续逼迫”的效果。活四则是两头都空,对手无论堵哪一头,你都直接赢了。两者在棋理上的地位相差巨大,分数也必须拉开一个量级。
识别某一点棋型的代码思路是:从该点出发,沿四个方向分别取左右各四格,总共 9 个格子的状态,然后拼成一个长度为 9 的字符串,再把当前点所在的棋段截取出来做匹配。这里有个关键细节:左右延伸时遇到边界或对手棋子就停止,而不是机械地取固定长度的字符串。
3.2 落子位置遍历与候选点生成
如果不做任何优化,AI 每次思考要遍历棋盘上所有空位——最多两百多个,每个位置还要向四个方向做棋型识别和打分,总计算量看着不大,但在低端设备上如果每步都全量计算,还是会有一点迟滞。
我用的优化手段是候选点剪枝。不要全盘扫描,只扫描以当前已有棋子为中心、半径 2 格范围内的空位。道理很简单:如果某个空位离所有棋子都很远,下在这里几乎不会立刻形成威胁或防守,不是紧急点。
javascript复制function getCandidateMoves(board) {
const candidates = new Set();
for (let r = 0; r < 15; r++) {
for (let c = 0; c < 15; c++) {
if (board[r][c] === 0) continue;
for (let dr = -2; dr <= 2; dr++) {
for (let dc = -2; dc <= 2; dc++) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < 15 && nc >= 0 && nc < 15 && board[nr][nc] === 0) {
candidates.add(`${nr},${nc}`);
}
}
}
}
}
return [...candidates].map(s => s.split(',').map(Number));
}
在棋子最密集的中盘,候选点数量通常只有三四十个,相比全盘两百多个空位,计算量下降了七八倍。
每个候选点最终得分:
javascript复制const score = attackWeight * getAttackScore(board, r, c, aiPlayer)
+ defendWeight * getDefendScore(board, r, c, humanPlayer);
攻击分和防守分都基于同一个评分函数来计算,只不过分别站在 AI 自己回合和假设对手下在这两个角度去看局面。差别在于 attackWeight 和 defendWeight。
这两个权重的比例,直接决定了 AI 风格:
- 困难模式:
attackWeight = 1.2, defendWeight = 1.0,进攻性较强。 - 普通模式:
attackWeight = 1.0, defendWeight = 1.1,防守优先,但不出彩。 - 简单模式:
attackWeight = 0.8, defendWeight = 0.6,且加入更大的随机扰动,AI 会经常“犯蠢”。
3.3 难度分级与随机扰动
很多人实现难度分级时只改一步棋的搜索深度,但评分表法没有深度参数。我的做法是改三个东西:防守权重、候选点范围、随机扰动幅度。
| 难度 | attackWeight | defendWeight | 候选范围 | 随机扰动 |
|---|---|---|---|---|
| 简单 | 0.9 | 0.7 | 半径 1 格 | 分数差在 300 分以内随机选 |
| 普通 | 1.0 | 1.05 | 半径 2 格 | 分数差在 100 分以内随机选 |
| 困难 | 1.2 | 1.0 | 半径 2 格 | 分数差在 30 分以内随机选 |
随机扰动的实现有点讲究。不是每次都从所有候选点里随机,而是先算出最高分,然后筛选出分数落在“最高分减去扰动阈值”这个区间内的点,再从中随机选一个。这样既保证了 AI 不会永远下同一个点位,又不会下出明显违背棋理的臭棋。
我在实际测试中发现,随机扰动在很大程度上决定了“简单模式”的拟人感。如果只是降低权重,AI 看起来像一个“反应很慢但不会犯错的机器”;加了扰动之后,偶尔走出一个奇怪的挂角位,反而更像真人。
3.4 手感问题与思考时间的平衡
AI 模块还有一个很容易忽略的性能点:异步计算。如果 AI 决策在浏览器主线程执行,计算过程中界面会完全卡死。15x15 棋盘、候选点四十个、每个点做四方向棋型识别,总耗时在普通电脑上不到 20 毫秒,一般感知不到。
但联机模式下,AI 可以放在 Web Worker 里跑,避免和 UI 渲染抢线程。我的实现是:把棋盘状态做成不可变快照传给 Worker,Worker 计算完返回坐标,主线程拿到坐标后再落子。这样即便未来把 AI 算法换成搜索树,也不会导致界面卡顿。
4. 联机对战模块:局域网房间制的实现思路
4.1 技术选型:原生 WebSocket 还是现成框架
做联机前我犹豫了很久。如果只是局域网双人下棋,最省事的方式是用 Node.js 写一个简单的 WebSocket 服务,不需要引入 Socket.IO 这类重框架。我的理由有三个:联机对局的实时性要求不高,每秒 1-2 条消息就足够;WebSocket 协议本身足够可靠,断开时也能感知;少一个第三方依赖,部署更简单。
服务端设计了三个消息类型:
json复制{"type": "create_room", "playerName": "alice"}
{"type": "join_room", "roomId": "e7e9", "playerName": "bob"}
{"type": "move", "roomId": "e7e9", "row": 7, "col": 9}
房间 ID 用 4 位随机字符串。创建房间的人执黑先手,加入的人执白。移动消息只包含坐标和房间号,不包含整个棋盘快照。
4.2 状态同步与回合校验
联机模块最容易出问题的地方在“回合一致性”。设想一个场景:两个玩家同时点击同一格,或者黑棋连下两步。如果服务端不做校验,客户端各自的数据就分叉了。
我用的方案是服务端权威同步:客户端收到落子点击后,先把动作发给服务端,服务端校验“当前轮到谁”“这个格子是否为空”“动作是否合法”,通过后广播给房间里所有人。客户端不直接更新棋盘,等收到服务端的广播再刷新。
这套机制稍显繁琐,但好处是彻底避免了对账。如果某一步发生冲突,服务端可以明确拒绝非法移动,并告诉客户端重试。
消息格式上,我统一用 {type: 'move', data: {row, col}} 包装,这样后续要扩展“聊天消息”或“观战模式”都比较方便。
4.3 断线处理与重连
浏览器端断线是大概率事件,尤其是手机切后台超过一分钟,WebSocket 通常会被系统回收。我的处理策略分三层:
- 心跳机制:客户端每 20 秒发送一个
ping,服务端回pong。超过 60 秒没收到心跳,服务端认为该玩家掉线,向对手发送“对方已掉线”提示。 - 房间保留:服务端在玩家掉线后保留房间和当前棋局 5 分钟,期间允许同一 socket 重连,或者通过玩家 ID 找回对局。
- 断线续赛:重连成功后,服务端把当前棋盘状态和落子历史一次性发给重连方,确保双方状态一致。
实现重连时最需要注意的是:不要让先掉线的玩家在重连之后被判定为“超时判负”。我是在 ping/pong 的存活判断里只做提示,不做自动判负,让双方手动协商。
5. 常见问题与排查技巧实录
5.1 棋盘坐标偏移:高分屏和 Canvas 缩放问题
症状:鼠标点击落子时,棋子总是落在偏左上方或右下方的位置,越靠近边角越明显。
原因:Canvas 的物理尺寸由 width 和 height 属性决定,而 CSS 尺寸可能因为响应式布局被拉伸。两者不一致时,直接用 e.offsetX / e.offsetY 换算必然出错。
解决办法:在点击事件里用 getBoundingClientRect() 读取当前实际显示尺寸,再和 Canvas 的物理尺寸做比例换算。换算公式已经在前面的代码里给出了。如果你的项目里同时用了 CSS transform: scale(),还要额外考虑变换矩阵,但五子棋项目一般用不到。
5.2 AI 为什么会“只守不攻”或者“错过必杀”
最初版 AI 有个很典型的问题:玩家连续下了两头活三,AI 却不堵,而是跑去做一个边角位置的“眠三”。调试后发现根因在棋型识别函数——判断防守分时传错了玩家参数,导致 AI 一直站在自己的视角去评估“对手下这一步对对手的帮助”,结果所有位置的防守分都偏低。
排查这个问题的思路是单元测试。我把若干个典型局面写进测试用例,比如“黑棋在中央有一个活三,白棋该怎么防守”,逐个验证 AI 输出是否合理。这个做法效率远高于肉眼判断。建议所有做 AI 的项目都养成为棋型识别单独写测试的习惯。
5.3 悔棋功能的边界条件
悔棋看起来简单,实际上有一堆细节:
- 人机对战模式下,玩家点悔棋时,要同时撤回 AI 的一步和玩家自己的一步。如果当前已经轮到玩家,而玩家反悔,应该撤回刚才玩家走过的棋和 AI 之前的应对。
- 双人对战模式,悔棋只需要回退一步,但不能出现“悔到对手回合”的情况。
- 已经结束的棋局不允许悔棋。
- 棋盘状态回退时,胜负标记必须清除。
我的实现用了一个 history 栈,每个元素记录 { row, col, player }。悔棋方法是:
javascript复制function undo() {
if (!canUndo || gameOver) return;
if (mode === 'PVP') {
const last = history.pop();
board[last.row][last.col] = 0;
} else if (mode === 'PVE') {
const aiMove = history.pop();
const humanMove = history.pop();
board[aiMove.row][aiMove.col] = 0;
board[humanMove.row][humanMove.col] = 0;
}
refreshCanvas();
}
5.4 联机模式下“双方看到的棋盘不一致”
这个问题的根源多半不是网络丢包,而是本地回合状态没同步上。一个典型场景:黑棋落子后,客户端 A 立即更新界面,但服务端广播延迟了 200 毫秒,这期间客户端 B 已经被允许点击棋盘,于是产生了一个“非法落子”。
解决策略很明确:联机模式下,任何一方的落子都必须等服务端确认后再更新棋盘。哪怕 UI 上可以先显示“落子动画”,真实的棋盘状态修改也必须在收到广播之后。这个“确认后再更新”的原则,可以避免掉大多数联机不同步的坑。
6. 版本迭代中的架构反思
五子棋 3.0 做下来,我最大的体会是:版本号要配得上重构力度。从 2.0 到 3.0,表面上是加了 AI 和联机两个大功能,实际上是把整个项目的数据流重新梳理了一遍。
如果你现在也在做类似项目,建议一开始就把“棋盘状态”和“UI 渲染”完全分离。棋盘状态就是一个 15x15 的二维数组,任何对棋盘的修改都通过统一的方法进行,比如 placePiece(row, col, player),UI 层只负责监听“棋盘状态变化”事件并重绘。这样,后面无论叠加 AI、联机、复盘,都不会让代码乱成一团。
AI 调参也是需要耐心的事。评分表法的每个分数,我一开始都觉得合理,但实际对局中发现“活二”的分数设得太高,导致 AI 总是喜欢开局在中间散落几个互不相关的棋子,缺乏连贯性。后来把活二从 800 降到了 500,对局质量明显提升。这类参数只能用对局测试来验证,光靠推理很难一步到位。
另外,AI 对战如果只做“AI 先手”和“AI 后手”两种情况,会漏掉一个隐藏问题:AI 执黑时,如果要开启禁手规则,必须在评分函数里加入“不主动制造双三/四四/长连”的惩罚项。我一开始没做,结果 AI 经常下出双三直接违规,在标准规则下等于送分。这个功能虽然只在“房间自定义规则”里开放,但逻辑上远比想象的复杂,建议新手先从简单的评分表开始,把基础棋力调好再考虑禁手。
五子棋 3.0 对我来说不只是一个游戏项目。它把数据结构和算法、UI 框架、网络协议三大块串起来了。你写十个 CRUD 项目,未必能碰上一次 WebSocket 的临界状态问题;但做一个联机五子棋,一周内就能遇到。希望能给想拿项目练手的朋友一些参考,少走点弯路。
