본문 바로가기
Unity

Unity(5) - Inventory & Crafting

by Srff5123 2026. 3. 5.
728x90

저번 오류를 조금 뒤로하고 인벤토리를 먼저 만들고자 한다.

마인크래프트처럼 대강 이런식으로 구현을 해볼 것이다.

 

각 기능은 전체를 총괄하는 InventoryPanel 안에 각 기능에 맞는 Panel들을 만들어 따로 관리할 것이다.

변화가 일어날떄마다 인벤토리 전체를 갱신하는 것은 매우 비효율 이기 때문 , 그리고 따로 관리하는게 유지보수에 편함

일단 Item이 칸에 들어가 표시를 해줄 UI먼저 만들어 주자

Canvas안에 UI_Slot을 만들어 Icon, Count, IconHighlight를 넣어줄것이다. 

이런느낌으로 만들어서 프리팹으로 저장한다.

프리팹 -> 재사용 가능한 오브젝트를 뜻한다.

프로젝트에 Prefabs폴더와 그 안에 UI 폴더를 만들어 방금 만든 UI_Slot을 넣어준다.

 

다음 이제 해당 이미지와 텍스트에 들어가줄 코드를 작성해준다.

Script로 UItemSlotView를 만들어준다.

드래그 드롭의 기능과 클릭 기능을 통해 아이템의 이동 또한 구현해준다.

 

using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class UIItemSlotView : MonoBehaviour,
    IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler,
    IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler
    // 클릭, 마우스 터치, 드래그 드롭을 위한 인터페이스
{
    [Header("Refs")]
    [SerializeField] private Image iconImage; // 아이템 이미지
    [SerializeField] private TMP_Text countText; // 아이템 개수
    [SerializeField] private GameObject highlightObj; // 선택 또는 호버 상태 시 하이라이트

    private InventoryUIController owner; // 동작 컨트롤 담당
    public SlotGroup group; // 인벤토리인지 핫바인지 제작패널인지 확인을 위해 그룹으로 구분
    public int index; // 해당 그룹에서 몇 번째 슬롯인지 확인

    private bool hovered; // 호버 상태 확인
    private bool selected; // 선택 확인

    public void Bind(InventoryUIController owner, SlotGroup group, int index)
        // 그룹 등록
    {
        this.owner = owner;
        this.group = group;
        this.index = index;
    }

    public void Set(ItemId id, int count, Sprite icon)
        // 현재 슬롯 ui에 반영
    {
        bool empty = (id == ItemId.None) || (count <= 0) || (icon == null);

        if (iconImage != null) 
        {
            iconImage.sprite = icon; // 아이콘 표시
            iconImage.enabled = !empty; // 빈슬롯이면 이미지 표시 안함
        }

        if (countText != null)
        {
            bool showCount = !empty && count > 1;
            countText.gameObject.SetActive(showCount);
            if (showCount) countText.text = count.ToString();
            // 스택이 있을 경우에만 1이상일 때만 텍스트 표시 1개면 굳이 
        }
    }

    public void SetSelected(bool on)
    {
        // 선택 확인 하이라이트 on
        selected = on;
        UpdateHighlight();
    }

    private void UpdateHighlight()
    {
        if (highlightObj != null)
            highlightObj.SetActive(selected || hovered);
        // 선택 또는 호버 상태일 경우 업데이트
    }

    // 클릭 = 선택(핫바 선택)
    public void OnPointerClick(PointerEventData eventData)
    {
        owner?.OnSlotClickSelect(group, index);
        // 클릭 이벤트 uicontrol로 보내줌
    }

    public void OnPointerEnter(PointerEventData eventData)
    {
        hovered = true;
        UpdateHighlight();
    }
    // 위아래 하이라이트 업데이트 키고 끄고
    public void OnPointerExit(PointerEventData eventData)
    {
        hovered = false;
        UpdateHighlight();
    }

    // 드래그 시작
    public void OnBeginDrag(PointerEventData eventData)
    {
        owner?.OnSlotBeginDrag(group, index, eventData);
    }

    // 드래그 중인 상태
    public void OnDrag(PointerEventData eventData)
    {
        owner?.OnSlotDrag(eventData);
    }

    // 드래그 중단
    public void OnEndDrag(PointerEventData eventData)
    {
        owner?.OnSlotEndDrag(eventData);
    }

    // 드롭
    public void OnDrop(PointerEventData eventData)
    {
        owner?.OnSlotDrop(group, index, eventData);
    }

    // 드래그 드롭도 uicon으로 보내줌
}

 

완료 하였으면 UI_slot에 컴포넌트를 추가해주고 icon, count, highlight를 드래그해서 넣어준다.

 

이제 다음 인벤토리 3x9의 크기로 만들어 줄 것이다. 

Hierarchy 에서 UI -> Panel // InventoryPanel로 만들어주고 그 안에 CreatEmpty로 Grid_Inventory를 만들어준다.

다음 해당 부분에 컴포넌트  Grid Layout Group과 Content Size Filter 추가하고 위의 사진처럼 기본값 세팅

 

일일이 배치하기는 귀찮기도하고 비효율적이니 코드를 통해 3X9를 자동으로 생성해주자

InventoryGridBuilder Script를 만들어주고

using UnityEngine;

public class InventoryGridBuilder : MonoBehaviour
{
    [Header("Refs")]
    [SerializeField] private Transform gridRoot;   // 인벤토리에 붙이기
    [SerializeField] private GameObject slotPrefab; // ui프리팹

    [Header("Grid Size")]
    [SerializeField] private int columns = 9; // 인벤토리 패널 9x3으로 만들기
    [SerializeField] private int rows = 3;

    [Header("Build Options")]
    [SerializeField] private bool buildOnAwake = true; // 시작할때 슬롯 깔아주기

    private void Awake()
    {
        if (buildOnAwake) // 한번만 호출해서 시작 깔아주기 초기 세팅
            
            Rebuild();
    }

    [ContextMenu("Rebuild")]
    public void Rebuild()
    {
        if (gridRoot == null || slotPrefab == null)
        {
            Debug.LogError("[InventoryGridBuilder] gridRoot or slotPrefab is missing.");
            return;
            // inspector에서 연결이 안되어 있을 경우 터지는거 방지
        }

        for (int i = gridRoot.childCount - 1; i >= 0; i--)
        {
            Destroy(gridRoot.GetChild(i).gameObject);
        }
        // 이미 존재하는 슬롯 삭제 후 다시 깔아주기 재생성

        // 2) 슬롯 생성
        int total = columns * rows;
    
        for (int i = 0; i < total; i++) //슬롯 개수 만큼 반복
        {
            var go = Instantiate(slotPrefab, gridRoot); // uislot 프리팹 복제해서 슬롯깔아줌
            go.name = $"Slot_{i:D2}";
        }

        Debug.Log($"[InventoryGridBuilder] Built {total} slots.");
        // 잘만들어졌는지 확인
    }
}

 

다음 Hierarchy에 있는 InventoryPanel에 방금만든 코드를 컴포넌트로 추가해주고

실행해보면 잘 만들어지고 있는 것을 확인할 수 있다

 

이제 만들어진 것을 배치하고 아이템을 받아줄 코드를 작성 

InventoryPanel Script를 만들어준다.

 

using UnityEngine;

public class PlayerInventory : MonoBehaviour
{
    [SerializeField] private int columns = 9;
    [SerializeField] private int rows = 3;

    [SerializeField] private ItemStack[] slots;

    public int Count => slots != null ? slots.Length : 0;

    void Awake()
    {
        int total = columns * rows;
        if (slots == null || slots.Length != total)
            slots = new ItemStack[total];
        // 슬롯 배열 초기화, 크기 보정
    }

    public ItemStack Get(int index)
    {
        if (slots == null || index < 0 || index >= slots.Length) return default;
        return slots[index];
        // 슬롯 데이터 받기
    }

    public void Set(int index, ItemStack s)
    {
        if (slots == null || index < 0 || index >= slots.Length) return;
        slots[index] = s;
        // 슬롯 데이터 주기
    }

    public bool Add(ItemId id, int count)
    {
        // 아이템 id를 받아 개수 만큼 넣어줌
        if (count <= 0) return true;
        if (id == ItemId.None) return false;

        int max = ItemDb.MaxStack(id); // 블록은 64, 도구는 1로 최대 수량

        // id를 통해 같은 아이템인지 확인 후 합쳐주기 스택 쌓기
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].id == id && slots[i].count < max)
            {
                int can = Mathf.Min(max - slots[i].count, count);
                slots[i].count += can;
                count -= can;
                if (count <= 0) return true;
            }
        }

        // 빈칸이 있다면 아이템 새롭게 넣어주기(아이템 가득 찬 경우)
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].IsEmpty)
            {
                int put = Mathf.Min(max, count);
                slots[i] = new ItemStack(id, put);
                count -= put;
                if (count <= 0) return true;
            }
        }

        return false;
        // 공간 부족하면 실패
    }
}

PlayerInventory

 

 

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

public class InventoryUIController : MonoBehaviour
{
    [Header("Models")]
    [SerializeField] private PlayerInventory playerInv;
    [SerializeField] private HotbarInventory hotbar;
    [SerializeField] private CraftingInventory craftingInv;
    [SerializeField] private ItemIconDb iconDb;
    // 실제 데이터 모델

    [Header("UI Parents")]
    [SerializeField] private Transform inventoryGridParent; // 인벤토리 슬롯 부모
    [SerializeField] private Transform hotbarGridParent; // 핫바 슬롯
    [SerializeField] private Transform hotbarGridParentInInv; // 인벤토리 내 핫바
    [SerializeField] private Transform craftingGridParent; // 2x2 제작대
    [SerializeField] private UIItemSlotView craftingResult; // 제작 결과 슬롯
    // 연결될 자식들

    [Header("Drag Icon (Optional)")]
    [SerializeField] private Image dragIcon; // 드래그 드롭 시 마우스를 따라다닐 아이콘

    [Header("InputNumber")]
    [SerializeField] private StackSplitPopup splitPopup; // 스택 분할/ 이동

    private readonly List<UIItemSlotView> invViews = new List<UIItemSlotView>();
    private readonly List<UIItemSlotView> hotbarViews = new List<UIItemSlotView>();
    private readonly List<UIItemSlotView> hotbarViewsInInv = new List<UIItemSlotView>();
    private readonly List<UIItemSlotView> craftViews = new List<UIItemSlotView>();
    // ItemSlotView를 미리 리스트로 캐싱하여 빠른 갱신

    private readonly int[] craftConsume = new int[4]; // 2x2 제작의 칸

    private bool hasPick; // 손에 들고있는 상태인지 확인
    private SlotGroup pickGroup; // 어느 슬롯에서 잡았는지 확인
    private int pickIndex; 

    private bool pendingSplit;
    private SlotGroup pendingToGroup; // 어디로 옮길지 저징
    private int pendingToIndex; // 실제 이동

    private ItemStack craftResultStack; // 제작된 결과 아이템 . 개수

    private void Start()
    {
        CacheAndBindAllViews(); // 모든 슬롯뷰를 찾아 group에 연결
        RefreshAll(); // ui갱신

        if (dragIcon != null) dragIcon.enabled = false;
    }

    private void CacheAndBindAllViews()
    {
        invViews.Clear();
        hotbarViews.Clear();
        hotbarViewsInInv.Clear();
        craftViews.Clear();
        // 나중 재호출 위해 clear

        if (inventoryGridParent != null)
        {
            inventoryGridParent.GetComponentsInChildren(invViews);
            for (int i = 0; i < invViews.Count; i++)
                invViews[i].Bind(this, SlotGroup.Inventory, i);
        }

        if (hotbarGridParent != null)
        {
            hotbarGridParent.GetComponentsInChildren(hotbarViews);
            for (int i = 0; i < hotbarViews.Count; i++)
                hotbarViews[i].Bind(this, SlotGroup.Hotbar, i);
        }

        if (hotbarGridParentInInv != null)
        {
            hotbarGridParentInInv.GetComponentsInChildren(hotbarViewsInInv);
            for (int i = 0; i < hotbarViewsInInv.Count; i++)
                hotbarViewsInInv[i].Bind(this, SlotGroup.Hotbar, i);
        }

        if (craftingGridParent != null)
        {
            craftingGridParent.GetComponentsInChildren(craftViews);
            for (int i = 0; i < craftViews.Count; i++)
                craftViews[i].Bind(this, SlotGroup.CraftingInput, i);
        }

        if (craftingResult != null)
            craftingResult.Bind(this, SlotGroup.CraftingResult, 0);

        // UIItemSlotView를 가져와  bind 시킴
    }

    public void RefreshAll()
    {
        RefreshInventory();
        RefreshHotbar();
        RefreshCrafting();
        RefreshHighlights();
        // 인벤토리 ui 갱신
    }

    private Sprite Icon(ItemId id)
    {
        return (iconDb != null) ? iconDb.Get(id) : null;
    }

    private void RefreshInventory()
    {
        if (playerInv == null) return;

        for (int i = 0; i < invViews.Count; i++)
        {
            ItemStack s = playerInv.Get(i);
            invViews[i].Set(s.id, s.count, Icon(s.id));
        }
        // 인벤토리 갱신
    }

    private void RefreshHotbar()
    {
        if (hotbar == null) return;

        for (int i = 0; i < hotbarViews.Count; i++)
        {
            ItemStack s = hotbar.GetSlot(i);
            hotbarViews[i].Set(s.id, s.count, Icon(s.id));
        }

        for (int i = 0; i < hotbarViewsInInv.Count; i++)
        {
            ItemStack s = hotbar.GetSlot(i);
            hotbarViewsInInv[i].Set(s.id, s.count, Icon(s.id));
        }
        // 핫바 갱신, 핫바가 인벤토리 내에도 있기에 두번
    }

    private void RefreshCrafting()
    {
        if (craftingInv != null)
        {
            for (int i = 0; i < craftViews.Count; i++)
            {
                ItemStack s = craftingInv.Get(i);
                craftViews[i].Set(s.id, s.count, Icon(s.id));
            }
        }
        //제작대 갱신

        craftResultStack = EvaluateCraftResult(); // 제작 레시피에 따라 결과 계산
        if (craftingResult != null)
            craftingResult.Set(craftResultStack.id, craftResultStack.count, Icon(craftResultStack.id));
        
    }

    private void RefreshHighlights()
    {
        for (int i = 0; i < invViews.Count; i++)
            invViews[i].SetSelected(hasPick && pickGroup == SlotGroup.Inventory && pickIndex == i);

        for (int i = 0; i < hotbarViews.Count; i++)
        {
            bool selectedHotbar = (hotbar != null && hotbar.SelectedIndex == i);
            bool picking = (hasPick && pickGroup == SlotGroup.Hotbar && pickIndex == i);
            hotbarViews[i].SetSelected(selectedHotbar || picking);
        }

        for (int i = 0; i < hotbarViewsInInv.Count; i++)
        {
            bool selectedHotbar = (hotbar != null && hotbar.SelectedIndex == i);
            bool picking = (hasPick && pickGroup == SlotGroup.Hotbar && pickIndex == i);
            hotbarViewsInInv[i].SetSelected(selectedHotbar || picking);
        }

        // 선택 or 호버상태인 경우 selected 하이라이트 표시 

        for (int i = 0; i < craftViews.Count; i++)
            craftViews[i].SetSelected(hasPick && pickGroup == SlotGroup.CraftingInput && pickIndex == i);

        if (craftingResult != null)
            craftingResult.SetSelected(false);
        
    }


    public void OnSlotClickSelect(SlotGroup group, int index)
    {
        if (group == SlotGroup.CraftingResult)
        {
            TryCraft(); // 결과 슬롯은 제작 시도 이후 리턴
            return;
        }

        // Hotbar 클릭 시 맵에 블록 설치 대상 변경
        if (group == SlotGroup.Hotbar && hotbar != null)
        {
            hotbar.Select(index);
            RefreshHighlights();
        }
        
    }

    public void OnSlotBeginDrag(SlotGroup group, int index, PointerEventData eventData)
    {
        if (group == SlotGroup.CraftingResult) return;
        // 결과 슬롯은 드래그 불가 

            
        if (!hasPick) // 드래그 체크
            OnSlotClicked(group, index);

        UpdateDragIcon(eventData);
    }

    public void OnSlotDrag(PointerEventData eventData)
    {
        UpdateDragIcon(eventData); // 드래그 중 아이콘 위치만 따라다니도록
    }

    public void OnSlotDrop(SlotGroup toGroup, int toIndex, PointerEventData eventData)
    {
        if (!hasPick) return;
        if (toGroup == SlotGroup.CraftingResult) return;

        ItemStack from = GetStack(pickGroup, pickIndex);
        ItemStack to = GetStack(toGroup, toIndex);
        // 아이템 스택 읽어주기
   
        bool targetAcceptsSplit =
            to.IsEmpty ||
            (to.id == from.id && to.count < ItemDb.MaxStack(to.id));

        if (splitPopup != null && from.count >= 2 && targetAcceptsSplit)
        {
            
            int maxMove = Mathf.Min(from.count, 64);

            if (!to.IsEmpty && to.id == from.id)
            {
                int space = ItemDb.MaxStack(to.id) - to.count;
                maxMove = Mathf.Min(maxMove, space);
            }

            // maxMove가 0이면 옮길 수 없으니 그냥 return
            if (maxMove <= 0) return;

            pendingSplit = true;
            pendingToGroup = toGroup;
            pendingToIndex = toIndex;

            // 팝업 띄우고 
            splitPopup.Show(
                maxMove,
                onConfirm: (amount) =>
                {
                    pendingSplit = false;
                    MovePartial(pickGroup, pickIndex, pendingToGroup, pendingToIndex, amount);
                    hasPick = false;
                    RefreshAll();
                },
                onCancel: () =>
                {
                    pendingSplit = false;
                    hasPick = false;
                    RefreshHighlights();
                }
            );
            // 버튼 선택에 따라 취소, 확인, 맥스 구분
            // 남은 수량은 자동 복귀
            return;
        }

        
        OnSlotClicked(toGroup, toIndex);
    }

    public void OnSlotEndDrag(PointerEventData eventData)
    {
        EndDragIcon(); // 드래그 표시 끄기
    }

    private void UpdateDragIcon(PointerEventData eventData)
    {
        if (dragIcon == null) return;
        if (!hasPick) { dragIcon.enabled = false; return; }

        ItemStack s = GetStack(pickGroup, pickIndex);
        Sprite sp = Icon(s.id);
        if (s.IsEmpty || sp == null) { dragIcon.enabled = false; return; }

        dragIcon.enabled = true;
        dragIcon.sprite = sp;
        dragIcon.transform.position = eventData.position;
        // 드래그 아이콘 표시, 마우스 따라가기
    }

    private void EndDragIcon()
    {
        if (dragIcon != null) dragIcon.enabled = false;
    }

    public void OnSlotClicked(SlotGroup group, int index)
    {
        // 클릭 이벤트

        if (group == SlotGroup.CraftingResult) 
        {
            // 결과 슬롯 레시피에 따라 제작
            TryCraft();
            return;
        }

        if (!hasPick)
        {
            ItemStack s = GetStack(group, index);
            if (s.IsEmpty) return;

            hasPick = true;
            pickGroup = group;
            pickIndex = index;
            RefreshHighlights();
            return;
            // 슬롯이 비어 있으면 return
        }

        if (pickGroup == group && pickIndex == index)
        {
            hasPick = false;
            RefreshHighlights();
            return;
            // 같은 슬롯 다시 누르는 경우 선택됌 동작 취소
        }

        TryMoveOrSwap(pickGroup, pickIndex, group, index);

        hasPick = false;
        RefreshAll();
    }

    private void TryMoveOrSwap(SlotGroup fromG, int fromI, SlotGroup toG, int toI)
    {
        ItemStack from = GetStack(fromG, fromI);
        ItemStack to = GetStack(toG, toI);
        // 아이템 읽기

        // to 비어 있다면 그냥 이동, id가 같다면 스택 합치기

        if (from.IsEmpty) return;


        if (to.IsEmpty)
        {
            SetStack(toG, toI, from);
            SetStack(fromG, fromI, new ItemStack(ItemId.None, 0));
            return;
        }

        if (from.id == to.id)
        {
            int max = ItemDb.MaxStack(from.id);
            int canPut = Mathf.Max(0, max - to.count);
            if (canPut <= 0) return;

            int move = Mathf.Min(canPut, from.count);
            to.count += move;
            from.count -= move;

            SetStack(toG, toI, to);
            SetStack(fromG, fromI, (from.count > 0) ? from : new ItemStack(ItemId.None, 0));
            return;
        }

        SetStack(toG, toI, from);
        SetStack(fromG, fromI, to);
        // 아이템이 서로 다르다면 서로 위치 바꿔줌
    }

    private ItemStack GetStack(SlotGroup g, int i)
    {
        switch (g)
        {
            case SlotGroup.Inventory:
                return (playerInv != null) ? playerInv.Get(i) : default(ItemStack);
            case SlotGroup.Hotbar:
                return (hotbar != null) ? hotbar.GetSlot(i) : default(ItemStack);
            case SlotGroup.CraftingInput:
                return (craftingInv != null) ? craftingInv.Get(i) : default(ItemStack);
            default:
                return default(ItemStack);
        }
        // SlotGroup에 따라 Switch를 통해 통일된 접근 만들어줌
    }

    private void SetStack(SlotGroup g, int i, ItemStack s)
    {
        switch (g)
        {
            case SlotGroup.Inventory:
                if (playerInv != null) playerInv.Set(i, s);
                break;
            case SlotGroup.Hotbar:
                if (hotbar != null) hotbar.SetSlot(i, s.id, s.count);
                break;
            case SlotGroup.CraftingInput:
                if (craftingInv != null) craftingInv.Set(i, s);
                break;
        }
    }

    private ItemStack EvaluateCraftResult()
    {
        // 아이템 제작 레시피 등록

        for (int i = 0; i < craftConsume.Length; i++) craftConsume[i] = 0;

        if (craftingInv == null || craftingInv.Count < 4)
            return new ItemStack(ItemId.None, 0);

        // 2x2 입력을 a b / c d 로 읽음 (인덱스 0,1 / 2,3)
        ItemStack a = craftingInv.Get(0);
        ItemStack b = craftingInv.Get(1);
        ItemStack c = craftingInv.Get(2);
        ItemStack d = craftingInv.Get(3);

        // ---------- 레시피 돌 2개 세로로 놓으면 막대 4개 ----------
        // 왼쪽 컬럼: a,c가 Stone이고 b,d는 비어있음
        bool stickLeft =
            a.id == ItemId.Stone && a.count >= 1 &&
            c.id == ItemId.Stone && c.count >= 1 &&
            b.IsEmpty && d.IsEmpty;

        // 오른쪽 컬럼: b,d가 Stone이고 a,c는 비어있음
        bool stickRight =
            b.id == ItemId.Stone && b.count >= 1 &&
            d.id == ItemId.Stone && d.count >= 1 &&
            a.IsEmpty && c.IsEmpty;

        if (stickLeft)
        {
            craftConsume[0] = 1;
            craftConsume[2] = 1;
            return new ItemStack(ItemId.Stick, 4);
        }

        if (stickRight)
        {
            craftConsume[1] = 1;
            craftConsume[3] = 1;
            return new ItemStack(ItemId.Stick, 4);
        }

        // ---------- 레시피 2) 돌 4개(2x2 꽉) -> 철 1개 ----------
        bool iron =
            a.id == ItemId.Stone && a.count >= 1 &&
            b.id == ItemId.Stone && b.count >= 1 &&
            c.id == ItemId.Stone && c.count >= 1 &&
            d.id == ItemId.Stone && d.count >= 1;

        if (iron)
        {
            craftConsume[0] = craftConsume[1] = craftConsume[2] = craftConsume[3] = 1;
            return new ItemStack(ItemId.Iron, 1);
        }

        // ---------- 레시피 3) 흙 or 잔디 4개(2x2 꽉) -> 제작대 1개 ----------
        bool IsDirtOrGrass(ItemStack s)
            => (s.id == ItemId.Dirt || s.id == ItemId.Grass) && s.count >= 1;

        bool craftingTable =
            IsDirtOrGrass(a) &&
            IsDirtOrGrass(b) &&
            IsDirtOrGrass(c) &&
            IsDirtOrGrass(d);

        if (craftingTable) // 레시피에 따라 결과물 만들어주고 리턴
        {
            craftConsume[0] = craftConsume[1] = craftConsume[2] = craftConsume[3] = 1;
            return new ItemStack(ItemId.CraftingTable, 1);
        }

        return new ItemStack(ItemId.None, 0);
    }

    private void TryCraft()
    {
        // 현재 결과가 유효한지 확인
        if (craftResultStack.IsEmpty) return;
        if (craftingInv == null) return;

        // 1) 결과를 인벤 또는 핫바에 추가
        bool added = false;
        if (playerInv != null) added = playerInv.Add(craftResultStack.id, craftResultStack.count);
        if (!added && hotbar != null) added = hotbar.Add(craftResultStack.id, craftResultStack.count);

        if (!added)
            return; // 넣을 공간이 없으면 아무것도 소비하지 않음

        // 재료 소비
        for (int i = 0; i < 4; i++)
        {
            int consume = craftConsume[i];
            if (consume <= 0) continue;

            ItemStack s = craftingInv.Get(i);
            if (s.IsEmpty) continue; 

            s.count -= consume;
            if (s.count <= 0) s = new ItemStack(ItemId.None, 0);

            craftingInv.Set(i, s);
            // 성공 한경우 입력칸에서 count 감소
        }

        // UI 갱신
        RefreshAll();
    }

    private void MovePartial(SlotGroup fromG, int fromI, SlotGroup toG, int toI, int amount)
    {
        // 분할 이동

        if (amount <= 0) return;

        ItemStack from = GetStack(fromG, fromI);
        ItemStack to = GetStack(toG, toI);
        if (from.IsEmpty) return;

        amount = Mathf.Clamp(amount, 1, 64);
        amount = Mathf.Min(amount, from.count);

        // 타겟이 비어있으면 amount만 옮기기
        if (to.IsEmpty)
        {
            SetStack(toG, toI, new ItemStack(from.id, amount));

            from.count -= amount;
            if (from.count <= 0) from = new ItemStack(ItemId.None, 0);
            SetStack(fromG, fromI, from);
            return;
        }

        // 타겟이 같은 아이템일 때만 합치기 가능
        if (to.id == from.id)
        {
            int max = ItemDb.MaxStack(to.id);
            int space = Mathf.Max(0, max - to.count);
            if (space <= 0) return;

            int move = Mathf.Min(amount, space);
            to.count += move;
            from.count -= move;

            SetStack(toG, toI, to);
            SetStack(fromG, fromI, from.count > 0 ? from : new ItemStack(ItemId.None, 0));
            return;
        }

        // 타겟이 다른 아이템이면 부분이동 취소
    }

}

InventoryContoller( 가장 중요한 부분) - 인벤토리 내의 작업을 해줌

 

using UnityEngine;

[System.Serializable]
public struct ItemStack
{
    public ItemId id;
    // 어떤 아이템인지 id 확인
    public int count;
    // 해당 아이템의 개수

    public bool IsEmpty => id == ItemId.None || count <= 0;
    // 비어있는 슬롯 판정, id가 none이거나 수량이 0인 경우

    public ItemStack(ItemId id, int count)
    {
        // itemstack 생성
        this.id = id;
        this.count = count;
    }
}

public class HotbarInventory : MonoBehaviour
{
    [Header("Hotbar")]
    [SerializeField] private int slotCount = 3; // 슬롯 기본 3개, 나중 확실해지면 9개로 늘릴예정

    [SerializeField] private ItemStack[] slots; // 슬롯 데이터, 각 칸에 id와 count 넣어줌

    [SerializeField] private int selectedIndex = 0; // 현재 선택된 슬롯 번호

    public int SlotCount => slots != null ? slots.Length : 0;
    // 현재 슬롯 개수가 null이면 0
    public int SelectedIndex => selectedIndex;
    // 현재 선택 인덱스를 외부에서 읽기만 가능

    public ItemStack Selected => GetSlot(selectedIndex);
    // 현재 선택된 슬롯의 ItemStack을 반환해줌

    void Awake()
    {
        if (slots == null || slots.Length != slotCount)
            slots = new ItemStack[slotCount];
        // 슬롯에 배열이 없거나, 길이가 count와 다르다면 새로 생성해줌 -> 안정성
    }

    public void Select(int index)
    {
        if (slots == null || slots.Length == 0) return;
        // 슬롯없으면 선택 불가
        selectedIndex = Mathf.Clamp(index, 0, slots.Length - 1);
        // 인덱스 범위를 벗어나도 clamp로 제어
    }

    public ItemStack GetSlot(int index)
    {
        if (slots == null || index < 0 || index >= slots.Length) return default;
        // 슬롯의 유효성 검사, 배열이 없거나 인덱스 범위 밖이면 default
        return slots[index];
        // 해당 슬롯 데이터 받기
    }

    public void SetSlot(int index, ItemId id, int count)
    {
        // 특정 슬롯에 아이템 강제 세팅, 아이템이 제대로 들어가나 테스트 확인
        if (slots == null || index < 0 || index >= slots.Length) return;
        slots[index] = new ItemStack(id, count);
        // 새로운 스택으로 덮어쓰기
    }

    public bool ConsumeSelected(int count)
    {
        // 설치 하는 경우 재료 1개 줄이기
        if (count <= 0) return true;
        // 0 이하 소비는 넘기기 의미 없음

        if (slots == null || selectedIndex < 0 || selectedIndex >= slots.Length)
            return false;
        // 선택 인덱스 유효한지 검사

        var s = slots[selectedIndex]; // 현재 선택 슬롯 가져오기
        if (s.IsEmpty) return false; // 비어있는 상태면 소비 불가
        if (s.count < count) return false; // 소비할 개수가 가진 개수가 더 적으면 실패

        s.count -= count; // 수량 차감
        if (s.count <= 0) s = new ItemStack(ItemId.None, 0); // 차감 후 0 이하면 빈슬롯으로 변경
        slots[selectedIndex] = s; // 다시 배열에 반영 -> 복사본 수정했기 떄문에 갱신
        return true; // 성공
    }


    public bool Add(ItemId id, int count)
    {
        // 아이템 추가
        if (count <= 0) return true;
        if (id == ItemId.None) return false;
        // none 아이템은 인벤에 넣을 수 없음

        int max = ItemDb.MaxStack(id);
        // 아이템의 최대 수량

        // 1) 같은 아이템이 있고 스택 여유가 있으면 먼저 채우기
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].id == id && slots[i].count < max)
            {
                // 같은 아이템이고 아직 max 미만이면 합칠 수 있음
                int can = Mathf.Min(max - slots[i].count, count);
                // 이번 슬롯에 넣을 수 있는 개수, 남은 공간, 남은 count 중 작은 값
                slots[i].count += can; 
                // 슬롯에 추가
                count -= can;
                // 남은 count 감소
                if (count <= 0) return true;
                // 다 넣었으면 성공 종료
            }
        }

        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].IsEmpty)
            {
                // 빈 슬롯이면 새로 스택 생성 가능
                int put = Mathf.Min(max, count); // 새 슬롯에 넣을 개수
                slots[i] = new ItemStack(id, put); // 새 스택 생성해서 넣기
                count -= put; // 남은 count 감소
                if (count <= 0) return true; // 다 넣었으면 성공 종료
            }
        }

        // 못 넣은 경우(핫바 꽉 찼음)
        return false;
    }
}

핫바관리할 Script만들기

다 하였으면 이제 ItemItemDb 아이콘 설정을 할건데 일단 아이콘 부터 구하자 

아이콘이 기본 적으로 png로 되어있는데 이를 프로젝트 파일에 넣어주고 Inspector창에서 Sprite로 바꾸어준다.

바꾼뒤 ItemIconDb_Asset에  아이템 개수 만큼 엔트리 추가해서 넣어줌

using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "DB/Item Icon DB")] // db를 애셋으로 만들 수 있게 함
public class ItemIconDb : ScriptableObject
{
    [System.Serializable]
    public struct Entry // 구조체를 통해 id와 아이콘 저장
    {
        public ItemId id;
        public Sprite icon;
    }

    [SerializeField] private List<Entry> entries = new();

    // 런타임 lookup 캐시
    private Dictionary<ItemId, Sprite> map;
    // 런타임 시 Dictionary 캐시, 갱신을 빠르게 하기 위해서

    private void OnEnable()
    {
        Rebuild();
    }

    // 에디터에서 entries 수정했을 때도 바로 반영되게(선택)
#if UNITY_EDITOR
    private void OnValidate()
    {
        Rebuild();
    }
#endif

    private void Rebuild()
    {
        map = new Dictionary<ItemId, Sprite>();
        // 딕셔너리 새로 생성

        foreach (var e in entries)
        {
            if (e.id == ItemId.None) continue;
            if (e.icon == null) continue;

            // 같은 id가 중복되면 마지막 값으로 덮어쓰기
            map[e.id] = e.icon;
        }
    }

    public Sprite Get(ItemId id)
    {
        if (id == ItemId.None) return null;
        if (map == null) Rebuild();

        return map.TryGetValue(id, out var sp) ? sp : null;
        // null이라면 리빌드해서 복구해주기
    }
}

 

HotBar 기존에 하나씩 배치해서 만든 3개의 HotBar도 Gird를 통해 코드로 자동 생성을 해준다.

Hierarchy 에서 아까 처럼 HotbarPanel 추가 하고 자식으로 CreateEmpty로 HotbarGrid 추가하고

InventoryGridBuild 컴포넌트 추가해서 재사용 해줌 그리고 만든 Hotbar를 InventoryPanel에도 추가해서

Inventory가 열렸을때 아이템 이동을 할때 사용할 핫바, 그리고 인벤토리가 꺼져있을 경우에 사용하는 핫바 두개를 만들어준다.

 

다음 내부 코드도 수정하여 준다. 

public enum SlotGroup : byte
{
    Inventory,
    Hotbar,
    CraftingInput,
    CraftingResult
}

 

중간점검 이제 이렇게 만들어짐

 

 

이제 크래프트, 캐릭터 뷰, 장비창, 정도 남았는데 그전에 인벤토리창을 끄고 키는것 그리고 블록을 부섰을 때 아이템이 제대로 인벤토리에 들어오는지 부터 해볼것이다.

 

using UnityEngine;

public class InventoryPanelToggle : MonoBehaviour
{
    [Header("UI")]
    [SerializeField] private GameObject inventoryPanel;
    [SerializeField] private InventoryUIController ui;     

    [Header("Key")]
    [SerializeField] private KeyCode toggleKey = KeyCode.I;

    [Header("Optional")]
    [SerializeField] private bool startOpened = false;

    void Awake()
    {
        if (inventoryPanel != null)
            inventoryPanel.SetActive(startOpened);
        // 게임 시작 시에는 인벤토리 패널 닫기
    }

    void Update()
    {
        if (Input.GetKeyDown(toggleKey))
        {
            Toggle(); // 키다운 통해 인벤토리 열어주기
        }
    }

    public void Toggle()
    {
        if (inventoryPanel == null) return;

        bool next = !inventoryPanel.activeSelf;
        inventoryPanel.SetActive(next);

        // 인벤토리 열때 UI 한번 갱신 아이콘,수량 최신화
        if (next && ui != null)
            ui.RefreshAll();
    }
}

 

 

I키를 통해 인벤토리 On Off를 하고

블럭이 채굴되었을떄 ItemID에 따라 인벤토리에 아이템이 들어오도록 설정을 해준다.

 

using UnityEngine;

public class CraftingInventory : MonoBehaviour
{
    [SerializeField] private ItemStack[] slots = new ItemStack[4]; // 제작대 2x2

    public int Count => slots != null ? slots.Length : 0; // 제작칸 확인

    void Awake()
    {
        if (slots == null || slots.Length != 4)
            slots = new ItemStack[4];
        // 비어 있거나 4칸이 아니면 4칸으로 재생성
    }

    public ItemStack Get(int index)
    {
        if (slots == null || index < 0 || index >= slots.Length) return default;
        return slots[index];
        // 아이템 값 받고 읽어주기, 
    }

    public void Set(int index, ItemStack s)
    {
        if (slots == null || index < 0 || index >= slots.Length) return;
        slots[index] = s;
        // 아이템 드롭애서 넣거나, 제작 후 재료 count 감소할 때 사용
    }

    public void ClearAll()
    {
        for (int i = 0; i < slots.Length; i++)
            slots[i] = new ItemStack(ItemId.None, 0);

        // 0으로 비워줌
    }
}

다음 Crafting Panel을 통해 아이템의 조합도 구현해줄것이기떄문에 2x2의 칸과 결과칸 1개를 만들어준다.

레시피는 아직 많은 아이템이 구현이 안되어있기때문에 간단하게 Stone두개면 막대기, 흙4개면 조합대 이런식으로 할 예정이다.

이제 hot바에 아이템을 옮기고 내가 누른 아이템 설치 및 핫바 아이템 이동

 

 

 

조합을 할 때 아이템이 여러개로 나누어져야 하는데 이 부분을 위해 아이템을 나누기 위한 UI와 코드 추가

StackSplitPopup UI와 스크립트만들어주고 아이템을 드래그 드롭, 이떄 드롭 시에 UI를 띄워주어서 아이템의 개수 , 수락, 취소, 맥스(자동 최대 수량)으로 

using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class StackSplitPopup : MonoBehaviour
{
    [Header("UI Refs")]
    [SerializeField] private GameObject root;          
    [SerializeField] private TMP_Text titleText;      
    [SerializeField] private TMP_InputField inputField;
    [SerializeField] private Button okButton;
    [SerializeField] private Button cancelButton;
    [SerializeField] private Button maxButton;        

    private Action<int> onConfirm;
    private Action onCancel;
    // 콜백
    // 수락 -> 최종 결정된 수량 전달, 취소 -> 취소 처리

    private int maxAllowed = 1;

    private void Awake()
    {
        if (okButton != null) okButton.onClick.AddListener(Confirm);
        if (cancelButton != null) cancelButton.onClick.AddListener(Cancel);
        if (maxButton != null) maxButton.onClick.AddListener(SetMax);
        // 버튼 이벤트 연결, 

        Hide(); // 숨겨둠, 옮길때 on
    }

    public void Show(int maxAllowed, Action<int> onConfirm, Action onCancel)
    {
        this.maxAllowed = Mathf.Clamp(maxAllowed, 1, 64);
        this.onConfirm = onConfirm;
        this.onCancel = onCancel;
        // 콜백과 최대 개수 저장

        if (titleText != null)
            titleText.text = $"옮길 개수를 입력하세요 (1 ~ {this.maxAllowed})";

        if (inputField != null)
        {
            inputField.text = this.maxAllowed.ToString(); // 기본값=최대
            inputField.contentType = TMP_InputField.ContentType.IntegerNumber;
            inputField.ActivateInputField();
            // 입력 필드 초기값 설정
        }

        if (root != null) root.SetActive(true);
        else gameObject.SetActive(true);
    }

    public void Hide()
    {
        if (root != null) root.SetActive(false);
        else gameObject.SetActive(false);
        // 팝업창 숨기기
    }

    private void SetMax()
    {
        if (inputField != null)
            inputField.text = maxAllowed.ToString();
        // 맥스값 
    }

    private void Confirm()
    {
        int v = maxAllowed;

        if (inputField != null)
        {
            // 입력 문자열 int 변환
            if (!int.TryParse(inputField.text, out v))
                v = maxAllowed;
            // 실패한 경우 맥스 값으로 처리
        }

        // 규칙: 1~64, 그리고 maxAllowed 이내
        v = Mathf.Clamp(v, 1, 64);
        v = Mathf.Min(v, maxAllowed);

        Hide();
        onConfirm?.Invoke(v);
    }

    private void Cancel()
    {
        Hide();
        onCancel?.Invoke();
    }
}

 

이렇게 다했으면 만든것들에 대해서 Hierachy와 Inspector에 추가

현재 만들어진 Hierachy 구조

 

실행 전 화면

 

실행 후 블럭 부수었을 경우 아이템 인벤토리에 들어오는거 확인

드래그 드롭 시 팝업 뜨고 숫자 패드 선택( 타이틀 깨진건 나중 수정) 

드래그를 통해 CraftPanel에 드롭 성공

남은 돌 하나도 옮겨서 새로운 아이템 막대기 4개 제작 성공 -> Result 칸 클릭시 인벤토리에 자동으로 들어옴

블럭만 64로 제한 두었기에 하나씩 칸 차지한 모습 

흙 또는 잔디 블럭 하나씩 크래프트에 배치 하면 제작블록 만들어지는거도 확인 완료

 

저번 블럭을 캐서 해당 자리에 갔을때 아래로 떨어지는 오류는 아직 수정중,,, 쉽게 해결이 안되네요 ㅋㅋㅋㅋ

 

다음 글에서는 해당 제작블록을 통해 설치 -> 우클릭 -> 3x3  제작대 UI 띄우고 레시피 통해서 도구를 만드는 것을 해볼 예정입니다

(그래야 장비창 만들어서 장착하고 도구를 통한 블럭을 캐는 그런게 자연스래 될거같음)

728x90

'Unity' 카테고리의 다른 글

Unity(7) - 3x3 Crafting Table  (0) 2026.03.13
Unity(6) - Atlas Block Map & Crafting Table (2)  (0) 2026.03.10
Unity(4) - Ray Cast & Block Destroy  (0) 2026.02.24
Unity(3) - Block Map  (1) 2026.02.19
Unity(2) - Basic Move, Rotation  (0) 2026.02.13