Pygame Zero核心update详解:从静止到动起来的关键

发布时间:2026/10/22 8:22:37
Pygame Zero核心update详解:从静止到动起来的关键
pygame zero 这个库很多朋友都是从“画个图片、点几下鼠标”开始上手的。画一个小人、贴一张背景图觉得挺简单可一旦想让这个小人跑起来、跳起来立刻就被卡住了——代码看起来都写对了画面怎么就是不动于是开始怀疑自己是不是没有天赋。其实不是你笨是你还没找到那个让游戏“活过来”的入口。这个入口就是今天要讲的 update。我打算用一个小系列把 pygame zero 里最容易被初学者忽略或者说最容易“翻到就抄、抄完就忘”的几个核心知识点掰开揉碎过一遍。第一篇就锁定在 update 身上。这篇文章适合谁看一种情况是已经能画出静态画面、但一涉及“动起来”就懵的初学者另一种情况是写过一阵子 pygame但总觉得自己的代码在 update 里越写越乱、帧率一变就出问题想回头把这层窗户纸捅破的人。我会从 update 的基本触发机制讲起把“帧”“游戏循环”“dt 时间参数”这些说法一次说清楚再带你把一个能跑能玩的小游戏写完。整个过程不用下载额外依赖一个编辑器加一张图片就能跟上。1. 为什么学 pygame zero 要先啃 update 这块骨头1.1 藏在背后的游戏循环你写 pygame zero 的时候应该见过这个三件套import pgzrun WIDTH 800 HEIGHT 600 def draw(): pass def update(): pass pgzrun.go()只要写了pgzrun.go()程序就会一直运行直到你关掉窗口。这个“一直运行”的本质是一个游戏循环。在你没有写任何循环代码的情况下pgzrun 已经在后台一刻不停地转圈了每秒钟大约转 60 圈也就是 60 帧每一圈它会自动做三件事——处理你按下的键盘、鼠标事件调用update()更新游戏状态调用draw()把当前画面画出来。如果自己写过原生 pygame 的while True主循环对这个结构应该很熟poll 事件、update 逻辑、flip 画面。pgzrun 做的就是把这个循环替你封装好了把“逻辑更新”和“画面绘制”拆成了两个函数给你用。所以搞清楚 update 的触发时机就搞清楚了你整个游戏的脉搏。这里有个初学者很容易踩的误区以为 update 只在发生点什么事的时候才被调用。不是的。它每一帧都会被调用哪怕你的角色根本没动、场景里什么都没有发生。这恰恰是游戏逻辑的“心跳”——每一帧给你一次机会让你告诉程序“现在的世界应该改成什么样”。想通这一层后面学什么都顺。1.2 draw 和 update 的分工一个管“长什么样”一个管“怎么变”这两个函数的分工我一般跟学生这么解释draw 是拍照update 是排练。draw()只负责把当前所有物体的位置、状态画到屏幕上它不负责改这些状态。update()负责修改状态——位置、速度、分数、血量、剩余时间统统在这里更新。上一帧 update 里你把小球的位置从 100 改成 101下一帧 draw 的时候它画出来的就是位置 101 的小球。一帧改一点连续起来就是动画。我见过不少人把移动代码直接塞进 draw 里画之前先挪一下位置。结果画面也会动但经常出现画面闪烁、拖影、动作一卡一顿的情况。原因在于 draw 在有些窗口环境下可能被重复调用把逻辑放在里面相当于让排队的人一边摆动作一边被拍照很容易乱。所以游戏圈有个不成文的规定逻辑一律放 updatedraw 里只做“把你看到的东西画出来”这一件事。2. 第一个 update让小球动起来2.1 从“静止图片”到“动画”只需要改一行先从最小例子看。假设你已经准备了一张小球的图片放在项目的images目录下命名为ball.png。pygame zero 里用 Actor 加载它ball Actor(ball, center(400, 300)) def draw(): screen.clear() ball.draw() def update(): ball.x 1这段代码跑起来你会发现小球每帧向右挪 1 像素。60 帧每秒就是每秒匀速向右挪 60 像素。这就是“游戏逻辑驱动动画”的雏形update 负责改变ball.xdraw 负责用新的ball.x去绘制。你不需要手动控制动画节奏帧循环替你盯着。想让它一直往右走到屏幕外面它真的会走出屏幕然后就看不见了。因为代码是“每帧加 1”没有任何限制。这里顺便就能引出一个高频需求限制位置在屏幕内。def update(): ball.x 1 if ball.x WIDTH: ball.x 0这行判断解决了“回到左边重新走”的问题。逻辑很简单但很多人初学时会问为什么要写WIDTH而不是 800写上 WIDTH 的好处是你哪天把窗口改成 1280逻辑代码一行不用动。养成用常量、用 Actor 的left / right / top / bottom属性去判断边界的习惯后头写复杂逻辑会轻松很多。如果是想自己造一个“球”而不是用图片也可以用screen.draw.circle直接画。实操时我喜欢两手准备学习阶段用圆形、矩形这些图元省去找图片素材的麻烦正式做小项目时再换图片效果一下子就起来了。2.2 配合键盘用 update 做角色控制接下来是所有做小游戏的人最关心的怎么让角色跟着按键动。pgzrun 提供了keyboard对象直接在 update 里做判断就行def update(): if keyboard.left: player.x - 5 if keyboard.right: player.x 5注意这里用的是if而不是elif。假设某天你想处理“同时按下左和上”的对角线移动if能让两个方向同时生效。用elif的话后一个方向会被前一个吞掉角色只能一次动一个方向手感会很生硬。再提醒一个真实会遇到的坑键盘事件和按键状态是两回事。on_key_down这类事件函数只在“按下去那一刻”触发一次keyboard.left表示“这个键现在是否处于按住状态”。角色持续移动需要用后者也就是在 update 里判断 keyboard 状态。如果你把移动写进on_key_down按下不动时角色只挪一次松手再按才再挪一下根本跑不起来。限制角色别跑出屏幕再配合上下边界约束这就是很多人写的第一个“可操控角色”了def update(): if keyboard.left: player.x - 5 if keyboard.right: player.x 5 player.x max(20, min(WIDTH - 20, player.x))最外面那层max/min组合我多说一句max(20, ...)保证结果不小于 20min(WIDTH - 20, ...)保证结果不大于 WIDTH-20两层套在一起就是“把值夹在中间”。这比连环if干净也更好改。2.3 碰撞检测的入口一切都发生在 update 里有了移动下一步自然是“碰到东西怎么办”。Actor 自带的colliderect方法就是干这个的。典型场景角色碰到金币金币消失、分数加一def update(): # 之前的移动代码省略 if player.colliderect(coin): coin.x random.randint(50, WIDTH - 50) coin.y random.randint(50, HEIGHT - 50) score 1这里有个细节colliderect默认用的是 Actor 的矩形碰撞框也就是图片的包围盒。对于透明背景的大图片可能出现“明明角色没碰到金币只是图片边缘挨上了就算碰撞”的情况手感会差。后头想精细控制可以给 Actor 指定自定义的碰撞矩形这是进阶话题update 篇先不展开但心里要有这个意识。碰撞检测为什么要放在 update 里因为你每帧都需要问一次“现在的两个物体是不是重叠了”这是一条“持续生效的规则”不是一次性事件。凡是这种持续性规则都天然属于 update 的地盘。3. 真正懂 update(dt)帧率不同的坑3.1 为什么同样代码换个电脑速度就不一样前面所有例子都是写死“每帧加 5”。但你有没有想过如果一台电脑跑 60 帧另一台跑 120 帧同一个角色在这两台电脑上的移动速度会差多少答案是一倍。每帧加 5帧率高的跑得飞快帧率低的慢吞吞游戏体验完全不可控。更麻烦的是你自己电脑上测试好好的发给别人一跑速度全变了。这个问题在游戏开发里叫“帧率相关”意思是游戏速度和帧率绑定了。小游戏可能不明显但一旦有跳跃、有重力、有高速弹幕帧率稍微不稳手感就完全不一样。解决思路很直接不用“每帧移动多少像素”改成“每秒移动多少像素”然后在 update 里根据上一帧到底过去了多少秒算出这一步该移动多少。这就需要 update 接收一个额外参数也就是常说的 d。3.2 dt 的正确用法把“每帧加 5”改成“每秒跑 300”pgzrun 的 update 可以写成带参数的形式def update(dt): player.x 300 * dt这里的dt是 delta time 的缩写表示距离上一次 update 调用的时间间隔单位是秒。60 帧下dt 大约是 0.016 秒120 帧下dt 大约是 0.008 秒。不管帧率怎么变300 * dt这一帧移动的量会自动缩放帧率高了每次挪得少一点帧率低了每次挪得多一点但每秒加起来都是 300 像素。这就是“帧率无关”的移动。我建议从第一天就养成写 dt 的习惯哪怕是固定速度的移动也带上。别嫌麻烦这个习惯能帮你省掉大量后面调手感的时间。很多半路学 pygame 的人就是因为早期全写死后面一加跳跃、一加快节奏就崩。不过也要留意不是所有逻辑都该乘 dt。比如计数器、触发标记、状态切换这类“一次性”的布尔判断乘以 dt 反而会出错。记住一句话凡是跟“速度、距离、累积量”相关的数值才需要 dt。判断逻辑、状态开关保持原样就好。4. 动手做一个小项目下落接球游戏4.1 思路拆解光说不练是不行的。下面我们用表面上这些知识点做一个非常简单但完整的游戏小球从天上往下掉你用一块板子左右移动去接它接住了加分球落地就重置回顶部重新落。这个项目麻雀虽小但把 update 的核心能力全用上了键盘控制、碰撞检测、重力模拟、状态重置。先拆需求一个小球受重力影响加速下落一块板子用左右键控制限制在屏幕内球碰到板子反弹一下并加分球掉出屏幕底部重置位置重新从顶部下落这个需求清单本身就是你写代码的“施工图”。我写游戏的习惯是先列规则再写代码不然容易写着写着把自己绕进去。4.2 完整代码与逐段解析import pgzrun import random WIDTH 800 HEIGHT 600 ball Actor(ball, center(400, 100)) pad Actor(pad, center(400, 550)) vy 0 score 0 GRAVITY 500 PAD_SPEED 400 def draw(): screen.clear() ball.draw() pad.draw() screen.draw.text(f得分: {score}, (10, 10), fontsize30, colorwhite) def update(dt): global vy, score if keyboard.left: pad.x - PAD_SPEED * dt if keyboard.right: pad.x PAD_SPEED * dt pad.x max(20, min(WIDTH - 20, pad.x)) vy GRAVITY * dt ball.y vy * dt if ball.colliderect(pad) and vy 0: vy -300 score 1 if ball.y HEIGHT 50: ball.pos (random.randint(50, WIDTH - 50), 100) vy 0 pgzrun.go()代码不复杂但有几个地方值得细说。第一重力是用“每帧给 vy 加一个值”模拟的。GRAVITY 500 可以理解成重力加速度单位是“像素每秒平方”每秒速度会增加 500 像素/秒。vy 是垂直速度每次 update 都执行vy GRAVITY * dt速度就越来越大再执行ball.y vy * dt小球就越落越快。这个过程就是高中物理里的匀加速运动只不过我们把公式搬到了 update 循环里。第二接球判断加了一个vy 0的条件。这个很容易被忽略但非常关键如果球正在往上飞vy 是负数它也刚好和板子重叠你会看到球被“吸住”分数疯狂往上跳。加这个条件保证只有球往下落时碰到板子才触发反弹上行的球直接穿过不处理。类似的方向判断在以后的跳跃游戏里你会反复用上。第三重置时把 vy 归零而不是只挪位置。我见过不少人重置位置时忘了清零速度球回顶了还带着原来的速度继续飞结果第一秒就冲出屏幕底部。重置状态这件事一定要把跟运动相关的变量都一起带上。代码里我为了演示方便写了两行分开的赋值实际更喜欢写成一行元组赋值ball.pos (...)加一行vy 0一眼就能看出“位置和速度一起重置了”。这块小游戏写完后你可以自己扩展玩法。比如每接一次就让板子变窄一点或者让球落下的初始位置更随机甚至可以做一个“撞击变向”的效果。每个扩展最后都会落回 update 里改逻辑这恰恰是特训的意义不是背代码而是学会在 update 里组织自己的规则。4.3 怎么快速验证逻辑对不对很多初学者卡在“代码跑起来没报错但行为不对”不知道从哪里查。这里分享三个最实用的手段。第一是print。在 update 里打印关键变量的值def update(dt): global vy, score # ... 业务逻辑省略 if ball.y % 10 0: print(ball.pos, vy)注意别每帧都 print一秒钟 60 行日志终端刷屏刷到你想哭。加个条件或者只在事件触发时打印输出量才可控。第二也是我最推荐的把调试信息画到屏幕上。print 只能看终端做游戏时盯着画面其实更容易发现问题。第三是故意制造低帧率来验证 dt 是否生效。你可以临时在 update 里加一个 for 循环空转几百次人为制造卡顿然后观察小球是否仍然在匀速移动。如果速度保持不变说明dt的补偿逻辑对了。做一次这个实验你对 dt 的理解会立刻上一个台阶。跑完记得把这些测试代码删掉或注释掉留着会影响正式手感。5. 常见坑与避坑清单5.1 常见问题速查表我把自己带初学者时遇到频率最高的几个 update 相关问题整理成了表可以直接对照自查。现象可能原因解决办法画面完全不动没写pgzrun.go()或 update 里没有任何变量改变检查主循环入口确认移动代码写在 update 内角色移动忽快忽慢移动写成每帧固定像素没乘 dt统一改成“速度 * dt”按下按键只动一次把持续移动写进了on_key_down持续移动放 update 里用 keyboard 状态判断球被反复“吸住”加分碰撞反弹时没判断运动方向加上如vy 0这样的方向条件小球重置后立刻冲出屏幕重置位置时忘了清零速度重置时同时清零相关速度变量画面一卡一卡、闪烁把逻辑写进了 draw或 draw 里做了耗时操作逻辑全部迁到 update运行报错 NameError函数里给全局变量赋值但没声明global函数内用到全局赋值时先写global这张表看着简单但每一条都是真实踩过的坑。我给学生的建议是先把这张表抄在自己的项目笔记里遇到问题先对照比从头读一遍文档快得多。5.2 一个容易被忽略的调试手段把状态画到屏幕上前面提过把调试信息画到屏幕上这里单独展开一次。做游戏调试时print 有一个致命缺点你看不到“那一刻画面到底发生了什么”。比如你想排查球是不是在某个位置穿模了终端里只有数字没有画面很难定位到具体帧。我的做法是在 draw 里加一排“调试 HUD”def draw(): screen.clear() ball.draw() pad.draw() screen.draw.text(fpos: {ball.pos}, (10, 30), coloryellow) screen.draw.text(fvy: {vy:.1f}, (10, 60), coloryellow) screen.draw.text(ffps: {clock.get_fps():.0f}, (10, 90), coloryellow)fps 可以直接用 pgzero 内置的clock.get_fps()拿到非常方便。一边跑一边盯着数值变化能发现很多“看代码看不出问题”的瞬间。调完之后把这几行注释掉就行不用删下次排查新问题还能复用。这个 HUD 思路到了后续做多物体、做粒子效果时同样有效。你可以在屏幕上画出每个物体的碰撞框、速度方向、状态标记调试效率和“靠蒙”完全不是一个级别。6. 关于 update 特训我自己的一点体会写到这想把我带过很多新手之后最想强调的一点放在最后update 的思路本质上就是“每一帧都问自己现在这个世界的状态要变成什么样”。一开始你可能只会在里面写一行ball.x 1等游戏复杂度上来会发现几乎所有规则最后都会落到 update 里计时、移动、碰撞、计分、敌人 AI、动画状态切换全是它的活儿。与其去背一个个孤立的知识点不如把 update 当成一个“世界模拟器”来练。每次加一条游戏规则都是往这个模拟器里加一条“每帧执行的规则”。这个心态建立起来之后后面学计时器、学动画、学场景切换都会顺很多。这个系列后面会继续拆 update 的进阶用法比如怎么用计数器做定时任务、怎么实现简单的缓动动画、怎么把碰撞回调拆成独立函数。但如果你能把这篇文章里的内容真的跑一遍、拆一遍update 篇的第一层地基就算打扎实了。最后照例留一个作业把上面的接球游戏改成一个你想自己设计规则的小游戏哪怕只是把重力改成一个离谱的值或者让板子接一次就变窄 10 像素。动手跑过和只看文章效果完全不一样。