
简介单机记忆翻牌游戏是一份基于 VC 与 MFC 框架实现的 C 桌面小游戏工程面向正在学习 Windows 编程、准备课程设计或希望用完整案例理解对话框应用开发的初、中级开发者。压缩包共 419 个文件约 6.63MB其中 384 个 bmp 图像承担卡面与图标素材11 个 h 头文件和 9 个 cpp 源文件构成核心逻辑另有 5 个 mp3 音效以及 sln、vcxproj、rc 等工程与资源文件目录组织较完整。已有 508 人学习。项目覆盖对话框界面布局、卡片点击消息映射、翻牌与匹配判定、计时器自动回翻、计分统计和位图资源加载等环节读者可借助源码理清 MFC 的消息机制与资源管理方式也能在编译运行后对照代码理解从界面到逻辑的完整实现过程。1. 单机记忆翻牌游戏一份 VC 工程从白屏到能点的全过程上周 A同学 把一份 VC 工程丢过来说在他机器上跑得好好的拷到同事电脑上一点开就是白屏。解压后一看是个单机记忆翻牌游戏MFC 对话框工程四乘四的牌阵点开两张图案一样就消掉右上角挂着计时和步数。这类东西在 C 练手项目里出现频率高得离谱高到很多人拿到手只想赶紧编译交差结果全卡在字符集、资源路径和 GDI 闪烁上。下面按这份工程的实际结构走一遍它由哪些类撑起来、怎么在全新环境里跑通、洗牌和翻牌的核心代码长什么样以及二次开发时哪几个地方能放心动、哪几个地方一动就崩。刚接触 MFC 想找个能跑的小工程练手的拿到类似源码想拆开看的都能照着走。2. 工程骨架MFC 对话框项目里谁管数据、谁管绘制拆这份代码时我先做的不是双击 .sln而是把文件按层摊开看。原因很直接MFC 对话框工程天生容易长成“一个 Dlg 类包打天下”两三千行全塞在 OnPaint、OnLButtonDown 和各个 OnBnClicked 里能跑但接手的人基本无从下手。这个翻牌游戏规模不大四乘四牌阵、八对图案、一个计时器正好够把职责切干净切完再回头看改动成本能降一个数量级。2.1 目录与资源清单一个能跑的最小集合这份工程没有第三方依赖纯 VS MFC GDI所以目录很平能跑起来需要的东西就下面这些路径作用MemoryGame.sln / MemoryGame.vcxproj解决方案与工程文件工具集决定能不能直接打开MemoryGameApp.h / .cppCWinApp 派生类进程入口只管初始化和弹主对话框MemoryGameDlg.h / .cpp主对话框消息映射、鼠标交互、绘制全在这儿Card.h牌的数据结构纯 POD不含任何绘制代码GameCore.h / .cpp洗牌、配对判定、胜负判断、步数统计resource.h / MemoryGame.rc对话框模板、控件 ID、位图资源 IDres/card_back.bmp、card01.bmp…card08.bmp一张牌背加八张牌面24 位 bmp表里没列的 bin/、obj/、.vs/ 都是编译产物删掉不影响。真正需要留意的是 res 目录很多版本把位图直接嵌进 .rc也有版本放在 exe 同级目录用相对路径读。前者编译进可执行文件拷走不漏资源后者一旦路径写死成绝对路径换台机器就白屏——A同学 遇到的就是这个。我一般优先选嵌入资源省掉一整类“在我这儿好好的”问题。2.2 三个类的职责边界数据、规则、表现拆开分层这件事在小项目里常被嫌麻烦但翻牌游戏有个特殊性配对规则和界面重绘是两套节奏。规则层只关心“这两张是不是一对”表现层只关心“这张牌该画正面还是背面”。混在一起写后面想加个“三秒预览”或者“连击加分”就得在绘制代码里找业务逻辑。// Card.h —— 纯数据不碰 CDC不碰窗口句柄 struct Card { int pairId -1; // 配对编号相同即为一对 int imageIdx 0; // 牌面图索引对应 IDB_CARD01 imageIdx bool faceUp false;// 当前是否处于翻开状态 bool matched false;// 是否已配对消除 CRect rect; // 在客户区里的矩形绘制和命中测试共用 }; // GameCore.h —— 规则层只依赖 std::vectorCard class GameCore { public: void Reset(int rows, int cols, unsigned seed); bool Flip(int index); // 返回 true 表示这一回合需要定时回翻 void ResolvePending(); // 把没配上的两张翻回去 bool IsWin() const { return m_matchedPairs * 2 (int)m_cards.size(); } int GetMoves() const { return m_moves; } const std::vectorCard Cards() const { return m_cards; } private: std::vectorCard m_cards; int m_rows 4, m_cols 4; int m_pendingA -1, m_pendingB -1; // 本回合翻开的两张下标 int m_moves 0, m_matchedPairs 0; };几个参数值得说清楚。pairId用 0 到 pairCount-1 的整数而不是图案名字符串比较时只比整数省掉字符串开销也方便外部配置表映射。rect放在 Card 里而不是绘制时现算是因为命中测试和绘制必须用同一套坐标分开算早晚对不上。Flip的返回值是个小设计返回 true 表示“这两张没配上需要延时翻回”由对话框决定什么时候起定时器规则层不碰 SetTimer才能在控制台里单独跑测试。2.3 消息映射没有 main 的游戏主循环写惯了 Win32 或者 SDL 的人第一次看 MFC 会找 main 函数找不到。MFC 的“主循环”就是消息泵游戏逻辑挂在消息处理函数上靠消息映射表把窗口消息分派到成员函数。这张表就是这套代码的控制流入口BEGIN_MESSAGE_MAP(CMemoryGameDlg, CDialogEx) ON_WM_PAINT() // 重绘全部牌面 ON_WM_ERASEBKGND() // 屏蔽系统擦背景配合双缓冲 ON_WM_LBUTTONDOWN() // 点击翻牌的唯一入口 ON_WM_TIMER() // 回翻延时 秒表 ON_BN_CLICKED(IDC_BTN_RESTART, CMemoryGameDlg::OnBnClickedRestart) END_MESSAGE_MAP()ON_WM_xxx系列要求头文件里声明对应的afx_msg void OnXxx()函数签名和消息类型一一对应写错了不会报错只会“点了没反应”这是排查时最费时间的一类。ON_BN_CLICKED走的是控件通知第二参数是成员函数指针比老式的ON_COMMAND加 ID 更好定位。ON_WM_ERASEBKGND单独列出来不是为了好看后面双缓冲那一节会讲不拦住它系统会在你的绘制之后又刷一遍背景闪烁就是这么来的。3. 跑起来从工具集重定向到第一张牌翻开把工程从压缩包里解出来第一步永远是先看 .vcxproj 里的 PlatformToolset 和 CharacterSet而不是急着按 F5。这两个属性决定了你在新环境里看到的是编译错误还是一屏乱码改起来只要两分钟不改能耗掉一晚上。下面按我实际操作的顺序走一遍。3.1 工具集与字符集先解决打开就报错老工程常见的情况是工具集写死 v142 甚至 v141新装的 VS 只有 v143双击 .sln 直接弹重定向提示重定向之后仍然报 MSB8020。最稳的做法是打开 .vcxproj 手动对齐顺便把 MFC 链接方式和字符集一起定下来!-- MemoryGame.vcxproj 片段工具集、MFC 链接方式、字符集一次改到位 -- PropertyGroup LabelConfiguration ConfigurationTypeApplication/ConfigurationType !-- Static 表示静态链接 MFC拷给别人不用装运行库Dynamic 需要目标机有 MFC DLL -- UseOfMfcStatic/UseOfMfc !-- 统一 Unicode。别一半源码是 MBCS 的 char*一半是宽字符 -- CharacterSetUnicode/CharacterSet PlatformToolsetv143/PlatformToolset /PropertyGroupUseOfMfc有三档false 表示不用 MFCStatic 静态链接Dynamic 动态链接。选 Dynamic 时 VS 会自动加_AFXDLL预处理器定义千万别自己在预处理器里手写写重了或者写漏了都是 LNK2019。CharacterSet改成 Unicode 之后源码里所有字符串都要改成_T(字符串)或TEXT(字符串)否则一串 C2664 会教做人。有人图快把 CharacterSet 改回 MultiByte编译确实过了但只要工程里混进一个宽字符 API比如某些版本的 CImage 接口问题会以更隐蔽的方式回来。改完记得清一次 bin 和 obj。VS 的增量编译对被改过工具集的老工程不太聪明残留的 .obj 会让新配置的链接结果与预期不符这是我自己翻过车的地方明明改对了属性页编译还是报同样的错清了目录才好。3.2 资源加载LoadBitmap 和 CImage 的取舍资源这块的坑不在读取本身而在读取的时机和句柄所有权。见过不少版本在 OnPaint 里每次都LoadBitmap一遍一秒钟几十次句柄越积越多任务管理器里 GDI 对象数一路往上飙。正确做法是启动时一次性读进来长期持有// 位图只在初始化时加载一次OnPaint 里直接复用 void CMemoryGameDlg::LoadCardBitmaps() { for (int i 0; i 8; i) { CBitmap tmp; // IDB_CARD01 在 resource.h 里连续定义资源 ID 可以直接做算术 tmp.LoadBitmap(IDB_CARD01 i); // Detach 把句柄从临时对象上摘下来再 Attach 给成员变量 // 直接赋值会让两个 CBitmap 析构时 DeleteObject 同一个句柄二次释放必崩 m_cardBmp[i].Attach((HBITMAP)tmp.Detach()); } m_backBmp.LoadBitmap(IDB_CARD_BACK); // 牌面需要透明通道时改用 CImageLoadBitmap 读不了 alpha // CImage img; // img.LoadFromResource(AfxGetInstanceHandle(), IDB_CARD_PNG); // 再用 img.Draw(pDC-GetSafeHdc(), x, y) 贴图 }LoadBitmap只认 bmp位深要跟资源实际一致CImage支持 png 并且能带 alpha代价是绘制路径不一样得用CImage::Draw而不是CDC::BitBlt。Detach/Attach这一对是这份代码里最容易看漏的地方CBitmap析构会调DeleteObject临时对象和成员变量如果持有同一个 HBITMAP程序退出时就是一次 double free表现为关闭窗口时偶发崩溃调试器里指向 mfc140 深处很难往回追。3.3 运行验证四步确认这份工程是活的改完配置按 F5别急着看界面好不好看先按这四步确认链路是通的编译输出 0 error链接无 LNK2019、LNK1120bin 目录下生成 exe窗口出来后牌背排布正常四行四列不重叠、不越界缩放窗口时牌面跟着重排点第一张牌翻开点第二张不同图案约 0.8 秒后两张一起翻回点两张相同的保持翻开并计入步数点“重新开始”牌堆重洗、计时归零、步数清零连续配完八对弹出胜利提示且定时器停止。第 3 步是最能暴露问题的如果两张牌翻回的时间不一致说明定时器里只处理了 pendingB如果配对的牌翻回后又弹回来说明 matched 标志没在 ResolvePending 里跳过。第 4 步的“定时器停止”容易被忽略秒表不停的话胜利提示后面 wx 还在走看着很别扭。4. 核心模块拆解洗牌、翻牌状态机与双缓冲真正决定这套代码好不好改的是三个地方牌堆怎么建、翻牌状态怎么表达、画面怎么刷。这三个模块加起来不到三百行但每一处都有典型写法与常见误写的差别下面逐个拆。4.1 牌堆建模与洗牌Fisher-Yates 而不是随机交换先按 pairId 顺序铺牌再做一次洗牌是最省事的建模方式。洗牌算法的选择直接影响手感——用“循环 n 次随机交换两个位置”的写法牌堆看起来也乱但分布不均匀运气差的时候连续几局开头都是同一对玩家会怀疑游戏作弊。void GameCore::Reset(int rows, int cols, unsigned seed) { m_rows rows; m_cols cols; const int total rows * cols; // 行列乘积必须是偶数否则会有单牌 m_cards.assign(total, Card{}); m_moves 0; m_matchedPairs 0; m_pendingA m_pendingB -1; // 先顺序铺pairId 为 0,0,1,1,2,2 ... 保证每种图案恰好两张 for (int i 0; i total; i) { m_cards[i].pairId i / 2; m_cards[i].imageIdx (i / 2) % 8; // 图案不够时循环复用 } // Fisher-Yates从后往前每次与 [0, i] 内的随机位置交换 std::mt19937 rng(seed); for (int i total - 1; i 0; --i) { std::uniform_int_distributionint dist(0, i); std::swap(m_cards[i], m_cards[dist(rng)]); } }rows * cols必须是偶数这点要在调用方就校验奇数格子的翻牌游戏没法保证全部配对。seed参数是我加得最值的一个调试时传固定值每次洗出来的牌序完全一样复现“第三张牌点了没反应”这类问题不用靠运气正式运行时传(unsigned)time(nullptr)就行。mt19937加uniform_int_distribution而不是rand() % n是因为后者存在取模偏置而且RAND_MAX在很多实现里只有 32767格子一多就会丢随机性。洗牌只解决顺序牌的屏幕位置还得单独算。常见做法是在 Reset 之后遍历一遍按rows/cols把客户区分成等大的格子留出边距后写进Card::rect。窗口大小变化时ON_WM_SIZE重新算一遍 rect而不是重洗牌——顺序不该因为拖了下窗口就变。4.2 翻牌状态机两张牌、一个定时器、三个状态翻牌这件事本质上是个三状态机空闲、翻了一张、翻了两张待判定。用枚举写当然可以但用两个下标哨兵更省代码逻辑也更贴近“回合”这个语义// 返回 true 表示本回合没配上需要外部起定时器延时翻回 bool GameCore::Flip(int index) { if (index 0 || index (int)m_cards.size()) return false; Card c m_cards[index]; if (c.matched || c.faceUp) return false; // 已消除或已翻开忽略重复点击 c.faceUp true; if (m_pendingA 0) { // 本回合第一张 m_pendingA index; return false; } m_pendingB index; // 本回合第二张 m_moves; if (m_cards[m_pendingA].pairId m_cards[m_pendingB].pairId) { m_cards[m_pendingA].matched true; m_cards[m_pendingB].matched true; m_matchedPairs; m_pendingA m_pendingB -1; // 配对成功清空回合 return false; // 不需要回翻 } return true; // 配对失败等定时器处理 }m_pendingA 0作为“本回合还没翻第一张”的判据比额外维护一个 enum 少一半状态迁移代码代价是必须在定时器里严格清空。这个代价不小ResolvePending如果只翻回m_pendingB忘了m_pendingA下一回合就会带着一个幽灵下标继续跑表现为“第三张牌点了没反应”或者“两张不相关的牌莫名其妙一起消失”。定时器这边两个 ID 分工明确一个管回翻延迟一个管秒表void CMemoryGameDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_FLIP_BACK) { KillTimer(TIMER_FLIP_BACK); // 单次定时器先杀再处理 m_core.ResolvePending(); if (m_core.IsWin()) { KillTimer(TIMER_TICK); // 胜利后秒表要停 SetDlgItemText(IDC_STATIC_TIP, _T(全部配对完成)); } Invalidate(FALSE); // FALSE 表示只重绘不擦背景 } else if (nIDEvent TIMER_TICK) { m_seconds; SetDlgItemInt(IDC_STATIC_TIME, m_seconds); } CDialogEx::OnTimer(nIDEvent); }SetTimer(TIMER_FLIP_BACK, 800, nullptr)的 800 毫秒是手感参数低于 500 玩家记不住位置高于 1200 又显得拖沓700 到 900 之间比较舒服。KillTimer必须和SetTimer的 ID 严格对应写成字面量 1、2 是常见的笔误来源用enum { TIMER_FLIP_BACK 1, TIMER_TICK 2 }定死。另外 WM_TIMER 不保证精度它是消息队列里的低优先级消息别拿它当帧循环或者精确计时算分的时候用GetTickCount64()更靠谱。4.3 双缓冲绘制把闪烁压下去GDI 直接往窗口 DC 上画每画一张牌屏幕就刷新一次牌一多就是满屏闪。双缓冲的思路很朴素先画到内存位图画完一次性贴上去。坑在于两个 API 的参数写错一个就是全黑。void CMemoryGameDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); if (rc.IsRectEmpty()) return; CDC mem; mem.CreateCompatibleDC(dc); // 兼容位图必须基于窗口 DC 创建传 mem 会得到单色位图画出来一片黑 CBitmap buf; buf.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* old mem.SelectObject(buf); // 记下旧句柄结束时必须还原 mem.FillSolidRect(rc, RGB(24, 28, 36)); for (const Card c : m_core.Cards()) { CBitmap* src c.faceUp ? m_cardBmp[c.imageIdx] : m_backBmp; DrawCard(mem, c.rect, *src, c.matched); // matched 时画半透明蒙版 } dc.BitBlt(0, 0, rc.Width(), rc.Height(), mem, 0, 0, SRCCOPY); mem.SelectObject(old); // 不还原位图句柄泄漏长时间运行必崩 } // 系统擦背景会覆盖双缓冲结果直接返回 TRUE 拦掉 BOOL CMemoryGameDlg::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; }三个细节分别对应三种典型故障。CreateCompatibleBitmap(dc, ...)里的dc必须是CPaintDC或CClientDC也就是绑定到真实窗口的 DC换成内存 DC创建出来的是 1 位单色位图效果就是整屏黑而代码逻辑看起来完全正确非常迷惑。SelectObject之后必须还原旧句柄否则位图对象一直被 DC 持有析构时删不掉任务管理器里 GDI 对象数会缓慢上升。OnEraseBkgnd返回 TRUE 则是拦掉系统的背景擦除不拦的话每帧都是“擦一次、画一次”闪得比不用双缓冲还厉害。DrawCard这种辅助函数我一般单独放参数是CDC、目标矩形、源位图和是否已消除。用mem.StretchBlt做缩放源位图尺寸固定目标矩形跟着窗口变这样换分辨率不用重做资源。已消除的牌不要直接跳过不画画一张暗色蒙版更有反馈感玩家能看清哪些位置已经空了。5. 避坑与排查五条血泪记录覆盖编译、闪退、闪烁与资源丢失这套代码从别人机器上搬过来踩的坑集中在两类编译链接期的配置错位和运行期的资源与句柄问题。下面五条是我实际遇到过的每条都按现象、原因、解决写遇到同类问题可以直接对照。5.1 编译链接阶段的三个坑第一条打开就提示平台工具集未安装。现象是双击 .sln 弹出重定向对话框重定向完成后编译仍报 MSB8020说找不到 v142 生成工具。原因是老工程在多个 PropertyGroup 里写死了 PlatformToolsetVS 的自动重定向只改了其中一部分配置通常是 Debug|Win32Release 或者 x64 那几组还留着老值。解决办法是直接在 .vcxproj 里全局搜PlatformToolset把每一处都改成当前安装的版本或者在属性页里逐个配置切换改完删掉 bin、obj 重新生成。第二条一串 C2664无法将 const char 数组转换为 LPCWSTR。现象是改完字符集之后原来能编过的代码全红了报错集中在AfxMessageBox、SetDlgItemText、LoadBitmap这几类调用。原因是工程设成了 Unicode所有接受LPCTSTR的接口都变成宽字符版本而源码里还在用窄字符串字面量。解决办法是把字符串统一包进_T()或TEXT()宏LoadBitmap(IDB_XXX)这类宏本身就是整数不受影响但千万别写成LoadBitmap(_T(IDB_XXX))。回退到 MultiByte 只能压住症状后面接 CImage 或者调用宽字符 API 时问题会以更难看的形式回来。第三条链接报 LNK2019符号是 _imp开头的 CDialogEx 构造。现象是编译全过链接阶段说某个 MFC 符号无法解析错误信息里带__imp_。原因是UseOfMfc和预处理器定义不一致典型场景是从别的工程拷了 .vcxproj 片段Dynamic 配置下缺_AFXDLL或者反过来静态配置下多了_AFXDLL。解决办法是不要在预处理器里手写_AFXDLL交给属性页的“在共享 DLL 中使用 MFC / 在静态库中使用 MFC”控制改完清一次中间目录。5.2 运行阶段的两个坑第四条窗口出来一片黑或者牌面全是黑块。现象是对话框能弹出控件也在但客户区什么都没有或者每张牌都是一块纯黑矩形。原因有两个高发点一是CreateCompatibleBitmap传了内存 DC 而不是窗口 DC创建出单色位图二是 bmp 资源的位深和屏幕不匹配256 色资源在 32 位色桌面上会走调色板BitBlt出来就是黑的。解决办法是先确认CreateCompatibleBitmap的第一个参数是CPaintDC再把资源用画图工具另存为 24 位或 32 位 bmp如果是 png 带 alpha直接换 CImage 那条绘制路径别用 LoadBitmap 硬读。第五条牌翻过去不回来点第三张直接卡住。现象是点了两张不匹配的牌它们一直保持翻开再点别的牌毫无反应或者两张不相关的牌突然一起消失。原因通常是Flip返回 true 之后没有SetTimer或者OnTimer里忘了KillTimer导致重复触发也可能是ResolvePending只清了 pendingB 没清 pendingA。解决办法是把“起定时器”和“清回合”这两件事绑在同一个函数里Flip返回 true 就立刻SetTimer(TIMER_FLIP_BACK, 800, nullptr)OnTimer第一行先KillTimerResolvePending里两张牌的faceUp和两个 pending 下标一起清空。我习惯在ResolvePending末尾加一句断言检查 pending 两个值都是 -1调试期能立刻抓出漏清。6. 二次开发难度曲线、关卡配置与皮肤替换想把这份工程改成自己的东西改动收益最高的是把“写死的四乘四”变成可配置。GameCore::Reset(rows, cols, seed)已经把行列做成参数剩下的是从外部读配置。我一般用 ini 走GetPrivateProfileInt不引第三方库五行代码搞定// 从 exe 同级的 game.ini 读难度参数读不到就用默认值 int rows GetPrivateProfileInt(_T(level), _T(rows), 4, iniPath); int cols GetPrivateProfileInt(_T(level), _T(cols), 4, iniPath); int backMs GetPrivateProfileInt(_T(level), _T(flipBackMs), 800, iniPath); m_core.Reset(rows, cols, (unsigned)time(nullptr)); SetTimer(TIMER_FLIP_BACK, backMs, nullptr);可调的参数不多但每个都直接影响体验列出来方便按需改参数默认值调整效果rows / cols4 / 4决定总牌数乘积必须为偶数flipBackMs800回翻延迟700900 手感最好previewMs0开局预览全部牌面的时长大于 0 才有imageCount8图案种类数不够时按取模复用皮肤替换比想象中简单因为资源 ID 是连续的。把IDB_CARD01到IDB_CARD08换成新的 bmp尺寸保持一致就行想上带透明边的 png就把m_cardBmp数组的类型从CBitmap换成CImageDrawCard里改一行绘制调用。真正需要注意的是尺寸StretchBlt能缩放但缩放比太夸张时牌面会糊最好是按目标格子大小重做一套资源。验证改动是否安全我有个固定套路给GameCore写一个不依赖 MFC 的小测试固定 seed 后连续调用Flip断言配对的牌最终matched为真、步数等于预期、IsWin()在最后一对配完时返回真。规则层不碰窗口句柄这段测试可以在控制台工程里直接跑几十毫秒完成比手点一百次靠谱得多。表现层的改动就只能靠看重点盯两处窗口拖大拖小时牌面是否跟着重排、已消除的牌是否还有残留绘制。从那以后我每次改这类 GDI 工程都强制先跑一遍固定 seed 的规则测试再手动点三局确认手感和闪烁最后才谈界面美化。顺序颠倒过一次界面调了两小时回头发现是洗牌算法漏了一张牌白忙。希望帮到你。本文还有配套的精品资源点击获取