消息
谁都能发的带类型事件、谁来处理、物体自己的通道,以及一条消息在线路上的开销。
声明一条消息
消息是一个带字段的类,外加一对说明它们如何传输的方法:
public class OpenDoor : NetworkEvent
{
public int doorId;
public override void Serialize(ByteWriter writer)
{
writer.WriteInt32(doorId);
}
public override void Deserialize(ByteReader reader)
{
doorId = reader.ReadInt32();
}
}
打包要自己写,是因为让它猜代价更大:Udon 没有反射,任何替你序列化的东西最后仍然要拿到每个字段的描述。没有字段的信号两个方法都不用重写。
把愿望和事实分开。 OpenDoorRequest 和 DoorOpened 两个类型,几乎总是好过同一个类型发两次。处理器看类型就知道拿到的是哪一个,也就不会把别人的请求当成已经定下的事来处理。
消息只有两个去处
一条消息要么发给整个房间,要么发到某个物体自己的通道。选择只有这两个,而你怎么写就说明了是哪一个:
Room.Send(new OpenDoor { doorId = 3 }); // 房间:每个客户端
this.Send(new Touched()); // 这个物体自己的通道
channel.Send(new Touched()); // 引用到的另一个物体的通道
this.Send 不是 Room.Send。 它发到 behaviour 所在的那个物体自己的通道上,所以不必再带一个 id 说明指的是哪一个物体。Room.Send 才是发给整个房间的那个,而且它是 static 的,在没有 this 的普通类或抽象类里也能调用。
订阅完全对应:
Room.Subscribe<OpenDoor>(OnOpenDoor); // 从房间接收该类型
this.Subscribe<Touched>(OnTouched); // 在这个物体的通道上接收
channel.Subscribe<Touched>(OnTouched); // 在另一个物体的通道上接收
| 你写的 | 去哪里 | 谁会收到 |
|---|---|---|
Room.Send(evt) |
房间 | 每个客户端上订阅了该类型的一切 |
this.Send(evt) |
这个物体 | 在这个物体上订阅了该类型的一切 |
channel.Send(evt) |
那一个物体 | 在该通道上订阅了该类型的一切 |
谁都可能发的时候用房间。物体自己的通道只有一个所有者,所以 this.Send 需要拥有该物体。
谁来处理
寻址决定的是谁来处理,从来不是谁可以发:
Room.Send(message, NetworkTarget.All); // 所有人,默认
Room.Send(message, NetworkTarget.Master); // 只有在仲裁的那个客户端
Room.Send(message, NetworkTarget.Others); // 除发送者外的所有人
Room.Send(message, player); // 某一个玩家
任何客户端都能把任何消息按自己想要的寻址发上线路,字节无论如何都会到每个客户端。NetworkTarget.Master 是过滤,不是许可;真正需要权限的世界要检查是谁发的:
private void OnOpenDoorRequest(VRCPlayerApi sender, OpenDoorRequest message)
{
if (!Room.IsMaster || !Allowed(from))
return;
Room.Send(new DoorOpened { doorId = message.doorId });
}
这就是把 master 当服务器的形状:客户端请求,仲裁的那个客户端决定,而它发给所有人的那条消息才真正改变了什么。
你能得到什么
按顺序、各一次、包括发送者。 发送的客户端会立刻处理自己的消息,因为客户端收不到自己的写入,否则唯一知道发生了什么的那个反而听不到。
同一批到两次也只处理一次。 每条消息带序号,读的一方记着自己处理到哪里,所以 VRChat 拒收后重发也不会改变任何东西。
晚来的人不会收到自己错过的部分。 每一批都带着它是何时生成的,后加入的客户端会把自己到达之前的那批丢掉,而不是重放一场自己没看见的争斗。这和你对同步值的期待正好相反,而正是你对事件的期待。如果晚来的人应该知道当前状态,请用同步值。
被拒绝的发送不会丢。 VRChat 限制一个 behaviour 一次能发多少,超过的一批会被拒绝而不是截断。被拒绝的一批会留着重发。如果 Room.Bus.Pending 一直大于零,说明发得比线路能承受的快。
物体自己的通道
关于某个物体的消息可以走那个物体而不是房间,这样就不用带一个 id 说明指的是哪一个。不用加组件,也不用引用,把同样的两个动词直接写在 behaviour 自己身上:
public override void Start()
{
this.Subscribe<Touched>(OnTouched);
}
public override void Interact()
{
this.Send(new Touched());
}
处理方法也可以一并接收玩家,从而知道是谁发的:
this.Subscribe<Touched>((sender, message) => Log.Info(sender.displayName + " 碰了它"));
发送需要拥有该物体。 只有所有者的写入会被复制,所以非所有者当场就被告知,而不是事后才发现消息根本没发出去。若想让任意客户端都能发,先用 Ownership.Claim(gameObject) 取得所有权;pickup 或 interact 通常已经取到了。
要从别处读另一个物体的通道,那个物体上需要有 NetworkBus,同样的动词对引用照样有效:
public NetworkBus channel;
channel.Subscribe<Touched>(OnTouched);
channel.Send(new Touched());
无论哪种,物体通道都只有一个所有者,两个玩家往同一个通道发就会争抢。物体的所有者是发送方时用物体通道,谁都可能发时用房间。
开销是多少
每条消息三个字节:一个说谁该处理,一个是长度,一个说它是什么类型。发给特定玩家的消息还会带上那个玩家的 id。
每一批还会一次性付出四个字节,用来记它是何时生成的。是每批一次而不是每条消息一次,所以一批里装的消息越多就摊得越薄。
真正会卡住你的额度,是 VRChat 对一个 behaviour 同步状态的上限,它作用于整批而不是单条消息。this.Pending() 就是让世界看见它逼近的办法:几乎总是 0,若一直大于 0,说明一批的体积超过了一次同步能带走的量。
不用这个包的自定义事件
VRChat 自己的事件调用一直都在,无论你用不用上面这些,因为编译器会直接放行:
this.SendCustomNetworkEvent(NetworkEventTarget.All, nameof(Ring));
public void Ring()
{
bell.Play();
}
它按名字在每个客户端上调用一个 public 方法,用 NetworkEventTarget.Owner 则只在所有者上调用。它不携带数据,这正是带类型消息存在的理由。SendCustomEventDelayedSeconds 和 SendCustomEventDelayedFrames 只是把本地调用推迟,不发送任何东西。
重写 OnOwnershipRequest 返回 false 可以拒绝转移,OnOwnershipTransferred 会在所有者改变时于每个客户端上运行。ClientSim 不会触发所有权请求,所以这一对要在上传的世界里验证。
什么都没到的时候
它会说出来,每个 behaviour 说一次,而不是留你去猜:
- 场景里没有 bus 就发送了,于是消息哪儿也没去。
- 订阅之后,十秒内在任何地方都找不到可读的 bus。
两者在实践中是同一件事:承载房间消息的那个物体缺失或被禁用了。世界第一次发送时它会被自动添加,所以通常的原因是它跟着别的东西一起被关掉了。