☰
VC++扫雷源码深度解析:Win32窗口开发与GDI绘图实战
2026/10/20 4:01:00 网站建设 项目流程

简介:这份VC++游戏源程序扫雷面向具备C++基础、希望深入Windows图形界面开发与游戏逻辑实现的编程学习者。项目基于Visual C++与MFC类库构建,完整呈现经典扫雷从雷区初始化、点击判断、标记雷到邻接雷数计算的核心算法,涉及二维数组遍历、递归展开与事件驱动编程等关键知识点,是理解C++实践应用与软件工程流程的典型案例。压缩包共54个文件,约1.95MB,包含cpp与h源码文件、bmp位图与ico图标、wav音效、rc资源脚本,以及sbr、obj、pdb等编译调试中间文件,另有dsw、dsp工程文件与exe可执行程序,结构完整可直接编译运行。目前已有94人学习关注。通过研读源码,读者可掌握MFC对话框与控件的事件绑定方式、鼠标交互响应逻辑,并借助调试信息理解程序运行流程与排错思路,适合作为课程设计、自学练手或游戏开发入门的参考素材。

1. 从一份 VC++ 扫雷源码说起:为什么它比想象中更值得拆

很多人第一次接触 Windows 桌面开发,都是从扫雷这类小游戏开始的。这份 VC++ 游戏源程序扫雷,核心价值不在于“扫雷”本身,而在于它是一份完整的、能编译运行的 Win32 窗口程序。它把窗口注册、消息循环、GDI 绘图、鼠标交互、递归展开这些桌面开发的基础链路全部串了一遍。你拿到的不是零散代码片段,而是一个能直接跑起来的工程。适合谁?适合刚学完 C++ 语法、想找一个“能看见界面”的项目练手的人;也适合想回头补 Win32 基础、理解消息驱动机制的老手。它体量不大,但五脏俱全,改起来不心疼,跑起来有反馈,是少有的“改一行就能看到变化”的练手素材。

2. 工程结构与技术选型:一个 Win32 程序的最小闭环

2.1 目录里都有什么

拿到源码包后,先别急着打开 .sln 或 .dsp 文件。我一般会先扫一遍目录,确认工程类型和依赖情况。这类 VC++ 扫雷项目通常包含以下内容:

文件/目录作用
MineSweeper.sln/.dsp工程文件,决定用哪个版本的 VC++ 打开
MineSweeper.cpp主程序入口,包含 WinMain 和窗口过程
resource.h资源 ID 定义,菜单、图标、对话框都靠它
MineSweeper.rc资源脚本,定义菜单项、加速键、图标
stdafx.h/pch.h预编译头,老版本 VC++ 常见
res/或根目录图标、位图等素材

如果你用的是 Visual Studio 2019 或 2022,打开旧版 .dsp 可能会提示升级。常见做法是直接新建一个“Windows 桌面应用程序”空项目,把 .cpp、.h、.rc 和资源文件拖进去,手动配置一遍。这样比让 IDE 自动升级更可控,也能顺便看清工程到底依赖了什么。

2.2 技术栈:纯 Win32 + GDI,没有 MFC

这份源码大概率是纯 Win32 API 写的,没有用 MFC 框架。判断方法很简单:看入口函数是WinMain还是CWinApp派生类。纯 Win32 的好处是依赖少、体积小、逻辑透明,坏处是什么都要自己写,窗口类注册、消息循环、绘图全得手动来。

核心依赖只有两个库:

// 链接 Windows 核心库和 GDI 绘图库 #pragma comment(lib, "user32.lib") // 窗口、消息、鼠标键盘输入 #pragma comment(lib, "gdi32.lib") // 画线、画矩形、填充、文字输出

user32.lib负责窗口创建、消息分发、鼠标事件捕获;gdi32.lib负责把所有格子、数字、雷区画到屏幕上。没有 DirectX,没有 OpenGL,就是最朴素的 GDI 双缓冲或直接绘制。这也意味着你不需要额外配置任何第三方库,装好 Visual Studio 的“使用 C++ 的桌面开发”工作负载就能编译。

2.3 程序启动链路:从 WinMain 到消息循环

Win32 程序的骨架非常固定,但每一步都有存在的理由。下面这段是典型入口:

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { // 1. 注册窗口类,指定窗口过程函数 WndProc WNDCLASSEX wc = { sizeof(WNDCLASSEX) }; wc.style = CS_HREDRAW | CS_VREDRAW; // 水平/垂直尺寸变化时重绘 wc.lpfnWndProc = WndProc; // 消息处理入口 wc.hInstance = hInstance; wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); wc.lpszClassName = L"MineSweeperClass"; RegisterClassEx(&wc); // 2. 创建主窗口,固定大小,禁止缩放 HWND hWnd = CreateWindowEx(0, L"MineSweeperClass", L"扫雷", WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, 400, 500, NULL, NULL, hInstance, NULL); // 3. 显示窗口,进入消息循环 ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); // 分发给 WndProc } return (int)msg.wParam; }

这里有几个参数值得注意。CS_HREDRAW | CS_VREDRAW保证窗口尺寸变化时整个客户区重绘,扫雷窗口一般固定大小,所以这个标志更多是保险。WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX去掉了最大化按钮,防止玩家把窗口拉大后格子错位。CreateWindowEx的宽高是包含边框的,实际客户区要减去标题栏和边框,后面绘图时要用GetClientRect重新算。

消息循环里的GetMessage在收到WM_QUIT时返回 0,循环结束。TranslateMessage把键盘按键转成字符消息,DispatchMessage把消息送到WndProc。整个程序的所有交互——鼠标点击、菜单选择、窗口重绘——都从这里分发出去。

2.4 窗口过程:所有逻辑的汇聚点

WndProc是消息驱动的核心,扫雷的所有行为都在这里分支处理:

LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: // 初始化游戏数据:分配雷区数组、随机布雷、重置状态 InitGame(hWnd); break; case WM_LBUTTONDOWN: // 左键:翻开格子 OnLeftClick(hWnd, LOWORD(lParam), HIWORD(lParam)); break; case WM_RBUTTONDOWN: // 右键:标记旗子 OnRightClick(hWnd, LOWORD(lParam), HIWORD(lParam)); break; case WM_PAINT: // 重绘:把所有格子画出来 OnPaint(hWnd); break; case WM_COMMAND: // 菜单:新游戏、难度选择、退出 OnMenuCommand(hWnd, LOWORD(wParam)); break; case WM_DESTROY: // 清理资源,发送退出消息 CleanUp(hWnd); PostQuitMessage(0); break; default: return DefWindowProc(hWnd, msg, wParam, lParam); } return 0; }

WM_LBUTTONDOWN和WM_RBUTTONDOWN里的lParam低 16 位是鼠标 x 坐标,高 16 位是 y 坐标。拿到坐标后要除以格子边长,算出是第几行第几列。WM_PAINT里必须调用BeginPaint和EndPaint,否则重绘会出问题。WM_COMMAND处理菜单点击,LOWORD(wParam)是菜单项 ID。WM_DESTROY里PostQuitMessage(0)会让消息循环退出,程序正常结束。

这套结构看起来简单,但它是所有 Windows 桌面程序的通用骨架。把扫雷换成记事本、画图、计算器,消息分支不同,骨架完全一样。所以拆这份源码,真正学到的是 Win32 程序的“最小闭环”。

3. 核心模块拆解:布雷、翻格与递归展开

3.1 数据结构:一维数组还是二维数组

扫雷的雷区数据怎么存,直接决定后面代码好不好写。常见做法是用一维数组模拟二维,或者直接用二维数组。这份源码大概率用的是二维数组,因为可读性更好:

#define MAX_ROWS 16 #define MAX_COLS 30 #define MINE_COUNT 99 struct Cell { bool isMine; // 是否是雷 bool isRevealed; // 是否已翻开 bool isFlagged; // 是否插旗 int adjacentMines; // 周围 8 格雷数 }; Cell g_board[MAX_ROWS][MAX_COLS]; int g_rows = 9, g_cols = 9, g_mineCount = 10; int g_revealedCount = 0; // 已翻开格子数,用于判断胜利 bool g_gameOver = false; bool g_win = false;

Cell结构体把每个格子的状态打包在一起,比用多个平行数组清晰得多。adjacentMines在布雷后统一计算一次,之后翻开时直接读,不用每次重新数。g_revealedCount用来判断胜利:当已翻开格子数等于总格子数减去雷数时,玩家赢。

注意:MAX_ROWS和MAX_COLS要按最大难度预留,否则切换难度时数组越界。常见做法是直接按最大棋盘分配,运行时只用前g_rows行和前g_cols列。

3.2 布雷算法:随机数怎么用才不出错

布雷看起来简单,但有两个坑:一是随机数种子,二是避免重复布雷。下面是常见写法:

void InitGame(HWND hWnd) { // 重置状态 g_gameOver = false; g_win = false; g_revealedCount = 0; memset(g_board, 0, sizeof(g_board)); // 用当前时间做种子,保证每局不同 srand((unsigned)time(NULL)); // 随机布雷,避免重复 int placed = 0; while (placed < g_mineCount) { int r = rand() % g_rows; int c = rand() % g_cols; if (!g_board[r][c].isMine) { g_board[r][c].isMine = true; placed++; } } // 计算每个格子周围的雷数 for (int r = 0; r < g_rows; r++) { for (int c = 0; c < g_cols; c++) { if (g_board[r][c].isMine) continue; int count = 0; for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { int nr = r + dr, nc = c + dc; if (nr >= 0 && nr < g_rows && nc >= 0 && nc < g_cols && g_board[nr][nc].isMine) count++; } } g_board[r][c].adjacentMines = count; } } InvalidateRect(hWnd, NULL, TRUE); // 触发重绘 }

srand((unsigned)time(NULL))用时间做种子,保证每次运行雷区不同。如果忘了这行,每次开局雷区一模一样,调试时容易误以为逻辑正确。布雷用while循环加isMine判断,避免同一格重复放雷。计算周围雷数时,双重循环遍历 8 个方向,边界检查不能少,否则数组越界。

InvalidateRect(hWnd, NULL, TRUE)把整个客户区标记为无效,系统会发WM_PAINT消息,触发重绘。第三个参数TRUE表示擦除背景,扫雷一般需要擦除,否则旧画面会残留。

3.3 翻开逻辑:递归展开的实现与边界

左键点击翻开格子,如果周围没有雷,要自动展开相邻区域。这是扫雷最核心的算法:

void RevealCell(int r, int c) { // 边界检查、已翻开、已插旗、游戏结束,都不处理 if (r < 0 || r >= g_rows || c < 0 || c >= g_cols) return; if (g_board[r][c].isRevealed || g_board[r][c].isFlagged) return; if (g_gameOver) return; g_board[r][c].isRevealed = true; g_revealedCount++; // 踩雷,游戏结束 if (g_board[r][c].isMine) { g_gameOver = true; return; } // 周围无雷,递归展开 8 个邻居 if (g_board[r][c].adjacentMines == 0) { for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; RevealCell(r + dr, c + dc); } } } }

递归展开的终止条件有三个:越界、已翻开、已插旗。少任何一个都会导致死循环或重复计数。g_revealedCount在每次翻开时自增,用于后面判断胜利。踩雷后设置g_gameOver = true,但不立即返回,让调用方决定是否弹出失败提示。

递归深度在最大棋盘 16×30 时最多 480 层,现代编译器默认栈大小足够,不会溢出。但如果改成更大的棋盘,比如 100×100,就要考虑改成迭代加队列,避免栈溢出。这是常见的改造点。

3.4 胜利判断与重绘

胜利判断放在每次翻开之后:

void CheckWin(HWND hWnd) { int totalCells = g_rows * g_cols; if (g_revealedCount == totalCells - g_mineCount) { g_win = true; g_gameOver = true; MessageBox(hWnd, L"你赢了!", L"扫雷", MB_OK | MB_ICONINFORMATION); } }

总格子数减去雷数就是需要翻开的格子数。当g_revealedCount达到这个值,玩家胜利。MessageBox弹出提示后,游戏状态锁定,后续点击不再响应。

重绘函数OnPaint负责把所有格子画出来:

void OnPaint(HWND hWnd) { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 双缓冲,避免闪烁 HDC memDC = CreateCompatibleDC(hdc); HBITMAP memBmp = CreateCompatibleBitmap(hdc, g_cols * CELL_SIZE, g_rows * CELL_SIZE); HBITMAP oldBmp = (HBITMAP)SelectObject(memDC, memBmp); // 逐格绘制 for (int r = 0; r < g_rows; r++) { for (int c = 0; c < g_cols; c++) { int x = c * CELL_SIZE; int y = r * CELL_SIZE; DrawCell(memDC, r, c, x, y); } } // 一次性贴到屏幕 BitBlt(hdc, 0, 0, g_cols * CELL_SIZE, g_rows * CELL_SIZE, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hWnd, &ps); }

双缓冲是 GDI 绘图防闪烁的标准做法。先在内存 DC 上画好整张图,再BitBlt到屏幕 DC。如果不做双缓冲,每次重绘都会看到格子逐个出现,体验很差。DrawCell根据格子状态画不同内容:未翻开画凸起矩形,已翻开画凹陷矩形,雷画黑色圆,数字画对应颜色文字。

4. 编译运行与调试:从源码到可玩程序的完整步骤

4.1 环境准备与工程打开

这份源码是 VC++ 工程,最稳妥的编译环境是 Visual Studio 2019 或 2022,安装时勾选“使用 C++ 的桌面开发”工作负载。如果你只有 VS Code,需要自己配 MinGW 或 MSVC 命令行,但资源脚本.rc的处理会比较麻烦,不推荐新手走这条路。

打开方式有两种:一是直接双击.sln,让 VS 自动升级;二是新建空项目,手动添加文件。我一般选第二种,因为自动升级有时会改乱工程配置,尤其是字符集和运行库设置。手动添加时注意:

  • 字符集选“使用 Unicode 字符集”,因为源码里字符串是L"..."宽字符。
  • 运行库选“多线程调试 (/MTd)”或“多线程 (/MT)”,避免依赖 VC 运行库 DLL。
  • 如果源码用了预编译头stdafx.h,在项目属性里开启预编译头,或者直接把#include "stdafx.h"注释掉。

4.2 常见编译错误与修复

旧版 VC++ 源码在新编译器上最容易出这几类错误:

错误一:fopen、sprintf等函数被标记为不安全。解决方法是加宏定义:

#define _CRT_SECURE_NO_WARNINGS

放在所有#include之前,或者直接在项目属性里加预处理器定义。

错误二:LOWORD、HIWORD未定义。需要包含windows.h,如果已经包含还报错,检查是否被其他头文件覆盖了宏定义。

错误三:资源文件编译失败。.rc文件里引用的图标、位图路径不对,或者resource.h里的 ID 冲突。解决方法是检查资源视图里每个资源的路径,确保文件真实存在。

错误四:链接错误unresolved external symbol。通常是少了user32.lib或gdi32.lib,在代码里加#pragma comment(lib, ...)或在项目属性里添加依赖库。

4.3 调试技巧:怎么快速定位逻辑问题

扫雷的逻辑 bug 大多集中在布雷和递归展开。调试时我一般用这几个手段:

第一,在InitGame里加日志,把雷区打印到控制台。用OutputDebugString输出到 VS 的输出窗口,不干扰图形界面:

// 调试用:把雷区打印到输出窗口 for (int r = 0; r < g_rows; r++) { CString line; for (int c = 0; c < g_cols; c++) line += g_board[r][c].isMine ? L"*" : L"."; OutputDebugString(line + L"\n"); }

第二,把CELL_SIZE改大,比如从 20 改成 40,方便肉眼观察格子状态。第三,在RevealCell入口加断点,观察递归调用栈,确认展开范围是否符合预期。第四,临时把雷数改成 1,手动测试踩雷和胜利两条路径。

提示:调试递归展开时,如果发现展开范围异常,先检查边界条件r < 0 || r >= g_rows是否写反,这是最常见的翻车点。

4.4 运行效果与可玩性验证

编译成功后,程序应该能正常显示一个固定大小的扫雷窗口。验证清单:

  • 左键点击格子能翻开,数字颜色正确。
  • 右键点击能插旗和取消旗子。
  • 点到雷会弹出失败提示,所有雷显示出来。
  • 翻开所有非雷格子会弹出胜利提示。
  • 菜单里的“新游戏”能重置棋盘,难度切换能改变行列数和雷数。

如果某一条不满足,回到对应模块排查。比如右键没反应,检查WM_RBUTTONDOWN分支是否被WM_CONTEXTMENU拦截了。Windows 默认会把右键转成上下文菜单消息,需要在WndProc里处理WM_CONTEXTMENU并直接返回,或者用WM_RBUTTONUP代替。

5. 避坑与常见问题:那些年我踩过的扫雷源码坑

5.1 现象:窗口一闪而过,程序直接退出

原因:WinMain里消息循环写成了while (msg.message != WM_QUIT),但GetMessage返回 0 时msg.message不一定是WM_QUIT,导致循环条件判断错误。或者CreateWindowEx返回 NULL,没有检查就直接ShowWindow。

解决:用while (GetMessage(&msg, NULL, 0, 0))作为循环条件,并在CreateWindowEx后加if (!hWnd) return 0;。另外检查窗口类是否注册成功,RegisterClassEx返回 0 时要用GetLastError看错误码。

5.2 现象:格子画出来了,但点击位置对不上

原因:鼠标坐标是客户区坐标,但绘图时没有考虑客户区左上角偏移,或者CELL_SIZE和实际绘制尺寸不一致。常见的是CreateWindowEx的宽高包含了标题栏和边框,而绘图从 (0,0) 开始,导致整体偏移。

解决:用GetClientRect获取实际客户区大小,所有绘图和点击计算都基于客户区坐标。点击时用(x - offsetX) / CELL_SIZE算列号,(y - offsetY) / CELL_SIZE算行号,offsetX和offsetY是棋盘在客户区里的起始偏移。

5.3 现象:递归展开导致程序卡死或栈溢出

原因:RevealCell没有正确检查isRevealed,导致同一格子被反复递归。或者边界检查写错,递归进入无效区域后继续扩散。

解决:在RevealCell入口第一行就检查if (g_board[r][c].isRevealed) return;,并在设置isRevealed = true之后再递归。边界检查用r < 0 || r >= g_rows || c < 0 || c >= g_cols,四个条件缺一不可。如果棋盘很大,改成队列迭代:

void RevealCellIterative(int startR, int startC) { std::queue<std::pair<int,int>> q; q.push({startR, startC}); while (!q.empty()) { auto [r, c] = q.front(); q.pop(); if (r < 0 || r >= g_rows || c < 0 || c >= g_cols) continue; if (g_board[r][c].isRevealed || g_board[r][c].isFlagged) continue; g_board[r][c].isRevealed = true; g_revealedCount++; if (g_board[r][c].isMine) { g_gameOver = true; continue; } if (g_board[r][c].adjacentMines == 0) { for (int dr = -1; dr <= 1; dr++) for (int dc = -1; dc <= 1; dc++) if (dr || dc) q.push({r + dr, c + dc}); } } }

5.4 现象:右键插旗后,左键点击仍然能翻开

原因:RevealCell里只检查了isRevealed,没有检查isFlagged。或者右键消息被系统拦截,旗子状态根本没设置成功。

解决:在RevealCell入口加if (g_board[r][c].isFlagged) return;。右键处理用WM_RBUTTONUP而不是WM_RBUTTONDOWN,并在WndProc里处理WM_CONTEXTMENU直接返回 0,防止系统弹出上下文菜单。

5.5 现象:切换难度后棋盘错乱,旧数据残留

原因:切换难度时只改了g_rows和g_cols,没有重新初始化整个g_board数组,导致旧雷区数据残留。或者InvalidateRect没有调用,界面没刷新。

解决:难度切换时调用InitGame重新初始化,memset清零整个数组,重新布雷。然后调用InvalidateRect(hWnd, NULL, TRUE)强制重绘。如果窗口大小需要变化,还要调用SetWindowPos调整窗口尺寸。

6. 二次开发改造:从能跑到好用,我一般这么改

这份源码跑通之后,真正的乐趣在于改。我拿到任何一份扫雷源码,第一件事不是加功能,而是把“计时器”和“剩余雷数”补上。原版通常只有棋盘,没有这两个 UI 元素,玩起来没有压力感。加计时器很简单:在WM_CREATE里SetTimer(hWnd, 1, 1000, NULL),在WM_TIMER里累加秒数并刷新显示区域。剩余雷数就是g_mineCount减去已插旗数量,每次右键后更新。

第二件事是加“首次点击不踩雷”。原版布雷在WM_CREATE就完成了,第一次点击可能直接踩雷。改法是把布雷推迟到第一次左键点击时,并且在布雷时排除点击位置及其周围 8 格。这样第一下必定展开一片区域,体验好很多。代码改动集中在OnLeftClick:

void OnLeftClick(HWND hWnd, int x, int y) { int c = x / CELL_SIZE; int r = y / CELL_SIZE; if (r < 0 || r >= g_rows || c < 0 || c >= g_cols) return; // 首次点击,延迟布雷,排除点击位置周围 if (!g_minePlaced) { PlaceMinesExcluding(r, c); g_minePlaced = true; } RevealCell(r, c); CheckWin(hWnd); InvalidateRect(hWnd, NULL, FALSE); }

PlaceMinesExcluding在布雷时跳过以(r,c)为中心的 3×3 区域,保证第一下不会踩雷,也不会出现“点一下直接结束”的尴尬。

第三件事是加“双击快速展开”。当某个已翻开数字格周围旗子数等于该数字时,双击可以一次性翻开周围未插旗的格子。这是扫雷高手最常用的操作,原版没有。实现思路是在WM_LBUTTONDBLCLK里判断当前格子isRevealed且adjacentMines > 0,然后统计周围旗子数,如果相等就遍历周围未插旗格子调用RevealCell。注意要在窗口类里加CS_DBLCLKS样式,否则收不到双击消息。

第四件事是换皮肤。GDI 绘图的好处是所有颜色和形状都在DrawCell里,改起来非常直接。我一般会把格子画成圆角矩形,数字用更粗的字体,雷区用自定义位图代替黑色圆。如果要支持主题切换,把颜色值抽成全局变量,菜单里加几个预设方案,切换后InvalidateRect即可。

最后说一个我踩过的坑:改递归为迭代之后,忘了在迭代版本里检查isFlagged,导致插旗的格子被展开,玩家直接看到雷。从那以后我每次改RevealCell相关逻辑,都强制走一遍“插旗后点击、双击展开、边界格子、全盘展开”四个用例,确认状态判断没有遗漏。这份源码不大,但每个分支都值得亲手改一遍,改完你对 Win32 消息驱动和 GDI 绘图的理解会扎实很多。希望帮到你。

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

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

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

立即咨询