第 19 章 · 状态同步还是命令同步
这一章解决:在闭环 2 的基础上,下一段需要同步的状态是该走「同步当前是什么样」还是「同步刚发生了什么动作」。两条路看起来都能跑通,但调试成本、迟入恢复难度和带宽走得完全不一样。
先看一下
闭环 2 的 GameState 同步了 phase、totalScore、phaseStartServerTime 三个字段。三者都是「当前是什么样」:迟入玩家进来读这三个字段就能知道现在在 INGAME、当前总分多少、本阶段开始于哪一秒。这一类同步在迟入恢复上是天然的,但有一类问题它处理不了:第 17 章里 A 玩家击杀 B、加 1 分,这个「击杀事件」本身没有被同步过来,做战报、做爆头特效、做击杀回放都接不到入口。
把同步分成两条路:状态同步贴的是「当前棋盘长什么样」,命令同步贴的是「玩家刚下了哪一步」。本章把两条路并排,给出选择依据。
这一章会拿到什么
- 同一个加分逻辑写两版伪骨架:一版状态同步,一版命令同步
- 五个判断维度(迟入恢复 / 改动频率 / 字节大小 / 调试成本 / 是否需要回看历史)
- 一份「这一段状态走哪条路」的决策思路,第 21 章会扩成完整决策表
- 自检:能不能识别闭环 2 里的字段各自属于哪一条路
依赖前面
- 第 1 章「事件不会重放,状态会复制」结论
- 第 12 章请求式架构
requestId/requestType/requestPayload字段 - 闭环 2 三个故意保留的缺陷(重复加分 / 状态版本乱 / 串味状态)
一段加分逻辑的两种写法
Section titled “一段加分逻辑的两种写法”把闭环 2 的「按按钮加 1 分」拆开,分别用两条路写一遍骨架。两版功能等价,区别只在「同步什么」。
写法 A:状态同步
Section titled “写法 A:状态同步”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 立刻知道现在多少分。代码短、迟入恢复天然,但这一分是怎么打上去的没有被同步过来:是谁打的、什么时候、加了多少,全部丢失。
写法 B:命令同步
Section titled “写法 B:命令同步”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,他读不到「之前发生过什么」。这条路天然没有迟入恢复。
五个判断维度
Section titled “五个判断维度”写法 A 不是写法 B 的简化版,两者各有适合的场景。下面五个维度是写新一段同步前要逐条问的问题。
维度 1:迟入恢复
Section titled “维度 1:迟入恢复”迟入玩家进来时能不能看到「该有的样子」。
- 状态同步天然支持。同步变量本身是当前快照,迟入玩家拿到的就是当前值。
- 命令同步默认不支持。命令是「发生过一下」,迟入玩家不会重放历史命令。要让迟入恢复,得在
GameState上额外留一份当前快照(比如localTotalScore也变成[UdonSynced]),命令同步就退化成「状态 + 命令两份都同步」。
判断标准:这段状态如果迟入玩家看不到会不会让他「看不懂这局」?看不懂就必须有当前快照,命令同步不能孤立用。
维度 2:改动频率
Section titled “维度 2:改动频率”每秒、每分钟、每局发生几次。
- 每秒多次(按住按钮加分、连发技能、移动位置):状态同步代价高。每次改动都是一次
RequestSerialization,会撞 per-object rate limit 和 11 KB/s 总预算。命令同步通常更省,因为「最近一次发生了什么」比「当前精确状态」要小一个量级。 - 每局几次(开局、阶段切换、胜负判定):用什么都行。状态同步因为简单更稳,不要为了精炼字节让逻辑变复杂。
判断标准:把改动频率乘以单次字节数,看落进 11 KB/s 预算之后还剩多少。预算还宽就选简单写法。
维度 3:字节大小
Section titled “维度 3:字节大小”单次同步的字节体量。官方当前文档写法是: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)时必须拆分。
维度 4:调试成本
Section titled “维度 4:调试成本”哪条路出 bug 时容易找到原因。
- 状态同步的同步变量值是「现在 GameState 上是什么」。Bug 经常表现为「我看到的值不对」,但「为什么变成这个值」要回到代码里翻路径。
- 命令同步因为每次变化都留下了「类型 + 谁 + 何时 + payload」,可以在 Debug View 里直接看「上一条命令是什么」。第 22 章的命令日志会把这一点扩成可重放结构。
判断标准:这段状态如果出错,需不需要复盘「上一步发生了什么」?需要的话命令同步是天然友好的。
维度 5:是否需要回看历史
Section titled “维度 5:是否需要回看历史”需要不需要把「过去一段时间的全部动作」拿出来再看一遍。
- 状态同步天然不保留历史。当前是什么就是什么,过去的状态被覆盖了。
- 命令同步保留至少最近一条命令,配合命令日志(第 22 章)可以保留全程。
需要回看历史的典型场景:回放、战报、击杀提示、聊天记录、棋牌走子记录。这些场景下状态同步孤立用不了。
把闭环 2 的字段映射回去
Section titled “把闭环 2 的字段映射回去”按这五个维度回头看闭环 2 的同步字段:
| 字段 | 维度 1 迟入 | 维度 2 频率 | 维度 3 字节 | 维度 4 调试 | 维度 5 历史 | 结论 |
|---|---|---|---|---|---|---|
phase | 必须 | 每局几次 | 1 byte | 简单 | 不需要 | 状态同步合适 |
totalScore | 必须 | 每场几十次 | 4 bytes | 中等 | 局内不需要 | 状态同步够用 |
phaseStartServerTime | 必须 | 每阶段一次 | 8 bytes | 简单 | 不需要 | 状态同步合适 |
| 「谁打了这一分」(第 17 章战报需要) | 不必须 | 同 totalScore | 4 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字段为什么走状态同步是对的 - 能解释「全部走命令同步省字节」错在哪
- 能识别迟入恢复对命令同步的限制,并说出两种补救方向
- VRChat Creator Docs · Networking and Synchronization — 同步变量与
OnDeserialization的官方说明。 - 第 1 章 · 一局游戏需要哪些状态 — 持续 × 瞬时 / 共享 × 私有四象限的来源。
- 第 12 章 · 请求式架构 —
requestId/requestType/requestPayload字段命名的来源。 - 闭环 2 · PlayerObject + GameState 的中级一局 — 故意保留的三个缺陷。
- 第 21 章 · 参数化 Network Event 与决策表 — 把本章五维度扩成七通道完整决策表。
- 附录 · 术语表 —
State/Command/Event的客观定义。