跳转到内容

第 13 章 · 权限与冲突

约 6 分钟 难度:2 动手章

这一章解决:第 12 章请求式架构在「多人同时请求」下会产生哪些冲突,以及怎么用最小成本的工具(幂等、冷却、Owner 检查、批准回执)处理这些冲突。

先看一下

第 12 章给了 requestId / requestType / requestPayload。两位玩家几乎同时按按钮时,他们各自的 PlayerObject 上会同时出现新的请求字段。GameState Owner 在 OnPlayerRequestUpdated 里依次扫描每位玩家的请求,按读到的顺序处理。

「依次处理」就够了吗?三个具体场景说不够:

  • 大厅里只剩 1 个红队名额,A 和 B 同时按「加入红队」。GameState 应该让谁进。
  • InGame 中只有一个钥匙,A 和 B 同时拾取。
  • 大厅里 Master 按「Start」,但 Master 本人忘了 Ready,按下没反应也没提示。

这一章把这三类冲突逐一处理。版本号(stateVersion)留给闭环 3。

这一章会拿到什么

  • 三类冲突场景的最小处理代码
  • 「批准回执」模式:GameState 通过 pls.SendCustomNetworkEvent(NetworkEventTarget.Owner, ...) 让玩家自己写 PlayerObject 字段
  • 一份「冲突类别 → 处理方式」对照表

依赖前面

  • 第 12 章请求式架构的字段命名和处理流程
  • 第 9 章 PlayerObject 写权边界
  • 第 4 章 OnPostSerialization 用法

第 12 章 HandleJoinTeam 留了四个 TODO。本节填完。

PlayerObject 端先加两个回调(脚本顶部沿用第 12 章的 using 段,特别注意要有 using VRC.SDK3.UdonNetworkCalling;):

public class PlayerLobbyState : UdonSharpBehaviour
{
// 第 12 章已有字段省略
// GameState 批准入队后调用这里。Owner 是该玩家本人,所以可以写 teamId
[NetworkCallable]
public void OnTeamApproved(int approvedTeam)
{
if (!Networking.IsOwner(gameObject)) return;
teamId = (byte)approvedTeam;
RequestSerialization();
}
// GameState 拒绝入队时调用这里
[NetworkCallable]
public void OnTeamRejected(int reason)
{
// 0 = 队伍已满, 1 = 队伍 ID 非法, 2 = 比赛已开始
// 本地 UI 弹出提示。这里 Owner 不写网络字段
Debug.Log("[PlayerLobbyState] team rejected, reason=" + reason);
}
}

GameState 端把 HandleJoinTeam 写完整:

[Header("队伍配置")]
public int teamCount = 2;
public int teamCapacity = 4; // 每队上限
private int[] teamReservationCount = new int[4];
private void ScanPlayerRequests()
{
RefreshTeamReservationCount();
// 第 12 章已有:扫描所有玩家并调用 ProcessRequest
}
private void HandleJoinTeam(VRCPlayerApi who, byte team)
{
// TODO 1:合法性
if (team >= teamCount || team >= teamReservationCount.Length)
{
ReplyTeamRejected(who, 1);
return;
}
// TODO 2:阶段检查
if (phase != PHASE_LOBBY)
{
ReplyTeamRejected(who, 2);
return;
}
// TODO 3:人数上限。用本轮扫描里的预约人数,而不是只读已经同步回来的 teamId
if (teamReservationCount[team] >= teamCapacity)
{
ReplyTeamRejected(who, 0);
return;
}
// TODO 4:批准。先在 GameState Owner 本地预约名额,再发回执
teamReservationCount[team]++;
ReplyTeamApproved(who, team);
}
private void RefreshTeamReservationCount()
{
for (int i = 0; i < teamReservationCount.Length; i++) teamReservationCount[i] = 0;
int count = VRCPlayerApi.GetPlayerCount();
var players = new VRCPlayerApi[count];
VRCPlayerApi.GetPlayers(players);
for (int i = 0; i < count; i++)
{
var p = players[i];
if (p == null || !p.IsValid()) continue;
var pls = GetPLS(p); // 第 11 章 helper
if (pls == null) continue;
byte team = pls.TeamId;
if (team < teamReservationCount.Length) teamReservationCount[team]++;
}
}
private void ReplyTeamApproved(VRCPlayerApi who, byte team)
{
var pls = GetPLS(who);
if (pls == null) return;
// 关键一行:把 OnTeamApproved 这条事件发给 who 本人,让他自己写 teamId
pls.SendCustomNetworkEvent(
VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner,
nameof(PlayerLobbyState.OnTeamApproved),
(int)team);
}
private void ReplyTeamRejected(VRCPlayerApi who, int reason)
{
var pls = GetPLS(who);
if (pls == null) return;
pls.SendCustomNetworkEvent(
VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner,
nameof(PlayerLobbyState.OnTeamRejected),
reason);
}

NetworkEventTarget.Owner 在 PlayerObject 上的语义是「这份对象的 Owner」,也就是 who 本人。所以 pls.SendCustomNetworkEvent(target=Owner, method=OnTeamApproved) 等同于「把这条事件发给 who 客户端」。who 客户端收到后,因为他是 PlayerObject 的 Owner,可以合法写 teamId

整套就是「批准回执模式」:GameState Owner 决定结果,但写 PlayerObject 字段的动作由玩家本人完成。

A 和 B 同时按「加入红队」。两位玩家本地分别 IssueRequest(REQ_JOIN_TEAM, 0),请求字段同步出去。GameState Owner 的 OnPlayerRequestUpdated 扫到两位的请求,按读到的顺序处理(VRChat 同步的 batch 顺序由 SDK 决定,本卷视为不可预测)。

假设红队还剩 1 个名额:

  1. 本轮扫描开始前,RefreshTeamReservationCount() 读到红队已有 3 人。
  2. 先处理到 A 的请求,红队预约人数从 3 升到 4,批准 A,发回执,A 端稍后写 teamId=0
  3. 处理 B 的请求时,不等 A 的 teamId 同步回来,GameState Owner 本地已经知道红队预约人数是 4,于是拒绝 B,发回执原因 0。

哪一位被批准取决于同步顺序。这是可接受的:物理上「同时按下」的概念在分布式系统里没有意义,落到 GameState 端就是先到先批。关键是 GameState Owner 要在同一轮扫描里维护预约人数,不能只依赖 PlayerObject 稍后同步回来的 teamId


抢按钮:本地预校验 + Owner 防抖

Section titled “抢按钮:本地预校验 + Owner 防抖”

InGame 中场景里有一个特殊按钮(限时奖励),第一个按下的玩家拿走。

PlayerObject 端:

public void RequestUseSkill(int skillId, int targetId)
{
if (!Networking.IsOwner(gameObject)) return;
// 本地预校验:如果本地已经看到「奖励已经被拿走」,直接不发
// 这是省一次往返的优化,不是安全门
if (gameState != null && gameState.BonusTaken) return;
IssueRequest(REQ_USE_SKILL, skillId, targetId);
}

GameState 端:

[UdonSynced] private bool bonusTaken;
[UdonSynced] private int bonusTakenByPlayerId = -1;
public bool BonusTaken => bonusTaken;
public int BonusTakenByPlayerId => bonusTakenByPlayerId;
private void HandleUseSkill(VRCPlayerApi who, int skillId, int targetId)
{
if (skillId == 0 /* BONUS */)
{
if (bonusTaken) return; // 已被人拿走,忽略后到的请求
bonusTaken = true;
bonusTakenByPlayerId = who.playerId;
ApplyState();
return;
}
// 其他技能...
}

第一个到达 GameState 的请求把 bonusTaken = true,同步出去。后续到达的请求看到 bonusTaken 已为 true,被忽略。

「本地预校验」让玩家在本地已经看到 bonusTaken=true 时不再发请求,省一次同步往返。但它不是安全门:GameState 端的 if (bonusTaken) return 才是真正的判定。本地预校验可能因为同步延迟看到旧值,所以两位玩家都发了请求,最终由 GameState 一锤定音。


A 在 1 秒内连按按钮 50 次,发出 50 个请求。GameState 全部受理,分数加 50。这通常不是预期。

PlayerObject 端加冷却:

private float lastIssueTime = -999f;
public float requestCooldown = 0.3f;
private void IssueRequest(byte type, int payload, int extra)
{
if (!Networking.IsOwner(gameObject)) return;
// 本地冷却:同一个 PlayerObject 0.3 秒内只发一次
if (Time.time - lastIssueTime < requestCooldown) return;
lastIssueTime = Time.time;
requestId++;
requestType = type;
requestPayload = payload;
requestPayloadExtra = extra;
RequestSerialization();
if (gameState != null)
{
gameState.SendCustomNetworkEvent(
VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner,
nameof(GameState.OnPlayerRequestUpdated));
}
}

本地冷却拦得住「正常玩家手抖」,拦不住「故意改客户端代码绕过冷却」。后者要靠 GameState 端冷却:

private float[] lastProcessTime = new float[100]; // 按受控 slot 索引
private void ProcessRequest(VRCPlayerApi player, PlayerLobbyState pls)
{
// GameState 端冷却:同一玩家 0.3 秒内只处理一次
if (player.playerId < lastProcessTime.Length)
{
if (Time.time - lastProcessTime[player.playerId] < 0.3f) return;
lastProcessTime[player.playerId] = Time.time;
}
byte type = pls.RequestType;
// ...原有逻辑
}

注意 lastProcessTime 是 GameState Owner 客户端本地字段(不加 [UdonSynced])。Owner 转移瞬间这些数据会丢失,新 Owner 一开始没有冷却历史。本卷视为可接受:Owner 转移本身就少见,转移后的几秒里不严格冷却不会破坏体验。

冷却写在哪一层取决于威胁模型:

冷却位置拦截谁代价
PlayerObject 本地正常玩家手抖、按钮连按改客户端可绕过
GameState 端改客户端的玩家也拦得住Owner 转移时冷却历史丢失
两者都做全部最稳,代码翻倍

合作 PvE 用 PlayerObject 本地冷却就够了。竞技对抗两层都要做。


把本章三类场景汇总:

冲突类型例子处理留给闭环 3 的部分
资源争夺加入队伍、拾取唯一物品GameState 端先到先得 + 批准回执
重复请求按 Score 50 次本地冷却 + GameState 端冷却总分上限 / 每秒上限
状态时序网络抖动下「请求看到的 phase」与 GameState 实际 phase 不一致GameState 端 phase 校验(已有 if (phase != PHASE_INGAME) returnstateVersion 字段,让客户端能识别自己看到的状态版本

第三行是闭环 2 故意保留、闭环 3 才修的缺陷。本章只处理前两类。


  • 把队伍切换写成「玩家可以反复切换 Team,不需要 GameState 批准」。在什么场景下这样写是合理的?什么场景不合理?
  • 给 RequestScore 加一个全局上限:每秒整局最多 +5 分。这一项写在 PlayerObject 本地、GameState 端、两个地方都写,三种实现各自的代码量与可绕过性怎么对比?
  • 实现一个「主持人接管 GameState Owner」的按钮:主持人客户端按下后,GameState 的 Owner 转给他。怎样防止有人冒充主持人?

场景预期实际
1. A、B 同时抢红队最后一个名额一位被批准,另一位收到 OnTeamRejected reason=0
2. A 1 秒内连按 Score 10 次本地冷却下只发 3–4 次(0.3s 间隔),分数实际 +3 或 +4
3. A 改客户端绕过本地冷却,连按 50 次GameState 端冷却仍然拦下,分数 +N(N 远小于 50)
4. A 拾取唯一奖励,B 0.1 秒后也按拾取A 拿到奖励,B 收到「已被拿走」反馈或本地预校验直接不发
5. GameState Owner 转移后,本地 lastProcessTime 丢失转移后短期内冷却不严格,不影响正确性

  • 能复述「批准回执」模式三个步骤
  • 能解释为什么 PlayerObject 本地冷却拦不住改客户端的玩家
  • 能写出 ReplyTeamApprovedSendCustomNetworkEvent 的 target 与 method 各是什么
  • 能识别本章哪些冲突处理只到「最小可用」,哪些将由闭环 3 的版本号补完

  • VRChat Creator Docs · Network EventsSendCustomNetworkEvent 的 target、参数、速率限制。
  • VRChat Creator Docs · Object Ownership:Owner 写权、SetOwnerOnOwnershipTransferred 的官方说明。
  • 第 12 章 · 请求式架构:本章扩展的母本。
  • 闭环 1 · 缺陷 2 现场lobbyReady 缺陷已经间接展示了写权冲突。
  • 附录 · 术语表Conflict ResolutionApproval ReplyCooldown 的客观定义。