跳转到内容

第 21 章 · 参数化 Network Event 与决策表

约 10 分钟 难度:3 动手章

这一章解决:把「请求传一两个简单参数」从「写到同步字段再触发」简化到「直接在事件签名里传」。配合本章决策表,能选清楚每一段同步该走哪条通道。

先看一下

闭环 2 的请求字段 requestType + requestPayload 当时是为了承载「参数」:玩家想加几分、想切到几号队伍、想选什么角色。这一份方案在第 12 章被立起来时,SendCustomNetworkEvent 还不支持参数,只能传方法名。SDK 3.8.1 起加了 [NetworkCallable],方法签名里写参数,调用时直接传值。

参数化事件不是「替代 requestType + requestPayload」的新通道。它有自己的限制和适合的场景:参数个数上限、单次载荷上限、迟入恢复仍然不行。这一章把它和现有通道并排。

这一章会拿到什么

  • 参数化 SendCustomNetworkEvent 的完整签名与硬性限制
  • 同一段「请求加 1 分」三种写法对照(同步字段触发 / 参数化事件 / Object Sync)
  • 本部锚点决策表:七条通道按四个轴横向对比,第 22–24 章 + 闭环 3 都引这张表
  • 一段「写新功能时通道选错的常见症状」自检

依赖前面

  • 第 1 章 SendCustomNetworkEvent 的「事件不重放」结论
  • 第 2 章 NetworkEventTarget.All / Owner 区别
  • 第 4 章 [UdonSynced] 同步变量同步模式与序列化回调
  • 第 12 章请求式架构 requestType + requestPayload 字段
  • 第 19 章状态 vs 命令五维度

参数化版本和无参版本共用同一个 SendCustomNetworkEvent。差别在被调方法上:被调方法标 [NetworkCallable] 后,调用方传参时按位置传。

using VRC.SDK3.UdonNetworkCalling; // [NetworkCallable] 的命名空间,需 SDK ≥ 3.8.1
using VRC.Udon.Common.Interfaces; // NetworkEventTarget
// 调用方(任何客户端,把请求送给某玩家的 Owner 端)
target.SendCustomNetworkEvent(
NetworkEventTarget.Owner,
nameof(NetGiveGift),
receiverPlayerId, // 第一个参数
giftType, // 第二个参数
quantity); // 第三个参数
// 被调方(在 target 这个 UdonBehaviour 上)
[NetworkCallable]
public void NetGiveGift(int receiverPlayerId, byte giftType, int quantity)
{
if (!Networking.IsOwner(gameObject)) return; // 在 Owner 上跑
// 业务逻辑
}

[NetworkCallable] 的方法在调用时按签名顺序填参数。和无参版本一样,事件不重放给迟入玩家。NetworkEventTargetAll / Others / Owner / Self 四种;本章主要用 Owner 发送请求,用 All 播瞬时反馈。


本次校验以 VRChat Creator Docs 的英文 Network Events 页面为准:

项目限制
参数个数最多 8 个
单次载荷总大小上限 16 KB;超过 1024 bytes 会内部分片,接收端重组成一次方法调用,但分片会计入速率限制
参数类型与同步变量同一类可同步类型:bool / char / byte / sbyte / short / ushort / int / uint / long / ulong / float / double / Vector2 / Vector3 / Vector4 / Quaternion / string / VRCUrl / Color / Color32,及上述类型的数组
不支持的参数自定义 class / structUdonBehaviour 引用、嵌套数组、DataDictionary / DataListVRCPlayerApi(传 int playerId,远端用 VRCPlayerApi.GetPlayerById 还原)
方法形态必须 public、无返回值,不能是 static / virtual / override
重载 / 默认参数不支持成员重载,不支持 params,不支持默认参数
速率默认 5 events/s;[NetworkCallable(maxEventsPerSecond: X)] 可设 1–100;全局 outgoing event 约 100 events/s;总出站数据硬上限约 18 KB/s,实际常见上限约 8–10 KB/s

写参数化事件前先按这张表过一遍。自定义 class 之类的限制经常让初学者写出「这不就编译失败了」的代码,UdonSharp 编译器会给提示,但消息有时候不直观(说「类型不支持序列化」,原因可能是嵌套字段里有 UdonBehaviour)。


同一段「请求加 1 分」三种写法

Section titled “同一段「请求加 1 分」三种写法”

下面把闭环 2 的「按 Score 按钮加 1 分」用三种通道分别写一遍骨架。差别集中在「请求怎么送过去」。

写法 1:同步字段触发(闭环 2 现状)

Section titled “写法 1:同步字段触发(闭环 2 现状)”
// 客户端 A(玩家本人):改自己 PlayerLobbyState 的请求字段
public void RequestScore()
{
if (!Networking.IsOwner(gameObject)) return;
requestId++;
requestType = REQ_SCORE;
requestPayload = 0;
RequestSerialization();
}
// GameState(轮询所有玩家的 PlayerLobbyState)
private void TickRequests()
{
foreach (var p in players)
{
var pls = GetPLS(p);
if (pls.RequestId > lastProcessedRequestId[p.playerId])
DispatchByType(pls.RequestType, pls.RequestPayload, p);
}
}

特点:迟入恢复天然(迟入玩家进来读 requestId 立刻知道当前进度),幂等天然(按 requestId 比较即可),但轮询有成本(第二部第 12 章已经讨论过:Update 里的轮询不要每帧跑,按需触发)。

// 客户端 A(玩家本人):直接给 GameState 发参数化事件
public void RequestScore()
{
gameState.SendCustomNetworkEvent(
NetworkEventTarget.Owner,
nameof(GameState.NetRequestScore),
Networking.LocalPlayer.playerId,
1); // 加 1 分
}
// GameState
[NetworkCallable]
public void NetRequestScore(int playerId, int delta)
{
if (!Networking.IsOwner(gameObject)) return;
if (TooHot(playerId)) return; // GameState 端冷却
totalScore += delta;
RequestSerialization();
}

特点:代码短、没有轮询,但幂等不天然(事件本身没有 requestIdTooHot 只是冷却,不是幂等),迟入玩家收不到 Owner 转移瞬间在路上的事件,16 KB 单次上限对单参数通常没影响但对密集参数化要算一下。

写法 3:状态字段直接同步(PlayerObject 模型)

Section titled “写法 3:状态字段直接同步(PlayerObject 模型)”
// PlayerLobbyState 上加一个字段
[UdonSynced] private int personalScore;
// 玩家本人按 Score 按钮直接写自己字段
public void RequestScore()
{
if (!Networking.IsOwner(gameObject)) return;
personalScore += 1;
RequestSerialization();
// GameState 不参与;UI 端按 playerId 加总
}

这一版完全跳过 GameState,把分数写成「每位玩家自己的字段」。第 17 章已经讨论过:分数归个人时这是合适写法,归队伍 / 全局时不合适。


维度写法 1 同步字段写法 2 参数化事件写法 3 PlayerObject
迟入恢复当前状态字段自然支持,请求字段只作触发不支持,事件不重放自然支持,PlayerObject 字段同步
幂等requestId 比较自己加 requestId 字段不需要,玩家本人独占字段
GameState 介入是,需要轮询是,单次回调
代码量多(请求字段 + 轮询)最少
调试requestId 历史可见事件历史不可见字段值可见
适合跨 Owner 写权 + 迟入要看到一次性反馈、不影响状态恢复玩家私有状态

每一种都不是「最好」,按这一段同步的需求选。


第 19 章列了五维度,第 20 章给了三个工具。现在把维度铺到具体通道,给出本部锚点决策表。后续章节(第 22–24 章 + 闭环 3)回链这张表。

通道字节范围迟入恢复改动频率适合场景不适合场景
本地变量(不同步)不需要任意纯本地 UI、客户端预测、临时状态任何需要其他客户端看到的状态
SendCustomNetworkEvent(无参)极小不支持中(有总速率预算)一次性反馈:飘字、音效、按钮特效任何需要状态恢复的字段
SendCustomNetworkEvent(参数化)单次 ≤ 16 KB,>1 KB 分片不支持「请求一个动作 + 一两个简单参数」状态恢复、自定义结构
[UdonSynced] 基础类型几到几十 byte自然支持受同步模式预算当前状态:分数、阶段、计时高频小变化(撞速率)
[UdonSynced] 数组(int / byte / float)数组长度 × 元素大小自然支持中等全员状态:每位玩家的分 / 队伍归属不规则结构
VRCJson 字符串字段字符串长度,建议 ≤ 几 KB自然支持复杂结构:棋盘、配置、复盘数据高频改动、追求字节经济
VRCPlayerObject按字段累加自然支持中等per-player 状态、玩家个人输入 / UI 桥全局共享状态、长期存档本身
VRC Object Sync按 Transform / Rigidbody自然支持高(每帧)物理对象、抓取、被丢出去的物体Udon 状态

怎么看这张表:写新一段同步前,先问下面四个问题,按表查。

  1. 迟入玩家要不要看到这段状态?要 → 排除两条事件通道。
  2. 每秒改几次?高频 → 倾向 Object Sync 或 PlayerObject;低频 → 几乎所有通道都行。
  3. 单次字节多大?> 1 KB → 倾向 VRCJson 字符串或拆字段;到几十 KB 时先拆,接近官方 manual sync 单次上限(约 280 KB)时必须拆。
  4. 状态归谁?全局 → GameState 的同步字段;玩家个人 → PlayerObject;物理 → Object Sync

四个问题不是按顺序回答完才能选,而是哪个问题先把通道淘汰掉就先看哪个。


下面这些症状提示你写新功能时通道选错了,回头查决策表。

症状 1:「迟入玩家看不到飘字 / 战报」。事件通道被孤立用,没补状态。修法:要么把事件触发的瞬时反馈接受迟入收不到(飘字 / 音效 / 短特效都属此类),要么把事件转成状态(命令日志,第 22 章)。

症状 2:「按按钮 5 秒分数加得慢」。事件通道撞到了 per-event 或全局速率限制,消息开始排队。修法:把高频改动从事件挪到同步字段(让 manual sync 自己合并),或加冷却。

症状 3:「同步字段总是慢一拍」。同步字段在 continuous 模式下每帧合并,每次只发一部分。如果改动太频繁,远端总是落后几帧。修法:要么切 manual sync 控制发送时机,要么把高频字段挪到 VRC Object Sync(如果是 Transform),要么降低改动频率。

症状 4:「VRCJson 字段超过几 KB 后同步变慢,甚至排队」。通常先撞到 per-object rate limit 和总带宽压力,不是等到约 280 KB 单次上限才出问题。修法:把 JSON 拆成增量(只同步「变化的子树」),或者降级到字节打包(第 23 章)。

症状 5:「Owner 转移期间状态不对」。多半和参数化事件 + 没有 requestId 有关。修法:状态恢复必须靠同步字段,不靠事件。事件转换为命令字段或加 requestId 幂等。


挑一条试。

  • 把闭环 2 的 RequestScore 改成参数化事件版本(写法 2)。在不加 requestId 字段的前提下,能复现「按一下加了两分」吗?为什么?
  • 设计一个「玩家点击地板让全场闪一下颜色」的功能。事件 / 状态 / 字段 / 参数化事件四种通道选哪个?给出三句话的理由。
  • 一个「房间公告板」字段,玩家能输入一行字让所有玩家看到。如果字符串可能到 5 KB(粘贴一段 Markdown),按决策表查应该走哪条通道?答错的话会撞什么限制?

照决策表选完通道后,按这张表跑一遍:

场景预期实际
1. 多客户端 Build & Test写法 2 / 写法 3 都能跑出加分
2. 迟入玩家加入写法 2:迟入玩家看不到事件历史;写法 1 / 3:迟入玩家立刻看到当前分数
3. Owner 转移瞬间写法 2:路上的事件可能丢;写法 1 / 3:字段照常同步
4. 长按 Score 按钮写法 2:被事件速率限制排队;写法 1 + GameState 冷却:被冷却拍平
5. 故意发 8 个参数UdonSharp 编译通过;运行时正常触发
6. 故意发自定义 structUdonSharp 编译失败,给「类型不支持序列化」消息

  • 能默写 [NetworkCallable] 的四条硬限制(参数个数 / 载荷 / 类型 / 可见性)
  • 能解释参数化事件仍然不重放给迟入玩家
  • 能在「请求一个动作」场景下从三种写法中选一个,并说出依据
  • 能拿决策表对一段新同步做四问检查
  • 能识别五个「通道选错」的症状,并对每个给出修法