写在前面
- 本文基于 UE4.26 编写,阅读前需要对 GAS 的 技能组件 ASC、属性 Attribute 以及 技能 GameplayAbility 有基本的了解。
- 本文的目标是用一篇文章把 UE GAS Buff 模块讲清楚。
Buff 功能
- 标记状态:Buff 可以携带并赋予所属 ASC 若干 GameplayTag,标记所属 Actor 处于特殊状态
- 修改属性:Buff 可以通过多种手段修改所属 Actor 的属性集中的属性,比如血量、移速、攻击力等等
- 携带技能:Buff 可以挂载技能脚本,赋予给 Buff 目标 Actor,比如命中击退就可以在命中时施加击退 Buff 动态赋予一个击退技能
- 触发表现:Buff 可以挂载 GameplayCue,在施加移除不同阶段触发相应的动作特效音效以及 UI 表现
如何存在
下述的三个结构体就是 UE Buff 实例的核心数据结构
FActiveGameplayEffect
- UE 中的 Buff 实例以一个结构体 FActiveGameplayEffect 对象形式存在,被集中收集到了技能组件 ASC 上的容器 ActiveGameplayEffects(FActiveGameplayEffectsContainer) 当中。
- FActiveGameplayEffect 中保存有全局唯一的 buff 句柄(EffectHandle),以及 buff 对应的数据规格(EffectSpec)。
FActiveGameplayEffectHandle
- Buff 实例对应的唯一句柄,在施加 buff 时返回供外部缓存,用于后续对 Buff 实例进行获取和移除等操作。
FGameplayEffectSpec
- 姑且翻译为 Buff 实例的数据规格,内部包含有 Buff 实例所需的数据模板、动态数据以及上下文。
- 数据模板 指的是 Def(UGameplayEffect*),是一份静态 Buff 配置,规定了此类 Buff 的持续策略、叠层策略、状态标签、修改属性和修改方式、存续条件、移除规则等等配置时确定的规则。
- 动态数据 指的是实例的时长 Duration、生效周期 Period、施加概率 ChanceToApplyToTarget、叠层数值 StackCount 以及等级 Level 等施加 Buff 时确定下来的数据。
- 上下文 则是指 EffectContext(FGameplayEffectContextHandle),其实是对 FGameplayEffectContext 的一层包装。EffectContext 中保存了 Instigator Causer InstigatorASC Ability HitResult 等等上下文,供 Buff 流程中的各个环节获取使用。
施加与移除接口
NOTE: 这一部分讲通用的施加移除流程,暂时先不涉及预测、不同 DurationPolicy 施加的特殊流程以及容器锁。
Apply
- 上一章 - 存在形式 中了解到 ASC 的 buff 容器 ActiveGameplayEffects 中保存的是 FActiveGameplayEffect,那么施加 buff 的流程也就是构造它并加入容器的过程。
- 构造 EffectContext 上下文:施加 buff 前构造 FGameplayEffectContextHandle,将 Instigator Causer Ability 等等上下文信息填充进去。
- 构造 EffectSpec 和 SpecHandle : 提供 GameplayEffect 类以及上下文,调用 FGameplayEffectSpecHandle UAbilitySystemComponent::MakeOutgoingSpec(GameplayEffectClass, ..., Context) 来构造出 SpecHandle。
- 施加 buff : 当我们准备好了 EffectSpec,调用 FActiveGameplayEffectHandle UAbilitySystemComponent::ApplyGameplayEffectSpecToSelf(EffectSpec, ...) 即可将给定的 buff 规则传到 GAS 底层去施加 buff 实例。
- GAS 容器施加 buff:底层会调用 FActiveGameplayEffect FActiveGameplayEffectsContainer::ApplyGameplayEffectSpec(EffectSpec, ...),构造新的 ActiveGE 加入到 ActiveGameplayEffects 容器并通过 FActiveGameplayEffectHandle::GenerateNewHandle() 创建唯一对应的 ActiveGEHandle 并关联,最终返回给上层。
- ASC 施加接口,最终都会调到 xxxToSelf
// 代码位置:Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/AbilitySystemComponent.h /** Applies a previously created gameplay effect spec to a target */ UFUNCTION(BlueprintCallable, Category = GameplayEffects, meta = (DisplayName = "ApplyGameplayEffectSpecToTarget", ScriptName = "ApplyGameplayEffectSpecToTarget")) FActiveGameplayEffectHandle BP_ApplyGameplayEffectSpecToTarget(const FGameplayEffectSpecHandle& SpecHandle, UAbilitySystemComponent* Target); virtual FActiveGameplayEffectHandle ApplyGameplayEffectSpecToTarget(const FGameplayEffectSpec& GameplayEffect, UAbilitySystemComponent *Target, FPredictionKey PredictionKey=FPredictionKey());/** Applies a previously created gameplay effect spec to this component */ UFUNCTION(BlueprintCallable, Category = GameplayEffects, meta = (DisplayName = "ApplyGameplayEffectSpecToSelf", ScriptName = "ApplyGameplayEffectSpecToSelf")) FActiveGameplayEffectHandle BP_ApplyGameplayEffectSpecToSelf(const FGameplayEffectSpecHandle& SpecHandle); virtual FActiveGameplayEffectHandle ApplyGameplayEffectSpecToSelf(const FGameplayEffectSpec& GameplayEffect, FPredictionKey PredictionKey = FPredictionKey());
Remove
- ASC 上主要的 buff 移除函数:UAbilitySystemComponent::RemoveActiveGameplayEffect(EffectHandle, ...) 和 UAbilitySystemComponent::RemoveActiveEffects(Query, ...),提供提前缓存下来的 buff 句柄或者一个查询结构 Query。
- 底层会调用 FActiveGameplayEffectsContainer 上的移除接口,最终执行 InternalRemoveActiveGameplayEffect() 从 buff 实例容器中移除。
- ASC 移除接口
// 代码位置:Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/AbilitySystemComponent.h /** Removes GameplayEffect by Handle. StacksToRemove=-1 will remove all stacks. */ UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly, Category = GameplayEffects) virtual bool RemoveActiveGameplayEffect(FActiveGameplayEffectHandle Handle, int32 StacksToRemove=-1);UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly, Category = GameplayEffects) virtual void RemoveActiveGameplayEffectBySourceEffect(TSubclassOf<UGameplayEffect> GameplayEffect, UAbilitySystemComponent* InstigatorAbilitySystemComponent, int32 StacksToRemove = -1);/** Removes all active effects that match given query. StacksToRemove=-1 will remove all stacks. */ virtual int32 RemoveActiveEffects(const FGameplayEffectQuery& Query, int32 StacksToRemove = -1);
DurationPolicy
类型与功能
- 常见的 buff 是持续一段时间而后自动结束,比如狂暴状态持续 10s。此外,有一些 buff 是一次性或者一直存在的,比如奶妈瞬时回血 100 点,又比如某皮肤自带增加 10 点攻击力整局生效。
- 在 UE GameplayEffect 中 DurationPolicy 配置就是用来标记 buff 持续策略的。
| EGameplayEffectDurationType | Type | Duration | 示例 |
|---|---|---|---|
| Instant | 一次性 | - | 瞬时回血 |
| Infinite | 永久 | -1 | 皮肤增加攻击力 |
| HasDuration | 有持续时间 | Duration | 狂暴 10s |
施加流程的比较
- Infinite 和 HasDuration 施加流程大致相同。
- Instant 一次性的 buff 较为特殊,在服务器施加一次性 buff 时,服务器并不会创建 ActiveGE 加入容器,而是直接执行对属性基础值的修改返回一个无效 ActiveGEHandle。如果在主端预测施加一次性 buff,主端会将其视为永久性 (bTreatAsInfiniteDuration) 的 buff 并添加到 buff 容器中,目的是方便主端移除本地预测的buff实例。
- HasDuration 的 buff,在 FActiveGameplayEffectsContainer::ApplyGameplayEffectSpec() 施加时定时回调执行 CheckDurationExpired() 将自己移除(可叠层的 buff 则更为复杂,后续 Stacking 部分会细说)。
Period 周期生效
- 常见的使用场景是每隔一段时间恢复血量 / 耐力 / 消耗体力,这时候就需要 buff 能够定时循环生效去修改属性,Period 应运而生。
- 生效周期需要在施加 buff 前构造 GESpec 时赋值给 GESpec.Period。
- FActiveGameplayEffectsContainer::ApplyGameplayEffectSpec() 施加 buff 时,定时器周期性回调 UAbilitySystemComponent::ExecutePeriodicEffect()。最终还是调用到 ActiveGamepalyEffects 容器 InternalExecutePeriodicGameplayEffect() 去直接修改属性基础值。如果想要在周期性 buff 施加时就立即生效一次修改,需要打开配置项 bExecutePeriodicEffectOnApplication
施加、存续、抑制、免疫
施加许可
- ChanceToApplyToTarget
- 功能:概率施加 buff,范围 [0.0f, 1.0f],比如命中时几率点燃。
- 生效时机:UAbilitySystemComponent::ApplyGameplayEffectSpecToSelf,FGameplayEffectSpec::Initialize/SetLevel 内部从 GE 模板缓存概率值到 EffectSpec,在施加时随机,不满足概率则施加失败。
- ApplicationTagRequirements
- 功能:可施加的前置标签条件,比如无敌不可以被施加减益 buff / 满血不可施加一次性回血。
- 生效时机:UAbilitySystemComponent::ApplyGameplayEffectSpecToSelf,概率判断通过后紧接着就会判断标签条件是否满足,不满足则施加失败。
被移除
- RemovalTagRequirements
- 功能: Tag条件满足时,移除自己或者不施加。控制 buff 需要被净化移除,那么就可以给控制 GE 的 Removal 配上净化携带的 Tag。
- 生效时机:
- 施加时:UAbilitySystemComponent::ApplyGameplayEffectSpecToSelf,施加时如果角色和 ASC 当前的 Tag 已经满足了当前 buff 的 Removal 条件,那么就无需施加了,直接施加失败。
- Owner ASC Tag 变化时:FActiveGameplayEffectsContainer::OnOwnerTagChange,当 Tag 变化遍历现有 buff,满足 Removal 就移除掉。
- RemoveGameplayEffectQuery
命名上叫 RemovalXXX 好些,Remove 有些误导让人以为是要移除别的
- 功能:功能上类似 RemovalTagRequirements,是被移除的配置。每当有新的 buff 施加,回看现有的所有 buff,如果现有 buff 配置了 RemoveQuery 且 新加 buff 满足这个 Query。
- 特别的:既然功能类似,那为什么要加一个新的?因为它更加强大。首先 FGameplayTagQuery 就比 FGameplayTagRequirements 强大,另外 FGameplayEffectQuery 支持单独过滤 AssetTags GrantedTags 修改属性 GE Class 模板,甚至可以过滤 EffectSource Ojbect。
- 如果想要达到 在某个使用特定 GE 模板类型或者修改特定属性的 buff 施加时,本 buff 被移除,那么使用它。
- 生效时机:FActiveGameplayEffectsContainer::AttemptRemoveActiveEffectsOnEffectApplication
移除
- RemoveGameplayEffectsWithTags
- 功能:每当新 buff 施加时,将现有 buff 中满足新 buff Remove 条件的移除。净化移除控制,就可以在净化 buff 配置 Remove 控制 buff Tag,这是另一种实现方式。
- 生效时机:FActiveGameplayEffectsContainer::AttemptRemoveActiveEffectsOnEffectApplication
被抑制
被抑制只是暂时不生效,不是被移除
- OngoingTagRequirements
- 功能:Tag 变化时,如果不满足条件,被抑制不生效修改,等条件满足则恢复生效。
- 生效时机:FActiveGameplayEffect::CheckOngoingTagRequirements,底层的抑制状态 bIsInhibited 放在 ActiveGE 上。buff 施加时以及 Tag 变化时更新被抑制的状态。被抑制状态下会移除属性修改,移除施加的 Tag,移除携带的技能脚本。
免疫 / 阻塞
- GrantedApplicationImmunityTags
- 功能:阻塞携带特定类型 Tag 的 buff 不能施加,用于判断的 Tag 包括 AssetTags、Instigator 以及 InstigatorASC 上的 Tag,注意这里不包括 GrantedTags。
- 生效时机:ASC::ApplyGameplayEffectSpecToSelf 时调用 FActiveGameplayEffectsContainer::HasApplicationImmunityToSpec,其中遍历现有的所有 buff,如果新加 buff 满足某个现有 buff 的免疫 / 阻塞条件,则新 buff 不可施加。
- GrantedApplicationImmunityQuery
- 功能:阻塞特定类型的 buff 不能施加,Query 功能更加强大一些。
- 生效时机:FActiveGameplayEffectsContainer::HasApplicationImmunityToSpec,至于 GEQuery 如何匹配,查阅 FGameplayEffectQuery::Matches(GESpec)。
属性修改
修改属性是 buff 模块中重要的内容,主要分为 Modifiers 和 Executions 两块。
Modifiers
一个修改配置数组 TArray
,具体单个配置中指定要修改的属性、修改量、修改方式 (加减乘除或覆盖),底层对相同的属性的 buff 统一计算。
- Instant 和周期性 Buff
- 他俩直接修改属性基础值,执行链路 FActiveGameplayEffectsContainer::ExecuteActiveEffectsFrom() -> InternalExecuteMod() -> ApplyModToAttribute() -> FAggregator::StaticExecModOnBaseValue() 拿到属性当前的基础值 (Base) 根据修改配置算出新值覆盖基础值。
- 算法 NewBase = CurrentBase +-*/ ModifierMagnitude。
- 他俩直接修改属性基础值,执行链路 FActiveGameplayEffectsContainer::ExecuteActiveEffectsFrom() -> InternalExecuteMod() -> ApplyModToAttribute() -> FAggregator::StaticExecModOnBaseValue() 拿到属性当前的基础值 (Base) 根据修改配置算出新值覆盖基础值。
- HasDuration 和 Infinite buff
无周期的持续性 buff 通过 Aggregator 机制来操作属性。
- Aggregator
- Aggregator 是无周期持续性 buff 修改属性的核心机制。
- 聚合器:核心的设计理念是每一个属性对应一个修改聚合器,每个聚合器当中存在一个 ModChannels 容器用来容纳不同 Channel 的修改(如 Base/Current)。
- 修改通道:ModChannels 内维护有一个 Channel 类型到聚合修改 Channel 的字典 TMap<EGameplayModEvaluationChannel, FAggregatorModChannel> ModChannelsMap,这里可以简单理解为聚合器当中对于修改属性有不同的流水线。
- 通道聚合修改:FAggregatorModChannel 是真正聚合修改所在,内部有一个二维数组,加减乘除覆盖不同操作类型的修改都有其对应的一个数组来存放。不同流水线的计算最终也由 FAggregatorModChannel 完成,它汇总自己肚子里的所有修改,算出最终值交给上层去更新属性。
- 所有 buff 对于同一属性的修改都会聚合到一个聚合器当中,需要更新属性时,遍历所有有效的 Mod,根据不同的修改方式,用统一的计算公式计算并最终更新属性值。
- 属性聚合流程:
- 施加时寻找创建对应属性的聚合器:AddActiveGameplayEffectGrantedTagsAndModifiers -> FindOrCreateAttributeAggregator(FGameplayAttribute),所有的聚合器缓存在 FActiveGameplayEffectContainer.AttributeAggregatorMap 字典当中。
- 向对应属性聚合器 - 对应通道 - 对应操作类型桶当中塞入新加 buff 配置的修改 FAggregatorMod:AddAggregatorMod(EvaluatedMagnitude, ModOp, ModChannel, ...)
- 属性计算更新流程:
- 每当聚合器当中塞入 / 移除 Mod / 属性基础值发生改变时会触发 FAggregator::BroadcastOnDirty(),一旦聚合器触发 Dirty,会立即回调 FActiveGameplayEffectsContainer::OnAttributeAggregatorDirty() -> Aggregator->Evaluate() 重新逐通道计算属性值并更新,注意这里更新的是当前值。
- 通道内算法:
- Adds = Add1 + Add2 + ...
- Multis = 1 + (Multi1 - 1) + (Multi2 - 1) + ...
- Divs = 1 + (Div1 - 1) + (Div2 - 1) + ...
- FinalValue = ModOp != Override ? (BaseValue + Adds) * Multis / Divs : OverrideValue
- 划分多通道的意义:让项目能够定制属性算法,修改默认单通道的先加后乘再除,比如通道 0 放乘法,通道 1 放除法,通道 2 放加法,那么公式就变成了
- FinalValue = ModOp != Override ? BaseValue * Multis / Divs + Adds : OverrideValue
- Aggregator 是无周期持续性 buff 修改属性的核心机制。
- Aggregator
Executions
一个自定义修改数组 TArray
,允许在施加移除 buff 时执行项目自定义的逻辑。
- 引擎原生代码中,仅在 Instant 和周期性 Buff 中执行 Executions 逻辑。
- FGameplayEffectExecutionDefinition.CalculationClass 是自定义计算类,从 CDO 上执行 Execute(),所以如果想要自定义修改算法,就继承类重写实现 Execute()。执行时会塞入一个 FGameplayEffectCustomExecutionOutput 参数,允许上层 “夹带私货”,把自己准备的 Modifiers 塞入到 OutputModifiers 当中,底层如果发现有塞入,也会执行 InternalExecuteMod() 生效夹带进来的修改。
- 如果想要非一次性非周期性的 buff 也能生效 Executions,可以在 AddActiveGameplayEffectGrantedTagsAndModifiers() 和 RemoveActiveGameplayEffectGrantedTagsAndModifiers() 当中参考 ExecuteActiveEffectsFrom() 补充逻辑。
叠层与溢出
Stacking
Stacking 就是 "同一类 Buff 被多次施加时,合并成一个多层的 buff 实例,而不是各过各的以不同实例存在"。它是 GAS 用来表达 "X 层中毒 3 层易伤 5 层狂暴" 这种可叠加、可查层数的 Buff 模型的基础机制。
-
StackingType:叠层类型有三种
EGameplayEffectStackingType 功能 None 不叠层,多个相同 buff 拥有不同实例,各自独立存在 AggregateBySource 根据来源叠层,比如 A 和 B 都对 C 施加了相同 buff,那么各自叠层,C 上有两个 buff, 来自 A 的 buff 层数为 1,来自 B 的 buff 层数为 1 AggregateByTarget 根据目标叠层,A 和 B 都对 C 施加了相同 buff,那么 C 上有一个 buff,层数为 2 -
StackLimitCount:叠层上限
- 如果 GE 模板配置了使用叠层,施加非一次性 buff 时会先在现有 buff 中搜索可叠层的实例(GE 相同,按照目标叠层或者按照来源叠层且两个 buff 来源相同)。
- 如果已经存在的实例叠层数已经达到了 StackLimitCount,如果配置了拒绝溢出 bDenyOverflowApplication = true,那么新的施加失败。
- 否则,不创建新的 buff 实例,直接修改已经存在的可叠层实例的层数,调用 OnStackCountChange() -> UpdateAllAggregatorModMagnitudes() 重新计算并更新属性值。
- 叠层 buff 如何计算属性
- 周期性 buff 叠层周期修改属性执行 ExecuteActiveEffectsFrom() 构造修改数据 FGameplayModifierEvaluatedData 时会根据叠层值 EffectSpec.StackCount 计算修改量 (FGameplayEffectSpec::GetModifierMagnitude(bFactorInStackCount=true))。
- 无周期持续性 buff:在成功叠层时执行 OnStackCountChange() -> UpdateAllAggregatorModMagnitudes() 内部会将叠层 buff 实例对应的 Mods 从聚合器中移除重建,重建 AddMod 时会考虑 StackCount 传入叠层的修改量给 FAggregatorMod.EvaluatedMagnitude,后续计算就带上了叠层。
- 叠层修改量的计算在 GameplayEffectUtilities::ComputeStackedModifierMagnitude() 中实现,
- 如果 GE 模板配置了使用叠层,施加非一次性 buff 时会先在现有 buff 中搜索可叠层的实例(GE 相同,按照目标叠层或者按照来源叠层且两个 buff 来源相同)。
Overflow
所谓溢出,是指叠层达到上限后还施加叠层 buff 后的行为。
- bDenyOverflowApplication:拒绝溢出,如果施加新的叠层 buff 时发现已经有可叠层的实例达到了 StackLimitCount,如果拒绝溢出,那么新的 buff 将无法施加。如果不拒绝那么该 EffectSpec 可以正常施加,但是 StackCount 不会增长,ActiveGE.Spec 会正常更新。
- bClearStackOnOverflow:只有在 bDenyOverflowApplication == true 的前提下,bClearStackOnOverflow 才有意义;两者同时为 true 时,溢出会导致现存叠层 buff 被整体移除(而不是简单拒绝本次施加)。
预测与同步
Buff 实例如何同步
- 存放所有 buff 实例的容器在 ASC 上通过属性同步的方式同步到所有客户端
// AbilitySystemComponent.h /** Contains all of the gameplay effects that are currently active on this component */ UPROPERTY(Replicated) FActiveGameplayEffectsContainer ActiveGameplayEffects;// UAbilitySystemComponent::GetLifetimeReplicatedProps FDoRepLifetimeParams Params; Params.bIsPushBased = true; ... DOREPLIFETIME_WITH_PARAMS_FAST(UAbilitySystemComponent, ActiveGameplayEffects, Params); - 容器通过 FastArray 的同步机制,优化大数组高频局部修改的比对和同步开销。
如何预测施加
借助 PredictionKey 的机制,主端在施加 buff 时传入有效 Key 就可以本地预测先施加 buff 作用效果。
- 示例预测代码
FScopedPredictionWindow ScopedPrediction(ASC, IsPredictingClient()); // 预测窗口 ASC->ApplyGameplayEffectSpecToSelf(EffectSpec, ASC->GetPredictionKeyForNewAction()); - NOTE:根据 PredictionKey 的机制,想要预测完整生效还需要将 Key 发到服务器跑相同的逻辑,才能执行后续的确认或者拒绝流程。
- 周期性 buff 不能预测:周期性 buff 是循环执行属性基础值修改,一旦预测失败,主端移除 buff,属性是很难或者无法恢复的,此外每次周期性修改也没有与之对应的 Key。
- 一次性 Instant buff 当作 Infinite buff 来预测:主端预测施加一次性 buff 时在主端会将其视为是永久的 buff,预测成功和失败后主端本地预测的那份实例会移除。
- 为什么要在客户端当作永久 buff
- 一次性 buff 是无状态的,所谓无状态就是一次计算修改属性基础值,不会创建 ActiveGE 实例加入到 buff 容器。
- 既然是无状态的,那么就不会记录 buff 实例以及修改量,那么也就无法回滚。
- 一旦当作持续的 buff,主端就会创建 buff 实例缓存,并绑定预测 key 的 CaughtUp/Reject 的事件去移除本地预测的修改。这次只要移除聚合器当中的 Mod 就实现了回滚不是么。
- 为什么要在客户端当作永久 buff
能否预测移除
结论是不能。
- GAS 的 buff 预测的设计思路是主端通过 Key 预测,主端绑定 Key 处理事件,无论预测成功与否,服务器都会通知到客户端移除掉预测的 buff 实例,主端主动移除实际上破坏了这一设计。
- 一次性 buff 被视为永久 buff 为的就是预测失败时能够回滚,如果主端主动移除,也破坏了这一设计。
- 移除操作是不带 Key 的,如果客户端移除了,服务器没有移除,那么双端 buff 实例不一致,会导致诸多数值和表现问题。
- 所以,请确保仅在服务器端调用 Remove 相关接口!
主端预测成功与失败处理
// GameplayAbilities/Source/GameplayAbilities/Private/GameplayEffect.cpp
FActiveGameplayEffect* FActiveGameplayEffectsContainer::ApplyGameplayEffectSpec(const FGameplayEffectSpec& Spec, FPredictionKey& InPredictionKey, bool& bFoundExistingStackableGE)
{// ...if (InPredictionKey.IsLocalClientKey() == false || IsNetAuthority()) // Clients predicting a GameplayEffect must not call MarkItemDirty{MarkItemDirty(*AppliedActiveGE);ABILITY_LOG(Verbose, TEXT("Added GE: %s. ReplicationID: %d. Key: %d. PredictionLey: %d"), *AppliedActiveGE->Spec.Def->GetName(), AppliedActiveGE->ReplicationID, AppliedActiveGE->ReplicationKey, InPredictionKey.Current);}else{// Clients predicting should call MarkArrayDirty to force the internal replication map to be rebuilt.MarkArrayDirty();// 这里主端预测施加成功后,绑定预测 Key 的事件,无论确认 / 拒绝,都要移除掉本地预测的 buff 实例。// Once replicated state has caught up to this prediction key, we must remove this gameplay effect.InPredictionKey.NewRejectOrCaughtUpDelegate(FPredictionKeyEvent::CreateUObject(Owner, &UAbilitySystemComponent::RemoveActiveGameplayEffect_NoReturn, AppliedActiveGE->Handle, -1));}// ...
}
锁机制
FScopedActiveGameplayEffectLock
- 核心功能:手动锁住容器数组的增删,避免在使用缓存的 buff 实例的结构体指针过程中,容器数组发生改变,导致野指针即崩溃。即 推迟所有数组增删操作直到没有逻辑持有锁。
- 常见用法:在遍历 ActiveGameplayEffects 容器或者拿到容器里某个 ActiveGameplayEffect * 实例指针操作 前,使用 GAMEPLAYEFFECT_SCOPE_LOCK() 宏手动锁住容器。
- 实现原理
- FActiveGameplayEffectsContainer 内部封装了一个数组 TArray
GameplayEffects_Internal ,用来装当前的 buff 实例。 - 外部获取 buff 实例都是通过形如 GetActiveGameplayEffect() 的接口,返回的是一个数组内的结构体指针。如果在获取到指针 / 遍历数组过程中数组发送改变,那么对于缓存的指针的操作会变得非常危险,很容易出现崩溃和未定义行为。
- 容器内部存了一个计数锁 ScopedLockCount,每当通过宏或者直接创建 FScopedActiveGameplayEffectLock 对象构造时,调用 Container.IncrementLock() 自增锁计数。离开作用域局部对象析构时调用 Container.DecrementLock() 自减锁计数。
- 其他逻辑中如果想要添加数组元素,先判断 ScopedLockCount > 0,如果已经被锁,那么就不能直接操作数组。而是将要添加的 ActiveGE 挂到一个链表末尾(PendingGameplayEffectNext 用来存储当前链表尾地址)。
- 其他逻辑如果想要删除数组元素,先将 ActiveGE.IsPendingRemove 置为 true 标记待删除,然后判断是否被锁,如果被锁则不直接移除,而是记录 PendingRemoves++,延迟移除。
- 被锁的 Pending 操作,在解锁执行 FActiveGameplayEffectsContainer::DecrementLock() 内部会判断锁计数是否归 0,如果归 0 代表已经完全解锁了。此时遍历链表一股脑把待添加和待移除的buff实例都添加移除到容器内部 GameplayEffects_Internal 数组当中。
- FActiveGameplayEffectsContainer 内部封装了一个数组 TArray
FScopedAggregatorOnDirtyBatch
- 核心功能:推迟 Aggregator::BroadcastOnDirty() 广播操作直到没有逻辑持有锁。避免批量添加或者移除相同属性的 Mods 时,重复多次触发相同聚合器的 Dirty 事件导致无谓的属性计算与更新。
- 常见用法:在所需的作用域内实例化 FScopedAggregatorOnDirtyBatch 对象,或者使用宏 AGGREGATOR_BATCH_SCOPE()
- 实现原理
- FScopedAggregatorOnDirtyBatch 对象构造时自增锁计数 GlobalBatchCount,析构时自减并检查计数归 0,如果归 0 那就将缓存推迟的聚合器 Dirty 事件统一触发广播。
