ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++/MFC扫雷完整程序:大作业从能跑到交得掉的避坑指南

C++/MFC扫雷完整程序:大作业从能跑到交得掉的避坑指南

简介:这是一份面向C++初学者与课程设计需求者的MFC扫雷完整工程,适合作为C++大作业、Windows桌面开发入门练手项目。资源以C++语言结合MFC类库实现,涵盖二维数组雷区建模、消息映射事件响应、周围雷数计算算法、踩雷判定与状态管理,以及新游戏、标记等按钮交互逻辑,并借助资源文件存放图标位图美化界面。压缩包共31个文件,包含6个cpp源文件、8个h头文件与17个bmp图片资源,整体约33KB,源码与素材齐备,可直接在Visual Studio中打开编译调试。目前已有1568人学习下载,读者可据此理解MFC窗口、对话框与控件类的组织方式,掌握事件驱动编程与调试排错思路,并在此基础上扩展难度设置、计时与排行榜等功能,对提升C++工程实践能力有较高参考价值。

1. 从一份 C++/MFC 扫雷完整程序说起:大作业为什么总卡在“能跑但交不掉”

很多人做 C++ 大作业的第一反应是找一份“扫雷完整程序”改改交上去,结果打开工程就翻车:编译报一堆afxwin.h找不到,链接提示mfc140ud.lib缺失,运行起来窗口一闪就退。问题不在扫雷逻辑,而在 MFC 这套桌面开发框架的工程配置和消息机制,它和你在 VS Code 里写c++ stl小练习完全不是一回事。

这份 C++/MFC 扫雷完整程序要解决的核心问题很具体:用 MFC 的文档/视图或对话框框架,把雷区生成、左键翻开、右键插旗、空白区递归展开、胜负判定这几件事串成一个能编译、能演示、能讲清设计思路的桌面程序。它适合正在赶 C++ 大作业、需要一份结构清晰可讲解代码的在校生,也适合想借扫雷这个经典案例重新捡起 MFC 消息映射和 GDI 绘图的开发者。下面按“先立住原理、再动手复现、最后避坑”的顺序拆开讲,参数和代码都能直接抄。

2. 扫雷的数据模型与 MFC 框架选型:先想清楚再建工程

2.1 雷区到底该用什么数据结构存

扫雷看着简单,数据模型设计错了后面全是补丁。常见做法是用一个二维数组存每个格子的状态,但状态不止“有雷/无雷”两种,还要区分“已翻开/未翻开/已插旗”,以及记录周围雷数。我一般会拆成两个二维数组:一个int存雷和计数,一个int存显示状态。这样逻辑和渲染彻底分离,调试时打印状态矩阵就能定位问题。

// 雷区核心数据,放在 CMyMineView 或对话框类里 const int ROWS = 16; // 行数 const int COLS = 16; // 列数 const int MINES = 40; // 雷数 int m_cell[ROWS][COLS]; // -1 表示雷,0~8 表示周围雷数 int m_state[ROWS][COLS]; // 0 未翻开,1 已翻开,2 已插旗 bool m_bGameOver; // 游戏是否结束 bool m_bFirstClick; // 是否还没点第一下

m_cell用 -1 表示雷是个约定,好处是周围雷数计算时可以直接判断== -1,不用额外布尔数组。m_state单独存是为了右键插旗不影响雷数信息。m_bFirstClick这个标志很关键,正规扫雷第一次点击不能踩雷,需要在第一次点击后再布雷,或者把雷挪开。很多人忽略这点,演示时第一下就炸,观感很差。

参数上ROWS、COLS、MINES建议做成常量或从配置读,方便改成初级 9×9/10 雷、中级 16×16/40 雷、高级 16×30/99 雷。大作业里能切换难度是加分项,但别一上来就做,先把固定难度跑通。

2.2 MFC 用对话框还是文档/视图

这是选型第一个岔路口。对话框程序(Dialog-based)上手快,所有控件拖一拖,消息处理直接写OnLButtonDown,适合时间紧、只求交作业。文档/视图(SDI)结构更“正统”,老师一看就知道你懂 MFC 框架,但要多理解CDocument、CView、OnDraw的配合,调试成本高。

我的建议:如果大作业要求里明确写了“体现 MFC 文档/视图架构”,就选 SDI;否则对话框程序足够,把精力放在扫雷逻辑和界面上。两者在扫雷核心逻辑上完全一样,区别只是绘制和消息入口。对话框程序里用OnPaint或自绘按钮,SDI 里用OnDraw配合CDC。

选对话框时,别用一堆按钮控件拼雷区,16×16 就是 256 个控件,资源编辑器卡到怀疑人生,消息映射也难维护。正确做法是整个客户区自绘:在OnPaint里根据m_state逐格画矩形和数字,鼠标点击时用坐标反算行列。这样代码量反而小,性能也好。

2.3 消息映射:左键右键怎么落到具体格子

MFC 的消息映射是新手最容易懵的地方。你要处理鼠标左键、右键,还要区分是按下还是抬起。扫雷一般用WM_LBUTTONDOWN翻开、WM_RBUTTONDOWN插旗。在对话框类里加消息处理函数,类向导会自动生成映射宏。

// 头文件里声明 afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnRButtonDown(UINT nFlags, CPoint point); // 源文件消息映射 BEGIN_MESSAGE_MAP(CMyMineDlg, CDialogEx) ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() END_MESSAGE_MAP() void CMyMineDlg::OnLButtonDown(UINT nFlags, CPoint point) { int row = point.y / CELL_SIZE; // CELL_SIZE 是格子像素边长 int col = point.x / CELL_SIZE; if (row >= 0 && row < ROWS && col >= 0 && col < COLS) RevealCell(row, col); // 翻开逻辑 CDialogEx::OnLButtonDown(nFlags, point); }

point是客户区坐标,除以格子边长就得到行列。这里必须做边界判断,否则点到窗口边缘会数组越界,程序直接崩。CELL_SIZE建议设 24 或 30 像素,太小数字看不清,太大一屏放不下。RevealCell是核心翻开函数,下一章展开讲。

提示:消息映射宏必须写在.cpp的BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间,函数声明和定义签名要完全一致,否则编译能过但点击没反应,这是 MFC 最典型的“玄学”问题之一。

3. 核心逻辑实现:布雷、翻开、递归展开与胜负判定

3.1 第一次点击后再布雷,避开首点踩雷

前面提到首点不能踩雷。实现方式有两种:一是先随机布雷,首点时如果正好是雷就把它和某个非雷格交换;二是首点后再布雷,且布雷时排除首点及其周围 8 格。第二种体验更好,首点必定展开一片,但实现稍复杂。大作业里用第一种就够,代码简单不易错。

void CMyMineDlg::InitBoard(int safeRow, int safeCol) { // 先全部清零 for (int r = 0; r < ROWS; r++) for (int c = 0; c < COLS; c++) { m_cell[r][c] = 0; m_state[r][c] = 0; } // 随机布雷,避开首点 int placed = 0; while (placed < MINES) { int r = rand() % ROWS; int c = rand() % COLS; if (m_cell[r][c] == -1) continue; if (r == safeRow && c == safeCol) continue; m_cell[r][c] = -1; placed++; } // 计算周围雷数 for (int r = 0; r < ROWS; r++) for (int c = 0; c < COLS; c++) { if (m_cell[r][c] == -1) continue; int cnt = 0; for (int dr = -1; dr <= 1; dr++) for (int dc = -1; dc <= 1; dc++) { int nr = r + dr, nc = c + dc; if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && m_cell[nr][nc] == -1) cnt++; } m_cell[r][c] = cnt; } }

rand()前记得srand((unsigned)time(NULL)),否则每次运行雷区一样,演示时会被看出来。周围雷数计算用双重循环扫 8 邻域,边界判断不能省。这段是 O(ROWS×COLS×9),16×16 规模下瞬间完成,不用优化。

3.2 递归展开:空白格连锁翻开怎么写不爆栈

点到一个周围雷数为 0 的格子,要自动把相连的空白区全翻开,这是扫雷的灵魂。用递归最直观,但 16×16 最多 256 格,递归深度有限,不会爆栈。真正要注意的是别重复访问导致死循环,靠m_state判断即可。

void CMyMineDlg::RevealCell(int r, int c) { if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return; if (m_state[r][c] != 0) return; // 已翻开或已插旗,直接返回 if (m_cell[r][c] == -1) { // 踩雷 m_bGameOver = true; m_state[r][c] = 1; Invalidate(); // 触发重绘 MessageBox(_T("踩雷了!")); return; } m_state[r][c] = 1; // 标记翻开 if (m_cell[r][c] == 0) { // 空白格,递归展开 8 邻域 for (int dr = -1; dr <= 1; dr++) for (int dc = -1; dc <= 1; dc++) if (dr != 0 || dc != 0) RevealCell(r + dr, c + dc); } }

关键判断是m_state[r][c] != 0直接返回,这既挡住了已翻开的格子,也挡住了插旗的格子,避免递归重复进入。踩雷后设m_bGameOver并弹提示,同时Invalidate()让界面重绘显示所有雷。注意递归里对每个邻格都调RevealCell,边界判断在函数开头统一做,代码干净。

3.3 右键插旗与胜负判定

右键插旗逻辑简单:未翻开的格子状态在 0 和 2 之间切换,已翻开的不响应。插旗数量可以显示剩余雷数,但别强制限制插旗数,否则体验差。

void CMyMineDlg::OnRButtonDown(UINT nFlags, CPoint point) { int row = point.y / CELL_SIZE; int col = point.x / CELL_SIZE; if (row >= 0 && row < ROWS && col >= 0 && col < COLS && m_state[row][col] != 1) { m_state[row][col] = (m_state[row][col] == 2) ? 0 : 2; Invalidate(); } CDialogEx::OnRButtonDown(nFlags, point); }

胜负判定放在每次翻开后:统计已翻开格子数,如果等于总格子数减雷数,就赢了。也可以在插旗时判断所有雷是否都被正确标记,但前者更可靠。

bool CMyMineDlg::CheckWin() { int revealed = 0; for (int r = 0; r < ROWS; r++) for (int c = 0; c < COLS; c++) if (m_state[r][c] == 1) revealed++; return revealed == ROWS * COLS - MINES; }

RevealCell末尾调用CheckWin(),赢了就弹提示并禁用后续点击(靠m_bGameOver判断)。这套逻辑跑通,扫雷的核心就完成了,剩下的是绘制和界面美化。

3.4 用 GDI 把雷区画出来

绘制放在OnPaint里,用CPaintDC。每个格子根据m_state和m_cell决定画什么:未翻开画凸起灰色矩形,已翻开画平面并显示数字,插旗画个红旗或字母 F,踩中的雷画红色背景。

void CMyMineDlg::OnPaint() { CPaintDC dc(this); for (int r = 0; r < ROWS; r++) for (int c = 0; c < COLS; c++) { CRect rect(c * CELL_SIZE, r * CELL_SIZE, (c + 1) * CELL_SIZE, (r + 1) * CELL_SIZE); if (m_state[r][c] == 0) { dc.FillSolidRect(rect, RGB(192, 192, 192)); dc.Draw3dRect(rect, RGB(255,255,255), RGB(128,128,128)); } else if (m_state[r][c] == 2) { dc.FillSolidRect(rect, RGB(192, 192, 192)); dc.SetTextColor(RGB(255, 0, 0)); dc.DrawText(_T("F"), rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } else { dc.FillSolidRect(rect, RGB(224, 224, 224)); if (m_cell[r][c] > 0) { CString s; s.Format(_T("%d"), m_cell[r][c]); dc.SetTextColor(GetNumColor(m_cell[r][c])); dc.DrawText(s, rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } } }

Draw3dRect画出立体边框,未翻开格子有凸起感。数字颜色按经典扫雷规则:1 蓝、2 绿、3 红、4 深蓝等,写个GetNumColor辅助函数返回COLORREF。DrawText用DT_CENTER | DT_VCENTER | DT_SINGLELINE让数字居中。绘制代码不复杂,但格子多时要注意Invalidate()别频繁调用,一次重绘整屏即可。

4. 工程配置与编译链接:让程序在别人机器上也能跑起来

4.1 字符集与 MFC 库的链接方式

MFC 工程最常见的编译错误是字符集不匹配。VS 默认用 Unicode,代码里字符串要用_T("...")或L"...",直接写"..."在CString赋值时可能报错。另一个坑是运行库链接方式:/MDd(调试多线程 DLL)和/MTd(调试多线程静态)不能混用,MFC 工程一般用“在共享 DLL 中使用 MFC”,对应/MDd。

在项目属性里检查三处:配置属性 → 常规 → 字符集设为“使用 Unicode 字符集”;配置属性 → 高级 → MFC 的使用设为“在共享 DLL 中使用 MFC”;C/C++ → 代码生成 → 运行库设为“多线程调试 DLL (/MDd)”。这三处一致,基本不会出链接错误。

4.2 常见编译错误对照表

错误现象原因解决
找不到afxwin.h未安装 MFC 组件或未包含头文件VS Installer 勾选“用于最新 v143 生成工具的 C++ MFC”
LNK2019无法解析外部符号运行库或 MFC 链接方式不一致统一改为共享 DLL 使用 MFC
CString与const char*不兼容字符集为 Unicode 但用了窄字符串字符串加_T()宏
窗口一闪就退对话框程序DoModal前出错或断言失败调试运行看输出窗口的断言信息
点击无反应消息映射宏缺失或签名不匹配检查BEGIN_MESSAGE_MAP和函数声明

这张表覆盖了大作业里 90% 的编译运行问题。遇到LNK错误先看完整错误信息里的库名,再对照运行库设置。断言失败一般会在输出窗口打印文件和行号,顺着看就能定位。

4.3 发布时让别人也能运行

交作业时老师可能直接双击 exe。如果你用“共享 DLL 使用 MFC”,目标机器没装对应运行库就会报缺 DLL。稳妥做法是改成“在静态库中使用 MFC”,对应运行库/MTd(调试)或/MT(发布),这样 exe 自带 MFC 代码,不依赖外部 DLL,代价是文件大几 MB。大作业场景下这点体积无所谓,换来的是双击即开。

改完静态链接后要重新生成整个工程,清理旧的中间文件,否则可能残留/MDd的 obj 导致链接冲突。生成 → 清理解决方案,再重新生成,这一步别省。

5. 避坑与排查:扫雷大作业里那些让人熬夜的细节

5.1 递归展开时界面卡死或闪屏

现象:点一个空白格,程序卡住几秒才刷新,或者整个窗口闪个不停。原因通常是每翻开一格就调一次Invalidate(),256 格触发 256 次重绘。解决:递归过程中只改数据,递归结束后统一调一次Invalidate()。把RevealCell里的Invalidate()挪到鼠标消息处理函数的末尾。

5.2 首点踩雷导致演示翻车

现象:程序刚启动,第一次点击就炸。原因:布雷在初始化时完成,没排除首点。解决:把布雷推迟到第一次左键点击,用m_bFirstClick标志判断,首点时传入安全坐标调InitBoard。这个改动很小,但演示效果天差地别。

5.3 数组越界导致随机崩溃

现象:点窗口边缘或快速连点,程序偶尔崩溃。原因:坐标反算行列后没做边界判断,或者递归展开时邻格坐标越界。解决:所有访问m_cell、m_state的地方,下标前必须判断0 <= r < ROWS && 0 <= c < COLS。递归函数开头统一判断,鼠标消息里也判断一次,双保险。

5.4 插旗后左键还能翻开

现象:右键插了旗,左键点同一格还是翻开了。原因:RevealCell只判断了m_state != 0,但插旗状态是 2,应该被挡住。检查判断条件是否写成了m_state[r][c] == 1才返回。正确写法是if (m_state[r][c] != 0) return;,这样 1 和 2 都挡住。

5.5 静态链接后仍报缺 DLL

现象:改成静态库使用 MFC,换台机器还是提示缺mfc140u.dll。原因:只改了 MFC 使用方式,没改运行库,或者没清理重新生成。解决:确认运行库是/MT(发布)或/MTd(调试),清理解决方案后重新生成。另外检查是否引用了其他动态库,比如某些第三方控件。

6. 进阶技巧:把扫雷大作业做出“可讲解”的加分项

大作业评分不只看能不能跑,还看你能不能讲清设计。我一般会加两个小功能,成本低但答辩时很能说。第一个是计时器和剩余雷数显示,放在状态栏或对话框顶部。MFC 里用SetTimer每秒更新一次,KillTimer在游戏结束时停掉。剩余雷数就是MINES减去已插旗数,每次插旗后刷新。

// 在对话框初始化里启动计时 SetTimer(1, 1000, NULL); // 消息映射 ON_WM_TIMER,处理函数里 void CMyMineDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && !m_bGameOver) { m_nSeconds++; CString s; s.Format(_T("时间:%d 秒"), m_nSeconds); m_staticTime.SetWindowText(s); // 静态文本控件 } CDialogEx::OnTimer(nIDEvent); }

第二个加分项是把难度切换做成菜单或按钮,初级 9×9/10 雷、中级 16×16/40 雷、高级 16×30/99 雷。切换时重置所有状态并重新初始化,注意CELL_SIZE要按难度调整,高级模式格子得小一点才放得下。这个功能能体现你对参数化的理解,答辩时老师问“怎么扩展”你就有话说。

验证程序是否真的正确,别只靠手点。我习惯写一个临时的调试函数,把m_cell和m_state打印到输出窗口,对照雷区图检查周围雷数算得对不对。特别是角落和边缘格子,8 邻域边界处理最容易错。打印出来看一眼,比点半天靠谱。

最后一个血泪经验:答辩前一定在老师那台机器上试运行,或者至少在一台没装 VS 的电脑上双击 exe。我见过太多人本地跑得好好的,换台机器就缺 DLL 或者字符集乱码。静态链接加 Unicode,这两条守住,基本不会翻车。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进