ARTICLE DETAIL

资讯详情

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

C++/Qt飞机大战源码解析:QTimer驱动游戏循环与碰撞检测

C++/Qt飞机大战源码解析:QTimer驱动游戏循环与碰撞检测 简介基于C与Qt开发的飞机大战小游戏完整工程面向计算机相关专业在校学生、教师及初级开发者尤其适合作为课程设计、毕业设计或Qt入门练手项目。工程代码经过测试可正常运行也支持在此基础上扩展新玩法帮助读者理解Qt窗口系统、事件循环、定时器、碰撞检测及资源管理等核心机制。压缩包共58个文件约33.2MB以31张PNG素材图、7个CPP源文件、7个头文件为主体同时包含JPG背景图、WAV音效、ICO图标、Qt工程文件.pro及资源编译文件.qrc/.rcc目录结构清晰素材与逻辑代码分区明确。当前已有352人学习下载适合需要动手实践或完成课设、毕设初期演示的读者下载后可按README引导快速运行并二次开发。1. 为什么是飞机大战一份能直接编译的 C/Qt 练手资源飞机大战是 C/Qt 学习者绕不开的经典项目窗口、定时器、键盘、碰撞、贴图一轮下来把 Qt 事件驱动的基本功全过了一遍。这份资源是完整跑通的成品不是只有半截代码的演示残骸——源码、贴图、音效、资源文件都在配好环境就能编译运行。值得细看的东西有三处MainScene 里 QTimer 驱动的游戏循环、HeroPlane 和 EnemyPlane 的移动与碰撞、bomb-1~7 那组逐帧爆炸动画。课程设计、Qt 进阶、或者想看看一个游戏怎么从零件拼成整机这套代码都是现成的底子。下载后先别急着双击 .pro——把 AirplaneWar.pro.user 删掉那是原作者机器的 Qt 配置留着八成要踩编译坑。下面按工程结构、启动链路、核心逻辑、避坑、进阶改造的顺序逐个拆。2. 拆开 AirplaneWar.pro二十多个文件按职责归位重建工程只需四条命令把 zip 解压后第一眼二十多个文件铺了一桌别慌。这套工程的组成其实就四拨工程配置、游戏逻辑、全局参数、资源文件。搞清楚每一拨是干什么的比急着点开 .pro 更重要——后面改代码、调参数、修 bug找文件靠的就是这张地图。2.1 先按职责分四类文件清单不迷路类别文件干什么用工程配置AirplaneWar.pro、AirplaneWar.pro.userqmake 工程描述.pro.user 是个人 Qt 配置跨机器必须删启动与主窗口main.cpp、mainscene.h/cpp程序入口 游戏循环调度游戏对象map.h/cpp、heroplane.h/cpp、enemyplane.h/cpp、bullet.h/cpp、bomb.h滚动背景、英雄机、敌机、子弹、爆炸动画全局与资源config.h、res.qrc、plane.rcc速度与窗口参数图片音效的资源索引对号入座后你会发现一个细节bomb.h 没有对应的 bomb.cpp。爆炸动画这个类太小——一张位置、一个帧序号、一个「播完没播完」的标记——直接内联在头文件里就够了这是 Qt 小项目里很常见的写法不是漏了文件。2.2 .pro 文件qmake 怎么知道该编译谁AirplaneWar.pro 是纯文本用记事本就能打开。它的作用是告诉 qmake要哪些模块、编译哪些源文件、打包哪些资源。QT core gui widgets TARGET AirplaneWar TEMPLATE app SOURCES main.cpp \ mainscene.cpp \ map.cpp \ heroplane.cpp \ enemyplane.cpp \ bullet.cpp HEADERS mainscene.h \ map.h \ heroplane.h \ enemyplane.h \ bullet.h \ bomb.h \ config.h RESOURCES res.qrcQT widgets 这一行是重点Qt5 里 QWidget 搬到了 widgets 模块少了它 mainscene.h 会直接报 QWidget 找不到。SOURCES 里没有 bomb.cpp对应前面说的 bomb.h 内联实现。RESOURCES 指向 res.qrc构建时 rcc 编译器会把图片音效编成二进制塞进 exe所以发布时只需要一个 exe不用带一堆 png。另一个文件 AirplaneWar.pro.user 不在上面的清单里因为它是 Qt Creator 自动生成的个人配置记录的是原作者机器的 kit、编译器路径、qmake 版本。别人发给你的工程这个文件十有八九是坑——我拿到任何 Qt 工程的第一件事就是删它。2.3 config.h改手感参数先来这整个游戏的「手感」几乎集中在 config.h 里。窗口多大、飞机多快、敌机多密全在这// config.h —— 全局手感参数 #ifndef CONFIG_H #define CONFIG_H #define GAME_WIDTH 480 // 窗口宽 #define GAME_HEIGHT 700 // 窗口高 #define GAME_INTERVAL 15 // 游戏定时器单位毫秒 #define HERO_SPEED 10 // 英雄机每帧移动像素 #define BULLET_SPEED 8 // 子弹每帧上移像素 #define ENEMY_SPEED 3 // 敌机每帧下移像素 #define ENEMY_SPAWN_INTERVAL 30 // 每 30 帧生成一架敌机 #endif参数怎么调给一张参照表参数默认值影响调法GAME_INTERVAL15帧率约 66 帧/秒改 10 更跟手但费 CPU改 30 会肉眼可见卡顿HERO_SPEED10英雄机灵敏度感觉漂就降到 7嫌肉就提到 12BULLET_SPEED8打击手感小于敌机速度的两倍时子弹会追不上敌机ENEMY_SPAWN_INTERVAL30敌机密度越小越难改成 10 就是地狱模式2.4 res.qrc 与 plane.rcc资源路径的两条路res.qrc 也是文本里面是图片音效的索引清单RCC qresource prefix/ fileimg-plane_1.png/file filehero.png/file filebg.wav/file /qresource /RCC代码里加载图片用的是QPixmap(:/img-plane_1.png)冒号加斜杠是 Qt 资源系统的固定写法。如果你看到代码里写:/res/img-plane_1.png而 qrc 里没有prefix/res加载必然失败——这是后面 5.2 节黑屏问题的来源之一。plane.rcc 是 rcc --binary 预编译出来的二进制资源包属于第二条路要么在 .pro 里直接把 .rcc 加进 RESOURCES 链接进去要么运行时用 QResource::registerResource() 动态加载。对这份资源来说你平时改图片只动 res.qrc 就行不用碰 .rcc。2.5 命令行重建四条命令从零编译不想用 Qt Creator 的话纯命令行也能把这套工程跑起来# 1) 删掉别人的 Qt 配置强制重新选择 kit rm -f AirplaneWar.pro.user # 2) 核对 qmake 跟你想要的环境是一套 qmake --version # 期望输出类似: QMake version 3.1, Using Qt version 5.15.2 # 3) 生成 Makefile 并编译 qmake AirplaneWar.pro mingw32-make -j4 # 如果是 MinGW 套件 # 或者nmake 需要先进入 VS 的开发者命令行 nmake # 如果是 MSVC 套件步骤 2 是最容易被忽略的——同一个 Qt 5.15.2 就有 msvc2019_64、mingw73_64 好几个包qmake 来自哪个包就必须配哪个编译器。步骤 3 里 mingw32-make 和 nmake 不能混用混了会刷出一堆语法不认识的错误。编译完的可执行文件在 debug 或 release 子目录下取决于 qmake 的 CONFIG 配置。Windows 上不想敲 rm 命令直接去资源管理器里把 .pro.user 删掉也一样。3. 从 main.cpp 到游戏循环QTimer、事件驱动与绘制顺序这一章把程序从双击启动到画出第一帧的过程走一遍。整条链路只有四个环节main 启动事件循环 → MainScene 构造时启动定时器 → 定时器每 15ms 触发 updateGame → update() 请求重绘最终在 paintEvent 里把画面画出来。看懂这条链你就知道该在哪一环插自己的逻辑。3.1 main.cpp 只有十行事件循环是怎么转起来的#include mainscene.h #include QApplication int main(int argc, char *argv[]) { QApplication a(argc, argv); // 每个 Qt 程序有且只有一个 QApplication MainScene w; w.show(); // 只负责显示不阻塞 return a.exec(); // 进入事件循环窗口关闭才返回 }a.exec() 不会立刻返回它内部是一个持续分发事件的消息泵键盘按键、鼠标移动、定时器超时全都在这里排队分发。这也是 Qt 程序跟传统 C 控制台程序最大的思维差异——不是「主函数控制一切」而是「事件驱动」。有些同学拿到 Qt 想用 while(1) 包游戏循环结果按键没反应、窗口一卡一卡的原因就是死循环把事件循环堵死了。QTimer 每 15ms 从事件循环里取一次 timeout 事件该画图画图该响应键盘响应键盘这才是 Qt 游戏的正确姿势。3.2 15ms 一帧QTimer 是游戏的心脏MainScene::MainScene(QWidget *parent) : QWidget(parent) { setFixedSize(GAME_WIDTH, GAME_HEIGHT); // 固定窗口防止布局乱套 m_timer.setInterval(GAME_INTERVAL); // config.h 里的 15ms connect(m_timer, QTimer::timeout, this, MainScene::updateGame); // 每帧把游戏逻辑推进一步 m_timer.start(); m_hero.init(); // 英雄机摆到初始位置 }setInterval(15) 表示每 15ms 触发一次 timeout一秒约 66 帧。要注意 setInterval 在 start 之前设置才有效运行中改间隔要 restart() 重新启动。课程设计级别的 2D 游戏15ms 是经典取值再小到 5ms帧率上去了但 CPU 占用明显发热再大到 30ms33 帧的刷新肉眼可见的掉帧。也有人问为什么不用 QGraphicsView。这套工程走的是 QWidget QTimer paintEvent 路线每一帧的绘制权完全握在自己手里代码量少、逻辑直白。QGraphicsView 适合大量可交互物体但初学者容易在 item 坐标管理和碰撞处理上绕晕——课设答辩时前者讲起来反而更清楚。3.3 updateGame一帧里按顺序干七件事void MainScene::updateGame() { m_map.update(); // 背景滚动 m_hero.update(); // 按键状态驱动移动 spawnEnemy(); // 按帧计数生成敌机 updateEnemies(); // 敌机下移 出屏移除 updateBullets(); // 子弹上移 出屏移除 checkCollision(); // 子弹打敌机、敌机撞英雄机 updateBombs(); // 爆炸动画帧推进 update(); // 请求重绘真正的绘制在 paintEvent }注意最后一行 update() 不是立即画它只是给窗口打一个「脏标记」。Qt 会把同一帧内的多次 update() 合并成一次 paintEvent所以哪怕你在一个事件里调两次 update也不会画两遍。逻辑和绘制分离是这套代码最值得学的习惯——所有状态更新在 updateGame 里完成paintEvent 只负责把当前状态画出来。3.4 paintEvent 的绘制顺序后画的盖先画的void MainScene::paintEvent(QPaintEvent *) { QPainter painter(this); m_map.draw(painter); // 最底层滚动背景 for (auto e : m_enemies) e.draw(painter); // 敌机 for (auto b : m_bullets) b.draw(painter); // 子弹 m_hero.draw(painter); // 英雄机压在敌机之上 for (auto b : m_bombs) b.draw(painter); // 爆炸特效置顶 }绘制顺序就是视觉层级背景必须第一个画爆炸特效放最后命中瞬间的反馈才明显。QPainter 不需要手动 delete出了 paintEvent 作用域自动结束绘制begin/end 也不用手动调。如果想做「敌机从英雄机头顶飞过」的纵深效果可以把敌机拆成上层下层两组画两次。另外不要在 paintEvent 里做碰撞和移动——那会让绘制和逻辑耦合paintEvent 一慢整帧全慢。4. 键盘状态表、碰撞盒与对象生命周期核心逻辑改这三处游戏能不能玩、好不好玩全看这一章的三个点按键怎么响应、碰撞判定准不准、对象怎么回收。这也是答辩时老师最爱问的地方。4.1 按键状态表按住方向键不掉帧地持续移动// mainscene.h 里声明一个按键状态表 // QSetint m_pressedKeys; void MainScene::keyPressEvent(QKeyEvent *ev) { m_pressedKeys.insert(ev-key()); // 按下记下来但不直接移动 } void MainScene::keyReleaseEvent(QKeyEvent *ev) { m_pressedKeys.remove(ev-key()); // 松开才清除 } void MainScene::updateHero() { if (m_pressedKeys.contains(Qt::Key_Left)) m_hero.moveLeft(); if (m_pressedKeys.contains(Qt::Key_Right)) m_hero.moveRight(); if (m_pressedKeys.contains(Qt::Key_Up)) m_hero.moveUp(); if (m_pressedKeys.contains(Qt::Key_Down)) m_hero.moveDown(); }为什么不在 keyPressEvent 里直接移动飞机因为按住方向键时系统只发一次按下事件不缓存状态飞机就只动一格。用 QSet 缓存按键在定时器回调里每帧读取才能实现「按住持续移动」。heroplane 的 moveLeft/Right 内部还要做边界钳制void HeroPlane::moveLeft() { m_x - HERO_SPEED; if (m_x 0) m_x 0; // 左边界撞墙 } void HeroPlane::moveRight() { m_x HERO_SPEED; if (m_x GAME_WIDTH - m_width) // 右边界不能半身出屏 m_x GAME_WIDTH - m_width; }上下方向同理钳制值换成 GAME_HEIGHT 相关的那组。这里的 m_width 是英雄机贴图的实际宽度直接用窗口宽度减它是为了让飞机贴边时还留半身在屏幕内手感比完全压到边缘好。4.2 子弹连发节流每 5 帧一发的限流参数void MainScene::fireBullet() { if (m_fireCount 5) // 还没攒够 5 帧先憋着 { m_fireCount; return; } m_fireCount 0; Bullet b; b.init(m_hero.rect().center()); // 从英雄机中心出膛 m_bullets.append(b); }fireBullet 每帧都会被 updateGame 调用5 帧一憋就是约 13 发/秒这是飞行射击类小游戏常见的连发密度。改成 2 就变机关枪改成 10 手感会明显发绵。资源列表里有 hero_bullet_7.png对应英雄机专属子弹贴图init 时把这张图塞给 Bullet 就行。发射位置用 rect().center() 而不是左上角是为了让子弹从机身中部而不是从机头一侧冒出来看着不歪。4.3 碰撞检测把贴图边界缩一圈再相交判断void MainScene::checkCollision() { for (int i m_bullets.size() - 1; i 0; --i) { for (int j m_enemies.size() - 1; j 0; --j) { // 贴图四周往往有透明边碰撞盒向内缩一圈更公平 QRect bulletBox m_bullets[i].rect().adjusted(4, 4, -4, -4); QRect enemyBox m_enemies[j].rect().adjusted(8, 8, -8, -8); if (bulletBox.intersects(enemyBox)) { makeBomb(enemyBox); // 命中位置生成爆炸动画 m_enemies.removeAt(j); m_bullets.removeAt(i); break; } } } }QRect::adjusted(4,4,-4,-4) 表示四条边各向内缩 4 像素。为什么敌机缩 8 像素而子弹只缩 4因为敌机贴图翅膀展开透明区域大。调参经验是判定太松子弹没碰到就炸看着假判定太紧子弹直接穿模。原则是每次 2px 地加调到「看着该中的都能中」就停。反向遍历是必须的——removeAt 会把后面的元素往前搬正向遍历会跳过下一个没检查的元素。4.4 敌机与子弹的生命周期反向遍历移除void MainScene::spawnEnemy() { if (m_spawnCount ENEMY_SPAWN_INTERVAL) { m_spawnCount; return; } m_spawnCount 0; EnemyPlane e; QStringList pilots {:/img-plane_1.png, :/img-plane_2.png, :/img-plane_3.png}; e.init(pilots[rand() % pilots.size()], // 随机挑一张敌机皮肤 rand() % (GAME_WIDTH - e.width())); // 随机出生横坐标 m_enemies.append(e); }生成逻辑要点随机 x 必须用 GAME_WIDTH 减掉敌机自身宽度否则会有一截机身生成在屏幕外。更新和回收逻辑是配套的void MainScene::updateEnemies() { for (int i m_enemies.size() - 1; i 0; --i) { m_enemies[i].update(); // 每帧下移 ENEMY_SPEED if (m_enemies[i].rect().top() GAME_HEIGHT) m_enemies.removeAt(i); // 飞出屏幕下沿就回收 } }敌机、子弹、爆炸全部按值存放在 QVector 里不 new 不 deleteremoveAt 即回收容器自动管理内存。这套「值对象 反向遍历 出界移除」的打法就是这份资源里最值得抄的一段。爆炸动画 bomb.h 用内联方式封装了逐帧切换逻辑// bomb.h 内联实现的爆炸动画 class Bomb { public: void init(const QPoint pos) { m_pos pos; m_index 0; m_interval 0; m_done false; } void update() { m_interval; if (m_interval % 3 0 !m_done) m_index; // 每 3 帧切下一帧贴图 if (m_index 7) m_done true; // bomb-1~7 共 7 帧播完标记 } bool isDone() const { return m_done; } void draw(QPainter p) { if (!m_done) p.drawPixmap(m_pos, m_frames[m_index]); } private: QPixmap m_frames[7]; // 构造时一次性加载 bomb-1~7.png QPoint m_pos; int m_index{0}; int m_interval{0}; bool m_done{false}; };注意这里把 7 帧 QPixmap 提前加载进数组而不是每帧 draw 时临时拼路径构造。QString 拼接加文件读取放在绘制路径里是新手最常见的性能杀手。爆炸播完要立刻移除void MainScene::updateBombs() { for (int i m_bombs.size() - 1; i 0; --i) { m_bombs[i].update(); if (m_bombs[i].isDone()) m_bombs.removeAt(i); // 播完就扔掉别让容器膨胀 } }5. 避坑记录拿到工程后最常见的五个翻车现场下面这五条是我在实验室、宿舍、公司电脑上前前后后编译同类 Qt 工程时反复撞见的按出现频率排序。每一条都是「现象 → 原因 → 解决」的格式对号入座。5.1 dependent 开头的一长串路径报错Kit 选错工程根本编不了现象打开工程直接构建报:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\QtWidgets not found一长串 ..\ 看着像路径穿越。原因AirplaneWar.pro.user 里记录的 kit 指向原作者机器上的 Qt 5.15.2 msvc2019_64 安装目录你的机器没有这个路径或者你本地装的是 MinGW 编译器但 Qt 包下载成了 MSVC 版编译器对不上。解决第一步删掉 AirplaneWar.pro.user重新用 Qt Creator 打开工程让它重新选 kit。第二步确认套件三件套对齐Qt 包的版本标识msvc2019_64 还是 mingw73_64、编译器MSVC 还是 MinGW、调试器要来自同一套。msvc2019_64 的包必须配 VS2019 的 MSVC 编译器单独装个 MinGW 是编不了的。如果本地没装 Qt去清华开源镜像站下载跟编译器匹配的 5.15.x 包装完回来重新配置。5.2 画面全黑或飞机消失QPixmap::isNull 教你看资源路径现象程序能跑窗口也有但背景和飞机全都不显示或者只有部分贴图显示。原因最常见有两个。一是 res.qrc 里带了 prefix 而代码没带例如文件实际路径是:/res/img-plane_1.png代码里读的却是:/img-plane_1.png二是文件名大小写不一致Windows 上不敏感一换到 Linux 或 macOS 立刻暴露。解决双击 res.qrc 展开每个条目看它带不带前缀。然后在加载贴图的代码后面临时插一行调试语句qDebug() hero.png is null? m_hero.getPixmap().isNull();哪张返回 true就说明哪张的路径没对上。我一般还会点开图片文件把实际文件名和代码里的字符串逐字对一遍——空格、连字符、下划线都容易抄错。5.3 关窗口时程序崩溃定时器还在对象却先走了现象点关闭按钮后程序闪退Qt Creator 提示The program has unexpectedly finished。原因主窗口析构时 QTimer 还没来得及停某次 timeout 回调访问了正在析构的成员或者代码里敌机、子弹用了裸指针 new 出来析构时又没人统一 delete。解决MainScene 的析构函数里第一行写 m_timer.stop()。关窗口先停心脏再让它自然析构MainScene::~MainScene() { m_timer.stop(); // 先停定时器再让成员自然析构 }对象管理上保持这份资源现在的做法——敌机、子弹、爆炸都用 QVector 按值存放removeAt 移除。只要不手动 new/delete九成的内存崩溃都能直接消失。5.4 音效不响bomb.wav 的编码比路径更坑现象别的一切正常bg.wav 和 bomb.wav 一点声音没有或者某个 wav 响、另一个不响。原因QSound 对 wav 编码有硬性要求必须是 PCM 编码的 16-bit 格式。很多工具导出的 8-bit 或压缩格式 wavQSound 会直接静音。两个 wav 一个响一个不响往往就是两个文件的编码不同。解决先qDebug() QSound::isAvailable();确认平台支持然后用 Audacity 把两个 wav 统一转成 16-bit PCM、22050Hz、单声道替换掉 res.qrc 里对应的文件重新编译。想在 Qt 5.15 里更省心的话可以换成 QSoundEffect接口差别不大对编码的容忍度更好。5.5 越玩越卡爆炸帧每帧重复建 QPixmap 的代价现象前 30 秒流畅一两分钟后明显掉帧内存占用只涨不降。原因两个典型。一是爆炸动画没有移除条件m_bombs 里堆积一堆播完的帧二是 draw 里临时拼路径构造 QPixmapQString 拼接加文件读取每帧都在发生。解决按 4.4 的方式isDone 就 removeAtQPixmap 在对象构造时一次性加载成成员数组绘制期只做 drawPixmap。做完这两步这个规模的 2D 游戏在 15ms 定时器下全程顶到 60 帧不往下掉。6. 进阶改造计分关卡、暂停与 FPS 验证的三个小动作把这套资源从「能玩」变成「能演示」我一般会加三个小功能每个改动量都不大但对答辩和演示效果提升明显。6.1 计分驱动的关卡推进把五张背景图用起来资源里有 img_bg_level_1~5.jpg 五张背景图这就是现成的关卡位。按分数切关卡void MainScene::updateLevel() { int level m_score / 2000 1; // 每 2000 分升一级 if (level 5) level 5; // 资源里只有 5 张背景 if (level m_level) return; m_level level; m_map.setBg(QString(:/img_bg_level_%1.jpg).arg(m_level)); m_spawnInterval qMax(10, ENEMY_SPAWN_INTERVAL - (m_level - 1) * 3); }QString::arg 拼出背景资源名正好对上 qrc 里的五张 jpg。敌机生成间隔从 30 帧递减到 10 帧封底第 5 关的极限密度不会把难度顶穿这就形成了一条平滑的难度曲线。6.2 一键暂停Escape 控制定时器顺便清空按键状态void MainScene::keyPressEvent(QKeyEvent *ev) { if (ev-key() Qt::Key_Escape) { // 定时器停 暂停重新 start 继续 m_timer.isActive() ? m_timer.stop() : m_timer.start(); m_pressedKeys.clear(); // 关键: 清空按键缓存 return; } m_pressedKeys.insert(ev-key()); }有个坑必须提醒暂停期间玩家可能还按着方向键恢复的瞬间按键状态还在英雄机会自己飘一段。所以恢复前强制 clear 一次按键表。这个细节不做现场演示时很容易被当成 bug 提问。6.3 把 FPS 显示到标题栏性能优化前先量化改性能前先量化不然不知道改完有没有效果。用 QElapsedTimer 统计真实帧率// mainscene.h 里加两个成员 int m_frames 0; QElapsedTimer m_clock; // 构造函数里 m_clock.start(); // paintEvent 末尾 m_frames; if (m_clock.elapsed() 1000) { setWindowTitle(QString(飞机大战 - %1 FPS).arg(m_frames)); m_frames 0; m_clock.restart(); }这比用 QTimer 的 interval 推算靠谱得多——interval 只是理论上限真实帧率会被绘制耗时和系统调度拉低。标题栏常驻 FPS答辩时直接对着数字讲「我优化后帧率从多少到多少」比空口说流畅有说服力得多。从那以后我每次拿到别人的 Qt 工程第一件事永远是删掉 .pro.user、核对 qmake 版本、再看一眼 res.qrc 的前缀这三步走完八成问题在编译阶段就消失了剩下两成就按第 5 章的清单逐条对。希望帮到你。本文还有配套的精品资源点击获取
返回列表