ARTICLE DETAIL

资讯详情

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

C++与Qt实战:数字华容道游戏完整实现与避坑指南

C++与Qt实战:数字华容道游戏完整实现与避坑指南 简介面向高校程序设计课程大作业场景这是一份完整的C/Qt数字华容道项目源码与课程设计报告。资源体现了从控制台版本到图形界面的完整演进过程先在VS中实现基于cout的简易逻辑类再在Qt Creator中建立多难度窗口、设计UI绘图与槽函数跳转并提供背景音乐、游戏音效及通关后播放happy猫视频的彩蛋适合需要参考完整项目结构、体验Qt图形编程入门实践的在校生。压缩包共126个文件包含41个cpp源码、35个h头文件、31个ui界面文件以及pro工程文件另有mp4彩蛋视频、mp3/wav音频和PDF课程设计报告整体约6.17MB目录结构清晰便于按模块查阅。项目从逻辑类封装到界面交互层次分明报告可辅助理解设计思路与实现细节。目前已有209人学习可作为C程序设计或Qt开发大作业的高质量参考。1. 数字华容道大作业为什么选 C 和 Qt课程设计做数字华容道很多同学第一反应是用控制台打印数字完事我的建议是别省这一步直接用 C 配合 Qt 把图形界面做出来。这个题目的逻辑量不大核心就是一张 4×4 棋盘但正好能把类封装、随机数、事件处理、绘图这几块 C 的高频考点全串起来答辩时也能一眼看出工作量。本文按「先写核心逻辑再套 Qt 界面最后补报告」的顺序把一套完整的数字华容道小游戏源码拆给你看适合正在找高级语言程序设计大作业方向、想拿到一份能跑又能讲清楚的设计方案的从业者。你不用照着抄照着这套思路自己搭反而更稳。2. 棋盘状态与移动规则先写一个不依赖界面也能跑的核心逻辑我在做这个题目时习惯先把界面扔到一边把棋盘逻辑写成一个不依赖 Qt 的普通 C 类用控制台程序验证通过后再往上套界面。这样做的直接好处是核心逻辑可以单独测试出问题不用在图形界面上猜另一个好处是课程设计报告里能理直气壮地写「业务逻辑与界面分离」老师很吃这一套。2.1 用二维数组表示棋盘空格用 0 占位4×4 数字华容道有 15 个数字方块加一个空格。我的表示方式是一个std::vectorstd::vectorint每个格子存一个整数0 专门表示空格。目标状态是第 1 行 1 到 4、第 2 行 5 到 8、第 3 行 9 到 12、第 4 行 13 到 15最后右下角是 0。用 0 而不是 -1 的好处是后续判断空格位置时不需要特殊处理负数std::swap也能直接把数字和空格交换少写分支。#ifndef GAME_BOARD_H #define GAME_BOARD_H #include vector enum Direction { Left, Right, Up, Down }; // 四个移动方向 class GameBoard { public: static const int kSize 4; // 4×4 棋盘 GameBoard(); bool Move(Direction dir); // 沿指定方向移动空格 void Shuffle(int steps 300); // 用合法移动洗牌 bool IsSolved() const; // 是否回到目标状态 int At(int row, int col) const; // 读取格子数字0 表示空格 int ZeroRow() const { return zero_row_; } int ZeroCol() const { return zero_col_; } private: static Direction Opposite(Direction d); // 反方向洗牌时用 bool InBoard(int r, int c) const; // 坐标是否合法 std::vectorstd::vectorint board_; int zero_row_; int zero_col_; }; #endif // GAME_BOARD_H构造函数把棋盘填成目标状态再把右下角置 0。注意我用value填充这样目标状态天然有序后续判断完成状态只需要顺序比对。kSize声明为static const int在类内直接初始化因为它是整型常量符合 C 的常量表达式规则外界也能用GameBoard::kSize引用不需要实例化对象。GameBoard::GameBoard() : board_(kSize, std::vectorint(kSize, 0)) { int value 1; for (int r 0; r kSize; r) { for (int c 0; c kSize; c) { board_[r][c] value; } } board_[kSize - 1][kSize - 1] 0; // 空格在右下角 zero_row_ kSize - 1; zero_col_ kSize - 1; }2.2 移动规则只允许空格与相邻格子交换数字华容道的移动规则用一句话就能说清用户想移动的方块必须和空格相邻移动的本质是方块与空格交换位置。从空格角度看相当于空格往上下左右移动一格如果目标位置在棋盘外就失败。我把方向映射成坐标增量Up对应行减 1Down对应行加 1Left对应列减 1Right对应列加 1。bool GameBoard::Move(Direction dir) { int dr 0, dc 0; switch (dir) { case Up: dr -1; break; case Down: dr 1; break; case Left: dc -1; break; case Right: dc 1; break; } int nr zero_row_ dr; int nc zero_col_ dc; if (!InBoard(nr, nc)) { return false; // 空格已经在边界不能再往这个方向走 } std::swap(board_[zero_row_][zero_col_], board_[nr][nc]); zero_row_ nr; zero_col_ nc; return true; } bool GameBoard::InBoard(int r, int c) const { return r 0 r kSize c 0 c kSize; }这段代码的逻辑核心是两个成员变量zero_row_和zero_col_。移动成功后必须同步更新这两个值否则下一次判相邻就会错位。std::swap交换的是格子里的整型值不用写第三个临时变量。返回值设计成bool界面层可以直接用它判断这次操作是否有效无效时可以不刷新画面、不计步数。2.3 随机打乱用合法移动洗牌不赌奇偶性打乱棋盘是个容易翻车的地方。如果直接生成一个随机排列大约有一半的概率落在不可达状态——数字华容道不是随便排都能复原的存在逆序数奇偶性的限制。最稳妥的做法不是生成随机排列再校验而是「从目标状态出发随机做若干次合法移动」。因为每一步移动都对应真实存在的棋盘状态转换最终状态必然可达从理论上绕开了无解问题。Direction GameBoard::Opposite(Direction d) { switch (d) { case Left: return Right; case Right: return Left; case Up: return Down; case Down: return Up; } return Left; // 兜底实际不会走到 } void GameBoard::Shuffle(int steps) { std::mt19937 rng(static_castunsigned int( std::chrono::steady_clock::now().time_since_epoch().count())); std::uniform_int_distributionint pick(0, 3); const Direction dirs[4] {Left, Right, Up, Down}; Direction last Down; // 上一次成功移动的方向 for (int i 0; i steps; i) { Direction d dirs[pick(rng)]; if (d Opposite(last)) { continue; // 避免空格在两个相邻格子之间来回横跳 } if (Move(d)) { last d; // 只有真正移动成功才更新 last } } }这里用了random里的std::mt19937而不是rand()。rand()的随机质量在这个场景下其实够用但课程设计报告里老师常追问「你用的随机数生成器是什么」mt19937是梅森旋转算法周期长、分布均匀能少挨一次问。种子用当前时间戳转unsigned int保证每次启动游戏局面不同。last记录上一次的方向如果这次随机方向恰好是反方向就跳过避免空格在两点间空转浪费洗牌步数。steps默认 300对 4×4 棋盘来说足够打乱但不至于让局面复杂到离谱你可以调大调小观察难度变化。2.4 胜负判定与一次控制台验收胜负判定就是顺序比对从左上角开始依次期望 1、2、3……一直到倒数第二个格子期望 15最后右下角必须是 0。实现时我用一个expect变量边遍历边自增遇到不等直接返回 false走完整个棋盘还没失败就说明复原成功。bool GameBoard::IsSolved() const { int expect 1; for (int r 0; r kSize; r) { for (int c 0; c kSize; c) { if (r kSize - 1 c kSize - 1) { return board_[r][c] 0; // 最后一个格子必须是空格 } if (board_[r][c] ! expect) { return false; } expect; } } return true; }核心逻辑写好后我建议先写个 20 行的控制台main()跑一遍确认洗牌和判定都正常再进 Qt 阶段。这个控制台程序不用删后面课程设计报告里「单元测试」部分能直接引用。#include iostream #include game_board.h int main() { GameBoard g; g.Shuffle(500); // 打乱 500 步 std::cout 打乱完成当前棋盘 std::endl; for (int r 0; r GameBoard::kSize; r) { for (int c 0; c GameBoard::kSize; c) { std::cout g.At(r, c) \t; } std::cout std::endl; } std::cout 当前是否已完成: (g.IsSolved() ? 是 : 否) std::endl; return 0; }这段代码如果打印出「否」说明洗牌生效如果偶尔打出「是」说明打乱步数太少或者last防回退逻辑没拦住极端情况把洗牌步数调大即可。At()是只读接口界面层遍历棋盘绘图时也用这个函数不会暴露内部数组类的封装性也说得通。如果需要校验一个外部给定的局面是否可解可以补一个逆序数检查函数把棋盘按行展开成一维数组空格跳过统计逆序对数量。列数为偶数时(逆序数 空格距底部行数) 为偶数才有解列数为奇数时只看逆序数是否为偶数。这个函数可以作为洗牌方案的补充验证也能在报告里体现你没有只抄代码、而是理解了华容道的数学本质。3. 用 Qt 把图形界面搭起来从空窗口到可点击棋盘核心逻辑稳定后Qt 部分就是「把状态画出来 把操作传进去」。这个题目不需要复杂的界面设计一个 QWidget 中央窗体加菜单栏就够了。你甚至可以不打开 Qt Designer直接用代码创建界面组件但如果你习惯用 Qt Designer 拖拽布局也完全可以把主窗口的菜单栏和状态栏先拖好棋盘区域留一个 QWidget 子类容器。3.1 搭建工程Qt Creator 还是 CMake工程创建我建议直接用 Qt Creator 的「Qt Widgets Application」模板。选择编译器套件时Windows 上注意区分 MinGW 和 MSVC——你下载的 Qt 库是哪套编译的工程就得用哪套 Kit混用会在链接阶段报出那种让人一头雾水的cannot mix incompatible qt library错误。如果你更习惯用 VSCode 配 CMake 做 C 开发也可以手写 CMakeLists.txt用find_package(Qt5 COMPONENTS Widgets)找库但课程设计场景下没必要多折腾Qt Creator 自带模板最快。工程里至少要有一个主窗口类和一个棋盘控件类。我一般这样做MainWindow负责菜单、状态栏、布局BoardWidget继承QWidget重写paintEvent、mousePressEvent、keyPressEvent内部持有GameBoard对象。这样划分后MainWindow不关心棋盘怎么画BoardWidget不关心菜单怎么建职责清楚报告也好写。3.2 用 QWidget 自绘棋盘而不是堆按钮有人会问为什么不直接放 15 个 QPushButton我试过能跑但有两个问题。第一按钮自带边框和默认样式想做圆角、变色、数字居中这些效果反而要写一堆样式表第二后续想做滑动动画时按钮方案的地板会被 QSS 和布局系统绑死。自绘方案只需要在paintEvent里把数字画到对应矩形上状态变了调update()触发重绘视觉完全可控。#ifndef BOARD_WIDGET_H #define BOARD_WIDGET_H #include QWidget #include game_board.h class BoardWidget : public QWidget { Q_OBJECT public: explicit BoardWidget(QWidget *parent nullptr); void NewGame(); // 重置并洗牌 int StepCount() const { return steps_; } protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private: QRect CellRect(int row, int col) const; // 计算某个格子的绘制区域 GameBoard board_; int cell_size_ 60; // 格子边长paintEvent 里按窗口大小重算 int steps_ 0; // 已移动步数 }; #endif // BOARD_WIDGET_HNewGame()里我构造一个临时GameBoard调用它的Shuffle()再把临时对象赋值给成员board_。GameBoard的数据成员都是std::vector和int默认拷贝赋值即可不需要手写拷贝构造函数。setFocusPolicy(Qt::StrongFocus)必须设置否则键盘事件不会送到这个控件上——这个坑我在第 4 章还会展开。BoardWidget::BoardWidget(QWidget *parent) : QWidget(parent) { setFocusPolicy(Qt::StrongFocus); setMinimumSize(320, 360); // 防止窗口缩小时棋盘被挤压 } void BoardWidget::NewGame() { GameBoard fresh; fresh.Shuffle(300); board_ fresh; steps_ 0; update(); }3.3 绘制格子paintEvent 里的尺寸计算paintEvent是整个界面部分最核心的方法。每次窗口大小变化、棋盘状态变化、动画刷新时它都会被调用所以它必须是「无状态」的只根据当前棋盘内容画出画面不在这里改数据。格子间距我固定为 6 像素用qMin(width(), height())取短边作为棋盘可用区域再减去 5 个间距4 个格子之间有 3 个间距加上两侧各一个除以 4 得到格子边长。这样窗口无论怎么拉棋盘都能居中占满。const int kGap 6; // 格子与格子之间的空白 void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 抗锯齿 int side qMin(width(), height()); cell_size_ (side - (GameBoard::kSize 1) * kGap) / GameBoard::kSize; if (cell_size_ 0) return; // 窗口太小时跳过绘制 for (int r 0; r GameBoard::kSize; r) { for (int c 0; c GameBoard::kSize; c) { QRect rect CellRect(r, c); int value board_.At(r, c); if (value 0) { painter.fillRect(rect, QColor(#e8e8e8)); // 空格用浅灰 continue; } painter.fillRect(rect, QColor(#3a7bd5)); // 数字块用蓝色 painter.setPen(Qt::white); QFont font painter.font(); font.setPixelSize(cell_size_ / 2); font.setBold(true); painter.setFont(font); painter.drawText(rect, Qt::AlignCenter, QString::number(value)); } } } QRect BoardWidget::CellRect(int row, int col) const { int step cell_size_ kGap; return QRect(kGap col * step, kGap row * step, cell_size_, cell_size_); }CellRect单独抽出来是有原因的点击事件里计算用户点中了哪个格子也要用同一套坐标公式。如果两处各写一遍改间距时很容易漏改一处造成「点击判定位置和画面显示位置错位」的玄学问题。QPainter::drawText的Qt::AlignCenter让数字自动居中不需要手动计算文字偏移。font.setPixelSize(cell_size_ / 2)让字号跟随格子大小变化窗口拉大时字不会显得小气。3.4 点击和键盘两套操作入口游戏操作我实现了两套鼠标点击方块以及键盘方向键控制空格移动。鼠标点击的判定规则是先算出点击位置对应的行列如果这个位置就是空格则忽略如果和空格同行点击在空格左边就往左移、右边就往右移同列同理。如果点击的方块与空格既不同行也不同列就是一次无效操作直接返回不计数不刷新。void BoardWidget::mousePressEvent(QMouseEvent *event) { int step cell_size_ kGap; int col (event-pos().x() - kGap) / step; int row (event-pos().y() - kGap) / step; if (row 0 || row GameBoard::kSize || col 0 || col GameBoard::kSize) { return; // 点在棋盘外面 } if (row board_.ZeroRow() col board_.ZeroCol()) { return; // 点到空格本身没有方块可移 } if (row board_.ZeroRow()) { board_.Move(col board_.ZeroCol() ? Left : Right); } else if (col board_.ZeroCol()) { board_.Move(row board_.ZeroRow() ? Up : Down); } else { return; // 方块与空格不在同一行或同一列不响应 } steps_; update(); CheckSolved(); }event-pos()返回的是相对于当前控件左上角的坐标不是窗口坐标这一点在报告里可以单独写一句体现你理解 Qt 的坐标体系。如果棋盘不是从控件原点开始画的点击换算公式也要跟着改我这里空出了kGap边距所以减去kGap再除步长。键盘事件走的是另一条路径直接调用board_.Move(dir)移动成功返回 true 才计数和刷新。移动失败说明方向越界不处理即可。两步代码共享同一个GameBoard不会出现鼠标动一下、键盘又动一下导致棋盘状态被撕裂的问题因为移动都集中在核心逻辑类里。void BoardWidget::keyPressEvent(QKeyEvent *event) { Direction dir; switch (event-key()) { case Qt::Key_Left: dir Left; break; case Qt::Key_Right: dir Right; break; case Qt::Key_Up: dir Up; break; case Qt::Key_Down: dir Down; break; default: return; // 其他按键不响应 } if (board_.Move(dir)) { steps_; update(); CheckSolved(); } }3.5 主窗口集成菜单、状态栏和窗口大小主窗口我继承了QMainWindow中央区域放BoardWidget顶部菜单栏放「游戏」菜单菜单里只有一个「新游戏」动作状态栏显示操作提示。窗口尺寸 480×520 是因为 4×4 棋盘加间距约 380 像素再多留一些给标题栏和菜单栏整体比例协调不会让棋盘挤在角落。#include QMainWindow #include QAction #include QMenuBar #include QStatusBar #include QKeySequence #include board_widget.h class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow() { board_ new BoardWidget(this); setCentralWidget(board_); resize(480, 520); setWindowTitle(tr(数字华容道)); QAction *newAct new QAction(tr(新游戏(N)), this); newAct-setShortcut(QKeySequence::New); connect(newAct, QAction::triggered, board_, BoardWidget::NewGame); QMenu *gameMenu menuBar()-addMenu(tr(游戏(G))); gameMenu-addAction(newAct); statusBar()-showMessage( tr(点击方块或按方向键移动目标是把数字排成 1 到 15右下角为空格)); } private: BoardWidget *board_; };QAction加入菜单后由菜单管理生命周期new出来不用手动delete。setShortcut(QKeySequence::New)给「新游戏」绑定了 CtrlN这是 Qt 内置的标准快捷键枚举免去自己处理按键转义的麻烦。注意Q_OBJECT宏不能省略它开启了 Qt 的信号槽机制MainWindow头文件如果要被编译必须在工程里经过元对象编译器处理Qt Creator 模板会自动完成这一步。4. 避坑给 Qt 数字华容道收尾前先看这 5 个坑这部分是我真正想跟你说的血泪经验。逻辑代码能跑、界面能显示离「能交作业」还差一步下面 5 个问题按「现象 → 原因 → 解决」写你大概率至少撞上一个。4.1 链接或运行时报 cannot mix incompatible qt library现象程序在编译阶段或启动瞬间弹出一个对话框写着cannot mix incompatible qt library (version ex50601) with this library。我见过的多数情况是使用 Qt 5.15.2 的库但工程里混进了别的版本头文件或者 MinGW 编译的代码去链接 MSVC 编译的 Qt DLL。原因Qt 的 DLL 区分编译器套件MinGW 和 MSVC 两套编译产物不通用此外 Qt 5 和 Qt 6 的库也不能混用。多数人是在 Qt Creator 里新建工程时 Kit 选错或者手动往.pro文件里加过 Qt 安装目录里的库路径。解决在 Qt Creator 左侧「项目 → 构建套件」里确认选中的 Kit 和你安装的 Qt 版本一致Windows 下如果装过多个 Qt删掉.pro里手动指定的LIBS路径让 qmake 自动匹配。如果用的是 qmake 工程检查QT widgets这一行是否完整。4.2 启动时报 could not find the qt platform plugin linuxfb现象程序编译通过运行时输出qt.qpa.plugin: could not find the qt platform plugin linuxfb然后退出。我遇到的一次是在树莓派 4 上做交叉编译程序用echo部署到板子后直接双击运行就报这个。原因Qt 依赖平台插件QPA来创建窗口Windows 上叫qwindowsLinux 桌面叫qxcb嵌入式无显示器环境叫linuxfb。你用的 Qt 是桌面版没有附带嵌入式平台的linuxfb插件或者插件目录存在但程序运行时找不到plugins/platforms的路径。解决桌面开发默认用windows或xcb平台不需要linuxfb如果是交叉编译到嵌入式设备部署时把 Qt 库目录下的plugins/platforms整个目录复制到可执行文件旁边并设置环境变量QT_QPA_PLATFORM_PLUGIN_PATH指向它。排查时先运行./你的程序 -platform xcb确认是不是平台插件缺失再针对性补齐。在无显示器的环境里想先验证逻辑可以用-platform offscreen让程序不弹窗运行。4.3 洗牌后怎么都拼不回去无解棋盘现象游戏看起来一切正常但你用人工还原时发现无论如何都差最后两个数字对调怎么都到不了目标状态。如果洗牌用的是「把 1 到 15 随机打散填进棋盘」这个问题几乎必然出现只是时间早晚。原因数字华容道 4×4 只有一半的随机排列是可解的。判定规则是把棋盘按行展开去掉空格统计逆序数列数为偶数时逆序数与空格所在行从底部数最后一行记为 0的奇偶性必须相同。完全随机生成不满足这个条件就会无解。解决改用 2.3 节的合法移动洗牌法从目标状态出发随机走 300 步保证可达。如果是老师指定的初始局面比如某些习题集里的怪布局写一个HasSolution(flat, cols, empty_row_from_bottom)函数做校验不达标就提示用户换一个布局。校验函数本身也可以写进课程设计报告的「算法分析」小节。4.4 点击方块没反应但方向键能走现象键盘控制正常鼠标点击棋盘上任何位置都没反应移动计数也不增加。我在第一次做这个题时卡了一晚上代码看起来没有任何问题。原因mousePressEvent里调用了board_.Move()之后没有调用update()。Qt 的绘图是事件驱动的你改了GameBoard的内部状态但界面不知道要重新绘制于是画面保持旧状态看起来就是没反应。另外还有一个隐蔽原因控件没有设置setFocusPolicy(Qt::StrongFocus)时键盘事件全被父窗口截获但鼠标事件本身不该受影响如果两者都失灵优先检查event-pos()的换算公式是否算出了棋盘外的坐标。解决每次移动完成后update()强制触发paintEvent。检查换算公式如果棋盘从(kGap, kGap)开始画点击坐标也应当先减kGap再除(cell_size_ kGap)。在mousePressEvent里加一行qDebug() row col;做断点输出能立刻定位是坐标问题还是刷新问题。4.5 界面上中文变成乱码现象窗口标题、菜单、状态栏里的中文全部显示成乱码但数字和英文正常。我用 MinGW 套件时没遇到改用 MSVC 2019 编译后复现过一次。原因MSVC 默认按本地代码页解析源文件中的字符串字面量而 Qt 5 内部按 UTF-8 处理QString两边编码不一致导致乱码另外 Qt Creator 保存源文件时如果不是 UTF-8 编码也会踩同样的坑。解决在 Qt Creator 里确认源文件编码为 UTF-8菜单「编辑 → 选择编码 → UTF-8」代码里统一用QStringLiteral或tr()包裹中文字符串不要用char*或std::string直接传给 Qt 接口。用 MSVC 时还可以在.pro文件里加一句QMAKE_CXXFLAGS /utf-8指定编译器按 UTF-8 解析源码这是最简单的后悔药。5. 把源码沉淀成课程设计报告图表、测试用例与设计说明代码写完只完成了一半另一半是把代码变成「能说服老师」的课程设计报告。我不会给你模板只讲清楚一份合格的报告该有的骨架以及每个部分到底在写什么。5.1 报告结构先写骨架再填代码一份数字华容道的课程设计报告我一般按六个部分组织需求分析、总体设计、详细设计、测试报告、遇到的问题、总结。下面这个表格可以直接当你的报告目录每一栏后面括号里是我建议的重点。章节重点内容篇幅建议需求分析功能需求移动、洗牌、胜负判断、非功能需求界面友好、操作流畅1 页总体设计系统模块图、模块划分核心逻辑模块 / 界面模块、数据流2 页详细设计关键类设计、核心函数说明、算法讲解4 页测试报告测试环境、测试用例表、结果截图2 页遇到的问题选 2 个真实踩坑问题写清楚1 页总结完成度、收获、不足与扩展1 页「遇到的问题」这一章最值得认真写。老师看过的报告里大概率满篇都是「本次设计顺利完成」你写一个类似「随机打乱可能导致无解改用合法移动洗牌」的实际问题可信度立刻不一样。这段内容不用另找素材第 4 章的 5 个坑里挑两个写进去就行。5.2 核心模块设计说明别贴代码画结构与流程详细设计部分最忌讳整段粘贴源码。正确的写法是先用表格列出「类 / 职责 / 关键接口」再用自然语言描述流程。下面的模块划分表是我这道题的版本你可以调整后直接使用。模块类或文件职责关键接口核心逻辑GameBoard棋盘状态、移动规则、洗牌、胜负判断Move()、Shuffle()、IsSolved()界面绘图BoardWidget绘制棋盘、处理鼠标与键盘事件paintEvent()、mousePressEvent()主窗口MainWindow菜单栏、状态栏、实例化棋盘控件newAct信号连接流程描述给出这个顺序程序启动 →MainWindow构造 → 创建BoardWidget→ 调用NewGame()洗牌 → 界面等待输入 → 鼠标/键盘事件触发Move()→update()重绘 →IsSolved()判断是否结束 → 弹出完成提示。这一段就是答辩时要口头讲的主线写报告时把它从代码里抽出来变成文字比贴代码有用得多。5.3 测试用例表把「能跑」变成「有测试」课程设计答辩时老师常问「你测试过哪些情况」。空口说「我都测过了」没用拿一张测试用例表给他看才是硬证据。我的测试表一般包含五类用例正常移动、边界移动、胜负判定、洗牌合法性、非法操作。每一条都给出操作步骤、预期结果和实际结果。编号测试内容操作步骤预期结果实际结果T01向左移动点击空格左边方块方块与空格交换步数加 1通过T02边界移动空格在棋盘左边缘时按左方向键移动失败状态不变通过T03胜负判定手动复原到目标状态弹出完成提示通过T04洗牌有效性连续新游戏 100 次每次局面不同且有解通过T05点击非相邻块点击与空格不相邻的方块无响应不计数通过T04 值得展开说你可以在main()里写一个循环连续构造 1000 个GameBoard并洗牌统计其中IsSolved()为真的次数。理论上 300 步合法移动洗牌后回到完成态的概率极低如果循环里出现多次「洗牌后已完成」说明洗牌步数太少或防回退逻辑失效。这条统计可以直接截一张控制台输出图放进报告证明你不是只做了界面没有做逻辑验证。5.4 答辩现场能拿得出手的验收流程报告写完以后我习惯把「验收流程」单独列在最后一页前面的位置确保演示时节奏不乱。流程是三句话第一打开程序展示初始随机棋盘第二点几次方块展示移动和计数第三用「新游戏」重置指出每次局面不同。如果时间充裕再演示键盘方向键操作说明你做了两套输入。还有一个常被忽略的细节把控制台版本的测试程序放在工程里但要和 Qt 版本保持同一个GameBoard类不要复制粘贴代码两份维护。答辩时可以现场跑一下控制台程序展示HasSolution的校验结果这一手能让老师觉得你的工程设计有分层意识——界面是一层核心逻辑是另一层两层通过接口通信而不是绑定在一起。6. 进阶给移动加过渡动画并用脚本验证算法正确性如果你想让这份作业「有亮点」最值得做的改进是给方块移动加一个 120ms 左右的滑动动画。核心逻辑不用动只需在BoardWidget里记录「哪个格子正在从哪移到哪」以及当前动画进度然后在paintEvent里按进度插值计算格子位置。动画我用QVariantAnimation它可以直接对double类型的进度做插值。移动时记录起止坐标动画每刷新一帧就把进度值存到成员变量里并调用update()paintEvent里画方块位置时把插值后的偏移加上去。关键参数是动画时长100ms 太短像瞬移200ms 又觉得拖沓120ms 到 150ms 是拼图类游戏比较舒服的节奏缓动曲线用OutCubic开头快、结尾慢视觉上有「滑入」而不是「平移」的质感。// BoardWidget 中增加动画相关成员 float moving_progress_ -1.0f; // -1 表示没有动画在播放 int moving_from_row_, moving_from_col_; int moving_to_row_, moving_to_col_; void BoardWidget::AnimateMove(int fromRow, int fromCol, int toRow, int toCol) { moving_from_row_ fromRow; moving_from_col_ fromCol; moving_to_row_ toRow; moving_to_col_ toCol; moving_progress_ 0.0f; QVariantAnimation *anim new QVariantAnimation(this); anim-setDuration(130); anim-setStartValue(0.0); anim-setEndValue(1.0); anim-setEasingCurve(QEasingCurve::OutCubic); connect(anim, QVariantAnimation::valueChanged, this, [this](const QVariant v) { moving_progress_ v.toFloat(); update(); }); connect(anim, QVariantAnimation::finished, this, [this]() { moving_progress_ -1.0f; // 动画结束回到非动画状态 }); anim-start(QAbstractAnimation::DeleteWhenStopped); }注意动画期间如果玩家又快速点了一次会同时出现两个动画争夺moving_progress_画面会跳。我建议在AnimateMove开头判断moving_progress_ 0就忽略新操作或者用anim-stop()把上一个动画停掉。验证方面我习惯写一个不弹窗的批量测试脚本专门检验洗牌算法的无解率。做法是循环 10000 次每次构造GameBoard、调用Shuffle(300)再把棋盘展开传给HasSolution校验奇偶性同时用IsSolved()确认没有洗回完成态。只要HasSolution全部返回 true就说明洗牌逻辑在这几万次采样里没有产生不可达局面。这个脚本也可以在报告里作为「算法正确性验证」的量化结果。#include iostream #include game_board.h static bool HasSolution(const std::vectorint flat, int cols, int emptyRowFromBottom) { int inv 0; for (size_t i 0; i flat.size(); i) { for (size_t j i 1; j flat.size(); j) { if (flat[i] 0 || flat[j] 0) continue; if (flat[i] flat[j]) inv; } } if (cols % 2 1) return inv % 2 0; return (inv emptyRowFromBottom) % 2 0; } int main() { int ok 0; for (int i 0; i 10000; i) { GameBoard g; g.Shuffle(300); std::vectorint flat; int emptyRowFromBottom -1; for (int r 0; r GameBoard::kSize; r) { for (int c 0; c GameBoard::kSize; c) { flat.push_back(g.At(r, c)); if (g.At(r, c) 0) emptyRowFromBottom GameBoard::kSize - 1 - r; } } if (HasSolution(flat, GameBoard::kSize, emptyRowFromBottom)) ok; } std::cout 可解比例: ok / 10000 std::endl; return 0; }这段代码说明两点一是HasSolution的公式对 4×4 的合法性二是洗牌算法质量。我自己的测试里 10000 次全部可解但你要理解这个结果依赖Shuffle永远从目标状态出发而不是生成随机排列再用HasSolution过滤——前者从设计上保证了可解后者是事后补救。到这里一份「核心逻辑 Qt 界面 课程设计报告」的完整方案就闭环了。我自己的习惯是每次写完一个游戏类项目都先跑一遍无逻辑依赖的批量测试再谈界面效果。这次做数字华容道也是如此测试通过后再套 Qt踩坑少了一半。希望帮到你。本文还有配套的精品资源点击获取
返回列表