简介:这是一份面向Qt与C++初学者及课程设计学习者的2D射击游戏完整项目源码,以飞机大战为载体,帮助读者理解跨平台图形界面开发与游戏逻辑实现。压缩包共1017个文件,约42.88MB,其中png、jpg、bmp、gif等图片资源构成飞机、敌机与场景素材,mp3、ogg音频提供背景音乐与音效,37个cpp与36个h文件承载核心逻辑,另有ui界面文件、qrc资源清单、pro工程配置及dll依赖库,并附一份实践报告文档。项目覆盖QGraphicsView场景渲染、QTimer定时动画、键盘事件输入、碰撞检测与分数计算等关键环节,读者可据此梳理类结构设计、资源加载方式与游戏循环组织思路,也能对照报告复盘开发中遇到的问题及解决过程。目前已有1025人学习,适合作为Qt入门练手与课程实践的参考案例。
1. 从一份 7z 压缩包说起:Qt 飞机大战能拿来练什么
下载下来是一个 7z 包,解压后是一套完整的 Qt C++ 工程,打开 .pro 文件就能编译,跑起来是一个能玩的竖版飞机大战。这类项目在网上一抓一大把,但真正适合拿来练手的并不多——很多是半成品,缺资源、缺配置、编译一堆红。这份工程的价值在于它把「一个能跑起来的 Qt 桌面小游戏」该有的东西都凑齐了:窗口渲染、定时器驱动的游戏循环、键盘事件、碰撞检测、资源加载、分数与生命值管理。对刚学完 Qt 基础控件、想找个东西把信号槽、事件循环、QPainter 串起来的人来说,它比看文档强得多;对熟手来说,它是一份可以拿来改玩法、换素材、加关卡的骨架。关键词就三个:Qt、飞机大战、C++,下面按「能干什么 → 怎么跑 → 怎么改」的顺序拆。
2. 工程结构与技术选型:一个 Qt Widgets 游戏的最小闭环
2.1 目录长什么样,每个文件管什么
解压后的工程是典型的 Qt Widgets 项目结构,没有用 QML,全部走 C++ 加 QPainter 自绘。常见做法是这样一个布局:
PlaneWar/ ├── PlaneWar.pro # qmake 工程文件,声明模块和源文件 ├── main.cpp # 程序入口,创建 QApplication 和主窗口 ├── mainwindow.h/.cpp # 主窗口,游戏主循环、事件处理、绘制都在这 ├── mainwindow.ui # 界面文件(如果用了 Designer) ├── player.h/.cpp # 玩家飞机类 ├── enemy.h/.cpp # 敌机类 ├── bullet.h/.cpp # 子弹类 ├── gameconfig.h # 常量配置:窗口尺寸、速度、刷新间隔 └── res/ ├── images/ # 飞机、子弹、背景图 └── sounds/ # 音效(可选).pro文件是理解依赖的入口,里面一般会写QT += core gui,如果用了音效会加multimedia。资源通过.qrc文件打包进二进制,这样发布时不用带一堆散图。判断一个 Qt 游戏工程是否完整,先看.pro里SOURCES、HEADERS、RESOURCES三行是否对得上实际文件,对不上就是编译报错的头号来源。
2.2 为什么用 QWidget 而不是 QGraphicsScene
这类小游戏有两种常见实现路线:QGraphicsView 框架和纯 QWidget 自绘。这份工程走的是后者,主窗口继承 QWidget,重写paintEvent用 QPainter 把所有对象画上去。选它的理由很实际——对象数量少(几十个子弹、十几个敌机),不需要场景图的索引和变换能力,自绘反而代码直白、调试方便,一个paintEvent里能看到全部绘制顺序。
代价是所有对象管理、碰撞检测都得自己写。常见做法是用QList<Bullet*>之类的容器持有对象指针,每帧遍历更新位置、判断越界、判断碰撞。这种写法在对象上千时会卡,但飞机大战这个量级完全够用。如果你后续想扩到弹幕游戏,就得考虑换 QGraphicsScene 或者自己做对象池,这是后话。
2.3 游戏循环:QTimer 驱动的帧更新
Qt 没有内置游戏循环,标准做法是用 QTimer 以固定间隔触发update(),在paintEvent里推进逻辑。核心代码大概长这样:
// mainwindow.cpp 构造函数里 gameTimer = new QTimer(this); connect(gameTimer, &QTimer::timeout, this, &MainWindow::gameLoop); gameTimer->start(16); // 约 60 FPS void MainWindow::gameLoop() { updatePlayer(); // 根据按键状态移动玩家 updateBullets(); // 子弹上移、越界移除 updateEnemies(); // 敌机下移、生成新敌机 checkCollisions(); // 子弹打敌机、敌机撞玩家 update(); // 触发重绘 }start(16)里的 16 是毫秒,对应约 60 帧每秒。这个值不是越小越好:设成 1 会让 CPU 空转,设成 33 会明显卡顿。参数说明上,updatePlayer依赖一个按键状态表(bool keys[4]),在keyPressEvent和keyReleaseEvent里维护,而不是在按键事件里直接移动——后者会导致按住时移动速度受系统按键重复率影响,手感很飘。这是新手最容易翻车的地方之一。
2.4 事件处理与坐标系统
Qt 的坐标系原点在左上角,x 向右、y 向下,飞机大战里敌机从 y=0 往下飞,正好顺着这个方向,不用做翻转。键盘事件重写keyPressEvent(QKeyEvent *e),用e->key()判断方向键或 WASD。注意要处理e->isAutoRepeat(),否则按住方向键会触发大量重复事件。
void MainWindow::keyPressEvent(QKeyEvent *e) { if (e->isAutoRepeat()) return; // 过滤系统按键重复 switch (e->key()) { case Qt::Key_Left: keys[LEFT] = true; break; case Qt::Key_Right: keys[RIGHT] = true; break; case Qt::Key_Space: fireBullet(); break; // 空格开火 default: QWidget::keyPressEvent(e); } }fireBullet()里创建子弹对象、设置初始坐标在玩家机头位置、加入子弹容器。开火频率靠一个冷却计数器控制,不能每帧都生成,否则子弹会连成一条线。常见做法是记录上次开火时间,间隔超过 150 毫秒才允许再发。
3. 核心模块拆解:玩家、敌机、子弹与碰撞检测
3.1 玩家飞机:移动边界与状态
玩家类的核心是位置更新和边界钳制。移动逻辑放在updatePlayer()里,根据keys数组决定 x、y 的增量,然后限制在窗口范围内:
void MainWindow::updatePlayer() { int step = 5; // 每帧移动像素 if (keys[LEFT]) playerX -= step; if (keys[RIGHT]) playerX += step; if (keys[UP]) playerY -= step; if (keys[DOWN]) playerY += step; // 边界钳制,防止飞出窗口 playerX = qBound(0, playerX, width() - playerW); playerY = qBound(0, playerY, height() - playerH); }qBound(min, val, max)是 Qt 提供的钳制函数,比手写 if 判断干净。step=5这个值决定了手感,配合 60 帧就是每秒 300 像素,窗口宽 480 的话横穿要 1.6 秒,偏慢,实际调成 7 到 8 更跟手。玩家还有生命值属性,被敌机撞到减一,归零触发游戏结束——这部分通常用一个int hp成员加一个gameOver标志位实现,paintEvent里根据标志位决定画不画「Game Over」文字。
3.2 敌机生成:随机位置与难度曲线
敌机不是一开始就全在场上,而是按时间间隔从顶部随机 x 位置生成。常见做法是在updateEnemies()里维护一个生成计时器:
void MainWindow::updateEnemies() { if (++spawnCounter >= spawnInterval) { spawnCounter = 0; Enemy *e = new Enemy; e->x = QRandomGenerator::global()->bounded(width() - enemyW); e->y = -enemyH; // 从窗口上方外面进入 e->speed = 2 + level; // 随关卡加速 enemies.append(e); } // 移动与越界清理 for (int i = enemies.size() - 1; i >= 0; --i) { enemies[i]->y += enemies[i]->speed; if (enemies[i]->y > height()) { delete enemies.takeAt(i); // 飞出底部就销毁 } } }两个细节值得说。一是QRandomGenerator::global()是 Qt 5.10 之后推荐的随机数接口,比qrand()好,不用手动播种。二是遍历删除时从后往前,用takeAt而不是removeAt,前者返回指针方便 delete,避免内存泄漏。spawnInterval随关卡递减、speed随关卡递增,就构成了最简单的难度曲线,不需要复杂的配置表。
3.3 碰撞检测:矩形相交就够用
飞机大战的碰撞不需要像素级检测,矩形包围盒(AABB)完全够。Qt 的QRect自带intersects():
bool MainWindow::hit(const QRect &a, const QRect &b) { return a.intersects(b); } void MainWindow::checkCollisions() { // 子弹 vs 敌机 for (int i = bullets.size() - 1; i >= 0; --i) { QRect br(bullets[i]->x, bullets[i]->y, bulletW, bulletH); for (int j = enemies.size() - 1; j >= 0; --j) { QRect er(enemies[j]->x, enemies[j]->y, enemyW, enemyH); if (br.intersects(er)) { delete bullets.takeAt(i); delete enemies.takeAt(j); score += 10; break; // 一颗子弹只打一个敌机 } } } // 敌机 vs 玩家(略) }这里有个性能上的小讲究:双层循环是 O(子弹数 × 敌机数),几十乘十几完全无感,但如果以后子弹上百,就得考虑空间划分。break那行很关键,不加的话一颗子弹可能同时命中多个重叠敌机,分数会异常增长。碰撞检测放在每帧逻辑的最后,保证位置已经更新完再判断,否则会出现「看着没碰到却炸了」的玄学现象。
3.4 绘制:paintEvent 里的分层顺序
所有画面都在paintEvent里画,顺序决定遮挡关系:
void MainWindow::paintEvent(QPaintEvent *) { QPainter p(this); p.drawPixmap(0, 0, bgPixmap); // 1. 背景 for (Enemy *e : enemies) // 2. 敌机 p.drawPixmap(e->x, e->y, enemyPixmap); for (Bullet *b : bullets) // 3. 子弹 p.drawPixmap(b->x, b->y, bulletPixmap); p.drawPixmap(playerX, playerY, playerPixmap); // 4. 玩家 p.setPen(Qt::white); // 5. HUD p.drawText(10, 20, QString("Score: %1 HP: %2").arg(score).arg(hp)); }背景图如果比窗口小,需要平铺或者缩放,常见做法是在构造函数里bgPixmap = bgPixmap.scaled(size())。HUD 文字用drawText直接画,不用额外控件,省得布局打架。注意paintEvent里不要做耗时操作(比如加载图片),图片资源应该在构造时一次性加载成成员变量,否则每帧读盘会卡成幻灯片。
4. 编译运行与资源打包:从源码到可执行文件
4.1 环境准备与 qmake 构建
这份工程用 qmake,需要装 Qt 开发环境(Qt 5 或 Qt 6 均可,注意 Qt 6 里部分 API 有变动,比如QRandomGenerator用法一致但QRegExp被移除,本项目不涉及)。用 Qt Creator 打开.pro文件,选一个 Desktop Kit,直接点构建运行即可。命令行方式:
# 在工程目录下 qmake PlaneWar.pro make # Linux/macOS # Windows 下用 nmake 或 mingw32-make,取决于 Kit如果报「找不到 QApplication」,检查.pro里有没有QT += core gui;如果报图片找不到,检查.qrc是否被正确引用、资源路径前缀是否和代码里的:/images/xxx.png对得上。资源路径是新手翻车重灾区,代码里写":/res/images/plane.png",qrc 里前缀却是/images,编译能过但运行时图是空的,窗口一片黑。
4.2 发布:windeployqt 与依赖收集
编译出来的 exe 不能直接拷给别人,缺 Qt 的 dll 会报「找不到入口点」。Windows 下用 Qt 自带的部署工具:
windeployqt --release PlaneWar.exe它会把Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll以及 platforms 插件拷到 exe 同目录。Linux 下用linuxdeployqt或手动带 so,macOS 用macdeployqt。这一步不做,别人拿到就是打不开,血泪经验。另外如果用了 multimedia 模块放音效,记得把对应的插件目录也带上,否则声音静默失败但不报错,排查起来很费劲。
4.3 常见编译报错对照
| 现象 | 原因 | 解决 |
|---|---|---|
cannot find -lQt5Widgets | Kit 没配好或 Qt 版本不匹配 | 在 Qt Creator 里重新选 Kit |
| 运行时窗口空白无图 | qrc 前缀与代码路径不一致 | 核对:/后的完整路径 |
| 中文乱码 | 源文件编码与编译器不一致 | 统一 UTF-8,Qt 5 加#pragma execution_character_set("utf-8") |
| 按键无响应 | 焦点不在主窗口 | 构造函数里setFocusPolicy(Qt::StrongFocus) |
5. 避坑与排查:那些编译能过、跑起来才炸的坑
5.1 对象 new 了没 delete,内存一路涨
现象是玩几分钟后任务管理器里内存持续上升。原因是子弹和敌机用new创建,越界或碰撞后只从容器里移除、没 delete。解决是把「移除」和「销毁」绑在一起,统一用delete container.takeAt(i),或者干脆用QList<QSharedPointer<Bullet>>让智能指针托管。我一般会在每个 update 函数末尾检查容器大小是否异常增长,作为早期预警。
5.2 定时器里 update() 和逻辑混在一起导致卡顿
现象是帧率不稳、移动一顿一顿。原因常见于把图片加载、文件读写放进了gameLoop。解决是把所有资源加载挪到构造函数,gameLoop里只做纯内存运算。另一个诱因是paintEvent里创建 QPainter 之外的临时对象,比如每帧new QPixmap,这个必须避免。
5.3 碰撞判定「看着没碰到就炸了」
现象是玩家觉得离敌机还有距离就掉血。原因是碰撞矩形用的是图片原始尺寸,而绘制时做了缩放,判定框和视觉框对不上。解决是判定用的宽高和drawPixmap的目标矩形保持一致,或者干脆在类里存一个QRect hitBox,绘制和判定都读它,改一处全生效。
5.4 打包后音效不响
现象是开发机上正常,发给别人没声音。原因是 multimedia 的后端插件没随包发布,或者音频格式在目标机器缺解码器。解决是优先用 wav 这种无压缩格式,windeployqt时确认mediaservice目录被带上。如果还不行,检查QMediaPlayer的error()信号,把错误打出来,别让它静默失败。
5.5 高 DPI 屏上界面模糊或错位
现象是在 4K 屏上飞机糊成一片。原因是 Qt 5 默认不启用高 DPI 缩放。解决是在main.cpp里QApplication::setAttribute(Qt::AA_EnableHighDpiScaling),并且坐标计算尽量用逻辑像素,别写死物理像素。Qt 6 默认开启,但混合使用固定尺寸图片时仍要注意准备 2x 图。
6. 二次开发:换皮、加玩法与验证思路
拿到这份工程,最省力的改造是换皮——把res/images下的飞机、子弹、背景替换成自己的素材,尺寸保持一致就不用改代码,尺寸变了就同步改gameconfig.h里的宽高常量。这一步能快速验证工程是否真的跑通,也是熟悉资源加载链路的好办法。
再往上一层是加玩法。比如加「道具掉落」:在敌机销毁时按概率生成一个道具对象,玩家碰到后触发三秒双发子弹。实现上新增PowerUp类,在checkCollisions里加一段玩家与道具的判定,用一个bool doubleShot加倒计时控制。改动集中在三个函数里,不会动到主循环结构,适合作为第一个二次开发练习。
难度曲线也值得调。现在的spawnInterval和speed是线性变化,玩久了会腻。可以改成按分数分段:每 500 分提升一档,档位配置写进一个struct LevelConfig { int interval; int speed; }数组,updateEnemies里根据当前分数查表。这样调平衡不用改逻辑代码,只改数据。
验证改造是否成功,我习惯用三个检查点:一是编译零警告(警告往往预示类型或资源问题);二是连续玩三分钟内存不涨(任务管理器看);三是把窗口拉到最大最小,看边界钳制和 HUD 是否还正常。这三关过了,基本可以认为改动没有破坏原有闭环。
有个教训值得说:我早期改这类工程时,喜欢直接在paintEvent里加新绘制逻辑,结果越加越乱,后来强制自己把「状态更新」和「绘制」分开——所有对象的位置、生命、状态在 update 系列函数里改完,paintEvent只读不写。从那以后我每次动这类游戏代码,都强制走一遍「先改状态、再改绘制」的顺序,返工率低了很多。希望帮到你。
本文还有配套的精品资源,点击获取