简介:这是一份面向Qt与C++初学者及课程设计学习者的2D射击游戏完整项目源码,围绕飞机移动、子弹发射、敌机生成与碰撞判定等核心玩法,帮助读者理解桌面小游戏从界面搭建到逻辑闭环的实现路径。压缩包共1017个文件,约42.88MB,以png、jpg图片素材和mp3、ogg音频资源为主,配合37个cpp与36个h源文件承载游戏逻辑,另有ui界面文件、qrc资源清单、pro工程配置及dll运行库,素材与代码分层清晰,便于按模块查阅。项目覆盖QGraphicsView场景渲染、QTimer定时刷新、键盘事件响应、QSound音频播放与矩形碰撞检测等知识点,并附实践报告记录设计思路与问题解决过程。目前已有1025人学习,适合想通过完整案例熟悉Qt图形视图框架与C++面向对象封装的读者参考借鉴。
1. 从一份 7z 压缩包说起:Qt 飞机大战能拿来练什么
下载完解压,第一眼看到的不是一堆散落的 cpp,而是一个能直接双击运行的 Qt 工程目录,这种体验对想练手 C++ 图形界面的人来说很关键。Qt 飞机大战这个项目,本质是一个用 Qt Widgets 加 QPainter 手绘实现的 2D 竖版射击小游戏:玩家飞机跟随鼠标或键盘移动,敌机按波次生成下落,子弹碰撞判定、爆炸动画、分数统计、暂停重开这些环节一个不少。它适合两类人:一类是刚学完 C++ 语法、想找个完整项目把类设计、定时器、事件循环串起来的初学者;另一类是想快速摸清 Qt 定时器驱动游戏循环、资源打包、信号槽在游戏里怎么用的人。整个工程规模不大,但五脏俱全,拿来当二次开发的底座比从零搭架子省事得多。
2. 工程结构与技术选型:一个 Qt Widgets 游戏的最小骨架
2.1 目录里都有什么
解压后的工程通常长这样,不同人整理习惯略有差异,但核心文件跑不出这个范围:
PlaneWar/ ├── PlaneWar.pro # qmake 工程文件 ├── main.cpp # 程序入口 ├── mainwindow.h/.cpp # 主窗口,游戏循环与场景调度 ├── plane.h/.cpp # 玩家飞机 ├── enemy.h/.cpp # 敌机 ├── bullet.h/.cpp # 子弹 ├── explosion.h/.cpp # 爆炸动画 ├── resources.qrc # 图片、音效资源清单 └── images/ # 贴图目录.pro文件是 qmake 的入口,决定了用哪些 Qt 模块、C++ 标准是多少。打开它一般能看到类似这样的内容:
QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = PlaneWar TEMPLATE = app CONFIG += c++11 SOURCES += main.cpp mainwindow.cpp plane.cpp enemy.cpp bullet.cpp explosion.cpp HEADERS += mainwindow.h plane.h enemy.h bullet.h explosion.h RESOURCES += resources.qrcQT += core gui widgets说明这是个纯 Widgets 项目,没有用 QML,也没有引入多媒体模块。如果原工程带音效,可能会多一行QT += multimedia,但很多教学向的飞机大战为了降低依赖,音效是可选甚至省略的。CONFIG += c++11是底线,现代 Qt 版本下改成c++17也能编,但没必要动,除非你要用新特性。
resources.qrc是资源打包的关键。Qt 会把图片编译进可执行文件,运行时用:/images/plane.png这种路径访问,不依赖外部目录。这一点对分发很友好,但也意味着换贴图必须重新编译,不能直接替换文件。
2.2 为什么用 QPainter 而不是 QGraphicsScene
Qt 做 2D 游戏有两条路:QGraphicsView 框架和 QPainter 手绘。这个项目选的是后者,在MainWindow::paintEvent里统一绘制所有对象。原因很实际:飞机大战的对象数量少、层级简单,用 QGraphicsScene 反而要维护场景图、item 坐标转换,代码量更大。手绘模式下,每个游戏对象自己管自己的坐标和贴图,主窗口只负责按顺序画出来,逻辑直白。
代价是碰撞检测、对象管理都得自己写。项目里通常用一个QList<Bullet*>或QVector<Enemy*>存活动对象,每帧遍历更新位置、判断越界、做矩形相交检测。这种写法在对象数几百以内完全够用,帧率稳定。
2.3 游戏循环靠 QTimer 驱动
Qt Widgets 没有内置游戏循环,标准做法是用QTimer以固定间隔触发update(),再在paintEvent里推进一帧。主窗口构造函数里一般有:
// 游戏主定时器,约 60 FPS gameTimer = new QTimer(this); connect(gameTimer, &QTimer::timeout, this, &MainWindow::updateGame); gameTimer->start(16); // 16ms ≈ 62.5 FPSstart(16)里的 16 是毫秒,对应大约 60 帧。这个值不是随便定的:太小会占满 CPU,太大画面卡顿。实际项目里更稳妥的做法是用QElapsedTimer记录真实帧间隔,做速度补偿,但教学工程直接写死 16ms 也能跑。updateGame()里做三件事:更新所有对象坐标、检测碰撞、调用update()触发重绘。注意update()是异步的,它只是标记需要重绘,真正绘制发生在事件循环回到paintEvent时,所以不要在updateGame里直接调paintEvent。
3. 核心模块拆解:飞机、子弹、敌机的类设计与碰撞
3.1 玩家飞机:输入响应与边界约束
玩家飞机类通常继承QObject或干脆是个普通类,持有坐标、贴图、生命值。移动逻辑有两种:鼠标跟随和键盘控制。鼠标跟随在MainWindow::mouseMoveEvent里把飞机中心设到鼠标位置,键盘则在keyPressEvent里改坐标。无论哪种,都要做边界钳制,否则飞机会跑出窗口。
void Plane::moveBy(int dx, int dy) { x += dx; y += dy; // 钳制在窗口内,留出贴图半宽半高 if (x < width / 2) x = width / 2; if (x > sceneWidth - width / 2) x = sceneWidth - width / 2; if (y < height / 2) y = height / 2; if (y > sceneHeight - height / 2) y = sceneHeight - height / 2; }width、height是贴图尺寸,sceneWidth、sceneHeight是游戏区域大小。这里用中心点坐标而不是左上角,是为了旋转和碰撞检测方便。参数dx、dy是每帧位移量,键盘控制时通常取 5 到 10 像素,鼠标跟随则直接算目标位置差值。边界判断用半宽半高,保证贴图完整显示在窗口内,这是最容易被忽略的细节——不钳制的话飞机飞到边缘会只剩一半,视觉上很别扭。
3.2 子弹发射与对象池
子弹的生成时机由发射冷却控制,常见写法是在updateGame里维护一个计数器,每 N 帧生成一颗:
// 每 10 帧发射一颗子弹 if (++fireCounter >= 10) { fireCounter = 0; Bullet *b = new Bullet(plane->x(), plane->y() - plane->height() / 2); bullets.append(b); }子弹对象存进QList<Bullet*>,每帧遍历更新y -= speed,当y < 0时从列表移除并delete。这里有个血泪经验:如果只removeOne不delete,跑久了内存会持续上涨,任务管理器里能看到进程占用一路爬。正确做法是移除后立即释放,或者用qDeleteAll批量清理。更讲究的会用对象池复用子弹对象,避免频繁 new/delete 带来的碎片,但小项目里直接删也问题不大。
3.3 碰撞检测:矩形相交就够用
飞机大战的碰撞判定不需要像素级精确,用QRect::intersects做包围盒检测即可:
bool MainWindow::checkCollision(const QRect &a, const QRect &b) { return a.intersects(b); }每个对象提供一个rect()方法返回自己的包围盒。遍历子弹和敌机做双重循环,命中后标记双方死亡、生成爆炸、加分。注意遍历时删除元素会让迭代器失效,常见做法是先收集要删除的对象,循环结束后统一清理,或者用倒序遍历。这个坑几乎每个手写游戏的人都会踩一次,表现是程序偶发崩溃或某个敌机打不死。
3.4 爆炸动画与状态机
爆炸通常是一个多帧贴图序列,用一个短生命周期的对象管理当前帧索引:
void Explosion::update() { if (++frameIndex >= frameCount) { finished = true; // 标记可回收 } }主循环里检查finished标志,为真就从列表移除。爆炸对象不参与碰撞,只负责播放。帧切换速度可以跟主定时器同步,也可以单独用一个更快的定时器,看贴图数量而定。这里的关键是生命周期管理:爆炸播完必须回收,否则每炸一次就多一个僵尸对象。
4. 编译运行与资源打包:从解压到第一架飞机起飞
4.1 环境准备与 qmake 构建
这个工程用 qmake,所以最省事的路径是装 Qt 官方安装包,勾选对应版本的 Desktop 套件(MinGW 或 MSVC 都行)。装完后用 Qt Creator 打开.pro文件,直接点构建运行。命令行方式则是:
qmake PlaneWar.pro make # Linux/macOS mingw32-make # Windows + MinGW如果提示找不到qmake,说明 Qt 的 bin 目录没进 PATH,用 Qt Creator 自带的终端或者手动加环境变量即可。构建产物在build-*目录下,可执行文件旁边不需要放 images 目录,因为资源已经编进二进制了。
4.2 常见编译报错对照
| 报错信息 | 原因 | 处理 |
|---|---|---|
cannot find -lQt5Widgets | 套件没装 Widgets 模块 | 安装时勾选 Qt Widgets |
No such file or directory: ui_mainwindow.h | 工程里混了 .ui 文件但没配 | 检查 .pro 的 FORMS 段 |
undefined reference to vtable | 类声明了 Q_OBJECT 但没跑 moc | 重新执行 qmake 再构建 |
| 中文乱码 | 源文件编码与编译器不一致 | 统一存为 UTF-8,MSVC 加/utf-8 |
undefined reference to vtable这个报错特别常见,本质是 moc 没生成元对象代码。Qt Creator 里改完头文件后偶尔会漏掉重新 moc,手动执行一次 qmake 再构建基本能解决。
4.3 资源路径与高分屏适配
资源访问统一用:/前缀,比如QPixmap(":/images/enemy.png")。如果贴图加载失败,先确认.qrc里路径拼写和实际目录一致,再确认构建时资源有没有被重新编译——改了 qrc 不重新构建,运行时还是旧资源。
高分屏上如果画面模糊,可以在main.cpp里开缩放:
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);这行要在创建QApplication之前调用。开了之后贴图会按设备像素比放大,清晰度提升,但坐标计算仍然用逻辑像素,一般不需要改游戏逻辑。
5. 避坑与排查:跑起来之后最容易翻车的几处
5.1 画面闪烁
现象是飞机移动时拖影明显。原因通常是paintEvent里先fillRect清屏再逐对象绘制,但没有开双缓冲。Qt Widgets 默认对子控件有双缓冲,但如果你在paintEvent里直接画到窗口,可能绕过。解决办法是在构造时设置setAttribute(Qt::WA_OpaquePaintEvent)或者确保用QPainter在paintEvent内绘制,不要手动调repaint造成重入。
5.2 定时器越跑越慢
现象是游戏运行几分钟后帧率下降。原因多半是对象列表里堆积了未回收的子弹或爆炸对象,每帧遍历量越来越大。排查方法是打印bullets.size()和enemies.size(),正常应该在一个稳定区间波动。解决就是前面说的,移除即释放,爆炸播完标记回收。
5.3 按键无响应
现象是方向键按了飞机不动。原因通常是主窗口没有设置焦点策略,或者焦点被其他控件抢走。在MainWindow构造函数里加setFocusPolicy(Qt::StrongFocus),并确保没有子控件截获键盘事件。如果用了鼠标跟随,键盘事件可能根本没接,检查keyPressEvent有没有重写。
5.4 打包后资源丢失
现象是开发机运行正常,拷到别的机器上贴图全没了。原因是用动态库方式分发时漏了 Qt 的 platform 插件,或者资源路径写成了相对路径。用windeployqt(Windows)或macdeployqt(macOS)自动收集依赖,资源走 qrc 就不会丢。如果坚持外置图片,记得把 images 目录一起拷过去,并且路径用QCoreApplication::applicationDirPath()拼接。
6. 二次开发改造:从能跑到好玩,几个可落地的方向
拿到这个工程后,最值得先动的是难度曲线。原版敌机生成间隔往往是固定值,玩久了要么太简单要么突然暴毙。我一般会把生成间隔做成随分数递减的函数,比如interval = max(300, 1200 - score * 2)毫秒,再配合敌机下落速度的缓慢提升,手感会顺很多。改的时候注意把参数抽成成员变量,别散落在各处魔法数字里,后面调平衡才不用满工程搜。
第二个方向是加道具系统。在敌机死亡时按概率掉落道具对象,玩家飞机与道具做碰撞检测,命中后修改子弹数量或临时无敌。实现上可以复用子弹的列表管理模式,新增一个PowerUp类,主循环里多一层遍历。这里有个容易翻车的点:道具和敌机都用矩形碰撞,如果道具贴图比逻辑判定框大,会出现“看着没碰到却吃到了”的玄学现象,所以rect()返回的判定框要略小于贴图。
第三个方向是存档与最高分。用QSettings存本地配置最省事,键值对写分数和音量,跨平台不用改代码。读取时注意首次运行没有键值,要给默认值,否则会拿到空字符串转 int 变成 0,看起来像存档丢了。
| 改造点 | 改动文件 | 难度 | 验证方式 |
|---|---|---|---|
| 动态难度 | mainwindow.cpp | 低 | 连续玩 3 分钟看曲线 |
| 道具系统 | 新增 powerup.h/.cpp | 中 | 强制掉落概率调 100% 测 |
| 最高分存档 | mainwindow.cpp | 低 | 重启程序看分数保留 |
| 音效 | 需加 multimedia 模块 | 中 | 静音开关是否生效 |
改完一定要做一轮回归:暂停再恢复、窗口最小化再还原、连续死亡重开,这几个场景最容易暴露定时器没停、对象没清的问题。我自己的习惯是每次动完核心循环,都强制走一遍“暂停—恢复—重开”三连,因为早期有一次忘了在暂停时停掉生成定时器,结果恢复瞬间屏幕上炸出一片敌机,那画面至今记得。希望这些拆解能帮你少走点弯路,把这份工程改成自己顺手的样子。
本文还有配套的精品资源,点击获取