☰
VC++扫雷源码:Win32窗口编程与GDI绘制的完整实战解析
2026/10/16 7:05:18 网站建设 项目流程

简介:这是一份基于VC++与MFC实现的经典扫雷游戏完整源程序,面向C++初学者、游戏开发爱好者以及学习Windows GUI编程的读者。资源包约1.95MB,共54个文件,包含核心C++源文件与头文件、MFC对话框资源、位图图标、WAV音效,以及Visual Studio工程配置和调试生成文件,打开工程后即可编译运行并逐模块分析。

目前已有94人学习下载,适合对照教材或课程设计复用。程序围绕雷区初始化、点击判断、标记雷、计算并显示周围数字等核心逻辑展开,涉及二维数组遍历、邻接计数、递归展开处理空白区域,以及鼠标左键/右键事件与界面控件的绑定;同时,源码中保留了错误处理与调试信息,便于观察程序运行流程和排查问题。

通过阅读整套源代码,读者可以理解C++面向对象设计、MFC框架下的窗口与对话框机制、消息映射与菜单交互,以及小型游戏的工程组织方式,对提升实践编程能力和独立开发同类小游戏具有直接参考价值。

1. VC++扫雷源程序:理解 Windows 桌面编程的最小入口

当你对着“vc++游戏源程序扫雷”找到一份代码时,先别急着把七八百行从头读到尾。这个游戏在 VC++ 圈子里流传了很久,但它真正值钱的地方不在于扫雷本身,而在于它把 Windows 桌面程序的几根主线全串起来了:窗口创建、消息循环、鼠标坐标换算、GDI 绘制、定时器、二维数组算法、游戏状态机。一个新手如果只靠这个项目,就能把“Windows 编程是怎么回事”这件事弄明白七成;一个熟手则能从这里快速找回对 Win32 API 的手感。这篇文章就按“读懂源码 → 自己跑通 → 改造成自己的版本”这个顺序来讲,最后落到几个不翻车就注意不到的细节上。

适合看这篇文章的人,是正在学 VC++、准备 Windows 客户端开发面试、或者手里已经有一份扫雷源码但读不下去的人。不适合谁?想用扫雷学 C++ 语法的人,建议先回去补语言基础;想做商业游戏的人,这个方向也不是你该走的路。

2. 拆源码骨架:数据结构、窗口框架、绘制与规则分开想

我第一次拿到这类源码时,习惯是双击打开、从第一行往下读。读到两百行左右就发现脑子已经转不动了。原因很简单:大部分流传的 VC++ 扫雷源码都把逻辑堆在WndProc里,鼠标消息处理、绘图代码、游戏规则混成一团,读起来像一碗浆糊。后来我把代码按职责重新拆成四块,思路才清晰起来。

2.1 不管源码怎么组织,四个职责必须先分开

一份能看懂、能改的扫雷源程序,至少要分成四块:

  • 游戏数据:棋盘、雷数、格子状态、游戏是否结束。
  • 窗口框架:注册窗口类、创建窗口、消息循环。
  • 绘制模块:根据游戏数据,用 GDI 把棋盘画到窗口上。
  • 规则模块:左键翻开、右键标旗、0 格子扩散、输赢判定。

我见过的几十份扫雷源码里,凡是能让人读下去的,基本都遵守了这个划分;凡是读起来头疼的,基本都是WndProc里直接写“翻格子 + 画格子 + 改状态”。如果你拿到的源码是后者,先别急着改功能,先把上面的结构理出来再动手。

这样做的好处有两个:一是排查问题时能迅速定位,比如画面不对只看绘制函数,点开没反应只看鼠标消息;二是后续要扩展功能,比如加“剩余雷数显示”“难度选择”“音效”,你只需要在对应模块里加代码,不会牵一发动全身。

2.2 棋盘数据模型:两个 int 数组,还是一个结构体数组

经典扫雷源码里最常见的数据结构是两个二维数组:

// 棋盘尺寸与地雷数量 #define ROWS 9 #define COLS 9 #define MINE_COUNT 10 // value: -1 表示地雷,0~8 表示周围雷数 // state: 0 未翻开,1 翻开,2 标记为旗子 int value[ROWS][COLS]; int state[ROWS][COLS];

两个数组并行的写法直接把概念映射到代码里,新手好理解:value存这个格子里是什么,state存这个格子当前处于什么状态。这种写法在数据量小的游戏里没有任何问题,而且访问速度很快。缺点是可扩展性差,比如想增加“问号”状态、想记录格子是否被点击过、想加动画帧,就得再开一个数组,代码会越来越散。

我一般更推荐把格子封装成一个结构体:

// 格子结构体:把数值和状态绑在一起 struct Cell { int value; // -1 表示地雷,0~8 表示周围雷数 int state; // 0 未翻开,1 翻开,2 标旗 }; Cell grid[ROWS][COLS];

使用结构体后,改格子状态时一眼就能看到整体,比如grid[row][col].state = 1;表示这个格子被翻开。后面如果要加状态,直接在Cell里加一个字段就行,不用再新建一个平行数组。

两种方案都能跑,建议是:拿到源码先看它用的是哪种;如果源码已经是结构体版本,恭喜你,它大概率是可维护的;如果还是双数组版本,你可以在阅读时脑内把它们对应起来。

2.3 窗口框架:Win32 API 还是 MFC

VC++ 这个名称跟 MFC 绑定得很深,网上流传的扫雷源码也有相当一部分是用 MFC 写的。这里要先说清楚一个事实:VC++ 项目里既可以用 MFC 封装类,也可以用纯 Win32 API,两者都是合法且常见的写法。

如果是拿来学习,我强烈建议先看 Win32 API 版本。理由很简单:MFC 把窗口过程、消息映射、消息路由封装成了宏,你在调试时看到的是BEGIN_MESSAGE_MAP、ON_WM_LBUTTONDOWN()这些宏展开,底层机制反而不容易看清。而扫雷这个项目最重要的学习价值就在“消息从哪里来、到哪里去”。Win32 API 版本里,你能直接看到WndProc里case WM_LBUTTONDOWN:的处理,对应关系一目了然。

如果你手里的源码是 MFC 版,也不要慌。它的核心逻辑跟 Win32 版本几乎一样,只是把WndProc换成了OnLButtonDown、OnPaint这类成员函数。你在读的时候,只需要把“消息处理函数”对应到“消息分支”上,数据结构和算法部分是通用的。

2.4 定时器与重绘:扫雷的秒表是怎么走起来的

扫雷有一个计时器,显示玩家用了多少秒。Win32 里最常见的实现是用SetTimer,让系统每隔 1000 毫秒发一个WM_TIMER消息。

// 在窗口创建完成后启动定时器 SetTimer(hWnd, 1, 1000, NULL);
// 在窗口过程里处理 WM_TIMER case WM_TIMER: if (!gameOver && !win) { elapsedSeconds++; // 只需要重绘时间区域,不用重绘整个棋盘 InvalidateRect(hWnd, &timeRect, FALSE); } break;

参数说明:SetTimer第一个参数是窗口句柄,第二个是定时器编号,第三个是间隔毫秒数,第四个是回调函数指针,这里用NULL表示走窗口消息。InvalidateRect的第三个参数FALSE表示不擦除背景,只重画指定区域,这样能减少闪烁。

这里有一个很多人容易忽略的点:WM_TIMER不是实时中断,它的精度也不高。如果只是记录“秒”,1000 毫秒的间隔完全够用;但如果想做毫秒级计时,SetTimer就会飘。我自己的习惯是只把SetTimer用来触发“UI 更新”,实际计秒用一个GetTickCount64()记录起始时间,在WM_PAINT里计算差值。

3. 把 VC++ 扫雷源码跑通:项目配置、地图初始化和第一帧画面

这一章的目标是让你在 30 分钟内,从零建出一个能跑起来的扫雷窗口。不需要先写完整逻辑,先把窗口、棋盘、鼠标响应这三根骨头立起来。

3.1 创建项目:选 Win32 还是空项目,字符集要注意

在 Visual Studio 里新建项目时,你有两个常见路径:

  • 创建“Windows 桌面向导”项目,然后勾选“空项目”。向导会帮你在项目属性里配置好 Windows SDK、子系统为 Windows,入口点默认设置为wWinMain。
  • 手写创建一个控制台应用,然后改链接器子系统。

第二种路径很容易踩坑:控制台应用的入口是main,而 Windows 图形程序入口是WinMain或wWinMain。如果子系统设置成“控制台”,你写了WinMain却不会链接对应的入口,编译器会报LNK2019 unresolved external symbol _main之类的错误。所以最省事的方案是直接用 Windows 桌面向导,不要自己去改。

另外要注意字符集。Visual Studio 默认使用 Unicode 字符集,意味着TCHAR在编译时被替换成wchar_t,字符串要写成_T("扫雷")这种形式。如果你拿到的老源码里用的是"扫雷"直接赋值给char数组,在 Unicode 工程下会报警告甚至报错。

// 推荐写法:兼容 Unicode 与多字节字符集 #include <tchar.h> int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PWSTR pCmdLine, int nCmdShow) { // 程序入口 return 0; }

参数说明:hInstance是当前模块的实例句柄,注册窗口类时要用;pCmdLine是命令行参数;nCmdShow是窗口初始显示方式。这段代码只是一个骨架,实际扫雷程序的窗口注册和消息循环会写在wWinMain里。

3.2 地图初始化:布雷与雷数统计

扫雷的第一步是把棋盘填上地雷,然后给每个非雷格子计算“周围八个格子有几个雷”。这里我先给出一段完整的初始化代码。

// 方向数组:八个邻居的行列偏移 static int dir[8][2] = { {-1, -1}, {-1, 0}, {-1, 1}, {0, -1}, {0, 1}, {1, -1}, {1, 0}, {1, 1} }; // 布雷:把 MINE_COUNT 颗雷随机放入棋盘 void PlaceMines() { int placed = 0; while (placed < MINE_COUNT) { int r = rand() % ROWS; int c = rand() % COLS; if (grid[r][c].value != -1) { // 这个位置还没有雷 grid[r][c].value = -1; placed++; } } } // 计算每个非雷格子的数字 void CalcNumbers() { for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (grid[r][c].value == -1) continue; // 跳过雷 int cnt = 0; for (int k = 0; k < 8; k++) { int nr = r + dir[k][0]; int nc = c + dir[k][1]; if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && grid[nr][nc].value == -1) { cnt++; } } grid[r][c].value = cnt; } } }

逻辑说明:PlaceMines用随机坐标布雷,每放一颗雷计数加一,直到完成MINE_COUNT颗。CalcNumbers遍历所有格子,对每个非雷格子检查八个邻居中雷的数量,存入value。

这里有两个关键细节。第一,rand() % ROWS产生 0 到 8 的随机数,这个方案在雷数远小于格子数时效率不错;如果雷数接近格子总数,循环可能会反复生成重复坐标,极端情况下会拖慢速度。第二,dir方向数组的顺序是固定的,你可以从左上角开始顺时针写,也可以从左边开始写,但要保证八个方向一个不漏。

3.3 WM_PAINT 与 GDI 绘制:画出第一帧棋盘

绘制是扫雷源码里最直观也最容易被写烂的部分。最简单的绘制用两个 GDI 函数就能完成:FillRect填充已翻开格子的背景色,DrawEdge画未翻开格子的凸起边框。

// 绘制棋盘 void DrawBoard(HDC hdc) { RECT cell; for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { // 计算当前格子对应的像素矩形 cell.left = c * CELL_SIZE; cell.top = r * CELL_SIZE; cell.right = (c + 1) * CELL_SIZE; cell.bottom = (r + 1) * CELL_SIZE; if (grid[r][c].state == 0) { // 未翻开:画凸起边缘 DrawEdge(hdc, &cell, EDGE_RAISED, BF_RECT); } else { // 已翻开:填充灰色背景 FillRect(hdc, &cell, (HBRUSH)GetStockObject(DKGRAY_BRUSH)); // 如果数字大于 0,后续在这里绘制数字 } } } }
case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 画棋盘之前先填充窗口背景,避免残留 FillRect(hdc, &ps.rcPaint, (HBRUSH)GetStockObject(WHITE_BRUSH)); DrawBoard(hdc); EndPaint(hWnd, &ps); break; }

参数说明:CELL_SIZE是每个格子的边长像素数,一般用 32 或 40,太小点不准,太大窗口会铺满屏幕。EDGE_RAISED让未翻开格子看起来像凸起按钮,BF_RECT表示对整个矩形边框生效。GetStockObject取系统预定义的画刷,好处是不需要手动删除句柄,不会造成 GDI 泄漏。

很多新手在这里会把DrawEdge和FillRect混用,结果是边框和背景重叠,画面发虚。正确思路是:未翻开格子只画三个凸起边框和一个浅灰色底,已翻开格子画一个深灰色底,两者用state区分。

3.4 鼠标消息换算成棋盘坐标

鼠标点击的lParam参数里,低位是 X 坐标,高位是 Y 坐标。换算成行列号的公式是:行号 = Y 除以格子像素高度,列号 = X 除以格子像素宽度。

case WM_LBUTTONDOWN: { // 把像素坐标换算成棋盘行列 int col = LOWORD(lParam) / CELL_SIZE; int row = HIWORD(lParam) / CELL_SIZE; // 越界保护:点击窗口边框或在棋盘之外直接忽略 if (row < 0 || row >= ROWS || col < 0 || col >= COLS) break; // 翻开格子,后文会实现 RevealCell(row, col); // 请求重绘,让画面反映最新状态 InvalidateRect(hWnd, NULL, FALSE); break; }

参数说明:LOWORD(lParam)取出低 16 位作为 X 坐标,HIWORD(lParam)取出高 16 位作为 Y 坐标。除法用的是整数除法,自动取整,正好得到格子序号。越界判断虽然看起来多余,但如果没有,玩家点击窗口边缘时可能触发越界数组访问,程序崩溃,这个习惯要养成。

4. 扫雷源码的规则部分:揭开逻辑、0 扩散与首击保护

地图画出来、鼠标能点中格子之后,剩下的核心就是游戏规则了。扫雷看起来规则简单,但要把它写成代码,有几个很容易漏掉的点。

4.1 左键翻开格子:地雷、数字、空地的三分支

翻格子是扫雷的核心动作。逻辑是这样的:如果点到雷,游戏结束;如果点到数字,只翻开这一格;如果点到空地(值为 0),要递归地把周围所有相邻空地翻开。

// 翻开格子,带 0 扩散 void RevealCell(int row, int col) { // 越界检查 if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return; // 已经翻开或已标旗的格子不再处理 if (grid[row][col].state == 1 || grid[row][col].state == 2) return; // 点到地雷:游戏结束 if (grid[row][col].value == -1) { grid[row][col].state = 1; gameOver = true; return; } // 先把这个格子标记为已翻开 grid[row][col].state = 1; revealedCount++; // 统计已翻开的格子数,后面判断胜负用 // 如果当前格是 0,继续翻开它的邻居 if (grid[row][col].value == 0) { for (int k = 0; k < 8; k++) { RevealCell(row + dir[k][0], col + dir[k][1]); } } }

逻辑说明:函数先做两个提前返回,一是越界,二是状态不允许。注意第二个提前返回里包含state == 2,这很关键:玩家已经用右键标了旗子,左键就不能再翻开,否则会出现“标旗还能点开”的反直觉表现。然后判断雷,最后把格子标记成翻开,如果值是 0 就递归扩散。

这段递归代码在 9×9 棋盘上没有任何问题,展开范围一般只有几十格,不会爆栈。但如果把棋盘放大到 30×30、雷数增加到 99 格的“专家模式”,递归深度理论上可能达到几百层,在 Windows 默认栈大小下仍然安全,但如果你追求极致稳妥,可以用显式栈来模拟递归。

4.2 数字颜色与字体:一个表搞定视觉区分

翻开格子后,数字 1 到 8 要有不同颜色,这是扫雷的视觉语言。源码里通常用一个颜色数组映射数字。

数字颜色RGB 值
1蓝色RGB(0, 0, 255)
2绿色RGB(0, 128, 0)
3红色RGB(255, 0, 0)
4深蓝RGB(0, 0, 128)
5深红RGB(128, 0, 0)
6青色RGB(0, 128, 128)
7黑色RGB(0, 0, 0)
8灰色RGB(128, 128, 128)
// 数字颜色表,下标对应数字 0~8 static COLORREF colorTable[9] = { RGB(0, 0, 0), // 0 不使用 RGB(0, 0, 255), // 1 蓝 RGB(0, 128, 0), // 2 绿 RGB(255, 0, 0), // 3 红 RGB(0, 0, 128), // 4 深蓝 RGB(128, 0, 0), // 5 深红 RGB(0, 128, 128), // 6 青 RGB(0, 0, 0), // 7 黑 RGB(128, 128, 128) // 8 灰 };

绘制数字时,有两个细节容易翻车。第一,必须设置背景为透明,否则数字会盖住原来格子的灰色背景,形成一块白色方块。第二,文本对齐要设置成居中,否则数字会偏到格子左上角。

// 绘制已翻开格子的数字 void DrawNumber(HDC hdc, RECT cell, int num) { if (num <= 0) return; // 设置前景色为对应数字颜色 SetTextColor(hdc, colorTable[num]); // 背景透明,让格子底色透出来 SetBkMode(hdc, TRANSPARENT); // 把数字画在格子中央 DrawText(hdc, _T("3"), -1, &cell, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }

这段代码里的_T("3")是我写死的示例,实际绘制时应该把num转成字符串。可以用wsprintf或者std::to_wstring。如果源码里直接把数字当字符处理,要注意1 + 48 = '1'这个 ASCII 转换不能忘。

4.3 右键标记旗子:状态机里的第三个状态

右键的作用是在未翻开格子上插旗,表示“这里我确定是雷”。已插旗的格子不能再被左键翻开,直到玩家再次右键取消标记。

case WM_RBUTTONDOWN: { int col = LOWORD(lParam) / CELL_SIZE; int row = HIWORD(lParam) / CELL_SIZE; if (row < 0 || row >= ROWS || col < 0 || col >= COLS) break; // 只有未翻开和已插旗的格子可以切换 if (grid[row][col].state == 0) { grid[row][col].state = 2; // 标记为旗子 } else if (grid[row][col].state == 2) { grid[row][col].state = 0; // 取消标记 } InvalidateRect(hWnd, NULL, FALSE); break; }

这个逻辑简单,但有一个容易忽略的边界条件:已翻开的格子不能右键操作。如果玩家在已翻开的格子上点右键,什么也不应该发生。上面的代码因为state既不是 0 也不是 2,自动跳过,不会出问题。

4.4 首击保护:雷不能在第一次点击时炸

玩家第一次点击就踩雷,在真正的 Windows 扫雷里不会发生。这是设计上的保护,但在源码里经常被遗漏。实现方式有两种:

  • 方案 A:先布雷,如果第一次点击落在雷上,把该雷移到远处空格。
  • 方案 B:第一次点击时才布雷,并且保证点击的格子周围 3×3 区域没有雷。

我推荐方案 B,因为它同时保证了“首击周围是空地”,体验更好。实现时只需要把一个布尔变量firstClick初始化为true,在RevealCell调用布雷。

bool firstClick = true; void RevealCell(int row, int col) { // ... 越界和状态判断省略 ... // 第一次点开时先布雷,避开当前格子的 3x3 区域 if (firstClick) { firstClick = false; PlaceMinesWithSafeArea(row, col); CalcNumbers(); } // 后续逻辑正常执行 }
// 布雷并保证 (safeRow, safeCol) 周围 3x3 无雷 void PlaceMinesWithSafeArea(int safeRow, int safeCol) { int placed = 0; while (placed < MINE_COUNT) { int r = rand() % ROWS; int c = rand() % COLS; // 跳过安全区域:以 safeRow/safeCol 为中心的 3x3 if (abs(r - safeRow) <= 1 && abs(c - safeCol) <= 1) continue; if (grid[r][c].value != -1) { grid[r][c].value = -1; placed++; } } }

参数说明:abs(r - safeRow) <= 1 && abs(c - safeCol) <= 1判断当前随机坐标是否落在安全区域内。这个判断有个特殊情况:如果棋盘太小,安全区域会吃掉太多可用格子,导致布雷死循环。9×9 棋盘放 10 颗雷时,安全区域 9 格,剩余 72 格,完全够用。

4.5 胜负判定:一个计数器和两个布尔变量

扫雷的胜利条件是:所有非雷格子都被翻开。实现上只需要维护一个计数器revealedCount,每成功翻开一个非雷格子就加一。当计数器等于ROWS * COLS - MINE_COUNT时,游戏胜利。

// 在 RevealCell 成功翻开非雷格子后调用 void CheckWin() { if (revealedCount == ROWS * COLS - MINE_COUNT) { win = true; gameOver = true; // 可以在这里弹出提示,或者停止计时器 KillTimer(hWnd, 1); } }

这里的注意点是:gameOver和win是两个意义不同的变量。gameOver表示游戏进入终局,可能是胜利也可能是失败;win表示是否胜利。很多源码只用一个变量,导致输赢状态无法区分。我在自己的版本里始终用两个布尔变量,画面绘制和逻辑判断都清晰得多。

5. 扫雷源码排雷:五个编译与运行时常见问题排查

这一章写我实际遇到的五个问题,按“现象 → 原因 → 解决”的顺序来。每一个都来自真实跑源码的过程,不是理论猜测。

5.1 编译报错:入口点缺失,链接器说找不到 WinMain

现象:新建项目后把扫雷源码粘贴进去,编译报LNK2019 unresolved external symbol _main referenced in function "int __cdecl invoke_main",或者提示WinMain找不到。

原因:项目创建成了“控制台应用程序”,链接器子系统是“控制台”,它寻找的入口函数是main;而 Win32 程序入口是WinMain或wWinMain,两者对不上。

解决:在项目属性 → 链接器 → 系统 → 子系统中,把子系统改成“窗口 (/SUBSYSTEM:WINDOWS)”。如果项目是用 Windows 桌面向导创建的,就不会有这个问题。另外,如果你的源码用的是WinMain而不是wWinMain,且项目是 Unicode 字符集,可能需要统一改成wWinMain,或在项目属性里把字符集改成“多字节字符集”。

5.2 数字和字体画不出来,或文字位置跑偏

现象:棋盘绘制正常,但翻开格子后数字不显示,或者数字出现在格子左上角、背景变成白色块。

原因:绘制数字时没有设置SetBkMode(hdc, TRANSPARENT),导致 GDI 用不透明的背景填充文字区域,盖住了格子底色的同时让文字位置看起来像“飘”在一个白块上。另外,如果没有调用SetTextAlign(hdc, TA_CENTER | TA_TOP)或使用DrawText的居中标志,文字默认从格子左上角开始画。

解决:在绘制数字前统一加两行:SetBkMode(hdc, TRANSPARENT);和SetTextAlign(hdc, TA_CENTER | TA_TOP);。用DrawText的DT_CENTER | DT_VCENTER是更省事的方式,因为它不依赖之前的文本对齐状态。

5.3 每次启动雷阵都一模一样

现象:反复运行程序,地雷的位置固定不变,没有随机性。

原因:源码里直接用了rand(),但整个程序从头到尾没有调用srand()。C 标准库的随机数生成器默认用种子值 1,所以每次运行产生的随机序列完全相同。

解决:在WinMain的开头加一行种子初始化:

#include <time.h> srand((unsigned)time(NULL));

注意srand只需要调用一次,不要在每个格子布雷循环里重复调用。如果在循环里反复srand(time(NULL)),因为time()粒度是秒,循环在极短时间内执行,反而可能生成相同的随机序列或让随机性分布变差。

5.4 窗口关闭后程序还在跑,再开一个窗口就卡死

现象:关闭游戏窗口,进程还在后台;或者再次打开程序,界面卡住、定时器乱跳。

原因:SetTimer创建的定时器没有在窗口销毁时销毁。窗口虽然关了,但定时器对象还挂在消息队列上;如果窗口句柄已失效,系统会持续向无效窗口发送消息或者引发资源泄漏。

解决:在WM_DESTROY消息里调用KillTimer(hWnd, 1),把程序用到的定时器全部清理干净。

case WM_DESTROY: KillTimer(hWnd, 1); PostQuitMessage(0); break;

5.5 翻开数字时游戏直接崩溃,疑似数组越界

现象:点击棋盘边缘的格子,程序偶尔崩溃,调试器定位到grid[nr][nc]附近。

原因:在 0 扩散的递归代码里,没有在访问邻居之前做越界检查;或者在布雷逻辑里,row + dir[k][0]计算出负数或超过ROWS的下标。

解决:所有访问grid数组的下标,在送入数组前都必须经过边界判断。我习惯在RevealCell最开头加一行统一的越界返回,而不是在 8 个邻居循环里逐个判断。这样逻辑更集中,也减少出错概率。

// 统一越界保护,放在函数最前面 if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return;

6. 拿到扫雷源码后的三处升级:位图外观、计时精度与干净发布

源码跑通只是第一步。如果你打算把这个项目放进简历或者当作练习作品,我建议你至少做三处升级,这三处改完,项目完成度会有明显提升。

6.1 把凸起边框换成位图或自绘贴图

DrawEdge画出来的凸起边框很有年代感,但和现代审美差距较大。常见做法是用LoadImage加载几张位图,分别对应未翻开、已翻开、旗子、地雷等状态,然后在WM_PAINT里用BitBlt或TransparentBlt把位图绘制到单元格上。

// 加载位图资源,例如 IDB_MINE 是地雷图片 HBITMAP hMine = LoadImage(hInstance, MAKEINTRESOURCE(IDB_MINE), IMAGE_BITMAP, CELL_SIZE, CELL_SIZE, LR_DEFAULTCOLOR);

这里的参数说明:LoadImage第三个参数指定加载类型,第四、第五个参数指定拉伸后的宽高;最后一个参数里LR_DEFAULTCOLOR表示按原色加载。位图尺寸最好和CELL_SIZE一致或按比例缩放,否则会有拉伸变形。

6.2 计时精度:用 GetTickCount64 替代 SetTimer 计数

前面提过SetTimer的精度问题。在真正实现计时功能时,我会用系统启动时间函数记录起点,然后在WM_PAINT或通过心跳消息计算已过秒数。

// 记录游戏开始时的系统时间 ULONGLONG startTime = GetTickCount64(); // 在绘制或刷新时计算已经过去的秒数 int seconds = (int)((GetTickCount64() - startTime) / 1000);

这样做的好处是:即使界面因为拖动、最小化等原因短暂卡顿,计时也不会累积误差;而SetTimer在消息堆积时会丢掉WM_TIMER,导致显示时间偏小。

6.3 发布:从 Debug 切到 Release

最后一步虽然和代码无关,但直接影响别人能否跑起来。发布前请在 Visual Studio 工具栏把配置从 Debug 切到 Release,然后在项目属性 → C/C++ → 代码生成 → 运行库里,把“多线程 DLL (/MD)”改成“多线程 (/MT)”,也就是静态链接 C 运行时库。这样目标机器上不需要安装 VC++ 运行时也能运行。

我现在的一个习惯是,改造完源码后一定会做两件事:第一,打开任务管理器,观察运行五分钟后的 GDI 对象数是否稳定,如果持续增长,说明绘制代码里有句柄泄漏;第二,用 Release 配置重新编译,然后扔到一台“干净”的虚拟机上跑一遍,确认没有依赖 DLL 缺失的问题。这两件事做完,项目才算真正交付过。

这些经验是从一次次翻车里攒出来的。也希望你拿到手里的扫雷源码时,不要只停留在“能编译能运行”,而是像我一样把它拆开、跑通、再改出你自己的版本,这个过程里踩过的坑都是值得的。希望帮到你。

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

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

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

立即咨询