跳转到内容

28. 告诉别人发生了什么

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

第 27 章那盏灯,所有权能正常切换了。但两个客户端的灯还是各亮各的:A 按一下,A 这边灯灭,B 那边灯亮如旧。

差的就是「广播」这一步。脚本本地翻完字段,得告诉 VRChat:「这个变化要发出去」。这一章把这件事接通。

完成之后,两个客户端的灯第一次能同步切换:A 按一下,B 那边的灯也跟着灭。这个瞬间值得花时间把流程看穿,往后所有同步逻辑都是这套机制的变体。

最后顺手介绍另一种同步工具:SendCustomNetworkEvent。它跟同步变量是两件不同的事,混着用容易出 bug。

让一个字段同步到所有客户端,需要四件东西。每一件都不能漏:

  1. [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]:写在脚本类上方,告诉 UdonSharp 这个脚本走「手动同步」模式。
  2. [UdonSynced]:写在要同步的字段上方,标记它是「需要广播给所有人」的字段。
  3. RequestSerialization():在 Owner 改完字段后调用一次,告诉 VRChat「现在打包发出去」。serialization 中文叫「序列化」,意思是把字段值打包成网络数据。
  4. public override void OnDeserialization():远端客户端收到打包过来的字段后,VRChat 会调一次这个方法,让脚本根据新字段更新本地表现。deserialization 是「反序列化」,把网络数据解包还原回字段值。

四件凑齐了字段才能跨客户端同步。少一件就有具体的失败模式:

漏的件失败现象
[UdonBehaviourSyncMode]同步模式没有被脚本固定,Inspector 里的设置可能和教程预期不一致
[UdonSynced]字段不进同步打包,跟没标一样
RequestSerialization()Manual 模式下不调就不发,远端永远收不到
OnDeserialization()远端收到了字段但不知道要根据它做什么,灯仍然不变

下面把第 27 章那盏灯按这四件挨个补上。

第一步:声明同步模式和同步字段

Section titled “第一步:声明同步模式和同步字段”

回到 LampToggle.cs。第 27 章的版本里 targetLight.enabled 是「直接读写 Light 组件」的方式。要让它同步,得把状态拆出来放进一个本脚本字段:让脚本拥有「灯应该是亮还是灭」这个真相,再让那盏 Light 跟着这个字段走。

LampToggle.cs(28 章版)
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class LampToggle : UdonSharpBehaviour
{
public Light targetLight;
[UdonSynced]
private bool isOn = false;
private void Start()
{
ApplyState();
}
public override void Interact()
{
// 抢所有权
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
// 翻字段
isOn = !isOn;
// 把这个新状态打包广播
RequestSerialization();
// 本地立刻看到效果(不等远端回应)
ApplyState();
}
public override void OnDeserialization()
{
// 远端 Owner 改完发过来了,更新本地表现
ApplyState();
}
private void ApplyState()
{
if (targetLight != null)
{
targetLight.enabled = isOn;
}
}
}

逐项扫一遍:

  • 类的最上面 [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]:声明这个脚本走 Manual 同步模式。Owner 调一次 RequestSerialization 才发一次。
  • [UdonSynced] private bool isOn:脚本拥有「灯应该是开还是关」这件事。这是本脚本的单一真相
  • Start() 里调 ApplyState():保证场景刚加载时 Light 跟字段一致。
  • Interact() 里:抢所有权 → 翻字段 → 调 RequestSerialization → 调 ApplyState。前三步是同步必须,最后一步是「本地立刻看到」的优化(不优化也行,但本地看着会有一小段延迟)。
  • OnDeserialization():远端推过来的新值已经写进 isOn,调一次 ApplyState 把灯跟着翻。
  • ApplyState():把字段值反映到 Light.enabled。这种「单一函数集中处理状态到表现」的写法第 17 章那个红黄绿信号灯里见过。

把脚本存盘,回 Unity。Inspector 里 LampToggle 的 Sync Mode 显示成 ManualTarget Light 字段还是要拖那盏 GlowBall 进去(跟第 13 章一样)。

Build & Publish 重新发布。两个客户端进同一个实例。

Alpha 按一下 LampSwitch

  • Alpha 本地:Interact 跑,抢所有权(已经是 Owner 不抢),翻 isOnfalseRequestSerializationApplyState 把灯灭。
  • Beta 本地:VRChat 收到 Alpha 客户端打包来的同步字段(isOn = false),写进 Beta 客户端这台脚本的 isOn,触发 OnDeserializationApplyState 把 Beta 这边的灯也灭。

两边的灯同时灭。

Beta 按一下:

  • Beta 本地:Interact 跑,发现自己不是 Owner,调 SetOwner,所有权转给 Beta(Alpha 那边触发 OnOwnershipTransferred),翻 isOntrueRequestSerialization,本地灯亮。
  • Alpha 本地:收到 Beta 打包过来的 isOn = true,触发 OnDeserialization,灯亮。

两边的灯第一次同时亮。

来回按几次确认状态稳定一致。如果还有偏差,下面这个表是按顺序的检查清单。

把刚才那次按按钮拆成时间线,大概是这样:

T=0 Alpha 按 LampSwitch
T=0 Alpha 本地:Interact 跑,SetOwner(已是 Owner,跳过)
T=0 Alpha 本地:isOn = !isOn
T=0 Alpha 本地:RequestSerialization()
T=0 Alpha 本地:ApplyState(灯立刻熄灭)
VRChat 把脚本里所有 [UdonSynced] 字段打包成网络数据包
T~50ms 数据包通过 VRChat 服务器发给所有其他客户端
T~150ms Beta 客户端收到数据包
T~150ms Beta 客户端:写入 isOn 字段
T~150ms Beta 客户端:触发 OnDeserialization()
T~150ms Beta 客户端:ApplyState(灯熄灭)

Alpha 看到瞬间反应,Beta 看到延迟约 100ms 的反应。延迟的大小取决于网络情况,在 PC 局域网里可能更短,跨大陆 200ms 都正常。

第 17 章那扇门用同样的四件套接通。DoorController.cs 改成:

DoorController.cs(28 章同步版)
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class DoorController : UdonSharpBehaviour
{
public Animator doorAnimator;
public float animationDuration = 0.5f;
[UdonSynced]
private bool isOpen = false;
private bool isAnimating = false;
public override void Interact()
{
if (isAnimating) return;
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
isOpen = !isOpen;
RequestSerialization();
StartAnimation();
}
public override void OnDeserialization()
{
// 远端 Owner 翻了 isOpen,本机也跟着播一次动画
StartAnimation();
}
private void StartAnimation()
{
isAnimating = true;
if (doorAnimator != null)
{
doorAnimator.SetBool("IsOpen", isOpen);
}
SendCustomEventDelayedSeconds(nameof(OnAnimationFinished), animationDuration);
}
public void OnAnimationFinished()
{
isAnimating = false;
}
}

跟灯几乎一样的骨架:四件套加上原本的防双击锁。注意几件事:

  • isAnimating 没标 [UdonSynced]。每个客户端各管各的动画播放,不需要同步。
  • OnDeserialization 里调 StartAnimation:远端的玩家也能看到门「滑动着开」,不只是状态突变。
  • 门按一下,A、B 两边都看到门同时滑开半秒。

LampToggle 那条「九成代码不用动」的承诺兑现了。第 17 章脚本到这里只补了三个东西:类标 [UdonBehaviourSyncMode]、字段标 [UdonSynced]、加 RequestSerialization + OnDeserialization

[UdonSynced] 不是任何字段都能标。能装的类型大致这几类:

类别类型备注
数值sbyte / byte / short / ushort / int / uint / long / ulong / float / double都行
布尔bool一个字节
字符char / stringstring 可同步但有长度上限,避免装大段文本
Unity 数学Vector2 / Vector3 / Vector4 / Quaternion / Color / Color32可补间,能加 UdonSyncMode.Linear 平滑插值(让数值在两次同步之间平滑过渡,不瞬跳)
URLVRCUrlVRChat 的视频 / 图片 URL 类型
数组上面这些类型的一维数组多维数组不行

不能直接同步的:

  • GameObject / Transform / Component 类型的字段:这些是引用,跨客户端没意义。
  • 自定义的类对象:UdonSharp 不支持类的网络序列化。
  • List<T> / Dictionary<K,V>:UdonSharp 多数集合类型本来就不能用,更不用说同步。

要同步「指向场景里某个物体的引用」,做法是把 int 当索引,所有候选物体在 Inspector 里拖进一个数组,同步那个 int 索引。

要同步复杂结构,多数情况下能拆成几个基础类型字段。第 30 章会演示:四人计分板用 int[] scores 一个数组同步所有人的分数。

同步变量解决「状态」。还有一种需求是「事件」:按一下按钮,所有人的客户端都播一次爆炸音效。事件触发完就完了,没有「状态」要被记住。

用同步变量也能做:标一个 [UdonSynced] int explosionCount,每次按按钮自加一,OnDeserialization 里跟上次比较,不一样就播音效。但麻烦:

  • 第三个加入的玩家收到 explosionCount = 5,他客户端会以为「漏了一次」吗?还是已经过去的就过去了?
  • 两个玩家同时按按钮,count 怎么算?

这种场景 VRChat 给了另一种工具 SendCustomNetworkEvent。在某个客户端调用,让指定的方法在所有客户端(或仅 Owner)上各自跑一次。

ExplosionButton.cs
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon.Common.Interfaces;
[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
public class ExplosionButton : UdonSharpBehaviour
{
public AudioSource explosionSound;
public ParticleSystem explosionEffect;
public override void Interact()
{
// 让所有客户端(包括自己)都跑一次 PlayExplosion
SendCustomNetworkEvent(NetworkEventTarget.All, nameof(PlayExplosion));
}
public void PlayExplosion()
{
if (explosionSound != null)
{
explosionSound.Play();
}
if (explosionEffect != null)
{
explosionEffect.Play();
}
}
}

值得停一下的几件事:

  • 类上面是 [UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)],因为这个脚本没有 [UdonSynced] 字段,明确声明「不需要同步变量」。
  • using VRC.Udon.Common.Interfaces;NetworkEventTarget 这个枚举的命名空间。第一次写时常会被编辑器红线提醒,加上就好。
  • SendCustomNetworkEvent(NetworkEventTarget.All, nameof(PlayExplosion)):让所有客户端都调 PlayExplosion() 方法。
  • PlayExplosion 必须声明为 public。VRChat 网络事件只能调 public 方法,跟第 17 章 SendCustomEventDelayedSeconds 一样的规则。
  • 这个脚本不需要 Networking.SetOwner:网络事件不需要 Owner,任何客户端都可以发起。

这一章先用两个 NetworkEventTarget

  • NetworkEventTarget.All:所有当前在实例里的客户端,包括发起者。
  • NetworkEventTarget.Owner:仅这个 UdonBehaviour 的 Owner。

较新的 SDK 还提供 Self / Others 等目标。入门阶段先把 AllOwner 用熟,后面需要精细分发时再查当前 SDK 文档。

哪个用同步变量、哪个用网络事件

Section titled “哪个用同步变量、哪个用网络事件”

两种工具放一起会乱。判断标准就一条:这个东西是「状态」还是「事件」?

它是什么用什么例子
当前的状态,迟到的人也要看到同步变量 [UdonSynced]灯的开关、门的开关、计分板分数、当前回合
一次性发生的事,过了就过了网络事件 SendCustomNetworkEvent爆炸音效、UI 闪烁、临时 VFX

混着用最容易出的两种错:

  • 状态当事件:用 SendCustomNetworkEvent(All, "OpenDoor") 来开门。第 5 个进来的玩家完全不知道门已经开过了,他那边门是关的。
  • 事件当状态:用同步变量记「爆炸了几次」,远端在 OnDeserialization 里依靠「跟上次相比变化了」判断要播放音效。第 5 个进来的玩家拿到 count = 5,他要不要播 5 次音效?这件事得作者自己决定,复杂度上去了。

简单原则:状态用同步变量,事件用网络事件。需要既同步状态又触发瞬时反馈,两个工具一起用。门的同步版就是这样:isOpen 是状态(同步变量),开门时播一次 WoodCreak.wav 是事件(同步变量在 OnDeserialization 里调 audio.Play() 也能做到,但更纯净的写法是从 Interact 里发一次 SendCustomNetworkEvent(All, "PlayCreak"),状态和事件分两条线)。

把同步开关 LampToggle.cs 当骨架:

  • 把灯的颜色也加进去:[UdonSynced] private Color currentColor;[UdonSynced] private float currentIntensity;,按按钮时随机生成颜色和亮度,所有人客户端都看到一样的灯光变化。
  • 加一个「广播范围」开关:在 LampToggle 里加 [UdonBehaviourSyncMode] 切到 Continuous,看看带宽监控里同步频率是不是变密了。改完记得切回 Manual,按按钮的场景 Continuous 是浪费。
  • ExplosionButton 改成「按下后让所有玩家被弹起 3 米」:用 SendCustomNetworkEvent(All, "BounceLocal")BounceLocal 里调 Networking.LocalPlayer.SetVelocity(...)。每个客户端各自把自己的本机玩家弹一下。

第三个有一个值得注意的点:每个客户端调 Networking.LocalPlayer.SetVelocity 改的都是「自己的本机玩家」,不是「所有玩家」。让所有人弹起来正是因为每个客户端都各自弹了自己一下。这是网络事件的一种常见用法:所有人各自跑一段「对自己生效」的代码。

最重要的两条:

  • 状态用同步变量[UdonBehaviourSyncMode(Manual)] + [UdonSynced] + RequestSerialization + OnDeserialization。Owner 改字段,所有人收到。
  • 事件用网络事件SendCustomNetworkEvent(All, "Method")。所有客户端各跑一次该方法,不留状态。

这两件齐了,VRChat 多人世界绝大多数同步需求都能解。但还有一个坑:第三个迟到加入的玩家,他能拿到当前的 [UdonSynced] 字段值,但拿不到之前发过的 SendCustomNetworkEvent。这件事第 29 章专门讲。