跳转到内容

第 9 章 · VRCPlayerObject:每个玩家一份对象

约 9 分钟 难度:2 动手章

这一章解决:第 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 是一个组件,不是单独的「分配器」。把它挂在场景里某一个 GameObject 上,那个 GameObject 就成了「模板」。SDK 在玩家加入实例时做三件事:

  1. 按这个模板克隆一份给该玩家。
  2. 把这份克隆的 Owner 锁定为这位玩家本人,不可被其他玩家请求转移。
  3. 在玩家离开实例时,自动销毁这份克隆。

每位玩家一份。8 人房间会有 8 份 PlayerLobbyState,每份各自的 Owner 是不同的玩家。每位玩家只能写自己那份的同步字段,其他玩家通过 SDK 提供的查询接口读到所有人的状态。

模板本身在运行时会被 SDK 关闭(设为非激活),用来禁止被直接引用。所以 Inspector 里把模板拖进别的脚本字段里没用,运行时拿到的是被禁用的模板,不是任何玩家的实例。运行时的引用必须靠后面会讲的 Networking.GetPlayerObjects 来取。

一个 GameObject PlayerLobbyStateTemplate(命名随意),就这一个:

  • 根节点挂 VRCPlayerObject 组件(来自 VRC.SDK3.Persistence 命名空间,在 Add Component 里搜 VRC Player Object 即可找到)。
  • 同一个根节点挂 PlayerLobbyState(下面写的 UdonSharp 脚本)。
  • 如果要持久化,再挂 VRCEnablePersistence(本卷只占位,Vol.5 展开)。
  • 不要在这个模板下放「全局只该一份」的资源(如全局 UI Canvas、共享音效源)。模板每位玩家一份,全局资源会被复制 N 份。

场景里其他位置不需要预先摆每一位玩家的实例,SDK 会在运行时按这一份模板自动克隆。

建议截图:场景里的模板 GameObject + Inspector 上的 VRCPlayerObjectPlayerLobbyState 两个组件。


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 转移协议。


闭环 1 里 GameLoopMaster.NetStart 检查 if (!lobbyReady) return;。改造成 PlayerObject 之后,门变成「所有玩家都 Ready 了才能开局」。

读一位玩家的 PlayerObject 实例,要用 SDK 提供的 Networking.GetPlayerObjects(VRCPlayerApi player)。它返回该玩家所有模板克隆的 GameObject[](同一场景可以同时摆几种不同模板,每种各占数组一项)。再从每个 GameObjectGetComponentInChildren<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.GetPlayerObjectsVRC.SDKBase 命名空间的 Networking 类里,UdonSharp 脚本里通常已经 using VRC.SDKBase; 所以可以直接调用。第 11 章 GameState 会在自己内部声明同样的 GetPLS,并在多处复用。


下面这张卡是本卷后续章节统一回查的依据。第 11、12、14、28、40 章再讨论「这件事归 PlayerObject 还是 GameState」时,回这一节,不在别处重复展开。

适合例子理由
玩家本人才有写权的状态isReady、teamId、selectedRoleOwner 锁定,写权天然干净
数量随玩家数线性增长的字段每位玩家的分数、HP、CDper-player 实例化,不用维护数组下标
需要随玩家离开自动消失的状态持续装备、临时 buff、当前观战目标OnPlayerLeft 时实例自动销毁
玩家本地输入到全局逻辑的请求载体第 12 章请求式架构的 requestId / requestType玩家写、GameState 读
需要一份真实 GameObject 的持久化数据玩家专属道具、玩家专属工具VRCEnablePersistence 让 PlayerObject 上的同步字段跨访问恢复;简单键值仍归 PlayerData
不适合例子理由
全局唯一的房间状态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 处理。


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.GetPlayerObjectsOnPlayerJoined 里立刻调用可能返回空 / 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 全局共用的具体表现。
  • 附录 · 术语表VRCPlayerObjectPer-Player StatePlayerObject Template 的客观定义。