ARTICLE DETAIL

资讯详情

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

Qt翻金币游戏开发:信号槽、自定义控件与关卡数据设计实践

Qt翻金币游戏开发:信号槽、自定义控件与关卡数据设计实践 简介翻金币小游戏项目来自传智教育Qt课程面向正在学习Qt/C的开发者是一个包含二十个关卡的完整实战案例。项目以图形视图框架搭建游戏场景用动画框架实现金币翻转通过信号与槽处理点击事件和关卡切换还涉及关卡数据管理、音效播放与界面美化能够帮助初学者把零散语法串成可运行的完整程序。压缩包共九十三份文件其中七个源代码文件与六个头文件为工程核心十八个图片文件用于界面和金币素材四个音频文件作为操作音效三十个动态链接库为运行依赖另有翻译文件、界面定义、资源清单、工程配置和可执行程序大小约27.6MB。目前已有592人学习适合用于Qt项目实训或课程设计参考。通过阅读源码和资源组织方式读者可深入理解关卡数据结构的存储、翻转动画的绘制思路、多媒体反馈的接入方法同时积累性能优化与内存管理方面的排错经验对独立开发同类小游戏或桌面应用有直接帮助。1. 翻金币小游戏Qt 课程里第一个带“游戏感”的练手项目翻金币小游戏是传智教育 Qt 课程项目清单里很特别的一个界面不复杂玩法却能把信号槽、自定义控件、动画、音效、资源打包这些 Qt 基本功全部串起来。二十个关卡看起来多但关卡数据本身是一张表不是 20 个写死的页面你只要把数据结构设计好加关卡只是往表里添一行的事。这个项目非常适合刚学完 QWidget 基础控件、手里还没有一个完整应用的人做完之后你至少能回答三个问题Qt 项目里界面怎么切换、状态怎么管理、最终怎么发布成能发给别人的 exe。后面章节我会按「关卡数据 → 金币控件 → 界面组装 → 踩坑 → 打包」这条完整落地路径逐个拆开。2. 关卡数据结构驱动二十关不写二十个页面很多初学者拿到“二十个关卡”的第一反应是复制粘贴 20 个页面然后每个页面手动摆金币。这种做法不是不能跑但改一个金币初始状态要重新编译一次加到 20 关时项目里全是重复代码。我做这个项目时选的是“一份关卡表 一个通用游戏页”二十关只靠数据不同来区分界面完全复用。2.1 一维数组存棋盘关卡表当“数据库”传智版翻金币的棋盘通常是一个矩形网格比如 4×4、6×4每一格有正面和反面两种状态。棋盘数据最自然的存法是一维数组索引按从左到右、从上到下排列第 i 个格子的行号是i / colCount列号是i % colCount。这样写关卡表最紧凑遍历判断胜利时也只需要一个 for 循环。// CoinLevel.h #ifndef COINLEVEL_H #define COINLEVEL_H #include QVector #include QString // 一个关卡的完整配置 struct CoinLevelData { int levelNo; // 关卡号 1~20 int rowCount; // 行数例如 4 int colCount; // 列数例如 4 QVectorint initState; // 长度 rowCount * colCount // 1 表示正面0 表示反面 int maxSteps; // 允许的最大点击次数0 表示不限 }; // 关卡表后续要加关卡直接往这里追加 static const QVectorCoinLevelData kLevelTable { {1, 4, 4, QVectorint{ 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, 0}, {2, 4, 4, QVectorint{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 6}, }; #endif // COINLEVEL_H这段代码把“关卡配置”和“游戏逻辑”剥离开了。initState里的 0/1 顺序对应控件摆放顺序初始化游戏页时只需要按顺序给每个CoinButton设置正面或反面即可。maxSteps是给到玩家头上的限制如果这一关要求 6 步内通关就把它设为 6不限制则设为 0。为什么用一维数组而不是二维数组一是QVectorint初始化字面量看着直观拷贝、比较、序列化都方便二是后面要把关卡配置外置成 JSON 或文本时一维数组可以直接用一个字符串表示比如11011000解析几乎没有成本。二维数组在逻辑上清晰但要写成配置数据反而啰嗦。2.2 胜利判定与步数限制在逻辑层而不是界面层判断游戏页里点击金币后会翻转自身以及上下左右相邻金币每点一次步数加一。判断是否通关只需要扫描initState的当前副本所有值都为 1全部正面朝上就弹胜利框。这个判断绝不要写在按钮点击的界面代码里而是抽成一个checkWin()方法返回bool界面只管弹窗和跳关。// GamePlayLogic.cpp #include GamePlayLogic.h #include QVector // 棋盘状态的运行时副本 QVectorint g_boardState; int g_curStep 0; int g_maxSteps 0; int g_rows 0; int g_cols 0; void loadLevel(const CoinLevelData level) { g_boardState level.initState; g_curStep 0; g_maxSteps level.maxSteps; g_rows level.rowCount; g_cols level.colCount; } // 翻转格子(row, col)如果是边界外就忽略 void flipCell(int row, int col) { if (row 0 || row g_rows || col 0 || col g_cols) { return; } int index row * g_cols col; g_boardState[index] g_boardState[index] ? 0 : 1; } // 点击 (row, col) 后的游戏规则自己 上下左右 void handleClickAt(int row, int col) { flipCell(row, col); flipCell(row - 1, col); // 上 flipCell(row 1, col); // 下 flipCell(row, col - 1); // 左 flipCell(row, col 1); // 右 g_curStep; } bool checkWin() { for (int i 0; i g_boardState.size(); i) { if (g_boardState[i] 0) { return false; } } return true; } bool stepLimitReached() { return g_maxSteps 0 g_curStep g_maxSteps; }flipCell里先做边界检查再翻转这是最容易漏的地方边界上的格子没有“上”或“下”直接访问数组会越界崩溃。handleClickAt里的注释标明了四个方向顺序无关紧要但每次都要重新计算row * g_cols col不要用成员变量缓存索引因为行和列在点击时是独立传入的。stepLimitReached的判断放在handleClickAt之后、刷新界面之前。如果达到步数上限还没获胜就提示失败并重开本关。传智版的常见设计是失败后回到当前关的初始状态而不是退回关卡选择页这样玩家可以马上重试挫败感小一些。游戏页的“重置”按钮本质上就是把g_boardState重新赋值为level.initState再把所有按钮图标恢复代码量很小。3. 金币翻转自定义 CoinButton、动画与相邻联动金币是这个项目的门面。很多第一次做 Qt 游戏的人会纠结用QLabel贴图还是用QPushButton我的结论是用按钮玩家要点的是一整块区域按钮天然有按下、悬停、点击信号不需要额外做事件过滤。3.1 自定义 CoinButton状态、坐标和点击信号CoinButton继承QPushButton内部保存三个东西当前是正面还是反面、所在行、所在列。点击信号不直接暴露按钮指针而是发出clickedGrid(row, col)这样游戏逻辑层完全不用知道界面上谁点的、按钮在哪拿到行列直接调handleClickAt就行。// CoinButton.h #pragma once #include QPushButton class CoinButton : public QPushButton { Q_OBJECT public: explicit CoinButton(QWidget* parent nullptr); void setGridPos(int row, int col); void setCoinFace(bool front); bool coinFront() const { return m_front; } signals: void clickedGrid(int row, int col); private slots: void onSelfClicked(); private: bool m_front true; int m_row 0; int m_col 0; }; // CoinButton.cpp #include CoinButton.h #include QPixmap #include QIcon CoinButton::CoinButton(QWidget* parent) : QPushButton(parent) { connect(this, QPushButton::clicked, this, CoinButton::onSelfClicked); setFixedSize(60, 60); setCoinFace(true); } void CoinButton::setGridPos(int row, int col) { m_row row; m_col col; } void CoinButton::setCoinFace(bool front) { m_front front; QPixmap pix(front ? :/res/coin_front.png : :/res/coin_back.png); // 统一缩放到按钮大小避免不同素材尺寸导致图标偏移 setIcon(QIcon(pix.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation))); setIconSize(size()); } void CoinButton::onSelfClicked() { emit clickedGrid(m_row, m_col); }构造函数里调用setFixedSize(60, 60)是为了让按钮大小固定避免放在布局里被拉伸成大色块。setCoinFace用pix.scaled(size(), ...)再setIcon这么做的原因是 qrc 里的原始图片可能不是 60×60如果直接把原图setIconQt 会缩放但画质粗糙加了Qt::SmoothTransformation后边缘平滑很多。这一点到第五章还会再提。这里有条小经验setIconSize要跟在setIcon之后调用一次即可不要每次翻面都重新设置否则按钮在固定大小下可能抖动。setFixedSize和布局的冲突也要留意如果金币按钮放在QGridLayout里布局会尝试拉伸按钮setFixedSize能压住布局但代价是网格间距不统一我一般建议游戏页用绝对定位或者自己控制行列距离。3.2 相邻翻转与 QPropertyAnimation 参数落地点击一个金币它自己和上下左右四个格子都要翻面。翻面的视觉反馈不能是“啪一下换图”常见做法是两段式缩放动画先缩窄到 0换图再放大回原尺寸。实现上就是两个QPropertyAnimation串行播放动画属性选geometry因为geometry动画对按钮控件的缩放效果最直观。// GamePlayWidget.cpp 片段 #include QPropertyAnimation #include QEasingCurve #include QDebug #include CoinButton.h #include GamePlayLogic.h void GamePlayWidget::flipCoin(CoinButton* btn) { QRect originalRect btn-geometry(); QRect zeroRect originalRect; zeroRect.setWidth(0); zeroRect.moveCenter(originalRect.center()); QPropertyAnimation* shrink new QPropertyAnimation(btn, geometry, this); shrink-setDuration(150); shrink-setStartValue(originalRect); shrink-setEndValue(zeroRect); shrink-setEasingCurve(QEasingCurve::InQuad); QPropertyAnimation* grow new QPropertyAnimation(btn, geometry, this); grow-setDuration(150); grow-setStartValue(zeroRect); grow-setEndValue(originalRect); grow-setEasingCurve(QEasingCurve::OutQuad); connect(shrink, QPropertyAnimation::finished, this, []() { btn-setCoinFace(!btn-coinFront()); grow-start(); }); connect(grow, QPropertyAnimation::finished, this, []() { shrink-deleteLater(); grow-deleteLater(); }); shrink-start(); }两个动画对象都用了this作为父对象防止在游戏页销毁时出现悬垂指针。shrink结束后再翻面、再启动grow保证了玩家看到的顺序是“缩小 → 换图 → 放大”而不是先换图再缩小。InQuad和OutQuad一个加速一个减速视觉上是迅速收拢再自然展开比起默认的线性曲线更像真实的翻面。参数怎么调duration150 毫秒偏轻快适合频繁点击如果做成 250 毫秒以上会明显有“慢动作”感新手演示反而更清楚。zeroRect.setWidth(0)是让按钮横向收成一条线之后grow再从 0 展开如果你想让按钮向中心点收拢记得moveCenter(originalRect.center())否则收缩的基准点是按钮左上角动画看起来向左上角偏。点击处理的整体流程放在游戏页里CoinButton发出clickedGrid信号游戏页拿到行列后先调用逻辑层handleClickAt(row, col)再遍历 5 个位置找到对应的 5 个CoinButton分别flipCoin。这里要特别注意必须先更新逻辑层状态再播放动画。因为动画是异步的如果先播放动画再更新状态玩家快速点击两次时第二次读取到的还是旧状态金币的最终显示会跟逻辑层不一致。4. 界面组装主窗口、关卡选择页与游戏页的切换翻金币项目的界面层级是典型的“三页式”开始页、关卡选择页、游戏页。开始页放一个标题和一个“开始游戏”按钮关卡选择页放 20 个关卡按钮游戏页放棋盘、返回按钮、重置按钮。三者之间通过QStackedWidget管理切换只改变当前页索引不销毁页面实例。4.1 QStackedWidget 组织三个页面QStackedWidget是 Qt 界面设计里做页面切换最省事的容器。主窗口创建三个子页面用addWidget按顺序加入通过setCurrentIndex或setCurrentWidget切换。索引 0 是开始页1 是关卡选择页2 是游戏页。// MainWindow.cpp 关键片段 #include MainWindow.h #include QStackedWidget #include QPushButton #include QLabel #include QVBoxLayout MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { m_stacked new QStackedWidget(this); // 页面 0开始页 QWidget* startPage new QWidget(this); QVBoxLayout* startLayout new QVBoxLayout(startPage); QLabel* titleLabel new QLabel(翻金币, startPage); QPushButton* startBtn new QPushButton(开始游戏, startPage); startLayout-addWidget(titleLabel); startLayout-addWidget(startBtn); // 页面 1关卡选择页 m_levelPage new LevelSelectWidget(this); // 页面 2游戏页 m_gamePage new GamePlayWidget(this); m_stacked-addWidget(startPage); // index 0 m_stacked-addWidget(m_levelPage); // index 1 m_stacked-addWidget(m_gamePage); // index 2 connect(startBtn, QPushButton::clicked, this, []() { m_stacked-setCurrentIndex(1); }); connect(m_levelPage, LevelSelectWidget::levelSelected, this, [](int levelNo) { m_gamePage-startLevel(levelNo); m_stacked-setCurrentIndex(2); }); connect(m_gamePage, GamePlayWidget::backToLevel, this, []() { m_stacked-setCurrentIndex(1); }); setCentralWidget(m_stacked); resize(800, 600); }这里有个细节游戏页的返回按钮信号叫backToLevel不直接操作m_stacked。好处是GamePlayWidget不依赖MainWindow以后想把这个游戏页单独嵌到别的地方也能复用。LevelSelectWidget的levelSelected信号携带关卡号主窗口拿到后先让游戏页startLevel(levelNo)再切页保证切过去时棋盘已经就绪。4.2 关卡按钮动态生成与游戏页状态重置20 个关卡按钮不应该在.ui文件里手工拖 20 个而是遍历关卡表动态生成。这样当你把关卡表从 20 关扩到 40 关时关卡选择页的按钮数量自动跟着变不需要改界面布局。// LevelSelectWidget.cpp 片段 #include LevelSelectWidget.h #include QGridLayout #include QPushButton #include CoinLevel.h LevelSelectWidget::LevelSelectWidget(QWidget* parent) : QWidget(parent) { QGridLayout* grid new QGridLayout(this); for (int i 0; i kLevelTable.size(); i) { int levelNo kLevelTable[i].levelNo; QPushButton* btn new QPushButton(QString(第%1关).arg(levelNo), this); // 把关卡号存到按钮属性里lambda 里再取出来 btn-setProperty(levelNo, levelNo); btn-setFixedSize(100, 60); connect(btn, QPushButton::clicked, this, []() { QPushButton* senderBtn qobject_castQPushButton*(sender()); int no senderBtn-property(levelNo).toInt(); emit levelSelected(no); }); int row i / 4; // 每行 4 个 int col i % 4; grid-addWidget(btn, row, col); } }按钮上直接显示“第N关”而不是“关卡 N”是考虑到后续可能给已通关关卡加一个星星标记。setProperty(levelNo, levelNo)配合sender()取值避免了在循环里用int levelNo捕获导致所有按钮都绑定到最后一个关卡号的经典错误。游戏页的startLevel(levelNo)要做三件事从关卡表取出对应数据、调用逻辑层loadLevel、按初始状态创建或重置棋盘按钮。第一次进入某关时按钮还没有创建需要new再次进入同一关只需要更新图标不需要重建按钮。这里最怕的是“每次进关都 new 一套按钮旧的没删干净”顶层界面越堆越卡第五章会专门说这个坑。5. 避坑清单翻金币项目最常见的 5 个翻车现场这一章写的都是我做类似 Qt 项目时实际踩过、也看别人反复踩的问题。每条按照「现象 → 原因 → 解决」的顺序说基本覆盖了这个项目从开发到交付的大部分意外。5.1 金币点一次却联动两次信号槽重复连接现象点击一个金币后周围金币连续翻动两次或者动画叠加后按钮位置错乱。原因把connect写在了flipCoin函数或者按钮初始化函数里而这些函数在一个关卡内被反复调用每调用一次就多连接一次信号槽。尤其动画的finished信号连接一次就会多触发一次后续动作。解决connect只放在构造函数或页面初始化的地方不要在点击处理函数里建立新连接。如果确实需要临时连接记得在动画结束里disconnect或对动画对象调用deleteLater避免旧连接泄漏。5.2 图标模糊、按钮不显示金币QPixmap 缩放与 qrc 路径现象金币图片拉伸变形、边缘锯齿严重或者干脆只有一个空白按钮。原因有两个层面。路径层面qrc 文件里的资源前缀写错运行时路径找不到图片QPixmap构造出来是空的而 Qt 对空 QPixmap 不会报错只会画出空白。缩放层面直接把原始图片setIcon到按钮上没有scaled素材尺寸和按钮不一致时 Qt 用默认快速缩放锯齿明显。解决统一用pix.scaled(btnSize, Qt::KeepAspectRatio, Qt::SmoothTransformation)后再setIconqrc 路径前缀必须和.qrc文件里声明的一致大小写也要对。排查时不要看代码直接qDebug() pix.isNull()空就是资源加载问题不空才是缩放问题。5.3 音效静默无声QSoundEffect 的启动时机与 wav 格式现象翻金币没有“叮”的音效控制台也没有任何报错。原因分三种一是QSoundEffect在窗口构造函数里创建并准备播放此时播放是在事件循环还没完全启动的阶段音频播放被吞掉二是素材不是真正的 wav 格式只是改了后缀QSoundEffect加载失败三是 qrc 路径写错资源为空。解决音效播放放在点击事件里而不是构造函数里素材用音频工具转成 16bit 44.1kHz 的 PCM wav别用从网页直接下载的文件改名凑合播放前用setSource(QUrl(qrc:/res/coin_sound.wav))并判断status()是否为Ready。如果做出来的包还想压缩体积换QMediaPlayer播放 mp3 是另一个方向但小项目用 wav 最省事。5.4 MSVC 编译报 dependent 路径过长工程目录结构与 include 路径现象编译时错误信息里一大串路径类似:-1: error: dependent ..\..\..\..\Qt\5.15.2\msvc2019_64\include\qtwidgets does not exist。原因项目工程目录层次太深Windows 对路径长度有限制Qt 头文件的 include 路径被相对路径..\..\..拉长后超出限制。这个问题在 Qt 5.15 MSVC 2019 的环境下比较常见尤其是工程放在多级子目录里时。解决把项目整个复制到浅目录比如D:\CoinGame清掉build目录和.qmake.stash重新构建。如果你用的是 Qt 在线安装器装的 5.15.2 msvc2019_64 套件这一步能消掉绝大多数路径相关报错。构建成功后尽量不要挪动 Qt 安装目录否则 Windows 注册表里的路径会失效Qt Creator 里会显示套件失效。5.5 重新进入同一关会残留上局状态游戏页复用但没有重置现象从第 5 关退出到关卡选择页再进第 5 关金币还是上次退出时的状态而不是初始状态。原因QStackedWidget切换页面不会销毁页面实例游戏页对象一直在startLevel里如果没有重新给每个按钮设置coinFront按钮保留的是上次的图标和状态。解决startLevel里从level.initState出发对棋盘上每个按钮重新调用setCoinFace不能只调用loadLevel。loadLevel更新的是逻辑层数组界面上的控件状态必须同步刷新。顺手把g_curStep 0也恢复不然步数限制会带着上一关的数据算。6. 项目交付前最后两件事打包发布与关卡配置外置代码跑通只是第一步这个项目真正验收的时候十个人里有八个卡在“发给别人打不开”。翻金币项目的发布用windeployqt是最稳的它是 Qt 官方命令行工具能自动把依赖的 Qt 库和插件复制到 exe 目录。以 Qt 5.15.2 msvc2019_64 为例release 构建完成后在 Qt 命令行工具里执行cd /d D:\CoinGame\build\release D:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe CoinFlip.exe执行完会生成platforms、styles、imageformats这些子目录和一堆 dll。注意看输出里有没有Warning: Cannot find ...有就说明某些依赖缺失。发给别人之前把整个目录压成 zip而不是只发 exe。另外 msvc 套件打包出来的程序在别的电脑运行可能提示缺少 VCRUNTIME 库这种情况把vc_redist.x64.exe一起发给对方安装或者在自己项目里启用静态链接。别问我为什么知道要强调这个第一次做 Qt 项目的人几乎都会在这里翻一次车。关卡配置外置这件事属于“做了以后加关卡不用重新编译”的进阶收益。常见做法是把kLevelTable换成读取一个 JSON 文件字段和结构体一一对应{ levels: [ { no: 1, rows: 4, cols: 4, init: 1111011111111111, maxSteps: 0 }, { no: 2, rows: 4, cols: 4, init: 0000000000000000, maxSteps: 6 } ] }init字符串的长度必须等于rows * cols加载时逐个字符转成 0/1 填进QVectorint。读取用QFile加QJsonDocument文件放到 exe 同目录或者qrc里都行放到qrc里的好处是不会丢文件坏处是改关卡还要重新编译。我的习惯是开发阶段放外部 JSON发布时打进qrc。验证加新关是否正常不需要真去点完整个棋盘写一个自动化逻辑加载 JSON → 模拟在不同位置点击 → 每步调用一次checkWin()如果最终全 1 就认为配置合法。这个函数跑通后续扩展二十关以外的内容就有底气了。这个项目教会我最实在的一件事是Qt 游戏和普通业务界面的分界线不在控件多花哨而在状态是否集中管理。金币怎么画、动画怎么播都是表面功夫真正决定项目能不能扛到二十关以后的是数据结构和页面生命周期。希望这个拆解对你正在做的 Qt 项目有帮助。本文还有配套的精品资源点击获取
返回列表