简介:这份压缩包是一套基于微软基础类库(MFC)编写的数据结构课程设计——连连看游戏项目,面向正在学习数据结构、面向对象程序设计或Windows桌面开发的学生,也适合需要参考完整实例来巩固理论知识的开发者。包内共含532个文件,主体包括13个头文件与9个C++源文件,以及Visual Studio工程配置文件;界面资源方面有69个bmp背景图、309个png图片素材和18个wav音效,另附可执行程序、PDF说明、Word文档等,压缩包整体约287MB,结构较完整,便于直接打开工程查看代码与资源。目前已有477人浏览学习,参考价值得到一定验证。项目利用二维数组表示棋盘状态,使用队列进行广度优先搜索求解可消除图案对,并借助MFC的CDC完成图形绘制与消息映射处理交互;除游戏主逻辑外,还包含开始界面、背景切换、消除动画等素材,包内各部分分工清晰,适合结合数据结构知识点逐模块分析,也可在此基础上扩展功能或二次开发。
1. 拆开 LLK-MFC-master:一个用二维数组撑起来的连连看
很多人第一次看到 LLK-MFC-master 这样的工程,注意力都会被那堆 bmp 资源吸引——prtScr1.bmp、gamebkg (1).bmp 到 (5).bmp、llk_start_background0.bmp 和 1.bmp,还有那个 Lianliankan.aps。但真正决定这个项目能不能跑、跑起来卡不卡、消除判定准不准的,是棋盘背后的数据结构选型。连连看看起来是个休闲小游戏,实际上是一道很典型的数据结构综合题:棋盘怎么存、连通路径怎么找、消除后空缺怎么处理,三件事分别对应二维数组、广度优先搜索和状态维护。
这个工程适合两类人:一类是正在做数据结构课程设计、想找一个能把数组、队列、BFS 串起来的完整案例;另一类是想碰 MFC 但一直停留在对话框 Demo 阶段,想看看 CDC 绘图、消息映射、资源加载在真实项目里怎么配合。下面按数据层、渲染层、算法层、工程层拆开讲,所有步骤都以 Visual Studio 打开 vcxproj 后能直接操作为准。
2. 棋盘数据结构选型与 MFC 工程落地
2.1 为什么最终还是要回到二维数组
摘要里列了二维数组、链表、队列、堆四种候选。真动手写就会发现,链表在连连看里是个陷阱。棋盘是固定 8×12 或 10×14 的矩形,行列位置天然连续,用链表存反而要为每次「取第 i 行第 j 列」付出 O(n) 的遍历代价,而连通判定里这个操作会被调用上万次。
常见做法是用一个带边界填充的二维数组:
// 棋盘四周留一圈空白,消除判定时不必反复做边界检查 const int ROWS = 12; // 含上下各一圈空白 const int COLS = 14; // 含左右各一圈空白 const int EMPTY = 0; // 0 表示该格已空 int m_board[ROWS][COLS]; // 初始化:只给内部区域填图案编号,外圈保持 EMPTY void InitBoard() { for (int r = 0; r < ROWS; ++r) for (int c = 0; c < COLS; ++c) m_board[r][c] = EMPTY; // 内部区域 1..ROWS-2, 1..COLS-2 成对填充图案 for (int r = 1; r < ROWS - 1; ++r) for (int c = 1; c < COLS - 1; ++c) m_board[r][c] = RandomType(); // 返回 1..N 的图案编号 }参数说明:ROWS 和 COLS 比实际棋盘大 2,是为了让「绕棋盘外侧走」这条合法路径天然落在数组范围内。图案编号从 1 开始,0 专门留给空位,这样判断空格只需一次比较,不用额外的布尔数组。RandomType 返回的图案必须成对出现,否则会出现无解残局,常见做法是先按对数生成再整体洗牌。
2.2 在 VS 里把 MFC 工程跑起来
vcxproj 打开后第一件事不是编译,是确认 MFC 库挂上了。项目属性里检查三处:
| 配置项 | 路径 | 应设为 |
|---|---|---|
| 使用 MFC | 配置属性 → 高级 → 使用 MFC | 在共享 DLL 中使用 MFC |
| 字符集 | 配置属性 → 高级 → 字符集 | 使用 Unicode 字符集 |
| 子系统 | 链接器 → 系统 → 子系统 | Windows (/SUBSYSTEM:WINDOWS) |
如果报「无法打开 afxwin.h」或一堆 unresolved external,基本都是「使用 MFC」被设成了「不使用 MFC」。另外字符集如果不统一,CDC::DrawText 和 CString 的传参会报类型不匹配,这是新手在这个工程上最常卡住的地方。
资源视图里能看到 rc 文件定义的对话框和图标。aps 文件是 VS 自动生成的资源符号缓存,删掉后重新打开工程会重建,不用手动维护。
2.3 用 CDC 把二维数组画到客户区
数据层就绪后,渲染是直接的映射:数组下标乘单元格尺寸就是像素坐标。
void CLLKView::OnDraw(CDC* pDC) { const int CELL = 48; // 每格 48 像素,与 bmp 图案尺寸一致 for (int r = 1; r < ROWS - 1; ++r) { for (int c = 1; c < COLS - 1; ++c) { int type = m_board[r][c]; if (type == EMPTY) continue; // 空格不绘制 int x = (c - 1) * CELL; int y = (r - 1) * CELL; // 选中格加高亮边框,提升点击反馈 if (r == m_selR && c == m_selC) pDC->Rectangle(x - 2, y - 2, x + CELL + 2, y + CELL + 2); m_imgList.Draw(pDC, type - 1, CPoint(x, y), ILD_NORMAL); } } }这里用 CImageList 管理图案比每次 LoadBitmap 高效得多,图案在初始化时一次性载入。注意 (c-1) 和 (r-1) 的偏移,因为数组外圈是空白,绘制时要扣掉,否则整个棋盘会右下偏移一格。视觉资源里那几张 gamebkg 就是在这里当背景贴图用的,先 Draw 背景再叠图案即可。
3. 连通判定:BFS 找路径与转弯数控制
3.1 连连看判定规则的形式化
规则说起来简单:两个同图案格子,如果之间存在一条不经过其他图案、拐弯不超过两次的路径,就能消除。关键在「拐弯不超过两次」这个约束,它把问题变成带状态的最短路搜索。
判定的核心洞察是:任意一条合法路径最多三段直线,拐点为零到两个。所以不必真的搜最短路径,只要枚举所有可能的拐点组合,对每段做直线可达性检查即可。常见实现是枚举两个拐点的行列,O(N²) 量级,在 12×14 的棋盘上完全够用;用 BFS 按转弯数做分层搜索也可以,逻辑更统一。
3.2 直线可达性与转弯点枚举
先写最基础的原子操作:两点在同一行或同一列,且中间全是空的。
// 判断同行或同列两点之间是否畅通(不含端点) bool CLLKView::IsLineClear(int r1, int c1, int r2, int c2) { if (r1 == r2) // 同一行,横向扫描 { int lo = min(c1, c2) + 1, hi = max(c1, c2); for (int c = lo; c < hi; ++c) if (m_board[r1][c] != EMPTY) return false; return true; } if (c1 == c2) // 同一列,纵向扫描 { int lo = min(r1, r2) + 1, hi = max(r1, r2); for (int r = lo; r < hi; ++r) if (m_board[r][c1] != EMPTY) return false; return true; } return false; // 既不同行也不同列,不存在直线 }注意端点本身不参与检查,因为端点格子是有图案的,检查它必然返回 false。这个细节写错会导致所有相邻同类图案都判不出来。
一转弯判定就是找一个既是 A 的可达点、又是 B 的可达点的拐点:
bool CLLKView::CanLinkOneTurn(int r1, int c1, int r2, int c2) { // 拐点 (r1,c2):A 横向到它,再纵向到 B if (m_board[r1][c2] == EMPTY && IsLineClear(r1, c1, r1, c2) && IsLineClear(r1, c2, r2, c2)) return true; // 拐点 (r2,c1):A 纵向到它,再横向到 B if (m_board[r2][c1] == EMPTY && IsLineClear(r1, c1, r2, c1) && IsLineClear(r2, c1, r2, c2)) return true; return false; }拐点必须是空格,这一点常被忽略。如果拐点上有图案,路径实际上被挡住了,判定会误判为可消除。
两转弯则在两点的行列延伸线上枚举拐点对,逐段验证。有了外圈那层空白,绕棋盘外侧走的情况自动被覆盖,不需要单独写分支。
3.3 消除后的状态维护与死局检测
消除成功后要立刻清空数组并重绘:
void CLLKView::Eliminate(int r1, int c1, int r2, int c2) { m_board[r1][c1] = EMPTY; m_board[r2][c2] = EMPTY; m_remain -= 2; Invalidate(); // 触发 OnDraw 重绘 if (!HasAnyMatch()) // 无解处理 { if (!ShuffleBoard()) // 洗牌也救不回来 AfxMessageBox(_T("无解,游戏结束")); } }HasAnyMatch 的常见做法是遍历所有同类型的格子对,调用一次判定函数;类型分组可以先按图案编号建索引,避免全量两两比较。ShuffleBoard 把剩余图案重新随机分配到现有空格上,重新分配后要再检测一次,防止洗出死局。这套「消除—检测—洗牌」的循环是保证游戏可玩性的关键,缺了死局检测,玩家会遇到点遍全场都消不掉的局面。
4. 消息映射、资源加载与工程结构排错
4.1 消息映射把点击事件接到判定逻辑
MFC 不像 Qt 那样用信号槽,双击类视图里的对话框资源,或者手动在消息映射表里加条目:
// LLKView.h afx_msg void OnLButtonDown(UINT nFlags, CPoint point); // LLKView.cpp BEGIN_MESSAGE_MAP(CLLKView, CView) ON_WM_LBUTTONDOWN() ON_WM_PAINT() ON_WM_ERASEBKGND() END_MESSAGE_MAP() void CLLKView::OnLButtonDown(UINT nFlags, CPoint point) { int c = point.x / CELL + 1; // 屏幕坐标反算回数组下标 int r = point.y / CELL + 1; if (r < 1 || r >= ROWS - 1 || c < 1 || c >= COLS - 1) return; // 点在棋盘外,忽略 if (m_board[r][c] == EMPTY) return; if (m_selR < 0) // 首次选中 { m_selR = r; m_selC = c; } else if (m_selR == r && m_selC == c) // 点同一格,取消选中 { m_selR = m_selC = -1; } else if (m_board[r][c] == m_board[m_selR][m_selC] && CanLink(m_selR, m_selC, r, c)) { Eliminate(m_selR, m_selC, r, c); m_selR = m_selC = -1; } else // 换选 { m_selR = r; m_selC = c; } Invalidate(); }这里写成point.x / CELL + 1而不是减,是因为数组带外圈,屏幕坐标 (0,0) 对应数组的 (1,1)。坐标换算的偏移方向搞反,是整个工程里最容易出现、最难肉眼发现的一类 bug,表现是点哪里都消不掉或者消错格子。
4.2 资源与 bmp 加载的常见坑
工程里那批 bmp 要挂到资源里才能被 LoadBitmap 读到。资源视图 → 右键「添加资源」→ Bitmap → 导入,然后给每个 ID 起名。CImageList 建议在窗口初始化时创建:
BOOL CLLKView::PreCreateWindow(CREATESTRUCT& cs) { m_imgList.Create(48, 48, ILC_COLOR24 | ILC_MASK, 0, 8); // 按图案编号顺序加载,索引与 m_board 里的编号减一对应 for (UINT id = IDB_TILE_BEGIN; id <= IDB_TILE_END; ++id) m_imgList.Add(&CBitmap(), (COLORREF)0); return CView::PreCreateWindow(cs); }ILC_MASK 配合透明色可以去掉图案背景,不然所有格子都会是方块。如果 bmp 尺寸和 CELL 不一致,图案会被拉伸或裁切,建议先在资源编辑器里确认每张图都是 48×48。
| 现象 | 大概率原因 | 处理 |
|---|---|---|
| 编译报 afxwin.h 找不到 | 未启用 MFC | 属性里改「使用 MFC」 |
| 图案显示为方块 | 未设透明色 | ILC_MASK + 指定掩码色 |
| 点击位置偏移 | 坐标换算漏加外圈偏移 | 检查 /CELL+1 |
| 消除后界面不刷新 | 未调用 Invalidate | 状态变更后立即重绘 |
| 相邻同类消不掉 | 直线检查把端点算进去 | IsLineClear 排除端点 |
4.3 拆分源码文件保持工程可维护
一个把逻辑、绘制、消息全塞进 View 类的工程,写到后面会很难改。常见做法是按职责拆成三个单元:GameBoard 负责 m_board、InitBoard、HasAnyMatch、ShuffleBoard;LinkChecker 负责 IsLineClear、CanLink 这类纯算法;View 只保留绘图和消息响应。这样算法部分可以单独写控制台测试用例,不用起整个 MFC 窗口就能验证判定逻辑,调试效率完全不同。
5. 用控制台测试桩验证连通判定
MFC 工程的调试痛点是每次验证算法都要走 GUI,慢且难定位。一个实用技巧是把棋盘和判定逻辑抽到一个不依赖 MFC 的独立类里,用控制台程序跑自动化用例:
// test_link.cpp —— 不引用任何 MFC 头文件 int main() { GameBoard board; board.LoadFromString( "0000000" "0110000" "0010000" "0000000"); // 1 表示同种图案 LinkChecker checker(board); // 两个 1 可以经外侧绕行,应返回 true assert(checker.CanLink(1, 1, 2, 2) == true); board.Set(1, 2, 1); // 挡住直连路径 assert(checker.CanLink(1, 1, 2, 2) == true); // 仍有绕行路径 board.Set(1, 3, 1); board.Set(2, 1, 1); // 三面围死 assert(checker.CanLink(1, 1, 2, 2) == false); return 0; }用字符串写棋盘的好处是测试用例一眼能看懂,改一个字符就能构造新场景。把 IsLineClear、CanLink 从 MFC 的 CView 里剥出来,是这个工程最值得做的重构,它让数据结构部分的正确性可以被独立验证,而不是靠肉眼盯着屏幕猜。
判定正确之后再看性能。12×14 棋盘上每次点击的判定量在毫秒级,一般不需要优化;但如果把棋盘做到 20×20 以上,两转弯枚举的 O(N⁴) 会开始有感。这时可以按转弯数做分层 BFS,每一层只扩展当前转弯数可达的格子,把复杂度压下来。常见的分层做法是:起点先向四个方向做直线延伸,遇到的空格入队并标记为「1 转弯可达」,再从这些格子继续向垂直方向延伸,超过两次转弯就停止。用已访问标记避免重复入队,是这类搜索的标准写法。路径本身也可以存下来,用于绘制消除时的那道连线动画,把转弯点坐标传给 CDC 画折线即可,这是让游戏手感明显提升的一步。
本文还有配套的精品资源,点击获取