제작 시스템

제작 시스템은 재료와 재화를 소모하여 아이템을 생성하는 구조이다.

검색 기능을 통해 제작 가능한 아이템을 빠르게 탐색할 수 있다.

목차

1. 유니티 구현

2. 전체 코드

유니티 구현

이 UI는 제작 가능한 아이템 목록을 탐색하고 필터링하기 위한 인터페이스로 설계되어 있다.

제작 가능한 아이템 수는 게임 진행에 따라 계속 증가할 수 있기 때문에, 단순히 패널에 고정적으로 배치하는 방식은 확장성이 떨어진다.

따라서 Scroll View + Grid Layout Group + InputField 기반 검색 구조를 사용하여 많은 아이템을 효율적으로 탐색할 수 있도록 구성하였다.

먼저 아이템 목록을 표시하는 영역은 Unity의 Scroll View(UI ScrollRect) 를 사용해 구현하였다.

Scroll View는 하나의 Viewport 안에서 Content 오브젝트를 이동시키는 방식으로 동작하는 UI 컴포넌트이며, 리스트형 데이터나 인벤토리, 상점 목록 등 동적으로 길어지는 UI 데이터를 표시할 때 가장 일반적으로 사용하는 구조다.

제작 시스템에서도 아이템이 추가될수록 슬롯 수가 늘어나기 때문에, 화면에 모든 슬롯을 동시에 배치하는 대신 스크롤 가능한 리스트 형태로 구성하였다.

Scroll View 내부 구조는 다음과 같은 계층으로 구성된다.

Scroll View
→ Viewport (Mask 영역)
→ Content (아이템 슬롯들이 실제로 배치되는 영역)

Content 오브젝트에는 Grid Layout Group을 적용하였다.

Grid Layout Group은 자식 오브젝트를 자동으로 격자 형태로 정렬하는 레이아웃 컴포넌트다.

제작 가능한 아이템 슬롯을 코드에서 동적으로 생성하고 있기 때문에, 위치를 수동으로 계산해 배치하는 대신 Grid Layout Group이 자동으로 정렬하도록 설계하였다.

이 방식의 장점은 슬롯 수가 증가하거나 감소해도 UI 정렬 로직을 추가로 작성할 필요 없이 자동으로 일정한 간격과 정렬을 유지한다는 점이다.

또한 Scroll View와 함께 사용하면 Content의 크기가 자동으로 확장되면서 자연스럽게 스크롤이 가능해진다.

스크롤 동작은 Vertical Scrollbar만 활성화하도록 설정하였다.

제작 아이템 목록은 세로 방향으로만 증가하는 구조이기 때문에 가로 스크롤은 필요하지 않다.

따라서 Horizontal Scroll은 비활성화하고 Vertical Scrollbar만 남겨 UI 동작을 단순화하였다.

이는 사용자가 스크롤 방향을 직관적으로 이해하도록 돕고, 불필요한 인터랙션을 제거하는 목적도 있다.

ScrollRect의 Movement Type은 Clamped로 설정하였다.

Clamped는 콘텐츠가 스크롤 영역의 끝에 도달하면 더 이상 이동하지 않도록 제한하는 방식이다.

Unity ScrollRect에는 Elastic, Clamped, Unrestricted 세 가지 이동 방식이 존재하는데, Elastic은 끝에서 튕기는 효과가 발생하고 Unrestricted는 제한 없이 이동한다.

제작 목록 UI에서는 튕김 애니메이션이나 과도한 이동이 필요하지 않기 때문에 목록 범위를 벗어나지 않도록 안정적인 동작을 제공하는 Clamped 방식을 선택하였다.

이 설정은 특히 마우스 휠이나 터치 스크롤에서 콘텐츠가 과도하게 흔들리는 현상을 방지하는 장점이 있다.

Scroll Sensitivity는 15로 설정하였다. Scroll Sensitivity는 마우스 휠 입력에 따라 Content가 이동하는 속도를 조절하는 값이다.

기본값은 비교적 낮기 때문에 아이템 목록이 많을 경우 스크롤을 여러 번 해야 하는 불편함이 생길 수 있다.

따라서 제작 목록을 빠르게 탐색할 수 있도록 감도를 15로 조정하여 마우스 휠 한 번에 적절한 거리만큼 이동하도록 조정하였다.

또한 제작 시스템에는 InputField 기반 검색 기능을 함께 구현하였다.

제작 가능한 아이템이 많아질 경우 스크롤만으로 원하는 아이템을 찾는 것은 비효율적이기 때문에, 검색을 통해 목록을 필터링할 수 있도록 설계하였다.

사용자가 InputField에 텍스트를 입력하면 onValueChanged 이벤트가 호출되고, 이 이벤트를 통해 현재 입력된 문자열을 기반으로 아이템 목록을 다시 생성하도록 구성하였다.

검색 기능은 단순 문자열 검색뿐만 아니라 한글 초성 검색까지 지원하도록 구현하였다.

일반적인 Contains 기반 검색은 "반지", "체력" 같은 완성형 단어 검색은 가능하지만, 한국어 사용자들은 보통 초성으로 검색하는 경우가 많다.

예를 들어 "생명의 반지"라는 아이템은 "생반", "ㅅㅂ" 같은 방식으로 검색하는 경우가 있다.

이를 지원하기 위해 아이템 이름에서 한글 초성을 추출하는 로직을 구현하고, 입력된 검색어 또한 초성 패턴으로 변환하여 비교하도록 설계하였다.

이 방식은 한국어 UI에서 사용자 경험을 개선하기 위한 접근으로, 실제 게임 UI에서도 자주 사용하는 검색 방식이다.

검색 결과는 기존 슬롯을 모두 제거한 후 조건에 맞는 아이템만 다시 생성하는 방식으로 처리된다.

즉, InputField 입력 → 필터 적용 → Scroll View Content 재생성의 흐름으로 동작한다.

이 구조의 장점은 구현이 단순하고 UI 상태를 항상 동일한 방식으로 유지할 수 있다는 점이다.

다만 모든 슬롯을 다시 생성하기 때문에 아이템 수가 매우 많아질 경우 성능 비용이 증가할 수 있다는 단점이 있다.

일반적인 제작 시스템에서는 아이템 수가 수백 개를 넘지 않는 경우가 많기 때문에 이 방식이 충분히 안정적으로 동작한다.

정리하면 이 제작 UI는 Scroll View를 통해 많은 아이템 목록을 효율적으로 탐색할 수 있도록 하고, Grid Layout Group을 통해 슬롯 정렬을 자동화하며, InputField 검색 기능을 통해 원하는 아이템을 빠르게 찾을 수 있도록 설계된 구조다.

전체 코드

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

public class CraftSystem : MonoBehaviour
{
    [Header("프리팹 및 패널")]
    public GameObject itemSlotPrefab;     // 제작 아이템 슬롯 프리팹
    public Transform contentPanel;      // 아이템 슬롯이 배치될 부모 패널
    public GameObject craftPanel;       // 아이템 제작 패널

    [Header("아이템 데이터")]
    public List<Item> craftableItems;   // 제작 가능한 아이템 목록
    public List<Item> materialItems;    // 각 아이템 제작에 필요한 재료 목록

    [Header("UI 표시 요소")]
    public Image itemImage;             // 제작 아이템 이미지 표시
    public Image materialImage;         // 제작 재료 아이템 이미지 표시
    public TextMeshProUGUI statTxt;     // 제작 아이템 스탯 정보
    public TextMeshProUGUI haveItemTxt;    // 현재 소지 재료 아이템 수량
    public TextMeshProUGUI haveCoinTxt;    // 현재 보유 재화 수량
    public TextMeshProUGUI needItemTxt;    // 필요한 재료 수량
    public TextMeshProUGUI needCoinTxt;    // 필요한 재화 수량
    public GameObject cantCraftMsg;        // 재료 부족 시 표시할 메시지

    [Header("아이템 검색")]
    public TMP_InputField serch_InputField;

    ItemManager itemManager;    
    private int selectedIndex = -1; // 현재 선택된 아이템 인덱스

    private void Start()
    {
        itemManager = GameManager_LDW.instance.itemManager;

        if (serch_InputField != null) serch_InputField.onValueChanged.AddListener(OnSearchValueChanged);

        CreateItemSlots(); 
    }

    private void Update()
    {
        // 선택된 아이템이 있을 때, 재료/재화 실시간 갱신
        if (selectedIndex >= 0 && selectedIndex < materialItems.Count) 
        {
            haveItemTxt.text = materialItems[selectedIndex].count.ToString();
            haveCoinTxt.text = itemManager.coin.ToString();  
        }
    }

    /// --- 아이템 검색 --- ///
    // 한글 완성형 초성 테이블
    private static readonly char[] InitialConsonants =
    {
    'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ',
    'ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
    };

    // 아이템 이름 → 초성 문자열 (예: "생명의 반지" → "ㅅㅁㅇㅢㅂㅈ")
    private string GetInitialsFromName(string source)
    {
        if (string.IsNullOrEmpty(source))
            return string.Empty;

        StringBuilder sb = new StringBuilder();

        foreach (char ch in source)
        {
            // 한글 완성형: AC00 ~ D7A3
            if (ch >= 0xAC00 && ch <= 0xD7A3)
            {
                int unicode = ch - 0xAC00;
                int initialIndex = unicode / (21 * 28);
                sb.Append(InitialConsonants[initialIndex]);
            }
            // 나머지(공백, 영어 등)는 그냥 무시
        }

        return sb.ToString();
    }

    // 검색어 → 초성 패턴 (예: "생반" → "ㅅㅂ", "ㅅ" → "ㅅ")
    private string GetInitialPatternFromFilter(string filter)
    {
        if (string.IsNullOrEmpty(filter))
            return string.Empty;

        StringBuilder sb = new StringBuilder();

        foreach (char ch in filter)
        {
            // 완성형 한글이면 초성 뽑기
            if (ch >= 0xAC00 && ch <= 0xD7A3)
            {
                int unicode = ch - 0xAC00;
                int initialIndex = unicode / (21 * 28);
                sb.Append(InitialConsonants[initialIndex]);
            }
            // 자모(ㄱ~ㅎ)면 그대로 사용
            else if (ch >= 0x3131 && ch <= 0x314E)
            {
                sb.Append(ch);
            }
            // 그 외(숫자, 영어 등)는 무시
        }

        return sb.ToString();
    }


    /// --- 제작 슬롯 생성 --- ///
    public void CreateItemSlots()
    {
        CreateItemSlots(string.Empty);
    }

    private void CreateItemSlots(string filter)
    {
        // 기존 슬롯 전부 삭제
        foreach (Transform child in contentPanel)
            Destroy(child.gameObject);

        string lowerFilter = string.IsNullOrEmpty(filter) ? string.Empty : filter.ToLower();
        string initialFilter = GetInitialPatternFromFilter(filter);   // 검색어의 초성 패턴

        for (int i = 0; i < craftableItems.Count; i++)
        {
            Item item = craftableItems[i];
            Item material = materialItems[i];

            // 검색어가 있을 때만 필터링
            if (!string.IsNullOrEmpty(lowerFilter))
            {
                string nameLower = item.itemName.ToLower();
                string nameInitials = GetInitialsFromName(item.itemName);

                bool nameMatch = nameLower.Contains(lowerFilter);    // "반지", "체력" 등 일반 검색
                bool initialMatch = false;

                // 초성 패턴이 있을 때만 초성 비교
                if (!string.IsNullOrEmpty(initialFilter))
                {
                    initialMatch = nameInitials.Contains(initialFilter);
                }

                if (!nameMatch && !initialMatch)
                    continue; // 둘 다 아니면 스킵
            }

            GameObject slot = Instantiate(itemSlotPrefab, contentPanel);
            SetSlotInfo(slot, item, material, i);
        }
    }

    private void SetSlotInfo(GameObject slot, Item item, Item material, int index)
    {
        slot.transform.Find("ItemNameTxt").GetComponent<TextMeshProUGUI>().text = item.itemName;
        slot.transform.Find("ItemIcon").GetComponent<Image>().sprite = item.itemImage;              
        slot.transform.Find("MaterialIcon").GetComponent<Image>().sprite = material.itemImage; 

        TextMeshProUGUI matAmountTxt = slot.transform.Find("MaterialAmountTxt").GetComponent<TextMeshProUGUI>();    
        TextMeshProUGUI coinAmountTxt = slot.transform.Find("CoinAmountTxt").GetComponent<TextMeshProUGUI>();   

        // 아이템별 제작 필요 수량 설정
        if (item.itemCode == "200_01_01")   
        {
            matAmountTxt.text = "x 3";
            coinAmountTxt.text = "x 100"; 
        }
        else  
        {
            matAmountTxt.text = "x 1";  
            coinAmountTxt.text = "x 50";   
        }
        
        slot.GetComponent<Button>().onClick.AddListener(() => OnItemClicked(item, index)); 
    }

    // 검색 결과에 따라 제작 슬롯 생성
    private void OnSearchValueChanged(string searchText)
    {
        // 양쪽 공백 제거
        searchText = searchText.Trim();

        // 아무 것도 없으면 전체 다시 생성
        if (string.IsNullOrEmpty(searchText))
        {
            CreateItemSlots();
        }
        else
        {
            CreateItemSlots(searchText);
        }

        // 검색할 때는 선택 초기화 / 제작 패널 닫기
        selectedIndex = -1;
        craftPanel.SetActive(false);
    }


    /// --- 아이템 클릭 시 제작창 표시 --- ///
    private void OnItemClicked(Item item, int index)
    {
        selectedIndex = index; 
        craftPanel.SetActive(true);

        itemImage.sprite = item.itemImage;
        materialImage.sprite = materialItems[index].itemImage;

        // 스탯 표시
        statTxt.text = string.Join("\n", item.stats);

        // 현재 재료 및 재화 표시
        haveItemTxt.text = materialItems[index].count.ToString();  
        haveCoinTxt.text = itemManager.coin.ToString();

        // 필요 수량 계산
        bool isHpPotion = item.itemCode == "200_01_01";
        needItemTxt.text = isHpPotion ? "3" : "1";
        needCoinTxt.text = isHpPotion ? "100" : "50";

        // 버튼 이벤트 설정
        Button craftBtn = craftPanel.transform.Find("CraftBtn").GetComponent<Button>();
        craftBtn.onClick.RemoveAllListeners();
        craftBtn.onClick.AddListener(() => TryCraft(item, index));
    }


    /// --- 제작 시도 및 검증 --- ///
    private void TryCraft(Item item, int index)
    {
        int haveItems = int.Parse(haveItemTxt.text);
        int needItems = int.Parse(needItemTxt.text);
        int haveCoins = int.Parse(haveCoinTxt.text);
        int needCoins = int.Parse(needCoinTxt.text);

        if (haveItems >= needItems && haveCoins >= needCoins)
            CraftItem(item, index, needItems, needCoins);
        else
            StartCoroutine(ShowTemporaryMsg(cantCraftMsg, 0.5f));
    }


    /// --- 제작 처리 --- ///
    private void CraftItem(Item item, int index, int requiredItem, int requiredCoin)
    {
        materialItems[index].count -= requiredItem;
        itemManager.coin -= requiredCoin;

        itemManager.AddItem(item.itemCode, 1);
        UpdateUIAfterCraft(index);
    }

    private void UpdateUIAfterCraft(int index)
    {
        haveItemTxt.text = materialItems[index].count.ToString();
        haveCoinTxt.text = itemManager.coin.ToString();
    }

    private IEnumerator ShowTemporaryMsg(GameObject obj, float duration)
    {
        obj.SetActive(true);
        yield return new WaitForSeconds(duration);
        obj.SetActive(false);
    }
}