第 28 章 · 观战、旁观、掉线后重入进阶
这一章解决:第 18 章的最小旁观席能跑,但若干具体问题留作扩展。玩家中途离开同实例再回来要不要恢复进度?UI 字段忘了绑
IsSpectator查询导致旁观者还能看到「我的得分」?旁观者按了「下一局参赛」按钮,下一局开始时怎么知道把哪些人变回普通玩家?这些扩展不发明新模式,沿用第 18 章三时刻 + 三层拦截框架。
先看一下
闭环 3 + 第 18 章基础旁观席跑 4 人合作防守原型:A 打到第 5 波退实例(网络抖动 / 临时换房间)。第 8 波 A 重新进入。A 端看到自己 PlayerObject 是新克隆的:personalScore=0、teamId=-1、isReady=false。第 18 章默认策略是把 A 放进旁观席,本局 A 不再参赛。下一局 A 才能重新选边。
但项目可能希望:A 在第 8 波回来时立刻继续战斗(合作 PvE);或者 A 回来时仍按本局贡献结算(PvP 排位)。两类扩展都靠加同步字段实现,本章给具体设计。
这一章会拿到什么
- 跨阶段重入的三种处理(默认旁观 / 立刻入队 / 恢复贡献)和各自的字段成本
- 迟入串味的四个具体场景,每个场景给修法
- 「旁观请求参赛」(
REQ_PLAY_NEXT)的完整实装:字段 + 入口 + RESTART 时的归并 - RESULT → LOBBY 阶段间策略二选:全员归并 vs 请求归并
- 一份「这位玩家算不算同一位玩家」的判断方法
依赖前面
- 第 18 章
spectatorPlayerIds[]+ 三层拦截 + 三个写时刻 - 第 17 章
personalScore/ 结算快照 /OnMatchEndedHook接口 - 第 14 章
OnPlayerLeft字段清理 - 闭环 3
OnPlayerLeftCleanup扩展点
跨阶段重入:三种处理
Section titled “跨阶段重入:三种处理”「玩家在 INGAME 第 N 阶段离开实例,回到同实例时仍在 INGAME」。第 18 章默认按「新加入旁观」处理。本章把另外两种扩展也写出来,让项目按需选。
处理 1:默认旁观(第 18 章给的)
Section titled “处理 1:默认旁观(第 18 章给的)”A 离开时 PlayerObject 销毁,所有 per-player 数据消失。A 重新进入时 PlayerObject 重新克隆,所有字段重置。OnPlayerJoined 看到 phase==INGAME 把 A 写进 spectatorPlayerIds。下一局 RESTART 后 A 变回普通玩家。
代价:A 本局贡献丢失。适合「玩家中途离开很少发生 + 单局短(10 分钟内)」的世界。
处理 2:立刻入队(合作 PvE)
Section titled “处理 2:立刻入队(合作 PvE)”A 重新进入时不进旁观席,直接成为参赛者。personalScore 不恢复(仍是 0),从当前时刻起继续累计。
实装:在 OnPlayerJoined 的 INGAME 分支里加一个开关。
public bool allowMidMatchJoin = true; // 项目参数
public override void OnPlayerJoined(VRCPlayerApi player){ if (!Networking.IsOwner(gameObject)) return; if (player == null) return;
if (phase == PHASE_INGAME) { if (allowMidMatchJoin) { AssignToWeakestTeam(player); // 第 17 章「自动入弱队」逻辑 // 不写入 spectatorPlayerIds } else { AddSpectator(player.playerId); // 第 18 章默认 } return; } // 其他 phase 走第 18 章默认}代价:UI 和分数计算要假设「玩家可能从任意 wave 开始」。「时间奖励」第 27 章按 personalScore 占比分配,新加入的玩家占比小,自然奖励少。这一处理在合作 PvE 里通常不会让玩家不满。
适合「合作 PvE + 长局(30+ 分钟)」的世界。
处理 3:恢复贡献(PvP 排位 / 长局)
Section titled “处理 3:恢复贡献(PvP 排位 / 长局)”A 重新进入时不仅入队,还恢复本局已累积的 personalScore / teamId / roleId。这一处理需要把字段从 PlayerObject 上分离出来:因为 PlayerObject 跟着玩家销毁,离开时数据就丢了。
实装方案:在 GameState 上加一份恢复簿。这里先给字段和入口,数组搬移按第 14 章 OnPlayerLeftCleanup 的同款写法处理,不在正文展开。
// GameState 上[UdonSynced] private int[] recoverPlayerIds = new int[8];[UdonSynced] private int[] recoverPersonalScores = new int[8];[UdonSynced] private byte[] recoverTeamIds = new byte[8];[UdonSynced] private byte[] recoverRoleIds = new byte[8];[UdonSynced] private int recoverCount;三处入口够用。OnPlayerLeft 在 Owner 且 phase == PHASE_INGAME 时,把离开者的 personalScore / teamId / roleId 写进第一空槽。OnPlayerJoined 的 INGAME 分支先调 TryRestoreOnRejoin(player),恢复成功就向新克隆出的 PlayerObject Owner 发送 OnRestoreFromRejoin,再从恢复簿删掉这一槽。恢复失败则走默认旁观或立刻入队。
if (phase == PHASE_INGAME && TryRestoreOnRejoin(player)){ return; // 恢复成功,不进旁观席}代价:恢复簿是同步字段,每位玩家离开都会增加同步包。8 位玩家上限的房间里 recoverCount 最多 8,可控;但示例里的 recoverPlayerIds[] 只能说明字段形态,不能当作可靠身份方案。下一节展开。
迟入串味的四个具体场景
Section titled “迟入串味的四个具体场景”「串味」指迟入玩家或旁观玩家看到本不该看到的数据,或者操作了本不该操作的入口。第 18 章三层拦截框架已经覆盖大部分情况,但实操有几个常踩的具体点。
场景 1:UI 字段没绑 IsSpectator 查询
Section titled “场景 1:UI 字段没绑 IsSpectator 查询”// 错的写法(场景 1)private void Update(){ var localPlayer = Networking.LocalPlayer; if (localPlayer == null) return; var pls = GetPLS(localPlayer); if (pls == null) return; myScoreLabel.text = pls.PersonalScore.ToString(); // 旁观者也看到 0}旁观者本人没参与本局,PersonalScore=0。UI 直接显示「我:0」会让旁观者疑惑(看上去像 bug)。修法:
// 对的写法if (gameState != null && gameState.IsSpectator(localPlayer.playerId)){ myScoreLabel.text = "(旁观)"; return;}myScoreLabel.text = pls.PersonalScore.ToString();或者在 RESULT 阶段进一步隐藏整行:
if (phase == PHASE_RESULT && pls.PersonalScore == 0){ myScoreRow.SetActive(false);}判断标准:所有「本人字段」的 UI 渲染前都要查 IsSpectator,决定是显示真实值、显示「(旁观)」还是隐藏。
场景 2:Handle* 漏 IsSpectator 检查
Section titled “场景 2:Handle* 漏 IsSpectator 检查”第 18 章已经写过 HandleScore 加 if (IsSpectator(who.playerId)) return;。但项目加新 Handle 函数(HandleUseSkill / HandleInteractObjective / HandleVoteRestart)时容易漏。
修法:把 IsSpectator 检查抽到 ProcessRequest 入口处统一拦截。
private void ProcessRequest(VRCPlayerApi who, PlayerLobbyState pls){ byte type = pls.RequestType;
// 旁观者只允许少数请求类型通过 if (IsSpectator(who.playerId)) { if (type != PlayerLobbyState.REQ_PLAY_NEXT) return; }
if (type == PlayerLobbyState.REQ_START) HandleStart(who); else if (type == PlayerLobbyState.REQ_SCORE) HandleScore(who); // ... 其他 Handle ...}REQ_PLAY_NEXT 是旁观者唯一允许通过的请求类型(下一节展开)。这种集中处理避免漏检。
场景 3:OnPlayerLeftCleanup 漏清业务字段
Section titled “场景 3:OnPlayerLeftCleanup 漏清业务字段”闭环 3 留的 OnPlayerLeftCleanup 扩展点。新加的 per-player 业务字段如果忘了在这里清,离开者的旧数据会让新加入玩家看到「幽灵记录」。
第 17 章扩展(助攻 / 时间奖励)几乎没有 per-player 业务字段挂在 GameState 上,因为本卷的设计倾向是 per-player 字段挂 PlayerObject。但有一类例外:奖励标记字段(如 bonusTakenByPlayerId = -1)必须挂 GameState,因为「这一份奖励是否已被领取」是房间状态。
private void OnPlayerLeftCleanup(VRCPlayerApi player){ if (bonusTakenByPlayerId == player.playerId) { bonusTakenByPlayerId = -1; // 让奖励重新可领 } // 助攻簿伤害簿不在 GameState 上,跟着 PlayerObject 销毁,无需清 // 旁观席 spectatorPlayerIds 已在第 18 章 OnPlayerLeft 主流程清}判断方法:每加一个 [UdonSynced] 字段在 GameState 上,立刻问「这个字段会不会绑定到一位玩家身上」。如果会,OnPlayerLeftCleanup 里加一行清理。
场景 4:spectatorPlayerIds[] 容量不够
Section titled “场景 4:spectatorPlayerIds[] 容量不够”第 18 章选 MAX_PLAYERS 容量。如果项目在 BeginMatch 时把所有「人在房但不参赛」的玩家都写入旁观席(重建名单逻辑),容量会接近上限。再有迟入玩家进来时数组写满,无法追加。
第 18 章已经讨论这一点:当前用 if (spectatorCount < spectatorPlayerIds.Length) 防止越界,但越界后玩家不进旁观席也不参赛,状态错乱。
修法:
- 容量按世界 Maximum Capacity 设置,留 20% 余量。
- 或者改用
DataDictionary(第 24 章),动态容量。 - 极端情况下加一个 fallback:写不进
spectatorPlayerIds[]时直接禁用玩家所有 UI(等同于「外部观察者」状态)。
旁观请求参赛:完整实装
Section titled “旁观请求参赛:完整实装”第 17 / 18 章留的 REQ_PLAY_NEXT 接口签名,本节实装。
// PlayerLobbyState 已有 REQ_PLAY_NEXT 常量定义public const byte REQ_PLAY_NEXT = 4;
// GameState 上加[UdonSynced] private int[] wantsToReturnPlayerIds = new int[16]; // 「下一局想参赛」的旁观者[UdonSynced] private int wantsToReturnCount = 0;PlayerLobbyState 上加发送函数:
public void RequestPlayNext(){ IssueRequest(REQ_PLAY_NEXT, 0); // 沿用闭环 2 IssueRequest 模板}GameState.HandlePlayNext:
private void HandlePlayNext(VRCPlayerApi who){ if (!IsSpectator(who.playerId)) return; // 不是旁观者忽略 if (phase != PHASE_INGAME && phase != PHASE_RESULT) return; AddToWantsToReturn(who.playerId);}
private void AddToWantsToReturn(int playerId){ // 先去重 for (int i = 0; i < wantsToReturnCount; i++) if (wantsToReturnPlayerIds[i] == playerId) return;
if (wantsToReturnCount >= wantsToReturnPlayerIds.Length) return; wantsToReturnPlayerIds[wantsToReturnCount] = playerId; wantsToReturnCount++;}RESTART 时的归并
Section titled “RESTART 时的归并”RestartMatch 把旁观者归并回普通玩家。第 18 章已经写过默认策略「全员归并」(所有旁观者变回普通玩家)。本节给「请求归并」(只有按过 REQ_PLAY_NEXT 的旁观者归并回,其他保留旁观)。
public bool restartMode = false; // false=全员归并,true=请求归并
private void RestartMatch(){ // ... 第 16 章 RestartMatch 原有逻辑 ...
if (restartMode) CompactSpectatorsByWantList(); else ClearSpectators();
ClearWantList(); RequestSerialization(); ApplyState();}CompactSpectatorsByWantList 做一件事:遍历 spectatorPlayerIds[],把没按过 REQ_PLAY_NEXT 的旁观者留在数组前半段,按过的移出旁观席,尾部清零。具体循环和第 14 章的数组清理同款,不重复贴。
旁观者面板加一个按钮 RequestPlayNextButton,按下调 pls.RequestPlayNext()。已加入 wantsToReturnPlayerIds 的旁观者 UI 显示「等待下一局」(按钮变灰)。
// UI 渲染if (gameState.IsSpectator(localPlayer.playerId)){ bool alreadyRequested = gameState.IsInWantsToReturn(localPlayer.playerId); requestPlayNextButton.interactable = !alreadyRequested; requestPlayNextLabel.text = alreadyRequested ? "等待下一局" : "下一局参赛";}RESULT → LOBBY 阶段间策略二选
Section titled “RESULT → LOBBY 阶段间策略二选”完整对照。
| 项目 | 全员归并(第 18 章默认) | 请求归并(本章扩展) |
|---|---|---|
| 旁观者 RESTART 后 | 自动变回普通玩家 | 仅按过 REQ_PLAY_NEXT 的变回,其他保持旁观 |
| 玩家意图 | 不需要明示 | 必须按按钮明示 |
| 适合场景 | 合作 PvE / 休闲房 | PvP / 8 人对战 / 长局 |
| 字段成本 | 仅 spectatorPlayerIds[] 在 RESTART 时清空 | 加 wantsToReturnPlayerIds[] + UI 按钮 |
| UI 复杂度 | 不变 | 加请求按钮 + 状态显示 |
策略选择由 restartMode 开关控制,运行时不切换(在 Inspector 里设定)。
「这位玩家算不算同一位玩家」的判断方法
Section titled “「这位玩家算不算同一位玩家」的判断方法”跨阶段重入 / 跨会话重连 / 旁观请求参赛三类场景都要回答这个问题。
玩家 X 离开后又有人进来 ID 是 Y。X == Y 吗? ├── 同一位玩家本机重新进入同实例 → 本卷不按同一 playerId 处理 ├── X 离开,5 分钟后从手机重新登录 → 不是(跨会话) └── 用 displayName 匹配 → 不稳(用户可改名)
实操判断: - 不能用 playerId 跨重入匹配 - 不能用 displayName 跨重入匹配(虽然实操更稳一些) - 跨实例 / 跨会话的「同一位玩家」靠 PlayerData / VRCEnablePersistence(Vol.5)
本卷的处理: - 默认放弃跨重入恢复(处理 1,第 18 章默认) - 项目自有需求时按处理 2 / 3 实装,明示约束 - 长期身份保留交给 Vol.5判断方法的实质是:在 VRChat 的当前 SDK 下,「同一位玩家」概念在跨进程 / 跨会话场景里没有本卷可直接依赖的稳定标识。任何尝试做跨重入恢复的代码都要明示这一约束,让运营时能预判异常情况。
不同世界不同活法
Section titled “不同世界不同活法”挑一条试。
- 实装处理 3「恢复贡献」并故意设置
playerId不一致的测试用例。观察TryRestoreOnRejoin找不到匹配时的行为。给一种「显式回滚」机制(让recoverCount > 0时自动清理超过 N 分钟的归档)。 - 把 UI 字段绑
IsSpectator查询的 4 个场景全部修一遍。修完后的 UI 在「我是参赛者」「我是旁观者」「我是 RESULT 阶段刚加入」三种状态下分别显示什么? - 把「请求归并」的
wantsToReturnPlayerIds[]改成「在 LOBBY 阶段重置,玩家可以反复按 / 取消」。需要加几个新入口?UI 要怎么变化?
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. 处理 1:A 在 wave 5 离开 wave 8 进入 | A 进入 spectatorPlayerIds,UI 显示「(旁观)」,本局结束 RESTART 后 A 变回普通玩家 | … |
2. 处理 2:A 同样情况 + allowMidMatchJoin=true | A 不进旁观席,入弱队继续战斗,personalScore=0 从当前累计 | … |
| 3. 处理 3:A 同样情况 + 假设 playerId 一致 | A 恢复 personalScore / teamId / roleId,继续战斗 | … |
| 4. 场景 1:旁观者打开自己的 UI | 显示「(旁观)」或隐藏「我」一行,不显示「我:0」 | … |
| 5. 场景 2:旁观者改客户端发 REQ_SCORE | ProcessRequest 入口拦截,不进入 HandleScore | … |
| 6. 场景 3:A 拿走 bonus 后离开 | OnPlayerLeftCleanup 把 bonusTakenByPlayerId 重置为 -1,bonus 重新可领 | … |
| 7. 旁观者按「下一局参赛」 | wantsToReturnPlayerIds 加入该 playerId,UI 显示「等待下一局」 | … |
| 8. 全员归并:RESTART 后旁观者状态 | 全部变回普通玩家,spectatorPlayerIds 清空 | … |
| 9. 请求归并:RESTART 后旁观者状态 | 仅按过 REQ_PLAY_NEXT 的变回,其他保持旁观,wantsToReturn 清空 | … |
| 10. 容量极限:spectatorPlayerIds 写满 | 后续迟入玩家拒绝处理(fallback),UI 提示房间满载 | … |
第 4 / 5 / 6 是迟入串味的核心修复。第 8 / 9 是阶段间策略二选的对照。
- 能列出跨阶段重入的三种处理及各自适用场景
- 能识别
playerId不能用作跨重入匹配键的原因 - 能默写迟入串味四个场景的具体修法
- 能解释
ProcessRequest入口集中检查比每个 Handle 各自检查更稳的原因 - 能实装「请求归并」并说出与「全员归并」的字段成本差异
- 能识别
OnPlayerLeftCleanup必须清的字段类型(per-player 业务标记)
- VRChat Creator Docs · Player API —
OnPlayerJoined/OnPlayerLeft/playerId行为。 - VRChat Creator Docs · Persistence — Vol.5 跨会话身份的最终方案。
- 第 14 章 · Owner 离开后的恢复 —
OnPlayerLeft主流程。 - 第 17 章 · 计分、奖励与结算 —
OnMatchEndedHook接口与 personalScore 边界。 - 第 18 章 · 观战、旁观、掉线后重入 — 旁观席三时刻 + 三层拦截 + 会话内重入。
- 闭环 3 · 加上命令版本号的工程化一局 —
OnPlayerLeftCleanup扩展点。 - 第 27 章 · 计分、奖励与结算进阶 — 临时空位的队伍补偿。
- 附录 · 术语表 —
Cross-phase Rejoin/Want-to-Return List的客观定义。