跳转到内容

第 30 章 · Owner 伪权威与确认回包

约 6 分钟 难度:4 动手章

这一章解决:本地预演发出请求后,如何知道 Owner 是否处理了它,并把「临时反馈」切换成「已确认结果」。

先看一下

第 29 章把按钮反馈拆成三层:本地表现、待确认请求、共识结果。现在缺中间那条回路。玩家 A 按下技能按钮,本地已经播了音效;GameState Owner 过了一小段时间才处理请求。A 需要知道这次请求是成功、失败,还是还在路上。

这一章会拿到什么

  • requestId + lastProcessedRequestId 作为确认回包的最小模板
  • NetSkillReceipt 参数化事件回执片段
  • 「同步字段确认」和「事件回执确认」的取舍
  • 一条硬边界:Owner 伪权威只做可玩性裁决,不做安全承诺

依赖前面

  • 第 12 章的请求式架构模板
  • 第 20 章的请求幂等工具
  • 第 21 章的参数化 Network Event
  • 第 29 章的本地预演 pending 状态

GameState Owner 在本卷里承担实例内裁决:检查阶段、冷却、分数、目标是否存在,然后写同步字段。它接近传统 netcode 里的服务器角色,但仍然运行在某位玩家的客户端上,不能作为可信安全边界。

所以本章使用「伪权威」这个词:它能让一局游戏有单一裁决点,让状态更一致,让本地预演有确认回包;反作弊、客户端输入可信、高频物理的严格权威模拟,都超出它的能力边界。

这个边界决定了确认回包的目标:让交互可观察、可恢复、可调试。 它不承诺把 VRChat 世界变成服务器权威游戏。


确认可以走同步字段,也可以走参数化事件。两者不是互斥关系。

方式适合不适合
同步字段确认迟入要看到、需要 Debug 面板回查、请求少但重要高频、只给发起者看的短反馈
参数化事件回执只通知发起者「这次请求成了 / 没成」、需要低延迟反馈迟入恢复、长期状态、战报历史

第 12 / 20 章的 lastProcessedRequestId[] 是同步字段确认:它让 Owner 记住每位玩家处理到哪一条。第 30 章再加一条事件回执:Owner 处理完后,单独告诉发起者本次请求是否接受。


GameState 上已经有一组字段:

[UdonSynced] private int[] lastProcessedRequestId = new int[100];

这里用 playerId 直接当数组下标,是为了让片段短。完整项目要把它包成一个 GetPlayerSlot(playerId)IsValidPlayerSlot(playerId) helper:先检查范围,再读写数组。玩家离开时也要清理或冻结对应槽位,避免旧玩家的请求序号影响后续玩家。

处理请求时,Owner 先看玩家自己的 requestId,再更新这一槽。

private void ProcessSkillRequest(PlayerLobbyState pls, VRCPlayerApi who)
{
int playerId = who.playerId;
int slot = GetPlayerSlot(playerId);
if (slot < 0) return;
int reqId = pls.RequestId;
if (reqId <= lastProcessedRequestId[slot]) return;
bool accepted = TryApplySkill(pls.SkillId, pls.TargetId, who);
lastProcessedRequestId[slot] = reqId;
RequestSerialization();
SendSkillReceipt(playerId, reqId, accepted);
}

lastProcessedRequestId[slot] = reqId 的语义是:Owner 已经看过这条请求,并决定接受或拒绝。它不是「一定成功」。成功失败要用额外字段或回执表达。


回执只给发起者本人看,适合走参数化事件。由于官方没有 NetworkEventTarget.Player,常见做法是向 All 广播回执,所有客户端本地过滤目标 playerId

这不是私聊通道。所有当前客户端都会收到这条事件,只是本地过滤后不处理。它适合低频、无隐私的确认回包;不适合高频输入流,也不适合携带只应给某个玩家看的隐藏信息。

private void SendSkillReceipt(int playerId, int requestId, bool accepted)
{
SendCustomNetworkEvent(
NetworkEventTarget.All,
nameof(NetSkillReceipt),
playerId,
requestId,
accepted);
}
[NetworkCallable]
public void NetSkillReceipt(int playerId, int requestId, bool accepted)
{
if (Networking.LocalPlayer == null) return;
if (Networking.LocalPlayer.playerId != playerId) return;
playerLobbyState.OnSkillConfirmed(requestId, accepted);
}

这段需要 using VRC.SDK3.UdonNetworkCalling;using VRC.Udon.Common.Interfaces;。它的作用是把第 29 章的 pending 本地预演收尾;长期状态仍然交给同步字段。

如果回执丢了怎么办?同步字段仍然会更新。发起者可以在 OnDeserialization 或本地轮询里用自己的槽位检查 lastProcessedRequestId[slot] >= pendingRequestId,用字段确认兜底清理 pending。事件回执负责快,字段确认负责稳。


accepted 一个布尔值通常不够。UI 如果要显示原因,可以把拒绝原因压成 byte reason

reason含义UI 表现
0成功进入真实冷却
1阶段不允许显示「当前阶段不能使用」
2冷却中恢复按钮,播放轻提示
3目标无效取消准星锁定
4资源不足显示资源不足提示

回执参数可以变成 playerId, requestId, accepted, reason。这仍然是小参数事件,不需要 JSON。

注意:拒绝原因是 UI 反馈,不是安全日志。真正需要回查的异常(例如 1 秒内 50 个请求、分数突变、速度异常)应写进第 33 章的异常日志字段或调试面板。


Gambetta 原模型里,服务器返回的是权威状态 + 已处理输入序号。客户端收到后,以服务器状态为基准,重放尚未确认输入。

VRChat 版本只保留其中一部分:

原模型VRChat 近似
权威服务器处理输入GameState Owner 处理请求
服务器返回权威状态同步字段复制当前共识状态
last processed requestlastProcessedRequestId[slot]
客户端重放未确认输入只清理 / 撤销本地预演,不做完整重放
服务器防作弊只能做合理性检查,不能成为可信安全边界

这张表是第六部的核心翻译动作。能借的是序号、确认、pending 清理;不能借的是服务器权威和完整重放。


挑一条试。

  • 把回执事件删掉,只保留同步字段确认。按钮 pending 会慢多少?哪类 UI 会受影响最大?
  • accepted 拆成 reason。设计 3 个拒绝原因,并决定哪些原因要播放音效,哪些只显示短文字。
场景预期实际
1. 合法请求Owner 推进 lastProcessedRequestId,回执 accepted=true,本地进入真实冷却
2. 冷却中重复请求Owner 推进处理序号但回执拒绝,本地撤销预演
3. 人为丢失回执事件同步字段确认仍能兜底清 pending
4. Owner 转移后发请求新 Owner 继续从同步字段里的 lastProcessedRequestId 判断,不重复处理旧请求
5. 迟入玩家加入不补收历史回执,只看到当前共识字段
  • 能解释 GameState Owner 为什么是伪权威,不是可信服务器
  • 能区分「处理过」和「接受」
  • 能说明拒绝请求为什么也要推进 lastProcessedRequestId
  • 能写出事件回执 + 同步字段兜底的双层确认
  • 能指出 VRChat 近似版确认和传统 server confirmation 的差异