
简介基于QT框架开发的陨石撞飞机游戏完整项目属于C期末大作业源码与文档打包。项目融合图形界面设计、游戏逻辑开发与面向对象编程适合计算机专业学生完成课程设计、期末作业或毕业设计也适合需要实战练习的初学者。整体难度适中代码经助教审定和导师指导QT提供界面渲染与交互支持。资源包共含79个文件、约34.93MB主要由8个cpp源文件、8个h头文件、47张PNG游戏素材、jpg背景图、ico图标、wav音效、qrc资源文件及README文档构成能清晰展示项目结构。通过源码可学习碰撞检测、动态精灵、游戏关卡与音效管理等实现配套文档包含需求分析、设计说明和测试报告便于理解项目全貌并快速上手复用。目前已有59人学习项目以98分高分通过评审所有源码均本地编译调试过保证稳定可运行适合作为完整可靠的实践参考样本。1. 基于QT的陨石撞击飞机游戏核心不是绘图而是这三个机制拿到一个基于QT的陨石撞击飞机游戏源码包很多人的第一反应是打开.pro或CMakeLists.txt直接编译。但如果你把它当成一份 C 期末大作业来对待拉开差距的从来不是代码行数而是你有没有把 QGraphicsView 场景图模型讲清楚飞机是一个能用键盘控制的图元陨石是定时器批量生成的图元碰撞是两个图元之间的相交判定而分数、生命值只是挂在信号槽上的状态更新。这篇笔记按从零搭项目的顺序把这套机制一步步拆开写顺带把环境报错、中文乱码和性能问题这些实际踩过的坑也列在一起。适合正在赶 C 期末作业、又希望代码能上台讲明白的人。2. 搭一个能跑的 QT 游戏骨架项目配置、main 函数与第一帧画面2.1 为什么选 QGraphicsView 而不是 QWidget 硬画刚开始做这类小游戏会有人建议直接继承QWidget重写paintEvent用QPainter画飞机和陨石。这种做法的优点是代码很直白一个绘图函数一个定时器一个更新触发。但它的代价很快会显现——陨石一多你得自己维护所有物体的位置列表自己判断绘制顺序自己写碰撞的遍历稍微加个旋转效果就要把坐标变换重算一遍。QGraphicsView 这套框架把这些问题拆成了三层QGraphicsScene负责管理图元的生命周期QGraphicsItem是游戏中所有可移动对象的基类QGraphicsView只负责把场景渲染到窗口上。每颗陨石、每架飞机都是一个独立图元自带坐标、旋转、层级和碰撞检测接口。你需要做的不是“画一个游戏”而是“往场景里添加图元然后让它们动起来”。从期末作业的角度看用 QGraphicsView 还有一个直接好处答辩时老师看到的是你分出了PlaneItem、MeteorItem、GameController这样的类而不是把一万行代码塞进paintEvent里。这本身就是设计能力的证明。2.2 最小工程配置.pro 文件与 main.cpp 的职责Qt Creator 新建项目时如果选了 qmake 构建核心配置就集中在.pro文件里。我一般会把它精简到这种程度QT core gui widgets TARGET MeteorGame TEMPLATE app CONFIG c11 SOURCES \ main.cpp \ gamewindow.cpp \ planeitem.cpp \ meteoritem.cpp HEADERS \ gamewindow.h \ planeitem.h \ meteoritem.h逐行说下关键参数。QT core gui widgets这里一定不能省掉widgetsQGraphicsView 依赖的是 Qt Widgets 模块只写core gui会在编译时报一堆“无法打开包含文件 QGraphicsView”的错误。CONFIG c11是给旧版本 Qt Creator 看的如果你用的是 Qt 6默认已经支持 C17这行可以不加。TARGET就是生成的 exe 名称。main.cpp 的职责只有一个把事件循环跑起来。#include QApplication #include gamewindow.h int main(int argc, char *argv[]) { QApplication a(argc, argv); GameWindow w; w.show(); return a.exec(); }QApplication负责初始化 Qt 的运行环境w.show()让主窗口显示出来a.exec()进入事件循环。程序所有后续响应——键盘事件、定时器触发、信号槽调用——都是在exec()之后发生的。所以你千万不要在main里写什么while(1)循环事件循环本身就是死循环。2.3 第一帧画面场景、图元和视图的创建顺序在 GameWindow 的构造函数里要做三件事创建场景、创建视图、往场景里放图元。顺序错了会出现黑屏。GameWindow::GameWindow(QWidget *parent) : QMainWindow(parent) { // 1. 创建场景并设置逻辑坐标系范围 scene new QGraphicsScene(this); scene-setSceneRect(0, 0, 800, 600); // 2. 创建视图并放入主窗口中央 view new QGraphicsView(scene, this); setCentralWidget(view); // 3. 创建飞机图元并指定初始坐标 plane new PlaneItem(); plane-setPos(400, 520); scene-addItem(plane); }setSceneRect(0, 0, 800, 600)定义的是游戏的逻辑坐标系单位不是像素而是场景坐标。为什么推荐固定成 800x600因为后面陨石的随机生成、碰撞边界判断都依赖这套坐标写死成常量方便调整。plane-setPos(400, 520)是图元中心点的位置这一点容易搞混——QGraphicsItem 的坐标默认是相对于图元中心不是左上角。PlaneItem最简单的一种实现是继承QGraphicsPixmapItem直接把一张飞机图片塞进去class PlaneItem : public QGraphicsPixmapItem { public: PlaneItem() { setPixmap(QPixmap(:/images/plane.png)); setTransformOriginPoint(boundingRect().center()); } };这里setTransformOriginPoint是给后续旋转准备的先把旋转中心固定在图元中心。如果你暂时没有素材图也可以直接用QGraphicsRectItem加颜色画一个方块飞机答辩前再换贴图。3. 陨石生成、碰撞检测与计分让游戏真正“有得玩”的里子3.1 用 QTimer 批量生成陨石间隔和随机位置的参数怎么定飞机画出来后第二步是让陨石源源不断从顶部落下来。这里的关键不是“写一个生成函数”而是“用什么节奏生成”。最常见的做法是挂一个QTimer定时发射信号在槽函数里创建一颗陨石。// 初始化定时器 QTimer *meteorTimer new QTimer(this); // 每 1.5 秒生成一颗陨石 meteorTimer-start(1500); connect(meteorTimer, QTimer::timeout, this, GameWindow::spawnMeteor);槽函数这边不能只创建图元就完事还要处理好随机坐标和越界回收void GameWindow::spawnMeteor() { MeteorItem *meteor new MeteorItem(); // 横坐标在 (0, 760) 范围内随机纵坐标固定从屏幕外进入 int x QRandomGenerator::global()-bounded(800 - 40); meteor-setPos(x, -50); // 每个陨石的下降速度在 2~5 之间随机 meteor-setFallSpeed(QRandomGenerator::global()-bounded(2, 5)); scene-addItem(meteor); meteorList.append(meteor); }说两个容易被忽略的点。QRandomGenerator是 Qt 5.10 之后推荐的随机数接口旧代码里的qrand()在新版本里会被标记为 deprecated虽然能编译但会在编译窗口刷一圈警告。陨石的横坐标上限写成800 - 40是因为陨石图元本身有 40 的宽度如果让它从 800 的位置生成一半会掉出场景边界。这种细节不处理也能运行但玩起来会觉得陨石“凭空卡在边上”。定时器间隔 1500 毫秒只是个起步值。实际调参时我会把间隔放到变量里做成关卡难度每过 30 秒就把间隔缩小 100 毫秒最低到 500 毫秒。难度曲线是期末作业答辩的高频加分点老师常问“游戏难度怎么递增的”。3.2 矩形碰撞检测期末作业精度够用的一种实现碰撞检测在小游戏里一般有三种做法矩形相交、圆形距离判断、像素级检测。像素级检测最精确但性能最差而且需要访问位图数据期末作业里基本用不上。圆形判断适合球形物体对飞机这种不规则形状不够友好。最常用、也最容易讲清楚的是矩形相交——Qt 直接用collidesWithItem帮你算好了。void GameWindow::checkCollisions() { QListQGraphicsItem * itemsToRemove; for (MeteorItem *meteor : meteorList) { // 陨石与飞机发生碰撞 if (meteor-collidesWithItem(plane)) { itemsToRemove.append(meteor); health--; emit healthChanged(health); } // 陨石飞出屏幕底部也回收掉 else if (meteor-pos().y() scene-sceneRect().height()) { itemsToRemove.append(meteor); } } // 统一清理避免在遍历过程中直接删除 for (QGraphicsItem *item : itemsToRemove) { MeteorItem *meteor static_castMeteorItem*(item); meteorList.removeOne(meteor); scene-removeItem(item); delete item; } }这段代码里最值得讲的是“为什么要先收集再删除”。如果在第一个for循环里直接delete meteor会破坏meteorList的迭代器结构下一次就指向了已经释放的内存程序会直接崩掉。分两个循环看起来多写了几行但它保证删除操作发生的时候列表结构已经稳定了。这是很多新手写碰撞检测时最容易踩的崩溃原因。关于collidesWithItem的实现细节默认用的就是 item 的shape()与另一个 item 的shape()做相交判定对非透明像素区域计算的是矩形边界。如果你的飞机图片带了透明翅膀碰撞会有“差一格打中了”的体感。想让判定更贴合可以重写shape()返回一个较窄的矩形QPainterPath MeteorItem::shape() const { QPainterPath path; // 缩小到原图 70% 的区域提升容错 path.addRect(boundingRect().adjusted(6, 6, -6, -6)); return path; }这个.adjusted(6, 6, -6, -6)挺玄学的具体缩多少没有标准答案完全靠手感调。不过它能当作一个不错的答辩讲点你主动处理了“碰撞判定过于严格”这个问题。3.3 生命值与计分用信号槽把界面和逻辑拆开游戏状态变更是这类项目最容易写乱的地方。最常见的问题是把 score、health 定义成全局变量界面标签直接引用改起来牵一发动全身。我的习惯是把状态封装成一个GameState类用信号把变化广播出去。class GameState : public QObject { Q_OBJECT public: explicit GameState(QObject *parent nullptr); int score() const { return m_score; } int health() const { return m_health; } public slots: void addScore(int points); // 击毁陨石加分 void decreaseHealth(int damage); // 被撞扣血 signals: void scoreChanged(int newScore); void healthChanged(int newHealth); void gameOver(); };界面层和游戏逻辑层各自监听信号connect(gameState, GameState::scoreChanged, this, GameWindow::onScoreChanged); void GameWindow::onScoreChanged(int newScore) { scoreLabel-setText(QString(分数%1).arg(newScore)); }用信号槽的好处是你不必在每次分数变动的地方手动改标签文字逻辑层只负责改数据界面层只负责响应。如果以后想加一个音效播放再连一个槽到scoreChanged就行逻辑层一行不用动。4. 把源码整理成能拿高分的课程设计类图、注释与答辩准备4.1 类图与模块划分控制在 6 个类以内讲清楚课程设计的文档老师首先翻的是设计部分看得最多的是一张类图。类图不是越复杂越好而是越能自圆其说越好。一个典型的陨石撞击飞机游戏可以拆成这几个类类名父类职责GameWindowQMainWindow创建场景和视图组装所有模块PlaneItemQGraphicsPixmapItem飞机图元处理键盘移动MeteorItemQGraphicsPixmapItem陨石图元控制下落和旋转GameStateQObject分数、生命值的状态存储与信号广播GameControllerQObject定时器管理、碰撞检测调度QGraphicsSceneQt 自带图元管理和场景范围前 5 个类足够讲清楚一个游戏闭环。写文档的时候每个类配 3 行说明继承关系、核心成员、对外信号或槽。不要用 UML 工具生成一大张复杂的图老师更愿意看到你用手画逻辑关系因为那说明你真的懂。4.2 README 和注释规范老师会翻开看的几个位置源码交付件里README 是第一个被打开的但它通常被写在“怎么编译”这一个层面。更好的写法是把“玩法说明”和“架构简介”放进去编译步骤反而简单提两句就行。注释方面我见过的期末作业有两种极端一种是没有注释另一种是每行都写“// 设置分数为0”这种废话。比较合适的密度是——类和函数声明处写职责和参数含义函数内部的逻辑只注释非直觉的分支。比如前面碰撞检测里的“先收集再删除”这个有必要注释而health--这种一行代码看得懂的不写反而清爽。有一点要提醒Q_OBJECT类里的信号和槽最好在头文件里写成带注释的接口块因为 Qt 的元对象编译器会扫描这里生成 moc 文件。注释写清楚参数是什么单位方便接下来调试。4.3 答辩现场最爱问的 6 个问题与应对思路老师问的问题通常围绕三个方向你做了什么、你怎么做的、哪里证明是你做的。下面是出现频率最高的几个提问回答思路游戏的工作循环是怎么跑的事件循环 QTimer 信号触发绘制由 QGraphicsView 自动完成碰撞检测的精度如何默认边界相交裁剪 shape() 后判定不采用像素级检测的原因陨石越来越多性能会劣化吗移出场景的陨石统一 delete不泄露内存后续可用对象池优化分数是怎么更新的GameState 信号广播给界面逻辑层与表现层解耦飞机移动为什么不用死循环Qt 采用事件驱动键盘事件通过 keyPressEvent 分发不需要独立线程这个项目的难点是什么图元管理与删除时机遍历中删除会破坏迭代器导致崩溃最后一问实际上是个“送分题”很多同学会把难点说成“代码量太大”这就浪费了表现机会。更聪明的说法是碰撞检测选择了矢量边界方案避开了像素级检测的性能开销然后在 shape() 中做了容错调整。把一个具体问题讲透比列举十个名词有用得多。5. QT 陨石游戏避坑指南环境报错、中文乱码和定时器翻车现场5.1 环境配置依赖路径报错和 QT 5.15.2 的编译玄学这部分写的每一条都是实际调试中反复见过的。现象1qmake 构建时报如下错误:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets 无法找到原因构建目录里 Qt 路径泄漏。这类路径通常来自 Qt Creator 自动生成的临时文件常见诱因有两个一是把 Qt 安装到了带空格或中文的目录里如C:\Qt\5.15.2-x二是 Kit 选择错误MSVC 版本与编译器版本不匹配。前者改了安装路径就恢复后者要去 Qt Creator 的Kits设置里重选编译器保持 Qt 库与编译器的位数一致。解决优先重装到纯英文无空格路径再检查 Kit 的 Qt version 与 MSVC 位数是否匹配。注意链接器报错和预处理器报错的解决方向不一样前者查.lib依赖后者查包含目录。现象2程序编译通过运行时直接崩溃提示This application failed to start because no Qt platform plugin could be initialized。原因这是 Qt 程序最常见的运行时报错。多半是你把生成的 exe 单独拷贝出来运行exe 旁边没有platforms插件目录。解决开发期直接返回 Qt Creator 里运行要发布则用 windeployqt 把运行库和插件目录复制到 exe 同目录。你也可能会在 vscode 或控制台下调试这个 exe同样要注意PATH环境变量需包含 Qt 的bin目录。5.2 中文乱码源码文件的编码陷阱现象编译时字符串常量里的中文在界面上显示为乱码报错里出现C4819警告。原因国内 Windows 系统默认编码是 GBK而 Qt 6 之前的版本默认按 UTF-8 读取源码。当你在中文版 Windows 上用记事本新建一个 .cpp 文件默认保存为 ANSIQt Creator 打开后发现编码不一致中文自然炸掉。解决两步走。第一所有源文件统一改为 UTF-8 without BOMQt Creator 的Edit - Select Encoding里可以批量转换。第二在.pro里加上编译选项强制编译器按 UTF-8 解析msvc { QMAKE_CXXFLAGS /utf-8 }另外字符串里有\n这种转义时不要用 utf-8 字面量拼接Qt 里首选QString::fromUtf8(中文)或直接用QStringLiteral。5.3 定时器与事件循环槽函数不执行是哪里出了问题现象QTimer 明明调用了start但timeout槽函数一次都不执行。原因90% 的情况是QTimer对象没有父对象且声明在局部函数里函数结束定时器随栈内存析构信号自然发不出来。另外还有一种情况是主线程被某个高耗时操作阻塞事件循环无法处理定时器事件常见诱因是在循环里写了Sleep或者大量同步磁盘操作。解决定时器成员变量用new QTimer(this)确保生命周期跟随窗口耗时操作用异步方式代替。做一个检查小技巧在槽函数入口用qDebug()打印一条日志确认是不是被绑定或发射频率的问题。5.4 陨石数量多了以后界面明显卡顿现象游戏运行 2-3 分钟后帧率下降CPU 占用飙升。原因这类项目最常见的两种内存泄漏。一是陨石飞出屏幕后只是removeItem没有delete图元对象还在内存里二是每次meteorList里都是失效指针碰撞检测还在遍历它们。Qt 的QGraphicsScene对已 remove 的 item 不会自动回收内存越积越多。解决拿掉陨石的路径上delete item补上。另外还有一个细节值得注意QGraphicsScene设备坐标较大时重绘区域过大会加剧 CPU 消耗可以把QGraphicsView的setRenderHint打开抗锯齿性能会稍差但视觉好很多如果追求 fps可以在陨石数量和纹理精度之间做取舍。另外补充一个稍冷门的点如果陨石图片是QPixmap创建后反复缩放会触发重复的图像采样代价不小。预先在加载时固定尺寸再入库运行时就不要setPixmap频繁变更了。6. 让期末作业往前再走一步旋转动画、碰撞音效与最高分存档6.1 用 QPropertyAnimation 给陨石加旋转效果静止的陨石下落很生硬。Qt 里做旋转动画最省事的是QPropertyAnimation直接改图元的rotation属性不用自己写坐标变换QPropertyAnimation *animation new QPropertyAnimation(meteor, rotation); animation-setDuration(800); animation-setStartValue(0); animation-setEndValue(360); animation-setLoopCount(-1); // 无限旋转 animation-start();这样每颗陨石从出现到消失会匀速转圈视觉上比减速跑或者平移生动很多。注意设置循环次数为 -1 前给陨石绑定析构时停止动画否则动画对象持有了已删除的图元指针会触发 Qt 的崩溃保护。6.2 用 QSoundEffect 接碰撞音效音效不建议用 QMediaPlayer开销大、初始化慢。QSoundEffect对小文件更合适链路很短QSoundEffect *hitSound new QSoundEffect(this); hitSound-setSource(QUrl(qrc:/sounds/hit.wav)); hitSound-setVolume(0.5f); hitSound-play();计分时play()一发零状态同步成本。音频文件尽量压到 100KB 以内wav 格式是 Qt 支持最稳的直接用 44100Hz / 16bit 的单声道就行。6.3 最高分存档用 QDataStream 写本地记录期末作业一般不需要数据库落一个本地二进制文件就够了同时也能顺便演示 Qt 的持久化接口。数据格式用QDataStream写 QHash读取时要注意版本匹配void saveHighScore(int score) { QFile file(save.dat); if (!file.open(QIODevice::WriteOnly)) return; QDataStream out(file); out score QDateTime::currentDateTime().toString(Qt::ISODate); file.close(); } int loadHighScore() { QFile file(save.dat); if (!file.open(QIODevice::ReadOnly)) return 0; QDataStream in(file); int score 0; in score; file.close(); return score; }存文本格式也行但二进制文件防止老师用记事本直接改档对课程设计来说反而更稳妥。读档之后把历史最高分挂在标题栏或界面角落游戏结束弹窗时对比一下很容易形成一个自洽的功能闭环。作为一个自己也用 QGraphicsView 写过不下五次类似小游戏的人我的习惯是拿到题目先别写代码把对象模型在纸上画出来谁持有谁、谁发信号、谁响应、谁删除图元。这四行想清楚剩下就是填充逻辑的问题。真正让人一遍遍翻车的从来不是 C 语法而是时机问题——对象什么时候被析构、动画什么时候被中断、定时器什么时候停。后面的同学做这类课题时建议把最后一步调试日志留给对象生命周期而非绘制效果。希望这篇记录能帮你少熬两三个凌晨。本文还有配套的精品资源点击获取