Post

Unreal: Gameplay Ability System(GAS) - 콤보 만들기

Unreal: Gameplay Ability System(GAS) - 콤보 만들기

GAS를 이용한 콤보 공격

애님 몽타주에서 애님 노티파이를 이용하면 GA에서 애니메이션에 따른 콤보 공격도 구현할 수 있다.

  • 공격 입력으로 콤보 GA 실행한다.
  • 콤보용 애님 몽타주 재생한다. 몽타주에는 설정된 시점에 Anim Notify를 전달한다.
  • Anim Notify가 Combat.Combo.Window.Stage2같은 이벤트를 전달해 다음 콤보 입력이 가능한 시점을 알린다.
  • 입력이 들어오면 다음 공격 섹션 예약한다.
  • 공격 판정 Notify가 소켓 위치와 데미지 이벤트를 전달한다.
  • GA에서는 트레이스를 실행하고 현재 몽타주 섹션에 맞는 GE를 피격 대상에게 적용한다.

먼저 여러 공격 애니메이션을 하나의 Anim Montage에 넣고 Stage1, Stage2, Stage3처럼 섹션을 나누어준다. 콤보 입력이 없을 때 다음 공격으로 자동 진행하지 않도록 Montage Sections의 연결은 모두 끊어둔다.

GA_MeleeChain에서는 몽타주를 에디터에서 지정할 수 있도록 변수를 만들고, Ability가 실행되면 UAbilityTask_PlayMontageAndWait로 재생한다. CommitAbility()가 실패하면 비용이나 쿨타임 조건을 만족하지 못한 것이므로 Ability를 바로 종료한다.

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
// GA_MeleeChain.h
UPROPERTY(EditDefaultsOnly, Category = "Animation")
TObjectPtr<UAnimMontage> AttackChainMontage;

// GA_MeleeChain.cpp
void UGA_MeleeChain::ActivateAbility(
    const FGameplayAbilitySpecHandle Handle,
    const FGameplayAbilityActorInfo* ActorInfo,
    const FGameplayAbilityActivationInfo ActivationInfo,
    const FGameplayEventData* TriggerEventData)
{
    if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
    {
        K2_EndAbility();
        return;
    }

    UAbilityTask_PlayMontageAndWait* MontageTask =
        UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(
            this, NAME_None, AttackChainMontage);

    MontageTask->OnBlendOut.AddDynamic(this, &UGA_MeleeChain::K2_EndAbility);
    MontageTask->OnCancelled.AddDynamic(this, &UGA_MeleeChain::K2_EndAbility);
    MontageTask->OnCompleted.AddDynamic(this, &UGA_MeleeChain::K2_EndAbility);
    MontageTask->OnInterrupted.AddDynamic(this, &UGA_MeleeChain::K2_EndAbility);
    MontageTask->ReadyForActivation();
}

K2_가 붙은 함수는 블루프린트에서 사용하기 위해 제공되는 함수이다. K2_EndAbility()는 별도의 C++ 매개변수 없이 블루프린트의 End Ability와 같은 방식으로 Ability를 종료할 수 있다.

같은 공격 Ability가 몽타주 재생 도중 중복 실행되는 것도 막아야 한다. 기본 공격을 나타내는 Ability.Attack.Melee 태그를 AbilityTags와 BlockAbilitiesWithTag에 함께 넣으면 실행 중인 기본 공격이 또 실행되는 것을 방지할 수 있다. 문자열 오타를 줄이기 위해 태그는 별도의 Blueprint Function Library에서 반환하도록 만들 수 있다.

1
2
3
4
5
6
7
8
9
10
11
FGameplayTag UCombatTagLibrary::GetMeleeAttackTag()
{
    return FGameplayTag::RequestGameplayTag("Ability.Attack.Melee");
}

UGA_MeleeChain::UGA_MeleeChain()
{
    const FGameplayTag MeleeAttackTag = UCombatTagLibrary::GetMeleeAttackTag();
    AbilityTags.AddTag(MeleeAttackTag);
    BlockAbilitiesWithTag.AddTag(MeleeAttackTag);
}

AbilityTask와 콤보 상태를 멤버 변수로 관리하기 때문에 GA의 Instancing Policy는 Instanced Per Actor로 설정한다. 그렇지 않으면 실행 중 인스턴스 상태를 저장하려 할 때 오류가 발생할 수 있다.

Anim Notify로 Gameplay Event 전달

콤보 입력 가능 시점은 애니메이션에 맞춰야 하므로 Anim Notify에서 Gameplay Event를 보내도록 만든다. Notify가 실행되면 Mesh의 Owner가 ASC를 사용하는지 확인하고, 설정된 EventTag를 SendGameplayEventToActor()로 전달한다.

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
// AN_EmitAbilityEvent.h
UPROPERTY(EditAnywhere, Category = "Gameplay Ability")
FGameplayTag AbilityEventTag;

// AN_EmitAbilityEvent.cpp
void UAN_EmitAbilityEvent::Notify(
    USkeletalMeshComponent* MeshComp,
    UAnimSequenceBase* Animation,
    const FAnimNotifyEventReference& EventReference)
{
    Super::Notify(MeshComp, Animation, EventReference);

    if (!MeshComp || !MeshComp->GetOwner())
    {
        return;
    }

    UAbilitySystemComponent* AbilitySystem =
        UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(MeshComp->GetOwner());
    if (!AbilitySystem)
    {
        return;
    }

    UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(
        MeshComp->GetOwner(), AbilityEventTag, FGameplayEventData());
}

몽타주에는 다음 공격 입력을 허용할 위치에 Combat.Combo.Window.Stage2, Combat.Combo.Window.Stage3 태그를 넣고, 더 이상 다음 공격으로 넘어갈 수 없는 위치에는 Combat.Combo.Window.Close 태그를 넣는다. GA에서는 부모 태그인 Combat.Combo.Window를 기다리면 하위 태그 이벤트까지 하나의 Task에서 받을 수 있다.

1
2
3
4
5
6
7
UAbilityTask_WaitGameplayEvent* ComboWindowTask =
    UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(
        this, GetComboWindowTag(), nullptr, false, false);

ComboWindowTask->EventReceived.AddDynamic(
    this, &UGA_MeleeChain::OnComboWindowEvent);
ComboWindowTask->ReadyForActivation();

이벤트가 들어오면 마지막 태그 이름을 다음 몽타주 섹션 이름으로 저장한다. Combat.Combo.Window.Stage2라면 Stage2가 저장된다. Close 이벤트가 들어온 경우에는 NAME_None으로 초기화해 더 이상 콤보가 이어지지 않도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
void UGA_MeleeChain::OnComboWindowEvent(FGameplayEventData EventPayload)
{
    if (EventPayload.EventTag == GetComboWindowCloseTag())
    {
        PendingSection = NAME_None;
        return;
    }

    TArray<FName> TagPath;
    UGameplayTagsManager::Get().SplitGameplayTagFName(EventPayload.EventTag, TagPath);
    PendingSection = TagPath.Last();
}

입력을 받아 다음 섹션 예약

Ability 실행에 사용했던 입력은 UAbilityTask_WaitInputPress로 다시 감지할 수 있다. 입력을 받을 때마다 새로운 WaitInputPress Task를 등록해 다음 입력도 계속 받을 수 있도록 한다.

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
28
29
30
31
void UGA_MeleeChain::ListenForChainInput()
{
    UAbilityTask_WaitInputPress* InputTask =
        UAbilityTask_WaitInputPress::WaitInputPress(this);
    InputTask->OnPress.AddDynamic(this, &UGA_MeleeChain::OnChainInput);
    InputTask->ReadyForActivation();
}

void UGA_MeleeChain::OnChainInput(float TimeWaited)
{
    ListenForChainInput();
    ReserveNextAttack();
}

void UGA_MeleeChain::ReserveNextAttack()
{
    if (PendingSection == NAME_None)
    {
        return;
    }

    UAnimInstance* CharacterAnimInstance =
        GetOwningComponentFromActorInfo()->GetAnimInstance();
    if (CharacterAnimInstance)
    {
        CharacterAnimInstance->Montage_SetNextSection(
            CharacterAnimInstance->Montage_GetCurrentSection(AttackChainMontage),
            PendingSection,
            AttackChainMontage);
    }
}

여기서 입력이 들어오자마자 애니메이션을 강제로 전환하는 것이 아니라 현재 섹션 다음에 실행할 섹션을 예약한다. 따라서 현재 공격 모션은 끝까지 재생되고 자연스럽게 다음 공격으로 연결된다. Notify가 허용 구간을 열기 전이나 Close 이벤트 이후에는 PendingSection이 비어있으므로 입력해도 다음 섹션이 예약되지 않는다.

소켓 기반 공격 판정 전달

공격 판정도 Anim Notify에서 발생시키면 애니메이션의 타격 시점과 판정 시점을 맞출 수 있다. 무기나 손의 이동 경로를 따라 판정하려면 Skeletal Mesh에 두개 이상의 Socket을 만들고, Notify에서 인접한 Socket 위치들을 시작점과 끝점으로 묶어 FGameplayAbilityTargetData_LocationInfo에 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FGameplayEventData HitEvent;

for (int32 SocketIndex = 1; SocketIndex < TraceSocketNames.Num(); ++SocketIndex)
{
    FGameplayAbilityTargetData_LocationInfo* TraceSegment =
        new FGameplayAbilityTargetData_LocationInfo();

    const FVector SegmentStart =
        MeshComp->GetSocketLocation(TraceSocketNames[SocketIndex - 1]);
    const FVector SegmentEnd =
        MeshComp->GetSocketLocation(TraceSocketNames[SocketIndex]);

    TraceSegment->SourceLocation.LiteralTransform.SetLocation(SegmentStart);
    TraceSegment->TargetLocation.LiteralTransform.SetLocation(SegmentEnd);
    HitEvent.TargetData.Add(TraceSegment);
}

UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(
    MeshComp->GetOwner(), HitTraceEventTag, HitEvent);

이 Notify의 EventTag는 Combat.Hit.Trace로 설정한다. GA에서는 별도의 WaitGameplayEvent Task로 해당 이벤트를 기다렸다가 전달받은 위치 정보를 이용해 Sphere Trace를 실행한다.

1
2
3
4
5
6
UAbilityTask_WaitGameplayEvent* HitTraceTask =
    UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(
        this, GetHitTraceEventTag());

HitTraceTask->EventReceived.AddDynamic(this, &UGA_MeleeChain::ApplyHitEffects);
HitTraceTask->ReadyForActivation();

각 TargetData의 시작점과 끝점 사이에 SphereTraceMultiForObjects()를 실행하면 단순한 한 지점 판정보다 빠르게 움직이는 무기의 궤적을 안정적으로 검사할 수 있다. 여러 소켓 구간이 같은 액터를 중복 검출할 수 있으므로 TSet<AActor*>로 이미 처리한 대상을 걸러준다.

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
28
29
30
31
32
33
34
35
36
TArray<FHitResult> UniqueHits;
TSet<AActor*> ProcessedActors;

for (const TSharedPtr<FGameplayAbilityTargetData> TraceData : TargetDataHandle.Data)
{
    const FVector TraceStart = TraceData->GetOrigin().GetTranslation();
    const FVector TraceEnd = TraceData->GetEndPoint();

    TArray<TEnumAsByte<EObjectTypeQuery>> ObjectTypes;
    ObjectTypes.Add(UEngineTypes::ConvertToObjectType(ECC_Pawn));

    TArray<AActor*> IgnoredActors;
    if (bIgnoreSelf)
    {
        IgnoredActors.Add(GetAvatarActorFromActorInfo());
    }

    TArray<FHitResult> SegmentHits;
    UKismetSystemLibrary::SphereTraceMultiForObjects(
        this, TraceStart, TraceEnd, SphereSweepRadius,
        ObjectTypes, false, IgnoredActors,
        bDrawDebug ? EDrawDebugTrace::ForDuration : EDrawDebugTrace::None,
        SegmentHits, bIgnoreSelf);

    for (const FHitResult& SegmentHit : SegmentHits)
    {
        AActor* HitActor = SegmentHit.GetActor();
        if (!HitActor || ProcessedActors.Contains(HitActor))
        {
            continue;
        }

        ProcessedActors.Add(HitActor);
        UniqueHits.Add(SegmentHit);
    }
}

현재 콤보에 맞는 Gameplay Effect 적용

각 공격 단계마다 데미지가 다르다면 몽타주 섹션 이름과 Gameplay Effect를 TMap으로 연결할 수 있다. Map에서 현재 섹션 이름을 찾지 못한 경우를 위해 기본 Effect도 함께 둔다.

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
UPROPERTY(EditDefaultsOnly, Category = "Gameplay Effect")
TMap<FName, TSubclassOf<UGameplayEffect>> SectionHitEffects;

UPROPERTY(EditDefaultsOnly, Category = "Gameplay Effect")
TSubclassOf<UGameplayEffect> FallbackHitEffect;

TSubclassOf<UGameplayEffect> UGA_MeleeChain::FindEffectForActiveSection() const
{
    UAnimInstance* CharacterAnimInstance =
        GetOwningComponentFromActorInfo()->GetAnimInstance();

    if (CharacterAnimInstance)
    {
        const FName ActiveSection =
            CharacterAnimInstance->Montage_GetCurrentSection(AttackChainMontage);

        if (const TSubclassOf<UGameplayEffect>* SectionEffect =
            SectionHitEffects.Find(ActiveSection))
        {
            return *SectionEffect;
        }
    }

    return FallbackHitEffect;
}

Trace로 찾은 각 대상에는 현재 콤보 단계의 GE Spec을 만들어 적용한다.

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
28
29
30
31
32
33
void UGA_MeleeChain::ApplyHitEffects(FGameplayEventData HitEvent)
{
    const TArray<FHitResult> HitTargets =
        CollectSweepHits(HitEvent.TargetData, 30.f, true, true);

    const TSubclassOf<UGameplayEffect> HitEffect =
        FindEffectForActiveSection();
    if (!HitEffect)
    {
        return;
    }

    const FGameplayEffectSpecHandle HitEffectSpec =
        MakeOutgoingGameplayEffectSpec(
            HitEffect,
            GetAbilityLevel(GetCurrentAbilitySpecHandle(), GetCurrentActorInfo()));

    if (!HitEffectSpec.IsValid())
    {
        return;
    }

    for (const FHitResult& HitTarget : HitTargets)
    {
        ApplyGameplayEffectSpecToTarget(
            GetCurrentAbilitySpecHandle(),
            CurrentActorInfo,
            CurrentActivationInfo,
            HitEffectSpec,
            UAbilitySystemBlueprintLibrary::AbilityTargetDataFromActor(
                HitTarget.GetActor()));
    }
}

블루프린트에서는 Stage1, Stage2, Stage3 키에 각 단계의 Damage GE를 넣는다. 이 이름은 Anim Montage의 섹션 이름과 정확히 같아야 한다. 이렇게 구성하면 애니메이션의 입력 구간과 타격 시점은 Notify가 담당하고, 콤보 상태와 판정, 데미지는 GA와 GE가 담당하게 된다. 애니메이션을 바꾸더라도 Notify 위치와 섹션 구성만 조정하면 되므로 캐릭터 코드에 공격별 분기를 계속 추가하는 방식보다 확장과 관리가 쉬워진다.

This post is licensed under CC BY 4.0 by the author.