1. 项目整体设计:先拆清“HTML、AI、象棋”三件事的关系
写一个HTML版AI象棋程序,起源其实很朴素。有一天我想在浏览器里跟电脑下盘象棋,但不想登录棋类平台、不想被广告挡住,更不想为了一个小功能去启动 Python 后端。我需要的只是一个用 HTML、CSS、JavaScript 写的单文件页面,双击 index.html 就能跑起来,能正常下棋,AI会响应,顺手还能悔棋、换先手、调难度。
这个标题拆开看是三件事:HTML负责搭出页面壳子,AI负责模拟“对手”,象棋程序负责把车马炮兵卒的走法、将军、胜负这套规则跑起来。很多新手一看到“AI”两个字就发怵,以为要自己从头写神经网络。其实象棋的AI完全可以走传统博弈树路线:程序准备好所有合法走法,利用估值函数给棋盘打分,再用搜索算法挑出最有利的着法。这套实现不依赖显卡、不需要训练数据,一台普通电脑都能跑,代码量也远没有想象中大。
如果你想用这个项目练手,或者要做课程设计、比赛演示,这篇文章会把它拆成几个可以独立验收的模块:棋盘界面、数据模型、规则引擎、AI搜索、交互细节。完整的工程里,模块顺序应该是先做棋盘渲染和基础数据结构,再做走法生成与合法性过滤,最后接入AI评估函数和搜索剪枝。我的建议是不要一上来就写搜索,先把“人能走子、能吃子、能判断将军”做出来,AI部分整个就是一道加法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 棋盘与规则引擎:让双方先把子“走明白”
2.1 用二维数组保存棋局,不用DOM节点管数据
象棋棋盘是9列10行,落子位置是离散的格子,最适合的数据结构就是二维数组。开局时初始化一个数组,每个位置要么是null,要么是包含side和type的对象,例如红色“车”就是 { side: 'red', type: '车' }。这个方案比把棋子全部铺在DOM节点上更好调试,因为在AI搜索过程中,我们要反复模拟走子、撤销、再走子,频繁操作DOM会非常慢,但操作二维数组只是内存读写。
坐标这里的“行”是从上往下数的,0到9;列是从左到右,0到8。棋盘上方是黑方,下方是红方。可以这样初始化棋盘:
javascript复制const ROWS = 10;
const COLS = 9;
function createInitBoard() {
const board = Array.from({ length: ROWS }, () => Array(COLS).fill(null));
const redBack = ['车', '马', '相', '仕', '帅', '仕', '相', '马', '车'];
const blackBack = ['车', '马', '象', '士', '将', '士', '象', '马', '车'];
for (let x = 0; x < COLS; x++) {
board[9][x] = { side: 'red', type: redBack[x] };
board[0][x] = { side: 'black', type: blackBack[x] };
}
board[7][1] = { side: 'red', type: '炮' };
board[7][7] = { side: 'red', type: '炮' };
board[2][1] = { side: 'black', type: '炮' };
board[2][7] = { side: 'black', type: '炮' };
for (let x = 0; x < COLS; x += 2) {
board[6][x] = { side: 'red', type: '兵' };
board[3][x] = { side: 'black', type: '卒' };
}
return board;
}
注意红方在下方、黑方在上方。如果有同学习惯把红方放在上面,后面所有关于“兵过河”“仕不出九宫”的判断都要同步反过来,非常容易出错。用这种上下结构,红方“帅”的活动范围是第7到第9行、第3到第5列,恰好落在底部的九宫格里,语义很直观。
2.2 用Canvas画棋盘,把像素坐标换算成行列
画棋盘我用Canvas 2D,不直接写格子的div,理由和前面说的一样:界面要能快速重绘。玩家走一步,AI思考后再走一步,中间还要高亮选中棋子、显示可走位置,Canvas重绘比反复增删DOM节点轻松得多。
画盘的基础参数一般是:四周留30像素左右的边距,内部网格区域宽度固定成一个方便计算的值,比如每个格子边长50像素,那么棋盘总宽度大约是950+两倍边距,高度是1050+边距。下面是裁剪过的绘制逻辑:
javascript复制const margin = 30;
const cellSize = 50;
function boardX(col) {
return margin + col * cellSize;
}
function boardY(row) {
return margin + row * cellSize;
}
画竖线时不需要每条都从第0行画到第9行,因为最左侧和最右侧会形成棋盘外框;画横线时中间两条河界处的横线被楚河汉界文字截断,所以常用循环分两段画。九宫斜线也要单独判断:红方九宫在下方,黑方九宫在上方。
绘制棋子时比较省事的做法是每个棋子画一个圆,再调用 ctx.fillText 画中文棋子名。红方用红色描边、红色文字,黑方用黑色。选中的棋子额外画一圈亮色外环,可走位置用半透明小圆点表示,这样人机交互会清晰很多。
这里有一个比较隐蔽的坑:浏览器高分屏缩放。如果在普通笔记本上看起来正常,换到Retina屏幕,Canvas棋盘和棋子会明显发虚。原因是设备的物理像素密度比CSS像素高,Canvas默认按设备像素渲染。解决方式是在窗口尺寸变化时重新设置canvas.width大小:
javascript复制function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
const displayWidth = document.getElementById('board').clientWidth;
const displayHeight = displayWidth * (10 / 9);
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;
ctx.scale(dpr, dpr);
drawBoard();
}
每次设置canvas.width都会触发画布清空和缩放重置,所以必须先设置宽高再设置ctx.scale,这些细节直接影响视觉体验。
点击棋子的交互,本质上就是一个坐标反算过程。监听canvas的click事件,通过event.clientX减掉canvas左上角在页面中的坐标,得到相对于canvas的像素坐标,再利用现成的格点公式反推点击落在哪一行、哪一列:
javascript复制canvas.addEventListener('click', function(e) {
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const col = Math.round((px - margin) / cellSize);
const row = Math.round((py - margin) / cellSize);
if (row >= 0 && row < ROWS && col >= 0 && col < COLS) {
handleGridClick(row, col);
}
});
需要提醒的是,边缘的点击很容易因为边界判断不到位而误选相邻格子。用Math.round比Math.floor更符合直觉,因为只有点击横线附近时才会映射过去,点击格子中央的结果更准确。
2.3 走法生成是规则引擎的心脏
规则模块要解决的核心问题是:给定当前棋局和某个棋子的位置,这个棋子合法能走哪些格子。走法生成要做到两件事,第一是判断目标位置是否在棋盘范围内且不吃到己方棋子,第二是每种子力各自的“行走规则”必须单独实现。
最容易出错的子是马、炮、兵。
马走“日”字,移动方向是横向2格纵向1格,或者纵向2格横向1格。关键要判断“蹩马腿”:如果马横向移动两格,就要检查它所在行与目标行之间的那条边中间是否有棋子;如果马纵向移动两格,就要检查两个格子中间的那个横向位置是否有棋子。很多初版代码会把蹩马腿方向写反,看起来AI总能用马突破常规障碍,其实问题不在AI,而在规则层。
炮的移动分两种情况:不吃子时,只能沿直线移动,中间不能有任何棋子;吃子时,必须跳过恰好吃子点和目标位置之间的一个棋子。实现时要一边向某个方向扫,一边数路径上的棋子数:
javascript复制function getPaoCandidateMoves(board, row, col) {
const result = [];
const side = board[row][col].side;
const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [dr, dc] of dirs) {
let nr = row + dr;
let nc = col + dc;
let jumped = false;
while (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS) {
if (board[nr][nc] === null) {
if (!jumped) result.push([nr, nc]);
} else {
if (!jumped) {
jumped = true;
} else {
if (board[nr][nc].side !== side) {
result.push([nr, nc]);
}
break;
}
}
nr += dr;
nc += dc;
}
}
return result;
}
兵和卒最容易漏掉“过河后可以横走”的状态。红方兵无论在哪一行只要没有过河就不能横走,黑方卒同理,只是过河方向是反的。过河判断简单的方案是看“当前所在行是否已经越过楚河汉界的分界线”,红兵在第6行及以下算过河?不,棋盘上方是黑方,红兵过河后进入第0到第4行;黑卒过河后进入第5到第9行。以红兵为基准,当row < 5时它就过河了。不要用棋子类型区分“兵和卒”,因为一个“卒”字本身已经包含了黑方身份,直接通过side判断最省事。
最后,所有走法都必须经过一层合法性过滤:走完这步后,自己的帅不能被吃。即使某粒子的走法符合基本规则,如果它导致老将暴露,这步也不能下。这一条会在后文将军检测部分详细说。
3. 从“能走”到“会下”:将军检测和非法走法拦截
3.1 判断“老将是否暴露”比判断“是否将军”更重要
规则引擎实现了基础走法后,下一步要处理将军和送将。网上很多象棋项目只写了“吃完老将就赢”,却忘了拦截把自己送将的走法。这样会导致AI下出一手傻棋:它明明可以吃掉对方老将获胜,却放任自己的老将被反杀。
正确顺序应该是:每次模拟走完一步后,立刻判断执行这一步的这一方,自己的帅是否暴露在对方的攻击范围里。如果暴露,这步就不合法。判断一条线是否被攻击,其实不需要让对手把整盘棋的所有合法走法都列出来,只针对单点判断就行。
比如我写了一个函数canAttack(board, side, targetRow, targetCol),用来判断side方是否存在某个棋子能攻击到target位置。实现方式要考虑车炮在水平和竖直方向的直线扫描、马的八方向日字、兵的过河前进位等。判断直线时,还要区分目标位置是被车吃到还是被炮打到:车要求中间无棋子;炮要求中间恰好隔一个棋子。
这套单点攻击判断同样能处理“将帅对脸”的问题:如果红帅和黑将在同一条竖线上且中间没有其他棋子,双方都不能在这个位置正面相对。因为将帅在一条直线且无遮挡时,帅可以直接攻击将。也就是说,在同一直线上中间无子时,对方老将能够“隔空吃帅”。
基本代码如下:
javascript复制function isSquareAttacked(board, side, row, col) {
// 这里的side是攻击方,检查是否存在side方棋子能攻击到(row, col)
// 检查车、炮直线攻击
const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [dr, dc] of dirs) {
let nr = row + dr;
let nc = col + dc;
let blockCount = 0;
while (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS) {
if (board[nr][nc]) {
blockCount++;
}
if (board[nr][nc] && board[nr][nc].side === side) {
if (board[nr][nc].type === '车' || board[nr][nc].type === '炮') {
const needBlock = board[nr][nc].type === '炮' ? 1 : 0;
if (blockCount - 1 === needBlock) return true;
}
break;
}
nr += dr;
nc += dc;
}
}
// 省略马、兵、将帅的判断
return false;
}
这个实现有一个前提:直线扫描中遇到的第一个棋子如果是side方棋子,则根据棋子类型判断是否能攻击;如果遇到对方棋子则立即停止,因为后面的棋子会被挡住。这个代码块里blockCount设计得比较巧妙,初学者如果能自己推演一遍,对棋类程序的逻辑理解会提升很多。
3.2 走一步看看,再决定合不合法
有了单点攻击判断后,合法走法过滤就简单了。只要模拟这一步执行,然后调用isSquareAttacked判断这方自己的帅是否安全。这个过程不用额外维护特殊变量,特别适合AI搜索中的大量模拟。
具体来说,getLegalMovesForPiece返回的基础候选走法不一定全部合法,得在外面再包一层:
javascript复制function findLegalMoves(state, side, fromRow, fromCol) {
const candidates = getRawMoves(state, fromRow, fromCol);
const legalMoves = [];
for (const [toRow, toCol] of candidates) {
const captured = state[toRow][toCol];
const movingPiece = state[fromRow][fromCol];
state[toRow][toCol] = movingPiece;
state[fromRow][fromCol] = null;
const kingPos = findKing(state, side);
if (kingPos && !isSquareAttacked(state, opponent(side), kingPos.row, kingPos.col)) {
legalMoves.push([toRow, toCol]);
}
state[fromRow][fromCol] = movingPiece;
state[toRow][toCol] = captured;
}
return legalMoves;
}
这里需要注意的是findKing在正常局面下不会返回空,但如果棋盘上老将已经被吃掉了,就说明上一手结束了,不应继续生成走法。因此在正常的行棋流程里,每走完一步后,最好立刻检查对方老将是否还在棋盘上,在或者不在决定是否继续。很多项目陷入死循环,问题就出在“吃掉老将”之后还继续生成走法,把已经消失的pos当作undefined,导致搜索过程崩掉。
3.3 行棋入口的逻辑顺序
规则部分完整接入后,行棋入口的控制顺序很关键。我推荐用一个统一入口处理玩家的点击:
- 第一次点击有己方棋子的格子时,记录选中棋子,并高亮它以及它的所有合法目标位置。
- 第二次点击高亮目标位置时,真正执行移动。
- 如果第二次点击的是己方棋子,则重新选中这枚棋子,而不是执行移动,这样用户可以连续切换选子。
执行完一步后,要把“轮到谁走”的turn变量切换,然后判断胜负条件。接着才轮到AI搜索。如果用户走了一步之后不马上让AI走,会出现棋盘上红方的兵已经在第二个位置,但AI还在按红方先手思考,非常奇怪。
4. AI决策模块:用估值函数和Alpha-Beta剪枝让电脑学会下棋
4.1 价格不能说明一切:给每颗棋子建立基本价值表
AI要思考哪一步更好,第一步是把棋局量化成数字。最直白的思路是给每颗子标一个分数。红方累计正分,黑方累计负分,评估函数返回双方分数差值。分数越高,代表红方局面越好;分数越低,代表黑方局面越好。AI搜索时,红方想最大化分数,黑方想最小化分数。
我建议给车、马、炮等子力设定如下基本分值:
| 棋子 | 基础价值 | 备注 |
|---|---|---|
| 车 | 900 | 直线控制力强 |
| 马 | 450 | 中局价值高 |
| 炮 | 400 | 开局作用明显 |
| 兵(过河前) | 100 | 过河前杀伤有限 |
| 兵(过河后) | 150 | 过河后可横移 |
| 相/象 | 200 | 防守型棋子 |
| 仕/士 | 200 | 防守型棋子 |
| 帅/将 | 100000 | 老将价值无限大 |
只看基本价值还不够。一个马被赶到角落,和一颗马稳稳站在河头,控制力差别很大。常见的优化是给每个兵种附加“位置价值表”。红方马在己方河头附近得分更高,黑方马则在黑方河头附近得分更高。实现时要特别注意镜像对称:红方的第9行对应黑方的第0行,同一个位置价值表不能直接拿来复用。最简单的做法是红方用row,黑方用9-row查表。
一个完整但不过度复杂的评估函数可以写成:
javascript复制function evaluate(board) {
let score = 0;
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
const piece = board[row][col];
if (!piece) continue;
const absolute = PieceValue[piece.type];
const isRed = piece.side === 'red';
let posBonus = 0;
if (piece.type === '马') {
posBonus = HorsePosition[isRed ? row : 9 - row][col];
} else if (piece.type === '炮') {
posBonus = CannonPosition[isRed ? row : 9 - row][col];
}
score += isRed ? absolute + posBonus : -(absolute + posBonus);
}
}
return score;
}
要注意的是,这里所有变量和函数都在AI搜索线程的同一个JS上下文里执行,修改board时务必用“走一步再撤销”的方式,尽量少创建新数组对象。每创建一个新数组都意味着更多的垃圾回收,搜索深度一高就会拖慢速度。
4.2 负极大值框架:让搜索代码少写一半
传统极大极小搜索需要分别写max层和min层,代码容易重复。负极大值(Negamax)写法利用了一个简单的数学关系:某一方的最佳收益,等于对手视角下的“负的自己的最差结果”。写成代码,可以让搜索函数只维护自己的alpha和beta上限。
简化后的递归逻辑是这样:
javascript复制function negamax(depth, alpha, beta, side) {
const moves = generateAllLegalMoves(board, side);
if (depth === 0 || moves.length === 0) {
const val = evaluate(board);
return side === 'red' ? val : -val;
}
let best = -Infinity;
for (const mv of moves) {
doMove(board, mv);
const score = -negamax(depth - 1, -beta, -alpha, opponent(side));
undoMove(board, mv);
if (score > best) best = score;
if (score > alpha) alpha = score;
if (alpha >= beta) break;
}
return best;
}
搜索入口需要遍历当前走棋方的所有合法走法,分别计算每个走法执行后的分数,选出分数最高的一步。注意Negamax函数里每次返回的评估值都是从一个固定视角来看的,如果你传入的是红方,就返回正数;传入的是黑方,就需要取负。否则AI会严重偏向某一方。
Alpha-Beta剪枝的核心逻辑在这段代码里只有几行:当score >= beta时立即break,说明当前这一层已经找到了足够好的结果,兄弟节点再继续搜索只会浪费时间。剪枝效率高不高,很大程度上取决于前面先搜索什么走法。如果先搜索了最差的走法,剪枝基本不发生;如果先搜索了最好的走法,剪枝就能砍掉大量分支。
4.3 着法排序:只给搜索加一小段代码,效率翻倍
我在最开始写AI时,直接用合法走法的原始顺序搜索,深度到4层时一盘棋思考时间已经要等十几秒。后来做了个优化,搜索前把着法按照“预估分数从高到低”排序。预估值可以简单粗暴地定为:能吃子的走法优先,再用被吃子的基本价值做排序。能吃掉对方车、马、炮的走法排在最前面,普通走子排后面,这样Alpha-Beta能更早遇到最优分支,剪枝量会显著增加。
着法排序的代码可以很轻量:
javascript复制function orderMoves(moves) {
return moves.sort((a, b) => {
return (scoreMove(b) - scoreMove(a));
});
}
function scoreMove(move) {
const target = board[move.toRow][move.toCol];
let score = 0;
if (target) {
score += 10 + PieceValue[target.type];
}
return score;
}
这里的10只是一个人为设定的常数,用来保证“吃子”永远排在“普通移动”之前;再用被吃子的价值排在同一层级的不同吃法中。这个优化做完后,深度4的搜索时间通常能从十几秒降到两三秒,效果非常明显。
但也不要把深度无脑调高。在纯JavaScript里同步搜索,深度4到5已经适合大部分机器;深度6以上的搜索时间会指数级上升。比较好的做法是提供“简单/中等/困难”三档难度:简单难度的搜索深度为1,并且加入少量随机扰动,让AI会下但经常犯错;中等深度为3;困难深度为5或6。对于普通用户而言,一档能稳定击败初学者但会给两三个缓手的中等AI,反而比一台永不失误的机器更让人愿意陪它下。
4.4 同步搜索会卡住界面,用异步任务来兜底
JavaScript是单线程的。如果事件处理函数里执行深度5的搜索,整个页面会卡住一两秒甚至更久,用户会以为页面崩溃了。这个问题在生产中很常见,最简单的解决方式是把搜索放入setTimeout,先让浏览器完成本轮界面渲染,再进入计算:
javascript复制function onPlayerMove(from, to) {
doMove(board, from, to);
render();
if (isGameOver()) return;
setStatus('电脑思考中...');
setTimeout(() => {
const aiMove = searchBestMove(board, currentTurn, difficultyDepth);
if (aiMove) {
doMove(board, aiMove.from, aiMove.to);
setStatus('轮到你了');
render();
}
}, 30);
}
注意在搜索期间要给玩家的棋子加点击锁。不能让用户在AI思考时又点棋盘,否则会产生状态错乱。可以在点击处理函数开头判断一个thinking变量,如果为true直接returrn。等待几十毫秒再进入计算的另一个好处是,浏览器有时间把点击选中高亮、移动后的动画刷新出来,用户体验会真实很多。
如果想进一步优化,可以使用Web Worker把搜索放到独立线程里,不过这就需要把棋盘数据和规则代码单独抽成一个可导入的脚本,复杂度会提高,单文件的便利性也会打折扣。对于一个HTML版项目,目前setTimeout方案够用且稳定。
5. 上线前必须处理的细节和常见问题排查
5.1 悔棋、重新开局和先后手切换
悔棋这种看起来不起眼的功能,实现方式直接影响体验。我的做法是维护一个历史栈,每次执行一步走子后,把当前局面以完整二维数组快照的形式压入栈中,悔棋时直接将整个棋盘赋值成上一份快照。
在AI对战模式下,人类玩家悔棋要格外小心:红黑双方各有一个棋盘快照,用户走了一手红棋,AI又走了一手黑棋,用户后悔这一手时应该把这两步都撤回。也就是说,一次悔棋动作需要出栈两次。如果AI还在思考,这时候要先把pending的AI任务清理掉,再恢复棋盘,否则会出现“面板上回退了,AI思考结束又自动走了一步”的情况。
重新开局就简单了,把board变量重新初始化,清空历史栈,把turn设回红方即可。如果想支持“AI先手”,只需要把当前的currentTurn改成黑方,并主动触发一次AI搜索。很多象棋AI程序默认人类总是红方,实际上AI执红棋先走的难度和执黑棋后手差异挺大,最好在设置里加一个“玩家执红/玩家执黑”的选择。
5.2 移动端适配与单文件部署要注意的三个边角问题
由于这是个HTML页面,最大的优势是不需要服务器。有人会给这个项目套一个python http.server,其实完全没必要。只要项目里没有fetch或XMLHttpRequest请求本地外部资源,双击index.html就能运行。不过这里有几个边角问题容易踩坑。
第一,文件编码。HTML文件里最好声明 <meta charset="utf-8">,否则Windows记事本默认保存的GBK编码会让中文字符变成乱码。即使你的编辑器显示正常,换一台电脑打开也可能出问题。第二,目录依赖。如果项目中引用了外部的CSS或JS文件,双击打开时浏览器会限制file协议下的一部分模块加载,但纯单文件嵌入逻辑不会受影响。所以我最终把CSS、JS全部塞进同一个HTML文件,这也让分享变得非常简单,直接把文件发给朋友就能打开玩。
移动端适配的核心是棋盘大小。不要用固定像素宽度的canvas,而是在窗口尺寸变化时动态计算一块正方形的绘制区域。手机竖屏时棋盘宽度应当取页面可视宽度的92%左右,外接大屏显示器时可以用最小视口宽度和最大棋盘像素值做约束。处理起来类似:
javascript复制function refreshBoardPanel() {
const maxW = Math.min(window.innerWidth - 16, 560);
const maxH = window.innerHeight - 180;
const side = Math.min(maxW, maxH);
wrap.style.width = side + 'px';
resizeCanvas();
}
5.3 实战中容易踩的坑,按现象、原因、排查方向速查
实际开发过程里,有一堆问题不是编译报错能看出来的,而是逻辑层面的隐性错误。我整理成了一张表,方便大家照着排查。
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| 棋子点击后没有高亮显示 | 坐标反算时用的rect不是当前canvas的相对位置 | 打印点击坐标和计算得到的行列,先验证映射 |
| 点击一个棋子后二次点击别处不落子 | 选择的己方棋子没有正确写入selected字段 | 检查row、col是否越界,以及是否使用了==而不是===比较类型 |
| AI一直走同一个位置且不变化 | 搜索深度为1,评估值相同,走法顺序恒定 | 检查是否加入随机扰动,或增加深度到3以上 |
| AI走子速度极慢 | 没有使用Alpha-Beta剪枝或没有着法排序 | 确认递归中alpha>=beta分支是否生效 |
| AI会吃掉自己的兵 | 走子后没有过滤“自己帅暴露”的不合法走法 | 检查findLegalMoves是否调用了isSquareAttacked |
| 炮可以不吃子走任意步 | 炮的基础走法判断中少了路径上棋子数统计 | 重点检查jumped变量逻辑 |
| 红方在下方却计算成红方过河方向错误 | 过河判断逻辑写反 | 打印坐标确认兵过河后row的范围 |
排查这类问题最快的方式是写一个简单的调试接口。在控制台里执行moveNow(row, col),把内部状态打出来,比一遍遍刷新页面、点击鼠标快得多。我甚至会在浏览器控制台执行一个showBoard(),用简洁的方式把棋盘缩略成一行一行的字符。看一行文本比看Canvas图上几十颗子更快。
5.4 我踩过的三个记忆深刻的“AI玄学”Bug
第一个Bug是AI在残局阶段反复把车送到炮口。起初我以为是搜索逻辑有问题,后来打印出搜索分数才发现,我的评估函数没有加分“捉子先手”这个概念,AI宁可吃对方一个兵也不想自己的车被吃,但搜索深度太浅时根本看不到往后两步被反杀的危险。于是我把炮的基本价值提到了马的一半还高,并增加了捉子走法的惩罚项,棋力才明显正常起来。
第二个Bug和红黑双方价值取负有关。我的评估函数返回的是“红方视角”的分数,但搜索函数在负极大值框架里没有根据side做取反,导致黑方AI总是选择能让红方局面更好的一步。那几盘棋看起来就像黑方在给红方喂子,这个Bug足足浪费了我一个下午。现在我会在评估函数旁边写一行注释:所有走法生成的搜索结果,在红方视角下都应该乘上side系数,黑方乘-1,红方乘1。
第三个Bug不是逻辑错误,而是环境相关。我在某一次更新中给页面加了一个全屏拖动事件,用来让棋盘响应触摸,结果电脑端鼠标选中棋子拖动时,总会触发浏览器的默认文本选择,导致棋盘状态混乱。后来把相关的事件改成pointer events,才统一处理了鼠标和触摸。我的建议是这种纯拖拽式交互尽量少用,对于棋类程序,选择后点击落子的模式在任何设备上都不容易出问题。
整段调完以后,最让我满意的还是用单文件跑通完整人机对战的那一瞬间:双击HTML打开页面,红方走炮二平五,电脑黑方立刻回一个马8进7。作为开发者,我看到的不是简单的棋盘响应,而是一套规则引擎、一套评估系统和一套搜索剪枝框架在几十毫秒内互相协作的结果。以后想继续扩展也很简单,把FEN棋谱输出加上就能做历史记录复盘,把评估函数换成更复杂的局面因素就能提升AI棋力。做这种小项目最值钱的不是有多少行代码,而是能亲手验证每一步棋背后的决策路径到底为什么成立。
