尧图精选

C++ Qt实现老鼠走迷宫:递归回溯生成与BFS寻路

🕒 发布时间:2026/10/1 4:30:47 📁 来源:尧图网络
简介一套基于C与Qt框架的老鼠走迷宫游戏源码附可直接运行的EXE面向Qt初学者、游戏开发爱好者及算法学习者可用来练习GUI编程、迷宫生成和路径规划。压缩包共53个文件大小约24.31MB包含27个DLL运行库、8个C源文件、7个头文件、7个UI界面文件以及工程配置和主程序EXE这些DLL将Qt6运行环境一并打包解压后无需额外配置即可在Windows上直接启动游戏。目前已有291人学习/浏览适合作为课堂项目或自学范例。游戏提供随机迷宫生成和自定义设计两种模式随机模式基于深度优先、Prim或Kruskal等算法自动生成布局自定义模式允许手动铺设墙体与通路可自由调整难度核心代码包含老鼠移动、碰撞检测、胜利判断和A*寻路演示能帮助读者理解二维数组迷宫建模、Qt图形绘制、事件驱动编程及最终打包发布的过程。学习时可对照UI文件与CPP文件理清界面控件与业务逻辑的对应关系也可基于现有代码扩展更多迷宫算法或难度等级。1. 老鼠走迷宫为什么把游戏引擎放在 C/Qt 上用 C 基于 QT 实现的老鼠走迷宫游戏源代码乍看是个“小游戏”其实它把数据结构、算法、GUI 事件、绘图和打包发布全串了一遍。你在这个项目里既可以用递归回溯算法生成随机迷宫也能让老鼠自动寻路或用手动方向键控制它走出迷宫甚至还能在地图上用鼠标亲手设计墙和路。适合刚学完 C 语法、想在 Qt 上做第一个完整桌面应用的开发者也适合做课程设计或作品集项目。相比用网页 H5 或者 Python 写迷宫Qt 版能把“像素坐标与格子映射”“事件循环与动画驱动”这些底层逻辑暴露出来跑通它之后你会彻底理解图形程序是怎么响应键盘和鼠标的。2. 随机迷宫生成递归回溯与墙路编码2.1 迷宫数据结构用一维数组存墙和路很多初学者一上来就写vectorvectorint maze但 Qt 的QPainter绘制是按“索引”访问的一维数组配合行列转换反而更顺手。我习惯定义一个逻辑迷宫尺寸rows x cols再映射到物理数组gridRows 2*rows1、gridCols 2*cols1。这个 2 倍加 1 的映射是迷宫生成的关键物理坐标的奇数行列是全为路的通道偶数行列是墙这样生成的迷宫墙厚是均匀的。// maze.h class Maze { public: Maze(int rows, int cols); void generate(); // 随机生成迷宫 void setCell(int row, int col, int value); // 编辑器用 int at(int row, int col) const; // 0 表示路1 表示墙 // 逻辑坐标转物理坐标 int physRow(int row) const { return row * 2 1; } int physCol(int col) const { return col * 2 1; } private: int rows_, cols_; // 逻辑格数 int gridRows_, gridCols_;// 物理数组尺寸 std::vectorint data_; // 0路 1墙 };这里的at(row,col)返回的是逻辑坐标(row, col)对应的物理格子data_[physRow(row)*gridCols_ physCol(col)]但你要记住真正决定墙和路的是整个物理数组包括边界墙和格子之间的墙。初始化的时将data_全部填 1然后从逻辑格(0,0)开始挖路逐步把物理坐标(1,1)、(1,3)、(1,5)... 变成 0。这个映射关系是整个生成算法的基础后边 BFS 寻路和编辑器点击也是靠它换算。2.2 递归回溯挖路核心代码与随机方向生成随机迷宫最常见的算法是递归回溯也叫深度优先挖路。它的原理很简单从当前格出发随机挑一个间隔两格的邻居如果它没有被访问过就打掉两格之间那面墙然后跳到这个邻居继续如果四周都访问过了就沿原路退回。用递归写法虽然直观但大迷宫下递归深度可能会突破系统栈所以我会用显式栈实现效果完全一样。#include random #include vector #include utility void Maze::generate() { std::fill(data_.begin(), data_.end(), 1); std::vectorstd::vectorbool visited(rows_, std::vectorbool(cols_, false)); std::vectorstd::pairint,int stack; std::mt19937 rng(std::random_device{}()); int r 0, c 0; visited[r][c] true; data_[physRow(r) * gridCols_ physCol(c)] 0; stack.push_back({r, c}); while (!stack.empty()) { auto [cr, cc] stack.back(); std::vectorstd::pairint,int candidates; if (cr 2 !visited[cr-2][cc]) candidates.push_back({-2, 0}); if (cr 2 rows_ !visited[cr2][cc]) candidates.push_back({2, 0}); if (cc 2 !visited[cr][cc-2]) candidates.push_back({0, -2}); if (cc 2 cols_ !visited[cr][cc2]) candidates.push_back({0, 2}); if (candidates.empty()) { stack.pop_back(); // 死路回溯 continue; } std::uniform_int_distributionint dist(0, candidates.size()-1); auto [dr, dc] candidates[dist(rng)]; int nr cr dr, nc cc dc; visited[nr][nc] true; // 打掉当前格与目标格之间的那面墙 int wallRow cr dr / 2; int wallCol cc dc / 2; data_[wallRow * gridCols_ wallCol] 0; data_[physRow(nr) * gridCols_ physCol(nc)] 0; stack.push_back({nr, nc}); } }这段代码里的随机数用了 C11 的random库比rand()%n更均匀也不会因为rand()的线性同余产生周期性的规则迷宫。注意candidates里存的是相对偏移比如{-2,0}表示向上走两格那么它和当前格之间的墙在(cr-1, cc)所以用dr/2和dc/2就能算出墙的位置。如果你把rows_和cols_都设成奇数倍逻辑值生成的迷宫边界更规整。生成完后入口我固定放在左上角(0,0)出口放在右下角(rows_-1, cols_-1)因为递归回溯生成的迷宫任意两个可达格之间一定有唯一路径所以这样设计必然有解。2.3 渲染与缩放用 QPainter 画迷宫迷宫数据生成之后剩下就是把它画到窗口上。在QWidget子类里重写paintEvent遍历物理数组遇到1就画一个实心小方格。为了适应窗口拉伸我不会写死格子大小而是根据当前控件宽高动态计算。void MazeWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.fillRect(rect(), Qt::white); int cell qMin(width() / maze.gridCols(), height() / maze.gridRows()); if (cell 2) cell 2; int offsetX (width() - cell * maze.gridCols()) / 2; int offsetY (height() - cell * maze.gridRows()) / 2; for (int r 0; r maze.gridRows(); r) { for (int c 0; c maze.gridCols(); c) { int idx r * maze.gridCols() c; if (maze.isWall(idx)) { painter.fillRect(offsetX c*cell, offsetY r*cell, cell, cell, QColor(70, 70, 70)); } } } // 入口画绿点出口画红点 }这里我额外加了offsetX/offsetY做居中这样窗口拉大时迷宫不会贴边甚至被裁掉。isWall(idx)是data_[idx]1的封装绘制时还可以顺手把起点终点标出来。如果后期要塞入老鼠贴图也是在paintEvent里画一个椭圆或矩形位置用mouseRow、mouseCol换算到像素坐标即可。记住不要在paintEvent里做耗时计算迷宫数据在生成阶段处理完绘制阶段只负责描格子否则窗口拖动时会明显卡顿。3. 老鼠寻路与手动控制BFS 路线和 QTimer 动画3.1 BFS 寻路与路径回溯老鼠自动走迷宫最常用的不是 DFS而是 BFS广度优先搜索。原因很简单老鼠要的是最短路径BFS 按层扩展第一次到达终点时就是最短解。数据量也不大几百格以内的迷宫 BFS 跑起来不到一毫秒没必要上 A*。std::vectorstd::pairint,int Maze::findPath(int sr, int sc, int er, int ec) { static const int dr[] {1, -1, 0, 0}; static const int dc[] {0, 0, 1, -1}; std::vectorstd::vectorint pre(rows_, std::vectorint(cols_, -1)); std::queuestd::pairint,int q; q.push({sr, sc}); pre[sr][sc] sr * cols_ sc; // 标记已访问 while (!q.empty()) { auto [r, c] q.front(); q.pop(); if (r er c ec) break; for (int dir 0; dir 4; dir) { int nr r dr[dir], nc c dc[dir]; if (nr 0 || nc 0 || nr rows_ || nc cols_) continue; if (at(nr, nc) ! 0) continue; // 墙 if (pre[nr][nc] ! -1) continue; // 已访问 pre[nr][nc] r * cols_ c; // 记录前驱 q.push({nr, nc}); } } if (pre[er][ec] -1) return {}; // 无解 std::vectorstd::pairint,int path; for (int r er, c ec; !(r sr c sc); ) { path.push_back({r, c}); int prev pre[r][c]; r prev / cols_; c prev % cols_; } path.push_back({sr, sc}); std::reverse(path.begin(), path.end()); return path; }代码里的pre数组存的是父节点的一维索引用行*cols列的方式压缩。这么做比写二维pair数组更省内存而且从终点倒推路径时只需要不停除法和取模。pre[sr][sc]我初始化为自身这样倒推循环不会死循环。注意at()取的是逻辑坐标的路/墙状态与物理数组无关所以 BFS 在逻辑格上跑就行。如果返回的path为空说明迷宫没出口这在随机生成时几乎不会发生但在自定义编辑器里就必须处理。3.2 用 QTimer 驱动老鼠平滑移动路径算出来后不能让老鼠瞬间瞬移到终点否则看不出走迷宫的过程。常见做法是拿一个QTimer每隔固定时间取路径的下一个点把老鼠的坐标更新成那个格子的中心然后update()重绘。核心代码如下void MazeWidget::startAutoMove(const std::vectorstd::pairint,int path) { path_ path; step_ 0; if (timer_ nullptr) { timer_ new QTimer(this); connect(timer_, QTimer::timeout, this, MazeWidget::onTimer); } timer_-start(120); // 每 120ms 走一步 } void MazeWidget::onTimer() { if (step_ path_.size()) { timer_-stop(); QMessageBox::information(this, 完成, 老鼠成功走出迷宫); return; } auto [r, c] path_[step_]; mouseRow_ r; mouseCol_ c; update(); }这里的时间间隔120ms是我试过比较舒服的观感太快容易花眼太慢显得拖沓。如果你想让老鼠走得更平滑可以在onTimer里不直接跳到格子中心而是保存上一格坐标、当前格坐标和插值进度每次update()前计算一个介于两者之间的像素偏移。不过大多数情况下按格子跳着走已经完全够用也更容易看清寻路顺序。一个容易被忽略的细节是手动行走时如果还在跑QTimer自动寻路两个逻辑会互相打架。我一般会在启动自动寻路时禁用方向键事件或者用一个bool autoMode_标志在keyPressEvent开头判断若正在自动模式则直接返回等迷宫走完后再恢复手动控制。3.3 手动模式方向键与碰撞检测手动模式就简单多了重写keyPressEvent根据方向键修改候选坐标然后检查maze.at(nr, nc) 0才允许移动。void MazeWidget::keyPressEvent(QKeyEvent* event) { if (autoMode_) return; // 自动模式下手动无效 int r mouseRow_, c mouseCol_; switch (event-key()) { case Qt::Key_Up: r--; break; case Qt::Key_Down: r; break; case Qt::Key_Left: c--; break; case Qt::Key_Right: c; break; default: return; } if (r 0 c 0 r maze.rows() c maze.cols() maze.at(r, c) 0) { mouseRow_ r; mouseCol_ c; update(); } }很多新手在这里会踩一个坑窗口里如果有按钮或者其他控件抢到了焦点方向键事件根本不会传给MazeWidget。解决办法很简单在构造函数里加一行setFocusPolicy(Qt::StrongFocus)并在鼠标点击迷宫区域后调用setFocus()。如果是在 Qt Designer 里做的界面记得在 property 面板把迷宫画布控件的focusPolicy设置为StrongFocus否则运行时照样没反应。手动模式下建议加一个“越界/撞墙”的音效或状态栏提示虽然代码不复杂但在演示时很加分。4. 自定义迷宫用 QWidget 鼠标事件做编辑器4.1 编辑器状态墙、路、入口和出口随机迷宫终究是“电脑说了算”自定义设计迷宫才是用户真正想要的自由度。编辑器的核心是把迷宫数据从“只读”变成“可写”同时管理好四种状态墙、路、入口、出口。我通常用一个枚举定义当前笔刷再用两个变量记录入口和出口的逻辑坐标。enum class Brush { Wall, Road, Start, Exit }; class MazeWidget : public QWidget { Q_OBJECT public: void setBrush(Brush b) { brush_ b; update(); } void setMaze(const Maze maze) { maze_ maze; update(); } protected: void mousePressEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; private: Brush brush_ Brush::Wall; int startRow_ -1, startCol_ -1; int exitRow_ -1, exitCol_ -1; };这里的入口和出口不建议直接写进二维路/墙数据里因为如果用户把出口铺成路再改成墙状态就乱套了。单独存储后绘制时先画迷宫底色再在入口和出口上别画绿/红标记逻辑更清晰。笔刷切换我放在工具栏里用QActionGroup做单选用户点一下就能换状态不需要同时按键盘。编辑器在内存中维护的迷宫数据可以复用Maze类只不过不用generate()而是从空白墙或空白路开始。通常我会提供一个“一键清空为墙”的按钮用户可以从满墙状态一点一点挖出想要的迷宫也提供一个“一键全通路”按钮方便搭开放场景。这类操作在 UI 上很简单但能极大减少用户从全墙开始创建迷宫的挫败感。4.2 鼠标点击与拖拽切换格子在画布上编辑迷宫核心就是两个鼠标事件按下和拖动。按下时把像素坐标换算成行、列然后根据当前笔刷修改对应格子拖动时继续修改新经过的格子才能实现像画笔一样的涂墙效果。void MazeWidget::mousePressEvent(QMouseEvent* event) { QPoint pos event-pos(); int cell cellSize(); // 需要与 paintEvent 中计算保持一致 int r (pos.y() - offsetY()) / cell; int c (pos.x() - offsetX()) / cell; if (r 0 || c 0 || r maze.gridRows() || c maze.gridCols()) return; applyBrush(r, c); lastEditRow_ r; lastEditCol_ c; } void MazeWidget::mouseMoveEvent(QMouseEvent* event) { if (!(event-buttons() Qt::LeftButton)) return; // 按住左键才生效 QPoint pos event-pos(); int cell cellSize(); int r (pos.y() - offsetY()) / cell; int c (pos.x() - offsetX()) / cell; if (r lastEditRow_ c lastEditCol_) return; if (r 0 || c 0 || r maze.gridRows() || c maze.gridCols()) return; applyBrush(r, c); }applyBrush根据brush_做不同处理墙笔刷将物理格子设为 1路笔刷设为 0。入口和出口笔刷则只更新startRow_/startCol_或exitRow_/exitCol_不会改动墙和路。注意cellSize()和offsetY()必须提取成函数避免在 paintEvent 和鼠标事件里各写一套计算导致位置偏差。拖拽时我用lastEditRow_防止同一个格子被反复触发因为mouseMoveEvent的触发频率远高于格子变动频率不加这个判断会白白消耗 CPU。对于迷宫编辑器而言还应该允许用户设置逻辑网格的行列数。比如在新建迷宫时弹出对话框输入rows和cols然后重建物理数组。这不算复杂但能让自定义迷宫有不同尺寸而不是固定死 10x10。4.3 校验迷宫连通性而不是靠运气用户手绘的迷宫很容易画成死路或者干脆无解所以“开始游戏”按钮不能无条件放行。我一般会在点击开始之前调用 BFS 检测入口到出口是否连通如果无解就弹窗提示。bool MazeWidget::verifySolution() { if (startRow_ 0 || exitRow_ 0) return false; auto path maze_.findPath(startRow_, startCol_, exitRow_, exitCol_); return !path.empty(); }这里的findPath就是第 3 章用的 BFS它天然返回最短路径。一旦检测通过我还可以顺便把返回的路径高亮显示一段时间帮助玩家预览“设计师视角”的最优解这个做法在演示时特别讨喜。如果无解弹窗里我用QMessageBox::warning提示“当前迷宫没有通道到出口”并高亮入口和出口的位置让用户知道哪里断了。还有一个容易忽视的边界自定义编辑时用户可能把入口或出口的格子改成了墙。所以verifySolution里除了 BFS 还要检查maze_.at(startRow_, startCol_) 0否则 BFS 起点就卡在墙里。同样出口格被改成墙也需要拦截。如果你愿意做得更完善可以在每次编辑后自动调用一次校验并在状态栏显示“当前路径连通/不连通”。不过频繁 BFS 在小尺寸迷宫中几乎没有性能压力放心用。5. 五条必踩的坑从编码到 Qt 版本不匹配坑一源码中文注释和字符串在别的机器上变成乱码现象在 Qt Creator 里写的中文显示正常把源码放在其他电脑或者用 MSVC 编译后界面按钮、提示消息全变成一片问号或乱码。原因Qt 源码默认按 UTF-8 保存但 MSVC 编译器默认按本地代码页比如 GBK解析导致中文字符被错误解码。老版本 Qt 甚至会出现编译警告 C4819。解决在.pro文件里加上QMAKE_CXXFLAGS /utf-8或者在主函数文件头加#pragma execution_character_set(utf-8)。用 CMake 的话可以在 CMakeLists.txt 里加add_compile_options($$CXX_COMPILER_ID:MSVC:/utf-8)。这个坑只要配置一次后面所有中文字符串都安全。坑二打包 EXE 后双击提示“找不到 Qt 平台插件 windows”现象在自己机器上运行正常把 Release 版 exe 拷贝到别的电脑双击后QWidget程序直接崩溃或弹窗qt.qpa.plugin: could not find the Qt platform plugin windows。原因Qt 程序启动时需要动态加载platforms/qwindows.dll这个文件位于 Qt 安装目录的plugins/platforms/下发布时没有一并带上。解决不要手动复制 DLL直接用 Qt 自带的windeployqt工具。在 Qt 命令行环境Qt 5.15.2 的 Developer Command Prompt中执行cd /d D:\build\MazeGame\release windeployqt MazeGame.exe它会自动分析依赖把platforms、styles、Qt5Core.dll等按需复制到 exe 同目录。打包前记得确认编译用的是 Release 配置Debug 版依赖的调试 DLL 通常没版权且体积巨大。坑三方向键按下老鼠不动现象窗口里能显示迷宫鼠标也能点击但按上下左右箭头没有任何反应。原因当界面上有其他按钮时按钮会抢走键盘焦点keyPressEvent根本没有发给MazeWidget。也有可能是焦点策略默认是NoFocus导致控件收不到按键。解决在MazeWidget构造函数里调用setFocusPolicy(Qt::StrongFocus)鼠标点击画布时再调用setFocus()。如果界面里有QLineEdit之类的输入框需要在切换编辑模式时主动把焦点交还给迷宫控件。这些细节不处理好手动模式等于废了。坑四生成的迷宫有“孤岛”或者某个区域永远进不去现象随机生成后迷宫里有一块很大的空心区域但老鼠怎么也走不进去。或者自定义编辑时不小心把入口围了起来怎么看都不通。原因递归回溯算法本身能保证所有格子连通但如果你在生成前把入口出口的逻辑格固定错了位置或者物理数组没有正确初始化有的格子被排除在visited之外。自定义编辑器里用户画了一堵闭合墙更常见。解决在生成后调用verifySolution()做一次 BFS 校验。如果随机生成也失败优先检查visited数组的初始化边界rows_和cols_都是逻辑尺寸物理数组要留出外框。调式时可以在生成后遍历物理数组把data_[idx]0的位置全部打印出来人工看一眼挖路是否连续。坑五编译时出现cannot mix incompatible Qt library或各种fatal error C1083现象项目在同事电脑上正常自己同一台机器换了 Qt 版本或编译器后一编译就报类似fatal: cannot mix incompatible Qt library (version ex50601) with this library或干脆说找不到 Qt 头文件。原因这是典型的 Qt 版本与编译环境不匹配。Qt 分了 MSVC 版和 MinGW 版MSVC 版也要区分 x86/x64。用 MinGW 编译器去链接 MSVC 预编译的 Qt 库必然版本对不上。解决装 Qt 5.15.2 时把一套工具链从头到尾统一。例如用msvc2019_64套件就要求编译器也是 MSVC 2019 x64并且打开 Qt Creator 时选择同一个套件构建。如果一定要用 MinGW 环境就安装mingw81_64对应的 Qt 库两者不能混搭。这个坑排查成本最高所以我建议新手一开始就全部用 Qt Creator 默认套件不要手动切编译器。6. 用 windeployqt 打包 EXE 并让用户一键打开6.1 打包命令和需要带上的运行库当你要把项目源码连同 EXE 提供给其他人光拷贝一个MazeGame.exe是跑不起来的。Qt 程序运行至少需要 Qt5Core、Qt5Gui、Qt5Widgets 等 DLL以及platforms/qwindows.dll。我一般在项目目录建一个release文件夹把编译产物放进里面然后用 Qt 命令行工具执行windeployqt --release --no-compiler-runtime MazeGame.exe--no-compiler-runtime表示不自动拷贝 MSVC 运行库如果你不想让用户安装 VC 环境就去掉这个参数。生成后建议用dumpbin /dependents MazeGame.exe检查一下是否还有多余的依赖。最后把 exe 和所有 DLL 打包成一个 zip用户解压就能运行不用再装任何插件。6.2 进一步可以做的存档、难度和音效打包只是结尾真正让这个项目变得更耐玩的方向有三个一是把自定义迷宫保存成 JSON 或简单文本文件用QFileDialog提供导入导出二是加入难度分级比如 5x5、10x10、20x20 分别对应不同生成参数并限制每局时间三是给老鼠移动配上QSoundEffect音效在撞墙或通关时播放提示音。我最喜欢加的是“步数统计”每走一步就在状态栏更新计数自动寻路走完显示最短步数手动走完显示实际步数能直观对比你的绕路程度。这些功能都不需要改核心数据结构只在外围扩展所以很适合拿来练手。我自己最开始做这个项目时把 90% 的时间花在了修“Qt 版本不匹配”和“打包缺插件”上甚至一度想放弃。后来才意识到先把算法在控制台里用纯 C 验证一遍再接入 Qt 绘图和事件最后集中处理发布流程能少走一大截弯路。如果你从随机生成的算法入手骨架起来之后每一步都可以单独测试最后再拼图形界面成功率会高很多。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →