인벤토리 시스템 Part3 - 컴포넌트 세부사항
인벤토리 컴포넌트와 장비 컴포넌트 작동 방식
인벤토리 시스템 Part3 - 컴포넌트 세부사항
개요
https://github.com/Reitbe/IVAN
- 인벤토리 컴포넌트의 내부 구조에 대해 설명한다.
- 인벤토리 위젯에서 장비를 변경했을 때, 인벤토리 컴포넌트와 장비 컴포넌트에서 어떤 동작이 수행되는지 살펴본다.
전체 구조
캐릭터는 생성자에서 스탯, 장비, 인벤토리 컴포넌트를 생성하고, 이렇게 생성된 컴포넌트들은 각각의 BeginPlay()에서 캐릭터의 인터페이스를 통해 다른 컴포넌트에 접근한다. 캐릭터는 다음의 인터페이스들을 구현한다.
IIIVInventoryComponentProvider
IIIVEquipInterface
IIIVCharacterComponentProvider
이 인터페이스들은 컴포넌트를 외부에 제공하는 순수가상함수를 포함한다. 컴포넌트는 BeginPlay()에서 GetOwner()를 통해 소유자에게 접근한 뒤, 해당 인터페이스가 구현되어 있는지를 확인하고 캐스팅하여 다른 컴포넌트에 접근한다.
인벤토리 컴포넌트
데이터 저장과 대리자 연결
// IVInventoryComponent.h
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnInventorySlotUpdated);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnQuickSlotUpdated);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnEquipSlotUpdated);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnWeaponSlotUpdated);
...
// 인벤토리 갱신 대리자
public:
void NotifySlotUpdated(EInventorySlotType SlotType);
UPROPERTY(BlueprintAssignable, Category = "Inventory")
FOnInventorySlotUpdated OnInventorySlotUpdated;
UPROPERTY(BlueprintAssignable, Category = "Inventory")
FOnQuickSlotUpdated OnQuickSlotUpdated;
UPROPERTY(BlueprintAssignable, Category = "Inventory")
FOnEquipSlotUpdated OnEquipSlotUpdated;
UPROPERTY(BlueprintAssignable, Category = "Inventory")
FOnWeaponSlotUpdated OnWeaponSlotUpdated;
private:
/* 대리자 목록*/
TArray<FMulticastScriptDelegate*> SlotDelegates;
// 인벤토리 저장 공간
public:
/* 슬롯 타입에 맞는 인벤토리 제공자 */
TArray<FItemBaseInfo>* GetSlotArray(EInventorySlotType SlotType);
/* 아이템 데이터베이스 */
UPROPERTY(EditAnyWhere, BlueprintReadWrite, Category = "Inventory")
TObjectPtr<UIVItemDatabase> ItemDatabase;
/* 유저 인벤토리 */
UPROPERTY(EditAnyWhere, BlueprintReadWrite, Category = "Inventory")
TArray<FItemBaseInfo> InventorySlots;
/* 퀵 슬롯 */
UPROPERTY(EditAnyWhere, BlueprintReadWrite, Category = "Inventory")
TArray<FItemBaseInfo> QuickSlots;
/* 장비 슬롯 */
UPROPERTY(EditAnyWhere, BlueprintReadWrite, Category = "Inventory")
TArray<FItemBaseInfo> EquipSlots;
/* 무기 슬롯 */
UPROPERTY(EditAnyWhere, BlueprintReadWrite, Category = "Inventory")
TArray<FItemBaseInfo> WeaponSlots;
private:
/* 슬롯타입-인벤토리 연결 */
TArray<TArray<FItemBaseInfo>*> SlotTypeToSlotArray;
인벤토리 컴포넌트 내부에는 위와 같이 네 종류의 슬롯 타입별로 대리자와 배열이 선언되어있다. 배열은 FItemBaseInfo
아이템 구조체를 기반으로 하며, 아이템이 없이 비어있는 슬롯들도 기본 구조체를 포함한다.
배열과 대리자는 일대일로 연결된다. 예를 들어 InventorySlots
가 변경되면 OnInventorySlotUpdated
가 호출되는 식이다.
여기서 주목할 부분은 이들과 별도로 존재하는 두 개의 배열이다. 이들은 아이템의 슬롯 타입에 따라서 대응되는 대리자나 아이템 배열에 접근할 수 있도록 설계했다.
TArray<FMulticastScriptDelegate*> SlotDelegates
TArray<TArray<FItemBaseInfo>*> SlotTypeToSlotArray
인벤토리 타입에 상관 없이 호출할 수 있는 함수
왜 저 두 배열을 만들었는가? 슬롯 타입별로 코드를 따로 분기하는 것이 비효율적이었기 때문이다. 예를 들어 인벤토리에서 기본적으로 사용되는 기능인 드래그 앤 드롭을 통한 아이템 위치 스왑을 생각해보자.
공용 슬롯에서 장비 슬롯으로 드래그 앤 드랍이 발생한다면 다음과 같은 동작이 진행된다. 공용 슬롯과 장비 슬롯에 접근하여 아이템 정보를 가져오고, 두 슬롯 사이에 정보를 교환하며, 각 슬롯에 변화가 있었음을 대리자를 통해 전달한다. 퀵 슬롯이나 다른 슬롯으로의 이동 또한 마찬가지이다.
이런 환경에서 각각의 From→To 조합별로 함수나 분기를 만드는 것은 너무 많은 경우의 수를 포함하고 유지보수를 어렵게하는 선택이다. 그래서 슬롯 타입만으로 적절한 배열이나 대리자에 접근할 수 있도록 위의 두 배열을 만들었다.
// IVInventoryComponent.cpp
// 슬롯 타입에 따른 슬롯 배열 초기화
SlotTypeToSlotArray.Init(nullptr, static_cast<int32>(EInventorySlotType::MAX));
SlotTypeToSlotArray[static_cast<int32>(EInventorySlotType::None)] = nullptr;
SlotTypeToSlotArray[static_cast<int32>(EInventorySlotType::QuickSlot)] = &QuickSlots;
SlotTypeToSlotArray[static_cast<int32>(EInventorySlotType::EquipSlot)] = &EquipSlots;
SlotTypeToSlotArray[static_cast<int32>(EInventorySlotType::WeaponSlot)] = &WeaponSlots;
SlotTypeToSlotArray[static_cast<int32>(EInventorySlotType::InventorySlot)] = &InventorySlots;
// 인벤토리 갱신 대리자 초기화
SlotDelegates.Init(nullptr, static_cast<int32>(EInventorySlotType::MAX));
SlotDelegates[static_cast<int32>(EInventorySlotType::None)] = nullptr;
SlotDelegates[static_cast<int32>(EInventorySlotType::QuickSlot)] = &OnQuickSlotUpdated;
SlotDelegates[static_cast<int32>(EInventorySlotType::EquipSlot)] = &OnEquipSlotUpdated;
SlotDelegates[static_cast<int32>(EInventorySlotType::WeaponSlot)] = &OnWeaponSlotUpdated;
SlotDelegates[static_cast<int32>(EInventorySlotType::InventorySlot)] = &OnInventorySlotUpdated;
EInventorySlotType
은 uint8기반 Enum이기 때문에 배열의 인덱스로 사용할 수 있다. 배열의 각 요소는 아이템 배열이나 대리자의 주소와 연결된다.
// IVInventoryComponent.cpp
void UIVInventoryComponent::NotifySlotUpdated(EInventorySlotType SlotType)
{
int32 Index = static_cast<int32>(SlotType);
if (Index >= 0 && Index < SlotDelegates.Num() && SlotDelegates[Index])
{
// 대리자 호출
SlotDelegates[Index]->ProcessMulticastDelegate<UObject>(nullptr);
}
}
TArray<FItemBaseInfo>* UIVInventoryComponent::GetSlotArray(EInventorySlotType SlotType)
{
int32 Index = static_cast<int32>(SlotType);
if (Index >= 0 && Index < SlotTypeToSlotArray.Num())
{
return SlotTypeToSlotArray[Index];
}
return nullptr;
}
또한, 슬롯 타입을 인자로 넘겨서 해당 배열이나 대리자를 반환하는 함수를 만들어 두었다. 다른 코드에서는 배열에 직접 접근할 필요 없이 이 함수만 호출하면 된다.
/**
* Executes a multi-cast delegate by calling all functions on objects bound to the delegate. Always
* safe to call, even if when no objects are bound, or if objects have expired. In general, you should
* never call this function directly. Instead, call Broadcast() on a derived class.
*
* @param Params Parameter structure
*/
template <class UObjectTemplate>
void ProcessMulticastDelegate(void* Parameters) const
특이한 점은 대리자를 배열에 넣어두고 호출했다는 것이다. 보통 대리자.Broadcast()
방식을 사용하지만, 이렇게 배열에 넣어둔 상태에서는 이 방법을 사용할 수 없었다. 방법을 찾다보니 대리자도 결국 특정 클래스를 상속받고, 연결된 함수들을 호출하는 내부 구조를 가진다는 것을 알 수 있었다. 그 결과, 위와 같은 호출 방식을 사용하여 대리자를 호출했다.
위젯에서 인벤토리 컴포넌트로 입력 전달
인벤토리 위젯에서 사용자가 할 수 있는 일은 드래그 앤 드롭과 더블클릭이다. 이 둘은 겹치는 부분이 있으면서도 서로 다른 동작을 수행한다.
- 드래그 앤 드롭
- 시작 슬롯과 끝 슬롯을 알고있기 때문에 인벤토리 컴포넌트에 From-To를 바로 전달할 수 있다.
- 동일 슬롯 간 드래그 앤 드롭은 단순 위치 변경이지만, 타 슬롯간 드래그 앤 드롭은 불가능하거나 아이템의 사용으로 이어진다.
- 공용 슬롯↔퀵 슬롯은 아이템 사용이 아닌 퀵슬롯 지정(교환 동작 포함)이다.
- 더블클릭
- 오직 시작 슬롯만이 존재한다.
- 소모성 아이템은 그 자리에서 사용하고 끝이지만, 방어구와 무기 아이템의 경우에는 장착하기 위한 끝 슬롯이 필요하므로 이를 지정하는 과정이 필요하다.
- 방어구와 무기 아이템은 장착 시 교환 동작이 포함된다.
그렇기에 아래와 같이 동작을 구분했다. 우선 드래그 앤 드롭의 경우 동일 슬롯 타입에서의 이동은 바로 스왑 처리를 진행하지만, 다른 슬롯 타입 간의 교환은 타입을 먼저 확인하고 스왑 혹은 장비 착용을 실시한다.
// IVInventoryComponent.cpp
bool UIVInventoryComponent::DragDropItem(EInventorySlotType FromSlotType, int32 FromSlotIndex, EInventorySlotType ToSlotType, int32 ToSlotIndex)
{
TArray<FItemBaseInfo>* FromSlotArray = IsValidSlot(FromSlotType, FromSlotIndex);
TArray<FItemBaseInfo>* ToSlotArray = IsValidSlot(ToSlotType, ToSlotIndex);
// 슬롯 유효성 검사
if (!FromSlotArray || !ToSlotArray)
{
return false;
}
// 동일 슬롯 타입에서의 이동은 바로 스왑 처리
if (FromSlotType == ToSlotType)
{
return SwapInventorySlot(FromSlotType, FromSlotIndex, ToSlotType, ToSlotIndex);
}
// 서로 다른 슬롯 타입간 스왑이 가능한 경우
if (CanSwapSlot(FromSlotType, FromSlotIndex, ToSlotType, ToSlotIndex))
{
// 일반 인벤토리 슬롯과 퀵 슬롯간 이동은 스왑 처리
if (FromSlotType == EInventorySlotType::QuickSlot || ToSlotType == EInventorySlotType::QuickSlot)
{
return SwapInventorySlot(FromSlotType, FromSlotIndex, ToSlotType, ToSlotIndex);
}
// 일반 인벤토리 슬롯과 장비 슬롯간 이동은 장비 처리. anrl 무기 슬롯도 동일하다.
else if (FromSlotType == EInventorySlotType::EquipSlot || ToSlotType == EInventorySlotType::EquipSlot)
{
return EquipItem(FromSlotType, FromSlotIndex, ToSlotType, ToSlotIndex);
}
else if (FromSlotType == EInventorySlotType::WeaponSlot || ToSlotType == EInventorySlotType::WeaponSlot)
{
return EquipItem(FromSlotType, FromSlotIndex, ToSlotType, ToSlotIndex);
}
else
{
return false;
}
}
else
{
return false;
}
}
더블클릭의 경우 소비아이템은 곧바로 아이템 사용 함수를 호출하는 반면, 방어구와 무기 아이템은 스왑할 슬롯을 탐색한 이후에 장착을 진행한다.
// IVInventoryComponent.cpp
bool UIVInventoryComponent::UseItemFromSlot(EInventorySlotType SlotType, const int32 SlotIndex)
{
// 사용할 슬롯 유효성 검사
TArray<FItemBaseInfo>* SlotArray = IsValidSlot(SlotType, SlotIndex);
if(!SlotArray)
{
return false;
}
// 사용할 아이템 정보 유효성 검사
FItemBaseInfo ItemBaseInfo = (*SlotArray)[SlotIndex];
if (ItemBaseInfo.ItemID.IsNone())
{
return false;
}
EItemType ItemType = ItemBaseInfo.ItemType; // 아이템 타입에 따른 동작 분리
// 소비 아이템인 경우 - 스탯 적용 & 인벤토리에서 삭제
if (ItemType == EItemType::Consumable)
{
return ConsumeItem(SlotType, SlotIndex);
}
// 무기 및 장비아이템인 경우 - 스왑 & 기존 장비 해제 & 새 장비 등록
else if (ItemType == EItemType::Armor || ItemType == EItemType::Weapon)
{
// 목적 배열에 빈 슬롯이 있는지 확인, 빈 슬롯이 없다면 첫번째 슬롯으로 대체
TArray<FItemBaseInfo>* DestSlotArray = (ItemType == EItemType::Armor) ? &EquipSlots : &WeaponSlots;
int32 DestSlotIndex = 0;
for (int32 SlotIdx = 0; SlotIdx < DestSlotArray->Num(); SlotIdx++)
{
if ((*DestSlotArray)[SlotIdx].ItemID.IsNone()) // 비어있는 슬롯 번호 구하기
{
DestSlotIndex = SlotIdx;
break;
}
}
// 장비 장착
EInventorySlotType DestSlotType = (ItemType == EItemType::Armor) ? EInventorySlotType::EquipSlot : EInventorySlotType::WeaponSlot;
return EquipItem(SlotType, SlotIndex, DestSlotType, DestSlotIndex);
}
else
{
return false;
}
}
스왑 동작
드래그 앤 드롭이나 장착 동작의 대부분은 스왑 동작을 포함한다. 이 스왑 동작은 단순하게 보면 A 배열의 a번째 아이템과 B배열의 b번째 아이템을 교환하는 작업이라 할 수 있다. 세부 과정은 아래와 같다.
// IVInventoryComponent.cpp
bool UIVInventoryComponent::SwapInventorySlot(EInventorySlotType FromSlotType, int32 FromSlotIndex, EInventorySlotType ToSlotType, int32 ToSlotIndex)
{
TArray<FItemBaseInfo>* FromSlotArray = IsValidSlot(FromSlotType, FromSlotIndex);
TArray<FItemBaseInfo>* ToSlotArray = IsValidSlot(ToSlotType, ToSlotIndex);
// 슬롯 유효성 검사
if(!FromSlotArray || !ToSlotArray)
{
return false;
}
// 스왑할 슬롯과 아이템 타입 호환 검사
if (!CanSwapSlot(FromSlotType, FromSlotIndex, ToSlotType, ToSlotIndex))
{
return false;
}
// 슬롯간 교환 처리
FItemBaseInfo TempItem = (*FromSlotArray)[FromSlotIndex];
(*FromSlotArray)[FromSlotIndex] = (*ToSlotArray)[ToSlotIndex];
(*ToSlotArray)[ToSlotIndex] = TempItem;
// 슬롯에 맞는 인벤토리 갱신
if (FromSlotType != ToSlotType)
{
NotifySlotUpdated(FromSlotType);
NotifySlotUpdated(ToSlotType);
}
else
{
NotifySlotUpdated(FromSlotType);
}
return false;
}
먼저, 인자로 From-To 슬롯의 타입과 인덱스를 받아들인다. 이후 슬롯 타입과 인덱스가 유효한지, 스왑이 가능한지 여부를 확인한다. 스왑이 가능하다는 것은 From→To, To→From으로의 이동이 가능하다는 것을 의미한다. 빈 슬롯의 경우에도 기본 아이템 구조체 정보를 가지기 때문에 스왑이 가능하다.
// IVInventoryComponent.cpp
bool UIVInventoryComponent::CanSwapSlot(EInventorySlotType FromSlotType, int32 FromSlotIndex, EInventorySlotType ToSlotType, int32 ToSlotIndex)
{
/* 슬롯과 아이템 정보는 이미 검사된 상태 */
bool CanAtoB = false; // From -> To 아이템 이동 가능 여부
bool CanBtoA = false; // To -> From 아이템 이동 가능 여부
// From의 아이템을 To 슬롯으로 넘길 수 있는가? (A->B)
EItemType FromItemType = (*GetSlotArray(FromSlotType))[FromSlotIndex].ItemType;
if (FromItemType == EItemType::None) CanAtoB = true; // FromItemType이 None인 경우는 어느 슬롯이든 통과
if (ToSlotType == EInventorySlotType::InventorySlot) CanAtoB = true; // 인벤토리 슬롯으로는 모든 아이템 이동 가능
if (ToSlotType == EInventorySlotType::WeaponSlot && FromItemType == EItemType::Weapon) CanAtoB = true;
if (ToSlotType == EInventorySlotType::EquipSlot && FromItemType == EItemType::Armor) CanAtoB = true;
if (ToSlotType == EInventorySlotType::QuickSlot && FromItemType == EItemType::Consumable) CanAtoB = true;
// To의 아이템을 From 슬롯으로 넘길 수 있는가? (B->A)
EItemType ToItemType = (*GetSlotArray(ToSlotType))[ToSlotIndex].ItemType;
if (ToItemType == EItemType::None) CanBtoA = true; // ToItemType이 None인 경우는 어느 슬롯이든 통과
if (FromSlotType == EInventorySlotType::InventorySlot) CanBtoA = true; // 인벤토리 슬롯으로는 모든 아이템 이동 가능
if (FromSlotType == EInventorySlotType::WeaponSlot && ToItemType == EItemType::Weapon) CanBtoA = true;
if (FromSlotType == EInventorySlotType::EquipSlot && ToItemType == EItemType::Armor) CanBtoA = true;
if (FromSlotType == EInventorySlotType::QuickSlot && ToItemType == EItemType::Consumable) CanBtoA = true;
return (CanAtoB && CanBtoA); // 양방향으로 이동 가능해야 스왑 가능
}
스왑을 완료했다면, 두 슬롯 혹은 하나의 슬롯이 업데이트 되었음을 대리자를 통해 전달한다. 여기서도 슬롯 타입 기반 접근 방식이 사용된다.
아이템의 사용
아이템 사용은 크게 소모성 아이템과 장비 아이템(무기 & 방어구)로 나뉜다. 양쪽 모두 캐릭터 스탯 컴포넌트의 함수를 호출하여 아이템 효과를 적용하는 것은 동일하지만, 소모성 아이템은 인벤토리에서 바로 해당 아이템을 제거하는 반면 장비 아이템은 스왑 및 장비 컴포넌트와의 연결을 진행한다는 점이 다르다.
// IVInventoryComponent.cpp
bool UIVInventoryComponent::ConsumeItem(EInventorySlotType SlotType, const int32 SlotIndex)
{
/* 슬롯과 아이템 정보는 이미 검사된 상태 */
if (CharacterStatComponent)
{
FItemBaseInfo ItemBaseInfo = (*GetSlotArray(SlotType))[SlotIndex]; // 사용할 아이템의 스탯 정보 획득
CharacterStatComponent->UseConsumableItem(ItemBaseInfo.ItemStat, ItemBaseInfo.ItemDamageStat); // 아이템 스탯 적용
if (RemoveItemFromInventoryByIndex(SlotType, SlotIndex)) // 인벤토리에서 아이템 제거
{
NotifySlotUpdated(SlotType); // 성공시 인벤토리 갱신
}
return true;
}
else
{
return false;
}
}
소모성 아이템을 보자. 캐릭터의 인터페이스를 통해 캐릭터 스탯 컴포넌트에 접근하고, 사용할 아이템 배열의 인덱스에 접근하여 아이템을 사용하는 모습이다. 사용한 아이템은 인벤토리에서 제거하며 스택이 적용된 아이템이라면 갯수를 차감하고, 개별 아이템이거나 수량이 1개인 아이템이라면 삭제를 진행한다.
// IVInventoryComponent.cpp
bool UIVInventoryComponent::EquipItem(EInventorySlotType FromSlotType, int32 FromSlotIndex, EInventorySlotType ToSlotType, int32 ToSlotIndex)
{
/* 슬롯과 아이템 정보는 이미 검사된 상태 */
// 스왑을 먼저 처리한다. 스왑이 성공한다면 스탯 작업을 처리한다.
if (SwapInventorySlot(FromSlotType, FromSlotIndex, ToSlotType, ToSlotIndex))
{
if (CharacterStatComponent)
{
FItemBaseInfo EquipItemInfo = (*GetSlotArray(ToSlotType))[ToSlotIndex]; // 장착한 아이템의 스탯 획득
CharacterStatComponent->EquipItem(EquipItemInfo.ItemStat, EquipItemInfo.ItemDamageStat); // 아이템 스탯 적용
FItemBaseInfo UnequipItemInfo = (*GetSlotArray(FromSlotType))[FromSlotIndex]; // 해제한 아이템의 스탯 획득
CharacterStatComponent->UnequipItem(UnequipItemInfo.ItemStat, UnequipItemInfo.ItemDamageStat); // 아이템 스탯 해제
}
return true; // 인벤토리 갱신은 SwapInventorySlot에서 처리함.
}
else
{
return false;
}
}
장비 아이템의 경우에는 이야기가 다르다. 우선 스왑을 먼저 처리하고, 스왑이 가능한 경우에만 새 아이템의 스탯 적용 및 기존 아이템의 스탯 해제를 적용한다. 단순 스탯 적용이 아니라 메쉬 적용 및 아이템 장착 과정은 장비 컴포넌트에서 진행된다. SwapInventorySlot의 마지막 부분에서 대리자를 호출하는데, 이 호출에 장비컴포넌트가 바인딩 되어있기에 장비 갱신을 전달받을 수 있다. 세부과정은 아래에서 작성한다.
장비 컴포넌트
방어구와 무기의 차이점
처음에는 ‘장비 슬롯’ 하나로 퉁쳤었다. 그런데 인벤토리 시스템과 아이템 구조가 점점 명확해지면서 몇 가지 문제점이 드러났다.
-
갯수 제한이 다르다
방어구는 슬롯과 캐릭터의 장비용 스켈레탈 메쉬 컴포넌트 갯수가 허용되는 한 여러 장비를 장착할 수 있다. 그러나 무기는 오로지 단 하나만 장착 가능하다. 무기 설계 자체를 그렇게 해뒀다.
무기를 장착할 때마다 장비슬롯 전체를 순회하여 기존 무기와 교환하는 방법도 가능하겠지만, 아래의 문제 때문에 결국 분리했다.
-
사용되는 메쉬 종류가 다르다.
일반 아이템은 스태틱 메쉬만을 사용한다. 아이템을 스폰할 때 이 스태틱 메쉬를 액터에 적용하여 사용한다. 무기 아이템도 동일하다. 무기마다 지정된 스태틱 메쉬가 있고, 이를 적용한다. 그러나 방어구는 이야기가 다르다. 레벨에 스폰할 때는 스태틱 메쉬를 사용하지만, 캐릭터가 장착하기 위해서는 스켈레탈 메쉬가 필요하다. 특히 모듈형 의상 시스템을 사용중이기에 이를 스테틱으로 변경할 수도 없었다.
위와 같은 문제가 발생했기에 무기와 방어구를 다른 슬롯, 다른 배열, 다른 사용 함수로 구분했다.
그렇다면 아이템의 장착은 어떻게 되는걸까? 앞서 인벤토리 컴포넌트에서는 무기와 방어구의 구분 없이 모두 EquipItem
을 사용하여 스왑을 진행하고 스탯을 적용했다. 하지만 스왑 과정의 마지막에 호출되는 대리자가 서로 다르다. 슬롯 타입에 기반하여 각각 OnEquipSlotUpdated
, OnWeaponSlotUpdated
가 호출된다.
// IVEquipComponent.cpp
void UIVEquipComponent::BeginPlay()
{
Super::BeginPlay();
// 플레이어 인벤토리 컴포넌트 연결
AActor* Owner = GetOwner();
if (Owner && Owner->Implements<UIIVInventoryComponentProvider>())
{
IIIVInventoryComponentProvider* InventoryProvider= Cast<IIIVInventoryComponentProvider>(Owner);
if (InventoryProvider)
{
UIVInventoryComponent* InventoryComponent = InventoryProvider->GetInventoryComponent();
if (InventoryComponent) // 장비 목록 주소 및 갱신 바인딩
{
InventoryComponent->OnEquipSlotUpdated.AddDynamic(this, &UIVEquipComponent::EquipArmors);
InventoryComponent->OnWeaponSlotUpdated.AddDynamic(this, &UIVEquipComponent::EquipWeapon);
}
}
}
// 인벤토리가 없더라도 장비컴포넌트에 기본 무기가 지정되어있다면 장착
if (WeaponClass)
{
EquipDefaultWeapon();
}
}
장비 컴포넌트는 BeginPlay에서 이들 대리자와 무기, 방어구 장착 함수를 바인딩하여 개별적으로 호출되도록 하였다.
캐릭터와 방어구 장착
캐릭터의 방어구 장착은 결국 스켈레탈 메쉬의 갱신과 같다. 장비의 스탯 부분은 인벤토리 컴포넌트에서 스탯 컴포넌트에 전달하여 적용했고, 장비 컴포넌트는 인벤토리 컴포넌트에서 가져온 장비 목록과 캐릭터에서 가져온 스켈레탈 메쉬 목록을 매칭하여 갱신해주는 과정을 담당하기 때문이다. 각 부분을 더 살펴보자.
플레이어와 몬스터 모두가 공유하는 CharacterBase
에는 장비 장착을 위한 장비 컴포넌트, 스켈레탈 메쉬들과 이를 관리하기 위한 배열이 선언되어있다.
// IVCharacterBase.h
// 장비 관련(모듈형 의상)
public:
/* 캐릭터 장비 관리 컴포넌트 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Equip")
TObjectPtr<UIVEquipComponent> EquipComponent;
/* 장비 메쉬 관리용 배열*/
TArray<USkeletalMeshComponent*> EquipMeshes;
protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ModularSkin", Meta = (AllowPrivateAccess = "true"))
TObjectPtr<USkeletalMeshComponent> HelmMesh;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ModularSkin", Meta = (AllowPrivateAccess = "true"))
TObjectPtr<USkeletalMeshComponent> LegsMesh;
...
이 배열은 캐릭터가 구현한 IIIVEquipInterface
의 virtual TArray<USkeletalMeshComponent*>& GetEquipMeshArray() = 0;
에서 전달된다.
내가 원하는 것은 내용 복사가 아닌 배열의 주소 전달이다. TObjectPtr<TArray<USkeletalMeshComponent*>>
을 시도했으나 TObjectPtr은 UObject에만 사용 가능했기에 실패했다. 장비 컴포넌트에 포인터를 선언해두고 캐릭터에서 TArray<USkeletalMeshComponent*>* EquipMeshes
를 제공하는 방법도 생각해봤으나, 필요할 때 참조하여 사용하는 것이 더 안전하다고 판단하여 위와 같은 형태를 띄게 되었다.
// IVEquipComponent.cpp
void UIVEquipComponent::EquipArmors()
{
AActor* Owner = GetOwner();
if (Owner)
{
// 인벤토리 컴포넌트 접근
UIVInventoryComponent* InventoryComponent = nullptr;
if (Owner && Owner->Implements<UIIVInventoryComponentProvider>())
{
IIIVInventoryComponentProvider* InventoryProvider = Cast<IIIVInventoryComponentProvider>(Owner);
if (InventoryProvider)
{
InventoryComponent = InventoryProvider->GetInventoryComponent();
}
}
// 캐릭터에서 스켈레탈 메쉬
IIIVEquipInterface* MeshProvider = nullptr;
if (Owner && Owner->Implements<UIIVEquipInterface>())
{
MeshProvider = Cast<IIIVEquipInterface>(Owner);
}
if (InventoryComponent && MeshProvider)
{
TArray<FItemBaseInfo>& EquipList = InventoryComponent->EquipSlots;
TArray<USkeletalMeshComponent*>& EquipMeshes = MeshProvider->GetEquipMeshArray();
// 장비 메쉬 목록을 초기화한다.
for (USkeletalMeshComponent* Mesh : EquipMeshes)
{
Mesh->SetSkeletalMeshAsset(nullptr);
}
// 장비 목록을 순회하며 장비 메쉬에 부착한다.
for (int32 i = 0; i < EquipList.Num(); i++)
{
// 장비 메쉬가 존재하고, 장비 목록에 아이템이 존재한다면
if (EquipMeshes.IsValidIndex(i) && EquipList.IsValidIndex(i))
{
// 장비 메쉬에 아이템을 부착한다.
USkeletalMesh* Mesh = EquipList[i].ItemSkeletalMesh;
if (Mesh)
{
EquipMeshes[i]->SetSkeletalMeshAsset(Mesh);
}
}
}
}
}
}
방어구의 장착은 다음과 같이 진행된다. 우선 캐릭터가 구현하는 IIIVInventoryComponentProvider
, IIIVEquipInterface
를 통해 인벤토리와 캐릭터 장비 메쉬 목록에 접근한다. 둘 모두 참조 형태이다. 이후 기존에 장착한 장비 메쉬들을 모두 초기화하고, 인벤토리에 장착된 각 장비의 메쉬 정보를 가져와 스켈레탈 메쉬 컴포넌트에 지정한다.
캐릭터와 무기 장착
무기 장착은 방어구와 다르게 액터 스폰 및 소켓 부착 과정이 필요하다. 그리고 연관된 다른 컴포넌트들 또한 있기에 약간의 과정이 더 추가된다.
우선적으로 판단해야 하는 것은 캐릭터의 최초 무기 지정 여부이다. 몬스터의 경우, 캐릭터 블루프린트에서 장비 컴포넌트에 별도의 무기를 지정해둔다. 몬스터가 생성될 때 무기 또한 함께 생성 및 장착을 하겠다는 의미이다.
// IVEquipComponent.cpp
void UIVEquipComponent::EquipDefaultWeapon()
{
// 기본으로 지정된 무기 클래스로부터 무기 스폰
if (WeaponClass)
{
IIIVEquipInterface* EquipOwner = Cast<IIIVEquipInterface>(GetOwner());
if (EquipOwner)
{
WeaponInstance = GetWorld()->SpawnActor<AIVWeapon>(WeaponClass, FVector().ZeroVector, FRotator().ZeroRotator);
WeaponInstance->SetActorLocation(GetOwner()->GetActorLocation()+FVector(0.0f, 0.0f, 100.0f));
WeaponInstance->ApplyEquipSettings();
if (WeaponInstance)
{
FName EquipSocketName = WeaponInstance->ItemInfo.EquipSocket;
EquipOwner->EquipByInstance(WeaponInstance, EquipSocketName);
}
}
}
}
이런 경우에는 인벤토리에 접근하는 별도의 과정없이 바로 무기 액터 스폰 후 장착 과정을 거친다. 캐릭터에 무기를 장착하는 것도 IIIVEquipInterface
를 사용한다. 캐릭터가 장착하고자 하는 무기의 주소와 소켓을 전달받으면, 소켓과 무기가 존재하는지 확인하고 부착을 진행한다.
// IVPlayerCharacter.cpp
void AIVPlayerCharacter::EquipByInstance(TObjectPtr<AIVWeapon> Weapon, FName EquipSocket) const
{
// 소켓 유무 확인 및 소켓에 무기 장착
if (Weapon)
{
if (!GetMesh()->DoesSocketExist(EquipSocket)) // 보통 "hand_rSocket"에 장착한다
{
return;
}
if (Weapon->AttachToComponent(GetMesh(), FAttachmentTransformRules::SnapToTargetIncludingScale, EquipSocket))
{
if (AttackComponent)
{
AttackComponent->SetWeapon(Weapon);
AttackComponent->ProvideOwnerAttackRanges(AttackRanges); // 캐릭터가 보유한 공격 범위 제공
Weapon->SetInteractable(false);
}
}
}
}
캐릭터의 메쉬 컴포넌트에 접근해야하기에 이곳에서 진행하는 것이다. 장착에 성공했다면 공격 컴포넌트에 무기를 지정하는 것으로 장비 과정이 완료된다. 참고로 AttachToComponent
를 하기 위해서는 물리 시뮬레이션의 비활성화가 선행되어야한다.
// IVEquipComponent.cpp
void UIVEquipComponent::EquipWeapon()
{
// 인벤토리 컴포넌트에 접근하여 무기 가져오기
AActor* Owner = GetOwner();
if (Owner)
{
// 오너의 인벤토리 컴포넌트에 접근
UIVInventoryComponent* InventoryComponent = nullptr;
if (Owner && Owner->Implements<UIIVInventoryComponentProvider>())
{
IIIVInventoryComponentProvider* InventoryProvider = Cast<IIIVInventoryComponentProvider>(Owner);
if (InventoryProvider)
{
InventoryComponent = InventoryProvider->GetInventoryComponent();
}
}
// 슬롯에 장착된 무기 클래스를 가져온다.
FName EquipSocketName = TEXT("WeaponSocket");
if (InventoryComponent && InventoryComponent->ItemDatabase)
{
TArray<FItemBaseInfo>& WeaponList = InventoryComponent->WeaponSlots;
if (WeaponList.Num() <= 0) return; // 혹시나 초기화 안되어있으면
FName WeaponID = WeaponList[0].ItemID;
EquipSocketName = WeaponList[0].EquipSocket;
if (WeaponID == NAME_None) // 빈 슬롯 -> 무기 해제
{
UnEquipWeapon();
return;
}
else if (!InventoryComponent->ItemDatabase->ItemActorMap.Contains(WeaponID)) // DB에 존재하지 않는 ID
{
return;
}
else
{
WeaponClass = TSubclassOf<AIVWeapon>(InventoryComponent->ItemDatabase->ItemActorMap[WeaponID]);
}
}
// 무기 클래스가 존재한다면 무기를 스폰
if (WeaponClass)
{
if (WeaponInstance) // 기존 무기 액터 제거
{
WeaponInstance->DetachFromActor(FDetachmentTransformRules::KeepWorldTransform);
WeaponInstance->Destroy();
WeaponInstance = nullptr;
}
WeaponInstance = GetWorld()->SpawnActor<AIVWeapon>(WeaponClass, FVector().ZeroVector, FRotator().ZeroRotator);
WeaponInstance->ApplyEquipSettings();
}
// 캐릭터에 무기를 부착한다.
if (WeaponInstance)
{
IIIVEquipInterface* EquipOwner = Cast<IIIVEquipInterface>(GetOwner());
if (EquipOwner)
{
EquipOwner->EquipByInstance(WeaponInstance, EquipSocketName);
}
}
}
}
그렇다면 일반적인 무기 장착은 어떨까? 인벤토리에 접근하고, 아이템 데이터베이스와 무기 목록에 접근하여 데이터를 가져오는 과정이 필요하다. 아이템 데이터베이스에 접근하는 이유는 무기 스폰을 위한 액터 블루프린트가 아이템 데이터베이스에 지정되어있기 때문이다. 그 이후의 과정은 위에서 설명했던 것과 동일하게 진행된다.
시연 영상
추가하고 싶은 점
- 방어구 슬롯도 타입별로 지정해 두는 편이 좋았을 것.
- 아이템 구조체의 세부 내용들을 표시하는 UI도 추가하면 좋았을 것.