MFC连连看源码拆解:位图透明、双缓冲与消息映射实战
简介基于MFC框架的连连看游戏完整源码面向正在学习C桌面开发、希望从零理解Windows游戏设计流程的初学者与中级开发者。项目共27个文件压缩包仅3.8MB结构清晰7个头文件与4个C源文件承载主对话框、游戏逻辑及连通判定算法6个位图资源提供背景、图案与按钮界面附带的解决方案、项目配置、版本控制与许可文件让项目可直接用Visual Studio编译运行。游戏要求玩家在限定时间内匹配并消除相同图案源码将计时计分、路径判断等功能模块化适合逐段研读同时预编译头与资源脚本的设置也体现了正规工程的实践习惯。已有100人学习下载。通过分析这份实现可掌握MFC界面搭建、资源管理到游戏算法落地的完整路径并能为后续扩展关卡、音效与动画打下扎实基础。1. MFC连连看源码26个文件的Windows桌面游戏工程这份基于MFC框架的连连看游戏源码我第一眼扫过文件清单时心里就有数了CGameDlg.cpp 管游戏逻辑MFCApplicationz_LinkDlg.cpp 管主界面6 张 BMP 位图把背景、水果图案、掩码一次备齐26 个文件覆盖了从 .sln 到 .rc 的完整工程链路。换句话说这是一份拿到手就能在 Visual Studio 里编译运行、带完整项目配置的 Windows 桌面游戏源码。对正在做 MFC 课程设计的人或者想搞懂「对话框程序怎么把消息循环、GDI 绘制和游戏算法缝在一起」的 C 学习者来说它比零散贴出来的代码片段有价值得多——因为你能直接看到资源文件、头文件、源文件是怎么咬合着把游戏跑起来的。适合拿来拆、拿来改、拿来当课程设计的底子也适合作为你研究 MFC 显示 BMP 图片与消息映射交互的活教材。2. 工程结构拆解对话框类、位图资源与配置文件的分工2.1 类文件分工CGameDlg 与主对话框各管什么MFC 项目里最常见的误区是把所有逻辑塞进主对话框类。这份源码的文件命名已经把分工写在脸上了MFCApplicationz_LinkDlg.h / .cpp 是程序的主对话框类负责整个窗口的创建、菜单响应、游戏开始与结束的流程调度CGameDlg.h / .cpp 是游戏面板类负责棋盘的数据结构和绘制渲染。你从 .rc 资源脚本里看到的对话框模板 IDD_MFCAPPLICATIONZ_LINK_DIALOG对应的就是主对话框类而 CGameDlg 通常是一个嵌入主对话框的自定义窗口或者是一个独立的子对话框。这两个类的边界值得细看。主对话框里一般只放按钮、静态文本这些控件以及 OnTimer、OnPaint 这类窗口级消息CGameDlg 里才是 m_map 二维数组、连通判定、洗牌这些游戏核心逻辑。这样拆分的好处是你想换一套 UI 皮肤时不用动算法想把算法抽出来做单元测试时也不用依赖窗口句柄。很多课程设计翻车就翻在把所有东西写进一个类最后 OnPaint 里塞满了游戏逻辑改一处崩三处。布局这一步我给一个常见做法的参考主对话框用 CDialogEx 派生游戏区域用一个自绘的 CStatic 子类或者直接重写主对话框的 OnPaint把棋盘画在对话框客户区的固定矩形内。这份源码的文件结构暗示它走的是后者——主对话框直接负责整个客户区的绘制CGameDlg 更像是承载算法与状态机的辅助类。具体是哪种打开 CGameDlg.h 构造函数看一眼有没有传入父窗口句柄就清楚了。2.2 六张位图的职责背景、元素、掩码如何配合资源列表里有 6 张 BMPllk_main.bmp、fruit_bg.bmp、fruit_element.bmp、fruit_mask.bmp、bitmap6.bmp、Pic.bmp。命名已经把用途交代清楚了。llk_main.bmp 是主界面背景图fruit_bg.bmp 是棋盘背景fruit_element.bmp 是水果图案的素材集fruit_mask.bmp 是掩码图——这四张是核心。bitmap6.bmp 和 Pic.bmp 可能是按钮图标或装饰图具体看 .rc 里怎么引用。整个项目的文件构成可以先列个清单类别文件作用头文件MFCApplicationz_LinkDlg.h、CGameDlg.h、framework.h、pch.h、resource.h、targetver.h、MFCApplicationz_Link.h类声明、资源 ID 定义、预编译头声明源文件MFCApplicationz_LinkDlg.cpp、CGameDlg.cpp、MFCApplicationz_Link.cpp、pch.cpp主对话框实现、游戏逻辑实现、程序入口位图llk_main.bmp、fruit_bg.bmp、fruit_element.bmp、fruit_mask.bmp、bitmap6.bmp、Pic.bmp界面背景、棋盘背景、图案素材、透明掩码等工程文件.sln、.vcxproj、.vcxproj.filters、.rc、.rc2、.icoVisual Studio 工程配置、资源脚本其他readme.txt、LICENSE、.gitignore、.gitattributes使用说明、许可证、Git 配置掩码图是 MFC 里做位图透明的经典方案。Windows 的 BitBlt 本身不支持 Alpha 通道透明BMP 只有 1-bit 透明或者干脆不透明所以老派做法是准备一张黑白掩码图白色区域表示「保留原图」黑色区域表示「抠掉」。绘制时先对目标区域执行 SRCAND 把掩码图的黑色部分挖空再对原图执行 SRCPAINT 把图案贴上去。两步拼起来就是透明效果。// 透明位图绘制的两段式 BitBltMFC 下最稳的兼容方案 void DrawTransparentBitmap(CDC* pDC, CBitmap* pBitmap, int x, int y, int w, int h) { CDC memDC, maskDC; memDC.CreateCompatibleDC(pDC); maskDC.CreateCompatibleDC(pDC); CBitmap* pOldMem memDC.SelectObject(pBitmap); // 创建并绘制掩码把图案的透明色变成白色其余变成黑色 CBitmap maskBmp; maskBmp.CreateBitmap(w, h, 1, 1, NULL); CBitmap* pOldMask maskDC.SelectObject(maskBmp); memDC.SetBkColor(memDC.GetPixel(0, 0)); // 取左上角像素当透明色 maskDC.BitBlt(0, 0, w, h, memDC, 0, 0, SRCCOPY); // 两次 BitBlt 完成透明合成 pDC-BitBlt(x, y, w, h, maskDC, 0, 0, SRCAND); pDC-BitBlt(x, y, w, h, memDC, 0, 0, SRCPAINT); memDC.SelectObject(pOldMem); maskDC.SelectObject(pOldMask); }这段代码的逻辑是先用 SetBkColor 指定透明色取图案左上角像素然后 CreateBitmap 生成一张 1 位掩码图第一次 BitBlt 把背景挖空第二次把图案贴上去。参数上要注意 w 和 h 必须与 Bitmap 实际尺寸一致否则掩码错位画出来边缘全是锯齿或色块。如果你用的是 32 位带 Alpha 的 BMP这方案就不适用了得换 AlphaBlend 或者直接把 BMP 转 PNG 用 GDI。提示判断位图是不是带 Alpha 的 32 位 BMP最简单的方法是看文件头里 biBitCount 字段或者直接在 Visual Studio 资源视图里打开图片看属性面板。2.3 配置文件与预编译头sln、vcxproj、pch 的门道.sln 和 .vcxproj 是 Visual Studio 的工程入口。MFCApplicationz_Link.sln 可以从 VS2015 到 VS2022 一路打开打开时如果提示平台工具集升级选「不升级」也能编译——前提是代码里没用到高版本才能编译的特性。.vcxproj 里值得注意的有两处一个是 Character Set 设置Unicode 还是多字节另一个是 Use of MFC 设置Use MFC in a Shared DLL 还是 Use MFC in a Static Library。这份源码的 readme.txt 里应该写了编译环境我建议优先保持默认别手贱改成静态库具体原因放第 5 章避坑里讲。pch.h 和 pch.cpp 是预编译头文件。MFC 项目的 pch.h 里通常是一排#include afxwin.h、#include afxdialogex.h这类头文件pch.cpp 只做生成 .pch 用。预编译头的意义是这些头文件基本不变编译一次缓存下来后面每次编译源文件时不用再解析一遍能省掉大量重复编译时间。改代码时如果动到了 pch.h 里的内容Visual Studio 会触发全量重编译这是正常的别以为是工程坏了。resource.h 是资源 ID 的统一定义处IDD_MFCAPPLICATIONZ_LINK_DIALOG、IDC_BUTTON_START、IDB_BITMAP_MAIN 这些宏都在这里。.rc 文件里凡是引用位图、对话框、菜单的地方最终都映射回 resource.h 的数字。常见的坑是你手动删了 .rc 里某个资源但 resource.h 里还留着宏定义或者反过来Visual Studio 的资源编辑器偶尔会报「ID 已被占用」这时候直接改 resource.h 里的数字就行——注意别和系统资源 ID 撞车0 到 0x7FFF 是用户自定义区。3. 游戏逻辑实现地图生成、连通判定与计时机制的代码解读3.1 地图生成成对填充与洗牌算法连连看的棋盘本质上是一个 R 行 × C 列的二维数组数组元素存的是图案类型编号。为了让每种图案恰好出现偶数次一般是两次最常见的做法是先按「总格数 ÷ 2」生成成对的图案序列然后洗牌打乱顺序。// CGameDlg::InitMap —— 生成一张成对且已打乱的棋盘 void CGameDlg::InitMap(int nRows, int nCols, int nTypes) { ASSERT(nRows * nCols % 2 0); // 总格数必须是偶数否则无法配对 // 第 1 步按顺序生成成对的图案编号 // 每个图案类型出现 2 次所以循环步长是 2 for (int i 0; i nRows * nCols; i 2) m_map[i / nCols][i % nCols] i / 2 % nTypes 1; // 第 2 步Fisher-Yates 洗牌保证随机且每种图案仍恰好 2 个 for (int i nRows * nCols - 1; i 0; i--) { int j rand() % (i 1); // 在 [0, i] 内随机选一个位置 int r1 i / nCols, c1 i % nCols; int r2 j / nCols, c2 j % nCols; std::swap(m_map[r1][c1], m_map[r2][c2]); } }这段代码有两点要解释。第一为什么步长是 2 而不是 1因为我们要保证每种图案数量是偶数直接m_map[i/nCols][i%nCols] i/2 % nTypes 1会让图案 1 占位置 0、1图案 2 占位置 2、3以此类推。第二Fisher-Yates 洗牌的边界j rand() % (i 1)保证每个位置被选中的概率均等如果写成rand() % nRows*nCols洗牌结果的均匀性会变差——这是常见的隐蔽错误。另外记得在 CGameDlg 构造函数或 OnInitDialog 里调用srand((unsigned)time(NULL))否则每次启动游戏的地图布局都一样。3.2 连通判定0 折、1 折、2 折路径的代码实现连通判定是连连看整个项目的核心算法也是面试里常考的「BFS 最多拐两次弯」的变体。它的规则是两个相同图案的格子之间若存在一条路径路径上最多拐两个弯2 折且路径经过的格子均为空或边界外则视为可消除。边界外的特殊处理路径可以绕过棋盘边缘因为棋盘外部的区域在算法里视为通路。// 检查 (r1,c1) 到 (r2,c2) 能否连通0 折 / 1 折 / 2 折依次判断 BOOL CGameDlg::CanConnect(int r1, int c1, int r2, int c2) { if (m_map[r1][c1] ! m_map[r2][c2]) return FALSE; // 图案不同直接排除 if (r1 r2 c1 c2) return FALSE; // 同一格不算 // 0 折同行或同列且中间无阻挡 if (r1 r2 LineClearRow(r1, c1, c2)) return TRUE; if (c1 c2 LineClearCol(c1, r1, r2)) return TRUE; // 1 折拐点 (r1,c2) 或 (r2,c1) 为空且两条线段均无障碍 if (m_map[r1][c2] EMPTY LineClearRow(r1, c1, c2) LineClearCol(c2, r1, r2)) return TRUE; if (m_map[r2][c1] EMPTY LineClearRow(r2, c1, c2) LineClearCol(c1, r1, r2)) return TRUE; // 2 折遍历中间行 i若 (i,c1) 与 (i,c2) 均为空且整段横线、两段竖线均无障碍 for (int i 0; i m_nRows; i) { if (i r1 || i r2) continue; // 已在上面的 0/1 折逻辑覆盖过 if (m_map[i][c1] EMPTY m_map[i][c2] EMPTY LineClearCol(c1, MIN(r1, i), MAX(r1, i)) LineClearCol(c2, MIN(r2, i), MAX(r2, i))) return TRUE; } return FALSE; }LineClearRow / LineClearCol 的实现思路是逐格检查从起点列到终点列之间不含端点的所有格子是否为空。这里有两个细节容易错第一1 折时的拐点 (r1,c2) 和 (r2,c1) 必须是为空的格子因为路径要经过它第二2 折遍历时要把 r1 和 r2 这两行跳过去否则会跟 0 折、1 折重复判断。LineClear 函数通常不检查终点本身因为终点是图案格不是通路。写的时候把「端点是否允许为已消除的空白格」想清楚这个算法就稳了。注意如果你把棋盘边界也当作「外部通路」那么 2 折遍历的下界要改成从 -1 到 nRows-1 代表棋盘上边缘外侧。很多入门代码在这上面栽跟头——棋盘边缘的两个图案明明可以通过外侧绕过去连起来程序却判为不可消除玩家体验就很差。这份源码的 fruit_bg.bmp 边缘留白设计其实也是给这条规则留了视觉上的合法性。3.3 计时计分WM_TIMER 与状态机计时功能在 MFC 里几乎都是 SetTimer 实现的。主对话框或 CGameDlg 收到 WM_TIMER 消息后把剩余时间减 1 秒更新界面上的静态文本控件时间到就弹出结束对话框。加分逻辑通常是点掉一对加 10 分连续消除有连击加成。这些在 CGameDlg.cpp 里都是一眼能看出来的简单逻辑但有几个时序上的坑值得说// 在 OnInitDialog 中启动计时器1 秒触发一次 SetTimer(TIMER_GAME, 1000, NULL); // 消息映射ON_WM_TIMER() 对应下面的 OnTimer void CGameDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_GAME) { m_nTimeLeft--; if (m_nTimeLeft 0) { KillTimer(TIMER_GAME); // 时间用尽先停表再处理结算避免重复弹窗 OnGameOver(); return; } UpdateTimeDisplay(); // 刷新显示剩余时间的静态文本 } CDialogEx::OnTimer(nIDEvent); }这个实现的要点是先 KillTimer 再进结算逻辑否则 OnGameOver 里的弹窗阻塞期间定时器继续回调可能出现重复弹窗或崩溃。另外UpdateTimeDisplay 里如果用 SetDlgItemInt 更新文本记得把 m_nTimeLeft 声明为 int 而不是 unsigned否则倒计时显示到 0 后会变成 4294967295 这种天文数字。这类问题用 Debug 版跑一遍、在 Watch 窗口盯一下变量就从黑匣子变成明牌了。4. MFC交互层实现消息映射、双缓冲绘制与透明位图处理4.1 消息映射让窗口响应鼠标点击与定时器MFC 把 Win32 的窗口过程封装成了消息映射表。你只要在类的 BEGIN_MESSAGE_MAP 和 END_MESSAGE_MAP 之间注册消息处理函数窗口收到对应消息时框架就会自动调用你的函数。连连看里最核心的三个消息是WM_LBUTTONDOWN 处理玩家点击格子、WM_TIMER 处理倒计时、WM_PAINT 处理棋盘重绘。BEGIN_MESSAGE_MAP(CGameDlg, CDialogEx) ON_WM_LBUTTONDOWN() ON_WM_PAINT() ON_WM_TIMER() ON_BN_CLICKED(IDC_BTN_HINT, CGameDlg::OnBnClickedHint) // 提示按钮 END_MESSAGE_MAP() void CGameDlg::OnLButtonDown(UINT nFlags, CPoint point) { // 将客户区坐标换算成棋盘行列号 int col (point.x - m_originX) / m_cellSize; int row (point.y - m_originY) / m_cellSize; if (row 0 || row m_nRows || col 0 || col m_nCols) return; // 点击落在棋盘外直接忽略 if (m_map[row][col] EMPTY) return; // 该位置已被消除 if (!m_bFirstSelected) { m_selRow row; m_selCol col; m_bFirstSelected TRUE; InvalidateRect(GetCellRect(row, col)); // 只重绘选中格效率更高 } else { if (CanConnect(m_selRow, m_selCol, row, col)) { m_map[m_selRow][m_selCol] EMPTY; m_map[row][col] EMPTY; m_nScore 10; PlayEliminateEffect(); // 消除动画或音效的入口 } m_bFirstSelected FALSE; Invalidate(); // 整板重绘刷新消除结果 } CDialogEx::OnLButtonDown(nFlags, point); }这里有个细节值得留意第一次选中时 InvalidateRect(GetCellRect(...)) 只重绘单个格子第二次点击后再 Invalidate() 全盘刷新。这样做的好处是减少无谓的绘制量——棋盘 100 个格子时看不出差别但如果是 12×12 的大棋盘、每格还贴了 32 位带阴影的位图区域重绘和全屏重绘的帧率差距就很明显了。4.2 双缓冲把「闪烁」这个 MFC 老毛病一次治好Windows 窗口在收到 WM_PAINT 时的默认行为是背景先擦成白色或对话框背景色然后再逐格绘制位图。这个「先擦后画」的过程肉眼看起来就是闪烁——尤其是棋盘上几十个格子要循环贴图时闪得你眼睛疼。解决办法就是双缓冲先在内存里建一张和客户区一样大的位图把所有格子都画到这张图上最后一次性 BitBlt 到屏幕上。void CGameDlg::OnPaint() { CPaintDC dc(this); // 设备上下文所有画图操作最终都作用到窗口 // 内存 DC先在内存里画完整张棋盘 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap memBmp; memBmp.CreateCompatibleBitmap(dc, m_rtClient.Width(), m_rtClient.Height()); CBitmap* pOld memDC.SelectObject(memBmp); // 画背景 memDC.FillSolidRect(m_rtClient, RGB(240, 240, 210)); // 逐格绘制水果图案 for (int row 0; row m_nRows; row) { for (int col 0; col m_nCols; col) { if (m_map[row][col] EMPTY) continue; DrawFruit(memDC, row, col, m_map[row][col]); } } // 最后一次 BitBlt 把内存图整块提交到屏幕 dc.BitBlt(0, 0, m_rtClient.Width(), m_rtClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); // memBmp 析构时会自动释放 GDI 对象注意别手动 DeleteObject 导致重复释放 }双缓冲的代价是额外占用一块与客户区等大小的内存位图对连连看这种 2D 游戏来说开销完全可以忽略。真正要注意的是 GDI 对象的生命周期CBitmap 和 CDC 都是 RAII 类析构时自动释放底层句柄如果你在中间调用了 DeleteObject 反而会双重释放导致程序在退出时崩溃。这篇代码里 pOld 必须保存原位图并在结束时选回否则 memDC 析构时会试图释放一个被选入其他 DC 的位图属于未定义行为。4.3 透明与自绘水果图案抠图的两种路线BMP 做透明有两条路线一是第 2.2 节写的掩码图两段式 BitBlt优点是性能好、不依赖 GDI、老版本 Windows 也能跑二是用 TransparentBlt需要链接 msimg32.lib它会自动把指定的某种颜色当作透明色代码量小一半。如果你的资源里已经有 fruit_mask.bmp说明原作者走的是掩码路线如果只有 fruit_element.bmp 而没有掩码就得用 TransparentBlt 或手动生成掩码。TransparentBlt 的关键参数是透明色值代码里一般写成TransparentBlt(dc, x, y, w, h, srcDC, 0, 0, w, h, RGB(255, 0, 255))——这里的 RGB(255, 0, 255) 是品红色也是老游戏资源里最常用的透明色因为水果图案里几乎不会出现这种颜色。用掩码路线的项目要注意掩码图和原图必须逐像素对齐任何一处尺寸不一致都会导致抠图边缘出现黑边或白边。自绘这块常遇到的问题「mfc中cbutton按钮颜色设置步骤」也来自这里——连连看的开始按钮、提示按钮通常不做成标准灰色按钮而是贴一张位图。做法是把 CButton 的 Owner Draw 属性打开然后在 DrawItem 里自己画void CMyButton::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC CDC::FromHandle(lpDrawItemStruct-hDC); CRect rc(lpDrawItemStruct-rcItem); pDC-BitBlt(0, 0, rc.Width(), rc.Height(), m_memDC, 0, 0, SRCCOPY); // 按下状态可做偏移绘制让人感觉按钮被按下去了 if (lpDrawItemStruct-itemState ODS_SELECTED) { pDC-Draw3dRect(rc, RGB(128,128,128), RGB(255,255,255)); } }这里 ODS_SELECTED 判断按钮是否被按下按住时画一个凹陷效果的边框松手时恢复正常。按钮自绘最容易翻车的点是把 m_memDC 在 DrawItem 里临时创建——那会导致每次重绘都创建和销毁 GDI 对象界面频繁刷新时句柄泄露跑几分钟后整个程序画不出来了。正确做法是在 OnInitDialog 里创建好位图并选入 m_memDCDrawItem 只负责贴图。5. 避坑与常见问题排查编译报错、图片花屏与内存泄漏5.1 现象打开工程后编译报错 C2065「IDD_MFCAPPLICATIONZ_LINK_DIALOG 未声明」原因resource.h 没有被包含到使用该 ID 的源文件中。MFC 的对话框类源文件MFCApplicationz_LinkDlg.cpp一般会通过#include resource.h或#include MFCApplicationz_LinkDlg.h后者再包含 resource.h来引入资源 ID。如果 .rc 文件与项目不在同一目录Visual Studio 的包含路径没配好就会在编译 .cpp 时找不到 ID 宏。解决检查 .vcxproj 里 AdditionalIncludeDirectories 是否包含 resource.h 所在目录另一个更稳的办法是在 pch.h 里加上#include resource.h这样所有源文件都能看到资源 ID。5.2 现象图片绘制出来是黑的或者四周有明显色块原因位图是 24 位 BMP且原图没有做透明处理直接用 BitBlt SRCCOPY 贴上去时BMP 自己的黑色或品红背景被当成内容画了出来。另一个常见原因是 memDC.SetBkColor 与掩码图不匹配——掩码图是用某一种特定透明色生成的而代码里指定的透明色是另一种结果是掩码错位。解决先确认 fruit_element.bmp 的透明色是什么。用画图工具打开取左上角第一个像素的颜色值再把代码里的 SetBkColor 改成这个值。如果项目里有 fruit_mask.bmp直接用掩码路线不要混用 TransparentBlt 和掩码。5.3 现象棋盘在拖动窗口或点击时疯狂闪烁原因没有使用双缓冲。默认 WM_PAINT 是先让背景刷成系统色再绘制图形当绘制内容复杂几十个格子加位图时擦除与重绘之间的时间差肉眼可见。另一层原因是窗口启用了 CS_HREDRAW | CS_VREDRAW 样式窗口尺寸变化时强制完全重绘。解决把绘制逻辑全部搬进内存 DC见 4.2 节最后一次性 BitBlt。另外在 OnPaint 里避免调用 Invalidate()否则会触发无限重绘循环——你会在任务管理器里看到 CPU 占用飙到 100%。5.4 现象Debug 版正常Release 版一点开始游戏就崩溃原因最常见的是未初始化的局部变量或数组越界。Debug 版编译器会把未初始化的栈内存填充成 0xCC某些越界读可能碰巧读到合理值Release 版不做这个填充未初始化的 m_map 元素是随机值被当作图案编号索引位图数组时越界崩溃。解决所有成员变量在构造函数里显式初始化别依赖 memset 清零——m_map 如果是二维 C 数组用memset(m_map, 0, sizeof(m_map))没问题但如果是std::vectorstd::vectorintmemset 就是未定义行为。另外把 Release 版的「启用运行时检查」和「迭代器检查」打开能帮你定位到越界那一行。5.5 现象换一台电脑或换一个 VS 版本后位图加载失败或弹窗出不来原因.rc 文件里位图路径写的是绝对路径或者位图没被正确添加到 .rc 的「位图」区段导致 LoadBitmap(IDB_BITMAP_MAIN) 返回 NULL。这也和「mfc静态库中对话框创建失败」的场景相关——当项目从共享 DLL 改为静态库编译时资源句柄可能来自不同模块FindResource 找不到对话框模板弹窗直接失败。解决用 LoadBitmap 之后立刻 ASSERT(hBmp) 或判断返回值把 .rc 中的位图条目改成相对路径res\\fruit_element.bmp。静态库场景下在 InitInstance 里加一句AFX_MANAGE_STATE(AfxGetStaticModuleState())能解决大部分资源找不到的问题。若还不行打开 VS 的资源视图确认 IDB_BITMAP_MAIN 那项前面的图标是有效位图而不是感叹号。5.6 现象退出游戏时输出窗口报 GDI 对象泄漏或程序退出时卡死原因最常见的模式是每次 OnPaint 都 CreateCompatibleDC CreateCompatibleBitmap但没有保存或恢复原来的选择对象析构时 GDI 句柄没有正确释放。另一个高频原因是定时器没有在 OnDestroy 里 KillTimer——对话框销毁时定时器回调还在排队消息循环结束后回调访问已销毁的窗口句柄直接崩溃。解决严格遵循「SelectObject 的旧对象必须最后 SelectObject 回来」的纪律在 OnDestroy 里 KillTimer(TIMER_GAME)用任务管理器看 GDI 对象计数——正常玩一局应该在 50 到 100 之间波动如果一直在涨就是 GDI 泄漏。最怕的是那种每帧都创建 CDC 和 CBitmap、但从来不放回也不删除的代码这种代码哪怕编译通过并运行正常时间一长必定翻车。6. 扩展方向把课程设计升级成完整产品的三个改造点6.1 难度参数化让同一套代码支撑多档关卡现在写死的棋盘尺寸和图案种类数量改成在开始游戏前弹一个对话框让玩家选。常见做法是把 InitMap 的 nRows、nCols、nTypes 从成员变量读取初值在 OnInitDialog 里按难度档位赋值。简单档 8×8、4 种图案中等档 10×10、6 种困难档 12×12、8 种。改动量很小但「能调难度」和「写死一关」在课程设计答辩时是两个印象分。6.2 分数与排行榜持久化用 CStdioFile 或简单的写注册表方式把最高分存下来。注册表方式适合 MFC 程序——WriteProfileInt 一行就能写读取也方便还不用处理文件路径问题。排行榜可以做成一个模态对话框显示历史前五名。如果不想碰注册表用std::ofstream写一个 score.dat 到 exe 所在目录注意处理「目录不可写」的情况。6.3 提示与洗牌把「卡死」变成「可自救」无限模式里玩家卡住时需要一个提示按钮——遍历棋盘找出第一对可连通的格子并高亮。这个功能考的就是 3.2 节的 CanConnect双重循环扫一遍就行。洗牌则是把所有剩余图案重新随机排列但要保证无解时能自动洗到有解。这两项加起来 30 行代码但直接把游戏从「卡死只能退出」变成「有完整交互闭环」。下载这份 MFC 连连看源码后我建议你别急着编译先按第 2 章的顺序把 readme、.rc 和 resource.h 过一遍再动手改。以前我调这种 MFC 小游戏时最爱犯的毛病是上来就编译、跑起来了才改逻辑结果每次都在消息映射和 GDI 释放上反复横跳。后来我强制自己把顺序改成「资源 → 消息 → 算法」定位问题的速度明显不一样。从那以后我每拿到一个陌生工程都先看 .rc 和资源文件再碰代码这习惯帮我避开了无数个黑匣子。希望这份拆解能帮你少走一段弯路。本文还有配套的精品资源点击获取