using UnityEngine;
public class GameDataBootstrap : MonoBehaviour
{
private static GameDataBootstrap instance;
[Header("CSV Data")]
[SerializeField] private TextAsset itemDataCsv;
// ItemData.csv 참조
[SerializeField] private TextAsset craftingTableCsv;
// CraftingTable.csv 참조
[Header("Options")]
[SerializeField] private bool dontDestroyOnLoad = true;
[SerializeField] private bool loadOnAwake = true;
// 로비에서 데이터를 로드하고, 메인씬으로 가도 유지하여 사용할 수 있게 해줌
private void Awake()
{
if (instance != null && instance != this)
{
Destroy(gameObject);
return;
}
instance = this;
if (dontDestroyOnLoad)
DontDestroyOnLoad(gameObject);
// 씬이 전환되어도 해당 오브젝트가 파괴되지 않도록 만들어줌
if (loadOnAwake)
LoadAll();
// cvs 데이터 로드
}
public void LoadAll()
{
ItemDatabase.Load(itemDataCsv);
RecipeDatabase.Load(craftingTableCsv);
// ItemData.csv, CraftingTable.csv 파일을 읽어서
// 각각의 데이터베이스에 등록해줌
Debug.Log(
$"[GameDataBootstrap] Data Loaded. " +
$"Items={ItemDatabase.Count}, Recipes={RecipeDatabase.Count}"
);
// 잘됐는지 로그 확인
}
}
저번 글에서 로비에서 스테이지 선택을 통해 정보를 받아 해당 정보를 인게임에 전달하여 랜덤맵, 스테이지 선택맵을 플레이 할 수 있도록 만들었다
이번 글에서는 맵에 나무를 랜덤 배치 하는 것과 손에 들고있는 도구에 따라 블럭의 파괴시간이 달라지는것을 구현
우선 나무 블록을 만들기 위해 필요한 것들
아틀라스에 추가적으로 넣어줄 나무, 나뭇잎 텍스처 , 넣는김에 화로 텍스처도 넣어줌
블록에 나무와 나뭇잎 정보를 넣어줄 BlockType
public enum BlockType : byte
{
Air = 0,
Dirt = 1,
Grass = 2,
Stone = 3,
Water = 4,
Coal = 5,
Iron = 6,
Wood = 7,
Planks = 8, // 판자
Gold = 9,
Diamond = 10,
CraftingTable = 11,
Leaves = 12,
Furnace = 13,
FurnaceLit = 14
}
수정된 아틀라스 텍스처 파일에 대한 텍스처 수 수정 및 블록 타입에 따라 블럭에 텍스처 넣어주기

private static int GetTileIndex(BlockType t, FaceDir dir)
// 아틀라스 몇 번째 텍스쳐 사용할건지 블록 타입에 따라 리턴
{
switch (t)
{
case BlockType.Dirt:
return 0;
case BlockType.Grass:
// 윗면만 GrassTop, 아랫면은 Dirt, 옆면은 GrassSide
if (dir == FaceDir.PosY) return 10; // 위
if (dir == FaceDir.NegY) return 0; // 아래
return 1; // 옆
case BlockType.Stone:
return 2;
case BlockType.Wood:
return 3;
case BlockType.Planks:
return 4;
case BlockType.Coal:
return 5;
case BlockType.Iron:
return 6;
case BlockType.Gold:
return 7;
case BlockType.Diamond:
return 8;
case BlockType.CraftingTable:
return 9;
case BlockType.Water:
return 11;
case BlockType.Leaves:
return 12;
case BlockType.Furnace:
return 13;
case BlockType.FurnaceLit:
return 14;
default:
return 2; // 모르면 Stone
}
}
다음 BlockInteractor에서 로비와 인게임의 블록을 부수는 방식에 대한 차이를 위해

모드를 나누어주고
private static ItemId ToItemId(BlockType bt)
{
return bt switch
{
BlockType.Dirt => ItemId.Dirt,
BlockType.Grass => ItemId.Grass,
BlockType.Stone => ItemId.Stone,
BlockType.Coal => ItemId.Coal,
BlockType.Iron => ItemId.Iron,
BlockType.Diamond => ItemId.Diamond,
BlockType.Gold => ItemId.Gold,
BlockType.Wood => ItemId.Wood,
BlockType.CraftingTable => ItemId.CraftingTable,
BlockType.Planks => ItemId.Planks,
// Leaves는 아이템으로 존재하지 않고, 부수면 Stick을 드롭한다.
BlockType.Leaves => ItemId.Stick,
// FurnaceLit은 인벤토리 아이템이 아니라 월드 상태다.
// 부수면 일반 Furnace 아이템으로 돌아온다.
BlockType.Furnace => ItemId.Furnace,
BlockType.FurnaceLit => ItemId.Furnace,
_ => ItemId.None
};
}
블럭을 부섰을 경우 들어오는 아이템 설정, 나뭇잎 부수면 나무스틱 들어오도록하고
private float GetMineDuration(BlockType bt, ItemId held)
{
bool isPickaxe = false;
if (ItemDb.Kind(held) == ItemKind.Tool)
{
var info = ItemDb.ToolInfo(held);
if (info.kind == ToolKind.Pickaxe)
isPickaxe = true;
}
return bt switch
{
BlockType.Leaves => 1.5f,
BlockType.Dirt => 1.5f,
BlockType.Grass => 1.5f,
BlockType.Stone => isPickaxe ? 2f : 10f,
BlockType.Coal => isPickaxe ? 3f : 10f,
BlockType.Iron => isPickaxe ? 4f : 15f,
BlockType.Planks => isPickaxe ? 2f : 4f,
BlockType.Diamond => isPickaxe ? 5f : 20f,
BlockType.Gold => isPickaxe ? 4f : 20f,
BlockType.CraftingTable => isPickaxe ? 2f : 4f,
BlockType.Wood => isPickaxe ? 2f : 4f,
BlockType.Furnace => isPickaxe ? 2.5f : 8f,
BlockType.FurnaceLit => isPickaxe ? 2.5f : 8f,
BlockType.Water => 1000f,
_ => 9000f
};
}
손에 들고있는 ToolType에 따라 블럭 부수는 시간 다르게 설정
지금은 곡갱이를 기준으로 다르게 하였으나 나중 더 세부적으로 나눌것임
레시피와 아이템 데이터 추가에 따라 ItemType도 변경
이제 추가된 아이템에 따라 조합법을 만들어 줄건데
기존에는 InventoryUIController에서 조합식을 하드코딩을 통해 하였으나 아이템의 개수가 많아짐에 따라 CraftringUIController를 만들어 분리하여주고 엑셀을 통한 csv파일로 조합식 파일을 만들어 관리한다.
using UnityEngine;
public class GameDataBootstrap : MonoBehaviour
{
private static GameDataBootstrap instance;
[Header("CSV Data")]
[SerializeField] private TextAsset itemDataCsv;
// ItemData.csv 참조
[SerializeField] private TextAsset craftingTableCsv;
// CraftingTable.csv 참조
[Header("Options")]
[SerializeField] private bool dontDestroyOnLoad = true;
[SerializeField] private bool loadOnAwake = true;
// 로비에서 데이터를 로드하고, 메인씬으로 가도 유지하여 사용할 수 있게 해줌
private void Awake()
{
if (instance != null && instance != this)
{
Destroy(gameObject);
return;
}
instance = this;
if (dontDestroyOnLoad)
DontDestroyOnLoad(gameObject);
// 씬이 전환되어도 해당 오브젝트가 파괴되지 않도록 만들어줌
if (loadOnAwake)
LoadAll();
// cvs 데이터 로드
}
public void LoadAll()
{
ItemDatabase.Load(itemDataCsv);
RecipeDatabase.Load(craftingTableCsv);
// ItemData.csv, CraftingTable.csv 파일을 읽어서
// 각각의 데이터베이스에 등록해줌
Debug.Log(
$"[GameDataBootstrap] Data Loaded. " +
$"Items={ItemDatabase.Count}, Recipes={RecipeDatabase.Count}"
);
// 잘됐는지 로그 확인
}
}
GameDataBootstrap
엑셀을 통해 작성한 ItemData.csv와 CraftingTable.csv파일 가져오기
using System.Collections.Generic;
using System.IO;
using UnityEngine;
public static class CsvTableReader
{
public static List<Dictionary<string, string>> Read(TextAsset csvAsset)
{
if (csvAsset == null)
{
Debug.LogError("[CsvTableReader] CSV TextAsset is null.");
return new List<Dictionary<string, string>>();
}
return Read(csvAsset.text);
// csv의 문자열 데이터를 읽어 문자열 기반 로드
}
public static List<Dictionary<string, string>> Read(string csvText)
{
List<Dictionary<string, string>> rows = new List<Dictionary<string, string>>();
// csv의 각 행을 Dictionary로 저장할 리스트
// Key = header 이름 , value = 해당 행의 셀 값
// 형태로 저장
if (string.IsNullOrWhiteSpace(csvText))
return rows;
using StringReader reader = new StringReader(csvText);
// 문자열 한 줄씩 읽기 위해 StringReader 사용
string headerLine = reader.ReadLine();
// 첫 번째 줄은 header로 사용
if (string.IsNullOrWhiteSpace(headerLine))
return rows;
headerLine = RemoveBom(headerLine);
// UTF-8 BOM이 붙어 있을 경우 제거
// 엑셀에서 CSV UTF-8로 저장하게 되면 첫 header 앞에 BOM이 붙게 되는 경우가 있는데
// 이를 제거하지 않으면 id 가 아니라 \uFEFFid로 읽혀 key 매칭이 실패할 수 있다
// 여기서 BOM이 무엇이냐, BOM(Byte Order Mark)은 바이트 순서 표시의 약자로
// 유니코드 문자 U+FEFF를 문서의 가장 앞에 추가해 텍스트를 읽응 프로그램에 정보를
// 전달하는 것이다. 그렇기에 잘못 읽을 수 있어 미리 제거한다.
List<string> headers = ParseLine(headerLine);
// header 줄을 CSV 규칙에 맞게 파싱
string line;
int lineNumber = 1;
// 현재 줄 번호
// 지금 코드에서는 디버그용으로만 증가
while ((line = reader.ReadLine()) != null)
{
lineNumber++;
// 다음 줄을 읽을 때마다 줄 번호 증가
if (string.IsNullOrWhiteSpace(line))
continue;
// 빈 줄 무시
List<string> values = ParseLine(line);
// 현재 행을 CSV 규칙에 맞게 파싱
Dictionary<string, string> row = new Dictionary<string, string>();
// 현재 행의 데이터를 저장한 Dictionary
for (int i = 0; i < headers.Count; i++)
{
string key = headers[i].Trim();
// header 이름을 key로 사용
string value = i < values.Count ? values[i].Trim() : string.Empty;
// 값이 존재하면 trim해서 사용하고
// 값이 부족하면 빈 문자열 넣기
// 이렇게 하면 일부 행의 셀 개수가 header보다 부족해도
// 예외 없이 읽을 수 있다.
row[key] = value;
// header 이름과 현재 셀 값을 매칭해 저장
}
rows.Add(row);
// 완성된 행을 결과 리스트에 추가
}
return rows;
// 모든 행을 반환
}
private static List<string> ParseLine(string line)
{
List<string> values = new List<string>();
// 현재 줄에서 파싱한 셀 값 목록
bool inQuotes = false;
// 현재 큰따옴표 내부를 읽고 있는지 여부
// true면 쉼표가 나와도 셀 구분자로 처리하지 않음
System.Text.StringBuilder current = new System.Text.StringBuilder();
// 현재 셀에 들어갈 문자열을 누적
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
// 현재 문자
if (c == '"')
{
// CSV에서 ""는 따옴표 문자 하나를 의미한다.
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
{
current.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}
// 큰따옴표를 만나면 따옴표 내부 / 외부 상태를 전환
// Wood,None | None,None
// 이 문자열 안의 쉼표는 셀 구분자가 아니므로
// InQuotes가 true인 동안은 쉼표를 일반 문자로 취급
continue;
}
if (c == ',' && !inQuotes)
{
values.Add(current.ToString());
current.Clear();
continue;
}
// 쉼표를 만났고 따옴표 내부가 아니라면
// 현재 셀을 종료하고 다음 셀로 넘어간다
current.Append(c);
// 일반 문자는 현재 셀 문자열에 추가
}
values.Add(current.ToString());
// 마지막 셀 추가
// 줄 끝에 쉼표가 없기에 루프 이후 직접 추가해야함
return values;
}
private static string RemoveBom(string text)
{
if (string.IsNullOrEmpty(text))
return text;
// 문자열이 비어 있으면 그대로 반환
return text.TrimStart('\uFEFF');
// UTF-8 BOM 제거
}
}
CsvTableReader
csv파일의 내부 데이터를 문자열 파싱을 통해 구분하여 데이터를 읽어주는 클래스
using System;
using UnityEngine;
[Serializable]
public class ItemDefinition
{
public int id; // 아이템 고유 ID
public string key; // 아이템 문자열 구분 key
public string displayName; // UI에 표시할 이름
public CsvItemKind kind; // 아이템 종류
public int maxStack; // 최대 스택 수
public string iconKey; // 아이콘 매칭에 사용할 key
public string blockTypeName; // BlockType 이름
public string toolKindName; // 도구 종류 이름
public CsvEquipmentSlot equipmentSlot; // 장착 아이템
public int attackPower; // 공격력
public int defense; // 방어력
public bool IsBlock => kind == CsvItemKind.Block; // 블록 아이템인지 확인
public bool IsTool => kind == CsvItemKind.Tool; // 도구인지 확인
public bool IsEquipment => kind == CsvItemKind.Equipment; // 장착 아이템 확인
public bool IsStackable => maxStack > 1; // 스택 가능한 아이템 확인
}
public enum CsvItemKind // 아이템 종류
{
None,
Block,
Tool,
Equipment,
Material,
Other
}
public enum CsvEquipmentSlot // 장착 가능한 아이템 종류
{
None,
Head,
Chest,
Legs,
Feet
}
ItemDefinition
csv에서 읽은 하나의 아이템에 대한 정보 분류
using System.Collections.Generic;
using UnityEngine;
public static class ItemDatabase
{
private static readonly Dictionary<int, ItemDefinition> byId = new Dictionary<int, ItemDefinition>();
// item id 기준 검색
private static readonly Dictionary<string, ItemDefinition> byKey = new Dictionary<string, ItemDefinition>();
// item key 기준 검색
public static bool IsLoaded { get; private set; }
// ItemDatabase 로드 되었는지 확인
public static int Count => byId.Count;
// 현재 등록된 아이템 개수
public static void Load(TextAsset itemDataCsv)
{
Clear();
// 기존 데이터 초기화, 중복 방지
if (itemDataCsv == null)
{
Debug.LogError("[ItemDatabase] ItemData.csv is not assigned.");
return;
}
List<Dictionary<string, string>> rows = CsvTableReader.Read(itemDataCsv);
// CsvTableReader를 사용해 CSV를 행 단위 Dictionary 리스트 변환
foreach (var row in rows)
{
ItemDefinition def = ParseItem(row);
// CSV 한 행을 ItemDefinition으로 변환
if (def == null)
continue;
// 잘못된 행이면 건너뛰기
if (byId.ContainsKey(def.id))
{
Debug.LogWarning($"[ItemDatabase] Duplicate item id: {def.id}. key={def.key}");
continue;
}
if (!string.IsNullOrEmpty(def.key) && byKey.ContainsKey(def.key))
{
Debug.LogWarning($"[ItemDatabase] Duplicate item key: {def.key}. id={def.id}");
continue;
}
byId.Add(def.id, def);
// id 기준 Dictionary에 등록
if (!string.IsNullOrEmpty(def.key))
byKey.Add(def.key, def);
// key가 비어 있지 않다면 key 기준 Dictionary에도 등록
}
IsLoaded = true;
// 로드 완료 표시
ValidateBlockTypes();
// Blcok 아이템의 blockTypeName이 실제 BlockType enum에 있는지 확인
Debug.Log($"[ItemDatabase] Loaded. Count={byId.Count}");
}
public static void Clear()
{
byId.Clear();
byKey.Clear();
IsLoaded = false;
// 모든 데이터 초기화
}
public static bool TryGet(int id, out ItemDefinition def)
{
return byId.TryGetValue(id, out def);
// id를 이용해 아이템 정보 찾기
}
public static bool TryGet(string key, out ItemDefinition def)
{
if (string.IsNullOrWhiteSpace(key))
{
def = null;
return false;
}
return byKey.TryGetValue(key, out def);
// key로 아이템 정보 찾기
}
public static ItemDefinition GetOrNull(int id)
{
byId.TryGetValue(id, out ItemDefinition def);
return def;
}
public static ItemDefinition GetOrNull(string key)
{
byKey.TryGetValue(key, out ItemDefinition def);
return def;
}
// 찾아서 없는경우 null 반환
public static bool TryGetItemIdByKey(string key, out int itemId)
{
itemId = 0;
if (!TryGet(key, out ItemDefinition def))
return false;
itemId = def.id;
return true;
// key 해당되는 아이템을 찾았으면 id 반환
}
public static string GetKey(int id)
{
return TryGet(id, out ItemDefinition def) ? def.key : string.Empty;
// id에 해당하는 key 반환
// RecipeDatabase는 현재 조합칸의 itemId를 key로 바꾸어 recipe.patternkeys와 비교
}
public static string GetDisplayName(int id)
{
return TryGet(id, out ItemDefinition def) ? def.displayName : $"Unknown Item {id}";
// UI 표시 이름 반환
// 없는 경우 Unknown Item으로 표시
}
public static int GetMaxStack(int id, int defaultValue = 64)
{
return TryGet(id, out ItemDefinition def) ? Mathf.Max(1, def.maxStack) : defaultValue;
// 값이 1보다 작게 들어와도 최소 1로 보장
}
public static CsvItemKind GetKind(int id)
{
return TryGet(id, out ItemDefinition def) ? def.kind : CsvItemKind.None;
// 아이템 종류 반환
}
public static CsvEquipmentSlot GetEquipmentSlot(int id)
{
return TryGet(id, out ItemDefinition def) ? def.equipmentSlot : CsvEquipmentSlot.None;
// 장비 아이템의 장비 슬롯 반환
}
public static int GetAttackPower(int id)
{
return TryGet(id, out ItemDefinition def) ? def.attackPower : 0;
// 공격력 반환
}
public static int GetDefense(int id)
{
return TryGet(id, out ItemDefinition def) ? def.defense : 0;
// 방어력 반환
}
public static bool TryGetBlockType(int id, out BlockType blockType)
{
blockType = BlockType.Air; // 블럭 기본값 air
if (!TryGet(id, out ItemDefinition def))
return false;
if (string.IsNullOrWhiteSpace(def.blockTypeName))
return false;
if (IsNone(def.blockTypeName))
return false;
return System.Enum.TryParse(def.blockTypeName, ignoreCase: true, out blockType);
// 문자열을 BlockType enum으로 변환
}
private static ItemDefinition ParseItem(Dictionary<string, string> row)
{
int id = GetInt(row, "id", 0);
// CSV의 id 값 읽기
if (id <= 0)
{
Debug.LogWarning("[ItemDatabase] Invalid item id.");
return null;
}
string key = GetString(row, "key"); // CSV의 key 값
string displayName = GetString(row, "displayName"); // CSV의 UI 표출 텍스트 값
ItemDefinition def = new ItemDefinition
{
id = id,
key = key,
displayName = string.IsNullOrWhiteSpace(displayName) ? key : displayName,
// displayName이 비어 있으면 key를 표시 이름으로 사용
kind = ParseKind(GetString(row, "kind")),
// 종류 문자열을 enum으로 변환
maxStack = Mathf.Max(1, GetInt(row, "maxStack", 64)),
// 최대 스택 수 64 기본 , 최소 1
iconKey = NormalizeOptional(GetString(row, "iconKey")), // 아이콘 매칭 key
blockTypeName = NormalizeOptional(GetString(row, "blockType")),
// 블록 아이템인 경우 BlockType 으로
toolKindName = NormalizeOptional(GetString(row, "toolKind")),
// 도구 아이템인 경우 도구 종류
equipmentSlot = ParseEquipmentSlot(GetString(row, "equipmentSlot")),
// 장비 아이템인 경우 장착 슬롯으로 분류
attackPower = GetInt(row, "attackPower", 0),
// 공격력
defense = GetInt(row, "defense", 0)
// 방어력
};
return def;
// 완성된 Item 정보 반환
}
private static CsvItemKind ParseKind(string raw)
{
if (string.IsNullOrWhiteSpace(raw) || IsNone(raw))
return CsvItemKind.None;
if (System.Enum.TryParse(raw, ignoreCase: true, out CsvItemKind kind))
return kind;
// 현재 CSV에 Iron/Gold/Diamond 재료 kind가 Stone으로 들어가 있어서
// 일단 Material로 받아준다.
if (raw.Equals("Stone", System.StringComparison.OrdinalIgnoreCase))
return CsvItemKind.Material;
Debug.LogWarning($"[ItemDatabase] Unknown item kind '{raw}'. It will be treated as Other.");
return CsvItemKind.Other;
// 아이템 종류 변환
}
private static CsvEquipmentSlot ParseEquipmentSlot(string raw)
{
if (string.IsNullOrWhiteSpace(raw) || IsNone(raw))
return CsvEquipmentSlot.None;
if (System.Enum.TryParse(raw, ignoreCase: true, out CsvEquipmentSlot slot))
return slot;
Debug.LogWarning($"[ItemDatabase] Unknown equipment slot '{raw}'.");
return CsvEquipmentSlot.None;
// 장착 슬롯 변환
}
private static void ValidateBlockTypes()
{
foreach (var pair in byId)
{
ItemDefinition def = pair.Value;
if (def.kind != CsvItemKind.Block)
continue;
if (string.IsNullOrWhiteSpace(def.blockTypeName) || IsNone(def.blockTypeName))
{
Debug.LogWarning($"[ItemDatabase] Block item has no blockType. id={def.id}, key={def.key}");
continue;
}
if (!System.Enum.TryParse(def.blockTypeName, ignoreCase: true, out BlockType _))
{
Debug.LogWarning(
$"[ItemDatabase] blockType '{def.blockTypeName}' does not exist in BlockType enum. " +
$"id={def.id}, key={def.key}"
);
}
}
// 블록 아이템 검사
}
private static string GetString(Dictionary<string, string> row, string key)
{
return row.TryGetValue(key, out string value) ? value.Trim() : string.Empty;
// Dictionary에서 문자열 값 가져오기
}
private static int GetInt(Dictionary<string, string> row, string key, int defaultValue)
{
string text = GetString(row, key); // 문자열 값 가져오기
if (int.TryParse(text, out int value))
return value;
// int 변환
return defaultValue;
// 변환 실패 시 기본값으로
}
private static string NormalizeOptional(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
if (IsNone(value))
return string.Empty;
return value.Trim();
}
private static bool IsNone(string value)
{
return value.Equals("None", System.StringComparison.OrdinalIgnoreCase) ||
value.Equals("Null", System.StringComparison.OrdinalIgnoreCase) ||
value.Equals("-", System.StringComparison.OrdinalIgnoreCase);
// CSV에서 값 없음으로 취급한 문자열
}
}
ItemDatabase
전체 아이템을 id 와 key를 기준으로 검색 가능하도록 저장하도록 구현
아이템을 추가하는 경우에도 코드 수정보다 ItemData.csv에 추가하는 방식으로 쉽게 추가할 수 있도록 관리
using System;
using System.Collections.Generic;
[Serializable]
public class RecipeDefinition
{
public int recipeId; // 레시피 고유 번호
public RecipeStation station; // 어떤 제작대로 하고 있는지 확인 2x2, 3x3
public int width; // 레시피 가로 수
public int height; // 레피시 세로 수
public List<string> patternKeys = new List<string>();
// 레시피 패턴을 행(row) 우선 순서로 저장
public int resultItemId; // 조합 결과 아이템 id
public int resultCount; // 결과 개수
public string GetPatternKey(int x, int y)
{
int index = y * width + x; // 2차원 좌표 x,y를 1차원 인덱스로 변환
if (index < 0 || index >= patternKeys.Count)
return string.Empty;
// 범위를 벗어난 경우 빈 문자열을 반환
// 빈 문자열은 레시피에서 빈 칸
return patternKeys[index];
// 해당 좌표의 레시피 key 반환
}
}
public enum RecipeStation
{
None, // 제작 공간 없음
Inventory2x2, // 플레이어 인벤토리 2x2
CraftingTable3x3 // 제작대 3x3
}
RecipeDefinition
CraftingTable.csv의 레시피 하하나의 데이터를 담는 클래스
using System.Collections.Generic;
using UnityEngine;
public static class RecipeDatabase
{
private static readonly List<RecipeDefinition> recipes = new List<RecipeDefinition>();
// 전체 레시피 리스트, csv에서 읽은 레시피를 저장
private static readonly Dictionary<int, RecipeDefinition> byId = new Dictionary<int, RecipeDefinition>();
// recipeid 기준 검색 Dictionary
public static bool IsLoaded { get; private set; } // 레시피가 잘 로드 되었는지
public static int Count => recipes.Count; // 현재 등록된 레시피 개수
public static IReadOnlyList<RecipeDefinition> Recipes => recipes;
// 외부에서 레시피 목록을 읽을 수 있게 제공
public static void Load(TextAsset recipeCsv)
{
Clear();
// 기존 레시피 데이터 초기화
if (recipeCsv == null)
{
Debug.LogError("[RecipeDatabase] CraftingTable.csv is not assigned.");
return;
}
List<Dictionary<string, string>> rows = CsvTableReader.Read(recipeCsv);
// 행 단위 Dictionary 리스트로 읽기
foreach (var row in rows)
{
RecipeDefinition recipe = ParseRecipe(row);
// CSV 한 행을 레시피로 변환
if (recipe == null)
continue;
recipes.Add(recipe);
// 전체 레시피 리스트에 추가
if (byId.ContainsKey(recipe.recipeId))
{
Debug.LogWarning($"[RecipeDatabase] Duplicate recipeId: {recipe.recipeId}");
}
else
{
byId.Add(recipe.recipeId, recipe);
}
// 레시피 id 기준 Dictionary에도 등록
// 중복 recipeid가 있다면 경고만 출력하고
// byid에는 먼저 등록된 레시피를 유지
}
IsLoaded = true; // 로드 완료
ValidateAgainstItemDatabase();
// 레시피에 사용된 item key와 결과 id가 실제 ItemDatabase에 존재하는지 검증
Debug.Log($"[RecipeDatabase] Loaded. Count={recipes.Count}");
}
public static void Clear()
{
recipes.Clear();
byId.Clear();
IsLoaded = false;
// 모든 레시피 데이터 초기화
}
public static bool TryGet(int recipeId, out RecipeDefinition recipe)
{
return byId.TryGetValue(recipeId, out recipe);
// 레시피id로 특정 레시피 찾기
}
public static bool TryFindRecipe(
RecipeStation station,
int gridWidth,
int gridHeight,
IReadOnlyList<int> gridItemIds,
out RecipeDefinition matchedRecipe)
// 현재 격자와 일치하는 레시피를 찾음
// gridwidth, height와 정확히 같아야 매칭
{
matchedRecipe = null;
if (gridItemIds == null)
return false;
int expectedCount = gridWidth * gridHeight;
// 필요한 슬롯 개수 계산
if (gridItemIds.Count < expectedCount)
return false;
for (int i = 0; i < recipes.Count; i++)
{
RecipeDefinition recipe = recipes[i]; // 등록된 레시피를 하나씩 확인
if (recipe.station != station)
continue;
if (recipe.width != gridWidth || recipe.height != gridHeight)
continue;
if (IsRecipeMatch(recipe, gridItemIds))
{
matchedRecipe = recipe;
return true;
}
// 현재 조합칸 상태와 레시피 패턴이 일치하면 성공 처리
}
return false;
// 일치하는 레시피 없음
}
private static bool IsRecipeMatch(RecipeDefinition recipe, IReadOnlyList<int> gridItemIds)
{
int count = recipe.width * recipe.height;
// 비교해야 할 칸 수
for (int i = 0; i < count; i++)
{
string requiredKey = i < recipe.patternKeys.Count ? recipe.patternKeys[i] : string.Empty;
// 레시피가 요구하는 key
int currentItemId = gridItemIds[i];
// 현재 조합칸의 아이템 id
if (string.IsNullOrWhiteSpace(requiredKey))
{
if (currentItemId > 0)
return false;
// 빈칸인데 있다면 실패 처리
continue;
}
if (currentItemId <= 0)
return false;
string currentKey = ItemDatabase.GetKey(currentItemId);
// 현재 아이템 id를 ItemDatabase를 통해 key 문자열로 변환
if (!requiredKey.Equals(currentKey, System.StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
// 모든 칸이 일치하면 레시피 매칭 성공
}
public static void ValidateAgainstItemDatabase()
{
if (!ItemDatabase.IsLoaded)
{
Debug.LogWarning("[RecipeDatabase] ItemDatabase is not loaded. Recipe validation skipped.");
return;
}
foreach (RecipeDefinition recipe in recipes)
{
if (!ItemDatabase.TryGet(recipe.resultItemId, out _))
{
Debug.LogWarning(
$"[RecipeDatabase] Recipe result item id does not exist. " +
$"recipeId={recipe.recipeId}, resultItemId={recipe.resultItemId}"
);
}
// 결과 아이템이 ItemData.csv에 존재하는지 확인
foreach (string key in recipe.patternKeys)
{
if (string.IsNullOrWhiteSpace(key))
continue;
// 빈칸은 검사 안함
if (!ItemDatabase.TryGet(key, out _))
{
Debug.LogWarning(
$"[RecipeDatabase] Recipe pattern item key does not exist in ItemData.csv. " +
$"recipeId={recipe.recipeId}, missingKey={key}"
);
}
// 패턴에 사용된 key가 ItemData.csv에 존재하는지 확인
}
}
}
private static RecipeDefinition ParseRecipe(Dictionary<string, string> row)
{
int recipeId = GetInt(row, "recipeId", 0);
// csv 레시피 값 읽기
if (recipeId <= 0)
{
Debug.LogWarning("[RecipeDatabase] Invalid recipeId.");
return null;
}
RecipeDefinition recipe = new RecipeDefinition
{
recipeId = recipeId, // 레시피 고유 번호
station = ParseStation(GetString(row, "station")), // 레시피 문자열을 리스트로
width = Mathf.Max(1, GetInt(row, "width", 1)), // 가로 크기 최소 1
height = Mathf.Max(1, GetInt(row, "height", 1)), // 세로 크기
resultItemId = GetInt(row, "resultItemId", 0), // 결과 아이템 id
resultCount = Mathf.Max(1, GetInt(row, "resultCount", 1)) // 결과 아이템 개수
};
// pattern = csv 안에 있는 목록 이름, 아이템 레시피
string pattern = GetString(row, "pattern"); // pattern 문자열 읽기
recipe.patternKeys = ParsePattern(pattern, recipe.width, recipe.height);
// Pattern 문자열을 행 우선 key 리스트로 변환
int expected = recipe.width * recipe.height;
// 패턴 칸 수
if (recipe.patternKeys.Count != expected)
{
Debug.LogWarning(
$"[RecipeDatabase] Pattern cell count mismatch. " +
$"recipeId={recipe.recipeId}, expected={expected}, actual={recipe.patternKeys.Count}"
);
}
return recipe;
// 완성된 레시피 반환
}
private static List<string> ParsePattern(string pattern, int width, int height)
{
List<string> result = new List<string>();
// 패턴 키를 저장할 리스트
if (string.IsNullOrWhiteSpace(pattern))
return result;
string[] rows = pattern.Split('|');
// 행은 | 이걸로 구분
for (int y = 0; y < rows.Length; y++)
{
string[] cells = rows[y].Split(',');
// 각 행의 칸은 , 로 구분
for (int x = 0; x < cells.Length; x++)
{
string key = NormalizeRecipeKey(cells[x]);
// 각 칸의 문자열을 정리해줌
result.Add(key);
// 행 우선 순서로 리스트에 추가
}
}
return result;
// 완성된 패턴 key 리스트 반환
}
private static RecipeStation ParseStation(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
return RecipeStation.None;
// 비어 있으면 None
if (System.Enum.TryParse(raw, ignoreCase: true, out RecipeStation station))
return station;
// 문자열을 RecipeStation enum으로 변환
// RecipeStation.Inventory2x2
Debug.LogWarning($"[RecipeDatabase] Unknown recipe station '{raw}'.");
return RecipeStation.None;
}
private static string NormalizeRecipeKey(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
string value = raw.Trim(); // 공백 제거
if (value.Equals("None", System.StringComparison.OrdinalIgnoreCase) ||
value.Equals("Null", System.StringComparison.OrdinalIgnoreCase) ||
value.Equals("-", System.StringComparison.OrdinalIgnoreCase))
// 빈칸
{
return string.Empty;
}
return value;
// 그 외의 값은 아이템 key 사용
}
private static string GetString(Dictionary<string, string> row, string key)
{
return row.TryGetValue(key, out string value) ? value.Trim() : string.Empty;
// Dictionary에서 문자열 값을 안전하게 가져옴
}
private static int GetInt(Dictionary<string, string> row, string key, int defaultValue)
{
string text = GetString(row, key);
// 문자열 값 가져오기
if (int.TryParse(text, out int value))
return value;
// int 변환 성공 시 반환
return defaultValue;
// 변환 실패 시 기본값 반환
}
}
RecipeDatabase
레시피를 저장하고 현재 조합칸 상태와 비교하는 역할
TryFindRecipe()를 통해 station, width, height가 맞는 레시피만 검사
IsRecipeMatch를 통해 각 칸을 비교하고 일치하면 matchedRecipe 반환해준다.
비교할떄 Itemid를 문자열 key로 바꾸어 비교해 csv에서는 문자열을 사용하고 게임 내부에서는 안정적인 숫자 id를 사용
using UnityEngine;
public class CraftingInventory : MonoBehaviour
{
private const int SIZE = 9; // 9칸 기본 설정
// 4칸 9칸을 이리저리 왔다갔다 하면 비효율적이니 필요한 칸만 보이도록
[SerializeField] private ItemStack[] slots = new ItemStack[SIZE];
// 실제 조합칸 아이템 저장 배열
public int Count => slots != null ? slots.Length : 0;
// 현재 슬롯 개수 반환
private void Awake()
{
EnsureSize();
// 런타임 시작 시 슬롯 배열 크기를 보정
}
private void OnValidate()
{
EnsureSize();
// 에디터에서 컴포넌트 클릭 시, 값 수정, 인스펙터 변경 시 호출해줌
// 자동으로 9칸 보정
}
private void EnsureSize()
{
if (slots == null || slots.Length != SIZE)
{
// 슬롯이 null이거나 size가 9가 아니면 재생성
var old = slots;
slots = new ItemStack[SIZE];
// 기존 값이 있으면 가능한 만큼 복사
// 데이터 유실 방지
if (old != null)
{
int n = Mathf.Min(old.Length, slots.Length);
for (int i = 0; i < n; i++) slots[i] = old[i];
}
}
// 빈칸은 None으로 정리
for (int i = 0; i < slots.Length; i++)
{
if (slots[i].count <= 0 || slots[i].id == ItemId.None)
slots[i] = new ItemStack(ItemId.None, 0);
}
}
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;
// 해당 슬롯에 ItemStack 저장
}
public void ClearAll()
{
for (int i = 0; i < slots.Length; i++)
slots[i] = new ItemStack(ItemId.None, 0);
// 모든 조합칸을 빈 슬롯으로 초기화
}
}
CraftingInventory
조합칸의 실제 데이터를 저장하는 클래스로
9칸의 아이템 데이터를 저장하고 CraftingUIController가 현재 모드에 따라 어느 칸을 사용할지 결정
using System.Collections.Generic;
using UnityEngine;
public static class CraftingRecipeResolver
{
public struct CraftResult
{
public bool success; // 조합 성공 여부
public int resultItemId; // 결과 아이템 ID
public int resultCount; // 결과 아이템 개수
public int[] consumeCounts; // 어느 칸에서 몇개를 소비 할지 나타내는 배열
// 여러개의 아이템이 들어가있으나 조합식은 맞은 경우 만드는데 필요한 개수만 딱 사라지게 하기위함
public static CraftResult Empty(int modelSlotCount = 9)
{
return new CraftResult
{
success = false,
resultItemId = 0,
resultCount = 0,
consumeCounts = new int[modelSlotCount]
};
// 조합 실패 상태 반환
}
}
public static CraftResult Resolve(
RecipeStation station,
int gridWidth,
int gridHeight,
IReadOnlyList<int> gridItemIds,
IReadOnlyList<int> sourceSlotIndices,
int modelSlotCount = 9)
{
if (!RecipeDatabase.IsLoaded)
{
Debug.LogWarning("[CraftingRecipeResolver] RecipeDatabase is not loaded.");
return CraftResult.Empty(modelSlotCount);
}
if (!ItemDatabase.IsLoaded)
{
Debug.LogWarning("[CraftingRecipeResolver] ItemDatabase is not loaded.");
return CraftResult.Empty(modelSlotCount);
}
if (gridItemIds == null || sourceSlotIndices == null)
return CraftResult.Empty(modelSlotCount);
if (TryResolveExact(station, gridWidth, gridHeight, gridItemIds, sourceSlotIndices, modelSlotCount, out CraftResult exact))
return exact;
if (station == RecipeStation.CraftingTable3x3 && gridWidth == 3 && gridHeight == 3)
{
if (TryResolve2x2Inside3x3(gridItemIds, modelSlotCount, out CraftResult smallRecipe))
return smallRecipe;
}
// 제작대 3x3 에서는 기본 2x2의 레시피도 허용
return CraftResult.Empty(modelSlotCount);
// 맞는 레시피가 없으면 실패 반환
}
private static bool TryResolveExact(
RecipeStation station,
int gridWidth,
int gridHeight,
IReadOnlyList<int> gridItemIds,
IReadOnlyList<int> sourceSlotIndices,
int modelSlotCount,
out CraftResult result)
{
result = CraftResult.Empty(modelSlotCount);
// 기본값은 실패 상태
if (!RecipeDatabase.TryFindRecipe(station, gridWidth, gridHeight, gridItemIds, out RecipeDefinition recipe))
return false;
// RecipeDatabase에서 현재 grid 상태와 일치하는 레시피가 있는지 확인
result = BuildResult(recipe, sourceSlotIndices, modelSlotCount);
// 찾은 레시피를 CraftResult로 변환
return true;
}
private static bool TryResolve2x2Inside3x3(
IReadOnlyList<int> grid3x3,
int modelSlotCount,
out CraftResult result)
{
result = CraftResult.Empty(modelSlotCount);
// 3x3 안에서 가능한 2x2 영역들
int[][] windows =
{
new[] { 0, 1, 3, 4 },
new[] { 1, 2, 4, 5 },
new[] { 3, 4, 6, 7 },
new[] { 4, 5, 7, 8 }
};
for (int w = 0; w < windows.Length; w++)
{
int[] window = windows[w];
// 현재 검사할 2x2
if (!OutsideWindowEmpty(grid3x3, window))
continue;
// 영역 밖에 아이템이 있다면 2x2 레시피로 안쳐줌
List<int> ids2x2 = new List<int>
{
grid3x3[window[0]],
grid3x3[window[1]],
grid3x3[window[2]],
grid3x3[window[3]]
};
// 현재 2x2 영역의 아이템 id들을 2x2 행 우선 순서로 추출
if (RecipeDatabase.TryFindRecipe(
RecipeStation.Inventory2x2,
2,
2,
ids2x2,
out RecipeDefinition recipe))
{
result = BuildResult(recipe, window, modelSlotCount);
return true;
}
// Inventory 2x2 레시피 중 현재 2x2 영역과 일치하는 것이 있는지 검사
// 일치하면 실제 소비 슬롯을 3x3의 window index 사용
}
return false;
// 일치하는 레시피 없음
}
private static bool OutsideWindowEmpty(IReadOnlyList<int> grid3x3, IReadOnlyList<int> window)
{
for (int i = 0; i < 9; i++)
{
bool inside = false;
// 현재 검사중인 2x2 안에 있는지 확인
for (int j = 0; j < window.Count; j++)
{
if (window[j] == i)
{
inside = true;
break;
}
}
// window 배열 안에 i가 있다면 inside = true로
if (inside)
continue;
// window 안의 칸은 검사하지 않는다.
if (grid3x3[i] > 0)
return false;
// window 밖에 아이템이 있다면 2x2레시피로 안쳐줌
}
return true;
}
private static CraftResult BuildResult(
RecipeDefinition recipe,
IReadOnlyList<int> sourceSlotIndices,
int modelSlotCount)
{
CraftResult result = new CraftResult
{
success = true,
resultItemId = recipe.resultItemId,
resultCount = recipe.resultCount,
consumeCounts = new int[modelSlotCount]
};
// 레시피 결과를 CraftResult로 변환
for (int i = 0; i < recipe.patternKeys.Count && i < sourceSlotIndices.Count; i++)
{
string key = recipe.patternKeys[i];
// 레시피의 i번쨰 칸 key
if (string.IsNullOrWhiteSpace(key))
continue;
// 빈 칸은 소비할 재료가 없으므로 건너뛰기
int sourceIndex = sourceSlotIndices[i];
// 실제 CraftingInventory에서 소비해야 할 슬롯 index
if (sourceIndex < 0 || sourceIndex >= result.consumeCounts.Length)
continue;
// 범위를 벗어나면 무시
result.consumeCounts[sourceIndex] = 1;
// 레피시 칸 하나당 아이템 1개 소모
}
return result;
//제작 결과 반환
}
}
CraftingRecipeResolver
제작 계산 클래스로 현재 조합칸 상태 RecipeDatabase와 비교하고 결과 아이템 계산 어느 슬롯에서 재료를 소비할지 계산
하고 해당 정보를 CraftingUIController로 보내 실제로 작업해줌1
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class CraftingUIController : MonoBehaviour
{
private static readonly int[] HiddenFor2x2 = { 2, 5, 6, 7, 8 };
// 기본 9칸, 2x2인 경우 2,5,6,7,8 슬롯 숨겨줌
private CraftingInventory craftingInv; // 실제 제작칸 아이템 데이터 저장
private PlayerInventory playerInv; // 결과 아이템을 넣어줄 플레이어 인벤토리
private HotbarInventory hotbar; // 인벤토리가 가득 찬 상태면 핫바로
private ItemIconDb iconDb; // 결과 아이콘 표시
private List<UIItemSlotView> craftViews; // 제작 입력 슬롯 UI 목록
private UIItemSlotView resultView; // 제작 결과 슬롯 UI
private GridLayoutGroup gridLayout; // 제작칸 UI 컨트롤 2x2, 3x3
private Action onInventoryChanged; // 제작 후 전체 인벤토리 갱신 콜백
private InventoryUIController.CraftingMode craftingMode = InventoryUIController.CraftingMode.Player2x2;
// 현재 제작 모드, 기본 2x2
private ItemStack currentResultStack; // 현재 조합칸 상태로 만들 수 있는 결과 아이템
private int[] currentConsumeCounts = new int[9]; // 각 칸에서 소모될 아이템
public void Initialize(
CraftingInventory craftingInv,
PlayerInventory playerInv,
HotbarInventory hotbar,
ItemIconDb iconDb,
List<UIItemSlotView> craftViews,
UIItemSlotView resultView,
GridLayoutGroup gridLayout,
Action onInventoryChanged)
{
this.craftingInv = craftingInv; // 제작 데이터 모델 연결
this.playerInv = playerInv; // 일반 인벤토리 연결
this.hotbar = hotbar; // 핫바 연결
this.iconDb = iconDb; // 아이콘 DB 연결
this.craftViews = craftViews; // 제작 입력 슬롯 UI 목록
this.resultView = resultView; // 결과 슬롯 UI
this.gridLayout = gridLayout; // 제작칸
this.onInventoryChanged = onInventoryChanged; // UI 갱신 콜백
currentResultStack = new ItemStack(ItemId.None, 0);
// 초기 결과 X
if (currentConsumeCounts == null || currentConsumeCounts.Length != 9)
currentConsumeCounts = new int[9];
// 소비 배열이 없거나 크기가 잘못되었다면 9칸으로 다시 만들기
}
public void Refresh()
{
RefreshInputSlots(); // 제작 입력 슬롯 UI를 현재 CraftingInventory 상태에 맞게
RefreshResultSlot(); // 현재 조합 결과를 계산하고 결과 슬롯 UI를 갱신
}
public void SetCraftingMode(InventoryUIController.CraftingMode mode, bool notifyRefresh = true)
{
craftingMode = mode;
// 현재 제작 모드 저장
bool use3x3 = mode == InventoryUIController.CraftingMode.Table3x3;
// 3x3 제작대 모드인지 확인
if (gridLayout != null)
{
gridLayout.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
// 고정 Column count 방식으로 사용
gridLayout.constraintCount = use3x3 ? 3 : 2;
// 3x3 모드면 3, 3x3 는 2
gridLayout.enabled = true;
// grid 활성화
}
if (craftViews != null)
{
for (int i = 0; i < craftViews.Count; i++)
{
if (craftViews[i] != null)
craftViews[i].gameObject.SetActive(true);
}
// 모든 제작 슬롯 활성화
// 3x3 -> 2x2 매번 전체를 켜고 필요한 슬롯만 다시 끄는 방식
if (!use3x3)
{
foreach (int index in HiddenFor2x2)
{
if (index >= 0 && index < craftViews.Count && craftViews[index] != null)
craftViews[index].gameObject.SetActive(false);
}
}
// 2x2 모드일 경우 2,5,6,7,8 슬롯을 숨겨줌
}
if (!use3x3)
ReturnExtraCraftItemsToInventory();
// 3x3에서 2x2로 돌아갈때
// 제작대에서 아이템 올리고 인벤토리로 키거나, 일정 거리 떨어지게 되어 풀리게 되면
// 숨겨지는 슬롯에 있던 아이템은 자동으로 인벤토리.핫바로
if (gridLayout != null)
{
Canvas.ForceUpdateCanvases(); // Canvas 레이아웃을 즉시 갱신
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform)gridLayout.transform);
// gird 다시 계산
}
Refresh();
// 모든 변경 후 제작 슬롯과 결과 슬롯 갱신
if (notifyRefresh)
onInventoryChanged?.Invoke();
// 필요하다면 전체 인벤토리 UI도 갱신
}
public bool TryCraft()
{
EvaluateCraftResult();
// 현재 조합칸 상태를 다시 계산
if (currentResultStack.IsEmpty)
return false;
// 만들 수 있는 결과 X 면 false
if (craftingInv == null)
return false;
// 제작 인벤토리가 없다면 제작 불가
if (!CanAddResult(currentResultStack.id, currentResultStack.count))
return false;
// 결과 아이템을 인벤토리, 핫바에 넣을 공간이있는지 확인
bool added = AddResultToInventories(currentResultStack.id, currentResultStack.count);
// 결과 아이템을 인벤토리,핫바에 추가
if (!added)
return false;
// 추가에 실패하면 제작 중단
ConsumeIngredients();
// 결과 아이템이 정상적으로 추가된 다음 재료 소비
onInventoryChanged?.Invoke(); // 전체 인벤토리 ui 갱신
return true; // 제작 성공
}
private void RefreshInputSlots()
{
if (craftingInv == null || craftViews == null)
return;
for (int i = 0; i < craftViews.Count; i++)
{
if (craftViews[i] == null)
continue;
ItemStack stack = craftingInv.Get(i); // i번쨰 슬롯 상태 가져오기
craftViews[i].Set(stack.id, stack.count, Icon(stack.id));
// UI 슬롯에 아이템 id, 개수, 아이콘 표시
}
}
private void RefreshResultSlot()
{
EvaluateCraftResult();
// 현재 조합칸 상태를 기준으로 결과 아이템 계산
if (resultView != null)
resultView.Set(currentResultStack.id, currentResultStack.count, Icon(currentResultStack.id));
// 결과 슬롯 UI에 결과 아이템을 표시한다
// 결과가 없다면 ItemId.None 0이 표시되어 빈 슬롯처럼 보이도록
}
private void EvaluateCraftResult()
{
ClearConsumeCounts();
// 이전 결과의 소비 정보 초기화
currentResultStack = new ItemStack(ItemId.None, 0);
// 현재 결과 초기화
if (craftingInv == null || craftingInv.Count < 9)
return;
// CraftingInventory가 없더나 9칸보다 작으면 계산 불가
List<int> gridItemIds = new List<int>();
// 현재 조합칸 ItemId 목록
List<int> sourceSlotIndices = new List<int>();
// gridItemId의 각 칸이 실제 CraftingInventory의 몇 번 슬롯인지 저장
// 2x2 모드에서는 UI grid index와 CraftingInventory index가 다르기 떄문
RecipeStation station;
int width;
int height;
if (craftingMode == InventoryUIController.CraftingMode.Table3x3)
{
station = RecipeStation.CraftingTable3x3;
width = 3;
height = 3;
for (int i = 0; i < 9; i++)
{
ItemStack stack = craftingInv.Get(i);
gridItemIds.Add(stack.IsEmpty ? 0 : (int)stack.id);
sourceSlotIndices.Add(i);
}
// 3x3 모드에서는 0~8 슬롯 전체를 그대로 사용한다
}
else
{
station = RecipeStation.Inventory2x2;
width = 2;
height = 2;
int[] visible2x2 = { 0, 1, 3, 4 };
// 현재 UI 구조상 2x2 제작은
// CraftingInventory의 0,1,3,4 슬롯을 사용
for (int i = 0; i < visible2x2.Length; i++)
{
int sourceIndex = visible2x2[i];
ItemStack stack = craftingInv.Get(sourceIndex);
gridItemIds.Add(stack.IsEmpty ? 0 : (int)stack.id);
sourceSlotIndices.Add(sourceIndex);
}
// Resolver 에는 2x2 grid로 넘기나
// 실제 소비하는 CraftingInventory index는 0,1,3,4 로 전달
}
CraftingRecipeResolver.CraftResult result =
CraftingRecipeResolver.Resolve(
station,
width,
height,
gridItemIds,
sourceSlotIndices,
9
);
// 현재 조합칸 상태를 CSV 레시피와 비교한다
if (!result.success || result.resultItemId <= 0)
return;
ItemId resultId = (ItemId)result.resultItemId;
// 결과 itemId를 ItemId enum으로 변환
currentResultStack = new ItemStack(resultId, result.resultCount);
// 현재 결과 아이템 저장
if (result.consumeCounts != null)
{
for (int i = 0; i < currentConsumeCounts.Length && i < result.consumeCounts.Length; i++)
currentConsumeCounts[i] = result.consumeCounts[i];
}
// 계산된 소비 정보를 현재 컨트롤러에 저장
}
private void ConsumeIngredients()
{
if (craftingInv == null)
return;
for (int i = 0; i < currentConsumeCounts.Length; i++)
{
int consume = currentConsumeCounts[i];
// i 번쨰 슬롯에서 소비할 개수
if (consume <= 0)
continue;
// 소비할 필요가 없으면 건너뛴다
ItemStack stack = craftingInv.Get(i);
// 현재 제작 슬롯 아이템
if (stack.IsEmpty)
continue;
// 빈 슬롯이면 소비할 수 없음
stack.count -= consume;
// 아이템 개수 감소
if (stack.count <= 0)
stack = new ItemStack(ItemId.None, 0);
// 개수가 0 이하가 되면 빈 슬롯으로 만듬
craftingInv.Set(i, stack);
// 변경된 슬롯 상태 저장
}
ClearConsumeCounts();
// 소비 완료 후 소비 배열 초기화
}
private void ReturnExtraCraftItemsToInventory()
{
if (craftingInv == null)
return;
foreach (int index in HiddenFor2x2)
{
ItemStack stack = craftingInv.Get(index);
// 2x2 모드에서 숨겨지는 슬롯의 아이템 확인
if (stack.IsEmpty)
continue;
// 비어 있으면 처리 x
if (!CanAddResult(stack.id, stack.count))
{
Debug.LogWarning($"[CraftingUIController] No space to return item {stack.id} x{stack.count} from crafting slot {index}.");
continue;
}
// 숨겨질 슬롯의 아이템을 돌릴 공간이 없는경우, 경고
// 아이템 강제제거 x
bool added = AddResultToInventories(stack.id, stack.count);
// 인벤토리 또는 핫바에 아이템을 반환
if (added)
craftingInv.Set(index, new ItemStack(ItemId.None, 0));
// 반환에 성공하면 숨겨질 제작 슬롯은 비움
}
}
private bool CanAddResult(ItemId id, int count)
{
if (id == ItemId.None || count <= 0)
return false;
// 유효한 아이템만 추가
int remaining = count;
int maxStack = ItemDb.MaxStack(id);
// 아이템의 최대 스택 수를 가져옴
if (playerInv != null)
remaining = SimulateAddToPlayerInventory(id, remaining, maxStack);
if (remaining <= 0)
return true;
// 전부 들어갈 수 있다면 true
if (hotbar != null)
remaining = SimulateAddToHotbar(id, remaining, maxStack);
// 일반 인벤토리에 다 못 넣는 경우 핫바도 확인
return remaining <= 0;
// 남은 개수가 0 이하면 추가 가능
}
private int SimulateAddToPlayerInventory(ItemId id, int count, int maxStack)
{
int remaining = count;
for (int i = 0; i < playerInv.Count; i++)
{
ItemStack stack = playerInv.Get(i);
if (stack.IsEmpty)
{
remaining -= Mathf.Min(maxStack, remaining);
}
else if (stack.id == id && stack.count < maxStack)
{
remaining -= Mathf.Min(maxStack - stack.count, remaining);
}
if (remaining <= 0)
return 0;
}
return remaining;
}
private int SimulateAddToHotbar(ItemId id, int count, int maxStack)
{
int remaining = count;
for (int i = 0; i < hotbar.SlotCount; i++)
{
ItemStack stack = hotbar.GetSlot(i);
if (stack.IsEmpty)
{
remaining -= Mathf.Min(maxStack, remaining);
}
else if (stack.id == id && stack.count < maxStack)
{
remaining -= Mathf.Min(maxStack - stack.count, remaining);
}
if (remaining <= 0)
return 0;
}
return remaining;
}
private bool AddResultToInventories(ItemId id, int count)
{
int remaining = count;
int maxStack = ItemDb.MaxStack(id);
// 추가할 아이템의 최대 스택 수
if (playerInv != null)
remaining = AddToPlayerInventory(id, remaining, maxStack);
// 일반 인벤토리에 추가
if (remaining > 0 && hotbar != null)
remaining = AddToHotbar(id, remaining, maxStack);
// 다 안들어가면 핫바로
return remaining <= 0;
}
private int AddToPlayerInventory(ItemId id, int count, int maxStack)
{
int remaining = count;
for (int i = 0; i < playerInv.Count; i++)
{
ItemStack stack = playerInv.Get(i);
if (stack.IsEmpty)
continue;
if (stack.id != id)
continue;
if (stack.count >= maxStack)
continue;
int add = Mathf.Min(maxStack - stack.count, remaining);
stack.count += add;
remaining -= add;
playerInv.Set(i, stack);
if (remaining <= 0)
return 0;
}
// 같은 아이템이 들어 있는 기존 스택에 합쳐줌
for (int i = 0; i < playerInv.Count; i++)
{
ItemStack stack = playerInv.Get(i);
if (!stack.IsEmpty)
continue;
int add = Mathf.Min(maxStack, remaining);
playerInv.Set(i, new ItemStack(id, add));
remaining -= add;
if (remaining <= 0)
return 0;
}
// 다 합치지 못하면 새로운 스택 만들고
// 남는거 채우기
return remaining;
}
private int AddToHotbar(ItemId id, int count, int maxStack)
{
int remaining = count;
for (int i = 0; i < hotbar.SlotCount; i++)
{
ItemStack stack = hotbar.GetSlot(i);
if (stack.IsEmpty)
continue;
if (stack.id != id)
continue;
if (stack.count >= maxStack)
continue;
int add = Mathf.Min(maxStack - stack.count, remaining);
stack.count += add;
remaining -= add;
hotbar.SetSlot(i, stack.id, stack.count);
if (remaining <= 0)
return 0;
}
// 핫바의 기존 아이템 스택에 합치기
for (int i = 0; i < hotbar.SlotCount; i++)
{
ItemStack stack = hotbar.GetSlot(i);
if (!stack.IsEmpty)
continue;
int add = Mathf.Min(maxStack, remaining);
hotbar.SetSlot(i, id, add);
remaining -= add;
if (remaining <= 0)
return 0;
}
// 빈 핫바 슬롯에 새 스택만들고 남은거 더해주기
return remaining;
}
private void ClearConsumeCounts()
{
if (currentConsumeCounts == null || currentConsumeCounts.Length != 9)
currentConsumeCounts = new int[9];
for (int i = 0; i < currentConsumeCounts.Length; i++)
currentConsumeCounts[i] = 0;
// 소모한 정보 0으로
}
private Sprite Icon(ItemId id)
{
return iconDb != null ? iconDb.Get(id) : null;
// ItemIconDb에서 아이콘을 가져옴, 없으면 null
}
}
CraftingUIController
기존 InventoryUIController에서 하드 코딩을 통해 제작 레시피를 간단하게 동작시켰는데
csv파일의 추가와 함께 많은 양의 아이템을 추가함에 따라 책임 분리를 위해 CraftingUIController를 만들어
제작 관련 로직을 맡아 관리하고
InventoryUIController에서 인벤토리의 역할만 하도록 관리함
[Header("Crafting")]
[SerializeField] private GridLayoutGroup craftingGridLayout;
[SerializeField] private CraftingUIController craftingUI;
private void Start()
{
CacheAndBindAllViews();
SetupCraftingController();
RefreshAll();
if (dragIcon != null)
dragIcon.enabled = false;
}
private void SetupCraftingController()
{
if (craftingUI == null)
craftingUI = GetComponent<CraftingUIController>();
if (craftingUI == null)
craftingUI = gameObject.AddComponent<CraftingUIController>();
craftingUI.Initialize(
craftingInv,
playerInv,
hotbar,
iconDb,
craftViews,
craftingResult,
craftingGridLayout,
RefreshAll
);
craftingUI.SetCraftingMode(craftingMode, notifyRefresh: false);
}
////////////////////////////////////// private void CacheAndBindAllViews() 안에 추가
if (craftingGridParent != null)
{
UIItemSlotView[] craftingSlotArray =
craftingGridParent.GetComponentsInChildren<UIItemSlotView>(true);
craftViews.AddRange(craftingSlotArray);
for (int i = 0; i < craftViews.Count; i++)
craftViews[i].Bind(this, SlotGroup.CraftingInput, i);
}
//////////////////////////////
public void OnSlotClickSelect(SlotGroup group, int index)
{
if (group == SlotGroup.CraftingResult)
{
craftingUI?.TryCraft();
return;
}
if (group == SlotGroup.Hotbar && hotbar != null)
{
hotbar.Select(index);
RefreshHighlights();
}
}
public void OnSlotClicked(SlotGroup group, int index)
{
if (group == SlotGroup.CraftingResult)
{
craftingUI?.TryCraft();
return;
}
if (!hasPick)
{
ItemStack stack = GetStack(group, index);
if (stack.IsEmpty)
return;
hasPick = true;
pickGroup = group;
pickIndex = index;
RefreshHighlights();
return;
}
if (pickGroup == group && pickIndex == index)
{
hasPick = false;
RefreshHighlights();
return;
}
TryMoveOrSwap(pickGroup, pickIndex, group, index);
hasPick = false;
RefreshAll();
}
private void RefreshCrafting()
{
if (craftingUI != null)
craftingUI.Refresh();
}
public void SetCraftingMode(CraftingMode mode)
{
// 제작대와 상호작용하면 Table3x3 모드로 바꾸고, 제작대를 닫으면 2x2로
craftingMode = mode;
if (craftingUI != null)
{
craftingUI.SetCraftingMode(craftingMode);
}
else
{
RefreshAll();
}
}
InventoryUIContoller
일부 수정, CraftingUIController와 연결하고 기존에 있던 제작 코드 삭제
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이라면 리빌드해서 복구해주기
}
}
ItemIconDb
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
public static class ItemIconDbAutoFiller
{
private const string ItemDataCsvPath = "Assets/Data/CSV/ItemData.csv";
// 아이템 데이터 경로
private const string ImageRootPath = "Assets/Image";
// 아이콘 Sprite 검색 폴더
[MenuItem("Tools/MineUnity/Auto Fill Selected ItemIconDb")]
private static void AutoFillSelectedItemIconDb()
{
ItemIconDb db = Selection.activeObject as ItemIconDb;
if (db == null)
{
Debug.LogWarning("[ItemIconDbAutoFiller] Select an ItemIconDb asset first.");
return;
}
TextAsset itemDataCsv = AssetDatabase.LoadAssetAtPath<TextAsset>(ItemDataCsvPath);
// 경로에서 ItemData.csv를 TextAsset으로 로드
if (itemDataCsv == null)
{
Debug.LogError($"[ItemIconDbAutoFiller] ItemData.csv not found. Path={ItemDataCsvPath}");
return;
}
Dictionary<int, List<string>> candidatesById = BuildCandidatesFromCsv(itemDataCsv);
// CSV에서 각 item id별 아이콘 후보 문자열 목록 만들기
Dictionary<string, Sprite> spritesByNormalizedName = LoadSprites();
// Sprite를 모두 읽고
// 정규화된 Sprite 이름을 key로 Dictionary 만듬
SerializedObject serializedDb = new SerializedObject(db);
SerializedProperty entriesProp = serializedDb.FindProperty("entries");
// ItemIconDb 안의 private 필드 entries를 찾는다
if (entriesProp == null)
{
Debug.LogError("[ItemIconDbAutoFiller] Could not find 'entries' property in ItemIconDb.");
return;
}
entriesProp.ClearArray();
// 기존 entries 모두 비우기
int added = 0; // 자동 등록에 성공한 아이콘
int missing = 0; // 아이콘 찾지 못한 아이템 개수
foreach (ItemId itemId in Enum.GetValues(typeof(ItemId)))
{
if (itemId == ItemId.None)
continue;
int rawId = (int)itemId; // 아이템 enum을 int로 변환
if (!candidatesById.TryGetValue(rawId, out List<string> candidates))
{
// CSV에 없는 enum은 스킵한다.
continue;
}
Sprite sprite = FindSprite(candidates, spritesByNormalizedName);
if (sprite == null)
{
missing++;
Debug.LogWarning(
$"[ItemIconDbAutoFiller] Missing icon. " +
$"ItemId={itemId}, candidates={string.Join(", ", candidates)}"
);
continue;
}
int index = entriesProp.arraySize;
entriesProp.InsertArrayElementAtIndex(index);
SerializedProperty entryProp = entriesProp.GetArrayElementAtIndex(index);
SerializedProperty idProp = entryProp.FindPropertyRelative("id");
SerializedProperty iconProp = entryProp.FindPropertyRelative("icon");
idProp.intValue = rawId;
iconProp.objectReferenceValue = sprite; // Entry.icon에 찾은 Sprite 넣기
added++; // 아이콘 등록 성공 개수 증가
}
serializedDb.ApplyModifiedProperties();
// 수정한값을 ItemIconDb 애셋에 반영
EditorUtility.SetDirty(db); // 애셋이 수정 되었음을 알림
AssetDatabase.SaveAssets(); // 수정된 애셋 저장
Debug.Log(
$"[ItemIconDbAutoFiller] Complete. " +
$"Added={added}, Missing={missing}, DB={db.name}"
);
}
private static Dictionary<int, List<string>> BuildCandidatesFromCsv(TextAsset itemDataCsv)
{
Dictionary<int, List<string>> result = new Dictionary<int, List<string>>();
List<Dictionary<string, string>> rows = CsvTableReader.Read(itemDataCsv);
foreach (Dictionary<string, string> row in rows)
{
int id = GetInt(row, "id", 0);
if (id <= 0)
continue;
string key = GetString(row, "key");
string displayName = GetString(row, "displayName");
string iconKey = GetString(row, "iconKey");
List<string> candidates = new List<string>();
AddCandidate(candidates, iconKey);
AddCandidate(candidates, key);
AddCandidate(candidates, displayName);
result[id] = candidates;
}
// csv 파일을 읽고 아이콘 등록
return result;
}
private static Dictionary<string, Sprite> LoadSprites()
{
Dictionary<string, Sprite> result = new Dictionary<string, Sprite>();
string[] guids = AssetDatabase.FindAssets("t:Sprite", new[] { ImageRootPath });
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
Sprite sprite = AssetDatabase.LoadAssetAtPath<Sprite>(path);
if (sprite == null)
continue;
string normalized = Normalize(sprite.name);
if (!result.ContainsKey(normalized))
result.Add(normalized, sprite);
}
Debug.Log($"[ItemIconDbAutoFiller] Loaded Sprites={result.Count} from {ImageRootPath}");
return result;
}
private static Sprite FindSprite(
List<string> candidates,
Dictionary<string, Sprite> spritesByNormalizedName)
{
for (int i = 0; i < candidates.Count; i++)
{
string normalized = Normalize(candidates[i]);
if (spritesByNormalizedName.TryGetValue(normalized, out Sprite sprite))
return sprite;
}
return null;
}
private static void AddCandidate(List<string> list, string value)
{
if (string.IsNullOrWhiteSpace(value))
return;
if (value.Equals("None", StringComparison.OrdinalIgnoreCase))
return;
if (!list.Contains(value))
list.Add(value);
}
private static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
return value
.Trim()
.ToLowerInvariant()
.Replace("_", "")
.Replace("-", "")
.Replace(" ", "");
}
private static string GetString(Dictionary<string, string> row, string key)
{
return row.TryGetValue(key, out string value) ? value.Trim() : string.Empty;
// csv 행에서 문자열 값을 안전하게 가져옴
}
private static int GetInt(Dictionary<string, string> row, string key, int defaultValue)
{
string text = GetString(row, key);
if (int.TryParse(text, out int value))
return value;
// int 변환
return defaultValue;
}
}
#endif
ItemIconDbAutoFiller
유니티 에디터에서 동작하는 클래스로
아이콘 DB를 자동생성하는 역할을 함 Image 폴더에 Sprite로 만든 아이콘을 넣으면 찾아서 자동 등록을 해줌
테스트 동작 영상
음 테스트를 해보니 일부 레시피의 결과가 다르게 나오는것을 확인 수정 하겠음..
'Unity' 카테고리의 다른 글
| Unity(14) - Furnace System & Equipment Stat (0) | 2026.07.11 |
|---|---|
| Unity(13) - Character Equipment (1) | 2026.06.23 |
| Unity(11) - Stage Select & Lobby (0) | 2026.05.31 |
| Unity(10) - Take Damage Event & UI (0) | 2026.04.28 |
| Unity(9) - Random Spawn Monster & A* (0) | 2026.04.27 |