用Win32+GDI手写扫雷:消息循环、递归展开与发布避坑指南
2026/9/7 2:43:58 网站建设 项目流程

简介:VC++游戏源程序扫雷是一套基于Visual C++与MFC框架的经典扫雷游戏完整源码,面向C++初学者、游戏开发入门者及需要Windows GUI编程参考的学生。项目覆盖雷区初始化、二维数组遍历、邻格雷数统计、递归翻开空白区以及鼠标交互与计时实现,清晰展示了MFC对话框程序从界面搭建、控件布局到消息映射与事件处理的完整流程,适合剖析程序结构与界面逻辑。压缩包共包含54个文件,主体为6个cpp、8个h源文件,另有7个bmp、3个wav等界面与音效资源,以及dsp/dsw工程文件、pdb调试信息和可直接运行的exe程序,整体仅1.95MB,便于快速查阅与调试。目前已有94人学习下载,适合用来理解经典算法、对照学习MFC控件与GDI绘图用法,也可作为课程设计或二次开发的基础版本。 用 VC++ 写一个扫雷游戏源程序,放在二十年前是 Windows 程序员的入门作业,放在今天依然是检验你对消息循环、GDI 绘制、数组边界控制有没有真正理解的试金石。很多人拿到这种项目第一件事就是去找现成源码,但拆开一看,要么是 MFC 巨型工程,要么在 VS2022 里一编译就是几十条报错,根本读不下去。这篇我就用一个可跑的 Win32 扫雷程序的思路,从界面选型、雷区数据结构、翻开逻辑、鼠标交互到最后的运行库分发放一块讲透,顺带把那些教程里不写、但你一定会碰到的坑全部指出来。适合正在学 VC++、想做课程设计、或者单纯想读懂扫雷源码的开发者。

1. 用 Win32 + GDI 还是 MFC:扫雷项目的界面选型与工程配置

1.1 先说结论:这个项目更适合原生 Win32

很多人一提到 VC++,第一反应就是 MFC。MFC 封装了窗口类、消息映射、控件体系,确实能让界面代码看起来规整,但扫雷这种游戏有个特点:界面主体不是一堆控件,而是一块可以自由绘制的画布。你用 MFC 也要 CView、OnDraw,消息处理还要套 ON_MESSAGE、ON_COMMAND 那一套宏,对初学者来说,框架本身的复杂度比游戏逻辑还高。我见过不少同学的课程设计,光是把 MFC 的向导工程跑起来、理解 Document/View 的关系,就花了一周,而扫雷算法本身撑死三天。

原生 Win32 API 的好处是直接面对 Windows 的消息循环,窗口过程里处理鼠标消息,WM_PAINT 里画格子,所有事情都摆在明面上,没有隐藏逻辑。对扫雷这种几十个格子、状态有限的游戏,GDI 画矩形和文本完全够用,性能根本不用担心。所以我在实际选择时直接放弃了 MFC,用 RegisterClass + CreateWindow + 消息循环写窗口,再配合 GDI 函数完成绘制。

1.2 Visual Studio 里的模板到底怎么选

这里有个很关键的坑。VS2017 及以后的版本里,新建项目时搜索“Windows 窗体应用程序”,出来的模板是 C++/CLI 的,跑在 .NET Framework 上,并不是经典的原生 Win32 程序。C++/CLI 适合用来和 C# 代码互操作,但对一个“VC++ 游戏源程序”来说,路线完全不对,你在里面写的 main 和 WinMain 都不存在,反而会套上一堆 ref class、gcnew 的语法。我建议在新建项目时直接选“Windows 桌面应用程序”或“Windows 桌面向导”,指定应用程序类型为“Windows 应用程序”,空项目的话自己写 WinMain 也可以。

工程配置里还有一个值得注意的地方:字符集。默认情况下新工程是 Unicode 字符集,所以窗口类名、标题字符串都要用 L"..." 宽字符,或者用 TCHAR 宏。你要是从网上下载旧源码,里面全是 char 和 LPSTR,编译时就会报类型不匹配,解决办法要么改字符串,要么在项目属性 → 常规 → 字符集里改成“使用多字节字符集”。

1.3 自己搭工程时的高频报错和预处理

还有一个会让新手瞬间懵掉的报错:C4996。VS 对 rand()、strcpy 这类函数会有安全警告,但它不影响编译。想省事就在预处理器定义里加上 _CRT_SECURE_NO_WARNINGS,或者用 std::mt19937 替代 rand()。这里我不建议一上来就整现代 C++ 的随机库,扫雷的核心是逻辑,不是随机数优雅度,等把游戏跑通了再优化也不迟。

提示:用 Win32 写扫雷,你的代码量会非常集中:WinMain、WndProc、游戏逻辑、绘制函数,加起来大概 600 到 900 行。这个体量刚好适合一口气读懂,不会因为文件太多而失去重点。

2. 雷区的数据建模:二维数组、随机布雷与数字统计

2.1 用二维数组还是展开成一维

扫雷的棋盘本质是一张二维表格。我最开始也尝试过用一维数组,通过 y * width + x 索引,画图时坐标转换很直接,但判断八方向邻接时要反复做除法和取模,代码写出来不如二维数组直观。对于标准扫雷,棋盘最大也就 30×30,二维数组 int map[32][32] 完全够用,内存开销可以忽略。我最终采用三个同尺寸数组,分别管三件事:

  • mineMap:每个格子的地雷/数字信息,-1 表示雷,0~8 表示周围雷数。
  • revealed:是否已翻开。
  • marked:标记状态,0 无标记,1 插旗,2 问号。

有些实现会把标记状态和翻开状态合并到一个枚举里,比如用 0~2 表示未翻开但已插旗等,实际开发中分开反而更清晰。你后面对比源码时看到标记数组不要觉得多余,它是整个交互逻辑的地基。

2.2 布雷算法:尽量避免“随机到重复位置”

最简单的布雷思路是:随机生成 x 和 y,如果该格已经是雷就重来。小棋盘上问题不大,但雷数接近格子总数时,重复概率飙升,循环次数不可控。我推荐的做法是把所有格子的坐标放进一个 vector,用 std::shuffle 打乱,然后取前 mineCount 个格子布雷。这样不用处理重复判断,效率也稳定。

还有一个细节:srand 的种子。用 time(NULL) 是常见做法,但同一秒内多次启动程序会得到相同布局。如果你自己写代码,可以试试用 std::random_device 给 std::mt19937 做种子,每次运行布局都不同。

布雷完成后立刻统计每个非雷格周围八个方向里雷的数量。这个统计用两层循环遍历相邻格子,注意越界判断。

int CountMines(int x, int y) { int cnt = 0; for (int dy = -1; dy <= 1; dy++) { for (int dx = -1; dx <= 1; dx++) { int nx = x + dx, ny = y + dy; if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue; if (mineMap[ny][nx] == -1) cnt++; } } return cnt; }

这段代码看起来简单,实际就是整个扫雷引擎里被调用最多的函数之一。注意 continue 放在最前面,比在循环里套 if 嵌套要清爽得多。

2.3 第一次点击保护:两种实现方式

标准扫雷有个不成文的规定:第一次点击不能踩雷,而且经常要求第一次点击后周围一片打开。实现上有两种路线。第一种是先布雷,等玩家点击后,如果点到雷就把这颗雷挪到另一个空格,同时重新统计受影响的格子;第二种是不预先布雷,等玩家第一次点击结束后再布雷,并且把点击位置周围九宫格都排除在雷区之外。我推荐第二种,代码量少、逻辑直观,而且天然保证了首点附近不是雷。

具体做法就是在第一次点击时,先记录 firstX、firstY,再调用布雷函数。布雷函数里跳过所有满足 abs(x - firstX) <= 1 && abs(y - firstY) <= 1 的格子。这个方案唯一的“缺陷”是玩家首点前棋盘上没有雷,但玩家看不到任何信息,所以没有公平性问题。

3. 翻开与连锁展开:递归展开、边界防护和游戏状态流转

3.1 格子翻开的递归逻辑

扫雷最核心的体验是“点开一个空格,周围一大片空白跟着展开”。这个展开条件很明确:如果当前格子的数字是 0,说明它周围没有雷,于是对八个相邻格子递归执行同样的翻开操作。如果相邻格子也是 0,就继续向外扩散,直到遇到数字格子为止。

void Reveal(int x, int y) { if (x < 0 || x >= W || y < 0 || y >= H) return; if (revealed[y][x] || marked[y][x] == 1) return; revealed[y][x] = true; openedCount++; if (mineMap[y][x] == 0) { for (int dy = -1; dy <= 1; dy++) for (int dx = -1; dx <= 1; dx++) if (dx != 0 || dy != 0) Reveal(x + dx, y + dy); } }

这个递归在 30×30 的棋盘上不可能栈溢出,放心用。真正的边界陷阱是忘了判断已插旗的格子。很多扫雷源码里插旗的格子也能被左键点开,体验很奇怪。上面代码里 marked[y][x] == 1 就是防止翻开旗子。问号状态不影响翻开,因为玩家可能点错后重新标记。

3.2 打开数字格与快速翻开的可选增强

如果点开的格子数字是 1~8,递归立即停止,只翻开当前格。这一步对应“只能看到数字”的直觉。如果玩家左键点在一个已翻开的数字格上,并且周围插旗数量等于这个数字,标准扫雷会执行“快速翻开”操作:把周围未标记且未翻开的格子全部尝试翻开。这个功能很实用,相当于批量处理,缺点是如果你旗子插错了,会直接踩雷。作为源程序,我建议主体版本可以先不做,把基础翻开逻辑跑稳,后续再加。

3.3 胜负判定:不要用“是否还剩雷”来判断胜利

一个常见的错误是用“剩余雷数为 0”作为胜利条件,因为玩家可以在棋盘上乱插旗,把旗子插在非雷格上,雷数显示为 0,但游戏实际还没赢。正确做法是统计已正确翻开的非雷格子数,当这个数字等于 W * H - mineCount 时,意味着所有非雷格都已经翻开,玩家获胜。

所以游戏的状态机只有三种:等待开局、游戏中、结束。我建议用一个枚举类型保存,窗口过程里的鼠标和定时器消息都先检查状态,比如结束时左键点击不能翻开格子,定时器也不再累加。

4. 鼠标左右键与三种标记状态:交互细节和状态图设计

4.1 左键翻开、右键标记的分工

Windows 窗口过程里,WM_LBUTTONDOWN 和 WM_RBUTTONDOWN 分别处理左右键。把鼠标坐标转换成格子坐标时有一个容易被忽略的点:棋盘在客户区里不一定是左上角对齐,如果上面有剩余雷数显示面板和时间面板,棋盘区域要增加一个起始偏移,转换公式为 gx = (x - boardLeft) / cellSize。忘了偏移的话,点击边框和下方空白区域时会算出负数或超过边界的索引,轻则没反应,重则越界。

右键标记的状态转换是扫雷交互的精华。经典 Windows 扫雷中,未翻开的格子右键单击会在“无标记 → 旗子 → 问号 → 无标记”之间循环。我用一个 marked 数组保存状态,右键处理就一段 switch。

switch (marked[gy][gx]) { case 0: marked[gy][gx] = 1; break; case 1: marked[gy][gx] = 2; break; case 2: marked[gy][gx] = 0; break; }

这个状态图本身就是一个有限状态机,对应热词里那个“扫雷状态图”。画图时根据 marked 的值决定格子中央画红旗还是问号。

4.2 剩余雷数的显示与计数

左上角通常显示三位的剩余雷数,计算方式不是“雷总数减去翻开格数”,而是 mineCount - flagCount。插旗时 flagCount 加 1,取消旗子或把旗子变成问号时减 1,问号再变成无标记时不变。很多人直接把雷数显示做成固定数字,插旗后不减,那就失去提示意义了。

4.3 双击翻开的高级交互可先搁置

Windows 原版扫雷可以用左右键同时按下快速翻开数字周围格子,也就是 chord 操作。实现起来需要在 WM_LBUTTONDOWN 时检查当前格是否已翻开且数字与周围旗数相等,再触发一次 Reveal 周围的未标记格子。对初学者而言,这个逻辑容易和右键状态循环混在一起出错,我建议核心版本先不要加,等基础功能稳定后再补齐。这是我在实际改写源码时的取舍。

5. GDI 绘图、秒表计时与发布运行库配置:收尾还要避开的坑

5.1 GDI 绘制扫雷界面:凸起和凹陷的格子效果

格子效果可以用两对矩形线条模拟,标准按钮的凸起感是左上亮、右下暗,凹陷反之。简单方案是用 DrawEdge API 直接画 EDGE_RAISED 或 EDGE_SUNKEN 边框,代码量少,效果也很接近原版扫雷。翻开后是凹下去的效果,未翻开是凸起效果,每次刷新时根据 revealed 数组和 marked 数组决定画法。

数字颜色是扫雷最容易出错的地方。原版扫雷数字从 1 到 8 有固定配色,比如 1 蓝、2 绿、3 红、4 深蓝。绘制用 DrawText 在矩形中央输出,注意背景要先用 SolidBrush 填充灰色后 DrawText 才能正确显示透明背景。如果你发现数字边缘有黑块,多半是没用 SetBkMode(hdc, TRANSPARENT)。

刷新策略不要每个格子都 InvalidateRect。最简单可靠的做法是整块棋盘 Repaint:消息处理里把所有格子状态改完,最后统一 InvalidateRect(hwnd, NULL, TRUE),让系统合成一次重绘。对扫雷这种尺寸来说性能绰绰有余,逐格刷新反而容易闪烁。

5.2 计时器:从第一次点击后才开始走秒

秒表逻辑不复杂,但状态边界容易乱。我建议用 SetTimer 启动后,每收到一次 WM_TIMER 就让 gameTime 加 1。关键是启动时机:第一次点击左键或右键后才 SetTimer,在此之前计时器不启动,重置游戏时 KillTimer 并清零。窗口销毁时也别忘了 KillTimer。

case WM_TIMER: if (gameState == STATE_PLAYING) { gameTime++; InvalidateRect(hwnd, &timeRect, TRUE); } return 0;

位图双击缓冲不是必须的。用双缓冲可以消除格子刷新时的闪烁感,做法是创建内存 DC,先把所有格子画到内存 DC,再一次性 BitBlt 到窗口 DC。对扫雷这个刷新频率不算高的游戏,单缓冲也能接受,但如果你想分享源码给别人,双缓冲会显得更专业。

5.3 编译配置与发布:VC++ Redistributable、x86/x64 和运行库

写扫雷源码最烦的不是代码,而是换一台电脑别人的环境不对跑不起来。默认情况下 VS 工程使用 /MD 动态运行库,编译出来的 exe 依赖 msvcp140.dll、vcruntime140.dll。目标机器如果没有安装对应版本的 VC++ 2015-2022 Redistributable,就会弹出找不到 dll 的报错。热词里那个 vc++ redistributable 说的就是这回事。

想省去装运行库的麻烦,一个最直接的方法是在项目属性 → C/C++ → 代码生成 → 运行库里,把 Release 配置改成“多线程 /MT”,即静态链接。这样运行库代码会直接打进 exe,文件体积大一点,但目标机器上不用装任何依赖。Debug 配置建议继续保留 /MTd,不影响发布。

x86 和 x64 的选择也要考虑。旧扫雷源码大多是 32 位工程,新电脑装 64 位系统能跑,但要保证跑的是 x86 版本时,目标机器上装的是 x86 版运行库;如果你的程序是 x64 编译,就装 x64 版 VC++ Redistributable。最常见的 0xc000007b 错误,多半是程序位数和运行库版本不匹配导致的。

最后再分享一个实际操作中的经验:项目里尽量用 OutputDebugString 输出关键状态,比如布雷完成、翻开格子数、游戏状态切换,而不是满屏 MessageBox。扫雷是有窗口消息循环的程序,MessageBox 一弹,消息循环就阻塞了,你调试时很容易觉得程序“卡死”,其实是弹窗挡住了。OutputDebugString 写到 VS 输出窗口,不阻塞游戏,又能看到整个逻辑轨迹,排查边界问题会轻松很多。希望这篇内容能帮你看懂手上的扫雷源码,或者写出第一个属于自己的 Win32 小游戏。

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

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

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

立即咨询