第 15 章 · 大厅、准备、队伍
这一章解决:闭环 2 的 Lobby 段太薄,只够「全员 Ready 谁按 Start 都行」。把它扩成 4 人合作防守原型真正需要的样子:选边、选角色、Start Vote、开局倒计时,并把每个字段的归属和撤回路径理清楚。
先看一下
闭环 2 的开局门是 AllPlayersReady() && phase==LOBBY,谁按 Start 都行。两个问题:4 人合作原型要分边(红 / 蓝)和角色(工程 / 攻击 / 治疗),闭环 2 没处理;任何一位玩家按 Start 就开局,刚进入房间、还没看完介绍的迟入玩家也会直接进入 InGame。
把 Lobby 做完整后,状态机大致是:每位玩家选好队伍和角色 → 都点 Ready → 至少一半玩家投 Start Vote → 5 秒倒计时 → 进入 InGame。任何人中途撤 Ready 或撤投票,倒计时立刻取消。
这一章会拿到什么
- Lobby 三块视图的职责分层:本人面板 / 房间总览 / 队伍区
- 一张「Lobby 字段归属表」:每个新字段放 PlayerObject 还是 GameState、为什么
- Start Vote 三档状态机的判断条件与撤销路径
- 一份「Lobby 完成度」检查表
依赖前面
- 闭环 2 的
PlayerLobbyState+GameState完整骨架 - 第 12 章
IssueRequest/requestId流 - 第 13 章批准回执模式
- 第 11 章
Networking.GetServerTimeInSeconds()用法
Lobby 由谁负责什么
Section titled “Lobby 由谁负责什么”写代码前把视图先理清楚。Lobby UI 大致摆三块。这三块物理上可以合在一张 Canvas 上,职责要分开。
第一块是本人面板。只看本地玩家自己的状态:当前队伍、当前角色、是否 Ready、是否投了 Start Vote。它读的全是「本地玩家自己 PlayerObject 上的字段」(第 9 章那一套),按钮调的全是「本地玩家自己 PlayerObject 上的方法」。
第二块是房间总览。读的是 GameState:现在 Ready 几人、投票几人、倒计时还剩多少秒、能不能进 InGame。这一块是只读视图,没有任何按钮。
第三块是队伍区。每个队伍一个面板,列出这队当前几位玩家、各自选了什么角色。遍历所有玩家的 PlayerLobbyState,按 TeamId 分组显示。
把按钮和读取严格按这三块切分,代码里就不容易把「修改房间状态」的按钮挂到只读面板上。第 11 章已经讲过:玩家不能直接写 GameState 字段,所有改动走请求字段。Lobby UI 把这条规则映射成「三块面板里只有第一块有按钮」。
建议截图:把上述三块面板的实际版本截图,标注每块读哪些字段、第一块的按钮各自调什么方法。
Lobby 字段归属表
Section titled “Lobby 字段归属表”本章在闭环 2 上加 4 个新字段。在写任何代码之前先决定它们各自归属。
| 字段 | 类型 | 归属 | 写权 | 同步成本 | 同模板(闭环 2) |
|---|---|---|---|---|---|
roleId | byte | PlayerLobbyState(PlayerObject) | 玩家本人通过批准回执 | per-player 1 byte | teamId(第 13 章批准回执) |
wantStart(Start Vote 投票) | bool | PlayerLobbyState(PlayerObject) | 玩家本人直接 toggle | per-player 1 bit | isReady |
inStartCountdown | bool | GameState | GameState Owner 写 | 全房间 1 bit | phase(GameState 上的状态字段) |
startCountdownEndServerTime | double | GameState | GameState Owner 写 | 全房间 8 byte | phaseStartServerTime |
最右一列把每个新字段对回闭环 2 已经存在的同模板字段。能找到对应的就照抄那一处的写法,不需要重学新机制。
判断维度:
roleId是「玩家自己的属性」,但需要GameState裁决(同队角色唯一性)。这是第 13 章批准回执的标准场景:玩家发请求 → GameState 检查 → 让玩家本人写自己字段。wantStart也是「玩家自己的属性」,但不需要裁决。每位玩家此刻想不想开局是他自己的事,没有冲突。和isReady同款,直接 toggle 自己字段、不进请求队列。inStartCountdown和startCountdownEndServerTime是房间共享状态,必须放GameState。所有玩家看到同一个倒计时数字。
三类字段对应三种写法。Lobby 段以后每加一个字段,先按这张表对一遍归属。
角色切换:复用第 13 章批准回执
Section titled “角色切换:复用第 13 章批准回执”HandleSetRole 和第 13 章 HandleJoinTeam 是同模板。骨架一行不动,每段都是「阶段检查 → 合法性 → 业务约束 → 批准/拒绝回执」。
// GameState 增量private void HandleSetRole(VRCPlayerApi who, byte role){ if (phase != PHASE_LOBBY) { ReplyRoleRejected(who, 2); return; } if (role >= roleCount) { ReplyRoleRejected(who, 1); return; }
if (roleUniquePerTeam) { var pls = GetPLS(who); if (pls != null && IsRoleTaken(pls.TeamId, role, who.playerId)) { ReplyRoleRejected(who, 0); return; } } ReplyRoleApproved(who, role);}IsRoleTaken 遍历同队所有玩家的 RoleId,排除请求者自己。ReplyRoleApproved 走 pls.SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(OnRoleApproved), role),事件目标是那份 PlayerLobbyState 的 Owner。OnRoleApproved 必须写成 public 并加 [NetworkCallable],参数类型也要属于官方允许同步的类型。玩家本人收到回执后写自己的 roleId。这一段的具体写法照抄第 13 章 OnTeamApproved。
OnTeamApproved 里有一行容易漏:换队时把 roleId 清成 255(哨兵值,表示未选)。理由:玩家从红队攻击切到蓝队,到了蓝队仍然是攻击。如果蓝队已经有人在攻击,新切过来的玩家保留旧角色,roleUniquePerTeam 的约束被绕过。换队之后强制重选角色是兜底。
PlayerLobbyState 增加的本地按钮入口写法和第 13 章一致:RequestSetRoleEngineer / Attacker / Healer 三个小函数各调一次 IssueRequest(REQ_SET_ROLE, payload),UI 按钮直接挂事件不必填参数。
Start Vote 三档状态机
Section titled “Start Vote 三档状态机”闭环 2 的 RequestStart 是请求 → HandleStart 立刻进 INGAME。本章把它替换成三档:
状态 1:等待 — 票数不够 或 还没全员 Ready状态 2:倒计时 — 票数够 且 全员 Ready状态 3:进 InGame — 倒计时归零倒计时由 GameState Owner 在 Update 里推进。每帧检查这三档的转换条件:
// TickStartVote 的核心判断(每帧 Owner 上跑一次)int requiredVotes = Mathf.CeilToInt(total * startVoteThreshold);bool conditionsMet = allReady && wantCount >= requiredVotes;
if (conditionsMet && !inStartCountdown) { /* 进入倒计时 */ }else if (!conditionsMet && inStartCountdown) { /* 撤销倒计时 */ }else if (inStartCountdown && now >= endServerTime) { /* 进 InGame */ }startVoteThreshold = 0.5f 默认,4 人房需要 2 票,3 人房需要 2 票(向上取整)。倒计时 startCountdownDuration = 5f 默认。
倒计时数值用 Networking.GetServerTimeInSeconds() 计算,不用本地 Time.time。做法是让 GameState Owner 同步一个截止时间戳,每个客户端用同一把共享时钟反推本地剩余秒数。这个时钟适合倒计时和相对顺序,不适合作安全判定或精确计时。Time.time 是各客户端从启动时刻开始计数的本地时间,跨客户端不一致,不能用作共享倒计时。
UI 一侧:lobbyHintLabel 在 inStartCountdown 时显示倒计时秒数;不在倒计时时显示当前缺什么条件,如「Ready 3/4」或「Vote to start 1/2」。这一段是 ApplyState 里的字符串拼接,没有特殊技术。
倒计时撤销的四种情况
Section titled “倒计时撤销的四种情况”倒计时不是「一旦进入就一定开局」。下面四种事件让它在中途取消:
有人撤 Ready。isReady 切回 false,allReady 变 false,进入「撤销倒计时」分支。
有人撤投票。wantStart 切回 false,wantCount 减一,可能跌破 startVoteThreshold。撤销路径同上。
新玩家加入。新玩家进来后 total 增加,他默认 IsReady=false,立刻让 allReady 变 false。倒计时撤销,房间回到「等新人 Ready」状态。这是有意的:新人还没看到房间状态就直接进入 InGame,流程上不可读。
Owner 转移。倒计时过程中 GameState Owner 离开,新 Owner 接管时 inStartCountdown=true 和 startCountdownEndServerTime 都同步过来。新 Owner 不重新计算时间,沿用旧的截止时间。如果接管延迟超过 5 秒(极端情况),倒计时进入「时间已到」分支直接进 InGame。
第四种的副作用:接管延迟超过倒计时长度时,新 Owner 接管那一刻直接进 InGame,没给玩家补充窗口。本章不修这个,作为「Owner 转移期间不要做敏感操作」的实例。第 14 章「Owner 失效恢复检查表」标注过类似限制。
Lobby 完成度检查表
Section titled “Lobby 完成度检查表”写完一个新世界的 Lobby 后按下面 8 条核对一次。漏一条都可能在多人测试时被发现。
[ ] 三块视图职责切分清楚(本人面板 / 房间总览 / 队伍区)[ ] 修改房间状态的按钮全部挂在「本人面板」上,没漏到只读视图[ ] PlayerLobbyState 上的字段都加了 [UdonSynced][ ] 队伍切换调用走 IssueRequest,配 OnTeamApproved / OnTeamRejected 回执[ ] 角色切换调用走 IssueRequest,配 OnRoleApproved / OnRoleRejected 回执[ ] OnTeamApproved 里有清 roleId(如果开了 roleUniquePerTeam)[ ] Start Vote 倒计时只在 GameState Owner 的 Update 里推,且用 Networking.GetServerTimeInSeconds()[ ] 撤 Ready / 撤投票 / 新玩家加入 都能让 inStartCountdown 立刻回到 false把这张表贴进项目的 design/lobby-checklist.md(没有就建一个)。第 16 章「开局与重开」会把「开局后回到 Lobby」这条路径展开。
挑一条试。每条不要直接翻第 16 章答案,先自己想一遍。
- 把
startVoteThreshold从固定 0.5f 改成「按房间人数动态调整」:2 人房需要全票,3–4 人房需要 0.5f,5 人以上需要 0.4f。改在TickStartVote里读门槛比改 Inspector 字段稳,为什么? - 给「房主一票否决」加个开关:
bool hostCanForceStart。当开关开启时,候选队列第一位玩家(即 GameState Owner)按 Start 不需要 Vote 直接进倒计时。在哪一层加这道分支?这种「主持人接管」和反作弊近似的关系是什么(第 33 章话题)? - 把
OnTeamApproved里清roleId那一行注释掉,然后跑:玩家在红队选攻击 → 切到蓝队(蓝队已有人选了攻击)。结果会怎样?这是roleUniquePerTeam的边界还是别的问题?
按闭环 2 的测试表扩展。两到四个客户端 Build & Test。
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. 4 人都加入红队 | 第 3 位被拒(teamCapacity=2),收到 OnTeamRejected reason=0 | … |
| 2. 同队选重复角色 | 第 2 位被拒(roleUniquePerTeam=true),收到 OnRoleRejected reason=0 | … |
| 3. 全员 Ready 但票数不够 | lobbyHintLabel 显示 Vote to start 0 / 2,未进倒计时 | … |
| 4. 全员 Ready 且 2 人投票 | 进入 5 秒倒计时,所有客户端用同一个截止时间戳显示剩余秒数 | … |
| 5. 倒计时中有人撤 Ready | inStartCountdown 立刻回 false,提示回到 Ready N/M | … |
| 6. 倒计时中新玩家加入 | 倒计时立刻撤销,allReady 因新人变 false | … |
| 7. 倒计时归零 | phase 切 INGAME,phaseStartServerTime 是新值 | … |
| 8. GameState Owner 在倒计时中离开 | 候选队列下一位接管,倒计时按 startCountdownEndServerTime 继续 | … |
| 9. 切队后旧角色 | 切队后 roleId 变 255,UI 显示「未选角色」| … |
第 4、5、6 行是本章相对闭环 2 的核心新增。如果第 4 行倒计时秒数在不同客户端不同步,先回去检查 startCountdownEndServerTime 是不是真的同步了。
- 能复述 Lobby 三块视图各自的读 / 写边界
- 能解释为什么
wantStart不进请求字段流,而roleId进了 - 能默写 Start Vote 三档状态机的转换条件
- 能说出
OnTeamApproved里清roleId的两种触发场景 - 能识别四种倒计时撤销路径,并指出哪种会让接管延迟变成实际开局延迟
- VRChat Creator Docs · Networking and Synchronization — Ownership、同步变量、Network Event 的总览。
- VRChat Creator Docs · Network Events — 参数化
SendCustomNetworkEvent、NetworkEventTarget.Owner与[NetworkCallable]的官方说明。 - UhiyamaLab · Introduction to Network Synchronization — 英文社区教程,用来对照 Owner /
[UdonSynced]/RequestSerialization()的基础模式。 - 闭环 2 · PlayerObject + GameState 的中级一局 — 本章扩展的母本。
- 第 12 章 · 请求式架构 —
IssueRequest与requestId流。 - 第 13 章 · 权限与冲突 — 批准回执模式(
OnTeamApproved/OnTeamRejected与OnRoleApproved/OnRoleRejected同模板)。 - 第 14 章 · Owner 离开后的恢复 — 倒计时中 Owner 转移的延迟来源。
- 附录 · 术语表 —
Start Vote、Approval Reply、Server Time的客观定义。