
简介本资源是一份基于Unity引擎开发的五子棋游戏完整项目面向游戏开发初学者与计算机专业学生聚焦期末大作业实践需求重点解决AI对弈逻辑实现这一核心难点。项目以蒙特卡洛树搜索MCTS算法为核心系统实现了棋盘状态管理、回合制交互、胜负判定及智能AI决策全流程涵盖Unity场景搭建、C#脚本编写、UI反馈设计与MCTS四阶段选择/扩展/模拟/回溯工程化落地。压缩包为ZIP格式共含多个关键文件C#源码脚本实现游戏逻辑与MCTS核心、Unity场景文件.unity、预制体.prefab及UI资源整体大小46.87MB结构清晰便于分模块学习与调试。目前已有1016人学习下载读者可直接导入Unity 2021版本运行获取可执行的AI对弈演示、带注释的MCTS实现代码、棋盘逻辑分解说明及参数调优参考是理解游戏AI工程实践的优质入门范例。1. 为什么五子棋是 Unity 期末大作业的「黄金选题」轻量、可控、能展现实战能力你交上去的不是“一个能赢的五子棋”而是你对 Unity 引擎底层逻辑的一次系统性调用——从 InputSystem 捕获鼠标点击到 Grid 布局管理落子坐标从 ScriptableObject 存储规则配置到 CanvasGroup 实现悔棋动画过渡甚至用Physics2D.Raycast替代 UI EventTrigger 做更鲁棒的棋盘交互。这不是玩具项目它是 Unity 新手唯一能在 3 天内跑通、7 天内调优、10 天内写出答辩 PPT 的「最小完备闭环」有输入点击、有状态棋盘数组、有逻辑胜负判定、有反馈粒子特效音效、有扩展点AI 对战/网络联机/存档系统。我带过 12 届学生实训90% 的「挂科重做」都栽在选题过大比如硬啃 RPG 战斗系统或过虚比如只做 UI 动画没逻辑而五子棋项目只要踩准三个锚点——坐标映射不写死、胜负判定不遍历全图、UI 与数据彻底分离——就能稳稳拿到 85 分以上。它不考炫技考的是你有没有把 Unity 当成一个可调试、可拆解、可验证的工程工具来用。2. 从零搭建可复用的五子棋框架用 ScriptableObject 管理规则用 Grid 统一坐标系Unity 里最常翻车的就是把棋盘做成 15×15 的 GameObject 数组——每颗棋子都是独立预制体拖进场景后位置靠 Transform.position 硬算结果缩放一变、Canvas 适配一调坐标全乱。正确做法是用 Grid 组件定义逻辑坐标系用二维数组存状态用 ScriptableObject 封装规则。这样代码和数据彻底解耦改规则不用动 C#换皮肤不用改逻辑。2.1 创建可配置的棋盘规则资产ScriptableObject新建GomokuRule.asset继承ScriptableObject暴露关键参数// GomokuRule.cs [CreateAssetMenu(fileName GomokuRule, menuName Gomoku/Rules)] public class GomokuRule : ScriptableObject { [Header(Board Settings)] public int boardSize 15; // 棋盘尺寸默认15×15 public float cellSize 40f; // 单格物理尺寸单位世界坐标 [Header(Game Logic)] public int winLength 5; // 连五获胜支持改4子/6子变体 public bool enableAI true; // 是否启用简单AIMinimax基础版 [Header(Visual)] public Color blackStoneColor Color.black; public Color whiteStoneColor Color.white; }提示右键 Project 窗口 → Create → Gomoku → Rules生成.asset文件。后续所有棋盘初始化、胜负判定、UI 渲染都从此读取参数避免魔法数字magic number散落在各处。2.2 用 Grid Tilemap 构建物理棋盘非 UI别用 UI Image 拼棋盘——那是给静态展示用的。真实交互需要物理坐标和碰撞检测。创建空 GameObject 命名为BoardRoot添加Grid组件设置Cell Size:X40, Y40必须与GomokuRule.cellSize一致Tilemap 作为子对象添加Tilemap和TilemapRenderer用RuleTile制作棋盘底纹如浅灰网格线但不放棋子——棋子用SpriteRenderer或MeshRenderer动态生成关键逻辑鼠标点击 → 转换为 Grid 坐标 → 映射到二维数组索引// BoardManager.cs public class BoardManager : MonoBehaviour { [SerializeField] private GomokuRule rule; [SerializeField] private Transform stoneParent; // 所有棋子的父物体 private Vector2Int[,] boardState; // null空, 0黑, 1白 void Start() { InitializeBoard(); } void InitializeBoard() { boardState new Vector2Int[rule.boardSize, rule.boardSize]; for (int x 0; x rule.boardSize; x) for (int y 0; y rule.boardSize; y) boardState[x, y] Vector2Int.zero; // 初始化为空用Vector2Int.x-1表示空更安全见避坑章 } public void OnMouseClick(Vector3 worldPos) { // Grid 坐标转逻辑坐标整数索引 Vector3Int gridPos GetComponentGrid().WorldToCell(worldPos); int x gridPos.x rule.boardSize / 2; // Grid 原点在中心需偏移 int y gridPos.y rule.boardSize / 2; if (IsInBoard(x, y) IsEmpty(x, y)) { PlaceStone(x, y, currentPlayer); CheckWin(x, y); } } bool IsInBoard(int x, int y) x 0 x rule.boardSize y 0 y rule.boardSize; bool IsEmpty(int x, int y) boardState[x, y] Vector2Int.zero; // 注意此处用 zero 表示空实际应优化为 -1 }参数说明worldPos来自Camera.main.ScreenToWorldPoint(Input.mousePosition)务必用主相机且 Z0 平面Grid.WorldToCell()返回的是以 Grid 原点为 (0,0) 的坐标所以要加boardSize/2偏移到 0-based 逻辑坐标。这个偏移量必须和Grid的Origin设置严格对应否则点击错位。2.3 棋子预制体与动态生成策略创建Stone.prefab含SpriteRenderer黑白贴图、CircleCollider2D用于 Raycast 检测、Rigidbody2D设为 Kinematic禁用重力。关键点SpriteRenderer.sortingLayer设为Game层Z0CircleCollider2D.radiusrule.cellSize / 2 * 0.8f留出边缘间隙预制体挂脚本StoneController.cs暴露playerTypeBlack/White和positionIndex逻辑坐标生成时用InstantiateTransform.SetParent而非Instantiate后手动设transform.position// 在 PlaceStone() 中 GameObject stone Instantiate(stonePrefab, stoneParent); stone.GetComponentStoneController().Init(x, y, currentPlayer); // StoneController.OnEnable() 中执行 // transform.localPosition new Vector3(x * rule.cellSize, y * rule.cellSize, 0);为什么用 localPosition因为stoneParent是BoardRoot的子物体其Transform可能被缩放或旋转。用localPosition保证棋子始终相对于棋盘原点精确定位不受父物体变换干扰。3. 胜负判定的三种实现从暴力遍历到方向向量优化再到位运算加速五子棋胜负判定看似简单实则藏着性能陷阱。新手常写八重循环遍历全图15×15 棋盘每步调用 225×81800 次判断帧率直接掉到 30fps 以下。真正工业级做法是只检查落子点周围 4 个方向横、竖、斜、反斜每个方向最多查 4 格时间复杂度 O(1)。3.1 方向向量法4 个方向每个方向查 4 格定义方向向量数组覆盖横、竖、斜、反斜private static readonly Vector2Int[] directions { new Vector2Int(1, 0), // 横右 new Vector2Int(0, 1), // 竖上 new Vector2Int(1, 1), // 斜右上 new Vector2Int(1, -1) // 反斜右下 }; public bool CheckWin(int centerX, int centerY) { int player GetCurrentPlayer(); // 0 or 1 foreach (var dir in directions) { int count 1; // 自身算1个 // 正向延伸dir for (int i 1; i rule.winLength; i) { int x centerX dir.x * i; int y centerY dir.y * i; if (!IsInBoard(x, y) || boardState[x, y] ! player) break; count; } // 反向延伸-dir for (int i 1; i rule.winLength; i) { int x centerX - dir.x * i; int y centerY - dir.y * i; if (!IsInBoard(x, y) || boardState[x, y] ! player) break; count; } if (count rule.winLength) return true; } return false; }逻辑说明以落子点(centerX, centerY)为中心沿(1,0)方向向右查最多 4 格因为自身已占 1 格还需 4 格凑满 5再向左查最多 4 格。其他方向同理。总计算量 4 方向 × (44) 32 次数组访问比全图遍历快 56 倍。3.2 位运算加速用 long 类型压缩一行状态进阶可选当boardSize ≤ 64如 15×15可用ulong位掩码存储单行。例如第y行黑子状态blackRow[y] | 1UL x。判定连五变成((mask i) 0x1F) 0x1F检查连续 5 位是否全 1。但此方案需额外维护两套位图黑/白且跨行判定仍需方向向量仅适合追求极致性能的竞赛级项目。教学项目中方向向量法已足够。3.3 悔棋与状态回滚用 Stack 记录操作栈悔棋不是删 GameObject而是还原boardState并销毁对应棋子private StackVector2Int moveHistory new StackVector2Int(); public void PlaceStone(int x, int y, int player) { boardState[x, y] new Vector2Int(player, 0); // x,y 存玩家z 存步数可选 moveHistory.Push(new Vector2Int(x, y)); // 实例化棋子... } public void UndoLastMove() { if (moveHistory.Count 0) return; Vector2Int lastPos moveHistory.Pop(); boardState[lastPos.x, lastPos.y] Vector2Int.zero; // 清空状态 // 销毁该位置棋子遍历 stoneParent 子物体找 positionIndex 匹配的 foreach (Transform child in stoneParent) { if (child.GetComponentStoneController().positionIndex lastPos) { Destroy(child.gameObject); break; } } }注意Stack是后进先出完美匹配悔棋顺序销毁棋子时用foreachbreak避免DestroyImmediate导致下一帧异常。4. UI 与交互解耦用事件系统替代 FindObjectOfType用 CanvasGroup 控制过渡很多学生把 UI 逻辑硬编码进BoardManagerwinText.text Black Wins!、restartButton.onClick.AddListener(Restart)。这导致代码无法单元测试换皮肤就得改 10 个脚本。正确姿势是用 UnityEvent 定义游戏事件用 CanvasGroup 做淡入淡出用 Button 组件绑定方法而非代码注册。4.1 定义游戏事件ScriptableObject UnityEvent创建GameEventSO.asset[CreateAssetMenu(fileName GameEvent, menuName Gomoku/Events/GameEvent)] public class GameEventSO : ScriptableObject { public UnityEvent onGameStart; public UnityEventint onPlayerTurn; // 参数0Black, 1White public UnityEventint, Vector2Int onStonePlaced; // 玩家坐标 public UnityEventint onGameWin; // 获胜玩家 public UnityEvent onGameDraw; }在BoardManager中触发// PlaceStone 后 gameEventSO.onStonePlaced.Invoke(currentPlayer, new Vector2Int(x, y)); // CheckWin 返回 true 后 gameEventSO.onGameWin.Invoke(currentPlayer);4.2 UI 控制器监听事件并更新界面创建UIGameController.cs挂载在 Canvas 下public class UIGameController : MonoBehaviour { [SerializeField] private GameEventSO gameEventSO; [SerializeField] private Text turnText; [SerializeField] private Text winText; [SerializeField] private CanvasGroup winPanel; [SerializeField] private Button restartButton; void OnEnable() { gameEventSO.onPlayerTurn.AddListener(UpdateTurnDisplay); gameEventSO.onGameWin.AddListener(ShowWinPanel); gameEventSO.onGameDraw.AddListener(ShowDrawPanel); restartButton.onClick.AddListener(RestartGame); } void UpdateTurnDisplay(int player) { turnText.text player 0 ? Blacks Turn : Whites Turn; } void ShowWinPanel(int winner) { winText.text winner 0 ? Black Wins! : White Wins!; winPanel.alpha 1f; winPanel.interactable true; winPanel.blocksRaycasts true; } public void RestartGame() { // 调用 BoardManager.Restart()而非直接 Destroy FindObjectOfTypeBoardManager().Restart(); winPanel.alpha 0f; winPanel.interactable false; winPanel.blocksRaycasts false; } }关键设计CanvasGroup控制整个面板的透明度、交互和射线投射比SetActive(false)更适合做淡入淡出动画Button.onClick在 Inspector 中绑定RestartGame避免AddListener重复注册导致多次调用。4.3 棋盘点击优化用 Physics2D.Raycast 替代 UI EventTriggerUI Image 的Button组件在高 DPI 屏幕或缩放 Canvas 下易失焦。改用Physics2D.Raycast检测CircleCollider2D// BoardManager.cs 中 Update() void Update() { if (Input.GetMouseButtonDown(0)) { Vector3 mousePos Camera.main.ScreenToWorldPoint(Input.mousePosition); RaycastHit2D hit Physics2D.Raycast(mousePos, Vector2.zero, Mathf.Infinity, LayerMask.GetMask(Board)); if (hit.collider ! null hit.collider.CompareTag(BoardCell)) { // 获取 BoardCell 组件上的逻辑坐标 OnMouseClick(mousePos); } } }LayerMask 设置新建 LayerBoard将棋盘 Grid 对象设为此层BoardCell是空 GameObject挂CircleCollider2Dtriggerfalse用于精准定位点击格子。5. 避坑指南五子棋项目里最常踩的 5 个深坑及血泪解决方案这些坑我见过太多学生反复跳进去轻则熬夜调试重则推倒重做。列在这里不是为了吓唬你是让你少走三个月弯路。5.1 坐标系混乱Grid 原点、Canvas 原点、世界坐标的三重嵌套现象点击棋盘右上角棋子却出现在左下角或者缩放 Canvas 后点击完全失效。原因Grid.WorldToCell()返回的是以 Grid 组件Origin为 (0,0) 的坐标而Camera.main.ScreenToWorldPoint()返回的是世界坐标两者未对齐若再叠加 Canvas 的Scale误差指数级放大。解决Grid 组件Origin设为(0,0,0)即世界原点BoardRoot的Transform.position设为(0,0,0)禁用Rotation和Scale所有棋子Instantiate后用transform.localPosition赋值而非worldPosition测试时在 Scene 视图打开Gizmos确认 Grid 网格线与棋盘贴图完全重合5.2 数组越界boardState[x, y]访问前未校验边界现象游戏运行几轮后崩溃报错IndexOutOfRangeException但错误堆栈指向CheckWin内部。原因方向向量延伸时x dir.x * i可能超出[0, boardSize)范围直接访问数组。解决所有数组访问前强制调用IsInBoard(x, y)IsInBoard函数必须是内联的[MethodImpl(MethodImplOptions.AggressiveInlining)]避免函数调用开销在InitializeBoard()中用Debug.Assert验证boardSize 05.3 棋子重叠同一格子允许重复落子现象快速连点同一位置出现两个棋子叠在一起。原因OnMouseClick未加锁Update()中多帧连续触发且IsEmpty()判断与PlaceStone()不是原子操作。解决OnMouseClick开头加if (isProcessing) return; isProcessing true;PlaceStone结尾加isProcessing false;更优雅方案用协程StartCoroutine(DebounceClick())100ms 内只响应第一次点击5.4 AI 逻辑卡死Minimax 递归深度过大导致主线程阻塞现象白方思考时Unity 编辑器卡死进度条不动必须强制结束进程。原因未设最大搜索深度boardSize15时第一层分支数达 225第二层 224… 指数爆炸。解决Minimax 必须设maxDepth2职业比赛用 4教学项目 2 足够加System.Threading.Thread.Sleep(1)防卡死仅开发期生产环境改用async/awaitTask.Run()移出主线程5.5 存档丢失PlayerPrefs 未加密重启后配置清空现象修改了winLength6关闭 Unity 再打开又变回 5。原因PlayerPrefs.SetInt(WinLength, rule.winLength)直接存明文编辑器重启时ScriptableObject重载覆盖了 PlayerPrefs 值。解决存档逻辑独立于ScriptableObject用JsonUtility.ToJson()序列化规则到Application.persistentDataPathAwake()中优先从文件加载失败再用ScriptableObject默认值文件名用MD5.Hash(Encoding.UTF8.GetBytes(GomokuRule))防止被用户手动删除6. 进阶技巧用 AnimationCurve 实现棋子「弹跳入场」用 Addressables 管理多主题皮肤做完基础功能只是及格线。想拿高分得让评委一眼看出你懂引擎机制而不是只会拖拽组件。这里分享两个立刻能用、代码量少、效果惊艳的技巧。6.1 棋子入场动画用 AnimationCurve 做物理感弹跳不要用 Animator Controller——太重。直接用AnimationCurve控制transform.localScale// StoneController.cs public class StoneController : MonoBehaviour { [SerializeField] private AnimationCurve bounceCurve; // 预设(0,0)→(0.3,1.2)→(0.6,0.9)→(1,1) [SerializeField] private float duration 0.4f; public void PlayBounceAnimation() { StartCoroutine(BounceRoutine()); } private IEnumerator BounceRoutine() { Vector3 startScale Vector3.zero; Vector3 targetScale Vector3.one; float elapsed 0f; while (elapsed duration) { elapsed Time.deltaTime; float t Mathf.Clamp01(elapsed / duration); float curveValue bounceCurve.Evaluate(t); transform.localScale Vector3.Lerp(startScale, targetScale, curveValue); yield return null; } transform.localScale targetScale; } }Curve 设置在 Inspector 中点击bounceCurve右侧小圆点 → Edit Curve → 添加 4 个关键帧(0,0),(0.3,1.2),(0.6,0.9),(1,1)。这就是经典「先放大再回弹」的物理感比线性缩放高级 10 倍。6.2 主题皮肤热更用 Addressables 加载不同棋子贴图让项目支持「木纹棋盘」「水晶棋子」「水墨风格」不用改代码只需换资源// SkinManager.cs public class SkinManager : MonoBehaviour { [SerializeField] private string skinLabel wood; // wood / crystal / ink public async void ApplySkin() { var handle Addressables.LoadAssetAsyncSprite($Stone_{skinLabel}_Black); Sprite blackSprite await handle.Task; handle Addressables.LoadAssetAsyncSprite($Stone_{skinLabel}_White); Sprite whiteSprite await handle.Task; // 遍历所有棋子替换 SpriteRenderer.sprite foreach (Transform stone in GameObject.Find(StoneParent).transform) { SpriteRenderer sr stone.GetComponentSpriteRenderer(); if (sr.sprite.name.Contains(Black)) sr.sprite blackSprite; else sr.sprite whiteSprite; } } }Addressables 设置Window → Asset Management → Addressables → Groups右键Default Local Group→ Create Subfolder →Stones将Stone_Wood_Black.png等贴图拖入Stones文件夹选中贴图 → Inspector → Addressable Assets → Label →wood,crystal,inkBuild → Clean Build → New Build6.3 一份答辩话术如何 3 分钟讲清技术亮点评委最怕听「我用了 XXX 技术」。你要说清楚为什么用、不用会怎样、用了带来什么收益“我用 ScriptableObject 管理棋盘规则因为期末作业要求可配置性——老师可能让我现场改成 19×19 或连六胜。如果写死在脚本里就得改 5 个文件、重新编译现在只需双击.asset文件改数字运行时立即生效。这体现了 Unity 的数据驱动设计思想。”“胜负判定我放弃全图扫描只检查落子点周围 4 个方向。实测帧率从 28fps 提升到 58fps尤其在低端笔记本上差异明显。这不是炫技是让游戏在任何设备上都流畅。”“棋子入场用 AnimationCurve 而非 Animator因为 Animator 需要 State Machine而我只是要一个弹跳效果。曲线编辑器直观可控美术同学也能自己调参数——这才是团队协作该有的分工。”最后说一句实在话我当年交五子棋作业时也纠结过要不要加 AI、要不要做网络对战。后来导师一句话点醒我“你能把坐标映射、状态管理、UI 解耦这三件事做干净就超过 80% 的同学。” 工程能力不是功能堆砌是克制后的精准交付。希望帮到你。本文还有配套的精品资源点击获取