对照小练 A · 把闭环 1 改成两人猜数字
这一章解决:闭环 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 实现请按各自项目里现成的输入组件做,本小练不展开。
三处必须改的位置
Section titled “三处必须改的位置”1. 阶段语义不变,字段集换掉
Section titled “1. 阶段语义不变,字段集换掉”phase、lobbyReady、phaseStartServerTime 三个字段在闭环 1 里描述「合作加分游戏正在进行的当下」。本小练里它们的物理含义没变(哪一段、有没有人 Ready、阶段从什么时候开始),只是语义换成「回合制对抗游戏正在进行的当下」。保留。
下表把闭环 1 的同步字段和本小练的同步字段并排:
| 闭环 1 | 对照小练 A | 是否保留 | 备注 |
|---|---|---|---|
byte phase | byte phase | 保留 | 三阶段骨架不动 |
bool lobbyReady | bool lobbyReady | 保留 | 缺陷 2 仍在,第二部一并修 |
double phaseStartServerTime | double 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 行为一致,便于对照。
2. 玩家端入口换掉
Section titled “2. 玩家端入口换掉”闭环 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;3. Owner 端处理换掉
Section titled “3. Owner 端处理换掉”闭环 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 字段上多加几行:把 targetNumber、currentTurnPlayerId、lastGuess、lastGuessHint、winnerPlayerId 全部回零。ApplyState 里多加几行:把目标数字仅显示给 Master(或全场不显示,结算时才揭晓),把当前轮次玩家用 VRCPlayerApi.GetPlayerById(currentTurnPlayerId)?.displayName 渲染到 UI。
Update 里的「时间到 → Result」逻辑可以删掉。本玩法没有时间限制,唯一的结束条件是猜中。
一份「保留 vs 替换」的视图
Section titled “一份「保留 vs 替换」的视图”把上面三处合起来看:
| 模块 | 保留 | 替换 |
|---|---|---|
| 阶段机 | phase 三态、lobbyReady、Lobby/InGame/Result 切换边界 | (无) |
| Owner 拓扑 | Master Owner、SendCustomNetworkEvent(NetworkEventTarget.Owner, ...) 请求模式 | (无) |
| 同步生命周期 | Manual 模式、RequestSerialization 在每个 Net* 末尾、OnDeserialization 刷 UI | (无) |
| 玩法字段 | (无) | 加分 → 目标数 + 当前轮次 + 上回合反馈 + 赢家 |
| 玩法入口 | (无) | RequestScore → RequestGuess(int),使用参数化的 SendCustomNetworkEvent |
| 玩法处理 | (无) | NetAddScore 三行 → NetSubmitGuess 完整回合判定 |
保留区是骨架,替换区是玩法。 这就是「同一架构换玩法」的实际含义。第二部第 9 章把保留区的「Master Owner」这一点替换成 VRCPlayerObject + GameState,对照下来会看到本小练里那一句「真实项目里这一段会被 PlayerObject + 玩家列表替换」具体长什么样。
- 能解释闭环 1 和本小练在状态结构上的差异,并说出哪些字段是「玩法独有的」、哪些字段是「骨架共用的」
- 能写出参数化
SendCustomNetworkEvent的完整签名,并说出[NetworkCallable]的几条硬性限制 - 在
NetSubmitGuess里能自己写出「猜中 → 切阶段」「未猜中 → 切轮次」的两条分支 - 能指出本小练里和闭环 1 一样仍然存在的两个缺陷(Master Owner 兜底 +
lobbyReady共用),并说明第二部哪一章修哪一条
- 闭环 1 · 单 Master Owner 的极简一局 — 本小练的母本,所有保留下来的字段和方法都从这里来。
- VRChat Creator Docs · Networking and Synchronization —
SendCustomNetworkEvent、SetOwner、参数化事件的官方说明(自 SDK 3.8.1 起)。 - 附录 · 术语表 —
Owner、Master、Player ID、Phase Machine、Sync Field的客观定义。