본문 바로가기

개발 공부 기록/UnrealEngine5

이득우의 언리얼 C++ 게임 개발의 정석 UE5로 따라잡기 - 8

개요

'이득우의 언리얼 C++ 게임 개발의 정석'의 10장 아이템 상자와 무기 제작 내용을 바탕으로 하고 있습니다.

알라딘 책 구매 링크

 

이득우의 언리얼 C++ 게임 개발의 정석

언리얼 엔진 학습에 목말라하는 게임 개발자에게 단비 같은 언리얼 엔진 프로그래밍 책이다. 에픽게임즈 본사의 개발자 프로그램 언리얼 데브 그랜트를 수상한 저자의 책으로, 언리얼 엔진의

www.aladin.co.kr

 

본문

원래 강의의 내용에 따르면 마켓플레이스에서 “InfinityBladeWeapons” 애셋을 다운 받아 사용할 수 있지만,
현재 Fab으로 통합된 이후에 해당 애셋을 다운 받을 수가 없어서 “QMS_Fantasy_Sword_Pack_Free” 애셋을 다운 받아서 사용한다.

다른 점이라면 원래 예제에서 사용한 애셋의 경우 스켈레탈 메시를 사용하지만 대체한 애셋은 스태틱 메시 애셋이기 때문에
캐릭터에게 부착시켜줄 때 코드가 살짝 다르지만 전체적으론 동일하다.

우선 사용중인 캐릭터 스켈레탈 메시의 스켈레톤 트리에서 hard_r 아래 hand_rSocket을 찾아 우클릭 후
프리뷰 애셋 추가 → 사용할 무기 애셋을 넣고 임의로 적용해본다.
프리뷰이기 때문에 실제로 적용이 되지는 않고 소켓의 디테일 창에서 무기가 자연스럽게 장착될 수 있도록 위치를 조정한다.

 

생성자에서 무기를 붙여줄 소켓을 찾은 후 소켓에 무기로 사용할 스태틱 메시를 붙여주도록 한다.

// ABCharacter.h
UPROPERTY(VisibleAnywhere, Category = Weapon)
UStaticMeshComponent* Weapon;

// ABCharacter.cpp
AABCharacter::AABCharacter()
{
	// ... 생략
	FName WeaponSocket(TEXT("hand_rSocket"));
	if (GetMesh()->DoesSocketExist(WeaponSocket))
	{
		Weapon = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("WEAPON"));
		static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_WEAPON(TEXT("/Script/Engine.StaticMesh'/Game/QMS_Fantasy_Sword_Pack_FREE/Geometries/SM_Sword_015_Damaged.SM_Sword_015_Damaged'"));
		if (SM_WEAPON.Succeeded())
		{
			Weapon->SetStaticMesh(SM_WEAPON.Object);
		}
		Weapon->SetupAttachment(GetMesh(), WeaponSocket);
	}
}

 

이렇게 액터에 고정으로 무기를 장착할 수도 있지만 여러 무기를 사용하는 등 필요에 따라 무기를 변경하려면
무기 자체를 액터로 분리해서 만드는 것이 좋다.

새 액터 C++ 클래스 ABWeapon을 만든다. 그 후 ABCharacter에 넣어줬던 무기 생성 로직을 옮겨 적어 준다.

// ABWeapon.h

#pragma once

#include "ArenaBattle.h"
#include "GameFramework/Actor.h"
#include "ABWeapon.generated.h"

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

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

public:	
	UPROPERTY(VisibleAnywhere, Category = Weapon)
	UStaticMeshComponent* Weapon;
};

// ABWeapon.cpp

#include "ABWeapon.h"

// Sets default values
AABWeapon::AABWeapon()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = false;

	Weapon = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("WEAPON"));
	RootComponent = Weapon;

	static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_WEAPON(TEXT("/Script/Engine.StaticMesh'/Game/QMS_Fantasy_Sword_Pack_FREE/Geometries/SM_Sword_015_Damaged.SM_Sword_015_Damaged'"));
	if (SM_WEAPON.Succeeded())
	{
		Weapon->SetStaticMesh(SM_WEAPON.Object);
	}

	Weapon->SetCollisionProfileName(TEXT("NoCollision"));
}

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

 

생성자에서 무기를 생성해주던 부분은 무기 액터로 옮겨졌으니 캐릭터의 BeginPlay에서 무기 액터를 생성하여
소켓에 장착하는 로직을 새롭게 추가해준다.

// ABCharacter.cpp

void AABCharacter::BeginPlay()
{
	Super::BeginPlay();
	
	if (APlayerController* PlayerController = Cast<APlayerController>(GetController()))
	{
		if (UEnhancedInputLocalPlayerSubsystem* SubSystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer()))
		{
			SubSystem->AddMappingContext(DefaultContext, 0);
		}
	}

	FName WeaponSocket(TEXT("hand_rSocket"));

	// 월드에서 새롭게 액터를 생성하는 함수(SpawnActor)
	auto CurWeapon = GetWorld()->SpawnActor<AABWeapon>(FVector::ZeroVector, FRotator::ZeroRotator);
	if (nullptr != CurWeapon)
	{
		CurWeapon->AttachToComponent(GetMesh(), FAttachmentTransformRules::SnapToTargetNotIncludingScale, WeaponSocket);
	}

	// bUseControllerRotationYaw = true;
}

 

다음은 아이템 습득을 위한 아이템 박스를 제작해본다.

기본 스태틱 메시의 크기는 좌상단의 크기 근사치를 보면 알 수 있는데 이를 키우고 싶다면
우측 디테일 창에서 빌드 세팅 → 빌드 스케일을 xyz 항목마다 배율로 수정해주면 된다.

 

그 후 코드로 액터에 박스 콜리전 컴포넌트와 스태틱메시 컴포넌트 애셋을 지정한다.

Extend 값은 전체 박스 영역 크기의 절반 값을 의미한다.

// ABItemBox.h

#pragma once

#include "ArenaBattle.h"
#include "GameFramework/Actor.h"
#include "ABItemBox.generated.h"

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

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

public:	
	UPROPERTY(VisibleAnywhere, Category = Box)
	UBoxComponent* Trigger;

	UPROPERTY(VisibleAnywhere, Category = Box)
	UStaticMeshComponent* Box;
};
// ABItemBox.cpp

#include "ABItemBox.h"

// Sets default values
AABItemBox::AABItemBox()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = false;

	Trigger = CreateDefaultSubobject<UBoxComponent>(TEXT("TRIGGER"));
	Box = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BOX"));

	RootComponent = Trigger;
	Box->SetupAttachment(RootComponent);

	Trigger->SetBoxExtent(FVector(40.0f, 42.0f, 30.0f));
	static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_BOX(TEXT("/Script/Engine.StaticMesh'/Game/InfinityBladeGrassLands/Environments/Breakables/StaticMesh/Box/SM_Env_Breakables_Box1.SM_Env_Breakables_Box1'"));
	if (SM_BOX.Succeeded())
	{
		Box->SetStaticMesh(SM_BOX.Object);
	}
	Box->SetRelativeLocation(FVector(0.0f, -3.5f, -30.0f));
}

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

 

그 후 ItemBox의 콜리전 설정을 위해 오브젝트 채널과 프리셋을 추가 설정한다.
기본적으로 캐릭터와의 오버랩 이벤트 발생을 위해 ABCharacter 오브젝트 채널에만 오버랩 설정을 하고 나머지는 무시로 설정한다.

 

ABItemBox 클래스에 추가한 박스 컴포넌트에는 Overlap 이벤트를 처리할 수 있게
OnComponentBeginOverlap이라는 이름의 델리게이트가 선언돼 있다.

예시로 Trigger의 OnComponentBeginOverlap을 작성해본 후 F12키를 통해 정의로 계속 이동하다보면
멀티캐스트 다이나믹 델리게이트로 선언되어 있음을 확인할 수 있다.

 

해당 델리게이트의 인자들을 복사한 후 동일한 멤버 함수를 선언하고 이를 해당 델리게이트에 바인딩하면
Overlap 이벤트가 발생할 때마다 바인딩한 멤버 함수가 호출된다.

// ABItemBox.h

#pragma once

#include "ArenaBattle.h"
#include "GameFramework/Actor.h"
#include "ABItemBox.generated.h"

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

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

public:	
	UPROPERTY(VisibleAnywhere, Category = Box)
	UBoxComponent* Trigger;

	UPROPERTY(VisibleAnywhere, Category = Box)
	UStaticMeshComponent* Box;

private:
	UFUNCTION()
	void OnCharacterOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
};
// ABItemBox.cpp

#include "ABItemBox.h"

// Sets default values
AABItemBox::AABItemBox()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = false;

	Trigger = CreateDefaultSubobject<UBoxComponent>(TEXT("TRIGGER"));
	Box = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BOX"));

	RootComponent = Trigger;
	Box->SetupAttachment(RootComponent);

	Trigger->SetBoxExtent(FVector(40.0f, 42.0f, 30.0f));
	static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_BOX(TEXT("/Script/Engine.StaticMesh'/Game/InfinityBladeGrassLands/Environments/Breakables/StaticMesh/Box/SM_Env_Breakables_Box1.SM_Env_Breakables_Box1'"));
	if (SM_BOX.Succeeded())
	{
		Box->SetStaticMesh(SM_BOX.Object);
	}
	Box->SetRelativeLocation(FVector(0.0f, -3.5f, -30.0f));

	Trigger->SetCollisionProfileName(TEXT("ItemBox"));
	Box->SetCollisionProfileName(TEXT("NoCollision"));
}

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

void AABItemBox::PostInitializeComponents()
{
	Super::PostInitializeComponents();

	Trigger->OnComponentBeginOverlap.AddDynamic(this, &AABItemBox::OnCharacterOverlap);
}

void AABItemBox::OnCharacterOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	ABLOG_S(Warning);
}

 

이제 아이템 상자를 통과하면 빈손의 플레이어에게 아이템을 쥐어주는 기능을 구현한다.
아이템 상자에 클래스 정보를 저장할 속성을 추가하고, 이 값을 기반으로 플레이어가
아이템 상자 영역으로 들어왔을 때 아이템을 생성하도록 기능을 구현한다.

// ABItemBox.h

// ... 생략
public:
	// AABWeapon 클래스와 상속받은 클래스 목록을 한정하도록 TSubClassOf를 사용함
	UPROPERTY(EditInstanceOnly, Category = Box)
	TSubclassOf<class AABWeapon> WeaponItemClass;
// ABItemBox.cpp

AABItemBox::AABItemBox()
{
	// ... 생략
	WeaponItemClass = AABWeapon::StaticClass();
}

 

위와 같이 코드를 입력하여 박스에게 WeaponItemClass라는 속성을 부여하고 ABWeapon 클래스의 정보를 기본으로 세팅할 수 있다.

 

이제 캐릭터에 무기를 장착시키는 SetWeapon이라는 멤버 함수를 구현한다.
이 함수는 캐릭터에게 무기가 없으면 hand_rSocket에 무기를 장착시키고 무기 액터의 소유자를 캐릭터로 변경시키는 로직을 구현한다.
위에서 BeginPlay에 넣어놨던 무기 액터 장착 로직은 삭제한다.

// ABCharacter.h
bool CanSetWeapon();
void SetWeapon(class AABWeapon* NewWeapon);

UPROPERTY(VisibleAnywhere, Category = Weapon)
class AABWeapon* CurrentWeapon;

------------------------------------------------------------

// ABCharacter.cpp

bool AABCharacter::CanSetWeapon()
{
	return (nullptr == CurrentWeapon);
}

void AABCharacter::SetWeapon(AABWeapon* NewWeapon)
{
	ABCHECK(nullptr != NewWeapon && nullptr == CurrentWeapon);
	FName WeaponSocket(TEXT("hand_rSocket"));
	if (nullptr != NewWeapon)
	{
		NewWeapon->AttachToComponent(GetMesh(), FAttachmentTransformRules::SnapToTargetNotIncludingScale, WeaponSocket);
		NewWeapon->SetOwner(this);
		CurrentWeapon = NewWeapon;
	}
}
//ABItemBox.cpp

void AABItemBox::OnCharacterOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	ABLOG_S(Warning);

	// Overlap된 OtherActor를 AABCharacter 클래스로 형변환함
	auto ABCharacter = Cast<AABCharacter>(OtherActor);
	ABCHECK(nullptr != ABCharacter);

	if (nullptr != ABCharacter && nullptr != WeaponItemClass)
	{
		// ABCharacter가 웨펀을 장착할 수 있는 상태라면 무기를 스폰하고 장착시킴
		if (ABCharacter->CanSetWeapon())
		{
			auto NewWeapon = GetWorld()->SpawnActor<AABWeapon>(WeaponItemClass, FVector::ZeroVector, FRotator::ZeroRotator);
			ABCharacter->SetWeapon(NewWeapon);
		}
		else
		{
			ABLOG(Warning, TEXT("%s can't equip weapon currently"), *ABCharacter->GetName());
		}
	}
}

 

구현이 다 되면 시작 시에 캐릭터는 빈 손으로 시작하고 아이템 상자를 지나갈 때 Overlap 이벤트가 발생하면서
무기가 손에 들리고 다시 지나가면 Overlap 이벤트가 발생하고 무기를 쥘 수 없다는 로그가 발생한다.

 

이번에는 상자에 이펙트를 부착해 아이템을 습득하면 이펙트를 재생하고, 이펙트 재생이 완료되면 상자가 사라지는 기능을 구현한다.

상자 액터에 파티클 컴포넌트를 추가하고 파티클 컴포넌트에서 제공하는 OnSystemFinished 델리게이트를 통해 이펙트 재생이 종료되면

아이템 상자가 제거 되도록 로직을 구성한다.

추가로 이펙트가 재생될 때 액터의 충돌 기능을 제거하여 캐릭터가 아이템을 두 번 습득하지 못하도록 방지하고 박스 스태틱메시도

액터가 제거될 때까지 모습을 숨기도록 한다.

// ABItemBox.h

public:
	UPROPERTY(VisibleAnywhere, Category = Effect)
	UParticleSystemComponent* Effect;
	
private:
	UFUNCTION()
	void OnEffectFinished(class UParticleSystemComponent* PSystem);
// ABItemBox.cpp

AABItemBox::AABItemBox()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = false;

	Trigger = CreateDefaultSubobject<UBoxComponent>(TEXT("TRIGGER"));
	Box = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BOX"));
	Effect = CreateDefaultSubobject<UParticleSystemComponent>(TEXT("EFFECT"));

	RootComponent = Trigger;
	Box->SetupAttachment(RootComponent);
	Effect->SetupAttachment(RootComponent);

	Trigger->SetBoxExtent(FVector(40.0f, 42.0f, 30.0f));
	static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_BOX(TEXT("/Script/Engine.StaticMesh'/Game/InfinityBladeGrassLands/Environments/Breakables/StaticMesh/Box/SM_Env_Breakables_Box1.SM_Env_Breakables_Box1'"));
	if (SM_BOX.Succeeded())
	{
		Box->SetStaticMesh(SM_BOX.Object);
	}
	Box->SetRelativeLocation(FVector(0.0f, -3.5f, -30.0f));

	static ConstructorHelpers::FObjectFinder<UParticleSystem> P_CHESTOPEN(TEXT("/Script/Engine.ParticleSystem'/Game/InfinityBladeGrassLands/Effects/FX_Treasure/Chest/P_TreasureChest_Open_Mesh.P_TreasureChest_Open_Mesh'"));
	if (P_CHESTOPEN.Succeeded())
	{
		Effect->SetTemplate(P_CHESTOPEN.Object);
		Effect->bAutoActivate = false;
	}

	Trigger->SetCollisionProfileName(TEXT("ItemBox"));
	Box->SetCollisionProfileName(TEXT("NoCollision"));

	WeaponItemClass = AABWeapon::StaticClass();
}

void AABItemBox::OnCharacterOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	ABLOG_S(Warning);

	// Overlap된 OtherActor를 AABCharacter 클래스로 형변환함
	auto ABCharacter = Cast<AABCharacter>(OtherActor);
	ABCHECK(nullptr != ABCharacter);

	if (nullptr != ABCharacter && nullptr != WeaponItemClass)
	{
		// ABCharacter가 웨펀을 장착할 수 있는 상태라면 무기를 스폰하고 장착시킴
		if (ABCharacter->CanSetWeapon())
		{
			auto NewWeapon = GetWorld()->SpawnActor<AABWeapon>(WeaponItemClass, FVector::ZeroVector, FRotator::ZeroRotator);
			ABCharacter->SetWeapon(NewWeapon);
			// 이펙트를 활성화
			Effect->Activate(true);
			// Box를 감춤(인게임에서만)
			Box->SetHiddenInGame(true, true);
			SetActorEnableCollision(false);
			Effect->OnSystemFinished.AddDynamic(this, &AABItemBox::OnEffectFinished);
		}
		else
		{
			ABLOG(Warning, TEXT("%s can't equip weapon currently"), *ABCharacter->GetName());
		}
	}
}

void AABItemBox::OnEffectFinished(UParticleSystemComponent* PSystem)
{
	Destroy();
}

 

그 후 새로운 무기를 추가하는데 이번에는 블루프린트 클래스로 생성해본다.

앞서 생성했던 ABWeapon 클래스를 부모로 하는 블루프린트 클래스를 생성하면 동일한 스태틱 메시를 사용하는 무기 애셋이 생성된다.

이 클래스를 BP_WeaponSword라 명명해 생성하고 스태틱 메시 애셋을 다른 검 애셋으로 바꿔줬다.

 

그 후 배치되어 있는 아이템 박스의 Weapon Item Class를 가보면 방금 생성한 BP_WeaponSword를 설정할 수 있고
이를 설정하고 아이템 박스를 습득하면 새로 만든 무기가 캐릭터에게 장착된다.