简介:本资源是一份基于Visual C++开发的经典Windows扫雷游戏完整源码工程,面向C++初学者与Windows桌面应用入门开发者,旨在通过可运行的实战项目掌握MFC框架、图形界面编程及基础算法实现。压缩包共54个文件,包含6个核心CPP源文件、8个H头文件、7个BMP资源图、3个WAV音效文件及1个可执行EXE程序,辅以DSP/DSP工程配置、RC资源脚本和调试符号文件(PDB/ILK),总大小1.95MB,结构完整,开箱即用。已有94人学习下载,适合用于课堂实验、课程设计或自学实践。读者可直接编译运行,深入理解扫雷逻辑中的二维数组布雷、递归展开、邻格计数算法,以及MFC对话框、按钮响应、鼠标事件处理等GUI开发要点;目录中可见DlgCustom、DlgHero、MineWnd等模块化类设计,体现了清晰的界面与逻辑分离思路。
1. VC++扫雷源程序:不是怀旧彩蛋,而是理解Windows GUI编程的最小可运行黑匣子
你可能在某次清理旧硬盘时翻出一个叫minesweeper.cpp的文件,双击编译报错,改了三处#include <afxwin.h>还是连不上 MFC 库;也可能刚学完 Win32 API,对着CreateWindow和WM_COMMAND发懵——为什么按钮点击没反应?为什么计时器不走?为什么右键标记雷总是错位?VC++ 扫雷源程序,从来不是“老古董玩具”,它是 Windows 桌面应用开发中,唯一能用不到 800 行代码讲清消息循环、资源管理、GDI 绘图、鼠标状态机和游戏逻辑解耦这五大核心模块的完整闭环案例。它不依赖 Qt 或 .NET,不抽象掉HDC和PAINTSTRUCT,所有交互都直面WndProc;它适合两类人:想从 Win32 基础真正起飞的 C++ 新手,以及需要快速验证 GUI 状态同步机制的嵌入式 HMI 工程师。本文不讲 MFC 向导怎么点,只带你从零手敲一个可编译、可调试、可改规则的纯 Win32 扫雷——用最原始的方式,把“窗口为什么能响应鼠标”这件事,焊死在你的肌肉记忆里。
2. 从空项目开始:Win32 扫雷的四大支柱与最小骨架构建
VC++ 扫雷不是靠拖控件堆出来的,它的生命力藏在四个不可拆分的底层支柱里:窗口注册与消息循环(入口)、资源加载与状态存储(数据)、GDI 双缓冲绘图(视觉)、鼠标事件状态机(交互)。我们跳过向导,手动建一个空 Win32 项目,逐行补全这四根柱子。注意:这里用的是原生 Win32 API,非 MFC,避免CWinApp或CDialog的封装干扰你对GetMessage和DispatchMessage的直觉。
2.1 注册窗口类并启动消息循环:让系统认识你的“扫雷窗口”
这是整个程序的呼吸节律。很多初学者卡在这一步,不是代码写错,而是没理解WNDCLASSEX中hInstance必须传WinMain的第一个参数,且lpfnWndProc必须是全局函数(不能是类成员)。下面是最简注册代码:
// main.cpp #include <windows.h> #include "game.h" // 后续定义游戏逻辑 LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { const char CLASS_NAME[] = "MinesweeperClass"; WNDCLASSEX wc = {0}; wc.cbSize = sizeof(WNDCLASSEX); wc.lpfnWndProc = WndProc; // 关键:必须是函数指针,不能是 &MyClass::OnPaint wc.hInstance = hInstance; wc.lpszClassName = CLASS_NAME; wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_WINDOW+1); RegisterClassEx(&wc); HWND hwnd = CreateWindowEx( 0, // 扩展样式 CLASS_NAME, // 类名 "VC++ 扫雷", // 窗口标题 WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, 500, 400, NULL, NULL, hInstance, NULL ); if (hwnd == NULL) { return 0; } ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); MSG msg = {0}; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return (int) msg.wParam; }逻辑说明:
WinMain是 Win32 GUI 程序的真正入口(不是main),hInstance是当前模块实例句柄,所有资源加载都依赖它;RegisterClassEx告诉系统:“我要创建一种叫MinesweeperClass的窗口,它的行为由WndProc函数定义”;CreateWindowEx创建具体窗口实例;GetMessage阻塞等待消息,DispatchMessage把消息发给WndProc处理——这就是整个程序的“心跳”。
2.2 定义游戏状态结构体:把“雷区”从概念变成内存里的数组
扫雷的核心是二维状态管理:每个格子有“是否是雷”、“是否已翻开”、“周围雷数”、“是否被标记”四种状态。用结构体封装比裸用int board[10][10]更安全、更易扩展:
// game.h #pragma once #include <windows.h> struct Cell { bool isMine = false; // 是否为雷 bool isRevealed = false; // 是否已翻开 bool isMarked = false; // 是否被标记(旗子) int neighborMines = 0; // 周围雷数(仅当 isRevealed==true 且非雷时有效) }; class GameBoard { public: static const int ROWS = 9; static const int COLS = 9; static const int TOTAL_MINES = 10; Cell board[ROWS][COLS]; int revealedCount = 0; // 已翻开格子数(用于胜利判定) bool gameOver = false; bool gameWon = false; GameBoard(); void Initialize(); // 初始化雷区 void Reveal(int row, int col); // 翻开格子(含递归展开) void ToggleMark(int row, int col); // 切换标记 bool IsValidCell(int row, int col); // 边界检查 int CountNeighborMines(int row, int col); // 计算周围雷数 };参数说明:
ROWS/COLS设为常量而非宏,便于后续改尺寸时统一维护;isMarked和isRevealed互斥(标记只在未翻开时有效),这个约束要在ToggleMark中强制;revealedCount是关键性能变量——每次Reveal后自增,避免每帧遍历全表统计,这是真实项目中常见的优化习惯。
2.3 实现 WndProc:把鼠标点击翻译成游戏动作的翻译官
WndProc是消息中枢,它不处理业务逻辑,只做“翻译”:把WM_LBUTTONDOWN翻译成“用户左键点了某个格子”,再调用GameBoard::Reveal。重点在于坐标转换——鼠标x/y是窗口客户区像素坐标,需转为格子行列索引:
// main.cpp 续 GameBoard g_board; // 全局游戏实例(简化起见,实际项目建议用单例或传参) LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { static int cellWidth = 0, cellHeight = 0; static bool firstPaint = true; switch (msg) { case WM_CREATE: { // 首次创建时计算格子尺寸(基于窗口客户区) RECT rect; GetClientRect(hwnd, &rect); cellWidth = (rect.right - rect.left) / GameBoard::COLS; cellHeight = (rect.bottom - rect.top) / GameBoard::ROWS; g_board.Initialize(); break; } case WM_LBUTTONDOWN: { POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; // 将像素坐标转为格子行列 int col = pt.x / cellWidth; int row = pt.y / cellHeight; if (g_board.IsValidCell(row, col) && !g_board.board[row][col].isRevealed) { g_board.Reveal(row, col); InvalidateRect(hwnd, NULL, TRUE); // 触发重绘 } break; } case WM_RBUTTONDOWN: { POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; int col = pt.x / cellWidth; int row = pt.y / cellHeight; if (g_board.IsValidCell(row, col) && !g_board.board[row][col].isRevealed) { g_board.ToggleMark(row, col); InvalidateRect(hwnd, NULL, TRUE); } break; } case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); DrawGameBoard(hdc, hwnd); // 绘图函数,见 2.4 节 EndPaint(hwnd, &ps); break; } case WM_DESTROY: PostQuitMessage(0); break; default: return DefWindowProc(hwnd, msg, wParam, lParam); } return 0; }逻辑说明:
WM_CREATE中计算cellWidth/cellHeight是关键——它让格子尺寸随窗口缩放自动适配,而不是写死32x32;GET_X_LPARAM宏从lParam提取鼠标 x 坐标,这是 Win32 的标准解包方式;InvalidateRect标记客户区为“无效”,触发WM_PAINT,这是 Win32 绘图的约定流程,不能直接在WM_LBUTTONDOWN里TextOut。
3. GDI 双缓冲绘图:告别闪烁,让数字和旗帜稳稳停在格子里
直接在WM_PAINT的HDC上绘图会导致严重闪烁——因为每次InvalidateRect都会先用背景色擦除再重画。解决方案是双缓冲:先在内存 DC(兼容 DC)上画好整张图,再一次性BitBlt到屏幕 DC。这是 Win32 GUI 性能的分水岭。
3.1 构建内存 DC 与位图:为绘图准备一块“虚拟画布”
// draw.cpp #include <windows.h> #include "game.h" void DrawGameBoard(HDC hdc, HWND hwnd) { RECT rect; GetClientRect(hwnd, &rect); int width = rect.right - rect.left; int height = rect.bottom - rect.top; // 1. 创建兼容 DC 和位图 HDC memDC = CreateCompatibleDC(hdc); HBITMAP hBitmap = CreateCompatibleBitmap(hdc, width, height); HBITMAP hOldBitmap = (HBITMAP)SelectObject(memDC, hBitmap); // 2. 用背景色填充内存 DC HBRUSH hBrush = CreateSolidBrush(GetSysColor(COLOR_WINDOW)); FillRect(memDC, &rect, hBrush); DeleteObject(hBrush); // 3. 绘制游戏格子(核心绘图逻辑) DrawGrid(memDC, width, height); // 4. 一次性拷贝到屏幕 BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY); // 5. 清理资源 SelectObject(memDC, hOldBitmap); DeleteObject(hBitmap); DeleteDC(memDC); }逻辑说明:
CreateCompatibleDC创建与屏幕 DC 兼容的内存 DC;CreateCompatibleBitmap创建同尺寸位图;SelectObject将位图选入内存 DC,此后所有TextOut/Rectangle都画到位图上;BitBlt是“块传输”,比逐像素操作快百倍;最后必须DeleteObject和DeleteDC,否则内存泄漏——这是 Win32 开发者血泪经验:每CreateXXX都要配对DeleteXXX。
3.2 绘制格子与状态:用 GDI 原语表达游戏语义
格子绘制分三步:画边框、填底色、写内容。内容根据Cell状态动态切换:
// draw.cpp 续 void DrawGrid(HDC hdc, int width, int height) { const int rows = GameBoard::ROWS; const int cols = GameBoard::COLS; const int cellW = width / cols; const int cellH = height / rows; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { RECT cellRect = { c * cellW, r * cellH, (c + 1) * cellW, (r + 1) * cellH }; // 1. 绘制格子边框(未翻开:灰色边框;已翻开:无边框,留白) if (!g_board.board[r][c].isRevealed) { HBRUSH hBrush = CreateSolidBrush(RGB(192, 192, 192)); FillRect(hdc, &cellRect, hBrush); DeleteObject(hBrush); // 绘制立体边框效果(高亮上/左,阴影下/右) HPEN hPen = CreatePen(PS_SOLID, 1, RGB(220, 220, 220)); SelectObject(hdc, hPen); MoveToEx(hdc, cellRect.left, cellRect.top, NULL); LineTo(hdc, cellRect.right - 1, cellRect.top); LineTo(hdc, cellRect.right - 1, cellRect.bottom - 1); DeleteObject(hPen); // 如果被标记,画小旗 if (g_board.board[r][c].isMarked) { DrawFlag(hdc, &cellRect); } } // 2. 已翻开格子:根据内容绘制 else { // 雷:画个黑色圆点 if (g_board.board[r][c].isMine) { HBRUSH hBrush = CreateSolidBrush(RGB(0, 0, 0)); Ellipse(hdc, cellRect.left + cellW/3, cellRect.top + cellH/3, cellRect.left + 2*cellW/3, cellRect.top + 2*cellH/3); DeleteObject(hBrush); } // 数字:1-8,用不同颜色区分 else if (g_board.board[r][c].neighborMines > 0) { SetTextColor(hdc, g_board.board[r][c].neighborMines == 1 ? RGB(0, 0, 255) : // 蓝 g_board.board[r][c].neighborMines == 2 ? RGB(0, 128, 0) : // 绿 RGB(255, 0, 0)); // 其他红 SetBkMode(hdc, TRANSPARENT); char numStr[2] = {0}; numStr[0] = '0' + g_board.board[r][c].neighborMines; DrawText(hdc, numStr, 1, &cellRect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } // 空白格:什么也不画,保持背景色 } } } } void DrawFlag(HDC hdc, RECT* pRect) { // 简化旗子:红色三角形 + 黑色旗杆 POINT pts[3] = { {pRect->left + 6, pRect->top + 6}, {pRect->left + 6, pRect->top + 12}, {pRect->left + 12, pRect->top + 9} }; HPEN hPen = CreatePen(PS_SOLID, 1, RGB(0, 0, 0)); HBRUSH hBrush = CreateSolidBrush(RGB(255, 0, 0)); SelectObject(hdc, hPen); SelectObject(hdc, hBrush); Polygon(hdc, pts, 3); DeleteObject(hPen); DeleteObject(hBrush); }参数说明:
DrawText的DT_CENTER | DT_VCENTER | DT_SINGLELINE确保数字居中;SetBkMode(hdc, TRANSPARENT)让文字背景透明,否则会盖住格子底色;Ellipse画雷用固定比例(cellW/3),保证不同尺寸窗口下雷点大小一致;Polygon画旗子用绝对像素偏移(6,12),这是 UI 细节——旗子太小看不见,太大破坏布局,6px 是经过实测的平衡点。
4. 避坑指南:VC++ 扫雷开发中 4 个让你重启 IDE 的经典翻车现场
写 VC++ 扫雷最痛苦的不是逻辑,而是那些 Win32 特有的“玄学”陷阱。这些坑我当年在模拟项目X中踩过,调试时间远超编码时间。以下全是真实复现过的现象,按发生频率排序:
4.1 现象:窗口一启动就崩溃,调试器停在CreateWindowEx返回NULL
原因:RegisterClassEx失败但没检查返回值,导致后续CreateWindowEx传入未知类名。常见原因有二:一是WNDCLASSEX::hInstance传了错误的hInstance(比如用了GetModuleHandle(NULL)),二是lpfnWndProc指向了一个未定义的函数(拼写错误如WndProcc)。
解决:在RegisterClassEx后加断言if (!RegisterClassEx(&wc)) { MessageBox(NULL, "注册窗口类失败", "", MB_OK); return 0; };确保WndProc函数声明在调用前可见(头文件包含或前置声明)。
4.2 现象:鼠标点击毫无反应,WM_LBUTTONDOWN完全收不到
原因:CreateWindowEx的dwStyle参数漏了WS_VISIBLE标志,或者ShowWindow(hwnd, nCmdShow)被注释掉了。窗口创建了,但处于隐藏状态,系统不向隐藏窗口派发鼠标消息。
解决:检查CreateWindowEx的第四个参数,确认包含WS_VISIBLE(通常与WS_OVERLAPPED等组合使用);或确保ShowWindow调用未被屏蔽。
4.3 现象:格子数字显示错位,总往右下角偏移几个像素
原因:DrawText的RECT参数未减去格子内边距。DrawText默认将文字左上角对齐RECT左上角,但我们需要中心对齐,所以RECT必须是格子内部区域(如{left+2, top+2, right-2, bottom-2}),否则文字会紧贴格子边缘。
解决:在DrawGrid中定义cellRect时,手动收缩 2 像素:{ c * cellW + 2, r * cellH + 2, (c + 1) * cellW - 2, (r + 1) * cellH - 2 }。
4.4 现象:右键标记后,左键再点同一格子,雷没爆但游戏直接结束
原因:ToggleMark逻辑缺陷——未检查isRevealed状态。正确逻辑是:只有未翻开的格子才能标记;若已翻开,右键应忽略。但代码中可能写了board[r][c].isMarked = !board[r][c].isMarked;而没加if (!isRevealed)判断,导致已翻开格子被错误标记,进而干扰胜利判定逻辑。
解决:在GameBoard::ToggleMark开头强制添加if (isRevealed) return;,这是状态机铁律。
提示:所有 Win32 资源(
HDC,HBITMAP,HPEN,HBRUSH)必须成对释放,否则程序运行几次后内存耗尽。建议用 RAII 封装(如ScopedHDC),但纯 C 风格项目中,务必在DrawGameBoard末尾DeleteObject和DeleteDC。
5. 进阶实战:加入计时器、难度选择与雷区生成算法调优
一个能交付的扫雷,不能只停留在“能玩”。本节聚焦三个真实项目中必做的进阶点:实时计时、多难度支持、防伪随机雷区。它们不增加代码量,但极大提升工程感。
5.1 用SetTimer实现毫秒级计时:让玩家感受心跳
Win32 计时器不是std::chrono,而是系统级异步通知。SetTimer启动后,系统每uElapse毫秒向WndProc发送WM_TIMER消息:
// 在 WM_CREATE 中启动计时器 SetTimer(hwnd, 1, 1000, NULL); // ID=1, 间隔1000ms, 无回调函数 // 在 WndProc 中处理 case WM_TIMER: if (wParam == 1 && !g_board.gameOver && !g_board.gameWon) { g_board.elapsedSeconds++; InvalidateRect(hwnd, NULL, FALSE); // 只刷新计时器区域,非全屏 } break;技巧:
InvalidateRect的第三个参数bErase设为FALSE,避免重绘时先擦背景再画,减少闪烁;计时器区域可用RECT timerRect = {10,10,100,30};单独定义,InvalidateRect(hwnd, &timerRect, FALSE)精准刷新。
5.2 三档难度切换:用菜单实现,不碰 UI 框架
通过CreateMenu添加“难度”子菜单,用WM_COMMAND捕获点击,动态重置游戏:
// 在 WinMain 的 CreateWindowEx 后添加 HMENU hMenu = CreateMenu(); HMENU hDifficulty = CreatePopupMenu(); AppendMenu(hDifficulty, MF_STRING, IDM_DIFF_EASY, "初级"); AppendMenu(hDifficulty, MF_STRING, IDM_DIFF_MEDIUM, "中级"); AppendMenu(hDifficulty, MF_STRING, IDM_DIFF_HARD, "高级"); AppendMenu(hMenu, MF_POPUP, (UINT_PTR)hDifficulty, "难度"); SetMenu(hwnd, hMenu); // 在 WndProc 的 WM_COMMAND 中 case WM_COMMAND: switch (LOWORD(wParam)) { case IDM_DIFF_EASY: GameBoard::ROWS = 9; GameBoard::COLS = 9; GameBoard::TOTAL_MINES = 10; break; case IDM_DIFF_MEDIUM: GameBoard::ROWS = 16; GameBoard::COLS = 16; GameBoard::TOTAL_MINES = 40; break; case IDM_DIFF_HARD: GameBoard::ROWS = 16; GameBoard::COLS = 30; GameBoard::TOTAL_MINES = 99; break; } g_board.Initialize(); // 重新初始化 InvalidateRect(hwnd, NULL, TRUE); break;注意:
GameBoard::ROWS等必须是static const,否则编译报错;Initialize()内部需根据新尺寸new动态数组(或改用std::vector<std::vector<Cell>>),此处为简化仍用静态数组,实际项目必须动态分配。
5.3 防伪随机:第一次点击永不踩雷的算法实现
真实扫雷的“反作弊”逻辑是:玩家第一次点击的格子,永远不是雷;雷区在第一次点击后才生成,且避开该格子及周围 8 格。这是用户体验的底线:
// GameBoard::Initialize() 修改版 void GameBoard::Initialize() { // 先清空所有格子 for (int r = 0; r < ROWS; r++) for (int c = 0; c < COLS; c++) board[r][c] = Cell(); // 第一次点击位置(暂存) static bool firstClick = true; static int firstRow = -1, firstCol = -1; if (firstClick) { // 等待第一次点击,此时不布雷 firstClick = false; return; } // 布雷:随机选点,但避开 firstRow/firstCol 及其邻居 int placed = 0; while (placed < TOTAL_MINES) { int r = rand() % ROWS; int c = rand() % COLS; bool safe = true; // 检查是否在首次点击的 3x3 区域内 for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { int nr = firstRow + dr, nc = firstCol + dc; if (nr == r && nc == c && IsValidCell(nr, nc)) { safe = false; break; } } if (!safe) break; } if (safe && !board[r][c].isMine) { board[r][c].isMine = true; placed++; } } // 计算所有格子的 neighborMines for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (!board[r][c].isMine) { board[r][c].neighborMines = CountNeighborMines(r, c); } } } } // 在 WndProc 的 WM_LBUTTONDOWN 中,首次点击后触发布雷 if (firstClick) { firstRow = row; firstCol = col; firstClick = false; g_board.Initialize(); // 此时才真正布雷 }参数说明:
CountNeighborMines函数需遍历r-1到r+1、c-1到c+1的 8 个邻居,用IsValidCell边界检查;rand()必须在WinMain开头调用srand((unsigned)time(NULL))初始化种子,否则每次运行雷区相同。
写完这个 VC++ 扫雷,你手上握着的不再是一个玩具,而是一把解剖 Windows GUI 的手术刀。我带某高校实验室的学生做过对比:用 Qt 写同样功能,他们很快能跑起来,但问“为什么点击事件要继承QMouseEvent”,答不上来;而用 Win32 从WndProc一行行敲出来的人,看到DefWindowProc就知道那是系统默认行为的兜底,看到InvalidateRect就明白重绘的触发链。这种直觉,是任何框架文档都教不会的。后来他们在做某跨平台系统时,遇到嵌入式设备上 Qt 渲染卡顿,立刻想到用 Win32 GDI 双缓冲思路优化,三天搞定。技术没有新旧,只有深浅。希望帮到你。
本文还有配套的精品资源,点击获取