ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

零素材可运行的 Python pygame 飞机大战实战

零素材可运行的 Python pygame 飞机大战实战

不少同学刚学完 Python 的列表、循环和函数,就陷入一种尴尬:语法都会,但写不出东西。想练手做大作业,网上找的飞机大战源码动辄十几个文件、一堆图片素材,克隆下来还跑不起来,光是配环境就先劝退了。这篇就把一个能直接运行、不依赖任何图片和音频素材的飞机大战完整讲一遍,从 pygame 的核心概念到每一行关键代码,再到常见报错排查,全部摊开讲。适合刚学完 Python 基础、想找一个课设或期末大作业项目的同学,也适合想快速补一补 pygame 图形编程的开发者。跟着走完,拿到的不只是一份能跑的源码,而是一套“游戏循环 + 精灵管理 + 碰撞检测”的可复用思路。

1. 为什么飞机大战是 Python 入门的黄金练手项目

1.1 从“语法都会但写不出东西”说起

学完 Python 基础之后最常见的问题,不是不懂语法,而是缺少一个能把零散知识点串起来的载体。写命令行计算器太单调,写爬虫又涉及网络和反爬,写 Web 后端要理解框架和数据库,门槛都不低。而飞机大战恰好卡在一个很舒服的位置:它需要你用到类、列表、循环、条件判断、随机数、时间控制,这些全是你已经会的;同时又引入了一个新的维度——实时图形渲染和事件驱动,让你第一次感受到“程序在不停地跑”是什么体验。

更关键的是,飞机大战的复杂度是可以自己控制的。最简单的版本,玩家一个方块、子弹一条线、敌机一个三角,一百多行就能跑起来;想加东西也随时有得加,敌机种类、爆炸动画、音效、道具、Boss 战、排行榜,每一个都是独立的小模块,可以循序渐进地往上叠。这种“可成长性”对一个练手项目来说非常重要,因为你能明显感觉到自己在进步,而不是卡在一个不动的大工程里。

从课设和期末大作业的角度看,飞机大战还有个实际优势:演示效果好。老师翻开你的项目,看到的是一个能动手玩的东西,有画面、有分数、有生命值,比一个打印在终端里的学生管理系统直观得多。只要你的代码结构不是全塞在一个函数里,注释写得清楚,答辩时能讲明白每个类是干什么的,拿个不错的分数并不难。

1.2 pygame 是什么,为什么选它

pygame 是一个基于 SDL 的 Python 多媒体库,专门用来做 2D 游戏和交互式图形程序。它把底层那些图形渲染、声音播放、键盘鼠标事件、计时器的细节都封装好了,你只需要关心“我要在哪个坐标画什么东西”。 pygame 不是游戏引擎,它没有可视化编辑器,也没有内置的物理系统,所有的逻辑都要自己写——这恰恰是它适合练手的原因,因为所有东西都暴露在你面前,不会让你产生“黑盒魔法”的错觉。

选择 pygame 还有几个实际考虑。第一,它是纯 Python 的第三方库,pip install一条命令搞定,没有编译环节,Windows、macOS、Linux 上表现基本一致。第二,它的 API 设计得比较朴素,Surface、Rect、Sprite、Group这几个概念掌握了,绝大部分 2D 需求都能覆盖,学习曲线很平缓。第三,资料多,遇到问题搜索关键词基本都能找到答案,这对自学很重要。

需要提前说明一点:pygame 并不适合做商业级游戏,它的渲染效率、资源管理、跨平台音频支持都有明显局限。但用来做课程设计、毕业设计的前期原型、或者单纯练手,完全够用。搞清楚工具的定位,比盲目追求“最强的技术栈”重要得多。

1.3 这个项目真正训练的能力

抛开“做出一个游戏”这个表面成果,飞机大战这个项目实际上在训练四项能力,而且这四项能力在后续做任何软件项目时都用得上。

第一项是状态管理。游戏有“准备中”“进行中”“已结束”三种状态,不同状态下的输入响应、渲染内容、逻辑更新都不一样。这不就是后端开发里订单状态机的雏形吗?把状态想清楚、把状态之间的转换条件写清楚,是工程能力的基本功。

第二项是对象生命周期管理。子弹出界要销毁,敌机飞出屏幕要销毁,被击中的敌机要销毁。什么时候创建、什么时候回收、回收之后还有没有别的地方在引用它,这些问题如果不想清楚,程序就会出现“内存里堆着几千个不可见对象”或者“引用了已经被删掉的对象导致报错”的情况。pygame 的Group和kill()机制给了你一个很直观的练习场。

第三项是帧与时间的分离。新手写游戏最爱犯的错,是把“移动速度”写成“每帧移动多少像素”,结果换一台刷新率不同的电脑,游戏速度就完全变了。正确做法是基于时间做计算。这个思路在做动画、做定时任务、做性能优化时是通用的。

第四项是调试能力。游戏出问题不像接口报错那样有清晰的堆栈信息,更多时候是“子弹不消失”“敌机穿墙”“分数不涨”,你得学会打日志、画调试框、二分定位。这套排查方法比任何语法知识都值钱。

2. 环境准备与版本说明

2.1 Python 与 pygame 的安装

本文示例使用的环境是 Python 3.8 及以上版本、pygame 2.x 系列。这两个都不是硬性约束,Python 3.6 以上基本都能跑,pygame 1.9.x 也能用,但 2.x 在 Apple Silicon 和高分屏上的兼容性更好,建议直接上 2.x。具体的小版本号不写死了,因为不同时间点pip拉到的版本不一样,你装完之后用下面的命令确认一下就行。

先确认 Python 环境:

python --version # 或者在某些系统上 python3 --version

如果能正常输出Python 3.x.x,说明解释器没问题。接下来安装 pygame:

pip install pygame

如果你的机器上同时装了多个 Python 版本,更稳妥的写法是用模块方式安装,避免把包装到了另一个解释器里:

python -m pip install pygame

国内网络下载慢的话,可以临时指定镜像源,这条命令只对本次安装生效,不会改动你的全局 pip 配置:

python -m pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple

安装完成后,验证一下版本和安装位置:

python -m pip show pygame

输出里会有Version、Location两行,Location指向的目录应该和你当前使用的 Python 的site-packages一致。如果Version显示的是 2.x,那就可以继续往下走了。

2.2 用一个官方示例验证图形环境

装完库不代表图形环境就正常。pygame 需要一个可用的显示设备,在纯命令行服务器、Docker 容器、WSL 的某些配置下,会报pygame.error: No available video device。pygame 自带了一批示例程序,用它来验证最直接:

python -m pygame.examples.aliens

这条命令会弹出一个窗口,里面是一个简易的射击游戏,能用方向键移动、空格射击。如果窗口正常弹出并且能操作,说明你的 pygame 安装、显示驱动、事件系统全部就绪,可以放心写自己的代码了。

如果这条命令报错,先别急着往下写代码,先把环境问题解决掉。常见的处理思路在后面的排查章节会详细展开。这里只需要建立一个意识:跑通官方示例是后续所有工作的前提,不要跳过这一步直接去调试自己的代码,否则你分不清是环境问题还是代码问题。

另外提一句,本文的完整示例不使用任何外部图片、字体、音频文件,所有图形都用pygame.draw系列函数直接画出来,字体用 pygame 内置的默认字体。这样做的好处是:你把代码复制到一个空文件里就能跑,不用到处找素材,也不会因为素材路径写错而报FileNotFoundError。等你把逻辑跑通了,再换成自己的美术资源,那是后话。

2.3 项目结构约定

本文先给一个单文件版本,方便你复制运行、快速看到效果;后面会讲怎么拆成多文件结构。单文件版本的文件名建议叫plane_game.py,放在一个干净的目录下:

plane_game/ └── plane_game.py

如果你的项目将来要交作业,建议从一开始就养成好习惯,目录结构规划成下面这样,后面拆分代码时直接往里填:

plane_game/ ├── main.py # 程序入口 ├── settings.py # 全局常量配置 ├── sprites/ │ ├── __init__.py │ ├── player.py # 玩家飞机 │ ├── bullet.py # 子弹 │ └── enemy.py # 敌机 ├── assets/ # 图片、音效(本文示例可为空) └── README.md # 运行说明

这个结构不是必须的,但它的好处很明确:常量集中在一处,改数值不用满文件搜;每个精灵一个文件,类与类之间的依赖关系一目了然;入口文件只负责组装和主循环,不掺杂具体业务逻辑。这些在单文件阶段看起来是“过度设计”,但在代码超过 300 行之后,你会非常庆幸自己提前拆了。

3. pygame 核心概念拆解

3.1 游戏循环:整个程序的骨架

所有实时游戏的核心都是一个死循环,通常长这样:

初始化 → 进入循环 → [处理输入 → 更新逻辑 → 渲染画面] → 循环结束 → 退出清理

这个循环每秒执行几十次,每一次称为一“帧”。把这三步拆开理解:处理输入,是把这一帧内发生的键盘、鼠标、窗口关闭等事件全部取出来,更新程序的状态;更新逻辑,是根据当前状态推进物理位置、生成新对象、检测碰撞、计算分数;渲染画面,是把当前状态画到屏幕上。三步的顺序不能乱,先处理输入再更新逻辑,最后才渲染,否则你画出来的永远是上一帧的画面。

在 pygame 里,事件必须被主动“取走”,如果你不在循环里调用pygame.event.get(),操作系统的事件队列会越积越多,窗口就会显示“无响应”。这是新手遇到的第一个坑,记住一句话:只要用了 pygame,主循环里必须每一帧都清空事件队列。

3.2 Surface 与 Rect:画布和位置

Surface是 pygame 里“一块可以绘制的图像区域”的抽象。屏幕本身就是一个 Surface,你用pygame.display.set_mode()拿到的那个对象就是它;你自己创建的每一个图形、每一张加载的图片,也都是 Surface。screen.blit(surface, (x, y))的意思就是“把 surface 这块图像贴到 screen 的 (x, y) 位置”。

Rect是矩形区域,用来表示位置和大小,它有x、y、width、height四个属性,还有一堆方便的派生属性,比如left、right、top、bottom、center、centerx、midbottom。这些属性既可以读也可以写,写rect.centerx = 240就等于把矩形水平居中。这个设计非常好用,因为游戏里大多数时候你关心的是“底部对齐”“中心对齐”,而不是左上角坐标。

一个必须强调的点:Surface 本身不知道自己在哪。位置信息是由外部的 Rect 携带的。所以你会看到 pygame 的精灵类里总是成对出现self.image(Surface)和self.rect(位置),这两个东西组合起来才是“屏幕上某个位置的一个图形”。

pygame.draw系列函数可以直接在 Surface 上画图形,常用的几个:

函数作用关键参数
pygame.draw.rect画矩形surface, color, rect, width
pygame.draw.circle画圆surface, color, center, radius
pygame.draw.polygon画多边形surface, color, points
pygame.draw.line画线段surface, color, start, end, width

其中width参数如果传 0 表示填充,传大于 0 的值表示只画边框。画飞机、敌机这种不规则形状,用polygon给一组顶点坐标最方便。

3.3 Sprite 与 Group:对象管理的正确姿势

Sprite是 pygame 提供的精灵基类,它本身几乎不做什么事,主要作用是提供一个统一约定:每个精灵都有image和rect,并且可以被加入Group。它的价值在于配合Group使用。

Group是精灵的容器,它替你解决了几件麻烦事。第一,批量更新:group.update()会依次调用组内每个精灵的update()方法。第二,批量绘制:group.draw(screen)会依次把每个精灵的image贴到它自己的rect位置上。第三,批量管理:精灵调用self.kill()会自动从所有它所在的 Group 中移除自己,不需要你手动遍历列表删除元素。

手动用列表管理精灵当然也可以,但你会反复写这样的代码:遍历列表判断是否出界、是的话list.remove(item)、然后还要担心遍历时删除元素导致的索引错乱。Group把这些脏活都做了,代码会干净非常多。

关于Group.update(),这里有个坑要提前说:它不带参数地调用每个精灵的update()。如果你的玩家类写成def update(self, keys),直接放进 Group 里调group.update()就会报参数数量不匹配。解决办法有两个,一是玩家不进 Group,单独调用player.update(keys);二是把keys存成类的属性,让update(self)无参。本文示例采用第一种,因为它对新手来说更直观——你能一眼看出玩家和其他对象在更新方式上的区别。

3.4 时间控制:clock 与 get_ticks

pygame.time.Clock用来控制帧率。clock.tick(60)写在循环开头,作用是“保证这一帧距离上一帧至少过了 1/60 秒,如果跑得比这快就等待,跑得慢就尽快返回”。它的返回值是这一帧实际消耗的毫秒数。

pygame.time.get_ticks()返回的是“从 pygame 初始化到现在经过的毫秒数”。这个函数是做定时逻辑的关键。比如射击冷却、敌机生成间隔、无敌时间,都应该用get_ticks()的差值来判断,而不是“每帧计数器加一”。

为什么不能用帧计数?因为帧率不一定是稳定的 60。机器卡顿时帧率掉到 30,你的“每 30 帧发射一颗子弹”就变成了“每秒钟发射一颗”,游戏手感完全变了。用毫秒判断则是稳定的:不管帧率多少,180 毫秒就是 180 毫秒。这个思路在做任何限流、防抖、定时任务时都适用。

4. 游戏整体设计

4.1 需求拆分

在动手写代码之前,先把“飞机大战”这个模糊的需求拆成可以逐个击破的功能点。不拆的话,你打开编辑器就会不知道该从哪一行开始写。

核心玩法层面:玩家可以上下左右移动;玩家可以发射子弹;敌机会从屏幕上方出现并向下移动;子弹击中敌机,敌机消失、加分;敌机撞到玩家,玩家掉血;玩家血量归零,游戏结束。

界面层面:需要显示当前分数、剩余生命值;需要有开始界面和结束界面;结束界面要能重新开始。

体验层面:玩家移动要跟手;子弹要有发射冷却,不能无限连射;玩家被撞后要有短暂无敌时间,否则一次碰撞可能瞬间扣光所有血;背景最好有滚动效果,让画面“活”起来。

非功能层面:代码要能一键运行,不依赖外部素材;主循环结构要清晰,方便后续加功能;数值要集中配置,便于调参。

把这些列出来之后,你会发现需要写的类其实只有三个:玩家、子弹、敌机。其余都是主循环里的逻辑。这个认知很重要,它把“一个游戏”缩小成了“三个类加一个循环”,心理压力立刻小很多。

4.2 类设计与职责划分

Player 类负责:维护自己的位置和外观、响应键盘输入改变位置、限制移动边界、记录生命值和无敌截止时间、记录上次开火时间。它不负责创建子弹,只负责告诉主循环“我现在可以开火了”。

Bullet 类负责:维护自己的位置、每帧向上移动、飞出屏幕顶部后自我销毁。它不知道自己打中了谁,碰撞由主循环统一判定。

Enemy 类负责:维护自己的位置、随机的外观尺寸和下落速度、每帧向下移动、飞出屏幕底部后自我销毁。它同样不关心自己被谁打中。

这种“对象只管自己的状态,交互由上层协调”的设计是最容易扩展的。以后你想加一种“会左右摆动”的敌机,只需要改Enemy.update(),不用动任何碰撞代码;想加“追踪弹”,只需要改Bullet.update()。如果反过来,让子弹自己去遍历敌机列表判断碰撞,代码很快就会纠缠成一团。

4.3 状态机设计

游戏状态用三个常量表示:

状态含义输入响应是否更新逻辑
STATE_READY开始界面空格/回车开始否(只滚动背景)
STATE_PLAYING游戏进行中方向键移动、空格射击是
STATE_OVER游戏结束空格/回车重开否(只滚动背景)

把状态单独拎出来,最大的好处是避免了那种“用一个布尔变量is_playing到处判断,最后加了暂停功能发现布尔不够用”的窘境。三个状态的转换关系也很清晰:READY 按开始键进 PLAYING,PLAYING 血量归零进 OVER,OVER 按重开键回 PLAYING。没有别的路径,写起来不会有遗漏。

5. 完整实战:从零实现飞机大战

5.1 常量配置层

所有可调数值集中放在文件顶部。这一步看起来简单,但它决定了你后面调参时的效率。如果速度、间隔、颜色散落在代码各处,改一个手感要翻遍整个文件。

# 文件路径:plane_game.py(顶部常量区) SCREEN_WIDTH = 480 # 窗口宽度 SCREEN_HEIGHT = 700 # 窗口高度 FPS = 60 # 目标帧率 PLAYER_SPEED = 6 # 玩家每帧移动像素 BULLET_SPEED = 12 # 子弹每帧上移像素 FIRE_INTERVAL = 180 # 两次射击的最小间隔(毫秒) ENEMY_SPAWN_INTERVAL = 600 # 敌机生成间隔(毫秒) PLAYER_INVINCIBLE_TIME = 1200 # 被撞后的无敌时长(毫秒) PLAYER_MAX_HP = 3 # 初始生命值 # 颜色常量,用 RGB 三元组表示 BLACK = (10, 10, 20) WHITE = (255, 255, 255) RED = (220, 70, 70) BLUE = (70, 130, 220) LIGHT_BLUE = (150, 200, 255) YELLOW = (250, 210, 60) GRAY = (160, 160, 160)

这里FIRE_INTERVAL = 180意味着最快每秒能打五发多一点,配合子弹速度 12,射速不会太离谱也不会太憋屈。ENEMY_SPAWN_INTERVAL = 600大约是每秒 1.6 架敌机,配合慢速下落,屏幕上同时存在的敌机大概五六架,难度适中。

这些数字没有标准答案,完全取决于你想要的手感。建议先按上面的值跑起来,玩两把之后再调。调的时候有个经验:一次只改一个参数。同时改速度和间隔,你分不清是哪个改动导致的体验变化。

5.2 玩家飞机

玩家类继承pygame.sprite.Sprite,在__init__里把自己的外观画好、位置摆好、状态初始化好。外观用一个带透明通道的 Surface,用三个多边形拼出一架飞机的形状。

# 文件路径:plane_game.py(玩家类) class Player(pygame.sprite.Sprite): """玩家飞机:负责移动、边界限制、生命值、开火冷却""" def __init__(self): super().__init__() self.image = self._build_image() self.rect = self.image.get_rect() # midbottom 表示“底边中点”,用它来定位最直观 self.rect.midbottom = (SCREEN_WIDTH // 2, SCREEN_HEIGHT - 20) self.speed = PLAYER_SPEED self.hp = PLAYER_MAX_HP self.last_fire = 0 # 上次开火的时间戳(毫秒) self.invincible_until = 0 # 无敌状态截止时间戳 @staticmethod def _build_image(): """用多边形拼一个飞机外观,不依赖任何图片素材""" surface = pygame.Surface((44, 56), pygame.SRCALPHA) # 机身:一个朝上的三角形 pygame.draw.polygon(surface, BLUE, [(22, 0), (40, 44), (4, 44)]) # 机翼:一个更宽的三角形叠在下半部分 pygame.draw.polygon(surface, LIGHT_BLUE, [(22, 24), (44, 52), (0, 52)]) # 尾焰:底部的小黄三角 pygame.draw.polygon(surface, YELLOW, [(16, 52), (22, 56), (28, 52)]) return surface def update(self, keys): """根据按键状态移动,并把位置限制在窗口内""" if keys[pygame.K_LEFT] or keys[pygame.K_a]: self.rect.x -= self.speed if keys[pygame.K_RIGHT] or keys[pygame.K_d]: self.rect.x += self.speed if keys[pygame.K_UP] or keys[pygame.K_w]: self.rect.y -= self.speed if keys[pygame.K_DOWN] or keys[pygame.K_s]: self.rect.y += self.speed # 边界限制:直接用 Rect 的派生属性,比手算坐标清楚 if self.rect.left < 0: self.rect.left = 0 if self.rect.right > SCREEN_WIDTH: self.rect.right = SCREEN_WIDTH if self.rect.top < 0: self.rect.top = 0 if self.rect.bottom > SCREEN_HEIGHT: self.rect.bottom = SCREEN_HEIGHT def can_fire(self, now): """判断当前时刻是否允许开火,允许则同时刷新冷却时间""" if now - self.last_fire >= FIRE_INTERVAL: self.last_fire = now return True return False def take_damage(self, now): """扣血并进入无敌状态;返回 True 表示游戏结束""" self.hp -= 1 self.invincible_until = now + PLAYER_INVINCIBLE_TIME return self.hp <= 0

有几个细节值得单独说。pygame.Surface((44, 56), pygame.SRCALPHA)里的SRCALPHA很关键,它让 Surface 带上每像素的 alpha 通道,这样画出来的三角形外面是透明的,贴到屏幕上才不会出现一个黑色方块底。如果你不加这个参数,会看到飞机周围一圈黑边,非常难看。

can_fire这个方法把“判断能不能开火”和“刷新冷却时间”合并在一起,是为了防止调用方忘记刷新时间导致冷却失效。这种设计思路叫“把不变量封装在方法内部”,在业务代码里同样适用:与其让调用方记得先判断再赋值,不如提供一个原子操作。

take_damage直接返回“是否死亡”,而不是让主循环去读player.hp判断。这同样是减少调用方的心智负担。

5.3 子弹

子弹比玩家简单得多,它只做两件事:向上移动、出界自毁。

# 文件路径:plane_game.py(子弹类) class Bullet(pygame.sprite.Sprite): """子弹:向上飞行,飞出屏幕后自动销毁""" def __init__(self, x, y): super().__init__() self.image = pygame.Surface((4, 14)) self.image.fill(YELLOW) self.rect = self.image.get_rect() # 传入的 (x, y) 是玩家机头的中心点 self.rect.centerx = x self.rect.bottom = y self.speed = BULLET_SPEED def update(self): self.rect.y -= self.speed # 完全飞出屏幕顶部就销毁自己 if self.rect.bottom < 0: self.kill()

注意这里没有用SRCALPHA,因为子弹就是一个纯黄色的实心矩形,不需要透明。少一层 alpha 混合,渲染会更快一点。这种微小的优化在对象少的时候看不出差别,但养成“不需要透明就不开透明”的习惯是好事。

self.kill()是Sprite自带的方法,它会把当前精灵从所有包含它的 Group 里移除。你不需要手动bullets.remove(bullet),更不需要在遍历 Group 的时候删除元素——kill()的设计就是为了避开这个陷阱。

5.4 敌机

敌机的特点是随机性:每次生成的尺寸和速度都不一样,让画面不那么呆板。

# 文件路径:plane_game.py(敌机类) class Enemy(pygame.sprite.Sprite): """敌机:从顶部随机位置出现,以随机速度下落""" def __init__(self): super().__init__() size = random.randint(30, 50) self.image = self._build_image(size) self.rect = self.image.get_rect() self.rect.x = random.randint(0, SCREEN_WIDTH - self.rect.width) self.rect.bottom = 0 # 初始位置在屏幕正上方之外 self.speed = random.randint(2, 5) @staticmethod def _build_image(size): """画一个倒三角作为敌机,中间加个圆做驾驶舱""" surface = pygame.Surface((size, size), pygame.SRCALPHA) half = size // 2 # 倒三角:底边在上,顶点在下 pygame.draw.polygon(surface, RED, [(half, size), (size, 0), (0, 0)]) # 驾驶舱 pygame.draw.circle(surface, (255, 170, 170), (half, size // 3), max(3, size // 8)) return surface def update(self): self.rect.y += self.speed # 完全飞出屏幕底部就销毁 if self.rect.top > SCREEN_HEIGHT: self.kill()

self.rect.bottom = 0这一行的意思是“让敌机的底边刚好在 y=0 这条线上”,也就是整架飞机都在屏幕上方看不见的地方。它下一帧开始往下落,慢慢出现在屏幕上。这比self.rect.y = -size更直观,因为你不必关心 size 具体是多少。

random.randint(0, SCREEN_WIDTH - self.rect.width)保证敌机初始的水平位置不会超出右边界。如果直接写random.randint(0, SCREEN_WIDTH),会有部分敌机只露出半个身子,虽然不影响游戏,但视觉上不够整洁。

5.5 主循环与碰撞检测

主循环是整个程序的骨架,把前面所有零件组装起来。为了代码清晰,先写一个工具函数用于居中绘制文字,再写主函数。

# 文件路径:plane_game.py(工具函数) def draw_text_center(screen, font, text, y, color): """在屏幕水平居中位置绘制一行文字""" surface = font.render(text, True, color) rect = surface.get_rect(center=(SCREEN_WIDTH // 2, y)) screen.blit(surface, rect)

font.render(text, True, color)的第二个参数是抗锯齿开关,传True文字边缘会更平滑。返回值是一个新的 Surface,然后我们用get_rect(center=...)把它居中定位,最后blit上去。这个三步式操作是 pygame 渲染文字的标准写法,记住就行。

下面是主函数,包含状态机、事件处理、逻辑更新、碰撞检测、渲染五部分:

# 文件路径:plane_game.py(主函数) def main(): pygame.init() screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("Plane Battle - Python pygame Demo") clock = pygame.time.Clock() # 字体:Font(None, size) 使用 pygame 内置默认字体,不依赖系统字体文件 font = pygame.font.Font(None, 30) big_font = pygame.font.Font(None, 56) # 星空背景:预先随机生成一批星星,每帧向下滚动 stars = [ [random.randint(0, SCREEN_WIDTH), random.randint(0, SCREEN_HEIGHT), random.choice((1, 2))] for _ in range(70) ] def create_world(): """创建一局游戏所需的全部对象""" player = Player() bullets = pygame.sprite.Group() enemies = pygame.sprite.Group() return player, bullets, enemies player, bullets, enemies = create_world() score = 0 state = STATE_READY last_spawn = 0 running = True while running: clock.tick(FPS) now = pygame.time.get_ticks() keys = pygame.key.get_pressed() # ---------- 1. 事件处理 ---------- for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: running = False elif state == STATE_READY and event.key in (pygame.K_SPACE, pygame.K_RETURN): state = STATE_PLAYING last_spawn = now elif state == STATE_OVER and event.key in (pygame.K_SPACE, pygame.K_RETURN): player, bullets, enemies = create_world() score = 0 state = STATE_PLAYING last_spawn = now # ---------- 2. 逻辑更新 ---------- if state == STATE_PLAYING: player.update(keys) bullets.update() enemies.update() # 开火 if keys[pygame.K_SPACE] and player.can_fire(now): bullets.add(Bullet(player.rect.centerx, player.rect.top)) # 生成敌机 if now - last_spawn >= ENEMY_SPAWN_INTERVAL: last_spawn = now enemies.add(Enemy()) # 碰撞:子弹 vs 敌机(两个都销毁) hits = pygame.sprite.groupcollide(bullets, enemies, True, True) if hits: score += sum(len(v) for v in hits.values()) * 10 # 碰撞:玩家 vs 敌机(无敌期间跳过判定) if now >= player.invincible_until: crashed = pygame.sprite.spritecollide(player, enemies, True) if crashed: if player.take_damage(now): state = STATE_OVER # ---------- 3. 渲染 ---------- screen.fill(BLACK) # 星空滚动 for star in stars: star[1] += star[2] if star[1] > SCREEN_HEIGHT: star[1] = 0 star[0] = random.randint(0, SCREEN_WIDTH) screen.set_at((star[0], int(star[1])), GRAY) bullets.draw(screen) enemies.draw(screen) # 玩家:无敌期间闪烁,用时间取模实现 if state != STATE_OVER: if now >= player.invincible_until or (now // 80) % 2 == 0: screen.blit(player.image, player.rect) # 分数与生命值 screen.blit(font.render(f"Score: {score}", True, WHITE), (12, 12)) screen.blit(font.render(f"HP: {player.hp}", True, RED), (12, 46)) # 开始界面 if state == STATE_READY: draw_text_center(screen, big_font, "PLANE BATTLE", 240, WHITE) draw_text_center(screen, font, "Arrow Keys / WASD to Move", 310, GRAY) draw_text_center(screen, font, "Hold SPACE to Fire", 345, GRAY) draw_text_center(screen, font, "Press SPACE to Start", 400, YELLOW) # 结束界面 if state == STATE_OVER: overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA) overlay.fill((0, 0, 0, 170)) screen.blit(overlay, (0, 0)) draw_text_center(screen, big_font, "GAME OVER", 250, RED) draw_text_center(screen, font, f"Final Score: {score}", 315, WHITE) draw_text_center(screen, font, "Press SPACE to Restart", 365, YELLOW) pygame.display.flip() pygame.quit() if __name__ == "__main__": main()

这段代码里有几处值得展开讲。

pygame.sprite.groupcollide(bullets, enemies, True, True)返回的是一个字典,键是被击中的子弹,值是这发子弹同时碰到的敌机列表(理论上可能有多架重叠)。所以用sum(len(v) for v in hits.values())才是真正的击落数。如果你直接写len(hits) * 10,在敌机叠在一起的时候会少算分数。这是个很容易忽略的细节。

spritecollide(player, enemies, True)的第三个参数True表示“碰撞发生后把撞到的敌机从 Group 里移除”。注意这里只有玩家这一个对象参与判定,所以用spritecollide而不是groupcollide。参数含义别记混:groupcollide是两组对撞,spritecollide是单个对一组。

无敌闪烁用(now // 80) % 2 == 0实现,意思是每 80 毫秒切换一次可见性,形成闪烁效果。这种“用时间戳取模做周期变化”的技巧在动画里非常常用,比维护一个额外的计数器状态要简洁。

星空背景用screen.set_at()直接写单个像素点。这个方法在绘制少量像素时很方便,但它不适合画大量内容,因为每次调用都有 Python 函数调用开销。这里只有 70 个星星,完全没问题。如果星星数量上千,就应该改用 Surface 批量绘制。

5.6 补上程序入口需要的状态常量

为了让上面的代码可以独立运行,文件最前面还需要导入模块和定义状态常量。完整的文件头部如下:

# 文件路径:plane_game.py(文件头部) import random import sys import pygame # 游戏状态 STATE_READY = 0 STATE_PLAYING = 1 STATE_OVER = 2

sys在这个版本里其实没有直接用到(我们用running变量控制循环退出,而不是sys.exit()),如果你想更简洁,可以把它删掉。保留它是因为很多同学后续会加“退出时清理资源”的逻辑,那时会用到sys.exit()。

5.7 运行与验证

把上面所有代码块按顺序拼进一个plane_game.py文件,然后在命令行里运行:

python plane_game.py

预期能看到一个 480×700 的窗口,深色背景上有缓慢下落的灰色星点,屏幕中央显示“PLANE BATTLE”标题和三行操作提示。按空格进入游戏,屏幕左下角出现Score: 0和HP: 3,一架蓝色小飞机出现在屏幕底部中间。

用方向键或 WASD 移动,飞机应该跟手并且不会移出窗口边界。按住空格,黄色子弹以固定间隔向上飞出,飞出顶部后消失。敌机(红色倒三角)从顶部随机位置下落,子弹打中后敌机立刻消失、分数加 10。敌机撞到玩家时,玩家血量减一,同时开始闪烁,闪烁期间再撞不会继续掉血。血量归零后弹出半透明遮罩,显示“GAME OVER”和最终分数,按空格重新开局,所有状态清零。

如果以上每一条都符合,恭喜你,核心玩法已经跑通了。如果某一条不对,先别改代码,回到后面的排查章节,按现象对照着找。

6. 手感与性能优化

6.1 为什么移动速度要按帧而不是按时间

本文示例里,玩家和敌机的移动速度写的是“每帧移动多少像素”。这在固定 60 帧的环境下没问题,但它有个隐患:如果某台机器性能不足,帧率掉到 30,游戏里所有东西都会慢一倍。严格的写法是用dt(每帧耗时)做乘法:

# 需要把 clock.tick() 的返回值利用起来 dt = clock.tick(FPS) / 1000.0 # 转成秒 player.rect.x += PLAYER_SPEED_PER_SEC * dt

这样无论帧率怎么变,一秒内移动的总距离都是固定的。对课设作业来说,按帧写完全够用,因为演示环境一般都还行;但如果你的项目要“在任何电脑上表现一致”,就应该改用基于时间的写法。这是个体现工程思维的点,答辩时讲出来是加分的。

6.2 Surface 复用与 convert_alpha

Enemy类在每次生成时都会调用_build_image()新建一个 Surface,并执行一次多边形绘制。如果你的游戏后期敌机生成频率很高,这就是一笔不小的开销。优化思路是把绘制结果缓存起来复用:

class Enemy(pygame.sprite.Sprite): _image_cache = {} # 类属性,所有实例共享 @classmethod def _get_image(cls, size): if size not in cls._image_cache: cls._image_cache[size] = cls._build_image(size) return cls._image_cache[size]

因为 size 只有 30 到 50 这 21 种可能,缓存之后每种尺寸只会绘制一次,后续直接复用 Surface。这种“用空间换时间”的思路在游戏开发里非常常见。

另外一个优化点是convert_alpha()。如果你从外部加载了带透明的 PNG 图片,加载后应该立刻调用image.convert_alpha(),它会把这图片转换成和当前显示模式匹配的像素格式,后续blit会快很多。本文示例全是运行时绘制的 Surface,用不上这个方法,但你换成真实素材之后一定要加。

6.3 难度曲线怎么设计

本文示例的敌机生成间隔是固定的 600 毫秒,玩久了会觉得一成不变。想做出难度递增的效果,最简单的做法是让间隔随分数缩短:

# 每得 100 分,生成间隔减少 40 毫秒,但最快不低于 220 毫秒 spawn_interval = max(220, ENEMY_SPAWN_INTERVAL - (score // 100) * 40) if now - last_spawn >= spawn_interval: last_spawn = now enemies.add(Enemy())

这段逻辑只有三行,但体验提升很明显。你会发现分数越高越紧张,游戏就有了“有节奏的挑战感”。

同样的思路可以用在敌机速度上,比如分数超过 200 之后,让Enemy.speed的上限提高。但要注意别让难度曲线太陡,第一分钟就让人手忙脚乱,玩家会直接关掉窗口。建议每次只调整一个维度,测过之后再调下一个。

6.4 碰撞精度:矩形碰撞与像素级碰撞

pygame.sprite.collide_rect是默认的碰撞检测方式,它用两个矩形的交集来判断,速度快但不够精确。对于飞机这种三角形外观、四角有大量空白区域的图形,矩形碰撞会出现“明明没碰到却判定为撞了”的情况,玩家会觉得憋屈。

pygame 提供了pygame.sprite.collide_mask,它基于 Surface 的 alpha 通道做像素级判定,只有实际不透明的像素重叠时才认为碰撞。用法是:

crashed = pygame.sprite.spritecollide( player, enemies, True, pygame.sprite.collide_mask )

代价是性能。像素级碰撞需要遍历比较区域内的像素,对象多了会明显变慢。实际项目里的常见做法是:先用矩形碰撞做一次粗筛,只有矩形重叠了才做像素级精筛。groupcollide和spritecollide都支持传入自定义的collided回调函数,你可以在里面实现这个两级判定。对本文的规模来说,直接用矩形碰撞完全够用,玩家也不太会有明显的不适感。

7. 常见问题与排查思路

游戏开发最难受的地方在于:没有报错信息,但画面就是不对。下面把新手最容易遇到的几种情况整理成表,再逐条展开。

问题现象常见原因解决思路
ModuleNotFoundError: No module named 'pygame'包没装,或装到了另一个 Python 解释器用python -m pip install pygame确保装到当前解释器
pygame.error: No available video device无图形界面环境,或显示驱动不可用在本地带桌面的环境运行,不要在没有显示服务的地方跑图形程序
窗口白屏 / 无响应主循环里没有调用pygame.event.get()每帧都必须清空事件队列
飞机周围有黑色方框创建 Surface 时没加pygame.SRCALPHA改成pygame.Surface(size, pygame.SRCALPHA)
子弹一直不消失,越积越多update里没判断出界,或忘了调用kill()检查if self.rect.bottom < 0: self.kill()
分数每次加的不对用len(hits)而不是统计敌机总数用sum(len(v) for v in hits.values())
文字显示成方块或乱码用了不支持中文的默认字体默认字体只渲染 ASCII,中文需指定字体文件
游戏速度忽快忽慢移动逻辑依赖帧数而非时间用dt做乘法,或确保clock.tick(FPS)正常执行
按空格没反应用的KEYDOWN事件判断,但忘记按住持续开火的需求移动和射击用get_pressed(),一次性动作用KEYDOWN

关于空格射击这一条要多说两句。本文示例是把射击放在逻辑更新里用keys[pygame.K_SPACE]判断的,所以按住空格会持续发射。如果你把射击写在event.type == pygame.KEYDOWN分支里,那么按住空格只会发射一次(操作系统的按键重复延迟可能让你误以为连发了,但那个延迟通常有 300-500 毫秒,手感很怪)。理解“事件”和“状态查询”的区别,是 Python 游戏输入处理的关键分水岭。

关于中文显示,pygame 的pygame.font.Font(None, size)用的是内置的默认字体,它只包含拉丁字符,渲染中文会变成方块或者干脆什么都不显示。要显示中文,有两个办法。一是用系统中文字体,比如pygame.font.SysFont("microsoftyahei", 24),但这个字体名在 Linux 和 macOS 上不一定存在,跨平台会很麻烦。二是把.ttf字体文件放到项目的assets目录下,用pygame.font.Font("assets/myfont.ttf", 24)加载,这种方式最可靠,因为字体文件跟着项目走。本文示例为了“零依赖”,所有界面文字都用了英文,这是有意的取舍,你换成中文时记得改字体加载方式。

关于打包成 exe,很多同学做完想发给别人或者交作业,会用到pyinstaller。基本命令是pyinstaller -F -w plane_game.py,-F打成单文件,-w表示不带控制台窗口。这里要提醒的是,如果项目里有assets目录,打包时需要额外处理资源路径,否则程序启动后会找不到图片而闪退。常用的做法是用sys._MEIPASS判断运行环境,把资源路径拼出来。这部分内容超出本文范围,但你如果打算交作业,建议提前测一次打包,别等到答辩当天才发现打不开。

关于性能突然下降,最常见的元凶是对象泄漏:子弹或敌机没有被正确销毁,屏幕上虽然有几百个不可见的对象,但Group.draw()和碰撞检测依然在遍历它们。排查方法很简单,在循环里打印一下组的大小:

# 调试用,正式提交前记得删掉 if pygame.time.get_ticks() % 1000 < 20: print(f"bullets={len(bullets)}, enemies={len(enemies)}")

正常运行时这两个数字应该在个位数到几十之间波动。如果看到数字持续上涨到几百上千,那就一定是销毁逻辑写漏了。

8. 工程化建议与扩展方向

8.1 把单文件拆成模块

单文件写到两百多行还能看,但如果继续加功能,很快会变成“滚动条地狱”。拆分的原则是按职责切,不按代码量切。建议的拆法在前面已经给过目录结构,这里说几个拆分时的具体注意点。

循环依赖是拆模块时最容易踩的坑。比如player.py

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进