第 18 章 · 观战、旁观、掉线后重入
这一章解决:迟入玩家在 INGAME 中应当看到什么、能操作什么;玩家中途掉线再重入时算不算「同一位玩家」;旁观席什么时候清空。
先看一下
第 17 章把 INGAME 中迟入的默认处理留给旁观席。本章补上判断标准:只看「OnPlayerJoined 时 phase==INGAME」不够。它只能识别开局后加入的人,识别不了开局前已经在房间、但本局没有参与开局的人。
把这条直接做成同步字段 spectatorPlayerIds:BeginMatch 重建本局旁观名单,INGAME 中 OnPlayerJoined 追加迟入玩家,比赛结束 RestartMatch 清空。代码层面比间接判断「加入时机」更稳,UI 层面也方便。
这一章会拿到什么
- 旁观席字段为什么放
GameState而不是PlayerObject:判断维度 - 三个写时刻的职责(
BeginMatch/ INGAME 中OnPlayerJoined/OnPlayerLeft) - 旁观期间 UI /
Interact/Handle*三层拦截原则 - 「会话内掉线重入」与「跨会话重连」两种场景的边界
RESULT → LOBBY时旁观者归并的两种策略
依赖前面
- 第 17 章
personalScore与结算快照 - 第 16 章
BeginMatch/RestartMatch - 第 14 章
OnPlayerLeft字段清理 - 闭环 2 候选队列接管
旁观席这条字段放哪里
Section titled “旁观席这条字段放哪里”判断「字段放 PlayerObject 还是 GameState」的标准是写权和裁决时机。
isSpectator 这条字段:
- 由谁裁决:
GameState Owner(在OnPlayerJoined看phase决定)。 - 谁需要看:所有客户端(UI 显示「这位玩家是不是观众」)。
- 是不是「玩家自己的属性」:在某种意义上是,但真正的写权在
GameState。
放 PlayerObject 上的代价:每位玩家进房间都要跑一段「我是不是旁观者」的判断逻辑。这段逻辑在 Start 里跑,但 Start 时 phase 可能还没同步到位(玩家刚进来 PlayerObject 立刻 Start,但 GameState 字段同步还要几百毫秒)。结果是字段容易写成错误值。
放 GameState 上则是「裁决一次写一次,所有客户端读同一份字段」。GameState Owner 在 OnPlayerJoined 里看到 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 完成。
时刻 1 · BeginMatch 时重建名单
Section titled “时刻 1 · BeginMatch 时重建名单”新一局开始时先清掉上一局的旁观名单,再按本局参与条件重建一次。默认合作防守原型要求所有参赛者已经选队、选角色、Ready,所以名单通常是空的;如果项目允许「人在房间但本局不参赛」,就在这里把这些玩家追加进 spectatorPlayerIds。
ClearSpectators();
foreach (var p in currentPlayers){ if (!IsParticipantForThisMatch(p)) AddSpectator(p.playerId);}关键点是「重建」,不是只把 spectatorCount = 0。上一局的旁观者不会因为旧名单残留继续被判旁观,本局没有参与开局的人也不会被误当成参赛者。
时刻 2 · INGAME 中 OnPlayerJoined
Section titled “时刻 2 · INGAME 中 OnPlayerJoined”OnPlayerJoined 里看到 phase==INGAME 时,把新玩家追加进旁观席:
if (phase == PHASE_INGAME && spectatorCount < spectatorPlayerIds.Length){ spectatorPlayerIds[spectatorCount] = player.playerId; spectatorCount++; RequestSerialization();}phase==LOBBY 时新玩家是普通参赛者,不进旁观席。phase==RESULT 时新玩家也不进旁观席:他直接看到结算屏,下一局 BeginMatch 视为普通参赛者。
时刻 3 · OnPlayerLeft 清理
Section titled “时刻 3 · OnPlayerLeft 清理”玩家离开时,OnPlayerLeft 中已经清理候选队列和 lastProcessedRequestId(第 14 章)。本章追加一条:从 spectatorPlayerIds 数组中移除这位玩家。写法和候选队列的 RemoveFromCandidates 同款:找到位置 → 后续元素前移 → spectatorCount--。
旁观者离开和参赛者离开走同一条 OnPlayerLeft。区别在于参赛者离开时他的 personalScore 跟着 PlayerObject 自动销毁,旁观者本来就没有 personalScore 增量,离开干净。
旁观期间能点什么:三层拦截
Section titled “旁观期间能点什么:三层拦截”旁观者按钮的统一规则:
- 本人面板按钮:Ready / Start Vote / 加入队伍 / 选角色 / Score 全部不可点(UI 置灰)。
- 房间总览:完整可见。旁观者可以看到分数、倒计时、其他玩家的角色。
- 队伍区:只读可见。
三层拦截分工:
| 层 | 实现位置 | 角色 |
|---|---|---|
| 1. UI 置灰 | Unity Button 组件 | 用户友好提示,让旁观者一眼看到自己不能操作 |
2. GameLoopButton.Interact | C# 按钮代码 | 拦截 UI 误触,避免按钮被错连或脚本调用绕过 |
3. Handle* 函数 | GameState Handle 系列 | 真正的安全门,拦改客户端绕过前两层的玩家 |
Interact 里的关键判断只有一行:
if (gameState != null && gameState.IsSpectator(p.playerId)) return;加在 kind != KIND_REQUEST_PLAY_NEXT 之后即可,让旁观者还能用「请求参赛」按钮(如果项目实现了)。
Handle* 一侧也要查 IsSpectator。阶段检查只能挡住 Lobby 命令:旁观期间 phase==INGAME,HandleJoinTeam / HandleSetRole 会因 phase != PHASE_LOBBY 被拒,但 HandleScore 原本就在 INGAME 受理。如果旁观者改客户端绕过 UI 闸门发了 REQ_SCORE,最里层必须挡住。
if (IsSpectator(who.playerId)) return;这行放在 HandleScore、HandleUseSkill、HandleInteractObjective 这类会改变比赛结果的入口前面。UI 置灰负责体验,Interact 拦截负责防错连,Handle* 才是最终安全门。
会话内掉线重入:本卷的简化处理
Section titled “会话内掉线重入:本卷的简化处理”VRChat 玩家可能因为网络抖动离开实例,再进入同一实例。这种「会话内重入」对 Udon 来说是一次离开加一次加入:
OnPlayerLeft(player)触发:他的PlayerObject被销毁,personalScore和teamId全部丢失。- 重新进入实例时
OnPlayerJoined触发。playerId只适合当作实例内的临时编号,不适合作跨重连身份键。Udon 也没有暴露可作为稳定账号 ID 使用的字段;displayName会改名,也不能当身份键。 - 如果此时
phase==INGAME,他按迟入玩家进入旁观席。
本卷对会话内重入不做特殊优化:重入玩家被视为新加入的玩家。personalScore 不恢复(PlayerObject 已销毁),下一局重新积累。
如果要做「跨掉线保留分数」,需要把 personalScore 等本局个人字段挪到 GameState 的数组、玩家离开时保留一小段时间、玩家重入时用项目自己的规则匹配身份。这一组改动会大幅增加 GameState 的复杂度。是否值得做,取决于单局时长和掉线后的惩罚成本。
跨会话重连:交给 Vol.5
Section titled “跨会话重连:交给 Vol.5”「玩家在世界 A 玩了一局,结束后离开 VRChat,几小时后重新打开 VRChat 又进世界 A,希望保留之前累积的成就」是跨会话重连。本卷不处理。
实现路径在 Vol.5:
- 玩家个人成就放在启用
VRCEnablePersistence的对象上,或放进 PlayerData。 - 玩家加入新实例后,等
OnPlayerRestored(player)触发,再读取和使用持久字段。 - 第 8 章「玩家进入房间后,系统要给他什么」已经标注过
OnPlayerRestored的位置。
本卷在 EndMatch 里留的 OnMatchEndedHook(VRCPlayerApi who, int personalDelta) 接口(第 17 章)就是 Vol.5 持久层的接入点。
RESULT → LOBBY:旁观者归并
Section titled “RESULT → LOBBY:旁观者归并”RestartMatch 把 spectatorCount = 0 并清数组体(已经在「三个写时刻」一节里写过)。旁观者在 RESULT → LOBBY 时回到普通 Lobby 玩家:OnResetLobby 把 isReady = 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 在旁观期间按 Score | GameLoopButton.Interact 拦截;即使请求被伪造,HandleScore 也因 IsSpectator 拒绝 | … |
| 3. RESULT 阶段加入 E | IsSpectator(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 后旁观者全部归并)的两种自定义改法
- VRChat Creator Docs · Player API —
OnPlayerJoined/OnPlayerLeft触发时机与VRCPlayerApi。 - VRChat Creator Docs · Persistence 与 PlayerData — 跨会话重连的
OnPlayerRestored,本卷只引边界。 - VR Creators · Persistence, PlayerData, And PlayerObjects — 英文社区资料,用来对照 PlayerData 与 PlayerObject 的边界。
- 第 14 章 · Owner 离开后的恢复 — 候选队列 /
lastProcessedRequestId清理的母本。 - 第 17 章 · 计分、奖励与结算 —
personalScore与结算快照。 - 第 8 章 · 玩家进入房间后,系统要给他什么 — 玩家生命周期六阶段。
- 附录 · 术语表 —
Spectator、Session-Internal Rejoin、Cross-Session Reconnect的客观定义。