简介这是一套基于VC与MFC编写的迷宫小游戏工程适合C初学者、游戏开发入门者以及有课程设计需求的学生。程序支持随机生成迷宫地图玩家使用方向键控制红色方块从起点走到出口直观展示了键盘消息处理、二维数组地图建模、寻路与碰撞检测等基础编程知识。压缩包共11个文件大小仅14KB以头文件承载地图创建、绘制、寻路等核心模块另有cpp源文件、rc资源脚本和dsp/dsw工程配置编译环境完整便于直接运行和二次修改。读者可以对照学习随机迷宫生成算法的实现思路以及MFC窗口程序的组织方式。目前已有487人学习下载资源虽小但结构清晰适合用来分析代码模块划分和游戏主循环设计也可在现有框架上继续增加计时、关卡或音效等功能。1. VC迷宫游戏随机生成的迷宫地图才是这个项目真正的灵魂一说 VC 迷宫游戏很多人第一反应是“课设又来了”。确实是课设经典但真正把它做好并不容易——难点不在窗口和键盘响应而在于那一句“支持随机生成迷宫地图”。地图是程序跑起来的那一刻才产生的每次按 F5 都不一样这意味着你不能手摆坐标必须用算法在内存里“长”出一张迷宫。这个项目适合三类人准备交课程设计的学生、想把图论算法落到具体界面的练手者以及想给孩子做一个可玩性游戏、顺便展示数据结构价值的开发者。读完这篇你能从选算法、写数据、画界面到排坑走完一遍拿到一套可以直接改成课设或小游戏的核心代码思路。2. 迷宫生成算法怎么选深度优先、Prim 还是递归分割2.1 三种主流算法生成的迷宫玩起来手感完全不同随机迷宫生成不是一拍脑袋随便开墙的它是图论里“生成树”问题的一次可视化落地。迷宫可以看成一张网格图每个房间是一个节点墙是未连通的边生成迷宫就是从这张全连接的网格里抽出一棵生成树树上的边就是路剩下的边全部封死成墙。树的结构决定了迷宫的长相和玩法。最常见的三种算法递归回溯深度优先、随机 Prim、递归分割。它们的核心特征差异非常大算法核心思路迷宫特征适合场景深度优先DFS一路往前挖死路就回头通道修长、岔路少、解唯一且绕最经典适合普通玩家寻路随机 Prim从边界随机扩张分支多、路径短、更像“树根”需要丰富岔路探索的地图递归分割二分房间再开墙结构化强、有房间感更接近真实地牢风格我做这个项目时首选深度优先理由很直接它生成的迷宫只有一个解玩家体验最“正”——走到死胡同就是走错了不存在绕来绕去全是岔路的问题。Prim 生成的迷宫分支太多对于一个小地图来说反而显得碎递归分割视觉上好看但代码量更大而且开墙时容易开出“一条直路穿到底”的通道减少了绕路感。课设答辩场景下深度优先的口头讲解也是最容易说清楚的。2.2 深度优先迭代版生成核心代码避免递归爆栈深度优先最常见的是递归写法。递归版代码很短很好懂但它有个致命短板地图一旦到 50x50 以上递归深度可能超过几千层调用栈直接爆掉。所以我写这个迷宫生成时直接用迭代版用std::vector模拟栈。核心代码如下// maze.h struct MazeMap { int width 41; // 地图宽度必须是奇数 int height 41; // 地图高度必须是奇数 int cell 12; // 每个格子的像素大小 std::vectorstd::vectorint map; // 0路 1墙 }; // maze_gen.cpp深度优先生成迷宫迭代版 void GenerateMaze(MazeMap maze) { // 1. 初始化全墙 maze.map.assign(maze.height, std::vectorint(maze.width, 1)); // 2. 使用 vector 模拟栈避免递归过深 std::vectorstd::pairint, int stack; int startX 1, startY 1; maze.map[startY][startX] 0; // 起点挖成路 stack.push_back({startX, startY}); // 3. 方向向量右、下、左、上每次走两格 int dirs[4][2] {{2, 0}, {0, 2}, {-2, 0}, {0, -2}}; while (!stack.empty()) { auto [cx, cy] stack.back(); // 4. 收集“隔着墙”的未访问格子 std::vectorint candidates; for (int i 0; i 4; i) { int nx cx dirs[i][0]; int ny cy dirs[i][1]; if (nx 0 ny 0 nx maze.width - 1 ny maze.height - 1 maze.map[ny][nx] 1) { candidates.push_back(i); } } // 5. 有可挖的邻居就随机选一个拆墙开路 if (candidates.empty()) { stack.pop_back(); // 没有出路就回溯 } else { int dirIndex candidates[rand() % candidates.size()]; int wallX cx dirs[dirIndex][0] / 2; int wallY cy dirs[dirIndex][1] / 2; maze.map[wallY][wallX] 0; // 把之间的墙挖掉 int nx cx dirs[dirIndex][0]; int ny cy dirs[dirIndex][1]; maze.map[ny][nx] 0; // 新格子变成路 stack.push_back({nx, ny}); } } }这段代码的逻辑拆开看其实就一句话每次站在当前格子隔墙找没去过的邻居找到了就拆墙走过去找不到就原路退回。真正画到窗口上的时候你看到的迷宫就是从起点开始“一路摸黑挖出来的”。参数上有两个必须注意的地方。第一是dirs数组每次移动量是 2拆墙位置取 1这是为了保证墙和路的宽度一致——如果移动量是 3墙就会比路宽画面不协调。第二是rand() % candidates.size()这里srand的种子如果固定每次生成的迷宫都一样想每次不同要么用srand((unsigned)time(nullptr))要么在 Windows 下用rand_s这类更可靠的随机源。种子问题后面第四章单独讲。2.3 地图尺寸和墙/路编码为什么必须用奇数宽高地图数据我直接用一个vectorvectorint二维数组1是墙0是路。这个编码在初始化时非常方便——全部填 1然后从起点开始逐个挖成 0。绘图的时候灰色刷子画 1 的格子白色刷子画 0 的格子逻辑干净利落。这里有一个很容易被忽略但十分关键的约定地图的宽和高必须同时是奇数。因为生成算法的起点在(1,1)每次跳两格坐标的奇偶性永远不变——起点是奇数坐标1,1所有被挖开的格子坐标也都是奇数。而墙的坐标必然是偶数。如果地图宽度是偶数最右边的列坐标是偶数它在算法视角里永远是墙就会出现整列墙的“边框粗边”问题而且玩家视线会被一堵永远打不破的墙堵住。所以我在代码里把宽高写死为 41因为 41 是奇数且格子总数适中在 12 像素格子的设置下画布约 492 像素一个 Win32 窗口刚好放下。另外需要注意的是如果你希望玩家从右下角出去终点最好放在(width-2, height-2)。这个位置是奇数坐标一定是路不会被墙卡住。很多半成品迷宫“没出口”本质就是终点选在了偶数坐标刚好压在了墙上。3. 用 GDI 把地图画出来并让玩家动起来3.1 框架怎么选Win32 API 还是 MFCVC 的“C”和“微软”两个标签下最常见的两个界面框架是 MFC 和 Win32 API。对于迷宫游戏这个量级我一般直接用 Win32 API 手写窗口过程不挂 MFC。理由很简单迷宫游戏只有一个窗口、一组按键响应MFC 的消息映射、文档视图结构在这里全是负担完全用不上。且 Win32 API 的代码在 VS2017、VS2019、VS2022 上都能直接编译不需要额外配置运行库。不过我必须提醒一句如果你是照着老教材抄的例程打开项目发现大量CString、AfxMessageBox那是 MFC 工程。新起一个 Win32 项目时最好从“Windows 桌面应用程序”模板创建源代码文件用.cpp后缀避免模板默认生成一堆用不到的头文件。另外有些电脑没有装 VC 运行库静态编译或者把运行库选项调成“多线程 (/MT)”可以让 exe 直接扔到别的机器上跑少一些环境问题。3.2 绘制迷宫画布从二维数组到矩形块迷宫数据在内存里是一堆 0 和 1窗口里要把它翻译成矩形块。GDI 里最笨也最稳的办法是双重缓冲——先在内存 DC 上画完一整张图再一次性BitBlt到窗口。这个方法对迷宫这种图案固定、刷新频率极高的场景特别重要不做双缓冲的话窗口每次重绘都会闪烁整个画面像在“抖动”实际观感会劝退很多人。// render.cpp绘制迷宫地图 void DrawMaze(HDC hdc, const MazeMap maze, HWND hwnd) { // 1. 创建内存 DC用于双缓冲绘制 HDC memDC CreateCompatibleDC(hdc); int totalW maze.width * maze.cell; int totalH maze.height * maze.cell; HBITMAP memBmp CreateCompatibleBitmap(hdc, totalW, totalH); HBITMAP oldBmp (HBITMAP)SelectObject(memDC, memBmp); // 2. 逐格绘制1画灰色墙0画白色路 for (int row 0; row maze.height; row) { for (int col 0; col maze.width; col) { RECT cell { col * maze.cell, row * maze.cell, (col 1) * maze.cell, (row 1) * maze.cell }; if (maze.map[row][col] 1) { FillRect(memDC, cell, (HBRUSH)GetStockObject(GRAY_BRUSH)); } else { FillRect(memDC, cell, (HBRUSH)GetStockObject(WHITE_BRUSH)); } } } // 3. 一次性贴到窗口避免闪烁 BitBlt(hdc, 0, 0, totalW, totalH, memDC, 0, 0, SRCCOPY); // 4. 释放 GDI 对象防止句柄泄漏 SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); }绘图代码有两点需要细细说。第一个是FillRect的效率它一次填充一个矩形不需要创建画刷对象配合GetStockObject返回的系统画刷不会产生自定义 GDI 资源泄漏。对 41x41 共 1681 格来说每帧 1681 次FillRect完全扛得住。第二个是SelectObject的返回值要存好画完必须换回来再DeleteObject如果直接删memBmp画笔还挂在 DC 上就会出问题。这是 GDI 编程里最常见的翻车点我刚开始写时吃过不少亏后面专门用了一个变量存旧位图。3.3 方向键控制与碰撞检测让“移动”这件事手感自然键盘控制的核心是WM_KEYDOWN消息。这里有个容易踩的坑很多人把移动逻辑写进OnKeyDown后按方向键没反应原因是窗口没有拿到键盘焦点。解决方式是窗口创建后主动调SetFocus(hwnd)并且在WM_ACTIVATE里重新设置焦点。还有一点方向键默认会触发窗口的WM_GETDLGCODE之类的处理所以要处理WM_KEYDOWN里的VK_UP、VK_DOWN、VK_LEFT、VK_RIGHT。移动判定核心就一句话目标格子的map值是否为 0是 0 才允许移动。// player.cpp键盘响应与移动碰撞检测 case WM_KEYDOWN: { int newX g_player.x; int newY g_player.y; switch (wParam) { case VK_UP: newY--; break; case VK_DOWN: newY; break; case VK_LEFT: newX--; break; case VK_RIGHT: newX; break; default: break; } // 碰撞判定只在路0上才能走 if (g_maze.map[newY][newX] 0) { g_player.x newX; g_player.y newY; InvalidateRect(hwnd, NULL, TRUE); // 触发重绘 } break; }这段逻辑没有把玩家位置直接改掉而是先放到newX/newY等确认目标格子是路再赋值。这个“先试探、再落子”的习惯可以帮助你避免很多边界问题比如走出地图范围、撞进墙里。此外刷新用InvalidateRect(hwnd, NULL, TRUE)配合在WM_PAINT里调DrawMaze让窗口在空闲时重绘而不是每帧强制刷新。有些新手会直接调UpdateWindow效果其实也差不多但频繁调用会增加 CPU 占用。4. 从“能走”到“好玩”随机种子、路径验证与出口规则4.1 随机种子每次启动都要一局全新地图很多半成品的迷宫程序“随机”是假的——rand()不设种子每次运行生成的地图一模一样。原因在于rand()是伪随机数发生器默认种子是 1。所以必须在程序启动时设置种子最常用的是时间#include ctime #include cstdlib // 程序启动WinMain 或窗口创建之前调用 srand((unsigned)time(nullptr));这样每次启动时间不同种子不同生成的迷宫就不同。但如果你足够细心会发现同一秒内启动两次地图仍然相同——这是时间种子的天然缺陷。如果希望两次启动之间的差异更明显可以使用 Windows 自带的更高精度随机源#include windows.h unsigned int seed; rand_s(seed); // 系统级随机数非时间推导 srand(seed);rand_s基于系统熵源不需要播种每次值都不一样比time靠谱得多。对迷宫游戏来说其实time也够用了但加上rand_s能体现你考虑问题的粒度课设答辩时也是一个加分项。4.2 如何保证起点到终点一定有通路深度优先生成的树天然连通起点到任意格子都有且仅有一条路径所以理论上不存在无解迷宫。但很多人在实际调试里还是遇到了“走到出口发现是墙”的尴尬情况这通常不是算法问题而是出口坐标选择错误。我之前说过路径格坐标一定和起点同奇偶即奇数坐标。所以终点必须放在(width-2, height-2)而不是(width-1, height-1)。如果你发现终点坐标是偶数那它大概率是墙代码看起来有路实际一碰就是墙。还有一种情况是生成迷宫后又手动把出口处的格子改成 0看似打通了路径但破坏了树的唯一解结构。连通性没问题但可能出现两条路到出口的情况对寻路算法的运行会有影响。更好的做法是在生成后做一次连通性检查用 BFS 从起点出发沿 0 的格子跑一圈看终点是否可达// check.cppBFS 连通性检查 bool IsReachable(const MazeMap maze, int startX, int startY, int endX, int endY) { std::vectorstd::vectorbool visited( maze.height, std::vectorbool(maze.width, false)); std::queuestd::pairint, int q; q.push({startX, startY}); visited[startY][startX] true; int dirs[4][2] {{1,0}, {-1,0}, {0,1}, {0,-1}}; while (!q.empty()) { auto [cx, cy] q.front(); q.pop(); if (cx endX cy endY) return true; for (auto d : dirs) { int nx cx d[0]; int ny cy d[1]; if (nx 0 ny 0 nx maze.width ny maze.height !visited[ny][nx] maze.map[ny][nx] 0) { visited[ny][nx] true; q.push({nx, ny}); } } } return false; }这个 BFS 在生成完成后调用一次如果返回false就是算法出 bug 了。对 41x41 的地图来说这个查询是毫秒级的几乎不消耗性能。把这段代码贴进课设里能直接向答辩老师证明你的地图不是碰运气画出来的而是有算法保证的。4.3 出口标记与通关判定迷宫光有一张图还不行玩家需要一个明确的目标。我的做法是在起点格子画一个绿色小方块终点画一个红色小方块再用GetAsyncKeyState或者WM_KEYDOWN判断玩家位置是否抵达终点抵达后弹窗提示通关并统计步数。// render.cpp绘制起点和终点标记 HBRUSH greenBrush CreateSolidBrush(RGB(0, 180, 0)); HBRUSH redBrush CreateSolidBrush(RGB(200, 30, 30)); RECT startRect { 1 * maze.cell 2, 1 * maze.cell 2, (1 1) * maze.cell - 2, (1 1) * maze.cell - 2 }; RECT endRect { (maze.width - 2) * maze.cell 2, (maze.height - 2) * maze.cell 2, (maze.width - 1) * maze.cell - 2, (maze.height - 1) * maze.cell - 2 }; FillRect(memDC, startRect, greenBrush); FillRect(memDC, endRect, redBrush); DeleteObject(greenBrush); DeleteObject(redBrush);这里如果硬编码 1 和width-2作为起点终点后续想改地图尺寸就得同步改。我的习惯是直接用g_maze.startX/startY和g_maze.endX/endY这样的成员变量赋值一次到处引用省得改尺寸时漏改一处导致起点画在墙上。这个“抽成变量”的小习惯能帮你省下不少后面调整参数的精力。5. 踩坑笔记从编译崩溃到移动翻车五个典型问题5.1 中文乱码VS 新版本的控制台和窗口完全两个世界现象你用printf在控制台调试输出中文全是乱码或者 MFC 工程里MessageBox显示中文正常但保存文件之后再打开变成了乱码。原因Visual Studio 2019 以后默认源文件保存为 UTF-8而 Windows 控制台默认的代码页是 GBK936。两边编码不一致中文输出自然就“翻车”了。解决如果你需要控制台调试输出在WinMain开头加一句SetConsoleOutputCP(CP_UTF8)并把源文件保存为 UTF-8 with BOM如果是 MFC 界面显示建议直接使用TCHAR和_T()宏不要在一套代码里混用char和wchar_t。我一般干脆不用控制台调试时直接用OutputDebugString配合 VS 的“输出”窗口查看绕开编码问题。5.2 递归爆栈地图一大就崩溃查不到逻辑错误现象地图 41x41 正常改成 81x81 直接崩溃编译器不报错运行到一半闪退。原因典型的递归深度问题。深度优先递归生成迷宫时递归深度等于迷宫路径长度81x81 的最长路径可能超过 2000 层默认栈大小1MB不够用。解决用第二章的迭代版代码把递归调用改为显式栈std::vector。栈在堆上分配容量大得多且不会出现“栈溢出”的运行时错误。顺便说一句如果你用std::stack也有同样效果但vector省去了底层容器的二次封装性能略好一点。5.3 按键没反应或移动“发飘”现象程序启动后按方向键完全没反应或者按一下走了两格有时候点击窗口后按键恢复正常有时候又偶尔丢失。原因窗口没有焦点所以WM_KEYDOWN不会被触发或者你在WM_KEYDOWN里处理了移动但wParam判断的是大写字母键盘而不是方向键也可能是你使用了OnTimer定时刷新同时又在按键里修改坐标导致同一帧被处理了两次。解决创建窗口后调用SetFocus(hwnd)在WM_ACTIVATE消息里判断LOWORD(wParam) ! WA_INACTIVE时再次SetFocus(hwnd)。移动逻辑只保留在WM_KEYDOWN里不引入定时器自动移动这样玩家每次按键只走一格手感干净利落。5.4 生成的迷宫难度失控要么太简单要么全是死胡同现象迷宫生成后发现整张图只有一条长路走一遍就通关另一种是到处都是分叉玩家像无头苍蝇一样乱转。原因深度优先的随机方向选择导致不同种子下的迷宫风格差异极大。方向顺序固定时算法会偏好朝某个方向延伸形成“直路走廊”方向打乱不够充分时又会形成大片短岔路。解决在判断候选方向之前先把方向数组随机打乱一次而不是每次都用一个固定的{右、下、左、上}顺序。常见做法是用洗牌算法遍历方向数组随机交换元素。这样每个方向的优先级每次都不同生成的迷宫会呈现出更自然的分支结构。另外可以把格子设置成固定大小cell8~14太小的格子会让通道看起来像一条细线视觉上也就是“难度过高”的来源。5.5 内存拷来拷去GDI 资源泄漏导致窗口越画越卡现象程序刚启动时很流畅玩了两三分钟后拖动窗口或重绘时明显变卡最后直接变成白屏。原因每次WM_PAINT都CreateCompatibleDC和CreateCompatibleBitmap不使用时不删除。GDI 对象有数量上限默认约 10000 个泄漏到一定数量后绘图操作会静默失败。解决每次创建成功后SelectObject恢复旧对象DeleteObject删除位图DeleteDC删除 DC。直接在绘制函数尾部按资源创建顺序反向释放。如果你不确定哪里泄漏了可以用任务管理器看进程的“GDI 对象”一列数量只增不减就说明释放遗漏了。6. 加一个 BFS 自动求解让迷宫不仅“能玩”还能“自证正确”迷宫生成出来是不是合理最有说服力的验证方式就是让程序自己走一遍。自动求解本质上是 BFS 寻路顺便还能输出最短路径长度和路径点位。BFS 和第四章的连通性检查结构几乎一样区别在于要用一个parent数组记录每个格子是从哪里走来的最后从终点回溯到起点得到完整路径。// solve.cppBFS 求迷宫最短路径 bool SolveMaze(const MazeMap maze, std::vectorstd::pairint, int path) { std::vectorstd::vectorint parent( maze.height, std::vectorint(maze.width, -1)); std::queuestd::pairint, int q; q.push({maze.startX, maze.startY}); parent[maze.startY][maze.startX] maze.startY * maze.width maze.startX; int dirs[4][2] {{1,0}, {-1,0}, {0,1}, {0,-1}}; while (!q.empty()) { auto [cx, cy] q.front(); q.pop(); if (cx maze.endX cy maze.endY) { // 回溯路径 int px cx, py cy; while (px ! maze.startX || py ! maze.startY) { path.push_back({px, py}); int code parent[py][px]; py code / maze.width; px code % maze.width; } path.push_back({maze.startX, maze.startY}); std::reverse(path.begin(), path.end()); return true; } for (auto d : dirs) { int nx cx d[0]; int ny cy d[1]; if (nx 0 ny 0 nx maze.width ny maze.height parent[ny][nx] -1 maze.map[ny][nx] 0) { parent[ny][nx] cy * maze.width cx; q.push({nx, ny}); } } } return false; }路径绘制也很简单遍历path里的坐标在对应格子里画一个半透明蓝色小块再InvalidateRect刷新。你可以把“自动求解”绑定到 F2 快捷键上这样演示时先手动走一遍再按 F2 展示最短路径效果非常直观。路径长短也能间接反映迷宫难度——41x41 的地图如果最短路径超过 800 格说明这张图偏绕可以调整方向打乱次数来改变生成风格。如果你还顺手把最短路径长度显示在窗口标题上那么“游戏数据可视化”这个点也能写进课设报告里。做这个项目的过程中我养成了一个习惯每次改完算法先把地图单独打印成字符画到控制台里看一眼再决定要不要接 UI。这个习惯帮我省了大量调 GDI 的时间。地图数据本身是干净的UI 层的问题就该在 UI 层排查两者混在一起查会耽误很多功夫。希望这篇笔记能把你的迷宫项目从“跑得起来”推到“解释得清楚、演示得好看”的状态。希望帮到你。本文还有配套的精品资源点击获取
