运一次"上下左右控制一个方块躲避障碍"这样的小游戏,从新手到能跑通的完整过程,你会踩哪些坑、需要懂哪些原理,我今天一次性讲清楚。
1. Pygame到底是什么:它不是引擎,而是一套媒体工具箱
1.1 Pygame的来历与定位
Pygame 是一个基于 SDL 库的 Python 第三方库,专门用来处理游戏开发里最常见的那些事:窗口创建、图像绘制、键盘鼠标输入、声音播放、碰撞检测基础数据结构。它最早由 Pete Shinners 在 2000 年左右发起,目的很朴素——让 Python 程序员不用去折腾底层窗口 API 和图形接口,就能直接做 2D 游戏。
这里有一个很多新手一开始搞不清的点:Pygame 不是一个游戏引擎。你打开 Unity 或者 Godot,里面自带场景、节点、物理引擎、动画状态机;Pygame 不提供这些东西,它只给你一堆"零件"——一个可以画图的窗口、一堆处理输入的 API、一组几何结构体。你要做的"引擎",就是一个 Python 脚本里的循环。恰恰因为这样,Pygame 特别适合用来理解游戏的核心运行机制:游戏 = 循环 + 状态 + 输入 + 渲染。
1.2 为什么说Pygame是第一款游戏的最佳选择
市面上能用来写 2D 游戏的 Python 库不少,Arcade、Pyglet、Tkinter 的 Canvas 也能画图,甚至有人用 Kivy 做小游戏。但我给纯新手的建议永远是:先学 Pygame。原因很简单:
- 资料多。搜"Python 游戏开发",十篇文章里有八篇是 Pygame 的,遇到问题更容易找到解决方案。
- 门槛低。核心 API 就几十个,安装完你就能上手画第一个矩形,不用理解复杂的场景树和组件系统。
- 结构自由度大。正因为没有引擎约束,你必须自己设计游戏状态、自己决定如何组织代码,这反而逼你把基础打好。
1.3 Pygame 2.x相比1.x的最大变化
很多人现在搜到老教程,还会遇到pygame.sprite模块里那些老写法,但要注意 Pygame 2.x 在主循环、图像转换、文本渲染上都有明显改进。最大的体感差异有三个:
pygame.display.set_mode()不再强制要求立即传入分辨率,可以后续再动态调整窗口大小。- 内置了对高清屏 DPI 缩放的基础支持(虽然仍不完美,后面我会专门说)。
- 性能上比 1.x 好了不少,
Surface.convert()和convert_alpha()的建议依旧有效,但普通小游戏里不优化也不会卡。
另外要提一嘴pygame-ce(社区版),它是原 Pygame 停更维护后由社区分叉出来的活跃版本。如果你在 2025 年才开始学,我其实更推荐直接用pygame-ce,API 基本兼容,修复了大量老问题,安装名是pygame-ce,导入仍然是import pygame。
2. 安装与环境准备:多花五分钟就少崩溃一整晚
2.1 各平台安装方式与Python版本坑
安装本身不难,但很多人的第一个坑恰恰出在这里。我用的是 Windows,直接:
pip install pygame pygame-ce如果你在 macOS 或 Linux 上,流程也差不多。但有几个细节值得注意:
- 尽量不用系统自带的 Python。macOS 自带的 Python 是 2.x 时代遗留的,装了也没意义;Windows 商店版的 Python 有时路径有问题,pip 装包倒是可以,但后面 PyInstaller 打包时容易出幺蛾子。建议直接去 python.org 下载官方版本,选 3.10 到 3.12 之间的版本都行。
- 检查 pip 是不是对应版本。同一个命令行里敲
python --version和pip --version,确认两者来自同一个环境。经常有人开了虚拟环境然后在全局 pip 安装,装完发现 import 不到。 - Linux 下可能出现音频设备找不到、窗口打不开的问题,常规解决办法是安装 SDL 依赖,Ubuntu 系一般执行
sudo apt install libsdl2-2.0-0 libsdl2-mixer-2.0-0 libsdl2-image-2.0-0。
装完验证方式很简单,在终端里跑:
python -c "import pygame; print(pygame.version.ver)"如果打印出类似2.6.1的版本号,就说明环境没问题。
2.2 那个著名的"窗口一闪而过"事故
这是 Pygame 新手区提问率最高的问题:脚本运行了,一个黑色窗口闪了一下马上消失,没有报错,跑完就退出。本质原因特别简单——你没有让程序进入持续循环。窗口默认是"画出后立刻执行完脚本就销毁"的,想让窗口停留,你必须写一个while循环阻塞住主线程。
很多人会在这里直接抄网上的模板,然后疑惑为什么别人代码能停留。我建议你亲手理解一下这个问题的原理:set_mode 创建的窗口,生命周期与 Python 进程绑定,进程没退出,窗口就在;进程一结束,窗口立刻销毁。你需要在代码里留一个循环结构让它"挂住"。后面第三部分我会专门写这个循环,这是整个游戏开发最核心的骨架。
2.3 给素材文件建一个干净的目录结构
这是我做第一个项目时没人告诉我的事。Pygame 项目一开始可以只有一个.py文件,但当你开始加载图片、声音、字体时,散乱的路径会让你后患无穷。最稳的项目结构不管以后写大游戏还是小玩意儿都受用:
my_game/ ├── main.py ├── settings.py ├── assets/ │ ├── images/ # 图片素材 │ ├── sounds/ # 音效音频 │ └── fonts/ # 字体文件 └── modules/ ├── player.py └── enemies.py路径问题直接关系到最后打包,我放在第 6 部分细讲,但目录结构从第一天就养成习惯,后面省事非常多。
3. 核心循环:游戏开发的第一性原理
3.1 主循环:游戏本质是一个"无限循环 + 条件退出"
不管多复杂的游戏,剥到最里面都是这样一个骨架:
import pygame pygame.init() screen = pygame.display.set_mode((800, 600)) pygame.display.set_caption("我的第一个游戏") running = True while running: # 1. 处理输入事件 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # 2. 更新游戏状态 # 3. 绘制画面 screen.fill((0, 0, 0)) pygame.display.flip() pygame.quit()理解这段代码,你就理解了游戏工业化开发里常说的"游戏循环"。游戏之所以和你平时写的那种"从上到下跑一遍就结束"的程序不同,是因为它对实时性有要求,需要每秒几十次地重复做三件事:
- 收集输入:用户按了哪个键、点了哪里、窗口有没有被关闭。
- 更新状态:根据输入和游戏规则去算新位置、新血量、新得分。
- 渲染画面:把最新的状态画到屏幕上。
running = False是唯一合法的退出方式。这里有一个常见误解:新手以为要在代码里写break来退出游戏,实际上对于while循环,设置标志位让循环条件失效,然后在while之后统一调pygame.quit(),永远是最可控的退出方式。因为如果中途直接 break,窗口资源的释放时机就很尴尬,容易在退出时有杂音或窗口残留。
3.2 Surface 与 blit:屏幕上的一切都是"贴像素"
我刚学 Pygame 时,最困惑的概念是Surface。后来我把它理解成"一张能画东西的画布",一切瞬间清楚了。窗口是一个 Surface,你 load 进来的图片也是一个 Surface,甚至一行文字、一个矩形,都会被 Pygame 渲染成一个小 Surface。
那动词,也就是绘制,在 Pygame 里叫做blit。它的意思是"把一块 Surface 的像素原样贴到另一块 Surface 上"。比如我把角色图片贴到窗口的正中央:
hero_image = pygame.image.load("assets/images/hero.png") screen.blit(hero_image, (400, 300))第二参数是一个元组坐标,代表这张图片要贴到窗口的哪个位置。注意那是图片的左上角位置。一帧一帧地改变这个坐标,再重新 blit,角色就动起来了。这就是"动画"的全部秘密——不是真的有动画,只是快速切换静帧。
Surface上还有一个常用的方法叫convert_alpha(),它的作用是把图片转换成和窗口一样的像素格式,这样 blit 时不用做格式转换,速度快很多,透明通道也能保留。这是初步优化最有效的一行代码。
3.3 Rect 与坐标系统:左上角原点,向下为正
Pygame 的坐标系和你在数学课上学的不一样,原点在左上角,x 轴向右增长,y 轴向下增长。也就是说,(0, 0)是屏幕左上角,(100, 50)是往右 100 像素、往下 50 像素。对于刚接触的读者,这个方向性最容易搞混。一个简单记忆法:想一想文本在 Word 里是怎么排的,电脑屏幕上的坐标就是这个逻辑。
Rect是 Pygame 里最实用的数据结构之一。它本质是一个"矩形区域",保存了x, y, width, height四个值,同时提供了大量方便属性:left, right, top, bottom, center, centerx, centery等等。
为什么说它实用?因为游戏里几乎所有的碰撞检测、位置判断,都是在和 Rect 打交道。举个判断玩家是否超出屏幕左边界的例子:
if player_rect.left < 0: player_rect.left = 0这样写比if player_rect.x < 0: player_rect.x = 0更直观,因为left这个语义本身就是"左边界坐标"。Rect 还提供move(x, y)方法返回移动后的新矩形,以及colliderect()方法判断两个矩形是否相交。这些都是后面碰撞检测的基础。
3.4 Clock 与 FPS:为什么你的游戏一会快一会慢
新人写的第一版游戏往往不控制帧率,结果在性能好的电脑上角色飞一样地跑,性能差的电脑上慢吞吞。这是因为游戏状态更新的次数取决于循环的迭代速度,而在不同机器上,循环速度并不一样。
解决办法是给游戏加上Clock节拍器:
clock = pygame.time.Clock() while running: # ... clock.tick(60)tick(60)的意思是:让这一帧循环最多持续 1/60 秒,如果这一帧处理得太快,就原地等待一下,保证每秒最多执行 60 次循环。这样游戏速度就和硬件无关了。
但这里还有一个进阶问题:如果你的循环里某个操作开销很大,一帧实际耗时超过了 1/60 秒,那tick(60)并不会阻止你变慢,它只是限速不加速,所以性能优化永远是必要的。等游戏复杂度上来后,你还会用到增量时间(delta time),把角色的移动速度写成"每秒 200 像素"而不是"每帧 5 像素",Pygame 2.x 推荐的做法是帧率乘以时间增量。第一批开发时,先让tick(60)控住速度,已经足够应付绝大多数小游戏了。
4. 事件处理:键盘、鼠标,以及那个"事件锁"问题
4.1 pygame.event.get() 和 poll() 的差异
游戏循环第一步是"处理输入",Pygame 里输入的表现形式就是事件(Event)。事件是什么?你按一个键、移动鼠标、点关闭窗口按钮,操作系统都会产生一条消息,Pygame 把这些消息翻译成事件对象,塞进一个队列里。你得把它们取出来处理。
最常用的取事件函数是:
for event in pygame.event.get(): if event.type == pygame.QUIT: running = False它一次取出当前队列里的所有事件。但我看很多人没注意过另一个函数pygame.event.poll(),它的区别是:每次只取一个事件,如果队列为空就返回一个pygame.NOEVENT类型的事件。
什么时候用 poll?当你不想一次性取空队列里所有事件,而是想要"精确控制每次循环只处理一个事件"时,可以用 while 循环包住它。实践中大多数游戏用get()就足够了,但理解 poll 能帮你理清事件队列的逻辑:get 是"把积水一次抽干",poll 是"一滴一滴接"。
4.2 键盘事件与连续按键:两种思路
处理键盘输入有两种完全不同的思路,新手很容易混淆。
第一种是事件驱动,也就是你按下某个键的瞬间,KEYDOWN事件进入队列;松开时,KEYUP事件进入队列。适用于:菜单选择、跳跃触发、打开背包等"一次性动作"。
第二种是状态查询,用pygame.key.get_pressed()获取当前所有键的按下状态,返回一个元组,索引对应键码。适用于:左右移动、持续加速等"按住才持续生效"的操作。
举个例子,移动角色:
keys = pygame.key.get_pressed() if keys[pygame.K_LEFT]: player_rect.x -= 5 if keys[pygame.K_RIGHT]: player_rect.x += 5这里有个很多人踩过的坑:如果把左右移动也写在事件驱动的for event循环里,你会发现按一下只走一格,移动手感就像打字机一样,一顿一顿的。原因就是KEYDOWN只在按下的那一帧产生一个事件,不会一直发。所以判断"是否按住"必须用get_pressed(),判断"按下的一瞬间"才用KEYDOWN事件。区分好这两个场景,你的手感会立刻上一个档次。
4.3 "事件锁"到底锁的是什么
热词里出现了一个很有意思的词——"事件锁"。很多人在做游戏时发现:一个按键按下去,程序里同一个动作被触发了两次以上,或者游戏状态在按下和松开之间极端闪烁。这其实就是典型的事件竞争问题,也就是游戏开发里需要"事件锁"的场景。
举一个最简单的例子。你想做一个按空格暂停的功能:
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: paused = not paused表面看没问题,但如果某帧里用户快速按了两下空格,事件队列里可能排了两个KEYDOWN,你的逻辑就会瞬间执行两次取反,结果暂停等于没暂停。这种"按下动作应该只生效一次,但物理上多触发了一次"的问题,就需要加"锁"。
两个常见的锁机制:
- 状态锁:用帧数或时间戳记录上次触发的时间,短时间内不允许再次触发。比如
last_press_time + 200毫秒 之后才允许下一次触发,防抖。 - 状态翻转锁:在
KEYDOWN里执行取反之后,立刻设一个space_handled = True标志,直到KEYUP事件出现才把它设回 False。这样同一个按键的按住过程中,无论KEYDOWN出现多少次,只处理第一次。
space_handled = False for event in pygame.event.get(): if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: if not space_handled: paused = not paused space_handled = True if event.type == pygame.KEYUP and event.key == pygame.K_SPACE: space_handled = False这就是游戏里"事件锁"的直观解释。它锁的不是事件本身,而是防止一个物理动作在多帧中产生重复逻辑。做格斗游戏、射击游戏时这类需求会更多,提前理解这个概念可以省掉不少 debug 时间。
4.4 自定义事件与窗口事件
pygame.event.Event()允许你自己制造一个事件对象,再用pygame.event.post()把它丢回事件队列。这个能力看起来冷门,但在做定时事件(比如每 5 秒生成一个敌人)时非常好用。你可以设置一个自定义定时事件:
SPAWN_ENEMY = pygame.USEREVENT + 1 pygame.time.set_timer(SPAWN_ENEMY, 5000) while running: for event in pygame.event.get(): if event.type == SPAWN_ENEMY: spawn_enemy()这样你就能在主循环里统一处理所有"动作"事件,而不用在循环外挂一堆各自为政的计时器了。
还有一个必须处理的窗口事件是pygame.QUIT,它对应点击窗口关闭按钮。如果只设置running = False不够,还要记得之后pygame.quit()释放窗口资源。在复杂的游戏里,你可能还要处理pygame.WINDOWRESIZED事件来重新计算布局,不过基础阶段先不管。
5. 从零到一:写一个能玩的"接苹果"小游戏
光讲 API 不落地等于白讲。下面用一个小游戏把前面所有概念串起来:玩家在底部左右移动,从屏幕顶端不断掉下苹果,接住得分,漏掉则损失一条命。这个游戏麻雀虽小,五脏俱全。
5.1 游戏拆解:四个核心部分
写代码前先拆游戏规则。任何游戏都可以拆成玩家控制、掉落物、碰撞判定、计分与结束条件四个部分:
- 玩家:一个矩形(或图片),跟随鼠标或键盘左右移动。
- 掉落物:苹果图片(或者一个红色圆形),从顶部以一定速度下落。
- 碰撞判定:苹果与玩家接触的瞬间,得分并移除苹果。
- 结束条件:某个苹果落到底部并超出屏幕范围时,生命减一,生命归零则游戏结束。
这种"先拆规则再写代码"的习惯,比写码本身更重要。拆得越细,你的代码结构就越自然。
5.2 精灵与组:为什么老手一上来就建 Class
Pygame 的pygame.sprite.Sprite类和pygame.sprite.Group类是官方推荐的管理游戏对象的方式。Sprite是一个"可绘制、可移动、可碰撞"的实体基类,Group则是一个容器,可以批量绘制、批量更新、批量检测碰撞。
import pygame class Apple(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image = pygame.Surface((20, 20)) self.image.fill((255, 0, 0)) self.rect = self.image.get_rect(center=(x, y)) self.speed = 3 def update(self): self.rect.y += self.speed if self.rect.top > 600: self.kill()这里的self.image和self.rect是 Sprite 的两个约定俗成的属性,draw()方法会依据 rect 位置把 image 画出来,update()方法则是让每个精灵执行自己的行为逻辑。你只需要在主循环里调用:
apples.draw(screen) apples.update()游戏中所有苹果的位置更新和绘制就全部完成了。省去了手写一个苹果列表然后逐个遍历的麻烦。用面向对象的方式来组织游戏对象,是几百行后项目还能不被搞乱的关键。虽然用 dict 或 list 也能实现,但类封装行为、Group 来遍历,可读性和扩展性完全不在一个层面。
5.3 碰撞检测:矩形碰撞和像素完美碰撞的取舍
Pygame 里最朴素的碰撞检测是两个矩形是否相交,对应Rect.colliderect():
hits = pygame.sprite.spritecollide(player, apples, True) if hits: score += 1spritecollide(精灵, 组, True)会遍历组内所有精灵,检测与传入精灵是否碰撞,第三个参数为 True 表示碰撞到的对象会从组里移除。这个函数默认用的就是矩形碰撞。
矩形碰撞的缺点是:如果你的物体不规则,比如一个圆形苹果被当作正方形检测,四个角都会造成"看起来没碰到却算碰到"的假碰撞。解决办法有两种:
- 用
collide_mask参数指定像素级碰撞。Pygame 会按图像的非透明像素来检测,精确度最高,但性能开销也大。小游戏里几十个物体的像素碰撞没问题,几百个就要小心了。 - 手动缩小碰撞盒。就是给 Rect 的宽高乘一个系数,让判定区域比画面区域小一点。比如 30x30 的圆形图片,碰撞盒设成 24x24。这招在实战中非常常用,混合了精确度和性能。
我的建议是:先全部用矩形碰撞把游戏跑通,等确认功能稳定后,再逐个替换成需要的精确模式。不要一上来就追求完美,导致半天调试不出结果。
5.4 游戏状态机:开始界面、游戏中、结束界面
一个正经游戏绝不可能只有一个"永远在跑"的主循环。它至少有三个状态:开始菜单、游戏进行中、结束画面。在 Pygame 里没有现成的"场景系统",所以你得自己用状态变量去控制,最常见的写法是:
GAME_MENU = 0 GAME_PLAY = 1 GAME_OVER = 2 game_state = GAME_MENU while running: if game_state == GAME_MENU: # 显示标题和提示文字 # 检测按键,按下空格进入游戏 elif game_state == GAME_PLAY: # 更新苹果、检测碰撞、更新时间 # 生命归零时切到 GAME_OVER elif game_state == GAME_OVER: # 显示最终得分 # 按任意键重新开始这就是一个极简的状态机。写状态机的时候有个容易踩的坑:不要用if去改状态后又同一帧继续执行新状态的逻辑。比如在GAME_MENU分支里检测到空格,把状态改成GAME_PLAY,如果下面没有elif而是连续if,同一帧它会继续跑游戏逻辑,导致开局瞬间就产生一次碰撞或更新。所以状态分支必须用elif明确互斥,或者每个分支后面continue跳过本轮剩余逻辑。类似地,从结束画面按下空格重开游戏时,一定要重置所有精灵、分数和时间,否则你会看到上一局的残局。
状态机是游戏从"能跑"走向"能玩"的分水岭。早期我用一个超级大的主循环堆所有逻辑,改个功能就要滚半天代码;用状态机拆分之后,每个界面的代码彼此隔离,主循环清爽多了。
6. 新手最容易翻车的四个地方:文字、音效、打包和高清屏
6.1 中文显示全变方块:字体加载的坑
如果你尝试在 Pygame 里写screen.blit(font.render("你好, 游戏", True, (255,255,255)), (100, 100)),会很惊喜地发现:方块,全是方块。这是 Pygame 新手绕不开的坑。
原因在于 Pygame 的默认字体不知道去哪加载,或者加载到的字体根本不支持中文字符。解决办法也不是很难,你只需要告诉它一个支持中文的字体路径。Windows 下可以这样写:
font_path = "C:/Windows/Fonts/msyh.ttc" # 微软雅黑 font = pygame.font.Font(font_path, 36)为了跨平台稳定,更好的做法是把字体文件放进项目的assets/fonts/目录里,然后用相对路径加载。字体文件一般不能乱拷贝传播,注意看微软雅黑等字体的版权,但自用学习完全没问题,或者可以使用思源黑体等开源字体。
还有一个冷门细节:pygame.font.SysFont可以通过系统字体名创建字体,它在不同系统上字段不同,比如 Windows 认 "微软雅黑",macOS 可能认 "PingFang SC" 或 "Songti SC",因此跨平台时会出问题。用pygame.font.Font(文件路径)加载固定字体文件,是保证任何机器上效果一致的最稳方案。
6.2 音效不响:初始化的顺序问题
如果你的游戏里用了音频素材,必须在任何音频操作之前调用pygame.mixer.pre_init()或pygame.mixer.init()。这里大多数人会栽的跟头是:在pygame.init()之后再调用mixer.init有时会静默失败——播放音效时没有声音,也没报错。
标准做法是提前设置:
pygame.mixer.pre_init(44100, -16, 2, 512) pygame.init()这段代码写在pygame.init()之前。第二、三、四个参数分别是采样率、位深度、声道数、缓冲大小。如果不清楚怎么填,默认值就够用,重要的是pre_init 必须在 init 前。然后加载音效:
jump_sound = pygame.mixer.Sound("assets/sounds/jump.wav") jump_sound.play()注意play()不是等音频放完,只负责启动播放。想做背景音乐,用pygame.mixer.music.load("bgm.mp3"); pygame.mixer.music.play(-1),-1表示循环播放。若遇到 MP3 在部分环境不支持,用 WAV 或 OGG 格式最保险。
6.3 PyInstaller 打包:路径机制的隐藏炸弹
游戏写好后想发给朋友玩,第一步基本就是pip install pyinstaller,然后pyinstaller -F main.py。这一步能跑出 exe 但大概率会出现一个大问题:游戏启动后找不到图片、找不到音效。原因不是你的代码有问题,而是 PyInstaller 打出来的程序运行时,工作目录和 Python 环境不同,相对路径失效了。
解决办法是写一个统一的路径工具函数。判断程序是打包状态还是开发状态,分别取不同的基准目录:
import sys, os def resource_path(relative_path): base_path = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base_path, relative_path)在使用素材路径时,全部通过resource_path("assets/images/hero.png")获取。这样开发时它返回项目目录下的路径,打包时它返回 PyInstaller 释放临时文件的目录路径。这个工具函数我建议写进所有做 Pygame 项目的人的工具箱,真的不知道救了多少次场。
另外,-F参数会把所有东西打成一个超大文件,启动慢,杀软也容易误报。如果你发给别人玩,建议用-w(隐藏命令行窗口)加-F;如果只给自己用,保留窗口还能方便打印日志。
6.4 高清屏和多屏环境下的坐标失真
Pygame 2.x 在 Windows 高分屏下的表现依然不完美。如果你的屏幕是 2K、4K 且系统缩放比例调到 125% 或 150%,你会觉得窗口比设定的小,鼠标坐标和画面内容对不上。
根本原因是 Windows 在缩放时,系统会把 Pygame 创建的窗口按逻辑像素显示,但 Pygame 拿到的鼠标坐标却是物理像素。一个简单粗暴的兼容方案是:在代码开头让窗口自适应缩放:
import ctypes ctypes.windll.user32.SetProcessDPIAware()然后再创建窗口。这个 API 调用在 Windows 上告诉系统"我自己处理 DPI,不需要你帮我缩放",通常能解决大多数错位问题。但如果你要发布给各种屏幕配置不同的玩家,更稳妥的方案是写"分辨率自适配"——即固定的游戏逻辑分辨率,比如 960x540,然后把整个窗口缩放显示。这一块不是新手必须掌握的,但如果你准备把作品发布给别人玩,迟早会面对它。第一次遇到坐标错位,先查 DPI,再查是不是窗口真的发生了 Resize 事件,两个排查方向基本覆盖九成问题。
最后说几句心里话
如果让我给后来人一个最精简的建议,那就是:别急着学很多花哨的功能,先把一个球在屏幕上移动、碰撞、计分、结束这四件事串起来跑通。这个过程能让你真正理解什么是事件循环、什么是矩形碰撞、什么是状态切换。我在实际开发中最大的体会是,Pygame 是一个让人"敢于动手"的库——它不完美,但它把游戏制作的门槛压到了一个下午就可以跨过去的高度。卡住了就去翻事件队列、去打印精灵数量、去查 FPS,这些小技巧比任何插件都好用。你的第一个游戏或许画面粗糙,但亲手创造出一个能运行的交互世界,那种感觉会是持续学习的最大动力。