文章目录一、AttributeSet 是什么属性的集中营二、8 个属性的声明FGameplayAttributeData 宏2.1 为什么属性类型是 FGameplayAttributeData2.2 BaseValue vs CurrentValue永久改变 vs 临时叠加三、ATTRIBUTE\_ACCESSORS 宏一行展开成四个函数四、PreAttributeChange最大血量变化时的等比缩放4.1 为什么要等比缩放4.2 手算一遍80/100 → ?/2004.3 为什么用 ApplyModToAttributeUnsafe五、Damage一个故意不复制的临时中转站六、网络复制ReplicatedUsing OnRep REPNOTIFY6.1 声明ReplicatedUsing6.2 注册GetLifetimeReplicatedProps6.3 回调OnRep 与 GAMEPLAYATTRIBUTE\_REPNOTIFY七、小结AttributeSet 的设计要点血量、蓝量、攻击、防御、移速——任何一个 RPG 都绕不开这一组数字。在 GASGameplayAbilities里这些数字不是随便挂在角色上的float成员而是被统一收进一个叫AttributeSet属性集的对象由能力系统集中管理。本文精读 ActionRPG 的URPGAttributeSet它如何声明这 8 个属性、ATTRIBUTE_ACCESSORS宏背后到底生成了什么、BaseValue和CurrentValue为什么要分家以及当最大血量变化时那段优雅的等比缩放逻辑。本文聚焦属性怎么定义、怎么被改。至于伤害怎么算出来、怎么扣到血上——那条PostGameplayEffectExecute→HandleDamage→OnDamaged的链路是下一篇伤害管线的主角本文只在边界处点到为止。一、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(constFGameplayAttributeAttribute,floatNewValue)override;virtualvoidPostGameplayEffectExecute(constFGameplayEffectModCallbackDataData)override;virtualvoidGetLifetimeReplicatedProps(TArrayFLifetimePropertyOutLifetimeProps)constoverride;// ... 8 个属性声明 ...};它继承自引擎的UAttributeSet而UAttributeSet本身只是一个UObject。注释里那句“it instantiates a copy of this on every character”点明了它的生命周期定位每个角色都会实例化一份自己的属性集作为AbilitySystemComponentASC的子对象注册进去。玩家有玩家的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,CategoryHealth,ReplicatedUsingOnRep_Health)FGameplayAttributeData Health;ATTRIBUTE_ACCESSORS(URPGAttributeSet,Health)/** MaxHealth is its own attribute, since GameplayEffects may modify it */UPROPERTY(BlueprintReadOnly,CategoryHealth,ReplicatedUsingOnRep_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。它的结构非常简单——核心就是两个floatstructGAMEPLAYABILITIES_APIFGameplayAttributeData{FGameplayAttributeData(floatDefaultValue):BaseValue(DefaultValue),CurrentValue(DefaultValue){}floatGetCurrentValue()const;// 返回当前值含临时 buffvirtualvoidSetCurrentValue(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 血量上限——这些改的是BaseValue。Execute类型的 GameplayEffect瞬时执行改的就是它。CurrentValue当前值在BaseValue之上叠加所有临时性的修饰。一个持续 5 秒、移速 10的 Buff并不会动BaseValue它只是临时把CurrentValue顶高Buff 到期后CurrentValue自动还原回BaseValue。可以用一个公式记忆CurrentValueBaseValue ⊕(所有当前生效的 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 和 CurrentvoidInitHealth(floatNewVal);这四个函数各司其职理解它们的区别非常重要函数返回/作用典型调用处GetHealthAttribute()返回FGameplayAttribute——属性的反射句柄用来做是哪个属性的身份比较PreAttributeChange里if (Attribute GetMaxHealthAttribute())GetHealth()返回float当前值逻辑里读血量、伤害管线里GetMaxHealth()做钳制SetHealth(v)通过 ASC 写当前值伤害管线里SetHealth(Clamp(...))InitHealth(v)初始化BaseCurrent 同时设角色初始化属性时第①个GetHealthAttribute()最容易被忽视但它是 GAS 里属性身份识别的基石。FGameplayAttribute内部包着一个UProperty*反射指针所以两个属性能用比较——本质是比指针。后面PreAttributeChange判断现在改的是不是 MaxHealth靠的就是它if(AttributeGetMaxHealthAttribute()){...}小练习手写出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(constFGameplayAttributeAttribute,floatNewValue){// This is called whenever attributes change, so for max health/mana we want to scale the current totals to matchSuper::PreAttributeChange(Attribute,NewValue);if(AttributeGetMaxHealthAttribute()){AdjustAttributeForMaxChange(Health,MaxHealth,NewValue,GetHealthAttribute());}elseif(AttributeGetMaxManaAttribute()){AdjustAttributeForMaxChange(Mana,MaxMana,NewValue,GetManaAttribute());}}逻辑很克制只关心MaxHealth和MaxMana两个上限类属性的变化其余属性一律放行。一旦发现是MaxHealth要变就调用辅助函数AdjustAttributeForMaxChange把当前Health同步缩放。4.1 为什么要等比缩放设想没有这段逻辑玩家满血 80/100吃了一件 100 最大生命的装备MaxHealth变成 200但Health还停在 80——瞬间从满血变成半血。这显然不符合直觉。正确的体验是血条的填充百分比保持不变80/10080%→ 160/20080%。来看AdjustAttributeForMaxChange怎么实现这个保持百分比voidURPGAttributeSet::AdjustAttributeForMaxChange(FGameplayAttributeDataAffectedAttribute,// 被影响的属性如 HealthconstFGameplayAttributeDataMaxAttribute,// 对应的最大值属性如 MaxHealthfloatNewMaxValue,// 即将写入的新最大值constFGameplayAttributeAffectedAttributeProperty){UAbilitySystemComponent*AbilityCompGetOwningAbilitySystemComponent();constfloatCurrentMaxValueMaxAttribute.GetCurrentValue();// 旧的最大值if(!FMath::IsNearlyEqual(CurrentMaxValue,NewMaxValue)AbilityComp){// Change current value to maintain the current Val / Max percentconstfloatCurrentValueAffectedAttribute.GetCurrentValue();floatNewDelta(CurrentMaxValue0.f)?(CurrentValue*NewMaxValue/CurrentMaxValue)-CurrentValue:NewMaxValue;AbilityComp-ApplyModToAttributeUnsafe(AffectedAttributeProperty,EGameplayModOp::Additive,NewDelta);}}4.2 手算一遍80/100 → ?/200把数字代进去走一遍体会公式CurrentMaxValue旧最大 100NewMaxValue新最大 200CurrentValue当前血 80NewDelta 80 * 200 / 100 - 80 160 - 80 80于是给Health加一个80的修饰当前血变成80 80 160。最终 160/200 80%和改之前的 80/100 80% 完全一致。✅注意它算的是delta增量 80而不是直接设成 160。这有两个讲究CurrentMaxValue 0的判空如果旧最大值是 0比如属性还没初始化除法会出问题此时退化为NewDelta NewMaxValue直接把当前值顶到新上限避免除零。用ApplyModToAttributeUnsafe而不是SetCurrentValue4.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,CategoryHealth,ReplicatedUsingOnRep_Health)FGameplayAttributeData Health;// Damage没有 ReplicatedUsingUPROPERTY(BlueprintReadOnly,CategoryDamage)FGameplayAttributeData Damage;注释把它的身份说得很直白“Damage is a ‘temporary’ attribute used by the DamageExecution to calculate final damage, which then turns into -Health”。它不是一个角色拥有的属性角色身上并没有一个叫伤害值的常驻数值而是一个计算用的临时寄存器URPGDamageExecution把这一次攻击算出的最终伤害写进DamagePostGameplayEffectExecute检测到Damage被改了立刻把它取出来GetDamage()、清零SetDamage(0)、换算成Health的扣减Damage用完即弃永远在 0 附近。正因为它是用完即清零的本地中转值复制它毫无意义——它从不代表任何需要在客户端持久显示的状态。需要同步给客户端的是扣血之后的Health而Health是复制的。所以Damage故意不写ReplicatedUsing也不出现在下面的复制清单里。这就是任务里那个问题为什么 Damage 没有 ReplicatedUsing的答案它是中转站不是状态。至于Damage→Health的完整换算攻击力、防御力、钳制、回调是下一篇伤害管线的内容这里不展开。六、网络复制ReplicatedUsing OnRep REPNOTIFY最后看属性如何参与网络同步。这部分由三段代码协同完成。6.1 声明ReplicatedUsing7 个需要复制的属性都在UPROPERTY里写了ReplicatedUsingOnRep_XxxUPROPERTY(BlueprintReadOnly,CategoryHealth,ReplicatedUsingOnRep_Health)FGameplayAttributeData Health;ReplicatedUsingOnRep_Health的意思是当这个属性从服务器复制到客户端时引擎会在客户端自动调用OnRep_Health函数通知你。6.2 注册GetLifetimeReplicatedProps光声明还不够还要在GetLifetimeReplicatedProps里用DOREPLIFETIME把它们登记为需要在整个生命周期内复制voidURPGAttributeSet::GetLifetimeReplicatedProps(TArrayFLifetimePropertyOutLifetimeProps)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_REPNOTIFY7 个OnRep_Xxx函数的实现几乎一模一样都是一行宏voidURPGAttributeSet::OnRep_Health(constFGameplayAttributeDataOldValue){GAMEPLAYATTRIBUTE_REPNOTIFY(URPGAttributeSet,Health,OldValue);}voidURPGAttributeSet::OnRep_MaxHealth(constFGameplayAttributeDataOldValue){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核心是BaseValueCurrentValue两个 floatBase vs CurrentBase 永久改变升级/装备Current Base 叠加临时 Buff逻辑读 CurrentATTRIBUTE_ACCESSORS一行宏 → 4 个函数GetXxxAttribute反射句柄/GetXxx/SetXxx/InitXxxPreAttributeChange任何属性改前的钩子做钳制/约束这里实现 MaxHealth 变化时 Health 等比缩放ApplyModToAttributeUnsafe走 ASC 正规通道改属性保持聚合器账本一致而非直接 SetCurrentValueDamage故意不复制的临时中转站——是计算寄存器不是角色状态网络复制ReplicatedUsing声明 DOREPLIFETIME注册 GAMEPLAYATTRIBUTE_REPNOTIFY回调对齐URPGAttributeSet把 GAS 属性系统的精华几乎都浓缩了进来用FGameplayAttributeData分离永久与临时、用宏批量生成访问器、用PreAttributeChange做数值约束、用一整套复制机制支撑联机。理解了它伤害管线那一篇里PostGameplayEffectExecute如何把Damage换成-Health就只剩最后一层窗户纸了。
【UE源码精读-ActionRPG】属性系统:AttributeSet 精读
文章目录一、AttributeSet 是什么属性的集中营二、8 个属性的声明FGameplayAttributeData 宏2.1 为什么属性类型是 FGameplayAttributeData2.2 BaseValue vs CurrentValue永久改变 vs 临时叠加三、ATTRIBUTE\_ACCESSORS 宏一行展开成四个函数四、PreAttributeChange最大血量变化时的等比缩放4.1 为什么要等比缩放4.2 手算一遍80/100 → ?/2004.3 为什么用 ApplyModToAttributeUnsafe五、Damage一个故意不复制的临时中转站六、网络复制ReplicatedUsing OnRep REPNOTIFY6.1 声明ReplicatedUsing6.2 注册GetLifetimeReplicatedProps6.3 回调OnRep 与 GAMEPLAYATTRIBUTE\_REPNOTIFY七、小结AttributeSet 的设计要点血量、蓝量、攻击、防御、移速——任何一个 RPG 都绕不开这一组数字。在 GASGameplayAbilities里这些数字不是随便挂在角色上的float成员而是被统一收进一个叫AttributeSet属性集的对象由能力系统集中管理。本文精读 ActionRPG 的URPGAttributeSet它如何声明这 8 个属性、ATTRIBUTE_ACCESSORS宏背后到底生成了什么、BaseValue和CurrentValue为什么要分家以及当最大血量变化时那段优雅的等比缩放逻辑。本文聚焦属性怎么定义、怎么被改。至于伤害怎么算出来、怎么扣到血上——那条PostGameplayEffectExecute→HandleDamage→OnDamaged的链路是下一篇伤害管线的主角本文只在边界处点到为止。一、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(constFGameplayAttributeAttribute,floatNewValue)override;virtualvoidPostGameplayEffectExecute(constFGameplayEffectModCallbackDataData)override;virtualvoidGetLifetimeReplicatedProps(TArrayFLifetimePropertyOutLifetimeProps)constoverride;// ... 8 个属性声明 ...};它继承自引擎的UAttributeSet而UAttributeSet本身只是一个UObject。注释里那句“it instantiates a copy of this on every character”点明了它的生命周期定位每个角色都会实例化一份自己的属性集作为AbilitySystemComponentASC的子对象注册进去。玩家有玩家的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,CategoryHealth,ReplicatedUsingOnRep_Health)FGameplayAttributeData Health;ATTRIBUTE_ACCESSORS(URPGAttributeSet,Health)/** MaxHealth is its own attribute, since GameplayEffects may modify it */UPROPERTY(BlueprintReadOnly,CategoryHealth,ReplicatedUsingOnRep_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。它的结构非常简单——核心就是两个floatstructGAMEPLAYABILITIES_APIFGameplayAttributeData{FGameplayAttributeData(floatDefaultValue):BaseValue(DefaultValue),CurrentValue(DefaultValue){}floatGetCurrentValue()const;// 返回当前值含临时 buffvirtualvoidSetCurrentValue(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 血量上限——这些改的是BaseValue。Execute类型的 GameplayEffect瞬时执行改的就是它。CurrentValue当前值在BaseValue之上叠加所有临时性的修饰。一个持续 5 秒、移速 10的 Buff并不会动BaseValue它只是临时把CurrentValue顶高Buff 到期后CurrentValue自动还原回BaseValue。可以用一个公式记忆CurrentValueBaseValue ⊕(所有当前生效的 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 和 CurrentvoidInitHealth(floatNewVal);这四个函数各司其职理解它们的区别非常重要函数返回/作用典型调用处GetHealthAttribute()返回FGameplayAttribute——属性的反射句柄用来做是哪个属性的身份比较PreAttributeChange里if (Attribute GetMaxHealthAttribute())GetHealth()返回float当前值逻辑里读血量、伤害管线里GetMaxHealth()做钳制SetHealth(v)通过 ASC 写当前值伤害管线里SetHealth(Clamp(...))InitHealth(v)初始化BaseCurrent 同时设角色初始化属性时第①个GetHealthAttribute()最容易被忽视但它是 GAS 里属性身份识别的基石。FGameplayAttribute内部包着一个UProperty*反射指针所以两个属性能用比较——本质是比指针。后面PreAttributeChange判断现在改的是不是 MaxHealth靠的就是它if(AttributeGetMaxHealthAttribute()){...}小练习手写出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(constFGameplayAttributeAttribute,floatNewValue){// This is called whenever attributes change, so for max health/mana we want to scale the current totals to matchSuper::PreAttributeChange(Attribute,NewValue);if(AttributeGetMaxHealthAttribute()){AdjustAttributeForMaxChange(Health,MaxHealth,NewValue,GetHealthAttribute());}elseif(AttributeGetMaxManaAttribute()){AdjustAttributeForMaxChange(Mana,MaxMana,NewValue,GetManaAttribute());}}逻辑很克制只关心MaxHealth和MaxMana两个上限类属性的变化其余属性一律放行。一旦发现是MaxHealth要变就调用辅助函数AdjustAttributeForMaxChange把当前Health同步缩放。4.1 为什么要等比缩放设想没有这段逻辑玩家满血 80/100吃了一件 100 最大生命的装备MaxHealth变成 200但Health还停在 80——瞬间从满血变成半血。这显然不符合直觉。正确的体验是血条的填充百分比保持不变80/10080%→ 160/20080%。来看AdjustAttributeForMaxChange怎么实现这个保持百分比voidURPGAttributeSet::AdjustAttributeForMaxChange(FGameplayAttributeDataAffectedAttribute,// 被影响的属性如 HealthconstFGameplayAttributeDataMaxAttribute,// 对应的最大值属性如 MaxHealthfloatNewMaxValue,// 即将写入的新最大值constFGameplayAttributeAffectedAttributeProperty){UAbilitySystemComponent*AbilityCompGetOwningAbilitySystemComponent();constfloatCurrentMaxValueMaxAttribute.GetCurrentValue();// 旧的最大值if(!FMath::IsNearlyEqual(CurrentMaxValue,NewMaxValue)AbilityComp){// Change current value to maintain the current Val / Max percentconstfloatCurrentValueAffectedAttribute.GetCurrentValue();floatNewDelta(CurrentMaxValue0.f)?(CurrentValue*NewMaxValue/CurrentMaxValue)-CurrentValue:NewMaxValue;AbilityComp-ApplyModToAttributeUnsafe(AffectedAttributeProperty,EGameplayModOp::Additive,NewDelta);}}4.2 手算一遍80/100 → ?/200把数字代进去走一遍体会公式CurrentMaxValue旧最大 100NewMaxValue新最大 200CurrentValue当前血 80NewDelta 80 * 200 / 100 - 80 160 - 80 80于是给Health加一个80的修饰当前血变成80 80 160。最终 160/200 80%和改之前的 80/100 80% 完全一致。✅注意它算的是delta增量 80而不是直接设成 160。这有两个讲究CurrentMaxValue 0的判空如果旧最大值是 0比如属性还没初始化除法会出问题此时退化为NewDelta NewMaxValue直接把当前值顶到新上限避免除零。用ApplyModToAttributeUnsafe而不是SetCurrentValue4.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,CategoryHealth,ReplicatedUsingOnRep_Health)FGameplayAttributeData Health;// Damage没有 ReplicatedUsingUPROPERTY(BlueprintReadOnly,CategoryDamage)FGameplayAttributeData Damage;注释把它的身份说得很直白“Damage is a ‘temporary’ attribute used by the DamageExecution to calculate final damage, which then turns into -Health”。它不是一个角色拥有的属性角色身上并没有一个叫伤害值的常驻数值而是一个计算用的临时寄存器URPGDamageExecution把这一次攻击算出的最终伤害写进DamagePostGameplayEffectExecute检测到Damage被改了立刻把它取出来GetDamage()、清零SetDamage(0)、换算成Health的扣减Damage用完即弃永远在 0 附近。正因为它是用完即清零的本地中转值复制它毫无意义——它从不代表任何需要在客户端持久显示的状态。需要同步给客户端的是扣血之后的Health而Health是复制的。所以Damage故意不写ReplicatedUsing也不出现在下面的复制清单里。这就是任务里那个问题为什么 Damage 没有 ReplicatedUsing的答案它是中转站不是状态。至于Damage→Health的完整换算攻击力、防御力、钳制、回调是下一篇伤害管线的内容这里不展开。六、网络复制ReplicatedUsing OnRep REPNOTIFY最后看属性如何参与网络同步。这部分由三段代码协同完成。6.1 声明ReplicatedUsing7 个需要复制的属性都在UPROPERTY里写了ReplicatedUsingOnRep_XxxUPROPERTY(BlueprintReadOnly,CategoryHealth,ReplicatedUsingOnRep_Health)FGameplayAttributeData Health;ReplicatedUsingOnRep_Health的意思是当这个属性从服务器复制到客户端时引擎会在客户端自动调用OnRep_Health函数通知你。6.2 注册GetLifetimeReplicatedProps光声明还不够还要在GetLifetimeReplicatedProps里用DOREPLIFETIME把它们登记为需要在整个生命周期内复制voidURPGAttributeSet::GetLifetimeReplicatedProps(TArrayFLifetimePropertyOutLifetimeProps)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_REPNOTIFY7 个OnRep_Xxx函数的实现几乎一模一样都是一行宏voidURPGAttributeSet::OnRep_Health(constFGameplayAttributeDataOldValue){GAMEPLAYATTRIBUTE_REPNOTIFY(URPGAttributeSet,Health,OldValue);}voidURPGAttributeSet::OnRep_MaxHealth(constFGameplayAttributeDataOldValue){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核心是BaseValueCurrentValue两个 floatBase vs CurrentBase 永久改变升级/装备Current Base 叠加临时 Buff逻辑读 CurrentATTRIBUTE_ACCESSORS一行宏 → 4 个函数GetXxxAttribute反射句柄/GetXxx/SetXxx/InitXxxPreAttributeChange任何属性改前的钩子做钳制/约束这里实现 MaxHealth 变化时 Health 等比缩放ApplyModToAttributeUnsafe走 ASC 正规通道改属性保持聚合器账本一致而非直接 SetCurrentValueDamage故意不复制的临时中转站——是计算寄存器不是角色状态网络复制ReplicatedUsing声明 DOREPLIFETIME注册 GAMEPLAYATTRIBUTE_REPNOTIFY回调对齐URPGAttributeSet把 GAS 属性系统的精华几乎都浓缩了进来用FGameplayAttributeData分离永久与临时、用宏批量生成访问器、用PreAttributeChange做数值约束、用一整套复制机制支撑联机。理解了它伤害管线那一篇里PostGameplayEffectExecute如何把Damage换成-Health就只剩最后一层窗户纸了。