资讯动态

飞机大战核心模块:Qt/C++飞机类的面向对象设计与实现

发布时间:2026/10/17 15:24:27 来源:尧图企业网站定制
飞机大战写到第二篇正好聚焦在“飞机类”这个核心模块上。做这类小游戏很多新手一上来就急着画界面、拖控件结果写到一半发现逻辑全挤在一起改起来动一发牵全身。其实整个项目的骨架就是把飞机、子弹、敌人、特效这些实体抽象成类再把它们的生命周期管理好。而飞机类恰恰是所有实体里最复杂、最值得提前设计的一个它既有玩家的操作反馈又有敌机的AI行为还要承担碰撞、掉血、死亡、复活这一整套状态流转。这篇内容适合正在用 Qt/C 写飞机大战、或者打算复刻一个纵版射击游戏的朋友参考。我会从飞机基类应该放什么、玩家飞机和敌机怎么继承扩展到碰撞检测、对象池、动画音效这些具体实现把踩过的坑和常用的套路一起整理出来。代码基于 Qt 5/6 的 Graphics View 框架但设计思路不局限于某个版本。1. 飞机类的整体设计与职责划分1.1 为什么要把飞机单独抽成一个类飞机大战里出现的“飞行物”其实挺多的玩家飞机、普通小敌机、精英怪、Boss还有它们的子弹甚至僚机。如果每个实体各写一套逻辑代码会迅速膨胀而且玩家飞机和敌机之间其实共享了大量基础能力——有位置、有血量、会移动、会被子弹击中、会死亡。把这些公共部分收拢到一个基类里子类只负责差异是代价最小、收益最明显的设计。我最初写第一版时偷懒玩家飞机直接在一个 QGraphicsPixmapItem 子类里写死了所有逻辑后面加敌机时复制粘贴了一整套结果两边的 bug 还得分开修。第二版把公共部分抽到 Plane 基类后很多问题自动消失了比如掉血扣减、死亡信号、爆炸动画触发这些流程只需要在基类里写一遍子类通过虚函数做成钩子hook需要特殊处理时再覆盖。1.2 继承体系和文件组织飞机类建议按这样划分层级Plane抽象基类定义公共接口 ├── PlayerPlane玩家飞机 └── EnemyPlane普通敌机 └── BossPlaneBoss也可以独立每个类一个类名.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; } QListQGraphicsItem* hitList bullet-collidingItems(); for (QGraphicsItem* item : hitList) { Plane* plane dynamic_castPlane*(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: QVectorBullet* m_pool; };池子的容量不必预先精确计算。可以设一个初始容量比如 200不够时动态扩容。飞机对象本身不建议频繁创建销毁普通敌机死亡后可以等爆炸动画播放完再回收也可以考虑复用但复用飞机比子弹复杂收益不大我建议飞机还是走“生成时创建、死亡动画结束后延迟删除”的路子。删除时注意不要直接delete item尤其是在主循环正在遍历 item 列表时。应该调用deleteLater()或把它先加入一个待清理列表等当前迭代结束统一处理。6. 动画、音效与资源加载6.1 动画帧播放的两种方案飞机大战里最常见的动画是推进器火焰和爆炸。简单做法是把多张图片放进一个QListQPixmap然后用一个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 里最常见的问题。先查两点shape()是否被正确重写返回路径是否非空。如果返回空路径默认碰撞判定退化为boundingRect()或直接失败。飞机和子弹是否在同一场景。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 驱动对象池思路可以继续复用。希望这篇对正在重构飞机大战的朋友有帮助。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑