恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Pygame飞机大战子弹系统设计:生命周期与碰撞检测
首页
资讯中心
/
Pygame飞机大战子弹系统设计:生命周期与碰撞检测
Pygame飞机大战子弹系统设计:生命周期与碰撞检测
发布时间:2026/10/15 10:25:57
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, speed10, color(255, 255, 255), radius3, damage1): 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_speed10): 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, size200, **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, speed10, color(255, 255, 255), radius3, damage1): 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_width480, screen_height700): 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_speed10, shoot_interval12, 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_speed10, shoot_interval8) # 加载音效如果没有音效文件就注释掉 # 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_interval8意味着每秒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里把speed10理解为每帧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。我个人在实际操作中的体会是不要再做那种先随便写写、之后再重构的事情。子弹系统麻雀虽小五脏俱全几乎涵盖了游戏对象管理的全部核心难点。你花一个晚上把它彻底搞透后面做任何游戏碰到类似批量对象产生、移动、碰撞、销毁的问题用的都是同一套思维模型。这个模型一旦建立起来你会发现自己写游戏的速度和代码的可维护性完全不在一个档次。最后再分享一个小技巧子弹系统的参数射速、速度、偏移量、伤害最好全部放进一个统一的配置字典里测试平衡时改参数跑一局比你满文件翻魔法数字要快几十倍。把调试的时间省下来多玩两把自己的作品那个感觉才真的值。