简介:一份基于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 界面上画出来是左上角那块格子。游戏逻辑里的“上下左右”和程序里的“row+1 / row-1 / col+1 / 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::vector<int> 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 游戏画面里最容易被忽略的细节——不设置 NoPen,Qt 会给每个矩形加一个 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:"level='asInvoker' uiAccess='false'"作为运维手段,也可以建议目标用户安装“Microsoft Visual C++ 2015-2022 Redistributable (x64)”,但发布包里能自带的库就尽量带上,用户安装一年半载不关心这个,启动报错就会直接来敲你。
5.4 角色移动时出现残影或画面闪烁
现象:角色快速移动时,身后会有拖影,窗口内容在缩放时跳动闪烁。
原因:QGraphicsView 默认没有开启双缓冲。如果你直接在场景里大量调用 addItem 和 removeItem,绘制时没有缓冲就很容易出现闪烁。
解决:默认现代 Qt 版本(5.4 以后)对 QGraphicsView 是启用双缓冲的。如果还闪,检查是不是你用了 QWidget::repaint() 强制立即重绘。把它替换成 QWidget::update(),让 Qt 把重绘并入事件循环里合并调度。
如果问题依旧,检查是不是在 paintEvent 里做了复杂计算——迷宫场景如果直接走 QGraphicsView,QGraphicsScene 本身已经处理好了刷新,额外重写 paintEvent 反而制造闪烁。
5.5 界面字体模糊或窗口缩放后迷宫错位
现象:在 1080p 屏幕上开了 125% 缩放,Qt 界面字体发虚,迷宫格子之间有白缝。
原因:Qt 默认未启用高 DPI 缩放。Windows 下如果你没有声明 Qt::AA_EnableHighDpiScaling,Qt 会按逻辑像素画,物理像素密度高时文字被拉伸发虚,图形元素因为整数倍缩放不对齐出现缝隙。
解决:在 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() 方法能够返回指定区域内的所有图形项。你可以用来做“到达终点触发区域”的检测,比定时查坐标更通用——如果你的终点是一个不规则区域或陷阱区,用它就能统一处理。
QList<QGraphicsItem*> 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::queue<std::pair<int,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 节搭场景,最后回来处理按键。每一步都能单独验证,比一次性写完整份项目要稳得多。希望帮到你。
本文还有配套的精品资源,点击获取