ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity开发扫雷游戏:从数据结构到核心算法与UGUI交互实现

2026/8/8 9:58:04 拓冰建站 浏览量
Unity开发扫雷游戏:从数据结构到核心算法与UGUI交互实现 1. 项目概述从经典到现代扫雷在Unity中的重生扫雷这个几乎刻在每一个Windows用户DNA里的经典游戏如今依然是学习游戏开发逻辑与算法的绝佳练手项目。它麻雀虽小五脏俱全随机生成、递归算法、状态管理、用户交互一个不落。今天我们不谈那些复杂的3A大作就从这个看似简单的“扫雷”入手用Unity引擎和C#语言亲手把它从零构建出来。这不仅仅是一个复刻更是一次对游戏核心交互逻辑与底层算法的深度剖析。无论你是刚接触Unity的新手想通过一个完整项目练手还是有一定基础想巩固算法和状态机设计的老鸟这个项目都能让你收获满满。我们将聚焦于两个核心一是如何用Unity的UGUI系统构建清晰、响应灵敏的交互界面二是如何设计并实现扫雷背后那套精妙的游戏逻辑算法尤其是那个经典的“空白区域连锁翻开”效果。你会发现一个简单的点击背后藏着不少值得琢磨的设计思路。2. 游戏核心逻辑与数据结构设计2.1 网格数据模型用二维数组构建游戏世界扫雷游戏的核心是一个二维网格。在代码层面我们最直观的选择就是使用二维数组。但数组里存储的不能仅仅是一个“是否有雷”的布尔值我们需要一个自定义的Cell格子类来封装每个格子的所有状态。public class MineCell { public bool IsMine; // 是否是地雷 public bool IsRevealed; // 是否已被翻开 public bool IsFlagged; // 是否被标记为旗帜插旗 public int AdjacentMineCount; // 周围8格的地雷数量 // 可以添加坐标信息方便查找 public int X; public int Y; }然后我们在管理类例如MineSweeperGameManager中声明一个二维数组作为游戏地图private MineCell[,] _grid;。在游戏初始化时根据玩家选择的难度如初级9x9、中级16x16、高级16x30来实例化这个数组。这个_grid就是我们整个游戏状态的唯一真相来源所有UI显示和逻辑判断都基于它。注意为什么不用ListListMineCell对于固定大小的网格二维数组在内存访问速度和代码简洁性上更有优势。_grid[x, y]的访问方式非常直观。2.2 地雷的随机生成公平性与可预测性生成地雷的核心是“随机”但必须确保每次生成的位置不重复。一个常见的方法是先初始化一个所有格子都不是雷的干净网格然后循环“地雷总数”次在循环内随机生成坐标(x, y)如果该位置已经不是雷就将其IsMine设为true否则重新随机。这里有个关键细节第一次点击的安全区。在经典扫雷中第一次点击永远不会触雷。为了实现这一点我们需要在玩家第一次点击之后再生成地雷。具体流程是初始化一个没有地雷的网格。玩家点击某个格子(firstX, firstY)。生成地雷但必须跳过以(firstX, firstY)为中心的3x3区域确保第一次点击的格子及其周围一圈绝对安全。根据地雷分布为所有非雷格子计算AdjacentMineCount。private void GenerateMines(int firstClickX, int firstClickY) { int minesPlaced 0; while (minesPlaced totalMines) { int x Random.Range(0, width); int y Random.Range(0, height); // 跳过第一次点击的格子及其周围8格 if (Mathf.Abs(x - firstClickX) 1 Mathf.Abs(y - firstClickY) 1) continue; if (!_grid[x, y].IsMine) { _grid[x, y].IsMine true; minesPlaced; } } CalculateAllAdjacentMines(); // 生成后立即计算周围雷数 }2.3 相邻地雷数计算八方向遍历为每个非雷格子计算周围地雷数是一个标准的遍历操作。我们为每个格子计算其周围8个方向上、下、左、右、左上、右上、左下、右下的格子统计其中IsMine为true的数量。private void CalculateAdjacentMines(int x, int y) { if (_grid[x, y].IsMine) { _grid[x, y].AdjacentMineCount -1; // 或用特定值表示但通常雷格子不计算此值 return; } int count 0; // 遍历周围8格 for (int i -1; i 1; i) { for (int j -1; j 1; j) { if (i 0 j 0) continue; // 跳过自己 int checkX x i; int checkY y j; // 检查边界 if (checkX 0 checkX width checkY 0 checkY height) { if (_grid[checkX, checkY].IsMine) { count; } } } } _grid[x, y].AdjacentMineCount count; }这个AdjacentMineCount是后续UI显示数字1-8的直接数据来源也是逻辑判断的关键。3. Unity交互界面构建与事件处理3.1 UGUI网格动态生成预制体与自动布局在Unity中我们通常使用UGUI来构建2D游戏界面。对于扫雷网格最合适的组件是Grid Layout Group。我们可以创建一个空的GameObject作为“棋盘”容器为其添加Grid Layout Group组件并设置合适的Cell Size和Spacing。格子本身是一个预制体CellPrefab。这个预制体通常包含以下UI元素一个Button组件用于接收点击。一个Image组件作为背景默认状态、翻开状态、雷、旗帜等不同状态用不同颜色或图片。一个TextMeshProUGUI组件用于显示数字1-8。可能还有一个用于显示旗帜或问号标记的Image。游戏初始化时我们根据难度设置动态实例化CellPrefab到棋盘容器下数量为width * height。Grid Layout Group会自动将它们排列整齐。同时我们需要将每个生成的格子对象与数据层_grid[x, y]关联起来通常可以通过在CellPrefab上挂载一个MineCellView脚本并在生成时传入坐标(x, y)来实现。private void CreateGridUI() { for (int y 0; y height; y) { for (int x 0; x width; x) { GameObject cellObj Instantiate(cellPrefab, gridContainer.transform); MineCellView cellView cellObj.GetComponentMineCellView(); cellView.Initialize(x, y, OnCellClicked, OnCellRightClicked); _cellViews[x, y] cellView; // 用一个二维数组保存视图引用方便更新 } } }3.2 鼠标交互逻辑左键、右键与长按扫雷的核心交互是鼠标操作。我们需要处理左键点击翻开格子、右键点击插旗/标记问号。在Unity的Button组件中我们可以监听onClick事件但这通常只对应左键。为了区分左右键我们需要使用更底层的事件接口或者一些变通方法。方法一使用Event Trigger组件为CellPrefab添加Event Trigger组件然后添加Pointer Click事件。在回调函数中通过PointerEventData参数的button属性来判断鼠标键。public void OnPointerClick(PointerEventData eventData) { if (eventData.button PointerEventData.InputButton.Left) { OnLeftClick(); } else if (eventData.button PointerEventData.InputButton.Right) { OnRightClick(); } }方法二组合使用Button与自定义脚本Button的onClick处理左键。同时在MineCellView脚本中实现IPointerDownHandler接口来捕获右键按下事件。public void OnPointerDown(PointerEventData eventData) { if (eventData.button PointerEventData.InputButton.Right) { // 处理右键逻辑例如切换标记状态 ToggleFlag(); eventData.Use(); // 阻止事件继续冒泡避免触发其他操作 } }实操心得在实际开发中我更喜欢方法二。因为它逻辑清晰左键的Button组件自带点击动画效果体验更好。处理右键时记得调用eventData.Use()否则可能会和容器或其他UI元素的事件冲突。另外可以考虑加入“长按”来标记问号第二次右键这需要用到IPointerDownHandler和IPointerUpHandler来计时。3.3 游戏状态UI计时器、雷数与表情按钮除了主网格游戏界面通常还包括剩余雷数显示器初始值为总雷数。每当玩家插上一面旗数量减1取消旗帜数量加1。这个数字需要实时更新。计时器从第一次有效点击翻开格子开始计时直到游戏胜利或失败结束。可以使用Time.time或Time.deltaTime累加并格式化为mm:ss显示。中央表情按钮显示笑脸进行中、惊讶按下时、戴墨镜笑胜利、哭脸失败。点击它可以重新开始游戏。它的状态是游戏整体状态的直观反映。这些UI元素通过一个UIManager或直接在GameManager中管理根据游戏逻辑事件如插旗、游戏结束来更新显示。4. 核心算法实现翻开逻辑与连锁反应4.1 单个格子的翻开与状态更新当玩家左键点击一个格子时触发RevealCell(x, y)函数。这是游戏逻辑最核心的入口。private void RevealCell(int x, int y) { MineCell cell _grid[x, y]; // 检查状态已翻开或已插旗的格子不应响应 if (cell.IsRevealed || cell.IsFlagged) return; // 如果是第一次点击在此刻生成地雷确保安全 if (!_gameStarted) { GenerateMines(x, y); _gameStarted true; StartTimer(); } // 翻开当前格子 cell.IsRevealed true; UpdateCellView(x, y); // 更新UI显示 // 判断结果 if (cell.IsMine) { // 踩到雷游戏结束 OnGameOver(false); return; } // 如果是数字格子AdjacentMineCount 0则翻开到此为止 // 如果是空白格子AdjacentMineCount 0则触发连锁翻开 if (cell.AdjacentMineCount 0) { RevealAdjacentCells(x, y); } // 每次翻开后检查是否胜利所有非雷格子都已翻开 CheckForWin(); }UpdateCellView(x, y)函数根据cell的当前状态是否翻开、是否是雷、周围雷数来更新对应的MineCellView的UI表现例如显示数字图片、更换背景色等。4.2 空白区域的递归/迭代连锁翻开算法这是扫雷游戏最经典也最有趣的算法部分。当翻开的格子周围雷数为0时我们需要自动翻开其周围所有8个格子。如果这些被翻开的格子里又有周围雷数为0的则需要继续翻开其周围格子以此类推直到所有相邻的空白区域和数字边界都被翻开。递归实现清晰直观private void RevealAdjacentCells(int x, int y) { // 遍历周围8格 for (int i -1; i 1; i) { for (int j -1; j 1; j) { if (i 0 j 0) continue; int newX x i; int newY y j; // 检查边界 if (newX 0 || newX width || newY 0 || newY height) continue; MineCell adjacentCell _grid[newX, newY]; // 只处理未翻开且未插旗的格子 if (!adjacentCell.IsRevealed !adjacentCell.IsFlagged) { adjacentCell.IsRevealed true; UpdateCellView(newX, newY); // 如果这个格子也是空白的则递归翻开其周围 if (adjacentCell.AdjacentMineCount 0) { RevealAdjacentCells(newX, newY); } } } } }递归的写法非常简洁符合我们对这个过程的直觉理解。但是在极端大的棋盘上虽然扫雷一般不那么大可能存在栈溢出的风险。迭代实现使用队列更安全private void RevealAdjacentCellsIterative(int startX, int startY) { QueueVector2Int cellsToCheck new QueueVector2Int(); cellsToCheck.Enqueue(new Vector2Int(startX, startY)); while (cellsToCheck.Count 0) { Vector2Int cellPos cellsToCheck.Dequeue(); int x cellPos.x; int y cellPos.y; // 遍历当前格子的周围8格 for (int i -1; i 1; i) { for (int j -1; j 1; j) { if (i 0 j 0) continue; int newX x i; int newY y j; if (newX 0 || newX width || newY 0 || newY height) continue; MineCell cell _grid[newX, newY]; if (!cell.IsRevealed !cell.IsFlagged) { cell.IsRevealed true; UpdateCellView(newX, newY); // 只有周围雷数为0的格子才需要将其周围的格子加入队列继续检查 if (cell.AdjacentMineCount 0) { cellsToCheck.Enqueue(new Vector2Int(newX, newY)); } } } } } }迭代方法使用队列广度优先搜索BFS来模拟递归过程避免了递归深度可能过深的问题是更工程化的选择。在扫雷这个场景中两种方法性能差异不大但了解迭代实现有助于理解更广泛的算法问题。4.3 游戏胜利判定与结束逻辑胜利条件很简单所有不包含地雷的格子都被翻开了。我们可以在每次翻开格子后进行检查。private void CheckForWin() { int revealedSafeCells 0; int totalSafeCells width * height - totalMines; for (int y 0; y height; y) { for (int x 0; x width; x) { if (!_grid[x, y].IsMine _grid[x, y].IsRevealed) { revealedSafeCells; } } } if (revealedSafeCells totalSafeCells) { OnGameOver(true); // 游戏胜利 } }OnGameOver(bool isWin)函数负责处理游戏结束的逻辑停止计时器。更新中央表情按钮为胜利或失败状态。如果失败揭示所有地雷的位置通常将踩中的雷标红其他雷显示出来。锁定所有格子的交互防止游戏结束后还能操作。可以弹出结算界面显示用时等信息。5. 性能优化与高级功能拓展5.1 对象池优化网格生成在Unity中频繁地实例化(Instantiate)和销毁(Destroy)GameObject是性能开销较大的操作。对于扫雷格子这种数量可能较多高级难度有480个且生命周期固定的对象使用对象池是标准的优化手段。我们可以创建一个CellObjectPool。游戏初始化时预先实例化出足够数量的格子预制体放入一个池子如QueueGameObject或ListGameObject中并禁用它们。需要创建格子时从池中取出一个设置其位置、状态并激活当游戏结束或切换难度时不销毁格子而是将其放回池中并禁用以备下次使用。public class CellObjectPool : MonoBehaviour { public GameObject cellPrefab; public int poolSize; private QueueGameObject _pool new QueueGameObject(); void Start() { for (int i 0; i poolSize; i) { GameObject obj Instantiate(cellPrefab); obj.SetActive(false); obj.transform.SetParent(this.transform); // 可以先放在一个隐藏的父节点下 _pool.Enqueue(obj); } } public GameObject GetCell() { if (_pool.Count 0) { GameObject obj _pool.Dequeue(); obj.SetActive(true); return obj; } else { // 池子空了动态扩容可选 GameObject obj Instantiate(cellPrefab); return obj; } } public void ReturnCell(GameObject obj) { obj.SetActive(false); _pool.Enqueue(obj); } }这样在多次重玩或切换难度时能极大减少GC垃圾回收压力提升游戏流畅度。5.2 “和弦点击”功能实现“和弦点击”是高级扫雷玩家常用的技巧当一个已翻开的数字格子其周围插的旗子数量恰好等于该数字时在此数字格子上同时按下鼠标左右键或点击数字格子本身可以自动翻开其周围所有未插旗的格子。这个功能能极大提升操作效率。实现逻辑如下监听一个“和弦点击”事件在Unity中可以检测鼠标左右键同时按下的状态或者在数字格子的点击事件中判断是否满足条件后触发一个特殊函数。当在某个已翻开的数字格子(x, y)上触发和弦点击时获取其周围8格。统计这些格子中IsFlagged为true的数量。如果旗子数量等于_grid[x, y].AdjacentMineCount则遍历周围8格对所有!IsFlagged !IsRevealed的格子执行RevealCell操作。public void OnChordClick(int x, int y) { MineCell cell _grid[x, y]; if (!cell.IsRevealed || cell.AdjacentMineCount 0) return; // 只有已翻开的数字格子才能和弦点击 int flaggedCount 0; ListVector2Int cellsToReveal new ListVector2Int(); // 统计周围旗子数并收集未标记未翻开的格子 for (int i -1; i 1; i) { for (int j -1; j 1; j) { if (i 0 j 0) continue; int checkX x i; int checkY y j; if (checkX 0 checkX width checkY 0 checkY height) { MineCell adjCell _grid[checkX, checkY]; if (adjCell.IsFlagged) { flaggedCount; } else if (!adjCell.IsRevealed) { cellsToReveal.Add(new Vector2Int(checkX, checkY)); } } } } // 如果旗子数等于数字则翻开收集到的格子 if (flaggedCount cell.AdjacentMineCount) { foreach (var pos in cellsToReveal) { RevealCell(pos.x, pos.y); // 注意这里会触发连锁反应和胜利检查 } } }注意事项实现和弦点击时必须确保逻辑严密。如果玩家插错了旗旗子数量不等于实际雷数和弦点击可能会导致误翻开地雷而直接判负。这正是游戏规则的一部分所以实现时要原样保留这个“风险”。5.3 游戏状态保存与读取为了让玩家可以中途退出后再回来或者提供“存档”功能我们需要实现游戏状态的序列化与反序列化。需要保存的数据包括游戏难度宽度、高度、雷数。当前已用时间。每个格子的状态IsMine,IsRevealed,IsFlagged。注意AdjacentMineCount可以通过重新计算或一并保存。我们可以定义一个可序列化的类GameSaveData使用JsonUtility.ToJson将其转换为JSON字符串然后通过PlayerPrefs或文件系统保存。读取时再反序列化回来并根据数据重建网格和UI状态。[System.Serializable] public class GameSaveData { public int width; public int height; public int totalMines; public float elapsedTime; public ListCellSaveData cellData; // 另一个可序列化类存储每个格子的状态 } // 保存 string json JsonUtility.ToJson(saveData); PlayerPrefs.SetString(MineSweeper_Save, json); PlayerPrefs.Save(); // 读取 if (PlayerPrefs.HasKey(MineSweeper_Save)) { string json PlayerPrefs.GetString(MineSweeper_Save); GameSaveData loadedData JsonUtility.FromJsonGameSaveData(json); // 根据loadedData初始化游戏 }这里有一个细节地雷的布局必须在每次新游戏或读档时保持一致。如果我们读档后重新调用Random.Range生成地雷布局就变了。因此要么在存档时保存每个格子是否是雷的信息要么使用一个固定的随机种子Random.InitState(seed)并在存档时保存这个种子读档时用同样的种子初始化随机数生成器。6. 常见问题排查与调试技巧6.1 UI显示不同步数据与视图分离最常见的问题是格子的UI显示和底层数据状态不一致。比如点击后数据IsRevealed变成了true但画面还是未翻开的样子。排查思路确认更新函数被调用在UpdateCellView(x, y)函数开始处加Debug.Log确保在数据改变后确实执行了UI更新。检查视图引用确保_cellViews[x, y]这个二维数组在生成UI时被正确赋值且没有越界或空引用。核对更新逻辑在UpdateCellView内部使用if-else或switch语句严格根据MineCell的所有状态IsRevealed,IsFlagged,IsMine,AdjacentMineCount来设置UI元素背景图、数字文本、标记图标的显示与隐藏。一个清晰的顺序是void UpdateCellView(int x, int y) { MineCell cell _grid[x, y]; MineCellView view _cellViews[x, y]; view.flagImage.SetActive(cell.IsFlagged); view.questionImage.SetActive(false); // 如果有问号标记 view.numberText.gameObject.SetActive(false); view.mineImage.SetActive(false); if (cell.IsRevealed) { view.coverImage.SetActive(false); // 覆盖层隐藏 if (cell.IsMine) { view.mineImage.SetActive(true); } else if (cell.AdjacentMineCount 0) { view.numberText.gameObject.SetActive(true); view.numberText.text cell.AdjacentMineCount.ToString(); // 还可以根据数字不同设置不同颜色 } // 如果AdjacentMineCount 0就什么都不显示保持空白 } else { view.coverImage.SetActive(true); // 显示覆盖层 // 未翻开时只可能显示旗帜或问号标记 } }6.2 递归翻开导致的栈溢出或性能卡顿在极大棋盘非标准扫雷尺寸上如果一片巨大的空白区域被翻开递归深度可能非常深在Unity中可能导致栈溢出错误StackOverflowException或明显的处理卡顿。解决方案改用迭代队列BFS实现如前文所述这是最根本的解决方法。BFS使用堆内存比递归的栈内存通常大得多更安全。添加翻开动画延迟即使使用迭代一次性更新几百个格子的UI也会造成帧率下降。可以引入一个简单的协程每帧只翻开固定数量比如10个的格子形成一种“扩散”动画效果既能分散性能压力也能提升视觉效果。IEnumerator RevealCellsWithDelay(ListVector2Int cellsToReveal) { int cellsPerFrame 10; for (int i 0; i cellsToReveal.Count; i) { Vector2Int pos cellsToReveal[i]; _grid[pos.x, pos.y].IsRevealed true; UpdateCellView(pos.x, pos.y); if (i % cellsPerFrame 0) { yield return null; // 等待下一帧 } } }6.3 第一次点击生成地雷的边界条件BUG这是一个容易出错的逻辑点。关键在于“确保第一次点击的格子及其周围一圈不是雷”。在GenerateMines函数中跳过区域的判断必须准确。常见BUG判断条件错误使用了(x firstClickX y firstClickY)只跳过了点击格本身没有跳过周围8格。生成地雷后未立即计算周围雷数在GenerateMines函数结束后必须调用CalculateAllAdjacentMines()否则第一次点击的格子显示的周围雷数可能是0实际可能不是或者后续翻开的格子数字不正确。游戏状态标志未设置忘记在第一次点击后设置_gameStarted true导致计时器没有启动或者后续点击又触发生成地雷的逻辑。调试技巧在开发初期可以暂时注释掉“第一次点击安全”的逻辑并固定随机种子Random.InitState(0)这样每次游戏地雷布局都相同方便复现和测试点击逻辑。同时在RevealCell函数开始处打印格子的坐标和状态便于跟踪执行流程。6.4 移动端适配与输入处理如果项目需要发布到手机端交互方式从鼠标变成了触摸。Unity的Event Trigger和Button组件在移动端默认可以响应触摸但右键操作没有了。移动端适配方案长按作为右键这是最常见的适配方案。监听IPointerDownHandler和IPointerUpHandler计算按下和抬起的时间差。如果时间超过某个阈值如0.5秒且期间没有移动则判定为长按执行插旗/标记逻辑。双指点击作为和弦点击可以尝试将双指同时点击一个格子映射为和弦点击操作。添加UI按钮在屏幕角落添加一个“模式切换”按钮如“挖掘/插旗”点击切换当前操作模式然后所有点击都根据当前模式来决定是翻开还是插旗。处理触摸时要特别注意EventSystem的IsPointerOverGameObject方法用来判断触摸点是否在UI元素上避免误操作。开发扫雷这样一个完整的项目就像搭积木每一步都环环相扣。从数据结构设计到UI交互从核心算法到细节优化每一个环节都能加深你对Unity和C#编程的理解。当你最终看到自己实现的扫雷流畅运行那种亲手创造出一个完整逻辑世界的成就感是无可替代的。我建议你在实现基础功能后不妨挑战一下自己试试实现“复盘”功能记录并回放每一步操作或者加入不同的“棋盘皮肤”和“音效”让这个经典游戏在你的手中焕发新的个性。