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显示和逻辑判断都基于它。
注意:为什么不用
List<List<MineCell>>?对于固定大小的网格,二维数组在内存访问速度和代码简洁性上更有优势。_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.GetComponent<MineCellView>(); 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) { Queue<Vector2Int> cellsToCheck = new Queue<Vector2Int>(); 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。游戏初始化时,预先实例化出足够数量的格子预制体,放入一个池子(如Queue<GameObject>或List<GameObject>)中并禁用它们。需要创建格子时,从池中取出一个,设置其位置、状态并激活;当游戏结束或切换难度时,不销毁格子,而是将其放回池中并禁用,以备下次使用。
public class CellObjectPool : MonoBehaviour { public GameObject cellPrefab; public int poolSize; private Queue<GameObject> _pool = new Queue<GameObject>(); 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; List<Vector2Int> cellsToReveal = new List<Vector2Int>(); // 统计周围旗子数,并收集未标记未翻开的格子 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 List<CellSaveData> 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.FromJson<GameSaveData>(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(List<Vector2Int> 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#编程的理解。当你最终看到自己实现的扫雷流畅运行,那种亲手创造出一个完整逻辑世界的成就感,是无可替代的。我建议你在实现基础功能后,不妨挑战一下自己,试试实现“复盘”功能(记录并回放每一步操作),或者加入不同的“棋盘皮肤”和“音效”,让这个经典游戏在你的手中焕发新的个性。