☰
Qt飞机大战源码解析:Graphics View架构与碰撞检测实战
2026/10/21 8:04:37 网站建设 项目流程

简介:基于Qt与C++实现的飞机大战小游戏项目,面向Qt初学者和2D游戏开发入门者,适合课程设计、课后练手或项目框架参考,可系统学习图形视图框架、定时器动画、键盘响应、碰撞检测及音频播放等关键机制。项目借助QGraphicsView搭建场景,并将玩家飞机、子弹、敌机等对象封装为QGraphicsItem派生类,整体采用C++面向对象设计,结构清晰。7z压缩包内共1017个文件,约42.88MB,包含大量png/jpg图片素材、mp3/ogg音频、cpp/h源码、qrc资源与ui界面文件,另含exe可执行程序和doc实践报告,便于直接运行、阅读与对照修改。目前已有1024人学习下载,完成度较高。结合完整源码、素材与实践报告,可梳理对象类设计、场景刷新、子弹发射、敌机生成、碰撞判定与计分逻辑等实现路径;资源目录中源码、素材与文档分开存放,参考排错记录能更有把握地完成课程设计或向扩展版游戏迭代。

1. 从课设到面试晒代码:这份Qt飞机大战包给你省掉的不只是一坨重复代码

用Qt写飞机大战是C++图形编程里绕不过去的一个练手项目。我见过不少人花了两周从零搭框架,结果一半时间耗在“为什么画面不刷新”“为什么子弹一多就卡”这种跟玩法无关的事情上。这套基于Qt的飞机大战源码,把场景管理、主循环、碰撞检测、对象生命周期全串好了——你拿到的不是几坨孤立的类,而是一条能跑通整局的游戏链路。适合正在做课程设计的同学,也适合想把C++游戏逻辑讲清楚放在简历上的开发者。下面对照着工程文件把关键设计逐个拆开,先说架构,再说怎么改。

2. 为什么选Qt写飞机大战:场景-视图架构与主循环的底层逻辑

用C++做2D小游戏的方案其实不少,SDL、SFML、Qt都能跑,选Qt的原因很直接:飞机大战里最麻烦的不是画图,而是维护一堆对象的坐标、层级和碰撞关系。Graphics View框架把这三件事全部收编了,scene负责管对象,view负责显示,item负责自己的行为。这样拆下来,游戏逻辑和渲染逻辑之间的干扰就压到了最低。

2.1 QGraphicsView这套玩法:三个类一台戏

场景(QGraphicsScene)、视图(QGraphicsView)、图元(QGraphicsItem)对应到游戏里,就是“世界”“窗口”和“实体”。场景是对象仓库,所有飞机、子弹、爆炸特效都addItem进去;视图是渲染窗口,它只负责把场景里可见部分画出来;图元则是玩家飞机、敌机、子弹这些具体的东西,位置、形状、自己的行为都归它自己管。

工程里我一般会先建一个GameWidget类,直接继承QGraphicsView,然后在构造函数里把场景和窗口参数一次配好:

// GameWidget 构造函数里的初始化片段 scene = new QGraphicsScene(this); scene->setSceneRect(0, 0, 480, 640); // 游戏世界的大小 setScene(scene); // 把场景挂到视图上 setFixedSize(480, 640); // 窗口固定大小,避免拖拽变形 setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setRenderHint(QPainter::Antialiasing);

setSceneRect这一步很关键,后面判断子弹越界、敌机随机出生点都要拿它当基准。我见过有人跳过这步,结果场景尺寸默认跟随item区域变化,游戏世界越长越大,最后敌机越生成越分散。窗口固定大小这点也别省,飞机大战是固定视角游戏,允许用户拉拽窗口会让坐标逻辑失去基准。滚动条关掉是一样道理,否则场景里的item超出视口时,view会自动出滚动条,视觉上就是画面乱跳。

2.2 游戏主循环:QTimer是心脏,别用while(true)

Qt是事件驱动框架,直接用while(true)写游戏循环会卡死整个事件循环,窗口拖不动、按钮点不了。正统做法是拿一个QTimer当心跳:

QTimer *timer = new QTimer(this); timer->setInterval(16); // 约60FPS,感觉掉帧再微调 connect(timer, &QTimer::timeout, this, &GameWidget::updateFrame); timer->start();

setInterval的单位是毫秒,16表示每16毫秒触发一次timeout。数值取舍上,20毫秒对应50FPS,日常看不会太难受;10毫秒对应100FPS,肉眼感知不明显但CPU占用会涨。我一般先用16,再根据机器实际情况调。

updateFrame是个空壳函数,里面干的事就是“让场景里的所有对象走一步”。QGraphicsScene自带一个advance(),会通知场景里所有item执行advance(phase),所以很多工程并不会自己遍历所有对象,而是让各item各自响应advance阶段。这里想提醒一句:QTimer只是软实时定时器,实际操作系统的调度影响,跑出来的实际帧间隔会有几毫秒抖动。飞机大战这种休闲游戏完全够用,但要明白它不等于一个精确的帧时钟,别在它上面挂严格物理计算。

2.3 为什么不用QPainter硬画:当你要做碰撞检测时就想明白了

拿QPainter直接画飞机,思路完全没问题,小规模Demo甚至更简单。但它有个明显的天花板:所有飞机、子弹的位置、尺寸、存活状态都得自己用数组或链表维护,每帧手动遍历一遍画出来,同时还得自己写碰撞判断。Graphics View把这块全部内建了,每架飞机、每颗子弹就是一个对象,查它跟谁撞了,一条collidingItems()直接给你列出来。

对比项QPainter 直接绘制Graphics View 框架
对象管理自己维护坐标列表每个实体是独立 item
碰撞检测手写矩形/圆计算collidingItems() 内置
渲染优化全量重绘按变化区域局部刷新
内存释放手动遍历清理removeItem 后由 item 自管

选择边界同样清楚:如果只是做一个“按空格发射看烟花”的演示,QPainter可能更快;一旦进入多敌机、多子弹、带掉落物的完整玩法,Graphics View能把对象生命周期的问题从“游戏逻辑”里抽出来,代码结构清晰得多。这套飞机大战包走的正是后者,后面几章碰撞检测全部建立在collidingItems的基础上。

3. 把玩家飞机和子弹跑起来:键盘响应与对象管理的标准写法

这一章从玩家控制链路切进去,先把“飞机能动、子弹能发”这条主线跑起来。资源包里最核心的可复用逻辑也集中在这里:实体定义、事件响应、对象回收,三条线串起来就是一个能玩的骨架。

3.1 玩家飞机的实体:一张图、一个碰撞矩形

继承哪个Item最省事,答案通常是QGraphicsPixmapItem。飞机不像形状那么简单,直接用贴图承载外观,避免手动画图。工程里资源一般放在qrc文件里,以“:/”开头引用:

class Player : public QGraphicsPixmapItem { public: Player() { // 图片放在资源文件里,路径以 qrc 中的 / 开头 setPixmap(QPixmap(":/assets/player.png")); setPos(240 - 25, 640 - 80); // 初始位置:底部居中 // 可聚焦,才能接收键盘事件 setFlag(QGraphicsItem::ItemIsFocusable); setFocus(); } QRectF boundingRect() const override { // 贴图有透明边,碰撞矩形收紧一些更公平 return QRectF(4, 4, 44, 54); } int speed = 6; };

boundingRect这里藏着一个小门道。默认情况下QGraphicsPixmapItem的boundingRect就是图片尺寸,但很多飞机素材带一圈透明边缘,肉眼看着没撞上,系统判定已经撞上了。把碰撞矩形往里收紧几个像素,玩家体感会舒服很多。setFocus这一步尤其容易被漏,漏了之后键盘事件永远到不了这个item,按键失灵的问题到第5章还会专门展开。

3.2 键盘移动与子弹发射:事件分发的两种路子

键盘事件可以在View层重写keyPressEvent,也可以让item自己接收。这套工程走的是View层统一收集再分发的路子,逻辑集中在一个地方,好调试:

void GameWidget::keyPressEvent(QKeyEvent *event) { if (!player) return; switch (event->key()) { case Qt::Key_Left: // 限制在场景左边界内,防止飞出窗外 player->setPos(qMax(scene->sceneRect().left(), player->x() - player->speed), player->y()); break; case Qt::Key_Right: player->setPos(qMin(scene->sceneRect().right(), player->x() + player->speed), player->y()); break; case Qt::Key_Space: shootBullet(); break; default: break; } }

移动用setPos而不是moveBy,好处是可以顺手做边界钳制。qMax和qMin保证了玩家飞机不可能越过场景左右边界,这条逻辑省掉了后面一堆“飞机不见了”的bug。shootBullet里创建子弹并把它放到玩家头顶:

void GameWidget::shootBullet() { Bullet *bullet = new Bullet(); qreal bulletX = player->x() + player->boundingRect().width() / 2 - 3; // 让子弹从机头中心射出 bullet->setPos(bulletX, player->y() - 20); scene->addItem(bullet); }

那3像素是为了补偿子弹自身宽度的一半,这样子弹看起来是从枪口正中央飞出去的,而不是歪在一边。如果你换了一张不对称的飞机贴图,这个补偿值要怎么算?最稳妥的方法是把玩家宽度除以二再减去子弹宽度除以二,写成一个通用表达式,不要像我刚开始那样手写死数字,换个素材就翻车。

3.3 子弹的飞行与回收:在advance里移动,在越界时自毁

子弹本身是一个简单图元,加上一个advance方法让它每帧往上跑:

class Bullet : public QGraphicsRectItem { public: Bullet() { setRect(0, 0, 6, 14); setBrush(QBrush(Qt::yellow)); // 先用色块占位,后期换贴图 } void advance(int phase) { if (phase == 0) return; // phase 0 留给碰撞检测 moveBy(0, -12); // 每帧向上12像素 if (y() < -20) { // 飞出上边界 scene()->removeItem(this); delete this; } } };

advance是Graphics View里的两阶段通知机制:阶段0用于检测和计算,阶段1用于实际移动。很多新手把两种逻辑混在一个phase里,结果出现“物体自己把自己移到场景外才被删”的尴尬情况。越界阈值用-20而不是0,是为了让子弹完全消失在视野外再回收,避免在窗口边缘出现一闪而过的残影。子弹速度12是一个偏快的初始值,调整它时要注意和定时器间隔联动:间隔16毫秒、速度12,意味着每秒向上飞750像素,穿越640像素高的场景大约需要0.85秒。

4. 敌机与碰撞检测:难度曲线靠这三个参数撑起来

敌机生成、碰撞判定、得分反馈,这三个模块决定了游戏玩起来是“爽”还是“憋屈”。我拆这份工程时最大的感受是:难度平衡根本不是玄学,就是三个参数在互相配合。

4.1 敌机生成器:随机数与出怪节奏的三段式控制

敌机每隔一段时间从场景顶部冒出来,x坐标随机,y坐标固定从负值入场,这样飞出来时有一个“入场”过程,而不是凭空出现在玩家视野里:

void GameWidget::spawnEnemy() { Enemy *enemy = new Enemy(); int x = QRandomGenerator::global() ->bounded(int(scene->sceneRect().width())); enemy->setPos(x, -enemy->boundingRect().height()); scene->addItem(enemy); }

QRandomGenerator::global()是Qt 5.10以后推荐的随机数写法,不需要你手动srand,也不用担心多线程下的种子冲突。生成间隔的控制才是难度曲线的核心:

void GameWidget::setupSpawnTimer() { spawnTimer = new QTimer(this); spawnTimer->setInterval(900); // 初始间隔900毫秒 connect(spawnTimer, &QTimer::timeout, this, &GameWidget::spawnEnemy); spawnTimer->start(); connect(spawnTimer, &QTimer::timeout, this, [this]() { // 得分越高,生成间隔越短,但保留下限防止弹幕过密 int interval = 900 - score / 50 * 30; spawnTimer->setInterval(qMax(300, interval)); }); }
难度参数初始值变化方向下限
生成间隔900ms随得分升高缩短300ms
敌机速度2随波次提升8
同屏数量上限5逐步放开15

这三个参数是一个互相制约的三角:生成间隔决定敌机出现的频率,速度决定单架敌机的威胁程度,同屏上限决定场面混乱度的封顶值。只调间隔不调速度,玩家会觉得“敌人变多了但都好躲”;只调速度不调间隔,画面上乱七八糟却打不到几下。

4.2 碰撞检测:矩形包围盒就够了,别一上来就做像素级

碰撞检测这里我见过不少过度设计:有人一上来就抠像素级透明通道碰撞,结果游戏里几十颗子弹和十几架敌机互相检测,帧率直接崩了。飞机大战这种快节奏游戏,矩形包围盒足够。

void GameWidget::checkCollisions() { // 拿一份场景对象快照,不能在遍历过程中直接删 QList<QGraphicsItem*> allItems = scene->items(); foreach (QGraphicsItem *bullet, allItems) { if (!bullet || bullet->type() != Bullet::Type) continue; QList<QGraphicsItem*> hits = bullet->collidingItems(); foreach (QGraphicsItem *hit, hits) { if (hit->type() == Enemy::Type) { scene->removeItem(hit); delete hit; scene->removeItem(bullet); delete bullet; emit enemyDestroyed(); // 得分信号往外抛 break; } } } }

两个foreach都用了一份快照列表,这是最关键的一步。直接在scene->items()返回的列表上边遍历边删除,迭代器会失效,闪退是早晚的事。collidingItems默认使用shape()作为碰撞外形,QGraphicsRectItem的shape就是矩形;如果用了带透明边的贴图,记得把shape也收紧,否则会出现“子弹离飞机还有半个身位就算命中”的判定。

type()的用法也值得说一句:每个自定义item类都要实现一个静态常量Type成员,并重写type()返回它。这样在碰撞检测里可以快速过滤对象类型,而不是靠qgraphicsitem_cast或者动态转类型判断,性能差距在对象一多时非常明显。

4.3 信号槽把得分与生命串起来:游戏状态机的雏形

碰撞结果不能直接写在UI上,而是用信号事件往外通知。这套工程的思路是GameWidget定义两个信号,一个管加分一个管扣命:

class GameWidget : public QGraphicsView { Q_OBJECT signals: void enemyDestroyed(); void playerHit(); };

扣命信号从碰撞检测里发出去,GameWidget在构造函数里统一绑定:

connect(this, &GameWidget::enemyDestroyed, this, [this]() { score += 10; scoreText->setPlainText(QString("得分: %1").arg(score)); }); connect(this, &GameWidget::playerHit, this, [this]() { lives--; if (lives <= 0) { QMessageBox::information(this, "结束", QString("最终得分: %1").arg(score)); close(); } });

把得分和生命挂在信号上而不是散落在碰撞函数里,好处是后续加音效、加特效时不需要再改动碰撞检测代码,直接connect新的处理函数进去就行。这其实是游戏状态机的一个原始雏形:逻辑事件和展示反馈完全解耦。

5. 避坑排查:从编译报错到运行时玄学的五个高频问题

这份工程我自己跑的时候踩了不少坑,有的是Qt版本差异,有的纯粹是抽象层次没想清楚。按下述问题挨个对一遍,能帮你省下大把查资料的时间。

5.1 编译期:模块配置与头文件的高频雷区

第一条:QGraphicsView头文件找不到。

现象:凡是用了QGraphicsScene、QGraphicsView相关头文件的.cpp,编译直接报错“QGraphicsScene: No such file or directory”。

原因:Graphics View从Qt 5开始归属于widgets模块,工程.pro里只写了core和gui,头文件搜索路径里根本没有widgets目录。

解决:把.pro文件改成下面这样,然后重新qmake。

QT += core gui widgets

第二条:引入音效报模块缺失。

现象:代码里用了QSoundEffect之后,编译或链接阶段提示找不到这个类。

原因:Qt 6对multimedia模块做了拆分,很多人以为.pro里写一个QT += multimedia就够了,实际对应类在不同版本里归属有差异。

解决:按你本地Qt版本的官方模块说明补全multimedia相关项,并确认信号槽连接里没有把QSoundEffect的播放函数当普通成员函数来绑定。

5.2 运行期:闪退、卡顿、按键失灵的三条记录

第三条:遍历场景时删item导致间歇性闪退。

现象:敌机被击毁的瞬间,程序崩溃,报错有时是“pure virtual method called”,有时直接无响应。这种崩溃时好时坏,跟子弹和敌机的排列顺序有关。

原因:在scene->items()返回的列表上一边遍历一边delete item,迭代器失效,触碰已释放内存时触发未定义行为。这是典型的“一运行就没事,一调试就崩溃”的玄学问题。

解决:先把所有需要删除的对象收集到一个临时列表,遍历结束后统一销毁。删除时固定走scene()->removeItem(item); delete item;两步,不省任何一步。

第四条:程序启动后按键没反应,鼠标点一下窗口又好了。

现象:游戏窗口正常显示,但按方向键飞机纹丝不动,点击窗口后控制又恢复正常。

原因:QGraphicsView默认不接受键盘焦点,新窗口启动时焦点处于“悬空”状态,键盘事件没有投递目标。

解决:在GameWidget构造函数里加一行setFocusPolicy(Qt::StrongFocus)。如果让某个item直接接收键盘,记得给它setFlag(QGraphicsItem::ItemIsFocusable)并调用setFocus()。

第五条:子弹一多就卡顿。

现象:后期子弹和敌机数量上来,画面明显掉帧,飞机移动出现残影。

原因:QGraphicsView默认的viewport更新策略是重绘整个视口,每次update都把全屏重新画一遍;加上碰撞检测里频繁调用collidingItems,CPU开销直线上升。

解决:把viewport更新模式改成只刷新变化区域。

setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate);

顺手把定时器间隔从10毫秒回调到16毫秒,肉眼基本无感知,CPU占用会明显下降。

6. 进阶验证:音效、道具与关卡数据分离的三个改动方向

这套飞机大战跑通之后,下一步不是急着加新玩法,而是先把三个最实用的方向做进去。

第一个方向是音效接入。射击音和爆炸音用QSoundEffect就够,关键是音效文件的加载放在初始化阶段,不要在碰撞检测函数里动态加载文件,否则第一声枪响会卡一下。我一般会在GameWidget里预加载两个QSoundEffect成员,发射和命中各一个,播放时只调play()。

第二个方向是道具系统。设计一个Collectible类继承QGraphicsPixmapItem,在地图里随机生成,玩家碰到后走单独的碰撞分支。道具类型用枚举控制,加分、加速、护盾各是一个分支,再配一张掉落概率表。

第三个方向是关卡数据分离。把敌机生成间隔、速度上限、掉落概率这些数值从代码里抽出来,放到一个JSON或配置文件里,关卡递增只改数据不动逻辑。这样调平衡不用重新编译工程,调完参数直接重启看效果。

验证方法也很简单:在updateFrame里打印一帧耗时,确认稳定在16毫秒左右;再用Qt Test写一个模拟按键序列,连续发送左移和空格键,断言玩家坐标和子弹数量符合预期。这些验证做一轮,后面加功能就不容易把核心玩法改坏。

以前我做这玩意的时候,最惨的一次是把所有逻辑塞进一个GameWidget里,改一个参数要翻三处地方。从那以后我每次动手前都强制自己先画一张类关系图,写清楚哪个类负责哪些状态,再开始编码——这份工程也是这样组织的,你改起来会直观很多。希望看到这里能给你省下几个通宵。

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

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

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

立即咨询