跳转到内容

第 16 章 · 开局与重开

约 9 分钟 难度:2 动手章

这一章解决:把「Lobby → InGame → Result → Lobby」这条回路做成可重复事务。每一次开局都用同一个入口、每一次重开都用同一个入口,避免每加一个新机制就要在三四个地方手动复位。

先看一下

第 15 章的 Start Vote 倒计时归零后写了七行代码:phase = INGAME、清 totalScore、写 phaseStartServerTime、撤倒计时、RequestSerializationApplyState。这七行是「开局」要做的全部吗?再加一条「比赛中场景里要复位的浮空炮塔」,又得改这七行所在的位置;再加一条「重开时不要清队伍配置但要清个人分数」,又得改一遍。

把这一段抽成 BeginMatch()EndMatch()RestartMatch() 三个入口。每个入口写一次「这一档要做的所有事」。后面所有章节加新字段时只更新这三个函数,不再四处散落。

这一章会拿到什么

  • 三个固定入口的职责对照:BeginMatch / EndMatch / RestartMatch 各做什么、不做什么
  • 一份「字段在三档要不要清」的速查表,加新字段时按表归档
  • 全房间广播 target=All 与点对点 target=Owner 的适用场景
  • TickSanity 兜底检查:什么时候空房自愈到 LOBBY

依赖前面

  • 第 15 章 Start Vote 倒计时
  • 第 13 章批准回执模式
  • 第 12 章 IssueRequest / requestId
  • 闭环 2 的 phase 状态机

「事务」在这里只取一个属性:要么整批做完,要么一次没做。每个客户端要么看到「比赛已开始 + 全部初始化完成」的状态,要么看到「比赛未开始」的状态,不应当看到「phase=INGAMEtotalScore 还是上一局的 5」这种字段只改了一半的画面。

闭环 2 的缺陷 2 已经描述过这个现象。本章不彻底修(彻底修要等闭环 3 的 stateVersion),但通过把开局集中到一个函数里,能减少漏改字段的概率。

具体做法是把「Lobby → InGame」这一刻该做的所有事全部塞进 BeginMatch()

  1. phase 到 INGAME。
  2. 复位本局共享的 InGame 字段(分数、时间戳、本局衍生计数器)。
  3. 广播给所有 PlayerObject:「请清你们的本局个人字段」(个人分、技能 CD、复活计数)。
  4. 清 Lobby 段的暂态(倒计时、Start Vote 标志)。

EndMatch()RestartMatch() 同款思路:每个入口写一次「这一档要做的事」,后续维护成本集中在三个函数里。


入口触发时机主要动作不做的事
BeginMatch()Start Vote 倒计时归零,由 TickStartVote 调用phase=INGAME、清共享 InGame 字段、广播给 PlayerObject 清个人字段、清 Lobby 暂态不清队伍 / 角色(保留进比赛)
EndMatch()InGame 中 Update 检测到 score >= target 或时间到phase=RESULT、写 matchEndServerTime、计算结算快照(胜方、MVP)不清 totalScoreteamScore(结算屏要看)
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();
}

EndMatchRestartMatch 是同款骨架:阶段检查 → 改字段 → 广播(如需)→ RequestSerialization + ApplyState。具体字段动什么不动什么,看下面那张速查表。


下面是本卷推荐的字段清理规则。新加字段时按这张表归档。

字段所属对象字段例子BeginMatchEndMatchRestartMatch
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 章的 HandleJoinTeamHandleSetRole 都已经有 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 校验。这是闭环 3 stateVersion 的预演。

场景预期实际
1. 倒计时归零进 INGAMEphase=INGAME,所有客户端 1 秒内 totalScore=0personalScore=0
2. EndMatch 触发phase=RESULTmatchEndServerTime 写入新值,totalScore 保留
3. RestartMatchphase=LOBBY,所有玩家 isReady=falsewantStart=falseteamId/roleId 保留
4. InGame 中切角色被拒OnRoleRejected reason=2roleId 不变
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 广播 + 本地过滤 vs target=Owner 单播的适用场景
  • 能复述 OnBeginMatchBroadcast 里必须有的两道闸门(Networking.IsOwner + 字段访问的 PlayerObject 隔离)
  • 能识别 TickSanity 每秒检查一次的两条理由