[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,TArray<FHitResult>&OutHitResults,TArray<AActor*>&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 的操作)。
什么是 CDO(Class 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,TArray<FHitResult>&OutHitResults,TArray<AActor*>&OutActors)const{OutActors.Add(TargetingCharacter);}实现只有一行:把发动技能的角色自己加到目标列表。使用场景:喝药水、使用主动防御技能——效果施加给自身。
1.2 URPGTargetType_UseEventData:从事件数据取目标
voidURPGTargetType_UseEventData::GetTargets_Implementation(ARPGCharacterBase*TargetingCharacter,AActor*TargetingActor,FGameplayEventData EventData,TArray<FHitResult>&OutHitResults,TArray<AActor*>&OutActors)const{constFHitResult*FoundHitResult=EventData.ContextHandle.GetHitResult();if(FoundHitResult){OutHitResults.Add(*FoundHitResult);}elseif(EventData.Target){OutActors.Add(const_cast<AActor*>(EventData.Target));}}从GameplayEventData里取:如果 Context 里有 HitResult(近战扫描命中),就用 HitResult;否则取TargetActor(比如锁定技能已知目标)。使用场景:近战攻击命中目标——动画的AnimNotify时机做了一次碰撞检测,把 HitResult 塞进GameplayEventData,这个类从里面取出来。
两个子类都标NotBlueprintable——它们太简单,不需要蓝图扩展;更复杂的目标选择逻辑应该继承URPGTargetType(Blueprintable)在蓝图里实现(比如范围扇形检测、锥形 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 内置了两个常用 AbilityTask:
UAbilityTask_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 监听(必须在播放蒙太奇之前!)EventHandle=RPGAbilitySystemComponent->AddGameplayEventTagContainerDelegate(EventTags,FGameplayEventTagMulticastDelegate::FDelegate::CreateUObject(this,&URPGAbilityTask_PlayMontageAndWaitForEvent::OnGameplayEvent));// 2. 播放蒙太奇RPGAbilitySystemComponent->PlayMontage(Ability,...,MontageToPlay,Rate,StartSection);// 3. 绑定蒙太奇事件(BlendingOut、End、Cancelled)CancelledHandle=Ability->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.EventTag=EventTag;EventReceived.Broadcast(EventTag,TempData);}}ShouldBroadcastAbilityTaskDelegates()是 AbilityTask 基类的保护方法,在能力已经结束/取消时返回 false,防止僵尸回调。之后广播EventReceived,把EventTag和Payload(含 HitResult)一起送出去——蓝图里接这个引脚拿到 HitResult,填进 ContainerSpec,完成闭环。
3.4 OnDestroy:清理所有监听
voidURPGAbilityTask_PlayMontageAndWaitForEvent::OnDestroy(boolAbilityEnded){// 停止蒙太奇if(Ability&&bStopWhenAbilityEnds)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.Melee |
| Block Abilities with Tag | 激活本 GA 时,阻止激活拥有指定标签的 GA(已激活的不受影响) | 受击时阻止翻滚:受击 BlockAbility.Roll |
| Activation Owned Tags | 本 GA 激活期间,给 ASC 赋予指定标签 | 攻击期间给角色加State.Attacking标签 |
| Activation Required Tags | 激活本 GA 时,ASC 必须拥有这些标签 | 连击第二段要求State.Combo1Complete |
| Activation Blocked Tags | 激活本 GA 时,ASC 不能有这些标签 | 眩晕时禁止所有攻击:Blocked byState.Stunned |
这套标签系统让技能之间的互斥与协作完全通过配置而非代码解决——不需要写if (IsAttacking) return;这类硬编码判断。当 AbilityTask 的OnCancelled/OnInterrupted引脚被触发时,往往就是因为另一个 GA 的Cancel Abilities with Tag规则命中了当前 GA 的标签。理解了这一层,那些蓝图里"为什么攻击动画突然被打断"的 bug 就有了排查方向。