☰
C++ Qt飞机大战小游戏开发详解:环境配置与碰撞检测实战
2026/10/5 3:49:18 网站建设 项目流程

简介:基于C++与Qt框架开发的飞机大战小游戏完整工程包,适合计算机相关专业在校学生、老师或企业员工用于课程设计、毕业设计及项目初期演示,也适合有一定基础的开发者作为Qt实战练手。项目代码已通过运行测试,可直接编译启动,覆盖游戏主循环、地图滚动、敌机生成、碰撞检测、子弹发射、音效播放等典型玩法逻辑,有助于理解Qt事件处理与面向对象设计思路。资源共58个文件,压缩包约33.2MB,以7个cpp源文件和7个头文件为核心,配合31张png、5张jpg图片素材用于飞机、敌机、子弹、爆炸特效及背景绘制,另含wav音效、ico图标和pro/qrc工程配置文件,目录结构清晰,便于定位不同模块并在此基础上扩展功能或优化界面。目前已有352人学习/下载,对正在做游戏开发相关课设、毕设或进行Qt入门进阶的读者具有不错参考价值。

1. 飞机大战小游戏,为什么值得自己从头写一遍?

如果你已经掌握了 C++ 语法,却还没碰过 QT,那这个「基于 c++ QT 开发的飞机大战小游戏.zip」是最适合拿来当第一个完整工程的材料。它的本质不只是一个游戏,而是一堂浓缩的桌面应用课:你会看到 C++ 的类设计怎么落到界面代码里,QT 的事件循环怎么驱动游戏动画,以及碰撞检测这些经典算法在一个 6 倍速小游戏里会踩出多真实的坑。下载解压后你能跑起来,能改参数,能加功能,甚至能把它变成答辩项目或简历里那句「独立开发过基于 QT 的桌面游戏」的来源。这篇笔记会带着你把工程从打开到玩明白,把环境配置、核心逻辑、画面刷新和常见报错一条条拆开讲,全是能直接抄作业的步骤。

2. 把 zip 里的工程跑通:环境匹配与最小运行方案

2.1 先确认编译器与 QT 版本,别让玄学报错卡死第一关

拿到 zip 解压后,我建议第一件事不是急着双击project.pro,而是先看一眼压缩包里的文件后缀。如果是.pro,说明是 qmake 工程;如果是CMakeLists.txt,说明走 CMake。两种构建方式在你本地的 QT Creator 里都能打开,但底层的编译器套件必须和 QT 库匹配。常见的坑是:下载的 QT 5.15.2 是msvc2019_64版本的,却用 MinGW 32 位的套件去编译,结果报一堆找不到qwindows.dll或qtmain.lib的错。这不是代码问题,是编译链不匹配。

我一般会先看 zip 里的Qt路径引用,或者直接跑一次qmake看输出。如果你装了多个 QT 版本,会在构建日志里看到类似这样一行信息:

:-1: error: dependent '..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets' not found

这就是典型的 QT 包含路径写死了某个版本,而你的环境里没有那个目录。解决方法不是去改.pro里的绝对路径,而是重新配置构建套件,让 QT Creator 自动选择当前可用的 QT 版本。具体操作是:在「工具 -> 选项 -> Kits」里,选择与 QT 库匹配的编译器套件,并把「Qt Version」下拉框指向你安装的 QT 路径。如果仍然找不到头文件,再手动在.pro里添加:

QT += core gui widgets greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TEMPLATE = app TARGET = PlanWar SOURCES += main.cpp GameWindow.cpp HEADERS += GameWindow.h

QT += widgets缺失是新手最常犯的错。一旦没写,QPainter、QApplication全都会报未定义。这里TARGET决定生成 exe 的名字,SOURCES和HEADERS要按你自己的文件名改,不然会把编译直接卡在「无法打开源文件」。

2.2 在 VS Code 里编译 QT 工程:配置 c_cpp_properties.json 是关键

如果你不习惯 QT Creator,也可以在 VS Code 里跑这个项目。但千万别直接按 Ctrl+F5,VS Code 的 C++ 插件不负责链接 QT 库。我常用的办法是先用 QT Creator 生成一份 Makefile,再回 VS Code 里调用终端编译。不过更省心的方案是直接用 CMake,前提是 zip 里有CMakeLists.txt。如果没有,你可以在项目根目录手动建一个,内容大概长这样:

cmake_minimum_required(VERSION 3.16) project(PlanWar VERSION 1.0 LANGUAGES CXX) set(CMAKE_AUTOMOC ON) find_package(Qt5 REQUIRED COMPONENTS Widgets) add_executable(PlanWar main.cpp GameWindow.cpp GameWindow.h) target_link_libraries(PlanWar Qt5::Widgets)

然后打开 VS Code,在.vscode/c_cpp_properties.json里把 QT 头文件路径加进去,否则编辑器会到处标红,但注意这只是让编辑器认头文件,真正编译还是要用 CMake 工具链:

{ "configurations": [ { "name": "Qt5.15.2", "includePath": [ "${workspaceFolder}/**", "C:/Qt/Qt5.15.2/5.15.2/msvc2019_64/include", "C:/Qt/Qt5.15.2/5.15.2/msvc2019_64/include/QtWidgets", "C:/Qt/Qt5.15.2/5.15.2/msvc2019_64/include/QtCore", "C:/Qt/Qt5.15.2/5.15.2/msvc2019_64/include/QtGui" ], "defines": ["QT_WIDGETS_LIB", "QT_CORE_LIB", "QT_GUI_LIB"], "compilerPath": "C:/Qt/Tools/OpenSSL/Win_x64/bin/openssl.exe" } ] }

这里的defines特别重要,QT_WIDGETS_LIB决定了一些条件编译宏会被打开。如果你不写,头文件里的某些类会被跳过声明,导致报「未知类型名 QPushButton」。另外compilerPath其实写不写都不会影响 cmake 构建,VS Code 只是用它来查找标准库头,真正编译交给 CMake Tools 扩展会自动选编译器。跑构建时建议用cmake --build build --config Debug,然后在 build 目录下找到 exe,双击运行。这一步的好处是你能在 VS Code 里打断点调试,坏处是每次修改.pro文件后要手动重新跑 CMake 配置。

2.3 链接器错误和运行库缺失:Visual C++ Redistributable 是隐形前提

这个 zip 里的飞机大战如果是在 Windows 上用 MSVC 编译的,那你运行 exe 时十有八九会遇到「VCRUNTIME140.dll 找不到」。这不是程序问题,是你的电脑缺少 Visual C++ Redistributable 运行库。热词里频繁出现的visual c++ redistributable指的就是这个。去微软官网下载最新的 x64 版本装上就行,装完后 exe 才能找到运行的环境。如果你不想让用户装这个,可以在 QT Creator 里选择「静态编译」的 QT 版本重新构建,但那需要你下载对应源码并编译,过程比较耗时,对新手不推荐。我自己的经验是:这个坑在我第一次跑 QT 程序时卡了一下午,最后发现是电脑缺运行库,把 exe 拷给别人也要先装它,所以现在我在 README 里都会写清楚运行依赖,这个 zip 的简介里如果没写,你自己心里要有数。

3. 飞机大战核心玩法拆解:从定时器到碰撞检测

3.1 游戏循环:用 QTimer 驱动 60 帧刷新,而不是自己写 while 死循环

飞机大战的所有动态效果,来自两个字「帧」和「状态」。你打开 zip 里的GameWindow.cpp,大概率会看到类似这样的代码:

GameWindow::GameWindow(QWidget *parent) : QWidget(parent), m_score(0), m_level(1) { setFixedSize(480, 700); m_timer = new QTimer(this); connect(m_timer, &QTimer::timeout, this, &GameWindow::updateGame); m_timer->start(16); // 约 60 帧/秒 }

这里的start(16)是核心参数。16 毫秒对应约 62.5 帧,肉眼看起来非常顺滑;如果改成 50,就会明显掉帧变成幻灯片。为什么不能直接在while(1)里更新画面?因为 QT 的事件循环(信号槽机制)需要定期回到主循环去处理键盘和鼠标消息。你在同一线程里跑死循环,界面会假死,键盘事件收不到,这也就是热词里经常搜到的「qt 崩溃」的常见原因之一。QTimer 的底层是事件循环驱动的,每次 timeout 触发updateGame(),然后在updateGame()里调用update()请求重绘,再由paintEvent把当前画面画出来。这个顺序不能反,很多新人把更新的代码放在paintEvent里,一帧更新了好几次,子弹就会瞬移。

3.2 碰撞检测:AABB 矩形相交判断,两行代码搞定敌人和子弹

飞机大战的碰撞可以用「轴对齐包围盒」近似,每个飞机和子弹都有自己的位置x, y和尺寸w, h。判断两个矩形是否相交,就是看它们的边界是否有重叠。代码写出来是这样:

bool isCollided(const QRect &a, const QRect &b) { return a.left() < b.right() && a.right() > b.left() && a.top() < b.bottom() && a.bottom() > b.top(); }

这个公式比直接比较中心距离更精确,因为飞机是长方形的,用中心距离做圆形碰撞会感觉明明撞上了还没爆炸。还有一种常见写法是:

if (abs(a.center().x() - b.center().x()) < (a.width() + b.width()) / 2 && abs(a.center().y() - b.center().y()) < (a.height() + b.height()) / 2)

但注意第二种在物体速度很快时会发生「隧道效应」,就是一帧内物体跨越了对方的体积,导致直接穿透。解决办法一是在updateGame()里不要一次移动太多像素,二是用上面第一种连续检查交叉范围。我在工程里会把飞机的QRect缩小几个像素(内缩 3 像素),这样玩家会感觉判定更宽容,实际游戏体验更有「擦弹感」,而不是觉得飞机还没碰到子弹就被打掉。

3.3 敌机生成与难度曲线:随机位置加递增速度,公式要可调

敌机不能一股脑全堆出来,通常用「每隔 N 帧生成一架」的方式。帧数 N 随游戏时间越来越小,敌机速度越来越快。常见代码:

void GameWindow::generateEnemy() { m_generateCounter++; int interval = qMax(20, 45 - m_level * 3); if (m_generateCounter >= interval) { m_generateCounter = 0; Enemy e; e.rect.setX(QRandomGenerator::global()->bounded(width() - 40)); e.rect.setY(0); e.rect.setWidth(40); e.rect.setHeight(30); e.speed = 2 + m_level; // 每关加速 1 像素/帧 m_enemies.append(e); } }

这里的QRandomGenerator是 QT 5.10 之后的推荐写法,替代旧的qrand()。参数interval的下限是 20,防止后期平均一帧刷一个敌人,造成屏幕怪满为患。speed与关卡m_level线性挂钩,你也可以改成speed = 2 + m_level * 0.5,调整难度曲线。我在实际调试时发现,难度曲线最怕「线性增长过快」,玩家刚打到第二关就觉得无力。比较好的经验是让interval下降快一些,但速度增长慢一些,这样弹幕密度增加,单个敌机却不至于快到看不清路径。

4. Qt 绘图与事件响应:让画面动起来的关键实现

4.1 在paintEvent里用 QPainter 绘制所有游戏元素

QPainter 是 QT 绘图的画笔,你要做的就是把每一帧的画面按顺序画出来。顺序很重要:先画背景,再画子弹,再画敌机,最后画玩家飞机和分数。否则后画的会盖住先画的。核心代码片段:

void GameWindow::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.fillRect(rect(), QColor(10, 10, 30)); // 深色背景防残影 painter.setPen(Qt::white); painter.drawText(QPoint(20, 30), QString("Score: %1 Level: %2").arg(m_score).arg(m_level)); for (const Bullet &b : m_bullets) { painter.fillRect(b.rect, Qt::yellow); // 子弹画成黄色矩形 } for (const Enemy &e : m_enemies) { painter.setBrush(QBrush(QColor(200, 30, 30))); painter.drawRect(e.rect); // 敌机画红色矩形 } painter.setBrush(QBrush(QColor(30, 200, 90))); painter.drawRect(m_playerRect); // 玩家飞机画绿色矩形 }

注意fillRect和drawRect的区别:fillRect只填充没有边框,drawRect会用当前setPen的画笔描边。如果你不先painter.fillRect(rect(), ...)清掉整个窗口上一帧的内容,就会出现残影——你移动飞机时,原来位置上的图像还在那儿。热词里搜的「qt 绘图」大多是这个问题。另外这里所有绘制都是实时的,没有用贴图资源,好处是 zip 里不需要附带图片文件,坏处是形状太简陋。也可以换成drawPixmap加载 PNG 图片,但那需要在项目里加入 Qt Resource System(.qrc),并且注意路径。

4.2 键盘事件:重写keyPressEvent控制飞机移动和发射子弹

飞机移动不能写成「按一次键动一次」——那意味着你要等按钮重复事件,不够灵敏。更常见的做法是维护一个按下的方向标志,然后在updateGame()里持续移动:

void GameWindow::keyPressEvent(QKeyEvent *event) { if (event->key() == Qt::Key_Left) m_left = true; if (event->key() == Qt::Key_Right) m_right = true; if (event->key() == Qt::Key_Up) m_up = true; if (event->key() == Qt::Key_Down) m_down = true; if (event->key() == Qt::Key_Space) emit fireBullet(); } void GameWindow::keyReleaseEvent(QKeyEvent *event) { if (event->key() == Qt::Key_Left) m_left = false; if (event->key() == Qt::Key_Right) m_right = false; }

然后在updateGame()里按照布尔值移动飞机:

if (m_left) m_playerRect.moveTo(qMax(0, m_playerRect.x() - m_speed)); if (m_right) m_playerRect.moveTo(qMin(width() - m_playerRect.width(), m_playerRect.x() + m_speed));

这里的m_speed是每帧移动的像素数,一般取 5~8,帧率 60 时每秒移动 300~480 像素,体感刚好。如果用moveTo,注意要加边界限制qMax/qMin,否则飞机跑到屏幕外面就看不见了,这是很多翻车现场的直接原因。发射子弹不一定要在按键事件里直接创建新子弹,因为你是 16ms 一帧,一次按键可能创建多个子弹。一个常用的节流手段是记录上次发射时间,只有间隔超过 250ms 才允许下一次发射:

if (currentTime - m_lastFireTime > 250) { // create bullet m_lastFireTime = currentTime; }

4.3 用结构体或类管理游戏对象,别用一堆平行数组

我在这个游戏的代码里最想让你看的,不是绘图,而是对象组织方式。最简单的版本里,子弹可以是一个结构体:

struct Bullet { QRect rect; int speedY; // 垂直速度 bool active; // 是否还存活 }; struct Enemy { QRect rect; int speedY; int hp; // 被击中一次 hp-- bool active; };

存活的判断用active标记,而不是删除时直接 erase 容器元素。因为你在updateGame()里要遍历所有子弹做碰撞检测,如果用removeAt边遍历边删除,会导致迭代器失效。正确姿势是先标记active = false,等遍历结束后再统一清理:

for (auto &b : m_bullets) { b.rect.moveBottom(b.rect.bottom() - b.speedY); if (b.rect.bottom() < 0) b.active = false; } for (auto &e : m_enemies) { e.rect.moveTop(e.rect.top() + e.speedY); if (e.rect.top() > height()) e.active = false; } // 统一移除 inactive m_bullets.erase(std::remove_if(m_bullets.begin(), m_bullets.end(), [](const Bullet &b){ return !b.active; }), m_bullets.end());

用std::remove_if加erase是 C++ 的标准组合,能一次性把容器里标记为死亡的对象移除,效率比手动循环删除高。这里要注意QRect::moveTop和moveBottom的坐标系统:QT 的 y 轴向下增长,所以子弹向上移是moveBottom(b.rect.bottom() - speedY),敌机向下移是moveTop(top() + speedY)。搞反方向也是新手常犯的错误,一眼看上去屏幕上的物体全在倒着飞。

5. 避坑 / 常见问题:五条血泪经验帮你少走弯路

5.1 编译错::-1: error: dependent '..\..\..\qt\5.15.2\msvc2019_64\include\qtwidget' not found

  • 现象:QT Creator 打开工程后,构建输出栏直接报错,指向一串qt\5.15.2\msvc2019_64\include\qtwidget路径不存在,导致无法编译。
  • 原因:.pro文件里写死了旧版本的 QT include 路径,或者你在「Projects -> Build Environment」里手动设置了QTDIR环境变量,值却指向一个无效目录。更多情况是本机装的 QT 版本与题述路径的msvc2019_64不一致,例如你装的是 MinGW 版,或 QT 6.x 版。
  • 解决:在 QT Creator 左侧「项目」选项卡中,取消勾选「Shadow build」,然后找到「Manage Kits」,为当前构建套件选择正确的 Qt Version。如果还是报错,检查系统环境变量QTDIR是否被设置成了错误路径,删除后在 QT Creator 中重新构建。实在不行在.pro中加入message(QTDIR $$QTDIR)打印出来看。

5.2 运行时崩溃:QPainter: Cannot paint on a null pixmap

  • 现象:程序编译通过,但一运行就闪退,控制台输出QPainter: Cannot paint on a null pixmap,有时会伴随double free or corruption。
  • 原因:你在paintEvent里调用了drawPixmap,但传入的 QPixmap 对象因图片路径错误导致isNull()。常见的路径错误是使用了相对路径,而运行时工作目录是构建目录,图片放在源码目录下导致找不到。
  • 解决:把图片资源加到.qrc文件中:
RESOURCES += res.qrc

然后在代码里使用:/images/player.png这种资源路径,例如:

QPixmap playerPix(":/images/player.png"); painter.drawPixmap(m_playerRect.topLeft(), playerPix);

在调试阶段,可以先检查playerPix.isNull(),如果为真就在qDebug() << "load failed",定位到问题后把实际路径用QFileInfo打印出来。这个坑把人卡得想砸键盘,实际上就是一张图片的事。

5.3 视觉残影:飞机移动后留下拖影,画面像墨迹

  • 现象:飞机从左到右移动时,整个路径都变成了绿色,屏幕越来越脏。
  • 原因:典型的未清背景。paintEvent中如果你只画了飞机,没有填充空白区域,上一帧的飞机图像就一直留在缓冲区。即便你调用了update(),QT 默认不会自动擦除整个窗口。
  • 解决:在paintEvent最前面添加painter.fillRect(rect(), Qt::black),用不透明颜色覆盖整个窗口。另一种做法是设置窗口背景色:setAutoFillBackground(true),并且构造一个QPalette填充。我习惯用fillRect,因为颜色可以在运行时动态变化,比如换成QColor(20, 20, 50)以模拟星空渐变。

5.4 事件循环假死:窗口无响应,游戏卡死

  • 现象:游戏运行几秒后窗口转圈圈,点击没反应,CPU 占用 100%。
  • 原因:最常见的是在updateGame()里做了大量计算,比如遍历所有子弹和敌人时使用双重循环,而对象数量又很多,导致单帧耗时超过 16ms。或者你在某个槽函数里用了Sleep(100)或QThread::msleep来模拟延迟,这会阻塞主线程的事件循环。
  • 解决:先减少对象数量,检查碰撞检测是否是用了 O(n²) 的暴力遍历且没有提前退出。可以把所有物体按 y 坐标排序,只检测同一区域内的物体。如果只是想在发射子弹后播放音效,用QTimer::singleShot异步延迟,不要阻塞。另外一定不要在paintEvent里更新游戏状态,绘图函数应该只负责画,不负责算。

5.5std::vector迭代器失效:删除敌人时程序崩溃

  • 现象:当子弹击中敌机后,程序偶尔崩溃,错误信息list iterator not dereferenceable或vector iterator not incrementable。
  • 原因:你在双循环中一边遍历m_enemies,一边调用erase删除当前元素,导致后续迭代器全部失效。这在加上射击功能后尤其常见,因为子弹和敌机都要互相检测。
  • 解决:改用我前面 4.3 节的方法,先标记active,再用std::remove_if统一删除。如果坚持要在循环里删除,也必须用迭代器自增前返址:
for (auto it = m_enemies.begin(); it != m_enemies.end(); ) { it = (isDead(*it)) ? m_enemies.erase(it) : it + 1; }

但注意erase返回的是下一个有效迭代器,it + 1在 vector 里没问题,在 list 里就要用it = m_enemies.erase(it)。为了避免记忆混乱,我统一用「标记+remove_if」大法。这个坑让我明白了为什么 C++ 优先顺序容器用 index 循环比迭代器更安全,但工程中为了效率还是学会了标准库的这套删除组合。

6. 让飞机大战更像一个产品:音效、暂停与难度曲线的进阶技巧

玩法本身跑通后,下一步就是把它从「能玩的 Demo」升级成「别人愿意打开第二次的小游戏」。我建议优先加三个东西:音效、暂停、动态难度曲线。

音效不要用QMediaPlayer,那个库依赖较多,在这个小游戏里属于杀鸡用牛刀。用QSoundEffect就够了,它直接从资源文件加载短音频:

QSoundEffect *shootSound = new QSoundEffect(this); shootSound->setSource(QUrl("qrc:/sounds/shoot.wav")); shootSound->setVolume(0.5f); shootSound->play();

注意音频文件必须是 WAV 或未压缩格式,MP3 在这个类上支持不全。我的教训是别一次发射多颗子弹疯狂调用play(),QSoundEffect 每次调用都会叠加播放,一梭子下去声音变成噪音轰炸。更好的做法是每次发射只播放一次,或使用setLoopCount(1)。

暂停功能的关键在于把 QTimer 停掉,而不是屏蔽键盘。我设计了一个简单的状态机:

enum GameState { MENU, PLAYING, PAUSED, GAME_OVER };

在keyPressEvent中监听QKeyEvent::Key_P,如果状态是PLAYING就置为PAUSED并调用m_timer->stop(),再显示一个半透明的暂停面板。恢复时调用m_timer->start(16)。注意stop()之后,窗口仍然能响应键盘事件,所以updateGame()不会被触发,画面会停在最后一帧。不要手动去画「暂停」两个字,而是在状态为PAUSED时让paintEvent多画一个矩形覆盖层。用状态机的另一个好处是,游戏结束时不会因为剩余敌机还在更新而继续计分。

难度曲线我把前面 3.3 节的公式改成了动态依据得分调整,而不是按时间:

m_level = 1 + m_score / 1000; double speedFactor = 1.0 + (m_level - 1) * 0.15;

这样做的好处是玩家能直观地感到「分数越高,敌人越硬」,形成正反馈。但要注意m_level不能无限增长,每个关卡最多提速 2 倍就够了,否则后期飞机的移动速度根本躲不开。我做过一次测试,把 level 上限设成 10,到第 8 关时全屏子弹密度大到 QPainter 绘制都有压力,帧率掉到 35 以下。所以记得在生成敌机时加入最小值下限,比如:

int interval = qMax(30, 60 - m_level * 3);

最后一个进阶技巧是验证你的游戏手感:自己写一个简单的自动化测试,模拟按键移动和发射,检查飞机是否被限制在窗口边界内、子弹是否在出界后从容器中移除。这些断言不需要引入复杂的测试框架,直接在updateGame()末尾加一段if (m_playerRect.x() < 0) qWarning()就够。手调阶段我会用一个高帧率的调试变量m_debugShowCollision,置为 true 时把碰撞检测框画出来,用红色半透明填充,这样能直观看到判定范围是不是和视觉一致。很多人问我「这个飞机大战项目值不值得下下来改一改」,我的答案是:如果你所在的公司或学校需要你用 C++ 做界面开发,这个工程就是最便宜的练手材料;把里面的结构体换成类,把 QTimer 换成QBasicTimer,再把绘制换成 OpenGL,你能从它身上学到的比看十篇 QT 教程都多。我后来在正式项目里做图形编辑器,用的还是这套「标记-清理」容器管理法和QTimer驱动刷新的思路。希望这篇拆解能帮你避开我当年走过的弯路,把那台飞机顺利开上天。

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

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

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

立即咨询