ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

拆解Qt迷宫游戏:DFS生成、A*寻路与QGraphicsScene架构

拆解Qt迷宫游戏:DFS生成、A*寻路与QGraphicsScene架构 简介这套基于 Qt C 的迷宫游戏项目源码面向具备基础 C 语法、希望学习 Qt 图形界面与算法实战的开发者可帮助解决迷宫随机生成、路径规划及键盘交互等常见游戏开发问题尤其适合迷宫类小游戏课设或 Qt 界面编程练习。压缩包共 27 个文件含 3 个 cpp 源文件、2 个头文件、Qt Designer 生成的 .ui 界面文件以及 18 张用于地图块与角色表现的 png 图片配齐 .pro 工程文件和 .qrc 资源文件整体仅 333KB结构紧凑、便于快速定位代码。已有 961 人在 CSDN 学习下载具备一定参考价值。通过阅读源码可掌握基于 QGraphicsView/QGraphicsScene 的场景搭建思路理解 DFS 与 Prim 迷宫生成算法的区别学习回溯或 A* 式通关路径计算并亲测用 W 等按键控制角色移动的完整交互流程适合作为课程设计、Qt 自学或游戏开发的练手资料。读者可按注释逐步理解算法流程并替换图片资源快速搭建自己的迷宫原型。 拆完 Maze_qt 迷宫这套 Qt C 迷宫游戏源码我最大的感受是它把一个本来需要折腾很久的图形小游戏拆成了可以照着改的五个模块。随机迷宫生成、键盘控制角色、自动通关路径、结果判定与重开全链路都跑在 QGraphicsView/QGraphicsScene 这套经典框架上。适合三类人刚学完 C 想找图形界面练手的、要做课程设计交完整项目的、想复用迷宫生成和 A* 寻路代码的老手。与其说它是毕业设计模板不如说它是一份能直接编译运行的 Qt 工程底稿——你只需要把里面的资源路径和生成参数改一改就能变成自己的版本。2. 迷宫生成两条关键路DFS 回溯网格与二进制墙面贴图迷宫游戏的核心不是 UI而是那张地图怎么从无到有。这份代码的文件清单里有一组 4 位编号的 png比如 0000、0100、1010、1110这其实是把每个格子的四面墙按“上右下左”顺序编码成二进制位位值为 1 表示这一面有墙。理解了这套编码你不仅看懂了迷宫生成也看懂了它为什么用这种方案做贴图。2.1 迷宫生成的算法选择DFS 回溯为什么是首选常见的迷宫生成算法有 DFS深度优先搜索回溯、随机 Prim、递归分割等。这份工程最可能的实现是 DFS 回溯因为它的结果是一棵连通树任意两个格子之间有且只有一条路径迷宫里不存在孤岛也不会有环形绕路。对玩家体验来说DFS 生成的迷宫会有较多长走廊走起来比 Prim 那种四散分布的迷宫更有“闯关感”。我一般会把生成逻辑封装在 Maze 类里用一个二维 QVector 记录格子状态再用栈做回溯。核心代码大概是这个结构// maze.cpp —— DFS 回溯生成迷宫 void Maze::generate(int rows, int cols) { grid QVectorQVectorint(rows, QVectorint(cols, 0b1111)); visited QVectorQVectorbool(rows, QVectorbool(cols, false)); int dirs[4][2] {{0, -1}, {0, 1}, {-1, 0}, {1, 0}}; int mask[4] {0b1000, 0b0010, 0b0001, 0b0100}; // 上 下 左 右 QStackQPoint stack; stack.push(QPoint(0, 0)); visited[0][0] true; while (!stack.isEmpty()) { QPoint cur stack.top(); QVectorint order {0, 1, 2, 3}; std::shuffle(order.begin(), order.end(), std::mt19937(rand())); bool moved false; for (int d : order) { int nx cur.x() dirs[d][0]; int ny cur.y() dirs[d][1]; if (nx 0 || ny 0 || nx cols || ny rows) continue; if (visited[ny][nx]) continue; grid[cur.y()][cur.x()] ~mask[d]; int back d ^ 1; grid[ny][nx] ~mask[back]; visited[ny][nx] true; stack.push(QPoint(nx, ny)); moved true; break; } if (!moved) stack.pop(); // 没有可扩展的邻居就回溯 } }这段代码里最值得注意的有三点。第一mask 数组与 dirs 数组的顺序必须一一对应方向弄错了生成出来的迷宫会出现墙对不上。第二当前格清掉某一面的墙时邻居格要同时清掉反方向的墙比如向右打通时当前格清 right 位邻居格清 left 位否则墙只消失了一侧。第三shuffle 里用了可复现的随机源实际工程里会用时间种子我下面会单独讲种子调试的坑。参数方面rows 和 cols 决定迷宫规模对应到 grid 的行列数。生成完成后每格的值是一个 0b0000 到 0b1111 之间的整数比如 1010 表示上墙和下墙存在左右可以通行也就是一条竖直通道。2.2 从二维数组到二进制墙面贴图png 命名里藏着方向位生成完 grid 之后下一步是把每个格子翻译成图片。这里我没有直接把墙画在窗口上而是先写一个把格子值转成 4 位字符串的函数再去 QPixmap 缓存里查表。这样的好处是图片素材可以预加载运行时只需要做 Key 查找省掉逐帧绘制墙线的开销。// maze.cpp —— 将当前格子值转换为 4 位贴图编号 QString Maze::cellKey(int x, int y) const { int v grid[y][x]; int up (v 0b1000) ? 1 : 0; int right (v 0b0100) ? 1 : 0; int down (v 0b0010) ? 1 : 0; int left (v 0b0001) ? 1 : 0; return QString(%1%2%3%4).arg(up).arg(right).arg(down).arg(left); }按“上右下左”的顺序拼出来的编号正好对应项目里那一批 png 文件名。下面这张表是几个典型例子的含义位串二进制含义实际场景0000四面全部开放位于网格内部、四个方向都通的空旷节点1010上、下有墙左右通竖直通道0101右、左有墙上下通水平通道1110上、右、下有墙左通只朝左边开口的盲端0111右、下、左有墙上通只朝上开口的盲端有了 cellKey 之后加载迷宫画面就变成了简单的循环贴图。玩家角色 hero1.png 是单独放在角色坐标上的path.png 是路径高亮用的除了这些特殊素材其余编号 png 全都可以提前装进 QHashQString, QPixmap 做缓存。// mainwindow.cpp —— 把 grid 数据贴到 QGraphicsScene 上 void MainWindow::loadMaze(const Maze maze) { scene-clear(); const int cell maze.cellSize(); // 通常取 48 或 64 for (int row 0; row maze.rows(); row) { for (int col 0; col maze.cols(); col) { QString key maze.cellKey(col, row); QPixmap pm pixmapCache.value(key); QGraphicsPixmapItem* item scene-addPixmap(pm); item-setPos(col * cell, row * cell); } } }这里有一个容易忽略的参数cellSize 是逻辑格子边长所有图片素材的像素尺寸必须都是 cell × cell而且坐标要按 col * cell 和 row * cell 计算。一旦素材尺寸和 cellSize 不一致贴图之间就会出现缝隙或者角色踩在两张图中间看起来像悬空。提示不要试图在运行期用 QPainter 实时画墙线。小迷宫里性能差距不明显迷宫到 100×100 之后每帧判墙绘制的开销会明显拖慢界面。静态编号贴图是和这份代码最匹配的方案。3. QGraphics 场景装配与键盘控制从 .pro 到角色移动的完整链路迷宫生成只解决了数据问题玩家能看到的还是黑乎乎一团。这份工程里mainwindow.cpp、mainwindow.ui 和 image.qrc 三者协同把 QGraphicsScene、QGraphicsView、贴图资源和键盘事件串成一条完整的交互链路。这一章我按工程装配、键盘控制、穿墙判定三个层次拆开讲。3.1 工程装配.ui、QGraphicsScene 与资源系统的连接项目里带 mainwindow.ui说明界面是在 Qt Designer 里拖出来的。常见做法是在 .ui 里放一个 QGraphicsView 控件然后在 MainWindow 构造函数里 new 一个 QGraphicsScene 并关联到 view 上。.pro 文件里需要声明 QT widgets否则 QGraphicsView 相关类不会参与编译。我一般会在 main.cpp 里只保留标准的 QApplication 启动代码把初始化全部放进 MainWindow 的构造函数这样重开迷宫时只需要调用 loadMaze 重置场景不需要销毁重建窗口。// mainwindow.cpp —— 构造函数里初始化场景 MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { ui-setupUi(this); scene new QGraphicsScene(this); ui-graphicsView-setScene(scene); ui-graphicsView-setRenderHint(QPainter::Antialiasing); loadPixmaps(); // 从 image.qrc 加载全部贴图 maze.generate(21, 21); // 先生成一个 21x21 的迷宫 loadMaze(maze); setupPlayer(); // 把 hero1.png 放到起点 }这里 loadPixmaps 要从 image.qrc 里读取图片资源。Qt 的资源系统有两种引用方式一种是绝对路径比如:/new/prefix1/hero1.png一种是相对路径比如直接QPixmap(:/images/hero1.png)。我踩过最典型的坑是把 png 文件直接丢进工程目录却不加进 .qrc结果运行时pixmap.isNull()返回 true整个场景空白。void MainWindow::loadPixmaps() { QStringList keys {0000, 0001, 0010, 0011, 0100, 0101, 0110, 0111, 1000, 1001, 1010, 1011, 1100, 1101, 1110}; for (const QString key : keys) { QPixmap pm(QString(:/images/%1.png).arg(key)); if (pm.isNull()) { qWarning() missing pixmap key; continue; } pixmapCache.insert(key, pm); } }这段代码里参数 key 的范围来自 png 文件清单漏一个就补一个。注意 qWarning 的检查很关键它能在开发期直接告诉你哪个贴图文件没进 qrc而不是等运行时界面出现白格子再去猜。3.2 键盘控制与穿墙判定方向掩码比位移更先执行角色移动的常规做法是重写 MainWindow::keyPressEvent捕获 W/S/A/D 或方向键。直接改坐标看起来简单但会留下两个隐患一是步长可能不是 cellSize 的整数倍导致角色卡在两格之间二是没有先做墙位校验角色会直接穿墙。我的组合方案是先算方向向量再用 hasWall 检查当前格和目标格之间的墙最后才移动。方向掩码顺序仍然沿用上右下左。// mainwindow.cpp —— 键盘控制角色移动 void MainWindow::keyPressEvent(QKeyEvent* event) { if (event-isAutoRepeat()) return; // 按住不放只走一步 int dx 0, dy 0; int dir -1; if (event-key() Qt::Key_W || event-key() Qt::Key_Up) { dx 0; dy -1; dir 0; // 上 } else if (event-key() Qt::Key_S || event-key() Qt::Key_Down) { dx 0; dy 1; dir 1; // 下 } else if (event-key() Qt::Key_A || event-key() Qt::Key_Left) { dx -1; dy 0; dir 2; // 左 } else if (event-key() Qt::Key_D || event-key() Qt::Key_Right) { dx 1; dy 0; dir 3; // 右 } else { QMainWindow::keyPressEvent(event); return; } int nx playerX dx; int ny playerY dy; if (nx 0 || ny 0 || nx maze.cols() || ny maze.rows()) return; if (maze.hasWall(playerX, playerY, dir)) return; playerX nx; playerY ny; QPointF pos(nx * maze.cellSize(), ny * maze.cellSize()); playerItem-setPos(pos); }按键映射可以整理成下面这张表方便对照方向位和位移按键方向位移向量方向位W 或 ↑上(0, -1)0S 或 ↓下(0, 1)1A 或 ←左(-1, 0)2D 或 →右(1, 0)3hasWall 的判断逻辑与生成算法里的 mask 完全对应。这里方向位的定义必须与 maze.cpp 里的 dirs 数组顺序一致如果把上和下的方向位反了玩家按 W 时检查的会是下墙表现成“向下走却有墙挡住”这是新手最容易翻车的点。// maze.cpp —— 判断当前格子某方向是否有墙 bool Maze::hasWall(int x, int y, int dir) const { int v grid[y][x]; // dir: 0上 1下 2左 3右 const int mask[4] {0b1000, 0b0010, 0b0001, 0b0100}; return (v mask[dir]) ! 0; }提示只要移动前先读 hasWall就不需要额外做复杂碰撞检测因为迷宫地图本身已经把物理限制写死了。这个方法同样适用于后面自动寻路时的邻居扩展判断。4. 自动通关路径给玩家一条“后悔药”的 A* 寻路路径高亮是这个项目里比较亮眼的功能。玩家走不过去的时候点一下“提示路径”画面上就铺出一条从当前角色到出口的高亮路线。实现上常见的选择是 BFS 或者 A*这份代码里最可能落地的方案是 A*因为它天生自带代价函数后续如果要扩展到带权迷宫也不用推翻重写。4.1 为什么 A* 适合这个迷宫项目BFS 也可以但 A* 便于扩展如果迷宫只有 20×20BFS 和 A* 的性能差异几乎可以忽略两者都能找到最短路径。区别在于搜索范围BFS 会向外一圈一圈地扩散直到碰到终点A* 通过启发函数 f g h 引导搜索方向优先扩展“看起来离终点更近”的节点搜索的节点数会明显更少。这个项目里还有一个更现实的理由绘制路径时你会想统计步数或代价A* 的 g 值天然就是“从起点到当前节点的真实步数”可以直接拿来显示不需要再额外维护 visited 深度。启发函数我用曼哈顿距离因为迷宫只允许上下左右四个方向移动用欧氏距离会低估代价导致 A* 退化成接近 BFS 的搜索范围反而没了启发式搜索的优势。4.2 A* 核心实现从起点到出口的搜索循环怎么写我习惯把 A* 封装成一个独立函数输入起点和终点返回一组路径点。这里用一个一维 int 数组记录 parent 索引方便最后从终点回溯整条路径。// maze.cpp —— A* 寻路实现曼哈顿启发 QVectorQPoint Maze::findPath(int sx, int sy, int ex, int ey) const { const int dirs[4][2] {{0, -1}, {0, 1}, {-1, 0}, {1, 0}}; struct Node { int x, y, g, h; int parent; // 上一节点的索引-1 表示起点 }; QVectorNode nodes; QVectorbool closed(rows() * cols(), false); auto heur [](int x1, int y1, int x2, int y2) { return qAbs(x1 - x2) qAbs(y1 - y2); }; nodes.append({sx, sy, 0, heur(sx, sy, ex, ey), -1}); while (!nodes.isEmpty()) { int best 0; for (int i 1; i nodes.size(); i) { if (nodes[i].g nodes[i].h nodes[best].g nodes[best].h) { best i; } } Node cur nodes.takeAt(best); if (closed[cur.y * cols() cur.x]) continue; closed[cur.y * cols() cur.x] true; if (cur.x ex cur.y ey) { QVectorQPoint path; int idx nodes.indexOf(cur); while (idx ! -1) { path.prepend(QPoint(nodes[idx].x, nodes[idx].y)); idx nodes[idx].parent; } return path; } for (int d 0; d 4; d) { int nx cur.x dirs[d][0]; int ny cur.y dirs[d][1]; if (nx 0 || ny 0 || nx cols() || ny rows()) continue; if (hasWall(cur.x, cur.y, d)) continue; if (closed[ny * cols() nx]) continue; int ng cur.g 1; int nh heur(nx, ny, ex, ey); bool found false; for (int i 0; i nodes.size(); i) { if (nodes[i].x nx nodes[i].y ny) { found true; if (ng nodes[i].g) { nodes[i].g ng; nodes[i].parent nodes.indexOf(cur); } break; } } if (!found) { nodes.append({nx, ny, ng, nh, nodes.indexOf(cur)}); } } } return QVectorQPoint(); // open 表耗尽说明终点不可达 }把这段代码讲清楚需要抓住几个参数。closed 数组用一维索引存储作用等价于 BFS 的 visited避免同一节点被反复扩展。启发函数 heur 返回曼哈顿距离它不参与路径合法性判断只影响搜索顺序。parent 保存的是 nodes 容器里的索引回溯路径时从终点一路往前指到起点再用 prepend 调成从起点到终点的顺序。路径找到之后下一步是把 path.png 依次贴到路径经过的每个格子上。我通常在 MainWindow 里维护一个 QListQGraphicsPixmapItem*高亮前先清空旧的再遍历路径点添加新的 QGraphicsPixmapItem位置还是 x * cellSize 和 y * cellSize与角色坐标保持一致。提示如果返回的 QVector 为空说明迷宫生成出了问题最常见的原因是生成算法里某个方向的 mask 写反把墙打通到了错误的一侧。此时不要急着查寻路代码先打印几个关键格子的 cellKey 人工核对。5. 编译与运行排查六个高频翻车点与对应的解决顺序拿到这份源码之后很多人第一步不是改代码而是卡在编译和资源加载上。我整理了六个最高频的问题按它们出现的先后顺序排列从环境配置到逻辑错误一条条说基本能覆盖你遇到的大多数异常。5.1 报错 dependent ........\qt\5.15.2\msvc2019_64\include\qtwidgets 找不到这是解压位置和构建套件不匹配的典型症状。现象是 Qt Creator 打开 .pro 后编译刚开始就报“依赖路径不存在”后面跟着一长串..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets。原因有两个一是工程文件被放在多层嵌套目录里相对路径被拉得过长二是当前选择的构建套件 Kits 没有正确关联到 Qt 5.15.2。解决顺序我建议这样走先把整个工程解压到浅目录比如D:\QtWork\Maze杜绝中文路径和超长目录然后在 Qt Creator 左侧“项目”页里重新选择构建套件确认 Kit 的 Qt 版本是 5.15.2、编译器是 MSVC2019 64bit最后清理并重新 qmake再执行构建。多数情况下走到第二步就能过。5.2 用 VSCode 还是 Qt Creator命令行工具与资源系统的配合很多人习惯用 VSCode 配好 c/c 环境后直接在终端里敲 qmake、nmake。代码是能编但经常会遇到“程序能启动、贴图全部不显示”的问题。原因在于 Qt 的资源系统依赖编译期生成的 qrc 对象终端手动构建时必须保证 qmake 和编译器环境变量一致而 VSCode 默认环境里 Qt 的 bin 目录往往不在 PATH 中。我一般会建议把 VSCode 当作阅读器真正跑工程还是用 Qt Creator。如果你非要命令行最简单的方式是打开 Qt 自带的“Qt 5.15.2 (MSVC 2019 64-bit)”命令行工具然后依次执行 qmake、nmake这样环境变量才是完整对齐的。5.3 角色移动穿墙、窗口 resize 后格子错位穿墙的原因九成是方向位映射错乱。现象是你能看到墙按方向键却直接穿过去或者明明感觉应该能走却撞到空气墙。解决方法是打印当前位置 cellKey人工对照上一章的贴图表确认 up、down、left、right 的位定义和键盘事件里的 dir 一致。另一个常见现象是 resize 窗口后迷宫被裁掉格子显示不全原因是没有重写 resizeEvent 去重置 QGraphicsView 的缩放。我习惯在 resizeEvent 里调用void MainWindow::resizeEvent(QResizeEvent* event) { QMainWindow::resizeEvent(event); ui-graphicsView-fitInView(scene-sceneRect(), Qt::KeepAspectRatio); }这里参数 Qt::KeepAspectRatio 很关键它能保证迷宫不变形代价是背景两侧会有留白属于可以接受的取舍。5.4 每次运行迷宫都一样且路径高亮不出来程序每次启动生成的迷宫完全一致这是随机种子没有被正确初始化。Qt5 里我一般用qsrand(QTime::currentTime().msecsSinceStartOfDay())或者在生成函数里传std::random_device。另一个极端是调试时把种子写死也会出现“固定地图”的现象测试没问题交付给用户时忘了改回去。路径高亮不出来则多半是 path.png 没有加载成功。检查 image.qrc 里有没有包含这个文件引用路径是否写成:/images/path.png而不是相对路径。出现空白界面时先用 qWarning 打印每个 QPixmap 的 isNull 状态这是最快定位方法。6. 进阶一档预渲染迷宫贴图与固定种子复现如果只是把这份源码跑起来那它就是一个能玩的小项目。但你真的想把它拿去做课程设计或者游戏原型我建议先做两件事把逐格贴图改成一次性预渲染整张迷宫图再把随机种子暴露到界面上。这两件事分别解决性能问题和调试问题改动量不大收益却很明显。6.1 用 QImage 一次性渲染迷宫地图逐格贴图的性能瓶颈在场景里 Item 的数量。一个 21×21 的迷宫只有 441 个 Item但如果你把迷宫调到 100×100就是 10000 个 QGraphicsPixmapItem拖拽和缩放时帧率会明显下降。预渲染的思路是用 QPainter 把整张迷宫画成一张大 QImage再交给 QGraphicsScene 显示场景里只有一个图像 Item。// maze.cpp —— 把整张迷宫预渲染为一张 QImage QImage Maze::renderToImage(int cellSize) const { int w cols() * cellSize; int h rows() * cellSize; QImage image(w, h, QImage::Format_ARGB32); image.fill(Qt::white); QPainter painter(image); painter.setPen(QPen(QColor(black), 3)); const int dirs[4][2] {{0, -1}, {0, 1}, {-1, 0}, {1, 0}}; for (int y 0; y rows(); y) { for (int x 0; x cols(); x) { for (int d 0; d 4; d) { if (!hasWall(x, y, d)) continue; int x1 x * cellSize, y1 y * cellSize; int x2 x1 cellSize, y2 y1 cellSize; switch (d) { case 0: painter.drawLine(x1, y1, x2, y1); break; case 1: painter.drawLine(x1, y2, x2, y2); break; case 2: painter.drawLine(x1, y1, x1, y2); break; case 3: painter.drawLine(x2, y1, x2, y2); break; } } } } return image; }cellSize 参数决定渲染分辨率建议 16 到 32 之间网格够清晰图片内存占用也不会太大。显示端仍然保留原来的缩放逻辑。这个方案的缺点是不再直接复用它那组编号 png但换来的是场景 Item 数量从几万降到 1交互流畅度完全是另一个量级。6.2 固定随机种子复现问题调试迷宫算法的后悔药随机迷宫最让人头痛的不是生成不出来而是用户报了一个 bug你在本机怎么都复现不了。原因很简单每次启动用的随机种子都不同地图从根源上就变了。我的习惯是在 MainWindow 里加一个 QSpinBox 输入种子生成前固定调用qsrand(seed)并把当前种子写进日志。这样用户反馈“第 3 关走到右下角就卡死”我可以拿同一个种子重新生成地图用 findPath 验证终点是否可达几步就能定位到是生成算法问题还是寻路问题。这套思路同样适用于任何带随机过程的 Qt 项目。我早年调试随机迷宫时吃过亏同一个迷宫在别人机器上无解我却复现不了因为程序每次运行都换了种子。从那以后我每个迷宫工程都会预留一个种子入口先固定种子跑一遍检索连通性再交给玩家随机这套习惯能帮你省掉很多查不出来的“玄学”问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表