游戏效果(GameplayEffect)
GE的主要用途是通过改变目标或自身的Attribute或者Tags实现诸如造成伤害、治疗、强化、削弱等效果,GE也提供了Execution来执行逻辑,提供了相当大的灵活性。
(我个人觉得GE是整个GAS框架中基本逻辑最为复杂的部分,真的有好多好多功能=。=)
GE的数据结构
GE是一个纯数据蓝图,不能添加任何逻辑,它在执行时,会根据数据创建一个GameplayEffectSpec的实例用来产生效果。
GE通常不需要拓展,策划只需要创建UGameplayEffet的子类即可。
持续类型
GE有三种持续类型:瞬时(Instant)、持续(Duration)、永久(Infinite)
- Instant:适用于产生一次性效果,如伤害、治疗等,InstantGE的Modifiers会立即永久改变Attribute的BaseValue。InstantGE无法向角色添加Tag。
- Duration:持续一段时间的GE,持续时间在GE上配置。可以修改Atrribute的CurrentValue。可以向角色添加Tag,并在GE过期或被移除时自动移除。
- Infinite:与Duration类似,但不会过期,必须手动移除。
GE有两种方式去修改属性,分别是修改器(Modifiers)和操作器(Executions)。
Modifiers(修改器)
一个GE可以配置多个修改器,每个修改器只能修改一个属性(Attribute)
修改器提供以下4种修改属性的方式(ModifierOp):
- Add:加。
- Multiply:乘。
- Divide:初。
- Override:覆盖。
多个对同一属性的修改,会通过聚合器叠加到属性的CurrentValue上,标准的聚合器是FAggregatorModChannel:EvaluateWithBase,其聚合公式如下:
(BaseValue+Add)*Mutiply/Divide
Override修改会直接覆盖最终值,如果有多个Override修改器,只有一个会生效。
标签过滤:修改器可以进行标签过滤,根据Source和Target身上的标签情况,决定修改器是否生效。可以做一些类似“目标中毒时,降低50%防御力”的需求。
Modifier Magnitude(修改值)
修改值方面,GE提供了4种方式:
- ScalableFloat:写死一个浮点值。最简单的方式,不用多说。
- AttributeBase:基于属性值算出一个值。(看到这我惊了,功能真特么强大)
- 取一个属性Attribute
- 可选属性来自Source还是Target。
- 可选是取BaseValue,还是CurrentValue,还是CurrentValue-BaseValue的变化值。
- 可选是否快照,快照会抓取GE添加时刻的属性值,不快照的话则会跟着变。
- 用这个属性,按照(Value+PreMultiplyAdditiveValue)*Coeffcient+PostMultiplyAdditiveValue得出最终值,这三个值是可配的。
- 这里的参数和属性,可以配置一个曲线表格,但我还没研究明白怎么玩。
- CustomCalculationClass:适用于更加复杂灵活的修改,你需要创建一个ModifierMagnitudeCalculation(MMC)类,在其中计算出一个Float,然后通过Pre/Post/Coeffcient进一步修改。这个MMC类可以做很多奇怪的事情,或者说很多依赖Buff的奇怪的东西都适合写在这。
- SetByCaller:这种方式,是在GE的Spec创建之后,再由Ability传入一个值,例如技能的蓄力时间越长,伤害越高。使用起来比较麻烦,在这里不做介绍。
Modifier Magnitude Calculation(MMC)
MMC很牛逼,单拿出来讨论一下。MMC也是ModifierMagnitude的一种,所以他需要返回一个float值,MMC通过CalculateBaseMagnitude_Implementation返回这个值,你的子类C++类或者蓝图应该重写这个方法。
这里贴一段示例项目中的代码,它实现了一个类似法力燃烧的效果。
UPAMMC_PoisonMana::UPAMMC_PoisonMana() { //ManaDef defined in header FGameplayEffectAttributeCaptureDefinition ManaDef; ManaDef.AttributeToCapture = UPAAttributeSetBase::GetManaAttribute(); ManaDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Target; ManaDef.bSnapshot = false ; //MaxManaDef defined in header FGameplayEffectAttributeCaptureDefinition MaxManaDef; MaxManaDef.AttributeToCapture = UPAAttributeSetBase::GetMaxManaAttribute(); MaxManaDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Target; MaxManaDef.bSnapshot = false ; RelevantAttributesToCapture.Add(ManaDef); RelevantAttributesToCapture.Add(MaxManaDef); } float UPAMMC_PoisonMana::CalculateBaseMagnitude_Implementation( const FGameplayEffectSpec & Spec) const { // Gather the tags from the source and target as that can affect which buffs should be used const FGameplayTagContainer* SourceTags = Spec.CapturedSourceTags.GetAggregatedTags(); const FGameplayTagContainer* TargetTags = Spec.CapturedTargetTags.GetAggregatedTags(); FAggregatorEvaluateParameters EvaluationParameters; EvaluationParameters.SourceTags = SourceTags; EvaluationParameters.TargetTags = TargetTags; float Mana = 0.f; GetCapturedAttributeMagnitude(ManaDef, Spec, EvaluationParameters, Mana); Mana = FMath::Max< float >(Mana, 0.0f); float MaxMana = 0.f; GetCapturedAttributeMagnitude(MaxManaDef, Spec, EvaluationParameters, MaxMana); MaxMana = FMath::Max< float >(MaxMana, 1.0f); // Avoid divide by zero float Reduction = -20.0f; if (Mana / MaxMana > 0.5f) { //半蓝以上削蓝翻倍 Reduction *= 2; } if (TargetTags->HasTagExact(FGameplayTag::RequestGameplayTag(FName( "Status.WeakToPoisonMana" )))) { //如果目标有削蓝强化,削蓝翻倍 Reduction *= 2; } return Reduction; } |
ExecutionClac(操作器)
Execution仅能用于InstantGE或者Periodic,如果我想要在GE添加或间隔触发时做一些事情,比如一个Dot造成伤害时,如果目标的生命值少于30%,那么就立刻杀死他。这个逻辑就适合放在EC中去做。
你需要创建一个UGameplayEffectExecutionCalculation的子类,并且重写Execute_Implementation方法。
示例项目做了一个EC来处理伤害受到护甲削减,以及爆头的伤害增加以及标签,同样贴上源码:
void UGSDamageExecutionCalc::Execute_Implementation( const FGameplayEffectCustomExecutionParameters& ExecutionParams, OUT FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const { UAbilitySystemComponent* TargetAbilitySystemComponent = ExecutionParams.GetTargetAbilitySystemComponent(); UAbilitySystemComponent* SourceAbilitySystemComponent = ExecutionParams.GetSourceAbilitySystemComponent(); AActor* SourceActor = SourceAbilitySystemComponent ? SourceAbilitySystemComponent->AvatarActor : nullptr; AActor* TargetActor = TargetAbilitySystemComponent ? TargetAbilitySystemComponent->AvatarActor : nullptr; const FGameplayEffectSpec& Spec = ExecutionParams.GetOwningSpec(); FGameplayTagContainer AssetTags; Spec.GetAllAssetTags(AssetTags); // Gather the tags from the source and target as that can affect which buffs should be used const FGameplayTagContainer* SourceTags = Spec.CapturedSourceTags.GetAggregatedTags(); const FGameplayTagContainer* TargetTags = Spec.CapturedTargetTags.GetAggregatedTags(); FAggregatorEvaluateParameters EvaluationParameters; EvaluationParameters.SourceTags = SourceTags; EvaluationParameters.TargetTags = TargetTags; float Armor = 0.0f; ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().ArmorDef, EvaluationParameters, Armor); Armor = FMath::Max< float >(Armor, 0.0f); float Damage = 0.0f; // Capture optional damage value set on the damage GE as a CalculationModifier under the ExecutionCalculation ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().DamageDef, EvaluationParameters, Damage); // Add SetByCaller damage if it exists Damage += FMath::Max< float >(Spec.GetSetByCallerMagnitude(FGameplayTag::RequestGameplayTag(FName( "Data.Damage" )), false , -1.0f), 0.0f); float UnmitigatedDamage = Damage; // Can multiply any damage boosters here // Check for headshot. There's only one character mesh here, but you could have a function on your Character class to return the head bone name const FHitResult* Hit = Spec.GetContext().GetHitResult(); //这里通过GE上下文拿到了命中数据,GE上下文里存了很多东西,下面会说。 if (AssetTags.HasTagExact(FGameplayTag::RequestGameplayTag(FName( "Effect.Damage.CanHeadShot" ))) && Hit && Hit->BoneName == "b_head" ) { UnmitigatedDamage *= HeadShotMultiplier; FGameplayEffectSpec* MutableSpec = ExecutionParams.GetOwningSpecForPreExecuteMod(); MutableSpec->DynamicAssetTags.AddTag(FGameplayTag::RequestGameplayTag(FName( "Effect.Damage.HeadShot" ))); //他给这个GE动态加了一个爆头标签,这样待会处理伤害数字的时候,他就知道要显示一个暴击数字了 } float MitigatedDamage = (UnmitigatedDamage) * (100 / (100 + Armor)); if (MitigatedDamage > 0.f) { // Set the Target's damage meta attribute OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(DamageStatics().DamageProperty, EGameplayModOp::Additive, MitigatedDamage)); } } |
我感觉这个功能放在MMC中实现更合适一些,因为他最后把伤害值塞进了MMC中,然后用SetByCaller读取它。(也许MMC不支持某些特性?)
Period(间隔触发)
一个Duration或Infinite的GE可以间隔地执行Modifiers和Executions,要被看成是Instant,每次都会永久性地修改BaseValue。
周期:可以配置一个浮点值,或者一个曲线,作为触发的间隔时间。
ExecutePeriodicEffectOnApplication:是否在GE添加时立刻执行一次。
Periodic Inhibtion policy:被抑制之后的策略,当这个GE因为Tag条件等原因被禁止时,间隔触发应该怎么做?
- Never Reset:不重置,间隔执行器仿佛没停一样,会在再次激活后,在本来会执行的时刻执行。
- Reset Period:重置,间隔时间会在再次激活后的一个周期之后执行。
- Execute And Reset Period:被禁用时立刻执行一次,然后重置。
Application(激活条件)
Change To Apply To Target:添加概率,可以配置一个Float值。
Application Requirement:这个可以认为是一个自定义激活条件,与Tag条件共存,可以配置若干个UGameplayEffectCustomApplicationRequirement的子类,可以简称GER
这个类只有一个bool CanApplyGameplayEffect(const UGameplayEffect* GameplayEffect, const FGameplayEffectSpec& Spec, UAbilitySystemComponent* ASC)接口,它返回一个布尔值,只有所有的GER都为true时,效果才能成功添加。
Stacking(叠层)
这里不得不提一下,在GE的持续时间类型上,将永久和有限持续作为静态互斥的类型区分,让叠层变得很便捷,不需要考虑有限和无限的GE叠层的时候,时间刷新的问题。
Stacking Type:叠层类型。
- 按源聚合:每个施放者添加的GE会分别叠层。
- 按目标聚合:所有施放者添加的GE都会叠层。
Statck Limit Count:叠层上限。
Stack Duration Refresh Policy:是否刷新持续时间。
Stack Period Reset Policy:是否在叠层时刷新间隔触发的周期。
Stack Expiration Policy:到期时候的策略。
- 移除所有叠层:就直接移除。
- 移除一层叠层,并且刷新持续时间。
- 刷新持续时间:选这个会把GE变相搞成无限持续的。源码说,策划提了个需求,是每次到期之后,叠层变化的规则是不确定的,比如每次到期之后加1层,如果身上有XXTag就加2层,然后他们弄了这个类型,然后在OnStackCountChange中手动处理这些需求(如果选了这个类型,层数不会自动变,但是会触发回调)。
Overflow(溢出)
指的是叠层打到上限时,再上一个新的GE的时候,会触发溢出。
Overflow Effects:在一个会引起溢出GE尝试添加的时候,就会向Target添加的GE。注意:不管那个触发了溢出的GE是否添加成功,这些Effect都会添加!
Deny Overflow Application:禁用溢出。就是说一个新的GE加上来的时候,如果会引起溢出,就否决它,仿佛什么都没有发生。但会正常触发溢出,添加Overflow Effects。
Clear Stack On Overflow:清除所有叠层。只有开启了Deny Overflow Application之后才能勾选。
Expiration(过期处理)
Premature Expiration Effect Classes:过早移除时添加的GE。可以配若干个。
Routine Expiration Effect Classes:常规移除时添加的GE。可以配若干个。
Immunity(免疫)
GrantedApplicationImmunityTags:检查持有者是否符合指定的标签需求。
GrantedApplicationImmunityQuery:相当于上一条的强化版,除了Tags之外,还能检查很多其他的条件。
标签判断
有数个具备不同功能的标签容器,每个标签容器包括Add和Remove两部分,看源码的意思,貌似是能让策划更灵活的方式编辑标签组。
Gameplay Effect Asset Tag:这个GE的Tag,不是加给Actor的Tag。
GrantedTags:加给目标的标签。
Ongoing Tag Requiements:如果目标不满足这组Tags,GE将会被关闭,知道满足时会再次打开。
Application Tag Requirements:目标身上有这些标签时,GE才能被激活。
Removal Tag Requirements:如果遇到这些标签,GE将会被移除,如果目标身上有这些标签,GE也上不去。
Remove Gameplay Effects With Tags:当GE被激活时,如果目标身上的GE的AssetTags或者GrantedTags有这些标签,这些GE将会被移除。
显示
Require Modifier Success to Trigger Cues:至少有一个修改器成功时,才显示Cues。
Suppress Stacking Cues:如果是个叠层的GE,那么只有第一层显示Cues,如果不勾,那么每层都会创建一个Cues。
Gameplay Cues :配置Cues。
UIData:GE的UI相关的数据,一个UGameplayEffectUIData的子类,这是个空类,你需要自己实现并解析它。
赋予Ability
GE可以赋予目标新的GA,InstantGE不能赋予GA。
官方提到的一个典型的用例,是给目标添加击退、击飞等GA,并自动激活。
应用GameplayEffect
给角色应用一个GE的最基本方法,就是在其ASC上调用UAbilitySystemComponent::ApplyGameplayEffectSpecToSelf(),GA也提供了多个方法来应用GE,但本质还是还是调上面那个接口。
GE是一个纯数据蓝图,本身没有任何逻辑,你需要先以GE类创建一个GameplayEffectSpec对象,然后再把它Apply到目标的ASC上。
如果你想通过子弹给目标添加GE,你可以先通过GA创建出一个GESpec对象,然后把它传给子弹Actor,然后在碰撞发生时Apply它。
GameplayEffectSpec(游戏效果细则)
GESpec是GE的实例化数据,是一个struct,它包含了GameplayEffect的信息,以及包括施放者,等级等实例所需的数据,它会在Apply的时候创建FActiveGameplayEffect,作为实时数据。
GESpec旨在从技能施放,到GE实际上产生效果这两个时间点期间,去收集所需的信息,比如炮弹的落点等。
GESpec通过UAbilityStstemComponent::MakeOutGoingSpec()以一个GE为模板所创建。并且在调用Apply之后,创建FActiveGameplayEffect(AGE)。
GESpec可以调整的内容:
- GEClass
- 持续时间
- 等级
- 周期间隔
- 堆叠数
- 上下文(包含GE的施放者、应用的目标等,源码说你可以拓展这个类,但是同时要改一大堆东西....)
- 属性快照
- 标签组
- SetByCaller映射
移除GameplayEffect
除了利用GE本身的规则移除它之外,手动在目标身上调用RemoveActiveGameplayEffect也可以移除一个GE。
GA的冷却时间GE
GA可以设置一个GE来管理其冷却时间,这个GE必须是一个DurationGE。另外需要在CooldownTag中配置每个GA的位移GameplayTag。GA的运行时检查的,就是角色身上是否有这个Tag。
一般来说一个技能的冷却时间是定义在GA上的,所以如果用一般的方式,你得给每个GA都配置一个冷却GE。
想要用一个通用的GE解决冷却时间问题,可以通过GE的DurationMagnitude,用一个Tag版本的SetByCaller解决:
首先给GA定义一个float类型的冷却时间,以及一个FGameplayTagContainer 用来配置冷却标签。
UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "Cooldown" ) FScalableFloat CooldownDuration; UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "Cooldown" ) FGameplayTagContainer CooldownTags; // Temp container that we will return the pointer to in GetCooldownTags(). // This will be a union of our CooldownTags and the Cooldown GE's cooldown tags. UPROPERTY() FGameplayTagContainer TempCooldownTags; |
然后重写GA的GetCooldownTags(),将配置的GE添加到MutableTaggs中。
const FGameplayTagContainer * UPGGameplayAbility::GetCooldownTags() const { FGameplayTagContainer* MutableTags = const_cast <FGameplayTagContainer*>(&TempCooldownTags); const FGameplayTagContainer* ParentTags = Super::GetCooldownTags(); if (ParentTags) { MutableTags->AppendTags(*ParentTags); } MutableTags->AppendTags(CooldownTags); return MutableTags; } |
最后向SetByCaller中写入冷却时间
void UPGGameplayAbility::ApplyCooldown( const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo * ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo) const { UGameplayEffect* CooldownGE = GetCooldownGameplayEffect(); if (CooldownGE) { FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(CooldownGE->GetClass(), GetAbilityLevel()); SpecHandle.Data.Get()->DynamicGrantedTags.AppendTags(CooldownTags); SpecHandle.Data.Get()->SetSetByCallerMagnitude(FGameplayTag::RequestGameplayTag(FName( OurSetByCallerTag )), CooldownDuration.GetValueAtLevel(GetAbilityLevel())); ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, SpecHandle); } } |
查询剩余的冷却时间
bool APGPlayerState::GetCooldownRemainingForTag(FGameplayTagContainer CooldownTags, float & TimeRemaining, float & CooldownDuration) { if (AbilitySystemComponent && CooldownTags.Num() > 0) { TimeRemaining = 0.f; CooldownDuration = 0.f; FGameplayEffectQuery const Query = FGameplayEffectQuery::MakeQuery_MatchAnyOwningTags(CooldownTags); TArray< TPair< float , float > > DurationAndTimeRemaining = AbilitySystemComponent->GetActiveEffectsTimeRemainingAndDuration(Query); if (DurationAndTimeRemaining.Num() > 0) { int32 BestIdx = 0; float LongestTime = DurationAndTimeRemaining[0].Key; for (int32 Idx = 1; Idx < DurationAndTimeRemaining.Num(); ++Idx) { if (DurationAndTimeRemaining[Idx].Key > LongestTime) { LongestTime = DurationAndTimeRemaining[Idx].Key; BestIdx = Idx; } } TimeRemaining = DurationAndTimeRemaining[BestIdx].Key; CooldownDuration = DurationAndTimeRemaining[BestIdx].Value; return true ; } } return false ; } |
要判断冷却开始和结束,这个我推荐监听冷却Tag的添加和移除,因为GE不一定复制,而Tag是统一复制的,监听Tag比较省心。
AbilitySystemComponent->RegisterGameplayTagEvent(CooldownTag, EGameplayTagEventType::NewOrRemoved)
操作冷却时间
要修改GESpec的Duration,然后要更新
1.StartServerWorldTime.
2.CachedStartServerWorldTime.
3.StartWorldTime.
然后调用CheckDuration()更新持续时间,然后手动调用GESpec的Broadcast()和ASC的OnGameplayEffectDurationChange();
bool UPAAbilitySystemComponent::SetGameplayEffectDurationHandle(FActiveGameplayEffectHandle Handle, float NewDuration) { if (!Handle.IsValid()) { return false ; } const FActiveGameplayEffect* ActiveGameplayEffect = GetActiveGameplayEffect(Handle); if (!ActiveGameplayEffect) { return false ; } FActiveGameplayEffect* AGE = const_cast <FActiveGameplayEffect*>(ActiveGameplayEffect); if (NewDuration > 0) { AGE->Spec.Duration = NewDuration; } else { AGE->Spec.Duration = 0.01f; } AGE->StartServerWorldTime = ActiveGameplayEffects.GetServerWorldTime(); AGE->CachedStartServerWorldTime = AGE->StartServerWorldTime; AGE->StartWorldTime = ActiveGameplayEffects.GetWorldTime(); ActiveGameplayEffects.MarkItemDirty(*AGE); ActiveGameplayEffects.CheckDuration(Handle); AGE->EventSet.OnTimeChanged.Broadcast(AGE->Handle, AGE->StartWorldTime, AGE->GetDuration()); OnGameplayEffectDurationChange(*AGE); return true ; } |
GA的消耗GE
跟冷却GE类似,可以实现一个MMC来从GA中读取消耗值。
float UPGMMC_HeroAbilityCost::CalculateBaseMagnitude_Implementation( const FGameplayEffectSpec & Spec) const { const UPGGameplayAbility* Ability = Cast<UPGGameplayAbility>(Spec.GetContext().GetAbilityInstance_NotReplicated()); if (!Ability) { return 0.0f; } return Ability->Cost.GetValueAtLevel(Ability->GetAbilityLevel()); } |
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 25岁的心里话
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现