UE5.8 GAS Attribute 计算与 Meta Attribute

📑 目录

UE5.8 GAS Attribute 计算与 Meta Attribute

1. 被冰刺反噬的火球

莉莉的角色是一名元素使,主手拿着一根火属性法杖。今天她在霜语森林里遇到了一只冰霜傀儡。傀儡的血条很长,全身覆盖着厚实的冰甲。莉莉按下技能键,一发火球飞出,命中目标后跳出一串数字:

  • 基础伤害 320
  • 暴击 480
  • 被冰甲减免后 312
  • 火元素克制加成 1.25 倍后 390
  • 最终扣血 390

这一串数字看起来简单,背后却经过了好几轮属性计算。UE5.8 的 Gameplay Ability System(GAS)把这些计算拆成了多个阶段:属性声明、Base Value 与 Current Value 的维护、PreAttributeChange 的前置修正、Execution Calculation 的伤害公式、PostGameplayEffectExecute 的实际扣血,以及 Meta Attribute 作为临时载体的用法。这篇文章会沿着这场战斗的数值流程,把这些环节逐个拆开。

2. Attribute 不是单一数值

很多刚开始接触 GAS 的开发者会误以为 Attribute 就是一个 float,读取和写入都是直接操作。实际上,每个 Attribute 内部都封装了 FGameplayAttributeData,它同时保存两份数据:Base Value 和 Current Value。Base Value 是源头数值,Current Value 是在 Base Value 之上叠加了所有 Gameplay Effect(GE)修改后的结果。

可以把 Base Value 理解为角色面板上的裸值。比如莉莉的力量是 100,装备法杖后增加了 30 点攻击力,这两个数字相加得到 Base Attack。随后一个持续 10 秒的 BUFF 又让攻击力提升 20%,这个 20% 不会写回 Base Value,而是通过 GE 的 Modifier 挂在一个 Aggregator 上。读取 Current Value 时,GAS 会重新把所有 Modifier 聚合起来,得到当前真正生效的值。

flowchart TD
    A[Attribute 声明] --> B[初始化 Default Value]
    B --> C[Base Value]
    C --> D{是否有 GE 修改}
    D -->|是| E[Aggregator 计算]
    E --> F[Current Value]
    D -->|否| F
    F --> G[Clamp 到合法区间]
    G --> H[同步到客户端]

这种设计的意义在于解耦。Base Value 负责长期存档和成长曲线,Current Value 负责战斗中的临时状态。一个冰冻效果降低 50% 移速,只会影响 Current Value,战斗结束后 GE 被移除,角色立刻恢复到原来的 Base Value,不需要手动还原。

FGameplayAttributeData 提供了 SetBaseValueSetCurrentValue 两个底层接口。常规情况下,InitHealth 会同时设置 Base 和 Current。Instant GE 默认修改 Current Value,但如果你在 Modifier 的运算方式里选择 Override,也可以改写 Base Value。不过在战斗系统中,绝大多数 Modifier 都是 Add 或 Multiply,针对 Current Value 生效。读取 Current Value 时,GAS 会遍历所有 Active 的 GE,把它们的 Modifier 按照 Priority 和 Tag 过滤条件聚合起来,最终返回一个快照。这个快照只在读取那一刻有效,所以即使你在同一帧内多次读取,结果也可能因为 GE 的增删而变化。

在 C++ 里声明 Attribute 时,通常会这样写:

UCLASS()
class MYGAME_API UMyAttributeSet : public UAttributeSet
{
    GENERATED_BODY()
public:
    UPROPERTY(BlueprintReadOnly, Category="Attributes")
    FGameplayAttributeData Health;
    ATTRIBUTE_ACCESSORS(UMyAttributeSet, Health)

    UPROPERTY(BlueprintReadOnly, Category="Attributes")
    FGameplayAttributeData MaxHealth;

    UPROPERTY(BlueprintReadOnly, Category="Meta")
    FGameplayAttributeData IncomingDamage;
    ATTRIBUTE_ACCESSORS(UMyAttributeSet, IncomingDamage)

    virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override;
    virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;
};

ATTRIBUTE_ACCESSORS 宏会生成 GetHealthSetHealthInitHealthGetMaxHealth 等辅助函数,后续写 Execution Calculation 时会频繁用到。它还会生成 Attribute Getter 的静态函数,例如 static FGameplayAttribute GetHealthAttribute()。在蓝图中可以通过 Select Attribute 节点直接选择这些属性,不需要手写字符串,减少了拼写错误。

3. PreAttributeChange 与 PostGameplayEffectExecute

AttributeSet 提供了两个非常重要的回调,分别对应一次属性修改的前后。

PreAttributeChange 在 Current Value 即将改变时触发,参数 NewValue 是按引用传递的,你可以在这里把它 clamp 到合法范围。比如 Health 永远不应该超过 MaxHealth,也不应该低于 0。如果某个 GE 让 Health 变成 -50,你可以在 PreAttributeChange 里把它拉回 0。

void UMyAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
    if (Attribute == GetHealthAttribute())
    {
        NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxHealth());
    }
}

这个回调只负责数值边界,不应该在这里触发游戏逻辑事件,因为它可能在属性计算过程中被多次调用。真正的扣血逻辑、死亡判定、伤害飘字,应该放在 PostGameplayEffectExecute 里。

PreAttributeChange 的触发次数并不固定。一个 GE 里如果有多个 Modifier 同时修改同一个 Attribute,每次 Modifier 计算后都可能触发一次。因此这里只适合做幂等的边界修正,不适合做扣血只触发一次这种有副作用的逻辑。PostGameplayEffectExecute 则不同,它只在整个 GE 执行完成后触发一次,是处理副作用的安全位置。

PostGameplayEffectExecute 在一次 GE 执行完毕后触发,参数 Data.EvaluatedData.Attribute 告诉你哪个 Attribute 被修改了。这个回调适合处理 Meta Attribute。当 IncomingDamage 被写入后,你可以在这里把它换算成 Health 的扣减,然后清空 IncomingDamage,避免它留到下一帧造成重复扣血。

void UMyAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
    if (Data.EvaluatedData.Attribute == GetIncomingDamageAttribute())
    {
        const float Damage = GetIncomingDamage();
        SetIncomingDamage(0.0f);
        if (Damage > 0.0f)
        {
            const float NewHealth = GetHealth() - Damage;
            SetHealth(FMath::Clamp(NewHealth, 0.0f, GetMaxHealth()));
        }
    }
}

这里有一个容易踩的坑:不要在 PostGameplayEffectExecute 里直接给 Health 做减法,又把扣血逻辑写在一个 Instant GE 的 Modifier 里。这会导致扣血被执行两次。正确的做法是让 Instant GE 只修改 Meta Attribute,然后在 PostGameplayEffectExecute 里统一把 Meta Attribute 同步到 Health。

4. Meta Attribute:用 IncomingDamage 做中间层

Meta Attribute 不是角色面板上的可见属性,而是专门用来在 GE 执行过程中承载临时数值的属性。IncomingDamage 是最常见的 Meta Attribute。它的生命周期非常短:GE 触发前为 0,Execution Calculation 算出原始伤害后写入它,PostGameplayEffectExecute 把它应用到 Health 上,随后立刻重置为 0。

为什么要多此一举?因为伤害计算往往不是简单的加减法,而是涉及攻击力、防御力、暴击、元素抗性、技能倍率等多个变量。如果直接让 GE 修改 Health,很难在一个地方拿到完整的上下文。通过 IncomingDamage,Execution Calculation 可以一次性算好最终伤害,再把结果交给 AttributeSet 处理。

冰霜傀儡那一战里,系统内部经历了这样的流程:

  1. 莉莉的火球 GE 被应用到傀儡的 Ability System Component(ASC)上。
  2. 因为 GE 使用了自定义的 Execution Calculation,控制权进入 Execute_Implementation
  3. 在 Execution Calculation 里,读取攻击者的 BaseAtkBaseAtk、暴击率 CritChanceCritChance、暴击倍率 CritMulCritMul,读取目标的 DefDef、冰抗 IceResIceRes、火抗 FireResFireRes
  4. 计算最终伤害 FinalDamageFinalDamage
  5. FinalDamageFinalDamage 写入目标的 IncomingDamage
  6. 目标的 PostGameplayEffectExecuteIncomingDamage 从 Health 中扣除。
sequenceDiagram
    participant Attacker as 攻击者 ASC
    participant Target as 目标 ASC
    participant Exec as Execution Calc
    participant Set as AttributeSet
    participant Health as Health Attribute
    Attacker->>Target: Apply Gameplay Effect Spec
    Target->>Exec: 触发 Execution Calculation
    Exec->>Set: 读取 Meta Attribute IncomingDamage
    Set->>Exec: 返回原始值 0
    Exec->>Set: 计算后写入 IncomingDamage
    Set->>Set: PreAttributeChange 修正
    Set->>Set: PostGameplayEffectExecute 扣血
    Set->>Health: 更新 Current Health
    Health-->>Target: OnHealthChanged 事件

这种把计算和扣血分开的模式,让代码结构非常清晰。如果你想扩展伤害类型,比如吸血、反伤、护盾优先吸收,都可以在 PostGameplayEffectExecute 里统一处理,而不用改每一个 GE。

Meta Attribute 的另一个好处是可以避免网络同步上的浪费。IncomingDamage 通常只在服务器上存在一帧,客户端甚至不需要知道它的中间值。你可以把 IncomingDamage 的 Replication Condition 设为 None,只让 Health 的修改同步到客户端。这样既能保证服务器权威,又能减少网络流量。

5. Execution Calculation 与伤害公式

Execution Calculation 是 GAS 里自由度最高的伤害计算方式。普通的 Instant GE 只能做简单的 BaseValue * Coefficient + FlatBonus,而 Execution Calculation 可以完全自定义。你需要继承 UGameplayEffectExecutionCalculation,重写 Execute_Implementation

在冰霜傀儡的例子里,我们可以把伤害公式拆成下面几部分:

FinalDamage=max(0,BaseAtk(1+Δatk)DefPen)CritMulElemMulFinalDamage = \left\lfloor \max(0,\; BaseAtk \cdot (1 + \Delta_{atk}) - Def \cdot Pen) \cdot CritMul \cdot ElemMul \right\rfloor

其中:

  • BaseAtkBaseAtk 来自攻击者的攻击力 Current Value。
  • Δatk\Delta_{atk} 是当前技能的伤害加成系数。
  • DefDef 是目标的防御力。
  • PenPen 是穿透系数,比如 0.8 表示只计算 80% 防御。
  • CritMulCritMul 在暴击时取 1.5,否则取 1.0。
  • ElemMulElemMul 由元素克制关系决定,火打冰为 1.25,火打火为 1.0。

Capture 分为 Source 和 Target 两种。Source Capture 读取的是释放技能那一方的 Attribute,Target Capture 读取的是被作用方的 Attribute。在编辑器里,你可以通过 Gameplay Effect 的 Calculation Classes 添加 Captured Attributes。代码里则需要重写对应的捕获定义函数,把战斗公式里需要的变量提前声明,让 GAS 在执行前把它们收集好。伤害公式的可读性非常重要。当项目进入中后期,Execution Calculation 里可能有十几个系数。把每个中间变量都起好名字,比如 DamageAfterArmorCritAppliedDamage,比直接写一长串乘法链要容易维护得多。

还有一种更轻量的做法是使用 SetByCaller Magnitude。策划可以在 Ability 蓝图里给 GE Spec 设置一个 Tag 对应的浮点值,比如 Fireball.BaseDamage。Execution Calculation 里通过 Spec.GetSetByCallerMagnitude 读取这个值。这样同一个 Execution Class 可以复用在多个技能上,只需要在蓝图中配置不同的 Base Damage。

执行过程大致如下:

graph LR
    Strength -->|系数| Attack
    WeaponAtk --> Attack
    SkillBonus -->|Delta_atk| DamageBase
    Attack --> DamageBase
    Armor --> Defense
    BuffDef --> Defense
    Defense -->|减去| DamageAfterDef
    DamageBase --> DamageAfterDef
    Pen --> DamageAfterDef
    CritChance --> CritRoll
    CritMultiplier --> FinalDamage
    DamageAfterDef --> FinalDamage
    FireRes --> ElementalMultiplier
    IceRes --> ElementalMultiplier
    ElementalMultiplier --> FinalDamage
    FinalDamage --> IncomingDamage
    IncomingDamage --> Health

Execute_Implementation 里,你需要调用 EvaluateParametersExecuteModCallbacks 来捕获所有 Modifier。然后用 Capture 机制读取属性,写法比较固定,这里不再贴大段代码。核心思路是:先声明要捕获的 Attribute,比如 Source 的 Attack、Target 的 Defense,然后在 Execute 里读取这些捕获值,最后通过 OutExecutionOutput 把伤害写回 Target 的 IncomingDamage。

一个常被忽略的细节是捕获时机。Instant GE 的 Execution 在应用时只执行一次,Duration GE 的 Periodic 触发会周期性执行。如果你的技能是持续伤害,需要把 Execution 配在 Periodic 上,而不是 Instant。否则一次触发后就不会再有后续伤害。

6. Clamp 与属性下限

Clamp 是防止数值失控的最后防线。GAS 本身不会自动阻止 Health 变成负数,也不会阻止 Mana 超过上限。这些约束必须在代码里显式表达。

除了 Health,常见的还有 Mana、Stamina 的 clamp。移速 buff 后需要限制最大移速,避免角色飞出地图。攻击速度也要 clamp 到动画系统能处理的上限,否则可能会出现播放速度异常或者连击判定错乱。

PreAttributeChange 是最自然的 clamp 位置,因为它会在任何修改 Current Value 的操作之前生效。除了 Health,常见的还有:

  • Mana、Stamina clamp 到 [0, MaxMana]
  • 移速 buff 后 clamp 到一个最大移速,避免角色飞出地图。
  • 攻击速度 clamp 到上限,防止动画系统崩掉。

但 PreAttributeChange 不能修改 Base Value。如果你需要限制的是 Base Value 的成长,比如角色等级提升后力量不能超过某个上限,那就要在升级逻辑里手动 Set。还有一种情况:当 MaxHealth 被临时降低时,Current Health 可能超过新的 MaxHealth。此时可以在 PostAttributeChange 或者监听 OnHealthChanged 的回调里做二次处理。

另外要注意 clamp 的方向。伤害计算里常用 max(0, ...) 保证伤害非负。如果让防御力减伤公式变成 Damage = max(0, Attack - Defense),当防御高于攻击时伤害就是 0。这种硬下限简单直接,但也会让高防御敌人变得完全打不动。更常见的做法是引入一个最小伤害比例,比如:

Damage=max(Attack0.05,AttackDefPen)Damage = \max(Attack \cdot 0.05,\; Attack - Def \cdot Pen)

这样即使防御再高,也能保留 5% 的保底伤害,战斗不会完全陷入僵局。

Clamp 还要注意和 GE 的 Modifier 叠加顺序有关。GAS 的 Aggregator 支持 Priority,Priority 高的 Modifier 会先计算。如果一个 GE 同时提升 MaxHealth 和当前 Health,最好让 MaxHealth 的 Modifier 拥有更高 Priority,这样 Health 的 clamp 上限在 PreAttributeChange 里已经变大,不会出现先把 Health 加到 120 却发现 MaxHealth 还是 100 的尴尬情况。

7. 暴击、闪避与元素反应

复杂的战斗系统里,单次攻击往往要先判定是否命中、是否暴击、是否被格挡,再进入伤害计算。这些判定不应该散落在各个 Blueprint 里,而应该集中在 Execution Calculation 中,或者作为 Meta Attribute 的前置流程。

对于闪避,推荐的做法是给目标 ASC 加上一个 Gameplay Tag,比如 State.Evading。Execution Calculation 在计算伤害前读取这个 Tag,如果存在,就把伤害置零并标记为 Evaded。闪避不是属性,不应该用 Attribute 来表达,Tag 是更合适的抽象。

以暴击为例,Execution Calculation 里可以这样处理:

  • 读取攻击者的暴击率 CritChanceCritChance 和暴击倍率 CritMulCritMul
  • 生成一个随机数 r[0,1]r \in [0, 1]
  • 如果 r<CritChancer < CritChance,则 CritMulCritMul 生效,否则按 1.0 计算。
  • 最终伤害乘以生效后的暴击倍率。

闪避和格挡可以类似处理。如果目标闪避成功,直接把 IncomingDamage 设为 0,并在 PostGameplayEffectExecute 里触发一个闪避事件。格挡成功则减去固定数值或百分比。

元素反应更有趣。火球打冰傀儡触发的不是简单的伤害倍率,而是「融化」反应:先结算一次火伤,再根据剩余冰层附加一次额外伤害。这种二次结算可以通过在 Execution Calculation 里写两段逻辑完成。第一段算出基础火伤,第二段读取目标身上的元素状态 Tag,判断是否触发反应,如果是,再叠加一次反应伤害。

元素反应如果想做得更深,可以引入元素附着量。比如冰傀儡身上有 80 点冰元素附着,火球造成 100 点火伤,先消耗 80 点冰附着触发融化反应,剩余 20 点火伤再按正常倍率结算。这种分阶段计算同样可以在 Execution Calculation 里完成,只需要在 AttributeSet 里额外维护 IceGaugeFireGauge 这样的状态量。

所有这些复杂逻辑都共享同一个入口:Execution Calculation。只要保持输入输出接口稳定,策划调整公式时就不需要改动 Ability 蓝图。

8. 数值平衡与调试

当公式越来越复杂,数值平衡就成了一项系统工程。一个常见的调试方法是在 Execution Calculation 里打一条结构化的日志,把每一步的中间值都打出来:

  • BaseAtk
  • 加成后的 Attack
  • 防御减伤后的伤害
  • 暴击判定结果
  • 元素倍率
  • 最终 IncomingDamage

这样当策划说「火球打冰傀儡伤害太低」时,可以直接看日志定位是哪一环出了问题。是攻击力没堆上去,还是防御减伤过猛,或者是元素倍率配错了。

数值平衡的常见误区是只调最终伤害,而不看中间变量。一个感觉刮痧的技能,可能是 BaseAtk 太低,也可能是防御减伤公式太陡,还可能是元素倍率被错误地乘了两次。结构化的中间日志能帮你在半小时内定位根因,而不是靠猜。

另外,建议把伤害公式里的所有系数都暴露到 Data Table 里。每一行代表一个技能,列包括 BaseDamage、AtkRatio、DefPen、CritChance、CritMul、ElementMultiplier。策划只需要改表,不需要碰代码。Execution Calculation 在运行时读取对应的行,这样平衡调整的速度会快很多。

Gameplay Debugger 里打开 Ability System 视图后,可以看到每个 Active GE 的 Stack Count、Duration、Modifier 列表,以及每个 Attribute 的 Base 和 Current 值。当发现某个 Attribute 异常时,先确认是不是某个 GE 的 Modifier 没有正确移除。Duration GE 在过期后应该自动清理,但如果是自定义 ASC 手动管理 GE Handle,就要小心 Handle 丢失导致 BUFF 永久挂住。

architecture-beta
    group attribute_data[Attribute 数据]
        service base[Base Value]
        service current[Current Value]
        service meta[Meta Attribute]
    group execution[执行层]
        service pre[PreAttributeChange]
        service post[PostGameplayEffectExecute]
        service exec[Execution Calc]
    group gameplay[Gameplay Effect]
        service instant[Instant GE]
        service duration[Duration GE]
        service periodic[Periodic GE]
    base:L --> R:current
    current:T --> B:pre
    pre:T --> B:exec
    exec:T --> B:post
    instant:L --> R:exec
    duration:L --> R:exec
    periodic:L --> R:exec

除了日志,GAS 还提供了 Gameplay Debugger。在 PIE 里按单引号键可以查看每个 ASC 上的 Active Gameplay Effects、Attribute 当前值、Cooldown、Tags 等信息。调试 Attribute 问题时,这个工具比看代码更快。

另一个好习惯是给每个 Attribute 的 Base Value 和 Current Value 都写单元测试。用一张数据表格枚举不同等级、不同装备、不同 BUFF 下的期望值,自动对比实际结果。GAS 的 Attribute 系统是纯数值计算,非常适合这种表格驱动测试。

9. 回到那场战斗

现在再看莉莉的火球:

基础攻击力 BaseAtk=320BaseAtk=320,技能加成 Δatk=0.0\Delta_{atk}=0.0,目标防御 Def=120Def=120,穿透 Pen=1.0Pen=1.0,代入公式得到 320120=200320 - 120 = 200。暴击判定成功,乘以 CritMul=1.5CritMul=1.5 得到 300。火打冰的元素倍率 ElemMul=1.25ElemMul=1.25,最终伤害 300×1.25=375300 \times 1.25 = 375。这里为了演示方便,原文里的 390 是加入了某个隐藏 BUFF 加成后的结果,实际项目中可能还有天赋、装备特效、阵营克制等额外系数。

这些系数每增加一个,Execution Calculation 里就多一个输入,但只要 IncomingDamage 这个 Meta Attribute 不变,扣血逻辑就不需要改动。这就是 GAS 设计的高明之处:把易变的计算规则和稳定的扣血流程分离开。

10. 小结

  • Attribute 内部有 Base Value 和 Current Value 之分,Base 是裸值,Current 是加上所有 GE Modifier 后的生效值。
  • PreAttributeChange 用于 clamp 和前置修正,PostGameplayEffectExecute 用于真正的游戏逻辑扣血。
  • Meta Attribute 如 IncomingDamage 是临时载体,把复杂的 Execution Calculation 和 Health 修改解耦。
  • Execution Calculation 是自定义伤害公式的核心,所有暴击、闪避、元素反应都可以在这里统一处理。
  • Clamp 和属性下限能防止数值失控,但要避免过度硬直导致战斗体验变差。
  • 数值平衡依赖结构化日志、Gameplay Debugger 和表格测试。

最后提醒一点:GAS 的属性计算默认在服务器上执行权威,客户端看到的 Attribute 变化来自同步。如果你要做客户端预测,比如按下技能立刻扣蓝,需要单独处理本地回滚逻辑。Attribute 同步策略可以在 ASC 的 Replication Mode 里配置,Full 模式下所有 Attribute 都会同步,Minimal 模式下只同步部分关键属性,适合对带宽敏感的项目。

UE5.8 的 GAS 并没有改变这些核心概念,但在性能、调试工具和蓝图集成上都有持续优化。把这些基础机制吃透,后续做更复杂的战斗系统就会顺手很多。莉莉的下一发暴风雪已经在读条了。这篇笔记没有覆盖 GAS 的所有细节,但 Attribute 计算的主干已经铺开了,剩下的就是在项目中不断打磨。