0. 들어가기에 앞서
오늘은 컨디션이 안 좋아서 작업을 많이 하지 못했다. 하지만 내일 씬 병합 작업이 들어가니 최대한 할 수 있는 걸 끝내놔야 한다.
박스는 임시로 접촉하면 열릴 수 있게 구현해 놨으며 각각의 박스가 개별적으로 작동할 수 있도록 구현하였다.
박스에 등장하는 아이템은 4종류이고 각각 확률표에 따라 아이템의 등장확률이 결정된다.
이 중에서 두 가지는 일자 시스템이 들어가야 하며 일자 시스템이 아직 구현되지 않은 것으로 보여 이 부분은 구현하지 않고, 할 수 있는 두 가지에 관해서 우선적으로 구현해 두었다.
퀵슬롯 시스템을 개편했다.
기존에는 숫자가 이미 지정되어 있었지만, 이를 원하는 숫자로 지정할 수 있게 시스템을 개편했다.
박스는 데이터를 담는 BoxSystem과 BoxController, 각각의 칸을 관리하는 BoxSlotUnit 으로 구성되어 있다.
여기서 핵심이 되는 부분은 각각의 박스를 배치하면서 어떤 박스가 열려 있는지 판정하는 기준이 필요하다는 것이다. 이에 따라서 어떤 박스에 데이터를 저장할지를 결정한다.
using System;
using UnityEngine;
public class BoxSystem : MonoBehaviour
{
[SerializeField] private ItemSO[] _boxItem;
[SerializeField] private int[] _boxStack;
public event Action OnBoxSlotUpdated;
private void Awake()
{
// TODO : Item Random input System
}
/// <summary>
/// Read Data of box slot.
/// </summary>
/// <param name="index"></param>
/// <param name="stack"></param>
/// <returns></returns>
public ItemSO ReadFromBoxSlot(int index, out int stack)
{
stack = _boxStack[index];
return _boxItem[index];
}
public void RemoveAllItem()
{
for(int i = 0; i < _boxItem.Length; i++)
{
_boxItem[i] = null;
_boxStack[i] = 0;
}
}
/// <summary>
/// Used as Onclick event.
/// Get all items in the box to inventory.
/// </summary>
public void GetAllBoxItemIntoInventory()
{
for(int i = 0; i < _boxItem.Length; i++)
{
InventoryManager.Instance.GetItemFromBox(i);
}
}
/// <summary>
/// Add item to box Slot.(From Inventory)
/// </summary>
/// <param name="item"></param>
/// <param name="index"></param>
/// <param name="stack"></param>
/// <returns></returns>
public bool AddItemToBoxSlot(ItemSO item, int stack = 1)
{
int remain = stack;
while (remain > 0)
{
for (int i = 0; i < _boxItem.Length; i++)
{
if (_boxItem[i] == item)
{
remain = BoxSlotTryAdd(item, i, remain);
if (remain <= 0) break;
}
}
if (remain <= 0) break;
for (int i = 0; i < _boxItem.Length; i++)
{
if (_boxItem[i] == null)
{
remain = BoxSlotTryAdd(item, i, remain);
if (remain <= 0) break;
}
}
if (remain <= 0) break;
else
{
Debug.Log("slot is full"); return false;
}
}
return true;
}
/// <summary>
/// Return Item to the Inventory.
/// </summary>
/// <param name="index"></param>
public void SendItemToInventory(int index)
{
if (index == -1) return;
if (_boxItem[index] != null)
{
InventoryManager.Instance.AddItemToInventory(_boxItem[index], _boxStack[index]);
_boxItem[index] = null;
_boxStack[index] = 0;
OnBoxSlotUpdated?.Invoke();
}
}
/// <summary>
/// Move item in the box slot.
/// </summary>
/// <param name="startIndex"></param>
/// <param name="endIndex"></param>
public void MoveItemInBoxSlot(int startIndex, int endIndex)
{
// if there is no item in start cell.
if (startIndex == -1 || endIndex == -1)
{
return;
}
// if there is item in start cell and drag into empty cell.
else if (_boxItem[startIndex] != null && endIndex != -1 && _boxItem[endIndex] == null)
{
BoxSlotTryAdd(_boxItem[startIndex], endIndex, _boxStack[startIndex]);
_boxItem[startIndex] = null;
_boxStack[startIndex] = 0;
OnBoxSlotUpdated?.Invoke();
}
// if there is item both in start and end
else if (_boxItem[startIndex] != null && _boxItem[endIndex] != null)
{
// if item is same and stack is same, return.
if (_boxItem[startIndex] == _boxItem[endIndex] && _boxStack[startIndex] == _boxStack[endIndex]) return;
ItemSO tempItem = _boxItem[endIndex];
int tempStack = _boxStack[endIndex];
_boxItem[endIndex] = _boxItem[startIndex];
_boxStack[endIndex] = _boxStack[startIndex];
_boxItem[startIndex] = tempItem;
_boxStack[startIndex] = tempStack;
OnBoxSlotUpdated?.Invoke();
}
}
/// <summary>
/// Try add item in the box slot.
/// </summary>
/// <param name="item"></param>
/// <param name="index"></param>
/// <param name="amount"></param>
/// <returns></returns>
private int BoxSlotTryAdd(ItemSO item, int index, int amount)
{
_boxItem[index] = item;
// If the stack is enough.
if (amount <= (item.MaxStackSize - _boxStack[index]))
{
_boxStack[index] += amount;
OnBoxSlotUpdated?.Invoke();
return 0;
}
// If the stack is not enough and has space.
else if (item.MaxStackSize > _boxStack[index])
{
amount -= (item.MaxStackSize - _boxStack[index]);
_boxStack[index] = item.MaxStackSize;
OnBoxSlotUpdated?.Invoke();
return amount;
}
// If the stack is full.
else
{
return amount;
}
}
}
using UnityEngine;
public class BoxController : MonoBehaviour
{
[SerializeField] private BoxSlotUnit[] _slots;
[SerializeField] private BoxSystem _data;
private void Start()
{
_data.OnBoxSlotUpdated += UpdateUISlot;
UpdateUISlot();
}
private void OnEnable()
{
_data.OnBoxSlotUpdated += UpdateUISlot;
UpdateUISlot();
InventoryManager.Instance.OpenBox(this._data);
}
private void OnDisable()
{
_data.OnBoxSlotUpdated -= UpdateUISlot;
InventoryManager.Instance.CloseBox();
}
private void UpdateUISlot()
{
for (int i = 0; i < _slots.Length; i++)
{
_slots[i].UpdateUI(i);
}
}
}
using UnityEngine;
public class BoxSlotUnit : ItemSlotUnit
{
[SerializeField] BoxSystem _data;
public override void Awake()
{
_image.color = Color.clear;
_text.text = "";
}
/// <summary>
/// Update UI.
/// </summary>
/// <param name="index"></param>
public override void UpdateUI(int index)
{
_item = _data.ReadFromBoxSlot(index, out int stack);
if (_item == null)
{
_image.color = Color.clear;
_itemStack = 0;
_text.text = "";
}
else
{
_image.color = Color.white;
_image.sprite = _item.Icon;
_itemStack = stack;
_text.text = stack > 1 ? stack.ToString() : "";
}
}
/// <summary>
/// OnClick event. If Click button, send item to the inventory.
/// </summary>
public void OnClick()
{
_data.SendItemToInventory(_index);
}
}
#region Box
public void OpenBox(BoxSystem box)
{
_currentOpenedBox = box;
}
public void CloseBox()
{
_currentOpenedBox = null;
}
/// <summary>
/// Send item to box slot.
/// </summary>
/// <param name="startIndex"></param>
/// <param name="endindex"></param>
public void ReturnItemToBox(int startIndex)
{
if (startIndex == -1) return;
for (int i = 0; i < _boxSlotData.Length; i++)
{
if (_boxSlotData[i] == _currentOpenedBox)
{
if (_boxSlotData[i].AddItemToBoxSlot(_inventoryItem[startIndex], _inventoryStack[startIndex]))
{
_inventoryItem[startIndex] = null;
_inventoryStack[startIndex] = 0;
OnInventorySlotChanged?.Invoke();
}
}
}
}
/// <summary>
/// Get Item to InventorySlot from box slot.
/// </summary>
/// <param name="startIndex"></param>
public void GetItemFromBox(int startIndex)
{
for (int i = 0; i < _boxSlotData.Length; i++)
{
if (_boxSlotData[i] == _currentOpenedBox)
{
_boxSlotData[i].SendItemToInventory(startIndex);
OnInventorySlotChanged?.Invoke();
}
}
}
/// <summary>
/// Move Item in the Box slot.
/// </summary>
/// <param name="startIndex"></param>
/// <param name="endIndex"></param>
public void MoveItemInBoxSlot(int startIndex, int endIndex)
{
for (int i = 0; i < _boxSlotData.Length; i++)
{
_boxSlotData[i].MoveItemInBoxSlot(startIndex, endIndex);
}
}
#endregion
}
특히 지금 열린 박스를 어떻게 판별할 것인가에 대해서 InventoryManager에서 기록할 수 있도록 하는 함수를 만드는 것이 중요했다. 해당 과정을 거치지 않고 상자를 열었을 경우 맨 처음 상자부터 아이템이 채워지기 때문에 오류를 겪었었다.
그 다음 퀵슬롯 내용까지 포함하여, 다소 하드코딩이 될 순 있지만 아래와 같이 작동 원리를 적용했다.
public void OnEndDrag(PointerEventData eventData)
{
DragSlot.Instance.ClearDragSlot();
// if start is inventory slot and end is inventory slot, or start is inventory slot and end is empty space.
if ((_startIsInventorySlot == true && _endIsInventorySlot == true) || (_startIsInventorySlot == true && _startDragPoint != -1 && _endDragPoint == -1))
{
InventoryManager.Instance.MoveItemInInventory(_startDragPoint, _endDragPoint);
}
else if (_startIsInventorySlot == true && _endIsInventorySlot == false)
{
if (_endIsDecompositionSlot) InventoryManager.Instance.SendItemToDecomposition(_startDragPoint);
else if (_endIsQuickSlot) InventoryManager.Instance.AddQuickSlotItem(_endDragPoint, _startDragPoint);
else InventoryManager.Instance.ReturnItemToBox(_startDragPoint);
}
else if (_startIsInventorySlot == false && _endIsInventorySlot == true)
{
if (_endIsDecompositionSlot) InventoryManager.Instance.ReturnItemFromDecomposition(_startDragPoint);
else InventoryManager.Instance.ReturnItemToBox(_startDragPoint);
}
else
{
if (_startIsDecompositionSlot && _endIsDecompositionSlot) InventoryManager.Instance.MoveItemInDecompositionSlot(_startDragPoint, _endDragPoint);
else if (_startIsBoxSlot && _endIsBoxSlot) InventoryManager.Instance.MoveItemInBoxSlot(_startDragPoint, _endDragPoint);
}
}
유니티에서는 Random.Range라는 함수를 통해서 숫자를 랜덤으로 뽑을 수 있다. 이러한 방식으로 확률이란 것을 만들 수도 있겠지만, 이제 좀 더 복잡한 확률이 되어버리면 적용하기가 어려울 수도 있을 것이다.
이에 따라 방법을 찾아보던 중 가중치 확률이란 방법을 찾게 되었다.
가중치 확률이란 특정 선택에 가중치를 두어, 뽑기로 뽑을 때에도 특정 선택이 등장할 확률을 높일 수도 있고 특정 확률을 낮출 수도 있는, 이른 바 동일하지 않는 확률을 적용하는 방식이다.
원리 자체도 그렇게 어렵지는 않았고, 핵심은 가짓수에 따라 뽑을 수 있는 수를 다르게 두는 것이다.
using System.Collections.Generic;
using UnityEngine;
public class WeightedRandom<T>
{
private Dictionary<T, int> _dic;
public WeightedRandom()
{
_dic = new Dictionary<T, int>();
}
/// <summary>
/// Add Item to list due to weight.
/// </summary>
/// <param name="item"></param>
/// <param name="value"></param>
public void Add(T item, int value)
{
if (value < 0)
{
Debug.LogError("Value Under 0 can't be added");
return;
}
if (_dic.ContainsKey(item))
{
_dic[item] += value;
}
else
{
_dic.Add(item, value);
}
}
/// <summary>
/// Substract item from list due to weight.
/// </summary>
/// <param name="item"></param>
/// <param name="value"></param>
public void Sub(T item, int value)
{
if (value < 0)
{
Debug.LogError("Value under 0 can't be substracted");
return;
}
if(_dic.ContainsKey(item))
{
if(_dic[item] > value)
{
_dic[item] -= value;
}
else
{
Remove(item);
}
}
}
/// <summary>
/// Remove the item in the list.
/// </summary>
/// <param name="item"></param>
public void Remove(T item)
{
if (_dic.ContainsKey(item))
{
_dic.Remove(item);
}
else
{
Debug.LogError($"{item} is not exist");
}
}
/// <summary>
/// Get total weight of list.
/// </summary>
/// <returns></returns>
public int GetTotalWeight()
{
int totalWeight = 0;
foreach(int value in _dic.Values)
{
totalWeight += value;
}
return totalWeight;
}
/// <summary>
/// Get parcentage of each items.
/// </summary>
/// <returns></returns>
public Dictionary<T, float> GetPercent()
{
Dictionary<T, float> _percentDic = new Dictionary<T, float>();
float totalWeight = GetTotalWeight();
foreach(var item in _dic)
{
_percentDic.Add(item.Key, item.Value / totalWeight);
}
return _percentDic;
}
/// <summary>
/// Get random item and substrate.
/// (if you picked certain item, then the item percentage of list decreases.)
/// </summary>
/// <returns></returns>
public T GetRandomItemBySub()
{
if( _dic.Count <= 0 )
{
Debug.LogError("There's no item in list.");
return default;
}
int weight = 0;
int totalWeight = GetTotalWeight();
int pivot = Mathf.RoundToInt(totalWeight * Random.Range(0.0f, 1.0f));
foreach(var item in _dic)
{
weight += item.Value;
if(pivot <= weight)
{
_dic[item.Key] -= 1;
return item.Key;
}
}
return default;
}
/// <summary>
/// Get random item.
/// No percentage change occurs.
/// </summary>
/// <returns></returns>
public T GetRandomItem()
{
if (_dic.Count <= 0)
{
Debug.LogError("There's no item in list.");
return default;
}
int weight = 0;
int totalWeight = GetTotalWeight();
int pivot = Mathf.RoundToInt(totalWeight * Random.Range(0.0f, 1.0f));
foreach (var item in _dic)
{
weight += item.Value;
if (pivot <= weight)
{
return item.Key;
}
}
return default;
}
/// <summary>
/// Get all list of dictionary.
/// </summary>
/// <returns></returns>
public Dictionary<T, int> GetList()
{
return _dic;
}
}
이와 같은 제네릭 클래스를 만들고, 이를 적용하여 상자의 아이템 확률 등장 방식에 아래와 같이 적용했다.
using UnityEngine;
enum BoxTier { Tier1, Tier2, Tier3 }
public class BoxItemRandomSystem : MonoBehaviour
{
[SerializeField] BoxTier _tier;
[SerializeField] BoxSystem _data;
[Header("Item - Normal")]
[SerializeField] ItemSO[] _itemA_Items1;
[Header("Item - Rare")]
[SerializeField] ItemSO[] _itemA_Items2;
[Header("Item - Unique")]
[SerializeField] ItemSO[] _itemA_Items3;
[Header("Item - Legendary")]
[SerializeField] ItemSO[] _itemA_Items4;
[Header("Journal - List")]
[SerializeField] ItemSO[] _itemB_Journal;
[Header("Food")]
[SerializeField] ItemSO[] _itemC_Food;
[Header("Collectible - List")]
[SerializeField] ItemSO[] _itemD_Collection;
private WeightedRandom<ItemSO> _weightedRandomA = new WeightedRandom<ItemSO>();
// private WeightedRandom<ItemSO> _weightedRandomB = new WeightedRandom<ItemSO>();
// private WeightedRandom<ItemSO> _weightedRandomC = new WeightedRandom<ItemSO>();
private WeightedRandom<ItemSO> _weightedRandomD = new WeightedRandom<ItemSO>();
private void Awake()
{
// Item A init
switch (_tier)
{
case BoxTier.Tier1: ItemAInit(300, 145, 50, 10);
break;
case BoxTier.Tier2: ItemAInit(150, 200, 100, 100);
break;
case BoxTier.Tier3: ItemAInit(50, 125, 200, 250);
break;
}
// Item B init
// Item C init
// Item D init
ItemDInit();
}
private void OnEnable()
{
ItemAddToBox();
}
private void OnDisable()
{
_data.RemoveAllItem();
}
private void ItemAddToBox()
{
ItemASelect();
// TODO : Add Item B in probability
// TODO : Add Item C in probability
ItemDSelect();
}
/// <summary>
/// Select Item A.
/// Item A is definitely collecitible, and the number of A item is 4.
/// </summary>
private void ItemASelect()
{
for (int i = 0; i < 4; i++)
{
_data.AddItemToBoxSlot(_weightedRandomA.GetRandomItem());
}
}
/// <summary>
/// Set Item A weightRandom.
/// </summary>
/// <param name="normal"></param>
/// <param name="rare"></param>
/// <param name="unique"></param>
/// <param name="legendary"></param>
private void ItemAInit(int normal, int rare, int unique, int legendary)
{
for(int i = 0; i < _itemA_Items1.Length; i++)
{
_weightedRandomA.Add(_itemA_Items1[i], normal);
}
for(int i = 0; i < _itemA_Items2.Length; i++)
{
_weightedRandomA.Add(_itemA_Items2[i], rare);
}
for(int i = 0; i < _itemA_Items3.Length; i++)
{
_weightedRandomA.Add(_itemA_Items3[i], unique);
}
for (int i = 0; i < _itemA_Items4.Length; i++)
{
_weightedRandomA.Add(_itemA_Items4[i], legendary);
}
}
private void ItemBInit()
{
}
private void ItemCInit()
{
}
/// <summary>
/// Select Item D.
/// </summary>
private void ItemDSelect()
{
float randomNum = Random.Range(0.0f, 1.0f);
if (randomNum > 0.7f) return;
_data.AddItemToBoxSlot(_weightedRandomD.GetRandomItem());
}
/// <summary>
/// Set Item D weightRandom.
/// </summary>
private void ItemDInit()
{
for(int i = 0; i < _itemD_Collection.Length; i++)
{
_weightedRandomD.Add(_itemD_Collection[i], 10);
}
}
}
실제 테스트를 진행하기에는 스케일이 너무 커서 우선은 확률 계산 자체만 반영해놨으며, 아래와 같은 확률표 하에 내용을 우선 반영해놓았다.
우선 구현한 부분은 A 아이템을 티어별로 구현하고, D의 아이템은 10개의 아이템의 각각 7퍼센트의 확률(동일확률)로 등장하는 방식을 적용하였다.
오늘 특히나 어려웠던 작업 중 하나였다.
퀵슬롯 자체는 인벤토리와 직접적으로 연결되어 있기 때문에 별도의 데이터 저장소를 만들 필요는 없었다. 다만 그 연결 과정에 대해서 신중해야 할 필요가 있었다.
이제는 분해슬롯이나 상자 슬롯 등 많은 것이 엮여 있다 보니, 하나를 잘못 건들면 다른 곳에서 바로 문제가 생길 정도로 내용이 복잡해졌기 때문이다.
여기서 집중해야 할 건 HotbarController와 HotbarSlotUnit이다.
using UnityEngine;
public class HotbarController : MonoBehaviour
{
[SerializeField] HotBarSlotUnit[] _hotBarSlot;
[SerializeField] int[] _inventoryitemSlot;
private void Awake()
{
for(int i = 0; i < _inventoryitemSlot.Length; i++)
{
_inventoryitemSlot[i] = -1;
}
}
private void OnEnable()
{
InventoryManager.OnInventorySlotChanged += UpdateUISlot;
}
private void OnDisable()
{
InventoryManager.OnInventorySlotChanged -= UpdateUISlot;
}
public void UpdateUISlot()
{
for(int i = 0; i < _hotBarSlot.Length; i++)
{
if (_inventoryitemSlot[i] == -1) continue;
_hotBarSlot[i].UpdateUI(_inventoryitemSlot[i]);
}
}
public void SetSlot(int targetIndex, int inventoryIndex)
{
_inventoryitemSlot[targetIndex] = inventoryIndex;
}
public int GetSlot(int hotbarSlot)
{
return _inventoryitemSlot[hotbarSlot];
}
}
using UnityEngine;
using UnityEngine.EventSystems;
/// <summary>
/// For static Hotbar(quickslot) Slot Unit.
/// </summary>
public class HotBarSlotUnit : ItemSlotUnit
{
[SerializeField] HotbarController _controller;
public override void Awake()
{
_image.color = Color.clear;
_text.text = "";
}
/// <summary>
/// Update UI.
/// </summary>
/// <param name="index"></param>
public override void UpdateUI(int index)
{
if (index == -1) return;
_item = InventoryManager.Instance.ReadFromInventory(index, out int stack);
if (_item == null)
{
_image.color = Color.clear;
_text.text = "";
_controller.SetSlot(_index, -1);
}
else
{
_image.color = Color.white;
_image.sprite = _item.Icon;
_text.text = stack > 1 ? stack.ToString() : "";
}
}
/// <summary>
/// Item Use when click.(Interface)
/// </summary>
/// <param name="eventData"></param>
public override void OnPointerClick(PointerEventData eventData)
{
if (_item != null)
{
_item.Prefab.GetComponent<ItemController>().Use();
InventoryManager.Instance.UseItem(_controller.GetSlot(_index));
_controller.UpdateUISlot();
}
}
}
여기서 HotBarSlotUnit에서 인벤토리의 슬롯을 가져오기 때문에 마치 인벤토리에 연동되어 있는 것처럼 보이는 것이다.
이 연결점이 훼손되지 않도록 하여 연결하는 것이 핵심이다.
그러므로 InventoryManager에서 필요한 건 Hotbar의 슬롯을 어떤 인벤토리 슬롯에 연결할 지에 대한 정보이다.
#region QuickSlot
public void AddQuickSlotItem(int targetIndex, int inventoryIndex)
{
if (_inventoryItem[inventoryIndex].Type == ItemType.Usable || _inventoryItem[inventoryIndex].Type == ItemType.Equip)
{
_hotbarController.SetSlot(targetIndex, inventoryIndex);
OnInventorySlotChanged?.Invoke();
}
}
public void RemoveQuickSlotItem(int index)
{
_hotbarController.SetSlot(index, -1);
OnInventorySlotChanged?.Invoke();
}
#endregion
그리고 버튼을 누르면 아이템이 사용되는 방식에 대해서도 개편이 필요하다. 기존에는 ItemSlotUnit에 아이템의 사용을 구현해놨지만, 이렇게 하면 퀵슬롯에서 버그가 일어난다.
->퀵슬롯에서의 아이템 사용은, 그 자체의 인덱스가 아닌 인벤토리의 인덱스의 아이템을 사용해야 한다. 따라서, 아이템의 사용을 override로 따로 구현하여 아이템이 이상하게 사용되는 버그를 수정했다.
퀵슬롯에는 현재 사용 불가능한 아이템은 등록이 안 되게 설정되어 있지만, 퀵슬롯을 이미 설정한 상태에서 인벤토리 내 아이템을 교환하면 강제로 사용불가능한 아이템을 퀵슬롯에 등록할 수 있게 된다. 해당 방식을 방지할 방법이 필요해 보이긴 하다.
이제는 아이템을 생성하고 데이터 테이블을 연결해야 하는 작업을 해야 한다. 그러면 아이템 프리팹 작업과 이미지를 등록해야 한다.