
简介基于Qt与C实现的飞机大战小游戏项目面向Qt初学者和2D游戏开发入门者适合课程设计、课后练手或项目框架参考可系统学习图形视图框架、定时器动画、键盘响应、碰撞检测及音频播放等关键机制。项目借助QGraphicsView搭建场景并将玩家飞机、子弹、敌机等对象封装为QGraphicsItem派生类整体采用C面向对象设计结构清晰。7z压缩包内共1017个文件约42.88MB包含大量png/jpg图片素材、mp3/ogg音频、cpp/h源码、qrc资源与ui界面文件另含exe可执行程序和doc实践报告便于直接运行、阅读与对照修改。目前已有1024人学习下载完成度较高。结合完整源码、素材与实践报告可梳理对象类设计、场景刷新、子弹发射、敌机生成、碰撞判定与计分逻辑等实现路径资源目录中源码、素材与文档分开存放参考排错记录能更有把握地完成课程设计或向扩展版游戏迭代。1. 从课设到面试晒代码这份Qt飞机大战包给你省掉的不只是一坨重复代码用Qt写飞机大战是C图形编程里绕不过去的一个练手项目。我见过不少人花了两周从零搭框架结果一半时间耗在“为什么画面不刷新”“为什么子弹一多就卡”这种跟玩法无关的事情上。这套基于Qt的飞机大战源码把场景管理、主循环、碰撞检测、对象生命周期全串好了——你拿到的不是几坨孤立的类而是一条能跑通整局的游戏链路。适合正在做课程设计的同学也适合想把C游戏逻辑讲清楚放在简历上的开发者。下面对照着工程文件把关键设计逐个拆开先说架构再说怎么改。2. 为什么选Qt写飞机大战场景-视图架构与主循环的底层逻辑用C做2D小游戏的方案其实不少SDL、SFML、Qt都能跑选Qt的原因很直接飞机大战里最麻烦的不是画图而是维护一堆对象的坐标、层级和碰撞关系。Graphics View框架把这三件事全部收编了scene负责管对象view负责显示item负责自己的行为。这样拆下来游戏逻辑和渲染逻辑之间的干扰就压到了最低。2.1 QGraphicsView这套玩法三个类一台戏场景QGraphicsScene、视图QGraphicsView、图元QGraphicsItem对应到游戏里就是“世界”“窗口”和“实体”。场景是对象仓库所有飞机、子弹、爆炸特效都addItem进去视图是渲染窗口它只负责把场景里可见部分画出来图元则是玩家飞机、敌机、子弹这些具体的东西位置、形状、自己的行为都归它自己管。工程里我一般会先建一个GameWidget类直接继承QGraphicsView然后在构造函数里把场景和窗口参数一次配好// GameWidget 构造函数里的初始化片段 scene new QGraphicsScene(this); scene-setSceneRect(0, 0, 480, 640); // 游戏世界的大小 setScene(scene); // 把场景挂到视图上 setFixedSize(480, 640); // 窗口固定大小避免拖拽变形 setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setRenderHint(QPainter::Antialiasing);setSceneRect这一步很关键后面判断子弹越界、敌机随机出生点都要拿它当基准。我见过有人跳过这步结果场景尺寸默认跟随item区域变化游戏世界越长越大最后敌机越生成越分散。窗口固定大小这点也别省飞机大战是固定视角游戏允许用户拉拽窗口会让坐标逻辑失去基准。滚动条关掉是一样道理否则场景里的item超出视口时view会自动出滚动条视觉上就是画面乱跳。2.2 游戏主循环QTimer是心脏别用while(true)Qt是事件驱动框架直接用while(true)写游戏循环会卡死整个事件循环窗口拖不动、按钮点不了。正统做法是拿一个QTimer当心跳QTimer *timer new QTimer(this); timer-setInterval(16); // 约60FPS感觉掉帧再微调 connect(timer, QTimer::timeout, this, GameWidget::updateFrame); timer-start();setInterval的单位是毫秒16表示每16毫秒触发一次timeout。数值取舍上20毫秒对应50FPS日常看不会太难受10毫秒对应100FPS肉眼感知不明显但CPU占用会涨。我一般先用16再根据机器实际情况调。updateFrame是个空壳函数里面干的事就是“让场景里的所有对象走一步”。QGraphicsScene自带一个advance()会通知场景里所有item执行advance(phase)所以很多工程并不会自己遍历所有对象而是让各item各自响应advance阶段。这里想提醒一句QTimer只是软实时定时器实际操作系统的调度影响跑出来的实际帧间隔会有几毫秒抖动。飞机大战这种休闲游戏完全够用但要明白它不等于一个精确的帧时钟别在它上面挂严格物理计算。2.3 为什么不用QPainter硬画当你要做碰撞检测时就想明白了拿QPainter直接画飞机思路完全没问题小规模Demo甚至更简单。但它有个明显的天花板所有飞机、子弹的位置、尺寸、存活状态都得自己用数组或链表维护每帧手动遍历一遍画出来同时还得自己写碰撞判断。Graphics View把这块全部内建了每架飞机、每颗子弹就是一个对象查它跟谁撞了一条collidingItems()直接给你列出来。对比项QPainter 直接绘制Graphics View 框架对象管理自己维护坐标列表每个实体是独立 item碰撞检测手写矩形/圆计算collidingItems() 内置渲染优化全量重绘按变化区域局部刷新内存释放手动遍历清理removeItem 后由 item 自管选择边界同样清楚如果只是做一个“按空格发射看烟花”的演示QPainter可能更快一旦进入多敌机、多子弹、带掉落物的完整玩法Graphics View能把对象生命周期的问题从“游戏逻辑”里抽出来代码结构清晰得多。这套飞机大战包走的正是后者后面几章碰撞检测全部建立在collidingItems的基础上。3. 把玩家飞机和子弹跑起来键盘响应与对象管理的标准写法这一章从玩家控制链路切进去先把“飞机能动、子弹能发”这条主线跑起来。资源包里最核心的可复用逻辑也集中在这里实体定义、事件响应、对象回收三条线串起来就是一个能玩的骨架。3.1 玩家飞机的实体一张图、一个碰撞矩形继承哪个Item最省事答案通常是QGraphicsPixmapItem。飞机不像形状那么简单直接用贴图承载外观避免手动画图。工程里资源一般放在qrc文件里以“:/”开头引用class Player : public QGraphicsPixmapItem { public: Player() { // 图片放在资源文件里路径以 qrc 中的 / 开头 setPixmap(QPixmap(:/assets/player.png)); setPos(240 - 25, 640 - 80); // 初始位置底部居中 // 可聚焦才能接收键盘事件 setFlag(QGraphicsItem::ItemIsFocusable); setFocus(); } QRectF boundingRect() const override { // 贴图有透明边碰撞矩形收紧一些更公平 return QRectF(4, 4, 44, 54); } int speed 6; };boundingRect这里藏着一个小门道。默认情况下QGraphicsPixmapItem的boundingRect就是图片尺寸但很多飞机素材带一圈透明边缘肉眼看着没撞上系统判定已经撞上了。把碰撞矩形往里收紧几个像素玩家体感会舒服很多。setFocus这一步尤其容易被漏漏了之后键盘事件永远到不了这个item按键失灵的问题到第5章还会专门展开。3.2 键盘移动与子弹发射事件分发的两种路子键盘事件可以在View层重写keyPressEvent也可以让item自己接收。这套工程走的是View层统一收集再分发的路子逻辑集中在一个地方好调试void GameWidget::keyPressEvent(QKeyEvent *event) { if (!player) return; switch (event-key()) { case Qt::Key_Left: // 限制在场景左边界内防止飞出窗外 player-setPos(qMax(scene-sceneRect().left(), player-x() - player-speed), player-y()); break; case Qt::Key_Right: player-setPos(qMin(scene-sceneRect().right(), player-x() player-speed), player-y()); break; case Qt::Key_Space: shootBullet(); break; default: break; } }移动用setPos而不是moveBy好处是可以顺手做边界钳制。qMax和qMin保证了玩家飞机不可能越过场景左右边界这条逻辑省掉了后面一堆“飞机不见了”的bug。shootBullet里创建子弹并把它放到玩家头顶void GameWidget::shootBullet() { Bullet *bullet new Bullet(); qreal bulletX player-x() player-boundingRect().width() / 2 - 3; // 让子弹从机头中心射出 bullet-setPos(bulletX, player-y() - 20); scene-addItem(bullet); }那3像素是为了补偿子弹自身宽度的一半这样子弹看起来是从枪口正中央飞出去的而不是歪在一边。如果你换了一张不对称的飞机贴图这个补偿值要怎么算最稳妥的方法是把玩家宽度除以二再减去子弹宽度除以二写成一个通用表达式不要像我刚开始那样手写死数字换个素材就翻车。3.3 子弹的飞行与回收在advance里移动在越界时自毁子弹本身是一个简单图元加上一个advance方法让它每帧往上跑class Bullet : public QGraphicsRectItem { public: Bullet() { setRect(0, 0, 6, 14); setBrush(QBrush(Qt::yellow)); // 先用色块占位后期换贴图 } void advance(int phase) { if (phase 0) return; // phase 0 留给碰撞检测 moveBy(0, -12); // 每帧向上12像素 if (y() -20) { // 飞出上边界 scene()-removeItem(this); delete this; } } };advance是Graphics View里的两阶段通知机制阶段0用于检测和计算阶段1用于实际移动。很多新手把两种逻辑混在一个phase里结果出现“物体自己把自己移到场景外才被删”的尴尬情况。越界阈值用-20而不是0是为了让子弹完全消失在视野外再回收避免在窗口边缘出现一闪而过的残影。子弹速度12是一个偏快的初始值调整它时要注意和定时器间隔联动间隔16毫秒、速度12意味着每秒向上飞750像素穿越640像素高的场景大约需要0.85秒。4. 敌机与碰撞检测难度曲线靠这三个参数撑起来敌机生成、碰撞判定、得分反馈这三个模块决定了游戏玩起来是“爽”还是“憋屈”。我拆这份工程时最大的感受是难度平衡根本不是玄学就是三个参数在互相配合。4.1 敌机生成器随机数与出怪节奏的三段式控制敌机每隔一段时间从场景顶部冒出来x坐标随机y坐标固定从负值入场这样飞出来时有一个“入场”过程而不是凭空出现在玩家视野里void GameWidget::spawnEnemy() { Enemy *enemy new Enemy(); int x QRandomGenerator::global() -bounded(int(scene-sceneRect().width())); enemy-setPos(x, -enemy-boundingRect().height()); scene-addItem(enemy); }QRandomGenerator::global()是Qt 5.10以后推荐的随机数写法不需要你手动srand也不用担心多线程下的种子冲突。生成间隔的控制才是难度曲线的核心void GameWidget::setupSpawnTimer() { spawnTimer new QTimer(this); spawnTimer-setInterval(900); // 初始间隔900毫秒 connect(spawnTimer, QTimer::timeout, this, GameWidget::spawnEnemy); spawnTimer-start(); connect(spawnTimer, QTimer::timeout, this, [this]() { // 得分越高生成间隔越短但保留下限防止弹幕过密 int interval 900 - score / 50 * 30; spawnTimer-setInterval(qMax(300, interval)); }); }难度参数初始值变化方向下限生成间隔900ms随得分升高缩短300ms敌机速度2随波次提升8同屏数量上限5逐步放开15这三个参数是一个互相制约的三角生成间隔决定敌机出现的频率速度决定单架敌机的威胁程度同屏上限决定场面混乱度的封顶值。只调间隔不调速度玩家会觉得“敌人变多了但都好躲”只调速度不调间隔画面上乱七八糟却打不到几下。4.2 碰撞检测矩形包围盒就够了别一上来就做像素级碰撞检测这里我见过不少过度设计有人一上来就抠像素级透明通道碰撞结果游戏里几十颗子弹和十几架敌机互相检测帧率直接崩了。飞机大战这种快节奏游戏矩形包围盒足够。void GameWidget::checkCollisions() { // 拿一份场景对象快照不能在遍历过程中直接删 QListQGraphicsItem* allItems scene-items(); foreach (QGraphicsItem *bullet, allItems) { if (!bullet || bullet-type() ! Bullet::Type) continue; QListQGraphicsItem* hits bullet-collidingItems(); foreach (QGraphicsItem *hit, hits) { if (hit-type() Enemy::Type) { scene-removeItem(hit); delete hit; scene-removeItem(bullet); delete bullet; emit enemyDestroyed(); // 得分信号往外抛 break; } } } }两个foreach都用了一份快照列表这是最关键的一步。直接在scene-items()返回的列表上边遍历边删除迭代器会失效闪退是早晚的事。collidingItems默认使用shape()作为碰撞外形QGraphicsRectItem的shape就是矩形如果用了带透明边的贴图记得把shape也收紧否则会出现“子弹离飞机还有半个身位就算命中”的判定。type()的用法也值得说一句每个自定义item类都要实现一个静态常量Type成员并重写type()返回它。这样在碰撞检测里可以快速过滤对象类型而不是靠qgraphicsitem_cast或者动态转类型判断性能差距在对象一多时非常明显。4.3 信号槽把得分与生命串起来游戏状态机的雏形碰撞结果不能直接写在UI上而是用信号事件往外通知。这套工程的思路是GameWidget定义两个信号一个管加分一个管扣命class GameWidget : public QGraphicsView { Q_OBJECT signals: void enemyDestroyed(); void playerHit(); };扣命信号从碰撞检测里发出去GameWidget在构造函数里统一绑定connect(this, GameWidget::enemyDestroyed, this, [this]() { score 10; scoreText-setPlainText(QString(得分: %1).arg(score)); }); connect(this, GameWidget::playerHit, this, [this]() { lives--; if (lives 0) { QMessageBox::information(this, 结束, QString(最终得分: %1).arg(score)); close(); } });把得分和生命挂在信号上而不是散落在碰撞函数里好处是后续加音效、加特效时不需要再改动碰撞检测代码直接connect新的处理函数进去就行。这其实是游戏状态机的一个原始雏形逻辑事件和展示反馈完全解耦。5. 避坑排查从编译报错到运行时玄学的五个高频问题这份工程我自己跑的时候踩了不少坑有的是Qt版本差异有的纯粹是抽象层次没想清楚。按下述问题挨个对一遍能帮你省下大把查资料的时间。5.1 编译期模块配置与头文件的高频雷区第一条QGraphicsView头文件找不到。现象凡是用了QGraphicsScene、QGraphicsView相关头文件的.cpp编译直接报错“QGraphicsScene: No such file or directory”。原因Graphics View从Qt 5开始归属于widgets模块工程.pro里只写了core和gui头文件搜索路径里根本没有widgets目录。解决把.pro文件改成下面这样然后重新qmake。QT core gui widgets第二条引入音效报模块缺失。现象代码里用了QSoundEffect之后编译或链接阶段提示找不到这个类。原因Qt 6对multimedia模块做了拆分很多人以为.pro里写一个QT multimedia就够了实际对应类在不同版本里归属有差异。解决按你本地Qt版本的官方模块说明补全multimedia相关项并确认信号槽连接里没有把QSoundEffect的播放函数当普通成员函数来绑定。5.2 运行期闪退、卡顿、按键失灵的三条记录第三条遍历场景时删item导致间歇性闪退。现象敌机被击毁的瞬间程序崩溃报错有时是“pure virtual method called”有时直接无响应。这种崩溃时好时坏跟子弹和敌机的排列顺序有关。原因在scene-items()返回的列表上一边遍历一边delete item迭代器失效触碰已释放内存时触发未定义行为。这是典型的“一运行就没事一调试就崩溃”的玄学问题。解决先把所有需要删除的对象收集到一个临时列表遍历结束后统一销毁。删除时固定走scene()-removeItem(item); delete item;两步不省任何一步。第四条程序启动后按键没反应鼠标点一下窗口又好了。现象游戏窗口正常显示但按方向键飞机纹丝不动点击窗口后控制又恢复正常。原因QGraphicsView默认不接受键盘焦点新窗口启动时焦点处于“悬空”状态键盘事件没有投递目标。解决在GameWidget构造函数里加一行setFocusPolicy(Qt::StrongFocus)。如果让某个item直接接收键盘记得给它setFlag(QGraphicsItem::ItemIsFocusable)并调用setFocus()。第五条子弹一多就卡顿。现象后期子弹和敌机数量上来画面明显掉帧飞机移动出现残影。原因QGraphicsView默认的viewport更新策略是重绘整个视口每次update都把全屏重新画一遍加上碰撞检测里频繁调用collidingItemsCPU开销直线上升。解决把viewport更新模式改成只刷新变化区域。setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate);顺手把定时器间隔从10毫秒回调到16毫秒肉眼基本无感知CPU占用会明显下降。6. 进阶验证音效、道具与关卡数据分离的三个改动方向这套飞机大战跑通之后下一步不是急着加新玩法而是先把三个最实用的方向做进去。第一个方向是音效接入。射击音和爆炸音用QSoundEffect就够关键是音效文件的加载放在初始化阶段不要在碰撞检测函数里动态加载文件否则第一声枪响会卡一下。我一般会在GameWidget里预加载两个QSoundEffect成员发射和命中各一个播放时只调play()。第二个方向是道具系统。设计一个Collectible类继承QGraphicsPixmapItem在地图里随机生成玩家碰到后走单独的碰撞分支。道具类型用枚举控制加分、加速、护盾各是一个分支再配一张掉落概率表。第三个方向是关卡数据分离。把敌机生成间隔、速度上限、掉落概率这些数值从代码里抽出来放到一个JSON或配置文件里关卡递增只改数据不动逻辑。这样调平衡不用重新编译工程调完参数直接重启看效果。验证方法也很简单在updateFrame里打印一帧耗时确认稳定在16毫秒左右再用Qt Test写一个模拟按键序列连续发送左移和空格键断言玩家坐标和子弹数量符合预期。这些验证做一轮后面加功能就不容易把核心玩法改坏。以前我做这玩意的时候最惨的一次是把所有逻辑塞进一个GameWidget里改一个参数要翻三处地方。从那以后我每次动手前都强制自己先画一张类关系图写清楚哪个类负责哪些状态再开始编码——这份工程也是这样组织的你改起来会直观很多。希望看到这里能给你省下几个通宵。本文还有配套的精品资源点击获取