简介:武汉理工大学数据结构与算法综合实验的连连看游戏实验报告,面向计算机相关专业学生及需要完成同类课程设计的学习者。文档完整记录了基于C++与MFC框架开发“欢乐连连看”的流程,涵盖实验目标、游戏设计、消子算法与胜负判断等核心内容。其中游戏地图采用16行10列的二维数组存储,图片种类与重复次数决定难度,并包含提示、重排、计时及多种游戏模式。消子部分详细给出了直线、两拐点、三拐点三种连通性判断的实现思路,结合结构体tagVertex与栈保存连通路径;胜负判断按基本、休闲等模式区分。读者可借此掌握数组遍历、栈操作、GDI绘图及迭代开发方法,理解如何将线性结构理论落地为可运行程序。资源包含1个docx格式文件,压缩包大小1.38MB,内容为完整实验报告,适合作为课程设计参考、算法复盘或答辩素材。已有81人学习下载,对正在完成数据结构综合实验的学生具有实用参考价值。
1. 别小看 160 张 40×40 图片:这是 MFC + 线性结构的一份完整作业
把一张 16 行 × 10 列的棋盘铺满 160 张 40×40 图片,用 C++ 写一个能在 MFC 对话框里真正跑起来的连连看——这不是控制台里打印九九乘法表的小练习,它是武汉理工大学《数据结构与算法综合实验》的一份完整实验报告。这份文档的价值在于:地图不是手工打表写死的,而是用二维数组动态生成;消子也不靠撞运气,而是把“一条直线、两条直线、三条直线”连通拆成三个可复用的判定函数;胜负、计时、重排、暂停这些交互,又逼着你把数组和栈用到界面层去。适合两类人:一是正在做同类课程设计、需要一份能讲清楚原理的参考实现的学生,二是想看看 MFC 老项目里棋盘类游戏怎么组织代码的从业者。下文我按“数据层 → 消子算法 → 控制层 → 排错”的顺序把它拆开,能直接抄作业的地方都给了注释。
2. 地图数据层先行:二维数组、tagVertex 与随机打乱的生成细节
2.1 为什么用二维数组存地图:线性结构的直观映射
连连看的地图是一个 640×400 的矩形,被切成 16 行 × 10 列共 160 个格子。最直接的做法就是用int m_Map[16][10]保存每个格子的图片编号,行号对应屏幕 y 方向,列号对应 x 方向。选中两张图片时,鼠标点击坐标换算成(row, col),剩下的判断全都在这个数组上进行。
数组在这里的优势是随机访问 O(1)。消子算法要反复检查“某一行从 col1 到 col2 是否全空”“某一列从 row1 到 row2 是否全空”,数组下标一步到位。如果换成链表存地图,每次判断都要从头遍历,一次点击可能要扫整张表,性能上是纯亏的。
原报告里用了一个global.h头文件统一放结构体定义,这是老 MFC 工程的标准习惯:
// global.h #pragma once const int MAP_ROWS = 16; // 地图行数 const int MAP_COLS = 10; // 地图列数 const int BLANK = -1; // 空格标记,图片编号从 0 开始 // 地图中一个点:行号、列号、图片编号 struct tagVertex { int row; int col; int value; };注意BLANK用-1而不是0,因为图片编号本身从 0 开始,如果用 0 表示空格,第一张图片就会被当成“已消除”,后期判断会出错。这是个很小的细节,但很多第一次写的同学在这里翻过车。tagVertex后续会同时用于:地图点坐标、消子路径拐点、鼠标选中的两张图,一套结构体通吃。
2.2 地图初始化:nRows * nCols / nPicNums 的取整与两个异常分支
生成地图不是直接往数组里乱填图片,而是先确定两个关键数字:图片种类数nPicNums和每种图片的重复次数nRepeatNum。它们必须满足两个约束:
行数 × 列数能被图片种类数整除;- 每种图片的重复次数必须是偶数,即 2 的倍数。
第二条是连连看能通关的数学前提:每种图片一共出现偶数次,才能保证最后两两配对消完。如果某张图出现 3 次,玩到后期必然剩一个孤子,游戏永远无法获胜。
初始化的核心代码,我一般这样写:
// CGameLogic::InitMap int nRows = MAP_ROWS; int nCols = MAP_COLS; int nPicNums = 8; // 8 种图片 int nTotal = nRows * nCols; // 160 int nRepeatNum = nTotal / nPicNums; // 每种 20 张 // 必做校验:不能整除直接退出,避免后面的数组越界 if (nTotal % nPicNums != 0) { AfxMessageBox(_T("地图格子数必须是图片种类数的整数倍!")); return; } // 必做校验:重复次数为奇数时,必然剩单张图 if (nRepeatNum % 2 != 0) { AfxMessageBox(_T("每种图片必须出现偶数次!")); return; } // 按从左到右、从上到下的顺序填入地图 int idx = 0; for (int j = 0; j < nRepeatNum; j++) { for (int i = 0; i < nPicNums; i++) { int row = idx / nCols; int col = idx % nCols; m_Map[row][col] = i; idx++; } }逻辑说明:外层循环控制“每种图片的重复轮数”,内层循环控制“一轮里的图片种类”。跑完一遍后,地图上每种图片出现的次数正好等于nRepeatNum,而且是规则排列,相同图片全部聚在一起。idx / nCols和idx % nCols这组公式是二维数组遍历的标配,把一维索引换算成行列号,滚动次数多了自然就熟了。
参数说明:nPicNums越大,图片种类越多,每种图片重复次数越少,配对难度越高;nRepeatNum是重复轮数,它的奇偶校验关系到游戏能不能过关,测试的时候建议分别用160 % 8 != 0、nRepeatNum = 19这类输入触发一下异常分支,确认弹窗正常。
2.3 随机打乱:用交换法让初始布局不呆板
规则填充出来的地图是“整齐排列”的,相同图片全部挤在一起,玩家一眼就能全消完,毫无游戏性。所以生成地图后必须洗牌。原报告的做法是:随机选两个元素,交换它们的值,重复若干次。
// CGameLogic::ShuffleMap srand((unsigned int)time(NULL)); // 用系统时间做种子,避免每次运行布局一样 int nTotal = MAP_ROWS * MAP_COLS; for (int i = 0; i < 1000; i++) { // 随机选两个格子 int pos1 = rand() % nTotal; int pos2 = rand() % nTotal; int r1 = pos1 / MAP_COLS; int c1 = pos1 % MAP_COLS; int r2 = pos2 / MAP_COLS; int c2 = pos2 % MAP_COLS; // 交换图片编号 int temp = m_Map[r1][c1]; m_Map[r1][c1] = m_Map[r2][c2]; m_Map[r2][c2] = temp; }srand只需要执行一次,放在构造函数或者InitMap开头都行。如果放在循环里每次rand()前都调用,生成的随机序列反而会高度重复。交换次数 1000 次对于 160 个格子来说已经足够充分,你甚至可以只交换 500 次,效果差别不大。这地方没什么玄学,关键是交换时不要误把BLANK空格也换进去——洗牌只发生在游戏初始化阶段,此时地图还没有任何消除操作,所以不需要额外判断空格,但重排功能里必须判断。
2.4 工程文件划分:global.h、CGameLogic 与 CGameControl 的分工
老 MFC 工程不像现在一个类一个文件那么纯粹,这套代码的文件划分很典型:
| 文件 | 职责 | 对应代码内容 |
|---|---|---|
global.h | 全局数据定义 | tagVertex结构体、常量、BLANK定义 |
CGameLogic.h/.cpp | 核心算法 | 地图初始化、打乱、RowLink、ColLink、OneCornerLink、TwoCornerLink |
CGameControl.h/.cpp | 控制层 | 调算法完成消子、重排的入口,给界面层提供接口 |
CGameDlg.h/.cpp | 界面层 | MFC 对话框、按钮事件、定时器、绘图 |
很多人写课程设计喜欢把所有代码堆进OnLButtonDown里,看起来能跑,但一是没法做单元测试,二是后期想加“提示”功能要在事件处理函数里翻半天。这个三层划分是值得照抄的:CGameLogic不依赖任何 MFC 控件,把算法函数写成纯逻辑,传进m_Map就能跑,方便单独调试。
3. 消子核心:一条、两条、三条直线连通怎么判
3.1 RowLink 与 ColLink:同线连通的空洞检测
这是整个连连看算法体系的基石。两条直线、三条直线判断到最后,都会退化成若干个“同一直线上是否连通”的子问题。原报告的RowLink()负责 X 方向(同一行),ColLink()负责 Y 方向(同一列)。
// CGameLogic::RowLink // 判断同一行中,从 col1 到 col2 之间是否全部为空 bool CGameLogic::RowLink(int row, int col1, int col2) { if (col1 == col2) return false; // 同一个点,无可连通性 // 统一方向:保证 col1 < col2 if (col1 > col2) { int tmp = col1; col1 = col2; col2 = tmp; } // 注意区间是 (col1, col2),两端点本身不检查 for (int c = col1 + 1; c < col2; c++) { if (m_Map[row][c] != BLANK) return false; // 中间有图片挡路 } return true; }ColLink的结构完全对称,只是把行和列互换,按列遍历:
bool CGameLogic::ColLink(int col, int row1, int row2) { if (row1 == row2) return false; if (row1 > row2) { int tmp = row1; row1 = row2; row2 = tmp; } for (int r = row1 + 1; r < row2; r++) { if (m_Map[r][col] != BLANK) return false; } return true; }逻辑说明:两个函数都先做端点排序。因为传入的col1、col2可能来自鼠标点击顺序,用户先点右边再点左边是常事,不排序直接循环会漏判。循环范围用col1 + 1到col2 - 1,只检查中间格子,端点上可能是正在被点击的图片,不该把它自己也挡住。
整个判断的意图一句话说清:两个端点是空的(或者正要消除的图片),中间一整条线没有任何图片挡住,就算连通。这个函数后面会被反复调用,所以它的正确性直接决定整个游戏能不能玩。
3.2 OneCornerLink:一个拐点的相交点检测
一条直线连不通时,下一步看两条直线能不能通。两条直线意味着中间有一个拐点,对应原报告里的OneCornerLink()。它只处理两个点“行列都不相同”的情况——如果同行或同列,直接走RowLink/ColLink就行,不需要拐点。
bool CGameLogic::OneCornerLink(tagVertex v1, tagVertex v2) { int r1 = v1.row, c1 = v1.col; int r2 = v2.row, c2 = v2.col; if (r1 == r2 || c1 == c2) return false; // 同行/同列,不属于双直线场景 // 候选拐点 A:(r1, c2),即先走列再走行 if (m_Map[r1][c2] == BLANK && RowLink(r1, c1, c2) && ColLink(c2, r1, r2)) { return true; } // 候选拐点 B:(r2, c1),即先走行再走列 if (m_Map[r2][c1] == BLANK && RowLink(r2, c1, c2) && ColLink(c1, r1, r2)) { return true; } return false; }逻辑说明:两个点(r1, c1)和(r2, c2)连成斜对角线时,矩形另外两个顶点天然就是两个候选拐点。拐点必须为空,同时拐点到两个原点的两段直线必须都连通。这里最容易漏的恰恰是“检查拐点自身是否为空”这一行——很多人只判断两段直线,结果拐点上明明有图片也判定成功,消除动画画了一条穿图的线,非常出戏。
这个函数只能判断“能不能通”,还不能告诉你路径是哪条。如果需要画线,原报告的做法是把拐点也压进路径栈,后面第三章的TwoCornerLink会统一处理。
3.3 TwoCornerLink:枚举扫描与路径栈保存
两条直线也连不通时,还有最后一线希望:三条直线,也就是两个拐点。原报告的TwoCornerLink()用的是暴力枚举法——这完全可以接受,因为地图只有 16×10,枚举所有可能拐点的计算量撑死几百次,比任何“优化”都省心。
思路是这样的:假设玩家选了两个点 V0(row0, col0) 和 V3(row3, col3)。三条直线要连通,必然有一条“中间线段”是水平方向或垂直方向的。先枚举水平中间线:从第 0 行扫到第 15 行,每一行取两个候选拐点 V1(row, col0)、V2(row, col3),如果 V1 和 V2 之间水平连通,且 V0 到 V1 垂直连通、V3 到 V2 垂直连通,那这条路就通了。
bool CGameLogic::TwoCornerLink(tagVertex v0, tagVertex v3, std::vector<tagVertex>& path) { // 先枚举水平中间线:两个拐点在同一行 for (int r = 0; r < MAP_ROWS; r++) { tagVertex v1(r, v0.col, 0); tagVertex v2(r, v3.col, 0); // 拐点自身必须为空,且三段分别连通 if (m_Map[r][v0.col] == BLANK && m_Map[r][v3.col] == BLANK && RowLink(r, v0.col, v3.col) && ColLink(v0.col, v0.row, r) && ColLink(v3.col, v3.row, r)) { // 保存完整路径:起点 V0 -> 拐点 V1 -> 拐点 V2 -> 终点 V3 path.clear(); path.push_back(v0); path.push_back(v1); path.push_back(v2); path.push_back(v3); return true; } } // 再枚举垂直中间线:两个拐点在同一列 for (int c = 0; c < MAP_COLS; c++) { tagVertex v1(v0.row, c, 0); tagVertex v2(v3.row, c, 0); if (m_Map[v0.row][c] == BLANK && m_Map[v3.row][c] == BLANK && ColLink(c, v0.row, v3.row) && RowLink(v0.row, v0.col, c) && RowLink(v3.row, v3.col, c)) { path.clear(); path.push_back(v0); path.push_back(v1); path.push_back(v2); path.push_back(v3); return true; } } return false; }逻辑说明:枚举时要把两个拐点坐标都算出来再判断,不能只判断中间线连通。因为这跟OneCornerLink一样,拐点必须为空,而且 V0→V1、V2→V3 这两段也要分别检查。用std::vector代替原报告里的“栈”,是因为 MFC 老代码里用CArray或自己封的栈类都行,逻辑上都是一个先进先出的路径点序列,画线时从头取到尾就行。
复杂度上,这个函数最多枚举 16 行 + 10 列,每次都做两三次直线判断,总量是常数级别。所以千万别在这个函数里折腾什么启发式搜索或剪枝——对于 160 格的棋盘,暴力枚举就是最优解。
3.4 组装判定接口:消子前的四步检查
三个连通函数各自独立后,还需要一个总入口,统一判断“这一对能不能消”。消子判断不是只调TwoCornerLink就完事,而是有一组前置检查:
// CGameLogic::CanLink // 统一的消子判断入口:返回 true 表示可以消除,path 保存路径点 bool CGameLogic::CanLink(tagVertex v1, tagVertex v2, std::vector<tagVertex>& path) { // 1. 同一个点不能自己消自己 if (v1.row == v2.row && v1.col == v2.col) return false; // 2. 其中一个是空格,直接返回 if (m_Map[v1.row][v1.col] == BLANK || m_Map[v2.row][v2.col] == BLANK) return false; // 3. 两张图片编号不同,不能消除 if (m_Map[v1.row][v1.col] != m_Map[v2.row][v2.col]) return false; // 4. 按复杂度从低到高依次检查 path.clear(); // 同行:一条直线 if (v1.row == v2.row && RowLink(v1.row, v1.col, v2.col)) { path.push_back(v1); path.push_back(v2); return true; } // 同列:一条直线 if (v1.col == v2.col && ColLink(v1.col, v1.row, v2.row)) { path.push_back(v1); path.push_back(v2); return true; } // 两条直线:一个拐点 if (OneCornerLink(v1, v2)) { // OneCorner 内部补充路径点 BuildOneCornerPath(v1, v2, path); return true; } // 三条直线:两个拐点 if (TwoCornerLink(v1, v2, path)) return true; return false; }先判断最简单的一条直线,再逐步升级,是为了避免TwoCornerLink对“同行同列”的点对做无意义枚举。判定成功后在界面层把path里的点连线画出来,再把两个格子置为BLANK,加积分。如果连不上,原报告的设计是保持地图不变,点击状态清空,重新等待玩家选下一对。别小看这个“四步检查顺序”,它其实隐含了一次剪枝:只有当你把一条、两条都排掉后才跑最重的三条枚举,平均每次点击少跑十几行扫描循环,手感会顺不少。
4. 游戏控制层:定时器、重排、胜负判定在 MFC 里怎么落地
4.1 定时器与进度条:SetTimer / OnTimer / KillTimer 的配合
基本模式限时五分钟,界面上放一个进度条倒计时。进度条和数据绑定在CGameDlg里,核心机制是SetTimer启动一个每秒触发一次的定时器,然后在OnTimer里把进度条位置一格一格往下减。
// CGameDlg::OnBnClickedButtonStart // 点击“开始游戏”后触发 void CGameDlg::OnBnClickedButtonStart() { // 初始化进度条:范围 0~5000,单位是毫秒 CProgressCtrl* pProgress = (CProgressCtrl*)GetDlgItem(IDC_PROGRESS); pProgress->SetRange(0, 5000); pProgress->SetPos(5000); // 启动定时器:1000 毫秒触发一次 SetTimer(PLAY_TIMER_ID, 1000, NULL); m_bPlaying = TRUE; GetDlgItem(IDC_BUTTON_START)->EnableWindow(FALSE); }// CGameDlg::OnTimer void CGameDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == PLAY_TIMER_ID && m_bPlaying) { CProgressCtrl* pProgress = (CProgressCtrl*)GetDlgItem(IDC_PROGRESS); int nPos = pProgress->GetPos(); pProgress->SetPos(nPos - 1000); // 每秒扣 1000 毫秒 // 进度条归零:时间到 if (pProgress->GetPos() <= 0) { KillTimer(PLAY_TIMER_ID); m_bPlaying = FALSE; MessageBox(_T("很遗憾,时间到了!是否重新开始游戏?"), _T("提示"), MB_YESNO); } } CDialogEx::OnTimer(nIDEvent); }逻辑说明:进度条的范围直接设成毫秒数(0 到 5000),每秒减 1000,这样可以少用一个单独的时间变量。你只需要读进度条的位置,就能同时拿到“剩余时间”和“进度条显示位置”,一举两得。m_bPlaying是暂停状态的开关,暂停时m_bPlaying = FALSE,OnTimer里第一步就拦截掉,进度条自然停住。
参数说明:PLAY_TIMER_ID是自己定义的消息 ID,比如#define PLAY_TIMER_ID 1001。MFC 的SetTimer可以带一个UINT_PTR参数,如果程序里还有其他定时器(比如动画刷新),必须靠它区分,别在OnTimer里不判断 ID 直接处理,那样会连别的定时器一起误伤。
4.2 重排 DisOrderMap:只打乱剩余棋子
游戏进行中很可能出现“还有棋子,但谁跟谁都连不上”的死局。这时候重排按钮就要把所有剩余棋子随机打乱,让玩家继续玩。
// CGameLogic::DisOrderMap void CGameLogic::DisOrderMap() { srand((unsigned int)time(NULL)); int nTotal = MAP_ROWS * MAP_COLS; for (int i = 0; i < 500; i++) { int pos1 = rand() % nTotal; int pos2 = rand() % nTotal; int r1 = pos1 / MAP_COLS; int c1 = pos1 % MAP_COLS; int r2 = pos2 / MAP_COLS; int c2 = pos2 % MAP_COLS; // 跳过空格:不能把已消除的位置又翻出一张图来 if (m_Map[r1][c1] == BLANK || m_Map[r2][c2] == BLANK) continue; int temp = m_Map[r1][c1]; m_Map[r1][c1] = m_Map[r2][c2]; m_Map[r2][c2] = temp; } }这里和初始化的ShuffleMap最大的区别就是多了“跳过空格”的判断。初始洗牌时地图全满,自然不需要;但重排时棋盘上已经消了一大半,如果随机选中一个空格和一个有图格子交换,地图就会“凭空多出”一张图片,总棋子数对不上,后期可能永远消不完。重排的调用链是:界面层OnBnClickedButtonReset()→CGameControl::DisOrder()→CGameLogic::DisOrderMap(),一层套一层,每一层只做自己职责内的事。
重排有个隐藏问题:如果地图本身已经处于无解状态,打乱后仍然可能是无解的,这属于概率事件。严谨的做法是重排后立即跑一遍“全局可消检测”,没找到任何可消除对就再打乱一次,这个检测在第五章会展开说。
4.3 胜负判定与三种模式
胜负判定围绕IsBlank展开——遍历整个地图,所有格子都为BLANK说明消完了。原报告用IsBlank(CGameLogic::m_Map)封装了这个遍历。不同模式的差异在于“完胜条件”:
| 模式 | 计时 | 胜负条件 | 说明 |
|---|---|---|---|
| 基本模式 | 5 分钟倒计时 | 时间未用完且全部消除才获胜 | 时间到但没消完,判负 |
| 休闲模式 | 不计时 | 不限制时间,全部消除即获胜 | 只管消完,无失败概念 |
| 关卡模式 | 可配置 | 原报告只开了口子,未给出独立实现 | 复用时修改nPicNums与倒计时即可 |
对应代码逻辑:
// 胜负判断 if (m_GameProgress.GetPos() <= 0 && !cgc.IsBlank(cgc.m_Map)) { // 时间到,还没消完,判负 KillTimer(PLAY_TIMER_ID); MessageBox(_T("很遗憾,时间到了!是否重新开始游戏?"), _T("提示"), MB_YESNO); } else if (m_GameProgress.GetPos() > 0 && cgc.IsBlank(cgc.m_Map)) { // 时间没用完,地图已空,判胜 KillTimer(PLAY_TIMER_ID); MessageBox(_T("获胜!是否重新开始游戏?"), _T("提示"), MB_YESNO); }这段是从原报告里直接能读到的逻辑。休闲模式下GetPos()永远不会到达 0,所以只需要关注后一个分支。要注意的是先判断“时间到且未消完”,再判断“已消完且时间未到”,顺序不能反——如果消完最后一张图的瞬间进度条恰好归零,应该优先判负,还是判胜?按原报告的分支顺序,是先判时间,这意味着“边消边倒计时”的瞬间以时间为准,这个取舍在做需求分析时最好提前定下来。
4.4 暂停继续、帮助页与提示功能
暂停按钮的交互在这类作业里很讨巧:按钮文字在“暂停游戏/继续游戏”之间切换,同时控制一个m_bPlaying标志。暂停时不再响应消子点击,定时器虽然在跑但OnTimer里被m_bPlaying拦截,进度条停下来;继续则恢复全部响应。原报告的代码片段里有SetDlgItemText(IDC_BTN_GAME_STOP, ...)切换文字,配合KillTimer/SetTimer恢复计时即可。
帮助对话框CHelpDialog是另一个 MFC 入门的典型场景:加载一张 BMP 图片,加上滚动条。加载图片用::LoadImage(NULL, _T("theme\\picture\\Help1.bmp"), IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE),然后BitBlt把图片画到内存 DC 再贴到界面。滚动条则通过SetScrollRange(0, bmpHeight)设定范围,在OnVScroll里根据滚动消息类型(SB_LINEUP、SB_PAGEDOWN等)调整滚动位置并调用UpdateHelp(Pos)重绘。
这里给一个提升质感的建议:帮助页不要用一张超长图片硬撑,可以把游戏规则拆成几行文字控件,滚动条只滚动一个容器区域。这样加载快,后期改文案也不用重新做图。提示功能也是同理——遍历整张地图,把所有未消除的点对交给CanLink判一遍,找到第一对能消的就返回。160 个格子全遍历也就一万多次调用,玩家点提示时瞬间出结果。
5. 避坑排查:地图生成、拐点占用和 MFC 集成的六个常见问题
5.1 地图生成层:奇数校验、行列约定与死局
问题 1:图片出现次数是奇数,玩到后期剩 1~3 张孤子。现象:游戏进行到最后,界面上剩一两张相同的图片,可无论怎么点都提示“不能消除”,永远无法通关。 原因:初始化时没做nRepeatNum % 2校验。比如 6 种图片、160 个格子时,160 / 6 = 26,每种出现 26 次,这是偶数没问题;但如果用户自定义地图大小时配了160 / 12 = 13,每种 13 张,必然剩单张。 解决:在填充地图前,先做if (nTotal % nPicNums != 0 || nRepeatNum % 2 != 0)的校验,不合法就直接弹窗报错,不进入游戏。这个分支一定要留着,否则后面所有判定函数跑得再对,地图本身就是个死局。
问题 2:m_Map[10][16]和m_Map[16][10]混用,下标越界。现象:编译不报错,但运行时一选图片就崩溃,或者 Debug 版本弹出“数组下标越界”断言。 原因:原报告里出现了m_Map[10][15]、m_Map[10][16]这类写法。版本作者把“16 行 × 10 列”误写成数组[10][16],第一维和第二维的位置颠倒。代码里一旦混用两套约定,m_Map[r][c]里的r和c会在某处被互换,行列都越界。 解决:统一用#define MAP_ROWS 16和#define MAP_COLS 10,所有函数签名都写int map[MAP_ROWS][MAP_COLS]。拿到第三方源码时,第一件事是看它数组定义处注释的是“行×列”还是“宽×高”,是后者就做一次行列转置,不要心存侥幸直接调用。
问题 3:重排后仍然无解,玩家反复点重排想骂人。现象:点击重排,界面刷新了,但依然没有任何一对能消的图片。 原因:DisOrderMap只做了随机交换,没有判断交换后局面的可解性。纯随机洗牌遇到死局的概率不低,尤其是棋子还很多的时候。 解决:重排后立即跑一遍“全盘扫描”——遍历所有剩余棋子对,找是否存在任意一对满足CanLink。如果没有,就再洗一次,最多重试若干次;重试超过上限后,干脆把剩余棋子重新按“初始填充 + 洗牌”的方式再一次生成。这个全盘扫描在 160 格地图上,最坏情况约 12800 次CanLink调用,每秒钟能跑完,不用担心卡界面。
5.2 判定算法层:拐点是否为空、边界与端点排序
问题 4:斜对角的两张图片,中间明明空了一片,却提示不能消。现象:玩家手动验证过,两条直线路径上没有任何图片,但程序就是返回false。 原因:OneCornerLink只检查了两段直线,漏了拐点自身的空格判断。候选拐点(r1, c2)或(r2, c1)上可能恰好有一张没消除的图片,路径穿图而过,逻辑上不合法。 解决:判定顺序固定为“拐点为空 → 第一段直线连通 → 第二段直线连通”,三步缺一不可。写代码时把拐点检查放在最前面,注释里标清楚,防止以后重构时顺手删掉。
问题 5:地图边缘的棋子偶尔判定异常,或者程序崩溃。现象:点击靠边的图片对时,明明能通却被判不通;极端情况下报“访问越界”。 原因:TwoCornerLink枚举拐点行时,如果从-1或MAP_ROWS取值,m_Map[r][c]就会越界。另外RowLink/ColLink如果没对端点做排序,当col1 > col2时循环会直接跳过,误判为不连通。 解决:RowLink/ColLink内部做端点交换排序;TwoCornerLink的枚举范围严格控制在0 <= r < MAP_ROWS、0 <= c < MAP_COLS。如果地图外圈有意留了一圈空白,那边界是1到MAP_ROWS-2,这个偏移量要单独定义成常量,别直接写在循环条件里。
5.3 MFC 层:定时器不停止、资源路径与画面刷新
问题 6:消完最后一张图后,倒计时还在继续,甚至弹出失败提示。现象:玩家明明获胜了,进度条仍然在走,过一会儿弹出来“时间到了”。 原因:胜利分支里只MessageBox弹窗,没有调用KillTimer。定时器还在跑,OnTimer继续扣进度条,直到归零触发失败分支。 解决:胜负两个分支里都先KillTimer(PLAY_TIMER_ID),再弹窗。暂停时同理,先KillTimer再改m_bPlaying,恢复时再SetTimer。养成“改状态先停表”的习惯,这类问题就断根了。
问题 7:帮助页图片加载不出来,或者游戏界面刷新时疯狂闪烁。现象:LoadImage返回NULL,图片区域一片空白;消除两张图时整个界面白屏闪一下。 原因:LoadImage用的相对路径"theme\\picture\\Help1.bmp"是相对于“当前工作目录”的,而 MFC 工程调试时工作目录经常是项目目录而不是 exe 目录,路径就对不上。闪烁则是因为直接Invalidate后立刻重绘,没有用双缓冲。 解决:用GetModuleFileName拼出 exe 所在目录,再拼接位图相对路径,彻底避开工作目录问题。绘图采用m_dcMem.BitBlt先把背景和棋子画到内存 DC,再一次BitBlt到屏幕 DC,原报告代码里已经有这套雏形,把它完整运用到所有重绘分支就行。
6. 验证与改进:从三直线枚举到统一 BFS 判定
算法写完了,怎么证明它对?我的习惯是先把地图抽象成一组手工用例跑一遍,而不是直接开游戏乱点。准备几个 5×5 的小棋盘,手动摆出这些局面:
| 用例 | 摆法 | 预期 |
|---|---|---|
| 同一行中间隔两个空格 | 1 _ _ 1 | RowLink返回 true |
| 同一行中间有一张其他图 | 1 2 1 | RowLink返回 false |
| L 形,拐点为空 | 两张 1 分布在 2×2 斜对角,另外两个拐点中一个是空格 | OneCornerLink返回 true |
| U 形,两个拐点都在空线上 | 两张 1 之间隔一行,中间有一整行空格 | TwoCornerLink返回 true |
跑通这四组用例,核心算法基本可信。再用随机生成的 160 格地图做压力验证:循环一万次CanLink,观察有没有异常崩溃,顺便统计平均判定耗时。
如果想让算法更优雅,三条直线枚举法可以改造成统一的 BFS 搜索。把“连通”定义成“从起点沿四个方向直行,每次直行都允许拐一次弯,拐弯次数不超过 2 次”,状态是(row, col, 当前方向, 已拐弯次数),BFS 一直扩展到终点为止:
// 思路示意:状态四元组 struct State { int row, col; int dir; // 上一次移动方向:0=上 1=右 2=下 3=左 int turns; // 已拐弯次数 };扩展时沿当前方向一路走到底,遇到空格就继续,遇到非空格就停下;停下来后允许左转或右转进入新方向,同时turns + 1,超过 2 就丢弃该状态。这样三个函数统一成一个搜索流程,以后想支持“最多 N 个拐点”的变体规则,只改一个上限常量,不用新增三个函数。代价是需要额外的队列和状态数组,在这个体量下完全可以忽略。
从那以后,我每次写这种棋盘类小游戏,都会先花十分钟把“数据定义、判定函数、控制层”三块在注释里理清楚再动键,任何棋盘类作业都先强制过一遍那四组手工用例,再挂界面。思路顺了,后面基本不用回头改核心算法。这份实验报告里的地图生成、消子判定、MFC 交互和排错经验都拆在上面了,照着一章章复现下来,你的课程设计应该能少走不少弯路。希望帮到你。
本文还有配套的精品资源,点击获取