跳转到内容

第 5 章 · 迟入玩家怎样恢复世界

约 8 分钟 难度:1 动手章

VRChat 世界是「随时可以走进来」的房间。游戏开始 30 秒后才进来的玩家,看到的世界必须立刻是合理的:分数对、波次对、门是开着还是关着也对。Vol.1 的同步灯不需要面对这件事,因为只有一个状态。Vol.2 之后的每一章都要面对。

这一章把核心原则讲清楚:事件不会重放,状态会复制。然后给一份分类清单,把游戏世界里的所有可同步内容按「迟入要不要看到」分成四档。


VRChat 网络层的行为:

  1. 新玩家加入实例后,VRChat 把当前实例里所有网络对象上 [UdonSynced] 字段的最新值送给他。
  2. 在新玩家本地,每个收到值的网络对象上触发一次 OnDeserialization(如果有变化)。
  3. 过去发生过的 Custom Network Event 不会重新发给他。无论多久之前发生的,不重放。
  4. VRCObjectSync 上的 Transform 位置和旋转会同步到当前值。
  5. VRCObjectPool 的 active/inactive 状态自动同步。

简化成一句话:新玩家拿到「现在」,拿不到「曾经发生过」

这条规则有一个直接推论:任何一个「迟入要看到」的状态都必须有 [UdonSynced] 字段托底(或由 VRCObjectSync / VRCObjectPool 兜底)。只用网络事件实现的视觉表现,迟入玩家天然缺失。还有一个常见反模式是用 bool 同步字段在 false → true → false 之间切换来「触发一次效果」,迟入玩家可能恰好在 true 那一秒进来,本地把这次切换误当成新触发,下文「反模式」一节专门展开。


合作防守世界的所有内容,按「迟入是否需要看到」可以分成四档:

描述例子实现
① 必须可恢复迟入玩家必须立刻看到正确状态当前波次、总分、阶段、玩家 HP[UdonSynced] 字段
② Transform 兜底Transform 的位置和旋转就是状态可拾取武器的位置、被推倒的物体VRCObjectSync
③ 池化兜底激活/未激活就是状态召唤出来的敌人、临时陷阱VRCObjectPool
④ 可丢弃迟入玩家天然看不到也无所谓一次性爆炸特效、击杀提示音网络事件

每个档都有它最自然的实现方式。Vol.2 反复出现的工程纪律:先确定一个状态属于哪一档,再选实现,不要反过来


判定问句:「这个状态如果迟入看不到,游戏就崩了或者体验严重错位」?

是 → 档①。最经典的是当前波次和总分。迟入玩家不知道现在第几波,UI 显示就是错的;看到分数显示 0、全场实际已经攒了 800 分,体验立刻崩。玩家 HP 的共享显示部分、当前阶段(大厅 / 战斗 / 结算)也属于这一档,理由相同:缺失这些值,新进来的玩家根本不知道自己面对的是什么局面。

实现上把状态放在一个或几个 GameState 类型的同步对象上:Owner 修改时调 RequestSerialization,迟入玩家在自己本地的 OnDeserialization 里把 UI 刷新一次。

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class GameState : UdonSharpBehaviour
{
[UdonSynced] public int currentWave;
[UdonSynced] public int totalScore;
[UdonSynced] public byte phase;
public override void OnDeserialization()
{
UpdateUI();
}
private void UpdateUI() { /* 把字段刷到 UI */ }
}

判定问句:「这个状态本质就是一个物体的位置 / 旋转」?

是 → 档②。VRCObjectSync 自动同步 Transform 的位置和旋转,迟入玩家进来时直接拿到当前姿态。可拾取武器、被推倒的桌子、能推动的物理拼图块都属于这一档。

实现上把 VRC Object Sync 组件挂到对象上,不写代码。

注意:这只解决「位置 / 物理」的迟入恢复。如果对象上还有别的状态(比如「是哪个武器类型」「弹药剩多少」),那部分仍然是档①,要走 [UdonSynced]


判定问句:「这个状态是『某些预制体此刻是激活的还是未激活的』」?

是 → 档③。VRC Object Pool 自动同步池里对象的激活状态。例如当前波次的敌人(预制体放在池里,刷怪时 TryToSpawn 激活,死亡时 Return 归还),或临时触发出来的特效物体(带持续状态的,不是一次性的)。

实现上创建一个 VRC Object Pool,把候选对象作为 children 配置进去:Owner 调 pool.TryToSpawn() 拿到一个激活了的对象,调 pool.Return(obj) 归还。

迟入玩家进来时,池子里激活的几个会自动是激活的,其他保持未激活。池子里对象自身的状态(HP、目标)仍然是档①,需要每个对象自己有同步字段。


判定问句:「这件事过了就过了,迟入玩家天然错过也没关系」?

是 → 档④,用网络事件即可。最典型的是陷阱触发的爆炸特效:爆炸是瞬时的,迟入玩家没看到那一下也无所谓。击杀提示音、一闪而过的弹幕公告同理。

实现上 Owner 调 SendCustomNetworkEvent(NetworkEventTarget.All, nameof(PlayBoom)),接收方触发 PlayBoom,播放特效。


反模式:用 bool 同步字段触发瞬时效果

Section titled “反模式:用 bool 同步字段触发瞬时效果”

最经典的迟入翻车场景,第 1 章预告过一次,这里展开:

[UdonSynced] private bool isExploding;
public void Explode()
{
if (!Networking.IsOwner(gameObject)) return;
isExploding = true;
RequestSerialization();
SendCustomEventDelayedSeconds(nameof(EndExplode), 2f);
}
public void EndExplode()
{
if (!Networking.IsOwner(gameObject)) return;
isExploding = false;
RequestSerialization();
}
public override void OnDeserialization()
{
if (isExploding)
{
PlayExplosionEffect();
}
}

代码看起来很对:爆炸开始置 true、广播;2 秒后置 false、广播。所有人都能看到那 2 秒的爆炸。

但是迟入玩家恰好在 isExploding == true 的那一秒进来,本地 OnDeserialization 触发,看到 isExploding 是 true,于是重新播放一次爆炸特效。在场玩家看到的爆炸是 1.5 秒前发生的,迟入玩家看到的是「刚刚」发生的,画面错位。

更糟的是:如果 Owner 离开导致 isExploding 卡在 true 没被清掉,再有迟入玩家进来都会触发幽灵爆炸。

正确写法:爆炸是档④,用网络事件

public void Explode()
{
if (!Networking.IsOwner(gameObject)) return;
SendCustomNetworkEvent(NetworkEventTarget.All, nameof(PlayBoom));
}
public void PlayBoom()
{
PlayExplosionEffect();
}

迟入玩家天然错过那一次爆炸。这就是档④的意义所在,不是 bug。


把合作防守世界的所有同步内容过一遍:

内容实现
当前波次[UdonSynced] int currentWave
总分[UdonSynced] int totalScore
阶段(大厅 / 战斗 / 结算)[UdonSynced] byte phase
玩家 HP(共享显示部分)[UdonSynced] 在每个玩家对象上
玩家位置自动VRChat 自带
玩家是否倒下[UdonSynced] bool isDown
玩家手持武器类型[UdonSynced] byte weaponType
武器物体本身的位置VRCObjectSync
当前波次的敌人列表VRCObjectPool
每只敌人的 HP敌人对象自己的 [UdonSynced] int hp
每只敌人的位置 / 旋转② or 自定义VRCObjectSync 或自定义同步(取决于性能)
陷阱冷却剩余时间[UdonSynced] float cooldownEnd
陷阱触发的爆炸特效SendCustomNetworkEvent
击杀提示音SendCustomNetworkEvent
「捡到道具」的弹幕公告SendCustomNetworkEvent
鼠标悬停高亮本地不同步
个人音量本地不同步

把这张表理顺,第二部和第四部的所有同步对象设计基本都有了骨架。


把回合制卡牌世界的所有同步内容过一遍,做一份和上面一样的清单:

  • 当前是谁的回合
  • 当前回合还剩多少秒
  • 桌面上摆出来的牌
  • 每个玩家的手牌(私有的怎么办?)
  • 双方血量
  • 抽牌动画
  • 出牌时的高亮特效
  • 卡牌被打出后的位置(会摆到桌面上)

私有手牌是关键考验:它不能用普通同步变量(任何人都能读到),怎么实现?

(提示:第二部 9 章会讲 VRCPlayerObject,它解决「每个玩家一份对象」和 Owner 拓扑,不解决保密。手牌这类私有信息通常留本地,只同步公开后的结果;「桌面上的牌」可以是档①。)


迟入玩家拿到「现在」,拿不到「曾经发生过」,这是 VRChat 网络层的硬约束。把同步内容按迟入需求分四档:必须可恢复、Transform 兜底、池化兜底、可丢弃,每一档对应一种实现。用 [UdonSynced] bool 触发瞬时效果是反模式,因为迟入玩家会在 true 那一秒进来时误触发,瞬时效果走网络事件。写每一个新的同步对象之前都先做一次分档。