简介:这是一份基于MFC框架实现的扫雷游戏完整源码工程,面向正在学习Windows桌面开发、C++面向对象编程以及MFC文档视图架构的初学者与进阶者。资源以鼠标点击操作为核心交互方式,界面简洁明了,代码结构清晰,适合作为课程设计、毕业设计参考或MFC练手项目。压缩包共45个文件,约2.85MB,包含6个cpp源文件与7个h头文件构成核心逻辑,17个bmp位图与2个ico图标提供游戏素材,另有rc资源脚本、dsp/dsw工程文件及可执行程序,方便直接编译运行与二次修改。目前已有315人学习下载。通过阅读该工程,读者可以掌握MFC文档视图框架的搭建方式、消息映射与鼠标事件响应机制、雷区数据结构的组织与递归展开算法,以及资源文件与对话框的整合方法,是理解经典扫雷实现思路的实用参考。
1. 基于MFC实现的扫雷游戏:从对话框工程到可玩成品的完整路径
很多人第一次接触 Windows 桌面开发,都是从 MFC 扫雷开始的。它不像记事本那样只是堆控件,也不像计算器那样只做四则运算,扫雷要求你把界面绘制、鼠标消息、定时器、递归展开、状态管理全部串起来。标题里的“基于MFC实现的扫雷游戏”,核心就是用 MFC 的对话框工程搭一个 9×9 到 16×30 的雷区,左键翻开、右键插旗、计时计分、胜负判定。适合已经会一点 C++、想找一个完整小项目练手的人,也适合被“mfc四大类”“mfc教程”绕晕、需要一条清晰落地路径的开发者。下面按我实际做过的顺序,把选型、绘制、逻辑、避坑一次讲透。
2. 对话框工程选型与雷区数据结构设计
2.1 为什么扫雷优先选对话框工程而不是单文档
MFC 的工程模板常见有三类:单文档、多文档、对话框。扫雷这种固定窗口、控件密集、不需要文档/视图分离的程序,对话框工程是最省事的。单文档会自带菜单、工具栏、状态栏和文档序列化框架,你为了一个扫雷去裁剪这些,反而容易在 OnDraw 和视图坐标上翻车。对话框工程直接给你一个 CDialogEx 派生类,资源编辑器里拖控件,消息映射写响应,路径最短。
我一般会这样建工程:Visual Studio 里选“MFC 应用”,应用程序类型选“基于对话框”,项目名比如 MineSweeper。生成后你会看到CMineSweeperApp和CMineSweeperDlg两个核心类,前者管应用生命周期,后者管窗口。这里有个热词里常被问到的点——“mfc四大类”,通常指 CWinApp、CFrameWnd、CView、CDocument,但对话框工程里真正天天打交道的是 CWinApp、CDialogEx、CWnd、CDC,别被概念绕住,先把对话框跑起来再说。
雷区数据不要用二维数组int map[30][30]硬编码,因为行列数要可配置。常见做法是用std::vector<std::vector<Cell>>,Cell 结构体里放三个字段:是否是雷、是否已翻开、是否插旗。这样行列数改起来只动一个常量,也方便后面做递归展开。
// MineSweeperDlg.h 里定义单元格结构 struct Cell { bool isMine = false; // 是否是地雷 bool isRevealed = false; // 是否已翻开 bool isFlagged = false; // 是否插旗 int neighborMines = 0; // 周围8格雷数 }; // 对话框类成员 int m_rows = 9; // 行数 int m_cols = 9; // 列数 int m_mineCount = 10; // 雷数 std::vector<std::vector<Cell>> m_board; // 雷区 bool m_gameOver = false; bool m_win = false; int m_remainingFlags = 0;这段代码的关键在于把“状态”和“显示”分开。Cell 只存逻辑状态,不存按钮指针或颜色。参数说明:m_rows、m_cols、m_mineCount是难度配置,初级 9×9/10 雷,中级 16×16/40 雷,高级 16×30/99 雷。m_remainingFlags用来在界面上显示剩余旗数,初始等于雷数。m_gameOver和m_win控制后续点击是否还响应。很多新手把按钮数组和逻辑数组混在一起,结果重置游戏时按钮状态清不干净,这是后面避坑章要重点说的。
2.2 用按钮网格还是自绘雷区
扫雷的界面实现有两条路:一是用一堆 CButton 拼成网格,二是用一块自绘区域,在 OnPaint 里根据坐标画格子。按钮方案上手快,资源编辑器里拖 81 个按钮虽然累,但消息映射直观,适合第一次做。自绘方案灵活,格子大小、颜色、图标都能控,但你要自己处理鼠标坐标到行列的换算,还要管重绘闪烁。
我的建议是:第一次做用按钮网格,但不要真的拖 81 个按钮。用CButton动态创建,在OnInitDialog里根据行列数循环Create,存到一个std::vector<CButton*>里。这样难度切换时销毁重建也方便。按钮的 ID 可以用一个基数加偏移,比如IDC_BTN_BASE + row * m_cols + col,消息映射里用ON_COMMAND_RANGE批量处理。
// OnInitDialog 中动态创建按钮网格 CRect rect; GetClientRect(&rect); int btnSize = 24; // 每个格子24像素 for (int r = 0; r < m_rows; ++r) { for (int c = 0; c < m_cols; ++c) { CButton* pBtn = new CButton(); pBtn->Create(_T(""), WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, CRect(10 + c * btnSize, 50 + r * btnSize, 10 + (c + 1) * btnSize, 50 + (r + 1) * btnSize), this, IDC_BTN_BASE + r * m_cols + c); m_buttons.push_back(pBtn); } }逻辑说明:Create的第四个参数是父窗口,第五个是控件 ID。IDC_BTN_BASE在 resource.h 里定义一个起始值,比如 1000,保证不和对话框自带的 IDOK、IDCANCEL 冲突。参数说明:btnSize决定格子大小,24 像素在 9×9 下窗口约 240×280,比较紧凑;高级 16×30 时窗口会到 720 宽,需要给对话框加滚动条或缩小格子。10和50是左边距和上边距,给标题和剩余雷数显示留位置。注意按钮创建后要ShowWindow(SW_SHOW),否则可能不显示。这个方案在“mfc制作一个近似于windows记事本怎么做”里也常用,思路都是动态控件加消息映射。
3. 布雷、翻开与递归展开的核心逻辑
3.1 首次点击不踩雷的布雷策略
扫雷有个约定俗成的体验:第一次点击不能是雷。实现方式有两种,一是在OnInitDialog里先布雷,但把首次点击的格子及其周围排除;二是延迟布雷,等第一次左键点击时再随机放雷,保证点击格和周围 8 格无雷。第二种更干净,我一般用第二种。
void CMineSweeperDlg::PlaceMines(int safeRow, int safeCol) { // 收集所有安全格之外的坐标 std::vector<std::pair<int,int>> candidates; for (int r = 0; r < m_rows; ++r) { for (int c = 0; c < m_cols; ++c) { // 首次点击格及其周围8格不放雷 if (abs(r - safeRow) <= 1 && abs(c - safeCol) <= 1) continue; candidates.push_back({r, c}); } } // 洗牌后取前 m_mineCount 个作为雷 std::random_device rd; std::mt19937 gen(rd()); std::shuffle(candidates.begin(), candidates.end(), gen); for (int i = 0; i < m_mineCount && i < (int)candidates.size(); ++i) { m_board[candidates[i].first][candidates[i].second].isMine = true; } // 计算每个非雷格周围的雷数 for (int r = 0; r < m_rows; ++r) { for (int c = 0; c < m_cols; ++c) { if (m_board[r][c].isMine) 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 < m_rows && nc >= 0 && nc < m_cols && m_board[nr][nc].isMine) cnt++; } m_board[r][c].neighborMines = cnt; } } }逻辑说明:先收集候选坐标,排除安全区,再用std::shuffle打乱,取前 N 个设为雷。参数说明:safeRow、safeCol是首次点击的行列,abs判断保证 3×3 安全区。m_mineCount不能超过候选数,高级 16×30 有 480 格,99 雷完全够。计算邻居雷数时用双重循环扫 8 方向,注意边界判断。这段逻辑跑通后,扫雷的核心就立住了一半。热词里“mfc 获取列表总列数”在这里对应的是m_cols,如果你用 CListCtrl 做排行榜,列数用GetHeaderCtrl()->GetItemCount(),但扫雷本身不需要。
3.2 递归展开与胜利判定
点击一个非雷格后,如果周围雷数为 0,要自动展开周围 8 格,这就是扫雷的“连锁反应”。递归实现最直观,但要注意防止重复展开和栈溢出。9×9 还好,16×30 递归深度最多 480,不会爆栈,但重复访问会拖慢。加一个isRevealed判断即可。
void CMineSweeperDlg::RevealCell(int r, int c) { if (r < 0 || r >= m_rows || c < 0 || c >= m_cols) return; Cell& cell = m_board[r][c]; if (cell.isRevealed || cell.isFlagged) return; cell.isRevealed = true; UpdateButton(r, c); // 刷新按钮显示 if (cell.isMine) { m_gameOver = true; RevealAllMines(); return; } if (cell.neighborMines == 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); } } CheckWin(); }逻辑说明:先做边界和状态检查,翻开当前格,如果是雷就结束游戏并显示所有雷。如果是空格,递归展开周围 8 格。UpdateButton负责把逻辑状态映射到按钮文字或颜色。参数说明:r、c是当前格坐标,递归时传r+dr、c+dc。CheckWin在每次翻开后调用,判断已翻开格数是否等于总格数减雷数。
胜利判定不要用“所有非雷格都翻开”去遍历整个数组,那样每次点击都 O(n²)。用一个计数器m_revealedCount,翻开时加一,和m_rows * m_cols - m_mineCount比较即可。这是性能上的小优化,但习惯养成了后面做更大棋盘不会卡。
void CMineSweeperDlg::CheckWin() { if (m_revealedCount == m_rows * m_cols - m_mineCount) { m_win = true; m_gameOver = true; KillTimer(TIMER_ID); AfxMessageBox(_T("你赢了!")); } }参数说明:TIMER_ID是计时器 ID,胜利后停掉。AfxMessageBox是 MFC 的弹窗,简单直接。注意m_revealedCount要在RevealCell里每次成功翻开时递增,且不能重复递增,所以isRevealed判断要放在最前面。
4. 鼠标消息、插旗与计时器的落地细节
4.1 右键插旗与左键翻开的区分
MFC 按钮默认只响应左键,右键插旗需要自己处理。常见做法是不用按钮的 BN_CLICKED,而是重写对话框的OnLButtonDown和OnRButtonDown,通过坐标换算出行列。但按钮会挡住鼠标消息,所以要么给按钮加BS_OWNERDRAW自绘,要么在按钮的父窗口里用PreTranslateMessage拦截。
我一般用PreTranslateMessage,因为它不用改按钮样式,直接在消息到达按钮前处理。
BOOL CMineSweeperDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg->message == WM_RBUTTONDOWN) { CPoint pt = pMsg->pt; ScreenToClient(&pt); int r, c; if (HitTest(pt, r, c)) { OnRightClick(r, c); return TRUE; // 拦截,不再传递 } } return CDialogEx::PreTranslateMessage(pMsg); }逻辑说明:WM_RBUTTONDOWN时把屏幕坐标转成客户区坐标,HitTest根据坐标反算行列。参数说明:pMsg->pt是屏幕坐标,必须ScreenToClient。HitTest里用(pt.x - 10) / btnSize和(pt.y - 50) / btnSize算行列,注意边界检查。返回TRUE表示消息已处理,不再往下传,否则按钮可能会收到右键消息导致奇怪行为。
OnRightClick里做插旗切换:如果已翻开则忽略,如果已插旗则取消,否则插旗,并更新m_remainingFlags和按钮显示。插旗显示可以用SetWindowText(_T("F"))或改按钮图标,简单起见用文字。
4.2 计时器与剩余雷数显示
扫雷的计时器从第一次点击开始,每秒加一。MFC 里用SetTimer和OnTimer。
// 第一次点击时启动 SetTimer(TIMER_ID, 1000, nullptr); m_startTime = GetTickCount(); // 消息映射 ON_WM_TIMER() void CMineSweeperDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_ID && !m_gameOver) { int elapsed = (GetTickCount() - m_startTime) / 1000; CString str; str.Format(_T("时间:%d 秒"), elapsed); GetDlgItem(IDC_STATIC_TIME)->SetWindowText(str); } CDialogEx::OnTimer(nIDEvent); }逻辑说明:SetTimer第三个参数 1000 表示 1 秒触发一次。GetTickCount返回毫秒,除以 1000 得秒。参数说明:TIMER_ID自定义一个值,比如 1。IDC_STATIC_TIME是静态文本控件 ID。注意游戏结束后要KillTimer,否则计时器还在跑。剩余雷数显示在插旗时更新:m_remainingFlags = m_mineCount - 已插旗数,用GetDlgItem(IDC_STATIC_FLAGS)->SetWindowText刷新。
这里有个热词“mfc 字符串内存泄漏”值得提一句:CString本身管理内存,但如果你用new CString或GetBuffer后不ReleaseBuffer,就会泄漏。扫雷里字符串操作少,但计时器每秒 Format 一次,用栈上 CString 没问题,别去new。
5. 扫雷开发中容易翻车的五个坑
5.1 按钮动态创建后消息映射不响应
现象:按钮显示出来了,点击没反应。原因:动态创建的按钮 ID 不在ON_COMMAND_RANGE范围内,或者消息映射没写。解决:在.h里声明afx_msg void OnBtnClick(UINT nID);,在.cpp里ON_COMMAND_RANGE(IDC_BTN_BASE, IDC_BTN_BASE + 480, OnBtnClick),范围要覆盖最大棋盘。OnBtnClick里用nID - IDC_BTN_BASE反算行列。
5.2 重置游戏时旧按钮没销毁导致内存泄漏
现象:切换难度或重开一局后,旧按钮还在,内存持续涨。原因:new CButton后没有delete。解决:重置时遍历m_buttons,DestroyWindow后delete,再clear。或者用std::vector<std::unique_ptr<CButton>>自动管理。血泪经验:MFC 里new的控件一定要手动delete,它不会随父窗口销毁自动释放。
5.3 递归展开导致重复翻开和显示错乱
现象:连锁展开时某些格子翻开了但按钮没更新,或者已经插旗的格子被翻开。原因:递归前没检查isFlagged和isRevealed。解决:RevealCell开头就判断if (cell.isRevealed || cell.isFlagged) return;,并且UpdateButton要在状态改变后立即调用。注意插旗格不能被递归翻开,这是规则。
5.4 首次点击踩雷
现象:第一下就点到雷,体验极差。原因:布雷在OnInitDialog里做了,没排除点击格。解决:延迟布雷,在第一次左键点击时调用PlaceMines,并把点击格及周围 8 格排除。注意如果雷数太多,安全区可能不够,要判断候选数是否大于雷数。
5.5 计时器在游戏结束后还在跑
现象:赢了或输了之后时间还在涨。原因:KillTimer没调用或调用位置不对。解决:在m_gameOver = true的地方统一KillTimer(TIMER_ID)。注意OnTimer里也要判断m_gameOver,双保险。这个坑很小但很常见,属于“后悔药”级别的疏忽。
6. 把扫雷做成可配置难度与自绘皮肤的进阶技巧
基础版跑通后,可以往两个方向进阶:一是难度配置,二是自绘皮肤。难度配置不只是改行列和雷数,还要处理窗口大小、按钮尺寸、滚动条。我一般把难度参数放到一个结构体里,切换时先销毁旧棋盘,再重建。
struct Difficulty { int rows, cols, mines; const TCHAR* name; }; Difficulty g_levels[] = { {9, 9, 10, _T("初级")}, {16, 16, 40, _T("中级")}, {16, 30, 99, _T("高级")} };参数说明:rows、cols、mines对应棋盘规格,name用于菜单显示。切换时调用ResetGame(levelIndex),内部先ClearBoard()销毁按钮,再按新参数创建。注意高级 16×30 在 24 像素格子下宽度 720,普通对话框放不下,要么缩小格子到 20 像素,要么给对话框加WS_HSCROLL和WS_VSCROLL,在OnHScroll里ScrollWindow。
自绘皮肤的思路是放弃 CButton,改用一块CStatic或直接在主对话框上OnPaint画。每个格子根据状态画不同颜色:未翻开灰色、已翻开白色、雷红色、旗子绿色。数字用DrawText画在格子中央。这样格子大小可以自由缩放,也方便做“mfc显示bmp图片”那种贴图皮肤。但自绘要处理双缓冲,否则闪烁严重。在OnPaint里用CDC memDC; memDC.CreateCompatibleDC(pDC);先画到内存 DC,再BitBlt到屏幕。
验证方法:改完难度后,用初级跑一局确认首次点击安全、递归展开正常、胜利弹窗;再用高级跑一局确认滚动条和性能。如果高级下点击卡顿,检查UpdateButton是否每次都SetWindowText,可以改成只更新变化的格子。我自己的习惯是每加一个功能就用初级快速验证,别等全做完再调,否则出了问题不知道是哪一步引入的。希望帮到你。
本文还有配套的精品资源,点击获取