☰
用MFC写扫雷:消息循环、文档视图与GDI绘图全解析
2026/10/30 3:50:15 网站建设 项目流程

简介:MFC是Windows桌面应用开发的经典框架,其消息循环与文档-视图架构至今仍支撑着大量银行柜台、工业上位机等遗留系统。理解消息映射如何将鼠标点击路由到处理函数、视图类如何通过GDI绘制界面,是维护和扩展这类项目的基石。本文以MFC实现扫雷游戏为实践载体,从空窗口搭建、消息映射绑定、布雷算法到状态栏实时刷新,完整展示桌面应用的核心开发链路。内容覆盖CPaintDC双缓冲绘图、首点保护、显式栈扩散、GDI句柄泄漏排查等高频技术难点,帮助读者快速掌握MFC事件驱动、定时器与绘图三大核心机制,为接手真实工程代码提供可复用的工程经验。

1. 为什么还在用MFC写扫雷:一个30年前的游戏框架依然能打的理由

很多入门教程都在劝人学新框架,可真当你打开一家公司的代码仓库,会看到大量MFC老项目还在一线跑着——银行柜台、工业上位机、内部工具,界面朴素但功能扎实。基于MFC实现的扫雷游戏,恰恰是吃透这类老项目最划算的练手题。它把Windows消息循环、GDI绘图、文档-视图架构全浓缩进一个几百行的小程序里:你以为是做个游戏,实际上是在演练MFC程序员的日常——窗口怎么建、消息怎么路由到处理函数、资源怎么释放,一个都没少。这篇笔记写给三类人:做课程设计的学生、刚接手老MFC项目的工程师,以及想用最小成本验证桌面编程原理的自学者。按可复现的路径,从空窗口一路做到能玩能计时的完整游戏,顺带把高频坑都标出来。

2. MFC扫雷的地基:文档-视图架构与消息映射怎么拆

2.1 文档-视图架构在扫雷里的角色分配

MFC的文档-视图(Doc/View)架构,很多人学完就忘,写扫雷时正好能把它讲透。核心分工一句话:CDocument管数据和规则,CView管呈现和操作。落到扫雷游戏里,文档类持有三个数组——记录某个格子是否为雷的bool数组、记录每个格子邻接雷数的int数组、记录格子状态(未翻开/已翻开/插旗)的状态数组。视图类只拿文档指针,需要画什么、判胜负、翻开格子都通过文档类完成。

我第一次写扫雷时贪省事,把雷区数组直接放在视图类成员里,结果想加"重新开始"功能时发现文档里没有数据,状态一乱全得重来。后来老老实实按标准分工,视图只管"画"和"收鼠标消息"两件事,逻辑全推到文档类。这个架构的额外收益是存档变得极其简单:CDocument自带序列化机制,把三个数组写进CArchive就能落盘,想做成带复盘功能的扫雷,这一条路是通的。

选型上还有个问题:视图基类用CView还是CFormView?扫雷是纯绘图交互,CView最合适。CFormView虽然能拖控件、摆按钮,但鼠标绘图时控件会截住消息,处理起来很别扭。CView的OnPaint里想怎么画就怎么画,配合双缓冲就能做到不闪屏。

2.2 消息映射:从鼠标点击到格子翻转的完整链路

MFC与裸Win32最大的区别,是把switch-case式的窗口过程换成了消息映射表。BEGIN_MESSAGE_MAP和END_MESSAGE_MAP这对宏,把Windows消息和类的成员函数绑在一起。扫雷里最常用四类消息:WM_LBUTTONDOWN左键点击、WM_RBUTTONDOWN右键点击、WM_PAINT重绘、WM_TIMER计时器。绑定写法如下:

BEGIN_MESSAGE_MAP(CMinesweeperView, CView) ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_PAINT() ON_WM_TIMER() END_MESSAGE_MAP()

声明对应的处理函数时,签名必须和MFC框架预期一致。左键处理函数写成afx_msg void OnLButtonDown(UINT nFlags, CPoint point),其中point是客户区像素坐标,不是格子坐标。要把像素坐标换算成格子坐标,用格子边长做除法:

int col = point.x / m_cellSize; int row = point.y / m_cellSize; if (col < 0 || col >= m_cols || row < 0 || row >= m_rows) return; // 点击落在窗体边缘或格子间隙,直接忽略

m_cellSize我固定取32像素,9x9雷区刚好288x288像素,窗口尺寸留出状态栏和边距,整体比例协调。换算后必须立刻做越界检查——如果玩家点击了客户区里雷区矩形之外的区域,col或row会超出数组下标。Release模式下越界不会弹框,而是静默改写相邻内存,表现出来就是"偶尔闪退、布局错乱",这类问题排查起来非常像玄学。

右键处理函数里做插旗状态切换,左键处理函数里做翻开动作。逻辑跑完后,关键的一步是调用Invalidate(),让框架在下一轮消息循环里触发OnPaint。漏掉这步的典型表现是:游戏逻辑已经在内存里执行完了,界面却纹丝不动。代码顺序我习惯写成"改文档数据 -> 判胜负 -> Invalidate -> UpdateWindow()"。UpdateWindow会强制同步重绘,比等消息循环轮转更快,手感上更跟手。

3. 把最小框架跑起来:CWinApp、CMainFrame与CView的搭建顺序

3.1 第一步:CWinApp把程序入口接住

MFC程序没有传统意义的main函数,入口在CWinApp派生类的InitInstance里。扫雷是单窗口小游戏,不需要套用完整的SDI多文档框架,直接在InitInstance里new出主窗口即可。我一般是这样写的:

class CMineApp : public CWinApp { public: virtual BOOL InitInstance(); }; BOOL CMineApp::InitInstance() { CMainFrame* pFrame = new CMainFrame; if (!pFrame->Create(NULL, _T("MFC扫雷"), WS_OVERLAPPEDWINDOW, CRect(0, 0, 480, 440))) { return FALSE; } m_pMainWnd = pFrame; pFrame->ShowWindow(SW_SHOW); pFrame->UpdateWindow(); return TRUE; }

这段代码里有三个细节容易翻车。第一,Create的第四个参数是窗口初始尺寸,480x440给9x9雷区留足了富裕空间,窗口创建后用户还能手动拉伸,视图会在OnSize里紧跟客户区大小调整格子间距。第二,m_pMainWnd必须赋值,它告诉MFC主窗口是谁——关掉窗口时消息循环随之退出,进程干净结束。漏了这行,关窗口后进程缩在后台任务管理器里,界面没了但进程还在,排查起来非常难受。第三,pFrame用new创建后不需要手动delete,框架在窗口销毁时会自动回收,乱加delete反而会二次释放崩溃。

3.2 第二步:CMainFrame承载视图窗口

CMainFrame的职责是窗口骨架:标题栏、菜单栏、状态栏和中间的客户区。视图窗口在OnCreate里创建,它是主窗口的子窗口,铺满客户区。写法如下:

int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CFrameWnd::OnCreate(lpCreateStruct) == -1) return -1; m_view.Create(NULL, NULL, WS_CHILD | WS_VISIBLE, CRect(0, 0, 0, 0), this, 101); return 0; }

注意m_view.Create必须传WS_CHILD和WS_VISIBLE风格。WS_CHILD把视图变成主窗口的子窗口,WS_VISIBLE让它立即显示。两个风格缺一不可:缺了WS_CHILD,视图会变成独立顶层窗口,任务栏多出一个图标;缺了WS_VISIBLE,窗口黑屏无内容。CRect(0,0,0,0)只是占位,真正布局由CFrameWnd在WM_SIZE里自动调整子窗口位置和大小。

视图类还要配合动态创建机制——在头文件里声明DECLARE_DYNCREATE(CMinesweeperView),在cpp里写IMPLEMENT_DYNCREATE(CMinesweeperView, CView)。这两个宏是MFC利用运行时类型信息创建对象的钥匙。漏掉任何一个,Create调用会失败,程序照跑但界面全黑。这种黑屏故障最迷惑人——明明消息都进去了,窗口就是不显示。

3.3 第三步:CView里实现OnPaint画雷区

视图创建完后,核心工作是OnPaint。思路是双层循环遍历每个格子,根据文档里的状态数组分支绘图:

void CMinesweeperView::OnPaint() { CPaintDC dc(this); int cell = m_cellSize; for (int row = 0; row < m_rows; row++) { for (int col = 0; col < m_cols; col++) { CRect cellRect(col * cell, row * cell, (col + 1) * cell, (row + 1) * cell); DrawCell(dc, cellRect, row, col); } } }

CPaintDC是OnPaint里专用的设备上下文,构造函数里自动BeginPaint,析构时自动EndPaint。如果你在OnPaint里用CClientDC替代,会出现绘制不完整或者残留的怪问题——因为CClientDC不会正确处理WM_PAINT的无效区域。DrawCell内部按格子状态分支:未翻开画凸起立体边框,已翻开且数字非0画数字,已翻开且数字为0画空白,插旗画红旗形状,踩雷画雷的图标。

数字颜色沿用Windows经典扫雷的配色规则:1蓝色、2绿色、3红色、4深蓝、5棕红。颜色值用RGB宏直接写,不要用系统颜色GetSysColor——别人机器上主题不同,颜色可能糊成一团。每画完一个格子,右侧或底部会有一小条"多余区域",是客户区尺寸不能被32整除时留下的。处理方法是OnPaint最后用背景色把剩余矩形填掉:

CRect rect; GetClientRect(&rect); CRect remain(rect.left, rect.top + m_rows * cell, rect.right, rect.bottom); dc.FillSolidRect(remain, RGB(192, 192, 192));

到这里,一个能显示雷区、能响应用户点击的MFC扫雷雏形就有了。下一步把游戏规则注入进去。

4. 核心算法:布雷、邻接计算与翻开扩散的参数设计

4.1 布雷:随机种子与雷数密度的关系

扫雷玩法本身简单,核心难点在布雷算法、邻接数字计算和翻开扩散三个环节。布雷最容易踩坑的是"重复放雷"和"首点踩雷",我封装成这样一个函数:

void CMinesweeperDoc::GenerateMines(int firstRow, int firstCol) { memset(m_isMine, 0, sizeof(m_isMine)); srand((unsigned)time(NULL) ^ (m_seed++ * 7919)); int placed = 0; while (placed < m_mineCount) { int r = rand() % m_rows; int c = rand() % m_cols; if (m_isMine[r][c]) continue; // 首点保护:第一次点击的格子及其八邻域不布雷 if (abs(r - firstRow) <= 1 && abs(c - firstCol) <= 1) continue; m_isMine[r][c] = true; placed++; } CalcNeighborNumbers(); }

srand的种子我做了混合处理——time(NULL)加一个递增计数器再乘7919。直接srand(time(NULL))有个隐患:玩家在1秒内连续点"重新开始",两次雷区完全一样,因为种子没变。混合计数器后,每次开局雷区都不同。

首点保护是必须的。Windows原版扫雷保证第一次左键点击翻开的是一片无雷区域,而不是单格。我把firstRow/firstCol的九宫格全部排除在布雷范围外,这样首点后至少能翻开3x3区域,游戏体验正常得多。如果漏掉这段,玩家第一次下笔就踩雷,游戏秒结束。

雷数密度是手感的关键。初级9x9放10颗雷,中级16x16放40颗雷,高级30x16放99颗雷,这是Windows扫雷验证过的参数。密度控制在12%到15%之间,难度曲线最舒服;超过20%,数字7和8频繁出现,游戏变得靠运气而非推理。参数我放在文档类构造函数里,方便后续做成难度选择菜单。

4.2 邻接计算:八方向遍历的边界处理

布雷完之后,要把每个非雷格子的邻接雷数算出来。用偏移数组实现最紧凑,代码量最小,也最容易检查边界逻辑:

void CMinesweeperDoc::CalcNeighborNumbers() { static const int dr[8] = { -1, -1, -1, 0, 0, 1, 1, 1 }; static const int dc[8] = { -1, 0, 1, -1, 1, -1, 0, 1 }; for (int r = 0; r < m_rows; r++) { for (int c = 0; c < m_cols; c++) { if (m_isMine[r][c]) { m_neighbor[r][c] = 9; // 9作为雷标记,绘图时与数字区分 continue; } int cnt = 0; for (int k = 0; k < 8; k++) { int nr = r + dr[k]; int nc = c + dc[k]; if (nr < 0 || nr >= m_rows || nc < 0 || nc >= m_cols) continue; if (m_isMine[nr][nc]) cnt++; } m_neighbor[r][c] = cnt; } } }

把雷格子的邻接值设为9,绘图时用一个int值就能区分数字格和雷格,省掉一次数组查询。边界检查必须写成nr < 0 || nr >= m_rows这种左闭右开形式,少写一个条件在Debug版会触发断言,Release版则直接越界读写——表现成随机崩溃,可能玩十分钟才炸一次,很难复现。

这段代码的复杂度是O(rows * cols * 8),对9x9和16x16的雷区来说不到一千次运算,性能完全不是问题。真正要注意的是偏移数组的顺序,我按顺时针排列,方便阅读和调试。如果你把某个方向的偏移写错,比如把{-1,1}写成{-1,-1},角落格子的数字会偏小,玩家明明看到"1"却实际是"2",导致推理错误。这种逻辑错误不会崩溃,但会让游戏没法玩。

4.3 翻开扩散:递归与显式栈的取舍

点开一个数字为0的格子时,要把连成片的空白区域全部翻开,这块逻辑教科书版本用递归,但我在工程里用显式栈替代——递归深度在16x16地图的极端情形能到几百层,虽然栈够用,但显式栈能精确控制遍历顺序,也便于调试:

void CMinesweeperDoc::RevealCell(int r, int c) { if (m_state[r][c] == STATE_REVEALED) return; std::vector<std::pair<int, int>> stack; stack.push_back({ r, c }); while (!stack.empty()) { auto [cr, cc] = stack.back(); stack.pop_back(); if (cr < 0 || cr >= m_rows || cc < 0 || cc >= m_cols) continue; if (m_state[cr][cc] == STATE_REVEALED) continue; if (m_isMine[cr][cc]) continue; if (m_state[cr][cc] == STATE_FLAGGED) continue; // 插旗的格子视为玩家标记,不强制翻开 m_state[cr][cc] = STATE_REVEALED; if (m_neighbor[cr][cc] == 0) { for (int k = 0; k < 8; k++) { int nr = cr + dr[k]; int nc = cc + dc[k]; if (nr >= 0 && nr < m_rows && nc >= 0 && nc < m_cols) { if (m_state[nr][nc] != STATE_REVEALED) stack.push_back({ nr, nc }); } } } } }

显式栈版本的关键逻辑:当前格邻接雷数为0时,才把八方向邻居压栈;数字格被翻开后不继续扩散,它天然形成扩散的边界。m_state[cr][cc] == STATE_FLAGGED的判断很多人会漏,玩家插旗表示"我认为这里有雷",扩散把旗子翻开等于违背玩家预期。在原版扫雷里,插旗格也确实不会被扩散翻开。

代码使用C++17的结构化绑定特性auto [cr, cc] = stack.back(),如果工程还在用Visual Studio 2015或更老的工具集,需要改为int cr = stack.back().first; int cc = stack.back().second;。我一般在项目属性里把语言标准设为C++17,省很多手写拆分的麻烦。

翻开后立刻检查胜利条件:已翻开格子数加上雷数是否等于总格子数。等于则游戏胜利。这里还有个细节:应该在扩散完成后统一判断,不要在RevealCell里边翻边统计,否则连续翻开多个格子时胜利检测会重复触发多次。

5. 扫雷常见翻车现场:6个高频坑与排查思路

5.1 首点踩雷:开局即结束的尴尬

现象:玩家第一次左键点下去,雷区直接在脚下炸开,弹"你输了"。

原因:布雷逻辑在首点之前执行,rand恰好把雷布到了点击格子上。经典扫雷规则里,首点永远不会踩雷,这是保证游戏可玩性的设计,不是可有可无的优化。

解决:采用4.1节的首点保护,生成雷区时传入firstRow和firstCol,把九宫格区域排除在布雷范围外。关键是要在OnLButtonDown里根据点击坐标调用GenerateMines(row, col),而不是在OnInitialUpdate里预先布雷。否则首次保护参数失效,问题依旧。

5.2 重绘闪烁:背景擦除惹的祸

现象:鼠标划过格子或拉伸窗口时,雷区高频闪烁,边缘出现残影。

原因:MFC默认把WM_ERASEBKGND处理成刷灰整个客户区,再进OnPaint重画格子。刷灰和重画之间存在时间差,人眼能感知到一次明暗交替。

解决:让OnEraseBkgnd直接return TRUE,禁止框架擦背景;然后在OnPaint里用双缓冲绘制——先画到内存DC,再一次性BitBlt到屏幕。双缓冲做法如下:

BOOL CMinesweeperView::OnEraseBkgnd(CDC* pDC) { return TRUE; // 禁止默认擦除,防止闪烁 } void CMinesweeperView::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(&rect); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rect.Width(), rect.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); DrawAll(&memDC); // 所有绘图逻辑画进内存DC dc.BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }

注意CBitmap必须是局部对象且生命周期覆盖整个OnPaint,中途不能提前释放,否则memDC还引用一块已销毁的GDI对象,绘制直接失败或崩溃。

5.3 GDI句柄泄漏:黑匣子一样的卡顿

现象:游戏玩了几分钟后,界面绘制越来越慢,任务管理器里"GDI对象"数量涨到几千上万,最终卡死。

原因:OnPaint里如果偶尔用了GetDC而没有ReleaseDC,或者每次CreateCompatibleDC后没有DeleteDC,GDI句柄就会持续泄漏。

解决:用CPaintDC和CClientDC时不用手动释放,它们析构时自动还原。如果直接调GetDC,必须配对ReleaseDC;CreateCompatibleDC必须配对DeleteDC。我习惯在DrawCell函数的入口和出口检查GDI对象数量,用GetGuiResources加断点观察是否单调增长。这属于血泪经验——Windows不会替你回收GDI句柄,进程不重启,泄漏就一直在。

5.4 消息映射不响应:函数签名与宏的错位

现象:在视图类里写了OnLButtonDown,编译通过,点击却毫无反应。

原因:最常见的三种——消息映射宏没写进BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间;函数签名少了UINT nFlags参数或CPoint参数;或者你写的是ON_COMMAND而不是ON_WM_LBUTTONDOWN,前者是菜单/按钮命令消息,后者才是鼠标消息,混用会让框架找不到处理入口。

解决:先用类向导重新生成一次处理函数,对比签名差异。确认头文件里afx_msg声明的析构函数与实际定义完全一致。如果是在现有VS MFC工程里加功能,尤其要检查是不是把函数写到了别的类里。

5.5 布雷数量不对:伪随机与计数逻辑的双重陷阱

现象:设置10颗雷,布完发现雷区里只有8颗,或多出12颗。

原因:概率性布雷写法m_isMine[r][c] = rand()%100 < 12是典型错误——它不保证总数,只保证逼近概率。另一种情况是while循环里处理重复位置时没有continue,同一个格子被放了两颗雷,计数变量也跟着多加。

解决:用while (placed < m_mineCount)循环配合去重continue,直到精确放满指定数量才退出。调试时在CalcNeighborNumbers前打印实际雷数,确认与配置一致。不要用概率布雷法,扫雷的雷数必须精确。

5.6 计时器迟到:结束判定后还在跳秒

现象:游戏已经结束(无论胜负),状态栏或界面的秒数还在增长。

原因:设置计时器用SetTimer(1, 1000, NULL),但游戏结束的判定分支里漏了KillTimer(1),或者OnTimer里没检查游戏是否已结束。

解决:在胜利和失败两个分支里都调用KillTimer(1);OnTimer处理函数第一行判断m_bGameOver,为true直接return。我还习惯在KillTimer后把状态栏时间文本冻结,避免"死后还在跳秒"。这套逻辑用调试输出验证:在OnTimer里加OutputDebugString打印时间戳,看结束后的输出是否立即停止。

6. 进阶玩法:状态栏实时显示计时与剩余雷数

扫雷做到能玩能赢,离"像样"还差一步:经典扫雷的界面下方有剩余雷数,右上有计时器。MFC里这两项都能用状态栏实现——顺带解决很多人问过的"MFC状态栏怎么显示"的问题。

状态栏用CStatusBar控件,定义指示器数组并创建三个面板:

static UINT indicators[] = { ID_SEPARATOR, // 第0格:弹性区域,显示剩余雷数文字 IDS_MINE_LEFT, // 第1格:剩余雷数 IDS_ELAPSED_TIME // 第2格:已用时间 }; m_wndStatusBar.Create(this); m_wndStatusBar.SetIndicators(indicators, 3); m_wndStatusBar.SetPaneInfo(0, ID_SEPARATOR, SBPS_STRETCH, 0); m_wndStatusBar.SetPaneInfo(1, IDS_MINE_LEFT, SBPS_NORMAL, 90); m_wndStatusBar.SetPaneInfo(2, IDS_ELAPSED_TIME, SBPS_NORMAL, 90);

SetPaneInfo的第三个参数控制面板宽度,90像素够显示"剩余雷数: 10"这个长度。刷新时调用SetPaneText:

CString strMine; strMine.Format(_T("剩余雷数: %d"), m_mineLeft); m_wndStatusBar.SetPaneText(1, strMine); CString strTime; strTime.Format(_T("用时: %d秒"), m_elapsedSeconds); m_wndStatusBar.SetPaneText(2, strTime);

在OnTimer里每秒刷新一次时间面板,在插旗/取消插旗动作里刷新雷数面板。更新后调用UpdateWindow()强制重绘,高刷屏上能避免几十毫秒的滞后感。

如果你想进一步扩展——比如在现有VS MFC工程上增加按钮弹出对话框并显示实时数据图表——扫雷项目可以做个统计对话框:统计已翻开格子数、旗数、每一步的操作耗时,用自绘控件或直接画折线图。对话框有自己的消息循环,但数据源要指向同一个文档指针,这正是前面坚持文档-视图架构的好处——视图和对话框都只是"读数据"的窗口。我自己做这个功能时踩过一坑:直接在对话框的OnTimer里SetTimer,但对话框关闭时没KillTimer,导致计时器泄漏。后来把计时器统一挂在主视图类上,对话框只被动刷新,问题就不再出现。

这套从零搭扫雷的过程,本质是把MFC三大主题——事件驱动、定时器、GDI绘制——全部走一遍。我给新人的建议是:先跑通无状态栏版本,再加状态栏,再加右键联查,每步都独立编译验证。MFC排错靠的是耐心打输出,别急着重构架构,先检查消息映射、坐标换算和GDI释放这三件套,八成问题自己就露出来了。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询