
[TOC]上一篇讲了EffectContainerMap如何把效果配置和施加分两步走。但有两个问题留着没答一是目标怎么选——谁来决定这次命中打中了哪个目标二是命中帧怎么触发结算——攻击动画里的哪一帧算打中如何精确地在那一帧完成从动画到 GAS 的跳转这两个问题分别由URPGTargetType策略模式和RPGAbilityTask_PlayMontageAndWaitForEvent自定义 AbilityTask来回答。一、URPGTargetType目标选择的策略模式URPGTargetType是一个极简的策略接口只有一个纯虚方法UCLASS(Blueprintable,meta(ShowWorldContextPin))classACTIONRPG_APIURPGTargetType:publicUObject{GENERATED_BODY()public:URPGTargetType(){}/** Called to determine targets to apply gameplay effects to */UFUNCTION(BlueprintNativeEvent)voidGetTargets(ARPGCharacterBase*TargetingCharacter,AActor*TargetingActor,FGameplayEventData EventData,TArrayFHitResultOutHitResults,TArrayAActor*OutActors)const;};注意类上的注释“This does not subclass GameplayAbilityTargetActor because this class is never instanced into the world”。常规的GameplayAbilityTargetActor是一个真正的 Actor需要 Spawn 进世界场景才能用生命周期复杂。URPGTargetType是UObject从不实例化进世界——通过GetDefaultObject()拿 CDO直接调GetTargets调完就扔。这是一种更轻量的策略分发方式代价是无法感知场景不能做射线检测这类需要 WorldContext 的操作。什么是 CDOClass Default Object类默认对象UE 里每一个UClass在被加载时都会自动创建且仅创建一个默认对象——这就是 CDO。它有两个关键作用保存这个类所有属性的默认值。你在编辑器Class Defaults面板里填的、或标了EditDefaultsOnly的属性比如上一篇的EffectContainerMap存的就是这个类 CDO 里的值。充当模板每次NewObject/SpawnActor创建新实例时引擎先把 CDO 拷一份再在上面改——所以新实例的初始值就来自 CDO。CDO 有几个特点正好解释了这里为什么用它做策略对象全程只有一个、常驻内存类一加载它就在不用 new、也不用销毁GetDefaultObject()每次拿到的都是同一个共享实例。应当被当作无状态、只读来用因为全局共享谁都不该往 CDO 上写运行时数据。URPGTargetType的GetTargets是const、结果全部通过Out参数返回、自身不存任何状态——正因如此直接拿 CDO 调一下就扔才是安全的。一句话CDO 这个类的出厂设置 共享单例模板。对URPGTargetType这种纯逻辑、无状态的策略类直接借 CDO 当函数载体比 Spawn 一个 Actor 轻量得多。ActionRPG 提供了两个内置的 C 子类1.1 URPGTargetType_UseOwner自我目标voidURPGTargetType_UseOwner::GetTargets_Implementation(ARPGCharacterBase*TargetingCharacter,AActor*TargetingActor,FGameplayEventData EventData,TArrayFHitResultOutHitResults,TArrayAActor*OutActors)const{OutActors.Add(TargetingCharacter);}实现只有一行把发动技能的角色自己加到目标列表。使用场景喝药水、使用主动防御技能——效果施加给自身。1.2 URPGTargetType_UseEventData从事件数据取目标voidURPGTargetType_UseEventData::GetTargets_Implementation(ARPGCharacterBase*TargetingCharacter,AActor*TargetingActor,FGameplayEventData EventData,TArrayFHitResultOutHitResults,TArrayAActor*OutActors)const{constFHitResult*FoundHitResultEventData.ContextHandle.GetHitResult();if(FoundHitResult){OutHitResults.Add(*FoundHitResult);}elseif(EventData.Target){OutActors.Add(const_castAActor*(EventData.Target));}}从GameplayEventData里取如果 Context 里有 HitResult近战扫描命中就用 HitResult否则取TargetActor比如锁定技能已知目标。使用场景近战攻击命中目标——动画的AnimNotify时机做了一次碰撞检测把 HitResult 塞进GameplayEventData这个类从里面取出来。两个子类都标NotBlueprintable——它们太简单不需要蓝图扩展更复杂的目标选择逻辑应该继承URPGTargetTypeBlueprintable在蓝图里实现比如范围扇形检测、锥形 AOE 等。1.3 策略模式的优雅之处这个设计把目标逻辑从技能逻辑里彻底剥离一个近战攻击能力配UseEventData一个喝药水能力配UseOwner换目标策略只改FRPGGameplayEffectContainer.TargetType那一行配置能力本身的激活、动画、效果施加逻辑完全不变。二、AnimNotifyState动画驱动碰撞的机制在进入 AbilityTask 之前先理解命中帧是怎么产生的。ActionRPG 用AnimNotifyState标记动画里的攻击判定窗口NotifyBegin时开始做碰撞检测NotifyEnd时停止。一旦检测到命中AnimNotifyState通过AbilitySystemComponent-HandleGameplayEvent把 HitResult 打包成GameplayEventData发送一个带标签的 GameplayEvent如Event.Montage.Attack。这就是GameplayEventData里有 HitResult 的原因也是UseEventData能取到目标的前提。三、RPGAbilityTask_PlayMontageAndWaitForEvent合二为一3.0 为什么需要 AbilityTask一个关键前提GA 的激活逻辑本身是在一帧内完成的。如果你在ActivateAbility里直接写播放 2 秒的攻击动画、等命中事件、再结算伤害那么等待这个概念在单帧逻辑里不存在——你需要一种异步机制。AbilityTask 就是 GAS 的异步方案。它是一种特殊的UGameplayTask挂在 GA 上运行可以跨多帧持续等待某个条件等条件满足后通过 Delegate 回调通知 GA。GA 本身在发起 Task 后就让出控制了等 Task 的回调驱动后续逻辑。这和协程/Promise 的思路类似——只是在 UE 的世界里用了 Delegate Task 的形式。GAS 内置了两个常用 AbilityTaskUAbilityTask_PlayMontageAndWait播放蒙太奇结束/中断时通知。UAbilityTask_WaitGameplayEvent等待某个 GameplayEvent收到时通知。这两个分开用有个问题近战攻击需要同时做这两件事——播放攻击动画并且在动画进行中等待命中事件。ActionRPG 把它们合并成URPGAbilityTask_PlayMontageAndWaitForEvent在一个节点里搞定。3.1 五个输出引脚/** The montage completely finished playing */UPROPERTY(BlueprintAssignable)FRPGPlayMontageAndWaitForEventDelegate OnCompleted;/** The montage started blending out */UPROPERTY(BlueprintAssignable)FRPGPlayMontageAndWaitForEventDelegate OnBlendOut;/** The montage was interrupted */UPROPERTY(BlueprintAssignable)FRPGPlayMontageAndWaitForEventDelegate OnInterrupted;/** The ability task was explicitly cancelled by another ability */UPROPERTY(BlueprintAssignable)FRPGPlayMontageAndWaitForEventDelegate OnCancelled;/** One of the triggering gameplay events happened */UPROPERTY(BlueprintAssignable)FRPGPlayMontageAndWaitForEventDelegate EventReceived;引脚触发条件典型处理OnCompleted蒙太奇正常播完能力正常结束OnBlendOut蒙太奇开始淡出提前解锁输入OnInterrupted蒙太奇被打断受击等取消效果回滚状态OnCancelled能力被另一个能力主动取消清理资源EventReceived注册的 GameplayEvent 标签匹配到结算伤害在 GA_Melee_Base 蓝图里接EventReceived的节点调用ApplyEffectContainerSpec这就是命中帧触发结算的完整闭环。前四个引脚的参数EventTag、EventData在蒙太奇回调里都是空值——它们是为了保持函数签名一致而统一用FRPGPlayMontageAndWaitForEventDelegate。3.2 Activate绑定监听的时机voidURPGAbilityTask_PlayMontageAndWaitForEvent::Activate(){// 1. 先注册 GameplayEvent 监听必须在播放蒙太奇之前EventHandleRPGAbilitySystemComponent-AddGameplayEventTagContainerDelegate(EventTags,FGameplayEventTagMulticastDelegate::FDelegate::CreateUObject(this,URPGAbilityTask_PlayMontageAndWaitForEvent::OnGameplayEvent));// 2. 播放蒙太奇RPGAbilitySystemComponent-PlayMontage(Ability,...,MontageToPlay,Rate,StartSection);// 3. 绑定蒙太奇事件BlendingOut、End、CancelledCancelledHandleAbility-OnGameplayAbilityCancelled.AddUObject(this,...);AnimInstance-Montage_SetBlendingOutDelegate(...);AnimInstance-Montage_SetEndDelegate(...);}关键顺序先绑 GameplayEvent 监听再播蒙太奇。如果反过来蒙太奇很快或帧率极低可能在第一帧就触发 AnimNotify此时监听还没注册命中事件会被漏掉。EventTags是构造时传入的标签集合只有匹配到其中任意一个 Tag 的 GameplayEvent 才会触发EventReceived。这意味着一个 Task 可以同时等待多种命中类型重击和轻击都监听根据收到的EventTag区分处理。3.3 OnGameplayEvent事件转委托voidURPGAbilityTask_PlayMontageAndWaitForEvent::OnGameplayEvent(FGameplayTag EventTag,constFGameplayEventData*Payload){if(ShouldBroadcastAbilityTaskDelegates()){FGameplayEventData TempData*Payload;TempData.EventTagEventTag;EventReceived.Broadcast(EventTag,TempData);}}ShouldBroadcastAbilityTaskDelegates()是 AbilityTask 基类的保护方法在能力已经结束/取消时返回 false防止僵尸回调。之后广播EventReceived把EventTag和Payload含 HitResult一起送出去——蓝图里接这个引脚拿到 HitResult填进 ContainerSpec完成闭环。3.4 OnDestroy清理所有监听voidURPGAbilityTask_PlayMontageAndWaitForEvent::OnDestroy(boolAbilityEnded){// 停止蒙太奇if(AbilitybStopWhenAbilityEnds)StopPlayingMontage();// 移除 GameplayEvent 监听if(RPGAbilitySystemComponent)RPGAbilitySystemComponent-RemoveGameplayEventTagContainerDelegate(EventTags,EventHandle);Super::OnDestroy(AbilityEnded);// 必须最后调会 PendingKill}注释强调Super::OnDestroy必须放最后调——它会把对象标记为PendingKill之后访问任何成员都可能 crash。四、完整的动画事件驱动结算流把上面所有环节串起来一次近战攻击的事件流是这样的1. 玩家输入攻击 │2. 蓝图调用 ActivateAbilitiesWithItemSlot │ → ARPGCharacterBase::ActivateAbilitiesWithItemSlot │ → ASC::TryActivateAbility │3. GA_MeleeBase 能力激活EventActivateAbility │ → 调用 PlayMontageAndWaitForEvent Task开始等待 │4. 动画播放AnimNotifyState.NotifyBegin 开始碰撞检测 │5. 检测到命中 → NotifyState 构造 GameplayEventData含 HitResult │ → ASC.HandleGameplayEvent(Event.Montage.Attack, EventData)│6. AbilityTask.OnGameplayEvent 被触发 │ → EventReceived.Broadcast(EventTag, EventData)│7. 蓝图 EventReceived 节点 │ → MakeEffectContainerSpec用 EventData 里的 HitResult │ → ApplyEffectContainerSpec │8. PostGameplayEffectExecute 执行伤害计算见伤害管线篇 │9. OnDamaged / OnHealthChanged 触发蓝图播放受击特效五、小结两个设计哲学URPGTargetType 体现的是策略模式把变化的部分目标选取逻辑从不变的部分能力激活流程里分离通过配置切换而不是继承重写。新增目标策略范围 AOE、锁定最近目标等只需继承URPGTargetType写一个新的GetTargets无需动任何能力代码。AbilityTask 体现的是状态机 事件驱动一个 Task 代表一段等待状态它知道何时进入Activate、何时退出OnDestroy、中间发生了什么各 Delegate 广播。把播动画和等命中事件合并成一个 Task消除了两个独立 Task 之间同步状态的问题。两者结合让 ActionRPG 的攻击逻辑在蓝图里只需要几个节点就能完整表达EventActivateAbility → PlayMontageAndWaitForEvent → EventReceived → ApplyEffectContainerSpec——每个节点的背后都是本文梳理的那套 C 机制。附录GA 标签系统——能力之间的互斥与协作ActionRPG 的代码里没有显式展示 GA Tag 的配置它在蓝图的 Details 面板里设置但理解这套标签系统对于读懂 AbilityTask 的 Cancel/Interrupt 逻辑至关重要。每个 GA 蓝图的 Details 面板里有一组 Tag 配置项控制能力之间的相互关系标签类型含义典型用法Ability Tags该 GA 自己的标签身份Ability.Melee——标识这是近战能力Cancel Abilities with Tag激活本 GA 时打断拥有指定标签的其他 GA施法打断普攻施法 CancelAbility.MeleeBlock Abilities with Tag激活本 GA 时阻止激活拥有指定标签的 GA已激活的不受影响受击时阻止翻滚受击 BlockAbility.RollActivation Owned Tags本 GA 激活期间给 ASC 赋予指定标签攻击期间给角色加State.Attacking标签Activation Required Tags激活本 GA 时ASC 必须拥有这些标签连击第二段要求State.Combo1CompleteActivation Blocked Tags激活本 GA 时ASC 不能有这些标签眩晕时禁止所有攻击Blocked byState.Stunned这套标签系统让技能之间的互斥与协作完全通过配置而非代码解决——不需要写if (IsAttacking) return;这类硬编码判断。当 AbilityTask 的OnCancelled/OnInterrupted引脚被触发时往往就是因为另一个 GA 的Cancel Abilities with Tag规则命中了当前 GA 的标签。理解了这一层那些蓝图里为什么攻击动画突然被打断的 bug 就有了排查方向。