跳转到内容

第 17 章 · 计分、奖励与结算

约 8 分钟 难度:2 动手章

这一章解决:第 16 章把比赛跑起来后,分数怎么落到「个人 / 队伍 / 房间」三个层级,结算屏怎么显示一份不会再变的快照,迟入玩家在 InGame 中和 RESULT 中分别看到什么。

先看一下

闭环 2 的 totalScore 是一个 int:所有玩家加的分一起进这个池子。4 人合作防守原型需要拆出三种视图:个人贡献、队伍对比、结算 MVP。totalScore 一个字段撑不住这三种视图。

把它拆成三层:每位玩家的 personalScore 在 PlayerObject 上、每个队伍的 teamScore[] 在 GameState 上、房间总分 totalScore 在 GameState 上从两者派生。每加一分时三层一起更新,结算屏读三层各自的快照。

这一章会拿到什么

  • 分数三层架构:personalScore / teamScore[] / totalScore
  • 三层各自的写权与同步代价对照
  • 结算冻结模式:靠 Handle* 的阶段检查,不靠新代码
  • 结算快照:胜方与 MVP 在 EndMatch 时算一次写进同步字段
  • 迟入玩家两种视图:INGAME 中显示「比赛进行中」,RESULT 中显示完整结算
  • 长期奖励的接口边界:本卷只规定接口签名,实现在 Vol.5

依赖前面

  • 第 16 章 BeginMatch / EndMatch / RestartMatch 三个入口
  • 第 15 章 PlayerLobbyState.teamIdroleId
  • 第 12 章 IssueRequest / requestId
  • 第 13 章批准回执模式
  • 第 5 章迟入恢复四类判断

把分数语义拆成三层。每层各有一份字段,互不混用。

层级字段位置字段名写权回答的问题
个人PlayerLobbyState(PlayerObject)personalScore(int)玩家本人通过批准回执写「本局个人贡献是多少」
队伍GameStateteamScore(int[])GameState Owner 直接写「红队对蓝队多少」
房间GameStatetotalScore(int)GameState Owner 直接写「这一局合作进度」

三层不是冗余存储。每层回答的问题不同:

  • 个人分:结算屏要给玩家看本局贡献,第 8 部主项目要根据它算 MVP。
  • 队伍分:PvP 对战玩法的胜负判定从这一层读。
  • 总分:合作 PvE 玩法的目标分数从这一层读。

teamScore 是数组,按 teamId 索引,长度预设 8。teamCount=2 的 4 人合作原型只用前两位,剩下的位置预留给后续 teamCount=4 的派对世界扩展。


HandleScore 在闭环 2 里只做 totalScore++。本章扩展成三层一起更新。三层的写法不同:

// HandleScore 内部的核心三步
totalScore += delta; // 共享层直接写
if (team < teamScore.Length) teamScore[team] += delta; // 共享层直接写
// 个人层走批准回执
pls.SendCustomNetworkEvent(
NetworkEventTarget.Owner,
nameof(PlayerLobbyState.OnPersonalScoreAdded),
delta);

共享层(totalScore / teamScore)由 GameState Owner 直接写。个人层(personalScore)走第 13 章批准回执:GameState 发事件给 PLS 的 Owner(即玩家本人),玩家本人写自己 PlayerObject 字段。

三层更新顺序:先共享层,后个人层。如果 GameState 同步成功而批准回执途中丢失,会出现「房间总分 +1,但这位玩家的个人分没 +1」的不一致。本卷把这种不一致归类为闭环 2 缺陷 2 的一部分(状态版本乱),闭环 3 的 stateVersion 会处理。本章不修。


EndMatchphase 切到 RESULT 后,所有 Handle* 函数的第一道闸门 if (phase != PHASE_XXX) return; 把请求挡住。结果是 RESULT 阶段:

  • HandleScore 拒绝(要 INGAME)。
  • HandleJoinTeam / HandleSetRole 拒绝(要 LOBBY)。
  • HandleStart 不存在(开局走 Start Vote,不是请求字段)。
  • HandleRestart 接受(要 RESULT),调 RestartMatch 切回 LOBBY。

这是「结算冻结」的实现:不写新的冻结代码,靠每个 Handle 函数自己的阶段检查。teamScore / totalScore / personalScore 在 RESULT 阶段只读,UI 直接读字段就是结算屏的内容。


有一类显示需要在 EndMatch额外写一份快照:胜方、MVP playerId。这一份快照不是「字段值的复制」,是「计算结果」。算这两个值要遍历所有玩家,最好在 EndMatch 那一刻算一次、写进同步字段,结算屏读字段即可。

GameState 上加两个同步字段:

字段类型含义
resultWinningTeambyte胜方 teamId,255 表示平局或无胜方
resultMvpPlayerIdintMVP 的 playerId,-1 表示无 MVP

EndMatch 的关键三步:

phase = PHASE_RESULT;
matchEndServerTime = Networking.GetServerTimeInSeconds();
resultWinningTeam = ComputeWinningTeam(); // 遍历 teamScore[],平局返 255
resultMvpPlayerId = ComputeMvpPlayerId(); // 遍历所有 PLS.PersonalScore 找最大

ComputeWinningTeam 检测平局:多个队伍同分时返回 255。ComputeMvpPlayerId 在所有玩家中找 personalScore 最大的,同分时取先遍历到的(不严格仲裁,本卷视为可接受)。

RestartMatch 时把这两个字段一起清,teamScore[] 数组也清。具体清理规则在第 16 章字段速查表里。


迟入玩家进入实例时房间可能在 LOBBY、INGAME、RESULT 三档。LOBBY 不需要特殊处理(迟入玩家就是普通新玩家)。INGAME 和 RESULT 各需要一种视图。

本卷推荐两种处理之一:

模式 A · 旁观席:迟入玩家不进入比赛,成为观察者。Lobby UI 显示「比赛进行中,剩余 N 秒结束」。第 18 章「观战、旁观、掉线后重入」展开旁观席的具体实现。

模式 B · 直接入队:把迟入玩家直接放进人少的队伍,从当前时刻起算他的 personalScore。这种处理在合作 PvE 玩法里常见。

本卷默认模式 A。模式 B 留给第八部主项目按需开放。第 18 章会用专门的 [UdonSynced] int[] spectatorPlayerIds 字段记录旁观者列表。

phase=RESULT 时迟入玩家进来,结算屏的字段(teamScore / totalScore / resultWinningTeam / resultMvpPlayerId)通过同步字段拉过来即可,迟入玩家直接看到完整结算。

personalScore 例外:迟入玩家自己的 PlayerObject 是新克隆的,personalScore = 0。结算屏显示「我:0」是合理的(他没参与本局)。如果要进一步隐藏「我」一行,UI 加 if (myPersonalScore == 0 && phase == RESULT) hideMyRow

这一段是迟入恢复(第 5 章「事件不会重放,状态会复制」)的具体应用。结算快照能让迟入玩家立刻重建 RESULT 视图,靠的就是「整批同步字段」。


VRChat 的 VRCEnablePersistence 组件挂在带 UdonBehaviour 的 GameObject 上,让其上的 [UdonSynced] 字段跨会话保存。本卷不写持久化的细节(在 Vol.5)。本章只规定接口签名,让第八部主项目和 Vol.5 能对上。

接口约定:

  • 名称:OnMatchEndedHook(VRCPlayerApi who, int personalDelta)
  • 调用时机:EndMatch 内部,在写完结算快照之后
  • 参数:哪位玩家、本局个人分增量
  • 返回:无
private void OnMatchEndedHook(VRCPlayerApi who, int personalDelta)
{
// Vol.5 实现:持久层在这里读 personalDelta,更新 lifetimeScore 等持久字段
// 本卷只留接口签名,不写体
}

这条接口在 Vol.5 PlayerData / Persistence 章节会扩展。本卷写代码时只用 OnMatchEndedHook 占位。


挑一条试。

  • 把「平局 = 255」改成「平局时让 teamScore 高的玩家组成的虚拟队伍胜出」。这种处理合理吗?什么场景下平局应该真的是平局?
  • HandleScore 加一个「队伍分上限」:每队最多 50 分,到 50 分立刻 EndMatch。在哪一层加上限?这条上限和 targetScore 的关系是什么?
  • 把 MVP 计算改成「按队伍分 / 个人分加权」:队伍胜方的玩家 personalScore × 1.5 后再比。哪一层做这个加权(计算时 vs 写入时)?

场景预期实际
1. A(红)按 ScoretotalScore +1teamScore[0] +1A.personalScore +1,三者同步
2. A、B 各自加分各自的 personalScore 独立累加,互不影响
3. PvE 单队 8 人都加分teamCount=1winningTeam=255(平局兼无胜方)
4. PvP 红 6 蓝 5winningTeam=0mvpPlayerId 为红队个人分最高的玩家
5. 平局 红 5 蓝 5winningTeam=255
6. RESULT 阶段按 ScoreHandleScore 拒绝,totalScore 不变
7. RESULT 阶段迟入玩家立刻看到完整结算(teamScore / totalScore / winningTeam / mvpPlayerId),自己 personalScore=0
8. INGAME 中迟入玩家UI 提示「比赛进行中」,本人不进入计分
9. RestartMatchteamScore[] / resultWinningTeam / resultMvpPlayerId 全部清干净

第 4、5 行是结算计算正确性。第 7 行是迟入恢复的具体应用,对应第 5 章迟入恢复的四类判断中「整批同步字段足以让迟入玩家重建 RESULT 视图」。


  • 能默写分数三层各自的字段位置与写权
  • 能解释为什么 GameState Owner 不能直接写 personalScore,必须走批准回执
  • 能复述结算冻结的实现机制(不靠新代码,靠 Handle 函数的阶段检查)
  • 能说出 INGAME 中迟入和 RESULT 中迟入两种视图各自该看到什么
  • 能识别长期奖励接口在本卷只到「签名」一档,不写实现

  • VRChat Creator Docs · Persistence — 长期奖励持久化的官方说明,本卷只引边界。
  • VRChat Creator Docs · Network EventsNetworkEventTarget.Owner 与参数化事件的官方说明。
  • VR Creators · Udon Networking Decision Guide — 英文社区资料,用来对照「状态用同步变量,瞬时动作用事件」的选择规则。
  • 第 9 章 · VRCPlayerObject 边界卡personalScore 放 PlayerObject 的依据。
  • 第 13 章 · 权限与冲突 — 批准回执模式。
  • 第 16 章 · 开局与重开BeginMatch / EndMatch / RestartMatch 三入口。
  • 第 5 章 · 迟入玩家怎样恢复世界 — 迟入恢复四类判断。
  • 附录 · 术语表Personal ScoreTeam ScoreResult Snapshot 的客观定义。