简介:基于 C++ 语言与 Qt 应用框架的飞机大战小游戏项目,代码均经过实际运行测试,功能完整,可直接用作课程设计、毕业设计或新手进阶的练习素材。面向计算机、人工智能、通信工程、自动化、电子信息等专业的在校学生、老师及企业开发者,既能帮助理解 Qt 图形界面、事件循环、对象树与信号槽机制,也能在此基础上扩展屏幕滚动、敌机智能、得分系统等功能。压缩包内共 58 个文件,整体大小 33.2MB,其中包含 7 个源码文件与 7 个头文件,另有 31 张游戏素材图片、5 张背景图片、2 个音效文件,以及 QRC 资源索引文件,资源按模块存放,定位和替换都很方便。项目还附有 README 说明和 Qt 工程文件,英雄机、敌机、子弹、地图等关键模块独立成文件,代码结构清晰,便于对照学习资源打包、信号连接、碰撞检测等常见开发技巧。目前已有 349 人下载学习。
1. 一个没有控件的Qt程序:逐帧绘制撑起的飞机大战
压缩包里找不到一个.ui文件,也看不到QPushButton、QLabel这类常规控件。整个“飞机大战”从背景、敌机到子弹,全都是QPainter在每一帧把贴图画到QWidget上,再由QTimer按固定间隔反复触发。对用惯控件拖拽的人来说,这个思路第一眼会有点别扭,但恰恰是 c++ 小游戏里 Qt 绘图最典型的骨架。
它解决的是“会用控件但不会做游戏循环”的断层:贴图怎么管理、碰撞怎么判定、地图怎么滚起来、爆炸动画怎么不卡帧。适合做过两三个 Qt 窗口、想试游戏逻辑的在校学生,也适合拿来当课程设计或立项演示。代码量不大,结构也干净,改起来不费劲,值得完整拆一遍。
2. 工程骨架与资源编译:从AirplaneWar.pro到plane.rcc
在 Qt 官网下载 Qt 5.15 LTS 或 Qt 6.x,安装时勾选 MinGW 或 MSVC 套件,双击AirplaneWar.pro就能被 Qt Creator 读取。这套源码用 qmake 管理,工程文件把源文件、资源、平台配置都串在一起,先读工程再读代码,能少踩不少环境层面的坑。
2.1 pro 文件如何约束一个 Qt Widgets 项目
AirplaneWar.pro是整套工程的起点,它决定了编译哪些源码、链接哪些 Qt 模块、生成什么名字的可执行文件。典型的项目文件内容如下:
QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = AirplaneWar TEMPLATE = app CONFIG += c++11 SOURCES += main.cpp mainscene.cpp map.cpp \ heroplane.cpp enemyplane.cpp \ bullet.cpp bomb.cpp HEADERS += config.h mainscene.h map.h \ heroplane.h enemyplane.h \ bullet.h bomb.h RESOURCES += res.qrc RC_ICONS = app.ico第一行的core gui是最基础的模块,greaterThan(QT_MAJOR_VERSION, 4): QT += widgets是 Qt5 兼容 Qt4 的写法,在 Qt 5.15 与 Qt 6 下都成立,凡是直接用QWidget子类的程序都必须保留这一行。CONFIG += c++11把编译器切到 C++11 标准,代码里大量用到的auto、范围 for 和 lambda 都依赖它。TARGET指定生成的 exe 文件名,RC_ICONS = app.ico在 Windows 下直接把图标嵌进 exe,发布后不需要额外携带 ico 文件。
| 配置项 | 作用 | 本项目的值 |
|---|---|---|
| TARGET | 输出的可执行文件名 | AirplaneWar |
| TEMPLATE | 工程模板,app 表示生成可执行程序 | app |
| CONFIG += c++11 | 启用 C++11 标准 | auto、范围 for、lambda |
| RC_ICONS | Windows 下设置 exe 图标 | app.ico |
| RESOURCES | 把 qrc 资源编译进程序 | res.qrc |
有一点容易忽略:项目里放了bg.wav和bomb.wav,如果实际工程用了QSoundEffect或QMediaPlayer,pro 文件里通常还要加QT += multimedia。如果编译时提示找不到QSoundEffect头文件,先检查这一项,而不是去翻代码。AirplaneWar.pro.user是 Qt Creator 生成的个人配置,记录了打开时用哪个构建套件,不同机器会有差异,真正需要提交的是.pro和源码文件。
提示:缺失 multimedia 模块时,Qt 头文件搜索阶段就会失败。先把 pro 改对,再谈代码。
2.2 资源系统:res.qrc 与 plane.rcc
图片和音频通过res.qrc统一管理。qrc 文件本质是 XML,把项目目录下的文件路径登记进去,编译时 Qt 会把它们全部转换成 C++ 数组,最终内嵌进 exe。这个项目的贴图文件很多,光是子弹就有bullet_1.png到bullet_14.png,敌机有img-plane_1.png到img-plane_7.png,背景有img_bg_level_1.jpg到img_bg_level_5.jpg,在 qrc 里逐个登记后,代码里用":/路径/文件名"访问。注意 qrc 里的路径前缀要一致,比如前缀设为/,引用就写":/hero.png";如果代码里写的是":/res/hero.png",qrc 里也要保持同样的相对结构。
<qrc> <resource> <file>res/hero.png</file> <file>res/bg.wav</file> </resource> </qrc>这段 xml 对应的是把hero.png和bg.wav放进 qrc 的最简写法,编译后代码通过":/res/hero.png"加载。这个项目还带了plane.rcc,它是rcc工具编译出来的二进制资源容器,用QResource::registerResource在运行时注册。qrc 是构建期编译进 exe,rcc 是运行时从外部文件加载,常见做法是以 qrc 为主,rcc 留着做资源热更新或调试用。
2.3 入口:main.cpp 与 mainscene.cpp 的启动顺序
main.cpp非常短,只负责创建应用、创建主场景、显示窗口、进入事件循环:
#include "mainscene.h" #include <QApplication> int main(int argc, char *argv[]) { QApplication a(argc, argv); MainScene w; // 主场景,QWidget 子类 w.setWindowTitle("Airplane War"); w.show(); return a.exec(); }这里容易忽略的是setWindowTitle和show的顺序:先设置标题再显示,避免窗口闪现默认标题。MainScene直接继承QWidget而不是QMainWindow,原因在于游戏不需要菜单栏、工具栏和状态栏,QWidget的整个客户区都可以交给QPainter,少了一个中间层,坐标计算也少一层窗口框架偏移。真正重要的是游戏循环不在main里启动,而在MainScene构造完成后、窗口显示之后由QTimer驱动,事件循环起来后定时器回调才会稳定触发。
3. 游戏对象设计:敌机、子弹、炸弹与 QTimer 驱动的主循环
看heroplane.h、enemyplane.h、bullet.h、bomb.h,会发现它们的结构高度一致:每一个类都维护自己的坐标、贴图、速度和一个更新函数,谁该做什么由自己决定。这就是 c++ 小游戏最基础的对象拆分方式,也是后续加新机型、新子弹时最省事的设计。
3.1 让每个对象“自己会动”:坐标、贴图与碰撞盒
以敌机为例,头文件里通常长这样:
class EnemyPlane : public QObject { Q_OBJECT public: EnemyPlane(); void updatePosition(); // 每帧更新一次坐标 QRect rect() const; // 给碰撞检测用的矩形 QPixmap img; // 当前贴图 int x, y; // 敌机左上角坐标 int speed; // 向下移动速度 int hp; // 生命值 bool isFree; // false 表示可复用/可删除 };这个类的设计核心是updatePosition():每一帧由主场景统一调用,函数内部只做y += speed;这样的事。rect()返回当前坐标下的QRect,碰撞检测完全基于它,不直接碰贴图像素。isFree是一个复用标记,被打中的敌机不立即销毁,而是等统一回收。
| 类 | 职责 | 关键成员 |
|---|---|---|
| HeroPlane | 玩家控制的飞机 | x、y、hp、bulletInterval |
| EnemyPlane | 自动下落的敌机 | speed、hp、isFree |
| Bullet | 子弹的移动与回收 | isFree、speed、rect() |
| Bomb | 爆炸动画与伤害范围 | 动画帧索引、范围 |
3.2 QTimer 驱动主循环,而不是 while + sleep
游戏主循环在MainScene里,常见做法是维护一个成员QTimer,间隔设为 15ms 或 20ms,timeout 触发一次更新函数:
m_timer.setInterval(15); // 约 66 FPS connect(&m_timer, &QTimer::timeout, this, &MainScene::updateGame); m_timer.start();updateGame内部做的事按顺序是:更新地图坐标、读取键盘状态移动英雄机、按概率生成敌机、更新所有子弹和敌机坐标、做碰撞检测、统一回收死亡对象、最后调用update()触发重绘。键盘控制不要写在keyPressEvent里直接动坐标,因为系统键盘事件有按下延迟,按住方向键会一顿一顿。正确做法是维护四个布尔状态:
void MainScene::keyPressEvent(QKeyEvent *e) { if (e->key() == Qt::Key_Left) m_keyLeft = true; if (e->key() == Qt::Key_Right) m_keyRight = true; if (e->key() == Qt::Key_Space) m_keySpace = true; } void MainScene::keyReleaseEvent(QKeyEvent *e) { if (e->key() == Qt::Key_Left) m_keyLeft = false; if (e->key() == Qt::Key_Right) m_keyRight = false; if (e->key() == Qt::Key_Space) m_keySpace = false; }然后在updateGame里每帧检查布尔值,按下期间每帧都移动,松开立即停止。左右边界一个是0,一个是width() - hero.img.width(),防止飞机半个身子飞出窗口。空格键同理,用布尔状态加上发射间隔,就能实现按住连发,不需要每按一次都点一下。
注意:connect 用的槽函数返回值必须是 void。如果写成
int updateGame(),编译期不会立刻报错,但信号槽连接会失败,游戏一直不刷新。这是“qt 槽函数 返回值”最常见的坑。
用QTimer而不是while(true) + sleep的好处是事件循环不被阻塞,键盘事件、窗口拖拽、音频回调都能正常执行。想要调节难度,改setInterval的数值就够了。对 CPU 占用敏感的机器,把 15ms 调整为 20ms,帧率虽然下降,但游戏逻辑照常跑。
3.3 对象生命周期:删除、复用与迭代器失效
游戏里子弹、敌机是高频创建销毁的对象,直接new/delete会产生大量碎片。常见做法是维护一个QList或QVector,对象死亡时置isFree = true,每隔几帧统一回收一次:
for (int i = m_enemies.size() - 1; i >= 0; --i) { if (m_enemies[i].isFree) m_enemies.removeAt(i); }这里必须从尾部向前遍历。removeAt(i)会把后面的元素整体前移,如果从前往后删,下标会错乱,漏删或越界都有可能。先标记再回收的好处是,碰撞检测遍历时不会因为删除操作导致迭代器失效,同一批对象可以被多个系统安全访问。
4. 滚动地图与碰撞检测:让游戏开始“像样”
地图滚动是飞机大战里最抢眼的 Qt 绘图效果,也是map.cpp的全部职责。看img_bg_level_1.jpg到img_bg_level_5.jpg,命名方式说明背景是按关卡拆了多张图,使用时根据关卡索引加载对应文件即可,不必做拼接大图。
4.1 双图循环拼出无尽背景
Map类内部放两张QPixmap和两个纵坐标,一个简化的更新逻辑如下:
void Map::updatePosition() { m_y2 += m_speed; // 第二张向下滚动 if (m_y2 >= m_height) // 第二张完全滚出屏幕底部 m_y2 = 0; // 回到顶部重新开始 m_y1 = m_y2 - m_height; // 第一张永远接在第二张上方 } void Map::draw(QPainter &p) { p.drawPixmap(0, m_y2, m_pixBg2); p.drawPixmap(0, m_y1, m_pixBg1); }初始时m_y2 = 0,m_y1 = -m_height,第一张图正好悬在屏幕上方一帧的偏移处。每帧m_y2增加 speed,m_y1同步跟随,画面上始终有一张图从底部进入、另一张从上端补位,衔接处没有视觉断口。speed 通常放在config.h里统一配置,换关时改背景图、提速都只用改一处。
绘制顺序也值得注意。paintEvent里先画背景,再画爆炸特效、敌机、英雄机、子弹,最后画血条和分数。前后遮挡关系一旦颠倒,比如英雄机被背景盖住,游戏画面就会“消失”,这种问题排查起来比逻辑错误更隐蔽。
4.2 QRect::intersects 做碰撞检测
碰撞检测用的是QRect::intersects,也就是两个矩形区域是否有交集。整个游戏最核心的判断就三对:英雄机与敌机、子弹与敌机、炸弹与敌机。子弹打敌机的逻辑是这样:
for (Bullet &b : m_bullets) { if (b.isFree) continue; for (EnemyPlane &e : m_enemies) { if (e.rect().intersects(b.rect())) { b.isFree = true; // 子弹消失 e.hp--; if (e.hp <= 0) e.isFree = true; // 敌机标记为待回收 break; } } }这个循环只做标记,不做删除,配合上一章的回收机制遍历非常安全。QRect的判定速度足够快,敌方单位几十个的规模下完全不用做四叉树之类的优化。
| 碰撞对 | 触发条件 | 本项目通常的处理 |
|---|---|---|
| 英雄机 vs 敌机 | 两个 rect 相交 | 英雄血量减少,敌机标记销毁 |
| 子弹 vs 敌机 | 两个 rect 相交 | 子弹消失,敌机扣血 |
| 炸弹 vs 敌机 | 爆炸范围与敌机 rect 相交 | 范围内敌机扣血 |
| 敌机 vs 屏幕底部 | y 坐标超过窗口高度 | 标记销毁,视规则扣生命 |
有一个细节值得注意:QRect是按整块矩形算的,飞机贴图往往有透明边角,直接拿整张图做碰撞会显得“还没碰到就掉了”。想更精细,要么把rect()返回值向内缩几像素,要么手动指定热点区域,比如把 x 和 y 各加 4,宽高各减 8。
4.3 音效与绘制性能的边界
bg.wav做背景音乐、bomb.wav做爆炸音效,在 Qt 里比较轻量的做法是QSoundEffect,音频文件小、循环播放方便:
QSoundEffect bgMusic; bgMusic.setSource(QUrl("qrc:/bg.wav")); bgMusic.setLoopCount(QSoundEffect::Infinite); bgMusic.setVolume(0.3f); bgMusic.play();如果bg.wav实际是较长的音乐,QSoundEffect在部分平台会性能吃紧,那时再换QMediaPlayer+QMediaPlaylist也不迟。本项目音频资源很小,QSoundEffect足够。
绘制性能方面,最影响体验的是每帧都去加载贴图。QPixmap(":/img-plane_1.png")这种写法一旦出现在paintEvent里,文件 IO 会直接拖垮帧率。正确做法是对象构造时把QPixmap存成成员变量,绘制只做drawPixmap。此外在paintEvent里创建QPainter后建议开启setRenderHint(QPainter::SmoothPixmapTransform),缩放贴图时边缘不会出现明显的锯齿。
5. 发布、打包与血条绘制:从开发机到别人能跑
开发机上程序能跑只是第一步,把 exe 发给别人还能跑,才是 qt 发布软件的完整流程。这一章先解决打包,再补两个能直接用进游戏的技巧。
5.1 Release 构建与 windeployqt 一键起飞
在 Qt Creator 左侧选择 Release 模式,构建完成后在构建目录里找到AirplaneWar.exe。打开命令行,进入 exe 所在目录,执行:
windeployqt AirplaneWar.exewindeployqt会把 Qt 的动态库、platforms插件目录、styles、imageformats等依赖一次性复制到 exe 旁边。这里最常见的报错是运行 exe 时提示:
qt.qpa.platform: could not load the Qt platform plugin "windows"这就是plugins/platforms/qwindows.dll没有随程序一起部署。windeployqt默认会处理,关键是别手动删掉plugins目录。源码里的图片和音频如果只存在于 qrc,不用额外拷贝;如果是运行时从文件系统读取,就要把res目录整体放到 exe 同级。用QFileInfo可以快速自检部署结果:
QFileInfo check(QCoreApplication::applicationDirPath() + "/plugins/platforms/qwindows.dll"); qDebug() << (check.exists() ? "ok" : "missing");MSVC 套件编译出来的 exe 在干净机器上可能还缺 VC++ 运行库,打包时把对应版本的 Visual C++ Redistributable 一并带上或提示用户安装即可。
5.2 自己画血条:一个可复用的 QPainter 进度条组件
飞机大战里英雄机的血量很少用QProgressBar控件,因为控件的边框样式和游戏画面不搭。常见做法是直接用QPainter画,这也就是“qt 自定义进度条”在游戏里的落地方式:
void paintProgressBar(QPainter &p, int x, int y, int w, int h, qreal ratio, const QColor &bg, const QColor &fg) { ratio = qBound<qreal>(0.0, ratio, 1.0); p.fillRect(x, y, w, h, bg); p.fillRect(x, y, qRound(w * ratio), h, fg); p.drawRect(x, y, w, h); }调用时传入英雄机当前血量和最大血量的比值,比如paintProgressBar(p, 10, 10, 200, 14, hero.hp * 1.0 / hero.maxHp, QColor(60, 60, 60), QColor(255, 80, 80))。ratio先被qBound限制在 0 到 1 之间,即使hp扣成负数也不会把进度条画到区域外面。灰色底、红色前景、最外层描边,三段式绘制足够应付大多数游戏 UI。如果嫌纯色单调,把前景换成渐变填充或叠加一张纹理 PNG 就是完整血条皮肤。
5.3 国际化前置:先用 tr() 包字符串
这个项目暂时只有中文界面,但setWindowTitle("Airplane War")这类字符串建议从一开始就用tr()包起来。后续做 qt 国际化时,在工程根目录执行lupdate AirplaneWar.pro会扫描全部源码,把tr()包住的字符串导出成 ts 文件,用 Qt Linguist 翻译后用lrelease生成 qm,最后在main.cpp里加载QTranslator即可切换语言。包一层tr()的成本几乎为零,却能让代码在需要出多语言版本时不用回头改源码。
本文还有配套的精品资源,点击获取