28. 告诉别人发生了什么
第 27 章那盏灯,所有权能正常切换了。但两个客户端的灯还是各亮各的:A 按一下,A 这边灯灭,B 那边灯亮如旧。
差的就是「广播」这一步。脚本本地翻完字段,得告诉 VRChat:「这个变化要发出去」。这一章把这件事接通。
完成之后,两个客户端的灯第一次能同步切换:A 按一下,B 那边的灯也跟着灭。这个瞬间值得花时间把流程看穿,往后所有同步逻辑都是这套机制的变体。
最后顺手介绍另一种同步工具:SendCustomNetworkEvent。它跟同步变量是两件不同的事,混着用容易出 bug。
同步变量四件套
Section titled “同步变量四件套”让一个字段同步到所有客户端,需要四件东西。每一件都不能漏:
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]:写在脚本类上方,告诉 UdonSharp 这个脚本走「手动同步」模式。[UdonSynced]:写在要同步的字段上方,标记它是「需要广播给所有人」的字段。RequestSerialization():在 Owner 改完字段后调用一次,告诉 VRChat「现在打包发出去」。serialization 中文叫「序列化」,意思是把字段值打包成网络数据。public override void OnDeserialization():远端客户端收到打包过来的字段后,VRChat 会调一次这个方法,让脚本根据新字段更新本地表现。deserialization 是「反序列化」,把网络数据解包还原回字段值。
四件凑齐了字段才能跨客户端同步。少一件就有具体的失败模式:
| 漏的件 | 失败现象 |
|---|---|
[UdonBehaviourSyncMode] | 同步模式没有被脚本固定,Inspector 里的设置可能和教程预期不一致 |
[UdonSynced] | 字段不进同步打包,跟没标一样 |
RequestSerialization() | Manual 模式下不调就不发,远端永远收不到 |
OnDeserialization() | 远端收到了字段但不知道要根据它做什么,灯仍然不变 |
下面把第 27 章那盏灯按这四件挨个补上。
第一步:声明同步模式和同步字段
Section titled “第一步:声明同步模式和同步字段”回到 LampToggle.cs。第 27 章的版本里 targetLight.enabled 是「直接读写 Light 组件」的方式。要让它同步,得把状态拆出来放进一个本脚本字段:让脚本拥有「灯应该是亮还是灭」这个真相,再让那盏 Light 跟着这个字段走。
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 显示成 Manual,Target Light 字段还是要拖那盏 GlowBall 进去(跟第 13 章一样)。
第二步:试一次同步
Section titled “第二步:试一次同步”Build & Publish 重新发布。两个客户端进同一个实例。
Alpha 按一下 LampSwitch:
- Alpha 本地:
Interact跑,抢所有权(已经是 Owner 不抢),翻isOn为false,RequestSerialization,ApplyState把灯灭。 - Beta 本地:VRChat 收到 Alpha 客户端打包来的同步字段(
isOn = false),写进 Beta 客户端这台脚本的isOn,触发OnDeserialization,ApplyState把 Beta 这边的灯也灭。
两边的灯同时灭。
Beta 按一下:
- Beta 本地:
Interact跑,发现自己不是 Owner,调SetOwner,所有权转给 Beta(Alpha 那边触发OnOwnershipTransferred),翻isOn为true,RequestSerialization,本地灯亮。 - Alpha 本地:收到 Beta 打包过来的
isOn = true,触发OnDeserialization,灯亮。
两边的灯第一次同时亮。
来回按几次确认状态稳定一致。如果还有偏差,下面这个表是按顺序的检查清单。
这条同步链路具体在做什么
Section titled “这条同步链路具体在做什么”把刚才那次按按钮拆成时间线,大概是这样:
T=0 Alpha 按 LampSwitchT=0 Alpha 本地:Interact 跑,SetOwner(已是 Owner,跳过)T=0 Alpha 本地:isOn = !isOnT=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 都正常。
把门也接上同步
Section titled “把门也接上同步”第 17 章那扇门用同样的四件套接通。DoorController.cs 改成:
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。
同步字段能装什么
Section titled “同步字段能装什么”[UdonSynced] 不是任何字段都能标。能装的类型大致这几类:
| 类别 | 类型 | 备注 |
|---|---|---|
| 数值 | sbyte / byte / short / ushort / int / uint / long / ulong / float / double | 都行 |
| 布尔 | bool | 一个字节 |
| 字符 | char / string | string 可同步但有长度上限,避免装大段文本 |
| Unity 数学 | Vector2 / Vector3 / Vector4 / Quaternion / Color / Color32 | 可补间,能加 UdonSyncMode.Linear 平滑插值(让数值在两次同步之间平滑过渡,不瞬跳) |
| URL | VRCUrl | VRChat 的视频 / 图片 URL 类型 |
| 数组 | 上面这些类型的一维数组 | 多维数组不行 |
不能直接同步的:
GameObject/Transform/Component类型的字段:这些是引用,跨客户端没意义。- 自定义的类对象:UdonSharp 不支持类的网络序列化。
List<T>/Dictionary<K,V>:UdonSharp 多数集合类型本来就不能用,更不用说同步。
要同步「指向场景里某个物体的引用」,做法是把 int 当索引,所有候选物体在 Inspector 里拖进一个数组,同步那个 int 索引。
要同步复杂结构,多数情况下能拆成几个基础类型字段。第 30 章会演示:四人计分板用 int[] scores 一个数组同步所有人的分数。
一次性事件:SendCustomNetworkEvent
Section titled “一次性事件:SendCustomNetworkEvent”同步变量解决「状态」。还有一种需求是「事件」:按一下按钮,所有人的客户端都播一次爆炸音效。事件触发完就完了,没有「状态」要被记住。
用同步变量也能做:标一个 [UdonSynced] int explosionCount,每次按按钮自加一,OnDeserialization 里跟上次比较,不一样就播音效。但麻烦:
- 第三个加入的玩家收到
explosionCount = 5,他客户端会以为「漏了一次」吗?还是已经过去的就过去了? - 两个玩家同时按按钮,count 怎么算?
这种场景 VRChat 给了另一种工具 SendCustomNetworkEvent。在某个客户端调用,让指定的方法在所有客户端(或仅 Owner)上各自跑一次。
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 等目标。入门阶段先把 All 和 Owner 用熟,后面需要精细分发时再查当前 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"),状态和事件分两条线)。
自己改三个版本
Section titled “自己改三个版本”把同步开关 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 改的都是「自己的本机玩家」,不是「所有玩家」。让所有人弹起来正是因为每个客户端都各自弹了自己一下。这是网络事件的一种常见用法:所有人各自跑一段「对自己生效」的代码。
状态用变量,事件用网络事件
Section titled “状态用变量,事件用网络事件”最重要的两条:
- 状态用同步变量:
[UdonBehaviourSyncMode(Manual)]+[UdonSynced]+RequestSerialization+OnDeserialization。Owner 改字段,所有人收到。 - 事件用网络事件:
SendCustomNetworkEvent(All, "Method")。所有客户端各跑一次该方法,不留状态。
这两件齐了,VRChat 多人世界绝大多数同步需求都能解。但还有一个坑:第三个迟到加入的玩家,他能拿到当前的 [UdonSynced] 字段值,但拿不到之前发过的 SendCustomNetworkEvent。这件事第 29 章专门讲。