Home [UE5 TPS 제작기] 12. Line Trace를 이용한 총 쏘기 구현
Post
Cancel

[UE5 TPS 제작기] 12. Line Trace를 이용한 총 쏘기 구현

2023-09-17. 코드 수정

0. 서론

총을 쏘는 FPS 또는 TPS 게임을 많이 해보았다면 총 쏘는 방식에 대해 크게 2가지로 나뉘어 있다는 걸 알 수 있는데요.

그 2가지는 아래와 같습니다.

  • 발사체 방식
  • Raycast 방식

발사체 방식

발사체 방식을 사용하면 실제로 발사체(Ex. 총알)를 쏘는 것이기 때문에 

  • 발사체에 따른 중력 적용
  • 발사체의 속력

과 같은 다양한 발사체 특징을 적용할 수 있습니다.

이런 발사체 방식을 적용한 게임의 대표적인 예는 배틀필드”**가 있습니다.

RayCast 방식

RayCast 방식을 사용하면 발사와 함께 즉각적인 피드백을 받을 수 있습니다.

그래서 발사체 방식과 다르게 중력 적용, 속력과 같은 속성을 발사체에 따라 구별하지 않고 동일하게 적용하는 편입니다.

RayCast 방식을 사용한 대표적 게임은 “카운터 스트라이크”가 있습니다.

이 글에서는 두 가지 중 RayCast 방식을 이용한 총 쏘기를 구현해 볼 예정입니다.

1. Ray Cast 란

Ray Cast는 ~실제 구현 방법으로 들어가면 엄청 복잡하겠지만~ 기본 개념은 간단합니다.

일단 아래 그림부터 보면 A라는 객체에서 전방을 향해 Ray(광선)을 발사했습니다.

그럼 발사한 Ray는 어딘가에 부딪혔을 텐데 그 부딪힌 객체를 B라고 가정하면 이때 B는 Ray를 맞은 결과이기 때문에 Hit Result라고 합니다.

img

위 그림과 같이 특정 객체에서 Ray(광선)을 발사해 특정 객체를 찾는 것을 Ray Cast라고 합니다.

이 방식은 객체를 찾는 것뿐만 아니라 Ray의 끝에 특정 매시를 그린다든지 다양한 방식으로 사용할 수 있습니다.

2. 구현

2-1. Input 세팅

총을 발사할 때 마우스 왼쪽 버튼을 누르고 떼는 조작법을 많이 사용하고 있습니다.

그러므로, UE에서 지원해 주는 Action Mappings를 사용할 것입니다.

Action Mappings는 “키를 누르고 떼는 것”에 대한 조작을 만들 때 사용하기 좋은 방식입니다.
Ex. 점프(Space Bar 누르기), 앉기(Ctrl 누르기)

아래와 같이 액션 매핑에 “Fire”를 추가해 주고 키는 “왼쪽 마우스 버튼”을 지정해 줍시다.

img_1

그리고 C++코드로 돌아와서 추가한 “Fire” Action을 바인딩해 줍니다.

1
2
3
4
// APlayerCharacter.h 파일
protected:
	void StartFire() const;
	void StopFire() const;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// APlayerCharacter.cpp 파일
void APlayerCharacter::StartFire()
{

}

void APlayerCharacter::StopFire()
{

}

void APlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);

	// "Fire" Action Mappings에 대해 메서드 바인딩
	PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &APlayerCharacter::StartFire);
	PlayerInputComponent->BindAction("Fire", IE_Released, this, &APlayerCharacter::StopFire);
}

액션 매핑에 대한 바인딩으로 BindAction 메서드를 사용하는 걸 볼 수 있습니다.

여기서 IE_Pressed, IE_Released라는 파라미터를 볼 수 있는데 해당 값은 EInputEvent라는 enum값을 사용하고 있습니다.

EInputEvent는 enum class가 아니라 enum으로 되어 있습니다.
그래서 EInputEvent::IE_Pressed가 아니라 IE_Pressed로 접근이 가능한 것입니다.

자세한 내용은 C++11 enum class를 검색!!

1
2
3
4
5
6
7
8
9
10
11
12
13
//
//	EInputEvent
//
UENUM( BlueprintType, meta=(ScriptName="InputEventType"))
enum EInputEvent
{
	IE_Pressed              =0,
	IE_Released             =1,
	IE_Repeat               =2,
	IE_DoubleClick          =3,
	IE_Axis                 =4,
	IE_MAX                  =5,
};

변수명 그대로

  • Pressed는 눌렀을 때
  • Released는 버튼을 뗐을 때

라고 보면 됩니다.

그러므로, 바인딩 코드를 해석하면 아래와 같습니다.

  • 마우스 왼쪽 버튼을 눌렀을 때: StartFire 메서드 실행
  • 마우스 왼쪽 버튼을 뗐을 때: StopFire 메서드 실행
1
2
3
// "Fire" Action Mappings에 대해 메서드 바인딩
PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &APlayerCharacter::StartFire);
PlayerInputComponent->BindAction("Fire", IE_Released, this, &APlayerCharacter::StopFire);

2-2. StartFire, StopFire 메서드 구현

시작하기 앞서 우리는 이전에 무기를 붙이는 코드를 작성하였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// APlayerCharacter.cpp 파일
void APlayerCharacter::AttachWeapon(TSubclassOf<class AWeapon> weapon)
{
	if (weapon)
	{
		_equipWeapon = GetWorld()->SpawnActor<AWeapon>(weapon);

		const USkeletalMeshSocket* weaponSocket = GetMesh()->GetSocketByName("WeaponSocket");

		if (_equipWeapon && weaponSocket)
		{
			weaponSocket->AttachActor(_equipWeapon, GetMesh());
		}
	}
}

여기에 있는 _equipWeapon 멤버 변수는 현재 내가 장착하고 있는 무기에 대한 정보를 가지고 있습니다.

그리고 발사 기능은 무기마다 다르므로 무기 액터 내에 정의하는 것이 맞다고 생각했기에 _equipWeapon의 메서드를 호출해 발사 기능을 구현할 것입니다.

이를 참고해서 StartFire() 메서드를 구현해 보면 아래와 같습니다.

파라미터 값으로 this를 넣어준 이유는 2-3의 “AssaultRifle 구현”에서 설명하고 있습니다.

1
2
3
4
5
6
7
void APlayerCharacter::StartFire()
{
	if (_equipWeapon)
	{
		_equipWeapon->StartFire(this);
	}
}

발사 기능이 무기 액터 내에 있기 때문에 자연스럽게 발사를 멈추는 기능 또한 무기 액터에 존재하는 게 좋을 것 같기에

StopFire() 메서드 또한 아래와 같이 정의할 수 있습니다.

1
2
3
4
5
6
7
void APlayerCharacter::StopFire()
{
	if (_equipWeapon)
	{
		_equipWeapon->StopFire();
	}
}

2-3. 무기 액터 내 StartFire, StopFire구현

아래 내용을 읽었다는 가정 하에 진행하는 글입니다.
클래스 구조 또한 아래 글에 설명되어 있으니 꼭 한번 읽으신 후에 아래 내용을 읽어주세요
[UE5] 10. Weapon Actor 생성

Weapon.h 파일 구현

우리는 무기 액터를 상속 구조로 만들고 있습니다.

img_2

그러므로 공용으로 사용하는 무기 발사를 시작하는 메서드, 무기 발사를 멈추는 메서드는 Weapon 클래스에 비순수 가상 함수로 만들어서 자식에서 재정의하게 할 것입니다.

그리고 무기마다 발사 방식이 다를 수 있으므로, enum class를 사용해서 FireType를 정의했습니다.

이를 모두 포함한 Weapon.h의 전체 코드는 아래와 같다.

StartFire()와 StopFire()가 가상함수인 것에 주의하자!

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Weapon.generated.h"

/** 발사 타입 */
UENUM(BlueprintType)
enum class EFireType : uint8
{
	EF_LineTrace	UMETA(DisplayName = "Line Trace"),
	EF_Projectile	UMETA(DisplayName = "Projectile"),
};

UCLASS(Abstract)
class TPS_PROTOTYPE_API AWeapon : public AActor
{
	GENERATED_BODY()
	

public:	
	// Sets default values for this actor's properties
	AWeapon();
	inline int GetAmmoMaxCount() { return _ammoMaxCount; }
	inline float GetReloadingDelayTime() { return _reloadingDelayTime; }
protected:
	/** 액터의 스켈레톤 매시*/
	UPROPERTY(EditAnywhere, meta = (AllowPrivateAccess = "true"))
	class USkeletalMeshComponent* SkeletalMeshComponent;

	/** 탄약 최대 개수 */
	UPROPERTY(EditAnywhere,Category ="Weapon Properties", meta = (AllowPrivateAccess = "true"))
	int _ammoMaxCount = 30;

	/** 현재 소지한 탄약의 개수*/
	UPROPERTY(EditAnywhere, Category = "Weapon Properties", meta = (AllowPrivateAccess = "true"))
	int _ammoRemainCount;

	/** 재장전까지 걸리는 시간 */
	UPROPERTY(EditAnywhere, Category = "Weapon Properties", meta = (AllowPrivateAccess = "true"))
	float _reloadingDelayTime = 3.f;

	/** 발사 간의 간격 */
	UPROPERTY(EditAnywhere, Category = "Weapon Properties", meta = (AllowPrivateAccess = "true"))
	float _fireInterval = 0.1f;

	/** Line Trace의 Ray 길이 */
	UPROPERTY(EditAnywhere, Category = "Weapon Properties", meta = (AllowPrivateAccess = "true"))
	float _traceDistance = 1000.f;

	/** 발사 타입 */
	UPROPERTY(EditAnywhere, Category = "Weapon Properties", meta = (AllowPrivateAccess = "true"))
	EFireType _fireType = EFireType::EF_LineTrace;

	// Called when the game starts or when spawned
	virtual void BeginPlay() override;

public:	
	/** 발사를 시작하는 메서드 */
	virtual void StartFire(/*const ACharacter* owner */ TWeakObjectPtr<APlayerCharacter> owner);

	/** 발사를 멈추는 메서드*/
	virtual void StopFire();

	/** 재장전 메서드*/
	virtual void Reloading();
	// Called every frame
	virtual void Tick(float DeltaTime) override;
};

2-4. AssaultRifle::StartFire 메서드 구현

먼저 AssaultRifle::StartFire 메서드를 구현해 볼 것입니다.

첫 번째로 생각해야 할 것은 유저가 쏘기 버튼(마우스 왼쪽 버튼)을 꾹 누르고 있을 때 발사 간 간격에 맞추어 총을 쏴야 한다는 것입니다.

Unity라면 코루틴을 사용해서 구현할 것 같아 UE에서도 동일한 기능을 하는 메서드가 있는지를 찾아보았는데

1
GetWorldTimerManager().SetTimer

메서드를 발견했고 이것을 이용해 구현할 것입니다

아래 공식 문서에 설명이 잘 되어 있습니다.
공식 문서를 읽고 아래 코드를 보면 이해가 쉽습니다

 게임플레이 타이머

1
2
3
4
5
6
7
8
9
UCLASS()
class TPS_PROTOTYPE_API AAssaultRifle : public AWeapon
{
	GENERATED_BODY()
private:
	/** 발사 기능 타이머용 변수 
    	* 이 변수에 적용된 타이머에 대한 정보를 가지고 있다고 보면 된다. */
	FTimerHandle FireTimerHandle;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void AAssaultRifle::StartFire(/*const ACharacter* owner */ TWeakObjectPtr<APlayerCharacter> owner)
{
	auto Character = owner.Get();
	if (Character)
	{
		switch (_fireType)
		{
			case EFireType::EF_LineTrace:
				GetWorldTimerManager().SetTimer(
						FireTimerHandle, // 해당 타이머를 관리하는 변수
						[&Character, this]() { FireWithLineTrace(Character); },  // 호출할 메서드
						_fireInterval,// 호출 간 간격
						true); // 루프 여부
			break;
			case EFireType::EF_Projectile:
			break;
		}
	}
}

이제 타이머를 통해 특정 주기마다 람다로 처리한 [owner, this]() { FireWithLineTrace(owner); }를 호출할 것입니다.

람다로 들어간 곳의 파라미터 값은 아래와 같습니다.
<void()> &&CallBack

이제 FireWithLineTrace(owner) 메서드에서 어떻게 Ray를 사용하는지 확인해 봅시다!!

2-5. LineTrace

RayCast 방식을 이용해서 발사 기능을 구현할 것이기 때문에 UE에서 Ray Cast로 지원하는 LineTraceSingleByChannel를 이용할 것입니다.

LineTrace를 보면 Single과 Multi가 있는데 이 둘의 차이는 Hit에 결과가 단수냐 복수냐의 차이

1
2
3
4
5
6
7
8
9
10
11
// Single은 OutHit이 하나이다.
bool UWorld::LineTraceSingleByChannel(struct FHitResult& OutHit,const FVector& Start,const FVector& End,ECollisionChannel TraceChannel,const FCollisionQueryParams& Params /* = FCollisionQueryParams::DefaultQueryParam */, const FCollisionResponseParams& ResponseParam /* = FCollisionResponseParams::DefaultResponseParam */) const
{
	return FPhysicsInterface::RaycastSingle(this, OutHit, Start, End, TraceChannel, Params, ResponseParam, FCollisionObjectQueryParams::DefaultObjectQueryParam);
}

// Multi는 OutHits가 배열이다.
bool UWorld::LineTraceMultiByChannel(TArray<struct FHitResult>& OutHits,const FVector& Start,const FVector& End,ECollisionChannel TraceChannel,const FCollisionQueryParams& Params /* = FCollisionQueryParams::DefaultQueryParam */, const FCollisionResponseParams& ResponseParam /* = FCollisionResponseParams::DefaultResponseParam */) const
{
	return FPhysicsInterface::RaycastMulti(this, OutHits, Start, End, TraceChannel, Params, ResponseParam, FCollisionObjectQueryParams::DefaultObjectQueryParam);
}

 UWorld::LineTraceSingleByChannel

Ray가 발사되는 시작점

LineTrace를 사용하려면 Ray가 발사되는 시작 지점을 정의해야 합니다.

시작 지점은 무기의 길이, 총구 위치에 따라 다 다르기 때문에 스켈레톤 메시의 소켓을 이용해서 시작 지점을 잡아줄 것이고 현재 구현하는 AssaultRifle 스켈레톤 매시에 FireSocket을 생성해 줍니다.

img_3

스켈레톤 메시 내 GetSocketLocation 메서드를 통해 소켓의 위치를 start 벡터에 할당해 줍니다.

1
const FVector start = _skeletalMeshComponent->GetSocketLocation("FireSocket");

Ray가 발사되는 종료 지점

이제 Ray의 종료 지점을 만들어야 하는데 Ray의 종료 지점은

((현재 유저가 조준하고 있는 방향벡터 * Ray의 총길이) + Start 벡터의 위치)입니다.

방향벡터를 Ray 길이만큼 늘려주고 Start 벡터를 이용해 평행 이동

유저가 조준하고 있는 방향벡터는 유저 컨트롤러의 회전 값의 Vector이기 때문에 아래와 같이 구할 수 있습니다.

FRtoator::Vector() 공식 문서를 보면 아래와 같이 설명하고 있습니다.
“회전을 해당 방향을 향하는 단위 벡터로 변환합니다”

1
const FVector end = (ownerController->GetControlRotation().Vector() * _traceDistance) + start;

FCollisionQueryParams

Ray가 가는 도중에 현재 자신의 객체에 대해 충돌 반응이 일어나면 안 되기 때문에

FCollisionQueryParams::AddIgnoredActor()를 통해 자기 자신을 무시하도록 지정해 줍니다.

FCollisionQueryParams 공식 문서

1
2
FCollisionQueryParams collisionParams;
collisionParams.AddIgnoredActor(this);

DrawDebugLine

실제로 Ray가 정상적으로 잘 가는지 확인하기 위해서 DebugLine을 그려주면 좋을 것 같습니다.

위에서 구한 Start와 End Vector를 통해 디버깅용 라인을 그려줍니다.

1
DrawDebugLine(GetWorld(), start, end, FColor::Red, false, 1.0f);

UWorld::LineTraceSingleByChannel 메서드 구현

이제 실제로 Ray를 발사하기 위해 UWorld::LineTraceSingleByChannel 메서드를 호출할 것입니다.

UWorld에 해당하는 GetWorld()를 통해서 LineTraceSingleByChannel 메서드를 호출해 봅시다.

ECollisionChannel는 보이는 거에 대해 전부 Hit가 되어야 할 것 같아 ECC_Visibility로 하였습니다.

이유는
게임에서 보이는 특정 물체에 Ray가 닿았을 때 물체에 총알이 부딪혔다는 흔적을 보여주기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const UWorld* currentWorld = GetWorld();
if (currentWorld)
{
    DrawDebugLine(currentWorld, start, end, FColor::Red, false, 1.0f);

	// 명중!
    if (currentWorld->LineTraceSingleByChannel(
        hitResult,
        start,
        end,
        ECC_Visibility,
        collisionParams))
    {
    
    }
}

Hit(명중)되었을 때 디버깅 처리

LineTrace가 Hit(명중!)되었을 때 잘 명중이 되었는지 확인하기 위해 명중된 액터의 이름을 에디터 디스플레이에 노출되도록 합니다.

1
2
3
4
5
if (hitResult.GetActor())
{
    auto* hitActor = hitResult.GetActor();
    GEngine->AddOnScreenDebugMessage(-1, 1.f, FColor::Red, FString::Printf(TEXT("Hit Actor Name: %s"), *hitActor->GetName()));
}

이로써 FireWithLineTrace 메서드에 필요한 코드는 전부 구현했습니다.

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
37
38
39
void AAssaultRifle::FireWithLineTrace(/*const ACharacter* owner */ TWeakObjectPtr<APlayerCharacter> owner)
{
	if (_ammoRemainCount <= 0)
	{
		StopFire();
		return;
	}

	auto character = owner.Get();

	if (character)
	{
		const AController* ownerController = character->GetController();

		if (ownerController)
		{
			const FVector start = SkeletalMeshComponent->GetSocketLocation("FireSocket");
			const FVector end = (ownerController->GetControlRotation().Vector() * _traceDistance) + start;
			FHitResult hitResult;
			FCollisionQueryParams collisionParams;
			collisionParams.AddIgnoredActor(this);

			DrawDebugLine(GetWorld(), start, end, FColor::Red, false, 1.0f);
			if (GetWorld()->LineTraceSingleByChannel(
				hitResult,
				start,
				end,
				ECC_Visibility,
				collisionParams))
			{
				if (hitResult.GetActor())
				{
					auto* hitActor = hitResult.GetActor();
					GEngine->AddOnScreenDebugMessage(-1, 1.f, FColor::Red,FString::Printf(TEXT("Hit Actor Name: %s"),*hitActor->GetName()));
				}
			}
		}
	}
}

2-6. AssaultRifle::StopFire 메서드 구현

StopFire는 매우 간단한데 AssaultRifle::StartFire 메서드를 통해 재생된 타이머를 Clear 시켜주기만 하면 됩니다.

1
2
3
4
5
6
7
void AAssaultRifle::StopFire()
{
	if (FireTimerHandle.IsValid())
	{
		GetWorldTimerManager().ClearTimer(FireTimerHandle);
	}
}

3. 결과

DebugDrawLine을 통해 Ray가 어떻게 나가는지 확인할 수 있으며 Hit 된 액터의 이름 또한 디스플레이에 잘 찍히는 것을 볼 수 있습니다.

그리고 발사 버튼을 꾹 누르고 있을 때 연사로 발사되는 것을 볼 수 있으며 발사 버튼을 뗐을 때 발사가 멈추는 것 또한 확인할 수 있습니다.

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

[UE] TsubclassOf

[C#] "unsafe"로 포인터 사용하기