
简介一款基于 SpriteKit 游戏引擎的飞机大战 iOS 游戏完整源码适合已有 Objective-C 基础、希望系统理解游戏开发全流程的开发者。项目完整实现了玩家飞机控制、子弹发射、多类型敌机生成与 Boss 战斗并涵盖了碰撞检测、计分机制、游戏状态切换等核心逻辑同时利用 storyboard 构建游戏界面整合纹理图集和音效资源来提升表现力。压缩包内共 39 个文件以 .m 和 .h 源码、storyboard 界面文件及 plist 配置为主体辅以 mp3 音效和 png 图片素材整个包体仅 1.28MB文件分类清楚方便按模块检索学习。目前已有 512 人学习下载作者对关键功能都添加了注释从场景初始化、触摸事件响应到碰撞判定与动画播放都有清晰的思路说明非常适合作为课程设计或毕业设计项目。开发者还可在此基础上继续扩展新功能深入理解游戏面向对象设计、性能优化和资源管理的实践方法。 飞机大战这个项目可以说是Python游戏开发里被写烂了的经典题目。但越是经典的项目越能看出一个开发者对代码架构、运行时资源和工程细节的掌控力。很多人交上来的飞机大战就是三个文件拼在一起子弹、敌机、碰撞全用list硬扛能用是能用但只要想加个Boss、加个道具系统整个代码就得推翻重来。这篇博文我从头到尾写了一个完整版的飞机大战不是说把图片素材堆上去那种“完整”而是从游戏循环、对象管理、碰撞检测、音效资源到打包发布每个环节都按工程标准来做。如果你正在学pygame或者想找一个能写进简历的练手项目这篇内容可以帮你省下大量试错的时间。1. 整体设计思路先定架构再谈功能写游戏最忌讳的就是上来就写代码。我见过太多人第一步就把pygame.display.set_mode()敲出来结果写到第500行发现逻辑乱了不得不全部推倒。飞机大战虽然是个小游戏但麻雀虽小五脏俱全——它有游戏状态流转、实体对象管理、碰撞检测、UI渲染、音效播放、难度曲线这些模块如果不提前规划好后面每一步都是火葬场。1.1 核心需求解析咱们先理清飞机大战到底需要什么东西。从玩法和功能层面拆解完整版需要满足以下这些点玩家飞机可以左右/上下移动移动范围要限制在屏幕内按空格或鼠标持续发射子弹子弹需要有射速上限不能每帧都生成敌机从屏幕上方随机位置生成向下方移动速度随机玩家子弹击中敌机后敌机消失并产生爆炸特效和得分敌机如果飞出屏幕底部或撞到玩家玩家扣血血量归零则游戏结束支持重新开始、游戏暂停、最高分存档音效、背景音乐、爆炸动画、分数显示这些展示层不能缺这些需求写出来任何人一看都觉得简单但真正重要的不是“怎么做”而是“怎么组织代码让这些东西互不干扰”。我采用的方法是分层设计游戏核心逻辑状态机、循环、对象管理和展示层渲染、音效、特效分开各干各的事需要交互的地方用事件和接口解耦。1.2 为什么选择Pygame而不是其他框架Python写游戏其实选择面很窄。比较常见的有Pygame、Arcade、Panda3D、Ursina再偏门一点有cocos2d-python。我的选择是Pygame原因很简单生态最成熟网上踩坑资料最多遇到问题不愁没地方查内置的sprite模块和Group类提供了现成的对象管理和碰撞检测方案学习曲线平缓适合练手但又不是简单到什么都替你做有些人觉得Pygame性能不够说做不了大场景。那是误解。Pygame的瓶颈主要在CPU渲染但它对2D游戏的支持特别是Surface.blit、transform、mask这些底层操作只要你代码写得合理处理几百个对象是完全没问题的。飞机大战这种几十个子弹加十几架敌机同时存在的场景性能余量绰绰有余。1.3 文件结构与模块划分我最终确定的文件结构是这样的你们可以直接参考plane_war/ ├── main.py # 入口文件游戏主循环 ├── settings.py # 全局配置屏幕尺寸、颜色、速度参数 ├── game.py # Game类游戏状态管理、场景切换 ├── sprites.py # 所有精灵类玩家、敌机、子弹、爆炸 ├── ui.py # UI组件按钮、分数面板、血条 ├── resources.py # 资源加载器图片、音频的加载和缓存 ├── assets/ │ ├── images/ # 飞机、子弹、背景图片 │ └── sounds/ # 射击音效、爆炸音效、背景音乐 └── score.py # 最高分存取JSON序列化main.py只做一件事初始化窗口、创建Game实例、调用game.run()。真正的主循环在game.py里这样入口干净逻辑清晰。settings.py把所有的可调参数集中放在一起后面调平衡性的时候只需要改这个文件不用满代码库去找一个魔法数字。2. 核心系统实现拆解精灵管理和碰撞检测的正确姿势这一块是整个游戏开发里技术含量最高、也最容易踩坑的地方。很多新手一写碰撞检测就是两个for循环套起来所有子弹遍历所有敌机再所有敌机遍历玩家。这在小规模场景里没问题但只要对象一多性能瞬间崩掉。完整版的代码必须用pygame.sprite.Group来做。2.1 基于精灵组的对象生命周期管理Pygame的sprite.Sprite类和Group类是天生一对。Sprite负责定义单个对象的属性和行为update()方法就是每帧要执行的动作Group负责批量管理这些对象包括添加、删除、更新和绘制。我的代码里定义了这样几个组# 所有可飞行单位 all_sprites pygame.sprite.Group() # 所有子弹 bullets pygame.sprite.Group() # 所有敌机 enemies pygame.sprite.Group() # 所有爆炸特效 explosions pygame.sprite.Group()每一帧要做的操作就是# 更新所有精灵的状态 all_sprites.update() bullets.update() enemies.update() # 把所有精灵绘制到屏幕上 all_sprites.draw(screen)为什么bullets和enemies要单独再建组一句话为了碰撞检测的效率。pygame.sprite.groupcollide()方法接收两个组只会检测这两组之间的碰撞不会做全量遍历。这样子弹只跟敌机比敌机只跟玩家比计算量从O(n*m)降到了实际需要检测的范围而且代码可读性也更好。2.2 碰撞检测矩形碰撞和像素级碰撞的取舍Pygame提供三种碰撞检测方式矩形碰撞colliderect、圆形碰撞collide_circle、像素级碰撞mask。矩形碰撞最简单但有个致命问题——飞机图片通常是带透明通道的PNG矩形会把透明区域也算进去。如果你的飞机是三角形或者弧形矩形碰撞体验极差明明看着没碰到子弹却已经击中了。像素级碰撞用pygame.mask.from_surface()生成精灵的碰撞掩膜检测的时候只对比实际不透明像素的覆盖情况精度非常高。代价是需要额外的内存和计算时间。我的方案是分场景使用。子弹和敌机之间的碰撞用矩形碰撞就够了因为子弹很小矩形近似带来的误差感知不到。但敌机和玩家之间的碰撞必须用mask因为玩家被击中一次就掉一条命精度要求高误判会严重影响游戏体验。# 子弹和敌机的碰撞矩形 hits pygame.sprite.groupcollide(bullets, enemies, True, True) # 玩家和敌机的碰撞像素级 if pygame.sprite.spritecollide(player, enemies, False, pygame.sprite.collide_mask): player.lives - 1这里还涉及一个被很多人忽略的细节pygame.sprite.spritecollide()的第四个参数是collided参数默认是collide_rect。要使用像素级检测必须显式传入pygame.sprite.collide_mask。而且使用collide_mask之前必须保证所有精灵都有rect属性且mask是在__init__里提前生成好的——千万别每帧都重新生成mask性能会崩。2.3 具体代码实现精灵类的设计与编写我先给出玩家飞机类的核心代码你们感受一下结构class Player(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image load_image(player.png) # 加载图片 self.mask pygame.mask.from_surface(self.image) # 生成碰撞掩膜 self.rect self.image.get_rect() self.rect.midbottom (SCREEN_WIDTH // 2, SCREEN_HEIGHT - 30) self.speed PLAYER_SPEED self.lives MAX_LIVES self.invincible False self.invincible_timer 0 self.shoot_cooldown 0 def update(self): 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_WIDTH: self.rect.x self.speed if keys[pygame.K_UP] and self.rect.top 0: self.rect.y - self.speed if keys[pygame.K_DOWN] and self.rect.bottom SCREEN_HEIGHT: self.rect.y self.speed # 无敌时间倒计时 if self.invincible: self.invincible_timer - 1 if self.invincible_timer 0: self.invincible False def shoot(self): if self.shoot_cooldown 0: return bullet Bullet(self.rect.centerx, self.rect.top) bullets.add(bullet) all_sprites.add(bullet) self.shoot_cooldown SHOOT_COOLDOWN注意几个细节shoot_cooldown不是通过pygame.time.get_ticks()判断而是每帧减1。这样就避免了在事件循环里记录时间戳的麻烦。速度PLAYER_SPEED是每秒移动的像素数除以每帧的毫秒时间这个我放到帧率控制部分详细讲。再来看敌机类的设计。我的敌机分两种类型普通敌机和敏捷敌机。普通敌机血厚但速度慢敏捷敌机速度快但一枪就死。为了让代码不重复我用了继承class Enemy(pygame.sprite.Sprite): def __init__(self, image_path, hp, speed): super().__init__() self.image load_image(image_path) self.rect self.image.get_rect() self.rect.x random.randint(0, SCREEN_WIDTH - self.rect.width) self.rect.y -self.rect.height # 从屏幕外生成 self.hp hp self.speed speed def update(self): self.rect.y self.speed if self.rect.top SCREEN_HEIGHT: self.kill() # 飞出屏幕自动回收 class FastEnemy(Enemy): def __init__(self): super().__init__(enemy_small.png, hp1, speed6) class HeavyEnemy(Enemy): def __init__(self): super().__init__(enemy_big.png, hp5, speed2)这里有个非常实用的细节self.kill()。只要敌机飞出屏幕底部self.rect.top SCREEN_HEIGHT就调用kill()方法从所有Group中移除。如果不做这一步敌机对象会一直累积在内存里游戏运行十分钟后帧率就会明显下降。这是新手最常见的性能坑之一。3. 实操过程与核心环节实现从主循环到画面渲染有了架构和精灵类下一步就是把整个游戏跑起来。这一节我把从主循环到画面渲染的完整流程演示一遍期间会涉及帧率控制、游戏状态管理、难度曲线调优这些关键环节。3.1 帧率控制与物理运动的时间基准pygame.time.Clock是控制游戏帧率的核心工具。我设置FPS 60然后每一帧结束时调用clock.tick(FPS)。这样做有两个作用一是限制游戏最多60帧避免CPU空转二是让游戏在不同性能的电脑上运行速度一致。但这里有个关键的细节容易被忽略把所有对象速度定义成“每帧移动多少像素”是最烂的做法。因为即使你用clock.tick(60)锁帧实际帧率在复杂场景下可能会掉到50帧甚至更低这时物体移动速度会变慢游戏难度也在无形中变化。标准的做法是以时间为基准定义速度每帧根据实际耗时计算位移。Pygame里最简单的方式是用dtdelta time在update()方法里接收# main.py 中 dt clock.tick(FPS) / 1000.0 # 转换为秒 game.update(dt)# Player.update() 中 self.rect.x self.speed * dt * 60 # speed是60帧下的像素/帧乘以60换算为每秒像素不过这个做法会让代码变得复杂很多教程为了简洁都直接用“每帧像素”方案。我的观点是如果只是练手直接每帧像素完全够用但如果要做成一个认真维护的项目务必引入dt机制这是一道分水岭。3.2 游戏主循环实现与状态管理我用一个Game类来封装整个游戏的运行逻辑。主循环的骨架是这样的class Game: def __init__(self): pygame.init() self.screen pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption(飞机大战完整版) self.clock pygame.time.Clock() self.running True self.game_state menu # menu / playing / paused / gameover self.score 0 self.high_score load_high_score() ... def run(self): while self.running: # 1. 处理事件 self.handle_events() # 2. 更新游戏逻辑 if self.game_state playing: self.update() # 3. 绘制画面 self.draw() # 4. 控制帧率 self.clock.tick(FPS) def handle_events(self): for event in pygame.event.get(): if event.type pygame.QUIT: self.running False elif event.type pygame.KEYDOWN: if event.key pygame.K_ESCAPE: if self.game_state playing: self.game_state paused elif self.game_state paused: self.game_state playing elif event.key pygame.K_SPACE: if self.game_state menu or self.game_state gameover: self.start_game() def update(self): # 生成敌机 if random.random() ENEMY_SPAWN_RATE: self.spawn_enemy() # 更新所有精灵 all_sprites.update() # 碰撞检测 self.check_collisions() # 更新难度 self.difficulty_timer - 1 if self.difficulty_timer 0: self.increase_difficulty()game_state是状态机的核心变量。菜单、游戏中、暂停、结束四个状态每个状态对事件的处理和update/draw的行为都不一样。用状态机的好处是逻辑清晰后续加一个“结算界面”或“设置界面”只需要新增一个状态枚举不需要动主循环结构。3.3 敌机生成与难度曲线的数值设计敌机不能无限生成否则游戏永远不会结束或者说永远活在死亡边缘。我设计了ENEMY_SPAWN_RATE这个变量来控制生成概率。初始值是0.02也就是每帧有2%的概率生成一架敌机。配合60FPS的帧率算下来平均每秒生成1.2架敌机。随着游戏时间推移难度要递进。我的方案是每隔15秒ENEMY_SPAWN_RATE加上0.005同时普通敌机的速度增加0.2。这样游戏的“压力曲线”是一个缓慢上升的线性增长不会突然变难但会给玩家持续的压迫感。# settings.py ENEMY_SPAWN_RATE 0.02 MAX_ENEMY_SPAWN_RATE 0.12 # 上限避免后期疯狂刷屏 DIFFICULTY_INCREASE_INTERVAL 15 * 60 # 每15秒提升一次难度有个细节要特别提醒生成概率一定要有上限。如果你不做限制游戏运行10分钟后每帧的生成概率飙到0.3那屏幕上会同时出现十几架敌机直接把玩家碾死。我在网上看到很多飞机大战的完整版代码都有这个问题前期体验还行后期难度直接不合理玩家根本没有反馈和成长的空间。3.4 资源的懒加载与缓存游戏运行过程中需要反复加载图片和音效。如果每次生成一架敌机就pygame.image.load()一次性能会非常难看。正确的做法是在游戏初始化时把所有资源加载到内存用字典缓存起来# resources.py _resources_cache {} def load_image(name): if name not in _resources_cache: path os.path.join(ASSETS_DIR, images, name) _resources_cache[name] pygame.image.load(path).convert_alpha() return _resources_cache[name] def load_sound(name): if name not in _resources_cache: path os.path.join(ASSETS_DIR, sounds, name) _resources_cache[name] pygame.mixer.Sound(path) return _resources_cache[name]我分享一个经验画面上如果有大量图片要加载特别是爆炸特效有多帧连续图用convert_alpha()转化格式后渲染速度能提升20%-30%。这是因为convert_alpha()会把图片转成屏幕的像素格式省去逐像素转换的开销。另一个要点是:在开发机上做资源加载统一管理后期打包成exe的时候只需要改ASSETS_DIR的路径就行不用在代码里到处找路径拼接。4. 常见问题与排查技巧实录这一部分都是我在实际开发中踩过的坑网上很多代码甚至干脆带着这些bug就发布了。我在这里整理成速查表你们遇到问题的时候直接对照检查。4.1 常见问题速查表问题现象问题原因解决方案游戏窗口无响应或黑屏pygame.display.update()没有调用确保主循环每帧调用display.update()或display.flip()飞机移动一卡一卡帧率不稳定速度用帧数计算引入dt时间差机制或用clock.tick(FPS)锁帧子弹打中敌机但没消失碰撞检测用了colliderect而子弹太小把子弹rect的信息在初始化时打印出来确认坐标正确内存占用随时间增长精灵对象被击毁后没有从Group中移除在update()中调用self.kill()或用groupcollide的dokill参数音效炸裂或延迟一次生成太多pygame.mixer.Sound实例用缓存机制复用声音对象不要每次都加载打包成exe后图片加载失败资源路径使用了相对路径使用sys._MEIPASS判断打包环境切换资源根路径4.2 一个经典坑碰撞检测不正确这是作者遇到的最多的问题。表现是子弹明明穿过了敌机的身体但敌机没消失或者玩家飞机距离敌机还有一段距离却已经判定碰撞了。排查步骤第一步确认Group里是不是有对象被反复添加了。如果你在__init__里把同一架飞机同时添加到了all_sprites和enemies然后update()的时候又重复添加了一次就会导致一个精灵被绘制两次或碰撞检测重复计算。可以用print(len(all_sprites))在每帧确认数量是否合理。第二步检查rect的初始位置是否在屏幕外。如果敌机的rect.y为负数在屏幕上方之外但你的碰撞检测逻辑没有排除屏幕外对象子弹和敌机碰撞的判定结果可能看起来非常奇怪。第三步像素级碰撞失效。pygame.sprite.collide_mask要求每个Sprite必须要有mask属性。如果你有的精灵有mask有的没有collide_mask会直接返回False——也就是说敌机撞到你了但判定为没碰撞。一个稳妥的方案是给所有精灵统一生成mask或者自定义碰撞函数做容错处理def safe_collide_mask(sprite1, sprite2): if not hasattr(sprite1, mask) or not hasattr(sprite2, mask): return pygame.sprite.collide_rect(sprite1, sprite2) return pygame.sprite.collide_mask(sprite1, sprite2)4.3 帧率与性能的平衡技巧飞机大战虽然简单但如果特效多——爆炸粒子、拖尾、多个背景层滚动——帧率也是会掉的。我有三个优化经验第一粒子和特效的数量要设上限。爆炸特效我实现为Explosion精灵用一个动画帧列表来播放播放完就kill()。但同屏的特效对象最多同时存在20个超过就先销毁最早的那个。这样既保证视觉效果又不至于拖垮性能。第二背景滚动用“图块复用”而不是“每次新建背景图”。背景是一张可平铺的图片滚动时只需要在update中位移background_y变量绘制时画两张同样的图首尾相接rel_y self.bg_y % BG_HEIGHT self.screen.blit(bg_image, (0, rel_y - BG_HEIGHT)) self.screen.blit(bg_image, (0, rel_y)) self.bg_y SCROLL_SPEED这样永远不会产生新的Surface对象性能开销几乎为零。第三减少不必要的pygame.transform操作。scale、rotate这类操作非常耗时不要在精灵初始化时反复调用。如果同一个图片需要多个尺寸提前在resources.py里把缩放结果缓存起来。4.4 无敌帧机制的正确实现玩家被撞到后会掉血但如果不给无敌帧玩家可能在同一个位置连续被多架敌机撞到瞬间掉完所有生命值。我的实现是被撞到后进入invincible状态持续1.5秒期间玩家闪烁不可被再次命中同时不检测与敌机的碰撞。if self.player.invincible: # 闪烁效果 if self.invincible_flash_timer % 10 5: self.player.image.set_alpha(100) else: self.player.image.set_alpha(255) else: self.player.image.set_alpha(255)有一个细节容易忽略set_alpha会修改图片的透明度但如果敌机通过Group.draw()批量绘制透明度可能在渲染后被重置。我最终的方案是不修改image本身的alpha而是用一张同样的半透明图片在渲染时切换。这样一个player_normal和player_flash两个Surface交替显示不会污染原始图片也不会影响后续的mask碰撞检测。4.5 最高分存档的序列化方案完整版游戏需要有最高分记录功能否则每次重新打开归零损失了很大的挑战动力。我用JSON格式来存档因为可视化好调试也方便。def save_high_score(score): data {high_score: score, last_time: time.strftime(%Y-%m-%d %H:%M:%S)} with open(HIGH_SCORE_FILE, w) as f: json.dump(data, f, indent2) def load_high_score(): try: with open(HIGH_SCORE_FILE, r) as f: data json.load(f) return data.get(high_score, 0) except (FileNotFoundError, json.JSONDecodeError): return 0这里要非常刻意地处理json.JSONDecodeError。如果文件内容损坏比如用户手动编辑过json.load()会抛异常导致游戏启动直接崩溃。完整版代码必须容错保证任何情况下游戏都能正常启动。5. 完整运行指南从源码到可执行文件到这儿我不妨把整个代码的启动和运行方式给你们梳理一遍。如果你的环境已经装好了Python那从获得代码到开始游戏的路径应该是非常顺畅的。5.1 环境依赖与安装我使用的Python版本是3.9pygame版本是2.5.2。如果你想完全复刻我的运行环境可以这样安装pip install pygame2.5.2然后在项目根目录下执行python main.py几秒钟后窗口就会弹出。如果遇到模块导入错误检查一下是否在项目根目录下执行且项目文件没有被改名。5.2 打包成exe的完整配置很多朋友写完游戏后想分享给不装Python的朋友玩那就需要打包成exe。我用的工具是PyInstaller打包命令非常简单pip install pyinstaller pyinstaller -F -w --add-data assets;assets main.py参数解释-F表示打包成单个exe文件-w表示运行时不显示控制台窗口因为GUI游戏不需要--add-data把assets文件夹一起打包进去。但这里有个坑打包后运行时资源路径从相对路径变成了_MEIPASS临时目录。你需要修改resources.py的路径获取逻辑import sys if getattr(sys, frozen, False): base_path sys._MEIPASS else: base_path os.path.dirname(__file__) ASSETS_DIR os.path.join(base_path, assets)不加这个判断的话打包出来的exe会在点击后闪退——很多人都会栽在这个问题上。我在源码里已经内置了这个逻辑但如果你想自己动手打包记得把这段复制过去。另外一个经验打包之前确保用python main.py完整跑一遍游戏流程比如打一关到Game Over界面再重新开始确认没有内存泄漏或崩溃。PyInstaller打包出来的exe在同样的代码下表现和直接运行一致唯一的区别是资源路径不同。这个坑我是真的踩过好几次才悟到的。6. 最后的扩展建议从游戏到作品到这里飞机大战的完整版已经能跑起来了。但我问你一个问题一个能够运行的飞机大战和一个能打动人的飞机大战差在哪儿我的答案是你愿意花多少精力在“游戏体验”上。这包括敌人的AI行为模式而不是随机的直线下落包括Boss战时的多阶段弹幕设计包括不同武器之间的手感差异和平衡性包括连击奖励和得分反馈给玩家的正循环激励。我在做完完整版之后给自己定了两个扩展方向。一是加入道具系统双发子弹、护盾、炸弹清屏每个道具用不同的掉落物区分玩家吃到后产生对应的效果和音效。二是加入关卡系统每10波敌机刷新一个BossBoss有不同的攻击模式击败Boss进入下一关。如果你也想走同一条路我建议你在现有的架构基础上做。因为已经有了Game状态机、对象分组管理、资源缓存这些地基加道具只需要新增一个PowerUp类加Boss只需要新增一个Boss子类并扩展spawn_enemy的逻辑。架构的好处就在这时候体现出来了——它让你在后面的每一个修改中都不需要推翻重来。最后分享一个小技巧当你觉得“代码已经写完了”的时候把它拿给别人试玩十分钟。你会发现一个玩家在意的和你在意的完全不一样。玩家关注的永远是你以为不重要的部分手感是否顺滑、被击中时反馈是否明显、死亡后重新开始的路径是否磨叽。这些都是代码层面的小改动但对游戏品质的提升是决定性的。本文还有配套的精品资源点击获取