简介:一份基于C++与Qt实现的图形化数字华容道课程设计作品,面向需要完成高级语言程序设计大作业或学习Qt图形界面开发的学生。项目从VS中字符版Szhrd类起步,再到Qt Creator中构建多个不同难度窗口、设计UI绘图与槽函数跳转,完整展示了控制台应用向图形界面迁移的工程思路。压缩包共126个文件,以41个cpp源文件、35个h头文件和31个ui界面文件为核心,辅以mp4/mp3/wav资源用于彩蛋视频与背景音乐,另含pdf课程设计报告,整体仅6.17MB。已有209人学习下载。通过源码可学习不同难度关卡的生成与判定、窗口跳转、按钮点击交互、音效播放及彩蛋触发机制;高难度通关后还会播放happy猫视频,细节完整。配套报告梳理了开发流程与类设计,便于快速理解工程结构,适合课程设计参考或Qt入门进阶。
1. 为什么拿数字华容道当 Qt 图形界面大作业:一个经典题目的三个隐藏价值
数字华容道看起来只是个小游戏,却是 C++ 课程设计的宝藏题目:数组索引映射、状态判定、事件驱动、随机数用法全串起来了。基于 Qt 实现图形界面后,它比学生管理系统更像一个产品,答辩可讲的点也更多——算法、界面绘制、打包部署,每一层都有实打实的代码量。这也是为什么我劝人做这个题目时,会让他直接上 Qt 而不是控制台版:控制台证明懂语法,Qt 版证明懂程序结构。
这个题目适合第一次用 Qt 写界面、想在报告里展示算法细节、以及需要一份答辩现场不翻车的课程设计的人。后面按我自己做过的 Qt 5.15.2 方案讲,从棋盘算法到报告结构到发布踩坑,一条线走完。
2. 先把游戏逻辑写对:可解性判断、c++ 随机数打乱与胜利检测
多数人拿到这个题目会先画界面,我的习惯相反:先把棋盘逻辑写成一个与 Qt 无关的 Board 类,用命令行跑通,再花一个晚上套界面。好处是调试时可以只看逻辑,不用管鼠标坐标和重绘;课程设计报告里的“模块划分”也顺理成章变成 Board 和 Widget 两层。
2.1 数字华容道的核心数据结构:一维数组还是二维数组
数字华容道的棋盘是 n×n 网格,最常见的是 3×3(八数码)和 4×4(十五数码)。教材里通常建议用二维数组 int board[4][4] 存,直观但写移动逻辑时要反复算行列,打乱时还要双重循环。我在这里用一维数组加索引换算,理由有两个:一是界面层遍历格子和 QPainter 坐标换算,用 row * size + col 一行就能取到元素;二是 std::vector 可以直接交给 std::shuffle 打乱,二维数组没有这么方便。
关键约定就一条:数组里存 1 到 n²-1 的数字块,0 表示空白格,还原态就是[1, 2, 3, ..., 15, 0],空白在右下角。这个约定定下来后,移动、胜利判定、打乱都围绕它写,不要在代码里混用 -1 或者 n*n 表示空白,否则后面找 bug 会怀疑人生。
还有一个选型问题常被答辩老师问:为什么不用 QVector 而是 std::vector ?因为我想让 Board 类完全不依赖 Qt 头文件,这样它可以用任意 C++ 工程编译,报告里也能写“逻辑层与界面层解耦”。std::vector 配合 std::shuffle 是 C++11 之后的标准做法,QVector 虽然也提供了类似接口,但把逻辑层绑死在 Qt 上了。
2.2 用 std::shuffle 与 c++ 随机数打乱棋盘,必须绕开“死局”陷阱
随机打乱是新手翻车重灾区。直接 std::shuffle 确实能把棋盘打乱,但大约一半的排列是死局——无论如何滑动都拼不还原。这是数字华容道的数学性质决定的:每次移动交换空白和相邻数字块,相当于一次置换,而 4×4 棋盘上可达的排列只占全部排列的一半。
判断可解性的标准做法是算逆序数。把棋盘按从上到下、从左到右展开成序列,剔除空白块,统计每个数前面比它大的数字个数之和:
- 奇数宽(3×3):逆序数为偶数则可解
- 偶数宽(4×4):逆序数加上空格所在行(从底部数,行号从 0 开始)为偶数则可解
因为我在打乱后把空白固定在右下角,空格从底部数的行号恒为 0,所以偶数宽的条件退化为“逆序数为偶数”。这也是为什么下面代码里 isSolvable 写全了两种分支:报告里能讲清楚通式,代码又能在当前场景下工作。
// board.h #ifndef BOARD_H #define BOARD_H #include <vector> class Board { public: explicit Board(int size = 4); void shuffle(int seed = -1); // seed >= 0 时使用固定种子,便于测试复现 bool move(int index); // 点击 index 位置的数字块 bool moveByDirection(int dir); // 键盘方向键:0上 1下 2左 3右 bool isSolved() const; bool isSolvable() const; // 测试与报告用,按需开放 const std::vector<int>& tiles() const { return m_tiles; } int size() const { return m_size; } int blankIndex() const { return m_blank; } private: int rowOf(int index) const { return index / m_size; } int colOf(int index) const { return index % m_size; } int m_size; std::vector<int> m_tiles; // 0 表示空白,1..n-1 为数字块 int m_blank; // 空白块的当前索引 }; #endif// board.cpp #include "board.h" #include <algorithm> #include <numeric> #include <random> Board::Board(int size) : m_size(size), m_tiles(size * size), m_blank(size * size - 1) { // 还原态:按行优先填入 1..15,最后一块放 0 表示空白 for (int i = 0; i < m_size * m_size - 1; ++i) { m_tiles[i] = i + 1; } m_tiles[m_size * m_size - 1] = 0; } void Board::shuffle(int seed) { std::vector<int> nums(m_size * m_size - 1); std::iota(nums.begin(), nums.end(), 1); std::mt19937 rng; if (seed < 0) { std::random_device rd; rng.seed(rd()); } else { rng.seed(seed); } // 打乱后检查可解性:不可解就重新打乱,避免用户拿到一副拼不出来的牌 do { std::shuffle(nums.begin(), nums.end(), rng); for (int i = 0; i < m_size * m_size - 1; ++i) { m_tiles[i] = nums[i]; } m_tiles[m_size * m_size - 1] = 0; m_blank = m_size * m_size - 1; } while (!isSolvable() || isSolved()); } bool Board::move(int index) { if (index < 0 || index >= m_size * m_size) return false; if (m_tiles[index] == 0) return false; int r = rowOf(index), c = colOf(index); int br = rowOf(m_blank), bc = colOf(m_blank); // 曼哈顿距离等于 1 才允许交换,即只有空白格相邻块能点 if (std::abs(r - br) + std::abs(c - bc) != 1) return false; std::swap(m_tiles[index], m_tiles[m_blank]); m_blank = index; return true; } bool Board::moveByDirection(int dir) { int r = rowOf(m_blank), c = colOf(m_blank); int nr = r, nc = c; switch (dir) { case 0: nr = r - 1; break; // 上:空白向上走 case 1: nr = r + 1; break; // 下:空白向下走 case 2: nc = c - 1; break; // 左:空白向左走 case 3: nc = c + 1; break; // 右:空白向右走 } if (nr < 0 || nr >= m_size || nc < 0 || nc >= m_size) return false; return move(nr * m_size + nc); // 空白要去的格子就是被推移的数字块 } bool Board::isSolved() const { for (int i = 0; i < m_size * m_size - 1; ++i) { if (m_tiles[i] != i + 1) return false; } return m_tiles[m_blank] == 0; } bool Board::isSolvable() const { int inv = 0; for (int i = 0; i < m_size * m_size - 1; ++i) { for (int j = i + 1; j < m_size * m_size - 1; ++j) { if (m_tiles[i] && m_tiles[j] && m_tiles[i] > m_tiles[j]) ++inv; } } if (m_size % 2 == 1) return inv % 2 == 0; int blankRowFromBottom = m_size - 1 - rowOf(m_blank); return (inv + blankRowFromBottom) % 2 == 0; }这段代码有几个参数值得说。shuffle 的 seed 是给报告准备的后门:测试时传 0、1、2 这样的固定种子,每次启动棋盘都一样,方便截图拼进文档;正常游戏传 -1 走 std::random_device,让每次开局不同。move 里的曼哈顿距离判断是精简过的,abs(r - br) + abs(c - bc) 等于 1 表示上下左右相邻,等于 0 是点到了空白本身,等于 2 是对角相邻,这两种情况都不允许移动。
moveByDirection 的方向语义需要跟你讲清楚:我实现的“上”是空白向上走,视觉上表现为空白上方的数字块被推下来。如果你觉得反直觉,把 case 0 和 case 1 的目标行号对调就行,但要注意游戏逻辑会变成“按上键把最上面的块推下来”,两种都常见,界面层给个方向提示即可。
2.3 步进移动判定与胜利条件:把逻辑写成与界面无关的纯 C++ 类
逻辑层与界面层分开的最大好处是可以用命令行直接验证。下面这个验证器跑 1000 次固定种子的 shuffle,确认每次打乱都可解。如果输出solvable: 1000/1000,说明随机打乱算法和 isSolvable 配合正常;如果出现个位数不通过,优先查 isSolvable 里奇偶宽的分支处理。
// validator.cpp:独立于 Qt 的自动验证程序 #include "board.h" #include <cstdio> int main() { Board b(4); int solvable = 0; for (int i = 0; i < 1000; ++i) { b.shuffle(i); // 固定种子,运行结果可复现 if (b.isSolvable()) ++solvable; } std::printf("solvable: %d/1000\n", solvable); return 0; }固定种子在这里很关键:如果某次跑挂了,可以用同一个种子在 debugger 里复现现场,而不是赌概率。1000 次循环对 4×4 棋盘的开销可以忽略,逆序数算法是 O(n²),n 只有 15,完全不用上归并排序那种优化。
除了 shuffle 加可解性检查,还有一种替代思路:从还原态出发,随机执行 500 次合法移动,最终状态天然可解,不需要算逆序数。这个方案代码更短,但打乱效果依赖随机移动的充分性,而且报告里少了一个可以展开讲的算法点。我建议课程设计还是用逆序数方案——答辩证上可以多讲一句“我还检验过打乱结果的概率分布”。
3. 用 Qt Creator 搭图形界面:从空白工程到可玩小游戏
逻辑层就绪后,Qt 界面就是一层皮。这章从建工程开始,到鼠标键盘操作都能玩为止,最终代码量在 300 行左右,适合课程设计的体量。
3.1 在 Qt Creator 里创建 QWidget 工程:qmake 与 .pro 文件的三个必写项
Qt Creator 里新建 Qt Widgets Application,类名 Widget,基类选 QWidget,构建系统选 qmake。也有人习惯用 Visual Studio Code 配 c/c++ 环境写 Qt,不是不行,但工程模板、套件切换、帮助文档集成都没 Qt Creator 省事,课程设计倒计时阶段别折腾环境。
# huarongdao.pro QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++11 TARGET = SlidingPuzzle TEMPLATE = app SOURCES += main.cpp widget.cpp board.cpp HEADERS += widget.h board.h三个必写项解释一下:QT += widgets是 Qt5 从 QtGui 里拆出 GUI 组件后的标配,少这一行 Qt Widgets 的类全部不可用;CONFIG += c++11保证 std::shuffle、std::mt19937、std::iota 这些 C++11 随机数设施能用;TARGET是生成的可执行文件名,建议用英文字母,中文名在部分打包场景下会出编码问题。
main.cpp 没什么好写的,标准三行:创建 QApplication、显示 Widget、进入事件循环。注意 QApplication 构造必须在任何界面对象之前,这是 Qt 事件系统的基本要求。
3.2 两种界面方案:QPushButton 网格与 QPainter 自绘,我为什么选自绘
界面这层有个经典选择:用 16 个 QPushButton 摆成网格,还是用一个 QWidget 配合 QPainter 全部自绘。按 QPushButton 方案,每个格子是一个按钮,点击信号连接到槽函数,思路直白,但有两个问题:一是按钮样式改起来非常费劲,圆角、阴影、数字字体都要走 QSS;二是每个按钮都要维护对应的棋盘索引,代码重复度高。用 Qt Designer 拖界面也是一样的套路,拖一个无布局的空 QWidget 当棋盘容器,剩下全靠代码画。
我选 QPainter 自绘,理由很务实:这个题目的核心展示点就是“图形化数字华容道”,自绘代码能讲出坐标换算、绘制管线、事件响应三个知识点,报告篇幅自然充实。Qt Designer 在这种场景下反而派不上大用场,因为整个棋盘是在 paintEvent 里动态生成的。
// widget.h #ifndef WIDGET_H #define WIDGET_H #include <QWidget> #include "board.h" class Widget : public QWidget { Q_OBJECT public: explicit Widget(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private: int indexAt(const QPoint &pos) const; // 像素坐标 -> 棋盘索引 void onMoveFinished(); Board m_board; int m_moveCount = 0; bool m_gameOver = false; }; #endif// widget.cpp 界面绘制与事件响应关键部分 #include "widget.h" #include <QPainter> #include <QMouseEvent> #include <QKeyEvent> #include <QMessageBox> static const int kMargin = 16; // 棋盘边距 static const int kSpacing = 6; // 格子间隙 Widget::Widget(QWidget *parent) : QWidget(parent), m_board(4) { setWindowTitle(QStringLiteral("数字华容道 - Qt")); setFixedSize(480, 520); setFocusPolicy(Qt::StrongFocus); // 必须设置,否则键盘事件收不到 m_board.shuffle(); } void Widget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); const int size = m_board.size(); const int boardPix = qMin(width(), height() - 80) - 2 * kMargin; const int cell = (boardPix - (size - 1) * kSpacing) / size; // 背景 painter.fillRect(rect(), QColor(0x2b, 0x2b, 0x2b)); // 数字块 for (int row = 0; row < size; ++row) { for (int col = 0; col < size; ++col) { int idx = row * size + col; int value = m_board.tiles()[idx]; QRect cellRect(kMargin + col * (cell + kSpacing), kMargin + row * (cell + kSpacing), cell, cell); if (value == 0) { painter.fillRect(cellRect, QColor(0x3c, 0x3c, 0x3c)); // 空白格 continue; } painter.setPen(Qt::NoPen); painter.setBrush(QColor(0x5b, 0x8c, 0xef)); painter.drawRoundedRect(cellRect, 8, 8); painter.setPen(Qt::white); QFont font = painter.font(); font.setPixelSize(cell * 2 / 3); font.setBold(true); painter.setFont(font); painter.drawText(cellRect, Qt::AlignCenter, QString::number(value)); } } // 顶部信息:当前步数 painter.setPen(Qt::white); painter.drawText(rect().adjusted(kMargin, boardPix + 2 * kMargin + 20, -kMargin, -8), Qt::AlignLeft | Qt::AlignVCenter, QStringLiteral("步数: %1").arg(m_moveCount)); }绘制参数里最值得抄的是cell的计算公式:先预留出底部信息栏高度(80 像素)和边距,再减去格子间隙,除以棋盘尺寸。这样做的好处是窗口大小变化时格子自动缩放,不会出现最后一列掉到窗口外的情况。字体大小用cell * 2 / 3,数字在格子里视觉上刚好饱满;圆角 8 像素配合Antialiasing是 Qt 自绘界面最朴素的质感来源。
颜色这块,深灰背景加蓝块白字是默认方案,答辩时不会被说丑。如果想换皮肤,把三处 QColor 改成变量就行,这也是第 6 章进阶内容的基础。
3.3 鼠标点击与键盘方向键事件:让用户按直觉操作
界面的另一半是事件。鼠标点击要把物理像素换算成棋盘索引,这个函数写错了会表现为“点格子没反应”或“点了 A 格子却动了 B 格子”。
int Widget::indexAt(const QPoint &pos) const { const int size = m_board.size(); const int boardPix = qMin(width(), height() - 80) - 2 * kMargin; const int cell = (boardPix - (size - 1) * kSpacing) / size; int col = (pos.x() - kMargin) / (cell + kSpacing); int row = (pos.y() - kMargin) / (cell + kSpacing); if (col < 0 || col >= size || row < 0 || row >= size) return -1; // 判断点击是否落在格子间隙里 int localX = pos.x() - kMargin - col * (cell + kSpacing); int localY = pos.y() - kMargin - row * (cell + kSpacing); if (localX > cell || localY > cell) return -1; return row * size + col; } void Widget::mousePressEvent(QMouseEvent *event) { if (m_gameOver) return; if (event->button() != Qt::LeftButton) return; int idx = indexAt(event->pos()); if (idx < 0) return; if (m_board.move(idx)) { ++m_moveCount; onMoveFinished(); } update(); // 无论是否移动成功都要刷新,保证视觉反馈 } void Widget::keyPressEvent(QKeyEvent *event) { if (m_gameOver) return; int dir = -1; switch (event->key()) { case Qt::Key_Up: dir = 0; break; case Qt::Key_Down: dir = 1; break; case Qt::Key_Left: dir = 2; break; case Qt::Key_Right: dir = 3; break; default: QWidget::keyPressEvent(event); return; } if (m_board.moveByDirection(dir)) { ++m_moveCount; onMoveFinished(); } update(); } void Widget::onMoveFinished() { if (m_board.isSolved()) { m_gameOver = true; QMessageBox::information(this, QStringLiteral("完成"), QStringLiteral("用了 %1 步完成,恭喜!").arg(m_moveCount)); } }indexAt 里最容易错的是间隙判断。除以cell + kSpacing能算出格子序号,但如果点正好落在两个格子之间的 6 像素空隙里,col 值可能指向右边的格子,这时候用localX > cell把无效点击过滤掉。我第一版漏了这个判断,测试时点格子边缘会误触旁边的块,后来统一用曼哈顿距离做了兜底才消停。
键盘事件还有个隐蔽坑:QWidget 默认不接收键盘焦点,构造函数里没有setFocusPolicy(Qt::StrongFocus),方向键按了没反应。这个可以在报告测试部分写进“已知问题与处理”,属于细节处见工作量。
如果想要自动化演示或跑测试,Qt 提供 QTest 模块可以模拟鼠标点击事件,比如QTest::mouseClick(this, Qt::LeftButton, {}, pos),不需要真人在屏幕上点。课程设计报告里提一句“用 QTest 模拟点击路径验证移动逻辑”,比截图更有说服力。
4. 课程设计报告怎么写出工作量:结构、图表与测试数据
源码之外的课程设计报告占一半分数。见过太多人代码写完了,报告却像流水账:先写背景,再贴一遍代码,最后两句感想。这章说清楚报告怎么组织才能让老师觉得“这事做透了”。
4.1 报告结构:从需求分析到测试,五张表撑起一份像样的设计文档
一份能拿高分的课程设计报告不用长篇大论,但要每个部分都有明确的作用。我常用的结构是六段式,每段配一张表或一张图,让老师翻开任意一页都有东西可看。
| 报告章节 | 写什么 | 篇幅建议 | 别写成什么 |
|---|---|---|---|
| 需求分析 | 功能需求、运行环境 | 1-2 页 | 产品说明书 |
| 概要设计 | 模块划分、类图、流程 | 2-3 页 | 贴大段代码 |
| 详细设计 | 核心数据结构、算法、界面绘制 | 6-8 页 | 代码流水账 |
| 测试 | 用例表、测试结果、异常处理 | 2-3 页 | 只说“测试通过” |
| 总结 | 遇到的具体问题和解决思路 | 1 页 | “通过本次设计我学会了……” |
需求分析不要写“本系统具有极佳的用户体验”这种空话。写清楚数字华容道的规则、支持的棋盘尺寸(3×3 和 4×4)、操作方式(鼠标点击和键盘方向键)、运行环境(Windows 10,Qt 5.15.2 MSVC 套件)就够了。这部分老师只会扫一眼,真正决定印象分的是详细设计和测试。
4.2 关键设计图:类图、流程图与界面原型怎么画
课程设计的核心插图就三种,别在这上面过度设计。
类图画两个框:Board 在逻辑层,属性写 m_tiles、m_blank,方法写 shuffle、move、isSolved;Widget 在界面层,依赖 Board,属性写 m_moveCount、m_gameOver,方法写 paintEvent、mousePressEvent、keyPressEvent。类图要体现依赖方向:Widget 调用 Board,Board 不依赖 Widget。这一句话就能让老师看出你会分层设计。
流程图只需要主流程:程序启动 → 创建 Board 并 shuffle → 进入事件循环 → 收到鼠标或键盘事件 → Board.move 判断合法性 → 合法则更新界面并检查胜利 → 胜利则弹窗结束。建议用文字描述加序号,不要画十几行的大图,课程设计流程图越简单越不容易出错。
界面原型不用单独画,直接截程序运行的窗口图,用箭头标注 kMargin、kSpacing、cell 这三个参数的物理含义。这张图放在详细设计的界面部分,比任何 UML 都直观。
4.3 测试用例怎么写:边界操作、异常输入与随机性验证
报告的测试部分最容易糊弄,也最好拿分。不需要自动化测试框架,一张表格就能把工作量写出来。
| 编号 | 测试项 | 操作 | 预期结果 | 结论 |
|---|---|---|---|---|
| T01 | 启动初始化 | 双击运行程序 | 棋盘随机打乱,无重叠 | 通过 |
| T02 | 点击相邻块 | 点击空白格左侧数字块 | 数字块向右滑入空格,步数 +1 | 通过 |
| T03 | 点击非相邻块 | 点击距离空白两格的数字块 | 无任何反应 | 通过 |
| T04 | 点击空白格 | 直接点击空白位置 | 无反应,步数不变 | 通过 |
| T05 | 键盘方向键 | 依次按上下左右 | 对应方向的数字块移动 | 通过 |
| T06 | 游戏完成 | 还原全部数字顺序 | 弹出完成提示,步数正确 | 通过 |
| T07 | 快速连续点击 | 高频点击相邻块 | 无崩溃,无错乱 | 通过 |
| T08 | 随机性验证 | 跑 1000 次 shuffle | 全部可解 | 通过 |
T08 是亮点用例:在验证程序里循环 1000 次,每次固定不同种子,断言 isSolvable 都返回真。这个用例同时证明了随机数打乱和可解性判断的正确性,老师看到基本不会再质疑算法。测试环境写 Qt 5.15.2、MSVC 2019 64 位、Windows 10 即可,不要编造更老的版本。
还有一类边界测试容易被忽略:棋盘尺寸。如果你只实现了 4×4,在报告里说明“考虑到课程设计演示时间有限,主界面固定 4×4,逻辑层通过构造参数支持 3×3”,这也算交代清楚。别把代码写成只能硬编码 4×4 却声称支持任意尺寸。
5. 从编译到答辩的避坑清单:Qt 版本混用、平台插件与中文乱码
这几条踩坑记录按我在不同学生工程里见到的频率排序,每条都是“现象 → 原因 → 解决”的结构。提前看完能省一周时间,尤其是前两条,属于那种报错信息完全看不懂、搜了半天才发现是环境问题的类型。
5.1 qt.qpa.plugin 找不到 linuxfb 平台插件:开发环境与运行环境不一致
现象:程序在 Windows 上编译运行都正常,拷到 WSL2 Ubuntu 图形界面或者树莓派板子上,启动时直接报qt.qpa.plugin: could not find the qt platform plugin "linuxfb",窗口还没出现进程就退了。
原因:Qt5 启动时会根据运行环境加载平台插件,插件位于 plugins/platforms 目录。Windows 桌面用 qwindows.dll,Linux 桌面用 xcb,嵌入式 Linux 常用 linuxfb 或 eglfs。报这个错,要么是环境变量 QT_QPA_PLATFORM 被设置成了 linuxfb 而实际没有对应插件,要么是运行目录里缺 plugins 目录。
解决:在 WSL2 里先确认 DISPLAY 环境变量有值,WSLg 正常工作时 DISPLAY 指向 :0;在树莓派这类嵌入式设备上,交叉编译时要把 Qt 的 plugins 目录一并拷贝到目标文件系统,必要时显式-platform linuxfb或者-platform eglfs。定位这类问题最快的办法是设置QT_DEBUG_PLUGINS=1再运行,Qt 会打印出它依次找了哪些路径。图形后端对 Qt 来说像一层黑匣子,错误信息不友好,但这个环境变量能把查找过程全暴露出来。
5.2 fatal: cannot mix incompatible qt library:qmake 路径残留与 Qt 版本混用
现象:编译到一半或者生成 Makefile 时,报fatal: cannot mix incompatible Qt library (version ex50601) with this library,有时前面还跟着一堆 undefined reference。看着像代码问题,重装 Qt 后依然出现。
原因:ex50601 是 Qt 内部的版本宏编码,本质是编译用的头文件和链接用的库来自不同 Qt 套件。最常见的是系统里 apt 装了一套 Qt5,Qt Creator 的 Kit 又指定了另一套 Qt 5.15.2,命令行编译时 PATH 里先找到的是 apt 那套。
解决:命令行编译前先执行qmake -v,看打印的 Qt 版本路径和你预期的是否一致;把 Qt 5.15.2 的 bin 目录手动前置到 PATH;删掉 build 目录重新 qmake,不要复用旧 Makefile。版本错配是 Qt 工程的经典玄学,报错本身不可读,先查路径再动手,别一上来就重装。
5.3 中文字符串乱码与 c++ 字符串数组初始化:source 编码统一才能根治
现象:在 Qt Creator 里编译运行中文都正常,但换到 MSVC 命令行编译,或者把工程发给同学后,QMessageBox 的按钮文字变成锟斤拷或者一排问号。
原因:Qt Creator 默认把源文件存成 UTF-8,而 MSVC 默认按本地代码页(GBK)读源文件,UTF-8 的中文字节被按 GBK 解码,乱码就出现了。c++ 字符串数组初始化如果写成const char* names[] = {"上", "下", "左", "右"},存的就是原始字节序列,同样受源文件编码影响。
解决:在 .pro 文件里加一行编译选项,强制 MSVC 按 UTF-8 读取源文件:
QMAKE_CXXFLAGS += /utf-8同时把传给 Qt 的中文字符串统一交给 QStringLiteral 宏处理,它会按源码编码正确转换到 QString。如果还有老代码用 const char* 直接 setText,先构造成 QString 再传,不要裸用。
5.4 拷贝 exe 到别的机器跑不起来:windeployqt 打包与 VC++ 运行库
现象:Release 构建的 exe 在自己电脑上跑得好好的,拷到没装 Qt 的机器上,双击没反应,或者提示缺少 Qt5Core.dll、Qt5Widgets.dll;补齐 DLL 后又报0xc000007b。
原因:Qt 是动态链接的,exe 旁边没有 Qt 运行库和平台插件,系统根本找不到依赖;0xc000007b 这个错误常见于 x86/x64 架构不匹配,或者缺少 Visual C++ Redistributable 运行库。本质和 C# 调用 C++ 出现的 Access Violation 类似,都是 ABI 不一致的表现。
解决:用 Qt 安装目录 bin 下的 windeployqt 工具自动部署。命令行进到 exe 所在目录,执行windeployqt SlidingPuzzle.exe,它会自动拷贝 Qt5Core.dll、Qt5Widgets.dll、Qt5Gui.dll 以及 platforms/qwindows.dll。注意两点:一是用 Release 版 exe 部署,Debug 版带着调试运行时体积大而且目标机器未必有;二是下载 Qt 时选 msvc2019_64 套件,对应的运行库是 VC++ 2015-2022 Redistributable x64,目标机器要先装这个。部署完的整个目录就是你在答辩现场要运行的版本,别再从 Qt Creator 里启动。
6. 让作业多走一步:计时计步、滑动动画与复盘回放的加分实现
核心功能完成后,一般还剩三四天时间,这时候加两个“小而明显”的特性,性价比远高于重构代码。
6.1 计时与计步:QTimer 让演示有整体感
计步数在 onMoveFinished 里累加就够了,真正的加分项是计时。用 QTimer 每秒触发一次,界面顶部实时显示“用时 00:00 / 步数 N”。
m_timer = new QTimer(this); connect(m_timer, &QTimer::timeout, this, [this]() { ++m_elapsed; update(); }); m_timer->start(1000);这个改动只有十几行,但答辩演示时老师会看到你的程序有“状态”概念。计步和计时分别对应逻辑层的计数和界面层的重绘,报告里可以顺带写一句“界面状态通过事件驱动更新”。
6.2 滑动动画、复盘回放与皮肤:三个改动不大的技巧
滑动动画是最直观的质感提升。思路是记录正在滑动的数字块、起始格子、目标格子和一个 0 到 1 的进度值,paintEvent 里按进度插值计算绘制位置。不需要引入 QPropertyAnimation 这类重量级机制,用 QTimer 每 16ms 推进一次进度就行:
struct SlideAnim { int value; // 正在滑动的数字 int fromIndex; // 起始棋盘索引 int toIndex; // 目标棋盘索引 qreal progress; // 0.0 ~ 1.0 };paintEvent 里根据fromIndex和toIndex分别算出像素坐标,再做线性插值。数值华容道的滑动距离只有一格,160ms 的动画时长手感最好,太短像瞬移,太长耽误连续操作。
复盘回放的实现更简单:每次合法 move 都把目标索引追加到一个 QVector<int> 里,回放时按记录逐个调用 m_board.move。给界面加一个“回放”按钮,演示完了点一下,程序自己把刚才的打法重演一遍。这招在答辩现场的效果很好,而且代码量极小。
皮肤替换则是把 paintEvent 里的 drawRoundedRect 换成 drawPixmap,准备几张数字图片就行。别在这个题目上惦记 MVVM 框架之类的重型架构,逻辑层和界面层两层分离已经足够,硬套框架反而把答辩问题引向你自己说不清的方向。
我做第一版时没做可解性判断,随机打乱后一半概率拼不还原,自己玩都玩不下去,答辩前才补上 isSolvable,差点翻车。现在无论谁问起数字华容道,我都会先问一句:你的打乱函数验证过可解性吗?这个问题的答案,决定了你后面所有代码是站在地基上还是沙子上。希望帮到你。
本文还有配套的精品资源,点击获取