
简介这是一份基于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::vectorstd::vectorCellCell 结构体里放三个字段是否是雷、是否已翻开、是否插旗。这样行列数改起来只动一个常量也方便后面做递归展开。// 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::vectorstd::vectorCell 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::vectorCButton*里。这样难度切换时销毁重建也方便。按钮的 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::vectorstd::pairint,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是当前格坐标递归时传rdr、cdc。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_buttonsDestroyWindow后delete再clear。或者用std::vectorstd::unique_ptrCButton自动管理。血泪经验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可以改成只更新变化的格子。我自己的习惯是每加一个功能就用初级快速验证别等全做完再调否则出了问题不知道是哪一步引入的。希望帮到你。本文还有配套的精品资源点击获取