跳转到内容

第 22 章 · 命令模式与事件日志

约 8 分钟 难度:4 动手章

进阶章。读到这里可以先去第五部,做完闭环 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 骨架

一条命令包含五个字段。这五个字段是命令模式的最小元数据,沿着 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[]4128
cmdPlayerId[]4128
cmdType[]132
cmdPayload[]4128
cmdServerLikeTime[]8256
cmdHead + cmdCount88
合计约 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 / PhotonVRChat 等价
[Command] 客户端 → 服务器IssueRequest 写到玩家 PlayerLobbyState 上的请求字段,由 GameState Owner 轮询处理
[ClientRpc] 服务器 → 全体GameState 的同步字段(状态同步)或 SendCustomNetworkEvent(NetworkEventTarget.All, ...)(命令同步)
[Command] 参数requestType + requestPayload(同步字段)或参数化事件(第 21 章)
命令模式的命令日志本章 cmdXxx[] 环形数组
服务器是权威GameState Owner 是当前实例的状态提交者,不是可信服务器(第 11 章红线)

缺什么前提

  1. 没有真正的权威。Mirror 的服务器不可被玩家直接控制,VRChat 的 GameState Owner 是某位玩家的本机进程。这位玩家改客户端就改了。本卷把这一前提一直放在台面上,第 33 章会展开「反作弊近似」能做什么。
  2. 没有有序保证。Mirror 的 [Command] 在服务器端按收到顺序执行;VRChat 多个客户端发的 IssueRequest 通过 [UdonSynced] 字段同步,到达 Owner 的顺序由网络层决定,相同帧内多份变更会被合并。requestId 单调比较保证了同一玩家的有序,但跨玩家的相对顺序仍然不确定。
  3. 没有断线重连。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] 里把两件事写完,因为服务器同时承担「裁决」和「广播」。


命令日志不是默认能力。下面三个条件都满足时再接:

  1. 需要复盘历史:战报、击杀提示列表、回放、聊天记录任意之一。
  2. 历史长度有界:能用 32 / 64 / 128 容量装下,不需要「全程」。需要全程的写到 VRCJson 字段(第 24 章)或者外接持久化(Vol.5)。
  3. 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 时不清命令日志