ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

五子棋AI网页版实战:从评估函数到Alpha-Beta剪枝

2026/9/15 12:52:16 拓冰建站 浏览量
五子棋AI网页版实战:从评估函数到Alpha-Beta剪枝 简介这是面向网页前端与AI入门学习者的五子棋AI项目基于HTMLJavaScript实现人机对弈核心涵盖博弈树构建、极大极小值搜索与α-β剪枝算法适合希望将经典AI算法落地到实际游戏场景的开发者。压缩包共7个文件包含1个HTML页面、2个JS逻辑脚本、1个CSS样式文件及3张图片素材整体仅73KB结构精简便于直接打开运行和对照学习。已有2.4万余人学习下载。通过阅读代码可了解AI走子评估、搜索深度控制与剪枝优化思路同时掌握网页游戏UI与交互逻辑的写法是一份麻雀虽小但五脏俱全的算法实战参考。1. 五子棋AI网页版从评估函数到落子的最短路径在浏览器里打开一个网页版五子棋页面就能和AI下一盘有来有回的对局。这个场景背后的技术链条其实很紧凑棋盘状态转成数值、评估函数给每个空位打分、在搜索树里找出最优落点最后用Canvas渲染落子。这四步全部可以落在纯前端JavaScript上不需要后端接口也不需要数据库——一个HTML文件加一个Worker脚本就能撑起整盘对局。不聊神经网络只用经典的评估函数加Alpha-Beta剪枝15×15棋盘单步落子1秒内返回代码量控制在几百行。这套方案适合刚接触博弈树搜索的开发者也适合想用Web Worker优化前端计算性能的工程师。后面给出的代码都是可以直接复制进项目跑起来的最小实现。2. 五子棋AI的核心算法选型为什么是极小极大与Alpha-Beta剪枝2.1 评估函数把棋盘局面变成可比较的分数五子棋AI要回答的第一个问题不是“怎么赢”而是“现在谁更占优”。占优程度需要用数字表达这个数字就是评估分数。把所有棋子按黑、白两组分别计算棋型得分再用己方总分减对方总分得到局面净分净分为正表示当前偏向执黑的AI为负表示偏向人类玩家绝对值越大优势越大。棋型是评估函数的基本单位。五子棋里值得关注棋型不多分值配置直接决定AI的行为如果冲四和活三的差距太小AI宁可去发展自己的活三也不堵对手的冲四这在终盘是致命的。下面这组分值是我惯用的基准棋型连续数开放端数分值连五5-100000活四425000活三32500冲四411000眠三31100活二2230眠二2110冲四1000对活三500活四5000对活三500这样AI才知道“连成活四比冲四更接近胜利”同时“对手活三的威胁相当于我冲四的一半”。实战里这套比例可以再微调但量级不要动。真正要注意的是同一颗棋子会同时参与横、竖、两条斜线共四个方向的棋型四个方向要分别统计漏掉任何一个方向AI对眠三、活三这类棋型就会“瞎”。2.2 极小极大搜索与剪枝AI怎么在博弈树上选落点评估函数解决的是“给定局面怎么打分”接着要解决“走哪一步能让这个分数最大化”。把从当前局面开始的每一步落子画成树状图就得到博弈树轮黑方时黑方取能让己方分数最大的分支轮白方时白方取能让白方分数最大、黑方分数最小的分支。双方轮流取最大/最小这就是极小极大搜索。Alpha-Beta剪枝是给这棵博弈树加的两个边界。alpha是当前分支里黑方能拿到的最高保证分beta是白方允许黑方拿到的最高上限分。在MAX节点黑方走棋里只要某个子节点的分数已经达到beta因为上一层MIN节点会选择更小的分支这个节点剩下的子分支就不必再看了在MIN节点白方走棋里只要某个子节点的分数已经低于alpha黑方在上层不会选这条路剩余分支同样直接跳过。剪枝效果和候选点的搜索顺序强相关把“更有希望的点”排前面alpha和beta的收敛会极快这也是后面调优的核心抓手。2.3 搜索深度与时间消耗网页版AI该搜几层15×15棋盘的所有空位展开深度4在理论最坏情况下有225×224×223×222约24亿个节点浏览器根本扛不住。加上Alpha-Beta剪枝与候选点裁剪实际分支因子能压到10左右深度4的搜索才会落到秒钟级别。网页版AI建议按阶段调深度前10手候选点多、棋型没成型用深度5也不至于太慢中盘候选点在30到60之间深度4更稳妥发现棋盘上已有连续4颗同色棋子或者明显冲四防点可以临时切到深度6去确认必胜路线。深度参数的入口就是minimax的depth后面代码里会看到。当一个深度跑完耗时接近1.5秒就不要再往上加了玩家的耐心会先耗完。有些同学会问为什么不用蒙特卡洛树搜索。MCTS靠大量随机模拟来估算节点胜率在围棋这类超大分支因子的棋类里很强但五子棋15×15的分支因子更适合Alpha-Beta这种确定性搜索。MCTS在网页端每次对局需要维护相当多的统计节点内存占用和实现复杂度都更高单机人机对弈场景不划算。3. 用JavaScript实现五子棋AI的核心决策模块3.1 棋盘数据结构与胜负判定棋盘用15×15的二维数组表示0是空位1是黑方2是白方。AI统一执黑用BLACK1代表自己。这个约定贯穿评估、搜索和渲染三块代码后边换颜色只改渲染层AI逻辑不用动。const BOARD_SIZE 15; const EMPTY 0, BLACK 1, WHITE 2; let board Array.from({ length: BOARD_SIZE }, () Array(BOARD_SIZE).fill(EMPTY)); function hasWon(board, player) { const dirs [[1, 0], [0, 1], [1, 1], [1, -1]]; for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (board[r][c] ! player) continue; for (const [dx, dy] of dirs) { let count 1; for (let i 1; i 5; i) { const nr r dx * i, nc c dy * i; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE || board[nr][nc] ! player) break; count; } if (count 5) return true; } } } return false; }dirs里只定义四个方向水平、垂直、主对角线、副对角线。代码只往正方向数但它从每个棋子出发各数一次一条水平五连从最左边棋子出发能数到5从中间那颗出发只能数到2或3不会超过5。任何一条五连都必定从某个端点出发数到5所以这个写法能正确判胜。代价是有重复扫描但15×15的棋盘单次胜负检查只涉及几百次数组访问可以忽略。落子流程是检查交叉点是否为空写入棋盘调用hasWon判定再决定是否轮到AI。hasWon在每手落子后调用不需要等到棋盘填满。3.2 棋型打分活三、冲四怎么转成分数评估函数我拆成两层evaluateBoard负责全盘扫描某个玩家的棋型得分evaluate对黑白各扫一次再相减。全盘扫描时有一个非常值得注意的细节——一个活三会被连续三颗棋子各统计一次如果不做起点判断活三的分数会被放大3倍活四被放大4倍不同棋型之间的比值失真AI的走法会跟着变歪。function linePattern(board, r, c, dx, dy, player) { let count 1; let openEnds 0; let nr r dx, nc c dy; while (nr 0 nr BOARD_SIZE nc 0 nc BOARD_SIZE) { if (board[nr][nc] player) { count; nr dx; nc dy; } else { if (board[nr][nc] EMPTY) openEnds; break; } } nr r - dx; nc c - dy; while (nr 0 nr BOARD_SIZE nc 0 nc BOARD_SIZE) { if (board[nr][nc] player) { count; nr - dx; nc - dy; } else { if (board[nr][nc] EMPTY) openEnds; break; } } return { count, openEnds }; } function shapeScore(count, openEnds) { if (count 5) return 100000; if (openEnds 2) { if (count 4) return 5000; if (count 3) return 500; if (count 2) return 30; } else if (openEnds 1) { if (count 4) return 1000; if (count 3) return 100; if (count 2) return 10; } return 0; } function evaluateBoard(board, player) { let score 0; const dirs [[1, 0], [0, 1], [1, 1], [1, -1]]; for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (board[r][c] ! player) continue; for (const [dx, dy] of dirs) { const pr r - dx, pc c - dy; if (pr 0 pr BOARD_SIZE pc 0 pc BOARD_SIZE board[pr][pc] player) continue; const { count, openEnds } linePattern(board, r, c, dx, dy, player); score shapeScore(count, openEnds); } } } return score; } function evaluate(board) { return evaluateBoard(board, BLACK) - evaluateBoard(board, WHITE); }linePattern从(r,c)出发分别沿正反两个方向延伸数出这条连续线段的长度同时统计线段两端的状态空位记一个开放端对手棋子或出界不做记录。openEnds能取0、1、2正好对应两端被堵、一端开放、两端开放三种形态。shapeScore接收这两个参数按表格里的分值返回。evaluateBoard里那行board[pr][pc] player的continue是整个评分正确的关键它保证每段连续线段只从最端点的那颗棋子开始统计一次活四不会因为四颗棋子在四个循环里各算一遍而变成四倍分。这一步漏掉AI就可能觉得“制造一个活三”比“堵住对手的活三”更有价值。这套评估只识别连续棋子不处理跳子棋型如X_XXX的跳活三。实际对局中跳活三很常见如果对手频繁使用这类形态评估就开始失真需要更精细的棋型识别时可以在此基础上加模式匹配但基础引擎用连续棋型已经能下出合格的中盘棋。3.3 Alpha-Beta剪枝搜索AI落子决策的主干搜索入口是findBestMove。它枚举候选点在候选点上落子后调用minimax评估对手回合最后把分数最高的点返回给渲染层。const DEPTH 4; function findBestMove(board) { let bestMove null; let bestScore -Infinity; const candidates generateCandidates(board); for (const [r, c] of candidates) { board[r][c] BLACK; const score minimax(board, DEPTH - 1, -Infinity, Infinity, false); board[r][c] EMPTY; if (score bestScore) { bestScore score; bestMove { row: r, col: c }; } } return bestMove; } function minimax(board, depth, alpha, beta, isMaximizing) { const result checkBoardState(board, depth); if (result ! null) return result; const player isMaximizing ? BLACK : WHITE; const candidates generateCandidates(board); if (isMaximizing) { let value -Infinity; for (const [r, c] of candidates) { board[r][c] player; value Math.max(value, minimax(board, depth - 1, alpha, beta, false)); board[r][c] EMPTY; alpha Math.max(alpha, value); if (beta alpha) break; } return value; } else { let value Infinity; for (const [r, c] of candidates) { board[r][c] player; value Math.min(value, minimax(board, depth - 1, alpha, beta, true)); board[r][c] EMPTY; beta Math.min(beta, value); if (beta alpha) break; } return value; } } function checkBoardState(board, depth) { if (hasWon(board, BLACK)) return 100000 depth; if (hasWon(board, WHITE)) return -100000 - depth; if (depth 0) return evaluate(board); return null; }每次在候选点上尝试落子后递归进入对手回合然后在返回前立刻撤销。board[r][c] EMPTY这行几乎是博弈搜索最常见的bug来源漏掉它后续分支会在被污染的局面里继续评估AI会走出完全看不懂的棋。如果在调试时发现AI的某一步棋和局面逻辑明显矛盾先检查这里有没有还原。checkBoardState里用100000 depth而不是固定100000是想让赢棋“越快越好”同是连五深度还剩2时获胜比深度还剩1时更早分数更高AI就有了走出“最短胜利路径”的倾向而不是找一条绕远路去赢。负分同理。现象可能原因检查点AI从不堵对手活三冲四与活三的分数差不明显检查shapeScore里的分值比例搜索长时间卡死剪枝条件方向写反对照MAX/MIN两处的不等号落子位置忽好忽坏棋盘状态没有还原检查递归前后是否都有board[r][c]EMPTY4. 网页版交互实现Canvas棋盘渲染与Web Worker防卡顿4.1 Canvas绘制棋盘与点击落子五子棋网页版只需要一块Canvas不需要引入任何UI框架。我一般用30px一格、20px边距的布局15路棋盘总宽460px正好适配桌面浏览器默认视口。绘制函数拆成三段网格、星位、棋子。const canvas document.getElementById(gobang); const ctx canvas.getContext(2d); const GRID 30, MARGIN 20; function drawBoard() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle #555; for (let i 0; i BOARD_SIZE; i) { ctx.beginPath(); ctx.moveTo(MARGIN, MARGIN i * GRID); ctx.lineTo(MARGIN (BOARD_SIZE - 1) * GRID, MARGIN i * GRID); ctx.stroke(); ctx.beginPath(); ctx.moveTo(MARGIN i * GRID, MARGIN); ctx.lineTo(MARGIN i * GRID, MARGIN (BOARD_SIZE - 1) * GRID); ctx.stroke(); } const stars [[3, 3], [3, 11], [11, 3], [11, 11], [7, 7]]; for (const [sr, sc] of stars) { ctx.beginPath(); ctx.arc(MARGIN sc * GRID, MARGIN sr * GRID, 3, 0, Math.PI * 2); ctx.fillStyle #555; ctx.fill(); } for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (board[r][c] EMPTY) continue; ctx.beginPath(); ctx.arc(MARGIN c * GRID, MARGIN r * GRID, GRID * 0.42, 0, Math.PI * 2); ctx.fillStyle board[r][c] BLACK ? #222 : #f4f4f4; ctx.fill(); ctx.stroke(); } } } canvas.addEventListener(click, (e) { if (isThinking || gameOverFlag) return; const col Math.round((e.offsetX - MARGIN) / GRID); const row Math.round((e.offsetY - MARGIN) / GRID); if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return; if (board[row][col] ! EMPTY) return; board[row][col] BLACK; drawBoard(); if (hasWon(board, BLACK)) { gameOverFlag true; showResult(你赢了); return; } requestAIMove(); });网格和棋子的绘制都直接用坐标换算不涉及任何缓存。星位数组定义了15路棋盘的五个标记点四个角星加天元画上去之后棋盘更接近真实棋具视觉上不容易看错行。坐标换算用Math.round而不是Math.floor点击位置落在两条网格线之间时四舍五入会吸附到最近的交叉点手感好很多。isThinking在AI搜索期间置为true硬卡掉这段窗口里的所有点击否则玩家连续点两个格子会把棋盘状态搞乱。4.2 Web Worker把AI搜索挪出主线程浏览器主线程要同时处理渲染和事件循环深度4的搜索偶尔会达到几百毫秒。直接在主线程里跑findBestMove这段时间里页面会进入“未响应”状态用户拖窗口、点按钮都会卡。Web Worker把这段计算挪到独立线程主线程照常绘制棋盘和响应点击。let aiWorker new Worker(./ai-worker.js); function requestAIMove() { isThinking true; aiWorker.postMessage(JSON.stringify(board)); } aiWorker.onmessage (e) { isThinking false; const { row, col } JSON.parse(e.data); board[row][col] WHITE; drawBoard(); if (hasWon(board, WHITE)) { gameOverFlag true; showResult(AI 获胜); } };postMessage传的是结构化克隆Worker里修改board不会影响主线程。这里用JSON.stringify把二维数组转成纯字符串主要为了避免结构化克隆在旧版浏览器上的跨线程对象传递兼容问题。在ai-worker.js里把findBestMove、minimax、evaluate这些函数完整复制进去onmessage收到棋盘后直接算算完postMessage坐标回来。注意new Worker(./ai-worker.js)在file://协议下会被浏览器拦截。开发时最好在项目目录起一个静态服务器比如执行python3 -m http.server 8000再通过http://localhost:8000访问页面。4.3 落子节奏控制与悔棋的实现AI计算完成立刻落子整个交互会显得很“急”人眼跟不上。我给AI落子加了300ms延迟主线程收到Worker返回后先把鼠标光标切成wait状态再用setTimeout执行实际落子。function handleAIResult(data) { const { row, col } JSON.parse(data); document.body.style.cursor wait; setTimeout(() { board[row][col] WHITE; drawBoard(); document.body.style.cursor default; isThinking false; }, 300); }这段延迟不影响AI决策只是让“AI思考”的节奏更接近人类对手。isThinking必须在延迟结束、棋子落定之后再复位否则玩家会在AI还没落子的300ms里抢走下一手。悔棋只维护一个落子历史栈。每落一子就push{row, col, player}悔棋时栈顶弹出人类最近一手和AI最近一手棋盘对应位置清零。麻烦的是如果AI搜索还没结束旧Worker的结果会在悔棋后返回并覆盖棋盘。处理方式是在悔棋入口直接terminate旧Worker重新new一个function undo() { if (moveHistory.length 2 || isThinking) return; aiWorker.terminate(); aiWorker new Worker(./ai-worker.js); const lastAi moveHistory.pop(); const lastHuman moveHistory.pop(); board[lastAi.row][lastAi.col] EMPTY; board[lastHuman.row][lastHuman.col] EMPTY; isThinking false; drawBoard(); }terminate之后必须重建Worker否则之后的postMessage只会抛错。这里顺带把isThinking复位因为旧的搜索已经被强杀UI上的“思考中”状态也要同步解除。项目里再加入“重开一局”时也遵循同样的顺序先终止Worker、再清棋盘状态。状态变量作用初始值isThinking禁止AI计算期间重复落子falsegameOverFlag标记本局是否结束falsemoveHistory记录双方落子顺序供悔棋[]5. 把AI搜索速度压进1秒启发式候选点、Zobrist缓存与搜索终止5.1 邻域候选点把候选数量压到三十分之一如果不加限制直接搜225个点深度4想都不用想。邻域候选点的做法是只把「距离任意已有棋子不超过2格」的空位放进候选列表。用Set去重后候选点通常稳定在2060之间搜索成本下降一个量级。function generateCandidates(board) { const set new Set(); for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (board[r][c] EMPTY) 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 BOARD_SIZE nc 0 nc BOARD_SIZE board[nr][nc] EMPTY) { set.add(nr * BOARD_SIZE nc); } } } } } return [...set].map(k [Math.floor(k / BOARD_SIZE), k % BOARD_SIZE]); }距离2的阈值覆盖了活三、冲四的常见防守点。Set去重解决相邻棋子周围区域大量重叠的问题。5.2 启发式排序与Zobrist缓存让剪枝更早触发Alpha-Beta剪枝的效率和搜索顺序强相关。在每个候选点临时放一颗黑子用evaluate算一个快速分按分从高到低排序再进入递归。排序的开销是几毫秒换来的剪枝率提升往往是百分之几十。Zobrist缓存是另一个容易见效的优化给棋盘状态算一个64位哈希搜索中遇到重复局面直接返回缓存结果。由于五子棋的搜索树里同一局面可以从不同落子顺序到达缓存命中率在中盘相当可观。落子和撤销都用一次异或来更新哈希成本极低。5.3 参数对照与强制获胜检测组合深度4参考耗时说明全盘搜索无法结束仅作对比邻域候选点约1.2s基础邻域启发式排序约0.4s剪枝率提升邻域排序Zobrist约0.2s重复局面跳过数值随棋面浮动但相对顺序稳定。三个优化全开后再搜深度5通常也能在1秒内出结果。搜索函数最外层可以再记录Date.now()超过900ms就抛一个自定义错误由findBestMove捕获并返回当前已找到的最佳点保证任何机器上都兜底出结果。末盘还有一个值得单独实现的技巧VCF强制获胜检测。在进入深搜前扫描每个空位模拟落子后是否形成冲四如果冲四成立就继续模拟对手被迫堵点直到形成五连。一旦找到这条强制获胜路线立刻返回起点落子不再进入深度搜索这能补上搜索深度不够时漏掉的必赢手段。本文还有配套的精品资源点击获取