VC++扫雷源码实战:Win32 API与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::vectorstd::vectorCell 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 LMineSweeperWnd; 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::vectorstd::pairint,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::queuestd::pairint,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 语言标准至少 C11、链接器子系统是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 代码都强制走一遍“创建-使用-删除”的配对检查宁可多写两行清理代码也不留句柄泄漏的后悔药。希望这份拆解能帮到你拿到源码后先跑通再挑一个模块动手改比干看强得多。本文还有配套的精品资源点击获取