简介:本资源是一份基于MFC框架开发的Windows扫雷游戏完整实现方案,面向C++与Windows桌面开发初学者及高校课程设计学习者,帮助其掌握GUI编程、消息机制、二维逻辑建模等核心技能。压缩包共71个文件,含8个头文件(.h)定义类结构与接口、7个源文件(.cpp)实现游戏逻辑与事件响应、26个位图资源(.bmp/.png/.ico)支撑界面渲染,以及可执行程序(.exe)、调试符号(.pdb/.ilk)和工程配置文件(.dsw/.dsp/.rc),总大小2.7MB,结构完整,开箱即用。已有475人学习下载,资源附带详细源码组织说明与典型模块注释,涵盖雷区随机生成、格子状态管理、计时统计、自定义按钮交互及对话框主界面搭建,特别适合通过经典小游戏项目系统理解MFC消息映射、资源管理与Win32底层交互机制。
1. 这不是怀旧彩蛋,而是一份能直接编译、调试、改出新玩法的MFC扫雷工程包
你手头刚下载完一个标着“MFC扫雷源码”的压缩包,双击打开却发现资源视图里全是问号图标,对话框类一编译就报 C2065:'IDB_BITMAP1' : undeclared identifier——别急,这不是项目坏了,是它默认依赖 Visual Studio 2015 或更高版本的 MFC 动态链接库,且资源 ID 命名和位图加载逻辑藏了三处硬编码陷阱。这个项目不是教学 Demo,而是一个真实可运行、结构清晰、模块职责分明的 Win32 桌面游戏工程:主窗口用 CMainFrame 封装消息循环,游戏逻辑由 CMineSweeperCore 独立承载,格子渲染走 CDC 双缓冲绘图,右键标记/左键翻开/失败重置全部走标准 Windows 消息响应链。它适合两类人:一是想补全 MFC 实战能力的 C++ 初学者(比 ATL 简单,比 Qt 更贴近 Win32 底层),二是需要快速验证算法逻辑(如雷区生成、连通区域展开、难度参数化)的嵌入式或工控界面开发者——毕竟扫雷本质是个状态机 + 图形反馈系统,和很多工业 HMI 的交互范式高度同源。我拿它给某高校实验室改过一套设备自检模拟器,把雷区替换成传感器故障点,点击即触发对应串口指令,三天就跑通了原型。
2. 工程结构与核心模块拆解:从资源文件到游戏状态机
2.1 目录结构与关键文件定位
整个工程是标准 Visual Studio MFC 单文档架构(SDI),但去掉了无关的打印、OLE 支持等冗余功能,精简后共 14 个源文件,核心逻辑集中在以下 5 个文件中:
| 文件名 | 类型 | 作用说明 |
|---|---|---|
MineSweeperDoc.h/cpp | 文档类 | 不存业务数据,仅作为框架占位,实际游戏状态全在CMineSweeperCore中 |
MineSweeperView.h/cpp | 视图类 | 唯一图形入口,负责响应鼠标消息、调用CDC::BitBlt绘制格子、管理双缓冲位图m_bmpBuffer |
MineSweeperCore.h/cpp | 独立逻辑类 | 真正的游戏内核,含GenerateMineField()雷区生成、RevealCell()连锁展开、CheckWinCondition()胜利判定等全部算法 |
Resource.h | 资源定义头 | 所有 ID 定义源头,必须与资源编辑器中实际 ID 严格一致,否则位图/菜单加载失败 |
MineSweeper.rc | 资源脚本 | 包含位图资源IDB_BITMAP_CELL(未翻开格子)、IDB_BITMAP_FLAG(旗帜)、IDB_BITMAP_MINE(地雷)等,共 9 张 PNG 转换的 BMP |
提示:所有位图资源均需为 24 位真彩色 BMP 格式(非 PNG/JPEG),尺寸严格为 32×32 像素。若用 Photoshop 导出,请关闭“包含 ICC 配置文件”选项,否则 MFC
LoadBitmap会返回 NULL。
2.2 CMineSweeperCore:雷区生成与状态管理的核心实现
这是整个项目最值得细读的部分。它不继承任何 MFC 类,纯 C++ 实现,便于单元测试和跨平台移植。关键成员变量如下:
// MineSweeperCore.h class CMineSweeperCore { private: std::vector<std::vector<CELL_STATE>> m_grid; // 二维向量存储每个格子状态 std::vector<std::vector<bool>> m_mineMap; // 雷区布尔矩阵,true 表示有雷 int m_rows, m_cols, m_totalMines; bool m_gameStarted; // 首次点击才生成雷区,避免开局即踩雷(经典扫雷规则) int m_revealedCount; // 已翻开格子数,用于胜利判定 };雷区生成采用“首次点击安全”策略,核心逻辑在GenerateMineField(int firstRow, int firstCol)中:
// MineSweeperCore.cpp void CMineSweeperCore::GenerateMineField(int firstRow, int firstCol) { // 步骤1:初始化全空网格 m_mineMap.assign(m_rows, std::vector<bool>(m_cols, false)); // 步骤2:在首次点击位置周围3×3区域排除雷(保证首点安全) for (int r = std::max(0, firstRow-1); r <= std::min(m_rows-1, firstRow+1); ++r) { for (int c = std::max(0, firstCol-1); c <= std::min(m_cols-1, firstCol+1); ++c) { m_mineMap[r][c] = false; } } // 步骤3:随机布雷,直到布满 m_totalMines 颗 int placed = 0; while (placed < m_totalMines) { int r = rand() % m_rows; int c = rand() % m_cols; if (!m_mineMap[r][c] && !(r >= firstRow-1 && r <= firstRow+1 && c >= firstCol-1 && c <= firstCol+1)) { m_mineMap[r][c] = true; placed++; } } }逻辑说明:
firstRow/firstCol是用户第一次左键点击的坐标,传入此函数后立即锁定安全区;rand()初始化需在CMineSweeperCore::Init()中调用srand((unsigned int)time(nullptr)),否则每次生成雷区完全相同;- 安全区排除逻辑必须在布雷循环内二次校验(
!(r >= ...)),因为rand()可能重复生成同一坐标,仅靠初始化设false不够。
2.3 MineSweeperView:双缓冲绘图与鼠标事件路由
视图类是图形表现层,其OnDraw(CDC* pDC)函数是性能关键点。它不直接调用pDC->Rectangle()画格子,而是先绘制到内存位图,再一次性BitBlt到屏幕,彻底避免闪烁:
// MineSweeperView.cpp void CMineSweeperView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(&rect); // 步骤1:创建/调整内存DC和位图 if (m_bmpBuffer.GetSafeHandle() == NULL || m_bmpWidth != rect.Width() || m_bmpHeight != rect.Height()) { m_bmpBuffer.DeleteObject(); m_bmpBuffer.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); m_bmpWidth = rect.Width(); m_bmpHeight = rect.Height(); } CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap* pOldBmp = memDC.SelectObject(&m_bmpBuffer); // 步骤2:清空内存DC背景(灰色) memDC.FillSolidRect(&rect, RGB(240, 240, 240)); // 步骤3:逐格绘制(核心循环) const int CELL_SIZE = 32; for (int r = 0; r < m_core.GetRows(); ++r) { for (int c = 0; c < m_core.GetCols(); ++c) { CRect cellRect(c * CELL_SIZE, r * CELL_SIZE, (c+1) * CELL_SIZE, (r+1) * CELL_SIZE); // 根据格子状态选择不同位图绘制 switch (m_core.GetCellState(r, c)) { case CELL_HIDDEN: DrawBitmap(&memDC, IDB_BITMAP_CELL, cellRect); break; case CELL_REVEALED: DrawNumber(&memDC, m_core.GetAdjacentMineCount(r, c), cellRect); break; case CELL_FLAGGED: DrawBitmap(&memDC, IDB_BITMAP_FLAG, cellRect); break; case CELL_MINE: DrawBitmap(&memDC, IDB_BITMAP_MINE, cellRect); break; case CELL_MINE_HIT: DrawBitmap(&memDC, IDB_BITMAP_MINE_RED, cellRect); break; // 红色爆炸效果 } } } // 步骤4:一次性输出到屏幕 pDC->BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }参数说明:
CELL_SIZE = 32是硬编码值,与资源位图尺寸强绑定,修改位图尺寸时必须同步改此处;DrawBitmap()是封装函数,内部调用CBitmap::LoadBitmap(ID)+CDC::StretchBlt(),确保位图拉伸不失真;DrawNumber()使用CDC::TextOut()绘制数字,字体为CreateFont(24, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, _T("Arial")),保证数字清晰锐利。
3. 编译与运行全流程:从环境配置到首次成功运行
3.1 开发环境准备:VS 版本与 MFC 组件安装
该项目基于 Visual Studio 2019 社区版开发,最低兼容 VS 2015。若使用 VS 2022,需额外注意两点:
MFC 桌面组件必须手动勾选安装:在 VS Installer 中,“工作负载” → “使用 C++ 的桌面开发” → 右侧“单独组件”搜索并勾选:
CMake tools for Visual StudioWindows 10/11 SDK(建议选 10.0.19041.0 或更新)C++ MFC for latest v143 build tools(关键!)
项目属性页关键设置:
配置属性 → 常规 → 使用 MFC:必须设为“在共享 DLL 中使用 MFC”(而非静态链接),否则CFrameWnd等类链接失败;配置属性 → C/C++ → 语言 → C++ 语言标准:设为ISO C++14 标准 (/std:c++14),因std::vector::assign()在 C++11 中无双参数重载;配置属性 → 链接器 → 输入 → 附加依赖项:确认包含mfcs140.lib(VS 2015)、mfcs142.lib(VS 2019)或mfcs143.lib(VS 2022),名称需与所选工具集匹配。
提示:若编译报错
LNK2001: unresolved external symbol "public: virtual struct CRuntimeClass * __thiscall CMineSweeperView::GetRuntimeClass(void)const ",90% 是 MFC 使用方式设为“在静态库中使用 MFC”,请立即修正。
3.2 资源加载失败的典型修复路径
资源加载失败是新手第一道坎,常见于位图 ID 不匹配或路径错误。按顺序排查:
检查 Resource.h 中 ID 定义是否与 .rc 文件一致:
打开Resource.h,确认存在#define IDB_BITMAP_CELL 101;再打开MineSweeper.rc,搜索IDB_BITMAP_CELL,确认其后紧跟BITMAP DISCARDABLE "res\\cell.bmp"。若.rc中写的是IDB_CELL,则必须同步修改Resource.h。验证位图文件物理存在且路径正确:
工程默认位图放在res\子目录下。在 VS 解决方案资源管理器中,右键res文件夹 → “在文件资源管理器中打开文件夹”,确认cell.bmp、flag.bmp等文件真实存在。若放错位置(如直接放在工程根目录),需在.rc文件中修改路径为"cell.bmp"并重新加载资源。强制重建资源符号表:
若修改过Resource.h,务必执行:菜单栏生成 → 清理解决方案→生成 → 重新生成解决方案。VS 不会自动检测Resource.h变更并刷新资源 ID 映射,跳过清理将导致旧 ID 缓存生效。
3.3 首次运行与基础交互验证
成功编译后,按Ctrl+F5启动(不调试),窗口应显示 10×10 网格,所有格子为灰色未翻开状态。此时验证三项基础交互:
- 左键翻开:点击任意格子,若非雷则显示周围雷数(如
1、2),若为雷则显示红色地雷图标并停止游戏; - 右键标记:在未翻开格子上右键,应循环切换:无标记 → 旗帜 → 问号 → 无标记;
- 双击展开:在已显示数字的格子上双击(如显示
2),若周围未标记格子数等于2,则自动翻开所有未标记邻格(经典扫雷高级操作)。
注意:双击逻辑实现在
MineSweeperView::OnLButtonDblClk()中,它调用m_core.AutoRevealAdjacent(row, col),该函数会遍历 8 个方向,对每个CELL_HIDDEN邻格调用RevealCell()。若双击无反应,请检查ON_WM_LBUTTONDBLCLK()是否在BEGIN_MESSAGE_MAP中正确定义。
4. 常见问题与避坑指南:血泪经验总结的 5 条硬核记录
4.1 现象:程序启动后窗口空白,任务栏有图标但主界面不可见
原因:MineSweeperView::OnInitialUpdate()中未调用ResizeParentToFit(TRUE),导致视图客户区尺寸为 0×0,后续绘图无区域可画。
解决:在MineSweeperView.cpp的OnInitialUpdate()函数末尾添加:
void CMineSweeperView::OnInitialUpdate() { CView::OnInitialUpdate(); // ... 原有代码 ResizeParentToFit(TRUE); // 必加!强制父窗口适配视图内容 }4.2 现象:点击格子无响应,调试发现OnLButtonDown根本没被调用
原因:MineSweeperView类未在消息映射中声明ON_WM_LBUTTONDOWN(),或声明位置错误(如放在END_MESSAGE_MAP()之后)。
解决:检查MineSweeperView.h中DECLARE_MESSAGE_MAP()前是否有afx_msg void OnLButtonDown(UINT nFlags, CPoint point);,并在MineSweeperView.cpp的BEGIN_MESSAGE_MAP块内添加:
BEGIN_MESSAGE_MAP(CMineSweeperView, CView) ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_LBUTTONDBLCLK() // ... 其他消息 END_MESSAGE_MAP()4.3 现象:雷区生成后,GetAdjacentMineCount()返回值全为 0
原因:CMineSweeperCore::CalculateAdjacentMines()函数中边界判断错误,导致r-1或c-1下标越界访问m_mineMap,返回随机值。
解决:将原函数中for (int dr = -1; dr <= 1; dr++)循环内的if (m_mineMap[r+dr][c+dc])改为安全访问:
for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; int nr = r + dr, nc = c + dc; if (nr >= 0 && nr < m_rows && nc >= 0 && nc < m_cols) { // 必须加此判断! if (m_mineMap[nr][nc]) count++; } } }4.4 现象:修改m_rows/m_cols为 15×15 后,窗口显示错位,格子被截断
原因:MineSweeperView::OnSize()未重写,导致客户区大小变更时,视图不通知核心类更新尺寸,OnDraw中仍按旧尺寸计算cellRect。
解决:在MineSweeperView.h中声明afx_msg void OnSize(UINT nType, int cx, int cy);,在.cpp中实现:
void CMineSweeperView::OnSize(UINT nType, int cx, int cy) { CView::OnSize(nType, cx, cy); // 通知核心类当前可视区域能容纳多少格子(按 CELL_SIZE=32 计算) int newCols = cx / 32; int newRows = cy / 32; if (newRows > 0 && newCols > 0) { m_core.ResizeGrid(newRows, newCols); // 需在 CMineSweeperCore 中新增此方法 } }4.5 现象:添加新难度(如专家级 16×30)后,程序崩溃在GenerateMineField()
原因:rand() % m_cols中m_cols为 0(未初始化),因CMineSweeperCore::Init()未被调用。
解决:在MineSweeperDoc::OnNewDocument()中,m_core.Init()必须在m_core.ResizeGrid()之后调用:
BOOL CMineSweeperDoc::OnNewDocument() { if (!CDocument::OnNewDocument()) return FALSE; m_core.ResizeGrid(10, 10); // 先设默认尺寸 m_core.Init(); // 再初始化随机数种子和内部状态 return TRUE; }5. 二次开发实战:三步改造出「计时器+步数统计」功能
5.1 第一步:扩展核心类状态,注入时间与步数字段
CMineSweeperCore当前只管雷区逻辑,需增加游戏元数据。在MineSweeperCore.h中添加:
class CMineSweeperCore { private: // ... 原有成员 DWORD m_startTime; // 游戏开始时刻(GetTickCount()) DWORD m_elapsedTime; // 当前已用时间(毫秒) int m_moveCount; // 总操作步数(左右键各算1步) bool m_isRunning; // 计时器是否启动(首次翻开后为true) public: void StartTimer(); // 首次翻开时调用 void UpdateTimer(); // 每帧调用(需外部驱动) void IncrementMoveCount(); // 每次鼠标操作后调用 DWORD GetElapsedTime() const { return m_elapsedTime; } int GetMoveCount() const { return m_moveCount; } bool IsTimerRunning() const { return m_isRunning; } };StartTimer()实现需确保只在首次翻开时触发:
// MineSweeperCore.cpp void CMineSweeperCore::StartTimer() { if (!m_isRunning) { m_startTime = GetTickCount(); m_elapsedTime = 0; m_isRunning = true; } } void CMineSweeperCore::UpdateTimer() { if (m_isRunning) { DWORD now = GetTickCount(); // 防止长时间挂起导致时间倒流 if (now >= m_startTime) { m_elapsedTime = now - m_startTime; } } } void CMineSweeperCore::IncrementMoveCount() { m_moveCount++; }关键设计点:
UpdateTimer()不启动线程,而是由视图层每 100ms 主动调用,避免多线程同步复杂度。GetTickCount()足够精度(毫秒级),且无需#include <windows.h>(MFC 已隐式包含)。
5.2 第二步:视图层集成定时器与状态栏更新
MFC 状态栏(CStatusBar)是显示计时器/步数的天然位置。在MineSweeperView.h中添加:
class CMineSweeperView : public CView { private: CStatusBar* m_pStatusBar; // 指向主框架状态栏 UINT m_timerID; // 定时器ID public: afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); afx_msg void OnDestroy(); afx_msg void OnTimer(UINT_PTR nIDEvent); void UpdateStatusBar(); // 刷新状态栏文本 };在OnCreate()中获取状态栏并启动定时器:
int CMineSweeperView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) == -1) return -1; // 获取主框架状态栏指针 CMainFrame* pFrame = (CMainFrame*)GetParentFrame(); if (pFrame) { m_pStatusBar = &pFrame->m_wndStatusBar; } // 启动100ms定时器(ID为1) m_timerID = SetTimer(1, 100, NULL); return 0; }OnTimer()中驱动核心更新并刷新 UI:
void CMineSweeperView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && m_pStatusBar) { m_core.UpdateTimer(); // 更新计时器 UpdateStatusBar(); // 刷新状态栏 Invalidate(); // 触发重绘(让数字实时变化) } CView::OnTimer(nIDEvent); }UpdateStatusBar()格式化显示:
void CMineSweeperView::UpdateStatusBar() { if (!m_pStatusBar) return; CString strTime, strMoves; DWORD ms = m_core.GetElapsedTime(); int moves = m_core.GetMoveCount(); // 转换为 MM:SS 格式 int seconds = ms / 1000; strTime.Format(_T("时间:%02d:%02d"), seconds / 60, seconds % 60); strMoves.Format(_T("步数:%d"), moves); // 设置状态栏第0、1个面板(需在 CMainFrame::OnCreate() 中预设2个面板) m_pStatusBar->SetPaneText(0, strTime); m_pStatusBar->SetPaneText(1, strMoves); }注意:
CMainFrame::OnCreate()中必须预先设置状态栏面板数,否则SetPaneText无效:// CMainFrame.cpp int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) { // ... 原有代码 m_wndStatusBar.Create(this); m_wndStatusBar.SetIndicators(indicators, sizeof(indicators)/sizeof(UINT)); // indicators[2] = {ID_SEPARATOR, ID_SEPARATOR} return 0; }
5.3 第三步:在鼠标事件中注入步数统计与计时启动
最后,在MineSweeperView::OnLButtonDown和OnRButtonDown中调用核心类方法:
void CMineSweeperView::OnLButtonDown(UINT nFlags, CPoint point) { CView::OnLButtonDown(nFlags, point); // 计算点击格子坐标 int row = point.y / 32; int col = point.x / 32; // 首次翻开启动计时器 if (!m_core.IsTimerRunning() && m_core.GetCellState(row, col) == CELL_HIDDEN) { m_core.StartTimer(); } // 所有鼠标操作都计步 m_core.IncrementMoveCount(); // 执行翻开逻辑 m_core.RevealCell(row, col); Invalidate(); }同理,在OnRButtonDown中添加m_core.IncrementMoveCount()。至此,编译运行后,状态栏将实时显示「时间:00:00」和「步数:0」,每点击一次步数+1,首次翻开后时间开始走动。
从那以后我每次给新人讲 MFC 项目,都会先带他们把这个扫雷工程的计时器加上——不是为了炫技,而是强迫他们亲手走一遍「状态注入→UI 驱动→事件钩子」的完整闭环。这比看十页文档都管用,因为所有代码都在眼皮底下,改一行就能看到结果。希望帮到你。
本文还有配套的精品资源,点击获取