前言

上回说到,FireGun 这个类有一个名为 DamageGameplayEffect 的 GE 用于伤害计算以及击中特效播放,并且最终通过 Projectile->DamageEffectSpecHandle 的形式将该 GE 传递给了 Projectile。不难想象最终一定会由子弹本身的碰撞等事件来执行该 GE 以计算伤害(DamageExecCalc)及播放 GC。

1
2
UPROPERTY(BlueprintReadOnly, EditAnywhere)
TSubclassOf<UGameplayEffect> DamageGameplayEffect;

上一节主要分析了前者,即 GDDamageExecCalculation 的实现,本节就从 GameplayCue(GC)开始分析。

GameplayCue

首先来看一下文档中关于 GC 的解释:

GASDocumentation_Chinese:GameplayCue

GameplayCue(GC) 执行非游戏逻辑相关的功能, 像音效, 粒子效果, 镜头抖动等等。 GameplayCue 一般是可同步(除非在客户端明确执行(Executed), 添加(Added)移除(Removed))和可预测的。

我们可以在 ASC 中通过发送一个强制带有 “GameplayCue” 父名的相应 GameplayTagGameplayCueManager 的事件类型(Execute, Add或Remove)来触发GameplayCueGameplayCueNotify 对象和其他实现 IGameplayCueInterface 的 Actor 可以基于 GameplayCueGameplayTag(GameplayCueTag) 来订阅(Subscribe)这些事件。

Note: 再次强调, GameplayCueGameplayTag 需要以 GameplayCue 为开头, 举例来说, 一个有效的 GameplayCueGameplayTag 可能是 GameplayCue.A.B.C

有两个GameplayCueNotify类, StaticActor。它们各自响应不同的事件, 并且不同的 GameplayEffect 类型可以触发它们,根据你的逻辑重写相关的事件。

GameplayCue类 事件 GameplayEffect类型 描述
GameplayCueNotify_Static Execute Instant或Periodic Static GameplayCueNotify直接操作ClassDefaultObject(意味着没有实例)并且对于一次性效果(像击打伤害)是极好的.
GameplayCueNotify_Actor Add或Remove Duration或Infinite Actor GameplayCueNotify会在添加(Added)时生成一个新的实例, 因为其是实例化的, 所以可以随时间推移执行操作直到被移除(Removed). 这对循环的声音和粒子效果是很好的, 其会在持续(Duration)或无限(Infinite)GameplayEffect被移除或手动调用移除时移除. 其也自带选项来管理允许同时添加(Added)多少个, 因此多个相同效果的应用只启用一次声音或粒子效果.

默认情况下, 游戏开始时 GameplayCueManager 会扫描游戏的全部目录以寻找 GameplayCueNotify 并将其加载进内存. 我们可以设置 DefaultGame.ini 来修改 GameplayCueManager 的扫描路径。

查看 GE_GunDamage 这个 GE 蓝图,会发现在 Display 中指定了一个 Tag 为 GameplayCue.Hero.FireGun.Impact 的 GC。配置在这里的 GC 会在 GE 生效时触发。

image.png

上文中也说过一个合法的 GC 的 Tag 必须以 GameplayCue.A.B.C 的形式来组织,游戏开始时 GameplayCueManager 会扫描全部 GC 并加载,GC 在创建时就需要绑定一个 Tag,因此这样就完成了 Tag -> GC 的对应,在 GE 的 Display 中配置相应的 Tag 就可以播放对应的特效或声效了。

在 FireGun 文件夹中,可以找到一个名为 GC_FireGunImpact 的 GC,打开该蓝图就会发现相应的 Tag 配置的刚好是 GameplayCue.Hero.FireGun.Impact,也就是说在 GE_GunDamage 生效时就会播放这个 GC 了。

image.png

该 GC 继承自 GameplayCueNotify_Static 并且重写了 OnExcute() 函数,查看蓝图中的逻辑就会发现该 GC 会提取 EffectContext 信息并在 HitResult.Location 的位置播放一个特效,该效果就是打击特效了。

image.png

博主在之后加了一个音效播放(

GE_GunDamage 的生效方式

上文中我们说到,FireGun 这个 GA(GameplayAbility)最终会生成一颗子弹,并把 GE_GunDamage 包装为 DamageEffectSpecHandle 传给 Projectile,而这个 GE 要负责伤害的计算,以及播放一个 GC;那么,这个 GE 是什么时候生效的呢?这就需要回到 BP_GunProjectile 中来了。

查看该蓝图,貌似逻辑较为复杂,我们一点一点分析。

image.png

首先,在 BeginPlay() 时通过 RangeSpeed 设置了子弹的 LifeSpan

image.png

剩下的都是发生碰撞时的执行逻辑,这里可以看出子弹不会攻击释放者。

image.png

这里获取了被击中目标的 ASC,可以看出,只有在 Server 端才会触发之后的逻辑,Client 会直接销毁子弹。即,GE 只会发生在 Server 上,Client 通过 Attribute 同步的方式接收信息。

image.png

这部分的目的就是为了获取到被击中的方向并赋给 FinalHit,以播放不同方向的受击动画。

image.png

最后这部分就是 GE 真正生效的地方了,我们终于用到了 DamageEffectSpecHandle 并且将 FinalHit 设置给了它的 EffectContext 并且最终将该 GE 作用于被击中的 Target,最后再销毁该子弹。

GE 生效的过程我们已经分析过,分为计算伤害与播放 GC 两个部分。

属性修改、受击动画

现在,我们的 Projectile 中携带的 GE 终于对 Target 生效了,播放了击中的烟雾特效并且修改了 Target 的 Damage 属性,接下来的问题是,如何将 Damage 属性的改变反映到 Health 中呢? 这部分的逻辑在上一节提到过的 UGDAttributeSetBase::PostGameplayEffectExecute 中完成。

Meta Attribute

在分析这部分内容之前,需要先了解以下 Damage 这个特殊的属性,GDAttriibuteSetBase.h 中对于该属性是这样注释的:

1
2
3
4
5
// Damage is a meta attribute used by the DamageExecution to calculate final damage, 
// which then turns into -Health. Temporary value that only exists on the Server. Not replicated.
UPROPERTY(BlueprintReadOnly, Category = "Damage", meta = (HideFromLevelInfos))
FGameplayAttributeData Damage;
ATTRIBUTE_ACCESSORS(UGDAttributeSetBase, Damage)

这是一个“临时变量”并且只存在于 Server 端,更重要的是,这是一个 meta Attribute

查看 GASDocumentation_Chinese:metaAttribute 的解释:

一些 Attribute 被视为占位符, 其是用于预计和 Attribute 交互的临时值, 这些 Attribute 被叫做 Meta Attribute。例如, 我们通常定义伤害值为 Meta Attribute, 使用伤害值 Meta Attribute 作为占位符, 而不是使用 GameplayEffect 直接修改生命值 Attribute, 使用这种方法, 伤害值就可以在 GameplayEffectExecutionCalculation 中由 buff 和 debuff 修改, 并且可以在 AttributeSet 中进一步操作, 例如, 在最终将生命值减去伤害值之前, 要将伤害值减去当前的护盾值. 伤害值 Meta AttributeGameplayEffect 之间不是持久化的, 并且可以被任何一方重写. Meta Attribute 一般是不可同步的。

Meta Attribute对于在"我们应该造成多少伤害?"和"我们该如何处理伤害值?"这种问题之中的伤害值和治疗值做了很好的解构, 这种解构意味着 GameplayEffectExecutionCalculation 无需了解目标是如何处理伤害值的。继续看伤害值的例子, GameplayEffect 确定造成多少伤害, 之后 AttributeSet 决定如何使用该伤害值, 不是所有的 Character 都有相同的Attribute, 特别是使用了 AttributeSet 子类的话, AttributeSet 基类可能只有一个生命值 Attribute, 但是它的子类可能增加了一个护盾值 Attribute, 拥有护盾值 Attribute 的子类 AttributeSet 可能会以不同于 AttributeSet 基类的方式分配收到的伤害。

尽管 Meta Attribute 是一个很好的设计模式, 但其并不是强制使用的. 如果你只有一个用于所有伤害实例的 Execution Calculation 和一个所有 Character 共用的 AttributeSet 类, 那么你就可以在 Exeuction Calculation 中分配伤害到生命, 护盾等等, 并直接修改那些 Attribute, 这种方式你只会丢失灵活性, 但总体上并无大碍。

--------------- 分界线 ------------------

可以看出,Damage 属性是对于伤害与生命值等属性之间的又一次解耦,将伤害值本身抽取出来成为一个属性,这样可以处理更复杂的情况。虽然 Damage 不会同步到客户端,但 Damage 对于 Health 的修改会由 Health 本身同步到客户端。

一般而言,在使用 GAS 的项目中,AttributeSet 都会有这样一个属性。

PostGameplayEffectExecute

回到这个函数中,在 案例分析一 中分析过,该函数在 GE 执行后触发,并且在调用时属性还没有被同步到客户端,可以在这里再对属性进行一些操作,不会造成二次同步的问题。

1
void UGDAttributeSetBase::PostGameplayEffectExecute(const FGameplayEffectModCallbackData & Data)

观察签名就会发现,该函数接受一个 FGameplayEffectModCallbackData& 的参数,想必是包含了 GE 的所有上下文信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FGameplayEffectContextHandle Context = Data.EffectSpec.GetContext();
UAbilitySystemComponent* Source = Context.GetOriginalInstigatorAbilitySystemComponent();
const FGameplayTagContainer& SourceTags = *Data.EffectSpec.CapturedSourceTags.GetAggregatedTags();
FGameplayTagContainer SpecAssetTags;
Data.EffectSpec.GetAllAssetTags(SpecAssetTags);

// Get the Target actor, which should be our owner
AActor* TargetActor = nullptr;
AController* TargetController = nullptr;
AGDCharacterBase* TargetCharacter = nullptr;
if (Data.Target.AbilityActorInfo.IsValid() && Data.Target.AbilityActorInfo->AvatarActor.IsValid())
{
TargetActor = Data.Target.AbilityActorInfo->AvatarActor.Get();
TargetController = Data.Target.AbilityActorInfo->PlayerController.Get();
TargetCharacter = Cast<AGDCharacterBase>(TargetActor);
}

// Get the Source actor
// ...

可以看出,Data 中不仅包含了 FGameplayEffectContextHandle,还包含了关于 Target 与 Source 的信息,可以处理许多逻辑。

这里先就 Data.EvaluatedData.Attribute == GetDamageAttribute() 的情况进行分析。

1
2
3
// Store a local copy of the amount of damage done and clear the damage attribute
const float LocalDamageDone = GetDamage();
SetDamage(0.f);

可以看出这里储存了一份 Damage 的备份,并把属性中的 Damage 清零。

1
2
3
// Apply the health change and then clamp it
const float NewHealth = GetHealth() - LocalDamageDone;
SetHealth(FMath::Clamp(NewHealth, 0.0f, GetMaxHealth()));

之后通过 LocalDamageDone 设置了 Health 的值,完成了扣血的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Play HitReact animation and sound with a multicast RPC.
const FHitResult* Hit = Data.EffectSpec.GetContext().GetHitResult();

if (Hit)
{
EGDHitReactDirection HitDirection = TargetCharacter->GetHitReactDirection(Data.EffectSpec.GetContext().GetHitResult()->Location);
switch (HitDirection)
{
case EGDHitReactDirection::Left:
TargetCharacter->PlayHitReact(HitDirectionLeftTag, SourceCharacter);
break;
case EGDHitReactDirection::Front:
TargetCharacter->PlayHitReact(HitDirectionFrontTag, SourceCharacter);
break;
case EGDHitReactDirection::Right:
TargetCharacter->PlayHitReact(HitDirectionRightTag, SourceCharacter);
break;
case EGDHitReactDirection::Back:
TargetCharacter->PlayHitReact(HitDirectionBackTag, SourceCharacter);
break;
}
}
else
{
// No hit result. Default to front.
TargetCharacter->PlayHitReact(HitDirectionFrontTag, SourceCharacter);
}

此外,这里还获取了 context 中储存的 HitResult 以得到受击方向。GetHitReactDirection 定义在 GDCharacterBase.h 中,输出一个代表方向的枚举值。PlayHitReact 也定义在该类中,最终会通知 ABP_Hero(Animation BluePrint)播放对应的受击动画。

1
2
3
4
5
6
7
8
9
// Show damage number for the Source player unless it was self damage
if (SourceActor != TargetActor)
{
AGDPlayerController* PC = Cast<AGDPlayerController>(SourceController);
if (PC)
{
PC->ShowDamageNumber(LocalDamageDone, TargetCharacter);
}
}

除此之外,还函数还处理了伤害的 PopupText,会调用 AGDPlayerController::ShowDamageNumber 以显示伤害数字。最后还处理了增加攻击者的经验及金币等的情况,这些功能都不是很重要,这里就不详细分析了。

总的来看,在 PostGameplayEffectExecute 完成了 Damage -> -Health 的计算,并且处理了许多显示上的逻辑,如播放受击动画、伤害漂字等。

还有高手?

没有高手了,FireGun 的整个 GA 流程已经被我们分析完毕,现在就来梳理一下整个 GA 的作用流程。

首先,GDGA_FireGun 直接继承自 UGDGameplayAbility,在 UGDGA_FireGun::ActivateAbility 中,通过UGDAT_PlayMontageAndWaitForEvent::PlayMontageAndWaitForEvent 的方式播放射击动画并等待动画事件。

在 Montage 中,共触发了两个事件,分别为生成一个 Projectile(BP_GunProjectile) 以及结束 Ability。GDGA_FireGun 中有一个 Damage 属性代表子弹的伤害,同时存放了一个实际赋值为 GE_GunDamage 的 GE,该 GE 最终会实例化赋给生成的子弹,负责计算伤害以及播放击中效果的 GC。

BP_GunProjectile 中设置了碰撞事件,会在被击中的角色的 ASC 上执行该 GE 以造成伤害以及播放特效。此外,还额外传递了 HitResultProjectile->DamageEffectSpecHandle 以保存击中方向的信息。

在被击中角色的 UGDAttributeSetBase::PostGameplayEffectExecute 中,会处理掉 Projectile 对 Damage 的改动,并进行实际的扣血操作,此外还会根据 HitResult 的方向来播放不同的受击动画以及处理伤害漂字等效果。

至此,FireGun 的整个流程也就分析完毕。