首页 / 资讯中心 / 文章详情

pygame飞机大战开发实战:从碰撞检测到性能优化的完整指南

pygame飞机大战开发实战:从碰撞检测到性能优化的完整指南 ★ FEATURED ARTICLE
简介Python飞机大战游戏源码基于Pygame库实现面向刚接触Python编程或对游戏开发感兴趣的初学者。项目围绕一个完整可玩的飞行射击小游戏展开演示了从窗口初始化、游戏循环搭建到飞机对象创建、子弹发射、碰撞检测、音效播放及分数统计的完整流程能够帮助理解面向对象设计和事件驱动模型在游戏中的实际运用。压缩包约1.66MB文件总数与类型清单暂未提供但核心源码及注释仍具备不错的参考价值。已有395人学习浏览适合作为入门级实战项目。读者可以从中获得可运行的Pygame游戏代码并学习如何组织Player、Enemy、Bullet等类结构理解pygame.Rect矩形区域碰撞判定和pygame.mixer音频加载等关键模块的用法也可以参考作者对游戏结束条件、道具、关卡等扩展功能的处理思路为后续开发更复杂的游戏打下基础。1. 用 pygame 写飞机大战别小看这个入门项目飞机大战几乎是每个 py 游戏开发者的第一个完整作品但真把它做下来你会发现一个能流畅跑、能计分、能出敌机波次、还能打包给别人玩的 Demo远不是「贴几张图、按方向键移动」那么简单。pygame 把窗口、事件、音频这些底层细节都封装好了可碰撞检测的误判、图片透明通道丢失、帧率忽高忽低这些问题恰恰卡住了大多数人。这篇笔记我按自己做的一个可玩版本拆开讲从主循环到碰撞、从资源管理到打包每一步给出能直接抄的代码和参数说明。适合刚学完 Python 基础、想用 pygame 做出第一个完整项目的读者也适合想把自己写的 Demo 整理成能发布的成果的人。2. 主循环与窗口框架先把帧率、事件、渲染三件事理顺2.1 pygame 初始化与窗口参数pygame 开发的第一行代码基本都是pygame.init()它会把底层依赖的 SDL 库全部初始化包括显示、音频、事件队列这些模块。我见过有人跳过这步直接建窗口结果在部分机器上报video system not initialized所以初始化这步不要省。窗口和游戏画布的尺寸我一般定成 480x720这个比例接近早期竖版射击游戏的手机屏幕飞机和子弹的视觉纵深感比较好。窗口大小通过pygame.display.set_mode((480, 720))设置第二个参数可以传pygame.RESIZABLE或pygame.FULLSCREEN但初版不建议开因为自适应逻辑会牵扯到碰撞坐标换算后面改起来很麻烦。import pygame import sys # 初始化 pygame 与音频子系统 pygame.init() pygame.mixer.init(frequency44100, size-16, channels2, buffer512) # 窗口与基本常量 SCREEN_WIDTH 480 SCREEN_HEIGHT 720 screen pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption(飞机大战 Demo) # 帧率控制器 clock pygame.time.Clock()这里的逻辑是pygame.mixer.init单独初始化音频参数frequency44100是 CD 音质标准size-16表示 16 位有符号采样channels2为立体声。buffer512是音频缓冲块大小这个值设小了声音会断续设大了播放有延迟感。Clock对象后面用来限制主循环帧率避免游戏在 144Hz 显示器上跑得比 60Hz 上快一倍。2.2 主循环结构与帧率锚定主循环是整个游戏的发动机。每帧要做三件事处理输入事件、更新游戏逻辑、绘制画面。很多人喜欢把逻辑写在事件循环外面但 py 本身是单线程如果你在for event in pygame.event.get()里做大量计算后面的渲染会被卡住。帧率控制是另一个关键。clock.tick(60)会把每帧耗时锚定在 16.67ms 左右这样游戏在不同刷新率的屏幕上速度一致。注意tick要放在循环末尾而不是开头因为它返回的是上一帧消耗的时间放在开头等于白白丢弃一次计时。running True while running: # 事件处理统一在这一步消费事件队列 for event in pygame.event.get(): if event.type pygame.QUIT: running False # 玩家操作读取 keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: player_rect.x - 5 if keys[pygame.K_RIGHT]: player_rect.x 5 # 渲染先铺背景再画飞机 screen.fill((20, 20, 40)) pygame.draw.rect(screen, (0, 255, 0), player_rect) pygame.display.flip() # 帧率锚定放在末尾 clock.tick(60) pygame.quit() sys.exit()这段代码我把玩家飞机的移动写在了keys判断里用的是key.get_pressed()而不是事件里的KEYDOWN。区别在于get_pressed()读取的是键盘实时状态按住方向键会持续移动KEYDOWN事件只在按下瞬间触发一次如果希望「按住不放持续移动」必须自己维护一个按键状态字典比较麻烦。移动步长5是按每帧 60 帧算出来的如果改帧率到 120飞机速度会翻倍这就是前面说的帧率逻辑耦合问题后面第 5 章的避坑部分会细说。2.3 玩家飞机的绘制与坐标边界用pygame.draw.rect画临时占位矩形是快速验证逻辑的好办法但正式版本里玩家飞机一般是 PNG 贴图。这里有个容易踩的坐标问题set_mode的坐标系原点在窗口左上角x 向右增大y 向下增大。所以飞机往上飞其实是 y 坐标减小很多人套用数学课的坐标系习惯把上下方向写反。我一般先把玩家飞机定义成一个Rect对象Rect的好处是自带x、y、centerx、top这些属性碰撞检测和居中都非常方便。边界限制用clamp方法它能直接把矩形限制在另一个矩形范围内比自己写一堆if判断干净得多。# 玩家飞机矩形初始位置在底部居中 player_rect pygame.Rect(0, 0, 48, 64) player_rect.centerx SCREEN_WIDTH // 2 player_rect.bottom SCREEN_HEIGHT - 30 # 键盘实时响应 keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: player_rect.x - 5 if keys[pygame.K_RIGHT]: player_rect.x 5 if keys[pygame.K_UP]: player_rect.y - 4 if keys[pygame.K_DOWN]: player_rect.y 4 # 边界限制把矩形拉回窗口内 screen_rect screen.get_rect() player_rect.clamp_ip(screen_rect)clamp_ip里的ip表示 in-place 原地修改直接改player_rect本身不需要接收返回值。如果你用player_rect player_rect.clamp(screen_rect)也是等价的但新手容易忘掉赋值导致边界无效。上下移动步长我设了4比左右慢一点原因是竖版射击游戏里左右闪避是主要操作上下主要用于微调高度手感上放缓一点更舒服。3. 子弹、敌机与碰撞检测让游戏真正“打”起来3.1 子弹发射与对象管理子弹系统最简单的实现是维护一个list每次按空格往列表里追加一个Rect每帧把这些Rect的 y 坐标往上减。但直接用 list 管理有个坑你需要在遍历时手动把超出屏幕的子弹移除否则列表无限膨胀帧率会肉眼可见地崩塌。bullets [] BULLET_SPEED -10 # y 方向负值表示向上 # 在射击按键事件中追加子弹 for event in pygame.event.get(): if event.type pygame.KEYDOWN and event.key pygame.K_SPACE: bullet_rect pygame.Rect(0, 0, 6, 16) bullet_rect.centerx player_rect.centerx bullet_rect.bottom player_rect.top bullets.append(bullet_rect) # 每帧更新移动子弹并清理出界的对象 for bullet in bullets[:]: bullet.y BULLET_SPEED if bullet.bottom 0: bullets.remove(bullet) for bullet in bullets: pygame.draw.rect(screen, (255, 255, 0), bullet)这里有个很关键的写法遍历bullets[:]而不是bullets。冒号切片会生成一个新的列表副本你可以在遍历副本的同时对原列表做remove不会跳过元素。如果直接用原列表遍历并删除删除动作会让迭代器错位结果就是隔一个子弹才被检查到甚至可能IndexError。BULLET_SPEED为什么设为负值因为坐标系 y 轴向下负值代表向上移动。把速度定义成一个常量后面要调整弹道手感时只需要改这一处。子弹的初始位置是bullet_rect.bottom player_rect.top意思是子弹底部贴着飞机顶部出现这样视觉效果上子弹是从机头发射的不会突然出现在飞机中间。3.2 敌机生成与定时器机制敌机生成如果放在每帧里做会出现一秒钟生成几千个敌机的灾难。常见做法有两种一种是自己维护帧计数器if frame_count % 60 0表示每 60 帧生成一次另一种是用 pygame 的set_timer把自定义事件挂到事件队列里。我更推荐set_timer因为它是事件驱动写起来和KEYDOWN的处理方式统一。CREATE_ENEMY pygame.USEREVENT 1 pygame.time.set_timer(CREATE_ENEMY, 1000) # 每 1000ms 生成一次 enemies [] def create_enemy(): width, height 40, 30 enemy_rect pygame.Rect(0, 0, width, height) enemy_rect.x random.randint(0, SCREEN_WIDTH - width) enemy_rect.y -height # 从屏幕顶外出现 return enemy_rect for event in pygame.event.get(): if event.type CREATE_ENEMY: enemies.append(create_enemy())pygame.USEREVENT 1是给自定义事件分配一个不冲突的事件编号。set_timer的第二个参数是毫秒间隔1000就是一秒一个敌机。这里如果用random.randint(0, SCREEN_WIDTH)敌机可能贴着窗口右边界生成一半看不到所以上限要减去敌机宽度。敌机移动就很简单了每帧让 y 坐标往下加。不同敌机如果统一速度会显得很机械我一般会给每架敌机绑定一个随机速度属性enemies_data [] # 每项是一个 [rect, speed] 列表 # 替代方案用自定义类或者字典但列表够用 for data in enemies_data[:]: data[0].y data[1] if data[0].top SCREEN_HEIGHT: enemies_data.remove(data)用[rect, speed]这种裸列表结构在小 Demo 里没问题但代码一多容易分不清索引含义。更可维护的做法是定义一个Enemy类把速度和矩形封装进去。如果你后面想加敌机血量、掉宝这些功能类的优势会非常明显所以在初版就建议用类而不是嵌套列表。3.3 碰撞检测的方案选型pygame 提供了好几种碰撞检测方式Rect.colliderect判断两个矩形是否相交Sprite家族的groupcollide可以一次性检测两组对象间的碰撞。对于飞机大战来说子弹和敌机的碰撞用groupcollide是最省事的它会返回碰撞对并且支持自动销毁。但直接用groupcollide前需要把对象转成Sprite子类。如果你前面偷懒用Rect存敌人和子弹这里就得返工。我一般会在项目一开始就用pygame.sprite.Sprite定义玩家、子弹、敌机三个类不是因为它有多高级而是因为Sprite自带kill()方法配合pygame.sprite.Group后清理对象非常干净。class Bullet(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image pygame.Surface((6, 16)) self.image.fill((255, 255, 0)) self.rect self.image.get_rect() self.rect.bottom y self.rect.centerx x def update(self): self.rect.y BULLET_SPEED if self.rect.bottom 0: self.kill() class Enemy(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image pygame.Surface((40, 30)) self.image.fill((200, 50, 50)) self.rect self.image.get_rect() self.rect.x random.randint(0, SCREEN_WIDTH - self.rect.width) self.rect.y -self.rect.height self.speed random.randint(2, 6) def update(self): self.rect.y self.speed if self.rect.top SCREEN_HEIGHT: self.kill()update方法里的kill()会把自己从所属的 Group 中移除不需要手动遍历删除。判碰撞的代码则非常简单bullets_group pygame.sprite.Group() enemies_group pygame.sprite.Group() # 在游戏循环里调用各组的 update bullets_group.update() enemies_group.update() # 碰撞检测第一个返回值是子弹命中集合第二个是敌机集合 hits pygame.sprite.groupcollide(bullets_group, enemies_group, True, True) score len(hits) * 10groupcollide的参数True, True分别表示「子弹碰撞后销毁」和「敌机碰撞后销毁」。返回值hits是一个字典键是子弹对象值是被撞到的敌机列表。同一个子弹理论上可能撞到多架敌机但因为我们设置了子弹销毁所以实战中字典的长度就是击杀数。用Sprite和Group的另一个好处是批量绘制pygame.sprite.Group.draw(screen)会把组内所有对象的image按各自的rect坐标画上去一个循环全搞定。前面用列表管理时每画一个对象都要手动写一次draw这个差距在敌机数量多的时候特别明显。4. 资源加载与运行优化图片、音效和性能的综合处理4.1 图片资源加载与透明通道飞机大战的视觉素材基本是 PNG 格式因为 PNG 支持透明背景。pygame 加载图片的标准做法是pygame.image.load(player.png)但直接加载出来的 Surface 在绘制时如果带了透明通道必须调用convert_alpha()否则会出现黑色背景块。convert()和convert_alpha()的区别在于前者转换后丢弃透明信息适合不透明的 JPG 图片后者保留 alpha 通道适合 PNG 贴图。player_img pygame.image.load(assets/player.png).convert_alpha() enemy_img pygame.image.load(assets/enemy.png).convert_alpha() bullet_img pygame.image.load(assets/bullet.png).convert_alpha() # 如果图片尺寸和逻辑尺寸不一致统一缩放 player_img pygame.transform.scale(player_img, (48, 64))convert_alpha()会把图片的像素格式转换成与当前显示模式匹配的格式这样绘制时不需要每次做像素格式转换性能好很多。缩放用pygame.transform.scale它会做简单的拉伸缩放如果原图比例和目标的 48x64 不一致图片会变形。如果不想变形可以用smoothscale配合比例计算或者让美术直接按尺寸出图。实际开发中我习惯把资源加载单独放一个函数它会变成项目的「资源清单」缺哪张图、哪个路径写错看报错比在游戏循环里排查快得多。4.2 音效与混音参数音效是飞机大战的加分项。pygame 的mixer.Sound适合加载短音效比如射击声、爆炸声而背景音乐会用mixer.music模块两者接口不同。shoot_sound pygame.mixer.Sound(assets/shoot.wav) explode_sound pygame.mixer.Sound(assets/explode.wav) shoot_sound.set_volume(0.3) explode_sound.set_volume(0.5) # 播放音效 shoot_sound.play()音效文件格式上pygame 对 WAV 支持最好而 MP3 在一些版本上需要额外的解码器加载失败的概率高。如果只有 MP3先用工具转成 WAV44.1kHz、16 位、单声道就够立体声的 WAV 文件体积大且播放时没有明显提升。set_volume的参数范围是 0.0 到 1.0射击声频率高音量调低才不会把爆炸声盖住。有个隐藏坑如果pygame.mixer.init()失败mixer.Sound会直接报错。在无音频设备的机器上打包运行时需要做异常兜底try: pygame.mixer.init() shoot_sound pygame.mixer.Sound(assets/shoot.wav) except pygame.error: shoot_sound None # 播放前判空 if shoot_sound: shoot_sound.play()4.3 对象池与帧率优化Python 的垃圾回收机制是引用计数加分代回收虽然对小对象很友好但在游戏里高频创建和销毁子弹、敌机会产生大量内存碎片帧率会持续波动。一个有效的优化方案是对象池预先创建一批子弹对象不活跃时隐藏需要发射时从池里拿一个并重置位置而不是新建再销毁。class BulletPool: def __init__(self, size100): self.pool [] self.active [] for _ in range(size): bullet Bullet(0, 0) bullet.kill() # 先把它们都标记为死亡 self.pool.append(bullet) def acquire(self, x, y): if not self.pool: return None bullet self.pool.pop() bullet.rect.centerx x bullet.rect.bottom y bullet.alive() # 恢复存活状态 self.active.append(bullet) return bullet这段代码用bullet.kill()和bullet.alive()来切换 Sprite 的存活状态。被kill()掉的对象不会在 Group 中被绘制和更新但对象本身还留在内存池里下次acquire时重新激活。子弹的射击声、移动逻辑还是一样写但从内存角度看整个游戏过程中子弹对象数量是固定的不会因为持续射击导致对象数量无限增长。如果你的机型性能一般还应该限制同屏敌机上限。比如生成逻辑加一行判断if len(enemies_group) 30:超过 30 架就不再生成。我见过很多飞机大战在敌机密集时变成幻灯片就是因为漏了这一步。4.4 游戏状态开始、运行、结束三段式飞机大战不能一打开就疯狂打总要有开始界面和 Game Over 界面。用状态机管理最简单定义MENU、PLAYING、GAMEOVER三个状态变量主循环里根据状态分支执行不同的逻辑。MENU, PLAYING, GAMEOVER 0, 1, 2 game_state MENU while running: if game_state MENU: # 显示标题和开始提示 screen.blit(start_bg, (0, 0)) if keys[pygame.K_RETURN]: game_state PLAYING reset_game() elif game_state PLAYING: # 正常游戏的更新和绘制 bullets_group.update() enemies_group.update() if pygame.sprite.spritecollide(player, enemies_group, True): game_state GAMEOVER elif game_state GAMEOVER: # 显示得分与重新开始按钮 screen.blit(gameover_bg, (0, 0)) if keys[pygame.K_r]: game_state PLAYING reset_game() pygame.display.flip() clock.tick(60)状态机的核心是每个状态只处理自己的事件和渲染状态切换时统一调用reset_game()清空所有 Group 和得分。很多人会在代码里写一堆if game_over:的散装判断状态一多就乱。用状态机的好处是每个分支入口和出口清晰后面加「暂停」状态也只是往枚举里加一个值。5. 避坑指南pygame 飞机大战的五个常见翻车点5.1 图片显示成黑块或白块现象加载 PNG 贴图后图片周围有一圈黑色背景完全不透明。原因PNG 的透明通道没有被正确处理。直接pygame.image.load()返回的 Surface 保留了原始像素格式在绘制时透明信息会被忽略或错误处理。解决加载后统一调用.convert_alpha()把图片转为与当前显示模式匹配的格式透明通道才生效。如果图片本身是 JPG 格式JPG 不支持透明换 PNG 资源才是正解。5.2 子弹一多就卡顿掉帧现象连续按住空格射击几秒后帧率明显下降游戏变得粘滞。原因每帧创建新子弹对象、遍历全量列表并删除出界对象列表频繁增删导致 GC 压力大而且遍历时被删对象的内存释放有延迟。解决改用pygame.sprite.Group加Sprite.kill()机制或者做对象池。同时把子弹列表的遍历改成对[:]副本操作避免删除元素时迭代器错位。5.3 游戏在不同电脑上速度不一样现象在 60Hz 显示器上正常换到 144Hz 显示器后飞机和子弹速度快了一倍多。原因逻辑更新直接在每帧里执行clock.tick(60)只限制了帧率上限但游戏逻辑本身和帧率没有解耦。解决两种思路。一是在循环开头计算dt时间增量把移动距离改为speed * dt。二是保持固定 60 帧并把tick放在循环最前面让超出能力的机器主动掉帧而不是加速。我一般用第二种方法调游戏手感简单直观。5.4 音效播放异常或程序启动时卡死现象双击 exe 后程序卡在启动阶段或者音效播放时嘶哑断续。原因音频设备没有正确初始化或者音频格式不被当前平台/版本支持。某些机器上pygame.mixer.init()默认参数会请求一个设备不支持的采样频率导致初始化阻塞或报错。解决给mixer.init显式传参用 44100Hz、16 位、双声道这种最通用的组合。加载音效前先确认文件是 WAV 格式MP3 在打包环境容易缺解码器。初始化加try/except失败时把音效对象置为None程序照常运行。5.5 敌机碰撞感觉「不对」明明没碰到却被判定死亡现象敌机看起来离玩家飞机还有一小段距离游戏就判定碰撞了玩家觉得被「空气墙」击杀。原因碰撞检测用的是Rect矩形而飞机 PNG 贴图通常是矩形的但画面里的飞机主体往往只占矩形中间一部分机翼边缘是透明像素。矩形相交的判定比视觉主体相交更宽。解决缩小碰撞矩形用rect.inflate(-offset_x, -offset_y)把碰撞范围向内收缩正向是放大。也可以手动重设hitbox pygame.Rect(x, y, w, h)视觉上飞机多大碰撞盒就设多大不透明像素占比高的区域多留一点余量。6. 把 Demo 变成完整作品打包、调试与手感校准技巧游戏逻辑跑通后下一步是把它变成一个能双击运行的成品。打包用 PyInstaller 是最常见的方案但直接pyinstaller main.py打出来的 exe 会踩资源路径的坑。因为打包后程序运行时的工作目录不是脚本所在目录而是临时解压目录直接加载assets/player.png会报文件不存在。我一般用sys._MEIPASS判断是源码模式还是打包模式import sys import os def resource_path(relative_path): 兼容源码运行和打包后的资源路径解析 base_path getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base_path, relative_path)打包命令我固定加--noconsole这样运行时不弹黑色控制台窗口--onefile会把所有依赖打进单个 exe缺点是启动稍慢。注意命令行窗口里要先cd到项目目录再执行 PyInstaller否则它会找不到你的入口文件。调试手感是最后一道工序。我会在窗口标题栏实时显示帧率和对象数量这对定位性能问题非常有用pygame.display.set_caption(f飞机大战 | FPS: {clock.get_fps():.0f} | 敌机: {len(enemies_group)} | 子弹: {len(bullets_group)})clock.get_fps()返回的是最近一秒的平均帧率显示它而不是自己算帧间隔是因为它已经做了平滑处理。如果 FPS 长期低于 55就该回头检查第 4 章说的对象池和同屏上限了。初始版本完成后我通常还会做一个深呼吸测试按住射击键不放持续一分钟同时观察标题栏的 FPS 和对象数。这个动作能暴露出内存泄漏、对象清理不彻底、音效重复播放无上限等问题。从那以后我每次做完一个玩法原型都会强制自己先跑一遍「持续施压测试」把性能问题解决在功能扩展之前。这次拆飞机大战项目的所有经验和这几段核心代码你直接照搬就能少踩很多弯路希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站