资讯动态

C++控制台迷宫小游戏实战:DFS生成与BFS寻路全解析

发布时间:2026/10/25 11:39:44 来源:尧图企业网站定制
简介一份用C实现的迷宫小游戏源码包适合算法初学者和编程实践者完整演示了经典的“小老鼠走迷宫”过程。代码以二维数组表示迷宫用0和1标记通道与墙壁采用广度优先搜索BFS寻找出口并通过队列管理待扩展节点能够避免重复访问。游戏循环中处理了上下左右移动、撞墙提示、到达终点等交互逻辑用户可直接运行生成的可执行文件也可阅读源码学习项目组织方式。压缩包共16个文件涵盖Maze.h、Maze.cpp、main.cpp等源代码以及exe、obj、pdb、dsp、dsw等构建与调试配套文件整体体积仅1.17MB。目前已有1172人浏览学习借助该代码包可快速理解BFS算法的实际应用并掌握二维数组建图、队列搜索、用户输入处理等技能还能进一步扩展SDL2/SFML图形界面或随机迷宫生成是课程设计与算法实验的良好参考。1. Maze迷宫游戏与C一个控制台程序为什么值得认真做如果你搜过“Maze迷宫游戏 源码”或“迷宫小游戏C”大概率见过两种东西一张控制台截图配几百行代码复制进Visual Studio却跑不起来或者一篇只讲生成算法的片段没有输入、没有渲染、没有游戏循环。迷宫小游戏这个标题看着简单实际拆开有四条线随机生成算法、地图建模、控制台交互、自动寻路。哪条线没闭环代码就只能停在“能生成一张迷宫图”的阶段。这篇文章按一条完整可复现的路来走——用C写一个能在控制台里用方向键走迷宫的版本顺便把生成、寻路、参数验证一次讲透。适合想练C项目、做课程设计或者纯粹想搞明白迷宫算法背后逻辑的人。2. 迷宫生成算法选型DFS回溯与递归分割先写哪个2.1 完美迷宫与非完美迷宫先定验收标准写生成算法前先定一个标准你要的迷宫是“完美迷宫”还是“非完美迷宫”。完美迷宫指任意两个格子之间只有唯一一条路径没有环路也没有不可达区域。DFS回溯、递归分割、Prim、Kruskal这几种主流算法生成的默认都是完美迷宫如果你在生成后手动多凿开几堵墙迷宫就会出现环路变成非完美迷宫。从游戏角度看完美迷宫已经足够好玩路径长、岔路多、有挑战性而且它自带一个极好的验收特性用BFS从起点搜到终点如果能搜到说明迷宫没有把路堵死。这个特性后面会被拿来做自动回归验证。所以我建议第一版就锁定“生成完美迷宫”这个目标不搞复杂的地形装饰。另一个需要提前定的参数是迷宫尺寸的表示方式。常见做法是用“房间格”加“墙位”两层模型玩家实际站在房间格里两个相邻房间之间隔着一格宽的墙。也就是说如果房间格是9行9列地图字符数组的尺寸是(291)×(291)19×19。这个映射关系第一次写很容易算错建议你先在纸上画一个3×3的房间格再把墙位补上确认坐标公式之后再写代码。2.2 DFS回溯生成迭代栈版本与随机数参数DFS回溯的思路一句话就能说清从起点房间出发随机挑一个没去过的相邻房间打通中间的墙走过去把当前房间压栈如果当前房间四周都去过了就弹栈回退。栈空时所有房间都被访问过迷宫生成完毕。这里有一个值得注意的工程决策生成用迭代栈而不是递归。递归实现看起来更短但递归深度等于探索路径的最长长度在几十乘几十的房间格下很容易爆栈这在后面避坑章会展开。下面是我常用的生成函数直接返回地图字符串数组#include vector #include stack #include random #include string // 生成 rows*cols 房间格的完美迷宫 // 返回地图尺寸: (2*rows1) x (2*cols1), # 为墙, 为路 std::vectorstd::string generateMaze(int rows, int cols, unsigned seed) { int h 2 * rows 1; int w 2 * cols 1; std::vectorstd::string maze(h, std::string(w, #)); std::vectorstd::vectorbool visited(rows, std::vectorbool(cols, false)); int dr[4] {-1, 0, 1, 0}; // 上 右 下 左 int dc[4] {0, 1, 0, -1}; std::mt19937 gen(seed); std::uniform_int_distributionint pick(0, 3); std::stackstd::pairint, int st; visited[0][0] true; st.push({0, 0}); while (!st.empty()) { int r st.top().first; int c st.top().second; std::vectorint candidates; for (int d 0; d 4; d) { int nr r dr[d]; int nc c dc[d]; if (nr 0 nr rows nc 0 nc cols !visited[nr][nc]) { candidates.push_back(d); } } if (candidates.empty()) { st.pop(); // 四周都访问过回溯 continue; } int d candidates[pick(gen) % candidates.size()]; int nr r dr[d]; int nc c dc[d]; // 房间格在地图坐标是 (2r1, 2c1) // 两个相邻房间的墙位正好在它们坐标的中点 maze[2 * r 1 dr[d]][2 * c 1 dc[d]] ; maze[2 * nr 1][2 * nc 1] ; visited[nr][nc] true; st.push({nr, nc}); } return maze; }逻辑说明每个房间格四周各有四个墙位墙位坐标就是两个相邻房间地图坐标的中间点比如房间(0,0)和(0,1)之间的墙位是(1,1)。代码里maze[2 * r 1 dr[d]][2 * c 1 dc[d]] 实际上是从当前房间跨半步到墙位然后再maze[2 * nr 1][2 * nc 1] 把目标房间打通两步合起来完成了“破墙”。生成结束后起点房间(0,0)和终点房间(rows-1, cols-1)一定是空格但还没有标记出入口那是渲染时的事。参数说明rows和cols是房间格数量不是地图字符尺寸9×9房间对应19×19字符地图。seed是随机种子这里用C11的std::mt19937不要用srandrand具体原因在避坑章会讲。pick(gen) % candidates.size()是因为当某个房间只剩两个未访问邻居时方向取值范围必须收缩不能直接用4取余否则会选中已访问邻居导致逻辑错误。2.3 递归分割与Prim的对比什么时候换算法DFS回溯不是唯一选择至少有三种常见算法值得知道选型的依据是你要的迷宫质感。算法空间结构路径走向实现复杂度适用场景DFS回溯单通道深、分支少长而曲折低游戏关卡、新手练习递归分割规则分块、中轴对称偏长且规律低快速生成、程序化纹理Prim / Kruskal分支多、通道短随机感强中探索型游戏、视觉自然递归分割的思路是把整个区域用一堵带缺口的墙分成两半然后对两个子区域递归执行同样的操作。它生成的迷宫非常快但墙的位置天然对齐在二分坐标上玩起来能明显感觉到“块状”规律缺少DFS回溯那种一走进死胡同再绕出来的拉扯感。Prim则从起点开始维护一个候选墙集合每次随机挑一面墙打通并把新墙加入集合生成结果分支多、路径段但代码需要额外维护集合第一版不必上。我自己的建议是第一版只用DFS回溯跑通完整游戏循环后再考虑换算法。原因很简单DFS回溯的代码与“房间格墙位”模型绑定得最紧换算法时你只需要替换generateMaze这一个函数游戏逻辑完全不用动。这个替换成本很低等你看腻了单通道迷宫再换不迟。3. 从地图到可玩的C小游戏渲染、方向键与游戏主循环3.1 地图数据结构用vector 而不是裸数组地图数据我在生成函数里已经用了std::vectorstd::string这不是随手选的。如果用char maze[H][W]H和W必须是编译期常量那命令行传参控制迷宫尺寸就做不到了。std::vectorstd::string可以在运行时决定尺寸每行是一个独立的std::string下标访问maze[r][c]和裸数组手感一致但拷贝、传参、改行长度都安全得多。初始化也省事std::vectorstd::string maze(h, std::string(w, #))一行就建好全墙地图。这句代码值得记一下它其实是“用同一个字符串初始化vector里的每一行”的标准写法。如果你项目要求老标准不支持std::vectorstd::string退一步用vectorvectorchar也能工作但每行的字符串操作比如输出整行时直接puts(maze[r].c_str())就没那么顺了。迷宫尺寸的换算要在一开始就封装成函数别在main里裸算。地图高度是2 * rows 1宽度是2 * cols 1玩家起点在左上角房间地图坐标永远是(1, 1)终点在右下角房间地图坐标是(2 * rows - 1, 2 * cols - 1)。这三个坐标在游戏循环、寻路、渲染里到处要用写死数字是翻车的开始。3.2 用windows.h做控制台渲染光标定位与防闪烁控制台渲染最常见的错误是每次用system(cls)清屏。这个函数在Windows控制台上有肉眼可见的闪烁因为清屏外加光标回到左上角不是原子操作屏幕会闪一下。更好的做法是把整帧迷宫拼成一个字符串用SetConsoleCursorPosition把光标定位到(0,0)再一次性printf输出。#include windows.h #include cstdio #include string #include vector void renderMaze(const std::vectorstd::string maze, int playerR, int playerC) { static std::string buffer; buffer.clear(); for (int r 0; r (int)maze.size(); r) { for (int c 0; c (int)maze[r].size(); c) { char ch maze[r][c]; if (r playerR c playerC) { ch ; // 玩家位置在渲染层覆盖 } buffer ch; } buffer \n; } HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); COORD pos {0, 0}; SetConsoleCursorPosition(hOut, pos); printf(%s, buffer.c_str()); }逻辑说明玩家位置不写进maze而是在渲染时才用覆盖到缓冲区对应字符上。这样移动玩家只需要改playerR和playerC两个变量不需要在地图数组里来回擦写旧位置。static std::string buffer避免每帧重新分配堆内存清空后逐字符追加输出内容固定时也不会有残影。参数说明COORD是Windows API的结构体两个字段分别表示列和行注意别把{0, 0}写成{0}少一个字段会编译不过。printf(%s, ...)比cout输出一整块字符串快一个量级游戏循环里每帧一次输出完全够用。不喜欢整帧重绘的话也可以只重绘玩家旧位置和新位置两个字符但迷宫本身很小整帧重绘反而代码更简单。3.3 方向键输入与游戏主循环_getch()的两种返回值方向键在Windows控制台里不是普通字符_getch()第一次会返回0xE0有些键盘驱动返回0第二次才返回真正的扫描码上72、下80、左75、右77。需要conio.h头文件这是Windows专属API跨平台项目可以封装一层接口。#include conio.h // 处理一次方向键移动, 返回是否成功移动 bool movePlayer(std::vectorstd::string maze, int pr, int pc, int key) { int nr pr, nc pc; if (key 72) nr--; // 上 else if (key 80) nr; // 下 else if (key 75) nc--; // 左 else if (key 77) nc; // 右 else return false; if (maze[nr][nc] ) { // 只允许走到路格 maze[pr][pc] ; // 旧位置还原成路 pr nr; pc nc; return true; } return false; } int main() { int rows 9, cols 9; auto maze generateMaze(rows, cols, 20240301); int pr 1, pc 1; // 起点房间 int er 2 * rows - 1, ec 2 * cols - 1; // 终点房间 maze[er][ec] E; for (;;) { renderMaze(maze, pr, pc); int ch _getch(); if (ch 0xE0 || ch 0) { ch _getch(); // 读真正方向码 } if (ch q) break; if (ch 72 || ch 75 || ch 80 || ch 77) { movePlayer(maze, pr, pc, ch); } } return 0; }逻辑说明_getch()是阻塞式读取玩家按一下键才跑一圈循环所以不用额外加Sleep限帧。首字节判断是关键——如果你只判断第二字节方向键第一次返回的0xE0会进入你的分支条件结果什么也不匹配游戏表现为“按键没反应”如果你完全不判断首字节方向码读取就会错位。移动后maze[pr][pc] 是把旧的玩家位置还原因为上次渲染时旧位置在屏幕上显示为但地图数组里存的始终是空格不还原的话下一帧会把写进地图。参数说明主循环里没有做“到达终点”判定那是留给读者扩展的。实际做的时候可以在movePlayer之后判断pr er pc ec打印胜利信息后break。20240301是一个固定的随机种子调试时固定种子能让你每次跑出同一个迷宫这是后面自动化验证的基础。4. BFS自动寻路用一条最短路径验证迷宫连通性4.1 为什么最短路径用BFS而不是DFS迷宫生成完第一个需要验证的问题是从起点到终点真的连通吗在“找一条路”这个语义下DFS也能找到路但它不保证最短。DFS回溯生成迷宫时迷宫本身是一棵树任意两点之间只有唯一路径所以理论上这条路就是唯一的不存在“最短”问题DFS找出来的路也就是那条唯一的路径。但这里有个微妙之处一旦你以后换用Prim或递归分割之外的算法或者手动加了额外通道迷宫出现环路DFS找出的路就不再可靠——它会受递归顺序影响可能绕远路。BFS按层扩展首次到达终点时就保证是最短路径。这个性质让BFS成为验证连通性的正确工具如果BFS能到达终点迷宫连通如果BFS路径长度异常偏长说明地图建模有问题。复杂度上也不用担心。迷宫地图是格子图每个格子的边数最多4条BFS的复杂度是O(VE)在100×100房间格也就是4万个格子量级控制台上肉眼可见的“瞬间完成”。这也是为什么我给迷宫项目配自动寻路时首先考虑BFS而不是更花哨的A*——A*多出来的启发式函数在完美迷宫这种小图上几乎没有收益。4.2 BFS寻路实现队列、前驱数组与路径回溯实现BFS需要两样东西一个队列用于按层遍历一个parent数组记录每个格子的前驱节点。前者用std::queue后者用vectorvectorpairint,int尺寸和地图一致。#include queue #include algorithm // 返回起点到终点的最短路径, 不可达时返回空数组 std::vectorstd::pairint,int bfsShortestPath( const std::vectorstd::string maze, std::pairint,int start, std::pairint,int end) { int h (int)maze.size(); int w (int)maze[0].size(); std::vectorstd::vectorstd::pairint,int parent( h, std::vectorstd::pairint,int(w, {-1, -1})); std::vectorstd::vectorbool visited(h, std::vectorbool(w, false)); std::queuestd::pairint,int q; q.push(start); visited[start.first][start.second] true; int dr[4] {-1, 1, 0, 0}; int dc[4] {0, 0, -1, 1}; while (!q.empty()) { auto cur q.front(); q.pop(); if (cur end) break; for (int d 0; d 4; d) { int nr cur.first dr[d]; int nc cur.second dc[d]; if (nr 0 || nr h || nc 0 || nc w) continue; if (visited[nr][nc] || maze[nr][nc] #) continue; visited[nr][nc] true; parent[nr][nc] cur; q.push({nr, nc}); } } // 回溯路径: 从终点倒着找前驱直到起点 std::vectorstd::pairint,int path; for (auto cur end; cur ! start; cur parent[cur.first][cur.second]) { path.push_back(cur); if (cur std::make_pair(-1, -1)) break; // 不连通 } std::reverse(path.begin(), path.end()); return path; }逻辑说明parent里存的是“我从哪里来”不只是布尔标记。BFS逐层向外扩展时第一次遇到某个格子时的前驱一定来自最近的层因此最终回溯出来的路径就是最短路径。回溯时从end出发每次跳到parent[cur]直到回到start。如果中途遇到{-1,-1}说明生成地图时出问题终点不在起点所在的连通分量里此时返回空路径。参数说明start和end是地图坐标不是房间格坐标。调用时传{1,1}和{2*rows-1, 2*cols-1}和主循环里的玩家起点、终点保持一致。跳过条件maze[nr][nc] #是BFS能跑在迷宫地图上的关键墙格永远不进队列但要注意出口格E和玩家占位格在寻路时不会被搜索到——寻路应该跑在原始地图上而不是渲染后的地图。如果map里已经写了BFS会把当成墙跳过路径会绕开玩家当前位置这个细节后面避坑章还会提到。4.3 把路径标到地图上一键“作弊”的展示逻辑拿到路径数组后把路径上的每个格子标成*再渲染一帧就能在控制台上肉眼确认迷宫生成得对不对。这个功能也是游戏里“自动寻路”或“作弊模式”的雏形。void markPath(std::vectorstd::string maze, const std::vectorstd::pairint,int path, char marker *) { for (auto p : path) { int r p.first; int c p.second; if (maze[r][c] || maze[r][c] *) { maze[r][c] marker; } } }逻辑说明标路径时要跳过E和玩家位置否则会把出口字符覆盖掉。marker参数默认*你也可以标成.来区分“路径”和“玩家能走的区域”。隐藏路径时遍历同样的路径数组把*重新还原成 即可不需要再跑一次BFS。参数说明maze以非常量引用传入说明这个函数会直接改地图。一个工程上的好习惯是寻路结果只用于展示不要在修改后的地图上再次寻路。如果你按一次键就显示路径再按一次隐藏地图数据会在原始状态和标记状态之间来回切换保证隐藏时全部还原成空格就不会出错。我习惯把这个功能绑定在Tab键上结合方向键玩家可以边看边学路线。5. C迷宫小游戏避坑指南随机数、乱码与爆栈的排查5.1 同一个迷宫重复出现srand(time(0))的秒级陷阱现象程序每次启动生成的迷宫一模一样或者在同一秒内反复启动结果完全相同玩家炒几次迷宫发现摆烂都一样。原因rand()配合srand(time(0))初始化随机数时time(0)精确到秒。同一秒内启动的所有进程种子相同rand()产生的序列也完全一致。更隐蔽的是如果你用rand() % 4选方向在迷宫本来就需要收缩取值范围时取模偏差也会让某些迷宫形态出现概率异常。解决用C11的random头文件。std::mt19937 gen(seed)配合std::uniform_int_distributionint pick(0, 3)质量远超rand()。调试期固定传入一个常数种子比如20240301发布时用std::random_device{}()生成熵。我在生成函数里保留seed参数就是为了让迷宫可复现。注意mt19937的种子类型是unsigned从命令行读字符串时要std::stoul转换不是stoi。5.2 控制台中文乱码字符集与代码页现象用中文给游戏加提示文字输出变成一片乱码迷宫地图本身也被挤得对不齐。走迷宫的时候墙体字符错位玩家位置看起来跟实际坐标不一致。原因Windows控制台默认代码页是936GBK而用VSCode配置C/C环境时源文件默认以UTF-8保存。当源文件里的中文字符串以UTF-8编码存储、但控制台按GBK解码时多字节序列就被拆成了乱码。迷宫游戏里如果连地图字符都用中文后果更严重——一个中文字符的UTF-8是3字节在控制台上占2列地图行宽直接错乱。解决最省心的方案是迷宫本体只用#、空格、、E这些单字节ASCII字符。游戏提示信息如果要中文在main开头调用SetConsoleOutputCP(65001)把控制台代码页切成UTF-8或者外部执行chcp 65001。我自己的血泪经验是迷宫生成和渲染的代码不要依赖任何中文字符这是这类小游戏最不容易踩坑的写法。5.3 方向键读取翻车_getch()的扫描码边界现象按方向键游戏没反应或者按一次动两格甚至按q退出时被识别成上方向。原因方向键在Windows控制台是两字节序列第一个字节0xE0或0x00第二个字节才是真正的扫描码。代码里如果只调用一次_getch()然后直接和72比较读到的是0xE0当然匹配不上。如果完全忽略首字节、直接拿第二次的扫描码当普通键那q的正常单字节读取也会被误判。解决每次读取先判断首字节。int ch _getch(); if (ch 0xE0 || ch 0) ch _getch();这行是标准做法。之后判断72/75/80/77四个方向码q的ASCII是113走else分支就不会冲突。写测试的时候注意方向键在有的远程终端或IDE集成终端里行为不一致我一般先在原生控制台里验证输入再挪到VSCode的集成终端里调试。5.4 大尺寸迷宫爆栈递归版DFS的深度问题现象把rows和cols调到50以上程序运行几秒后直接闪退或报出栈溢出错误。30以下没事60以上必现。原因递归实现的DFS回溯深度等于当前探索路径的深度在迷宫这种可以一条路走到黑的结构里最坏情况深度接近房间格总数。每层递归占用栈帧Windows默认栈大小约1MB几千上万层递归就会溢出。迭代版用std::stack注意是容器适配器内部在堆上分配替代函数调用栈深度不再受线程栈限制。解决生成迷宫一律用迭代栈版本就是我第2章给的代码。std::stack内部容器默认是deque节点分配在堆上只受内存总量约束。像50×50房间格栈深最大2500层对迭代版本来说完全无感。寻路的BFS本来就是队列迭代实现不存在爆栈问题真正需要检查的是递归版的生成函数、以及如果你把DFS写成递归版来做连通性测试时的隐患。5.5 终点设在墙格里现象生成的迷宫能看到E标记但玩家走到E旁边时被弹回来永远到不了终点BFS寻路也返回空路径。原因有人图省事直接在右下角maze[2*rows-1][2*cols-1] E但这个坐标在边界上恰好是墙格#的位置。移动判定maze[nr][nc] 会拦住玩家因为E不是空格。入口和出口必须设置在房间格上也就是坐标必须同时满足行和列都是奇数的格子。解决起点固定{1, 1}终点固定{2*rows-1, 2*cols-1}这两个坐标永远是房间格。如果你玩“随机出口”就不要在房间里硬放E而是先确认目标格是 再覆盖。我一般把出口标记放在渲染层处理和玩家一样不给地图数据里混入不可通行的特殊字符这样移动逻辑和寻路逻辑都不用关心出口字符。6. 参数化尺寸与自动验证把迷宫项目收尾成可维护的代码6.1 用命令行参数控制行数、列数与随机种子迷宫生成函数已经接受rows、cols、seed三个参数main函数把它们暴露成命令行参数就够用了。--rows 15 --cols 20 --seed 42这种风格解析起来最简单不需要依赖第三方库。#include string int main(int argc, char* argv[]) { int rows 9, cols 9; unsigned seed std::random_device{}(); for (int i 1; i argc; i) { std::string arg argv[i]; if (arg --rows i 1 argc) { rows std::stoi(argv[i]); } else if (arg --cols i 1 argc) { cols std::stoi(argv[i]); } else if (arg --seed i 1 argc) { seed static_castunsigned(std::stoul(argv[i])); } } // 解析后用 rows/cols/seed 生成迷宫并进入主循环 }逻辑说明参数在循环里挨个扫遇到已知参数名就消费后面的值。std::stoi会自动跳过前导空格但要注意它碰到非法输入会抛异常这里没有包try-catch实际使用时建议包一层或者用std::from_chars做更严格的解析。seed用std::stoul而不是stoi因为种子是无符号整数。6.2 批量生成与连通性回归让寻路失败无处可藏参数化带来的最大收益是自动化测试。我每次改完生成算法都会跑一遍批量验证循环100个种子每个种子生成一张迷宫然后跑BFS检查起点到终点是否连通。只要有一个种子生成的地图断的就能立刻定位到算法改动引入的问题。void runRegression(int rows, int cols, int samples) { std::random_device rd; for (int i 0; i samples; i) { unsigned seed rd(); auto maze generateMaze(rows, cols, seed); auto path bfsShortestPath(maze, {1, 1}, {2 * rows - 1, 2 * cols - 1}); if (path.empty()) { printf(seed %u: 起点到终点不可达\n, seed); } } printf(回归完成: 共 %d 个样本\n, samples); }逻辑说明连通性靠BFS的结果判断比人眼盯屏幕可靠得多。100个种子跑完只需要几秒钟代价可以忽略。这个函数独立于游戏入口存在我在main里加一个--check分支解析到该参数时就跑回归测试然后直接退出不进游戏循环。这样每次改完代码只需要mazeGame --check --rows 30 --cols 30 --samples 200一条命令。6.3 一个值得养成的习惯我自己现在写迷宫类程序第一步永远是把命令行参数和--check分支加好再动生成算法。先让种子可复现再让验证可自动最后才谈新功能。没有这两道保险就改算法出问题只能靠肉眼盯屏幕猜那才是真的黑匣子。迷宫小游戏这个方向值不值得投入我的答案是值得——它把STL容器、图遍历、控制台API、参数化设计四个C高频考点串成一条线而且每步都有肉眼可见的反馈。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑