☰
VC++扫雷源程序拆解:MFC绘图、递归展开与二次开发
2026/11/6 5:55:12 网站建设 项目流程

简介:《VC++游戏源程序扫雷》是一份面向 C++ 初学者、课程设计学生和 Windows 桌面开发爱好者的完整项目源码,围绕经典扫雷的雷区初始化、翻格判断、插旗标记和数字提示等核心逻辑展开,可用于源码研读、课程实践与编程练习。压缩包为 rar 格式,共 54 个文件,约 1.95MB,内含 cpp 与 h 源文件、rc 资源脚本、bmp/ico/wav 界面素材、dsp/dsw 工程文件,以及 exe、pdb 等编译调试产物,便于直接查看工程组织与运行示例。已有 95 人学习下载。读者可据此梳理二维数组遍历、周围八格雷数统计、递归展开、鼠标事件响应和对话框控件绑定等实现思路,并结合错误处理与调试信息理解程序流程,尤其适合借此理解 MFC 消息映射、资源组织与事件驱动编程,在示例基础上修改界面或规则以巩固面向对象封装能力。

1. 从一份 VC++ 扫雷源程序说起:为什么它比“Hello World”更适合练手

前阵子有人丢给我一个压缩包,说里面是一份 VC++ 游戏源程序——扫雷,编译一开满屏红色报错,问是不是代码坏了。我打开看了一眼,代码没坏,是工程本身有点年头了:预编译头还叫 stdafx.h,字符集是多字节,平台工具集停在很老的版本上。改了三处配置,编译通过,游戏正常跑起来。

扫雷这种体量的源码,价值不在于游戏本身多好玩,而在于它把 MFC 的窗口生命周期、消息映射、GDI 绘制、定时器,还有递归展开这套算法,全都塞进了一个不到两千行的工程里。新手拿它练手,能看到一条完整的“界面 — 数据 — 算法”链路;熟手拿它改,能顺手加上难度配置、存档复盘、自定义图谱这些原版没有的东西。下面按“先跑起来、再拆逻辑、最后动手改”的顺序,把这份源程序拆开讲。

2. 工程结构与技术栈:一份 VC++ 扫雷源程序由哪些文件撑起来

2.1 目录结构:工程文件、资源文件与源码三块

老 MFC 工程的目录结构几乎是模板化的,扫雷这种单窗口小游戏更是标准。拆包后第一件事不是看代码,是先把文件按职责归堆,搞清楚哪块能动、哪块最好别动。

文件 / 目录职责改动频率
Mine.sln/Mine.vcxproj(老工程是.dsp/.dsw)解决方案与工程描述,记录工具集、字符集、链接依赖只在换编译环境时动
MineApp.h/MineApp.cpp应用类,继承CWinApp,负责程序启动与退出几乎不动
MineDlg.h/MineDlg.cpp主对话框,所有鼠标消息、定时器、按钮响应的落点改得最多
MineBoard.h/MineBoard.cpp棋盘数据结构与核心算法,跟界面完全解耦改逻辑时动
Mine.rc/resource.h对话框布局、图标、菜单、控件 ID 定义加控件时动
stdafx.h(或pch.h)预编译头,常见是afxwin.h、afxext.h的集合基本不动
res/图标、位图资源换皮时动

这份源码值得肯定的地方,是把棋盘逻辑单独抽成了MineBoard类,而不是把二维数组、布雷、翻开全塞进MineDlg::OnLButtonDown里。这个分层看着不起眼,但决定了后面好不好改——想加难度预设、加存档、加自定义地图,只要MineBoard的接口稳定,界面层不用大动。

反过来讲,很多网上下载的扫雷源码恰恰是反面教材:一个OnLButtonDown写了两百多行,rand()播种、数组遍历、CDC绘图混在一起,m_nMineCount这种成员变量散落在对话框类里。这种代码能跑,但你想把窗口从 9×9 改成 30×16,就得全局搜索替换。看源码时先扫一眼.cpp的OnLButtonDown长度,基本就能判断这份代码的质量。

目录里还有一个容易被忽略的东西:.rc文件。MFC 的资源脚本是纯文本,控件 ID 定义在resource.h里,形如#define IDC_BTN_SMILE 1001。后面加“重开一局”按钮、“难度下拉框”都要在这里占一个 ID,且不能和已有 ID 冲突,否则对话框加载时会直接断言失败。这是改造阶段的第一道门槛。

2.2 程序形态选择:对话框程序比文档视图更适合做扫雷

MFC 建工程时有三条路:单文档、多文档、基于对话框。扫雷这种单窗口、固定布局、没有文件打开保存概念的程序,绝大多数源码走的是对话框路线。原因是省事——所有控件在资源编辑器里拖一拖就有,消息处理靠类向导生成,不用管CDocument和CView之间那套序列化链路。

程序入口通常在MineApp.cpp里,长这样:

// MineApp.cpp —— 应用入口,决定了整个程序是「对话框形态」 BOOL CMineApp::InitInstance() { // 老工程常见:初始化公共控件,不加的话某些控件显示不出来 INITCOMMONCONTROLSEX icc; icc.dwSize = sizeof(icc); icc.dwICC = ICC_WIN95_CLASSES; InitCommonControlsEx(&icc); CWinApp::InitInstance(); CMineDlg dlg; // 主对话框对象,栈上创建 m_pMainWnd = &dlg; // 挂到主窗口指针,MFC 框架靠它分发关闭消息 INT_PTR nResponse = dlg.DoModal(); // 模态运行,整个游戏生命周期就这一次 // 返回 FALSE 表示不进入消息循环,对话框关闭即退出程序 return FALSE; }

逻辑说明:DoModal()会阻塞在这里,直到对话框被销毁才返回,返回值是IDOK或IDCANCEL。return FALSE是关键——如果返回TRUE,MFC 还会再起一轮Run()消息泵,程序会在关窗后残留一个空进程,得从任务管理器里结束。这个坑在不少老源码里都存在,现象就是“关掉窗口了,任务管理器里还有个进程挂着”。

参数说明:INITCOMMONCONTROLSEX的dwICC指定要初始化哪批公共控件,ICC_WIN95_CLASSES覆盖了列表、树、进度条等常用控件。扫雷本身不一定用到,但如果源码里加了CListCtrl做排行榜,漏了这段就会在Create时崩。

改成文档视图形态也行,但没必要。CView那套架构的价值在于文档模型和多种视图的同步,扫雷只有一个视图、一个内存里的棋盘,用CDocument承载棋盘反而多一层转发。二次开发时如果你非要上CScrollView支持大棋盘滚动,再考虑换形态——不过更简单的做法是对话框里嵌一个CStatic当画布,自己处理滚动条消息。

2.3 编译环境与配置:三处不改就编译不过

拿到老工程直接 F5 大概率是编译不过的,报错集中在三处:预编译头、字符集、平台工具集。这三处属于配置问题,不是代码问题,改完就能过。

第一处是预编译头。老工程里每个.cpp第一行都是#include "stdafx.h",新 IDE 默认用pch.h,找不到就报 C1083“无法打开包括文件”。有两种解法:把工程属性里“C/C++ → 预编译头”改成“不使用预编译头”,最省事,代价是编译慢一点;或者保留预编译头,把文件名统一改掉。我一般选前者,扫雷这种规模几十个文件,全量编译也就几秒钟,没必要为预编译头折腾。

第二处是字符集。工程属性“高级 → 字符集”有三个选项:使用 Unicode 字符集、使用多字节字符集、未设置。老源码十有八九是按多字节写的,里面直接strcpy、sprintf、char*一把梭。切到 Unicode 后,CString变成宽字符,所有%s都得改成%S或者%ls,strcpy要么换wcscpy要么换_tcscpy。血泪经验是:先按原工程的多字节字符集编译通过,跑起来看看效果,再决定要不要花时间做 Unicode 迁移,别一上手就改字符集,那样报错会从 3 个变成 300 个。

第三处是平台工具集。工程属性“常规 → 平台工具集”如果显示的是一个本机没装的版本,直接改成当前可用的版本号即可。改完可能还会跳 C4996 警告,提示strcpy、sprintf、fopen这些函数不安全,这只是警告不是错误,实在嫌吵可以在文件顶部加#define _CRT_SECURE_NO_WARNINGS,但更好的做法是把它们换成_s后缀的安全版本。

命令行编译比开 IDE 更容易定位环境问题:

# 在工程目录下执行,Release 配置 + x86 平台,输出会直接告诉你缺哪个组件 msbuild Mine.sln /p:Configuration=Release /p:Platform=x86 /m

参数说明:/p:Configuration指定配置,/p:Platform指定平台,/m开多核并行编译。如果报“找不到 MFC 头文件 afxwin.h”,说明当前 IDE 安装时没勾选 MFC 组件,得回去补装“适用于最新生成工具的 C++ MFC”。这个报错很典型,看到就知道是环境缺件,不用去翻代码。

3. 核心逻辑拆解:棋盘数据、布雷算法与递归展开

3.1 单元格与棋盘:一个二维数组撑起整张地图

扫雷的数据模型非常简单:每个格子只需要知道三件事——是不是雷、周围有几颗雷、当前显示状态。三个字段就够,多了都是冗余。

// MineBoard.h —— 单元格与棋盘的数据定义 enum CellState { CELL_CLOSED = 0, // 未翻开,界面显示为凸起的方块 CELL_OPENED = 1, // 已翻开,显示数字或空白 CELL_FLAG = 2, // 已插旗,左键点不开 CELL_ASK = 3 // 问号态,右键三态循环时用得到 }; struct MineCell { bool bIsMine; // 是否是雷,布雷阶段一次性写死 int nAdjacent; // 周围 8 格的雷数,取值 0~8 CellState state; // 当前状态,界面上画什么全靠它 }; class CMineBoard { public: void Init(int nRows, int nCols, int nMineCount); // 按难度初始化 bool OpenCell(int r, int c); // 左键翻开 void ToggleFlag(int r, int c); // 右键插旗 / 取消 bool IsWin() const; // 是否已清完所有非雷格 const MineCell& At(int r, int c) const { return m_grid[r][c]; } private: void GenerateMines(int nSafeR, int nSafeC); // 布雷,带首点保护 void CalcAdjacent(); // 统一算一遍周围雷数 void FloodFill(int r, int c); // 空白区域递归展开 std::vector<std::vector<MineCell> > m_grid; // 二维表,先按行再按列 int m_nRows, m_nCols, m_nMines; int m_nOpened; // 已翻开格数,用于胜负判定 bool m_bGameOver; // 踩雷后置位,屏蔽后续操作 };

逻辑说明:用vector<vector<>>而不是裸的new MineCell*[rows],好处是析构不用管,拷贝也安全。老源码里常见m_pGrid = new MineCell*[m_nRows]然后循环new,OnDestroy里再循环delete[],少写一行就漏内存。MFC 的 Debug 版退出时会在输出窗口打印泄漏块号和字节数,看到Detected memory leaks!基本就是这里没释放干净。

参数说明:m_nOpened这个计数器是胜负判定的核心——只要已翻开格数等于总格数 - 雷数,就赢了,不用每次遍历全表去查。m_bGameOver在踩雷后置true,所有对外接口第一行都判它,直接return,避免玩家在爆炸动画播放期间继续点击导致状态错乱。

nAdjacent建议在布雷完成后统一算一遍,而不是每次绘图时实时统计。前者是 O(rows×cols×8),一次性开销;后者在重绘频繁时会被反复计算,画面一卡就露馅。这是性能上最容易被忽视的一处,尤其在 30×16 这种中等棋盘上,实时计算的浪费很明显。

3.2 首点安全的布雷算法:洗牌取前 N 个比循环 rand 靠谱

扫雷最基础的体验要求是:第一次点击不能踩雷。很多老源码的做法是srand(time(NULL))之后循环rand() % rows、rand() % cols往棋盘上撒雷,如果位置上已经有雷就跳过,撒够nMines颗为止。这个写法有两个硬伤:一是当雷密度接近 1 时会退化成近乎死循环;二是没法保证首点安全,玩家开局点一下,直接“BOOM”。

更稳的做法是把所有可以放雷的格子收集成一个列表,洗牌,取前 N 个。首点安全的实现就更简单了:把首点及其相邻 8 格从候选列表里剔掉即可。

// MineBoard.cpp —— 首点安全布雷,Fisher-Yates 部分洗牌 void CMineBoard::GenerateMines(int nSafeR, int nSafeC) { std::vector<std::pair<int, int> > candidates; // 第一步:收集所有合法位置,排除首点及其 8 邻域 for (int r = 0; r < m_nRows; ++r) { for (int c = 0; c < m_nCols; ++c) { bool bSafeZone = (abs(r - nSafeR) <= 1 && abs(c - nSafeC) <= 1); if (!bSafeZone) candidates.push_back(std::make_pair(r, c)); } } // 第二步:只需要前 nMines 个位置,做部分洗牌即可 for (int i = 0; i < m_nMines && i < (int)candidates.size(); ++i) { int j = i + rand() % (candidates.size() - i); // 在 [i, size) 中随机取一个 std::swap(candidates[i], candidates[j]); // 换到前面,保证不重复 m_grid[candidates[i].first][candidates[i].second].bIsMine = true; } CalcAdjacent(); // 布完雷统一计算周围雷数 }

逻辑说明:candidates里装的是“可以放雷”的坐标对。循环只跑nMines次,每次都从尚未确定的区间[i, size)里随机抽一个换到位置i,抽完的位置不会再被抽到,天然不重复。这比“随机撒 + 判重 + 重试”的做法稳定得多,也不会出现雷数不足的边界情况。

参数说明:nSafeR/nSafeC是玩家第一次点击的坐标。用abs(...) <= 1把周围一圈都排除掉,好处是首点必定展开成一片空白,体验最好;如果源码只想排除首点本身,把条件改成r != nSafeR || c != nSafeC即可,两种都算常见做法。注意abs对int够用,但如果是用size_t做下标要小心类型提升。

还有一个细节容易被忽略:rand()的种子。老源码常常在InitInstance里srand(time(NULL))一次,然后从不再调用。这意味着同一次运行里重开多局,雷的分布永远是同一套——只是首点不同导致看起来不一样。改法是每次调用Init时都重新srand((unsigned)time(NULL) ^ GetCurrentProcessId()),或者干脆换成<random>里的std::mt19937,后者分布质量更好,也不用担心种子碰撞。

3.3 左键翻开与递归展开:FloodFill 的边界条件最容易翻车

点击一个格子,如果是雷,游戏结束;如果是数字格,翻开并显示一个 0~8 的数字;如果是空白格(nAdjacent == 0),就要向外扩散,把连成一片的空白区域和它们边缘的数字格一起翻开。这套扩散就是扫雷里唯一的递归逻辑。

// MineBoard.cpp —— 翻开一格,空白区域自动扩散 bool CMineBoard::OpenCell(int r, int c) { if (m_bGameOver) return false; // 已经结束,直接忽略 if (r < 0 || r >= m_nRows || c < 0 || c >= m_nCols) return false; // 越界保护 if (m_grid[r][c].state != CELL_CLOSED) return false; // 插旗或已翻开的格子点不开 if (m_grid[r][c].bIsMine) { m_bGameOver = true; // 踩雷,交给界面播动画 m_grid[r][c].state = CELL_OPENED; return false; } FloodFill(r, c); // 非雷格,走扩散 return true; } void CMineBoard::FloodFill(int r, int c) { // 边界检查必须放在取 cell 引用之前,否则大棋盘上直接越界读 if (r < 0 || r >= m_nRows || c < 0 || c >= m_nCols) return; MineCell& cell = m_grid[r][c]; if (cell.state != CELL_CLOSED || cell.bIsMine) return; // 只处理未翻开的非雷格 cell.state = CELL_OPENED; ++m_nOpened; if (cell.nAdjacent != 0) return; // 数字格是扩散的边界,到此为止 // 空白格向 8 个方向继续扩散 for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) if (dr != 0 || dc != 0) FloodFill(r + dr, c + dc); }

逻辑说明:这个递归能收敛,靠的是“先改状态再递归”这个顺序。每次进入FloodFill,先把state从CELL_CLOSED改成CELL_OPENED,递归回来再遇到这格时,state != CELL_CLOSED就return了,不会重复处理,也不会无限循环。如果有人把状态改动挪到递归之后,代码看起来差不多,但会在大片空白区域里指数级重复访问,现象就是点一下卡三秒。

参数说明:r/c是当前处理格子的行列下标。dr/dc从 -1 到 1 双重循环,配合dr != 0 || dc != 0排除自身,正好覆盖 8 个邻居。OpenCell的返回值设计成bool,是为了让界面层区分“这次点击是否有效”——无效点击(点已翻开格、点插旗格)不该触发重绘。

递归的替代方案是用显式栈或队列做 BFS。9×9 的棋盘递归深度最多 81 层,不会爆栈;但如果你把难度开到 50×50 以上,虽然深度理论上是格子数,实际递归树的分支宽度会带来大量函数调用开销。我一般会在大棋盘版本里改成std::queue存坐标,出队时处理、入队时判重,逻辑完全一样,但内存占用可控,还能顺便做动画式逐格展开。这是二次开发时一个很自然的改造点。

还有一个非常隐蔽的坑:动作顺序。炸雷后,m_bGameOver置位没错,但如果界面层的重绘是在OpenCell返回后才统一做,那么爆炸瞬间棋盘上所有雷应该一起显示出来,而不是只显示踩中的那一颗。这需要棋盘类提供一个RevealAllMines()接口,把所有bIsMine的格子状态刷成CELL_OPENED,同时把插错位置的旗标成红叉。原版扫雷正是这么做的。

4. 绘制与交互:GDI 画格子、鼠标消息与计时器

4.1 双缓冲绘制:一次 BitBlt 解决闪烁

扫雷的界面全是自己画的:凸起的方块、凹下去的翻开格、彩色数字、小旗子、地雷。MFC 里对应的是WM_PAINT消息和CDC绘图。如果直接在OnPaint拿CPaintDC一格格画,鼠标快速点几下,画面就会白闪——因为系统先用背景色擦了一遍,再画内容,两次绘制的间隙被眼睛捕捉到了。

解决办法是双缓冲:先在一块内存位图上画完整幅画面,最后一次性拷到屏幕。

// MineDlg.cpp —— 双缓冲绘制,缓存位图在 OnSize 时创建 void CMineDlg::OnPaint() { CPaintDC dc(this); if (!m_pBackBmp) return; // 位图还没准备好,直接返回 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap* pOld = memDC.SelectObject(m_pBackBmp); // 背景先铺一遍,避免旧帧残留 memDC.FillSolidRect(0, 0, m_nClientW, m_nClientH, RGB(192, 192, 192)); for (int r = 0; r < m_nRows; ++r) for (int c = 0; c < m_nCols; ++c) DrawCell(&memDC, r, c); // 每格按状态自己绘制 dc.BitBlt(0, 0, m_nClientW, m_nClientH, &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); // 恢复,否则位图删不掉 } // 位图只在这里重建,OnPaint 里不再 CreateCompatibleBitmap void CMineDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); m_nClientW = cx; m_nClientH = cy; if (m_pBackBmp) { delete m_pBackBmp; m_pBackBmp = NULL; } if (cx > 0 && cy > 0) { CClientDC dc(this); m_pBackBmp = new CBitmap(); m_pBackBmp->CreateCompatibleBitmap(&dc, cx, cy); } }

逻辑说明:OnPaint里只做三件事——建内存 DC、把缓存位图选进去、在它上面画完再用BitBlt一次贴出去。关键点是位图不能每次重绘都CreateCompatibleBitmap,那是重量级 GDI 对象,频繁创建会导致句柄表溢出,现象是程序跑一会儿就卡死或者画面变黑。所以创建放在OnSize,尺寸变了才重建。

参数说明:CreateCompatibleDC创建的内存 DC 初始只有 1×1 像素,必须选入一张位图才有实际画布。SRCCOPY表示直接拷贝,不做光栅运算。SelectObject(pOld)这行不能少,选入的位图在被替换出来之前,delete会失败并泄漏 GDI 句柄,Debug 下句柄数一直涨就是这里漏的。

绘制单格的DrawCell里,通常分四类画:未翻开画一个凸起矩形,右上亮左上暗,模拟 3D 效果;已翻开画凹下矩形,并居中DrawText出数字;插旗画红底三角小旗;踩雷画爆炸图标。数字颜色建议固定一套——1 蓝、2 绿、3 红、4 深蓝、5 褐、6 青、7 黑、8 灰,这套是经典配色,玩家一眼就能分辨。用RGB()宏定义成常量数组,按nAdjacent取下标即可,不要写一串if-else。

4.2 鼠标消息与坐标换算:左键翻开、右键插旗、双击和弦

整个游戏的操作入口只有两三个鼠标消息。WM_LBUTTONDOWN翻开,WM_RBUTTONDOWN插旗,WM_LBUTTONDBLCLK做“和弦”——即在一个数字格上双击,如果周围插旗数量正好等于该数字,就把周围未插旗的格子一次性翻开。这是扫雷高手最常用的操作,改造时值得加上。

// MineDlg.cpp —— 把像素坐标换算成行列下标,越界直接丢弃 void CMineDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (!m_bFirstClick) { // 首点还没布雷,先布 m_board.Init(m_nRows, m_nCols, m_nMines); m_bFirstClick = FALSE; } int c = point.x / m_nCellSize; // 整除,落在哪一格就是哪一格 int r = point.y / m_nCellSize; if (r < 0 || r >= m_nRows || c < 0 || c >= m_nCols) return; // 点到空白边框 if (m_board.OpenCell(r, c)) { StartClockIfNeeded(); // 第一次有效翻开才启动秒表 Invalidate(FALSE); // 只重绘,不擦背景,配合双缓冲 } else if (m_board.IsGameOver()) { KillTimer(TIMER_CLOCK); Invalidate(FALSE); // 触发一次全量重绘展示所有雷 } CDialogEx::OnLButtonDown(nFlags, point); }

逻辑说明:point是客户区坐标,除以格子边长再取整,就得到行列下标。这里必须做越界判断,因为客户区未必正好等于格子数 × 边长,右侧和底部常留几个像素的边距,玩家不小心点到边距,r或c就会算成m_nRows,后面直接数组越界,轻则画面异常,重则崩。这是扫雷源码里最高频的崩溃原因之一。

参数说明:Invalidate(FALSE)的参数含义是“是否擦除背景”,传FALSE表示不擦,直接重绘。配合双缓冲使用时,背景由OnPaint里的FillSolidRect负责,所以这里不用擦,能少一次闪烁。如果传TRUE,系统会先发WM_ERASEBKGND再发WM_PAINT,画面会明显抖一下。

右键插旗要注意两个细节。一是不要用WM_CONTEXTMENU,那是右键菜单消息,会弹出一个空菜单;老老实实处理WM_RBUTTONDOWN。二是插旗三态循环——未翻开 → 旗 → 问号 → 未翻开,这个顺序是老玩家的肌肉记忆,但实际上很多人只用两态,改的时候看源码里CellState有没有CELL_ASK就知道。

双击和弦有一个必须处理的坑:MFC 的对话框窗口类默认没有注册CS_DBLCLKS风格,双击会被系统拆成两次WM_LBUTTONDOWN,WM_LBUTTONDBLCLK永远不触发。现象就是“双击没反应,反而翻开两格”。解决方式是在OnInitDialog里把窗口类的风格补上:

// MineDlg.cpp —— 让对话框能收到双击消息 BOOL CMineDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 补注册 CS_DBLCLKS,否则 WM_LBUTTONDBLCLK 永远收不到 WNDCLASS wc; if (GetClassInfo(AfxGetInstanceHandle(), _T("#32770"), &wc)) { wc.style |= CS_DBLCLKS; SetClassLong(m_hWnd, GCL_STYLE, wc.style); } return TRUE; }

这段代码改动很小,但能省掉半天的排查时间。:32770是对话框的内建类名,SetClassLong修改的是类级别的风格,改完后同一个对话框类创建的所有窗口都生效。

4.3 计时器与胜负判定:秒表什么时候停

扫雷的计时器逻辑看着简单,实际上坑不少。SetTimer设定的间隔不是精确值,系统最小精度在 15 毫秒左右,用++m_nSeconds累加的方式计时,跑上十分钟能偏差好几秒。更要命的是启停时机——什么时候开始算、踩雷后要不要停、赢了之后显示实际用时还是显示 0。

// MineDlg.cpp —— 定时器只负责刷新显示,时间靠差值算 void CMineDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_CLOCK) { DWORD dwElapsed = GetTickCount() - m_dwStartTick; // 毫秒差值 int nSec = (int)(dwElapsed / 1000); if (nSec > 999) nSec = 999; // 三位数显示,封顶 if (nSec != m_nDisplaySec) // 秒数变了才重绘 { m_nDisplaySec = nSec; InvalidateRect(&m_rcClock, FALSE); // 只刷计时器那一小块 } } CDialogEx::OnTimer(nIDEvent); }

逻辑说明:OnTimer里不做累加,只做“当前时刻减开始时刻”的差值计算,这样即使定时器被系统延迟几次,显示的时间也不会漂。GetTickCount()返回的是开机以来的毫秒数,32 位下大约 49 天回绕一次,扫雷这种秒级游戏完全不受影响,不用担心。

参数说明:m_dwStartTick在第一次有效翻开时记录,InvalidateRect只刷计时器那块小矩形而不是整个客户区,能明显降低重绘开销。注意KillTimer(TIMER_CLOCK)必须在“踩雷”和“获胜”两条路径里都调用,漏一条现象就是“游戏结束了,秒表还在走”,而且会一直走到nSec封顶 999。

胜负判定逻辑放在棋盘类里:

// MineBoard.cpp —— 已翻开数等于非雷数即为胜 bool CMineBoard::IsWin() const { return !m_bGameOver && (m_nOpened == m_nRows * m_nCols - m_nMines); }

这个判断依赖m_nOpened的准确性。所有会改变格子状态的路径——FloodFill、OpenCell、和弦展开——都必须严格维护这个计数,多加了或者漏加了,胜负判定就会失灵。现象是“所有格子都翻开了但游戏不结束”,或者“还没翻完就提示胜利”。老源码里最常见的错误是FloodFill递归和OpenCell里各加了一次,导致计数翻倍,游戏提前判胜。改的时候在OpenCell里只保留一处++m_nOpened,让FloodFill内部不要再加。

踩雷后的处理顺序也有讲究:先置m_bGameOver,再调RevealAllMines(),最后KillTimer。如果先KillTimer再置状态,中间这一小段时间里玩家还能点,可能出现“踩雷后还能继续翻格”的诡异现象。

5. 常见问题排查:编译、绘制与判定上的几处坑

5.1 编译与链接阶段

坑一:C1083 无法打开包括文件 pch.h

现象:打开工程直接编译,每个.cpp都报这个错,红色刷屏。

原因:老工程的预编译头文件叫stdafx.h,新 IDE 生成的工程模板叫pch.h,而工程属性里的“预编译头文件”字段还是pch.h,两边对不上。

解决:两条路选一条。要么在工程属性“C/C++ → 预编译头”里把“预编译头”改成“不使用预编译头”,最省事;要么把字段值改成stdafx.h,同时保留原有 include。我一般用前者,扫雷规模小,全量编译没感觉。

坑二:LNK2019 无法解析的外部符号

现象:编译过了,链接挂了,报某个函数或类成员未定义。

原因:多半是新增了.cpp文件但没加进工程,或者函数声明和定义签名不一致。字符集切换时最容易出这类问题——头文件里声明的是void SetText(char*),实现里写成了void SetText(LPCTSTR),两边对不上。

解决:先看报错里提到的是哪个符号,在源码里全局搜索函数名,比对声明和定义。如果确实是新文件没加进工程,在解决方案资源管理器里右键“添加 → 现有项”补上。字符集问题就统一按工程属性里的字符集走,别混用char和TCHAR。

坑三:Debug 正常,Release 一跑就闪退

现象:Debug 配置下玩得好好的,切到 Release 双击图标就退出,连窗口都看不到。

原因:未初始化的局部变量在 Debug 下会被编译器填成0xCC,恰好看上去是个合法值;Release 下优化过后变成随机值,一用就崩。也有可能是数组越界写坏了栈。

解决:先打开“C/C++ → 常规 → SDL 检查”和“代码生成 → 基本运行时检查”,让 Release 也做一遍检查;再在InitInstance里加日志,确认崩在哪一步。如果怀疑数组越界,把m_grid从vector换成带at()的访问版本,越界会直接抛异常并指出位置,比随机崩溃好查一百倍。

5.2 运行阶段的逻辑异常

坑四:第一次点击就踩雷

现象:开局随便点一下,直接爆。

原因:布雷在Init()里就做完了,玩家点到哪一格纯看运气,没有首点保护。

解决:按 3.2 节的做法,把布雷推迟到第一次有效点击之后再执行,并且把首点及其 8 邻域从候选列表里排除。如果源码是“先布雷、点击时再判定”的结构,改造成本会稍微大一点,需要在OpenCell里加一个m_bFirstClick判断,第一次调用时先清空雷区重新布。

坑五:格子有残影,或者跑一段时间后画面全黑

现象:快速点击时上一帧的数字还留在格子上,或者玩了几分钟整个客户区变成一片灰白。

原因:残影是因为没有擦背景,或者说擦得不对;全黑十有八九是 GDI 句柄泄漏——每次OnPaint都CreateCompatibleBitmap和CreateCompatibleDC,从不释放,句柄表满了系统就不再分配,BitBlt拷过去的是一张空的。

解决:位图和内存 DC 都在OnSize时创建、在OnDestroy时释放,OnPaint里只选入使用。检查句柄泄漏可以在任务管理器里加一列“GDI 对象”,正常应该在几十以内,一直往上爬到几千就是漏了。

坑六:游戏结束了秒表还在走,或者赢了不提示

现象:所有非雷格都翻开了,但界面没有任何胜利提示,计时器还在跳。

原因:胜负判定依赖m_nOpened计数器,而这个计数器在FloodFill和OpenCell里被重复自增,值早就超过了总格数 - 雷数,IsWin()永远返回false。

解决:全局搜++m_nOpened,只保留一处。然后加重开逻辑——每次Init时把m_nOpened、m_bGameOver、m_nDisplaySec全部复位,千万别只重置棋盘数组忘了计数器,那样第二局一开局就判胜。

提示:改完一轮之后一定按“首点 → 空白展开 → 插旗 → 和弦 → 踩雷 → 重开”完整走一遍,别只测编译通过就完事。扫雷的 bug 基本都藏在这条操作链的状态切换里。

6. 二次开发改造:把难度、存档和自定义图谱塞进去

原版源码能跑之后,最值得加的三样东西是:难度外置、存档复盘、自定义图谱。

6.1 难度参数外置到 ini

别把 9×9、10 颗雷写死在代码里。加一个mine.ini,三个难度各占一节:

[Easy] Rows=9 Cols=9 Mines=10 [Hard] Rows=16 Cols=30 Mines=99

读取用GetPrivateProfileInt,一行一个参数,改难度不用重新编译。改造时注意Init之后要同步重算格子边长和窗口尺寸,否则画布和棋盘对不上,格子会被画到客户区外面去。

6.2 存档与复盘

棋盘结构本身很好序列化:存rows/cols/mines/已用秒数,再存一张二维的状态表,每个格子一个字符(0未翻开、1已翻开、2旗、3雷)。纯文本格式,几十行就能读写,还方便人工查看。复盘的价值在于把“剩余雷数”和“首点位置”一起存下来,重放时可以验证布雷是否真的首点安全——这是我验证改造有没有引入回归最常用的手段。

6.3 改造后的验证清单

改完别只看编译过不过,按这个顺序跑一遍:改 ini 里的雷数看是否生效;首点是否必定展开;插旗后左键是否点不开;和弦在旗数正确/错误两种情况下表现是否一致;存盘后重开再读档,棋盘是否完全一致;连续重开十局,看 GDI 对象数是否稳定。最后一项最容易漏,却是内存泄漏最容易暴露的地方。

讲个自己的习惯:我每次改完扫雷这类状态机式的程序,都会先不画界面,把所有操作打成日志往控制台输出一遍,确认数据层的行为完全正确再回去调 UI。界面会骗人,数据不会。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询