第 17 章 · 计分、奖励与结算
这一章解决:第 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.teamId与roleId - 第 12 章
IssueRequest/requestId流 - 第 13 章批准回执模式
- 第 5 章迟入恢复四类判断
把分数语义拆成三层。每层各有一份字段,互不混用。
| 层级 | 字段位置 | 字段名 | 写权 | 回答的问题 |
|---|---|---|---|---|
| 个人 | PlayerLobbyState(PlayerObject) | personalScore(int) | 玩家本人通过批准回执写 | 「本局个人贡献是多少」 |
| 队伍 | GameState | teamScore(int[]) | GameState Owner 直接写 | 「红队对蓝队多少」 |
| 房间 | GameState | totalScore(int) | GameState Owner 直接写 | 「这一局合作进度」 |
三层不是冗余存储。每层回答的问题不同:
- 个人分:结算屏要给玩家看本局贡献,第 8 部主项目要根据它算 MVP。
- 队伍分:PvP 对战玩法的胜负判定从这一层读。
- 总分:合作 PvE 玩法的目标分数从这一层读。
teamScore 是数组,按 teamId 索引,长度预设 8。teamCount=2 的 4 人合作原型只用前两位,剩下的位置预留给后续 teamCount=4 的派对世界扩展。
HandleScore 的扩展思路
Section titled “HandleScore 的扩展思路”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 会处理。本章不修。
结算冻结:靠 Handle 的阶段检查
Section titled “结算冻结:靠 Handle 的阶段检查”EndMatch 把 phase 切到 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 上加两个同步字段:
| 字段 | 类型 | 含义 |
|---|---|---|
resultWinningTeam | byte | 胜方 teamId,255 表示平局或无胜方 |
resultMvpPlayerId | int | MVP 的 playerId,-1 表示无 MVP |
EndMatch 的关键三步:
phase = PHASE_RESULT;matchEndServerTime = Networking.GetServerTimeInSeconds();resultWinningTeam = ComputeWinningTeam(); // 遍历 teamScore[],平局返 255resultMvpPlayerId = ComputeMvpPlayerId(); // 遍历所有 PLS.PersonalScore 找最大ComputeWinningTeam 检测平局:多个队伍同分时返回 255。ComputeMvpPlayerId 在所有玩家中找 personalScore 最大的,同分时取先遍历到的(不严格仲裁,本卷视为可接受)。
RestartMatch 时把这两个字段一起清,teamScore[] 数组也清。具体清理规则在第 16 章字段速查表里。
迟入玩家的两种视图
Section titled “迟入玩家的两种视图”迟入玩家进入实例时房间可能在 LOBBY、INGAME、RESULT 三档。LOBBY 不需要特殊处理(迟入玩家就是普通新玩家)。INGAME 和 RESULT 各需要一种视图。
INGAME 中迟入
Section titled “INGAME 中迟入”本卷推荐两种处理之一:
模式 A · 旁观席:迟入玩家不进入比赛,成为观察者。Lobby UI 显示「比赛进行中,剩余 N 秒结束」。第 18 章「观战、旁观、掉线后重入」展开旁观席的具体实现。
模式 B · 直接入队:把迟入玩家直接放进人少的队伍,从当前时刻起算他的 personalScore。这种处理在合作 PvE 玩法里常见。
本卷默认模式 A。模式 B 留给第八部主项目按需开放。第 18 章会用专门的 [UdonSynced] int[] spectatorPlayerIds 字段记录旁观者列表。
RESULT 中迟入
Section titled “RESULT 中迟入”phase=RESULT 时迟入玩家进来,结算屏的字段(teamScore / totalScore / resultWinningTeam / resultMvpPlayerId)通过同步字段拉过来即可,迟入玩家直接看到完整结算。
personalScore 例外:迟入玩家自己的 PlayerObject 是新克隆的,personalScore = 0。结算屏显示「我:0」是合理的(他没参与本局)。如果要进一步隐藏「我」一行,UI 加 if (myPersonalScore == 0 && phase == RESULT) hideMyRow。
这一段是迟入恢复(第 5 章「事件不会重放,状态会复制」)的具体应用。结算快照能让迟入玩家立刻重建 RESULT 视图,靠的就是「整批同步字段」。
长期奖励:只写接口边界
Section titled “长期奖励:只写接口边界”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(红)按 Score | totalScore +1、teamScore[0] +1、A.personalScore +1,三者同步 | … |
| 2. A、B 各自加分 | 各自的 personalScore 独立累加,互不影响 | … |
| 3. PvE 单队 8 人都加分 | teamCount=1 时 winningTeam=255(平局兼无胜方) | … |
| 4. PvP 红 6 蓝 5 | winningTeam=0,mvpPlayerId 为红队个人分最高的玩家 | … |
| 5. 平局 红 5 蓝 5 | winningTeam=255 | … |
| 6. RESULT 阶段按 Score | HandleScore 拒绝,totalScore 不变 | … |
| 7. RESULT 阶段迟入玩家 | 立刻看到完整结算(teamScore / totalScore / winningTeam / mvpPlayerId),自己 personalScore=0 | … |
| 8. INGAME 中迟入玩家 | UI 提示「比赛进行中」,本人不进入计分 | … |
| 9. RestartMatch | teamScore[] / resultWinningTeam / resultMvpPlayerId 全部清干净 | … |
第 4、5 行是结算计算正确性。第 7 行是迟入恢复的具体应用,对应第 5 章迟入恢复的四类判断中「整批同步字段足以让迟入玩家重建 RESULT 视图」。
- 能默写分数三层各自的字段位置与写权
- 能解释为什么
GameState Owner不能直接写personalScore,必须走批准回执 - 能复述结算冻结的实现机制(不靠新代码,靠 Handle 函数的阶段检查)
- 能说出 INGAME 中迟入和 RESULT 中迟入两种视图各自该看到什么
- 能识别长期奖励接口在本卷只到「签名」一档,不写实现
- VRChat Creator Docs · Persistence — 长期奖励持久化的官方说明,本卷只引边界。
- VRChat Creator Docs · Network Events —
NetworkEventTarget.Owner与参数化事件的官方说明。 - VR Creators · Udon Networking Decision Guide — 英文社区资料,用来对照「状态用同步变量,瞬时动作用事件」的选择规则。
- 第 9 章 · VRCPlayerObject 边界卡 —
personalScore放 PlayerObject 的依据。 - 第 13 章 · 权限与冲突 — 批准回执模式。
- 第 16 章 · 开局与重开 —
BeginMatch/EndMatch/RestartMatch三入口。 - 第 5 章 · 迟入玩家怎样恢复世界 — 迟入恢复四类判断。
- 附录 · 术语表 —
Personal Score、Team Score、Result Snapshot的客观定义。