Home [UE5 TPS 제작기] 13. 발사체(Projectile)를 이용한 총 쏘기 구현
Post
Cancel

[UE5 TPS 제작기] 13. 발사체(Projectile)를 이용한 총 쏘기 구현

2023-09-17. 코드 수정

0. 서론

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

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

  • 발사체 방식
  • Raycast 방식

발사체 방식

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

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

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

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

RayCast 방식

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

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

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

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

LineTrace 방식을 이용한 총 쏘기가 궁금하다면?(클릭)

1. 발사체(Projectile)이란?

발사체 방식이란 우리가 현실 세계에서 총을 쏠 때와 동일한 방식입니다.

즉, 실제 총알(발사체)이 날아가 적에게 부딪혀 대미지를 주는 방식인데요.

실제 객체가 날라가기 때문에 중력과 같은 실제 객체에 영향을 주는 값들을 넣을 수 있습니다.

2. 구현

2-1. Input 세팅

아래 글을 보기 전에 이전 글들을 꼭 읽고 와주세요 [UE5] 10. Weapon Actor 생성 [UE5] 11. Skeletal Mesh Socket을 이용한 무기 장착

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

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

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

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

img

그리고 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
16
// 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_1

그러므로 공용으로 사용하는 무기 발사를 시작하는 메서드, 무기 발사를 멈추는 메서드는 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
/** 발사 타입 */
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,Category = "Mesh", meta = (AllowPrivateAccess = "true"))
	class USkeletalMeshComponent* _skeletalMeshComponent;

	/** 탄약의 스태틱 매시*/
	UPROPERTY(EditAnywhere, Category = "Mesh", meta = (AllowPrivateAccess = "true"))
	TSubclassOf<class AARBullet> Bullet;

	/** 탄약 최대 개수 */
	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_Projectile;

	// 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
20
void AAssaultRifle::StartFire(/*const ACharacter* owner */ TWeakObjectPtr<APlayerCharacter> owner)
{
	auto Character = owner.Get();
	if (Character)
	{
		switch (_fireType)
		{
			case EFireType::EF_LineTrace:
			// 생략
			break;
			case EFireType::EF_Projectile:
				GetWorldTimerManager().SetTimer(
					FireTimerHandle,
					[Character,this]() { FireWithProjectile(Character); },
					_fireInterval,
					true);
			break;
		}
	}
}

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

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

이제 FireWithProjectile(owner) 메서드에서 어떻게 발사체를 쏘는지 확인해 봅시다!!

2-5. FireWithProjectile(owner) 구현

구현하기 앞서, 먼저 생각해봐야 할 것들이 있는데 이를 정리해 보았습니다.

발사체(Projectile)가 발사되는 시작점

발사체 방식을 사용하려면 시작 지점을 정의해야 합니다.

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

img_2

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

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

발사체가 나아가야 할 방향 구하기

이제 발사체가 나아가야 방향을 구해야 하는데

구하기 위해서는 시작 지점 기준으로 캐릭터가 보고 있는 방향이 어디인지 확인해야 합니다.

img_3

캐릭터가 보고 있는 방향을 구하기 위해서는 “현재 유저가 조준하고 있는 방향벡터 + start 벡터 위치”로 구할 수 있습니다.

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

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

1
ownerController->GetControlRotation().Vector()

여기에 start 벡터를 이용하여 위에서 구한 벡터를 평행 이동하여 start 벡터보다 조금 더 앞서 있는 벡터를 구할 수 있습니다.

이를 end 벡터라 하겠습니다.

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

end벡터에서 start벡터를 빼서 발사체가 나아가야 할 방향을 구합니다.

1
FVector Direction = end - start;

FireWithProjectile() 구현

위에서 확인했던 내용들을 바탕으로 발사체를 월드에 스폰하고 발사체 액터에 정의된 발사 함수를 호출하여 발사체를 발사할 것입니다.

함수를 구현하면 아래와 같습니다.

발사체 액터에 대한 내용은 바로 아래 “2-6. ARBullet 클래스”에서 확인하실 수 있습니다.

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

	auto Character = owner.Get();

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

		if (ownerController)
		{
			FVector start = _skeletalMeshComponent->GetSocketLocation("FireSocket");
			FVector end = (ownerController->GetControlRotation().Vector()) + start;
			FVector Direction = end - start;
			
			UWorld* world = GetWorld();

			if (world)
			{
				// 발사체 액터인 AARBullet 생성
				AARBullet* bullet = world->SpawnActor<AARBullet>(Bullet);

				if (bullet)
				{
					// 생성된 액터 출발 위치 지정
					bullet->SetActorLocation(start);
					// 생성된 액터 방향 지정(에셋이 위를 쳐다보고 있어 조정)
					bullet->SetActorRotation(Direction.Rotation());
					bullet->AddActorLocalRotation(FRotator(-90, 0, 0));
                    
					// 방향벡터를 정규화
					if (Direction.Normalize())
					{
						// 정규화에 성공하면 Fire 함수에 넣어준다.
						// Fire 함수에 대한 내용은 "2-6" 글에서 확인하자!
						bullet->Fire(Direction);
						_ammoRemainCount--;
					}
				}
			}
		}
	}
}

2-6. ARBullet 클래스

발사체는 실제 객체이기 때문에 액터 클래스의 파생 클래스가 필요합니다.

이를 위해 “ARBullet”이란 이름의 클래스를 생성해 줍니다.

Mesh 생성

ARBullet에는 스켈레톤 애니메이션이 필요하지 않기 때문에 UStaticMeshComponent을 이용하여 메시를 만들어 줍니다.

Static Mesh 란?

UStaticMeshComponent 란?

Projectile 움직임 제어

그리고 발사체 움직임을 제어하기 위해 UE에서 지원하는 UProjectileMovementComponent를 사용할 것입니다.

아래 링크를 살펴보면 Projectile에서 필요한 초기 및 최대 속도 값, 바운스 여부 등이 정의되어 있음을 확인할 수 있습니다.

UProjectileMovementComponent

충돌 체크

충돌 체크는 Hit이 아닌 Overlap을 이용할 것입니다.

Overlap이 아닌 경우 총알끼리 부딪혀 원하는 방향으로 가지 않는 문제가 생길 수 있기 때문에 Overlap을 사용했습니다.

ARBullet클래스는 AActor 클래스의 파생 클래스이기 때문에 NotifyActorBeginOverlap과 NotifyActorEndOverlap를 사용하여 구현할 것입니다.

1
2
virtual void NotifyActorBeginOverlap(AActor* OtherActor) override;
virtual void NotifyActorEndOverlap(AActor* OtherActor) override;

자세한 내용은 아래 글에서 확인할 수 있습니다.

 [UE] 충돌 체크 - OnComponentBeginOverlap vs NotifyActorBeginOverlap

방향에 맞추어 액터 발사하기

UProjectileMovementComponent를 사용하여 발사체 발사를 구현해야 합니다.

방법은 간단하게

UProjectileMovementComponent에 있는 Velocity값에다 “방향벡터 * 초기 속도”를 넣어 발사체의 속력을 세팅해 줄 것입니다.

1
2
3
4
void AARBullet::Fire(const FVector& Driection) const
{
    ProjectileMovementComponent->Velocity = Driection * ProjectileMovementComponent->InitialSpeed;
}

Code

이를 적용한 Header File Code를 보면 아래와 같습니다.

Header File

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
UCLASS()
class TPS_PROTOTYPE_API AARBullet : public AActor
{
	GENERATED_BODY()
	
public:	
	// Sets default values for this actor's properties
	AARBullet();

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

	/** 액터의 스태틱 메시*/
	UPROPERTY(EditAnywhere, Category = "Properties", meta = (AllowPrivateAccess = "true"))
	class UStaticMeshComponent* StaticMeshComponent;

	/** 프로젝타일 무브먼트 컴포넌트*/
	UPROPERTY(EditAnywhere, Category = "Properties", meta = (AllowPrivateAccess = "true"))
	class UProjectileMovementComponent* ProjectileMovementComponent;

	UPROPERTY(EditAnywhere, Category = "Properties", meta = (AllowPrivateAccess = "true"))
	float InitSpeed = 30000.f;

	UPROPERTY(EditAnywhere, Category = "Properties", meta = (AllowPrivateAccess = "true"))
	float MaxSpeed = 30000.f;

	virtual void NotifyActorBeginOverlap(AActor* OtherActor) override;

	virtual void NotifyActorEndOverlap(AActor* OtherActor) override;
public:	
	// Called every frame
	virtual void Tick(float DeltaTime) override;

	void Fire(const FVector& Driection) const;
};

CPP File

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
// Sets default values
AARBullet::AARBullet()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = true;

	StaticMeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
	SetRootComponent(StaticMeshComponent);
    
	ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("Projectile Movement Component"));
	ProjectileMovementComponent->SetUpdatedComponent(GetRootComponent());
	ProjectileMovementComponent->InitialSpeed = InitSpeed;
	ProjectileMovementComponent->MaxSpeed = MaxSpeed;
	/** 속도에 따른 로테이션 변화 X */
	ProjectileMovementComponent->bRotationFollowsVelocity = false;
	/** 바운스 X */
	ProjectileMovementComponent->bShouldBounce = false;
}

// Called when the game starts or when spawned
void AARBullet::BeginPlay()
{
	Super::BeginPlay();
}

void AARBullet::NotifyActorBeginOverlap(AActor* OtherActor)
{
	// Debug
	GEngine->AddOnScreenDebugMessage(-1, 1.f, FColor::Yellow, FString::Printf(TEXT("Notify Actor Begin Overlap... Other Actor Name: %s"), *OtherActor->GetName()));
}

void AARBullet::NotifyActorEndOverlap(AActor* OtherActor)
{
	// Debug
	GEngine->AddOnScreenDebugMessage(-1, 1.f, FColor::Blue, FString::Printf(TEXT("Notify Actor End Overlap... Other Actor Name: %s"), *OtherActor->GetName()));
}

// Called every frame
void AARBullet::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
}

void AARBullet::Fire(const FVector& Driection) const
{
	ProjectileMovementComponent->Velocity = Driection * ProjectileMovementComponent->InitialSpeed;
}

3. 결과

발사체가 잘 보이기 위해서 Scale 값을 의도적으로 키운 영상입니다.

총을 발사하면 발사체가 정상적으로 조준한 방향을 향해 날아가는 것을 확인할 수 있습니다.

그리고 벽에 Overlap 되었을 때와 Overlap이 끝났을 때 로그 또한 잘 남는 것을 확인할 수 있습니다.

이제 몇 가지 더 처리해야 할 것이 남아 있지만
해당 글에서는 실제로 만들어보고 발사해 보는 단계까지 작성하려고 했기 때문에 이대로 마무리하려고 합니다.

현재로서 추가로 처리가 필요하다고 보이는 내용들은 아래와 같습니다.
  - 오브젝트 풀링을 이용한 발사체 관리
  - Overlap 되었을 때 총알이 월드에서 삭제되도록 처리

둘 다 구현한 코드에서 조금만 수정하면 되기 때문에 간단하게 해결할 수 있을 것 같고
관련 내용도 추후에 포스팅해보도록 하겠습니다 :)

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

[UE] 충돌 체크 - OnComponentBeginOverlap vs NotifyActorBeginOverlap

[C++] new 와 delete 연산자