C++ MFC连连看:二维数组、栈与连通算法实践详解
简介武汉理工大学计算机科学与技术学院“数据结构与算法综合实验”课程报告围绕“欢乐连连看”游戏开发完整记录C编程、MFC Dialog框架、GDI绘图以及数组、栈等线性结构的综合应用过程。文档包含实验目标与要求、连连看游戏设计、消子判断、胜负判定、提示与重排、计时与多模式玩法并配以关键代码和实现流程。游戏地图采用16行10列共160个40×40像素方格通过int**动态二维数组与tagVertex结构体保存图片编号及行列信息重点讲解一条、两条、三条直线连通消子算法以及基于随机交换的洗牌思路。资源为1个docx文件大小1.38MB排版规范、章节清晰可直接作为数据结构课程设计或MFC入门实践的参考。已有81人学习下载适合正在完成同类实验的在校学生借鉴。1. 从连连看课设说起数组、栈与 MFC 的第一次实战某高校的数据结构与算法综合实验课结课方式是做一个能玩的连连看游戏。题目要求用 C 和 MFC 写一个完整的桌面应用地图是 10 行 16 列共 160 个格子每张图片 40×40 像素核心考核点落在三块二维数组如何管理地图数据、三种连通算法如何判定消子、栈如何保存连通路径。这道课设把线性结构的理论和真实 GUI 开发串在了一起是典型的数据结构落地场景。这篇笔记按实际开发顺序把题目拆开——先讲数据结构和地图生成再讲消子判定然后是 MFC 控制逻辑最后是几个调试中踩过的坑适合正在做类似课设、或者想系统梳理 MFC 对话框加线性表应用的人。2. 地图数据与线性结构二维数组如何撑起整个连连看2.1 顶点定义与数组选型int** 与固定数组怎么选文档里先定义了一个 VertextagVertex结构体用三个字段记录一个格子的全部信息row 表示行号、col 表示列号、value 表示图片编号。row 和 col 是运行时的坐标索引value 是图片种类的标识三者解耦之后消子判断和界面绘图都可以只依赖坐标而不关心这张图具体长什么样。地图本身的存储文档给出的方案是 int 类型的动态二维数组 int **m_pGameMap。动态二维数组的好处是行列数可以在初始化时按难度参数调整。但实际代码实现时地图固定在 10 行 16 列所以大部分函数直接用了 int m_Map[ROWS][COLS] 这样的固定数组。我的建议是如果课设不要求动态调节地图尺寸直接写固定数组省掉 new/delete 和内存越界的心智负担如果后续要扩展关卡模式再考虑把 m_Map 改成指针并在 InitMap 里分配。global.h 里可以这样组织// global.h - 全局定义与顶点结构体 #ifndef GLOBAL_H #define GLOBAL_H #define ROWS 10 // 地图行数 #define COLS 16 // 地图列数 #define BLANK 0 // 空格子标记0 表示该位置已经消除 // 顶点结构体保存一个格子的行号、列号和图片编号 struct tagVertex { int row; // 行号范围 0 ~ ROWS-1 int col; // 列号范围 0 ~ COLS-1 int value; // 图片编号BLANK 表示空格 }; typedef struct tagVertex Vertex; #endif这个结构体在后边的消子判断中会作为函数参数来回传递。value 字段在判断两张图片是否同色时直接参与比较row 和 col 则决定了连通路径的计算起点和终点。另外文档里写“16 行乘 10 列”是和实际映射矛盾的——640×400 像素对应的是 16 列×40px、10 行×40px所以代码里 ROWS 取 10、COLS 取 16 才符合“每张图 40×40”的描述。2.2 地图生成规则花色填充与两个整除校验生成一张合法且能消完的地图必须先搞清楚几个数字之间的关系。地图总格数等于图片种类数乘以每种图片的重复次数160 nPicNums × nRepeatNum。同时每种图片必须成对出现nRepeatNum 必须是 2 的倍数否则最后必然剩下单张图片无法消除。nPicNums图片种类数nRepeatNum每种重复次数难度说明820较容易每类图多达 20 张1016中等匹配机会适中208较难每类图只有 8 张404很难配对位置分散初始化代码分成三段参数校验、规则填充、洗牌。校验阶段做两个判断总格数能否被种类数整除以及每种重复次数是否为偶数。这两个判断缺一不可很多移植代码只保留了第一个。// CGameLogic::InitMap - 初始化地图并随机打乱 void CGameLogic::InitMap(int nPicNums) { int nTotal ROWS * COLS; // 总格子数 10 * 16 160 if (nTotal % nPicNums ! 0) { AfxMessageBox(_T(图片种类必须能整除总格数)); return; } int nRepeatNum nTotal / nPicNums; // 每种图片重复次数 if (nRepeatNum % 2 ! 0) { AfxMessageBox(_T(每种图片必须成对出现否则无法全部消除)); return; } // 规则填充先按花色从左到右、从上到下排布 int idx 1; // 图片编号从 1 开始0 留给 BLANK for (int i 0; i nPicNums; i) { for (int j 0; j nRepeatNum; j) { int pos i * nRepeatNum j; // 线性下标 int row pos / COLS; // 行号 int col pos % COLS; // 列号 m_Map[row][col] idx; } idx; } // 规则排布必然导致相同花色聚在一起必须洗牌 DisOrderMap(); }填充思路是先把相同花色连续排成一个矩形区域比如 8 种图每种 20 张前两行半全是图 1再两行半全是图 2。如果不洗牌玩家一眼就能看出分布规律。pos 的计算是关键pos i * nRepeatNum j 把二维填充变成一维遍历再用 row pos / COLS、col pos % COLS 映射回行列比双重循环里直接维护 row/col 变量更不容易错。2.3 洗牌与重排随机交换必须跳过空格洗牌算法本身很简单随机挑两个格子交换。但要注意区分两个场景初始洗牌时地图是满的任意两个格子都能交换游戏中点击重排按钮时地图上已经有被消除的空格只能挑非空格交换否则会把空格“洗”到别处破坏玩家已消掉的位置。// CGameLogic::DisOrderMap - 只对剩余图片进行随机重排 void CGameLogic::DisOrderMap() { srand((int)time(NULL)); // 收集所有非空格子的坐标 std::vectorstd::pairint, int positions; for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { if (m_Map[i][j] ! BLANK) { positions.push_back(std::make_pair(i, j)); } } } // 随机交换 500 次 int nCount (int)positions.size(); for (int k 0; k 500; k) { int a rand() % nCount; int b rand() % nCount; if (a b) continue; int r1 positions[a].first, c1 positions[a].second; int r2 positions[b].first, c2 positions[b].second; std::swap(m_Map[r1][c1], m_Map[r2][c2]); } }这里的 std::pairint,int 用来存坐标使用前记得包含 、 和 头文件。srand 只需要在程序启动时调用一次反复调用反而可能导致随机序列退化。交换次数 500 次对 160 个格子来说足够打散增加到 1000 次肉眼几乎看不出差异不用盲目加大。提示重排后可能存在“无解死局”这个问题放在第 4.4 节详细说。3. 消子判定算法一条直线、单拐点与双拐点的完整实现3.1 一条直线连通行与列的对称判断消子判定是整个项目的核心。两个被点击的格子能不能消先满足两个前提value 相同同花色且两点坐标不同。第三个隐藏前提是两点中不能有 BLANK这个在点击事件里已经被过滤掉了但逻辑类里最好也防御一下。一条直线连通分两种情况同一行检查两者之间所有格子是否为空同一列同理。文档里的 RowLink 和 ColLink 分别对应行方向和列方向我习惯把它们统一成 LineX 和 LineY// 判断水平方向同一行从 (row, col1) 到 (row, col2) 是否畅通 bool CGameLogic::LineX(int m_Map[ROWS][COLS], int row, int col1, int col2) { if (col1 col2) { std::swap(col1, col2); // 保证从左往右扫 } for (int i col1 1; i col2; i) { if (m_Map[row][i] ! BLANK) { return false; // 中间任意一个格子非空直接失败 } } return true; } // 判断垂直方向同一列从 (row1, col) 到 (row2, col) 是否畅通 bool CGameLogic::LineY(int m_Map[ROWS][COLS], int col, int row1, int row2) { if (row1 row2) { std::swap(row1, row2); // 保证从上往下扫 } for (int i row1 1; i row2; i) { if (m_Map[i][col] ! BLANK) { return false; // 中间任意一个格子非空直接失败 } } return true; }有个边界情况必须处理col1 和 col2 相差 1也就是两个格子紧挨着。此时 for (int i col1 1; i col2; i) 循环体一次都不会执行直接返回 true。这符合游戏直觉——相邻的同花色图片可以直接消除不需要中间路径。swap 那步不能省如果调用方传进来的 col1 大于 col2扫描起点就错了。3.2 两条直线连通两个候选拐点的枚举一条直线不通就考虑“L”形路径。假设 v1(row1, col1)v2(row2, col2)那么唯一可能的两个拐点是 (row1, col2) 和 (row2, col1)。想走“L”形拐点本身必须是空格并且拐点与两个端点之间的两段线段各自要连通。// 判断两个顶点能否通过一个拐点相连L 形路径 bool CGameLogic::OneCornerLink(int m_Map[ROWS][COLS], Vertex v1, Vertex v2) { int row1 v1.row, col1 v1.col; int row2 v2.row, col2 v2.col; // 候选拐点 1: (row1, col2) if (m_Map[row1][col2] BLANK) { if (LineY(m_Map, col2, row1, row2) // v1 到拐点垂直连通 LineX(m_Map, row1, col1, col2)) { // 拐点到 v2 水平连通 return true; } } // 候选拐点 2: (row2, col1) if (m_Map[row2][col1] BLANK) { if (LineY(m_Map, col1, row1, row2) // v1 到拐点垂直连通 LineX(m_Map, row2, col1, col2)) { // 拐点到 v2 水平连通 return true; } } return false; }这个函数的正确性完全建立在两个候选拐点的枚举上。细心的读者会发现如果 v1 和 v2 本身在同一行或同一列拐点就落在端点本身上这时走的其实是直线连通分支。但调用顺序上一般会先调 LineX/LineY走不通再调 OneCornerLink所以不会重复判断。拐点的空格判断很关键——拐点位置如果有图片路径就被挡住了哪怕两条线段都畅通也不能消。3.3 三条直线连通按行按列扫描关键路径两条直线走不通就轮到最复杂的双拐点情况。思路是枚举所有可能的中间线段先扫描每一行看这一行能否作为“桥梁”连接两个端点所在的列找不到再扫描每一列。具体来说假设两个端点分别是 V0(row0, col0) 和 V3(row3, col3)。扫描任意一行 row把拐点设为 V1(row, col0)、V2(row, col3)。如果 V1 和 V2 之间的水平线段畅通且 V0 到 V1 的垂直线段畅通、V2 到 V3 的垂直线段畅通就找到一条三直线路径。扫描每一列同理。// 判断两个顶点能否通过两个拐点相连Z 形或 U 形路径 bool CGameLogic::TwoCornerLink(int m_Map[ROWS][COLS], Vertex v1, Vertex v2) { int row1 v1.row, col1 v1.col; int row2 v2.row, col2 v2.col; // 先扫描行水平走廊 两条垂直边 for (int row 0; row ROWS; row) { if (row row1 || row row2) { continue; // 这种行对应的是直线连接场景跳过 } // 两个拐点 (row, col1) 和 (row, col2) 必须都是空格 if (m_Map[row][col1] BLANK m_Map[row][col2] BLANK) { if (LineX(m_Map, row, col1, col2) // 水平段畅通 LineY(m_Map, col1, row1, row) // V0 到拐点 1 垂直畅通 LineY(m_Map, col2, row2, row)) { // 拐点 2 到 V3 垂直畅通 return true; } } } // 再扫描列垂直走廊 两条水平边 for (int col 0; col COLS; col) { if (col col1 || col col2) { continue; // 跳过直线连接对应的列 } if (m_Map[row1][col] BLANK m_Map[row2][col] BLANK) { if (LineY(m_Map, col, row1, row2) // 垂直段畅通 LineX(m_Map, row1, col1, col) // V0 到拐点 1 水平畅通 LineX(m_Map, row2, col2, col)) { // 拐点 2 到 V3 水平畅通 return true; } } } return false; }跳过 row row1 或 row row2 的行很重要。如果拐点和端点同行那其实退化成一条直线或两条直线的情况虽然调 LineX/LineY 也能算出正确结果但会让路径变长也可能出现重复计算。三直线连通的时间复杂度是 O(ROWS×COLS COLS×ROWS)在 10×16 的地图上每次判断最多几百次循环玩家完全感觉不到延迟。如果地图放大到 100×100这个 O(n²) 级别的枚举就会开始卡需要做预处理优化但课设场景不需要。3.4 连通路径的保存用栈记录拐点序列消子判定只回答了“能不能消”但界面上还要画一条折线把两个图片连起来这就需要把路径的拐点保存下来。文档里的做法是用栈保存关键点起点 V0 入栈找到拐点 V1、V2、终点 V3 后依次入栈绘图时从栈顶往栈底取点连线。栈在这里的作用是路径回溯。先压起点再根据连通结果压入拐点和终点。连线绘制时直接把栈里四个点按顺序取出来画折线。我通常会在 CGameLogic 里维护一个成员栈 std::stack m_pathStack每次成功判定后先清空栈再压入关键点// 判定成功后保存路径 void CGameLogic::SavePath(Vertex v0, Vertex v1, Vertex v2, Vertex v3) { while (!m_pathStack.empty()) { m_pathStack.pop(); // 清空上一次的路径 } m_pathStack.push(v0); // 起点 if (v1.row ! -1) m_pathStack.push(v1); // 拐点 1 if (v2.row ! -1) m_pathStack.push(v2); // 拐点 2 m_pathStack.push(v3); // 终点 }界面层只需要读取栈里的点去画线不需要关心路径是怎么算出来的。注意要记得包含 头文件。这样的分层让算法和绘图完全解耦调试起来也很方便——栈里点的顺序不对用断点看一眼就知道是哪个环节出了问题。4. 游戏控制与 MFC 界面计时、暂停、重排与胜负判断的联动4.1 定时器驱动进度条五分钟倒计时的实现在 MFC Dialog 程序里做倒计时最直接的方式是 SetTimer 加 OnTimer。游戏开始时启动一个 1 秒的定时器再放一个 CProgressCtrl 进度条。进度条范围设成 100初始值为 100每触发一次 OnTimer 就减 5。五分钟共 300 秒每秒减 5300 次正好扣完。void CGameDlg::OnBtnClickedStart() { // 初始化地图和逻辑类 cgc.InitMap(m_nPicNums); m_GameProgress.SetRange(0, 100); m_GameProgress.SetPos(100); m_bPlaying TRUE; SetTimer(PLAY_TIMER_ID, 1000, NULL); // 每秒触发一次 OnTimer GetDlgItem(IDC_BUTTON_START)-EnableWindow(FALSE); } void CGameDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent PLAY_TIMER_ID m_bPlaying) { int nPos m_GameProgress.GetPos() - 5; // 每秒扣 5五分钟正好扣完 if (nPos 0) nPos 0; m_GameProgress.SetPos(nPos); } CDialogEx::OnTimer(nIDEvent); }这里有个关键细节OnTimer 里要判断 nIDEvent 是否为 PLAY_TIMER_ID因为界面上可能还有别的定时器。m_bPlaying 标志位是给暂停用的暂停时不想让进度条继续走就不能在 OnTimer 里直接扣减。GetPos 取到当前进度值减到 0 再减会变成负数所以要夹到 0。SetTimer 的第二个参数 1000 表示触发间隔 1000 毫秒想改倒计时长就调整每次扣减的量比如 10 分钟就每秒扣 1共 600 秒。4.2 暂停与继续按钮文字和标志位的配合暂停逻辑文档里写得比较完整。点击“暂停游戏”按钮按钮文字变成“继续游戏”m_bPlaying 置 FALSE计时停止。再点一次m_bPlaying 恢复 TRUE进度条继续走。void CGameDlg::OnBnClickedBtnGameStop() { if (m_bPlaying) { m_bPlaying FALSE; // 暂停不再计时 KillTimer(PLAY_TIMER_ID); SetDlgItemText(IDC_BTN_GAME_STOP, _T(继续游戏)); } else { m_bPlaying TRUE; // 继续恢复计时 SetTimer(PLAY_TIMER_ID, 1000, NULL); SetDlgItemText(IDC_BTN_GAME_STOP, _T(暂停游戏)); } }这里有一个隐藏的坑暂停时 KillTimer继续时重新 SetTimer进度条会从暂停时的位置继续扣这在功能上是对的。但如果玩家暂停了 10 分钟再继续倒计时仍然从暂停位置往下走——这不符合大多数人的预期。想做得严谨一点暂停时应该记录剩余毫秒数继续时用 SetTimer 的第三个参数指定首次触发延时把暂停消耗的时间剔除掉。4.3 胜负判断时间耗尽与地图清空的边界胜负判断文档里有一段代码框架但有两个边界没覆盖到。原写法是if (m_GameProgress.GetPos() 0 !cgc.IsBlank(cgc.m_Map)) { // 判负 } else if (m_GameProgress.GetPos() 0 cgc.IsBlank(cgc.m_Map)) { // 判胜 }如果时间刚好为 0 时玩家正好消完最后两张图这个写法会判负。我一般会调整判断顺序先看地图是否清空再判断时间if (cgc.IsBlank(cgc.m_Map)) { // 地图空了无论如何都是赢 KillTimer(PLAY_TIMER_ID); MessageBox(_T(获胜), _T(提示)); } else if (m_GameProgress.GetPos() 0) { // 地图没空且时间耗尽判负 KillTimer(PLAY_TIMER_ID); MessageBox(_T(很遗憾时间到了), _T(提示)); GetDlgItem(IDC_BUTTON_START)-EnableWindow(TRUE); }把“地图全空”放在最优先可以避免时间边界上输赢判错的尴尬。休闲模式没有定时器根本不走这个分支只在地图全空时触发胜利提示。IsBlank 的实现就是遍历整个 m_Map只要有一个元素不等于 BLANK 就返回 false这个函数简单但有价值。4.4 重排与可解性分层调用和死局处理重排的调用链是OnBnClickedButtonReset 按钮响应 - CGameControl::DisOrder() - CGameLogic::DisOrderMap()。CGameControl 负责调用逻辑类的洗牌函数然后刷新界面。分层的意义在于 CGameLogic 完全不知道界面存在只做数据操作CGameControl 做中介CGameDlg 只负责按钮消息和界面更新。点击重排按钮前要先把当前地图状态交给逻辑类重排完成后调用 InvalidateRect 触发重绘。这个顺序不能反——先重绘再重排界面上看到的还是旧地图。DisOrderMap 纯随机打乱不保证可解性。在某些随机种子下图片分布会出现“环形封锁”结构任意相同花色对都被其他图挡死这就是典型的死局。处理办法洗牌完成后调用 FindHint 搜索一个可消对找不到就再洗牌最多重试 30 次。如果 30 次后仍然找不到可消对再重新调用 InitMap 重新生成整个地图同时保留当前得分。这个启发式方案没法 100% 保证可解但在课设场景下能把死局概率压到很低。5. 避坑与排查笔记五个让课设翻车的真实案例5.1 地图初始化后同花色图片出现奇数个现象游戏进行到最后界面上剩一张图找不到配对无论如何都无法通关。原因初始化时只校验了 160 能否被图片种类数整除漏掉了“每种图片的重复次数必须是偶数”这个条件。比如选了个 15 种图160 不能被 15 整除这个会被拦下来但有些同学用浮点数取整或者手改地图宽高导致 nRepeatNum 算出来是奇数消到最后必然剩单张。解决在 InitMap 里做两道校验任何一道不过直接弹窗提示并返回。第一道 nTotal % nPicNums ! 0第二道 nRepeatNum % 2 ! 0。这道坑是最容易在移植代码时被改丢的尤其是从别人的工程里复制初始化函数时常常只带走了填充逻辑。5.2 数组越界地图尺寸宏定义不统一现象程序运行一段时间后偶发崩溃报堆栈被破坏而且每次崩溃位置都不一样。原因文档原代码里有 m_Map[10][16]也有函数签名写成 m_Map[10][15]列下标一旦访问到 15 就属于越界。VS2010 的调试器不是每次越界都会立刻报错很多情况是写到堆的边界区域等到下一次分配内存时才崩。解决统一用 ROWS 和 COLS 宏定义代替所有魔法数字所有函数签名都写 int m_Map[ROWS][COLS]。另外在 InitMap 里加一行断言ASSERT(ROWS 10 COLS 16); // 确保地图尺寸与绘图区域一致如果后续有人改地图尺寸断言会第一时间提醒。5.3 帮助对话框加载不出背景图片现象点击“帮助”按钮弹出的对话框一片空白背景图没显示但程序不报错。原因LoadImage 用了相对路径 theme\picture\Help1.bmp。VS2010 调试时的工作目录默认是项目文件所在目录不是 exe 所在目录相对路径解析失败LoadImage 返回空句柄后续绘图自然画不上。这类问题的诡异之处在于直接双击 exe 运行时图片能出来但在 IDE 里按 F5 就跑不出排查起来容易怀疑人生。解决用 GetModuleFileName 拼出 exe 所在目录再相对这个目录拼图片路径TCHAR szPath[MAX_PATH]; GetModuleFileName(NULL, szPath, MAX_PATH); PathRemoveFileSpec(szPath); CString strBmp; strBmp.Format(_T(%s\\theme\\picture\\Help1.bmp), szPath); HANDLE bmp ::LoadImage(NULL, strBmp, IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE);另外加载 BMP 图片时要注意图片格式LoadImage 对 24 位 BMP 兼容性最好有些截屏保存的 32 位 PNG 转成 BMP 后可能因为颜色格式导致显示异常。5.4 消子后界面不刷新地图“看起来”没有变化现象点击两张图片判定成功逻辑类里对应的数组元素已经置成 BLANK但界面上的图还在。原因CGameLogic 改了 m_Map但窗口没有重绘。MFC 不会自动感知数组内容变化必须显式调用 InvalidateRect 让窗口发 WM_PAINT。很多初学 MFC 的人会忽略这一步以为修改了数据界面就会自动变。解决每次成功消子后在 CGameDlg 里调用 InvalidateRect(rect, FALSE)。注意如果用双缓冲 BitBlt 绘制游戏区域重绘时要从 m_Map 重新读数据而不是从一个缓存的 DC 里重复贴旧图。双缓冲的刷新顺序是先把背景贴到内存 DC再把当前地图的图片贴到内存 DC最后一次性 BitBlt 到窗口 DC。5.5 暂停后恢复计时进度条被偷走了几秒现象点暂停后立刻点继续进度条少了一截或者点暂停后进度条还在走。原因暂停逻辑同时用了 KillTimer 和 m_bPlaying 两种机制但有的按钮消息处理函数里只置了 m_bPlaying FALSE没有 KillTimer而 OnTimer 里又没判断 m_bPlaying导致计时继续走。两个控制点没有对齐行为自然分裂。解决统一入口。暂停时先 KillTimer 再置 m_bPlaying FALSE继续时 SetTimer 再置 m_bPlaying TRUE。OnTimer 第一行就判断 if (nIDEvent PLAY_TIMER_ID m_bPlaying)不满足直接 return。这样无论按钮被点多少次进度条和计时状态始终一致。还有一个细节暂停时最好把当前进度值存到一个成员变量里继续时从该值恢复防止进度条控件本身的状态被外部干扰。6. 让课设多拿分的四个改进提示、动画与关卡参数化6.1 提示功能暴力扫描所有剩余图片对提示功能本质就是全图搜索遍历每一对 value 相同且都不为空的格子调用 CheckLink 判断是否连通找到第一对就高亮。复杂度大约是一万多次连通判断每次判断又是 O(ROWSCOLS) 级别总共百万级操作瞬间出结果。实现时可以先把所有非空格子按 value 分组每组数量一定不超过 nRepeatNum组内两两做连通测试这样可以跳过不同花色之间的无效判断让提示功能更快。6.2 消子连线的绘制双缓冲加延迟刷新判定成功但还没真正消掉之前先画一条连接两个图片的折线停顿 200 毫秒再把两张图置空并刷新界面。视觉效果会好很多玩家也能直观看到为什么这一对能消。延迟一般用 Sleep(200) 简单实现但它会卡 UI 线程更好的做法是配合定时器分两步走先画线定时器到点后再消子刷新。课设里用 Sleep 完全够用但要说明这个取舍。6.3 关卡模式把图片种类数做成动态参数原文档的三种游戏模式里关卡模式的核心是调整游戏难度。最简单也最实用的做法是不同关卡传入不同的 nPicNums地图尺寸不变图片种类越多、每种重复次数越少玩家找到配对的难度越高。void CGameDlg::StartLevel(int nLevel) { int nPicNums 6 (nLevel - 1) * 2; // 第 1 关 6 种第 2 关 8 种…… if (nPicNums * 2 ROWS * COLS) { nPicNums ROWS * COLS / 2; // 上限每种图最少 2 张 } cgc.InitMap(nPicNums); }关卡模式下图片种类的上限是 80 种但实际不可能用这么大的图库而且 80 种图每种只有 2 张找配对难度会变得极高反而失去乐趣。一般控制在 10~30 种之间比较合适。6.4 代码分层别让界面类堆积所有逻辑最后是代码组织。原文档分了 CGameDlg、CGameControl、CGameLogic 三个类分层是合理的CGameLogic 管算法和地图数据CGameControl 管游戏状态流转CGameDlg 管界面交互。一个常见问题是把所有函数塞进 CGameDlg看起来能跑但一旦要加提示、重排、暂停这些功能按钮消息处理函数会膨胀得很快出问题也不好定位。我自己的习惯是 CGameLogic 里不出现任何 MFC 控件类型所有输入输出都是 int、Vertex、bool 这些基础类型这样逻辑类可以直接用控制台程序验证消子算法对不对。如果你把判断逻辑和 MFC 控件写在一起想单独测试算法都没法下手。这份实验报告里的代码和思路主体就是从零搭起来的 MFC 连连看覆盖地图生成、消子判定、计时重排、胜负判断和帮助提示这些完整功能下载后对照着改能省不少摸索时间。尤其建议把 CGameLogic.cpp 单独抽出来跑一遍确认三类消子算法的边界行为符合预期再接界面。从那以后我每次动手写课设都先理清楚数据结构怎么存、算法边界有哪些再开 IDE 写界面每次遇到诡异问题第一反应也是设断点看中间变量而不是瞎改代码碰运气。改掉这两个习惯之后这类综合实验的推进速度快了很多。希望帮到你。本文还有配套的精品资源点击获取