☰
用Qt和C++重写经典小游戏FlappyBird:从架构到避坑的完整实战
2026/10/10 11:40:15 网站建设 项目流程

简介:基于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 的某个区域,同时把键盘鼠标事件转交给 scene;QGraphicsItem(或它的子类)是“演员”,每个演员自己决定长什么样、占多大地方、是否和其他演员碰撞。这样分离以后,游戏逻辑只需要关心“小鸟位置怎么变”,而不用管窗口坐标换算和重绘细节。

还有一点对 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 = qBound<qreal>(-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 的第二个核心。常见做法是维护一个 QList<PipeItem*>,每走一段距离就生成一对上下管道,整个列表随着游戏进行向左平移。关键参数有三个:管道宽度(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 的 shape

Qt 的 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 画到 y=0(缝隙中心),下管道从 gapCenter 画到 y=800,这样只需要按缝隙中心位置切一刀就行,而不用给两个管道类。

坑点提醒:rect.intersects() 是包含边界的,也就是说两个矩形只是边贴边也会判定碰撞,玩家会觉得判定偏严格。如果你希望宽容一点,可以把 birdRect 缩小 20%,比如 birdRect.adjust(3, 3, -3, -3),我一般在课程设计里会把它做成一个可调的“判定偏移量”,答辩时展示这个参数对难度的影响。

3.4 计分与游戏状态:一个四状态机就够了

小游戏不需要引入复杂的游戏引擎状态管理,一个 enum 加一个 switch 足够:Ready(待开始)、Playing(游戏中)、GameOver(已死亡)、Paused(暂停)。Ready 状态下小鸟悬浮在地面以上微微浮动,点击左键或按空格才切换到 Playing;Playing 状态每帧执行 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 和 PipeItem,UI 层用 QGraphicsTextItem 或 QGraphicsProxyWidget 放在场景最上层。这样切换游戏状态时只需要 setVisible(true/false),不需要各种 hide/show 的胶水代码。MainWindow 里放一个中央 QGraphicsView,scene 通过 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_cast<QKeyEvent*>(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 做到能交、能跑、能讲的程度。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询