ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Qt与C++实现迷宫游戏:DFS生成算法与QGraphicsView渲染实战

用Qt与C++实现迷宫游戏:DFS生成算法与QGraphicsView渲染实战 简介一份基于Qt C框架的迷宫游戏完整项目面向具备一定C基础、希望学习Qt图形界面与游戏逻辑开发的读者。项目实现了迷宫随机生成、玩家操控、通关路径自动计算等功能覆盖DFS或Prim生成算法、QGraphicsView场景搭建、键盘事件响应等关键知识点。资源包共27个文件主要包括3个cpp与2个h源码文件、1个ui界面文件、1个qrc资源文件、多个png图标与迷宫贴图以及pro工程文件整体仅333KB结构紧凑便于阅读。已有962人学习下载。从中可获取一套可直接编译运行的Qt项目配合源码和界面文件能直观理解迷宫生成、寻路算法与Qt事件循环的协作方式也适合作为课程设计或游戏开发入门参考。1. 用 Qt 和 C 写一个迷宫游戏比你想的更值钱当你在搜索引擎里敲下“Qt 迷宫”这几个字时大概率不是想找一份能跑通的源码那么简单。你要的是一个能放进简历、能拿去交课程设计、甚至能扩展成 Roguelike 游戏原型的完整方案。迷宫 Qt C 这个组合最大的价值不是“画一个迷宫”而是它天然覆盖了游戏开发的三条主线算法生成地图、事件循环处理交互、QPainter / QGraphicsView 做渲染。这三条线几乎就是所有 2D 游戏的骨架。我见过太多人把时间浪费在“抄一份能跑的代码”上结果连迷宫是怎么生成的都说不清。面试官一问 DFS 和 BFS 的区别、Qt 的描画坐标系为什么是左上角原点立刻露馅。这篇文章不打算给你一份黑匣子而是把从零写一个 Qt 迷宫游戏的每一步掰开生成算法怎么选、界面怎么搭、键盘交互怎么设计、关卡计时和校验怎么写以及那些让你深夜崩溃的坑——比如 Qt 的像素映射、双缓冲闪烁、随机数种子导致的“每次启动迷宫都一样”。你能得到一份直接照着敲就能跑出窗口的代码更重要的是你会知道每行代码为什么存在。2. 迷宫生成算法选型为什么先写 DFS 而不是 Prim迷宫的核心是地图数据。QVector、二维数组还是 std::array这些容器选型都好说真正的分水岭在于“迷宫是怎么长出来的”。最常见的两种算法是递归回溯DFS和随机 Prim。对于 Qt 游戏这种场景我强烈建议先用 DFS不是因为它效果最好而是它的逻辑和你打游戏时的手感天然匹配。2.1 迷宫数据结构和坐标系的约定迷宫的地图我用二维 int 数组表示约定 0 是墙1 是路。这是所有后续绘制和碰撞检测的基础。如果你一上来就搞一个复杂的结构体后面只会越写越乱。const int ROWS 21; // 行数必须是奇数 const int COLS 21; // 列数必须是奇数 int maze[ROWS][COLS] {0}; // 0墙, 1路 // 初始化全部围起来奇数行列留出来打洞 for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { if (i % 2 1 j % 2 1) { maze[i][j] 1; // 预置奇数点为路这样挖墙时不会挖穿边界 } } }为什么奇数必须有讲究递归回溯的挖墙逻辑要成对处理细胞格和墙格。如果行或列是偶数最后一个细胞会没有墙可以挖路径就断了。这个约束会在后面生成算法里反复出现。另一个容易踩的坑是坐标系的统一。QPainter 的坐标系以屏幕左上角为原点x 向右增大y 向下增大。所以 maze[0][0] 在 Qt 界面上画出来是左上角那块格子。游戏逻辑里的“上下左右”和程序里的“row1 / row-1 / col1 / col-1”必须一一对应我建议在代码里写死一个枚举enum Direction { UP 0, DOWN, LEFT, RIGHT }; // 移动时的行/列增量 const int dr[4] {-1, 1, 0, 0}; const int dc[4] {0, 0, -1, 1};Direction 枚举和增量数组这种写法要多早用就多早用别到写碰撞检测时再把四个方向拆成四个 if。2.2 递归回溯生成迷宫两页纸讲透代码递归回溯的实现并不长核心就几句话从起点细胞出发随机选一个相邻且未被访问过的细胞打通中间隔着的墙走过去然后递归。无路可走就往回退。void MazeGenerator::dfs(int row, int col) { visited[row][col] true; // 随机打乱方向保证每次生成的迷宫不一样 std::vectorint dirs {0, 1, 2, 3}; std::shuffle(dirs.begin(), dirs.end(), rng); for (int dir : dirs) { int nr row dr[dir] * 2; // 跨过一个墙格所以乘2 int nc col dc[dir] * 2; if (nr 0 nr ROWS - 1 nc 0 nc COLS - 1 !visited[nr][nc]) { // 打通中间的墙 maze[row dr[dir]][col dc[dir]] 1; maze[nr][nc] 1; dfs(nr, nc); } } }逻辑说明dr 和 dc 分别是上下左右的行列偏移量。乘 2 是因为我们要跳到两个格子之外的细胞中间那格就是待打通的墙。if 里的边界判断去掉四个边框防止数组越界。已经访问过的细胞跳过避免回路。这样递归下去最终所有奇数坐标的细胞都会被访问一次整个迷宫就是一个没有环的树形结构。参数说明rng 是 std::mt19937 随机数引擎我在构造函数里用 std::random_device 来播种避免每次程序启动迷宫一模一样。如果你想要“每日迷宫”之类的功能也可以改成用当前日期做种子这样用户每天来看到的迷宫都是新鲜又固定。这里有一个很重要的直觉DFS 生成出来的迷宫是“深而窄的”——路径长、岔路少、有很长的死胡同。玩起来感觉是在探洞一条路走到黑。如果你想要更开阔、更像平面迷宫的效果Prim 算法才是正解。但 Prim 需要额外的集合数据结构逻辑复杂度高一些。我的建议是先 DFS 跑通全流程再做第二个算法版本时换 Prim。3. Qt 图形界面搭建QGraphicsView 才是游戏正确起点很多教程让你用 QWidget 加 QPainter 的 paintEvent 画迷宫。这能跑但如果你要加角色移动、动画、碰撞检测你会发现所有东西都得自己在 paintEvent 里重绘性能和代码结构都会很难受。3.1 用 QGraphicsScene 和 QGraphicsView 代替 QWidget 重绘QGraphicsView 是 Qt 专门为“有较多图形对象、需要频繁移动”的场景设计的框架。它不是一次性画一张图而是维护一个 scene 对象列表每个对象可以独立移动、隐藏、响应事件。这让角色、终点、墙块都天然成了对象。class MazeGame : public QGraphicsView { Q_OBJECT public: MazeGame(QWidget *parent nullptr); private: QGraphicsScene *scene; QGraphicsRectItem *player; int cellSize 30; // 每个迷宫格子的像素大小 }; // 构建迷宫墙体 void MazeGame::buildWalls() { for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { if (maze[row][col] 0) { // 是墙就画一个灰色矩形进场景 QGraphicsRectItem *wall new QGraphicsRectItem( col * cellSize, row * cellSize, cellSize, cellSize); wall-setBrush(QBrush(QColor(80, 80, 90))); wall-setPen(Qt::NoPen); // 去掉边框线视觉更干净 scene-addItem(wall); } } } }逻辑说明每个墙块是一个独立的 QGraphicsRectItem 对象。单元格的左上角坐标是 col * cellSize 和 row * cellSize这正好对应前面提到的“左上角原点”坐标系。setPen(Qt::NoPen) 是所有 Qt 游戏画面里最容易被忽略的细节——不设置 NoPenQt 会给每个矩形加一个 1px 黑色描边迷宫看起来像一张渔网。参数说明cellSize 是全局像素比例调它就能缩放整个游戏。31×31 的迷宫配 20px 的格子窗口大约 620×620还算舒适。我习惯把 cellSize 单独提出来因为后面加“窗口尺寸自适应”或“地图编辑模式”时改一个变量就行。3.2 玩家角色、终点标记和渲染顺序场景对象的 z-value 决定了谁画在谁上面。墙在最底层终点标记在墙上面玩家又在终点上面。不设 z-value 的话QGraphicsScene 按照添加顺序绘制后期插入对象会盖在早期对象上很容易出逻辑错误。// 玩家一个圆形便于视觉定位 player new QGraphicsEllipseItem(0, 0, cellSize * 0.8, cellSize * 0.8); player-setBrush(QBrush(QColor(240, 84, 84))); player-setPos(1 * cellSize, 1 * cellSize); player-setZValue(10); scene-addItem(player); // 终点用一个更亮的绿色块表示 QGraphicsRectItem *goal new QGraphicsRectItem( (COLS - 2) * cellSize, (ROWS - 2) * cellSize, cellSize, cellSize); goal-setBrush(QBrush(QColor(70, 200, 120))); goal-setZValue(5); scene-addItem(goal);setPos 方法接收的是场景坐标左上角为原点所以把玩家放到 maze[1][1] 的位置等于 setPos(1 * cellSize, 1 * cellSize)。这套坐标换算你后面写键盘移动时还会用一百遍。渲染顺序还有个坑如果你把玩家加进 scene 之前忘了 setZValue发现角色被墙盖住了先查是不是 z-value 设置失效。其次要检查是不是掉进了 QGraphicsScene 的 update 死循环——一帧里调用多次 update 会导致画面卡顿但那是性能问题不是绘制逻辑问题。4. 键盘交互与游戏逻辑从按一下走到按着走迷宫不是一锤子买卖玩家得能走、能停、能判断胜负、能计时。Qt 的键盘事件整套机制足够支撑这个需求几个核心点得分开处理。4.1 重写 keyPressEvent 和碰撞检测的设计QGraphicsView 是 QWidget 的子类直接用键盘事件最简单。重写父类的 keyPressEvent里面判断方向键然后检查下个位置是不是墙。void MazeGame::keyPressEvent(QKeyEvent *event) { int nextRow playerRow; int nextCol playerCol; switch (event-key()) { case Qt::Key_Up: nextRow--; break; case Qt::Key_Down: nextRow; break; case Qt::Key_Left: nextCol--; break; case Qt::Key_Right: nextCol; break; default: QGraphicsView::keyPressEvent(event); return; } if (maze[nextRow][nextCol] 1) { // 只有路才能走 playerRow nextRow; playerCol nextCol; player-setPos(nextCol * cellSize, nextRow * cellSize); } checkGoal(); }逻辑说明这里重要的设计是把“游戏逻辑坐标”playerRow / playerCol和“场景坐标”setPos 参数分开。前者是迷宫数组的下标后者是以像素为单位的位置。换算规约就是乘以 cellSize不涉及任何画布缩放或滚轮变换时这个公式始终成立。碰撞检测不是“碰到墙就弹回来”而是“压根不允许走进墙里”。检查目标格子的 maze 值是 0 还是 1比检测图形碰撞简单一个数量级而且永远不会出现角色半卡在墙里的状态。这是基于格子的游戏最推荐的做法。参数说明checkGoal 只需要判断 playerRow ROWS-2 playerCol COLS-2 就能触发胜利。这里 ROWS-2 和 COLS-2 正好是终点所在的迷宫数组下标因为 0 和 ROWS-1 是外墙从 1 开始那圈内边缘就是路的边界。4.2 连续移动、定时器与记步系统只按一次走一格体验太干瘪。玩家更期望按住方向键连续走。做法是用一个 QTimer 驱动角色每隔一段固定时间朝当前方向移动一格。timer new QTimer(this); timer-setInterval(120); // 每 120ms 移动一格 connect(timer, QTimer::timeout, this, MazeGame::moveOneStep); void MazeGame::keyPressEvent(QKeyEvent *event) { // 记录当前方向 if (event-key() Qt::Key_Up) currentDir UP; timer-start(); // 按下键就开始定时移动 } void MazeGame::keyReleaseEvent(QKeyEvent *event) { if (event-isAutoRepeat()) return; // 忽略系统自动重复 if (event-key() Qt::Key_Up) currentDir NONE; // 松开则停 }逻辑说明定时器移动 按键事件只改变方向这种拆分让角色在持续按住时不间断地往前。你还需要在 moveOneStep 里对 maze[nextRow][nextCol] 做同样的碰撞判断。一个新手特别容易翻车的点键盘按住不放时会触发连续 keyPressEvent事件里如果总是 timer-start()Qt 会不断重启计时器结果就是一格都走不动。解决方式是只在首次按下时 start后续事件只更新 currentDir。我习惯在设计里给每个方向键按下判断一次“isKeyPressed 位图”用位图记录当前按下的键然后合并成方向处理起来最灵活。计步系统更是简单到离谱moveOneStep 里每成功移动一格steps在界面上用一个 QLabel 刷新显示。这就是后面关卡评分、竞速模式的地基。5. 避坑指南Qt 迷宫项目里最常见的五个翻车现场这个章节的价值就是让你少熬夜。以下每一条都是我踩过或者帮别人排查过的真实问题。5.1 随机种子固定导致每次启动迷宫一模一样现象每次运行程序生成的迷宫完全一样只有重启电脑才变化。原因使用了 time(nullptr) 作为种子在同一秒内多次运行程序时间戳完全相同所以随机数序列一致。这个坑在做“换一张地图”按钮时最明显点按钮连续换图结果每次换出来的迷宫都是同一个形状。解决用 std::random_device 初始化 mt19937它从硬件熵池取随机数不会撞种子。std::mt19937 rng{std::random_device{}()};这段代码放在 MazeGenerator 类里即可。如果目标平台不支持 random_device某些嵌入式环境会退化成伪随机发生器退而求其次可以用 chrono 的高精度时钟纳秒做种子unsigned seed std::chrono::system_clock::now().time_since_epoch().count();5.2 QGraphicsView 的 scroll 导致角色坐标算错现象角色移动时越走越偏跑到一定位置就消失不见。原因当场景尺寸超过视图可视范围QGraphicsView 自动提供滚动条视图的坐标系和场景坐标系错开了。比如你向右走scrollbar 向右滚动此时 setPos 设置的是场景坐标但视觉上看起来角色相对屏幕往左退了。解决在构造函数里强制关闭滚动条并且设定视图锚点不对齐。setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setFixedSize(COLS * cellSize, ROWS * cellSize);把窗口固定成和迷宫一样大小是最省心的处理方式。如果你想让迷宫比窗口大、靠拖视野来探索那就得学会 view-centerOn(player)这是一个新玩法但千万别同时保留滚动条。5.3 Windows 下缺少 Visual C 运行库导致 Qt 程序启动崩溃现象程序在自己电脑编译运行正常拷贝到别的电脑一打开就报警“VCRUNTIME140.dll 缺失”或者直接闪退。原因Qt 的 MSVC 版本依赖 VC 运行库。发布 Debug 版本更是灾难Debug 库不会跟着安装到目标机器。解决发布时选择 Release 模式并开启 Qt 的 windeployqt 工具它会自动把需要的 Qt 模块 DLL 复制到发布目录。另外在项目文件里加上CONFIG release QMAKE_LFLAGS /MANIFESTUAC:levelasInvoker uiAccessfalse作为运维手段也可以建议目标用户安装“Microsoft Visual C 2015-2022 Redistributable (x64)”但发布包里能自带的库就尽量带上用户安装一年半载不关心这个启动报错就会直接来敲你。5.4 角色移动时出现残影或画面闪烁现象角色快速移动时身后会有拖影窗口内容在缩放时跳动闪烁。原因QGraphicsView 默认没有开启双缓冲。如果你直接在场景里大量调用 addItem 和 removeItem绘制时没有缓冲就很容易出现闪烁。解决默认现代 Qt 版本5.4 以后对 QGraphicsView 是启用双缓冲的。如果还闪检查是不是你用了 QWidget::repaint() 强制立即重绘。把它替换成 QWidget::update()让 Qt 把重绘并入事件循环里合并调度。如果问题依旧检查是不是在 paintEvent 里做了复杂计算——迷宫场景如果直接走 QGraphicsViewQGraphicsScene 本身已经处理好了刷新额外重写 paintEvent 反而制造闪烁。5.5 界面字体模糊或窗口缩放后迷宫错位现象在 1080p 屏幕上开了 125% 缩放Qt 界面字体发虚迷宫格子之间有白缝。原因Qt 默认未启用高 DPI 缩放。Windows 下如果你没有声明 Qt::AA_EnableHighDpiScalingQt 会按逻辑像素画物理像素密度高时文字被拉伸发虚图形元素因为整数倍缩放不对齐出现缝隙。解决在 main.cpp 中、创建 QApplication 之前加QApplication::setAttribute(Qt::AA_EnableHighDpiScaling, true); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps, true);开启后Qt 负责把逻辑坐标映射到物理像素cellSize 如果设为奇数像素还是会偶发缝隙。最彻底的办法是把 cellSize 设成偶数比如 24 或 32从结构上避免半个像素问题。6. 性能优化与扩展技巧让迷宫游戏从“能玩”到“耐玩”迷宫能跑之后最难的是让它跑得顺、玩得久。这里有几个我从更大型的 Qt 项目里带回的战斗经验。6.1 用 QGraphicsScene 的项管理和碰撞检测做扩展QGraphicsScene 的 items() 方法能够返回指定区域内的所有图形项。你可以用来做“到达终点触发区域”的检测比定时查坐标更通用——如果你的终点是一个不规则区域或陷阱区用它就能统一处理。QListQGraphicsItem* hitItems scene-items( QRectF(player-pos(), QSizeF(cellSize, cellSize))); for (QGraphicsItem *item : hitItems) { if (item-data(0).toString() goal) { gameWin(); } }逻辑说明data(0) 是给每个 item 挂的自定义数据比 type() 判断更灵活。给终点标记 setData(0, goal)给陷阱setData(0, trap)检测逻辑就只读 data(0)新增游戏元素时完全不用改碰撞检测代码。参数说明QRectF(player-pos(), QSizeF(cellSize, cellSize)) 是以玩家当前位置构造的矩形区域items 会返回与这个矩形相交的所有项。如果你的图形项比格子大记得把矩形尺寸同步放大否则检测会漏。6.2 记步与计时评估游戏性的下一步迷宫单次走到终点其实很容易腻。给玩家加了“最少步数”“最短时间”两个维度之后耐玩度立刻提升。最少步数其实在生成迷宫时就能算出来——用 BFS 从起点扫一遍记录终点步数作为理论最优值。int bfsMinSteps() { std::queuestd::pairint,int q; int dist[ROWS][COLS]; memset(dist, -1, sizeof(dist)); q.push({1,1}); dist[1][1] 0; while (!q.empty()) { auto [r, c] q.front(); q.pop(); if (r ROWS-2 c COLS-2) return dist[r][c]; for (int d 0; d 4; d) { int nr r dr[d], nc c dc[d]; if (maze[nr][nc] 1 dist[nr][nc] -1) { dist[nr][nc] dist[r][c] 1; q.push({nr, nc}); } } } return -1; }逻辑说明这个 BFS 的代价和迷宫生成算法完全不同它用的是队列逐层扩散第一次到达终点时的步数就是全局最短路径。把最短步数和实际步数都显示在界面上玩家就会开始琢磨“咦我以为我已经走的是最短路线了”。6.3 我验证迷宫正确性的土办法做游戏项目时我习惯额外写一个 debug 模式程序里按 F5 键直接在迷宫上面画一条 BFS 算出来的最短路径。这样我能一眼看出生成算法生成的迷宫是不是“通的”——如果你发现 BFS 返回 -1 或者路径穿墙绝对是生成算法在某条边界上出错了而不是玩家操作问题。我会把“F5 显示最短路径”这个功能一直留在代码里发布也不删除。它既是一个给测试人员用的调试器又是一个“无敌挂”——演示给导师看的时候一键可视化说服力拉满。这类小技巧积少成多后一个 Qt 迷宫项目就不只是课程设计了它是我后来写 2D Roguelike 游戏引擎的基础骨架。从生成、渲染、交互到评分管线几乎原样复用。今天你在网上搜“Qt 迷宫 代码”十有八九搜到的是老旧的 QWidget 写法希望这篇基于 QGraphicsView 的版本能让你少走两三个月弯路。如果你是从零开始先照着 2.2 节的 DFS 把生成跑通再跳到 3.1 节搭场景最后回来处理按键。每一步都能单独验证比一次性写完整份项目要稳得多。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进