
简介一份面向C初学者的迷宫游戏完整工程演示随机生成迷宫地图与键盘交互玩法。程序用方向键控制红色方块在迷宫中移动直至找到出口获胜适合学习Win32窗口程序、游戏主循环、碰撞检测、随机寻路等基础知识点。压缩包共11个文件包含5个头文件分别承担迷宫生成、整理、绘制与寻路等模块、1个C源程序以及.dsp/.dsw工程配置与.aps/.rc/.hm等资源脚本整体仅14KB结构紧凑便于逐文件阅读和分析。已有488人浏览学习这份VC工程可直接编译运行通过阅读源码可理清迷宫生成算法如深度优先回溯与绘图刷新流程同时头文件与实现分离的设计也展示了模块化编程思路。对准备课程设计或想动手练习图形编程的C学习者来说是一份小巧而完整的参考范例也可以在此基础上扩展存档、难度选择等二次开发。1. 迷宫游戏为什么先解决「随机地图生成」不少人第一次写 VC 迷宫游戏卡住他的往往不是界面而是地图——没有地图后面全是空谈。这个项目标题的关键在“随机生成”四个字每次运行都要刷出一张不一样的、保证有通路、能走到出口的迷宫这需要先解决生成算法再把地图交给 MFC/GDI 画出来。下面按这个顺序展开先讲数据结构与生成算法再讲渲染和玩家交互最后是 VC 版本、字符集这类能让人翻车一整晚的环境坑。这个方案适合正在做课程设计、或者想在 Windows 桌面端练手 C/MFC 的开发者地图生成逻辑不依赖界面框架就算你打算用 Qt 或者控制台后面第 2 章的代码也能直接搬过去。2. 随机生成迷宫的两条主流算法DFS 回溯与随机 Prim迷宫生成算法多到能写一本书但真正落到 VC 这种单线程、内存受限的桌面程序里实用且容易验证的只有两类深度优先回溯迭代栈版和随机 Prim墙列表版。前者生成狭长曲折的通道后者生成分支较多的树状结构。无论哪种核心都是把“迷宫”抽象成一个二维网格然后反复拆墙直到整个图连通。2.1 地图数据结构用二维数组还是位图地图用二维 int 数组最直接。约定 0 表示路、1 表示墙行列数都用奇数这样起点、终点和墙的边界能严格对称。为什么必须奇数因为生成算法通常在两个格子的中间打通一面墙如果地图是偶数尺寸边界上会留下无法归类的格子渲染时还会出现半格像素的尴尬情况。一个常用尺寸是 31 行 31 列相当于 15×15 的房间数窗口 500 像素左右就能显示得比较舒服。如果你要做大迷宫比如 101×101内存占用只有 10 KB完全不用担心。// MazeMap.h —— 一个极简的地图类 #pragma once #include vector class CMazeMap { public: CMazeMap(int rows, int cols) : m_rows(rows), m_cols(cols) { // 全部初始化为墙1 m_grid.assign(rows, std::vectorint(cols, 1)); } int GetRows() const { return m_rows; } int GetCols() const { return m_cols; } int GetCell(int r, int c) const { return m_grid[r][c]; } void SetCell(int r, int c, int v) { m_grid[r][c] v; } // 判断下标是否越界 bool IsInside(int r, int c) const { return r 0 r m_rows c 0 c m_cols; } private: int m_rows; int m_cols; std::vectorstd::vectorint m_grid; };逻辑说明这里把地图封装成类所有对地图的访问都走 GetCell / SetCell后续生成算法和绘制代码就不用关心底层是 vector 还是原生数组。构造时把全部格子置为墙生成算法再“挖路”。参数说明m_rows 和 m_cols 必须是奇数建议至少 5×5太小没有迷宫感太大则生成和绘制都会变慢。vectorvector 对二维访问有轻微开销但在地图尺寸不超过 500×500 时无所谓。如果你追求极致性能可以改成 int* 一维数组再手动做下标映射。2.2 深度优先回溯生成迭代栈实现与参数深度优先回溯的直观描述是从起点出发随机选一个方向打通前方两格之间的墙走过去无路可走时退回上一个岔路口继续尝试。递归写法最简洁但迷宫一大就容易爆栈所以我习惯用显式栈代替递归。// GenerateDfs.cpp —— 迭代栈版深度优先生成 #include vector #include stack #include random void GenerateMazeDFS(CMazeMap maze, int startR, int startC) { const int rows maze.GetRows(); const int cols maze.GetCols(); // 方向上、下、左、右 const int dr[4] { -1, 1, 0, 0 }; const int dc[4] { 0, 0, -1, 1 }; // 起点打通为路 maze.SetCell(startR, startC, 0); // visited 用二维 bool避免把“路”和“是否访问过”混在一起 std::vectorstd::vectorbool visited(rows, std::vectorbool(cols, false)); visited[startR][startC] true; // 显式栈保存当前路径上的格子 std::stackstd::pairint, int st; st.push({ startR, startC }); // 随机数引擎 std::mt19937 rng(std::random_device{}()); while (!st.empty()) { auto [r, c] st.top(); // 收集当前格子周围“向前两格”未访问的方向 std::vectorint dirs; for (int i 0; i 4; i) { int nr r dr[i] * 2; int nc c dc[i] * 2; if (maze.IsInside(nr, nc) !visited[nr][nc]) dirs.push_back(i); } if (dirs.empty()) { // 没有可走方向回溯 st.pop(); continue; } // 随机洗牌后取第一个 std::shuffle(dirs.begin(), dirs.end(), rng); int dir dirs[0]; // 打通当前格与目标格之间的墙以及目标格本身 int midR r dr[dir]; int midC c dc[dir]; int nr r dr[dir] * 2; int nc c dc[dir] * 2; maze.SetCell(midR, midC, 0); maze.SetCell(nr, nc, 0); visited[nr][nc] true; st.push({ nr, nc }); } }逻辑说明每次挑一个方向把前方的墙和前方的格子都改成路然后入栈等当前格子的四个方向都走完弹出返回。这个算法生成的迷宫“死路多、通道窄”很像人工设计的古典迷宫玩家体验上会感觉一条路走到黑经常要原路返回。参数说明startR、startC 一般取 1,1也就是左上角第一个可用格子。dr[i]*2 这种写法保证每次跨两格墙永远在中间。mt19937 比旧的 rand() 均匀性好Windows 上 VC 的 rand() 低 16 位周期较短迷宫一旦超过 200×200 就会看到重复纹理。dirs 用 std::shuffle 洗牌而不是每次随机取一个是为了让四个方向的概率在长跑统计上更均匀。2.3 随机 Prim 生成墙列表的取舍随机 Prim 的思路完全不同维护一个“候选墙列表”每次随机取一面墙如果这面墙分隔的两个格子恰好一个是路、一个是墙就把墙和墙那个格子都变成路并把新格子的四周墙加进列表如果不满足条件直接丢掉。循环直到列表为空。// GeneratePrim.cpp —— 随机 Prim 生成 #include vector #include set #include random struct Wall { int r, c; // 墙的位置 int dir; // 0:上,1:下,2:左,3:右 —— 相对“已通路格子”的方向 }; void GenerateMazePrim(CMazeMap maze, int startR, int startC) { const int rows maze.GetRows(); const int cols maze.GetCols(); const int dr[4] { -1, 1, 0, 0 }; const int dc[4] { 0, 0, -1, 1 }; // 先把起点设为路 maze.SetCell(startR, startC, 0); // 用 set 当作候选墙列表避免重复插入同一面墙 std::setstd::pairint, int walls; // 只存墙坐标 std::mt19937 rng(std::random_device{}()); // 把起点四周的墙加入列表 for (int i 0; i 4; i) { int wr startR dr[i]; int wc startC dc[i]; if (maze.IsInside(wr, wc)) walls.insert({ wr, wc }); } while (!walls.empty()) { // 随机挑一面墙set 不支持随机访问先复制到 vector std::vectorstd::pairint, int candidates(walls.begin(), walls.end()); int idx std::uniform_int_distributionint(0, (int)candidates.size() - 1)(rng); auto [wr, wc] candidates[idx]; walls.erase({ wr, wc }); // 找这面墙两侧的格子 int cellA -1, cellB -1; for (int i 0; i 4; i) { int nr wr dr[i]; int nc wc dc[i]; if (!maze.IsInside(nr, nc)) continue; // 墙的上下或左右两个格子 if (i 0 || i 1) { if (cellA -1) { cellA nr * cols nc; } else { cellB nr * cols nc; } } else { if (cellA -1) { cellA nr * cols nc; } else { cellB nr * cols nc; } } } // 恰好一个路一个墙时打通 int rA cellA / cols, cA cellA % cols; int rB cellB / cols, cB cellB % cols; bool aIsRoad (maze.GetCell(rA, cA) 0); bool bIsRoad (maze.GetCell(rB, cB) 0); if (aIsRoad ! bIsRoad) { int newR aIsRoad ? rB : rA; int newC aIsRoad ? cB : cA; maze.SetCell(wr, wc, 0); // 墙变路 maze.SetCell(newR, newC, 0); // 另一侧也变路 // 把新格子的四周墙加入候选 for (int i 0; i 4; i) { int wr2 newR dr[i]; int wc2 newC dc[i]; if (maze.IsInside(wr2, wc2) maze.GetCell(wr2, wc2) 1) walls.insert({ wr2, wc2 }); } } } }逻辑说明这段代码用 std::set 存储候选墙坐标天然去重代价是每次随机抽取要把 set 拷贝到 vector。地图 100×100 时墙总数不过一万拷贝开销完全可以接受。核心判断“aIsRoad ! bIsRoad”保证不会把两块已经连通的路再打通避免出现多余环路。参数说明walls 里只存墙坐标不存方向因为从墙坐标可以反推两侧格子。uniform_int_distribution 的下标从 0 开始注意 candidates.size() 是 size_t需要强转 int 再减一否则空列表时会有负数下标风险。这个算法生成的迷宫分支多、环路少视觉上更“蓬松”玩家不容易被单条死路困死。2.4 两种算法的对比与参数表对比项深度优先回溯随机 Prim生成效果长通道、死路多、主路明显分支多、房间多、路径短时间复杂度O(N)N 为格子数O(N log N)set 操作有开销代码复杂度低容易调试中注意墙列表维护典型场景恐怖、探险类单线迷宫解谜、寻宝类开放迷宫玩家体感容易迷路、回头路长目标感强、每条路都不长选择建议课程设计如果只需要“像迷宫”深度优先回溯就够想展示算法多样性就把两个都做了用菜单或按钮切换生成方式。还有个折衷方案——先跑一遍 DFS 保证骨架再用随机 Prim 打通一部分额外的墙生成“半开放”迷宫这个后面第 6 章讲难度分级时会再提。3. 把地图画到窗口MFC/GDI 渲染与双缓冲算法生成的是二维数组用户看到的是窗口里的色块。这一步要把格子坐标换算成像素坐标再画到屏幕上。很多新手在这里踩了第一个坑直接在 OnPaint 里画矩形窗口一刷新就狂闪后来才知道要做双缓冲。3.1 格子坐标与像素坐标的换算假设每个格子边长 cellSize 像素那么第 r 行第 c 列的格子其左上角像素坐标是 (c * cellSize, r * cellSize)右下角是 ((c1) * cellSize, (r1) * cellSize)。这个换算看似简单却有两个常见错误一是行列写反导致地图旋转 90 度二是忘了把窗口客户区大小和地图总像素对齐导致地图画不全或者右侧留白。解决第二个问题最省事的办法窗口创建后调用 ResizeWindow把客户区强制设置成 cols * cellSize 和 rows * cellSize这样玩家窗口永远不会出现滚动条或者黑边。// 窗口尺寸调整让客户区刚好容纳整张地图 void CMazeView::ResizeWindowToMap(CMazeMap maze, int cellSize) { int mapW maze.GetCols() * cellSize; int mapH maze.GetRows() * cellSize; // 拿到当前窗口的边框大小 CRect rcClient, rcWindow; GetClientRect(rcClient); GetWindowRect(rcWindow); int borderX rcWindow.Width() - rcClient.Width(); int borderY rcWindow.Height() - rcClient.Height(); // 调整外层窗口尺寸 SetWindowPos(NULL, 0, 0, mapW borderX, mapH borderY, SWP_NOMOVE | SWP_NOZORDER); }逻辑说明GetClientRect 得到客户区大小GetWindowRect 得到整个窗口大小两者差值就是标题栏和边框占用的像素。把地图像素尺寸加上边框差值再调用 SetWindowPos窗口就会精确贴合地图。参数说明cellSize 通常取 816 像素31×31 地图配 14 像素时窗口约 434×434视觉密度适中。cellSize 小于 8 时格子边界看不清大于 20 时地图太大玩家一眼看不全。如果想做“滚动画布”的大迷宫就不要用这个函数改为在滚动视图 CScrollView 里重写 OnDraw。3.2 OnPaint 里画墙与路最小可运行代码MFC 的 View 类中OnPaint 或 OnDraw 是唯一的绘图入口。用 CPaintDC 在 OnPaint 里绘制或者用 CClientDC 在定时器、键盘事件里手动触发绘制。这里给出 OnPaint 版本包含双缓冲也是实际项目中直接能用的骨架。// 双缓冲绘制迷宫先画到内存 DC再一次性贴图 void CMazeView::OnPaint() { CPaintDC dc(this); // 设备上下文 CRect rcClient; GetClientRect(rcClient); int cellSize 14; int rows m_maze.GetRows(); int cols m_maze.GetCols(); // 1. 创建内存 DC 和兼容位图 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 2. 背景整体填充 memDC.FillSolidRect(rcClient, RGB(245, 245, 245)); // 3. 画墙墙用深灰色路保留背景色 CBrush wallBrush(RGB(45, 45, 48)); CBrush* pOldBrush memDC.SelectObject(wallBrush); for (int r 0; r rows; r) { for (int c 0; c cols; c) { if (m_maze.GetCell(r, c) 1) // 1 墙 { CRect cellRect(c * cellSize, r * cellSize, (c 1) * cellSize, (r 1) * cellSize); memDC.FillRect(cellRect, wallBrush); } } } // 4. 把内存 DC 一次性拷贝到窗口 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); // 5. 清理资源 memDC.SelectObject(pOldBrush); memDC.SelectObject(pOldBmp); bmp.DeleteObject(); }逻辑说明步骤 1 建立内存位图步骤 23 在内存 DC 上完成全部绘制步骤 4 用 BitBlt 一次拷贝。这样窗口刷新时只看到完整的一帧而不是逐格画上去的“动画”从根源上消除闪烁。参数说明wallBrush 在这里是局部变量FillRect 期间有效。RGB(45, 45, 48) 比纯黑柔和长时间盯着不容易累。如果想画起点和终点可以在第 3 步之后单独处理起点用绿色 FillRect终点用红色 FillRect。注意 FillRect 的 CRect 是左闭右开区间最后一列的右边界不要超出客户区否则 GDI 会截断。提示如果窗口仍然闪烁检查是否重写了 OnEraseBkgnd。默认 OnEraseBkgnd 会用背景色擦除客户区再触发 OnPaint等于画了两遍。在 View 类里重写 OnEraseBkgnd 并直接 return TRUE闪烁会明显减弱。3.3 玩家当前位置的绘制叠加地图画完还要在玩家所在格子画一个高亮块。最简单的方式是不进迷宫数据只在绘制循环里判断“当前行列是否等于玩家坐标”是则换一种颜色。// 玩家位置绘制叠加在路块之上 if (r m_player.row c m_player.col) { CRect playerRect(c * cellSize 2, r * cellSize 2, (c 1) * cellSize - 2, (r 1) * cellSize - 2); CBrush playerBrush(RGB(220, 120, 30)); memDC.FillRect(playerRect, playerBrush); }逻辑说明玩家矩形做 2 像素内缩视觉上像一个小圆角方块和路块之间有间隙玩家能清楚看到自己站在哪条路上而不是占满整个格子。参数说明内缩值 2 像素对应 cellSize 14 时比较协调如果 cellSize 较小内缩 1 像素即可。这个绘制位置放在墙循环之后、BitBlt 之前保证玩家在最上层。4. 玩家移动、碰撞检测与胜负判定地图有了接下来让玩家动起来。MFC 里方向键是虚拟键 VK_UP / VK_DOWN / VK_LEFT / VK_RIGHT需要在 View 类中重写 OnKeyDown 或 PreTranslateMessage 来接收。这里有个新手最容易忽略的点键盘消息发给当前有焦点的窗口View 如果没有焦点OnKeyDown 永远不被调用。4.1 键盘消息接收与焦点问题View 默认在创建时会获得焦点但如果你在窗口里加了按钮、编辑框之类的控件焦点就可能被抢走。保险的做法是在 OnActivateView 里调用 SetFocus 强制把焦点还回来。// 确保视图始终持有焦点 void CMazeView::OnActivateView(BOOL bActivate, CView* pActivateView, CView* pDeactiveView) { CView::OnActivateView(bActivate, pActivateView, pDeactiveView); if (bActivate) SetFocus(); }逻辑说明窗口从后台切回前台时OnActivateView 会被调用bActivate 为 TRUE 时重新设置焦点。这样即使之前焦点在别的控件上玩家切回迷宫窗口后按方向键依然有效。参数说明这个函数在每次视图激活时都会触发频繁 SetFocus 的开销可忽略。如果同时存在多个视图比如切分窗口要判断 pActivateView 是不是自己再决定是否 SetFocus否则会互相抢焦点。4.2 方向键移动与墙碰撞检测移动逻辑不复杂先根据按键算出目标行列再判断目标是不是路0。是路就更新玩家坐标并触发重绘是墙就什么都不做。注意每次移动只走一格不要长按加速否则玩家会瞬间穿过整条通道看起来像瞬移。// 键盘方向键响应移动玩家并做碰撞检测 void CMazeView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { int pr m_player.row; int pc m_player.col; int nr pr, nc pc; switch (nChar) { case VK_UP: nr pr - 1; break; case VK_DOWN: nr pr 1; break; case VK_LEFT: nc pc - 1; break; case VK_RIGHT: nc pc 1; break; default: CView::OnKeyDown(nChar, nRepCnt, nFlags); return; } // 边界检测不能超出地图范围 if (nr 0 || nr m_maze.GetRows() || nc 0 || nc m_maze.GetCols()) return; // 碰撞检测目标必须是路 if (m_maze.GetCell(nr, nc) ! 0) return; // 更新位置并重绘 m_player.row nr; m_player.col nc; // 到达出口判定 if (nr m_exit.row nc m_exit.col) { MessageBox(_T(恭喜你走出了迷宫), _T(胜利), MB_OK | MB_ICONINFORMATION); ResetGame(); // 重新生成一张新地图 return; } Invalidate(FALSE); // FALSE 表示不擦除背景减少闪烁 CView::OnKeyDown(nChar, nRepCnt, nFlags); }逻辑说明先更新临时变量 nr、nc再统一做边界和墙检测避免在 switch 里重复写检测逻辑。到达出口后弹窗提示然后调用 ResetGame 重新生成地图这一局就算结束。Invalidate(FALSE) 比 Invalidate(TRUE) 少一次背景擦除配合双缓冲后视觉上几乎没有延迟。参数说明nRepCnt 表示按键重复次数nFlags 包含扫描码等信息这里都用不上。如果以后想支持“按一下走一格按住连续走”需要自己记录上次按键时间单纯靠 nRepCnt 不可靠因为不同键盘的重复延迟差异很大。想做成“走格”而不是“滑行”的话不要用定时器连续移动否则碰撞检测会因多次触发而出现卡顿感。4.3 定时器实现自动演示或计时课程设计里常要求加一个“自动演示”“计时”功能。自动演示可以用一个定时器驱动每隔一段时间沿随机方向走一步直到到达出口。计时则更简单在游戏开始时记录 startTime胜利时用 GetTickCount64 算差值。// 启动自动演示每 300 毫秒走一步 void CMazeView::StartAutoDemo() { m_autoDemo true; SetTimer(1, 300, NULL); // 定时器 ID 为 1间隔 300ms } void CMazeView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1 m_autoDemo) { // 随机选一个方向能走就走 int dirs[4][2] { { -1, 0 }, { 1, 0 }, { 0, -1 }, { 0, 1 } }; int idx rand() % 4; int nr m_player.row dirs[idx][0]; int nc m_player.col dirs[idx][1]; if (m_maze.IsInside(nr, nc) m_maze.GetCell(nr, nc) 0) { m_player.row nr; m_player.col nc; Invalidate(FALSE); } // 到达出口停止 if (m_player.row m_exit.row m_player.col m_exit.col) { KillTimer(1); m_autoDemo false; } } CView::OnTimer(nIDEvent); }逻辑说明SetTimer 启动后系统每隔 300ms 触发一次 OnTimer。随机方向可能撞墙撞墙就跳过本次移动等下一次定时器触发直到走出通道。这个演示逻辑虽然笨但代码简单能证明地图是“可解”的。参数说明300ms 是演示速度觉得慢可以改成 150ms。注意 rand() 在这里用没问题因为只是演示不需要高质量随机。如果自动演示和玩家手动操作同时发生要在键盘消息里重置或停用定时器否则会出现“玩家刚按左演示又走了右”的打架现象。提示如果你发现按方向键没反应先检查两个地方一是 View 是否持有焦点二是是不是在调试状态下手动给 View 设了断点导致消息没派发。焦点问题按 4.1 的方法处理断点问题直接 F5 继续即可不是 bug。5. 常见问题与避坑运行时、字符集与越界排查这部分聊聊实际编译运行中容易让人熬夜的坑。VC 项目版本跨度大老代码拿到新系统上编不过、跑不动是常态下面几条是我真实遇到过的现象和对应解法。5.1 VC 2008 老项目在 Win11/Win12 上提示运行库缺失现象双击编译好的 exe弹窗提示“VC 2008 runtime libraries are not installed”或者“应用程序无法启动因为应用程序的并行配置不正确”。如果是从网上下载的老项目源码打开时还可能出现fatal error C1083: 无法打开包括文件: afxwin.h。原因老项目用 Visual Studio 2008VC 9.0编译依赖 VC 2008 SP1 可再发行组件Win11/Win12 默认不预装这个组件同时机器上如果装过某些 GIS 或 FPGA 开发软件可能把运行库版本改乱导致新版和旧版互相覆盖。解决分两步处理。第一步安装 Visual C 2008 SP1 Redistributable Package这是最简单直接的路径装上即可解决运行库提示。第二步如果你手里有源码建议直接升级工程用新版 Visual Studio2015 到 2022 都行打开 .sln项目属性里的“平台工具集”选Visual Studio 2022 (v143)字符集选“使用 Unicode 字符集”重新编译。升级过程中最可能出现的报错是strcpy、sprintf被标记为不安全函数在预处理器定义里加_CRT_SECURE_NO_WARNINGS即可。注意不要为了消警告把_CRT_SECURE_NO_WARNINGS加到全局属性表里那会让整个系统编译环境都发生变化后续排查问题会变得很困惑。只在你自己的项目里加。5.2 字符集不匹配LPSTR 与 LPCWSTR 互相转换报错现象老代码里写MessageBox(hello)在 VS2015 以上版本编译报错C2664: int CWnd::MessageBox(LPCTSTR,...): 无法将参数 1 从 const char [6] 转换为 LPCTSTR。原因Visual Studio 2013 之后默认字符集是 UnicodeLPCTSTR被解析成LPCWSTR而字符串字面量默认是窄字符const char*两者不兼容。老 VC 6.0 项目默认是多字节字符集所以没这个问题。解决最省事的方法是在项目属性 → 常规 → 字符集里从“使用 Unicode 字符集”改回“使用多字节字符集”。但长期来看这不可取Unicode 才是正确方向。改法是把字符串字面量统一包一层_T()宏例如_T(你好)并把char换成TCHAR。如果一个项目字符串量特别大可以先用全局替换把MessageBox(替换成MessageBox(_T(注意别把注释里的内容也替换掉。5.3 地图生成结果每次跑都不一样但玩家位置或出口不在路上现象生成算法明明把大片格子设成了路但玩家出生点或者出口偶尔出现在墙里导致一开局就卡死或者出口永远走不到。原因我在 2.1 节强调过地图行列必须是奇数起点和终点坐标必须是奇数且位于路的格子。很多人生成完地图后直接写死起点为 (0,0)但 (0,0) 在最外层边界上是墙。出口同理放在右下角 (rows-1, cols-1)那也是墙。解决起点取 (1,1)出口取 (rows-2, cols-2)两者天然在路格子上。如果希望出口位置随机就在生成算法跑完后收集所有值为 0 的格子随机挑一个距离起点最远的当出口这样既保证可达又有一定挑战性。收集路格子的代码很简单// 收集所有路格子 std::vectorstd::pairint, int roads; for (int r 1; r rows - 1; r) for (int c 1; c cols - 1; c) if (m_maze.GetCell(r, c) 0) roads.push_back({ r, c }); // 从 roads 里随机挑一个作为出口5.4 随机数种子重复每次启动迷宫一模一样现象每次启动程序生成的迷宫完全相同或者每次点击“重新生成”都刷出同样的图。原因生成算法用了rand()而rand()的默认种子是固定值 1。如果只在全局初始化时srand(time(NULL))一次然后一直复用理论上每次运行应该不同但如果在生成函数内部每次调用前都srand(time(NULL))由于 time 函数精度是秒连续两次点击“重新生成”发生在同一秒内两次种子相同生成结果当然相同。解决把srand(time(NULL))放在程序启动处WinMain 或 OnInitDialog生成函数内部不要再次调用。如果你按第 2 章的代码用了std::mt19937和std::random_device则完全不受这个问题影响。想更保险的话可以记录上一次的种子值每次重新生成时手动改变种子比如rng.seed(rng() 1)。5.5 数组越界导致生成时崩溃或绘制花屏现象地图在 Debug 下崩在m_grid[r][c]Release 下偶尔不崩但画面有奇怪的灰色条纹。原因生成算法里访问了非法坐标比如dr[i]*2让目标行变成-1或rows。我在第 2 章代码里用了maze.IsInside(nr, nc)做检查但如果你自己改写过或从网上抄的代码里前后顺序不对——先赋值再检查越界——就会踩进未定义行为。解决牢记两条铁律。第一条所有格子访问前先IsInside包括中间墙坐标midR/midC也要检查因为边界上的格子可能没有两个方向的邻居。第二条vector的operator[]不做边界检查越界时行为未定义Debug 下可能直接 assertRelease 下可能读取到相邻内存画出随机色块。排查方法是在关键函数开头加断言// 调试期打开Release 自动消失 ASSERT(r 0 r m_maze.GetRows()); ASSERT(c 0 c m_maze.GetCols());如果崩的位置不确定用 F5 调试把“第一次机会异常”设成中断系统会在越界瞬间停住调用堆栈直接指向出错行。6. 进阶存档、自动寻路与难度分级基础功能做完剩下的就是拉开差距的部分。这里选三个性价比最高的进阶方向地图存取、自动寻路验证、难度分级。每个都能在答辩或作品展示时多讲两分钟而且代码量不大。6.1 存档与读档把二维数组写进文件地图本质是二维数组存档就是把这个数组连同玩家坐标一起写进文件。用 C 标准库fstream最为直接MFC 的CFile也可以但代码更啰嗦。// 保存地图格式为 行数 列数 玩家行 玩家列 出口行 出口列 地图数据 bool SaveMaze(const CMazeMap maze, const PlayerInfo player, const PlayerInfo exit, const CString filePath) { FILE* fp NULL; errno_t err fopen_s(fp, CT2A(filePath), w); if (err ! 0 || fp NULL) return false; fprintf(fp, %d %d\n, maze.GetRows(), maze.GetCols()); fprintf(fp, %d %d %d %d\n, player.row, player.col, exit.row, exit.col); for (int r 0; r maze.GetRows(); r) { for (int c 0; c maze.GetCols(); c) fprintf(fp, %d , maze.GetCell(r, c)); fprintf(fp, \n); } fclose(fp); return true; }逻辑说明第一行存地图尺寸第二行存玩家和出口坐标后面逐行存地图数据。读档就是反向扫描先读尺寸再读坐标最后按行填充二维数组。格式足够简单方便用文本编辑器直接检查。参数说明CT2A宏把 CString 转成窄字符指针只在 Unicode 字符集下需要多字节字符集或直接传const char*时可以省略。存出来的文件大小很好估算31×31 地图每个数字占 12 字节总文件不到 3 KB。6.2 自动寻路验证地图可解性的最佳手段生成算法理论上保证连通但代码里只要有一个方向写错就可能出现“看起来是迷宫、实际有死区”的情况。做一次 BFS 自动寻路既能在生成后立刻验证地图合法性又能作为“自动演示”的升级版——这次演示走的是最优路径比 4.3 的随机乱走专业得多。// BFS 找最短路径返回从起点到终点的路径坐标 bool FindPathBFS(const CMazeMap maze, const PlayerInfo start, const PlayerInfo exit, std::vectorstd::pairint, int path) { const int rows maze.GetRows(); const int cols maze.GetCols(); // visited 与 parent 数组parent 记录每个格子来自哪个方向 std::vectorstd::vectorint parent(rows, std::vectorint(cols, -1)); std::queuestd::pairint, int q; const int dr[4] { -1, 1, 0, 0 }; const int dc[4] { 0, 0, -1, 1 }; parent[start.row][start.col] -2; // 标记起点 q.push({ start.row, start.col }); while (!q.empty()) { auto [r, c] q.front(); q.pop(); if (r exit.row c exit.col) { // 从终点回溯到起点 int cr r, cc c; while (!(cr start.row cc start.col)) { path.push_back({ cr, cc }); int dir parent[cr][cc]; cr - dr[dir]; cc - dc[dir]; } path.push_back({ start.row, start.col }); std::reverse(path.begin(), path.end()); return true; } for (int i 0; i 4; i) { int nr r dr[i]; int nc c dc[i]; if (maze.IsInside(nr, nc) maze.GetCell(nr, nc) 0 parent[nr][nc] -1) { parent[nr][nc] i; q.push({ nr, nc }); } } } return false; // 无路可达 }逻辑说明parent[nr][nc] i记录的是“从哪个方向走到当前格”回溯时反向相减即可还原路径。复杂度 O(N)迷宫再大也能瞬间算完所以可以放心在生成后立刻调用作为合法性校验。参数说明这个 BFS 返回的是最短路径但并不是迷宫“唯一解”——第 2 章的生成算法本身保证任意两点之间只有唯一路径树结构所以最短路径就是唯一路径。如果你想实现“多条路径”的迷宫需要回到 2.4 提到的折衷方案在生成后额外随机打通部分墙然后用 BFS 重新验证。6.3 难度分级用参数而不是三套代码实现难度分级最省心的做法是只调整两个参数地图尺寸和“额外打通路”的概率。地图越大玩家视野越窄难度越高额外打通路越多死路越少反而越简单。// 生成带难度参数的迷宫打通率 0 表示完全树状越大约简单 void GenerateMazeWithDifficulty(CMazeMap maze, double openRatio, int seed) { std::mt19937 rng(seed); // 1. 先跑一次 DFS 生成基础迷宫 GenerateMazeDFS(maze, 1, 1); // 2. 收集所有“相邻两个路格子之间的墙” std::vectorstd::pairint, int extraWalls; for (int r 1; r maze.GetRows() - 1; r 2) { for (int c 1; c maze.GetCols() - 1; c 2) { // 检查右墙和下墙 if (c 2 maze.GetCols() - 1 maze.GetCell(r, c) 0 maze.GetCell(r, c 2) 0) extraWalls.push_back({ r, c 1 }); if (r 2 maze.GetRows() - 1 maze.GetCell(r, c) 0 maze.GetCell(r 2, c) 0) extraWalls.push_back({ r 1, c }); } } // 3. 按概率打通一部分墙 std::shuffle(extraWalls.begin(), extraWalls.end(), rng); int toOpen (int)(extraWalls.size() * openRatio); for (int i 0; i toOpen; i) maze.SetCell(extraWalls[i].first, extraWalls[i].second, 0); }逻辑说明先生成树状迷宫再按一定概率把死路中间的墙打开形成环路。openRatio 0是最难状态每一条路都是死路openRatio 0.3左右玩家能感受到岔路变多但依然有迹可循openRatio 0.5就基本接近空旷场地了。参数说明seed 用于复现——如果你想展示“同一张地图的不同难度”就把 seed 固定只调整 openRatio如果每次都要新地图就传time(NULL)。这个函数返回后建议立刻跑一遍 6.2 的 BFS确保起点和终点依然连通。额外打墙不会破坏连通性可以只省略掉这个检查。个人习惯写完生成算法先用控制台程序跑 100 次检查无崩溃、无死区再往 MFC 界面里搬。因为界面代码难调试等渲染和键盘处理都做完时才排查算法成本翻三倍不止。这个习惯帮我省下了大量“看着图猜 bug”的时间也建议你试试。希望帮到你。本文还有配套的精品资源点击获取