ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ MFC连连看算法实战:数据结构与消子判断详解

C++ MFC连连看算法实战:数据结构与消子判断详解 简介武汉理工大学数据结构与算法综合实验的『欢乐连连看』游戏开发报告面向计算机专业学生、课程设计者以及希望掌握C与MFC游戏开发的学习者完整呈现从需求分析、数据结构设计到核心算法实现的全过程。报告重点讲解基于二维数组与int动态二维数组的地图存储方式详述一条直线、两条直线和三条直线三种消子判断算法并覆盖胜负判断、提示、重排、计时及游戏模式等关键功能同时介绍MFC框架与GDI编程在图形界面中的应用。文档为单个docx文件压缩包约1.37MB内容完整详实可直接作为实验报告参考或项目复现依据。目前已有227人浏览学习适合用于复习数据结构知识点与C桌面应用开发实战。1. 数据结构与算法实验做成连连看这份报告到底能拿来干什么拿数据结构期末的课程设计选题十个里有七个会落到“连连看”上但你真去搜一遍会发现网上下载的完整实验报告和源码包质量参差不齐要么是控制台文字版没有图形界面要么代码全是贴的没注释导师一问你三不知直接被挂掉。这份“武汉理工大学数据结构与算法综合实验连连看”是少见的完整版本基于 C 和 MFC 对话框框架实现了带真实图片、计时、进度条、重排、提示、三种游戏模式的桌面应用程序。地图用 16×10 二维数组存放 160 个格子消子部分实现了直线、两条直线、三条直线三种连通判断胜负逻辑又区分基本模式、休闲模式和关卡模式。适合三类人正在做数据结构大作业的学生、想快速掌握 MFC Dialog GDI 绘图流程的入门者、需要写实验报告但代码逻辑说不清楚的人。这份资源真正值钱的地方不是把游戏做出来而是把“用数组和栈解一个实际问题”这件事讲透了。2. 地图的数据结构与初始化二维数组、动态分配和花色打乱2.1 为什么地图用二维数组而不是链表或图结构连连看的地图本质是一个固定行列的网格每个格子里放一张图片。网格天然适合用二维数组表达访问第 row 行第 col 列的元素只需要map[row][col]时间复杂度 O(1)而且在判断两个点是否连通时要频繁按行、按列做区间扫描数组的连续内存布局让顺序访问非常快。链表访问第 N 个节点要 O(N) 遍历图结构虽然能表达连通关系但建图成本高对这类“矩形网格 直线扫描”的场景反而多余。报告中用的存储结构是一个结构体 Vertex 加一个动态二维数组定义放在 global.h 里// global.h #ifndef GLOBAL_H #define GLOBAL_H #define ROWS 16 // 地图行数 #define COLS 10 // 地图列数 #define BLANK 0 // BLANK 表示该位置为空已被消除 // 顶点结构保存地图中一个点的行号、列号和图片编号 typedef struct tagVertex { int row; // 行号范围 0 ~ ROWS-1 int col; // 列号范围 0 ~ COLS-1 int value; // 图片编号0 表示空其他值表示不同花色 } Vertex; #endif顶点结构体的存在是为了消子算法服务的。判断两个点能否消除需要把“坐标”和“值”绑定在一起传递如果只传行列每次都要从二维数组取值代码冗余。value 字段在路径搜索时还会被临时改成 BLANK 来模拟“该点已空”这一点在后面的三线连通算法里非常关键。地图本体在 CGameLogic 类里声明为 int 类型的动态二维数组// CGameLogic.h 关键成员 class CGameLogic { public: CGameLogic(); virtual ~CGameLogic(); int** m_pGameMap; // 动态二维数组行优先存储 int m_nRows; // 实际行数 int m_nCols; // 实际列数 void InitMap(int nRows, int nCols, int nPicNums); void DisOrderMap(); bool IsBlank(); // 判断地图是否全空 private: // 消子判断函数 bool RowLink(int row, int col1, int col2); // 水平方向直线连通 bool ColLink(int col, int row1, int row2); // 垂直方向直线连通 bool OneCornerLink(int m_Map[ROWS][COLS], Vertex v1, Vertex v2); bool TwoCornerLink(int m_Map[ROWS][COLS], Vertex v1, Vertex v2); };用int**而不是固定大小数组是为了让地图尺寸可以配置。关卡模式如果改成更大的地图或者调试时临时把 16×10 改成 8×8 验证算法不需要改头文件重编整个程序。代价是必须手动管理内存用完后 delete 掉每一行再 delete 指针数组这个细节在后面踩坑部分会展开讲。2.2 图片种类与重复数的匹配计算连连看的规则决定了每种图片必须出现偶数次——因为只有相同的图片才能成对消除如果某种图片出现奇数次最终一定会剩一张消不掉游戏死局。所以初始化地图的核心逻辑是地图总格子数 行数 × 列数每种图片重复次数 总格子数 ÷ 图片种类数必须满足总格子数能被图片种类数整除且重复次数为偶数报告中给的实现思路是int nRepeatNum nRows * nCols / nPicNums; // 每种图片重复次数 // 合法性校验 if (nRows * nCols % nPicNums ! 0) { // 异常处理地图格子数无法被花色数整除 MessageBox(_T(地图大小与图片种类数不匹配), _T(错误), MB_ICONERROR); return; } if (nRepeatNum % 2 ! 0) { // 异常处理重复次数是奇数必然导致最后剩一张无法消除 MessageBox(_T(每种图片重复次数必须为偶数), _T(错误), MB_ICONERROR); return; }参数说明nRows 和 nCols 是地图行数和列数本项目中是 16 和 10总格子数 160nPicNums 是图片种类数如果设为 20重复次数就是 8每种图出现 8 次刚好配成 80 对。如果想加大难度可以调多花样数到 40重复次数变 4配对数量不变但相邻相同图片的概率变小找对子的难度明显上升。想调简单就把种类数设为 10重复次数 16满屏都是重复图。这个校验绝不能省。我第一次做的时候偷懒没写取模判断直接拿 160 除以 12得到 13.33C 整数除法截断成 13奇数重复次数导致最后一张图永远消不掉游戏无法判定胜利。2.3 地图生成规则填充 随机打乱不是边生成边随机地图生成有两个阶段。第一阶段按从左到右、从上到下的顺序把每种图片循环填入二维数组生成一个完全规则的矩阵第二阶段用随机数把数组里的元素打乱。分两步是必要的如果边生成边随机很容易出现某种图片数量不均衡破坏偶数配对规则。打乱算法的常见做法是遍历每个位置随机选一个位置交换// CGameLogic.cpp #include stdlib.h #include time.h void CGameLogic::DisOrderMap() { // 用当前时间做随机种子保证每次运行生成不同布局 srand((int)time(NULL)); int nTotal m_nRows * m_nCols; // 总格子数 for (int i 0; i nTotal; i) { int row1 i / m_nCols; // 当前位置行号 int col1 i % m_nCols; // 当前位置列号 // 随机选另一个位置 int j rand() % nTotal; int row2 j / m_nCols; int col2 j % m_nCols; // 交换两个格子的图片编号 int temp m_pGameMap[row1][col1]; m_pGameMap[row1][col1] m_pGameMap[row2][col2]; m_pGameMap[row2][col2] temp; } }逻辑说明循环次数等于总格子数每轮把当前位置和随机位置交换。rand() % nTotal生成 0 到 159 之间的随机索引再用除法和取模转成行列坐标。这里有个细节——我见过有人只用一次 rand() 确定交换位置的下标导致前后两次运行结果一样因为 srand 只做一次种子初始化。赋值给 temp 再交换是标准三步交换法别用异或可读性差调试不方便。打完乱之后最好加一步验证统计每种图片的实际数量确认每一种都是偶数。因为 rand() 是伪随机理论上有极端情况下仍然可能因为算法实现缺陷导致分布异常虽然概率极低但加个断言能避免游戏中途翻车// 可选校验每种图片数量是否为偶数 int count[64] { 0 }; for (int i 0; i m_nRows; i) { for (int j 0; j m_nCols; j) { count[m_pGameMap[i][j]]; } } for (int k 1; k nPicNums; k) { if (count[k] % 2 ! 0) { // 理论上不可能到这到了就是初始化逻辑有 bug ASSERT(FALSE); } }注意 count 数组大小要大于最大图片编号。如果图片编号是 1 到 20count[64] 足够如果关卡模式用到更多图片记得同步调大。3. 消子算法核心从一条直线到三条直线的连通判断3.1 直线连通同行扫列、同列扫行逐格检查空位消子判断是连连看的心脏。两位玩家选中的图片要能消除必须满足两个前提两张图片相同且它们之间存在一条不超过三个线段两个拐点的路径路径上所有格子为空。先看最简单的一种——直线连通。直线连通分两种情况行相同或列相同。行相同时两个点的连线是水平的从 col1 到 col2 之间的每一格必须为空列相同时同理。// CGameLogic.cpp // 水平方向直线连通判断第 row 行上col1 到 col2 之间是否全部为空 bool CGameLogic::RowLink(int row, int col1, int col2) { // 确保 col1 col2方便循环扫描也避免负数 if (col1 col2) { int temp col1; col1 col2; col2 temp; } // 依次检查两点之间的每一个格子注意区间是开区间两端点本身可能有图片 for (int i col1 1; i col2; i) { if (m_pGameMap[row][i] ! BLANK) { return false; // 中间任一格非空不能连通 } } return true; } // 垂直方向直线连通判断第 col 列上row1 到 row2 之间是否全部为空 bool CGameLogic::ColLink(int col, int row1, int row2) { if (row1 row2) { int temp row1; row1 row2; row2 temp; } for (int i row1 1; i row2; i) { if (m_pGameMap[i][col] ! BLANK) { return false; } } return true; }循环从col1 1开始到col2 - 1结束这是开区间判断——两个端点是要消除的图片本身不必为空。面试或者答辩时容易在这个细节上翻车有人会从 col1 开始检查把自己的起点格子判成非空导致永远返回 false。另外入参没有校验 row 和 col 的合法性实际调用前要确保坐标在大地图范围内这个在 UI 层鼠标点击时已经限制了。3.2 两条直线连通找交点交点为空的判断关键两条直线连通就是两点之间存在一个拐点。以选中点为 V1(row1, col1) 和 V2(row2, col2) 为例拐点有两个候选位置拐点 A(row1, col2)即把 V1 的列换成 V2 的列拐点 B(row2, col1)即把 V2 的列换成 V1 的列只要拐点为空的格子且拐点与两个端点之间各自直线连通就说明一条两段路径成立。bool CGameLogic::OneCornerLink(int m_Map[ROWS][COLS], Vertex v1, Vertex v2) { // 情况一拐点在 (row1, col2) if (m_Map[v1.row][v2.col] BLANK) { // v1 到拐点垂直连通v2 到拐点水平连通 if (ColLink(v2.col, v1.row, v2.row) RowLink(v1.row, v1.col, v2.col)) { return true; } } // 情况二拐点在 (row2, col1) if (m_Map[v2.row][v1.col] BLANK) { // v1 到拐点水平连通v2 到拐点垂直连通 if (RowLink(v2.row, v1.col, v2.col) ColLink(v1.col, v1.row, v2.row)) { return true; } } return false; }两个候选拐点是固定的不需要枚举。判断逻辑的核心是拐点必须为空因为连线要从它上面经过。这里有坑当 V1 和 V2 本身相邻时也要走这个函数比如 V1(0,0) 和 V2(0,1) 相邻的格子拐点 (row2, col1) 就是 V1 自己不是 BLANK第二个 if 直接不成立拐点 (row1, col2) 就是 V2 自己同样是 BLANK 判断失败。但相邻的两张相同图片能消吗能。直线判断已经覆盖了RowLink(0, 0, 1) 的循环一次都不执行直接返回 true。所以接口调用顺序应该是先直线、再两折、最后三折这个顺序不能乱。3.3 三条直线连通枚举法按行扫描拐点三条直线连通的实现是枚举法。假设玩家选中的两个顶点是 V0(nRow1, nCol1) 和 V3(nRow2, nCol2)需要找到两个拐点 V1 和 V2使得路径 V0→V1→V2→V3 连通且 V1 和 V2 在同一行或同一列这样中间的线段才是水平或垂直的。以“先找 Y 轴平行的连通线段”为例也就是 V1、V2 在同一行。第一步从第 0 行开始扫描每扫描到一行 nRow设置拐点 V1(nRow, nCol1)、V2(nRow, nCol2)第二步判断 V1 和 V2 在水平方向是否连通第三步判断 V0 到 V1 垂直连通、V2 到 V3 垂直连通。三条线段都连通则消子成立。bool CGameLogic::TwoCornerLink(int m_Map[ROWS][COLS], Vertex v1, Vertex v2) { // 枚举每一行作为拐点所在行找水平方向的关键路径 for (int row 0; row ROWS; row) { // 拐点 V1: (row, v1.col)拐点 V2: (row, v2.col) // 第一步V1 到 V2 能否水平连通 if (RowLink(row, v1.col, v2.col)) { // 第二步V0 到 V1 垂直连通 if (ColLink(v1.col, v1.row, row) m_Map[row][v1.col] BLANK) { // 第三步V3 到 V2 垂直连通 if (ColLink(v2.col, v2.row, row) m_Map[row][v2.col] BLANK) { // 保存拐点供后续画连线使用 Vertex V1 { row, v1.col, BLANK }; Vertex V2 { row, v2.col, BLANK }; AddVertex(V1); AddVertex(V2); return true; } } } } // 枚举每一列作为拐点所在列找垂直方向的关键路径 for (int col 0; col COLS; col) { if (ColLink(col, v1.row, v2.row)) { if (RowLink(v1.row, v1.col, col) m_Map[v1.row][col] BLANK) { if (RowLink(v2.row, v2.col, col) m_Map[v2.row][col] BLANK) { Vertex V1 { v1.row, col, BLANK }; Vertex V2 { v2.row, col, BLANK }; AddVertex(V1); AddVertex(V2); return true; } } } } return false; }参数说明两个循环分别按行枚举和按列枚举因为路径可能是“竖-横-竖”拐点在同一行也可能是“横-竖-横”拐点在同一列。RowLink(row, v1.col, v2.col)判断两个候选拐点之间的水平段是否畅通注意这里的 RowLink 内部实现了 col1 和 col2 的大小交换所以传参不用管顺序。ColLink(v1.col, v1.row, row)判断端点 V0 与拐点 V1 的垂直段同理 V2 与 V3。两个 BLANK判断是为了确认拐点本身为空因为 RowLink 和 ColLink 只检查开区间端点处有图也能返回 true但这时拐点位置还没消除不能走线。这段枚举法的时间复杂度是 O(ROWS COLS)对 16×10 的地图毫无压力。如果地图放大到 100×100这个判断也就 200 次直线检查依然毫秒级。真正的性能瓶颈在提示功能要遍历全图找可消除对那是 O(n²) 的对子枚举地图大了要考虑优化。3.4 保存连通路径用栈记录拐点画线不重算消子成功后要画连接线这时候不能重新再跑一次连通判断而是要在判断过程中把拐点记下来。报告里用栈保存路径上的关键点起始点 V0、拐点 V1、拐点 V2 和终点 V3。// CGameLogic.h #include stack class CGameLogic { private: std::stackVertex m_pathStack; // 保存当前消除路径的关键顶点 void AddVertex(Vertex v) { m_pathStack.push(v); } public: void ClearPath() { while (!m_pathStack.empty()) m_pathStack.pop(); } std::stackVertex GetPath() { return m_pathStack; } };用栈的结构是合理的消除后按顺序弹栈就能依次画出每个线段栈天然符合“后进先出”我们从终点往回绘制时不需要翻转数组。实际调用的顺序是// CGameLogic.cpp 消子总入口 bool CGameLogic::CanRemove(Vertex v1, Vertex v2) { // 第一步两张图片必须相同且不是同一个格子 if (v1.value ! v2.value) return false; if (v1.row v2.row v1.col v2.col) return false; // 第二步按顺序尝试三种连通方式 ClearPath(); AddVertex(v1); // 起点入栈 if (RowLink(v1.row, v1.col, v2.col) v1.row v2.row) { AddVertex(v2); return true; } if (ColLink(v1.col, v1.row, v2.row) v1.col v2.col) { AddVertex(v2); return true; } if (OneCornerLink(m_Map, v1, v2)) { AddVertex(v2); return true; } if (TwoCornerLink(m_Map, v1, v2)) { AddVertex(v2); return true; } // 三条都不通清栈并返回 false ClearPath(); return false; }直线连通没有入栈拐点直接把终点压栈即可。路线绘制时从栈顶弹一个点画一段线视觉上是从终点往起点画的实际效果一样。这里有个容易忽略的细节OneCornerLink和TwoCornerLink内部如果判断成功已经自行 AddVertex 了拐点所以总入口不能重复压拐点否则路径上出现多余的点画出来的线会异常。4. MFC 界面与游戏流程Dialog 程序、双缓冲绘图和定时器4.1 工程结构三个类各管一件事整个工程分成三个文件对各负责一块逻辑互相调用关系清晰global.h定义 Vertex 结构体、地图常量、BLANK 常量CGameLogic.h/cpp地图数据的存储、消子算法、胜负判断、重排算法纯逻辑层不依赖界面CGameControl.h/cpp游戏控制层调度 GameLogic 和界面更新管理游戏状态开始、暂停、重排GameDlg.h/cpp主对话框类承载窗口消息、绘图、鼠标点击、定时器MFC 写应用题最容易架子乱绘图逻辑写在按钮响应里地图数据散落在对话框类里GameLogic 就是一堆全局函数。这套三层结构的好处是消子算法完全不关心图片怎么画、鼠标怎么点只认二维数组里的行号、列号和值。你要把这个游戏改成控制台版只需要把 GameDlg 换掉GameLogic 一行不用动。4.2 绘制地图与双缓冲消除闪烁的关键做法GDI 绘图最典型的问题是闪烁。每次 InvalidateRect 触发 OnPaint如果直接在这个函数里把 160 张图片重画刷新频率一高窗口会肉眼可见地闪。原因是 GDI 先擦背景再画内容两个动作之间有短暂白屏。解决方案是双缓冲先把所有内容画到内存 DC 中再一次性地 BitBlt 到屏幕。// GameDlg.cpp 绘制地图的核心代码 void CGameDlg::UpdateMap() { // m_dcMem 是内存兼容 DCm_bmpMem 是与之关联的位图 for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { int nIndex m_gameControl.GetGameMap()[row][col]; if (nIndex ! BLANK) { // 根据图片编号选择对应的 BMP 资源 // 每张图片 40*40按行列计算绘制位置 m_dcMem.DrawState(CPoint(col * 40, row * 40), CSize(40, 40), m_picList[nIndex], DSS_NORMAL); } } } // 一次性将内存 DC 内容复制到窗口 DC避免逐张绘制造成的闪烁 CDC* pDC GetDC(); pDC-BitBlt(m_rtGameRect.left, m_rtGameRect.top, m_rtGameRect.Width(), m_rtGameRect.Height(), m_dcMem, 0, 0, SRCCOPY); ReleaseDC(pDC); }逻辑说明内层循环把每个非空格子按 40×40 尺寸画到内存 DC 里坐标就是 row 和 col 乘以 40。m_picList 是 CBitmap 指针数组在初始化时用LoadImage从 theme\picture 目录加载 BMP 文件。绘制完成后BitBlt 把整块内存 DC 内容一次性复制到窗口 DC。SRCCOPY 是直接复制不涉及透明处理如果图片是 PNG 格式需要黑色透明效果得换成TransparentBlt或 GDI但报告里用的是 BMPSRCCOPY 就足够。双缓冲的代价是内存占用高——160 张 40×40 的位图加载到 CBitmap 数组每张 32 位色就是 160×40×40×4 ≈ 1MB 显存。这个体量在现在的机器上完全不是问题但在答辩时如果老师问为什么不用 GDI你要答得上来BMP 加载简单、兼容性好、内存可控。4.3 消子交互鼠标点击坐标换算和消除流程用户点击某个格子OnLButtonDown 消息响应函数拿到的是屏幕坐标。要换算成地图坐标需要减去游戏区域的左上角偏移量再除以图片边长// GameDlg.cpp 鼠标点击响应 void CGameDlg::OnLButtonDown(UINT nFlags, CPoint point) { // 判断点击是否落在游戏区域内 if (!m_rtGameRect.PtInRect(point)) { return; } // 屏幕坐标转换成地图网格坐标 int col (point.x - m_rtGameRect.left) / 40; int row (point.y - m_rtGameRect.top) / 40; // 越界保护 if (row 0 || row ROWS || col 0 || col COLS) { return; } int nValue m_gameControl.GetGameMap()[row][col]; if (nValue BLANK) { return; // 点击空位不处理 } // 记录第一次选中点 if (!m_bFirstSelected) { m_firstVertex.row row; m_firstVertex.col col; m_firstVertex.value nValue; m_bFirstSelected true; // 选中状态高亮显示 DrawSelectedRect(row, col); } else { // 第二次选中执行消除判断 Vertex secondVertex { row, col, nValue }; if (m_gameControl.CanRemove(m_firstVertex, secondVertex)) { // 可以消除清空数组、画连线、延时消除图片 m_gameControl.RemovePair(m_firstVertex, secondVertex); DrawLinkLine(); // 显示连接路线 Sleep(200); // 短暂停顿让玩家看到路线 UpdateMap(); // 重绘地图消除后的格子变成空白 CheckWin(); // 判断是否通关 } else { // 不能消除取消第一次选中状态 m_bFirstSelected false; UpdateMap(); } } }坐标换算时先减m_rtGameRect.left/top再除以 40是因为游戏区域不一定从窗口 (0,0) 开始左边可能有花边背景或者工具按钮。这个偏移量踩坑率极高尤其是用资源编辑器调整对话框布局后m_rtGameRect 变了但代码里写死了 40会出现点击和实际选中格子错位。正确做法是每次窗口大小变化时重新 GetClientRect 计算游戏区域。4.4 计时、暂停和重排状态机与定时器的配合游戏基础模式限时 5 分钟进度条CProgressCtrl配合定时器实现。定时器 ID 是PLAY_TIMER_ID每次触发间隔 1000ms 即 1 秒在 OnTimer 回调里让进度条 StepIt 前进一格。暂停功能的实现细节值得注意——按钮文字在“暂停游戏”和“继续游戏”之间切换同时停掉定时器// GameDlg.cpp 暂停/继续按钮响应 void CGameDlg::OnBnClickedBtnGameStop() { CString btnPauseName; GetDlgItemText(IDC_BTN_GAME_STOP, btnPauseName); CString pauGame(暂停游戏); CString goGame(继续游戏); if (btnPauseName pauGame) { // 当前是运行中执行暂停 KillTimer(PLAY_TIMER_ID); // 停止计时 SetDlgItemText(IDC_BTN_GAME_STOP, goGame); // 按钮文字改为“继续游戏” m_bGamePause true; m_bPlaying false; } else { // 当前是暂停中恢复运行 SetTimer(PLAY_TIMER_ID, 1000, NULL); // 重新启动定时器 SetDlgItemText(IDC_BTN_GAME_STOP, pauGame); m_bGamePause false; m_bPlaying true; } }逻辑说明用按钮当前文字判断当前状态状态切换点只有两个——暂停到继续和继续到暂停。KillTimer和SetTimer必须成对出现否则定时器资源泄漏。注意暂停时还要置m_bPlaying false因为 OnTimer 里第一步就判断if(nIDEvent PLAY_TIMER_ID m_bPlaying)如果只 KillTimer 不置位恢复时进度条可能因为状态没同步而错乱。重排功能在报告中是调DisOrderMap()把所有剩余图片重新随机打乱。注意打乱的是剩余图片不是全部 160 张——已经消除的空位要跳过否则已经消掉的格子又出现新图片游戏就没法玩了。实现时先收集所有非空格子的值再随机填回非空位置。4.5 帮助对话框滚动条加载大图帮助界面加载一张整页说明图片图片高度大于对话框高度时用滚动条查看。CHelpDialog 初始化时用 LoadImage 读入 Help1.bmpSetScrollRange(0, bmpInfo.bmHeight - 客户区高度, TRUE)设置滚动范围然后在滚动条消息响应里根据当前位置重绘对应部分。这个小功能麻雀虽小五脏俱全涉及的 GDI 知识点包括位图信息获取 BITMAP 结构、滚动条范围设置、内存 DC 的局部 BitBlt——如果课程设计要求“界面丰富度”这个帮助对话框是一个很好的加分点。5. 避坑指南地图奇偶校验、数组越界、重排死局和闪烁问题5.1 图片数量出现奇数次游戏死循环无法通关现象游戏进入后期地图上剩一张图片消不掉胜利判断永远不触发重排也没用。原因初始化时图片种类数与地图大小不匹配。比如 160 格用 12 种图片160 ÷ 12 13每种图出现 13 次必然是奇数张导致最后无法两两配对。还有个隐蔽情况动态数组没有正确初始化malloc 出来的内存里是垃圾值某些图片编号统计出来是奇数。解决初始化时强制走完整性校验——先算总数能否被种类整除再算每种重复数是否为偶数二者都满足才填数组。填完数组后加一个 ASSERT 断言遍历统计哪怕手工测试没发现问题答辩时被老师拷问也能理直气壮地说“这是静态检查保证的不是靠运气”。5.2 数组越界m_Map[10][15] 和 [10][16] 在代码里混用现象程序偶尔崩溃或者消子判断返回错误结果debug 模式下 VS 弹出数组越界断点。原因报告里多处声明不一致。函数形参写int m_Map[10][15]另一处写int m_Map[10][16]还有地方定义m_Map[16][10]。MFC 的 C 语法不检查形参数组边界运行时访问m_Map[row][col]如果 col 超过声明值但小于内存上真实布局的列数不会立刻崩溃而是读到相邻行的数据产生幽灵图片。解决全局统一走 ROWS 和 COLS 宏所有函数形参改成int m_Map[ROWS][COLS]禁止出现魔法数字。我自己的习惯是写一个#define MAP_H 16和#define MAP_W 10放在 global.h任何新增函数必须用宏声明数组维度。调试时如果怀疑越界手动把地图缩小到 4×4问题更容易暴露。5.3 重排后出现无法消除的死局现象剩余图片数量大于 2 但找不到任何一对可消除提示功能也找不到对子玩家只能反复重排。原因打乱算法是随机交换没有检查死局。连连看和普通翻牌游戏不同消除要考虑位置关系随机重排可能让可消对子全部被挡住比如某种图片的剩余两张全在边缘四个角彼此隔着两堵墙。解决分两层处理。第一层是提示功能的兜底——提示函数遍历全图找第一个可消除对子找到了就闪烁高亮告诉玩家找不到就说明当前局面确实死局重排按钮高亮提醒。第二层是重排算法增加一个简单检查打乱后调用 FindHint 函数如果找不到可消除对就重新打乱最多重试 5 次。这个策略不算最优解但简单有效工程上够用。5.4 双缓冲失效窗口刷新依然闪烁现象已经疑似用了双缓冲但游戏区域拖拽窗口或消除图片时还是闪。原因OnEraseBkgnd没重写。MFC 默认在 BeginPaint 前擦除背景即使你 BitBlt 了一次性图像擦除动作仍然会把整个游戏区域清成白色造成闪白。双缓冲只解决了绘图阶段没解决擦背景阶段。解决重写 OnEraseBkgnd 直接返回 TRUE告诉系统“背景不需要额外擦我们全权处理”BOOL CGameDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 返回 TRUE 阻止系统擦除背景配合内存 DC 消除闪烁 }另外检查一下内存 DC 是否在 OnPaint 里重复创建——如果在每次绘图时创建了新的内存 DC 和位图代价很高应该在 OnCreate 或 OnInitDialog 里创建一次OnPaint 只负责 BitBlt 和局部更新。5.5 定时器累积KillTimer 没配对暂停后进度条加速现象点击暂停再继续发现进度条突然快进了一大截。原因暂停按钮响应函数里没有 KillTimer或者 KillTimer 之后没把 m_bPlaying 置 false导致 OnTimer 里的m_GameProgress.StepIt()还在执行。如果暂停和继续都被多次点击SetTimer 被调用两次相同 ID 的定时器不会叠加但进度条可能被 StepIt 多走一步。解决暂停逻辑做成一个统一开关函数进函数先判断当前状态再决定 Kill 还是 Set。同时置一个 bool 成员变量 m_bPlaying 作为第二道保险OnTimer 里双重判断。这一步能保证定时器状态和游戏状态永远一致。6. 调试与验证技巧断点看地图、输出窗口打印、死局预检测答辩和验收环节最容易翻车的不是功能做没做出来而是被问“你怎么证明这个程序是对的”。光靠肉眼玩几局不够有说服力。我在做这类课程设计时养成了一个习惯把调试信息直接打到 VS 输出窗口用代码验证代码。// 调试辅助函数把当前地图按行列打印到输出窗口 void CGameLogic::DebugPrintMap() { CString strLog; for (int i 0; i m_nRows; i) { strLog.Empty(); for (int j 0; j m_nCols; j) { CString num; num.Format(_T(%2d ), m_pGameMap[i][j]); strLog num; } OutputDebugString(strLog _T(\n)); } OutputDebugString(_T(---------------\n)); }这个方法比打断点逐个检查变量高效得多——地图 160 个格子打断点在循环里看局部变量只能看到一个坐标和一个值看不到全局布局。输出窗口实时打印整张地图消子前后各调一次 DebugPrintMap两张图对比一眼就能看出有没有错位。调试奇偶问题时在 IsBlank 和 CanRemove 里各加一个条件断点条件是某个坐标的值异常时断下配合 Watch 窗口看 m_pGameMap 的连续内存——MFC 调试器能在 Watch 里输入m_pGameMap[0][0], m_pGameMap[0][1]逐格查看不需要手动加监视器。另一个实用技巧是给消子算法写一个独立的控制台测试入口。把 GameLogic 类里的核心函数抽出来在 main() 里构造一个 16×10 的测试地图写几个已知形状断言// ConsoleTest.cpp 消子算法单元测试入口 #include CGameLogic.h #include assert.h void TestRowLink() { CGameLogic logic; logic.InitMap(3, 4, 2); // 3行4列2种图片 // 手动设置一张地图第 1 行全空 for (int col 0; col 4; col) { logic.m_pGameMap[1][col] BLANK; } // 断言第 1 行 0 列到 3 列水平方向连通 bool bResult logic.RowLink(1, 0, 3); assert(bResult true); } void TestTwoCornerLink() { CGameLogic logic; logic.InitMap(4, 4, 2); // 构造一个三折路径场景 // 手动设置地图后验证 TwoCornerLink 返回 true // ... } int main() { TestRowLink(); TestTwoCornerLink(); OutputDebugString(_T(All tests passed.\n)); return 0; }把测试代码放在工程里但不参与正式构建用预处理器条件编译包住或是建一个单独的 Win32 控制台工程引用同一份 CGameLogic.cpp。测试的好处是以后改代码不会改坏原有功能——改一次算法跑一遍全部断言3 秒内验证完所有已知边界。死局预检测这是我后来才悟到的技巧。连连看重排之后如果找不到任何可消除对玩家体验极差。与其等玩家吐槽不如在 DisOrderMap 函数末尾顺手调用一次 FindHint——报告里的提示功能本身就要遍历全图找第一对可消除图片这个函数写出来自然可以复用bool CGameLogic::FindHint() { // 遍历所有非空顶点找第一对可消除的组合 for (int i 0; i m_nRows; i) { for (int j 0; j m_nCols; j) { if (m_pGameMap[i][j] BLANK) continue; Vertex v1 { i, j, m_pGameMap[i][j] }; for (int p i; p m_nRows; p) { for (int q (p i) ? j 1 : 0; q m_nCols; q) { if (m_pGameMap[p][q] ! v1.value) continue; Vertex v2 { p, q, m_pGameMap[p][q] }; // 三种消子判断依次尝试 if (CanRemove(v1, v2)) { return true; // 找到一对直接返回 } } } } } return false; // 全图无可消对 }这段代码是双重循环套双重循环O(n²) 开销但地图只有 160 个格子最坏情况也只要几毫秒。重排函数调用它时如果返回 false 就再打乱一次直到有解。这比让玩家手动点“重排”按钮碰运气可靠得多。从那以后我每次做类似网格消除类游戏都会强制把“有解检测”塞进重排逻辑里宁可多跑几十毫秒也不让用户面对无解棋盘直接败掉游戏口碑。希望这套思路在你做课设的时候也能省下几晚通宵。本文还有配套的精品资源点击获取
返回列表