1. 从一条弹幕说起为什么我劝你先用 Python 写个飞机大战说个真事儿。前段时间帮一个学弟看简历他说自己精通 Python结果面试官让他现场写个对象在屏幕上动起来他憋了二十分钟没写出来。问题出在哪不是语法不会是没做过能把语法串起来的完整项目。for循环、class、if判断这些他都懂但一到怎么让一架飞机从下往上飞、还能打子弹、还能撞爆炸就卡壳了。飞机大战这个题材在 Python 入门圈里几乎是标配练手项目原因很简单它把面向对象、事件循环、坐标计算、碰撞检测、状态管理这几个核心概念全塞进了一个你能看见、能玩、有反馈的小框里。你写完它再看 Python 基础语法感觉完全不一样——以前是背知识点现在是知道这些知识点在什么场景下用。我打算用这篇文章把这几年带人写飞机大战踩过的坑、绕过的弯、以及那些教程里从来不说的细节一次性讲清楚。你会看到完整的实现思路、可以直接跑的代码骨架、参数怎么调、性能为什么掉帧、以及最关键的——哪些地方新手一定会写错。适合谁看如果你能看懂def和class的区别装好了 Python 却不知道该拿它干嘛那这篇就是给你准备的。如果你已经写过一两个小游戏也可以从第四节的问题排查里挑点东西走。先说清楚技术选型。网上搜飞机大战 Python你会看到两个主流方向一个是pygame一个是tkinter。还有一部分人用cv2OpenCV配合鼠标操作做人机交互但那是另一个流派本文不做展开。我选pygame理由是它专为 2D 游戏设计精灵组、碰撞检测、音效播放、帧率控制这几样都是现成的你不需要自己造轮子。用 tkinter 写也不是不行但你得自己用after()手动跑主循环自己画矩形当飞机等写到碰撞检测和帧同步的时候会非常痛苦。这个选择背后的逻辑是入门项目要让你把精力花在游戏结构上而不是怎么实现一个游戏引擎上。2. 动手之前环境、目录和素材别一上来就写代码2.1 安装 Python 与 pygame 的正确姿势很多人在这儿就翻车了。流程是装 Python → 装 pygame → 建项目。Python 建议用 3.8 以上版本3.10、3.11 都行实际测试下来对 pygame 支持都没问题。安装时那个Add Python to PATH的勾一定要打上不然后面pip命令在终端里根本找不到你会对着不是内部或外部命令发呆半小时。这是热词里python安装教程出现这么高频的原因真心是第一个大坑。装完 Python 后打开终端敲python --version确认能输出版本号。然后装 pygamepip install pygame如果你在国内下载慢或者卡住可以换镜像源这个操作是常规做法pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple装完验证一下跑这段import pygame print(pygame.ver)能打印出版本号就成了。如果报ModuleNotFoundError八成是你电脑上有多个 Pythonpip装到了另一个版本里。这时候用python -m pip install pygame更保险它保证 pip 和当前 python 是同一个。编辑器方面VSCode 和 PyCharm 都行。VSCode 轻快但环境配置要多一步——你得在左下角选对解释器Python Interpreter不然会出现终端能跑但编辑器提示 import 报红的情况这也是热词里vscode python环境配置的由来。PyCharm 开箱体验更顺新建项目时它会自动建虚拟环境缺点是启动慢、占内存。我个人建议新手先 PyCharm把环境这关过了再说。2.2 项目目录与素材准备目录别乱堆按下面这样分后面加功能的时候你会感激自己plane_war/ ├── main.py 主程序入口 ├── game.py 游戏逻辑 ├── settings.py 所有常量配置 └── images/ 图片素材 ├── hero.png ├── enemy.png ├── bullet.png └── background.png为什么把常量单独抽到settings.py因为你一定会反复调参数——敌机速度、子弹冷却、屏幕尺寸。如果这些数字散落在各个文件里改一次你就要全局搜索一遍。集中管理之后调难度就是改一个文件五分钟搞定。素材方面不求精美求能用。尺寸建议主角机 60×60 像素左右敌机 50×50子弹 8×20背景 480×700。你要是懒得找图用纯色矩形也完全可以pygame.Surface填充个颜色就能当飞机效果丑但逻辑一点不差。我见过太多人卡在找素材上找了两小时图代码一行没写。先用方块跑通逻辑最后再换图这个顺序能让你少走一大半弯路。注意图片建议统一用 PNG 格式它支持透明通道不然你的飞机后面会顶着一个白方块看起来极其廉价。JPG 不支持透明别用。3. 核心架构拆解一个游戏循环加上三类对象3.1 游戏循环到底在循环什么几乎所有游戏的心脏都是同一个结构飞机大战也不例外while True: events pygame.event.get() # 1. 收事件 update() # 2. 更新状态 draw() # 3. 绘制画面 pygame.display.flip() # 4. 刷新屏幕这四步理解透了你就理解了游戏编程的本质。收事件是看玩家按了什么键、点没点关闭按钮更新状态是让所有物体按各自速度移动一格、判断有没有撞上绘制画面是把每个物体画到它当前坐标刷新屏幕是把画好的这帧显示出来。每秒重复 60 次物体就动起来了。这里有个新手最容易忽略的点pygame.display.flip()缺了你会看到一片黑屏。因为所有绘制都画在内存里的一个后台缓冲区上flip才是把它推到显示器上的那一下。我最早写的时候删过这一行对着黑屏debug了四十分钟一直以为是我图片路径写错了。帧率控制用clock.tick(60)它有两个作用一是锁住 60 帧二是返回上一帧耗时。没有它循环会以 CPU 极限速度狂跑你的飞机可能一秒钟飞过整个屏幕。为什么是 60因为大多数显示器刷新率就是 60Hz超过这个数字肉眼也看不出来白白浪费 CPU。3.2 三类对象我方、敌方、子弹用面向对象来组织是因为这三类东西有共性——都有坐标、都有图、都要移动、都要能碰撞。自然的做法是抽一个基类import pygame class GameObject(pygame.sprite.Sprite): def __init__(self, image_path, x, y): super().__init__() self.image pygame.image.load(image_path) self.rect self.image.get_rect() self.rect.x x self.rect.y y self.speed 0 def update(self): self.rect.y self.speedSprite这个基类提供了image和rect两个属性看起来不起眼但pygame.sprite.groupcollide()全靠这两个属性做碰撞检测所以命名不能改。这是个隐式约定很多教程不说新手自己改成self.pic就发现碰撞检测永远返回空怎么找都找不到原因。敌机直接继承把speed设成正数update里 y 坐标加它就往下飞了class Enemy(GameObject): def __init__(self, x, y): super().__init__(images/enemy.png, x, y) self.speed 3主角机要复杂点它得响应键盘、得有开火冷却class Hero(GameObject): def __init__(self, x, y): super().__init__(images/hero.png, x, y) self.speed 5 self.last_shot 0 self.shot_interval 200 # 毫秒 def shoot(self, bullet_group): now pygame.time.get_ticks() if now - self.last_shot self.shot_interval: self.last_shot now bullet Bullet(self.rect.centerx, self.rect.top) bullet_group.add(bullet)这里pygame.time.get_ticks()返回的是程序启动至今的毫秒数用它来控制射速比用帧计数器更稳。为什么因为如果哪天你为了让画面更流畅改成 120 帧用帧数当冷却的话射速会直接翻倍而用毫秒数就不会。这个细节我觉得挺重要很多教程用frame_count % 10 0那种写法一改帧率就全乱了。3.3 用精灵组管理而不是一堆列表不用精灵组行不行行你可以把所有敌机塞进一个 list然后自己写双层 for 循环两两判断碰撞。但坏处很直接当你有 100 个子弹、30 个敌机的时候你要比较 3000 次而且遍历时删除元素还要小心index越界问题。用精灵组就是enemies pygame.sprite.Group() bullets pygame.sprite.Group() # 子弹打到敌机两两碰撞 hits pygame.sprite.groupcollide(enemies, bullets, True, True)一行代码命中即双方移除。groupcollide内部做了矩形相交判断还帮你处理了删除。我在带人写的时候最常听到的感叹就是原来还能这样。这不是偷懒这叫站在库的肩膀上。你该花时间理解的是游戏逻辑而不是优化一个碰撞检测算法。4. 完整实操从零到能玩每个环节的参数怎么定4.1 搭建入口和主循环骨架先写settings.pySCREEN_WIDTH 480 SCREEN_HEIGHT 700 FPS 60 ENEMY_SPAWN_INTERVAL 500 # 敌机生成间隔毫秒 HERO_SPEED 5 ENEMY_SPEED 3 BULLET_SPEED -8屏幕用 480×700 而不是全屏是因为这个竖版比例接近老式街机玩起来手感最好而且测试时窗口小方便调试截图。敌机生成间隔 500 毫秒是个起点值下面会讲怎么调。主程序入口import pygame import sys from settings import * from game import Game def main(): pygame.init() screen pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption(飞机大战) clock pygame.time.Clock() game Game(screen) while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() game.update() game.draw() pygame.display.flip() clock.tick(FPS) if __name__ __main__: main()这份代码你直接抄没问题。pygame.init()负责初始化所有子模块忘记它会出现字体未初始化之类莫名其妙的报错。sys.exit()在退出前调用pygame.quit()这是标准做法避免进程残留。4.2 键盘控制为什么用 pressed 而不是按下事件主角持续移动靠的是按住状态检测不是单次按键事件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为什么不用event.type pygame.KEYDOWN因为那个只在你按下瞬间触发一次松开之前不会重复。用它写移动你按住方向键飞机只动一格就停了体验极差。get_pressed()返回的是当前这一刻哪些键处于按下状态配合每帧调用就是连续的平滑移动。那两个边界判断self.rect.left 0和self.rect.right SCREEN_WIDTH也别省。没有它们飞机能开出屏幕外然后你再也找不到它了。第一次遇到这种情况会特别懵明明程序没报错飞机就是没了。因为它的坐标还在变只是画在了你看不见的地方。4.3 敌机生成定时器不是越频繁越刺激敌机生成用定时事件pygame.time.set_timer(pygame.USEREVENT, ENEMY_SPAWN_INTERVAL)然后在事件循环里if event.type pygame.USEREVENT: x random.randint(0, SCREEN_WIDTH - 50) enemy Enemy(x, -50) enemies.add(enemy)pygame.USEREVENT是个自定义事件的数字编号set_timer让它每隔指定毫秒触发一次。敌机初始 y 设成 -50是在屏幕上方外面这样它能飞入画面视觉上更自然直接生成在 y0 会显得很突兀像凭空冒出来。生成间隔的调法有个经验500 毫秒开始看你撑得过 30 秒就说明太慢往下调到 350如果十秒就死往上加到 700。这个数值没有标准答案取决于你屏幕高度和敌机速度。我一般会先用 500 跑一遍感受一下手忙脚乱但不绝望的节奏那个值就对了。4.4 子弹与碰撞命中判定的完整链路子弹类很简单关键在于速度方向class Bullet(GameObject): def __init__(self, x, y): super().__init__(images/bullet.png, x, y) self.speed -8速度是负数因为它向上飞。pygame 的坐标系和数学坐标不同左上角是原点 (0,0)y 轴向下为正。所以向上飞是 y 减小。这个坐标系差异是新手必踩的坑你会看到有人写子弹往上飞但速度设成正数然后就纳闷子弹为什么往下掉。碰撞检测每帧做一次def check_collisions(self): hits pygame.sprite.groupcollide(self.enemies, self.bullets, True, True) self.score len(hits) * 10 for enemy in pygame.sprite.spritecollide(self.hero, self.enemies, True): self.hero_alive Falsespritecollide是单个精灵和一组精灵的碰撞用在主角撞敌机上。第二个参数True表示撞到后把敌机从组里删掉。整个链路是生成 → 移动 → 每帧检测 → 命中移除 → 加分/死亡。理解这条链你就能加任何东西——道具、护盾、多级子弹无非是在这条链上插节点。4.5 绘制顺序决定你能看见什么绘制顺序不是随便排的后画的东西盖在前面画的上面self.screen.blit(self.background, (0, 0)) # 背景 self.enemies.draw(self.screen) # 敌机 self.bullets.draw(self.screen) # 子弹 self.screen.blit(self.hero.image, self.hero.rect) # 主角 self.screen.blit(score_text, (10, 10)) # 分数永远在最上层把背景放第一位是因为它是底分数放最后因为要一直看得见。要是你把背景放到最后画恭喜整个屏幕全被背景盖住你又得到一片黑屏。这个错误和忘记flip是并列的黑屏两大元凶别问我是怎么知道的。5. 排查实录那些教程里从来不写的坑5.1 常见报错速查表现象可能原因解决方式黑屏但有窗口忘记display.flip()或背景画在最后检查绘制顺序加上 flip飞机图片后带白块用了 JPG 素材换成带透明通道的 PNG碰撞检测永远返回空rect属性被改名保证 Sprite 子类用self.rectModuleNotFoundError: pygamepip 装到了别的解释器用python -m pip install pygame报不是内部或外部命令安装时没勾 PATH重装并勾选 Add to PATH敌机不动或飞出画面speed 为 0 或坐标溢出检查速度值加边界处理按键移动一卡一卡用了 KEYDOWN 事件改用key.get_pressed()视频内存越跑越大每次循环都image.load素材只加载一次复用 Surface最后一条是我踩过最深的坑。早期我把pygame.image.load()写在生成对象的__init__里每生成一发子弹就读一次磁盘文件。打到后期屏幕上几十发子弹帧率直接掉到 20硬盘灯狂闪。解决方法是加载一次全局复用:# 在程序启动时加载 enemy_image pygame.image.load(images/enemy.png) class Enemy(GameObject): def __init__(self, x, y): super().__init__(enemy_image, x, y) # 传 Surface 而不是路径内存里同一张图只有一个副本所有敌机共享它。这个优化在游戏圈是常识但在 Python 小游戏教程里经常被忽略因为它不影响功能只在帧率上悄悄惩罚你。5.2 帧率不稳怎么查帧率是这类项目的生命线。我习惯在左上角打印实时 FPSfps_text font.render(fFPS: {int(clock.get_fps())}, True, (255, 0, 0)) screen.blit(fps_text, (SCREEN_WIDTH - 100, 10))clock.get_fps()返回最近一秒的平均帧率。如果它长期低于 55就该找原因了。排查顺序我一般是这么走的先看是不是每帧都在加载资源上面那个坑再看是不是精灵数量太多导致碰撞检测 O(n²) 爆炸最后看有没有画了特别大的背景图每次都缩放。这三条基本覆盖 90% 的掉帧情况。有一点要提醒不要把clock.tick(FPS)放在循环开头。放在末尾它限制的是这一帧总共花了多少时间放在开头会变成先等一个固定时间再干活帧率反而更不稳。这个区别很微妙但实测是有差别的。5.3 退出时的那点事程序退出前要pygame.quit()但坑在于——如果你在游戏循环里直接sys.exit()而不先pygame.quit()窗口有时候不会立刻关掉会卡在那儿几百毫秒。正确顺序是先清理再退出。测试的时候如果你频繁重启程序还可能在任务管理器里看到一堆残留的 python 进程都是因为没正常退出。另外sys.exit()抛的是SystemExit异常如果你外面包了try...except Exception它会把这个异常吃掉导致退不出去因为 SystemExit 继承自 BaseException 不继承 Exception实际上不会被吃但如果你写成except BaseException就会被吃。这个属于进阶细节新手了解即可写的时候单独处理 QUIT 事件最稳。6. 加点料让这个项目从能跑变成有意思6.1 难度曲线从匀速到动态加快固定间隔很快就会腻。加个随时间递减的生成间隔def get_spawn_interval(self): base 600 elapsed (pygame.time.get_ticks() - self.start_time) / 1000 interval max(180, base - int(elapsed * 12)) return interval每秒把间隔缩短 12 毫秒最低压到 180 毫秒。这样开局轻松越到后面越紧张第 30 秒左右达到最难。和匀速相比玩家的心流体验会好很多——有进步感也有压迫感。这个最低值兜底很重要不加的话到后面间隔会变成负数敌机会瞬间满屏。调这个曲线也看你的感觉。我个人喜欢30 秒见真章的节奏你也可以把系数调大做成硬核版或者调小做成休闲版。核心是把难度增长和游玩时长绑定而不是一上来就拉满。6.2 加音效和爆炸动画音效方面pygame.mixer一条龙。先初始化退出时也要对应关掉pygame.mixer.init() shoot_sound pygame.mixer.Sound(sounds/shoot.wav) explode_sound pygame.mixer.Sound(sounds/explode.wav) # 触发时 shoot_sound.play()音频文件我建议用 WAV因为它解码延迟低适合短促的音效WAV 文件体积大点没关系一个射击音效也就几十 KB。MP3 在 pygame 里播放短音效偶尔会有延迟感体验打了折扣。这是实打实的经验——你用一个几百毫秒的枪声配上一百毫秒的播放延迟感觉枪都打不准。爆炸动画本质是多帧图片按顺序播放。做法是先加载一串explode_0.png到explode_5.png然后维护一个当前帧索引每 5 帧换一张self.frame_index 0 self.frame_timer 0 def update(self): self.frame_timer 1 if self.frame_timer 5: self.frame_timer 0 self.frame_index 1 if self.frame_index len(self.frames): self.kill() # 动画放完从组里移除 else: self.image self.frames[self.frame_index]self.kill()是 Sprite 自带的方法把它从所有它所在的组里移除比手动group.remove(obj)干净。动画放完自动回收不会在内存里越积越多。6.3 分数、生命和游戏结束一个能反复玩的小游戏必须有完整的开始→游玩→死亡→重开状态。我用一个状态字符串来控制self.state running # running / gameover def update(self): if self.state gameover: keys pygame.key.get_pressed() if keys[pygame.K_r]: self.reset() return # 正常的游戏更新逻辑...死亡时把状态切成gameover绘制一个按 R 重开的提示按下 R 调用reset()清空所有精灵组、分数归零、状态改回running。reset()里一定要把精灵组清干净不然重开时上一局的敌机还飘在屏幕上。这个 bug 我遇到过好几次表现就是重开后屏幕中间已经有一堆敌机了排查了半天才发现是没清组。分数用font.render渲染成 Surface再blit到屏幕。字体初始化别忘了pygame.font.init()虽然pygame.init()会自动调它但单独初始化更保险。渲染文字有个性能点别在每帧都重新 render 一样的文字可以缓存一下只在分数变化时才重新渲染省下的 CPU 拿去画飞机。6.4 代码结构再优化事件总线当功能越来越多game.py会膨胀到几百行主循环里塞满各种判断。这时候可以引入一个简单的事件总线定义几个事件谁触发了谁去处理主循环只负责派发。这块属于项目做大了之后的必然演化新手不用一开始就上。但你要有这个意识游戏逻辑和渲染逻辑要分开update()里不该出现screen.blitdraw()里不该改数据。分开之后你在写测试或者调参数的时候会轻松很多。7. 我个人在这个项目里的一些体会写飞机大战这件事最值钱的从来不是最后那个能飞的飞机而是中间那些磕磕绊绊。我从最初的黑屏、白块、飞机飞出去回不来到后来能顺手用精灵组和各种定时器中间隔着的不是语法是对帧这个概念的理解。游戏编程和写脚本最大的区别就是你要在脑子里同时运转两条时间线一条是每个物体的状态在变一条是屏幕每 16 毫秒刷新一次。这个思维一旦建立你再看别的循环类的程序都会觉得通透。还有一个很个人的感受这个项目的天花板其实比看起来高。你把它当作业写完那是及格你把难度曲线、音效、动画、重开都做了那是良好如果你再把它拆成模块化的代码结构能复用、能扩展那就是你简历上一个真的能拿得出手的东西。很多人问我该做什么项目练手答案往往不是去找一个没人做过的新题材而是把手边最普通的东西做到别人做不到的完成度。这个项目后面其实还能继续长。你可以加道具系统打怪掉护盾和双发子弹可以加多种敌机有的直线飞、有的走 S 形、有的会开火还可以加个最简单的本地排行榜把最高分存到文件里下次打开还在。这些扩展每一个都不难难的是先有那个最基础、能跑通的骨架。骨架立住了剩下的都是往上搭积木。