ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

MFC扫雷源码实战:VC++项目复刻与核心逻辑解析

MFC扫雷源码实战:VC++项目复刻与核心逻辑解析 简介这是一份面向Windows程序设计初学者与MFC进阶者的扫雷游戏完整开发资源基于Microsoft Foundation Classes框架实现帮助读者理解如何用C类库构建图形界面、处理消息循环与用户交互事件。压缩包共71个文件约2.7MB包含7个cpp源文件与8个h头文件承载核心逻辑26个bmp位图与2个ico图标构成游戏界面素材另有rc资源脚本、dsp与dsw工程文件及exe可执行程序方便直接运行与调试。资源围绕雷区随机生成、格子标记、计时统计等机制展开通过自定义对话框与按钮类演示控件布局、消息映射和事件响应源码结构清晰适合对照阅读以掌握MFC与Windows消息机制的结合方式。目前已有475人学习下载可作为课程设计或自学练手的实践参考。1. 用 MFC 复刻扫雷一份能直接编译运行的 VC 源码项目很多人第一次接触 Windows 桌面开发都是从 MFC 开始的而扫雷这个经典小游戏几乎是绕不开的练手项目。这份源码包给的是一个完整的、基于 MFC 框架实现的扫雷程序打开解决方案就能编译跑起来就是一个带菜单、带状态栏、能正常判雷判胜的标准 Windows 窗口程序。它适合两类人一类是刚学完 C 语法、想找个能跑起来的完整项目练手的同学另一类是想回头补一补 MFC 消息映射、GDI 绘图、文档视图这套老框架的开发者。整个项目不依赖任何第三方库纯 Win32 MFC代码量适中逻辑清晰拿来当二次开发的底子非常合适。2. 项目结构与技术选型MFC 单文档框架怎么撑起一个扫雷2.1 为什么用 MFC 单文档而不是对话框扫雷这个程序表面上看是个小游戏但它有菜单栏、有客户区绘图、有状态栏显示剩余雷数这几样东西凑在一起用对话框程序做会显得很别扭。对话框程序天生是给表单输入用的客户区被各种控件占满想在上面自由画格子反而要绕一圈。单文档SDI框架就不一样了它天然带一个主框架窗口、一个菜单资源、一个视图类视图类的OnDraw就是给你画东西用的状态栏也能通过框架类挂上去。这份源码走的就是标准 SDI 路线核心类大概这么几个类名职责C扫雷App应用程序类负责初始化CMainFrame主框架窗口管菜单和状态栏C扫雷View视图类负责绘制棋盘、处理鼠标点击C扫雷Doc文档类存棋盘数据雷区、翻开状态数据放 Doc、显示放 View这是 MFC 文档视图架构的基本分工。棋盘数据每个格子是不是雷、有没有被翻开、周围雷数全部存在文档类里视图类只负责根据文档数据画出来鼠标点击也是先转成对文档数据的修改再触发重绘。这个分工的好处是以后想加存档功能直接序列化文档类就行视图不用动。2.2 目录结构长什么样解压源码后目录大致是这样扫雷/ ├── 扫雷.sln // VS 解决方案文件 ├── 扫雷/ │ ├── 扫雷.vcxproj // 项目文件 │ ├── 扫雷.h / .cpp // 应用程序类 │ ├── MainFrm.h / .cpp // 主框架窗口 │ ├── 扫雷Doc.h / .cpp // 文档类棋盘数据 │ ├── 扫雷View.h / .cpp // 视图类绘图与交互 │ ├── StdAfx.h / .cpp // 预编译头 │ ├── res/ // 图标、菜单等资源 │ └── resource.h // 资源 ID 定义这个结构是 VS 向导生成的标准 MFC 工程结构没有多余的东西。res目录里放的是菜单资源和图标菜单里一般会有「游戏」菜单下面挂「初级」「中级」「高级」和「重新开始」几个菜单项。resource.h 里定义这些菜单项的 ID消息映射里通过 ID 来响应。2.3 核心数据结构棋盘怎么存棋盘数据是整个程序的地基存得好不好直接决定后面写起来顺不顺。常见做法是用一个二维数组每个格子用一个结构体或者一个整数编码。这份源码里文档类里大概会定义这么几个成员// 扫雷Doc.h 中的核心数据成员 class C扫雷Doc : public CDocument { public: int m_nRows; // 行数 int m_nCols; // 列数 int m_nMines; // 总雷数 int m_nFlags; // 已插旗数 int m_nRemain; // 剩余未翻开格子数 BOOL m_bGameOver; // 游戏是否结束 // 每个格子的状态用二维数组存 // 低 4 位存周围雷数高位存标志位 BYTE** m_pBoard; };这里用BYTE**动态申请二维数组而不是固定大小的int board[16][16]是为了支持初级、中级、高级三种不同尺寸的棋盘。初级 9×9 共 10 雷中级 16×16 共 40 雷高级 16×30 共 99 雷尺寸变了数组也得跟着变所以用动态申请。每个BYTE里塞了两种信息低 4 位存这个格子周围有几颗雷0 到 8高位用几个 bit 分别表示「是不是雷」「有没有翻开」「有没有插旗」。这种位运算的存法在 C 语言写的小游戏里很常见省内存判断起来也快。比如判断一个格子是不是雷就是m_pBoard[r][c] MINE_BIT翻开就是m_pBoard[r][c] | OPEN_BIT。提示如果你打算二次开发建议先把这几个 bit 的含义在头文件里用宏定义清楚别用魔法数字不然过两天自己都看不懂。3. 核心逻辑拆解布雷、翻开、递归展开与胜负判定3.1 布雷为什么第一次点击不能踩雷扫雷有个约定俗成的规矩第一次点击绝对不能踩雷。如果一开始就把雷布好玩家第一下就点中雷那体验就太差了。所以正规做法是等玩家第一次点击之后再布雷并且要避开玩家点的那一格以及它周围八格。这份源码里布雷逻辑大概长这样// 在第一次点击后调用避开点击位置 (clickR, clickC) void C扫雷Doc::PlaceMines(int clickR, int clickC) { int placed 0; srand((unsigned)time(NULL)); // 用时间做随机种子 while (placed m_nMines) { int r rand() % m_nRows; int c rand() % m_nCols; // 跳过已经放了雷的格子 if (m_pBoard[r][c] MINE_BIT) continue; // 跳过第一次点击位置及其周围八格 if (abs(r - clickR) 1 abs(c - clickC) 1) continue; m_pBoard[r][c] | MINE_BIT; placed; } // 布雷完成后计算每个格子周围的雷数 CalcAdjacentMines(); }这段逻辑有几个点值得说。第一随机种子用time(NULL)保证每次开局雷的分布不一样。第二abs(r - clickR) 1 abs(c - clickC) 1这个判断把点击位置周围 3×3 的区域全部排除掉这样第一下点开至少能展开一片不会一点就死。第三布雷和计算周围雷数是分开的两步先全部布完再统一算避免边布边算导致统计不准。CalcAdjacentMines就是遍历每个非雷格子数它周围八格里有多少个雷把结果写进低 4 位。这个函数写起来就是两层循环加一个八方向偏移数组属于体力活但必须写对不然数字显示会错。3.2 翻开格子递归展开那一片空白扫雷最爽的瞬间就是点一下哗啦展开一大片。这个效果靠的是递归展开点到一个周围雷数为 0 的格子就把它周围八格也翻开如果周围八格里还有雷数为 0 的继续递归。// 翻开 (r, c)如果是空白格则递归展开 void C扫雷Doc::OpenCell(int r, int c) { // 越界、已翻开、已插旗都不处理 if (r 0 || r m_nRows || c 0 || c m_nCols) return; if (m_pBoard[r][c] OPEN_BIT) return; if (m_pBoard[r][c] FLAG_BIT) return; // 标记为已翻开 m_pBoard[r][c] | OPEN_BIT; m_nRemain--; // 如果踩到雷游戏结束 if (m_pBoard[r][c] MINE_BIT) { m_bGameOver TRUE; return; } // 如果周围没有雷递归展开周围八格 int adj m_pBoard[r][c] 0x0F; if (adj 0) { for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) OpenCell(r dr, c dc); } }递归的终止条件有三个越界、已经翻开、插了旗。这三个判断缺一不可少了任何一个都会出问题。少了越界判断会数组越界崩溃少了已翻开判断会无限递归栈溢出少了插旗判断会把玩家标记的旗子冲掉。m_nRemain是剩余未翻开格子数每翻开一个减一减到等于雷数的时候说明所有非雷格子都翻开了游戏胜利。这个判定放在视图类里每次翻开后检查一下。注意递归展开在高级棋盘16×30上最坏情况递归深度可能接近 480 层一般不会栈溢出但如果你把棋盘改得特别大就要考虑改成用栈模拟或者队列的迭代写法。3.3 右键插旗与胜负判定右键插旗的逻辑比左键简单就是切换格子的 FLAG_BIT同时更新剩余雷数显示。但有几个细节要注意已经翻开的格子不能插旗插旗数量不能超过总雷数有些版本允许超过但会显示负数体验不好。// 右键点击切换插旗状态 void C扫雷Doc::ToggleFlag(int r, int c) { if (m_pBoard[r][c] OPEN_BIT) // 已翻开不能插旗 return; if (m_pBoard[r][c] FLAG_BIT) { m_pBoard[r][c] ~FLAG_BIT; // 取消旗子 m_nFlags--; } else { if (m_nFlags m_nMines) // 旗子数不能超过雷数 return; m_pBoard[r][c] | FLAG_BIT; m_nFlags; } }胜负判定分两种。输的情况很简单翻开到雷就m_bGameOver TRUE然后把所有雷显示出来。赢的情况是m_nRemain m_nMines也就是剩下的未翻开格子全是雷这时候自动把所有雷插上旗弹个提示。状态栏上一般会显示两个数字剩余雷数总雷数减去已插旗数和用时。用时用一个SetTimer每秒更新一次游戏结束时KillTimer。4. 视图绘制与消息响应格子怎么画、鼠标怎么响应4.1 OnDraw 里怎么画棋盘MFC 的绘图全靠CDC类视图类的OnDraw(CDC* pDC)是绘制入口。画扫雷棋盘核心就是两层循环遍历每个格子根据格子状态决定画什么。void C扫雷View::OnDraw(CDC* pDC) { C扫雷Doc* pDoc GetDocument(); if (!pDoc || !pDoc-m_pBoard) return; int cellSize 20; // 每个格子 20 像素 for (int r 0; r pDoc-m_nRows; r) { for (int c 0; c pDoc-m_nCols; c) { int x c * cellSize; int y r * cellSize; CRect rect(x, y, x cellSize, y cellSize); BYTE cell pDoc-m_pBoard[r][c]; if (cell OPEN_BIT) { // 已翻开画灰色背景 pDC-FillSolidRect(rect, RGB(200, 200, 200)); pDC-Rectangle(rect); if (cell MINE_BIT) { // 是雷画个红圆表示 pDC-Ellipse(rect); } else { int adj cell 0x0F; if (adj 0) { // 画数字不同数字用不同颜色 CString str; str.Format(_T(%d), adj); pDC-SetTextColor(GetNumColor(adj)); pDC-DrawText(str, rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } } else { // 未翻开画凸起的按钮效果 pDC-FillSolidRect(rect, RGB(192, 192, 192)); pDC-Draw3dRect(rect, RGB(255, 255, 255), RGB(128, 128, 128)); if (cell FLAG_BIT) { // 插旗画个红旗 DrawFlag(pDC, rect); } } } } }这段代码里FillSolidRect填背景Draw3dRect画立体边框DrawText画数字。数字颜色用一个辅助函数GetNumColor根据数字返回不同颜色经典扫雷里 1 是蓝色、2 是绿色、3 是红色这个可以自己定。DrawFlag是画旗子的辅助函数可以用Polygon画个三角形加个杆也可以用现成的图标资源DrawIcon。源码里用的是哪种打开 View 类的 cpp 一看便知。4.2 鼠标消息怎么转成格子坐标视图类要响应WM_LBUTTONDOWN和WM_RBUTTONDOWN两个消息通过类向导添加OnLButtonDown和OnRButtonDown处理函数。消息映射宏长这样BEGIN_MESSAGE_MAP(C扫雷View, CView) ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_TIMER() END_MESSAGE_MAP()处理函数里关键是把鼠标的像素坐标转成棋盘的行列号void C扫雷View::OnLButtonDown(UINT nFlags, CPoint point) { C扫雷Doc* pDoc GetDocument(); if (pDoc-m_bGameOver) return; int cellSize 20; int c point.x / cellSize; int r point.y / cellSize; // 越界检查 if (r 0 || r pDoc-m_nRows || c 0 || c pDoc-m_nCols) return; // 第一次点击才布雷 if (!pDoc-m_bMinesPlaced) { pDoc-PlaceMines(r, c); pDoc-m_bMinesPlaced TRUE; SetTimer(1, 1000, NULL); // 启动计时器 } pDoc-OpenCell(r, c); // 检查胜负 if (pDoc-m_bGameOver || pDoc-m_nRemain pDoc-m_nMines) { KillTimer(1); pDoc-m_bGameOver TRUE; } Invalidate(); // 触发重绘 CView::OnLButtonDown(nFlags, point); }坐标转换就是简单的除法point.x / cellSize得到列号point.y / cellSize得到行号。这里有个容易翻车的地方如果棋盘不是从客户区左上角开始画的比如留了边距那除法之前要先减去边距。源码里如果没留边距就直接除。Invalidate()会触发OnDraw重绘整个棋盘重画一遍。对于 16×30 的棋盘480 个格子重画一次的开销很小不用做局部刷新优化。4.3 菜单与状态栏菜单响应通过ON_COMMAND宏绑定到框架类或者视图类的处理函数。比如「初级」菜单项 ID 是ID_GAME_BEGINNER处理函数里就把文档的棋盘重置成 9×9 10 雷。void CMainFrame::OnGameBeginner() { C扫雷Doc* pDoc (C扫雷Doc*)GetActiveDocument(); pDoc-NewGame(9, 9, 10); GetActiveView()-Invalidate(); }状态栏的创建在CMainFrame::OnCreate里用CStatusBar类设置几个窗格分别显示剩余雷数和用时。更新状态栏用SetPaneText在视图类里每次操作后调用。// 状态栏窗格定义 static UINT indicators[] { ID_SEPARATOR, // 第一个窗格显示提示信息 ID_INDICATOR_MINES, // 剩余雷数 ID_INDICATOR_TIME, // 用时 };ID_INDICATOR_MINES和ID_INDICATOR_TIME是在 resource.h 里自定义的 ID字符串资源里给个初始宽度。更新的时候CString str; str.Format(_T(雷: %d), pDoc-m_nMines - pDoc-m_nFlags); m_wndStatusBar.SetPaneText(1, str);5. 避坑与常见问题编译、运行、逻辑上的那些坑5.1 编译报错「无法打开包括文件 afxwin.h」现象用 VS 打开解决方案一编译就报找不到afxwin.h。原因MFC 不是 VS 默认安装的组件安装 VS 的时候如果没有勾选「MFC 和 ATL 支持」就会缺这个头文件。解决打开 Visual Studio Installer找到当前 VS 版本点「修改」在「单个组件」里搜索 MFC勾选「适用于最新 v143 生成工具的 C MFC」装完重启 VS 再编译。5.2 运行起来棋盘画不出来一片空白现象程序能跑窗口也出来了但客户区是白的没有格子。原因OnDraw里没有正确获取文档指针或者m_pBoard还是空指针。常见的是GetDocument()返回了 NULL或者棋盘数据还没初始化就调用了重绘。解决在OnDraw开头加个判空if (!pDoc || !pDoc-m_pBoard) return;。然后在OnInitialUpdate或者OnNewDocument里确保棋盘数据已经申请好。如果是 SDI 程序OnInitialUpdate会在窗口显示前调用一次适合做初始化。5.3 第一次点击就踩雷现象开局第一下点下去直接炸了。原因布雷逻辑没有排除第一次点击位置或者布雷时机不对在点击之前就把雷布好了。解决确认布雷是在OnLButtonDown里、调用OpenCell之前才执行的并且PlaceMines里排除了点击位置周围 3×3 区域。如果源码里是开局就布雷那就要改成延迟布雷加一个m_bMinesPlaced标志位。5.4 递归展开导致栈溢出崩溃现象点开一片空白区域程序直接闪退。原因递归展开没有正确终止或者棋盘数据有环导致无限递归。解决检查OpenCell里的三个终止条件是否都写了越界、已翻开、已插旗。特别是「已翻开」这个判断必须在递归调用之前就标记当前格子为已翻开否则递归回来又会重新处理。另外如果棋盘特别大可以把递归改成用std::stack或std::queue的迭代写法。5.5 状态栏数字不更新现象插旗、翻开格子状态栏上的剩余雷数一直不变。原因状态栏更新代码没调用或者调用了但窗格索引写错了。解决确认每次操作后都调用了SetPaneText并且索引对应的是雷数那个窗格。SetPaneText的第一个参数是窗格索引从 0 开始如果状态栏有三个窗格雷数一般是索引 1。另外SetPaneText之后可能需要调用m_wndStatusBar.UpdateWindow()强制刷新。6. 二次开发怎么改从换皮到加功能的几条路子拿到这份源码最直接的改法就是换皮。把OnDraw里的FillSolidRect颜色改一改格子大小从 20 改成 30数字颜色换一套就是一个新版本。如果想做得好看点可以把格子绘制换成贴图用CImage加载几张 png根据格子状态Draw上去比纯色块好看得多。再进一步可以加功能。比如加一个「提示」按钮点一下自动翻开一个安全格子加一个「撤销」功能记录每一步操作用栈存起来点撤销就回退。撤销这个功能要注意递归展开一次会翻开很多格子撤销的时候要一次性全部回退所以记录的时候要记录整批操作而不是单个格子。还有一个常见的改造方向是加难度自定义。现在只有初级、中级、高级三档可以加一个对话框让用户输入行数、列数、雷数然后动态生成棋盘。这个改动主要涉及文档类的NewGame函数让它接受参数以及视图类根据新的行列数重新计算窗口大小。// 自定义难度动态调整棋盘 void C扫雷Doc::NewGame(int rows, int cols, int mines) { // 释放旧棋盘 if (m_pBoard) { for (int i 0; i m_nRows; i) delete[] m_pBoard[i]; delete[] m_pBoard; } m_nRows rows; m_nCols cols; m_nMines mines; m_nFlags 0; m_nRemain rows * cols; m_bGameOver FALSE; m_bMinesPlaced FALSE; // 申请新棋盘全部清零 m_pBoard new BYTE*[rows]; for (int i 0; i rows; i) { m_pBoard[i] new BYTE[cols]; memset(m_pBoard[i], 0, cols * sizeof(BYTE)); } }这个NewGame里内存释放和申请要配对不然反复开新局会内存泄漏。m_nRemain初始值是总格子数布雷之后不会变每翻开一个减一减到等于雷数就是胜利。改完逻辑验证的时候有个小技巧把雷数设成 0然后点开一个格子应该哗啦全翻开直接胜利。再把雷数设成总格子数减一点开一个格子应该只翻开那一格周围全是雷。这两个极端情况跑通了基本逻辑就没大问题。我自己的习惯是每次改完棋盘相关的代码都先用 9×9 10 雷跑一遍完整流程开局、点开一片、插几个旗、踩一次雷、再重开一局。这套走下来没问题才敢往大了改。扫雷这项目看着简单但递归展开和位运算这两块稍不留神就翻车多跑几遍不丢人。希望这份源码能帮你把 MFC 这套老框架捡起来改出点自己的东西。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表