多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于MFC实现扫雷游戏:从零手搓经典桌面开发项目

基于MFC实现扫雷游戏:从零手搓经典桌面开发项目 简介这是一份基于MFC框架实现的扫雷游戏完整源码工程面向正在学习Windows桌面开发、C面向对象编程以及MFC文档视图架构的初学者与进阶者。项目复刻了经典扫雷的核心玩法鼠标点击即可完成翻开格子、标记雷区等操作界面简洁明了代码结构清晰适合作为课程设计、毕业设计参考或MFC入门练手项目。压缩包共45个文件约2.85MB包含17个bmp位图素材、7个头文件与6个cpp源文件另有ico图标、rc资源脚本、dsp与dsw工程文件及可执行程序覆盖从界面资源到逻辑实现的完整链路。资源包内还附带ReadMe说明与LICENSE授权文件方便快速了解工程组织方式。目前已有315人学习下载读者可借此掌握MFC消息映射、对话框与视图交互、资源加载等关键知识点并在此基础上自行扩展难度设置、计时与排行榜等功能。1. 从零手搓一个 MFC 扫雷为什么它至今仍是桌面开发的试金石很多人第一次接触 MFC都是从扫雷、计算器这类小工具开始的。标题里的「基于 MFC 实现的扫雷游戏」说白了就是用 Visual Studio 里的 MFC 框架把 Windows 自带那个经典扫雷重新写一遍。它看着简单实际把 MFC 四大类CWinApp、CFrameWnd、CView、CDocument、消息映射、GDI 绘图、定时器、鼠标交互全串了一遍是检验你懂不懂 Windows 消息驱动机制的绝佳练手项目。适合刚学完 C 语法、想搞明白「窗口到底怎么响应点击」的新手也适合多年没碰 MFC、想找回手感的老手。下面我按自己复现过的路径把选型、绘图、布雷、递归展开、双缓冲、踩坑一条条讲清楚你照着能跑出一个能玩、不闪、逻辑正确的扫雷。2. 选对话框还是单文档MFC 扫雷的工程骨架怎么搭2.1 两种工程模板的取舍与消息映射差异MFC 新建项目时Visual Studio 会给你几个选项单文档SDI、多文档MDI、基于对话框Dialog based。扫雷这种只有一个固定大小窗口、没有菜单栏文档概念的小游戏我一般直接选「基于对话框」。原因很实在对话框模板自带资源编辑器你拖一个静态框就能当棋盘容器控件 ID 直接映射消息省掉 CView 里那一套 OnDraw 和文档/视图分离的绕路。单文档当然也能做但你要自己处理框架窗口的客户区坐标、滚动条、菜单对扫雷来说是过度设计。选对话框后工程里会自动生成三个核心文件MineSweeperApp.cppCWinApp 派生类程序入口、MineSweeperDlg.cppCDialogEx 派生类主窗口逻辑、resource.h资源 ID。MFC 四大类在这里体现为 CWinApp 负责启动消息循环CDialogEx 负责窗口和控件。消息映射是 MFC 的命脉你右键对话框类 → 属性 → 消息能看到WM_LBUTTONDOWN、WM_RBUTTONDOWN、WM_TIMER、WM_PAINT这些。扫雷全靠它们左键挖开、右键插旗、定时器走秒、重绘刷新。提示如果你用的是较新的 Visual Studio安装时记得勾选「使用 C 的桌面开发」里的 MFC 组件否则新建项目里根本找不到 MFC 模板。离线环境可以用 VS 安装器里的「单个组件」搜 MFC 补装。2.2 棋盘数据结构与初始化代码棋盘我习惯用一个二维数组存状态再加一个二维数组存「是否已挖开」。格子状态用枚举0 表示空1-8 表示周围雷数9 表示雷。另外用bool数组标记是否插旗、是否翻开。这样逻辑清晰绘图时按状态取不同颜色或位图。// MineSweeperDlg.h 里定义 enum CellState { EMPTY 0, MINE 9 }; const int ROWS 16; const int COLS 16; const int MINE_COUNT 40; const int CELL_SIZE 24; // 每格像素 int m_board[ROWS][COLS]; // 雷数或雷标记 bool m_revealed[ROWS][COLS]; // 是否已翻开 bool m_flagged[ROWS][COLS]; // 是否插旗 bool m_gameOver; int m_remainingMines;初始化时先把所有格子清零再用随机数布雷。布雷要避免重复我一般用「洗牌」思路生成一个 0 到 ROWS*COLS-1 的序列随机打乱取前 MINE_COUNT 个作为雷的位置。这样不会死循环分布也均匀。void CMineSweeperDlg::InitBoard() { srand((unsigned)time(nullptr)); memset(m_board, 0, sizeof(m_board)); memset(m_revealed, 0, sizeof(m_revealed)); memset(m_flagged, 0, sizeof(m_flagged)); m_gameOver false; m_remainingMines MINE_COUNT; // 洗牌法布雷 std::vectorint pos(ROWS * COLS); for (int i 0; i ROWS * COLS; i) pos[i] i; std::random_shuffle(pos.begin(), pos.end()); for (int i 0; i MINE_COUNT; i) { int r pos[i] / COLS; int c pos[i] % COLS; m_board[r][c] MINE; } // 计算每格周围雷数 for (int r 0; r ROWS; r) for (int c 0; c COLS; c) { if (m_board[r][c] MINE) continue; int cnt 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 ROWS nc 0 nc COLS m_board[nr][nc] MINE) cnt; } m_board[r][c] cnt; } }这段代码里random_shuffle在 C17 后已被弃用可以换成std::shuffle配std::mt19937但老项目里random_shuffle照样能跑。参数ROWS、COLS、MINE_COUNT就是难度初级 9x9 10 雷中级 16x16 40 雷高级 16x30 99 雷你改这三个常量就能切换难度。注意布雷后立刻算雷数别等到点击时才算否则递归展开会反复计算性能差。3. 把格子画到对话框上GDI 绘图与双缓冲消闪3.1 OnPaint 里用 CDC 绘制棋盘MFC 里绘图靠CDC类在OnPaint里拿CPaintDC dc(this)然后按格子坐标画矩形、画数字、画旗子。最朴素的做法是每个格子dc.Rectangle()画边框再dc.TextOut()写数字。但这样有个问题窗口一刷新就闪鼠标点一下整个棋盘重绘肉眼可见的闪烁。这就是新手第一个翻车点。void CMineSweeperDlg::OnPaint() { CPaintDC dc(this); for (int r 0; r ROWS; r) for (int c 0; c COLS; c) { CRect rect(c * CELL_SIZE, r * CELL_SIZE, (c 1) * CELL_SIZE, (r 1) * CELL_SIZE); if (m_revealed[r][c]) { dc.FillSolidRect(rect, RGB(220, 220, 220)); if (m_board[r][c] 0 m_board[r][c] MINE) { CString s; s.Format(_T(%d), m_board[r][c]); dc.SetTextColor(GetNumberColor(m_board[r][c])); dc.DrawText(s, rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } else if (m_board[r][c] MINE) { dc.FillSolidRect(rect, RGB(255, 0, 0)); } } else { dc.FillSolidRect(rect, RGB(192, 192, 192)); dc.Draw3dRect(rect, RGB(255, 255, 255), RGB(128, 128, 128)); if (m_flagged[r][c]) { // 画旗子简单用红色三角形代替 CPoint pts[3] { CPoint(rect.left 6, rect.top 4), CPoint(rect.left 6, rect.bottom - 4), CPoint(rect.right - 4, rect.top 10) }; CBrush brush(RGB(255, 0, 0)); CBrush* old dc.SelectObject(brush); dc.Polygon(pts, 3); dc.SelectObject(old); } } } }GetNumberColor是个辅助函数按 1 到 8 返回经典扫雷的蓝、绿、红、深蓝、棕、青、黑、灰。Draw3dRect画出凸起效果比单纯Rectangle好看。注意CPaintDC只能在OnPaint里用别在别处new一个否则析构时EndPaint会出问题。3.2 双缓冲用内存 DC 彻底干掉闪烁要消闪标准做法是双缓冲先在内存 DC 里把整个棋盘画好再一次性BitBlt到屏幕 DC。这样屏幕只刷新一次肉眼看不到中间过程。代码结构改成在OnPaint里创建兼容 DC 和位图画完再拷贝。void CMineSweeperDlg::OnPaint() { CPaintDC dc(this); CRect clientRect; GetClientRect(clientRect); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, clientRect.Width(), clientRect.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 背景 memDC.FillSolidRect(clientRect, RGB(192, 192, 192)); // 这里调用 DrawBoard(memDC) 画所有格子 DrawBoard(memDC); dc.BitBlt(0, 0, clientRect.Width(), clientRect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }DrawBoard就是把上面那段循环抽出来参数改成CDC* pDC。双缓冲的代价是每次重绘多一张位图的内存16x16 的棋盘也就几十 KB完全可接受。注意位图要CreateCompatibleBitmap而不是CreateBitmap否则颜色会失真。还有memDC和bmp都是局部变量析构自动释放别手动DeleteObject造成二次释放。注意如果你在OnPaint之外比如鼠标消息里想立即刷新用Invalidate()标记重绘区域再UpdateWindow()强制立刻重绘。别直接调OnPaint那会绕过消息机制。4. 左键挖开、右键插旗鼠标消息与递归展开的实现4.1 坐标换算与左右键分支鼠标消息OnLButtonDown和OnRButtonDown里point是客户区坐标要除以CELL_SIZE换算成行列。注意边界判断别让点击落在棋盘外还去访问数组那是典型的越界翻车。void CMineSweeperDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_gameOver) return; int c point.x / CELL_SIZE; int r point.y / CELL_SIZE; if (r 0 || r ROWS || c 0 || c COLS) return; if (m_flagged[r][c] || m_revealed[r][c]) return; if (m_board[r][c] MINE) { m_gameOver true; RevealAllMines(); MessageBox(_T(踩雷了), _T(游戏结束), MB_OK); } else { RevealCell(r, c); } Invalidate(); CDialogEx::OnLButtonDown(nFlags, point); }右键插旗逻辑类似只是把m_flagged取反同时更新剩余雷数显示。注意插旗的格子不能再被左键挖开所以左键开头要判断m_flagged。4.2 递归展开空白格连锁翻开的边界处理扫雷最核心的算法是递归展开点到一个周围雷数为 0 的格子自动把相邻 8 格也翻开如果相邻格也是 0继续递归。这里必须防重复访问否则无限递归直接栈溢出。我用m_revealed做访问标记翻开前先判断。void CMineSweeperDlg::RevealCell(int r, int c) { if (r 0 || r ROWS || c 0 || c COLS) return; if (m_revealed[r][c] || m_flagged[r][c]) return; m_revealed[r][c] true; if (m_board[r][c] 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); } } }递归深度最大就是棋盘格子数16x16 才 256 层栈完全撑得住。但如果你做 30x30 以上建议改成显式栈或队列的迭代版本避免极端情况栈溢出。另外注意插旗的格子不能被递归翻开所以m_flagged判断要放在最前面。4.3 胜利判定与剩余雷数显示每次翻开后检查是否所有非雷格都已翻开是则胜利。剩余雷数就是MINE_COUNT减去已插旗数显示在对话框顶部的静态文本里。用SetDlgItemInt或SetWindowText更新。bool CMineSweeperDlg::CheckWin() { for (int r 0; r ROWS; r) for (int c 0; c COLS; c) if (m_board[r][c] ! MINE !m_revealed[r][c]) return false; return true; }胜利后弹个框把所有雷自动插旗然后m_gameOver true锁住操作。这里有个细节踩雷时要把所有雷显示出来但别把插旗的格子覆盖掉否则玩家会觉得旗子白插了。5. 扫雷开发中最容易翻车的 5 个坑5.1 现象窗口一刷新就闪成一片原因每次OnPaint直接往屏幕 DC 画GDI 逐格绘制被系统合成多次。解决上双缓冲内存 DC 画完再BitBlt这是最立竿见影的优化。5.2 现象点空白格程序直接崩原因递归展开时没判断边界rdr或cdc越界访问数组。解决RevealCell开头加边界检查越界直接 return。血泪经验这个 bug 在 9x9 小棋盘上不容易触发一到 16x16 边缘就崩。5.3 现象右键插旗后左键还能挖开原因左键处理里漏了m_flagged判断。解决左键开头统一判断if (m_flagged[r][c] || m_revealed[r][c]) return;别只在部分分支判断。5.4 现象布雷时程序卡死或雷数不对原因用rand()反复生成坐标直到不重复雷多时碰撞概率高循环次数爆炸。解决改用洗牌法先生成全部位置再打乱取前 N 个时间复杂度 O(n)稳定不卡。5.5 现象数字颜色和经典扫雷对不上原因SetTextColor在DrawText前设置但背景FillSolidRect之后没恢复导致后续格子颜色串了。解决每个格子画之前重新SetTextColor或者用SetBkMode(TRANSPARENT)让文字背景透明避免颜色污染。6. 进阶技巧用定时器做计时器与自定义难度计时器是扫雷的标配。在OnInitDialog里SetTimer(1, 1000, nullptr)然后在OnTimer里累加秒数并更新显示。注意第一次点击才开始计时别一打开窗口就跑表那不符合扫雷规则。我一般用一个m_started标志首次左键挖开时置 true 并启动定时器。void CMineSweeperDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1 m_started !m_gameOver) { m_seconds; CString s; s.Format(_T(%03d), m_seconds); SetDlgItemText(IDC_STATIC_TIME, s); } CDialogEx::OnTimer(nIDEvent); }自定义难度可以加个菜单或按钮弹出对话框让用户输入行、列、雷数然后重新InitBoard并调整窗口大小。调整窗口大小时用SetWindowPos或MoveWindow把客户区尺寸设成COLS*CELL_SIZE宽、ROWS*CELL_SIZE高再留出顶部状态栏的高度。这里有个坑对话框的客户区尺寸和窗口尺寸差一个边框和标题栏直接用MoveWindow传客户区尺寸会偏得用AdjustWindowRect换算。验证方法很简单跑起来先点几个角看递归展开是否正常再故意踩雷看所有雷是否显示最后插满旗看剩余雷数是否归零。我习惯在InitBoard后加一句TRACE输出雷的分布调试时对着看比盲点快得多。这个项目代码量不大但把 MFC 的消息映射、GDI 绘图、定时器、递归全练了一遍做完再去看别的 MFC 程序心里就有底了。希望帮到你。本文还有配套的精品资源点击获取
返回列表