跳转到内容

第 8 章 · 玩家进入房间后,系统要给他什么

约 7 分钟 难度:2 动手章

这一章解决:在写任何同步代码之前,先把「一位玩家从进入到离开,房间要给他什么」拆清楚,避免后面把 per-player 状态硬塞进全局字段。

先看一下

闭环 1 里 lobbyReady 是全局一份 bool:A 按 Ready 后整个房间是 READY,B 按 Ready 又翻回 NOT READY。两位玩家都按过,房间却显示未准备。类型 bool 是合适的;问题在于它在 GameLoopMaster 上只有一份。每位玩家应当有自己的一份 Ready 状态。

把这个观察往上推一层:每位玩家在房间里有一组属于他自己的状态,Ready 只是其中一项。这一章把它们一次性列出来。

这一章会拿到什么

  • 一份「玩家进入到离开」六阶段生命周期图,每阶段配一类要给的东西
  • 六类 per-player 状态的对照表,每类附「适合放哪、为什么不能放全局」
  • 一份「玩家上下文检查表」,写第 9 章 VRCPlayerObject 之前用它做需求清单

依赖前面

  • 第 1 章四象限里的「私有」与「共享 × 持续」两档
  • 第 5 章迟入恢复的四类判断
  • 闭环 1 的缺陷 2(lobbyReady 全局共用)

一位玩家在房间里走的六个阶段

Section titled “一位玩家在房间里走的六个阶段”

把第 0 章设想里的 4 人合作防守原型当画布。新玩家从打开 VRChat 到离开实例,会经过六个阶段。每个阶段对系统都有一类诉求。每个阶段标记的是「这位玩家此刻需要被记的什么东西」,不一定对应一块 UI。

进入实例 ─→ 身份建立 ─→ 加载等待 ─→ 大厅 ─→ 比赛 ─→ 离开 / 重连
│ │ │ │ │ │
OnPlayer playerId OnPlayer isReady HP/score OnPlayer
Joined 可用 Restored teamId skillCD Left

VRChat 在玩家加入时调用所有 UdonBehaviour 的 OnPlayerJoined(VRCPlayerApi player)。这一刻系统要决定:

这位玩家在这局里叫什么 ID。VRChat 的 playerId 跨客户端可比,直接用。这位玩家是不是 Master 一般可以暂不关心,因为 Vol.2 不再围绕 IsMaster 设计权威(第 3 章已经讲过)。这位玩家要不要拿到一份 per-player 对象,是第 9 章的主题。

启用了 VRCEnablePersistence 的世界,玩家进来后还要等持久数据从云端拉回。OnPlayerRestored(VRCPlayerApi player) 是这一刻的回调。在它触发之前,玩家的持久字段不能被读,否则可能读到默认值。

本卷不展开持久化。Vol.5 是它的主轴。这里只标位置:有持久化的世界多一个等待阶段,没有的世界跳过。

玩家进了大厅 UI。要给他的:一个能切换的 Ready 状态,只属于他自己;一个能切换的队伍 / 角色(如果有);以及看到其他玩家 Ready / 队伍状态的视图。闭环 1 的缺陷 2 卡在这一步:Ready 没分离。

InGame 期间,玩家自己要被记的字段通常包括:当前角色 / 装备 / 技能冷却(在合作防守里就是炮塔类型、剩余血量、技能 CD);个人分数 / 击杀 / 助攻;他向 GameState 发出的请求队列。这些字段不是大家共享的全局状态,是「这位玩家自己拥有的状态」,其他玩家只读、不写。

InGame 结束。每位玩家有自己的结算视图:本局战绩、是不是 MVP(这一项是 GameState 算的,每位玩家只读)、长期奖励(接 Vol.5)。

VRChat 在玩家离开时调用 OnPlayerLeft(VRCPlayerApi player)。系统要决定:这位玩家拥有的 per-player 对象怎么办(第 9 章会讲,VRCPlayerObject 自动销毁);这位玩家拥有的非 per-player 对象怎么办(第 14 章三类恢复路径);他在 GameState 里留下的请求 / 排队 / 队伍位置怎么办。

如果他重连回来,会话内重连应当能恢复(第 14 章),跨会话重连走持久化(Vol.5)。


把六阶段折叠一下:不论哪个阶段,玩家最多要被记六类东西。

类别例子适合放哪为什么不能放全局
1. 身份记录playerId、displayName 缓存PlayerObject 或 GameState 玩家列表全局存一个 int playerId 就只能记一位
2. 准备状态isReady、isLoaded、isAFKPlayerObject(per-player)闭环 1 缺陷 2 已经演示过
3. 队伍 / 角色team=Red, role=EngineerPlayerObject全局只能存房间里第一个选完的那位
4. 输入通道按键请求、技能请求、加入队伍请求PlayerObject 上的请求字段 + GameState 处理第 12 章请求式架构展开
5. 个人 UI鼠标在哪、面板开没开、本地提示完全本地(档「私有」)同步浪费带宽,其他玩家看不见无意义
6. 持久恢复信号OnPlayerRestored 后的 isRestored 标志PlayerObject 上的标志位全局标志会被多位玩家撞

类别 4 是第 12 章的主题。类别 5 纯本地,不进入网络层。其余四类都需要 per-player 的同步存储,第 9 章的 VRCPlayerObject 会成为它们的物理载体。


写第 9 章 VRCPlayerObject 之前先答一遍下面 7 题。每题对应一类要决定的事,答完就有了 PlayerObject 字段集合的初稿。

[ ] 身份:除了 playerId,本局还要不要记 displayName 缓存?
(Owner 离开后还想显示名字时需要)
[ ] 准备:Ready 是 bool 还是 byte(多档准备)?
[ ] 队伍:有没有队伍 / 角色选择?数量多少?
[ ] 比赛态:玩家自己有几个会变的字段(HP、score、CD、装备)?
[ ] 输入:玩家会发起哪几类请求(按按钮、放技能、加入队伍)?
[ ] 个人 UI:哪些 UI 状态严格本地?
[ ] 持久:本卷一律先填「无」,等 Vol.5 再回来。

把答案贴进项目里的 design/player-context.md(如果还没有就建一个)。后续每多想出一类 per-player 字段,先回这张表上加一行,再去改 PlayerObject 代码。


挑一两条试试。每条不要直接翻第 9 章,先自己想一遍再对答案。

  • 把六阶段图画到自己当前在做的某个 VRChat 项目上。每阶段问一句「这一步系统要记这位玩家什么」。空白的阶段意味着这种玩法不需要 per-player 状态。
  • 把闭环 1 的 lobbyReady 在脑里挪到「PlayerObject 上的 bool isReady」。NetToggleReady 要写成什么样?这次写操作的 Owner 是谁?
  • 给阶段 4「比赛」想一组炮塔技能 CD 字段。如果你把它放进 GameState 而不是 PlayerObject,会发生什么具体的现象?

本章不写代码,给一份审计矩阵:拿一个现成的 VRChat 项目(自己的或闭环 1)按下面四问扫一遍。

检查项期望实际
全局字段里有没有「单数 per-player」嫌疑(如 bool isReadyint currentPlayerId 当唯一存储)没有,或被识别出来
OnPlayerJoined 里有没有立刻读 PlayerObject 字段没有
OnPlayerLeft 里有没有清掉这位玩家的 GameState 痕迹有,或被识别为缺失
启用了 VRCEnablePersistence 的字段读取是否在 OnPlayerRestored 之后是,或被识别为缺失

  • 能把六阶段生命周期默写出来,并指出每阶段对应的 VRChat 回调
  • 能解释为什么 lobbyReady 写成全局 bool 是错位
  • 能区分类别 5(个人 UI 本地)和类别 1–4(per-player 同步)
  • 能复述 OnPlayerJoined 不是「立刻可用」的两条理由