MFC连连看游戏实现:二维数组地图与消子连通算法详解
简介武汉理工大学数据结构与算法综合实验的连连看游戏实验报告面向计算机相关专业学生及需要完成同类课程设计的学习者。文档完整记录了基于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、TwoCornerLinkCGameControl.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::vectortagVertex 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::vectortagVertex 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 FALSEOnTimer里第一步就拦截掉进度条自然停住。参数说明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)的校验不合法就直接弹窗报错不进入游戏。这个分支一定要留着否则后面所有判定函数跑得再对地图本身就是个死局。问题 2m_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 _ _ 1RowLink返回 true同一行中间有一张其他图1 2 1RowLink返回 falseL 形拐点为空两张 1 分布在 2×2 斜对角另外两个拐点中一个是空格OneCornerLink返回 trueU 形两个拐点都在空线上两张 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 交互和排错经验都拆在上面了照着一章章复现下来你的课程设计应该能少走不少弯路。希望帮到你。本文还有配套的精品资源点击获取