
简介基于Qt与C实现的FlappyBird小游戏完整源码适合计算机相关专业学生用于毕业设计、课程设计或个人项目开发。项目经过严格测试导入Qt环境后可直接编译运行核心代码围绕游戏对象、碰撞检测、管道生成、计分显示与音效播放等模块展开文件划分结构清楚方便阅读和维护。压缩包共27个文件主要包含5个cpp源文件、4个头文件、8张png图片素材、2个mp3和1个wav音效文件同时附带可执行exe、Qt工程文件pro、qrc资源管理文件以及说明文档目录将image和sound资源单独归档整体体积仅3.38MB轻量易用。目前已有188人学习下载参考者既可以把它当作Qt入门实践也能在此基础上扩展菜单、存档、难度调节等功能。若想进一步深入还可尝试用信号槽机制实现动画交互或添加最高分记录以完善游戏闭环。1. flappyBird 在 Qt C 里重写一遍课程设计最划算的完整闭环很多人在做 C 课程设计或毕业设计时会陷入两难写个控制台程序太单薄撑不起“设计”两个字硬上一个大型项目又怕三个月写不完。把 flappyBird 用 Qt C 重新实现一次恰好卡在中间玩法规则足够简单但背后的 Qt 界面设计、绘图事件、定时器调度、碰撞检测、信号槽通信全都会涉及到是一个典型的“小项目、大覆盖”选题。这篇笔记我会从空工程开始把架构怎么拆、核心玩法怎么写、资源音效怎么接、以及最容易让人翻车的几个坑完整走一遍适合准备交课程设计、毕业设计或给简历补充一个落地作品的开发者参考。2. 先用 QGraphicsView 把 flappyBird 的骨架搭起来场景与两个 Item 的设计2.1 QGraphicsView / QGraphicsScene / QGraphicsItem 在 flappyBird 里分别负责什么Qt 里做 2D 游戏常见有三条路线最原始的是在 QWidget 上重写 paintEvent配合 QTimer 手动重绘第二种是 QGraphicsView 图形视图框架第三种是 Qt Quick / QML。对用 C 交课程设计的人来说我一般建议选 QGraphicsView而不是 QWidget 自绘也不是 QML——原因很实际QWidget 自绘会把背景、小鸟、管道、分数全部塞进一个 paintEvent 里代码很容易写成一千行的“面条”但 QGraphicsView 天生就把画面拆解成了场景、视图、图元三层天然对应 MVC 里的 Model / View / Item答辩时讲结构会顺很多。具体到 flappyBird三层职责可以这样划分QGraphicsScene 是“游戏世界”负责装东西小鸟、管道、地面这些 QGraphicsItem 都被 addItem 进去QGraphicsView 是“摄像机”负责显示 scene 的某个区域同时把键盘鼠标事件转交给 sceneQGraphicsItem或它的子类是“演员”每个演员自己决定长什么样、占多大地方、是否和其他演员碰撞。这样分离以后游戏逻辑只需要关心“小鸟位置怎么变”而不用管窗口坐标换算和重绘细节。还有一点对 Qt 绘图很重要QGraphicsView 默认开启了视口缓存场景里几百个图元同时移动时性能也扛得住。flappyBird 同时存在的图元数其实很少——小鸟 1 个管道通常 3 到 4 对地面 2 段背景 1 张最多不到 15 个对象所以 QGraphicsView 在这类小游戏里完全是杀鸡用牛刀但好处是它顺手帮你把矩形相交测试、图元变换、事件分发这些能力都包好了。2.2 BirdItem 与 PipeItem继承 QGraphicsItem 后要重写哪四个函数自定义图元是 QGraphicsView 开发的核心。每个新图元都要继承 QGraphicsItem如果要发信号也可以继承 QGraphicsObject然后至少重写四个成员boundingRect()、paint()、shape()可选但强烈建议、type()。boundingRect()告诉框架“我这个图元占多大矩形”框架用它做重绘区域的裁剪和碰撞粗筛。注意一定要返回一个比实际绘制范围稍微大一点的矩形否则边缘会出现残影或画不全。paint()真正的 Qt 绘图逻辑都在这个函数里QPainter 在这里被传入你可以画圆、画矩形、画图片。shape()默认实现是返回 boundingRect() 的矩形但对 flappyBird 来说小鸟实际是圆形的管道是两个矩形拼出来的如果你希望碰撞贴合视觉需要手动返回一个 QPainterPath。type()返回一个自定义枚举值方便你在遍历场景时用 qgraphicsitem_cast 判断图元类型。下面是一个 BirdItem 的头文件骨架我通常会把游戏参数直接定义为类的常量方便后续调手感// birditem.h #ifndef BIRDITEM_H #define BIRDITEM_H #include QGraphicsObject class BirdItem : public QGraphicsObject { Q_OBJECT public: enum { Type UserType 1 }; // 自定义类型枚举 explicit BirdItem(QGraphicsItem *parent nullptr); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; QPainterPath shape() const override; int type() const override { return Type; } // 供游戏控制器调用的接口 void flap(); // 跳一下 void tick(qreal dt); // 每帧更新物理状态 qreal y() const { return m_y; } int score() const { return m_score; } signals: void passedPipe(); // 穿过一组管道用于加分 void dead(); // 碰撞了用于通知游戏结束 private: qreal m_x; // 固定横坐标 qreal m_y; // 当前纵坐标 qreal m_vy; // 纵向速度 qreal m_rotation; // 小鸟俯仰角度 static const qreal GRAVITY; // 重力加速度 static const qreal JUMP_SPEED; // 跳跃初速度 }; #endif // BIRDITEM_H这里有两个设计细节值得注意。第一小鸟的横坐标 m_x 在整个游戏过程中是固定的管道动而小鸟不动这样计分逻辑会非常干净——只要判断管道的 x 坐标是否越过小鸟的 x 坐标即可。第二我让 BirdItem 继承了 QGraphicsObject 而不是 QGraphicsItem因为 QGraphicsObject 自带信号槽能力碰撞死亡和穿越管道加分这两个信号可以直接从图元发出GameController 只需要 connect 一下就能驱动状态机省去在外部轮询的麻烦。2.3 从空白工程到能跑出第一个黑色窗口工程文件与最小代码新建项目这一步最常见的是用 Qt Creator 的向导生成一个 QMainWindow 或 QWidget 工程。需要注意在向导里勾选“QGraphicsView”相关组件其实不用Qt 默认把 widgets 模块带进工程只有用到 QSoundEffect 时才需要额外加 multimedia。课程设计常用 qmake 的 .pro 文件一份最小可用的 .pro 长这样# flappyBird.pro QT core gui widgets multimedia TARGET flappyBird TEMPLATE app SOURCES \ main.cpp \ mainwindow.cpp \ birditem.cpp \ pipeitem.cpp HEADERS \ mainwindow.h \ birditem.h \ pipeitem.h RESOURCES \ resources.qrc对应的 main.cpp 核心内容只有三步创建 QApplication创建主窗口显示主窗口。很多课程设计喜欢把全部代码塞在 main 里我建议还是单独建一个 MainWindow 类因为 flappyBird 后续要接管开始界面、暂停、游戏结束等状态MainWindow 作为最高层的控制器最合适。main.cpp 的典型写法是这样// main.cpp #include QApplication #include mainwindow.h int main(int argc, char *argv[]) { QApplication a(argc, argv); MainWindow w; // 主窗口 w.setWindowTitle(flappyBird Qt/C 课程设计); w.show(); return a.exec(); // 进入事件循环 }这段代码没什么可解释的是 Qt 程序的固定入口。需要注意安装 Qt 时不要把组件勾得太少Qt Creator 在 Windows 上需要搭配对应版本的编译套件MinGW 或 MSVC若本机没有装 Visual C 运行库双击 exe 会直接报“无法启动此程序因为计算机中丢失 VCRUNTIME140.dll”之类的错误这在打包交作业时属于最高频事故后面避坑章节再展开。3. 把 flappyBird 核心玩法写出来重力模拟、管道生成和碰撞判定3.1 重力模型为什么用“速度 位移”而不是“每帧移动固定像素”flutter 小游戏的手感核心在于重力模拟。不少第一次写游戏的人会这么做每帧把小鸟的 y 坐标直接加上一个常量按一下空格就往上减结果发现小鸟飞起来像跳格子完全没有抛物线感。正确做法是维护两个量当前速度 m_vy 和当前位置 m_y重力是一个每帧叠加到速度上的加速度。伪代码就三行先给速度加重力再让位置顺着速度方向移动最后把速度限制在最大下降速度内。Qt 里如果用 QTimer 每 16ms 触发一次 tick这个模型可以这样落地// birditem.cpp 片段tick 每帧被 GameController 调用 void BirdItem::tick(qreal dt) { // dt 是上一帧到这一帧的时间差单位秒用于帧率无关的计算 m_vy GRAVITY * dt; // 速度累加重力 if (m_vy MAX_FALL_SPEED) // 限制最大下落速度防止坠穿 m_vy MAX_FALL_SPEED; m_y m_vy * dt; // 位置累加速度 setPos(m_x, m_y); // 角度随速度变化抬头或低头 m_rotation qBoundqreal(-30.0, m_vy * 4.0, 60.0); setTransformOriginPoint(boundingRect().center()); // 绕中心旋转 setRotation(m_rotation); update(); // 通知框架重绘 } void BirdItem::flap() { m_vy JUMP_SPEED; // 直接给一个向上的初速度 m_rotation 30.0; // 拍翅膀瞬间抬头 }逻辑说明tick 函数是典型的欧拉积分读者不要觉得这个名字高大上它就是“速度叠加加速度、位置叠加速度”两步走。dt 的作用是让游戏在 60 帧和 120 帧的机器上表现一致如果直接用 m_y m_vy 而不乘 dt在高帧率下小鸟会飞得慢、低帧率下飞得快这是很多人在同学电脑上跑出来手感完全不同的原因。参数说明GRAVITY 我取 1500 到 1800 像素/秒²JUMP_SPEED 取 -450 到 -500 像素/秒MAX_FALL_SPEED 限制在 600 像素/秒左右。这些数值不是推导出来的而是反复试出来的血泪经验重力太小小鸟飘大了一点又像砸铅球建议以 10% 步进微调。窗口高度如果是 600 像素重力 1600、跳跃速度 -460 这个组合能保证抛物线最高点大约在窗口一半高度观感最舒服。3.2 管道生成固定水平间距 随机垂直缝隙管道系统是 flappyBird 的第二个核心。常见做法是维护一个 QListPipeItem*每走一段距离就生成一对上下管道整个列表随着游戏进行向左平移。关键参数有三个管道宽度60 到 80 像素、上下缝隙高度140 到 180 像素、相邻管道组的水平间距200 到 260 像素。管道不应该每帧新建那样会有性能问题正确做法是复用对象或生成后用定时器清理出视野的项。PipeItem 内部通常持有两个矩形topRect 和 bottomRect用一个 setGapY(qreal gapCenterY) 设置缝隙的中心位置。生成管道的算法我一般写在 GameController 里而不是 PipeItem 里因为控制器负责“什么时候生成”管道只负责“长什么样”。一个可靠的生成逻辑是这样// gamecontroller.cpp 中生成一对管道 void GameController::spawnPipe() { // 窗口逻辑高度 600取缝隙中心在 180~420 之间随机 const int sceneHeight 600; const int gapCenter 180 qrand() % 240; // 240 420 - 180 PipeItem *pipe new PipeItem(PIPE_WIDTH, PIPE_GAP, gapCenter); pipe-setPos(sceneWidth, -100); // 从场景最右缘进场 scene-addItem(pipe); pipeList.append(pipe); // 下一次生成时机当前管道组向右移动 PIPE_SPACING 像素后 nextSpawnX sceneWidth PIPE_SPACING; }逻辑说明每个管道组的初始 x 固定在场景右边缘之后每一帧由控制器统一向左移动。movePipes 时遍历 pipeList把每个 pipe 的 x 减掉水平速度乘以 dt再判断 x 是否小于 -PIPE_WIDTH如果是就从场景移除并 delete。这里要注意不要在遍历容器时直接 delete 当前项否则迭代器会失效。参数说明PIPE_WIDTH 影响视觉比例与碰撞面大小PIPE_GAP 是难度核心数值越小玩家越容易撞上下管道PIPE_SPACING 决定连续管道的间距如果你发现玩家连续躲过三组都毫无压力就该把间距收紧或者把缝隙调小。课程设计交作业时我建议把这几个值做成 MainWindow 上的 QSlider 或写进配置文件答辩时现场调参直观展示“我能对游戏平衡性做数据化调整”。3.3 碰撞检测手动算矩形相交还是用 QGraphicsItem 的 shapeQt 的 QGraphicsScene 天然支持两种碰撞检测collidesWithItem() 和 items() 空间索引查询。但 flappyBird 情况特殊小鸟在飞管道在移如果用 collidesWithItem 遍历场景里的对象逻辑上更省事却有一个坑——它默认用 boundingRect 相交来判断而小鸟的 boundingRect 是矩形视觉上鸟的翅膀边缘还没碰到管道系统却判定死亡玩家会觉得“我怎么没碰上也死了”。所以我更推荐手动做碰撞检测理由也很简单碰撞对象就三类——小鸟矩形、地面矩形、上下管道矩形最多也就 7 个矩形做相交运算手写 30 行代码比调框架 API 更容易控制判定精度。下面这段在 GameController::checkCollision() 里实现// 手动碰撞检测返回 true 表示已碰撞 bool GameController::checkCollision() { // 1. 小鸟的中心点坐标 QPointF birdPos bird-pos(); QRectF birdRect bird-boundingRect(); birdRect.translate(birdPos); // 将本地矩形平移到全局坐标 // 2. 地面撞击窗口高度 600地面顶部在 560 if (birdRect.bottom() groundY) { bird-emitDead(); return true; } // 3. 与每根管道做矩形相交 for (PipeItem *pipe : pipeList) { QPointF pipePos pipe-pos(); const QRectF pipeLocalRect pipe-boundingRect(); // 上管道本地坐标的矩形 QRectF topRect(0, -800, PIPE_WIDTH, 800); topRect.translate(pipePos); // 下管道从缝隙中心向下延伸 QRectF bottomRect(0, pipe-gapCenter(), PIPE_WIDTH, 800); bottomRect.translate(pipePos); if (birdRect.intersects(topRect) || birdRect.intersects(bottomRect)) { bird-emitDead(); return true; } // 加分判定小鸟越过管道右边缘且未计过数 if (!pipe-scored() pipePos.x() PIPE_WIDTH birdPos.x()) { pipe-setScored(true); score; bird-emitPassedPipe(); } } return false; }逻辑说明第一步把小鸟的本地矩形平移到全局坐标是因为 QGraphicsItem 的 pos() 是它在场景中的坐标paint 时画在本地坐标系。第二步地面判定最简单。第三步最核心PipeItem 的本地矩形我把上管道的绘制区域从 -800 画到 y0缝隙中心下管道从 gapCenter 画到 y800这样只需要按缝隙中心位置切一刀就行而不用给两个管道类。坑点提醒rect.intersects() 是包含边界的也就是说两个矩形只是边贴边也会判定碰撞玩家会觉得判定偏严格。如果你希望宽容一点可以把 birdRect 缩小 20%比如 birdRect.adjust(3, 3, -3, -3)我一般在课程设计里会把它做成一个可调的“判定偏移量”答辩时展示这个参数对难度的影响。3.4 计分与游戏状态一个四状态机就够了小游戏不需要引入复杂的游戏引擎状态管理一个 enum 加一个 switch 足够Ready待开始、Playing游戏中、GameOver已死亡、Paused暂停。Ready 状态下小鸟悬浮在地面以上微微浮动点击左键或按空格才切换到 PlayingPlaying 状态每帧执行 tick、移动管道、检查碰撞一旦收到 dead 信号就切到 GameOver停止所有移动并显示分数面板。这个状态机我通常直接放在 GameController 里用信号槽接收 BirdItem 发来的 dead 和 passedPipe 信号// gamecontroller.cpp void GameController::init() { // 与小鸟交互信号 connect(bird, BirdItem::dead, this, GameController::gameOver); connect(bird, BirdItem::passedPipe, this, GameController::addScore); } void GameController::start() { state PLAYING; timer-start(1000 / 60); // 60 FPS 固定间隔 } void GameController::gameOver() { state GAMEOVER; timer-stop(); // 把分数同步到界面 Label ui-scoreLabel-setText(QString::number(score)); ui-gameOverPanel-show(); }这里有个容易忽略的设计点score 计数不放在 BirdItem 里放在 GameController 里因为计分是“游戏规则”不是“鸟的行为”。管道对象用自身的一个 bool scored 标记防止重复加分比在控制器里维护一个“已计分管道列表”要省心得多这算是容器管理的实践细节。4. 素材、音效与界面状态让 flappyBird 从“能玩”变成“像样”4.1 用 QPainter 手工绘制小鸟和管道免素材、不怕加载失败课程设计最常见的翻车点是用了网上找的 png 图片结果换一台电脑图片路径失效或者相素尺寸不对拉变形。我的建议是所有视觉元素全部用 QPainter 手绘这样整个项目只有代码和 qrc 资源文件没有外部图片依赖交源码包时不可能缺文件。手绘的好处还能展现你的 Qt 绘图能力答辩时你能讲出“这里用了 QPainterPath 绘制圆角矩形、QLinearGradient 填充渐变”比一句“加载图片”有说服力得多。下面是一个只画“皮”的 BirdItem::paint 示例你可以把它替换成你自己的可爱风格// birditem.cpp 中的 paint void BirdItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); painter-setRenderHint(QPainter::Antialiasing, true); // 抗锯齿开启 // 身体黄色圆 渐变 QRadialGradient rg(QPointF(20, 18), 18); rg.setColorAt(0.0, QColor(#FFE082)); rg.setColorAt(0.8, QColor(#FFB300)); painter-setBrush(rg); painter-setPen(QPen(QColor(#5D4037), 2)); painter-drawEllipse(QRectF(6, 4, 30, 30)); // 白色眼珠 黑色瞳孔 高光 painter-setBrush(Qt::white); painter-drawEllipse(QPointF(28, 14), 5, 5); painter-setBrush(Qt::black); painter-drawEllipse(QPointF(30, 14), 2, 2); painter-setBrush(Qt::white); painter-drawEllipse(QPointF(31, 13), 1, 1); // 嘴黄色小三角 QPolygonF beak; beak QPointF(34, 19) QPointF(42, 20) QPointF(34, 24); painter-setBrush(QColor(#FF6F00)); painter-drawPolygon(beak); }这段代码虽然长但每个绘制步骤都很直白先画一个带渐变圆作为身体再画眼睛、瞳孔、嘴。参数说明里最值得写的是 QPainter::Antialiasing如果不开启圆形边缘会出现明显的锯齿观感瞬间掉一个档次而 setRenderHint 这个调用在 Qt 绘图里是高频操作建议放在 paint 的开头统一处理。管道的手绘更简单QBrush 用绿色先填一个不透明矩形再画一个突出边缘的“帽”。管道的视觉效果不影响任何逻辑只要和碰撞矩形对得上就行。给我印象很深的一个坑是有人手绘管道时随手把缝隙宽度画成了 100 像素但逻辑上 PIPE_GAP 是 160导致视觉上“很明显能过去”的地方总是撞死调试好久才找到是绘制与碰撞矩形不一致。4.2 QSoundEffect 加音效格式、资源路径与播放时机Qt 里播放音效有 QSound、QSoundEffect、QMediaPlayer 三条路。QSound 只能播 wav且是阻塞播放游戏循环里容易卡顿QMediaPlayer 功能全但臃肿适合播背景音乐游戏短音效的最佳选择是 QSoundEffect它底层走音频解码线程不会阻塞主线程而且支持多路同时播放跳一下和得分同时响也没问题。引入音效后需要在 .pro 里补上 multimedia 模块这在前面的 .pro 文件里已经写到了。QSoundEffect 的用法很简单构造对象setSource 指定 wav 文件路径然后调 play()。文件放在 qrc 资源系统里是最好的因为 qrc 会把音频打进 exe不会出现运行时找不到文件的问题。一个典型的使用方式// mainwindow.cpp 中初始化音效 #include QSoundEffect // 成员QSoundEffect *sfxJump, *sfxScore, *sfxHit; void MainWindow::initSound() { // 注意QSoundEffect 对音频格式有要求需要未压缩的 PCM WAV sfxJump new QSoundEffect(this); sfxJump-setSource(QUrl(qrc:/sounds/jump.wav)); sfxJump-setVolume(0.5f); sfxScore new QSoundEffect(this); sfxScore-setSource(QUrl(qrc:/sounds/score.wav)); sfxScore-setVolume(0.6f); // 播放时机在 keyPressEvent 中 jump 时和 addScore 时调用 play() }踩坑提示QSoundEffect 的 wav 文件必须是未压缩的 PCM 格式某些从网页下到的 wav 是 ADPCM 或特殊编码加载后静默无声且不报错。用 Audacity 转成 44100Hz 16 位单声道 PCM 最保险。还有一点qrc 路径前面必须带 qrc:如果写相对路径只能在开发机跑换机器就崩。想要毕业设计加印象分建议再配一首轻量 mp3 背景音乐用 QMediaPlayer 循环播放但这属于加分项不做也不影响核心功能。4.3 开始界面 / 结束界面 / 分数面板的状态切换flappyBird 的界面不要用传统 Widget 布局硬拼更推荐的做法是把场景分成“游戏层”和“UI 层”游戏层放 BirdItem 和 PipeItemUI 层用 QGraphicsTextItem 或 QGraphicsProxyWidget 放在场景最上层。这样切换游戏状态时只需要 setVisible(true/false)不需要各种 hide/show 的胶水代码。MainWindow 里放一个中央 QGraphicsViewscene 通过 setScene 挂上去所有元素都是场景里的 item。开始界面通常有三个元素游戏 logo 文本、分数最高纪录文本、提示“按空格或点击屏幕开始”。游戏结束界面包含当前分数、最高分、重新开始按钮。如果你用 QGraphicsTextItem 显示文字要注意它的锚点默认在左上角居中显示需要手动计算文本宽度// 分数文本居中的一个常用写法 QGraphicsTextItem *scoreText new QGraphicsTextItem(0); scoreText-setFont(QFont(Arial, 24, QFont::Bold)); scoreText-setDefaultTextColor(Qt::white); scoreText-setPos(sceneWidth / 2 - scoreText-boundingRect().width() / 2, 50);为什么不用 QLabel 叠加在 QGraphicsView 上因为 QLabel 是窗口系统的控件图层永远盖在 QGraphicsView 之上全屏或窗口缩放时位置要手动同步而且无法随场景旋转缩放。用 QGraphicsProxyWidget 把 QLabel 代理进场景也是个办法但为了一个按钮引入代理类不值得直接用 QGraphicsTextItem 加一个半透明面板即可。游戏状态的切换全部由 GameController 控制MainWindow 只负责创建控制器和 view这样 MVC 边界清晰答辩时讲“高内聚低耦合”有实际例子佐证。5. flappyBird 的避坑现场QTimer、焦点、随机数与生命周期的四个翻车点5.1 QTimer 已触发但画面不动现象qDebug() 证明定时器每 16ms 都在走但窗口里的画面纹丝不动只有拖动窗口时才会刷新。原因QGraphicsItem 的位置变了但没有触发重绘通知。QGraphicsItem::setPos() 其实会自动调用 update()理论上不需要手动刷新但有一种情况例外——你在 tick 里直接改的是 m_y 再 setPos这没问题可如果漏掉 setTransformOriginPoint 或设置旋转后再 setPos变换原点不一致会导致图形只旋转不移动看起来像“没动”。另一个更常见的原因是定时器连接的是 QWidget 的 update()而不是 viewport 的 update()而 QGraphicsView 的绘制在视口上重绘主窗口不会触发视口刷新。解决统一在 GameController 的 tick() 末尾调用 view-viewport()-update()不要依赖 item 的自动 update。同时检查 view-setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate)这个设置下视图会在所有图元 boundingRect 变化时自动重绘虽然性能略低但 flappyBird 图元数量少完全可以接受。建议调试期直接用 FullViewportUpdate排错更直观。5.2 碰撞检测偶发“穿透”现象管道看起来已经压到小鸟了但游戏没有结束偶尔还会出现小鸟从管道中间穿过去的诡异现象。原因碰撞检测是离散进行的每帧检查一次。当游戏帧率降到 30 FPS 时小鸟移动 20 像素/帧而管道壁厚可能只有 10 像素上一帧鸟在管道左侧下一帧鸟已经在管道右侧两次检测都没检测到相交视觉上就是穿模了。解决最稳妥的办法是把游戏逻辑固定成 60 FPS用 QTimer 的定时器间隔 16ms 驱动而不是用事件循环里不稳定的时间差。如果仍然担心低配机掉帧可以在 move 之前计算“本帧位移量”如果位移超过管道壁厚的 1/2就拆成两段检测但课程设计做到这个程度已经超纲通常固定帧率就够。另一个实用技巧是把碰撞矩形扩展一点例如 birdRect.adjust(-2, -2, 2, 2)让边界更敏感宁可把玩家“冤枉死”也不放“侥幸活”。5.3 管道随机高度“越跑越乱”或“每次一样”现象重新启动程序后管道随机的高度序列和上一次完全一样或者跑到后期出现连续几组管道高度差很小玩家根本来不及反应。原因使用了全局的 qrand()而 qrand() 的随机种子默认是 1程序每次启动随机序列都一样。至于“越跑越乱”其实是随机范围设置太大了缝隙中心在 180 到 420 之间随机相邻两组可能一高一低差 200 像素视觉上就像乱跳。解决程序启动时播一次种子用当前毫秒级时间戳。对应地把随机范围缩小并且加一个“相邻限制”逻辑——下一组的缝隙中心与当前组差值控制在正负 80 像素以内。这样既保证了关卡变化又不会出现让玩家摔手机的悬崖式难度。具体做法是维护 lastGapCenter生成新管道时在 lastGapCenter ± 80 范围内取随机数再 clamp 到整体安全区间。5.4 键盘按键和鼠标点击没有反应现象程序跑起来了小鸟在空中悬停按空格、点鼠标都毫无反应但窗口最小化再恢复偶尔能触发一次。原因焦点问题。QGraphicsView 默认不接收键盘事件需要给 view 或主窗口设置焦点策略另一方面如果你重写了 QMainWindow 的 keyPressEvent而焦点落在某个子控件上事件会被子控件吞掉根本到不了主窗口。鼠标点击同理可能被按钮或 Qt 默认的 drag 事件拦截。解决最省心的做法是不要依赖窗口键盘事件而是给 QGraphicsView 设置焦点策略并重写 view 的 keyPressEvent也可以在 GameController 里监听事件过滤器。我一般推荐后一种在主窗口安装事件过滤器拦截所有键盘和鼠标事件判断 key 或 button 后交给控制器分发这样焦点在哪都能响应代码也只写一次。事件过滤器的安装代码在避坑语境下价值很高贴一个最小版本// mainwindow.cpp 中启用事件过滤器 ui-graphicsView-installEventFilter(this); bool MainWindow::eventFilter(QObject *obj, QEvent *ev) { if (obj ui-graphicsView) { if (ev-type() QEvent::KeyPress) { QKeyEvent *keyEv static_castQKeyEvent*(ev); if (keyEv-key() Qt::Key_Space) controller-onJump(); // 统一收口到控制器 } else if (ev-type() QEvent::MouseButtonPress) { controller-onJump(); } } return QMainWindow::eventFilter(obj, ev); // 交给基类继续处理 }事件过滤器在 Qt 里是个很容易被误解的功能但课程设计用它解决输入问题非常合适——只要一行 installEventFilter 就能拦截 view 的所有输入不会再出现焦点丢失的玄学问题。5.5 退出时 Qt 崩溃double free 与场景所有权现象游戏正常玩没事但一发退出按钮、点窗口 X程序直接崩溃报错信息里有 double free 或 pure virtual method called。原因QGraphicsScene 拥有场景内所有 item 的所有权场景析构时自动 delete 全部 item。如果你在控制器析构里又手动 delete 了一遍 bird 或 pipe就会 double delete。反过来如果你把 bird 和 pipe 都 new 在栈上场景析构还会再删一次栈对象直接崩溃。这是最典型的 Qt 生命周期问题也是课程设计答辩时容易被老师抓包的隐藏 bug。解决定一个规矩——所有 QGraphicsItem 统一 new 在堆上并交给场景管理外部只保留裸指针用于逻辑访问绝不手动 delete。在重启游戏时用 qDeleteAll(pipeList) 删掉旧管道前先调用 scene-removeItem(pipe)再删除否则场景仍持有野指针。这个片段要写进重启逻辑里// gamecontroller.cpp 重启逻辑 void GameController::reset() { // 清掉旧管道 for (PipeItem *pipe : pipeList) { scene-removeItem(pipe); // 先从场景移除切断持有关系 delete pipe; // 再释放对象 } pipeList.clear(); // 小鸟复位 bird-setPos(bird-startX(), bird-startY()); bird-resetVelocity(); score 0; state READY; }这里又引出一个细节removedFromScene 信号配合 deleteLater 也是一种常见清理方式但课程设计用同步 delete 更直观。如果读者以后做更复杂的项目建议一律用 deleteLater避免在事件处理循环中释放图元导致崩溃。这是 C 内存管理和 Qt 对象树两条知识线的交汇点答辩老师特别喜欢追问。6. 收尾把 flappyBird 参数调到“手感好”并用三个增量方向换高分flappyBird 的代码写完只是第一步真正拉开课程设计档次的是调参和增量。最容易见效的参数表我直接列出来重力 1400 到 1800 像素/秒²跳跃初速度 -420 到 -500 像素/秒最大下落速度 500 到 600 像素/秒管道宽度 70 到 80 像素缝隙高度 150 到 180 像素管道间距 200 到 260 像素。调参时不要一次改两三个变量那样无法判断手感变化来自哪一项。我自己的习惯是先固定重力为 1600然后单独调跳跃速度找到“按一下能升半个窗口高度”的值再回头微调重力最后用管道间距来适配整体难度。如果调了半天还是觉得别扭检查窗口尺寸——600 高的窗口和 800 高的窗口对同一组参数的手感差异很大建议先定窗口高度再调参数做完后把这两者写进配置类常量。验证方法也提一个可落地的套路写一个 Debug 面板四个滑块实时绑定四个核心参数重力、跳跃、缝隙、间距开着游戏边玩边拖找到舒适点后把数值抄进代码常量。这个面板不作为正式功能发布但调参效率能快一倍。至于增量方向我给三个具体建议每个的工作量都在一个晚上以内第一个是把最高分存进 QSettings重启不丢这是 Qt 持久化存储的标准用法第二个是给小鸟死亡加一个慢动作倒放特效也就是 GameOver 后让时间步长变成负值代码上只需要把 tick 的 dt 取反视觉效果好且好讲原理第三个是接手柄或触摸屏触摸屏在 Qt 里只需要处理 QTouchEvent手机和平板上都能演示。把这三个任选两个做进去答辩讲 5 分钟绰绰有余。回到我自己的经历我第一次写 flappyBird 时管道碰撞用的还是定时器里手动遍历像素点调整碰撞框逻辑调了一个通宵最后发现是图片和逻辑矩形没对齐。后来彻底用手绘图形并统一从 boundingRect 出发做碰撞这类问题再也没出现过。现在我给别人讲 Qt 写 2D 游戏最想强调的还是那句图元的绘制逻辑和碰撞逻辑必须共用同一个尺寸来源不然视觉和判定永远是两套世界。希望这些细节能帮你在课程设计或毕业设计里少走几个来回花一个周末把 flappyBird 做到能交、能跑、能讲的程度。本文还有配套的精品资源点击获取