文章目录
血量、蓝量、攻击、防御、移速——任何一个 RPG 都绕不开这一组数字。在 GAS(GameplayAbilities)里,这些数字不是随便挂在角色上的
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()
class ACTIONRPG_API URPGAttributeSet : public UAttributeSet
{
GENERATED_BODY()
public:
// Constructor and overrides
URPGAttributeSet();
virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override;
virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// ... 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 | 任何属性值即将改变前 | ✅ 重点 |
PostGameplayEffectExecute | GameplayEffect 执行之后(改 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 个属性是:
| 属性 | 默认值 | 含义 | 是否复制 |
|---|---|---|---|
Health | 1.0 | 当前血量,0 即死亡,上限 MaxHealth | ✅ |
MaxHealth | 1.0 | 最大血量(独立属性,可被 GE 改) | ✅ |
Mana | 0.0 | 当前蓝量,上限 MaxMana | ✅ |
MaxMana | 0.0 | 最大蓝量 | ✅ |
AttackPower | 1.0 | 攻击力,乘到基础伤害上,1.0 = 无加成 | ✅ |
DefensePower | 1.0 | 防御力,基础伤害除以它,1.0 = 无减免 | ✅ |
MoveSpeed | 1.0 | 移动速度倍率 | ✅ |
Damage | 0.0 | 临时中转属性,被换算成 -Health | ❌ |
注意一个设计细节:MaxHealth 是一个独立属性,而不是常量。注释写得很清楚——“since GameplayEffects may modify it”。一件 +50 最大生命的装备,就是一个修改 MaxHealth 属性的 GameplayEffect。如果把最大血量写成 const float,这种 Buff 就无从挂载。
2.1 为什么属性类型是 FGameplayAttributeData
属性的类型不是 float,而是 FGameplayAttributeData。它的结构非常简单——核心就是两个 float:
struct GAMEPLAYABILITIES_API FGameplayAttributeData
{
FGameplayAttributeData(float DefaultValue)
: BaseValue(DefaultValue)
, CurrentValue(DefaultValue)
{}
float GetCurrentValue() const; // 返回当前值(含临时 buff)
virtual void SetCurrentValue(float NewValue);
float GetBaseValue() const; // 返回基础值(只含永久改变)
virtual void SetBaseValue(float NewValue);
protected:
UPROPERTY(...) float BaseValue; // 基础值
UPROPERTY(...) float CurrentValue; // 当前值
};
引擎注释里有一句话值得加粗:“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。
可以用一个公式记忆:
CurrentValue = BaseValue ⊕ (所有当前生效的 Duration/Infinite 类修饰器)
游戏逻辑里读取属性时几乎总是读 CurrentValue(你想知道的是"此刻实际有多少血"),而永久成长改的是 BaseValue。这套分离让"5 秒减速"和"永久升级"这两类完全不同的数值改动,能干净地共存而不互相污染——减速到期,不会把你升级得来的永久加成一起抹掉。
引擎对
PostGameplayEffectExecute的注释也印证了这点:“Called just before a GameplayEffect is executed 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
#define ATTRIBUTE_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(反射句柄)
static FGameplayAttribute GetHealthAttribute();
// ② VALUE_GETTER:读当前值,等价于 Health.GetCurrentValue()
float GetHealth() const;
// ③ VALUE_SETTER:通过 ASC 设置当前值(走能力系统,而非直接赋值)
void SetHealth(float NewVal);
// ④ VALUE_INITTER:初始化属性(同时设 Base 和 Current)
void InitHealth(float NewVal);
这四个函数各司其职,理解它们的区别非常重要:
| 函数 | 返回/作用 | 典型调用处 |
|---|---|---|
GetHealthAttribute() | 返回 FGameplayAttribute——属性的反射句柄,用来做"是哪个属性"的身份比较 | PreAttributeChange 里 if (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全替换成AttackPower:GetAttackPowerAttribute()/GetAttackPower()/SetAttackPower(float)/InitAttackPower(float)。8 个属性 × 4 个函数 = 32 个访问器,全由这一行宏批量生成——这就是 GAS 用宏换取声明简洁性的典型手法。
四、PreAttributeChange:最大血量变化时的等比缩放
PreAttributeChange 是本文的技术高潮。先看引擎给它的定位:
Called just before any modification 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 用它解决一个具体问题:当最大血量变了,当前血量要按比例跟着变。
void URPGAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
// This is called whenever attributes change, so for max health/mana we want to scale the current totals to match
Super::PreAttributeChange(Attribute, NewValue);
if (Attribute == GetMaxHealthAttribute())
{
AdjustAttributeForMaxChange(Health, MaxHealth, NewValue, GetHealthAttribute());
}
else if (Attribute == GetMaxManaAttribute())
{
AdjustAttributeForMaxChange(Mana, MaxMana, NewValue, GetManaAttribute());
}
}
逻辑很克制:只关心 MaxHealth 和 MaxMana 两个"上限类"属性的变化,其余属性一律放行。一旦发现是 MaxHealth 要变,就调用辅助函数 AdjustAttributeForMaxChange,把当前 Health 同步缩放。
4.1 为什么要等比缩放
设想没有这段逻辑:玩家满血 80/100,吃了一件 +100 最大生命的装备,MaxHealth 变成 200,但 Health 还停在 80——瞬间从"满血"变成"半血"。这显然不符合直觉。正确的体验是:血条的填充百分比保持不变,80/100(80%)→ 160/200(80%)。
来看 AdjustAttributeForMaxChange 怎么实现这个"保持百分比":

void URPGAttributeSet::AdjustAttributeForMaxChange(
FGameplayAttributeData& AffectedAttribute, // 被影响的属性,如 Health
const FGameplayAttributeData& MaxAttribute, // 对应的最大值属性,如 MaxHealth
float NewMaxValue, // 即将写入的新最大值
const FGameplayAttribute& AffectedAttributeProperty)
{
UAbilitySystemComponent* AbilityComp = GetOwningAbilitySystemComponent();
const float CurrentMaxValue = MaxAttribute.GetCurrentValue(); // 旧的最大值
if (!FMath::IsNearlyEqual(CurrentMaxValue, NewMaxValue) && AbilityComp)
{
// Change current value to maintain the current Val / Max percent
const float CurrentValue = AffectedAttribute.GetCurrentValue();
float NewDelta = (CurrentMaxValue > 0.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而不是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:有 ReplicatedUsing
UPROPERTY(BlueprintReadOnly, Category = "Health", ReplicatedUsing=OnRep_Health)
FGameplayAttributeData Health;
// Damage:没有 ReplicatedUsing
UPROPERTY(BlueprintReadOnly, Category = "Damage")
FGameplayAttributeData Damage;

注释把它的身份说得很直白:“Damage is a ‘temporary’ attribute used by the DamageExecution to calculate final damage, which then turns into -Health”。它不是一个"角色拥有的属性"(角色身上并没有一个叫"伤害值"的常驻数值),而是一个计算用的临时寄存器:
URPGDamageExecution把这一次攻击算出的最终伤害写进Damage;PostGameplayEffectExecute检测到Damage被改了,立刻把它取出来(GetDamage())、清零(SetDamage(0))、换算成Health的扣减;Damage用完即弃,永远在 0 附近。
正因为它是"用完即清零的本地中转值",复制它毫无意义——它从不代表任何需要在客户端持久显示的状态。需要同步给客户端的是扣血之后的 Health,而 Health 是复制的。所以 Damage 故意不写 ReplicatedUsing,也不出现在下面的复制清单里。这就是任务里那个问题"为什么 Damage 没有 ReplicatedUsing"的答案:它是中转站,不是状态。
至于
Damage→Health的完整换算(攻击力、防御力、钳制、回调),是下一篇伤害管线的内容,这里不展开。
六、网络复制: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 把它们登记为"需要在整个生命周期内复制":
void URPGAttributeSet::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 函数的实现几乎一模一样,都是一行宏:

void URPGAttributeSet::OnRep_Health(const FGameplayAttributeData& OldValue)
{
GAMEPLAYATTRIBUTE_REPNOTIFY(URPGAttributeSet, Health, OldValue);
}
void URPGAttributeSet::OnRep_MaxHealth(const FGameplayAttributeData& 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 Current | Base = 永久改变(升级/装备),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,就只剩最后一层窗户纸了。

277

被折叠的 条评论
为什么被折叠?



