简介:单机记忆翻牌游戏是一份基于 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 / .cpp | CWinApp 派生类,进程入口,只管初始化和弹主对话框 |
| MemoryGameDlg.h / .cpp | 主对话框,消息映射、鼠标交互、绘制全在这儿 |
| Card.h | 牌的数据结构,纯 POD,不含任何绘制代码 |
| GameCore.h / .cpp | 洗牌、配对判定、胜负判断、步数统计 |
| resource.h / MemoryGame.rc | 对话框模板、控件 ID、位图资源 ID |
| res/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::vector<Card> 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::vector<Card>& Cards() const { return m_cards; } private: std::vector<Card> 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 Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <!-- Static 表示静态链接 MFC,拷给别人不用装运行库;Dynamic 需要目标机有 MFC DLL --> <UseOfMfc>Static</UseOfMfc> <!-- 统一 Unicode。别一半源码是 MBCS 的 char*,一半是宽字符 --> <CharacterSet>Unicode</CharacterSet> <PlatformToolset>v143</PlatformToolset> </PropertyGroup>UseOfMfc有三档:false 表示不用 MFC,Static 静态链接,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); // 牌面需要透明通道时改用 CImage:LoadBitmap 读不了 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、LNK1120,bin 目录下生成 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_distribution<int> 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 里写死了 PlatformToolset,VS 的自动重定向只改了其中一部分配置(通常是 Debug|Win32),Release 或者 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第一行先KillTimer,ResolvePending里两张牌的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 / cols | 4 / 4 | 决定总牌数,乘积必须为偶数 |
| flipBackMs | 800 | 回翻延迟,700~900 手感最好 |
| previewMs | 0 | 开局预览全部牌面的时长,大于 0 才有 |
| imageCount | 8 | 图案种类数,不够时按取模复用 |
皮肤替换比想象中简单,因为资源 ID 是连续的。把IDB_CARD01到IDB_CARD08换成新的 bmp,尺寸保持一致就行;想上带透明边的 png,就把m_cardBmp数组的类型从CBitmap换成CImage,DrawCard里改一行绘制调用。真正需要注意的是尺寸:StretchBlt能缩放,但缩放比太夸张时牌面会糊,最好是按目标格子大小重做一套资源。
验证改动是否安全,我有个固定套路:给GameCore写一个不依赖 MFC 的小测试,固定 seed 后连续调用Flip,断言配对的牌最终matched为真、步数等于预期、IsWin()在最后一对配完时返回真。规则层不碰窗口句柄,这段测试可以在控制台工程里直接跑,几十毫秒完成,比手点一百次靠谱得多。表现层的改动就只能靠看,重点盯两处:窗口拖大拖小时牌面是否跟着重排、已消除的牌是否还有残留绘制。
从那以后我每次改这类 GDI 工程,都强制先跑一遍固定 seed 的规则测试,再手动点三局确认手感和闪烁,最后才谈界面美化。顺序颠倒过一次,界面调了两小时,回头发现是洗牌算法漏了一张牌,白忙。希望帮到你。
本文还有配套的精品资源,点击获取