news 2026/10/22 6:32:41

Pygame Zero游戏循环核心:深入理解update()函数与dt参数

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Pygame Zero游戏循环核心:深入理解update()函数与dt参数

刚上手 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 每一帧的实际执行顺序大致如下:

  1. 处理输入事件(键盘、鼠标、触控)
  2. 调用update()—— 更新游戏状态
  3. 调用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 * dt

dt是"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()

问题有三个:

  1. 逻辑与表现混在一起:一旦你想"暂停游戏"(比如玩家点了暂停按钮,你需要停止更新但不停止绘制),你就无从下手——绘制本来就该持续进行,但状态不该变。如果逻辑混在draw()里,你根本没法单独暂停它。
  2. 绘制顺序影响状态:如果画面里要画多个角色,先画的那个角色状态改变了,后面画的时候看到的坐标就"不同步",容易出现莫名其妙的偏差。
  3. 不利于调试:你很难判断到底是"状态算错了"还是"画面画错了",因为两边都改了数据。

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() 保持"轻盈"的三个原则

我总结了三个原则,写游戏时照着做,性能基本不会差:

  1. 只算必要的东西:如果某个值这一帧没变(比如背景层的坐标),别反复重算。
  2. 避免在 update() 里做 IO:读文件、解压缩、网络请求,全都放外面。
  3. 对象数量要心里有数:如果一帧要循环检测几万个对象的碰撞,就得考虑空间划分(如网格划分、简单的四叉树),或者减少每帧检测的配对数量。

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()里的更进阶玩法——状态机切换、对象池复用、复杂碰撞的拆分处理,到时候见。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 6:32:26

Spring AI Alibaba:在Spring AI之上构建统一大模型接入层的分层架构实践

过去几周我一直在折腾一个内部知识库的智能问答模块。说实话&#xff0c;刚拿到需求的时候&#xff0c;我脑子里跳出来两条路线&#xff1a;直接用大模型厂商的 SDK 写&#xff0c;或者引入 Spring AI 做统一抽象。等我打开 Spring AI 的官方文档翻了半天&#xff0c;又发现事情…

作者头像 李华
网站建设 2026/10/22 6:31:16

OpenAI公开722篇数学论文与Meta代理协议:AI能力与信任双线推进

2026年10月7日&#xff0c;AI 领域的消息密度让我这个从业者都有点应接不暇。上午圈子里还在聊模型推理成本和开源生态&#xff0c;下午就直接被两条消息刷屏&#xff1a;OpenAI 一夜之间公开了 722 篇数学相关论文&#xff0c;Meta 联合沃尔玛发布了「个人代理协议」。前者是在…

作者头像 李华
网站建设 2026/10/22 6:30:19

XXL-AI v1.1.1桌面客户端与云本结合,重塑端云协同与隐私边界

1. 先看这次更新&#xff1a;v1.1.1 到底改了哪些东西收到 XXL-AI v1.1.1 发布的消息&#xff0c;我的第一反应不是去翻更新日志&#xff0c;而是先把桌面客户端拉下来装了一遍。作为一个从 v1.0 版本就开始用、中途还因为网页端在长文本场景下卡顿而差点弃坑的用户&#xff0c…

作者头像 李华
网站建设 2026/10/22 6:30:11

Claude Code视频Skill生态盘点:180个工具与工作流组合实践

最近圈子里明显热闹起来的一件事&#xff0c;是围绕 Claude Code 的开源 Skill 生态突然长出了一大片新东西。尤其视频相关&#xff0c;从脚本、分镜、画面生成到配音、剪辑、平台适配&#xff0c;几乎每隔几天就有新仓库冒出来。我这一两周把 GitHub 上能翻的视频类 Skill 大体…

作者头像 李华
网站建设 2026/10/22 6:29:24

考研复试C/C++机试全攻略:高频考点、环境准备与避坑指南

考研复试机试C/C回顾每年复试季&#xff0c;总有学弟学妹问我机试到底怎么准备、难度如何、该刷哪些题。说实话&#xff0c;机试这东西不像笔试那样背一背就能过&#xff0c;它考察的完全是另一套能力——短时间内的代码实现、调试、还有临场心理素质。我自己当年备考的时候踩了…

作者头像 李华
网站建设 2026/10/22 6:29:24

AI Agent入门:别急着啃框架,先手写一个最小Agent循环

身边一个做运维的朋友A同学&#xff0c;年初决定转行做AI应用开发。他的第一步和大部分人一样&#xff1a;收集了各个Agent编排框架的官方文档、翻译版电子书、付费视频课&#xff0c;笔记记了两大本&#xff0c;还跟着教程敲了好几个Demo。结果呢&#xff1f;学了一个多月&…

作者头像 李华