跳转到内容

第 12 章 · 请求式架构:固定模板

约 7 分钟 难度:3 动手章

这一章解决:玩家想改全局状态时,在 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(本章会对照其差异)

两种写法的对照。

维度SendCustomNetworkEvent(闭环 1 写法)请求字段(本章写法)
状态形态一次性事件同步字段
Owner 离开时处理中的请求丢失字段仍在,新 Owner 接手处理
迟入玩家看不到过去的请求通过 lastProcessedRequestId 判断该不该重处理
调试看不到「这一刻请求队列里有什么」Debug View 直接读出来
字节代价极小需要几个同步字段(约 16 字节起)
实现复杂度

请求字段的字节代价是非零的,多换来「请求成为持久状态」这件事。回合制 / 卡牌 / 解谜 / 合作 PvE 这类需要每个动作都可追溯的玩法都值得这个代价。极简实时玩法(按按钮立刻反馈)可以保留 SendCustomNetworkEvent 的写法。


下面这套命名是 Vol.2 后续章节的固定基底。闭环 2、闭环 3、第八部主项目里出现这些字段时直接套用,不再重新解释。

PlayerObject 端字段:

字段类型含义
requestIdint单调递增的本地请求序号。玩家本地从 1 开始递增
requestTypebyte请求种类(开局 / 加分 / 加入队伍 / 使用技能)
requestPayloadint请求附带的整数参数(队伍 ID、技能 ID、目标坐标等)
requestPayloadExtraint第二个整数参数,可选

GameState 端字段:

字段类型含义
lastProcessedRequestIdint[]数组,按玩家槽位索引,记录每位玩家最后处理过的 requestId

requestId 单调递增是为了让 GameState 能识别「这一轮请求我处理过没」。同一个玩家的 requestId 从 1 开始:1、2、3。Owner 看到这位玩家的 requestIdlastProcessedRequestId[slot] 大,就处理一次,处理完更新 lastProcessedRequestId[slot]

这个设计是幂等的:网络重发、迟入恢复、Owner 转移都不会让同一个 requestId 被处理两次。这是闭环 3 引入「版本号」概念的雏形。本章只拿到一半(lastProcessedRequestId),完整的 stateVersion 留给闭环 3。


下面两份脚本是本卷的基底。后续闭环和章节里出现「玩家请求 / 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++requestTyperequestPayload,请求字段同步出去。

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 章会用「批准回执」模式处理。


一次完整请求经过的步骤。把它印在脑里,后续阅读 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 按 ScoretotalScore +1,所有客户端 1 秒内可见
2. A 短时间内连按 Score 5 次totalScore 累加到 5,没有丢失
3. 拥塞下 A 连按 Score 50 次totalScore 最终为 50(或被 targetScore 截断)
4. 迟入玩家 C 进来时 GameState 已处理过 A.requestId=10C 看到 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 EventsSendCustomNetworkEvent[NetworkCallable]、参数和速率限制的官方说明。
  • VRChat Creator Docs · Network Variables:同步变量、Manual sync、迟入玩家接收当前值的官方说明。
  • VRChat Creator Docs · Late Joiners:事件不重放、同步变量恢复当前状态的官方说明。
  • 对照小练 A · 参数化 SendCustomNetworkEvent 的最朴素用法:与本章请求字段写法的差异。
  • 第 9 章 · PlayerObject 边界卡:「请求字段属于 PlayerObject」的边界依据。
  • 第 11 章 · GameState 骨架:本章 GameState 扩展的母本。
  • 附录 · 术语表Request IDIdempotentLast Processed Request Id 的客观定义。