☰
飞机大战核心模块:Qt/C++飞机类的面向对象设计与实现
2026/10/17 15:24:21 网站建设 项目流程

飞机大战写到第二篇,正好聚焦在“飞机类”这个核心模块上。做这类小游戏,很多新手一上来就急着画界面、拖控件,结果写到一半发现逻辑全挤在一起,改起来动一发牵全身。其实整个项目的骨架,就是把飞机、子弹、敌人、特效这些实体抽象成类,再把它们的生命周期管理好。而飞机类,恰恰是所有实体里最复杂、最值得提前设计的一个:它既有玩家的操作反馈,又有敌机的AI行为,还要承担碰撞、掉血、死亡、复活这一整套状态流转。

这篇内容适合正在用 Qt/C++ 写飞机大战、或者打算复刻一个纵版射击游戏的朋友参考。我会从飞机基类应该放什么、玩家飞机和敌机怎么继承扩展,到碰撞检测、对象池、动画音效这些具体实现,把踩过的坑和常用的套路一起整理出来。代码基于 Qt 5/6 的 Graphics View 框架,但设计思路不局限于某个版本。

1. 飞机类的整体设计与职责划分

1.1 为什么要把飞机单独抽成一个类

飞机大战里出现的“飞行物”其实挺多的:玩家飞机、普通小敌机、精英怪、Boss,还有它们的子弹甚至僚机。如果每个实体各写一套逻辑,代码会迅速膨胀,而且玩家飞机和敌机之间其实共享了大量基础能力——有位置、有血量、会移动、会被子弹击中、会死亡。把这些公共部分收拢到一个基类里,子类只负责差异,是代价最小、收益最明显的设计。

我最初写第一版时偷懒,玩家飞机直接在一个 QGraphicsPixmapItem 子类里写死了所有逻辑,后面加敌机时复制粘贴了一整套,结果两边的 bug 还得分开修。第二版把公共部分抽到 Plane 基类后,很多问题自动消失了:比如掉血扣减、死亡信号、爆炸动画触发,这些流程只需要在基类里写一遍,子类通过虚函数做成钩子(hook),需要特殊处理时再覆盖。

1.2 继承体系和文件组织

飞机类建议按这样划分层级:

Plane(抽象基类,定义公共接口) ├── PlayerPlane(玩家飞机) └── EnemyPlane(普通敌机) └── BossPlane(Boss,也可以独立)

每个类一个类名.h+类名.cpp,不要小看这个习惯。当飞机类代码超过五六百行时,按文件拆分能让你快速定位位置,而不是在一个几千行的文件里来回滚动。

实现上,我建议飞机基类继承QGraphicsObject而不是QGraphicsPixmapItem。原因有两点:

  • QGraphicsObject继承自QObject,自带信号槽机制,飞机血量变化、被击中、死亡、发射子弹这些动作都适合通过信号向外广播。
  • QGraphicsObject本身就是QGraphicsItem的子类,仍然可以放进QGraphicsScene,不影响渲染和碰撞。

可能有人担心多继承的代价,实际上 Qt 内部已经帮你做了封装,直接用即可。

2. 飞机基类的核心属性与接口设计

2.1 核心数据成员应该放什么

飞机基类里最少要有这几组数据:

  • 身份标识:PlaneType,用来区分玩家、敌机、Boss,碰撞处理时判断阵营。
  • 属性数值:血量hp、最大血量maxHp、速度speed。
  • 尺寸信息:碰撞盒大小size,而不是直接用贴图原始尺寸。
  • 射击节奏:开火计时器shootTimer和开火间隔shootInterval。
  • 朝向/运动方向:纵版射击游戏基本是向下或向上移动,方向可以用QPointF表示,灵活度高。

一个简化版的 Plane 基类头部长这样:

class Plane : public QGraphicsObject { Q_OBJECT public: enum PlaneType { PlayerSide, EnemySide, BossSide }; Plane(PlaneType type, QGraphicsItem* parent = nullptr); virtual ~Plane(); // 数值接口 void setHp(int hp); int hp() const; int maxHp() const; void setSpeed(qreal speed); // 尺寸与碰撞区域 void setPlaneSize(const QSizeF& size); QRectF boundingRect() const override; QPainterPath shape() const override; // 主循环钩子:每帧由场景管理器调用 virtual void tick(qreal deltaSeconds) = 0; // 被击中 virtual void takeDamage(int damage); // 死亡逻辑 virtual void onDead(); signals: void hpChanged(int currentHp, int maxHp); void planeDead(Plane* sender); protected: void advance(int phase) override; private: PlaneType m_type; int m_hp = 0; int m_maxHp = 0; qreal m_speed = 0.0; qreal m_shootTimer = 0.0; qreal m_shootInterval = 0.5; QSizeF m_size; };

注意advance(int phase)重写。Graphics View 框架每次迭代会调用场景内所有 item 的advance(),phase 为 0 表示准备移动,phase 为 1 表示执行移动。但我们不一定要依赖它,后面会说到为什么用了QTimer主循环后,统一在外面 tick 更可控。

2.2 主循环钩子与信号接口

飞机类不应该自己去开一个QTimer,因为一个场景里可能同时存在几十个飞机,各自开定时器毫无必要,还容易导致时间不同步。更好的方式是整个游戏只有一个主时钟,在每一帧里统一调用所有实体的tick():

void GameScene::startGame() { m_elapsed.start(); m_gameTimer.start(16); // 约 60 FPS } void GameScene::onTick() { qreal dt = m_elapsed.restart() / 1000.0; dt = qMin(dt, 0.05); // 防止切窗口回来时一帧时间过长 for (Plane* plane : std::as_const(m_planes)) { if (plane->isVisible()) { plane->tick(dt); } } // 碰撞检测、子弹移动、特效更新随后统一处理 }

为什么用deltaSeconds而不是每帧固定移动一格?因为不同电脑刷新率不同,如果每帧移动固定像素,60Hz 和 120Hz 下速度会差一倍。带上时间增量后,飞机速度可以定义为“每秒像素数”,无论帧率高低,表现一致。

信号设计上,planeDead(Plane*)比gameOver()更通用。场景管理器收到后,可以统一做加分、爆炸特效、掉落道具等处理。玩家飞机死亡时,再额外监听一个玩家死亡信号来触发游戏结束或复活。

2.3 坐标系与中心点对齐

一个容易被忽略但影响很大的细节:QGraphicsPixmapItem默认原点是图片左上角。也就是说setPos(100, 200)后,图片右上角会落在 (100, 200) 处,旋转、缩放、碰撞的参考点全偏了。飞机大战里,玩家飞机的坐标如果是“屏幕左下角 200 像素”这种意图,就直接定位左上角,会导致贴图比预期偏右下半个机身。

解决办法是在初始加载图片时设置偏移量:

QPixmap pixmap(":/images/player.png"); setPixmap(pixmap); setOffset(-pixmap.width() / 2.0, -pixmap.height() / 2.0);

这样setPos(x, y)就表示飞机中心点位于 (x, y),后续所有计算都围绕中心展开,直觉上舒服很多。

3. 玩家飞机:操作手感与反馈

3.1 按键控制与边界约束

玩家飞机是游戏里和用户直接交互的部分,手感重于一切。按键控制我选用了一个维标志变量moveFlags,用位与判断当前是否按住某个方向,而不是靠keyPressEvent里立刻响应移动。

原因在于:如果只在按下QKeyEvent时移动,那松开按键瞬间飞机就停了,而且按住不放的自动重复事件表现不够稳定。更好的做法是按下时设置标志,松开时清除标志,然后在每帧tick()里根据标志位计算最终速度。

void PlayerPlane::keyPressEvent(QKeyEvent* event) { switch (event->key()) { case Qt::Key_Left: m_moveFlags |= MoveLeft; break; case Qt::Key_Right: m_moveFlags |= MoveRight; break; case Qt::Key_Up: m_moveFlags |= MoveUp; break; case Qt::Key_Down: m_moveFlags |= MoveDown; break; case Qt::Key_Space: m_firing = true; break; default: break; } } void PlayerPlane::keyReleaseEvent(QKeyEvent* event) { switch (event->key()) { case Qt::Key_Left: m_moveFlags &= ~MoveLeft; break; case Qt::Key_Right: m_moveFlags &= ~MoveRight; break; case Qt::Key_Up: m_moveFlags &= ~MoveUp; break; case Qt::Key_Down: m_moveFlags &= ~MoveDown; break; case Qt::Key_Space: m_firing = false; break; default: break; } }

移动通常分“普通移动”和“加速移动”两档。可以给飞机一个基础速度,比如每秒 300 像素,再在按住加速键时乘以 1.6 倍。纵版射击游戏里,玩家飞机一般不会飞出主区域,边界约束放在 PlayerPlane 内部即可:

void PlayerPlane::clampToSceneArea(const QRectF& area) { QPointF pos = this->pos(); // 这里的坐标以飞机中心点为基准 qreal halfW = m_size.width() / 2.0; qreal halfH = m_size.height() / 2.0; pos.setX(qBound(area.left() + halfW, pos.x(), area.right() - halfW)); pos.setY(qBound(area.top() + halfH, pos.y(), area.bottom() - halfH)); setPos(pos); }

qBound是 Qt 自带的限制函数,比手写大小判断更简洁。

3.2 子弹发射与连发节奏

玩家飞机的射击不应该是每帧发射一颗,而是先按帧率累计时间,达到开火间隔后再真正生成子弹。这样才能让连发速率稳定在不同帧率下。

void PlayerPlane::tick(qreal dt) { handleMove(dt); clampToSceneArea(m_sceneArea); if (m_firing) { m_shootTimer -= dt; if (m_shootTimer <= 0.0) { m_shootTimer = m_shootInterval; shoot(); } } }

m_shootInterval我习惯设为 0.12 秒到 0.2 秒之间,手感比较顺。你可以在游戏设置里加一个“火力等级”影响这个值,升级后间隔变小,弹速加快。

高级一点的方案是发射多颗子弹组成弹幕,比如三路弹、五路弹:

void PlayerPlane::shoot() { const QPointF muzzlePos = pos() - QPointF(0, m_size.height() / 2.0); emit fireBullet(muzzlePos, QVector2D(0, -1), Bullet::PlayerBullet); }

如果你把“开火”作为信号发出来,由子弹管理器统一创建子弹,那么玩家飞机甚至不需要直接 new Bullet。这样做的好处是子弹的类型、对象池、声音反馈全都在管理器里控制,飞机类只管发出意图。

3.3 无敌帧、掉血与复活

纵版射击游戏普遍存在“受击后短暂无敌”的机制。如果不加无敌帧,玩家会在密集的弹幕里一帧内连续吃到三四颗子弹,瞬间暴毙,体验极差。

无敌帧实现方式不复杂:用一个计数器倒计时,倒计时未结束时,所有伤害强制降为 0,同时飞机贴图闪烁提示玩家。

void PlayerPlane::takeDamage(int damage) { if (m_invincible || m_respawning) { return; } Plane::takeDamage(damage); // 触发短无敌 m_invincible = true; m_invincibleTimer = 1.5; // 1.5秒 emit hitFeedback(); }

闪烁可以靠切换透明度实现。我试过两种方案,一种是用QTimer周期切换setOpacity(0.3)和setOpacity(1.0),另一种是在tick()里用正弦函数平滑变化。实际效果差别不大,但定时器方案在某些刷新场景下容易多触发一次,不如直接在 tick 里按时间计算:

if (m_invincible) { qreal alpha = (sin(m_invincibleTimer * 12.0) + 1.0) / 2.0; setOpacity(0.3 + 0.7 * alpha); } else { setOpacity(1.0); }

复活逻辑可以简化为:血量归零后延迟 1 秒,恢复满血并重置位置到屏幕下方中央,而不是立刻重新开始。这个细节能让玩家保留继续挑战的感觉,不至于一死就安排重新加载整个场景。

4. 敌机与Boss的设计

4.1 敌机类型与行为状态

普通敌机不应该是千篇一律地朝下飞,一个好的飞机大战至少得有几种基础行为:

  • 直线型:从顶部出现,沿固定角度斜向下飞,不带转向。
  • 追踪型:缓慢调整方向,朝玩家当前位置逼近。
  • 狙击型:先在场边“瞄准”,每隔固定时间朝玩家方向发射单发子弹。
  • 自爆型:接近玩家时加速,到达一定距离后爆炸。

这些行为用一个枚举状态机管理就很清晰:

enum EnemyAction { EnterState, PatrolState, AimState, AttackState, LeaveState };

比如追踪型敌机的 tick 可能长这样:

void ChaseEnemy::tick(qreal dt) { if (m_state == PatrolState) { // 先向下飞一段 moveBy(0, m_speed * dt); if (y() > 300) { m_state = AimState; } } else if (m_state == AimState) { // 转向玩家,这里只做朝向计算 QPointF target = m_targetPlayer->pos(); QPointF dir = target - this->pos(); m_direction = dir.normalized(); m_state = AttackState; m_attackTimer = 1.0; } else if (m_state == AttackState) { // 朝方向移动并间歇射击 moveBy(m_direction.x() * m_speed * dt, m_direction.y() * m_speed * dt); m_attackTimer -= dt; if (m_attackTimer <= 0.0) { m_attackTimer = m_attackInterval; emit fireBullet(pos(), QVector2D(m_direction), Bullet::EnemyBullet); } } }

QVector2D做方向向量很顺手,乘速度再乘dt就得到位移。

4.2 敌机生成器与投放节奏

敌机通常不会一开始就塞满整个屏幕,而是由生成器按关卡节奏不断投放。我倾向于把生成逻辑放在 GameScene 或者一个独立的 Spawner 类里,用敌机模板结构体描述“什么时候生成什么类型、从哪儿出现”。

struct EnemySpawnInfo { int timeMs; // 关卡开始后多少毫秒生成 QString enemyType; // "chase", "sniper", "bomber" QPointF spawnPos; int count; };

生成器读取配置列表,按时间触发。这样做的好处是关卡难度调整不需要改代码,只需改配置文件或数组。最初我把生成逻辑直接写在EnemyPlane::tick()里,敌机自己再发一个信号去创建新的敌机,看起来方便,但调试时根本搞不清是谁生成谁,非常头疼。抽出来之后,逻辑清晰很多。

敌机从屏幕外生成时,建议初始状态设为不可见、不参与碰撞,等它完全进入屏幕后再激活。否则玩家会看到敌机在屏幕边缘突然出现并被打掉,体验很差。

4.3 Boss阶段转换

Boss 本质上也是 Plane,但比普通敌机多了阶段切换。阶段可以按血量阈值触发,比如血量降到 70% 进入第二阶段,降到 30% 进入第三阶段。每次切换时,Boss 切换攻击模式、改变移动路径,甚至短暂进入无敌状态来播放变身动画。

Boss 的血量条通常显示在屏幕顶部,所以 Boss 类要暴露maxHp和hpChanged信号。场景里挂一个专门的血条控件,时刻监听这两个值。因为血条刷新频率高,我建议直接用信号槽连接,不用每帧轮询 hp。

Boss 死亡逻辑可以比普通敌机多做一步:爆炸不是一次性消失,而是区分多段爆炸动画,同时触发全屏奖励掉落。这些属于后续扩展,但基类里预留onDead()虚函数后,加扩展非常自然。

5. 碰撞检测与对象生命周期管理

5.1 碰撞判定为什么要用 shape()

很多新手直接用boundingRect()做碰撞判定,发现子弹明明擦着飞机边角却被打中,或者子弹穿过敌机尾巴没反应。问题就出在boundingRect()是轴对齐的最小包围矩形,而飞机贴图往往有大量透明区域,机翼和尾巴的形状不是矩形。

正确做法是重写QGraphicsItem::shape(),返回一个能表达碰撞范围的QPainterPath。比如把碰撞区域收缩到机身中部:

QPainterPath Plane::shape() const { QPainterPath path; qreal w = m_size.width() * 0.75; qreal h = m_size.height() * 0.85; path.addEllipse(QPointF(0, 0), w / 2.0, h / 2.0); return path; }

椭圆只是个参考,你可以根据自己的飞机贴图调整比例。关键点在于:碰撞体积要比视觉体积小 15% 到 30%。这样玩家会明显感觉“子弹擦边飞过去没中”,游戏公平感非常好。反过来,如果碰撞体积比视觉体积大,玩家会觉得被空气打中,很容易造成负面反馈。

QGraphicsScene::collidingItems()和QGraphicsItem::collidesWithItem()默认都会基于shape()去做精确判定,所以我们只需要把shape()写准确,碰撞检测的准确性就自动提高了。

5.2 子弹与飞机碰撞处理

碰撞处理我建议集中在场景管理器里做,而不是分散在每个飞机类的tick()里。集中处理的理由很朴素:碰撞是“两个对象”之间的关系,如果每个飞机都collidingItems(),几十个飞机互相查询,复杂度随数量平方上升,还会重复处理。

做法是在主循环里先移动所有子弹,然后统一执行一次碰撞检测:

void GameScene::checkCollisions() { // 子弹与飞机的检测 for (Bullet* bullet : std::as_const(m_activeBullets)) { if (!bullet->isActive()) { continue; } QList<QGraphicsItem*> hitList = bullet->collidingItems(); for (QGraphicsItem* item : hitList) { Plane* plane = dynamic_cast<Plane*>(item); if (!plane) { continue; } if (bullet->owner() == plane->type()) { continue; // 同一阵营不误伤 } plane->takeDamage(bullet->damage()); bullet->setActive(false); break; } } }

这里的关键是子弹的“阵营”和飞机的“阵营”要一致,用枚举比较即可,不要用item == player这种硬编码。

5.3 对象池与内存管理

飞机大战里子弹和爆炸特效的创建非常频繁,如果每开一枪就new Bullet、每打一发就delete Bullet,内存分配压力很大,还会引起场景的 item 列表频繁变化。最直接的方案是 Bullet 对象池。

对象池的思路:预分配一批 Bullet,初始全部隐藏,需要发射时取一个可用对象,设置位置、方向、伤害后显示;子弹飞出屏幕或被击中时,隐藏并归还池子,而不是销毁对象。

class BulletPool : public QObject { public: Bullet* acquire() { for (Bullet* b : std::as_const(m_pool)) { if (!b->isActive()) { b->setActive(true); b->setVisible(true); return b; } } // 池不够时再创建新的 Bullet* b = new Bullet(); m_pool.push_back(b); b->setActive(true); return b; } void release(Bullet* bullet) { bullet->setActive(false); bullet->setVisible(false); scene()->removeItem(bullet); // 从场景移除,但不 delete } private: QVector<Bullet*> m_pool; };

池子的容量不必预先精确计算。可以设一个初始容量,比如 200,不够时动态扩容。飞机对象本身不建议频繁创建销毁,普通敌机死亡后可以等爆炸动画播放完再回收,也可以考虑复用,但复用飞机比子弹复杂,收益不大,我建议飞机还是走“生成时创建、死亡动画结束后延迟删除”的路子。

删除时注意不要直接delete item,尤其是在主循环正在遍历 item 列表时。应该调用deleteLater()或把它先加入一个待清理列表,等当前迭代结束统一处理。

6. 动画、音效与资源加载

6.1 动画帧播放的两种方案

飞机大战里最常见的动画是推进器火焰和爆炸。简单做法是把多张图片放进一个QList<QPixmap>,然后用一个QTimer每 50ms 切换一帧。

另一种做法是在tick(qreal dt)里累计帧播放时间,好处是不需要额外的定时器对象,帧率波动时表现也更平滑:

void ExplosionEffect::tick(qreal dt) { m_frameTimer += dt; if (m_frameTimer >= m_frameDuration) { m_frameTimer -= m_frameDuration; m_currentFrame++; if (m_currentFrame >= m_frames.size()) { stop(); return; } setPixmap(m_frames.at(m_currentFrame)); } }

爆炸动画比飞机本体更建议用这种方法。因为爆炸结束后要回收或删除自己,如果用独立 QTimer,容易出现定时器回调时对象已经被删除的情况,排查起来很痛苦。统一由主循环驱动,生命周期就好控制多了。

6.2 音效与爆炸反馈

音效在飞机大战里属于“锦上添花但必须有”的部分。Qt 里播放短音效最方便的是QSoundEffect,支持 wav 格式,延迟很低,适合射击、爆炸这类音效。播放长音乐背景用QMediaPlayer。

注意QSoundEffect每个实例只适合播放一种固定音效,不要用同一个实例动态切换不同音效文件,否则会出现加载延迟和播放不出来的情况。

在子弹发射处播放音效时,频率高会导致音效互相重叠。可以加一点限制:同一个音效的最小播放间隔不小于 50ms。也可以用“每次生成子弹时只随机播放几种射击音效之一”来分散频率。

6.3 资源缓存与管理

QPixmap 加载大图或重复使用小图时,最好先过一遍QPixmapCache。比如同一个 Boss 贴图会被创建多个实例引用,直接QPixmapCache::find会避免反复读文件。

QPixmap getPixmap(const QString& key) { QPixmap pixmap; if (!QPixmapCache::find(key, &pixmap)) { pixmap.load(key); QPixmapCache::insert(key, pixmap); } return pixmap; }

另外,建议所有图片和音效都打进.qrc资源文件里,而不是用相对路径。发布时只需打包一个可执行文件和资源文件,不会出现路径找不到的问题。缺点是这个模式下的布局预览不好做,所以我在 UI 设计阶段会先用本地路径,最后跑通后再统一替换成:/images/...路径。

7. 常见问题与排查技巧实录

7.1 子弹和飞机发生了“穿模”,就是不触发碰撞

这是 Graphics View 里最常见的问题。先查两点:

  1. shape()是否被正确重写,返回路径是否非空。如果返回空路径,默认碰撞判定退化为boundingRect()或直接失败。
  2. 飞机和子弹是否在同一场景。setPos之后没有scene()->addItem(plane),碰撞检测永远找不到对方。

另外,setItemFlag(QGraphicsItem::ItemIsMovable)和实际移动不冲突,但建议不要开着可移动标志让鼠标拖走飞机,否则会出现玩家鼠标一点,飞机跟着跑的情况。

7.2 游戏掉帧,飞机一多就卡顿

大多数卡顿不是贴图太大,而是无效计算太多。优先检查这几处:

  • 每个 item 是否都在自己做collidingItems(),改成集中检测。
  • 发射子弹是不是每帧都在 new/delete,改成对象池。
  • 是否每个飞机都维护了一个独立 QTimer 做移动,改成统一主循环。

还有一个很隐蔽的坑:advance()是 Graphics View 自动维护的,如果你又手动调scene()->advance(),可能让移动计算执行两次。既然我们有了自己的 tick 循环,就别用advance()来驱动移动逻辑。

7.3 飞机死亡时程序崩溃

崩溃通常在delete处。场景里存在父子关系时,父对象删除后子对象会自动删,如果你又手动 delete 子对象,就会二次释放。

规范做法是:飞机类内不要自行删自己;统一由 GameScene 管理。飞机死亡时只发信号、播放动画,等待场景管理器决定何时deleteLater()。不要把deleteLater()放得太随意,尤其不要在信号发出的下一行代码里继续访问这个飞机。

7.4 信号槽断开与内存泄漏

QGraphicsObject自带 QObject 属性,但如果飞机从场景移除后没有被正确delete,其 connected 信号槽不会自动断开,会造成悬空引用。排查时可以在场景关闭时打印一下未清理的对象数量:

qDebug() << scene()->items().size();

如果数量不对,检查是不是还有对象没有从场景移除就直接delete,或者delete后还留在场景里。正确顺序是先scene()->removeItem(item),等当前帧结束再deleteLater()。

个人经验里,飞机类的开发最值得投入的是基类接口是否清晰。如果你发现写玩家的逻辑时想用某个方法,发现基类里没有;写敌机时又想改基类公共数据结构,说明基类的抽象边界没划好。宁可多花十分钟想清楚接口,也不要边写边改继承体系,否则后面补 Boss、加僚机时成本会成倍增加。

飞机类设计好后,下一步自然是子弹系统和关卡波次生成。子弹和飞机都是用同一套 tick 驱动,对象池思路可以继续复用。希望这篇对正在重构飞机大战的朋友有帮助。

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

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

立即咨询