[UE5] 인벤토리 시스템 - 인벤토리 UI
프로젝트 개요
여러 명이 하나의 스포츠 영상을 시청하면서 특정 대상(스포츠 팀 / 선수)을 응원하는 플랫폼을 제공하는 메타버스 컨텐츠를 제작하는 프로젝트
인벤토리의 필요성
스포츠 경기를 관람할 때 다이나믹한 응원을 위해 폭죽을 터트리고 응원봉을 흔들듯 여러 아이템이 있으면 영상을 시청하는데 재미 요소를 더할 수 있을 것이라 판단했고, 여러 아이템을 자신이 가진 포인트로 구매하고 구매한 아이템 소지하는 기능이 필요하다 판단하여 인벤토리를 구현하게 되었다.
구현 개요
인벤토리 UI는 플레이어가 갖고 있는 InventoryComponent를 참조해 InventoryComponent의 Invetory 배열이 변경될 때마다 UI를 갱신한다. 키보드 입력 I를 통해 인벤토리 UI를 팝업시킬 수 있다. 인벤토리 UI를 통해 아이템을 사용 및 장착하고 인벤토리에서 아이템을 버리는 기능을 사용할 수 있다. 간단한 Inventory이기도 하고 Drag& Drop이 필수 기능은 아니라 생각해 넣지 않았다.
인벤토리 UI 구현
- Widget 생성자 (NativeContruct)
void UInventoryWidget::NativeConstruct()
{
if (!Btn_ThrowAway->OnClicked.IsBound())
{
Btn_ThrowAway->OnClicked.AddDynamic(this, &UInventoryWidget::ThrowAwaySelectedItem);
}
if (!Btn_Use->OnClicked.IsBound())
{
Btn_Use->OnClicked.AddDynamic(this, &UInventoryWidget::UseItem);
}
if (!Btn_CostumeCategory->OnClicked.IsBound())
{
Btn_CostumeCategory->OnClicked.AddDynamic(this, &UInventoryWidget::SelectCategory_Costume);
}
if (!Btn_ActiveCategory->OnClicked.IsBound())
{
Btn_ActiveCategory->OnClicked.AddDynamic(this, &UInventoryWidget::SelectCategory_Active);
}
if (!Btn_EmotionCategory->OnClicked.IsBound())
{
Btn_EmotionCategory->OnClicked.AddDynamic(this, &UInventoryWidget::SelectCategory_Emotion);
}
if (!Btn_EmojiCategory->OnClicked.IsBound())
{
Btn_EmojiCategory->OnClicked.AddDynamic(this, &UInventoryWidget::SelectCategory_Emoji);
}
if (!Btn_SoundCategory->OnClicked.IsBound())
{
Btn_SoundCategory->OnClicked.AddDynamic(this, &UInventoryWidget::SelectCategory_Sound);
}
GI = Cast<UHG_GameInstance>(GetWorld()->GetGameInstance());
SelectedCategory = WB_SlotList_Active;
}
※Constructor에서 AddDynamic으로 바인딩을 한 번 하고 다음에 또 바딩을 시도하면 오류 발생하므로 이벤트의 IsBound() 함수를 통해 이벤트가 바운딩 됐는지를 검사해야함. 충돌이 나거나 엔진이 터지진 않지만 UI에서의 기능들이 이상해진다.
- 인벤토리UI 초기화 함수 (InitInventoryUI())
void UInventoryWidget::InitInventoryUI()
{
// 모든 WrapBox의 자식들을 없애준다. (해주지 않으면 아이템이 복사됨)
WB_SlotList_Active->ClearChildren();
WB_SlotList_Costume->ClearChildren();
WB_SlotList_Emotion->ClearChildren();
WB_SlotList_Emoji->ClearChildren();
WB_SlotList_Sound->ClearChildren();
// 선택 슬롯도 초기화 (UX 적으로 인벤토리를 껐다 켰을 때 선택된 슬롯도 없는 것이 맞다고 판단)
SelectedSlot = nullptr;
DIsplaySelectedItemInfo();
// 현재 이 인벤토리 위젯이 어떤 Pawn 에게 소유 되었는지 검사
if (this->GetOwningPlayer() != nullptr)
{
// 소유하고 있는 Pawn 이 있다면
// 해당 Pawn 을 Player 로 Cast 한다.
auto* OwningPlayer = Cast<AHG_Player>(this->GetOwningPlayer()->GetPawn());
if (OwningPlayer)
{
// Cast 에 성공했다면
// 해당 Player 가 가진 Inventory 의 모든 요소를 접근
for (auto slot : OwningPlayer->InventoryComp->Inventory)
{
// 각 슬롯(요소)별로 위젯을 만듦
UHG_SlotWidget* SlotWidget = CreateWidget<UHG_SlotWidget>(this, SlotWidgetFactory);
if (SlotWidget)
{
// 만드는데 성공했다면
// 슬롯 위젯을 현재 접근 중인 인벤토리 슬롯의 데이터로 초기화해준다.
SlotWidget->InitSlot(slot);
SlotWidget->SetItemIcon();
// 슬롯 위젯의 주인을 설정해준다. (인벤토리 UI에서 상대적인 자기 자신에 대한 정보를 알아야하기 때문)
SlotWidget->SetOwner(this);
// 현재 접근하고 있는 슬롯의 카테고리에 맞는 WrapBox에 넣어주기 위한 EItemCategory 검사
if (slot.ItemInfo.ItemCategory == EItemCategory::Category_Active)
{
WB_SlotList_Active->AddChildToWrapBox(SlotWidget);
}
else if (slot.ItemInfo.ItemCategory == EItemCategory::Category_Emotion)
{
WB_SlotList_Emotion->AddChildToWrapBox(SlotWidget);
}
else if (slot.ItemInfo.ItemCategory == EItemCategory::Category_Emoji)
{
WB_SlotList_Emoji->AddChildToWrapBox(SlotWidget);
}
else if (slot.ItemInfo.ItemCategory == EItemCategory::Category_Sound)
{
WB_SlotList_Sound->AddChildToWrapBox(SlotWidget);
}
else
{
WB_SlotList_Costume->AddChildToWrapBox(SlotWidget);
CheckEquipitem();
for (auto EquipSlot : EquipList)
{
// 갖고있는 ItemData의 ItemName으로 현재 생성한 SlotWidget가 EquipList에 요소로 존재했었는지 검사
if (EquipSlot->SlotInfo.ItemInfo.ItemName == SlotWidget->SlotInfo.ItemInfo.ItemName)
{
// EquipList에 존재했었다면
// 아이템이 현재 장착되었는지 보여주기 위한 표시
// HitTestInvisible => 클릭 등의 입력을 비활성화 하는 옵션, 그냥 Visible로 하면 Slot에 있는 Button 의 입력을 Img_Equip 이 가져감
SlotWidget->Img_Equip->SetVisibility(ESlateVisibility::HitTestInvisible);
// EquipList에서 기존에 장착했던 아이템을 다시 생성한 SlotWidget로 갱신해줌
EquipList[EquipList.Find(EquipSlot)] = SlotWidget;
}
}
}
}
}
}
}
// 인벤토리를 팝업하자마자 보이는 WrapBox 는 Active 로 설정
SelectedCategory = WB_SlotList_Active;
WS_Category->SetActiveWidgetIndex(0);
}
- 아이템 버리기 (ThrowAwaySelectedItem())
// 아이템 버리기
void UInventoryWidget::ThrowAwaySelectedItem()
{
if (SelectedSlot)
{
// 위젯의 주인 찾기
auto* Owner = Cast<AHG_Player>(this->GetOwningPlayer()->GetPawn());
// 선택된 슬롯이 장착된 아이템 슬롯 리스트에 있는지 확인
if (EquipList.Contains(SelectedSlot))
{
// 버튼의 텍스트를 장착하기로 바꿔줌
TB_Use->SetText(FText::FromString(TEXT("장착하기")));
// 선택된 슬롯의 장착 이미지를 보이지 않게 함
SelectedSlot->Img_Equip->SetVisibility(ESlateVisibility::Hidden);
// 장착된 아이템 슬롯 리스트에서 선택된 슬롯 제거
EquipList.Remove(SelectedSlot);
// 인벤토리 위젯의 주인이 장착하고 있는 아이템을 장착 해제
Owner->UnequipItemToSocket(SelectedSlot->SlotInfo.ItemInfo.ItemName);
}
// 현재 선택된 Category WrapBox의 자식 개수를 가져옴
int32 ChildCount = SelectedCategory->GetChildrenCount();
// 자식의 수만큼 반복함
for (int32 i = 0; i < ChildCount; i++)
{
// SelectedCategory 에서 i 번째 자식을 가져와서 UHG_SlotWidget 으로 Casting
UHG_SlotWidget* Child = Cast<UHG_SlotWidget>(SelectedCategory->GetChildAt(i));
// 선택한 슬롯과 SelectedCategory의 i 번째 자식이 같은지 확인
if (Child == SelectedSlot)
{
// 같다면
// NullCheck
if (Owner)
{
// Owner의 인벤토리에서 선택된 슬롯의 데이터와 같은 아이템을 제거
Owner->InventoryComp->RemoveFromInventory(SelectedSlot->SlotInfo.ItemInfo, 1);
}
// 슬롯의 Quantity를 하나 줄임
SelectedSlot->SlotInfo.Quantity--;
// 슬롯의 Quantity가 0이 되면
if (SelectedSlot->SlotInfo.Quantity == 0)
{
// 선택된 카테고리의 i번째 자식을 없애고
SelectedCategory->RemoveChildAt(i);
// SelectedSlot을 비워줌
SelectedSlot = nullptr;
}
// 현재의 슬롯 정보를 갱신
DIsplaySelectedItemInfo();
break;
}
}
}
}
- 아이템 사용하기 (UseItem)
// 아이템 사용하기
void UInventoryWidget::UseItem()
{
// NullCheck
if (SelectedSlot)
{
// NullCheck
if (this->GetOwningPlayer() != nullptr)
{
// 위젯의 주인 찾기
auto* OwningPlayer = Cast<AHG_Player>(this->GetOwningPlayer()->GetPawn());
// NullCheck
if (nullptr != OwningPlayer)
{
// 선택된 슬롯이 무엇인지 확인
if (SelectedCategory == WB_SlotList_Active)
{
// Active에서 아이템을 사용했다면
// 아이템을 월드에 스폰 시키고 사용하는 AHG_Player의 SpawnItem 함수 사용
OwningPlayer->SpawnItem(SelectedSlot->SlotInfo.ItemInfo);
// 아이템 1개 감소
ThrowAwaySelectedItem();
}
else if (SelectedCategory == WB_SlotList_Emoji || SelectedCategory == WB_SlotList_Sound)
{
// 이모티콘 혹은 효과음 아이템을 사용했다면
// 아이템을 월드에 스폰 시키고 사용하는 AHG_Player의 SpawnItem 함수 사용
OwningPlayer->SpawnItem(SelectedSlot->SlotInfo.ItemInfo);
}
else if (SelectedCategory == WB_SlotList_Costume)
{
// GameInstance의 EquipSlotIndexList에 내가 선택한 슬롯의 아이템의 인덱스가 포함되어 있는지 확인 ==> 장착한 아이템인지 확인
if (GI->EquipSlotIndexList.Contains(SelectedSlot->MyIndex))
{
// 장착한 아이템이라면
// 사용하는 버튼의 텍스트를 해제하기에서 장착하기로 변경
TB_Use->SetText(FText::FromString(TEXT("장착하기")));
// 선택한 슬롯의 장착 표시 이미지를 보이지 않게 함
SelectedSlot->Img_Equip->SetVisibility(ESlateVisibility::Hidden);
// EquipList에서 선택한 슬롯을 제거
EquipList.Remove(SelectedSlot);
// GameInstance의 EquipSlotIndexList에서 현재 슬롯의 인덱스 제거
GI->EquipSlotIndexList.Remove(GetSlotIndexInWB(SelectedSlot));
// 플레이어 아이템 장착 해제
OwningPlayer->UnequipItemToSocket(SelectedSlot->SlotInfo.ItemInfo.ItemName);
}
else
{
// 장착하지 않았다면
// 사용하는 버튼의 텍스트를 장착하기에서 해제하기로 변경
TB_Use->SetText(FText::FromString(TEXT("해제하기")));
// 선택한 슬롯의 장착 표시 이미지를 보이게 함
SelectedSlot->Img_Equip->SetVisibility(ESlateVisibility::HitTestInvisible);
// EquipList에서 선택한 슬롯을 추가
EquipList.Add(SelectedSlot);
// GameInstance의 EquipSlotIndexList에서 현재 슬롯의 인덱스 추가
GI->EquipSlotIndexList.Add(GetSlotIndexInWB(SelectedSlot));
// 플레이어 아이템 장착
OwningPlayer->EquipItemToSocket(SelectedSlot->SlotInfo.ItemInfo);
}
}
else if(SelectedCategory == WB_SlotList_Emotion)
{
// 이모션 아이템을 사용했다면
// 플레이어가 Montage를 실행하도록 함
OwningPlayer->ServerRPC_Emotion(SelectedSlot->SlotInfo.ItemInfo.Montage);
}
}
}
}
}
※ 아이템을 사용하면 아이템이 소모되는 코드를 ThrowAwaySelectedItem() 함수를 사용하였는데 본인이 진행한 프로젝트에선 아이템을 인벤토리에서 버렸을 때 월드에 버려지지 않고 그냥 사라지는 방식으로 기획되었기 때문에 아이템을 버리는 로직을 그대로 사용하였지만 아이템을 버렸을 때 월드에 아이템이 스폰되어야 한다면 ThrowAwaySelectedItem()을 아이템을 사용하는 코드에 사용하는 것은 바람직하지 못함
- GetSlotIndexInWB()
// 현재 선택한 슬롯이 부모에서 몇 번째 자식인지 반환받는 함수
int32 UInventoryWidget::GetSlotIndexInWB(UWidget* SlotWidget)
{
if (!WB_SlotList_Costume || !SlotWidget) return -1;
const TArray<UWidget*>& Slots = WB_SlotList_Costume->GetAllChildren();
int32 Index = Slots.IndexOfByKey(SlotWidget);
return Index;
}
- CheckEquipitem()
// 레벨 이동간 장착 아이템에 대한 정보를 유지하기 위한 함수
void UInventoryWidget::CheckEquipitem()
{
if (GI->EquipSlotIndexList.Num() <= 0) return;
for (auto n : GI->EquipSlotIndexList)
{
UHG_SlotWidget* EquipSlot = Cast<UHG_SlotWidget>(WB_SlotList_Costume->GetChildAt(n));
if (EquipSlot)
{
EquipSlot->Img_Equip->SetVisibility(ESlateVisibility::HitTestInvisible);
}
}
}
- 카테고리를 바꾸는 버튼이 클릭 됐을 때 실행되는 함수들
// Active 카테고리 버튼이 클릭 됐을 때 실행되는 함수
void UInventoryWidget::SelectCategory_Active()
{
SelectedCategory = WB_SlotList_Active;
WS_Category->SetActiveWidgetIndex(0);
TB_Use->SetText(FText::FromString(TEXT("사용하기")));
}
// Costume 카테고리 버튼이 클릭 됐을 때 실행되는 함수
void UInventoryWidget::SelectCategory_Costume()
{
SelectedCategory = WB_SlotList_Costume;
WS_Category->SetActiveWidgetIndex(1);
if (SelectedSlot != nullptr && EquipList.Contains(SelectedSlot))
{
TB_Use->SetText(FText::FromString(TEXT("해제하기")));
}
else
{
TB_Use->SetText(FText::FromString(TEXT("장착하기")));
}
}
// Emotion 카테고리 버튼이 클릭 됐을 때 실행되는 함수
void UInventoryWidget::SelectCategory_Emotion()
{
SelectedCategory = WB_SlotList_Emotion;
WS_Category->SetActiveWidgetIndex(2);
TB_Use->SetText(FText::FromString(TEXT("사용하기")));
}
// Emoji 카테고리 버튼이 클릭 됐을 때 실행되는 함수
void UInventoryWidget::SelectCategory_Emoji()
{
SelectedCategory = WB_SlotList_Emoji;
WS_Category->SetActiveWidgetIndex(3);
TB_Use->SetText(FText::FromString(TEXT("사용하기")));
}
// Sound 카테고리 버튼이 클릭 됐을 때 실행되는 함수
void UInventoryWidget::SelectCategory_Sound()
{
SelectedCategory = WB_SlotList_Sound;
WS_Category->SetActiveWidgetIndex(4);
TB_Use->SetText(FText::FromString(TEXT("사용하기")));
}
- SlotWidget이 클릭되었을 때 선택된 Slot의 아이템 정보를 표시하는 함수
void UInventoryWidget::DisplaySelectedItemInfo()
{
// 선택된 슬롯이 있는지 없는지 검사
if (SelectedSlot)
{
// 있다면 선택된 슬롯의 정보로 UI를 갱신
Img_SelectedItem->SetBrushFromTexture(SelectedSlot->SlotInfo.ItemInfo.ItemIcon);
TB_ItemName->SetText(FText::FromString(SelectedSlot->SlotInfo.ItemInfo.ItemName));
TB_Price->SetText(FText::AsNumber((SelectedSlot->SlotInfo.ItemInfo.ItemPrice)));
TB_Quantity->SetText(FText::AsNumber(SelectedSlot->SlotInfo.Quantity));
}
else
{
// 없다면 아무것도 표시하지 않음
Img_SelectedItem->SetBrushFromMaterial(DefaultImage);
TB_ItemName->SetText(FText::FromString(TEXT("")));
TB_Price->SetText(FText::FromString(TEXT("")));
TB_Quantity->SetText(FText::FromString(TEXT("")));
}
}