跳转到内容

第 28 章 · 观战、旁观、掉线后重入进阶

约 11 分钟 难度:3 动手章

这一章解决:第 18 章的最小旁观席能跑,但若干具体问题留作扩展。玩家中途离开同实例再回来要不要恢复进度?UI 字段忘了绑 IsSpectator 查询导致旁观者还能看到「我的得分」?旁观者按了「下一局参赛」按钮,下一局开始时怎么知道把哪些人变回普通玩家?这些扩展不发明新模式,沿用第 18 章三时刻 + 三层拦截框架。

先看一下

闭环 3 + 第 18 章基础旁观席跑 4 人合作防守原型:A 打到第 5 波退实例(网络抖动 / 临时换房间)。第 8 波 A 重新进入。A 端看到自己 PlayerObject 是新克隆的:personalScore=0teamId=-1isReady=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 扩展点

「玩家在 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 分钟内)」的世界。

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[] 只能说明字段形态,不能当作可靠身份方案。下一节展开。


「串味」指迟入玩家或旁观玩家看到本不该看到的数据,或者操作了本不该操作的入口。第 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,决定是显示真实值、显示「(旁观)」还是隐藏。

第 18 章已经写过 HandleScoreif (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(等同于「外部观察者」状态)。

第 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++;
}

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 ? "等待下一局" : "下一局参赛";
}

完整对照。

项目全员归并(第 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 下,「同一位玩家」概念在跨进程 / 跨会话场景里没有本卷可直接依赖的稳定标识。任何尝试做跨重入恢复的代码都要明示这一约束,让运营时能预判异常情况。



挑一条试。

  • 实装处理 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=trueA 不进旁观席,入弱队继续战斗,personalScore=0 从当前累计
3. 处理 3:A 同样情况 + 假设 playerId 一致A 恢复 personalScore / teamId / roleId,继续战斗
4. 场景 1:旁观者打开自己的 UI显示「(旁观)」或隐藏「我」一行,不显示「我:0」
5. 场景 2:旁观者改客户端发 REQ_SCOREProcessRequest 入口拦截,不进入 HandleScore
6. 场景 3:A 拿走 bonus 后离开OnPlayerLeftCleanupbonusTakenByPlayerId 重置为 -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 业务标记)