언리얼 엔진 5/개발 일지

[UE5] 블루아카이브 TPS게임 개발일지 (10) - 사격 기능 구현 준비 작업 + DIP

ciel45 2023. 12. 18. 21:39

사격 애니메이션까지는 만들어 두었으므로, 실제로 총이 발사되는 메커니즘을 만들고자 한다.

 

우선 발사를 구현하는 데에는 두가지 방법이 있다. 라인 트레이싱(Line Tracing)투사체(Projectile)이다.

 

오버워치를 해본 사람은 히트스캔과 투사체라는 용어가 익숙할 것이다.

투사체는 여기서의 투사체와 같고, 언리얼 엔진에서 히트스캔을 구현하는 방법이 라인 트레이싱이라고 할 수 있다.

 

언리얼의 라인 트레이싱은 유니티의 레이캐스팅(RayCast)과 동일하다.

 

본 프로젝트에서는 칸나의 일반 사격은 라인 트레이싱으로, EX스킬은 투사체로, 적의 사격은 투사체로 구현할 생각이다.

아무래도 적의 공격을 받을 땐 실제로 총알이 날아오는게 보여야 게임이 더 재밌을 것 같기 때문이다.

 

물론 바로 이것부터 작업할 것은 아니며, 제목과 같이 우선 준비 작업을 할 것이다.

 

 

 

 

지금까지는 캐릭터 블루프린트에 단순히 무기를 붙여만 놓았었지만, 이제 총기도 별도의 액터로써 작동하도록 할 것이다.

우선 게임에서 플레이어가 사용할 총기는 권총, 돌격소총이 있다.

사실 돌격소총은 넣을지 말지 아직 명확히 결정하지 못했지만, 일단 추후에 넣는다고 가정하였다.

 

 

이렇게 총의 종류가 여러가지가 있을 때, 캐릭터가 총의 각 종류에 모두 의존하는 것은 바람직하지 않다.

 

이는 DIP(의존성 뒤집기 원칙) 위반이라고 할 수 있는데, DIP란 상위 모듈이 하위 모듈이 아닌 인터페이스(추상클래스)에 의존하도록 하는 것을 뜻한다.

 

따라서 권총과 돌격소총의 공통 기능을 뽑아내어 추상클래스를 만들고, 캐릭터는 거기에만 의존하도록 하려고 한다.

클래스 다이어그램을 단순하게 그려보자면 다음과 같다.

캐릭터는 Gun 추상클래스에만 의존하고, Pistol과 AssultRifle은 Gun을 상속받는다.

 

이렇게 하면 Gun이 인터페이스의 역할을 해준다. 

 

캐릭터는 현재 무기가 권총인지 소총인지 모르지만, 그것이 Gun 추상클래스를 상속받은 클래스라는 것은 알고 있다.

 

따라서 Gun의 함수를 호출하면 그 함수가 동적 바인딩되도록 설계함으로써 두 무기가 실제로는 다르게 동작하도록 할 수있다.

 

 

다음은 Gun 추상클래스의 헤더 전문이다.

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Gun.generated.h"

class USkeletalMeshComponent;

UENUM()
enum class EFiringMode
{
	EFM_SEMIAUTO,
	EFM_AUTO
};

UCLASS(Abstract)
class KANNATPS_API AGun : public AActor
{
	GENERATED_BODY()
	
public:	
	// Sets default values for this actor's properties
	AGun();

	// Called every frame
	virtual void Tick(float DeltaTime) override;

	virtual void Fire();

	virtual void Reload();

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

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
	int32 CurrentAmmo;

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
	int32 TotalAmmo;

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
	int32 MaxAmmo;

	UPROPERTY()
	EFiringMode FiringMode;

private:
	UPROPERTY(VisibleAnywhere)
	USkeletalMeshComponent* GunMesh;
};

 

총기가 공통적으로 가지고 있어야 할 정보(자동/반자동, 탄약 개수 등), 공통적으로 가능해야 할 기능(발사, 재장전)을 담았다.

 

추상클래스이므로 UCLASS(Abstract)를 달아줘야 한다. 이렇게 하면 해당 클래스는 그대로 월드에 배치할 수 없으며, 이를 구현한 서브클래스만이 월드에 배치될 수 있다.

 

 

Gun 추상클래스의 .cpp 내부에서 생성자에서의 메쉬의 생성, 장전 메소드는 기본적인 구현을 만들어두었다.

// Fill out your copyright notice in the Description page of Project Settings.


#include "Weapons/Gun.h"
#include "Components/SkeletalMeshComponent.h"

// Sets default values
AGun::AGun()
{
 	// 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;

	GunMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Mesh"));
	GunMesh->SetupAttachment(GetRootComponent());
}

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

void AGun::Fire()
{
}


void AGun::Reload()
{
	UE_LOG(LogTemp,Warning, TEXT("RELOAD"));

	int32 ReloadingAmmo = MaxAmmo - CurrentAmmo;

	TotalAmmo -= ReloadingAmmo;

	CurrentAmmo = MaxAmmo;
}

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

}

 

 

다음은 Gun을 상속받은 Pistol 클래스의 .cpp 이다.

// Fill out your copyright notice in the Description page of Project Settings.


#include "Weapons/Pistol.h"
#include "Kismet/GameplayStatics.h"
#include "Character/KannaCharacter.h"

// Sets default values
APistol::APistol()
{
 	// 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;

	FiringMode = EFiringMode::EFM_SEMIAUTO;
}

// Called when the game starts or when spawned
void APistol::BeginPlay()
{
	Super::BeginPlay();
	
	AKannaCharacter* KannaCharacter = Cast<AKannaCharacter>(UGameplayStatics::GetPlayerPawn(GetWorld(), 0));

	if (KannaCharacter)
	{
		KannaCharacter->AddWeaponToList(this); //권총은 시작할 때부터 가지고 있어야 한다.

		this->AttachToComponent(KannaCharacter->GetMesh(), FAttachmentTransformRules::SnapToTargetIncludingScale, FName("Pistol_Socket"));
	}
}


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

}

void APistol::Fire()
{
	UE_LOG(LogTemp, Warning, TEXT("PISTOL FIRE")); //로그에 출력
}

 

생성자에서 발사 모드를 반자동으로 설정해주었고, BeginPlay에서 캐릭터를 찾아 캐릭터의 무기 리스트에 자신을 자동으로 넣게 하였다. 또한, 캐릭터 메쉬의 Pistol 소켓에 자기자신을 붙인다.

 

캐릭터를 찾는 메소드인 GetPlayerPawn을 사용하기 위해 #include "Kismet/GameplayStatics.h"를 사용한 것을 볼 수 있다.

 

하위 모듈인 권총이 상위 모듈인 캐릭터를 계속해서 참조하고 있는것은 좋지 않기에, KannaCharacter를 변수로 저장해놓지는 않을 것이다.

 

KannaCharacter.h 내부에 정의된 WeaponList

UPROPERTY()
TArray<AGun*> WeaponList;

 

AddWeaponToList를 통해 무기 리스트에 무기를 넣어줄 수 있다. 위의 Pistol.cpp의 BeginPlay에서도 이 메소드를 자기자신의 포인터를 파라미터로 하여 호출한 것을 볼 수 있다.

UFUNCTION()
FORCEINLINE void AddWeaponToList(AGun* Weapon) {WeaponList.Add(Weapon); }

 

 

TArray는 언리얼 엔진에서 사용할 수 있는 컬렉션이다. 동적으로 길이가 늘어나는 컬렉션으로, 편리하기 때문에 언리얼 엔진 게임 개발에서 아주 폭넓게 쓰인다. 추후에 이에 관하여 별도의 포스팅을 올릴 생각이다.

 

C++의 STL중 하나인 Vector와 거의 비슷하다.

 

 

캐릭터의 헤더에는 소지중인 무기 리스트 외에도 현재 장착중인 무기를 뜻하는 CurrentWeapon 변수를 추가해주었다.

UPROPERTY()
AGun* CurrentWeapon;

 

다음은 SwitchWeapon키 (숫자 1 버튼) 입력 시의 콜백 함수이다. 추후에 소총이 추가되면 1번을 누르면 권총, 2번을 누르면 소총과 같이 바꿔줄 필요가 있겠지만, 우선 테스트를 위해 이렇게 작성하였다.

void AKannaCharacter::SwitchWeapon()
{
	if(ActionState != EActionState::EAS_Neutral) return;

	if (CharacterState == ECharacterState::ECS_ArmedWithPistol) // 권총 -> 맨손
	{
		CharacterState = ECharacterState::ECS_Unarmed;
		CurrentWeapon = nullptr;
	}
	else if (CharacterState == ECharacterState::ECS_Unarmed) //맨손 -> 권총
	{
		CharacterState = ECharacterState::ECS_ArmedWithPistol;
		CurrentWeapon = WeaponList[0];
	}

	SetNeutralStateSpeed(); // 중립 상태일 때의 걷기 속도 조정
}

 

CurrentWeapon을 nullptr에서 권총으로, 권총에서 nullptr로 바꿔주는 것을 볼 수 있다.

 

 

 

 

이제 게임플레이 시 작동 메커니즘은 다음과 같다.

  • BeginPlay() → Pistol.cpp에서 자기자신을 KannaCharacter의 WeaponList에 추가, 소켓에 부착한다.
  • CurrentWeapon(Gun 타입 포인터)은 nullptr이다.
  • 1번을 눌러 무기를 전환할 시 CurrentWeapon에 Pistol 포인터가 들어간다. (Pistol은 Gun의 서브클래스이므로 Gun 포인터로 다룰 수 있다.)
  • 그 상태로 CurrentWeapon의 Fire()를 호출할 시 함수가 동적 바인딩되어 Pistol의 Fire()가 호출된다.

 

다음은 테스트 영상이다. 로그를 주목해보자.

 

1번을 눌러 권총을 선택하지 않은 채로 발사, 재장전 버튼을 눌러도 함수는 실행되지 않는 것을 볼 수 있다.

 

이제 다음에 할 것은 발사 버튼을 누를 시 실제로 라인트레이싱을 수행해서 적이 맞았는지 확인하는 코드를 추가하는 것이다.

이는 Pistol.cpp의 Fire() 함수 내부에서 구현할 것이다.