
简介本资源是一个基于Qt5框架实现的轻量级2D游戏——“飞行的小鸟”专为Qt图形编程初学者设计帮助理解QGraphicsView架构下的场景管理、动画驱动与碰撞检测等核心机制。项目完整呈现了QGraphicsScene与QGraphicsView协同工作的典型范式小鸟、管道、地面等元素均以QGraphicsItem子类实现并通过定时器驱动位置更新与边界判定是学习Qt图形视图模块动画开发的优质实践案例。压缩包共24个文件含6个C源文件cpp、6个头文件h实现逻辑与界面分离8个PNG资源图用于角色与背景另含pro工程配置、qrc资源注册、ui界面定义及user配置文件总大小仅187KB结构清晰、开箱即用。已有516人学习下载读者可直接编译运行深入剖析gamecontroller、barrier、roaditem等模块的职责划分掌握从初始化、用户交互空格键控制、物理模拟到游戏状态切换的完整流程。1. QT 实现飞行的小鸟不是游戏引擎而是用 QWidget QTimer 手搓物理动画的硬核入门路径“QT 实现飞行的小鸟”这个标题乍看像极了某个被简化到只剩骨架的 Flappy Bird 克隆项目但实际落地时它根本不是调个 QML 动画组件、拖几个控件就能跑通的事。它是一次对 Qt 图形渲染底层逻辑的诚实拷问当没有 QGraphicsView 场景管理、不引入第三方物理库、甚至刻意回避 QML 的声明式动画系统时你能否仅靠 QWidget::paintEvent QTimer 基础运动学公式让一只矩形椭圆拼成的“小鸟”在窗口里真实地——加速下坠、点击跃升、碰撞边界、产生惯性缓冲我带某高校嵌入式方向的 A 同学做过这个 Demo他原以为这是“两天能交差的课设”结果卡在重力加速度单位换算和 repaint 时机错位上整整三天。这项目真正价值不在“做出小鸟”而在于逼你亲手把“帧率”“状态更新”“绘制分离”“事件响应延迟”这些抽象词焊进每一行 paintEvent 和 timerEvent 里。适合所有正在从 Qt Widgets 入门转向自定义控件开发、或需要在资源受限嵌入式平台如 i.MX6 Qt5.12上实现轻量级动态 UI 的开发者——它不炫技但每一步都踩在 Qt 渲染管线的真实节拍上。2. 从零构建可运行的最小框架QWidget 子类 定时器驱动 双缓冲绘图要让一只“小鸟”飞起来第一关不是物理而是结构可信度你得先确认这个程序的骨架不会在第 3 帧就撕裂。Qt Widgets 下的动画本质是“状态驱动绘制”即维护一个数据结构描述小鸟此刻的位置、速度、旋转角用定时器以固定间隔更新该结构再触发重绘把当前状态“拍”到屏幕上。三者缺一不可且顺序不能乱。常见翻车点是把状态更新写在 paintEvent 里——那等于让绘制逻辑承担计算职责一旦窗口被遮挡重绘小鸟就“瞬移”或“卡死”。下面是最小可运行框架已通过 Qt5.15 / Qt6.5 双环境验证注意 Qt6 中 QTimer 的 moveToThread 已废弃此处保持跨版本兼容写法2.1 定义小鸟状态结构体与主窗口类声明// bird.h #ifndef BIRD_H #define BIRD_H #include QWidget #include QTimer #include QPainter #include QKeyEvent #include QPaintEvent #include QResizeEvent struct BirdState { qreal x 100.0; // 水平位置像素 qreal y 200.0; // 垂直位置像素y 轴向下为正 qreal velocityY 0.0; // 垂直速度像素/帧 qreal rotation 0.0; // 旋转角度度用于模拟俯仰 bool isAlive true; // 生存状态控制是否继续更新 }; class BirdWidget : public QWidget { Q_OBJECT public: explicit BirdWidget(QWidget *parent nullptr); ~BirdWidget() override; protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; void resizeEvent(QResizeEvent *event) override; private slots: void updateBirdState(); // 核心状态更新槽函数 private: BirdState m_bird; QTimer *m_timer; int m_frameRate 60; // 目标帧率Hz qreal m_gravity 0.5; // 重力加速度像素/帧²非物理真实值需调 qreal m_jumpForce -8.0; // 跳跃初速度像素/帧负值表示向上 QSize m_windowSize; // 缓存窗口尺寸避免频繁调用 size() QImage m_offscreenBuffer; // 双缓冲图像防止闪烁 }; #endif // BIRD_H提示m_gravity和m_jumpForce是两个必须动手调的参数。它们不是物理常量而是帧率耦合参数——若你把m_frameRate改成 30不调整这两个值小鸟会像在糖浆里扑腾。原因见后文「避坑」章节。2.2 实现构造函数与定时器初始化// bird.cpp #include bird.h #include QPainter #include QPainterPath #include cmath BirdWidget::BirdWidget(QWidget *parent) : QWidget(parent), m_timer(new QTimer(this)) { setFocusPolicy(Qt::StrongFocus); // 确保键盘事件能被捕获 setAttribute(Qt::WA_PaintOnScreen, false); // 启用双缓冲Qt5 默认开启Qt6 需显式 setAttribute(Qt::WA_OpaquePaintEvent, true); // 告诉 Qt 此 widget 完全覆盖背景 // 初始化定时器精确控制帧率 connect(m_timer, QTimer::timeout, this, BirdWidget::updateBirdState); m_timer-start(1000 / m_frameRate); // 毫秒级启动60fps → ~16.67ms // 初始窗口尺寸缓存 m_windowSize size(); }逻辑说明setFocusPolicy(Qt::StrongFocus)是关键。很多新手写完发现按空格没反应根源就是 QWidget 默认不抢键盘焦点。setAttribute(Qt::WA_PaintOnScreen, false)显式关闭直绘模式强制 Qt 使用合成器缓冲区这是消除闪烁的底层开关。1000 / m_frameRate计算的是理论间隔实际精度受系统调度影响后续会用QElapsedTimer做帧时间补偿。2.3 实现核心状态更新逻辑含简易物理模型void BirdWidget::updateBirdState() { if (!m_bird.isAlive) return; // 1. 应用重力v v0 g * t此处 t1 帧故简化为累加 m_bird.velocityY m_gravity; // 2. 更新垂直位置y y0 v * tt1 帧 → y v m_bird.y m_bird.velocityY; // 3. 计算旋转角模拟俯仰速度越向下鸟头越朝下-30° ~ 20° // 使用 tanh 避免突变更符合真实鸟类姿态响应 m_bird.rotation -30.0 50.0 * std::tanh(m_bird.velocityY / 10.0); // 4. 边界检测顶部y 0和底部y height - birdHeight const int birdHeight 40; if (m_bird.y 0) { m_bird.y 0; m_bird.velocityY 0; // 顶部碰撞后速度归零 m_bird.rotation 0; // 重置姿态 } if (m_bird.y m_windowSize.height() - birdHeight) { m_bird.y m_windowSize.height() - birdHeight; m_bird.velocityY 0; m_bird.rotation -30; // 底部撞击鸟头猛扎 m_bird.isAlive false; // 游戏结束 } }参数说明std::tanh()是玄学调参点它把无界的velocityY映射到 [-1,1]再线性缩放到 [-30°,20°]比直接qMin/qMax更平滑。若不用cmath需加#include math.h并链接-lm嵌入式平台尤其注意。birdHeight 40是硬编码的视觉高度后续应抽离为成员变量并随窗口缩放自适应。m_bird.isAlive false不终止定时器只冻结状态更新——这是为了保留“死亡定格”画面方便后续添加“重试”逻辑。2.4 实现双缓冲绘制用 QImage 中转规避闪烁void BirdWidget::paintEvent(QPaintEvent *event) { // 1. 创建/调整离屏缓冲区大小 if (m_offscreenBuffer.size() ! m_windowSize) { m_offscreenBuffer QImage(m_windowSize, QImage::Format_ARGB32); m_offscreenBuffer.fill(Qt::white); // 填充背景色 } // 2. 在离屏缓冲区上绘制所有内容 QPainter offscreenPainter(m_offscreenBuffer); offscreenPainter.setRenderHint(QPainter::Antialiasing, true); offscreenPainter.setRenderHint(QPainter::SmoothPixmapTransform, true); // 绘制背景可替换为 QPixmap offscreenPainter.fillRect(rect(), Qt::white); // 绘制小鸟主体一个黄色椭圆身体 红色小圆嘴 黑色小圆眼睛 // 先保存原始坐标系 offscreenPainter.save(); offscreenPainter.translate(m_bird.x, m_bird.y); offscreenPainter.rotate(m_bird.rotation); // 绘制身体椭圆 offscreenPainter.setBrush(Qt::yellow); offscreenPainter.setPen(Qt::darkYellow); offscreenPainter.drawEllipse(-15, -20, 30, 40); // x,y,w,h中心在 (0,0) // 绘制眼睛左眼 offscreenPainter.setBrush(Qt::black); offscreenPainter.drawEllipse(-8, -12, 4, 4); // 绘制嘴三角形用 QPainterPath 更精准 QPainterPath beakPath; beakPath.moveTo(10, -5); beakPath.lineTo(20, 0); beakPath.lineTo(10, 5); beakPath.closeSubpath(); offscreenPainter.fillPath(beakPath, Qt::red); offscreenPainter.restore(); // 恢复坐标系 // 3. 将离屏缓冲区一次性绘制到屏幕 QPainter screenPainter(this); screenPainter.drawImage(0, 0, m_offscreenBuffer); }逻辑说明双缓冲的核心是“绘制与显示分离”。QImage作为内存画布所有耗时绘制操作如drawEllipse、fillPath都在其上完成最后用drawImage一次 blit 到屏幕。save()/restore()确保旋转、平移等变换不影响后续绘制比如你想加地面滚动条就不会被小鸟的旋转带偏。setRenderHint开启抗锯齿否则椭圆边缘会出现明显阶梯。3. 键盘交互与生命周期控制让点击真正“推”动小鸟而非“设置”位置很多初版实现把keyPressEvent写成m_bird.y - 50这会导致小鸟像被瞬移完全丢失物理感。真正的“推”是改变速度让后续帧自然体现加速度效果。本节解决三个关键问题如何捕获空格/鼠标点击、如何设计合理的跃升阻尼、如何处理多键连按导致的速度爆炸。3.1 重载 keyPressEvent支持空格与方向键 UPvoid BirdWidget::keyPressEvent(QKeyEvent *event) { if (!m_bird.isAlive) { // 死亡状态下空格/UP 键重启游戏 if (event-key() Qt::Key_Space || event-key() Qt::Key_Up) { m_bird BirdState(); // 重置为初始状态 m_bird.isAlive true; m_bird.velocityY 0; // 确保初始静止 } return; } // 游戏进行中空格或 UP 键触发跃升 if (event-key() Qt::Key_Space || event-key() Qt::Key_Up) { // 关键不是设置速度而是叠加一个向上的冲量 m_bird.velocityY m_jumpForce; // 防连按在跃升后 200ms 内忽略新按键需配合 QElapsedTimer // 此处先留空下一节实现防抖 } }注意m_bird BirdState()是 C11 的聚合初始化语法等价于逐字段赋值。它比手写m_bird.x100; m_bird.y200;...更安全避免遗漏字段。3.2 引入 QElapsedTimer 实现跃升防抖解决“狂按空格小鸟飞天”问题// 在 bird.h 的 private 区域添加 #include QElapsedTimer // ... private: QElapsedTimer m_lastJumpTimer; // 记录上次跃升时间 const int JUMP_COOLDOWN_MS 200; // 跃升冷却时间毫秒 // 在 bird.cpp 的构造函数中初始化 BirdWidget::BirdWidget(QWidget *parent) : QWidget(parent), m_timer(new QTimer(this)), m_lastJumpTimer() { // ... 其他初始化 m_lastJumpTimer.start(); // 启动计时器 } // 修改 keyPressEvent 中的跃升逻辑 if (event-key() Qt::Key_Space || event-key() Qt::Key_Up) { if (m_lastJumpTimer.elapsed() JUMP_COOLDOWN_MS) { m_bird.velocityY m_jumpForce; m_lastJumpTimer.restart(); // 重置冷却计时 } }为什么需要防抖若用户以 100Hz 频率狂按空格而updateBirdState()每 16ms 执行一次那么在 16ms 内可能收到多个keyPressEvent。若不加限制m_bird.velocityY会被反复设为-8.0导致小鸟获得远超设计的初速度瞬间撞顶。QElapsedTimer::elapsed()返回毫秒级绝对时间差比QTime::currentTime()更精准且不受系统时间跳变影响。3.3 添加鼠标点击支持触摸屏友好// 在 bird.h 中声明 protected: void mousePressEvent(QMouseEvent *event) override; // 在 bird.cpp 中实现 void BirdWidget::mousePressEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton m_bird.isAlive) { if (m_lastJumpTimer.elapsed() JUMP_COOLDOWN_MS) { m_bird.velocityY m_jumpForce; m_lastJumpTimer.restart(); } } QWidget::mousePressEvent(event); // 调用父类确保焦点正常传递 }提示mousePressEvent必须检查m_bird.isAlive否则死亡后点击会意外复活。QWidget::mousePressEvent(event)是良好实践避免覆盖默认行为如焦点获取。4. 避坑5 个让开发者连续熬夜的真实翻车现场与血泪解法这个项目看似简单实则遍布 Qt 渲染与事件循环的暗礁。以下是我在某跨平台系统调试中记录的 5 个高频问题每个都附带现象、根因和可立即验证的修复方案。4.1 现象小鸟在高分辨率屏如 4K上移动卡顿帧率暴跌至 20fps原因QImage离屏缓冲区尺寸与物理屏幕分辨率不匹配导致drawImage时触发隐式缩放CPU 占用飙升。Qt 默认将QImage视为设备无关但在高 DPI 屏幕上size()返回的是逻辑像素而QImage构造需物理像素。解决// 替换 paintEvent 中的缓冲区创建逻辑 if (m_offscreenBuffer.size() ! m_windowSize * devicePixelRatio()) { m_offscreenBuffer QImage(m_windowSize * devicePixelRatio(), QImage::Format_ARGB32); m_offscreenBuffer.setDevicePixelRatio(devicePixelRatio()); m_offscreenBuffer.fill(Qt::white); } // 绘制时QPainter 会自动适配 DPI 缩放验证在 Windows 设置“缩放与布局”为 150%运行程序观察任务管理器 CPU 占用是否回落。4.2 现象窗口最大化后小鸟突然“坠机”y 坐标疯狂增长直至负数原因resizeEvent中未更新m_windowSize导致边界检测仍用旧尺寸如 800x600而实际窗口已是 3840x2160m_bird.y m_windowSize.height() - 40永远为假。解决void BirdWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); m_windowSize event-size(); // 必须在此处更新 // 若使用了高 DPI 缓冲此处还需重建 m_offscreenBuffer }4.3 现象快速连按空格后小鸟在顶部边界“弹跳”无法稳定停住原因updateBirdState()中顶部碰撞逻辑m_bird.y 0; m_bird.velocityY 0;未考虑“上一帧速度过大导致本帧计算后 y 已为负值”直接设y0会造成位置突变。解决改为弹性碰撞模型简化版if (m_bird.y 0) { m_bird.y 0; m_bird.velocityY -m_bird.velocityY * 0.6; // 60% 速度反弹带阻尼 if (std::abs(m_bird.velocityY) 0.5) { // 低于阈值视为静止 m_bird.velocityY 0; m_bird.rotation 0; } }4.4 现象Qt6 编译报错‘QPainter::end’ called with no matching ‘QPainter::begin’原因Qt6 中QPainter构造函数不再隐式调用begin()必须显式检查isActive()或手动begin()。解决修改paintEvent中的离屏绘制部分QPainter offscreenPainter; if (!offscreenPainter.begin(m_offscreenBuffer)) { qWarning() Failed to begin painting on offscreen buffer; return; } // ... 后续绘制代码 offscreenPainter.end(); // Qt6 必须显式 end4.5 现象嵌入式平台ARM Qt5.9上小鸟完全不动updateBirdState从不触发原因QTimer在无事件循环的线程中无法工作。若主函数未调用QApplication::exec()或QTimer被 moveToThread 到错误线程timeout信号永不发射。解决确保main()中有标准事件循环int main(int argc, char *argv[]) { QApplication app(argc, argv); BirdWidget widget; widget.show(); return app.exec(); // 关键不能漏 }删除所有moveToThread()相关代码本项目无需多线程。5. 进阶技巧用 QPropertyAnimation 替代手写 QTimer实现更可控的跃升曲线虽然手写QTimer能让你彻底掌控物理逻辑但当项目复杂度上升比如要加入“翅膀扇动”、“风力扰动”、“得分粒子”状态更新逻辑会迅速膨胀。此时Qt 自带的QPropertyAnimation就成了“后悔药”——它不替代物理模型而是把跃升动作从“状态更新循环”中剥离交给动画系统托管让主循环更干净。5.1 将小鸟 Y 坐标封装为 Qt 属性支持动画绑定// 在 bird.h 的 BirdState 结构体外添加属性类 class AnimatedBird : public QObject { Q_OBJECT Q_PROPERTY(qreal y READ y WRITE setY NOTIFY yChanged) public: explicit AnimatedBird(QObject *parent nullptr) : QObject(parent) {} qreal y() const { return m_y; } void setY(qreal newY) { if (qFuzzyCompare(m_y, newY)) return; m_y newY; emit yChanged(); } signals: void yChanged(); private: qreal m_y 200.0; }; // 在 BirdWidget 类中添加成员 private: AnimatedBird *m_animatedBird; QPropertyAnimation *m_jumpAnimation;5.2 在构造函数中初始化动画并重写跃升逻辑// BirdWidget 构造函数末尾添加 m_animatedBird new AnimatedBird(this); m_jumpAnimation new QPropertyAnimation(m_animatedBird, y, this); m_jumpAnimation-setDuration(200); // 跃升动画总时长ms m_jumpAnimation-setEasingCurve(QEasingCurve::OutInQuad); // 先快后慢再快模拟真实起跳 // 修改 keyPressEvent 中的跃升逻辑 if (event-key() Qt::Key_Space || event-key() Qt::Key_Up) { if (m_lastJumpTimer.elapsed() JUMP_COOLDOWN_MS) { // 停止当前动画避免叠加 m_jumpAnimation-stop(); // 设置起始/结束位置从当前 y 到 y-120向上跃升 120px m_jumpAnimation-setStartValue(m_bird.y); m_jumpAnimation-setEndValue(m_bird.y - 120); m_jumpAnimation-start(); m_lastJumpTimer.restart(); } } // 在 updateBirdState() 中同步动画值到真实状态 void BirdWidget::updateBirdState() { if (!m_bird.isAlive) return; // 读取动画系统当前 y 值覆盖手写物理的 y 更新 m_bird.y m_animatedBird-y(); // 其余物理逻辑重力、旋转、边界照常执行... // 注意此时 velocityY 不再驱动 y 变化仅用于旋转计算 m_bird.rotation -30.0 50.0 * std::tanh( /* 用动画速度替代 */ ); }关键洞察QPropertyAnimation并非取代物理而是接管了“跃升”这一特定动作的插值。重力下坠、碰撞反馈等仍由手写逻辑控制二者可共存。QEasingCurve::OutInQuad比线性更自然——起跳初段加速中段匀速触顶前减速这是人眼判定“真实”的关键线索。5.3 用表格对比两种方案的适用场景维度手写 QTimer 方案QPropertyAnimation 方案学习价值⭐⭐⭐⭐⭐ 深刻理解帧循环、状态同步、性能瓶颈⭐⭐⭐ 理解 Qt 属性系统与动画管线集成代码可维护性⚠️ 状态分散在多个函数修改跃升逻辑需改多处✅ 跃升行为集中于动画配置物理逻辑更纯粹嵌入式友好度✅ 无额外依赖内存占用极低1KB⚠️QPropertyAnimation在 Qt5.9 ARM 上有约 50KB 静态开销扩展性❌ 添加新动画如翅膀需重写大量 timer 逻辑✅ 可轻松绑定多个属性rotation,scale到不同动画调试难度⚠️ 需手动打日志跟踪每帧y、velocityY✅ Qt Creator 内置动画调试器可实时查看曲线我一般会在原型阶段用纯 QTimer 快速验证物理手感一旦确定核心玩法立刻迁移到QPropertyAnimation 自定义QAbstractAnimation子类来管理复合动作。这不是偷懒而是把“让小鸟飞得像真鸟”这件事从程序员的脑内模拟变成可配置、可复用、可调试的工程模块。希望帮到你。本文还有配套的精品资源点击获取