跳转到内容

27. 谁说了算,Ownership

约 45 分钟 难度:进阶 动手章

第 26 章那盏灯,两个客户端各按各的,状态各走各的。要让两边「按一次只翻一次」,必须先回答一个问题:A 按了和 B 按了,谁的值算数?

VRChat 给的答案是:每个网络对象同一时刻只有一个 Owner,第 28 章要广播的同步字段,只有 Owner 改了才会发出去。其他人在自己客户端上改这个字段,只在本地生效,发不出去。

这一章不让灯真的同步起来,那是第 28 章的事。这一章把 Owner 这件事讲清楚,并给那盏灯加一个「按按钮的人成为 Owner」的步骤。地基铺好了,再接 [UdonSynced] 才能合拢。

每个被 VRChat 视为「网络对象」的 GameObject 都有一个 Owner。判断标准是这个物体上至少挂着一个 UdonBehaviourVRC Object Sync 这类网络组件。

Owner 是一名玩家。具体规则:

  • 场景一开始就存在的网络物体,初始 Owner 通常是实例的第一个进来的玩家(也就是常说的 Master)。
  • Owner 离开实例时,VRChat 会自动把对象所有权改派给仍在实例中的其他玩家(常见是当前 Master / 最早加入者)。不要把具体顺序写进玩法规则里;把它当成 SDK 会自动接管的兜底机制。
  • 任何在场玩家都可以通过 Networking.SetOwner(player, gameObject) 主动请求接管所有权(前提是脚本不拒绝,下面 OnOwnershipRequest 会讲)。
  • 同一时刻,Owner 只有一位。

Owner 不能做的事:

  • Owner 不是「管理员」。它不能阻止别人的客户端在本地改自己的字段。
  • Owner 离世界后这个角色就消失了,不带走任何数据。下一个 Owner 接手时看到的是这个对象当前的同步状态。

继续用第 26 章那盏灯。把 LampToggle.cs 改成:

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。

假设玩家 Alpha 第一个进入实例,玩家 Beta 后进。

Alpha 客户端的 Output Log:

[Lamp] Alpha pressed. enabled = False, owner = Alpha, IsOwner = True

Beta 客户端的 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 在这种状态下改的同步字段不会被广播。

回头看第 15 章那只 PickupBall。挂了 VRC Object Sync 的它,在多人下行为正常:A 抓起来,B 那边看到球被搬到 A 手里。

这件事背后就是所有权自动转移。VRC PickupVRC Object Sync 配合时,VRChat 替作者做了这一连串:

玩家伸手抓物体
VRC Pickup 检测到抓取尝试
内部调用 Networking.SetOwner(抓的人, 球)
所有权转给这位玩家
她接下来移动球时,球的 Transform 变化
VRC Object Sync 检测变化(她是 Owner,发得出去)
广播给其他客户端
VRC Object Sync 的内部同步把球摆到对应位置 / 旋转

每一步都是一条已经存在的机制,VRChat 把它们打包成「抓起 Pickup 自动同步」这件让作者觉得理所当然的事。

PickupBall 也加一段调试日志,亲眼看一次:

给第 15 章 PickupGlow.cs 的 OnPickup 加日志
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 的内部同步不是同一条机制。

回到那盏灯。要让它在多人下能正常同步,得在按下的瞬间把所有权先抢过来:

LampToggle.cs(27 章版)
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}"
);
}
}

新加那一段就两件事:

  1. 先用 Networking.IsOwner(gameObject) 检查本机是不是 Owner。
  2. 不是就调 Networking.SetOwner(Networking.LocalPlayer, gameObject),把所有权抢过来。

这一章字段还没加 [UdonSynced],所以两台客户端的灯还是各自翻各自的。但 Owner 的归属会变了。

Build & Publish 重测:Alpha 进来,按一下,日志:

[Lamp] Alpha pressed. enabled = False, owner = Alpha

Beta 接着按一下,日志(注意 owner 变了):

[Lamp] Beta pressed. enabled = False, owner = Beta

Alpha 在自己客户端再按一下:

[Lamp] Alpha pressed. enabled = True, owner = Alpha

SetOwner 的效果立刻可见:每次有人按,Owner 都会切到她。把 enabled 标成 [UdonSynced] 之后(第 28 章会做这件事),这个 Owner 切换的瞬间就是「广播这条新状态」的时机。

某些场景不希望随便就被人接管所有权。考虑一个游戏房间: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 转移流程才会真的开始。

所有权真的转完之后,VRChat 会在实例中的所有客户端上触发 OnOwnershipTransferred

public override void OnOwnershipTransferred(VRCPlayerApi player)
{
// player:新 Owner 是谁
Debug.Log($"[Lamp] ownership transferred to {player.displayName}");
}

参数是新 Owner。回调的常见用途:

  • 更新 UI:在某个面板上显示「现在由 Alpha 控制」。
  • 重置某些状态:旧 Owner 留下的临时数据清掉。
  • 触发音效或视觉反馈:让玩家知道控制权变了。

Networking.GetOwner(gameObject) 只对网络化的 GameObject有意义。判断标准:物体上至少挂了一个 UdonBehaviourVRC Object Sync 等网络组件。

挂了几个普通 Unity 组件(MeshRendererLightCollider)的物体,VRChat 不把它当网络对象,调 GetOwner 会报警告或返回 null。

实战里这条不太容易踩到。习惯做法是:要管所有权的脚本字段,挂在写 UdonSharp 的那个 GameObject 上。gameObject 这个引用在 UdonSharpBehaviour 里指的就是「自己挂在的那个 GameObject」,自动满足条件。

把当前的 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 的工具齐了:

  • Networking.IsOwner(go) / GetOwner(go):判断当前 Owner。
  • Networking.SetOwner(player, go):抢占所有权,先 IsOwner 判一下、再调,少做一次完整的转移流程。
  • OnOwnershipRequest(req, target) 返回 bool:在请求方和当前 Owner 两边评估,任一端拒绝都会中止。
  • OnOwnershipTransferred(player):转移完成后所有客户端都触发,常用来更新 UI 或重置临时状态。

「按按钮的人成为 Owner」这一步铺好之后,灯本身仍然两边各亮各的。所有权决定的是「谁改的字段才会被发出去」,但 VRChat 还需要知道哪个字段要被发出去。这件事要等同步变量上场才合拢。