总览
AttributeSet 是 GAS 里最容易被低估的部分。很多人第一眼把它当成“带复制的属性表”,于是直接 SetHealth、UI 每帧读值、伤害公式到处扣血。这样短期能跑,长期会绕过 GameplayEffect 聚合、预测对账、持续效果撤销和服务器权威。
本篇只讲属性系统:FGameplayAttributeData 为什么有 BaseValue 和 CurrentValue,AttributeSet 回调分别在什么时候触发,Meta Attribute 怎么处理伤害,复制和 UI 应该怎么接。
源码依据
关键源码在 AttributeSet.h、GameplayEffectTypes.h 和 GameplayPrediction.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.hEngine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Private/AbilitySystemComponent.cppEngine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Private/AbilitySystemComponent_Abilities.cppEngine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/Abilities/GameplayAbility.hEngine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/GameplayEffect.hEngine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/AttributeSet.hEngine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/GameplayPrediction.h