0.들어가기에 앞서
기획에서 뭐가 나올지 모르지만, 여전히 대비를 해야 할 것이다.
일단 지금 할 수 있는 공부에 전념을 하고, 최종적으로 나온 기획안에 따라 업무를 분담 받고 구현에 집중해야 할 것이다.
6/8일자에 정리했던 주말 특강의 내용을 리뷰하고 다시 정리해야 할 것이다.
특강의 내용은 Json을 이용한 세이브 데이터 로컬 저장 기법, Scriptable Object를 활용한 예시 등이 있었다.
특히나 Scriptable Object를 통한 아이템 구성 및 인벤토리의 구성법에 대한 부분을 집중적으로 보고, 몬스터 및 퀘스트 구성 등의 방식도 참고해볼 생각이다.
https://www.youtube.com/playlist?list=PL-hj540P5Q1hLK7NS5fTSNYoNJpPWSL24
유튜브에 인벤토리 관련으로 10시간 분량의 강의를 추천받았다. 우선은 특강 내용 복습을 마무리한 후 인벤토리 관련 튜토리얼을 배워보자.
세이브 데이터를 만들기 위해서는, 어떤 데이터를 저장해야 하는지에 대한 고민이 필요하다.
아래와 같이 게임 데이터라는 클래스를 만들어보고, 저장할 데이터를 고민해보자.
[System.Serializable]
public partial class GameData // 이 게임에 필요한 모든 저장 데이터를 보관하는 객체
{
public int level;
public int exp;
public string name;
}
여기에서 Partial로 만든 이유는, 예를 들어 공통적으로 만들 데이터는 이렇게 한 데 모아두고, 인벤토리에서만 사용할 데이터는 다른 쪽에 Partial로 만들어 분리 저장하는 것이 편할 수도 있기 때문이다. 특히 팀 프로젝트로 진행할 경우 자신이 필요한 데이터들을 자신 쪽에서 Partial로 만들어 관리하는 편이 유지보수에 더 좋을 수도 있다. (취향적인 문제로, 한 곳에 모아 관리하는 것도 나쁘지 않은 선택이다.)
또한 System.Serializable을 선언함으로서 인스펙터 창에서 해당 데이터를 확인하거나 설정할 수 있도록 한다.
이런 데이터를 관리할 데이터 매니저를 만들어 주고, 아래와 같이 데이터를 설정할 수 있도록 해 보자.
using System;
using System.IO;
using UnityEngine;
public class DataManager : MonoBehaviour
{
#region Singleton
public static DataManager Instance { get; private set; }
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
#endregion // Singleton
public GameData gameData;
public const string FileName = "SaveFile";
#if UNITY_EDITOR
public string path => Path.Combine(Application.dataPath, $"Data/{FileName}"); // 제작 당시에 확인하는 경로
#else
public string path => Path.Combine(Application.persistentDataPath, $"Data/{FileName}"); // 실제 출시에 써야 할 경로
#endif
public event Action OnFileChanged;
// 테스트용 코드
private void Update()
{
if (Input.GetKeyDown(KeyCode.Z))
{
SaveData(0);
}
if (Input.GetKeyDown(KeyCode.X))
{
LoadData(0);
}
}
public void NewData()
{
gameData = new GameData();
}
// 데이터 저장
public void SaveData(int slot)
{
if (Directory.Exists(Path.GetDirectoryName($"{path}_{slot}")) == false)
{
Debug.Log("폴더가 없어서 생성했습니다");
Directory.CreateDirectory(Path.GetDirectoryName($"{path}_{slot}"));
}
string json = JsonUtility.ToJson(gameData, true);
File.WriteAllText($"{path}_{slot}", json);
Debug.Log(json);
OnFileChanged?.Invoke();
}
// 데이터 로드
public bool LoadData(int slot)
{
if (Directory.Exists(Path.GetDirectoryName($"{path}_{slot}")) == false)
return false;
if (File.Exists($"{path}_{slot}") == false)
return false;
string json = File.ReadAllText($"{path}_{slot}");
gameData = JsonUtility.FromJson<GameData>(json);
return true;
}
// 데이터 존재여부 확인
public bool ExistData(int slot)
{
if (Directory.Exists(Path.GetDirectoryName($"{path}_{slot}")) == false)
return false;
return File.Exists($"{path}_{slot}");
}
// 데이터 삭제
public bool RemoveData(int slot)
{
if (Directory.Exists(Path.GetDirectoryName($"{path}_{slot}")) == false)
return false;
if (File.Exists($"{path}_{slot}") == false)
return false;
File.Delete($"{path}_{slot}");
OnFileChanged?.Invoke();
return true;
}
}
게임 데이터를 다루는 데 필요한 코드를 짜는 방법 자체는 다소 낯설 수는 있지만, 그렇게 이해하기 어렵지는 않았다. 생소한 용어만 기억해 두고, 데이터 매니저에서 필요한 것이 이와 같은 것임을 알게 되었다.
싱글톤으로 구현
어느 씬에서나 게임을 저장하거나 불러올 수 있도록 싱글톤으로 선언하고 데이터를 가지고 있을 것
게임 데이터 저장하기
핵심 키워드 - JsonUtility.ToJson(gameData, true)
JsonUtility를 통해 데이터를 Json 형태로 변환하고, 기본 false로 하면 데이터가 한 줄로 나오지만 true로 설정을 바꿔주면 데이터가 한 줄로 나온다.
File.WriteAllText로 해당 텍스트를 Json 형태로 저장한다.
게임 데이터를 저장했던 방법의 역순으로 데이터를 불러오면 된다.
File.ReadAllText로 데이터를 가져오고, JsonUtility.FromJson(gameData, true) 로 데이터를 가져오는 것이다.
File.Delete 를 통해 데이터를 지울 수 있다.
이와 같이 만든 후 UI를 연결시키거나, 특정 키를 눌러 저장하는 경우 실행시켜보면 이렇게 Data 경로로 세이브 파일이 생성된 것을 확인할 수 있다.
이와 같이 데이터가 저장된 것을 확인할 수 있다.
Scriptable Object로 된 아이템을 구현하고, 이 아이템을 인벤토리에 넣어 데이터를 저장할 수 있는 방법을 알아보자.
먼저 아이템을 스크립터블 오브젝트로 구성해보자.
using UnityEngine;
public enum ItemType { Material, Usable, Equip }
[CreateAssetMenu (fileName = "LHWTestItem", menuName = "Data/LHWTestItem")]
public abstract class LHWTestItem : ScriptableObject
{
[field: SerializeField] public string Name { get; private set; }
[field: SerializeField] public float Weight { get; private set; }
[field: SerializeField] public ItemType Type { get; private set; }
// if is stackable, input number large than 1
[field: SerializeField] public int MaxStackSize { get; private set; }
[field: SerializeField] public GameObject Prefab { get; private set; }
public abstract void Use();
}
테스트용으로 당장 만들 아이템은 포션이다.
using UnityEngine;
[CreateAssetMenu(fileName = "LHWTestPotion", menuName = "Data/LHWTestPotion")]
public class LHWTestPotion : LHWTestItem
{
[field: SerializeField] public int Amount;
public override void Use()
{
Debug.Log($"By using Potion, heal {Amount} of Health.");
}
}
이와 같이 테스트용 스크립터블 오브젝트를 만든 후, 테스트용 스크립터블 오브젝트를 만들어보자.
데이터를 가져올 ItemController도 만든 다음, 해당 스크립트를 통해 오브젝트에 데이터를 전달한다.
using UnityEngine;
public class LHWTestItemController : MonoBehaviour
{
[SerializeField] LHWTestItem _data;
}
이와 같이 포션 아이템을 만들 수 있게 되었다.
인벤토리 첫 프로토타입은 아래와 같이 만들었다.
using System;
using UnityEngine;
public class Inventory : MonoBehaviour
{
[SerializeField] public LHWTestItem[] Items { get; private set; }
[SerializeField] private LHWTestItem[] _items;
[SerializeField] private int _slots;
public LHWTestItem[] Items => _items;
public int Slots => _slots;
public event Action<int> OnItemChanged;
private void Awake()
{
_items = new LHWTestItem[_slots];
}
#region Test
// Test Scripts
[SerializeField] LHWTestItem _potion;
private void Update()
{
if(Input.GetKeyDown(KeyCode.A))
{
AddItem(_potion);
}
if(Input.GetKey(KeyCode.S))
{
UseItem(0);
}
}
#endregion
public bool AddItem(LHWTestItem item)
{
int lastIndex = -1;
// 빈 공간을 찾는다
for (int i = 0; i < _items.Length; i++)
{
if (_items[i] == null)
{
lastIndex = i;
break;
}
}
// 빈 공간이 없으면 인벤토리가 다 찬 것
if (lastIndex == -1)
{
Debug.Log("Inventory is Full.");
return false;
}
_items[lastIndex] = item;
OnItemChanged?.Invoke(lastIndex);
return true;
}
public bool UseItem(int index)
{
if(_items[index] == null) return false;
// 재료는 직접적 사용 불가
if(_items[index].Type == ItemType.Material) return false;
// 사용 가능한 아이템의 경우 사용하고 해당 칸 비우기
if (_items[index].Type == ItemType.Usable)
{
_items[index].Use();
_items[index] = null;
}
else if (_items[index].Type == ItemType.Equip)
{
// if there's Equipment item
}
OnItemChanged?.Invoke(index);
return true;
}
}
이와 같이 인벤토리의 기본형을 만드는 것 자체는 별로 어렵지 않았다. 다만, 이와 같은 인벤토리는 특정 아이템의 누적을 반영하지 못한다.
예를 들어 포션 99개를 저장하는 칸이나, 돈스타브에서 나무를 한 칸에 20개를 저장할 수 있는 이런 기능을 구현하기 위해서는, 이런 기본형으로는 부족하다고 느꼈다.
따라서 튜토리얼을 참고하고, 다른 여러 자료들을 참고한 끝에 아이템 추가 방법에 대한 코드로 인벤토리를 새로 정리했다.
앞서 아이템 추상클래스에서 MaxStackSize라는 변수를 선언했다.
using UnityEngine;
public enum ItemType { Material, Usable, Equip }
[CreateAssetMenu (fileName = "LHWTestItem", menuName = "Data/LHWTestItem")]
public abstract class LHWTestItem : ScriptableObject
{
[field: SerializeField] public string Name { get; private set; }
[field: SerializeField] public float Weight { get; private set; }
[field: SerializeField] public ItemType Type { get; private set; }
// if is stackable, input number large than 1
[field: SerializeField] public int MaxStackSize { get; private set; }
[field: SerializeField] public GameObject Prefab { get; private set; }
public abstract void Use();
}
현재 아이템을 CSV로 받아 기획팀에서 아이템을 쉽게 만들 수 있도록 구성하는 데 시도하고 있으며, 기획안이 어떻게 나올지에 따라 달라질 수 있지만 크게 아래와 같은 정보를 바탕으로 CSV를 만들도록 요청할 것이다.
이와 같은 정보를 바탕으로, 인벤토리에 아이템을 저장할 수 있도록 인벤토리의 구조 자체를 전면적으로 개편했다.
스택형 아이템을 만들기 위해서는 해당 배열에 아이템이 몇 개 저장되어 있는지를 확인할 수 있는 변수가 필요하다고 생각했는데, 이걸 단일 클래스로 구현하려고 하니 생각보다 어려웠다. 그래서 나름의 팁을 얻은 것이, 인벤토리의 기능을 관리하는 InventorySystem, 인벤토리 단일 슬롯 하나만을 관리하는 InventorySlots, 인벤토리를 실제로 생성하는 Monobehaviour로 선언 가능한 Inventory 이렇게 세 가지로 인벤토리를 분리하기로 했다.
대략 이런 느낌으로 구성했다고 생각하면 되겠다.
InventorySlot을 InventorySystem 내의 리스트로 선언해서 인벤토리의 스택 시스템을 관리하는 것이다.
하지만 이 과정이 쉽지는 않았다. 버그, 오류를 엄청 많이 겪었고 아주 약간 견고함이 부족해도 바로 버그가 터지기 십상이었기 때문이다.
그래서 오늘 하루종일 인벤토리를 붙잡고 겨우 인벤토리로의 아이템 추가 시스템만 겨우 구현하는데 성공했다.
InventorySlots는 우선 초기화 및 스택 업데이트가 가능한 기능이 담겨있다.
또한 단일 인벤토리에서 이루어지는 스택에 아이템을 추가하거나 아이템을 제거할 수 있고, 해당 스택에 아이템을 추가할 수 있는지 여부 및 남은 공간을 반환하는 함수가 포함되어 있다.
using UnityEngine;
[System.Serializable]
public class InventorySlots
{
[SerializeField] private LHWTestItem _data;
[SerializeField] private int _stackSize;
public LHWTestItem Data => _data;
public int StackSize => _stackSize;
// 생성자: 아이템 데이터와 수량을 받아 초기화
public InventorySlots(LHWTestItem source, int amount)
{
_data = source;
_stackSize = amount;
}
// (오버로드)기본 생성자
public InventorySlots()
{
ClearSlot();
}
public void ClearSlot()
{
_data = null;
_stackSize = -1;
}
// 아이템이 들어있다가 비어버린 슬롯의 경우, 아이템을 받으면서 다시 초기화
// 여기서 받아들이는 아이템의 수량이 해당 아이템 최대 스택 수치를 넘어설 경우,
// 해당 스택의 최대치까지만 저장하도록 함
public void UpdateInvetorySlots(LHWTestItem source, int amount)
{
_data = source;
_stackSize = amount <= _data.MaxStackSize ? amount : _data.MaxStackSize;
}
// 스택에 공간이 남았는지 확인하고, 남은 공간을 반환함
public bool RoomLeftInStack(int amountToAdd, out int amountRemaining)
{
amountRemaining = _data.MaxStackSize - _stackSize;
return RoomLeftInStack(amountToAdd);
}
// (오버로드) 스택에 공간이 남았는지 확인
public bool RoomLeftInStack(int amountToAdd)
{
if (_stackSize + amountToAdd <= _data.MaxStackSize) return true;
else return false;
}
// 스택에 아이템 추가
public void AddToStack(int amount)
{
_stackSize += amount;
}
// 스택에 아이템 제거
public void RemoveFromStack(int amount)
{
_stackSize -= amount;
}
}
InventorySystem은 인벤토리에서 일어나는 모든 상황에 대해 정의한다. 인벤토리 내의 아이템의 추가, 아이템 사용, 아이템 버리기, 아이템 옮기기 등이 구현되어야 할 것이다.
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
[Serializable]
public class InventorySystem
{
[SerializeField] private List<InventorySlots> _inventorySlots;
public List<InventorySlots> InventorySlots => _inventorySlots;
public int InventorySize => InventorySlots.Count;
public Action<InventorySlots> OnInventorySlotChanged;
public InventorySystem(int size)
{
_inventorySlots = new List<InventorySlots>(size);
for (int i = 0; i < size; i++)
{
InventorySlots.Add(new InventorySlots());
}
}
/// <summary>
/// Add singular Item
/// </summary>
/// <param name="item"></param>
/// <returns></returns>
public bool AddItem(LHWTestItem item)
{
return AddItem(item, 1);
}
/// <summary>
/// Add item with amount
/// </summary>
/// <param name="item"></param>
/// <param name="amountToAdd"></param>
/// <returns></returns>
public bool AddItem(LHWTestItem item, int amountToAdd)
{
// if there's item stack already, try adding amount on the stack
if (AlreadyHasItemStack(item, out List<InventorySlots> inventorySlots))
{
foreach (var slot in inventorySlots)
{
if (slot.RoomLeftInStack(amountToAdd))
{
slot.AddToStack(amountToAdd);
OnInventorySlotChanged?.Invoke(slot);
return true;
}
else
{
slot.RoomLeftInStack(amountToAdd, out int amountRemaining);
if (amountRemaining > 0)
{
slot.AddToStack(amountRemaining);
OnInventorySlotChanged?.Invoke(slot);
amountToAdd -= amountRemaining;
}
}
}
}
// if there's no item stack, then try adding amount on the new stack
while (amountToAdd > 0)
{
if (HasEmptyStack(out InventorySlots emptySlot))
{
if (amountToAdd <= item.MaxStackSize)
{
emptySlot.UpdateInvetorySlots(item, amountToAdd);
OnInventorySlotChanged?.Invoke(emptySlot);
return true;
}
else
{
emptySlot.UpdateInvetorySlots(item, item.MaxStackSize);
amountToAdd -= item.MaxStackSize;
}
}
else
{
Debug.Log("Inventory is full.1");
return false;
}
}
Debug.Log("Inventory is full.2");
return false;
}
public bool AlreadyHasItemStack(LHWTestItem item, out List<InventorySlots> inventorySlots)
{
inventorySlots = InventorySlots.Where(i => i.Data == item).ToList();
return inventorySlots != null && inventorySlots.Count > 0;
}
public bool HasEmptyStack(out InventorySlots emptySlot)
{
emptySlot = InventorySlots.FirstOrDefault(i => i.Data == null);
return emptySlot != null;
}
}
여기서 특히 의사코드가 중요한 것이, 인벤토리에서 아이템이 추가될 때 어떻게 추가되어야 하는지 생각할 필요성이 있었다.
예를 들어 내가 많이 하는 게임인 메이플스토리의 경우, 파워엘릭서라는 아이템을 주웠을 때, 그냥 무작정 앞 칸에 채워지지 않았다.
파워엘릭서가 인벤토리에 들어가는 방식은 아래와 같았다.
이와 같은 의사코드를 바탕으로 우선은 추가할 아이템과 같은 인벤토리 슬롯을 전부 찾은 다음, 해당 슬롯에 아이템을 누적하는 것부터 시작한다.
그리고 이와 같은 과정으로도 인벤토리를 전부 못 채웠을 경우, 빈 인벤토리 슬롯을 찾아 해당 인벤토리 슬롯에 아이템을 새로 저장한다.
인벤토리가 가득찼을 경우 아이템을 넣을 수 없도록 한다.
- 개선점
어무래도 코드가 보기 좋지 않다 보니 코드를 다시 새로 짜려고 했었지만, 지금 상황에서 내 나름대로의 아이디어와 논리를 반영한 코드는 위의 코드가 최선이었다. 다만 시간이 남으면 리팩토링을 시도해보고자 한다.
- 과제
- 아이템 사용 기능 구현
- 아이템 버리기 기능 구현
- (도전) 아이템 이동하기, 아이템 정렬하기
또한 오늘 추가적인 업무분장으로 아이템 기능이 결국 나한테 넘어왔기 때문에, 이 부분도 생각해봐야 한다.
특히 팀장님과 상의한 결과로 아이템의 구현방식은 스크립터블 오브젝트로 하되, 이걸 CSV로 연결할 수 있는 방법에 대해 알아와서 구현해야 할 것이다.
참고용 사이트는 아래와 같이 찾아두었으며, 공부해서 해당 기능의 구현이 필요하다.
https://mintchobab.tistory.com/29
+보너스 - 수집품 아이템 아이디어