
简介这份压缩包是一份基于Qt框架开发的Flappy Bird克隆游戏完整源码面向想学习Qt游戏开发、2D图形界面编程或面向对象C实战的初中级开发者。包内共57个文件涵盖C源文件与头文件、PNG图像素材、MP3音效、ICO图标、Qt工程文件.pro、资源文件.qrc及说明文档类型齐全整体仅490KB轻量便于快速上手。已有189人学习下载适合作为课程设计或个人项目参考。源码围绕Qt核心机制展开通过QGraphicsView与QGraphicsScene管理游戏场景和图形项利用信号槽响应点击事件借助QTimer驱动鸟的扇翅、管道滚动等动画并实现了碰撞检测、计分、游戏状态切换等完整逻辑。同时项目演示了Qt资源系统加载图片和声音的方法得益于Qt的跨平台特性代码可运行于Windows、Linux、macOS等多系统。目录结构清晰素材与代码分离注释与文档齐备便于对照调试和二次开发是理解Qt游戏开发全流程的实用范本。1. 用 Qt 复刻 Flappy Bird先想清楚它到底在训练什么把一个 Flappy Bird 的 Qt 源码压缩包比如 QtFlappyBird-master.zip打开看到的往往不是游戏本身而是一套典型的 Qt 客户端开发基本功QWidget 或 QGraphicsView 选型、QPainter 绘图、QTimer 驱动游戏循环、事件处理、资源文件管理以及最后的打包发布。很多五年以上经验的开发者拿到这类小游戏源码第一反应是“这不就是个定时器加碰撞检测”但真正动手改一遍会发现帧率控制、设备坐标系、事件循环阻塞、高分榜持久化每一个点都能延展出实际项目里会踩的坑。这篇文章就从解压一个 Qt 制作的 Flappy Bird 项目出发把它拆成能跑通的最小框架再逐步补上碰撞检测、音效、界面美化和发布部署这些工程细节。无论你是想用 Qt 练手游戏开发还是要把这个项目改造成教学案例都可以直接从第二章的代码开始动手。2. 先搭出最小可玩的 QtFlappyBird 游戏框架2.1 选 QWidget 还是 QGraphicsViewFlappyBird 这类 2D 游戏的标准答案常见的 Qt 游戏源码里Flappy Bird 有两种实现路线一种继承 QWidget重写 paintEvent所有游戏对象都在 widget 上用 QPainter 绘制另一种用 QGraphicsScene QGraphicsView把鸟、管道、背景都做成 QGraphicsItem由场景统一管理碰撞和遍历。我见过的 Qt 初学者项目大多数选前者因为它代码量少容易理解但实际项目里地图元素多起来以后自己写的碰撞遍历会越来越吃力。QGraphicsView 虽然有 Item 级别的碰撞检测和变换但对 Flappy Bird 这种玩家只有一只鸟、管道固定生成的游戏来说额外的抽象反而让代码变散。所以这里采用 QWidget 直接自绘的方案——对象少、逻辑集中在 paintEvent 和 update 调用里比引入一套图形框架更适合作为教学基线。以下是最小工程的文件结构对应 Qt Creator 里新建的 QWidget Application类名用 GameWidget主窗口直接把这个 widget setCentralWidget 进去// gamewidget.h #ifndef GAMEWIDGET_H #define GAMEWIDGET_H #include QWidget #include QTimer #include QElapsedTimer class GameWidget : public QWidget { Q_OBJECT public: explicit GameWidget(QWidget *parent nullptr); protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; void mousePressEvent(QMouseEvent *event) override; private: void updateGame(); void resetGame(); void spawnPipe(); QTimer m_timer; QElapsedTimer m_clock; double m_birdY 300.0; // 鸟的 Y 坐标逻辑坐标系 double m_birdVy 0.0; // 垂直速度 double m_gravity 980.0; // 重力加速度单位像素/秒^2 double m_jumpSpeed -320.0; // 点击时的瞬时速度 QVectorQRectF m_pipes; // 管道矩形集合 int m_score 0; bool m_running false; }; #endif // GAMEWIDGET_H核心的updateGame()里用QElapsedTimer计算真实帧间隔而不是假设每次定时器触发间隔都一样// gamewidget.cpp 关键片段 void GameWidget::updateGame() { if (!m_running) return; double dt m_clock.restart() / 1000.0; if (dt 0.05) dt 0.05; // 防止窗口拖动时帧间隔过大 m_birdVy m_gravity * dt; m_birdY m_birdVy * dt; // 管道左移每秒120像素这是 FlappyBird 的基础速度 for (auto pipe : m_pipes) { pipe.moveLeft(pipe.left() - 120.0 * dt); } // 移除移出屏幕左侧的管道同时计分 for (int i m_pipes.size() - 1; i 0; --i) { if (m_pipes[i].right() 0) { m_pipes.removeAt(i); m_score; } } update(); // 触发重绘 }这里有一个容易忽略的点QTimer默认是精准性不可靠的尤其在窗口拖动或主线程繁忙时定时器回调可能延迟甚至合并。所以真正的帧率控制用QElapsedTimer计算dt做补偿而不是把定时器 interval 设成 16 就当 60 FPS 用。定时器本身只当作“每次 tick 去取一次当前时间”的触发器。2.2 固定帧率与随机管道生成让参数可见、可调很多 Flappy Bird 的 Qt 实现把定时器 interval 直接设成 16然后在timerEvent里做所有逻辑帧率完全依赖定时器稳定触发。这种做法在简单场景下看不出问题但一旦打包发布到性能波动大的机器上游戏会变快或变慢。正确做法是固定时间步长 累计渲染间隔。这里给出一个实际可用的升级版void GameWidget::startLoop() { m_clock.start(); m_timer.start(4); // 4ms tick用于驱动逻辑实际步长由累加器决定 } void GameWidget::updateGame() { double frameTime m_clock.restart() / 1000.0; if (frameTime 0.05) frameTime 0.05; m_accumulator frameTime; const double step 1.0 / 240.0; // 物理步长 240Hz更平滑 while (m_accumulator step) { stepGame(step); m_accumulator - step; } update(); }管道生成采用“距离触发”而不是“定时触发”——记录上一根管道的 X 坐标当它移动出一定距离后生成新管道。这样无论帧率高还是低两根管道之间的水平间距始终是恒定的空间距离而不是相等的时间间隔。容易出现的错误是直接在stepGame里加一个计时器变量到了设定毫秒数就 spawn结果帧率低时管道间距变大、帧率高时管道又挤在一起游戏难度完全失控。距离触发代码如下void GameWidget::spawnPipeIfNeeded() { double lastPipeX m_pipes.isEmpty() ? width() : m_pipes.last().left(); if (lastPipeX width() - 260.0) { double gapY 150.0 (QRandomGenerator::global()-bounded(200.0) - 100.0); QRectF lowerPipe(QRectF(width(), gapY 140.0, 70.0, height() - (gapY 140.0))); QRectF upperPipe(QRectF(width(), 0, 70.0, gapY - 90.0)); m_pipes.append(lowerPipe); m_pipes.prepend(upperPipe); } }注意m_pipes里同时保存上管道和下管道每根管道的 X 坐标始终一致碰撞检测时要配对判断。gapY是上下管道之间的空隙中心 Y 坐标140 的 gap 高度对新手合适改成 100 到 120 会明显变难。这些参数不要埋在代码里建议在校验阶段提到配置文件或类成员方便后面调难度。2.3 逻辑坐标系与设备坐标系高分屏不会让小鸟飞得比别人快Qt 里QWidget::width()和paintEvent里拿到的绘制区域默认都是设备像素但 Flappy Bird 的逻辑坐标是独立的。如果不做坐标映射2K 屏和 1080P 屏上同样的m_birdY数值实际像素高度完全不同重力加速度 980 像素/秒²在不同屏幕上的视觉速度也完全不同。常见做法是设定一个固定的“虚拟分辨率”比如 480×800然后在paintEvent里把所有逻辑坐标乘以缩放系数void GameWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); const double scaleX double(width()) / 480.0; const double scaleY double(height()) / 800.0; painter.scale(scaleX, scaleY); // 此后所有绘制命令都按 480x800 的逻辑坐标写 // 绘制背景、小鸟、管道…… }配合QPainter::save()和restore()保护缩放状态绘制文本时如果不想让文字跟着缩放变形可以先把缩放系数取出来反向补偿。这里有一个很多人踩过的坑鼠标和键盘事件的坐标也是设备像素判断是否点击到鸟身上时要先把事件坐标映射回逻辑坐标系否则不同缩放比下点击区域完全错位。映射方式是用devicePixelRatioF()或者直接除以缩放系数。3. 碰撞检测与分数判定把 Qt 矩形相交玩明白3.1 用 QRectF 自带的 intersects 还是自己判断边距最直观的碰撞检测是鸟的包围盒和每个管道矩形做intersects判断。鸟是一个约 34×24 的矩形中心在 (birdX, birdY)管道是QRectF两者相交即游戏结束。简单写法是bool GameWidget::checkCollision() { QRectF birdRect(m_birdX - 17.0, m_birdY - 12.0, 34.0, 24.0); for (int i 0; i m_pipes.size(); i) { if (birdRect.intersects(m_pipes[i])) { return true; } } // 地面碰撞逻辑坐标 Y 720 表示地面顶线 if (m_birdY 12.0 720.0) return true; if (m_birdY - 12.0 0.0) return true; return false; }但intersects是严格的矩形重叠判断玩家会明显感觉到“鸟还没碰到管道就死了”——因为矩形包围盒比鸟的实际贴图大。针对 Flappy Bird 这个具体场景常见处理方式是把鸟的碰撞矩形缩小到 24×18管道左右各向内收缩 3 个像素上下各收缩 2 个像素。也就是用adjusted()QRectF birdRect(m_birdX - 12.0, m_birdY - 9.0, 24.0, 18.0); QRectF pipeRect m_pipes[i].adjusted(3.0, 2.0, -3.0, -2.0); if (birdRect.intersects(pipeRect)) { // 碰撞 }这样玩家体验会稍微宽容一些。真正精细的像素级碰撞可以继承QGraphicsItem重写shape()方法返回QPainterPath但 Flappy Bird 的素材相对简单矩形收缩就够了。整体来说这套逻辑可以直接用到实际小游戏开发中像素级碰撞在粒子特效和弹幕游戏里才值得引入。3.2 分数判断为什么不能在用 传送门 式逻辑时写错计分时机Flappy Bird 的得分时机是鸟的上边缘越过管道空隙中心线的那一帧。常见的错误写法是在碰撞检测里顺手计分——鸟每碰到一次空隙就加分但鸟可能在空隙里上下抖动同一根管道被多次触发加分。正确写法是每根管道设置一个scored标记struct PipePair { QRectF upper; QRectF lower; bool scored false; }; // 在管道移动之后检查 for (auto pair : m_pipes) { if (!pair.scored pair.upper.right() m_birdX) { pair.scored true; m_score; } }判断条件是“管道右边缘已经越过鸟的左边缘”既不是中心也不是左边缘这样保证只触发一次。另外注意分数更新后要有 UI 反馈常见做法是发一个scoreChanged信号让界面层更新 QLabel或者直接在paintEvent里画出来——如果后续要做 QSS 美化或者高分榜持久化拆成信号槽更合适。3.3 碰撞性能优化管道多起来以后 QVector 的遍历到底够不够用Flappy Bird 同时存在的管道数量通常不超过 6 根线性遍历完全够用。但把这个项目改造成弹幕游戏、跑酷游戏甚至 Flappy Bird 变种时管道数量可能到几十上百每帧对所有管道调用intersects会开始有压力。可以提高的空间分区思路只检测 X 坐标在鸟的前后 100 像素范围内的管道因为太远的管道不可能碰撞用两个索引数组维护“活跃窗口”窗口左右滑动时只对新进入窗口的管道做碰撞判断更复杂的场景用自定义四叉树但 QGraphicsScene 自带的索引机制其实已经做了空间哈希这也是为什么复杂游戏建议用 QGraphicsView 的原因对 Flappy Bird 来说二分查找当前鸟 X 坐标在管道数组中的位置只检查邻近两根管道是最容易实现的优化代码量不超过 15 行。很多人一开始就写for循环遍历全部管道属于过早优化里最不值得担心的一种真正应该注意的反而是碰撞判定的主循环不要写在paintEvent里——重绘频率和逻辑帧率不一致时会出现画面看起来没碰到但游戏判定已经死了的诡异问题。4. 用 QPainter 绘制背景、地面和小鸟动画让代码见画4.1 绘制顺序与图层管理先画什么后画什么是有讲究的Flappy Bird 的画面层级从下到上是天空渐变背景 - 远景建筑群 - 地面 - 管道 - 小鸟 - 分数文字。在paintEvent里严格按这个顺序绘制因为后画的覆盖先画的。常见初学者喜欢先画小鸟再画管道导致小鸟被管道遮住看起来像是穿模。使用 QPainter 绘制时可以考虑用状态保存的方式控制每个图层的变换void GameWidget::drawBackground(QPainter p) { p.save(); p.setPen(Qt::NoPen); QLinearGradient gradient(0, 0, 0, 800); gradient.setColorAt(0.0, QColor(#70c5ce)); gradient.setColorAt(1.0, QColor(#e8f4f8)); p.fillRect(QRectF(0, 0, 480, 800), gradient); p.restore(); }QLinearGradient是直接从零绘制天空的常用工具如果要用图片背景就改成drawPixmap平铺。这里有一个高效技巧背景如果不变可以提前渲染到QPixmap上paintEvent里只做整块drawPixmap避免每帧重新计算渐变和远景。这个技术叫静态图层缓存在实际项目里处理复杂背景时非常管用Flappy Bird 的地面滚动也可以这样优化——把地面纹理生成一张比屏幕宽一些的 pixmap每次只平移绘制位置的部分区域。4.2 用定时器实现小鸟飞行动画的两种方式小鸟的飞行动画有两种主流方式多张精灵图切换或者用 QPainterPath 画一只几何小鸟。多精灵图方式需要准备 3 张不同翅膀状态的图片用QTimer每秒切换 10 次加上qrand随机延迟让动画更自然。几何画法适合代码生成和教学场景void GameWidget::drawBird(QPainter p) { p.save(); p.translate(m_birdX, m_birdY); // 向上飞时鸟头朝上向下飞时鸟头朝下 double rotateAngle qBound(-25.0, m_birdVy / 8.0, 70.0); p.rotate(rotateAngle); // 身体 p.setBrush(QColor(#f8d44c)); p.setPen(Qt::NoPen); p.drawEllipse(QPointF(0, 0), 16, 12); // 眼睛 p.setBrush(Qt::white); p.drawEllipse(QPointF(9, -5), 5, 5); p.setBrush(Qt::black); p.drawEllipse(QPointF(11, -5), 2.5, 2.5); // 翅膀随动画帧切换上下角度 p.save(); QTransform transform; transform.translate(-3, 0); transform.rotate(m_wingAngle); p.setTransform(transform, true); p.drawEllipse(QPointF(-6, 0), 8, 5); p.restore(); // 嘴 p.setBrush(QColor(#f26d21)); p.drawRoundedRect(QRectF(12, -2, 7, 4), 1, 1); p.restore(); }鸟的身体旋转角度直接绑在m_birdVy上下滑时俯冲低头上跳时抬头手感会比固定朝向好不少。p.setTransform(transform, true)里的true表示叠加而不是替换当前变换做局部动画比如翅膀绕自己中心旋转时这个参数非常关键。放实际项目里如果不用几何画法而直接用精灵图也可以把精灵图放进 QPixmap 后按同样方式translate rotate来画注意旋转要围绕鸟的中心所以translate(m_birdX, m_birdY)放在最外层。4.3 QPainter 绘图效率的 4 个必调参数p.setRenderHint(QPainter::Antialiasing, false)游戏里几何图形如果不追求平滑关掉抗锯齿能快不少不要每次在paintEvent里加载图片资源提前在构造函数里QPixmap pix(:/images/bird.png)绘制时drawPixmap即可update()可以传入需要重绘的矩形区域比如update(birdRect)但 Flappy Bird 整体场景都在动局部更新没有意义直接全幅更新就好尽量避免在绘制代码里动态创建QBrush、QPen把它们设为成员变量在构造时创建绘图效率在桌面端 Flappy Bird 上很难感觉到差别但做成嵌入式版本时目标帧率 30 FPS 且 CPU 是 ARM A7 的环境里这些细节决定游戏能不能跑起来。Qt 的QPainter软件渲染在低端设备上要特别注意渐变填充线性渐变比填充纯色贵得多必要时用图片替代。5. 事件处理、定时器与信号槽让交互像样5.1 键盘点击、鼠标点击与触摸事件的三合一处理Flappy Bird 最常见的操作是点击屏幕。Qt 桌面端用mousePressEvent移动端用触摸事件但键盘空格也要支持——因为很多测试环境没有鼠标交互。写一个统一的触发入口void GameWidget::mousePressEvent(QMouseEvent *event) { Q_UNUSED(event); onPlayerAction(); } void GameWidget::keyPressEvent(QKeyEvent *event) { if (event-key() Qt::Key_Space || event-key() Qt::Key_Up) { onPlayerAction(); } } void GameWidget::onPlayerAction() { if (!m_running) { m_running true; m_clock.start(); return; } m_birdVy m_jumpSpeed; }这里有一个边界情况第一次点击前游戏处于准备状态点击后游戏开始游戏结束后点击要重置而不是让小鸟继续下落。所以状态机最好拆成Ready / Running / Dead三个阶段而不是一个bool m_running。常见 Qt 游戏项目里很多人用两个布尔量running dead结果逻辑分支越来越乱。5.2 QTimer 驱动游戏循环与信号槽的边界QTimer在 Qt 里默认属于当前线程的事件循环。如果在槽函数里做了耗时的同步操作比如写文件、加载大图片定时器回调会推迟游戏画面就会卡住。Flappy Bird 本身逻辑很轻不会触发这个问题但把它作为框架扩展时音效播放不要用阻塞式QSound::play在旧版 Qt 里会阻塞改用QSoundEffect或QMediaPlayer异步播放分数持久化不要每次得分都写文件攒到游戏结束再写。这里给一个信号槽的实际用法把分数变化用信号广播出去主窗口的 QLabel 更新分数游戏结束时再发一个gameOver(int finalScore)信号主窗口弹出 QMessageBox 或切换到结束界面。这样GameWidget不依赖具体 UI后续换 QML 界面或加排行榜都容易。signals: void scoreChanged(int score); void gameOver(int score); // 在计分处 if (!pair.scored pair.upper.right() m_birdX) { pair.scored true; m_score; emit scoreChanged(m_score); }emit是 Qt 的宏实际就是个空操作但读代码的人看到emit就知道这里发信号了。信号槽连接时如果分数更新频率很高用Qt::QueuedConnection可能积压消息但 Flappy Bird 一秒加一次分完全不用纠结。5.3 游戏状态机在 Qt 里的实现准备、运行、死亡三态状态机用最简单的枚举加switch就够不需要引入QStateMachine。paintEvent里按当前状态绘制不同的界面Ready 状态显示“点击开始”Dead 状态显示 Game Over 和分数。注意 Dead 状态下仍要重绘背景和管道但小鸟可以继续下落做动画落地后再弹结束框。这种“死亡动画期间游戏还在渲染”的需求如果直接return不更新逻辑画面就会冻结在撞管道帧非常生硬。常见的做法void GameWidget::updateGame() { switch (m_state) { case Ready: update(); return; case Running: stepPhysics(); if (checkCollision()) { m_state Dead; emit gameOver(m_score); } update(); return; case Dead: // 小鸟继续受重力下落直到碰到地面 m_birdVy m_gravity * dt; m_birdY m_birdVy * dt; if (m_birdY groundY) { m_birdY groundY; // 停止更新只保留重绘 } update(); return; } }6. 发布部署与排错QPA plugin path 和打包后闪退的排查套路6.1 打包发布时缺 DLL 的快速诊断Flappy Bird 项目做完要发给别人玩最常用的发布方式是用windeployqt工具自动收集依赖并放到同一个目录。完整命令是打开 Qt 自带的命令行工具注意要用与你编译套件匹配的那个比如 MinGW 对应 MinGW 版工具链进入构建目录执行windeployqt --release --no-translations --no-opengl-sw .注意 Release 版构建才能发布Debug 版带了大量调试符号和额外的 DLL包体和启动速度都差很多。windeployqt结束以后目录下会多出platforms/子目录里面包含qwindows.dll。如果发布后双击闪退先看是否缺少platforms/qwindows.dll——这是最常见的 Qt 打包失败原因。另一个常见情况是你电脑能跑但别人电脑不行那就要检查是否用了较高版本 MSVC 编译对方机器没有对应 VC 运行库。用静态编译可以绕开这个问题但 Qt 本身开源版静态编译配置比较折腾教学项目不必强求。6.2 运行时 QPA Platform Plugin 报错的根因与处理遇到qt.qpa.plugin: Could not load the Qt platform plugin windows in even though it was found这类报错时大前提是platforms/qwindows.dll缺失或路径不对。但还有一种隐蔽情况程序通过某种方式手动设置了QT_QPA_PLATFORM_PLUGIN_PATH环境变量指到了一个旧版本 Qt 的platforms目录。比如搜到的报错信息里出现QT_QPA_PLATFORM_PLUGIN_PATHD:\Qt\5.15.2\msvc2019_64\而实际编译的 Qt 版本是 5.15.3两个版本平台插件混用就会导致加载失败。排查方法是先用dumpbin /dependents看主程序依赖了哪些 Qt DLL再用Process Explorer或Procmon看程序实际加载的 DLL 路径来自哪里。更直接的排除法是暂时清掉环境变量QT_QPA_PLATFORM_PLUGIN_PATH和PATH里 Qt 相关路径只保留程序所在目录来验证。6.3 一套可抄的发布前自检清单发布前用下表逐项检查能覆盖 90% 的 Qt 桌面程序发布事故检查项命令或方法失败判断Release 构建检查构建目录里 exe 大小Debug 通常大很多重新编译 Releasewindeployqt 完整执行命令结束后看 platforms 目录是否存在用--verbose 2查看详细日志VC 运行库依赖用dumpbin /dependents查 MSVCP*.dll安装 VC Redistributable 或改用 MinGW动态运行库冲突目标机器跑where Qt5Core.dll确认加载的是程序同目录不是系统路径里的旧版图标和资源文件检查qrc资源是否在编译时嵌入资源文件是文本或图片不要放外部路径还有一个容易被忽略的细节如果项目里用了QMediaPlayer播放音频windeployqt默认不会收集所有音频插件要把mediaservice和audio目录确认检查一遍。Flappy Bird 这种小型游戏建议直接用QSoundEffect它只依赖 QtMultimedia打包含插件也少。最后再分享一个小技巧发布后若在别人机器上闪退但本机正常在main函数开头启用qInstallMessageHandler把日志输出到文件再让测试人员把 log 文件发回来看崩溃前的最后一条输出。这比远程调试快得多也是 Qt 实际项目里最常使用的现场排查方式。这个技巧对 Flappy Bird 这种小项目可能用不上但如果你把这个项目不断加功能、最后变成一个完整的 Qt 应用它会成为你第一个该掌握的排错手段。本文还有配套的精品资源点击获取