跳转到内容

闭环 1 · 单 Master Owner 的极简一局

约 8 分钟 难度:2

这一章解决:把第一部的所有概念(状态分类、四种通道、Owner、手动同步、迟入恢复、网络预算、Debug 测试)合在一起,做出第一个能跑的「一局」。

先看一下

第一部七章的每个样例单看都能跑,但还没合成一局完整游戏。这一章把它们合起来:玩家进入大厅,按 Ready 表示准备好;Master 按 Start 进入比赛;任何玩家按场景里的得分按钮 +1 分;分数到上限或时间结束进入结算;Master 按 Restart 回到大厅。

为了让本闭环的代码量保持在「半天能做出来」,所有同步状态都归一个 GameObject 上的 GameLoopMaster 持有,Owner 默认是 Master。这是 Vol.1 的旧默认习惯。本闭环之后第二部会指出它的两个缺陷,并给出 VRCPlayerObject + GameState 的修复方案。

这一章会拿到什么

  • 一个能跑通完整一局流程的最小示例:两个客户端 Build & Test 直接试得出来
  • 一份本闭环故意保留的两个缺陷的清单,以及在测试矩阵里能怎样观察到它们
  • 一套 Phase + Owner 集中处理 + RequestSerialization 的项目骨架,第二部、第四部的闭环都会复用

依赖前面

  • 第 1 章四象限状态分类
  • 第 2 章四种通道的默认选择顺序
  • 第 3 章 Networking.IsOwnerIsMaster 的边界
  • 第 4 章 Manual 同步的生命周期与 OnPostSerialization
  • 第 5 章迟入恢复的「档①必须可恢复」
  • 第 6 章字节预算
  • 第 7 章测试矩阵
  • 理解章 A 的「同步即状态复制」心智模型

三个阶段(Phase):

┌────────── Lobby ──────────┐
│ · 任意玩家按 Ready │
│ · Master 按 Start ──→ │
└──────────┬────────────────┘
┌────────── InGame ─────────┐
│ · 任意玩家按得分按钮 +1 │
│ · 分数 ≥ 10 或时间到 ──→ │
└──────────┬────────────────┘
┌────────── Result ─────────┐
│ · 显示最终分数 │
│ · Master 按 Restart ──→ │
└───────────────────────────┘
回到 Lobby

游戏机制本身极简,是为了让网络结构这一面被看清。第二部、第三部、第四部会分别把这一份骨架往里加权威拓扑、阶段流转、增量序列化。


一个空 GameObject GameLoop,挂 GameLoopMaster

四种按钮,分别用一个简单的 3D Cube 做触发体,挂同一个脚本 GameLoopButton,在 Inspector 里指定按钮种类。Ready 和 Score 任意玩家可按,Start 和 Restart 只有 Master 按下才生效(脚本里硬编码这条):

按钮数量谁能按生效
Ready1任意玩家
Start1只有 Master
Score3-5(自由)任意玩家
Restart1只有 Master

一块 Canvas(World Space),上面四个 TextPhaseLabelScoreLabelTimerLabelReadyLabel。把这四个 Text 拖到 GameLoopMaster 对应的字段上即可。

建议截图:场景结构与 Inspector 引用关系。


using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDKBase;
using VRC.Udon.Common;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class GameLoopMaster : UdonSharpBehaviour
{
// 三个阶段。byte 字段同步 1 字节,省于 enum
public const byte PHASE_LOBBY = 0;
public const byte PHASE_INGAME = 1;
public const byte PHASE_RESULT = 2;
[Header("UI 引用")]
public Text phaseLabel;
public Text scoreLabel;
public Text timerLabel;
public Text readyLabel;
[Header("游戏参数")]
public int targetScore = 10;
public float matchDuration = 30f;
// ===== 同步字段(档①:必须可恢复) =====
[UdonSynced] private byte phase;
[UdonSynced] private int totalScore;
// 缺陷 2 在这里:lobbyReady 是「全员共享一份」,本应是 per-player
[UdonSynced] private bool lobbyReady;
// 倒计时基准。所有客户端用同一个服务器时间起点算剩余时间
[UdonSynced] private double phaseStartServerTime;
private void Start()
{
ApplyState();
}
public override void OnDeserialization()
{
// 远端整批收到新值后刷一次 UI。第 4 章的整批级回调
ApplyState();
}
private void Update()
{
// 所有客户端都用 Networking.GetServerTimeInSeconds 算自己看到的剩余时间
if (phase == PHASE_INGAME && timerLabel != null)
{
double elapsed = Networking.CalculateServerDeltaTime(
Networking.GetServerTimeInSeconds(),
phaseStartServerTime);
float remaining = Mathf.Max(0f, matchDuration - (float)elapsed);
timerLabel.text = remaining.ToString("F1") + "s";
}
// 阶段推进只发生在 Owner 端。其他客户端只看
if (!Networking.IsOwner(gameObject)) return;
if (phase != PHASE_INGAME) return;
double e = Networking.CalculateServerDeltaTime(
Networking.GetServerTimeInSeconds(),
phaseStartServerTime);
if (e >= matchDuration || totalScore >= targetScore)
{
phase = PHASE_RESULT;
RequestSerialization();
ApplyState();
}
}
// ===== 玩家端入口(被按钮调用) =====
public void RequestToggleReady()
{
// 转给 Owner 处理。Owner 默认是 Master
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(NetToggleReady));
}
public void RequestStart()
{
// 这一行用 IsMaster 当门,是 Vol.1 的旧默认习惯。第 3 章已经标记为不推荐
if (!Networking.IsMaster) return;
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(NetStart));
}
public void RequestScore()
{
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(NetAddScore));
}
public void RequestRestart()
{
if (!Networking.IsMaster) return;
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(NetRestart));
}
// ===== Owner 端真正改字段的位置 =====
public void NetToggleReady()
{
if (!Networking.IsOwner(gameObject)) return;
if (phase != PHASE_LOBBY) return;
lobbyReady = !lobbyReady;
RequestSerialization();
ApplyState();
}
public void NetStart()
{
if (!Networking.IsOwner(gameObject)) return;
if (phase != PHASE_LOBBY) return;
if (!lobbyReady) return;
phase = PHASE_INGAME;
totalScore = 0;
phaseStartServerTime = Networking.GetServerTimeInSeconds();
RequestSerialization();
ApplyState();
}
public void NetAddScore()
{
if (!Networking.IsOwner(gameObject)) return;
if (phase != PHASE_INGAME) return;
totalScore++;
RequestSerialization();
ApplyState();
}
public void NetRestart()
{
if (!Networking.IsOwner(gameObject)) return;
if (phase != PHASE_RESULT) return;
phase = PHASE_LOBBY;
totalScore = 0;
lobbyReady = false;
RequestSerialization();
ApplyState();
}
// ===== UI 应用 =====
private void ApplyState()
{
if (phaseLabel != null)
{
phaseLabel.text =
phase == PHASE_LOBBY ? "Lobby" :
phase == PHASE_INGAME ? "InGame" : "Result";
}
if (scoreLabel != null) scoreLabel.text = totalScore.ToString();
if (readyLabel != null) readyLabel.text = lobbyReady ? "READY" : "NOT READY";
}
// ===== 第 4 章的发送回执 =====
public override void OnPostSerialization(SerializationResult result)
{
if (!result.success)
{
Debug.LogWarning("[GameLoop] sync failed at phase=" + phase);
}
}
}

using UdonSharp;
using UnityEngine;
public class GameLoopButton : UdonSharpBehaviour
{
public const int KIND_READY = 0;
public const int KIND_START = 1;
public const int KIND_SCORE = 2;
public const int KIND_RESTART = 3;
public GameLoopMaster gameLoop;
[Tooltip("0=Ready, 1=Start, 2=Score, 3=Restart")]
public int kind;
public override void Interact()
{
if (gameLoop == null) return;
if (kind == KIND_READY) gameLoop.RequestToggleReady();
else if (kind == KIND_START) gameLoop.RequestStart();
else if (kind == KIND_SCORE) gameLoop.RequestScore();
else if (kind == KIND_RESTART) gameLoop.RequestRestart();
}
}

按钮自身完全是本地的Interact 只在按下交互的客户端触发,本地调用 gameLoop.RequestXxx(),再由 RequestXxxSendCustomNetworkEvent(NetworkEventTarget.Owner, ...) 把请求送给 GameLoop 的 Owner。第 2 章实现 A → B → C 的三档选择在这里被同时用上:按钮本身停在档 A,请求传输走档 B(一次性事件),最终落到的状态走档 C(同步变量)。


在闭环里以什么形式出现
第 1 章 四象限phasetotalScorelobbyReadyphaseStartServerTime 是档①「共享 × 持续」;按按钮的 Interact 反馈是档「私有」;SendCustomNetworkEvent 这一跳是档④「共享 × 瞬时」的临时通道
第 2 章 四种通道按钮交互停档 A,请求走档 B,状态走档 C,没有物理对象因此没用档 D
第 3 章 Owner / MasterOwner 默认是 Master;改字段前都做了 IsOwner 检查;IsMaster 仅出现在 Start / Restart 这两处「旧门」上,本闭环留作演示,第二部会替换
第 4 章 Manual 生命周期[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]、每个 Net 方法末尾的 RequestSerialization()OnPostSerialization 监控发送、OnDeserialization 整批应用
第 5 章 迟入恢复四个 [UdonSynced] 字段全是档①,迟入玩家进来一刻自动看到正确的阶段、分数、剩余时间。爆炸 / 提示音类没出现是因为本闭环还不需要档④效果
第 6 章 网络预算整个 GameLoop 的同步 payload 是 1 + 4 + 1 + 8 = 14 字节,远低于 Continuous 上限 200。本可以用 Continuous,选 Manual 是为了把发送时机交给阶段切换显式控制
第 7 章 测试矩阵下面「测试矩阵」一节直接套第 7 章那张五行表

整局没有出现一行「为了让网络代码工作而写」的逻辑:每一行都来自前面某一章的概念。这是 Vol.2 后续闭环写作的范本:先想清楚状态、Owner、通道,再写代码


下面两条是本闭环有意不修的设计取舍。第二部、第四部会逐一处理。

缺陷 1:所有状态归 Master Owner,Master 离开后状态托管全靠运气

Section titled “缺陷 1:所有状态归 Master Owner,Master 离开后状态托管全靠运气”

GameLoop 是场景里自带的对象,Owner 默认是 Master。整局没有任何一行 Networking.SetOwner,意味着这套状态的托管完全依赖 VRChat 的「Master 离开自动选新 Master」机制。

可观察到的两类不良现象:

  • Master 在 InGame 中途强制退出。Owner 自动转给新 Master,Update 在新 Master 那边继续跑。但 Master 离开的那几百毫秒里,倒计时和分数推进会顿一下。
  • 没有「主持人」的概念。任何人都不能主动请求接管,Master 是谁完全由 VRChat 决定。Master 那个人即使中途想把控制权交出来,没接口可走。

第二部第 9 章引入 VRCPlayerObject 的 per-player 拥有,第 11 章引入 GameStateRequestStateOwner 接管协议。

缺陷 2:lobbyReady 是全局一份,不是 per-player

Section titled “缺陷 2:lobbyReady 是全局一份,不是 per-player”

代码里只有一个 [UdonSynced] bool lobbyReady。语义本应是「每位玩家自己的 Ready 状态」,但实现里只能存「房间里此刻有人按了 Ready」一个比特。

可观察到的不良现象:

  • A 按 Ready 后 lobbyReady = true。B 按 Ready 时执行 lobbyReady = !lobbyReady,结果变 false。两位玩家都按过 Ready,房间状态却显示 NOT READY
  • Master 看不出「现在 4 位玩家里到底有几位准备好」。Start 这道门的意义被稀释成「只要有人按过 Ready 就能开」。

第二部第 9 章会把 isReady 字段挪到 VRCPlayerObject 上,每位玩家一份,独立写、共同读。


挑一两条试试,不需要全做。每条都不要给完整答案,先自己写一版再翻第二部。

  • targetScore 从 10 改成 5,matchDuration 从 30 秒改成 10 秒。两个客户端跑一局,看 OnDeserialization 在 B 端被触发了多少次。
  • OnPostSerialization 里把 result.byteCount 也打出来。一局完整跑下来累积发送字节数大概是多少?这一项答案可以拿到第 6 章那张预算表上对照。
  • 故意把 RequestSerializationNetAddScore 里删掉,再跑一局。Owner 端能看到分数变化吗?远端呢?看完恢复回来。
  • 把按钮种类的 int kind 拆成四个独立脚本(ReadyButton.csStartButton.csScoreButton.csRestartButton.cs),代码会变成什么样?这种「按职责分」和「按种类分」哪一种更适合后续扩展?

按第 7 章五行表跑。两个客户端用 SDK Build & Test。

场景预期实际
1. Owner 端改字段 → 远端同步A 按 Ready / Score,B 端在 1 秒内更新 UI;Debug View 8 上 GameLoop 显示 Q=100%
2. 迟入玩家恢复A 已经把 phase 推到 InGame、score=5,B 后进;B 进入瞬间立刻看到 Phase=InGame, Score=5, Timer=剩余正确
3. Owner(Master)离开InGame 中途 A 退出,B 接管;倒计时和分数继续推进,可能短暂顿一拍;UI 不被默认值覆盖…(这是缺陷 1 的暴露场景)
4. 双人同时按 ReadyA 按一次 → READY;紧接 B 按一次 → 变回 NOT READY;两位玩家都按过却显示未准备…(这是缺陷 2 的暴露场景)
5. 拥塞下日志在 InGame 阶段 1 秒内连按 Score 50 次,OnPostSerialization 报告里 success 大部分为 true,byteCount 加起来仍可控;最终 totalScore 落地为 50(或被 targetScore 截断)

「实际」这一列在第一次跑通后填回来。本闭环的目标不是过五项全绿,是看到第三、第四行确实出现了缺陷的具体表现,作为第二部的动机。


  • 能解释为什么本闭环里 Master 一离开 InGame,倒计时会顿一下
  • 能复述三个阶段(Lobby / InGame / Result)的状态字段,并说出每个字段是档①还是档④
  • 能说出本闭环为什么用 Manual 同步而不是 Continuous
  • 能在 Networking.GetServerTimeInSecondsTime.timeSinceLevelLoad 之间选对,并解释理由
  • 能指出 lobbyReady 字段「应该但还没」被拆成 per-player 的位置

  • VRChat Creator Docs · Networking and SynchronizationSendCustomNetworkEventNetworkEventTarget.Owner、Manual 同步的官方说明。
  • VRChat Creator Docs · Network Specs and TipsOnPostSerialization 字段、Manual 上限的官方数据。
  • VRChat 汉化文档 · VRChat API · NetworkingGetServerTimeInSeconds / CalculateServerDeltaTime 的中文说明。
  • 附录 · 术语表OwnerMasterManual SyncRequestSerializationOnDeserializationServer Time 的客观定义。