ARTICLE DETAIL

资讯详情

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

C++数字华容道从控制台到Qt:棋盘建模、可解性与界面迁移

C++数字华容道从控制台到Qt:棋盘建模、可解性与界面迁移 简介面向高校C课程设计场景这份资源提供基于Qt实现的图形化数字华容道小游戏完整源码与课程设计报告。项目从VS中构建Szhrd类输出字符版雏形再到Qt Creator中设计多难度窗口、绘图函数与槽函数并加入彩蛋视频与背景音乐适合作为高级语言程序设计大作业参考。压缩包共126个文件约6.17MB涵盖41个cpp源文件、35个h头文件、31个ui界面文件及pro工程配置另有mp4彩蛋视频、mp3/wav音频和pdf报告结构清晰便于按模块学习。目前已有209人学习下载。通过源码可掌握面向对象封装、Qt信号槽机制、界面与逻辑分离等实践方法报告亦可直接参考整体流程设计与实现思路。1. 从 cout 到 Qt 窗口C 数字华容道的分层与界面迁移一个 4x4 数字华容道在控制台里用 cout 逐行打印和用 Qt 绘制成彩色方块差距比大多数初学者想象的大。这套高级语言程序设计大作业最有价值的点不是“做出了一个能玩的游戏”而是把同一套 C 棋盘逻辑先后跑在字符终端和窗口环境里Szhrd 类负责棋盘、移动和胜负判断QWidget 只负责 paintEvent 绘制、键盘与鼠标事件、槽函数分发。对 C 新手来说这是一个很容易理解的分层方式对写过几年业务代码的工程师也能从它的窗口跳转、彩蛋视频和背景音乐实现里观察到 Qt 对象生命周期与事件循环的设计边界。下面按棋盘建模、可解性判定、Qt 绘图、音视频联动到发布验证的路径把这份源码拆成一份可以复现的技术笔记。2. 棋盘建模与可解性判定Szhrd 类的数据结构与随机局面生成2.1 为什么用一维数组存棋盘而不是 vectorvector 控制台版和 Qt 版共用的 Szhrd 类核心成员就是一段连续内存里的棋盘状态。用int board_[25]而不是vectorvectorint不是因为后者写不出来而是因为数字华容道所有核心操作都依赖“格子索引”空白格下标、目标格下标、上下左右相邻关系。一维数组可以直接用index / size和index % size换算行列逆序数计算也只需要一次线性遍历不需要两层 vector 的间接寻址。class Szhrd { public: static constexpr int kMaxSize 5; int board_[kMaxSize * kMaxSize]; int size_ 4; // 3/4/5 对应不同难度 int blank_ 15; // 空位下标 void reset(int size); };这份声明把“逻辑类”和“界面类”之间的契约定住了界面层只需要拿到board_和size_就能绘制不需要关心移动细节。blank_单独保存空位下标省去每次判空都扫描整个数组。很多课程设计版本会在 QWidget 里直接写一个 4x4 的 QPushButton 数组再把按钮文本和棋盘值耦合在一起这样的代码一旦要改成 5x5 或增加步数统计就要重写界面逻辑。2.2 逆序数判定只有随机打乱还不够随机生成一个局面后如果直接丢给玩家有很大概率是无解的。4x4 数字华容道不是任意排列都能通过滑动还原。判断方法依赖逆序数和空位位置把棋盘按行展开成序列跳过 0计算逆序数inv。记空位从底部数起的行号为blankRowFromBottom最底行是 1。如果size是奇数可解条件是inv % 2 0。如果size是偶数可解条件是(inv blankRowFromBottom) % 2 1。这个规则的来源是每一次水平滑动不会改变逆序数每一次垂直滑动会翻转逆序数奇偶性同时也会改变blankRowFromBottom的奇偶性。因此inv blankRowFromBottom的奇偶性在合法移动下保持不变。目标状态的空位在右下角inv 0blankRowFromBottom 1所以可解局面必须满足这个和为奇数。int findBlank(const int board[], int size) { for (int i 0; i size * size; i) if (board[i] 0) return i; return -1; } bool isSolvable(const int board[], int size) { int inv 0; for (int i 0; i size * size; i) { if (board[i] 0) continue; for (int j i 1; j size * size; j) { if (board[j] 0) continue; if (board[i] board[j]) inv; } } int blankIdx findBlank(board, size); int blankRowFromBottom size - blankIdx / size; if (size % 2 1) return inv % 2 0; return (inv blankRowFromBottom) % 2 1; }这段代码的时间复杂度是 O(n²)对 5x5 棋盘只有 625 次比较生成局面时调用一次完全没压力。注意blankRowFromBottom的计算第 0 行是最顶行size - row是行号例如 4x4 中空位在第 4 行下标 3时blankRowFromBottom 1正是目标状态。2.3 生成初始局面完全随机加修正还是从目标态步进常见做法有两种。第一种是从目标态随机执行若干次合法移动这样生成的一定可解但打乱步数需要人工调太少会太简单太多会退化成随机局面第二种是直接用std::shuffle生成任意排列再用可解性判定修正。我一般会在课程设计里用第二种因为判定函数写出来了就必须真的用起来。修正逻辑也很简单随机排列如果不可解交换前两个非零数字即可因为交换两个非零数字会改变inv的奇偶性但不会改变空位行号所以一次交换就能让局面可解。void Szhrd::reset(int size) { size_ size; int total size * size; std::vectorint vals; for (int i 0; i total - 1; i) vals.push_back(i 1); vals.push_back(0); std::mt19937 rng(std::random_device{}()); std::shuffle(vals.begin(), vals.end(), rng); if (!isSolvable(vals.data(), size_)) { int first -1, second -1; for (int i 0; i total; i) { if (vals[i] 0) continue; if (first -1) first i; else if (second -1) { second i; break; } } std::swap(vals[first], vals[second]); } std::copy(vals.begin(), vals.end(), board_); blank_ findBlank(vals.data(), size_); }这段代码把随机数引擎mt19937和shuffle放进了逻辑层界面层只需要在点击“开始游戏”时调用reset(size)。需要注意if (!isSolvable(...))之后交换的是vals里的前两个非零元素不是交换 0 和 1。如果直接交换vals[0]和vals[1]当 0 出现在这两个下标时修正逻辑会失败。难度棋盘尺寸打乱方式空位初始位置简单3x3随机排列 可解修正右下角普通4x4随机排列 可解修正右下角挑战5x5随机排列 可解修正右下角3. Qt 绘图与槽函数GameWidget 的事件响应和窗口跳转3.1 界面层如何复用同一个 Szhrd 类Qt 版不是把控制台代码删掉重写而是在 Qt Creator 里新建GameWidget让它持有Szhrd对象。这个设计比把所有逻辑都塞进 QWidget 子类更干净因为Szhrd可以在没有 Qt 环境的地方单独跑单元测试也可以把在 VS 里调试过的命令行版本原样保留。class GameWidget : public QWidget { Q_OBJECT public: explicit GameWidget(QWidget* parent nullptr); void startGame(int size); protected: void paintEvent(QPaintEvent*) override; void keyPressEvent(QKeyEvent*) override; void mousePressEvent(QMouseEvent*) override; signals: void gameFinished(int moves); private: Szhrd logic_; int cellSize_ 80; };GameWidget不直接依赖具体难度难度通过startGame(int size)传入。这样可以避免为简单、普通、挑战各写一个窗口类。很多初学者会为 3x3 建一个类、4x4 再建一个类复制粘贴大量 paintEvent 代码最后改一个格的配色要改三处。更好的做法是一个GameWidget实例由主窗口通过QStackedWidget切换页面。3.2 绘制数字方块paintEvent 和坐标换算绘图函数是所有 Qt 小游戏的核心。每次update()被调用Qt 就会触发一次paintEvent。这里要注意不要在 paintEvent 里直接读鼠标位置或做游戏逻辑判断它只负责画。坐标换算公式统一为先算可用边长再取最小值为格子边长最后把棋盘整体居中。void GameWidget::paintEvent(QPaintEvent*) { QPainter painter(this); int margin 12; int n logic_.size(); int cell qMin(width() - 2 * margin, height() - 2 * margin) / n; int startX (width() - cell * n) / 2; int startY (height() - cell * n) / 2; painter.setRenderHint(QPainter::Antialiasing); QFont font painter.font(); for (int r 0; r n; r) { for (int c 0; c n; c) { int v logic_.value(r, c); QRectF rect(startX c * cell 1, startY r * cell 1, cell - 2, cell - 2); if (v 0) { painter.fillRect(rect, QColor(40, 44, 52)); continue; } painter.fillRect(rect, QColor(86, 156, 214)); painter.setPen(Qt::white); font.setPixelSize(cell * 2 / 5); painter.setFont(font); painter.drawText(rect, Qt::AlignCenter, QString::number(v)); } } }这里的关键是QRectF的构造函数参数。每个格子要留 1 像素空隙所以坐标加 1宽高减 2否则相邻方块会粘在一起。qMin(width() - 2 * margin, ...)保证窗口被拉长时棋盘不会变形。font.setPixelSize(cell * 2 / 5)让数字大小跟随格子缩放5x5 难度下同样不需要单独调整字体。3.3 方向键、鼠标点击与槽函数跳转键盘事件里最容易出错的是方向键语义。常见设计是“按左键把空位右边的数字块向左滑入空位”所以代码里先拿到空位坐标再根据按键方向计算目标方块坐标。也就是说按方向键移动的是方块不是空位。void GameWidget::keyPressEvent(QKeyEvent* e) { int blankRow logic_.blankRow(); int blankCol logic_.blankCol(); int targetRow blankRow; int targetCol blankCol; switch (e-key()) { case Qt::Key_Left: targetCol blankCol 1; break; case Qt::Key_Right: targetCol blankCol - 1; break; case Qt::Key_Up: targetRow blankRow 1; break; case Qt::Key_Down: targetRow blankRow - 1; break; default: QWidget::keyPressEvent(e); return; } if (targetRow 0 || targetRow logic_.size() || targetCol 0 || targetCol logic_.size()) { return; } logic_.move(targetRow, targetCol); if (logic_.isWin()) emit gameFinished(logic_.moves()); update(); }logic_.move(row, col)是 Szhrd 类里的一个方法内部必须校验目标位置与空位是否相邻。不要在校验前直接交换board_[blank_]和board_[target]否则快速连按方向键时会产生“隔空吃子”的 bug。鼠标点击同理在mousePressEvent里根据点击坐标反推行列再判断点击位置是否与空位相邻。窗口跳转用槽函数完成。主窗口里维护一个QStackedWidget把菜单页和 GameWidget 放进去点击不同难度按钮时调用同一个槽函数void MainWindow::startGame(int size) { gameWidget_-startGame(size); ui-stackedWidget-setCurrentWidget(gameWidget_); gameWidget_-setFocus(); }setFocus()经常被漏掉。如果焦点还在主窗口的 QPushButton 上游戏界面的 keyPressEvent 根本不会触发方向键完全无效。这属于 Qt 事件聚焦机制的基本约束不是信号槽问题。3.4 界面层排错表格现象常见原因处理方式点击按钮后窗口不变connect 的接收者是 this但当前页面不是目标页面检查 stackedWidget 设置的是不是 GameWidget 指针方向键无响应GameWidget 没有 setFocusPolicy(Qt::StrongFocus)构造函数中设置焦点策略并在切换到游戏页时 setFocus()窗口拉伸后格子错乱没有重算 cellSize 和 startX重写 resizeEvent在其中只调用 update()移动后画面不更新只改了 board_没有调用 update()每次移动后必须调用 update() 触发重绘Qt 5 老代码 connect 报错槽函数参数类型不匹配使用 Qt 5 新语法并保证信号参数与槽函数参数一致4. 音效、彩蛋与计时器QMediaPlayer 和 Qt 事件循环的协作边界4.1 背景音乐和音效不要放在同一个类里管理这份课程设计报告里提到背景音乐和游戏音效的函数加进了 Szhrd 类。从逻辑层角度看我更建议把音频独立成AudioManager因为棋盘类负责的是纯 C 状态一旦包含QMediaPlayer这个类就不能再用于 VS 控制台工程。原项目从控制台版本迁移到 Qt 版本正是需要保持逻辑层的纯净性。音频选型上背景音乐用QMediaPlayer QMediaPlaylist短音效用QSoundEffect。短音效需要立刻播放比如数字块碰撞声QSoundEffect延迟更低背景音乐需要循环用QMediaPlaylist的 Loop 模式最直接。以下代码按 Qt 5.15 的 API 写这也是很多课程设计使用的版本。#include QMediaPlayer #include QMediaPlaylist #include QSoundEffect class AudioManager : public QObject { Q_OBJECT public: explicit AudioManager(QObject* parent nullptr) : QObject(parent) { moveEffect_ new QSoundEffect(this); moveEffect_-setSource(QUrl::fromLocalFile( QCoreApplication::applicationDirPath() /assets/move.wav)); moveEffect_-setVolume(0.3f); } void playBgm() { if (bgmPlayer_) return; auto* playlist new QMediaPlaylist(this); playlist-addMedia(QUrl::fromLocalFile( QCoreApplication::applicationDirPath() /assets/bgm.mp3)); playlist-setPlaybackMode(QMediaPlaylist::Loop); bgmPlayer_ new QMediaPlayer(this); bgmPlayer_-setPlaylist(playlist); bgmPlayer_-play(); } void playMove() { moveEffect_-play(); } private: QMediaPlayer* bgmPlayer_ nullptr; QSoundEffect* moveEffect_ nullptr; };路径问题值得单独说。QDir::currentPath()在 Qt Creator 中通常是构建目录而不是 exe 所在目录。直接写相对路径“assets/bgm.mp3”在调试时可能报错发布后也可能找不到。用QCoreApplication::applicationDirPath()拼接路径可以保证 exe 无论从哪个工作目录启动都能找到资源。4.2 彩蛋视频QDialog 与 QVideoWidget 的组合原项目在完成高难度华容道后播放 happy 猫视频。这个彩蛋的实现方式一般有两种一种是在 GameWidget 胜利后弹出 QDialog另一种是使用独立的 QWidget 覆盖当前窗口。我建议用 QDialog因为它的exec()会启动一个嵌套事件循环播放视频期间键盘事件不会漏到游戏界面。void MainWindow::showEasterEgg() { auto* dialog new QDialog(this); auto* videoWidget new QVideoWidget(dialog); auto* player new QMediaPlayer(dialog); player-setVideoOutput(videoWidget); player-setMedia(QUrl::fromLocalFile( QCoreApplication::applicationDirPath() /assets/happy_cat.mp4)); auto* layout new QVBoxLayout(dialog); layout-addWidget(videoWidget); connect(player, QMediaPlayer::mediaStatusChanged, dialog, [player](QMediaPlayer::MediaStatus status) { if (status QMediaPlayer::EndOfMedia) { player-stop(); } }); player-play(); dialog-exec(); }这个例子里的 Qt 5 写法在 Qt 6 中已经不推荐Qt 6 需要setAudioOutput并且QMediaPlaylist被移除了。写课程设计报告时如果注明“基于 Qt 5.15 开发”答辩老师不会挑这个毛病。真正需要关注的是播放器生命周期player的父对象是dialog所以 dialog 关闭时播放器会被销毁。如果只调用dialog-show()而不exec()注意不要让 dialog 被垃圾回收否则视频画面会闪退。4.3 计时器与胜利信号QTimer 不会拖累绘图线程步数统计可以直接放在 Szhrd 类里每次move()成功就自增。计时器放在 GameWidget 中用QTimer每秒更新一次 label不需要写多线程。QTimer基于事件循环在按键响应间隙触发不会阻塞 paintEvent。要防止一个常见误区不要在paintEvent里启动QTimer因为重绘频率很高会造成计时器重置。void GameWidget::startGame(int size) { logic_.reset(size); moveCount_ 0; seconds_ 0; timer_.start(1000); } void GameWidget::onTimeout() { seconds_; emit timeUpdated(seconds_); }当gameFinished信号发出时主窗口接收后先timer_.stop()再弹出结算对话框。如果不先停定时器对话框出现后时间还会继续跳这会让答辩演示时看起来像是 bug。媒体类型推荐 Qt 类循环方式延迟适用场景背景音乐QMediaPlayer QMediaPlaylistLoop 模式较高MP3 长音频短音效QSoundEffect无低移动、碰撞、胜利彩蛋视频QMediaPlayer QVideoWidgetEndOfMedia 后 stop较高视频彩蛋5. windeployqt 打包、验证与课程设计报告的隐藏加分点5.1 用 windeployqt 打一个能拷走的目录Qt 程序在开发机上运行依赖 Qt 5.15.2 的 DLL。直接拷 exe 到别的电脑会提示找不到Qt5Core.dll。标准做法是打开对应编译器版本的 Qt 命令行环境进入构建目录后运行windeployqt。如果是在 Qt Creator 里构建release 输出一般在build-项目名-Desktop_Qt_5_15_2_MSVC2019_64bit-Release\release\。cd /d D:\Qt\5.15.2\msvc2019_64\bin windeployqt.exe --release D:\build\szhrd\release\szhrd.exe执行完以后exe 同目录下会出现platforms、styles、imageformats等文件夹。检查时必须看platforms\qwindows.dll是否存在缺少这个文件程序启动时直接提示“could not find or load the Qt platform plugin”。打包时也别忘了把assets目录bgm.mp3、happy_cat.mp4、move.wav复制到 exe 同级目录因为代码里用的是applicationDirPath()路径。如果是在 vscode 配置 c/c 环境而不是直接用 Qt Creator还需要手动把 Qt 的 bin 目录加入 PATH或者用 cmake 的windeployqttarget。相比之下保持项目里的.pro文件完整直接用 Qt Creator 打开最省事。5.2 报告里值得补的测试记录课程设计报告里如果只贴代码和截图答辩分数不会高。建议补一张手工测试表专门覆盖边界场景输入场景预期结果实测结果随机生成 1000 个 4x4 局面调用 isSolvable全部返回 true通过连续快速按方向键 20 次数字块每次只移动一格无跳变通过5x5 难度完成最后一格滑动立即停止计时并弹出结算窗口通过游戏窗口拉伸到 800x400棋盘保持正方形并居中通过彩蛋视频播放期间关闭对话框播放器停止程序无崩溃通过这些用例里最有技术含量的是第一行。可以再加一个反向验证对 3x3 随机排列直接算isSolvable统计无解比例理论上是 50% 左右。写进报告中能证明你是真的理解可解性判定而不是网上抄了一段判断函数。5.3 答辩追问的一个准备方向老师最可能问的是为什么交换两个非零数字一定能修正局面答案在 2.2 节的奇偶不变式里。交换两个非零数字只改变逆序数奇偶性不改变空位行号所以(inv blankRowFromBottom)的奇偶性一定翻转。无解局面要变成有解局面只需要让这个和变成奇数因此一次交换一定够用。改到 5x5 时这个判定依然有效但如果想进一步计算“最少多少步还原”那就不是逆序数能解决的了需要换 A* 搜索或 IDA* 来估价。本文还有配套的精品资源点击获取
返回列表