☰
C++ MFC连连看算法实战:数据结构与消子判断详解
2026/10/7 4:03:04 网站建设 项目流程

简介:武汉理工大学数据结构与算法综合实验的『欢乐连连看』游戏开发报告,面向计算机专业学生、课程设计者以及希望掌握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,总格子数 160;nPicNums 是图片种类数,如果设为 20,重复次数就是 8,每种图出现 8 次,刚好配成 80 对。如果想加大难度,可以调多花样数到 40,重复次数变 4,配对数量不变但相邻相同图片的概率变小,找对子的难度明显上升。想调简单就把种类数设为 10,重复次数 16,满屏都是重复图。

这个校验绝不能省。我第一次做的时候偷懒没写取模判断,直接拿 160 除以 12,得到 13.33,C++ 整数除法截断成 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 到 20,count[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::stack<Vertex> m_pathStack; // 保存当前消除路径的关键顶点 void AddVertex(Vertex v) { m_pathStack.push(v); } public: void ClearPath() { while (!m_pathStack.empty()) m_pathStack.pop(); } std::stack<Vertex> 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 是内存兼容 DC,m_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+,但报告里用的是 BMP,SRCCOPY 就足够。

双缓冲的代价是内存占用高——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.bmp,SetScrollRange(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 就再打乱一次,直到有解。这比让玩家手动点“重排”按钮碰运气可靠得多。从那以后我每次做类似网格消除类游戏都会强制把“有解检测”塞进重排逻辑里,宁可多跑几十毫秒也不让用户面对无解棋盘,直接败掉游戏口碑。希望这套思路在你做课设的时候也能省下几晚通宵。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询