做前端时间一长,总会在某个时刻冒出个念头:能不能不碰后端、不搭服务器,只靠浏览器里那点 JavaScript,写一个真正能跟人下棋的 AI 象棋程序?这个想法我一直惦记着,后来终于花几个周末把它落地了。
这个“HTML版AI象棋程序”就是一套纯前端实现的中国象棋人机对战网页:页面负责画棋盘、响应鼠标点击、记录行棋规则,AI 则通过搜索算法模拟思考,在当前局面下替电脑方算出一步棋。整个项目零依赖、零构建,一个 HTML 文件加一份脚本就能跑起来,双击就能打开。能做的功能包括:完整象棋规则判断(蹩马腿、塞象眼、将帅照面、兵卒过河等)、玩家执红先行、AI 自动回棋、胜负判定与将军提示。适合想深入理解棋类 AI 原理的前端开发者,也适合拿来当算法课的练手项目。
更让我觉得值得分享的是,这种“纯前端 + 极小极大搜索 + Alpha-Beta 剪枝”的路线,几乎把前端能玩的性能极限都压榨了一遍。接下来我会从整体设计、棋盘渲染、AI 算法、核心代码、踩坑记录五个方向,把这个项目掰开揉碎讲清楚。
1. 项目概述与方案选型
1.1 一句话讲清这个程序到底是什么
所谓 HTML 版 AI 象棋程序,其实就是在一个网页里完整复刻中国象棋的对局体验,并且让浏览器扮演“对手”。玩家点击棋子、选择目标格、松手落子,程序先校验是否符合象棋规则,再切换回合。
如果是轮到 AI 走棋,它就启动一套搜索算法:把未来若干步可能出现的局面全部展开,用评估函数给每个局面打分,然后选择对自己最有利的一条分支走。整个过程只在浏览器本地运行,不需要服务器,不需要 API 网关,也不需要训练模型权重的 Python 环境。
我给它定的目标是:AI 在 1 秒内完成思考,对普通爱好者至少能下出“不乱走、会兑子、知道将军”的水平。如果想做一个上线给朋友玩的产品,这个完成度已经足够作为一个 MVP。
1.2 为什么我不选后端服务或深度学习路线
大多数第一次听到“AI 象棋”的人,第一反应是上强化学习或者调一个神经网络模型。但真做起来你会发现,一个靠谱的棋类 AI 最核心的并不是“模仿人”,而是在规则空间内做有限深度的确定性搜索。
同样一盘棋,神经网络需要海量棋谱训练,而搜索算法只需要把局面展开然后选最优。中国象棋的分支因子通常在 40 左右,也就是平均每步有 40 种合法着法。相比围棋的 200+,搜索空间对于现代浏览器来说其实是可控的。用原生 JavaScript 实现带剪枝的搜索,深度在 4 到 6 层时可以做到准实时响应,效果已经相当能打。
我选择不碰后端还有一层现实原因:纯前端文件可以随时打开、随意分享,没有任何部署成本。用户拿到一个 .html 就能玩,配合 GitHub Pages 或任意静态托管就能挂到公网。AI 计算全部在本地完成,没有并发压力,也不涉及隐私数据。这对一个个人项目来说几乎是理想的形态。
1.3 最终功能清单
开发完以后,我把功能收敛成这样一份清单:
| 功能项 | 实现说明 |
|---|---|
| 棋盘渲染 | Canvas 绘制 9 列 x 10 行的象棋棋盘,包含楚河汉界与九宫斜线 |
| 走子交互 | 鼠标选中棋子高亮,点击目标格完成行棋,自动标注可走位置 |
| 完整规则 | 车、马、象、士、将、炮、兵的所有走法约束,包含蹩马腿、塞象眼、将帅不可照面等 |
| 回合管理 | 红方先行,玩家执红,AI 执黑,状态机轮转 |
| AI 落子 | 极小极大搜索 + Alpha-Beta 剪枝,当前版本固定搜索深度为 4 |
| 终局判定 | 将死、困毙、将军提示,和局提示 |
| 悔棋与重开 | 记录历史局面栈,支持一键悔棋和重置 |
有了这个清单以后,后面的开发就有了明确的验收标准。实际情况是:做完棋盘渲染只需要半天,规则校验花了整整一个周末,AI 搜索迭代又花了一个周末。下面我把这几个环节的关键决策一一拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 棋盘绘制与交互设计——先把“壳”做扎实
2.1 用 Canvas 还是 DOM:我的选择逻辑
棋盘渲染可以走两条路:一是用 CSS + 绝对定位的 DOM 棋子,二是用 Canvas 统一绘制。我最后选了 Canvas,原因有三:
第一,Canvas 的重绘是立即模式的,绘制一个棋盘 10ms 以内就能完成,而 DOM 在棋子频繁移动时会产生布局计算和重排,对低端手机不够友好。第二,棋盘本身有大量网格线、九宫斜线、文字标注,Canvas 的路径 API 画起来更顺手。第三,Canvas 的坐标系和控制逻辑完全由自己掌控,后面画“可走位置提示点”、搞动画或调试显形走法时,可以直接把内部数据画出来,方便得不是一点半点。
对素材的执念可以放下:棋子不需要任何 PNG 图片资源,我直接用填充圆 + 文本绘制,“将”“士”“象”“马”这些字通过 fillText 写上去,中文天生就是最好的棋子素材。
2.2 坐标系统设计
这一步看起来简单,却是全项目最关键的抽象之一。中国象棋棋盘是 9 列 x 10 行,玩家习惯把横向称为“路”、纵向称为“线”。在代码里,我统一用 Board 坐标 [row, col],其中 row 范围 0~9(从上到下),col 范围 0~8(从左到右)。
举个例子:红方最底线的“车”位于 [9, 0],黑方最底线的“车”位于 [0, 0]。这样做的好处是:搜索算法、走法生成、AI 评估全都在同一套坐标系下面工作,不会出现“UI 坐标是 y 向下,AI 坐标是 x 向下”的混乱。
Canvas 像素坐标和棋盘逻辑坐标之间需要两个转换函数:
javascript复制function boardToPixel(row, col) {
return {
x: MARGIN + col * CELL_SIZE,
y: MARGIN + row * CELL_SIZE
};
}
function pixelToBoard(x, y) {
const col = Math.round((x - MARGIN) / CELL_SIZE);
const row = Math.round((y - MARGIN) / CELL_SIZE);
if (row < 0 || row > 9 || col < 0 || col > 8) return null;
return { row, col };
}
MARGIN 我取 40px,CELL_SIZE 随画布宽度自适应,比如画布宽 640px 时单元格约为 62px,棋子半径取 27px。棋盘绘制时按固定比例缩放,可以保证在手机和桌面端都不变形。
2.3 行棋交互与落点预览
交互流程设计成标准三段式:
- 玩家点击一个己方棋子,把它标记为“选中”,高亮显示;
- 程序立刻计算该棋子的所有合法落点,并用半透明圆点绘制在棋盘上;
- 玩家点击某个落点,或者点击另一枚己方棋子完成切换,点击空白区域取消选择。
这个逻辑放 DOM 实现非常容易,在 Canvas 下只需要做两件额外的事:一是命中检测,我使用反向映射把鼠标坐标换算成 Board 坐标,再读棋盘数组;二是重绘策略,我的做法比较“粗暴”——每次交互后整盘重绘。因为棋盘所有元素加起来也就 32 个棋子加网格线,整帧重绘的开销远小于增量绘制的复杂度。
为了避免 PC 端的鼠标事件和移动端的触摸事件互相干扰,我把监听事件统一在指针事件 API 上,无论用户是鼠标还是手指点击都能响应:
javascript复制canvas.addEventListener('pointerdown', handlePointerDown);
再用 canvas.style.touchAction = 'none' 关掉触摸默认行为,防止移动端点击时触发页面缩放。
2.4 用“走法生成器”串起 UI 和 AI
必须强调一点:行棋阶段的合法落点预览,和 AI 搜索时的走法生成,底层必须是同一套函数。如果 UI 自己写一套、AI 又另写一套规则,后续会出现“玩家能走但 AI 认为不能走”的诡异 bug。
我项目里有两个核心函数:
generateMoves(board, side):返回某个颜色所有棋子的全部合法走法;getMovesForPiece(board, row, col):返回单枚棋子的合法走法。
UI 落点预览调用第二个函数,AI 搜索调用第一个函数。两者最终都复用一系列方向与距离判断函数。这套抽象的收益在后面调试规则时体现得淋漓尽致,我只需要修改底层函数,UI 和 AI 会同时被修正。
3. AI 引擎核心拆解:走法生成、局面评估与搜索剪枝
3.1 走法生成器:必须精确到所有“暗规则”
很多象棋程序跑起来像个半成品,问题往往不出在搜索算法,而是走法生成器漏了规则。下面是易错点清单,也是我当初调试时反复核对的地方:
- 马:走“日”字,四个正方向不能被蹩马腿。计算方式是先按横纵方向移一格,如果该位置有棋子(无论敌我),马就不能往那边跳。
- 象 / 相:走“田”字,不能过河。塞象眼指田字中心有子时不能走。
- 士:只能在九宫斜线移动,逐格斜行。
- 将 / 帅:只能在九宫直线移动一格;将帅直接照面时,可以沿直线“飞”过去吃掉对方(中间无子才能走)。
- 炮:移动时不吃子,走直线不限格数;吃子时中间必须且只能隔一个“炮架”。
- 兵 / 卒:过河前只能向前,过河后可以向前或左右;永远不能后退。
其中,最容易写错的是炮的“隔一个子吃子”逻辑。用 JavaScript 写的时候,最好用沿方向扫描的方式,而不是暴力枚举跳点:
javascript复制function generatePaoMoves(board, row, col, result) {
const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [dr, dc] of dirs) {
let r = row + dr;
let c = col + dc;
let jumped = false;
while (inBoard(r, c)) {
if (!jumped) {
if (board[r][c] === EMPTY) {
result.push(createMove(row, col, r, c));
} else {
jumped = true; // 遇到第一个子,作为炮架,越过它继续找吃子目标
}
} else {
if (board[r][c] !== EMPTY) {
if (isEnemy(board[r][c], side)) {
result.push(createMove(row, col, r, c));
}
break; // 炮架后第一个子就是要吃的子,无论敌我都停止
}
}
r += dr;
c += dc;
}
}
}
这段代码体现了炮走法的全程扫描法:前半段是“不吃子移动”,后半段是“隔子打”。写完以后,我用一个拆解好的棋谱逐步行棋验证,确保没有二义性。
3.2 局面评估:子力、位置与进阶技巧
AI 要判断哪个局面好,需要一套可量化的标准。最简单的评估函数是“子力价值差”:车 = 900,马 = 400,炮 = 450,象 / 士 = 200,兵 = 100,将 = 100000。但只算子力会导致 AI 像个“莽夫”,为了吃一个马不惜把车送掉,因为吃子的即时收益冲昏了头——除非搜索深度能看到后面的损失,否则中短深度下棋力会很差。
所以我在评估函数里加入了两块信息:
一是位置价值表。同一枚兵,在河前和即将冲入九宫时的价值完全不同;同一匹马,在自己阵地的中心和被压在角落,控制力也天差地远。给每个兵种设计一张 10 行 x 9 列的价值表,评估棋子分数时把“基础子力分 + 位置分”叠加。
以兵为例,红方视角的位置价值表大概是这样的(数字越大越好):
| 兵卒位置(红方视角,row=9 是红底线) | 价值加成 |
|---|---|
| 过河前(row 5~9) | 0 |
| 刚过河(row 4) | 15 |
| 深入敌方两线(row 2~3) | 35 |
| 逼近九宫一线(row 0~1) | 55 |
马的价值表更讲究“中心化”。边的马进攻路线少,塞在角落的马基本是废子。我设计的位置分梯度大概为:棋盘中央位置加成 20~40,边线减 20,角落减 40。这些数字不需要调到完美,但对 AI 的行棋风格有立竿见影的效果——同样是马,AI 会倾向于跳向中场而不是缩在底线。
二是威胁度。评估一方局面时,我会统计“当前局面中所有棋子能攻击到的敌方棋子位置”的集合,如果某个棋子处于被攻击状态且该攻击方价值较小,就给予额外扣分。这项特性能让 AI 表现出明显的兑子意识:哪怕搜索深度不够,也能通过评估“被吃风险”来避免白送大子。
最终评估函数定义为:
code复制evaluate = (红方总子力分 - 黑方总子力分)
+ (红方位置分 - 黑方位置分)
+ (红方威胁加分 - 黑方威胁加分)
得分正数代表红方优势,负数代表黑方优势。AI 走黑棋时,就搜索能令评估分最小化的走法。
3.3 极小极大搜索与 Alpha-Beta 剪枝:AI 是怎么“想”的
搜索的原理可以用一个日常类比理解:假设你在下一盘棋,你要考虑自己走一步后对手会怎么应对,你当然希望选择一个自己收益最大、对手能找到的最强回应也伤害最小的方案。极小极大算法就是把这种博弈过程显式展开成一棵树,自己走棋的节点取“最大”,对手走棋的节点取“最小”。
Alpha-Beta 剪枝则是这棵树的“剪枝刀”。它记录两个参数:Alpha 是当前玩家已经能保证的最低分,Beta 是对手能接受的最高分。当搜索某个分支时,如果分支分数已经比 Alpha 更差(也就是对手已经有更好的选择),剩下的子树不必再搜。
剪枝带来的性能提升极大。裸极小极大搜索展开 4 层需要遍历大约 40 的 4 次方即 256 万节点,而加上 Alpha-Beta 且走法按“吃子优先”排序后,实际计算量通常能降到 10 万节点以内,速度提升十几倍到几十倍。搜索顺序很重要:先算吃子、将军等“有潜力”的走法,剪枝效率最高。
核心代码长这样:
javascript复制function alphaBeta(board, depth, alpha, beta, side) {
if (depth === 0) {
return evaluateBoard(board);
}
const moves = generateMoves(board, side);
if (moves.length === 0) {
return isInCheck(board, side) ? -CHECKMATE_SCORE + (MAX_DEPTH - depth) : 0;
}
// 走法排序:先走吃子、先走被敌方威胁的棋子,能大幅提升剪枝效率
moves.sort((a, b) => moveScore(b) - moveScore(a));
if (side === AI_SIDE) { // 黑方,极小化
let minScore = Infinity;
for (const move of moves) {
applyMove(board, move);
const score = alphaBeta(board, depth - 1, alpha, beta, HUMAN_SIDE);
undoMove(board, move);
if (score < minScore) minScore = score;
beta = Math.min(beta, score);
if (beta <= alpha) break; // 剪枝
}
return minScore;
} else {
let maxScore = -Infinity;
for (const move of moves) {
applyMove(board, move);
const score = alphaBeta(board, depth - 1, alpha, beta, AI_SIDE);
undoMove(board, move);
if (score > maxScore) maxScore = score;
alpha = Math.max(alpha, score);
if (beta <= alpha) break;
}
return maxScore;
}
}
注意终局判断的一行:当没有合法走法时,如果己方正被将军,说明被将死,返回 -CHECKMATE_SCORE + (MAX_DEPTH - depth),加上的这个深度差是为了让 AI 在有多种杀法时优先选择“更快杀死”的方案,而不是所有胜势局面等分。
4. 完整实操:核心代码模块与接入流程
4.1 数据结构与基础工具函数
我先把棋盘定义成一个长度为 90 的一维数组,索引换算成行列的公式是 index = row * 9 + col。棋子值我用整数表示:正数为红子,负数为黑子。比如红车 = 1,黑车 = -1,红马 = 3,黑马 = -3。取绝对值即可知道兵种。这个方案的好处是“判断敌我”可以直接用正负号乘积。
棋子编码如下:
| 值 | 棋子 |
|---|---|
| 1 / -1 | 车 |
| 2 / -2 | 马 |
| 3 / -3 | 炮 |
| 4 / -4 | 象 / 相 |
| 5 / -5 | 士 |
| 6 / -6 | 将 / 帅 |
| 7 / -7 | 兵 / 卒 |
初始局面按标准象棋开局摆好。UI 层每次渲染前清空画布,按 90 格数组重绘。由于每个棋子都附带“黑红符号”,我能轻松通过值正负判断是否翻转子。
记录走法结构我用 { fromRow, fromCol, toRow, toCol, movedPiece, capturedPiece }。这个结构同时服务于 undo——通过保存被吃掉的棋子和移动的棋子,可以随时恢复局面。
4.2 评估函数的量化实现
评估函数分两块,子力分直接查表:
javascript复制const PIECE_VALUES = {
1: 900, // 车
2: 400, // 马
3: 450, // 炮
4: 200, // 象
5: 200, // 士
6: 100000, // 将
7: 100 // 兵
};
位置分我采用对称表。红方视角的位置表定义好之后,黑方用同一个表时要把 row 镜像处理(mirroredRow = 9 - row),因为黑方是反方向进攻的。注意这里有一个新手最容易踩的坑:如果直接复用位置表而不做行镜像,黑方棋子会被评估成“越在自家底线越有优势”,AI 就会像被抽走灵魂一样拒绝进攻。
位置表本身我是按经验调的,不追求极限优化。一个能用的马的简化位置加分表如下(行从黑方底线往下到红方底线):
code复制马位置加分(9x10 表格摘要):
row 0: -30 -10 -10 -10 -10 -10 -10 -10 -30
row 1: -10 0 5 5 5 5 5 0 -10
row 2: -10 5 15 15 15 15 15 5 -10
row 3: -10 10 20 25 25 20 10 -10 -10
row 4: -10 10 20 30 30 20 10 -10 -10
row 5: -10 5 15 20 20 15 5 -5 -10
row 6: -20 0 10 10 10 10 0 -5 -20
row 7: -20 -5 0 0 0 0 -5 -10 -20
row 8: -30 -10 0 0 0 0 -10 -20 -30
row 9: -40 -20 -10 -10 -10 -10 -20 -40 -40
这个表用真实行号在下标里寻址。写表的时候我是按“价值中心对称”的思路去刻画的,马在棋盘中央控制点最多,所以分数最高;在边、角因为活动空间小,给负加成。AI 在实战中会自然表现出“跳正马”“盘河马”的倾向,这是纯子力分给不了的棋感。
威胁度评估的代码实现大致是:遍历每方棋子,用该棋子的合法走法去命中对方棋子集,命中一个就记录被威胁棋子的价值。然后累加所有被威胁的敌方棋子价值并乘以权重(我用 12%~15%)。需要注意的是,这个逻辑必须排除“能互相吃”的等价互换场景,否则 AI 会为了避免兑子而缩手缩脚。我的处理方式偏保守:威胁加分只针对“攻击方价值 < 被攻击方价值”的局面,也就是说它鼓励吃大子,但对等价兑子不特别敏感。
4.3 搜索深度与时间预算的取舍
固定搜索深度为 4,这是在浏览器环境里普通笔记本实测下来最稳的方案。如果机器性能足够,也可以动态往上加:先算 2 层,如果耗时低于 300ms,就尝试 3 层,继续叠加直到超出单步时间预算。
我在代码里加了这样一个“迭代加深”逻辑:
javascript复制function findBestMove(board, side, timeLimit = 800) {
const start = performance.now();
let bestMove = null;
let bestScore = side === AI_SIDE ? Infinity : -Infinity;
for (let depth = 1; depth <= MAX_DEPTH; depth++) {
const result = alphaBetaRoot(board, depth, side);
bestMove = result.move;
bestScore = result.score;
if (performance.now() - start > timeLimit) {
break; // 时间预算耗尽,不再加深
}
}
return bestMove;
}
迭代加深的额外收益是,AI 永远不会出现“来不及思考”的空白时间。它每跑完一层就保存一个可用的 bestMove,即使时间被截断,也能拿最近一层的思考结果走棋。
4 层纯 JS 计算在桌面 Chrome 上大约耗时 200~600ms。但对低端 Android 手机来说,可能会到 1.5 秒左右。这时候需要配合 setTimeout 或 requestAnimationFrame 把重计算从 UI 线程里释放出来,再加一层加载提示。为了不让 AI 思考时页面冻结,我先在点击事件里执行 canvas.style.opacity = '0.7',再用 setTimeout(() => { ... AI 搜索 ... }, 50) 把搜算放到下一帧,避免当前交互的渲染被阻塞。
4.4 AI 与界面联动
AI 搜索完成后返回走法,界面要走一遍和人类玩家完全相同的“应用走法”流程:更新棋盘数组、播放当前位置变化、检查将军与胜负状态、切换回合、整盘重绘。
UI 层所有行为做成“引擎无关”的状态机:
javascript复制const gameState = {
phase: 'playerTurn', // playerTurn | aiThinking | gameOver
currentSide: RED,
history: [],
board: initBoard()
};
玩家点击落子后,phase 变为 aiThinking,搜索完成、走子结束再切回 playerTurn。这个状态机让代码逻辑非常清晰:无论在哪个 UI 入口触发行棋,核心处理流程唯一,不会出现“AI 走棋瞬间玩家还能抢点棋子”的并发错乱。
同时,我把“将军”“将死”“困毙”的逻辑独立封装。每次行棋以后统一调用一下 checkGameOver(),返回的状态有四种:checking(将军)、checkmate(将死)、stalemate(困毙)、normal。UI 顶部文字框会显示“将军!”“黑方胜”等提示。将死的判定方式就是在走法生成器返回空的基础上再查一次当前局面是否存在被将军状态,可以共用同一套底层的 isInCheck(board, side) 函数,减少冗余逻辑。
5. 调试实录与常见问题速查
5.1 棋力“忽高忽低”,剪枝好像失灵了
开发过程中我第一次启用 Alpha-Beta 剪枝时,发现 AI 棋力反而变差了,有时候明明能吃掉的车不吃,非走一步无用棋。排查后发现不是剪枝 bug,而是“走法排序”没做好。
剪枝的效率极度依赖搜索顺序:如果先走了一堆烂棋,Alpha 和 Beta 的边界迟迟不收紧,剪枝就跟没剪一样。而走法排序如果把吃子放到最后,AI 很可能因为提前剪掉了一个初始看似普通但包含妙手的分支,导致“剪掉了最优解”的假象。
我的修复方法是在进入搜索前,对走法列表做一个快速的启发式排序:吃大子的走法排前面、被将军的走法排前面、其余按棋子价值和目标位置粗略估分。这个排序不需要精确,只要让大概率的好棋先被搜索,Alpha-Beta 的效率就会成倍上升。我实测在深度 4 时,用好排序后搜索节点数从约 90 万降到约 15 万。
5.2 无限递归与“AI 不走棋”
项目刚完成基础规则后,我第一次跑 AI 时页面直接卡死,控制台报 Maximum call stack size exceeded。原因很典型:applyMove 和 undoMove 之间没有正确维护“王/将位置”,导致搜索树中反复出现来回走将的循环局面。
更隐蔽的问题是“重复局面”。象棋里长捉、长将是可以被判负的,但 AI 搜索时如果遇到一个循环变化(例如车反复在两条线之间将军),深度加深后就会出现无限递归。解决方法是引入“历史表”或者简单加一个局面重复检测,给重复局面打一个惩罚分。我这里只做了简单版本:在同一条搜索路径中,如果碰到完全相同的棋盘状态,直接给一个极差的分数,避免 AI 主动选择循环着法。
5.3 移动端页面卡顿与点击错位
我自己测试时把网页发到手机上,发现 AI 思考时页面会白屏一瞬,手指点击落子也偶尔出现错位。问题有几个来源:
一是画布没有适配设备像素比(DPR)。在 Retina 屏幕上,CSS 像素和物理像素是 1:2 的关系,不处理的话棋盘看起来发虚,棋子边缘毛毛的。修复方式是读取 window.devicePixelRatio,把 Canvas 的实际绘图尺寸乘以 DPR,然后用 ctx.scale(dpr, dpr) 把坐标系缩回来。
二是触控目标太小。有些设备下棋子的渲染半径只有 24px,导致点击位置上像素和格子判断有偏移。我把棋盘 MARGIN 加大,在指针事件里加上接触半径容差判定,允许用户点在目标位置周围 10px 内的误差。
三是页面在 AI 计算时没有加“锁”,用户可以在 AI 思考过程中继续点棋盘,导致历史栈记录错乱。解决办法就是我之前提的 phase 锁,在 aiThinking 状态下直接忽略所有玩家棋盘点击。
5.4 疑难杂症速查表
| 症状 | 可能原因 | 处理方案 |
|---|---|---|
| 马可以越过河对岸的兵跳 | 没有检查中间位置棋子 | 跳马前检查四个“蹩马腿”方向对应的位置 |
| 象可以过河 | 没有限制河界 | 象 / 相移动的目标位置 row 必须在己方半场,或判断不能越过 row=4/5 边界 |
| 炮可以空打 | 吃子时没有检查“炮架” | 吃子路径上必须存在且只存在一个中间棋子,再遇到子才是可吃目标 |
| 将帅照面不违规 | 没有处理“将帅互见” | 每次移动将/帅后检查与对方将帅是否在同一条竖线且中间无子 |
| AI 不进攻 | 评估函数缺少位置分 | 给马、兵、炮增加位置表并镜像处理 |
| AI 思考卡死 | 搜索深度过高或没有剪枝 | 改用 Alpha-Beta,并给搜索加时间预算 |
| 点击错位 | 未处理设备像素比或边界坐标 | 用 DPR 缩放,允许坐标转换的取整误差 |
| 悔棋后 AI 不走 | 历史栈状态未正确回滚 | 悔棋时必须恢复 board 快照和当前回合方 |
这类问题零零总总大概有十几个,但主线始终一致:棋盘数据是唯一事实来源,UI 只是它的投影。任何看似“玄学”的 bug,几乎都能从数据维护上找到根因。
6. 后续扩展思路与个人实操体会
6.1 这个项目还能怎么升级
现在这套纯前端 AI 达到的水平,对刚学棋的玩家已经有点压力了,但距离真正的强 AI 还非常远。如果继续做下去,我建议从三个方向切入。
第一个方向是开局库。固定深度搜索在开局阶段没有方向感,容易走出一些“没毛病但不专业”的棋。可以把经典开局(中炮对屏风马、仙人指路等)的前 6~8 回合做成哈希表,开局阶段直接从库里挑,这样 AI 的前几步观感会立刻提升。
第二个方向是残局库。在棋子数很少的终局,暴力搜索深度不够,但可以用离线计算的残局表把特定局面压缩成最优步数。比如“单车难胜士象全”这类经典和棋局面,普通搜索很可能判断不出和棋,AI 会白白送子,用残局知识库可以解决。
第三个方向是接入 Web Worker。AI 搜索是纯 CPU 密集任务,即便用 setTimeout 也只能避免事件阻塞,不能真正利用多核。把搜索逻辑放进 worker.js,主线程只负责渲染和事件,这样手机端的思考时间能明显缩短,还可以把搜索深度稳定开到 5 层。
另外,UI 层面也可以加一些体验功能:历史棋谱导入导出(支持 FEN 格式)、棋子拖拽动画、AI 难度滑块(通过限制深度或加入随机扰动实现)。如果想让浏览器里的 AI 自动打谱复盘,还可以加一个“分析模式”,每走一步自动标出评估分变化,这对学棋的人来说价值很大。
6.2 我踩过的最值得说的一坑
按重要性排序,我最想提醒后来者的,是千万别在 UI 和 AI 之间维护两套数据。刚开始写的时候,我为了让 UI 更顺滑,给每个棋子加了独立的 { row, col, type } 对象数组,渲染时遍历对象数组;而 AI 搜索时用的是 90 格整数数组。结果每次玩家行棋,我要先改对象、再改整数数组,还得保证二者同步。一旦某个逻辑漏了同步,AI 思考的局面和 UI 显示的局面就出现偏差,调试极为痛苦。
这个教训让我下定决定:一切以 90 格整数数组为准,UI 每次需要棋子信息时实时从这个数组派生。棋子对象可以“看得见”,但不能成为第二数据源。类似原则对于任何棋牌类前端项目都适用,包括国际象棋、五子棋、斗地主等——状态源越单一,bug 指数越少。
6.3 个人的一点体会
做这个项目最大的收获不是棋力有多强,而是把一个“看似需要专业算法知识”的东西,压缩成了一个纯前端文件就能跑通的最小实现。象棋 AI 没有想象中那么高不可攀,它的骨架就是走法生成、局面评估和搜索剪枝三件事;当我把这三个模块在浏览器里跑通的那一刻,回头再看所谓“AI”这个概念,心里就有了一幅非常具体的画面。
如果看完这篇文章你有动手的冲动,我的建议是先不要急着写搜索算法,老老实实把棋盘画出来、把规则走法做对,再往上面套一层搜索,你会发现每一步都是水到渠成。就算你只做到“能够实现完整规则 + 人机对弈”这一步,这个项目也已经足够撑起一份很漂亮的个人作品集了。
