做五子棋练手,是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[,] _board,0表示空、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 |
| 活四 | 2 | 50000 |
| 冲四 | 1 | 10000 |
| 活三 | 2 | 10000 |
| 眠三 | 1 | 1000 |
| 活二 | 2 | 1000 |
| 眠二 | 1 | 100 |
为什么活三和冲四给同样分数?因为它们都代表着一步之后能形成必胜局面,抢到先手时这俩价值接近。实际调的时候你会发现,如果防守权重太高,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打分,取最高分。
代码写出来就是GetBestMove:
public Vector2Int GetBestMove(int[,] board) { List<Vector2Int> 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的Prefab,BattleBoard上挂好引用。
- 建一个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; List<Vector2Int> 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 List<Vector2Int> GetEmptyCells() { List<Vector2Int> result = new List<Vector2Int>(); 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,把评分模型换成Minimax+Alpha-Beta,深度设4层,保留现有的棋型评分作为递归结束时的局面对比,AI水平立刻上一个档。另一个方向是改联机,把AIPlayer替换成网络模块,把"AI返回坐标"的调用点换成从服务器接收消息就行,回合流程完全不用动。
我后来还添加过一个很实用的功能:直观显示AI评估结果。在EvaluatePoint过程中,把每个空位的得分存到一个二维数组,落子前用颜色深浅把热度图画出来。这样能看到AI眼里哪些点是热点,Debug时一眼就知道评分函数是不是在乱给分。建议你也加一下,比看控制台数字直观得多。
最后再分享一个我实测的小技巧:AI的防守权重不要给太高,我给1.2手感好,但有些人喜欢给到1.5,会让AI太过保守,明明能冲四一步赢的棋,非要跑去堵对面一个活二。调试时你可以在AIPlayer里把权重做成一个public float,边调边看胜率,比自己改代码舒服多了。棋盘类项目最怕的就是"觉得AI弱但不知道哪里弱",可视化调试比什么都有用。