[Godot] Basic RPG Item Inventory System 기본 아이템 인벤토리 시스템 만들기
Godot 2026. 9. 6. 11:00 |간단한 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);
}
}
}
}
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키를 누르면 저장된 인벤토리를 불러온다.

'Godot' 카테고리의 다른 글
| [Godot] Network Multi Play - Chating 네트워크 멀티 플레이 - 채팅 (0) | 2026.09.09 |
|---|---|
| [Godot] RPG Top-Down View Character 탑다운 뷰 캐릭터 1 (0) | 2026.09.07 |
| [Godot] Making Plugin or Add On 플러그인, 애드온 만들기 3 (0) | 2026.09.03 |
| [Godot] Making Plugin or Add On 플러그인, 애드온 만들기 2 (0) | 2026.09.03 |
| [Godot] Making Plugin or Add On 플러그인, 애드온 만들기 1 (0) | 2026.09.02 |