尧图精选

纯前端AI象棋程序:从规则建模到Alpha-Beta剪枝

🕒 发布时间:2026/9/9 22:53:12 📁 来源:尧图网络
前几天整理旧项目时翻出一个用纯 HTML CSS JavaScript 写的象棋程序里面带了一个不依赖任何后端的 AI 对手保存下来就能在浏览器里直接玩。想起当时为了把“能走棋”升级成“有棋力”前前后后折腾了一周多踩过的坑还挺典型。这篇文章就把这个“HTML 版 AI 象棋程序”从棋盘渲染、规则判定到 AI 搜索的完整思路拆开讲一遍适合正在学前端、对博弈类 AI 感兴趣或者想找个练手项目把 JavaScript 和算法串起来的朋友。先说明一下这个项目的定位它不是用 Python Pygame 写的桌面棋软也不是调用某个在线接口的 AI 服务而是所有逻辑都跑在浏览器本地的纯前端程序。打开网页就能玩不需要安装 npm 依赖不需要后端没有网络请求连图片素材都不用。整个项目由一个 HTML 文件、一个 CSS 文件、一个 JavaScript 文件组成总体量在两千行左右。这带来的好处很实际静态部署、离线可用、方便分享甚至可以直接用浏览器打开本地文件运行。这个项目的核心价值在于它把前端开发的几个基本功——DOM/Canvas 操作、状态管理、事件处理——和算法领域的经典内容——深度优先搜索、剪枝、评估函数——非常自然地揉在了一起。下面按我实际开发时的推进顺序来写你可以照着复现也可以直接跳到感兴趣的章节。1. 技术选型纯 HTML 项目为什么不用框架1.1 从“能跑”到“有棋力”选型决定了上限最早我考虑过用 React 或者 Vue 来做界面毕竟组件化写起来顺手。但想到这个项目的核心是 AI 搜索逻辑不是视图层的数据流引入框架反而增加心智负担。传统 DOM 更新足够应付棋盘这种低频交互——人类下棋一秒最多一两步AI 计算期间棋盘也不会实时刷新完全不需要虚拟 DOM 那种高效渲染机制。这个判断后来被验证是对的即便 AI 在计算第 5 层深度时界面也只需要显示一个“思考中”的状态没有任何频繁渲染压力。1.2 Canvas 和 DOM我为什么选了 Canvas棋盘渲染我最终用了canvas而不是 CSS Grid 画格子。原因有三个棋子需要在棋盘上自由移动Canvas 的drawImage/arc系列 API 做绘制和清屏非常直接。坐标计算统一基于像素鼠标点击位置到棋盘行列的换算只有一道数学公式。后续想做动画、残影、AI 思考轨迹展示Canvas 扩展空间更大。不过 DOM 方案也不是不行。如果只想做一个极简版用 9 列 × 10 行的 CSS Grid 也能实现每个格子放一个div。但棋子的重叠、拖拽、选中态高亮处理起来会比较绕。我建议目标是把 AI 算法讲清楚就选 Canvas想练前端布局再考虑 DOM。1.3 文件结构与零依赖的部署方式项目最终结构是这样的xiangqi/ ├── index.html ├── style.css └── js/ ├── board.js ├── moves.js ├── ai.js └── main.jsindex.html引入style.css再按顺序引入board.js棋盘状态与渲染、moves.js走法生成与合法性校验、ai.js搜索评估、main.js交互与事件绑定。模块依赖顺序不能乱因为moves.js里的函数会被ai.js调用。如果追求极致整洁也可以用 ES Module 的import但直接多文件引入在双击打开本地文件时也完全正常零构建步骤。这里有个实用经验如果你打算把项目挂到 GitHub Pages 或者任意静态服务器上路径用相对路径就好千万别用/js/board.js这种绝对路径。本地调试没问题部署到子目录下就会 404。提示整个项目只有一个页面不需要路由、不需要状态管理库、不需要构建工具。这类小项目恰恰是练习“用最合适的技术解决真问题”的好机会不要一上来就堆工程化工具链。2. 棋盘建模数据结构决定规则的复杂度2.1 二维数组和一维数组我为什么最终用了一维中国象棋棋盘是 9 列 × 10 行最直观的表示方式当然是二维数组board[row][col]。但我实际写下来发现一维数组配合索引计算走法生成和悔棋操作反而更简洁。做法是const BOARD_W 9; const BOARD_H 10; const board new Array(90).fill(0);棋盘索引0~89index row * 9 col。任何一个棋子它的上下左右移动都对应index ± 9和index ± 1。生成所有走法时只需要遍历数组跳过 0再对非空棋子调走法生成函数。相比二维数组省去了xy来回转换的麻烦而且数组是连续内存性能也略好。但二维数组的可读性更高尤其对新手。如果你自己写按照熟悉的来。我后面为了调试方便在代码里加了一个posToXY(index)工具函数把一维索引转成行列坐标这样 console 打印局面时不会疯掉。2.2 棋子编码一个数字表示一个棋子棋盘数组里存什么我用了整数编码正数表示红方负数表示黑方0 表示空位。const PIECE { EMPTY: 0, KING: 6, // 帅/将 ADVISOR: 5, // 仕/士 BISHOP: 4, // 相/象 KNIGHT: 3, // 马 ROOK: 7, // 车 CANNON: 2, // 炮 PAWN: 1 // 兵/卒 };于是局面初始化为const START_FEN rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR;我甚至直接支持了 FEN 字符串解析。FEN 是国际象棋通用的局面描述格式中国象棋也有扩展版本。用 FEN 的好处是调试杀法时可以直接粘贴一个残局局面不用手动摆棋。这一开始觉得是画蛇添足实际用几次就离不开了。解析函数很简单按/分割行数字表示空格数字母表示棋子。博弈类项目里局面序列化能力一定值得提前做后面写测试、复现 bug 会靠它救命。2.3 渲染坐标换算逻辑坐标到像素坐标逻辑棋盘是 9×10画布大小可以设为520×570左右边距 20px格子边长 60px。那么function logicToPixel(row, col) { return { x: MARGIN col * CELL_SIZE, y: MARGIN row * CELL_SIZE }; }鼠标点击事件拿到的clientX/clientY减去画布偏移量再除以格子边长就能得到点击的行列。注意canvas.getBoundingClientRect()拿到的宽高是 CSS 像素如果设备像素比大于 1通常需要乘一个缩放系数否则移动端点击会偏。canvas.addEventListener(click, (e) { const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; const canvasX (e.clientX - rect.left) * scaleX; const canvasY (e.clientY - rect.top) * scaleY; const col Math.floor((canvasX - MARGIN) / CELL_SIZE); const row Math.floor((canvasY - MARGIN) / CELL_SIZE); // ... });这个细节我一开始没注意在带高分屏的笔记本电脑上点击棋子总是偏左上。后来打印出坐标才发现是设备像素比的问题。2.4 棋子的绘制不用图片也能好看棋子我直接用 Canvas 画圆外层黑色圆描边内部填充一个浅黄色圆中间写汉字。车马炮兵这些字用fillText居中绘制字体大小根据格子边长动态计算。function drawPiece(ctx, piece, x, y) { const r CELL_SIZE * 0.42; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle #f7d29c; ctx.fill(); ctx.strokeStyle #333; ctx.lineWidth 2; ctx.stroke(); ctx.fillStyle piece 0 ? #b22222 : #222; ctx.font bold ${CELL_SIZE * 0.52}px KaiTi, STKaiti, serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(pieceName(piece), x, y 2); }颜色上红方用深红黑方用深黑棋盘底色用浅木色线条用深棕。不用图片素材一是为了零依赖二是文字棋子反而有传统棋盘的感觉。如果你想让视觉更好可以下载一套 chess 棋子的 PNG 素材但本质不影响逻辑。3. 走法生成象棋规则里最容易被忽略的细节走法生成是整个 AI 的地基。这一步错了评估函数写得再漂亮搜索出棋也是乱的。多数人第一个版本会遗漏的往往是这些规则马腿、象眼、将帅照面、士相不出宫、兵卒过河前后方向变化。3.1 各兵种走法的方向与步长我用方向偏移表来生成走法。比如车可以向四个方向一直走直到出界或遇到棋子function generateRookMoves(board, idx, isRed, legalMoves) { const dirs [-9, 9, -1, 1]; for (const dir of dirs) { let cur idx dir; while (inBoard(cur)) { const target board[cur]; if (target 0) { legalMoves.push({ from: idx, to: cur }); } else { if (isEnemy(target, isRed)) { legalMoves.push({ from: idx, to: cur }); } break; // 遇到棋子就停了 } cur dir; } } }注意边界一维数组里col0的位置向左移动会跑到上一行末位所以方向为-1、1的移动前必须检查当前列。这里需要写一个colOf(idx)函数防止换行穿越。马的方向偏移表const KNIGHT_MOVES [ { dr: -2, dc: -1 }, { dr: -2, dc: 1 }, { dr: -1, dc: -2 }, { dr: -1, dc: 2 }, { dr: 1, dc: -2 }, { dr: 1, dc: 2 }, { dr: 2, dc: -1 }, { dr: 2, dc: 1 } ];马腿的检测dr的绝对值是 2腿的位置在(row dr/2, col)dc的绝对值是 2腿在(row, col dc/2)。如果不判断AI 会走出“飞马”——跨过棋子这属于比较低级的 bug。同理象走田字腿在对角线中心const BISHOP_MOVES [ { dr: -2, dc: -2 }, { dr: -2, dc: 2 }, { dr: 2, dc: -2 }, { dr: 2, dc: 2 } ]; // 腿坐标: (row dr/2, col dc/2)3.2 将帅照面最容易漏的隐性规则中国象棋里有条特殊规则双方将帅不能在同一条直线上“照面”中间必须有棋子隔着。这条规则在走法生成阶段单独写了一个函数来处理function isFaceToFace(board, redKingIdx, blackKingIdx) { if (colOf(redKingIdx) ! colOf(blackKingIdx)) return false; const col colOf(redKingIdx); let rowStart Math.floor(redKingIdx / 9) 1; let rowEnd Math.floor(blackKingIdx / 9) - 1; for (let r rowStart; r rowEnd; r) { if (board[r * 9 col] ! 0) return false; // 有旗子隔着 } return true; }在执行每一步走法后如果发现自己方的将和对方将之间没有任何棋子这步走法必须废弃。也就是说走完一步棋后不能让自己“送将”。这类“走完后的状态检查”属于合法性验证的第二层。3.3 三步合法性检查模拟、将军、解除将军走法生成拆成三步做思路会清晰很多基础规则层根据兵种走法生成候选点。比如车走直线、马走日字、兵卒只能向前过河后才能左右。局面约束层检查是否走出九宫、是否塞象眼、是否蹩马腿、是否会造成将帅照面。自将军检测层模拟走完这步棋检查己方将帅是否处于被攻击状态如果是这步棋非法。第 3 层最简单粗暴的实现是走完一步后调用一个isSquareAttacked(board, kingIdx, attackerSide)函数遍历所有敌方棋子看能否吃到将。这个函数在将军判定、胜负判定里都会用到值得写细致。3.4 吃子与将军状态判定将军状态会在 AI 搜索中频繁用到如果某方走了一步后对方将帅处于被攻击状态就是“将军”。在搜索时被将军的一方所有走法中必须包含“应将”走将、垫子、吃掉攻击子否则会被判负。AI 搜索里判断胜负最简单的方式走法列表为空的一方判负不需要单独写死循环规则。这个判断方式能同时覆盖被将死、无子可动、困毙三种情况。残局里有时会出现“困毙”——一方没被将军但无合法走法按中国象棋现行规则也算负这样写正好一致。4. AI 棋力突破从随机走子到 Alpha-Beta 剪枝刚写完走法生成和界面时AI 用的是随机挑合法走法那棋力基本上等于“乱走”。要让程序具备基础棋力核心是两件事评估函数和搜索算法。4.1 评估函数棋子的价值不只是“分高”评估函数返回一个分数正数表示红方优势负数表示黑方优势。最简单的是棋子子力值兵种价值帅/将100000车900马400炮450相/象200仕/士200兵/卒100过河后 150光有固定值不够。同一枚马在中心和在角落的威力天差地别。所以需要加一张位置价值表。比如马的位置表部分示例const KNIGHT_POS [ [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 2, 4, 8, 8, 8, 4, 2, 0], [0, 4, 8, 12, 14, 12, 8, 4, 0], [0, 6, 10, 16, 20, 16, 10, 6, 0], [0, 6, 10, 16, 20, 16, 10, 6, 0], [0, 4, 8, 12, 14, 12, 8, 4, 0], [0, 2, 4, 8, 8, 8, 4, 2, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0] ];黑方的表通常要上下翻转处理简单点也能左右对称只做一套。位置价值表是开源象棋项目里最不缺的资源你完全可以参考象棋小巫师、ElephantEye 等开源引擎的评估表来调。评估函数最终形态function evaluate(board) { let score 0; for (let i 0; i 90; i) { const piece board[i]; if (piece 0) continue; const row Math.floor(i / 9); const col i % 9; const value PIECE_VALUE[Math.abs(piece)]; if (piece 0) { score value POS_TABLE[Math.abs(piece)][row][col]; } else { score - (value POS_TABLE[Math.abs(piece)][9 - row][col]); } } return score; }这个函数的棋感比想象中影响大。一开始我把士相价值定得太高AI 开局宁愿动老将也不换士走出极其僵硬的开局。后来把士相从 200 左右调到 150~180局面立刻合理很多。4.2 极大极小搜索让 AI “想 N 步”AI 的核心逻辑是模拟对弈假设自己走完一步后对手会在所有选项中走出对他最有利的一步自己再在对手走完后的局面里选择对己方最有利的一步。这个递归交替过程就是极大极小搜索。伪代码function minMax(depth, isRedTurn) { if (depth 0) return evaluate(board); const moves generateAllMoves(board, isRedTurn); if (moves.length 0) return isRedTurn ? -INF : INF; let best isRedTurn ? -Infinity : Infinity; for (const mv of moves) { doMove(mv); const score minMax(depth - 1, !isRedTurn); undoMove(mv); if (isRedTurn) { best Math.max(best, score); } else { best Math.min(best, score); } } return best; }红方取最大值黑方取最小值。搜索深度为 N 时AI 会考虑 N 步以内所有可能的着法组合。中国象棋平均分支因子大约 40深度 4 就需要评估 40^4 ≈ 256 万个叶子节点纯递归在浏览器里会卡顿。4.3 Alpha-Beta 剪枝把搜索量砍掉一大半Alpha-Beta 剪枝是在极大极小搜索基础上做的优化。核心思想是如果当前分支已经不可能比之前搜索过的分支更好就提前放弃搜索。function alphaBeta(depth, alpha, beta, isRedTurn) { if (depth 0) return evaluate(board); const moves generateAllMoves(board, isRedTurn); if (moves.length 0) return isRedTurn ? -MATE : MATE; if (isRedTurn) { let best -Infinity; for (const mv of moves) { doMove(mv); best Math.max(best, alphaBeta(depth - 1, alpha, beta, false)); undoMove(mv); alpha Math.max(alpha, best); if (beta alpha) { break; // 剪枝 } } return best; } else { let best Infinity; for (const mv of moves) { doMove(mv); best Math.min(best, alphaBeta(depth - 1, alpha, beta, true)); undoMove(mv); beta Math.min(beta, best); if (beta alpha) { break; } } return best; } }alpha是红方能保证的下界beta是黑方能保证的上界。节点搜索中一旦发现beta alpha说明这个分支不可能影响最终决策直接截断。理想情况下剪枝能把搜索量从指数级降到约 O(b^(d/2))也就是说同样的时间内Alpha-Beta 搜索的深度比朴素极大极小搜索多一倍。在实际测试中我在浏览器里不剪枝的深度 4 需要 10 秒以上加上剪枝后同深度降到 1 秒内。4.4 搜索深度与棋力的实际关系深度和棋力不是线性关系。我的实测经验深度 1只考虑当前一步得失会白送车深度 2能防止简单送子但看不到连续捉双深度 3开始有战术意识能吃回无根子深度 4中局阶段有不错的兑子计算纯前端可接受深度 5棋力明显更强但浏览器里有时需要 3~5 秒我做了一个难度选择简单 深度 2中等 深度 3困难 深度 4。因为深度 5 在移动端容易卡顿除非后续用 Web Worker 优化否则不建议默认开启。5. 让 AI 更像人对局搜索优化的四个细节Alpha-Beta 剪枝只是骨架同一深度下棋力差距来自细节。5.1 走法排序决定剪枝效率剪枝效果极大程度取决于走法的搜索顺序。如果最优秀的那步棋被先搜到alpha 值很快就能提高后续大量分支被剪掉。反过来如果每次都先搜烂棋beta 下限半天降不下来剪枝效率非常低。我用的是最经典的 MVV-LVA 排序先走吃子着法按被吃棋子价值从高到低排列价值相同时优先用价值低的棋子去吃。比如用兵吃车排在用车吃车前面因为要保子力。这个顺序在大部分局面里都能让搜索效率提升显著。非吃子着法我按历史启发分数排序。每次发生剪枝时当前走法加一分下次搜索相同局面时分数高的走法排前面。5.2 将军检测与循环延伸还有一个细节是“搜索到底层时可能正在被将军”这种情况下直接返回评估值会失真。一个小技巧是在depth 0返回前先检测当前走棋方的将帅是否被攻击。如果被将军说明局面可能仍在战术变动中强制继续搜索一层“应将”。这叫将军延伸能让 AI 在深度限制下也能走出连续将军的杀棋。但这个逻辑要控制好不然遇到循环将军长将会陷入极其深的递归。中国象棋的循环局面判罚比较复杂为了先跑通我直接限定延伸不超过额外 4 层再超出就按评估值返回。5.3 历史启发让 AI 记住好用的走法历史启发表是一个二维数组history[from][to]存储这个走法的“功绩”。在 Alpha-Beta 搜索中只要某个节点发生剪枝就说明当前走法逼退了对手值得在后续搜索中优先尝试。const historyTable Array.from({ length: 90 }, () new Array(90).fill(0)); // 剪枝时 historyTable[mv.from][mv.to] depth * depth;排序时非吃子走法按历史表降序。这个优化对暴露出来的 bug 排查也很有帮助因为历史表能让 AI 在某些固定局面下稳定走出同一手棋方便复现问题。5.4 开局随机化不要让 AI 背死谱深度搜索的 AI 在开局阶段会走出固定套路比如红方第一步永远只动车或炮。玩家会觉得“怎么每次都一样”很出戏。我加了一个小技巧搜索深度为 1 或者开局前几步时在所有得分接近最好的走法里随机选一个。只要分差不超过 30 分就认为它们都是可接受的开局然后随机挑。这样同样一个 AI每次开局走法会有变化测试时也不会因为复现同一个开局而失去新鲜感。6. 前端交互与部署从“能跑”到“好用”的最后一公里6.1 交互设计点击、高亮、提示合法走法交互上用“先选棋子再选落点”的模式点击红方棋子人机对战时玩家执红该棋子高亮。生成该棋子的所有合法走法在可落子的位置画一个小圆点。点击圆点区域执行走法点击另一个己方棋子切换选择点击空白处取消选择。桌面端用click事件移动端用touchstart配合preventDefault防止双击缩放。记住在touchstart里不要用clientX直接换算要用e.touches[0].clientX这个不踩一次很难记住。人机对战时AI 思考期间要把棋盘设为“锁定”状态阻止玩家继续点击棋子。我用一个全局变量isAIThinking每次 AI 开始计算前置为true计算结束后置为false交互事件里第一步检查这个变量。6.2 悔棋、认输、重新开始与局面显示悔棋的坑在于人机对战时玩家悔棋通常要悔两步——玩家走的那步和 AI 回的那步。我在history栈里存了每次走法的{ from, to, capturedPiece, snapshot }悔棋时按需弹出两步。重新开始前要清空 AI 线程。如果你的 AI 没有用 Worker而是一段同步递归那么点击“重新开始”按钮不会立刻生效得等当前搜索结束。这个体验很差。所以我给搜索加了一个cancelFlag每次遍历走法前检查如果cancelFlag为真直接抛出异常或者返回一个特殊值再由外层 catch 住。6.3 移动端适配与本地持久化让页面能双指缩放破坏体验在head里加meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno棋盘的 Canvas 尺寸我改成根据屏幕宽度动态计算const minDim Math.min(window.innerWidth - 20, 540); const canvasWidth minDim; const CELL_SIZE (canvasWidth - 2 * MARGIN) / 8; // 9列 8格距注意棋盘是 9 列视觉上有 8 个格子间隔计算格子大小时不能写成除以 9。这个数学细节我调了半天才发现。持久化我用localStorage保存当前局面数组和走法历史玩家刷新页面后可以继续未结束的棋局。每次走棋后序列化一次数据量很小只有 90 个数字。6.4 我在调试 AI 时踩过的三个坑第一个坑走法生成里的数组引用串扰。我最初用一个全局moves数组存走法AI 递归时不断往同一个数组里 push导致不同层级的走法混在一起悔棋后局面错乱。解决方式走法统一用对象{from, to}每次递归时生成新数组不要在搜索过程中复用外部状态。第二个坑评估函数里将帅价值不够高。把将帅定为 1000 分AI 在残局为了吃一个士宁可将帅和对方照面。后来把将帅设为 100000同时加上照面检测才算稳妥。第三个坑同步递归导致浏览器假死。深度 5 搜索在旧笔记本上可能要几十秒期间页面完全没响应就像死机。解决办法把 AI 搜索包在setTimeout里异步执行搜索期间显示“思考中”动画。再进阶一点可以直接用 Web Worker让递归循环在独立线程里跑体验会好很多。关于部署这个项目就是两个文件加一个文件夹扔到任意静态托管平台即可。使用npx serve .或者直接双击index.html都能跑。如果想要离线使用甚至可以配一个极简的 Service Worker 缓存页面资源这样断网状态打开浏览器都还能下棋。最后再分享一个小技巧这个项目非常适合继续扩展。比如集成 Web Worker 把搜索深度推到 6 层或者给 AI 加上开局库让前几手走得更自然又或者做一个“教学模式”把当前评估分和最佳走法显示出来帮助棋友复盘。每次改动都不算大但都能让你把 JavaScript 的异步、性能优化和算法思维练得明明白白。我后来再看这个项目时最怀念的不是最终成品而是把 AI 从“被车吃”调到“会打死车”的那几天。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →