【UE源码精读-ActionRPG】属性系统:AttributeSet 精读

文章目录

    • 一、AttributeSet 是什么:属性的集中营
    • 二、8 个属性的声明:FGameplayAttributeData + 宏
      • 2.1 为什么属性类型是 FGameplayAttributeData
      • 2.2 BaseValue vs CurrentValue:永久改变 vs 临时叠加
    • 三、ATTRIBUTE\_ACCESSORS 宏:一行展开成四个函数
    • 四、PreAttributeChange:最大血量变化时的等比缩放
      • 4.1 为什么要等比缩放
      • 4.2 手算一遍:80/100 → ?/200
      • 4.3 为什么用 ApplyModToAttributeUnsafe
    • 五、Damage:一个故意不复制的"临时中转站"
    • 六、网络复制:ReplicatedUsing + OnRep + REPNOTIFY
      • 6.1 声明:ReplicatedUsing
      • 6.2 注册:GetLifetimeReplicatedProps
      • 6.3 回调:OnRep 与 GAMEPLAYATTRIBUTE\_REPNOTIFY
    • 七、小结:AttributeSet 的设计要点

血量、蓝量、攻击、防御、移速——任何一个 RPG 都绕不开这一组数字。在 GAS(GameplayAbilities)里,这些数字不是随便挂在角色上的float成员,而是被统一收进一个叫AttributeSet(属性集)的对象,由能力系统集中管理。本文精读 ActionRPG 的URPGAttributeSet:它如何声明这 8 个属性、ATTRIBUTE_ACCESSORS宏背后到底生成了什么、BaseValueCurrentValue为什么要分家,以及当最大血量变化时那段优雅的等比缩放逻辑。

本文聚焦"属性怎么定义、怎么被改"。至于"伤害怎么算出来、怎么扣到血上"——那条PostGameplayEffectExecuteHandleDamageOnDamaged的链路,是下一篇伤害管线的主角,本文只在边界处点到为止。


一、AttributeSet 是什么:属性的集中营

先看URPGAttributeSet的类声明:

/** This holds all of the attributes used by abilities, it instantiates a copy of this on every character */UCLASS()classACTIONRPG_APIURPGAttributeSet:publicUAttributeSet{GENERATED_BODY()public:// Constructor and overridesURPGAttributeSet();virtualvoidPreAttributeChange(constFGameplayAttribute&Attribute,float&NewValue)override;virtualvoidPostGameplayEffectExecute(constFGameplayEffectModCallbackData&Data)override;virtualvoidGetLifetimeReplicatedProps(TArray<FLifetimeProperty>&OutLifetimeProps)constoverride;// ... 8 个属性声明 ...};

它继承自引擎的UAttributeSet,而UAttributeSet本身只是一个UObject。注释里那句“it instantiates a copy of this on every character”点明了它的生命周期定位:每个角色都会实例化一份自己的属性集,作为AbilitySystemComponent(ASC)的子对象注册进去。玩家有玩家的URPGAttributeSet,每只怪有怪自己的——它们互不干扰。

为什么不直接在ARPGCharacterBase上写float Health; float Mana;?因为 GAS 要对属性做一整套它管不到原生float的事情:

  • 网络复制:属性需要在服务器和客户端之间同步,且支持客户端预测。
  • GameplayEffect 修改:Buff/Debuff 不是直接赋值,而是通过"修饰器(Modifier)“叠加,需要区分"永久改变"和"临时加成”。
  • 修改前后的钩子:在属性变化前后做钳制(Clamp)、等比缩放、触发回调。

这三件事,普通float一件都做不到。AttributeSet把属性从"裸数据"升级成"受能力系统托管的数据",代价就是要遵守它的一套规矩。URPGAttributeSet一共重写了三个虚函数:

重写的方法时机本文是否展开
PreAttributeChange任何属性值即将改变前✅ 重点
PostGameplayEffectExecuteGameplayEffect 执行之后(改 BaseValue)⏭ 下一篇伤害管线
GetLifetimeReplicatedProps声明哪些属性参与网络复制✅ 本文末尾

二、8 个属性的声明:FGameplayAttributeData + 宏

属性声明部分是高度模式化的,8 个属性长得几乎一模一样:

/** Current Health, when 0 we expect owner to die. Capped by MaxHealth */UPROPERTY(BlueprintReadOnly,Category="Health",ReplicatedUsing=OnRep_Health)FGameplayAttributeData Health;ATTRIBUTE_ACCESSORS(URPGAttributeSet,Health)/** MaxHealth is its own attribute, since GameplayEffects may modify it */UPROPERTY(BlueprintReadOnly,Category="Health",ReplicatedUsing=OnRep_MaxHealth)FGameplayAttributeData MaxHealth;ATTRIBUTE_ACCESSORS(URPGAttributeSet,MaxHealth)

每个属性都是三件套:一行注释、一个UPROPERTY(...) FGameplayAttributeData、一行ATTRIBUTE_ACCESSORS宏。ActionRPG 定义的 8 个属性是:

属性默认值含义是否复制
Health1.0当前血量,0 即死亡,上限MaxHealth
MaxHealth1.0最大血量(独立属性,可被 GE 改)
Mana0.0当前蓝量,上限MaxMana
MaxMana0.0最大蓝量
AttackPower1.0攻击力,乘到基础伤害上,1.0 = 无加成
DefensePower1.0防御力,基础伤害除以它,1.0 = 无减免
MoveSpeed1.0移动速度倍率
Damage0.0临时中转属性,被换算成-Health

注意一个设计细节:MaxHealth是一个独立属性,而不是常量。注释写得很清楚——“since GameplayEffects may modify it”。一件 +50 最大生命的装备,就是一个修改MaxHealth属性的 GameplayEffect。如果把最大血量写成const float,这种 Buff 就无从挂载。

2.1 为什么属性类型是 FGameplayAttributeData

属性的类型不是float,而是FGameplayAttributeData。它的结构非常简单——核心就是两个float

structGAMEPLAYABILITIES_APIFGameplayAttributeData{FGameplayAttributeData(floatDefaultValue):BaseValue(DefaultValue),CurrentValue(DefaultValue){}floatGetCurrentValue()const;// 返回当前值(含临时 buff)virtualvoidSetCurrentValue(floatNewValue);floatGetBaseValue()const;// 返回基础值(只含永久改变)virtualvoidSetBaseValue(floatNewValue);protected:UPROPERTY(...)floatBaseValue;// 基础值UPROPERTY(...)floatCurrentValue;// 当前值};

引擎注释里有一句话值得加粗:“It is strongly encouraged to use this instead of raw float attributes”——强烈建议用它而不是裸float。原因就在这两个float上。

2.2 BaseValue vs CurrentValue:永久改变 vs 临时叠加

这是属性系统里最容易绕晕、却又最关键的一对概念。

  • BaseValue(基础值):只反映永久性的改变。装备一把武器永久 +10 攻击、升级永久 +20 血量上限——这些改的是BaseValueExecute类型的 GameplayEffect(瞬时执行)改的就是它。
  • CurrentValue(当前值):在BaseValue之上叠加所有临时性的修饰。一个"持续 5 秒、移速 +10"的 Buff,并不会动BaseValue,它只是临时把CurrentValue顶高;Buff 到期后CurrentValue自动还原回BaseValue

可以用一个公式记忆:

CurrentValue=BaseValue ⊕(所有当前生效的 Duration/Infinite 类修饰器)

游戏逻辑里读取属性时几乎总是读CurrentValue(你想知道的是"此刻实际有多少血"),而永久成长改的是BaseValue。这套分离让"5 秒减速"和"永久升级"这两类完全不同的数值改动,能干净地共存而不互相污染——减速到期,不会把你升级得来的永久加成一起抹掉。

引擎对PostGameplayEffectExecute的注释也印证了这点:“Called just before a GameplayEffect isexecuted to modify the base value.” ——Execute改的是 base value,这正是下一篇伤害管线扣血时操作Health的入口。而 5 秒 +10 移速那种 Duration buff,不会触发PostGameplayEffectExecute


三、ATTRIBUTE_ACCESSORS 宏:一行展开成四个函数

每个属性下面那行ATTRIBUTE_ACCESSORS(URPGAttributeSet, Health)是什么?看它的定义:

// Uses macros from AttributeSet.h#defineATTRIBUTE_ACCESSORS(ClassName,PropertyName)\GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName,PropertyName)\GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName)\GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName)\GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)

它是 4 个引擎宏的打包。展开后,ATTRIBUTE_ACCESSORS(URPGAttributeSet, Health)一行会生成下面 4 个静态/成员函数:

// ① PROPERTY_GETTER:拿到描述这个属性的 FGameplayAttribute(反射句柄)staticFGameplayAttributeGetHealthAttribute();// ② VALUE_GETTER:读当前值,等价于 Health.GetCurrentValue()floatGetHealth()const;// ③ VALUE_SETTER:通过 ASC 设置当前值(走能力系统,而非直接赋值)voidSetHealth(floatNewVal);// ④ VALUE_INITTER:初始化属性(同时设 Base 和 Current)voidInitHealth(floatNewVal);

这四个函数各司其职,理解它们的区别非常重要:

函数返回/作用典型调用处
GetHealthAttribute()返回FGameplayAttribute——属性的反射句柄,用来做"是哪个属性"的身份比较PreAttributeChangeif (Attribute == GetMaxHealthAttribute())
GetHealth()返回float当前值逻辑里读血量、伤害管线里GetMaxHealth()做钳制
SetHealth(v)通过 ASC 写当前值伤害管线里SetHealth(Clamp(...))
InitHealth(v)初始化(Base+Current 同时设)角色初始化属性时

第①个GetHealthAttribute()最容易被忽视,但它是 GAS 里属性"身份识别"的基石。FGameplayAttribute内部包着一个UProperty*(反射指针),所以两个属性能用==比较——本质是比指针。后面PreAttributeChange判断"现在改的是不是 MaxHealth",靠的就是它:

if(Attribute==GetMaxHealthAttribute()){...}

小练习:手写出ATTRIBUTE_ACCESSORS(URPGAttributeSet, AttackPower)展开的四个函数签名。答案就是把上面四行里的Health全替换成AttackPowerGetAttackPowerAttribute()/GetAttackPower()/SetAttackPower(float)/InitAttackPower(float)。8 个属性 × 4 个函数 = 32 个访问器,全由这一行宏批量生成——这就是 GAS 用宏换取声明简洁性的典型手法。


四、PreAttributeChange:最大血量变化时的等比缩放

PreAttributeChange是本文的技术高潮。先看引擎给它的定位:

Called just beforeanymodification happens to an attribute. This function is meant to enforce things like “Health = Clamp(Health, 0, MaxHealth)” and NOT things like “trigger this extra thing if damage is applied”.

翻译过来:它在任何属性即将改变前被调用,定位是做钳制和约束这类"纯数值修正",而不是触发额外的游戏逻辑。参数float& NewValue可变引用——你甚至可以在这里把即将写入的值改掉。

ActionRPG 用它解决一个具体问题:当最大血量变了,当前血量要按比例跟着变。

voidURPGAttributeSet::PreAttributeChange(constFGameplayAttribute&Attribute,float&NewValue){// This is called whenever attributes change, so for max health/mana we want to scale the current totals to matchSuper::PreAttributeChange(Attribute,NewValue);if(Attribute==GetMaxHealthAttribute()){AdjustAttributeForMaxChange(Health,MaxHealth,NewValue,GetHealthAttribute());}elseif(Attribute==GetMaxManaAttribute()){AdjustAttributeForMaxChange(Mana,MaxMana,NewValue,GetManaAttribute());}}

逻辑很克制:只关心MaxHealthMaxMana两个"上限类"属性的变化,其余属性一律放行。一旦发现是MaxHealth要变,就调用辅助函数AdjustAttributeForMaxChange,把当前Health同步缩放。

4.1 为什么要等比缩放

设想没有这段逻辑:玩家满血 80/100,吃了一件 +100 最大生命的装备,MaxHealth变成 200,但Health还停在 80——瞬间从"满血"变成"半血"。这显然不符合直觉。正确的体验是:血条的填充百分比保持不变,80/100(80%)→ 160/200(80%)。

来看AdjustAttributeForMaxChange怎么实现这个"保持百分比":

voidURPGAttributeSet::AdjustAttributeForMaxChange(FGameplayAttributeData&AffectedAttribute,// 被影响的属性,如 HealthconstFGameplayAttributeData&MaxAttribute,// 对应的最大值属性,如 MaxHealthfloatNewMaxValue,// 即将写入的新最大值constFGameplayAttribute&AffectedAttributeProperty){UAbilitySystemComponent*AbilityComp=GetOwningAbilitySystemComponent();constfloatCurrentMaxValue=MaxAttribute.GetCurrentValue();// 旧的最大值if(!FMath::IsNearlyEqual(CurrentMaxValue,NewMaxValue)&&AbilityComp){// Change current value to maintain the current Val / Max percentconstfloatCurrentValue=AffectedAttribute.GetCurrentValue();floatNewDelta=(CurrentMaxValue>0.f)?(CurrentValue*NewMaxValue/CurrentMaxValue)-CurrentValue:NewMaxValue;AbilityComp->ApplyModToAttributeUnsafe(AffectedAttributeProperty,EGameplayModOp::Additive,NewDelta);}}

4.2 手算一遍:80/100 → ?/200

把数字代进去走一遍,体会公式:

  • CurrentMaxValue(旧最大)= 100
  • NewMaxValue(新最大)= 200
  • CurrentValue(当前血)= 80
  • NewDelta = 80 * 200 / 100 - 80 = 160 - 80 = 80

于是给Health加一个+80的修饰,当前血变成80 + 80 = 160。最终 160/200 = 80%,和改之前的 80/100 = 80% 完全一致。✅

注意它算的是delta(增量 +80)而不是直接设成 160。这有两个讲究:

  1. CurrentMaxValue > 0的判空:如果旧最大值是 0(比如属性还没初始化),除法会出问题,此时退化为NewDelta = NewMaxValue(直接把当前值顶到新上限),避免除零。
  2. ApplyModToAttributeUnsafe而不是SetCurrentValue

4.3 为什么用 ApplyModToAttributeUnsafe

这是个值得停下来想的点。明明可以AffectedAttribute.SetCurrentValue(160),为什么绕一圈走 ASC 的ApplyModToAttributeUnsafe

关键在于保持 ASC 内部状态机的一致性。GAS 里属性的当前值不是孤立的一个数,它背后挂着一套"聚合器(Aggregator)"——记录着所有正在生效的修饰器、它们的来源、叠加方式。如果你直接SetCurrentValue,等于绕过了这套账本系统,往属性里塞了一个 ASC 不知情的数值。下次有别的 Buff 重新计算聚合时,你这次偷偷设的值就可能被覆盖或算错。

ApplyModToAttributeUnsafe则是以"加一个增量修饰"的方式告诉 ASC:“给这个属性额外 +80”,让 ASC 通过它自己的正规通道完成修改,账本始终对得上。名字里的Unsafe是提醒你:它会立即修改且不走完整的预测/回滚网络逻辑,要清楚自己在干什么——但在PreAttributeChange这种"上限刚变、需要立即同步当前值"的场景里,它正是合适的工具。


五、Damage:一个故意不复制的"临时中转站"

8 个属性里,Damage是唯一的异类。把它和Health的声明放一起对比:

// Health:有 ReplicatedUsingUPROPERTY(BlueprintReadOnly,Category="Health",ReplicatedUsing=OnRep_Health)FGameplayAttributeData Health;// Damage:没有 ReplicatedUsingUPROPERTY(BlueprintReadOnly,Category="Damage")FGameplayAttributeData Damage;

注释把它的身份说得很直白:“Damage is a ‘temporary’ attribute used by the DamageExecution to calculate final damage, which then turns into -Health”。它不是一个"角色拥有的属性"(角色身上并没有一个叫"伤害值"的常驻数值),而是一个计算用的临时寄存器

  1. URPGDamageExecution把这一次攻击算出的最终伤害写进Damage
  2. PostGameplayEffectExecute检测到Damage被改了,立刻把它取出来(GetDamage())、清零(SetDamage(0))、换算成Health的扣减;
  3. Damage用完即弃,永远在 0 附近。

正因为它是"用完即清零的本地中转值",复制它毫无意义——它从不代表任何需要在客户端持久显示的状态。需要同步给客户端的是扣血之后Health,而Health是复制的。所以Damage故意不写ReplicatedUsing,也不出现在下面的复制清单里。这就是任务里那个问题"为什么 Damage 没有 ReplicatedUsing"的答案:它是中转站,不是状态。

至于DamageHealth的完整换算(攻击力、防御力、钳制、回调),是下一篇伤害管线的内容,这里不展开。


六、网络复制:ReplicatedUsing + OnRep + REPNOTIFY

最后看属性如何参与网络同步。这部分由三段代码协同完成。

6.1 声明:ReplicatedUsing

7 个需要复制的属性,都在UPROPERTY里写了ReplicatedUsing=OnRep_Xxx

UPROPERTY(BlueprintReadOnly,Category="Health",ReplicatedUsing=OnRep_Health)FGameplayAttributeData Health;

ReplicatedUsing=OnRep_Health的意思是:当这个属性从服务器复制到客户端时,引擎会在客户端自动调用OnRep_Health函数通知你。

6.2 注册:GetLifetimeReplicatedProps

光声明还不够,还要在GetLifetimeReplicatedProps里用DOREPLIFETIME把它们登记为"需要在整个生命周期内复制":

voidURPGAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>&OutLifetimeProps)const{Super::GetLifetimeReplicatedProps(OutLifetimeProps);DOREPLIFETIME(URPGAttributeSet,Health);DOREPLIFETIME(URPGAttributeSet,MaxHealth);DOREPLIFETIME(URPGAttributeSet,Mana);DOREPLIFETIME(URPGAttributeSet,MaxMana);DOREPLIFETIME(URPGAttributeSet,AttackPower);DOREPLIFETIME(URPGAttributeSet,DefensePower);DOREPLIFETIME(URPGAttributeSet,MoveSpeed);// 注意:没有 Damage —— 它不复制}

数一下:登记了 7 个,唯独没有Damage,和上一节呼应。

6.3 回调:OnRep 与 GAMEPLAYATTRIBUTE_REPNOTIFY

7 个OnRep_Xxx函数的实现几乎一模一样,都是一行宏:

voidURPGAttributeSet::OnRep_Health(constFGameplayAttributeData&OldValue){GAMEPLAYATTRIBUTE_REPNOTIFY(URPGAttributeSet,Health,OldValue);}voidURPGAttributeSet::OnRep_MaxHealth(constFGameplayAttributeData&OldValue){GAMEPLAYATTRIBUTE_REPNOTIFY(URPGAttributeSet,MaxHealth,OldValue);}// ... Mana / MaxMana / AttackPower / DefensePower / MoveSpeed 完全同理 ...

为什么需要这个GAMEPLAYATTRIBUTE_REPNOTIFY宏,而不是空着OnRep?头部注释点明了:它处理"可以被客户端预测修改的属性"。

在网络游戏里,客户端为了流畅,常会"预测"一些属性变化(比如本地先扣血,不等服务器确认)。当服务器的权威值复制回来时,客户端内部的聚合器状态需要和这个权威值重新对齐GAMEPLAYATTRIBUTE_REPNOTIFY宏就是干这个的——它通知 ASC:“这个属性收到了服务器的新值(OldValue是旧值),请用它重新同步内部表示”。少了这一步,客户端预测和服务器权威值之间就可能产生持久的偏差。

把这三段串起来,一个属性的完整复制链路是:

服务器改 Health → 引擎复制到客户端 → 客户端触发 OnRep_Health(OldValue)→ GAMEPLAYATTRIBUTE_REPNOTIFY → ASC 用新值重新对齐内部聚合器状态

七、小结:AttributeSet 的设计要点

把本文的关键点收束成一张表:

主题要点
定位每个角色一份,作为 ASC 子对象托管所有数值属性
属性类型FGameplayAttributeData,核心是BaseValue+CurrentValue两个 float
Base vs CurrentBase = 永久改变(升级/装备),Current = Base 叠加临时 Buff;逻辑读 Current
ATTRIBUTE_ACCESSORS一行宏 → 4 个函数:GetXxxAttribute(反射句柄)/GetXxx/SetXxx/InitXxx
PreAttributeChange任何属性改前的钩子,做钳制/约束;这里实现 MaxHealth 变化时 Health 等比缩放
ApplyModToAttributeUnsafe走 ASC 正规通道改属性,保持聚合器账本一致,而非直接 SetCurrentValue
Damage故意不复制的临时中转站——是计算寄存器,不是角色状态
网络复制ReplicatedUsing声明 +DOREPLIFETIME注册 +GAMEPLAYATTRIBUTE_REPNOTIFY回调对齐

URPGAttributeSet把 GAS 属性系统的精华几乎都浓缩了进来:用FGameplayAttributeData分离永久与临时、用宏批量生成访问器、用PreAttributeChange做数值约束、用一整套复制机制支撑联机。理解了它,伤害管线那一篇里PostGameplayEffectExecute如何把Damage换成-Health,就只剩最后一层窗户纸了。