TOC
上一篇属性系统精读以一句"至于 Damage → Health 的完整换算,是下一篇的主角"收尾。现在轮到这条链路登场:从一次攻击能力激活,到属性系统最终把血量扣掉,中间经过了哪些层、每层的职责是什么。ActionRPG 把这条链路设计得出奇地清晰,读完它,你对 GAS 执行计算的整个机制都会有质的提升。
一、链路全景:六层调用
先用一张图把全程六层串起来,再逐层剖析:
bash
能力激活(GA_Melee_Base 蓝图)
│ MakeEffectContainerSpec → ApplyEffectContainerSpec
▼
GameplayEffect 施加(含 URPGDamageExecution 作为 ExecutionCalculation)
│ Execute_Implementation 被调用
▼
URPGDamageExecution::Execute_Implementation
│ 捕获属性 → 套公式 → 输出 DamageDone 到 Damage 属性
▼
PostGameplayEffectExecute(URPGAttributeSet)
│ 检测 Damage 属性被改 → 读出值 → 清零 → 扣 Health
▼
ARPGCharacterBase::HandleDamage
│ C++ 中间层,组装参数
▼
OnDamaged(BlueprintImplementableEvent)
蓝图中实现:播放受击特效、判断死亡等

前两层(能力激活与效果施加)是 GAS 基础的话题,下一篇会展开。本文专注后四层 :URPGDamageExecution → PostGameplayEffectExecute → HandleDamage → OnDamaged。
二、URPGDamageExecution:属性捕获与伤害公式
URPGDamageExecution 继承自 UGameplayEffectExecutionCalculation,是 GAS 里自定义"执行计算"的标准写法。它的任务是:拿到攻击者和被攻击者的属性,按公式算出伤害,输出给属性系统。
Modifier 与 Execution 的区别 GE 有两种修改属性的方式:Modifier 和 Execution 。Modifier 是"一个 Modifier 对应一个 Attribute"的简单运算(加/乘/除/覆盖),适合"加 50 攻击力"这类直接修改。Execution(
UGameplayEffectExecutionCalculation)则可以一次读取多个属性、做复杂运算、输出到多个属性 ,适合"用攻击力和防御力计算最终伤害"这类跨属性的公式计算。ActionRPG 的伤害选择了 Execution 路线------因为伤害公式需要同时读取Damage、AttackPower、DefensePower三个属性,Modifier 无法做到。
2.1 RPGDamageStatics:声明要用的属性
计算之前,先要告诉 GAS "我要读哪些属性"。这用一个叫 RPGDamageStatics 的 local struct 完成:
cpp
struct RPGDamageStatics
{
DECLARE_ATTRIBUTE_CAPTUREDEF(DefensePower);
DECLARE_ATTRIBUTE_CAPTUREDEF(AttackPower);
DECLARE_ATTRIBUTE_CAPTUREDEF(Damage);
RPGDamageStatics()
{
// 目标的防御力:不快照(实时读)
DEFINE_ATTRIBUTE_CAPTUREDEF(URPGAttributeSet, DefensePower, Target, false);
// 施法者的攻击力:快照(用发动时的值)
DEFINE_ATTRIBUTE_CAPTUREDEF(URPGAttributeSet, AttackPower, Source, true);
// 施法者的基础伤害:快照
DEFINE_ATTRIBUTE_CAPTUREDEF(URPGAttributeSet, Damage, Source, true);
}
};
static const RPGDamageStatics& DamageStatics()
{
static RPGDamageStatics DmgStatics;
return DmgStatics;
}
这里出现两个宏:DECLARE_ATTRIBUTE_CAPTUREDEF 在 struct 内声明一个"捕获定义"成员,DEFINE_ATTRIBUTE_CAPTUREDEF 在构造函数里把它和具体的属性绑定,同时指定:
| 参数 | 含义 |
|---|---|
URPGAttributeSet |
属性所在的 AttributeSet 类 |
DefensePower / AttackPower / Damage |
属性名 |
Target / Source |
从哪一方捕获 |
false / true |
是否快照(Snapshot) |
然后在构造函数里把这三个捕获定义注册进去:
cpp
URPGDamageExecution::URPGDamageExecution()
{
RelevantAttributesToCapture.Add(DamageStatics().DefensePowerDef);
RelevantAttributesToCapture.Add(DamageStatics().AttackPowerDef);
RelevantAttributesToCapture.Add(DamageStatics().DamageDef);
}
2.2 快照 vs 不快照:抛射物场景的设计意图
快照(Snapshot = true) :创建 GameplayEffectSpec 那一刻,把属性值复制进去,之后不再跟踪变化。
不快照(Snapshot = false):每次执行计算时实时从目标读。
注释里有一个非常贴切的抛射物例子:
我们发射一颗火球。GE Spec 在发射时 创建------此时用发射者的 AttackPower 做快照。火球飞行途中,发射者的攻击力可能因为某个 Buff 下降了。当火球命中目标时,ExecutionCalculation 执行:它用的是快照里的攻击力(发射时),而不是命中时的实时值。这符合"攻击时锁定伤害"的直觉。
反观 DefensePower:它设置成不快照,因为我们希望用命中时目标的实际防御力来减伤------目标如果在火球飞行途中主动使用了防御技能,盾应该有效。
这两条规则看似简单,却决定了大量 RPG 数值交互的正确性。
2.3 伤害公式
Execute_Implementation 是实际运行的入口:
cpp
void URPGDamageExecution::Execute_Implementation(
const FGameplayEffectCustomExecutionParameters& ExecutionParams,
OUT FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const
{
FAggregatorEvaluateParameters EvaluationParameters;
EvaluationParameters.SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
EvaluationParameters.TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();
float DefensePower = 0.f;
ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(
DamageStatics().DefensePowerDef, EvaluationParameters, DefensePower);
if (DefensePower == 0.0f) DefensePower = 1.0f; // 防止除零
float AttackPower = 0.f;
ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(
DamageStatics().AttackPowerDef, EvaluationParameters, AttackPower);
float Damage = 0.f;
ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(
DamageStatics().DamageDef, EvaluationParameters, Damage);
// -------------------------------------------------------
// Damage Done = Damage * AttackPower / DefensePower
// -------------------------------------------------------
float DamageDone = Damage * AttackPower / DefensePower;
if (DamageDone > 0.f)
{
OutExecutionOutput.AddOutputModifier(
FGameplayModifierEvaluatedData(
DamageStatics().DamageProperty,
EGameplayModOp::Additive,
DamageDone));
}
}
公式一目了然:
bash
真实伤害 = 基础伤害 × 攻击力 / 防御力
几个细节:
AttemptCalculateCapturedAttributeMagnitude:这是"尝试"读取捕获的属性聚合值,带了标签过滤------拥有某些 Tag 的 Buff 才生效。比如"只对中毒状态目标有效的减防"可以靠标签做区分。- 输出 是调用
OutExecutionOutput.AddOutputModifier,用EGameplayModOp::Additive往Damage属性上加 一个值。注意这里的"加"加的是DamageProperty(Damage 属性的反射句柄)------也就是说,Execute_Implementation的输出,是对源 ASC 上的 Damage 属性叠加一个增量 ,而不是直接扣目标血量。这个增量进入 GAS 的修饰器管线,接着触发PostGameplayEffectExecute。
三、PostGameplayEffectExecute:Damage → Health 的换算
这个方法在一次 Instant 类 GameplayEffect 执行完成后被调用,也是属性扣血的真正入口。
它是被谁调用的?
很多人读到这里会有个疑问:在整个 ActionRPG 工程里 grep PostGameplayEffectExecute,只找得到这份"实现",却找不到任何一处"调用",那它到底是被谁触发的?
答案是:调用它的代码不在本工程里,而在引擎的 GameplayAbilities 插件内部。 PostGameplayEffectExecute 是引擎基类 UAttributeSet 声明的一个 virtual 虚函数 ,URPGAttributeSet 只是 override(重写) 了它------你只写实现,不写调用,调用是引擎回调过来的。这和 PreAttributeChange、OnRep_Health 完全是同一类机制。
真正的调用链在引擎侧(都位于 Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Private/GameplayEffect.cpp):
bash
UAbilitySystemComponent::ApplyGameplayEffectSpecToSelf
└─ FActiveGameplayEffectsContainer::ExecuteActiveEffectsFrom ← 执行 Instant GE
├─ 执行自定义 Execution
│ └─ URPGDamageExecution::Execute_Implementation ← 上一节
│ (输出 = 往 Damage 属性加一个 OutputModifier)
└─ 把输出的每一个 Modifier 逐个应用:
└─ FActiveGameplayEffectsContainer::InternalExecuteMod
├─ AttributeSet->PreGameplayEffectExecute(Data)
├─ 实际写入 BaseValue
└─ AttributeSet->PostGameplayEffectExecute(Data) ← ★就是在这里被调
关键在 InternalExecuteMod:每往 BaseValue 写入一个 Modifier,引擎就会找到该属性所属的 AttributeSet,回调一次它的 PostGameplayEffectExecute。 所以 URPGDamageExecution 对 Damage 属性 AddOutputModifier 出来的那"一个"增量,就是触发这次回调的直接原因。
换句话说,第二节的"输出加到 Damage 属性"和本节的"扣血"之间,其实还隔着一层引擎调用虚函数的动作------前面的全景图把这一层隐掉了,补上后应该是:
bash
URPGDamageExecution::Execute_Implementation
│ 往 Damage 属性输出一个 OutputModifier
▼
【引擎】FActiveGameplayEffectsContainer::InternalExecuteMod
│ 写完 BaseValue 后,立刻调用 AttributeSet 的虚函数
▼
PostGameplayEffectExecute(URPGAttributeSet 重写)
想亲眼确认的话,打开 UE 4.27 安装目录下的 GameplayEffect.cpp,搜索 PostGameplayEffectExecute,即可在 InternalExecuteMod 里定位到那次调用。
用真实调用栈验证一遍(Debug 配置)
上面这条链是"读代码推断"出来的。有了源码引擎的 Debug 配置 (游戏+引擎全部未优化),我们可以直接在 PostGameplayEffectExecute 的第 102 行 (函数体第一条语句 Data.EffectSpec.GetContext())打断点,触发一次近战命中,把整条调用栈抓下来对照。

真实堆栈很长(几十帧),其中一大半是蓝图虚拟机执行字节码的"管道帧"------ProcessEvent、UFunction::Invoke、FFrame::Step、execLet、ProcessLocalScriptFunction 等等。这些帧只说明"当前正在跑一段蓝图图表",本身没有业务含义,读栈时可以整段折叠掉。把管道帧去掉后,剩下的有业务含义的帧从下往上(最外层 → 断点)正好分成四段:
① 引擎 Tick + 物理重叠(最外层,伤害的物理起点)
bash
UWorld::Tick
└ UCharacterMovementComponent::TickComponent ← 角色移动的 Tick
└ PerformMovement → ~FScopedMovementUpdate ← 移动更新收尾时结算重叠
└ UpdateOverlaps → BeginComponentOverlap ← 武器碰撞体扫过、与目标重叠
└ AActor::NotifyActorBeginOverlap
└ AActor::ReceiveActorBeginOverlap ← 回调蓝图的 ActorBeginOverlap 事件
链路的真正起点不是"扣血",而是"这一帧角色动了" 。角色移动 Tick 里,武器碰撞体扫过目标,引擎在移动更新收尾(~FScopedMovementUpdate 析构)时结算重叠,检测到命中,于是回调蓝图的 ActorBeginOverlap。
② 蓝图事件 → 发 GameplayEvent → 被 AbilityTask 接住
bash
(蓝图虚拟机执行 Overlap 事件图表)
└ SendGameplayEventToActor ← 蓝图把这次命中广播成一个 GameplayEvent
└ UAbilitySystemComponent::HandleGameplayEvent
└ ...Broadcast → URPGAbilityTask_PlayMontageAndWaitForEvent::OnGameplayEvent ← 正在等事件的 AbilityTask 收到
└ FRPGPlayMontageAndWaitForEventDelegate 广播回技能蓝图
这正对应前面提到的 RPGAbilityTask_PlayMontageAndWaitForEvent------技能一边播放攻击蒙太奇,一边等 EventReceived。命中产生的 GameplayEvent 被它接住,再广播回技能蓝图,驱动下一步。
③ 技能应用效果容器(EffectContainer → GE Spec)
bash
URPGGameplayAbility::execApplyEffectContainer ← 蓝图调用 ApplyEffectContainer
└ ApplyEffectContainer → ApplyEffectContainerSpec ← MakeSpec / ApplySpec 那一对
└ K2_ApplyGameplayEffectSpecToTarget
└ UGameplayAbility::ApplyGameplayEffectSpecToTarget
└ UAbilitySystemComponent::ApplyGameplayEffectSpecToTarget
└ ApplyGameplayEffectSpecToSelf ← 施加到目标自己的 ASC 上
技能收到事件后,按 EffectContainerMap 取出对应的 GameplayEffect,组装成 Spec 并应用到目标身上。这就是第一节全景图里"MakeEffectContainerSpec → ApplyEffectContainerSpec"那一层的真身。
④ GE 执行 → 印证本节结论
bash
UAbilitySystemComponent::ExecuteGameplayEffect (行 934)
└ FActiveGameplayEffectsContainer::ExecuteActiveEffectsFrom (行 2010)
└ FActiveGameplayEffectsContainer::InternalExecuteMod (行 2710) ← 就是这里!
└ URPGAttributeSet::PostGameplayEffectExecute (行 102) ← 断点命中
最里面这四层,和本节前面"读代码推断"出来的引擎调用链一字不差 :InternalExecuteMod 写完 BaseValue 后回调 PostGameplayEffectExecute。之前靠翻源码猜的路径,被真实堆栈实锤了。
把四段连起来,就得到一条比全景图更完整、更靠前的因果链:
bash
角色移动 Tick → 武器碰撞体重叠 → 蓝图 ActorBeginOverlap
→ SendGameplayEventToActor(发 GameplayEvent)
→ PlayMontageAndWaitForEvent 接住事件
→ 技能 ApplyEffectContainer(取 GE、组 Spec、应用)
→ ExecuteGameplayEffect → InternalExecuteMod
→ PostGameplayEffectExecute → 扣血
断点此刻停在这根栈的最顶端 ,往下看每一帧,就能亲眼看到"到底是谁、经过哪些环节调用了我"。这也顺带说明了为什么值得费劲编译一套 Debug 引擎:只有游戏和引擎都未优化,这根跨越"引擎 Tick → GAS 插件 → 你自己的 AttributeSet"的完整栈才不会被优化折叠,每一帧的参数也才看得清。
为什么偏偏是它触发,而不是别的回调?
GE 按 Duration Policy(持续策略)分三种:Instant(瞬时)、Has Duration(限时)、Infinite(永久)。后两种改的是 CurrentValue(临时叠加,到期自动还原),走的是 PreAttributeChange;只有 Instant GE 直接改 BaseValue (永久改变),改完后才触发 PostGameplayEffectExecute。
ActionRPG 的伤害 GE 正是 Instant 类型------扣血是一次性的永久修改,不存在"10 秒后血量自动恢复"这种语义,所以走的是"改 BaseValue → 触发 PostGameplayEffectExecute"这条路。这也顺带解释了:为什么扣血逻辑要写在这个回调里,而不是写在 PreAttributeChange 里。
理解了"谁调用、何时调用"之后,再看实现。上一篇已经介绍过它的整体结构,这里专注伤害分支:
cpp
void URPGAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);
// ... 解析 Context、获取 Source/Target Actor ...
if (Data.EvaluatedData.Attribute == GetDamageAttribute())
{
// 1. 本地复制伤害值,并立刻清零(Damage 是临时中转站)
const float LocalDamageDone = GetDamage();
SetDamage(0.f);
if (LocalDamageDone > 0)
{
// 2. 扣血并钳制到 [0, MaxHealth]
const float OldHealth = GetHealth();
SetHealth(FMath::Clamp(OldHealth - LocalDamageDone, 0.0f, GetMaxHealth()));
if (TargetCharacter)
{
// 3. 触发角色的伤害回调
TargetCharacter->HandleDamage(LocalDamageDone, HitResult,
SourceTags, SourceCharacter, SourceActor);
// 4. 触发血量变化回调
TargetCharacter->HandleHealthChanged(-LocalDamageDone, SourceTags);
}
}
}
else if (Data.EvaluatedData.Attribute == GetHealthAttribute())
{
// 治疗/直接 Health 修改的分支
SetHealth(FMath::Clamp(GetHealth(), 0.0f, GetMaxHealth()));
if (TargetCharacter)
TargetCharacter->HandleHealthChanged(DeltaValue, SourceTags);
}
// ... Mana、MoveSpeed 分支同理 ...
}
这段代码的逻辑可以分四步理解:
第一步:取出 Damage 并立即清零
GetDamage() 读出 URPGDamageExecution 写进来的伤害值,SetDamage(0.f) 马上清零。这正是属性系统精读里讲的"Damage 是临时中转站"------它的生命周期就在这几行代码之间,用完即清。
第二步:扣血并钳制(Clamp)
"钳制"是 FMath::Clamp 的中文叫法,意思是把一个数值强行限制在给定的上下限区间内:小于下限就取下限,大于上限就取上限,落在中间才用原值。
FMath::Clamp(OldHealth - LocalDamageDone, 0.0f, GetMaxHealth()) 就是把"扣血后的新血量"限制到 [0, MaxHealth]:算出来若是负数(被打死了),取 0,血量不会变成负值;若超过了最大血量(比如同时还在回血),取 MaxHealth,不会溢出上限。夹好之后再调 SetHealth------这走的是 ASC 的正规通道,SetHealth 内部通过 ApplyModToAttributeUnsafe 修改 CurrentValue。
第三步:调 HandleDamage
这是一个"转发层"。HandleDamage 不做额外计算,直接调 OnDamaged(BlueprintImplementableEvent):
cpp
void ARPGCharacterBase::HandleDamage(float DamageAmount, const FHitResult& HitInfo,
const FGameplayTagContainer& DamageTags,
ARPGCharacterBase* InstigatorPawn, AActor* DamageCauser)
{
OnDamaged(DamageAmount, HitInfo, DamageTags, InstigatorPawn, DamageCauser);
}
为什么要有这个 C++ 中间层?因为 C++ 里的子类(比如 NPC 基类)可以重写 HandleDamage 做额外逻辑(记录伤害统计、触发 AI 行为树等),而蓝图的 OnDamaged 保持干净。这是"C++ 提供 Hook,蓝图实现内容"模式的典型体现。
第四步:调 HandleHealthChanged
注意参数是 -LocalDamageDone(负数),表示血量减少 了。治疗分支传的是 DeltaValue(正数)。接收这个回调的蓝图可以统一处理"血量变化显示":正数刷新治疗特效,负数刷新受伤血量跳字。
四、HandleDamage → OnDamaged 回调的设计
ARPGCharacterBase 里有四个 BlueprintImplementableEvent:
cpp
UFUNCTION(BlueprintImplementableEvent)
void OnDamaged(float DamageAmount, const FHitResult& HitInfo,
const FGameplayTagContainer& DamageTags,
ARPGCharacterBase* InstigatorCharacter, AActor* DamageCauser);
UFUNCTION(BlueprintImplementableEvent)
void OnHealthChanged(float DeltaValue, const FGameplayTagContainer& EventTags);
UFUNCTION(BlueprintImplementableEvent)
void OnManaChanged(float DeltaValue, const FGameplayTagContainer& EventTags);
UFUNCTION(BlueprintImplementableEvent)
void OnMoveSpeedChanged(float DeltaValue, const FGameplayTagContainer& EventTags);
它们各自对应 PostGameplayEffectExecute 里的四个 else if 分支。C++ 是事件的"发射端",蓝图是事件的"实现端"。
OnDamaged 和 OnHealthChanged 在一次伤害里会同时被调:
OnDamaged:带完整上下文(攻击者、HitResult、伤害标签)------适合实现受击动画、伤害来源判定、死亡逻辑。OnHealthChanged:更通用,只有"变化量和原因标签"------适合更新血条 UI、显示飘字伤害数。
为什么分开两个?因为治疗不触发 OnDamaged,但会触发 OnHealthChanged。血条 UI 绑定 OnHealthChanged,无论是受伤还是治疗都自动更新,而受击反馈只绑 OnDamaged。这样各自独立,互不干扰。
五、Source 的解析与 GetEffectCauser
PostGameplayEffectExecute 里有一段"解析攻击者"的代码,看起来有点啰嗦,但每一步都有道理:
cpp
AActor* SourceActor = nullptr;
AController* SourceController = nullptr;
ARPGCharacterBase* SourceCharacter = nullptr;
if (Source && Source->AbilityActorInfo.IsValid()
&& Source->AbilityActorInfo->AvatarActor.IsValid())
{
SourceActor = Source->AbilityActorInfo->AvatarActor.Get();
SourceController = Source->AbilityActorInfo->PlayerController.Get();
// PlayerController 为空时,尝试从 Pawn 拿
if (SourceController == nullptr && SourceActor != nullptr)
{
if (APawn* Pawn = Cast<APawn>(SourceActor))
SourceController = Pawn->GetController();
}
if (SourceController)
SourceCharacter = Cast<ARPGCharacterBase>(SourceController->GetPawn());
else
SourceCharacter = Cast<ARPGCharacterBase>(SourceActor);
// 如果 Context 里记录了 EffectCauser,覆盖 SourceActor
if (Context.GetEffectCauser())
SourceActor = Context.GetEffectCauser();
}
最后那行 GetEffectCauser() 覆盖是关键。这里要分清两个容易混的概念:
- Instigator(发起者):真正"发动这次攻击的人"------玩家或角色本体,攻击者的 ASC 就挂在它身上。
- EffectCauser(直接致害物) :真正"在物理上碰到目标、造成这次效果的 Actor"。近战里往往是挥出去的那把剑 ,远程里是飞出去的那颗火球,而不是玩家本体。
某些场景下这两者不是同一个东西:攻击的 ASC 属于玩家(发起者),但直接碰到目标的是武器(致害物)。Context.GetEffectCauser() 记录的就是这个"直接致害物"(那把剑)。OnDamaged 的 DamageCauser 参数拿到的正是它------蓝图可以据此播放对应的武器命中特效(比如剑砍中时的火花),而不用去管背后是哪个玩家发动的。
六、小结:设计亮点
把全管线梳理一遍,几个值得记忆的设计点:
| 位置 | 设计 | 原因 |
|---|---|---|
RPGDamageStatics |
AttackPower 快照,DefensePower 不快照 | 攻击力锁发射时,防御力用命中时 |
Execute_Implementation |
输出加到 Damage 属性,而不是直接扣血 |
让 AttributeSet 统一处理后处理逻辑 |
PostGameplayEffectExecute |
立刻 SetDamage(0) 清零 |
Damage 是中转站,不是持久状态 |
HandleDamage |
C++ 中间层转发到 OnDamaged |
允许 C++ 子类重写,同时蓝图干净 |
OnDamaged + OnHealthChanged 分开 |
受击逻辑与血量 UI 解耦 | 治疗只触发后者,不触发前者 |
整条管线理解之后,再看 URPGDamageExecution 里那句注释------"Most games will want to implement multiple game-specific executions"------就能真切体会到它的意思:ActionRPG 的 Damage × AttackPower / DefensePower 只是个示例公式,真实游戏里你可以写元素克制、护盾吸收、暴击乘数......统统塞进 Execute_Implementation,而 PostGameplayEffectExecute 那一层完全不用动。这就是 ExecutionCalculation 抽象的价值所在。