简介:基于MFC框架实现的经典扫雷游戏完整项目,压缩包内含可运行的exe与Visual C++工程源码,既适合初学Windows编程的开发者阅读,也可直接运行体验游戏。资源共54个文件,压缩后2.96MB,涵盖C++头文件与实现文件、MFC工程配置、位图图标与音效资源,以及调试生成物等,目录结构完整。已有146人学习下载。透过源码可重点理解MFC的CWnd窗口管理、消息映射处理鼠标点击、CDC绘制游戏面板、地雷生成与翻开逻辑等核心机制;同时提供DlgHero、DlgCustom、DlgNewRecord等对话框类,展示了菜单交互与自定义游戏设置的做法。对想用C++/MFC做小游戏练手的开发者而言,这是一份可运行、可修改、可调试的入门参考资料。
1. 经典MFC扫雷:为什么说它是Windows GUI编程的入门教科书
扫雷这种游戏,看着简单,真要自己动手用 MFC 实现一遍,才算把 Windows 消息驱动机制摸透了。MFC 实现扫雷这个项目(saolei.rar)是一个完整的 VC6 工程,主框架、棋盘绘制、自定义难度、排行榜全都有,适合刚啃完 C++ 语法想入门 Windows GUI 的人,也适合要快速复习 MFC 消息映射和 CDC 绘图的从业者。我始终觉得扫雷是所有 MFC 例子里性价比最高的一个:界面不复杂,但消息、绘制、资源、状态管理全都覆盖到了,看懂这个工程,再去做 MFC 状态栏显示、弹对话框这类日常需求就是顺水推舟的事。
2. 先看家底:saolei.rar 里的文件分别管什么
拿到压缩包第一件事,不是急着双击 exe,而是把文件清单过一遍。VC6 时代一个完整 MFC 项目会生成十几个后缀奇怪的文件,很多人一看到 .ncb、.clw、.opt 就懵了,其实它们各司其职,搞清楚之后再动手改代码会从容很多。
2.1 工程文件那一堆后缀:dsw、dsp、ncb 都是干嘛的
VC6 的工程体系分两层:最外层是工作空间(.dsw),里面装的是工程;每个工程对应一个 .dsp 文件,编译链接参数都写在里面。打开项目时双击 Mine.dsw 即可,VC6 会按 .dsp 里的配置加载源码。如果现在用的 IDE 是 VS 2008 以上版本,老 VC6 工程通常建议用“从现有代码创建新项目”的方式迁移,直接双击 .dsw 在新版 VS 里有兼容问题。
辅助文件里,.ncb 是类浏览器数据库,.clw 是 ClassWizard 的辅助索引,这两个属于“可再生文件”——删掉后用 IDE 重新打开会自动生成。.opt 保存的是工作区窗口布局和断点,.plg 是编译日志,不影响程序本身。常见做法是:老工程拷到新机器上第一次打开时,如果 IDE 弹出各种后缀格式不兼容的警告,直接把 .ncb、.clw、.opt 全部删掉再重新打开,干净利落。
2.2 源码文件与类模块的对应关系
项目正文里的文件清单可以分成三组:工程壳文件、业务源码、资源文件。把它们对应到 MFC 程序结构里,就一目了然了。
| 文件 | 模块角色 | 职责说明 |
|---|---|---|
| Mine.h / Mine.cpp | 主对话框类 | 游戏主窗口、菜单交互、整体状态管理 |
| MineWnd.h / MineWnd.cpp | 棋盘窗口类 | 从 CWnd 派生,负责格子绘制与鼠标点击 |
| MineDefs.h | 公共定义区 | 常量、枚举、面板尺寸等全局配置 |
| DlgCustom.h / DlgCustom.cpp | 自定义难度对话框 | 输入行数、列数、雷数 |
| DlgNewRecord.h / DlgNewRecord.cpp | 新纪录对话框 | 胜利后录入玩家名字 |
| DlgHero.h / DlgHero.cpp | 英雄榜对话框 | 展示历史最佳成绩 |
| Mine.rc / resource.h | 资源文件 | 菜单、图标、对话框模板、字符串 |
| StdAfx.h / StdAfx.cpp | 预编译头 | 集中引入 MFC 公共头文件,加速编译 |
这个结构是典型的三层拆分:对话框负责“壳”,棋盘窗口负责“画”,Mine.cpp 负责“算”。我拆过不少 MFC 老项目,扫雷这个分层算是很清晰的——界面和逻辑分离,改难度、改界面样式都不需要重写算法。Debug 目录是编译输出目录,里面有完整可执行文件,但我会建议你拿到源码后自己编译一次,比直接跑 exe 更能发现问题。
2.3 MineDefs.h:整个游戏的公共定义区
MineDefs.h 是整个工程的“黑匣子”入口。扫雷的面板尺寸、雷数上限、格子状态都会集中定义在一个公共头文件里,方便所有类引用。常见的定义方式是这样:
// MineDefs.h:游戏公共常量定义(常见实现) enum GameLevel { LEVEL_BEGINNER = 0, // 初级:9 x 9,10 颗雷 LEVEL_INTERMEDIATE, // 中级:16 x 16,40 颗雷 LEVEL_ADVANCED, // 高级:16 x 30,99 颗雷 LEVEL_CUSTOM // 自定义难度 }; #define ROW_MAX 30 // 行数上限,防止数组越界 #define COL_MAX 30 // 列数上限 #define MINE_MAX 99 // 雷数上限从这套定义能看出,作者在设计时就已经考虑到自定义难度边界:行列上限卡在 30,高级模式 16×30 正好落在边界内。雷数上限 99 也与高级难度对齐,避免自定义时雷数超过格子总数导致布局无解。这些边界参数是扫雷工程最容易翻车的地方,稍后避坑章节会专门展开。
3. 核心算法拆开看:布雷、翻开与洪水扩散的完整实现
扫雷的乐趣在于逻辑推理,但代码层面的核心其实只有四件事:存储面板、布雷、翻开格子、判断胜负。这一章我把每一项展开,代码是这类项目最常见的实现方式,拿到工程后可以对照源码找对应函数。
3.1 面板数据结构:二维数组加状态枚举
扫雷面板本质就是一个二维矩阵。每一格有两个关键属性:一个是格子里是什么(数字还是雷),一个是格子当前处于什么交互状态(未翻开、已翻开、插旗、问号)。所以常见的做法是用两个同样大小的二维数组,一个存雷和数字,一个存状态。
// MineDefs.h 中典型的格子状态定义(常见实现) enum CellState { CELL_HIDDEN = 0, // 未翻开 CELL_OPEN, // 已翻开 CELL_FLAG, // 玩家插旗标记 CELL_QUESTION // 问号标记,再点一次取消 }; // 面板数据 #define CELL_MINE -1 // 用 -1 表示该格是雷逻辑说明:格子数值用 -1 表示雷,0~8 表示周围雷数,与状态枚举分开存储。为什么分开?因为绘制时状态决定画旗子还是画数字,数值决定数字大小;判定时看数值是否为 -1,计算胜负时看状态是否为 CELL_OPEN。合在一起反而麻烦。参数说明:两套数组的索引保持一致,访问时用m_board[row][col]和m_state[row][col]同步读写,避免错位。
3.2 布雷逻辑:随机数和首点保护
布雷有两个硬性要求:雷数必须精准,位置必须随机。最直白的做法是循环随机取坐标,如果该位置还不是雷就放一颗,直到放满指定数量。传统扫雷还有一条隐形规则——第一次点击不能踩雷,否则游戏体验极差。我一般会在初始化布雷时把首点的周围 9 格全部排除在雷区外。
// 布雷实现:按难度在面板上随机放置指定数量的雷 void CMineWnd::InitMines(int rows, int cols, int mineCount, int safeRow, int safeCol) { int placed = 0; srand((unsigned)time(NULL)); while (placed < mineCount) { int r = rand() % rows; int c = rand() % cols; // 首点保护:第一次点击点及其周围 8 格不布雷 if (r == safeRow && c == safeCol) continue; if (abs(r - safeRow) <= 1 && abs(c - safeCol) <= 1) continue; if (m_board[r][c] != CELL_MINE) { m_board[r][c] = CELL_MINE; placed++; } } // 计算每个非雷格周围的雷数 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { if (m_board[i][j] != CELL_MINE) { m_board[i][j] = CountAdjacentMines(i, j); } } } }逻辑说明:safeRow和safeCol是第一次点击的坐标,跳过这格和它周围 8 格,能保证开局必开一片。循环里continue跳到下一次随机,直到布雷数量达标。最后的双重循环把每个非雷格周围的雷数统计出来,填到m_board[i][j]里,这样后面绘制时直接读数字即可,不需要每次点击都重算周围雷数。参数说明:自定义模式雷数太多、格子太少时,首点保护可能让while循环永远凑不满雷数,所以自定义难度的雷数必须做上限校验,这是很多教学工程忽略的边界。
3.3 翻开格子的洪水填充算法
扫雷里最爽的体验是点开一个大格后瞬间展开一片空白区域,这个效果由洪水填充(Flood Fill)算法实现。原理很简单:当你翻开的格子周围雷数为 0,就自动把周围 8 格也翻开;如果翻开的格子又是 0,就继续扩散,直到碰到带数字的格子停下来。递归写起来最直观,但遇到大面板没控制好深度会栈溢出,所以我这里用队列做广度优先搜索:
// 洪水填充:翻开格子并扩散到相邻的空格 void CMineWnd::FloodOpen(int row, int col) { std::queue<std::pair<int, int>> q; q.push(std::make_pair(row, col)); while (!q.empty()) { int r = q.front().first; int c = q.front().second; q.pop(); if (r < 0 || r >= m_rows || c < 0 || c >= m_cols) continue; if (m_state[r][c] == CELL_OPEN) continue; m_state[r][c] = CELL_OPEN; m_openCount++; 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) { q.push(std::make_pair(r + dr, c + dc)); } } } } } }逻辑说明:出队一个格子后先做边界检查和状态检查,已翻开的格子直接跳过,防止重复入队造成死循环。当格子数值为 0 时把周围 8 格全部入队,数值非 0 时只翻开自己就停止扩散——数字格子就是边界。m_openCount每次翻开都自增,为胜负判定提供依据。参数说明:队列用<utility>里的std::pair存坐标,如果你编译的老环境不支持 C++11,可以换成自定义结构体或两个队列分别存行列。这个算法的时间复杂度是 O(n),最坏情况是整张图 480 格全部入队一次,对 Windows 消息循环来说完全无压力。
3.4 胜负判定与剩余雷数修正
胜负判定在每次翻开动作之后执行。胜利条件很明确:非雷格全部被翻开,等价于m_openCount等于总格子数减雷数。失败条件更简单:翻开时发现m_board[r][c] == CELL_MINE。传统扫雷还有一个贴心细节——当玩家插旗标记了某格,即使那格不是雷,也要用插旗数去反推剩余雷数,让界面状态始终反映真实情况。
// 翻开操作入口:判断是否踩雷,否则走洪水填充并检查胜利 void CMineWnd::OnLeftClick(int row, int col) { if (m_gameOver || m_state[row][col] == CELL_OPEN) return; if (m_board[row][col] == CELL_MINE) { // 踩雷:翻开所有雷,游戏结束 RevealAllMines(); m_gameOver = true; return; } FloodOpen(row, col); // 胜利条件:翻开的非雷格数达到全场非雷总数 if (m_openCount == m_rows * m_cols - m_mineCount) { m_gameOver = true; PlayWinSound(); } }逻辑说明:入口处先拦截游戏已结束状态和重复点击,保证逻辑不会被连续鼠标事件搞乱。踩雷后调用RevealAllMines()把所有雷亮出来,这是给玩家的“死后复盘”机会。胜利判断放在洪水填充之后,因为一次展开可能一口气翻开几十格,m_openCount可能直接从胜利前跳到胜利后,所以必须在每次翻开后重新判定。参数说明:m_mineCount是当前难度雷数,初级 10、中级 40、高级 99,自定义则是用户输入值;判定公式把雷数排除在外,保证雷数变化不会影响胜利阈值。
4. 界面与交互:消息映射、CDC 绘制和双缓冲那一套
算法解决“游戏能不能跑”,界面解决“玩家能不能玩”。MFC 的界面代码集中在消息映射和绘图上,这也是很多人觉得 MFC 难上手的地方。其实拆开看就两类问题:怎么让鼠标点击进入函数,怎么把面板状态画到屏幕上。
4.1 消息映射:鼠标事件怎么进到游戏逻辑
MFC 消息映射宏把 Windows 消息与类成员函数绑定。扫雷涉及的核心消息有:左键点击翻开、右键点击插旗、双击翻开周围、菜单命令切换难度、定时器更新计时。看工程里的消息映射表,大致长这样:
// 消息映射表:把鼠标和菜单事件绑定到处理函数 BEGIN_MESSAGE_MAP(CMineWnd, CWnd) ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_LBUTTONDBLCLK() ON_WM_TIMER() ON_COMMAND(ID_GAME_BEGINNER, &CMineWnd::OnGameBeginner) ON_COMMAND(ID_GAME_INTERMEDIATE, &CMineWnd::OnGameIntermediate) ON_COMMAND(ID_GAME_ADVANCED, &CMineWnd::OnGameAdvanced) ON_COMMAND(ID_GAME_CUSTOM, &CMineWnd::OnGameCustom) END_MESSAGE_MAP()逻辑说明:ON_WM_LBUTTONDOWN()是把 WM_LBUTTONDOWN 消息映射到类的OnLButtonDown成员函数上,MFC 框架在窗口收到鼠标消息时自动查表调用对应函数。ON_COMMAND则处理菜单项的 0x111 命令消息,把“菜单点击”和“难度切换”绑定。参数说明:消息映射表的顺序有讲究,同一消息只能映射一次;如果两个处理函数都想响应同一消息,需要自己在函数里做二次分发,而不是在映射表里重复写。这对有编程基础的人来说可能觉得绕,但 MFC 就这么设计,习惯了反而觉得很直观。
左右键同时按下的经典操作,逻辑上走的是另一条路:在左键点击函数里判断GetKeyState(VK_RBUTTON)是否小于 0,如果是就先放开普通翻开逻辑,改为翻开周围 8 格。这个操作官方叫 Chord,新手往往忽略,但它是扫雷进阶操作的核心,源码里值得仔细看这部分处理。
4.2 格子绘制:CDC 画矩形和数字的颜色规则
MFC 绘图靠的是 CDC 类。每个格子本质是一个矩形,绘制逻辑分两态:未翻开时画凸起边框,已翻开时画平底色,数字用经典颜色规则显示。传统扫雷数字颜色是玩家肌肉记忆的一部分:1 蓝色、2 绿色、3 红色、4 深蓝、5 深红、6 青色、7 黑色、8 灰色。颜色查找表一般是独立的static数组。
// 绘制单个格子:按状态画凸起、按下或旗帜 void CMineWnd::DrawCell(CDC* pDC, int row, int col) { CRect rc = GetCellRect(row, col); if (m_state[row][col] == CELL_OPEN) { pDC->FillSolidRect(&rc, RGB(220, 220, 220)); // 已翻开底色 if (m_board[row][col] >= 1) { CString str; str.Format(_T("%d"), m_board[row][col]); pDC->SetTextColor(GetNumberColor(m_board[row][col])); pDC->SetBkMode(TRANSPARENT); // 背景透明,否则盖住底色 pDC->DrawText(str, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } else { pDC->FillSolidRect(&rc, RGB(180, 180, 180)); // 未翻开底色 if (m_state[row][col] == CELL_FLAG) { // 简化旗子画法:一个红色小矩形 + 黑色斜线 pDC->FillSolidRect(rc.left + 4, rc.top + 4, rc.Width() - 8, rc.Height() - 8, RGB(255, 0, 0)); } } }逻辑说明:GetCellRect把行列坐标换算成像素矩形,客户区尺寸变化时只要在OnSize里更新格子宽高,绘制函数就不用改。数字绘制有两点容易翻车:一是忘了SetBkMode(TRANSPARENT),文字背景会盖掉格子底色;二是SetTextColor只改变文字颜色,数字颜色规则必须自己维护。参数说明:DrawCell被逐格调用,绘制效率取决于被调用的次数;整盘重绘时 480 格逐格 DrawText,性能压力不大,但如果每一格都重新创建画笔和字体就会明显卡顿,所以画笔和字体最好在OnCreate里一次性创建好,绘制时直接SelectObject。
4.3 双缓冲绘制:内存 DC 消除闪烁
直接在一张可见窗口 DC 上逐格画矩形和文字,会导致边界闪烁,这是很多 MFC 游戏的通病。因为每画一格都直接写显存,频繁重绘时用户能看到明显的“闪白”过程。标准解法是双缓冲:先在内存里画完整张面板,然后一次性把整幅图像拷到屏幕。
// 双缓冲 OnPaint:先画到内存 DC,再整体 BitBlt 到窗口 void CMineWnd::OnPaint() { CPaintDC dc(this); // 窗口 DC CDC memDC; // 内存 DC memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, m_clientWidth, m_clientHeight); CBitmap* pOld = memDC.SelectObject(&bmp); for (int i = 0; i < m_rows; i++) { for (int j = 0; j < m_cols; j++) { DrawCell(&memDC, i, j); // 全部画到内存 } } dc.BitBlt(0, 0, m_clientWidth, m_clientHeight, &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); // 恢复原对象,释放前解锁 }逻辑说明:CreateCompatibleDC创建一块与窗口兼容的内存画布,但刚创建时它自带的是一个 1×1 单色位图,只有CreateCompatibleBitmap创建同尺寸真彩位图并SelectObject选进去之后,内存 DC 才是真正的画布。如果不做这一步,画上去的内容尺寸不对、颜色异常,这是 MFC 新手最容易遇到的“画出来全是黑的/花的”的玄学问题。最后BitBlt把整幅图像一次性拷贝到窗口,整个过程屏幕只刷新一次,闪烁自然消失。参数说明:SRCCOPY是拷贝方式,意思是直接覆盖目标区域,不透色不透明处理。注意窗口尺寸变化时m_clientWidth/m_clientHeight要跟着更新,否则 BitBlt 区域和窗口实际尺寸不匹配,会出现拖影。
4.4 对话框参数传递:自定义难度和排行榜
扫雷的自定义难度对话框 DlgCustom、胜利后的新纪录对话框 DlgNewRecord、高手榜 DlgHero,走的都是同一条 MFC 交互路径:DoModal模式对话框 + 控件值读取。经常能在论坛看到有人问 MFC 状态栏怎么显示、在现有 VS MFC 工程上增加按钮弹出对话框并显示实时数据图表,其实核心就是这套——一个对话框模板配一个 CDialog 派生类,调用DoModal弹出来,结束后从控件上取数据。
// 自定义难度:弹出对话框并读取玩家输入(常见实现) void CMineWnd::OnGameCustom() { DlgCustom dlg; dlg.m_rows = m_rows; // 传入当前值,方便玩家在旧值上修改 dlg.m_cols = m_cols; dlg.m_mines = m_mineCount; if (dlg.DoModal() == IDOK) { m_rows = dlg.m_rows; m_cols = dlg.m_cols; m_mineCount = dlg.m_mines; InitGame(); } }逻辑说明:DlgCustom 里声明三个成员变量(m_rows、m_cols、m_mines),并给对应编辑框控件绑定 DDX 变量,DoModal返回后 MFC 已经自动把编辑框里的字符串转成整型填回成员变量。调用方只需要判断返回值是IDOK还是IDCANCEL来决定是否应用配置。参数说明:向对话框传当前值作为初值,是常见的友好交互做法,否则每回打开都是空白的,玩家容易改错。InitGame()负责按新模式重新布雷、重置状态和计时,自定义模式下行列和雷数都变了,之前的面板数组要么动态分配、要么按上限 30×30 静态声明,后者更省事——这也解释了为什么 MineDefs.h 里会有ROW_MAX/COL_MAX两个上限常量。
5. MFC扫雷避坑指南:编译、重绘与资源文件的常见问题
这章全是血泪经验。扫雷工程本身不大,但想在现在的主流 Windows 版本上把老 VC6 工程编译跑起来,会遇到一批时代留下的坑。我把最常见的问题按“现象 → 原因 → 解决”列出来,每一条都值得在你自己的复现流程里提前规避。
5.1 现象:双击 Mine.dsw 编译报错,提示找不到 afxwin.h 或无法打开 MFC 类库
原因:VC6 的默认安装路径里 MFC 目录不在系统 Include 搜索链上,或者你的机器装的是 VS 高版本而不是 VC6,MFC 类库目录结构不一样。另一个常见情况是把工程拷到新电脑后,VC6 的安装路径变了,但工程里保存的绝对路径还指向旧位置。
解决:先在 VC6 的 Tools → Options → Directories 里确认 Include 和 Lib 分别指向...\VC98\MFC\INCLUDE、...\VC98\MFC\LIB;如果是用新版 VS 打开老工程,不要直接转项目,用“从现有代码创建新项目”重新组织工程,再把 .cpp/.h 全部加入进来,编译通过率高得多。
5.2 现象:ClassWizard 打开后看不到任何类,或者一打开就崩溃
原因:.clw 和 .ncb 是旧编译器生成的索引文件,工程被移动、重命名或增删过源码后,索引和实际文件对不上,ClassWizard 读索引时直接翻车。这个文件对程序本身没有影响,但 IDE 功能会废掉一半。
解决:把工程目录下的 .clw、.ncb、.opt 全部删干净,重新打开 .dsw,IDE 会自动重建索引。如果重建后 ClassWizard 还是空的,检查所有头文件是否都还在工程目录里,特别是 StdAfx.h 有没有被误删或改名。
5.3 现象:点击格子时整个面板闪烁,或者地图边缘出现残影
原因:没有双缓冲,或者重绘时用了Invalidate()整窗刷新。另外,如果只在OnLButtonDown里调了InvalidateRect(GetCellRect(row, col))但没调UpdateWindow(),WM_PAINT 消息会在消息队列里排队延后处理,玩家看到的界面滞后于点击。
解决:OnPaint 里走完整双缓冲流程(见 4.3);局部刷新时用InvalidateRect指定目标区域后再UpdateWindow()强制立即重绘,这样点击的格子会马上响应,不至于出现“点了没反应、等其他消息处理完才一起刷”的滞涩感。
5.4 现象:左右键同时按下时,周围格子没有一起翻开
原因:扫雷的 Chord 操作需要同时检测左右键状态,但默认情况下 MFC 的OnLButtonDown只能拿到左键消息,右键按下前、松开后的事件在时间上不保证与左键重叠。如果你只在WM_LBUTTONDOWN里处理逻辑,自然感知不到右键当前是否被按住。
解决:在左键处理函数开头用GetKeyState(VK_RBUTTON) < 0判断右键是否处于按下状态,如果按下了就进入 Chord 分支,翻开当前格子周围所有已插旗格对应的相邻区域;同时要处理右键单独点击的插旗逻辑,两者路径分离,避免重复触发。调试时可以先按住右键不放,再点左键,观察输出是否符合预期。
5.5 现象:程序编译通过,但运行时报缺少 MFC42D.dll 或资源加载失败,游戏界面一片空白
原因:Debug 版本默认动态链接 MFC 库,调试运行时依赖 VCDebug 环境里的 MFC42D.dll,把可执行文件单独拷走就缺库;资源加载失败则是 res 文件夹没有和 Mine.rc 放在同一目录下,或者资源文件被单独拷走后没有连带复制。
解决:想分发单文件,就把工程配置改成静态链接 MFC(Project → Settings → General → Microsoft Foundation Classes 选择 Use MFC in a Static Library),重新编译 Release;资源问题则检查 Mine.rc 里的#include "res\\xxx.ico"路径,确认 res 目录和 .rc 文件相对位置正确,编译后Mine.exe所在目录要能看到对应的位图和图标文件。
6. 进阶改造:计时器、难度扩展与固定雷阵验证法
扫雷的计时逻辑很多人以为是读系统时间,其实 MFC 工程里最常见的是SetTimer加消息映射。在游戏初始化后调用SetTimer(1, 1000, NULL),每 1000 毫秒触发一次WM_TIMER消息,在OnTimer里递增秒数并更新标题栏或状态栏。游戏结束或重新开始时用KillTimer(1)清掉定时器,避免消息堆积。用状态栏显示剩余时间时,就是经常被问到的“MFC 状态栏怎么显示”场景——拿着m_wndStatusBar.SetPaneText直接刷新即可,扫雷正好是一个天然的时间刷新状态栏的例子。
// 计时器初始化与消息处理:每秒更新一次计时显示 void CMineWnd::StartTimer() { SetTimer(1, 1000, NULL); // 1 秒触发一次,NULL 表示走消息映射默认处理 m_elapsed = 0; } void CMineWnd::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && !m_gameOver) { m_elapsed++; CString text; text.Format(_T("时间:%d 秒"), m_elapsed); SetWindowText(text); // 也可以改用状态栏显示 } CWnd::OnTimer(nIDEvent); }难度扩展是扫雷另一个很自然的改造点。经典三档是初级 9×9×10、中级 16×16×40、高级 16×30×99,但自定义模式理论上能覆盖 9×9 到 30×30 的任意尺寸。改难度最稳妥的方式是:先在 MineDefs.h 里把三个难度枚举和默认参数写好,再保证InitGame里所有数组访问都走m_rows/m_cols而不是写死数字。很多老工程喜欢在绘制时写死格子尺寸,这会导致切换难度后棋盘绘制错位,改成动态计算格子宽高是必须的动作。
验证扫雷逻辑最有效的方法,不是狂点鼠标碰运气,而是用固定雷阵做回归测试。把随机布雷换成手工指定的雷阵,让每局开局雷的位置完全一样,这样才能验证特定布局下的翻开、插旗、Chord 和胜负判定是否全部正确:
// 固定雷阵验证:替换随机布雷,手工指定雷的位置 int testMines[][2] = { {0, 0}, {0, 1}, {3, 3}, {3, 4}, {7, 15} }; void CMineWnd::InitFixedMines() { memset(m_board, 0, sizeof(m_board)); for (int i = 0; i < 5; i++) { m_board[testMines[i][0]][testMines[i][1]] = CELL_MINE; } for (int i = 0; i < m_rows; i++) { for (int j = 0; j < m_cols; j++) { if (m_board[i][j] != CELL_MINE) { m_board[i][j] = CountAdjacentMines(i, j); } } } }固定雷阵的好处是:每次改动代码后,你都知道哪一格翻开应该出现什么数字,可以准确判断逻辑是否回归。断言失败时一目了然,不用靠“手感”猜问题在哪。测试顺序我习惯这样走:先单格翻开验证数字、再验证 0 格洪水填充展开范围、接着验证插旗后 Chord 行为、最后验证胜负判定的边界条件,一套走完再切回随机布雷做随机性冒烟测试。从那以后我每次动扫雷的代码,都强制先跑一遍固定雷阵,再谈其他功能,这个习惯救过我很多次翻车现场。希望帮到你。
本文还有配套的精品资源,点击获取