ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt飞机大战源码解析:QGraphicsView游戏循环与碰撞检测实战

Qt飞机大战源码解析:QGraphicsView游戏循环与碰撞检测实战

简介:这份资源是面向Qt初学者与游戏开发入门者的「飞机大战」完整项目源码包,围绕Qt框架在经典空战游戏中的实际应用展开,可用于课程设计、自学练手或二次开发参考。压缩包共223个文件,约6.21MB,以105个C++头文件与32个cpp源文件构成核心逻辑,配合60个png图像、5个wav与2个ogg音频、4个glsl着色器及3个ui界面文件,另有qrc资源、pro工程与dll、lib等依赖库,覆盖渲染、音效与构建配置。内容预览可见玩家、敌机、AI对战与PVE等模块划分,配合信号槽事件处理、QPainter绘图与多线程思路,便于读者梳理游戏对象组织与资源加载方式。目前已有281人学习,适合希望从零理解Qt游戏项目结构、对照源码查漏补缺的开发者参考。

1. 从一份 Qt 飞机大战源码说起:它到底能帮你练什么

很多人第一次接触 Qt 都是从一个小游戏开始的,飞机大战就是最典型的那个。这份qt飞机大战游戏.zip解压后是一套完整的 Qt 桌面游戏工程,打开 Qt Creator 就能编译运行,玩家控制战机在窗口里移动、自动或手动发射子弹、击落不断下落的敌机、统计分数和生命值。它不依赖任何第三方游戏引擎,纯靠 Qt 的QGraphicsView图形视图框架加定时器驱动,把游戏循环、碰撞检测、资源加载、事件响应这几件事用最朴素的方式串了一遍。适合两类人:一是刚学完 Qt 基础控件、想找个能跑起来的完整项目练手的开发者;二是想搞清楚「一个桌面小游戏从窗口到帧循环到底怎么组织」的熟手。下面我按自己拆这份源码的顺序,把结构、跑法、核心实现和二次开发的路子讲清楚。

2. 工程结构与技术选型:一个 QGraphicsView 撑起的游戏骨架

2.1 目录与文件组织

解压后典型的工程结构大致是这样(不同版本命名略有出入,但分层逻辑一致):

qt飞机大战游戏/ ├── planeGame.pro # qmake 工程文件 ├── main.cpp # 程序入口 ├── mainwindow.h/.cpp/.ui # 主窗口,承载游戏视图 ├── gameview.h/.cpp # 游戏视图,负责场景与帧循环 ├── plane.h/.cpp # 玩家战机 ├── enemyplane.h/.cpp # 敌机 ├── bullet.h/.cpp # 子弹 ├── explosion.h/.cpp # 爆炸特效 ├── resources.qrc # 图片、音效资源索引 └── images/ # 战机、敌机、子弹、背景图

.pro文件里通常只挂QT += core gui widgets,没有额外模块依赖,这也是它能「下载即编译」的原因。资源统一走.qrc,好处是打包成 exe 后图片不会丢,坏处是改图必须重新编译资源,这点后面避坑章节会细说。

2.2 为什么选 QGraphicsView 而不是 QWidget 手绘

这是整个项目最值得先讲清的技术决策。飞机大战里同时存在几十个会动的对象(玩家、敌机、子弹、爆炸),如果用普通QWidget加paintEvent手绘,每一帧都要自己遍历所有对象算坐标再画,碰撞检测也得手写矩形相交。而QGraphicsView+QGraphicsScene+QGraphicsItem这套框架天生就是干这个的:

  • 每个游戏对象继承QGraphicsItem(或QGraphicsPixmapItem),自己管自己的绘制;
  • 场景负责管理所有 item 的层级和刷新;
  • 框架自带collidingItems(),碰撞检测一行搞定;
  • 视图负责坐标变换和滚动,窗口缩放不用改游戏逻辑。

常见做法是让GameView继承QGraphicsView,内部持有一个QGraphicsScene,所有对象addItem到场景里。这样游戏逻辑和渲染就解耦了,改玩法不用碰绘制代码。

2.3 帧循环:QTimer 驱动的固定步长

Qt 没有内置游戏循环,这份工程用的是最直接的方案——QTimer定时触发update()或自定义的advance():

// gameview.cpp 构造函数里初始化帧定时器 frameTimer = new QTimer(this); connect(frameTimer, &QTimer::timeout, this, &GameView::gameLoop); frameTimer->start(16); // 约 60 FPS,16ms 一帧

16这个数字不是随便写的,1000ms / 60 ≈ 16.6,取 16 是为了贴近 60 帧。参数含义很直白:间隔越小越流畅,但 CPU 占用越高;间隔太大(比如 50ms)子弹和敌机就会「跳帧」式移动,碰撞检测容易漏判。我一般会在gameLoop()里做三件事:更新所有对象位置、检测碰撞、清理越界对象。这个顺序不能乱,先移动再检测,否则会出现「子弹穿过敌机却没打中」的玄学问题。

3. 核心模块拆解:从战机移动到碰撞判定的一条完整链路

3.1 玩家战机:键盘事件与边界约束

玩家战机的移动靠重写keyPressEvent,按下方向键时改坐标,同时限制不能飞出窗口:

// plane.cpp void Plane::keyPressEvent(QKeyEvent *event) { switch (event->key()) { case Qt::Key_Left: if (x() > 0) setPos(x() - speed, y()); // speed 为每帧位移 break; case Qt::Key_Right: if (x() < scene()->width() - pixmap().width()) setPos(x() + speed, y()); break; case Qt::Key_Space: emit fireRequested(pos()); // 通知视图生成子弹 break; } }

这里有两个关键点。第一,speed是每帧位移而不是每秒位移,因为帧率固定,直接按帧加就行;如果哪天改成可变帧率,就得乘上 deltaTime,否则高刷屏上飞机会快得离谱。第二,开火用信号fireRequested抛给视图处理,而不是战机自己new Bullet,这样子弹的创建、回收都集中在视图层,避免对象之间互相持有指针导致内存泄漏。这是 Qt 信号槽在游戏里最实用的一个场景。

3.2 子弹与敌机:对象池思路的简化实现

子弹是高频创建销毁的对象,一秒可能生成十几颗。这份工程里通常是每次开火new Bullet加到场景,飞出边界后delete。写法简单,但频繁 new/delete 会有性能抖动。我一般会改成对象池:

// 简化的子弹池,避免频繁分配 Bullet* BulletPool::acquire() { if (!freeList.isEmpty()) { Bullet *b = freeList.takeLast(); b->setVisible(true); b->setActive(true); return b; } return new Bullet(); // 池空才新建 } void BulletPool::release(Bullet *b) { b->setVisible(false); b->setActive(false); freeList.append(b); // 回收不销毁 }

setActive(false)的子弹留在场景里但不参与碰撞和移动,gameLoop里遍历时跳过即可。参数上要注意:池的初始容量按「屏幕最多同时存在多少子弹」估,一般 30~50 够用;回收时一定要重置位置和速度,否则复用的子弹会带着上一发的状态乱飞,这个坑我踩过。

3.3 碰撞检测:collidingItems 的正确用法

碰撞判定是整个游戏的核心逻辑,用框架自带的接口最省事:

// gameview.cpp 的 gameLoop 中 void GameView::checkCollisions() { // 子弹 vs 敌机 foreach (Bullet *bullet, bulletList) { QList<QGraphicsItem*> hits = bullet->collidingItems(); foreach (QGraphicsItem *item, hits) { EnemyPlane *enemy = qgraphicsitem_cast<EnemyPlane*>(item); if (enemy && enemy->isActive()) { enemy->hit(); // 扣血或直接击毁 bulletPool.release(bullet); spawnExplosion(enemy->pos()); // 爆炸特效 score += 10; break; // 一颗子弹只打一个 } } } }

collidingItems()返回的是与当前 item 包围盒相交的所有 item,所以必须用qgraphicsitem_cast过滤类型,否则子弹会「撞上」背景图。另外break不能省,一颗子弹打中一架敌机后就该失效,不然会一穿多。参数上,QGraphicsItem的包围盒默认是boundingRect(),如果图片有透明边,实际碰撞会比视觉上「早一点」,想要精确可以重写shape()返回椭圆或路径。

3.4 分数、生命与游戏状态机

游戏状态一般用枚举管理:Ready / Running / Paused / GameOver。gameLoop开头先判断状态,非Running直接 return,这样暂停和结束逻辑就统一了:

void GameView::gameLoop() { if (state != Running) return; updatePlanes(); // 移动玩家、敌机、子弹 checkCollisions(); // 碰撞判定 cleanupOutOfBounds(); // 回收越界对象 updateHud(); // 刷新分数、生命显示 }

生命值归零时把state置为GameOver,停掉帧定时器,弹出结束界面。这种「一个状态变量控制全局」的写法比到处if (gameOver) return干净得多,二次开发加「关卡」「Boss 阶段」时也只需扩展枚举。

4. 编译运行与资源打包:从源码到能双击的 exe

4.1 用 Qt Creator 跑通第一遍

拿到工程先别急着改代码,按这个顺序走一遍确认环境没问题:

  1. 用 Qt Creator 打开planeGame.pro,选择已安装的 Kit(Qt 5.12 以上或 Qt 6 均可,注意 Qt 6 部分 API 有变动);
  2. 执行「构建 → 运行」,首次编译会生成build-xxx目录;
  3. 如果报QGraphicsItem: No such file,检查.pro里是否有QT += widgets;
  4. 运行后窗口出现但黑屏,多半是资源没加载,看.qrc是否被正确引用。

命令行编译也可以,适合没有 IDE 的环境:

qmake planeGame.pro make # Windows 下用 mingw32-make 或 nmake ./planeGame # 生成的可执行文件

qmake负责根据.pro生成 Makefile,make编译链接。如果提示找不到qmake,说明 Qt 的 bin 目录没进 PATH,用 Qt 自带的命令行工具打开终端最稳。

4.2 发布:windeployqt 补齐运行库

直接拷 exe 到别的机器大概率打不开,因为缺 Qt 的 dll。Qt 提供了windeployqt自动收集依赖:

windeployqt --release planeGame.exe

执行后会在 exe 同目录生成platforms/、imageformats/等文件夹和一堆 dll。注意--release要和编译模式对应,Debug 版 exe 配 Release 库会崩。打包时把整个目录压缩分发,别只发 exe。这一步是新手最容易翻车的地方——本机跑得好好的,发给别人就报「找不到 Qt5Core.dll」,血泪经验。

4.3 资源与配置的检查清单

检查项常见问题处理方式
.qrc资源路径图片显示为空白路径大小写与文件名严格一致
图片格式jpg 透明背景变黑用 png 保留 alpha 通道
音效播放无声音确认multimedia模块已添加
窗口尺寸游戏区域错位固定setFixedSize或按场景坐标换算

5. 避坑与排查:那些编译通过但玩起来不对劲的问题

5.1 敌机生成后不动或瞬间消失

现象:敌机出现在顶部但原地不动,或者一出现就没了。原因通常是敌机的移动逻辑写在构造函数里只执行一次,或者y()判断越界的条件写反(y() > height写成了<)。解决:把移动放到advance()或gameLoop里每帧执行,越界判断用if (y() > scene()->height()) release()。另外确认敌机setFlag(ItemIsMovable)没被误开,否则会和手动设坐标打架。

5.2 子弹打不中,视觉上明明碰到了

现象:子弹贴着敌机飞过去却不触发碰撞。原因多半是collidingItems()的调用时机在移动之前,或者子弹的boundingRect()没跟着setPos更新。解决:确保gameLoop里先updatePlanes()再checkCollisions();如果自定义了 item,重写boundingRect()时返回的矩形要覆盖整个 pixmap,且prepareGeometryChange()在尺寸变化时调用。

5.3 游戏越玩越卡,内存持续上涨

现象:玩几分钟后帧率下降,任务管理器内存飙升。原因:子弹和敌机只new不delete,越界后只是移出视野没销毁。解决:cleanupOutOfBounds()里对越界对象scene()->removeItem(item); delete item;,或者改用 3.2 节的对象池。用对象池时记得从活动列表移除,否则遍历时还在处理已回收的对象。

5.4 改了图片重新编译却不生效

现象:替换images/下的图,编译运行还是旧图。原因:资源被打进 qrc 编译成二进制,直接换文件不会触发重新编译。解决:在 Qt Creator 里对.qrc右键「重新构建」,或删掉 build 目录重编。更彻底的做法是开发期把图片放外部目录用相对路径加载,发布前再收进 qrc。

5.5 窗口失焦后按键失灵

现象:切到别的窗口再切回来,飞机不响应方向键。原因:焦点被其他控件抢走,keyPressEvent收不到事件。解决:在GameView构造里setFocusPolicy(Qt::StrongFocus),并在showEvent里setFocus()。如果场景里有按钮,确保按钮不抢焦点。

6. 二次开发:把这份源码改成你自己的玩法

拆完这份工程,真正有价值的是它留出的扩展点。想加「道具掉落」,就在敌机击毁时按概率spawnItem(),道具继承QGraphicsPixmapItem,玩家碰到后改speed或fireRate成员变量即可,不用动主循环。想加「Boss 战」,扩展状态机枚举加一个BossPhase,Boss 单独一个类管理血条和多段弹幕,checkCollisions里按类型分派。想换「竖屏/横屏」,改GameView的setSceneRect和背景图尺寸,玩家移动的边界判断跟着场景走,逻辑层几乎不用改。

验证改动是否生效,我习惯加一个调试开关:按F1打印当前活动对象数量、帧耗时、分数,按F2无敌模式方便测 Boss。这些调试代码用#ifdef QT_DEBUG包起来,发布版自动剔除。另外每次改碰撞相关逻辑,我都会先跑一遍「子弹密集发射 + 敌机密集生成」的压测场景,看有没有漏判和内存增长——这个习惯是从一次线上演示卡死换来的,从那以后我每次动核心循环都强制走一遍压测再提交。

如果你手上正好缺一个能完整跑通、结构又不复杂的 Qt 实战项目,这份飞机大战源码值得下载下来照着改一遍,改完你对 Qt 图形视图框架和事件驱动的理解会比看十篇教程都扎实。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进