做五子棋练手是Unity入门中期特别合适的项目。棋盘、落子交互、回合管理、胜负判定、AI决策一套流程下来什么关键环节都覆盖了难度又比象棋围棋低不少不会一上来就被搜索树和评估函数劝退。这篇就把Unity人机五子棋从零到能玩的全过程拆开讲一遍重点落在AI落子算法和棋盘坐标换算这两块并且给出可以直接抄作业的C#代码。这个项目能解决什么问题对于没写过完整回合制游戏的开发者它是最好的过渡案例对于想理解棋盘类AI的人它比井字棋多一点策略空间又比围棋简单到可以手工写完一个可用的评估函数。你不需要很深的算法底子有一轮C#语法基础就够。文章里所有代码都是我用实际工程跑通后精简出来的你照着搭一遍大概一小时就能在编辑器里玩起来。1. 项目概述与整体架构设计1.1 需求拆解这个项目真正难点在哪先用一句话说清楚我们要做的在Unity场景里摆一个15×15棋盘玩家用鼠标点击落黑子AI自动落白子任意一方形成五连即胜利中间要有状态提示和重新开始功能。表面功能就这么点但拆成技术点就多了怎么把鼠标点击的世界坐标换算成准确的格子坐标不能点歪、不能落偏。怎么维护一个干净利落的棋盘数据模型避免UI状态和逻辑状态混在一起。怎么在每一次落子后快速判断五连不能每步都全盘扫描拖慢体验。AI怎么决定下一步落哪里既要有防守意识也不能呆到只会堵棋。回合流转怎么控制玩家下完AI必须等一小会儿再下不能一帧内疯狂抢操作。我见过不少初学者一上来就写在同一个脚本里全世界变量都是public结果棋盘数据、渲染、AI、UI全缠在一起改AI难度得动一堆代码。正确做法是先拆模块棋盘管数据游戏控制器管流程AI只管拿棋盘数组返回一个坐标UI单独处理显示。后面调逻辑会舒服得多。1.2 技术选型为什么用物理射线这套方案实现鼠标落子有两条常见路线。一条是纯UGUI方案把棋盘做成格子区域用Button或EventTrigger接收点击然后做UI坐标到格子的换算。好处是Canvas天然处理了点击事件缩放也方便坏处是嵌套层级一多坐标换算反而容易绕晕棋盘线还得一张张图拼。另一条是世界坐标物理射线方案棋盘放在场景中用一个带BoxCollider的平面物体垫底鼠标点击时Camera发出射线打到平面上拿到碰撞点的世界坐标再除以格子间距取整得到棋盘坐标。我最终选了方案二原因很实在世界坐标的逻辑足够直觉BoardToWorld和WorldToBoard两个函数来回换算极其清楚调试时直接在场景里看Position数字根本不猜。而且棋子是场景里的GameObject哪个格子上有没有子编辑器里一眼就看得出来。1.3 模块划分与程序流程工程拆成五个脚本各管一摊脚本职责GameState.cs定义棋子类型、游戏状态等枚举BoardManager.cs维护棋盘二维数组、坐标换算、放子接口WinChecker.cs从指定点出发判定五连AIPlayer.cs输入棋盘数据输出AI落子坐标GameController.cs控制回合流转、接收玩家点击、调用AI、刷新UI流程非常直白游戏开始后轮到玩家玩家点击屏幕GameController把落点交给BoardManager校验空格则落子落完立即让WinChecker检测赢了直接进结束状态没赢就切换到AI回合。协程里等0.3秒后调用AIPlayer.GetBestMove拿到坐标同样落子、检测再把回合还回玩家。整个循环简单可靠后面扩展禁手、悔棋、联机都能在这个骨架上做。2. 棋盘数据与落子逻辑2.1 棋盘数据怎么存才不会乱棋盘底层就是二维数组我用的是int[,] _board0表示空、1表示黑子、2表示白子。为什么不直接放棋子GameObject因为AI需要快速遍历棋盘做评估如果每次都从场景里的物体去查性能会很难看而且数组拷贝、模拟落子都不方便。把数据和表现分离后模拟落子非常舒服直接把数组元素改掉跑一遍评分跑完再改回来床面上棋子纹丝不动。这个能力后面AI评估局面的核心就靠它。具体定义我放到了BoardManager里public const int BoardSize 15; private int[,] _board new int[BoardSize, BoardSize]; public float CellSize 0.64f; // 格子间距0和1、2的区别也顺便充当了黑先白后的玩家标识判断胜负时只需要把当前玩家数值传进WinChecker。2.2 格子坐标与世界坐标的互相换算这是新手最容易卡住的地方。原理其实就一步乘法棋盘某个位置(x, y)它对应的世界坐标就是x乘以格子间距、y乘以格子间距。如果你把棋盘的锚点放在原点直接从(0,0)开始排那换算代码短到不可思议public Vector3 BoardToWorld(int x, int y) { return new Vector3(x * CellSize, 0f, y * CellSize); } public Vector2Int WorldToBoard(Vector3 worldPos) { int x Mathf.RoundToInt(worldPos.x / CellSize); int y Mathf.RoundToInt(worldPos.z / CellSize); return new Vector2Int(x, y); }注意两个细节。第一Unity的平面里我给的是xz轴所以世界坐标用worldPos.z如果你把棋盘摆到xy平面那就要自己调整。第二取整用的是RoundToInt而不是FloorToInt为什么因为鼠标点落在格子边缘时四舍五入更符合我们直觉谁离得近就算谁。用Floor的话整个棋盘会感觉整体偏移半个格子。棋盘如果不想从原点开始可以给BoardManager加一个OriginPosition偏移量所有换算都减掉偏移逻辑完全一样。我实际项目里就加了主要是为了方便在场景里摆位置。2.3 落子接口与回合控制落子必须校验不能下到有子的地方也不能越界。BoardManager提供统一入口public bool TryPlacePiece(int x, int y, int player) { if (x 0 || x BoardSize || y 0 || y BoardSize) return false; if (_board[x, y] ! 0) return false; _board[x, y] player; SpawnPieceVisual(x, y, player); return true; }SpawnPieceVisual就是实例化棋子Prefab的位置黑子白子各一个Prefab按玩家类型选再用BoardToWorld放到场景里。这一步慢不了Instantiate每步只创建一个小物体15×15满盘也就225个Unity完全扛得住。回合控制的核心思路是一个状态变量一个状态机式的主循环。玩家回合里才响应点击AI回合里点击无效游戏结束时点击也无效。这种玩法下状态一定不能乱所以我用了一个CurrentGameState枚举而不是一堆bool开关。2.4 五子连珠判定只查最后一步附近最简单的判定是全盘扫描所有格子四条方向各数一遍连续同色。但全盘扫描完全没必要要知道五连形成时最后一颗棋子必然属于这个五连。我们只需要对最后落子那一格朝四个方向各数一遍连续同色棋子数量就行。四个方向分别是水平(1,0)、垂直(0,1)、主对角线(1,1)、副对角线(1,-1)。每个方向都朝正反两方向延伸计数总数大于等于5就赢public static bool CheckWin(int[,] board, int x, int y, int player) { int size board.GetLength(0); int[] dx { 1, 0, 1, 1 }; int[] dy { 0, 1, 1, -1 }; for (int i 0; i 4; i) { int count 1; for (int sign -1; sign 1; sign 2) { int cx x dx[i] * sign; int cy y dy[i] * sign; while (cx 0 cx size cy 0 cy size board[cx, cy] player) { count; cx dx[i] * sign; cy dy[i] * sign; } } if (count 5) return true; } return false; }为什么要从最后落子出发而不是扫描全盘性能和代码清晰度两方面的理由。落一步判一次撑死查四五十个格子恒定耗时而且写出来直观别人review代码时一眼就能看懂。3. 人机AI从堵棋到棋型评分3.1 AI难度应该怎么分层五子棋AI可以分成几个难度档每档对代码复杂度的要求完全不同入门级随机落子只堵已经成四的棋其他闭眼乱下。代码十行内但玩起来像个傻子。进阶级每个空位打分兼顾进攻和防守能堵三、冲四、连五。这是绝大多数练手项目的合理目标也是我这篇要讲的。高手级极小极大搜索配合Alpha-Beta剪枝深度到6层以上。代码量翻倍评估函数还得更细适合后面再折腾。我的建议是直接做进阶级。原因很实际它的效果已经足够让人觉得AI挺会下实现复杂度又控制在一个函数内不用引入递归搜索理解成本和调试成本都最低。3.2 棋型评分模型怎么让AI看懂局面评分模型的核心思想是对棋盘上每个空位假装放一颗己方棋子下去看看它能形成多好的棋型再假装放一颗对方棋子下去看看局面会有多危险。前者叫进攻分后者叫防守分两者加权相加就是最终分。棋型怎么判断从一个点出发沿四个方向统计连续同色棋子的数量和两端被堵的情况。例如count 3且两端都开放这就是活三下一步能成活四如果一端被堵就是眠三价值大打折扣。我整理了一张常用分值表数值是拍脑袋调的但基本符合棋理棋型两端空位数量分值五连-100000活四250000冲四110000活三210000眠三11000活二21000眠二1100为什么活三和冲四给同样分数因为它们都代表着一步之后能形成必胜局面抢到先手时这俩价值接近。实际调的时候你会发现如果防守权重太高AI会变得非常保守只堵不攻权重太低又会变成莽夫。我试下来给防守分乘以1.2左右手感最好既注重防守又不会放弃自己的进攻机会。3.3 写出评分函数核心代码解读单点评分的实现很直接每颗子判断完一个方向就数连续段private int GetLineScore(int[,] board, int x, int y, int player, int dx, int dy) { int count 1; int openEnds 0; for (int sign -1; sign 1; sign 2) { int cx x dx * sign; int cy y dy * sign; while (cx 0 cx 15 cy 0 cy 15 board[cx, cy] player) { count; cx dx * sign; cy dy * sign; } if (cx 0 cx 15 cy 0 cy 15 board[cx, cy] 0) openEnds; } if (count 5) return 100000; if (count 4 openEnds 2) return 50000; if (count 4 openEnds 1) return 10000; if (count 3 openEnds 2) return 10000; if (count 3 openEnds 1) return 1000; if (count 2 openEnds 2) return 1000; if (count 2 openEnds 1) return 100; return count 1 openEnds 2 ? 100 : 10; }然后对空位评分时四个方向分数加起来private int EvaluatePoint(int[,] board, int x, int y) { int aiScore 0; int humanScore 0; int[] dx { 1, 0, 1, 1 }; int[] dy { 0, 1, 1, -1 }; board[x, y] AIPlayer; for (int i 0; i 4; i) aiScore GetLineScore(board, x, y, AIPlayer, dx[i], dy[i]); board[x, y] HumanPlayer; for (int i 0; i 4; i) humanScore GetLineScore(board, x, y, HumanPlayer, dx[i], dy[i]); board[x, y] 0; return aiScore (int)(humanScore * 1.2f); }注意这里有个关键操作我先临时把棋盘数组的(x,y)改成AIPlayer评估再改成HumanPlayer评估最后恢复成0。这就是前面说数据和表现分离的实战意义如果直接操作场景里的棋子对象做这种模拟就得创建销毁一堆物体又慢又麻烦。3.4 AI决策主流程先赢棋再堵棋最后评分评估函数只是基础AIPlayer真正拿主意时还要加两层不得不做的判断先扫描所有空位模拟AI自己落子如果有空位能让AI直接五连胜闭眼下这里没有讨论余地。再扫描所有空位模拟玩家落子如果有空位能让玩家下一步五连胜必须优先堵这里否则评分模型可能在多个威胁之间摇摆。前两步都没命中才遍历所有空位用EvaluatePoint打分取最高分。代码写出来就是GetBestMovepublic Vector2Int GetBestMove(int[,] board) { ListVector2Int emptyCells GetAllEmptyCells(board); foreach (var cell in emptyCells) { board[cell.x, cell.y] AIPlayer; if (WinChecker.CheckWin(board, cell.x, cell.y, AIPlayer)) { board[cell.x, cell.y] 0; return cell; } board[cell.x, cell.y] 0; } foreach (var cell in emptyCells) { board[cell.x, cell.y] HumanPlayer; if (WinChecker.CheckWin(board, cell.x, cell.y, HumanPlayer)) { board[cell.x, cell.y] 0; return cell; } board[cell.x, cell.y] 0; } Vector2Int bestMove emptyCells[0]; int bestScore int.MinValue; foreach (var cell in emptyCells) { int score EvaluatePoint(board, cell.x, cell.y); if (score bestScore) { bestScore score; bestMove cell; } } return bestMove; }3.5 为什么我不直接上Minimax搜索有些教程上来就给你直接推Alpha-Beta剪枝我觉得这很容易打击热情。五子棋分支因子非常高空棋盘时有225个可落点深度到4层就有差不多26亿个节点不配合优秀的评估剪枝根本没法跑。而咱们现在的评分模型AI的反应速度是毫秒级的效果已经能用。先把这套评分理解透以后想升级成Minimax时其实只需要把AIScore从单步评估换成递归搜索评分函数还能继续复用。4. 完整工程搭建与程序代码4.1 场景搭建步骤新建Unity空场景后按下面操作搭十分钟搞定建一个空物体挂上BoardManager脚本它自己会在Start里画棋盘线。新建一个Quad作为棋盘底板位置放在(7, -0.05f, 7)缩放拉到(15, 1, 15)左右挂上BoxCollider。这个碰撞体就是鼠标射线要击中的目标。主摄像机放到(6.5f, 10f, 6.5f)附近Rotation设为(45, 0, 0)用正交模式更稳Size调到9左右。建黑子、白子两个圆形Sprite的PrefabBattleBoard上挂好引用。建一个Canvas放一个Text用来显示回合和胜负再放一个Button用来重开。把棋盘画线那一步说清楚我没有用美术给棋盘图直接在BoardManager里用LineRenderer画了15×15的网格。初始化线时计算好每条线的起点终点横线15条、竖线15条材质设成黑色宽度0.02代码非常清爽private void DrawBoardLines() { for (int i 0; i BoardSize - 1; i) { Vector3 start new Vector3(0f, 0f, i * CellSize); Vector3 end new Vector3((BoardSize - 1) * CellSize, 0f, i * CellSize); DrawLine(start, end); } // 竖线同理 }4.2 核心脚本全量代码先是最基础的GameState枚举放这里所有脚本都能引用public enum PlayerType { None 0, Black 1, White 2 } public enum GameState { PlayerTurn, AITurn, GameOver }然后是BoardManager它负责棋盘数组、视觉棋子和坐标转换using System.Collections; using UnityEngine; public class BoardManager : MonoBehaviour { public static BoardManager Instance; public const int BoardSize 15; public float CellSize 0.64f; public GameObject blackPiecePrefab; public GameObject whitePiecePrefab; private int[,] _board new int[BoardSize, BoardSize]; private Transform _pieceRoot; private void Awake() { Instance this; _pieceRoot new GameObject(PieceRoot).transform; DrawBoardLines(); } public int GetCell(int x, int y) { if (x 0 || x BoardSize || y 0 || y BoardSize) return 0; return _board[x, y]; } public bool TryPlacePiece(int x, int y, int player) { if (x 0 || x BoardSize || y 0 || y BoardSize) return false; if (_board[x, y] ! 0) return false; _board[x, y] player; SpawnPiece(x, y, player); return true; } private void SpawnPiece(int x, int y, int player) { GameObject prefab player 1 ? blackPiecePrefab : whitePiecePrefab; GameObject piece Instantiate(prefab, BoardToWorld(x, y), Quaternion.identity); piece.transform.SetParent(_pieceRoot); } public Vector3 BoardToWorld(int x, int y) { return new Vector3(x * CellSize, 0.05f, y * CellSize); } public Vector2Int WorldToBoard(Vector3 worldPos) { int x Mathf.RoundToInt(worldPos.x / CellSize); int y Mathf.RoundToInt(worldPos.z / CellSize); return new Vector2Int(x, y); } // 画线部分请参考上文DrawBoardLines }棋盘肯定不想老被翻代码所以我把WorldToBoard和BoardToWorld这两个函数写在了前面后面所有环节都用它不要直接在控制器里写除法不然以后改格子间距你会疯。WinChecker我上面已经给了接下来是AIPlayer完整代码using System.Collections.Generic; using UnityEngine; public class AIPlayer : MonoBehaviour { public int AIColor 2; // 白子 public int HumanColor 1; // 黑子 private int[,] _board; public Vector2Int GetBestMove(int[,] board) { _board board; ListVector2Int empty GetEmptyCells(); foreach (var c in empty) { _board[c.x, c.y] AIColor; if (WinChecker.CheckWin(_board, c.x, c.y, AIColor)) { _board[c.x, c.y] 0; return c; } _board[c.x, c.y] 0; } foreach (var c in empty) { _board[c.x, c.y] HumanColor; if (WinChecker.CheckWin(_board, c.x, c.y, HumanColor)) { _board[c.x, c.y] 0; return c; } _board[c.x, c.y] 0; } Vector2Int best empty[0]; int bestScore int.MinValue; foreach (var c in empty) { int score EvaluatePoint(c); if (score bestScore) { bestScore score; best c; } } return best; } private ListVector2Int GetEmptyCells() { ListVector2Int result new ListVector2Int(); for (int x 0; x BoardManager.BoardSize; x) for (int y 0; y BoardManager.BoardSize; y) if (_board[x, y] 0) result.Add(new Vector2Int(x, y)); return result; } private int EvaluatePoint(Vector2Int pos) { int x pos.x, y pos.y; int aiScore 0, humanScore 0; int[] dx { 1, 0, 1, 1 }; int[] dy { 0, 1, 1, -1 }; _board[x, y] AIColor; for (int i 0; i 4; i) aiScore GetLineScore(x, y, AIColor, dx[i], dy[i]); _board[x, y] HumanColor; for (int i 0; i 4; i) humanScore GetLineScore(x, y, HumanColor, dx[i], dy[i]); _board[x, y] 0; return aiScore (int)(humanScore * 1.2f); } private int GetLineScore(int x, int y, int player, int dx, int dy) { int count 1; int openEnds 0; for (int sign -1; sign 1; sign 2) { int cx x dx * sign; int cy y dy * sign; while (cx 0 cx BoardManager.BoardSize cy 0 cy BoardManager.BoardSize _board[cx, cy] player) { count; cx dx * sign; cy dy * sign; } if (cx 0 cx BoardManager.BoardSize cy 0 cy BoardManager.BoardSize _board[cx, cy] 0) { openEnds; } } if (count 5) return 100000; if (count 4 openEnds 2) return 50000; if (count 4 openEnds 1) return 10000; if (count 3 openEnds 2) return 10000; if (count 3 openEnds 1) return 1000; if (count 2 openEnds 2) return 1000; if (count 2 openEnds 1) return 100; if (count 1 openEnds 2) return 100; return 10; } }最后是GameController负责整个游戏流程using System.Collections; using UnityEngine; using UnityEngine.UI; public class GameController : MonoBehaviour { public BoardManager board; public AIPlayer ai; public Text statusText; public Button restartButton; private GameState _state; private int _currentPlayer; private void Start() { _currentPlayer (int)PlayerType.Black; _state GameState.PlayerTurn; statusText.text 你的回合黑子; restartButton.onClick.AddListener(RestartGame); } private void Update() { if (_state ! GameState.PlayerTurn) return; if (!Input.GetMouseButtonDown(0)) return; Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit)) { Vector2Int cell board.WorldToBoard(hit.point); TryPlayerMove(cell.x, cell.y); } } private void TryPlayerMove(int x, int y) { if (!board.TryPlacePiece(x, y, _currentPlayer)) return; if (WinChecker.CheckWin(board.GetCell, x, y, _currentPlayer)) { _state GameState.GameOver; statusText.text 你赢了; return; } _currentPlayer (int)PlayerType.White; _state GameState.AITurn; statusText.text AI思考中...; StartCoroutine(AIMove()); } private IEnumerator AIMove() { yield return new WaitForSeconds(0.3f); Vector2Int move ai.GetBestMove(board.Data); board.TryPlacePiece(move.x, move.y, (int)PlayerType.White); if (WinChecker.CheckWin(board.GetCell, move.x, move.y, (int)PlayerType.White)) { _state GameState.GameOver; statusText.text AI赢了; yield break; } _currentPlayer (int)PlayerType.Black; _state GameState.PlayerTurn; statusText.text 你的回合黑子; } private void RestartGame() { for (int x 0; x BoardManager.BoardSize; x) for (int y 0; y BoardManager.BoardSize; y) board.TryPlacePiece(x, y, 0); // 实际建议在BoardManager里加ClearBoard方法同时销毁场景里的棋子物体 _currentPlayer (int)PlayerType.Black; _state GameState.PlayerTurn; statusText.text 你的回合黑子; } }上面RestartGame我故意留了个缺口直接清数组没问题但场景里的棋子物体还在。实操中应该在BoardManager里加一个方法遍历_pieceRoot销毁所有子物体再清数组不然每重开一局棋盘上就堆一层棋子玩三局就看不清了。这也是新手最容易忽略的地方。4.3 挂载与调通场景里挂脚本时注意这几处BoardManager挂在一个空物体上把黑子、白子Prefab拖进对应字段。AIPlayer直接挂在空物体上不用写什么配置。GameController挂在一个空物体上把BoardManager、AIPlayer、UI的Text和Button拖进去。BoardManager里的公有字段如果没拖引用运行时会报空引用所以拖完后先跑一次看console有没有红字。调通后你会发现一个很奇怪的现象AI第一步如果棋盘全空它大概率会下在中心点附近。这不是bug是因为中心点周围四个方向全是空位评出来的总分天然比边角高。事实上五子棋中中心确实是最强点位AI这个行为符合棋理。5. 调试记录、高频问题与扩展建议5.1 高频问题速查表现象可能原因解决办法鼠标点击没反应射线没打到Collider检查棋盘底板是否有BoxCollider位置是否在射线可达范围棋子落点偏移严重WorldToBoard用错了坐标轴确保用worldPos.z而不是worldPos.y棋子叠在同一个格子里TryPlacePiece校验失败但没提示检查_board数组是否在Restart时没清零AI总是下了不走游戏状态停在AITurn检查AI协程是否没把状态切回PlayerTurn重开后棋子越来越多RestartGame没销毁场景棋子BoardManager里加ClearPieces方法销毁所有子物体5.2 三个容易踩的坑第一个坑是坐标偏移。我最早做的时候棋盘起点在(1,1)而不是(0,0)WorldToBoard里死活忘掉减去这个偏移导致鼠标点击偏右上角。后来我学乖了所有坐标换算只依赖BoardToWorld和WorldToBoard两个接口别的地方一律不许自己写乘除法。第二个坑是胜负判定用的还是旧棋盘数据。GameController里如果偷懒直接拿board.Data去判断而没实时读取数组会出现明明五颗子却判定不赢的诡异Bug。WinChecker每次传入的必须是当前最新数组。第三个坑是AI在开局时会有明显卡顿。其实不是卡顿是GetBestMove里第一步扫描了225个空位每个空位都要模拟两遍棋型统计在编辑器里还好但如果你把棋盘扩到19路或者评估函数复杂化这个耗时就会上升。优化方式很简单把GetAllEmptyCells的结果缓存复用别在循环里反复new List。5.3 从人机到联机的扩展思路这个项目做完想往两个方向走都顺。一个方向是强化AI把评分模型换成MinimaxAlpha-Beta深度设4层保留现有的棋型评分作为递归结束时的局面对比AI水平立刻上一个档。另一个方向是改联机把AIPlayer替换成网络模块把AI返回坐标的调用点换成从服务器接收消息就行回合流程完全不用动。我后来还添加过一个很实用的功能直观显示AI评估结果。在EvaluatePoint过程中把每个空位的得分存到一个二维数组落子前用颜色深浅把热度图画出来。这样能看到AI眼里哪些点是热点Debug时一眼就知道评分函数是不是在乱给分。建议你也加一下比看控制台数字直观得多。最后再分享一个我实测的小技巧AI的防守权重不要给太高我给1.2手感好但有些人喜欢给到1.5会让AI太过保守明明能冲四一步赢的棋非要跑去堵对面一个活二。调试时你可以在AIPlayer里把权重做成一个public float边调边看胜率比自己改代码舒服多了。棋盘类项目最怕的就是觉得AI弱但不知道哪里弱可视化调试比什么都有用。