闭环 3 · 加上命令版本号的工程化一局
这一章解决:闭环 2 故意保留了三个缺陷(重复加分 / 状态版本乱 / 玩家串味)。本闭环加
stateVersion+ GameState 端冷却 +OnPlayerLeft清理回调,把三个缺陷一次性修完。
先看一下
闭环 2 跑起来三人房一局:长按 Score 按钮分数加得比预期快(缺陷 1);反复 Restart → Start → Score 时远端 UI 偶尔闪一下(缺陷 2);带 bonusTakenByPlayerId 的扩展玩法上玩家 A 拿了奖励再离开,新进来的 D 看到 A 的旧记录(缺陷 3)。
本闭环把这三件事全修完。修法都已经在第 20 章铺垫过:requestId 幂等已经在闭环 2 里有了;stateVersion + OnDeserialization gating 是新加的;OnPlayerLeft 清理回调是新写的。
闭环 3 之后玩法层面的能力够用,第八部主项目(合作防守世界)再做完整版。本闭环不留新缺陷给闭环 4,因为闭环 4 在第六部「体感」里出现,是主题切换,不是同一系统的迭代。
这一章会拿到什么
- 一份能跑通的完整代码:
PlayerLobbyState(不变)+GameState(增量)+GameLoopButton(不变) - 闭环 2 三个缺陷各自的修复方式与测试矩阵观察
- 闭环 2 → 闭环 3 的「改了什么」对照表
- 闭环 3 不引入命令日志的判断依据
依赖前面
- 第 20 章
stateVersion+OnDeserializationgating 模式 - 第 22 章命令日志(参考,本闭环不接)
- 闭环 2 完整骨架
场景里要摆什么
Section titled “场景里要摆什么”相对闭环 2 的差异:
| 项目 | 闭环 2 | 闭环 3 |
|---|---|---|
GameState 同步模式 | Manual | Manual(不变) |
| 新增字段 | — | stateVersion(int)+ lastHandleScoreTime[](double,per-player) |
HandleScore | totalScore++ | if (TooHotForScore(who)) return; totalScore++; |
OnPreSerialization | 无 | stateVersion += 1; |
OnDeserialization | 直接 ApplyState() | 比较 stateVersion,没变就跳过 |
OnPlayerLeft | 仅清 lastProcessedRequestId[] 槽 | 同时调用 OnPlayerLeftCleanup 清业务字段 |
具体步骤:
- 在闭环 2 场景上不改 GameObject 结构。
GameState上的[UdonSynced]字段只是新增。 PlayerLobbyState完全不动。GameLoopButton也不动。- Inspector 上新加一个
lastHandleScoreTime数组的初始大小(和lastProcessedRequestId一致,100)。
代码 1:PlayerLobbyState.cs(不变)
Section titled “代码 1:PlayerLobbyState.cs(不变)”照抄闭环 2 的版本。本闭环修的是 GameState 端,玩家本地的请求字段流不变。
回链:闭环 2 PlayerLobbyState.cs 完整版。
代码 2:GameState.cs(增量 + 完整)
Section titled “代码 2:GameState.cs(增量 + 完整)”// 状态版本号 + per-handler 冷却(闭环 3 新增)[UdonSynced] private int stateVersion;[UdonSynced] private double[] lastHandleScoreTime = new double[100];
public float scoreCooldown = 0.5f; // GameState 端冷却,每位玩家加分最低 0.5 秒间隔stateVersion 不和 UI 关联,纯做版本标记。lastHandleScoreTime 按 playerId 索引(同 lastProcessedRequestId),记每位玩家最后一次成功 HandleScore 的服务器时间。
public override void OnPreSerialization(){ stateVersion += 1;}
public override void OnDeserialization(){ if (stateVersion == lastAppliedStateVersion) return; // gating lastAppliedStateVersion = stateVersion; ApplyState();}
private int lastAppliedStateVersion = -1; // 客户端本地缓存OnPreSerialization 在 Owner 调用 RequestSerialization 后、实际发送前的本帧执行一次。每次 Owner 决定要发送一批字段时版本号自动 +1。
OnDeserialization 在远端反序列化完成后执行,加 gating 后只在版本号真的变化时才应用整批字段,跳过中间态。
// HandleScore 增量:加 GameState 端冷却private void HandleScore(VRCPlayerApi who){ if (phase != PHASE_INGAME) return; if (TooHotForScore(who)) return; // GameState 端冷却(闭环 3 新增) totalScore++; lastHandleScoreTime[who.playerId] = Networking.GetServerTimeInSeconds(); ApplyState();}
private bool TooHotForScore(VRCPlayerApi who){ if (who.playerId >= lastHandleScoreTime.Length) return false; double now = Networking.GetServerTimeInSeconds(); double last = lastHandleScoreTime[who.playerId]; return (now - last) < scoreCooldown;}TooHotForScore 在 GameState 端检查每位玩家加分最低间隔。本地冷却(在 PlayerLobbyState 上)挡正常玩家手抖;GameState 端冷却挡改客户端绕过本地冷却的玩家。两层都要。
增量 OnPlayerLeft
Section titled “增量 OnPlayerLeft”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; if (player.playerId < lastHandleScoreTime.Length) lastHandleScoreTime[player.playerId] = 0;
OnPlayerLeftCleanup(player); // 闭环 3 新增
RequestSerialization(); return; }
// Owner 接管路径与闭环 2 相同 var current = Networking.GetOwner(gameObject); if (current != null && current.IsValid()) return; int nextPlayerId = (ownerCandidateCount > 0) ? ownerCandidateOrder[0] : -1; if (Networking.LocalPlayer != null && Networking.LocalPlayer.playerId == nextPlayerId) { Networking.SetOwner(Networking.LocalPlayer, gameObject); }}
// 业务字段清理回调(闭环 3 新增)private void OnPlayerLeftCleanup(VRCPlayerApi player){ // 玩家相关的业务字段在这里清。本闭环没有 bonusTakenByPlayerId // 之类的字段;扩展玩法(自己改着玩第三条)会用上这一回调。 // 例:if (bonusTakenByPlayerId == player.playerId) bonusTakenByPlayerId = -1;}OnPlayerLeftCleanup 是「闭环 2 缺陷 3」修复的入口。本闭环代码里的方法体是空的,因为闭环 2 / 3 的最小版本没有 per-player 的业务字段(只有 totalScore 全局 + personalScore 在 PlayerObject 上)。自己改着玩 第三条让读者扩展玩法时把清理逻辑写进这一回调。
完整版(合订)
Section titled “完整版(合订)”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; public float scoreCooldown = 0.5f; // GameState 端冷却
// 共享状态 [UdonSynced] private byte phase; [UdonSynced] private int totalScore; [UdonSynced] private double phaseStartServerTime; [UdonSynced] private int stateVersion; // 闭环 3 新增
// 请求处理记忆 [UdonSynced] private int[] lastProcessedRequestId = new int[100]; [UdonSynced] private double[] lastHandleScoreTime = new double[100]; // 闭环 3 新增
// Owner 候选队列 [UdonSynced] private int[] ownerCandidateOrder = new int[100]; [UdonSynced] private int ownerCandidateCount = 0;
// 客户端本地缓存(不同步) private int lastAppliedStateVersion = -1;
private void Start() { ApplyState(); }
public override void OnPreSerialization() { stateVersion += 1; // 闭环 3 新增 }
public override void OnDeserialization() { if (stateVersion == lastAppliedStateVersion) return; // gating(闭环 3 新增) lastAppliedStateVersion = stateVersion; ApplyState(); }
public override void OnPostSerialization(SerializationResult result) { if (!result.success) { Debug.LogWarning("[GameState] sync failed at phase=" + phase); } }
private void Update() { // 客户端本地的 timer 显示(不依赖 stateVersion) 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 (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;
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); 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; if (TooHotForScore(who)) return; // 闭环 3 新增 totalScore++; lastHandleScoreTime[who.playerId] = Networking.GetServerTimeInSeconds(); ApplyState(); }
private void HandleRestart(VRCPlayerApi who) { if (phase != PHASE_RESULT) return; phase = PHASE_LOBBY; totalScore = 0; ApplyState(); }
private bool TooHotForScore(VRCPlayerApi who) { if (who.playerId >= lastHandleScoreTime.Length) return false; double now = Networking.GetServerTimeInSeconds(); double last = lastHandleScoreTime[who.playerId]; return (now - last) < scoreCooldown; }
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; }
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; }
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; if (player.playerId < lastHandleScoreTime.Length) lastHandleScoreTime[player.playerId] = 0;
OnPlayerLeftCleanup(player); // 闭环 3 新增
RequestSerialization(); return; }
var current = Networking.GetOwner(gameObject); if (current != null && current.IsValid()) return;
int nextPlayerId = (ownerCandidateCount > 0) ? ownerCandidateOrder[0] : -1; if (Networking.LocalPlayer != null && Networking.LocalPlayer.playerId == nextPlayerId) { Networking.SetOwner(Networking.LocalPlayer, gameObject); } }
public override void OnOwnershipTransferred(VRCPlayerApi player) { if (!Networking.IsOwner(gameObject)) return;
if (Networking.LocalPlayer != null) { RemoveFromCandidates(Networking.LocalPlayer.playerId); RequestSerialization(); } SanityCheckAfterTakeover(); }
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--; }
// 业务字段清理回调(闭环 3 新增) private void OnPlayerLeftCleanup(VRCPlayerApi player) { // 本闭环最小版没有 per-player 业务字段。 // 扩展玩法(如 bonusTakenByPlayerId、claimedRewards 等)在这里清。 }
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; } }}代码 3:GameLoopButton.cs(不变)
Section titled “代码 3:GameLoopButton.cs(不变)”照抄闭环 2 的版本。回链:闭环 2 GameLoopButton.cs。
闭环 2 → 闭环 3 改了什么
Section titled “闭环 2 → 闭环 3 改了什么”| 项目 | 闭环 2 | 闭环 3 |
|---|---|---|
stateVersion 字段 | 无 | [UdonSynced] int stateVersion,每次 OnPreSerialization ++ |
OnDeserialization 应用 | 直接 ApplyState() | gating:版本变化才 ApplyState() |
HandleScore 冷却 | 仅 PlayerObject 本地(被改客户端绕过) | 本地 + GameState 端双层 |
lastHandleScoreTime[] | 无 | per-player 数组,按受控玩家槽位索引 |
OnPlayerLeft 清理 | 仅 lastProcessedRequestId[] | 加 lastHandleScoreTime[] 槽 + OnPlayerLeftCleanup(player) 回调 |
| 业务字段清理入口 | 无 | OnPlayerLeftCleanup 函数(本闭环为空,扩展玩法填) |
闭环 2 三个缺陷的修复
Section titled “闭环 2 三个缺陷的修复”缺陷 1 修复:重复加分
Section titled “缺陷 1 修复:重复加分”修法:HandleScore 头部加 TooHotForScore 检查。每位玩家加分最低间隔 0.5 秒(scoreCooldown 可调)。
为什么这样:本地冷却(PlayerObject 上 requestCooldown = 0.3f)挡正常玩家手抖。改客户端的玩家可以发送 requestId 递增超快的请求,被 requestId > lastProcessedRequestId 通过,本地冷却被绕过。GameState 端冷却基于服务器时间,玩家本地时间篡改无效,是这一层的兜底。
观察:测试矩阵第 6 行。长按 Score 按钮 5 秒,分数增长被 scoreCooldown 拍平到约 10 / 5 秒 = 2 分/秒。
缺陷 2 修复:状态版本乱
Section titled “缺陷 2 修复:状态版本乱”修法:OnPreSerialization 让 stateVersion += 1,OnDeserialization 比较版本号决定是否 ApplyState。
为什么这样:闭环 2 远端客户端的 UI 直接绑死在 OnDeserialization。当 phase / totalScore / phaseStartServerTime 三个字段在多帧间被分批同步时,UI 在中间状态下渲染。gating 把 UI 渲染时机绑到 stateVersion 变化上,所有字段一起切到下一版。
观察:测试矩阵第 7 行。反复 Restart → Start → Score 几次,开 Debug View 看远端 UI,闭环 3 中 UI 不会闪现 phase=INGAME 但 totalScore=0 的瞬间。
缺陷 3 修复:玩家串味状态
Section titled “缺陷 3 修复:玩家串味状态”修法:OnPlayerLeft 调 OnPlayerLeftCleanup(player)。本闭环最小版方法体为空,因为没有 per-player 业务字段;扩展玩法(自己改着玩第三条)填具体清理逻辑。
为什么这样:业务字段(bonusTakenByPlayerId / claimedReward[] / per-player 任务进度)和系统字段(lastProcessedRequestId[] / ownerCandidateOrder[])混在一起清是常见错误。系统字段闭环 2 已经清;业务字段闭环 2 漏了。把清理统一到 OnPlayerLeftCleanup 这一专门入口,避免「加新字段时漏清」。
观察:测试矩阵第 8 行。把扩展玩法的 bonusTakenByPlayerId 字段加上后跑「A 拿奖励 → A 离开 → D 加入」,看 D 端 UI 是否还显示 A。
不接命令日志的判断
Section titled “不接命令日志的判断”第 22 章给了命令日志骨架(cmdRequestId[] / cmdPlayerId[] / cmdType[] / cmdPayload[] / cmdServerLikeTime[] 五个并行数组 + 环形指针)。本闭环不接它,理由是:
- 目标是修缺陷,不是加能力:闭环 2 的三个缺陷靠
stateVersion+ 冷却 + 清理就够。命令日志解决的是「需要复盘 / 战报 / 审计」,本闭环的最小骨架没有这一需求。 - 同步成本不必扛:命令日志按 32 容量算约 680 bytes 单包,本闭环 5 个 GameState 字段约 30 bytes 单包。多接命令日志带宽涨 22 倍,没有等价收益。
- 复杂度不必加:命令日志要解决「迟入恢复时 cmdHead 同步」「Owner 转移时数组 indexing」等几个细节问题。本闭环要修三个缺陷而已。
第 22 章的工具是预备给后续主项目(第八部合作防守世界)的:那一类世界要做战报、要做反作弊近似异常分数检测,命令日志成立。
挑一条试。
- 把
scoreCooldown从 0.5f 调到 2.0f,跑一局观察玩家体验。冷却太长的副作用是什么?「正常的连续得分」会被误伤吗?冷却参数的合理范围是多少? - 在
GameState上加[UdonSynced] int bonusTakenByPlayerId = -1。HandleScore里第 5 次得分时改bonusTakenByPlayerId = who.playerId标记奖励归属。在OnPlayerLeftCleanup(player)里加一行:如果bonusTakenByPlayerId == player.playerId就= -1。跑「A 拿奖励 → A 离开 → D 加入」三个客户端,观察 D 看到的奖励归属。 - 把
stateVersion从int改成byte。每 256 次RequestSerialization后会发生什么? 远端 gating 会被卡在什么场景下?字段类型选int的合理性是什么?
按闭环 2 八行表的扩展。两到三个客户端 Build & Test。
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. A 切 Ready,B 端读到 | 与闭环 2 同 | … |
| 2. A 和 B 各自切 Ready 互不干扰 | 与闭环 2 同 | … |
| 3. 全员 Ready 后 A 按 Start | 与闭环 2 同 | … |
| 4. 迟入玩家 C 加入 INGAME 中 | C 一次性看到正确 phase / score / timer,不闪烁中间态 | … |
| 5. 当前 GameState Owner 离开 | 候选队列下一位接管,stateVersion 继续递增不回退 | … |
| 6. 缺陷 1 修复(重复加分) | 长按 Score 5 秒,分数增长被 0.5s 冷却拍平 | … |
| 7. 缺陷 2 修复(状态版本乱) | 反复 Restart → Start → Score,远端 UI 不闪现中间态 | … |
| 8. 缺陷 3 修复(串味) | 加 bonusTakenByPlayerId 后跑 A 拿走奖励 → 离开 → D 加入,D 端字段清空 | … |
| 9. stateVersion 单调性 | 任何场景下版本号单调递增,跨 Owner 转移仍递增 | … |
第 6、7、8 行是闭环 3 修复行。第 4 行的「不闪烁」是 gating 副作用:迟入恢复也是「一次性整批应用」。
- 能默写闭环 2 → 闭环 3 改了什么、修了哪三个缺陷
- 能解释
stateVersion为什么放在OnPreSerialization,不在改字段时 ++ - 能解释为什么本地冷却 + GameState 端冷却两层都需要
- 能识别
OnPlayerLeftCleanup的扩展点(业务字段在哪里加清理逻辑) - 能给出本闭环不接命令日志的判断
- 能识别
stateVersion+ gating 的设计前提(manual sync)
- 闭环 2 · PlayerObject + GameState 的中级一局 — 本闭环的母本与三个故意保留的缺陷。
- 第 12 章 · 请求式架构 —
requestId/lastProcessedRequestId[]字段命名。 - 第 14 章 · Owner 离开后的恢复 — 候选队列与
OnPlayerLeft入口。 - 第 19 章 · 状态同步还是命令同步 — 本闭环为什么不全部走命令同步。
- 第 20 章 · 版本号、序号与幂等 —
stateVersion+ gating 的设计来源。 - 第 21 章 · 参数化 Network Event 与决策表 — 通道选择的依据。
- 第 22 章 · 命令模式与事件日志 — 本闭环不接命令日志的判断。
- 附录 · 术语表 — 本闭环涉及的所有术语。