
简介基于Qt与C实现的Flappy Bird小游戏附带完整可编译源码适合计算机相关专业毕业设计、课程设计以及C/Qt初学者的项目实践。压缩包共27个文件约3.38MB其中包含8张PNG图片素材、5个C源文件与4个头文件、2首MP3及1个WAV音效另有工程文件.pro、资源文件.qrc与说明文档涵盖游戏开发所需的代码、素材与配置。目前已有188人下载学习可作入门级游戏开发参考项目。源码经严格测试可直接运行并在此基础上扩展例如修改游戏难度、更换角色皮肤或增加得分机制项目结构清晰包含核心游戏逻辑、界面交互与资源管理模块便于快速理解Qt事件循环、碰撞检测及音效播放等知识点。1. 用QTC写一个flappyBird毕业设计为什么都选它很多人觉得flappyBird这类游戏“太简单”但真正用QTC从零写一遍你会发现它把QT客户端开发的核心机制全带上了事件循环、定时器驱动、QPainter绘图、信号槽、资源打包、跨平台编译。作为课程设计或毕业设计它既不会像管理系统那样堆表格又能让答辩老师一眼看出你掌握了QT的骨架而不是背了几百行控件代码。这篇笔记给你一条能跑通的最小实现路径讲清楚参数怎么调、代码怎么拆、报错怎么解照着做能把源码变成自己的东西。2. 把QT开发环境调到“能跑游戏”的底线安装、编译器与pro文件2.1 用QT 5.15.2还是QT 6课程设计选型的现实理由做这个项目之前先解决编译器与QT版本的取舍。常见做法是用QT 5.15.2配合MinGW 64位编译器原因是5.15是LTS版本在线安装包里直接自带Qt Creator和MinGW套件不需要额外折腾MSVC的调试库。你搜索“qt 5.15.2下载安装”能找到官方开源版安装包安装时勾选组件请留意三项Qt 5.15.2下的“MinGW 8.1.0 64-bit”、Tools下的“Qt Creator”和“MinGW 8.1.0”。少勾任何一个后面都可能出现编译器识别不到的尴尬情况。我一般不建议在这个项目上直接用QT 6原因不是QT 6不好而是课程设计常用的参考代码、老插件和网上教程大多按5.x写法组织QT 6把QPainter的部分接口和多媒体模块拆得更细你抄一段代码报一堆弃用警告排查起来消耗答辩前的时间。QT 5.15.2对操作系统和开发机的兼容性更好Windows 10、11上装完直接跑不折腾环境变量。选MinGW而不是MSVC的逻辑也简单MinGW的编译链跟QT发布版捆绑发布不需要单独安装Visual Studio那套庞大的工具链而且生成的exe用windeployqt一拷就能交作业。MinGW跑少量绘图和游戏逻辑性能足够帧率稳定在60FPS没有问题。如果你是Windows上开发、Linux上演示QT的跨平台能力在这里也能体现这套代码贴到Linux里重新编译一次就能跑前提是别在代码里写死Windows绝对路径。2.2 新建项目与.pro文件三行配置决定编译成败创建项目时选“Qt Widgets Application”最省事然后手动把自动生成的.ui文件删掉——这个游戏纯代码绘制不依赖qt designer界面设计删掉.ui反而让逻辑更清爽。改动后.pro文件会长这样QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets CONFIG c11 TARGET FlappyBird TEMPLATE app SOURCES \ main.cpp \ Bird.cpp \ Pipe.cpp \ GameWidget.cpp HEADERS \ Bird.h \ Pipe.h \ GameWidget.h RESOURCES res.qrc这段配置里第一行QT core gui是所有Qt Widgets程序的基础第二行的greaterThan判断是为了兼容老版本QT4才加的实际编译QT 5.15时等价于直接加widgets模块保留它只是为了复制项目模板时少踩一个“Qt5不存在QMainWindow”的报错。CONFIG c11把编译标准锁在C11足够覆盖这个项目需要的智能指针、范围for循环和auto不要随手写成c17甚至c20老编译器对后两者的支持差异会给你添乱。RESOURCES res.qrc这一行容易被忽略——如果后续要加载图片音效qrc文件就是QT的资源清单。qrc里写的是相对路径图片放在项目根目录的res文件夹下qrc里写成 res/bird.png 。资源路径以“:/”开头例如QImage(:/res/bird.png)。没有这张图也能先跑后面第4章会给降级方案。2.3 用Qt Creator还是VS Code编译链的坑如果你习惯用vscode配置c/c环境这里要泼一盆冷水VS Code写QT代码本质上只是把它当成编辑器编译还是得靠CMake或qmake调用QT的编译链。网上很多教程让你改tasks.json和launch.json这个项目里我建议直接用Qt Creator它帮你把“编译器路径、QT库路径、头文件路径”这三样提前配好了不需要你手动填。真要用VS Code最常见的翻车点是launch.json里把“miDebuggerPath”指到系统自带的cdb.exe结果QT程序编译成功但一按F5就弹“无法启动调试”或者链接阶段报fatal: cannot mix incompatible qt library (version ex50601) with this library这种玄学错误。这条报错的实质是编译头和链接库来自两套QT——比如你用VS Code插件自动include了QT 5.12的头文件但.pro或CMakeLists里链接的是QT 5.15的lib。解决方向只有一个把头文件路径、库路径、编译器三者统一到同一个QT Kit下。所以我劝你Qt Creator自带的Kit就是后悔药别用折腾过的VS Code做QT项目。3. 游戏循环与碰撞判定从事件驱动到状态机的核心代码3.1 状态机设计ready、running、over三态为什么够用这类小游戏最容易翻车的点不是画面不漂亮而是状态混乱游戏没开始小鸟就往下掉、游戏结束后按空格又立刻飞起来。最可靠的做法是把整个游戏做成一个三态状态机三个状态分别是Ready等待开始、Running游戏中、Over已结束。每个状态只允许特定操作Ready时按空格进入RunningRunning时按空格让鸟跳一下Over时按空格重置回Ready再立刻进入Running。这个设计不只是为了代码好看它解决了一个实际痛点键盘事件本身是连续触发的如果你不做状态拦截按住空格就会让鸟不停地跳游戏逻辑就会被输入事件牵着走。把处理函数写好后逻辑入口非常清晰——所有输入都先进状态判断再进具体动作。状态机里再维护一个分数分数变化时发出的信号给窗口标题栏或得分标签用。class GameWidget : public QWidget { Q_OBJECT public: explicit GameWidget(QWidget *parent nullptr); enum GameState { Ready, Running, Over }; protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; void mousePressEvent(QMouseEvent *event) override; private slots: void onTimeout(); private: void startGame(); void resetGame(); void checkCollision(); void updateScore(); GameState m_state; QTimer m_timer; Bird m_bird; QVectorPipe m_pipes; int m_score; static const int PIPE_SPACING 300; static const int PIPE_SPEED 3; static const int TIMER_INTERVAL_MS 16; };Bird和Pipe是两个独立的小类各管自己的坐标和绘制矩形。GameWidget则负责把这两样东西组合进游戏循环。这里的Q_OBJECT宏必须保留后面要用信号槽如果编译时出现“undefined reference to vtable”九成是这个宏忘写了或者写了却没重新执行qmake。3.2 用QTimer驱动游戏循环间隔×帧数难度的数学关系游戏里所有动态效果——鸟下落、管道左移——都靠一个QTimer驱动。常见做法是设置定时器间隔为16毫秒理论帧率约62FPS视觉上足够流畅。注意这只是“理论”因为QTimer依赖Qt事件循环的调度Windows下实际触发间隔会有几毫秒抖动这对本游戏完全够用不必追求垂直同步那种精确定时。void GameWidget::startGame() { m_state Running; m_score 0; m_bird.reset(); m_pipes.clear(); // 开局先生成两根管道给玩家一个反应时间 m_pipes.append(createPipe(width() 50)); m_pipes.append(createPipe(width() PIPE_SPACING 50)); m_timer.start(TIMER_INTERVAL_MS); }定时器触发时需要把鸟的位置往下降同时让所有管道向左移动并生成新管道补充到队列尾部。核心移动逻辑放在onTimeout这个槽函数里每帧只做一次位移计算。很多人把位移写进paintEvent这是个坏习惯——绘制函数在窗口重绘时也会被调用可能一帧被重复移动好几次出现“鸟瞬移”的灵异现象。paintEvent只负责画update()只负责请求重画真正的游戏状态变化放在定时器槽函数里顺序是时间走到下一帧→改状态→调用update()→paintEvent画出来。void GameWidget::onTimeout() { m_bird.update(GRAVITY); for (int i m_pipes.size() - 1; i 0; --i) { m_pipes[i].moveLeft(PIPE_SPEED); // 管道完全移出窗口左侧就删掉防止容器无限变大 if (m_pipes[i].x() PIPE_WIDTH 0) { m_pipes.removeAt(i); } } // 当最后一根管道进入窗口一半时在窗口右侧生成新管道 if (m_pipes.last().x() width() - PIPE_SPACING) { m_pipes.append(createPipe(width() 50)); } checkCollision(); update(); }这段代码里最需要调的两个参数是GRAVITY和PIPE_SPEED。GRAVITY单位是“像素/帧²”我的初始值是0.5PIPE_SPEED单位是“像素/帧”初始值是3。两个参数的关系直接决定难度速度从3改成5意味着同一时间内管道位移多了近一倍但鸟的上升距离只受跳跃力度影响所以体感难度会骤增。改参数时一次只改一个变量改完编译跑一局感觉两分钟再调不要同时动多个。3.3 碰撞检测矩形相交以外的边界细节撞到管道和地面判失败穿过了管道间隙就算得分。最朴素的做法是拿QRectF自带的intersects方法判断矩形是否相交但直接用有个坑小鸟的贴图通常是带透明边角的圆角图视觉上没撞到矩形已经叠上了造成“明明还差一点却死了”的糟糕体验。我一般会把鸟的包围盒往里收缩2像素再参与碰撞检测。void GameWidget::checkCollision() { QRectF birdRect m_bird.rect().adjusted(2, 2, -2, -2); for (const Pipe pipe : m_pipes) { if (birdRect.intersects(pipe.topRect()) || birdRect.intersects(pipe.bottomRect())) { gameOver(); return; } } // 地面判定单独做低于地面线或飞出顶部都判结束 if (m_bird.rect().bottom() GROUND_HEIGHT || m_bird.rect().top() 0) { gameOver(); return; } // 计分逻辑鸟越过管道中线且未死亡加一分 for (const Pipe pipe : m_pipes) { if (!pipe.scored() pipe.x() PIPE_WIDTH m_bird.rect().center().x()) { pipe.setScored(true); m_score; emit scoreChanged(m_score); } } }计分这里有个隐藏问题一根管道只应该被记一次分。如果每次检测都判断“鸟在管道右边就加分”那一根管道可能触发三四次加分。解决办法是给Pipe加一个bool成员scored管道生成时初始化为false第一次满足条件后置为true后续帧就不再给它加分了。这个细节虽然简单却是很多同学实际开发时最容易写错的地方——分数突涨答辩时被一眼看穿。4. QPainter绘制、信号槽与音效把QT特性用进游戏而不是摆样子4.1 paintEvent与QPainter绘制为什么游戏画面必须画在paintEvent里游戏画面绘制只有一个正确位置重写QWidget的paintEvent函数在里面创建一个QPainter对象然后按状态绘制。QPainter相当于一块画布所有绘图指令在paintEvent结束后统一渲染到窗口上。不要在按钮点击事件里直接new一个QPainter往控件上画那样画出来要么被下一次重绘立刻擦掉要么直接抛异常“QPainter::begin: Paint device returned engine0”这是很多人初学QT踩得最深的坑。void GameWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); QLinearGradient bg(0, 0, 0, height()); bg.setColorAt(0, QColor(110, 200, 255)); bg.setColorAt(1, QColor(230, 245, 255)); painter.fillRect(rect(), bg); painter.setPen(Qt::NoPen); painter.fillRect(0, height() - GROUND_HEIGHT, width(), GROUND_HEIGHT, QColor(130, 90, 50)); for (const Pipe pipe : m_pipes) { painter.fillRect(pipe.topRect(), QColor(70, 160, 70)); painter.fillRect(pipe.bottomRect(), QColor(70, 160, 70)); } QImage bird m_bird.image(); if (bird.isNull()) { // 图片资源缺失时画一个圆的兜底方案保证游戏可玩 painter.setPen(Qt::NoPen); painter.setBrush(QColor(250, 200, 50)); painter.drawEllipse(m_bird.rect()); } else { painter.drawImage(m_bird.rect(), bird); } painter.setPen(Qt::white); QFont font painter.font(); font.setPointSize(28); painter.setFont(font); painter.drawText(width() / 2 - 15, 70, QString::number(m_score)); }这段代码里两个值得抄的细节一是背景用QLinearGradient画渐变不加载图片qrc打包体积小且色调统一二是bird.isNull()兜底判断——如果有设计图上图了就用图没图就画个圆游戏永远不会因为缺资源而黑屏。分数用drawText绘制而不是放一个QLabel省去布局和对齐的麻烦数值更新时只要调用update()触发重绘就自动刷新。QPainter在复杂度不高的时候性能无忧但要注意setRenderHint(Antialiasing)会稍微增加CPU开销。本游戏是轻量绘制开不开都行。如果你发现窗口拉伸时画面闪烁试着在QWidget构造函数里加一句setAttribute(Qt::WA_OpaquePaintEvent, true)可以减少背景擦除次数如果加了反而出现残影说明你绘制时没有覆盖完整区域把这句话去掉。4.2 信号槽在游戏里的两个真实用途计分与结束信号槽是QT区别于普通C库的招牌特性。在这个游戏里最常见也最不容易做作的用法有两个分数变化时发scoreChanged(int)通知窗口标题栏或独立得分控件更新游戏结束时发gameOver()让外部知道可以弹提示框或停止计时。用信号槽的好处是GameWidget不关心谁在听分数外面是QLabel还是QStatusBar它都不用改代码。// GameWidget.h 内新增声明 signals: void scoreChanged(int score); void gameOver(); // GameWidget.cpp 分数变化处 void GameWidget::updateScore(int newScore) { m_score newScore; emit scoreChanged(m_score); } // MainWindow 中连接信号 connect(gameWidget, GameWidget::scoreChanged, this, [this](int score) { this-setWindowTitle(QString(FlappyBird - 当前得分: %1).arg(score)); });连接信号用的是QT 5推荐的“函数指针lambda”新语法好处是编译期就能检查信号和槽函数是否匹配不会出现老语法里“connect写错槽函数名但编译不报错、运行不触发”的坑。lambda里捕获this时要小心如果lambda中的窗口对象已被销毁信号触发会访问野指针本项目中MainWindow和GameWidget生命周期一致这个风险实际存在但很低。哪怕你不太熟悉信号槽也必须把emit发信号这张牌打出来。为什么答辩老师最爱问的问题之一就是“游戏分数界面是怎么更新的”如果你回答“用drawText直接画”他顶多点点头如果你能说出“分数改变时发信号窗口槽函数更新标题”再配合代码里清晰可见的connect这一问基本稳了。4.3 随机数与管道复用用c随机数生成管道位置的真实现法管道高度不能写死每根生成时要在一定范围内随机这样每局的地形都不一样。这里要用到C的随机数。老式rand()在QT5.15里还能用但有两个毛病每次启动生成的序列固定需要手动srand(time(0))以及rand()%N的分布不均匀范围小时偏差不明显范围一大就有统计偏向。推荐用QT自己的QRandomGenerator一句代码解决分布和种子问题。Pipe GameWidget::createPipe(int x) { Pipe pipe; const int gap 180; // 上下管道之间的缺口高度 const int minTop 80; // 上管道最小高度留出天花板余量 int maxTop height() - GROUND_HEIGHT - gap - 80; // 上管道最大高度不能撞地面 int topHeight QRandomGenerator::global()-bounded(minTop, maxTop 1); pipe.topRect QRectF(x, 0, PIPE_WIDTH, topHeight); pipe.bottomRect QRectF(x, topHeight gap, PIPE_WIDTH, height() - GROUND_HEIGHT - topHeight - gap); return pipe; }bounded(int lowest, int highest)返回[lowest, highest]区间的随机整数注意highest是开区间。上面的maxTop 1就是为了让maxTop本身也有机会被取到。这个参数关系值得细看maxTop受窗口高度、地面高度和gap共同影响如果gap调得太大而窗口高度固定minTop到maxTop的随机区间就变得很窄管道位置几乎一样游戏就失去了随机性。换而言之调gap时也要同时调minTop或窗口高度三者是一个联动方程组不要只动一个。管道对象本身用QVector容器保存生成新管道时通过计算与上一根的水平距离来决定而不是固定每隔多少帧追加。PIPE_SPACING为300像素时按PIPE_SPEED3像素/帧计算每100帧才出现一根新管道约合1.6秒一根这个速度配合gap180正好是个“能练两下但不太送分”的难度。想让游戏更难优先减小gap而不是加快PIPE_SPEED小gap让人为失误概率上升大速度让人觉得“不公平”。5. flappyBird移植与调试的5个高频坑从编译报错到运行黑屏5.1 编译报错fatal: cannot mix incompatible qt library现象代码明明在别人电脑上能编译换到自己电脑一编译就报“fatal: cannot mix incompatible qt library (version ex50601) with this library”后面还跟着一堆lib文件路径。第一次遇到会以为QT装坏了重装三遍还是照旧。原因头文件目录、库目录、编译器来自不同的QT套件。最常见的是安装过QT 5.12和QT 5.15两套环境Qt Creator自动选择的Kit指向5.15但.pro文件里手动include了一个旧路径或者系统PATH里残留着老版本的bin目录链接器优先找到了老库。解决最干净的修法是不保留多个QT版本只留主用的5.15.2。然后做三步检查。第一步看Qt Creator左下角Kit选择的是不是“QT 5.15.2 MinGW 64-bit”第二步把.pro文件里手动写的INCLUDEPATH和LIBS全部删掉让qmake自动从Kit的QT_INSTALL_HEADERS和QT_INSTALL_LIBS取路径第三步清理构建目录菜单栏“构建→清理项目”然后重新执行qmake再构建。这报错是编译链自带的“防混装机制”不是代码本身错了不要浪费时间改C代码。自己手动加过LIBS的删掉就恢复了。5.2 运行报错qt.qpa.plugin: could not find the qt platform plugin linuxfb现象代码编译通过在Windows上跑得好好的把可执行文件拷到嵌入式Linux板子或树莓派上一运行就报“qt.qpa.plugin: could not find the qt platform plugin linuxfb”然后程序退出。有人说要装libqt5gui5装完还是老样子。原因QT的图形平台插件和可执行文件没放在一起。QT在不同系统上要加载对应的platform pluginWindows是qwindows.dllLinux桌面是qxcb.so嵌入式才用linuxfb。你编译得到的是X86_64版本的插件拷到ARM板子上当然找不到即使架构对插件目录没设置对也会报同样的错。解决交叉编译时把QT的platforms文件夹整体拷到目标机的可执行文件旁结构为app目录/platforms/libqxcb.so然后设置环境变量QT_QPA_PLATFORM_PLUGIN_PATH指向app目录。如果板子没有X服务才设置export QT_QPA_PLATFORMlinuxfb。大部分课程设计是Windows环境跑不会触发这个错只有你要做树莓派4交叉编译qt的移植展示时才会撞上。这个报错对架构和路径都敏感排查时先用ldd确认libqxcb.so依赖的库是否齐全再怀疑代码。5.3 运行黑屏窗口出来了画面一片空白现象程序启动后窗口正常弹出但游戏区域全白没有背景没有鸟按空格没反应。调试时全局搜paintEvent函数确实被调用了里面也调用了fillRect但就是看不到画面。原因两种常见情况。第一种QPainter绘制过程中某个对象没初始化比如Bird的QRectF坐标全是0画出来就在窗口左上角像素级区域里视觉上等于没画。第二种绘制代码里出了异常早期返回后面所有绘制命令都没执行。最隐蔽的是第二种代码里某个QImage加载失败返回空对象你调用image.isNull()判断后直接return把管道和地面全跳过了。解决在paintEvent里用qDebug()逐行打印bIsReady这种标志值。先确认窗口尺寸是否是预期值再确认Bird和每个Pipe的QRectF是否在窗口范围内。凡是QImage加载路径先打印QFile::exists(:/res/bird.png)资源路径不存在时打印出来会是false。我习惯的做法是加载失败给默认图片颜色而不是直接return跳过整段绘制。要养成用qDebug()给绘制路径做“体检”的习惯空白画面九成是数据问题不是绘制函数问题。5.4 游戏卡顿帧率忽高忽低管道移动一帧一卡现象画面移动不顺滑管道有时跳一大步像PPT播放。QTimer间隔明明设了16ms但实测一帧要三四十毫秒。原因最常见的不是绘制慢而是每帧都做了耗时操作。有人为了追分数把qDebug()写进了onTimeout每帧打印好几行日志Windows控制台I/O本身就慢打印越多帧率越惨。另一个原因是加载图片时QImage被反复解码。解决把所有qDebug()移到事件触发点不要放在每帧执行的循环里。图片在构造函数里一次性加载保存为QImage成员paintEvent直接drawImage。还有一个系统层面的坑Windows桌面开启了垂直同步或合成器动画效果QTimer在高刷屏上会抖动打开游戏程序时按WindowsG关掉游戏录制后台就能感受到帧率回归正常。5.5 小鸟飞出屏幕快速连按空格后鸟不见了现象游戏进行中快速连按空格鸟直接冲出窗口顶部消失过几秒又突然掉下来期间能正常计分。原因跳跃函数给鸟一个向上的瞬时速度每帧位移是速度加上重力影响的累加连按空格会把速度叠加到超过窗口高度所能容纳的极限鸟的y坐标就成了负数。解决在Bird的jump函数里加上界保护且限制速度上限。具体做法是jump时判断m_posY小于0就直接把位置归零、速度归零后续帧由重力拉回画面内。其实更推荐的方案是把“跳跃产生向上速度”改成“跳跃设置目标高度”让鸟在两三帧内插值走到目标高度彻底避免瞬时速度的大幅变化。这一条表面上是数学问题本质上是状态机的保护又没做全——每个操作都该问“我这个状态里做这个操作会不会产生非法值”问完才有稳健性。连点这个行为恰恰是最能暴露设计漏洞的压力测试答辩演示时手一抖就可能上演最好提前拦住。6. 让游戏不只是“能飞”难度曲线、一局可复现的验证跑通基础版之后真正让项目区别于“抄来的代码”的是难度曲线设计。我建议把GRAVITY、PIPE_SPEED、gap做成随分数动态调整的难度公式而不是全用常量。难度曲线分三段0到10分为入门段管道间隙保持在180像素方便玩家上手11到30分为挑战段间隙每过一分缩小1像素并与速度同步提升31分以上为极限段间隙下限锁定在120像素不再缩小但管道速度每十分加0.5像素/帧避免玩家永远过不去。参数方面我把“致命组合”列一张表供你调参时参考难度段 | 管道间隙像素 | 管道速度像素/帧 | 重力像素/帧² 入门0-10分 | 180 | 3 | 0.5 挑战11-30分 | 180→120 | 3→4 | 0.5→0.6 极限31分以上 | 120 | 4→5 | 0.6这个数值不是标准答案你完全可以在自己机器上跑几十局找到更舒服的曲线但注意改参数要遵循“一次一变量”的纪律否则调崩了不知道是哪一项导致的。调难度时先让身边同学试玩别人说“好难”才说明曲线有效自己玩得顺不代表难度合理——你已经形成肌肉记忆了。验证一局游戏是否“完整可玩”我习惯按固定输入序列录屏对比开局连续点五下空格、中间停三秒、再快速点三下观察鸟的轨迹是否平稳、分数是否会跳两分。连续点三下那一下如果分数一次加两分说明上一章的scored标志位逻辑有问题排查方向是先确认计分循环是否每帧都执行了。如果你改过管道生成算法就故意让鸟停在某根管道中间不操作看碰撞判定是否在预期帧数触发验证它不会“穿模”。我最后想补一个长期好习惯把签名档和注释写成“给三个月后的自己看的”而不是“给老师看的”。如果你能在答辩前三天把代码里的魔法数字全部改成具名常量、把注释从“设置分数”改成“按scored标志防止同一根管道重复计分”哪怕游戏的玩法没有任何新意这套代码也已经对得起“课程设计”四个字了。这个方案可以从头自己写一遍也可以照着拆解重构关键是你理解它。希望帮到你。本文还有配套的精品资源点击获取