ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Qt QGraphicsView开发Flappy Bird:2D游戏源码实战与避坑指南

2026/10/5 8:31:22 拓冰建站 浏览量
Qt QGraphicsView开发Flappy Bird:2D游戏源码实战与避坑指南 简介这是一份基于Qt框架实现的Flappy Bird游戏完整源码包面向已掌握C基础、想快速上手Qt游戏开发的学习者也适合作为课程设计与开源项目拆解样例。项目以QGraphicsView/QGraphicsScene搭建2D渲染场景通过QTimer控制鸟的飞行与管道滚动利用信号槽机制处理点击上升、碰撞检测和计分逻辑能清晰演示从图形绘制、交互响应到游戏状态管理的整套流程覆盖了2D游戏开发中常见的渲染、事件与资源组织问题。压缩包共57个文件、约490KB包含8个cpp源码、7个头文件、pro工程与qrc资源文件、28张png游戏素材、2个ico图标、5段mp3音效以及ReadMe和Markdown说明目录按地面、记分板、管道、小鸟等模块划分便于对照学习Qt的资源加载、界面更新和跨平台编译要点。目前已有189人浏览/学习适合借此掌握游戏循环、动画驱动和多媒体资源集成方法并可迁移到其他Qt小游戏项目中。1. 用 Qt 做 Flappy Bird一份能跑通的完整 2D 游戏源码实战拆解先说结论这份 QtFlappyBird-master.zip 不是那种只有几个文件的半成品 Demo而是一个用 Qt Widgets 模块QGraphicsView/QGraphicsScene 体系写完整了的 Flappy Bird 克隆。它把小鸟飞行、管道生成与移动、碰撞检测、计分、音效播放、开始/结束/计分板界面全串起来了麻雀虽小五脏俱全。对于想学 Qt 游戏开发、又不想从零画轮子的人这份源码正好当教科书用它演示了 QTimer 驱动游戏循环、信号槽处理用户输入、QGraphicsPixmapItem 管理游戏对象、QSound 播放音效这一整套标准做法。我拆完这份代码后的直接感受是它把“如何用 Qt 做 2D 游戏”这个问题落成了一份可以直接编译运行、逐步断点调试的真实答案。适合三类人——刚学完 C 想找 Qt 练手项目的学生、要把 Qt 游戏开发引入团队的组长、以及想快速搞清 QGraphicsView 体系怎么组织游戏代码的转岗工程师。2. QGraphicsView 与 QGraphicsScene这份源码的地基选型2.1 为什么是 QGraphicsView 而不是 QWidget 自绘拿到这份源码我第一个确认的就是它没用 QWidget::paintEvent 自绘而是用了 QGraphicsView QGraphicsScene QGraphicsItem 这套图形视图框架。这个选型很关键Flappy Bird 里有大量可移动的图形对象——小鸟、管道、地面、数字计分板如果全用自绘每次更新都得手动调用 update() 重绘整个界面还要自己管理碰撞区域代码量会翻倍。QGraphicsView 体系的优势在于场景管理QGraphicsScene 负责维护所有图形对象Item的增删改查QGraphicsView 只负责把场景渲染到窗口上。每一帧的变化只需要修改对应 Item 的坐标或状态视图自动重绘不用关心局部刷新问题。这份源码里地面、管道、小鸟都是独立的 Item 子类各自封装自己的行为和贴图主窗口只负责组装它们——这是标准的“组合优于继承职责分离”写法。在渲染性能上QGraphicsView 默认使用 GraphicsView 的渲染管线对 2D 游戏够用。源码里涉及动画表现的地方不多所以没有激进地开启 ViewportUpdateMode保持默认的 SmartViewportUpdate 模式即可只有 Item 移动的区域会被重绘其他区域不受影响。// 主窗口构造函数中初始化场景与视图的典型写法 MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { scene new QGraphicsScene(this); scene-setSceneRect(0, 0, GAME_WIDTH, GAME_HEIGHT); // 固定游戏世界大小 view new QGraphicsView(scene, this); view-setRenderHint(QPainter::Antialiasing); view-setFrameShape(QFrame::NoFrame); setCentralWidget(view); }这段代码把场景视口锁定为固定矩形好处是后续所有对象的坐标都不用关心窗口尺寸变化逻辑坐标和视觉坐标一一对应。注意 sceneRect 必须显式设置——如果让 Qt 自动计算场景边界会随 Item 移动而膨胀导致视口漂移这是一处新手常见的坑。setRenderHint(QPainter::Antialiasing) 用于抗锯齿Flappy Bird 这种纯像素贴图的游戏开不开视觉差异不大但如果贴图里有曲线元素建议保留。2.2 Item 体系从 bird 到 pipe 的统一抽象再看源码里 gameobjects 的组织方式。bird、pipe、ground、scoreboard 各自继承 QGraphicsPixmapItem 或 QObject QGraphicsPixmapItem 的多继承组合。QGraphicsPixmapItem 是最省事的 2D 图片 Item构造时传入 QPixmapsetPos 控制位置setRotation 控制旋转。这份源码里鸟的旋转处理值得单独说Flappy Bird 原版中鸟上下飞时身体会随速度方向倾斜这里用 setRotation 实现。需要注意 QGraphicsItem 的旋转默认绕 Item 自身的原点 (0,0) 旋转而贴图左上角是 (0,0)直接旋转会让鸟绕着左上角转而不是绕中心转——正确的做法是先 setTransformOriginPoint 到中心点。// bird 类中处理旋转的代码示意 void Bird::updateRotation() { // 将旋转原点设置为图片中心否则鸟会绕着左上角翻转 setTransformOriginPoint(boundingRect().center()); // 根据垂直速度映射旋转角度上升时抬头下降时低头 qreal angle qBoundqreal(-30.0, m_velocity * 0.4, 60.0); setRotation(angle); }qBound 把角度限制在 -30 到 60 度之间避免鸟出现头朝下的诡异姿态。m_velocity 是鸟的垂直速度正值向下——注意 Flappy Bird 的物理模型里“向上为正”还是“向下为正”一定要统一这份源码里我确认用的是 Y 轴向下为正屏幕坐标所以速度为正表示下落。后续所有计算碰撞偏移、判定得分时都遵循这个约定混用会导致碰撞检测时而灵敏时而失灵。2.3 场景对象组织方式值得直接模仿看完这份源码的类组织我觉得它的分层很干净场景只负责摆放和查询Item 自己管自己的贴图、动画、逻辑主窗口通过信号槽与 Item 通信。具体到这份源码主窗口持有 scene、bird、pipe 容器管道通常不止一根用 QList 管理、ground、scoreboard每帧更新由 QTimer 驱动槽函数里统一调用各 Item 的 update 逻辑。这种组织方式的直接好处是瓶颈好找。如果帧率低了优先怀疑 pipe 的数量和坐标计算如果碰撞失灵只需要看 bird 的 shape() 与 pipe 的 shape() 的交集判断如果要加新机关比如移动平台照 pipe 的写法加一个子类就是。它没有用 QML 的声明式写法但对 C 开发者来说反而更直观——每个类对应一个游戏对象打开头文件就能知道它干什么。3. Flappy Bird 源码中的动画驱动与物理模拟逻辑3.1 QTimer 驱动游戏循环固定步长与帧率控制剥开这份源码游戏的核心循环不是 while(1) 死循环而是 QTimer 的定时触发。主窗口里创建一个 QTimer设置 interval 为 16ms约 60fpstimeout 信号连接到 updateGame 槽函数。每个 tick 里依次执行更新 bird 的垂直速度与位置、更新所有 pipe 的水平位置、检查碰撞、检查是否得分、更新地面滚动。固定步长有个好处——逻辑确定性。每次 tick 的位移量是固定的不会因为机器卡顿导致飞行距离突变。代价是低配机器上 60fps 撑不住时游戏会整体变慢而不是像帧同步游戏那样“跳帧”。对于 Flappy Bird 这种操作频率低的游戏固定步长完全够用。// 主窗口中的定时器配置 gameTimer new QTimer(this); gameTimer-setTimerType(Qt::PreciseTimer); gameTimer-setInterval(16); // 约 60 FPS connect(gameTimer, QTimer::timeout, this, MainWindow::updateGame);setTimerType(Qt::PreciseTimer) 值得说明默认的 CoarseTimer 在系统负载高时可能合并触发导致动画抖动PreciseTimer 要求触发尽量准确代价是略多的系统资源开销。2D 游戏里建议直接 PreciseTimer避免排查“为什么动画一卡一卡”的玄学问题。物理模型部分这份源码实现的是简化重力模拟鸟有一个垂直速度 m_velocity每帧速度加上一个重力加速度常数然后按新速度移动位置。点击屏幕时把速度重置为一个向上的固定值比如 -5.0实现“扇翅膀”效果。// bird.cpp 中的物理更新 void Bird::updatePhysics() { m_velocity GRAVITY * dt; // 重力加速度累积 m_velocity qBoundqreal(-MAX_FALL_SPEED, m_velocity, MAX_FALL_SPEED); setPos(x(), y() m_velocity); // 按速度移动 }dt 是归一化的时间步长1.0 表示一帧便于调整帧率时保持物理一致性。GRAVITY 的值决定了游戏的手感太大鸟掉得快、操作窗口短太小鸟飘、没有坠落感。常见做法的初始值是 GRAVITY0.25、点击速度-5.0这份源码在这个区间附近具体手感可以按需微调。3.2 管道对的生成与移动从坐标计算到回收机制管道是 Flappy Bird 的“关卡生成器”。这份源码里管道是成对出现的一根上方管道倒挂和一根下方管道正立中间留出缺口让鸟穿过。管道间隔和缺口高度是控制难度的两个核心参数间隔决定玩家操作的节奏太密来不及反应太疏游戏失去紧张感缺口高度决定容错率。管道移动是每帧把所有 pipe 的 x 坐标向左移固定步长比如 2 像素视觉上鸟在前进。当管道对完全移出屏幕左侧时直接把它移回最右侧并重新随机生成缺口位置——这就是对象复用而不是销毁重建省去了频繁 new/delete 带来的内存碎片和性能抖动。// 管道生成与回收示意 void PipeManager::spawnPipe(qreal x) { qreal gapY QRandomGenerator::global()-bounded(MIN_GAP_Y, MAX_GAP_Y); // 上管道倒挂y 从顶部到 gapY 顶部 Pipe *topPipe new Pipe(QPixmap(:/images/pipe1.png), Pipe::Top); topPipe-setPos(x, gapY - topPipe-pixmap().height()); // 下管道正立y 从 gapY 底部到底部 Pipe *bottomPipe new Pipe(QPixmap(:/images/pipe2.png), Pipe::Bottom); bottomPipe-setPos(x, gapY GAP_HEIGHT); scene-addItem(topPipe); scene-addItem(bottomPipe); }这里的坐标计算是常见误用重灾区QPixmap 的 setPos 设的是 Item 左上角不是中心。上管道要“倒挂”图片本身就要预先倒置或者用 setTransform(QTransform().scale(1, -1)) 翻转。如果图片没处理好会出现管道错位半根的情况——调试时看两眼就能判断是图片方向问题还是坐标问题。回收逻辑更简单每帧检查 pipe 的 x 坐标是否小于-pipeWidth如果是就把它移到屏幕右侧VIEW_WIDTH pipeWidth的位置重新随机 gapY。这样整个游戏过程只需要维护固定数量的管道 Item性能不会随游戏时长下降。3.3 地面滚动与视差效果一个容易被忽略的细节地面ground在这份源码里是单独一个 Item持续向左移动移出屏幕后立即把 x 坐标恢复为 0形成无限滚动的视觉效果。这里需要注意地面移动速度必须和管道移动速度一致否则会产生“地面和管道相对滑动”的违和感。// ground 的滚动逻辑 void Ground::scroll() { setPos(x() - GROUND_SPEED, y()); if (x() -groundPixmap.width()) { setPos(0, y()); // 回绕到起点 } }回绕条件x() -width是精确的地面图片宽度恰好是屏幕宽度时移出整个图片宽度的一瞬间回绕视觉上无缝衔接。如果地面图片宽度不是屏幕宽度的整数倍回绕时会出现跳变——这是做视差背景时最容易翻车的细节。4. 状态机与音效资源管理从 ready 到 gameover 的完整闭环4.1 用枚举状态机管理游戏阶段这份源码没有用复杂的架构而是用一个枚举状态变量来区分游戏阶段READY等待开始、PLAYING游戏中、GAMEOVER结束。不同状态下同一输入事件走不同分支READY 状态下点击开始游戏并给鸟一个初始速度PLAYING 状态下点击让鸟向上飞GAMEOVER 状态下点击重置游戏。enum GameState { READY, PLAYING, GAMEOVER };这种写法直白、好调试、不易出错。比状态模式State Pattern更适合这种单一角色的小游戏。状态切换点有三个READY→PLAYING首次点击、PLAYING→GAMEOVER碰撞、GAMEOVER→READY点击重置。所有状态切换都集中处理不会出现“鸟死了还在动”“计分还在涨”的脏状态。状态机带来的直接收益是 bug 容易定位如果用户反馈“游戏结束了分数还在跳”你只需要检查 PLAYING→GAMEOVER 的切换条件里是不是忘了把计分逻辑停掉。这是新手最容易漏的地方——QTimer 还在跑但状态已经变了槽函数里没有判断状态就继续执行了游戏逻辑。4.2 信号槽处理点击输入与按键事件用户输入的响应这份源码用两种方式结合鼠标点击和键盘空格键都有。Qt 里处理这种“即时操作”的事件常见做法是重写 keyPressEvent 和 mousePressEvent或者用事件过滤器。这套源码选择直接在主窗口事件函数里判断状态并调用对应方法。// 主窗口中的事件处理 void MainWindow::mousePressEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton) { handleInput(); } } void MainWindow::keyPressEvent(QKeyEvent *event) { if (event-key() Qt::Key_Space) { handleInput(); } } void MainWindow::handleInput() { switch (m_gameState) { case READY: startGame(); break; case PLAYING: bird-flap(); // 给鸟一个向上速度 break; case GAMEOVER: resetGame(); break; } }这里有个细节mousePressEvent 要注意是否已经 setFocus否则键盘事件可能不触发。这份源码在主窗口构造函数里调用 setFocusPolicy(Qt::StrongFocus)确保点击窗口后键盘事件也能收到。如果遇到“鼠标好用但键盘没反应”的情况优先检查焦点策略。4.3 QSound 播放音效资源路径的两种写法声音资源在这份源码里用的是 Qt 的资源系统qrc 文件5 个音效分别对应扇翅膀、得分、撞击、 swooshing、死亡。加载方式用的是 QSound::play(:/sounds/sfx_point.mp3)——注意这里用的是资源路径冒号开头而不是相对路径。// 音效播放封装 void playSound(const QString name) { // 如果音效资源缺失不要崩溃静默跳过 if (QFile::exists(name)) { QSound::play(name); } }QSound 在 Windows 上用的是系统音频 API在 Linux 上需要 PulseAudio 或 ALSA 后端如果目标机器没有音频服务QSound::play 可能不做任何事也不报错——这是“游戏没声音但不知道为什么”的常见原因。加上 QFile::exists 判断是防御式写法避免资源缺失时播放空路径。这种“资源路径写在代码字符串里”的做法在小项目里没问题但资源文件改名或目录调整时字符串忘了同步就会出现“图出不来、声音放不出”的问题。后面我会给一个更稳的封装方式。5. 避坑指南这份 Qt Flappy Bird 源码最容易踩的 6 个坑5.1 中文乱码ReadMe 文件打开就花屏现象解压后用记事本打开 ReadMe_gb2312.txt中文显示为乱码或方块。 原因这份源码的说明文件用了 GB2312 编码而 Qt Creator 默认以 UTF-8 读取文件Windows 记事本在新版系统上默认 UTF-8直接打开 GB2312 文件必然乱码。 解决用 VS Code 打开时选择“通过编码重新打开”选 GB2312/GBK或者在 Qt Creator 中先改编辑器编码为 System 再打开。如果要把这份说明并入自己的项目文档建议转存为 UTF-8 并统一换行符为 LF。5.2 MSVC 编译报错找不到 QtWidgets 头文件现象用 VS2022 Qt 插件打开 .pro 文件后编译报错cannot open include file: QtWidgets/QApplication: No such file or directory或者提示依赖项路径里出现error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets。 原因.pro 文件里QT widgets没有正确传递到 VS 的 include 路径或者 Qt 版本与 VS 工具集不匹配msvc2019 的库用在 msvc2022 上。 解决右键项目 → Qt → Qt Project Settings确认 Qt 版本选择正确在 .pro 文件里显式声明QT core gui widgets如果用的是 Qt 5.15.2VS2022 需要安装“用于 Visual Studio 的 Qt 工具”扩展并选择 msvc2019_64 的套件实在不行改用 Qt 6.5 的 msvc2022_64 预编译包。注意msvc2019 和 msvc2022 的二进制不兼容不能混用。5.3 小鸟碰撞判定“不真实”明明是擦边却死了现象鸟的贴图边缘碰到管道一点点就算碰撞玩家觉得判定太严或者反过来管道明显挡住了但没死。 原因默认的 QGraphicsItem::collidesWithItem 用 boundingRect外接矩形做检测而贴图本身有透明区域矩形判定必然偏大另外碰撞发生在场景坐标系而鸟和管道的坐标系如果旋转过鸟设置了 Rotation形状计算会套用变换容易出偏差。 解决在 Bird 类里重写QPainterPath shape() const返回一个收缩过的 QPainterPath——用 QPainterPath 加一个略小于图片的圆角矩形或椭圆让判定贴合视觉。同时把管道图片的透明边距裁剪掉尽量让 boundingRect 接近实际像素。5.4 屏幕宽高比不同导致管道错位现象在 16:9 屏幕上正常切到 4:3 或竖屏模拟器上管道位置错乱、地面露出白边。 原因游戏世界坐标是硬编码的固定数值比如 480x800没做自适应View 缩放后物理像素和逻辑坐标对不上。 解决最省事的做法是把游戏世界坐标固定为设计分辨率View 开启setScaledContents效果不佳时改用view-fitInView(scene-sceneRect(), Qt::KeepAspectRatio)在窗口 resize 事件里重新调用一次。如果追求完美可以用 QGraphicsScene 的宽度作为基准来计算管道间距。注意fitInView 在窗口比例和目标比例不一致时会留黑边可接受就用不可接受就锁定窗口最小尺寸。5.5 计分重复触发一只鸟穿过两根管道加两次分现象鸟穿过管道缺口时分数一次加了 2 分甚至 3 分。 原因计分逻辑写在“鸟的 x 坐标超过管道 x 坐标”时触发但管道对象没有标记“已经计过分”如果同帧内多个管道同时满足条件或者管道回收后状态没重置就会重复计数。 解决在 Pipe 里加一个bool scored标志置为 false 表示未计分检测到穿过时如果!scored就加分并置为 true管道回收到右侧重新生成时重置为 false。这样保证每个管道实例只计一次分。5.6 导入源码包后资源文件找不到现象编译通过但运行时图片全空白、声音不响控制台输出file not found。 原因.qrc 文件里引用的资源路径是相对路径源码包解压到中文目录或路径带空格时资源收集阶段的相对路径失效也可能是 qrc 文件没有在 .pro 里注册。 解决在 .pro 文件里确认RESOURCES flappy.qrc用 Qt Creator 打开 flappy.qrc 检查里面的 alias 前缀是否与代码中的:/images/...一致项目路径尽量不要包含中文和空格。如果资源必须外置不编译进二进制改用 QFile 读取相对路径并自行处理缺失场景但这会失去 Qt 资源系统“打包即用”的好处。6. 把这份源码改造成自己的项目四项进阶优化6.1 从魔法数字到配置文件这份源码的难点参数重力常数、管道间隔、管道速度、缺口大小大概率直接写在 cpp 文件里改起来要翻好几个文件。我拿到手第一件事就是把这些值统一收敛到一个配置结构体里。// GameConfig.h struct GameConfig { qreal gravity 0.25; qreal flapVelocity -5.0; qreal maxFallSpeed 12.0; int pipeInterval 180; // 管道水平间距像素 int pipeGap 130; // 上下管道之间的缺口高度 qreal pipeMoveSpeed 2.0; }; GameConfig loadConfig(const QString path);然后用 QSettings 或者简单的 JSON 文件读取配置。好处是调难度不需要重新编译策划或你自己想试手感改数值文件重启游戏就行不用动代码。我自己的习惯是默认参数写死在结构体里做兜底配置文件缺失时用默认值避免程序直接崩溃。6.2 碰撞检测升级shape() 重写实战前面避坑提到了 shape()这里给出具体写法。默认 shape() 返回 boundingRect矩形但 Flappy Bird 的鸟图片往往是圆形或椭圆管道图片也是细长条。直接用 QPainterPath 构造贴合形状// Bird.cpp 中重写 shape() QPainterPath Bird::shape() const { QPainterPath path; // 椭圆中心在图片中心半径取宽高的 40%给玩家一定容错 QRectF rect boundingRect(); qreal rw rect.width() * 0.4; qreal rh rect.height() * 0.4; path.addEllipse(rect.center(), rw, rh); return path; }注意shape() 返回的是 Item 本地坐标系中的形状碰撞检测时 Qt 会自动应用 Item 的 transform。所以鸟旋转后椭圆的碰撞区域也会跟着转——这可能不是你想要的鸟旋转 60 度时碰撞区域也应该变细长。如果想要“无论鸟怎么转碰撞区域都不变”的效果考虑在检测时用 collidesWithPath 手动传入场景坐标的形状而不是依赖默认的 collidesWithItem。这是进阶调优点新手阶段用 shape() 重写已经比默认矩形好很多。6.3 用 QElapsedTimer 做真实帧率控制QTimer::setInterval(16) 只是告诉系统“尽量 16ms 触发一次”实际触发间隔受系统负载影响可能 10ms 也可能 30ms。在低配机器上固定步长会导致游戏变慢因为一帧移动的像素是固定的每秒帧数下降 每秒位移下降。更稳的做法是用 QElapsedTimer 测量真实经过的时间按真实时间缩放位移void MainWindow::updateGame() { qint64 elapsed elapsedTimer.nsecsElapsed(); elapsedTimer.restart(); qreal dt elapsed / 1000000.0 / 16.0; // 以 16ms 为基准时间的归一化步长 bird-updatePhysicsWithDt(dt); pipeManager-updateWithDt(dt); ground-scrollWithDt(dt); }dt 大于 1 说明这一帧耗时超过 16ms卡顿位移量变大dt 小于 1 说明跑得比 60fps 快位移量变小。这样游戏速度不会因为帧率波动而明显变化。注意这个方案要求物理更新全部改成乘 dt 的形式否则快速点击时鸟的飞行距离会飘。6.4 用 QSoundEffect 替代 QSoundQSound 在 Qt 6 里已经废弃而且 QSound 的播放是异步的连续快速点击时可能丢音。换成 QSoundEffectQt 5.15或 QMediaPlayerQt 6可以获得更稳定的播放控制和音量调节// 初始化时加载音效 QSoundEffect *wingSound new QSoundEffect(this); wingSound-setSource(QUrl(qrc:/sounds/sfx_wing.mp3)); wingSound-setVolume(0.6f); // 播放 wingSound-play();QSoundEffect 的优点是同一个对象重复 play 不会反复创建播放线程而且可以实时调音量。对 Flappy Bird 这种需要快速响应的音效场景比 QSound 更合适。Qt 6 里 QSound 被移除如果这份源码要移植到 Qt 6这步是必做的。我接手 Qt 项目有个习惯拿到一份新源码永远先编译跑通原版然后再动刀改参数。跑通之前不评论代码好坏——很多“问题”其实是自己环境没配对。这份 Flappy Bird 源码就是这样先原版跑起来感受重力、看管道速度然后按自己的手感调参数再逐步加自己的功能。从那以后我每次做 Qt 小游戏都强制走一遍这个流程原版跑通 → 参数配置化 → 碰撞 shape 精修 → 换现代音效接口。希望帮到你。本文还有配套的精品资源点击获取