动画同步:复制播放命令不等于复制动画状态

自动关联目录:动画同步:复制播放命令不等于复制动画状态

核心判断:网络动画的复制单位应是可重建的播放快照,而不是一次性的 Play RPC。快照必须钉住稳定轨道身份、单调代际、资产版本、权威时钟和停止阶段;动画事件默认只是本地表现事实,只有带幂等键并经服务端验证的语义事件才能进入玩法。

一、业务冲突:命令送达了,远端仍可能永远不一致

播放命令有三个天然的不可靠前提:远端动画宿主可能尚未创建,晚加入玩家没见过过去的 Play,拥有者预测后的服务端回声还可能把动画倒回网络往返前的位置。若动画资源在等待期间发布了新版本,晚加入再按“当前版本”解析相同逻辑 ID,甚至会与房间内其他玩家播放不同内容。

停止也不是一个瞬时布尔值。带淡出的 Stop 到达后,轨道仍在贡献姿势;若服务端立刻从活动集合删除它,晚加入者会看见该动作完全不存在。反过来,若没有最终删除标记,结束轨道会永久残留在重建快照里。宿主晚到时还有更细的竞态:Seek、调速或调权重可能覆盖队列里的 Play,最后只应用调整命令,却从未创建轨道。

因此“RPC 可靠、有序”并不能解决问题。RPC 只保证消息路径,动画需要的是在任意加入时间、任意宿主创建顺序和重复投递下都能恢复同一个播放会话。

二、实际实现:稳定 Track、Generation、版本 Pin 与 Tombstone 组成快照

当前协议为每条网络轨道分配稳定 ID,并为每次权威变更递增 Generation。客户端只接受严格大于本地已应用代际的快照,旧包和重复包直接拒绝。Play 与重建 Snapshot 必须携带不可变的动画身份和版本;晚加入沿用已经在播的版本,不能查询当前注册表后重新选版。

服务端保存活动轨道快照。Stop 只把快照标记为 Stopping 并保留版本与权重目标,直到淡出结束或 Ended 才广播 Tombstone 并从活动集合删除。新玩家 Ready 后收到所有非墓碑快照;客户端依据服务端时间和速度追赶相位,再执行 Play、Seek 和必要的 Stop。

宿主未创建时,快照按“宿主身份 + Track”暂存,只保留最高 Generation。后到的 Seek/Speed/Weight 不得抹掉尚未物化的 Play;第一次应用若发现只有调整命令但携带完整内容身份,会先提升为 Play。对象出现事件和短周期重试共同推动队列,销毁时用本地生命周期代际使旧重试失效。

拥有者先本地播放,再向服务端请求。确认回来后,本地预测 Track 原子映射到权威 Track ID,事件、查询、停止和销毁都改走新身份,但不会重放 Play,否则会按上行延迟倒带。Pending 请求有超时清理;同一确认若代际已过期也不会重新挂载。

sequenceDiagram
    participant O as 拥有者客户端
    participant S as 服务端快照库
    participant R as 观察者客户端
    O->>O: 预测 Play(local track)
    O->>S: Request(asset pin, request id)
    S->>S: 分配 stable track + generation
    S-->>O: Ack(snapshot)
    O->>O: Remap,不重放 Play
    S-->>R: Snapshot
    R->>R: 宿主缺失则按最高 generation 暂存
    R->>R: 宿主就绪后 Play + phase catch-up
    S-->>R: Stop(stopping=true)
    S-->>R: Tombstone(new generation)

三、值得保留:协议开始围绕“可重建状态”而不是 API 调用

资产版本 Pin 是关键。逻辑资产 ID 只能说明“想播什么”,不能说明本次多人会话实际使用哪个制品;快照沿用已播放版本,避免发布更新改变进行中的房间。缺少版本的晚加入快照选择失败关闭,也比悄悄选当前版本更可靠。

Generation 同时解决乱序、重复和超时确认。稳定 Track ID 又把本地预测对象、服务端对象和重建后的运行轨道连接起来,使事件与 Stop 不会继续发往已经废弃的本地 ID。Stop 与 Tombstone 分离则保留了淡出阶段的可重建性。

延迟物化队列也不是简单缓存原消息,而是按 Track 合并状态并保护创建命令;这直接修复了“最后一条调整消息覆盖 Play”的竞态。动画实例内对 Stopped、Ended、Loop 和 Marker 使用边沿标志和区间扫描,包含正放、倒放与跨多次循环,避免低帧率跳过 Marker 或重复结束事件。

四、结构性缺陷:播放状态可恢复了,事件权威仍不完整

相位追赶直接使用“本地当前时间 - 服务端发包时间”,隐含两端时钟可比较。没有经过同步的网络时间或估计偏移时,相位误差会被当成真实播放时长。正确输入应是权威时间域中的起播时间,再通过连接维护的时间偏移映射到本地表现时钟。

协议状态分散在脚本快照库、Actor Pending 队列和 AnimInstance Track 中,三者都保存一部分时间、停止和身份。出现问题时需要跨层拼接日志,且某层清理成功、另一层残留就会产生幽灵轨道。固定超时只能回收请求,不能告诉上层这是被拒绝、宿主永久缺失还是会话已经销毁。

更重要的是 Marker 上行只有 Track、名称和载荷,缺少播放代际、循环序号、Marker 序号或唯一事件 ID。重试、重连或恶意客户端都可能重复发送同一 Marker;服务端也无法判断它是否对应当前权威播放窗口。动画 Marker 可以驱动脚步声和特效,但不能直接成为伤害、掉落或状态转换的唯一依据。

五、目标设计:快照恢复表现,语义事件用独立的幂等协议

目标协议把动画会话定义为 {EntityId, TrackId, PlayEpoch}。权威快照包含资产 Pin、起播权威时间、速度、循环、权重、阶段和 Revision;调整命令只产生新的 Revision。客户端应用前先检查会话身份和单调 Revision,再把权威时间映射为本地相位。宿主不可用时保存最新完整快照,而不是保存一串必须重放的 API。

ApplyResult Apply(const AnimSnapshot& s, LocalReplica& r, NetworkClock clock)
{
    if (!s.ValidPin() || s.Revision <= r.LastRevision(s.Session))
        return Rejected;

    r.RememberRevision(s.Session, s.Revision); // Tombstone 即使宿主缺失也要记住
    if (s.Phase == Tombstone)
        return r.Remove(s.Session);
    if (!r.HostReady())
        return r.DeferLatest(s);

    float phase = s.PhaseAt(clock.ToLocal(s.AuthorityStartTime));
    r.MaterializeOrUpdate(s.Session, s.AssetPin, phase, s.Weight, s.Phase);
    return Applied;
}

EventId MakeEventId(SessionId session, int loop, int markerOrdinal);

表现事件在本地按区间扫描产生,可以丢弃不可见或过期轨道的事件。需要进入玩法的事件必须由服务端模拟直接产生,或携带 EventId + PlayEpoch + 发生相位 上行,由服务端验证当前会话、允许窗口和幂等账本。晚加入只恢复当前姿势和阶段,不补发已经过去的一次性玩法事件。

六、决策规则:状态用快照恢复,事件按权威后果分类

场景协议行为不变量
拥有者预测 Play 后收到确认原子 Remap,禁止重放相位不因 RTT 倒退
观察者宿主晚于快照创建保存最新完整快照,宿主 Ready 后物化调整消息不会覆盖创建事实
晚加入时资产已有新版本使用会话 Pin,缺 Pin 失败关闭同一会话内容一致
Stop 正在淡出快照保留 Stopping,结束后发 Tombstone可重建且最终清理
Tombstone 先于宿主到达仍记录 Revision 并清 Pending旧 Play 永不复活
低帧率跨过多个 Marker在时间区间内按序枚举不漏不重,顺逆播一致
Marker 触发玩法后果服务端验证 EventId 或直接权威产生重复上行至多生效一次

这段实践的提升点很明确:播放同步已经从“广播调用”演进到“复制会话状态”,但事件仍容易被动画 API 的便利性诱导成玩法协议。真正可靠的边界应是:动画快照负责远端看起来一致;玩法后果由权威语义事件负责。两者可以共享播放会话身份,不能共享权威假设。