☰
Pygame飞机大战子弹系统设计:生命周期与碰撞检测
2026/10/15 10:25:47 网站建设 项目流程

1. 为什么子弹系统是飞机大战的灵魂

做飞机大战,几乎所有初学pygame的人第一反应都是先画飞机、先加载背景、先让飞机能飞。但真正上手你会发现,飞机能移动只是开胃菜,游戏好不好玩、手感爽不爽,全看子弹系统做得怎么样。我见过太多人把飞机画得漂漂亮亮,结果子弹发射出去要么速度不对,要么碰撞判定糊成一团,玩起来总觉得"哪里不对但又说不上来"。

这个项目标题里的"子弹设计(一)"说明这不是一篇讲完所有内容的文章,而是聚焦在子弹从产生到销毁的一个完整生命周期上。你把这个搞明白了,后面什么敌机弹幕、Boss子弹、道具加特林,全都是同一个套路换着花样玩而已。

先说清楚这篇博文适合谁。你如果刚刚学完pygame基础,知道怎么创建窗口、怎么加载图片、怎么响应键盘事件,但写出来的小游戏总是卡顿、碰撞不准、代码越写越乱,那这篇文章就是给你准备的。如果你已经在做飞机大战但子弹模块写得很凑合,想看看更合理的结构设计,同样值得往下看。

我在做这个项目时给自己定了三个核心目标,也算是当初踩了很多坑之后的总结:一是子弹的发射频率和速度必须能够独立调节,不能把逻辑写死在主循环里;二是所有子弹必须集中管理,统一处理移动、碰撞、清理,而不是每个子弹自己维护状态;三是整个子弹模块的代码要能在不改动主逻辑的情况下从普通单发升级成双发、三发、散射。这三条听起来简单,但你逐条对照自己的代码,会发现大多数初学版本都做不到。

下面分享的完整源码,工作之余写来练手,整个实现基于pygame 2.x版本。我会把子弹设计从思路到编码再到调优的全过程拆开讲,重点不是让你抄代码,而是让你下次做任何需要"对象批量产生、移动、消失"的游戏模块时,都能照着这个思路自己搭起来。

2. 整体设计思路拆解

2.1 先想清楚子弹本质是什么

子弹在我的设计里就是一个矩形区域,带着速度和方向,每帧向外移动一次,离开屏幕就销毁。这里的关键不是"子弹应该是一个类",而是"你到底需要它有什么能力"。

首先它要有贴图或者颜色,其次它必须有位置和速度,再次它需要有碰撞范围,最后它要有一个"死亡"状态。这四个属性缺一不可。

很多初学者犯的错误是把子弹的逻辑直接写在主循环里:

for bullet in bullets: bullet.y -= 10 if bullet.y < 0: bullets.remove(bullet)

这段代码看起来没什么问题,短小精悍。但它把子弹的速度(10像素/帧)、边界清理、遍历方式全部写死在了游戏主循环里。等你想让子弹加速、想让不同战机发射不同速度的子弹、想给子弹加特效时,你就得翻回主循环里改这一段,然后后面改出越来越多的Bug。

子弹作为游戏里高频产生的对象,本身就应该是一个独立的类,有自己清晰的职责边界。我的设计里,子弹类只做三件事:知道自己长什么样、知道自己怎么动、知道自己还活着没有。至于子弹和敌机的碰撞怎么处理、子弹要不要跟着飞机偏移、子弹被打爆的动画怎么播,那都是外面的事情,不应该塞进这个类里面。

2.2 子弹对象的生命周期包含哪几个阶段

子弹从出生到消失,我认为应该明确分成这么几个状态:准备期、飞行期、死亡期。

准备期是子弹被创建但尚未参与逻辑计算的那段时间。你想想,如果你在按下空格键的瞬间创建一颗子弹,然后立刻让它移动,这一帧的视觉效果会非常突兀。因为子弹生成的位置在枪口,但你这一帧画面已经画完了,你必须在下一帧才显示它。这在高速游戏里问题不大,但在讲究手感的游戏里,子弹的第一帧往往是"跳"出去的,而不是"飞"出去的。

飞行期是整个生命周期里最长的状态。子弹按照每帧速度移动,并且每帧检查边界条件。这里的"每帧"是游戏循环里最核心的概念,你得理解帧率对子弹速度的影响。如果你的游戏跑在60帧每秒,子弹每帧移动10像素,那么每秒移动600像素。但如果你在性能差的电脑上游戏掉到30帧,同样每帧10像素,子弹每秒就只移动300像素,游戏难度完全不一样了。

这是很多新手根本不会注意到的坑。你有两个解决方案:一是用Clock.tick(60)固定帧率,这也是大多数pygame入门教程的做法,简单粗暴;二是把速度换算成"每秒像素数",在每一帧里根据dt时间差做插值计算。我在这个项目里选了第一种方案,因为固定帧率足够应对飞机大战这种轻量级游戏,而且逻辑更清晰。

死亡期就是子弹碰到敌人、碰到边界或者被判定为无效的那一帧。这个状态的处理很考验你的代码功底。我的做法是不立刻从列表里删除子弹,而是标记一个alive标志,放到一个"待回收"的机制里。后面我会详细说这么做的理由。

2.3 对象管理:为什么用列表而不是字典或者数组

子弹用列表管理是pygame社区里最常见的做法,但很多人只是人云亦云地用了列表,并不知道为什么。

pygame是一个没有内置实体管理系统的库,你需要自己用数据结构管理游戏对象。列表在绝大多数情况下是够用的原因在于,你往列表里添加元素是O(1)的操作,遍历列表也是O(n)的操作。你的游戏里同时存在的子弹数量一般不会超过几百颗,n很小,所以O(n)的性能完全不是问题。

但这里有一个细节,实际开发中绝大多数人会在删除元素时踩坑。如果你在遍历列表的时候直接remove元素,会导致列表索引错位,跳过一个元素或者访问到不存在的索引。我见过新手用最典型的错误代码是:

for bullet in bullets: if bullet.out_of_screen(): bullets.remove(bullet)

这段代码在连续两颗子弹都出界时会漏删掉一颗。因为for循环内部维护了一个索引,remove掉当前元素后,下一个元素的位置前移,循环索引却继续指向后一位,于是跳过了它。

正确处理方式有两种。一种是倒序遍历:

for bullet in bullets[::-1]: if bullet.out_of_screen(): bullets.remove(bullet)

倒序遍历时,删除当前元素只会影响它后面的元素位置,但你还没遍历到后面的元素所在的位置,所以不会跳过。

另一种是先用列表推导式筛选出存活子弹,再整体赋值:

bullets = [bullet for bullet in bullets if not bullet.dead]

第二种方式更简洁,但因为创建了新列表,在子弹数量极大时会有性能开销。飞机大战场景下我推荐第二种,代码可读性远大于那一点性能差异。

2.4 帧循环里子弹逻辑该怎么嵌入

pygame的主循环一般是这样的结构:处理输入、更新逻辑、绘制画面。子弹的更新逻辑就放在"更新逻辑"这个环节里。

我的流程是这样:

while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False keys = pygame.key.get_pressed() if keys[pygame.K_SPACE]: shoot_timer += 1 if shoot_timer >= shoot_interval: bullets.append(Bullet(player.rect.centerx, player.rect.top)) shoot_timer = 0 for bullet in bullets: bullet.update() bullets = [bullet for bullet in bullets if bullet.alive] for bullet in bullets: bullet.draw(screen)

这个流程的核心在于"生成-更新-清理-绘制"这四步的顺序不能乱。你先要生成新子弹,再更新所有子弹的位置,然后清理掉无效子弹,最后才绘制。如果你把清理放在更新之前,那么一颗子弹在生成后的第一帧就会被跳过更新,位置不动,会闪一下。

还有一个很多人容易忽略的点:生成子弹的频率控制。直接用if keys[K_SPACE]就发射子弹的话,按住空格会每帧都生成一颗子弹,以60帧来说就是每秒60颗,一方面性能扛不住,另一方面游戏平衡性崩溃。所以需要引入一个发射间隔控制器,这就是我用shoot_timer的原因。间隔可以独立设置为shoot_interval,想调整射速只需改这一个参数。

3. 子弹类的核心细节实现

3.1 子弹类的基础属性设计

我写的子弹类包含以下核心属性,每个属性的选取都有讲究:

class Bullet: def __init__(self, x, y, speed=10, color=(255, 255, 255), radius=3, damage=1): self.x = x self.y = y self.speed = speed self.color = color self.radius = radius self.damage = damage self.alive = True self.rect = pygame.Rect(x - radius, y - radius, radius * 2, radius * 2)

这里我把子弹设计成了圆形而不是矩形。原因很简单,圆形子弹的碰撞检测用距离计算,理论上精确,代码也简洁。矩形碰撞在pygame里虽然可以直接用colliderect,但如果子弹有特殊形状(比如长条形的激光),那张贴图的可视区域和碰撞区域可能不一致,调试起来很费劲。圆形子弹在旋转、缩放、特效处理时也更好做。

你可能想问为什么不直接用pygame的Sprite类。Sprite本质上就是对rect和image的封装,配合Group用起来确实方便。但我故意没用Sprite,原因是我在这个阶段想让学生更清楚地理解对象管理的本质,而不是把子弹逻辑复杂在Group的魔法方法里。当你理解了原理,再去用Sprite只是换了个写法的事情。

3.2 子弹移动为什么要区分x和y两个方向

飞机大战里的子弹大多数是直线向上飞,所以你可能觉得只需要改y坐标就够了。但我的子弹类依然把x和y分开处理了,这是有原因的。

首先是为了扩展性。如果下一关Boss发射斜向弹幕,那子弹的x方向你就得用上了。如果你的角色吃到了"两侧子弹"道具,子弹和飞机的中轴线有偏移,那么x方向的初始值也要单独设置。

其次是为了给后续做子弹特效铺路。比如有的子弹会做螺旋轨迹,也就是x随着时间做正弦波动,如果你把x和y写在一个坐标里混着处理,这时候改代码的复杂度会远大于你当初省下的那两行代码。

def update(self): self.y -= self.speed if self.x < 0 or self.x > screen_width: self.alive = False if self.y < 0 or self.y > screen_height: self.alive = False self.rect.center = (self.x, self.y)

这里有一个点注意一下:负值的出现。飞机向上飞,y坐标在pygame里是往下递增的,所以"向上"对应的是y坐标减小,我用的是self.y -= self.speed。如果你习惯把屏幕坐标系理解成数学坐标系,这个方向很容易搞反。调试的时候如果发现子弹往下跑,首先检查是不是这里减号写成了加号。

我在写这个类时还给update()方法留了一个扩展点,就是子弹可以携带一个角度属性angle。普通子弹的角度是垂直向上,即-90度,带有角度的子弹需要通过三角函数计算每帧的x和y速度分量。这个我们放在后面一篇讲子弹进阶时展开,这里先埋个伏笔。

3.3 子弹边界检测的正确姿势

子弹离开屏幕后必须被清理,这个逻辑本身很简单,但"离开屏幕"的判断条件需要仔细思考。

最直观的判断是if bullet.y < 0,表示子弹顶部越过了屏幕顶部。但是,如果子弹速度很快,这一帧还在屏幕内,下一帧已经跑出去几百像素了,你依然能检测到y < 0,没问题。真正的问题是子弹从屏幕左右两侧飞出去的情况,以及子弹还要考虑从底部飞出去的情况——对,我说的是以后做Boss弹幕时子弹会从顶部往底部飞。

为了让边界检测更加通用,我的做法不是判断一个点的位置,而是判断整个圆形的碰撞区域是否和屏幕矩形相交:

screen_rect = pygame.Rect(0, 0, screen_width, screen_height) if not screen_rect.colliderect(self.rect): self.alive = False

这个写法的好处是,不管子弹是圆形还是矩形,不管子弹速度有多快,只要子弹的可视区域完全离开了屏幕,就被判定为死亡,不需要你自己去推导一大堆不等式。

顺便说一下,这里如果你用的是Sprite类,Group自带update()方法会帮你迭代调用每一个精灵的update()。但清理逻辑依然要你自己写,因为Group不会因为你把某个精灵的坐标挪到屏幕外就自动把它杀掉。

3.4 碰撞检测为什么不用矩形

pygame里最常用的碰撞检测是rect.colliderect(),但如果子弹是圆形,矩形碰撞区域会比圆形实际区域大一圈,在子弹和敌机擦身而过时会造成"看着没打中,实际判中了"的挫败感。飞行射击游戏是快节奏游戏,这种判定错误会非常影响手感。

我在这里用的是圆心距离判定:

def collides_with(self, other_rect, other_radius): dx = self.x - other_rect.centerx dy = self.y - other_rect.centery distance_sq = dx * dx + dy * dy radius_sum = self.radius + other_radius return distance_sq <= radius_sum * radius_sum

这里我没有用math.sqrt开根号,因为距离比较只需要比较平方即可。这是一个很小的性能优化点,但设计哲学是:游戏里碰撞检测每帧执行成千上万次,能少一次sqrt调用就少一次。积少成多,帧率就是这么一点一点保住的。

如果你依然想用矩形碰撞,有一个折中方案:让子弹的rect比子弹的可视贴图小一圈。比如子弹看起来是10像素直径的圆,那你的碰撞矩形可以做成6x6像素的中心区域。这样判定区域会明显比视觉区域小,手感上反而更宽容一些。很多商业游戏就是这么做碰撞的,玩家看到子弹擦着自己的飞机飞过去,其实判定上已经算击中或者没击中。

3.5 子弹音效和特效的触发时机

一个飞机大战的爽感很大程度来自音效和特效的配合。子弹的发射音效应该在哪触发?我做的是在子弹成功生成后立刻触发:

if shoot_timer >= shoot_interval: bullet = Bullet(player.rect.centerx, player.rect.top) bullets.append(bullet) shoot_sound.play() shoot_timer = 0

很多人会把音效播放放在按键事件里。但你要知道,pygame的KEYDOWN事件只在按键刚按下那一刻触发一次,如果你按住空格键不松手,靠的是帧循环里的get_pressed()来连续生成子弹。如果在KEYDOWN里播放音效,你按住空格时只响一声,后面的子弹全部是哑火的。在get_pressed()分支里播放音效,才能做到按住空格时音效连续播放。

音效还有一个小坑:如果你每帧都调用play(),同一时间点可能会叠加很多声,导致声音突然特别响甚至破音。pygame.mixer.Sound.play()在大多数版本里如果你连续调用两次,它会叠加播放,我一般建议设置一个shoot_sound_channel,每次播放前先停掉之前的播放,或者控制发射间隔本身就足够长,让叠加概率降低。

再说特效。子弹的特效有两种,一种是子弹本身的尾迹特效,一种是子弹命中敌机时的爆炸特效。前者常见做法是在子弹移动路径上留下残影,即半透明圆形的短暂残留;后者需要单独管理一个粒子系统。本篇文章暂时只讲子弹本体,粒子系统可以等我后面写"特效设计"那一篇时详细展开。

4. 主循环里的子弹控制逻辑

4.1 发射间隔控制:从定时器到按帧计数

子弹发射间隔是我在设计中重点考虑的参数之一。它直接决定着游戏的难度曲线和手感节奏。

我采用的方案是把发射间隔定义成帧数。shoot_interval = 12意味着每12帧发射一颗子弹,在60FPS下就是每秒5发。这个数值看起来很直观,比"每隔0.2秒发射一颗"更好理解,因为你不用进行帧率和秒的换算。

shoot_timer = 0 shoot_interval = 12 # 每隔12帧发射一颗,约5发/秒 while running: if keys[pygame.K_SPACE]: shoot_timer += 1 if shoot_timer >= shoot_interval: bullets.append(Bullet(player.rect.centerx, player.rect.top)) shoot_timer = 0 else: shoot_timer = 0

其中有一个容易忽略的细节:我把shoot_timer放在if keys[K_SPACE]的分支内累加,也就是说你松开空格后计时器归零。这样设计的逻辑是:玩家每次按下空格,第一发子弹都是立即发射的,不需要重新等上shoot_interval帧。这个细节对射击手感的影响很大。如果计时器在松手后不归零,玩家会觉得"我明明按下了,子弹却犹豫了一下才出来"。

但要注意,如果玩家只是短按一下,timer从0加到12还差很远就松开归零了,那这次按键可能一颗子弹都没发出去。想要解决这个问题,需要把发射逻辑改成按下瞬间强制发射一次,然后进入冷却状态。典型的实现是:

if keys[pygame.K_SPACE]: if shoot_timer == -1: bullets.append(Bullet(...)) shoot_timer = shoot_interval else: shoot_timer -= 1

这里用shoot_timer == -1表示"可以发射"状态。第一次按下时空格键,shoot_timer本来就是-1,直接发射;发射后它变成shoot_interval,然后每帧减一,减到-1时又可以发射。这个写法和前面的计时器加法写法达到的效果几乎一样,但逻辑上更符合"冷却时间"的概念,后续调整时更好理解。

这两段代码对比,你可以看到一个核心设计思路:任何机制只要你用状态变量显式表达出来,后面维护就只剩改参数的事情,而不是改逻辑。

4.2 如何实现多方向射击

这一节我把发射管理封装成一个独立的Shooter组件。飞机大战后期你一定会碰到这种需求:吃到道具后从单发变双发、三发、甚至左右散射。

class Shooter: def __init__(self, bullet_speed=10): self.bullets = [] self.shoot_timer = 0 self.shoot_interval = 12 self.bullet_speed = bullet_speed self.pattern = "single" def update(self, owner_rect, keys): if keys[pygame.K_SPACE]: self.shoot_timer += 1 if self.shoot_timer >= self.shoot_interval: self._fire(owner_rect) self.shoot_timer = 0 else: self.shoot_timer = 0 def _fire(self, owner_rect): if self.pattern == "single": self.bullets.append(Bullet(owner_rect.centerx, owner_rect.top, self.bullet_speed)) elif self.pattern == "double": offset = 10 self.bullets.append(Bullet(owner_rect.centerx - offset, owner_rect.top, self.bullet_speed)) self.bullets.append(Bullet(owner_rect.centerx + offset, owner_rect.top, self.bullet_speed)) elif self.pattern == "triple": offset = 12 self.bullets.append(Bullet(owner_rect.centerx, owner_rect.top, self.bullet_speed)) self.bullets.append(Bullet(owner_rect.centerx - offset, owner_rect.top, self.bullet_speed)) self.bullets.append(Bullet(owner_rect.centerx + offset, owner_rect.top, self.bullet_speed))

这个Shooter类的设计把子弹的产生集中到了一处,主循环里只需要调用shooter.update()和遍历shooter.bullets即可。

到了"三发散射"这一步,你会发现散射不只是子弹x坐标的偏移,子弹的角度也需要改变。左右两侧的子弹应该斜着飞出去,而不是和中间那颗一起垂直向上。这个时候,Bullet类的速度就满足不了你了,你需要为Bullet增加vx和vy两个分量,然后用三角函数计算斜向速度。我会在下一篇专门讲子弹的各种弹道模式,这里先把代码留个升级接口。

4.3 对象池:高性能子弹管理策略

我在主循环里只用了一个列表来管理子弹,这个方案在飞机数量不多、子弹不超过几百颗的时候完全够用。但如果你做了弹幕游戏,比如每帧产生上百颗子弹,同时存活上千颗,频繁地创建新对象和删除旧对象会带来两个问题:一是垃圾回收的开销越来越大,二是内存碎片化导致游戏越跑越卡。

对象池的思路是:提前创建一批子弹对象放在池子里,射击时从池子里取出一个"复活",子弹出界后不销毁而是放回池子。这样整个游戏过程中子弹对象的总数是固定的,不存在反复创建销毁。

class BulletPool: def __init__(self, size=200, **kwargs): self.pool = [Bullet(**kwargs) for _ in range(size)] self.active = [] def spawn(self, x, y): bullet = self.pool.pop() bullet.x = x bullet.y = y bullet.alive = True self.active.append(bullet) return bullet def recycle(self): for bullet in self.active: if not bullet.alive: self.pool.append(bullet) self.active = [bullet for bullet in self.active if bullet.alive]

手动管理对象池会带来一个新的麻烦:如果池子里的对象不够用,你需要决定是扩容还是丢弃新请求。我在项目里选择的策略是池子默认200颗,如果申请时池子已经空了,就临时创建一个新的子弹对象,宁可牺牲一点点池化的纯度,也不能让子弹发射失效。游戏运行时可以通过打印池子的剩余数量来监控实际使用情况,如果长期满池,你就得调大初始池子大小。

不过你这个项目如果只是作为学习练手,我强烈建议你先不要上对象池。原因很实际:对象池增加了代码复杂度,你把维护重点放在碰撞、音效、手感上,对学习的收益更大。对象池是在你遇到真实性能瓶颈之后再引入的优化手段,而不是一开始就要背上的负担。

5. 完整源码实现

5.1 项目文件结构

先说清楚源码怎么组织。很多初学者习惯把所有代码写在一个文件里,几十行代码无所谓,但一旦上了几百行,你会发现自己找某个逻辑都要翻半天。良好的文件结构从第一天就该养成。

这个项目的文件结构我建议这样组织:

plane_game/ |-- main.py # 主程序入口 |-- bullet.py # 子弹类 |-- shooter.py # 发射控制类 |-- player.py # 玩家飞机类 |-- settings.py # 全局参数配置 |-- assets/ |-- bullet.png # 子弹图片(如果有的话) |-- shoot.wav # 射击音效

settings.py的加入会让你的代码好维护非常多。所有魔法数字(比如速度、间隔、窗口大小)都放进去,改游戏平衡时你只需要改这一个文件。

5.2 子弹类的完整代码

# bullet.py import pygame class Bullet: def __init__(self, x, y, speed=10, color=(255, 255, 255), radius=3, damage=1): self.x = x self.y = y self.speed = speed # 向上飞则速度为正 self.color = color self.radius = radius self.damage = damage self.alive = True self.rect = pygame.Rect( int(x - radius), int(y - radius), radius * 2, radius * 2 ) def update(self): self.y -= self.speed self._check_boundary() self.rect.center = (int(self.x), int(self.y)) def _check_boundary(self, screen_width=480, screen_height=700): if self.y < -self.radius: self.alive = False elif self.y > screen_height + self.radius: self.alive = False if self.x < -self.radius: self.alive = False elif self.x > screen_width + self.radius: self.alive = False def draw(self, surface): pygame.draw.circle(surface, self.color, (int(self.x), int(self.y)), self.radius) def collides_with(self, other_rect, other_radius): dx = self.x - other_rect.centerx dy = self.y - other_rect.centery dist_sq = dx * dx + dy * dy radius_sum = self.radius + other_radius return dist_sq <= radius_sum * radius_sum

我在这里保留了_check_boundary的参数默认值,这样在测试时可以传入不同的屏幕尺寸。实际调用时,main.py里把全局的screen_width、screen_height传进去即可。

这个类的设计里有一个值得注意的地方:speed的符号约定。子弹向上飞是y减少,所以我用self.y -= self.speed,这意味着speed为正时子弹向上。如果你写的是self.y += self.speed,那speed为负时向上,两种方式等价,但你自己必须统一。如果不同子弹类混用了不同的符号约定,调试时会非常痛苦。

5.3 发射控制器的完整代码

# shooter.py import pygame from bullet import Bullet class Shooter: def __init__(self, bullet_speed=10, shoot_interval=12, bullet_color=(255, 255, 255)): self.bullets = [] self.shoot_timer = 0 self.shoot_interval = shoot_interval self.bullet_speed = bullet_speed self.bullet_color = bullet_color self.pattern = "single" def update(self, owner_rect, keys): if keys[pygame.K_SPACE]: self.shoot_timer += 1 if self.shoot_timer >= self.shoot_interval: self.fire(owner_rect) self.shoot_timer = 0 elif self.shoot_timer > 0: self.shoot_timer -= 1 def fire(self, owner_rect): cx = owner_rect.centerx top = owner_rect.top if self.pattern == "single": self.bullets.append(Bullet(cx, top, self.bullet_speed, self.bullet_color)) elif self.pattern == "double": offset = 12 self.bullets.append(Bullet(cx - offset, top, self.bullet_speed, self.bullet_color)) self.bullets.append(Bullet(cx + offset, top, self.bullet_speed, self.bullet_color)) elif self.pattern == "triple": self.bullets.append(Bullet(cx, top, self.bullet_speed, self.bullet_color)) offset = 14 self.bullets.append(Bullet(cx - offset, top, self.bullet_speed, self.bullet_color)) self.bullets.append(Bullet(cx + offset, top, self.bullet_speed, self.bullet_color)) def update_all(self): for bullet in self.bullets: bullet.update() self.bullets = [b for b in self.bullets if b.alive] def draw_all(self, surface): for bullet in self.bullets: bullet.draw(surface)

注意update方法里我增加了一个elif分支,这个分支的作用是:在你没有按空格时,发射冷却会缓慢恢复。这个设计和前面"松开即归零"的方案略有不同,它允许你通过快速点按来压榨出比按住空格更快的射速。这在一些高玩向的飞机大战里是故意保留的机制。

5.4 主程序整合代码

# main.py import pygame import sys from bullet import Bullet from shooter import Shooter from player import Player SCREEN_WIDTH = 480 SCREEN_HEIGHT = 700 FPS = 60 def main(): pygame.init() screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("飞机大战 - 子弹系统实战") clock = pygame.time.Clock() player = Player(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 100) shooter = Shooter(bullet_speed=10, shoot_interval=8) # 加载音效(如果没有音效文件就注释掉) # shoot_sound = pygame.mixer.Sound("assets/shoot.wav") running = True while running: # 1. 事件处理 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False keys = pygame.key.get_pressed() # 2. 更新逻辑 player.update(keys) shooter.update(player.rect, keys) shooter.update_all() # 3. 碰撞检测 # 暂时省略敌机,后续补充 # 4. 绘制 screen.fill((0, 0, 0)) player.draw(screen) shooter.draw_all(screen) pygame.display.flip() clock.tick(FPS) pygame.quit() sys.exit() if __name__ == "__main__": main()

在实际跑起来时,你会发现按住空格后子弹从飞机头部连续向上飞去。这个小Demo虽然还没有敌机和碰撞,但你已经能够明确感受到"射击手感"是怎么回事了。shoot_interval=8意味着每秒7.5发左右,这个射速配得上轻快的BGM。你觉得太快或者太慢,只需要改这一个参数重新跑一遍。

6. 常见问题与排查技巧

6.1 发射的子弹没有出现在正确位置

这个是最常见的问题,往往表现是子弹从飞机的正中间冒出来,或者从飞机脚下飞出来。本质原因是owner_rect.centerx和owner_rect.top这两个属性拿错了位置。

Rect.top指的是矩形顶边那一行的y坐标,Rect.centery是中心点的y坐标。如果你希望子弹从飞机头部的中心位置发射,正确的组合是centerx(中心点x坐标)和top(顶部y坐标)。如果你写成了centerx和centery,子弹就会从飞机正中心产生,看起来像是从机身内部发射的。

还有一个小细节:如果你的飞机贴图顶部有几个像素的透明区域,top拿到的依然会被透明区域干扰,实际效果是子弹从飞机上方的空气中发射出来。这时你可以用一个常数GUN_OFFSET来校正:

bullet_x = player.rect.centerx bullet_y = player.rect.top + player.gun_offset # gun_offset通常为负值,向上偏移

这个偏移量需要你对着实际画面微调几次。最好把发射点直接画一个调试用的十字标记在屏幕上,对照着调整,直到视觉上子弹确实从枪口飞出去。

6.2 子弹飞得太快或者太慢

如果你觉得子弹速度不对劲,第一件事是确认帧率。clock.tick(FPS)放在主循环最后,它把每一帧的时长限制在1/60秒。如果这里没有tick或者tick参数不正确,游戏的运行速度会直接跟着电脑性能走,你不会得到稳定的手感。

第二件事是理解速度的单位。我在Bullet里把speed=10理解为"每帧10像素",配合60FPS就是每秒600像素。如果屏幕高700像素,从底部飞到顶部只需要1秒多一点点。这个速度在飞机大战里算是比较适中的。如果子弹速度改为20,那它0.5秒内就能穿越整个屏幕,肉眼几乎只有残影,不利于玩家判断弹道。

如果你希望在不同性能的电脑上都能保持相同的秒速,建议屏幕高度参考值抽象为一个参数,然后根据当前屏幕和参考屏幕的高度比例来缩放速度:

speed = base_speed * (SCREEN_HEIGHT / REFERENCE_HEIGHT)

这样做的好处是游戏在不同分辨率的屏幕上体验一致。分辨率越高的屏幕,物理距离越远,子弹需要越快才能保持同样的穿越时间。

6.3 子弹清洗后列表长度不减少

这是很多人在用了列表推导式后依然会遇到诡异现象。先看看你是不是在遍历列表的同时又修改了列表。哪怕你用了列表推导式,如果你之前的update逻辑里修改了self.bullets,然后在外部再遍历一次,那结果就可能不一致。

我调试这一类问题时的排查顺序是这样的:先打印列表长度,确认是不是一直在增长;如果增长,说明清理条件永远不成立;再逐个检查每个子弹的alive属性和rect位置。一个快速的检查办法是在update_all结束后临时打印存活数量:

print("active bullet:", len(self.bullets))

别小看这个print,它能在你开发阶段省下大把时间。游戏逻辑本身很直观,你只要把状态量打出来,问题会立刻浮出水面。

6.4 碰撞检测时子弹直接穿透敌人

子弹速度过快时,上一帧还在敌人左边,下一帧已经到了敌人右边,距离判定直接跳过碰撞区域。这种现象叫"隧穿效应"。解决它的常见办法是检查两帧之间的运动路径是否与目标相交,而不是只检查当前帧位置。

pygame里没有内置射线检测功能,但有几种折中方案。最简单的是把子弹速度限制在一个范围内,确保每帧移动距离小于最小敌人尺寸的一半。比如敌人最小尺寸是20像素,子弹每帧位移就不应该超过10像素。另一种方案是如果你的子弹需要很快,就把子弹的碰撞检测在每条运动路径上做两次,即上一帧的位置和当前帧的位置中间再插值一个位置检测。

对飞机大战来说,第一种方案足够用了。调速度时记住这个原则:bullet_speed < min_enemy_size / 2,基本不会出现穿透。

6.5 如果需要双发/三发,偏移量怎么设定

偏移量offset决定了双发子弹和中间线的距离。这个值的经验取值是:不能让两颗子弹完全重叠,也不能让它们离得太开以至于看起来不像从飞机上发出来的。

一般的做法是看玩家飞机的机身宽度。假设飞机矩形宽度是60像素,枪口分布在机头两侧,那么两侧子弹的发射点应该在centerx ± 15左右,同时y坐标保持在top - 5的位置,让子弹像从机头两侧伸出的炮管里打出来的。如果偏移量取到25,子弹间距50像素,视觉效果上就会感觉左右两侧子弹是从机身外面飞出来的。

我调试这个参数的方法是:把发射点的坐标打印到屏幕上,直接对照飞机图偏位置微调,一遍就跑准。

7. 让子弹系统更进一步

这一篇讲完了子弹的基础生命周期。你掌握了子弹类的设计、发射控制、列表管理、碰撞检测的圆形判定、以及几个实战参数。下一个阶段可以去尝试的方向,我列出来供你参考。

第一是子弹的贴图与动画。现在用的是pygame.draw.circle画圆,视觉上比较朴素,你完全可以加载一张PNG贴图替换draw逻辑,甚至做两帧交替的闪烁动画。具体做法是Bullet类里增加一个image属性,在draw时用blit代替draw.circle。

第二是子弹的尾迹效果。子弹移动后在原位置留下一个逐渐变透明的圆,形成拖尾。实现方法是在update里把当前位置的副本保存到一个列表中,每帧给这个列表里的尾迹元素减少透明度,衰减到0后移除。这种效果能显著提升视觉冲击力,而且实现成本很低。

第三是弹道模式升级。除了垂直向上直线飞,你可以给Bullet加入vx和vy分量,让子弹做正弦波动、螺旋旋转、跟踪导弹等运动。这一类弹道设计是飞机大战后期的重头戏,不管是玩家方的强化武器还是Boss的华丽弹幕,都靠这些花样撑起来。

第四是对象池的引入。如果你的游戏真的到了子弹数量上千的弹幕级别,回看第4.3节的BulletPool,把它接进来。注意接对象池时要保证子弹的alive状态切换干净,否则会出现子弹明明已经回收却又被画出来的恐怖Bug。

我个人在实际操作中的体会是,不要再做那种"先随便写写、之后再重构"的事情。子弹系统麻雀虽小五脏俱全,几乎涵盖了游戏对象管理的全部核心难点。你花一个晚上把它彻底搞透,后面做任何游戏碰到类似"批量对象产生、移动、碰撞、销毁"的问题,用的都是同一套思维模型。这个模型一旦建立起来,你会发现自己写游戏的速度和代码的可维护性完全不在一个档次。

最后再分享一个小技巧:子弹系统的参数(射速、速度、偏移量、伤害)最好全部放进一个统一的配置字典里,测试平衡时改参数跑一局,比你满文件翻魔法数字要快几十倍。把调试的时间省下来,多玩两把自己的作品,那个感觉才真的值。

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

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

立即咨询