简介:一份面向Qt初、中级开发者的随机迷宫生成与路径查找完整工程,适合学习图形界面与算法结合实践的人群。工程基于Qt框架,结合并查集数据结构管理迷宫连通关系,并采用A算法完成最短路径规划,既可作为课程设计参考,也能作为迷宫类小游戏或路径规划工具的基础模板。压缩包共66个文件,包含可执行文件与运行所需的dll、qss界面样式、ui界面定义,以及cpp源码、h头文件、qrc资源文件和Visual Studio工程配置等,便于直接编译调试,整体约28.14MB。目前已有353人学习浏览,代码与资源组织较为清晰。通过阅读迷宫生成、并查集合并与A搜索的具体实现,可以掌握二维网格建模、集合维护和启发式搜索的落地写法,同时熟悉QGraphicsView绘图交互的基本流程,是一份将数据结构和算法落实到实际界面的完整示例。
1. 随机迷宫不是玩具:从并查集到 Qt 界面,一份能直接跑起来的完整方案
把随机迷宫和 Qt 绑定在一起,看起来是个课设级别的题目,实际做下来才发现里面全是细节:生不成保证连通的迷宫、画出来的墙和对不上、路径获取结果比肉眼路线贵十倍、窗口缩放后坐标全偏。这篇笔记围绕“Qt随机迷宫及路径获取”这套资源展开,核心是用并查集生成一个矩形网格迷宫,再用 BFS 获取最短路径,最后用 Qt 的 QPainter 渲染并支持鼠标交互。适合正在写课程设计、想给自己的小工具加个迷宫模块、或者想理解并查集在带权连通性上怎么落地的读者。下面按“生成算法选型 → 界面渲染 → 路径获取 → 踩坑记录 → 进阶玩法”的顺序,把每一行代码和参数拆开讲,资源里所有源码能直接编译运行。
2. 随机迷宫生成:为什么我用并查集而不是深度优先
2.1 三种常见生成算法对比,先搞清各自边界
随机迷宫生成算法不少,常见的就三类:递归回溯(DFS 挖墙)、随机 Prim、基于并查集的 Kruskal 式拆墙。资源里用的是并查集版本,初次接触的人可能不理解:DFS 挖墙不也很快吗?随机性也很好吗?是的,但 DFS 生成的迷宫有强烈的长走廊偏向,因为递归回溯一旦深入就一条路走到黑;随机 Prim 能生成更均匀的分支,但需要维护一个墙集合,内存和随机数调用都更频繁;并查集版本从“所有墙”开始,随机抽取一堵墙,如果墙两侧的格子分属两个不同集合,就把这堵墙拆掉,然后合并集合,直到所有格子连通。这个过程其实就是 Kruskal 最小生成树算法的变体,它能生成理论上最均匀的迷宫,没有明显偏向,而且天然保证全图连通,不会出现某个区域完全孤立的情况。
从工程角度看,并查集版的代码量最少,逻辑最不容易出错。DFS 需要递归函数和栈控制,Prim 需要一个动态墙表,并查集只需要一个一维数组的 parent 指针和一堵墙的列表。这就是资源里选它的原因。迷宫本质是一个生成树,树边数是格子数减一,并查集合并 n 次即可,复杂度近似 O(n log n),在 200×200 的网格上也能秒级完成。
2.2 并查集模板与迷宫格子的映射关系
在写迷宫生成前,先把数据模型定清楚。资源里的迷宫是一个二维网格,每个格子有四面墙,上、下、左、右。用 bool 数组 wall 存储,比如 wall[grid_id][4] = true 表示墙存在。grid_id 从 0 到 rows*cols-1。并查集操作的对象就是格子编号,而不是墙。下面是基础模板:
class UnionFind { public: UnionFind(int n) { parent.resize(n); rank.resize(n, 0); for (int i = 0; i < n; i++) parent[i] = i; } int find(int x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; // 路径压缩 x = parent[x]; } return x; } bool unite(int a, int b) { int ra = find(a), rb = find(b); if (ra == rb) return false; if (rank[ra] < rank[rb]) std::swap(ra, rb); parent[rb] = ra; if (rank[ra] == rank[rb]) rank[ra]++; return true; } private: std::vector<int> parent; std::vector<int> rank; };find里用的路径压缩是迭代写法,比递归省一点栈空间,rank 按秩合并避免树退化成链。unite返回 bool,正好用来判断“这堵墙两侧的格子是否已经连通”。返回值是 true 说明墙可以拆,false 说明拆了会形成环,跳过。
初始化所有墙都是 true,然后把所有相邻格子之间的墙加入一个待拆列表。注意一条墙只记录一次,比如格子 (r, c) 和右侧格子 (r, c+1) 之间的墙,属于格子 (r,c) 的右墙,同时也属于 (r,c+1) 的左墙,但只记录一个方向。实现上可以规定只存“右墙”和“下墙”,这样每个格子最多记录两堵墙。资源里的 wall 数组只存四个方向的 bool,生成时只操作右和下,另一个方向通过相邻格子体现。这个映射不要搞混,否则会出现拆了这堵墙但另一侧还是实墙的错位。
2.3 生成主流程代码与随机种子控制
生成流程分三步:初始化 → 随机打乱墙列表 → 遍历合并。很多人以为只要遍历墙列表,随机顺序就行,但别忘了用随机数洗牌。这里用 std::shuffle 配合随机引擎,而不是 rand() % n 直接选,因为前者能保证均匀分布。代码里留个 seed 参数,方便复现同一个迷宫:
void MazeGenerator::generate(int rows, int cols, unsigned int seed) { m_rows = rows; m_cols = cols; int total = rows * cols; m_wall = QVector<QVector<bool>>(total, {true, true, true, true}); // 上右下左 UnionFind uf(total); // 收集所有可拆的墙:(格子A, 格子B, A的墙方向) std::vector<std::tuple<int, int, int>> edges; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { int id = r * cols + c; if (r > 0) edges.emplace_back(id, id - cols, 0); // 上墙 if (c > 0) edges.emplace_back(id, id - 1, 3); // 左墙 // 右墙和下墙会在相邻格子那边重复,这里不重复加 } } std::mt19937 rng(seed); std::shuffle(edges.begin(), edges.end(), rng); for (auto& [a, b, dir] : edges) { if (uf.unite(a, b)) { // 拆掉a与b之间的墙 m_wall[a][dir] = false; // 同时拆掉b对应方向的墙 int opposite = dir == 0 ? 2 : (dir == 2 ? 0 : (dir == 1 ? 3 : 1)); m_wall[b][opposite] = false; } } }注意 edges 只收集了上墙和左墙,每个方向只被记录一次,拆墙时同时更新两侧格子的 wall 状态,保证数据一致。std::mt19937是梅森旋转伪随机数生成器,seed 相同则迷宫相同,调试时可以固定一个 seed 复现问题。这里没有处理入口和出口,因为入口出口是外部需求,可以在生成后手动强制打开左上角和右下角的墙,资源里就是这么做的。如果想让入口固定为 (0,0) 的左上墙,出口为 (rows-1, cols-1) 的右下墙,在生成后把这两面墙设为 false 即可。
这里有个容易钻牛角尖的地方:理论上并查集生成的迷宫是一棵树,树中任意两点路径唯一,所以“最短路径”就是“唯一路径”。但实际资源里路径获取用的是 BFS,因为用户可能需要从任意一个格子走到任意另一个格子,树路径就是唯一最短路径,BFS 只是把它找出来而已。BFS 还能顺便记录步数,方便显示。
3. Qt 界面渲染与交互:把迷宫画出来,别让坐标乱飞
3.1 QPainter 绘图坐标与格子尺寸换算
迷宫数据是 rows×cols 的二维数组,界面是一个 QWidget 或 QGraphicsView。资源里用的是 QWidget + QPainter,简单直接,重写 paintEvent 即可。核心是把“格子坐标 (r, c)”换算成“像素坐标”。设格子边长为 cellSize,迷宫左上角起点坐标为 startX、startY,则第 r 行 c 列的格子左上角像素坐标为:
int x = startX + c * cellSize; int y = startY + r * cellSize;墙的厚度为 wallThickness,一般取 2 或 3。画墙时不是画一个细线,而是画一个小矩形,否则粗细不均匀。比如格子 (r,c) 的上墙,如果存在,就画一个从 (x, y) 到 (x + cellSize, y + wallThickness) 的矩形。右墙画矩形 (x + cellSize - wallThickness, y, wallThickness, cellSize)。下墙和左墙同理。注意边界格子的外边框也要画,比如第 0 行的上墙、第 0 列的左墙,这些墙虽然不在 edges 列表里,但初始值都是 true,画出来刚好围成迷宫外框。
这里最容易翻车的是 cellSize 的取值。如果窗口固定为 800×600,迷宫是 50×30,那么 cellSize 不能简单取 800/50=16,因为还要留边距和内墙厚度。我一般这样计算:
int margin = 20; int wallThickness = 2; cellSize = 12; int mazeWidth = cols * cellSize + wallThickness; int mazeHeight = rows * cellSize + wallThickness;然后在 paintEvent 里根据窗口大小动态调整 margin,保证迷宫居中。窗口 resize 时要更新缓存,避免失真。如果窗口拖得很大,不建议直接拉伸,因为墙厚和格子大小会失去比例,最好是重新计算 cellSize,但这样迷宫会变样。资源里的做法是固定 cellSize,窗口小于迷宫尺寸时用滚动区域,或者把迷宫作为固定尺寸的 QImage 渲染后贴到 QLabel 上。这是两种方案的分界线:交互需求强用 QWidget 自绘,纯展示用 QImage。
3.2 鼠标点击转格子坐标与路径触发
用户点击迷宫内部,要转换成格子坐标,然后以该格为起点、迷宫终点为终点,运行 BFS 并高亮路径。这个交互看起来简单,实际上大量考校边界判断。鼠标点击事件里拿到的 pos 是控件坐标系,需要减去 startX、startY,然后除以 cellSize:
void MazeWidget::mousePressEvent(QMouseEvent* event) { int x = event->pos().x() - startX; int y = event->pos().y() - startY; if (x < 0 || y < 0 || x >= cols * cellSize || y >= rows * cellSize) { return; } int c = x / cellSize; int r = y / cellSize; if (r >= rows || c >= cols) return; computePath(r, c); update(); }判空条件里最容易漏的是点击在右下角外侧墙的位置,x 可能刚好等于 cols*cellSize,这样 c 就等于 cols,数组越界。所以条件要写 >= ,不要写 >。另外鼠标按下后移动产生的拖拽不应触发多次路径计算,资源里加了一个 isMouseDown 标志位,并在 mouseMoveEvent 里也做相同转换,可以实现“拖动起点,路径实时更新”。这对用户体验提升很大,跑通后你会觉得是个加分的功能。
绘制路径时,把路径上的格子填成半透明色,或者画一条经过格子中心点的折线。折线的方式更直观,但要注意折点必须位于格子中心,穿墙效果才不明显。代码:
for (const QPoint& pt : pathPoints) { QPoint center(startX + pt.y() * cellSize + cellSize/2, startY + pt.x() * cellSize + cellSize/2); pathLine << center; } painter.drawPolyline(pathLine);这里 pathPoints 里的 QPoint 的 x 存行号、y 存列号,和绘制坐标的 (x, y) 正好相反,容易懵。建议结构体里用 row、col 命名,别用 x、y 混。drawPolyline 拿着 QPolygon 时,QPoint 的 first 和 second 分别代表 x 和 y,所以要么统一用 (c, r),要么转换时注意。我从这个坑里爬出来就把数据模型统一成了 QPoint(col, row),绘制时再换算。
4. 路径获取算法:BFS 求最短路径的 Qt 落地与边界情况
4.1 BFS 在迷宫网格上的队列与父节点回溯
路径获取可以由 DFS 或 BFS 实现,资源里选择 BFS,因为 BFS 在无权图中保证最短步数,而且迷宫是一个树结构,BFS 深度最多是格子数,不会爆栈。BFS 的过程是:从起点格子开始,把它加入队列,标记访问;弹出队首,依次检查上右下左四个方向,如果对应墙是 false(已被拆掉)且相邻格子未访问,就记录父节点,再加入队列。直到队列弹出终点,然后从终点沿着 parent 回溯到起点,反转得到路径。
具体代码:
QVector<QPoint> MazeGenerator::bfsPath(const QPoint& start, const QPoint& end) { QVector<QPoint> path; int rows = m_rows, cols = m_cols; QVector<QVector<int>> parent(rows, QVector<int>(cols, -1)); std::queue<QPoint> q; parent[start.row][start.col] = start.row * cols + start.col; // 起点指向自己 q.push(start); const int dr[4] = {-1, 0, 1, 0}; const int dc[4] = {0, 1, 0, -1}; const int dirIdx[4] = {0, 1, 2, 3}; // 上右下左 while (!q.empty()) { QPoint cur = q.front(); q.pop(); if (cur == end) { break; } int cr = cur.row, cc = cur.col; int curId = cr * cols + cc; for (int i = 0; i < 4; i++) { int nr = cr + dr[i]; int nc = cc + dc[i]; if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue; // 检查当前格子与相邻格子之间的墙是否打开 if (!isWallOpen(curId, i, nr, nc)) continue; int nId = nr * cols + nc; if (parent[nr][nc] == -1) { parent[nr][nc] = curId; q.push(QPoint(nr, nc)); } } } if (parent[end.row][end.col] == -1) return path; // 没找到,返回空 int step = 0; int curPos = end.row * cols + end.col; while (curPos != start.row * cols + start.col) { int r = curPos / cols; int c = curPos % cols; path.push_back(QPoint(r, c)); curPos = parent[r][c]; if (step++ > rows * cols) break; // 防御环 } path.push_back(start); std::reverse(path.begin(), path.end()); return path; }代码里parent矩阵的初值是 -1,既是判访问标记也是父指针。由于迷宫中任意两点间只有唯一路径,BFS 一定会找到终点,但防御性检查不能省。isWallOpen是一个辅助函数,根据方向 i 判断当前格子 curId 与相邻格子 (nr, nc) 之间的墙是否为 false。注意当 nr 越界时返回 false。方向数组的顺序必须和墙索引一致:0 对应上、1 对应右、2 对应下、3 对应左。这个索引顺序在生成、绘制、路径中但凡有一处不一致,就会出难查的 bug。资源里把isWallOpen这样实现:
bool isWallOpen(int curId, int dir, int nr, int nc) { int cr = curId / m_cols; int cc = curId % m_cols; if (dir == 0) return !m_wall[curId][0]; // 上墙 if (dir == 1) return !m_wall[curId][1]; // 右墙 if (dir == 2) return !m_wall[curId][2]; // 下墙 if (dir == 3) return !m_wall[curId][3]; // 左墙 return false; }但这样有一个问题:当前格子 curId 的右墙和右边格子的左墙是同一面墙,如果右墙被拆,两边都存了 false。上面仅检查当前格子的方向是对的。更稳妥的写法是同时检查相邻格子对应反向墙,但既然生成时同步处理了,就无需重复检查。记住:墙的状态是全局一致的,不要一边拆墙只更新一侧。
4.2 路径高亮与步数显示:数据驱动重绘
路径计算完成后,界面要展示。资源中维护一个QVector<QPoint> m_path成员变量,在 paintEvent 里除了画墙,还要画路径。画路径有几套方案供选择:
- 格子填充:将路径经过的格子填充为浅蓝色,思路简单,视觉整齐,但会覆盖格子背景,颜色深了看不清墙。
- 中心折线:用 QPen 画一条穿过格子中心的线,线宽取 cellSize/3,颜色取的醒目色。这种最直观,因为折线不会越过墙。
- 圆点位:在格子中心画小圆,圆之间不连线,最小改动但不如折线清晰。
资源里默认用的是中心折线,因为实验结果表明,当 cellSize 较小时(例如 12px),格子填充会让迷宫看起来一团糟,而折线很干净。折线路径由 QPolygon 保存,宽度固定为qMax(2, cellSize / 3)。这里无需抗锯齿,因为迷宫本身就是像素风。绘制路径的时机放在画墙之前或之后都可以,但建议画墙之后,让路径浮在墙的上方,这样被遮挡的感觉少一些。
步数显示可以直接用路径长度减一,因为每一步跨一个格子。在状态栏里显示“路径长度: N 步”,注意如果点击的是终点自身,路径长度为 1,步数应为 0。资源里在computePath里做了特判:如果起点等于终点,直接清空路径而不跑 BFS,因为 BFS 也能跑出来,但没必要。
4.3 性能边界:多大的迷宫还能实时响应
有读者问过:这个迷宫能支持 200×200 吗?并查集生成 40000 个格子,BFS 遍历最多 40000 个节点,耗时大约几十毫秒,完全没问题。但绘制环节才是瓶颈,因为 paintEvent 要循环 40000 格子的四面墙,每次 QPainter::drawRect 都要调用 OpenGL 或软渲染,200×200 的迷宫可能有近 16000 堵墙,每次重绘都会掉帧。资源性能实测(基于某开发者的笔记本):100×100 迷宫,绘制约 15ms;200×200 约 80-100ms,拖动窗口时明显卡顿。所以资源里默认开启“缓存迷宫背景”功能:在迷宫生成后,把迷宫渲染到一张 QImage,paintEvent 只需把 QImage 平铺到控件上,路径变化时单独重绘路径图层,这样即便 200×200 也能 60fps 更新路径。这个技巧很关键,值得参考:
void MazeWidget::renderMazeCache() { m_cache = QImage(width(), height(), QImage::Format_ARGB32); m_cache.fill(Qt::white); QPainter painter(&m_cache); drawWalls(painter); } void MazeWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.drawImage(0, 0, m_cache); drawPath(painter); }m_cache 只在迷宫重新生成时更新,路径变化时只重绘路径,性能提升明显。但注意:如果窗口尺寸变了,m_cache 的分辨率要重新生成,否则模糊。处理方式是重写 resizeEvent,重新调用 renderMazeCache。
5. 避坑 / 常见问题:生成、渲染、路径三处实战踩坑记录
5.1 迷宫生成后仍有封闭区域,部分格子永远走不到
现象:生成 30×30 迷宫,点击某些格子成不了路径,BFS 返回空。
原因:并查集合并时没有把所有相邻格子之间的墙都加入 edges。如果只加了上墙和左墙,确实验证了每个方向只加一次,但要注意对第 0 行和第 0 列的格子,上墙或左墙可能不存在,而右墙和下墙以及内部格子之间的对应关系是否全覆盖。常见的错误是漏加边界墙,但边界墙本来就不能拆,所以不影响连通性。更隐蔽的错误是在拆墙时opposite方向计算错了。比如当前格子 (r,c) 的右墙方向 dir=1,相邻格子 (r,c+1) 的左墙方向应为 3,如果计算成 2,就会拆掉下方墙,导致两格之间的实际墙仍然存在,但墙数据却显示已拆,渲染与连通逻辑互相矛盾。
解决:用一个调试函数把迷宫所有格子的墙状态导出为 ASCII 图,用#表示墙,空格表示通路。然后从左上角 BFS 检查能否访问所有格子,并统计未被访问的格子数。这个问题通常跑一次就暴露。资源里有一个assertMazeIntegrity()函数,专门在生成后检查每个格子的上墙是否与上一格子的下墙状态一致,不一致直接打印。我发现大多数“生成后走不通”都是这个原因。
5.2 点击格子路线偏了半个格子,路径画在墙上
现象:鼠标点击迷宫内部,高亮路径却从旁边的格子上穿过,或者折线起点偏离点击位置。
原因:坐标换算用了错误的起始点。startX、startY是指迷宫左上角墙体最外侧坐标,而不是第一个格子中心的坐标。格子中心坐标是startX + wallThickness + col * cellSize + cellSize/2。如果用startX + col * cellSize + cellSize/2,对于墙厚为 2、cellSize 为 12 的情况,偏移了 2 个像素,视觉上不至于发现,但墙厚变大(如 6)时偏差就很明显。另一个原因是点击事件里没有减去 wallThickness,把 x 直接除以 cellSize,导致第 0 列之后的格子都偏移一列。这在小 cellSize 下很难察觉,但一旦迷宫尺寸大就容易左下角点击无效。
解决:统一封装一个坐标换算函数:
QPoint widgetToMazeCell(const QPoint& pos) const { int x = pos.x() - startX - wallThickness; int y = pos.y() - startY - wallThickness; if (x < 0 || y < 0) return QPoint(-1, -1); int c = x / cellSize; int r = y / cellSize; if (r >= rows || c >= cols) return QPoint(-1, -1); return QPoint(c, r); }注意 if (x < 0) 要放在减 wallThickness 之后,因为边界格子的墙占位可能让角度点击产生负偏移。用统一函数后,绘制、点击、路径坐标就再没偏移过。
5.3 路径闪烁严重,拖动窗口时迷宫整个重画太慢
现象:每次 update() 后整窗重绘,路径出现闪烁,窗口缩放时一片白。
原因:没有使用双缓冲,也没有缓存迷宫背景。Qt 里默认对于 QWidget,paintEvent 可以自行双缓冲,但如果你在 paintEvent 里做大量 drawRect,还是可能闪烁。更常见的性能因素是 QPainter 每帧都画 8000 多次墙,这种 CPU 绘制无法满足高帧率。
解决:按资源里的缓存迷宫背景方案,把墙绘制到 QImage,每次 paintEvent 先 drawImage 把背景贴上去,然后只绘制路径。如果路径也是多段线,不会太慢。如果想进一步消除闪烁,可以设置setAutoFillBackground(false),或改用QOpenGLWidget,但 QImage 缓存方案已经能解决绝大多数场景。另外注意生成新迷宫时要调用renderMazeCache(),但路径清除时不要重新渲染背景,否则又卡回去。
5.4 并查集 find 递归爆栈,大迷宫直接崩溃
现象:生成 300×300 迷宫时程序崩溃,调用栈显示在 find 里。
原因:并查集 find 如果用递归写法,在未压缩路径时,最坏深度等于格子数,递归栈可能溢出。因为这里用了路径压缩和按秩合并,理论上高度很小,但如果初始化时 rank 处理错误,或者 find 只压缩了部分路径,极端情况下仍可能崩。
解决:改用迭代 find(如前面代码所示),并将 rank 合并写对。资源里还提供了一种更简单的方案:因为迷宫生成只需要判断连通性,可以不用 rank,直接用parent[ra] = rb,同时 find 里做全路径压缩,性能足够。从那次崩溃后,我所有的并查集实现都统一用迭代版本,另加一个递归安全版本在调试版里保留断言,工程上求稳不炫技。
5.5 路径获取结果走回头路,折线出现重复节点
现象:BFS 返回的路径中,相邻两点没有直接连通,折线上出现回头方向或穿过墙。
原因:回溯父节点时存的是当前格子的坐标,但终点可能不在 parent 记录中,因为 BFS 可能从其它方向搜索时先访问了终点,但中间没有处理当前格子的墙状态。排查后发现是在isWallOpen里使用了m_wall[curId][dir],如果当前格子 curId 与相邻格子之间没有墙,但相邻格子本身已经访问过,那仍然会跳过。正常流程中,BFS 应该只检查当前格子与相邻格子之间的墙是否打开,而不需要检查相邻格子是否被访问过,访问标记要单独管理。上面的代码用 parent 双重标识没有问题,但如果把访问判断写在isWallOpen内,就会漏掉某些合法邻居。
解决:保持访问标记和墙检测分离。parent 初始化为 -1 只做访问标记,墙检测只依赖isWallOpen,两者不要耦合。我见过一份代码把墙检测做在 if (parent[nr][nc] == -1) 外面,结果导致已经访问的格子被跳过,但当终点被先访问时路径却还没记录完整父链,回溯时就断了。修复方法是在 BFS 循环里严格按顺序:先判断合法,再判断墙,最后判断访问标记。
6. 进阶玩法:随机种子复现、生成动画与批量导出迷宫
走到这里基础功能已经完整,下一步可以给这套资源加三个很实用的能力,每个都能单独拿出去展示。第一个是随机种子可视化:把生成迷宫的 seed 显示在标题栏或状态栏,点击“再生成”按钮时,如果按住 Shift,则沿用上次 seed 增加 1 再生成,这样每次都能重现同一个迷宫序列。这对调试路径算法和做测试用例特别有用。实现上只需要把seed参数保存为成员变量并增加一个递进逻辑,不需要改生成器。种子复现这件事看起来简单,但实践久了你会感激它:某天你发现渲染 bug,没有固定种子,你就要重新生成一个迷宫碰运气复现,玄学调试最低效,种子就是后悔药。
第二个能力是生成过程动画。并查集生成迷宫的每一条拆墙记录都可以保存下来,生成后逐帧播放,每帧绘制一个拆墙步骤,效果非常像“迷宫生长”。资源里用一个 QVector 记录每次成功拆墙的两个格子坐标,播放时用 QTimer 以 10ms 间隔刷新,重绘时只画到当前步骤的位置。因为迷宫背景缓存被拆,需要临时关闭缓存,但动画结束后重新缓存。注意播放期间禁止用户点击,否则路径计算和动画互相干扰。动画本身不难,难在帧率控制,QTimer 的 interval 最少 16ms,否则太快看不清;不要用 sleep 阻塞主线程,那会让界面冻结。此处常见做法是用:
m_timer = new QTimer(this); m_timer->setInterval(20); connect(m_timer, &QTimer::timeout, this, &MazeWidget::nextAnimationStep);每个 timeout 画出其中的若干步,比如一次画 5 步,否则几百格的迷宫要播十几秒。
第三个能力是批量导出迷宫图片。将迷宫渲染成 QImage,用QPainter把墙和路径画上去,保存为 PNG。可以做一个“导出当前迷宫”按钮,也可以连续生成多个不同 seed 的迷宫并保存到固定目录。批量导出最常见的坑是图片尺寸固定,但迷宫多尺寸不同。资源里导出逻辑会先计算迷宫像素宽高,然后创建一个新的 QImage,并拷贝迷宫背景。这里注意 QImage 默认是透明背景,先用白色填充,否则导出图片背景是黑的。导出分辨率建议使用 2x 或 3x 超采样,再用setDevicePixelRatio让 Qt 自动缩放,这样可以获得更平滑的墙拐角。
这三个进阶功能做完,这套资源基本就是一个编译一次、能演示、能打样、能跑课程的完整工具了。回过头来看,整个迷宫项目虽然看着简单,但把并查集、网格建模、坐标变换、BFS 和 Qt 事件环串起来,每个环节都要严谨。路径算法本身不难,真正花时间的都在边界条件和数据一致性上。从那以后,我每次写涉及网格坐标的代码都会强制先写一个换算函数并加断言,再写业务逻辑。希望这份笔记能帮你少踩几次同样的坑,也希望你下载源码后不只会运行,还能顺着这个思路改造出自己的迷宫小工具。希望帮到你。
本文还有配套的精品资源,点击获取