第 9 章 · VRCPlayerObject:每个玩家一份对象
这一章解决:第 8 章列出来的 per-player 状态,物理上挂在哪。
VRCPlayerObject是当前 SDK 下这类状态的标准载体。
先看一下
闭环 1 里 lobbyReady 写成 [UdonSynced] bool lobbyReady,全局一份。第 8 章已经把它定性为「类别 2 准备状态」,应当 per-player。但 per-player 的物理意义是什么:每位玩家进房间,系统给他实例化一份对象,这份对象的 Owner 就是他本人,他写自己的字段,其他人只读。VRChat SDK 内置了这套机制,叫 VRCPlayerObject。
这一章把这套机制讲完,并把 lobbyReady 真的搬到每位玩家自己的对象上。
这一章会拿到什么
- 一份
VRCPlayerObject的设置流程:场景里要摆什么、预制体里要挂什么 - 一份
PlayerLobbyState脚本:把isReady挂到每位玩家自己的对象上 - 一份「PlayerObject 边界卡」:适合什么、不适合什么、模糊地带各列三条,本卷后续章节统一回查
依赖前面
- 第 8 章六类 per-player 状态分类与玩家上下文检查表
- 第 4 章 Manual 同步生命周期
- 第 3 章
Networking.IsOwner用法 - 闭环 1 缺陷 2(
lobbyReady全局共用)
VRCPlayerObject 在做的事
Section titled “VRCPlayerObject 在做的事”VRCPlayerObject 是一个组件,不是单独的「分配器」。把它挂在场景里某一个 GameObject 上,那个 GameObject 就成了「模板」。SDK 在玩家加入实例时做三件事:
- 按这个模板克隆一份给该玩家。
- 把这份克隆的 Owner 锁定为这位玩家本人,不可被其他玩家请求转移。
- 在玩家离开实例时,自动销毁这份克隆。
每位玩家一份。8 人房间会有 8 份 PlayerLobbyState,每份各自的 Owner 是不同的玩家。每位玩家只能写自己那份的同步字段,其他玩家通过 SDK 提供的查询接口读到所有人的状态。
模板本身在运行时会被 SDK 关闭(设为非激活),用来禁止被直接引用。所以 Inspector 里把模板拖进别的脚本字段里没用,运行时拿到的是被禁用的模板,不是任何玩家的实例。运行时的引用必须靠后面会讲的 Networking.GetPlayerObjects 来取。
场景里要摆什么
Section titled “场景里要摆什么”一个 GameObject PlayerLobbyStateTemplate(命名随意),就这一个:
- 根节点挂
VRCPlayerObject组件(来自VRC.SDK3.Persistence命名空间,在Add Component里搜VRC Player Object即可找到)。 - 同一个根节点挂
PlayerLobbyState(下面写的 UdonSharp 脚本)。 - 如果要持久化,再挂
VRCEnablePersistence(本卷只占位,Vol.5 展开)。 - 不要在这个模板下放「全局只该一份」的资源(如全局 UI Canvas、共享音效源)。模板每位玩家一份,全局资源会被复制 N 份。
场景里其他位置不需要预先摆每一位玩家的实例,SDK 会在运行时按这一份模板自动克隆。
建议截图:场景里的模板 GameObject + Inspector 上的
VRCPlayerObject与PlayerLobbyState两个组件。
第一份脚本:PlayerLobbyState.cs
Section titled “第一份脚本:PlayerLobbyState.cs”using UdonSharp;using UnityEngine;using VRC.SDKBase;// 用到 VRCPlayerObject / VRCEnablePersistence 类型时才需要。// 本脚本只读 VRCPlayerApi、Networking,可省略下面这一行。// using VRC.SDK3.Persistence;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] // 沿用闭环 1 的 Manual 习惯public class PlayerLobbyState : UdonSharpBehaviour{ // 每位玩家自己拥有的「准备」状态。这一份字段每位玩家一份,互不干扰 [UdonSynced] private bool isReady;
// 队伍 / 角色(暂留 0,第 11 章 GameState 会用到) [UdonSynced] private byte teamId;
// 这位玩家是否完成了加载(涉及持久化时用,本卷只占位) [UdonSynced] private bool isLoaded;
// ===== 公共读接口(其他脚本只读) ===== public bool IsReady => isReady; public byte TeamId => teamId; public bool IsLoaded => isLoaded;
// ===== 谁是这份对象的 Owner ===== // VRCPlayerObject 把 Owner 锁死为这位玩家。下面一行的返回值在整个生命周期内不会变 public VRCPlayerApi Owner => Networking.GetOwner(gameObject);
private void Start() { // 玩家进入后初始化。注意:远端拿到的初值会通过同步覆盖 if (Networking.IsOwner(gameObject)) { isReady = false; isLoaded = true; // 没有持久化的世界默认已经加载完 RequestSerialization(); } }
public override void OnDeserialization() { // 远端整批收到新值后通知 GameLoop / UI 刷新 // 实际通知会通过事件 / 直接查询完成,第 11 章 GameState 整理 }
// ===== 玩家本地按钮调用这条 ===== public void RequestToggleReady() { // 只有这份对象的 Owner(即这位玩家本人)才有写权 if (!Networking.IsOwner(gameObject)) return;
isReady = !isReady; RequestSerialization(); }
// ===== 队伍切换示例(第 11 章会扩展) ===== public void RequestSetTeam(byte target) { if (!Networking.IsOwner(gameObject)) return; teamId = target; RequestSerialization(); }}和闭环 1 的 GameLoopMaster.NetToggleReady 比较:闭环 1 里玩家按 Ready 是用 SendCustomNetworkEvent(NetworkEventTarget.Owner, ...) 把请求转给 Master Owner,再由 Master Owner 改全局 lobbyReady。这里玩家直接调用自己 PlayerObject 上的 RequestToggleReady,写的是他自己拥有的字段,不需要任何跨客户端事件转发。
「玩家自己改自己的字段」是 PlayerObject 模式的核心简化点:写权天然分到玩家本人,不再需要 Owner 转移协议。
怎么读所有玩家的 Ready
Section titled “怎么读所有玩家的 Ready”闭环 1 里 GameLoopMaster.NetStart 检查 if (!lobbyReady) return;。改造成 PlayerObject 之后,门变成「所有玩家都 Ready 了才能开局」。
读一位玩家的 PlayerObject 实例,要用 SDK 提供的 Networking.GetPlayerObjects(VRCPlayerApi player)。它返回该玩家所有模板克隆的 GameObject[](同一场景可以同时摆几种不同模板,每种各占数组一项)。再从每个 GameObject 上 GetComponentInChildren<T>() 取出目标脚本。
把这一段封装成 helper 函数,本卷后续章节统一复用:
// 写在 GameState(或任何需要按玩家查 PlayerObject 的脚本)里private PlayerLobbyState GetPLS(VRCPlayerApi player){ if (player == null || !player.IsValid()) return null; var objs = Networking.GetPlayerObjects(player); if (objs == null) return null;
for (int i = 0; i < objs.Length; i++) { if (objs[i] == null) continue; var pls = objs[i].GetComponentInChildren<PlayerLobbyState>(); if (pls != null) return pls; } return null;}有了 GetPLS,开局门就是「遍历所有玩家、每位都 Ready」:
private bool AllReady(){ int playerCount = VRCPlayerApi.GetPlayerCount(); if (playerCount == 0) return false;
var players = new VRCPlayerApi[playerCount]; VRCPlayerApi.GetPlayers(players);
for (int i = 0; i < playerCount; i++) { var p = players[i]; if (p == null || !p.IsValid()) continue;
var state = GetPLS(p); if (state == null) return false; // 这位玩家的对象还没克隆完 if (!state.IsReady) return false; } return true;}Networking.GetPlayerObjects 在 VRC.SDKBase 命名空间的 Networking 类里,UdonSharp 脚本里通常已经 using VRC.SDKBase; 所以可以直接调用。第 11 章 GameState 会在自己内部声明同样的 GetPLS,并在多处复用。
PlayerObject 边界卡
Section titled “PlayerObject 边界卡”下面这张卡是本卷后续章节统一回查的依据。第 11、12、14、28、40 章再讨论「这件事归 PlayerObject 还是 GameState」时,回这一节,不在别处重复展开。
适合放 PlayerObject 的字段
Section titled “适合放 PlayerObject 的字段”| 适合 | 例子 | 理由 |
|---|---|---|
| 玩家本人才有写权的状态 | isReady、teamId、selectedRole | Owner 锁定,写权天然干净 |
| 数量随玩家数线性增长的字段 | 每位玩家的分数、HP、CD | per-player 实例化,不用维护数组下标 |
| 需要随玩家离开自动消失的状态 | 持续装备、临时 buff、当前观战目标 | OnPlayerLeft 时实例自动销毁 |
| 玩家本地输入到全局逻辑的请求载体 | 第 12 章请求式架构的 requestId / requestType | 玩家写、GameState 读 |
| 需要一份真实 GameObject 的持久化数据 | 玩家专属道具、玩家专属工具 | VRCEnablePersistence 让 PlayerObject 上的同步字段跨访问恢复;简单键值仍归 PlayerData |
不适合放 PlayerObject 的字段
Section titled “不适合放 PlayerObject 的字段”| 不适合 | 例子 | 理由 |
|---|---|---|
| 全局唯一的房间状态 | phase、totalScore、当前波次 | 让玩家自己改全局状态会出现冲突,第 11 章 GameState 处理 |
| 跨玩家的胜负判定 | winnerPlayerId、MVP | 由 GameState Owner 裁决,PlayerObject 只能上报输入 |
| 房间共享的资源池 | 子弹池、敌人池、掉落物 | 第 7 部对象池主题,归共享对象 |
| 安全检查的最终判定 | 反作弊判定、计分裁决 | PlayerObject Owner 是该玩家本人,让他写就等于让作弊者自己判自己的分 |
| 看情况 | 例子 | 决策维度 |
|---|---|---|
| 玩家可见但全局共享的统计 | 击杀总数、本局贡献 | 写权在 GameState,读权在所有人;不放 PlayerObject |
| 玩家会请求修改的全局状态 | 「我想加入红队」 | 请求字段写在 PlayerObject,落地写在 GameState(请求式架构,第 12 章) |
| 玩家本地的 UI 状态 | 我打开了哪个面板 | 完全本地,不进同步层;既不在 PlayerObject 也不在 GameState |
简化判断是:Owner 是玩家本人才放 PlayerObject。Owner 应当是房间共识的全局对象放 GameState。Owner 是「这位玩家」并且字段需要写给「房间」的,写请求字段到 PlayerObject,房间收到请求后由 GameState 处理。
持久化的边界
Section titled “持久化的边界”VRCPlayerObject 上的字段加 [UdonSynced] 是实例内同步。只有在同一个 GameObject 上同时有 UdonBehaviour、同步字段和 VRCEnablePersistence 时,PlayerObject 的持久数据才会由 VRChat 恢复。这一节只标边界,本卷不展开:
- 一局之内的状态用普通
[UdonSynced]:玩家中途切换 Team、Ready、HP,这些会话结束后该丢的字段。 - 跨访问保留的状态交给持久化:玩家累计经验、解锁的角色、个人偏好。
- 简单键值优先用 PlayerData;确实需要每位玩家一份 GameObject 时,再用带
VRCEnablePersistence的 PlayerObject。 - 持久字段读取时机要等
OnPlayerRestored,第 8 章已经标注过。
完整的持久化设计、字段尺寸限制、版本迁移在 Vol.5。
- 把
PlayerLobbyState加一个[UdonSynced] string playerNickname,让玩家可以在大厅里给自己起一个本局昵称(不影响 VRChat 账号)。Owner 是谁?写权应该怎么校验? - 用
AllReady()写一个最小的GameLoopMaster.NetStart改造版,把闭环 1 的「if (!lobbyReady) return;」换成「if (!gameLoop.AllReady()) return;」,跑一下两人 Build & Test,看 Ready 是不是真的不会互相覆盖。 - 让
PlayerLobbyState里加一个本应该是全局的字段(比如[UdonSynced] int totalScore),写一份代码让所有玩家都能改它。跑两个客户端按按钮,观察这个字段的实际行为。看完恢复回来。
按第 7 章五行表跑。两个客户端 Build & Test。
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. A 切 Ready,B 端读到 | A 按 Ready 后,B 端 1 秒内通过 GetPLS(playerA).IsReady 读到 true | … |
| 2. A 和 B 都切 Ready | 两位 Ready 都为 true,互不覆盖(修复闭环 1 缺陷 2) | … |
| 3. 迟入玩家 C 加入 | C 进来时立刻能通过遍历读到 A、B 的 Ready 当前值;C 自己的 PlayerObject 在几百毫秒内生成完 | … |
| 4. A 离开实例 | A 的 PlayerLobbyState 实例自动销毁;其他玩家 AllReady() 不再卡在 A 身上 | … |
| 5. 拥塞下日志 | 30 秒内连按 Ready 60 次,OnPostSerialization 报告里 success 大部分为 true | … |
第二行是这一章相对闭环 1 的核心修复。如果第二行未通过,说明 PlayerObject 模板没有正确克隆,先回去检查模板上有没有挂 VRCPlayerObject 组件。
- 能解释为什么
PlayerLobbyState的字段是 per-player 的,而GameLoopMaster.lobbyReady是全局的 - 能默写 PlayerObject 边界卡的「适合 / 不适合 / 模糊」三类各两条
- 能说出
Networking.GetPlayerObjects在OnPlayerJoined里立刻调用可能返回空 / null 的两条原因 - 能区分
[UdonSynced](会话内)和VRCEnablePersistence(跨会话)
- VRChat Creator Docs · VRCPlayerObject:设置流程、生命周期、Owner 锁定、
OnPlayerRestored的官方说明。 - VRChat Creator Docs · Persistence:持久化能力的整体说明,本卷只引边界。
- VR Creators · Persistence, PlayerData, And PlayerObjects:英文社区资料,用来对照 PlayerData 与 PlayerObject 的边界。
- VRCD 文档库 · VRCPlayerObject:中文社区整理的实操要点。
- 闭环 1 · 缺陷 2 现场:
lobbyReady全局共用的具体表现。 - 附录 · 术语表:
VRCPlayerObject、Per-Player State、PlayerObject Template的客观定义。