第 12 章 · 请求式架构:固定模板
这一章解决:玩家想改全局状态时,在 PlayerObject 上写「请求」字段,GameState Owner 读到后做裁决。这套模式取代闭环 1 里玩家直接调
SendCustomNetworkEvent(NetworkEventTarget.Owner, ...)的写法。
先看一下
第 9、11 章把「玩家自己的字段」放进 PlayerObject、把「房间共享字段」放进 GameState 之后,下一个问题是:玩家想改房间共享字段(比如「我请求把 totalScore + 1」「我想加入红队」「我要使用一次冷却技能」)时怎么写。
闭环 1 的写法是直接 SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(NetAddScore))。这一招在小项目里跑得通,缺点是请求一次性发送,不留痕迹:迟入玩家看不到「过去发生过什么请求」、网络抖动时收没收到没法判断、Owner 转移瞬间正在处理的请求会丢失。
替代方案:玩家把请求写到 PlayerObject 的同步字段,GameState 读到后处理。请求变成持久状态,可恢复、可观察、可调试。
这一章会拿到什么
- 锁定的请求字段命名:
requestId/requestType/requestPayload/lastProcessedRequestId - PlayerObject 端的
RequestSomething模板 - GameState 端的
OnPlayerRequestUpdated处理模板 - 一张「请求生命周期」时序图
- 闭环 2 起所有章节复用的字段命名约定
依赖前面
- 第 9 章 PlayerObject 边界卡
- 第 11 章 GameState 骨架
- 第 4 章 Manual 同步生命周期与
OnPostSerialization - 对照小练 A 的参数化
SendCustomNetworkEvent(本章会对照其差异)
直接发事件 vs 写请求字段
Section titled “直接发事件 vs 写请求字段”两种写法的对照。
| 维度 | SendCustomNetworkEvent(闭环 1 写法) | 请求字段(本章写法) |
|---|---|---|
| 状态形态 | 一次性事件 | 同步字段 |
| Owner 离开时 | 处理中的请求丢失 | 字段仍在,新 Owner 接手处理 |
| 迟入玩家 | 看不到过去的请求 | 通过 lastProcessedRequestId 判断该不该重处理 |
| 调试 | 看不到「这一刻请求队列里有什么」 | Debug View 直接读出来 |
| 字节代价 | 极小 | 需要几个同步字段(约 16 字节起) |
| 实现复杂度 | 低 | 中 |
请求字段的字节代价是非零的,多换来「请求成为持久状态」这件事。回合制 / 卡牌 / 解谜 / 合作 PvE 这类需要每个动作都可追溯的玩法都值得这个代价。极简实时玩法(按按钮立刻反馈)可以保留 SendCustomNetworkEvent 的写法。
锁定的字段命名
Section titled “锁定的字段命名”下面这套命名是 Vol.2 后续章节的固定基底。闭环 2、闭环 3、第八部主项目里出现这些字段时直接套用,不再重新解释。
PlayerObject 端字段:
| 字段 | 类型 | 含义 |
|---|---|---|
requestId | int | 单调递增的本地请求序号。玩家本地从 1 开始递增 |
requestType | byte | 请求种类(开局 / 加分 / 加入队伍 / 使用技能) |
requestPayload | int | 请求附带的整数参数(队伍 ID、技能 ID、目标坐标等) |
requestPayloadExtra | int | 第二个整数参数,可选 |
GameState 端字段:
| 字段 | 类型 | 含义 |
|---|---|---|
lastProcessedRequestId | int[] | 数组,按玩家槽位索引,记录每位玩家最后处理过的 requestId |
requestId 单调递增是为了让 GameState 能识别「这一轮请求我处理过没」。同一个玩家的 requestId 从 1 开始:1、2、3。Owner 看到这位玩家的 requestId 比 lastProcessedRequestId[slot] 大,就处理一次,处理完更新 lastProcessedRequestId[slot]。
这个设计是幂等的:网络重发、迟入恢复、Owner 转移都不会让同一个 requestId 被处理两次。这是闭环 3 引入「版本号」概念的雏形。本章只拿到一半(lastProcessedRequestId),完整的 stateVersion 留给闭环 3。
完整代码骨架
Section titled “完整代码骨架”下面两份脚本是本卷的基底。后续闭环和章节里出现「玩家请求 / Owner 处理」时,复用这两份骨架。
PlayerObject 端:PlayerLobbyState.cs(在第 9 章基础上扩展)
Section titled “PlayerObject 端:PlayerLobbyState.cs(在第 9 章基础上扩展)”using UdonSharp;using UnityEngine;using VRC.SDKBase;// VRCPlayerObject / VRCEnablePersistence 等组件类型在这里:using VRC.SDK3.Persistence;// 参数化 SendCustomNetworkEvent 与 [NetworkCallable] 需要 SDK ≥ 3.8.1:using VRC.SDK3.UdonNetworkCalling;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public class PlayerLobbyState : UdonSharpBehaviour{ public const byte REQ_NONE = 0; public const byte REQ_START = 1; public const byte REQ_SCORE = 2; public const byte REQ_RESTART = 3; public const byte REQ_JOIN_TEAM = 4; public const byte REQ_USE_SKILL = 5;
// 第 9 章已有 [UdonSynced] private bool isReady; [UdonSynced] private byte teamId;
// 本章新增:请求字段 [UdonSynced] private int requestId; // 单调递增,从 1 起 [UdonSynced] private byte requestType; // 请求种类 [UdonSynced] private int requestPayload; // 主参数 [UdonSynced] private int requestPayloadExtra; // 副参数
public bool IsReady => isReady; public byte TeamId => teamId; public int RequestId => requestId; public byte RequestType => requestType; public int RequestPayload => requestPayload; public int RequestPayloadExtra => requestPayloadExtra;
[Header("引用")] public GameState gameState; // 在场景里直接拖 GameState 进字段,或 PlayerLobbyState.Start 里 GameObject.Find 回填
public void RequestToggleReady() { if (!Networking.IsOwner(gameObject)) return; isReady = !isReady; RequestSerialization(); }
// 通用请求入口。本地按钮调用这一个方法即可,type 和 payload 区分意图 private void IssueRequest(byte type, int payload, int extra) { if (!Networking.IsOwner(gameObject)) return;
requestId++; // 本地单调递增 requestType = type; requestPayload = payload; requestPayloadExtra = extra; RequestSerialization(); // 同步给所有人,包括 GameState Owner
// 主动通知一下 GameState Owner,让它尽快读这位玩家的请求 // 不是必需:GameState 也可以在自己的 OnDeserialization 里轮询 if (gameState != null) { gameState.SendCustomNetworkEvent( VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(GameState.OnPlayerRequestUpdated)); } }
public void RequestStart() { IssueRequest(REQ_START, 0, 0); } public void RequestScore() { IssueRequest(REQ_SCORE, 0, 0); } public void RequestRestart() { IssueRequest(REQ_RESTART, 0, 0); } public void RequestJoinTeam(int target) { IssueRequest(REQ_JOIN_TEAM, target, 0); } public void RequestUseSkill(int skillId, int targetId) { IssueRequest(REQ_USE_SKILL, skillId, targetId); }}IssueRequest 是模板核心:玩家本地按下按钮,本地写 requestId++、requestType、requestPayload,请求字段同步出去。
GameState 端:GameState.cs(在第 11 章基础上扩展)
Section titled “GameState 端:GameState.cs(在第 11 章基础上扩展)”public class GameState : UdonSharpBehaviour{ // 第 11 章已有:phase / totalScore / phaseStartServerTime / GetPLS helper
// 本章新增:每位玩家的最后处理 requestId // 数组按受控 slot 索引;slot 由 playerId 映射并做范围检查 [UdonSynced] private int[] lastProcessedRequestId = new int[100];
private float nextRequestScanTime;
private void Update() { if (!Networking.IsOwner(gameObject)) return; if (Time.time < nextRequestScanTime) return; nextRequestScanTime = Time.time + 0.25f; ScanPlayerRequests(); }
// 玩家发出请求后会走这里。它是 Owner 端的快路径入口 [NetworkCallable] // 参数化网络事件需要这个属性,第 21 章详述 public void OnPlayerRequestUpdated() { if (!Networking.IsOwner(gameObject)) return; ScanPlayerRequests(); }
private void ScanPlayerRequests() { // 扫描所有玩家的 PlayerObject,找出 requestId > lastProcessedRequestId 的那些 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); // 第 11 章 helper 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(); }
// 单条请求处理。dispatch by requestType private void ProcessRequest(VRCPlayerApi player, PlayerLobbyState pls) { byte type = pls.RequestType; int payload = pls.RequestPayload; int extra = pls.RequestPayloadExtra;
if (type == PlayerLobbyState.REQ_START) { HandleStart(player); } else if (type == PlayerLobbyState.REQ_SCORE) { HandleScore(player); } else if (type == PlayerLobbyState.REQ_RESTART) { HandleRestart(player); } else if (type == PlayerLobbyState.REQ_JOIN_TEAM) { HandleJoinTeam(player, (byte)payload); } else if (type == PlayerLobbyState.REQ_USE_SKILL) { HandleUseSkill(player, payload, extra); } // 未知类型忽略,不报错 }
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; totalScore++; ApplyState(); }
private void HandleRestart(VRCPlayerApi who) { if (phase != PHASE_RESULT) return; phase = PHASE_LOBBY; totalScore = 0; ApplyState(); }
private void HandleJoinTeam(VRCPlayerApi who, byte team) { // 队伍冲突 / 上限校验在第 13 章「权限与冲突」展开 // 本章先给最简版本:直接接受 var pls = GetPLS(who); // 注意:这里 GameState 不能直接写 PlayerObject 字段,因为 PlayerObject 的 Owner 是 who, // 不是 GameState Owner。要让玩家自己改字段。 // 一种实现是 GameState 通知 who 客户端:「批准你加入 team N」,由 who 自己写 teamId。 // 第 13 章会展开这种「批准回执」模式。 }
private void HandleUseSkill(VRCPlayerApi who, int skillId, int targetId) { // 技能冷却、合法性校验在第 5 部具体玩法章节展开 // 这一行预留接口 }}注意 HandleJoinTeam 里的注释:GameState Owner 不能直接写 PlayerObject 字段,因为 PlayerObject 的 Owner 锁死为玩家本人。这是请求式架构的一道边界,第 13 章会用「批准回执」模式处理。
请求生命周期时序
Section titled “请求生命周期时序”一次完整请求经过的步骤。把它印在脑里,后续阅读 GameState 代码会快很多。
玩家 A 客户端 所有客户端(含 A) GameState Owner 客户端───────────── ────────────────── ──────────────────────
[按按钮] ↓PlayerLobbyState.IssueRequest: requestId = 5 requestType = REQ_SCORE requestPayload = 0 ↓RequestSerialization ↓ ────────────同步广播────────────→ ↓ OnDeserialization 触发 (A 自己也会触发) ↓ GameState.OnPlayerRequestUpdated ↓ 扫描所有玩家 发现 A.requestId=5 > lastProcessedRequestId[A]=4 ↓ ProcessRequest(A, REQ_SCORE) totalScore++ lastProcessedRequestId[A] = 5 ↓ GameState.RequestSerialization ↓ ────同步广播 totalScore + lastProcessed────→ 所有客户端 OnDeserialization UI 显示 totalScore + 1整个流程里没有 SendCustomNetworkEvent 必须用。IssueRequest 末尾给 GameState Owner 发的那一次事件只是「催 Owner 立刻处理」的优化,不能当作唯一触发源。稳妥写法是在 GameState Owner 上保留低频轮询:事件先到字段后到时,下一次轮询仍然能扫到 requestId 的变化。
fading worked example:第 13 章会自己填的部分
Section titled “fading worked example:第 13 章会自己填的部分”下面的 HandleJoinTeam 留给读者完成(也是第 13 章会展开的内容):
private void HandleJoinTeam(VRCPlayerApi who, byte team){ // TODO 1:检查 team 是不是合法(0..N-1) // TODO 2:检查 team 当前人数有没有超上限 // TODO 3:发回执给 who,让他自己在 PlayerObject 上写 teamId // 具体写法:先用 GetPLS(who) 取出他的 PlayerLobbyState 实例, // 再调 pls.SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(PlayerLobbyState.OnTeamApproved), (int)team)。 // 因为 PlayerObject 的 Owner 锁定为 who 本人,target=Owner 等同于「发给 who」。 // PlayerLobbyState.OnTeamApproved(int team) 必须加 [NetworkCallable]。 // TODO 4:如果拒绝,发回执 OnTeamRejected(int reason)}读完第 13 章后回头补完这段。先不要看后文答案,自己写一遍再对照。
- 把
lastProcessedRequestId数组长度从 100 改成 8。在两个客户端跑,模拟一个 playerId > 8 的场景(实际 VRChat 的 playerId 不一定从 1 起,且会跳号)。会发生什么?怎么改成更鲁棒的写法? - 给
IssueRequest加冷却:同一个requestType在 1 秒内只能发一次。本地实现还是 GameState 端实现?两种各自的代价是什么? - 把
gameState.SendCustomNetworkEvent(... OnPlayerRequestUpdated)那一行注释掉,只保留 GameState Owner 的 0.25 秒低频轮询。跑一下,请求处理延迟从「按下立刻」变成什么?
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. A 按 Score | totalScore +1,所有客户端 1 秒内可见 | … |
| 2. A 短时间内连按 Score 5 次 | totalScore 累加到 5,没有丢失 | … |
| 3. 拥塞下 A 连按 Score 50 次 | totalScore 最终为 50(或被 targetScore 截断) | … |
| 4. 迟入玩家 C 进来时 GameState 已处理过 A.requestId=10 | C 看到 lastProcessedRequestId[A] 同步为 10,C 自己的请求从他自己的 requestId=1 起独立递增 | … |
| 5. GameState Owner 转移 | 转移瞬间正在处理的请求不会被重复处理(lastProcessedRequestId 在新 Owner 端仍然是 10) | … |
第 5 行是请求式架构相对 SendCustomNetworkEvent 的关键优势。如果第 5 行未通过,先回去检查 lastProcessedRequestId 是不是真的加了 [UdonSynced]。
- 能默写四个请求字段的命名和含义
- 能解释为什么
requestId每位玩家本地递增,而不是全局递增 - 能复述请求生命周期六个步骤,每步在哪一台客户端上跑
- 能说出 GameState Owner 不能直接写 PlayerObject 字段的原因,并说出「批准回执」的思路
- 能识别
HandleJoinTeam留下的四个 TODO 各自要做什么
- VRChat Creator Docs · Network Events:
SendCustomNetworkEvent、[NetworkCallable]、参数和速率限制的官方说明。 - VRChat Creator Docs · Network Variables:同步变量、Manual sync、迟入玩家接收当前值的官方说明。
- VRChat Creator Docs · Late Joiners:事件不重放、同步变量恢复当前状态的官方说明。
- 对照小练 A · 参数化 SendCustomNetworkEvent 的最朴素用法:与本章请求字段写法的差异。
- 第 9 章 · PlayerObject 边界卡:「请求字段属于 PlayerObject」的边界依据。
- 第 11 章 · GameState 骨架:本章 GameState 扩展的母本。
- 附录 · 术语表:
Request ID、Idempotent、Last Processed Request Id的客观定义。