ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Qt做FlappyBird小游戏:C++课程设计从0到答辩加分

用Qt做FlappyBird小游戏:C++课程设计从0到答辩加分 简介这是一份基于Qt和C实现的经典FlappyBird小游戏完整工程源码面向C与Qt入门者、高校学生尤其适合课程设计或图形界面编程实战练习。源码实现了点击屏幕控制小鸟向上飞行、避开障碍物的核心玩法覆盖游戏循环、碰撞检测、分数统计、资源加载等常见开发环节。压缩包共80个文件包含47个png游戏素材、12个cpp逻辑实现、11个h头文件、5个ui界面布局文件以及pro工程配置与qrc资源管理文件整体体积仅263KB轻量紧凑便于直接编译运行和二次修改。工程按Sources、Widgets、Resources等目录划分方便按模块查阅。目前已有103人学习下载作为轻量级Qt游戏范例这份源码能帮助读者理解窗口组件、绘图事件、定时器联动等关键机制也可为课程设计答辩提供完整参考。1. 为什么我用 FlappyBird 做 Qt 课程设计而不是做计算器或贪吃蛇如果你打开过学校里给的 C 课程设计题目清单大概率会看到计算器、图书管理系统、贪吃蛇这三件套。它们不是不好而是已经被做了太多年答辩老师看一眼界面就知道你用了多少功夫。我选 FlappyBird 的原因很简单它看起来是个小游戏但 C 的面向对象、Qt 的事件循环、绘图坐标系、定时器调度、碰撞检测全部都要用到代码量控制在 600 到 1000 行之间一个人一周能写完又能在答辩时演示“游戏能跑、手感可调、代码能讲”。这个项目非常适合第一次用 Qt 做完整小程序的 C 学习者你不需要懂 QML不需要碰模型视图框架只要把 QWidget QPainter QTimer 这三板斧吃透FlappyBird 就能做得像模像样。这篇就按我的实现顺序从类设计、绘图、碰撞、踩坑到答辩加分一条线讲完。2. 先把课程设计的“分数”拿到手类拆分与 Qt 定时器选型2.1 为什么用 QTimer 驱动游戏循环而不是 while 死循环第一次写游戏的人最容易犯的错是在 QWidget 里写while (1) { update(); }。在 Qt 里这么写窗口直接卡死因为事件循环被死循环堵住鼠标键盘消息全部排队QPainter 也没法正常重绘。FlappyBird 这种 60 帧/秒的小游戏正确驱动方式是QTimer定时触发在超时回调里更新逻辑坐标再调用update()请求重绘。我做这套代码时用的是QTimer 单次连接的写法而不是把定时器对象长期挂在界面上// GameWidget.h 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 slots: void onTimeout(); private: void startGame(); void stopGame(); void resetGame(); QTimer m_timer; int m_frameInterval 16; // 约 60 FPS单位 ms bool m_running false; double m_birdY 300.0; // 小鸟左上角 Y 坐标 double m_birdVy 0.0; // 小鸟垂直速度 double m_gravity 0.35; // 每帧重力加速度像素/帧^2 };m_frameInterval是定时器触发的毫秒间隔。16 毫秒约等于 60 FPS我一般取 16 而不是 1000 除以 60 的 16.67因为QTimer的毫秒参数只接受整数16 和 17 差别不大但 16 在 Windows 上配合Qt::PreciseTimer更容易稳定触发。m_gravity是每帧的速度增量单位是“像素/帧²”配合 60 FPS 时手感合适如果你把定时器改成 10ms 跑 100 帧这套数值要重新调否则小鸟会飘。在构造函数里启动定时器但不启动游戏逻辑GameWidget::GameWidget(QWidget *parent) : QWidget(parent) { setFocusPolicy(Qt::StrongFocus); setFixedSize(420, 640); m_timer.setInterval(m_frameInterval); m_timer.setTimerType(Qt::PreciseTimer); connect(m_timer, QTimer::timeout, this, GameWidget::onTimeout); m_timer.start(); }setFocusPolicy(Qt::StrongFocus)保证键盘事件能被窗口收到。很多人在 QWidget 里按空格没反应就是因为默认的Qt::NoFocus根本不接收键盘输入。setFixedSize锁死窗口尺寸避免用户拉伸窗口导致背景图片拉伸变形也省去重写resizeEvent的麻烦。2.2 把游戏拆成 State 而不是拆成“一堆函数”课程设计答辩最怕被问“如果加个暂停功能你怎么改”。如果你把游戏逻辑全写在onTimeout()里加暂停就得加一堆 if 判断。我习惯用一个枚举状态机来拆分代码读起来像游戏引擎的思路答辩时也讲得清楚enum class GameState { Ready, // 等待开始显示标题和操作提示 Playing, // 游戏中 Paused, // 暂停 GameOver // 撞柱子或落地 };每次状态切换对应处理进入和退出逻辑。比如进入Ready状态时把小鸟放到固定位置、清空柱子列表进入Paused时记录当前速度退出时恢复。QTimer始终在跑但onTimeout()里先看状态再决定是否执行物理更新和碰撞检测。这个设计把“游戏循环”“用户输入”“绘制”三层解耦是 Qt 小项目里性价比最高的架构没有之一。类拆分上我建议至少四个文件别把所有代码塞进一个 main.cpp文件职责包含内容main.cpp程序入口创建 QApplication实例化 GameWidgetGameWidget.h / .cpp窗口与游戏循环状态切换、QTimer 回调、键盘鼠标事件Bird.h / .cpp小鸟逻辑位置、速度、重力更新、跳跃动作Pipe.h / .cpp柱子逻辑上下柱坐标、间距、随机高度生成如果你愿意再拆一层把Bird和Pipe变成纯 C 类不依赖 QWidget只暴露update()、getRect()这类接口这样以后想移植到 QML 或控制台版本都能复用。但课程设计不建议拆太细四个文件已经足够展示面向对象能力拆成七八个文件反而显得刻意。2.3 帧率、重力加速度与跳跃初速度的手感标定FlappyBird 之所以让人又爱又恨是因为物理参数直接决定“容错率”。这里给出我调试后觉得手感适中的一组参数重力加速度 0.35 像素/帧²跳跃初速度 -6.5 像素/帧向上为负注意 Qt 的 Y 轴向下为正最大下落速度 8 像素/帧。这组参数配合 60 FPS小鸟从点击到最高点大约上升 110 像素下落过程不会太突兀。相关热词“C 小游戏”的项目里很多人把这组数值写死成常量其实应该把它们提到类成员变量方便答辩现场调参演示。const double kGravity 0.35; const double kJumpVelocity -6.5; const double kMaxFallSpeed 8.0; const double kPipeSpeed 3.0; // 柱子向左移动速度像素/帧 const int kPipeSpacing 160; // 上下柱之间的空隙像素 const int kPipeWidth 70; // 柱子宽度像素调参的痛点在于如果用 60 FPS 固定帧率kPipeSpeed为 3 时柱子从右到左穿过 420 像素宽的窗口需要约 140 帧也就是 2.3 秒玩起来节奏偏快。改成 2.5 会比较休闲。课程设计里建议把“高手模式”和“休闲模式”做成两个预设参数组这也算一个答辩亮点。3. 用 QPainter 画一个会动的场景坐标换算、背景滚动与小鸟帧动画3.1 先定画布坐标系再写绘制代码别边画边想Qt 的绘图坐标系是左上角为原点X 向右Y 向下。很多从数学坐标系转过来的人第一张图是上下颠倒的。FlappyBird 里所有物体坐标我都用“左上角 宽高”的QRectF来描述而不是用中心点。这样做碰撞检测直接调QRectF::intersects()省去自己写矩形相交判断的麻烦。QRectF birdRect QRectF(birdX, m_birdY, kBirdWidth, kBirdHeight);背景滚动的实现原理非常简单用一个m_backgroundOffset变量记录背景纹理的偏移量每帧渲染时先画一个超出窗口右侧的背景块再画一个与之相邻的块两块之间用拼接图样无缝衔接。但如果你不想找素材可以用纯色渐变 几条白色虚线模拟云朵效果也够看。我课程设计时用的就是一张 420x640 的 PNG 背景图程序启动时加载一次绘制时按偏移量分两次画void GameWidget::drawBackground(QPainter painter) { m_backgroundOffset - kPipeSpeed; if (m_backgroundOffset -m_background.width()) { m_backgroundOffset m_background.width(); } painter.drawImage(QPoint(m_backgroundOffset, 0), m_background); painter.drawImage(QPoint(m_backgroundOffset m_background.width(), 0), m_background); }这里有个细节当偏移量小于负的背景宽度时取模回退。因为背景图宽度和窗口宽度相等所以两张图刚好覆盖整个窗口滚动看起来是无缝的。如果你用的是比窗口宽的背景图取模的基准要换成背景图宽度。QPainter 绘制时建议开启抗锯齿否则小鸟边缘会像锯齿一样难看painter.setRenderHint(QPainter::Antialiasing, true);3.2 生成柱子随机高度背后的“可玩性数学”柱子的随机不能纯随机。如果上下柱之间空隙只有 160 像素而柱子最大高度 500 像素、最小高度 80 像素纯随机会出现连续两三根高柱或低柱玩家根本没法玩。FlappyBird 的精髓在于“随机但留有余地”。我的做法是维护一个上一根柱子的中心位置新柱子的中心在旧中心附近 ±60 像素范围内变化int newCenterY qBound(kPipeMinCenterY, m_lastPipeCenterY QRandomGenerator::global()-bias(-60, 61), kPipeMaxCenterY);qBound是 Qt 的裁剪函数保证随机结果落在上下界之间。这样生成的柱子序列既不会出现极端跳跃又能让每局地形不同。如果你想要更简单的实现也可以把中心位置固定在上一次的 ±40 像素内配合空隙 160 像素基本不会生成死局。每一次新柱子生成时把它推入一个QVectorPipe并且记录它是否已经计过分。柱子到达窗口左边界外时从容器里移除避免容器无限增长。这个容器管理是 FlappyBird 的核心逻辑之一建议在课程设计文档里单独画一张数据流图。struct Pipe { int x; // 柱子左上角 X int centerY; // 上下柱空隙的中心 Y int width; int gapHeight; bool scored; };判定“柱子是否生成”不需要按时间而是按距离。当最后一根柱子的 X 坐标小于窗口宽度减kPipeSpacing柱子间距时就生成下一根。这样不管柱子速度怎么调间距始终是 200 像素左右逻辑上更稳。3.3 小鸟的“扑腾”动画和旋转用帧计数解决FlappyBird 的小鸟如果不做动画就是一坨静止的黄色圆球观感差很多。我的做法是准备三帧翅膀姿态图片上、中、下用一个m_wingFrame变量在 0、1、2 之间循环每 5 帧切换一次。加上小鸟上升时抬头、下降时低头手感反馈非常直观。void GameWidget::updateBirdPose() { if (m_birdVy -1.0) { m_birdAngle -20; // 快速上升时抬头 } else if (m_birdVy 1.0) { m_birdAngle 40; // 下落时低头 } else { m_birdAngle 0; } }绘制时用painter.translate()到小鸟中心旋转坐标轴再画图片最后restore()painter.save(); painter.translate(birdCenterX, birdCenterY); painter.rotate(m_birdAngle); painter.drawImage(QRectF(-kBirdWidth/2, -kBirdHeight/2, kBirdWidth, kBirdHeight), birdImage); painter.restore();旋转的坑点在于translate必须在rotate之前否则是绕窗口原点旋转小鸟会飞出屏幕。另外QPainter::save/restore要成对出现漏掉restore会让后续绘制全部带着旋转状态画出来的柱子都是歪的。4. 撞柱子才叫 FlappyBird矩形相交判定、落地检测与计分逻辑4.1 用 QRectF::intersects 替代手写碰撞公式碰撞检测如果用“圆心到矩形距离”那套代码长而且容易出边界 bug。Qt 的QRectF已经提供了相交检测直接用它就好。我维护的是一根柱子由上下两个矩形组成上柱是“从窗口顶部到 gap 上边缘”下柱是“从 gap 下边缘到窗口底部”。QRectF upperRect QRectF(pipe.x, 0, pipe.width, pipe.centerY - pipe.gapHeight / 2.0); QRectF lowerRect QRectF(pipe.x, pipe.centerY pipe.gapHeight / 2.0, pipe.width, m_windowHeight - (pipe.centerY pipe.gapHeight / 2.0));碰撞判断只需要三行QRectF birdBox QRectF(m_birdX, m_birdY, kBirdWidth, kBirdHeight); if (birdBox.intersects(upperRect) || birdBox.intersects(lowerRect)) { setGameState(GameState::GameOver); }这里有个性能小优化QRectF::intersects在 Qt 5.15 里是内联的性能足够。课程设计里不需要自己写 AABB 碰撞但你要能讲清楚“为什么矩形碰撞检测是最高效的”——因为两个矩形不相交只需比较四条边时间复杂度 O(1)。答辩老师问到这里你能答上来就是加分项。4.2 落地判定比柱子碰撞更隐蔽的坑柱子碰撞大家都会写但落地判定经常被忽略。FlappyBird 如果只撞柱子不落地小鸟会飞出屏幕底部然后消失画面很难看。落地检测本质是判断小鸟底部是否超过地面高度const int kGroundHeight 80; // 底部地面占窗口高度 if (m_birdY kBirdHeight m_windowHeight - kGroundHeight) { m_birdY m_windowHeight - kGroundHeight - kBirdHeight; setGameState(GameState::GameOver); }注意落地瞬间要先停止小鸟下落把 Y 坐标钳制到地面高度之上否则画面会闪一帧穿模。这个“先钳制再结算”的顺序是我踩过坑的地方先结算状态再钳制会导致 GameOver 画面里小鸟陷进地面一半。4.3 计分逻辑什么时候算“过柱子”计分有三种常见实现碰到柱子中心线就加 1、飞出柱子右边界加 1、柱子完全通过小鸟左边界加 1。第三种手感最准玩家看到的是“柱子过去了才加分”不会出现“还没撞到就加分”的错觉。我用的是柱子右边缘小于小鸟左边缘时加分for (auto pipe : m_pipes) { if (!pipe.scored pipe.x pipe.width m_birdX) { pipe.scored true; m_score; } }加分时做一个简单的音效反馈QApplication::beep() 在 Windows 上能用但声音很单调。如果你引入了 QMediaPlayer就可以播鸟叫音效。但课程设计不建议在声音上花太多时间答辩现场没音箱会很尴尬。计分变量用 int刷新时在左上角用 QPainter::drawText 绘制painter.setPen(Qt::white); QFont font painter.font(); font.setPixelSize(32); font.setBold(true); painter.setFont(font); painter.drawText(QRect(0, 20, m_windowWidth, 40), Qt::AlignCenter, QString::number(m_score));4.4 用 QElapsedTimer 做“真实帧间隔”解决不同电脑手感差异这是一个进阶优化但不难。QTimer的 16ms 间隔在 Windows 上实际触发可能是 15ms 到 20ms 波动。如果游戏逻辑按“每帧移动 3 像素”固定算帧率波动时游戏速度会忽快忽慢。解决办法是用QElapsedTimer记录真实时间差把速度因子换算成“像素/毫秒”。qint64 elapsedMs m_frameTimer.elapsed(); m_frameTimer.restart(); double factor elapsedMs / 16.0; m_pipeOffset - kPipeSpeed * factor;这套换算在课程设计里属于亮点但实现复杂度会增加不少。如果你时间不够直接在答辩 PPT 里提一句“我用 60 FPS 固定帧率所以逻辑和渲染在同一线程”老师一般不会深究。5. 代码写完了先别交Qt 5.15.2 MSVC2019 环境下最常翻车的 5 个坑5.1 编译报错:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets...项目根本起不来现象新建 Qt Widgets Application 后一编译就报一个超长路径的 dependent 错误指向 Qt 安装目录的 include 文件和你的代码毫无关系。原因这是 Qt 5.15.2 在 Windows 上搭配 MSVC2019 时qmake生成的 Makefile 里写死了相对路径。常见触发条件是项目的构建目录build 目录和 Qt 安装目录不在同一个盘符或者路径里有中文。..\..\..\..\..\..这个相对路径一直回溯到根目录再进 Qt include一旦盘符不对直接失效。解决把项目路径放到纯英文且浅层目录例如D:\qt_work\FlappyBird构建目录用 Qt Creator 默认的 shadow build在项目文件夹下的 build- 目录。别把项目放在C:\Users\张三\桌面\Qt项目这种路径里中文用户名和空格都会让 qmake 的路径解析出幺蛾子。如果还报错就手动删除整个 build 目录重新用 qmake 构建别点“清理项目”直接删文件夹最干净。5.2 鼠标点击没反应键盘按空格也没反应现象窗口能显示柱子能滚但点鼠标小鸟不跳按空格也没反应。原因事件根本没传到 GameWidget。如果你创建 GameWidget 时用了布局或者把 GameWidget 设置为某个 QMainWindow 的 centralWidget按键焦点可能被其他控件抢走。另外构造函数里没调用setFocusPolicy的话QWidget 默认不接收键盘事件。解决构造函数里加setFocusPolicy(Qt::StrongFocus)并在鼠标点击事件里先setFocus()再处理跳跃。如果你是在 QMainWindow 里嵌入 GameWidget要确保 GameWidget 能拿到焦点最简单的办法是在窗口 show 之后主动gameWidget-setFocus()。这个坑我在课设互评时见过至少三个人踩。5.3 QTimer 槽函数里 update() 频繁调用导致 CPU 占用 30% 以上现象游戏在 Debug 模式下 CPU 占用非常高笔记本风扇狂转。原因QTimer 每 16ms 触发一次重绘而 QWidget 的 paintEvent 里如果做了loadImage或者drawImage大图每次重绘都要重新缩放图片开销巨大。我把背景 PNG 在构造函数里加载成 QImage 缓存paintEvent 里只 drawImage不加载CPU 占用立刻降下来。解决所有图片资源在构造函数或第一次 paintEvent 前加载完paintEvent 只负责绘制不能做 IO。还有一个优化是只对场景变化区域重绘但update()全窗口重绘在 420x640 的小窗口上完全够用不用做区域裁剪。5.4 Debug 模式正常Release 模式一运行就闪退现象Debug 下游戏一切正常切到 Release 编译运行几秒后直接崩溃没有任何提示。原因最常见的是未初始化的成员变量。Debug 模式下编译器会把未初始化内存填成 0运气好不崩Release 模式下内存里是随机值比如m_birdY初始值如果是垃圾值小鸟一开始就在碰撞状态碰撞后访问空指针或者数组越界直接崩。另一个常被忽略的问题QRandomGenerator::global()在多线程环境或 Release 优化下返回值异常。解决所有成员变量必须在构造函数初始化列表里赋初值不要指望默认值。我第一次写 FlappyBird 时m_pipes容器忘了 clearDebug 没事Release 崩得莫名其妙。建议在构造函数里把所有数值型成员列成一张清单逐一初始化。5.5 【Qt 5.15.2 专属】高 DPI 缩放下画面模糊文字发虚现象在 Windows 屏幕缩放 150% 的机器上运行整个窗口模糊文字像蒙了一层雾。原因Qt 5.15 默认启用高 DPI 缩放但你的程序没告诉系统自己支持缩放Windows 就用位图拉伸兼容模式。解决是在 main.cpp 最前面设置QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);并在创建 QApplication 之前设置环境变量Qt::AA_EnableHighDpiScalingint main(int argc, char *argv[]) { QApplication::setAttribute(Qt::AA_EnableHighDpiScaling, true); QApplication app(argc, argv); ... }如果你的电脑是 1080P 100% 缩放这个坑遇不到但答辩现场的投影电脑很可能开了 125% 缩放提前处理能省得当场翻车。相关热搜词里“qt崩溃”“qt界面设计”都有很多这个问题的讨论属于 Qt 老用户都懂的玄学坑。6. 从能运行到能答辩状态机扩展、换肤与打包发布技巧到这里你的 FlappyBird 已经能玩、能计分、能死、能重开但这只是“能运行”。课程设计得高分的关键是“能讲”。我建议按下面三条线打磨每一条都对应一个答辩老师喜欢问的点。第一是状态机可见化。在Ready状态画“点击屏幕开始”在Paused状态画“暂停中”在GameOver状态画“本次得分 XX 分按 R 重开”。这个需求在代码里只需要一个switch分支但展示效果好到出乎意料。答辩时老师可能问“你用了什么设计模式”你回答“状态模式枚举驱动状态切换”就能解释清楚。第二是一套换肤机制。把背景图片、小鸟图片、柱子颜色从代码里抽成资源路径用一个简单的setTheme(QString themeName)函数切换。我课设时做了“经典白天”和“暗夜荧光”两套皮肤只改三张图片和两个颜色常量。这个改动看着小但能展示你对“数据和逻辑分离”的理解比多写两百行业务代码更值钱。第三是发布打包。Qt 5.15.2 在 Release 模式下构建完生成的 exe 双击会提示缺少Qt5Widgets.dll和Qt5Core.dll。教学机器上装了 Qt 没事但答辩教室不一定装。用windeployqt FlappyBird.exe命令自动把依赖 DLL 拷到 exe 同目录是最省事的方案D:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe D:\build-FlappyBird\release\FlappyBird.exe运行完之后把整个 release 文件夹压缩成一个 ZIP 交作业里面一定要带说明.txt写清楚运行环境Windows 10/11 64 位需要 VC 运行库和你用的 Qt 版本。我当年就在这栽过打包的 exe 在老师电脑上运行失败原因是缺vcruntime140.dll后来装了Microsoft Visual C Redistributable才解决。答辩前一定要找一台没装 Qt 的干净电脑测试 exe 能不能跑这一步比优化代码值钱得多。最后说一个我的血泪经验答辩演示前把窗口尺寸固定好别让老师看到拉伸窗口后柱子间距变形的样子。setFixedSize虽然在第三章写了但很多人会为了“能弹窗口”而改成resize结果布局一乱前面讲的架构再好也白搭。代码提交前再通读一遍把“调试用”的qDebug()输出全部注释掉尤其是每帧刷新坐标的调试信息否则答辩时控制台疯狂刷屏印象分会大打折扣。希望这些踩坑经验能帮你的 FlappyBird 课程设计少走弯路把时间省下来琢磨游戏手感和你真正感兴趣的部分——Qt 的世界比这个小鸟和柱子大得多。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进