ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MFC扫雷源码全解析:从类设计到GDI绘制与避坑实战

MFC扫雷源码全解析:从类设计到GDI绘制与避坑实战

简介:基于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 在左键点击后调用,判断踩雷或展开数字。这里要注意一个设计:扫雷的核心逻辑完全不依赖 MFC,Game 不知道窗口长什么样,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 把绘制工作绑定到 OnPaint,ON_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 放在程序初始化时只用一次,或者改用 C++11 的 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 绘制串成一条线,少走我当初走过的弯路。

本文还有配套的精品资源,点击获取

返回列表