ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity五子棋开发实战:从数据层到UGUI的完整实现与避坑指南

Unity五子棋开发实战:从数据层到UGUI的完整实现与避坑指南 简介基于Unity的五子棋游戏项目面向期末大作业开发场景并以蒙特卡洛树搜索MCTS作为AI决策核心。项目完整覆盖场景搭建、棋盘状态管理、鼠标点击交互、胜负判定等关键环节同时详细拆解MCTS的选择、扩展、模拟、回溯四个阶段从零说明如何构建具备博弈能力的电脑对手。压缩包约46.87MB主体为Unity工程与C#脚本内置棋盘逻辑与AI搜索模块可直接加载运行也便于按需改造。已有1016人学习下载特别适合课程设计、期末答辩或入门游戏AI的开发者。通过本项目可以掌握五子棋规则实现、MCTS参数调节与剪枝优化思路并学会在Unity中组织代码结构是一份兼具教学性与实用性的完整参考。1. 期末大作业选五子棋为什么它是 Unity 里性价比最高的入门项目把 Unity 期末大作业的题目定成五子棋最直接的理由不是它简单而是它的“小”。小意味着你能在两三周里走完一条完整游戏链路棋局数据怎么存、点击怎么响应、胜负怎么判、界面怎么刷新、结果怎么展示。我见过不少同学选了带物理、带角色控制的大题目最后一周全在调碰撞体和动画状态机而五子棋把这类不确定性直接砍掉。它适合那些 Unity 基础还没完全夯实、但需要交一份能当场演示、答辩不怕被追问的作业的人。别小看这个题目赢棋之后还能继续落子、棋盘在手机上对不齐、打包后贴图变成紫红色这些翻车现场我在真实项目里都遇到过。这篇会把最容易踩坑的地方一个个拆开讲清楚。2. 先写规则还是先搭界面数据层与表现层分离让代码在答辩时扛得住追问很多五子棋教程上来就建一堆按钮和 Image等逻辑写不下去了再回头重构。我一般反过来先把棋盘的数据结构和规则写完再考虑任何 UI。这样做不是为了炫技而是因为期末作业的验收重点是“你说得清楚自己的代码”而数据层和表现层分离是所有解释里最容易讲明白的一种结构。2.1 棋盘数据用什么结构二维数组、一维数组还是对象列表五子棋棋盘固定是 15×15一共 225 个交叉点。数据模型可以选二维数组、一维数组、或者 List 。我的建议是二维数组配合一个枚举// GameData.cs —— 纯逻辑层不引用任何 UnityEngine 组件 public enum CellState { Empty 0, Black 1, White 2 } public class GameData { public const int BoardSize 15; public CellState[,] Board { get; private set; } public CellState CurrentPlayer { get; private set; } CellState.Black; public bool GameOver { get; private set; } public int MoveCount { get; private set; } public GameData() { Board new CellState[BoardSize, BoardSize]; } public bool TryPlaceStone(int x, int y) { if (x 0 || x BoardSize || y 0 || y BoardSize) return false; if (Board[x, y] ! CellState.Empty) return false; if (GameOver) return false; Board[x, y] CurrentPlayer; MoveCount; return true; } public void SwitchPlayer() { CurrentPlayer CurrentPlayer CellState.Black ? CellState.White : CellState.Black; } }这段代码刻意把“放棋子的合法性”全部收敛在GameData里越界、重复落子、游戏已结束三种非法情况都在TryPlaceStone里直接返回 false。这样 UI 层不需要自己去判断边界答辩时你可以直接说“所有规则相关的分支都在这个类里一眼数得清”。用二维数组而不是对象列表的原因有两个。第一是查重复杂度数组按下标访问是 O(1)List 每次落子前要遍历已有棋子虽然 225 格的量级差异不大但逻辑上更容易出错。第二是判定的便利性后面写连珠检测时Board[x dx, y dy]这种步进操作天然贴合二维数组。一维数组虽然能省一点内存但需要自己换算x * 15 y下标算错的概率远高于二维数组。2.2 回合轮转与赢后锁盘规则放控制器还是数据类里棋盘数组建好了谁来调TryPlaceStone常见做法是加一个GameController它挂在场景空物体上负责把“玩家点了格子”翻译成“棋局状态变化”。代码里我习惯让TryPlaceStone返回 bool再用一个TryPlayerMove包一层胜负和平局的判断// GameController.cs —— 挂在场景空物体上只负责流程不直接碰 UI using UnityEngine; public class GameController : MonoBehaviour { private GameData _data; public GameData Data _data; private void Start() { _data new GameData(); } public bool TryPlayerMove(int x, int y) { // 第一步数据层校验并落子 if (!_data.TryPlaceStone(x, y)) return false; // 第二步判定当前玩家是否因此获胜 if (WinChecker.Check(_data.Board, x, y, _data.CurrentPlayer)) { EndGame(); return true; } // 第三步判定是否下满整盘下满且没人赢就是平局 if (_data.MoveCount GameData.BoardSize * GameData.BoardSize) { EndGame(); return true; } // 第四步正常换手 _data.SwitchPlayer(); return true; } private void EndGame() { _data.GameOver true; // 这里可以向外发一个事件View 层自己决定怎么弹窗 // 比如public event System.ActionCellState GameEnded; } }这一层最关键的是“赢后锁盘”。GameData.GameOver一旦置真TryPlaceStone会拒绝所有后续落子从根上杜绝“赢了还能继续下”的尴尬。注意TryPlayerMove里判断平局用的是MoveCount 225不是 225因为万一有重复调用能兜住异常状态。有人会觉得把GameOver判定放在 Controller 而不是数据类里不够“纯粹”但我的取舍标准很简单GameData只保证单个棋子的合法性GameController负责一场游戏的流程。判胜、判平、换手都属于流程留在 Controller 里答辩时顺着方法的四步就能把整个游戏规则讲完。2.3 为什么很多教程把数组写在 MonoBehaviour 里我却要拆一层纯 C# 类搜 unity 操作教程会发现大量五子棋 Demo 直接把棋盘数组定义在继承 MonoBehaviour 的脚本里然后在 Update 里轮询。这种写法不是不能用但它把两个完全不同的问题搅在一起数组是“数据”MonoBehaviour 的 Update 是“表现”。拆开之后最明显的好处是调试。游戏里每一局的状态都独立在一个GameData实例里你想写自动化测试直接 new 一个GameData就能跑不需要场景、不需要相机、不需要任何 GameObject。另一个好处是存档或者回放的时候不用动 UI。GameData是纯 C# 类用 JsonUtility 或者自己写序列化都容易如果数组散落在 MonoBehaviour 里每次读档都得从场景里捞对象。期末项目到不了这么复杂但“以后想加功能不用推倒重来”这个理由在答辩时比“我用了 MVC”更能说明你的工程意识。3. 把棋盘搬到屏幕上UGUI 动态生成 15 路格子与点击坐标换算数据层写完了接下来才是界面。五子棋的界面包含两件事格子怎么铺出来以及点击位置怎么换算成格子坐标。这一章我用 UGUI 来完成这是 Unity 入门最通用、打包到手机也不会出问题的方案。3.1 用代码生成 15×15 个格子而不是手摆 225 个预制体Unity 资源商店里能下到不少棋盘素材但手摆 225 个按钮既费时间又没法批量改。我的做法是写一段生成脚本运行时把格子逐个实例化到棋盘根节点下并让每个格子左下角与棋盘根节点对齐// BoardView.cs —— 挂在 Canvas 下的 Board 空物体上负责生成格子 using UnityEngine; using UnityEngine.UI; public class BoardView : MonoBehaviour { [SerializeField] private RectTransform boardRoot; [SerializeField] private GameObject cellPrefab; [SerializeField] private GameController controller; private const int BoardSize 15; private Image[,] _cellImages; private float _cellSize; private void Awake() { BuildBoard(); } private void BuildBoard() { _cellImages new Image[BoardSize, BoardSize]; Rect rect boardRoot.rect; // 棋盘显示尺寸取短边保证是正方形 _cellSize Mathf.Min(rect.width, rect.height) / BoardSize; for (int x 0; x BoardSize; x) { for (int y 0; y BoardSize; y) { GameObject cell Instantiate(cellPrefab, boardRoot); RectTransform rt cell.GetComponentRectTransform(); // 关键点所有格子锚点统一归零pivot也归零 rt.anchorMin Vector2.zero; rt.anchorMax Vector2.zero; rt.pivot Vector2.zero; rt.sizeDelta new Vector2(_cellSize, _cellSize); rt.anchoredPosition new Vector2(x * _cellSize, y * _cellSize); int capturedX x, capturedY y; cell.GetComponentButton().onClick.AddListener(() { controller.TryPlayerMove(capturedX, capturedY); }); _cellImages[x, y] cell.GetComponentImage(); } } } }这里的核心是锚点与 pivot 全部归零。把anchorMin/Max都设成 0格子的定位就变成“以 boardRoot 左下角为原点的绝对坐标”这样x * _cellSize和y * _cellSize正好对应GameData.Board[x, y]不会再夹杂锚点偏移。capturedX和capturedY两个局部变量是必须的闭包捕获循环变量在 C# 里是个经典坑直接写x会造成所有按钮都回调最后一组坐标。3.2 点击后的坐标换算从像素坐标回到格子坐标用上面的方案时每个格子自己就是一个 Button坐标已经通过闭包正确绑定不需要再做换算。但如果你的棋盘是“一整张底图射线检测”或者你想用鼠标位置判断落在哪个格子上就需要把屏幕坐标转成格子坐标// BoardClickHandler.cs —— 可选方案整板底图 点击坐标换算 using UnityEngine; using UnityEngine.UI; public class BoardClickHandler : MonoBehaviour { [SerializeField] private RectTransform boardRect; [SerializeField] private float cellSize; [SerializeField] private GameController controller; public void OnBoardClicked(Vector2 screenPos) { // 把屏幕坐标转成相对于 boardRect 的局部坐标 RectTransformUtility.ScreenPointToLocalPointInRectangle( boardRect, screenPos, null, out Vector2 localPos); int x Mathf.FloorToInt(localPos.x / cellSize); int y Mathf.FloorToInt(localPos.y / cellSize); if (x 0 x GameData.BoardSize y 0 y GameData.BoardSize) { controller.TryPlayerMove(x, y); } } }ScreenPointToLocalPointInRectangle的第三个参数传 null 有一个前提Canvas 的 Render Mode 必须是 Screen Space Overlay。如果你把 Canvas 改成了 Screen Space Camera 或 World Space这里必须传入对应的 Camera否则坐标会偏出一个身位。这是常见错误里最隐蔽的一个先记住这个前提。FloorToInt而不是直接强转 int是因为局部坐标可能带小数点负数小数强转会向零取整比如 -0.3 变成 0恰好落进格子里而FloorToInt会得到 -1既不会被数组越界逻辑误收也能被下面的边界判断拦下来。3.3 棋子实例化与状态刷新别让“点一格生成一个对象”变成性能黑匣子格子生成好之后棋子表现层有个容易被忽略的选择每次落子都Instantiate一个棋子对象还是预先准备好黑白两种 Image 来回切换我习惯用第二种。方法是在BuildBoard里给每个格子 Image 的sprite预设为空、黑子、白子三张图落子时只改sprite和color。实例化方案的问题在于对象数量会随对局线性增长最后全场的棋子对象全部堆在场景里又不像格子那样可以复用。虽然 225 个对象不至于让 Unity 卡死但在手机上每多铺一层 UI 对象绘制开销就多一截讲 unity 游戏优化时这也算得上一个可说的点。用 Image 切换方案整个棋盘的 UI 对象数从一开始就是 225 个固定住不会因为下棋时间变长而增加。至于棋子的落子动画比如“棋子从半空落下”或“轻轻弹一下”可以在设置 sprite 时同时给Image.transform.localScale赋一个初始值再用DOTween或协程把它推回 1。期末项目里这类表现能加就加不加也不影响功能但加的时候一定要记住动画结束后必须把localScale归位否则棋子在下次刷新时残留一个小缩放视觉上就是黑匣子式的“莫名其妙变小了”。4. 胜负判定怎么写才对四方向连珠检测与平局边界胜负判定是五子棋逻辑层的核心。最笨的写法是每次落子后全盘扫描找出所有连在一起的同色棋子性能虽然能接受但代码绕且容易漏。标准做法是从当前落子点出发往四个方向数连子。4.1 胜利判定怎么扫从落子点向四个方向延伸五子棋的胜利条件是“横、竖、撇、捺四个方向任一方向出现连续 5 颗同色棋子”。从刚才落下的那颗棋开始向某个方向的正方向数和反方向数加起来就是该方向的总连子数。这样不需要遍历全盘每次判定最多扫 8 个方向步进计算量恒定// WinChecker.cs —— 静态判定类不依赖组件 public static class WinChecker { private static readonly (int dx, int dy)[] Directions { (1, 0), // 水平方向 (0, 1), // 垂直方向 (1, 1), // 对角线方向 (1, -1) // 反对角线方向 }; public static bool Check(CellState[,] board, int x, int y, CellState player) { if (player CellState.Empty) return false; foreach (var (dx, dy) in Directions) { int count 1; // 当前落点本身算一个 count CountInDir(board, x, y, dx, dy, player); count CountInDir(board, x, y, -dx, -dy, player); if (count 5) return true; } return false; } private static int CountInDir(CellState[,] board, int x, int y, int dx, int dy, CellState player) { int size board.GetLength(0); int count 0; int cx x dx, cy y dy; while (cx 0 cx size cy 0 cy size board[cx, cy] player) { count; cx dx; cy dy; } return count; } }注意 Directions 只写了四个方向而不是八个水平方向的正方向和反方向合起来才是完整的横向连子。如果你写了八个方向再两两相加很容易把同一条线上的棋子重复计数比如正东方向数三颗、反向向西又数三颗最后同一行加出六颗误判成胜利。count的初始值是 1因为起点那颗棋子也是连珠的一部分这是新手最容易漏掉的地方。边界检查用的是cx 0 cx size而不是 size数组最大的合法下标是 14写成就会越界Unity 会直接红字报错这属于最基础但高频的数组边界翻车点。4.2 长连、禁手与平局三条边界条件答辩能不能讲清楚五子棋规则里有一个老师喜欢追问的点超过五颗的“长连”算不算赢。我的处理是count 5也就是长连也算赢。这是国内民间对局最常见的约定实现也最简单。如果你在答辩时说“我实现了标准比赛规则”那就必须去处理黑棋的“三三禁手、四四禁手、长连禁手”这个复杂度会直接吞噬前面所有结构上的优势所以一般建议做自由规则并在 README 里写明“本实现不做禁手判断”。平局边界比长连更容易漏。棋盘 225 个空位全部落满但没人连成五子就是平局前面GameController里已经写了MoveCount BoardSize * BoardSize的判断。但要注意一个隐藏问题如果代码里只判断“当前落子后是否胜利”没判断“当前落子后是否满盘”就会出现“最后一步恰好成型五连被判定胜利但其实早在半手前就该结束”的状态残留这种 bug 写单元测试也难复现属于典型的时序坑。另一个值得提的是“先判胜还是先判满盘”。正确顺序是先判胜再判满盘如果最后一步同时满足了胜利和满盘结果应该是胜利而不是平局。这个顺序在TryPlayerMove里是自然满足的但如果你把判定拆到 Update 里轮询就很容易把顺序弄反。4.3 提前判断改成“每次落子都算一次”判定放 Update 里是坏味道线上资源里有些 Demo 会把胜负判定放在 Update 里每帧全盘扫描理由是“省得在想落子的时候忘记调用”。这套逻辑在期末作业里最大的问题是没法解释清楚而且会出现重复判定下一颗棋触发一次接下来每一帧还会再触发一次你就要额外做“只触发一次”的锁。这就是我为什么坚持在GameController.TryPlayerMove里同步调用WinChecker.Check让判定刚好发生在落子这个时间点。如果需要给 AI 让路或者做“回放复盘”功能可以用事件把判胜结果广播出去而不是在 Update 里轮询。也就是说数据层和规则层依然是同步调用只有视图层才通过事件来响应保持“统一入口 统一出口”。我在真实项目里见过因为 Update 轮询导致界面弹了三次胜利框的情况复盘时发现根源就是这里。5. 避坑五子棋小项目翻车率最高的五个细节这一章是血泪经验汇总。现象、原因、解决一条条列出来都是期末答辩现场真实出现过的翻车。5.1 赢了之后棋盘还能继续落子现象胜负对话框已经弹出来玩家继续点格子棋子上还能再叠一颗换手后之前赢家反而有可能输。原因WinChecker判定成功后只更新了 UI没有在数据层把GameOver置真。UI 层弹窗归弹窗数据层还认为游戏没结束后面所有落子都通过了合法性检查。解决把GameOver的写入放到GameData.TryPlaceStone的前置检查里并在TryPlayerMove胜利分支末尾统一置真。最简单的验证方式弹窗后连点五手如果棋盘纹丝不动就说明锁盘生效了。注意要在编辑器里测试不要只打包后测因为编辑器重编译时有时候逻辑状态会被重置掩盖问题。5.2 打包后棋盘变成紫红色现象编辑器里运行一切正常Build 出包后棋盘和棋子全是紫红色块完全没法看。原因紫红色是 Unity 在运行时找不到可用材质/着色器时的默认显示。常见于两类情况一是项目从默认渲染管线切到了 URP 或 HDRP棋盘用的材质还是内置 Standard 着色器编辑器里预览正常打包时着色器没被包含进去二是动态加载的 Build 中资源没保留图集引用断了。解决五子棋这种 UI 密集型项目不要让任何棋子或格子使用自定义材质全用 UGUI 的 Image 组件。Image 的材质由 Canvas 统一管理不涉及单独着色器天然避开紫红问题。如果场景里确实有自定义渲染目标就去 Graphics Settings 里的 Always Included Shaders 把用到的着色器预挂上。紫红色 bug 排查起来非常浪费时间因为编辑器里复现不出来最有效的预防就是不引入自定义材质。5.3 同一套 UI 在不同分辨率下点击对不上现象编辑器 Game 视图设成 1920×1080 一切正常Build 到手机上棋盘位置完全变了点击左上角却落在右下角。原因Canvas 的渲染模式是 Screen Space Overlay默认按屏幕像素坐标工作场景里的 CanvasScaler 如果是 Constant Pixel Size手机分辨率一变UI 元素绝对尺寸不变但屏幕比例变了棋盘就不再是居中的正方形。我在前面章节没强调是因为假设 Canvas 用默认设置实际打包必须显式处理。解决在 Canvas 上挂 CanvasScaler把 UI Scale Mode 设为 Scale With Screen Size参考分辨率填你设计时的 Game 视图大小Match 值设 0.5棋盘根节点 boardRoot 的四个锚点都设为中代码里用rect.width和rect.height动态计算格子大小而不是写死像素值。千万别用Screen.width和Screen.height去算格子尺寸这两个值只在运行时可靠编辑器预览会骗人。5.4 斜线五子判不出来靠边的五子也判不出来现象横着、竖着连五颗能弹胜利斜着连五颗毫无反应在棋盘边缘连五颗偶尔判不出来。原因WinChecker 里 Directions 数组只实现了(1,0)和(0,1)两个方向漏掉了两条对角线或者CountInDir里越界条件写错了比如用cx size而不是cx size数组越界的异常被静默吞掉后循环提前退出。边缘连子判不出来的常见原因是循环里从 0 开始计数把起点棋子算丢了。解决对照 4.1 的代码检查 Directions 是否四个方向齐全CountInDir的循环条件是否严格小于 size。如果不想对照代码我有个暴力验证法把棋盘 State 临时设成已知局面然后逐格调用 WinChecker 打印结果肉眼核对边界。这个方法慢但一定能暴露方向漏算的问题。5.5 演示手滑落错子没有后悔药现象答辩现场鼠标一抖棋子落在隔壁格子上整盘棋直接废了没保存也没法重来。原因这个不算代码 bug但比任何 bug 都影响演示效果。数据层只支持落子没有回退链路落错一步就全盘崩溃。解决加一个后悔药用栈记录每步落子的坐标和玩家撤消时弹栈清空。常见做法是把栈放进GameData// GameData.cs 追加的悔棋支持 using System.Collections.Generic; private Stack(int x, int y, CellState player) _history new Stack(int, int, CellState)(); // TryPlaceStone 成功落子时追加 // _history.Push((x, y, CurrentPlayer)); public bool TryUndo() { if (_history.Count 0) return false; var last _history.Pop(); Board[last.x, last.y] CellState.Empty; CurrentPlayer last.player; // 把下棋权还给悔棋的人 MoveCount--; return true; }注意CurrentPlayer last.player这一步它让悔棋方同时拿回下棋权。如果只清了棋子没管 CurrentPlayer就会出现“黑棋悔了一步结果变成白棋继续下”的怪象。这条规则看起来简单实际很容易忽略。答辩时主动演示一次悔棋比被老师追问“如果没有悔棋怎么办”强得多。6. 收尾前花两小时验证用自动化脚本跑一百局再决定要不要加 AI功能写完到提交之间最重要的一件事是验证。人的手点一百局既不可能也不严谨所以我习惯写一个极简的自动对弈脚本让逻辑层自己跟自己下跑完一百局统计胜平负确认没有隐藏的崩溃。6.1 自动对弈一百局不写单元测试也能验证胜负逻辑在场景里放一个空物体挂一个测试脚本按 F8 开始自动落子// AutoPlayTester.cs —— 测试专用提交前禁用或移除 using System.Collections; using UnityEngine; public class AutoPlayTester : MonoBehaviour { [SerializeField] private GameController controller; private void Update() { if (Input.GetKeyDown(KeyCode.F8)) { StartCoroutine(RunAutoPlay()); } } private IEnumerator RunAutoPlay() { int round 0; while (round 100 !controller.Data.GameOver) { // 随机找空位最多试 50 次 int attempts 0; while (attempts 50) { int x Random.Range(0, GameData.BoardSize); int y Random.Range(0, GameData.BoardSize); if (controller.TryPlayerMove(x, y)) { break; } attempts; } // 每次落子后等一帧让 UI 刷新 yield return new WaitForSeconds(0.02f); } } }这段脚本验证的是三件事第一一百局内没有越界异常第二游戏结束时GameOver状态正确置真第三每局都在有限步数内结束。如果跑的过程中 Unity 控制台出现红色报错或者controller.Data.GameOver一直为假导致 while 循环冲到一百局就说明 WinChecker 或平局判断有漏。测完记得把 AutoPlayTester 脚本从场景里移除否则答辩时按到 F8 会闹笑话。6.2 再往后加 AI 的三条路从评分表到搜索树如果验证通过你还想给自己的五子棋加 AI 抬高整体印象分最稳妥的路线是加一个局面评分函数。对每个空位分别给“黑子连成五、活四、冲四、活三”赋予不同分数取最高分落子这能在不引入搜索树的情况下让 AI 看起来有基本战斗力。第二步是负极大值搜索加 Alpha-Beta 剪枝深度控制在 2 到 4搜索时间压制在零点几秒内。第三步才是考虑用现成的开源五子棋算法库但要自己提前确认授权和打包兼容性。比起把时间花在 AI 上我更想提醒你把精力放在“演示路径的稳定性”上。我自己的习惯是交作业前把场景里所有测试脚本禁用重新从 Unity Hub 打开一遍工程确认摄像机角度、棋盘位置、按钮响应都正常再 Build 一个手机包。等这些都稳了AI 和联机都是可以慢慢加的加分项基础闭环才是最不该缩水的部分。希望这篇能帮你在期末周少走几步弯路把五子棋这个题目真正做成一个说得清、稳得住、不少演的项目。本文还有配套的精品资源点击获取
返回列表