[Lost Kingdom] 개발일지 - 8

조재훈·2024년 6월 8일

개발일지 - 8

엄청 오랜만에 언리얼(보고싶었다!)
빠르게 본론으로 넘어가자. 슬슬 캐릭터 공격을 구현하기 위해 마우스 좌클릭으로 로스트아크처럼 그 자리에서 마우스 방향으로 공격하게 만들어야 한다

기본 공격

애니메이션 몽타주

로스트아크에서는 마우스 왼쪽 클릭을 누르면 공격 애니메이션이 재생되는데 콤보 식으로 한 모션이 끝나기 전에 마우스를 한번 더 누르면 다음 콤보가 시작되어 애니메이션이 재생된다

이렇게 콤보 공격을 위해 여러 애니메이션을 연속해서 재생하는 방법으로 언리얼에서 애니메이션 몽타주가 있다. 우선 애셋으로 받은 캐릭터의 애니메이션에 공격 모션이 여러 개 있어 몽타주로 만들어봤다

애니메이션 몽타주 애셋을 만든 후 스켈레톤을 선택하고 처음에는 저 하단이 비어있을텐데 연속해서 재생하고 싶은 애니메이션을 추가한다. 그러면 추가한 애니메이션들이 연속해서 재생될 것이다

본인은 캐릭터 애셋을 받을 때 있던 애셋과 좀 밋밋해보여서 믹사모에 있는 애니메이션을 추가해서 몽타주를 만들었다(애니메이션 임포트하는 방법은 저번에 쓴 포스트에 있는 방법 그대로 써야 한다) 아래에 있는 노티파이는 뒤에서 설명

그런데 지금 구현하려고 하는 것은 콤보 공격인데 지금처럼 애니메이션 몽타주를 재생하면 클릭 한번에 캐릭터 혼자 콤보 공격을 끝까지 해버린다(사용자의 입력 없이)

그래서 이걸 위해 애니메이션 별로 섹션을 나눠 몽타주 안에서 애니메이션 별(단일 섹션)로 관리를 해서 애님 인스턴스를 통해 입력을 받으면 다음 섹션으로 넘어갈 수 있다

애니메이션을 누르고 섹션을 만든 후 애니메이션 시작 위치를 조절해주고 우측 하단에 있는 섹션 관리 탭이 원래 이렇게 체인처럼 연결이 되어있을텐데

화살표 우클릭해서 링크를 다 끊어줘서 단일 섹션으로 만들어줘야 한다

몽타주는 일단 여기서 그만 두고 공격 입력을 처리해보러 가자

입력 만들기

비슷하게 Input Action 애셋을 만든다.

마우스 버튼을 눌렀을 때 트리거되야 하니까 Trigger Pressed로 설정하고 로스트아크에서는 마우스 버튼을 계속 누르고 있으면 기본 공격이 계속 되니까 Down도 추가해준다

기존에 관리하고 있던 Input Mapping Context 애셋에서 방금 만든 Input Action을 등록해주자

코드로 공격해보기

이제 위에서 만든 Input을 컨트롤러가 처리해 캐릭터가 공격해보도록 해보자

먼저 지금까지 플레이어 컨트롤러에서 입력을 받아 처리해왔기에 헤더 파일에 공격 Input Action을 추가해서 입력 처리를 할 수 있도록 하고 Attack Input이 들어오면 실행할 함수를 선언해주자

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, meta = (AllowPrivateAccess = "true"))
class UInputAction* AttackAction;
...
void OnAttackTriggered();

이제 Action을 함수에 바인딩하는 것은 너무 많이 해서 Pass

Input이 들어오면 플레이어 캐릭터를 받아와 캐릭터의 공격 함수를 호출하게 하자

void ALKPlayerController::OnAttackTriggered()
{
	StopMovement();

	ALKPlayerCharacter* PlayerCharacter = CastChecked<ALKPlayerCharacter>(GetPawn());
	PlayerCharacter->ProcessCombo();
}

StopMovement 의 이유는 캐릭터가 이동하다가 공격 입력이 들어오면 이동을 중지하기 위해서이다
ProcessCombo 는 마우스 입력이 들어올 때마다 호출이 되어 콤보 공격을 처리할 함수이다

이제 플레이어 캐릭터로 가서 ProcessCombo 함수와 실행할 콤보 공격 몽타주를 선언해준다

void ALKPlayerCharacter::ProcessCombo()
{
	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
	AnimInstance->Montage_Play(ComboAttackMontage, 1.0f);
}

그냥 임시로 몽타주가 실행되는지 볼려고 바로 몽타주를 재생시킨 코드. 이제 컴파일하고 확인해보자(블루프린트 설정 확인!)

검을 한 번만 휘두르는 건 섹션을 나눠놨기 때문이고 여기서 콤보 공격 외 개선해야 할 점은 공격 시 이동을 못하게 + 마우스 클릭한 곳을 보고 공격하게 해야 한다

콤보 공격 들어가자

전체적으로 어떻게 설계할거냐면

플레이어 컨트롤러에서 공격 Input을 받아서 플레이어 캐릭터의 콤보 공격을 처리하는 함수(ProcessCombo)를 호출한다

콤보 공격을 처리하는 함수가 실행이 되면 현재 캐릭터가 콤보 공격중이라면 입력이 들어왔음을 처리하고 콤보 공격 중이 아니라면 처음부터 콤보 공격을 시작한다

애니메이션 노티파이

애니메이션 노티파이란 애니메이션의 특정 재생 부분이 지날 때 노티파이가 설정된 곳을 지나면 델리게이트처럼 이벤트가 호출되는 그런 개념이다(자세한 것은 구글링!)

위에 있는 몽타주 사진을 보면 노티파이라고 되어있는 트랙 부분에 ComboCheck라는 노티파이가 있는 것을 볼 수 있다. 그냥 일반 노티파이로 만들어줘도 되지만 나는 C++로 AnimNotify를 상속받은 콤보 체크를 하는 노티파이를 새로 만들겠다

그리고 노티파이를 Queued에서 BranchPoint로 바꿔줬는데 노티파이 클릭 후 Detail에서 보면 Montage Tick Type에서 바꿀 수 있다. Queued는 성능에 무리는 안 주는 대신 조금 반응이 느릴 수 있고 BranchPoint는 반대라서 콤보 같은 경우 빠른 반응이 중요하기에 바꿔주었다

이제 몽타주에서 노티파이 부분에 AddNotify를 하면 내가 만든 노티파이를 만들 수 있다. 만들고 나서 애니메이션 부분에서 다음 콤보로 넘어갈 위치에 적절히 배치해준다

그리고 이제 C++ 코드로 가보면 아무것도 없는데 우리는 Notify라는 함수를 오버라이드해서 애니메이션에서 노티파이를 지나칠 때 Notify 함수가 실행되게 할 것이다

// AnimNotify_ComboCheck 헤더 파일
public:
	virtual void Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation) override;

// cpp 파일
void UAnimNotify_ComboCheck::Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation)
{
	Super::Notify(MeshComp, Animation);

	if (MeshComp)
	{
		ALKPlayerCharacter* PlayerCharacter = Cast<ALKPlayerCharacter>(MeshComp->GetOwner());
		if (PlayerCharacter)
		{
			PlayerCharacter->NotifyCombo();
		}
	}
}

이러면 이제 노티파이가 호출될 때 내 플레이어 캐릭터의 NotifyCombo 함수가 실행될 것이다

주의
나만 그런지는 모르겠는데 원래는 저 코드에서 PlayerCharacter를 if로 체크하는 부분을 안넣었다. 그런데 게임 플레이할 때만 문제가 될 거라고 생각했는데 이상하게 언리얼 에디터에서 노티파이가 있는 애니메이션 몽타주를 키니까 엔진이 터져버렸다. 찾아보니까 테스트할려고 애니메이션 몽타주 자체에서 Notify 함수를 실행시킬 수 있다고 들어서 새로 몽타주를 만들어주고 예외처리를 다 하니까 더 이상 문제가 생기지 않음 -> 언리얼 포럼

코드보기

이제 코드에서 콤보 액션을 구현해보자

먼저 플레이어 캐릭터로 돌아가 헤더 파일을 수정하자

public:
	void ProcessCombo();
	void NotifyCombo();
    
// Attack Section
protected:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Animation")
	TObjectPtr<class UAnimMontage> ComboAttackMontage;
	
	void ComboAttackBegin();
	void ComboAttackEnd(class UAnimMontage* TargetMontage, bool IsProperlyEnded);

	// 노티파이 이벤트가 호출되면 입력이 들어왔는지 확인 후 다음 콤보 액션을 실행
	void ComboCheck();

	void LookAt();

	int32 MaxCombo = 4;
	int32 CurrentCombo = 0;

	// 노티파이 이전에 입력이 들어왔는지
	bool HasNextComboInput = false;
    ...
    
private:
    UPROPERTY()
	class UAnimInstance* AnimInstance;

	/* Input Debounce */
	FTimerHandle DebounceTimerHandle;
	uint8 bIsDebouncing : 1;
	FORCEINLINE void ResetDebounce() { bIsDebouncing = false; }

컨트롤러 및 노티파이같은 외부에서 호출할 함수들을 public으로 선언하고 몽타주 애셋, 콤보 어택을 시작하는 함수, 몽타주가 끝날때 호출되는 함수(델리게이트와 인자를 맞췄다)

다른 것은 주석이 있으니 이해갈 것으로 알고 넘어감

몽타주가 끝날 때 호출되는 델리게이트는 언리얼 자체에 있는 AnimMontage.h 파일에 이미 선언되어 있다

DECLARE_DELEGATE_TwoParams( FOnMontageEnded, class UAnimMontage*, bool /*bInterrupted*/)

완성 코드를 먼저 소개를 하고 설명을 하겠다

/// <summary>
/// 플레이어 컨트롤러에서 공격 입력이 들어오면 호출되는 함수
/// </summary>
void ALKPlayerCharacter::ProcessCombo()
{
	// 디바운스 처리를 해서 마우스를 짧게 눌러도 한번만 입력되도록 함
	if (bIsDebouncing)	return;

	bIsDebouncing = true;

	GetWorldTimerManager().SetTimer(DebounceTimerHandle, this, &ALKPlayerCharacter::ResetDebounce, 0.2f, false);

	if(CurrentCombo == 0)
	{
		ComboAttackBegin();
		return;
	}

	HasNextComboInput = true;
}

/// <summary>
/// 몽타주에서 노티파이 이벤트가 발생하면 ComboCheck를 호출 
/// </summary>
void ALKPlayerCharacter::NotifyCombo()
{
	ComboCheck();
}

void ALKPlayerCharacter::ComboAttackBegin()
{
	LookAt();
	CurrentCombo = 1;

	// 애니메이션 세팅
	const float AttackSpeed = 1.0f;		// 속도 특성 있을 경우 변경
	AnimInstance->Montage_Play(ComboAttackMontage, AttackSpeed);

	// 몽타주가 종료되면 ComboAttackEnd 함수 호출
	// 구조체 선언하고 함수 바인딩
	FOnMontageEnded EndDelegate;
	EndDelegate.BindUObject(this, &ALKPlayerCharacter::ComboAttackEnd);
	AnimInstance->Montage_SetEndDelegate(EndDelegate, ComboAttackMontage);
}

/// <summary>
/// 콤보	공격이 끝나면 호출되는 함수(몽타주 종료 시 호출)
/// </summary>
void ALKPlayerCharacter::ComboAttackEnd(UAnimMontage* TargetMontage, bool IsProperlyEnded)
{
	ensure(CurrentCombo != 0);
	CurrentCombo = 0;
}

/// <summary>
/// 콤보 체크할 때 이전에 입력이 들어왔다면 다음 콤보를 실행
/// </summary>
void ALKPlayerCharacter::ComboCheck()
{
	if (HasNextComboInput)
	{
		LookAt();
		CurrentCombo = FMath::Clamp(CurrentCombo + 1, 1, MaxCombo);

		FName NextSection = *FString::Printf(TEXT("ComboAttack%d"), CurrentCombo);
		AnimInstance->Montage_JumpToSection(NextSection, ComboAttackMontage);
		HasNextComboInput = false;
	}
}

/// <summary>
/// 캐릭터가 마우스를 클릭한 곳으로 보게 함. Yaw 회전만 변경
/// </summary>
void ALKPlayerCharacter::LookAt()
{
	APlayerController* PlayerController = CastChecked<APlayerController>(GetController());
	
	FHitResult Hit;
	bool bHitSuccessful = PlayerController->GetHitResultUnderCursor(ECollisionChannel::ECC_Visibility, true, Hit);

	if (bHitSuccessful)
	{
		FVector Cursor = Hit.Location;
		FVector CharacterLocation = GetActorLocation();

		FVector Direction = (Cursor - CharacterLocation).GetSafeNormal();
		FRotator NewRotation = Direction.Rotation();

		FRotator CurrentRotation = GetActorRotation();
		CurrentRotation.Yaw = NewRotation.Yaw;

		SetActorRotation(CurrentRotation);
	}
}

ProcessCombo

컨트롤러에서 호출하는 함수인데 공격 입력(마우스 좌클릭)이 들어오면 호출되는 함수이다. 처음 부분에 디바운스 뭐시기가 있는데 연속 입력 처리를 막기 위해서이다. 무슨 말이냐면 아까 Input Action에서 마우스를 눌렀을 때와 누르고 있을 때 입력을 받도록 설정했는데 누르고 있으면 계속 공격되는건 의도한 바인데 마우스를 한번만 클릭해도 그 짧은 순간 HasNextComboInput가 true로 설정되어 콤보가 연속으로 나가는 불상사가 생겨 타이머를 통해 클릭에 0.2초간의 간격을 둔다고 생각하면 편하다

현재 콤보가 0이면 콤보 공격을 시작하고 아니면 그냥 입력이 들어왔음을 알려주면 ComboCheck에서 처리할 것임

ComboAttackBegin

애니메이션 몽타주를 시작해서 첫 번째 콤보 공격 애니메이션 섹션을 재생함. 그리고 몽타주가 종료될 때 ComboAttackEnd를 호출하도록 델리게이트에 바인딩한다. 콤보 공격이 끝나면 CurrentCombo를 0으로 초기화함

ComboCheck

노티파이가 이 함수를 호출하면 말 그대로 다음 콤보로 넘어갈지 결정한다. HasNextComboInput라는 입력이 들어왔음을 알리는 변수를 통해 입력이 들어왔다면 다음 섹션을 재생하고 다시 변수를 false로 만든다. 이때 섹션이름을 몽타주의 섹션과 동일하게 해주기

LookAt

로스트아크에서는 마우스를 클릭해 공격할때마다 마우스 방향으로 캐릭터가 그 방향으로 공격을 한다. 그래서 섹션이 시작될 때마다 이 함수가 호출됨

결과

내가 원하던 공격 애니메이션이 나왔다. ㅠㅠ

이제 공격 판정 및 적을 만들어야할텐데 이것도 오래걸릴것같다 열심히 정진하자

profile
나태지옥

0개의 댓글