Inventory System (3)

JJW·2024년 12월 12일
0

Unity

목록 보기
16/34

어제 설명했던 Inventory System (2)에 이어지는 내용입니다.
이번 글에서 구현할 건 장비창에 대한 내용입니다.


Inventory System


장비 창 구현에 필요한 정보

  • 구현에 대해 필요한 정보를 먼저 설명하겠습니다.
  • MVP 패턴을 사용하여 장비창을 구현합니다.
  • 인벤토리 아이템을 Drag Drop으로 장비창에 장착이 가능하며 해제도 가능해야 합니다.
  • 장비 슬롯은 자기 타입에 맞는 아이템만 장착이 가능해야 합니다.
  • 장비 장착 및 해제시 Character의 스텟의 변동이 있어야 합니다. (이벤트 처리)

구현

1. EquipmentSlot 및 Slot 코드 변경

public class EquipmentSlot : SlotData
{
    [SerializeField]
    private EquipType equipType; // 슬롯의 장착 부위를 설정할 수 있는 필드
    public EquipType EquipType => equipType; // 외부에서 읽기 전용으로 접근 가능
}
  • SlotData를 상속받아서 사용합니다. EquipType의 구분을 위해 만들었습니다.
public class SlotData : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IPointerClickHandler, IPointerExitHandler
{
    [SerializeField] private Image itemIcon;
    [SerializeField] private Text itemName;
    [SerializeField] private Text itemQuantity;
    [SerializeField] private GameObject slotInfo;

    public InventoryItemData _itemData;
    public int _index;
    protected InventoryPresenter _inventoryPresenter;
    protected EquipmentPresenter _equipmentPresenter;

    protected Vector3 initialPosition;
    public RectTransform rectTransform;
    protected CanvasGroup canvasGroup;

    protected Canvas canvas;

    private void Awake()
    {
        rectTransform = slotInfo.GetComponent<RectTransform>();
        canvasGroup = slotInfo.GetComponent<CanvasGroup>();
    }

    // 슬롯 초기화
    public void Initialize(int index, InventoryPresenter inventoryPresenter, EquipmentPresenter equipmentPresenter = null)
    {
        _index = index;
        _inventoryPresenter = inventoryPresenter;
        _equipmentPresenter = equipmentPresenter;
    }

    // 아이템 세팅
    public virtual void SetItem(InventoryItemData item)
    {
        _itemData = item;
        if (item != null && item.Item != null)
        {
            slotInfo.SetActive(true);
            itemIcon.sprite = item.Item.Icon;
            itemIcon.enabled = true;
            itemName.text = item.Item.ItemName;
            itemQuantity.text = item.Quantity > 1 ? item.Quantity.ToString() : "";
        }
        else
        {
            slotInfo.SetActive(false);
            itemIcon.sprite = null;
            itemIcon.enabled = false;
            itemName.text = "";
            itemQuantity.text = "";
        }
    }

    public void ClearSlot()
    {
        SetItem(null);
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        if (_itemData != null && _itemData.Item != null)
        {
            ItemDetailView.Instance.ShowDetails(_itemData, rectTransform);
        }
    }

    public void OnPointerExit(PointerEventData eventData)
    {
        if (ItemDetailView.Instance.gameObject.activeSelf)
        {
            ItemDetailView.Instance.HideDetails(); // 슬롯을 벗어나면 창 닫기
        }
    }

    public virtual void OnBeginDrag(PointerEventData eventData)
    {
        if (_itemData?.Item != null)
        {
            initialPosition = rectTransform.localPosition;
            canvasGroup.blocksRaycasts = false;
            canvasGroup.alpha = 0.6f;

            canvas = slotInfo.AddComponent<Canvas>();
            canvas.overrideSorting = true;
            canvas.sortingOrder = 1;
        }
    }

    public void OnDrag(PointerEventData eventData)
    {
        if (_itemData?.Item != null)
        {
            rectTransform.anchoredPosition += eventData.delta / canvas.scaleFactor;
        }
    }

    public virtual void OnEndDrag(PointerEventData eventData)
    {
        if (_itemData?.Item != null)
        {
            canvasGroup.blocksRaycasts = true;
            canvasGroup.alpha = 1.0f;

            // 드롭 위치 확인
            if (eventData.pointerEnter != null)
            {
                var targetSlot = eventData.pointerEnter.GetComponent<SlotData>();
                if (targetSlot != null && targetSlot != this)
                {
                    // 장비 슬롯인 경우 타입 검증
                    if (targetSlot is EquipmentSlot equipmentSlot)
                    {
                        if (_itemData.Item.EquipType != equipmentSlot.EquipType)
                        {
                            Debug.LogError($"아이템 '{_itemData.Item.ItemName}'은(는) {equipmentSlot.EquipType} 슬롯에 장착할 수 없습니다.");
                            _inventoryPresenter.View.ShowErrorMessage($"이 아이템은 {equipmentSlot.EquipType} 슬롯에 장착할 수 없습니다.");

                            StartCoroutine(ShowInvalidSlotFeedback());

                            // 드래그가 끝난 후 원래 위치로 복귀
                            rectTransform.localPosition = initialPosition;
                            Destroy(canvas);
                            return;
                        }
                    }

                    // 인벤토리 슬롯에서 장비 슬롯으로, 또는 장비 슬롯에서 인벤토리 슬롯으로
                    if (_inventoryPresenter != null && targetSlot._equipmentPresenter != null)
                    {
                        // 인벤토리 -> 장비창
                        _inventoryPresenter.MoveItemToEquipment(_index, targetSlot._index, targetSlot.GetComponent<EquipmentSlot>().EquipType);
                    }
                    else if (_equipmentPresenter != null && targetSlot._inventoryPresenter != null)
                    {
                        // 장비창 -> 인벤토리
                        _equipmentPresenter.MoveItemToInventory(GetComponent<EquipmentSlot>().EquipType, targetSlot._index);
                    }
                    else if (_inventoryPresenter != null && targetSlot._inventoryPresenter != null)
                    {
                        // 인벤토리 슬롯 간 스왑
                        _inventoryPresenter.SwapItems(_index, targetSlot._index);
                    }
                    else if (_equipmentPresenter != null && targetSlot._equipmentPresenter != null)
                    {
                        // 장비 슬롯 간 스왑
                        _equipmentPresenter.SwapItems(_index, targetSlot._index, targetSlot.GetComponent<EquipmentSlot>().EquipType);
                    }
                }
            }

            // 드래그가 끝난 후 원래 위치로 복귀
            rectTransform.localPosition = initialPosition;
            Destroy(canvas);
        }
    }

    private IEnumerator ShowInvalidSlotFeedback()
    {
        // 아이템 아이콘에 빨간색 테두리 추가
        itemIcon.color = Color.red;
        yield return new WaitForSeconds(0.5f);
        itemIcon.color = Color.white;
    }

    // 슬롯 새로고침
    public void Refresh()
    {
        canvasGroup.blocksRaycasts = true;
        canvasGroup.alpha = 1.0f;

        rectTransform.localPosition = initialPosition;
        Destroy(canvas);
    }
}
  • 장비 창과 인벤토리 모두 SlotData를 사용하며 OnEndDrag() 부분에 조건 체크 함수를 구현하였습니다.
  • Inventory Presenter, Equipment Presenter 를 참조하여 사용합니다.

2. EquipmentModel

public class EquipmentModel
{
    private Dictionary<EquipType, InventoryItemData> _equippedItems;

    public EquipmentModel()
    {
        _equippedItems = new Dictionary<EquipType, InventoryItemData>();

        foreach (EquipType equipType in System.Enum.GetValues(typeof(EquipType)))
        {
            if (equipType != EquipType.None)
                _equippedItems[equipType] = new InventoryItemData(null,0);
        }
    }

    // 장착
    public bool EquipItem(InventoryItemData item)
    {
        if (item == null || item.Item == null || item.Item.EquipType == EquipType.None)
            return false;

        EquipType equipType = item.Item.EquipType;

        if (_equippedItems.ContainsKey(equipType))
        {
            _equippedItems[equipType] = item;
            return true;
        }

        return false;
    }

    // 장착 해제
    public InventoryItemData UnequipItem(EquipType equipType)
    {
        if (_equippedItems.ContainsKey(equipType))
        {
            InventoryItemData unequippedItem = _equippedItems[equipType];
            _equippedItems[equipType] = new InventoryItemData(null, 0); // 빈 슬롯으로 초기화
            return unequippedItem;
        }

        return null;
    }

    // 장착 부위 아이템 가져오기
    public InventoryItemData GetEquippedItem(EquipType equipType)
    {
        if (_equippedItems.ContainsKey(equipType))
            return _equippedItems[equipType];
        return new InventoryItemData(null, 0);
    }

    // 모든 부위 장착 아이템 가져오기
    public Dictionary<EquipType, InventoryItemData> GetEquippedItems()
    {
        return _equippedItems;
    }

    // 초기화
    public void ClearAllEquippedItems()
    {
        foreach (var key in _equippedItems.Keys)
        {
            _equippedItems[key] = null;
        }
    }
}
  • Model의 역할이며 데이터와 데이터 로직을 담당하도록 구현하였습니다.

3. EquipmentView

public class EquipmentView : MonoBehaviour
{
    [Header("Equipment Slots")]
    [SerializeField] private List<EquipmentSlot> equipmentSlots;

    [Header("UI Elements")]
    [SerializeField] private GameObject equipmentPanel; 
    [SerializeField] private Text errorMessageText; 

    private EquipmentPresenter _presenter;

    public void SetPresenter(EquipmentPresenter presenter)
    {
        _presenter = presenter;
    }
    
    // 슬롯 초기화
    public void InitSlot(InventoryPresenter presenter, EquipmentPresenter presenter1)
    {
        // InventoryView의 슬롯 초기화
        for (int i = 0; i < equipmentSlots.Count; i++)
        {
            if (equipmentSlots[i] != null)
            {
                equipmentSlots[i].Initialize(i, presenter, presenter1); // Inventory 슬롯에는 EquipmentPresenter가 없음
            }
        }
    }

    // 슬롯 갱신
    public void RefreshEquipment(Dictionary<EquipType, InventoryItemData> equippedItems)
    {
        foreach (var slot in equipmentSlots)
        {
            if (equippedItems.ContainsKey(slot.EquipType))
            {
                var itemData = equippedItems[slot.EquipType];
                if (itemData != null && itemData.Item != null)
                {
                    slot.SetItem(itemData);
                }
                else
                {
                    slot.ClearSlot();
                }
            }
            else
            {
                slot.ClearSlot();
            }
        }
    }

    // 에러메세지 함수
    public void ShowErrorMessage(string message)
    {
        StartCoroutine(ShowErrorCoroutine(message));
    }

    private IEnumerator ShowErrorCoroutine(string message)
    {
        errorMessageText.text = message;
        errorMessageText.gameObject.SetActive(true);
        yield return new WaitForSeconds(2f);
        errorMessageText.gameObject.SetActive(false);
    }
}
  • UI와 슬롯 갱신을 담당하도록 구현하였습니다. 특별한 내용은 딱히 없습니다.

4. EquipmentPresenter

public class EquipmentPresenter
{
    private EquipmentModel _model;
    private EquipmentView _view;
    private InventoryPresenter _inventoryPresenter; // InventoryPresenter 참조

    public EquipmentPresenter(EquipmentModel model, EquipmentView view, InventoryPresenter inventoryPresenter)
    {
        _model = model;
        _view = view;
        _inventoryPresenter = inventoryPresenter;

        _view.SetPresenter(this);
        RefreshView();
    }

    // View 갱신 요청
    public void RefreshView()
    {
        _view.RefreshEquipment(_model.GetEquippedItems());
    }

    // 장착 함수
    public bool EquipItem(InventoryItemData item)
    {
        if (_model.EquipItem(item))
        {
            return true;
        }
        return false;
    }

    // 장착 해제 함수
    public void UnequipItem(EquipType equipType)
    {
        InventoryItemData unequippedItem = _model.UnequipItem(equipType);
        if (unequippedItem == null)
        {
            _view.ShowErrorMessage("해제할 장비가 없습니다.");
            return;
        }

        // 인벤토리에 아이템을 추가
        if (!_inventoryPresenter.GetModel().AddItem(unequippedItem))
        {
            _view.ShowErrorMessage("인벤토리 슬롯이 부족하여 장비를 해제할 수 없습니다.");
            // 장비창에 다시 장착
            _model.EquipItem(unequippedItem);
            RefreshView();
        }
        else
        {
            _inventoryPresenter.RefreshView();
            RefreshView();
        }
    }

    // 장비 슬롯 <-> 장비 슬롯 스왑 함수
    public void SwapItems(int equipmentIndex, int otherEquipmentIndex, EquipType equipType)
    {
        // 장비 슬롯 인덱스를 EquipType으로 변환
        EquipType firstEquipType = (EquipType)equipmentIndex;
        EquipType secondEquipType = (EquipType)otherEquipmentIndex;

        InventoryItemData firstItem = _model.GetEquippedItem(firstEquipType);
        InventoryItemData secondItem = _model.GetEquippedItem(secondEquipType);

        if (firstItem == null || secondItem == null)
        {
            _view.ShowErrorMessage("스왑할 장비가 충분하지 않습니다.");
            return;
        }

        // 스왑
        _model.UnequipItem(firstEquipType);
        _model.UnequipItem(secondEquipType);

        _model.EquipItem(secondItem);
        _model.EquipItem(firstItem);

        RefreshView();
    }

    // Model 반환 함수
    public EquipmentModel GetModel()
    {
        return _model;
    }

    // 장비창 -> 인벤토리 이동 함수
    public void MoveItemToInventory(EquipType type, int inventoryIndex)
    {
        // EquipType을 장비 슬롯 인덱스로 매핑
        // EquipType equipType = (EquipType)equipmentIndex;

        InventoryItemData equippedItem = _model.GetEquippedItem(type);
        if (equippedItem == null)
        {
            _view.ShowErrorMessage("해제할 장비가 없습니다.");
            return;
        }

        if (_inventoryPresenter.GetModel().AddItem(equippedItem))
        {
            _model.UnequipItem(type);
            RefreshView();
            _inventoryPresenter.RefreshView();
        }
        else
        {
            _view.ShowErrorMessage("인벤토리 슬롯이 부족하여 장비를 해제할 수 없습니다.");
        }
    }
}
  • Model 과 View를 중개하는 Presenter 클래스입니다.
  • 기존 Inventory와 비슷하게 구현하였습니다.

5. UIInitializer

public class UIInitializer : MonoBehaviour
{
    [SerializeField] private InventoryView inventoryView;
    [SerializeField] private EquipmentView equipmentView;
    [SerializeField] private ItemRemoveHandler itemRemoveHandler;

    [SerializeField] private int inventoryMaxSlots = 20; // 인벤토리 최대 슬롯 수 설정

    private InventoryModel _inventoryModel;
    private EquipmentModel _equipmentModel;
    private InventoryPresenter _inventoryPresenter;
    private EquipmentPresenter _equipmentPresenter;

    void Start()
    {
        _inventoryModel = new InventoryModel(inventoryMaxSlots);
        _equipmentModel = new EquipmentModel();

        _inventoryPresenter = new InventoryPresenter(_inventoryModel, inventoryView, null);
        _equipmentPresenter = new EquipmentPresenter(_equipmentModel, equipmentView, _inventoryPresenter);

        // InventoryPresenter에 EquipmentPresenter를 설정
        _inventoryPresenter.SetEquipmentPresenter(_equipmentPresenter);

        inventoryView.InitSlot(_inventoryPresenter);
        equipmentView.InitSlot(_inventoryPresenter, _equipmentPresenter);

        itemRemoveHandler.Initialize(_inventoryPresenter);
    }
}
  • 장비 창과 인벤토리 연동을 위해 만든 클래스입니다. 각자 필요한 정보를 세팅해주며 슬롯의 최대 수를 이 클래스에서 조절하도록 변경하였습니다.

테스트

  • 장비 창 <-> 인벤토리 서로 상호작용이 잘 이루어집니다.
  • 다른 타입의 슬롯으로 장착하려고하면 오류 메세지와 함께 일시적으로 클릭이 불가능한 상태가 됩니다.

느낀 점

장비 창과 인벤토리가 SlotData를 같이 사용하면서 서로 업데이트 시켜줘야해서 Presenter를 두 기능이 알도록 구현하였는데..MVP 패턴이 깨진 듯 합니다.. 서로 알지 못하게 했어야 하지 않았나 싶습니다. 생각나는 방법이 저거 뿐이였기때문에 ㅠ.. 공부를 더 해야할 듯 합니다...

  • 제가 작성한 내용이 잘못 되었거나 더 좋은 방법이 있는 경우에는 댓글로 알려주시면 감사합니다 ! (´._.`)
profile
Unity 게임 개발자를 준비하는 취업준비생입니다..

0개의 댓글