【UE源码精读-ActionRPG】伤害管线:从捕获到扣血
[TOC]上一篇属性系统精读以一句至于 Damage → Health 的完整换算是下一篇的主角收尾。现在轮到这条链路登场从一次攻击能力激活到属性系统最终把血量扣掉中间经过了哪些层、每层的职责是什么。ActionRPG 把这条链路设计得出奇地清晰读完它你对 GAS 执行计算的整个机制都会有质的提升。一、链路全景六层调用先用一张图把全程六层串起来再逐层剖析能力激活GA_Melee_Base 蓝图 │ MakeEffectContainerSpec → ApplyEffectContainerSpec ▼ GameplayEffect 施加含 URPGDamageExecution 作为 ExecutionCalculation │ Execute_Implementation 被调用 ▼ URPGDamageExecution::Execute_Implementation │ 捕获属性 → 套公式 → 输出 DamageDone 到 Damage 属性 ▼ PostGameplayEffectExecuteURPGAttributeSet │ 检测 Damage 属性被改 → 读出值 → 清零 → 扣 Health ▼ ARPGCharacterBase::HandleDamage │ C 中间层组装参数 ▼ OnDamagedBlueprintImplementableEvent 蓝图中实现播放受击特效、判断死亡等前两层能力激活与效果施加是 GAS 基础的话题下一篇会展开。本文专注后四层URPGDamageExecution→PostGameplayEffectExecute→HandleDamage→OnDamaged。二、URPGDamageExecution属性捕获与伤害公式URPGDamageExecution继承自UGameplayEffectExecutionCalculation是 GAS 里自定义执行计算的标准写法。它的任务是拿到攻击者和被攻击者的属性按公式算出伤害输出给属性系统。Modifier 与 Execution 的区别GE 有两种修改属性的方式Modifier和Execution。Modifier 是一个 Modifier 对应一个 Attribute的简单运算加/乘/除/覆盖适合加 50 攻击力这类直接修改。ExecutionUGameplayEffectExecutionCalculation则可以一次读取多个属性、做复杂运算、输出到多个属性适合用攻击力和防御力计算最终伤害这类跨属性的公式计算。ActionRPG 的伤害选择了 Execution 路线——因为伤害公式需要同时读取Damage、AttackPower、DefensePower三个属性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);}};staticconstRPGDamageStaticsDamageStatics(){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(constFGameplayEffectCustomExecutionParametersExecutionParams,OUT FGameplayEffectCustomExecutionOutputOutExecutionOutput)const{FAggregatorEvaluateParameters EvaluationParameters;EvaluationParameters.SourceTagsSpec.CapturedSourceTags.GetAggregatedTags();EvaluationParameters.TargetTagsSpec.CapturedTargetTags.GetAggregatedTags();floatDefensePower0.f;ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().DefensePowerDef,EvaluationParameters,DefensePower);if(DefensePower0.0f)DefensePower1.0f;// 防止除零floatAttackPower0.f;ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().AttackPowerDef,EvaluationParameters,AttackPower);floatDamage0.f;ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().DamageDef,EvaluationParameters,Damage);// -------------------------------------------------------// Damage Done Damage * AttackPower / DefensePower// -------------------------------------------------------floatDamageDoneDamage*AttackPower/DefensePower;if(DamageDone0.f){OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(DamageStatics().DamageProperty,EGameplayModOp::Additive,DamageDone));}}公式一目了然真实伤害基础伤害 × 攻击力 / 防御力几个细节AttemptCalculateCapturedAttributeMagnitude这是尝试读取捕获的属性聚合值带了标签过滤——拥有某些 Tag 的 Buff 才生效。比如只对中毒状态目标有效的减防可以靠标签做区分。输出是调用OutExecutionOutput.AddOutputModifier用EGameplayModOp::Additive往Damage属性上加一个值。注意这里的加加的是DamagePropertyDamage 属性的反射句柄——也就是说Execute_Implementation的输出是对源 ASC 上的 Damage 属性叠加一个增量而不是直接扣目标血量。这个增量进入 GAS 的修饰器管线接着触发PostGameplayEffectExecute。三、PostGameplayEffectExecuteDamage → Health 的换算这个方法在一次 Instant 类 GameplayEffect 执行完成后被调用也是属性扣血的真正入口。它是被谁调用的很多人读到这里会有个疑问在整个 ActionRPG 工程里 grepPostGameplayEffectExecute只找得到这份实现却找不到任何一处调用那它到底是被谁触发的答案是调用它的代码不在本工程里而在引擎的 GameplayAbilities 插件内部。PostGameplayEffectExecute是引擎基类UAttributeSet声明的一个virtual 虚函数URPGAttributeSet只是override重写了它——你只写实现不写调用调用是引擎回调过来的。这和PreAttributeChange、OnRep_Health完全是同一类机制。真正的调用链在引擎侧都位于Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Private/GameplayEffect.cppUAbilitySystemComponent::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 属性和本节的扣血之间其实还隔着一层引擎调用虚函数的动作——前面的全景图把这一层隐掉了补上后应该是URPGDamageExecution::Execute_Implementation │ 往 Damage 属性输出一个 OutputModifier ▼ 【引擎】FActiveGameplayEffectsContainer::InternalExecuteMod │ 写完 BaseValue 后立刻调用 AttributeSet 的虚函数 ▼ PostGameplayEffectExecuteURPGAttributeSet 重写想亲眼确认的话打开 UE 4.27 安装目录下的GameplayEffect.cpp搜索PostGameplayEffectExecute即可在InternalExecuteMod里定位到那次调用。用真实调用栈验证一遍Debug 配置上面这条链是读代码推断出来的。有了源码引擎的Debug 配置游戏引擎全部未优化我们可以直接在PostGameplayEffectExecute的第 102 行函数体第一条语句Data.EffectSpec.GetContext()打断点触发一次近战命中把整条调用栈抓下来对照。真实堆栈很长几十帧其中一大半是蓝图虚拟机执行字节码的管道帧——ProcessEvent、UFunction::Invoke、FFrame::Step、execLet、ProcessLocalScriptFunction等等。这些帧只说明当前正在跑一段蓝图图表本身没有业务含义读栈时可以整段折叠掉。把管道帧去掉后剩下的有业务含义的帧从下往上最外层 → 断点正好分成四段① 引擎 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 SpecURPGGameplayAbility::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(constFGameplayEffectModCallbackDataData){Super::PostGameplayEffectExecute(Data);// ... 解析 Context、获取 Source/Target Actor ...if(Data.EvaluatedData.AttributeGetDamageAttribute()){// 1. 本地复制伤害值并立刻清零Damage 是临时中转站constfloatLocalDamageDoneGetDamage();SetDamage(0.f);if(LocalDamageDone0){// 2. 扣血并钳制到 [0, MaxHealth]constfloatOldHealthGetHealth();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.AttributeGetHealthAttribute()){// 治疗/直接 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不做额外计算直接调OnDamagedBlueprintImplementableEventvoidARPGCharacterBase::HandleDamage(floatDamageAmount,constFHitResultHitInfo,constFGameplayTagContainerDamageTags,ARPGCharacterBase*InstigatorPawn,AActor*DamageCauser){OnDamaged(DamageAmount,HitInfo,DamageTags,InstigatorPawn,DamageCauser);}为什么要有这个 C 中间层因为 C 里的子类比如 NPC 基类可以重写HandleDamage做额外逻辑记录伤害统计、触发 AI 行为树等而蓝图的OnDamaged保持干净。这是C 提供 Hook蓝图实现内容模式的典型体现。第四步调 HandleHealthChanged注意参数是-LocalDamageDone负数表示血量减少了。治疗分支传的是DeltaValue正数。接收这个回调的蓝图可以统一处理血量变化显示正数刷新治疗特效负数刷新受伤血量跳字。四、HandleDamage → OnDamaged 回调的设计ARPGCharacterBase里有四个BlueprintImplementableEventUFUNCTION(BlueprintImplementableEvent)voidOnDamaged(floatDamageAmount,constFHitResultHitInfo,constFGameplayTagContainerDamageTags,ARPGCharacterBase*InstigatorCharacter,AActor*DamageCauser);UFUNCTION(BlueprintImplementableEvent)voidOnHealthChanged(floatDeltaValue,constFGameplayTagContainerEventTags);UFUNCTION(BlueprintImplementableEvent)voidOnManaChanged(floatDeltaValue,constFGameplayTagContainerEventTags);UFUNCTION(BlueprintImplementableEvent)voidOnMoveSpeedChanged(floatDeltaValue,constFGameplayTagContainerEventTags);它们各自对应PostGameplayEffectExecute里的四个else if分支。C 是事件的发射端蓝图是事件的实现端。OnDamaged和OnHealthChanged在一次伤害里会同时被调OnDamaged带完整上下文攻击者、HitResult、伤害标签——适合实现受击动画、伤害来源判定、死亡逻辑。OnHealthChanged更通用只有变化量和原因标签——适合更新血条 UI、显示飘字伤害数。为什么分开两个因为治疗不触发OnDamaged但会触发OnHealthChanged。血条 UI 绑定OnHealthChanged无论是受伤还是治疗都自动更新而受击反馈只绑OnDamaged。这样各自独立互不干扰。五、Source 的解析与GetEffectCauserPostGameplayEffectExecute里有一段解析攻击者的代码看起来有点啰嗦但每一步都有道理AActor*SourceActornullptr;AController*SourceControllernullptr;ARPGCharacterBase*SourceCharacternullptr;if(SourceSource-AbilityActorInfo.IsValid()Source-AbilityActorInfo-AvatarActor.IsValid()){SourceActorSource-AbilityActorInfo-AvatarActor.Get();SourceControllerSource-AbilityActorInfo-PlayerController.Get();// PlayerController 为空时尝试从 Pawn 拿if(SourceControllernullptrSourceActor!nullptr){if(APawn*PawnCastAPawn(SourceActor))SourceControllerPawn-GetController();}if(SourceController)SourceCharacterCastARPGCharacterBase(SourceController-GetPawn());elseSourceCharacterCastARPGCharacterBase(SourceActor);// 如果 Context 里记录了 EffectCauser覆盖 SourceActorif(Context.GetEffectCauser())SourceActorContext.GetEffectCauser();}最后那行GetEffectCauser()覆盖是关键。这里要分清两个容易混的概念Instigator发起者真正发动这次攻击的人——玩家或角色本体攻击者的 ASC 就挂在它身上。EffectCauser直接致害物真正在物理上碰到目标、造成这次效果的 Actor。近战里往往是挥出去的那把剑远程里是飞出去的那颗火球而不是玩家本体。某些场景下这两者不是同一个东西攻击的 ASC 属于玩家发起者但直接碰到目标的是武器致害物。Context.GetEffectCauser()记录的就是这个直接致害物那把剑。OnDamaged的DamageCauser参数拿到的正是它——蓝图可以据此播放对应的武器命中特效比如剑砍中时的火花而不用去管背后是哪个玩家发动的。六、小结设计亮点把全管线梳理一遍几个值得记忆的设计点位置设计原因RPGDamageStaticsAttackPower 快照DefensePower 不快照攻击力锁发射时防御力用命中时Execute_Implementation输出加到Damage属性而不是直接扣血让 AttributeSet 统一处理后处理逻辑PostGameplayEffectExecute立刻SetDamage(0)清零Damage 是中转站不是持久状态HandleDamageC 中间层转发到OnDamaged允许 C 子类重写同时蓝图干净OnDamagedOnHealthChanged分开受击逻辑与血量 UI 解耦治疗只触发后者不触发前者整条管线理解之后再看URPGDamageExecution里那句注释——“Most games will want to implement multiple game-specific executions”——就能真切体会到它的意思ActionRPG 的Damage × AttackPower / DefensePower只是个示例公式真实游戏里你可以写元素克制、护盾吸收、暴击乘数……统统塞进Execute_Implementation而PostGameplayEffectExecute那一层完全不用动。这就是ExecutionCalculation抽象的价值所在。