news 2026/10/1 12:07:23

基于MFC实现扫雷游戏:对话框工程与核心逻辑详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于MFC实现扫雷游戏:对话框工程与核心逻辑详解

简介:这是一份基于MFC框架实现的扫雷游戏完整源码工程,面向正在学习Windows桌面开发、C++面向对象编程以及MFC文档视图架构的初学者与进阶者。资源以鼠标点击操作为核心交互方式,界面简洁明了,代码结构清晰,适合作为课程设计、毕业设计参考或MFC练手项目。压缩包共45个文件,约2.85MB,包含6个cpp源文件与7个h头文件构成核心逻辑,17个bmp位图与2个ico图标提供游戏素材,另有rc资源脚本、dsp/dsw工程文件及可执行程序,方便直接编译运行与二次修改。目前已有315人学习下载。通过阅读该工程,读者可以掌握MFC文档视图框架的搭建方式、消息映射与鼠标事件响应机制、雷区数据结构的组织与递归展开算法,以及资源文件与对话框的整合方法,是理解经典扫雷实现思路的实用参考。

1. 基于MFC实现的扫雷游戏:从对话框工程到可玩成品的完整路径

很多人第一次接触 Windows 桌面开发,都是从 MFC 扫雷开始的。它不像记事本那样只是堆控件,也不像计算器那样只做四则运算,扫雷要求你把界面绘制、鼠标消息、定时器、递归展开、状态管理全部串起来。标题里的“基于MFC实现的扫雷游戏”,核心就是用 MFC 的对话框工程搭一个 9×9 到 16×30 的雷区,左键翻开、右键插旗、计时计分、胜负判定。适合已经会一点 C++、想找一个完整小项目练手的人,也适合被“mfc四大类”“mfc教程”绕晕、需要一条清晰落地路径的开发者。下面按我实际做过的顺序,把选型、绘制、逻辑、避坑一次讲透。

2. 对话框工程选型与雷区数据结构设计

2.1 为什么扫雷优先选对话框工程而不是单文档

MFC 的工程模板常见有三类:单文档、多文档、对话框。扫雷这种固定窗口、控件密集、不需要文档/视图分离的程序,对话框工程是最省事的。单文档会自带菜单、工具栏、状态栏和文档序列化框架,你为了一个扫雷去裁剪这些,反而容易在 OnDraw 和视图坐标上翻车。对话框工程直接给你一个 CDialogEx 派生类,资源编辑器里拖控件,消息映射写响应,路径最短。

我一般会这样建工程:Visual Studio 里选“MFC 应用”,应用程序类型选“基于对话框”,项目名比如 MineSweeper。生成后你会看到CMineSweeperApp和CMineSweeperDlg两个核心类,前者管应用生命周期,后者管窗口。这里有个热词里常被问到的点——“mfc四大类”,通常指 CWinApp、CFrameWnd、CView、CDocument,但对话框工程里真正天天打交道的是 CWinApp、CDialogEx、CWnd、CDC,别被概念绕住,先把对话框跑起来再说。

雷区数据不要用二维数组int map[30][30]硬编码,因为行列数要可配置。常见做法是用std::vector<std::vector<Cell>>,Cell 结构体里放三个字段:是否是雷、是否已翻开、是否插旗。这样行列数改起来只动一个常量,也方便后面做递归展开。

// MineSweeperDlg.h 里定义单元格结构 struct Cell { bool isMine = false; // 是否是地雷 bool isRevealed = false; // 是否已翻开 bool isFlagged = false; // 是否插旗 int neighborMines = 0; // 周围8格雷数 }; // 对话框类成员 int m_rows = 9; // 行数 int m_cols = 9; // 列数 int m_mineCount = 10; // 雷数 std::vector<std::vector<Cell>> m_board; // 雷区 bool m_gameOver = false; bool m_win = false; int m_remainingFlags = 0;

这段代码的关键在于把“状态”和“显示”分开。Cell 只存逻辑状态,不存按钮指针或颜色。参数说明:m_rows、m_cols、m_mineCount是难度配置,初级 9×9/10 雷,中级 16×16/40 雷,高级 16×30/99 雷。m_remainingFlags用来在界面上显示剩余旗数,初始等于雷数。m_gameOver和m_win控制后续点击是否还响应。很多新手把按钮数组和逻辑数组混在一起,结果重置游戏时按钮状态清不干净,这是后面避坑章要重点说的。

2.2 用按钮网格还是自绘雷区

扫雷的界面实现有两条路:一是用一堆 CButton 拼成网格,二是用一块自绘区域,在 OnPaint 里根据坐标画格子。按钮方案上手快,资源编辑器里拖 81 个按钮虽然累,但消息映射直观,适合第一次做。自绘方案灵活,格子大小、颜色、图标都能控,但你要自己处理鼠标坐标到行列的换算,还要管重绘闪烁。

我的建议是:第一次做用按钮网格,但不要真的拖 81 个按钮。用CButton动态创建,在OnInitDialog里根据行列数循环Create,存到一个std::vector<CButton*>里。这样难度切换时销毁重建也方便。按钮的 ID 可以用一个基数加偏移,比如IDC_BTN_BASE + row * m_cols + col,消息映射里用ON_COMMAND_RANGE批量处理。

// OnInitDialog 中动态创建按钮网格 CRect rect; GetClientRect(&rect); int btnSize = 24; // 每个格子24像素 for (int r = 0; r < m_rows; ++r) { for (int c = 0; c < m_cols; ++c) { CButton* pBtn = new CButton(); pBtn->Create(_T(""), WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, CRect(10 + c * btnSize, 50 + r * btnSize, 10 + (c + 1) * btnSize, 50 + (r + 1) * btnSize), this, IDC_BTN_BASE + r * m_cols + c); m_buttons.push_back(pBtn); } }

逻辑说明:Create的第四个参数是父窗口,第五个是控件 ID。IDC_BTN_BASE在 resource.h 里定义一个起始值,比如 1000,保证不和对话框自带的 IDOK、IDCANCEL 冲突。参数说明:btnSize决定格子大小,24 像素在 9×9 下窗口约 240×280,比较紧凑;高级 16×30 时窗口会到 720 宽,需要给对话框加滚动条或缩小格子。10和50是左边距和上边距,给标题和剩余雷数显示留位置。注意按钮创建后要ShowWindow(SW_SHOW),否则可能不显示。这个方案在“mfc制作一个近似于windows记事本怎么做”里也常用,思路都是动态控件加消息映射。

3. 布雷、翻开与递归展开的核心逻辑

3.1 首次点击不踩雷的布雷策略

扫雷有个约定俗成的体验:第一次点击不能是雷。实现方式有两种,一是在OnInitDialog里先布雷,但把首次点击的格子及其周围排除;二是延迟布雷,等第一次左键点击时再随机放雷,保证点击格和周围 8 格无雷。第二种更干净,我一般用第二种。

void CMineSweeperDlg::PlaceMines(int safeRow, int safeCol) { // 收集所有安全格之外的坐标 std::vector<std::pair<int,int>> candidates; for (int r = 0; r < m_rows; ++r) { for (int c = 0; c < m_cols; ++c) { // 首次点击格及其周围8格不放雷 if (abs(r - safeRow) <= 1 && abs(c - safeCol) <= 1) continue; candidates.push_back({r, c}); } } // 洗牌后取前 m_mineCount 个作为雷 std::random_device rd; std::mt19937 gen(rd()); std::shuffle(candidates.begin(), candidates.end(), gen); for (int i = 0; i < m_mineCount && i < (int)candidates.size(); ++i) { m_board[candidates[i].first][candidates[i].second].isMine = true; } // 计算每个非雷格周围的雷数 for (int r = 0; r < m_rows; ++r) { for (int c = 0; c < m_cols; ++c) { if (m_board[r][c].isMine) 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 < m_rows && nc >= 0 && nc < m_cols && m_board[nr][nc].isMine) cnt++; } m_board[r][c].neighborMines = cnt; } } }

逻辑说明:先收集候选坐标,排除安全区,再用std::shuffle打乱,取前 N 个设为雷。参数说明:safeRow、safeCol是首次点击的行列,abs判断保证 3×3 安全区。m_mineCount不能超过候选数,高级 16×30 有 480 格,99 雷完全够。计算邻居雷数时用双重循环扫 8 方向,注意边界判断。这段逻辑跑通后,扫雷的核心就立住了一半。热词里“mfc 获取列表总列数”在这里对应的是m_cols,如果你用 CListCtrl 做排行榜,列数用GetHeaderCtrl()->GetItemCount(),但扫雷本身不需要。

3.2 递归展开与胜利判定

点击一个非雷格后,如果周围雷数为 0,要自动展开周围 8 格,这就是扫雷的“连锁反应”。递归实现最直观,但要注意防止重复展开和栈溢出。9×9 还好,16×30 递归深度最多 480,不会爆栈,但重复访问会拖慢。加一个isRevealed判断即可。

void CMineSweeperDlg::RevealCell(int r, int c) { if (r < 0 || r >= m_rows || c < 0 || c >= m_cols) return; Cell& cell = m_board[r][c]; if (cell.isRevealed || cell.isFlagged) return; cell.isRevealed = true; UpdateButton(r, c); // 刷新按钮显示 if (cell.isMine) { m_gameOver = true; RevealAllMines(); return; } if (cell.neighborMines == 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); } } CheckWin(); }

逻辑说明:先做边界和状态检查,翻开当前格,如果是雷就结束游戏并显示所有雷。如果是空格,递归展开周围 8 格。UpdateButton负责把逻辑状态映射到按钮文字或颜色。参数说明:r、c是当前格坐标,递归时传r+dr、c+dc。CheckWin在每次翻开后调用,判断已翻开格数是否等于总格数减雷数。

胜利判定不要用“所有非雷格都翻开”去遍历整个数组,那样每次点击都 O(n²)。用一个计数器m_revealedCount,翻开时加一,和m_rows * m_cols - m_mineCount比较即可。这是性能上的小优化,但习惯养成了后面做更大棋盘不会卡。

void CMineSweeperDlg::CheckWin() { if (m_revealedCount == m_rows * m_cols - m_mineCount) { m_win = true; m_gameOver = true; KillTimer(TIMER_ID); AfxMessageBox(_T("你赢了!")); } }

参数说明:TIMER_ID是计时器 ID,胜利后停掉。AfxMessageBox是 MFC 的弹窗,简单直接。注意m_revealedCount要在RevealCell里每次成功翻开时递增,且不能重复递增,所以isRevealed判断要放在最前面。

4. 鼠标消息、插旗与计时器的落地细节

4.1 右键插旗与左键翻开的区分

MFC 按钮默认只响应左键,右键插旗需要自己处理。常见做法是不用按钮的 BN_CLICKED,而是重写对话框的OnLButtonDown和OnRButtonDown,通过坐标换算出行列。但按钮会挡住鼠标消息,所以要么给按钮加BS_OWNERDRAW自绘,要么在按钮的父窗口里用PreTranslateMessage拦截。

我一般用PreTranslateMessage,因为它不用改按钮样式,直接在消息到达按钮前处理。

BOOL CMineSweeperDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg->message == WM_RBUTTONDOWN) { CPoint pt = pMsg->pt; ScreenToClient(&pt); int r, c; if (HitTest(pt, r, c)) { OnRightClick(r, c); return TRUE; // 拦截,不再传递 } } return CDialogEx::PreTranslateMessage(pMsg); }

逻辑说明:WM_RBUTTONDOWN时把屏幕坐标转成客户区坐标,HitTest根据坐标反算行列。参数说明:pMsg->pt是屏幕坐标,必须ScreenToClient。HitTest里用(pt.x - 10) / btnSize和(pt.y - 50) / btnSize算行列,注意边界检查。返回TRUE表示消息已处理,不再往下传,否则按钮可能会收到右键消息导致奇怪行为。

OnRightClick里做插旗切换:如果已翻开则忽略,如果已插旗则取消,否则插旗,并更新m_remainingFlags和按钮显示。插旗显示可以用SetWindowText(_T("F"))或改按钮图标,简单起见用文字。

4.2 计时器与剩余雷数显示

扫雷的计时器从第一次点击开始,每秒加一。MFC 里用SetTimer和OnTimer。

// 第一次点击时启动 SetTimer(TIMER_ID, 1000, nullptr); m_startTime = GetTickCount(); // 消息映射 ON_WM_TIMER() void CMineSweeperDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_ID && !m_gameOver) { int elapsed = (GetTickCount() - m_startTime) / 1000; CString str; str.Format(_T("时间:%d 秒"), elapsed); GetDlgItem(IDC_STATIC_TIME)->SetWindowText(str); } CDialogEx::OnTimer(nIDEvent); }

逻辑说明:SetTimer第三个参数 1000 表示 1 秒触发一次。GetTickCount返回毫秒,除以 1000 得秒。参数说明:TIMER_ID自定义一个值,比如 1。IDC_STATIC_TIME是静态文本控件 ID。注意游戏结束后要KillTimer,否则计时器还在跑。剩余雷数显示在插旗时更新:m_remainingFlags = m_mineCount - 已插旗数,用GetDlgItem(IDC_STATIC_FLAGS)->SetWindowText刷新。

这里有个热词“mfc 字符串内存泄漏”值得提一句:CString本身管理内存,但如果你用new CString或GetBuffer后不ReleaseBuffer,就会泄漏。扫雷里字符串操作少,但计时器每秒 Format 一次,用栈上 CString 没问题,别去new。

5. 扫雷开发中容易翻车的五个坑

5.1 按钮动态创建后消息映射不响应

现象:按钮显示出来了,点击没反应。原因:动态创建的按钮 ID 不在ON_COMMAND_RANGE范围内,或者消息映射没写。解决:在.h里声明afx_msg void OnBtnClick(UINT nID);,在.cpp里ON_COMMAND_RANGE(IDC_BTN_BASE, IDC_BTN_BASE + 480, OnBtnClick),范围要覆盖最大棋盘。OnBtnClick里用nID - IDC_BTN_BASE反算行列。

5.2 重置游戏时旧按钮没销毁导致内存泄漏

现象:切换难度或重开一局后,旧按钮还在,内存持续涨。原因:new CButton后没有delete。解决:重置时遍历m_buttons,DestroyWindow后delete,再clear。或者用std::vector<std::unique_ptr<CButton>>自动管理。血泪经验:MFC 里new的控件一定要手动delete,它不会随父窗口销毁自动释放。

5.3 递归展开导致重复翻开和显示错乱

现象:连锁展开时某些格子翻开了但按钮没更新,或者已经插旗的格子被翻开。原因:递归前没检查isFlagged和isRevealed。解决:RevealCell开头就判断if (cell.isRevealed || cell.isFlagged) return;,并且UpdateButton要在状态改变后立即调用。注意插旗格不能被递归翻开,这是规则。

5.4 首次点击踩雷

现象:第一下就点到雷,体验极差。原因:布雷在OnInitDialog里做了,没排除点击格。解决:延迟布雷,在第一次左键点击时调用PlaceMines,并把点击格及周围 8 格排除。注意如果雷数太多,安全区可能不够,要判断候选数是否大于雷数。

5.5 计时器在游戏结束后还在跑

现象:赢了或输了之后时间还在涨。原因:KillTimer没调用或调用位置不对。解决:在m_gameOver = true的地方统一KillTimer(TIMER_ID)。注意OnTimer里也要判断m_gameOver,双保险。这个坑很小但很常见,属于“后悔药”级别的疏忽。

6. 把扫雷做成可配置难度与自绘皮肤的进阶技巧

基础版跑通后,可以往两个方向进阶:一是难度配置,二是自绘皮肤。难度配置不只是改行列和雷数,还要处理窗口大小、按钮尺寸、滚动条。我一般把难度参数放到一个结构体里,切换时先销毁旧棋盘,再重建。

struct Difficulty { int rows, cols, mines; const TCHAR* name; }; Difficulty g_levels[] = { {9, 9, 10, _T("初级")}, {16, 16, 40, _T("中级")}, {16, 30, 99, _T("高级")} };

参数说明:rows、cols、mines对应棋盘规格,name用于菜单显示。切换时调用ResetGame(levelIndex),内部先ClearBoard()销毁按钮,再按新参数创建。注意高级 16×30 在 24 像素格子下宽度 720,普通对话框放不下,要么缩小格子到 20 像素,要么给对话框加WS_HSCROLL和WS_VSCROLL,在OnHScroll里ScrollWindow。

自绘皮肤的思路是放弃 CButton,改用一块CStatic或直接在主对话框上OnPaint画。每个格子根据状态画不同颜色:未翻开灰色、已翻开白色、雷红色、旗子绿色。数字用DrawText画在格子中央。这样格子大小可以自由缩放,也方便做“mfc显示bmp图片”那种贴图皮肤。但自绘要处理双缓冲,否则闪烁严重。在OnPaint里用CDC memDC; memDC.CreateCompatibleDC(pDC);先画到内存 DC,再BitBlt到屏幕。

验证方法:改完难度后,用初级跑一局确认首次点击安全、递归展开正常、胜利弹窗;再用高级跑一局确认滚动条和性能。如果高级下点击卡顿,检查UpdateButton是否每次都SetWindowText,可以改成只更新变化的格子。我自己的习惯是每加一个功能就用初级快速验证,别等全做完再调,否则出了问题不知道是哪一步引入的。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 12:06:18

Codex CLI 接入 Jev 模型服务:配置教程与踩坑指南

最近我在折腾 Codex CLI 的时候&#xff0c;发现一个很有意思的搭配&#xff1a;给 Codex 配上 Jev 模型服务&#xff0c;速度、成本、可用性直接起飞。这里不吹不黑&#xff0c;把配置过程和踩坑记录完整放出来。Codex 是 OpenAI 出的命令行编码代理&#xff0c;能用自然语言直…

作者头像 李华
网站建设 2026/10/1 12:06:11

NI-VISA下用C++调用数字万用表驱动:从SCPI到数据读取

简介&#xff1a;面向C开发者和NI硬件用户的DMM驱动资源&#xff0c;聚焦NI数字万用表&#xff08;DMM&#xff09;板卡的编程控制。资源对应《深入理解DMM驱动&#xff1a;NI数字万用表的C编程实践》&#xff0c;涵盖设备初始化、测量参数配置、数据采集、错误处理与设备关闭等…

作者头像 李华
网站建设 2026/10/1 12:04:21

Qoder本地AI编程引擎:告别HTTP延迟,实现毫秒级代码补全

1. 从“Codex用户”到“Qoder信徒”&#xff1a;一场IDE内AI编程体验的断崖式升级我第一次在IntelliJ IDEA里敲出// TODO: implement retry logic with exponential backoff&#xff0c;然后按下快捷键&#xff0c;等了3秒——光标没动&#xff0c;状态栏显示“Waiting for Cod…

作者头像 李华
网站建设 2026/10/1 12:04:10

字符串处理实战:多语言逆序、分割与转换陷阱解析

字符串大概是编程里最“不起眼”却又最能暴露水平的部分。我写了十几年代码&#xff0c;从C语言的char[]一路折腾到 Java、Python、C#、JavaScript 和各类SQL方言&#xff0c;发现一个很现实的问题&#xff1a;越基础的操作越容易翻车。逆序一个字符串人人都会&#xff0c;但遇…

作者头像 李华
网站建设 2026/10/1 12:04:08

电动车真空助力制动系统建模:从机理到数据驱动的实践

只有真正做过整车项目的工程师才懂&#xff0c;电动车制动系统最神奇的地方&#xff0c;不在卡钳和ESP&#xff0c;而在那块你几乎永远不会注意到的“真空”。每天早晚高峰&#xff0c;你踩下制动踏板&#xff0c;制动力在几十毫秒内建立&#xff0c;脚感和老燃油车几乎没差别。…

作者头像 李华
网站建设 2026/10/1 12:02:11

PyTorch非线性函数拟合实战:从数据归一化到激活函数选择

如果让我给刚接触 PyTorch 的朋友推荐一个练手项目&#xff0c;我大概率会先说&#xff1a;别急着上图像分类&#xff0c;也不用一上来就啃 Transformer&#xff0c;先拿一个非线性函数拟合任务把整个训练流程跑通再说。这个项目标题看起来很朴素——基于 PyTorch 实现的非线性…

作者头像 李华