刚上手 Pygame Zero 的同学,十有八九都会卡在同一个问题上:我能把图片显示出来,也能播放音乐,可游戏就是"不动"。画面像个定格的截图,按键盘也没反应。其实这时候缺的,就是游戏里最核心的那个东西——update()函数。
用一句话概括:update()是 Pygame Zero 每帧都会自动调用的"心跳"函数,你的游戏逻辑——移动、计时、碰撞、计分——全都应该放在这里面。它不复杂,但想用好它,得先理解它背后的运行机制。这篇文章我从头到尾拆一遍update()的玩法:它什么时候被调用、参数dt到底怎么回事、和draw()怎么分工、实战里怎么写移动和计时,最后再附上我踩过的一堆坑。不管你是完全零基础,还是已经能画几个图形但搞不懂"游戏动起来"的原理,这篇都能帮上忙。
1. 认识 update():游戏循环里的"帧心跳"
1.1 Pygame Zero 帮你隐藏的那台"发动机"
先想一个问题:你平时写 Python 程序,代码是一行行从上往下跑的,跑完就结束了。可游戏不一样,游戏必须在"跑完一遍"之后,一遍又一遍地重复执行,直到玩家关掉窗口为止。这个"不断重复执行"的过程,就是游戏循环(game loop)。
如果不用 Pygame Zero,你用原生 Pygame 写游戏循环,大概会长这样:
while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # 更新游戏状态 # 绘制画面 pygame.display.flip()Pygame Zero 把这台"发动机"藏起来了。你根本不需要写while循环,只需要定义两个函数:update()和draw()。Pygame Zero 在背后默默帮你调用它们,默认每秒调用 60 次(60 FPS)。这个设计对新手极其友好——你只需要关心"这一帧我要做什么",而不需要关心"循环怎么运转"。
注意:
update()和draw()是 Pygame Zero 约定的特殊函数名。如果你把它们改名成upd()、drawing(),Pygame Zero 是不会认的。这就像插座和插头,名字就是那个接口规格。
1.2 一帧到底有多长?
说到"每秒 60 次",很多初学者没概念。一帧(frame)就是一次完整循环的时间单位。60 FPS 意味着每帧大约 16.7 毫秒——你眨一次眼的功夫,游戏已经更新了三四帧了。
所以update()里做的事情一定要"快"。你不能在里面写time.sleep(1),不能做大量文件读取,不能跑特别耗时的计算——因为只要你赖着不返回,画面就会卡住,玩家感受到的就是"卡顿"。
这就是我为什么说它是"心跳":心跳必须稳定、轻快,才能支撑起整个身体的运转。
1.3 update() 与 draw() 的执行顺序
Pygame Zero 每一帧的实际执行顺序大致如下:
- 处理输入事件(键盘、鼠标、触控)
- 调用
update()—— 更新游戏状态 - 调用
draw()—— 把更新后的状态画到屏幕上
也就是说,永远是先"算",再"画"。状态变了,画面才会跟着变;如果update()里什么都没变,那draw()画出来的自然就是上一帧的老样子——游戏看起来就是"死"的。
搞懂这个顺序,你就明白了为什么几乎所有"让东西动起来"的代码都写在update()里。
2. 参数 dt:让游戏在快电脑和慢电脑上跑得一样快
2.1 为什么不能直接写"每次移动几像素"
先看一个最经典的移动写法:
def update(): if keyboard.right: player.x += 5按下右键,角色每帧往右挪 5 像素。在 60 FPS 的机器上,一秒跑 300 像素,看起来还算正常。但假如玩家的电脑很强,跑到了 120 FPS,那么update()每秒被调用 120 次,角色一秒就跑了 600 像素——速度快了一倍。反之,如果电脑卡顿,FPS 掉到 30,角色就慢了一半。
这就是典型的"帧率相关"(frame-dependent)问题:游戏速度被电脑性能绑架了。
2.2 dt 是"时间矫正器"
Pygame Zero 的作者早就想到了这个问题,所以update()可以接收一个可选参数dt:
def update(dt): if keyboard.right: player.x += 300 * dtdt是"delta time",表示上一帧到这一帧之间经过了多少秒,通常约等于 0.016(也就是 1/60 秒)。关键来了:
- 在 60 FPS 下,
dt ≈ 1/60,那么300 * dt ≈ 5,和原来没区别 - 在 120 FPS 下,
dt ≈ 1/120,那么300 * dt ≈ 2.5,每帧挪得少了,但因为帧数翻倍,一秒钟总移动量还是 300 像素 - 在某帧卡顿、耗时 0.05 秒时,
dt = 0.05,这一帧就会一次性补偿移动 15 像素,总速度依然保持 300 像素/秒
这个思路,就像你在高速上开车,不是"每秒钟固定踩几次油门",而是"根据时间流逝的速度去调整每次踩油门的力度"。结果就是:无论电脑快慢,游戏角色的移动速度始终保持一致。
如果
update()不写dt参数,Pygame Zero 调用它时就不会传这个值,你也自然拿不到。写了dt但用不到,也没关系,顶多是个"暂时没用的变量"。
2.3 用还是不用?我的建议
这里有个经验之谈:所有"随时间变化"的逻辑,都建议用dt做时间基准。包括:
- 角色移动、跳跃
- 子弹飞行、敌人追踪
- 倒计时、技能冷却
- 渐变色变化、转场效果
但要注意,dt在帧率极低时会变得很大(比如你 debug 时暂停了 2 秒,恢复后dt = 2),角色会被瞬移一大截。后面我会在"避坑"部分专门讲这个问题。
3. 逻辑放 update,画面放 draw:别让绘制函数干粗活
3.1 为什么不能在 draw() 里改游戏状态
我刚学 Pygame Zero 那会儿,偷懒把移动逻辑写在了draw()里,结果遇到一堆诡异的 bug。为什么不行?因为draw()的职责是"根据当前状态画画面",不是"改变状态"。
举个具体例子:
def draw(): screen.clear() player.x += 5 # 在 draw 里改坐标?不推荐 player.draw()问题有三个:
- 逻辑与表现混在一起:一旦你想"暂停游戏"(比如玩家点了暂停按钮,你需要停止更新但不停止绘制),你就无从下手——绘制本来就该持续进行,但状态不该变。如果逻辑混在
draw()里,你根本没法单独暂停它。 - 绘制顺序影响状态:如果画面里要画多个角色,先画的那个角色状态改变了,后面画的时候看到的坐标就"不同步",容易出现莫名其妙的偏差。
- 不利于调试:你很难判断到底是"状态算错了"还是"画面画错了",因为两边都改了数据。
3.2 正确分工:状态与视图的分离
把游戏想象成拍电影:
update()是编剧和导演,决定每个角色此刻应该在哪、应该是什么状态draw()是摄像师,只负责把"此刻的场面"拍下来
def draw(): screen.clear() player.draw() enemy.draw() score_label.draw() def update(dt): player.move(dt) enemy.chase(player, dt)update()里改了player.x,draw()里读到新的player.x去画——数据是唯一的,绘制只是"读取",这样代码一眼就能看懂,后续扩展也轻松。
3.3 特例:哪些绘制必须放在 draw() 里
有一些东西只能在draw()里做,哪怕过程看起来很"动态":
screen.clear()每一帧清屏screen.draw.text(...)绘制文字- 绘制精灵、矩形、圆
screen.surface相关的贴图操作
这些是 Pygame Zero 的绘制接口,本质上都是"把东西画到屏幕上"的行为,必须发生在绘制阶段。如果你在update()里调用screen.draw.text(),虽然有时候不报错,但它的执行时机和画面呈现之间会很混乱,不推荐这么写。
4. 实战拆解:用 update() 做出第一个会动的游戏
4.1 基础移动:键盘控制角色
先写一个最简 Demo:一个方块,用方向键控制移动。
import pgzrun WIDTH = 800 HEIGHT = 600 # 用 Rect 创建一个 50x50 的方块 player = Rect((WIDTH / 2, HEIGHT / 2), (50, 50)) SPEED = 300 # 像素/秒 def update(dt): dx = 0 dy = 0 if keyboard.left: dx = -1 if keyboard.right: dx = 1 if keyboard.up: dy = -1 if keyboard.down: dy = 1 # 归一化斜向移动速度,避免斜着飞更快 if dx != 0 and dy != 0: dx *= 0.707 # 1 / sqrt(2) dy *= 0.707 player.x += dx * SPEED * dt player.y += dy * SPEED * dt def draw(): screen.clear() screen.draw.rect(player, "white") pgzrun.go()这里有个细节值得讲:斜着移动时速度会变大。如果同时按右和上,dx = 1、dy = 1,合成的速度是 sqrt(1+1) ≈ 1.414 倍。解决办法是给两轴各乘 0.707,让斜向速度恰好等于横向速度。很多游戏的老 bug 就是这么来的,角色斜着走就是比直着走快一截。
4.2 限制角色不出屏幕
角色跑出屏幕就找不回来了,所以在update()里加边界判断:
player.clamp_ip((0, 0, WIDTH, HEIGHT))clamp_ip是 Pygame Rect 自带的方法,把矩形限制在给定范围内,超出部分自动拉回来。放在update()的最后一行,就能保证"角色永远不出界"。你不需要自己写 if 嵌套,这个方法帮你省了不少事。
4.3 用 update() 做倒计时
游戏里常见的玩法:倒计时到 0,游戏结束。用dt实现:
time_left = 30 # 剩余秒数 def update(dt): global time_left time_left -= dt if time_left <= 0: time_left = 0 # 游戏结束逻辑 print("时间到!")注意global关键字。Pygame Zero 的全局变量在update()里修改时,必须声明global,否则 Python 会认为你在函数内部新建了一个同名局部变量——这是一个新手高频坑。
另外,倒计时递减用dt而不是每帧减固定值,理由和移动一样:保证"30 秒"在任何帧率下都是真实的 30 秒。
4.4 简单碰撞检测与计分
让玩家吃掉金币的经典逻辑:
coins = [Actor("coin", (x, y)) for x, y in coin_positions] score = 0 def update(dt): global score # 先让玩家动起来 handle_movement(dt) # 检测碰撞 for coin in coins[:]: if player.colliderect(coin): coins.remove(coin) score += 1这里用了coins[:]做遍历副本,而不是直接for coin in coins。因为循环里要remove(coin),在遍历同一个列表时删除元素会导致跳过下一个元素甚至报错。这是 Python 里很经典的一个坑,游戏列表尤其常见。
colliderect是 Pygame Zero 为 Actor 提供的碰撞检测方法,判断两个矩形是否相交。如果你转发用 Rect,也可以用player.colliderect(coin),效果一样。
5. 新手最容易踩的坑——update() 翻车现场实录
5.1 在 update() 里 sleep,直接把游戏锁死
有人想做一个"每 2 秒换个动作"的动画,写法是:
import time def update(): time.sleep(2) player.image = "walk_2"看起来合理,实际跑起来你会发现:窗口直接卡死,连鼠标移动都卡。因为time.sleep(2)让整个游戏循环停了 2 秒,draw()也被卡住了,画面就冻结了。
正确做法是利用dt做计时器,或者用 Pygame Zero 自带的clock.schedule_interval:
def switch_image(): player.image = "walk_2" clock.schedule_interval(switch_image, 2.0)clock.schedule_interval是 Pygame Zero 提供的定时器接口,每秒调一次指定函数。它不会阻塞游戏循环,非常适合做周期性动作。
5.2 忘了加 dt,角色在高端机上跑来跑去
这个坑我前面提过,但实在值得再强调一次。新手常见的写法:
def update(): player.x += 5在 60 FPS 下,一秒移动 300 像素;在 144 Hz 显示器 + 高端显卡上,如果游戏能跑到 144 FPS,一秒就 720 像素。你换个电脑,游戏难度就变了——这显然是灾难。所有速度相关的数值,都改成速度 * dt的形式。
5.3 全局变量不带 global
我在带新手的时候,"局部变量遮蔽全局变量"是出现频率最高的报错之一:
score = 0 def update(): score += 1 # UnboundLocalError: local variable 'score' referenced before assignment这个报错不是 Pygame Zero 特有的,是 Python 的规则:函数内给变量赋值,Python 默认把它当作局部变量。解决方案就是函数第一行加上global score。
还有一种更推荐的做法:把游戏状态装进一个 object 里,比如:
class GameState: def __init__(self): self.score = 0 self.time_left = 30 state = GameState() def update(dt): state.score += 1这样就不用到处写global,代码结构也更清楚。
5.4 update 里做重计算导致帧率暴跌
update()每帧都要执行一次,所以它内部的代码必须"轻"。有些操作天然耗时,比如:
- 从磁盘读取图片素材
- 加载较大文件 / 解析大量数据
- 做大规模排序或深度搜索
这些应该放在update()外做,比如在函数开头、或者用标志位只做一次:
def update(dt): if not game.initialized: load_level_data() # 只执行一次 game.initialized = True哪怕是 Python 里看起来很简单的for循环,如果每帧遍历几千个对象,也会拖累性能。我的经验是:update()里尽量做"轻逻辑",把复杂的计算拆到多个帧里完成,或者干脆提前算好。
5.5 在 update() 里 add 正在遍历的列表
和"遍历时删除"同源的问题,是"遍历时新增"。比如你要做一个射击游戏,子弹发射后在update()里遍历子弹列表检测碰撞,同时又往列表里append新子弹——这时候列表大小变了,遍历可能出问题。解决办法同样是遍历副本,或者先收集要新增的子弹,循环结束后再统一添加。
6. 调试与性能优化:把 update() 改到丝滑
6.1 用 print 抓住 update() 的执行节奏
print()在游戏调试里特别有用。但要注意:60 FPS 意味着print每秒打印 60 行,刷屏刷到爆炸。如果只是想确认update()被调用了,可以配合clock每分钟打一次:
from datetime import datetime last_print = time.time() def update(dt): global last_print # ...游戏逻辑... now = time.time() if now - last_print > 1: last_print = now print(f"player.x = {player.x}")这样每秒最多打印一行,既能观察状态变化,又不会刷屏。
6.2 让 update() 保持"轻盈"的三个原则
我总结了三个原则,写游戏时照着做,性能基本不会差:
- 只算必要的东西:如果某个值这一帧没变(比如背景层的坐标),别反复重算。
- 避免在 update() 里做 IO:读文件、解压缩、网络请求,全都放外面。
- 对象数量要心里有数:如果一帧要循环检测几万个对象的碰撞,就得考虑空间划分(如网格划分、简单的四叉树),或者减少每帧检测的配对数量。
Pygame Zero 的 API 本身有性能优化,比如colliderect在数量少时完全够用,但量级上来了就要做 dodge。
6.3 帧率监视:用 FPS 数字说话
判断游戏卡不卡,不要靠肉眼,直接打印 FPS。Pygame Zero 里可以通过 clock 获取:
def draw(): screen.clear() player.draw() screen.draw.text(f"FPS: {clock.get_fps():.0f}", (10, 10), color="white")clock.get_fps()返回平滑后的帧率。如果数字稳定在 60 附近,说明update()和draw()的负载都不大;如果掉到 30 以下,赶紧回去看update()里是不是有什么耗时操作。
6.4 何时不需要 update():静态画面的特例
最后说一个反直觉的点:如果你的游戏画面完全是静态的——比如一个菜单界面、一张地图展示——那么update()可以完全留空:
def update(): pass这样draw()依然会每帧重绘画面,但没有任何逻辑需要更新。Pygame Zero 依然以 60 FPS 运行,但因为没有额外的计算量,CPU 占用会低很多。这个技巧在"暂停界面"里特别实用:你不希望暂停时逻辑还在跑,但画面要继续绘制。
我个人在实际项目里的习惯是:把update()里的逻辑进一步拆成几个小函数,比如handle_input(dt)、move_enemies(dt)、check_collisions(),然后update()里只做调用和组合。这样做的好处是每个小函数都能单独测试,出了问题也容易定位。另外一个很实用的经验是:任何需要调数值的玩法(速度、倒计时、冷却时间),统一放到文件顶部作为常量,比如我上文的SPEED = 300。这样后面想整体调难度时,改一个地方就够了,不用满代码搜数字。做游戏前期可以不在乎这些,但项目一复杂,这些习惯能帮你省下大量纠结的时间。
update()是 Pygame Zero 里最基础、也最容易踩坑的一个函数。把它的调用时机、dt的用法、和draw()的职责分工这三点吃透,你就已经跨过了"静态画面"到"动态游戏"之间的那道坎。下一篇我会接着聊update()里的更进阶玩法——状态机切换、对象池复用、复杂碰撞的拆分处理,到时候见。