说明
- 本文介绍一种支持联网同步的、基于 Unreal Lyra 的 Gameplay Ability System (以下简称 GAS) 技能流程实践方案。
- 本文所述方案和流程虽基于 Lyra ,但主要实现技术还是 GAS ,即便不使用 Lyra 工程,相关流程也完全有效。
- 本文所述方案只是当前可行方案的一种,不代表最佳实践,更非唯一方案。
- 本文基于 Unreal Engine 5.2,其中的 Lyra 为引擎自带示例游戏工程。工程中的资源(主要是工程和插件 Content 目录中的资源)从虚幻商城免费工程 Lyra 中得来。引擎(含工程)源码地址见文末相关链接。
- 本文应用的前提在于知道如何进行 GAS 技能的添加,对此如有疑问,可查阅:基于 Unreal Lyra 的 GAS 技能添加流程。
- 为行文方便,根据惯例,对部分名称做简称:GameplayAbility:GA、GameplayEffect:GE、GameplayCue:GC
效果
以下视频演示了2个技能的攻、受击全流程,包括一个伤害类技能,一个加 MP 的 BUFF 技能。大流程为:吟唱(动作、特效)、攻击(动作、特效)、飞行物、受击(伤害、飘字、特效)及 BUFF (特效、效果)等。

魔法类技能全流程展示https://www.zhihu.com/video/1672721454486626304流程
这里的技能流程基于传统的魔法即使战斗技能流程。
关键步骤流程图
关键步骤函数

实现
目标获取
主要基于 UAbilityTask_WaitTargetData 检测和获取目标。
关于 UAbilityTask_WaitTargetData 的使用
关键代码
void ULyraGameplayAbility_Skill::SelectTarget(FOnCompleteDelegate OnComplete)
{
UE_LOG(LogLyraAbilitySystem, Log, TEXT("===== ULyraGameplayAbility_Skill::SelectTarget ... "));
TargetCharacters = {};
TargetDataHandle = FGameplayAbilityTargetDataHandle();
OnTargetComplete = FOnTargetCompleteDelegate::CreateLambda(
[OnComplete, this](const FGameplayAbilityTargetDataHandle& TempGameplayAbilityTargetDataHandle)
{
UE_LOG(LogLyraAbilitySystem, Log, TEXT("===== ULyraGameplayAbility_Skill::SelectTarget end .targets:%d "),
TempGameplayAbilityTargetDataHandle.Num());
//提取目标,转换为角色,存入 TargetCharacters
for (int i = 0; i < TempGameplayAbilityTargetDataHandle.Num(); i++)
{
const auto TempTarget = TempGameplayAbilityTargetDataHandle.Get(i);
auto TempActors = TempTarget->GetActors();
for (auto TempActor : TempActors)
{
if (auto TempCharacter = Cast<ALyraCharacter>(TempActor))
{
TargetCharacters.Add(TempCharacter);
}
}
}
TargetDataHandle = TempGameplayAbilityTargetDataHandle;
// ReSharper disable once CppExpressionWithoutSideEffects
OnComplete.ExecuteIfBound();
});
UAbilityTask_WaitTargetData* WaitTargetDataTask = UAbilityTask_WaitTargetData::WaitTargetData(
this, NAME_None, EGameplayTargetingConfirmation::Instant, TargetClass);
AGameplayAbilityTargetActor* SpawnedActor;
WaitTargetDataTask->Cancelled.AddDynamic(this, &ULyraGameplayAbility_Skill::OnTargetCanceled);
WaitTargetDataTask->ValidData.AddDynamic(this, &ULyraGameplayAbility_Skill::OnTargetValidData);
WaitTargetDataTask->BeginSpawningActor(this, TargetClass, SpawnedActor);
//必须设置 StartLocation ,否则从0点开始表现会不对
FGameplayAbilityTargetingLocationInfo GameplayAbilityTargetingLocationInfo =
FGameplayAbilityTargetingLocationInfo();
GameplayAbilityTargetingLocationInfo.SourceActor = GetCurrentActorInfo()->AvatarActor.Get();
GameplayAbilityTargetingLocationInfo.LocationType = EGameplayAbilityTargetingLocationType::ActorTransform;
SpawnedActor->StartLocation = GameplayAbilityTargetingLocationInfo;
WaitTargetDataTask->FinishSpawningActor(this, SpawnedActor);
WaitTargetDataTask->ReadyForActivation();
}
说明
- 通过 UAbilityTask_WaitTargetData 的 ValidData 和 Cancelled 监听目标获取成功和取消的回调。
- 调用 UAbilityTask_WaitTargetData 的 BeginSpawningActor 和 FinishSpawningActor 生成目标选择物。
- 调用 UAbilityTask_WaitTargetData 的 ReadyForActivation 激活目标选择。
关于目标获取(AGameplayAbilityTargetActor)
关键代码
void ASQGameplayAbilityTargetActor::ConfirmTargetingAndContinue()
{
check(ShouldProduceTargetData());
if (!SourceActor)
{
return;
}
if (FromSelf)
{
//如果是从自身周围找,那么源就是自己
StartLocation.SourceActor = SourceActor.Get();
}
else
{
//如果不是从自身找,那就是从瞄准的目标周围找
FVector Location;
FRotator Rotation;
Cast<ACharacter>(SourceActor)->GetController()->GetPlayerViewPoint(Location, Rotation);
FCollisionQueryParams CollisionQueryParams;
CollisionQueryParams.TraceTag = NAME_None;
CollisionQueryParams.StatId = FCollisionQueryParams::GetUnknownStatId();
CollisionQueryParams.bTraceComplex = true;
CollisionQueryParams.AddIgnoredActor(SourceActor);
//射线检测,获取控制器朝向(一般亦为相机朝向)的目标
TArray<FHitResult> HitResults;
GetWorld()->LineTraceMultiByChannel(HitResults, Location, Location + Rotation.Vector() * 10000.f, ECC_Pawn,
CollisionQueryParams);
//TODO,可以进一步对结果进行过滤,比如过滤掉己方队友。当前过滤操作在技能GA那边也走了一遍。
StartLocation.SourceActor = HitResults.Num() > 0 ? HitResults[0].GetActor() : SourceActor;
}
FVector Origin = StartLocation.GetTargetingTransform().GetLocation();
TArray<TWeakObjectPtr<AActor>> AllTargets = PerformOverlap(Origin);
//按照离目标距离排序
AllTargets.Sort([Origin](const TWeakObjectPtr<AActor> A, const TWeakObjectPtr<AActor> B)
{
return FVector::Dist(A.Get()->GetActorLocation(), Origin) < FVector::Dist(
B.Get()->GetActorLocation(), Origin);
});
const auto SourceCharacter = Cast<ALyraCharacter>(SourceActor);
TArray<TWeakObjectPtr<AActor>> FilteredTargets;
for (auto TempTarget : AllTargets)
{
//超过目标上限了,则不继续寻找
if (MaxNum != 0 && FilteredTargets.Num() >= MaxNum)
{
break;
}
//目标必须为角色
const auto TempCharacter = Cast<ALyraCharacter>(TempTarget);
if (!TempCharacter->IsValidLowLevel())
{
continue;
}
//目标是自己,但配置要求不包括自己,跳过
if (!IncludeSelf && (TempCharacter == SourceActor))
{
continue;
}
//目标是队友,但是技能仅限敌人,跳过
if (TempCharacter->GetGenericTeamId() == SourceCharacter->GetGenericTeamId())
{
if (ForEnemy)
{
continue;
}
}
else //目标是敌人,但技能仅限友军,跳过
{
if (!ForEnemy)
{
continue;
}
}
FilteredTargets.Add(TempCharacter);
}
const FGameplayAbilityTargetDataHandle Handle = MakeTargetData(FilteredTargets, Origin);
TargetDataReadyDelegate.Broadcast(Handle);
}
说明
- 此处的 AGameplayAbilityTargetActor 稍加了封装,继承自 AGameplayAbilityTargetActor_Radius
- 通过 GetController()->GetPlayerViewPoint 获取到控制器(相机)的位置和朝向
- 通过 GetWorld()->LineTraceMultiByChannel 朝角色朝向的方向发射线,获取人物看向的目标
- 获取到所有目标后,通过 AGameplayAbilityTargetActor_Radius::MakeTargetData 生成 FGameplayAbilityTargetDataHandle ,通过 TargetDataReadyDelegate 广播出去,最终触发 UAbilityTask_WaitTargetData 的 ValidDataValidData 。
- 基于 TeamId 做敌我的判断需要考虑联网情况下我方 TeamId 可能是蓝方(不为1),否则可能打到自己人。
技能吟唱
播放 Montage ,完成技能吟唱。Montage 中可以插入特效,以做吟唱特效。
关于 montage 的播放
关键代码
const auto InWait = UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(
this, NAME_None, SingMontage.LoadSynchronous());
// InWait->OnBlendOut.AddDynamic(this, &OnBlendOut);
InWait->OnCompleted.AddDynamic(this, &ULyraGameplayAbility_Skill::OnSingMontageOnCompleted);
InWait->OnInterrupted.AddDynamic(this, &ULyraGameplayAbility_Skill::OnSingMontageInterrupted);
InWait->OnCancelled.AddDynamic(this, &ULyraGameplayAbility_Skill::OnSingMontageCancelled);
InWait->Activate();
说明
- 主要需要通过 UAbilityTask_PlayMontageAndWait 来播放 montage ,直接使用 UAnimInstance::Montage_Play 播放 montage 可能导致联网异常(表现如其他 client 看不到我方 client 的动作播放)
- 调用 UAbilityTask_PlayMontageAndWait::Activate 以在 C++ 触发 montage 的播放。
- 可在 montage 上插入 anim notify 等来播放音效和特效。
在 montage 上插入 anim notify 等来播放音效和特效
- 合理设置 montage group 的 defaultgroup ,如果应该用 fullbody 或 upperbody 的结果用了 defaultslot 的话,可能导致 montage 无法正常播放(可能不会有提示)
- UAbilityTask_PlayMontageAndWait->OnCompleted.AddDynamic 回调在 development 下无法命中断点,如需断点,可切换到 debug 。
飞行物
主要是借助 UProjectileMovementComponent 组件让物体飞行
Actor 的初始化
关键代码
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bTickEvenWhenPaused = true;
PrimaryActorTick.TickGroup = TG_PostUpdateWork;
PrimaryActorTick.bStartWithTickEnabled = true;
bAllowTickBeforeBeginPlay = true;
SetActorEnableCollision(true);
CollisionComponent = CreateDefaultSubobject<USphereComponent>(TEXT("Sphere"));
CollisionComponent->SetupAttachment(RootComponent);
CollisionComponent->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
CollisionComponent->SetCollisionProfileName(TEXT("Trigger"));
SetRootComponent(CollisionComponent);
CollisionComponent->OnComponentBeginOverlap.
AddDynamic(this, &ASQProjectileMoveActor::OnComponentBeginOverlapHandle);
CollisionComponent->OnComponentEndOverlap.
AddDynamic(this, &ASQProjectileMoveActor::OnComponentEndOverlapHandle);
StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMesh"));
StaticMesh->SetupAttachment(RootComponent);
StaticMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
StaticMesh->SetGenerateOverlapEvents(false);
ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovement"));
ProjectileMovementComponent->Deactivate();
ProjectileMovementComponent->ProjectileGravityScale = 0.0f;
ProjectileMovementComponent->bIsHomingProjectile = true;
ProjectileMovementComponent->InitialSpeed = 5000;
ProjectileMovementComponent->HomingAccelerationMagnitude = 5000;
说明
- USphereComponent 的目的在于让飞行物可以响应碰撞,注意设置其碰撞可用性、Profile及组件 Overlap 回调。
- UStaticMeshComponent 的目的在于让飞行物可以有不同的样子,注意要关闭其碰撞和事件,以免干扰。
- UProjectileMovementComponent 是飞行关键,勾选 bIsHomingProjectile 以让物体可以朝目标飞行。先 Deactivate ,等设置了目标之后再 Activate 。
- 合理设置 HomingAccelerationMagnitude ,否则可能目标不会追人。
- 合理设置最大速度,否则可能导致飞行物来回弹。
飞行物的创建
const auto Projectiler = GetWorld()->SpawnActor<ASQProjectileMoveActor>(
ProjectileMoveActor, CurrentActor->GetActorLocation(), CurrentActor->GetActorRotation(), SpawnParameters);
目标创建
ProjectileMovementComponent->HomingTargetComponent = InputTargetComponent;
合理设置 HomingTargetComponent 为目标身上的某个部位的组件,可让飞行物朝着理想的位置飞行(此处设置为 UAimAssistTargetComponent ,恰为目标中间部位)。
激活
ProjectileMovementComponent->Activate();
攻击
播放攻击动作和特效。主要用于实现类似施法之后在目标附近播放一个攻击特效。
攻击动作的播放跟吟唱一样,还是基于 UAbilityTask_PlayMontageAndWait ,此处不赘述。
动作播放完毕后,给目标添加一些 GameplayEffect
BP_ApplyGameplayEffectToTarget(Target, TempGameplayEffectClass.LoadSynchronous());
受击
受击在技能主流程中的主要逻辑是,给目标添加受击 GameplayEffect
在受击 GE 中给目标添加属性变化(如扣血或者加蓝等)和受击效果 GC。
伤害(此处基于 UGameplayEffectExecutionCalculation 施加,直接扣也可以)
GE
GC
GC
在受击 GC 中让受击目标播放受击动作、飘字、特效和音效。
伤害飘字(此处基于 ULyraNumberPopComponent )
伤害飘字
受击音效
受击音效
受击动作

受击动作
备注
- 技能 ActivateAbility 时记得调用父类同名方法,否则(倘若也没有主动调用 CommitAbility的话)可能导致技能配置了 Cost 但无效。
Cost 判定
- 可通过在对应 GE 中设置 stack 来控制 BUFF 能叠加多少次(如仅限一次,以免重复添加同样的效果)
Stack
- 目标属性的增减应该用 ApplyModToAttribute 而不是 SetNumericAttributeBase ,否则在技能即可 cost 某属性又可增加该属性时,可能导致该属性增减异常。
- 可通过在控制台输入 showdebug ability 来可视化调试技能
showdebug ability
- 注意伤害 GE 中配置的 GC 的 Magnitude Attribute 需要跟实际变化的属性的类型一致,否则可能导致对应 GC 中的参数不对。
区别
之前写过一篇类似的文章:兼容联网环境的 Unreal 战斗攻受击全流程使用实践总结,这里说下两者的主要联系和区别。
相同点
- 支持联网
- 基于 GAS
- 受击特效的播放均由受击 GC 控制。
区别
- 流程上
- 本文所属方案涉及的流程更全面,包括了吟唱等动作流程。
- 受击
- 前文中的受击是基于子弹等的碰撞触发的,命中目标是前提。命中后基于 GameplayEffect 触发受击 GA ,在受击 GA 中播放动画,在受击 GE 中播放受击 GC。
- 本文受击是直接在攻击技能 GA 中判定的,是基于攻击 GA 中做的目标检测获取到的目标,获取到目标后目标必定或受到伤害(或BUFF)。受击动画、特效等表现的播放由受击 GC 控制,更符合纯表现的东西由表现类控制的原则。
总的来说,虽然都有可取之处,但更推荐最新版本。
相关链接
**声明:**本文来自公众号:GameDevLearning),转载请附上原文链接(https://mp.weixin.qq.com/s/FwKCzhJZp3_l4K-wmJ-x5w)及本声明。