[UE5 C++] 아이템 & 인벤토리 시스템 - 2

LeeTaes·2024년 5월 15일
0

[UE_Project] MysticMaze

목록 보기
13/17
post-thumbnail

언리얼 엔진을 사용한 RPG 프로젝트 만들기

  • 인벤토리 아이템과 인벤토리 컴포넌트 구현

인벤토리 아이템

  • 인벤토리에 저장될 아이템 클래스를 제작해보도록 하겠습니다.
  • UObject를 상속받아 "MMInventoryItem" 클래스를 생성합니다.
    - 아이템 데이터 애셋과 수량을 저장해줍니다.
// MMInventoryItem Class

#pragma once

#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "MMInventoryItem.generated.h"

/**
 * 
 */
UCLASS()
class MYSTICMAZE_API UMMInventoryItem : public UObject
{
	GENERATED_BODY()
	
public:
	UMMInventoryItem();

public:
	UPROPERTY(VisibleAnywhere)
	TObjectPtr<class UMMItemData> ItemData;

	UPROPERTY(VisibleAnywhere)
	int32 ItemQuantity;
};

애셋 매니저와 인벤토리 컴포넌트

  • 아이템 박스나 추후 다른 곳에서 전달할 FString 타입의 데이터 애셋의 이름을 애샛 매니저로 받아와 사용할 것입니다.
  • 애샛 매니저에 아이템 데이터 애셋들의 위치를 넘겨 저장해주도록 하겠습니다.

[프로젝트 설정] - [게임] - [애셋 매니저]를 들어가 다음과 같이 설정을 추가해주도록 합니다.

프라이머리 애셋 타입은 추후 검색에 사용될 것으로 이전 아이템 데이터 애셋 클래스에서 선언했던 이름을 그대로 저장해주도록 하겠습니다.

애셋 베이스 클래스는 경로 상에서 찾을 클래스 타입입니다. 모든 아이템 데이터 애셋의 부모인 "MMItemData" 클래스로 지정합니다.

디렉터리에 해당 애셋이 모여있는 위치를 지정해주도록 합니다.


  • 인벤토리 컴포넌트를 제작합니다.
  • ActorComponent를 상속받는 "MMInventoryComponent" 클래스를 생성합니다.
  • 장비, 소비, 기타 아이템 저장용 배열과 골드를 추가하고 관련 로직을 구현해보도록 하겠습니다.

인벤토리 컴포넌트를 제작해주도록 하겠습니다.

인벤토리 아이템 슬롯을 구분하기 위한 열거형을 정의해주도록 합니다.

인벤토리 배열 3종을 추가해주고, 관련 변수들을 추가해주도록 하겠습니다.

제가 생각하는 인벤토리는 아이템의 추가와 삭제, 사용 로직을 가져야 합니다.
추가적으로 Drag&Drop으로 아이템의 위치를 변경시켜야 하므로 관련 함수들도 추가해주도록 하겠습니다.

다음으로는 인벤토리 내부 아이템이 추가/삭제되었을 때 호출하기 위한 델리게이트를 추가해줍니다. (+골드)


  • 선언한 함수들을 실제로 구현해주도록 하겠습니다.

인벤토리의 초기화 부분입니다.

InitializeComponent() 함수를 사용해 시작 전에 초기화를 진행하도록 구현하였습니다.

UMMInventoryComponent::UMMInventoryComponent()
{
	bWantsInitializeComponent = true;

	MaxInventoryNum = 30;
	MaxItemNum = 99;

	// 초기 사이즈 지정
	EquipmentItems.Init(nullptr, MaxInventoryNum);
	ConsumableItems.Init(nullptr, MaxInventoryNum);
	OtherItems.Init(nullptr, MaxInventoryNum);
}

void UMMInventoryComponent::InitializeComponent()
{
	Super::InitializeComponent();

	// 인벤토리 초기화
	InitInventory();
}

게임 진행 데이터를 File 형식으로 가지고 있을 예정입니다.

인벤토리 초기화 함수에서는 파일로부터 읽어와 인벤토리를 재구성하도록 구현할 예정이며, 우선은 테스트 데이터를 넣어 초기화를 진행하였습니다.

void UMMInventoryComponent::InitInventory()
{
	// TODO : 파일로부터 정보 읽어와서 설정하기
	CurrentGold = 1000;

	// 애셋 매니저 불러오기
	UAssetManager& Manager = UAssetManager::Get();

	// 애셋 아이디 리스트 받아오기
	TArray<FPrimaryAssetId> Assets;
	// * 태그 정보를 넘겨줘서 동일한 태그를 가진 애셋들의 목록을 배열로 반환받음
	Manager.GetPrimaryAssetIdList(TEXT("MMItemData"), Assets);

	if (Assets.Num() > 0)
	{
		TMap<int32, TPair<FName, int32>> InventoryEquipmentArray;
		TMap<int32, TPair<FName, int32>> InventoryConstableArray;
		// TODO : 세이브 파일에서 데이터 읽어오기 (현재는 테스트 용도)
		{
			InventoryEquipmentArray.Add(1, { TEXT("DA_Staff_BluntBell"), 1 });
			InventoryEquipmentArray.Add(0, { TEXT("DA_Staff_BluntHellHammerCine"), 1 });
			InventoryEquipmentArray.Add(27, { TEXT("DA_Bow_2"), 1 });

			InventoryConstableArray.Add(1, { TEXT("DA_HP_Potion_Large"), 10 });
			InventoryConstableArray.Add(0, { TEXT("DA_HP_Potion_Middle"), 62 });
			InventoryConstableArray.Add(7, { TEXT("DA_MP_Potion_Large"), 7 });
			InventoryConstableArray.Add(27, { TEXT("DA_MP_Potion_Middle"), 1 });
		}

		for (const auto& InvItem : InventoryEquipmentArray)
		{
			// 특정 아이템 키 생성
			FPrimaryAssetId Key;
			Key.PrimaryAssetType = TEXT("MMItemData");
			Key.PrimaryAssetName = InvItem.Value.Key;

			if (Assets.Contains(Key))
			{
				// 아이템 생성
				UMMInventoryItem* NewItem = NewObject<UMMInventoryItem>();
				if (NewItem)
				{
					FSoftObjectPtr AssetPtr(Manager.GetPrimaryAssetPath(Assets.FindByKey(Key)[0]));
					if (AssetPtr.IsPending())
					{
						AssetPtr.LoadSynchronous();
					}
					UMMItemData* ItemData = Cast<UMMItemData>(AssetPtr.Get());
					if (ItemData)
					{
						NewItem->ItemData = ItemData;
						NewItem->ItemQuantity = InvItem.Value.Value;
						// 아이템 넣기
						EquipmentItems[InvItem.Key] = NewItem;
					}
				}
			}
		}

		for (const auto& InvItem : InventoryConstableArray)
		{
			// 특정 아이템 키 생성
			FPrimaryAssetId Key;
			Key.PrimaryAssetType = TEXT("MMItemData");
			Key.PrimaryAssetName = InvItem.Value.Key;

			if (Assets.Contains(Key))
			{
				// 아이템 생성
				UMMInventoryItem* NewItem = NewObject<UMMInventoryItem>();
				if (NewItem)
				{
					FSoftObjectPtr AssetPtr(Manager.GetPrimaryAssetPath(Assets.FindByKey(Key)[0]));
					if (AssetPtr.IsPending())
					{
						AssetPtr.LoadSynchronous();
					}
					UMMItemData* ItemData = Cast<UMMItemData>(AssetPtr.Get());
					if (ItemData)
					{
						NewItem->ItemData = ItemData;
						NewItem->ItemQuantity = InvItem.Value.Value;
						// 아이템 넣기
						ConsumableItems[InvItem.Key] = NewItem;
					}
				}
			}
		}
	}
}

아이템 추가 함수(AddItem)을 구현해주도록 합니다.

  • 아이템 추가 함수는 이름과 수량을 받아 인벤토리에 추가해주는 역할입니다.

  • 애셋 매니저를 통해 해당 이름의 아이템 데이터 애셋을 찾아 저장해줍니다.

  • 아이템 박스로부터 아이템을 받아올 경우 인벤토리 공간이 부족한 경우가 생길 수 있으므로 남은 아이템의 수를 반환하는 Out 변수를 사용했습니다.

  • 무기 아이템은 한 칸에 1개만 저장 가능하며, 소비 아이템과 기타 아이템은 99개씩 저장이 가능합니다.

bool UMMInventoryComponent::AddItem(FName InItemName, int32 InItemQuantity, int32& OutItemQuantity)
{
	// 성공적으로 추가했는지에 대한 결과 반환용 변수
	bool bIsResult = false;

	// 애셋 매니저 불러오기
	UAssetManager& Manager = UAssetManager::Get();

	// 애셋 아이디 리스트 받아오기
	TArray<FPrimaryAssetId> Assets;
	// * 태그 정보를 넘겨줘서 동일한 태그를 가진 애셋들의 목록을 배열로 반환받음
	Manager.GetPrimaryAssetIdList(TEXT("MMItemData"), Assets);

	// 특정 아이템 키 생성
	FPrimaryAssetId Key;
	Key.PrimaryAssetType = TEXT("MMItemData");
	Key.PrimaryAssetName = InItemName;

	// 해당 키의 애셋이 존재한다면?
	if (Assets.Contains(Key))
	{
		UE_LOG(LogTemp, Warning, TEXT("Find Data"));

		// 아이템 생성
		UMMInventoryItem* NewItem = NewObject<UMMInventoryItem>();
		if (NewItem)
		{
			FSoftObjectPtr AssetPtr(Manager.GetPrimaryAssetPath(Assets.FindByKey(Key)[0]));
			if (AssetPtr.IsPending())
			{
				AssetPtr.LoadSynchronous();
			}
			UMMItemData* ItemData = Cast<UMMItemData>(AssetPtr.Get());
			if (ItemData)
			{
				NewItem->ItemData = ItemData;
				NewItem->ItemQuantity = InItemQuantity;
			}
			else
			{
				return bIsResult;
			}
		}

		// 이미 해당 아이템이 존재하는지 체크하기 (포션, 기타)
		if (NewItem->ItemData->ItemType != EItemType::IT_Weapon)
		{
			// 타입에 따라 해당 인벤토리에 저장하기
			switch (NewItem->ItemData->ItemType)
			{
			case EItemType::IT_Potion:
				for (UMMInventoryItem* Item : ConsumableItems)
				{
					if (IsValid(Item))
					{
						// 이름이 동일한 아이템이 있는지 체크하기
						if (Item->ItemData->ItemName == NewItem->ItemData->ItemName)
						{
							// 최대 수량 체크하기
							if (Item->ItemQuantity + NewItem->ItemQuantity> MaxItemNum)
							{
								// 최대 수량까지 채운 후 남은 값으로 설정하기
								NewItem->ItemQuantity = Item->ItemQuantity + NewItem->ItemQuantity - MaxItemNum;
								Item->ItemQuantity = MaxItemNum;
								break;
							}
							else
							{
								// 수량을 더하고 종료하기
								Item->ItemQuantity += NewItem->ItemQuantity;
								OnChangeInven.Broadcast();
								return true;
							}
						}
					}
				}
				break;

			case EItemType::IT_ManaStone:
				for (UMMInventoryItem* Item : OtherItems)
				{
					if (IsValid(Item))
					{
						// 이름이 동일한 아이템이 있는지 체크하기
						if (Item->ItemData->ItemName == NewItem->ItemData->ItemName)
						{
							// 최대 수량 체크하기
							if (Item->ItemQuantity + NewItem->ItemQuantity > MaxItemNum)
							{
								// 최대 수량까지 채운 후 남은 값으로 설정하기
								NewItem->ItemQuantity = Item->ItemQuantity + NewItem->ItemQuantity - MaxItemNum;
								Item->ItemQuantity = MaxItemNum;
								break;
							}
							else
							{
								// 수량을 더하고 종료하기
								Item->ItemQuantity += NewItem->ItemQuantity;
								OnChangeInven.Broadcast();
								return true;
							}
						}
					}
				}
				break;
			}
		}

		// 해당 아이템 타입의 인벤토리의 빈 칸 찾아 데이터 추가하기
		int32 Index = 0;
		switch (NewItem->ItemData->ItemType)
		{
		case EItemType::IT_Weapon:
			for (UMMInventoryItem* Item : EquipmentItems)
			{
				if (!IsValid(Item))
				{
					OtherItems[Index] = NewItem;
					bIsResult = true;
					OnChangeInven.Broadcast();
					break;
				}

				Index++;
			}
			break;
		case EItemType::IT_Potion:
			for (UMMInventoryItem* Item : ConsumableItems)
			{
				if (!IsValid(Item))
				{
					OtherItems[Index] = NewItem;
					bIsResult = true;
					OnChangeInven.Broadcast();
					break;
				}

				Index++;
			}
			break;
		case EItemType::IT_ManaStone:
			for (UMMInventoryItem* Item : OtherItems)
			{
				if (!IsValid(Item))
				{
					OtherItems[Index] = NewItem;
					bIsResult = true;
					OnChangeInven.Broadcast();
					break;
				}

				Index++;
			}
			break;
		}

		if (!bIsResult)
		{
			OutItemQuantity = NewItem->ItemQuantity;
			return bIsResult;
		}
		else
		{
			return bIsResult;
		}
	}

	OutItemQuantity = InItemQuantity;
	return bIsResult;
}

다음으로는 아이템 사용 (UseItem)함수를 구현해주도록 합니다.

아이템 사용 함수에서는 아이템 슬롯의 타입과 인덱스를 받아와서 해당 인벤토리의 아이템을 사용하는 로직을 추가할 것입니다.

앞으로도 소비 아이템만 사용할 것 같지만 혹시 몰라 Switch문을 사용해 타입별로 동작을 변경해주도록 설정했습니다. (ex. 무기 장착 등)

아이템 사용 함수는 실제 스탯에 반영할 일이 많을 것 같아 플레이어의 함수를 호출하는 쪽으로 구현 방향을 잡았습니다.

소비 아이템 사용 시 수량을 감소시키고, 만약 남은 수가 0이라면 해당 아이템을 소멸시켜주도록 하였습니다.

void UMMInventoryComponent::UseItem(int32 InSlotIndex, ESlotType InventoryType)
{
	// 해당 인벤토리 슬롯에 아이템이 존재하는지 체크하고 사용하기
	switch (InventoryType)
	{
	case ESlotType::ST_InventoryConsumable:
		if (ConsumableItems.IsValidIndex(InSlotIndex) && IsValid(ConsumableItems[InSlotIndex]))
		{
			// 수량을 줄여줍니다.
			ConsumableItems[InSlotIndex]->ItemQuantity--;
			// 아이템을 사용합니다. TODO : 플레이어에서 작업하기
			UE_LOG(LogTemp, Warning, TEXT("ConsumableItem Use"));
			// 수량이 0 이하라면 소멸시켜줍니다.
			if (ConsumableItems[InSlotIndex]->ItemQuantity <= 0)
			{
				RemoveItem(InSlotIndex, InventoryType);
			}
		}
		break;
	}
}

다음으로 골드를 추가해주는 함수를 추가해주도록 하겠습니다.

간단히 기존 골드에서 값만 더해 저장하도록 구현하였습니다.

void UMMInventoryComponent::AddGold(int32 InGold)
{
	if (InGold < 0) return;

	CurrentGold += InGold;
	OnChangeGold.Broadcast();
}

SwapItem(아이템 교체) 함수는 두 슬롯의 타입이 같은 경우에 해당 인벤토리 내부에서 아이템을 교체하는 로직을 구현해주었습니다.

void UMMInventoryComponent::SwapItem(int32 InPrevIndex, int32 InCurrentIndex, ESlotType InPrevSlotType, ESlotType InCurrentSlotType)
{
	// 슬롯 타입이 같은 경우 교환해주도록 합니다.
	if (InPrevSlotType == InCurrentSlotType)
	{
		switch (InCurrentSlotType)
		{
		case ESlotType::ST_InventoryEquipment:
			// 해당 슬롯의 아이템이 유효한지 체크합니다.
			if (EquipmentItems.IsValidIndex(InPrevIndex) && EquipmentItems.IsValidIndex(InCurrentIndex))
			{
				// 교체 후 이벤트를 호출합니다.
				EquipmentItems.Swap(InPrevIndex, InCurrentIndex);
				OnChangeInven.Broadcast();
			}
			break;

		case ESlotType::ST_InventoryConsumable:
			// 해당 슬롯의 아이템이 유효한지 체크합니다.
			if (ConsumableItems.IsValidIndex(InPrevIndex) && ConsumableItems.IsValidIndex(InCurrentIndex))
			{
				// 교체 후 이벤트를 호출합니다.
				ConsumableItems.Swap(InPrevIndex, InCurrentIndex);
				OnChangeInven.Broadcast();
			}
			break;

		case ESlotType::ST_InventoryOther:
			// 해당 슬롯의 아이템이 유효한지 체크합니다.
			if (OtherItems.IsValidIndex(InPrevIndex) && OtherItems.IsValidIndex(InCurrentIndex))
			{
				// 교체 후 이벤트를 호출합니다.
				OtherItems.Swap(InPrevIndex, InCurrentIndex);
				OnChangeInven.Broadcast();
			}
			break;
		}
	}
}

아이템의 정렬은 Algo::Sort() 함수를 사용해 구현하였습니다.

아이템의 이름 순으로 정렬하며, 이름이 같은 경우 수량이 많은 것이 앞으로 오도록 구현하였습니다.

void UMMInventoryComponent::SortItem(ESlotType InSlotType)
{
	// 슬롯 타입에 따라 정렬합니다.
	switch (InSlotType)
	{
	case ESlotType::ST_InventoryEquipment:
		Algo::Sort(EquipmentItems,
			[](const TObjectPtr<UMMInventoryItem>& A, const TObjectPtr<UMMInventoryItem>& B)
			{
				// 해당 요소가 nullptr이라면 뒤로 배치합니다
				if (!IsValid(A) && !IsValid(B))
					return false;
				else if (!IsValid(A))
					return false;
				else if (!IsValid(B))
					return true;

				// 동일한 이름의 아이템이라면 수량 순으로 배치합니다.
				if (A->ItemData->ItemName == B->ItemData->ItemName)
				{
					return A->ItemQuantity > B->ItemQuantity;
				}
				// 다른 이름의 아이템이라면 아이템 이름 순으로 배치합니다.
				else
				{
					return A->ItemData->ItemName < B->ItemData->ItemName;
				}
			}
		);
		OnChangeInven.Broadcast();
		break;
	
	case ESlotType::ST_InventoryConsumable:
		Algo::Sort(ConsumableItems,
			[](const TObjectPtr<UMMInventoryItem>& A, const TObjectPtr<UMMInventoryItem>& B)
			{
				// 해당 요소가 nullptr이라면 뒤로 배치합니다
				if (!IsValid(A) && !IsValid(B))
					return false;
				else if (!IsValid(A))
					return false;
				else if (!IsValid(B))
					return true;

				// 동일한 이름의 아이템이라면 수량 순으로 배치합니다.
				if (A->ItemData->ItemName == B->ItemData->ItemName)
				{
					return A->ItemQuantity > B->ItemQuantity;
				}
				// 다른 이름의 아이템이라면 아이템 이름 순으로 배치합니다.
				else
				{
					return A->ItemData->ItemName < B->ItemData->ItemName;
				}
			}
		);
		OnChangeInven.Broadcast();
		break;
	
	case ESlotType::ST_InventoryOther:
		Algo::Sort(OtherItems,
			[](const TObjectPtr<UMMInventoryItem>& A, const TObjectPtr<UMMInventoryItem>& B)
			{
				// 해당 요소가 nullptr이라면 뒤로 배치합니다
				if (!IsValid(A) && !IsValid(B))
					return false;
				else if (!IsValid(A))
					return false;
				else if (!IsValid(B))
					return true;

				// 동일한 이름의 아이템이라면 수량 순으로 배치합니다.
				if (A->ItemData->ItemName == B->ItemData->ItemName)
				{
					return A->ItemQuantity > B->ItemQuantity;
				}
				// 다른 이름의 아이템이라면 아이템 이름 순으로 배치합니다.
				else
				{
					return A->ItemData->ItemName < B->ItemData->ItemName;
				}
			}
		);
		OnChangeInven.Broadcast();
		break;
	}
}

마지막으로 아이템 제거 함수입니다.

인벤토리 아이템을 TObjectPtr<> 형식으로 배열에 저장해두었기 때문에 모든 참조가 사라지면 아이템은 자동으로 소멸하게 됩니다.

해당 슬롯의 아이템을 제거하는 것이 아닌 nullptr로 변환하여 공간을 유지시킨 채 아이템 데이터만 삭제하는 로직을 구현하였습니다.

void UMMInventoryComponent::RemoveItem(int32 InSlotIndex, ESlotType InventoryType)
{
	// 해당 인벤토리 슬롯의 유효성을 체크하고 소멸시켜줍니다.
	switch (InventoryType)
	{
	case ESlotType::ST_InventoryEquipment:
		if (EquipmentItems.IsValidIndex(InSlotIndex) && IsValid(EquipmentItems[InSlotIndex]))
		{
			EquipmentItems[InSlotIndex] = nullptr;
		}
		break;

	case ESlotType::ST_InventoryConsumable:
		if (ConsumableItems.IsValidIndex(InSlotIndex) && IsValid(ConsumableItems[InSlotIndex]))
		{
			ConsumableItems[InSlotIndex] = nullptr;
		}
		break;

	case ESlotType::ST_InventoryOther:
		if (OtherItems.IsValidIndex(InSlotIndex) && IsValid(OtherItems[InSlotIndex]))
		{
			OtherItems[InSlotIndex] = nullptr;
		}
		break;
	}
}
profile
클라이언트 프로그래머 지망생

0개의 댓글