对照小练 B · 用 PlayerObject 做两人回合制猜数字
这一练解决:把对照小练 A 的猜数字玩法搬到第二部 PlayerObject + GameState 架构上。玩法不变,权威拓扑改,看代码差异。
先看一下
对照小练 A 用闭环 1 骨架:所有同步状态归 GameLoopMaster(Master Owner),lobbyReady 全局共用。本练把权威拓扑切到第二部架构:每位玩家的 Ready 归他自己的 PlayerLobbyState,目标数 / 当前轮次 / 胜负落定归 GameState。玩家「我猜 X」用第 12 章的请求字段。
这是「同一玩法换权威拓扑」的对照。和对照小练 A 的「同一架构换玩法」配对:A 验证骨架可以复用,B 验证权威拓扑可以独立替换。
这一章会拿到什么
- 一份「字段归属对照表」:哪些归 PlayerObject,哪些归 GameState,哪些消失
RequestGuess(int guess)在请求式架构下的写法- 同一玩法在闭环 1 vs 闭环 2 两套架构上的代码量对比
依赖前面
- 对照小练 A · 两人猜数字
- 闭环 2 · PlayerObject + GameState 中级一局
- 第 12 章请求字段命名
和对照小练 A 完全一致:
- 固定 2 人。
- LOBBY → INGAME → RESULT 三阶段。
- 系统出 1–100 的
targetNumber,两位玩家轮流猜,提示偏高 / 偏低 / 猜中。 - 先猜中的人赢一局。
字段归属对照
Section titled “字段归属对照”| 字段 | 对照小练 A | 对照小练 B |
|---|---|---|
phase | GameLoopMaster 全局 | GameState 全局(不变) |
lobbyReady | GameLoopMaster 全局(缺陷) | 每位玩家 PlayerLobbyState.isReady |
targetNumber | GameLoopMaster 全局 | GameState Owner 本地字段(教学原型不同步隐藏答案) |
currentTurnPlayerId | GameLoopMaster 全局 | GameState 全局 |
lastGuess | GameLoopMaster 全局 | GameState 全局 |
lastGuessHint | GameLoopMaster 全局 | GameState 全局 |
winnerPlayerId | GameLoopMaster 全局 | GameState 全局 |
RequestGuess(guess) 调用链 | 玩家 → SendCustomNetworkEvent(target=Owner, NetSubmitGuess, guess) → Master 端 | 玩家 → IssueRequest(REQ_GUESS, guess) → 写自己 PlayerObject → GameState Owner 处理 |
| Owner 离开 | 自动转给新 Master,无候选队列 | 候选队列接管,SanityCheckAfterTakeover 处理 |
「轮次 / 胜负」是天然的全局字段,没有歧义放 GameState。「目标数」也由 GameState Owner 生成和裁决,但教学原型里不加 [UdonSynced],否则所有客户端都能从同步字段里看到答案。Ready 是 per-player 字段,挪到 PlayerObject。这是两边的本质差异。
PlayerObject 端:扩展 PlayerLobbyState
Section titled “PlayerObject 端:扩展 PlayerLobbyState”在闭环 2 的 PlayerLobbyState 上加一个 REQ_GUESS 类型即可。
public const byte REQ_GUESS = 5;
public void RequestGuess(int guess){ if (!Networking.IsOwner(gameObject)) return;
// 本地预校验:不是我的回合就不发 if (gameState != null && gameState.CurrentTurnPlayerId != Networking.LocalPlayer.playerId) return;
IssueRequest(REQ_GUESS, guess);}IssueRequest 是闭环 2 已有的通用方法,本地冷却复用。
GameState 端:加几个字段和处理函数
Section titled “GameState 端:加几个字段和处理函数”private int targetNumber; // 隐藏答案,不同步。Owner 转移后的恢复留给后续版本协议处理[UdonSynced] private int currentTurnPlayerId;[UdonSynced] private int lastGuess;[UdonSynced] private byte lastGuessHint; // 0 偏低, 1 偏高, 2 猜中[UdonSynced] private int winnerPlayerId = -1;
public int CurrentTurnPlayerId => currentTurnPlayerId;public int WinnerPlayerId => winnerPlayerId;
private void HandleStart(VRCPlayerApi who){ if (phase != PHASE_LOBBY) return; if (!AllPlayersReady()) return; if (VRCPlayerApi.GetPlayerCount() != 2) return; // 玩法限定 2 人
targetNumber = Random.Range(1, 101); currentTurnPlayerId = PickFirstPlayer(); lastGuess = 0; lastGuessHint = 0; winnerPlayerId = -1; phase = PHASE_INGAME; phaseStartServerTime = Networking.GetServerTimeInSeconds(); ApplyState();}
private int PickFirstPlayer(){ int count = VRCPlayerApi.GetPlayerCount(); var players = new VRCPlayerApi[count]; VRCPlayerApi.GetPlayers(players); // 第一位有效玩家。也可以按候选队列规则 for (int i = 0; i < count; i++) { if (players[i] != null && players[i].IsValid()) return players[i].playerId; } return -1;}
private void HandleGuess(VRCPlayerApi who, int guess){ if (phase != PHASE_INGAME) return; if (winnerPlayerId >= 0) return; if (who.playerId != currentTurnPlayerId) return; // GameState 端再校验一次轮次
lastGuess = guess; if (guess == targetNumber) { lastGuessHint = 2; winnerPlayerId = currentTurnPlayerId; phase = PHASE_RESULT; } else { lastGuessHint = (byte)(guess < targetNumber ? 0 : 1); currentTurnPlayerId = OtherPlayerId(currentTurnPlayerId); } ApplyState();}
private int OtherPlayerId(int current){ int count = VRCPlayerApi.GetPlayerCount(); var players = new VRCPlayerApi[count]; VRCPlayerApi.GetPlayers(players); for (int i = 0; i < count; i++) { if (players[i] != null && players[i].IsValid() && players[i].playerId != current) return players[i].playerId; } return current;}ProcessRequest 里加一个分支:
else if (type == PlayerLobbyState.REQ_GUESS) HandleGuess(who, pls.RequestPayload);OtherPlayerId 不再需要对照小练 A 里那种「在 OnPlayerJoined 缓存数组」的写法。直接遍历 VRCPlayerApi 列表,因为玩家信息是 SDK 全局可查的。这是 PlayerObject 架构的副作用:很多围绕「玩家身份维护」的脚手架不再需要。
同一玩法的代码量对比
Section titled “同一玩法的代码量对比”把对照小练 A 的 GameLoopMaster.cs 和本练的 GameState.cs(新增的部分)放并排:
| 部分 | 对照小练 A | 对照小练 B |
|---|---|---|
Net* 方法 | NetToggleReady / NetStart / NetSubmitGuess / NetRestart 四个 | 不需要专门的 Net 方法,HandleStart / HandleGuess / HandleRestart 是 GameState 端处理 |
| Master 限制 | RequestStart / RequestRestart 都有 if (!IsMaster) return; | 全部去掉 |
| 玩家身份缓存 | OnPlayerJoined / OnPlayerLeft 维护 playerIds[2] 数组 | 不需要 |
| Ready 字段 | 全局 bool lobbyReady(缺陷) | 每位玩家 PlayerLobbyState.isReady |
| Owner 离开 | Master 自动转,无显式接管 | 候选队列接管 |
代码量本身两边接近(GameState 多了候选队列),但心智模型复杂度对照 B 更低:每一行代码都能回答「写权属于谁、读权属于谁、Owner 离开时怎么办」三个问题。对照 A 在这些问题上回答模糊。
- 能复述目标数 / 轮次 / 胜负为什么放 GameState 而不是 PlayerObject
- 能写出
RequestGuess在闭环 2 架构下的完整调用链(玩家 → IssueRequest → RequestSerialization → GameState Owner → HandleGuess) - 能解释 GameState 端
HandleGuess里为什么还要再校验一次who.playerId != currentTurnPlayerId,即使 PlayerObject 端已经预校验过
- 对照小练 A · Master Owner 版猜数字:本练对照的母本。
- 闭环 2 · PlayerObject + GameState:本练的架构基底。
- 第 12 章 · 请求式架构:
IssueRequest与requestId来源。 - 附录 · 术语表:
Turn、Hidden Information、Per-Player Cooldown的客观定义。