跳转到内容

第 19 章 · 状态同步还是命令同步

约 9 分钟 难度:3 动手章

这一章解决:在闭环 2 的基础上,下一段需要同步的状态是该走「同步当前是什么样」还是「同步刚发生了什么动作」。两条路看起来都能跑通,但调试成本、迟入恢复难度和带宽走得完全不一样。

先看一下

闭环 2 的 GameState 同步了 phasetotalScorephaseStartServerTime 三个字段。三者都是「当前是什么样」:迟入玩家进来读这三个字段就能知道现在在 INGAME、当前总分多少、本阶段开始于哪一秒。这一类同步在迟入恢复上是天然的,但有一类问题它处理不了:第 17 章里 A 玩家击杀 B、加 1 分,这个「击杀事件」本身没有被同步过来,做战报、做爆头特效、做击杀回放都接不到入口。

把同步分成两条路:状态同步贴的是「当前棋盘长什么样」,命令同步贴的是「玩家刚下了哪一步」。本章把两条路并排,给出选择依据。

这一章会拿到什么

  • 同一个加分逻辑写两版伪骨架:一版状态同步,一版命令同步
  • 五个判断维度(迟入恢复 / 改动频率 / 字节大小 / 调试成本 / 是否需要回看历史)
  • 一份「这一段状态走哪条路」的决策思路,第 21 章会扩成完整决策表
  • 自检:能不能识别闭环 2 里的字段各自属于哪一条路

依赖前面

  • 第 1 章「事件不会重放,状态会复制」结论
  • 第 12 章请求式架构 requestId / requestType / requestPayload 字段
  • 闭环 2 三个故意保留的缺陷(重复加分 / 状态版本乱 / 串味状态)

把闭环 2 的「按按钮加 1 分」拆开,分别用两条路写一遍骨架。两版功能等价,区别只在「同步什么」。

GameState 上同步当前总分。玩家本人先发请求,GameState Owner 收到后改 totalScore,所有客户端通过 OnDeserialization 看到新值。

// GameState(状态同步版,闭环 2 的现状)
[UdonSynced] private int totalScore; // 当前总分。所有客户端读它。
[UdonSynced] private int lastScorerPlayerId; // 谁打的最后一分(可选,结算屏读)
private void HandleScore(VRCPlayerApi who)
{
totalScore += 1; // 改当前状态。
lastScorerPlayerId = who.playerId;
RequestSerialization(); // Manual sync 才需要这一行。
}

迟入玩家进来读 totalScore 立刻知道现在多少分。代码短、迟入恢复天然,但这一分是怎么打上去的没有被同步过来:是谁打的、什么时候、加了多少,全部丢失。

GameState 上同步「最近一次发生过什么动作」,每一位客户端在自己本地维护当前总分。

// GameState(命令同步版)
[UdonSynced] private int lastEventId; // 单调递增。每打一次球就 ++。
[UdonSynced] private byte lastEventType; // 0=Score, 1=Bonus, 2=Reset, ...
[UdonSynced] private int lastEventActorId;
[UdonSynced] private int lastEventPayload;
// 每位客户端本地维护
private int localTotalScore;
private void HandleScore(VRCPlayerApi who)
{
lastEventId += 1;
lastEventType = 0; // 0 = Score
lastEventActorId = who.playerId;
lastEventPayload = 1; // 加几分
RequestSerialization();
}
public override void OnDeserialization()
{
if (lastEventId == lastSeenEventId) return; // 没新事件,跳过
lastSeenEventId = lastEventId;
if (lastEventType == 0) localTotalScore += lastEventPayload;
// ... 别的事件类型分支
}

每位客户端自己累加总分,根据收到的命令一条条放。打了几分、什么时候、谁打的,全部写在命令字段里,能直接被战报 UI 读取。

但迟入玩家进来时 localTotalScore 是 0,他读不到「之前发生过什么」。这条路天然没有迟入恢复。


写法 A 不是写法 B 的简化版,两者各有适合的场景。下面五个维度是写新一段同步前要逐条问的问题。

迟入玩家进来时能不能看到「该有的样子」。

  • 状态同步天然支持。同步变量本身是当前快照,迟入玩家拿到的就是当前值。
  • 命令同步默认不支持。命令是「发生过一下」,迟入玩家不会重放历史命令。要让迟入恢复,得在 GameState额外留一份当前快照(比如 localTotalScore 也变成 [UdonSynced]),命令同步就退化成「状态 + 命令两份都同步」。

判断标准:这段状态如果迟入玩家看不到会不会让他「看不懂这局」?看不懂就必须有当前快照,命令同步不能孤立用。

每秒、每分钟、每局发生几次。

  • 每秒多次(按住按钮加分、连发技能、移动位置):状态同步代价高。每次改动都是一次 RequestSerialization,会撞 per-object rate limit 和 11 KB/s 总预算。命令同步通常更省,因为「最近一次发生了什么」比「当前精确状态」要小一个量级。
  • 每局几次(开局、阶段切换、胜负判定):用什么都行。状态同步因为简单更稳,不要为了精炼字节让逻辑变复杂。

判断标准:把改动频率乘以单次字节数,看落进 11 KB/s 预算之后还剩多少。预算还宽就选简单写法。

单次同步的字节体量。官方当前文档写法是:Continuous 单次序列化约 200 bytes,Manual 单次序列化约 280 KB(per-object,且有随数据大小变化的 rate limit)。

  • (单字段、bool、byte、int,几个到十几个字节):状态同步轻松装得下。
  • (一个 16 长度的 int 数组、一段几十字节的字符串):两条路都行,看维度 1 的频率。
  • (整张棋盘的 JSON、玩家事件的全量列表):命令同步天然友好,因为命令本身只描述「最近一次变化」,不背全量。状态同步要装大对象就只能切 Manual sync 并接受 rate limit。

判断标准:单次同步字节超过 1 KB,先考虑命令同步或拆字段;到几十 KB 时不要硬塞一个字段,接近官方 manual sync 单次上限(约 280 KB)时必须拆分。

哪条路出 bug 时容易找到原因。

  • 状态同步的同步变量值是「现在 GameState 上是什么」。Bug 经常表现为「我看到的值不对」,但「为什么变成这个值」要回到代码里翻路径。
  • 命令同步因为每次变化都留下了「类型 + 谁 + 何时 + payload」,可以在 Debug View 里直接看「上一条命令是什么」。第 22 章的命令日志会把这一点扩成可重放结构。

判断标准:这段状态如果出错,需不需要复盘「上一步发生了什么」?需要的话命令同步是天然友好的。

需要不需要把「过去一段时间的全部动作」拿出来再看一遍。

  • 状态同步天然不保留历史。当前是什么就是什么,过去的状态被覆盖了。
  • 命令同步保留至少最近一条命令,配合命令日志(第 22 章)可以保留全程。

需要回看历史的典型场景:回放、战报、击杀提示、聊天记录、棋牌走子记录。这些场景下状态同步孤立用不了。


按这五个维度回头看闭环 2 的同步字段:

字段维度 1 迟入维度 2 频率维度 3 字节维度 4 调试维度 5 历史结论
phase必须每局几次1 byte简单不需要状态同步合适
totalScore必须每场几十次4 bytes中等局内不需要状态同步够用
phaseStartServerTime必须每阶段一次8 bytes简单不需要状态同步合适
「谁打了这一分」(第 17 章战报需要)不必须同 totalScore4 bytes需要复盘需要列表命令同步合适
「玩家进入旁观席的事件」(第 18 章弹幕)不必须偶发几字节需要复盘需要历史命令同步合适

闭环 2 三个 GameState 字段属于状态同步合适的类型,所以闭环 2 用状态同步是对的。第 17 章的战报、第 18 章的弹幕在闭环 2 的写法里没真正落地(只贴了 UI),背后原因之一就是缺一条命令同步通道。



挑一条试。每条不要直接翻第 21 章决策表答案,先按五维度自己判断一次。

  • 设计一个「房间公告板」,玩家能输入一行字让所有玩家看到。这段状态走哪条路?如果换成「滚动通知」(每条只显示 5 秒就消失),结论变不变?
  • 第 17 章「战报」做一个「最近 5 条得分」的滚动列表。这一段是状态同步还是命令同步?需不需要持久化(迟入玩家要看到列表里之前的内容吗)?
  • 一个「玩家拍照存到画廊」的功能,每位玩家一局最多拍 10 张,存的是图片 URL。状态 vs 命令?为什么?

本章不写代码,没有可运行的测试。自己改着玩三条之一选完写出来后,按下面的表跑:

场景预期实际
1. 多客户端 Build & Test(2 / 4 人)写法 A 与写法 B 在多人场景下都能跑出加分流程
2. 迟入玩家加入状态同步版本:迟入玩家立刻看到当前分数;命令同步版本:迟入玩家从 0 开始(如果没加快照)
3. 长按按钮 5 秒状态同步:每次 RequestSerialization 都尝试发送,可能撞 rate limit;命令同步:同样会撞,但单条字节更小
4. Owner 离开后状态同步:候选队列接管即拿到当前值;命令同步:新 Owner 上 lastEventId 仍然是当前值,但 lastSeenEventId 数组要正确迁移
5. 故意制造网络抖动状态同步:可能看到中间态(闭环 2 缺陷 2);命令同步:可能看到「跳过了几条命令」

第 5 行是后续第 20 章版本号 / 幂等要解决的问题。


  • 能用一句话说清楚「状态同步」和「命令同步」的核心区别
  • 能默写五个判断维度,并对每一维举一个状态合适、一个命令合适的例子
  • 能识别闭环 2 三个 GameState 字段为什么走状态同步是对的
  • 能解释「全部走命令同步省字节」错在哪
  • 能识别迟入恢复对命令同步的限制,并说出两种补救方向