简介面向计算机专业毕业设计与课程设计需求这份资源是一套基于C与QT开发的仿宝石迷阵游戏完整项目包含源码、文档说明及各类素材适合正在准备毕设或希望借助项目实战提升编程能力的学生。项目经过导师指导并获99分高分评价代码完整确保可运行能帮助读者掌握QT图形界面搭建、游戏核心逻辑、音效动画资源调用等关键技术也可作为课程设计或期末大作业的参考方案。压缩包共185个文件整体约44.77MB其中52个PNG图片、13个GIF动画与6个JPG负责视觉呈现15个WAV提供操作音效22个CPP源文件与19个H头文件构成游戏主体逻辑7个UI文件维护界面布局另含3个PRO工程文件和文档说明目录划分清晰便于按模块学习。已有95人学习下载适合通过完整项目快速理解C与QT协同开发的实战流程。1. 仿宝石迷阵毕设一条不会让你临阵换题的 C/Qt 路线基于C和QT实现的仿宝石迷阵游戏项目源码文档说明是毕业设计里少见的“性价比”选题。它不是让你从零研究引擎而是用最经典的二维消除玩法把 C 的类设计、数据结构、Qt 的事件循环和自绘控件全部串起来。这个题目不需要美术资源不需要数据库一个 QMainWindow 加一个自定义 QWidget 就能完成答辩演示。它的核心其实只做三件事用二维数组维护宝石网格用相邻匹配判断消除再用 QTimer 驱动下落和补位。适合那些 C 基础一般、想尽量控制风险并且能讲清楚技术亮点的同学。2. 先把消除逻辑做成纯 C 类棋盘、匹配、下落与随机数2.1 棋盘数据结构为什么必须独立于 QWidget我见过不少同学一上来就在 QWidget 里塞一个二维 QPushButton 数组然后把消除逻辑全写在按钮点击事件里。这种做法确实能快速看到效果但你只要写一次下落补位就会明白在界面代码里处理网格数据就是给自己挖坑。宝石迷阵的核心是状态不是控件。QPushButton 自带边框、焦点、点击切换样式你只想要它的坐标响应却要绕开一堆多余的交互行为。所以第一步是把“棋盘”独立成一个纯 C 类不依赖 Qt 的界面层。这样你可以在命令行里直接测试逻辑也可以在 Qt 里复用。定义一个简单的棋盘类头文件// board.h #pragma once #include vector #include random enum class Gem { Empty, Red, Blue, Green, Yellow, Purple }; struct Position { int row; int col; }; class GameBoard { public: GameBoard(int rows, int cols, int gemKinds 5); void initBoard(); // 生成开局棋盘 bool swapGems(Position a, Position b); // 交换两颗宝石有效则返回 true int clearMatches(); // 消除所有成组宝石返回消除个数 void collapse(); // 使空位上的宝石下落 void refill(); // 从顶部补入新宝石 bool canMove(); // 是否存在任何可行的交换 const std::vectorstd::vectorGem grid() const { return grid_; } int score() const { return score_; } private: bool isMatch(); // 检查当前棋盘是否有至少三连 void markMatches(std::vectorPosition out); // 把所有成组位置标记出来 int rows_; int cols_; int gemKinds_; int score_ 0; std::vectorstd::vectorGem grid_; std::mt19937 rng_; };这里把棋盘尺寸和宝石种类数做成参数开局时按rows_ * cols_生成随机棋盘。使用std::mt19937而不是rand()是因为我们可以在测试时传入固定种子复现同一盘局这点在调试消除逻辑时非常重要。独立于 UI 的好处在于你可以在main函数里写一个循环反复交换、消除、补充用断言验证分数是否按预期增长。很多毕设翻车都是因为逻辑和界面耦合测试只能靠鼠标点出了 bug 还要手动回放。2.2 交换与消除判定算法要写成无 UI 依赖的函数交换是最容易写错的函数很多实现是先交换判断有没有三连没有就交换回去但忘了在交换后同步刷新分数和状态。一个标准做法是bool GameBoard::swapGems(Position a, Position b) { if (std::abs(a.row - b.row) std::abs(a.col - b.col) ! 1) return false; if (grid_[a.row][a.col] Gem::Empty || grid_[b.row][b.col] Gem::Empty) return false; std::swap(grid_[a.row][a.col], grid_[b.row][b.col]); if (isMatch()) { return true; // 这次交换产生了消除保留 } // 没有消除换回去 std::swap(grid_[a.row][a.col], grid_[b.row][b.col]); return false; }注意我用曼哈顿距离来判断两颗宝石是否相邻横纵坐标差的绝对值之和等于 1就说明是上下或左右紧邻。这个条件在鼠标事件里也要用界面层不能用“拖动距离小于某个像素”来替代否则会出现斜对角交换的 bug。消除判定的核心是isMatch()。我一般会同时检查水平方向和垂直方向只要某一行或某一列连续出现三个相同宝石就算匹配。这里有一个隐藏问题一次交换可能同时产生两处消除比如一个“L”形。所以clearMatches()不能只扫一遍必须把扫描到的所有成组位置标记出来然后一次性清空。int GameBoard::clearMatches() { std::vectorPosition toClear; markMatches(toClear); if (toClear.empty()) return 0; for (const auto pos : toClear) { grid_[pos.row][pos.col] Gem::Empty; } score_ toClear.size() * 10; return static_castint(toClear.size()); }这里加分按消除数量算也可以按连击倍数算。我建议初版用单价乘数量加一次连击加成后续文档里能写出“采用了动态难度评分”。但初版最好简单分数只在clearMatches里更新不要在 UI 层自说自话地加分否则逻辑和显示很容易不一致。2.3 下落与填补QTimer 之外的另一层状态机消除之后空位要掉落掉落之后还要从顶部补新宝石。很多同学在这里写 while 循环一直往下塞结果界面卡死。正确的做法是把它设计成一个有限状态机先把空位逻辑上移除然后让非空宝石逐列下移最后在顶部生成新宝石。void GameBoard::collapse() { // 从底向上逐列处理 for (int c 0; c cols_; c) { int writeRow rows_ - 1; for (int r rows_ - 1; r 0; --r) { if (grid_[r][c] ! Gem::Empty) { if (writeRow ! r) { grid_[writeRow][c] grid_[r][c]; grid_[r][c] Gem::Empty; } --writeRow; } } } } void GameBoard::refill() { for (int c 0; c cols_; c) { for (int r 0; r rows_; r) { if (grid_[r][c] Gem::Empty) { grid_[r][c] static_castGem( 1 static_castint(rng_() % gemKinds_)); } } } }下落代码的关键是“写指针”和“读指针”从下往上扫遇到非空宝石就放到最下面的空位然后写指针上移。这样避免了每次消除后都整列重新判断的臃肿逻辑。refill里用rng_() % gemKinds_而不是uniform_int_distribution是因为这里是均匀取模实现的分布偏差对游戏影响不大而且代码更短。如果你的毕设文档里写了“随机性分析”建议还是用std::uniform_int_distribution这是标准做法。我一般会在refill后再次调用clearMatches并循环直到没有匹配否则会出现“消除后生成的新宝石刚好又连成三个”这种情况。这个循环必须放上限比如最多执行 10 次防止极端情况下无限递归。这也是一个文档里能写出来的边界设计。2.4 随机数生成写不对开局棋盘全是“死局”还有一个高频坑开局随机棋盘用rand() % 6生成结果经常出现初始就有三连第一次交换都不知道点什么。更常见的问题是完全随机棋盘产生后玩家没有任何可交换的步也就是“死局”。宝石迷阵的规则里开局必须保证没有三连并且至少存在一个可交换的合法步。标准做法是在initBoard()里先生成一个随机棋盘然后逐格检查若发现连续相同则替换为另一个颜色直到不再有三连。注意不要只检查生成结果的最后一行因为水平消除可能在中间行出现。void GameBoard::initBoard() { std::uniform_int_distributionint dist(1, gemKinds_); for (int r 0; r rows_; r) { for (int c 0; c cols_; c) { do { grid_[r][c] static_castGem(dist(rng_)); } while ((c 2 grid_[r][c] grid_[r][c-1] grid_[r][c] grid_[r][c-2]) || (r 2 grid_[r][c] grid_[r-1][c] grid_[r][c] grid_[r-2][c])); } } if (!canMove()) initBoard(); // 递归重新生成 }这里的dist(rng_)生成了 1 到gemKinds_之间的数字static_castGem把数字映射成枚举。gemKinds_建议用 5因为颜色太少会让棋盘充满死局颜色太多又不利于玩家识别。至于canMove()就是遍历所有相邻交换对每个组合调用swapGems然后完全不保存判断是否有一次产生消除。这个函数不要去改实际棋盘我当时就踩过这个坑忘了在交换后换回去导致整个棋盘被改得一团糟。3. 用 Qt 把棋盘画出来自绘控件、鼠标事件与 QTimer 动画3.1 自绘棋盘 vs Qt Designer为什么手动写 paintEvent仿宝石迷阵的界面不复杂但用 Qt Designer 拖一组 QPushButton 来做棋盘会让代码很难看。QPushButton 有内建边框、选中态和焦点矩形你要做的是把它当一块静态画布而不是按钮。更好的做法是继承一个QWidget重写paintEvent用QPainter画宝石。这样每个格子可以是任意矩形区域颜色、纹理、动画都可以直接控制。常见的实现是定义cellSize_在resizeEvent里计算格子尺寸让窗口拉伸时棋盘跟着变。不要写死像素值否则在 2K 屏上格子小到看不见。我一般这样定义控件class BoardWidget : public QWidget { Q_OBJECT public: explicit BoardWidget(GameBoard* board, QWidget* parent nullptr) : QWidget(parent), board_(board) { setMinimumSize(8 * 48, 8 * 48); } protected: void paintEvent(QPaintEvent*) override; void mousePressEvent(QMouseEvent* e) override; void mouseReleaseEvent(QMouseEvent* e) override; void resizeEvent(QResizeEvent* e) override; private: QRect cellRect(int row, int col) const; Position cellAt(const QPoint p) const; GameBoard* board_; int cellSize_ 48; };board_指针指向外部传入的GameBoard对象控件只负责读取和触发不直接修改数据。这一步设计能让第 2 章的纯 C 逻辑原封不动地接入。paintEvent里最重要的就是坐标换算cellRect(row, col)返回该格子在整个控件内的 QRect。QRect BoardWidget::cellRect(int row, int col) const { return QRect(col * cellSize_, row * cellSize_, cellSize_, cellSize_); }cellAt是反向换算把鼠标点的坐标换算成棋盘行列。注意要在mousePressEvent里做边界判断如果点击位置超出行列范围直接返回不要把越界坐标交给swapGems。3.2 鼠标交互的三个事件按下、释放、移动的边界判断宝石迷阵的交互有两种主流做法一种是点击第一颗再点击第二颗一种是拖拽交换。我建议用拖拽因为更贴近手机上的体验但实现时有一个顺序问题在鼠标按下时记录起始格子在鼠标释放时判断目标格子。如果鼠标在移动过程中离开了棋盘区域也要能正确处理不能产生非相邻交换。void BoardWidget::mousePressEvent(QMouseEvent* e) { if (e-button() Qt::LeftButton) { Position pos cellAt(e-pos()); if (pos.row 0 pos.col 0) { pressedPos_ pos; } } } void BoardWidget::mouseReleaseEvent(QMouseEvent* e) { if (e-button() ! Qt::LeftButton || pressedPos_.row 0) return; Position releasePos cellAt(e-pos()); if (releasePos.row 0 releasePos.col 0) { // 只允许相邻交换 if (std::abs(pressedPos_.row - releasePos.row) std::abs(pressedPos_.col - releasePos.col) 1) { if (board_-swapGems(pressedPos_, releasePos)) { // 交换有效触发消除和下落状态机 startResolve(); } } } pressedPos_ Position{-1, -1}; }这里cellAt要返回一个无效坐标表示越界。我习惯用row -1或col -1作为哨兵而不是抛出异常因为鼠标点一下不该让程序崩掉。还有一个细节如果按下时在棋盘外释放时在棋盘内也要忽略所以pressedPos_.row 0的判断必须放在前面。3.3 动画刷新的两个方案QTimer 轮询 vs QPropertyAnimation消除下落如果瞬间完成玩家看不清楚过程体验很差。常见做法是在startResolve()里启动一个QTimer每隔 30 毫秒推进一次游戏状态机的步骤。第 2 章的clearMatches、collapse、refill正好可以拆成三步第一步消除并暂停一下显示空位第二步下落动画第三步补充新宝石。用 QTimer 的好处是逻辑简单坏处是动画不够圆滑。void BoardWidget::startResolve() { resolveTimer_.start(30); // 30ms 触发一次 } void BoardWidget::onResolveTick() { if (!resolving_) { if (board_-clearMatches() 0) { resolveTimer_.stop(); return; } resolving_ true; } else { board_-collapse(); board_-refill(); resolving_ false; } update(); // 触发重绘 }这个时序里resolving_标志位防止重复进入消除步骤。QTimer 的间隔就是动画帧率30 毫秒大约 33 帧够用。如果要更顺滑可以改成在每个状态下记录当前动画进度百分比在paintEvent里按百分比绘制宝石的 y 坐标。那样代码会复杂很多但答辩时能讲“我实现了基于时间戳的补间动画”分数立刻不一样。另一个方案是用QPropertyAnimation给每个宝石的位置做插值但宝石数量多时每个都创建动画对象内存开销不小而且下落逻辑要插桩很多位置计算。我不建议初版做这个先让“能跑”优先。3.4 用 QLabel 和 QStatusBar 显示分数与提示在 Qt 主窗口里除了棋盘还要显示分数、当前状态和操作提示。别小看这个布局它直接影响答辩观感。我会在MainWindow下方放一个QStatusBar更新分数和“消除 10”的提示右侧放一个QLabel显示当前得分字体调到 18pt。分数更新的触发要放在onResolveTick里消除完成后从board_-score()读一次再刷新两个控件。这里同样要避免逻辑和 UI 耦合BoardWidget只负责绘制棋盘和发送信号例如void matchResolved(int score)主窗口收到信号再更新标签。这样如果以后换 Qt Quick 或前端核心逻辑完全不用动。4. 编译与运行期避坑几个让毕设当场翻车的典型问题4.1 现象编译报错 “fatal: cannot mix incompatible Qt library (version ex50601) with this library”这是 Qt 版本混用的典型报错。很多同学在电脑上装了不止一套 Qt比如系统自带的 Qt5.9、Qt5.15.2还有 Qt6。用 QtCreator 构建时如果头文件路径和链接库路径来自不同版本就会出现版本号里的ex506015.6.1 的内部版本号与当前库不一致。原因通常是项目文件里手动设置了INCLUDEPATH或LIBS而不是交给 QMake/CMake 自动判断。解决方法是在 QtCreator 的“构建目录”里执行彻底清理然后删除用户目录下的CMakeCache.txt或.qmake.stash再到“工具→选项→Kits”里确认编译器、调试器、Qt 版本三者匹配。你自己写的INCLUDEPATH要么删掉要么保证和你选择的 Qt 套件完全一致。我遇到过一次是因为另一个项目的环境变量QTDIR指到了旧版 Qt导致命令行构建和 QtCreator 构建结果不同。4.2 现象运行程序时提示 “qt.qpa.plugin: Could not find the Qt platform plugin linuxfb”这个报错常见于把 Qt 程序拷贝到没有桌面环境的 Linux 板子或容器里。原因是 Qt 的窗口系统插件没被加载而程序里又指定了linuxfb平台插件。另外如果你在 Windows 上开发部署时忘带platforms/qwindows.dll也会出现类似的 could not find the platform plugin 报错。对毕业设计来说最常见的是在 Ubuntu 虚拟机上直接用 QtCreator 运行没问题但把生成的二进制文件拷贝到另一个裸环境就失去依赖。解决办法是确认 Qt 安装目录里的platforms子目录是否存在libqlinuxfb.so或qwindows.dll把它拷贝到可执行文件所在目录的platforms/下。同时检查是否设置了QT_QPA_PLATFORM环境变量如果设了linuxfb就要保证当前显示设备支持。最简单的方式是在main函数里调用qputenv(QT_QPA_PLATFORM, xcb)强制用 X11但前提是你的环境有 X 服务。4.3 现象界面上所有中文字段显示成一堆乱码这个坑在 Qt5 的 MSVC 编译器下尤其常见。原因很简单源文件编码不是 UTF-8而 Qt 内部使用 UTF-16当编译器按 GBK 解释字符串又传给QString::fromUtf8时必然产生乱码。另外如果你在代码里用QString::fromLocal8Bit在中文 Windows 上通常是 GBK在 Linux 上通常是 UTF-8跨平台就出问题。解决的办法很笨但有效编辑器统一用 UTF-8 编码保存源码所有中文字符串写成QStringLiteral(开始游戏)不要裸写开始游戏。如果你用的是 QtCreator在“编辑→首选项→环境→文件编码”里把默认编码改成 UTF-8。还需要在 .pro 文件里加QMAKE_CXXFLAGS /utf-8MSVC或确保-finput-charsetUTF-8 -fexec-charsetUTF-8GCC。这样能同时避免跨平台乱码。4.4 现象程序启动后图片资源路径一直找不到仿宝石迷阵通常会用到宝石图片或背景图。很多同学把图片放在源文件目录下运行时用相对路径imgs/x.png然后从 QtCreator 运行可以直接双击可执行文件却找不到。原因是 Qt 程序的工作目录是启动时的当前目录不是可执行文件所在目录双击运行时当前目录可能是桌面或某个固定路径。解决办法是把图片放进 qrc 资源文件代码里用:/images/gem_red.png访问这是 Qt 推荐做法。如果一定要用外部文件可以在main函数里用QDir::setCurrent(QCoreApplication::applicationDirPath())把工作目录切到可执行文件目录。资源系统还有个好处打包时不需要单独维护图片目录一道 qrc 文件全解决了。4.5 现象把程序拷贝到另一台电脑上运行提示缺少 Qt5Core.dll这个现象是动态链接的必然结果。毕业设计答辩经常要用实验室电脑而实验室电脑不一定装了 Qt 运行库。原因很简单你编译的是动态版本程序运行时需要Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll这些不会自动跟随 exe 走。解决办法有两种一种是用 Qt 自带的windeployqt.exe在命令行执行windeployqt 你的程序.exe它会自动把需要的 DLL 和 plugins 拷到同一个目录然后整个文件夹拿去用。还有一种是在编译时把 Qt 静态链接进程序但静态编译需要自己编译一套 Qt 库过程长且容易出错。我建议用 windeployqt干净利落。5. 文档与答辩要点从“代码能跑”到“高分毕设差什么”5.1 一份完整毕设文档的文件清单毕设评审看的不是你写了多少行代码而是看你能不能把代码结构、设计和验证过程说清楚。基于 C 和 QT 实现的仿宝石迷阵游戏项目源码文档说明通常需要配套以下文档需求分析说明书、概要与详细设计、数据库如果有不必要本题目没有数据库但可以有一个“游戏设计说明”描述规则、计分、流程还有测试报告和用户手册。我一般会按这个顺序组织文件文件内容是否必须需求分析.md / .doc游戏规则、功能列表、用例图是总体设计文档模块划分、类图、调用关系是详细设计文档每个类的方法说明、算法伪代码是测试报告功能测试用例、边界测试结果是用户操作手册如何编译、运行、操作建议答辩 PPT演示路径、代码片段、性能分析是一个常被忽略的点是文档里的图表要和源码一致。比如类图里写了GameBoard源码里就不能叫BoardManager否则答辩时老师会追问为什么对不上。文档里画流程图时把第 2 章的状态机画准确比堆一堆截图有用得多。5.2 需求分析怎么写才能和代码对得上很多同学的需求分析是从网上抄一段“本系统为用户提供休闲娱乐功能”的空话。真正让老师认可的写法是先把“宝石消除”的规则写成一条条可验收的需求比如“玩家点击两颗相邻宝石后若形成三连则消除并对调位置”。需求里的每一个功能点后面在测试报告里都要有对应用例。我会建议用表格列需求清单编号、需求描述、优先级、验收标准。例如 REQ-01棋盘为 8 行 8 列五种宝石验收标准启动后棋盘满填且无初始三连。REQ-02支持鼠标拖拽交换验收标准点击相邻宝石成功交换非法交换自动恢复。REQ-03消除后自动下落补位验收标准连续消除能触发连击加分。这样需求、代码、测试三方能一一对应评审会觉得你工程素养高。5.3 测试报告用表格记录手工测试和边界条件这个游戏没有复杂后端但测试报告依然能写出亮点。除了正常点击测试还要覆盖边界棋盘最上方一行能否通过拖拽交换棋盘最右侧一列能否左移连续快速点击会不会导致状态错乱如果当前没有可交换步程序是否会自动重新初始化棋盘分数是否在消除后正确累加重开一局是否清零用例编号操作预期结果实测结果TC01启动程序棋盘无三连有可移动步通过TC02点击两个不相邻宝石位置不变无加分通过TC03交换并形成水平三连三连消除上方宝石下落通过TC04在无可行步的棋盘上点击提示棋盘重新生成通过表格里的每一条都要能对应上一节的需求编号。这里不需要写自动化测试工具但如果你在核心逻辑层写了单元测试一定要在文档里提到。老师很看重“可测试性”这种设计意识本身就是加分项。5.4 答辩演示的代码讲解顺序答辩时不要上来就讲paintEvent怎么画圆。我给自己定的顺序是先讲游戏规则然后打开源码里的GameBoard类说明棋盘数据结构、消除判定、下落填充三种算法是用纯 C 实现的不依赖 UI再切到 Qt 界面讲自绘棋盘如何响应鼠标事件最后跑一遍游戏指出某一步消除后触发了哪个函数。这个顺序让老师能按“需求→设计→实现→演示”完整走一遍。还有一个实用技巧在代码里为关键函数写一行注释标出“复杂度O(N)”或者“当连续消除时递归调用上限 10 次”。老师翻代码时能直接看到这样的说明比写在文档里更有说服力。但注释要克制不要每一行都写否则反而显得像网页教程。6. 让我再做一次我会先做这件事核心逻辑的自动化验证最后这个进阶经验是我从头做过几次以后反复踩坑换来的。回想当初我也是一写完界面就急着点鼠标玩结果发现交换判断、下落顺序、分数计算里各种边界 bug只能靠手动点击复现效率极低。如果再让我做一次这个题目我会在写完GameBoard后立刻写一个不依赖 Qt 的测试入口用断言验证三条核心逻辑交换合法时返回 true 且分数正确非法交换返回 false 且棋盘恢复原样连续消除后棋盘无三连。可以简单写// test_board.cpp纯命令行测试入口 #include board.h #include cassert int main() { GameBoard board(8, 8, 5); board.initBoard(); assert(board.canMove()); // 找到第一个可交换的位置执行交换并验证有消除 for (int r 0; r 8; r) { for (int c 0; c 7; c) { Position a{r, c}, b{r, c 1}; if (board.swapGems(a, b)) { int cleared board.clearMatches(); assert(cleared 3); assert(board.grid().size() 8); return 0; } } } return 1; }这个测试入口要和 Qt 界面完全隔离只在 CMake 或 .pro 里作为单独的一个 target 编译。这样你每改一次逻辑跑一遍测试能拦截绝大部分回归 bug。我最后的血泪经验是界面好改算法难调先把算法跑稳再谈画面和动画否则你会在“颜色配得好不好看”和“为什么交换没反应”之间循环消耗时间。这个方向真的值得投入希望这篇笔记能帮到正在为毕业设计头秃的同学把仿宝石迷阵做成一套能讲出技术亮点的完整项目。本文还有配套的精品资源点击获取