一套简单的网络同步友好的基于 Unreal Lyra 的 GAS 技能流程

Viewed 17

说明

  • 本文介绍一种支持联网同步的、基于 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)及本声明。

0 Answers