XR플밍 - (주말작업) 사전 합반 프로젝트 7.2일차(6/29)

이형원·2025년 6월 29일
0

XR플밍

목록 보기
119/215

0.들어가기에 앞서
어제 인벤토리를 구현했고, 크래프팅의 초반부까지 진행했다. 이제 크래프팅과 그 이후의 기능도 구현해야 할 차례이다.

1. 금일 한 업무 정리, 구현 내용

PR을 하면서 어제자에 끝낸 인벤토리 구현 내용과 함께 추가적으로 구현핸 내용을 전달했다.

크래프팅 시스템의 구현을 완료했으며 하위로 분류하자면 크게 아래와 같이 기능을 구현했다.

  • 현재 선택된 레시피를 바탕으로 레시피의 아이템이 제작 가능한지를 판정하는 기능 구현.
  • 아이템의 생성이 가능하면, 버튼을 눌렀을 때 재료의 개수만큼의 아이템을 소비하는 기능 구현.
  • 레시피에는 각각의 쿨타임이 걸려 있으며, 해당 쿨타임만큼 제작 시간을 걸어두고 게이지를 채워주는 방식
  • 게이지가 완전히 채워지고 나서야 결과물이 나오며, 결과 버튼을 누르면 결과 아이템을 얻음과 동시에 결과 창의 버튼의 이미지를 제거하고 비활성화함.

2. 문제의 발생과 해결 과정

2.1 레시피의 제작 가능 여부를 판별하기

조합 시스템에서 효율을 챙기기 위해서 많은 고민을 했고, 그 중에서 처음으로 떠올린 건 어제 한 작업물이었다.

일반적이라면 조합시스템에서 조합을 담당하는 컨트롤러가 모든 레시피를 가지는 것이 맞을 것이다. 하지만 인벤토리 내 아이템과 모든 레시피 데이터와 비교하는 것은 효율적으로 좋지 못하다고 생각했다. 이를 방지하기 위해서, 차라리 UI 각각에 레시피를 하나씩 배정하고, 그 레시피를 선택했을 때 그것이 _currentRecipe로 선택되도록 하자.

그래서 실질적으로 조합을 시도하고자 했을 때 지금 선택된 레시피와 인벤토리의 내용물만을 비교하여 제작 가능 여부를 판별할 수 있도록 하는 게 효율을 챙기는 것에서 좋다고 생각했다.

그렇다면 레시피의 제작 여부를 판별하기 위해서는 어떻게 하면 될까? 효율성을 포기하고 가장 단순한 방법으로 가자고 한다면 무식하게 for 문을 돌려서 아이템의 종류와 개수를 일일히 대조하는 방식이 있을 것이다. 하지만 난 조금이라도 더 효율을 챙겨보기 위해서 해싱이라는 개념을 사용해보기로 했다.

해싱(Hashing)은 키값을 해시 함수를 사용하여 고유한 인덱스 값으로 변환하고, 이 인덱스를 사용하여 데이터를 저장하고 검색하는 기술이다.

그리고 이런 해싱에 대해 알아보니, 이 해싱을 이용한 인터페이스가 '딕셔너리'였다.

딕셔너리는 키(Key)와 값(Value)의 쌍으로 데이터를 저장하는 자료구조이다.

즉 요약하자면 해싱은 기술이고 딕셔너리는 해싱을 사용하는 자료구조란 소리다. 조합 시스템에 해싱을 적용해보라는 특강 내용 당시에는 그다지 와닿지 않았는데, 딕셔너리를 사용해보겠다는 생각으로 뻗어가자 제법 계획이 손에 잡혔다.

우리가 만들고 있는 게임에는 이미 아이템에 아이템ID를 붙이기로 합의가 되어 있으니, 딕셔너리의 키로 ItemID를, 값으로 수량을 저장하면 이 둘을 비교할 수 있는 기능 구현이 가능할 것이다.

2.2 구체적인 구현 과정

이와 같은 생각으로 구현해낸 내용은 다음과 같다.

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class CraftingController : MonoBehaviour
{
    [SerializeField] private Image _image;
    [SerializeField] private TMP_Text _nameText;
    [SerializeField] private TMP_Text _descriptionText;
    [SerializeField] private Button _craftButton;
    [SerializeField] private Image _sliderImage;
    [SerializeField] private Button _resultButton;
    [SerializeField] private Image _resultImage;

    private CraftingRecipe _currentRecipe;

    private Coroutine _craftCoroutine;

    private void Update()
    {
        UIUpdate();
        CraftingUIUpdate();
    }

    /// <summary>
    /// For OnClick Button Event. Get the current selected recipe.
    /// </summary>
    /// <param name="recipe"></param>
    public void SelectRecipe(CraftingRecipe recipe)
    {
        _currentRecipe = recipe;
    }

    /// <summary>
    /// Update Recipe Description based on currentRecipe.
    /// </summary>
    private void UIUpdate()
    {
        if (_currentRecipe != null)
        {
            _image.sprite = _currentRecipe.resultItem.Icon;
            _nameText.text = _currentRecipe.resultItem.Name;
            _descriptionText.text = _currentRecipe.resultItem.Description;
        }
    }

    /// <summary>
    /// Update Create Button.
    /// If item is Craftable, interaction will be occur.
    /// </summary>
    private void CraftingUIUpdate()
    {
        bool isCraftable = AbleToMakeItem(RecipeRequireItemDictionary(_currentRecipe), InventoryItemDictionary());

        if (isCraftable) _craftButton.interactable = true;
        else _craftButton.interactable = false;
    }

    /// <summary>
    /// For OnClick button event. Craft Item.
    /// </summary>
    public void CraftItem()
    {
        if (_craftCoroutine == null)
        {
            ConsumeItem();
            _craftCoroutine = StartCoroutine(CraftingTime());            
        }
    }

    /// <summary>
    /// If recipe is craftable, consume material item.
    /// </summary>
    private void ConsumeItem()
    {
        bool isCraftable = AbleToMakeItem(RecipeRequireItemDictionary(_currentRecipe), InventoryItemDictionary());

        if (!isCraftable) return;

        int inventorySlot = InventoryManager.Instance.InventoryCount;

        Dictionary<ItemSO, int> reqItemsAndNum = RecipeRequireItemDictionary(_currentRecipe);

        foreach (var key in reqItemsAndNum.Keys)
        {
            InventoryManager.Instance.RemoveItemFromInventory(key, reqItemsAndNum[key]);
        }
        Debug.Log("제거됨");
    }

    /// <summary>
    /// Coroutine, UI bar Update for crafting time.
    /// </summary>
    /// <returns></returns>
    private IEnumerator CraftingTime()
    {
        float process = 0f;
        while (process < 1f)
        {
            process += (float)Time.deltaTime / _currentRecipe.craftingTime;
            _sliderImage.fillAmount = Mathf.Lerp(0f, 1f, process);
            yield return null;
        }
        ResultPrint();
        _craftCoroutine = null;
    }

    private void ResultPrint()
    {
        _resultImage.color = Color.white;
        _resultImage.sprite = _currentRecipe.resultItem.Icon;
        _resultButton.interactable = true;
    }

    public void GetItem()
    {
        if (_currentRecipe != null)
        {
            InventoryManager.Instance.AddItemToInventory(_currentRecipe.resultItem);             
        }
    }

    /// <summary>
    /// Compare Dictionary of inventory item and Recipe require item.
    /// If there's enough materials for recipe, return true.
    /// </summary>
    /// <param name="reqItemsAndNum"></param>
    /// <param name="possessItemsAndNum"></param>
    /// <returns></returns>
    private bool AbleToMakeItem(Dictionary<ItemSO, int> reqItemsAndNum, Dictionary<ItemSO, int> possessItemsAndNum)
    {
        if (reqItemsAndNum == null || possessItemsAndNum == null) return false;

        foreach (var key in reqItemsAndNum.Keys)
        {
            if (!possessItemsAndNum.ContainsKey(key) || possessItemsAndNum[key] < reqItemsAndNum[key])
                return false;
        }
        return true;
    }

    /// <summary>
    /// Convert require items list as dictionary.
    /// key - item id / value - numbers needed to create recipe.
    /// </summary>
    /// <param name="recipe"></param>
    /// <returns></returns>
    private Dictionary<ItemSO, int> RecipeRequireItemDictionary(CraftingRecipe recipe)
    {
        if (recipe == null) return null;

        Dictionary<ItemSO, int> reqItemsAndNum = new Dictionary<ItemSO, int>();

        int reqItemListCount = recipe.reqItem.Count;

        for (int i = 0; i < reqItemListCount; i++)
        {
            if (recipe.reqItem[i] == null) continue;

            ItemSO itemID = recipe.reqItem[i];
            if (reqItemsAndNum.ContainsKey(itemID)) reqItemsAndNum[itemID] += 1;
            else reqItemsAndNum[itemID] = 1;
        }
        return reqItemsAndNum;
    }

    /// <summary>
    /// Convert inventory items quantity as dictionary.
    /// key - item id / value - numbers of item that the player possess.
    /// </summary>
    /// <returns></returns>
    private Dictionary<ItemSO, int> InventoryItemDictionary()
    {
        Dictionary<ItemSO, int> possessItemsAndNum = new Dictionary<ItemSO, int>();

        int inventorySlot = InventoryManager.Instance.InventoryCount;

        for (int i = 0; i < inventorySlot; i++)
        {
            ItemSO item = InventoryManager.Instance.ReadFromInventory(i, out int stack);

            if (item == null) continue;

            if (possessItemsAndNum.ContainsKey(item)) possessItemsAndNum[item] += stack;
            else possessItemsAndNum[item] = stack;
        }
        return possessItemsAndNum;
    }
}

여기서 딕셔너리를 활용한 아이템 판별법의 방식은 다음과 같다.

  • 레시피의 요구 재료의 경우 아이템정보 리스트로 되어 있기 때문에, 해당 key가 중복될 때마다 1을 더하는 방식으로 딕셔너리를 반환
  • 아이템의 경우 각각의 칸마다 아이템과 수량이 있으므로, 각 칸을 순회하면서 key를 등록하고 stack만큼 값을 더한다
  • 이와 같이 둘의 딕셔너리를 구한 다음, 해당 값을 비교하여 요구 재료에 해당하는 키 값이 인벤토리에 없을 경우(요구 재료를 가지고 있지 않음) / 요구 재료의 수량이 부족할 경우 false(제작할 수 없음)를 반환, 아니면 true(제작할 수 있음)를 반환한다.

2.3 코루틴을 이용한 게이지 시스템 구현

위 스크립트를 보면 게이지 증가 시스템을 코루틴으로 사용한 것을 알 수 있다. 처음에는 게이지 시스템을 코루틴으로 구현해도 괜찮을까 하는 의문이 약간 들기도 했다. 왜냐하면 코루틴 도중에 다른 작업을 할 수 있게 하는 여지를 주는 셈이기 때문이다.

하지만 이와 같은 방식으로 구현하는 것도 나름대로 새로운 시도였다.

2.4 결과 아이템이 나왔을 때 반환하는 방법?

이 부분에서는 나름대로 꼼수를 활용했다. 처음에는 해당 결과물에 대한 정보가 결과물 버튼에 있어야 한다고 생각하여, 어떻게든 정보를 결과물 버튼으로 넣을 수 있도록 하는 방식을 구현하려고 했다.

하지만 실제 유저가 볼 때는 이 버튼에 데이터가 담겨있는지 알 수가 없고, 사실 그리 중요한 문제가 아닐 것이다. 그래서 나는 차라리 시각적으로만 마치 아이템이 나온 것처럼 묘사하고, 마치 그 아이템을 획득한 것처럼 연출하기 위해 꼼수를 사용했다.

CraftController는 이미 _currentRecipe로 결과 아이템에 대한 이미지를 갖고 있으므로, 결과 창 버튼에는 그냥 이 아이템이 나왔습니다 하고 이미지만 띄우고 버튼을 활성화한다. 그러면 유저는 분명 그 버튼을 보고 이 아이템을 획득하려고 버튼을 누를 것이다. 그러면 막상 버튼 자체는 CraftController에서 자기 스스로 함수를 작동하도록만 이벤트를 주고 자신은 비활성화된다.
이와 같은 방식으로 마치 결과 아이템이 나오고 그걸 유저가 획득한 것처럼 연출하는 방식을 사용했다.

3. 개선점 및 과제

3.1 (버그) 크래프팅 도중 크래프팅 창을 종료하면 다시 크래프팅이 되지 않는 현상

해당 버그의 발생 원인은 코루틴의 강제종료로 인한 null 처리가 되지 않아 발생하는 문제이다. 사실 이 부분은 크래프팅 도중 강제로 해당 창 밖으로 벗어날 수 없게 처리할 셈이지만, 아직 플레이어와 연결하지 못한 상황이다 보니 구현은 후순위로 미뤘다.

3.2 (구현) 분해시스템 구현

분해 시스템에 대해 구현해 보려고 한다. 여기서 처음에는 분해용 배열 리스트를 다시 만들어 구현해보려는 시도를 했었지만, 그렇게 하면 이미 Static으로 구현된 InventoryManager가 너무 많은 책임을 가지고, 또 과도하게 많은 Static 데이터를 만들 것이다. 그래서 구현하던 방식을 잠시 중단하고 다른 방법으로 구현할 수 없을지 고민중이다.

3.3 아이템 생성기 및 레시피 생성기 연동

팀장님이 작업하던 테이블 매니저의 경우 팀장님의 업무 과다로 다른 팀원한테 넘겨졌었고, 해당 부분의 구현이 완료된 것으로 알고 있다. 따라서 해당 데이터 테이블 생성방식에 따라 스크립터블 오브젝트 생성기의 생성 방식을 조금 고쳐줘야 한다.

3.4 그 외, 미완성 혹은 보충이 필요한 작업들

(중요도 상) 아이템 사용 기능 구현 - 이 부분은 나름대로 아이디어 정리가 되어 있지만 아직 구현하지 않았다. 크래프팅 관련 작업을 끝내고 바로 구현하자
(중요도 중) 퀵슬롯 기능 다듬기 - 기획 의도대로 재료 아이템은 담지 못하도록 처리하는 부분, 그 외.
(중요도 중) 인벤토리 확장에 대한 부분 - 아이템 목록도 지금 기획 상에선 나왔는데, 인벤토리 칸을 늘리는 아이템이 있었다. 이 부분에 대한 처리를 어떻게 할지 고민이 필요해 보인다.<알고 보니 인벤토리 확장 기능이 아니라 무게 증가 시스템이었다고 한다. 캐릭터 담당자 쪽에서 어떻게든 처리하겠지?
(중요도 하) 아이템 스택 분리 기능 - 이전 인벤토리 구현에는 있었지만 이번에는 아직 구현되지 않은 부분이다. 하지만 우선순위가 높은 작업은 아니라도 판단하여 후순위로 미뤘다.

profile
게임 만들러 코딩 공부중

0개의 댓글