跳转到内容

第 11 章 · 全局状态管理器

约 8 分钟 难度:2 动手章

这一章解决:闭环 1 的全局字段(phasetotalScorephaseStartServerTime)需要一个独立对象承载,并需要一种 Owner 策略让 Master 离开后状态能继续被托管。这个对象在 Vol.2 里统一称为 GameState

先看一下

闭环 1 的所有全局状态挂在 GameLoopMaster 上,Owner 默认是 Master。Master 离开瞬间倒计时和分数会顿一下,且没有人能主动接管 GameState。第 9 章把 per-player 状态搬到 VRCPlayerObject 解决了「玩家自己的事」,但「房间共享的事」还在裸奔。

这一章给 GameState 一个独立位置,并讨论三种 Owner 策略各自适合什么。在动手之前,第一节先划一条红线:GameState Owner 是当前实例内的状态提交者,不是可信服务器。

这一章会拿到什么

  • 一句话边界:GameState Owner 是状态提交者,不是安全边界
  • 三种 Owner 策略对照(固定 Owner / Owner 候选队列 / Owner 重选),各自适合的玩法
  • 一份 GameState 骨架代码,把闭环 1 的全局字段搬过来
  • 三个反向案例:休闲房、画廊、单人体验,这些世界不需要 GameState

依赖前面

  • 第 9 章 PlayerObject 边界卡
  • 第 3 章 Owner 不是 Master
  • 第 4 章 Manual 同步生命周期
  • 闭环 1 缺陷 1(Master 离开后状态托管全靠运气)

第一条红线:GameState Owner 不是服务器

Section titled “第一条红线:GameState Owner 不是服务器”

写这一章的代码之前必须看这一节。后续第 33 章「反作弊近似」会回链这里。

GameState Owner 是某一位玩家的客户端。本卷把房间共享状态的写权集中到这位 Owner,是为了:让所有客户端在读写顺序上有共识、让请求处理逻辑有一处明确的执行点、让 Master 离开时状态能转移给新 Owner。

不是服务器:

  • 这位 Owner 是另一位玩家的客户端,他能读到 GameState 的内存。
  • 他能修改自己客户端上的代码(理论上)。
  • 他不掌握其他玩家的输入真相(其他玩家通过同步告诉他「我请求加 1 分」,他无法分辨这条请求是不是真按了按钮)。

所以 GameState 解决的是架构整洁,不是安全可信。在 VRChat 里,「让分数公平」「让作弊变难」需要其他工具:合理性检查、冷却、上限、举报、主持人权限。第 33 章会展开。

GameState Owner 想成服务器,会写出过度承诺的代码:例如让玩家上报分数后立刻发奖励、把胜负判定完全交给 Owner 计算且不留备份。一旦 Owner 客户端被改、或被 Owner 离开瞬间的状态丢失,整局玩家的数据都会出问题。

正确心智:GameState 是当前实例内众人约定的状态提交者,不超越 VRChat 客户端的可信范围。


GameState 这个对象的 Owner 由谁来当,本卷给三种策略。每种适合不同玩法。

策略 A · 固定 Owner(场景对象自带 Master Owner)

Section titled “策略 A · 固定 Owner(场景对象自带 Master Owner)”

GameState 是场景里自带的 GameObject,Owner 默认是 Master。这是闭环 1 的做法。

  • 适合:内测期、小型实验世界、合作 PvE(Master 对作弊的耐受度高)。
  • 不适合:竞技对抗(Master 能改全局分数,对其他玩家不公平)、需要主持人接管(没有接管接口)。
  • Master 离开时:VRChat 自动把 Owner 转给新 Master,会有几百毫秒顿挫。

策略 B · Owner 候选队列(按加入顺序排队)

Section titled “策略 B · Owner 候选队列(按加入顺序排队)”

GameState 维护一份 [UdonSynced] int[] ownerCandidatePlayerIds,按玩家加入顺序排序。当前 Owner 离开后,候选队列里下一位玩家自动接管,并把自己从队列里删掉。

  • 适合:合作 / 解谜世界(不需要主持人,但希望 Owner 转移有可预测顺序)。
  • 不适合:玩家流动性大(候选队列每秒变化,转移逻辑复杂度上升)。

策略 C · Owner 重选(按某条规则推举)

Section titled “策略 C · Owner 重选(按某条规则推举)”

GameState 离开 Owner 时,根据规则推举新 Owner。规则可以是「房间里加入时间最早的玩家」「房间里 ping 最低的玩家」「主持人列表里第一位在房间的玩家」。

  • 适合:活动世界(有指定主持人)、商业世界(有运营人员账号)。
  • 不适合:纯自助玩法(推举规则没意义)。

本章给策略 A 的代码骨架,足够支撑闭环 2。策略 B、C 的实现思路在第 14 章「Owner 离开后的恢复」展开。


把闭环 1 的全局字段从 GameLoopMaster 搬到一个独立 GameObject GameState 上。后续闭环都复用这个名字。

using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDKBase;
using VRC.Udon.Common;
using VRC.Udon.Common.Interfaces;
using VRC.SDK3.UdonNetworkCalling;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class GameState : UdonSharpBehaviour
{
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 readyCountLabel;
[Header("游戏参数")]
public int targetScore = 10;
public float matchDuration = 30f;
// ===== 房间共享状态(GameState Owner 写、所有人读) =====
[UdonSynced] private byte phase;
[UdonSynced] private int totalScore;
[UdonSynced] private double phaseStartServerTime;
// 聚合自所有 PlayerLobbyState 的 isReady。每位玩家自己写自己的 PlayerObject,
// GameState Owner 在 NetStart 时扫一遍来决定能否开局
// 注意:readyCount 不是同步字段,是计算字段。下面 ReCount() 在每次需要时重算
private int readyCountCache;
// ===== 公共读接口 =====
public byte Phase => phase;
public int TotalScore => totalScore;
public double PhaseStartServerTime => phaseStartServerTime;
private void Start()
{
ApplyState();
}
public override void OnDeserialization()
{
ApplyState();
}
private void Update()
{
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";
}
// 阶段推进只在 GameState 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();
}
}
// ===== 玩家端入口(按钮调用,转给 GameState Owner) =====
public void RequestStart()
{
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(NetStart));
}
public void RequestScore()
{
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(NetAddScore));
}
public void RequestRestart()
{
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(NetRestart));
}
// ===== Owner 端真正改字段的位置 =====
[NetworkCallable]
public void NetStart()
{
if (!Networking.IsOwner(gameObject)) return;
if (phase != PHASE_LOBBY) return;
if (!AllPlayersReady()) return; // 第 9 章 AllReady 思路
phase = PHASE_INGAME;
totalScore = 0;
phaseStartServerTime = Networking.GetServerTimeInSeconds();
RequestSerialization();
ApplyState();
}
[NetworkCallable]
public void NetAddScore()
{
if (!Networking.IsOwner(gameObject)) return;
if (phase != PHASE_INGAME) return;
totalScore++;
RequestSerialization();
ApplyState();
}
[NetworkCallable]
public void NetRestart()
{
if (!Networking.IsOwner(gameObject)) return;
if (phase != PHASE_RESULT) return;
phase = PHASE_LOBBY;
totalScore = 0;
RequestSerialization();
ApplyState();
}
// ===== 聚合 PlayerLobbyState 的辅助 =====
// 第 9 章定义的 helper:按玩家取 PlayerLobbyState
private PlayerLobbyState GetPLS(VRCPlayerApi player)
{
if (player == null || !player.IsValid()) return null;
var objs = Networking.GetPlayerObjects(player);
if (objs == null) return null;
for (int i = 0; i < objs.Length; i++)
{
if (objs[i] == null) continue;
var pls = objs[i].GetComponentInChildren<PlayerLobbyState>();
if (pls != null) return pls;
}
return null;
}
private bool AllPlayersReady()
{
int count = VRCPlayerApi.GetPlayerCount();
if (count == 0) return false;
var players = new VRCPlayerApi[count];
VRCPlayerApi.GetPlayers(players);
int ready = 0;
for (int i = 0; i < count; i++)
{
var p = players[i];
if (p == null || !p.IsValid()) continue;
var s = GetPLS(p);
if (s == null) return false;
if (!s.IsReady) return false;
ready++;
}
readyCountCache = ready;
return true;
}
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 (readyCountLabel != null)
{
// 简化版本:每次 ApplyState 时重算。实际项目里可以让 PlayerLobbyState 在 OnDeserialization
// 通知 GameState 增量更新,或按第 12 章请求式架构的低频扫描写法处理
int count = VRCPlayerApi.GetPlayerCount();
int ready = 0;
var players = new VRCPlayerApi[count];
VRCPlayerApi.GetPlayers(players);
for (int i = 0; i < count; i++)
{
var p = players[i];
if (p == null || !p.IsValid()) continue;
var s = GetPLS(p);
if (s != null && s.IsReady) ready++;
}
readyCountLabel.text = ready + " / " + count;
}
}
public override void OnPostSerialization(SerializationResult result)
{
if (!result.success)
{
Debug.LogWarning("[GameState] sync failed at phase=" + phase);
}
}
}

和闭环 1 GameLoopMaster 比较:

  • lobbyReady 字段消失了,被 PlayerLobbyState.isReady 取代。AllPlayersReady 是新的开局门。
  • 其余字段(phasetotalScorephaseStartServerTime)原封不动。
  • 类名从 GameLoopMaster 改成 GameState。「Master」一词被去掉,因为本章已经不再围绕 IsMaster 设计权威。
  • Owner 仍然默认是 Master(策略 A),第 14 章会换成策略 B / C。

GameLoopButton 引用 GameLoopMaster 的地方都改成 GameState,方法签名一致,无需改按钮逻辑。


三个反向案例:不需要 GameState 的世界

Section titled “三个反向案例:不需要 GameState 的世界”

不是所有 VRChat 世界都需要这套架构。下面三类世界做得最好的实现是不写 GameState

一片好看的草地、几个互动玩具、播放音乐。玩家进来聊天、做表情、互拍照片。没有阶段、没有分数、没有胜负。

要的是什么:让每个玩具的状态(被谁拿着、丢在地上的位置)正确同步。 不要的是什么:阶段机、计分系统、Ready / Start 流程。

实现:每个玩具用 VRC Object Sync(第 7 部主题)。整个世界没有任何 UdonSharp 全局脚本,或只有极简的「灯光开关」「音乐音量」之类的本地状态。

走进去看作品、读说明、坐下来欣赏。可能有讲解音频、互动按钮,但没有「玩家之间的协作或对抗」。

要的是什么:每件作品的展示状态(讲解音频是否在播放)。 不要的是什么:玩家级 Ready 状态、计分。

实现:作品级 UdonBehaviour 各自管自己。Owner 用策略 A(场景自带 Master Owner)就够,因为讲解音频在 Master 离开后短暂顿一下不会破坏体验。

为单人玩家设计的探索 / 解谜 / 表演世界。理论上是公开实例,但内容主要面向「单人来访问」。

要的是什么:本地剧情进度、本地 UI、本地音效。 不要的是什么:跨玩家的状态共享。

实现:UdonSharp 脚本几乎全是本地(第 1 章「私有」档),同步层只用极少几个字段(玩家位置之类,靠 SDK 内置的 VRCObjectSync 处理 Avatar)。


  • 把策略 A 的 GameState 跑两个客户端,在 Master 离开瞬间观察 phase 字段的同步抖动。具体能不能在 Debug View 8 看到 Q(队列)短暂上涨?
  • AllPlayersReady 改成「至少 50% 玩家 Ready 即可开局」,写个 RequestForceStart 接口让 Master 强制开局。这个接口和反例里说的「主持人接管」是同一种思路吗?
  • readyCountLabel 的更新逻辑从「每帧重算」改成「PlayerLobbyState 改变 isReady 时主动调用 GameState 上的 NetReadyChanged」。会不会反而引入新问题(比如初始化时序)?

场景预期实际
1. GameState 字段同步A 改字段,B 端 1 秒内更新
2. 迟入恢复已 InGame 时 C 进来,立刻看到 phase / score / timer
3. Master 离开(策略 A)Owner 自动转给新 Master,倒计时短暂顿挫但继续推进
4. 全部玩家 Ready 后 NetStart进入 InGame,readyCount 显示 N/N
5. 部分玩家未 Ready 就按 StartNetStart 立即返回,phase 不变

第三行是闭环 1 缺陷 1 的相对改善:本章用策略 A 时仍然有顿挫,第 14 章策略 B / C 才彻底解决。


  • 能复述「GameState Owner 不是可信服务器」的三条理由
  • 能说出三种 Owner 策略各自适合的玩法类型
  • 能复述三个反向案例,并指出它们用什么技术替代 GameState
  • 能解释为什么 totalScore 放 GameState 而 isReady 放 PlayerObject

  • VRChat Creator Docs · Object OwnershipNetworking.IsOwnerSetOwner、自动 Owner 转移的官方说明。
  • VRChat Creator Docs · Network Events[NetworkCallable]NetworkEventTarget.Owner 的官方说明。
  • 第 9 章 · VRCPlayerObject 边界卡:适合 / 不适合放 PlayerObject 的对照。
  • 闭环 1 · 缺陷 1 现场:Master 离开后状态托管的问题。
  • 附录 · 术语表GameStateOwner StrategyState Submitter 的客观定义。