Pygame飞机大战完整指南:循环、碰撞与打包全解析
简介这是一份基于苹果移动操作系统平台的飞机大战完整项目代码面向想入门游戏开发或学习SpriteKit与Objective-C的开发者也适合作为课程设计、毕业设计的参考原型。项目以经典打飞机玩法为蓝本完整涵盖游戏引擎调度、飞机/子弹/敌机对象管理、碰撞检测、分数系统与游戏状态管理——从开始界面、游戏进行到暂停与结束都提供了清晰实现同时加入音效与素材加载处理并配有较详细的注释便于对照理解每个模块的实现细节。资源共39个文件包含Objective-C源码、SpriteKit场景类、配置文件、图片素材、音频音效、界面布局文件等压缩包仅1.28MB目录结构规整适合快速导入Xcode开发工具运行。目前已有515人学习下载对希望通过实际案例掌握苹果移动端游戏开发流程、学习对象设计与事件处理的读者来说是一份轻量而完整的实践参考。1. 飞机大战完整版代码一个窗口背后藏着的三件事“飞机大战完整版代码”这个检索词在搜索引擎里能翻出一大堆结果但真正把代码下下来照着跑的人一半卡在图片路径不对另一半卡在碰撞检测一次扣两次血。这说明大多数版本只是“能跑”不是“能完整跑”。一个完整的飞机大战至少要同时解决主循环、主角控制、多机种敌机、子弹、道具、计分、音效、背景滚动和胜负判定缺任意一块游戏体验都会在某一分钟突然垮掉。适合拿这个题目练手的人是想搞懂游戏循环怎么写、碰撞怎么判、资源怎么管的从业者。读完这一篇你不仅能照抄而且能知道每个参数为什么是这么设的踩过的坑我也一并标出来。2. 用 Pygame 把飞机大战跑起来窗口、循环与主角的 30 行骨架2.1 为什么是 Pygame一个窗口背后的完整体系做飞机大战这种 2D 俯冲射击游戏框架选型基本没有悬念Pygame。它不是引擎不替你管理场景和组件但它把窗口创建、事件分发、图片加载、声音播放、矩形碰撞这些底层操作全部封装成了足够简单的 API开发者只需要关注“循环里每一步干什么”。这对理解游戏本质反而是好事因为无论用什么引擎游戏核心都绕不开那几件事读输入、更新状态、画画面、控制节奏。Pygame 的坐标体系也需要先立起来。屏幕左上角是 (0, 0)x 轴向右y 轴向下。飞机往屏幕下方飞y 值就在变大。这个坐标系和数学课上习惯的直角坐标不一样很多新手在这里翻车想让敌机从上方出现结果设置成 y 0 的负值图片直接跑到屏幕外面去了。我一般会先在纸上画一个 480x700 的矩形框把主角出发位置、敌机出生位置全部标上去再写代码这样视觉上不会乱。另一个必须一开始就理解的机制是事件循环。Pygame 程序不是从上往下执行完就退出的而是靠一个 while 循环不断重复“处理事件、更新逻辑、绘制画面”三个步骤。每执行一轮就是一帧pygame.time.Clock().tick(60)把帧率限制在每秒 60 帧。你按一下方向键程序在下一帧读键盘状态再下一帧把飞机位置挪动几像素连续看起来就是平滑移动。2.2 游戏循环与主角让飞机动起来的两件事先把最小可运行版本搭出来。这段代码只有窗口、主角、方向键移动和边界限制不涉及敌人和子弹但它是一切的基础。import pygame import sys pygame.init() screen pygame.display.set_mode((480, 700)) pygame.display.set_caption(飞机大战) # 加载图片保证图片和脚本在同一目录 plane_img pygame.image.load(plane.png) plane_rect plane_img.get_rect() plane_rect.midbottom (240, 650) clock pygame.time.Clock() while True: # 1. 处理事件 for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() # 2. 读取键盘状态并更新主角位置 keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: plane_rect.x - 5 if keys[pygame.K_RIGHT]: plane_rect.x 5 # 限制主角不出左右边界 if plane_rect.left 0: plane_rect.left 0 if plane_rect.right 480: plane_rect.right 480 # 3. 绘制画面 screen.fill((0, 0, 0)) screen.blit(plane_img, plane_rect) pygame.display.flip() clock.tick(60)代码里有一个容易忽视的设计pygame.key.get_pressed()读的是键盘的实时状态而不是单个按键事件。这意味着按住左键不放飞机就会持续向左移动每一次循环里plane_rect.x - 5都会执行。如果用KEYDOWN事件你只能得到按下那一瞬间的反馈松手也不会自动停住体验会差很多。screen.fill((0, 0, 0))的作用是清空上一帧画面不填的话上一帧的飞机图像会残留在屏幕上形成拖影。blit是“画一块图像到窗口上”的操作参数是图片和位置矩形。最后pygame.display.flip()才真正把画好的内容推送到显示设备这一步漏掉窗口会一直黑屏。clock.tick(60)放在循环末尾作用是每帧睡眠到固定时长保证所有机器上运行速度大致一致。不同机器如果没这行飞机的移动速度会跟着 CPU 性能走。2.3 图片加载与碰撞检测先给游戏立好边界主角的plane_rect来自plane_img.get_rect()这个矩形对象是整个系统里最重要的东西。它不仅有 x、y、宽、高等位置属性还承担碰撞检测的职责。Pygame 内置了rect.colliderect(other_rect)、rect.collidepoint((x, y))等系列方法后面子弹打敌机、敌机撞主角全都是靠矩形相交判断的。图片本身的尺寸也要注意。我习惯把主角图片控制在 80x80 以内敌机 60x60 或 60x80这样在小窗口上视觉不会失衡。plane_rect.midbottom (240, 650)是让飞机的底部中心点定位在窗口底部偏上的位置这样飞机看起来是“站在”场景里而不是贴在屏幕顶端。这个阶段还要养成一个习惯图片文件路径不要写死成plane.png后面会讲到路径问题但至少一开始你要让图片和脚本放在同一个目录。运行时如果报pygame.error: Unable to open file基本就是路径对不上或文件名打错。先用相对路径跑通后面再统一处理。3. 敌机、子弹与道具让“完整版”工作量翻倍的三件事3.1 敌机不同机种、不同移动方式怎么在一套代码里兼容敌机系统是“完整版”和“demo”的分水岭。demo 只会生成一种小飞机匀速向下飞完整版至少要有两到三种机型重量、速度、血量、分值都不一样。我一般用类继承来解决这个问题写一个Enemy基类把坐标生成、向下移动、出屏回收这些公共逻辑放在里面具体机型通过子类或配置参数区分。import random import pygame class Enemy(pygame.sprite.Sprite): def __init__(self, image_path, speed, hp, score_value): super().__init__() self.image pygame.image.load(image_path) self.rect self.image.get_rect() self.speed speed self.hp hp self.max_hp hp self.score_value score_value self.rect.x random.randint(0, 480 - self.rect.width) self.rect.y -self.rect.height def update(self): self.rect.y self.speed if self.rect.top 700: self.kill()hp用于记录血量max_hp用于后面画血条或判断血量比例。self.rect.y -self.rect.height是让敌机出现在屏幕正上方之外随着 update 里 y 不断增加它从顶部“飞”进画面。如果敌机在窗口内出生然后往下飞玩家会感觉画面很突兀像是在天上凭空刷怪。random.randint控制出生横坐标保证每架敌机的横向位置随机。self.kill()是 Pygame 精灵组的特性。当敌机飞出屏幕底部时调用它把自身从精灵组中移除否则列表越积越长内存开销越来越大帧率会逐渐下降。这个回收机制是精灵组比普通 list 管理对象方便的核心原因。一个常见的配置参数表可以这么设供你参考机种图片尺寸速度血量分值小型机50x504~61100中型机70x702~33300大型机100x1001~28800你还可以让大型机在血量低于一半时分裂成两架小型机但那是进阶玩法先不展开。这里给两个建议其一速度不要用固定整数用random.randint(4, 6)敌人速度略微波动会让玩家觉得“每局手感不一样”其二敌机刷新间隔不要写死后面测难度曲线时用得分去动态调整。3.2 子弹发射节奏、弹速与屏幕边界回收子弹系统的核心参数有三个发射间隔、子弹速度、伤害值。发射间隔决定了玩家按一下空格能射出多少发速度决定了弹道看起来是“射出去”还是“飘出去”。我常用的值是间隔 150~200 毫秒速度 12~15 像素每帧60 帧下约 720~900 像素每秒伤害 1。这个配置在小窗口上弹道清晰也不会快到无法躲避。class Bullet(pygame.sprite.Sprite): def __init__(self, x, y, speed15, damage1): super().__init__() self.image pygame.image.load(bullet.png) self.rect self.image.get_rect() self.rect.centerx x self.rect.bottom y self.speed speed self.damage damage def update(self): self.rect.y - self.speed if self.rect.bottom 0: self.kill()子弹的出生位置self.rect.centerx x、self.rect.bottom y是两个容易被忽略的细节。如果直接用rect.x和rect.y子弹会从飞机的左上角冒出来看起来像是从飞机屁股后发射。而用centerx和bottom子弹正好从飞机头部中央射出视觉上才正确。这里y是传进来的飞机头部 y 坐标所以bottom y表示子弹底部贴着飞机头部位置。子弹回收条件和敌机相反敌机飞出底部要清除子弹飞出顶部要清除。不回收的子弹会无限向上飘几轮射击后整个列表有上千个物体每帧都要做碰撞检测帧率崩盘是必然的。发射节奏由谁控制我一般是记录一个last_shot_time每帧判断当前时间减上次发射时间是否大于间隔current_time pygame.time.get_ticks() if current_time - last_shot_time 150 and keys[pygame.K_SPACE]: bullet Bullet(plane_rect.centerx, plane_rect.top) bullets.add(bullet) last_shot_time current_timepygame.time.get_ticks()返回程序启动后的毫秒数用它来做时间间隔判断比用帧数可靠。帧数受帧率波动影响而 get_ticks 是真实时间不会因机器性能不同而改变。这个思路在无敌帧、连击判定、道具持续时间里都会反复用到。3.3 道具增强火力与恢复生命如何不破坏难度曲线道具系统是完整版里最需要克制设计的部分。常见道具三类火力增强双发或三发、回血、护盾。道具掉落方式有两种敌机被击毁后概率掉落或每隔固定时间在屏幕顶部随机生成。我推荐前一种因为道具掉落与玩家击杀行为挂钩玩家会觉得“是我自己打出来的”而不是白捡的。掉落概率控制在 15%~20%太高了玩家每局都满火力敌机难度形同虚设太低则道具系统形同虚设。class PowerUp(pygame.sprite.Sprite): def __init__(self, x, y, power_type): super().__init__() self.power_type power_type # double, heal, shield self.image pygame.image.load(fpower_{power_type}.png) self.rect self.image.get_rect() self.rect.center (x, y) self.speed 3 def update(self): self.rect.y self.speed if self.rect.top 700: self.kill()火力增强的效果不是直接把子弹速度翻倍而是改变主角的发射方式。最简单的实现是给主角加一个fire_level属性发射时根据等级拆成多组子弹坐标def fire(plane_rect, fire_level, bullets): if fire_level 1: bullets.add(Bullet(plane_rect.centerx, plane_rect.top)) elif fire_level 2: bullets.add(Bullet(plane_rect.centerx - 15, plane_rect.top)) bullets.add(Bullet(plane_rect.centerx 15, plane_rect.top)) elif fire_level 3: bullets.add(Bullet(plane_rect.centerx, plane_rect.top)) bullets.add(Bullet(plane_rect.centerx - 25, plane_rect.top, speed12)) bullets.add(Bullet(plane_rect.centerx 25, plane_rect.top, speed12))注意speed12是关键字参数这里中间子弹速度 15、两侧子弹速度 12形成一束弹道的层次感。这个细节不调也没什么但调了之后弹幕的视觉密度会有明显提升。火力等级要有上限一般三级封顶且被撞一次后降一级。这样玩家既享受成长感又不会无敌到失去挑战性。道具的持续时间和拾取碰撞也要单独处理。回血和护盾是一次性生效直接改主角属性火力增强如果是定时持续则需要记录power_end_time每帧检查当前时间是否超过power_end_time超过了就退回一级。不要用“开启后就这么强下去”的永久机制那是 demo 的写法会毁掉难度曲线。4. 计分、音效与背景滚动三件让游戏“活过来”的事4.1 计分与连击分数不是加法是难度曲线的开关很多代码里的计分就是一句话score 100。但完整版里分数至少承担两个责任给玩家即时反馈以及驱动难度变化。击毁不同机种给不同分值、连续击毁不加倍这个基础逻辑要先写对。具体分数的回传方式常见做法是让敌机在死亡时返回自己的score_value主循环里统一累加不要在每个子类里直接改全局变量。比较进阶的计分设计是连击系统。2 秒内连续击毁敌机击毁数递增每击毁一架将连击数乘以对应倍率累加到分数里。这个系统在飞机大战里不算必需但它能把玩家的操作节奏从“乱射”逼成“精准射击”游戏可玩性立刻上一个台阶。实现连击只需要一个时间戳变量每次击毁敌机时用当前时间减去上次击毁时间如果间隔小于 1500 毫秒连击数加 1否则连击数重置为 1。这个时间戳判断本身不复杂但很值得做因为它是少数用几行代码就能提升“游戏感”的设计。同时你要明确分数在游戏平衡里的地位分数是难度曲线的开关。后面的敌机刷新间隔、敌机速度提升都应该从分数读取而不是从关卡数读取。关卡数需要额外维护一个变量而且玩家死亡后重开会比较尴尬分数本来就是递增的拿来当难度计最自然。4.2 背景滚动伪 3D 效果的引擎飞机大战的主体是“飞机在空中飞”但如果不做背景玩家看到的是一片黑色飞行的感觉会大打折扣。最常见的做法是让一张长条形的星空或云层图片从屏幕底部匀速向下滚动循环播放制造出“飞机在向上飞行”的错觉。这个效果只需要一张图片和两个 y 坐标变量bg_img pygame.image.load(background.png) # 尺寸 480x700 bg_y1 0 bg_y2 -700 bg_speed 2 def update_bg(screen): global bg_y1, bg_y2 bg_y1 bg_speed bg_y2 bg_speed if bg_y1 700: bg_y1 -700 if bg_y2 700: bg_y2 -700 screen.blit(bg_img, (0, bg_y1)) screen.blit(bg_img, (0, bg_y2))原理很简单两张完全相同的背景图首尾相接第二张放在第一张正上方一个屏幕高度的地方。两张图同时向下移动第一张完全滑出屏幕时第二张正好占据整个屏幕此时把第一张的坐标重置到屏幕上方循环继续。把bg_speed从 2 逐步提高到 5、8速度越快给玩家的心理暗示是“飞机在加速推进”这个变量也可以加入分数驱动的难度曲线里。背景速度的边界要控制好。超过 10 之后星空场景会变得让人眼花而且玩家视觉容易疲劳。我一般把背景速度上限设为 8比主角移动速度慢一截保证前景运动更突出。背景图片分辨率最好和窗口尺寸一致这样省去缩放处理也不会出现边缘间隙。4.3 音效与资源加载让游戏有手感的关键习惯音效是“游戏手感”的重要组成部分。子弹发射的声音、敌机爆炸的声音、被撞时的预警音都直接影响玩家对“命中”的判断。Pygame 的音效模块叫pygame.mixer用之前要先初始化然后加载声音文件播放时机放在对应事件里。这里有一个常见错误在初始化 mixer 前加载声音会直接抛pygame.error。另外有些机器上pygame.mixer.init()会失败比如音频设备被占用所以初始化后要检查返回值import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) ASSET_DIR os.path.join(BASE_DIR, assets) def load_sound(name): path os.path.join(ASSET_DIR, name) return pygame.mixer.Sound(path) pygame.mixer.init() try: shoot_sound load_sound(shoot.wav) explosion_sound load_sound(explosion.wav) except pygame.error as e: print(f音效加载失败: {e})这里我用了绝对路径而不是相对路径因为相对路径依赖“当前工作目录”在命令行里直接执行脚本和在 IDE 里运行脚本工作目录经常不一样。os.path.dirname(os.path.abspath(__file__))得到的是脚本所在目录再拼接assets子目录这样不管从哪个位置启动程序都能找到音效文件。这个习惯在打包发布时尤其重要。音效文件建议统一用 WAV 格式因为 Pygame 对 WAV 的处理最稳定延迟最低。MP3 在某些版本上有初始化问题。音量也要控制Pygame 的Sound默认音量是 1.0也就是最大音量多个音效叠加时容易刺耳。我一般把射击音设为 0.2爆炸音 0.4背景音乐 0.5具体用set_volume()设置。这个参数可以根据个人听感微调但先把这个习惯建立起来避免上线后玩家被突然巨响吓一跳。5. 飞机大战最容易翻车的 5 个坑从帧率到列表遍历5.1 帧率忽高忽低飞机“飘”【现象】飞机和敌机移动不匀速画面一顿一顿的像在动画里抽帧。 【原因】最常见的是clock.tick(60)被随意移动了位置或者循环里做了大量图片加载操作。另一个可能是在while循环里频繁调用pygame.image.load()每帧都重新从磁盘读图片拖垮循环速度。 【解决】clock.tick(60)固定在循环末尾执行。所有图片和音效在循环开始前一次性加载完毕循环里只做 blit 和坐标运算。如果画面还是卡检查是否有大尺寸图片的缩放操作放在循环内把pygame.transform.scale的结果提前存起来。5.2 一次碰撞反复扣血【现象】敌机撞上主角一次血量瞬间扣了两三次甚至直接游戏结束。 【原因】碰撞检测写在“敌机与主角矩形相交”这个条件里但没有移除碰撞对象或设置冷却时间。如果同一帧内多个敌机同时与主角矩形相交每个敌机都会触发扣血逻辑而如果主循环里敌机在下一次碰撞检测前没有被清除同一架敌机会连续触发多次。 【解决】给主角加一个无敌帧机制被撞后设置invincible_until pygame.time.get_ticks() 1500每帧做碰撞检测前先判断当前时间是否大于invincible_until。另外被撞的敌机立即调用kill()把这些对象从精灵组移除从根源上避免同一帧重复判定。5.3 遍历 list 时删除元素导致漏检【现象】子弹明明打中了敌机但敌机没有扣血或者某些子弹凭空消失。 【原因】常见写法是for e in enemies:遍历列表然后在循环里判断碰撞后调用enemies.remove(e)。但 Python 的 for 循环在遍历过程中删除元素会改变列表索引导致下一个元素被跳过。 【解决】不要直接遍历原始列表。用for e in enemies[:]:先复制一份副本或者使用 Pygame 的pygame.sprite.Group它内部实现了安全的移除机制。我推荐直接用 Group它既支持add/remove/kill内部也处理了遍历删除的问题比自己维护 list 省心得多。5.4 音效初始化失败不报错【现象】游戏跑起来没有声音程序也不报错看起来一切正常。 【原因】pygame.mixer.init()静默失败。某些设备上音频驱动不可用或已被占用时mixer 的初始化会返回一个未就绪的状态但不会抛异常。后续播放Sound.play()时资源不可用声音就无声地丢掉了。 【解决】初始化后检查pygame.mixer.get_init()如果返回None说明初始化失败。这种情况我是直接打印日志提示“音频不可用”然后让游戏继续以静音模式运行不要卡死。至少玩家知道是设备问题而不是代码 bug。5.5 复制项目后图片路径失效【现象】换了一台电脑把源码和图片目录原样拷过去运行时报找不到图片文件。 【原因】代码里写的是plane.png这样的相对路径。相对路径是相对于“当前工作目录”解析的而工作目录取决于你从哪里启动程序。有时候在 IDE 里运行没问题直接在命令行运行就报错就是因为工作目录变了。 【解决】把所有资源的加载统一走os.path.join(BASE_DIR, ...)这种基于脚本位置的绝对路径方式BASE_DIR在文件顶部定义一次全项目复用。这样不管从哪个目录启动程序资源路径都不会变。这个改动虽然简单但它决定了你的项目能不能被别人一键跑起来。6. 从能跑到能发布胜负逻辑、难度曲线与打包的一个小技巧6.1 胜负判定用状态机把游戏流程串起来飞机大战的胜负逻辑不能只写“血量为零就退出”否则开始界面、暂停、游戏结束后的重新开始都会变得很乱。我一般用三个状态常量管理整个游戏流程READY等待开始、PLAYING游戏进行中、OVER游戏结束。主循环里按状态分发不同的逻辑比如OVER状态下按键 R 重置主角位置、血量、分数和所有精灵组回到PLAYING。状态机不只是工程习惯它也是玩家体验的一部分死亡后不能立刻重新开局需要留出 1~2 秒动画时间让死亡反馈被看见否则玩家会觉得自己“莫名死了”挫败感会很强。6.2 难度曲线分数驱动的敌机刷新参数敌机刷新间隔不要从头到尾都是一个固定值。我推荐的曲线是以 80 帧为初始间隔分越高间隔越短但设一个下限 20避免出现几帧内刷出大量敌机的极端情况。具体公式可以是enemy_interval max(20, 80 - score // 2000 * 5)也就是说每获得 2000 分刷新间隔缩短 5 帧。配合敌机速度整体增加玩家每一段时间都会感觉到压力提升但又不会立刻被淹没。难度曲线必须做封顶否则最后几帧刷一堆敌机任何操作水平都躲不过去那就是设计事故了。6.3 打包发布资源路径统一处理的小函数打包飞机大战发布给同事或朋友试玩最常见的工具是 PyInstaller。打包出的 exe 在运行时工作目录可能是临时解压目录此时基于相对路径的资源全部失效。我的解决方案是在代码最顶部写一个资源路径函数def resource_path(relative): base getattr(sys, _MEIPASS, os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, relative)这个函数用到 PyInstaller 的一个特性打包后运行时会把资源解压到sys._MEIPASS未打包时用脚本所在目录。所有pygame.image.load(resource_path(plane.png))都走它既能本地直接跑也能打包后稳定运行。我自己的血泪经验是第一版打包后发给别人玩对方报找不到图片排查半天发现就是路径问题。有了这个函数之后再没翻过车。做游戏和写业务代码最大的不同是业务代码跑通就是胜利游戏跑通只是起点。你的每一次参数调整——刷新频率、子弹速度、无敌时间、音效音量——都是在跟玩家的耐心掰手腕。希望这篇笔记能让你少走几圈我走过的弯路把精力留给真正有意思的部分。希望帮到你。本文还有配套的精品资源点击获取