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

- 구현에 대해 필요한 정보를 먼저 설명하겠습니다.
- MVP 패턴을 사용하여 장비창을 구현합니다.
- 인벤토리 아이템을 Drag Drop으로 장비창에 장착이 가능하며 해제도 가능해야 합니다.
- 장비 슬롯은 자기 타입에 맞는 아이템만 장착이 가능해야 합니다.
- 장비 장착 및 해제시 Character의 스텟의 변동이 있어야 합니다. (이벤트 처리)
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 를 참조하여 사용합니다.
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의 역할이며 데이터와 데이터 로직을 담당하도록 구현하였습니다.
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와 슬롯 갱신을 담당하도록 구현하였습니다. 특별한 내용은 딱히 없습니다.
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와 비슷하게 구현하였습니다.
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 패턴이 깨진 듯 합니다.. 서로 알지 못하게 했어야 하지 않았나 싶습니다. 생각나는 방법이 저거 뿐이였기때문에 ㅠ.. 공부를 더 해야할 듯 합니다...
- 제가 작성한 내용이 잘못 되었거나 더 좋은 방법이 있는 경우에는 댓글로 알려주시면 감사합니다 ! (´._.`)