ARTICLE DETAIL

资讯详情

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

C++与QT实现仿宝石迷阵:三消算法、界面绘制与打包全攻略

C++与QT实现仿宝石迷阵:三消算法、界面绘制与打包全攻略 简介一份基于C与Qt框架的仿宝石迷阵三消游戏毕业设计项目适合计算机相关专业的毕设学生、课程设计或期末大作业需求者也适合希望积累项目实战经验的初中级开发者。项目代码完整、结构清晰覆盖游戏主界面、音效动画、交换消除判定、数据库管理等模块并配有文档说明评审分达99分。压缩包共185个文件包含52个png图片素材、22个cpp源码、19个h头文件、15个wav音效、13个gif动画、7个ui界面文件以及数据库、qrc资源文件等整体约44.77MB目录规划便于按功能模块查阅。目前已有95人学习下载。整套项目可直接编译运行通过剖析界面与逻辑分离的工程结构读者还能掌握Qt信号槽机制、自定义控件、资源管理等实用技能为毕业设计答辩或二次开发提供扎实基础。1. 仿宝石迷阵毕业设计为什么用 C 和 QT 而不是网页游戏如果你的毕业设计选题是“游戏类”又不想一头扎进网页三件套的海洋那 C 和 QT 的组合基本是最稳妥的捷径。这个仿宝石迷阵的题目表面上是做一个三消小游戏实际上它把二维数组建模、随机算法、界面重绘、事件响应、信号槽通信全串起来了正好是用人单位爱问、答辩老师爱听的几板斧。更关键的是QT 的跨平台特性让你写完一套代码既能交 Windows 的 exe也能顺手编译出 Linux 版本文档里再贴几张运行截图高分毕设的底子就有了。下面我按自己当年做这个题目的完整路径从机制到代码把每一步拆开讲清楚新手能跟着落地熟手也能通过避坑部分省下调试时间。2. 拆解宝石迷阵核心机制棋盘模型、三消检测与填充掉落2.1 棋盘本质是一张二维数组不是画布做任何消除类游戏第一件事是忘掉“界面”先把棋盘在内存里定下来。常见做法是用一个std::vectorstd::vectorint表示二维棋盘每个格子存一个宝石类型编号比如 0 到 5 代表六种宝石。这个数组就是整个游戏的“真相”界面只是它的一个投影。我见过不少新手直接把宝石画上去然后用QPushButton或者图片控件去拼棋盘点击时再去判断位置——这种做法前期看着快后期做消除、掉落、动画时会痛不欲生。因为按钮是控件它有焦点、有边框、有事件穿透问题而且很难做流畅的位移动画。正确做法是棋盘用纯数据维护界面用QWidget重写paintEvent把每一个格子按坐标画出来。数据怎么变界面就怎么画二者通过一个update()触发刷新。棋盘尺寸建议用常量定义const int ROWS 8; // 行数 const int COLS 8; // 列数 const int CELL_SIZE 60; // 每个格子边长像素这里固定 8×8 是为了让四消、五消的判断逻辑更直观。如果你做 10×10 或者 7×7逻辑完全一样但玩家体验会有差别——棋盘太大容易眼晕太小又不够消。我一般做 8×8格子像素 60 时窗口尺寸刚好是 480×480加上上下得分栏主窗口 600×600 左右在笔记本上截图也很协调。2.2 三消检测行列扫描比逐对比较更省心检测一次“有没有三消”最直接的想法是遍历所有格子然后检查它右边和下面是否构成连续三个相同。但更简洁的做法是按行、按列分别扫描统计连续相同宝石的长度长度大于等于 3 就标记消除。先定义消除标记矩阵std::vectorstd::vectorbool toRemove(ROWS, std::vectorbool(COLS, false));然后先按行扫for (int row 0; row ROWS; row) { int start 0; for (int col 1; col COLS; col) { if (col COLS board[row][col] board[row][start]) { continue; } int len col - start; if (len 3) { for (int c start; c col; c) { toRemove[row][c] true; } } start col; } }这段代码的思路是每行用一个start记录连续段的起点当遇到不同宝石或到达行尾时检查这一段长度。len 3就把这段全标记为待消除。列扫描同理只是把row和col互换。这里有一个容易忽略的细节行扫描和列扫描必须使用同一个toRemove矩阵而不是分开两个布尔矩阵再合并否则交叉位置的宝石可能被重复处理。2.3 填充掉落从下往上处理避免“悬空”错觉消除之后空洞的格子需要由上方宝石下落填补然后顶部再生成新宝石。这一步网上很多代码是“从上往下扫描遇到空洞就把上方宝石拉下来”但那样会留下中间空洞需要反复扫描。我用的是从下往上、逐列处理for (int col 0; col COLS; col) { int writeRow ROWS - 1; for (int row ROWS - 1; row 0; --row) { if (!toRemove[row][col]) { board[writeRow][col] board[row][col]; --writeRow; } } // 顶部空位填充新宝石 while (writeRow 0) { board[writeRow][col] rand() % GEM_TYPE_COUNT; --writeRow; } }这段代码的核心是writeRow指针。从最底行往上遍历遇到不需要消除的宝石就移动到writeRow的位置然后writeRow上移。这样所有保留下来的宝石都被“压”到了下方而writeRow以上的格子自然就是空位直接生成新宝石即可。注意这里用rand() % GEM_TYPE_COUNT生成新宝石后面我会讲这个随机数有什么坑。2.4 为什么 C 和 QT 适合这个题目选型理由其实很现实QT 的QPainter画二维图形非常直接画矩形、画图片、画圆角都只要几行代码信号槽机制天然适合“点击棋盘格发信号主逻辑响应后重绘”。相比 MFCQT 的跨平台能力让你在 macOS 上也能跑相比 Python 的 PygameC 的性能足够你在毕业设计里加上粒子特效而不掉帧。而且 QT Creator 自带的调试器对新手友好断点看二维数组比cout打印方便得多。当然也有同学用 Unity 做这个题但 Unity 的重心在场景、组件、资源管理反而冲淡了“数据结构与算法”的考察点。答辩老师看到你用 C 手写三消逻辑会比看到你用现成组件库更兴奋。这个题目真正值钱的部分就是棋盘建模和算法而不是引擎调用。3. 用 QT Designer 搭界面把棋盘画成一张可交互的画布3.1 界面布局先搭外壳再写画布QT Designer 的好处是可视化拖拽但你不需要把每个宝石都拖成控件。我一般用 Designer 搭出主窗口的外壳顶部放一个QLabel显示得分中间放一个空的QWidget作为棋盘容器底部放“重新开始”按钮。棋盘的绘制逻辑全部写在容器子类的paintEvent里这样界面设计器负责静态布局画布负责动态内容。在 Designer 里把一个QWidget提升promote为自定义类是稳妥做法。比如你在BoardWidget.h里声明了class BoardWidget : public QWidget就在 Designer 的属性面板里设置提升的类名为BoardWidget头文件写BoardWidget.h。这样你不用手工写布局代码UI 文件里的ui-boardWidget直接就是你的自定义类。3.2 在 paintEvent 里画宝石从画矩形开始第一次做的时候可以先不贴图用带颜色的圆角矩形代替宝石。每个格子的坐标由行列计算void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { int x col * CELL_SIZE; int y row * CELL_SIZE; QColor color gemColor(board[row][col]); painter.setBrush(color); painter.setPen(Qt::NoPen); painter.drawRoundedRect(x 2, y 2, CELL_SIZE - 4, CELL_SIZE - 4, 8, 8); } } }这里gemColor是你自己写的函数把宝石类型编号映射到QColor。注意所有格子都用了2和-4的内边距否则相邻宝石会挤在一起看起来没有缝隙。等界面跑通后再拿真实宝石图片替换把drawRoundedRect换成drawPixmap即可。3.3 鼠标点击与信号槽让数据跟着鼠标走玩家点击棋盘时需要把鼠标坐标换算成行列号然后交给游戏逻辑处理。这里我一般重写mousePressEvent而不是在 Designer 里连接信号void BoardWidget::mousePressEvent(QMouseEvent *event) { if (event-button() ! Qt::LeftButton) { return; } int col event-pos().x() / CELL_SIZE; int row event-pos().y() / CELL_SIZE; if (row 0 row ROWS col 0 col COLS) { emit cellClicked(row, col); } }这是自定义信号cellClicked(int, int)。主窗口连接这个信号在槽函数里做“选中、交换、消除”逻辑。把“点击坐标”和“业务逻辑”分开是 QT 里很自然的写法也方便写单元测试——你可以不经过鼠标直接调用业务层的swapAndMatch(row, col, dir)函数。3.4 交换逻辑先映射方向再更新数据宝石迷阵的核心操作是点击一个格子后再点击相邻格子进行交换。我实现的方式是第一次点击记录选中格第二次点击判断是否相邻如果相邻就交换并检测三消。如果不相邻就更新选中格为第二次点击的位置。用两个成员变量记录int selectedRow -1; int selectedCol -1;在槽函数里判断void MainWindow::onCellClicked(int row, int col) { if (selectedRow -1) { selectedRow row; selectedCol col; boardWidget-setSelectedCell(row, col); return; } // 判断是否相邻 int dr abs(row - selectedRow); int dc abs(col - selectedCol); if ((dr 1 dc 0) || (dr 0 dc 1)) { boardWidget-trySwap(selectedRow, selectedCol, row, col); } selectedRow -1; selectedCol -1; boardWidget-clearSelection(); }这里用abs判断相邻是最稳妥的不要写成row col和等于 1 之类的条件因为斜对角也满足某些数学关系但实际不是相邻。3.5 刷新机制update() 是你的后悔药每次数据变动后调用boardWidget-update()就会触发paintEvent重绘。这是 QT 双缓冲机制的一部分不需要你手动擦除背景。有的新手会手动调用repaint()但repaint()是同步重绘会阻塞当前线程在动画循环里容易卡界面update()把重绘请求合并到事件循环里同一帧内多次调用只重绘一次性能更好。把“修改数据 update()”封装成一个函数比如boardWidget-refresh()内部做消除-掉落-再消除的循环最后update()。这样主逻辑不会漏刷新也不会重复刷新。4. 落地 C 源码随机生成、消去重填与计分的关键实现4.1 初始化棋盘随机种子与“先消除后重填”策略游戏开始时要生成一个没有任何三消的初始棋盘。常见做法是逐格随机生成每生成一个就检查它左边前两个和上边前两个是否同色如果同色就重新随机。核心代码void GameBoard::initBoard() { std::srand((unsigned int)std::time(nullptr)); for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { do { board[row][col] std::rand() % GEM_TYPE_COUNT; } while (isPartOfMatch(row, col)); // 同时保证横向连续超过2的同色也不存在 if (col 2 board[row][col] board[row][col - 1] board[row][col] board[row][col - 2]) { board[row][col] (board[row][col] 1) % GEM_TYPE_COUNT; } } } }isPartOfMatch检查当前位置与左前两个或上前两个是否构成三连。但只靠这个函数还不够因为同一行连续三个的情况可能在第三个位置才被检测到。所以我在循环里又加了一个后置检查如果生成后第col个与第col-1、col-2同色就强改成下一种颜色。这样能保证初始棋盘干干净净。注意std::srand和std::rand是 C 风格随机数。如果程序启动后立刻随机time(nullptr)作为种子的粒度是一秒同一秒内多次启动会产生完全相同的棋盘。更稳的做法是用random库的std::mt19937配合std::random_device做种子。4.2 交换与匹配返回 bool让调用方决定是否撤回交换是玩家主动触发的交换之后如果没有产生三消需要换回去。所以函数必须返回 boolbool GameBoard::swapAndMatch(int r1, int c1, int r2, int c2) { std::swap(board[r1][c1], board[r2][c2]); if (!detectAndRemoveMatches()) { std::swap(board[r1][c1], board[r2][c2]); return false; } return true; }detectAndRemoveMatches要做两件事检测并标记所有三消然后调用removeMarkedAndRefill消除并重填最后再循环检测直到盘面稳定。4.3 消除-掉落-再消除的循环while 是关键一次交换可能引发连锁反应比如你交换后消了三个上方掉落又形成新的三消。所以必须用循环bool GameBoard::detectAndRemoveMatches() { bool anyRemoved false; while (true) { bool removed markMatches(); if (!removed) { break; } anyRemoved true; removeMarkedAndRefill(); } return anyRemoved; }markMatches()就是我第 2 章写的行列扫描把命中的格子标到toRemove矩阵并返回是否有消除。removeMarkedAndRefill做下落和填充。这个循环的退出条件是“没有再消除”所以最后棋盘一定是稳定的。这里我要强调必须把“标记消除”和“填充掉落”拆成两个函数不能在标记过程中直接修改棋盘。如果你一边标记一边移动宝石后续列扫描会读到已经变化的数据导致漏消。4.4 计分规则按消除数量和连锁加成设计计分是答辩时的演示亮点。我用的是“消除数量×10×连锁系数”int score 0; int GameBoard::markMatches() { // ... 行列扫描标记 ... int count 0; for (auto rowVec : toRemove) { for (bool flag : rowVec) { if (flag) count; } } if (count 3) { int comboFactor (comboCount 0 ? comboCount 1 : 1); score count * 10 * comboFactor; } return count; }comboCount在同一次swapAndMatch触发的while循环里递增。每完成一轮消除comboCount加 1。玩家连续消除越多得分越高这个机制能明显提高游戏的上瘾度。4.5 连续消除判定别忘了响应式更新业务逻辑跑完后必须让界面刷新。我在MainWindow的槽函数里这样组织void MainWindow::trySwap(int r1, int c1, int r2, int c2) { if (gameBoard.swapAndMatch(r1, c1, r2, c2)) { ui-scoreLabel-setText(QString(得分%1).arg(gameBoard.getScore())); boardWidget-update(); } else { boardWidget-update(); // 换回去也要刷新 } }即使用户的交换不合法界面也要更新否则棋盘停留在被篡改的中间状态。很多新手只在意“成功后的刷新”忽略了“失败后的刷新”结果盘面上宝石换过去了又没消除看起来像 bug实际上是没重绘。4.6 棋盘判死没有可行交换时怎么办三消游戏还有一个隐藏机制当棋盘上不存在任何一笔交换能产生三消时需要重新洗牌。这个判断我放在每次连锁结束之后bool GameBoard::hasValidMove() { for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { // 右邻交换 if (col 1 COLS) { std::swap(board[row][col], board[row][col 1]); if (wouldMakeMatch(row, col) || wouldMakeMatch(row, col 1)) { std::swap(board[row][col], board[row][col 1]); return true; } std::swap(board[row][col], board[row][col 1]); } // 下邻交换 if (row 1 ROWS) { std::swap(board[row][col], board[row 1][col]); if (wouldMakeMatch(row, col) || wouldMakeMatch(row 1, col)) { std::swap(board[row][col], board[row 1][col]); return true; } std::swap(board[row][col], board[row 1][col]); } } } return false; }wouldMakeMatch可以复用第 2 章的扫描逻辑只检测不消除。如果返回 false就调用initBoard()重新初始化并提示玩家“没有可交换的宝石已重新洗牌”。5. 避坑QT 版本冲突、随机数重复、动画卡顿的排查记录5.1 现象程序启动后棋盘一模一样每次运行都一样原因std::rand()用的种子time(nullptr)在同一秒内启动多次不会变化。另外如果在initBoard()里每次调用都srand一次也会把随机种子重置。解决改用random库并且只初始化一次。我一般把随机引擎做成GameBoard的静态成员static std::mt19937 rng() { static std::mt19937 engine{std::random_device{}()}; return engine; }生成宝石时用std::uniform_int_distributionint(0, GEM_TYPE_COUNT - 1)。这样既保证随机性又避免每次生成时重新构造引擎。5.2 现象编译时提示 “fatal: cannot mix incompatible Qt library (version 0x50601) with this library”原因这是典型的环境混用。你的系统里有多个 QT 版本或者你用了不同编译器编译出来的 QT 库。比如用 MinGW 编译的 QT 库却用 MSVC 的编译器去链接。解决确认你下载的 QT 安装包与编译器匹配。在 QT Creator 里检查“工具 → 选项 → 构建套件”确保 Kit 选择的编译器是 QT 官方安装包对应的那一套。如果是从网上下载的绿色版 QT尽量卸载并重新安装官方在线安装器安装时勾选与你 IDE 匹配的编译器版本。另外项目文件里不要手动添加 QT 安装目录以外的include路径否则 Qt 头文件会被混用。5.3 现象鼠标点击格子位置对不上尤其是窗口缩放后原因你用event-pos()除以固定CELL_SIZE换算行列但窗口缩放后CELL_SIZE没有变化而画布坐标被拉伸了导致点击错位。解决要么把窗口设置为固定大小禁止缩放要么在paintEvent里根据当前控件实际宽度动态计算格子大小。我用的是固定 480×480 的棋盘画布并设置setFixedSize(ROWS * CELL_SIZE, COLS * CELL_SIZE)。如果想自适应缩放需要把CELL_SIZE改为成员变量在resizeEvent里重新计算并且刷新棋盘。5.4 现象三消检测偶尔漏掉“L 形”或“T 形”消除原因你在行列扫描时只标记了行扫描或列扫描各自连续的部分但当一个宝石同时参与行消除和列消除时你只用了一个布尔矩阵并且覆盖了标记。解决我上面写的toRemove矩阵是全局标记行扫描和列扫描都在同一个矩阵上置 true因此不会覆盖。注意不要在两次扫描之间清空矩阵。如果是用两个独立矩阵分别标记再合并合并时用或运算。如果某个位置同时被行和列标记它也只算一次消除计分时统一数toRemove里 true 的数量即可。5.5 现象动画卡顿掉帧严重原因最常见的是在paintEvent里加载了高分辨率图片每次重绘都从磁盘读图。其次是update()调用过于频繁导致一帧内多次重绘。解决所有宝石图片在构造函数里提前加载到QPixmap存入QVectorQPixmap数组绘制时直接drawPixmap。如果做掉落动画我通常用QTimer驱动每次定时器触发时改变宝石的 y 偏移量然后update()。偏移量递增量控制在每次 2 像素配合 30ms 的定时器视觉上就是平滑下落。如果还是很卡检查定时器是否被事件循环中的其他耗时操作阻塞必要时把耗时逻辑放到子线程但毕业设计一般不需要。5.6 现象QT Designer 里提升的控件不显示自定义绘制内容原因你忘记给BoardWidget实现paintEvent或者类名与头文件不匹配。更隐蔽的原因是 Designer 生成的ui_xxx.h头文件里setupUi函数中提升后的控件指针类型是QWidget*如果你在代码里调用ui-boardWidget-setSelectedCell而该函数不在QWidget类中编译就会报错。解决在MainWindow类的构造和初始化中把ui-boardWidget显式转换成BoardWidget*BoardWidget *board qobject_castBoardWidget*(ui-boardWidget); board-initGame();这样确保调用的是子类接口。记得在MainWindow.h里包含BoardWidget.h而不是依赖ui_mainwindow.h的前置声明。6. 把高分毕设再做厚一点扩展特效、音效与打包发布6.1 宝石消除的高效特效碎片粒子不引入第三方库如果你想在答辩演示时让消除效果闪瞎评委不必引入粒子引擎。用QPainter在paintEvent里画十几个小圆片模拟碎片飞散即可。我当时的做法是每次消除时记录每个被消除格子的中心坐标生成若干个粒子每个粒子有位置、速度、生命值。用QTimer每 20ms 更新一次粒子状态生命值减到 0 就移除同时调用update()。粒子数据结构很简单struct Particle { QPointF pos; QPointF velocity; int life; QColor color; };在paintEvent绘制时遍历粒子列表按生命值淡出。这个效果完全基于 QPainter 2D 绘制性能足够代码量也不过 50 行却能让整个项目看起来有“游戏感”。6.2 音效用 QSound 播放 WAV别用 QSoundEffect 踩格式坑音效也是加分项。QT 自带的QSound类只支持 WAV 文件而且最好是无压缩 PCM 格式。我吃过亏从网上下载的 WAV 文件采样率异常播放出来是杂音。解决方法是先用音频工具把音效转成 44100Hz、16 位单声道 WAV。播放代码#include QSound QSound::play(:/resources/sounds/click.wav);注意路径前的:/表示 QT 资源文件系统中的路径。把音效添加到.qrc资源文件里这样打包发布时无需携带外部音频文件。6.3 打包发布MinGW 怎么让 exe 在别的电脑上跑起来答辩时需要把程序拷到老师电脑上演示这一步经常会翻车。用 MinGW 构建的结果直接拷贝 exe 到没有 QT 环境的电脑上会提示缺少Qt5Core.dll等。常见做法是使用windeployqt.exe把编译生成的Game.exe放到一个单独文件夹。打开 QT 命令行工具该工具会自动设置好环境变量。执行windeployqt Game.exe它会自动拷贝需要的 DLL 和插件到同目录。把整个文件夹打包即可在未安装 QT 的 Windows 上运行。如果里面用了 “styles” 或者字体可能还需要额外手动拷贝platforms/qwindows.dll。windeployqt一般会处理好这些但偶尔会遗漏我的血泪经验是打包后立刻在另一台干净虚拟机里跑一遍缺什么 DLL 会直接弹窗告诉你。我的习惯是最后做一次“回归验证”把流程完整走一遍——启动、初始棋盘无三消、点击交换、消除计分、连锁响应、死局重排。每一项写进文档的“测试用例”一章截图存档。这篇文档才是你高分毕业设计的临门一脚代码能跑是基础文档能让人看懂思路才是加分项。希望这些经验能帮你的毕设之路少一点玄学多一点成竹在胸。本文还有配套的精品资源点击获取
返回列表