27. 谁说了算,Ownership
第 26 章那盏灯,两个客户端各按各的,状态各走各的。要让两边「按一次只翻一次」,必须先回答一个问题:A 按了和 B 按了,谁的值算数?
VRChat 给的答案是:每个网络对象同一时刻只有一个 Owner,第 28 章要广播的同步字段,只有 Owner 改了才会发出去。其他人在自己客户端上改这个字段,只在本地生效,发不出去。
这一章不让灯真的同步起来,那是第 28 章的事。这一章把 Owner 这件事讲清楚,并给那盏灯加一个「按按钮的人成为 Owner」的步骤。地基铺好了,再接 [UdonSynced] 才能合拢。
Owner 是什么、不是什么
Section titled “Owner 是什么、不是什么”每个被 VRChat 视为「网络对象」的 GameObject 都有一个 Owner。判断标准是这个物体上至少挂着一个 UdonBehaviour 或 VRC Object Sync 这类网络组件。
Owner 是一名玩家。具体规则:
- 场景一开始就存在的网络物体,初始 Owner 通常是实例的第一个进来的玩家(也就是常说的 Master)。
- Owner 离开实例时,VRChat 会自动把对象所有权改派给仍在实例中的其他玩家(常见是当前 Master / 最早加入者)。不要把具体顺序写进玩法规则里;把它当成 SDK 会自动接管的兜底机制。
- 任何在场玩家都可以通过
Networking.SetOwner(player, gameObject)主动请求接管所有权(前提是脚本不拒绝,下面OnOwnershipRequest会讲)。 - 同一时刻,Owner 只有一位。
Owner 不能做的事:
- Owner 不是「管理员」。它不能阻止别人的客户端在本地改自己的字段。
- Owner 离世界后这个角色就消失了,不带走任何数据。下一个 Owner 接手时看到的是这个对象当前的同步状态。
给灯加几行 Owner 调试日志
Section titled “给灯加几行 Owner 调试日志”继续用第 26 章那盏灯。把 LampToggle.cs 改成:
using UdonSharp;using UnityEngine;using VRC.SDKBase;
public class LampToggle : UdonSharpBehaviour{ public Light targetLight;
public override void Interact() { targetLight.enabled = !targetLight.enabled;
VRCPlayerApi local = Networking.LocalPlayer; VRCPlayerApi owner = Networking.GetOwner(gameObject);
bool isOwner = Networking.IsOwner(gameObject);
string ownerName = owner != null ? owner.displayName : "null"; Debug.Log( $"[Lamp] {local.displayName} pressed. " + $"enabled = {targetLight.enabled}, " + $"owner = {ownerName}, " + $"IsOwner = {isOwner}" ); }}三个新工具方法这一章会反复用:
Networking.LocalPlayer:本机客户端的玩家对象。Networking.GetOwner(gameObject):返回当前是哪位玩家拥有这个物体。Networking.IsOwner(gameObject):是不是本机玩家就是这个物体的 Owner。等价于GetOwner(go) == LocalPlayer,写起来更短。
Networking 是 VRChat 提供的静态类,所有网络相关的工具方法都挂在它上面。第三部讲过静态类,这里就是典型应用。
存盘,Build & Publish,让两个客户端进同一个实例,按几下灯,看各自的 Output Log。
预期看到的日志
Section titled “预期看到的日志”假设玩家 Alpha 第一个进入实例,玩家 Beta 后进。
Alpha 客户端的 Output Log:
[Lamp] Alpha pressed. enabled = False, owner = Alpha, IsOwner = TrueBeta 客户端的 Output Log(按下同一个开关):
[Lamp] Beta pressed. enabled = False, owner = Alpha, IsOwner = False两件值得停一下的事:
- owner = Alpha:两台客户端都认为这个物体是 Alpha 拥有的。Alpha 是实例的第一个加入者,默认所有权给了她。
- Beta 那边 IsOwner = False:Beta 按下去,灯在 Beta 本地仍然翻了(
targetLight.enabled在本地还是会变)。但 Beta 不是 Owner,第 28 章接同步变量后会发现 Beta 在这种状态下改的同步字段不会被广播。
VRC Pickup 已经在悄悄转移所有权
Section titled “VRC Pickup 已经在悄悄转移所有权”回头看第 15 章那只 PickupBall。挂了 VRC Object Sync 的它,在多人下行为正常:A 抓起来,B 那边看到球被搬到 A 手里。
这件事背后就是所有权自动转移。VRC Pickup 加 VRC Object Sync 配合时,VRChat 替作者做了这一连串:
玩家伸手抓物体 ↓VRC Pickup 检测到抓取尝试 ↓内部调用 Networking.SetOwner(抓的人, 球) ↓所有权转给这位玩家 ↓她接下来移动球时,球的 Transform 变化 ↓VRC Object Sync 检测变化(她是 Owner,发得出去) ↓广播给其他客户端 ↓VRC Object Sync 的内部同步把球摆到对应位置 / 旋转每一步都是一条已经存在的机制,VRChat 把它们打包成「抓起 Pickup 自动同步」这件让作者觉得理所当然的事。
给 PickupBall 也加一段调试日志,亲眼看一次:
public override void OnPickup(){ if (targetLight != null) { targetLight.intensity = heldIntensity; }
VRCPlayerApi owner = Networking.GetOwner(gameObject); Debug.Log($"[Pickup] picked up. owner is now {owner.displayName}");}A 实例的 Owner 是 Alpha。Beta 抓起小球的瞬间,Beta 的客户端会打:
[Pickup] picked up. owner is now Beta要在两边确认所有权变化,更可靠的观察点是 OnOwnershipTransferred 或稍后打印 Networking.GetOwner(gameObject) 的结果。不要只依赖远端是否也打出 OnPickup 日志来判断;Pickup 交互事件和 Object Sync 的内部同步不是同一条机制。
给灯按钮加 SetOwner
Section titled “给灯按钮加 SetOwner”回到那盏灯。要让它在多人下能正常同步,得在按下的瞬间把所有权先抢过来:
using UdonSharp;using UnityEngine;using VRC.SDKBase;
public class LampToggle : UdonSharpBehaviour{ public Light targetLight;
public override void Interact() { // 第一步:把所有权抢到本机 if (!Networking.IsOwner(gameObject)) { Networking.SetOwner(Networking.LocalPlayer, gameObject); }
// 第二步:本地翻字段 targetLight.enabled = !targetLight.enabled;
// 调试:日志 VRCPlayerApi owner = Networking.GetOwner(gameObject); Debug.Log( $"[Lamp] {Networking.LocalPlayer.displayName} pressed. " + $"enabled = {targetLight.enabled}, " + $"owner = {owner.displayName}" ); }}新加那一段就两件事:
- 先用
Networking.IsOwner(gameObject)检查本机是不是 Owner。 - 不是就调
Networking.SetOwner(Networking.LocalPlayer, gameObject),把所有权抢过来。
这一章字段还没加 [UdonSynced],所以两台客户端的灯还是各自翻各自的。但 Owner 的归属会变了。
Build & Publish 重测:Alpha 进来,按一下,日志:
[Lamp] Alpha pressed. enabled = False, owner = AlphaBeta 接着按一下,日志(注意 owner 变了):
[Lamp] Beta pressed. enabled = False, owner = BetaAlpha 在自己客户端再按一下:
[Lamp] Alpha pressed. enabled = True, owner = AlphaSetOwner 的效果立刻可见:每次有人按,Owner 都会切到她。把 enabled 标成 [UdonSynced] 之后(第 28 章会做这件事),这个 Owner 切换的瞬间就是「广播这条新状态」的时机。
拒绝接管:OnOwnershipRequest
Section titled “拒绝接管:OnOwnershipRequest”某些场景不希望随便就被人接管所有权。考虑一个游戏房间:A 玩家正在玩一段单人小关卡,B 玩家走过来按了那个开关,所有权抢过去,A 的小关卡数据全乱。
VRChat 给的工具是 OnOwnershipRequest。在脚本里重写它,返回 bool 决定批不批准这次所有权请求:
public override bool OnOwnershipRequest(VRCPlayerApi requestingPlayer, VRCPlayerApi requestedOwner){ // requestingPlayer:发起请求的玩家(多数情况下就是想成为新 Owner 的那个) // requestedOwner:请求把所有权转给谁(即将成为新 Owner 的人) // 官方文档里这两个参数叫 requester / newOwner,含义一致。 // 返回 true:允许;返回 false:拒绝 return true;}默认行为(不重写时)等价于 return true:所有请求都批准。要写一个「比赛进行中不许换 Owner」的关卡,可以这样:
[UdonSynced]private bool isInMatch = false;
public override bool OnOwnershipRequest(VRCPlayerApi requestingPlayer, VRCPlayerApi requestedOwner){ if (isInMatch) { return false; // 比赛中拒绝换 Owner } return true;}OnOwnershipRequest 在请求方和当前 Owner 两台客户端上都会跑一次。意思是:B 想抢 A 的所有权,B 自己客户端的脚本先评估一遍(可以在这里挡掉本地不想发的请求),然后 VRChat 把请求发到 A 的客户端,A 客户端的脚本再判断要不要批准。任一端 return false 就会中止,两端都返回 true 转移流程才会真的开始。
转完做什么:OnOwnershipTransferred
Section titled “转完做什么:OnOwnershipTransferred”所有权真的转完之后,VRChat 会在实例中的所有客户端上触发 OnOwnershipTransferred:
public override void OnOwnershipTransferred(VRCPlayerApi player){ // player:新 Owner 是谁 Debug.Log($"[Lamp] ownership transferred to {player.displayName}");}参数是新 Owner。回调的常见用途:
- 更新 UI:在某个面板上显示「现在由 Alpha 控制」。
- 重置某些状态:旧 Owner 留下的临时数据清掉。
- 触发音效或视觉反馈:让玩家知道控制权变了。
不是所有 GameObject 都有 Owner
Section titled “不是所有 GameObject 都有 Owner”Networking.GetOwner(gameObject) 只对网络化的 GameObject有意义。判断标准:物体上至少挂了一个 UdonBehaviour 或 VRC Object Sync 等网络组件。
挂了几个普通 Unity 组件(MeshRenderer、Light、Collider)的物体,VRChat 不把它当网络对象,调 GetOwner 会报警告或返回 null。
实战里这条不太容易踩到。习惯做法是:要管所有权的脚本字段,挂在写 UdonSharp 的那个 GameObject 上。gameObject 这个引用在 UdonSharpBehaviour 里指的就是「自己挂在的那个 GameObject」,自动满足条件。
自己改两个版本
Section titled “自己改两个版本”把当前的 LampToggle.cs 当骨架,做两个变体看 Owner 行为:
- 加一个字段
public bool ownershipLocked = false;,并在OnOwnershipRequest里返回!ownershipLocked。Build & Publish 后,让 A 把ownershipLocked在 Inspector 里设为 true(或用一个调试按钮翻它),观察 B 再按那盏灯时 Owner 是否变化。 - 重写
OnOwnershipTransferred(VRCPlayerApi player),里面Debug.Log一行「ownership now belongs to X」。两个客户端都看一眼日志,确认这个事件在两边都触发了。
第一个变体会让 B 那边看到「按按钮没反应」的现象。所有权拒绝转移之后,本地 Interact 仍然跑(VRChat 不会因为拒绝 Owner 转移就跳过 Interact),但因为 B 不是 Owner,加了 [UdonSynced] 后 B 改的字段广播不出去。这种「能按但没效果」的现象常常让玩家以为按钮坏了,所以拒绝接管的世界通常配一个 UI 提示告诉玩家「现在不能动这个」。
Owner 工具箱
Section titled “Owner 工具箱”到这里 Owner 的工具齐了:
Networking.IsOwner(go)/GetOwner(go):判断当前 Owner。Networking.SetOwner(player, go):抢占所有权,先IsOwner判一下、再调,少做一次完整的转移流程。OnOwnershipRequest(req, target)返回 bool:在请求方和当前 Owner 两边评估,任一端拒绝都会中止。OnOwnershipTransferred(player):转移完成后所有客户端都触发,常用来更新 UI 或重置临时状态。
「按按钮的人成为 Owner」这一步铺好之后,灯本身仍然两边各亮各的。所有权决定的是「谁改的字段才会被发出去」,但 VRChat 还需要知道哪个字段要被发出去。这件事要等同步变量上场才合拢。