[Unity] 오픈월드 제작기(4)

WowNo0607·2025년 4월 24일

OpenWorldGame

목록 보기
9/9

MVP 모델을 이용한 UI 구현

MVP 모델 [Model-View-Presenter]

사용자 인터페이스(UI)와 비즈니스 로직을 분리하기 위한 소프트웨어 아키텍처 패턴


Model

UI(View)에 출력할 데이터를 의미합니다. 또한 비즈니스 로직을 구현하는 부분입니다.

public class InventoryModel 
{
    private Dictionary<int, Item> items = new();
    public event Action OnModelUpdated; 

    public Dictionary<int, Item> GetItemList() => new Dictionary<int, Item>(items);
    
    public InventoryModel(int maxSlotSize = 40){
        for(int idx = 0; idx < maxSlotSize; idx++){
            items[idx] = null;
        }
        OnModelUpdated?.Invoke();          
    }

    public void InitModel(List<SlotData<int>> slotDataList){
        Debug.Log($"Inventory Model : Init => {slotDataList.Count}");
        foreach(var data in slotDataList){
            UpdateModel(data);
        }
        OnModelUpdated?.Invoke();
    }

    // 뷰로부터 같은 데이터 이므로 뷰를 갱신하는 호출은 수행하지 않는다.
    public void UpdateModel(SlotData<int> data)
    {
        items[data.slotKey] = data.item;
    }

    // 먹은 아이템.
    public bool AddItem(ItemData itemData)
    {
        Item existingItem = FindExistingItem(itemData);
        
        if (existingItem != null && existingItem is Consumable consumable)
        {
            consumable.GetThisItem();
            OnModelUpdated?.Invoke();
            return true;
        }

        return AddNewItem(itemData);
    }

    private bool AddNewItem(ItemData data)
    {
        int index = SearchEmptyIndex();
        if (index == -1) return false;

        items[index] = ItemFactory.CreateItem(data);
        OnModelUpdated?.Invoke();
        return true;
    }

    public int SearchEmptyIndex(){
        int index = -1;
        foreach(var item in items){
            if(item.Value == null) return item.Key;
        }
        return index;
    }

    public Item FindExistingItem(ItemData newItem)
    {
        foreach (var item in items.Values)
        {
            // 올바르지 못한 아이템이거나, 둘이 다른 아이템이면 넘어가기
            if (item == null) continue;

            if (newItem is ConsumableData consumable1 && item.data is ConsumableData consumable2)
            {
                if (consumable1.subType == consumable2.subType)
                {
                    return item;
                }
            }
            else if (newItem is EquipmentData equipment1 && item.data is EquipmentData equipment2)
            {
                if (equipment1.subType == equipment2.subType)
                {
                    return item;
                }
            }
        }
        return null;
    }
}

View(UI)

사용자와 상호작용하는 UI부분을 의미합니다.

public class InventoryView : MonoBehaviour
{
    [SerializeField] Transform scrollContent;
    public GameObject inventoryWindow;

    Transform originalParent;
    
    [SerializeField] GameObject slotPrefab;
    [SerializeField] GameObject iconBasePrefab;
    private List<InventorySlot> slots = new List<InventorySlot>();

    [SerializeField, ReadOnly] List<SlotData<int>> itemsView;                              // 인스펙터 출력용

    public event Action<SlotData<int>> OnViewUpdated;                                   //inventoryView의 변화 감지 

    void Start(){
        originalParent = transform.parent;
    }

    public void SetActive(bool isActive){
        inventoryWindow.SetActive(isActive);
    }
    
    public void InitSlots(int maxSlotSize){
        for(int i=0;i<maxSlotSize;i++){
            InventorySlot slot = Instantiate(slotPrefab, scrollContent).GetComponent<InventorySlot>();
            slot.index = i;
            slot.OnSlotUpdated += ChagedEventHandler;
            slots.Add(slot);
        }
    }

    public void UpdateView(Dictionary<int, Item> items){
        ClearSlotData();
        foreach(var item in items){
            if(item.Value == null) continue;
            SetItemIcon(item.Value, slots[item.Key]);
        }
    }

    private void ClearSlotData(){
        foreach(var slot in slots){
            slot.ClearSlot();
        }
    }

    IEnumerator Coroutine_ChangedEventHandle(SlotData<int> data){
        yield return null;
        OnViewUpdated?.Invoke(data);

        itemsView.Clear();
        for(int i=0;i<slots.Count;i++){
            if(slots[i].GetItem() == null) continue;
            Item slotItem = slots[i].GetItem().GetComponent<ItemIcon>().item;
            
            SlotData<int> viewData = new SlotData<int>(i, slotItem, slotItem.count);
            itemsView.Add(viewData);
        }
    }
    
    private void SetItemIcon(Item item, InventorySlot slot){
        ItemIcon itemIcon = UIIconFactory.Instance.CreateItemIcon(item);
        slot.SetItem(itemIcon.gameObject);
    }
    
    public void ChagedEventHandler(SlotData<int> data){
        StartCoroutine(Coroutine_ChangedEventHandle(data));
    }
}

Presenter

View와 Model 사이의 중재자 역할을 수행합니다.

주로 데이터를 전달하는 작업을 수행합니다.

public class InventoryPresenter
{
    private InventoryModel model;
    private InventoryView view;

    //View가 비활성화 된 상태일 때 저장하는 코드.
    private List<SlotData<int>> pendingUpdates = new List<SlotData<int>>();

    public InventoryPresenter(InventoryModel model, InventoryView view){
        this.model = model;
        this.view = view;
        view.OnViewUpdated += UpdateModel;
        model.OnModelUpdated += UpdateView;
        view.InitSlots(40);
    }
    
    public void InitModel(List<SlotData<int>> datas)
    {
        model.InitModel(datas);
    }
    
    public void UpdateView(){
        if(!view.gameObject.activeSelf){
            pendingUpdates.Clear();
            foreach(var item in model.GetItemList()){
                pendingUpdates.Add(new SlotData<int>(item.Key, item.Value, item.Value.count));
            }
        }else{
            view.UpdateView(model.GetItemList());
        }
    }

    public void UpdateModel(SlotData<int> slot){
        model.UpdateModel(slot);
    } 

    public void AddItem(ItemData itemData)
    {
        model.AddItem(itemData);
    }

    public void ToggleInventory(){
        WindowController window = view.GetComponentInParent<WindowController>();

        bool isActive = !window.gameObject.activeSelf;
        window.gameObject.SetActive(isActive);
        
        if(isActive){
            foreach (var update in pendingUpdates) model.UpdateModel(update);
            
            pendingUpdates.Clear(); 
            view.UpdateView(model.GetItemList());
        }
    }

    public Item GetItemInstance(ItemData itemData){
        Item item = model.FindExistingItem(itemData);
        if(item == null) return null;
        return item;
    }
    #region Inspector Caller
    public Dictionary<int, Item> GetList() => model.GetItemList();
    #endregion
}

장점

  • 데이터를 따로 관리하므로 유지 보수가 좋습니다.
  • View는 출력만, model 및 presenter는 비즈니스 로직 수행과 같이 책임분리가 가능합니다.

주요 항목

  • Item의 경우 ItemData는 ScriptableObject를 통해 새로운 인스턴스를 생성하여 처리하도록 합니다

⇒ 이름, 설명과 같은 기본적인 데이터는 ScriptableObject와 같이 정적인 데이터로 관리하고 이 정적인 데이터를 가진 인스턴스를 mvp에서 관리하도록 했습니다.

이유 : ScriptableObject는 게임 내 해당 아이템에 대한 정보가 서로 다른 인스턴스라 해도 공유하고 있으므로 소지 갯수와 같은 동적인 데이터를 처리하기에는 적합하지 않아 새로운 클래스를 두어 객체를 만드는 방향으로 설정 했습니다.

참조

https://www.youtube.com/watch?v=fxlYxhhf83s

profile
안녕하세요

0개의 댓글