跳转到内容

对照小练 B · 用 PlayerObject 做两人回合制猜数字

约 3 分钟 难度:2

这一练解决:把对照小练 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 人。
  • LOBBY → INGAME → RESULT 三阶段。
  • 系统出 1–100 的 targetNumber,两位玩家轮流猜,提示偏高 / 偏低 / 猜中。
  • 先猜中的人赢一局。

字段对照小练 A对照小练 B
phaseGameLoopMaster 全局GameState 全局(不变)
lobbyReadyGameLoopMaster 全局(缺陷)每位玩家 PlayerLobbyState.isReady
targetNumberGameLoopMaster 全局GameState Owner 本地字段(教学原型不同步隐藏答案)
currentTurnPlayerIdGameLoopMaster 全局GameState 全局
lastGuessGameLoopMaster 全局GameState 全局
lastGuessHintGameLoopMaster 全局GameState 全局
winnerPlayerIdGameLoopMaster 全局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 架构的副作用:很多围绕「玩家身份维护」的脚手架不再需要。


把对照小练 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 端已经预校验过