ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity围棋游戏开发:深度解析气与提子算法实现

Unity围棋游戏开发:深度解析气与提子算法实现 1. 项目概述与核心价值最近在整理自己的Unity学习项目库翻到了一个几年前做的围棋游戏Demo。当时为了彻底搞懂围棋规则在代码层面的实现尤其是“气”的判断和“提子”这个核心逻辑花了差不多两个星期的时间去琢磨和调试。现在回头看这个项目虽然界面简陋但底层逻辑非常扎实是一个绝佳的学习Unity游戏逻辑和C#算法思维的案例。它不依赖任何复杂的插件纯粹用C#脚本实现了围棋的核心规则并且附带了一个可以直接运行的棋盘场景和一套基础的黑白棋子模型/贴图资源。对于刚接触Unity不久或者想从简单小游戏转向更复杂规则类游戏开发的开发者来说这个项目就像一份“活”的算法教案。你不仅能学到如何在Unity中组织一个棋类游戏的场景结构棋盘网格、棋子实例化、交互响应更能深入到围棋最精髓的规则实现中如何计算一片棋子的“气”如何判断棋子是否被“提”走这背后涉及的是递归、集合操作、图论遍历等编程基础知识的综合运用。网上很多围棋游戏的教程要么只讲个皮毛要么直接用了现成的规则库而这个项目把“黑盒”彻底打开了你可以一行行代码看明白规则是怎么运转的。接下来我就把这个项目的设计思路、关键代码实现以及我踩过的那些坑毫无保留地分享出来。2. 项目整体架构与设计思路2.1 场景与对象结构设计在Unity中构建一个棋盘游戏第一步不是写代码而是想清楚场景里的对象关系。我的设计非常清晰主要包含以下几个核心GameObject棋盘 (Board)一个空的GameObject作为所有棋盘逻辑的容器。它挂载了主要的控制脚本BoardManager。同时它下面会动态生成19x19个交叉点Intersection的视觉或逻辑标记。棋子预制体 (Stone Prefab)我准备了两个预制体BlackStone和WhiteStone。它们本质上可能是同一个模型比如一个扁圆柱体但材质球不同一个黑色一个白色。预制体上会挂载一个Stone脚本用于记录这颗棋子自身的属性比如颜色、所在的棋盘坐标。交互控制器 (Input Controller)负责处理玩家的鼠标点击或触摸输入将屏幕坐标转换为棋盘上的逻辑坐标并通知BoardManager在指定位置落子。游戏状态管理器 (GameManager)这是一个高阶管理器负责记录当前轮到哪一方下棋黑先白后、判断胜负、处理认输和重新开始等。在这个基础版本中它的功能相对简单主要与BoardManager交互。这种结构的好处是职责分离。BoardManager只关心棋盘状态和规则Stone只代表一个棋子实体InputController只管输入转换GameManager统筹全局。代码不会搅成一锅粥调试和扩展都方便很多。2.2 数据模型如何表示棋盘与棋子围棋棋盘是19路但我们写代码时用一个19x19的二维数组来表示是最直观的。我定义了一个枚举类型StoneColor来表示棋子的状态。public enum StoneColor { Empty, // 空交叉点 Black, White } public class BoardManager : MonoBehaviour { private const int BOARD_SIZE 19; private StoneColor[,] boardState new StoneColor[BOARD_SIZE, BOARD_SIZE]; // 核心状态数组 // ... 其他代码 }boardState这个二维数组就是整个游戏的“真理之源”。boardState[x, y]的值直接告诉我们坐标 (x, y) 上是什么空的、黑子还是白子。所有关于“气”和“提子”的判断都基于对这个数组的查询和计算。这里有个细节需要注意数组索引和棋盘坐标的对应关系。我通常定义(0, 0)为棋盘的左下角天元点附近是(9, 9)(18, 18)为右上角。在将鼠标点击位置转换为数组索引时要确保这个映射关系是正确且一致的否则会出现棋子下“歪”的诡异情况。注意在内存中维护一个与视觉棋盘完全同步的逻辑状态数组至关重要。永远不要试图只通过场景中GameObject的位置来推导棋盘状态那会是一场灾难。逻辑状态数组是“因”场景中的棋子表现是“果”。3. 核心算法深度解析气与提子这是整个项目的灵魂也是面试中常被拿来考察编程基本功的题目。我们分两步走先理解概念再看代码实现。3.1 “气”的概念与递归计算法围棋中“气”指的是一个棋子或一片棋子相邻的、空的交叉点。单个棋子的气最多有4个上、下、左、右。一片连在一起的同色棋子称为“块”或“串”它们共享所有的气。计算一块棋的气本质上是一个连通域搜索问题。我们从一个给定的棋子坐标(startX, startY)出发寻找所有通过直线相邻不包括斜角方式连接起来的、颜色相同的棋子并收集这些棋子周围所有空交叉点的集合。我采用深度优先搜索 (DFS)来实现因为它写起来直观。当然用广度优先搜索 (BFS) 也可以。// 在 BoardManager 类中 public HashSetVector2Int FindLiberties(int x, int y, StoneColor color, HashSetVector2Int visited null) { // liberties 存储找到的所有“气”空点 HashSetVector2Int liberties new HashSetVector2Int(); // visited 用于记录已经访问过的棋子坐标防止无限递归 if (visited null) visited new HashSetVector2Int(); // 如果坐标越界直接返回空集合 if (!IsWithinBoard(x, y)) return liberties; // 如果当前点已经是空那它就是一个“气”加入集合 if (boardState[x, y] StoneColor.Empty) { liberties.Add(new Vector2Int(x, y)); return liberties; } // 如果当前点有棋子但颜色不是我们要找的说明到了边界返回 if (boardState[x, y] ! color) return liberties; // 如果这个棋子坐标已经访问过返回避免重复处理 Vector2Int pos new Vector2Int(x, y); if (visited.Contains(pos)) return liberties; visited.Add(pos); // 标记为已访问 // 递归搜索四个方向上、下、左、右 int[] dx { 0, 1, 0, -1 }; int[] dy { 1, 0, -1, 0 }; for (int i 0; i 4; i) { int newX x dx[i]; int newY y dy[i]; if (IsWithinBoard(newX, newY)) { // 合并递归调用返回的“气” HashSetVector2Int subLiberties FindLiberties(newX, newY, color, visited); liberties.UnionWith(subLiberties); } } return liberties; }关键点解析使用HashSetVector2IntHashSet能自动去重非常适合存储“气”的集合因为同一个空点可能被多个相邻棋子共享。递归与 visited 集合visited参数在递归调用中传递确保同一颗棋子不会被重复检查这是DFS不陷入死循环的关键。边界条件IsWithinBoard这是一个辅助方法判断坐标是否在[0, BOARD_SIZE-1]范围内所有数组访问前都必须检查。UnionWith操作这是合并集合的高效方法将子递归找到的“气”合并到主集合中。这个FindLiberties函数是基石。给它一个棋子的坐标和颜色它就能返回这块棋所有“气”的坐标集合。3.2 “提子”逻辑的实现与时机提子的规则是当一方落子后如果导致对方某些棋子所有的“气”都被占据即气数为0则将这些棋子从棋盘上提走。逻辑听起来简单但实现时有一个极其重要的顺序和细节落子后必须先检查对方的棋子是否被提再检查己方的棋子是否被提即是否形成了“自杀”。这是围棋规则的规定。public bool PlaceStone(int x, int y, StoneColor color) { // 1. 检查该位置是否为空 if (boardState[x, y] ! StoneColor.Empty) { Debug.LogWarning($位置 ({x}, {y}) 已有棋子); return false; } // 2. 临时放置棋子为了进行气数计算 boardState[x, y] color; // 3. 检查相邻的对方棋子是否无气 ListVector2Int stonesToRemove new ListVector2Int(); int[] dx { 0, 1, 0, -1 }; int[] dy { 1, 0, -1, 0 }; StoneColor opponentColor (color StoneColor.Black) ? StoneColor.White : StoneColor.Black; for (int i 0; i 4; i) { int adjX x dx[i]; int adjY y dy[i]; if (IsWithinBoard(adjX, adjY) boardState[adjX, adjY] opponentColor) { // 检查这块对方棋是否有气 HashSetVector2Int liberties FindLiberties(adjX, adjY, opponentColor); if (liberties.Count 0) // 无气需要被提 { // 找到这块棋的所有棋子另一个DFS HashSetVector2Int group FindStoneGroup(adjX, adjY, opponentColor); foreach (var stonePos in group) { if (!stonesToRemove.Contains(stonePos)) stonesToRemove.Add(stonePos); } } } } // 4. 提走对方无气的棋子 bool hasCaptured stonesToRemove.Count 0; foreach (var pos in stonesToRemove) { boardState[pos.x, pos.y] StoneColor.Empty; // 这里还需要销毁场景中对应的棋子GameObject RemoveStoneFromScene(pos.x, pos.y); } // 5. 检查己方落子后是否无气自杀规则 // **关键**必须在提走对方棋子后再检查 HashSetVector2Int selfLiberties FindLiberties(x, y, color); if (selfLiberties.Count 0 !hasCaptured) { // 自杀禁着如果落子后己方无气且没有提掉任何对方棋子则落子无效 boardState[x, y] StoneColor.Empty; // 撤销临时落子 Debug.LogWarning($自杀禁着位置 ({x}, {y})); return false; } // 6. 落子成功更新场景视觉 InstantiateStoneAt(x, y, color); return true; }关键点与避坑指南临时落子为了计算落子后的影响我们必须先在逻辑数组boardState中“模拟”落下这颗子。这是一个非常重要的技巧。FindStoneGroup方法这是一个与FindLiberties类似的DFS但它收集的是所有相连的同色棋子坐标而不是空点。用于确定需要被提走的具体是哪些棋子。自杀规则的判断时机务必在提走对方棋子之后再判断自己是否无气。因为提子后可能会为刚下的这颗子创造出新的“气”。如果顺序反了就会错误地禁止很多合理的、通过提子求活的下法。hasCaptured标志这个变量用来记录本轮是否发生了提子。在自杀判断中即使自己没气了但只要提了对方的子就是允许的这就是“反提”。实操心得调试“提子”和“自杀”逻辑时最容易出错的就是顺序。我建议单独写几个单元测试用例比如测试“自杀”、“提一子”、“反提”打劫的雏形等典型局面用Debug.Log打印出每一步的boardState和计算出的气集合能帮你快速定位逻辑漏洞。4. 完整可运行场景的构建细节4.1 棋盘网格生成与坐标映射为了让逻辑坐标和视觉位置对应我们需要在场景中生成一个视觉参考系。我通常不直接用一个19x19的网格模型而是在BoardManager的Start()方法中动态生成小点或使用一个具有网格材质的平面。更实用的方法是计算映射。我们定义棋盘的左下角世界坐标为boardBottomLeft棋盘的总宽度为boardWidth。那么逻辑坐标(logicX, logicY)对应的世界位置worldPos可以通过线性插值计算private Vector3 LogicToWorldPosition(int logicX, int logicY) { float cellSize boardWidth / (BOARD_SIZE - 1); // 格子间距 Vector3 worldPos boardBottomLeft new Vector3(logicX * cellSize, 0, logicY * cellSize); return worldPos; }反过来当玩家点击屏幕时InputController通过射线检测获取点击点的世界坐标hitPoint再反算出逻辑坐标private Vector2Int WorldToLogicPosition(Vector3 worldPos) { Vector3 relativePos worldPos - boardBottomLeft; float cellSize boardWidth / (BOARD_SIZE - 1); // 四舍五入到最近的交叉点 int logicX Mathf.RoundToInt(relativePos.x / cellSize); int logicY Mathf.RoundToInt(relativePos.z / cellSize); // 假设Z轴是棋盘的另一维度 // 钳制在棋盘范围内 logicX Mathf.Clamp(logicX, 0, BOARD_SIZE - 1); logicY Mathf.Clamp(logicY, 0, BOARD_SIZE - 1); return new Vector2Int(logicX, logicY); }注意事项确保你的棋盘模型的原点Pivot设置正确。通常设置在棋盘中心或左下角会让计算更方便。boardBottomLeft和boardWidth可以在Inspector中公开运行时根据实际的棋盘GameObject的Renderer.bounds动态计算更稳健。4.2 棋子实例化与资源管理当PlaceStone函数返回true后需要将棋子的视觉表现实例化到场景中。private void InstantiateStoneAt(int x, int y, StoneColor color) { Vector3 worldPos LogicToWorldPosition(x, y); GameObject stonePrefab (color StoneColor.Black) ? blackStonePrefab : whiteStonePrefab; GameObject newStone Instantiate(stonePrefab, worldPos, Quaternion.identity, stonesParent); // 为生成的棋子对象附加Stone脚本记录其逻辑坐标 Stone stoneComponent newStone.GetComponentStone(); if (stoneComponent null) stoneComponent newStone.AddComponentStone(); stoneComponent.Initialize(color, x, y); // 将棋子GameObject存入一个字典方便后续通过坐标查找和销毁 stoneGameObjects[new Vector2Int(x, y)] newStone; } private void RemoveStoneFromScene(int x, int y) { Vector2Int posKey new Vector2Int(x, y); if (stoneGameObjects.TryGetValue(posKey, out GameObject stoneObj)) { Destroy(stoneObj); stoneGameObjects.Remove(posKey); } }资源管理技巧使用父物体将所有实例化的棋子放在一个名为Stones的空GameObject下保持场景层次结构整洁。对象池优化对于频繁的提子和落子频繁的Instantiate和Destroy可能产生GC垃圾回收压力。在实际发布的游戏中建议使用对象池预先创建一定数量的黑白棋子GameObject并禁用需要时激活并移动到指定位置提子时将其放回池中禁用。这对于移动端尤其重要。坐标字典使用DictionaryVector2Int, GameObject来维护逻辑坐标到棋子对象的映射使得根据坐标删除特定棋子的操作效率为O(1)。4.3 用户交互与回合制流程交互流程由InputController驱动并与GameManager和BoardManager协作。public class InputController : MonoBehaviour { public Camera gameCamera; public BoardManager boardManager; public GameManager gameManager; void Update() { if (Input.GetMouseButtonDown(0) gameManager.IsPlayerTurn) // 假设是玩家回合 { Ray ray gameCamera.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit)) { // 假设射线打到了棋盘碰撞体 Vector2Int logicPos boardManager.WorldToLogicPosition(hit.point); bool success boardManager.PlaceStone(logicPos.x, logicPos.y, gameManager.CurrentPlayerColor); if (success) { // 落子成功切换回合 gameManager.SwitchTurn(); // 这里可以触发音效、动画等 } } } } }GameManager则维护着当前回合、分数目数等全局状态并可以调用BoardManager提供的接口进行终局计算虽然这是一个非常复杂的模块本Demo可能未实现完整的数子逻辑。5. 常见问题、调试技巧与扩展方向5.1 开发中遇到的典型问题及解决棋子下在“线”上而不是“交叉点”问题点击棋盘时棋子位置有偏移。排查检查WorldToLogicPosition函数中的映射计算。确保boardBottomLeft和boardWidth的值准确对应了场景中棋盘模型的实际尺寸。使用Debug.DrawRay或Gizmos在LogicToWorldPosition计算出的位置画小点看是否与棋盘交叉点对齐。解决在棋盘生成时就将逻辑坐标(0,0)和(18,18)对应的世界坐标点可视化出来校准映射参数。提子逻辑偶尔漏提或多提问题特别是多块棋纠缠时提子结果不对。排查重点检查FindLiberties和FindStoneGroup两个DFS函数。在递归过程中打印日志查看visited集合是否正确记录了已访问节点liberties集合是否正确合并。解决编写单元测试。创建一个小的测试脚本手动设置boardState数组为一个特定残局比如一个“刀把五”的死形然后调用PlaceStone断言提子后的boardState是否符合预期。“自杀”规则被错误触发问题某些明明可以下的地方特别是能提子时被判定为自杀。排查100%确认PlaceStone函数中检查自杀的代码行是否放在了提走对方棋子之后。检查hasCaptured变量是否在提子循环中被正确设置为true。解决用一个最简单的测试在空旷处下一子然后紧挨着下另一子形成只有一气。再下一子去“叫吃”对方。这个过程能检验基本的气计算和自杀判断。性能问题在大棋盘上卡顿问题19路棋盘递归计算气在低性能设备上可能感觉延迟。优化缓存可以为每个“块”缓存其“气”的集合。只有当这个块周围的棋子状态发生变化时落子、提子才重新计算该块及其相邻块的气。这需要更复杂的数据结构来维护“块”的集合及其边界。迭代代替递归对于极深递归的担心可以用显式的Stack数据结构实现迭代版的DFS避免栈溢出风险虽然19路棋盘几乎不可能溢出。5.2 项目扩展方向与思考这个基础Demo完成后你可以沿着多个方向深化它每一个方向都能学到新东西实现“劫争”规则这是围棋最有趣的规则之一。你需要增加一个状态记录上一次提子后的棋盘局面。当出现“劫”时即提回一颗子后局面与上一步完全相同禁止立即提回。这需要维护一个“历史局面”的栈或列表来进行比对。终局与胜负判定实现完整的数子或计目算法。这需要识别出所有的“活棋”和“死棋”区分双方的“地”围住的空点。这比“气”的判断更复杂涉及到“两眼活棋”的判定又是一个经典的图论问题。集成AI对手可以从最简单的随机落子开始然后实现基于规则的AI比如只下在气多的地方、连接、切断等再尝试集成一个开源的轻量级围棋AI引擎如KataGo的轻量级版本通过进程间通信让AI返回落子点。网络对战使用Unity Netcode或Mirror等网络库实现双人在线对战。核心是将PlaceStone这样的操作变成网络RPC调用并在所有客户端同步boardState。美化与体验为落子和提子添加粒子特效和音效。实现棋谱记录和回放功能保存每一步的坐标和颜色即可。添加让子、贴目等比赛设置。这个围棋项目就像一把钥匙它帮你打开了用代码模拟复杂规则游戏的大门。把“气”和“提子”搞明白你再去看其他棋类如五子棋的禁手、象棋的移动规则或者状态复杂的游戏逻辑会发现底层的思想是相通的——都是对状态空间的定义、搜索和规则约束。最后我把自己调试时用的一个“棋盘状态打印”函数分享出来它能将boardState数组以文本形式打印到Console对于调试复杂局面 invaluableprivate void PrintBoardState() { System.Text.StringBuilder sb new System.Text.StringBuilder(); for (int y BOARD_SIZE - 1; y 0; y--) // 从上到下打印 { for (int x 0; x BOARD_SIZE; x) { char c .; if (boardState[x, y] StoneColor.Black) c B; else if (boardState[x, y] StoneColor.White) c W; sb.Append(c); sb.Append( ); } sb.AppendLine(); } Debug.Log(sb.ToString()); }
返回列表