
简介作为一款基于MFC框架开发的单机记忆翻牌游戏它面向C与Windows编程初学者完整演示了对话框界面设计、消息映射、位图资源管理与计时器逻辑适合通过源码学习传统MFC游戏开发基础。压缩包共419个文件、6.63MB以384张bmp图标素材为主体另含11个h头文件、9个cpp源文件、5个mp3音效以及rc资源描述和Visual Studio工程配置构成可直接编译学习的完整项目。已有504人学习下载说明其在MFC学习者中有一定热度。通过阅读其完整源码可掌握卡牌布局与翻牌动画、记忆匹配检测、计分刷新等核心机制同时了解CBitmap图片加载、定时器控制卡牌回翻、多媒体播放音效等实用技巧大量bmp素材还可用于后续二次开发与界面主题替换整体上是理解MFC控件交互与资源管理的良好范例。1. 单机记忆翻牌游戏一个MFC窗口程序练手的最小完整闭环出差路上同行的同事说想找个不联网、打开就能玩的小游戏打发时间我翻出电脑里一堆老工程干脆用MFC写了个单机记忆翻牌游戏。十六张牌随机洗开铺在窗口上点开两张做匹配图案相同就地消除不同就自动翻回去全部消完看耗时和步数。逻辑不长但窗口初始化、消息映射、GDI绘制、定时器、状态判断这些桌面程序的骨架全都有了。这项目最适合两类人刚学完C、想弄明白“一个窗口程序到底怎么转起来”的初学者以及要快速改造桌面小工具的老开发。牌桌排布、洗牌算法、翻牌判定这些模块边界很干净改一改就能塞进别的工具当交互组件这也是我把它整理成这篇笔记的原因。2. 工程结构与界面框架从CWinApp到自绘牌面的窗口组织2.1 程序骨架CWinApp与对话框主窗口MFC里一个窗口程序是从CWinApp的InitInstance开始执行的。这项目不需要文档序列化、不需要多窗口拆分所以没走Doc/View那套重量级框架直接让主对话框作为窗口主体InitInstance里创建它然后DoModal进入消息循环。这是单机小游戏最省事的组织方式所有游戏状态都收在对话框类里调试的时候一个断点就能看全貌。工程文件结构不复杂核心文件就几个整理成表格如下文件职责关键内容Game.cpp程序入口CGameApp::InitInstance创建主对话框GameDlg.h / GameDlg.cpp主对话框与全部游戏逻辑洗牌、翻牌、匹配、计时、绘制Resource.h资源ID定义对话框ID、图标ID、控件IDGame.rc对话框模板与图标资源主对话框布局、程序图标、版本信息InitInstance里最核心的就两件事构造主对话框对象、调用DoModal。DoModal返回后整个消息循环结束程序随之退出。这种写法比折腾单文档模板直观得多牌桌、按钮、状态都挂在一个对话框上出了问题顺着调用栈就能定位到具体成员函数。2.2 牌桌数据模型一张牌怎么描述牌面的数据结构是全部逻辑的地基。每张牌除了要知道自己被分配到哪个图案还得记录当前处于什么状态以及绘制时落在窗口的哪个区域。我最初只用一个数组存图案编号后来发现翻回、消除、重绘全都要额外的状态位干脆把一张牌定义成一个结构体#define CARD_COUNT 16 struct Card { int nPattern; // 图案编号0~7两两成对 BOOL bOpen; // 当前是否翻开 BOOL bMatched; // 是否已消除 CRect rc; // 在客户区中的绘制矩形 };注意这里用两个BOOL组合表达三态bOpenFALSE且bMatchedFALSE是盖牌bOpenTRUE且bMatchedFALSE是翻开但未消除bMatchedTRUE表示已配对消除。两个布尔位组合出一个状态机后续翻牌逻辑就是对这两个位的流转操作。主对话框类里再挂一组游戏全局状态。m_nFirstIndex和m_nSecondIndex必须分开存因为不匹配时要等定时器触发后同时翻回这两张牌只记一个索引必然丢状态。m_bProcessing是防重入的关键标志翻回动作没完成前不允许再点牌否则状态会乱成一锅粥。class CGameDlg : public CDialogEx { Card m_cards[CARD_COUNT]; int m_nFirstIndex; // 第一张翻开的牌下标-1表示当前没有待配对的牌 int m_nSecondIndex; // 第二张翻开的牌下标用于定时翻回 int m_nSteps; // 累计步数 int m_nSeconds; // 累计游戏秒数 BOOL m_bProcessing; // 正在等待翻回禁止继续点牌 // 省略构造与消息处理函数声明 };再看一眼就明白整个游戏的核心状态其实就这几行。后面所有功能——洗牌、翻牌、计时、胜负判断——都在围着这组成员变量转。这也是我推荐初学者自己敲一遍的原因状态定义清楚了逻辑写起来不会绕。2.3 消息映射与自绘方案为什么不摆一堆CButton摆16个CButton控件、每个绑一个点击事件看起来是最直观的做法实际写起来全是坑控件ID要连续编号、每个按钮都要处理不同ID、想加动画效果时按钮重绘又受限。所以在MFC做棋盘、牌桌类界面常见做法是自绘——整个客户区用一张画布管理鼠标点击时用坐标反查落在哪张牌上。消息映射先列出来每个消息负责一个方向BEGIN_MESSAGE_MAP(CGameDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_WM_TIMER() ON_WM_ERASEBKGND() ON_WM_SIZE() END_MESSAGE_MAP()ON_WM_LBUTTONDOWN是点击入口OnPaint负责把16张牌全画出来OnTimer驱动翻回和计时OnEraseBkgnd用来解决闪烁。这套消息分工在MFC窗口程序里非常典型套到别的交互程序上也只是换换函数名。绘制走双缓冲。直接把图案画到DC上会产生明显的闪烁因为系统先把背景擦白再画内容一擦一画视觉上就是闪。双缓冲的套路是先画到内存DC再一次BitBlt整个贴到窗口void CGameDlg::OnPaint() { CPaintDC dc(this); CDC memDC; CBitmap memBmp; memDC.CreateCompatibleDC(dc); memBmp.CreateCompatibleBitmap(dc, m_rcClient.Width(), m_rcClient.Height()); CBitmap* pOld memDC.SelectObject(memBmp); DrawBoard(memDC); // 画背景、画全部16张牌 dc.BitBlt(0, 0, m_rcClient.Width(), m_rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }每次点击触发的Invalidate只负责标记窗口需要重绘真正重绘全部收敛进OnPaint。牌背、牌面图案、已消除的空位全在DrawBoard里根据Card状态分支绘制。这套画法相比控件数组的好处是牌的位置、大小、动画效果都能随时调整不需要和Windows控件系统来回扯皮。提示把内存DC和BitBlt这套双缓冲写法存下来。以后做任何自绘控件、图表、图像预览程序时抗闪烁都用同一个套路。3. 核心逻辑拆解洗牌算法、翻牌状态机与匹配判定3.1 洗牌算法Fisher-Yates让每局都不一样开局时牌面必须是乱序的否则每次玩都一样游戏毫无意义。洗牌前先给每张牌赋图案编号用i除以2取整生成8对0到7的图案紧接着用Fisher-Yates算法从后往前遍历将当前位置与随机位置交换。这个算法每个元素只交换一次时间复杂度O(n)且能保证每种排列出现概率一致void CGameDlg::ShuffleCards() { srand((unsigned)GetTickCount()); // 以当前毫秒时间做随机种子 for (int i 0; i CARD_COUNT; i) { m_cards[i].nPattern i / 2; // 0,0,1,1,...,7,7 m_cards[i].bOpen FALSE; m_cards[i].bMatched FALSE; } for (int i CARD_COUNT - 1; i 0; i--) { int j rand() % (i 1); // 注意范围是0~ii本身也要参与 Card tmp m_cards[i]; m_cards[i] m_cards[j]; m_cards[j] tmp; } }参数说明rand()%(i1)保证每次抽取的下标落在0到i之间从后往前处理是为了避免已经交换过的位置再次被交换。srand加GetTickCount做种子保证每次启动游戏时随机序列不同这是MFC程序里最快速的随机初始化方式。如果你想做更高强度的乱序可以换成std::shuffle配合random_device但对翻牌游戏这种规模Fisher-Yates加rand已经足够。洗牌函数还承担了开局重置的职责把bOpen和bMatched全部清掉。后面做“再来一局”时直接调用同一个函数不需要额外写复位逻辑。3.2 翻牌状态机三态流转与防重入翻牌交互是整个程序最需要小心的地方。鼠标点击后先用坐标定位牌然后走状态机。定位用的是CRect自带的PtInRect16张牌逐个判断点是否落在矩形内。这一步相当于把“点的哪张牌”这个几何问题翻译成数组下标是棋盘类程序的通用手法void CGameDlg::OnLButtonDown(UINT nFlags, CPoint point) { for (int i 0; i CARD_COUNT; i) { if (m_cards[i].rc.PtInRect(point)) { OnCardClicked(i); break; } } CDialogEx::OnLButtonDown(nFlags, point); }OnCardClicked是全程序的心脏。进入函数先做三道禁止判断非游戏状态不响应、翻回等待中不响应、牌本身已翻开或已消除不响应。三道判断缺一道就会出现诡异问题尤其是“点同一张牌两次”这种误操作没有状态过滤直接就会把自己匹配掉void CGameDlg::OnCardClicked(int nIndex) { if (m_eState ! GS_PLAYING) return; if (m_bProcessing) return; if (m_cards[nIndex].bOpen || m_cards[nIndex].bMatched) return; m_cards[nIndex].bOpen TRUE; Invalidate(); if (m_nFirstIndex -1) { // 当前没有待配对的牌这张当作第一张 m_nFirstIndex nIndex; } else { // 已有第一张这张当作第二张进入匹配判定 m_nSteps; if (m_cards[m_nFirstIndex].nPattern m_cards[nIndex].nPattern) { m_cards[m_nFirstIndex].bMatched TRUE; m_cards[nIndex].bMatched TRUE; m_nFirstIndex -1; // 配对成功清空待配对下标 m_nSecondIndex -1; } else { m_nSecondIndex nIndex; // 暂存第二张 m_bProcessing TRUE; // 上锁 SetTimer(TIMER_FLIPBACK, 800, NULL); // 800ms后翻回 } } }参数说明TIMER_FLIPBACK是自定义定时器ID800毫秒是玩家观察牌面的记忆时间。m_nSecondIndex这里必须单独保存因为定时器回调触发时m_nFirstIndex仍然指向第一张两者配合才能把两张牌一起翻回去。m_bProcessing置TRUE后后续所有点击都会被秒拒等定时器翻回并解锁后才能继续操作。这个设计里暗含一个原则状态机进入等待阶段时必须上锁防止外部输入打乱内部流转。别小看这个判断我见过好几个翻牌程序翻车都翻在没有这层锁上后面避坑章节专门展开讲。3.3 匹配判定、计时与胜负匹配规则本身简单两张牌的nPattern相等即配对成功。真正的门道在成功和失败后的状态清理。配对成功后把两张牌的bMatched置TRUE、bOpen保持TRUE牌面显示为已消除图案同时把m_nFirstIndex归-1下一轮点击从零开始。失败则启动定时器等待观察时间结束后统一翻回void CGameDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_FLIPBACK) { KillTimer(TIMER_FLIPBACK); m_cards[m_nFirstIndex].bOpen FALSE; m_cards[m_nSecondIndex].bOpen FALSE; m_nFirstIndex -1; m_nSecondIndex -1; m_bProcessing FALSE; Invalidate(); } else if (nIDEvent TIMER_TICK) { m_nSeconds; Invalidate(FALSE); // 只刷新文字区域不整窗重绘 } CDialogEx::OnTimer(nIDEvent); }TIMER_TICK是每秒触发一次的秒表定时器只在游戏进行中开启。这里Invalidate(FALSE)特意告诉系统不需要擦除背景只重绘文字内容减少不必要的重绘开销。OnTimer里处理完自己的逻辑后必须调用基类版本这是一个很容易漏掉的MFC约定漏掉会导致定时器消息链断裂。胜负判定放在每次状态变更后调用。遍历整副牌统计bMatched的个数等于CARD_COUNT说明全部消除此时停掉秒表定时器弹出结算框void CGameDlg::UpdateMatchCount() { int nMatched 0; for (int i 0; i CARD_COUNT; i) { if (m_cards[i].bMatched) nMatched; } if (nMatched CARD_COUNT) { KillTimer(TIMER_TICK); CString strMsg; strMsg.Format(_T(全部消除用时 %d 秒共 %d 步), m_nSeconds, m_nSteps); MessageBox(strMsg, _T(通关), MB_OK | MB_ICONINFORMATION); } }从这里能看到整个程序的数据流点击牌→修改牌状态→Invalidate触发重绘→重绘时根据状态画不同内容→状态变更后检查胜负。所有环节都围绕m_cards数组这一份数据不存在数据不同步的问题。这也是自绘方案相对控件方案的一大优势控件方案往往要在控件状态和内部数据之间来回搬。4. 常见问题与排查记录闪烁、双击穿透与定时器“翻车”4.1 翻牌画面闪烁严重现象每次点击牌面后整个窗口肉眼可见地闪白一下尤其在拖动窗口时闪烁更明显。原因默认的WM_ERASEBKGND消息会把整个客户区用背景色擦一遍紧接着WM_PAINT再重新绘制内容。一擦一画之间擦出来的白底短暂暴露出来就成了闪烁。这个问题在OnPaint里做双缓冲只能解决一半因为擦除发生在双缓冲绘制之前。解决拦截WM_ERASEBKGND并直接返回TRUE告诉系统背景不需要擦除所有绘制统一交给OnPaint里的内存DC。加一个消息处理函数就好BOOL CGameDlg::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 擦除动作拦截背景绘制由OnPaint的内存DC负责 }这是MFC自绘界面抗闪烁的标准组合拳EraseBkgnd返回TRUE加内存DC双缓冲。两块缺一不可只做双缓冲不拦EraseBkgnd闪烁照样存在。以后做任何自绘控件都能直接搬这套搭配。4.2 双击同一张牌导致误匹配现象快速双击同一张牌牌面瞬间变成“已消除”状态自己跟自己配对成功了。原因第一条点击时m_nFirstIndex被赋为这张牌的下标第二条点击同一张牌时代码没有检查这张牌是否已经是翻开状态直接把它当成第二张进入匹配分支。比较m_nFirstIndex和自己时nPattern必然相等于是立刻匹配。解决进入匹配分支之前统一过滤已翻开和已消除的牌。就是我前面OnCardClicked里那个判断条件单独拿出来看更清楚if (m_cards[nIndex].bOpen || m_cards[nIndex].bMatched) return;这条判断放在函数最开始执行不管是双击还是误触已消除的空位都会被拦下来。血泪经验是状态类游戏里所有输入必须过状态检查别指望操作者会按规矩点击。4.3 延时翻回时“多翻牌”或漏翻牌现象快速连着点两张不匹配的牌紧接着又点第三张结果第三张翻开了但没有配对反应或者等翻回定时器触发时发现同时翻了两张以上的牌。原因这个坑我实实在在踩过。最开始没加m_bProcessing锁定时器等待期间用户继续点牌新点的牌作为“下一次”的第一张被记录而定时器回调触发后翻回逻辑用的是m_nFirstIndex和m_nSecondIndex此时这两个值可能已经被新的点击覆盖了。定时器一触发翻回去的可能是错误的牌甚至会把正确处理到一半的状态打乱。解决加m_bProcessing互斥锁翻回等待期间所有点击直接返回。同时在OnTimer里恢复m_nFirstIndex和m_nSecondIndex为-1确保状态干净。这个问题的根子是“延时操作期间的输入处理”MFC里所有涉及SetTimer做延时的交互逻辑都要考虑这层锁。把锁去掉单测三次大概率能复现这个翻车现场。4.4 最小化再还原后牌面错位现象窗口最小化后重新还原或者鼠标拖动改变窗口大小牌面之间出现重叠、间隙不均匀点牌时定位错乱。原因每张牌的rc矩形是在InitDialog时按当时的客户区大小计算一次之后没再更新。客户区尺寸变了rc还是老尺寸绘制和点击定位自然全乱。解决处理WM_SIZE消息在客户区大小变化时重新计算16张牌的矩形。按行列均分留出边距void CGameDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (cx 0 || cy 0) return; int nCols 4, nRows 4; int nGap 10; int nCardW (cx - nGap * (nCols 1)) / nCols; int nCardH (cy - nGap * (nRows 1)) / nRows; for (int i 0; i CARD_COUNT; i) { int row i / nCols; int col i % nCols; m_cards[i].rc CRect( nGap col * (nCardW nGap), nGap row * (nCardH nGap), (nGap col * (nCardW nGap)) nCardW, (nGap row * (nCardH nGap)) nCardH); } Invalidate(); }把列数行数写成局部变量而不是写死是为了后面改成6×6时只改两个数字。nCardW的除法会截断余数牌桌右侧会有几个像素的剩余空间这在视觉上几乎无法察觉不用过度纠结。4.5 中文与符号牌面显示乱码现象在Release模式下运行时牌面上的中文、特殊符号显示成乱码Debug模式正常或反之。原因工程字符集设置为多字节字符集(MBCS)代码里宽字符串和窄字符串混用CString在MBCS下默认窄字符而_ T(★)这类符号在窄字符集下无法正确表示。解决把项目属性里的字符集改为“使用Unicode字符集”代码里统一用_ T()宏包裹字符串字面量。图案表建议这样定义CString strPattern[8] { _T(◆), _T(●), _T(★), _T(▲), _T(■), _T(♥), _T(♦), _T(✿) };_ T()宏的作用是Unicode编译时展开为L宽字符串MBCS编译时展开为普通字符串跟随编译设置自适应。把这个宏当成字符串定义的习惯后字符集切换基本不会再出乱码问题。如果你的机器上还沿用老VC工程建议所有工程新代码统一Unicode起步。5. 编译运行与调试从源码到双击即玩的完整路径5.1 环境准备与工程导入项目使用MFC框架编译环境需要支持C桌面开发的Visual Studio。安装VS时勾选“使用C的桌面开发”工作负载并在单个组件里确认装有“适用于最新版本生成工具的MFC”组件。没装MFC库的话打开工程会直接报“无法打开包括文件: afxwin.h”这是最典型的环境缺失信号。拿到源码后先确认目录里有解决方案文件Game.sln直接双击用VS打开。如果本机VS版本比我编译时用的新会出现一次性的工程迁移提示接受即可MFC工程跨版本迁移的基本都能顺利转换。打开后先别急着编译做两个属性设置字符集改为“使用Unicode字符集”MFC使用改为“在静态库中使用MFC”。第二个设置很多人容易忽略它的作用是让生成的exe自带MFC运行库。默认的“共享MFC”模式编译出来的exe拷贝到没装VC运行库的机器上会提示缺少mfc140u.dll改成静态链接后一个exe就直接拷走用这才是真正的绿色单机程序。Debug模式下调试方便Release模式下体积更小速度也快日常测试用Debug交付用Release。5.2 资源与配置文件对话框模板、图标、字符串表工程里的资源文件负责定义窗口外观和非代码部分的内容。主对话框模板在Game.rc里用Visual Studio的资源编辑器打开能看到对话框的宽度、高度、标题栏文字“记忆翻牌游戏”以及是否带最大化和最小化按钮。游戏进行中对话框默认居中显示这些属性在资源编辑器里都能直接拖动调整。程序图标放在res/Game.ico替换成自己的图标只需要准备一个ico文件覆盖后重新编译。字符串表存储了MessageBox里的文案比如“全部消除用时 %d 秒共 %d 步”就定义在字符串表里代码运行时按ID加载。如果你想做多语言版本字符串表是MFC工程里做文案集中管理的标准位置。资源ID定义集中在Resource.h。IDD_GAME_DIALOG是主对话框编号IDR_MAINFRAME是程序图标和版本信息。排摸一个MFC工程时先浏览这三个文件Resource.h看有哪些资源Game.rc看资源长什么样GameDlg.h看类和消息映射整个程序的骨架基本就清楚了。5.3 断点调试验证核心链路编译通过只是第一步逻辑是否正确还得靠断点验证。建议在四个位置打断点ShuffleCards函数末尾看洗牌结果是否成对OnCardClicked入口确认点击坐标能正确映射到牌下标匹配分支里看nPattern比较是否符合预期OnTimer里确认翻回动作只发生在不匹配场景。调试运行后手动操作翻开一张牌观察m_nFirstIndex变化翻开第二张观察匹配分支走向故意点两张不同的牌等800毫秒看定时器翻回。配合“监视”窗口展开m_cards数组观察bOpen、bMatched的状态流转能直观看到每张牌从盖牌到翻开再到消除的完整生命周期。一个实用验证技巧在UpdateMatchCount里临时加断点观察nMatched的累加进度。连续配对所有牌时这个值应该从0逐步涨到16且每次配对成功后涨2。如果出现跳变基本可以断定是某次点击把已消除的牌又算进去了回到OnCardClicked检查状态过滤条件就好。定位逻辑问题时不要靠肉眼看界面“猜”监视窗口里的数据比画面更接近真相。6. 二次开发实战难度扩展、动画音效与存档系统的落地思路6.1 把4×4变成6×6布局参数化现在所有代码都写死了4×4共16张牌想扩展难度第一步是把硬编码数字全部抽成成员变量CARD_COUNT改36、nCols改6、nRows改6、图案表从8个图案扩到18个。布局计算已经有OnSize里均分的逻辑只需要把局部变量改成读成员变量。真正要注意的是数组扩容后洗牌生成图案的下标范围要从i/2的8对扩展到18对循环里不能越界。更工程化的做法是把固定数组换成std::vector 开局时按难度参数重建牌组这样后续加“简单4×4、普通6×6、困难8×8”三档难度就只是传个参数的事。洗牌函数改成接收牌数参数布局计算改成接收行列数参数整套代码就能在不同难度间复用这是把这项目往产品化方向推进的第一步。6.2 动画与音效让“翻牌”有反馈静态翻牌虽然功能完整但少点手感。翻牌动画的做法是点击后启动一个30毫秒的细化定时器每帧把牌的绘制宽度按比例缩小缩到0后再反向展开视觉上形成翻面效果。绘制时用rc.Width()乘一个动画百分比系数双缓冲保证动画过程不闪烁。动画期间同样要置m_bProcessing锁防止动画没结束用户又点了下一张。音效接入用PlaySound最省事把WAV文件作为资源加进RC文件代码里用资源方式播放。一个细节用SND_ASYNC异步播放避免PlaySound阻塞UI线程导致窗口卡顿。我把配对成功的提示音和翻牌音分开配了不同WAV实际玩起来反馈感明显强了一个档次。音效和动画这两块是不改动核心逻辑就能让体验明显提升的地方适合第一个上手改。6.3 存档与重开让单机游戏也有“后悔药”加一个“重新开始”按钮响应函数里先KillTimer清理所有定时器再把步数清零、秒数清零、m_nFirstIndex和m_nSecondIndex复位为-1、m_bProcessing复位为FALSE最后调ShuffleCards重洗重铺。这套复位逻辑也可以做成ResetGame公共函数新一局和重开共用。注意别漏了把游戏状态GS_PLAYING重新置位否则会无声无息地出现“点了没反应”。最佳成绩存档用WritePrivateProfileString写到exe同目录下的score.ini是最轻量的方案读取用GetPrivateProfileInt。结算时把当前成绩和存档比较破纪录时更新并提示。注册表也是可行的存放位置但写ini更直观方便玩家自己查看和删除对单机小游戏来说这个方案足够。到现在我还保留一个习惯写任何带交互状态的程序第一件事把状态迁移和防重入规则画在纸上再动手。这套单机游戏的骨架就这么沿用下来后面改造成其他桌面小工具的交互组件时少踩了很多坑。希望帮到你。本文还有配套的精品资源点击获取