KoreanFoodie's Study

이득우의 언리얼 C++ 8 : 애니메이션 시스템 활용 (Montage, Notify, ComboAttack) 본문

Game Dev/Unreal C++ : Tutorial

이득우의 언리얼 C++ 8 : 애니메이션 시스템 활용 (Montage, Notify, ComboAttack)

GoldGiver 2022. 3. 4. 16:35

이득우님의 "이득우의 언리얼 C++ 게임 개발의 정석" 책을 따라가며 실습한 내용을 정리한 포스팅입니다. 실습에 필요한 자료들은 이 링크에서, 제가 작업한 예제 소스 완성본은 여기에서 찾아보실 수 있습니다. (저는 언리얼 4.27.2 버전 기준으로 작업하였습니다)

몽타주(Montage)

애님 그래프에 스테이트 머신을 삽입해 모든 캐릭터의 애니메이션을 처리할 수도 있지만, 경우의 수가 많아지면 스테이트 머신으로 이를 관리하는 것은 매우 어렵다. 따라서 언리얼은 특정 상황에서 원하는 애니메이션을 발동시키는 애니메이션 몽타주라는 기능을 제공한다.

 

먼저, 애니메이션 블루프린트에서 몽타주를 생성한다.

 

몽타주는 섹션(Section) 단위로 애니메이션을 관리한다.

위에서 본 것처럼, Attack1, Attack2, Attack3, Attack4 의 섹션을 만든 후, 각각을 독립적으로 작동하도록 만들었다(기본 생성시, 자동으로 섹션이 넘어가도록 만들어짐). 그 후, 프로젝트 세팅에서 Attack 입력을 마우스 좌클릭과 바인딩한다.

위의 Attack(1~4) 섹션에는 이미 넣어놓은 WarriorAttack(1~4) 애니메이션이 각각 하나씩 할당이 되어 있다. 아래 줄들을 보면 노티파이가 있다. 노티파이는 아래 섹션에서 자세히 다루도록 한다.

 

애니메이션 블루프린트에서 몽타주를 재생하기 위해서는 몽타주 재생 노드를 애님 그래프에 추가해야 한다. 우리는 모든 상황에서 몽타주를 재생할 예정이므로 애님 그래프의 최종 애니메이션 포즈와 스테이트 머신 사이에 몽타주 재생 노드를 추가했다. 몽타주 파일을 잘 보면 슬록이 현재 DefaultGroup.DefaultSlot 으로 되어 있는 것을 확인해 볼 수 있다.

 

이제 Attack 함수를 구현한 후, 공격 입력이 주어졌을 때 이에 대응하는 Montage 를 재생해 보자. 먼저 AnimInstance 에 몽타주를 호출하는 코드를 구현한다.

 

ABAnimInstance.h

UCLASS()
class ARENABATTLE_API UABAnimInstance : public UAnimInstance
{
    ...

public:
    void PlayAttackMontage();
    
    ...
    
private:
    UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category = Attack, Meta = (AllowPrivateAccess = true))
    UAnimMontage* AttackMontage;
}

먼저 헤더 파일에 UAnimMontage* 타입의 AttackMontage 변수와 PlayAttackMontage( ) 함수 를 선언한다.

 

ABAnimInstance.cpp

UABAnimInstance::UABAnimInstance()
{
    ...
    static ConstructorHelpers::FObjectFinder<UAnimMontage> ATTACK_MONTAGE(TEXT("/Game/Book/Animations/SK_Mannequin_Skeleton_Montage.SK_Mannequin_Skeleton_Montage"));
    if (ATTACK_MONTAGE.Succeeded())
    {
    	AttackMontage = ATTACK_MONTAGE.Object;
    }
}

...

void UABAnimInstance::PlayAttackMontage()
{
    if (!Montage_IsPlaying(AttackMontage))
    {
    	Montage_Play(AttackMontage, 1.0f);
    }
}

헤더 파일에서 선언한 AttackMontage 파일에 우리가 만든 애님 몽타주를 연결시킨후, UAnimInstance 의 Montage_Play 함수를 이용해 1.0 배속으로 몽타주를 재생한다.

 

UPROPERTY 설정 키워드
Anywhere =
DefaultsOnly + InstanceOnly
Edit Visible
Anywhere EditAnywhere VisibleAnywhere
DefaultsOnly
(클래스의 기본값을 담당하는 블루프린트 편집 화면에서만 보임)
EditDefaultsOnly VisibleDefaultsOnly
InstanceOnly
(인스턴스의 속성을 보여주는 에디터 뷰포트에서만 보임)
EditInstanceOnly VisibleInstanceOnly

 

ABAnimInstance 에서 만든 AttackMontage 는 다음과 같이 변수가 잘 선언되어 있음을 확인할 수 있다.

 

ABCharacter.h

// Attack() 선언
void Attack();

 

ABCharacter.cpp

#include "ABAnimInstance.h"

void AABCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    PlayerInputComponent->BindAction(TEXT("Attack"), EInputEvent::IE_Pressed, this, &AABCharacter::Attack);
}

void AABCharacter::Attack()
{
    auto AnimInstance = Cast<UABAnimInstance>(GetMesh()->GetAnimInstance());
    if (nullptr == AnimInstance) return;
    
    AnimInstance->PlayAttackMontage();
}

ABAnimInstance 로부터 AnimInstance 를 받아 공격시 PlayAttackMontage 를 호출한다. PlayAttackMontage 는 ABAnimInstance.cpp 에 정의되어 있다.

 

 

델리게이트

위에서는 플레이어가 공격 명령을 내려도 몽타주 시스템이 구동 중이면 몽타주가 플레이되지 않도록 기능을 구현했다. 하지만 몽타주 시스템이 현재 구동 중인지를 계속 체크하는 것보다, 몽타주 재생이 끝나면 공격이 가능하다고 폰(Pawn)에게 알려주는 방식이 더 효율적이다. 마치 C++ 의 Condition Variable 같은 이 기능은, 언리얼에서는 델리게이트(Delegate) 기능으로 제공되고 있다.

언리얼 델리케이트 작동 예시

 

애님 인스턴스에는 애니메이션 몽타주 재생이 끝나면 발동하는 OnMantageEnded 라는 델리게이트를 제공한다. 어떤 언리얼 오브젝트라도 UAnimMontage* 인자와  bool 인자를 가진 멤버 함수를 가지고 있다면, 이를 OnMontageEnded 델리게이트에 등록해 몽타주 재생이 끝나는 타이밍을 파악할 수 있다.

 

ABCharacter 액터에 위의 함수 형식을 선언한 후, 윗줄에 UFUNCTION 이라는 매크로를 추가해야 한다. C++ 에서 연동하려는 함수를 블루프린트와 호환되는 함수형으로 선언해야 하기 때문이다.

해당 인자를 가진 함수를 ABCharacter 클래스에 선언하고, PostInitializeComponents 에서 해당 함수를 애님 인스턴스의 OnMontageEnded 델리게이트에 바인딩한다. 그리고 현재 공격 중인지 아닌지를 파악하는 bool 변수를 선언한다.

 

언리얼에서 델리게이트는 C++ 객체에만 사용할 수 있는 델리게이트와 C++, 블루프린트 객체가 모두 사용 가능한 델리게이트(다이나믹 델리게이트, Dynamic Delegate)로 나뉜다. 블루프린트 오브젝트는 멤버 함수에 대한 정보를 저장하고 로딩하는 직렬화(Serialization) 매커니즘이 들어가 있다.

 

ABCharacter.h

UCLASS()
class ARENABATTLE_API AABCharacter : public Acharacter
{
 public:
     virtual void PostInitializeComponents() override;
     
...
 
private:
    UFUNCTION()
    void OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted);

private:
    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = Attack, Meta = (AllowPrivateAccess = true))
    bool IsAttacking;

}

PostInitializeComponents 가 호출 될 때, OnAttackMontageEnded 가 호출되게 만들어 공격 상태를 초기화해 줄 것이다.

 

ArenaBattle.h

#define ABCHECK(Expr, ...) { if (!(Expr)) { ABLOG(Error, TEXT("ASSERTION : %s"), TEXT("'"#Expr"'")); return __VA_ARGS__;} }

추가로, 에러 상태를 체크하는 로그 함수를 ArenaBattle에 추가했다. 매크로를 활용해 애님 인스턴스의 OnMontageEnded 델리게이트와 우리가 선언한 OnAttackMontageEnded 를 연결해, 델리게이트가 발동할 때까지 애니메이션 시스템에 몽타주 재생 명령을 내리지 못하게 폰 로직에서 막아준다.

 

ABCharacter.h

class ARENABATTLE_API AABCharacter : public ACharacter
{
...

private:
    UPROPERTY()
    class UABAnimInstance* ABAnim;
}

애님 인스턴스는 캐릭터 클래스에서 계속 사용할 예정이므로 이를 멤버 함수로 선언한다. UABAnimInstance 클래스의 멤버 변수를 선언할 때 이를 전방 선언으로 설계하는 것이 바람직하다.

 

ABCharacter.cpp

AABCharacter::AABCharacter()
{
    ...
    IsAttacking = false;
}

...

void AABCharacter::PostInitializeComponents()
{
    Super::PostInitializeComponents();
    
    ABAnim = Cast<UABAnimInstance>(GetMesh()->GetAnimInstance());
    ABCHECK(nullptr != ABAnim);
    
    ABAnim->OnMontageEnded.AddDynamic(this, &AABCharacter::OnAttackMontageEnded);
}

...

void AABCharacter::Attack()
{
    if (IsAttacking) return;
    
    ABAnim->PlayAttackMontage();
    IsAttacking = true;
}

void AABCharacter::OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted)
{
    ABCHECK(IsAttacking);
    IsAttacking = false;
}

AABCharacter 는 IsAttacking 이라는 변수로 공격중인지를 체크한다. PostInitializeComponent 함수에서 UAnimInstance 클래스에 내장된 OnMontageEnded (몽타주 재생이 끝나면 발동) 에 OnAttackMontagEnded 를 델리게이트 등록해, 몽타주 재생이 끝나면 OnAttackMontageEnded 가 호출되게 된다. 그럼 IsAttacking 변수가 false 가 되는 식이다.

이전에는 Attack 함수에서 ABAnim 변수를 새롭게 정의했다면, 이제는 ABAnim 이 멤버 변수이기 때문에 간단하게 PlayAttackMontage 를 호출해 주기만 하면 된다. PlayAttackMontage 는 ABAnimInstance.cpp 에 정의되어 있을 것이다.

 

ABAnimInstance.cpp

...

void UABAnimInstance::PlayAttackMontage()
{
    Montage_Play(AttackMontage, 1.0f);
}

...

델리게이트에 의해 공격의 시작과 종료가 감지되므로, Montage_IsPlaying 함수를 이용하여 몽타주가 실행되고 있는지 체크하지 않아도 된다.

 

 

애니메이션 노티파이

애니메이션 노티파이는 애니메이션을 재생하는 동안 특정 타이밍에 애님 인스턴스에게 신호를 보낼 수 있는 기능이다. 애니메이션 노티파이는 일반 애니메이션과 몽타주 모두 사용 가능한데, 여기서는 노티파이를 이용해 콤포 공격을 구현해 보자.

 

먼저, 위와 같이 NextAttackCheck 노티파이를 만든다. 공격 중, NextAttackCheck 노티파이가 있는 영역에서 공격 버튼이 눌릴 경우, 다음 섹션으로 넘어가 콤보 공격을 가능하도록 만드는 것이 목표이다. AttackHitCheck 노티파이는 그냥 노티파이가 작동했는지 로그를 찍어주는 기능으로 구현할 것이다.

노티파이가 호출되면 언리얼은 자동으로 애님 인스턴스 클래스의 'AnimNotify_노티파이명' 이라는 이름의 멤버 함수를 찾아서 호출한다.

이때 멤버 함수는 언리얼 런타임이 찾을 수 이도록 UFUNCTION 매크로가 지정돼야 한다. 다이나믹 델리게이트에서처럼, C++ 와 블루프린트 객체 모두에서 사용가능할 수 있어야 하기 때문이다.

 

애니메이션 노티파이를 설정한 후에는 해당 프레임에 즉각적으로 반응하는 방식인 Branching Point 값으로 틱 타입(Tick Type)을 변경하는 것이 좋다. 기본값인 Queued 로 설정하게 되면 비동기 방식으로 신호를 받게 돼서 적절한 타이밍에 신호를 받는 것을 놓치게 될 수 있다. Queued 값은 주로 타이밍에 민감하지 않은 사운드나 이펙트를 발생시킬 때 사용하는 것이 적합하다.

 

ABCharacter.h

UCLASS()
class ARENABATTLE_API AABCharacter : public ACharacter
{

...

private:
    
    ...
    
    void AttackStartComboState();
    void AttackEndComboState();
   
private:

    UPROPERTY(VisibleInstanceOnly, BluePrintReadOnly, Category = Attack, Meta = (AllowPrivateAccess = true))
    bool CanNextCombo;
    
    UPROPERTY(VisibleInstanceOnly, BluePrintReadOnly, Category = Attack, Meta = (AllowPrivateAccess = true))
    bool IsComboInputOn;
    
    UPROPERTY(VisibleInstanceOnly, BluePrintReadOnly, Category = Attack, Meta = (AllowPrivateAccess = true))
    int32 CurrentCombo;
    
    UPROPERTY(VisibleInstanceOnly, BluePrintReadOnly, Category = Attack, Meta = (AllowPrivateAccess = true))
    int32 MaxCombo;
}

먼저, Character.h 에 AttackComboState, AttackEndState 멤버 함수와 CanNextCombo, IsComboInputOn, CurrentCombo, MaxCombo 변수를 선언한다.

 

ABCharacter.cpp

...

AABCharacter::AABCharacter()
{
    ...
    MaxCombo = 4;
    AttackEndComboState();
}

...

void AABCharacter::AttackStartComboState()
{
    CanNextCombo = true;
    IsComboInputOn = false;
    ABCHECK(FMath::IsWithinInclusive<int32>(CurrentCombo, 0, MaxCombo - 1));
    CurrentCombo = FMath::Clamp<int32>(CurrentCombo + 1, 1, MaxCombo);
}

void AABCharacter::AttackEndComboState()
{
    IsComboInputOn = false;
    CanNextCombo = false;
    CurrentCombo = 0;
}

공격이 시작하거나 끝날 때 사용할 함수와 변수를 모두 선언했다. 이때 FMath::Clamp 라는 표현식은 값을 받아 최소치와 최대치로 정의된 특정 범위로 제한시킨다. 최소가 1, 최대가 4이면, 결과값은 절대 1 미만이거나 4 초과가 되지 않는다.

 

이제 ABAnimInstance 클래스에서 콤보 카운트를 전달받으면 해당 몽타주 섹션을 재생하도록 기능을 구현한다. 앞서 선언한 NextAttackCheck 노티파이가 발생할 때마다 ABCharacter 에 이를 전달할 델리게이트를 선언하고 애니메이션 노티파이 함수에서 이를 호출한다.

이렇게 델리게이트 기능을 사용하면, 애님 인스턴스는 자신의 델리게이트를 사용하는 객체가 어떤 것인지 몰라도 델리게이트에 연결된 함수만 호출하면 되므로, 다른 클래스와 연결되지 않는 의존성 없는 설계를 할 수 있다는 장점이 생긴다.

반환 값과 인자 값이 없는 함수 유형으로 델리게이트를 선언하되, 여러 개의 함수가 등록되도록 멀티캐스트로 선언한다. 멀티캐스트 델리게이트에 등록된 모든 함수를 호출하는 멀티캐스트 델리게이트 명령은 Broadcast 이다.

 

ABAnimInstance.h

...

DECLARE_MULTICAST_DELEGATE(FOnNextAttackCheckDelegate);
DECLARE_MULTICAST_DELEGATE(FOnAttackHitCheckDelegate);

...

UCLASS()
class ARENABATTLE_API UABAnimInstance : public UAnimInstance
{

    ...
    
public:
    void JumpToAttackMontageSection(int32 NewSection);
    
public:
    FOnNextAttackCheckDelegate OnNextAttackCheck;
    FOnAttackHitCheckDelegate OnAttackHitCheck;
    
    ...
    
private:
    
    UFUCNTION()
    void AnimNotify_AttackHitCheck();
    
    UFUNCTION()
    void AnimNotify_NextAttackCheck();
    
    FName GetAttackMontageSectionName(int32 Section);

}

FOnNextAttackCheckDelegate 와 FOnAttackHitCheckDelegate 를 선언했다.

 

ABAnimInstance.cpp

...

void UABAnimInstance::JumpToAttackMontageSection(int32 NewSection)
{
    ABCHECK(Montage_IsPlaying(AttackMontage));
    Montage_JumpToSection(GetAttackMontageSectionName(NewSection), AttackMontage);
}

void UABAnimInstance::AnimNotify_AttackHitCheck()
{
    OnAttackHitCheck.Broadcast();
}

void UABAnimInstance::AnimNotify_NextAttackCheck()
{
    OnNextAttackCheck.Broadcast();
}

FName UABAnimInstance::GetAttackMontageSectionName(int32 Section)
{
    ABCHECK(FMath::IsWithinInclusive<int32>(Section, 1, 4), NAME_None);
    return FName(*FString::Printf(TEXT("Attack%d"), Section));
}

 

이제 플레이어가 공격 명령을 내리면 ABCharacter 는 콤보가 가능한지 아닌지 파악하고 각 상황에 대응한다. 

 

ABCharacter.cpp

...

void AABCharacter::PostInitializeComponents()
{

    ...
    
    ABAnim->OnNextAttackCheck.AddLambda([this]() -> void {
        ABLOG(Warning, TEXT("OnNextAttackCheck"));
        CanNextCombo = false;
        
        if (IsComboInputOn)
        {
            AttackStartComboState();
            ABAnim->JumpToAttackMontageSection(CurrentCombo);
        }
        
    });
}

...

void AABCharacter::Attack()
{
    if (IsAttacking)
    {
    	ABCHECK(FMath::IsWithinInclusive<int32>(CurrentCombo, 1, MaxCombo));
        if (CanNextCombo)
        {
            IsComboInputOn = true;
        }
    }
    else
    {
        ABCHECK(CurrentCombo == 0);
        AttackStartComboState();
        ABAnim->PlayAttackMontage();
        ABAnim->JumpToAttackMontageSection(CurrentCombo);
        IsAttacking = true;
    }
}

void AABCharacter::OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted)
{
    ABCHECK(IsAttacking);
    ABCHECK(CurrentCombo > 0);
    IsAttacking = false;
    AttackEndComboState();
}

ABAnimInstance 의 OnNextAttackCheck 델리게이트와 등록할 로직을 ABCharacter 에서 선언하고 구현했다. 이때 람다식을 이용했는데, 참조할 환경 (캡쳐, Capture) 는 this 로 지정한다. 람다 구문에서 인스턴스의 관련 멤버 함수와 변수를 사용하기 때문이다.

 

적절한 타이밍에 공격 버튼을 누르면, 콤보 공격이 진행된다.

 

Comments