关于 GE (Gameplay Effect) 有一些想法
一个 Effect 需要考虑哪些功能呢?
Duration 类型,持续时间是 3s,Period 为 0,作用是 Add HP 50%Instant 类型,作用是 Add HP -5先执行 A,再执行 B
Current Value = 40.0 Base Value = 40.0
Current Value = 60.0 Base Value = 40.0
Current Value = 52.5 Base Value = 35.0
Current Value = 35.0 Base Value = 35.0 // 3s 之后
很明显,由于 Instant 是直接修改 BaseValue,导致效果 A 基于 BaseValue 增加 50% 后计算得到值是 52.5
从玩家体验来看,理论上来说应该扣除 5 点血量,剩余 55 血量,结果实际上是 52.5。对用户来说这就是明显的 Bug
这里关于
Instant属性修改,可以先看下面 GE 的时间周期分类
于是乎,我们想到一种解决方案
以血量为例,除开 HP、MaxHP 之外,再额外加上 ExtraHP 和 Damage
对血量的操作并不直接作用于 HP,而是作用于 ExtraHP
CurrentValue 为 55,即 HP + ExtraHP - DamageExtraHP 的值动态修改 Damage 值,此时 ExtraHP 为 0,Damage 值为 0(因为 20 > 5,如果 Damage 值为 30,那么效果 A 结束时 Damage 更新为 10)CurrentValue 为 HP + ExtraHP - Damage = 4临时性 HP 修改全部作用于 ExtraHP,比如 效果A,或者其他 Buff、Debuff
直接扣除 HP 的全部作用于 Damage
增加当前 HP 的全部作用于 HP
GE 的时间配置如上图所示,分为三种类型
| 周期类型 | 作用 |
|---|---|
| Instant | 立即执行一次 |
| Infinite | 永久持续 |
| Has Duration | 持续一段时间 |
Period 用于配置执行周期
根据周期类型的不同,Period 数值的不同,会出现几种不同的情况
分析来看,这是合理的
Effect 是周期性的,但是 Period 为 0,表示这是一个增益性的 Buff,在持续时间结束后需要清除,所以修改 CurrentValue 更为合适Effect 是周期性的,并且 Period 不为 0 或者这个 GE 是 Instant 的,那么生效时应该修改 BaseValue但是这是一个隐式的计算过程,对外并不可见,可能会导致使用者的使用错误,所以需要添加额外说明
通过查看源码,可以发现对属性的所有修改操作,会封装成一个 Mod 的数组,基于 BaseValue 和 Mod 数组,最终计算得到 CurrentValue
Mod是Modifier的缩写
UGameplayEffect 用于配置,新建的 GE 的资产就是这个
FGameplayEffectSpec 包含 UGameplayEffect 和 对应的运行时信息,比如运行时计算得到的 Duration、Period 等
FActiveGameplayEffect 包含 UGameplayEffect,当一个 GE 是周期性时,会加入到 FActiveGameplayEffectsContainer 容器中,需要记录 GE 执行的 开始时间、下一次执行时间 等信息
综上,不同的配置用于的不同的情况,但是无论如何,一个 GE 只要被应用了,就会创建对应的 FGameplayEffectSpec 实例
以 BlueprintCallable 的 BP_ApplyGameplayEffectToSelf 作为入口
TSubclassOf<UGameplayEffect> 创建 GE 对象ApplyGameplayEffectToTargetUGameplayEffect* GameplayEffect = GameplayEffectClass->GetDefaultObject<UGameplayEffect>();
return ApplyGameplayEffectToTarget(GameplayEffect, Target, Level, Context); 注意这里是通过 GetDefaultObject 获取目标的 CDO 对象,也就是说所有
GE 在这里得到的 GameplayEffect 都是同一个对象,这为后面判断堆叠提供了帮助
在 ApplyGameplayEffectToTarget 中
FGameplayEffectSpecApplyGameplayEffectSpecToTargetFGameplayEffectSpec Spec(GameplayEffect, Context, Level);
return ApplyGameplayEffectSpecToTarget(Spec, Target, PredictionKey);在 ApplyGameplayEffectSpecToTarget 中
PredictionKeyTarget->ApplyGameplayEffectSpecToSelf 获得 GE 的 Handle
接下来所有代码都不考虑网络同步和预测的问题
在 Target->ApplyGameplayEffectSpecToSelf 中GameplayEffectApplicationQueries 检查当前要添加的 GE 是否有效ActiveGameplayEffects 和当前要添加的 GE 检查能否添加
GEComponent 来判断的AssetTagsGameplayEffectComponent、AbilitiesGameplayEffectComponent 等 GEComponent 配置AttributeExecuteGameplayEffect(*OurCopyOfSpec, PredictionKey) 直接执行 GEActiveGameplayEffects.ApplyGameplayEffectSpec 添加 GE 到容器中在 ActiveGameplayEffects.ApplyGameplayEffectSpec 中
FActiveGameplayEffect 实例FActiveGameplayEffect 实例FActiveGameplayEffect 数值,收集其绑定的属性FActiveGameplayEffect 实例上OnStackCountChangeInternalOnActiveGameplayEffectAdded在代码中有两个地方使用了 TimeManager
bLoop 设置为 falsebExecutePeriodicEffectOnApplication 为 true,表示 GE 添加时立刻执行一次,所以会额外注册一个 TimerManager.SetTimerForNextTick// Calculate Duration mods if we have a real duration
if (DurationBaseValue > 0.f)
{
float FinalDuration = AppliedEffectSpec.CalculateModifiedDuration();
// 一些判断和特殊数据处理
if (Owner && bSetDuration)
{
FTimerManager& TimerManager = Owner->GetWorld()->GetTimerManager();
FTimerDelegate Delegate = FTimerDelegate::CreateUObject(Owner, &UAbilitySystemComponent::CheckDurationExpired, AppliedActiveGE->Handle);
TimerManager.SetTimer(AppliedActiveGE->DurationHandle, Delegate, FinalDuration, false);
if (!ensureMsgf(AppliedActiveGE->DurationHandle.IsValid(), TEXT("Invalid Duration Handle after attempting to set duration for GE %s @ %.2f"),
*AppliedActiveGE->GetDebugString(), FinalDuration))
{
// Force this off next frame
TimerManager.SetTimerForNextTick(Delegate);
}
}
}
// Register period callbacks with the timer manager
if (bSetPeriod && Owner && (AppliedEffectSpec.GetPeriod() > UGameplayEffect::NO_PERIOD))
{
FTimerManager& TimerManager = Owner->GetWorld()->GetTimerManager();
FTimerDelegate Delegate = FTimerDelegate::CreateUObject(Owner, &UAbilitySystemComponent::ExecutePeriodicEffect, AppliedActiveGE->Handle);
// The timer manager moves things from the pending list to the active list after checking the active list on the first tick so we need to execute here
if (AppliedEffectSpec.Def->bExecutePeriodicEffectOnApplication)
{
TimerManager.SetTimerForNextTick(Delegate);
}
TimerManager.SetTimer(AppliedActiveGE->PeriodHandle, Delegate, AppliedEffectSpec.GetPeriod(), true);
}
在 InternalOnActiveGameplayEffectAdded 函数中
bIsInhibited)UAbilitySystemComponent::InhibitActiveGameplayEffect在 UAbilitySystemComponent::InhibitActiveGameplayEffect 函数中
RemoveActiveGameplayEffectGrantedTagsAndModifiersAddActiveGameplayEffectGrantedTagsAndModifiersOnInhibitionChanged 事件FScopedAggregatorOnDirtyBatch 对象析构的时候触发
ActiveGameplayEffects.OnMagnitudeDependencyChange 属性变化事件OnDirty 脏数据事件触发FTimerDelegate Delegate = FTimerDelegate::CreateUObject(Owner, &UAbilitySystemComponent::ExecutePeriodicEffect, AppliedActiveGE->Handle);
根据上面的代码,直接定位执行位置是 UAbilitySystemComponent::ExecutePeriodicEffect
通过函数调用,真正执行的代码在 FActiveGameplayEffectsContainer::ExecuteActiveEffectsFrom 函数中
TargetTags 即 Owner 标记上的 GameplayTagsGE 的 ModifiersInternalExecuteMod 应用 ModifierExecutions 数组,计算得到 ConditionalEffectSpecs 将要附加的 GEConditionalEffectSpecs重点在于两个函数 CalculateModifierMagnitudes 和 InternalExecuteMod
在 CalculateModifierMagnitudes 中
const FGameplayModifierInfo& ModDef = Def->Modifiers[ModIdx];
FModifierSpec& ModSpec = Modifiers[ModIdx];
if (ModDef.ModifierMagnitude.AttemptCalculateMagnitude(*this, ModSpec.EvaluatedMagnitude) == false)
{
ModSpec.EvaluatedMagnitude = 0.f;
ABILITY_LOG(Warning, TEXT("Modifier on spec: %s was asked to CalculateMagnitude and failed, falling back to 0."), *ToSimpleString());
}
很清晰,通过在 GE 中配置的 Def->Modifiers 计算出 ModSpec.EvaluatedMagnitude 的值
switch (MagnitudeCalculationType)
{
case EGameplayEffectMagnitudeCalculation::ScalableFloat:break;
case EGameplayEffectMagnitudeCalculation::AttributeBased:break;
case EGameplayEffectMagnitudeCalculation::CustomCalculationClass:break;
case EGameplayEffectMagnitudeCalculation::SetByCaller:break;
// ...
}
在 AttemptCalculateMagnitude 中通过枚举,来计算具体的值内容
在 InternalExecuteMod 函数中,核心代码如下
if (AttributeSet->PreGameplayEffectExecute(ExecuteData))
{
float OldValueOfProperty = Owner->GetNumericAttribute(ModEvalData.Attribute);
ApplyModToAttribute(ModEvalData.Attribute, ModEvalData.ModifierOp, ModEvalData.Magnitude, &ExecuteData);
FGameplayEffectModifiedAttribute* ModifiedAttribute = Spec.GetModifiedAttribute(ModEvalData.Attribute);
if (!ModifiedAttribute)
{
// If we haven't already created a modified attribute holder, create it
ModifiedAttribute = Spec.AddModifiedAttribute(ModEvalData.Attribute);
}
ModifiedAttribute->TotalMagnitude += ModEvalData.Magnitude;
{
SCOPE_CYCLE_COUNTER(STAT_PostGameplayEffectExecute);
/** This should apply 'gamewide' rules. Such as clamping Health to MaxHealth or granting +3 health for every point of strength, etc */
AttributeSet->PostGameplayEffectExecute(ExecuteData);
}
}
基本流程也很简单
PreGameplayEffectExecute 判断能否触发ApplyModToAttributePostGameplayEffectExecuteUGameplayEffectCalculation 与 UGameplayModMagnitudeCalculation 类似,都是可以自定义属性的计算过程,不同的是 UGameplayEffectCalculation 可以对多个属性进行操作
以 Lyra 项目中的 ULyraDamageExecution 为例
Execute_Implementation(const FGameplayEffectCustomExecutionParameters& ExecutionParams, FGameplayEffectCustomExecutionOutput& OutExecutionOutput)
首先观察参数
ExecutionParams 是 const &,表示作为输入OutExecutionOutput 是 &,表示作为输出通过 ExecutionParams 可以得到 FGameplayEffectSpec 这个 GE 的运行时实例
const FGameplayTagContainer* SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
const FGameplayTagContainer* TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();
FAggregatorEvaluateParameters EvaluateParameters;
EvaluateParameters.SourceTags = SourceTags;
EvaluateParameters.TargetTags = TargetTags;
float BaseDamage = 0.0f;
ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().BaseDamageDef, EvaluateParameters, BaseDamage);
收集 Source 和 Target 身上的 Tag,通过 AttemptCalculateCapturedAttributeMagnitude 将捕获的属性 以及 收集的信息 进行计算得到 BaseDamage
捕获的属性是
ULyraCombatSet::GetBaseDamageAttribute()这一步进行计算,得到属性值
接下来就是进行一系列计算:距离衰减、友伤判定、护甲衰减等,得到最终的数值 DamageDone
最后将 DamageDown 应用到属性上
OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(ULyraHealthSet::GetDamageAttribute(), EGameplayModOp::Additive, DamageDone));
注意,最开始获取伤害是通过 ULyraCombatSet::GetBaseDamageAttribute 属性,最后应用伤害是在 ULyraHealthSet::GetDamageAttribute
这也是 UGameplayEffectCalculation 的优势点
为了方便配置、提升可扩展性,将 GE 执行的各个流程封装成成 UGameplayEffectComponent,根据各自的需求实现各自的共嫩
从源码来看,UGameplayEffectComponent 内容非常简单
| 函数 | 作用 |
|---|---|
| CanGameplayEffectApply | 检查 GE 是否可以应用到目标,必须所有的 GEComponent 都返回 True ,该 GE 才可以被应用 |
| OnActiveGameplayEffectAdded | 带持续时间的效果激活时,如果返回 False,表示该 GE 不能激活 |
| OnGameplayEffectExecuted | 在瞬时效果或周期效果的执行时触发 |
| OnGameplayEffectApplied | 效果首次应用或堆叠时的通用入口 |
| OnGameplayEffectChanged | 编辑器中对 GameplayEffect 的修改保存后 |
OnGameplayEffectChanged通常执行在 Editor 模式下
通过拆分 GE 的生命周期,根据各自的需求封装 GEComponent
以 UAdditionalEffectsGameplayEffectComponent 为例
UAdditionalEffectsGameplayEffectComponent 的作用是当 GE 被 Applied 的时候,添加新的 GE 到 Target 上
OnActiveGameplayEffectAdded 激活时绑定了 GE 的 Removed 事件,方便在 Removed 的时候删除本次添加的 GEOnGameplayEffectApplied 通过 ConditionalEffect 将能够添加的 GE 添加到 Target 上以 UAssetTagsGameplayEffectComponent 为例
UAssetTagsGameplayEffectComponent 的作用是为 GE 添加 Tags
OnGameplayEffectChanged 的时候,将 InheritableAssetTags 存储的内容添加到 GE 的 CachedAssetTags 属性中PostEditChangeProperty 的时候,同步更新 GE 中的 CachedAssetTags 属性
PostEditChangeProperty是 UObject 的函数,当资产更新的时候触发,这里就是修改AssetTag的时候触发
还有一个 UGameplayEffectUIData
UGameplayEffectUIData 本身没有 任何属性,也没有重写 任何函数
它的作用就是作为一个标记类,所有继承 UGameplayEffectUIData 的类都是用于 UI 显示的配置
比如 Lyra 项目中 UTopDownArenaPickupUIData,在项目中可以通过 GetGameplayEffectUIData 获取对应的继承 UGameplayEffectUIData 的配置项
上述中,获取到对应的
UGameplayEffectUIData后,获取配置中的特效、贴图等配置,进行后续操作
由于 UGameplayEffectComponent 的存在,现在 GE 的配置更加方便、简单、易读