1. 从一条弹幕说起:为什么我劝你先用 Python 写个飞机大战
说个真事儿。前段时间帮一个学弟看简历,他说自己"精通 Python",结果面试官让他现场写个对象在屏幕上动起来,他憋了二十分钟没写出来。问题出在哪?不是语法不会,是没做过能把语法串起来的完整项目。for循环、class、if判断这些他都懂,但一到"怎么让一架飞机从下往上飞、还能打子弹、还能撞爆炸"就卡壳了。
飞机大战这个题材,在 Python 入门圈里几乎是标配练手项目,原因很简单:它把面向对象、事件循环、坐标计算、碰撞检测、状态管理这几个核心概念全塞进了一个你能看见、能玩、有反馈的小框里。你写完它,再看 Python 基础语法,感觉完全不一样——以前是背知识点,现在是知道这些知识点在什么场景下用。
我打算用这篇文章,把这几年带人写飞机大战踩过的坑、绕过的弯、以及那些教程里从来不说的细节,一次性讲清楚。你会看到完整的实现思路、可以直接跑的代码骨架、参数怎么调、性能为什么掉帧、以及最关键的——哪些地方新手一定会写错。适合谁看?如果你能看懂def和class的区别,装好了 Python 却不知道该拿它干嘛,那这篇就是给你准备的。如果你已经写过一两个小游戏,也可以从第四节的问题排查里挑点东西走。
先说清楚技术选型。网上搜"飞机大战 Python",你会看到两个主流方向:一个是pygame,一个是tkinter。还有一部分人用cv2(OpenCV)配合鼠标操作做人机交互,但那是另一个流派,本文不做展开。我选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确认能输出版本号。然后装 pygame:
pip install pygame如果你在国内,下载慢或者卡住,可以换镜像源,这个操作是常规做法:
pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple装完验证一下,跑这段:
import pygame print(pygame.ver)能打印出版本号就成了。如果报ModuleNotFoundError,八成是你电脑上有多个 Python,pip装到了另一个版本里。这时候用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.py:
SCREEN_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,是在屏幕上方外面,这样它能"飞入"画面,视觉上更自然;直接生成在 y=0 会显得很突兀,像凭空冒出来。
生成间隔的调法有个经验: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.rect |
ModuleNotFoundError: pygame | pip 装到了别的解释器 | 用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 帧率不稳怎么查
帧率是这类项目的生命线。我习惯在左上角打印实时 FPS:
fps_text = font.render(f'FPS: {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.blit,draw()里不该改数据。分开之后,你在写测试或者调参数的时候会轻松很多。
7. 我个人在这个项目里的一些体会
写飞机大战这件事,最值钱的从来不是最后那个能飞的飞机,而是中间那些磕磕绊绊。我从最初的黑屏、白块、飞机飞出去回不来,到后来能顺手用精灵组和各种定时器,中间隔着的不是语法,是对"帧"这个概念的理解。游戏编程和写脚本最大的区别,就是你要在脑子里同时运转两条时间线:一条是每个物体的状态在变,一条是屏幕每 16 毫秒刷新一次。这个思维一旦建立,你再看别的循环类的程序,都会觉得通透。
还有一个很个人的感受:这个项目的天花板其实比看起来高。你把它当作业写完,那是及格;你把难度曲线、音效、动画、重开都做了,那是良好;如果你再把它拆成模块化的代码结构,能复用、能扩展,那就是你简历上一个真的能拿得出手的东西。很多人问"我该做什么项目练手",答案往往不是去找一个没人做过的新题材,而是把手边最普通的东西做到别人做不到的完成度。
这个项目后面其实还能继续长。你可以加道具系统,打怪掉护盾和双发子弹;可以加多种敌机,有的直线飞、有的走 S 形、有的会开火;还可以加个最简单的本地排行榜,把最高分存到文件里,下次打开还在。这些扩展每一个都不难,难的是先有那个最基础、能跑通的骨架。骨架立住了,剩下的都是往上搭积木。