行业资讯
📅 2026/9/1 9:03:37
Python+pygame飞机大战完整版:架构设计与排错实战
简介一份基于经典“打飞机”玩法的完整代码项目基于Objective-C实现内含游戏场景管理、玩家飞机控制、敌机生成、子弹发射、碰撞检测、分数累计等核心模块适合具备基础编程知识、希望系统学习iOS游戏开发或完成期末课程设计的读者。代码中作者已撰写较详尽注释可对照理解游戏状态机切换、动画帧更新、资源加载与释放、触摸/按键事件处理等关键实现同时项目包含Xcode工程配置、storyboard界面、plist属性列表编译后可边玩边调试便于复现敌机AI、Boss战、爆炸特效等玩法机制。压缩包共39个文件主要包含Objective-C源码.m/.h、工程配置文件.pbxproj/.plist/.storyboard、音频资源.mp3、图片资源.png等整体大小仅1.28MB目录结构清晰适合直接导入工程进行二次开发或学习研究。目前已有512人学习下载对于想快速上手手游开发、吃透一个小型游戏全流程的读者是一份性价比很高的实战参考。 写飞机大战完整版代码这件事我前前后后折腾过好几版。从最早用C语言在控制台里拿字符画飞机到后来用Python配pygame做带贴图带音效的完整版本踩过的坑能写满一屏。这篇就把我最后一版完整代码的思路、设计、难点和排错过程全部摊开来讲代码可以直接跑结构也可以直接改希望能帮你少走点弯路。我默认你是想做一个真正能玩的、可以拿出去展示甚至参与课程设计的版本不是那种几十行就完事的演示品。所以这篇博文里你会看到完整的事件循环、精灵管理、碰撞检测、计分系统、音效和关卡难度递增全部模块化组织方便你按需删改。1. 整体设计思路为什么用Python和pygame先说选型。飞机大战这类2D射击游戏核心诉求就三个渲染快、逻辑明确、迭代方便。C语言写控制台版虽然能玩但你要贴图、要音效、要做粒子爆炸效果那就痛苦了。JavaScript网页版也没问题但代码组织起来分散调试体验一般。综合下来我最后选择Python配合pygame理由很简单事件循环模型是pygame自带的你只需要关心“每帧干什么”。精灵类Sprite和精灵组Group省掉了一大半对象管理的工作。素材处理极其方便png、jpg、wav直接加载。打包成exe也有现成方案给同学演示不要求对方装环境。这套东西不需要安装深度学习的依赖也不需要配置什么环境变量。你把pygame装上整个项目就三个文件main.py主逻辑、settings.py配置项、assets放图片和音效的文件夹。这样一个分层的好处是你想换素材、换难度参数、改窗口大小都只动settings.py不会把主逻辑弄得一团糟。从设计模式角度看我没有引入复杂的MVC分层那对这个小项目来说过重了。我采用的是“一个游戏循环驱动的状态机 精灵组管理”的写法。游戏状态只有三个READY待开始、PLAYING游戏中、GAMEOVER结束。整个游戏循环里switch状态分支对应处理不同的逻辑和渲染。这是2D小游戏最直接的架构改起来也最快。1.1 核心需求拆解从需求层面看一个“完整版”的飞机大战到底包含什么很多人写代码写一半就放弃了是因为需求根本没拆清楚。我按功能把需求拆成六块玩家飞机响应键盘上下左右移动按住空格或自动射击。敌机生成从上往下飞多种外形被击中会爆炸。子弹系统玩家子弹向上部分敌机还能向下发射子弹增加难度。碰撞判定子弹打中敌机、敌机撞到玩家、子弹打中玩家。计分与生命击落不同敌机得不同分数玩家有生命值或护盾。游戏状态管理开始界面、结束界面、重新开始、暂停。这六块覆盖了所有核心交互逻辑。每一块都能独立测试最后再整合进主循环。我的建议是你最好也按这个顺序写不要上来就写主循环然后到处补逻辑后面会越来越乱。1.2 精灵类划分对象模型怎么建pygame里的Sprite并不是一个普通对象它要求你实现两个关键方法update()每帧更新逻辑和draw()渲染到指定Surface。我没有每个类里重复写绘制方法而是直接继承pygame.sprite.Sprite然后在class内部实现自定义的update行为和加载图像的方法。我总共定义了五个类Player玩家飞机属性包括速度、冷却时间、无敌时间状态机里处理上下左右移动。Enemy普通敌机一个类通过传参控制速度、分数、贴图不搞继承。Boss可选大怪血条、攻击模式、多阶段看你需要。Bullet子弹区分敌我通过一个owner字段判定。Explosion爆炸动画播放几帧贴图后自动销毁。这里有个值得说的技巧不要把敌机逻辑写在Enemy类的update里硬编码把类型差异当成参数传进去比如speed、hp、score。这样你加新敌机只需要在settings里加一条配置数据不需要新写类。这个思路对后续扩展非常省事我的完整版里有四种敌机代码量却很小。2. 核心细节解析与实操要点飞机大战看着简单真正决定手感和代码质量的是几个细节移动加速度、子弹冷却、碰撞的宽容度、画面滚动节奏。这些细节不处理好玩家会觉得飞机僵硬、子弹打不中、判死又太苛刻。下面逐个说。2.1 帧率与时间控制的坑很多人写pygame不设帧率或者设了帧率却不知道Clock.tick()的真正作用。pygame.time.Clock.tick(60)意味着每帧最多耗时1/60秒多余的等待、不够就算忙碌帧总体保证游戏跑在60FPS左右。没有这一步不同电脑上游戏速度完全不一样高速电脑上敌机飞得跟闪电一样。但光有帧率还不够移动和发射的间隔最好用时间计算而不是“每帧固定执行”。举个例子子弹冷却时间我用的是self.cooldown - self.dt其中self.dt self.clock.tick(60) / 1000.0单位是秒。这样哪怕帧率掉到30射击间隔依然真实地保持在0.2秒。如果你用“每帧发射”它就会因为帧率波动导致体验时快时慢。实际上移动速度也一样是x x speed * self.dt保证每秒钟移动的像素数是常量而不是每帧移动多少像素。尤其当你要做速度加成道具时这种写法就能避免很多位置突变的bug。2.2 碰撞检测的宽容度设置pygame.sprite.spritecollide默认用的是矩形碰撞。飞机贴图周围有透明区域如果用完整矩形判定你会发现明明子弹没碰到飞机也判定为击中。这就很影响手感玩家的反应是“这都没打中为什么扣血”。解决方式是让碰撞矩形缩小一圈我用的是一个辅助函数get_collision_rect(surface, offset_ratio0.7)返回一个中心对齐、宽高都乘以0.7的Rect对象。在Sprite里覆盖self.rect为这个缩小的矩形。这样视觉上感觉还是原来的大小但实际碰撞范围更精准。另外子弹打敌机我通常用pygame.sprite.groupcollide(bullets, enemies, True, False)这里有个小细节第一个True表示子弹被销毁第二个False表示敌机不立即销毁。为什么不直接销毁敌机因为我想先播放爆炸动画所以我把敌机从enemies组移除加到explosions组延迟销毁。如果直接True、True敌机瞬间消失爆炸效果还没来得及展示就没了观感很生硬。2.3 背景滚动的实现背景滚动是飞机大战的标配没有它画面会显得非常静止。我用的是一整张高度是窗口高度两倍的背景图或者一张小图反复拼接。算法很简单两个相同的Surfacey坐标一个从0开始一个从-height开始每帧两个都向下移动speed像素当其中一个的y坐标大于等于窗口高度时把它放回另一个的上方。这样看起来就是无休止向下滚动的太空背景。这个逻辑应该放在一个Background类里独立于主循环之外只暴露update()和draw()。注意背景移动速度和敌机飞行速度要有档次差背景快敌机慢会显得敌机“顶风飞行”背景慢敌机快则显得飞机跳来跳去通常背景速度控制在敌机速度三分之一左右比较舒服。3. 实操过程与核心环节实现下面进入正题我把最后一版完整代码的骨架和关键实现串一遍。限于篇幅我尽量说明核心逻辑不会把所有代码整段贴出来但会覆盖关键行和易错点。3.1 初始化与游戏循环骨架main.py的入口部分整个游戏的骨架如下import pygame import sys from settings import * from player import Player from enemies import EnemyGroup from bullets import BulletGroup from effects import ExplosionGroup 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.state READY self.reset_game() def reset_game(self): self.player Player() self.enemies EnemyGroup() self.bullets BulletGroup() self.explosions ExplosionGroup() self.score 0 self.lives 3 self.spawn_timer 0 def run(self): while self.running: self.dt self.clock.tick(60) / 1000.0 self.handle_events() self.update() self.draw() pygame.quit() sys.exit()这里需要关注的是self.dt的计算。我在循环开始就取得本帧耗时后续所有的移动和冷却都用它不要自己在update里再计算一次不然时间基准不一致会出现飞行速度不一致的问题。事件处理里注意要监听pygame.KEYDOWN和pygame.KEYUP用self.keys pygame.key.get_pressed()也行但在状态切换时比如READY状态按空格开始得区分主循环外的“一次性按键”和持续按键。我的做法是状态切换用KEYDOWN事件移动和射击用keys持续状态。3.2 玩家类移动、射击、无敌Player类实现如下核心部分class Player(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image pygame.image.load(assets/player.png).convert_alpha() self.rect self.image.get_rect() self.rect.midbottom (SCREEN_WIDTH // 2, SCREEN_HEIGHT - 50) self.speed 300 self.shoot_cd 0.2 self.timer 0 self.invincible False self.invincible_timer 0 self.shoot_sound pygame.mixer.Sound(assets/shoot.wav)移动的写法是def update(self, dt, keys): dx, dy 0, 0 if keys[pygame.K_LEFT] or keys[pygame.K_a]: dx - self.speed * dt if keys[pygame.K_RIGHT] or keys[pygame.K_d]: dx self.speed * dt if keys[pygame.K_UP] or keys[pygame.K_w]: dy - self.speed * dt if keys[pygame.K_DOWN] or keys[pygame.K_s]: dy self.speed * dt self.rect.x dx self.rect.y dy self.rect.clamp_ip(pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)) if self.invincible: self.invincible_timer - dt if self.invincible_timer 0: self.invincible False这里有一个常见错误rect.x dx后没有做边界约束飞机直接飞出屏幕。用clamp_ip一次性修正可以避免写四个if判断。但注意clamp_ip传入的Rect是0, 0到SCREEN_WIDTH, SCREEN_HEIGHT这意味着飞机不能越过窗口上边缘如果想让飞机的头能被看到一半边界Rect的y要留出飞机高度。射击逻辑需要注意冷却时间def try_shoot(self): if self.timer 0: self.bullet_group.add(Bullet(self.rect.midtop, player)) self.timer self.shoot_cd self.shoot_sound.play()这里的timer在外部update里减去dt当小于等于0才允许发射。这个方案比每帧随机发射或直接按下就发射要稳定得多。3.3 敌机生成与多类型管理敌机生成我单独写了一个EnemyGroup类继承pygame.sprite.Group。它的核心工作是定时生成不同种类的敌机并传入对应配置。class EnemyGroup(pygame.sprite.Group): def __init__(self): super().__init__() self.spawn_interval 1.0 self.timer 0 self.level 1 def update(self, dt): self.timer - dt if self.timer 0: self.spawn_enemy() self.timer self.spawn_interval self.spawn_interval max(0.2, self.spawn_interval - 0.02) for enemy in self.sprites(): enemy.update(dt) if enemy.rect.top SCREEN_HEIGHT: enemy.kill()这里关键点是随游戏推进生成间隔会越来越短但设了一个下限0.2秒。如果没下限后期同一个屏幕几十架敌机玩家根本躲不开游戏体验直接崩塌。同时我对敌机的类型做权重随机前期全是小飞机25秒后出现中型机45秒后可能出现需要多次击中的盾机型。这些时间点用全局时间戳计算而不是基于杀敌数。3.4 碰撞、计分与爆炸处理碰撞逻辑我集中放在主循环的一个方法里def handle_collisions(self): hits pygame.sprite.groupcollide(self.bullets, self.enemies, True, False) for bullet, enemy_list in hits.items(): for enemy in enemy_list: enemy.hp - bullet.damage if enemy.hp 0: self.score enemy.score_value self.explosions.add(Explosion(enemy.rect.center)) enemy.kill() else: enemy.flash_damage() # 玩家与敌机相撞 crashes pygame.sprite.spritecollide(self.player, self.enemies, True) for enemy in crashes: if not self.player.invincible: self.lives - 1 self.explosions.add(Explosion(self.player.rect.center)) self.player.set_invincible(2.0) if self.lives 0: self.state GAMEOVER这里想强调几个细节。子弹命中但敌机没死的时候我调用enemy.flash_damage()实现敌机闪白效果。如果直接跳过这步玩家感受不到“打中了”会觉得子弹穿模了。闪白的实现很简单把敌机image换成一张白色蒙版图持续0.05秒再换回来。这是2D游戏里手感反馈很重要的部分。另一个细节是玩家与敌机相撞时我没有用spritecollide的默认参数True立即销毁敌机而是先把本帧碰撞的敌机都销毁了同时让玩家进入无敌状态。如果玩家无敌期间再撞上另一架敌机spritecollide仍会返回敌方列表并销毁它们但不会扣血。这样既不会因为无敌导致穿模也不会连续扣生命到负数。3.5 界面渲染分数、生命、状态提示完整版游戏还得有一整套UI渲染。我在draw阶段先绘制背景再绘制所有精灵组最后绘制文字层。文字层用pygame.font.SysFont(microsoftyahei或中文字体)来显示分数和生命不要用pygame.font.Font(None, 36)那种默认字体中文会显示成方块。另外从易读性角度我在屏幕左上角显示分数右上角显示生命图标。生命我用的是一排小红心贴图这样比写数字直观。开始界面上显示游戏名和“按空格开始”提示结束界面显示最终分数和“按R重新开始”。我习惯把UI绘制写成一个HUD类里面维护font和image引用。这样主循环里就一行hud.draw(self.screen, self.score, self.lives)非常干净。3.6 数据配置弹性与音效处理settings.py里我会放所有可配置参数包括窗口大小、颜色值、初始难度、音量大小、不同敌机的分数等。以前我图省事把常量散写在各个文件里后来想调游戏难度得全局搜索数字痛苦死了。把配置集中到一个文件后调节游戏节奏只需要改这一个文件。音效方面要注意pygame.mixer的初始化要在pygame.init()之后并且音频文件格式建议用wav因为mp3可能出现兼容问题。加载音效后设置pygame.mixer.Sound.set_volume()不要把音量拉到1.0游戏里射击音效嘈杂得很。我习惯把主音量控制在0.3~0.5之间背景音乐如果循环播放音量还要再低一点否则语音语音提示完全听不清。4. 常见问题与排查技巧实录这个项目做下来我遇到过很多奇奇怪怪的bug。有些问题一次就定位了有些排查了一晚上。整理一张问题速查表你如果跑代码遇到异常可以先对着看一眼。4.1 问题速查表现象可能原因解决方案窗口打开后闪电关闭没有进入主循环或pygame.quit()被误调用检查while循环是否正常运行init与quit保持层级对称飞机移动特别卡没有Clock.tick()或dt计算错误在循环第一行调用self.clock.tick(60)用dt驱动运动子弹穿过敌机不触发碰撞碰撞组归属错误或子弹owner未设置检查子弹是否加到bullets组敌机是否在enemies组中文显示成方块使用了默认字体使用pygame.font.SysFont指定中文字体背景滚动速度时快时慢背景移动用像素固定值而非时间*速度改用speed * dt击毙敌机后还有残影敌机销毁后未从组中移除调用kill()方法不要只从列表里remove音效播放崩溃音频文件损坏或格式不支持转成wav或降低采样率玩家开局就被敌机撞死无敌状态未在reset_game中初始化reset_game中把所有状态恢复默认打包exe后找不到图片相对路径在打包后失效用sys.path或tkinter获取资源绝对路径4.2 几个值得记录的排查过程第一个是“子弹数量一多就明显掉帧”。我一开始用列表手动管理子弹每帧对列表里所有子弹做移动和碰撞判定数量过200之后帧率直接掉到30。换用pygame.sprite.Group之后底层是C语言写的批量遍历效率提升非常多数量到300都没压力。这是pygame官方推荐精灵组的重要原因不是单纯图方便是性能优化。第二个是“爆炸动画播放一半就消失”。原因是Explosion对象的动画帧循环完成后被kill但pygame的group在迭代过程中如果删除当前元素可能导致其他对象未被正常更新。排查后发现是update顺序的问题。我的解决方式是把爆炸组放在最后更新而且在动画完成后不直接调用group内部的kill而是通过添加一个self.finished标志由主循环统一清理。第三个是“不同机器运行难度完全不同”。这个问题最隐蔽。最初我用每帧固定像素移动60Hz显示器上敌机每秒走60次144Hz显示器上每秒走144次游戏速度直接翻倍。把time-based movement基于时间的移动引入后这个差异就消失了。如果你以后写任何游戏这一点尤其重要。第四个是“按R键重新开始后旧状态还在”。原因是reset_game()只重新创建了player和enemies等对象但没有重置全局计时器或者某些单例类里的状态。所以我把生成敌机的时间戳也放进reset_game里并让HUD对象的得分、生命显示强制刷新。重新开始功能必须“从上到下全量重置”别偷懒只重置部分变量。4.3 素材制作的替代方案没有美术怎么办如果你不想花时间找素材其实可以用程序生成简易贴图。pygame里可以创建一个Surface直接画矩形、圆形、多边形来拼出飞机外形。比如玩家飞机用一个绿色三角形加蓝色矩形机翼敌机用红色圆形加黄色驾驶舱效果虽然简单但游戏性完全不受影响。这在原型阶段特别快等有美术素材了再替换图片路径就行。想走这个路线有一个绘图小技巧把几种基本形状的函数封装成工具函数比如draw_triangle_ship(color, size)返回Surface。这样你在settings里定义颜色和尺寸就能生成不一样风格的飞机。实测下来程序生成的素材还有一个好处就是旋转角度可以任意设定比如让敌机旋转进场这种效果使用固定素材图做起来反而麻烦。5. 完整版代码的模块化补充从能玩到“能展示”如果把上面这些逻辑都整合好游戏已经能玩了。但如果你打算把这个项目放进作品集、作业答辩或开源分享光能玩还不够还需要一些工程化层面的补充。5.1 暂停功能、帧率显示与配置热更新我给完整版加了暂停功能按P键暂停按下后再恢复。实现方式是在主循环里加一个if self.state PLAYING and self.paused:跳过update但仍绘制当前帧画面。很多新手在这里容易犯一个错误暂停后pygame.display.flip()仍然执行导致画面看似卡住但CPU占用100%。其实这是正常的但如果你想让CPU空转降下来可以在暂停时主动time.sleep(0.02)。帧率显示我放在窗口标题栏上每0.5秒更新一次标题文字。这个功能用途很大你可以快速验证自己的time-based movement是否生效以及哪个状态切换导致掉帧。代码就一行pygame.display.set_caption(f飞机大战 - FPS: {self.clock.get_fps():.0f})。get_fps()是Clock对象提供的方法返回最近一秒的均值。配置热更新我做得比较简单在settings.py里如果检测到有SETTINGS.ini文件读入覆盖默认变量。这样调整参数不需要改Python文件对演示场景很实用。不过这个功能对新手来说不是必须等你做完核心逻辑再考虑也不迟。5.2 打包exe与资源路径问题如果你想把项目发给别人玩可以用pyinstaller打包。我的打包命令是pyinstaller --onefile --windowed --add-data assets;assets main.py注意--add-data的路径分隔符在Windows上分号在Linux和macOS上是冒号。打包后程序运行时资源路径需要改成sys._MEIPASS拼接的方式。这个坑很多人踩过。我在代码里的写法是def resource_path(relative_path): if hasattr(sys, _MEIPASS): return os.path.join(sys._MEIPASS, relative_path) return os.path.join(os.path.dirname(__file__), relative_path)然后在加载图片、音效时统一用resource_path(assets/player.png)。这样一套组合拳打好之后你发给朋友的就是一个双击即玩的exe文件演示体验拉满。需要注意--onefile模式打包启动时会解压临时目录如果素材数千张启动会变慢飞机大战素材量小可以忽略这个影响。5.3 扩展方向从飞机大战到弹幕射击飞机大战再往前推一步可以做很多衍生玩法。最常见的是基于弹幕射击的扩展敌机会发射规律性的子弹弹幕比如扇形弹、螺旋弹、环形弹。实现上并不复杂你只需要在敌机类里加一个shoot_timer到时间后按一定角度遍历创建子弹对象。关键在于弹幕的数学模型比如螺旋弹就是每发子弹的初始角度递增15度已发射弹幕继续按原方向直线运动。我从飞机大战扩展弹幕玩法时最深的体会是逻辑架构必须一开始就为扩展留接口。比如玩家子弹类里要支持不同方向的运动敌机要支持射击行为碰撞要区分子弹的归属。如果不提前留好后期加弹幕会很痛苦因为你要改的不是一个方法而是一整套碰撞判定逻辑。最后分享一个小技巧如果你现在的飞机大战代码已经写了百来行但总是感觉手感不对我建议你从两处入手优化一是把碰撞矩形缩小20%二是把移动速度从固定数值改成每秒像素数并乘以dt。这两处改动代码量不大但体感提升非常明显。我最初做这个项目的时候反复调手感调了半个下午最后发现九成的问题都出在“没做时间驱动”和“碰撞检测太苛刻”上。另外测试时你会发现玩家不会盯着屏幕中心看眼球会追着飞机跑。所以HUD文字如果放在正上方容易被敌机遮挡。我的做法是把分数显示在左上角并给它加一个半透明黑色底框这样不管背景多亮都能看清。这个细节虽然不起眼但在答辩演示或朋友试玩时会显得你的项目完成度高出一截。本文还有配套的精品资源点击获取