본문으로 건너뛰기
뒤로가기

[Unity] Project Ellie: UGUI 바인딩과 인벤토리 슬롯 교환

TL;DRProject Ellie에서는 UI마다 반복되던 prefab 생성, child component 탐색과 pointer event 연결을 UIManager, UIBase, UIEventHandler로 공통화했습니다. 이 구조를 인벤토리에 적용해 슬롯 생성부터 드래그, 두 아이템의 위치 교환까지 같은 흐름으로 처리했습니다.

Table of contents

Open Table of contents

들어가며

Project Ellie에는 플레이어 상태 UI, 몬스터 체력, 팝업, 설정과 인벤토리처럼 형태가 다른 화면이 필요했습니다. 각 UI에서 prefab 경로를 찾고, child component를 연결하고, click·drag callback을 다시 작성하면 기능이 늘어날수록 초기화 코드도 반복됩니다.

이 반복을 줄이기 위해 PR #19에서 UIManager, UIBase, UIEventHandler를 포함한 UGUI 공통 구조를 추가했습니다. 이후 PR #32에서는 이 기반 위에 인벤토리의 카테고리, 아이템 슬롯, 장착 슬롯과 드래그 교환 기능을 구현했습니다.

이 글에서 다루는 내용:

사전 지식: Unity GameObject, prefab, UGUI와 EventSystem에 대한 기본 이해를 전제로 합니다.


1. UI 생성, 바인딩과 입력 처리를 나눴습니다

공통 UI 구조는 한 클래스가 모든 일을 처리하도록 만들지 않고 세 가지 역할로 나눴습니다.

UIManager
  -> Popup / Static / SubItem prefab 생성
  -> UIBase에서 child component 바인딩
  -> UIEventHandler에서 pointer event 전달
  -> Inventory가 화면별 상태와 동작 처리

UIManager는 UI 종류에 맞는 prefab 경로와 부모를 결정합니다. UIBase는 생성된 prefab 내부의 GameObject, Image, TextMeshProUGUI를 찾습니다. UIEventHandler는 Unity의 click, drag, drop interface를 실제 UI callback과 연결합니다.

이렇게 나눈 이유는 공통 구조가 인벤토리의 아이템 규칙까지 알지 않게 하기 위해서였습니다. 공통 계층은 생성과 연결만 담당하고, 슬롯 이동이나 장착 같은 동작은 인벤토리 클래스에 남겼습니다.


2. UIManager가 prefab 생성 경로를 통일합니다

UI prefab은 용도에 따라 Popup, Static, SubItem으로 나눴습니다. 인벤토리 전체 화면은 MakePopup<Inventory>()로 만들고, 화면 안의 슬롯과 설명 패널은 MakeSubItem<T>()로 생성합니다.

public T MakeSubItem<T>(Transform parent = null, string uiName = null)
    where T : UIBase
{
    if (string.IsNullOrEmpty(uiName))
        uiName = typeof(T).Name;

    GameObject go = ResourceManager.Instance.Instantiate(
        $"{PrefixSubItem}{uiName}");

    if (parent)
        go.transform.SetParent(parent);

    go.transform.localScale = Vector3.one;
    return go.GetOrAddComponent<T>();
}UIManager.cs

호출하는 쪽은 실제 resource prefix와 생성 절차를 반복하지 않고 UI 타입, 부모, 이름만 전달합니다. 팝업은 별도의 stack에 넣어 마지막으로 열린 팝업부터 닫도록 구성했고, sub item은 전달받은 부모 아래에 바로 배치했습니다.

인벤토리에서는 InventorySlotArea.MakeSlots()가 행과 열의 개수만큼 InventorySlot을 생성합니다. 화면을 구성하는 코드와 prefab을 가져오는 코드를 분리했기 때문에, 슬롯 영역은 몇 개를 어떤 타입으로 만들지만 결정합니다.


3. enum 이름으로 child component를 바인딩합니다

UGUI prefab은 여러 child object를 가지고 있습니다. 각 UI 클래스에 SerializeField 참조를 반복해서 추가하는 대신, UIBase.Bind<T>()가 enum 이름과 같은 child object를 찾아 타입별 목록에 저장하도록 했습니다.

private enum Texts
{
    ItemText
}

private enum Images
{
    ItemImage
}

private void Bind()
{
    Bind<Image>(typeof(Images));
    Bind<TextMeshProUGUI>(typeof(Texts));

    itemImage = GetImage((int)Images.ItemImage);
    itemText = GetText((int)Texts.ItemText);
}BaseSlotItem.cs

UIBase.Bind<T>()Enum.GetNames()로 이름 목록을 얻고, prefab hierarchy에서 같은 이름을 가진 component를 찾습니다. 따라서 인벤토리 슬롯 prefab의 ItemImage, ItemText와 코드의 enum 이름이 하나의 규칙을 공유합니다.

enum Images.ItemImage -> prefab child "ItemImage" -> Image component
enum Texts.ItemText   -> prefab child "ItemText"  -> TextMeshProUGUI component

이 방식은 Inspector reference 대신 이름 규칙을 사용합니다. 팀에서는 UI child 이름과 enum 이름을 함께 맞추는 기준을 두고, 누락된 object는 초기화 로그에서 확인했습니다. prefab 구조가 고정된 프로젝트에서 반복적인 참조 설정을 줄이기 위한 선택이었습니다.


4. UIEventHandler가 pointer event를 callback으로 전달합니다

인벤토리 아이템은 click뿐 아니라 drag 시작, 이동, 종료와 drop을 모두 처리해야 합니다. 각 클래스가 IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler를 반복 구현하지 않도록 UIEventHandler가 Unity interface를 한곳에서 구현했습니다.

아이템 클래스는 필요한 event와 callback만 등록합니다.

private void BindEvents()
{
    gameObject.BindEvent(OnBeginDragHandler, UIEvent.BeginDrag);
    gameObject.BindEvent(OnDragHandler, UIEvent.Drag);
    gameObject.BindEvent(OnEndDragHandler, UIEvent.EndDrag);
    gameObject.BindEvent(OnDropHandler, UIEvent.Drop);
    gameObject.BindEvent(OnClickHandler, UIEvent.Click);
}BaseSlotItem.cs

BindEvent()는 대상 GameObjectUIEventHandler가 없으면 추가하고, event 종류에 맞는 delegate에 callback을 연결합니다. 그 결과 인벤토리 코드는 Unity interface를 전달하는 부분보다 drag 중에 어떤 상태를 변경할지에 집중할 수 있습니다.

drag가 시작되면 아이템의 raycastTarget을 끄고 drag 전용 부모로 옮깁니다. 그래야 pointer 아래의 다른 슬롯이 drop event를 받을 수 있고, 이동 중인 아이템도 슬롯 hierarchy에 가려지지 않습니다. 유효한 슬롯에 놓이지 않았다면 drag 종료 시 원래 부모와 위치로 되돌립니다.


5. 임시 슬롯을 사용해 두 아이템을 교환합니다

아이템이 들어 있는 슬롯 위에 다른 아이템을 drop하면 두 슬롯의 내용을 바꿔야 합니다. 한쪽을 먼저 덮어쓰면 기존 아이템의 위치를 잃기 때문에 UIManager.slotSwapBuffer를 임시 저장 공간으로 사용했습니다.

교환 전: A = Item X, B = Item Y, Buffer = Empty

1. Buffer <- Item X
2. A      <- Item Y
3. B      <- Item X

교환 후: A = Item Y, B = Item X, Buffer = Empty

실제 drop handler는 두 슬롯이 같은 SlotAreaType인지 먼저 확인합니다. 아이템 영역과 장착 영역처럼 역할이 다른 슬롯을 임의로 교환하지 않기 위한 조건입니다.

if (otherSlot.SlotType != thisSlot.SlotType)
    return;

InventorySlot swapSlot = UIManager.Instance.slotSwapBuffer;
swapSlot.SlotType = thisSlot.SlotType;

swapSlot.InvokeCopyOrMove(this);
thisSlot.InvokeCopyOrMove(otherSlotItem);
otherSlot.InvokeCopyOrMove(this);BaseSlotItem.cs

InvokeCopyOrMove()는 아이템이 원본 슬롯에 있는지, 장착용 복사본인지와 대상 슬롯의 타입을 확인해 이동 또는 복사 event를 만듭니다. 실제 부모 Transform만 바꾸는 것이 아니라 SlotItemPositionSlotItemData 참조도 함께 갱신해 화면 위치와 인벤토리 상태를 맞췄습니다.

Project Ellie 인벤토리 UI


6. 공통화의 범위를 UI 연결까지로 제한했습니다

이 구조의 목적은 모든 UI를 하나의 범용 시스템으로 만드는 것이 아니라, 팀원이 새 화면을 만들 때 반복하는 작업을 줄이는 것이었습니다.

공통 계층에 인벤토리 규칙을 넣지 않았기 때문에 설정 UI나 몬스터 체력 UI도 같은 생성·바인딩 방식을 사용하면서 각자의 상태 처리는 따로 유지할 수 있었습니다. 프로젝트 기간과 팀 규모를 고려해 이름 기반 바인딩이라는 단순한 규칙을 사용하고, 기능별 동작은 구체 클래스에 남긴 선택입니다.


정리하며

Project Ellie의 UGUI 구조는 화면마다 반복되던 생성과 연결 코드를 공통화하고, 실제 기능의 상태 규칙은 각 UI에 남기는 방식으로 구성했습니다.

핵심 요약:

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[WinAPI] 메탈슬러그 모작: 데이터 기반 파트 애니메이션
다음 글
[Unity] Project Ellie: Event Channel로 입력과 UI 연결하기