UE5.6 GAS学习笔记(2)-->GA篇 [2.分析GA类基本内容]
本文继续GAS框架中的GameplayAbility(GA)拆解。
在上一篇中已经实现了如何将一个输入映射关联到一个具体的GA触发。现在我们来考虑如何创建一个GA类,目前有两种通用的方式,一是在IDE(我用的是JetBrains Rider 2025.3.3)中配置好UE环境后,可以直接创建大部分Unreal类,其中就包括GA。二是在编辑器中创建。
一、IDE
选择Unreal类
所有类中找到GA类并创建
当然,一般我们会先基于UGameplayAbility类继承一个项目内的通用GA类作为大部分GA类的基类。
编辑器中在BlueprintClass项找到此GA类并创建蓝图实例即可。
二、编辑器中
创建蓝图GA类
或者在C++ Classes 文件夹中右键找到New C++ Class,从中找到GA项,Create Class即可,这种方法和IDE中创建C++类一样,需要额外编译一次。
现在来看看一个创建好的GA蓝图类有哪些信息
继承自UGameplayAbility的一个基本蓝图类
Tags
这是判断GA能否激活的关键项,通过各种Tag实现了大量GA之间制约和依赖关系。
AssetTag:默认的GA携带的Tag,在这里添加的Tag在会ASC中代表此GA做Tag互斥,通常以Ability作为前缀,然后根据类型不同进行分层。
Cancel Abilities with Tag :配置Tag后,此GA激活时,所有携带这些Tag的GA都会被调用CancelAbility()(因此,为了防止某些不能被取消的GA被我们意外添加到其他GA的Cancel中,我们可以为这个GA重写CanBeCanceled函数,手动return false,就可以不对Cancel操作进行响应)
Block Abilities with Tag:激活该GA时,其他GA的AssetTags中只要带有这些BlockTag都会被阻塞无法激活。
(Tips:和上面的Cancel的区别在于,Cancel处理的是一个正在激活的GA,而Block处理的是没有激活但可能在这期间被激活的GA)
Activation Owned Tags:激活该GA时,将这些Tag临时添加到SourceASC中,在GA结束时会自动移除。
Activation Required Tags:激活GA时,SourceASC上必须有的标签,否则不能激活GA。
Activation Blocked Tags:激活GA时,SourceASC上不能有的标签,否则不能激活GA。
Source Required Tags:激活GA时,SourceTags参数中必须有的Tag。
Source Blocked Tags:激活GA时,SourceTags参数中不能有的Tag。
Target Required Tags:激活GA时,TargetTags参数中需要的Tag。
Target Blocked Tags:激活GA时,TargetTags 参数中不能有的Tag。
这些Tags在GA类中都是一个可以直接配置的FGameplayTagContainer。
SourceTags/TargetTags来自哪里?
bool UGameplayAbility::CanActivateAbility (const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayTagContainer* SourceTags, const FGameplayTagContainer* TargetTags, OUT FGameplayTagContainer* OptionalRelevantTags) const这个函数中有参数SourceTags和TargetTags作为形参,用来表示当前技能的临时上下文信息。临时上下文信息指的是:这两个Tags实际上不会注册到ASC中,只是携带一些必要代表信息的Tag,例如,在某次攻击类GA时在SourceTag中添加一个表示火属性的Tag,这意味着这次GA是火属性的,但是SourceASC本身并没有这个火属性Tag。
Source和Target的默认参数都是nullptr,不用的时候直接忽略这两项形参就行。
这些Tags是如何实现对GA的限定的?
不同触发链最终都会从ASC的ActivatableAbilities 中找到目标GA的FGameplayAbilitySpecHandle , 然后调 用TryActivateAbility(FGameplayAbilitySpecHandle Handle),这个函数在真正触发ActiavetAbility( ) 前进行一系列检查,会判断一下网络权限,然后调用InternalTryActivateAbility,在这里检查GA是否已经注册/激活,是否允许再次激活,并且处理Instancing Policy,处理网络预测等,再然后走到CanActivateAbility,在这里,进行严格的校验,判断所有必要变量是否存在,Cooldown,Cost是否允许激活,很关键的是还调用了UGameplayAbility::DoesAbilitySatisfyTagRequirements,就是这个函数对Tag之间限制关系进行判断(函数源码在GameplayAbility.cpp,并不复杂且可读性很高,推荐大家去看看)。它将AbilitySystemComponent.GetOwnedGameplayTags( )与Tags各个项分别进行比较(GA类中各个项都是成员变量,可以直接读),最后返回一个bool结果,确定GA是否通过Tag检查。
/** 源码中描述:Returns true if none of the ability's tags are blocked and if it doesn't have a "Blocking" tag and has all "Required" tags.
Replication Policy
有ReplicateNo和 ReplicateYes 两种选择,分别代表了【在客户端和服务端各创建一个实例】和【在服务端创建实例并通过网络子对象复制机制同步到客户端】两种情况。
前面说过,GA是在服务端于ASC进行注册的,其中大部分逻辑也是在服务端实现,客户端只是拥有一个视口并进行表现。而且Attribute、GE、Cue、Montage等GA逻辑内常用的需要同步的也已经在ASC内部做好了极好的网络同步封装,因此,大部分情况下的GA都是ReplicateNo类型的。
设置GA为Replicate的情况一般是在GA中使用了RPC在两端传输信息,或者对标记了Replicated的成员变量进行网络同步。RPC只能在同一个网络实例间建立,因为其本质上是属于函数调用,只是在网络对象的不同端实现。例如Lyra中的DashGA就是ReplicatePolicy,因为其使用了一个RPC将客户端的Direction和Montage发送给服务端进行调用。
事实上,UE并不推荐使用Replicate,因为一个UObject实例的动态复制对网络带宽和性能消耗都很大,而且变量的复制,修改,RPC的传递,往往都能通过GE,Cue,TargetData等实现。
Instancing Policy
有三种实例化策略:
NonInstanced:不实例化,每次激活都是取其CDO的数据直接使用,目前似乎已经很少使用,由于我没有使用经验,故本篇暂且不谈
InstancedPerActor:在注册到ASC时实例化一次,每次激活都调用的都是复用这个实例
InstancedPerExecution每次激活都实例化一次 (每个GameplayAbilitySpec可能创建多个GA实例)
如何选择这些Policy?
大部分GA都能以PerActor Policy实现,这是理想的Policy,因为它只在每次创建的时候在Actor生成一个GA实例,此后每次调用此GA都是复用此实例,而无需每次激活GA都重新实例化一次,这样避免大量新对象的生成(比如普攻GA,尤其是多个小兵情景下,每秒可能会有上百次普攻GA的激活,此时显然不能使用Per Execution,这会带来很大负担)。
也因为复用实例,上一次调用GA操作对GA实例的影响(如修改了某个成员变量【状态】)会在下一次调用的时候保存。相比之下,PerExecution Policy没有这个性质,因为它每次激活都创建一个新的GA实例,不记录上一次的信息。
这有利有弊,对于某些希望每次激活都是独立的,之前的激活不对当前产生影响的GA(如普通的技能,希望每次都调用一模一样的技能产生一样的效果),Per Execution就能符合这一需求。但是注意,这也同时建立在实例化的开销在可接受范围的情况下,如果是一个频繁调用的GA,还是建议以Per Actor进行实例化,如果改变了某些变量又不想影响下一次激活GA,可以在End Ability的时候重置修改了的变量。
对于某些希望记录多次GA激活时信息(比如某个GA根据GA释放次数增加伤害,需要记录激活次数),使用Per Actor就十分合适了。
除此之外,二者在激活操作上也有区别,由于每次激活都创建一个新的实例,Per Execution可以在单个角色上多次激活,而且互不影响。而Per Actor不允许这样做,必须等GA结束了才能继续激活。
如果你希望能够在Per ActorPolicy实现这样的功能:在GA激活期间再次激活时,能够立刻取消GA并重新激活,可以勾选Retrigger Instanced Ability 【eg:跳跃期间再次跳跃】。
Net Execution Policy
网络执行策略决定了当GA被触发时,客户端和服务器之间的权限和同步流程。
这是一个枚举类,有四种Policy,可以看到注释上说:这个Policy决定了GA在网络上如何执行,客户端是“询问并预测执行”还是“询问并等待执行”,还是“只等待执行”。
Local Predicted
最常用的Policy,在本地输入触发GA,调用TryActivateAbility后内部会调用CanActivateAbility判断当前是否满足激活条件,如果校验通过,则开启一个预测窗口生成FPredictionKey,然后调用ServerTryActivateAbility,将包含这个Key的激活请求发送给服务端,此后客户端本地直接激活此GA,无论服务端是否同意此次激活,或者因不合法被服务端拒绝。
这样做的目的是为了降低物理延迟,如果是服务端先执行,判断GA合法后再把Montage,属性集改变,GE应用等同步到客户端,由于网络同步的必然延迟,会导致客户端的手感受到“滞后”影响。
当然,服务端的权威性是GAS的前提,即使客户端先激活了GA,也是以服务器上GA的激活流程为准,一旦服务器GA激活失败,ASC会通过RPC通知客户端这次GA是失败的,作废客户端发送的预测Key,触发GA的回滚(这个回滚与Key绑定,会撤销预测阶段应用的GE,赋予的Tag,停止Montage并停止GA等)。
可以看到,这个Policy具有延迟低的优点,这对玩家的操作体验十分重要,所以它也是GA类的默认配置。
Local Only
完全的单机GA,无法修改任何属性,也无法对其他玩家产生任何实际影响。
一般用于纯客户端表现,如本地动作(当着其他客户端的面播放其他客户端也不会看到的表演性Montage),打开各种菜单(如果你想以GA实现,而非直接使用Widget的各种Button回调的话....),以及各种纯本地的操作。优点就在于绝对的0延迟和网络开销。
Server Only
GA只能被服务端调用,这意味着即使你为GA绑定了输入,并进行触发,GA也完全不进行响应,而是只响应来自服务端的TryActivateAbility。
这个Policy常用与AI相关GA,被AIController下的ASC进行GA调用,因为AIC只在服务端上存在,客户端不存在输入触发这一说,直接在服务端决定GA激活并同步属性到各个客户端的Actor上即可。
除此之外,各种被动GA也能够通过这个Policy实现,这是因为相比本地预测,只在服务端激活GA省去了客户端的开销,对于不是客户端自己触发的GA,这时没有了感官最明显的触发滞后感,节省开销带来的优化就变得可观了。
当然,这个技能应该比较简单,只处理数值或者简单的动画,例如一个持续恢复生命值的被动GA,逻辑就是简单的Apply一个HealthRegenGE,这时就非常合适了,客户端只看到自己的血条在回升,并没有手感一说。
它不应该是一个带有复杂表现的GA,否则还是会因为网络延迟导致不合预期的视觉表现。
Server Initiated
由服务端发起此GA,客户端同步属性变化,Montage播放和各种Cue特效等等,一般用来实现由其他客户端/环境触发的,同时客户端也必须同步的被动GA,这里的必须,指的是此GA有一部分逻辑专门在客户端调用。
例如,控制类GA,其他客户端通过服务端,调用SendGameplayEventToActor激活一个GemplayEvent触发的GA,这个GA带有控制效果,会在客户端本地唤起一个眩晕图标,同时闪屏,同时产生一些只有此客户端看到的特效之类的,这些都在 if(!HasAuthority)中运行。
Cost Gameplay Effect Class
顾名思义,这里提供了一个TSubClasss<UGameplayEffect> ,可以指定一个BP_GE类,用于为GA实现Cost(消耗)机制,例如消耗蓝量、耐力、甚至生命值等等。
前面提到在TryActivateAbility时有一个CanActivateAbility检查函数,会检查Cost和Cooldown,其中检查Cost就是一个CheckCost函数,它获取配置好CostGE的CDO对象,然后调用AbilitySystemComponent->CanApplyAttributeModifiers(),会计算GE配置中Modifiers值应用后的结果,如果低于0或自定义的阈值,就会失败,阻止此GA的激活。
注意,此时只是计算预测一下,不会真正应用,因为这个判断之后GA还会因为别的因素失败,不一定就激活。
真正激活这个Cost的地方在CommitAbility函数,这个函数需要手动调用,如果不调用的话Cost和Cooldown都不会生效。在Local Predicted Policy下的GA,这个CommitAbility可以直接在ActivateAbility调用(当然,如果你期望在特别的时候才Cost和Cooldown,也可以在那个位置调用),然后GE被实例化并调用,在客户端和服务端都进行CostGE相关属性值的扣除,此时这个GE属于预测性GE,也就是客户端上的属性值虽然立刻被修改,但其实决定这个值真正结果的还是权威端的属性值,一旦服务器GA激活失败,则Key作废,修改的属性值会立刻回滚,GA激活成功,则因为属性集的Replicated标记,客户端上真正拥有【同步】这个值。
一般来说,这个CostGE的DurationPolicy应该是Instant类型的,这是规范的定义,因为Cost在概念上就是瞬间完成的扣除,虽然也可以用其他的Policy,但可能导致无法产生准确的即时数值判定,如果你的需求是持续性的Cost,应该特别定义一个GE,由GA进行应用。
Cooldown Gameplay Effect Class
和Cost很像,可以指定一个GE类,使用Duration Policy作为GA的冷却时间,在这个期间,激活GA在检查函数期间就会判定失败。
Cooldown GE必须是Has Duration, Duration的值就是冷却时间的大小。除此之外,还有一个必不可少的部分,你需要在Components中找到GrantTagToTargetActor,其中可以配置一个CooldownTag。
GE类中
必须有Cooldown Tag的原因是GAS判定GA是否冷却的机制就是在OwnedTags中查询有没有CooldownTag,因为Grant Tags在GE应用时添加到ASC,在GE销毁时从角色身上移除,相当于一个一个方便又简单的跟随符号。
具体来说,在检查函数中调用AbilitySystemComponent->HasAnyMatchingGameplayTags(CooldownTags),如果存在任意一个Cooldown Tag,则激活直接失败。
在Commit函数中,实例化GE并应用到角色身上,和CostGE相同。
以基于Tag判断冷却的设计有什么好处呢?
Grant Tags可以不止一个,这给我们极大的操作空间,可以一个全局Cooldown Tag加到多个GA中,某一个GA一旦被激活,其他拥有这个Tag的GA也无法激活,实现了全局冷却逻辑。其次,多个Tag还能拓展很多操作,相比传统的一个CD值,然后每帧减少冷却时间这种做法而言,基于Tag判定冷却的设计模式强大许多。
Triggers
AbilityTriggers
有一个很好的说法,如果手动触发GA是‘手动挡’,那么Trigger就是‘自动挡’,通过定义的Source途径添加Tag,激活这个GA。
Trigger Tag:显而易见,填入一个Tag,作为Source的触发
Trigger Source:
1)GameplayEvent
最常用的Source,一般以ASC->SendGameplayEventToActor来触发,这个函数其中一个形参就是EventTag,即TriggerTag。上文有说,一个被动GA就能如此激活,当目标Actor的被动要被触发时,调用这个函数发特定的Tag激活即可。
我们知道ActivateAbility有一个参数const FGameplayEventData* TriggerEventData,它就是专门响应Trigger Gameplay Event的,那么这个Data来自哪里呢,答案是来自SendGameplayEventToActor,这个函数的参数除了EventTag,还有一个FGameplayEventData EventData参数,这个Data是我们手动创建的,可操作性很强,非常非常好用,它提供了很多信息,如HitResult(最常用)。
这里举个易懂的例子,在此之前先给大家看看EventData数据结构封装的内容
USTRUCT(BlueprintType) struct GAMEPLAYABILITIES_API FGameplayEventData { GENERATED_BODY() // 1. 触发这次事件的 Tag(例如:Event.HitReact) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = GameplayEventData) FGameplayTag EventTag; // 2. 谁发起的这次事件?(比如:挥刀攻击你的那个敌人) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = GameplayEventData) const AActor* Instigator; // 3. 谁承受了这次事件?(比如:被命中的你自己) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = GameplayEventData) const AActor* Target; // 4. 万能对象指针:可以塞入任何自定义的 UObject(比如当前武器的配置、道具数据) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = GameplayEventData) const UObject* OptionalObject; // 5. 第二个万能对象指针:UE5 新增,进一步扩展自定义空间 UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = GameplayEventData) const UObject* OptionalObject2; // 6. 这次事件的量化表现(比如:暴击造成的伤害数值 150.f) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = GameplayEventData) float EventMagnitude; // 7. 包含整个伤害流程的原始上下文(包含各种 TargetData、位置信息) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = GameplayEventData) FGameplayEffectContextHandle ContextHandle; // 8. 精确的命中位置、方向或碰撞到的 Component 数组 UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = GameplayEventData) FGameplayAbilityTargetDataHandle TargetData; };我想实现一个击飞效果,思路是将这个击飞做成被动GA,用Event发送给目标对象进行触发,除此之外,还要知道两个参数,1:击飞对象 2:击飞方向向量。这两个参数就能利用EventData中的HitResult完美获取。
实现过程:用EventData作为载体,TargetData可以存储多个HitResult和ImpactNormal。
void UCGameplayAbility::PushTarget(AActor* Target, const FVector& PushVel) { if (!Target) return; FGameplayEventData EventData; FGameplayAbilityTargetData_SingleTargetHit* HitData=new FGameplayAbilityTargetData_SingleTargetHit; FHitResult HitResult; HitResult.ImpactNormal=PushVel; HitData->HitResult=HitResult; EventData.TargetData.Add(HitData); //PassiveGA中设置了以GameplayEvent+EventTag,这里直接触发 UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(Target,UGAP_Launch::GetLaunchedAbilityActivationTag(),EventData); } //这里定义TriggerTag和Source UGAP_Launch::UGAP_Launch() { NetExecutionPolicy=EGameplayAbilityNetExecutionPolicy::ServerOnly; //收到一个GameplayEvent,且事件的EventTag==TriggerTag时这个GA才会被触发。 FAbilityTriggerData TriggerData; TriggerData.TriggerSource=EGameplayAbilityTriggerSource::GameplayEvent; TriggerData.TriggerTag=GetLaunchedAbilityActivationTag(); //添加到Triggers中 AbilityTriggers.Add(TriggerData); }这个Data相当于一个快递,存储了发送Event方的很多信息,GA收到这个快递并拆解,利用其中的信息实现逻辑。
当然,如果你的技能只是简单的被动技能,需要的信息不多,你也可以完全不要这个Data,直接传一个FGameplayEventData( )作为参数即可。
2)Owned Tag Added
它监听着ASC的OwnedTags,一旦多出了指定的Tag,GA就开始尝试激活,调用TryActivateAbility, 它具有严格的边界,在Tag数从0->1的一瞬间触发,如果已经有了,只是从1->2,是不会激活这个GA的。
特别注意:Tag消失的时候并不会自动结束GA,如果你想实现这个逻辑,应该选择监听这个Tag并回调EndAbility。
3)Owned Tag Present:没有使用经验,暂不赘述
本文总结了一下GA蓝图类中各个常用选项,这些配置基本都能在代码中直接做好,也可以先实现代码整体逻辑,然后在蓝图中可视化配置,了解这些选项的含义和使用方式是非常重要的。
感谢你看到这里 ,下次再见!
