C#实战:从零构建经典扫雷游戏,掌握WinForms与核心算法
1. 项目概述为什么用C#重写扫雷是个好主意扫雷这个几乎刻在Windows用户DNA里的小游戏相信大家都不陌生。点开一个方块数字告诉你周围有多少雷靠逻辑推理标记出所有地雷看似简单实则充满了策略和“惊喜”。但你想过没有这个经典游戏背后藏着多少编程的“宝藏”今天我就带大家用C#从零开始完整地实现一个扫雷游戏。这不仅仅是一个怀旧项目更是一个绝佳的C#项目实战练兵场。为什么这么说因为一个完整的扫雷游戏几乎涵盖了桌面应用开发的核心要素。你需要处理图形界面GUI无论是用WinForms还是WPF都得和控件、事件打交道你需要设计游戏核心逻辑包括雷区的生成、数字计算、胜负判定你需要实现用户交互处理鼠标的点击、右键标记你还需要考虑状态管理比如游戏计时、重置、难度切换。整个过程下来你对C#的类设计、事件驱动、集合操作、甚至多线程如果你想让计时器更精准都会有一个非常扎实的理解。对于想巩固C#基础、准备C#面试题、或者想做一个能放进简历的项目实战案例的朋友来说没有比这更合适的了。我这次选择用**Windows FormsWinForms**来实现。原因很简单它足够轻量、直观能让我们把主要精力集中在游戏逻辑本身而不是复杂的UI框架上。当然如果你对WPF或Avalonia感兴趣理解了核心逻辑后迁移过去也并不困难。整个项目我会从最基础的雷区数据模型开始一步步构建出完整的游戏。过程中我会分享我踩过的坑和优化技巧比如如何高效地计算每个格子周围的雷数如何处理第一次点击永不踩雷的“潜规则”以及如何让代码结构清晰、易于扩展。2. 核心设计与架构思路拆解在动手写代码之前我们先得把扫雷这个游戏“拆开”看看理清它的核心组成部分。一个好的架构能让后续编码事半功倍也更容易应对需求变化比如增加新的游戏模式。2.1 数据模型游戏世界的基石扫雷游戏的核心是一个二维的网格我们称之为“雷区”Minefield。每个格子Cell是这个游戏世界里的基本单位它有自己的状态。我们需要一个类来精确地描述它。一个格子至少需要包含以下信息是否有雷IsMine布尔值这是最根本的属性。周围雷数AdjacentMineCount整数记录周围8个格子中地雷的总数。这是玩家推理的依据。当前状态State枚举类型。一个格子对玩家来说有几种可能的状态未打开Closed初始状态灰色方块。已打开Opened被点击后显示数字或空白。已标记为雷Flagged玩家右键点击插上了小红旗。标记为问号Questioned玩家不确定时的标记可选功能。有了格子我们还需要一个“雷区管理器”来管理所有这些格子。这个类比如叫Minefield负责在初始化时根据难度行数、列数、雷数创建指定大小的格子二维数组。随机布置地雷。为每个非雷格子计算周围的雷数。提供方法让外部如UI查询或修改某个格子的状态。判断游戏是否胜利所有非雷格子都被打开或失败有雷格子被打开。注意这里有一个非常重要的设计考量——数据与视图分离。Cell和Minefield类应该只关心数据逻辑它们不应该知道任何关于UI比如按钮、图片的事情。这样设计的好处是你的游戏核心逻辑可以独立测试并且未来可以轻松更换UI框架从WinForms换到WPF。2.2 用户交互与游戏流程设计游戏流程是线性的但由用户事件驱动。一个典型的流程如下初始化创建雷区所有格子为“未打开”状态。计时器归零。首次点击这是一个特殊事件。为了保证游戏体验公认的规则是第一次点击绝对不能是雷。所以我们需要在玩家第一次点击某个格子后再根据这个格子的位置来生成地雷并计算数字。这避免了开局即“暴毙”的糟糕体验。处理点击左键点击打开如果点击的格子是未打开且未标记的则打开它。如果它是雷 - 游戏结束显示所有雷。如果它不是雷但周围雷数为0空白格 - 需要自动递归地打开所有相邻的空白格以及它们的数字格边界这是扫雷的核心体验之一。如果它不是雷且周围雷数大于0 - 仅显示该数字。右键点击标记在“未打开”、“标记为雷”、“标记为问号”三种状态间循环切换。标记雷通常会影响界面左上角显示的剩余雷数。状态检查每次操作后检查游戏是否满足胜利条件所有非雷格子已打开或失败条件雷被打开。重置提供重新开始按钮可以重置雷区开始新一局。这个流程决定了我们的事件处理函数应该如何编写以及Minefield类需要提供哪些方法如OpenCell(int x, int y),ToggleFlag(int x, int y),IsGameOver,IsVictory。2.3 界面与逻辑的绑定WinForms的实现选择在WinForms中最直观的表示就是一个由Button控件组成的网格。每个Button对应一个Cell。我们需要做的是将数据模型Cell的状态同步到视图Button的显示上。例如Cell.State为Closed-Button显示为灰色。Cell.State为Opened且AdjacentMineCount为 0 -Button显示为空白文本为空。Cell.State为Opened且AdjacentMineCount 0 -Button显示为已按下样式文本显示对应的数字通常数字有不同颜色。Cell.State为Flagged-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 ActionCell 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方法确保了第一次点击的安全。CalculateAdjacentMines和CountMinesAround是扫雷算法的关键。注意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(); }关键点解析递归展开OpenCellRecursive方法是实现“一点开一片”效果的核心。它采用深度优先搜索DFS。当打开一个周围雷数为0的格子时它会尝试打开周围所有未打开的、非雷的、未标记的格子。如果周围的格子也是0则继续递归。这个过程会一直持续到遇到数字格周围雷数0或边界为止。事件驱动我们定义了CellStateChanged、GameOver、Victory等事件。数据模型Minefield不关心UI具体怎么画它只负责在状态改变时“通知”UI。这是典型的观察者模式实现了松耦合。胜利判定CheckVictory方法遍历所有格子只要还有一个非雷格子没被打开就不算赢。这个判断在每次打开格子后执行。实操心得递归展开虽然直观但在极端大的雷区比如1000x1000上如果第一次点击就点中一个巨大的空白区域可能会导致栈溢出StackOverflowException。在实际产品中可能会使用栈Stack或队列Queue来显式管理待打开的格子避免深层递归。但对于标准难度如16x3099雷递归是完全可行的。3.3 WinForms界面搭建与数据绑定现在我们来创建游戏的窗体。在Visual Studio中新建一个Windows Forms App (.NET Framework 或 .NET Core/5/6/7/8皆可)然后设计主窗体。窗体设计在顶部放一个MenuStrip或ToolStrip用于“游戏Game”菜单包含“新游戏”、“初级”、“中级”、“高级”、“退出”等选项。在菜单下方放一个Panel或TableLayoutPanel用于动态创建按钮网格。在网格上方可以放两个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 ActionCell(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(); } // ... 中级、高级菜单项类似 } }界面与逻辑绑定要点动态创建控件我们没有在窗体设计器里手动画99个按钮而是根据难度设置在代码中动态创建Button数组并设置其Tag属性来关联数据坐标。这是处理动态网格的常用技巧。事件处理我们为每个按钮的MouseDown事件绑定了同一个处理函数CellButton_MouseDown。在这个函数里我们通过e.Button来区分是左键点击打开还是右键点击标记。线程安全更新UI在OnCellStateChanged事件处理器中我们使用了InvokeRequired和Invoke。这是因为Minefield的事件可能从非UI线程触发虽然我们这个简单例子中没有但这是一个好习惯也为未来可能的多线程处理留有余地。外观更新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); // 这会触发递归展开 } } } } }注意和弦点击的实现细节有很多变体。有些游戏是按住左右键点击有些是双击。上述代码提供了双击的实现思路。更精确的实现可能需要处理MouseDown和MouseUp事件来捕获组合键状态但这会复杂一些。对于初学者双击实现已经能提供核心功能。4.2 添加游戏状态栏与表情按钮经典的扫雷窗口顶部有一个表情按钮//用于重置游戏和反映当前状态进行中、失败、胜利。我们也可以添加剩余雷数和计时器。我们已经在前面预留了_lblMinesLeft和_lblTimer。现在需要在窗体设计器里真正添加这两个Label控件以及一个作为表情按钮的Button。表情按钮逻辑默认状态笑脸 游戏进行中鼠标在格子上按下但未松开惊讶 游戏失败哭脸 游戏胜利戴墨镜的笑脸 点击表情按钮重置游戏恢复笑脸。这需要跟踪鼠标在格子按钮上的按下和释放事件来切换表情。我们可以在CellButton_MouseDown和CellButton_MouseUp中更新表情按钮的文本。4.3 持久化与设置保存最高纪录我们可以添加一个简单的功能来保存每种难度下的最快通关时间。// 在某个配置管理类或窗体类中 using System.IO; using System.Text.Json; // 需要引用 System.Text.Json public class GameSettings { public Dictionarystring, int BestTimes { get; set; } new Dictionarystring, 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.DeserializeGameSettings(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默认的调用栈大小。解决方案使用显式栈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)); } } } } } }增加线程栈大小不推荐对于控制台应用可以通过链接器选项设置栈大小但对WinForms应用不适用且不是好方法。5.2 界面卡顿或闪烁问题当快速点击或递归展开大量格子时界面更新会卡顿或闪烁。原因每个格子的状态改变都会触发一次UI更新UpdateButtonAppearance导致频繁的重绘。解决方案双缓冲WinForms控件本身支持双缓冲。可以在窗体构造函数中设置this.DoubleBuffered true; // 或者对承载按钮的Panel设置 pnlGameBoard.DoubleBuffered true;批量更新修改Minefield使其在一次操作如递归展开中收集所有状态改变的格子最后通过一个事件如CellsStateChanged(ListCell cells)一次性通知UI更新。UI收到列表后再遍历更新所有相关按钮。使用SuspendLayout和ResumeLayout在动态创建或大量修改控件时可以暂停布局逻辑。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#学习路上的一块坚实基石。