[프로젝트 회고] - 악의 돼지 삼형제

🐷 프로젝트 시작
삼성청년SW아카데미에서 마지막 프로젝트, 자율 프로젝트를 진행했다. 핀테크 프로젝트를 같이 경험한 팀원분들에게 캐스팅을 받았다. 맨 처음엔 해당 팀에서 웹 프론트 개발을 하고자 했는데, 게임 프로젝트에서 클라이언트 개발 제안을 받았다. 포트폴리오에 일관성을 갖춰야 할 것 같아서 웹 프론트 개발을 하려 했으나, SSAFY를 수료하면 게임 개발을 언제 해보겠어! 하는 생각이 너무 컸다. 가슴이 시키는 일을 했을 때 실패한 적이 없었기 때문에 게임 개발을 해보기로 했다.
변동사항 고지를 빠르게 하고자 웹 개발에 제안을 주셨던 팀장님에게 바로 사과를 드리고, 과감히 게임 프로젝트에 참여했다. (해당 팀 팀장님과 팀원분들에겐 정말 죄송했고 또 이해해주셔서 감사했다.)
게임 기획에 참여하며 가장 신경을 썼던 부분은 어떻게 해야 유저들이 질리지 않을까였다. 앞선 두 번의 프로젝트는 7주간 6명의 팀원이 진행했는데, 해당 프로젝트는 C#과 Unity로 진행해서 러닝 커브도 있었을 뿐만 아니라, 6주간 5명(팀원 한 분은 취업하셨다!)이 진행했기 때문에 마감일까지 완성할 수 있는 볼륨을 정하는 것이 중요했다.
그렇게 기획에 많은 시간을 들여 '아기 돼지 삼형제' 게임 기획을 끝마쳤다. 기존의 비대칭 PvP 게임과 다르게 연령에 구애받지 않고 누구나 비대칭 PvP 서바이벌 게임을 부담 없이 쉽게 즐길 수 있도록 많은 사람들에게 친숙한 '악의 돼지 삼형제' 동화 속 스토리에 기반해 게임 구현을 시작했다.
나는 돼지 유저가 늑대 유저에게 대응하기 위한 방법 중 하나인 집을 짓고 업그레이드하고, 내구도를 관리하는 것과, 집 내부 제작대에서 이루어지는 모든 일, 맵 설계를 담당했다. 인벤토리 관련 일을 하시던 팀원 분이 취업으로 SSAFY를 퇴소하셔서, 인벤토리 구성과 로직 설계를 내가 하게 되었다.
🤔 프로젝트를 하면서 신경 쓴 점
게임 프로젝트이다 보니 시스템 부하에 제일 신경을 기울여서 로직을 설계했다. 그 중 가장 신경을 썼던 부분은 아이템 관련 부분. 슬롯 당 저장할 수 있는 아이템은 한계가 있는데(해당 프로젝트에서는 12슬롯이 존재했고, 1슬롯 당 10개의 아이템을 저장할 수 있었다), 아이템 관련 로직은 캐릭터가 아이템을 직접 소비하고, 아이템을 제작을 통해 소비하고, 생성하며, 파밍으로 아이템을 얻는 다양한 로직이 존재한다. 인벤토리에 아이템 정보가 변경될 때마다 for문을 이용해서 전체 인벤토리를 검사하는 것은 매우 비효율적일 것이라고 판단했다.
해당 문제를 극복하기 위해 아이템 유형당 마지막 슬롯 인덱스를 저장해두었다. 예를 들어 아이템으로 나무, 벼, 돌이 있다면, 나무의 마지막 인덱스를 3, 벼의 마지막 인덱스를 8, 돌의 마지막 인덱스를 -1 (존재하지 않을 경우)로 저장 해두고, 해당 아이템이 들어올 때만, for문을 사용하지 않고 해당 슬롯만, 아이템 정보를 업데이트 해 부하를 크게 줄일 수 있었다. 이러한 로직을 잘 설계해둬 어떻게 아이템 변경이 이뤄지든 간에 하나의 로직을 사용해 유지보수도 용이했다.
해당 로직을 적용하기 위해 퇴소한 팀원의 로직을 대부분 수정해야만 했는데, 해당 과정에서 시간이 많이 소요되어 빠듯하게만 느껴졌으나, 내가 구현해야만 했던 기능 중 하나를 다른 팀원이 구현해 주어서 협업을 통해 프로젝트를 잘 마감할 수 있었다.

💡 프로젝트를 하면서 기술적으로 배운 점
집 안에서 제작과 집 업그레이드가 이뤄질 때, 본인의 집이 업그레이드 되어야 한다는 점을 구현하기가 쉽지 않았다. PhotonView를 집 오브젝트의 컴포넌트로 걸어 두었고, PV.IsMine으로 특정 플레이어 중 본인이 지은 것 집만 구별하여 업데이트를 하는 로직을 설계했다.
public void OnCraftButtonClick() // 제작 버튼 클릭 시 실행할 로직
{
GameObject[] houses = GameObject.FindGameObjectsWithTag("House");
if (finishedProduct == "TreeHouse" || finishedProduct == "BrickHouse")
{
foreach (var house in houses)
{
PhotonView housePV = house.GetComponent<PhotonView>();
if (housePV.IsMine)
{
HouseManager HM = house.GetComponent<HouseManager>();
if (HM.isUpgrading && (finishedProduct == "TreeHouse" || finishedProduct == "BrickHouse")) // 업그레이드 중의 경우
{
FC.StartFadeIn("현재 집을 업그레이드 중입니다!\n조금 기다려 주세요!");
craftingPopupUI.SetActive(false);
panelPopupUI.SetActive(false);
PICT.ToggleCreateTable(); // 제작대 UI 비활성화
}
else
{
ConsumeMaterials();
}
}
}
} else {
ConsumeMaterials();
}
}
또한, 기획 초반에는 모든 돼지 유저들이 집 내부의 창고 상자를 공유할 수 있는 식으로 구현하고자 했다. 해당 방식으로 구현을 하려면 Chest Inventory Data의 변화를 PhotonView로 모든 유저들이 확인해야만 했다.
상자 인벤토리 데이터와 유저 인벤토리 데이터의 이동

슬롯이 클릭 되었을 때
public void SlotClicked(int idx, string InventoryType)
{
// 돼지는 RPC 안 쏜다. 상자의 경우에만 모든 유저에게 반영되도록 RPC 처리
if (InventoryType == "Pig")
{
InventoryData data = PGI.InventoryDatas[idx];
byte[] dataBytes = SerializeInventoryData(data); // RPC로 보내기 위해 data json화
string jsonData = Encoding.UTF8.GetString(dataBytes); // byte[]를 문자열로 변환
int addIdx = decideAddItemIdx(data.ItemName, "Chest"); // 애초에 슬롯이 꽉 찼는지 판별할 임의의 인덱스
if (data.ItemCount != 0)
{
if (data.ItemName == "Axe" || data.ItemName == "Shovel" || data.ItemName == "Pick" || data.ItemName == "Sickle" || data.ItemName == "Shoes")
{
FC.StartFadeIn("해당 아이템은 옮길 수 없어요!");
}
else if (addIdx == -1)
{
FC.StartFadeIn("상자가 다 찼어요!");
}
else // 상자에 넣을 수 있는 아이템
{
if (PGI.InventoryDatas[idx].ItemCount > 0)
{
LostItemFromPig(idx);
AddItemToChest(dataBytes, addIdx);
}
}
}
}
else if (InventoryType == "Chest")
{
InventoryData data = CIUI.currentChestGetItem.chestInventoryDatas[idx];
byte[] dataBytes = SerializeInventoryData(data); // 상자가 데이터 잃는단 걸 보여주기 위해 data json화
string jsonData = Encoding.UTF8.GetString(dataBytes); // byte[]를 문자열로 변환
int addIdx = decideAddItemIdx(data.ItemName, "Pig"); // 애초에 슬롯이 꽉 찼는지 판별할 임의의 인덱스
if (data.ItemCount != 0)
{
if (addIdx == -1)
{
FC.StartFadeIn("인벤토리가 다 찼어요!");
}
else if (data.ItemCount != 0)
{
if (CIUI.currentChestGetItem.chestInventoryDatas[idx].ItemCount > 0)
{
AddItemToPig(data.ItemName, data.ItemImage, addIdx);
LostItemFromChest(dataBytes, idx);
}
}
}
}
UpdateInventoryUI(); // 데이터 오간 후 인벤토리 업데이트 해준다.
}
돼지 유저 인벤토리의 변화
void LostItemFromPig(int index)
{
PGI.InventoryDatas[index].ItemCount--;
if (PGI.InventoryDatas[index].ItemCount == 0)
{
// 슬롯이 빈 경우 슬롯을 초기화해준다.
PGI.InventoryDatas[index].ItemName = null;
PGI.InventoryDatas[index].ItemImage = "Item/Images/NoItem";
CII.CheckIndex("Pig", PGI.InventoryDatas); // 항목별 마지막 인덱스 체크해주기
CII.CheckEmptySlot("Pig"); // 빈 슬롯 찾아주기
}
}
void AddItemToPig(string itemName, string itemImage, int idx)
{
if (PGI.InventoryDatas[idx].ItemCount == 0)
{
// 새 슬롯임
PGI.InventoryDatas[idx].ItemName = itemName;
PGI.InventoryDatas[idx].ItemImage = itemImage;
CII.CheckEmptySlot("Pig");
CII.CheckIndex("Pig", PGI.InventoryDatas);
}
PGI.InventoryDatas[idx].ItemCount++; // 새 슬롯이건 기존의 슬롯이건 아이템 올려주기
}
상자 인벤토리의 변화
[PunRPC]
public void LostItemFromChest(byte[] serializedData, int idx)
{
InventoryData data = DeserializeInventoryData(serializedData); // json화 풀어볼까
string jsonData = JsonUtility.ToJson(data);
// 변환된 JSON 문자열을 로그로 출력
Debug.Log("상자에서 잃을 역직렬화된 데이터 (JSON): " + jsonData);
// 체스트 인벤토리 내의 아이템 수량 감소
//CIUI
CIUI.currentChestGetItem.chestInventoryDatas[idx].ItemCount--;
if (CIUI.currentChestGetItem.chestInventoryDatas[idx].ItemCount == 0)
{
// 슬롯 초기화
CIUI.currentChestGetItem.chestInventoryDatas[idx].ItemName = null;
CIUI.currentChestGetItem.chestInventoryDatas[idx].ItemImage = "Item/Images/NoItem";
}
CII.CheckEmptySlot("Chest");
CII.CheckIndex("Chest", CIUI.currentChestGetItem.chestInventoryDatas);
}
[PunRPC]
public void AddItemToChest(byte[] serializedData, int addIdx)
{
Debug.Log(serializedData);
InventoryData data = DeserializeInventoryData(serializedData) as InventoryData;
string jsonData = JsonUtility.ToJson(data);
// 변환된 JSON 문자열을 로그로 출력
Debug.Log("상자에 넣어줄 역직렬화된 데이터 (JSON): " + jsonData);
string itemName = data.ItemName;
//int idx = decideAddItemIdx(itemName, "Chest"); // 여기에 추가해줄래
if (addIdx != -1)
{
if (CIUI.currentChestGetItem.chestInventoryDatas[addIdx].ItemCount == 0)
{
CIUI.currentChestGetItem.chestInventoryDatas[addIdx].ItemName = itemName;
CIUI.currentChestGetItem.chestInventoryDatas[addIdx].ItemImage = data.ItemImage;
CII.PrintIndicesInfo("Chest");
}
CIUI.currentChestGetItem.chestInventoryDatas[addIdx].ItemCount ++;
}
else
{
if (addIdx == -2)
{
FC.StartFadeIn("해당 아이템은 옮길 수 없습니다.");
}
else
{
FC.StartFadeIn("아이템 슬롯을 확인해주세요!");
}
}
CII.CheckIndex("Chest", CIUI.currentChestGetItem.chestInventoryDatas);
CII.CheckEmptySlot("Chest");
}
json 관련 로직
// 직렬화 함수. RPC에 담아 보내는 data는 json 형태
public static byte[] SerializeInventoryData(object InventoryData)
{
string json = JsonUtility.ToJson(InventoryData);
return Encoding.UTF8.GetBytes(json);
}
// 역직렬화 함수. RPC에서 받은 json data를 inventoryData 형태로 변환시킨다
public static InventoryData DeserializeInventoryData(byte[] bytes)
{
string json = Encoding.UTF8.GetString(bytes);
return JsonUtility.FromJson<InventoryData>(json);
}
Chest 관련 로직에서 알 수 있듯, 기존의 InventoryDatas 형태 대신 json 형태로 data를 보냈다. RPC 통신은 InventoryData와 같은 복잡한 형태의 data를 보내지 못하고, json과 같은 단순한 형태의 data만 통신에 이용할 수 있기 때문이었다. json 형태로 data를 보내야 했다는 것을 몰랐기 때문에 data가 송수신되지 않는 문제를 해결하기까지 많은 시간이 걸렸다. 위는 data를 주고 받은 일련의 과정을 나타낸다.
😿 아쉬웠던 점
상자 인벤토리를 유저들과 공유하고자 했는데, 동기화를 하는데 많은 시간을 투자했으나, 시간 부족 때문에 해당 기능을 타협해야만 했다. 결국 내가 지은 집에 딸린 상자만 열고 닫을 수 있는 식으로 창고 기능을 제작해야만 했다. 시간이 딱 일주일만 더 있었더라면, 팀원이 조금만 더 늦게 퇴소했다면을 우스갯소리로 달고 살 정도로 정말 딱 일주일만 더 있었으면 피드백도 더 받아보고 더 완벽하게 구현할 수 있을 것 같았는데 하지 못해 아쉬웠다.
🐺 느낀점
유저들에게 게임을 배포하고 피드백을 받아보며 패치를 하는 과정이 너무 즐거웠다. 유저들의 피드백 끝에 기능이 조금 더 추가되고 친절한 게임으로 변모하는 것을 보는 것, 유저들이 즐겁게 할 수 있는 게임을 만들 수 있다는 것이 너무 행복했다. 게임은 역시 유저의 손에서 완성된다는 것을 정말 체감할 수 있던 프로젝트였다!
public void CheckIndex(string inventoryType, List<InventoryData> inventoryDatas) // 모든 유형에 대해 마지막 인덱스 정해준다.
{
if (inventoryType == "Pig")
{
PGI.lastPigRiceIdx = -1;
...
// 리스트를 역순으로 검사하여 마지막 인덱스를 찾기
for (int i = inventoryDatas.Count - 1; i >= 0; i--)
{
if (inventoryDatas[i].ItemName != null && inventoryDatas[i].ItemCount > 0)
{
switch (inventoryDatas[i].ItemName)
{
case "Rice(Clone)":
if (PGI.lastPigRiceIdx == -1) PGI.lastPigRiceIdx = i;
break;
...
}
}
UpdateLastItemIndices("Pig");
}
}
else if (inventoryType == "Chest")
{
...
}
}
public void CheckEmptySlot(string inventoryType) // 빈 슬롯 인덱스 구해주는 함수
{
if (inventoryType == "Pig")
{
PGI.firstPigEmptyIdx = -1; // 빈 슬롯이 없는 경우를 위한 초기값 설정
for (int i = 0; i < PGI.InventoryDatas.Count; i++)
{
if (PGI.InventoryDatas[i].ItemCount == 0)
{
PGI.firstPigEmptyIdx = i;
break;
}
}
}
...
}'Project > 회고' 카테고리의 다른 글
| [프로젝트 회고] - KeePing (0) | 2023.11.24 |
|---|---|
| [프로젝트 회고] - Cook Create (0) | 2023.11.23 |
