简介:这款基于MFC框架开发的单机记忆翻牌游戏,以经典翻牌配对玩法训练玩家记忆力,适合C++与Windows桌面程序初学者作为实战练习项目;游戏要求玩家记住卡片位置,在有限的翻牌次数内完成全部配对,既考验短时记忆,也锻炼专注与策略选择。压缩包共419个文件,大小约6.63MB,其中包含384张BMP卡牌与图标图片、9个C++源码文件和11个头文件、5个MP3背景音乐,以及.sln、.vcxproj等Visual Studio工程配置文件;BMP提供丰富卡面素材,MP3用于背景与翻牌音效,h与cpp共同构成完整项目源码。已有504人学习下载。工程完整覆盖了对话框界面搭建、位图加载、点击事件消息映射、计时器控制卡片自动翻回、计分统计与多媒体音效等关键实现;大量图片资源可直接替换,便于拓展卡池和自定义主题。通过阅读源码可深入理解MFC的消息机制、控件应用和资源管理流程,是一份能动手、易上手的MFC入门实例。
1. 单机记忆翻牌游戏:不只是入门demo
如果你第一次用VC/MFC写东西,最容易找的例程多半是计算器或者文本编辑器,但它们都没法让你体会到“窗口交互 + 实时绘制”的完整乐趣。这个单机记忆翻牌游戏不一样:棋盘上铺着一堆背面朝上的卡片,你点击翻开两张,配对成功就消除,失败就自动翻回去,全程计时计步。听起来简单,但看完代码你会发现,MFC里的消息映射、自绘控件、定时器、GDI双缓冲,它全用上了。对新手来说,这是能在一天内跑通并看懂全部代码的练手项目;对想捡起C++桌面开发的熟手来说,它也把“事件驱动游戏”该有的骨架收得很干净。这篇文章我会从工程结构、核心实现和踩坑记录三个角度,把这份源码彻底拆开。
2. 技术选型与整体架构:一个MFC对话框应用的骨架
2.1 为什么用VC/MFC而不是换一种技术
单机记忆翻牌游戏的核心交互就是“点击”和“画矩形”,这套东西在Windows平台上用MFC对话框做最直接。对话框自带消息循环,鼠标点击会被框架转换为WM_LBUTTONDOWN消息,绘制则集中在WM_PAINT里。相比网页版还要操心DOM事件和CSS动画,MFC用几十行代码就能把棋盘的点击、重绘、状态翻转整套流程跑通。
另一个实际原因是发布方便。MFC工程把运行时库选为静态链接后,编译出来的exe可以拿到没有开发环境的机器上直接跑,不打任何安装包。这对单机小游戏来说非常友好,用户下载资源后双击就能玩,不用装.Net、不用装Java。旧机器上动辄几百MB的内存占用也不是问题,因为GDI只画矩形和位图,开销可以忽略。
2.2 工程目录与类结构
这份资源解压后是一个完整的Visual Studio工程,核心文件如下。
MemoryGame/ ├── MemoryGame.sln ├── MemoryGame/ │ ├── MemoryGameDlg.h / .cpp # 主对话框,承载全部游戏逻辑 │ ├── Card.h / .cpp # 卡片数据与绘制封装 │ ├── Shuffle.h / .cpp # Fisher-Yates洗牌工具 │ ├── Resource.h # 自定义资源ID定义 │ ├── MemoryGame.rc # 对话框模板、按钮、菜单资源 │ └── res/ # 程序图标与位图资源主对话框类CMemoryGameDlg持有所有卡片的数组,并响应鼠标和定时器消息;CCard单张卡片只做三件事——保存配对id、保存当前状态、绘制自己;CShuffle独立成类,为的是以后想换成“限次打乱”或者“动态难度”时,不用动界面代码。这个分层在MFC小项目中算得上清爽,界面层不写算法,算法层不碰窗口,调试时可以分开验证。
2.3 消息映射:事件驱动的游戏循环在哪里
如果你找过“MFC游戏循环”的教程,会发现在对话框程序里根本没有循环。Windows程序靠消息驱动,一条消息从系统消息队列走到你的处理函数,然后返回。记忆翻牌游戏的“循环”就是消息的往复:鼠标点击一张卡片,程序修改状态并重绘,系统再次等待下一条消息。
下表列出了本项目最关键的三组消息映射。
| 消息 | 处理函数 | 职责 |
|---|---|---|
WM_PAINT | OnPaint | 遍历所有卡片,按当前状态绘制到窗口 |
WM_LBUTTONDOWN | OnLButtonDown | 把点击坐标转换成卡片索引,更新翻转状态 |
WM_TIMER | OnTimer | 处理倒计时和“两张不匹配牌翻回去”的延时 |
此外还有一个容易被忽略的WM_ERASEBKGND。MFC默认会在重绘前擦除窗口背景,如果不拦截它,绘制时屏幕会闪得厉害。本项目在OnEraseBkgnd里直接返回TRUE,告诉系统“背景我已自己处理”,把清屏和重绘都交给OnPaint的双缓冲逻辑。这是游戏不闪的重要前提。
2.4 状态机:从一次点击到一次消除
游戏的状态可以用一个极简单的有限状态机描述。空闲等待、已翻开一张、已翻开两张、等待消除/翻回。落到代码里,不需要定义状态枚举,两个布尔变量外加一个定时器就够用了。
// 点击处理中的状态分支,省略了坐标转换 if (!m_bFirstClick) { // 当前是空闲状态,记录第一张牌 m_nFirstIndex = nIndex; m_bFirstClick = true; cards[nIndex].m_state = 1; // 翻开 } else if (m_nSecondIndex == -1) { // 已翻开一张,再记录第二张牌 m_nSecondIndex = nIndex; cards[nIndex].m_state = 1; OnMatchCheck(); // 立即检查两张是否配对 }m_bFirstClick表示是否已经翻开第一张,m_nSecondIndex表示第二张牌的位置(-1表示尚未翻开)。两张牌都翻开后,立刻进入配对检查。配对成功则把两张牌的状态改成“消除”;配对失败则启动一个300毫秒的定时器,时间到再把他们翻回背面。用两个索引加一个布尔值,就能完整描述这个状态机。
2.5 洗牌的随机性与可复现调试
翻牌游戏里,牌面的初始化顺序决定了整局游戏的体验。本项目用Fisher-Yates算法洗牌,这是保证均匀随机的最经典做法。随机种子用time(NULL)打底,每次进入新游戏连线都不一样。
但调试的时候,随机就变成了灾难。你想复现某个配对位置,可每次运行顺序都不同。我给CShuffle留了一个隐藏能力:按住Ctrl键点击“开始游戏”时,随机种子固定为0,这样洗牌结果完全可复现。断点跟到第N次交换时,每一步生成的数组都是固定的,排查“某张牌永远配不上对”这类问题会轻松很多。这个技巧在产品环境里也值得保留,它不污染正常逻辑,只在调试期影响随机性。
3. 核心模块拆解:洗牌、翻牌、匹配与绘制的完整链路
3.1 Fisher-Yates洗牌算法的实现
CShuffle类只有不到三十行代码,核心是一个从数组尾部向前遍历的循环,每步随机交换当前元素和某个前序元素,确保每个排列出现的概率相等。
// Shuffle.cpp #include "Shuffle.h" #include <stdlib.h> #include <time.h> void CShuffle::Shuffle(int* pArr, int nCount) { // 从最后一个元素开始,向前逐步交换 for (int i = nCount - 1; i > 0; i--) { // 在 [0, i] 之间取随机下标 int j = rand() % (i + 1); int tmp = pArr[i]; pArr[i] = pArr[j]; pArr[j] = tmp; } }该方法的关键在于循环次数为nCount - 1,每次确定最后一个位置的最终牌号。注意rand() % (i + 1)产生的随机数范围只到i,保证只从未洗牌的部分取值。如果改成rand() % nCount,概率分布就会不均匀,这是洗牌算法里最容易写错的地方。
调用处先生成一个包含两两重复元素的数组,比如4x4棋盘有16张牌,8对,每个配对编号各出现两次。
std::vector<int> vecRaw; for (int i = 0; i < nPairCount; i++) { vecRaw.push_back(i); vecRaw.push_back(i); } CShuffle::Shuffle(&vecRaw[0], (int)vecRaw.size());这样得到的vecRaw每个编号出现两次,且顺序完全随机。洗牌完成后,按照行列顺序填充到卡片数组,每张卡片得到自己的m_id。
3.2 鼠标点击命中检测
MFC里命中检测最简单的方式就是CRect::PtInRect。鼠标按下时,消息处理函数会收到一个CPoint坐标,遍历所有未消除的卡片,调用它的HitTest方法就能知道点到了哪张牌。
int CMemoryGameDlg::HitTestCards(const CPoint& pt) { for (size_t i = 0; i < m_cards.size(); i++) { // 已经消除的牌不参与点击 if (m_cards[i].m_state == 2) continue; if (m_cards[i].HitTest(pt)) return (int)i; } return -1; // 没有命中任何牌 }这张卡片的m_rect是初始化时按行列计算好的,HitTest内部直接调用m_rect.PtInRect(pt)。由于牌没有重叠,命中检测是线性的,32张牌最多循环32次,性能完全没有问题。如果以后做成“牌会移动”的玩法,线性遍历不够用,可以换四叉树或网格索引,但在这个项目里没必要。
3.3 翻牌后的配对与延时
OnMatchCheck是每次翻开第二张牌后立刻执行的核心逻辑。它读取第一张和第二张牌的id,相同则永久消除,不同则启动定时器延时翻转。
void CMemoryGameDlg::OnMatchCheck() { CCard& card1 = m_cards[m_nFirstIndex]; CCard& card2 = m_cards[m_nSecondIndex]; if (card1.m_id == card2.m_id) { // 配对成功:状态置为消除,已配对数+1 card1.m_state = 2; card2.m_state = 2; m_nMatchedCount++; InvalidateRect(&card1.m_rect); InvalidateRect(&card2.m_rect); if (m_nMatchedCount == m_nTotalPairs) { KillTimer(TIMER_GAME); MessageBox(_T("恭喜,闯关成功!"), _T("完成"), MB_OK); } } else { // 配对失败:开启一次性定时器,300ms后翻回 m_bWaitRevert = true; SetTimer(TIMER_REVERT, 300, NULL); } m_bFirstClick = false; m_nSecondIndex = -1; }代码里的两个参数值得说明。m_nMatchedCount是当前消除的对数,它等于总对数时游戏结束,所以计时器用KillTimer停掉。SetTimer(TIMER_REVERT, 300, NULL)的第二个参数是300毫秒,意味着两张不匹配的牌在用户眼前展示0.3秒后再翻回。太短用户看不清翻的是什么,太长又会拖慢节奏,300毫秒是多次试玩后的平衡值。
m_bWaitRevert用来防止用户在这个延时窗口里继续翻牌。OnLButtonDown开头如果发现这个变量为TRUE,直接忽略点击,避免状态被二次点击打乱。这是很多翻牌demo容易漏掉的边界。
3.4 GDI双缓冲绘制
OnPaint是绘制全部卡片的入口。为了不闪屏,先把所有图形画到内存DC,再一次复制到窗口。
void CMemoryGameDlg::OnPaint() { CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, m_rcClient.Width(), m_rcClient.Height()); memDC.SelectObject(&bmp); // 1. 绘制背景色 memDC.FillSolidRect(m_rcClient, RGB(64, 128, 64)); // 2. 遍历并绘制每张牌 for (size_t i = 0; i < m_cards.size(); i++) { m_cards[i].Draw(&memDC); } // 3. 一次性刷到窗口,避免闪烁 dc.BitBlt(0, 0, m_rcClient.Width(), m_rcClient.Height(), &memDC, 0, 0, SRCCOPY); }CreateCompatibleBitmap创建了一张与窗口尺寸一致的内存位图,SelectObject把它选进内存DC,之后所有绘制都在这张位图上进行。第3步的BitBlt是双缓冲的灵魂:它把内存中已完成全部绘制的位图“闪”到窗口,整个过程只有一次屏幕更新,所以看不到逐张卡片绘制的中间状态。
如果不做双缓冲,两张牌翻开的瞬间,背景、卡背、正面图案会依次重绘,肉眼看到的就是剧烈的闪烁。这也是所有自绘型MFC游戏都该优先解决的问题。
3.5 计时与步数统计
计时功能依赖WM_TIMER消息。游戏开始时启动一个1秒间隔的计时器,每次触发OnTimer对秒数加一,刷新静态文本控件。
void CMemoryGameDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_GAME) { m_nSeconds++; CString strTime; strTime.Format(_T("%02d:%02d"), m_nSeconds / 60, m_nSeconds % 60); SetDlgItemText(IDC_TIME, strTime); } else if (nIDEvent == TIMER_REVERT) { // 延时结束,把两张牌翻回背面 KillTimer(TIMER_REVERT); m_cards[m_nFirstIndex].m_state = 0; m_cards[m_nSecondIndex].m_state = 0; m_bWaitRevert = false; InvalidateRect(NULL); } CDialogEx::OnTimer(nIDEvent); }步数计数更方便:m_nMoveCount在每次点击第二张牌时加一,并在相同位置刷新显示。这里有一个“时间与步数哪一个更能衡量水平”的问题,但没有绝对答案,本项目两个都显示,玩家自己取舍。
4. 运行与调试:从编译到跑通的实战记录
4.1 用Visual Studio打开工程
拿到源码后,直接双击MemoryGame.sln就能启动Visual Studio。第一次打开时,VS会提示“工程由旧版本创建,是否升级”,确认即可。如果提示“无法加载项目,因为未安装MFC组件”,需要先到VS安装器里勾选“适用于C++的MFC工具”。这是最常见的新机器问题,不解决的话编译报错信息会很奇怪,根本不会涉及代码本身。
确认MFC组件安装后,把解决方案配置从Debug切到Release,平台选择x86或x64都可以。这个游戏逻辑简单,32位和64位编译结果没有差异,但建议初学者用x86。因为MFC里一些老代码在x64下可能遇到int和指针大小不匹配的警告,虽然不影响运行,但会干扰阅读。
4.2 字符集与Visual Studio版本
如果你打开工程后编译报“无法将参数从const char[N]转换为LPCTSTR”,八成是字符集问题。本项目默认使用Unicode字符集,所有字符串都包在_T()宏里。如果源码中暴露了裸的"开始"字面量,在高版本VS的默认Unicode配置下会报错。
解决办法有两种:要么把所有字符串都写成_T("开始"),要么在项目属性里把“字符集”改成“多字节字符集”。推荐前一种,因为你后续添加任何文字都要做同样处理,不如一开始就养成用_T的习惯。如果是从VC6搬来的老代码,还要注意afx.h和windows.h的包含顺序,MFC头文件必须放在标准头之后,否则会出现一堆莫名其妙的宏重定义。
4.3 用断点查看第一次点击流程
想把“点击一张牌”的全过程看清楚,在OnLButtonDown第一行打一个断点,然后在OnMatchCheck的card1.m_id == card2.m_id处再打一个。运行程序点击一张牌,第一处断点触发,可以展开m_cards数组查看所有牌的状态;继续执行到第二处断点,能看到两张被翻开的牌。
这里有个调试技巧:在Watch窗口输入m_cards[m_nFirstIndex].m_id,然后手动改这个值,能强行制造一次配对成功,用来测后续消除逻辑。同理,如果把两张牌改得不同,就能测“翻回”路径。这种方式比模拟十次随机点击高效,因为你可以精确控制要测的牌。
4.4 从VC6到高版本VS的代码兼容细节
这份源码如果要拿到VS2019/2022编译,除了字符集,还有三个隐藏的坑。
第一个是rand()的边界行为。老代码中常见rand() % n,如果n恰好是0会除以零,但洗牌时nCount永远不会为0,所以问题不大。
第二个是SetTimer的返回值。在VC6时代可以忽略返回值,但高版本VS的调试器会对SetTimer返回0的具体原因给出提示,遇到“定时器不触发”时,优先检查参数ID是否被其他控件占用。
第三个是CString的隐式转换。老代码里喜欢把CString传给printf之类的地方,这在Unicode下会编译失败。本项目已经用SetDlgItemText避免了这类问题,但二次开发时加入CString::GetBuffer后记得调用ReleaseBuffer,否则中文字符可能乱码。
5. 避坑指南:MFC记忆翻牌游戏最常见的4个坑
5.1 翻牌后画面闪烁
刚把代码跑通时,每次翻牌都像老式电视机的雪花屏。
原因很简单:OnPaint直接往屏幕DC上画,系统还会先擦除背景,两层操作叠加导致闪烁。解决方法是放弃直接画,改用内存DC绘制完再BitBlt,也就是前面3.4节的双缓冲。补充一个细节:只对包含卡片区域的矩形做InvalidateRect也能减少闪烁,全窗口刷新在32张牌的场景里性价比不高。
5.2 快速点击两张牌后,第三张牌也能翻开
这是状态机边界没锁住。第一次翻牌、第二次翻牌、等待翻回三个状态之间没有互斥,用户手速够快的话,可能会在两张错牌还没翻回时就点击第三张,导致m_nFirstIndex被覆盖。
原因是最初的代码没有加m_bWaitRevert标志。解决方式是在每次点击处理函数开头加一个判断:如果等待翻回的定时器还在运行,直接忽略本次点击。这样既保证了游戏节奏,也避免了数组索引错乱。
5.3 对话框拖动到高分屏后卡片位置错位
把窗口从100%缩放的屏幕拖到150%缩放的副屏,卡片之间的间距变得不均匀,个别牌甚至被截断。
原因是MFC对话框默认不处理DPI缩放,而程序里卡片矩形是用固定像素数值算出来的。高分屏下窗口客户区变大,但策划时用的字号和间隔没有跟着变。解决方法是调用SetProcessDPIAware(),或者干脆用CMFCDesktopAlertWnd之外的动态布局让卡片矩形随窗口尺寸缩放。这里最简单的做法是在OnInitDialog里获取窗口实际客户区大小,再按行列数均分,避免写死像素。
5.4 每次洗牌结果总是相同
在Windows上调试运行几次,发现每次点“开始游戏”的牌面顺序一模一样。
这是一种典型的“伪随机”误会。rand()函数在调用前没有设置随机种子时,默认种子是固定值,所以每次程序启动后第一次洗牌的结果相同。解决方法是OnInitDialog里写一行srand((unsigned int)time(NULL))。如果用了我的可复现调试技巧,注意在非调试环境下应去掉固定种子的逻辑,否则玩家玩到的是同一个棋盘。
6. 二次开发思路:从翻牌demo到关卡系统
如果你想把它改造成更完整的游戏,我建议从“关卡”入手而不是从界面美化入手。当前代码只有一个棋盘尺寸变量,在StartGame(int col, int row)里赋值。要做关卡系统,可以把这个函数改成读取m_nLevel,然后按关卡配置表设置行列数、倒计时秒数、允许的错误次数。错误次数超过限制就弹出失败提示,这样游戏就从“记忆训练”变成了“益智闯关”。
具体来说,增加一个CLevelConfig结构体,包含CSize boardSize、int nTimeLimit、int nMaxMistake。在开始按钮逻辑里根据关卡号加载对应配置,将OnMatchCheck中配对失败的分支改成错误次数自增,并检查是否耗尽。再加上通关后自动进入下一关,整个单机记忆翻牌游戏的可玩性马上提升一个档次。
另一个值得做的扩展是把“卡背图片”从纯色矩形换成外部图片。MFC里可以用CImage加载PNG,在Draw函数中替换掉原来的颜色填充。注意每张牌需要两个状态各一张图,翻牌动画如果能做到位,视觉效果会改善非常明显。
验证改造是否正确的方法很简单:手动把棋盘配置改成2x2,游戏逻辑依然应该正常启动、匹配、结束。如果连最小尺寸都能正常跑完,说明状态机没有硬编码依赖具体行数。从那以后,我每次改完逻辑都会强制在2x2和8x4两种极端配置下各玩一遍,避免只在大棋盘上能跑的潜藏bug。希望这份项目的拆解和避坑经验能帮到你,让它不只是你硬盘里的一个老工程,而是能继续长出功能的自留地。
本文还有配套的精品资源,点击获取