사용자 인터페이스(UI)와 비즈니스 로직을 분리하기 위한 소프트웨어 아키텍처 패턴
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;
}
}
사용자와 상호작용하는 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));
}
}
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
}
⇒ 이름, 설명과 같은 기본적인 데이터는 ScriptableObject와 같이 정적인 데이터로 관리하고 이 정적인 데이터를 가진 인스턴스를 mvp에서 관리하도록 했습니다.
이유 : ScriptableObject는 게임 내 해당 아이템에 대한 정보가 서로 다른 인스턴스라 해도 공유하고 있으므로 소지 갯수와 같은 동적인 데이터를 처리하기에는 적합하지 않아 새로운 클래스를 두어 객체를 만드는 방향으로 설정 했습니다.