闭环 2 · PlayerObject + GameState 的中级一局
这一章解决:把第二部 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 章候选队列接管
场景里要摆什么
Section titled “场景里要摆什么”相对闭环 1 的差异:
| 项目 | 闭环 1 | 闭环 2 |
|---|---|---|
| 中央对象 | GameLoop(挂 GameLoopMaster) | GameState(挂 GameState) |
| Per-player 模板 | 无 | PlayerLobbyStateTemplate(挂 VRCPlayerObject + PlayerLobbyState) |
| 按钮 | Ready / Start / Score / Restart | Ready / Start / Score / Restart(数量不变,但改成调用本地玩家自己的 PlayerLobbyState) |
| Canvas | PhaseLabel / ScoreLabel / TimerLabel / ReadyLabel | PhaseLabel / ScoreLabel / TimerLabel / ReadyCountLabel(N / 总人数) |
具体步骤:
- 删除闭环 1 的
GameLoopMaster组件(保留场景对象,重命名为GameState)。 - 给场景加一个
PlayerLobbyStateTemplate空对象,根节点同时挂VRCPlayerObject组件(来自VRC.SDK3.Persistence,Add Component里搜VRC Player Object)和下面要写的PlayerLobbyState脚本。 - Ready 按钮的
GameLoopButton改成「调用本地玩家自己 PlayerLobbyState 的 RequestToggleReady」。Score / Start / Restart 按钮改成「调用本地玩家自己 PlayerLobbyState 的 RequestScore / RequestStart / RequestRestart」。
代码 1:PlayerLobbyState.cs
Section titled “代码 1:PlayerLobbyState.cs”第 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 端的冷却 + 上限。
代码 2:GameState.cs
Section titled “代码 2:GameState.cs”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); } }}代码 3:GameLoopButton.cs(小改)
Section titled “代码 3:GameLoopButton.cs(小改)”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 改了什么
Section titled “闭环 1 → 闭环 2 改了什么”| 项目 | 闭环 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。每位玩家写自己的字段,互不影响。
闭环 2 故意保留的三个新缺陷
Section titled “闭环 2 故意保留的三个新缺陷”下面三条不是漏写,是有意保留的设计取舍。第三、四部会逐一处理。每条配测试矩阵观察方法。
缺陷 1:重复加分
Section titled “缺陷 1:重复加分”HandleScore 没有冷却、没有 per-player 加分上限。改客户端绕过 PlayerObject 本地冷却的玩家可以连续发 REQ_SCORE 请求,每个都被受理,分数无上限。即使不改客户端,正常玩家长按按钮(不同设备触发频率不同)也能看到分数加得比预期快。
观察方法:在 InGame 中按住 Score 按钮 5 秒,看 totalScore 增长曲线。如果增长速度超过 0.3s 一次(PlayerObject 本地冷却频率),说明本地冷却被绕过或没生效。
修复方向(闭环 3):GameState 端加冷却 + stateVersion 让客户端能验证「我看到的状态版本是不是 Owner 当前认可的版本」。
缺陷 2:请求不知道自己基于哪个状态版本
Section titled “缺陷 2:请求不知道自己基于哪个状态版本”闭环 2 的请求只带 requestType 和 requestPayload,没有带「玩家按下按钮时看到的 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 发现请求版本落后时,可以拒绝并发回「状态已变化,请重新操作」的提示。
缺陷 3:玩家串味状态
Section titled “缺陷 3:玩家串味状态”闭环 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 按 Start | phase 切 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 端冷却两层之间选择,并说明依据
- VRChat Creator Docs · VRCPlayerObject:PlayerObject 生命周期与 Owner 锁定。
- VRChat Creator Docs · Object Ownership:自动 Owner 转移与
SetOwner。 - VRChat Creator Docs · Network Events:
[NetworkCallable]与事件 target。 - 闭环 1 · 单 Master Owner 的极简一局:本闭环的母本。
- 第 9 章 · VRCPlayerObject:
PlayerLobbyState来源。 - 第 11 章 · GameState 三种 Owner 策略:策略 B 来源。
- 第 12 章 · 请求式架构:
requestId/lastProcessedRequestId来源。 - 第 13 章 · 权限与冲突:本地冷却 / 批准回执的来源。
- 第 14 章 · Owner 离开后的恢复:候选队列实现的母本。
- 附录 · 术语表:本闭环涉及的所有术语。