第 16 章 · 开局与重开
这一章解决:把「Lobby → InGame → Result → Lobby」这条回路做成可重复事务。每一次开局都用同一个入口、每一次重开都用同一个入口,避免每加一个新机制就要在三四个地方手动复位。
先看一下
第 15 章的 Start Vote 倒计时归零后写了七行代码:phase = INGAME、清 totalScore、写 phaseStartServerTime、撤倒计时、RequestSerialization、ApplyState。这七行是「开局」要做的全部吗?再加一条「比赛中场景里要复位的浮空炮塔」,又得改这七行所在的位置;再加一条「重开时不要清队伍配置但要清个人分数」,又得改一遍。
把这一段抽成 BeginMatch()、EndMatch()、RestartMatch() 三个入口。每个入口写一次「这一档要做的所有事」。后面所有章节加新字段时只更新这三个函数,不再四处散落。
这一章会拿到什么
- 三个固定入口的职责对照:BeginMatch / EndMatch / RestartMatch 各做什么、不做什么
- 一份「字段在三档要不要清」的速查表,加新字段时按表归档
- 全房间广播
target=All与点对点target=Owner的适用场景 TickSanity兜底检查:什么时候空房自愈到 LOBBY
依赖前面
- 第 15 章 Start Vote 倒计时
- 第 13 章批准回执模式
- 第 12 章
IssueRequest/requestId - 闭环 2 的
phase状态机
把开局当作一次事务
Section titled “把开局当作一次事务”「事务」在这里只取一个属性:要么整批做完,要么一次没做。每个客户端要么看到「比赛已开始 + 全部初始化完成」的状态,要么看到「比赛未开始」的状态,不应当看到「phase=INGAME 但 totalScore 还是上一局的 5」这种字段只改了一半的画面。
闭环 2 的缺陷 2 已经描述过这个现象。本章不彻底修(彻底修要等闭环 3 的 stateVersion),但通过把开局集中到一个函数里,能减少漏改字段的概率。
具体做法是把「Lobby → InGame」这一刻该做的所有事全部塞进 BeginMatch():
- 切
phase到 INGAME。 - 复位本局共享的 InGame 字段(分数、时间戳、本局衍生计数器)。
- 广播给所有 PlayerObject:「请清你们的本局个人字段」(个人分、技能 CD、复活计数)。
- 清 Lobby 段的暂态(倒计时、Start Vote 标志)。
EndMatch() 与 RestartMatch() 同款思路:每个入口写一次「这一档要做的事」,后续维护成本集中在三个函数里。
三个入口的职责
Section titled “三个入口的职责”| 入口 | 触发时机 | 主要动作 | 不做的事 |
|---|---|---|---|
BeginMatch() | Start Vote 倒计时归零,由 TickStartVote 调用 | 切 phase=INGAME、清共享 InGame 字段、广播给 PlayerObject 清个人字段、清 Lobby 暂态 | 不清队伍 / 角色(保留进比赛) |
EndMatch() | InGame 中 Update 检测到 score >= target 或时间到 | 切 phase=RESULT、写 matchEndServerTime、计算结算快照(胜方、MVP) | 不清 totalScore 与 teamScore(结算屏要看) |
RestartMatch() | 玩家按 Restart 触发 HandleRestart,由 HandleRestart 调用 | 切 phase=LOBBY、清所有 InGame 字段、清结算快照、广播给 PlayerObject 清 Ready / wantStart / personalScore | 不清队伍 / 角色(保留组队配置方便快速重玩) |
BeginMatch 入口长这样:
private void BeginMatch(){ if (!Networking.IsOwner(gameObject)) return; if (phase != PHASE_LOBBY) return;
phase = PHASE_INGAME; totalScore = 0; phaseStartServerTime = Networking.GetServerTimeInSeconds(); matchEndServerTime = 0;
BroadcastBeginMatch(); // 让玩家自己清 PlayerObject 上的本局字段
inStartCountdown = false; startCountdownEndServerTime = 0;
RequestSerialization(); ApplyState();}EndMatch 与 RestartMatch 是同款骨架:阶段检查 → 改字段 → 广播(如需)→ RequestSerialization + ApplyState。具体字段动什么不动什么,看下面那张速查表。
字段在三档要不要清
Section titled “字段在三档要不要清”下面是本卷推荐的字段清理规则。新加字段时按这张表归档。
| 字段所属对象 | 字段例子 | BeginMatch | EndMatch | RestartMatch |
|---|---|---|---|---|
| GameState 共享 | phase | 切 INGAME | 切 RESULT | 切 LOBBY |
| GameState 共享 | totalScore | 清为 0 | 不清(结算用) | 清为 0 |
| GameState 共享 | phaseStartServerTime | 写新值 | 不清 | 清为 0 |
| GameState 共享 | matchEndServerTime | 清为 0 | 写新值 | 清为 0 |
| GameState 共享 | inStartCountdown | 清 false | 不动 | 清 false |
| GameState 暂态 | bonusTakenByPlayerId(第 13 章) | 清为 -1 | 不动 | 清为 -1 |
| PlayerObject 个人 | personalScore | 清为 0 | 不清 | 清为 0(兜底) |
| PlayerObject 个人 | teamId | 不清 | 不清 | 不清 |
| PlayerObject 个人 | roleId | 不清 | 不清 | 不清 |
| PlayerObject 个人 | isReady | 不清 | 不清 | 清为 false |
| PlayerObject 个人 | wantStart | 不清 | 不清 | 清为 false |
| PlayerObject 个人 | requestId / lastProcessedRequestId | 不清 | 不清 | 不清 |
读这张表的判断规则:
- 共享 InGame 字段:BeginMatch 写新值或清零,EndMatch 不动(结算要看),RestartMatch 清零。
- 共享 Lobby 字段(如
inStartCountdown):BeginMatch 必须清,免得 InGame 中残留倒计时状态显示。 - 个人比赛字段(如
personalScore):BeginMatch 清,EndMatch 不清。RestartMatch 在 BeginMatch 已经清过的基础上兜底再清一次。 - 个人配置字段(如
teamId/roleId):从不清。玩家只能通过显式RequestJoinTeam/RequestSetRole改动。 - 个人 Lobby 行为字段(
isReady/wantStart):BeginMatch 不动(玩家进 InGame 时 isReady=true,让它保留即可),RestartMatch 清,让玩家在新一局 Lobby 里重新确认。 - 请求流字段(
requestId/lastProcessedRequestId):永远不清。请求流是跨阶段的可观察记录,清掉等于丢失「这位玩家最近发出过什么请求」的恢复信息。
bonusTakenByPlayerId 这一行的「不动」是 EndMatch 阶段的设计选择:本局奖励状态保留进结算屏,让玩家看到「上局奖励是 X 拿走的」。RestartMatch 时再清。每个项目按需调整。
全房间广播:让玩家自己清自己
Section titled “全房间广播:让玩家自己清自己”GameState Owner 不能直接写其他玩家 PlayerObject 上的字段(第 13 章批准回执的边界)。开局清场要通知每位玩家自己动手。
SendCustomNetworkEvent(target=All, ...) 给所有客户端发同一条事件。每位玩家在自己客户端上跑回调,通过 Networking.IsOwner 判断「这是不是我的 PlayerObject」,是才清字段。这是「广播 + 本地过滤」模式:
// GameState 上的广播入口private void BroadcastBeginMatch(){ SendCustomNetworkEvent( VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(OnBeginMatchBroadcast));}
[NetworkCallable]public void OnBeginMatchBroadcast(){ var pls = FindLocalPLS(); // 取本地玩家自己 PlayerObject if (pls != null) pls.OnBeginMatch(); // 让 PLS 自己清字段}PlayerLobbyState.OnBeginMatch 内部第一行检查 if (!Networking.IsOwner(gameObject)) return;,确保只有本地玩家自己写自己的字段。
锁队伍与锁角色:不重复发明轮子
Section titled “锁队伍与锁角色:不重复发明轮子”第 15 章的 HandleJoinTeam 和 HandleSetRole 都已经有 if (phase != PHASE_LOBBY) ReplyXxxRejected(who, 2); return;。InGame 中切队 / 切角色会被拒绝。
本章只补一条 UI 侧规则:Lobby 段以外的阶段,把队伍切换 / 角色切换的按钮置灰或隐藏。这条不写代码,写约束:
- 队伍 / 角色 / Ready / Start Vote 按钮:只在
phase==LOBBY时可点。 - Score / 技能按钮:只在
phase==INGAME时可点。 - Restart 按钮:只在
phase==RESULT时可点。
UI 置灰是用户友好提示,不是安全门。安全门已经在 Handle* 里。GameLoopButton.Interact 里也可以加 if (gameState.Phase != PHASE_XXX) return; 做第二层拦截。三层(UI / Interact / Handle)合起来,正常玩家不会误触发,改客户端的玩家被最里层的 Handle* 阻挡。
中途强制结束:SanityCheck 的边界
Section titled “中途强制结束:SanityCheck 的边界”闭环 2 在 OnOwnershipTransferred 后跑过一次 SanityCheckAfterTakeover,处理 phase=INGAME 但房间空了的情况。本章把这条扩展。
SanityCheck 跑在三种时刻:
- 接管
GameState之后(已实现)。 - 玩家离开后(候选队列接管时已经触发)。
Update里 InGame 阶段每秒检查一次(本章新增的TickSanity)。
第三种是补一道兜底:开局后所有玩家都离开,新 Owner 接管时 SanityCheckAfterTakeover 把状态拽回 LOBBY,但接管延迟可能让 phase=INGAME 的状态保留几秒。每秒兜底检查一次能更快发现并回到 LOBBY。
兜底版本没必要 BroadcastResetLobby(房间没玩家),所以走另一个分支 RestartMatchSilent():跳过广播,只清 GameState 字段。
挑一条试。每条都不要直接翻第 17 章答案。
- 把 BeginMatch 改成「锁定本局开始时刻每位玩家的队伍 + 角色快照」。在 GameState 上加
[UdonSynced] byte[] teamSnapshotByPlayerId,BeginMatch 时遍历填充。这样玩家在 InGame 中切角色(如果你解开了 phase 检查)也不会影响本局计分依据。这个快照在第 17 章计分会派上用场。 - 把 RestartMatch 的「清 isReady / wantStart」改成「保留 Ready,只清 wantStart」。这样玩家不需要每局重新按 Ready,但仍然要重新投 Start Vote。哪种 UX 更好?这是 Lobby UI 还是 GameState 的决策?
- 给 BeginMatch 加
[UdonSynced] int matchSeq:每次开局递增。所有 InGame 期间的personalScore/bonusTakenByPlayerId都带上matchSeq校验。这是闭环 3stateVersion的预演。
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. 倒计时归零进 INGAME | phase=INGAME,所有客户端 1 秒内 totalScore=0、personalScore=0 | … |
| 2. EndMatch 触发 | phase=RESULT,matchEndServerTime 写入新值,totalScore 保留 | … |
| 3. RestartMatch | phase=LOBBY,所有玩家 isReady=false、wantStart=false、teamId/roleId 保留 | … |
| 4. InGame 中切角色被拒 | OnRoleRejected reason=2,roleId 不变 | … |
| 5. 房间空了 | 1 秒内 phase 从 INGAME 回到 LOBBY(TickSanity) | … |
| 6. RestartMatch 时 Owner 转移 | 新 Owner 接管,状态正确回到 LOBBY,无字段残留 | … |
| 7. 连续 3 局快速重开 | 每一局 personalScore 都从 0 起,requestId / lastProcessedRequestId 不重置 | … |
| 8. EndMatch 时多位玩家拿过奖励 | bonusTakenByPlayerId 保留进 RESULT,RestartMatch 清掉 | … |
第 7 行是「重复事务」属性的体现:BeginMatch 是同一道入口,跑 N 次得到 N 次干净的开局。如果某局的 personalScore 没清干净,先回去检查 OnBeginMatch 是不是漏了 Owner 检查。
- 能解释为什么把开局抽成
BeginMatch函数比直接在TickStartVote里写七行字段更稳 - 能默写「字段在 BeginMatch / EndMatch / RestartMatch 三档要不要清」表里 3 类字段的处理
- 能区分
target=All广播 + 本地过滤 vstarget=Owner单播的适用场景 - 能复述
OnBeginMatchBroadcast里必须有的两道闸门(Networking.IsOwner+ 字段访问的 PlayerObject 隔离) - 能识别
TickSanity每秒检查一次的两条理由
- VRChat Creator Docs · Network Events —
SendCustomNetworkEvent的 target 选项、[NetworkCallable]、事件不会重放给迟入玩家等官方说明。 - 第 13 章 · 权限与冲突 — 批准回执模式(
target=Owner单播)。 - 第 14 章 · Owner 离开后的恢复 —
SanityCheckAfterTakeover的母本。 - 第 15 章 · 大厅、准备、队伍 — Start Vote 倒计时与 Lobby 字段。
- 闭环 2 · PlayerObject + GameState 的中级一局 —
phase状态机。 - 附录 · 术语表 —
Match Lifecycle、Broadcast、Sanity Check的客观定义。