반응형

간단한 RPG 게임의 아이템 인벤토리 시스템을 만들어 보자.

 

스크립트 폴더에 Resource를 상속하는 ItemData 클래스를 만든다. (위치가 상관 있는건 아니다)

 

※ 참고

Resources (Unity의 ScriptableObjects와 유사)

 

using Godot;

// [GlobalClass]를 붙여주면 Godot 에디터에서 우클릭으로 새 아이템 리소스를 쉽게 생성할 수 있다.
[GlobalClass]
public partial class ItemData : Resource
{
    [Export] public string ItemName { get; set; } = "이름 없음";
    [Export] public Texture2D Icon { get; set; } // 아이템 이미지
    [Export] public string Description { get; set; } = "설명";
    [Export] public int MaxStack { get; set; } = 5; // 겹칠 수 있는 최대 개수
}

 

 

Control - Panel - GridContainer - TextureRect - ColorRect/Label 노드를 차례로 만들고 Control 노드에서 그룹화 한다.

Control 노드에 InventoryUI 스크립트, TextureRect 노드에 InventorySlot 스크립트를 추가한다.

 

 

GridContainer - Columns를 4로 설정한다.

 

 

 

TextureRect - Expand Mode를 Ignore Size로 바꾸고 Custom Minimum Size를 64X64로 바꾼다.

 

 

ColorRect - Color를 적당한 색으로, Mouse - Filter를 Ignore로, Show Behind Parent를 체크한다.

 

 

TextureRect 노드와 자식을 원하는 만큼 복제(Ctrl+D)한다.

 

 

Control 노드에 추가된 InventoryUI 스크립트를 아래와 같이 작성한다.

 

using Godot;

public partial class InventoryUI : Control
{
    private GridContainer _grid;

    // Called when the node enters the scene tree for the first time.
    public override void _Ready()
    {
        // GridContainer를 찾아서 변수에 담는다.
        _grid = GetNode<GridContainer>("Panel/GridContainer");
    }

    // 빈 슬롯을 찾아서 아이템을 넣는다.
    public bool TryAddItem(ItemData itemToAdd, int amount = 1)
    {
        // GridContainer 아래에 있는 모든 슬롯(자식 노드)들을 순서대로 검사.
        foreach (Node child in _grid.GetChildren())
        {
            // 자식 노드가 InventorySlot 타입인지 확인.
            if (child is InventorySlot slot)
            {
                // 만약 슬롯이 비어있다면? (SlotItem이 null이라면)
                if (slot.SlotItem == null)
                {
                    // 아이템을 넣고 true를 반환하며 함수를 종료.
                    slot.UpdateSlot(itemToAdd, amount);
                    return true;
                }
            }
        }

        // 모든 슬롯을 다 돌았는데 빈칸이 없으면 꽉 찼다는 뜻.
        GD.Print("인벤토리가 가득 찼습니다!");
        return false;
    }

    public override void _Input(InputEvent @event)
    {
        if (@event is InputEventKey keyEvent && !keyEvent.Pressed)
        {
            // 키보드 C 키를 뗐을 때 동작
            if (keyEvent.Keycode == Key.C)
            {
                // 촛불 아이템 데이터를 만든다.
                ItemData item = new ItemData();
                item.ItemName = "촛불";

                // 촛불 아이템의 아이콘을 불러온다.
                item.Icon = GD.Load<Texture2D>("res://Sprite/UI/Candle.png");

                // 인벤토리에 아이템 넣기 시도.
                TryAddItem(item, 1);
            }

            // 키보드 H 키를 뗐을 때 동작
            if (keyEvent.Keycode == Key.H)
            {
                // 하트 아이템 데이터를 만든다.
                ItemData item = new ItemData();
                item.ItemName = "하트";

                // 하트 아이템의 아이콘을 불러온다.
                item.Icon = GD.Load<Texture2D>("res://Sprite/UI/Heart.png");

                // 인벤토리에 아이템 넣기 시도.
                TryAddItem(item, 1);
            }
        }
    }
}

 

Candle.png
0.00MB
Heart.png
0.00MB

 

 

 

TextureRect 노드에 추가된 InventorySlot 스크립트를 아래와 같이 작성한다.

 

using Godot;

public partial class InventorySlot : TextureRect
{
    // 이 슬롯이 현재 담고 있는 아이템 데이터와 개수
    public ItemData SlotItem { get; set; }
    public int Amount { get; set; }

    // 라벨을 담을 변수를 선언.
    private Label _amountLabel;

    public override void _Ready()
    {
        // 게임이 시작될 때 자식으로 있는 Label을 찾아온다.
        _amountLabel = GetNodeOrNull<Label>("Label");
        // GetNode<Label>("Label")로 하면 Label이 없을 때 에러가 기록되지만
        // GetNodeOrNull<Label>("Label")로 하면 Label이 없으면 null을 반환한다.
    }

    // _GetDragData(), _CanDropData(), _DropData()는 모두 Control 노드에 가상 매서드로 정의되어 있다.
    // 드래그 앤 드롭 관련 함수들이다.

    // 이 슬롯을 마우스로 클릭하고 "끌기 시작할 때" 자동으로 실행.
    // 호출 시점: 출발지 노드(슬롯 A) 위에서 마우스 왼쪽 버튼을 누르고 마우스를 움직이기 시작하는 바로 그 순간(Drag Start).
    // 실행 위치: 출발지 노드(슬롯 A)
    public override Variant _GetDragData(Vector2 atPosition)
    {
        if (SlotItem == null) return default; // 빈 슬롯이면 끌 수 없음

        // 미리보기 전체를 담을 투명한 빈 상자(Control)를 만든다.
        // 엔진은 이 상자의 좌표(0,0)를 마우스 커서에 맞춘다.
        Control previewContainer = new Control();

        // 실제 화면에 보여질(드래그 할때 보이는) 아이템 이미지(TextureRect)를 만든다.
        TextureRect previewImage = new TextureRect
        {
            Texture = this.Texture,
            ExpandMode = this.ExpandMode,     // 무시 모드 복사
            StretchMode = this.StretchMode,   // 늘림 모드 복사
            CustomMinimumSize = this.Size,    // 고삐가 풀리지 않도록 최소 크기 64x64 강제 고정!
            Size = this.Size,
            Position = -atPosition // 마우스 클릭 위치를 기준으로 이미지가 따라오도록 좌표를 반대로 설정
            // atPosition은 드래그 시작 시 마우스 커서가 슬롯 안에서 클릭된 위치를 의미.
        };

        // 상자 안에 이미지를 넣고 통째로 엔진에 넘긴다.
        previewContainer.AddChild(previewImage);
        SetDragPreview(previewContainer);

        // 드래그가 시작되면 이 슬롯을 반투명하게 만든다.
        // Color(R, G, B, Alpha) -> Alpha(투명도)를 0.3f로 설정해서 30% 투명하게.
        // 아예 안 보이게 하려면 0.0f로 설정한다.
        // this.Modulate로 변경하면 자식 노드까지 전부 투명해진다. SelfModulate를 사용해야 TextureRect만 투명해진다.
        this.SelfModulate = new Color(1, 1, 1, 0.3f);

        // 드롭될 때 넘겨줄 데이터를 Variant 형태로 반환. (여기서는 이 슬롯 자체를 넘김)
        return this;
    }

    // 엔진에서 발생하는 각종 알림(이벤트)을 수신한다.
    public override void _Notification(int what)
    {
        base._Notification(what);

        // NotificationDragEnd는 마우스 버튼을 떼서 드래그가 종료되었을 때 호출된다.
        if (what == NotificationDragEnd)
        {
            // 성공적으로 옮겼든, 바깥에 던져서 취소했든 항상 원래 불투명도(1.0f)로 복구한다.
            this.SelfModulate = new Color(1, 1, 1, 1.0f);
        }
    }

    // 다른 슬롯에서 아이템을 끌고 "이 슬롯 위로 올라왔을 때" 놓을 수 있는지 확인한다.
    // 호출 시점: 포션을 쥐고 있는 상태에서, 마우스 커서가 특정 노드의 영역 안으로 들어오거나 그 위에서 이동할 때마다
    // 지속적으로 발생.
    // 실행 위치: 마우스 커서 아래에 있는 모든 UI 노드(경유지 및 목적지)
    public override bool _CanDropData(Vector2 atPosition, Variant data)
    {
        // 끌고 온 데이터가 InventorySlot 타입이라면 놓을 수 있다고(true) 알려준다.
        return data.As<Node>() is InventorySlot;
    }

    // 끌고 온 아이템을 이 슬롯에 "내려놓았을 때(드롭)" 실행된다.
    // 호출 시점: _CanDropData가 true를 반환한 노드 위에서 플레이어가 꾹 누르고 있던 마우스 버튼을 떼는 순간(Drop).
    // 실행 위치: 도착지 노드(슬롯 B)
    public override void _DropData(Vector2 atPosition, Variant data)
    {
        // data 안에는 우리가 마우스로 끌고 온 '원래 슬롯(출발지)'이 들어있다.
        InventorySlot sourceSlot = data.As<InventorySlot>();

        // 끌고 온 슬롯이 정상적이고, 자기 자신(제자리)에 놓은 것이 아닐 때만 실행
        if (sourceSlot != null && sourceSlot != this)
        {
            // 비교는 두 아이템의 이름(ItemName)으로 한다. (ItemData에 ItemName이 유니크하게 설정되어 있다고 가정)
            if (this.SlotItem != null && sourceSlot.SlotItem != null && this.SlotItem.ItemName == sourceSlot.SlotItem.ItemName)
            {
                // 합치기(Stack) 로직: 도착지에도 아이템이 있고, 서로 같은 아이템일 때

                int totalAmount = this.Amount + sourceSlot.Amount;
                int maxStack = this.SlotItem.MaxStack; // 우리가 ItemData에 설정해둔 최대치 (예: 5)

                if (totalAmount <= maxStack)
                {
                    // 케이스 A: 더한 값이 최대치 이하면 모두 합치고 출발지를 깔끔하게 비운다.
                    this.UpdateSlot(this.SlotItem, totalAmount);
                    sourceSlot.UpdateSlot(null, 0);
                    GD.Print($"{this.SlotItem.ItemName}이(가) 전부 합쳐졌습니다! (현재 {totalAmount}개)");
                }
                else
                {
                    // 케이스 B: 더한 값이 최대치를 넘어가면 도착지를 꽉 채우고, 남은 것을 출발지에 둔다.
                    this.UpdateSlot(this.SlotItem, maxStack);
                    sourceSlot.UpdateSlot(sourceSlot.SlotItem, totalAmount - maxStack);
                    GD.Print($"최대치({maxStack})까지 합쳐지고, 원래 자리에 {totalAmount - maxStack}개가 남았습니다!");
                }
            }
            else
            {
                // 스왑 로직: 도착지에 아이템이 있고, 서로 다른 아이템일 때

                // 현재 슬롯(도착지)의 데이터를 임시 변수에 '백업'해 둔다.
                // (만약 빈칸이었다면 null과 0이 백업된다)
                ItemData tempItem = this.SlotItem;
                int tempAmount = this.Amount;

                // 끌고 온 슬롯(출발지)의 데이터를 현재 슬롯(도착지)에 덮어쓴다.
                this.UpdateSlot(sourceSlot.SlotItem, sourceSlot.Amount);

                // 백업해 두었던 현재 슬롯의 원래 데이터를 끌고 온 슬롯(출발지)에 넣어준다.
                sourceSlot.UpdateSlot(tempItem, tempAmount);

                GD.Print("아이템 위치가 변경되었습니다!");
            }
        }
        else
        {
            GD.Print("자기 자신에게는 아이템을 놓을 수 없습니다!");
        }
    }

    // 아이템 데이터를 받고 슬롯을 업데이트하는 함수
    public void UpdateSlot(ItemData newItem, int newAmount)
    {
        SlotItem = newItem;
        Amount = newAmount;

        if (SlotItem != null)
        {
            // 아이템이 들어오면 해당 아이템의 아이콘을 슬롯 텍스처로 표시한다.
            this.Texture = SlotItem.Icon;

            // 아이템이 있으면 숫자를 표시한다.
            if (_amountLabel != null)
            {
                _amountLabel.Text = Amount.ToString();

                // 1개일 때는 숫자를 숨기고, 2개 이상일 때만 보이게 한다.
                _amountLabel.Visible = Amount > 1;
            }
        }
        else
        {
            // 빈 슬롯이 되면 이미지를 지운다.
            this.Texture = null;

            // 빈칸이 되면 숫자도 숨긴다.
            if (_amountLabel != null)
            {
                _amountLabel.Visible = false;
            }
        }
    }

    // 키보드 입력을 감지하는 함수
    public override void _Input(InputEvent @event)
    {
        // 'D' 키가 막 눌렸을 때 (꾹 누르고 있을 때 연속 발생 방지: !keyEvent.Echo)
        if (@event is InputEventKey keyEvent && keyEvent.Keycode == Key.D && keyEvent.Pressed && !keyEvent.Echo)
        {
            // 현재 마우스 커서가 이 슬롯의 네모 영역(Rect) 안에 들어와 있는지 확인.
            // GetGlobalRect(): 현재 슬롯이 화면 전체에서 차지하고 있는 네모 반듯한 영토(좌표와 크기)를 가져온다.            
            // HasPoint(): 특정 좌표가 이 Rect 안에 들어와 있는지 확인한다. (true/false 반환)
            // GetGlobalMousePosition(): 현재 마우스 커서의 화면상 위치를 가져온다.
            if (GetGlobalRect().HasPoint(GetGlobalMousePosition()))
            {
                // 마우스가 이 슬롯 위에 있고, 비어있지 않다면 삭제를 진행.
                if (SlotItem != null)
                {
                    DecreaseItem(1); // 1개 삭제

                    // 이벤트 소모: 이 키 입력 처리를 여기서 끝낸다.
                    // (다른 뒤쪽 UI나 슬롯으로 D키 이벤트가 중복 전달되는 것을 막는다)
                    GetViewport().SetInputAsHandled();
                }
                else
                {
                    GD.Print("빈 슬롯은 삭제할 수 없습니다.");
                }
            }
        }
    }

    // 아이템 개수를 줄이거나 빈칸으로 만드는 헬퍼 함수
    public void DecreaseItem(int amountToRemove)
    {
        // 뺄 개수만큼 차감.
        int resultAmount = Amount - amountToRemove;

        if (resultAmount <= 0)
        {
            // 남은 개수가 0개 이하라면 슬롯을 완전히 비운다.
            this.UpdateSlot(null, 0);
            GD.Print("아이템이 모두 삭제되어 빈칸이 되었습니다.");
        }
        else
        {
            // 아직 개수가 남아있다면, 줄어든 숫자만 화면에 다시 갱신한다.
            this.UpdateSlot(SlotItem, resultAmount);
            GD.Print($"아이템을 1개 버렸습니다. (남은 개수: {resultAmount})");
        }
    }
}

 

 

 

게임을 실행하면 간단한 인벤토리 시스템이 표시된다.

■ C 키 - 촛불 아이템 추가

■ H 키 - 하트 아이템 추가

■ D 키 - 아이템 삭제

그리고 마우스로 아이템 이동과 합치기 동작이 가능하다.

 

아이템에 툴팁을 추가해 보자.

InventorySlot 스크립트의 UpdateSlot()에 this.TooltipText 프로퍼티를 정의한다.

// 아이템 데이터를 받고 슬롯을 업데이트하는 함수
public void UpdateSlot(ItemData newItem, int newAmount)
{
    SlotItem = newItem;
    Amount = newAmount;

    if (SlotItem != null)
    {
        // 아이템이 들어오면 해당 아이템의 아이콘을 슬롯 텍스처로 표시한다.
        this.Texture = SlotItem.Icon;

        // 툴팁에 아이템 이름과 설명을 표시한다. 공백 문자라도 넣어야 툴팁이 정상적으로 뜬다. (빈 문자열이면 툴팁이 안 뜬다)
        this.TooltipText = " ";

        // 아이템이 있으면 숫자를 표시한다.
        if (_amountLabel != null)
        {
            _amountLabel.Text = Amount.ToString();

            // 1개일 때는 숫자를 숨기고, 2개 이상일 때만 보이게 한다.
            _amountLabel.Visible = Amount > 1;
        }
    }
    else
    {
        // 빈 슬롯이 되면 이미지를 지운다.
        this.Texture = null;

        // 빈칸이 되면 숫자도 숨긴다.
        if (_amountLabel != null)
        {
            _amountLabel.Visible = false;
        }
    }
}

 

마찬가지로 InventorySlot 스크립트에 _MakeCustomTooltip()를 추가한다.

 

// 툴팁을 화면에 그릴 때 엔진이 자동으로 호출하는 함수
public override Control _MakeCustomTooltip(string forText)
{
    if (SlotItem == null) return null;

    // 툴팁의 전체 배경이 될 패널 (PanelContainer)
    PanelContainer tooltipBg = new PanelContainer();

    // 글자들을 위아래로 정렬해 줄 상자 (VBoxContainer)
    VBoxContainer vbox = new VBoxContainer();
    tooltipBg.AddChild(vbox); // 배경 패널 안에 상자를 넣는다.

    // 아이템 이름 라벨 (노란색으로 강조)
    Label nameLabel = new Label();
    nameLabel.Text = SlotItem.ItemName;
    nameLabel.Modulate = Colors.Yellow; // 이름은 눈에 띄게 노란색으로 칠한다.
    vbox.AddChild(nameLabel); // 상자 안에 이름 라벨을 넣는다.

    // 아이템 설명 라벨
    Label descLabel = new Label();
    descLabel.Text = SlotItem.Description;
    vbox.AddChild(descLabel); // 상자 안에 설명 라벨을 넣는다.
    
    // 완성된 툴팁 배경 패널을 엔진에 제출한다 (나머지 위치 계산은 엔진이 알아서 한다)
    return tooltipBg;
}

 

 

인벤토리를 저장하고 불러와 보자.

 

 

우선 각 아이템을 리소스 파일로 만들어야 한다. 적당한 폴더를 만들고 우클릭 - Create New - Resoure... 클릭 - 대화상자에서 ItemData를 검색하고 Create 버튼 클릭 - Candle과 Heart 리소스를 만든다.

 

 

 

위와 같이 각 리소스의 이름과 아이콘을 지정한다.

 

InventoryUI 스크립트를 아래와 같이 수정한다.

 

using Godot;
using System.Collections.Generic;
using System.Text.Json;

// 슬롯 하나의 정보를 담을 작은 저장용 클래스.
public class SlotSaveData
{
    public string ItemPath { get; set; } = ""; // 아이템 파일의 위치 (예: res://Resource/Candle.tres)
    public int Amount { get; set; } = 0;       // 아이템 개수
}

public partial class InventoryUI : Control
{
    private GridContainer _grid;

    // Called when the node enters the scene tree for the first time.
    public override void _Ready()
    {
        // GridContainer를 찾아서 변수에 담는다.
        _grid = GetNode<GridContainer>("Panel/GridContainer");
    }

    // 빈 슬롯을 찾아서 아이템을 넣는다.
    public bool TryAddItem(ItemData itemToAdd, int amount = 1)
    {
        // GridContainer 아래에 있는 모든 슬롯(자식 노드)들을 순서대로 검사.
        foreach (Node child in _grid.GetChildren())
        {
            // 자식 노드가 InventorySlot 타입인지 확인.
            if (child is InventorySlot slot)
            {
                // 만약 슬롯이 비어있다면? (SlotItem이 null이라면)
                if (slot.SlotItem == null)
                {
                    // 아이템을 넣고 true를 반환하며 함수를 종료.
                    slot.UpdateSlot(itemToAdd, amount);
                    return true;
                }
            }
        }

        // 모든 슬롯을 다 돌았는데 빈칸이 없으면 꽉 찼다는 뜻.
        GD.Print("인벤토리가 가득 찼습니다!");
        return false;
    }

    public override void _Input(InputEvent @event)
    {
        if (@event is InputEventKey keyEvent && !keyEvent.Pressed)
        {
            // 키보드 C 키를 뗐을 때 동작
            if (keyEvent.Keycode == Key.C)
            {
                // 촛불 아이템 데이터를 만든다.
                ItemData item = GD.Load<ItemData>("res://Resource/Candle.tres");

                // 인벤토리에 아이템 넣기 시도.
                TryAddItem(item, 1);
            }

            // 키보드 H 키를 뗐을 때 동작
            if (keyEvent.Keycode == Key.H)
            {
                // 하트 아이템 데이터를 만든다.
                ItemData item = GD.Load<ItemData>("res://Resource/Heart.tres");

                // 인벤토리에 아이템 넣기 시도.
                TryAddItem(item, 1);
            }

            // S 키: 인벤토리 저장
            if (keyEvent.Keycode == Key.S)
            {
                SaveInventory();
            }

            // L 키: 인벤토리 불러오기
            if (keyEvent.Keycode == Key.L)
            {
                LoadInventory();
            }
        }
    }

    // 인벤토리 저장하기
    public void SaveInventory()
    {
        List<SlotSaveData> saveDataList = new List<SlotSaveData>();

        foreach (Node child in _grid.GetChildren())
        {
            if (child is InventorySlot slot)
            {
                SlotSaveData data = new SlotSaveData();

                // 슬롯에 아이템이 있다면 파일 경로와 개수를 기록한다.
                if (slot.SlotItem != null)
                {
                    data.ItemPath = slot.SlotItem.ResourcePath; // "res://Resource/Candle.tres" 같은 경로가 저장된다
                    data.Amount = slot.Amount;
                }

                saveDataList.Add(data); // 리스트에 추가 (빈칸이면 빈 경로와 0개가 들어간다)
            }
        }

        // C# JSON 기능을 이용해 리스트를 문자열로 변환한다.
        string jsonString = JsonSerializer.Serialize(saveDataList);

        // Godot의 전용 세이브 폴더(user://)에 파일로 저장한다.
        using FileAccess file = FileAccess.Open("user://inventory_save.json", FileAccess.ModeFlags.Write);
        file.StoreString(jsonString);

        GD.Print("인벤토리 저장 완료! (user://inventory_save.json)");
    }

    // 인벤토리 불러오기
    public void LoadInventory()
    {
        // 세이브 파일이 없으면 불러오기를 취소한다.
        if (!FileAccess.FileExists("user://inventory_save.json"))
        {
            GD.Print("저장된 파일이 없습니다.");
            return;
        }

        // 파일을 읽어와서 문자열로 만든다.
        using FileAccess file = FileAccess.Open("user://inventory_save.json", FileAccess.ModeFlags.Read);
        string jsonString = file.GetAsText();

        // 문자열을 다시 C# 리스트 형태로 복구한다.
        List<SlotSaveData> loadedData = JsonSerializer.Deserialize<List<SlotSaveData>>(jsonString);

        int slotIndex = 0;
        foreach (Node child in _grid.GetChildren())
        {
            if (child is InventorySlot slot && slotIndex < loadedData.Count)
            {
                SlotSaveData data = loadedData[slotIndex];

                if (!string.IsNullOrEmpty(data.ItemPath))
                {
                    // 저장된 경로를 보고 진짜 아이템 파일(.tres)을 엔진에서 불러온다.
                    ItemData loadedItem = GD.Load<ItemData>(data.ItemPath);
                    slot.UpdateSlot(loadedItem, data.Amount);
                }
                else
                {
                    // 경로가 비어있으면 빈칸으로 만든다.
                    slot.UpdateSlot(null, 0);
                }
                slotIndex++;
            }
        }
        GD.Print("인벤토리 불러오기 완료!");
    }
}

 

S 키를 누르면 아래 경로에 인벤토리가 저장되고 L키를 누르면 저장된 인벤토리를 불러온다.

 

 

반응형
Posted by J-sean
: