简介:这是一份基于MFC框架实现的扫雷游戏完整源码工程,面向正在学习Windows桌面开发、C++面向对象编程以及MFC文档视图架构的初学者与进阶者。项目复刻了经典扫雷的核心玩法,鼠标点击即可完成翻开格子、标记雷区等操作,界面简洁明了,代码结构清晰,适合作为课程设计、毕业设计参考或MFC入门练手项目。压缩包共45个文件,约2.85MB,包含17个bmp位图素材、7个头文件与6个cpp源文件,另有ico图标、rc资源脚本、dsp与dsw工程文件及可执行程序,覆盖从界面资源到逻辑实现的完整链路。资源包内还附带ReadMe说明与LICENSE授权文件,方便快速了解工程组织方式。目前已有315人学习下载,读者可借此掌握MFC消息映射、对话框与视图交互、资源加载等关键知识点,并在此基础上自行扩展难度设置、计时与排行榜等功能。
1. 从零手搓一个 MFC 扫雷:为什么它至今仍是桌面开发的试金石
很多人第一次接触 MFC,都是从扫雷、计算器这类小工具开始的。标题里的「基于 MFC 实现的扫雷游戏」,说白了就是用 Visual Studio 里的 MFC 框架,把 Windows 自带那个经典扫雷重新写一遍。它看着简单,实际把 MFC 四大类(CWinApp、CFrameWnd、CView、CDocument)、消息映射、GDI 绘图、定时器、鼠标交互全串了一遍,是检验你懂不懂 Windows 消息驱动机制的绝佳练手项目。适合刚学完 C++ 语法、想搞明白「窗口到底怎么响应点击」的新手,也适合多年没碰 MFC、想找回手感的老手。下面我按自己复现过的路径,把选型、绘图、布雷、递归展开、双缓冲、踩坑一条条讲清楚,你照着能跑出一个能玩、不闪、逻辑正确的扫雷。
2. 选对话框还是单文档:MFC 扫雷的工程骨架怎么搭
2.1 两种工程模板的取舍与消息映射差异
MFC 新建项目时,Visual Studio 会给你几个选项:单文档(SDI)、多文档(MDI)、基于对话框(Dialog based)。扫雷这种只有一个固定大小窗口、没有菜单栏文档概念的小游戏,我一般直接选「基于对话框」。原因很实在:对话框模板自带资源编辑器,你拖一个静态框就能当棋盘容器,控件 ID 直接映射消息,省掉 CView 里那一套 OnDraw 和文档/视图分离的绕路。单文档当然也能做,但你要自己处理框架窗口的客户区坐标、滚动条、菜单,对扫雷来说是过度设计。
选对话框后,工程里会自动生成三个核心文件:MineSweeperApp.cpp(CWinApp 派生类,程序入口)、MineSweeperDlg.cpp(CDialogEx 派生类,主窗口逻辑)、resource.h(资源 ID)。MFC 四大类在这里体现为 CWinApp 负责启动消息循环,CDialogEx 负责窗口和控件。消息映射是 MFC 的命脉,你右键对话框类 → 属性 → 消息,能看到WM_LBUTTONDOWN、WM_RBUTTONDOWN、WM_TIMER、WM_PAINT这些。扫雷全靠它们:左键挖开、右键插旗、定时器走秒、重绘刷新。
提示:如果你用的是较新的 Visual Studio,安装时记得勾选「使用 C++ 的桌面开发」里的 MFC 组件,否则新建项目里根本找不到 MFC 模板。离线环境可以用 VS 安装器里的「单个组件」搜 MFC 补装。
2.2 棋盘数据结构与初始化代码
棋盘我习惯用一个二维数组存状态,再加一个二维数组存「是否已挖开」。格子状态用枚举:0 表示空,1-8 表示周围雷数,9 表示雷。另外用bool数组标记是否插旗、是否翻开。这样逻辑清晰,绘图时按状态取不同颜色或位图。
// MineSweeperDlg.h 里定义 enum CellState { EMPTY = 0, MINE = 9 }; const int ROWS = 16; const int COLS = 16; const int MINE_COUNT = 40; const int CELL_SIZE = 24; // 每格像素 int m_board[ROWS][COLS]; // 雷数或雷标记 bool m_revealed[ROWS][COLS]; // 是否已翻开 bool m_flagged[ROWS][COLS]; // 是否插旗 bool m_gameOver; int m_remainingMines;初始化时先把所有格子清零,再用随机数布雷。布雷要避免重复,我一般用「洗牌」思路:生成一个 0 到 ROWS*COLS-1 的序列,随机打乱,取前 MINE_COUNT 个作为雷的位置。这样不会死循环,分布也均匀。
void CMineSweeperDlg::InitBoard() { srand((unsigned)time(nullptr)); memset(m_board, 0, sizeof(m_board)); memset(m_revealed, 0, sizeof(m_revealed)); memset(m_flagged, 0, sizeof(m_flagged)); m_gameOver = false; m_remainingMines = MINE_COUNT; // 洗牌法布雷 std::vector<int> pos(ROWS * COLS); for (int i = 0; i < ROWS * COLS; ++i) pos[i] = i; std::random_shuffle(pos.begin(), pos.end()); for (int i = 0; i < MINE_COUNT; ++i) { int r = pos[i] / COLS; int c = pos[i] % COLS; m_board[r][c] = MINE; } // 计算每格周围雷数 for (int r = 0; r < ROWS; ++r) for (int c = 0; c < COLS; ++c) { if (m_board[r][c] == MINE) 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_board[nr][nc] == MINE) cnt++; } m_board[r][c] = cnt; } }这段代码里random_shuffle在 C++17 后已被弃用,可以换成std::shuffle配std::mt19937,但老项目里random_shuffle照样能跑。参数ROWS、COLS、MINE_COUNT就是难度,初级 9x9 10 雷,中级 16x16 40 雷,高级 16x30 99 雷,你改这三个常量就能切换难度。注意布雷后立刻算雷数,别等到点击时才算,否则递归展开会反复计算,性能差。
3. 把格子画到对话框上:GDI 绘图与双缓冲消闪
3.1 OnPaint 里用 CDC 绘制棋盘
MFC 里绘图靠CDC类,在OnPaint里拿CPaintDC dc(this),然后按格子坐标画矩形、画数字、画旗子。最朴素的做法是每个格子dc.Rectangle()画边框,再dc.TextOut()写数字。但这样有个问题:窗口一刷新就闪,鼠标点一下整个棋盘重绘,肉眼可见的闪烁。这就是新手第一个翻车点。
void CMineSweeperDlg::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_revealed[r][c]) { dc.FillSolidRect(rect, RGB(220, 220, 220)); if (m_board[r][c] > 0 && m_board[r][c] < MINE) { CString s; s.Format(_T("%d"), m_board[r][c]); dc.SetTextColor(GetNumberColor(m_board[r][c])); dc.DrawText(s, rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } else if (m_board[r][c] == MINE) { dc.FillSolidRect(rect, RGB(255, 0, 0)); } } else { dc.FillSolidRect(rect, RGB(192, 192, 192)); dc.Draw3dRect(rect, RGB(255, 255, 255), RGB(128, 128, 128)); if (m_flagged[r][c]) { // 画旗子,简单用红色三角形代替 CPoint pts[3] = { CPoint(rect.left + 6, rect.top + 4), CPoint(rect.left + 6, rect.bottom - 4), CPoint(rect.right - 4, rect.top + 10) }; CBrush brush(RGB(255, 0, 0)); CBrush* old = dc.SelectObject(&brush); dc.Polygon(pts, 3); dc.SelectObject(old); } } } }GetNumberColor是个辅助函数,按 1 到 8 返回经典扫雷的蓝、绿、红、深蓝、棕、青、黑、灰。Draw3dRect画出凸起效果,比单纯Rectangle好看。注意CPaintDC只能在OnPaint里用,别在别处new一个,否则析构时EndPaint会出问题。
3.2 双缓冲:用内存 DC 彻底干掉闪烁
要消闪,标准做法是双缓冲:先在内存 DC 里把整个棋盘画好,再一次性BitBlt到屏幕 DC。这样屏幕只刷新一次,肉眼看不到中间过程。代码结构改成在OnPaint里创建兼容 DC 和位图,画完再拷贝。
void CMineSweeperDlg::OnPaint() { CPaintDC dc(this); CRect clientRect; GetClientRect(&clientRect); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, clientRect.Width(), clientRect.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 背景 memDC.FillSolidRect(clientRect, RGB(192, 192, 192)); // 这里调用 DrawBoard(&memDC) 画所有格子 DrawBoard(&memDC); dc.BitBlt(0, 0, clientRect.Width(), clientRect.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }DrawBoard就是把上面那段循环抽出来,参数改成CDC* pDC。双缓冲的代价是每次重绘多一张位图的内存,16x16 的棋盘也就几十 KB,完全可接受。注意位图要CreateCompatibleBitmap而不是CreateBitmap,否则颜色会失真。还有,memDC和bmp都是局部变量,析构自动释放,别手动DeleteObject造成二次释放。
注意:如果你在
OnPaint之外(比如鼠标消息里)想立即刷新,用Invalidate()标记重绘区域,再UpdateWindow()强制立刻重绘。别直接调OnPaint,那会绕过消息机制。
4. 左键挖开、右键插旗:鼠标消息与递归展开的实现
4.1 坐标换算与左右键分支
鼠标消息OnLButtonDown和OnRButtonDown里,point是客户区坐标,要除以CELL_SIZE换算成行列。注意边界判断,别让点击落在棋盘外还去访问数组,那是典型的越界翻车。
void CMineSweeperDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_gameOver) return; int c = point.x / CELL_SIZE; int r = point.y / CELL_SIZE; if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return; if (m_flagged[r][c] || m_revealed[r][c]) return; if (m_board[r][c] == MINE) { m_gameOver = true; RevealAllMines(); MessageBox(_T("踩雷了!"), _T("游戏结束"), MB_OK); } else { RevealCell(r, c); } Invalidate(); CDialogEx::OnLButtonDown(nFlags, point); }右键插旗逻辑类似,只是把m_flagged取反,同时更新剩余雷数显示。注意插旗的格子不能再被左键挖开,所以左键开头要判断m_flagged。
4.2 递归展开:空白格连锁翻开的边界处理
扫雷最核心的算法是递归展开:点到一个周围雷数为 0 的格子,自动把相邻 8 格也翻开,如果相邻格也是 0,继续递归。这里必须防重复访问,否则无限递归直接栈溢出。我用m_revealed做访问标记,翻开前先判断。
void CMineSweeperDlg::RevealCell(int r, int c) { if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return; if (m_revealed[r][c] || m_flagged[r][c]) return; m_revealed[r][c] = true; if (m_board[r][c] == 0) { for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) { if (dr == 0 && dc == 0) continue; RevealCell(r + dr, c + dc); } } }递归深度最大就是棋盘格子数,16x16 才 256 层,栈完全撑得住。但如果你做 30x30 以上,建议改成显式栈或队列的迭代版本,避免极端情况栈溢出。另外注意:插旗的格子不能被递归翻开,所以m_flagged判断要放在最前面。
4.3 胜利判定与剩余雷数显示
每次翻开后检查是否所有非雷格都已翻开,是则胜利。剩余雷数就是MINE_COUNT减去已插旗数,显示在对话框顶部的静态文本里。用SetDlgItemInt或SetWindowText更新。
bool CMineSweeperDlg::CheckWin() { for (int r = 0; r < ROWS; ++r) for (int c = 0; c < COLS; ++c) if (m_board[r][c] != MINE && !m_revealed[r][c]) return false; return true; }胜利后弹个框,把所有雷自动插旗,然后m_gameOver = true锁住操作。这里有个细节:踩雷时要把所有雷显示出来,但别把插旗的格子覆盖掉,否则玩家会觉得旗子白插了。
5. 扫雷开发中最容易翻车的 5 个坑
5.1 现象:窗口一刷新就闪成一片
原因:每次OnPaint直接往屏幕 DC 画,GDI 逐格绘制被系统合成多次。解决:上双缓冲,内存 DC 画完再BitBlt,这是最立竿见影的优化。
5.2 现象:点空白格程序直接崩
原因:递归展开时没判断边界,r+dr或c+dc越界访问数组。解决:RevealCell开头加边界检查,越界直接 return。血泪经验:这个 bug 在 9x9 小棋盘上不容易触发,一到 16x16 边缘就崩。
5.3 现象:右键插旗后左键还能挖开
原因:左键处理里漏了m_flagged判断。解决:左键开头统一判断if (m_flagged[r][c] || m_revealed[r][c]) return;,别只在部分分支判断。
5.4 现象:布雷时程序卡死或雷数不对
原因:用rand()反复生成坐标直到不重复,雷多时碰撞概率高,循环次数爆炸。解决:改用洗牌法,先生成全部位置再打乱取前 N 个,时间复杂度 O(n),稳定不卡。
5.5 现象:数字颜色和经典扫雷对不上
原因:SetTextColor在DrawText前设置,但背景FillSolidRect之后没恢复,导致后续格子颜色串了。解决:每个格子画之前重新SetTextColor,或者用SetBkMode(TRANSPARENT)让文字背景透明,避免颜色污染。
6. 进阶技巧:用定时器做计时器与自定义难度
计时器是扫雷的标配。在OnInitDialog里SetTimer(1, 1000, nullptr),然后在OnTimer里累加秒数并更新显示。注意第一次点击才开始计时,别一打开窗口就跑表,那不符合扫雷规则。我一般用一个m_started标志,首次左键挖开时置 true 并启动定时器。
void CMineSweeperDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && m_started && !m_gameOver) { m_seconds++; CString s; s.Format(_T("%03d"), m_seconds); SetDlgItemText(IDC_STATIC_TIME, s); } CDialogEx::OnTimer(nIDEvent); }自定义难度可以加个菜单或按钮,弹出对话框让用户输入行、列、雷数,然后重新InitBoard并调整窗口大小。调整窗口大小时用SetWindowPos或MoveWindow,把客户区尺寸设成COLS*CELL_SIZE宽、ROWS*CELL_SIZE高,再留出顶部状态栏的高度。这里有个坑:对话框的客户区尺寸和窗口尺寸差一个边框和标题栏,直接用MoveWindow传客户区尺寸会偏,得用AdjustWindowRect换算。
验证方法很简单:跑起来先点几个角,看递归展开是否正常;再故意踩雷,看所有雷是否显示;最后插满旗看剩余雷数是否归零。我习惯在InitBoard后加一句TRACE输出雷的分布,调试时对着看,比盲点快得多。这个项目代码量不大,但把 MFC 的消息映射、GDI 绘图、定时器、递归全练了一遍,做完再去看别的 MFC 程序,心里就有底了。希望帮到你。
本文还有配套的精品资源,点击获取