资讯动态

Unity五子棋人机对战:用打分法实现AI决策与完整代码解析

发布时间:2026/10/17 10:53:45 来源:尧图企业网站定制
说实话Unity里写一个五子棋Demo难倒绝大多数人的不是棋盘绘制不是点击交互而是AI那一步该怎么想。很多人一上来就奔着Alpha-Beta剪枝去结果写了一整天AI还是又蠢又慢最后连个能看的局面评估都没调出来。我这边的做法是先不去碰那些听起来很高级的搜索树而是用一个足够直观、同样也足够耐打的“进攻/防守打分法”把五子棋人机对战在Unity里跑通并且把整套程序代码整理成了可直接参考的版本。这篇就来拆一拆这个unity人机五子棋项目从模块划分到核心算法再到那些真正会卡住你的坑顺带把可用的程序代码逻辑一并讲清楚。1. 五子棋项目的第一刀把需求拆成棋盘、规则、AI三个独立模块五子棋看起来就是个“点击落子”的小游戏但如果你直接把规则、UI、AI混在同一个脚本里写后面调试绝对会痛不欲生。我在这边整理这个项目时的第一刀就是把三个模块的边界先画清楚。1.1 棋盘数据结构的选择别用物体去找位置用数组做唯一权威Unity里通常有两种方式做五子棋棋盘一种是直接在场景里摆225个棋子物体用Physics或UI射线去判断点击了哪个另一种是先维护一份int[15, 15]的二维数组UI只负责“呈现”和“回传坐标”。我强烈建议选第二种而且这个是后续所有AI逻辑能顺利落地的前提。原因很简单不管是胜负判定、AI打分还是悔棋回滚它们本质上都是在对“棋盘状态”做计算。如果状态分散在一堆GameObject上每次计算都要去遍历场景效率低不说还极易出现“视觉上有个棋子逻辑里没有记录”这类幽灵Bug。通常我建一个GameBoard类来统一管理状态里面就四样东西public class GameBoard { public const int BoardSize 15; public int[,] grid new int[BoardSize, BoardSize]; public int currentPlayer 1; // 1黑2白 public int moveCount 0; public bool IsEmpty(int x, int y) { if (x 0 || x BoardSize || y 0 || y BoardSize) return false; return grid[x, y] 0; } public bool Place(int x, int y) { if (!IsEmpty(x, y)) return false; grid[x, y] currentPlayer; moveCount; return true; } }注意这里棋盘下标我统一用x, y而不是row, col因为我希望UI层坐标和AI逻辑坐标完全一致避免转换混乱。示例中currentPlayer第一次默认为黑棋也就是玩家先手AI持白后手这是最常见的人机五子棋设定。1.2 UI层的正确姿势GridLayoutGroup批量生成格子不要手动拖225个Image进场景那是纯体力活。用Unity UGUI的GridLayoutGroup配合一个循环生成子物体不到三十行代码就能完成棋盘渲染。我在这边的实现习惯是每个格子一个Button组件点击时通过Button.onClick绑同一个监听方法再把格子的行列坐标存到按钮自身的transform上或者用一个Cell脚本记录。关键点在于棋局逻辑完全不在Button里而是统一转发到GameManager处理。private void BuildBoard() { for (int x 0; x GameBoard.BoardSize; x) { for (int y 0; y GameBoard.BoardSize; y) { GameObject cell Instantiate(cellPrefab, gridContainer); int row x, col y; // 闭包陷阱必须用局部变量 cell.GetComponentButton().onClick.AddListener(() OnCellClick(row, col)); } } }这里有个大多数新手都会踩的坑在循环里给事件添加监听时lambda表达式捕获的循环变量会变成同一个值。所以在Unity 5.0以后都强制要求先复制一份局部变量。上面代码里的row、col就是干这个事的我这边写过太多次旧式写法导致所有格子点击后都落到最后一个格子所以这个细节一定要记住。2. AI方案选型为什么先做打分法而不是一上来就上搜索树关于五子棋AI网上你一搜清一色就是Min-Max加Alpha-Beta剪枝有的还挂上蒙特卡洛树。这些方法不是不好而是对一个从零起步的项目来说它们把“AI智能程度”和“算法复杂度”捆绑得太紧调试成本太高。我在整理这个unity人机五子棋项目时先把核心AI实现为“打分法”然后用一个开关可以切换体验。2.1 搜索树的代价你先得有一个能用的评估函数很多人没想明白一件事就算你写了Alpha-Beta剪枝剪枝本身不会下棋真正决定棋力的是叶子节点上那个“评估函数”。如果你的评估函数很粗糙搜索再深AI也只是在一个错误的方向上看得更远而已。所以AI设计的第一步永远是先把“评估函数”做对也就是“这个位置如果落子有多好”这个分数。打分法本质上就是一种深度为1的搜索只评估当前这一步的收益不做递归推演。由于五子棋是零和游戏评估一个点只需要做两件事评估“如果我AI下在这里”的进攻得分。评估“如果对手下在这里”的防守得分。最终这个点的价值是两者加权和。这种方案在实现上很轻量只需要对空位做一次扫描不需要递归运行速度极快也足够让AI表现出“会堵、会冲、会组织双路”的基础棋感。2.2 打分表设计所有棋型都必须显式定义打分法好不好用核心在于棋型分值的定义。我在这个项目里直接用一张常量表把所有常见棋型按威胁程度排好。棋型分值己方成五1000000对方成五999999活四500000冲四30000活三20000眠三3000活二2000眠二300单子20注意表格里同时包含己方和对方成五是为了让AI在检测到“对方就差一步赢”时能优先去堵截而不是继续做自己的棋型。五子棋里“防守优先”的思想不是靠复杂逻辑而是靠这个分值排序实现的。棋型识别算法我后面在代码部分会展开讲这时候只需要记住一个原则同一局面用连续棋子数和两端开口情况去判定棋型而不是只看数量。比如同样是三个子活三和眠三的威胁天壤之别这个区分如果在纸面上没做足AI就会出现“看着像在下棋实际在乱走”的观感。2.3 进攻与防守的加权策略打分法有个自由度很高的地方就是在totalScore attackScore defendScore * ratio里ratio的取值直接影响AI性格。我这边测试下来defenseRatio取2.0是刚起步时的相对稳妥值。也就是说AI优先封堵对手的活三/冲四其次才考虑自己搭棋型。取1.5会偏激进AI自己的攻击欲望更高但对对手的连续手段有时反应慢半拍取3.0则过于保守AI会一直在应对缺少自己主动取胜的能力。实践中常见的问题反而是AI“宁愿挡不住也要进攻”。因为你只做了“这步棋对我的进攻分 对对手的防守分”的评估却没考虑“我不挡的话对手下一步会不会连成五”。于是我在打分法里额外做了个特判如果某空位存在对手成五点直接把这个点的比分拉满保证AI绝对不会漏掉这个必救的位置。这个特判效率极高相当于给AI装了一个“最短路”保险。3. 核心程序代码解析从落子到胜负判断再到AI决策接下来是这篇unity人机五子棋文章最有价值的部分程序代码的逻辑骨架。我会按数据流顺序把“点击落子 → 胜负判断 → AI决策 → AI落子”一条线完整拆开讲。3.1 落子与刷新状态变化只有一条路我特意设计了一个GameManager所有操作在它下面统一处理。玩家点击某个格子后先对棋盘状态做校验再更新UI如果未分胜负且轮到AI就启动协程等一小段时间再让AI走棋。public void OnCellClick(int x, int y) { if (isGameOver || isAiThinking) return; if (board.currentPlayer ! 1) return; // 仅玩家回合 if (!board.Place(x, y)) return; Cell cell cells[x, y]; cell.SetVisual(1); if (CheckWin(x, y, 1)) { EndGame(1); return; } board.currentPlayer 2; isAiThinking true; StartCoroutine(AIPlayRoutine()); }这里的关键是CheckWin调用时机。初学者最容易犯的错误是在落子完成后立刻判断AI或玩家胜利结果总会出现“最后一步已经五连了游戏却不结束”的诡异现象。正确的顺序一定是落子 → 判断当前玩家是否获胜 → 若无胜者则切换当前玩家。UI和逻辑分离的好处这时候也体现出来了棋盘数组里改变了状态界面只做同步任何逻辑代码都不需要依赖某个具体图片是否被设置永远以数组为准。3.2 胜负判断用方向数组替代八方向枚举四子棋、五子棋这类判断连续棋型最容易判断错的就是方向遗漏。这里我用两个定长数组一次性覆盖四个直线方向private readonly int[] dx { 1, 0, 1, -1 }; private readonly int[] dy { 0, 1, 1, 1 }; public bool CheckWin(int x, int y, int player) { if (board.grid[x, y] ! player) return false; for (int d 0; d 4; d) { int count 1; for (int step 1; step 5; step) { int nx x dx[d] * step; int ny y dy[d] * step; if (!InBoard(nx, ny) || board.grid[nx, ny] ! player) break; count; } for (int step 1; step 5; step) { int nx x - dx[d] * step; int ny y - dy[d] * step; if (!InBoard(nx, ny) || board.grid[nx, ny] ! player) break; count; } if (count 5) return true; } return false; }dx, dy这组方向数组的含义从左到右依次是水平方向、垂直方向、主对角线方向、副对角线方向。从落子点向正负两个方向延伸统计同类棋子数量只要总数达到5就算赢。不需要去判断“长连”之类的情况因为普通五子棋规则下超过5个同样算赢。3.3 AI打分函数扫描每个空位的进攻与防守价值AI决策的核心在EvaluatePoint这个函数返回某个位置对指定玩家的价值。它考虑的是如果该玩家把子落在这里四个方向上能形成什么样的棋型。public int EvaluatePoint(int x, int y, int player) { int totalScore 0; for (int d 0; d 4; d) { int count 1; int openEnds 0; // 正向延伸 int nx x dx[d]; int ny y dy[d]; while (InBoard(nx, ny) board.grid[nx, ny] player) { count; nx dx[d]; ny dy[d]; } if (InBoard(nx, ny) board.grid[nx, ny] 0) openEnds; // 反向延伸 nx x - dx[d]; ny y - dy[d]; while (InBoard(nx, ny) board.grid[nx, ny] player) { count; nx - dx[d]; ny - dy[d]; } if (InBoard(nx, ny) board.grid[nx, ny] 0) openEnds; totalScore GetPatternScore(count, openEnds); } return totalScore; }GetPatternScore就是把前面分值表实例化的地方private int GetPatternScore(int count, int openEnds) { if (count 5) return 1000000; if (count 4) { if (openEnds 2) return 500000; // 活四 if (openEnds 1) return 30000; // 冲四 return 0; } if (count 3) { if (openEnds 2) return 20000; // 活三 if (openEnds 1) return 3000; // 眠三 return 0; } if (count 2) { if (openEnds 2) return 2000; // 活二 if (openEnds 1) return 300; // 眠二 return 0; } if (count 1 openEnds 2) return 20; return 0; }这个识别方式朴实但有效。需要强调一点对于限制长度超过5的情况比如你连续数出了6个此时两端几乎必然被封死GetPatternScore会返回0这在多数五子棋规则里是合理的因为长连虽然会造成胜利或特殊规则判定但在常规禁手规则里并不应该被推荐。3.4 AI选点逻辑算攻、算防、再决策得到EvaluatePoint能力后找最佳落点就非常自然了private Vector2Int FindBestMove() { int aiPlayer 2; int humanPlayer 1; int bestScore -999999; Vector2Int bestMove new Vector2Int(-1, -1); // 开局第一步直接下天元附近省去大量无意义扫描 if (board.moveCount 1) { return new Vector2Int(7, 7); } for (int x 0; x GameBoard.BoardSize; x) { for (int y 0; y GameBoard.BoardSize; y) { if (!board.IsEmpty(x, y)) continue; int attackScore EvaluatePoint(x, y, aiPlayer); int defendScore EvaluatePoint(x, y, humanPlayer); // 若对手当前局面存在成五可能立即封堵 if (defendScore 1000000) { return new Vector2Int(x, y); } int totalScore attackScore defendScore * 2; if (totalScore bestScore) { bestScore totalScore; bestMove new Vector2Int(x, y); } } } return bestMove; }一个非常实用的细节是开局时黑棋第一手下在什么位置直接决定AI要不要做全盘扫描。我这边统一处理为如果AI先手且棋盘为空直接落天元如果玩家先手第一手则AI优先从天元附近的八个候选点里选。这能避免AI一开局就把棋子下到角落里完全是“经验值”的体现。3.5 用协程模拟AI思考而不是直接卡死主线程AI计算量并不大15×15的棋盘全部扫描一遍225个空位每个空位评估8个方向耗时通常在一毫秒以内。但如果你在玩家落子后立刻同步调用AI选点UI会有一瞬间的卡顿表现为“点击没反应然后棋子突然跳出来”。为了手感我把AI执行装进协程人为加一个0.25秒的延迟private IEnumerator AIPlayRoutine() { yield return new WaitForSeconds(0.25f); Vector2Int move FindBestMove(); if (move.x 0 move.y 0) { board.Place(move.x, move.y); cells[move.x, move.y].SetVisual(2); if (CheckWin(move.x, move.y, 2)) { EndGame(2); yield break; } board.currentPlayer 1; isAiThinking false; } }这段代码同时解决了一个隐藏问题如果AI因为某种原因没有找到任何可落子的点游戏不会直接卡死在“轮到AI”的状态。把棋局状态判断放在每次轮换的出口处永远保证currentPlayer与UI提示一致。4. 实测踩坑记录这套代码真正会让你头痛的三个意外情况完整的代码框架在上面已经能给到一个很清晰的参考了。但说实话代码写出来是一回事能在项目里跑顺是另一回事。下面这几个问题是我在整理unity人机五子棋程序代码时真实卡过较久的地方。4.1 局面结算时机为什么AI会多走一步棋刚开始把AI写进去后我测试时发现一个很迷惑的现象玩家已经五连了AI还在思考并落下最后一子。查了半天才发现问题出在玩家落子后调用CheckWin用的是临时落子状态但由于board.currentPlayer在前一步已经切换成了AIUI回传的时候玩家点击事件被拦截但AI侧的控制逻辑却被启动导致AI在游戏结束后又跑了一次。解决起来不算难但要注意顺序。我的建议是在EndGame里同时设置isGameOver true并且在GetComponentButton()那里判断一次isGameOver。所有交互入口、AI协程入口都要在开头做一句“游戏是否已结束”的检查。这类问题不会在你第一次测试时出现通常都是对局到中后期、玩家被人机AI压制时因为手速太快点了好几个格子才暴露。4.2 长连与禁手规则不要默认所有人都下无禁手规则五子棋有“无禁手”和“有禁手”两种规则禁手规则主要限制黑棋的“双三、双四、长连”等获胜方式。很多并不可靠的AI示例里只做“连成五就赢”的判断导致黑棋长连也判胜利。在我们这个人机五子棋示例里默认是无禁手规则所以不需要考虑太多。但如果有人想扩展成有禁手那就要多写一个禁手检测函数并且必须在胜负判定之前调用。我在实测过程中遇到过将“禁手”误实现成“所有超过5个的连子都判负”结果AI自己长连被判负观感非常奇怪。所以要么简化处理要么严格定义规则不要在两种之间摇摆。4.3 打分法在“双三”面前会犯的糊涂打分法有个天生的盲区就是它对“两步或者三步之后才会形成强威胁”的复合棋型感知很弱。比如AI某个点如果同时形成两个活二那它对局面的威胁其实远大于一个活三。但打分法只看单点形成的四方向棋型不会去叠加评估组合效果。我在实际对局中发现AI有时会错过“双活二”这种重要走法因为它把两个方向的分值简单相加时仍低于一个“活三”的分值。这个问题可以通过给组合加分来解决比如判断某点是否同时让多个方向分值都高于活二就额外加一个“组合加成”。虽然不是最优解但能明显提升AI表现让对局更有压迫感。5. 从“能玩”到“好玩”手感和表现力的那些细节人机五子棋做到“能玩”很容易但要做成“能分享给别人玩、而且觉得还不错的”UI的反馈细节和规则完整度就变得重要了。5.1 落子动画与格子状态反馈对于大多数Unity项目给落子加一个“从无到有再落到棋盘上”的简单动画体验感会好非常多。我这边是用LocalScale从0到1通过一个短缓动来实现。格子本身可以设置Button的过渡色让玩家点击时有明确的按压反馈。但注意不要让动画阻塞逻辑。比如在动画播放期间玩家快速点击另一个格子此时可能会导致棋盘状态被修改两次。我的处理是在动画期间把isAiThinking设为true禁止所有新的点击动画结束后再开放交互。5.2 悔棋功能的实现思路五子棋这种回合制游戏悔棋几乎是标配。实现的关键点在于“回滚状态”而不仅仅是“把最后一个棋子变透明”。因为AI落子和玩家落子各占一步悔棋至少应该回滚两步玩家最后一步、AI最后一步。我建议用保存棋盘快照的方式实现public class BoardSnapshot { public int[,] grid; public int currentPlayer; public int moveCount; }在每次落子前把当前GameBoard完整快照压入栈中。悔棋时弹出栈顶快照并把UI按照快照的状态全部重绘一遍。这个过程对15×15的棋盘来说代价极小但能完美避免“AI和玩家各自悔一步导致状态错乱”的坑。5.3 给AI增加难度档位由于打分法本身没有深度搜索要让AI变弱最直接的做法是给它添加“随机扰动”。我在项目里暴露了一个int aiLevel字段当AI水平较低时它只在得分最高的前N个落点里随机选一个。N越大AI越弱。这个方案远比“让AI故意不下最优点”要自然因为它保留了AI的“棋型逻辑”只是决策不稳定玩家很难察觉是在放水。我实测下来五子棋入门玩家和2级AI对战胜率大约能稳定在六成以上这个调参思路很值得参考。6. 下一步当你觉得打分法不够强时该怎么加深度打分法最大的优点是可控、简单、适合教学但它的天花板也很明显没有前瞻能力。如果你希望自己的unity人机五子棋项目能再进一步我建议按下面顺序升级。增加两步搜索在打分法基础上先假设AI选点X再假设玩家最优应对点Y最后回到AI选点Z比较这一条路径的总分。这个搜索深度只有2到3层但已经能覆盖“双三”这类复合威胁。引入Alpha-Beta剪枝把打分函数作为叶节点评估函数再加上标准的极小化极大搜索。此时你的AI棋力会获得质的提升但要小心里面的排序优化遍历顺序决定剪枝效率。做索引候选点压缩不要扫描全部225个点。只扫描现有棋子周围两格范围内的空位这个剪枝技巧能让你把搜索深度从2层提升到4层而性能几乎不变。我在这边建议各位在动手前先把这篇里的打分法调熟。打分法做扎实了你后面的搜索树才有可靠的评估地基。不然直接套Alpha-Beta你会发现AI既缓慢又看不出哪里在下棋调试体验会非常崩溃。最后再分享一个我在实际使用中发现的小技巧所有棋型分值表里的数字不要写成魔法值散落在代码各处最好集中放在一个静态配置类里。这样你在后期调参时只需要改一张表所有模块的AI行为都会同步变化省掉了很多零散的重新编译时间。五子棋的核心魅力在于“规则极其简单但策略层次极深”希望这份unity人机五子棋程序代码能帮你快速搭出一个基础版本再慢慢走出自己的棋力进阶路线。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑