C#实战:从零构建经典扫雷游戏,掌握WinForms与核心算法
2026/8/4 8:35:39 网站建设 项目流程

1. 项目概述:为什么用C#重写扫雷是个好主意?

扫雷,这个几乎刻在Windows用户DNA里的小游戏,相信大家都不陌生。点开一个方块,数字告诉你周围有多少雷,靠逻辑推理标记出所有地雷,看似简单,实则充满了策略和“惊喜”。但你想过没有,这个经典游戏背后藏着多少编程的“宝藏”?今天,我就带大家用C#,从零开始,完整地实现一个扫雷游戏。这不仅仅是一个怀旧项目,更是一个绝佳的C#项目实战练兵场。

为什么这么说?因为一个完整的扫雷游戏,几乎涵盖了桌面应用开发的核心要素。你需要处理图形界面(GUI),无论是用WinForms还是WPF,都得和控件、事件打交道;你需要设计游戏核心逻辑,包括雷区的生成、数字计算、胜负判定;你需要实现用户交互,处理鼠标的点击、右键标记;你还需要考虑状态管理,比如游戏计时、重置、难度切换。整个过程下来,你对C#的类设计、事件驱动、集合操作、甚至多线程(如果你想让计时器更精准)都会有一个非常扎实的理解。对于想巩固C#基础、准备C#面试题、或者想做一个能放进简历的项目实战案例的朋友来说,没有比这更合适的了。

我这次选择用**Windows Forms(WinForms)**来实现。原因很简单,它足够轻量、直观,能让我们把主要精力集中在游戏逻辑本身,而不是复杂的UI框架上。当然,如果你对WPF或Avalonia感兴趣,理解了核心逻辑后,迁移过去也并不困难。整个项目,我会从最基础的雷区数据模型开始,一步步构建出完整的游戏。过程中,我会分享我踩过的坑和优化技巧,比如如何高效地计算每个格子周围的雷数,如何处理第一次点击永不踩雷的“潜规则”,以及如何让代码结构清晰、易于扩展。

2. 核心设计与架构思路拆解

在动手写代码之前,我们先得把扫雷这个游戏“拆开”看看,理清它的核心组成部分。一个好的架构能让后续编码事半功倍,也更容易应对需求变化,比如增加新的游戏模式。

2.1 数据模型:游戏世界的基石

扫雷游戏的核心是一个二维的网格,我们称之为“雷区”(Minefield)。每个格子(Cell)是这个游戏世界里的基本单位,它有自己的状态。我们需要一个类来精确地描述它。

一个格子至少需要包含以下信息:

  1. 是否有雷(IsMine):布尔值,这是最根本的属性。
  2. 周围雷数(AdjacentMineCount):整数,记录周围8个格子中地雷的总数。这是玩家推理的依据。
  3. 当前状态(State):枚举类型。一个格子对玩家来说,有几种可能的状态?
    • 未打开(Closed):初始状态,灰色方块。
    • 已打开(Opened):被点击后,显示数字或空白。
    • 已标记为雷(Flagged):玩家右键点击,插上了小红旗。
    • 标记为问号(Questioned):玩家不确定时的标记(可选功能)。

有了格子,我们还需要一个“雷区管理器”来管理所有这些格子。这个类(比如叫Minefield)负责:

  • 在初始化时,根据难度(行数、列数、雷数)创建指定大小的格子二维数组。
  • 随机布置地雷。
  • 为每个非雷格子计算周围的雷数。
  • 提供方法,让外部(如UI)查询或修改某个格子的状态。
  • 判断游戏是否胜利(所有非雷格子都被打开)或失败(有雷格子被打开)。

注意:这里有一个非常重要的设计考量——数据与视图分离CellMinefield类应该只关心数据逻辑,它们不应该知道任何关于UI(比如按钮、图片)的事情。这样设计的好处是,你的游戏核心逻辑可以独立测试,并且未来可以轻松更换UI框架(从WinForms换到WPF)。

2.2 用户交互与游戏流程设计

游戏流程是线性的,但由用户事件驱动。一个典型的流程如下:

  1. 初始化:创建雷区,所有格子为“未打开”状态。计时器归零。
  2. 首次点击:这是一个特殊事件。为了保证游戏体验,公认的规则是:第一次点击绝对不能是雷。所以,我们需要在玩家第一次点击某个格子后,再根据这个格子的位置来生成地雷,并计算数字。这避免了开局即“暴毙”的糟糕体验。
  3. 处理点击
    • 左键点击(打开):如果点击的格子是未打开且未标记的,则打开它。
      • 如果它是雷 -> 游戏结束,显示所有雷。
      • 如果它不是雷,但周围雷数为0(空白格) -> 需要自动递归地打开所有相邻的空白格以及它们的数字格边界,这是扫雷的核心体验之一。
      • 如果它不是雷,且周围雷数大于0 -> 仅显示该数字。
    • 右键点击(标记):在“未打开”、“标记为雷”、“标记为问号”三种状态间循环切换。标记雷通常会影响界面左上角显示的剩余雷数。
  4. 状态检查:每次操作后,检查游戏是否满足胜利条件(所有非雷格子已打开)或失败条件(雷被打开)。
  5. 重置:提供重新开始按钮,可以重置雷区,开始新一局。

这个流程决定了我们的事件处理函数应该如何编写,以及Minefield类需要提供哪些方法(如OpenCell(int x, int y),ToggleFlag(int x, int y),IsGameOver,IsVictory)。

2.3 界面与逻辑的绑定:WinForms的实现选择

在WinForms中,最直观的表示就是一个由Button控件组成的网格。每个Button对应一个Cell。我们需要做的是将数据模型(Cell)的状态,同步到视图(Button)的显示上。

例如:

  • Cell.StateClosed->Button显示为灰色。
  • Cell.StateOpenedAdjacentMineCount为 0 ->Button显示为空白,文本为空。
  • Cell.StateOpenedAdjacentMineCount> 0 ->Button显示为已按下样式,文本显示对应的数字(通常数字有不同颜色)。
  • Cell.StateFlagged->Button显示为小红旗图标或特定文字/颜色。

我们将通过事件处理器(button_Click,button_MouseDown)来捕获用户的点击,然后调用Minefield的相应方法,再根据返回的结果更新UI。

3. 核心模块实现与代码解析

理论说得差不多了,现在让我们进入实战环节,看看关键代码如何落地。我会先搭建数据模型,再实现核心算法,最后绑定UI。

3.1 数据模型类的实现

首先,我们定义格子的状态和格子类本身。

// CellState.cs namespace MineSweeper.Core { public enum CellState { Closed, // 未打开 Opened, // 已打开 Flagged, // 已标记为雷 Questioned // 标记为问号(可选) } }
// Cell.cs namespace MineSweeper.Core { public class Cell { // 核心属性 public bool IsMine { get; set; } public int AdjacentMineCount { get; set; } public CellState State { get; set; } // 坐标(可选,便于调试和某些算法) public int X { get; } public int Y { get; } public Cell(int x, int y) { X = x; Y = y; State = CellState.Closed; IsMine = false; AdjacentMineCount = 0; } // 一些便捷属性 public bool IsOpen => State == CellState.Opened; public bool IsFlagged => State == CellState.Flagged; } }

接下来是重头戏,雷区管理类Minefield。它的构造函数接受行数、列数和雷数。

// Minefield.cs using System; using System.Collections.Generic; using System.Linq; namespace MineSweeper.Core { public class Minefield { private readonly Cell[,] _cells; private readonly int _rows; private readonly int _cols; private readonly int _totalMines; private bool _isFirstClick = true; private bool _gameOver = false; private bool _isVictory = false; public int Rows => _rows; public int Cols => _cols; public int TotalMines => _totalMines; public int FlagsPlaced { get; private set; } public bool IsGameOver => _gameOver; public bool IsVictory => _isVictory; public Cell this[int x, int y] => _cells[x, y]; public event Action GameOver; public event Action Victory; public event Action<Cell> CellStateChanged; // 通知UI某个格子状态变了 public Minefield(int rows, int cols, int totalMines) { if (totalMines >= rows * cols) throw new ArgumentException("雷数不能大于或等于格子总数!"); _rows = rows; _cols = cols; _totalMines = totalMines; _cells = new Cell[rows, cols]; // 初始化所有格子 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { _cells[i, j] = new Cell(i, j); } } } // 在首次点击后布置地雷 private void PlaceMines(int firstClickX, int firstClickY) { var random = new Random(); int minesPlaced = 0; // 确保首次点击的格子及其周围一圈都不是雷,提升体验 var safeZone = GetNeighborCoordinates(firstClickX, firstClickY); safeZone.Add((firstClickX, firstClickY)); while (minesPlaced < _totalMines) { int x = random.Next(_rows); int y = random.Next(_cols); // 如果这个位置在安全区,或者是雷,则跳过 if (safeZone.Contains((x, y)) || _cells[x, y].IsMine) continue; _cells[x, y].IsMine = true; minesPlaced++; } // 为所有非雷格子计算周围雷数 CalculateAdjacentMines(); } // 计算每个格子周围的雷数 private void CalculateAdjacentMines() { for (int i = 0; i < _rows; i++) { for (int j = 0; j < _cols; j++) { if (!_cells[i, j].IsMine) { _cells[i, j].AdjacentMineCount = CountMinesAround(i, j); } } } } // 统计一个格子周围8个方向的雷数 private int CountMinesAround(int x, int y) { int count = 0; foreach (var (nx, ny) in GetNeighborCoordinates(x, y)) { if (nx >= 0 && nx < _rows && ny >= 0 && ny < _cols && _cells[nx, ny].IsMine) count++; } return count; } // 获取周围格子的坐标(包括对角) private List<(int, int)> GetNeighborCoordinates(int x, int y) { var neighbors = new List<(int, int)>(); for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { if (dx == 0 && dy == 0) continue; neighbors.Add((x + dx, y + dy)); } } return neighbors; } } }

代码写到这里,我们已经有了一个完整的数据核心。PlaceMines方法确保了第一次点击的安全。CalculateAdjacentMinesCountMinesAround是扫雷算法的关键。注意GetNeighborCoordinates方法,它封装了获取周围格子坐标的逻辑,在多个地方都会用到,这样写避免了代码重复。

3.2 游戏逻辑核心:打开格子与递归展开

这是扫雷游戏最精髓的部分。当玩家点击一个非雷且周围雷数为0的格子时,需要自动展开一片区域。我们来实现OpenCell方法。

// 在 Minefield 类中继续添加方法 public bool OpenCell(int x, int y) { // 边界检查和状态检查 if (_gameOver || _isVictory || x < 0 || x >= _rows || y < 0 || y >= _cols) return false; var cell = _cells[x, y]; // 已打开或已标记的格子不能直接打开 if (cell.IsOpen || cell.IsFlagged) return false; // 首次点击的特殊处理 if (_isFirstClick) { _isFirstClick = false; PlaceMines(x, y); // 在首次点击位置周围安全地布雷 } // 如果点到雷 if (cell.IsMine) { cell.State = CellState.Opened; _gameOver = true; CellStateChanged?.Invoke(cell); GameOver?.Invoke(); // 触发游戏结束事件 return false; } // 正常打开格子 OpenCellRecursive(x, y); // 检查是否胜利 CheckVictory(); return true; } // 递归打开格子的核心方法 private void OpenCellRecursive(int x, int y) { // 边界检查 if (x < 0 || x >= _rows || y < 0 || y >= _cols) return; var cell = _cells[x, y]; // 如果格子已经打开、是雷、或者被标记,则停止递归 if (cell.IsOpen || cell.IsMine || cell.IsFlagged) return; // 打开当前格子 cell.State = CellState.Opened; CellStateChanged?.Invoke(cell); // 如果当前格子周围雷数为0,则递归打开周围的格子 if (cell.AdjacentMineCount == 0) { foreach (var (nx, ny) in GetNeighborCoordinates(x, y)) { OpenCellRecursive(nx, ny); } } // 如果周围有雷,则只打开当前格子(显示数字),递归停止 } // 切换标记状态 public void ToggleFlag(int x, int y) { if (_gameOver || _isVictory || x < 0 || x >= _rows || y < 0 || y >= _cols) return; var cell = _cells[x, y]; if (cell.IsOpen) return; // 已打开的格子不能标记 switch (cell.State) { case CellState.Closed: cell.State = CellState.Flagged; FlagsPlaced++; break; case CellState.Flagged: cell.State = CellState.Questioned; // 或变回Closed,根据设计 FlagsPlaced--; break; case CellState.Questioned: cell.State = CellState.Closed; break; } CellStateChanged?.Invoke(cell); } // 检查胜利条件 private void CheckVictory() { // 胜利条件:所有非雷格子都被打开 for (int i = 0; i < _rows; i++) { for (int j = 0; j < _cols; j++) { var cell = _cells[i, j]; if (!cell.IsMine && !cell.IsOpen) { _isVictory = false; return; } } } _isVictory = true; _gameOver = true; // 游戏结束,胜利 Victory?.Invoke(); }

关键点解析

  1. 递归展开OpenCellRecursive方法是实现“一点开一片”效果的核心。它采用深度优先搜索(DFS)。当打开一个周围雷数为0的格子时,它会尝试打开周围所有未打开的、非雷的、未标记的格子。如果周围的格子也是0,则继续递归。这个过程会一直持续到遇到数字格(周围雷数>0)或边界为止。
  2. 事件驱动:我们定义了CellStateChangedGameOverVictory等事件。数据模型(Minefield)不关心UI具体怎么画,它只负责在状态改变时“通知”UI。这是典型的观察者模式,实现了松耦合。
  3. 胜利判定CheckVictory方法遍历所有格子,只要还有一个非雷格子没被打开,就不算赢。这个判断在每次打开格子后执行。

实操心得:递归展开虽然直观,但在极端大的雷区(比如1000x1000)上,如果第一次点击就点中一个巨大的空白区域,可能会导致栈溢出(StackOverflowException)。在实际产品中,可能会使用栈(Stack)或队列(Queue)来显式管理待打开的格子,避免深层递归。但对于标准难度(如16x30,99雷),递归是完全可行的。

3.3 WinForms界面搭建与数据绑定

现在,我们来创建游戏的窗体。在Visual Studio中新建一个Windows Forms App (.NET Framework 或 .NET Core/5/6/7/8皆可),然后设计主窗体。

窗体设计

  1. 在顶部放一个MenuStripToolStrip,用于“游戏(Game)”菜单,包含“新游戏”、“初级”、“中级”、“高级”、“退出”等选项。
  2. 在菜单下方放一个PanelTableLayoutPanel,用于动态创建按钮网格。
  3. 在网格上方,可以放两个Label显示剩余雷数和用时。

核心代码(Form1.cs 部分)

using MineSweeper.Core; using System; using System.Drawing; using System.Windows.Forms; namespace MineSweeper.WinForms { public partial class MainForm : Form { private Minefield _minefield; private Button[,] _cellButtons; private Timer _gameTimer; private int _elapsedSeconds = 0; private Label _lblMinesLeft; private Label _lblTimer; // 难度预设 private readonly (int rows, int cols, int mines)[] _difficulties = new[] { (9, 9, 10), // 初级 (16, 16, 40), // 中级 (16, 30, 99) // 高级 }; private int _currentDifficulty = 0; public MainForm() { InitializeComponent(); InitializeGame(); SetupTimer(); } private void InitializeGame() { // 清除旧的按钮网格 if (_cellButtons != null) { foreach (var btn in _cellButtons) { pnlGameBoard.Controls.Remove(btn); btn.Dispose(); } } var (rows, cols, mines) = _difficulties[_currentDifficulty]; _minefield = new Minefield(rows, cols, mines); _cellButtons = new Button[rows, cols]; // 订阅事件 _minefield.CellStateChanged += OnCellStateChanged; _minefield.GameOver += OnGameOver; _minefield.Victory += OnVictory; // 创建按钮网格 int buttonSize = 30; // 每个格子按钮的大小 pnlGameBoard.Size = new Size(cols * buttonSize, rows * buttonSize); this.ClientSize = new Size(pnlGameBoard.Width, pnlGameBoard.Height + menuStrip1.Height + 40); // 调整窗体大小 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { var btn = new Button { Size = new Size(buttonSize, buttonSize), Location = new Point(j * buttonSize, i * buttonSize), Font = new Font("Arial", 10, FontStyle.Bold), FlatStyle = FlatStyle.Flat, Margin = new Padding(0), Tag = (i, j) // 用Tag存储坐标 }; btn.MouseDown += CellButton_MouseDown; // 处理鼠标按下(区分左右键) _cellButtons[i, j] = btn; pnlGameBoard.Controls.Add(btn); UpdateButtonAppearance(i, j); // 初始化按钮外观 } } UpdateMinesLeft(); ResetTimer(); } private void CellButton_MouseDown(object sender, MouseEventArgs e) { var btn = (Button)sender; var (x, y) = ((int, int))btn.Tag; if (_minefield.IsGameOver) return; switch (e.Button) { case MouseButtons.Left: _minefield.OpenCell(x, y); break; case MouseButtons.Right: _minefield.ToggleFlag(x, y); UpdateMinesLeft(); break; } } // 根据Cell状态更新按钮外观 private void UpdateButtonAppearance(int x, int y) { var btn = _cellButtons[x, y]; var cell = _minefield[x, y]; btn.BackColor = SystemColors.Control; btn.Text = ""; btn.ForeColor = Color.Black; btn.Enabled = true; btn.FlatAppearance.BorderSize = 1; switch (cell.State) { case CellState.Closed: btn.Text = ""; btn.BackColor = SystemColors.ControlDark; break; case CellState.Opened: btn.FlatAppearance.BorderSize = 0; btn.Enabled = false; // 已打开的格子不可点击 if (cell.IsMine) { btn.Text = "💣"; // 可以用字符或图片 btn.BackColor = Color.Red; } else if (cell.AdjacentMineCount > 0) { btn.Text = cell.AdjacentMineCount.ToString(); // 给数字设置不同颜色,这是经典扫雷的视觉提示 btn.ForeColor = GetNumberColor(cell.AdjacentMineCount); } // 如果周围雷数为0,就是空白,保持默认 break; case CellState.Flagged: btn.Text = "🚩"; // 红旗标记 btn.ForeColor = Color.Red; break; case CellState.Questioned: btn.Text = "?"; btn.ForeColor = Color.Blue; break; } } private Color GetNumberColor(int num) { return num switch { 1 => Color.Blue, 2 => Color.Green, 3 => Color.Red, 4 => Color.DarkBlue, 5 => Color.DarkRed, 6 => Color.Teal, 7 => Color.Black, 8 => Color.Gray, _ => Color.Black }; } // 事件处理器:当格子状态改变时更新UI private void OnCellStateChanged(Cell cell) { // 必须在UI线程上更新控件 if (InvokeRequired) { Invoke(new Action<Cell>(OnCellStateChanged), cell); return; } UpdateButtonAppearance(cell.X, cell.Y); } private void UpdateMinesLeft() { if (_lblMinesLeft != null) _lblMinesLeft.Text = $"雷数: {_minefield.TotalMines - _minefield.FlagsPlaced}"; } private void SetupTimer() { _gameTimer = new Timer { Interval = 1000 }; // 1秒触发一次 _gameTimer.Tick += (s, e) => { _elapsedSeconds++; if (_lblTimer != null) _lblTimer.Text = $"时间: {_elapsedSeconds:D3}"; }; } private void ResetTimer() { _gameTimer.Stop(); _elapsedSeconds = 0; if (_lblTimer != null) _lblTimer.Text = "时间: 000"; } private void StartTimer() { if (!_minefield.IsGameOver && !_minefield.IsVictory) { ResetTimer(); _gameTimer.Start(); } } private void OnGameOver() { _gameTimer.Stop(); // 游戏结束,显示所有雷(可选) RevealAllMines(); MessageBox.Show("游戏结束!你踩到雷了!", "扫雷", MessageBoxButtons.OK, MessageBoxIcon.Information); } private void OnVictory() { _gameTimer.Stop(); MessageBox.Show($"恭喜你赢了!用时 {_elapsedSeconds} 秒。", "扫雷", MessageBoxButtons.OK, MessageBoxIcon.Exclamation); } private void RevealAllMines() { for (int i = 0; i < _minefield.Rows; i++) { for (int j = 0; j < _minefield.Cols; j++) { var cell = _minefield[i, j]; if (cell.IsMine && cell.State != CellState.Flagged) // 如果是雷且没被正确标记 { // 强制将其状态改为Opened以便显示 // 注意:这里直接修改了Cell状态,绕过了游戏逻辑,仅用于展示 cell.State = CellState.Opened; UpdateButtonAppearance(i, j); } } } } // 菜单项点击事件 private void newGameToolStripMenuItem_Click(object sender, EventArgs e) { InitializeGame(); ResetTimer(); } private void beginnerToolStripMenuItem_Click(object sender, EventArgs e) { _currentDifficulty = 0; InitializeGame(); ResetTimer(); } // ... 中级、高级菜单项类似 } }

界面与逻辑绑定要点

  1. 动态创建控件:我们没有在窗体设计器里手动画99个按钮,而是根据难度设置,在代码中动态创建Button数组,并设置其Tag属性来关联数据坐标。这是处理动态网格的常用技巧。
  2. 事件处理:我们为每个按钮的MouseDown事件绑定了同一个处理函数CellButton_MouseDown。在这个函数里,我们通过e.Button来区分是左键点击(打开)还是右键点击(标记)。
  3. 线程安全更新UI:在OnCellStateChanged事件处理器中,我们使用了InvokeRequiredInvoke。这是因为Minefield的事件可能从非UI线程触发(虽然我们这个简单例子中没有,但这是一个好习惯,也为未来可能的多线程处理留有余地)。
  4. 外观更新UpdateButtonAppearance方法根据Cell对象的状态,设置按钮的文本、颜色、是否可用等属性,实现了数据到视图的同步。

至此,一个功能完整的扫雷游戏核心就实现了。运行程序,你应该能看到一个可以玩的标准扫雷游戏。

4. 功能增强与优化实战

一个基础版本完成了,但离一个“好用”的扫雷还有距离。我们可以从用户体验和代码健壮性上做一些增强。

4.1 实现“和弦点击”(Chording)

和弦点击是扫雷的高级技巧,也是官方版本具备的功能。当你点开一个数字格,并且它周围已标记的旗子数等于这个数字时,同时按下鼠标左右键(或左键双击),可以自动打开周围所有未标记的格子。这能极大提升游戏速度。

实现原理是:在鼠标按下事件中,判断是否同时按下了左右键。如果是,则检查当前格子的状态,如果它是已打开的数字格,并且周围标记的旗子数等于其数字,则自动打开周围未打开且未标记的格子。

我们需要修改CellButton_MouseDown事件处理逻辑:

private void CellButton_MouseDown(object sender, MouseEventArgs e) { var btn = (Button)sender; var (x, y) = ((int, int))btn.Tag; var cell = _minefield[x, y]; if (_minefield.IsGameOver) return; // 和弦点击:左右键同时按下(或模拟双击) if (e.Button == MouseButtons.Left && Control.ModifierKeys == Keys.Control) { // 或者更精确地检测两个键都按下,需要一些额外处理 // 这里简化处理,用Ctrl+左键模拟 PerformChording(x, y); return; } // 更通用的检测:在MouseUp事件中判断可能更准确 // 这里为了简化,我们新增一个MouseUp事件来处理和弦逻辑 } // 在窗体构造函数或初始化中为按钮添加MouseUp事件 btn.MouseUp += CellButton_MouseUp; private void CellButton_MouseUp(object sender, MouseEventArgs e) { // 检测和弦点击(左键按下后,在按钮上释放时,检查右键是否也被按过?) // 一个更可靠的实现是记录鼠标按下的状态。 // 另一种常见做法是:在数字格上双击左键触发和弦。 } // 实现双击左键触发和弦 private void CellButton_DoubleClick(object sender, EventArgs e) { if (_minefield.IsGameOver) return; var btn = (Button)sender; var (x, y) = ((int, int))btn.Tag; var cell = _minefield[x, y]; // 只有已打开的数字格才能触发和弦 if (cell.IsOpen && cell.AdjacentMineCount > 0) { PerformChording(x, y); } } private void PerformChording(int x, int y) { var cell = _minefield[x, y]; if (!cell.IsOpen || cell.AdjacentMineCount == 0) return; // 计算周围已标记的旗子数 int flaggedCount = 0; foreach (var (nx, ny) in GetNeighborCoordinates(x, y)) // 需要能访问到Minefield的GetNeighborCoordinates { if (nx >= 0 && nx < _minefield.Rows && ny >= 0 && ny < _minefield.Cols) { if (_minefield[nx, ny].IsFlagged) flaggedCount++; } } // 如果旗子数等于格子数字,则打开周围未标记且未打开的格子 if (flaggedCount == cell.AdjacentMineCount) { foreach (var (nx, ny) in GetNeighborCoordinates(x, y)) { if (nx >= 0 && nx < _minefield.Rows && ny >= 0 && ny < _minefield.Cols) { var neighbor = _minefield[nx, ny]; if (!neighbor.IsOpen && !neighbor.IsFlagged) { _minefield.OpenCell(nx, ny); // 这会触发递归展开 } } } } }

注意:和弦点击的实现细节有很多变体。有些游戏是按住左右键点击,有些是双击。上述代码提供了双击的实现思路。更精确的实现可能需要处理MouseDownMouseUp事件来捕获组合键状态,但这会复杂一些。对于初学者,双击实现已经能提供核心功能。

4.2 添加游戏状态栏与表情按钮

经典的扫雷窗口顶部有一个表情按钮(😊/😵/😎),用于重置游戏和反映当前状态(进行中、失败、胜利)。我们也可以添加剩余雷数和计时器。

我们已经在前面预留了_lblMinesLeft_lblTimer。现在需要在窗体设计器里真正添加这两个Label控件,以及一个作为表情按钮的Button

表情按钮逻辑

  • 默认状态:笑脸 😊
  • 游戏进行中,鼠标在格子上按下但未松开:惊讶 😮
  • 游戏失败:哭脸 😵
  • 游戏胜利:戴墨镜的笑脸 😎
  • 点击表情按钮:重置游戏,恢复笑脸。

这需要跟踪鼠标在格子按钮上的按下和释放事件,来切换表情。我们可以在CellButton_MouseDownCellButton_MouseUp中更新表情按钮的文本。

4.3 持久化与设置:保存最高纪录

我们可以添加一个简单的功能来保存每种难度下的最快通关时间。

// 在某个配置管理类或窗体类中 using System.IO; using System.Text.Json; // 需要引用 System.Text.Json public class GameSettings { public Dictionary<string, int> BestTimes { get; set; } = new Dictionary<string, int>(); } public partial class MainForm : Form { private GameSettings _settings; private const string SettingsFile = "minesweeper_settings.json"; private void LoadSettings() { if (File.Exists(SettingsFile)) { try { string json = File.ReadAllText(SettingsFile); _settings = JsonSerializer.Deserialize<GameSettings>(json); } catch { /* 如果文件损坏,使用默认设置 */ } } _settings ??= new GameSettings(); } private void SaveSettings() { try { string json = JsonSerializer.Serialize(_settings, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(SettingsFile, json); } catch { /* 忽略保存错误 */ } } private void CheckAndUpdateBestTime(int time) { string key = $"BestTime_{_currentDifficulty}"; if (!_settings.BestTimes.ContainsKey(key) || time < _settings.BestTimes[key]) { _settings.BestTimes[key] = time; SaveSettings(); MessageBox.Show($"新纪录!{time} 秒", "恭喜", MessageBoxButtons.OK, MessageBoxIcon.Star); } } // 在 OnVictory 方法中调用 private void OnVictory() { _gameTimer.Stop(); CheckAndUpdateBestTime(_elapsedSeconds); MessageBox.Show($"恭喜你赢了!用时 {_elapsedSeconds} 秒。", "扫雷", MessageBoxButtons.OK, MessageBoxIcon.Exclamation); } }

5. 常见问题排查与调试技巧

在开发过程中,你肯定会遇到各种问题。这里我总结几个常见的坑和解决方法。

5.1 递归展开导致栈溢出

问题:在大型雷区(如自定义的很大尺寸),点击一个空白区域可能导致StackOverflowException原因:递归深度太深,超出了.NET默认的调用栈大小。解决方案

  1. 使用显式栈(Stack)代替递归。这是最根本的解决方法。将递归算法改为迭代算法。
    private void OpenCellIterative(int startX, int startY) { var stack = new Stack<(int, int)>(); stack.Push((startX, startY)); while (stack.Count > 0) { var (x, y) = stack.Pop(); // ... 边界和状态检查与递归版本相同 ... _cells[x, y].State = CellState.Opened; CellStateChanged?.Invoke(_cells[x, y]); if (_cells[x, y].AdjacentMineCount == 0) { foreach (var (nx, ny) in GetNeighborCoordinates(x, y)) { // 检查邻居是否有效且未处理,然后压栈 if (nx >= 0 && nx < _rows && ny >= 0 && ny < _cols) { var neighbor = _cells[nx, ny]; if (!neighbor.IsOpen && !neighbor.IsMine && !neighbor.IsFlagged) { stack.Push((nx, ny)); } } } } } }
  2. 增加线程栈大小(不推荐):对于控制台应用,可以通过链接器选项设置栈大小,但对WinForms应用不适用且不是好方法。

5.2 界面卡顿或闪烁

问题:当快速点击或递归展开大量格子时,界面更新会卡顿或闪烁。原因:每个格子的状态改变都会触发一次UI更新(UpdateButtonAppearance),导致频繁的重绘。解决方案

  1. 双缓冲:WinForms控件本身支持双缓冲。可以在窗体构造函数中设置:
    this.DoubleBuffered = true; // 或者对承载按钮的Panel设置 pnlGameBoard.DoubleBuffered = true;
  2. 批量更新:修改Minefield,使其在一次操作(如递归展开)中,收集所有状态改变的格子,最后通过一个事件(如CellsStateChanged(List<Cell> cells))一次性通知UI更新。UI收到列表后,再遍历更新所有相关按钮。
  3. 使用SuspendLayoutResumeLayout:在动态创建或大量修改控件时,可以暂停布局逻辑。
    pnlGameBoard.SuspendLayout(); // ... 创建或修改大量按钮 ... pnlGameBoard.ResumeLayout(false); pnlGameBoard.PerformLayout(); // 如果需要立即布局

5.3 首次点击生成地雷的算法缺陷

问题:我们之前的PlaceMines方法,在排除首次点击周围一圈后,使用随机数生成地雷。理论上没问题,但如果雷区很大而安全区也很大,随机数可能会长时间找不到有效位置,导致循环次数过多甚至死循环(概率极低但存在)。优化方案:预先生成所有可布雷位置的列表,然后随机打乱并取前N个。

private void PlaceMines(int firstClickX, int firstClickY) { var random = new Random(); var safeZone = GetNeighborCoordinates(firstClickX, firstClickY); safeZone.Add((firstClickX, firstClickY)); // 1. 生成所有可能位置的列表 var allPositions = new List<(int, int)>(); for (int i = 0; i < _rows; i++) for (int j = 0; j < _cols; j++) allPositions.Add((i, j)); // 2. 移除安全区位置 allPositions.RemoveAll(pos => safeZone.Contains(pos)); // 3. 随机打乱列表(Fisher-Yates洗牌算法) for (int i = allPositions.Count - 1; i > 0; i--) { int j = random.Next(i + 1); var temp = allPositions[i]; allPositions[i] = allPositions[j]; allPositions[j] = temp; } // 4. 取前_totalMines个位置布雷 for (int i = 0; i < _totalMines; i++) { var (x, y) = allPositions[i]; _cells[x, y].IsMine = true; } CalculateAdjacentMines(); }

这种方法效率更高,且完全避免了死循环。

5.4 游戏状态管理混乱

问题:游戏状态(进行中、已结束、已胜利)的判断逻辑分散,容易出错。经验:将游戏状态集中管理。我们已经有了_gameOver_isVictory。确保任何可能改变游戏状态的操作(OpenCell,ToggleFlag)开始前都检查_gameOver。在OpenCell中踩雷,立即设置_gameOver = true并触发事件。在CheckVictory中确认胜利后,设置_isVictory = true_gameOver = true。这样,_gameOver是游戏是否可操作的最终标志。

5.5 内存泄漏隐患

问题:在InitializeGame中,我们创建了新的Minefield并订阅了事件。如果反复开始新游戏,旧Minefield实例可能因为事件订阅而无法被垃圾回收。解决:在创建新Minefield前,取消对旧实例的事件订阅。

private void InitializeGame() { // 取消旧实例的事件订阅 if (_minefield != null) { _minefield.CellStateChanged -= OnCellStateChanged; _minefield.GameOver -= OnGameOver; _minefield.Victory -= OnVictory; } // ... 后续创建新实例并订阅事件 ... }

这是一个良好的编程习惯,特别是在存在对象生命周期管理的场景中。

通过这个完整的C#扫雷游戏源码项目实战,我们从零构建了一个经典的桌面游戏。这个过程涵盖了C#桌面开发的核心:数据模型设计、事件驱动编程、递归算法、UI绑定、状态管理以及一些性能优化和调试技巧。你可以在此基础上继续扩展,比如添加音效、更炫酷的皮肤、关卡编辑器、甚至网络对战功能。希望这个项目能成为你C#学习路上的一块坚实基石。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询