简介《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是关键——如果返回TRUEMFC 还会再起一轮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或者%lsstrcpy要么换wcscpy要么换_tcscpy。血泪经验是先按原工程的多字节字符集编译通过跑起来看看效果再决定要不要花时间做 Unicode 迁移别一上手就改字符集那样报错会从 3 个变成 300 个。第三处是平台工具集。工程属性“常规 → 平台工具集”如果显示的是一个本机没装的版本直接改成当前可用的版本号即可。改完可能还会跳 C4996 警告提示strcpy、sprintf、fopen这些函数不安全这只是警告不是错误实在嫌吵可以在文件顶部加#define _CRT_SECURE_NO_WARNINGS但更好的做法是把它们换成_s后缀的安全版本。命令行编译比开 IDE 更容易定位环境问题# 在工程目录下执行Release 配置 x86 平台输出会直接告诉你缺哪个组件 msbuild Mine.sln /p:ConfigurationRelease /p:Platformx86 /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::vectorstd::vectorMineCell m_grid; // 二维表先按行再按列 int m_nRows, m_nCols, m_nMines; int m_nOpened; // 已翻开格数用于胜负判定 bool m_bGameOver; // 踩雷后置位屏蔽后续操作 };逻辑说明用vectorvector而不是裸的new MineCell*[rows]好处是析构不用管拷贝也安全。老源码里常见m_pGrid new MineCell*[m_nRows]然后循环newOnDestroy里再循环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::vectorstd::pairint, 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_LBUTTONDOWNWM_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] Rows9 Cols9 Mines10 [Hard] Rows16 Cols30 Mines99读取用GetPrivateProfileInt一行一个参数改难度不用重新编译。改造时注意Init之后要同步重算格子边长和窗口尺寸否则画布和棋盘对不上格子会被画到客户区外面去。6.2 存档与复盘棋盘结构本身很好序列化存rows/cols/mines/已用秒数再存一张二维的状态表每个格子一个字符0未翻开、1已翻开、2旗、3雷。纯文本格式几十行就能读写还方便人工查看。复盘的价值在于把“剩余雷数”和“首点位置”一起存下来重放时可以验证布雷是否真的首点安全——这是我验证改造有没有引入回归最常用的手段。6.3 改造后的验证清单改完别只看编译过不过按这个顺序跑一遍改 ini 里的雷数看是否生效首点是否必定展开插旗后左键是否点不开和弦在旗数正确/错误两种情况下表现是否一致存盘后重开再读档棋盘是否完全一致连续重开十局看 GDI 对象数是否稳定。最后一项最容易漏却是内存泄漏最容易暴露的地方。讲个自己的习惯我每次改完扫雷这类状态机式的程序都会先不画界面把所有操作打成日志往控制台输出一遍确认数据层的行为完全正确再回去调 UI。界面会骗人数据不会。希望帮到你。本文还有配套的精品资源点击获取