今天更新一下moba游戏demo的伤害系统。目前的伤害计算函数实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
float UMMC_BaseAttackDamage::CalculateBaseMagnitude_Implementation(const FGameplayEffectSpec& Spec) const
{
FAggregatorEvaluateParameters EvalParams;
EvalParams.SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
EvalParams.TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();

float Attack = 0.f;
GetCapturedAttributeMagnitude(AttackCaptureDef, Spec, EvalParams, Attack);

float Armor = 0.f;
GetCapturedAttributeMagnitude(ArmorCaptureDef, Spec, EvalParams, Armor);

float DamageReduction = Armor / (Armor + 100);

float Damage = Attack * (1 - DamageReduction);

UE_LOG(LogTemp, Log, TEXT("Damage = %f"), Damage);

return -Damage;
}

思路很简单,就是根据攻击者的攻击力还有目标的护甲来计算出最终的伤害值并返回一个负数来表示生命值削减。

这段代码本身没有什么问题——捕获攻击方的 Attack 属性和防御方的 Armor 属性,套一个基本的减伤公式,返回一个负数直接叠加到 Health 上,逻辑清晰,代码简洁。

但问题在于 MMC 的定位。MMC 是作为 GameplayEffect 里某一个 Modifier 的计算插件存在的,一个 Modifier 只能针对一个目标属性做计算,MMC 的返回值也只有一个浮点数。这意味着 MMC 从结构上就被限制住了——它能做给 Health 改变多少这个问题,但做不到在造成伤害的同时顺便做些别的判断。

第一,想引入暴击系统。暴击是一个概率事件,需要在伤害计算的过程中做一次随机判断,如果暴击成立就把伤害乘以一个系数。这个判断和分叉的逻辑MMC 的 CalculateBaseMagnitude 一个返回值是表达不了的——返回的要么是普通伤害,要么是暴击伤害,但是否发生了暴击这个信息本身就丢失了。

第二,希望不同技能能传入不同的伤害系数。比如重击应该是 150% 攻击力,普攻是 100% 攻击力,激光技能是 80% 攻击力持续输出。这种技能决定计算参数的需求,依赖 GAS 的 SetByCaller 机制,而 MMC 读取 SetByCaller 的能力很弱,语义上也不自然。

第三,最根本的问题:MMC 的计算结果直接作用在 Health 这个属性上,是绕过了很多 GAS 推荐的最佳实践的。Health 是游戏里最核心的状态之一,直接用 Modifier 改它,意味着其他系统很难在”伤害发生”这个时机插入额外逻辑,比如护盾吸收、无敌帧判断、击杀奖励触发等等。

还是换成是UGameplayEffectExecutionCalculation,通常简称 ExecCalc。

ExecCalc 则是一个独立的计算单元,挂在 GE 的 Execution Calculations 列表里而不是 Modifiers 里。它拿到的是 FGameplayEffectCustomExecutionParameters,这个对象包含了完整的执行上下文——不仅是 Spec,还有 Source 和 Target 各自的 AbilitySystemComponent 引用,可以直接读取双方所有被声明为”可捕获”的属性。计算结束后,它通过 OutExecutionOutput.AddOutputModifier 往外输出任意数量的属性修改操作,而不仅仅是一个数值。

换用 ExecCalc 之前,还有一个设计问题需要先想清楚:伤害值算出来之后,怎么安全地交给 PostGameplayEffectExecute 去扣血?

如果 ExecCalc 直接输出对 Health 的修改,技术上没问题,但 PostGameplayEffectExecute 里分不清”这次 Health 变化来自伤害、回血、还是初始化”,逻辑分支会越来越乱,护盾吸收之类的中间层也没地方写。

GAS 社区里广泛采用的做法是引入一个”元属性”叫做 Damage。它不代表任何真实的游戏状态,不需要复制到客户端,也不会持久存在。它唯一的作用就是充当一个临时的数据通道:ExecCalc 把算出来的伤害值写进去,PostGameplayEffectExecute 把它读出来、立刻清零、然后从 Health 里扣掉。

这个设计把计算伤害是多少和把伤害落实到 Health 上两件事彻底分开了。ExecCalc 只关心前者,AttributeSet 的 PostGameplayEffectExecute 只关心后者,中间通过 Damage 这个元属性握手。将来想在”扣血之前”插入护盾逻辑,就在 PostGameplayEffectExecute 里读出 Damage 值之后、扣 Health 之前加判断就行,不需要动 ExecCalc 的任何代码。

有了 ExecCalc 和元属性之后,技能系数的问题也顺势解决了。GAS 的 SetByCaller 机制允许技能在创建 GE Spec 的时候,以一个 GameplayTag 为 Key,写入任意浮点值。ExecCalc 执行时用同一个 Tag 把这个值读出来,作为伤害系数参与计算。

为此专门在 GameplayTags 里新建了一个 Tags::Damage::Coefficient。技能只要在发起攻击时传入系数,伤害计算系统就能识别。如果技能没有传入这个值,ExecCalc 里设置的默认值是 1.0,等于 100% 攻击力,行为和原来保持一致。这样一来的话不同技能的伤害倍率完全由技能自己决定,伤害计算系统不需要知道是哪个技能调用的它,也不需要为每个技能维护单独的一份公式。新增技能只需要传入自己的系数,伤害系统的代码一行都不用改。

有了 ExecCalc 这个自由度后暴击系统的加入变得非常自然。在英雄属性集里加入暴击率和暴击伤害加成两个属性,ExecCalc 在执行时同样捕获这两个值,在算出基础伤害之后做一次概率判断,如果暴击成立就把伤害乘以对应倍率,最终写入 Damage 元属性。而且整个暴击判断完全内聚在 ExecCalc 里,不需要在 GE 蓝图上做任何额外配置。

受不了了编译一次100多个文件5到6分钟我真的受不了了早晚全改成前向声明。感觉我学习UE有一半时间都在编译啊啊啊啊啊啊啊啊啊啊啊啊玛德有些东西只能编译的时候报错看出来导致还要重新跑我真的受不了了受不了了也得受着谁叫我非得学UE不再学Unity了呢啊啊啊啊啊啊。

改造完成后,一次伤害的完整流程是这样的:技能代码创建伤害 GE 的 Spec,写入伤害系数,把 Spec 应用到目标身上。GE 执行时,ExecCalc 拿到双方属性和技能系数,算出经过护甲减伤和暴击判断的最终伤害,把这个正数写进 Damage 元属性。PostGameplayEffectExecute 检测到 Damage 属性被写入,读取其值、清零、扣除 Health,同步更新缓存的血量百分比。

这样每个环节就清晰了后面也好扩展。ExecCalc 往后还可以加伤害类型、护甲穿透、易伤 Tag 加成等逻辑;PostGameplayEffectExecute 往后可以加护盾、无敌、反伤等逻辑。

后面就好计算扩展了。