雷电战机Python源码拆解:对象管理、碰撞检测与性能调优
2026/9/15 7:54:18 网站建设 项目流程

简介:这是一份基于Python和pygame库实现的雷电战机射击游戏完整源码,适合Python初学者、游戏开发爱好者以及计算机课堂教学使用,可帮助读者快速掌握pygame项目的整体结构与基础游戏开发流程。压缩包共包含41个文件,大小10.87MB,其中1个main.py主程序、28张PNG和7张JPG图片素材、2个TTF字体、2个MP3和1个WAV音效文件,图像、字体、声音素材齐全,解压后即可运行和修改。已有1730人学习/下载,能直接对照代码研究。源码设计了Player、Bullet、Enemy等游戏对象类,覆盖战机移动与射击、敌我子弹发射、碰撞检测、生命与计分、游戏结束等核心玩法;同时包括pygame窗口初始化、帧率控制、事件监听、图像绘制与动画、音频播放、关卡难度调整等实现细节。分析该项目可系统学习Python面向对象编程与pygame模块用法,也便于在现有框架上二次开发,打造自己的飞行射击游戏。

1. 一份能跑的雷电战机源码,关键不在游戏循环

在搜索引擎里翻 Python 雷电战机小游戏源代码,十份里有八份的主循环长得差不多:pygame.init、建窗口、while True、event.get、flip。这些代码都能跑,但真正决定你拿回去能不能改出自己版本的,是它怎么管理战机、子弹、敌机这三类对象的生命周期,以及爆机后的资源怎么清。会 pygame 的入门者最容易在这份源码上翻车的地方不是循环写错,而是碰撞误判、对象越堆越多、波次节奏失控。按一套最常见的 pygame 实现路径,可以把这份战机游戏源代码拆成对象模型、碰撞、节奏和手感四个层面来改,参数给到可以直接照抄的程度。适合已经看完 python 教程、想拿现成源码做二次开发的玩家,也适合用课程设计交差的学生。

2. 核心循环与对象模型:看清战机源码的骨架

2.1 为什么雷电战机这类源码几乎都用 pygame

雷电战机是典型的纵版射击游戏,画面刷新频率要求 60 帧左右,要持续处理键盘输入、碰撞判断和对象销毁,这三个特征让它天然适合 pygame 而不是 Tkinter 或 turtle。pygame 把最耗时的绘图和事件循环封装在底层 C 库,Python 层只需要每帧做对象更新和碰撞计算。这份源码的第二个特点是对象类型固定:战机、子弹、敌机、爆炸特效,各自拥有独立的坐标、速度和生命周期。把这类对象统一建模成类,是复用这份源码改出自己版本的前提。

在动任何源码之前,先把运行环境确认到可复现的程度。常见做法是 Python 3.10 以上版本加 pygame 2.5 系列,python 环境装好之后执行:

pip install pygame

装完用python -c "import pygame; print(pygame.ver)"验证版本。雷电战机源码通常不依赖第三方资源文件,飞机和子弹多用 Surface 直接绘制,所以只要 pygame 装得上,代码基本能直接跑。这一点对 python 入门者格外友好,不用像爬虫项目那样处理依赖冲突。

2.2 用三个类拆分战机、子弹、敌机

一份能改得动的源码,至少会把战机、子弹、敌机拆成三个独立类,而不是写一堆全局变量加函数。以战机类为例,骨架一般长这样:

class Player(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image = pygame.Surface((44, 36)) self.image.fill((60, 180, 255)) self.rect = self.image.get_rect(center=(x, y)) self.speed = 6 self.max_hp = 3 self.hp = 3 self.shoot_cd = 0 # 射击冷却,单位毫秒 def update(self, dt): # dt 是上一帧到这一帧的毫秒数,用于统一不同帧率下的逻辑 if self.shoot_cd > 0: self.shoot_cd -= dt keys = pygame.key.get_pressed() if keys[pygame.K_LEFT] and self.rect.left > 0: self.rect.x -= self.speed if keys[pygame.K_RIGHT] and self.rect.right < SCREEN_W: self.rect.x += self.speed

这里最容易被忽略的是shoot_cd:如果不做冷却限制,按住空格每帧都会生成子弹,一秒钟能射 60 发,弹幕直接失控。update接收 dt 而不是假设帧率恒定,是为了让电脑卡顿掉帧时游戏不会加速。rect由 Surface 尺寸自动生成,之后所有碰撞和位移都基于 rect,不要自己维护一个 x 再手动同步,否则改到一半会撞上坐标不同步的经典 bug。

子弹和敌机类沿用同一套模式,差别只在 update 的位移方向和回收条件。子弹向上移动,超出屏幕顶端就调用self.kill();敌机向下移动,超出屏幕底端也kill(),同时扣玩家血量。把这层对象模型搭好,主循环里就只剩调用、绘制、翻页三件事。

2.3 用 Group 管理对象而不是 list

新手从源码里最容易学到的坏习惯是用 list 收集子弹和敌机,然后手写双重 for 循环做碰撞,还要手动 remove。pygame.sprite.Group 在这个场景下能全面替代 list,初始化通常是这样:

all_sprites = pygame.sprite.Group() player_group = pygame.sprite.Group() bullet_group = pygame.sprite.Group() enemy_group = pygame.sprite.Group() player = Player(SCREEN_W // 2, SCREEN_H - 80) player_group.add(player) all_sprites.add(player)

之后每帧的统一动作只有四行:

all_sprites.update(dt) enemy_group.update(dt) all_sprites.draw(screen) pygame.display.flip()

all_sprites.draw(screen)会自动遍历组内每个 sprite 的 rect 和 image 完成绘制;update(dt)会依次调用每个 sprite 的 update 方法。对比 list 方案,Group 少了两类 bug:一是遍历时删除元素导致跳项,二是漏掉某个对象没更新。需要留意 Group 对同一个 sprite 重复 add 是幂等的,不会出现一个对象被绘制两次的问题。

管理方式碰撞 API删除方式典型坑
list手写双重循环list.remove遍历时删除会跳过元素
Groupgroupcollide / spritecollidekill()忘记区分隐藏与真正移除

表中列的差异是实际排错的重要依据:如果发现爆炸后的敌机偶尔还能再撞到玩家一次,先检查是不是用了 list 加 remove,并且 remove 时下标已经越界。

2.4 主循环:固定帧率下的事件与更新分离

主循环是这份源码的门面,写法基本定型,关键是三件事的次序:事件、更新、绘制。

clock = pygame.time.Clock() running = True while running: dt = clock.tick(60) for event in pygame.event.get(): if event.type == pygame.QUIT: running = False if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: running = False keys = pygame.key.get_pressed() player.update(dt) # 连续按键走 get_pressed,单击事件走事件队列 bullet_group.update(dt) enemy_group.update(dt) screen.fill((8, 8, 16)) all_sprites.draw(screen) pygame.display.flip()

clock.tick(60)的返回值是上一帧到本帧的毫秒数,稳定在 16 左右,传给各个 update 做时间补偿。get_pressed()拿到的是当前所有按键状态,适合左右移动这类持续操作;KEYDOWN事件适合判断单次按键,两者分工要清楚。pygame.event.get()每帧只能调一次,如果一帧里调用两回会把事件队列取空,导致按键失灵,这是改源码时常见的低级错误。把事件读取、逻辑更新、绘制三层分开,后面加暂停菜单和结束界面都不用重构。

3. 碰撞、弹幕与波次:源码里最容易改坏的三个地方

3.1 矩形碰撞与掩码碰撞怎么选

雷电战机的碰撞判定大多数基于矩形,也就是用 sprite.rect 做相交测试。pygame 的groupcollide一次能算出两个 Group 之间的全部碰撞对,复杂度是 O(n×m),在子弹和敌机各几十个的规模下完全够用,写法通常是:

hits = pygame.sprite.groupcollide(enemy_group, bullet_group, False, True) for enemy, hit_bullets in hits.items(): enemy.hp -= len(hit_bullets) if enemy.hp <= 0: enemy.kill() score_box[0] += 100

groupcollide的参数顺序有讲究:第一个参数是作为 dict 键的一侧,这里是被打的敌机;第二个是子弹侧;第三个dokill1为 False,表示敌机碰撞后不直接销毁;第四个dokill2为 True,表示子弹碰到敌机就消失。返回值把每个敌机映射到一组命中它的子弹,用len(hit_bullets)让高血量敌机能承受多发子弹而不是一碰就碎。score_box用长度 1 的 list 而不是直接对局部变量赋值,是为了绕过 Python 闭包赋值作用域的坑,这种写法在源码里很常见。

矩形碰撞的误判来自飞机外形的斜角和机翼,两架飞机视觉上没碰到,矩形却先相交。解决思路是按需换掩码碰撞,pygame.mask.from_surface(image)按像素生成透明掩码,碰撞更准确但耗时是矩形的几十倍。实战经验是:子弹打敌机用矩形就够,只有玩家本体和敌机的高速擦碰才值得换掩码,而且不要每帧对全部对象算掩码,先做矩形排除,再对候选对做掩码相交。

3.2 敌机波次用配置表而不是硬编码

很多源码把敌机生成写成enemy = Enemy(...)然后直接 add,数量少时无所谓,波次一多就变成一坨没法调的 if 块。常见做法是把一波的参数抽成字典,用定时器驱动生成:

WAVE_CONFIG = { 1: {"interval": 900, "count": 8, "hp": 1, "speed": 2, "pattern": "line"}, 2: {"interval": 650, "count": 14, "hp": 2, "speed": 3, "pattern": "zigzag"}, 3: {"interval": 450, "count": 20, "hp": 3, "speed": 4, "pattern": "arc"}, }

生成器每帧用now = pygame.time.get_ticks()取当前毫秒时间戳,interval 毫秒一到就生成一架,数量达到 count 后停掉当前波次:

if now - self.last_spawn >= cfg["interval"] and self.spawned < cfg["count"]: e = Enemy(cfg["hp"], cfg["speed"], random_x()) enemy_group.add(e) self.spawned += 1 self.last_spawn = now

interval 是关键参数:450 毫秒意味着每秒约 2.2 架,如果前一波残留没清完,玩家几乎没有喘息空间。调难度时优先动 interval 和 hp,不要把 speed 拉太高,敌机纵向移动一快,玩家对弹道路径的预期就全乱了。pattern 字段决定敌机的横向轨迹,line 是直线下压,zigzag 是横向正弦摆动,arc 是绕圈靠近,三种 pattern 交替编排,观感和反复直线下压完全不同。

3.3 对象清理:kill 与 Group 的连带关系

kill()不只是从当前 group 移除,它会导致该 sprite 从所有 group 里消失,这是 pygame 的全局行为。利用这一点,子弹碰到敌机时对子弹调用 kill,子弹会自动从 bullet_group 和 all_sprites 两个组里消失,不会出现绘制层还在显示已死亡对象的问题。要注意kill()remove()的区别:remove 只从指定 group 移除,kill 是全局的,混用会留下幽灵对象。

注意:对专用组调用empty()不会连带清空 all_sprites。爆机重置时如果只 empty 掉敌机组,all_sprites 里仍残留旧敌机,绘制层会报错误或画出半截残影。正确做法是遍历时对每个对象调用 kill,或者清空专用组后同步重建 all_sprites。

对象清理的第二个坑是漏掉 off-screen 判定。子弹飞出屏幕外如果不 kill,组里的对象数量会一直涨,帧率肉眼可见地掉。在 update 里加一行边界检查是最廉价的防御:

if self.rect.bottom < 0 or self.rect.top > SCREEN_H: self.kill()

最后是玩家爆机后的重置:清空敌机组和子弹组时不要逐个 remove,直接对 group 调用empty()。但记住empty()不会自动清 all_sprites,正确顺序是把专用组 empty 之后,再对 all_sprites 做一次过滤重建,或者干脆让每个对象在自己消失时主动 kill。调试这种残留问题时,每帧打印各 group 长度是最快定位手段。

4. 手感调参与爆炸反馈:让源码从能跑变成能玩

4.1 速度、射速、判定框的参数平衡

“能跑”和“能玩”的分界线是手感。雷电战机的操作手感由四个参数决定:玩家移动速度、子弹速度、敌机速度、射击冷却。给一套可复现的起步参数:

参数推荐区间过大表现过小表现
玩家移动速度5~8 px/帧微操失控,容易撞弹幕躲闪困难,手感黏滞
子弹速度10~14 px/帧同屏子弹激增打不中移动的敌机
子弹冷却150~250 ms火力溢出,难度失效输出拖沓,挫败感强
敌机速度1~4 px/帧无法瞄准波次毫无压力

以上是 800×600 窗口下的经验值。窗口放大到 1920×1080 后,建议玩家速度和子弹速度按比例上调 40% 左右,否则从地图左下角飞到右上角的耗时会被拉得很长。调参时一次只动一个变量,改完跑一整局记录手感,同时改两个参数会分不清变化来自谁。

4.2 把判定框缩到视觉体积的七成

纵版射击游戏都靠“判定比视觉小”来维持公平感。pygame 提供了现成方案,spritecollide 的第四个参数传入 collide_rect_ratio:

hit_player = pygame.sprite.spritecollide( player, enemy_group, False, pygame.sprite.collide_rect_ratio(0.7) )

collide_rect_ratio 接受 0.0 到 1.0 的小数,0.7 表示只用原矩形 70% 的中心区域做碰撞判定。这一行比手动 inflate 矩形再相撞省事得多,也是改手感时性价比最高的一处。机身是长条形的敌机建议调到 0.6,子弹判定建议保持 1.0 不缩,否则玩家会觉得子弹穿过了自己却没事,反馈失真。

4.3 粒子爆炸与屏幕震动的最简实现

爆炸反馈最便宜的实现是粒子。定义一个轻量 Particle 类,爆点生成 10~20 个粒子对象,每个粒子有随机方向、速度和寿命:

class Particle(pygame.sprite.Sprite): def __init__(self, x, y, color, life): super().__init__() self.image = pygame.Surface((6, 6)) self.image.fill(color) self.rect = self.image.get_rect(center=(x, y)) self.speed_x = random.randint(-4, 4) self.speed_y = random.randint(-4, 4) self.life = life def update(self, dt): self.life -= dt if self.life <= 0: self.kill() self.rect.x += self.speed_x self.rect.y += self.speed_y

粒子寿命建议在 200~500 毫秒之间:超过 800 毫秒会有明显拖影残骸感,低于 100 毫秒则什么都看不清。颜色统一用白色、橙黄、亮蓝三色系,雷电战机的爆炸观感基本就立住了。屏幕震动不一定要做,做的话别去动窗口位置,把背景和精灵层整体偏移 2~3 像素、持续 100 毫秒即可,幅度超过 5 像素会直接摧毁瞄准节奏。

5. 把源码扩展成完整游戏:存档、关卡与打包

5.1 用 JSON 存高分,避免每次重开清零

游戏结束后把最高分写入本地 JSON 文件,下次启动自动读取,这是完整版源码的标准收尾。代码很短:

import json import os SAVE_FILE = "thunder_save.json" def load_best_score(): if not os.path.exists(SAVE_FILE): return 0 with open(SAVE_FILE, "r", encoding="utf-8") as f: return json.load(f).get("best_score", 0) def save_best_score(score): best = max(score, load_best_score()) with open(SAVE_FILE, "w", encoding="utf-8") as f: json.dump({"best_score": best}, f)

不要用 pickle 存这类小数据,pickle 存在反序列化风险,课程设计和开源分发都不合适。JSON 文件里再加一个"version": 1字段,之后扩展存档字段就无需迁移逻辑,直接兼容旧档。

5.2 打包成单文件 exe 的分寸

源码要交给别人跑,pyinstaller 是标准解法:

pip install pyinstaller pyinstaller -F -w -n thunder_fighter main.py

-F合成为单文件,-w去掉控制台窗口,-n指定输出文件名。如果代码里用了图片和音频资源,需要把资源目录用--add-data一并打入,运行时用相对路径os.path.join(os.path.dirname(__file__), "assets")定位。

注意:写死绝对路径在别人电脑上必然报错。打包后先在一台没装 Python 的机器上跑一遍,pygame 资源文件漏包是这类项目最常见的翻车点。

5.3 一个值得带走的验证技巧:调试叠层

扩展完任何一块逻辑,都值得在主循环里挂一个调试叠层,实时打印帧率和各对象数量:

debug_text = font.render( f"FPS={int(clock.get_fps())} " f"bullets={len(bullet_group)} enemies={len(enemy_group)}", True, (255, 255, 0) ) screen.blit(debug_text, (8, 8))

观察三件事:FPS 是否稳定在 60;子弹数是否只在开火时增长、松手立即回落;爆机重置后敌机数是否归零。这三项全对,说明对象生命周期没有泄漏;任何一项异常,都指向碰撞清理或生成逻辑的 bug。把这段叠层留着,等全部功能收尾再删,比到处加 print 日志可靠得多。

本文还有配套的精品资源,点击获取

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

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

立即咨询