第 27 章 · 计分、奖励与结算进阶
这一章解决:第 17 章的三层分数(个人 / 队伍 / 总分)够用于「按按钮加 1 分」的最小玩法。合作防守原型要做的还有:助攻判定、波次结束时的时间奖励、PvP 不对称队伍的相对评分、玩家中途离开导致的队伍空位补偿。每一类都要决定字段放哪里、谁有权改、怎么仲裁。
先看一下
闭环 3 + 第 17 章扩展跑一局 4 人合作防守:A 给敌人打了 80% 血量,B 收掉最后 20%。第 17 章默认全部分给 B(最后一击算分)。玩家会抗议:「我打了大半,最后一刀的人不应该独占」。这是助攻判定缺失。
类似缺失还有几处。波次结束时玩家剩 30 秒未用,原始三层架构没有「时间奖励」字段。一个 4 人合作 PvE 与 2v2 PvP 用相同的 winningTeam 字段,但两种玩法的胜负判定逻辑完全不同。本章把这些扩展加进来,每一类都不发明新模式,沿用第 17 章三层 + 第 22 章命令日志的现有工具。
这一章会拿到什么
- 助攻判定的两条实现路径(命令日志扫描 vs PlayerObject 上的伤害簿)
- 时间奖励字段在三层中的归属选择
- 不对称队伍(PvE 1 队 vs PvP 2 队 vs 派对 4 队)的
winningTeam计算 - 临时空位(玩家中途离开)对队伍分的影响
- 一份「这条新分数字段放哪里」的判断流程
依赖前面
- 第 17 章三层分数架构(
personalScore/teamScore[]/totalScore) - 第 17 章结算快照(
resultWinningTeam/resultMvpPlayerId)+OnMatchEndedHook接口 - 第 22 章命令日志(
cmdRequestId[]/cmdPlayerId[]/cmdServerLikeTime[]) - 闭环 3
OnPlayerLeftCleanup扩展点
字段放哪里:先复述三层
Section titled “字段放哪里:先复述三层”第 17 章的三层放第三遍出现,不展开。新增字段全部按这条规则归位。
| 层 | 位置 | 写权 | 适合存什么 |
|---|---|---|---|
| 个人 | PlayerLobbyState(PlayerObject) | 玩家本人通过批准回执 | per-player 的本局贡献 |
| 队伍 | GameState.teamScore[] | GameState Owner 直接 | 队伍间相对的胜负 |
| 房间 | GameState.totalScore | GameState Owner 直接 | 合作 PvE 的整体进度 |
新加的字段照表归位即可。下面具体几类。
需求:对最后 N 秒内对该敌人造成过伤害的玩家,按伤害比例分配助攻分。
实现路径有两条,分别有适合的场景。
路径 A:扫描命令日志
Section titled “路径 A:扫描命令日志”第 22 章命令日志(cmdRequestId[] / cmdPlayerId[] / cmdType[] / cmdPayload[] / cmdServerLikeTime[])每条记录已有「谁、什么时候、做了什么」。把伤害事件也写进命令日志,击杀时扫描最近 N 秒内对该敌人的伤害条目。
代价是伤害事件要走命令同步。每位玩家每次伤害都触发一次 RequestSerialization 写入命令日志,对 GameState 的同步包是持续压力。轻实时玩法(每秒 5–10 次伤害)下命令日志的字节预算很快被吃光。
适合的场景:回合制 + 命令日志已经在用、命令日志容量足够、伤害事件本来就要记录的玩法。
路径 B:PlayerObject 上的伤害簿(per-target)
Section titled “路径 B:PlayerObject 上的伤害簿(per-target)”每位玩家维护一份「对每个敌人造成的伤害」记录,挂在 PlayerLobbyState 上。击杀事件触发时,所有玩家本地查自己的伤害簿,向 GameState 报告自己对该敌人造成过的伤害。
// PlayerLobbyState(per-target 伤害簿)[UdonSynced] private int[] damageToEnemyId = new int[64]; // 敌人 ID(局内编号)[UdonSynced] private int[] damageAmount = new int[64];[UdonSynced] private int damageHead = 0; // 环形指针
// 玩家本人调用public void RecordDamage(int enemyId, int amount){ damageToEnemyId[damageHead] = enemyId; damageAmount[damageHead] = amount; damageHead = (damageHead + 1) % damageToEnemyId.Length; RequestSerialization();}
// GameState Owner 端查询:某玩家对某敌人造成过多少伤害public int QueryDamageOnEnemy(int playerId, int enemyId) { /* 遍历返回 */ }代价是PlayerObject 字段同步压力。每位玩家自己管自己的伤害簿,64 容量 × 8 byte = 512 byte 单包。8 人世界 8 份 PlayerObject × 每秒几次伤害更新,PlayerObject 同步预算不会爆,但要节流(仅在伤害发生时同步,不每帧同步)。
适合的场景:轻实时玩法(合作防守 / 推塔)、玩家数较少(≤8)、敌人编号能稳定分配。
击杀时计算助攻
Section titled “击杀时计算助攻”不管走哪条路径,击杀回调里调用同一个仲裁函数。
// GameState 上:击杀仲裁private void OnEnemyKilled(int killerPlayerId, int enemyId){ // 查最近 N 秒(或最近若干次)对该敌人的伤害贡献 // 按伤害比例分配 personalScore(路径 A 走 cmd[] 扫描,路径 B 走 PLS 查询) int totalDamage = 0; int[] contributors = ComputeContributors(enemyId, out int[] dmgPerPlayer); for (int i = 0; i < contributors.Length; i++) totalDamage += dmgPerPlayer[i];
if (totalDamage <= 0) { // 没有记录到伤害,全分给最后一击 AddPersonalScore(killerPlayerId, ENEMY_BASE_SCORE); return; }
for (int i = 0; i < contributors.Length; i++) { int share = (ENEMY_BASE_SCORE * dmgPerPlayer[i]) / totalDamage; AddPersonalScore(contributors[i], share); }}AddPersonalScore 是第 17 章「批准回执模式」的入口,本章不重写。
需求:合作 PvE 提前 N 秒清完所有波次,剩余时间换算成分数。PvP 通常不需要这一项(双方时间公平)。
实现的关键是「时间奖励放哪一层」。三层各自有理由。
| 候选 | 理由 | 反对理由 |
|---|---|---|
personalScore(个人层) | 每位玩家独立 | 实际上是房间整体提前结束,不应分到个人 |
teamScore[](队伍层) | 队伍整体获奖 | PvE 单队时退化成「全员得奖」,和总分等价 |
totalScore(房间层) | 整体进度提前 | 不直接奖励玩家,结算屏体感弱 |
合作 PvE 推荐「在结算时把时间奖励按贡献占比分到结算快照」。房间层先算总奖励,再按个人贡献写进 GameState 上的 RESULT 快照。若项目还希望玩家自己的 personalScore 在下一局前也包含这笔奖励,再通过批准回执异步写回 PlayerObject。
这里有一个关键边界:personalScore 的 Owner 是玩家本人,GameState Owner 发出批准回执后,字段不会在同一帧立刻变成新值。MVP 和结算屏要读 GameState 上已经拍好的快照,不能读「刚刚回写中的 PlayerObject 字段」。
关键片段:
// GameState 上的 RESULT 快照[UdonSynced] private int[] resultPlayerIds = new int[8];[UdonSynced] private int[] resultPersonalScores = new int[8];[UdonSynced] private int resultPlayerCount;
public float timeBonusRate = 1f; // 每剩余 1 秒奖 1 分
private void EndMatch(){ phase = PHASE_RESULT; matchEndServerTime = Networking.GetServerTimeInSeconds();
CaptureSettlementScores(); // 把当前 PLS.PersonalScore 拷进 RESULT 快照
float remaining = ComputeRemainingTime(); int totalBonus = (int)(remaining * timeBonusRate); ApplyTimeBonusToSettlement(totalBonus); // 只改快照,不等待批准回执
resultWinningTeam = ComputeWinningTeam(); resultMvpPlayerId = ComputeMvpPlayerIdFromSettlement(); RequestSerialization(); ApplyState();
SendTimeBonusReceipts(); // 可选:异步回写 PlayerObject}
private void ApplyTimeBonusToSettlement(int totalBonus){ int sumPersonal = SumResultPersonalScores(); if (sumPersonal <= 0) return;
for (int i = 0; i < resultPlayerCount; i++) { int share = (totalBonus * resultPersonalScores[i]) / sumPersonal; resultPersonalScores[i] += share; }}ApplyTimeBonusToSettlement 在 EndMatch 内部调一次,改的是 GameState 上的结算快照。结算屏直接读 resultPersonalScores[],不会等待 PlayerObject 的批准回执。SendTimeBonusReceipts 只是可选回写:它让玩家本人把奖励写回自己的 PlayerObject,但 MVP 和结算显示不依赖这条回写。
ComputeRemainingTime 取决于玩法:
- 合作 PvE:
max(0, matchDuration - elapsed) - 波次提前清完:
max(0, expectedFullDuration - elapsed) - 限时挑战:用项目自定义公式
需求:相同 GameState 字段同时支持 PvE 单队、PvP 双队、派对 4 队。
teamScore[] 是定长数组(第 17 章选 int[8]),适配多种 teamCount。resultWinningTeam 是单字节,254 个有效值绰绰有余。剩下的工作是 ComputeWinningTeam 函数按 teamCount 分支处理。
public int teamCount = 2; // BeginMatch 时按本局规则设public int targetScore = 100; // 胜负阈值
private byte ComputeWinningTeam(){ if (teamCount == 1) { // PvE 单队:达到 targetScore 算赢,否则 255 表示未通关 return (totalScore >= targetScore) ? (byte)0 : (byte)255; }
// 多队:找分数最高的,平分时返回 255(平局) int maxScore = -1; int winningTeam = 255; bool tie = false; for (int i = 0; i < teamCount; i++) { if (teamScore[i] > maxScore) { maxScore = teamScore[i]; winningTeam = i; tie = false; } else if (teamScore[i] == maxScore && winningTeam != 255) { tie = true; } } return tie ? (byte)255 : (byte)winningTeam;}PvP 不对称队伍(如「1 个守方 vs 3 个攻方」)的胜负判定不能直接走 teamScore[] 比大小:守方目标和攻方目标可能完全不同。这种玩法用专门的 attackerWonTimes / defenderWonTimes 同步字段,ComputeWinningTeam 按这些字段判断。三层架构仍然能用,只是多加几个同步字段。
临时空位的处理
Section titled “临时空位的处理”玩家中途离开实例,他的队伍少一个人。问题:
- 他的
personalScore应该被加到teamScore[]吗? - 他离开后队伍的胜负判定要不要补偿?
- MVP 计算还要不要算他?
本卷推荐三条处理。
处理 1:personalScore 跟着 PlayerObject 销毁
Section titled “处理 1:personalScore 跟着 PlayerObject 销毁”不挽救。第 17 章第 5 章已经讨论过:玩家离开实例时 PlayerObject 销毁,personalScore 字段消失。teamScore[] 在加分时已经累计,离开者的贡献保留在队伍分里。
如果项目要保留离开者的个人记录到结算屏,需要在 GameState 上加 int[] departedPlayerScores(按离开顺序追加),OnPlayerLeftCleanup 里写入。代价是数组持续增长,建议设上限。本卷不实装。
处理 2:胜负判定不动态调整
Section titled “处理 2:胜负判定不动态调整”ComputeWinningTeam 在 EndMatch 时按当时的 teamScore[] 算。中途有人离开不影响,因为 teamScore[] 字段从来不会因为玩家离开而被扣减。
但有一类玩法需要补偿:PvP 3v3,红队第 5 分钟一人退出,剩 2 人对 3 人。这种情况要不要在 teamScore[] 上给红队加补偿?
设计选择,本卷不替项目决定。给一个最小补偿模板:
public override void OnPlayerLeft(VRCPlayerApi player){ base.OnPlayerLeft(player); if (!Networking.IsOwner(gameObject)) return; if (phase != PHASE_INGAME) return;
var pls = GetPLS(player); if (pls == null) return;
int teamId = pls.TeamId; if (teamId < 0 || teamId >= teamCount) return;
// 可选补偿:把离开者的 personalScore 1:1 加到队伍分 teamScore[teamId] += pls.PersonalScore; RequestSerialization();}这种补偿仅适用于「合作 PvE,离开者的贡献仍属于队伍」。PvP 玩法里离开者通常被视为投降,补偿规则要更复杂,本卷不展开。
处理 3:MVP 计算只算结算快照里的玩家
Section titled “处理 3:MVP 计算只算结算快照里的玩家”ComputeMvpPlayerIdFromSettlement 遍历 resultPlayerIds[] / resultPersonalScores[],找快照里的最高分。已离开的玩家如果没有被写进结算快照,自然不会被算入。项目如果要把中途离开者也纳入 MVP,需要先在 OnPlayerLeftCleanup 里把他的贡献写进 GameState,再让 CaptureSettlementScores 合并这份归档。
长期奖励:还是只到接口边界
Section titled “长期奖励:还是只到接口边界”第 17 章定的 OnMatchEndedHook(VRCPlayerApi who, int personalDelta) 仍然只作为 Vol.5 的接入点。时间奖励改成结算快照后,长期奖励从同一份快照里读:
private void SendTimeBonusReceipts(){ for (int i = 0; i < resultPlayerCount; i++) { VRCPlayerApi p = FindPlayerById(resultPlayerIds[i]); if (p == null || !p.IsValid()) continue; OnMatchEndedHook(p, resultPersonalScores[i]); }}调用时机仍然在 EndMatch 内部,参数仍然是 (玩家, 个人分增量)。本卷不写 OnMatchEndedHook 函数体。Vol.5 PlayerData / Persistence 章节会展开。
如果项目临时需要简单的本地持久化(不上传任何后端),可以在 OnMatchEndedHook 里写 VRCEnablePersistence 字段。但本卷主线不在这里展开,避免抢 Vol.5 的主轴。
「这条新分数字段放哪里」的判断流程
Section titled “「这条新分数字段放哪里」的判断流程”写新分数字段时按这一流程过一遍。
新字段是 per-player 的本局贡献? → 是 → 挂 PlayerLobbyState(personalScore 那一层) → 否 → 下一问
新字段是队伍间相对评分(胜负判定从这里读)? → 是 → 挂 GameState.teamScore[] 同款(按 teamId 索引) → 否 → 下一问
新字段是房间整体进度(不分玩家不分队伍)? → 是 → 挂 GameState.totalScore 同款 → 否 → 下一问
新字段是结算时计算的快照(结算后只读)? → 是 → 挂 GameState 上加 [UdonSynced] 字段,EndMatch 写入 → 否 → 重新审视字段是不是真的需要
字段是「中间计算的临时值」? → 是 → 不需要同步,本地计算即可(如 ComputeMvpPlayerId 内的临时变量)第 27 章扩展的字段全部能落到流程上:
- 助攻贡献 → per-player →
PlayerLobbyState.damageAmount[] - 时间奖励占比分配后的金额 → 结算快照 →
GameState.resultPersonalScores[] - 时间奖励回写 → per-player → 可选写回
PlayerLobbyState.personalScore - 时间奖励总额(当前剩余秒)→ 中间计算 → 不同步
- 不对称队伍专门字段(如
attackerWonTimes)→ 整体进度 →GameState - MVP playerId → 结算快照 →
GameState.resultMvpPlayerId
不同世界不同活法
Section titled “不同世界不同活法”挑一条试。
- 把助攻判定从「按伤害占比」改成「贡献过伤害的所有人均分助攻」。改哪一行?这种简化处理在什么场景下不够用?
- 给时间奖励加一个「队伍奖」:先按队伍分配,再按队内 personalScore 占比细分。新增几个中间步骤?字段不需要新增对吗?
- 把 PvP 不对称队伍(守方 vs 攻方)的胜负判定写成自定义函数,不用
teamScore[]比大小。需要哪些新同步字段?哪些字段从决策表看应该放GameState上而不是PlayerLobbyState?
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. A 打 80 血 B 收 20 血击杀 | personalScore: A +8, B +2(占比分配,整数误差可接受) | … |
| 2. 无伤害记录的击杀 | personalScore 全分给最后一击 | … |
| 3. 5 秒内多人对同一敌人输出 | 击杀时按各自伤害占比分配,total 不超过 ENEMY_BASE_SCORE | … |
| 4. 提前 30 秒结束(PvE 1Hz) | 时间奖励 30 分按各人 personalScore 占比分配 | … |
| 5. 没有得分的玩家有时间奖励吗 | 占比 0 分得 0 时间奖励 | … |
| 6. PvP 平局 | winningTeam=255,MVP 仍按总 personalScore 算 | … |
| 7. 中途离开者的 personalScore | 跟着 PlayerObject 销毁,结算屏不显示该玩家 | … |
| 8. 中途离开者的队伍补偿 | 按可选补偿模板,离开者的 personalScore 1:1 加到 teamScore[teamId] | … |
9. OnMatchEndedHook 调用 | 每位玩家结算时调一次,参数为 (player, personalDelta含时间奖励) | … |
第 4、5 行是时间奖励占比分配的核心。第 7、8 行验证临时空位的处理。
- 能解释助攻判定两条路径(命令日志扫描 vs 伤害簿)的适用场景
- 能识别「击杀时仲裁」比「每次伤害仲裁」更稳的原因
- 能默写时间奖励的归属推荐:合作 PvE 占比分到结算快照,可选回写 personalScore
- 能识别
ApplyTimeBonusToSettlement不能在OnDeserialization调用的原因 - 能用判断流程把新分数字段归到三层之一
- 能列出三种不对称队伍场景,并说出
teamScore[]是否够用
- 第 9 章 · VRCPlayerObject 边界卡 —
personalScore/ 伤害簿放 PlayerObject 的依据。 - 第 13 章 · 权限与冲突 — 批准回执模式。
- 第 14 章 · Owner 离开后的恢复 —
OnPlayerLeftCleanup扩展点。 - 第 17 章 · 计分、奖励与结算 — 三层架构母本。
- 第 22 章 · 命令模式与事件日志 — 助攻判定路径 A 的命令日志扫描。
- VRChat Creator Docs · Persistence —
OnMatchEndedHook在 Vol.5 接的持久化层。 - 附录 · 术语表 —
Assist Window/Time Bonus/Asymmetric Team的客观定义。