☰
用Qt复刻飞机大战:掌握QGraphicsView游戏架构与碰撞检测
2026/10/20 18:01:38 网站建设 项目流程

简介:这份Qt飞机大战游戏压缩包是基于Qt框架实现的完整飞机大战项目工程,面向Qt初学者和游戏开发爱好者,帮助理解2D游戏从界面搭建到逻辑控制的完整流程。包内共223个文件,压缩包大小6.21MB,主要包含105个.h头文件、32个.cpp源文件、60个PNG图片资源、5个WAV与2个OGG音频,以及UI界面、GLSL着色器、pro工程文件和qrc资源文件等,覆盖了游戏图形、音效、界面和场景配置等各个开发环节。资源已有280人学习下载。源码按功能拆分出玩家、飞机、核心逻辑及人机/双人AI相关模块,可清晰看到玩家操控、敌机生成、碰撞检测、AI对战等核心逻辑的实现方式;结合QPainter绘图、信号槽通信和Qt动画框架,展示了如何在Qt中渲染飞机、子弹和爆炸效果并处理实时交互。整体代码结构清晰,既适合配合Qt教程逐段研读,也适合作为课程设计或毕业设计的参考工程,对提升Qt桌面应用与游戏开发能力很有帮助。

1. 先用 Qt 复刻一个飞机大战,值得吗

很多人看到「飞机大战」四个字,第一反应是「这不就是小游戏嘛」。但这个用 Qt 写的飞机大战,其实把一款游戏引擎最核心的几个模块都占了:事件循环、场景与精灵、碰撞检测、音效播放、状态切换。跑一遍会发现,整个游戏就建立在一个QGraphicsScene上,你的战机、敌机、子弹都是场景里的QGraphicsItem,一百多行核心代码能从按下按键到敌人爆炸完整跑通。适合有 C++ 基础但没接触过 Qt 游戏开发的人,正好用它来弄懂游戏循环在桌面框架里怎么落地;也适合熟手快速复习 Qt 的场景管理模型,因为整个项目拆开看,就是一个缩水版的引擎架构。

2. 工程解剖:QGraphicsView 框架下的游戏架构

2.1 目录结构:先看文件怎么排

拿到源码第一步,不是直接编译,而是先看目录结构。这个项目常见的排法是这样:

qt-aircraft-game/ ├── CMakeLists.txt # 构建脚本(qmake 版则对应 .pro 文件) ├── resources/ │ ├── images/ # 战机、敌机、子弹、背景 PNG │ └── sounds/ # 开火、爆炸、背景音乐等音频 ├── include/ │ ├── mainwindow.h # 主窗口:菜单、游戏重新开始入口 │ ├── gamescene.h # 游戏场景:敌机生成、碰撞检测、计分 │ ├── player.h # 玩家战机:键盘移动与开火 │ ├── enemy.h # 敌机:速度、生命值、分数 │ ├── bullet.h # 子弹:方向、速度、越界逻辑 │ └── config.h # 全局参数:场景尺寸、帧率、速度系数 └── src/ ├── main.cpp ├── mainwindow.cpp ├── gamescene.cpp └── ...

这里最值得看的是gamescene.h,它承担了「导演」的职责。整个游戏没有传统意义上的while循环,而是由一个QTimer每隔约 16 毫秒触发一次gameLoop(),场景里的所有对象都用事件驱动的方式推进。如果你之前写过多线程游戏循环,会发现 Qt 这套把「每帧更新」和「界面重绘」都塞进了事件循环,反而少了很多同步问题。

资源目录里的 PNG 通常要带 Alpha 通道,否则战机背景会显示成一个黑方块。音效文件用 WAV 或 OGG 都可以,但要留意 Qt 在不同平台解码器的支持情况,这一块我在第 4 章的避坑记录里专门说。

2.2 技术选型:为什么是 QGraphicsView 而不是 QPainter

在这个项目里,用 QGraphicsView 是比直接用 QPainter 更合理的选型。直接QPainter::drawPixmap画图,性能上限高,但碰撞检测、点击命中、层级管理都要自己写;而 QGraphicsView 提供了场景(Scene)和条目(Item)的体系,项目里的飞机、子弹天然就是一个个 Item,collidingItems()是现成的 API。

整个架构的轴线是:QGraphicsView负责把场景绘制到窗口,QGraphicsScene存对象,QGraphicsItem代表个体。玩家按方向键时,keyPressEvent把按键状态记录在 player 对象里,下一帧在gameLoop()中根据状态更新位置,而不是「按下立刻移动」。这样能保证移动速度与帧率挂钩,不会因为按了一下键盘就瞬移。

参数上,场景尺寸我一般固定为 480×700,对应config.h里的三个常量:

// config.h static const int SCENE_WIDTH = 480; static const int SCENE_HEIGHT = 700; static const int GAME_FPS = 60; static const int BULLET_SPEED = 8; // 子弹每帧移动 8 像素 static const int ENEMY_SPEED_MIN = 2; static const int ENEMY_SPEED_MAX = 5;

把速度跟帧率解耦的写法是每个对象记录pixPerFrame,一帧移动 8 像素,60 FPS 下就是每秒 480 像素。如果你想调「游戏难度」,改这里比去改循环逻辑要快得多。

2.3 游戏循环:谁在驱动每一帧

很多人第一次看 Qt 项目会找while (true),找不到就开始翻车。其实这个项目的循环藏在QTimer里:

// gamescene.cpp playTimer = new QTimer(this); playTimer->setInterval(1000 / GAME_FPS); // 约 16ms 一帧 connect(playTimer, &QTimer::timeout, this, &GameScene::gameLoop); playTimer->start(); void GameScene::gameLoop() { updateBullets(); // 所有子弹按速度前移 updateEnemies(); // 敌机向下移动 checkCollisions(); // 碰撞检测与爆炸音效 cleanupItems(); // 移除越界对象 updateScore(); // 刷新计分 }

updateBullets()遍历场景里的Bullet对象,每个对象调用一次自身的advance();updateEnemies()类似。这种驱动方式的优点是代码路径非常清晰,调试时打断点也直观;缺点是如果某个对象提前被 delete,下一帧再遍历就会悬空指针,所以cleanupItems()一定要晚于碰撞检测执行。

关于碰撞,我看到的常见实现里有一个取舍:用shape()做像素级判定是好看的,但性能较差;多数小项目直接用QRectF的相交判定或者保留一张缩小后的碰撞图(hitbox),把战机框成一个小矩形。我一般会先暴力相交试跑,发现误判再换QPainterPath,不要一上来就上像素级碰撞。

3. 核心模块拆解:一个子弹从按键到碰撞的完整链路

3.1 玩家战机:键盘状态与开火

玩家类继承自QGraphicsPixmapItem,键盘事件的响应方式有两种,项目里常见的是在Player::keyPressEvent里标记状态,然后在gameLoop的统一更新里消费。这样不会出现「按键被重复触发导致速度加倍」的问题:

// player.cpp void Player::keyPressEvent(QKeyEvent *event) { if (event->key() == Qt::Key_Left) isLeftPressed = true; if (event->key() == Qt::Key_Right) isRightPressed = true; if (event->key() == Qt::Key_Space) tryFire(); } void Player::advance() { int step = 5; // 每帧移动 5 像素 if (isLeftPressed && x() > 0) setPos(x() - step, y()); if (isRightPressed && x() + boundingRect().width() < SCENE_WIDTH) setPos(x() + step, y()); }

advance()里的x()是QGraphicsItem自带的方法,返回当前坐标;boundingRect()控制移动边界。tryFire()里加一个fireCooldown计数,比如每 10 帧允许开一枪,否则按住空格会变成机枪扫射,破坏平衡。

我一般还会在Player里留一个setInvincible(int frames)的接口,被击中后进入 60 帧的无敌时间,玩家状态会在gameLoop里统一检查。注意keyPressEvent只处理「按下」,keyReleaseEvent把对应状态位清零,否则会出现「松开键盘角色还往左飘」的黑匣子现象。

3.2 敌机生成:定时器驱动的波次

敌机不是在场景里写死的,而是由一个QTimer周期性调用spawnEnemy()生成。这个生成逻辑里我见过的经典写法是随机取 X 坐标,随机选敌机类型,再用一个随机的帧计数做「下一次生成间隔」:

// gamescene.cpp void GameScene::spawnEnemy() { Enemy *enemy = new Enemy( QRandomGenerator::global()->bounded(SCENE_WIDTH - 50), -60.0 ); enemy->setSpeed(QRandomGenerator::global()->bounded(ENEMY_SPEED_MIN, ENEMY_SPEED_MAX)); scene()->addItem(enemy); connect(enemy, &Enemy::shotDown, this, &GameScene::addScore); }

敌机初始 Y 设为负值,是让它从画面上方看不见的地方开进场景,避免「凭空出现」的违和感。setSpeed决定这架敌机每帧向下移动多少像素;shotDown是敌机自己发出的事件,通知场景加分——用信号槽而不是场景直接轮询敌机状态,能让后续新增多种敌机时不用改GameScene。

进一步的难度曲线会放在一个spawnInterval变量上:每消灭一个波次,间隔缩短 20 毫秒,最低不低于 300 毫秒。这个数值可以在config.h里做成DIFFICULTY_STEP和DIFFICULTY_MIN_INTERVAL,我给读者留的修改点就在这。注意删除敌机时要deleteLater(),不能直接delete item,因为 Qt 场景还在遍历 Item 列表,直接删会触发崩溃。

3.3 碰撞检测:相交判定与音效反馈

碰撞检测是这个项目里最能体现「代码顺序决定体验」的地方:

// gamescene.cpp void GameScene::checkCollisions() { const QList<QGraphicsItem *> bullets = scene()->items(); for (QGraphicsItem *bullet : bullets) { if (bullet->data(0) != ROLE_BULLET) continue; QList<QGraphicsItem *> hits = bullet->collidingItems(); for (QGraphicsItem *hit : hits) { if (hit->data(0) == ROLE_ENEMY) { // 子弹命中敌机 Enemy *enemy = static_cast<Enemy *>(hit); enemy->decreaseHp(bulletDamage); bullet->markForDeletion(); if (enemy->isDead()) { emit addScore(enemy->scoreValue()); playExplosionSound(); } break; } } } }

这里用data(0)存角色标记(ROLE_BULLET、ROLE_ENEMY、ROLE_PLAYER),用来过滤对象类型。collidingItems()默认用shape()或boundingRect()做判定,所以前面提到的碰撞框设计会直接影响手感。我把bulletDamage设为 1,小型敌机 1 发击毁,中型敌机 3 发,既有操作感又不至于让玩家觉得刮痧。

音效这边,开火和爆炸用QSoundEffect最合适,因为它加载的是压缩后的小文件,延迟低;背景音乐才用QMediaPlayer + QMediaPlaylist循环播放。很多初学同学把背景音乐也拿QSoundEffect放,会出现播放卡顿和句柄泄漏。

3.4 计分与结算:QSettings 存最高分

游戏的结束逻辑发生在玩家战机hp <= 0时,场景弹出一个结算面板,显示当前得分和最高分。最高分存法有几种,用QSettings写配置文件是跨平台里最省事的:

// gamescene.cpp QSettings settings("SomeCompany", "AircraftGame"); int lastHigh = settings.value("highScore", 0).toInt(); if (currentScore > lastHigh) { settings.setValue("highScore", currentScore); }

我一般会在「游戏结束」和「重新开始」之间加一个resetGame()接口,把所有对象的指针置空、计时器重启、分数清零。这里有个细节:新的一局开始前,要把旧场景里所有Item清空,否则上一局的敌机还挂在场景里,新玩家一出生就被包围。清理用scene()->clear(),但清空前要disconnect所有信号槽连接,防止悬空信号。这就是最典型的一个「没写清理就内存泄漏还连带着崩溃」的坑。

4. 编译运行与避坑指南:从下载到首飞的完整链路

4.1 环境准备:用哪个 Qt 版本最能复现

这个项目最常见的构建环境是 Qt 5.15 或 Qt 6.x,搭配 CMake。如果你只想快速跑通,用 Qt Creator 打开CMakeLists.txt,选择对应的 Kit(比如 MinGW 64 位),直接构建即可。前提是:Qt 的编译器位数要和 CMake 指定的 Kit 一致,否则链接阶段会报一堆“未定义的引用”。

如果是 qmake 的.pro工程,打开.pro文件后 Qt Creator 会自动识别,也是直接构建。二者差异不大,唯一需要确认的是QT += core gui widgets multimedia这行在.pro里有没有写全,漏了multimedia会导致音效相关类编译直接失败。

4.2 构建与运行:三种启动方式验证

跑通一个 Qt 游戏项目,验证方式有几种:

第一种是在 Qt Creator 里按Ctrl + R,走默认的运行时路径,日志窗口能看到 qDebug 输出的分数变化。第二种是在命令行单独执行构建产物(比如build/Release/qt-aircraft-game.exe),这时如果程序报缺少Qt6Core.dll,就把 Qt 的bin目录加入PATH,或者用windeployqt把运行时文件复制到 exe 同目录。第三种是调试模式F5,打断点看gameLoop是否每 16ms 触发一次,这是判断计时器逻辑是否正常的最快方法。

经历过这一步,基本就能区分“环境问题”和“代码问题”:程序闪退先看临时目录里有没有crash.log;游戏能启动但没有画面,八成是资源路径写的是绝对路径,换个机器就跑不起来了。这个项目里推荐用QDir::currentPath()拼接相对路径,或者用 Qt 的资源系统:/images/player.png,后者最省心。

4.3 避坑记录:我在这个项目上踩过的 5 个坑

下面按「现象 → 原因 → 解决」的格式记录几个高频问题,如果有同学在这个项目上卡住,基本都逃不出这几类。

第一个坑:中文乱码。现象是游戏里中文标题或提示文字显示成乱码。原因是 MSVC 编译器默认按本地代码页读取源码,而源码文件是 UTF-8 存储。解决方法是把源码文件转成 UTF-8 with BOM,或者在CMakeLists.txt里加add_compile_options("$<$<CXX_COMPILER_ID:MSVC>:/utf-8>")。这个是老项目里最高频的问题,没有之一。

第二个坑:PNG 图片带黑底,飞机显示成黑方块。原因是图片本身不带 Alpha 通道,或者用了 JPEG 转存的伪 PNG。解决方法是查看图片属性里的位深度,用支持透明的重新导出;并在代码里用QPixmap的setMask兜底,虽然效果一般,但至少不是黑底。

第三个坑:跑起来发现子弹有时会穿过敌机,尤其是全屏一堆子弹时。原因是碰撞检测只在gameLoop的固定时刻判定一次,子弹速度过快时,一帧的位移超过敌机宽度,就会跳过。解决方法是限制子弹速度上限,或者把碰撞范围从单点扩展成「上一帧位置到当前帧位置」的线段相交检测。在我的经验里,先把速度限制在 10 像素/帧以内,体验已经足够,线段检测是性能兜底的方案。

第四个坑:音效播放导致界面卡顿。现象是每次爆炸都卡一下。原因是用了QMediaPlayer播放短音效,而这类对象创建和销毁的开销比较大。解决方法是改用QSoundEffect,它在播放延迟和资源占用上都更适合短音效。背景音乐才用QMediaPlayer,并且只创建一个实例常驻。

第五个坑:退出游戏时崩溃。现象是点击关闭窗口后进程退出缓慢甚至直接段错误。原因是析构时QTimer还在运行,触发了gameLoop,而此时场景里的 Item 已被销毁。解决方法是把定时器停止放在closeEvent或析构函数第一行,然后先clear()场景再销毁播放器。

5. 二次开发技巧:三道改造题,让战机长出新玩法

拿到了源码,第一件事不是加新功能,而是把config.h里的参数全部改成可配置项。我的习惯是建一个GameSettings单例类,把SCENE_WIDTH、BULLET_SPEED、ENEMY_SPEED_MAX、spawnInterval这些常量收拢成int、double成员变量,让关卡脚本可以动态改。这样后面做任何玩法,都不用翻源码改常量了。

三道适合上手的改造题是:第一,给玩家加护盾道具。做一个PowerUp类,继承QGraphicsPixmapItem,用定时器每隔一段随机时间把它刷到场景里,当玩家战机和它碰撞时,设置invincibleFrames = 300。这个改造能训练你理解碰撞检测和 Item 生命周期。第二,做敌机弹幕。给Enemy加一个fireInterval,用独立的QTimer发射EnemyBullet,子弹方向和玩家一样向下移动,检测它是否命中玩家。注意这里要复用ROLE_PLAYER的过滤逻辑,否则玩家的子弹也会被敌人的子弹触发碰撞。第三,做关卡系统:根据当前分数切换背景图片、敌机速度和生成间隔。

一个具体的实现技巧是,新增任何 Item 类,都遵循「创建时加入场景、销毁时从场景移除」的原则,并统一在gameLoop里通过data(0)过滤类型。只要维护好这个约定,加新玩法就只需要写新类,不用动原核心循环。验证方法是给gameLoop加一个qDebug() << "FPS:" << fps的计时输出,如果帧数稳定在 55~60,说明你的改造没有拖慢主循环。

这个项目的代码量不大,正因如此,它是练手 Qt 游戏架构最好的素材。我拿到这类小项目后,会强制自己先按避坑清单走一遍:检查资源路径、检查定时器生命周期、检查碰撞框大小,再开始动代码。这套检查流程帮我少踩了至少一半的坑。希望帮到你。

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

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

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

立即咨询