第 22 章 · 命令模式与事件日志
进阶章。读到这里可以先去第五部,做完闭环 4 之后再回来。命令日志不是闭环 3 的前置,但闭环 3 之后做战报、复盘、审计时回来开。
这一章解决:第 19 章命令同步只同步「最近一条」,重放 / 战报 / 审计需要更完整的历史。把命令做成结构化日志,按环形数组同步最近 N 条。
先看一下
闭环 2 的 HandleScore 直接 totalScore += 1。出 bug 时只能从当前状态反推:分数对不对、阶段对不对,但「这一分是 A 在 12.3 秒打的,还是 B 在 12.4 秒打的」没人记。第 17 章提到的「最近 5 条得分」战报、第 33 章谈到的反作弊近似(异常分数突变检测)都需要这一份历史。
把每一次「请求被处理」记成一条命令,按环形数组存最近 N 条。客户端读这一份日志可以重放出战报;调试时可以拿日志对比「我看到的状态」和「应该的状态」;反作弊近似可以扫描日志找异常突变。
这一章会拿到什么
- 命令日志的字段集(
requestId/playerId/commandType/payload/serverLikeTime) - 环形数组实现:固定容量 +
head指针 + 同步成本算式 - 一节翻译章:Mirror / Photon 的
[Command]/[ClientRpc]在 VRChat 怎么落地,缺哪些前提 - 「该不该接命令日志」的三条决策
依赖前面
- 第 19 章命令同步「最近一条」的局限
- 第 20 章
requestId幂等 - 闭环 2
GameState骨架
命令日志的字段集
Section titled “命令日志的字段集”一条命令包含五个字段。这五个字段是命令模式的最小元数据,沿着 Mirror / Photon 等传统 netcode 的命令惯例,落到 VRChat 上保持同名。
// CommandLogEntry · 一条命令的最小元数据struct CommandLogEntry{ public int requestId; // 来自玩家本人 PlayerObject 的请求号 public int playerId; // 谁发的 public byte commandType; // 0=Score, 1=Bonus, 2=SetTeam, ... public int payload; // 命令参数(多参用 byte 数组打包,第 23 章) public double serverLikeTime; // GameState Owner 处理时的 Networking.GetServerTimeInSeconds}serverLikeTime 不是「服务器时间」。VRChat 没有权威服务器,Networking.GetServerTimeInSeconds() 是「贴近实时的共享时钟」(第 15 章已经讨论过其偏差量级)。命名带 Like 是为了让读者不要把它当成可信时间戳:它在 Owner 转移瞬间可能跳;它在不同客户端之间偏差几十到几百毫秒。但它够用作「相对顺序」和「大致时段」。
UdonSharp 不支持自定义 struct 同步。落到代码上拆成五个并行数组:
[UdonSynced] private int[] cmdRequestId;[UdonSynced] private int[] cmdPlayerId;[UdonSynced] private byte[] cmdType;[UdonSynced] private int[] cmdPayload;[UdonSynced] private double[] cmdServerLikeTime;
// 环形指针[UdonSynced] private int cmdHead; // 下一个要写的位置[UdonSynced] private int cmdCount; // 当前有效条数(不超过容量)public int cmdCapacity = 32; // 固定容量写入命令时 Owner 端:
private void AppendCommand(int reqId, int playerId, byte type, int payload){ cmdRequestId[cmdHead] = reqId; cmdPlayerId[cmdHead] = playerId; cmdType[cmdHead] = type; cmdPayload[cmdHead] = payload; cmdServerLikeTime[cmdHead] = Networking.GetServerTimeInSeconds(); cmdHead = (cmdHead + 1) % cmdCapacity; if (cmdCount < cmdCapacity) cmdCount++;}读取最近 5 条(按时序倒序):
private void ReadRecent(int n, out int[] outIndices){ outIndices = new int[Mathf.Min(n, cmdCount)]; int writeIdx = 0; for (int i = 0; i < outIndices.Length; i++) { int idx = (cmdHead - 1 - i + cmdCapacity) % cmdCapacity; outIndices[writeIdx++] = idx; }}环形数组的好处是不需要 shift,写入是 O(1)。坏处是日志被覆盖时旧记录就消失了。容量选 32 / 64 / 128 看场景:32 条够覆盖一局战报,128 条覆盖几局回放但同步成本翻 4 倍。
按 32 容量算单次同步字节:
| 字段 | 单元素字节 | 总字节(32 容量) |
|---|---|---|
cmdRequestId[] | 4 | 128 |
cmdPlayerId[] | 4 | 128 |
cmdType[] | 1 | 32 |
cmdPayload[] | 4 | 128 |
cmdServerLikeTime[] | 8 | 256 |
cmdHead + cmdCount | 8 | 8 |
| 合计 | — | 约 680 bytes |
切到 manual sync 的 GameState 上,680 bytes 远低于官方当前的约 280 KB 单次序列化上限。每次有命令就 RequestSerialization,按一局 50 条命令算(一局 5 分钟、平均 6 秒一条),单次同步只在命令发生时触发,撞不到速率限制。
把容量翻倍到 64,单次同步 1.3 KB;翻到 128,2.6 KB。在 GameState 字段堆里仍然是小头(相比 VRCJson 字段动辄几 KB)。
这一段成本预算是写命令日志前要算的。如果同步频率高(每秒几次命令)+ 容量大(128 以上),manual sync 单包够装但 11 KB/s 总预算会变紧。这种世界要么压缩 payload(第 23 章字节打包),要么放弃日志(用瞬时事件或回到状态同步)。
翻译章节 · Mirror 的 [Command] 在 VRChat 怎么落地
Section titled “翻译章节 · Mirror 的 [Command] 在 VRChat 怎么落地”这一节是按
encyclopedia-plan的 4 步翻译结构嵌进主线,不单独成页。
原概念一句话:Mirror / Photon 等传统多人框架里,[Command] 装饰的方法在客户端调用、在服务器上执行;[ClientRpc] 装饰的方法在服务器调用、在所有客户端上执行。命令模式(Command Pattern)把每次客户端请求结构化成一条命令,由服务器裁决、广播执行结果。
在 VRChat 等价于什么:
| Mirror / Photon | VRChat 等价 |
|---|---|
[Command] 客户端 → 服务器 | IssueRequest 写到玩家 PlayerLobbyState 上的请求字段,由 GameState Owner 轮询处理 |
[ClientRpc] 服务器 → 全体 | GameState 的同步字段(状态同步)或 SendCustomNetworkEvent(NetworkEventTarget.All, ...)(命令同步) |
[Command] 参数 | requestType + requestPayload(同步字段)或参数化事件(第 21 章) |
| 命令模式的命令日志 | 本章 cmdXxx[] 环形数组 |
| 服务器是权威 | GameState Owner 是当前实例的状态提交者,不是可信服务器(第 11 章红线) |
缺什么前提:
- 没有真正的权威。Mirror 的服务器不可被玩家直接控制,VRChat 的
GameState Owner是某位玩家的本机进程。这位玩家改客户端就改了。本卷把这一前提一直放在台面上,第 33 章会展开「反作弊近似」能做什么。 - 没有有序保证。Mirror 的
[Command]在服务器端按收到顺序执行;VRChat 多个客户端发的IssueRequest通过[UdonSynced]字段同步,到达 Owner 的顺序由网络层决定,相同帧内多份变更会被合并。requestId单调比较保证了同一玩家的有序,但跨玩家的相对顺序仍然不确定。 - 没有断线重连。Mirror 有 session token、断线后服务器保留状态;VRChat 玩家离线就是离开实例,回来是新
playerId。本卷视为新加入处理。
写法对照:Mirror 一段经典命令模式
// Mirror 风格(参考用,VRChat 不能直接跑)[Command]public void CmdScore(int delta){ score += delta; // 在服务器上跑 RpcOnScored(delta); // 广播给所有客户端}
[ClientRpc]public void RpcOnScored(int delta) { /* UI 飘字 */ }VRChat 落地:
// PlayerLobbyState(玩家本人)public void RequestScore() { IssueRequest(REQ_SCORE, 1); }
// GameState(Owner)private void HandleScore(VRCPlayerApi who, int delta){ totalScore += delta; AppendCommand(GetReqId(who), who.playerId, CMD_SCORE, delta); RequestSerialization(); // 同步 totalScore + 命令日志 SendCustomNetworkEvent(NetworkEventTarget.All, nameof(NetOnScored), delta); // 飘字事件不重放给迟入,迟入读命令日志看到历史}
[NetworkCallable]public void NetOnScored(int delta) { /* UI 飘字 */ }VRChat 把「命令分发」和「状态广播」拆成了两步,因为:状态需要迟入恢复(必须同步字段),事件可以瞬时(事件通道)。Mirror 在一个 [Command] 里把两件事写完,因为服务器同时承担「裁决」和「广播」。
何时该接命令日志
Section titled “何时该接命令日志”命令日志不是默认能力。下面三个条件都满足时再接:
- 需要复盘历史:战报、击杀提示列表、回放、聊天记录任意之一。
- 历史长度有界:能用 32 / 64 / 128 容量装下,不需要「全程」。需要全程的写到
VRCJson字段(第 24 章)或者外接持久化(Vol.5)。 - GameState 是 manual sync:闭环 3 已经切到 manual sync。如果场景的
GameState还是 continuous,先想想为什么。
三条任一不满足,命令日志就过度。比如休闲房不需要战报,单纯按按钮玩的世界 32 条足够,单人解谜世界根本没有玩家间命令。
挑一条试。
- 把命令日志容量从 32 改成 16,跑一局有 30 次得分的比赛。看战报 UI 在第 17 条得分进来时显示什么,环形覆盖语义对不对。
- 给一条命令加第二个 payload 参数(比如「击杀者 + 被击杀者」)。两个
int字段并排同步还是用一个long装两个int?两种写法的字节代价对比。 - 闭环 3 不接命令日志的理由是「修闭环 2 三个缺陷不需要日志」。如果产品需求里加了「最近 5 条得分战报」,本章日志接到闭环 3 上需要改哪几个函数?为什么
OnDeserialization里不需要做特别处理?
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. 玩家打 5 次分 | 命令日志 5 条,按时间倒序读出 | … |
| 2. 玩家打 33 次分(容量 32) | 最旧 1 条被覆盖,剩 32 条 | … |
| 3. 迟入玩家加入 INGAME | 立刻看到当前命令日志(按字段同步) | … |
| 4. Owner 转移期间打分 | 命令日志在新 Owner 上继续追加,cmdHead / cmdCount 从同步字段继承 | … |
| 5. 玩家离开 | 该玩家过去的命令仍在日志里 | … |
| 6. 战报 UI 渲染 | 用「最近 N 条」渲染,每条显示 playerId / commandType / serverLikeTime | … |
- 能默写命令日志的五个字段及为什么
serverLikeTime命名带Like - 能解释为什么用 5 个并行数组而不是单个
VRCJson字符串 - 能说出三条「该接命令日志」的判断条件
- 能识别 Mirror 的
[Command]/[ClientRpc]在 VRChat 落地时的两点不同 - 能解释为什么
OnPlayerLeft时不清命令日志
- VRChat Creator Docs · Networking and Synchronization —
[UdonSynced]数组的同步规则。 - Mirror Networking · Commands and ClientRpcs — 命令模式在传统 netcode 里的标准定义(参考用,VRChat 不直接采用)。
- 第 17 章 · 计分、奖励与结算 — 战报需要的命令历史。
- 第 19 章 · 状态同步还是命令同步 — 命令同步「最近一条」的局限。
- 第 23 章 · 字节打包与增量同步 — 命令 payload 多参时的压缩。
- 第 24 章 · JSON、DataDictionary 与 DataList — 复杂结构数据的另一条通道。
- 闭环 3 · 加上命令版本号的工程化一局 — 不接命令日志的理由。
- 附录 · 术语表 —
Command Log/Command的客观定义。