【UE源码精读-ActionRPG】伤害管线:从捕获到扣血

📅 2026/7/8 2:32:30 👁️ 阅读次数 📝 编程学习
【UE源码精读-ActionRPG】伤害管线:从捕获到扣血

[TOC]

上一篇属性系统精读以一句"至于 Damage → Health 的完整换算,是下一篇的主角"收尾。现在轮到这条链路登场:从一次攻击能力激活,到属性系统最终把血量扣掉,中间经过了哪些层、每层的职责是什么。ActionRPG 把这条链路设计得出奇地清晰,读完它,你对 GAS 执行计算的整个机制都会有质的提升。


一、链路全景:六层调用

先用一张图把全程六层串起来,再逐层剖析:

能力激活(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 基础的话题,下一篇会展开。本文专注后四层URPGDamageExecutionPostGameplayEffectExecuteHandleDamageOnDamaged


二、URPGDamageExecution:属性捕获与伤害公式

URPGDamageExecution继承自UGameplayEffectExecutionCalculation,是 GAS 里自定义"执行计算"的标准写法。它的任务是:拿到攻击者和被攻击者的属性,按公式算出伤害,输出给属性系统。

Modifier 与 Execution 的区别GE 有两种修改属性的方式:ModifierExecution。Modifier 是"一个 Modifier 对应一个 Attribute"的简单运算(加/乘/除/覆盖),适合"加 50 攻击力"这类直接修改。Execution(UGameplayEffectExecutionCalculation)则可以一次读取多个属性、做复杂运算、输出到多个属性,适合"用攻击力和防御力计算最终伤害"这类跨属性的公式计算。ActionRPG 的伤害选择了 Execution 路线——因为伤害公式需要同时读取DamageAttackPowerDefensePower三个属性,Modifier 无法做到。

2.1 RPGDamageStatics:声明要用的属性

计算之前,先要告诉 GAS “我要读哪些属性”。这用一个叫RPGDamageStatics的 local struct 完成:

structRPGDamageStatics{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);}};staticconstRPGDamageStatics&DamageStatics(){staticRPGDamageStatics DmgStatics;returnDmgStatics;}

这里出现两个宏:DECLARE_ATTRIBUTE_CAPTUREDEF在 struct 内声明一个"捕获定义"成员,DEFINE_ATTRIBUTE_CAPTUREDEF在构造函数里把它和具体的属性绑定,同时指定:

参数含义
URPGAttributeSet属性所在的 AttributeSet 类
DefensePower/AttackPower/Damage属性名
Target/Source从哪一方捕获
false/true是否快照(Snapshot)

然后在构造函数里把这三个捕获定义注册进去:

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是实际运行的入口:

voidURPGDamageExecution::Execute_Implementation(constFGameplayEffectCustomExecutionParameters&ExecutionParams,OUT FGameplayEffectCustomExecutionOutput&OutExecutionOutput)const{FAggregatorEvaluateParameters EvaluationParameters;EvaluationParameters.SourceTags=Spec.CapturedSourceTags.GetAggregatedTags();EvaluationParameters.TargetTags=Spec.CapturedTargetTags.GetAggregatedTags();floatDefensePower=0.f;ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().DefensePowerDef,EvaluationParameters,DefensePower);if(DefensePower==0.0f)DefensePower=1.0f;// 防止除零floatAttackPower=0.f;ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().AttackPowerDef,EvaluationParameters,AttackPower);floatDamage=0.f;ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().DamageDef,EvaluationParameters,Damage);// -------------------------------------------------------// Damage Done = Damage * AttackPower / DefensePower// -------------------------------------------------------floatDamageDone=Damage*AttackPower/DefensePower;if(DamageDone>0.f){OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(DamageStatics().DamageProperty,EGameplayModOp::Additive,DamageDone));}}

公式一目了然:

真实伤害=基础伤害 × 攻击力 / 防御力

几个细节:

  • AttemptCalculateCapturedAttributeMagnitude:这是"尝试"读取捕获的属性聚合值,带了标签过滤——拥有某些 Tag 的 Buff 才生效。比如"只对中毒状态目标有效的减防"可以靠标签做区分。
  • 输出是调用OutExecutionOutput.AddOutputModifier,用EGameplayModOp::AdditiveDamage属性上一个值。注意这里的"加"加的是DamageProperty(Damage 属性的反射句柄)——也就是说,Execute_Implementation的输出,是对源 ASC 上的 Damage 属性叠加一个增量,而不是直接扣目标血量。这个增量进入 GAS 的修饰器管线,接着触发PostGameplayEffectExecute

三、PostGameplayEffectExecute:Damage → Health 的换算

这个方法在一次 Instant 类 GameplayEffect 执行完成后被调用,也是属性扣血的真正入口。

它是被谁调用的?

很多人读到这里会有个疑问:在整个 ActionRPG 工程里 grepPostGameplayEffectExecute,只找得到这份"实现",却找不到任何一处"调用",那它到底是被谁触发的?

答案是:调用它的代码不在本工程里,而在引擎的 GameplayAbilities 插件内部。PostGameplayEffectExecute是引擎基类UAttributeSet声明的一个virtual 虚函数URPGAttributeSet只是override(重写)了它——你只写实现,不写调用,调用是引擎回调过来的。这和PreAttributeChangeOnRep_Health完全是同一类机制。

真正的调用链在引擎侧(都位于Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Private/GameplayEffect.cpp):

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所以URPGDamageExecutionDamage属性AddOutputModifier出来的那"一个"增量,就是触发这次回调的直接原因。

换句话说,第二节的"输出加到 Damage 属性"和本节的"扣血"之间,其实还隔着一层引擎调用虚函数的动作——前面的全景图把这一层隐掉了,补上后应该是:

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())打断点,触发一次近战命中,把整条调用栈抓下来对照。

真实堆栈很长(几十帧),其中一大半是蓝图虚拟机执行字节码的"管道帧"——ProcessEventUFunction::InvokeFFrame::StepexecLetProcessLocalScriptFunction等等。这些帧只说明"当前正在跑一段蓝图图表",本身没有业务含义,读栈时可以整段折叠掉。把管道帧去掉后,剩下的有业务含义的帧从下往上(最外层 → 断点)正好分成四段:

① 引擎 Tick + 物理重叠(最外层,伤害的物理起点)

UWorld::Tick └ UCharacterMovementComponent::TickComponent ← 角色移动的 Tick └ PerformMovement → ~FScopedMovementUpdate ← 移动更新收尾时结算重叠 └ UpdateOverlaps → BeginComponentOverlap ← 武器碰撞体扫过、与目标重叠 └ AActor::NotifyActorBeginOverlap └ AActor::ReceiveActorBeginOverlap ← 回调蓝图的 ActorBeginOverlap 事件

链路的真正起点不是"扣血",而是"这一帧角色动了"。角色移动 Tick 里,武器碰撞体扫过目标,引擎在移动更新收尾(~FScopedMovementUpdate析构)时结算重叠,检测到命中,于是回调蓝图的ActorBeginOverlap

② 蓝图事件 → 发 GameplayEvent → 被 AbilityTask 接住

(蓝图虚拟机执行 Overlap 事件图表) └ SendGameplayEventToActor ← 蓝图把这次命中广播成一个 GameplayEvent └ UAbilitySystemComponent::HandleGameplayEvent └ …Broadcast → URPGAbilityTask_PlayMontageAndWaitForEvent::OnGameplayEvent ← 正在等事件的 AbilityTask 收到 └ FRPGPlayMontageAndWaitForEventDelegate 广播回技能蓝图

这正对应前面提到的RPGAbilityTask_PlayMontageAndWaitForEvent——技能一边播放攻击蒙太奇,一边等EventReceived。命中产生的 GameplayEvent 被它接住,再广播回技能蓝图,驱动下一步。

③ 技能应用效果容器(EffectContainer → GE Spec)

URPGGameplayAbility::execApplyEffectContainer ← 蓝图调用 ApplyEffectContainer └ ApplyEffectContainer → ApplyEffectContainerSpec ← MakeSpec / ApplySpec 那一对 └ K2_ApplyGameplayEffectSpecToTarget └ UGameplayAbility::ApplyGameplayEffectSpecToTarget └ UAbilitySystemComponent::ApplyGameplayEffectSpecToTarget └ ApplyGameplayEffectSpecToSelf ← 施加到目标自己的 ASC 上

技能收到事件后,按EffectContainerMap取出对应的 GameplayEffect,组装成 Spec 并应用到目标身上。这就是第一节全景图里"MakeEffectContainerSpec → ApplyEffectContainerSpec"那一层的真身。

④ GE 执行 → 印证本节结论

UAbilitySystemComponent::ExecuteGameplayEffect (行934) └ FActiveGameplayEffectsContainer::ExecuteActiveEffectsFrom (行2010) └ FActiveGameplayEffectsContainer::InternalExecuteMod (行2710) ← 就是这里! └ URPGAttributeSet::PostGameplayEffectExecute (行102) ← 断点命中

最里面这四层,和本节前面"读代码推断"出来的引擎调用链一字不差InternalExecuteMod写完 BaseValue 后回调PostGameplayEffectExecute。之前靠翻源码猜的路径,被真实堆栈实锤了。

把四段连起来,就得到一条比全景图更完整、更靠前的因果链:

角色移动 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里。

理解了"谁调用、何时调用"之后,再看实现。上一篇已经介绍过它的整体结构,这里专注伤害分支:

voidURPGAttributeSet::PostGameplayEffectExecute(constFGameplayEffectModCallbackData&Data){Super::PostGameplayEffectExecute(Data);// ... 解析 Context、获取 Source/Target Actor ...if(Data.EvaluatedData.Attribute==GetDamageAttribute()){// 1. 本地复制伤害值,并立刻清零(Damage 是临时中转站)constfloatLocalDamageDone=GetDamage();SetDamage(0.f);if(LocalDamageDone>0){// 2. 扣血并钳制到 [0, MaxHealth]constfloatOldHealth=GetHealth();SetHealth(FMath::Clamp(OldHealth-LocalDamageDone,0.0f,GetMaxHealth()));if(TargetCharacter){// 3. 触发角色的伤害回调TargetCharacter->HandleDamage(LocalDamageDone,HitResult,SourceTags,SourceCharacter,SourceActor);// 4. 触发血量变化回调TargetCharacter->HandleHealthChanged(-LocalDamageDone,SourceTags);}}}elseif(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):

voidARPGCharacterBase::HandleDamage(floatDamageAmount,constFHitResult&HitInfo,constFGameplayTagContainer&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

UFUNCTION(BlueprintImplementableEvent)voidOnDamaged(floatDamageAmount,constFHitResult&HitInfo,constFGameplayTagContainer&DamageTags,ARPGCharacterBase*InstigatorCharacter,AActor*DamageCauser);UFUNCTION(BlueprintImplementableEvent)voidOnHealthChanged(floatDeltaValue,constFGameplayTagContainer&EventTags);UFUNCTION(BlueprintImplementableEvent)voidOnManaChanged(floatDeltaValue,constFGameplayTagContainer&EventTags);UFUNCTION(BlueprintImplementableEvent)voidOnMoveSpeedChanged(floatDeltaValue,constFGameplayTagContainer&EventTags);

它们各自对应PostGameplayEffectExecute里的四个else if分支。C++ 是事件的"发射端",蓝图是事件的"实现端"。

OnDamagedOnHealthChanged在一次伤害里会同时被调:

  • OnDamaged:带完整上下文(攻击者、HitResult、伤害标签)——适合实现受击动画、伤害来源判定、死亡逻辑。
  • OnHealthChanged:更通用,只有"变化量和原因标签"——适合更新血条 UI、显示飘字伤害数。

为什么分开两个?因为治疗不触发OnDamaged,但会触发OnHealthChanged。血条 UI 绑定OnHealthChanged,无论是受伤还是治疗都自动更新,而受击反馈只绑OnDamaged。这样各自独立,互不干扰。


五、Source 的解析与GetEffectCauser

PostGameplayEffectExecute里有一段"解析攻击者"的代码,看起来有点啰嗦,但每一步都有道理:

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());elseSourceCharacter=Cast<ARPGCharacterBase>(SourceActor);// 如果 Context 里记录了 EffectCauser,覆盖 SourceActorif(Context.GetEffectCauser())SourceActor=Context.GetEffectCauser();}

最后那行GetEffectCauser()覆盖是关键。这里要分清两个容易混的概念:

  • Instigator(发起者):真正"发动这次攻击的人"——玩家或角色本体,攻击者的 ASC 就挂在它身上。
  • EffectCauser(直接致害物):真正"在物理上碰到目标、造成这次效果的 Actor"。近战里往往是挥出去的那把,远程里是飞出去的那颗火球,而不是玩家本体。

某些场景下这两者不是同一个东西:攻击的 ASC 属于玩家(发起者),但直接碰到目标的是武器(致害物)。Context.GetEffectCauser()记录的就是这个"直接致害物"(那把剑)。OnDamagedDamageCauser参数拿到的正是它——蓝图可以据此播放对应的武器命中特效(比如剑砍中时的火花),而不用去管背后是哪个玩家发动的。


六、小结:设计亮点

把全管线梳理一遍,几个值得记忆的设计点:

位置设计原因
RPGDamageStaticsAttackPower 快照,DefensePower 不快照攻击力锁发射时,防御力用命中时
Execute_Implementation输出加到Damage属性,而不是直接扣血让 AttributeSet 统一处理后处理逻辑
PostGameplayEffectExecute立刻SetDamage(0)清零Damage 是中转站,不是持久状态
HandleDamageC++ 中间层转发到OnDamaged允许 C++ 子类重写,同时蓝图干净
OnDamaged+OnHealthChanged分开受击逻辑与血量 UI 解耦治疗只触发后者,不触发前者

整条管线理解之后,再看URPGDamageExecution里那句注释——“Most games will want to implement multiple game-specific executions”——就能真切体会到它的意思:ActionRPG 的Damage × AttackPower / DefensePower只是个示例公式,真实游戏里你可以写元素克制、护盾吸收、暴击乘数……统统塞进Execute_Implementation,而PostGameplayEffectExecute那一层完全不用动。这就是ExecutionCalculation抽象的价值所在。