简介:面向Qt5初学者与图形界面开发入门者的“飞行的小鸟”小游戏示例,围绕QGraphicsView与QGraphicsScene搭建可交互的2D场景,帮助理解QGraphicsItem绘制、定时器驱动动画、键盘事件处理、碰撞检测以及得分与游戏状态切换等基础环节。压缩包共24个文件,约187KB,包含6个cpp与6个h源码、8张png素材,以及pro工程、qrc资源、ui界面和user配置文件,结构紧凑,便于在Qt Creator中直接查阅和改造。已有520人学习。读者可据此梳理场景初始化、小鸟位置更新、障碍物移动与碰撞判定的代码组织方式,并借鉴资源加载、窗口布局和主逻辑分层写法,适合作为课程设计、自学练习或Qt图形视图动画的入门参考。
1. 从一只会掉下去的小鸟说起:Qt5 做 2D 小游戏能练到什么
前阵子接手一个用 Qt5 写的「飞行的小鸟」小游戏,整个工程不到一千行,却把 QGraphicsScene 场景图、QTimer 主循环、矩形与像素碰撞、状态机、QSettings 存档、QSoundEffect 音效全串了一遍。它解决的问题很具体:让你在一个能跑起来的完整闭环里,搞懂 Qt 图形视图框架到底是怎么把「一堆图片」变成「一个有物理、有判定、有胜负的 2D 世界」。适合两类人:刚学完 Qt 控件、想弄明白 QGraphicsItem 坐标体系的新手;想给自己桌面工具加一个 2D 动画界面、却不想引入游戏引擎的熟手。下面按工程骨架、玩法内核、碰撞计分、踩坑排查、二次开发的顺序往下拆。
2. 工程骨架:QGraphicsScene 把小鸟、管道、地面组织成什么
拿到这份代码第一件事不是看小鸟怎么飞,而是看目录和工程文件。Qt5 的 2D 项目最容易翻车的地方不在算法,而在「谁负责渲染、谁负责更新、坐标系从哪算起」,这三件事定不下来,后面每加一个元素都会歪。
2.1 目录结构与 .pro 里必须打开的几个开关
一份能跑的工程大致是这样的结构,文件数不多,但职责切得很干净:
FlyingBird/ ├── FlyingBird.pro ├── main.cpp ├── gameview.h / gameview.cpp # 视图层:窗口、缩放、输入 ├── gamescene.h / gamescene.cpp # 场景层:主循环、状态机、生成与回收 ├── bird.h / bird.cpp # 小鸟图元:物理与动画帧 ├── pipe.h / pipe.cpp # 管道图元:上下两段的绘制与碰撞矩形 ├── ground.h / ground.cpp # 地面图元:滚动与地面碰撞线 ├── resources/ │ ├── images/bird_0.png、bird_1.png、bird_2.png │ ├── images/pipe_body.png、pipe_cap.png │ └── sounds/flap.wav、hit.wav、score.wav └── resources.qrc.pro文件里有两个模块不能漏:
QT += core gui widgets multimedia CONFIG += c++11 TARGET = FlyingBird TEMPLATE = app RESOURCES += resources.qrcmultimedia是给 QSoundEffect 用的,漏了它编译不报错,链接到QSoundEffect时才会炸出一堆 undefined reference,新手很容易在这里卡半天。RESOURCES对应 qrc 文件,图片和音效全部走:/images/xxx.png这种资源路径,好处是打包成 exe 后不用拖一堆散文件,坏处是路径写错时程序不报错、只是图上啥也没有,这个坑后面单独说。
CONFIG += c++11不是可选项。项目里用了enum class GameState、override、成员初始化列表里的默认值,低于 C++11 直接编不过。如果你打算改成 C++17 用std::optional之类的东西,改这一行就行,代码本身没有依赖太老的写法。
2.2 QGraphicsView 的三行关键配置
GameView继承自QGraphicsView,构造函数里的配置决定了后面所有坐标能不能对得上:
GameView::GameView(QWidget *parent) : QGraphicsView(parent) { // 1. 关闭滚动条:我们不是在看一张大图,而是在看一个固定关卡 setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // 2. 抗锯齿 + 平滑缩放,像素图放大后才不会出现硬边和锯齿 setRenderHint(QPainter::Antialiasing, true); setRenderHint(QPainter::SmoothPixmapTransform, true); // 3. 固定场景矩形,这是整套坐标系的基准 setSceneRect(0, 0, 480, 720); setFixedSize(480, 720); setFrameShape(QFrame::NoFrame); }setSceneRect(0, 0, 480, 720)是最容易被删掉的一行。不设它,QGraphicsScene 会根据所有图元的位置自动计算场景边界,小鸟往上飞、管道从右侧进来,场景矩形就会被不断撑大,视图随之滚动,你看到的现象是「地面莫名其妙往左跑」「管道出生点飘忽」。设死之后,场景坐标就是你脑子里的那一套:左上角是 (0,0),地面线固定在 y=640,管道从 x=480 右侧进入。
setFixedSize是为了让场景和窗口 1:1 对应,省掉缩放换算。如果你想要自适应窗口,正确做法是保留setSceneRect,改用fitInView(sceneRect(), Qt::KeepAspectRatio)并在resizeEvent里调用,而不是让场景自己膨胀。
main.cpp只有几行,注意对象的构造顺序:
int main(int argc, char *argv[]) { QApplication app(argc, argv); GameView view; // 先构造视图 GameScene scene; // 后构造场景,析构时先销毁 view.setScene(&scene); // setScene 不接管所有权 view.show(); return app.exec(); }setScene不会把 scene 变成 view 的子对象,所以千万别在堆上new GameScene之后不管它。用栈对象、按「view 先、scene 后」的顺序定义,退出时 scene 先析构、视图再析构,不会出现 QGraphicsScene 析构时还在引用已销毁视图的警告。
2.3 图元分层:zValue 决定谁压着谁
一个场景里同时有背景、管道、地面、小鸟、分数文字,谁在前谁在后完全由zValue决定,和添加顺序无关:
| 元素 | zValue | 说明 |
|---|---|---|
| 背景图 | -10 | 铺满整个 sceneRect,永不移动 |
| 管道 | 0 | 基准层,滚动时整体左移 |
| 地面 | 5 | 压在管道底部,遮住管道穿地的部分 |
| 小鸟 | 10 | 永远在最上层,撞管道时视觉上才有「撞上去」的感觉 |
| 分数 / 提示文字 | 100 | UI 层,不参与任何碰撞 |
m_background->setZValue(-10); m_ground->setZValue(5); m_bird->setZValue(10); m_scoreText->setZValue(100);一个常见误用是:把地面当成「背景的一部分」画进背景图里。这样管道落到底部时没有遮挡,会看到管道一直延伸到屏幕外,视觉上非常廉价。地面单独做成一个图元,放在 zValue=5,它既负责遮挡,也负责滚动动画(两张地面图首尾相接循环平移),还能顺便给出地面碰撞线 y=640 这个常量。
3. 核心玩法实现:一只小鸟的物理、主循环和管道生成
骨架搭好之后,真正让它「像游戏」的是三件事:小鸟的竖直物理、稳定的主循环、管道的持续生成与回收。这三块代码量加起来不到四百行,但每一块的实现方式直接决定手感。
3.1 小鸟的物理模型:速度、重力与一次起跳
小鸟不是一个只会setY(y-10)的图片,它需要竖直速度、重力加速度和一次起跳的瞬时冲量。把这三个量做成成员变量:
class Bird : public QGraphicsItem { public: explicit Bird(QGraphicsItem *parent = nullptr); QRectF boundingRect() const override; QPainterPath shape() const override; // 碰撞体,后面细讲 void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override; void flap(); // 起跳:给一个向上的瞬时速度 void advancePhysics(qreal dt); // 每帧推进物理 void reset(); qreal vy() const { return m_vy; } private: qreal m_vy = 0.0; // 竖直速度,向下为正,单位 px/s qreal m_gravity = 1800.0; // 重力加速度,单位 px/s^2 qreal m_flapImpulse = -520.0; // 起跳瞬时速度,负值表示向上 QPixmap m_frames[3]; // 上抬 / 平飞 / 下落三帧 int m_frameIndex = 1; };物理推进函数是整套手感的核心,只有五行,但每行都有讲究:
void Bird::advancePhysics(qreal dt) { m_vy += m_gravity * dt; // v = v0 + g*t m_vy = qBound(-800.0, m_vy, 900.0); // 限速,防止高速穿模 setY(y() + m_vy * dt); // s = s0 + v*t // 用速度驱动帧动画:上升用第 0 帧,平飞第 1 帧,下落第 2 帧 if (m_vy < -100.0) m_frameIndex = 0; else if (m_vy > 300.0) m_frameIndex = 2; else m_frameIndex = 1; update(); }参数怎么调:m_gravity = 1800配上m_flapImpulse = -520,单次起跳大约上升 75 像素、滞空 0.58 秒,这个比例对应管道间隙 150 像素刚好够穿过去。想做得更「硬核」,把重力提到 2400、冲量提到 -600,滞空变短、操作频率变高;想做成休闲手感,重力降到 1400、冲量 -450。qBound那行是必须的,不加的话在低帧率下一帧位移过大,小鸟会直接穿过 52 像素宽的管道,表现为「明明没碰到却判我撞了」,也就是玩家口中的玄学判定。
setY(y() + ...)用的是QGraphicsItem自身的局部坐标。因为小鸟没有父图元,局部坐标就等于场景坐标,所以这行等价于修改场景 y。如果哪天你把小鸟塞进一个会移动的父节点里,这行就得改成setPos(pos() + QPointF(0, m_vy * dt)),否则坐标会绕晕。
3.2 主循环:QTimer 加固定步长,把帧率玄学摁住
用 QTimer 直接每帧调一次物理函数是最常见的写法,也是最常见的翻车点:机器快一点游戏就快一点,开个录屏软件掉帧了游戏就慢动作。稳妥的写法是「定时器只管触发,物理按固定步长走」:
void GameScene::tick() { // 用单调时钟算真实经过的时间,避免系统时间被调整时出问题 const qint64 nowNs = m_clock.nsecsElapsed(); qreal dt = (nowNs - m_lastNs) / 1.0e9; m_lastNs = nowNs; dt = qMin(dt, 0.05); // 卡顿保护:单帧最多补 50ms,防止一次跳太远 if (m_state != GameState::Running) return; m_acc += dt; while (m_acc >= kFixedStep) { // kFixedStep = 1.0/120.0,固定 120Hz 物理 stepPhysics(kFixedStep); m_acc -= kFixedStep; } update(); // 一次性请求重绘,而不是每次 stepPhysics 都刷 }kFixedStep取 1/120 秒,意味着一秒内物理推进 120 次,跟渲染帧率解耦。显示器 60Hz 时每帧执行两次物理步,144Hz 时有时执行一次、有时不执行,但每秒总步数恒定,游戏速度就不会漂。
dt = qMin(dt, 0.05)这行是保命的。窗口被拖动、系统弹了个对话框、调试器断点停了三秒,nsecsElapsed()会算出一个巨大的 dt,如果不截断,while循环会连续跑几百次物理步,小鸟瞬间落地,玩家看到的是「切回窗口就死了」。截断到 50ms,最多补 6 步,视觉上就是一次轻微卡顿,可以接受。
定时器的创建和启动放在构造函数和startGame()里:
constexpr int kFrameMs = 8; // 约 125Hz 的触发频率,略高于物理步频 m_timer = new QTimer(this); m_timer->setTimerType(Qt::PreciseTimer); // 默认 CoarseTimer 精度只有 5% connect(m_timer, &QTimer::timeout, this, &GameScene::tick);setTimerType(Qt::PreciseTimer)容易被忽略。Qt 默认用Qt::CoarseTimer,为了省电会把定时器误差放宽到 5%,8ms 的定时器实际可能在 8~9ms 之间抖,虽然固定步长已经吸收了大部分影响,但能设精确就设上,尤其是你要在这套代码上做手感调优的时候。
3.3 管道生成与回收:对象池比反复 new/delete 省心
管道每 1.6 秒生成一对,一局玩两分钟就是 75 对,如果每次都new Pipe再delete,内存碎片和场景节点频繁增删会让帧时间抖动明显。用对象池复用:
void GameScene::spawnPipe() { const qreal gap = qMax(120.0, 160.0 - m_score * 0.6); // 间隙随分数收窄 const qreal minTop = 60.0; const qreal maxTop = kGroundY - gap - 160.0; const qreal topH = minTop + QRandomGenerator::global()->bounded(int(maxTop - minTop)); Pipe *p = m_pool.isEmpty() ? new Pipe : m_pool.takeLast(); p->setup(topH, gap); // 重算上下两段的矩形 p->passed = false; // 复用必须重置计分标志 p->setX(sceneRect().right() + 20); addItem(p); // 池里的对象已被 removeItem,这里重新挂回场景 m_pipes.append(p); }p->passed = false是回收时最容易漏的一行。复用对象如果忘了重置,这根管道一出生就被判定为「已经过」,分数凭空加一,玩家会觉得莫名其妙。
回收放在物理步里做,条件是管道完全移出左边界:
for (int i = m_pipes.size() - 1; i >= 0; --i) { Pipe *p = m_pipes.at(i); p->setX(p->x() - m_pipeSpeed * dt); if (!p->passed && p->x() + Pipe::kWidth < m_bird->x()) { p->passed = true; ++m_score; emit scoreChanged(m_score); } if (p->x() + Pipe::kWidth < -20.0) { removeItem(p); // 先从场景摘掉 m_pipes.removeAt(i); m_pool.append(p); // 再放进池,等待下次复用 } }倒序遍历是删除元素的固定套路,正序删会跳元素。removeItem和addItem必须成对,只删不摘会让 QGraphicsScene 里残留一个不可见但仍参与碰撞的图元,表现是「屏幕上没管道,但小鸟在某个位置必死」,这类 bug 不看代码根本查不出来。
3.4 状态机:Ready / Running / Paused / GameOver 四个状态怎么切
游戏状态用一个enum class管住,所有输入和更新都先问一遍「现在是什么状态」:
enum class GameState { Ready, Running, Paused, GameOver };| 状态 | 定时器 | 小鸟物理 | 接受鼠标点击 | 说明 |
|---|---|---|---|---|
| Ready | 停止 | 不推进 | 是,点击后进入 Running | 显示「点击开始」 |
| Running | 运行 | 推进 | 是,点击触发 flap | 正常游戏 |
| Paused | 停止 | 不推进 | 是,点击恢复 | 窗口失焦自动进入 |
| GameOver | 停止 | 不推进 | 是,点击重开 | 显示分数与最高分 |
暂停恢复是状态机里最容易出错的地方。因为时钟对象一直在走,恢复时如果直接用当前时间减去暂停前记下的m_lastNs,就能得到一个几秒甚至几十秒的 dt,即使有 50ms 截断也会瞬间补 6 步物理,玩家看到小鸟「嗖」地掉下去一截。
void GameScene::pauseGame() { if (m_state != GameState::Running) return; m_state = GameState::Paused; m_timer->stop(); } void GameScene::resumeGame() { if (m_state != GameState::Paused) return; m_state = GameState::Running; m_lastNs = m_clock.nsecsElapsed(); // 关键:丢弃暂停期间累积的时间 m_acc = 0.0; // 累加器也一并清零 m_timer->start(kFrameMs); }重开(resetGame())要做的清理更多:清空m_pipes并把它们全部还回池、重置m_score、m_spawnAcc、小鸟位置和速度、地面滚动偏移。清理不彻底的表现是「重开一局,上一局的管道还在屏幕外慢慢移进来」,属于肉眼可见但很掉分的 bug。
4. 碰撞检测与计分:从 boundingRect 粗筛到 shape 精判
判定和计分是玩家感知最强烈的部分。判定松了,穿模过管道会被骂;判定严了,擦边就死也会被骂。Qt 的图形项给了两层判定手段,用哪层、怎么组合,直接决定这套代码的「手感可信度」。
4.1 用 boundingRect 粗筛,用 shape 精判
每个QGraphicsItem必须实现boundingRect(),它是重绘区域和粗碰撞的依据,返回轴对齐矩形:
QRectF Bird::boundingRect() const { return QRectF(0, 0, 48, 36); // 贴图原始尺寸 }管道图元返回的是上下两段的合并矩形:
QRectF Pipe::boundingRect() const { const qreal bottomY = m_topHeight + m_gap; return QRectF(0, 0, kWidth, qMax(bottomY + 200.0, kGroundY)); }真正的判定不直接用 boundingRect。小鸟贴图 48×36,四周有透明留白,用整张图去撞会让玩家觉得「明明还有空隙就死了」。正确做法是提供一个比贴图小一圈的shape():
QPainterPath Bird::shape() const { QPainterPath path; // 椭圆碰撞体,比贴图窄 8px、矮 6px,去掉透明边 path.addEllipse(QRectF(8, 8, 32, 22)); return path; }QGraphicsItem::collidesWithItem(other, mode)在Qt::IntersectsItemShape模式下会同时考虑双方的shape(),精度足够,性能也没问题:
bool GameScene::hitTest() const { // 1. 先做最便宜的边界判定:撞天花板或撞地面 const QRectF birdRect = m_bird->sceneBoundingRect(); if (birdRect.top() < 0.0) return true; if (birdRect.bottom() > kGroundY) return true; // 2. 只有水平方向接近的管道才做精确判定,减少无谓计算 for (Pipe *p : m_pipes) { if (p->x() > birdRect.right() || p->x() + Pipe::kWidth < birdRect.left()) continue; if (m_bird->collidesWithItem(p, Qt::IntersectsItemShape)) return true; } return false; }先做 x 方向区间过滤,再调collidesWithItem。管道最多同时存在四对,这个优化看起来可有可无,但它是一个好习惯:等你把管道数量调大做「密集模式」的时候,这一步能省下大量QPainterPath求交运算。
如果想做到像素级,可以把小鸟的QImage转成 alpha 遮罩,在重叠区域逐点采样:
bool GameScene::pixelHit(const QImage &birdImg, const QPoint &birdPos, const QImage &pipeImg, const QPoint &pipePos) { const QRect overlap = QRect(birdPos, birdImg.size()) .intersected(QRect(pipePos, pipeImg.size())); for (int y = overlap.top(); y <= overlap.bottom(); ++y) { for (int x = overlap.left(); x <= overlap.right(); ++x) { const QPoint bp = QPoint(x, y) - birdPos; const QPoint pp = QPoint(x, y) - pipePos; if (qAlpha(birdImg.pixel(bp)) > 40 && qAlpha(pipeImg.pixel(pp)) > 40) return true; } } return false; }我一般不建议在正式版本里开像素判定:一是每帧两重循环,重叠区域大时开销明显;二是判定过严,玩家贴着管道边缘飞过去会被判死,体验反而更差。椭圆shape()是手感和成本之间比较好的折中点。
4.2 分数判定:别用信号槽去数管道
计分有两种写法:给每根管道连一个「越过小鸟」的信号,或者在物理步里比较 x 坐标。前者看起来优雅,实际上要处理信号连接与断开、对象池复用时旧连接残留,问题很多。用坐标比较最省事,就是 3.3 节里那三行:
if (!p->passed && p->x() + Pipe::kWidth < m_bird->x()) { p->passed = true; ++m_score; emit scoreChanged(m_score); }判据是「管道右边缘越过小鸟中心」,而不是「管道左边缘」。用左边缘会出现「视觉上还没穿过去就加分」的违和感;用小鸟中心而不是小鸟左边缘,是因为玩家心理上把小鸟的头部当成自己的位置。
scoreChanged信号连到场景里的文字图元,避免每帧都去改文字:
connect(this, &GameScene::scoreChanged, this, [this](int s) { m_scoreText->setPlainText(QString::number(s)); m_bestText->setVisible(false); // 开始计分后隐藏「最高分」提示 });文字图元用QGraphicsSimpleTextItem就够,设好字体和颜色后setZValue(100)。要注意setPlainText会改变boundingRect,如果文字是居中对齐的,改完之后位置会偏,得重新setPos一次,或者干脆固定宽度、用QFontMetrics算偏移。
4.3 最高分落地与音效资源
最高分用 QSettings,两行读写,跨平台不用操心路径:
void GameScene::saveBestScore() { QSettings settings("FlyingBird", "FlyingBird"); const int best = settings.value("bestScore", 0).toInt(); if (m_score > best) { settings.setValue("bestScore", m_score); m_best = m_score; } settings.sync(); // 崩溃/强杀前尽量落盘,别省这一行 }音效部分用QSoundEffect,好处是延迟低、支持并发播放,坏处是资源必须提前加载好,而且内置音频后端在某些环境下会静默失败:
void GameScene::initSounds() { m_flapSfx = new QSoundEffect(this); m_flapSfx->setSource(QUrl("qrc:/sounds/flap.wav")); m_flapSfx->setVolume(0.35); if (!m_flapSfx->isLoaded()) qWarning() << "flap.wav 未加载成功,检查 qrc 路径"; }setSource之后立即调isLoaded()多数情况下返回 false,因为加载是异步的,正确的检查时机是连QSoundEffect::statusChanged信号,或者干脆不管,在play()前判断status() == QSoundEffect::Ready。音效对象必须是成员变量,写在函数里new完不保存指针,函数一返回没人引用就被回收,第二次点击就没声音了。
5. 避坑排查:五条把我反复打回原点的坑
这套代码逻辑不复杂,但 Qt 图形视图框架有一堆「不报错但表现错」的行为。下面五条都是我实际调过的,按现象、原因、解决写,遇到同款症状可以直接对号入座。
5.1 画面与坐标系上的坑
现象一:窗口拉大后管道越跑越偏,或者画面整体向左滚动。
原因:没调setSceneRect,或者在某处调了setSceneRect(item->sceneBoundingRect()),导致场景边界随内容变化。QGraphicsView 发现场景比视口大就会自动出滚动条,坐标系整体平移。
解决:在视图构造里写死setSceneRect(0, 0, 480, 720),并且全工程只在这一处设置。需要自适应窗口就改fitInView,不要动场景矩形。
现象二:小鸟边缘有锯齿,管道接缝处有一道白线。
原因:QPainter::SmoothPixmapTransform没开,图片被非整数倍缩放时按最近邻采样。管道接缝白线是因为管身和管帽两张图各自独立定位,setPos用了浮点数,接缝处出现半个像素的空隙。
解决:开setRenderHint(QPainter::SmoothPixmapTransform, true);管身和管帽合并成一张图,或者把两者的setPos都取整(std::round),接缝就消失了。
现象三:暂停再继续,小鸟瞬间下坠一截,甚至直接死掉。
原因:暂停期间QElapsedTimer还在走,恢复后算出的 dt 包含整段暂停时间。虽然主循环里有 50ms 截断,但截断后仍会补 6 个物理步,120Hz 下相当于 50ms 的自由落体,肉眼可见。
解决:resumeGame()里重置m_lastNs = m_clock.nsecsElapsed()并把m_acc清零,见 3.4 节的代码。凡是「停掉再启动」的循环,重启时都要重置时间基准,这是血泪经验。
5.2 逻辑与资源上的坑
现象四:屏幕没管道,但小鸟在某个固定位置必定死亡。
原因:管道移出屏幕后直接delete或者只从m_pipes列表里移除、却忘了removeItem,图元还挂在场景里,位置停在屏幕外,但collidesWithItem依然会命中。更隐蔽的情况是对象池复用时只调了setPos没调addItem。
解决:回收流程固定成三步——removeItem摘场景、从容器移除、放回池;复用流程也固定成三步——从池取出、setup重设尺寸和passed、addItem挂回场景。这两组三步各写成一个函数,别在两处各写一遍。
现象五:音效第一次响,第二次不响;或者打包成 exe 后图片全不见了。
原因:音效对象是函数内的局部变量,函数返回即析构,指针悬空;图片路径写的是相对路径images/bird_0.png,在 IDE 里跑的时候工作目录恰好是工程根目录,所以能找到,发布后工作目录变了就找不到。
解决:音效对象声明为成员,在场景构造时统一初始化;图片音效全部走 qrc 资源路径:/images/bird_0.png,并且在改完 qrc 文件后执行一次 qmake(Qt Creator 里是「执行 qmake」),只编译不 qmake 的话新加的资源不会被打进二进制。
补充一条容易被当成 bug 的正常现象:QSoundEffect在没有可用音频设备的机器上会打一行QAudioDeviceInfo相关警告,但不影响程序运行。如果你的目标是服务器或容器里跑,直接用QSoundEffect::status()判断,不可用就整体关掉音效,别让警告刷屏。
6. 二次开发:换皮、调难度和加一套可复现的验证
这份代码最舒服的地方是参数集中,改手感不用动逻辑。我把所有影响游戏性的数值都抽到了GameScene的成员里,换皮和加难度基本是改数字和换图片两件事。
6.1 难度曲线参数化
初始版本是恒定速度、恒定间隙,玩到 30 分就没意思了。把三个量做成随分数变化的曲线:
| 参数 | 初始值 | 每得 1 分变化 | 上下限 |
|---|---|---|---|
管道移动速度m_pipeSpeed | 120 px/s | +1.2 | 60 ~ 260 |
管道间隙gap | 160 px | -0.6 | 120 ~ 160 |
生成间隔m_spawnInterval | 1.6 s | -0.008 | 1.0 ~ 1.6 |
void GameScene::updateDifficulty() { m_pipeSpeed = qBound(60.0, 120.0 + m_score * 1.2, 260.0); m_spawnInterval = qBound(1.0, 1.6 - m_score * 0.008, 1.6); // 间隙在 spawnPipe() 里按当前分数算,避免影响已经在屏幕上的管道 }注意间隙不能在updateDifficulty里全局改,否则已经在场的管道会突然变窄,玩家正在穿的那根会瞬移。间隙只在spawnPipe()里对新生成的管道生效,这样难度是平滑递进的。
换皮更简单:把resources/images/里的三帧小鸟换成你自己的图(建议统一 48×36,保持boundingRect不变,否则物理参数要跟着调),管道图换成任意竖条贴图,改一下 qrc 重新 qmake 就行。如果新图尺寸变了,记得同步改Bird::boundingRect()和Bird::shape()里的椭圆区域,这两个值不跟着改,判定和手感会立刻错位。
6.2 加一套确定性回放,验证物理改动
调物理参数最痛苦的地方是「改了重力,感觉好像好一点,又好像没有」。我一般会加一个轻量的录制回放:固定随机种子,记录每次点击的时间戳,重放时按时间戳喂输入,看结果是否一致。
struct InputEvent { qint64 tMs; }; // 相对于开局的时间戳 void GameScene::startReplay(quint32 seed, const QVector<InputEvent> &events) { m_rng = QRandomGenerator(seed); // 替代 global(),保证管道高度可复现 m_replayEvents = events; m_replayIndex = 0; resetGame(); startGame(); }管道生成处从QRandomGenerator::global()->bounded(...)换成m_rng.bounded(...),整局游戏的随机序列就被种子钉死了。改完重力之后,用同一份输入回放一次,如果小鸟在第 17 根管道处死亡,改前改后一致,说明改动没有引入意外;如果改动后提前死了,那大概率是物理参数真的变了,而不是玄学。
这套东西我一共写了不到八十行,但省下的时间是几十倍。以前调手感全靠反复手玩,改一个数重开十把,现在录一段输入回放三遍,差异一目了然。从那以后我每次动物理常量之前都强制走一遍录制回放,哪怕是改一个qBound的上限,也先确认旧回放的结果没变,再去看新参数带来的变化。希望这套拆法能帮到你,把手里这只小鸟改成自己的版本。
本文还有配套的精品资源,点击获取