인벤토리 시스템 Part2 - UI
3계층으로 구성된 인벤토리 위젯과 드래그 앤 드롭 기능
개요
https://github.com/Reitbe/IVAN
- 인벤토리의 위젯 구조에 대해 설명한다.
- 드래그 앤 드롭으로 위치 조정이 가능한 창을 만든다.
- 드래그 앤 드롭으로 스왑 동작이 가능한 슬롯을 만든다.
인벤토리 위젯 구성
인벤토리 위젯은 여러 계층으로 구분되어 있으며, 각 계층은 서로 다른 기능을 담당한다. 특히 슬롯은 여러 곳에서 재사용되기 때문에 구조를 나누는 것이 중요했다. 아래는 가장 아래에 있는 위젯부터 순서대로 정리한 구조이다.
-
HUD
플레이어 컨트롤러에 부착되어 여러 위젯들을 관리하는 클래스이다. 캐릭터가 소유한 컴포넌트와 위젯을 바인딩하고, 데이터를 전달하는 역할을 한다. InventoryBase와 퀵 슬롯이 이곳에 부착된다.
-
퀵 슬롯
플레이어가 단축키를 눌러 사용할 수 있는 아이템을 표시하는 위젯. 인벤토리 위젯의 퀵 슬롯과 동일한 정보를 공유한다.
-
UIVInventoryBaseWidget
인벤토리 창이 위치할 수 있는 캔버스를 가진 베이스 위젯. HUD에 장착되며, 이 공간에 아이템을 드래그 앤 드롭 하면 인벤토리에서 아이템을 버리는 동작이 실행된다.
-
UIVInventoryWidget
플레이어가 보는 인벤토리 창. 상단 바를 드래그 하면 창의 위치를 조정할 수 있다.
-
UIVInventorySlotWidget
인벤토리 위젯에 부착되어 아이템의 아이콘과 수량을 표시하는 위젯. 드래그 앤 드랍을 통해 아이템을 옮기거나 더블클릭으로 아이템을 사용할 수 있다.
공용 / 방어구 / 무기 / 퀵슬롯 영역으로 구분되어있으며, 각 영역에 들어올 수 있는 아이템 타입이 정해져있다.
인벤토리 위젯은 캐릭터에 부착된 인벤토리 컴포넌트와 연동된다. 해당 컴포넌트는 실제 아이템 정보들을 저장한 아이템 구조체 배열을 가지고 있으며, 타입에 따라 4개의 배열로 나뉘어 있다. 인벤토리 위젯에서 발생한 입력(드래그 앤 드롭, 더블클릭)은 컴포넌트로 전달되어 구조체 배열을 수정하고, 이후 대리자를 통해 위젯에 변경을 알리는 방식으로 동작한다.
이동 가능한 창 만들기
인벤토리 베이스 위젯은 뷰포트 전체를 덮는 캔버스를 가지며, 인벤토리 위젯은 그 위에서 위치 조정이 가능해야한다.
처음에는 코드에서 드래그 앤 드롭 기능을 작업하고자 했다. 그러나 슬롯간 스왑 과정과 아이템 버리기에서도 같은 기능이 사용되기 때문에 블루프린트에서 작업하는 것을 선택했다. 관련 내용은 해당 튜토리얼을 참고했다.
언리얼에서 제공하는 DragDropOperation기능을 사용하지 않고, 마우스 누름&해제 처리를 통해 기능을 구현했다. 마우스를 눌렀을 때 인벤토리 위젯에서의 마우스 위치를 기억하고, 해제하기 전까지 일정 간격으로 마우스 움직임을 추적하여 위젯의 위치를 갱신하는 방식이다.
위젯의 위치를 계산할 때는 좌표계에 주의해야한다. 아래의 3가지 좌표계가 있다.
- 로컬 좌표 → 위젯이 속한 부모의 원점을 기준으로 한 좌표.
- 뷰포트 좌표 → 게임이 출력되는 뷰포트에서의 좌표.
- 스크린 좌표 → 디스플레이 상의 픽셀 좌표. 뷰포트를 넘어서 모니터 전체를 사용하는 좌표. 함수에서 AbsolutePosition은 이 스크린 좌표를 의미한다.
마우스의 위치를 얻을 때, 어떠한 좌표계에서의 위치인지를 먼저 확인하고 사용처에 알맞게 변환하는 과정이 필요하다. 다행히 이 위젯에서는 뷰포트의 크기와 캔버스의 크기가 같기 때문에 단순 계산이 가능하다. 마우스를 눌렀을 때 뷰포트 기준으로 마우스 위치를 구하고, 캔버스 기준으로 위젯 위치를 구하지만 연산에 문제가 없는 것은 이런 이유에서이다. 드래그 중일 때도 뷰포트를 기준으로 마우스를 구하고, 캔버스를 기준으로 SetPosition을 해도 멀쩡한이유도 이와 같다.
좌표 계산을 잘못하면 위젯이 갑자기 이상한 위치로 튄다던가, 드래그하고 움직이는 거리보다 훨씬 더 많이 움직이는 문제가 발생할 수 있다. 위치 업데이트를 잘못하면 빠르게 두 위치를 번갈아 움직이며 깜빡거리는 플리커링 현상이 발생할 수도 있다. 그렇다…
아이템 슬롯 드래그
인벤토리에서 아이템을 드래그하여 위치를 바꾸거나 버리는 기능은 아주 필수적이다. 이를 위해서는 다음과 같은 요소들을 고려해야 한다.
- 드래그 출발지와 목적지는 어디인가?
- 드래그 시 어떠한 정보를 옮겨야 하는가?
- 슬롯은 어떠한 데이터를 가져야하는가?
우선 출발지와 목적지를 생각해보자. 출발지는 특정 슬롯이지만 목적지는 두 곳이다. 하나는 인벤토리 위젯 내부의 다른 슬롯(스왑)이고, 다른 하나는 인벤토리 위젯 바깥의 캔버스 영역(버리기)이다.
// IVInventoryBaseWidget.cpp
bool UIVInventoryBaseWidget::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation)
{
Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
UIVDragDropOperation* DragDropOperation = Cast<UIVDragDropOperation>(InOperation);
if (DragDropOperation && InventoryComponent)
{
InventoryComponent->DropItemFromInventoryToLevel(DragDropOperation->InventorySlotType, DragDropOperation->SlotIndex);
return true;
}
return false;
}
// IVInventorySlotWidget.cpp
bool UIVInventorySlotWidget::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation)
{
Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
UIVDragDropOperation* DragDropOperation = Cast<UIVDragDropOperation>(InOperation);
if (DragDropOperation)
{
InventoryComponent->DragDropItem(DragDropOperation->InventorySlotType, DragDropOperation->SlotIndex, InventorySlotType, SlotIndex);
return true;
}
return false;
}
이를 위해서는 슬롯과 인벤토리 베이스 양쪽 모두 NativeOnDrop()
함수를 오버라이드 해야하며, 마우스가 놓인 위치의 위젯에서 해당 함수를 호출한다.
잠시 생각해보면 베이스→위젯→ 슬롯 순서로 놓여있는데, 슬롯과 베이스에서 모두 NativeOnDrop이 호출되지 않을까 걱정할 수 있다. 괜찮다! 언리얼은 가장 상단의 위젯부터 HitTest와 이벤트 처리를 진행한다. 만약 이벤트가 처리되지 않는다면 자식에서 부모 위젯으로 이벤트가 순서대로 전달되는데, 이를 버블링이라고 한다.
다음으로 생각할 것은 드래그 시 옮길 정보이다. 인벤토리 슬롯 위젯은 그 자체로 아이템 구조체를 가지고 있지 않으며, 어떤 타입 아이템 배열의 몇 번째 항목을 나타내는지 정보를 가진다. 이를 바탕으로 아이템 아이콘과 수량 텍스트를 업데이트 할 뿐이다.
// UIVDragDropOperation
UCLASS()
class IVAN_API UIVDragDropOperation : public UDragDropOperation
{
GENERATED_BODY()
public:
/* 아이템 슬롯 타입 */
EInventorySlotType InventorySlotType;
/* 슬롯 인덱스 */
int32 SlotIndex;
/* 드래그 오프셋 */
FVector2D DragOffset;
};
이 정보들이 DragDropOperation에 전달되어야한다. 드래그 앤 드롭과 관련된 함수들을 보면 인자로 UDragDropOperation
을 전달받는 것을 알 수 있다. 기본적으로 Tag, DefaultDragVisual 등을 지정할 수 있지만 위와같이 인벤토리 슬롯타입, 슬롯 인덱스 등을 전달하기 위해서 이를 상속받은 커스텀 동작을 만들어 사용했다.
// UIVInventorySlotWidget.h
virtual void NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation) override;
virtual bool NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) override;
// UIVInventorySlotWidget.cpp
void UIVInventorySlotWidget::NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation)
{
Super::NativeOnDragDetected(InGeometry, InMouseEvent, OutOperation);
// 드래그 동작
UIVDragDropOperation* DragDropOperation = Cast<UIVDragDropOperation>(UWidgetBlueprintLibrary::CreateDragDropOperation(UIVDragDropOperation::StaticClass()));
if (DragDropOperation)
{
DragDropOperation->DragOffset = InGeometry.AbsoluteToLocal(InMouseEvent.GetScreenSpacePosition());
DragDropOperation->InventorySlotType = InventorySlotType;
DragDropOperation->SlotIndex = SlotIndex;
DragDropOperation->Pivot = EDragPivot::MouseDown;
// 드래그용 위젯 생성
if (DragWidgetClass)
{
UIVInventoryDragWidget* DragWidget = CreateWidget<UIVInventoryDragWidget>(GetWorld(), DragWidgetClass);
if (DragWidget)
{
DragWidget->SetDragIcon(ItemIconTexture);
DragDropOperation->DefaultDragVisual = DragWidget;
}
}
OutOperation = DragDropOperation;
}
}
이처럼 마우스를 눌렀을 때 저장된 드래그 정보들은 드랍하는 위치의 위젯에게 전달된다. 드래그 중 마우스를 따라오는 위젯은 DefaultDragVisual 로 지정 가능하다.
퀵슬롯
퀵슬롯은 아이템 타입 중 소모품만이 들어갈 수 있는 슬롯이다. HUD에 별도로 붙어있다는 것과 키보드 입력을 통해 아이템을 사용할 수 있다는 것 등을 제외하면 일반 슬롯과 유사하다.
가장 큰 차이점은 슬롯 인덱스 처리 방식이다. 인벤토리 위젯에 배치된 슬롯들은 타입별 구역에 부착되어있는데, 그 순서에 상관없이 구역의 모든 하위 요소를 순회하며 슬롯만을 골라내는 방식을 사용한다. 그렇기에 플레이어가 보는 슬롯 번호와 실제 슬롯 번호가 다를 수 있다.
// IVInventoryWidget.cpp
void UIVInventoryWidget::NativeConstruct()
{
...
if (InventoryGridPanel)
{
InventorySlots.Empty();
for (UWidget* Widget : InventoryGridPanel->GetAllChildren())
{
if (UIVInventorySlotWidget* SlotWidget = Cast<UIVInventorySlotWidget>(Widget))
{
InventorySlots.Add(SlotWidget);
SlotWidget->SetSlotInfo(EInventorySlotType::InventorySlot, InventorySlots.Num() - 1);
if (DragWidgetClass)
{
SlotWidget->SetDragWidgetClass(DragWidgetClass);
}
}
}
}
...
}
하지만 퀵슬롯은 사용하고자 하는 아이템 슬롯 번호가 확실하게 지정되어있다. 특정 클래스를 모두 가져오는 방식이 아닌 N번 슬롯의 이미지와 텍스트를 UPROPERTY(meta = (BindWidget))
를 사용하여 바인딩했고, 배열에 위치를 직접 지정하는 방식을 사용했다.
// IVQuickSlotWidget.cpp
void UIVQuickSlotWidget::NativeConstruct()
{
// 이미지 및 텍스트 목록 초기화
QuickSlotImages.Init(nullptr, QuickSlotSize);
QuickSlotTexts.Init(nullptr, QuickSlotSize);
// 이미지 및 텍스트 목록 채우기
QuickSlotImages[0] = FirstQuickSlotImage;
QuickSlotImages[1] = SecondQuickSlotImage;
QuickSlotImages[2] = ThirdQuickSlotImage;
QuickSlotImages[3] = FourthQuickSlotImage;
QuickSlotTexts[0] = FirstQuickSlotText;
QuickSlotTexts[1] = SecondQuickSlotText;
QuickSlotTexts[2] = ThirdQuickSlotText;
QuickSlotTexts[3] = FourthQuickSlotText;
}
이제 0번이 첫 슬롯, 3번이 마지막 슬롯과 연결되어있다는 것을 알고 동작할 수 있다.