跳转到内容

第 14 章 · Owner 离开后的恢复

约 8 分钟 难度:2 动手章

这一章解决:当某位玩家是某个对象的 Owner,他离开实例时,这个对象怎么办。三类对象(玩家对象、全局状态、物理对象)的恢复路径不同,本章逐一讲。

先看一下

闭环 1 的缺陷 1 是 Master 离开后整局丢失。第 11 章把全局状态从 GameLoopMaster 搬到 GameState 之后,问题没消失:GameState Owner 仍然默认是 Master,他离开仍然会卡顿。

VRChat 的 Owner 自动转移机制只能保证「对象有人 Owner 着」,不能保证「转移后逻辑能继续」。需要写额外代码处理三类对象的离开恢复。

这一章会拿到什么

  • 三类对象的离开恢复路径对照
  • GameState Owner 候选队列(策略 B)的最小实现
  • 一份「Owner 失效恢复检查表」
  • 玩家串味状态的识别与清理(闭环 2 故意保留的缺陷之一)

依赖前面

  • 第 11 章 GameState 三种 Owner 策略
  • 第 9 章 PlayerObject 在玩家离开时的自动销毁
  • 第 12 章 lastProcessedRequestId 数组按受控玩家槽位索引

对象类型例子Owner 离开时恢复策略
玩家对象(PlayerObject)PlayerLobbyStateSDK 自动销毁实例不需要恢复,但要清理 GameState 里关于此玩家的痕迹
全局状态(GameState 类)GameStateVRChat 会自动分配新 Owner,但具体人选不作为设计承诺写显式 Owner 候选,必要时在自动转移后再纠正到候选人,并校验状态完整
物理对象(VRCObjectSync 类)抓取物、推塔的塔身、子弹由 SDK 兜底转移 Owner第 7 部主题,本章给思路

每条路径细看。

A 离开实例,A 的 PlayerLobbyState 实例被 SDK 自动销毁。任何持有引用的代码会拿到 null(再去 Networking.GetPlayerObjects(A) 也会拿到空数组或 null)。

要清理的:

  • GameState.lastProcessedRequestId[A.playerId] 不该长期当作有效玩家记录保留。即使当前 SDK 下 playerId 在同一实例内通常不会复用,清理离开玩家的槽位也能避免后续调试时把旧记录误读成当前玩家状态。
  • GameState 上挂在 A 身上的暂态字段(如 bonusTakenByPlayerId == A.playerId)按业务决定保留还是清理。比赛中拿走奖励的玩家离开后,记录是否保留取决于设计。
  • 队伍人数计数:CountTeamMembers 是实时计算的,不需要清理。

代码层面在 GameState 里加 OnPlayerLeft

public override void OnPlayerLeft(VRCPlayerApi player)
{
if (!Networking.IsOwner(gameObject)) return;
if (player != null && player.playerId < lastProcessedRequestId.Length)
{
lastProcessedRequestId[player.playerId] = 0;
// 同步清理后给 GameState 重新打包
RequestSerialization();
}
}

OnPlayerLeft 在所有客户端都触发。只让 GameState Owner 写字段。

路径 2:GameState 离开(含主动接管)

Section titled “路径 2:GameState 离开(含主动接管)”

GameState 是普通 GameObject,没有 PlayerObject 那样的 Owner 锁定。Owner 离开时 VRChat 会自动分配新 Owner;官方文档明确提醒,不要依赖某个具体玩家一定成为 Master 或新 Owner。本卷把这次自动分配视为「不可控制的兜底」。

不能只依赖兜底的两类情况:

  • 想做主持人接管:管理员账号在房间时,他必须能成为 Owner。
  • 想做候选队列:希望有可预测的转移顺序。

下面给策略 B「Owner 候选队列」的最小实现。

// 在 GameState.cs 里追加
[UdonSynced] private int[] ownerCandidateOrder = new int[100];
[UdonSynced] private int ownerCandidateCount = 0;
public override void OnPlayerJoined(VRCPlayerApi player)
{
if (!Networking.IsOwner(gameObject)) return;
if (player == null) return;
// 把这位玩家追加到候选队列尾部
if (ownerCandidateCount < ownerCandidateOrder.Length)
{
ownerCandidateOrder[ownerCandidateCount] = player.playerId;
ownerCandidateCount++;
RequestSerialization();
}
}
public override void OnPlayerLeft(VRCPlayerApi player)
{
// 注意:这一段在所有客户端都跑,包括离开 Owner 的接管逻辑
if (player == null) return;
// 1. 清理候选队列里的这位玩家
if (Networking.IsOwner(gameObject))
{
RemoveFromCandidates(player.playerId);
// 第 12 章 lastProcessedRequestId 清理
if (player.playerId < lastProcessedRequestId.Length)
{
lastProcessedRequestId[player.playerId] = 0;
}
RequestSerialization();
return;
}
// 2. VRChat 会先做兜底 Owner 转移。候选人检查自己是否应该纠正兜底结果
TryClaimIfFirstCandidate();
}
private void RemoveFromCandidates(int playerId)
{
int found = -1;
for (int i = 0; i < ownerCandidateCount; i++)
{
if (ownerCandidateOrder[i] == playerId) { found = i; break; }
}
if (found < 0) return;
for (int j = found; j < ownerCandidateCount - 1; j++)
{
ownerCandidateOrder[j] = ownerCandidateOrder[j + 1];
}
ownerCandidateCount--;
}
public override void OnOwnershipTransferred(VRCPlayerApi player)
{
// 所有客户端都会收到这个回调。候选人如果不是当前 Owner,也要有机会纠正兜底结果。
TryClaimIfFirstCandidate();
if (!Networking.IsOwner(gameObject)) return;
// 只有候选队列第一位真正接管时,才把自己移除。
// 如果 VRChat 兜底把 Owner 给了其他人,先别改队列,等第一候选人纠正。
if (Networking.LocalPlayer != null
&& ownerCandidateCount > 0
&& Networking.LocalPlayer.playerId == ownerCandidateOrder[0])
{
RemoveFromCandidates(Networking.LocalPlayer.playerId);
RequestSerialization();
}
SanityCheckAfterTakeover();
}
private void TryClaimIfFirstCandidate()
{
var local = Networking.LocalPlayer;
if (local == null || !local.IsValid()) return;
if (ownerCandidateCount <= 0) return;
if (local.playerId != ownerCandidateOrder[0]) return;
if (Networking.IsOwner(gameObject)) return;
Networking.SetOwner(local, gameObject);
}
private void SanityCheckAfterTakeover()
{
// 如果 phase=INGAME 但所有玩家都不在,重置回 LOBBY
if (phase == PHASE_INGAME && VRCPlayerApi.GetPlayerCount() == 0)
{
phase = PHASE_LOBBY;
totalScore = 0;
ApplyState();
RequestSerialization();
}
// 其他业务级一致性检查在这里加
}

SetOwner 是请求,不是瞬时。OnOwnershipTransferred 是确认接管的可靠时机。第 3 章已经强调过这一点。策略 B 仍然可能出现一次 VRChat 兜底 Owner 转移,然后候选人再接管的短暂过程;代码要按「会发生两次 Owner 变化」来写。

策略 B 比策略 A 多写约 50 行代码,换来「Master 离开后状态有明确接管路线」。这是闭环 2 相对闭环 1 的核心修复。

策略 C「按规则推举」的实现思路:把 ownerCandidateOrder 的填充规则换成「按某种排序把所有玩家放进去」(如按加入时间最早 / 主持人列表优先)。代码骨架与策略 B 完全一致,只是 OnPlayerJoined 那一行的插入逻辑变。

抓取物、推塔的塔身、子弹这类有 VRC Object Sync 组件的物体,Owner 离开时 SDK 会做兜底 Owner 转移。这条路径的细节在第 7 部主题。本章只提一句:物理对象的「位置」由 SDK 自动维护,但物理对象上挂的 Udon 状态字段(如「这个塔被谁建造」「这个塔的 HP」)需要按 GameState 或 PlayerObject 模式处理,不能依赖 Object Sync。

物理对象在第 7 部 36–37 章详述。


闭环 2 故意保留的一个缺陷:玩家 A 离开后,下一位玩家加入时分到一个看起来类似的状态。这种「串味」在闭环 2 里可观察到,原因是当前模板对玩家上下文清理不够彻底。

具体表现:

  • A 在 InGame 拿走了奖励 → bonusTakenByPlayerId = A.playerId
  • A 离开实例。GameState 没有清理 bonusTakenByPlayerId
  • 新玩家 D 加入,他看到「奖励已经被某人拿走,但拿走者已经离开」。这是合理的;但如果 UI 显示的是「拿走者:A 的 displayName 缓存」,D 看到的可能是空字符串或乱码。

最小处理:在 OnPlayerLeft 里把和这位玩家相关的暂态字段一起清理。

public override void OnPlayerLeft(VRCPlayerApi player)
{
// 已有:候选队列清理、lastProcessedRequestId 清理
if (player != null && bonusTakenByPlayerId == player.playerId)
{
// 业务决策:是否在拿走者离开后释放奖励?这里假设释放
bonusTaken = false;
bonusTakenByPlayerId = -1;
}
}

这一段是业务决策,本章只演示一种处理。是否释放奖励、是否保留击杀记录、离开者的分数是否归零,每个项目自己定。

闭环 2 留这个缺陷的方式:本章只清理 lastProcessedRequestId,不清理 bonusTakenByPlayerId。读者跑闭环 2 的测试矩阵第 6 行能看到这个串味现象。闭环 3 引入 stateVersion 后,串味问题在版本协议层被识别。


写完一个 GameState 类对象后,按下面 7 条核对一次。漏一条都可能在 Owner 离开时炸。

[ ] OnPlayerJoined 里有没有把这位玩家加入候选队列(如用策略 B/C)
[ ] OnPlayerLeft 里有没有:
[ ] 从候选队列移除
[ ] 清理或冻结 lastProcessedRequestId[slot]
[ ] 清理这位玩家相关的暂态字段(业务决策)
[ ] 能不能在 Master 强退测试中看到 OnOwnershipTransferred 触发
[ ] OnOwnershipTransferred 里有没有:
[ ] 把自己从候选队列移除
[ ] 跑 SanityCheckAfterTakeover
[ ] phase=INGAME 但 GetPlayerCount()=0 时能不能自愈(避免空房 InGame)
[ ] 候选队列字段加了 [UdonSynced]
[ ] 关键字段都做了 OnPostSerialization 的失败日志

把这张表贴进项目的 design/owner-recovery-checklist.md,每加一个新的 GameState 类对象都过一遍。


第 14 章是第二部最后一章主线。这一部累计下来的工具放在下面这张表里。第三部、第四部多次复用其中几样,写新代码前回这张表查比翻原章快。

工具出处解决的问题
玩家上下文检查表(7 题)第 8 章写 PlayerObject 字段集前,先把每位玩家要被记什么列清楚
VRCPlayerObject 边界卡第 9 章哪些字段适合 per-player,哪些不适合
GameState + 三种 Owner 策略第 11 章房间共享状态由谁托管:固定 Owner / 候选队列 / 推举
请求式骨架(requestId / requestType / requestPayload第 12 章玩家不能直接写 GameState,统一走请求字段
lastProcessedRequestId[] 幂等第 12 章同一请求不会被处理两次
批准回执模式第 13 章GameState 不能跨过去写 PlayerObject,让玩家本人写自己字段
本地冷却 + Owner 端冷却第 13 章重复点击与客户端绕过两档威胁分别拦下
候选队列接管(OnOwnershipTransferred第 14 章Master 离开后整局不丢失
OnPlayerLeft 字段清理第 14 章串味状态与无效冷却记录归零

进入第三部、第四部之前,遇到「这里要不要加新机制」的判断时,先回这张表里找可复用的工具。复用比新写一遍便宜。


  • 把策略 B 改成策略 C「按加入时间最早的玩家优先」。代码改动量有多大?哪一段最容易出错?
  • 故意让 GameState Owner 强退(用 VRChat 客户端的 Quick Menu 离开实例),观察 OnOwnershipTransferred 触发的延迟。这个延迟在你的玩法可接受范围内吗?
  • SanityCheckAfterTakeover 加一条:「如果 InGame 时间已经超过 matchDuration 但 phase 还没变 RESULT,强制切到 RESULT」。这条是必需的吗?什么情况下会触发?

场景预期实际
1. GameState Owner(A)离开按候选队列下一位(B)申请接管,2 秒内 phase / score 不丢
2. 候选队列里下一位也已离开跳到再下一位
3. 房间空了又来人phase 自愈到 LOBBY,新玩家进入大厅看到正常状态
4. A 拿走奖励后离开bonusTakenByPlayerId 被清,奖励重新可拿(业务上选择释放时)
5. lastProcessedRequestId 清理A 离开后,D 加入(即使 playerId 不复用),D 的请求从 1 起处理
6. 串味状态(闭环 2 故意保留)如果不在 OnPlayerLeft 清理 bonusTakenByPlayerId,UI 上 displayName 缓存可能显示已离开玩家的旧名字

第 6 行是闭环 2 故意保留的缺陷之一。读到这一行说明本部已经接近收尾。


  • 能默写三类对象的 Owner 离开恢复路径
  • 能复述策略 B 候选队列的实现要点(4 个回调点)
  • 能说出 OnOwnershipTransferred 必须做的两件事
  • 能识别「玩家串味状态」的来源,并给出一种清理方案

  • VRChat Creator Docs · Object OwnershipSetOwnerOnOwnershipRequestOnOwnershipTransferred、自动 Owner 转移的官方说明。
  • VRChat Creator Docs · Networking and Synchronization:Owner、同步变量、事件的总览。
  • 第 11 章 · GameState 三种 Owner 策略:本章策略 B 实现的母本。
  • 第 3 章 · Owner 不是 MasterSetOwner 是请求不是瞬时。
  • 附录 · 术语表Owner CandidateSanity CheckState Carryover(串味)的客观定义。