Class GameDev* SheepAdult

[Unreal Engine 4.27] Push & Pull Object - C++ 본문

Unreal Project

[Unreal Engine 4.27] Push & Pull Object - C++

SheepAdult 2022. 3. 1. 20:47

결과물 Youtube Link :

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

 

사이드 뷰 게임에서의 밀고 당기기 기능을 어느 정도 구현해봤다. 처음에 감이 잘 잡히지 않아 정리를 해보고 구현했다. 본문 같은 경우 AddForce 같은 물리로 구현한 것이 아닌 AddWorldOffset과 AddWorldRotation을 사용해서 굉장히 자연스러운 구현은 못했지만 원하는 결과는 어느 정도 얻었다.

 

 먼저 원리는 이렇다.

우선 개인적인 생각으로는 본인이 물건을 잡고있는 방향의 옆 방향을 입력했을 때는 움직일 수 없게 하는 게 자연스럽다고 생각했다.

<그림 1> 밀거나 당길 수 있는 각도의 범위
<그림 2> 왼쪽 아래를 입력한 모습

 <그림 1>에서 (1)은 움직일 수 없는 각도이고 (2)는 움직일 수 있는 각도이다. 만약 움직이는 키를 왼쪽 아래 방향을 입력한다면 왼쪽 아래 방향으로 서서히 이동하다가 <그림 2>와 같은 모습을 유지하며 움직일 것이다. 그리고 아래 키를 누르면 다시 <그림 1>의 모습으로 서서히 이동하는 것으로 생각했고 <그림 2>에서 오른쪽 아래 키나 왼쪽 위키를 누르면 움직일 수 없을 것이라고 생각했다. 보통 8방향만 주로 입력하므로 입력 방향 벡터와 캐릭터의 포워드 벡터를 내적 한 후 Cos(코사인)을 해주면 움직일 수 없는 각도는 1과 가까운 수가 되므로 1과 가까운 수일 경우 이동을 제한시키면 된다.

 

 그리고 밀기와 당기기의 경우가 다르므로(많이 다르진 않다.) 미는 경우와 당기는 경우를 나누어 주어야 했다. 그 후 어느 방향으로 눌렀을 때 돌아가야 하는 남은 Rotation을 구해 그만큼 Rotate 한 후 멈추는 방식으로 구현했다. 밀기와 당기기는 위의 경우에서 Cos를 뺀 내적 값 만으로 밀기와 당기기를 구분할 수 있다.

&amp;lt;그림 3&amp;gt; 움직여야 하는 각도

 <그림 3>은 오른쪽 위키를 쭉 누르고 있을 때(밀기) 움직여야 하는 각도이다. 그리고 왼쪽 아래키를 누르고 있을 때(당기기) 움직여야 하는 각도이기도 하다. 이 경우 입력한 방향의 벡터와 플레이어의 포워드 벡터의 절대 각도 차를 구하여 구현할 수 있을 것이라 생각했다. 

 

 사실 원리만 보면 간단한데 실제로 할땐 비록 간단한 개념이지만 수학도 익숙지 않고 Physics로 구현하려다가 실패해서 많은 시간이 걸렸다,,

 

 먼저 Axis 입력 축 값은 많이 사용하므로 변수에 저장해주고 IsAttached가 false일 땐 일반 움직임, true일 땐 Push 나 Pull인 경우로 이동한다. 

void AMainCharacter::MoveForward(float AxisValue)
{
	XAxisValue = AxisValue;
	if (AxisValue != 0.0f)
       ...
       ...
       ...
void AMainCharacter::MovableMode()
{
	if (!(XAxisValue == 0.0f && YAxisValue == 0.0f) && CanMove())
	{
    	const int Power = 100;
		FVector StartLoc = TriggerCapsuleComponent->GetComponentLocation();
		FVector EndLoc = StartLoc + TriggerCapsuleComponent->GetForwardVector() * Power;
		TArray<AActor*> ToIgnore;
		FHitResult OutHit;

		bool bIsOutHit = UKismetSystemLibrary::LineTraceSingle(
        	GetWorld(), 
            StartLoc, 
            EndLoc, 
            ETraceTypeQuery::TraceTypeQuery1, 
            false,
			ToIgnore, 
            EDrawDebugTrace::None, 
            OutHit, 
            true
        );

		if (bIsOutHit)
		{
        	...
			bool bIsPushing = IsPushOrPull();
            FVector Loc = FVector(XAxisValue * MoveSpeed, YAxisValue * MoveSpeed, 0.0f);
            FRotator Rot = FRotator(0.0f, GetMovableObjRotateValue(bIsPushing), 0.0f);
            MovableObj->SetTransform(Loc, Rot);
            ...
		}
	}
}

// Movable.cpp
void AMaster_Movable::SetTransform(FVector Loc, FRotator Rot)
{
	StaticMesh->AddWorldOffset(Loc * FApp::GetDeltaTime() * 60);
	StaticMesh->AddWorldRotation(Rot * FApp::GetDeltaTime() * 60);
}

// 다시 MainCharacter
bool AMainCharacter::CanMove()
{
	// 캐릭터의 앞 벡터와 입력 축 값을 내적하여 45도 단위로 끊기 위한 cos연산
	FVector ForwardVec = TriggerCapsuleComponent->GetForwardVector();
	float cos = FMath::Cos(FVector2D::DotProduct(FVector2D(XAxisValue, YAxisValue), FVector2D(ForwardVec.X, ForwardVec.Y)));
	// 특정 각도에서는 움직일 수 없게 하기 위한 반환 값
	return !UKismetMathLibrary::NearlyEqual_FloatFloat(cos, 1.0f, 0.1f);
}

bool AMainCharacter::IsPushOrPull()
{
	// 캐릭터 앞벡터(캐릭터와 닿은 면의 노말 벡터)와 입력축 값의 내적을 통해 앞, 뒤를 구분 
	// 양수는 밀기, 음수는 당기기
	return FVector2D::DotProduct(FVector2D(XAxisValue, YAxisValue), FVector2D(MovableObjNorm.X, MovableObjNorm.Y)) < 0.0f;
}

float  AMainCharacter:GetMovableObjRotateValue(bool bIsPush)
{
	// xy평면으로 봤을 때, 캐릭터의 앞 벡터의 절대 각도를 구하기 위한 벡터
	float Val;
	bIsPush ? Val = -1.f : Val = 1.f;
	FVector Vec = Val * MovableObjNorm;
	// 입력 축 값의 절대 각도와 캐릭터의 앞 벡터의 절대 각도 차이를 계산
	float DegreeGap = UKismetMathLibrary::DegAtan2(XAxisValue, YAxisValue) - UKismetMathLibrary::DegAtan2(Vec.X, Vec.Y);
	// 180도가 넘어가거나 -180도보다 작아지면 범위 내로 들어오게 Normalize
	if (DegreeGap < -180.0f) DegreeGap += 360.0f;
	if (DegreeGap > 180.0f) DegreeGap -= 360.0f;
	// 각도의 차가 줄어들 수록 회전 속도가 느리게 변화하는 문제를 막기 위한 연산이며 -500은 속도 조절
	float RotateValue = DegreeGap / ((FMath::Abs(DegreeGap) / 90.0f) * -500.0f);
	return RotateValue;
}

위는 사용하는 함수들이다. 위의 코드 중 아래에서 두 번째 함수가 PushPullRotSetting()이다. 밀기와 당기기는 반대 방향이므로(원리가 반대) -1 곱해주는 것으로 똑같이 맞춰줄 수 있다. 박스의 부딪힌 지점의 Normal 벡터의 절대 각도와 입력 축의 절대 각도를 Atan2(Degree)로 구해주고 차이를 줘 얼마나 움직여야 할지 정했다. 그런데 여기서 우리가 원하는 값은 90도 이내의 각도인데 어느 방향에선 둔각이나 180도 이상 넘어가는 각이 나와 45도만 움직이면 되는걸 135도나 315도를 움직이는 경우가 발생해 큰 값을 줄여주는 부분을 추가했다. (더 좋은 방법이 있을 것 같은데 언리얼 2달 차로 썬 모르겠다...) 큰 경우와 작은 경우를 모두 보정해주고 그냥 값을 넘겨 봤는데 점점 원하는 각도로 방향을 틀 수록 Rotate속도가 비례하여 감소하는 일이 발생했다. 그래서 이를 막아 주고자 약간 하드코딩적인 방법인데,, 반비례하는 값을 곱해줘 보정을 해줬다. 절댓값(ABS)을 90으로 나눠서 특정값(1이 들어가면 휙휙 방향이 바뀌어서 스르륵 하게 움직이려고 -1000 값을 최종 값에 나누어주는 것이다.)을 곱한 뒤 이를 기존 값에 나누어 주면 남은 각도가 적을수록 반비례하는 큰 값을 곱하게 되어 일정한 속도로 목표 각도까지 돌아간다. 참고로 박스는 Constraint 각도만 X, Y 잠그고 끝이다.

 

void AMainCharacter::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	if (bIsAttachedAtMovable)
	{
		MovableMode();
	}
 }

 

MovableObjPushPull()은 위와 같이 틱에서 사용한다.

움직일 수 있는 물체(이 후 '박스'로 부름)에 가까이 간 후 왼쪽 마우스 버튼을 누르면 박스를 잡는(사실은 잡히는...) 부분이다. 사이드 뷰 게임에서의 코드이다 보니 카메라에서 LineTrace를 쏘는 게 아닌 캐릭터 캡슐에서 쏴 특정 거리 안에 박스가 있다면 캐릭터 캡슐을 붙인다. 이때 AttachRule은 Keep World로 놓았으며 IsAttached라는 bool값으로 붙었는지 안 붙었는지를 판단했다. 그리고 방향 전환 시 일반적인 사이드 뷰 게임은 메쉬 자체도 돌아가는데 물건을 잡고 당기거나 밀 경우엔 메쉬가 돌아가면 안 되므로 CharacterMovementComponent의 Orient Rotation To Movement를 false로 바꾸어 줬다. 그리고 잡기 전엔 일반적인 충돌로는 움직이게 해주고 싶지 않아 기존엔 박스의 SetSimulate를 false를 해놓았다가 잡으면 true, 다시 놓으면 false로 바꿔줬다. 

void AMainCharacter::LineTraceLMB()
{
	if (!bIsCrouching)
	{
		FVector StartLoc1 = TriggerCapsuleComponent->GetRelativeLocation() + TriggerCapsuleComponent->GetRightVector() * 20.0f + TriggerCapsuleComponent->GetUpVector() * 30.0f;
		FVector EndLoc1 = StartLoc1 + TriggerCapsuleComponent->GetForwardVector() * 40.0f;
		TArray<AActor*> ToIgnore1;
		FHitResult OutHit1;
		bool bIsOutHit1 = UKismetSystemLibrary::LineTraceSingle(GetWorld(), StartLoc1, EndLoc1, ETraceTypeQuery::TraceTypeQuery1, false,
			ToIgnore1, EDrawDebugTrace::None, OutHit1, true);

		FVector StartLoc2 = TriggerCapsuleComponent->GetRelativeLocation() + TriggerCapsuleComponent->GetRightVector() * -20.0f + TriggerCapsuleComponent->GetUpVector() * 30.0f;
		FVector EndLoc2 = StartLoc2 + TriggerCapsuleComponent->GetForwardVector() * 40.0f;
		TArray<AActor*> ToIgnore2;
		FHitResult OutHit2;
		bool bIsOutHit2 = UKismetSystemLibrary::LineTraceSingle(GetWorld(), StartLoc2, EndLoc2, ETraceTypeQuery::TraceTypeQuery1, false,
			ToIgnore2, EDrawDebugTrace::None, OutHit2, true);

		if (bIsOutHit1 && bIsOutHit2)
		{

			MovableObj = Cast<AMaster_Movable>(OutHit1.Actor);
			if (MovableObj)
			{
				if (!bDoOncePushPull)
				{
                	// 밀고 당길 때는 입력 값에 따라 캐릭터 메쉬의 회전을 제한하기 위함이다.
					CharacterMovementComponent->bOrientRotationToMovement = false;
					bIsAttachedAtMovable = true;
					MovableObj->StaticMesh->SetSimulatePhysics(true);

					FVector BoxLoc = TriggerCapsuleComponent->GetComponentLocation();
					MovableObj->Box->SetWorldLocation(BoxLoc + OutHit1.ImpactNormal * 30);
					MovableObj->Box->SetCollisionProfileName("Custom...");
                    // 캐릭터가 물체에 붙어있는 동안 불필요한 충돌을 제거한다.
					MovableObj->Box->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Ignore);
					MovableObj->Box->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);

					FLatentActionInfo Info;
					Info.CallbackTarget = this;
					Info.ExecutionFunction = "MovableMoveComponentToFunc";
					Info.Linkage = 0;

					UKismetSystemLibrary::MoveComponentTo
					(
						TriggerCapsuleComponent,
						TriggerCapsuleComponent->GetRelativeLocation() + OutHit1.ImpactNormal * 30,
                        // 이상한 각도로 잡지 않게 하기 위함
						UKismetMathLibrary::MakeRotFromX(OutHit1.Normal * -1),
						true,
						true,
						0.2,
						false,
						EMoveComponentAction::Move,
						Info
					);

					bDoOncePushPull = true;
				}
			}
		}
	}
}

// 빼먹어서 2022-08-09에 추가
void AMainCharacter::MovableMoveComponentToFunc()
{
	if (bIsAttachedAtMovable)
	{
		TriggerCapsuleComponent->AttachToComponent(MovableObj->StaticMesh, FAttachmentTransformRules::KeepWorldTransform);
	}
}

위는 왼쪽 마우스 버튼을 눌렀을 때, 떼었을 때의 함수에서 호출하는 함수이다. 캐릭터를 박스에 붙이는 역할을 한다. 여기서는 LineTrace 두줄을 쏘는 이유는 두 손으로 잡기 위해서이다.

 

여기까지의 결과는 대충 아래와 같다.

당기기

 

밀기

해당 gif에서는 물체의 위치에 맞게 HandIK를 적용해 놓은 상태이며 당길 때 자연스럽게 하기위해 움직임에 변화를 주었다. 위에 코드만으로는 손의 위치가 맞지 않을 수 있으며 밀때와 당길때 속도가 같을 것이다.

 

+추가 2022-04-07

밀 수 없는 각도일 때 방향키를 누르면 애니메이션이 나가는 버그가 있어 수정했다.

밀 수 있을 때의 bool 값을 AnimInstance로 받아오고 해당 bool 값에 따른 애니메이션 블렌딩을 사용하여 해결했다.