Inflearn - '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part9: MMO 컨텐츠 구현 (DB, 대형구조, 라이브 준비)'를 스터디하며 정리한 글입니다.
Unity 클라이언트로 인벤토리 UI 작업
다른 Scene or Prefab 들과는 다르게 Stat & Inventory 창은 게임 Scene에서 계속 들고있고 껐다 켰다만 반복하도록 설정
Prefeb
UI_GameScene
: 인벤토리 & Stat 창의 캔버스 PrefabUI_GameScene.cs
UI_Inventory
: 인벤토리 창 전체의 PrefabUI_Inventory.cs
UI_Inventory_Item
: 아이템 하나의 PrefabUI_Inventory_Item.cs
UI_Stat
: Stat 창 전체의 PrefabUI_Stat.cs
연동 로직
UI_GameScene.cs
public UI_Stat StatUI { get; private set; }
public UI_Inventory InvenUI { get; private set; }
public override void Init()
{
base.Init();
StatUI = GetComponentInChildren<UI_Stat>();
InvenUI = GetComponentInChildren<UI_Inventory>();
// 처음에는 꺼줌
StatUI.gameObject.SetActive(false);
InvenUI.gameObject.SetActive(false);
}
GameScene.cs
UI_GameScene _sceneUI;
protected override void Init()
{
base.Init();
SceneType = Define.Scene.Game;
Managers.Map.LoadMap(1);
// 빌드 화면 크기 설정
Screen.SetResolution(640, 480, false);
_sceneUI = Managers.UI.ShowSceneUI<UI_GameScene>();
}
클라에서 본인의 아이템 목록을 UI_Inventory.cs
에 UI 상으로만 저장하는 것이 아니라 따로 클래스를 파서 동시에 관리하는 것이 좋음
-> Item.cs
, InventoryManager.cs
Item.cs
: 이전에 서버에서 쓰던 Item.cs
코드 수정하여 사용
InventoryManager.cs
: 이전에 서버에서 쓰던 Inventory.cs
코드 수정하여 사용
Data.contents.cs
& DataManager.cs
PacketHandler.cs
public static void S_ItemListHandler(PacketSession session, IMessage packet)
{
S_ItemList itemList = (S_ItemList)packet;
// UI 설정
UI_GameScene gameSceneUI = Managers.UI.SceneUI as UI_GameScene;
UI_Inventory invenUI = gameSceneUI.InvenUI;
Managers.Inven.Clear();
// 메모리상 아이템 정보 적용
foreach (ItemInfo itemInfo in itemList.Items)
{
Item item = Item.MakeItem(itemInfo);
Managers.Inven.Add(item);
}
// UI 에서 표시
invenUI.gameObject.SetActive(true);
invenUI.RefreshUI();
}
UI_Inventory.cs
// 인벤토리 내 보유 아이템 20개 목록
public List<UI_Inventory_Item> Items { get; } = new List<UI_Inventory_Item>();
public override void Init()
{
// 초기화
Items.Clear();
GameObject grid = transform.Find("ItemGrid").gameObject;
foreach (Transform child in grid.transform)
Destroy(child.gameObject);
for(int i=0;i<20;i++)
{
GameObject go = Managers.Resource.Instantiate("UI/Scene/UI_Inventory_Item", grid.transform);
UI_Inventory_Item item = go.GetOrAddComponent<UI_Inventory_Item>();
Items.Add(item);
}
}
public void RefreshUI()
{
// 메모리에 있는 아이템 목록 들고옴
List<Item> items = Managers.Inven.Items.Values.ToList();
// Slot 넘버 순으로 정렬
items.Sort((left, right) => { return left.Slot - right.Slot; });
foreach(Item item in items)
{
if (item.Slot < 0 || item.Slot >= 20)
continue;
Items[item.Slot].SetItem(item.TemplateId, item.Count);
}
}
ItemData.json
에서 iconPath
로 이미지 경로 추가로 전달UI_Inventory_Item.cs
[SerializeField]
Image _icon;
public override void Init()
{}
// 이미지를 해당 아이템 이미지로 변경
public void SetItem(int templateId, int count)
{
Data.ItemData itemData = null;
Managers.Data.ItemDict.TryGetValue(templateId, out itemData);
Sprite icon = Managers.Resource.Load<Sprite>(itemData.iconPath);
_icon.sprite = icon;
}
몬스터가 죽었을 때 아이템을 드랍하거나 플레이어의 인벤토리에 자동으로 들어가는 보상 시스템 작업
몬스터 처리 보상으로 새로운 아이템이 생겼다는 것을 클라이언트에게 알려줘야 함
Protocol.proto
enum MsgId {
...
S_ADD_ITEM = 17;
}
message S_AddItem{
repeated ItemInfo items = 1;
}
Data.Contents.cs
#region Monster
[Serializable]
public class RewardData
{
// 100분율 확률
public int probability;
public int itemId;
public int count;
}
[Serializable]
public class MonsterData
{
public int id;
public string name;
// totalExp는 처치 시 주는 경험치
public StatInfo stat;
public List<RewardData> rewards;
//public string prefabpath;
}
[Serializable]
public class MonsterLoader : ILoader<int, MonsterData>
{
public List<MonsterData> monsters = new List<MonsterData>();
public Dictionary<int, MonsterData> MakeDict()
{
Dictionary<int, MonsterData> dict = new Dictionary<int, MonsterData>();
foreach (MonsterData monster in monsters)
{
dict.Add(monster.id, monster);
}
return dict;
}
}
#endregion
-> DataMananger.cs
Dict, LoadData() 생성
DB로 아이템 생성 요청을 보내고 DB에서 성공적으로 저장이 되면 콜백으로 받아서 다시 진행
화살의 경우 ObjectType이 Player가 아니므로 보상 처리가 되지 않을 수 있음
GameObject.cs
public virtual GameObject GetOnwer()
{
return this;
}
->
Arrow.cs
public override GameObject GetOnwer()
{
return Owner;
}
Inventory.cs
public int? GetEmptySlot()
{
for (int slot = 0; slot < 20; slot++)
{
Item item = _items.Values.FirstOrDefault(i => i.Slot == slot);
// 빈 슬롯
if (item == null)
return slot;
}
// 슬롯이 다 참
return null;
}
DbTransaction.cs
public static void RewardPlayer(Player player, RewardData rewardData, GameRoom room)
{
if (player == null || rewardData == null || room == null)
return;
// TODO : 살짝 문제가 있긴 함
// 아이템 슬롯 할당
int? slot = player.Inven.GetEmptySlot();
if (slot == null)
return;
ItemDb itemDb = new ItemDb()
{
TemplateId = rewardData.itemId,
Count = rewardData.count,
Slot = slot.Value,
OwnerDbId = player.PlayerDbId
};
// You
Instance.Push(() =>
{
using (AppDbContext db = new AppDbContext())
{
// DB에 아이템 정보 추가
db.Items.Add(itemDb);
bool success = db.SaveChangesEx();
if (success)
{
// Me
// 플레이어 인벤토리에 아이템 추가
room.Push(() =>
{
Item newItem = Item.MakeItem(itemDb);
player.Inven.Add(newItem);
// 클라이언트에게 알림
{
S_AddItem itemPacket = new S_AddItem();
ItemInfo itemInfo = new ItemInfo();
itemInfo.MergeFrom(newItem.Info);
itemPacket.Items.Add(itemInfo);
player.Session.Send(itemPacket);
}
});
}
}
});
}
Monster.cs
public int TemplateId { get; private set; }
public void Init(int templateId)
{
TemplateId = templateId;
MonsterData monsterData = null;
DataManager.MonsterDict.TryGetValue(templateId, out monsterData);
Stat.MergeFrom(monsterData.stat);
Stat.Hp = monsterData.stat.MaxHp;
State = CreatureState.Idle;
}
public override void OnDead(GameObject attacker)
{
base.OnDead(attacker);
// 플레이어가 죽였을 때만 생성
GameObject owner = attacker.GetOnwer();
if(owner.ObjectType == GameObjectType.Player)
{
RewardData rewardData = GetRandomReward();
if(rewardData != null)
{
Player player = (Player)owner;
DbTransaction.RewardPlayer(player, rewardData, Room);
}
}
}
// 랜덤으로 하나의 아이템 보상
RewardData GetRandomReward()
{
MonsterData monsterData = null;
DataManager.MonsterDict.TryGetValue(TemplateId, out monsterData);
// 0~100
int rand = new Random().Next(0, 101);
int sum = 0;
foreach(RewardData rewardData in monsterData.rewards)
{
// 확률의 합이 rand를 넘기는 순간 그 아이템 반환
sum += rewardData.probability;
if(rand <= sum)
{
return rewardData;
}
}
return null;
}
MonsterData.Json
{
"monsters": [
{
"id": "1",
"name": "도플갱어",
"stat": {
"level": "1",
"maxHp": "200",
"attack": "20",
"speed": "10.0",
"totalExp": "10"
},
// 보상목록
"rewards": [
{
"probability": "10",
"itemId": "1",
"count": "1"
},
{
"probability": "10",
"itemId": "2",
"count": "1"
},
{
"probability": "10",
"itemId": "100",
"count": "1"
},
{
"probability": "10",
"itemId": "101",
"count": "1"
},
{
"probability": "10",
"itemId": "200",
"count": "5"
}
]
}
]
}
Data.Contents.cs
[Serializable]
public class MonsterData
{
public int id;
public string name;
// totalExp는 처치 시 주는 경험치
public StatInfo stat;
// 클라에서 들고 있으면 안되는 정보
// public List<RewardData> rewards;
public string prefabpath;
}
[Serializable]
public class MonsterLoader : ILoader<int, MonsterData>
{
public List<MonsterData> monsters = new List<MonsterData>();
public Dictionary<int, MonsterData> MakeDict()
{
Dictionary<int, MonsterData> dict = new Dictionary<int, MonsterData>();
foreach (MonsterData monster in monsters)
{
dict.Add(monster.id, monster);
}
return dict;
}
}
S_ItemListHandler
와 거의 동일PacketHandler.cs
public static void S_AddItemHandler(PacketSession session, IMessage packet)
{
S_AddItem itemList = (S_AddItem)packet;
// UI 설정
UI_GameScene gameSceneUI = Managers.UI.SceneUI as UI_GameScene;
UI_Inventory invenUI = gameSceneUI.InvenUI;
// 메모리상 아이템 정보 적용
foreach (ItemInfo itemInfo in itemList.Items)
{
Item item = Item.MakeItem(itemInfo);
Managers.Inven.Add(item);
}
Debug.Log("아이템을 획득했습니다!");
}
I
키를 누르면 인벤토리 키고 끌 수 있도록 처리MyPlayerController.cs
protected override void UpdateController()
{
GetUIKeyInput();
...
}
void GetUIKeyInput()
{
if (Input.GetKeyDown(KeyCode.I))
{
UI_GameScene gameSceneUI = Managers.UI.SceneUI as UI_GameScene;
UI_Inventory invenUI = gameSceneUI.InvenUI;
// 인벤토리가 켜져 있음
if(invenUI.gameObject.activeSelf)
{
invenUI.gameObject.SetActive(false);
}
// 인벤토리가 꺼져 있음
else
{
invenUI.gameObject.SetActive(true);
invenUI.RefreshUI();
}
}
}
플레이어가 보유중인 아이템을 착용하거나 탈착할 수 있도록 작업
Protocol.proto
enum MsgId {
...
C_EQUIP_ITEM = 18;
S_EQUIP_ITEM = 19;
S_CHANGE_STAT = 20;
}
...
message C_EquipItem{
int32 itemDbId = 1;
bool equipped = 2;
}
message S_EquipItem{
int32 itemDbId = 1;
bool equipped = 2;
}
message S_ChangeStat{
StatInfo statInfo = 1;
}
DataModel.cs
[Table("Item")]
public class ItemDb
{
...
public bool Equipped { get; set; } = false;
[ForeignKey("Owner")]
public int? OwnerDbId { get; set; }
public PlayerDb Owner { get; set; }
}
기존
DbTransaction
클래스를partial
를 이용해서 용도별 파일 구분
서버에서 처리하는 데이터의 성격에 따라 메모리 or DB 중 어느 순서로 저장할 지 달라짐
itemDbId
와 같이 반드시 DB에 적용돼야 처리할 수 있는 부분 : 선 DB, 후 메모리
DbTransaction.cs
아이템 착용과 같이 중요하지 않고 메모리에서 먼저 처리를 해도 상관 없는 부분 : 선 메모리, 후 DB
DbTransaction_Noti.cs
DbTransaction_Noti.cs
public static void EquipItemNoti(Player player, Item item)
{
if (player == null || item == null)
return;
// disconnected 상태
ItemDb itemDb = new ItemDb()
{
ItemDbId = item.ItemDbId,
Equipped = item.Equipped
};
Instance.Push(() =>
{
using (AppDbContext db = new AppDbContext())
{
db.Entry(itemDb).State = EntityState.Unchanged;
// Equipped만 변경됨
db.Entry(itemDb).Property(nameof(ItemDb.Equipped)).IsModified = true;
bool success = db.SaveChangesEx();
if(!success)
{
// 실패했으면 Kick
}
}
});
}
PacketHandler.cs
public static void C_EquipItemHandler(PacketSession session, IMessage packet)
{
C_EquipItem equipPacket = (C_EquipItem)packet;
ClientSession clientSession = (ClientSession)session;
// 게임룸에 들어가야 장착이 가능하기 때문에 룸에서 처리
Player player = clientSession.MyPlayer;
if (player == null)
return;
GameRoom room = player.Room;
if (room == null)
return;
room.Push(room.HandleEquipItem, player, equipPacket);
}
기존 GameRoom
클래스를 partial
를 이용해서 용도별 파일 구분
GameRoom.cs
GameRoom_Battle.cs
GameRoom_Item.cs
GameRoom_Item.cs
public void HandleEquipItem(Player player, C_EquipItem equipPacket)
{
if (player == null)
return;
// 플레이어의 인벤토리에 해당 아이템이 있는지 확인
Item item = player.Inven.Get(equipPacket.ItemDbId);
if (item == null)
return;
// 메모리 선 적용
item.Equipped = equipPacket.Equipped;
// DB에 Noti
DbTransaction.EquipItemNoti(player, item);
// 클라에 통보
S_EquipItem equiptOkItem = new S_EquipItem();
equiptOkItem.ItemDbId = item.ItemDbId;
equiptOkItem.Equipped = item.Equipped;
player.Session.Send(equiptOkItem);
}
인게임 인벤토리에서 아이템을 클릭했을 때 착용 <-> 미착용 되도록 처리
Frame
을 하나 둬서 착용시 켜줌UI_Inventory_Item.cs
public class UI_Inventory_Item : UI_Base
{
[SerializeField]
Image _icon = null;
[SerializeField]
Image _frame = null;
...
}
UI_Inventory_Item.cs
public int ItemDbid { get; private set; }
public int TemplateId { get; private set; }
public int Count { get; private set; }
public bool Equipped { get; private set; }
public override void Init()
{
// 클릭 시 이벤트 처리 Bind
_icon.gameObject.BindEvent((e) =>
{
Debug.Log("Click Item");
C_EquipItem equipPacket = new C_EquipItem();
equipPacket.ItemDbId = ItemDbid;
// 착용 <-> 미착용
equipPacket.Equipped = !Equipped;
Managers.Network.Send(equipPacket);
});
}
// 이미지를 해당 아이템 이미지로 변경
public void SetItem(Item item)
{
ItemDbid = item.ItemDbId;
TemplateId = item.TemplateId;
Count = item.Count;
Equipped = item.Equipped;
Data.ItemData itemData = null;
Managers.Data.ItemDict.TryGetValue(TemplateId, out itemData);
Sprite icon = Managers.Resource.Load<Sprite>(itemData.iconPath);
_icon.sprite = icon;
// 착용 UI 설정
_frame.gameObject.SetActive(Equipped);
}
PacketHandler.cs
public static void S_EquipItemHandler(PacketSession session, IMessage packet)
{
S_EquipItem equipItemOk = (S_EquipItem)packet;
// 메모리에 아이템 정보 적용
Item item = Managers.Inven.Get(equipItemOk.ItemDbId);
if (item == null)
return;
item.Equipped = equipItemOk.Equipped;
Debug.Log("아이템을 착용 변경!");
// UI 설정
UI_GameScene gameSceneUI = Managers.UI.SceneUI as UI_GameScene;
UI_Inventory invenUI = gameSceneUI.InvenUI;
invenUI.RefreshUI();
}
장비 종류별로 하나씩만 착용할 수 있고 아이템의 스탯이 플레이어의 스탯에 적용되도록 작업
GameRoom
에서 관리하던 아이템 착용 처리를 Player.cs
로 옮김Player.cs
public void HandleEquipItem(C_EquipItem equipPacket)
{
// 플레이어의 인벤토리에 해당 아이템이 있는지 확인
Item item = Inven.Get(equipPacket.ItemDbId);
if (item == null)
return;
// 물약이면 장착에서 제외
if (item.ItemType == ItemType.Consumable)
return;
// 착용 요청이라면, 겹치는 부위 해제
if (equipPacket.Equipped)
{
Item unequipItem = null;
if (item.ItemType == ItemType.Weapon)
{
unequipItem = Inven.Find(
i => i.Equipped && i.ItemType == ItemType.Weapon);
}
// 방어구면 방어구 부위도 검사
else if (item.ItemType == ItemType.Armor)
{
ArmorType armorType = ((Armor)item).ArmorType;
unequipItem = Inven.Find(
i => i.Equipped && i.ItemType == ItemType.Armor
&& ((Armor)i).ArmorType == armorType);
}
if (unequipItem != null)
{
// 메모리 선 적용
unequipItem.Equipped = false;
// DB에 Noti
DbTransaction.EquipItemNoti(this, unequipItem);
// 클라에 통보
S_EquipItem equiptOkItem = new S_EquipItem();
equiptOkItem.ItemDbId = unequipItem.ItemDbId;
equiptOkItem.Equipped = unequipItem.Equipped;
Session.Send(equiptOkItem);
}
}
...
}
기존에 사용하던 플레이어의
StatInfo
외에 따로 관리가 필요하게 됨
플레이어의 순수 스탯 + 아이템 스탯 방식
-> 추가적으로 늘어난 스탯에 대해서는 따로 관리를 해줘야 함
-> 아이템 탈착용시 변하는 스탯은 추가 스탯으로만 관리
기존 StatInfo
: 플레이어 순수 스텟
추가 스탯 - Player
에서 prop으로 따로 관리
GameObject.cs
public virtual int TotalAttack { get { return Stat.Attack; } }
public virtual int TotalDefence { get { return 0; } }
->
Player.cs
// 아이템 착용으로 인한 추가 스탯
public int WeaponDamage { get; private set; }
public int ArmorDefence { get; private set; }
// 순수 스탯 + 추가 스탯
public override int TotalAttack { get { return Stat.Attack + WeaponDamage; } }
public override int TotalDefence { get { return ArmorDefence; } }
OnDamaged
에서 데미지를 TotalDefence
만큼 감소OnDamaged
호출에서 데미지 매개변수를 TotalAttack
으로 변경Player.cs
// 플레이어의 추가 스탯 갱신
public void RefreshAdditionalStat()
{
// 처음부터 공식을 다시 계산하는 것이 버그를 최소화 할 수 있음
// 다소 부하는 있게 됨
WeaponDamage = 0;
ArmorDefence = 0;
foreach(Item item in Inven.Items.Values)
{
if (item.Equipped == false)
continue;
switch (item.ItemType)
{
case ItemType.Weapon:
WeaponDamage += ((Weapon)item).Damage;
break;
case ItemType.Armor:
ArmorDefence += ((Armor)item).Defence;
break;
}
}
}
생각할 점
추가 스탯 정보를 DB에 저장해야 할까?
-> 이미 DB에 가지고 있는 정보로도 언제든지 계산이 가능하기 때문에 따로 저장 필요X
추가 스탯 정보를 클라에 패킷으로 보내줘야 할까?
-> 서버의 계산 로직을 클라도 가지고 있다면 클라에서도 가지고 있는 정보로 계산이 가능
-> 따로 패킷 필요X
MyPlayerController.cs
public int WeaponDamage { get; private set; }
public int ArmorDefence { get; private set; }
protected override void Init()
{
base.Init();
RefreshAdditionalStat();
}
...
public void RefreshAdditionalStat()
{
WeaponDamage = 0;
ArmorDefence = 0;
foreach (Item item in Managers.Inven.Items.Values)
{
if (item.Equipped == false)
continue;
switch (item.ItemType)
{
case ItemType.Weapon:
WeaponDamage += ((Weapon)item).Damage;
break;
case ItemType.Armor:
ArmorDefence += ((Armor)item).Defence;
break;
}
}
}
->
아이템 착용의 변동이 있을 때마다 Refresh
PacketHandler.cs
S_ItemListHandler, S_AddItemHandler, S_EquipItemHandler
if(Managers.Object.MyPlayer != null)
Managers.Object.MyPlayer.RefreshAdditionalStat();
C
를 누르면 플레이어의 스탯 정보를 알려주는 스탯 창이 뜨도록 only 클라이언트 작업
StatUI.cs
: 이미지, 텍스트, 스탯 연동 관리public class UI_Stat : UI_Base
{
enum Images
{
Slot_Helmet,
Slot_Armor,
Slot_Boots,
Slot_Weapon,
Slot_Shield
}
enum Texts
{
NameText,
AttackValueText,
DefenceValueText
}
bool _init = false;
public override void Init()
{
// 자동 바인딩
Bind<Image>(typeof(Images));
Bind<Text>(typeof(Texts));
_init = true;
RefreshUI();
}
...
}
StatUI.cs
// stat 갱신
// 모두 장착 해제한 상태에서 하나하나씩 채워주는 것이 좋음
public void RefreshUI()
{
if (_init == false)
return;
// 우선은 다 가림
Get<Image>((int)Images.Slot_Helmet).enabled = false;
Get<Image>((int)Images.Slot_Armor).enabled = false;
Get<Image>((int)Images.Slot_Boots).enabled = false;
Get<Image>((int)Images.Slot_Weapon).enabled = false;
Get<Image>((int)Images.Slot_Shield).enabled = false;
// 채워줌
foreach (Item item in Managers.Inven.Items.Values)
{
if (item.Equipped == false)
continue;
ItemData itemData = null;
Managers.Data.ItemDict.TryGetValue(item.TemplateId, out itemData);
Sprite icon = Managers.Resource.Load<Sprite>(itemData.iconPath);
if(item.ItemType == ItemType.Weapon)
{
Get<Image>((int)Images.Slot_Weapon).enabled = true;
Get<Image>((int)Images.Slot_Weapon).sprite = icon;
}
else if (item.ItemType == ItemType.Armor)
{
Armor armor = ((Armor)item);
switch(armor.ArmorType)
{
case ArmorType.Helmet:
Get<Image>((int)Images.Slot_Helmet).enabled = true;
Get<Image>((int)Images.Slot_Helmet).sprite = icon;
break;
case ArmorType.Armor:
Get<Image>((int)Images.Slot_Armor).enabled = true;
Get<Image>((int)Images.Slot_Armor).sprite = icon;
break;
case ArmorType.Boots:
Get<Image>((int)Images.Slot_Boots).enabled = true;
Get<Image>((int)Images.Slot_Boots).sprite = icon;
break;
}
}
}
...
}
StatUI.cs
public void RefreshUI()
{
...
// Text
MyPlayerController player = Managers.Object.MyPlayer;
player.RefreshAdditionalStat();
Get<Text>((int)Texts.NameText).text = player.name;
// 공격력 -> 총 공격력(+무기 공격력) 양식
int totalDamage = player.Stat.Attack + player.WeaponDamage;
Get<Text>((int)Texts.AttackValueText).text = $"{totalDamage}(+{player.WeaponDamage})";
Get<Text>((int)Texts.DefenceValueText).text = $"{player.ArmorDefence}";
}
MyPlayerController.cs
void GetUIKeyInput()
{
...
else if (Input.GetKeyDown(KeyCode.C))
{
UI_GameScene gameSceneUI = Managers.UI.SceneUI as UI_GameScene;
UI_Stat statUI = gameSceneUI.StatUI;
// 인벤토리가 켜져 있음
if (statUI.gameObject.activeSelf)
{
statUI.gameObject.SetActive(false);
}
// 인벤토리가 꺼져 있음
else
{
statUI.gameObject.SetActive(true);
statUI.RefreshUI();
}
}
}
클라이언트만 ArmorType을 제대로 파싱하지 못하여 장비 타입이 분류가 안되는 버그가 발생하게 됨
JsonUtiliy
에서 string 데이터인 ArmorType
을 int 로 인식하기 때문DataManager.cs
의 파싱 방법을 서버와 똑같이 NewTonJson
으로 바꿔주면 됨DataManager.cs
Loader LoadJson<Loader, Key, Value>(string path) where Loader : ILoader<Key, Value>
{
TextAsset textAsset = Managers.Resource.Load<TextAsset>($"Data/{path}");
return JsonUtility.FromJson<Loader>(textAsset.text);
}
->
Loader LoadJson<Loader, Key, Value>(string path) where Loader : ILoader<Key, Value>
{
TextAsset textAsset = Managers.Resource.Load<TextAsset>($"Data/{path}");
return Newtonsoft.Json.JsonConvert.DeserializeObject<Loader>(textAsset.text);
}