跳转到内容

第 18 章 · 观战、旁观、掉线后重入

约 10 分钟 难度:2 动手章

这一章解决:迟入玩家在 INGAME 中应当看到什么、能操作什么;玩家中途掉线再重入时算不算「同一位玩家」;旁观席什么时候清空。

先看一下

第 17 章把 INGAME 中迟入的默认处理留给旁观席。本章补上判断标准:只看「OnPlayerJoinedphase==INGAME」不够。它只能识别开局后加入的人,识别不了开局前已经在房间、但本局没有参与开局的人。

把这条直接做成同步字段 spectatorPlayerIdsBeginMatch 重建本局旁观名单,INGAME 中 OnPlayerJoined 追加迟入玩家,比赛结束 RestartMatch 清空。代码层面比间接判断「加入时机」更稳,UI 层面也方便。

这一章会拿到什么

  • 旁观席字段为什么放 GameState 而不是 PlayerObject:判断维度
  • 三个写时刻的职责(BeginMatch / INGAME 中 OnPlayerJoined / OnPlayerLeft
  • 旁观期间 UI / Interact / Handle* 三层拦截原则
  • 「会话内掉线重入」与「跨会话重连」两种场景的边界
  • RESULT → LOBBY 时旁观者归并的两种策略

依赖前面

  • 第 17 章 personalScore 与结算快照
  • 第 16 章 BeginMatch / RestartMatch
  • 第 14 章 OnPlayerLeft 字段清理
  • 闭环 2 候选队列接管

判断「字段放 PlayerObject 还是 GameState」的标准是写权和裁决时机。

isSpectator 这条字段:

  • 由谁裁决:GameState Owner(在 OnPlayerJoinedphase 决定)。
  • 谁需要看:所有客户端(UI 显示「这位玩家是不是观众」)。
  • 是不是「玩家自己的属性」:在某种意义上是,但真正的写权在 GameState

PlayerObject 上的代价:每位玩家进房间都要跑一段「我是不是旁观者」的判断逻辑。这段逻辑在 Start 里跑,但 Startphase 可能还没同步到位(玩家刚进来 PlayerObject 立刻 Start,但 GameState 字段同步还要几百毫秒)。结果是字段容易写成错误值。

GameState 上则是「裁决一次写一次,所有客户端读同一份字段」。GameState OwnerOnPlayerJoined 里看到 phase==INGAME 时立刻写 spectatorPlayerIds,所有客户端通过同步收到这份名单。新玩家自己的客户端通过 IsSpectator(localPlayer.playerId) 查询自己是不是旁观者。

字段类型选 [UdonSynced] int[] spectatorPlayerIds = new int[MAX_PLAYERS] + [UdonSynced] int spectatorCount,沿用候选队列的「定长数组 + 计数」模板。MAX_PLAYERS 按世界的 Maximum Capacity 设置,避免旁观名单写满后有人落到错误的「非旁观」状态。IsSpectator 是只读查询,遍历前 spectatorCount 个 ID 比对:

public bool IsSpectator(int playerId)
{
for (int i = 0; i < spectatorCount; i++)
if (spectatorPlayerIds[i] == playerId) return true;
return false;
}

UI 和按钮都用它判断「本地玩家是不是旁观者」。


旁观席的写操作集中在三个时刻,全部由 GameState Owner 完成。

新一局开始时先清掉上一局的旁观名单,再按本局参与条件重建一次。默认合作防守原型要求所有参赛者已经选队、选角色、Ready,所以名单通常是空的;如果项目允许「人在房间但本局不参赛」,就在这里把这些玩家追加进 spectatorPlayerIds

ClearSpectators();
foreach (var p in currentPlayers)
{
if (!IsParticipantForThisMatch(p)) AddSpectator(p.playerId);
}

关键点是「重建」,不是只把 spectatorCount = 0。上一局的旁观者不会因为旧名单残留继续被判旁观,本局没有参与开局的人也不会被误当成参赛者。

OnPlayerJoined 里看到 phase==INGAME 时,把新玩家追加进旁观席:

if (phase == PHASE_INGAME && spectatorCount < spectatorPlayerIds.Length)
{
spectatorPlayerIds[spectatorCount] = player.playerId;
spectatorCount++;
RequestSerialization();
}

phase==LOBBY 时新玩家是普通参赛者,不进旁观席。phase==RESULT 时新玩家也不进旁观席:他直接看到结算屏,下一局 BeginMatch 视为普通参赛者。

玩家离开时,OnPlayerLeft 中已经清理候选队列和 lastProcessedRequestId(第 14 章)。本章追加一条:从 spectatorPlayerIds 数组中移除这位玩家。写法和候选队列的 RemoveFromCandidates 同款:找到位置 → 后续元素前移 → spectatorCount--

旁观者离开和参赛者离开走同一条 OnPlayerLeft。区别在于参赛者离开时他的 personalScore 跟着 PlayerObject 自动销毁,旁观者本来就没有 personalScore 增量,离开干净。


旁观者按钮的统一规则:

  • 本人面板按钮:Ready / Start Vote / 加入队伍 / 选角色 / Score 全部不可点(UI 置灰)。
  • 房间总览:完整可见。旁观者可以看到分数、倒计时、其他玩家的角色。
  • 队伍区:只读可见。

三层拦截分工:

实现位置角色
1. UI 置灰Unity Button 组件用户友好提示,让旁观者一眼看到自己不能操作
2. GameLoopButton.InteractC# 按钮代码拦截 UI 误触,避免按钮被错连或脚本调用绕过
3. Handle* 函数GameState Handle 系列真正的安全门,拦改客户端绕过前两层的玩家

Interact 里的关键判断只有一行:

if (gameState != null && gameState.IsSpectator(p.playerId)) return;

加在 kind != KIND_REQUEST_PLAY_NEXT 之后即可,让旁观者还能用「请求参赛」按钮(如果项目实现了)。

Handle* 一侧也要查 IsSpectator。阶段检查只能挡住 Lobby 命令:旁观期间 phase==INGAMEHandleJoinTeam / HandleSetRole 会因 phase != PHASE_LOBBY 被拒,但 HandleScore 原本就在 INGAME 受理。如果旁观者改客户端绕过 UI 闸门发了 REQ_SCORE,最里层必须挡住。

if (IsSpectator(who.playerId)) return;

这行放在 HandleScoreHandleUseSkillHandleInteractObjective 这类会改变比赛结果的入口前面。UI 置灰负责体验,Interact 拦截负责防错连,Handle* 才是最终安全门。


会话内掉线重入:本卷的简化处理

Section titled “会话内掉线重入:本卷的简化处理”

VRChat 玩家可能因为网络抖动离开实例,再进入同一实例。这种「会话内重入」对 Udon 来说是一次离开加一次加入:

  1. OnPlayerLeft(player) 触发:他的 PlayerObject 被销毁,personalScoreteamId 全部丢失。
  2. 重新进入实例时 OnPlayerJoined 触发。playerId 只适合当作实例内的临时编号,不适合作跨重连身份键。Udon 也没有暴露可作为稳定账号 ID 使用的字段;displayName 会改名,也不能当身份键。
  3. 如果此时 phase==INGAME,他按迟入玩家进入旁观席。

本卷对会话内重入不做特殊优化:重入玩家被视为新加入的玩家。personalScore 不恢复(PlayerObject 已销毁),下一局重新积累。

如果要做「跨掉线保留分数」,需要把 personalScore 等本局个人字段挪到 GameState 的数组、玩家离开时保留一小段时间、玩家重入时用项目自己的规则匹配身份。这一组改动会大幅增加 GameState 的复杂度。是否值得做,取决于单局时长和掉线后的惩罚成本。


「玩家在世界 A 玩了一局,结束后离开 VRChat,几小时后重新打开 VRChat 又进世界 A,希望保留之前累积的成就」是跨会话重连。本卷不处理。

实现路径在 Vol.5:

  • 玩家个人成就放在启用 VRCEnablePersistence 的对象上,或放进 PlayerData。
  • 玩家加入新实例后,等 OnPlayerRestored(player) 触发,再读取和使用持久字段。
  • 第 8 章「玩家进入房间后,系统要给他什么」已经标注过 OnPlayerRestored 的位置。

本卷在 EndMatch 里留的 OnMatchEndedHook(VRCPlayerApi who, int personalDelta) 接口(第 17 章)就是 Vol.5 持久层的接入点。


RestartMatchspectatorCount = 0 并清数组体(已经在「三个写时刻」一节里写过)。旁观者在 RESULT → LOBBY 时回到普通 Lobby 玩家:OnResetLobbyisReady = false,下一局开始前重新选边、Ready、投票。下一次 BeginMatch 再按本局参与条件重建旁观名单。

如果项目需要「旁观者请求参赛」按钮(让旁观者明示「下一局我要玩」),可以加一条命令 REQ_PLAY_NEXT,在 GameState 上加 wantsToReturnPlayerIds 数组,RestartMatch 时只把请求过的旁观者变回普通玩家。本卷只给接口签名,不实现:默认策略对大多数玩法够用。


挑一条试。

  • 把「INGAME 中迟入自动旁观」改成「INGAME 中迟入自动加入人少的队伍」。改哪几行?这种改动适合 PvE 还是 PvP?
  • 给旁观者加一条「请求参赛」按钮,结合 wantsToReturnPlayerIds 字段,让 RestartMatch 时只把请求过的旁观者变回普通玩家。
  • OnPlayerLeft 里清理离开者前,先把他的 personalScore 累加到 GameState 的「离开玩家总分」字段。这种字段在合作 PvE 里有用吗?还是在 PvP 排位赛里有用?

场景预期实际
1. INGAME 中 D 加入IsSpectator(D)=true,UI 按钮置灰
2. D 在旁观期间按 ScoreGameLoopButton.Interact 拦截;即使请求被伪造,HandleScore 也因 IsSpectator 拒绝
3. RESULT 阶段加入 EIsSpectator(E)=false(E 不进旁观席),E 直接看到结算
4. RestartMatch 后 D 状态IsSpectator(D)=false,D 在 LOBBY 是普通玩家,可选边
5. D 旁观期间离开spectatorPlayerIds 中 D 的位被移除,spectatorCount -1
6. 会话内掉线重入A 离开后重入;如果仍在 INGAME,按新加入玩家进旁观席,原 personalScore 不恢复
7. BeginMatch 重建旁观名单旧名单先清空;本局未参与开局的玩家被重新写入 spectatorPlayerIds
8. 最大容量压力测试spectatorPlayerIds.Length 覆盖世界 Maximum Capacity,没有玩家因数组写满落到错误的非旁观状态

第 4、7 行是关键:旁观者在新一局必须能正常参赛,未参与本局的人也必须被明确标成旁观。如果第 7 行未通过,先回去检查 BeginMatch 是不是只清空了旧名单、没有重建本局名单。


  • 能默写旁观席三个写时刻(BeginMatch / INGAME 中 OnPlayerJoined / OnPlayerLeft)
  • 能解释为什么旁观席放 GameState 而不是 PlayerObject
  • 能说出会话内重入和跨会话重连的边界,并指出后者交给哪一卷
  • 能复述旁观期间 UI / GameLoopButton / Handle* 三层拦截各自的角色
  • 能识别本卷默认策略(RESULT 后旁观者全部归并)的两种自定义改法