简介基于MFC的扫雷程序设计是一份适合C/MFC学习者参考的完整工程围绕经典扫雷玩法实现了雷区生成、左右键翻开、插旗标记等核心逻辑并仿照经典扫雷界面进行绘制可直接运行体验。压缩包为RAR格式共82个文件容量22.49MB其中既包括cpp/h源代码与bmp/ico/rc界面资源也包含sln/vcxproj等VS工程配置以及编译生成的exe/obj/pdb便于读者查看工程结构、重新编译或直接启动程序。项目按MFC常规架构拆分游戏逻辑与界面控制适合作为课程设计、MFC入门实践或期末项目的参考模板。已有208人学习/下载对打算快速上手MFC桌面程序开发的读者而言是一份体量适中、结构清晰的可用示例。1. 把 MFC 扫雷源码当教案一份能直接跑的经典程序如果你正在学 C 和 MFC又不想整天对着控制台的黑框做练习题那这份基于 MFC 的扫雷程序设计资源会是个不错的练手项目。它不是一个教学视频或 PPT而是一整套完整的 VS2012 工程源码包含 Mine.sln、MineDlg.cpp、Game.cpp、Cell.h 这些核心文件以及编译好的 Mine.exe下载解压后双击 exe 就能玩到界面和经典扫雷几乎一致的成品。整个程序的核心逻辑用纯 C 实现MFC 负责窗口、菜单、消息映射和绘图内部使用了一个按格子划分的数组来维护雷区数据通过定时器和 GDI 绘制完成棋盘刷新。这个资源特别适合两类人一类是刚做完课程设计、想看看别人的 MFC 程序是怎么拆模块的在校生尤其是把 C 面向对象封装和 MFC 消息机制结合起来看另一类是想在 MFC 里做自绘控件或自定义窗口背景的开发者因为扫雷的格子完全是自己画的比直接用按钮控件实现更有技术含量。源码里类的分工很清楚Game 管逻辑、Cell 管单格状态、MineDlg 管交互直接改这几个类的代码就能验证很多想法。后面我会按类拆分讲实现再把编译时最容易翻车的几个坑单独拎出来。2. 扫雷程序的类划分从 MFC 单文档到对话框程序2.1 为什么用对话框模板而不是单文档视图架构打开资源里的 Mine.sln你会发现这套程序走的是 MFC 对话框路线核心文件是 MineDlg.cpp 和 MineDlg.h而工程里虽然有 MineView.cpp、MineDoc.cpp 这些单文档程序才有的文件但它们只是 VS 向导生成的骨架真正干活的其实是两个对话框类和一个游戏逻辑类。这里有个容易看懵的点既然视图和文档文件被保留在工程里初学者可能会误以为程序用了文档视图架构双击打开 MineView.cpp 一看全是空实现就以为源码有问题。其实这是 MFC 生成工程时的历史遗留当你选择基于对话框的应用类型后向导依旧会生成这些文件占位但没有编译进实际业务。选择对话框模板做扫雷是合理的因为扫雷没有数据持久化需求也不需要多视图切换一个主对话框承载棋盘就够了。如果你打开资源里的 MainFrm.cpp会发现它只做了窗口标题和初始尺寸的设置因为主框架窗口只负责容纳对话框类似一个薄壳。在实际改这个项目时常见做法是把 MineDlg 当作主窗口类把棋盘绘制放在它的 OnPaint 里把鼠标点按事件放在 OnLButtonDown 和 OnRButtonDown 里所有游戏状态的变化都在 Mine 对象的成员函数中完成消息循环则由 MFC 自动驱动。2.2 Game、Cell、Mine 三个类的职责边界源码里最值得读的是 Game.cpp 和 Cell.h。Cell.h 定义了一个格子最基础的状态数据我展开讲一下#pragma once class Cell { public: Cell(); ~Cell(); public: BOOL m_bIsMine; // 是否是地雷 BOOL m_bIsOpen; // 是否已经被翻开 BOOL m_bIsFlag; // 是否被插了红旗 int m_nMineCount; // 周围八个格子的地雷数量 int m_nRow; // 行坐标 int m_nCol; // 列坐标 };这个类没有继承任何 MFC 基类就是一个纯 C 数据类内部全部用 BOOL 和 int 记录格子的原始状态。m_bIsMine 为 TRUE 表示埋雷m_bIsOpen 为 TRUE 表示左键翻过m_bIsFlag 为 TRUE 表示玩家插旗m_nMineCount 是周边八格的雷数值域 0 到 8。把坐标也存进格子是为了后期绘制和排雷时快速倒推位置不需要再用双重循环遍历找坐标。Game.cpp 则是负责布雷和排雷算法的类它的核心接口是 InitGame、OpenCell、OpenEmptyCell 和 CheckWin。InitGame 负责把棋盘大小、地雷数量、格子数组初始化OpenCell 在左键点击后调用判断踩雷或展开数字。这里要注意一个设计扫雷的核心逻辑完全不依赖 MFCGame 不知道窗口长什么样MineDlg 只负责把鼠标坐标转换成行列号然后调用 Game 的方法再触发重绘。这种界面与逻辑分离的做法是这个资源最有教学价值的地方。我在自己写桌面工具时也习惯这样拆因为将来把逻辑移植到 Qt 或控制台程序时Game.cpp 和 Cell.h 可以原封不动搬过去。2.3 MineDlg 的消息映射与界面刷新再看 MineDlg.cpp它的类继承自 CDialogEx重写了 OnInitDialog、OnPaint 和鼠标消息处理。这里贴一段关键的消息映射代码BEGIN_MESSAGE_MAP(CMineDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_TIMER() ON_BN_CLICKED(IDC_BUTTON_NEWGAME, CMineDlg::OnBnClickedButtonNewgame) END_MESSAGE_MAP()ON_WM_PAINT 把绘制工作绑定到 OnPaintON_WM_LBUTTONDOWN 处理左键翻开格子ON_WM_RBUTTONDOWN 处理右键插旗ON_WM_TIMER 是计时器回调用于记录扫雷耗时而 IDC_BUTTON_NEWGAME 对应界面上的新游戏按钮。注意 ON_WM_LBUTTONDOWN 和 ON_WM_RBUTTONDOWN 分别绑定处理器后你在 MineDlg.cpp 里会发现它们都调用了 Game 对象的接口但没有一个消息处理器直接修改雷区数组全部经由 OnPaint 中的绘制函数读取。这保证了消息处理只做两件事修改数据、触发 InvalidateRect 重绘。对于刚接触 MFC 的人容易犯的错误是直接在 OnLButtonDown 里画格子结果窗口一被遮挡或最小化就会花屏。这个工程里的正确思路是 OnLButtonDown 只改状态重绘永远走 OnPaint这是 MFC 窗口编程的核心习惯。2.4 棋盘尺寸的初始化和坐标换算MineDlg 的 OnInitDialog 里会设置棋盘参数通常是宽 9 格、高 9 格、10 颗雷这是经典扫雷的初级难度。从代码中可以看到布雷与坐标映射的实现逻辑BOOL CMineDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 游戏参数9x9 棋盘10 颗雷 m_pGame new Game(); m_pGame-InitGame(9, 9, 10); // 时钟用于计时 SetTimer(TIMER_ID, 1000, NULL); return TRUE; }InitGame 的三个参数分别是行数、列数、雷数。如果你改成 16、16、40就能得到中级难度的棋盘但界面尺寸和绘制函数都得同步改。MineDlg.cpp 里有个 GetCellRect 函数它把客户区的宽度和高度除以行列数计算出每个格子的像素宽度然后返回 CRect。这个函数的参数化处理得不错因为扫雷每格大小是动态计算的窗口拉大时格子跟着变大不会出现棋盘固定像素导致的缩放问题。如果这里写死 40 像素一格那窗口最大化后就只有左上角一小块区域有棋盘。鼠标点击到行列号的换算则是用点击坐标除以单格像素宽度向下取整。3. 核心算法实现布雷、递归翻开与胜利判定3.1 地雷分布的随机生成策略布雷函数在 Game.cpp 里它的实现逻辑是先把一个临时数组填成没有雷的状态再随机放置指定数量的雷。这里直接看代码和关键逻辑void Game::InitGame(int nRows, int nCols, int nMines) { // 保存棋盘参数并初始化格子数组 m_nRows nRows; m_nCols nCols; m_nMines nMines; m_pCells new Cell[nRows * nCols]; // 先清空所有格子状态 for (int i 0; i nRows * nCols; i) { m_pCells[i].m_bIsMine FALSE; m_pCells[i].m_bIsOpen FALSE; m_pCells[i].m_bIsFlag FALSE; } // 随机布雷 int nPlaced 0; srand((unsigned)time(NULL)); while (nPlaced nMines) { int index rand() % (nRows * nCols); if (!m_pCells[index].m_bIsMine) { m_pCells[index].m_bIsMine TRUE; nPlaced; } } // 计算每个格子周围的地雷数 CalculateMineCounts(); }这个布雷方式简单直接每次随机生成一个格子下标如果这个格子还没雷就埋进去重复直到 10 颗雷全部布置完成。因为一次随机可能命中已布雷的格子所以循环次数会略多于雷数但在 81 个格子放 10 颗雷的场景下这个效率损失完全可以忽略。要注意 srand 放在 InitGame 内并不是好习惯因为如果你在同一个进程中连续调用 InitGame 两次两次时间种子相隔极短可能产生完全一样的雷局。更稳的写法是把 srand 放在程序初始化时只用一次或者改用 C11 的 random_device 生成真随机种子。我在复现这个资源时把它改成在 CMineDlg 的构造函数里调用一次 srand规避了重复随机的问题。3.2 周边雷数统计的边界处理CalculateMineCounts 是 Game.cpp 里另一个核心函数它遍历整个棋盘对每个非雷格子统计周围八个方向的雷数。实现时最容易犯的错是越界访问。这个工程里用了一种常见的做法嵌套循环遍历扩展出的八个方向并逐一判断行列是否越界。void Game::CalculateMineCounts() { // 八个方向的行列偏移 int dx[8] { -1, -1, -1, 0, 0, 1, 1, 1 }; int dy[8] { -1, 0, 1, -1, 1, -1, 0, 1 }; for (int row 0; row m_nRows; row) { for (int col 0; col m_nCols; col) { Cell* pCell m_pCells[row * m_nCols col]; if (pCell-m_bIsMine) { pCell-m_nMineCount -1; // 雷格子的计数置为 -1 continue; } int count 0; for (int i 0; i 8; i) { int newRow row dx[i]; int newCol col dy[i]; // 越界检查防止访问数组负下标或超出范围 if (newRow 0 newRow m_nRows newCol 0 newCol m_nCols) { Cell* pNeighbor m_pCells[newRow * m_nCols newCol]; if (pNeighbor-m_bIsMine) { count; } } } pCell-m_nMineCount count; } } }这里的 dx 和 dy 数组分别存储了八个方向的横纵偏移从左上角开始逆时针排列。核心判据是 newRow 和 newCol 都要在棋盘合法范围内因为边缘格子的四周可能超出棋盘边界直接访问会读到随机内存导致数量统计错误。雷格子的计数设为 -1这样在绘制时一眼就能识别不需要再拿着 m_bIsMine 单独判断一次。注意这个实现的时间复杂度是 O(ns)对 9x9 的棋盘毫无压力但如果你把这个类复用到 100x100 的大棋盘这个双重循环加八方向的扫描也只需 8 万次操作对于现代 CPU 仍然是毫秒级。3.3 一键展开空白区域的洪水填充扫雷游戏里最爽快的交互是点到一个空白格子后周围所有空白和数字边界一起翻开。这个功能在源码里对应 OpenEmptyCell 函数。它的实现使用了深度优先递归为标准洪水填充算法也是这个资源里值得反复看的代码void Game::OpenEmptyCell(int nRow, int nCol) { // 越界或已翻开则直接返回 if (nRow 0 || nRow m_nRows || nCol 0 || nCol m_nCols) { return; } int index nRow * m_nCols nCol; Cell* pCell m_pCells[index]; // 如果是雷、已经打开、被插旗则跳过 if (pCell-m_bIsMine || pCell-m_bIsOpen || pCell-m_bIsFlag) { return; } // 标记为打开 pCell-m_bIsOpen TRUE; // 如果当前格子有数字则不再向周围扩散 if (pCell-m_nMineCount 0) { return; } // 当前格子是 0向八个方向扩散 int dx[8] { -1, -1, -1, 0, 0, 1, 1, 1 }; int dy[8] { -1, 0, 1, -1, 1, -1, 0, 1 }; for (int i 0; i 8; i) { OpenEmptyCell(nRow dx[i], nCol dy[i]); } }这个函数的核心是两个跳出条件一是发现了数字格子就不再扩散这里把 m_nMineCount 0 作为边界可以避免无意义的递归二是已经翻开或插旗的格子直接跳过防止死循环。平时大家玩扫雷时看到的那个快速翻开的连锁效果底层就是这种递归实现。不过这个写法有个潜在隐患如果棋盘特别大递归层数过深可能压爆调用栈。9x9 的棋盘不会出事但如果你把它改造成 30x30 以上的棋盘建议改成循环加队列的广度优先版本。我在看这套代码时觉得它作为教学样本非常合适因为递归版本的逻辑一看就懂比 BFS 版本直白得多。3.4 胜利条件的两种判定方式Game.cpp 里提供了两种胜利判定思路。一种是统计翻开格子数如果等于总格子数减雷数就代表所有非雷格都被翻开了。另一种是检查是否所有雷都被正确插旗。源码里主要用第一种因为如果只检查插旗数量容易出现在没翻开所有安全格的情况下提前结束的漏洞。BOOL Game::CheckWin() { int nOpened 0; for (int i 0; i m_nRows * m_nCols; i) { if (m_pCells[i].m_bIsOpen) { nOpened; } } // 所有非雷格子都被翻开则胜利 return (nOpened m_nRows * m_nCols - m_nMines); }为什么不用插旗数量来判断因为玩家完全可以在没排完雷的情况下乱插旗比如格子还剩 5 个没翻开但已经插了 10 面旗这时如果按旗子数量判断会出现假胜利。采用翻开数判断更准确。但这里有个细节值得思考如果玩家插旗插错了位置把安全格标成了雷而正确雷格没有被翻开此时翻开数也不会达标所以游戏不会误判。如果你想把判断变得更友好可以加上「所有 m_bIsMine 为 TRUE 的格子都被插旗」这个附加条件但要注意被插旗的格子往往还没翻开这样会跟当前逻辑有冲突需要把判定条件调整成统计正确插旗的数量。4. 界面与交互细节GDI 自绘、对话框与计时4.1 用 GDI 绘制格子与地雷符号MineDlg.cpp 里的 OnPaint 函数是这个资源中绘图部分的核心。整个棋盘不用任何控件全部通过 GDI 的画刷、矩形和文字绘制。它的思路是遍历所有 Cell根据状态决定画什么样式。这里给出关键代码void CMineDlg::OnPaint() { if (IsIconic()) { CPaintDC dc(this); // 最小化时的图标绘制 } else { CPaintDC dc(this); for (int row 0; row m_pGame-m_nRows; row) { for (int col 0; col m_pGame-m_nCols; col) { CRect rect; GetCellRect(col, row, rect); Cell* pCell m_pGame-m_pCells[row * m_pGame-m_nCols col]; // 未翻开绘制灰色凸起方块 if (!pCell-m_bIsOpen) { DrawClosedCell(dc, rect); } // 已翻开绘制底色和数字 else { DrawOpenCell(dc, rect, pCell); } } } } }GetCellRect 负责根据客户区尺寸和行列数算出 CRect这样就实现了窗口大小改变后棋盘自动缩放。DrawClosedCell 和 DrawOpenCell 是私有的辅助函数前者用 FillRect 填充灰色再画一圈边框模拟凸起的立体感后者会根据 m_nMineCount 绘制不同颜色和字号的数字。经典扫雷的数字颜色是有讲究的1 是蓝色、2 是绿色、3 是红色、4 是深蓝色这个源码里也复现了。对于想改游戏外观的人来说最省事的做法是修改 DrawOpenCell 里数字对应的颜色分支比如把 1 改成橙色、2 改成紫色。如果想给格子加背景图可以把 FillRect 替换成 BitBlt 贴图。这里有个细节由于 OnPaint 每次窗口任何区域被覆盖后都会完整执行如果格子数很大效率会略低。9x9 的棋盘完全够用但如果你把这个绘制逻辑改成 32 格乘 32 格建议加一个 m_bNeedFullRedraw 标志只在状态变化时才重绘整个棋盘。4.2 左右键交互和右键插旗的实现扫雷的交互是左键翻开、右键插旗这套事件映射在源码中分别对应 OnLButtonDown 和 OnRButtonDown。核心代码void CMineDlg::OnLButtonDown(UINT nFlags, CPoint point) { int col 0; int row 0; if (GetCellFromPoint(point, col, row)) { Cell* pCell m_pGame-m_pCells[row * m_pGame-m_nCols col]; // 已插旗的格子不能左键翻开 if (pCell-m_bIsFlag) { return; } if (pCell-m_bIsMine) { // 踩雷显示所有雷的位置 m_pGame-m_bGameOver TRUE; ShowAllMines(); } else { m_pGame-OpenCell(row, col); } InvalidateRect(NULL, FALSE); } CDialogEx::OnLButtonDown(nFlags, point); } void CMineDlg::OnRButtonDown(UINT nFlags, CPoint point) { int col 0; int row 0; if (GetCellFromPoint(point, col, row)) { Cell* pCell m_pGame-m_pCells[row * m_pGame-m_nCols col]; // 已翻开格子不能插旗 if (!pCell-m_bIsOpen) { pCell-m_bIsFlag !pCell-m_bIsFlag; if (pCell-m_bIsFlag) { // 播放插旗提示或者显示标记 } InvalidateRect(NULL, FALSE); } } CDialogEx::OnRButtonDown(nFlags, point); }这里左键和右键的关键区别是左键会触发 OpenCell 或者踩雷判定右键只翻转 m_bIsFlag 状态不触发翻开逻辑。注意代码中左键点击已经插旗的格子会被直接跳过这是为了避免玩家误点翻开插旗格导致爆炸。同时插旗后再左键点击格子最好是先强制右键取消旗子才能翻开但很多扫雷实现会选择直接忽略左键。这个资源采用了「忽略 早期返回」的策略安全性和手感都不错。GetCellFromPoint 函数内部用点击坐标除以格子像素宽高再判断是否落在合法范围内如果窗口客户区不是棋盘整数倍多余区域的点击会被返回 FALSE 忽略掉这其实是个隐藏的保护机制。4.3 计时器、新游戏按钮与图片资源扫雷界面右上角通常会显示用时这个工程的 Timer 实现也有值得说的点。在 OnInitDialog 里设置了 1 秒间隔的定时器OnTimer 里每秒把计时变量加一并更新对话框上的静态文本控件。这样做需要注意和 m_bGameOver 的配合void CMineDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_ID !m_pGame-m_bGameOver) { m_nElapsedSeconds; CString strTime; strTime.Format(_T(时间%d 秒), m_nElapsedSeconds); SetDlgItemText(IDC_STATIC_TIME, strTime); } CDialogEx::OnTimer(nIDEvent); }一旦 m_bGameOver 为 TRUE计时器就不会再更新文本但定时器本身仍会持续触发只是判断条件直接返回。如果你在调试模式下看 CPU 占用会发现定时器依旧在跑只是没有 UI 更新。后续优化时可以在游戏结束时调用 KillTimer 把定时器销毁但这个项目选择了保留定时器属于两种都合理的做法。资源里的 res 文件夹存放了图标文件其中 My.ico 是窗口标题栏图标resource.h 中定义了 IDR_MAINFRAME 和 IDC_STATIC_TIME 等资源 ID。很多人会忽略 .rc 文件但修改界面时要特别注意Mine.rc 里声明的对话框模板决定了初始对话框的大小和控件位置如果你改了棋盘行列数而没改模板尺寸界面会显示不全或留白。4.4 状态栏和窗口尺寸的自适应做法项目正文里提到 mfc 状态栏怎么显示这个扫雷程序默认没有状态栏但主框架窗口的预编译头文件和 MainFrm.cpp 保留了可扩展的位置。如果你想在底部加一条状态栏来显示剩余雷数常见做法是在 MainFrm.cpp 里声明一个 CStatusBar 成员然后在 OnCreate 中调用 Create 和 SetIndicators。不过这个工程的 UI 主体在对话框上对话框窗口并没有状态栏概念所以更合适的做法是直接在对话框底部添加一个静态文本控件每次插旗或触发新游戏时更新文本为剩余雷数。我复现这个资源时就是采用的后者因为对话框程序硬加状态栏需要把对话框嵌入框架窗口工作量大一倍。好消息是这个源码里剩余雷数的计算逻辑已经隐含在 Game.cpp 里你只需要在 OnRButtonDown 修改 m_bIsFlag 之后统计一下所有 m_bIsFlag 为 TRUE 的格子数量再用总雷数减去这个值显示到对话框的静态文本上即可。如果你对状态栏的方案更执念可以先把对话框类的 Create 改成以 MainFrame 为父窗口的嵌入式对话框然后在 MainFrame 的状态栏上加一个显示面板但这属于对窗口层级做较大改动需要先理解 CWnd::SetParent 和对话框控件的关系。5. 避坑手册编译错误、资源丢失与运行异常5.1 工程打不开VS 版本与解决方案格式不匹配现象双击 Mine.sln 后 Visual Studio 提示「此版本的应用程序不支持项目类型」或「解决方案文件版本号不兼容」。原因这个资源是 VS2012 生成的Mine.sln 的解决方案格式版本是 12.00而新版本的 Visual Studio如 2022会尝试升级但 vcxproj 里的 PlatformToolset 还指向 v110如果未安装对应工具集就会出现加载失败。解决如果你用的是 VS2019 或 2022先不要直接打开 .sln而是用「打开项目或解决方案」选择 Mine.vcxproj 文件允许 VS 自动升级。如果升级后出错右键项目 - 属性 - 常规 - 平台工具集改成你当前 VS 对应的版本比如 VS2019 选 v142。更稳妥的方式是把整个工程文件夹里的 .sln、.vcxproj、.vcxproj.filters 三个文件用文本编辑器打开把 v110 全部替换成 v143。5.2 编译提示找不到 afxwin.h 或 MFC 支持未启用现象编译报错 C1083 无法打开包括文件 afxwin.h或者报错说没有 MFC 相关宏定义。原因MFC 程序需要 _WINDOWS 和 _AFXDLL 预处理宏如果新建工程时选了空项目而不是 MFC 应用项目属性里的「使用 MFC」是「使用标准 Windows 库」编译器自然找不到 MFC 头文件。解决右键项目 - 属性 - 配置属性 - 常规 - 使用 MFC改成「在共享 DLL 中使用 MFC」。同时确认「配置类型」为「应用程序 (.exe)」而不是静态库。如果仍然报错再去 C/C - 预处理器 - 预处理定义检查是否包含 _WINDOWS、_AFXDLL、_UNICODE。5.3 MFC 初始化失败Debug 版本与 Release 版本混跑现象双击 Debug 目录下的 Mine.exe 能运行但把 Release 版本拷贝到别的电脑后弹窗提示 MFC 运行时库缺失或者直接闪退。原因Debug 版本默认动态链接 MFC 调试 DLL这些 DLL 只存在于安装有 Visual Studio 的机器上Release 版本如果没有选择静态链接 MFC目标机器缺少 mfc140u.dll 之类的运行库时就会启动失败。解决这个资源里自带 Debug 目录和 Debug 编译产物你要分发程序时在 VS 中把配置切换为 Release再在项目属性 - 常规 - 使用 MFC 中选择「在静态库中使用 MFC」这样生成的 exe 不再依赖外部 MFC DLL可以直接拷到别的机器运行。如果还是报错用 Dependency Walker 或 dumpbin 检查 exe 依赖的 DLL 列表。5.4 棋盘不显示或位置偏左资源文件与代码不同步现象程序能编译能运行但对话框里看不到棋盘或者只看到一半格子、位置明显偏移点鼠标也没有反应。原因Mine.rc 中对话框模板的尺寸和 OnPaint 里 GetCellRect 计算出的棋盘区域不匹配。常见情况是对话框模板改过大小但代码里格子数量或边距的计算方式没跟上导致绘制的格子实际超出了客户区可见范围。解决打开 Mine.rc 查看对话框模板的宽度和高度比如模板是 313x313那么 GetCellRect 计算的格子范围必须在这个像素区域之内。建议在 OnPaint 里加一个临时调试变量将棋盘总像素宽度算出来后用 TRACE 输出或 OutputDebugString 打印对比模板尺寸即可定位问题。5.5 连续多次新游戏出现重复雷局或崩溃现象点「新游戏」按钮后偶尔会出现和上一局完全相同的雷区布局严重时连续点几次按钮就报内存访问越界或 heep 初始化错误。原因InitGame 里使用了 srand(time(NULL))如果玩家连续快速点击新游戏按钮time 函数的秒级精度会导致两次初始化使用相同随机种子雷局完全相同。崩溃则是因为 InitGame 中 delete 旧的 m_pCells 数组后没有把指针置空在极端情况下二次 delete 会触发 double free。解决srand 只在程序启动时初始化一次放在 CMineDlg 构造函数里InitGame 内部改为只调用 rand。对于 double free 问题在 InitGame 开头加一句 if (m_pCells) delete[] m_pCells; 并在删除后立即 m_pCells NULL。这也是我长期排查后最终养成的习惯所有动态分配的数组删除后一律置空。6. 把扫雷改成你想要的样子三个方向的动手实验如果你没被上面的踩坑记录劝退那这个资源的源码已经可以当作玩具底盘来改造了。我推荐三个方向全部只需要改一个或两个文件难度由低到高。方向一是换肤把 DrawClosedCell 和 DrawOpenCell 里写死的 RGB 颜色替换成自己定义的颜色表顺便把格子边框从 1 像素改成 2 像素效果立刻不一样。你还能顺便把数字文字字体改成大号加粗只要在 OnPaint 里创建一个 CFont 再 SelectObject 到 DC 即可。这个改动的最大价值是让你彻底理解 GDI 的选对象、绘制、恢复对象的流程。方向二是增加自定义难度对话框。在资源编辑器里新建一个 Dialog添加三个编辑框分别接收行数、列数和雷数再用 DoModal 调用。获取输入后调用 InitGame同时更新对话框模板尺寸难度选择就完成了。你可以顺手把状态栏计数逻辑加上显示剩余雷数。这一步会强迫你处理自定义对话框与主对话框之间的数据传递常见的做法是给子对话框加三个 int 型成员变量OnOK 时读取编辑框内容主对话框弹出子对话框后访问这些成员。方向三是把单机扫雷改成带存档的版本。用 CStdioFile 把雷区数组写到文件里在 InitGame 里增加一个读取逻辑。这样你可以记录当前局面的每个格子状态和时间下次启动时完全恢复。实现时要解决的核心问题是文件格式设计建议直接按照 m_nRows、m_nCols、m_nMines、每个格子的四个 BOOL 和一个 int 的顺序写入再在前面附加文件头标识。这三个方向的验证方式也很直观改完编译运行如果棋盘绘制异常就看 OnPaint 的绘制分支和颜色参数如果交互异常就看消息映射和坐标换算。从那以后我每次拿到别人的 MFC 源码都会先做一次「换颜色 加参数」的最小改动再跑一下调试模式确保自己理解了绘制和消息循环的关系再继续深入改逻辑。这样能最快确认自己真的看懂了而不是停留在能编译就行的阶段。希望这套扫雷源码和这篇文章能帮你把 MFC 的对话框程序、消息映射和 GDI 绘制串成一条线少走我当初走过的弯路。本文还有配套的精品资源点击获取