简介:这份VC++扫雷游戏源程序面向具备C++基础、希望深入Windows桌面开发与游戏逻辑实现的开发者。项目基于Visual C++与MFC构建,完整呈现经典扫雷的算法设计与交互界面,涵盖雷区初始化、点击判断、标记雷、邻接格子雷数计算等核心逻辑,并借助对话框与控件实现鼠标点击、右键菜单等事件驱动交互,是理解二维数组遍历、递归展开与内存管理的实战范例。资源包共54个文件,约1.95MB,包含8个h头文件、6个cpp源文件、7个bmp位图、3个wav音效、1个ico图标及rc资源脚本,另有sbr、obj、pdb等编译调试中间文件,便于直接编译运行与断点调试。目前已有94人学习下载。通过研读源码,读者可掌握MFC界面搭建、事件绑定、错误处理与调试技巧,适合作为课程设计、C++进阶练习或游戏开发入门的参考素材。1. 扫雷不只是小游戏:一份 VC++ 源码能让你摸清 Windows 桌面开发的骨架很多人第一次接触 Windows 桌面开发,都是从扫雷这类小游戏开始的。这份 VC++ 游戏源程序扫雷,就是一套完整的 Win32 窗口程序,用 C++ 写成,编译出来就是一个能直接玩的扫雷。它适合两类人:一类是刚学完 C++ 语法、想找个能跑起来的项目练手的新手;另一类是想回头补 Win32 API、GDI 绘图和消息循环的老手。别看扫雷规则简单,它把窗口注册、消息泵、鼠标事件、定时器、资源脚本这些桌面开发的基本功全串了一遍。你把这套源码跑通、改几个参数,比看十篇 GDI 教程都管用。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序,把这份源码拆开讲。2. 工程结构与技术选型:Win32 API + GDI 的经典组合2.1 为什么是 VC++ 而不是 MFC 或 Qt这份源码用的是原生 Win32 API,没有依赖 MFC 框架,也没有 Qt 那套信号槽。好处是编译出来的 exe 体积极小,通常几十 KB,而且不需要额外运行库。坏处是所有窗口控件都要自己画,按钮、数字、笑脸全是 GDI 绘制出来的。对于想理解 Windows 消息机制的人来说,这反而是优点——没有框架帮你藏东西,WndProc里每个WM_消息都得自己处理。常见做法是:用 Visual Studio 新建一个“Windows 桌面应用程序”空项目,然后把源码里的.cpp、.h、.rc文件加进去。如果你用的是较新版本的 VS,注意把字符集改成“使用多字节字符集”,因为老式 Win32 代码里大量用了char和sprintf,改成 Unicode 会报一堆错。2.2 目录里都有什么一份典型的扫雷源码包,解压后大概长这样:MineSweeper/ ├── MineSweeper.sln // VS 解决方案文件 ├── MineSweeper/ │ ├── main.cpp // WinMain 入口,窗口注册与消息循环 │ ├── GameCore.cpp // 雷区生成、翻开、标记、胜负判定 │ ├── GameCore.h │ ├── Render.cpp // GDI 绘制格子、数字、笑脸 │ ├── Render.h │ ├── resource.h // 资源 ID 定义 │ ├── MineSweeper.rc // 菜单、图标、对话框资源 │ └── res/ // 图标、位图等核心逻辑集中在GameCore.cpp,界面绘制在Render.cpp,入口和消息分发在main.cpp。这种拆分方式虽然简单,但已经具备了“逻辑与渲染分离”的雏形,改起来不会牵一发动全身。2.3 关键数据结构雷区通常用一个二维数组表示,每个格子用一个结构体或整型编码:// GameCore.h struct Cell { bool isMine; // 是否是雷 bool isRevealed; // 是否已翻开 bool isFlagged; // 是否插旗 int adjacentMines; // 周围雷数,0-8 }; class GameCore { public: void Init(int rows, int cols, int mineCount); // 初始化雷区 void Reveal(int r, int c); // 翻开格子 void ToggleFlag(int r, int c); // 插旗/取消 bool IsWin() const; // 胜利判定 bool IsLose() const; // 失败判定 const Cell& GetCell(int r, int c) const; private: std::vector<std::vector<Cell>> grid; int rows, cols, mines; bool gameOver; void PlaceMines(int safeR, int safeC); // 首次点击不踩雷 void FloodFill(int r, int c); // 空白区域扩散 };PlaceMines接收首次点击坐标,保证第一下不会炸,这是扫雷的标配逻辑。FloodFill用递归或队列实现,翻开一个空白格后自动展开周围区域。参数rows、cols、mineCount决定了难度,初级 9×9 10 雷,中级 16×16 40 雷,高级 16×30 99 雷,这些都可以在初始化时改。3. 核心模块拆解:从消息循环到 GDI 绘制3.1 WinMain 与消息泵Windows 程序的生命线是消息循环。WinMain里先注册窗口类,再创建窗口,然后进入GetMessage/TranslateMessage/DispatchMessage的循环:// main.cpp int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE, LPSTR, int nCmdShow) { WNDCLASS wc = {0}; wc.lpfnWndProc = WndProc; // 窗口过程函数 wc.hInstance = hInstance; wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1); wc.lpszClassName = L"MineSweeperWnd"; RegisterClass(&wc); HWND hwnd = CreateWindow(wc.lpszClassName, L"扫雷", WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, 400, 400, NULL, NULL, hInstance, NULL); ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return (int)msg.wParam; }WndProc是消息处理中心,鼠标点击、定时器、重绘都从这里进来。WM_LBUTTONDOWN处理左键翻开,WM_RBUTTONDOWN处理右键插旗,WM_TIMER更新计时器,WM_PAINT触发 GDI 绘制。每个消息的lParam里打包了鼠标坐标,用LOWORD和HIWORD取 x、y,再除以格子边长就能算出点的是哪一格。3.2 雷区生成与首次点击保护雷区生成不能一上来就随机撒雷,否则第一下就可能炸。正确做法是等玩家第一次点击后,再在非点击区域随机布雷:void GameCore::PlaceMines(int safeR, int safeC) { std::vector<std::pair<int,int>> candidates; for (int r = 0; r < rows; ++r) for (int c = 0; c < cols; ++c) if (r != safeR || c != safeC) // 排除首次点击格 candidates.push_back({r, c}); std::random_device rd; std::mt19937 gen(rd()); std::shuffle(candidates.begin(), candidates.end(), gen); for (int i = 0; i < mines && i < (int)candidates.size(); ++i) { auto [r, c] = candidates[i]; grid[r][c].isMine = true; } // 计算每个格子周围的雷数 for (int r = 0; r < rows; ++r) for (int c = 0; c < cols; ++c) grid[r][c].adjacentMines = CountAdjacentMines(r, c); }std::shuffle比rand()更均匀,避免某些格子雷密度异常。CountAdjacentMines遍历周围 8 个方向,边界检查不能少,否则数组越界直接崩。如果你想把“首次点击保护”扩展成“首次点击周围 8 格也安全”,只需在排除条件里多排除一圈即可。3.3 GDI 绘制:把格子画到窗口上WM_PAINT里用BeginPaint/EndPaint配对,中间用Rectangle、TextOut、DrawText画格子:// Render.cpp void Render::DrawBoard(HDC hdc, const GameCore& game) { int cellSize = 24; for (int r = 0; r < game.GetRows(); ++r) { for (int c = 0; c < game.GetCols(); ++c) { int x = c * cellSize; int y = r * cellSize; const Cell& cell = game.GetCell(r, c); // 画格子边框 Rectangle(hdc, x, y, x + cellSize, y + cellSize); if (cell.isRevealed) { if (cell.isMine) { // 画雷 Ellipse(hdc, x + 4, y + 4, x + cellSize - 4, y + cellSize - 4); } else if (cell.adjacentMines > 0) { // 画数字,不同数字用不同颜色 SetTextColor(hdc, GetNumberColor(cell.adjacentMines)); char buf[2] = { (char)('0' + cell.adjacentMines), 0 }; TextOutA(hdc, x + 7, y + 4, buf, 1); } } else if (cell.isFlagged) { // 画旗子 // ... } } } }cellSize决定格子大小,改这个值就能缩放整个界面。GetNumberColor返回 1 到 8 对应的颜色,经典扫雷里 1 是蓝色、2 是绿色、3 是红色,这些都可以按自己喜好调。注意TextOutA用的是多字节字符集,如果你项目设成了 Unicode,要改成TextOutW并传宽字符。3.4 递归展开与胜负判定翻开空白格时,如果周围没有雷,要自动展开相邻区域。递归实现简单,但雷区大时可能栈溢出,稳妥做法是用队列:void GameCore::FloodFill(int r, int c) { std::queue<std::pair<int,int>> q; q.push({r, c}); while (!q.empty()) { auto [cr, cc] = q.front(); q.pop(); if (cr < 0 || cr >= rows || cc < 0 || cc >= cols) continue; Cell& cell = grid[cr][cc]; if (cell.isRevealed || cell.isFlagged) continue; cell.isRevealed = true; if (cell.adjacentMines == 0 && !cell.isMine) { for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) if (dr != 0 || dc != 0) q.push({cr + dr, cc + dc}); } } }胜利判定是:所有非雷格子都被翻开。失败判定是:翻开了任意一个雷。这两个条件在每次Reveal后检查一次,更新gameOver标志,然后触发重绘。4. 编译运行与调试:从源码到可玩 exe4.1 用 Visual Studio 跑起来打开.sln文件,确认平台工具集和 Windows SDK 版本匹配。如果提示找不到windows.h,说明没装“使用 C++ 的桌面开发”工作负载。编译前检查三点:字符集设为多字节、C++ 语言标准至少 C++11、链接器子系统是WINDOWS。按 F5 运行,应该能看到一个窗口,左键点格子,右键插旗,点中雷就弹提示。4.2 常见编译错误与处理error C2440: “初始化”: 无法从“const char [x]”转换为“LPCWSTR”:字符集设成了 Unicode,改成多字节即可。error LNK2019: 无法解析的外部符号:通常是.rc文件没加进项目,或者资源 ID 对不上。窗口一闪而过:WinMain里消息循环写错,检查GetMessage返回值判断。4.3 调试技巧在WndProc里对WM_LBUTTONDOWN下断点,看lParam解出来的坐标对不对。如果点击没反应,先确认窗口过程有没有正确返回DefWindowProc。GDI 绘制出问题,用OutputDebugString打印格子状态,或者临时把cellSize调大,肉眼观察绘制区域。5. 避坑与常见问题:那些年我踩过的雷5.1 首次点击就炸现象:刚开局点第一下就踩雷。原因:雷区在初始化时就已经随机布好,没有排除首次点击格。解决:把布雷推迟到第一次左键点击后,用PlaceMines(safeR, safeC)排除点击格。5.2 递归展开导致栈溢出现象:点开一大片空白区域时程序崩溃。原因:FloodFill用递归实现,雷区大时递归深度超过栈限制。解决:改成队列或栈的迭代写法,如上文FloodFill所示。5.3 右键插旗后左键还能翻开现象:插了旗的格子左键点击仍然会翻开。原因:Reveal里没有检查isFlagged。解决:在Reveal开头加if (cell.isFlagged) return;,插旗的格子不允许翻开。5.4 计时器不更新或更新过快现象:计时器数字不动,或者一秒跳好几下。原因:SetTimer的时间间隔单位是毫秒,设成 1000 才是 1 秒;另外WM_TIMER里要累加计数并重绘。解决:确认SetTimer(hwnd, 1, 1000, NULL),在WM_TIMER里seconds++后InvalidateRect。5.5 窗口重绘闪烁现象:调整窗口大小或频繁重绘时界面闪烁。原因:每次WM_PAINT直接画到屏幕 DC。解决:用双缓冲——先画到内存 DC,再BitBlt到屏幕 DC。这是 GDI 绘图的老毛病,加上双缓冲后闪烁基本消失。6. 二次开发与改造思路:把扫雷改成你自己的项目这份源码最大的价值不是扫雷本身,而是它提供了一个可运行的 Win32 骨架。你可以在这个骨架上换皮、换逻辑、加功能。下面说几个我实际改过的方向。改难度和界面尺寸:在GameCore::Init里把rows、cols、mineCount改成变量,从菜单或对话框读取。Render::DrawBoard里的cellSize改成根据窗口宽度动态计算,就能实现自适应缩放。注意改完要重新计算窗口客户区大小,用AdjustWindowRect把边框和标题栏算进去。加双缓冲:在WM_PAINT里创建兼容 DC 和位图,所有绘制先画到内存位图,最后一次性BitBlt到窗口。代码大概长这样:case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); HDC memDC = CreateCompatibleDC(hdc); HBITMAP memBmp = CreateCompatibleBitmap(hdc, clientW, clientH); HBITMAP oldBmp = (HBITMAP)SelectObject(memDC, memBmp); // 所有绘制操作改用 memDC Render::DrawBoard(memDC, game); BitBlt(hdc, 0, 0, clientW, clientH, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, &ps); break; }clientW和clientH从GetClientRect获取。双缓冲加上后,窗口拖动和重绘都不会闪。加音效和动画:用PlaySound播放 wav 文件,在翻开、插旗、爆炸时触发。动画可以用定时器逐帧重绘,比如爆炸时让雷闪几下。注意音效文件要放进res/并在.rc里注册。改造成其他棋类游戏:扫雷的格子模型和点击逻辑,稍加改动就能变成翻转棋、数独或推箱子。核心是把Cell结构体换成你需要的状态字段,Reveal换成你的操作函数,Render换成你的绘制逻辑。消息循环和窗口框架完全不用动。验证改造是否成功:每次改完,先跑一遍完整对局——开局、翻开、插旗、胜利、失败,五个状态都走一遍。再故意点边界格子,看有没有数组越界。最后用_CrtSetDbgFlag开内存泄漏检测,确认没有 GDI 对象泄漏。GDI 对象泄漏是 Win32 程序的血泪坑,CreateCompatibleDC、CreateCompatibleBitmap用完必须DeleteObject和DeleteDC,否则跑久了句柄数爆掉,界面直接卡死。从那以后我每次改 GDI 代码,都强制走一遍“创建-使用-删除”的配对检查,宁可多写两行清理代码,也不留句柄泄漏的后悔药。希望这份拆解能帮到你,拿到源码后先跑通,再挑一个模块动手改,比干看强得多。本文还有配套的精品资源,点击获取
简介:这份VC++扫雷游戏源程序面向具备C++基础、希望深入Windows桌面开发与游戏逻辑实现的开发者。项目基于Visual C++与MFC构建,完整呈现经典扫雷的算法设计与交互界面,涵盖雷区初始化、点击判断、标记雷、邻接格子雷数计算等核心逻辑,并借助对话框与控件实现鼠标点击、右键菜单等事件驱动交互,是理解二维数组遍历、递归展开与内存管理的实战范例。资源包共54个文件,约1.95MB,包含8个h头文件、6个cpp源文件、7个bmp位图、3个wav音效、1个ico图标及rc资源脚本,另有sbr、obj、pdb等编译调试中间文件,便于直接编译运行与断点调试。目前已有94人学习下载。通过研读源码,读者可掌握MFC界面搭建、事件绑定、错误处理与调试技巧,适合作为课程设计、C++进阶练习或游戏开发入门的参考素材。1. 扫雷不只是小游戏:一份 VC++ 源码能让你摸清 Windows 桌面开发的骨架很多人第一次接触 Windows 桌面开发,都是从扫雷这类小游戏开始的。这份 VC++ 游戏源程序扫雷,就是一套完整的 Win32 窗口程序,用 C++ 写成,编译出来就是一个能直接玩的扫雷。它适合两类人:一类是刚学完 C++ 语法、想找个能跑起来的项目练手的新手;另一类是想回头补 Win32 API、GDI 绘图和消息循环的老手。别看扫雷规则简单,它把窗口注册、消息泵、鼠标事件、定时器、资源脚本这些桌面开发的基本功全串了一遍。你把这套源码跑通、改几个参数,比看十篇 GDI 教程都管用。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序,把这份源码拆开讲。2. 工程结构与技术选型:Win32 API + GDI 的经典组合2.1 为什么是 VC++ 而不是 MFC 或 Qt这份源码用的是原生 Win32 API,没有依赖 MFC 框架,也没有 Qt 那套信号槽。好处是编译出来的 exe 体积极小,通常几十 KB,而且不需要额外运行库。坏处是所有窗口控件都要自己画,按钮、数字、笑脸全是 GDI 绘制出来的。对于想理解 Windows 消息机制的人来说,这反而是优点——没有框架帮你藏东西,WndProc里每个WM_消息都得自己处理。常见做法是:用 Visual Studio 新建一个“Windows 桌面应用程序”空项目,然后把源码里的.cpp、.h、.rc文件加进去。如果你用的是较新版本的 VS,注意把字符集改成“使用多字节字符集”,因为老式 Win32 代码里大量用了char和sprintf,改成 Unicode 会报一堆错。2.2 目录里都有什么一份典型的扫雷源码包,解压后大概长这样:MineSweeper/ ├── MineSweeper.sln // VS 解决方案文件 ├── MineSweeper/ │ ├── main.cpp // WinMain 入口,窗口注册与消息循环 │ ├── GameCore.cpp // 雷区生成、翻开、标记、胜负判定 │ ├── GameCore.h │ ├── Render.cpp // GDI 绘制格子、数字、笑脸 │ ├── Render.h │ ├── resource.h // 资源 ID 定义 │ ├── MineSweeper.rc // 菜单、图标、对话框资源 │ └── res/ // 图标、位图等核心逻辑集中在GameCore.cpp,界面绘制在Render.cpp,入口和消息分发在main.cpp。这种拆分方式虽然简单,但已经具备了“逻辑与渲染分离”的雏形,改起来不会牵一发动全身。2.3 关键数据结构雷区通常用一个二维数组表示,每个格子用一个结构体或整型编码:// GameCore.h struct Cell { bool isMine; // 是否是雷 bool isRevealed; // 是否已翻开 bool isFlagged; // 是否插旗 int adjacentMines; // 周围雷数,0-8 }; class GameCore { public: void Init(int rows, int cols, int mineCount); // 初始化雷区 void Reveal(int r, int c); // 翻开格子 void ToggleFlag(int r, int c); // 插旗/取消 bool IsWin() const; // 胜利判定 bool IsLose() const; // 失败判定 const Cell& GetCell(int r, int c) const; private: std::vector<std::vector<Cell>> grid; int rows, cols, mines; bool gameOver; void PlaceMines(int safeR, int safeC); // 首次点击不踩雷 void FloodFill(int r, int c); // 空白区域扩散 };PlaceMines接收首次点击坐标,保证第一下不会炸,这是扫雷的标配逻辑。FloodFill用递归或队列实现,翻开一个空白格后自动展开周围区域。参数rows、cols、mineCount决定了难度,初级 9×9 10 雷,中级 16×16 40 雷,高级 16×30 99 雷,这些都可以在初始化时改。3. 核心模块拆解:从消息循环到 GDI 绘制3.1 WinMain 与消息泵Windows 程序的生命线是消息循环。WinMain里先注册窗口类,再创建窗口,然后进入GetMessage/TranslateMessage/DispatchMessage的循环:// main.cpp int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE, LPSTR, int nCmdShow) { WNDCLASS wc = {0}; wc.lpfnWndProc = WndProc; // 窗口过程函数 wc.hInstance = hInstance; wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1); wc.lpszClassName = L"MineSweeperWnd"; RegisterClass(&wc); HWND hwnd = CreateWindow(wc.lpszClassName, L"扫雷", WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, 400, 400, NULL, NULL, hInstance, NULL); ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return (int)msg.wParam; }WndProc是消息处理中心,鼠标点击、定时器、重绘都从这里进来。WM_LBUTTONDOWN处理左键翻开,WM_RBUTTONDOWN处理右键插旗,WM_TIMER更新计时器,WM_PAINT触发 GDI 绘制。每个消息的lParam里打包了鼠标坐标,用LOWORD和HIWORD取 x、y,再除以格子边长就能算出点的是哪一格。3.2 雷区生成与首次点击保护雷区生成不能一上来就随机撒雷,否则第一下就可能炸。正确做法是等玩家第一次点击后,再在非点击区域随机布雷:void GameCore::PlaceMines(int safeR, int safeC) { std::vector<std::pair<int,int>> candidates; for (int r = 0; r < rows; ++r) for (int c = 0; c < cols; ++c) if (r != safeR || c != safeC) // 排除首次点击格 candidates.push_back({r, c}); std::random_device rd; std::mt19937 gen(rd()); std::shuffle(candidates.begin(), candidates.end(), gen); for (int i = 0; i < mines && i < (int)candidates.size(); ++i) { auto [r, c] = candidates[i]; grid[r][c].isMine = true; } // 计算每个格子周围的雷数 for (int r = 0; r < rows; ++r) for (int c = 0; c < cols; ++c) grid[r][c].adjacentMines = CountAdjacentMines(r, c); }std::shuffle比rand()更均匀,避免某些格子雷密度异常。CountAdjacentMines遍历周围 8 个方向,边界检查不能少,否则数组越界直接崩。如果你想把“首次点击保护”扩展成“首次点击周围 8 格也安全”,只需在排除条件里多排除一圈即可。3.3 GDI 绘制:把格子画到窗口上WM_PAINT里用BeginPaint/EndPaint配对,中间用Rectangle、TextOut、DrawText画格子:// Render.cpp void Render::DrawBoard(HDC hdc, const GameCore& game) { int cellSize = 24; for (int r = 0; r < game.GetRows(); ++r) { for (int c = 0; c < game.GetCols(); ++c) { int x = c * cellSize; int y = r * cellSize; const Cell& cell = game.GetCell(r, c); // 画格子边框 Rectangle(hdc, x, y, x + cellSize, y + cellSize); if (cell.isRevealed) { if (cell.isMine) { // 画雷 Ellipse(hdc, x + 4, y + 4, x + cellSize - 4, y + cellSize - 4); } else if (cell.adjacentMines > 0) { // 画数字,不同数字用不同颜色 SetTextColor(hdc, GetNumberColor(cell.adjacentMines)); char buf[2] = { (char)('0' + cell.adjacentMines), 0 }; TextOutA(hdc, x + 7, y + 4, buf, 1); } } else if (cell.isFlagged) { // 画旗子 // ... } } } }cellSize决定格子大小,改这个值就能缩放整个界面。GetNumberColor返回 1 到 8 对应的颜色,经典扫雷里 1 是蓝色、2 是绿色、3 是红色,这些都可以按自己喜好调。注意TextOutA用的是多字节字符集,如果你项目设成了 Unicode,要改成TextOutW并传宽字符。3.4 递归展开与胜负判定翻开空白格时,如果周围没有雷,要自动展开相邻区域。递归实现简单,但雷区大时可能栈溢出,稳妥做法是用队列:void GameCore::FloodFill(int r, int c) { std::queue<std::pair<int,int>> q; q.push({r, c}); while (!q.empty()) { auto [cr, cc] = q.front(); q.pop(); if (cr < 0 || cr >= rows || cc < 0 || cc >= cols) continue; Cell& cell = grid[cr][cc]; if (cell.isRevealed || cell.isFlagged) continue; cell.isRevealed = true; if (cell.adjacentMines == 0 && !cell.isMine) { for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) if (dr != 0 || dc != 0) q.push({cr + dr, cc + dc}); } } }胜利判定是:所有非雷格子都被翻开。失败判定是:翻开了任意一个雷。这两个条件在每次Reveal后检查一次,更新gameOver标志,然后触发重绘。4. 编译运行与调试:从源码到可玩 exe4.1 用 Visual Studio 跑起来打开.sln文件,确认平台工具集和 Windows SDK 版本匹配。如果提示找不到windows.h,说明没装“使用 C++ 的桌面开发”工作负载。编译前检查三点:字符集设为多字节、C++ 语言标准至少 C++11、链接器子系统是WINDOWS。按 F5 运行,应该能看到一个窗口,左键点格子,右键插旗,点中雷就弹提示。4.2 常见编译错误与处理error C2440: “初始化”: 无法从“const char [x]”转换为“LPCWSTR”:字符集设成了 Unicode,改成多字节即可。error LNK2019: 无法解析的外部符号:通常是.rc文件没加进项目,或者资源 ID 对不上。窗口一闪而过:WinMain里消息循环写错,检查GetMessage返回值判断。4.3 调试技巧在WndProc里对WM_LBUTTONDOWN下断点,看lParam解出来的坐标对不对。如果点击没反应,先确认窗口过程有没有正确返回DefWindowProc。GDI 绘制出问题,用OutputDebugString打印格子状态,或者临时把cellSize调大,肉眼观察绘制区域。5. 避坑与常见问题:那些年我踩过的雷5.1 首次点击就炸现象:刚开局点第一下就踩雷。原因:雷区在初始化时就已经随机布好,没有排除首次点击格。解决:把布雷推迟到第一次左键点击后,用PlaceMines(safeR, safeC)排除点击格。5.2 递归展开导致栈溢出现象:点开一大片空白区域时程序崩溃。原因:FloodFill用递归实现,雷区大时递归深度超过栈限制。解决:改成队列或栈的迭代写法,如上文FloodFill所示。5.3 右键插旗后左键还能翻开现象:插了旗的格子左键点击仍然会翻开。原因:Reveal里没有检查isFlagged。解决:在Reveal开头加if (cell.isFlagged) return;,插旗的格子不允许翻开。5.4 计时器不更新或更新过快现象:计时器数字不动,或者一秒跳好几下。原因:SetTimer的时间间隔单位是毫秒,设成 1000 才是 1 秒;另外WM_TIMER里要累加计数并重绘。解决:确认SetTimer(hwnd, 1, 1000, NULL),在WM_TIMER里seconds++后InvalidateRect。5.5 窗口重绘闪烁现象:调整窗口大小或频繁重绘时界面闪烁。原因:每次WM_PAINT直接画到屏幕 DC。解决:用双缓冲——先画到内存 DC,再BitBlt到屏幕 DC。这是 GDI 绘图的老毛病,加上双缓冲后闪烁基本消失。6. 二次开发与改造思路:把扫雷改成你自己的项目这份源码最大的价值不是扫雷本身,而是它提供了一个可运行的 Win32 骨架。你可以在这个骨架上换皮、换逻辑、加功能。下面说几个我实际改过的方向。改难度和界面尺寸:在GameCore::Init里把rows、cols、mineCount改成变量,从菜单或对话框读取。Render::DrawBoard里的cellSize改成根据窗口宽度动态计算,就能实现自适应缩放。注意改完要重新计算窗口客户区大小,用AdjustWindowRect把边框和标题栏算进去。加双缓冲:在WM_PAINT里创建兼容 DC 和位图,所有绘制先画到内存位图,最后一次性BitBlt到窗口。代码大概长这样:case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); HDC memDC = CreateCompatibleDC(hdc); HBITMAP memBmp = CreateCompatibleBitmap(hdc, clientW, clientH); HBITMAP oldBmp = (HBITMAP)SelectObject(memDC, memBmp); // 所有绘制操作改用 memDC Render::DrawBoard(memDC, game); BitBlt(hdc, 0, 0, clientW, clientH, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, &ps); break; }clientW和clientH从GetClientRect获取。双缓冲加上后,窗口拖动和重绘都不会闪。加音效和动画:用PlaySound播放 wav 文件,在翻开、插旗、爆炸时触发。动画可以用定时器逐帧重绘,比如爆炸时让雷闪几下。注意音效文件要放进res/并在.rc里注册。改造成其他棋类游戏:扫雷的格子模型和点击逻辑,稍加改动就能变成翻转棋、数独或推箱子。核心是把Cell结构体换成你需要的状态字段,Reveal换成你的操作函数,Render换成你的绘制逻辑。消息循环和窗口框架完全不用动。验证改造是否成功:每次改完,先跑一遍完整对局——开局、翻开、插旗、胜利、失败,五个状态都走一遍。再故意点边界格子,看有没有数组越界。最后用_CrtSetDbgFlag开内存泄漏检测,确认没有 GDI 对象泄漏。GDI 对象泄漏是 Win32 程序的血泪坑,CreateCompatibleDC、CreateCompatibleBitmap用完必须DeleteObject和DeleteDC,否则跑久了句柄数爆掉,界面直接卡死。从那以后我每次改 GDI 代码,都强制走一遍“创建-使用-删除”的配对检查,宁可多写两行清理代码,也不留句柄泄漏的后悔药。希望这份拆解能帮到你,拿到源码后先跑通,再挑一个模块动手改,比干看强得多。本文还有配套的精品资源,点击获取