理解章 C · 好同步服务于好流程
第三部从 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 和 targetId | GameState | 写在 PlayerObject 上的 CD 字段 + GameState 上的伤害字段 |
| 投降(第八部) | REQ_SURRENDER | GameState(要全队同意才生效) | 写在 GameState 上的 surrendered 字段 |
| 切到下一关(解谜) | REQ_VOTE_NEXT_LEVEL | GameState(多数票) | 写在 GameState 上的 levelId |
每一种都不需要发明新模式。请求 → 裁决 → 落地是同一根线。
流程的三种颗粒度
Section titled “流程的三种颗粒度”第三部里实际出现的流程颗粒度有三档:
整局事务。BeginMatch / EndMatch / RestartMatch 这一档是事务级别。一次执行影响很多字段,所有字段一起改、一起同步、一起 ApplyState。第 16 章把它们封成三个固定入口,是为了让事务粒度可控。新加字段时只动这三个函数,而不是散落在十几个事件回调里。
单点请求。玩家按 Score、按加入队伍、按选角色,每次都是单点请求。requestId + lastProcessedRequestId 让单点请求幂等,是闭环 2 的核心机制。这一档颗粒度小,每秒可能发生几十次。
持续状态。isReady / wantStart / phaseStartServerTime / inStartCountdown 是持续状态。它们不发生「一次事件」,只是「此刻是某个值」。Update 里 TickStartVote 每帧重读一次状态、决定是否切档。
三种颗粒度要分开。把整局事务(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 决定能不能操作」的旁观查询。这是第三部代码框架能远迁移的部分。
「该不该同步」的判断方法
Section titled “「该不该同步」的判断方法”写新世界时常碰到「这个字段要不要同步」的犹豫。第三部下来可以总结成三问:
第一问:这个字段服务于哪一档「玩家此刻该看到什么」的问题?
如果答不出来,这个字段大概率不该同步。比如 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 是把它做稳。