闭环 1 · 单 Master Owner 的极简一局
这一章解决:把第一部的所有概念(状态分类、四种通道、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.IsOwner和IsMaster的边界 - 第 4 章 Manual 同步的生命周期与
OnPostSerialization - 第 5 章迟入恢复的「档①必须可恢复」
- 第 6 章字节预算
- 第 7 章测试矩阵
- 理解章 A 的「同步即状态复制」心智模型
一局长什么样
Section titled “一局长什么样”三个阶段(Phase):
┌────────── Lobby ──────────┐ │ · 任意玩家按 Ready │ │ · Master 按 Start ──→ │ └──────────┬────────────────┘ ↓ ┌────────── InGame ─────────┐ │ · 任意玩家按得分按钮 +1 │ │ · 分数 ≥ 10 或时间到 ──→ │ └──────────┬────────────────┘ ↓ ┌────────── Result ─────────┐ │ · 显示最终分数 │ │ · Master 按 Restart ──→ │ └───────────────────────────┘ ↓ 回到 Lobby游戏机制本身极简,是为了让网络结构这一面被看清。第二部、第三部、第四部会分别把这一份骨架往里加权威拓扑、阶段流转、增量序列化。
场景里要摆什么
Section titled “场景里要摆什么”一个空 GameObject GameLoop,挂 GameLoopMaster。
四种按钮,分别用一个简单的 3D Cube 做触发体,挂同一个脚本 GameLoopButton,在 Inspector 里指定按钮种类。Ready 和 Score 任意玩家可按,Start 和 Restart 只有 Master 按下才生效(脚本里硬编码这条):
| 按钮 | 数量 | 谁能按生效 |
|---|---|---|
| Ready | 1 | 任意玩家 |
| Start | 1 | 只有 Master |
| Score | 3-5(自由) | 任意玩家 |
| Restart | 1 | 只有 Master |
一块 Canvas(World Space),上面四个 Text:PhaseLabel、ScoreLabel、TimerLabel、ReadyLabel。把这四个 Text 拖到 GameLoopMaster 对应的字段上即可。
建议截图:场景结构与 Inspector 引用关系。
代码 1:GameLoopMaster.cs
Section titled “代码 1:GameLoopMaster.cs”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); } }}代码 2:GameLoopButton.cs
Section titled “代码 2:GameLoopButton.cs”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(),再由 RequestXxx 用 SendCustomNetworkEvent(NetworkEventTarget.Owner, ...) 把请求送给 GameLoop 的 Owner。第 2 章实现 A → B → C 的三档选择在这里被同时用上:按钮本身停在档 A,请求传输走档 B(一次性事件),最终落到的状态走档 C(同步变量)。
这局到底用了第一部的什么
Section titled “这局到底用了第一部的什么”| 章 | 在闭环里以什么形式出现 |
|---|---|
| 第 1 章 四象限 | phase、totalScore、lobbyReady、phaseStartServerTime 是档①「共享 × 持续」;按按钮的 Interact 反馈是档「私有」;SendCustomNetworkEvent 这一跳是档④「共享 × 瞬时」的临时通道 |
| 第 2 章 四种通道 | 按钮交互停档 A,请求走档 B,状态走档 C,没有物理对象因此没用档 D |
| 第 3 章 Owner / Master | Owner 默认是 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、通道,再写代码。
本闭环故意保留的两个缺陷
Section titled “本闭环故意保留的两个缺陷”下面两条是本闭环有意不修的设计取舍。第二部、第四部会逐一处理。
缺陷 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 章引入 GameState 加 RequestStateOwner 接管协议。
缺陷 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 章那张预算表上对照。 - 故意把
RequestSerialization从NetAddScore里删掉,再跑一局。Owner 端能看到分数变化吗?远端呢?看完恢复回来。 - 把按钮种类的
int kind拆成四个独立脚本(ReadyButton.cs、StartButton.cs、ScoreButton.cs、RestartButton.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. 双人同时按 Ready | A 按一次 → 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.GetServerTimeInSeconds和Time.timeSinceLevelLoad之间选对,并解释理由 - 能指出
lobbyReady字段「应该但还没」被拆成 per-player 的位置
- VRChat Creator Docs · Networking and Synchronization —
SendCustomNetworkEvent、NetworkEventTarget.Owner、Manual 同步的官方说明。 - VRChat Creator Docs · Network Specs and Tips —
OnPostSerialization字段、Manual 上限的官方数据。 - VRChat 汉化文档 · VRChat API · Networking —
GetServerTimeInSeconds/CalculateServerDeltaTime的中文说明。 - 附录 · 术语表 —
Owner、Master、Manual Sync、RequestSerialization、OnDeserialization、Server Time的客观定义。