跳转到内容

对照小练 A · 把闭环 1 改成两人猜数字

约 6 分钟 难度:2

这一章解决:闭环 1 用一个具体玩法(按按钮加分)把骨架展示了一遍。本小练用一个回合制猜数字的玩法验证同一架构的迁移能力。

先看一下

闭环 1 是合作型的:所有玩家一起按 Score,没人会冲突。换一个对抗型的玩法:系统出一个 1-100 的目标数,两位玩家轮流猜,每次猜完系统给「偏高 / 偏低 / 猜中」的反馈,先猜中的人赢一局。

直觉上这个玩法和加分游戏完全不同:节奏是回合制的、有了胜负、有了系统对玩家的隐藏信息(目标数)。但实现上,三阶段 + Master Owner 同步 + 集中 Net 方法这套骨架可以原样保留,只换掉与「玩法本身」相关的字段和方法。

这一章会拿到什么

  • 一份「哪些字段保留、哪些字段替换、哪些字段新增」的对照表
  • 三处必须改的代码位置:阶段语义、字段集合、Net* 方法集合
  • 同一架构在不同玩法上的复用证据,给第二部「玩家对象与权威拓扑」预热

依赖前面

  • 闭环 1 的完整脚本和场景搭建

项目描述
玩家数固定 2 人
阶段Lobby → InGame → Result,与闭环 1 同名同结构
Lobby 退出条件两位玩家都 Ready 后 Master 按 Start
InGame 流程系统随机生成 1-100 的 targetNumber;Master 是 Owner,他先决定起手玩家;轮到的玩家本地输入 1-100,按 Submit;Owner 端比对,更新提示,切换轮次
InGame 结束条件任意一方猜中 → winnerPlayerId 落定,进入 Result
Result显示赢家、猜了多少回合、目标数。Master 按 Restart 回 Lobby

「如何在 VRChat 里给玩家一个数字输入框」是 UI 题,不是网络题,本小练给一种最小做法:场景里摆 10 个 GuessButton(数字 1-10、再大一点也行),玩家先选个十位再选个个位拼成最终猜测,按 Submit 提交。完整的 UI 实现请按各自项目里现成的输入组件做,本小练不展开。


phaselobbyReadyphaseStartServerTime 三个字段在闭环 1 里描述「合作加分游戏正在进行的当下」。本小练里它们的物理含义没变(哪一段、有没有人 Ready、阶段从什么时候开始),只是语义换成「回合制对抗游戏正在进行的当下」。保留

下表把闭环 1 的同步字段和本小练的同步字段并排:

闭环 1对照小练 A是否保留备注
byte phasebyte phase保留三阶段骨架不动
bool lobbyReadybool lobbyReady保留缺陷 2 仍在,第二部一并修
double phaseStartServerTimedouble phaseStartServerTime保留Result 屏幕也可以显示「这一局打了多久」
int totalScore(删除)替换不再是分数累加
int targetNumber新增系统出的目标数 1-100
int currentTurnPlayerId新增当前轮到的玩家 ID(VRChat 玩家 ID,跨客户端可比)
int lastGuess新增上一次提交的猜测,用于 UI
byte lastGuessHint新增0 偏低 / 1 偏高 / 2 猜中
int winnerPlayerId新增-1 表示尚未定胜

字段总字节估算:1 + 1 + 8 + 4 + 4 + 4 + 1 + 4 = 27 字节。仍然远低于第 6 章给的 Continuous 200 字节上限,本小练继续走 Manual 是为了和闭环 1 行为一致,便于对照。

闭环 1 的玩家端入口是 RequestScore:任何玩家按按钮,请求 Owner 给总分 +1,请求本身不带数据。

本小练换成 RequestGuess(int guess):当前轮次玩家本地凑出一个数字,把数字直接当参数发给 Owner。

public void RequestGuess(int guess)
{
// 本地预校验:不是我的回合就不发,省一次往返
if (Networking.LocalPlayer == null) return;
if (currentTurnPlayerId != Networking.LocalPlayer.playerId) return;
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(NetSubmitGuess), guess);
}

SendCustomNetworkEvent 自 VRChat SDK 3.8.1 起接受可变参数,完整签名是 (NetworkEventTarget target, string methodName, params object[] args)。被调用方法需要加 [NetworkCallable] 属性,参数类型沿用同步变量允许的那一套,最多 8 个,单次载荷上限 16 KB(超过 1 KB 会被分片发送)。本小练只用一个 int 参数;参数化事件的完整能力、限制、与 synced 变量的取舍,第四部第 21 章 会再展开。

文件顶部需要补两个 using:

using VRC.Udon.Common.Interfaces;
using VRC.SDK3.UdonNetworkCalling;

闭环 1 的 NetAddScore 三行:检查阶段、totalScore++RequestSerialization

本小练的 NetSubmitGuess 替换成完整的回合处理,把 guess 当参数收下来:

[NetworkCallable]
public void NetSubmitGuess(int guess)
{
if (!Networking.IsOwner(gameObject)) return;
if (phase != PHASE_INGAME) return;
if (winnerPlayerId >= 0) return; // 已经结束
lastGuess = guess;
if (guess == targetNumber)
{
lastGuessHint = 2;
winnerPlayerId = currentTurnPlayerId;
phase = PHASE_RESULT;
}
else
{
lastGuessHint = (byte)(guess < targetNumber ? 0 : 1);
// 切到对手回合
currentTurnPlayerId = OtherPlayerId(currentTurnPlayerId);
}
RequestSerialization();
ApplyState();
}
private int OtherPlayerId(int current)
{
// 本小练假设场景里两位玩家。一种实现是 OnPlayerJoined 时记下 player IDs
// 真实项目里这一段会被 PlayerObject + 玩家列表替换(第二部 9 章)
return /* … */;
}

OtherPlayerId 的具体实现这里不展开,因为它马上就会被第二部第 9 章的 VRCPlayerObject 替换掉。本小练给一个最朴素的版本即可:在 OnPlayerJoined / OnPlayerLeft 里维护一个长度 2 的 playerIds 缓存数组。

NetStart 也要改:从「记录开始时间」扩展为「随机生成 targetNumber、决定起手玩家」。

public void NetStart()
{
if (!Networking.IsOwner(gameObject)) return;
if (phase != PHASE_LOBBY) return;
if (!lobbyReady) return;
targetNumber = Random.Range(1, 101); // 仅 Owner 生成。本地随机够用,因为只 Owner 用
currentTurnPlayerId = playerIds[0]; // 起手玩家:见上文缓存
lastGuess = 0;
lastGuessHint = 0;
winnerPlayerId = -1;
phase = PHASE_INGAME;
phaseStartServerTime = Networking.GetServerTimeInSeconds();
RequestSerialization();
ApplyState();
}

NetRestart 在 Reset 字段上多加几行:把 targetNumbercurrentTurnPlayerIdlastGuesslastGuessHintwinnerPlayerId 全部回零。ApplyState 里多加几行:把目标数字仅显示给 Master(或全场不显示,结算时才揭晓),把当前轮次玩家用 VRCPlayerApi.GetPlayerById(currentTurnPlayerId)?.displayName 渲染到 UI。

Update 里的「时间到 → Result」逻辑可以删掉。本玩法没有时间限制,唯一的结束条件是猜中。


把上面三处合起来看:

模块保留替换
阶段机phase 三态、lobbyReady、Lobby/InGame/Result 切换边界(无)
Owner 拓扑Master Owner、SendCustomNetworkEvent(NetworkEventTarget.Owner, ...) 请求模式(无)
同步生命周期Manual 模式、RequestSerialization 在每个 Net* 末尾、OnDeserialization 刷 UI(无)
玩法字段(无)加分 → 目标数 + 当前轮次 + 上回合反馈 + 赢家
玩法入口(无)RequestScoreRequestGuess(int),使用参数化的 SendCustomNetworkEvent
玩法处理(无)NetAddScore 三行 → NetSubmitGuess 完整回合判定

保留区是骨架,替换区是玩法。 这就是「同一架构换玩法」的实际含义。第二部第 9 章把保留区的「Master Owner」这一点替换成 VRCPlayerObject + GameState,对照下来会看到本小练里那一句「真实项目里这一段会被 PlayerObject + 玩家列表替换」具体长什么样。


  • 能解释闭环 1 和本小练在状态结构上的差异,并说出哪些字段是「玩法独有的」、哪些字段是「骨架共用的」
  • 能写出参数化 SendCustomNetworkEvent 的完整签名,并说出 [NetworkCallable] 的几条硬性限制
  • NetSubmitGuess 里能自己写出「猜中 → 切阶段」「未猜中 → 切轮次」的两条分支
  • 能指出本小练里和闭环 1 一样仍然存在的两个缺陷(Master Owner 兜底 + lobbyReady 共用),并说明第二部哪一章修哪一条