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라고 합니다.
위 그림과 같이 특정 객체에서 Ray(광선)을 발사해 특정 객체를 찾는 것을 Ray Cast라고 합니다.
이 방식은 객체를 찾는 것뿐만 아니라 Ray의 끝에 특정 매시를 그린다든지 다양한 방식으로 사용할 수 있습니다.
2. 구현
2-1. Input 세팅
총을 발사할 때 마우스 왼쪽 버튼을 누르고 떼는 조작법을 많이 사용하고 있습니다.
그러므로, UE에서 지원해 주는 Action Mappings를 사용할 것입니다.
Action Mappings는 “키를 누르고 떼는 것”에 대한 조작을 만들 때 사용하기 좋은 방식입니다.
Ex. 점프(Space Bar 누르기), 앉기(Ctrl 누르기)
아래와 같이 액션 매핑에 “Fire”를 추가해 주고 키는 “왼쪽 마우스 버튼”을 지정해 줍시다.
그리고 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 파일 구현
우리는 무기 액터를 상속 구조로 만들고 있습니다.
그러므로 공용으로 사용하는 무기 발사를 시작하는 메서드, 무기 발사를 멈추는 메서드는 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을 생성해 줍니다.
스켈레톤 메시 내 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()를 통해 자기 자신을 무시하도록 지정해 줍니다.
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 된 액터의 이름 또한 디스플레이에 잘 찍히는 것을 볼 수 있습니다.
그리고 발사 버튼을 꾹 누르고 있을 때 연사로 발사되는 것을 볼 수 있으며 발사 버튼을 뗐을 때 발사가 멈추는 것 또한 확인할 수 있습니다.