刚开始接触pygame zero的时候,我最困惑的不是怎么画图,而是update到底用来干嘛。官方示例里总有一个神秘的def update(dt):,那个dt怎么看都像某个变量缩写,试着手动调用又直接报错。后来我才想明白,update才是整个游戏逻辑的心脏,draw只是负责把心脏泵出来的内容画到屏幕上。这篇特训第一讲,就把update从里到外扒干净,讲清楚它什么时候被调用、dt是什么、为什么移动逻辑必须写在这里,以及新手最常踩的坑。
这篇内容适合刚看完 pygame zero 基础教程、想自己写小游戏的人。就算你已经写过几段移动代码,里面关于帧率、全局变量和循环删元素的坑,多半也值得再看一眼。
1. 先搞懂一件事:update 和 draw 的分工,别把逻辑写错地方
1.1 两个函数,两条流水线
pygame zero 把每一帧要做的事情拆成了两个入口:update(dt)和draw()。游戏主循环每帧会做三件事:先计算距离上一帧过去了多久(这个时间就是dt),然后调用一次update(dt)来更新所有逻辑状态,最后调用draw()把最新的状态画到屏幕上。这个过程从pgzrun.go()开始之后就会一直循环下去,直到你关掉窗口。
所以update不是给你手动调的,也不是偶尔跑一次的东西,它是“每帧必到”的常驻方法。所有需要在每一帧发生变化的内容,都应该在这里面做计算。比如角色位置变化、血量扣减、计时器倒计时、敌人 AI 的决策、碰撞检测后的反馈,都是 update 的活儿。而draw()里只需要做一件事:读取当前状态,把它画出来。
这条分工线越清晰,游戏越不容易出莫名其妙的问题。我见过不少新手把移动写在draw()里,表面看起来也能动,但一旦窗口被拖动、屏幕需要重新绘制,画面就会突然跳一下,原理就是绘制调用和逻辑更新没有分开。
1.2 真的有人把移动写进 draw,结果画面乱跳
咱们看一个反面教材。假设你要让小球每帧往右移动一点,有人会这么写:
def draw(): ball.x += 1 ball.draw()代码跑起来确实能看到球在动,但问题在于:pygame zero 的draw()调用时机并不严格等同于一帧游戏逻辑。窗口刷新、系统重绘事件、后台触发的重绘,都可能让draw()在同一帧里被调用多次。一旦出现这种情况,ball.x += 1会被执行多次,球就一会儿快一会儿慢,甚至在你拖动窗口时瞬间飞出屏幕。
更麻烦的是,这种写法把“状态变更”混进了“渲染函数”,以后你想加暂停、加慢动作、加帧率控制,全都找不到一个干净的入口。正确的做法是:draw()里不做任何赋值操作,只负责画。移动那一步必须放进update(dt)。
1.3 update 只做“算”,draw 只做“画”:一个标准模板
记住这个最基础的骨架:
import pgzrun WIDTH = 800 HEIGHT = 600 def update(dt): # 在这里更新坐标、速度、状态、计时器 pass def draw(): # 在这里读取状态并绘制 screen.clear() pass pgzrun.go()WIDTH和HEIGHT是 pygame zero 内置识别的全局变量,定义以后窗口大小就是它们指定的。screen.clear()用来把上一帧的画面擦掉,然后重新画当前帧的内容。如果你不擦,上一帧的图形就会残留,拖出长长的“拖影”,这是最典型的 draw 职责缺失。
这个模板虽然简单,但它代表了游戏开发的经典模式:状态存储在全局变量或对象里,update负责变更状态,draw负责把状态可视化。你先把这个模式刻在脑子里,后面所有内容都是围绕它展开的。
2. dt 参数:update 里最容易忽略却最关键的东西
2.1 Frame Rate 与 dt 的关系
update(dt)里那个dt,全称是delta time,意思是“距离上一帧经过的时间”,单位是秒,是个浮点数。如果你的游戏稳定跑在 60 FPS,那么每帧间隔大约是 1/60 秒,也就是 0.0167 左右;如果突然卡顿,变成 20 FPS,那一帧间隔就是 0.05 秒。pygame zero 的主循环会默默把这个时间算好,然后作为参数塞给update。
它为什么重要?因为游戏的逻辑需要跟“真实时间”挂钩,而不是跟“帧数”挂钩。你可以把一帧想象成一次眨眼,每次眨眼之间隔多久并不固定。你的角色如果要“每秒走 300 像素”,那它就必须知道这一眨眼的功夫到底是 0.0167 秒还是 0.05 秒,才能算出这帧该走多远。
2.2 不看 dt 的移动速度,换台电脑差别天壤
很多人刚学的时候喜欢这么写:
def update(dt): ball.x += 2这里有三个问题:第一,ball.x += 2表示每帧走 2 像素,而不是每秒走 2 像素。在 60 FPS 下,走完 2 像素的节奏是每秒 120 像素;在 30 FPS 下,就变成每秒 60 像素;如果你的电脑跑到了 144 帧,那就是每秒 288 像素。同一个游戏,换一台电脑,球的快慢完全不同。
我曾经把这样一个不加dt的小 Demo 发给朋友,他在一台老笔记本上打开,说“你这个球是不是半路没油了”,实际上就是那台机器只能跑 30 帧左右。从那时候起我就意识到,凡是跟速率有关的逻辑,必须把dt用上。
第二,即使在同一台电脑上,如果你在update里做了重活导致掉帧,那球的移动速度也会跟着一起波动。程序一秒只跑了 20 次逻辑,但每帧却加了 2 像素,表现出来就是一顿一顿的慢动作。
2.3 用 dt 修正:从“每帧步进 5 像素”到“每秒步进 300 像素”
正确打开方式是这样:
SPEED = 300 # 每秒移动 300 像素 def update(dt): ball.x += SPEED * dtSPEED * dt的含义是:如果这一帧用了dt秒,那球就应该走300 * dt像素。在 60 FPS 下,每帧大约走300 * 0.0167 ≈ 5像素;在 30 FPS 下,每帧大约走300 * 0.033 ≈ 10像素。虽然每帧步进不同,但每秒累计走的距离都是一样的,都是 300 像素。
这个思路还能直接用到旋转、透明、缩放等各种渐变效果上。凡是跟“速率”相关的,一律乘dt。帧率高了,步长变小;帧率低了,步长变大,但真实世界的速度不变。这是游戏中“帧率无关逻辑”的核心思想。
注意:极端情况下,如果一帧卡了 2 秒,
dt = 2.0,那这一帧球会直接瞬移 600 像素。所以dt不是越大越好,但制作小游戏时通常不需要做特别复杂的限制,先理解它的基本用法就够。
3. update 里的三个新手翻车现场
3.1 局部变量阴影与 global 声明
经常有人写了这样的代码,然后运行报错:
x = 100 def update(dt): x += 1报错信息是UnboundLocalError: local variable 'x' referenced before assignment。原因是 Python 的规则:函数内部只要对一个变量做了赋值,它就会被当作局部变量,外面那个全局x反而读不到了。x += 1本质是x = x + 1,等号左边让 Python 决定“这是局部变量”,于是等号右边那个还没定义的x就成了引用未定义变量。
解决办法是加上一行global x:
x = 100 def update(dt): global x x += 1不过有一条例外让很多新手迷惑:如果你操作的是一个对象的属性,比如ball.x += 1,这时并不需要global ball。因为ball.x += 1等价于先获取ball这个对象,然后修改它的属性,并没有给ball这个变量重新赋值,引用本身没有改变。所以你在update里怎么改ball.x都没问题,不需要global ball。但如果你写了ball = Actor('ball')重新给整个变量赋值,那就要global ball了。
3.2 在 for 循环里改列表:迭代陷阱
update 里最常见的业务就是遍历敌人列表、子弹列表做更新和碰撞检测。新手很容易写出这种代码:
def update(dt): for enemy in enemies: enemy.hp -= 10 if enemy.hp <= 0: enemies.remove(enemy)问题在于:当你用for enemy in enemies遍历列表时,内部其实按索引移动,一边遍历一边remove,会让后面的元素往前顶,导致你跳过了一个元素。最恶劣的情况下,某些元素永远得不到“血量减 10”的处理,或者删除时出现逻辑混乱。
更安全的做法有三种。一是遍历副本:
for enemy in enemies[:]: if enemy.hp <= 0: enemies.remove(enemy)二是用列表推导直接生成新列表:
enemies = [e for e in enemies if e.hp > 0]三是先记录需要删除的项,等循环结束再统一删。我个人的习惯是:如果你的列表不大,直接用第二种,干净利落,还能顺便过滤掉所有需要抹除的对象。
做碰撞检测时也一样,比如子弹击中敌人,你先要在循环里判断击中,产生“扣血”或“移除”的效果。一定记得不要边遍历边remove,否则你会看到一堆明明应该消失的敌人还赖在场上,或者子弹穿过两个敌人但只生效一次。
3.3 在 update 里做耗时操作:帧率直接干穿
update 是在主循环里同步执行的,你让它干什么,玩家就得等它干完才能看到下一帧。如果在 update 里放了time.sleep(1)、大图片的加载、网络请求、复杂寻路算法,那么游戏画面会卡在那一瞬间,仿佛死机。如果耗时特别长,Python 解释器还会在 OS 层被打进“无响应”状态,看起来就是程序崩溃了。
即使没有这么夸张,只是偶尔做一次耗时计算,也会让dt突然变大。因为主循环计算dt时是看“上一帧处理完,到这一帧开始”的间隔。你让 update 卡了 0.5 秒,它给下一帧的dt就可能是 0.5,你的小球按SPEED * dt就会瞬间跳出老远,所谓“瞬移”就这样发生了。
正确习惯是:update 里只放轻量逻辑。加载图片、音效、字体这些资源放到启动阶段,或者用clock.schedule在空闲时处理;真正密集的计算,试着做成每几帧算一次,或者拆分成多步。如果你做的不是“物理引擎级”的项目,先养成“update 要快”的直觉就够了。
4. 一次完整的 update 实战:让小球跑起来并带状态切换
4.1 场景需求与初始代码
理论讲一堆,不如跑一个完整例子。我们要做一个 800×600 窗口里上下弹跳的小球,它有一个初始速度,碰到边界会反弹。之后还要加入键盘控制和“加速状态”。
这个例子我刻意不用外部图片资源,直接用screen.draw.filled_circle画圆,方便你复制到本地就能跑。完整代码:
import pgzrun WIDTH = 800 HEIGHT = 600 RADIUS = 20 SPEED = 300 ball_x = WIDTH // 2 ball_y = HEIGHT // 2 vx = SPEED vy = 0 def update(dt): global ball_x, ball_y ball_x += vx * dt ball_y += vy * dt def draw(): screen.clear() screen.draw.filled_circle((ball_x, ball_y), RADIUS, (255, 255, 0)) pgzrun.go()运行以后,小球会匀速向右移动,然后从屏幕右侧消失。这当然不算“反弹”,所以我们下一步加上边界检测。
4.2 键控移动 + 边界反弹 + 限速
边界反弹逻辑其实就是判断坐标是否超出边界,超出就把对应的速度分量取反,同时把坐标拉回边界内,防止球“嵌”进边缘。改一下 update:
def update(dt): global ball_x, ball_y, vx, vy ball_x += vx * dt ball_y += vy * dt if ball_x - RADIUS < 0: ball_x = RADIUS vx = -vx if ball_x + RADIUS > WIDTH: ball_x = WIDTH - RADIUS vx = -vx if ball_y - RADIUS < 0: ball_y = RADIUS vy = -vy if ball_y + RADIUS > HEIGHT: ball_y = HEIGHT - RADIUS vy = -vy每次碰到边界就把vx或vy取反,球就能一直在窗口里弹来弹去。由于我们乘了dt,不管机器帧率是 30 还是 120,球的移动速度和反弹后的轨迹基本一致。
现在加入键盘控制。让方向键决定球的加速度:
ACCEL = 500 MAX_SPEED = 800 def update(dt): global ball_x, ball_y, vx, vy if keyboard.left: vx -= ACCEL * dt if keyboard.right: vx += ACCEL * dt if keyboard.up: vy -= ACCEL * dt if keyboard.down: vy += ACCEL * dt vx = max(-MAX_SPEED, min(MAX_SPEED, vx)) vy = max(-MAX_SPEED, min(MAX_SPEED, vy)) ball_x += vx * dt ball_y += vy * dt # 边界反弹代码...这里keyboard.left这种判断的意思是“这个键当前是否按住”。按住期间每一帧都会生效,这很适合用来做持续加速。如果你想让速度不会无限增长,就用max/min把它钳制在MAX_SPEED以内。注意速度钳制要在移动之前做,否则这帧仍然可能用超速值移动。
4.3 用 dt 实现“按一下空格,进入 3 秒加速状态”
这里要区分两种按键判断:按住判断和按下触发判断。上面的方向键属于按住判断;而“按一下空格只生效一次”的启动逻辑,就不能用if keyboard.space:,因为这样的话玩家按住空格期间每一帧都会触发一次,你可能会连续开好几个状态。
pygame zero 提供了keyboard.pressed,它专门用来判断“这一帧是否有按键刚刚被按下”,只在按下动作发生的那一帧返回真,下一帧立刻变回假。用法:
boost_time = 0 def update(dt): global boost_time, current_speed if keyboard.pressed[keys.SPACE]: boost_time = 3.0 if boost_time > 0: boost_time -= dt current_speed = 800 else: current_speed = 300然后把移动速度改成current_speed:
ball_x += vx * current_speed * dt等等,这里我偷懒了,其实最好把速度方向归一化。但为了讲清楚状态切换,你可以用更简单的方式:加速状态期间,把SPEED常数替换成current_speed作为速度大小。真实项目里还会把vx, vy的方向保存下来再乘上当前速度,这可以作为思考题留给读者。
这个例子里,boost_time就是“倒计时”。每次按下空格,把它重置为 3.0;每帧减去dt;一旦减到 0 以下,就恢复普通速度。用dt做倒计时比用“帧计数”更科学。帧计数适合帧率稳定的情况,但你的游戏一旦在某些场景掉帧,120 帧和 60 帧下同一个“3 秒”就会差出一倍时间。dt累积是对真实时间负责,帧数再飘,3 秒就是 3 秒。
4.4 把 update 想象成一个“每帧必到的值班员”
我后来跟人讲 update,越来越喜欢用一个比喻:它是不知疲倦的值班员,每帧都准时到岗,领到一份叫dt的报表,报表上写着“距离上次到岗过去了多少秒”。值班员要做的事情很简单——按照这份报表更新游戏世界里的所有状态:角色动没动、敌人醒没醒、计时器还剩多少、子弹有没有撞到墙。
而draw()是另一位同事,只负责拍照:把值班员更新之后的世界拍成画面。值班员不拍照,拍照同事不改变世界。两者职责分开,游戏才稳。
你甚至可以把它套用到所有游戏功能上:跳跃逻辑、连击数、天气效果、倒计时、NPC 对话自动跳过,都是“在 update 里读输入、改状态”。一旦理解了这个模型,你会发现 pygame zero 剩下的功能都只是细节。
5. 从 update 出发:几个值得养成的习惯
5.1 凡是跟时间有关的逻辑,优先用 dt
帧计数在某些场合确实简单,比如“每 60 帧换一张图”,但一旦你想要的目的是“每隔 1.2 秒让敌人变红”,用dt累积是更稳定、更可移植的做法。你可以维护一个timer变量,每帧timer -= dt,归零就触发。这比frame_count % 72这种魔法数字要清晰得多,也更容易调整参数。
5.2 update 里只处理计算,不做输出
不要在update里调用draw()、打印日志、播放声音。打印日志偶尔调试可以,但如果每帧打印一次并且开着跑一分钟,终端会被刷得没法看,而且日志本身也会拖慢帧率。调试完及时删掉。声音播放一般由事件触发,不适合在每帧里重复播放;真要播放,也要判断是否刚进入某种状态,别依赖按住判断。
5.3 养成“先限制再赋值”的习惯
移动类逻辑最容易出的问题就是速度爆炸。如果你在 update 里直接vx += 100 * dt,同时玩家按住方向键五分钟,理论上小球速度可以无限增长,最终一帧跑出整个屏幕甚至直接穿墙。给速度加max/min钳制是最简单的安全网。同理,血量、倒计时、连击数这些数值,也建议在修改后立刻做边界限制,避免出现负数、溢出之类的低级 bug。
5.4 遇到“速度时快时慢”先怀疑 dt
如果你游戏里的角色移动速度忽快忽慢,第一反应不是去查坐标算法,而是先确认dt是不是稳定。你可以在 update 里临时加一句:
print(dt)如果打印出来的数值稳定在 0.016 左右,那说明帧率正常;如果频繁出现 0.05、0.1 甚至更大,那就是有东西在阻塞主循环。这时候回头检查 update 里有没有耗时操作、有没有循环删列表时做蠢事。这个排查思路能省下大量时间。
再分享一个我自己的操作习惯:完成一个功能后,我会刻意把窗口切到后台几秒钟,再切回来,观察游戏是否恢复。很多新手写的 update 会在窗口失去焦点时疯狂累积时间,导致切回来后角色瞬间瞬移一大截。如果你也希望切回来不瞬移,可以结合dt加一个上限,比如dt = min(dt, 0.1),把单帧间隔限制在 0.1 秒以内。这一行代码能解决相当多“切窗口瞬移”的小游戏毛病。
下一步特训我可以继续聊碰撞检测和 actor 管理,但只要你把 update 这个入口想透,后面很多内容都会顺理成章。动手跑一下这个弹球,再试着给它加上加速、减速、反弹变色,你会更快进入游戏开发的状态。