简介QtFlappyBird是一款使用Qt框架完成的Flappy Bird克隆游戏源码包定位为面向具备基础C语法、想进阶学习Qt 2D游戏开发的开发者也适合作为高校课程设计与开源项目研读案例。压缩包共57个文件以png图片资源、cpp/h源码为主同时包含mp3音效、ico图标、pro工程文件、qrc资源列表及详细README文档整体大小仅490KB文件组织按模块与资源分类方便快速定位。该项目完整演示了Qt游戏开发中的关键链路利用QGraphicsScene与QGraphicsView搭建渲染场景通过信号槽捕获玩家点击输入结合QTimer驱动小鸟上升落下与管道平移并实现碰撞检测、计分音效与结束界面交互。资源覆盖丰富的素材与可运行工程读者既能直接编译体验也能按源码逐步拆解游戏循环、状态管理和跨平台部署思路在较短时间内构建起对Qt游戏开发流程的系统认知。目前已有189人学习下载。1. Qt FlappyBird-master.zip 是什么把像素鸟搬进 Qt值得花一个周末不少刚开始学 Qt 的朋友拿到QtFlappyBird-master.zip后第一反应是解压、打开.pro、点运行然后看着黑窗口或者一堆报错发懵。这个压缩包里的工程本质就是“用 Qt Widgets 复刻 Flappy Bird 的最小游戏实现”它把事件循环、定时器、2D 绘图、键盘交互和最简单的碰撞检测全串在了一个不到三百行的程序里。对初学者来说这类项目最有价值的地方不是“我能照着抄一个游戏”而是能一次搞懂 Qt 程序到底是怎么跑起来的——谁在定时触发、谁在重绘画面、按空格键之后发生了什么。我见过太多人啃完了 C 语法却写不出一个能动的界面程序归根结底是缺了这样一条把 QApplication、QWidget、QTimer、QPainter 串起来的链路。如果你已经装好了 Qt 5.x 或 Qt 6.x 对应的 Qt Creator这个项目大概一个下午能调通就算你只想要一份“能演示事件和绘图”的工程模板它也足够你改造成别的 2D 小游戏。先别急着开跑我们把它拆开看。2. 从 zip 里的文件组织认识这段游戏代码工程骨架、资源和入口打开压缩包按这类 Qt 小游戏项目最常见的组织方式你会看到一组.pro、.cpp、.h外加一个assets或images目录。读懂这些文件怎么协作比读懂每行代码更重要因为 Qt 工程一旦蔓延到一百个文件卡住你的往往不是语法而是“哪个文件被编译了、资源从哪加载的”这类工程问题。2.1 .pro 与 main.cppqmake 工程入口先把骨架立住.pro文件是 qmake 的工程描述文件。这类小游戏一般会写成这样QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET FlappyBird TEMPLATE app SOURCES \ main.cpp \ widget.cpp HEADERS \ widget.h RESOURCES \ res.qrc这段配置说明了三件事项目依赖core、gui并且在 Qt5 以上追加widgets模块TARGET决定生成的可执行文件名SOURCES、HEADERS、RESOURCES分别列出参与编译的源文件、头文件和资源描述文件。如果你用 Qt Creator 新建 Qt Widgets Application 模板生成的.pro和上面几乎一样只是多了几行模板注释。此处最容易踩的坑是往工程目录丢了一个.cpp却没加进SOURCES结果函数声明都写好了还是报“undefined reference”。所以拿到别人的工程后我一般先看.pro确认它编译了哪些文件而不是着急点运行。接着看main.cpp。Qt 的初始化入口是固定的你几乎不用改它#include QApplication #include widget.h int main(int argc, char *argv[]) { QApplication a(argc, argv); Widget w; w.show(); return a.exec(); }QApplication负责初始化 Qt 的运行环境管理事件循环w.show()把窗口显示出来a.exec()进入事件循环游戏的所有动画、键盘响应都在这个循环里被分发处理。很多新手把exec()当成类似system(pause)的东西其实它是整个程序的发动机它不返回程序就不退出。2.2 widget 类绘图、键盘、逻辑三件事都在这一个类里这类小游戏通常不会引入复杂的 MVC 分层而是把游戏状态、物理更新、绘制全放在继承自QWidget的类里。头文件里你会看到这样一组成员class Widget : public QWidget { Q_OBJECT public: explicit Widget(QWidget *parent nullptr); protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private: QTimer *m_timer; int m_birdY; int m_birdVY; int m_pipeX; bool m_gameOver; };关键点在于paintEvent和keyPressEvent。前者在窗口需要重绘时被 Qt 自动调用后者在窗口持有键盘焦点并且用户按键时触发。这种“事件驱动”的写法与命令行程序完全不同你不需要在自己的while循环里轮询键盘Qt 已经替你做好了事件分发。很多初学 Qt 的人会把游戏循环塞进paintEvent里理由是“反正每帧都会调用”。这是典型的错误示范paintEvent里只应该做绘制不要把状态更新和碰撞检测放进去否则窗口一缩放或一闪现游戏逻辑就跑乱了。正解是把逻辑放在QTimer的槽函数里paintEvent只管把当前状态画出来。2.3 qrc 资源文件图片和音效统一编进二进制res.qrc长这样RCC qresource prefix/ file aliasbird.pngassets/bird.png/file file aliasflap.wavassets/flap.wav/file /qresource /RCCqrc 文件把磁盘上的图片、音频打包进可执行文件运行时通过:/bird.png这样的虚拟路径访问。这里有两个容易出问题的细节prefix/表示虚拟路径的根目录是:/后面引用图片要写:/bird.png而不是bird.png。alias允许你把assets/目录下的文件映射成一个短名字避免了在代码里写一长串:/assets/image/bird.png。另一个常见做法是把图片放在运行目录下用相对路径QFile(bird.png)或绝对路径读取。这么做的好处是换图不用重新编译坏处是发布时一旦忘记带上图片目录游戏就开始“隐身”——我们会在第 5 章的避坑部分专门展开这个问题。3. 在本地跑通一个最小 Flappy BirdQPainter 绘图 QTimer 帧循环学 Qt 游戏有个捷径先别管素材、音效、状态机用矩形和椭圆把核心逻辑跑通。等画面能动、碰撞能判定再换美术资源那只是改几个坐标和贴图的事。这一章给你一份可以直接建工程的最小实现。3.1 主窗口与成员变量把小鸟、管道、计时器都收进 QWidget新建widget.h#ifndef WIDGET_H #define WIDGET_H #include QWidget #include QTimer class Widget : public QWidget { Q_OBJECT public: explicit Widget(QWidget *parent nullptr); protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private: void updateGame(); QTimer *m_timer; qreal m_birdY 240.0; // 小鸟中心位置的 Y 坐标向下为正 qreal m_birdVY 0.0; // 小鸟垂直速度 qreal m_pipeX 480.0; // 管道左侧的 X 坐标 int m_score 0; bool m_gameOver false; }; #endif // WIDGET_H这里我把所有游戏数据都定义为成员变量因为paintEvent和keyPressEvent都要访问它们。qreal在 Qt 里默认是double用浮点数存坐标能避免像素单位下累加速度时出现严重的整数截断误差。3.2 用 QTimer 驱动 updateGame固定帧率的游戏循环构造函数里完成定时器的初始化Widget::Widget(QWidget *parent) : QWidget(parent) { setFixedSize(480, 640); setFocusPolicy(Qt::StrongFocus); m_timer new QTimer(this); m_timer-setInterval(16); // 约 60 FPS connect(m_timer, QTimer::timeout, this, Widget::updateGame); m_timer-start(); }setInterval(16)的意思是每 16 毫秒产生一次timeout信号换算下来约等于 60 FPS。connect把信号接到updateGame槽函数上只要定时器不停止游戏逻辑就会周期性执行。注意setFocusPolicy(Qt::StrongFocus)不设置这句窗口可能永远收不到键盘事件按下空格没有任何反应。这行代码是多数“按键无效”问题的根源。3.3 在 updateGame 里更新物理状态重力与位移void Widget::updateGame() { if (m_gameOver) return; // 重力加速度每帧让下落速度增加 0.45 像素/帧 m_birdVY 0.45; m_birdY m_birdVY; // 简单的下落边界碰到天花板就停在顶部 if (m_birdY 0) { m_birdY 0; m_birdVY 0; } // 管道向左移动模拟小鸟往前飞 m_pipeX - 2.0; if (m_pipeX -70) { m_pipeX 480; m_score; // 后面还可以在这里让管道随机变化高度 } // 撞地判定窗口高度 640小鸟图片高约 34 if (m_birdY 34 640) { m_gameOver true; } update(); // 请求重绘触发 paintEvent }多数 Flappy Bird 的实现会把“向下”当作y增大的方向和屏幕坐标一致。这也是两个常见的物理参数重力加速度 0.45 像素/帧²水平速度 2 像素/帧。数值看起来很小是因为它们是“每帧累积”的60 帧每秒叠下来一秒钟速度会接近 27 像素/帧配合 640 的窗口高度节奏正好。有人为了追求手感把重力调到 0.6 以上结果小鸟一松手就贴地完全没法玩。参数怎么调第 4 章再细说。update()是 Qt 里请求重绘的通用入口。它不会立刻调用paintEvent而是标记窗口为“需要重绘”等到事件循环空闲时再统一绘制。这样避免了在同一帧里多次修改状态导致绘图次数爆炸。3.4 paintEvent 里的绘制顺序先背景、再管道、最后鸟void Widget::paintEvent(QPaintEvent *) { QPainter painter(this); // 1. 背景 painter.fillRect(rect(), QColor(135, 206, 235)); // 2. 上管道从顶部延伸下来 painter.fillRect(QRectF(m_pipeX, 0, 70, 260), QColor(34, 139, 34)); // 3. 下管道留出 150 像素的裂缝高度 int gap 150; painter.fillRect(QRectF(m_pipeX, 260 gap, 70, 640 - (260 gap)), QColor(34, 139, 34)); // 4. 小鸟暂用椭圆代替 painter.setBrush(QColor(255, 215, 0)); painter.setPen(Qt::NoPen); painter.drawEllipse(QRectF(180, m_birdY, 34, 30)); // 5. 游戏结束提示 if (m_gameOver) { painter.setPen(Qt::white); painter.setFont(QFont(sans, 20)); painter.drawText(rect(), Qt::AlignCenter, Game Over); } }绘制顺序决定了遮挡关系。先画背景再画管道最后画小鸟小鸟才能显示在管道外层。如果先后顺序反了小鸟会被上管道盖住一半看起来就像穿模。这里用QRectF而不是QRect为的是让坐标计算在浮点域里保持一致如果混用int和qreal管道移动时可能出现像素跳动原因是绘制时反复取整。painter.drawText(rect(), Qt::AlignCenter, ...)是 Qt 里快速居中绘制文本的写法不需要手动计算文本的boundingRect。3.5 keyPressEvent空格跳跃与重开void Widget::keyPressEvent(QKeyEvent *event) { if (event-key() Qt::Key_Space) { if (m_gameOver) { // 重置所有状态 m_birdY 240.0; m_birdVY 0.0; m_pipeX 480.0; m_score 0; m_gameOver false; } else { m_birdVY -7.5; // 跳跃给一个向上的初速度 } } QWidget::keyPressEvent(event); }空格在非gameOver状态下提供向上的初速度在gameOver状态下则重置整个游戏状态。重置逻辑必须把小鸟位置、速度、管道位置、分数全部归零漏掉任何一个都会导致“复活之后立刻又挂了”的诡异行为。到这一步一个能玩的最小 Flappy Bird 已经成型。接下来要把这个“能跑的玩具”调成“手感能接受的游戏”核心工作是捋清各个参数之间的关系。4. 会让人改到怀疑人生的 5 个参数重力、跳跃力、管道间距、帧率与坐标我相信大多数人第一次修改 Flappy Bird 的手感时都会经历“重力调大一点试试……怎么摔得这么快跳跃力调大一点……怎么飞上天了”的循环。这套参数不是说改就改得好的它们之间有换算关系而且坐标系的方向搞反了会更乱。4.1 先把坐标系和像素/帧的单位统一为什么 birdY 向下增加QWidget的坐标系原点在左上角x向右增大y向下增大。所以“重力让小鸟下落”在代码里表现为m_birdVY 0.45速度为正数“跳跃向上”则是m_birdVY -7.5速度取负。这里有个运算细节速度的单位是“像素/帧”不是“像素/秒”。如果某天你把帧率从 60 FPS 改成 30 FPS但没调整单位换算所有物理参数的等效速度都会直接减半游戏会变得像慢放。常见的做法是让定时器固定在 16ms物理逻辑里不引入真实时间或者引入deltaTime做帧率无关的物理更新。对 Flappy Bird 这种小游戏而言固定帧率就是最简单的正解但记得不要随意改setInterval。另一个容易混淆的是世界坐标和窗口坐标。当窗口固定为 480×640 时两者可以直接等价一旦启用窗口缩放、全屏或者自适应布局坐标就要做比例换算。最省事的方案是setFixedSize锁死窗口拒绝缩放等后续要做多分辨率适配时再把逻辑坐标和绘制坐标分离。4.2 核心物理参数表重力、跳跃力、水平速度、裂缝高度这里给一组经过我反复试玩、手感相对合理的初始值单位统一为“像素/帧”参数推荐值说明重力加速度0.45每帧累加到垂直速度上跳跃初速度-7.5向上的速度负号代表向上水平速度2.0每帧管道向左移动的距离管道宽度70太宽视觉压抑太窄飞行难度剧增裂缝高度150上下管道之间的空隙小鸟画幅34×30它决定碰撞盒的基准尺寸定时器间隔16 ms约 60 FPS手感最流畅把这些数值带进去算一下小鸟在0.45的重力加速度下从零开始下落一秒位移大约是0.5 * 0.45 * 60^2 810像素也就是说一秒内从窗口顶部落到窗口底部还绰绰有余。每次点击跳跃给-7.5的初速度上升后再开始下落最终呈现出来的是“点击一下往上猛窜一下然后快速坠落”的效果——这正是 Flappy Bird 标志性的手感。如果想让游戏更温和建议优先调水平速度和裂缝高度而不是调重力。因为重力一变跳跃手感也会跟着变你很难只凭感觉找到新的平衡点。4.3 管道的生成节奏多根管道要存坐标单根 x 只适合演示上面第 3 章的代码只有一根管道绕出左边界后回到右侧分数加 1。这在演示物理逻辑时可以跑通但实际玩起来会让人无所适从——因为没有“管道群”游戏就没有持续的紧张感。要做多根管道就需要一个容器保存每一根管道的x坐标和裂缝中心高度struct Pipe { qreal x; qreal gapCenterY; }; QVectorPipe m_pipes;每生成一根新管道时让gapCenterY在 150 到 490 之间随机取值再定期把新管道追加到m_pipes尾部。更新逻辑改为遍历整个向量for (int i 0; i m_pipes.size(); i) { m_pipes[i].x - 2.0; if (m_pipes[i].x -70) { m_pipes.removeAt(i); --i; m_score; } } if (m_pipes.isEmpty() || m_pipes.last().x 240.0) { Pipe p; p.x 480.0; p.gapCenterY 150 qrand() % 340; // 随机裂缝中心位置 m_pipes.append(p); }这里用“最后一根管道移动到屏幕中间再生成下一根”的条件保证相邻管道的水平间距在约 240 像素玩家有足够的反应时间。qrand()是 Qt 4 时代遗留下来的随机函数在 Qt 5.10 更推荐用random或QRandomGenerator::global()-bounded(150, 490)。删除超过左边界的管道时用了removeAt并且把循环索引--i回退防止跳过下一根。这个细节看着小实际写错会导致管道内存越积越多游戏越来越卡。4.4 调整碰撞检测用缩小的命中盒替代整个矩形新手最容易直接把QRectF拿来用intersects()判断碰撞效果是“小鸟还没碰到管子就已经死了”。原因很简单绘制出来的椭圆贴在矩形内但玩家肉眼判断的是椭圆的边缘矩形判定把四个角的空白区域也算了进去。我一般会在绘制矩形的基础上把小鸟的碰撞盒缩小一圈管道也同理两边各缩 2~3 像素bool Widget::hitPipe(const QRectF birdRect) { // 缩小后的“实际身体” QRectF birdBody birdRect.adjusted(4, 4, -4, -4); for (const Pipe p : m_pipes) { QRectF pipeRect(p.x, 0, 70, p.gapCenterY - 75); // 上管道 QRectF pipeRect2(p.x, p.gapCenterY 75, 70, 640 - (p.gapCenterY 75)); // 下管道 if (birdBody.intersects(pipeRect) || birdBody.intersects(pipeRect2)) { return true; } } return false; }adjusted(4, 4, -4, -4)会把矩形四周各缩小 4 像素实际碰撞区域从 34×30 变成 26×22手感宽容度提升明显。上面公式里的75是管道半裂缝高度它和绘制代码里的gap有对应关系调这个值时必须让绘制和碰撞用同一个数值我曾经只改了碰撞检测忘了改绘制结果“明明撞上了却穿过去”。5. 避坑实录Qt 版本混用、qrc 路径与打包运行时的 4 个翻车现场写 Qt 小游戏的一大特点就是代码没错运行环境却有一万个理由让你崩溃。下面的四类问题我都在不同机器上遇到过按现象到解决完整梳理一遍。5.1 编译报错 version ex50601Qt 库版本混用的典型症状现象用 Qt Creator 打开.pro后点构建编译器报了一长串类似fatal: cannot mix incompatible Qt library (version ex50601) with this library的错误或者FATAL: module Qt5Widgets version 5.15.2 is incompatible。程序完全无法链接。原因这通常意味着你当前编译用的头文件来自某个 Qt 版本但链接器拿到的.lib或.so来自另一个版本。最常见的情形是PATH环境变量里有两个 Qt 目录或者 Qt Creator 的Kit选择的编译器和 Qt 版本不匹配。有的机器上装了 Qt 5.12 和 Qt 5.15编译器却只能找到其中一个的qmake于是一次编译混用了两个版本的头文件和库文件。解决打开 Qt Creator 的“工具 → 选项 → Kits”确认当前构建套件选择的 Qt 版本和编译器匹配然后在项目的构建目录里执行“清理项目”并全量重建避免旧产物残留。命令行下编译时务必把目标 Qt 的bin目录放到PATH最前面并在终端里执行qmake -v确认版本号。如果还是报错检查.pro里是否手写了LIBS -L... -lQt5Widgets这种硬编码路径这类写法非常容易引到另一个 Qt 安装目录。5.2 图片真机显示不出来qrc 路径的前缀坑现象编译和运行都成功窗口背景、管道都画出来了但小鸟图片是一片空白或者只有一个小红叉。原因代码里写的是相对路径bird.png而不是:/bird.png。当程序从 Qt Creator 里运行时当前工作目录可能是构建目录也可能是源码目录一旦用命令行手动启动可执行文件工作目录更是不可控程序找不到图片绘制自然失败。还有种情况是res.qrc里写了prefix/images代码里却写:/bird.png路径不匹配一样的表现。解决统一采用 qrc 虚拟路径例见 2.3 节。如果你确实要用外置图片资源就绝不要依赖相对路径而是基于QCoreApplication::applicationDirPath()拼接绝对路径QString imgPath QCoreApplication::applicationDirPath() /assets/bird.png; QPixmap birdPixmap(imgPath);这种做法在发布时要把整个assets目录和可执行文件放在一起否则换台机器照样失踪。5.3 打包后提示 could not find the qt platform plugin linuxfbplatforms 插件缺失现象在 Windows 或别的桌面环境开发时一切正常把可执行文件拷到一块嵌入式板子或不带桌面环境的系统上运行弹出qt.qpa.plugin: could not find the qt platform plugin linuxfb in ...程序直接退出。原因Qt 的窗口系统后端是运行时通过插件加载的Windows 上加载windows.dllLinux 桌面加载xcb.so嵌入式环境则可能用linuxfb或eglfs。发布时如果只拷贝了可执行文件没带plugins/platforms目录或没设置QT_QPA_PLATFORM_PLUGIN_PATHQt 就不知道去哪儿找平台插件。解决发布目录要保持与安装目录相同的结构。在 Windows 上可以用windeployqt.exe它会自动把插件和 DLL 拷到你指定的目录windeployqt --release FlappyBird.exe交叉编译到 ARM 板或树莓派等 Linux 嵌入式环境时要确认你安装的 Qt 包含了对应的平台插件同时设置export QT_QPA_PLATFORMlinuxfb export QT_QPA_PLATFORM_PLUGIN_PATH/your/qt/plugins然后运行ldd FlappyBird | grep -i not found检查动态库缺失情况。这一步容易在无桌面环境上踩到其中linuxfb只是 Qt 众多平台插件之一没有桌面的情况下它是最直接的显示后端。5.4 退出闪退 0xC0000005定时器与对象生命周期现象Windows 上正常玩没问题一关闭窗口就弹出崩溃框错误码类似0x0000005调试器定位到QTimer::timeout相关的槽函数里控制台还可能打印QObject::killTimer: Timers cannot be stopped from another thread。原因窗口关闭后Widget对象被销毁但如果定时器没有停止Qt 还会尝试把timeout信号发送给已经被销毁的对象。尤其是当你在栈上创建了Widget或者deleteLater()时机不对时虚函数表已经被清理这时触发槽函数等价于访问非法内存。解决在Widget的析构函数里主动停表杜绝越界访问Widget::~Widget() { m_timer-stop(); }不用手写delete m_timer因为m_timer以this为父对象父对象销毁时会自动清理子对象但stop()是显式停止你需要确保在窗口销毁之前定时器不再产生任何信号。另外还有一个隐藏坑QTimer如果被粗心设置为m_timer的父对象不是窗口而是nullptr那么窗口先销毁、定时器后销毁的时序同样会翻车。所以m_timer new QTimer(this);这句里的this不能省。6. 给 FlappyBird 做个状态机它就不再只是单文件小游戏最后一步把这个游戏从“一个类写完所有逻辑”升级成“状态驱动”核心是引入游戏状态机。状态触发条件行为Ready程序启动画面静止显示“按空格开始”RunningReady 状态下按空格定时器开始物理生效Paused按 P 键定时器停止画面冻结Over碰撞检测为真停止物理显示分数不必引入QStateMachine用enum加一个setState()就够了enum GameState { Ready, Running, Paused, Over }; GameState m_state Ready; void Widget::setState(GameState newState) { m_state newState; if (m_state Running) m_timer-start(); else m_timer-stop(); update(); }这样keyPressEvent里不再堆一长串if (m_gameOver)而是根据状态分发到不同处理逻辑。验证一个游戏实现是否健康我常用的方法是固定随机种子让参数可复现在调试输出里每秒打印一次FPS和m_pipeX确认逻辑帧稳定在 60 附近然后故意把碰撞盒调到最大再测试“视觉上没撞上却判死”是否消失。我最早做 Flappy Bird 的时候为了让它不闪退折腾了两晚最后发现只是一行setFocusPolicy漏写。这类小游戏的坑都很直白但你非得亲手踩一遍才能记住。希望这篇拆解能让你省下这个时间也希望帮到你。本文还有配套的精品资源点击获取