固定时间步已经开了,为什么游戏回放还会走出不同位置?
录下“按住右键,再松开”,把物理步长固定成 20 毫秒,同一段操作是否就能稳定重放?如果输入仍然每个渲染帧只取一次,答案仍可能是否定的:某一帧要补算四个逻辑步时,按下与松开可能先被合并成最后的“未按住”,四步都没有移动。
这里讨论的是作者 Playable Runtime 研究方案中的一个缺口:固定时间步决定每一步推进多少模拟时间;回放还必须决定每个动作在哪一步之前生效。 两者合起来,才能开始检查状态是否一致。本文没有实现完整 Runtime SDK,也没有运行浏览器游戏;后面的数值来自一个明确简化的整数模型。
引擎固定了物理步长,没有替你固定动作落点
Phaser 的 Arcade Physics 提供 fixedStep。2026 年 10 月 5 日核对官方文档及 v4.1.0 固定提交 7304c64e 时,World.update() 中可以看到:累计传入的 delta,先处理本次需要的第一步,再循环处理剩余步。下面是 World.js 第 1002–1007 行的原样短摘:1
while (this._elapsed >= msPerFrame)
{
this._elapsed -= msPerFrame;
this.step(fixedDelta);
}
这段代码证明的是“一次更新可能推进多个物理步”,不是“Phaser 回放有 bug”。如果接入层在这次更新前只读取一次按键状态,那么补算的几个步就可能共享同一个输入。引擎把时间切细了,应用仍可能把动作合并粗了。
浏览器这一侧也没有稳定的“一帧等于一步”承诺。MDN 说明,requestAnimationFrame() 的回调频率通常跟随显示刷新率,并且大多数浏览器会在后台标签页或隐藏 iframe 中暂停调用。它适合驱动显示,不能直接作为回放日志里的逻辑编号。2
因此,看到日志中同样的四次 simulate(0.02),还不能断言两次运行接受了同样的动作序列。还要看每次调用之前到底应用了哪些输入。
先分清三条时间线
屏幕刷新、输入事件到达和逻辑状态推进,是这里需要分开的三条时间线。刷新负责决定何时看到画面,事件队列负责保存用户按键,逻辑步决定这份输入何时影响位置。刷新快慢改变,不应让已经录好的动作重新获得不同的落点;但实时录制时事件到达条件不同,确实可能让两次按键采用不同tick。这是两个检查对象,不是同一个确定性承诺。
一个渲染批次补算多个物理步,只告诉你模拟在追赶累计时间。它没有说明玩法更新、按键采样和物理推进各被调用几次。如果玩法在渲染update里只执行一次,物理却执行四次,计时器和输入状态的消费可能与逐步模型不同。需要沿真实调用入口记录这些关系,不能看到fixedStep配置就假定所有系统都改成了逐tick更新。
排查时先画出一次渲染回调的实际顺序:读取了哪些事件,推进了多少逻辑步,每一步之前是否还有动作应用点,之后什么时候显示。把渲染批次编号和逻辑tick编号分别保存。看到一次批次有多个tick是正常现象;看到同一tick被两个入口推进,或动作仅带批次号而无法定位到步,才需要进一步核对。
暂停也是边界条件。后台恢复时若显示时间突然增加,应用选择暂停或补算,会影响下一步状态。即使每一步仍是二十毫秒,也可能执行了不同数量的步。比较回放时必须先对齐目标tick,不能只比较相同墙钟等待时长后的截图。本文不指定某浏览器后台的精确行为,也不给统一追赶上限;MDN说明支持的是显示调度不等于稳定逻辑时钟。
这份回调记录先检查tick是否连续且只推进一次,再核对每步动作。确认对应关系后再查碰撞或随机数,避免只看最后位置而漏掉最早的输入差异。
同一条动作带,换一种分组就丢掉了按住区间
用一个极小模型隔离这个问题:初始 x=0,每步 20 毫秒;只要右键处于按住状态,位置就增加 1。动作带已经给定:第 2 步之前按下,第 4 步之前松开。没有碰撞、随机数、网络或浏览器事件调度。
比较两种回放方式。第一种在每个渲染批次开始前,先取完这个批次覆盖的全部动作,只保留最终按键状态,然后执行批次内的各步。第二种则在执行每一步之前,只应用属于这一步的动作。
| 输入处理方式 | 四个批次,每批一步 | 一个批次,一次四步 |
|---|---|---|
| 批次开始先取完动作 | x 依次为 0、1、2、2 | x 依次为 0、0、0、0 |
| 逐步应用对应动作 | x 依次为 0、1、2、2 | x 依次为 0、1、2、2 |
第一行的粗批次先处理“按下”,紧接着又处理“松开”。开始积分时只剩未按住,因此丢掉了原本属于第 2、3 步的移动。第二行保留动作落点,分组只影响何时显示结果,没有改变这个模型的状态序列。
下面的单文件例复用随稿29行Python模型的整数推进逻辑,包含有限的同tick顺序检查;四条完整tick/held/x轨迹与原模型一致。它们验证构造反例及整数计算,没有证明Phaser游戏、移动端或跨平台物理具备确定性。真实浏览器事件何时进入队列,仍需单独记录;此表不是性能或兼容性测试。3
完整本地例:同一动作带不随显示分组重定落点
文件使用两条固定记录 (2,10,True)、(4,20,False),依次表示已采用的applied_tick、同tick序号seq、右键持有值。它们已经是录制决定的结果,不是刚到达的浏览器事件。代码没有墙钟参数,也不按显示批次重新计算落点。每个逻辑tick概念上覆盖20毫秒,按住每步x加1;真实执行耗时、显示间隔、键盘响应均不在测量范围。
run接收显式传入的已采用状态表,提供两条推进路径:错误对照在批次前取完动作,正确对照在每步前应用。adopted_tape把记录按applied_tick和seq整理,本例的“持有状态”在同tick取最后值。它没有实现完整动作日志;原始同tick边沿若另用于触发跳跃,不能丢掉后只用这张持有状态表恢复。
将下面完整代码保存为 tick_replay.py,或在包内 examples/ 使用同名附件,运行:
python3 tick_replay.py
"""Reuse the original integer model; no Phaser/browser/physics/RNG/game run."""
RECORDS = ((2, 10, True), (4, 20, False)) # applied_tick, seq, adopted held value
def adopted_tape(records):
seen, tape = set(), {}
for applied_tick, seq, value in sorted(records):
if type(applied_tick) is not int or not 1 <= applied_tick <= 4:
raise ValueError("tick_outside_example")
if type(seq) is not int or seq < 0 or type(value) is not bool:
raise ValueError("record_shape")
identity = (applied_tick, seq)
if identity in seen:
raise ValueError("duplicate_tick_seq")
seen.add(identity)
tape[applied_tick] = value
return tape
def run(frame_groups, per_tick, tape):
if any(type(n) is not int or n <= 0 for n in frame_groups) or sum(frame_groups) != 4:
raise ValueError("groups_must_cover_four_ticks")
tick, x, held = 0, 0, False
trace = []
for count in frame_groups:
end_tick = tick + count
if not per_tick:
for t in range(tick + 1, end_tick + 1):
held = tape.get(t, held)
for _ in range(count):
tick += 1
if per_tick:
held = tape.get(tick, held)
x += int(held)
trace.append({"tick": tick, "held": held, "x": x})
return trace
def rejected(name, records, expected):
try:
adopted_tape(records)
except ValueError as exc:
assert str(exc) == expected
print(f"reject_{name}={exc}")
else:
raise AssertionError(f"accepted {name}")
def main():
tape = adopted_tape(RECORDS)
assert tape == {2: True, 4: False}
results = {f"{mode}/{name}": run(groups, per_tick, tape)
for mode, per_tick in [("frame_drain", False), ("tick_apply", True)]
for name, groups in [("four_frames", [1, 1, 1, 1]), ("one_frame", [4])]}
for name, trace in results.items():
expected = [0, 0, 0, 0] if name == "frame_drain/one_frame" else [0, 1, 2, 2]
assert [r["x"] for r in trace] == expected
print(f"{name}: x={[r['x'] for r in trace]}")
assert results["tick_apply/four_frames"] == results["tick_apply/one_frame"]
left, right = results["frame_drain/one_frame"], results["tick_apply/one_frame"]
first = next(a["tick"] for a, b in zip(left, right) if a != b)
assert first == 2
print(f"first_difference_tick={first}; adopted_ticks={list(tape)}")
same_tick = ((2, 11, False), (2, 10, True))
ordered = adopted_tape(same_tick)
assert ordered == adopted_tape(tuple(reversed(same_tick))) == {2: False}
still = [r["x"] for r in run([4], True, ordered)]
assert still == [0, 0, 0, 0]
print(f"same_tick seq10=down seq11=up: x={still}; storage_order_ignored")
changed = adopted_tape(((2, 10, False), (2, 11, True)))
held = [r["x"] for r in run([4], True, changed)]
assert held == [0, 1, 2, 3] and changed != ordered
print(f"changed_seq last=down: x={held}; different_recorded_tape")
rejected("duplicate", ((2, 10, True), (2, 10, False)), "duplicate_tick_seq")
rejected("tick0", ((0, 10, True),), "tick_outside_example")
rejected("tick5", ((5, 10, True),), "tick_outside_example")
rejected("non_bool", ((2, 10, 1),), "record_shape")
print("fixed_dt_ms=20; scope=integer_holding_state_and_recorded_order")
print("browser_SDK_physics_continuous_sampling_snapshot_RNG=NOT_MEASURED")
if __name__ == "__main__":
main()
本轮在2026-10-13、Python3.11.6、macOS arm64执行,退出码0,stderr为空。实际stdout如下:
frame_drain/four_frames: x=[0, 1, 2, 2]
frame_drain/one_frame: x=[0, 0, 0, 0]
tick_apply/four_frames: x=[0, 1, 2, 2]
tick_apply/one_frame: x=[0, 1, 2, 2]
first_difference_tick=2; adopted_ticks=[2, 4]
same_tick seq10=down seq11=up: x=[0, 0, 0, 0]; storage_order_ignored
changed_seq last=down: x=[0, 1, 2, 3]; different_recorded_tape
reject_duplicate=duplicate_tick_seq
reject_tick0=tick_outside_example
reject_tick5=tick_outside_example
reject_non_bool=record_shape
fixed_dt_ms=20; scope=integer_holding_state_and_recorded_order
browser_SDK_physics_continuous_sampling_snapshot_RNG=NOT_MEASURED
前四行对应原表,不把新的顺序边界例混成新的游戏成绩。细分显示批次和粗显示批次在逐tick路径有相同完整轨迹;粗批次先取完动作则在tick2首次分歧。adopted_ticks仍为2、4,表示两条已采用记录没有随着分组变成另一个tick,而非测得了真人事件到达时刻。
同tick例特意把seq11的松开放在文件前面、seq10的按下放在后面。排序后仍先按下再松开,最终持有值为False;反转存储顺序不改变结果。它的全零轨迹与粗批次错误一样,但原因不同:这里按下和松开本来就在同tick,持有值没有覆盖后续积分区间。是否应另触发一次动作,由玩法的边沿或离散请求语义决定,不能只凭零位移宣布回放错误。
下一行改变的是记录身份:seq10变成松开、seq11变成按下,最后持有值为True。tick3/4无新动作,本例沿用上一输入,所以轨迹是0、1、2、3;这不是“同一动作带回放出了不同结果”。它说明不能删掉seq,也不能把无动作的步自动当成全部松开。若失焦、暂停或结束要求释放,需要另录采用结果或声明规则。无真实事件采集、失焦处理或暂停计时在此代码中执行。
重复的tick/seq、不在1至4范围内的tick以及非布尔持有值被拒绝,防止本例悄悄猜一个消费顺序。tick5拒绝只表示这个四步fixture的窗口结束,不表示真实Runtime不能处理第五步;日志缺口、外部事件去重、连续值真实采样和中途恢复也没有由这些断言覆盖。未知项在末行保留NOT_MEASURED。这个小例交付的是可复跑的动作消费差异和顺序反例,接入真实游戏仍须核对下一节的调度入口与后面的权威状态。
为什么终点和轨迹都要看
本轮输出中的first_difference_tick=2把首差定位到第二步。前面的四轨迹表与完整代码已列出按下、保持、松开的消费顺序,不必等到最后位置才诊断输入归属。
最终位置相同也不能证明轨迹相同。构造一种更长动作带,先右移后左移,两种错误消费方式可能都回到起点,但途中位置会不同。如果途中有触发器或碰撞,后续分支便可能改变。因此检查点应覆盖关心的中间步,不能仅断言游戏结束时x相等。这个例子是对验证方法的推导,没有新增已运行的游戏结果。
短按还有另一种边界:按下与松开被录制规则安排到同一个tick。按顺序执行后最终按键未按住,持有状态模型可以不产生移动,这并不一定是回放错误。若玩法需要把一次按下识别为离散触发,应把触发动作的消费语义另行定义,不能靠持有状态积分暗中代替。是否保留边沿、如何定义同tick动作,应由玩法要求决定并写进日志语义。
回归输入应写明“同一份已定序动作、只改变显示批次”,避免维护者重新采样真人操作后,把不同落点造成的差异误报为回放退化。
随稿整数模型的价值是给出一个可以读懂的失败,不是展示复杂物理。读者可先用它理解批次折叠,再在真实接入点寻找相同消费模式。真实游戏若没有这种处理方式,就不应套用结论;即使修复了这种方式,也需要继续验证状态依赖,不能将小模型断言扩成跨平台保证。
录制时做一次决定,回放时不要再猜一次
原研究方案已经提出按 tick 记录输入、保存 seed 和状态快照。需要进一步明确的是:tick 不是事件发生后随手补上的标签,而是动作正式进入模拟的落点。
对于本地轻互动内容,可以先采用一个容易解释的录制规则:输入到达时,把它排到下一个尚未执行的逻辑步;同一步内按递增序号处理,并记录最终采用的 applied_tick。这是作者建议,代价是输入可能等待一个逻辑步;它不适用于所有联网或回滚玩法。
一条记录至少要能回答:哪个动作、什么值、落在哪一步、同一步内排第几。连续摇杆也一样:如果按“每步取最后一个值”采样,就记录实际被采用的值。重放时读取这份已定序记录,不重新按当前机器的墙钟时间取整。
这里要区分两种比较:对同一份已录制动作带,可以要求改变渲染分组后状态仍相同;对两次真人重新按键,即使主观感觉一样,也不能要求浏览器事件恰好落到相同 tick。前者检查回放实现,后者还包含输入采样条件。
核心顺序可以写得很短:
取出本 tick 的动作,按 seq 排序
→ 更新输入状态
→ 执行本 tick 的玩法与物理
→ 提交权威状态并记录检查点
→ 需要显示时,从状态生成画面
这是处理顺序,不是可直接粘贴的 Phaser 插件。如果接入现有引擎,须列清哪些系统仍由原循环推进、哪些移入自管tick,并从实际日志确认每tick的推进入口,避免同一物理世界被两条路径推进。官方 ArcadePhysics.disableUpdate() 只移除自动 World.update 监听,文档同时明确 postUpdate 仍会被调用;不能凭一个关闭函数推断全部调度停止,也不应为本文建议无条件替换引擎循环。4
暂停策略也需要成为这套语义的一部分。原方案中的 delta 截断可以限制一次追赶量,但截掉的墙钟时间不会自动变成已经模拟过的时间。对于离开页面即暂停的内容,可以暂停逻辑时钟,记录暂停边界,恢复时重置并记录显示计时基线,不为后台期间凭空补 tick;如果玩法要求后台仍计时,则要另定义时间推进和补算规则,记录实际执行步数与输入消费。录制端和回放端须采用同一暂停语义,不能各自默认一种。
动作记录要保留采用结果
输入到达时可以保留原始事件时间用于诊断,但回放依据应是已决定的applied_tick与同tick顺序。前者解释用户事件何时到达,后者解释模拟何时使用。将原始时间在另一机器重新取整,会重新做一次调度决定,失去同一动作带的条件。本文采用下一未执行步只是便于解释的首版策略,不是唯一正确策略。
同tick的seq需要明确由谁分配。若按键、摇杆和外部事件来自多个队列,分别递增而合并时没有总顺序,就可能出现碰巧的处理顺序。作者建议在进入模拟的共同边界确定采用顺序,保留实际结果。这里没有引入网络时钟或宣称分布式排序已完成;复杂联网玩法需要自己的同步语义。
连续值采样和离散动作也不该混在一起。摇杆可按每步实际采用的值记录,频繁更新未被消费的中间值不必冒充状态推进;按下触发的跳跃则可能需要保留发生次数与顺序。先确定玩法究竟消费持有状态、变化边沿还是离散请求,再定义压缩方式,否则为了减小日志而合并动作,可能已经删掉必要信息。
回放遇到重复或缺失记录时,要用明确规则处理并报告。某动作被读两次,重复更新持有状态可能看不出问题,重复消费跳跃却可能改变玩法。某步没有动作,通常仍沿上一输入状态推进,不能自动解释为全部松开。暂停、失焦或运行结束时是否释放输入,须作为明确动作或规则记录,不能依赖浏览器当前状态。
先比较哪个 tick 开始不同,再比较画面
动作落点固定后,检查点还需要一个明确对象。本文把“权威状态”定义为下一步计算与规则判定真正依赖的状态,例如角色位置和速度、持有的按键状态、碰撞相关实体、计时器,以及会影响后续分支的随机数生成器状态。
只保存 seed 不足以支持任意中途恢复:如果已经消费过随机数,还需要恢复相应内部状态,或从相同起点重放到那里。只存画面也不足以恢复:两张相同截图可能背后有不同速度或待触发计时器。反过来,纯展示粒子的差异若不会反馈玩法,不应直接判成回放失败;一旦视觉结果参与碰撞、得分或下一动作判断,它就需要纳入状态或成为有序、可回放的外部输入。
读者可以把验证范围压成一段具体回归:固定内容和 Runtime 版本、初始快照、动作带以及外部输入;用平滑和集中追赶两种显示分组推进到相同 tick,逐个检查状态。发生分歧时,记录首个不同 tick、这一 tick 的已应用动作、状态字段差异和随机数状态,而不是只留下“最后分数不同”。
若用 hash 加速比较,先定义字段、稳定序列化与数值规则;hash 相等只能作为所选表示一致的检查信号,仍存在碰撞可能,更不能弥补漏记状态。恢复场景还要额外做一次“中途保存并继续”对照“从该快照恢复再继续”,因为完整重放通过不代表快照已经包含所有未来依赖。
这是一份待接到真实游戏上的验证方法,不是完成报告。最小整数模型已经展示了第一类失败:步长完全相同,动作只因归属位置被合并,状态就不同。 真正值得先实现的不是更大的回放 API,而是让“第几步应用了什么”可追溯,再检查那一步之后的权威状态。
用第一处分歧缩小范围
逐tick比较前,先核对运行身份:内容构建、Runtime版本、初态、动作带及外部输入是否相同。任何一项不同,都可能解释后续差异。输入身份固定后,从第一个不同tick取出步前状态、采用动作和步后状态,就能判断差异先出现在动作应用还是模拟计算。
假设步前状态一致、采用动作不同,优先查日志消费、同tick排序和输入保持;若采用动作也一致、步后位置不同,再查玩法与物理推进入口。若位置一致而计时器不同,就不要只围绕移动代码排障。以字段差异带动下一步,比看最后画面猜一个原因更省验证成本。这些是待用分支,没有对应实体运行日志。
状态清单必须按未来依赖挑选。一个待触发的计时器没显示在屏幕上,却会在下一步生成实体;碰撞相关实体的生命周期、当前输入保持和随机数内部状态,同样会影响后续。只选现在看得见的字段,检查点可能连续相同,直到遗漏变量参与分支时才暴露差异。该清单应跟随实际玩法核对,本文不能替未知游戏列出完整状态。
显示插值可以单独解释。相同权威tick的状态一致,两次截图仍可能位于不同显示阶段,因此画面位置略有区别并不自动意味着模拟分歧。只有显示结果反过来用于判定或下一动作输入时,才需要记录相应采样条件。比较状态和解释玩家看到的画面,是关联但不同的工作。
中途快照对照还要保留恢复点的tick与动作读取位置。状态正确却从旧动作索引继续,可能重放已经消费的动作;随机数状态正确却漏恢复输入保持,也可能在下一步出现差异。把恢复后的第一步单独比较,可以更快发现这类边界。快照通过的结论应限定在实际覆盖的恢复点和动作带。
最终交付可以分为三层:整数反例已有构造计算;真实引擎入口及动作落点是否核实,需运行证据;真实游戏状态与恢复回归是否一致,也需对应记录。不要让第一层替后两层背书。先让每一步用了什么、之后哪些字段变化可复查,回放差异才从一个画面问题变成可定位的状态问题。
来源与验证范围
原核对日为2026-10-05,本轮于2026-10-13复核固定源码、官方现行API说明与MDN。Phaser当前默认分支master为 02d8931b626d9764c133cbb3fbf99966c03c757c,本文所用World.js与ArcadePhysics.js原字节仍与固定版本相同;这只说明这两个文件的核实结果,不把默认分支或固定tag称为最新发行版。原图中的10/5核对日及原始实验证据保持不变。
Footnotes
-
Phaser 官方 v4.1.0,固定提交
7304c64effaa4a1be5b8bf02ab13143a76108a19,World.js L939–1007,2026-10-05 核对;短摘仅 L1002–1007。 ↩ -
MDN,requestAnimationFrame,2026-10-05 核对刷新率及后台暂停说明。 ↩
-
作者构造计算,随稿
05-assets/research-bundles/2026-10-05-playable-tick-replay/toy-replay.py与toy-replay-result.json。原料为个人研究包Playable-Runtime.md第 5、6、9 节及Technical-Architecture.md第 5.4 节,均是设计方案;无已交付 SDK 或游戏实测主张。本轮包内examples/tick_replay.py复用该整数推进逻辑并增加有限顺序检查;不覆盖浏览器或引擎集成。 ↩ -
Phaser 官方同一固定提交,ArcadePhysics.js L162–179,2026-10-05 核对自动更新移除范围。 ↩