Class GameDev* SheepAdult

[Unreal Engine 4.27] Third Person Pick up and Throw C++ (3인칭 시점 줍기, 던지기) 본문

Unreal Project

[Unreal Engine 4.27] Third Person Pick up and Throw C++ (3인칭 시점 줍기, 던지기)

SheepAdult 2022. 4. 7. 16:05

결과물 Youtube Link :

https://www.youtube.com/watch?v=olNclpA5Df0 

 

 

+2022.4.30 수정

+2023.1.29 수정 (오랜만에 글 보는데 너무 예전 코드라 일부 수정)

 3인칭 중에서도 사이드 뷰 시점일 경우에 물건을 줍고 던지는 기능을 구현해 봤다. 유튜브나 구글엔 원하는 형식의 강의가 없어서 스스로 구현해봤다. 먼저 구현된 모습이 어떨지 상상해 보면, 캐릭터가 물건을 집을 수 있는 위치에서 왼쪽 마우스 버튼을 누르면 그 물건을 줍는다. 줍는 애니메이션이 실행 중일 때는 움직일 수 없어야 하며, 줍는 애니메이션 실행 중 왼쪽 마우스 버튼을 뗀다면 애니메이션이 끝난 후 툭 떨어 뜨려야 한다.(사실 줍는 동안 손을 떼면 애니메이션이 해당 지점에서 중지하고 Reverse 하는 경우를 원했지만 잘 되지 않아 일단 본문과 같은 방법으로 구현했다.) 즉, 왼쪽 마우스 버튼을 누르고 있는 동안엔 물건을 집고 있는 것이다. 그리고 그 상태에서 점프 키인 스페이스 바를 누르면 던질 준비자세를 하며, 손을 떼면 누른 시간에 비례해 최소 0부터 최대 1까지의 힘으로 던지게 할 것이다. 이때, 물건을 집는 도중에 스페이스 바를 누른다고 해서 던지는 애니메이션이 나오면 안 되며, 물건을 주울 땐 손의 위치에 가져다 놓을 것이다. (이 부분도 Hand IK를 활용하면 더 자연스럽겠지만 본문에선 구현하지 않았다. 추후에 추가할 예정이다.)

 

 위의 내용은 리틀 나이트메어의 줍고 던지는 방식과 유사하게 구현했으나 차이점은 리틀 나이트메어에서는 던진 물건과 상호작용되는 물건 앞에서 던지면 그 방향으로 알아서 조준되는데, 아직 본 프로젝트에서는 던져서 버튼을 누른다든지의 방식을 사용할 진 정해지지 않아 그 부분은 제외했다. 본 프로젝트에서는 물건을 던져서 비교적 위의 맵에 물건을 가져다 놔야 한다든지, 넓은 부분에 던진다든지 구현할 것 같다.

 

 먼저 내 발 밑에 던질 수 있는 물건이 있는지 확인하기 위하여 SphereTrace를 쏜다. 이때, 일일이 던질 수 있는 물건인지 판단하기 보단 TraceChannel을 하나 더 만들어 HitResult를 판단했다. 

Throwable이 일반 오브젝트들보다 당연하게 적을 것이므로 디폴트를 무시로 두고 해당 오브젝트들에서 켜주었다.

 

MainCharacter.cpp이다.

 해당 프로젝트에서는 1인칭일 때가 아닌 사이드 뷰일 때만 이 기능이 가능하도록 할 것이며, 점프 중일 때나 앉아 있을 땐 주울 수 있으면 안 되므로 아래와 같이 구현해 준다.

void AMainCharacter::PressLMBForThrow()	// 해당 함수는 왼쪽 마우스를 클릭하면 호출된다.
{
	// 조사할 위치에 트레이스를 쏜다.
    FVector StartLoc = TriggerCapsuleComponent->GetRelativeLocation() + TriggerCapsuleComponent->GetForwardVector() * 30.0f + FVector(0.0f, 0.0f, -50.0f);
	FVector EndLoc = StartLoc;
	TArray<AActor*> ToIgnore;
	FHitResult OutHit;
	bool bIsThrowable = UKismetSystemLibrary::SphereTraceSingle(
		GetWorld(),
		StartLoc,
		EndLoc,
		10.0f,
		ETraceTypeQuery::TraceTypeQuery5,
		false,
		ToIgnore,
		EDrawDebugTrace::None,
		OutHit,
		true);
       
    // 조사되는 경우
	if (bIsThrowable)
	{
    	ThrowableObject = Cast<AMaster_Throwable>(OutHit.Actor);
		ThrowableObjec != nullptr ? bCanThrow = true : bCanThrow = false;
	}
    // 조사되지 않는 경우
	else
	{
		bCanThrow = false;
	}

	if (bIsControllable && bCanThrow)
	{
    	/* 여러 설정들 개인적으로 맞춰준다.
		...
        */
		if (M_PickUp)
		{
			PlayAnimMontage(M_PickUp);	// 몽타주 재생
			...
		}
	}
}

 아래는 마우스 왼쪽 버튼을 눌렀을 때 호출되는 함수에서 호출되는(?) 함수이다.

void AMainCharacter::ReleaseLMBForThrow()
{
	bReleasedLMB = true;	// 마우스 왼쪽버튼을 놓았을 때 true되는 값이다.
	DropDown();	// 왼쪽 마우스에서 손을 뗐을 때 호출되는 함수이다.
}

void AMainCharacter::DropDown()
{
	if (bIsGrabbingThrowable && bIsControllable)
	{
		bIsGrabbingThrowable = false;	// 집고 있는 애님메이션 중지
		...
		if (ThrowableObject)
		{
			ThrowableObject->DropDown();	// Throwable 오브젝트와 캐릭터 사이의 함수 호출을 위한 Interface 함수이다.
		}
	}
}

아래는 Tick에서 호출되며, 게이지를 모으는 키를 누르고 있을 경우 호출된다.

// Tick에서 호출
void AMainCharacter::ChargingThrowingGauge()
{
	if (bIsThrowKeyDown)
	{
		ThrowGauge = UKismetMathLibrary::FClamp(ThrowGauge + 0.005, 0.5f, 0.8f);
	}
}

 

아래는 애니메이션에 사용할 노티 파이들과 그에 대한 Delegate 함수들이다.

// MainCharacter.h
	UFUNCTION()
		void OnMontageNotifyBegin();	// Delegate로 바인딩 하므로 UFUNCTION() 필수
	UFUNCTION()
		void OnMontageEnded(UAnimMontage* AnimMontage, bool bInterrupted);	// Delegate로 바인딩 하므로 UFUNCTION() 필수
	FScriptDelegate Delegate_OnMontageNotifyBegin;	// Montage의 OnMontageNotifyBegin엔 FScriptDelegate가 필요하다.
    												// Bind함수에 마우스 올려놨을 때 그렇게 뜸,,

 

void AMainCharacter::BeginPlay()
{
	Super::BeginPlay();

	...

	// Throw Delegate
	Delegate_OnMontageNotifyBegin.BindUFunction(this, FName("OnMontageNotifyBegin"));	// FScriptDelegate로 선언한 변수를 Delegate로 호출하고자 하는
																						// 함수에 Binding해준다.	
    SkeletalMesh->GetAnimInstance()->OnPlayMontageNotifyBegin.Add(Delegate_OnMontageNotifyBegin);	// 그리고  OnPlayMontageNotifyBegin에 Add해준다.
    SkeletalMesh->GetAnimInstance()->OnMontageEnded.AddDynamic(this, &AMainCharacter::OnMontageEnded);	// End는 OnMontageEnded에 AddDynamic으로 바인드 해준다.

	...
}
void AMainCharacter::OnMontageNotifyBegin()	// 애니메이션 몽타주의 Notify가 시작할 때 호출되는 함수로 Delegate이다.
{
	if (GetCurrentMontage() == M_PickUp)	// 해당 몽타주가 Pickup 일 때
	{
    	// (임시 방법) 두 손 사이의 위치에 물건을 위치시키기 위한 함수로 인터페이스 함수이다. 뒤에서 설명한다.
		ThrowableObject->PickUp((SkeletalMesh->GetSocketLocation(FName("hand_r")) + SkeletalMesh->GetSocketLocation(FName("hand_l"))) / 2, this);
	}
	else if (GetCurrentMontage() == M_ReadyToThrow)	// 해당 몽타주가 던지기 위한 자세, 즉 게이지를 모으는 자세일 때
	{
		SkeletalMesh->GetAnimInstance()->Montage_Pause();	// 스페이스 바 키를 홀딩할 때 애니메이션을 중지시키기 위한 Montage_Pause
	}
}

void AMainCharacter::OnMontageEnded(UAnimMontage* AnimMontage, bool bInterrupted)	// 애니메이션 몽타주가 끝날 때 호출되는 함수로 Delegate이다.
{
	if (AnimMontage == M_PickUp)	// 몽타주가 Pickup 일 때
	{
		bIsPickingUp = false;
		bIsControllable = true;
		if (bReleasedLMB)	// 만약 이 부분이 없다면 줍는 애니메이션이 끝나기 전에 마우스 오른쪽 버튼에서 손을 뗀다면 손에 물건이 붙은채로 일반 Idle 상태로 들어간다.
		{					// 몽타주가 끝나는 순간 만약 손을 뗀 상태라면 내려놓게 해주는 부분이다.
			DropDown();
		}
	}
	else if (AnimMontage == M_Throw)	// 던지는 몽타주가 끝날 때
	{
		bIsControllable = true;		// 움직일 수 있게 해줘야 한다.
	}
}

위의 노티파이에 대한 애니메이션이다. (사용 에셋 : 캐릭터 - EpicGames Marketplace - UglyKid3, 애니메이션 - 직접 만듦)

Pickup 애니메이션 - 줍는 순간 손에 물건을 붙여줄 Notify
Throw 애니메이션 - 스페이스 바 홀딩 시 멈출 때 Notify

아래는 Throwable.cpp로 MainCharacter와 상호작용하는 함수들이다.

처음엔 MainCharacter에서 모든 함수를 썼다가 보기 지저분 하기도 하고, 좀 더 최적한 것 같아 Interface로 2차 수정했다. 손으로 이동할 때 자연스럽게 하기위해(어쨋든 물체가 손으로 스윽 움직여 부자연 스럽다...) MoveComponentTo를 사용했으며 물체과 캐릭터간의 충돌을 그 순간 억제하기 위해 물체에서 Pawn과의 충돌을 Ignore해줬다. 그리고 놓으면 Block을 걸어줬다. 그리고 Throw()함수, 즉 던질 때 물건에 순간적인 힘으로 던지기 위해 AddImpulse()를 해줘야 해서 잡고 있는 동안 꺼놨던 Simulate Physics를 다시 켜줘 AddImpulse()해준다.

#include "Master_Throwable.h"
#include "Kismet/KismetSystemLibrary.h"
#include "Kismet/GameplayStatics.h"
#include "MainCharacter.h"

// Sets default values
AMaster_Throwable::AMaster_Throwable()
{
	PrimaryActorTick.bCanEverTick = false;

	StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMesh"));
	RootComponent = StaticMesh;
	StaticMesh->SetSimulatePhysics(true);
	StaticMesh->CanCharacterStepUp(false);
	StaticMesh->SetCollisionProfileName(FName("Custom..."));
	StaticMesh->SetCollisionResponseToChannel(ECollisionChannel::ECC_EngineTraceChannel5, ECollisionResponse::ECR_Block);
}

void AMaster_Throwable::DropDown()	// 떨어뜨리는 함수
{
	StaticMesh->DetachFromComponent(FDetachmentTransformRules::KeepWorldTransform);
	StaticMesh->SetSimulatePhysics(true);
    // Delay
	FTimerHandle WaitHandle;
	float WaitTime = 1.0f;
	GetWorld()->GetTimerManager().SetTimer(WaitHandle, FTimerDelegate::CreateLambda([&]()
		{
        	// 바로 블락을 켜주면 덜덜거리거나 카메라가 흔들리는 문제가 발생해 약간의 시간을 두고 블락을 켜준다.
			StaticMesh->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Block);
		}), WaitTime, false);
}

void AMaster_Throwable::PickUp(FVector PickUpLocation, class AMainCharacter* MainCharacter)	// 주울 때 호출되는 함수
{
	StaticMesh->SetSimulatePhysics(false);	// 캐릭터와의 충돌을 위해 Default 값은 true로 설정했지만, 집을 경우 어쩔 수 없이 캐릭터 메쉬와 겹치는 일이 발생하는데
    										// 그렇게 되면 캐릭터가 멀리 날아가 버리거나 원치 않는 방향으로 이동하는 경우가 발생하여 Simulate도 꺼주고
	StaticMesh->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Ignore);	//	Collision도 캐릭터와 충돌하지 않게 ignore로 설정해준다.

	FLatentActionInfo Info;
	Info.CallbackTarget = this;
	Info.Linkage = 0;
	Info.ExecutionFunction = FName("MoveComponentToFunction");	// MoveComponentTo가 완료되면 호출
	
	UKismetSystemLibrary::MoveComponentTo(
		StaticMesh, 
		PickUpLocation,	// MainCharacter.cpp에서 손 사이의 위치로 설정한 값이다.
		FRotator(0.0f, 0.0f, 0.0f), 
		true, 
		true,
		0.2f,
		false, 
		EMoveComponentAction::Type::Move, 
		Info);
}

void AMaster_Throwable::Throw(class AMainCharacter* MainCharacter, float ChargeRate)	// 던지는 함수
{
	if (M_Throw)
    {
		MainCharacter->PlayAnimMontage(MainCharacter->M_Throw);
    }
	MainCharacter->SetIsControllable(false);	// MainCharacter의 bIsControllable이 private값이므로 이 값을 Set해주는 함수이다.
	StaticMesh->DetachFromComponent(FDetachmentTransformRules::KeepWorldTransform);	// 던지려면 몸에서 떼어내야 하므로 KeepWorld 룰로 떼어낸다.
	StaticMesh->SetSimulatePhysics(true);	// 던질 때 AddImpulse로 던질 것이므로 SimulatePhysics를 true로 해준다.
    // 홀딩 하는 동안 누적되는 게이지를 캐릭터의 앞, 위 대각선 방향에 곱해 힘의 크기와 방향을 만들어준다. 
	FVector ThrowPower = (MainCharacter->TriggerCapsuleComponent->GetForwardVector() * 100.0f + MainCharacter->TriggerCapsuleComponent->GetUpVector() * 300.0f) * ChargeRate * 2000.0f;
	StaticMesh->AddImpulse(ThrowPower);	// 던지기

	// Delay - 던질 때 물건과 캐릭터가 겹쳐 충돌해 덜덜 떨리는 현상이 발생하여 짧은 시간을 주고 충돌을 다시 켜주는 식으로 했다.
	FTimerHandle WaitHandle;
	float WaitTime = 0.2f;
	GetWorld()->GetTimerManager().SetTimer(WaitHandle, FTimerDelegate::CreateLambda([&]()
		{
			StaticMesh->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Block);
		}), WaitTime, false);
}

void AMaster_Throwable::MoveComponentToFunction() // MoveCompnentTo함수 완료시 호출되는 함수로 MoveComponentTo 바로 아래 써넣을 경우 MoveComponentTo가 끝나기도 전에 이 함수가 호출되어 원하지 않는
{												  // 위치로 물건이 이동하게 된다. 블루프린트에선 알아서 해주는데... 
	// 물건을 손에 붙여준다. 따로 소켓은 만들지 않았다.
    StaticMesh->AttachToComponent(Cast<AMainCharacter>(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0))->SkeletalMesh, FAttachmentTransformRules::KeepWorldTransform, FName("hand_r"));
}

-실행 결과

 

-정리

1. 어려움을 겪었던 부분 / 해결

1) 물건과 캐릭터 메쉬의 Attach 문제

 물건을 몸에 붙였을 때 누른 방향키대로 캐릭터가 움직이지 않거나 캐릭터가 멀리 날아가 버리거나 공중에 떠서 움직이는 문제가 발생했다. 왜 그런지 생각해보니 캐릭터와 물건이 겹쳐 있는 상태에서 생기는 문제 였고, SphereTrace를 길게 쏴 메쉬에 닿지 않게 잡아보니 그런 문제가 발생하지 않는 것을 확인했다. 그래서 잡을 때는 물체의 Simulate Physics를 끄고 Collision도 Pawn과 Ignore로 설정했더니 해당 문제가 없어지게 되었다.

2) 노티파이 Delegate 문제

  블루프린트에서는 몽타주 재생에 몽타주 끝날때, 블렌드 아웃될때, 노티파이 시작할 때 등 이미 핀이 있어 이어주기만 하면 되므로 비교적 간단했는데 C++에서는 일일이 함수와 바인딩 해줘야 하고, 관련된 자료도 구글에 거의 없어 직접 몸으로 부딫히는 식으로 해결해 고생 좀 한 부분이다. OnMontageEnded는 만든 함수에 AddDynamic만 해주면 되므로 비교적 간단했지만, Notify관련 Delegate는 아무리 찾아도 관련 자료를 찾지 못해(찾아도 적용이 안됐다.) 일일이 쳐가고 마우스올려다 보며 매개변수 확인한 결과 

FScriptDelegate를 사용하나 보다 하여 추가해서 넣어 줬더니 동작하는 것을 확인했다.

3) 집었을 때 물건이 없어지는 현상

 위에서 설명했지만 물건을 양손 사이에 위치시키고 손에 물건을 붙였더니 이동이 시간이 걸리는 함수라 물건을 붙인 위치인 상태로 Relative한 위치로 이동시켰더니 월드상의 좌표로 이동한 것이었다. 그래서 FLatentActionInfo 구조체에 함수를 연결해 끝나면 발생하도록하여 문제를 해결했다.