UE5.8 Gameplay Ability System 专题系列

UE5.8 GAS 专题(二):AttributeSet、属性聚合与复制

详解 FGameplayAttributeData、BaseValue/CurrentValue、AttributeSet 回调、Meta Attribute、属性复制、预测对账和 UI 监听方式。

总览

AttributeSet 是 GAS 里最容易被低估的部分。很多人第一眼把它当成“带复制的属性表”,于是直接 SetHealth、UI 每帧读值、伤害公式到处扣血。这样短期能跑,长期会绕过 GameplayEffect 聚合、预测对账、持续效果撤销和服务器权威。

本篇只讲属性系统:FGameplayAttributeData 为什么有 BaseValue 和 CurrentValue,AttributeSet 回调分别在什么时候触发,Meta Attribute 怎么处理伤害,复制和 UI 应该怎么接。

UE5.8 GAS 专题(二):AttributeSet、属性聚合与复制 配图
属性不是普通 float:Instant GE 修改 BaseValue,持续 GE 通过 Aggregator 改变 CurrentValue。

源码依据

关键源码在 AttributeSet.hGameplayEffectTypes.hGameplayPrediction.h

FGameplayAttributeData 保存两个数值:

  • BaseValue:没有持续修饰时的基础值。
  • CurrentValue:经过 Active Gameplay Effects 聚合后的当前值。

UAttributeSet 提供这些关键回调:

回调 触发点 典型用途
PreAttributeChange CurrentValue 变化前 Clamp 当前值,比如 Health 不超过 MaxHealth
PostAttributeChange CurrentValue 变化后 通知非 GE 直接变化
PreAttributeBaseChange BaseValue 变化前 Clamp 基础值
PreGameplayEffectExecute Instant GE 执行前 拦截或修改执行
PostGameplayEffectExecute Instant GE 执行后 处理 Damage、死亡、溢出治疗
OnAttributeAggregatorCreated 聚合器创建时 自定义 Evaluation MetaData

源码 README 也提醒:直接改属性很诱人,但应该优先通过 GameplayEffect 修改。因为 GE 才能被预测、复制、撤销和调试。

BaseValue 与 CurrentValue

BaseValue 可以理解成“属性账本本金”,CurrentValue 是“本金加上当前所有持续修饰后的展示值”。

操作 影响 示例
Instant GE Add Health -20 修改 BaseValue 受到一次伤害
Duration GE Add MoveSpeed +200 不改 BaseValue,持续影响 CurrentValue 加速 Buff
Infinite GE Multiply AttackPower 持续影响 CurrentValue 装备被动
移除 Duration GE CurrentValue 自动回落 加速结束

所以 UI 显示通常读 CurrentValue,但永久伤害、永久治疗和资源消耗通常通过 Instant GE 改 BaseValue。

属性定义模板

项目里建议每组属性一个 AttributeSet,不要把所有内容塞进一个巨型类。比如战斗属性、移动属性、资源属性可以拆开。

#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
    GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
    GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
    GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
    GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)

UCLASS()
class UCombatAttributeSet : public UAttributeSet
{
    GENERATED_BODY()

public:
    UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_Health)
    FGameplayAttributeData Health;
    ATTRIBUTE_ACCESSORS(UCombatAttributeSet, Health);

    UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_MaxHealth)
    FGameplayAttributeData MaxHealth;
    ATTRIBUTE_ACCESSORS(UCombatAttributeSet, MaxHealth);

    UPROPERTY(BlueprintReadOnly)
    FGameplayAttributeData Damage;
    ATTRIBUTE_ACCESSORS(UCombatAttributeSet, Damage);

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

protected:
    UFUNCTION()
    void OnRep_Health(const FGameplayAttributeData& OldValue);

    UFUNCTION()
    void OnRep_MaxHealth(const FGameplayAttributeData& OldValue);
};

Damage 这里是 Meta Attribute:它不是要长期显示的属性,而是 Execution 或 Instant GE 写入的临时结算结果。PostGameplayEffectExecute 读到 Damage 后扣 Health,再把 Damage 清零。

复制与预测对账

预测属性复制必须认真写。源码注释强调 Attribute RepNotify 应使用 REPNOTIFY_Always,并在 OnRep 里调用 GAMEPLAYATTRIBUTE_REPNOTIFY

void UCombatAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME_CONDITION_NOTIFY(UCombatAttributeSet, Health, COND_None, REPNOTIFY_Always);
    DOREPLIFETIME_CONDITION_NOTIFY(UCombatAttributeSet, MaxHealth, COND_None, REPNOTIFY_Always);
}

void UCombatAttributeSet::OnRep_Health(const FGameplayAttributeData& OldValue)
{
    GAMEPLAYATTRIBUTE_REPNOTIFY(UCombatAttributeSet, Health, OldValue);
}

如果用普通 RepNotify,预测客户端可能已经本地改过值,服务器复制同一个值回来时 RepNotify 不触发,UI 和聚合器对账就可能卡住。GAMEPLAYATTRIBUTE_REPNOTIFY 会把旧值交给 ASC 的属性变化委托,UI 才能稳定刷新。

Clamp 写在哪里

Clamp 要分场景:

  • PreAttributeChange:用于 CurrentValue clamp,比如 Health 当前值不超过 MaxHealth。
  • PreAttributeBaseChange:用于 BaseValue clamp。
  • PostGameplayEffectExecute:用于 Instant GE 之后的业务处理,比如 Damage 转 Health、Health 为 0 后死亡。
void UCombatAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
    Super::PreAttributeChange(Attribute, NewValue);

    if (Attribute == GetHealthAttribute())
    {
        NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxHealth());
    }
}

void UCombatAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
    Super::PostGameplayEffectExecute(Data);

    if (Data.EvaluatedData.Attribute == GetDamageAttribute())
    {
        const float LocalDamage = GetDamage();
        SetDamage(0.0f);

        if (LocalDamage > 0.0f)
        {
            SetHealth(FMath::Clamp(GetHealth() - LocalDamage, 0.0f, GetMaxHealth()));
        }
    }
}

不要只在 UI 层 clamp。UI 只是展示,权威数值应该在 AttributeSet 和 GE 层保证正确。

UI 应该怎么监听属性

UI 不应该每帧读 ASC,也不应该直接绑定 AttributeSet 成员变量。推荐在 UI ViewModel 或组件里绑定 ASC 的属性变化委托。

void UCombatStatusViewModel::BindToASC(UAbilitySystemComponent* ASC)
{
    if (!ASC)
    {
        return;
    }

    ASC->GetGameplayAttributeValueChangeDelegate(UCombatAttributeSet::GetHealthAttribute())
        .AddUObject(this, &UCombatStatusViewModel::HandleHealthChanged);
}

void UCombatStatusViewModel::HandleHealthChanged(const FOnAttributeChangeData& Data)
{
    Health = Data.NewValue;
    OnHealthChanged.Broadcast(Health);
}

这样 UI 不关心 Health 是预测变化、服务器复制、Instant GE、持续 GE 还是初始化 GE 造成的。它只接受“属性变了”的事实。

项目落地规范

建议每个项目建立属性规范表:

属性 类型 修改来源 是否预测 UI 监听 备注
Health 普通属性 Damage/Heal GE 部分预测 服务器最终权威
MaxHealth 普通属性 初始化 GE、装备 GE 可复制 Health clamp 依赖它
Damage Meta Attribute Execution 输出 不复制 PostExecute 立即消费
MoveSpeed 普通属性 Buff/Debuff GE 可预测 同步到 Movement
Stamina 普通属性 Cost GE、恢复 GE 可预测 消耗类 Ability 常用

初始化也建议走 GE,而不是 BeginPlay 里手动 Set:

FGameplayEffectContextHandle Context = ASC->MakeEffectContext();
FGameplayEffectSpecHandle Spec = ASC->MakeOutgoingSpec(InitAttributesEffect, Level, Context);
if (Spec.IsValid())
{
    ASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());
}

这让初始值、等级曲线、装备加成和调试路径保持统一。

常见坑

  • 属性用 float 而不是 FGameplayAttributeData 这样 GE 无法识别它。
  • 忘记 RepNotify Always。 预测客户端 UI 会出现不刷新或回跳。
  • 直接 SetHealth 扣血。 绕过 GE,Cue、预测、日志和撤销都不完整。
  • Damage 做成复制属性。 Meta Attribute 应该短生命周期消费,不是 UI 状态。
  • Clamp 只写在 UI。 服务器权威值仍可能非法。
  • MaxHealth 变化后不处理 Health。 最大生命改变时要决定当前生命是否按比例调整、保持不变或 clamp。

本篇结论:AttributeSet 不是“属性存储类”,而是属性变化的权威拦截点。把属性变化统一收束到 GE 和 AttributeSet 后,后面的伤害、冷却、UI、预测和调试才有稳定基础。

源码路径索引

  • Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/AbilitySystemComponent.h
  • Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Private/AbilitySystemComponent.cpp
  • Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Private/AbilitySystemComponent_Abilities.cpp
  • Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/Abilities/GameplayAbility.h
  • Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/GameplayEffect.h
  • Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/AttributeSet.h
  • Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/GameplayPrediction.h