2-8강 아이템 시스템

Ryan Ham·2024년 7월 4일
0

이득우 Unreal

목록 보기
13/23
post-thumbnail

강의 목표

  • Trigger 박스를 활용한 아이템 상자의 구현
  • Data Asset을 활용한 아이템 데이터의 관리
  • 의존성 분리를 위한 Interface 설계 구현
  • SoftObject 레퍼런스HardObject 레퍼런스의 차이 이해

사용할 에셋

Unreal Marketplace에서 제공하는 Infinity Blade : Weapons

Infinity Blade > Maps에 들어가면 우리가 사용할 무기 에셋들을 확인할 수 있다.


Trigger 박스의 설정

캐릭터가 아이템 상자를 먹으면 아이템이 습득되도록 우리가 기믹을 하나 설정을 해주어야 한다.

Component Hierarchy

  1. UBoxComponent로 만들어진 Trigger Box.
  2. UStaticMeshComponent로 만들어진 Mesh.
  3. UparticleSystemComponent로 만들어진 Effect.

이 3 component들을 모두 Trigger Box에 계층적으로 넣는다. Root에 trigger를 설정하고 자식에 mesh component를 부착한다.

Particle System

파티클 시스템은 Gimmick에 미리 turn-off 상태로 붙여놓고, trigger(캐릭터가 OverLap)가 발동되면 turn-on 한다.

Particle Asset 로드 및 설정

// ItemBox.cpp

static ConstructorHelpers::FObjectFinder<UParticleSystem> EffectRef(TEXT("/Game/ArenaBattle/Effect/P_TreasureChest_Open_Mesh.P_TreasureChest_Open_Mesh"));
if (EffectRef.Object)
{
	Effect->SetTemplate(EffectRef.Object);
    // event를 감지했을때만 실행시키도록 하기 위해서 자동 실행을 꺼놓는다. 
	Effect->bAutoActivate = false;
}

생성자에서 Mesh는 SetStaticMesh, Particle은 SetTemplate을 통해 값을 설정한다.


OverLap event를 위한 Delegate 사용

우리가 Overlap event를 따로 구현하지 않아도 이미 언리얼에서는 OnComponentBeginOverlap이라는 Delegate를 제공하고 있다. 우리는 이를 적극 활용해보자.

// ItemBox.cpp

Trigger = CreateDefaultSubobject<UBoxComponent>(TEXT("TriggerBox"));
...

// 오직 Pawn과 Overlap collision을 설정.
Trigger->SetCollisionProfileName(TEXT("RyanTrigger"));

// Box의 X,Y,Z size지정
Trigger->SetBoxExtent(FVector(40.0f, 42.0f, 30.0f));

// 이미 UBoxComponent에는 OnComponentBeginOverlap라는 Delegate가 존재한다. 이를 까보면 6개인 인자를 받는 delegate...!
// Dynamic delegate이기 때문에 AddDynamic으로 등록할 함수를 넘겨준다. 
Trigger->OnComponentBeginOverlap.AddDynamic(this, &ARyanItemBox::OnOverlapBegin);

ItemBox.cpp에서 OnComponentBeginOverlap Delegate에 바인딩 할 OnOverlapBegin라는 함수를 만들고 집어넣자. OnComponentBeginOverlap은 6개의 인자를 받은 Delegate여서 이에 맞춰 OnOverlapBegin 함수를 작성해준다. 이는 아래의 코드에서 자세히 확인할 수 있다.

DECLARE_DYNAMIC_MULTICAST_SPARSE_DELEGATE_SixParams( FComponentBeginOverlapSignature, UPrimitiveComponent, OnComponentBeginOverlap, UPrimitiveComponent*, OverlappedComponent, AActor*, OtherActor, UPrimitiveComponent*, OtherComp, int32, OtherBodyIndex, bool, bFromSweep, const FHitResult &, SweepResult);

OnComponentBeginOverlap의 정의를 들어가게 되면 받게 되는 인자의 data-type이 위와 같이 된다. 무서워 하지 말고 그냥 복사해버리자!

Overlap Delegate에 연결되는 함수

// 상자에 Overlap event가 감지되면 Delegate에 의해 자동으로 발동되는 함수 
void ARyanItemBox::OnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepHitResult)
{
	if (nullptr == Item)
	{
		Destroy();
		return;
	}

	// Overlapping 한 주체가 캐릭터인지 체크
	IRyanCharacterItemInterface* OverlappingPawn = Cast<IRyanCharacterItemInterface>(OtherActor);
	if (OverlappingPawn)
	{
		OverlappingPawn->TakeItem(Item);
	}

	Effect->Activate(true);
	Mesh->SetHiddenInGame(true);
	SetActorEnableCollision(false);
    // Particle 시스템 종료시 발동되는 함수 Delegate를 통해 실행
	Effect->OnSystemFinished.AddDynamic(this, &ARyanItemBox::OnEffectFinished);
}

// Particle 시스템이 자동으로 끝나면 발동되는 Delegate에 연결되는 함수
void ARyanItemBox::OnEffectFinished(UParticleSystemComponent* ParticleSystem)
{
	Destroy();
}

Dynamic Delegate는 뭘까?

Dynamic Delegate는 언리얼 엔진에서 이벤트를 처리하기 위해 사용되는 함수 포인터의 일종으로, 런타임에 바인딩할 수 있으며 블루프린트에서도 활용 가능하다. 이를 통해 특정 이벤트가 발생할 때 호출할 함수를 동적으로 지정할 수 있으며, 엔진의 리플렉션 시스템과 호환되어 직렬화나 네트워킹 같은 다양한 기능과 통합될 수 있다. 즉, 다이나믹 델리게이트는 유연하고 확장 가능한 이벤트 기반 시스템을 구축하는 데 유용한 도구!

TriggerBox의 Collision Profile

Collision Profile에서 Collision켜짐 항목을 잘 체크하자. 콜리전 반응 부분 뿐만 아니라 콜리전 켜짐 부분도 꼼꼼하게 체크하자. 필자는 여기를 No collision으로 해 놓아서 애먹었다.

TrifferBox에 대한 collision profile은 Query Only, Pawn에 대해 오직 Overlap의 설정으로 되어 있어야 한다.


아이템 상자 안에 있는 무기 먹기

Enum Class로 아이템 타입 관리

UENUM(BlueprintType)
enum class EItemType : uint8
{
	Weapon = 0,
	Potion,
	Scroll
};

Enum Class로 아이템 타입들을 정리해보고 UENUM 매크로를 붙여서 이를 블루프린트에 노출시켜보자.

Data Asset의 활용

Primary Data Asset 클래스로 무기 정보 관리하기

이번 BP도 마찬가지로 Primary Asset Data를 상속해 C++ 클래스를 먼저 만들고 BP에서 이 클래스를 다시 상속하는 구조로 설계.

확장성을 위해서 기본적인 Data Asset 클래스를 하나 만들고 세부적인 아이템들은 다시 이 클래스를 상속해서 시스템을 구현(아래 화살표와 같이).

Primary Asset Data -> Item Data -> (Weapon/Potion/Scroll) Data


의존성 분리해서 프로젝트 레이어 구성하기

Game Layer : 캐릭터, 게임 모드와 같이 게임 로직을 구체적으로 구현하는데 사용하는 레이어
Middleware Layer : 게임에 사용되는 독립적인 모듈. UI, 아이템, 애니메이션, 인공지능 모두 다 이 레이어에 해당.
Data Layer : 게임을 구성하는 기본 데이터들로만 구성. 데이터만 보관하는 레이어

이러한 계층구조에서 위 레이어 -> 아래 레이어를 참조할때는 직접 참조. 아래 레이어에서 위 레이어를 참조할때는 Interface를 사용해서 참조하도록 한다. 따라서 미들웨어 레이어의 헤더 파일에서 게임 레이어의 헤더 파일을 include하는 일은 하지 않도록 한다.

예를 들어서 Item은 Middleware Layer이고 Character는 Game Layer인데, Item box에서 캐릭터에게 정보를 보내고 싶은 경우에는 정보의 흐름이 Middleware Layer -> Game Layer임으로 인터페이스를 활용한다.


Delegate 배열로 관리하기

// CharacterBase.h

// FOnTakeItemDelegate라는 delegate 선언
DECLARE_DELEGATE_OneParam(FOnTakeItemDelegate, class URyanItemData* /*InItemData*/);

// Delegate 자체를 Array에 element로 사용할 수가 없어서
// Delegate를 감싸는 struct를 하나 만든 다음에 이를 배열에 추가하도록 하자. 
USTRUCT(BlueprintType)
struct FTakeItemDelegateWrapper
{
	GENERATED_BODY()
	FTakeItemDelegateWrapper() {}
	FTakeItemDelegateWrapper(const FOnTakeItemDelegate& InItemDelegate) : ItemDelegate(InItemDelegate) {}
	FOnTakeItemDelegate ItemDelegate;
};

Delegate를 원소로 하는 배열을 만들어 보자. 여기서 문제가 하나 있는데 고것은 바로 Delegate 자체를 Array에 원소로 사용할 수가 없다는 사실! 따라서 Delegate를 감싸는 struct를 하나 만든 다음에 이를 배열에 추가하도록 하자.

// CharacterBase.cpp

// CharacterBase 생성자 부분
// CreateUObject로 Delegate를 즉석에서 생성해서 배열에 집어넣는다. 
	TakeItemActions.Add(FTakeItemDelegateWrapper(FOnTakeItemDelegate::CreateUObject(this, &ARyanCharacterBase::EquipWeapon)));
	TakeItemActions.Add(FTakeItemDelegateWrapper(FOnTakeItemDelegate::CreateUObject(this, &ARyanCharacterBase::DrinkPotion)));
	TakeItemActions.Add(FTakeItemDelegateWrapper(FOnTakeItemDelegate::CreateUObject(this, &ARyanCharacterBase::ReadScroll)));
    
    ...
    ...

void ARyanCharacterBase::TakeItem(URyanItemData* InItemData)
{
	// Item의 Type에 따라 다른 action을 수행.
	// TakeItemActions에는 구조체로 wrapping된 delegate들이 들어있다. 
	// ExecuteIfBound로 인자를 넘겨주면서 해당 binding된 함수 실행
	if (InItemData)
	{
		TakeItemActions[(uint8)InItemData->Type].ItemDelegate.ExecuteIfBound(InItemData);
	}
}

Item의 Type에 따라 다른 action을 수행. TakeItemActions라는 배열에 구조체로 wrapping된 delegate들을 차례로 삽입한다.

ExecuteIfBound로 인자를 넘겨주면서 해당 binding된 함수 실행. Bound(bind의 과거형)이 되어있다면 execute하라는 의미!


이번 코드에서 쓰인 Delegate 정리!

Delegate들이 하도 많이 나와서 한 섹션에 어떤 Delegate들이 사용되었는지 정리해보자.

  1. 캐릭터와 아이템 상자가 Overlap 되었을때 사용되는 Delegate
  2. Particle System이 끝날때 상자를 Destory하기 위해 사용되는 Delegate
  3. 상자를 먹었을때 상자안의 아이템이 어떤 종류냐에 따라 캐릭터에게 다른 효과를 적용하는 Delegate

Socket을 통해 캐릭터 손에 무기 부착

// CharacterBase.cpp
// Weapon Component
Weapon = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Weapon"));
Weapon->SetupAttachment(GetMesh(), TEXT("hand_rSocket"));

미리 캐릭터 손에 Weapon Skeletal Mesh가 적용될 수 있게 USkeletalMeshComponent를 Socket을 통해 달아준다.

// CharacterBase.cpp
void ARyanCharacterBase::EquipWeapon(UABItemData* InItemData)
{
	URyanWeaponItemData* WeaponItemData = Cast<URyanWeaponItemData>(InItemData);
	if (WeaponItemData)
	{
		if (WeaponItemData->WeaponMesh.IsPending())
		{
			WeaponItemData->WeaponMesh.LoadSynchronous();
		}
		Weapon->SetSkeletalMesh(WeaponItemData->WeaponMesh.Get());
	}
}

Delegate에 바인딩 된 함수 중 무기 관련 함수인 EquipWeapon 코드이다. SetSkeletalMesh를 통해 캐릭터에게 무기를 달아준다.


Soft Referencing VS Hard Referencing

Actor loading시 TObjectPtr로 선언한 Unreal Object도 따라서 메모리에 로딩되는데, 이를 Hard Referencing이라고 한다. 이는 우리가 지금까지 일반적으로 계속 사용해왔던 방식이다.

하지만, 게임 진행에 필수적인 Unreal Object는 이렇게 선언해도 되지만 아이템의 경우에는 어떻게 할까? 예를 들어서, Data Library에 1000종의 아이템 목록이 있을때 이를 사전에 모두 다 로딩하는 것은 너무 비효율적이다.

이를 해결할 방법이 있는데 필요한 데이터만 그때그때 로딩하도록 TSoftObjectPtr로 선언하고 대신 Asset 주소 문자열을 지정해보자.

현재 게임에서 로딩되어 있는 Skeletal Mesh 목록 살펴보기

Obj List Class=SkeletalMesh

Tilda(~) 키를 눌러서 level이 재생될때 cmd 창을 열 수 있는데 위와 명령어를 입력해서 현재 load되어 있는 에셋을 알 수 있다.

Weapon 상자를 먹었을 때 비로소 Asset 로딩 하기

// WeaponItemData.h
public : 
	UPROPERTY(EditAnywhere, Category = Weapon)
	TSoftObjectPtr<USkeletalMesh> WeaponMesh;

Weapon Item을 관리하는 헤더에서 기존에 TObjectPtrHard Referencing되어 있던 무기의 Mesh 정보를 TSoftObjectPtr로 바꿈으로서 Soft Referencing하게 한다.

// CharacterBase.cpp
void ARyanCharacterBase::EquipWeapon(URyanItemData* InItemData)
{

	URyanWeaponItemData* WeaponItemData = Cast<URyanWeaponItemData>(InItemData);
	if (WeaponItemData)
	{
		if (WeaponItemData->WeaponMesh.IsPending())
		{
			WeaponItemData->WeaponMesh.LoadSynchronous();
		}

		// DA에 무기가 있는지 확인
		if(WeaponItemData)
		{
			Weapon->SetSkeletalMesh(WeaponItemData->WeaponMesh.Get());
		}

	}
}

여기서 IsPending()LoadSynchronous() 부분이 필요한 에셋만 그때 로딩하는 코드이다. IsPending()은 지금 Asset이 로드가 되어있는지 확인하는 코드이고, 최종적으로 LoadSynchronous()는 동기적으로 Asset을 로드하는 함수이다.

이와 같이,Soft Referencing을 사용하면 초기에 게임이 로드가 될때 메모리의 양을 최소화하는 것이 가능하다.


최종 화면


기타

Delegate VS Custom events

Delegates

Delegate는 특정 이벤트가 발생할 때 호출할 수 있는 함수 포인터를 캡슐화한 것으로, 주로 C++ 코드에서 사용된다. Delegate는 런타임에 바인딩할 수 있으며, 여러 개의 함수나 메서드를 동일한 이벤트에 연결할 수 있다. 이를 통해 다양한 객체가 동일한 이벤트에 대해 반응할 수 있게 하며, 유연하고 모듈화된 코드 작성이 가능하다. Delegate는 주로 복잡한 이벤트 시스템을 구현하거나, 코드의 유지 보수성과 확장성을 높이기 위해 사용된다.

Custom Events

Custom Event는 주로 블루프린트에서 사용되는 이벤트 시스템으로, 특정 시점에 실행되도록 설계된 사용자 정의 이벤트이다. Custom Event는 블루프린트 그래프에서 직접 정의하고 호출할 수 있으며, 시각적인 스크립팅을 통해 간단한 이벤트 흐름을 관리하는 데 유용하다. 이는 주로 게임플레이 로직을 블루프린트로 구현하거나, 디자이너와 비프로그래머가 게임 로직을 쉽게 작성하고 수정할 수 있게 돕는 역할을 한다. Custom Event는 코드보다는 블루프린트에서 직관적이고 간편하게 이벤트를 처리할 때 주로 사용된다.

Data Asset과 Data Table의 차이점

Data Asset

Data Asset은 주로 개별 데이터 객체를 정의하고 관리하는 데 사용된다. 이는 특정 클래스를 기반으로 하며, 개발자가 사용자 정의 클래스를 생성하고 해당 클래스의 인스턴스를 에디터에서 편집할 수 있다. Data Asset은 복잡한 데이터 구조를 정의하고, 직관적으로 데이터를 시각화하고 편집할 수 있게 하며, 특정 객체에 대한 상세한 속성을 관리하는 데 유용하다. 이를 통해 데이터 드리븐 디자인을 구현하고, 게임의 다양한 객체에 대한 설정을 체계적으로 관리할 수 있다.

Data Table

Data Table은 구조체 배열을 테이블 형태로 관리하는 방식으로, 주로 대량의 데이터를 효율적으로 처리하는 데 사용된다. CSV 파일이나 JSON 파일을 통해 데이터를 쉽게 가져오고 내보낼 수 있으며, 데이터의 행과 열을 통해 체계적으로 관리할 수 있다. Data Table은 대량의 반복적이고 구조화된 데이터를 처리하는 데 적합하며, 게임 내 다양한 설정값이나 아이템 목록, 캐릭터 스탯 등을 관리하는 데 효과적이다. 이는 데이터의 일관성을 유지하고, 대규모 데이터를 쉽게 편집하고 조회할 수 있게 한다.

profile
🏦KAIST EE | 🏦SNU AI(빅데이터 핀테크 전문가 과정) | 📙CryptoHipsters 저자

0개의 댓글