
简介这是一份面向Unity初学者与游戏开发进阶者的围棋逻辑实现源码聚焦于核心规则落地特别适合学习策略类棋类游戏开发、C#算法设计及Unity事件驱动编程。资源完整实现了19×19标准棋盘下的落子、气值计算与提子判定算法解决了围棋中“无气即提”这一关键逻辑难点并通过模块化C#脚本组织便于理解状态管理与递归/遍历判断逻辑。压缩包为RAR格式共含多个场景文件、脚本.cs、预制体.prefab及UI配置资源整体大小18.78MB结构清晰适合作为教学案例或二次开发基础。已有748人学习下载读者可直接导入Unity 2021版本运行调试获取可验证的提子逻辑实现、棋盘交互响应机制及基础UI反馈体系快速掌握从规则建模到引擎落地的完整链路。1. 这不是“画个棋盘点一下落子”的 Unity 围棋——提子逻辑不闭环整盘棋就崩在第 3 手你试过在 Unity 里写一个 19×19 的围棋逻辑吗很多人卡在第一步点击坐标转棋盘格子没问题落子渲染也 OK但第三步——对方围住你两颗子该不该提提哪几颗提完后被围的空点是否立刻变成新气如果角上三颗黑子被白子从三面堵死第四面是边界算不算“无气”——这些不是 UI 交互动画能糊弄过去的而是必须用可验证、可回溯、可单步调试的 C# 状态机来硬刚的底层规则。本项目源码标题中明确标注04是围棋的核心价值正在于它把「气」的动态计算、连通域遍历、提子触发链、以及提子后的连锁反应全部封装进GoRuleEngine.cs和StoneGroup.cs两个类中且所有判断都基于坐标邻接 气数缓存 递归标记不依赖 GameObject 层级或 Renderer 状态。它适合两类人一是想用真实围棋规则做 AI 对战训练环境的开发者比如接 MiniMax 或 MCTS二是需要在 Unity 中实现高精度策略类游戏逻辑的中级工程师——因为它的提子模块已通过 217 个边界/角/断点/眼位组合测试用例不是“能跑就行”的教学 Demo。2. 提子算法的三层实现从气数判定到连通域剪枝再到连锁提子触发2.1 气Liberty不是静态属性而是动态图结构的出度围棋中“有气”意味着某颗棋子或某组同色棋子至少有一个相邻空点。但直接对每颗子实时扫描四邻域会带来 O(N) 开销N 为棋盘格数且无法处理“提子后气再生”的连锁反应。本项目采用气缓存 连通域标记双机制每个Stone实例不存“当前气数”而是维护一个HashSetVector2Int类型的liberties字段初始由BoardManager.InitializeLiberties()批量生成当落子时BoardManager.PlaceStone()不仅设置棋子颜色还会调用UpdateAdjacentLiberties()—— 该方法只更新落子点上下左右四个坐标的邻接关系若邻点为空则将该坐标加入落子方liberties若邻点为敌方子则从敌方liberties中移除该坐标关键点在于气集合的增删永远只作用于邻接坐标不遍历全图时间复杂度稳定在 O(1)。// BoardManager.cs 片段 public void UpdateAdjacentLiberties(Vector2Int pos, Color32 stoneColor) { var directions new[] { Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right }; foreach (var dir in directions) { var adj pos dir; if (!IsOnBoard(adj)) continue; if (GetStoneColor(adj) Color.clear) { // 邻点为空加入当前落子方的气集合 GetStoneGroup(stoneColor).AddLiberty(adj); } else if (GetStoneColor(adj) ! stoneColor) { // 邻点为敌方子从敌方气集合中移除该坐标 GetStoneGroup(OppositeColor(stoneColor)).RemoveLiberty(adj); } } }提示GetStoneGroup()返回的是按颜色划分的StoneGroup实例每个StoneGroup内部用DictionaryVector2Int, HashSetVector2Int缓存各连通域的气集合避免跨域误删。这是区别于“每颗子单独管气”的关键设计。2.2 连通域识别DFS 标记 哈希坐标压缩拒绝递归爆栈提子判定本质是对某颗刚落下的子找到其所属连通域检查该域所有成员的liberties.Count 0。但朴素 DFS 在 19×19 棋盘上最坏情况需递归 361 层Unity 的 Mono 运行时极易栈溢出。本项目改用迭代式广度优先搜索BFS 坐标哈希去重使用QueueVector2Int替代递归调用栈用HashSetVector2Int记录已访问坐标防止环路每次从队列取坐标后只向四邻域中同色且未访问的坐标扩展最终返回ListVector2Int类型的连通域坐标列表供后续气数校验。// StoneGroup.cs 片段 public ListVector2Int GetConnectedGroup(Vector2Int startPos) { var visited new HashSetVector2Int(); var queue new QueueVector2Int(); var group new ListVector2Int(); if (!IsOnBoard(startPos) || GetStoneColor(startPos) Color.clear) return group; queue.Enqueue(startPos); visited.Add(startPos); while (queue.Count 0) { var current queue.Dequeue(); group.Add(current); foreach (var dir in Directions) { var next current dir; if (IsOnBoard(next) GetStoneColor(next) _color !visited.Contains(next)) { visited.Add(next); queue.Enqueue(next); } } } return group; }注意Directions是预定义的Vector2Int[]包含up/down/left/right四个方向向量不包含对角线——围棋规则中气只认正交邻接这点常被初学者误写成八方向。2.3 连锁提子状态暂存 逆序执行确保提子顺序可追溯真实围棋中一手棋可能引发多组提子如“打劫”前的长生局面且提子顺序影响后续气的生成。本项目采用事务式提子队列BoardManager.ProcessMove()落子后先调用FindAllGroupsToCapture()扫描所有敌方连通域将待提域按“气数为 0”条件批量收集到ListListVector2Int captureGroups关键步骤对每个待提域先调用stoneGroup.RemoveStones(group)清空棋子再调用UpdateAdjacentLiberties()更新气——但此更新不立即生效而是暂存到pendingLibertyUpdates字典中所有待提域处理完毕后统一应用pendingLibertyUpdates再触发 UI 刷新和事件回调。这样设计保证了即使 A 域提子导致 B 域瞬间“生气”B 域也不会被错误提掉——因为 B 域的气数是在所有提子操作完成后再统一重算的。步骤操作数据状态变化1. 落子PlaceStone(pos, black)黑方气集合新增邻空点白方气集合移除邻点2. 扫描FindAllGroupsToCapture(white)返回白方两个无气连通域[G1, G2]3. 提子RemoveStones(G1)→RemoveStones(G2)G1/G2 坐标从棋盘清空pendingLibertyUpdates累加邻点变更4. 同步ApplyPendingLibertyUpdates()所有邻点气状态一次性刷新避免中间态干扰3. Unity 场景层与逻辑层解耦02_ModelConfiguration_Test场景的工程意义3.102_ModelConfiguration_Test不是 Demo 场景而是模型-逻辑契约验证场项目正文提到02_ModelConfiguration_Test文件名这并非随意编号。它实际承担着Unity Editor 层与 C# 逻辑层的接口对齐验证功能。具体表现在三方面坐标映射契约棋盘 Prefab 中每个格子的Transform.position必须严格对应Vector2Int(x, y)误差 0.01 单位即触发BoardValidator.CheckGridAlignment()报错UI 元素绑定契约Canvas 下ScorePanel的Text组件必须命名为BlackScoreText/WhiteScoreText且脚本ScoreDisplay.cs通过GetComponentInChildrenText()查找而非Find(xxx)——避免因层级变动导致空引用输入事件契约BoardInputHandler.cs监听EventSystem.current.IsPointerOverGameObject()仅当鼠标未悬停 UI 元素时才触发OnBoardClick防止点击按钮时误落子。该场景中所有 GameObject 均带[RequireComponent(typeof(BoardManager))]且BoardManager的Awake()方法强制校验// BoardManager.cs private void Awake() { if (gridRoot null) throw new MissingReferenceException(gridRoot not assigned in Inspector); if (stonePrefab null) throw new MissingReferenceException(stonePrefab not assigned); // 坐标校验取 gridRoot 子物体中第一个格子的 position反推原点偏移 var firstTile gridRoot.transform.GetChild(0); originOffset firstTile.position - new Vector3(-9f, -9f, 0f); // 19x19 棋盘中心为 (0,0) }提示originOffset是核心偏移量用于将屏幕点击坐标Camera.main.ScreenToWorldPoint(Input.mousePosition)转换为棋盘整数坐标Vector2Int rounded Vector2Int.Round((worldPos - originOffset) / tileSize);。若tileSize在 Inspector 中设为 1.0而实际模型单位是 0.5则rounded会系统性偏移——02_ModelConfiguration_Test就是用来暴露这类隐性配置错误的。3.2 棋子渲染采用 SpriteRenderer Atlas而非 3D Mesh尽管项目正文提到“3D 模型”但源码实际使用SpriteRenderer渲染黑白子原因很务实2D 渲染开销低19×19 棋盘最多 361 个 SpriteGPU Draw Call 10支持SortingLayer精确控制棋子/棋盘/UI 层级避免 Z-FightingSpriteAtlas打包后内存占用比同等质量 PNG 小 40%且支持运行时SpriteAtlasManager.RequestAtlas()动态加载。关键配置在Resources/Atlases/GoStones.atlasblack_stone和white_stone两张图均采用Alpha 8 格式非 RGBA32灰度值 0~255 映射透明度无 RGB 通道浪费Pivot设为(0.5, 0.5)确保transform.position即棋盘格中心Mesh Type为Full Rect禁用 Tight 模式——避免 Sprite 边缘像素被裁剪导致落子时出现“悬浮感”。// StoneSpawner.cs public void SpawnStone(Vector2Int boardPos, Color32 color) { var worldPos BoardManager.Instance.GetWorldPosition(boardPos); var go Instantiate(stonePrefab, worldPos, Quaternion.identity, boardRoot); var spriteRenderer go.GetComponentSpriteRenderer(); // 根据颜色选择 Atlas 中的 Sprite spriteRenderer.sprite color Color.black ? stoneAtlas.GetSprite(black_stone) : stoneAtlas.GetSprite(white_stone); // 强制设置 Sorting Layer 为 GameOrder in Layer 为 boardPos.y * 100 boardPos.x spriteRenderer.sortingLayerName Game; spriteRenderer.sortingOrder boardPos.y * 100 boardPos.x; }注意sortingOrder按 Y 主序、X 次序排列确保同一列内上方棋子压住下方棋子符合视觉深度且不同列间不会因 Order 值重复导致渲染错乱。3.3 UI 事件系统与逻辑解耦IBoardEventListener接口驱动所有 UI 交互悔棋、切换玩家、显示提示不直接调用BoardManager方法而是通过事件总线BoardEventBus发布消息// BoardEventBus.cs public static class BoardEventBus { public static event ActionBoardEventType, object OnEvent; public static void Publish(BoardEventType type, object data null) { OnEvent?.Invoke(type, data); } } // UIButtonHandler.cs public void OnUndoButtonClick() { BoardEventBus.Publish(BoardEventType.UndoMove); } // BoardManager.cs 订阅 private void OnEnable() { BoardEventBus.OnEvent HandleBoardEvent; } private void HandleBoardEvent(BoardEventType type, object data) { switch (type) { case BoardEventType.UndoMove: UndoLastMove(); // 真正的逻辑在此 break; case BoardEventType.SwitchPlayer: SwitchCurrentPlayer(); break; } }这种模式让BoardManager完全无 UI 依赖可直接用于命令行测试或 Headless 模式训练 AI也是项目能支撑“接入 Minimax AI”扩展性的基础。4. 悔棋与 AI 对手的底层支撑MoveHistory 与 GameStateSnapshot4.1 悔棋不是简单List.RemoveAt()而是状态快照 差分回滚MoveHistory类不存储每步的完整棋盘而是记录增量操作每次落子生成MoveRecord含position、color、capturedStones被提坐标列表、addedLiberties新增气坐标UndoLastMove()时先恢复被提子将capturedStones重新设为敌方色再恢复气将addedLiberties加回敌方气集合关键点capturedStones和addedLiberties在ProcessMove()中已精确计算无需重新扫描棋盘。// MoveRecord.cs public struct MoveRecord { public Vector2Int position; public Color32 color; public ListVector2Int capturedStones; // 本步提掉的敌方坐标 public ListVector2Int addedLiberties; // 本步为敌方新增的气坐标因我方落子释放 } // BoardManager.cs public void UndoLastMove() { if (moveHistory.Count 0) return; var last moveHistory.Pop(); // 1. 恢复被提子将 capturedStones 设为敌方色 foreach (var pos in last.capturedStones) { SetStoneColor(pos, OppositeColor(last.color)); } // 2. 恢复气将 addedLiberties 加回敌方气集合 foreach (var liberty in last.addedLiberties) { GetStoneGroup(OppositeColor(last.color)).AddLiberty(liberty); } // 3. 移除本步落子 SetStoneColor(last.position, Color.clear); }提示addedLiberties是ProcessMove()中UpdateAdjacentLiberties()计算出的敌方新增气坐标它与capturedStones是互斥集合——被提子的位置不可能同时是气点因此差分回滚无冲突。4.2 AI 对手接入点IGoAIPlayer接口与 Minimax 基础框架项目预留了IGoAIPlayer接口要求实现CalculateNextMove(BoardState state)方法public interface IGoAIPlayer { Vector2Int CalculateNextMove(BoardState state); } // MinimaxAIPlayer.cs 示例实现 public Vector2Int CalculateNextMove(BoardState state) { var bestMove Vector2Int.zero; var bestScore int.MinValue; foreach (var move in state.GetValidMoves()) { var nextState state.Clone().ApplyMove(move, Color.black); var score Minimax(nextState, 2, false); // 深度 2 if (score bestScore) { bestScore score; bestMove move; } } return bestMove; }BoardState是轻量级不可变对象只含stoneColors[,]和currentPlayer不包含任何 Unity 特定类型如 GameObject、Transform确保 AI 可脱离引擎运行。ApplyMove()内部调用与玩家落子相同的ProcessMove()逻辑保证规则一致性。5. 实战调试技巧用 Editor 工具快速定位提子逻辑错误5.1 在 Scene 视图中可视化气与连通域BoardManager提供DebugDrawLiberties和DebugDrawGroups两个 Editor-only 方法通过Handles绘制调试信息#if UNITY_EDITOR [ContextMenu(Debug: Draw Liberties)] private void DebugDrawLiberties() { foreach (var kvp in blackGroup.libertiesByGroup) { foreach (var liberty in kvp.Value) { Handles.color Color.green; Handles.DrawWireCube(BoardManager.Instance.GetWorldPosition(liberty) Vector3.forward * 0.1f, new Vector3(0.3f, 0.3f, 0.02f)); } } } [ContextMenu(Debug: Draw Groups)] private void DebugDrawGroups() { var groups blackGroup.GetAllGroups(); for (int i 0; i groups.Count; i) { var color Color.HSVToRGB(i * 0.3f % 1f, 0.8f, 0.9f); foreach (var pos in groups[i]) { Handles.color color; Handles.DrawWireCube(BoardManager.Instance.GetWorldPosition(pos) Vector3.up * 0.05f, new Vector3(0.2f, 0.2f, 0.01f)); } } } #endif右键BoardManager组件即可调用绿色立方体气点彩色立方体不同连通域——一眼看出“为何这组子没被提”比如某气点被误删或“为何两组子被合并”坐标映射错误。5.2 用 Play Mode Test 快速验证提子边界用例项目包含Tests/GoRuleTests.cs使用 Unity Test Framework 运行纯逻辑测试[Test] public void CornerCapture_TwoStones() { // 设置角上两颗黑子(0,0) 和 (0,1) board.SetStoneColor(new Vector2Int(0, 0), Color.black); board.SetStoneColor(new Vector2Int(0, 1), Color.black); // 白方下在 (1,0)、(1,1)、(0,2) —— 三面围住 board.PlaceStone(new Vector2Int(1, 0), Color.white); board.PlaceStone(new Vector2Int(1, 1), Color.white); board.PlaceStone(new Vector2Int(0, 2), Color.white); // 此时黑方在 (0,0) 和 (0,1) 无气邻点全被占或越界 Assert.IsTrue(board.IsGroupCaptured(new ListVector2Int { new Vector2Int(0, 0), new Vector2Int(0, 1) })); }运行Edit Test Runner Play Mode Tests即可批量验证比手动点 UI 快 10 倍且能覆盖“提子后气再生”等易漏场景。5.3 性能瓶颈定位用 Profiler 检查GetConnectedGroup调用频次在BoardManager.ProcessMove()开头添加#if DEVELOPMENT_BUILD || UNITY_EDITOR Profiler.BeginSample(GetConnectedGroup); #endif var group GetConnectedGroup(pos); #if DEVELOPMENT_BUILD || UNITY_EDITOR Profiler.EndSample(); #endif然后在 Profiler 中筛选GetConnectedGroup观察正常情况每步调用 ≤ 2 次己方落子域 敌方被围域异常情况若出现 5 次/帧说明FindAllGroupsToCapture()未正确剪枝可能因liberties.Count未缓存导致重复扫描解决方案在StoneGroup中为每个连通域缓存int cachedLibertyCount仅当AddLiberty/RemoveLiberty时更新IsCaptured()直接读缓存值。注意cachedLibertyCount必须与liberties集合严格同步建议在AddLiberty()中加Debug.Assert(!liberties.Contains(pos))避免重复添加导致计数错误。本文还有配套的精品资源点击获取