ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ Qt飞机大战小游戏开发详解:环境配置与碰撞检测实战

C++ Qt飞机大战小游戏开发详解:环境配置与碰撞检测实战 简介基于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.hQT widgets缺失是新手最常犯的错。一旦没写QPainter、QApplication全都会报未定义。这里TARGET决定生成 exe 的名字SOURCES和HEADERS要按你自己的文件名改不然会把编译直接卡在「无法打开源文件」。2.2 在 VS Code 里编译 QT 工程配置 c_cpp_properties.json 是关键如果你不习惯 QT Creator也可以在 VS Code 里跑这个项目。但千万别直接按 CtrlF5VS 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是每帧移动的像素数一般取 58帧率 60 时每秒移动 300480 像素体感刚好。如果用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驱动刷新的思路。希望这篇拆解能帮你避开我当年走过的弯路把那台飞机顺利开上天。本文还有配套的精品资源点击获取
返回列表