跳转到内容

第 15 章 · 大厅、准备、队伍

约 10 分钟 难度:2 动手章

这一章解决:闭环 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 UI 大致摆三块。这三块物理上可以合在一张 Canvas 上,职责要分开。

第一块是本人面板。只看本地玩家自己的状态:当前队伍、当前角色、是否 Ready、是否投了 Start Vote。它读的全是「本地玩家自己 PlayerObject 上的字段」(第 9 章那一套),按钮调的全是「本地玩家自己 PlayerObject 上的方法」。

第二块是房间总览。读的是 GameState:现在 Ready 几人、投票几人、倒计时还剩多少秒、能不能进 InGame。这一块是只读视图,没有任何按钮。

第三块是队伍区。每个队伍一个面板,列出这队当前几位玩家、各自选了什么角色。遍历所有玩家的 PlayerLobbyState,按 TeamId 分组显示。

把按钮和读取严格按这三块切分,代码里就不容易把「修改房间状态」的按钮挂到只读面板上。第 11 章已经讲过:玩家不能直接写 GameState 字段,所有改动走请求字段。Lobby UI 把这条规则映射成「三块面板里只有第一块有按钮」。

建议截图:把上述三块面板的实际版本截图,标注每块读哪些字段、第一块的按钮各自调什么方法。


本章在闭环 2 上加 4 个新字段。在写任何代码之前先决定它们各自归属。

字段类型归属写权同步成本同模板(闭环 2)
roleIdbytePlayerLobbyState(PlayerObject)玩家本人通过批准回执per-player 1 byteteamId(第 13 章批准回执)
wantStart(Start Vote 投票)boolPlayerLobbyState(PlayerObject)玩家本人直接 toggleper-player 1 bitisReady
inStartCountdownboolGameStateGameState Owner 写全房间 1 bitphase(GameState 上的状态字段)
startCountdownEndServerTimedoubleGameStateGameState Owner 写全房间 8 bytephaseStartServerTime

最右一列把每个新字段对回闭环 2 已经存在的同模板字段。能找到对应的就照抄那一处的写法,不需要重学新机制。

判断维度:

  • roleId 是「玩家自己的属性」,但需要 GameState 裁决(同队角色唯一性)。这是第 13 章批准回执的标准场景:玩家发请求 → GameState 检查 → 让玩家本人写自己字段。
  • wantStart 也是「玩家自己的属性」,但不需要裁决。每位玩家此刻想不想开局是他自己的事,没有冲突。和 isReady 同款,直接 toggle 自己字段、不进请求队列。
  • inStartCountdownstartCountdownEndServerTime 是房间共享状态,必须放 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,排除请求者自己。ReplyRoleApprovedpls.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 按钮直接挂事件不必填参数。


闭环 2 的 RequestStart 是请求 → HandleStart 立刻进 INGAME。本章把它替换成三档:

状态 1:等待 — 票数不够 或 还没全员 Ready
状态 2:倒计时 — 票数够 且 全员 Ready
状态 3:进 InGame — 倒计时归零

倒计时由 GameState OwnerUpdate 里推进。每帧检查这三档的转换条件:

// 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 一侧:lobbyHintLabelinStartCountdown 时显示倒计时秒数;不在倒计时时显示当前缺什么条件,如「Ready 3/4」或「Vote to start 1/2」。这一段是 ApplyState 里的字符串拼接,没有特殊技术。


倒计时不是「一旦进入就一定开局」。下面四种事件让它在中途取消:

有人撤 ReadyisReady 切回 false,allReady 变 false,进入「撤销倒计时」分支。

有人撤投票wantStart 切回 false,wantCount 减一,可能跌破 startVoteThreshold。撤销路径同上。

新玩家加入。新玩家进来后 total 增加,他默认 IsReady=false,立刻让 allReady 变 false。倒计时撤销,房间回到「等新人 Ready」状态。这是有意的:新人还没看到房间状态就直接进入 InGame,流程上不可读。

Owner 转移。倒计时过程中 GameState Owner 离开,新 Owner 接管时 inStartCountdown=truestartCountdownEndServerTime 都同步过来。新 Owner 不重新计算时间,沿用旧的截止时间。如果接管延迟超过 5 秒(极端情况),倒计时进入「时间已到」分支直接进 InGame。

第四种的副作用:接管延迟超过倒计时长度时,新 Owner 接管那一刻直接进 InGame,没给玩家补充窗口。本章不修这个,作为「Owner 转移期间不要做敏感操作」的实例。第 14 章「Owner 失效恢复检查表」标注过类似限制。


写完一个新世界的 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. 倒计时中有人撤 ReadyinStartCountdown 立刻回 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 的两种触发场景
  • 能识别四种倒计时撤销路径,并指出哪种会让接管延迟变成实际开局延迟