# GAS ## 思考 关于 GE (Gameplay Effect) 有一些想法 一个 Effect 需要考虑哪些功能呢? - 配置项 - 周期触发配置项 - 叠层配置项 - 冲突、免疫配置项 - 冲突:该 Effect 生效时,哪些已有的 Buff 会失去效果 - 免疫:该 Effect 生效时,哪些还未添加的 Buff 无法添加或不能生效 - 上下文信息 - Caster:施加者。可以为空,比如场地效果 - Parent:被施加者。不可为空 - Ability:通过 技能 施加。可以为空,比如场地效果,以 技能 形式添加 - 当前 Effect 的 Tag - 当前 Effect 所处的状态 - BeforeInit - Init - Exeution - Finished - Destroy - Caster 被销毁时,是否保持该 Effect - Parent 被销毁时,是否保持该 Effect - 对属性进行的操作 - 是否在 UI 上显示 - 阶段触发事件 - 通知 Parent/Caster/Ability 当前 Buff 所处阶段 - 操作配置 - 优先级 - 目标属性 - 操作配置:加、乘、除、覆盖 - 数值配置 - 触发周期配置 - 触发类型 - 直接触发 - 周期性触发,持续一段时间 - 周期性触发,永久存在 - 触发周期间隔时间 - 持续时间 - 是否立刻触发:如果是周期性触发,是否在生效时立刻触发一次 - 叠层机制配置 - 能否叠层 - 最大叠层数限制 - 叠层方案 - 不叠层,刷新当前 buff 的持续时间 - 叠层,不刷新当前 buff 的持续时间,一层一层减少 - 叠层,刷新当前 buff的持续时间,并且更改数值,一层一层减少 - 叠层,刷新当前 buff 的持续时间,并且更改数值,一次性扣完所有层数 - 数值配置 - 配置固定值 - 基于属性百分比 - 数值来源 - Caster - Parent - 是否限制取值范围 - 最大值 - 最小值 - 自定义计算流程 - 是否动态计算,也就是运行时计算,还是一直使用添加时的数值 - 简单来说就是,是否锁面板 ## Attribute ### 关于属性的思考 - 效果 A:是 `Duration` 类型,持续时间是 3s,`Period` 为 0,作用是 Add HP 50% - 效果 B:是 `Instant` 类型,作用是 Add HP -5 先执行 A,再执行 B ```bash 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` 1. 假设初始血量为 40 2. 使用效果 A,增加血量 50%,此时 ExtraHP 值为 20 3. 使用效果 B,减少血量 5点,此时 Damage 值为 5 4. 计算得到 HP 的 `CurrentValue` 为 55,即 `HP + ExtraHP - Damage` 5. 效果 A 时间结束,结束时根据 `ExtraHP` 的值动态修改 `Damage` 值,此时 `ExtraHP` 为 0,`Damage` 值为 0(因为 20 > 5,如果 `Damage` 值为 30,那么效果 A 结束时 Damage 更新为 10) 6. 计算得到 HP 的 `CurrentValue` 为 `HP + ExtraHP - Damage` = 4 > 临时性 HP 修改全部作用于 ExtraHP,比如 效果A,或者其他 Buff、Debuff > 直接扣除 HP 的全部作用于 Damage > 增加当前 HP 的全部作用于 HP ## GE ### GE 的时间周期分类 ![](Image/004.png) GE 的时间配置如上图所示,分为三种类型 | 周期类型 | 作用 | | --- | --- | | Instant | 立即执行一次 | | Infinite | 永久持续 | | Has Duration | 持续一段时间 | `Period` 用于配置执行周期 根据周期类型的不同,`Period` 数值的不同,会出现几种不同的情况 | Duration 配置 | 效果 | 解释 | | --- | --- | --- | | DurationPolicy: Instant | ![](Image/005.png) | 修改 BaseValue(current也跟着变) | | DurationPolicy: Infinite
Period: 0.0 | ![](Image/006.png) | 只修改 CurrentValue,BaseValue 不变 | | DurationPolicy: Infinite
Period: 1.0 | ![](Image/007.png) | 修改 BaseValue(current也跟着变) | | DurationPolicy: HasDuration
ScalableFloatMagnitude: 2.0
Period: 0.0 | ![](Image/008.png) | 修改了CurrentValue,BaseValue不变,2s 后GE消除,CurrentValue 恢复到 100 | | DurationPolicy: HasDuration
ScalableFloatMagnitude: 2.0
Period: 1.0 | ![](Image/009.png) | 直接修改了 BaseValue (current也跟着变) | 分析来看,这是合理的 - 如果一个 `Effect` 是周期性的,但是 `Period` 为 0,表示这是一个增益性的 Buff,在持续时间结束后需要清除,所以修改 `CurrentValue` 更为合适 - 如果一个 `Effect` 是周期性的,并且 `Period` 不为 0 或者这个 GE 是 `Instant` 的,那么生效时应该修改 `BaseValue` 但是这是一个隐式的计算过程,对外并不可见,可能会导致使用者的使用错误,所以需要添加额外说明 通过查看源码,可以发现对属性的所有修改操作,会封装成一个 `Mod` 的数组,基于 `BaseValue` 和 `Mod` 数组,最终计算得到 `CurrentValue` > `Mod` 是 `Modifier` 的缩写 ### 关于 UGameplayEffect、FGameplayEffectSpec 和 FActiveGameplayEffect `UGameplayEffect` 用于配置,新建的 GE 的资产就是这个 `FGameplayEffectSpec` 包含 `UGameplayEffect` 和 对应的运行时信息,比如运行时计算得到的 `Duration`、`Period` 等 `FActiveGameplayEffect` 包含 `UGameplayEffect`,当一个 GE 是周期性时,会加入到 `FActiveGameplayEffectsContainer` 容器中,需要记录 GE 执行的 开始时间、下一次执行时间 等信息 综上,不同的配置用于的不同的情况,但是无论如何,一个 GE 只要被应用了,就会创建对应的 `FGameplayEffectSpec` 实例 ### GE 的添加流程 以 `BlueprintCallable` 的 `BP_ApplyGameplayEffectToSelf` 作为入口 1. 通过传入的 `TSubclassOf` 创建 GE 对象 2. 调用 `ApplyGameplayEffectToTarget` ```cpp UGameplayEffect* GameplayEffect = GameplayEffectClass->GetDefaultObject(); return ApplyGameplayEffectToTarget(GameplayEffect, Target, Level, Context); 注意这里是通过 GetDefaultObject 获取目标的 CDO 对象,也就是说所有 ``` GE 在这里得到的 `GameplayEffect` 都是同一个对象,这为后面判断堆叠提供了帮助 在 `ApplyGameplayEffectToTarget` 中 1. 通过传入参数创建 `FGameplayEffectSpec` 2. 调用 `ApplyGameplayEffectSpecToTarget` ```cpp FGameplayEffectSpec Spec(GameplayEffect, Context, Level); return ApplyGameplayEffectSpecToTarget(Spec, Target, PredictionKey);在 ApplyGameplayEffectSpecToTarget 中 ``` 1. 判断是否需要预测,以此来清空预测键 `PredictionKey` 2. 调用 `Target->ApplyGameplayEffectSpecToSelf` 获得 GE 的 `Handle` 接下来所有代码都不考虑网络同步和预测的问题 在 `Target->ApplyGameplayEffectSpecToSelf` 中 3. 检查 - 通过 `GameplayEffectApplicationQueries` 检查当前要添加的 GE 是否有效 - 通过 `ActiveGameplayEffects` 和当前要添加的 GE 检查能否添加 - 通过 `GEComponent` 来判断的 - 例如:`AssetTagsGameplayEffectComponent`、`AbilitiesGameplayEffectComponent` 等 `GEComponent` 配置 - 检查当前准备添加的 GE 是否配置了有效的 `Attribute` 4. 判断是否是立即执行的 GE - 如果是立即执行的 GE:`ExecuteGameplayEffect(*OurCopyOfSpec, PredictionKey)` 直接执行 GE - 如果是持续时间的 GE:`ActiveGameplayEffects.ApplyGameplayEffectSpec` 添加 GE 到容器中 在 `ActiveGameplayEffects.ApplyGameplayEffectSpec` 中 1. 判断堆叠 - 如果堆叠:更新叠层计数,根据条件刷新持续时间、重置计数器等操作,使用现有的 `FActiveGameplayEffect` 实例 - 如果不堆叠:创建 `FActiveGameplayEffect` 实例 2. 重新计算 `FActiveGameplayEffect` 数值,收集其绑定的属性 3. 计算持续时间和周期,绑定 Timer 到 `FActiveGameplayEffect` 实例上 4. 根据是否堆叠触发不同的事件 - 如果堆叠:触发 `OnStackCountChange` - 如果不堆叠:触发 `InternalOnActiveGameplayEffectAdded` 在代码中有两个地方使用了 `TimeManager` - 第一个 Timer 是在持续时间结束后触发,用于标记效果过期(可以移除效果),只触发一次所以 `bLoop` 设置为 false - 第二个 Timer 是固定时间执行周期性效果(每秒造成1点伤害),需要循环触发所以设置为 true - 如果 GE 的 `bExecutePeriodicEffectOnApplication` 为 true,表示 GE 添加时立刻执行一次,所以会额外注册一个 TimerManager.SetTimerForNextTick ```cpp // 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` 函数中 1. 会根据 GEComponent 来判断当前 GE 能否被激活或者说是否被抑制(`bIsInhibited`) 2. 调用 `UAbilitySystemComponent::InhibitActiveGameplayEffect` 在 `UAbilitySystemComponent::InhibitActiveGameplayEffect` 函数中 1. 根据 GE 是否被 抑制 - 被抑制:执行 `RemoveActiveGameplayEffectGrantedTagsAndModifiers` - 不被抑制:执行 `AddActiveGameplayEffectGrantedTagsAndModifiers` 2. 触发 `OnInhibitionChanged` 事件 3. 在 `FScopedAggregatorOnDirtyBatch` 对象析构的时候触发 - `ActiveGameplayEffects.OnMagnitudeDependencyChange` 属性变化事件 - `OnDirty` 脏数据事件触发 #### 流程图 ![](Image/010.png) ![](Image/011.png) #### 代码执行过程 ![GE的添加流程](Image/002.png) ### GE 的执行流程 ```cpp FTimerDelegate Delegate = FTimerDelegate::CreateUObject(Owner, &UAbilitySystemComponent::ExecutePeriodicEffect, AppliedActiveGE->Handle); ``` 根据上面的代码,直接定位执行位置是 `UAbilitySystemComponent::ExecutePeriodicEffect` 通过函数调用,真正执行的代码在 `FActiveGameplayEffectsContainer::ExecuteActiveEffectsFrom` 函数中 1. 设置 `TargetTags` 即 `Owner` 标记上的 `GameplayTags` 2. 计算 `GE` 的 `Modifiers` 3. 通过循环调用 `InternalExecuteMod` 应用 `Modifier` 4. 遍历 `Executions` 数组,计算得到 `ConditionalEffectSpecs` 将要附加的 GE 5. 触发 GC 6. 应用 `ConditionalEffectSpecs` 7. 最后触发事件 重点在于两个函数 `CalculateModifierMagnitudes` 和 `InternalExecuteMod` 在 `CalculateModifierMagnitudes` 中 ```cpp 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` 的值 ```cpp switch (MagnitudeCalculationType) { case EGameplayEffectMagnitudeCalculation::ScalableFloat:break; case EGameplayEffectMagnitudeCalculation::AttributeBased:break; case EGameplayEffectMagnitudeCalculation::CustomCalculationClass:break; case EGameplayEffectMagnitudeCalculation::SetByCaller:break; // ... } ``` 在 `AttemptCalculateMagnitude` 中通过枚举,来计算具体的值内容 在 `InternalExecuteMod` 函数中,核心代码如下 ```cpp 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); } } ``` 基本流程也很简单 1. 调用 `PreGameplayEffectExecute` 判断能否触发 2. 添加对应属性修改 `ApplyModToAttribute` 3. 添加属性的修改记录 4. 设置累加值 5. 触发事件 `PostGameplayEffectExecute` ![](Image/012.png) ![GE的执行流程](Image/001.png) ### UGameplayEffectCalculation `UGameplayEffectCalculation` 与 `UGameplayModMagnitudeCalculation` 类似,都是可以自定义属性的计算过程,不同的是 `UGameplayEffectCalculation` 可以对多个属性进行操作 以 `Lyra` 项目中的 `ULyraDamageExecution` 为例 ```cpp Execute_Implementation(const FGameplayEffectCustomExecutionParameters& ExecutionParams, FGameplayEffectCustomExecutionOutput& OutExecutionOutput) ``` 首先观察参数 - `ExecutionParams` 是 `const &`,表示作为输入 - `OutExecutionOutput` 是 `&`,表示作为输出 通过 `ExecutionParams` 可以得到 `FGameplayEffectSpec` 这个 GE 的运行时实例 ```cpp 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` 应用到属性上 ```cpp OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(ULyraHealthSet::GetDamageAttribute(), EGameplayModOp::Additive, DamageDone)); ``` 注意,最开始获取伤害是通过 `ULyraCombatSet::GetBaseDamageAttribute` 属性,最后应用伤害是在 `ULyraHealthSet::GetDamageAttribute` 这也是 `UGameplayEffectCalculation` 的优势点 ### UGameplayEffectComponent 为了方便配置、提升可扩展性,将 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 的时候删除本次添加的 GE - 在 `OnGameplayEffectApplied` 通过 `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` 的配置项 ![](Image/003.png) > 上述中,获取到对应的 `UGameplayEffectUIData` 后,获取配置中的特效、贴图等配置,进行后续操作 -------- 由于 `UGameplayEffectComponent` 的存在,现在 GE 的配置更加方便、简单、易读