跳转到内容

闭环 3 · 加上命令版本号的工程化一局

约 8 分钟 难度: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 + OnDeserialization gating 模式
  • 第 22 章命令日志(参考,本闭环不接)
  • 闭环 2 完整骨架

相对闭环 2 的差异:

项目闭环 2闭环 3
GameState 同步模式ManualManual(不变)
新增字段stateVersion(int)+ lastHandleScoreTime[](double,per-player)
HandleScoretotalScore++if (TooHotForScore(who)) return; totalScore++;
OnPreSerializationstateVersion += 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 关联,纯做版本标记。lastHandleScoreTimeplayerId 索引(同 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 端冷却挡改客户端绕过本地冷却的玩家。两层都要。

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 上)。自己改着玩 第三条让读者扩展玩法时把清理逻辑写进这一回调。

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;
}
}
}

照抄闭环 2 的版本。回链:闭环 2 GameLoopButton.cs


项目闭环 2闭环 3
stateVersion 字段[UdonSynced] int stateVersion,每次 OnPreSerialization ++
OnDeserialization 应用直接 ApplyState()gating:版本变化才 ApplyState()
HandleScore 冷却仅 PlayerObject 本地(被改客户端绕过)本地 + GameState 端双层
lastHandleScoreTime[]per-player 数组,按受控玩家槽位索引
OnPlayerLeft 清理lastProcessedRequestId[]lastHandleScoreTime[] 槽 + OnPlayerLeftCleanup(player) 回调
业务字段清理入口OnPlayerLeftCleanup 函数(本闭环为空,扩展玩法填)

修法HandleScore 头部加 TooHotForScore 检查。每位玩家加分最低间隔 0.5 秒(scoreCooldown 可调)。

为什么这样:本地冷却(PlayerObject 上 requestCooldown = 0.3f)挡正常玩家手抖。改客户端的玩家可以发送 requestId 递增超快的请求,被 requestId > lastProcessedRequestId 通过,本地冷却被绕过。GameState 端冷却基于服务器时间,玩家本地时间篡改无效,是这一层的兜底。

观察:测试矩阵第 6 行。长按 Score 按钮 5 秒,分数增长被 scoreCooldown 拍平到约 10 / 5 秒 = 2 分/秒。

修法OnPreSerializationstateVersion += 1OnDeserialization 比较版本号决定是否 ApplyState

为什么这样:闭环 2 远端客户端的 UI 直接绑死在 OnDeserialization。当 phase / totalScore / phaseStartServerTime 三个字段在多帧间被分批同步时,UI 在中间状态下渲染。gating 把 UI 渲染时机绑到 stateVersion 变化上,所有字段一起切到下一版。

观察:测试矩阵第 7 行。反复 Restart → Start → Score 几次,开 Debug View 看远端 UI,闭环 3 中 UI 不会闪现 phase=INGAMEtotalScore=0 的瞬间。

修法OnPlayerLeftOnPlayerLeftCleanup(player)。本闭环最小版方法体为空,因为没有 per-player 业务字段;扩展玩法(自己改着玩第三条)填具体清理逻辑。

为什么这样:业务字段(bonusTakenByPlayerId / claimedReward[] / per-player 任务进度)和系统字段(lastProcessedRequestId[] / ownerCandidateOrder[])混在一起清是常见错误。系统字段闭环 2 已经清;业务字段闭环 2 漏了。把清理统一到 OnPlayerLeftCleanup 这一专门入口,避免「加新字段时漏清」。

观察:测试矩阵第 8 行。把扩展玩法的 bonusTakenByPlayerId 字段加上后跑「A 拿奖励 → A 离开 → D 加入」,看 D 端 UI 是否还显示 A。


第 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 = -1HandleScore 里第 5 次得分时改 bonusTakenByPlayerId = who.playerId 标记奖励归属。在 OnPlayerLeftCleanup(player) 里加一行:如果 bonusTakenByPlayerId == player.playerId= -1。跑「A 拿奖励 → A 离开 → D 加入」三个客户端,观察 D 看到的奖励归属。
  • stateVersionint 改成 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)