1. 项目概述与设计思路1.1 为什么子弹系统是飞机大战的核心说到用 pygame 写飞机大战很多人第一反应是先把飞机画出来再把敌人放出来结果做到一半发现游戏根本不像样——子弹发不出去发出去打不中敌人打中敌人又看不出效果。问题出在哪子弹系统没设计好。飞机大战本质上是一个发射-命中-反馈的循环游戏玩家 90% 的操作时间都在跟子弹打交道。子弹的设计直接影响手感、打击感和游戏难度曲线。早期我做这个项目时一上来就写代码结果膨胀出几百行逻辑混乱的脚本连子弹的像素碰撞都搞错了后来痛定思痛把子弹系统单独抽出来设计整个项目才走上正轨。1.2 子弹系统的功能需求拆解在动手写代码前我先把这个系统的需求拆成四块子弹本体定义外观、尺寸、初始位置、速度、伤害值。发射机制玩家按键触发、发射间隔控制、子弹数量上限。运动逻辑每帧更新坐标、超出边界清除、方向与速度的配合。碰撞反馈命中敌机的判断、击毁后的表现消失、特效、得分。这四块之间互相耦合比如发射机制要调用子弹创建运动逻辑要控制子弹生命周期碰撞反馈又要依赖子弹的位置和状态。所以设计时不能把代码揉在一起最好用 pygame 的sprite.Sprite封装子弹类再用sprite.Group统一管理这样代码清晰后续加功能也好扩展。2. 子弹类的核心实现2.1 第一版子弹类的搭建子弹类是整个系统的地基。我先从最基础的 Sprite 子类开始把属性和方法定义好。import pygame class Bullet(pygame.sprite.Sprite): def __init__(self, x, y, speed-10, image_pathbullet.png): super().__init__() # 加载子弹图片若加载失败则用默认色块兜底 try: self.image pygame.image.load(image_path).convert_alpha() except pygame.error: self.image pygame.Surface((10, 20)) self.image.fill((255, 215, 0)) self.rect self.image.get_rect() self.rect.centerx x self.rect.bottom y self.speed speed # 负数表示向上飞 self.damage 1 # 伤害值扩展时很有用 def update(self): self.rect.y self.speed # 超出屏幕上方就销毁防止无效遍历 if self.rect.bottom 0: self.kill()这段代码里有几个设计细节值得展开讲。第一个是convert_alpha()很多新手不加这个转换直接pygame.image.load()加载 PNG 带透明通道的图片结果画面出现黑底方块。convert_alpha()能把图片像素格式转换成 pygame 内部高效格式同时保留 alpha 透明通道性能也更好。如果图片加载不到我用了一个纯色Surface兜底这样开发早期没有美术素材也不会卡住流程。第二个是rect.bottom y。设置子弹初始位置时如果是飞机从底部发射子弹应该让子弹的底边对齐飞机的顶部这样视觉上子弹从枪口长出来而不是从飞机中心穿出来。这个细节在游戏里非常影响观感试一次就知道差别了。第三个是update()中的屏幕边界判断。子弹飞出屏幕后如果不销毁它会一直存在于 Group 中每帧都参与更新和碰撞检测。子弹一多之后这种无效对象会拖慢运行帧率。kill()会把方格自身从所有 Group 中移除pygame 底层会回收这个对象这是很关键的内存管理手段。2.2 速度与方向的灵活控制第一版把速度写死在初始化参数里这是为了玩家子弹的简单性——玩家子弹永远是向上飞的。但敌方子弹方向可变如果敌机在屏幕上方朝下射速度就得是正数。所以我把 speed 作为参数传入负值向上、正值向下需要左右斜射时还得分出speed_x和speed_y两个分量。class Bullet(pygame.sprite.Sprite): def __init__(self, x, y, speed_x0, speed_y-10, image_pathbullet.png): super().__init__() self.image pygame.image.load(image_path).convert_alpha() self.rect self.image.get_rect() self.rect.centerx x self.rect.centery y self.speed_x speed_x self.speed_y speed_y self.damage 1 def update(self): self.rect.x self.speed_x self.rect.y self.speed_y if self.rect.bottom 0 or self.rect.top 600 or \ self.rect.right 0 or self.rect.left 800: self.kill()这里把上下左右四个方向全部判断可以复用同一个 Bullet 类实现多种弹幕敌机散射、斜向弹、回旋弹都能靠调整 speed_x 和 speed_y 完成。一个类搞定所有子弹类型比每种子弹写一个类要省事得多。有一个细节我想提醒位置更新时不能写成self.rect.center (speed_x, speed_y)因为 rect 的 center 是整数速度很小或很大时会有截断误差。直接用rect.x speed_x底层也是整数存储但坐标系更自然后续碰撞检测的位置也更精确。2.3 多种子弹类型的统一管理当游戏做到中后期玩家会吃到道具升级子弹比如双发、三发、激光弹。如果用同一个类加一个 type 字段来区分代码会越来越臃肿。我的做法是给 Bullet 加属性并结合几个工厂函数来生成不同配置的子弹实例。def create_normal_bullet(x, y): return Bullet(x, y, speed_x0, speed_y-12, damage1) def create_dual_bullet_left(x, y): return Bullet(x - 10, y, speed_x-2, speed_y-12, damage1) def create_dual_bullet_right(x, y): return Bullet(x 10, y, speed_x2, speed_y-12, damage1) def create_laser_bullet(x, y): return Bullet(x, y, speed_y-16, damage2)这样做的好处是新增一种射击模式时不需要改 Bullet 类本身只要新增一个工厂函数。我在项目里曾经见过把子弹类型写成一长串 if-else 的做法后期维护简直是灾难每次加功能都要让子弹类多承担一种职责最后类越来越难读。这里也可以引入配置文件把每种子弹的速度、伤害、弹道形式集中到一个字典里。子弹实例化时传入 type 字符串从配置里取参数这样数据与逻辑分离改数值不需要动代码。3. 发射机制与射击手感3.1 键盘监听与发射触发pygame 中监听键盘事件有两种方式pygame.event.get()和pygame.key.get_pressed()。很多人混用导致子弹连射逻辑时灵时不灵。我的经验是单发射击用事件连发射击用键盘状态。# 单发监听事件触发 for event in pygame.event.get(): if event.type pygame.KEYDOWN and event.key pygame.K_SPACE: shoot_once() # 连发检查按键状态逐帧触发 keys pygame.key.get_pressed() if keys[pygame.K_SPACE]: try_shoot()这两种方式有本质区别。事件队列只在按下的那一瞬间产生一个KEYDOWN事件如果你按住空格不放事件队列里只出现一次事件做连发就需要在每次循环里记录space_held状态。而key.get_pressed()返回的是当前所有按键的状态数组按住为 True松开为 False天然支持连发。对于飞机大战这种需要连续射击的玩法我推荐以get_pressed()为主。有经验的玩家会一直按住空格如果做成单发手感会大打折扣。3.2 发射间隔与帧率控制问题来了如果直接在每一帧里创建子弹60 帧运行时每秒会射出 60 发子弹这显然太密集了。需要引入发射冷却时间。class Player(pygame.sprite.Sprite): def __init__(self): super().__init__() self.shoot_cooldown 0 # 剩余冷却帧数 self.shoot_interval 8 # 每 8 帧发射一次 def update(self): self.shoot_cooldown max(0, self.shoot_cooldown - 1) def try_shoot(self, bullet_group): if self.shoot_cooldown 0: return self.shoot() self.shoot_cooldown self.shoot_interval def shoot(self): # 生成子弹并加入子弹组 bullet create_normal_bullet(self.rect.centerx, self.rect.top) bullet_group.add(bullet)这里以帧为单位控制冷却时间。如果游戏帧率固定在 60 帧8 帧间隔就等于每秒发射 7.5 发视觉上比较合适。如果你的游戏主循环用clock.tick(60)那么帧为单位没有问题但有些游戏在不同机器上帧率不一致更好的做法是用毫秒计时self.last_shot_time 0 self.shoot_interval_ms 150 # 150毫秒发射一次 def try_shoot(self, bullet_group, current_time): if current_time - self.last_shot_time self.shoot_interval_ms: return self.shoot() self.last_shot_time current_time使用毫秒的好处是帧率变化不影响射击频率。不过 pygame 游戏大多锁定 60 帧帧为单位更简单直观。我的建议是如果你的游戏需要适配不同性能的设备用毫秒如果只在固定环境跑用帧更省事。3.3 枪口位置与多弹道发射子弹的出生点应该从飞机头部出来而不是飞机中心。结合飞机图片的尺寸来定位飞机图片如果是 50×70飞机中心的 x 坐标self.rect.centerx飞机顶部的 y 坐标self.rect.top子弹射出的位置就是(self.rect.centerx, self.rect.top)。双发子弹要从两个枪口出来分别在中心偏左和偏右的位置def shoot_dual(self, bullet_group): left_bullet Bullet(self.rect.centerx - 15, self.rect.top, speed_y-12) right_bullet Bullet(self.rect.centerx 15, self.rect.top, speed_y-12) bullet_group.add(left_bullet, right_bullet)这里的 15 是经验值取决于飞机图片的宽度实际操作时多调试几次让子弹看起来是从两个炮管里射出的效果最好。做人字形弹道时只需要让左右子弹分别带不同的 speed_x比如左弹 speed_x-2、右弹 speed_x2子弹就会向两边散开。4. 子弹组管理与碰撞检测4.1 Group 在子弹系统中的应用pygame 的Group是管理多个 Sprite 的容器本质是一个集合支持添加、移除、遍历、更新和碰撞检测。我用三个 Group 来管理游戏实体player_group存放玩家飞机enemy_group存放敌机bullet_group存放玩家发射的子弹enemy_bullet_group存放敌人发射的子弹与玩家子弹分开避免误判把玩家子弹和敌机子弹分开碰撞检测时才不会出现自己打到自己的尴尬场景。Group 的update()方法会遍历所有成员并调用它们的update()方法这比手写 for 循环更简洁bullet_group.update() enemy_group.update() enemy_bullet_group.update()渲染时也只需要调用draw(screen)它会将每个 Sprite 的 image 按 rect 位置绘制到屏幕上省去手动逐个 blit 的麻烦。4.2 spritecollide 与碰撞检测的原理碰撞检测是子弹系统最核心的反馈逻辑。pygame 提供了两种主要方式# 方式一子弹组 与 敌机组 的碰撞 hits pygame.sprite.groupcollide(bullet_group, enemy_group, True, False) # 方式二单个子弹 与 敌机组 的碰撞常用于特殊子弹 bullet_hit pygame.sprite.spritecollide(bullet, enemy_group, False)groupcollide的参数依次是两个 Group、两个布尔值分别表示碰撞后是否销毁第一个组中的精灵和碰撞后是否销毁第二个组中的精灵。对于玩家子弹击中敌机通常设置第一个参数为 True子弹命中后消失第二个参数为 False敌机不直接在碰撞函数中消失而是由后续的血量逻辑处理这样可以在敌机血量归零时再销毁不会因为碰撞瞬间就消失而跳过受伤逻辑。返回的字典形式是{子弹: [它撞到的敌机列表]}遍历字典可以处理多目标命中。还有个实际问题子弹速度太快会穿透。当子弹一帧移动距离超过敌机的宽度时可能出现上一帧子弹还在敌机左侧下一帧已经跑到敌机右侧碰撞检测完全检测不到。解决方法是限制子弹速度上限或者使用更高级的连续碰撞检测——把子弹上一帧的位置和当前帧的位置连成一条线段再判断这条线段是否穿过敌机的矩形。对于普通飞机大战游戏把速度限制在每帧不超过敌机最小宽度的一半个数就能保证不错过碰撞。4.3 命中反馈与分数逻辑碰撞只是第一步玩家的打击感来自命中后的反馈。我建议在碰撞发生后做三件事for bullet, enemies_hit in hits.items(): for enemy in enemies_hit: enemy.hp - bullet.damage if enemy.hp 0: enemy.kill() player.score enemy.score_value spawn_explosion(enemy.rect.center)这里有几个坑。第一个是敌机血量判断不能放在groupcollide之前的代码里否则可能出现敌机已经死透但碰撞仍然触发的现象。第二个是爆炸特效不能直接写成新建一个 Group 每个帧绘制它应该是一个临时的动画对象播放完自动销毁。第三个是计分逻辑的位置score 应该作为 Player 的属性而不是全局变量方便后续保存最高分、多人模式等扩展。爆炸特效的简单实现是用帧动画列表class Explosion(pygame.sprite.Sprite): def __init__(self, center, images): super().__init__() self.images images self.frame_index 0 self.image self.images[0] self.rect self.image.get_rect(centercenter) def update(self): self.frame_index 1 if self.frame_index len(self.images): self.kill() else: self.image self.images[self.frame_index]这个类的逻辑很简单每次 update 切换一帧图片播完就销毁加入effect_group后不需要额外关注它的生命周期。5. 子弹系统的性能优化5.1 对象复用与内存管理在 while 游戏主循环里每帧创建子弹对象容易引发 GC 压力。Python 的垃圾回收在高频创建销毁对象时会产生明显的性能损耗。我实测过每帧创建 10 个子弹对象持续运行 10 分钟pygame 主循环的帧时间会逐渐增加说明零散对象影响了 GC 效率。一个常用的优化手段是对象池。预先创建一批 Bullet 对象用标志位区分活跃和空闲发射子弹时从空闲池取子弹销毁时归还池子。用 pygame 的 Sprite 实现对象池有个难题kill()会自动从所有 Group 移除而add()会重新加入这中间可以用一个标志self.active配合判断。不过对象池让代码复杂度上升不少对于小游戏来说我的建议是先不做对象池把工作量放在逻辑清晰和通关流程上。只有当你明确游戏卡顿、性能瓶颈确实在子弹创建时再引入对象池。5.2 控制同屏子弹数量同屏子弹太多除了性能还会让游戏变得过于简单或困难。我在项目里做了一个子弹上限控制MAX_BULLETS 30 def try_shoot(self, bullet_group): if self.shoot_cooldown 0: return if len(bullet_group) MAX_BULLETS: return # 达到上限就不发射逼迫玩家提高准度 self.shoot() self.shoot_cooldown self.shoot_interval这样设计后玩家需要思考射击节奏而不是无脑按住空格刷子弹。同时限制子弹数量也能从源头上控制性能开销。如果你想让上限不明显影响体验可以把上限设为 50这个值在多数公开的 pygame 飞机大战项目里算是比较合理的。5.3 图片的预加载与 Surface 转换pygame.image.load()是一个比较重的操作每帧加载同一个图片显然不行。正确做法是模块加载时统一预加载BULLET_IMAGE pygame.image.load(bullet.png).convert_alpha() ENEMY_IMAGE pygame.image.load(enemy.png).convert_alpha() class Bullet(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image BULLET_IMAGE # 直接引用预加载的图片要注意多个 Sprite 实例共用同一个 image 是安全的只要不修改 image 的内容碰撞检测、绘制都不会有问题。如果你需要不同颜色的子弹用pygame.transform复制后再调色避免一手素材被多个实例修改的冲突。5.4 碰撞检测效率优化groupcollide(bullet_group, enemy_group)的复杂度是 O(N×M)N 是子弹数量M 是敌机数量。正常情况下 N 和 M 都在 50 以内总计算量 2500 次矩形碰撞pygame 底层是 C 语言实现速度很快完全没问题。但如果你的弹幕数量特别多比如子弹 200 个、敌人 50 个就会出现 10000 次矩形判断这时候会有卡顿风险。优化方向有两个一是网格空间划分。把屏幕分成固定大小的格子比如 64×64每帧把子弹和敌机按所在格子加入字典碰撞检测时只检查同一格子和相邻 8 个格子内的对象。这样 O(N×M) 降为 O(N×平均邻居数)性能提升非常明显。二是降低碰撞检测频率。不需要每帧检测一次碰撞可以每两帧检测一次。这个方案简单但容易漏掉高速子弹需要配合子弹速度上限使用。我个人的建议是对于初版飞机大战用groupcollide就足够了。等到你确实做出了一款弹幕密集到需要空间划分的游戏时再做优化也不迟。过早优化是游戏开发新手最容易犯的错误。6. 常见问题与调试技巧6.1 子弹瞬移或穿透敌机前面说过高速子弹会穿过敌机矩形而检测不到碰撞。如果玩家升级子弹后速度过快这个问题会很突出。排查方法很简单把子弹速度降为 -5 试一下如果就能正常命中那基本可以确定是速度过快导致的穿透。解决方式一是限制最高速度二是把 Bullet 的update()改成按步长移动def update(self): # 将一次移动拆分成多步每步检测一次碰撞 steps 4 for _ in range(steps): self.rect.y self.speed / steps self._check_collision()步长拆分能保持子弹速度较快又不会穿透短小的目标。这个方案实现简单我实际用过对命中率提升很有效。6.2 子弹发射位置偏移、错位有时候子弹从飞机中部甚至尾部射出来原因几乎都是 rect 位置计算错误。记住rect.x/y是矩形的左上角rect.centerx/centery是矩形的中心点rect.top/bottom/left/right是矩形的四个边。发射位置应该基于self.rect.top而不是self.rect.y。虽然y和top绝大多数时候相等但当你旋转或缩放图片后rect 的边界会变化用top更可靠。如果你做了飞机的左右移动还需要保证子弹发射时取的是当前位置而不是上一次更新的位置。一个容易踩的坑是在按键触发函数里创建子弹但按键事件处理晚于飞机位置更新导致子弹出生在上一帧位置。解决方法是主循环中先更新玩家位置再处理碰撞和发射逻辑。6.3 子弹无法销毁、游戏内存持续增长如果你发现游戏运行时间越长帧率越低十有八九是子弹没有正常销毁。常见原因有update()中边界判断写错比如写成了if self.rect.y 0子弹飞出屏幕下方永远不触发销毁。忘记调用kill()只把自己加入一个已过期集合但 Sprite 仍然在 Group 里。修改 rect 坐标时用了self.rect self.image.get_rect()重新赋值 rect 导致原有 Group 的位置信息错乱碰撞检测失效。排查方法是在update()里加一行调试输出def update(self): self.rect.y self.speed if self.rect.bottom 0: print(fdestroy bullet at y{self.rect.y}, speed{self.speed}) self.kill()运行一次看子弹飞出屏幕顶端时是否打印这行日志。没有打印说明判断条件有问题有打印但帧率仍然降低说明销毁无效需要检查 Sprite 的 group 归属。6.4 双发/散射子弹的碰撞判定异常双发子弹通常从飞机左右两侧的不同位置发射飞行一段时间后可能重叠。重叠的子弹在groupcollide检测时会各自与同一个敌人碰撞造成一次多发的伤害叠加。如果你不想这样可以在碰撞后设置命中冷却class Enemy(pygame.sprite.Sprite): def __init__(self): # ... self.hit_cooldown 0 def update(self): self.hit_cooldown max(0, self.hit_cooldown - 1) def take_damage(self, damage): if self.hit_cooldown 0: return 0 # 冷却期间不受伤 self.hp - damage self.hit_cooldown 3 # 3帧内免疫 return damage这样即使十发子弹同时打在同一个敌人身上也只在第一发造成伤害后面几发无论是视觉效果还是数值反馈都符合正常的游戏逻辑。6.5 子弹贴图方向不对加载子弹图片后发现子弹朝下飞或者角度不对。这个问题的根源在于素材本身的方向和游戏坐标系不匹配。pygame 的坐标系原点在左上角y 轴向下为正。如果你的子弹图片上是弹头朝上那speed_y为负向上移动是正确的如果图片是弹头朝下那speed_y为正才能朝下飞但玩家子弹多半应该朝上飞所以图片方向一定要统一。统一素材方向的技巧是在加载图片后做一个自动修正def load_bullet_image(path, upwardFalse): image pygame.image.load(path).convert_alpha() if upward and image.get_height() 0: # 假设图片中导弹朝下需要翻转 180 度 image pygame.transform.rotate(image, 180) return image当然这个假设不通用更稳妥的做法是美术资源统一好朝向后不再旋转。如果后续要旋转作调试可以用pygame.transform.rotate(image, angle)但注意旋转会改变矩形大小碰撞框要以新的 rect 为准。7. 子弹系统扩展方向子弹系统做完后游戏的可玩性提升最直接的方向是道具系统。比如吃到火力强化道具后发射模式从单发变双发再变三发每级都有新的弹道形式吃到护盾道具后玩家的飞机周围多了一层光环子弹碰到光环会被抵消这时就需要判断子弹是否进入光环半径这是圆形碰撞检测的入门场景。另一个扩展方向是特殊攻击。按住 Shift 键蓄力松开时释放一个大范围激光或者导弹群。这需要设计一个蓄力计时器和一个大弹爆炸效果。蓄力期间的子弹颜色变化、音效变化都能增强交互感。如果你想把游戏做成关卡型子弹系统还可以跟不同 Boss 的弹幕模式联动。Boss 每管血切换一种弹幕比如扇形弹幕、螺旋弹幕、环形弹幕这些弹幕的核心都是你设计好的 Bullet 类和 Group 管理逻辑只需要调整生成规则就行。我在项目里实现过一种简单的环形弹幕本质是一次生成 12 个 Bullet每个的 speed_x 和 speed_y 按角度分配import math def spawn_circular_bullets(center, radius, count, speed): bullets [] for i in range(count): angle 2 * math.pi * i / count b Bullet(center[0], center[1], speed * math.cos(angle), speed * math.sin(angle)) bullets.append(b) return bullets这种环形弹幕的视觉效果非常好代码量却不大。后续如果要加弹幕的旋转、加速等特性只需要在 Bullet 中增加角速度属性。最后想提一个很多人忽略的点音效与视觉的配合。子弹射出去如果只有一个小方块在屏幕上飞手感很空。加上发射音效、命中音效和子弹尾部拖影游戏体验立刻上一个档次。拖影可以用不销毁轨迹的方式实现但要注意清理否则屏幕上残留的像素会越来越多造成视觉污染和性能问题。稳妥的做法是生成一个小型拖尾精灵每一帧透明度递减两帧后销毁。子弹系统是整个飞机大战项目里少有的一次做好全程受益的模块。早期多花点心思把类的结构、事件的触发、碰撞的反馈理清楚后面加再多功能都不慌。我做到一半时曾想重写整个项目后来发现只需要把子弹模块重构一遍其他部分几乎不用改这就体现出模块化设计的价值了。
阅读完成 · 觉得有帮助?