跳转到内容

闭环 2 · PlayerObject + GameState 的中级一局

约 7 分钟 难度:2

这一章解决:把第二部 8–14 章的工具合在一起,重做一遍闭环 1 的 Lobby → InGame → Result → Restart 流程。修复闭环 1 两个缺陷,留三个新缺陷待闭环 3 修。

先看一下

闭环 1 是单 Master Owner,所有状态归一个 GameObject。闭环 2 把状态拆成两层:每位玩家自己的状态(isReady、teamId)放进 PlayerLobbyState,由玩家本人持有;房间共享状态(phase、totalScore、phaseStartServerTime)放进 GameState,由 Owner 候选队列推举出来的玩家持有。

整局的玩法骨架不变(按 Score 加分),是为了让闭环 1 → 闭环 2 → 闭环 3 三次能并排比对,看每次升级修了什么。

这一章会拿到什么

  • 一份能跑通的完整代码:PlayerLobbyState + GameState + GameLoopButton,整合第 9、11、12、13、14 章
  • 闭环 2 修复闭环 1 缺陷的两条具体方式
  • 闭环 2 故意保留的三个新缺陷及其在测试矩阵里的观察方法
  • 闭环 1 → 闭环 2 的「改了什么」对照表

依赖前面

  • 第 9 章 PlayerLobbyState 骨架
  • 第 11 章 GameState 骨架
  • 第 12 章请求式架构与字段命名
  • 第 13 章批准回执与本地冷却
  • 第 14 章候选队列接管

相对闭环 1 的差异:

项目闭环 1闭环 2
中央对象GameLoop(挂 GameLoopMaster)GameState(挂 GameState)
Per-player 模板PlayerLobbyStateTemplate(挂 VRCPlayerObject + PlayerLobbyState
按钮Ready / Start / Score / RestartReady / Start / Score / Restart(数量不变,但改成调用本地玩家自己的 PlayerLobbyState
CanvasPhaseLabel / ScoreLabel / TimerLabel / ReadyLabelPhaseLabel / ScoreLabel / TimerLabel / ReadyCountLabel(N / 总人数

具体步骤:

  • 删除闭环 1 的 GameLoopMaster 组件(保留场景对象,重命名为 GameState)。
  • 给场景加一个 PlayerLobbyStateTemplate 空对象,根节点同时挂 VRCPlayerObject 组件(来自 VRC.SDK3.PersistenceAdd Component 里搜 VRC Player Object)和下面要写的 PlayerLobbyState 脚本。
  • Ready 按钮的 GameLoopButton 改成「调用本地玩家自己 PlayerLobbyState 的 RequestToggleReady」。Score / Start / Restart 按钮改成「调用本地玩家自己 PlayerLobbyState 的 RequestScore / RequestStart / RequestRestart」。

第 12 章请求字段 + 第 13 章批准回执 + 本地冷却的合订。

using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon.Common.Interfaces;
using VRC.SDK3.UdonNetworkCalling; // 参数化网络事件 + [NetworkCallable],需 SDK ≥ 3.8.1
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class PlayerLobbyState : UdonSharpBehaviour
{
public const byte REQ_NONE = 0;
public const byte REQ_START = 1;
public const byte REQ_SCORE = 2;
public const byte REQ_RESTART = 3;
public const byte REQ_TOGGLE_READY = 4;
[UdonSynced] private bool isReady;
[UdonSynced] private byte teamId; // 暂未使用,预留给后续章节
[UdonSynced] private int requestId;
[UdonSynced] private byte requestType;
[UdonSynced] private int requestPayload;
private float lastIssueTime = -999f;
public float requestCooldown = 0.3f;
[Header("引用")]
public GameState gameState; // 由场景里的 Linker 在 Start 时回填,下一节给
public bool IsReady => isReady;
public byte TeamId => teamId;
public int RequestId => requestId;
public byte RequestType => requestType;
public int RequestPayload => requestPayload;
private void Start()
{
// 把自己注册到 GameState(用于「主动推送请求」的优化)
if (gameState == null)
{
var stateObject = GameObject.Find("GameState");
if (stateObject != null) gameState = stateObject.GetComponent<GameState>();
}
}
public void RequestToggleReady()
{
if (!Networking.IsOwner(gameObject)) return;
// Ready 不发请求字段,直接写自己。免得占 requestId
isReady = !isReady;
RequestSerialization();
}
public void RequestStart() { IssueRequest(REQ_START, 0); }
public void RequestScore() { IssueRequest(REQ_SCORE, 0); }
public void RequestRestart() { IssueRequest(REQ_RESTART, 0); }
private void IssueRequest(byte type, int payload)
{
if (!Networking.IsOwner(gameObject)) return;
// 本地冷却(拦正常玩家手抖,拦不住改客户端)
if (Time.time - lastIssueTime < requestCooldown) return;
lastIssueTime = Time.time;
requestId++;
requestType = type;
requestPayload = payload;
RequestSerialization();
if (gameState != null)
{
gameState.SendCustomNetworkEvent(
NetworkEventTarget.Owner,
nameof(GameState.OnPlayerRequestUpdated));
}
}
}

注意:闭环 2 故意只在 PlayerObject 本地装冷却,在 GameState 端装冷却。这是闭环 2 留的缺陷之一(重复加分)。改客户端的玩家可以绕过本地冷却,把分数加到任意值。闭环 3 会补 GameState 端的冷却 + 上限。


using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDKBase;
using VRC.Udon.Common;
using VRC.Udon.Common.Interfaces;
using VRC.SDK3.UdonNetworkCalling; // 参数化网络事件 + [NetworkCallable],需 SDK ≥ 3.8.1
[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;
// 共享状态
[UdonSynced] private byte phase;
[UdonSynced] private int totalScore;
[UdonSynced] private double phaseStartServerTime;
// 请求处理记忆
[UdonSynced] private int[] lastProcessedRequestId = new int[100];
// Owner 候选队列
[UdonSynced] private int[] ownerCandidateOrder = new int[100];
[UdonSynced] private int ownerCandidateCount = 0;
private float nextRequestScanTime;
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";
}
if (!Networking.IsOwner(gameObject)) return;
if (Time.time >= nextRequestScanTime)
{
nextRequestScanTime = Time.time + 0.25f;
ScanPlayerRequests();
}
if (phase != PHASE_INGAME) return;
double e = Networking.CalculateServerDeltaTime(
Networking.GetServerTimeInSeconds(), phaseStartServerTime);
if (e >= matchDuration || totalScore >= targetScore)
{
phase = PHASE_RESULT;
RequestSerialization();
ApplyState();
}
}
// ===== 玩家请求处理入口 =====
[NetworkCallable]
public void OnPlayerRequestUpdated()
{
if (!Networking.IsOwner(gameObject)) return;
ScanPlayerRequests();
}
private void ScanPlayerRequests()
{
int count = VRCPlayerApi.GetPlayerCount();
var players = new VRCPlayerApi[count];
VRCPlayerApi.GetPlayers(players);
bool dirty = false;
for (int i = 0; i < count; i++)
{
var p = players[i];
if (p == null || !p.IsValid()) continue;
var pls = GetPLS(p); // helper 见本类底部
if (pls == null) continue;
int lastId = (p.playerId < lastProcessedRequestId.Length)
? lastProcessedRequestId[p.playerId] : 0;
if (pls.RequestId > lastId)
{
ProcessRequest(p, pls);
lastProcessedRequestId[p.playerId] = pls.RequestId;
dirty = true;
}
}
if (dirty) RequestSerialization();
}
private void ProcessRequest(VRCPlayerApi who, PlayerLobbyState pls)
{
byte type = pls.RequestType;
if (type == PlayerLobbyState.REQ_START) HandleStart(who);
else if (type == PlayerLobbyState.REQ_SCORE) HandleScore(who);
else if (type == PlayerLobbyState.REQ_RESTART) HandleRestart(who);
}
private void HandleStart(VRCPlayerApi who)
{
if (phase != PHASE_LOBBY) return;
if (!AllPlayersReady()) return;
phase = PHASE_INGAME;
totalScore = 0;
phaseStartServerTime = Networking.GetServerTimeInSeconds();
ApplyState();
}
private void HandleScore(VRCPlayerApi who)
{
if (phase != PHASE_INGAME) return;
// 闭环 2 没有 GameState 端冷却,没有 per-player 上限
// 改客户端的玩家可以连续发请求把分数刷满。这是缺陷 1
totalScore++;
ApplyState();
}
private void HandleRestart(VRCPlayerApi who)
{
if (phase != PHASE_RESULT) return;
phase = PHASE_LOBBY;
totalScore = 0;
ApplyState();
}
private bool AllPlayersReady()
{
int count = VRCPlayerApi.GetPlayerCount();
if (count == 0) return false;
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) return false;
if (!s.IsReady) return false;
}
return true;
}
// ===== 按玩家取 PlayerLobbyState 的 helper =====
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;
}
// ===== Owner 候选队列 =====
public override void OnPlayerJoined(VRCPlayerApi player)
{
if (!Networking.IsOwner(gameObject)) return;
if (player == null) return;
if (ownerCandidateCount < ownerCandidateOrder.Length)
{
ownerCandidateOrder[ownerCandidateCount] = player.playerId;
ownerCandidateCount++;
RequestSerialization();
}
}
public override void OnPlayerLeft(VRCPlayerApi player)
{
if (player == null) return;
if (Networking.IsOwner(gameObject))
{
RemoveFromCandidates(player.playerId);
if (player.playerId < lastProcessedRequestId.Length)
{
lastProcessedRequestId[player.playerId] = 0;
}
RequestSerialization();
return;
}
// VRChat 会先做兜底 Owner 转移。候选人检查自己是否应该纠正兜底结果
TryClaimIfFirstCandidate();
}
public override void OnOwnershipTransferred(VRCPlayerApi player)
{
TryClaimIfFirstCandidate();
if (!Networking.IsOwner(gameObject)) return;
if (Networking.LocalPlayer != null
&& ownerCandidateCount > 0
&& Networking.LocalPlayer.playerId == ownerCandidateOrder[0])
{
RemoveFromCandidates(Networking.LocalPlayer.playerId);
RequestSerialization();
}
SanityCheckAfterTakeover();
}
private void TryClaimIfFirstCandidate()
{
var local = Networking.LocalPlayer;
if (local == null || !local.IsValid()) return;
if (ownerCandidateCount <= 0) return;
if (local.playerId != ownerCandidateOrder[0]) return;
if (Networking.IsOwner(gameObject)) return;
Networking.SetOwner(local, gameObject);
}
private void SanityCheckAfterTakeover()
{
if (phase == PHASE_INGAME && VRCPlayerApi.GetPlayerCount() == 0)
{
phase = PHASE_LOBBY;
totalScore = 0;
ApplyState();
RequestSerialization();
}
}
private void RemoveFromCandidates(int playerId)
{
int found = -1;
for (int i = 0; i < ownerCandidateCount; i++)
{
if (ownerCandidateOrder[i] == playerId) { found = i; break; }
}
if (found < 0) return;
for (int j = found; j < ownerCandidateCount - 1; j++)
{
ownerCandidateOrder[j] = ownerCandidateOrder[j + 1];
}
ownerCandidateCount--;
}
// ===== 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 (readyCountLabel != null)
{
int total = VRCPlayerApi.GetPlayerCount();
int ready = 0;
var players = new VRCPlayerApi[total];
VRCPlayerApi.GetPlayers(players);
for (int i = 0; i < total; i++)
{
var p = players[i];
if (p == null || !p.IsValid()) continue;
var s = GetPLS(p);
if (s != null && s.IsReady) ready++;
}
readyCountLabel.text = ready + " / " + total;
}
}
public override void OnPostSerialization(SerializationResult result)
{
if (!result.success)
{
Debug.LogWarning("[GameState] sync failed at phase=" + phase);
}
}
}

using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
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;
[Tooltip("0=Ready, 1=Start, 2=Score, 3=Restart")]
public int kind;
public override void Interact()
{
// 找当前本地玩家自己的 PlayerLobbyState
if (Networking.LocalPlayer == null) return;
var pls = FindLocalPLS();
if (pls == null) return;
if (kind == KIND_READY) pls.RequestToggleReady();
else if (kind == KIND_START) pls.RequestStart();
else if (kind == KIND_SCORE) pls.RequestScore();
else if (kind == KIND_RESTART) pls.RequestRestart();
}
private PlayerLobbyState FindLocalPLS()
{
var p = Networking.LocalPlayer;
if (p == null || !p.IsValid()) return null;
var objs = Networking.GetPlayerObjects(p);
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;
}
}

按钮代码相对闭环 1 的差异:不再持有 GameLoopMaster 引用,而是每次按下都查询本地玩家自己的 PlayerLobbyState。Start 和 Restart 按钮没有显式 Master 限制,因为闭环 2 已经不再围绕 IsMaster 设计;任何玩家按 Start,请求都会进入 GameState 处理,由 AllPlayersReady 决定能否开局。


项目闭环 1闭环 2
lobbyReady 字段全局 bool,所有玩家共用每位玩家自己 PlayerObject 上 bool isReady
「准备」按钮调用链玩家 → SendCustomNetworkEvent(target=Owner, NetToggleReady) → Master 端写字段玩家 → 自己 PlayerObject 直接写
phase / totalScore挂在 GameLoopMaster,Owner=Master挂在 GameState,Owner 由候选队列推举
加分调用链玩家 → SendCustomNetworkEvent(target=Owner, NetAddScore) → Master 端 ++玩家 → 自己 PlayerObject 写 requestId++ → GameState Owner 扫描处理
开局门if (!lobbyReady) return;if (!AllPlayersReady()) return;
Start/Restart 限制if (!Networking.IsMaster) return;(旧门)任何人可按,由 AllPlayersReady 决定
Master 离开Owner 自动转给新 Master,可能顿挫Owner 候选队列推举下一位,OnOwnershipTransferred 做一致性检查

修复闭环 1 的两个缺陷:

  • 缺陷 1(Master 离开整局丢失):候选队列接管 + SanityCheckAfterTakeover。Master 离开时下一位候选玩家 SetOwner 自己,比赛不丢。
  • 缺陷 2(Ready 互相覆盖)isReady 挪到 PlayerObject。每位玩家写自己的字段,互不影响。

下面三条不是漏写,是有意保留的设计取舍。第三、四部会逐一处理。每条配测试矩阵观察方法。

HandleScore 没有冷却、没有 per-player 加分上限。改客户端绕过 PlayerObject 本地冷却的玩家可以连续发 REQ_SCORE 请求,每个都被受理,分数无上限。即使不改客户端,正常玩家长按按钮(不同设备触发频率不同)也能看到分数加得比预期快。

观察方法:在 InGame 中按住 Score 按钮 5 秒,看 totalScore 增长曲线。如果增长速度超过 0.3s 一次(PlayerObject 本地冷却频率),说明本地冷却被绕过或没生效。

修复方向(闭环 3):GameState 端加冷却 + stateVersion 让客户端能验证「我看到的状态版本是不是 Owner 当前认可的版本」。

缺陷 2:请求不知道自己基于哪个状态版本

Section titled “缺陷 2:请求不知道自己基于哪个状态版本”

闭环 2 的请求只带 requestTyperequestPayload,没有带「玩家按下按钮时看到的 GameState 版本」。网络抖动时,玩家本地可能还显示 LOBBY,GameState Owner 已经进入 INGAME;或者玩家本地看到的分数、阶段已经落后。GameState 端虽然会重新校验 phase,但无法区分「这是基于旧画面发出的请求」还是「这是玩家明确接受当前状态后发出的请求」。

观察方法:跑两个客户端,让 A 反复 Restart → Start,B 在画面刚变化时按 Score。看 B 的请求被 GameState 接受还是拒绝,以及 UI 提示能不能解释原因。

修复方向(闭环 3):引入 [UdonSynced] int stateVersion,GameState 每次关键状态变化时 +1;PlayerObject 发请求时带上自己观察到的 stateVersion。GameState Owner 发现请求版本落后时,可以拒绝并发回「状态已变化,请重新操作」的提示。

闭环 2 的 OnPlayerLeft 只清理了 lastProcessedRequestId,没清其他业务字段(这局没有 bonusTakenByPlayerId,但如果后续按「自己改着玩」加上奖励、击杀记录等,这一类字段会留下)。

观察方法:把 自己改着玩 第二条「加一个 bonusTakenByPlayerId」做完,让 A 拿走奖励后离开,新玩家 D 加入,看 bonusTakenByPlayerId 有没有被清。

修复方向(闭环 3):在 GameState 上加显式的「玩家离开清理回调」,业务字段一并清理;并把所有「挂在某位玩家身上」的暂态字段统一管理,离开时按表清。


  • 把策略 B 候选队列改成策略 C「按 displayName 字典序最小的玩家优先」。要改哪几个函数?
  • HandleScore 加上 GameState 端冷却(按第 13 章那段 lastProcessTime 数组)。改完后跑一遍缺陷 1 的观察方法,分数增长曲线应该被压平。
  • GameState 加一个 [UdonSynced] int bonusTakenByPlayerId,让按 Score 按钮第 5 次时认定为「奖励」。在 OnPlayerLeft 里清不清 bonusTakenByPlayerId 两种写法各跑一次,对比迟入玩家 UI 的差异。

按第 7 章五行表 + 第 9 章修复行 + 第 14 章扩展。两个或三个客户端 Build & Test。

场景预期实际
1. A 切 Ready,B 端读到B 通过 GetPLS(A).IsReady 1 秒内读到 true
2. A 和 B 各自切 Ready 互不干扰(修闭环 1 缺陷 2)两位都为 true,readyCount 显示 2/2
3. 全员 Ready 后 A 按 Startphase 切 INGAME,timer 倒数开始
4. 迟入玩家 C 加入 INGAME 中C 立刻看到 phase=INGAME, score=正确值, timer=正确剩余
5. 当前 GameState Owner 离开(修闭环 1 缺陷 1)候选队列下一位 SetOwner,phase / score 不丢,倒计时短暂顿挫但继续推进
6. 缺陷 1 现场(重复加分)长按 Score 5 秒,分数增长速度高于本地冷却频率,或改客户端绕过冷却
7. 缺陷 2 现场(状态版本乱)反复 Restart → Start → Score,Debug View 看到 phase/totalScore/phaseStartServerTime 三者短暂不一致
8. 缺陷 3 现场(串味)加上 bonusTaken 后让 A 拿走奖励再离开,D 加入看到旧记录残留

第 2、5 行是修复行;第 6、7、8 行是故意保留行。看到第 6、7、8 行确实出现现象,是闭环 2 的目标之一,作为闭环 3 的动机。


  • 能复述闭环 1 → 闭环 2 改了什么、修了哪两个缺陷
  • 能默写闭环 2 故意保留的三个缺陷与各自的观察方法
  • 能解释为什么 Start 按钮去掉了 IsMaster 限制
  • 能在 PlayerObject 本地冷却 + GameState 端冷却两层之间选择,并说明依据