ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity五子棋期末大作业:棋盘绘制、判胜算法与AI实现全解析

Unity五子棋期末大作业:棋盘绘制、判胜算法与AI实现全解析

简介:一份面向Unity初学者的五子棋游戏期末项目,完整实现双人对弈及基于蒙特卡洛树搜索(MCTS)的AI对战功能。项目涵盖Unity场景搭建、C#游戏逻辑(棋盘表示、落子判定、胜负检测)、鼠标交互反馈,并深入展示MCTS的选择、扩展、模拟、回溯流程,同时介绍Alpha-Beta剪枝等优化思路。压缩包整体约46.87MB,以zip格式打包,便于直接解压导入Unity工程或参考学习,适合需要完成课程设计、期末大作业或入门游戏AI的开发者。已有1016人学习下载,项目结构清晰,可直接运行体验,也能作为二次开发与算法研究的基础。

1. Unity 期末大作业挑五子棋,图的是“小而全”

用 Unity 做五子棋期末大作业,是很多人在课程设计、结课项目里选的方向。原因很直接:它把数组、坐标换算、输入检测、状态管理一次全占了,又不至于像动作游戏那样要处理一堆物理和动画。五子棋的交互收敛成一枚棋子一次点击,正好把精力留给棋盘逻辑和 AI 判定。

这篇笔记按一个能完整运行的 Unity 五子棋项目来拆——从棋盘绘制、落子、判胜到一个能陪你下完一局的 AI,每段都给出可抄的代码和参数。对第一次用 Unity 交作业的人友好,对后面想自己加禁手、悔棋规则的熟手也不碍事。如果你还在犹豫要不要选五子棋当作业,可以用一个标准判断:你需要在几天内拿出一个能演示、能讲清楚逻辑的东西,五子棋是少数能把“玩法闭环”和“数据结构”同时展示出来的题目。它没有联网、没有存档、没有复杂资源,难度上限由一个 AI 模块决定,做浅做深都拿得出手。

2. 先搭棋盘和棋子:UGUI、数据模型和坐标换算

五子棋的第一屏不是代码,是棋盘。棋盘长什么样、棋子位置怎么摆,直接决定后面所有坐标换算代码的写法。这一章先把选型说清楚,再落到一个最小能跑的工程里。

2.1 棋盘用 UGUI 还是 3D 物体:期末答辩里怎么选

先看两套方案在期末作业里的实际差异。用 UGUI,就是 Canvas 下放一张棋盘 Image,棋子也作为 Image 实例化进去。好处是分割分辨率和 Canvas Scaler 联动,答辩现场换电脑、切窗口,棋盘不会散架。用 3D 物体,常见做法是建一个平面当棋盘、Sphere 当棋子,摄像机固定成俯视角,甚至加一点点旋转让棋盘更有立体感;好处是以后想要镜头环绕、加上 VR/AR 扩展时有基础。

我一般建议期末作业默认 UGUI,理由有三个。第一,你不需要维护摄像机,屏幕坐标和 UI 坐标之间的换算 Unity 已经封装好了。第二,棋子放大缩小、换贴图、做落子动画,在 UI 层比 3D 层简单。第三,答辩时老师大概率只看你窗口里的画面,UI 方案在任意分辨率下都是同一个观感。如果你坚持用 3D 方案,摄像机不需要做跟随——棋盘的拍摄范围是固定的,网上常见的那种“摄像机跟随”脚本在这个项目里属于多余功能,加了反而要处理边界限制。

资源方面,棋盘贴图不用自己做。Unity 资源商店里搜 “Gomoku” 或 “五子棋” 能找到免费的棋盘棋子素材;不想联网,就自己用一张 800×800 的图片画 15 条横竖线,导出 PNG 拖进工程。注意导入时把 Texture Type 设为 Sprite(2D and UI),不然 UGUI 的 Image 不认。这一步对新手很友好,连 3D 建模都省了。

2.2 用二维数组记录棋盘状态:一段能跑通的最小模型

无论界面怎么画,核心数据模型最好是一张二维表。我习惯用一个 Board 类把棋盘数组包起来,而不是在 GameController 里到处写 int[,]。这样做的好处是,后面 AI 要读棋盘、悔棋要回滚状态,都只跟这个类打交道,不会出现“三个脚本各存一份棋盘副本”这种坑。

using UnityEngine; public class Board { public int size = 15; public int[,] cells; public Board(int size) { this.size = size; cells = new int[size, size]; } public bool IsEmpty(int x, int y) { if (x < 0 || x >= size || y < 0 || y >= size) return false; return cells[x, y] == 0; } public bool Place(int x, int y, int player) { if (!IsEmpty(x, y)) return false; cells[x, y] = player; return true; } }

cells 的值约定为 0 空、1 黑、2 白。IsEmpty 先做了越界判断,这很重要——AI 在遍历候选点时经常扫到边界外,如果这里不拦,后面会冒出一堆数组越界把你 debug 到怀疑人生。Place 返回 bool,调用方拿这个返回值决定要不要生成棋子 GameObject。如果你是 15 路棋盘,size 填 15;想做人机对战但棋盘小一点方便测试,可以改成 9 或者 11。改这一个变量就够了,后续坐标换算、判胜循环都读这个 size。注意 15 路是 15×15 的格点,不是 15 条线,所以要画 15 条横线和 15 条竖线,最外侧两条贴近棋盘边缘。

2.3 点击坐标换算成格点坐标:数学和代码

这一步是五子棋项目里最容易翻车的点,也是答辩时最容易被问的地方。先定一个规则:棋盘的左下角格点是 (0,0),x 向右,y 向上。棋盘 Image 的 RectTransform 宽度设为 cellSize * (size - 1),高度同样,pivot 设为 (0,0)。这样 Image 的左下角就是第一个格子的坐标原点,换算不用做偏移。

using UnityEngine; public class InputHandler : MonoBehaviour { public RectTransform boardRect; public float cellSize = 50f; void Update() { if (!Input.GetMouseButtonDown(0)) return; Vector2 localPoint; if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( boardRect, Input.mousePosition, null, out localPoint)) return; int x = Mathf.RoundToInt(localPoint.x / cellSize); int y = Mathf.RoundToInt(localPoint.y / cellSize); Debug.Log($"点击格点: ({x}, {y})"); } }

ScreenPointToLocalPointInRectangle 的第三个参数传 null,表示用屏幕坐标直接换算,适合 Canvas 的 Screen Space - Overlay 模式;如果你的 Canvas 改成了 Camera 模式,这里要传 UICamera。换算完的 localPoint 是相对棋盘左下角的二维坐标,单位是逻辑像素。Mathf.RoundToInt 是关键一步,四舍五入把连续坐标变成格子索引,如果你用了 Mathf.FloorToInt,点击格子右半边时会错误地落到左边那格。

cellSize 一般取 40 到 60。取值太小,棋子容易贴在一起,点起来费劲;取值太大,屏幕只能放下半张棋盘。我用 50 比较多,15 路棋盘总宽 700 像素,在 1080p 窗口里占中间偏上的一块,观感正好。这个值后续 AI 落子生成棋子也要用,建议放在 GameSettings 静态类里,别在多个脚本里各写一个。

写完这两块,你已经有了“棋盘图像 + 棋盘数据 + 鼠标点格”的最小闭环。暂停一下,做一次验证:运行工程,点击棋盘每个边缘格点,Console 里输出的坐标必须落在 0 到 14 之间;如果点最左边出现 -1,说明棋盘 Image 左边贴了一条“无效边”——这不是 bug,是 Image 比有效格子区多出了半个格子的空白。解决方式是回到 2.3 开头,把 Image 尺寸改回 cellSize * (size - 1)。

3. 落子交互与回合控制:鼠标点下去那一刻发生了什么

棋盘能响应坐标了,接下来要把它变成真正的对弈流程:点击方格、生成棋子、切换玩家、检查游戏是否结束。这一章的代码量不大,但顺序错了会出各种怪现象。

3.1 点击后先问三件事:空位、回合、游戏是否结束

很多新手写五子棋,直接在 Update 里检测到点击就开始落子,结果出现“游戏已经结束还能继续下”“轮到 AI 时玩家还能抢着下”“同一个位置放了两颗子”三个经典问题。解决方式是把点击的响应收拢到一个入口,所有条件在入口处一次性校验。

我习惯在 GameController 里维护 currentPlayer(1 或 2)、isGameOver(bool)和一个落子历史栈。TryPlacePiece 函数的开头就集中处理这三件事,任何一颗棋子要落到棋盘上,都必须从这个函数走。

public class GameController : MonoBehaviour { public Board board; public GameObject blackPiecePrefab; public GameObject whitePiecePrefab; public Transform pieceRoot; public RectTransform boardRect; public float cellSize = 50f; private int currentPlayer = 1; private bool isGameOver = false; private Stack<Vector2Int> history = new Stack<Vector2Int>(); public void TryPlacePiece(int x, int y) { if (isGameOver) return; if (!board.Place(x, y, currentPlayer)) return; SpawnPiece(x, y, currentPlayer); history.Push(new Vector2Int(x, y)); if (CheckWin(x, y, currentPlayer)) { isGameOver = true; Debug.Log($"玩家 {currentPlayer} 获胜"); return; } currentPlayer = currentPlayer == 1 ? 2 : 1; } private void SpawnPiece(int x, int y, int player) { GameObject prefab = player == 1 ? blackPiecePrefab : whitePiecePrefab; Vector3 worldPos = boardRect.anchoredPosition + new Vector3(x * cellSize, y * cellSize, 0); Instantiate(prefab, worldPos, Quaternion.identity, pieceRoot); } }

这里把“数据落子”和“显示落子”分成两步:board.Place 先写数组,成功后才生成 GameObject。这颗棋子的生成位置由 x、y 乘以 cellSize 得到,与 2.3 的换算正好互逆;如果位置对不上,先检查棋盘 Image pivot 是否还是 (0,0)。判胜那行 CheckWin 是下一章的主角,这里先留一个接口占位。history 栈现在只是压栈,后面悔棋功能会用到。一个容易被忽略的细节是:currentPlayer 的切换放在判胜之后,而不是落子之后立刻切,否则“落子即胜利”的那一瞬,UI 会显示错误回合的提示。

这套 TryPlacePiece 结构也方便以后扩展。如果你要给作业加“同房间双人联机”或者“局域网对战”,不需要改这函数:不管是鼠标点击还是网络消息,最终都汇聚到这个入口,数据层和表现层天然分离。

3.2 从 Update 到 EventSystem:两种输入处理怎么选

2.3 的 InputHandler 用了 Update 里轮询 GetMouseButtonDown。这个做法在五子棋里没问题,但你要知道它的边界:Unity 的 UI 按钮也会响应点击,如果棋盘上方还叠着“重新开始”按钮,轮询方式不会帮你过滤这些点击——你在按钮上点一下,棋盘同时落了一颗子。想省心,挂一个 EventSystem 的 IPointerClickHandler。

using UnityEngine; using UnityEngine.EventSystems; public class BoardClickHandler : MonoBehaviour, IPointerClickHandler { public GameController gameController; public void OnPointerClick(PointerEventData eventData) { Vector2 localPoint; if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( GetComponent<RectTransform>(), eventData.position, eventData.pressEventCamera, out localPoint)) return; int x = Mathf.RoundToInt(localPoint.x / GameSettings.CellSize); int y = Mathf.RoundToInt(localPoint.y / GameSettings.CellSize); if (x < 0 || x >= GameSettings.BoardSize || y < 0 || y >= GameSettings.BoardSize) return; gameController.TryPlacePiece(x, y); } }

OnPointerClick 比 Update 的好处是自带 UI 事件冒泡,按钮上点一下不会误触棋盘;代价是棋盘 Image 必须开 Raycast Target,否则收不到事件。第二行把 eventData.position 和 pressEventCamera 传给换算函数,在 Screen Space - Camera 模式下也能准确换算。如果 Canvas 上有全屏的半透明遮罩层,它会吃掉所有点击,棋盘 Image 收不到事件,这种问题排查起来很玄学,基本只能一层层看 Raycast Target。

提示:UGUI 的 Image 组件默认 Raycast Target 是开启的,别为了“省资源”随手关掉,否则棋盘点击会静默失效。

两种输入方案都试过之后,期末作业我更倾向 OnPointerClick。理由很简单:你不用自己关心鼠标是不是在窗口外、是不是误点了别的 UI,事件系统都帮你挡掉了。缺点只有一个:如果你将来要做“落子特效”,需要额外处理事件回调里不能直接拿鼠标屏幕坐标的问题,这不算大麻烦。

3.3 悔棋:用 Stack 回滚数据和棋子

悔棋是五子棋期末作业里性价比很高的加分项,实现量很小,答辩时却能展示你对数据结构的理解。前面代码已经在落子时把坐标压进了 history,悔棋只需要把它弹出来。单人本地对战的悔棋规则很简单:每悔一次棋,撤销最后一步。人机对战里通常要悔两步——玩家悔一手,AI 那一手也要一起撤,不然回合就会错乱。下面这段按“人机悔两步”来写。

public void Undo() { if (history.Count == 0) return; if (isGameOver) isGameOver = false; UndoOneMove(); if (history.Count > 0 && currentPlayer == 1) UndoOneMove(); } private void UndoOneMove() { Vector2Int pos = history.Pop(); board.cells[pos.x, pos.y] = 0; Transform lastChild = pieceRoot.GetChild(pieceRoot.childCount - 1); Destroy(lastChild.gameObject); currentPlayer = currentPlayer == 1 ? 2 : 1; }

UndoOneMove 里的“销毁最后生成的棋子”有一个前提:棋子生成顺序和落子顺序完全一致,中间不能有额外实例化。如果你加了落子动画或特效粒子,这部分可能对不上,那就不要在 pieceRoot 里按顺序取子物体,而是在落子时把生成的 GameObject 也压进同一个栈里。悔棋的边界有几个:游戏结束时 isGameOver 要复位,不然悔完棋还是不能落子;只悔到第一步时,第二次 UndoOneMove 会因 history.Count == 0 被拦截,不会越界。

一个常见误用是很多人用 List 记录棋子,悔棋时 RemoveAt 再 Destroy。List 也能用,但 Stack 天然契合“后进先出”的语义,读代码的人一眼就能明白。答辩时老师问“悔棋怎么实现的”,你只需要说“每步压栈,悔棋弹出,同时把二维数组写回 0”,就够了。这里有一个Destroy 的延时坑:Destroy 在帧末才真正移除物体,如果你悔棋后立刻又下了一步,pieceRoot 的 childCount 可能还没减一,取子物体时容易取错。解决方式是改用 DontDestroyImmediate,或者用对象池复用棋子,期末版本里直接用 Destroy 然后不去依赖 childCount 顺序,改成把生成的 GameObject 引用也入栈,是最稳的。

4. 胜负判定和基础 AI:从最后一手棋往外数五个

这一步是五子棋的核心算法,也是期末作业分数的分水岭。判胜写不好,会出现横竖能赢斜线不赢的怪事;AI 写得太复杂,又会把自己绕进去。这一章给一套能稳定工作的组合:基于最后一手的判胜,配一个会堵人的打分 AI。

4.1 五子连珠判定:为什么从刚落下的那颗棋开始查

一个常见的判胜写法是每下一步棋就全盘扫描,找有没有任意方向连续五颗同色。这个做法能跑,但代码啰嗦,而且在 15 路棋盘上完全没必要。五子棋的胜负一定和最后一手有关:如果这手棋没有形成五连,那它之前早就胜负已分。所以判胜只需要从最后一手的坐标出发,沿四个方向数。

四个方向分别是:水平 (1,0)、垂直 (0,1)、主对角线 (1,1)、副对角线 (1,-1)。对每个方向,从当前格向两个相反方向延伸,统计同色棋子的连续数量,加上最后一手自身,总数达到 5 就判赢。这个思路代码量小,而且天然不会漏判斜线,因为四个方向都在循环里。

public bool CheckWin(int x, int y, int player) { Vector2Int[] directions = { new Vector2Int(1, 0), new Vector2Int(0, 1), new Vector2Int(1, 1), new Vector2Int(1, -1) }; foreach (Vector2Int dir in directions) { int count = 1; count += CountDirection(x, y, dir.x, dir.y, player); count += CountDirection(x, y, -dir.x, -dir.y, player); if (count >= 5) return true; } return false; } private int CountDirection(int x, int y, int dx, int dy, int player) { int count = 0; int nx = x + dx; int ny = y + dy; while (nx >= 0 && nx < board.size && ny >= 0 && ny < board.size) { if (board.cells[nx, ny] != player) break; count++; nx += dx; ny += dy; } return count; }

边界条件全在 CountDirection 里处理,CheckWin 本身很干净。count 的初始值是 1,因为最后一手棋本身算一颗。很多人第一次写会忘掉 count 从 1 开始,结果总是要五子加一颗才能赢,那个 bug 很隐蔽。判胜函数只读棋盘数组,不要在里面临时改写数组,否则 AI 和界面同时读棋盘时,会出现偶尔凭空多一颗子的怪现象。这个坑我见过不止一次,属于典型的“看着没毛病,跑起来抽风”。

判胜还可以顺手处理平局。每次落子后先判赢,再遍历 cells 检查是否还有 0,一个 0 都没有就宣布平局。这个逻辑放在 TryPlacePiece 的判赢分支后面,几行就写完,但很多作业都漏了它。满盘平局在期末演示里很难自然出现,你可以用 5×5 小棋盘临时测试一下。

4.2 一个会堵人的 AI:打分函数的完整实现

期末作业的 AI 不需要会“必胜开局”,也不需要蒙特卡洛树搜索。一个能用且不被一眼看穿是“人工智障”的方案是打分法:遍历所有空位,假设这个位置落黑子,评估黑方收益;再假设落白子,评估白方收益;两者按权重相加,选分最高的位置落子。打分规则可以很简单:对每个方向,数一数假设落子后形成的连子数。连子数越多分越高,能凑成五连的位置直接给最高分。

public class SimpleAI { private Board board; public SimpleAI(Board board) { this.board = board; } public Vector2Int GetBestMove(int aiPlayer, int humanPlayer) { int bestScore = -1; Vector2Int bestMove = new Vector2Int(board.size / 2, board.size / 2); for (int x = 0; x < board.size; x++) { for (int y = 0; y < board.size; y++) { if (board.cells[x, y] != 0) continue; int score = EvaluatePoint(x, y, aiPlayer, humanPlayer); if (score > bestScore) { bestScore = score; bestMove = new Vector2Int(x, y); } } } return bestMove; } private int EvaluatePoint(int x, int y, int aiPlayer, int humanPlayer) { board.cells[x, y] = aiPlayer; int aiScore = EvaluatePointForPlayer(x, y, aiPlayer); board.cells[x, y] = humanPlayer; int humanScore = EvaluatePointForPlayer(x, y, humanPlayer); board.cells[x, y] = 0; return aiScore * 10 + humanScore * 8; } }

EvaluatePoint 里有一个关键细节:它临时篡改了棋盘数组。先把自己假设成 AI 落子,统计完立刻改成假设玩家落子,统计完再恢复为 0。这保证了统计函数能被统一复用,但也要求调用方必须按顺序执行,任何一个分支提前 return 都会让棋盘上残留一颗虚拟棋子。AI 进攻分权重给到 10,防守分给到 8,意思是“差一步就赢”的进攻点会比“差一步就输”的防守点优先。想让 AI 偏防守,把 8 改成 12;想让它更激进,把 10 调高。这两个数是经验值,建议先跑几局再手调。

EvaluatePointForPlayer 的方向统计逻辑和 4.1 很像,不同点是它只统计连子数,然后映射成分数。下面这段是打分 AI 的精简版,已经能做到基本的进攻和堵眼。

private int EvaluatePointForPlayer(int x, int y, int player) { int total = 0; Vector2Int[] dirs = { new Vector2Int(1, 0), new Vector2Int(0, 1), new Vector2Int(1, 1), new Vector2Int(1, -1) }; foreach (Vector2Int dir in dirs) { int count = 1; count += CountWithLimit(x, y, dir.x, dir.y, player); count += CountWithLimit(x, y, -dir.x, -dir.y, player); if (count >= 5) total += 10000; else if (count == 4) total += 1000; else if (count == 3) total += 100; else if (count == 2) total += 10; else total += 1; } return total; } private int CountWithLimit(int x, int y, int dx, int dy, int player) { int count = 0; int nx = x + dx; int ny = y + dy; while (nx >= 0 && nx < board.size && ny >= 0 && ny < board.size) { if (board.cells[nx, ny] != player) break; count++; nx += dx; ny += dy; } return count; }

这个精简版没有区分活三和眠三,所以 AI 偶尔会走出“看着是三连其实被堵死”的废棋。想提升棋力,可以加一个两端堵头判断:CountWithLimit 停下来的位置如果在棋盘内且为空,说明这条线还活着,给额外加分。这一步不复杂,但能让 AI 从“会堵眼”变成“会做活三”,答辩体感差别很大。AI 的计算量在 15 路棋盘上很小,但别把它放在 Update 里每帧跑,玩家每落一手触发一次就够了。想在界面上做出“对手思考中”的效果,可以加 0.2 秒延迟再调用 AI 的返回函数,演示观感会好很多。

4.3 功能优先级:先保证人能赢,再考虑 AI 棋力

如果你是第一次写 AI,把 4.2 的代码接进工程后,先不要急着调权重。第一步用两个玩家轮流操作同一台电脑,手动验证判胜、悔棋、平局逻辑都正常。第二步改成玩家执黑、AI 执白,连续下三局,记录 AI 的失误:看不懂活三、防守只看眼前、开局乱下。这些失误大多不是代码 bug,而是打分规则不够细。修一两个计分规则,棋力提升比加“搜索深度”明显得多。

如果 AI 第一步总是乱下角落,可以在 GetBestMove 开头加一个特判:棋盘为空时,直接返回正中央 (7,7)。五子棋黑棋第一步下天元通常是占优下法,这个小规则能让 AI 开局像回事。棋盘上只有一颗子时,选择 (7,7) 附近一个靠近该子的位置,也比遍历打分稳定。这些前置规则不干扰后续打分逻辑,属于“经验补丁”。这一步还有个常被忽略的点:AI 打分函数里的 while 循环,一定要确认 nx += dx、ny += dy 在循环体内执行。漏掉这一行,循环永远不会前进,界面会直接卡死,这是 AI 模块里最常见的死循环来源。遇到 AI 回合卡住时,先检查 CountWithLimit 的指针有没有推进。

5. 五子棋项目避坑指南:常见报错、错位和翻车现场

这一章把最容易浪费时间的几个坑按“现象→原因→解决”列出来,都是我见过或踩过的。每条都短,但每一条都能在答辩前一晚让你崩溃。

5.1 棋盘上的点击位置和棋子落点整体偏移半格

现象:点格子的正中心,棋子却出现在格线交叉点附近;点靠近格线时,棋子直接落到隔壁格。整个棋盘像是被平移了半个格子。

原因:棋盘 Image 的尺寸设置成了 cellSize * size,而不是 cellSize * (size - 1)。15 路棋盘只有 14 段间距,Image 多出的那个格子宽度,让局部坐标换算出来的格点索引整体偏移了半格。

解决:检查棋盘 RectTransform,宽高改成 (size - 1) * cellSize,pivot 保持 (0,0)。如果用 3D 棋盘,检查平面分段数,确保每格间距为 1,而不是整张平面长度除以格子数。这个坑的隐蔽之处在于:只偏半格,大多数人第一反应是鼠标取位不对,不会想到是棋盘尺寸写错。

5.2 棋盘上叠了一个透明 Button,所有点击都失灵

现象:棋盘看起来完全正常,点击却没有反应,Console 里也没有任何输出。所有鼠标事件像被什么东西挡住了一样。

原因:Canvas 上有一个覆盖全屏的透明 Image 或 Button,Raycast Target 是勾上的,它把点击全部吞掉了。UGUI 的 Raycast 是从上往下找第一个可接收事件的对象,棋盘在最底层,永远收不到事件。

解决:把所有全屏背景层的 Raycast Target 取消勾选。尤其是“开场界面”“胜利提示面板”这类隐藏后只是 SetActive(false) 的物体,一定检查它们是否还躺着。这类 bug 查起来很玄学,因为一切看起来都没问题,实际上整个 UI 的射线都被劫持了。

5.3 横竖能赢、斜线赢不了

现象:五子棋横着竖着连成五颗能判胜,斜着连成五颗永远不触发。老师随手试一条斜线,游戏毫无反应,场面非常尴尬。

原因:CheckWin 的 directions 数组里漏了 (1,1) 或 (1,-1)。很多人记得水平垂直两个方向,忘了对角线。

解决:补上四个方向。一个完整的判胜必须包含 (1,0)、(0,1)、(1,1)、(1,-1)。这个坑我当年亲自踩过,答辩演示时横竖都赢了,老师试斜线直接翻车。从那以后,我写判胜永远先把四个方向写成数组,而不是写四个分开的 if 判断,从结构上杜绝漏项。

5.4 窗口大小一变,棋盘和棋子错位

现象:固定窗口尺寸下运行一切正常,拖拽窗口边缘或切换分辨率后,棋格坐标整体漂移,点不准格子。

原因:Canvas Scaler 的 UI Scale Mode 设置不对,或者棋盘 Image 的 anchor 不是中心。用 Overlay 模式时,如果棋盘锚点设在左上角,窗口尺寸变化后棋盘位置跟着锚点走,之前的坐标换算全部失效。

解决:把 Canvas Scaler 设为 Scale With Screen Size,参考分辨率填 1920×1080;棋盘 Image 的 anchor 和 pivot 都设成 (0.5, 0.5),让它始终居中。棋子生成位置如果用的是 boardRect.anchoredPosition 加上格子偏移,要确认 boardRect 是同一张棋盘图,而不是某个父节点。还有一点:五子棋项目不要用屏幕自适应自动改 cellSize,棋盘格距固定时,坐标换算代码最稳。

5.5 工程打开后材质紫红色、以及 Unity Hub 安装器验证失败

现象:把资源商店下载的棋盘素材拖进场景,物体变成亮紫色;或者 Unity Hub 装编辑器时报“安装失败:验证失败”,导致你还没开始写代码就在环境上耗掉一晚上。

原因:紫红色是因为材质用的 Shader 在当前渲染管线里不存在。新建工程默认是内置渲染管线,如果下载的资源来自 URP 或 HDRP 工程,Shader 找不到就显示紫红。Hub 的验证失败多半是安装目录权限、磁盘空间或网络代理问题。

解决:紫红色对期末作业最直接的解决方式是别换 Shader,把材质球 Shader 改回 Universal Render Pipeline/Lit(URP)或内置的 Standard;不想改就直接用 UGUI 的 Image 显示棋盘图片,避开 3D 材质。Hub 安装失败则改用英文路径安装,以管理员身份运行 Unity Hub,或者换一个盘符重新下载编辑器。这类环境问题占掉的时间往往比写代码还多,建议装完先建一个空工程跑通再动手,不要等作业写到一半才排查环境。

6. 最后两小时的打磨:判胜边界、演示流程和收尾

6.1 把边界情况当成测试用例来验

AI、判胜都写完,别急着打包。花半小时,用手动操作把边界全部过一遍:第一手落在棋盘四个角、四个边中点;黑棋横着赢、竖着赢、两条对角线赢;在差一手赢的局面下让 AI 防守,确认它堵对地方;连续悔棋到空棋盘,再重新开始,确认回合没乱;满盘无胜负时触发平局。这些就是测试用例。每次跑完记下结果,别凭感觉说“应该没问题”。我见过太多演示时突然不动了的作业,基本都是没做这一步。你可以不做自动化测试,但手动过一遍是底线,尤其是斜线判胜这条。

6.2 答辩演示的两分钟流程

演示不要一上来就人机对战。先把工程停在主菜单,按这个顺序走:第一步,展示棋盘绘制和 UI,把窗口拖成不同大小,说明你用 Canvas Scaler 做了适配;第二步,进入双人对战,下一两颗子就故意落一颗到已有位置,说明你做了占位校验;第三步,开始人机对战,走到 AI 堵眼的那一步,停下来用光标指出它观察到你这里要成四连,所以来堵你;第四步,制造一个必胜局面,演示判胜横幅弹出;第五步,用悔棋回退一手,说明 Stack 的入栈和弹栈。这个顺序能把每个功能都讲成你主动设计的,而不是老师问到哪你答到哪。演示时把 Console 窗口打开,用 Debug.Log 打印的落子坐标和判胜信息做辅助说明,这至少证明代码是活的。

6.3 收尾

最后说一句关于代码规整的话。期末作业不是生产项目,但至少不要把全部逻辑塞进一个几百行的 GameController。把 Board、CheckWin、SimpleAI、InputHandler 分开文件放,老师打开工程看脚本列表时,一眼就知道你会不会做模块划分。这比任何花哨特效都加分。我做这个项目时,最大的教训不是 AI 写得弱,而是斜线判胜没单独测,答辩老师一试斜线就当场翻车。后来每做一个功能,我就先想好这个功能的边界输入是什么,再动手写代码。如果你时间紧,至少把 5.3 和 6.1 这两节过一遍,这两个坑一个毁逻辑、一个毁演示。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进