跳转到内容

理解章 C · 好同步服务于好流程

约 6 分钟

第三部从 Lobby 写到 Result,期间增加了队伍、角色、Start Vote、倒计时、广播清场、结算快照、旁观席、迟入恢复。看上去是七八件不同的事。回头数一下,每一件都在解同一个问题:让任何时刻进房间的任何一位玩家,能知道现在房间在哪一档、自己应该做什么、刚才发生了什么。

这一章把第三部的字段全部抹掉,看它们底下是什么。

同步是流程的支撑,不是流程本身

Section titled “同步是流程的支撑,不是流程本身”

闭环 1 的同步逻辑很简单:phase / totalScore / phaseStartServerTime 三个字段,靠 Manual 同步推到所有客户端。能跑。

但 4 人合作防守原型的真实流程不是「分数一直在涨」。它是:

有人在等 → 让他知道还差几位 Ready
全员 Ready 了 → 让他知道现在等投票
够票了 → 让他知道倒计时几秒
有人撤回了 → 让他知道倒计时取消、回到等待
进比赛了 → 让他知道分数怎么算、还剩多久
比赛结束 → 让他知道谁赢、自己拿了多少
新人进来了 → 让他知道现在不在比赛、可以做什么

每一档都有一组「玩家此刻应该看到什么」的问题。第 15 章的 lobbyHintLabel、第 16 章的 BeginMatch / EndMatch / RestartMatch、第 17 章的结算快照、第 18 章的 IsSpectator,回答的是这一组问题。同步的是字段,但字段是为了让流程可见

把这条反过来:如果一个字段不能直接服务任何一档「玩家应该看到什么」的问题,它大概率不需要同步。本章末尾会把这条变成判断方法。

「请求」和「状态」是同一根流程线上的两段

Section titled “「请求」和「状态」是同一根流程线上的两段”

第 12 章把 requestId / requestType 写成同步字段时,第一次显式区分了「请求」和「状态」:

  • 请求:玩家此刻想干什么。挂在 PlayerObject 上、玩家本人写。
  • 状态:房间此刻在哪一档。挂在 GameState 上、GameState Owner 写。

第 13 章批准回执模式让这两段连起来:玩家写请求 → GameState 读到 → 裁决 → 写回执 → 玩家本人写状态。整条链路是「请求穿过 GameState,落地回 PlayerObject」。

这一段在第三部里被用了好几次:加入队伍、选角色走完整的请求 → 裁决 → 落地;Start Vote 和 Ready 不进请求流,但仍然是「玩家表达意图,GameState 汇总后切换房间状态」的同一条流程线。理解这条之后,再加新机制时套同款思路:

新机制请求是什么谁裁决落地写在哪
加技能(第五部)REQ_USE_SKILL,附 skillId 和 targetIdGameState写在 PlayerObject 上的 CD 字段 + GameState 上的伤害字段
投降(第八部)REQ_SURRENDERGameState(要全队同意才生效)写在 GameState 上的 surrendered 字段
切到下一关(解谜)REQ_VOTE_NEXT_LEVELGameState(多数票)写在 GameState 上的 levelId

每一种都不需要发明新模式。请求 → 裁决 → 落地是同一根线。

第三部里实际出现的流程颗粒度有三档:

整局事务BeginMatch / EndMatch / RestartMatch 这一档是事务级别。一次执行影响很多字段,所有字段一起改、一起同步、一起 ApplyState。第 16 章把它们封成三个固定入口,是为了让事务粒度可控。新加字段时只动这三个函数,而不是散落在十几个事件回调里。

单点请求。玩家按 Score、按加入队伍、按选角色,每次都是单点请求。requestId + lastProcessedRequestId 让单点请求幂等,是闭环 2 的核心机制。这一档颗粒度小,每秒可能发生几十次。

持续状态isReady / wantStart / phaseStartServerTime / inStartCountdown 是持续状态。它们不发生「一次事件」,只是「此刻是某个值」。UpdateTickStartVote 每帧重读一次状态、决定是否切档。

三种颗粒度要分开。把整局事务(BeginMatch)写成单点请求(每个字段一条 IssueRequest),UI 会看到字段一个个跳变;把持续状态(isReady)写成单点请求(每次 Ready 切换发一条 REQ_TOGGLE_READY),网络流量翻几倍且没有收益。写入字段前先判断它属于哪一档。

远迁移:把「流程优先」用到不同玩法上

Section titled “远迁移:把「流程优先」用到不同玩法上”

第三部的代码骨架来自 4 人合作防守原型。但「同步服务于流程」这条原则在所有 VRChat 多人玩法里都成立。

回合制卡牌世界。流程比合作防守更长:洗牌、抽牌、出牌、确认、结算回合、下一位玩家。每一步都对应一档「玩家此刻该看什么」。同步字段大约是:当前轮到谁、当前牌堆状态、双方手牌(仅对当事人可见,对旁观可见与否是设计选择)、本回合行动是否已确认。requestId 流全用上,因为每个动作都需要裁决;Update 里几乎没有持续状态推进(回合制不需要 tick);事务级 BeginMatch/EndMatch 只在牌局开始 / 结束时触发。

解谜世界。流程是「找线索 → 触发机关 → 解锁下一区」。同步字段更像「区域开关 + 拼图状态」。请求流量很低(玩家点开关不频繁),持续状态不多(机关解锁后是常量)。事务级几乎没有:解谜世界一旦开始就不会回到 LOBBY,玩家中途进出靠迟入恢复。

派对小游戏世界。流程切换很快:玩 30 秒、计分、立刻进下一个 mini game。事务级 BeginMatch / EndMatch 每分钟跑两三次。请求流量看具体玩法(按按钮的多 / 跑路的少)。持续状态多在 InGame 那 30 秒内。

对战 PvP 一对一。流程更紧:选角色 → 加载完成 → 倒计时 → InGame → 一方 HP 归零 → 结算。事务级两次(BeginMatch / EndMatch),单点请求多(每次操作),持续状态多(HP / 位置 / 状态条 / 冷却)。

四种玩法看上去差别很大,但每种都用得上「请求 → 裁决 → 落地」三段、用得上「事务 / 请求 / 持续状态」三档颗粒度、用得上「IsSpectator 决定能不能操作」的旁观查询。这是第三部代码框架能远迁移的部分。

写新世界时常碰到「这个字段要不要同步」的犹豫。第三部下来可以总结成三问:

第一问:这个字段服务于哪一档「玩家此刻该看到什么」的问题?

如果答不出来,这个字段大概率不该同步。比如 lastIssueTime(玩家本地冷却的上次发送时间)只服务于本地玩家自己的冷却判断,远端客户端拿到这个值没用。它在第 13 章里没加 [UdonSynced],是对的。

第二问:这个字段属于「请求 / 状态 / 事务」哪一档颗粒度?

请求字段(requestId / requestType)必须每位玩家自己写,挂 PlayerObject。状态字段(phase / totalScore)必须 GameState Owner 写,挂 GameState。事务字段是状态字段的特殊形式,不需要单独标位置,但要记得 BeginMatch 时一起改。

第三问:这个字段在「迟入玩家进来时」需要被复制吗?

需要的是状态字段(必须同步)。不需要的是事件字段(不进同步)。这一道判断在第 5 章迟入恢复一节里出现过。第三部的同步字段都围绕这条展开:迟入玩家进来时通过 phase / teamScore / 现有玩家的 personalScore / spectatorPlayerIds 重建房间视图;自己新生成的 PlayerObject 则按新加入玩家处理。

三问下来,绝大多数「要不要同步」的犹豫能定下来。

第三部从「闭环 2 能跑」走到「Lobby → InGame → Result → Lobby 完整流程」。中途加的字段都不复杂,但每个字段都对应一档具体的玩家视角问题。这是「好同步服务于好流程」的实操体现。

第四部会回到通道层:什么时候把状态写成同步变量,什么时候写成 Network Event,什么时候打成字节数组,什么时候用 JSON。第三部已经默认了一部分选择(请求字段用 synced primitive、广播用 target=All 网络事件)。第四部把这些选择放进决策表,让读者能根据自己的玩法重新选。

到第四部结束时会走完闭环 3,把闭环 2 故意保留的三个缺陷(重复加分、状态版本乱、玩家串味)一起修。第三部已经把流程做完整,闭环 3 是把它做稳。