固定时间步已经开了,为什么游戏回放还会走出不同位置?

固定时间步已经开了,为什么游戏回放还会走出不同位置?

录下"按住右键,再松开",把物理步长固定成 20 毫秒,同一段操作是否就能稳定重放?如果输入仍然每个渲染帧只取一次,答案仍可能是否定的:某一帧要补算四个逻辑步时,按下与松开可能先被合并成最后的"未按住",四步都没有移动。

这里讨论的是作者 Playable Runtime 研究方案中的一个缺口:固定时间步决定每一步推进多少模拟时间;回放还必须决定每个动作在哪一步之前生效。 两者合起来,才能开始检查状态是否一致。本文没有实现完整 Runtime SDK,也没有运行浏览器游戏;后面的数值来自一个明确简化的整数模型。

引擎固定了物理步长,没有替你固定动作落点

Phaser 的 Arcade Physics 提供 fixedStep。2026 年 10 月 5 日核对官方文档及 v4.1.0 固定提交 7304c64e 时,World.update() 中可以看到:累计传入的 delta,先处理本次需要的第一步,再循环处理剩余步。下面是 World.js 第 1002--1007 行的原样短摘:^1^

javascript 复制代码
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/ 使用同名附件,运行:

bash 复制代码
python3 tick_replay.py
python 复制代码
"""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如下:

text 复制代码
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。前者检查回放实现,后者还包含输入采样条件。

核心顺序可以写得很短:

text 复制代码
取出本 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

  1. Phaser 官方 v4.1.0,固定提交 7304c64effaa4a1be5b8bf02ab13143a76108a19,World.js L939--1007,2026-10-05 核对;短摘仅 L1002--1007。 ↩

  2. MDN,requestAnimationFrame,2026-10-05 核对刷新率及后台暂停说明。 ↩

  3. 作者构造计算,随稿 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 复用该整数推进逻辑并增加有限顺序检查;不覆盖浏览器或引擎集成。 ↩

  4. Phaser 官方同一固定提交,ArcadePhysics.js L162--179,2026-10-05 核对自动更新移除范围。 ↩

相关推荐
axe_hu38 分钟前
AI产品经理的职业规划与学习路径
人工智能·职场和发展·产品经理·学习方法
70后写java的老码农42 分钟前
换模型只改三行配置:LangChain4j + Spring Boot 3.5 实战,一次分别接入 9 家模型服务
后端·ai编程
ManageEngineITSM42 分钟前
工单分类怎么设计?类别、子类别与自动分派的实用方法
大数据·服务器·数据库·人工智能·分类·数据挖掘
愚农搬码1 小时前
2026 年开源表单设计器选型:哪个更好用?
后端
听我哔哔1 小时前
口播视频唇形总对不上,Seedance 2.5在zeekeAI里同步
人工智能·音视频
Raas1001 小时前
MAI Gateway实战部署:AI网关私有化部署?从0到1上手指南
人工智能·gateway·mai gateway·企业级ai产品
EnCi Zheng1 小时前
Agent自动化系统怎么搭-从Super-agent架构到事件驱动调度的完整指南
自动化·agent·领导力·调度系统·super-agent
专注仿真1 小时前
卷积神经网络(CNN)算法详解
人工智能·python·算法·代码·大模型训练
FII工业富联科技服务1 小时前
多智能体如何进入制造现场?从业务协同到数字孪生验证
大数据·人工智能·算法·机器人·制造·数字孪生