简介:这份资源是面向C++初学者与课程大作业需求者的MFC扫雷完整工程,可直接在Visual Studio中打开编译运行,用于理解Windows桌面程序从界面搭建到游戏逻辑落地的完整链路。压缩包共31个文件、约33KB,以17个bmp位图资源配合8个头文件与6个cpp源文件构成,位图承担格子与界面素材,头文件与源文件分别承载视图、文档、主框架及雷区逻辑等模块,结构贴近MFC文档视图架构。资源围绕二维数组表示雷区、消息映射响应点击、遍历相邻八格统计周围雷数、踩雷判定与状态管理等关键环节展开,并涉及资源文件与调试测试的实践。目前已有1568人学习下载,适合作为课程设计参考模板,帮助读者对照梳理工程组织方式、界面与逻辑分层思路及常见问题排查方向。
1. 从一份能跑起来的 C++/MFC 扫雷说起
很多人学完 C++ 语法,面对课程设计却不知道从哪下手:控制台程序写腻了,想做个带界面的东西,又觉得 Win32 太底层。这份 C++/MFC 扫雷完整程序正好卡在这个位置上——它用 MFC 的文档视图框架把扫雷的棋盘绘制、鼠标交互、计时计分、难度切换全串了起来,代码量不大,但五脏俱全。适合两类人:一是要交 C++ 大作业、想找个能编译能跑、结构清晰的参考工程;二是已经会点 C++,想借一个完整小项目摸清 MFC 消息机制和 GDI 绘图套路的人。下面我按“这项目怎么组织、怎么跑起来、核心逻辑长什么样、哪里容易翻车、怎么改成自己的”这条线拆一遍。
2. 工程结构与 MFC 骨架:先看清项目怎么搭起来的
2.1 目录与文件职责
拿到源码包,第一件事不是急着编译,而是把文件按职责分堆。MFC 单文档工程的文件命名有固定套路,认准前缀就能快速定位:
| 文件 | 职责 |
|---|---|
MineApp.h/.cpp | 应用类,负责注册并创建主窗口 |
MainFrm.h/.cpp | 主框架窗口,承载菜单栏和视图 |
MineDoc.h/.cpp | 文档类,存放棋盘数据、雷区状态、游戏状态 |
MineView.h/.cpp | 视图类,负责绘制棋盘、处理鼠标点击 |
resource.h、.rc | 菜单、图标、对话框等资源定义 |
Board.h/.cpp(若有) | 棋盘逻辑封装,格子结构体与布雷算法 |
文档视图分离是这套骨架的关键:数据放 Doc,画和点放 View。新手最容易犯的错是把棋盘数组塞进 View 里,结果一改难度、一重开就状态错乱。这个工程把数据放在 Doc 里,View 只做“读数据、画出来、把点击坐标翻译成行列号再回调 Doc”,职责是干净的。
2.2 消息映射与启动链路
MFC 不像 Qt 那样用信号槽,它靠消息映射表把 Windows 消息绑到成员函数。视图类里处理鼠标左键、右键、定时器,全靠这几张表:
// MineView.h 中声明消息处理函数 afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnRButtonDown(UINT nFlags, CPoint point); afx_msg void OnTimer(UINT_PTR nIDEvent); // MineView.cpp 中建立映射 BEGIN_MESSAGE_MAP(CMineView, CView) ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_TIMER() END_MESSAGE_MAP()BEGIN_MESSAGE_MAP到END_MESSAGE_MAP之间每一条ON_WM_XXX都是一次“消息号 → 函数”的登记,宏展开后是一张静态数组,窗口过程收到消息就查表分发。OnLButtonDown的point是客户区坐标,单位是像素,必须换算成棋盘行列才能用。启动链路是:InitInstance创建文档模板 → 框架窗口 → 视图,视图OnInitialUpdate里初始化棋盘并启动定时器。理解这条链路,后面改难度、加功能才知道该往哪插。
2.3 编译环境与跑通步骤
这是纯 Win32 桌面工程,用 Visual Studio 打开.sln或.vcxproj即可。字符集要留意:老工程多用多字节字符集(MBCS),新版 VS 默认 Unicode,直接编译可能报C2664之类的字符串转换错误。稳妥做法是项目属性里把“字符集”改成“使用多字节字符集”,或者把CString相关调用统一到_T()宏。
# 命令行方式(在 Developer Command Prompt 下) msbuild Mine.sln /p:Configuration=Debug /p:Platform=x86 # 或直接跑生成的 exe .\Debug\Mine.exe/p:Platform=x86是因为不少老 MFC 工程只配了 32 位平台,切 x64 会提示缺少配置。编译通过后运行,能看到菜单栏的“游戏 → 初级/中级/高级/自定义”,点格子、右键插旗、计时器走动,就说明骨架是通的。跑不通先看输出窗口第一条错误,MFC 工程的报错往往第一条才是根因,后面一串都是连带。
3. 核心逻辑拆解:棋盘数据、布雷与点击展开
3.1 格子结构与棋盘初始化
扫雷的核心数据结构就一个二维数组,每个格子要记三件事:是不是雷、周围雷数、当前显示状态(未翻开/已翻开/插旗)。用一个结构体最直观:
struct Cell { bool isMine; // 是否地雷 int adjacent; // 周围 8 格雷数 int state; // 0=未翻开 1=已翻开 2=插旗 }; class CBoard { public: int rows, cols, mineCount; std::vector<std::vector<Cell>> grid; void Init(int r, int c, int mines) { rows = r; cols = c; mineCount = mines; grid.assign(rows, std::vector<Cell>(cols, {false, 0, 0})); PlaceMines(); CalcAdjacent(); } };grid.assign一次性把棋盘重置成干净状态,避免重开时残留上一局数据。PlaceMines负责随机布雷,CalcAdjacent遍历每个非雷格统计周围 8 格雷数。这里有个细节:布雷要在第一次点击之后再做,保证首点必不是雷,否则开局就炸体验很差。常见做法是首点后再随机,或者布雷时把首点及其周围排除。
3.2 随机布雷与首点保护
void CBoard::PlaceMines(int safeR, int safeC) { std::mt19937 rng(std::random_device{}()); std::uniform_int_distribution<int> rd(0, rows - 1); std::uniform_int_distribution<int> cd(0, cols - 1); int placed = 0; while (placed < mineCount) { int r = rd(rng), c = cd(rng); // 首点及其 8 邻域不放雷 if (abs(r - safeR) <= 1 && abs(c - safeC) <= 1) continue; if (grid[r][c].isMine) continue; grid[r][c].isMine = true; ++placed; } }mt19937是标准库的梅森旋转随机数引擎,比rand()分布均匀,扫雷这种小棋盘用rand()其实也够,但用现代写法更省心。safeR/safeC是首点坐标,abs判断保证首点周围一圈无雷,这样第一次点击至少能展开一片,不会点一下就是个数字 1。placed计数配合continue跳过重复位置,避免死循环——如果雷数接近格子总数,这个循环会变慢,实际难度配置里雷数远小于格子数,不用太担心。
3.3 点击展开的递归与边界
左键点开一个格子,如果是 0(周围无雷),要自动展开相邻区域,这是扫雷最经典的递归逻辑:
void CBoard::Reveal(int r, int c) { if (r < 0 || r >= rows || c < 0 || c >= cols) return; // 边界 Cell& cell = grid[r][c]; if (cell.state != 0 || cell.isMine) return; // 已翻开或是雷 cell.state = 1; if (cell.adjacent == 0) { for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) if (dr || dc) Reveal(r + dr, c + dc); // 8 邻域递归 } }三个提前返回是安全阀:越界、已翻开、是雷,任一命中就停。只有adjacent == 0才继续向 8 邻域扩散,数字格翻开即止,这正是扫雷“空白连片展开、数字封边”的手感来源。递归深度在标准难度下最多几百层,不会爆栈;但如果有人把棋盘改成超大尺寸,建议改成显式栈的迭代写法,血泪经验是递归在极端参数下确实会翻车。
3.4 视图绘制与坐标换算
数据在 Doc,画在 View。OnDraw里遍历棋盘,按格子状态选颜色和文字:
void CMineView::OnDraw(CDC* pDC) { CBoard& b = GetDocument()->m_board; for (int r = 0; r < b.rows; ++r) for (int c = 0; c < b.cols; ++c) { CRect rc(c * CELL, r * CELL, (c + 1) * CELL, (r + 1) * CELL); const Cell& cell = b.grid[r][c]; if (cell.state == 0) pDC->FillSolidRect(rc, RGB(192,192,192)); else if (cell.state == 2) pDC->FillSolidRect(rc, RGB(255,0,0)); else pDC->FillSolidRect(rc, RGB(255,255,255)); pDC->Rectangle(rc); // 画格线 } }CELL是格子边长常量,坐标换算就是行列 × CELL。鼠标点击反过来:int c = point.x / CELL; int r = point.y / CELL;,再判断是否越界。FillSolidRect按状态填色,未翻开灰、插旗红、翻开白,格线用Rectangle描边。这套画法简单直接,缺点是每次重绘全量刷,棋盘大时略卡,但扫雷尺寸下完全够用。想加数字颜色区分,就在翻开分支里按adjacent值SetTextColor再TextOut。
4. 避坑与排查:编译、运行、逻辑三类常见问题
4.1 编译报字符集与链接错误
现象:一堆C2664 cannot convert或LNK2019 unresolved external。原因多半是工程按 MBCS 写的,VS 默认 Unicode,CString与char*混用就炸;链接错误则常是没引入 MFC 库或平台不匹配。解决:项目属性 → 高级 → 字符集改“多字节”,链接器 → 系统 → 子系统确认是 Windows,平台统一 x86。改完清理再重编,别增量编译,老工程增量容易留脏 obj。
4.2 首点就踩雷
现象:开局第一下点开就是雷,直接结束。原因:布雷在初始化时就做完了,没做首点保护。解决:把PlaceMines挪到第一次左键点击时调用,传入首点坐标做安全区;或者初始化时先不布雷,标记一个firstClick标志,点击后补布雷再CalcAdjacent。注意补布雷后要重算周围雷数,否则数字全错。
4.3 右键插旗后左键仍能点开
现象:插了旗的格子左键还能翻开,甚至翻出雷。原因:Reveal只判断了state != 0,但插旗状态如果和翻开状态用了不同编码、或者左键处理没检查旗标,就会漏。解决:左键入口先判if (cell.state == 2) return;,右键则在未翻开和插旗之间切换。状态机要收敛到三个值,别用多个 bool 拼,容易组合出非法状态。
4.4 计时器不停或重开不清零
现象:游戏结束后计时还在走,或者重开一局时间接着上一局。原因:SetTimer启动后没在结束/重开时KillTimer,或者重开只重置了棋盘没重置计数变量。解决:游戏结束(踩雷或全翻开)时KillTimer(1),重开时先KillTimer再清零m_seconds再SetTimer。定时器 ID 用常量别用魔法数字,排查时一眼能对上。
4.5 高 DPI 下格子错位
现象:高分屏上格子画得和点击区域对不上,点 A 格翻开 B 格。原因:绘制和点击换算用了不同的缩放假设,或者窗口做了 DPI 缩放但CELL是写死像素。解决:统一用客户区坐标做换算,绘制和点击都走同一个PointToCell函数;必要时在OnSize里按客户区宽度动态算CELL。别在两处各写一套除法,迟早对不上。
5. 二次开发:把扫雷改成你自己的大作业
改造的第一步不是加功能,而是先把“数据与表现”的边界再收紧一点。我一般会把CBoard抽成一个不依赖 MFC 的纯 C++ 类,只留Init / Reveal / ToggleFlag / IsWin几个接口,View 只调这些接口再重绘。这样逻辑可以单独写个小 main 测试,不用每次开窗口验证,改布雷算法、改胜负判定都快得多。
具体能加的东西不少,按投入产出排:一是难度自定义对话框,把行、列、雷数做成三个输入框,确定后重建棋盘,注意重建时KillTimer并重置状态;二是首次点击保护做成可配置,有些老师就爱看你有没有处理这个边界;三是加“剩余雷数”显示,用mineCount - 已插旗数算,插旗数在ToggleFlag里维护;四是胜利判定,每次翻开后统计已翻开格数是否等于rows*cols - mineCount,相等即胜。胜利判定别每帧算,放在Reveal返回后算一次就够。
验证方法很土但有效:写一组固定种子的布雷测试,断言雷数正确、首点安全区无雷、adjacent与手工统计一致;再手动跑初级、中级、高级各三局,重点看插旗、取消插旗、重开、踩雷后重开这四条路径。改完记得把CELL、颜色、计时这些常量集中到一个头文件,别散落在 View 里,后面调手感不用满工程搜。
有个教训我记到现在:早期我图省事把棋盘数组直接放 View,结果加“重开”功能时状态怎么都清不干净,查了半天才发现 Doc 和 View 各存了一份。从那以后我每次接 MFC 小工程,都先把 Doc 里该放什么、View 里该放什么列一遍再动手,省下的调试时间远比列清单多。希望这份拆解帮到你,拿去改的时候少走点弯路。
本文还有配套的精品资源,点击获取