跳转到内容

第 27 章 · 计分、奖励与结算进阶

约 11 分钟 难度:3 动手章

这一章解决:第 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 扩展点

第 17 章的三层放第三遍出现,不展开。新增字段全部按这条规则归位。

位置写权适合存什么
个人PlayerLobbyState(PlayerObject)玩家本人通过批准回执per-player 的本局贡献
队伍GameState.teamScore[]GameState Owner 直接队伍间相对的胜负
房间GameState.totalScoreGameState Owner 直接合作 PvE 的整体进度

新加的字段照表归位即可。下面具体几类。


需求:对最后 N 秒内对该敌人造成过伤害的玩家,按伤害比例分配助攻分。

实现路径有两条,分别有适合的场景。

第 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)、敌人编号能稳定分配。

不管走哪条路径,击杀回调里调用同一个仲裁函数。

// 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;
}
}

ApplyTimeBonusToSettlementEndMatch 内部调一次,改的是 GameState 上的结算快照。结算屏直接读 resultPersonalScores[],不会等待 PlayerObject 的批准回执。SendTimeBonusReceipts 只是可选回写:它让玩家本人把奖励写回自己的 PlayerObject,但 MVP 和结算显示不依赖这条回写。

ComputeRemainingTime 取决于玩法:

  • 合作 PvE:max(0, matchDuration - elapsed)
  • 波次提前清完:max(0, expectedFullDuration - elapsed)
  • 限时挑战:用项目自定义公式

需求:相同 GameState 字段同时支持 PvE 单队、PvP 双队、派对 4 队。

teamScore[] 是定长数组(第 17 章选 int[8]),适配多种 teamCountresultWinningTeam 是单字节,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 按这些字段判断。三层架构仍然能用,只是多加几个同步字段。


玩家中途离开实例,他的队伍少一个人。问题:

  1. 他的 personalScore 应该被加到 teamScore[] 吗?
  2. 他离开后队伍的胜负判定要不要补偿?
  3. MVP 计算还要不要算他?

本卷推荐三条处理。

处理 1:personalScore 跟着 PlayerObject 销毁

Section titled “处理 1:personalScore 跟着 PlayerObject 销毁”

不挽救。第 17 章第 5 章已经讨论过:玩家离开实例时 PlayerObject 销毁,personalScore 字段消失。teamScore[] 在加分时已经累计,离开者的贡献保留在队伍分里。

如果项目要保留离开者的个人记录到结算屏,需要在 GameState 上加 int[] departedPlayerScores(按离开顺序追加),OnPlayerLeftCleanup 里写入。代价是数组持续增长,建议设上限。本卷不实装。

ComputeWinningTeamEndMatch 时按当时的 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 合并这份归档。


第 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


挑一条试。

  • 把助攻判定从「按伤害占比」改成「贡献过伤害的所有人均分助攻」。改哪一行?这种简化处理在什么场景下不够用?
  • 给时间奖励加一个「队伍奖」:先按队伍分配,再按队内 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[] 是否够用