Pygame 是我学习 Python 时第一次感受到“屏幕为我所用”的库。之前写的都是命令行工具,结果一行一行地打印倒还好,可凡是涉及到互动、画面、实时响应的东西,都只能干瞪眼。后来听朋友说 Pygame 画东西没那么神秘,我抱着试试看的心态跑通了官方示例,接着用了两三个晚上做出了第一个像样的接苹果小游戏。回头总结时才发现,真正难的不是 API 调用,而是理解游戏循环、事件、碰撞这些概念之间的协作关系。
这篇教程会用一条完整的“接苹果”游戏为主线,把 Pygame 从安装、建窗口、处理事件,到绘制图形、加载图片、播放音效,再到性能优化和排错,全部走一遍。你只要有 Python 的基础语法水平,比如会写循环、函数、if 判断,就能跟着做。做完之后,你对 Pygame 的认知不是停留在“看过文档”,而是真的能独立扩展出自己的小游戏。
1. 为什么选 Pygame:它跟游戏引擎不一样,但够用
很多人会纠结:现在做游戏不是应该直接上引擎吗?写成代码是不是太底层了?Pygame 的定位和 Unity、Godot 这类可视化引擎完全不同,理解这个区别,你就知道什么时候该用它。
1.1 Pygame 的定位:给 Python 脚本加上手和眼
Pygame 本质上是一个基于 SDL2 的 Python 封装库,它做的事可以概括为几件:创建一个显示窗口、向窗口上画图、读取键盘鼠标输入、播放声音、处理基本的图片和字体加载。它没有场景编辑器、没有动画状态机、没有资源管理器,这些都属于需要你自己组织的部分。
这种“从底层搭框架”的方式听起来麻烦,但好处是特别适合学原理。当你自己写游戏循环、自己管理物体列表、自己判断碰撞条件时,你会真正明白一个游戏的骨架是怎么构成的。这个认知一旦建立,后面换什么引擎都轻松。
1.2 安装与验证:两条命令起步
安装 Pygame 其实比安装很多库都顺利,因为它已经发布了非常全的预编译包。建议在干净的虚拟环境里操作,避免依赖冲突。
python -m venv venv venv\Scripts\activate pip install pygame装完怎么证明它真的能跑?我一般不建议直接跑空窗口脚本,而是运行官方自带的示例,这样能快速验证显卡、声音、输入设备是否都被正确识别:
python -m pygame.examples.astronoid这个命令会启动一个官方示例游戏,如果能看到画面并且能用键盘操作,说明安装没问题。装好之后,另一个经常被提到的点是可以查看版本:
import pygame print(pygame.version.ver)1.3 最小窗口:理解 Pygame 的初始化顺序
安装完成后,第一步是写出一个“能打开、能关闭”的窗口。别小看这个环节,很多人第一次卡住就是因为没有理解 Pygame 的程序结构。
import pygame from pygame.locals import * pygame.init() screen = pygame.display.set_mode((800, 600)) pygame.display.set_caption("我的第一个窗口") clock = pygame.time.Clock() running = True while running: for event in pygame.event.get(): if event.type == QUIT: running = False screen.fill((20, 20, 30)) pygame.display.flip() clock.tick(60) pygame.quit()这段代码里几个关键点要记牢:
pygame.init()会把 Pygame 的各个功能模块做一次整体初始化,包括 mixer、display、font 等。个别模块失败时会抛异常,所以大多数情况下直接调用没问题。pygame.display.set_mode会创建窗口;它返回的是一个 Surface 对象,所有画面都画在这个 Surface 上。pygame.event.get()必须放在循环内每次调用。它负责把事件队列里的数据取出来,如果一直不取,窗口管理器会觉得程序没响应,在 Mac 上就会转圈圈。pygame.display.flip()把内存中的画面真正刷新到屏幕上。clock.tick(60)控制游戏循环每秒最多跑 60 次,避免 CPU 被白白占满。
很多人会混淆display.flip()和display.update()。简单说,flip()是整窗口刷新,update()可以只刷新指定区域。小游戏里用flip()最省心。
2. 核心概念拆解:游戏循环、Surface、Rect 与坐标
把最小窗口跑起来之后,接下来要把 Pygame 的几个核心模型搞清楚。这些概念不是割裂的,它们组合起来才能塑造出一个可玩的游戏。
2.1 游戏循环:事件、更新、渲染,三步循环往复
游戏之所以看起来像“实时”的,不是因为程序在用多线程并发执行什么魔法,而是因为它在极短的时间里反复做三件事:处理输入事件、更新游戏状态、把状态画到屏幕上。
用生活类比就是翻书动画:每一帧画面都只是静态的,但一秒钟切换 60 次之后,人的眼睛就会脑补出连续运动的错觉。Pygame 的主循环,就是那个不断翻书的人。
在这个三步循环里,“更新状态”是最容易被初学者跳过的一步。很多人写游戏时直接把逻辑堆在事件处理里,比如在KEYDOWN事件里移动角色,结果发现角色移动跟帧率关系很大,按住方向键还会出现跳变。正确做法是:
- 一次性事件,比如按下某个键、点击关闭窗口,适合用事件队列处理。
- 持续状态,比如角色是否向左移动,适合用
pygame.key.get_pressed()每帧查询当前按键状态,然后在“更新”阶段修改位置。
这两者一定要分清,否则手感会很奇怪。
2.2 Surface 与 blit:所有绘图本质上都是像素拷贝
Pygame 里最重要的对象我认为不是精灵、不是角色,而是 Surface。Surface 可以理解为一个“能装像素的矩形画布”。
整个游戏里真正的主角就是屏幕 Surface,你通过set_mode拿到它。其他的 Surface 可以来自pygame.image.load()加载的图片,也可以来自pygame.Surface((宽, 高))自己创建的空画布。
把一张 Surface 画到另一张 Surface 上的动作叫blit,全称是 block image transfer,本质是像素块的复制。不要小看这个概念,理解了 blit 你就知道 Pygame 的性能关键在哪里:频繁对大区域做像素复制必然慢,所以资源、画面层次、刷新区域都要设计好。
我经常用这样一个比喻:屏幕是一块大的磁力板,图片是一块块磁贴。blit就是把磁贴贴到磁力板上的过程,而flip则把磁力板展示给观众看。
2.3 Rect 与碰撞:几乎大部分碰撞都是矩形碰撞
pygame.Rect是游戏开发里使用频率极高的对象。它表示一个矩形区域,存储四个值:x、y、width、height。
在“接苹果”游戏里,玩家接苹果的判定不需要精确到像素级别,只要苹果本身所在的矩形区域和玩家所在的矩形区域有重叠,就可以判定为“接到了”。这正是pygame.Rect.colliderect()做的事。
矩形碰撞的优点是计算量小:只要比较两个矩形的左、右、上、下边界就能判断出来,不需要挨个像素对比。也正是因为快,即使游戏里同时存在几十个物体,每一帧做几百次这种判断都没问题。
2.4 坐标系统:左上角是原点,y 轴朝下
新手的第一个反直觉点是 Pygame 的坐标系统:屏幕左上角是 (0, 0),x 轴向右增加,y 轴向下增加。也就是说,一个物体越往下,它的 y 值越大。
这跟我们数学里常见的直角坐标系相反,写代码时一定要记住。幸运的是,以“掉落物”为主的游戏里,让物体往下掉其实特别自然:只需要在更新状态时不断给 y 增加一个固定值,物体就等于在往下坠落。
3. 手写一个“接苹果”小游戏:代码与思考过程
概念讲再多,不落地都是空的。下面我们做一个真正能玩的小游戏“接苹果”:玩家在窗口底部控制一个小滑板;苹果从顶部随机位置掉落,接住加一分;同时会有炸弹混在苹果里掉下来,接住会扣生命值。游戏的基础版本没有图片、没有音效,只用色块和圆形,但这已经是完整的游戏逻辑了。
3.1 需求拆解:先把规则变成可计算的清单
写代码前先不要碰键盘,用文字把规则写清楚:
- 玩家滑板位于底部,可以用左右方向键控制移动。
- 滑板不能超出窗口边界。
- 每隔一段时间,窗口顶部生成一个物体,苹果或炸弹。
- 物体以一定速度下落。
- 物体与滑板碰撞时,如果是苹果则得分,如果是炸弹则扣生命。
- 窗口下方掉出屏幕的物体自动消失。
- 画面上方显示当前得分。
把规则拆成这样,再转换成代码结构就顺理成章了。你会发现,游戏最难的部分往往不是语法,而是“把模糊需求变成判断条件”。
3.2 完整代码:可以直接跑起来的基础版
下面这段代码是我精简后的版本,没有额外图片文件,复制就能运行:
import pygame import random from pygame.locals import * pygame.init() W, H = 800, 600 screen = pygame.display.set_mode((W, H)) pygame.display.set_caption("接苹果") clock = pygame.time.Clock() FONT = pygame.font.SysFont("arial", 28) PLAYER_W, PLAYER_H = 80, 16 player_x = W // 2 - PLAYER_W // 2 player_y = H - 50 MOVE_SPEED = 6 score = 0 life = 3 items = [] SPAWN_INTERVAL = 50 spawn_clock = 0 def spawn_item(): x = random.randint(20, W - 40) kind = random.choice(["apple", "bomb"]) speed = random.randint(4, 6) items.append([x, 0, kind, speed]) running = True while running: dt = clock.tick(60) for event in pygame.event.get(): if event.type == QUIT: running = False keys = pygame.key.get_pressed() if keys[K_LEFT]: player_x -= MOVE_SPEED if keys[K_RIGHT]: player_x += MOVE_SPEED player_x = max(0, min(W - PLAYER_W, player_x)) spawn_clock += dt if spawn_clock >= SPAWN_INTERVAL: spawn_item() spawn_clock = 0 player_rect = pygame.Rect(player_x, player_y, PLAYER_W, PLAYER_H) new_items = [] for it in items: it[1] += it[3] item_rect = pygame.Rect(it[0], it[1], 24, 24) if item_rect.colliderect(player_rect): if it[2] == "apple": score += 1 else: life -= 1 continue if it[1] < H + 20: new_items.append(it) items = new_items screen.fill((20, 20, 30)) pygame.draw.rect(screen, (80, 200, 80), (player_x, player_y, PLAYER_W, PLAYER_H)) for it in items: x, y, kind, _ = it if kind == "apple": pygame.draw.circle(screen, (220, 80, 60), (x + 12, y + 12), 12) else: pygame.draw.circle(screen, (90, 90, 100), (x + 12, y + 12), 12) score_img = FONT.render(f"Score: {score}", True, (255, 255, 255)) screen.blit(score_img, (10, 10)) pygame.display.flip() pygame.quit()如果你直接运行这段代码,会发现一个小问题:生命值减到负数也不会结束游戏。这是故意的,我把它留到下一节统一处理状态管理。
3.3 关键代码讲解:为什么这么写,而不是那么写
这里有几个实现细节,我建议不要随便改:
关于物体列表,我为什么用 new_items 而不是边遍历边 remove?
这是非常经典的 Python 坑。如果你在for it in items:循环体内调用items.remove(it),列表的元素位置会变化,导致漏掉元素或跳过判断。用new_items.append()收集保留的物体,最后整体替换掉,是又简单又不出错的做法。
关于碰撞矩形:为什么物体的矩形只有 24×24?
其实圆形画出来半径是 12,也就是直径 24,所以碰撞矩形用一个 24×24 的正方形。这样画出来的圆形正好包含在矩形内,判定范围与视觉感受一致。你不需要精确到每个像素的圆碰撞,矩形碰撞足够符合直觉,而且计算量也小。
关于得分文字的绘制:为什么每帧都调用 FONT.render?
这个版本代码里,我确实在每帧里重新生成得分文字的 Surface。这个成本其实很小,因为它只是一个很短的字符串。但在下一节优化时你会发现,如果画面里有大段的文本或者复杂图形,每次都重新渲染就变成性能问题了,到时候要做缓存。
3.4 手感调优:不要小看这几个参数
我能直接告诉你,光是把MOVE_SPEED、SPAWN_INTERVAL和物品下落速度调整几次,游戏的体验就会完全不同:
- 滑板移动速度太慢,玩家会觉得迟钝;太快又容易出现“看不清就过去了”的失控感。
- 生成间隔太短,屏幕上会同时出现大量物体,看起来像弹幕;太长则游戏节奏太慢。
- 苹果和炸弹下落速度如果相同,难度是均匀的;如果炸弹速度高于苹果,紧张感会明显提升。
我个人测试时的参数基准是这样的:
| 参数 | 初始值 | 调整方向 |
|---|---|---|
MOVE_SPEED | 5 | 手感发飘就降,追不上就升 |
SPAWN_INTERVAL | 50ms | 太难就调大,太闲就调小 |
| 物体下落速度 | 4~6 | 之后随分数逐步提升 |
| 滑板宽度 | 80 | 后期可缩窄增加难度 |
一个小技巧是:把生成间隔和下落速度做成动态函数,比如每得 5 分就把生成间隔减少 5 毫秒,这样游戏难度会自然递增,玩家也会被持续刺激到。这一版先不加,后面扩展时再动手。
4. 让游戏有质感:图片、文字、音效与游戏状态
上一版的“接苹果”能玩,但视觉上就像原型。为了让游戏有质感,我们需要把色块升级成图片,在画面里加文字提示,在碰撞时播放音效,还要增加游戏的开始、结束、暂停状态。
4.1 加载图片:一张苹果图片比一个圆更有说服力
使用图片替换色块非常简单。首先准备一张带透明背景的apple.png,然后:
apple_img = pygame.image.load("apple.png").convert_alpha() apple_img = pygame.transform.scale(apple_img, (28, 28))这里最容易被忽略的是.convert_alpha()。如果图片没有调用它,每次 blit 时系统会做额外的像素格式转换,速度明显变慢;更重要的是,如果不调用它,PNG 的透明通道往往无法正确显示,你会看到周围出现黑色或白色色块。
pygame.transform.scale()的作用是把大图缩小到游戏里实际使用的尺寸。千万别把一张几百像素的原图直接 blit 到屏幕上,那样既浪费显存又让物体尺寸失控。
加载完图片后,在渲染阶段替代圆形绘制:
screen.blit(apple_img, (x, y))blit的第二个参数是图片左上角的位置。也就是说,如果你的物体矩形是(x, y, 28, 28),只要传入(x, y),图片就会正好覆盖这个矩形范围。
炸弹图片的加载方式一样。因此,后面不管画面里有多少种物体,渲染逻辑都变成统一的样子:每个物体记录它自己对应的图片对象,然后循环里screen.blit(img, (x, y))。
4.2 中文显示:别让字体成为第一个“作品杀手”
Pygame 默认字体对中文的支持不太好,直接渲染中文字符串可能会出现方块。解决办法是加载一个中文字体文件。常见的做法是先找系统字体,找不到就退回默认字体:
import sys def get_font(size): font_path = pygame.font.match_font("microsoftyahei") if font_path is None: font_path = pygame.font.match_font("notosanscjksc") if font_path is None: font_path = pygame.font.match_font("arial") return pygame.font.Font(font_path, size)把上面这段函数放到项目里,遇到没有字体就用英文提示,这样在不同系统上的表现会稳定很多。
4.3 音效播放:碰撞反馈立刻就不一样了
听到声音的效果比看起来更明显。首先初始化音频模块,然后加载音效:
pygame.mixer.init() catch_sound = pygame.mixer.Sound("catch.wav") boom_sound = pygame.mixer.Sound("boom.wav")在碰撞判断处,接住苹果时播放catch_sound.play(),撞到炸弹时播放boom_sound.play()。这里有一个新手很容易踩的坑:某些系统上,如果你先pygame.init(),然后再加载音效文件,有时 mix 模块没有被正确初始化,导致无声或加载失败。稳妥写法是在播放之前单独显式调用pygame.mixer.init()。
背景音乐和音效不同,背景音乐适合用流式方式播放,因为文件往往比较大。Pygame 提供了独立的接口:
pygame.mixer.music.load("bgm.ogg") pygame.mixer.music.play(-1) # -1 表示循环播放音效适合用Sound,音乐用music,这个分工别搞混。
4.4 游戏状态管理:状态机是游戏逻辑的地基
一个好的小游戏不该只有“玩”这一个状态。至少要包含:开始、游戏中、游戏结束、暂停。这四种状态分别对应不同的画面和逻辑。
实现状态机最简单的办法是用一个字符串变量记录状态:
state = "start" # 主循环内部: if state == "start": # 显示标题画面 if keys[K_SPACE]: state = "playing" reset_game() elif state == "playing": # 正常的更新与判断 if life <= 0: state = "over" if keys[K_p]: state = "pause" elif state == "pause": if keys[K_p]: state = "playing" elif state == "over": if keys[K_r]: state = "playing" reset_game()注意,当状态不是playing时,物体的生成和下落的逻辑都不应该执行,否则你会在暂停画面里看到物体继续漂移,这直接暴露了状态机没做好。reset_game()的作用是把分数、生命值、物体列表全部恢复初始值,也就等于重新开局。
状态机看着简单,但它能让你以后扩展菜单、关卡、结算画面时不用推翻重写。我第一次写游戏时把所有逻辑挤在一个 while 循环里,加一个状态就头大。后来老老实实改成状态机,整个代码立刻清爽多了。
5. 性能优化与排错:实测中容易踩的坑
小游戏做到能玩之后,紧接着面临另一个问题:为什么有时卡顿、有时窗口无响应、有时图片透明区域是黑的?这一节我把实测中真正踩过的坑集中讲一遍。
5.1 卡顿的根源:你往往不知道什么操作是昂贵的
Pygame 的画面看起来简单,但它背后每帧都要完成:描画矩形、复制像素、绘制字体、检查碰撞、同步屏幕等。如果其中一个环节突然变慢,帧率就会掉,游戏手感就发飘。
我见过的最常见卡顿原因有两个:
- 在主循环里加载图片或声音。例如每次生成一个苹果,就现去
pygame.image.load()一次,这会严重拖慢游戏。资源必须在进入主循环前一次性加载完毕,循环内只做 blit,不加载。 - 每帧调用
pygame.transform.scale()对大图做缩放。缩放是一个耗操作,应该缩放好存进变量,循环里只使用结果。
记住一句话:主循环里唯一该做的昂贵操作是绘制本身,而不是准备绘制素材。
5.2 使用 convert 与预渲染:让 blit 尽量快
如果你用自己的代码画了一个 Surface,比如用Surface创建一个画布,然后在上边绘制了几个图形,那么让这个 Surface 和屏幕的像素格式保持一致,可以提高 blit 速度:
surface = pygame.Surface((100, 100)).convert()类似地,加载普通不带透明度的图片时用.convert();带透明通道的 PNG 用.convert_alpha()。这条规则记牢了,能减少很多不必要的性能开销。
文本渲染也尽量做缓存。比如得分文字只在分数变化时才FONT.render(),不要每帧渲染同一个字符串。下面的写法更合理:
if score != last_score: score_img = FONT.render(f"Score: {score}", True, (255, 255, 255)) last_score = score5.3 碰撞检测的简化:从矩形到圆,再到空间剪枝
矩形碰撞已经很快,但如果你希望苹果和滑板之间的碰撞更精确,可以用圆碰撞代替矩形碰撞。但全量检测所有物体之间的碰撞在物体数量多的时候会变成 O(n²) 复杂度,这时需要做简化。
简单的简化方法有两个:
- 先做矩形粗检测,只有矩形相交时再做带距离计算的圆检测。
- 把物体按先后顺序排列;在“接苹果”这类上往下掉落的游戏里,只检测每个物体是否在 y 轴方向靠近滑板区域,过滤掉离得远的物体。
这两个做法都不是魔法,只是提前排除不可能发生碰撞的对象。
5.4 窗口无响应、闪屏和按键不灵敏
窗口无响应最常见原因是没有在循环里调用pygame.event.get(),或者主循环里执行了太耗时的阻塞操作,比如下载文件、time.sleep()、弹窗输入。记住:游戏主循环不能有任何阻塞调用。
闪屏的出现,通常是因为没有在flip()前把整个窗口填充成背景色。Pygame 的 flip 只是把内存里的后缓冲显示出来,如果你这一帧忘画背景,就把上一帧残留画面和这一帧的新画面混在一起了。我的习惯是每帧开头第一件事就是screen.fill((背景色))。
按键不灵敏的问题,多半是在事件处理中使用了KEYDOWN来做移动。KEYDOWN只在按键按下的那一帧触发一次,但你希望的是“按住方向键就持续移动”,所以要用pygame.key.get_pressed()查询持续按下的状态。
这里我给新手一个判断表:
| 需求 | 推荐方法 |
|---|---|
| 检测“按下一次”的跳跃、暂停 | 事件循环KEYDOWN |
| 检测“按住后连续移动” | pygame.key.get_pressed() |
| 检测“松开按键” | 事件循环KEYUP |
| 检测鼠标点击 | 事件循环MOUSEBUTTONDOWN |
5.5 调试手段:遇到诡异现象怎么办
游戏开发里最折磨人的往往是“偶尔出现、复现困难”的 bug。我自己常用的排查办法是按阶段切小范围测试:
- 在循环里手动构造一个固定位置和速度的物体,看看它的轨迹是否符合预期。
- 用
print(it)把物体的位置、速度、碰撞结果实时输出出来,比凭肉眼猜快很多。 - 把 FPS 显示在窗口标题栏或画面上,便于观察卡顿是否出现。
pygame.display.set_caption(f"接苹果 | FPS: {clock.get_fps():.1f}")把 FPS 常驻显示是个好习惯。很多卡顿问题其实是你自己的循环设计导致帧率只有 30,而不是机器性能差。
6. 继续扩展:从“能玩”到“像一个作品”
当你把前面的代码和优化都消化完,“接苹果”已经是一个完整可玩的小游戏。但它离“像作品”还差几步。这里分享我后来实际加过的一些功能,给你做扩展参考。
第一是加入难度曲线。这个在第一节提到的动态参数就能实现。我用的规则是:每当分数达到 10、20、30,就把生成间隔缩短一点,把下落速度上限提高一点。这样游戏会在三分钟之内把玩家逼到极限,体验比一直匀速好很多。
第二是使用精灵类来组织代码。Pygame 提供了pygame.sprite.Sprite和pygame.sprite.Group,它们能帮你把每个物体的属性和行为封装成类,用group.update()、group.draw()取代手写循环。如果你发现自己的物体外面套了三层列表和字典,改写成精灵类会是更稳妥的方向。
第三是增加结算画面和简单动画。游戏结束后不要直接退出,而是显示“本次得分、最高分、按 R 重新开始”。最高分可以用一个文本文件保存,只需要在游戏结束时写一条open("record.txt", "w")之类的逻辑。一个能保存最高分的游戏,给人的完成度是完全不一样的。
第四是打包。如果你想让朋友直接运行,不想让对方安装 Python,可以用常见的 Python 打包工具把脚本打包成单个可执行文件。打包时记得把图片、字体和音效文件一起带上,路径尽量用相对路径,避免双击时找不到资源。
如果你还想继续往深了走,后续可以考虑:给苹果增加一点旋转动画、用粒子效果表现撞击、加入双人模式等。这些每一样都不复杂,但它们会逼着你把 Pygame 的绘图、动画、事件和状态管理再熟练一遍。
我在实际做这些小游戏的时候最深的感受是:Pygame 不会替你完成任何“游戏设计”的思考,但它把实现环节的门槛压得很低,低到只需一个周末就能让你做出一个真正能玩、能分享、能迭代的作品。希望这篇教程能帮你跨过从语法到游戏的那道坎。