Unity 내일배움캠프 TIL 0821 | 개인 과제 스파르타 던전 설계 및 구현

cheeseonrose·2023년 8월 21일
0

Unity 내일배움캠프

목록 보기
15/89
post-thumbnail

월요일 조 아 (아님)

월요일..월요일....월요일~!

개인 과제 - 스파르타 던전 게임 만들기

설계

  • 일단 Player, Store 클래스에서 필요한 기능 구현을 위해 프로퍼티와 메서드를 좀 추가했다.
  • IDungeon 인터페이스와 이걸 상속하는 Dungeon 클래스 3가지를 만들었다.
  • 파일 저장을 위해 IGameDatabaseRepository와 이를 구현하는 DefaultGameDatabaseRepository를 추가했다.
  • Monster는 아무래도..화면 좀 다듬고 나서 할 것 같아서 일단은 뺐는데.....귀찮으면 안 할지도............. 크흠



구현

상점 - 아이템 판매

  • SellItem
    • DisplayStore에서 아이템 판매를 선택하면 실행되는 함수
    • Player에게 구매한 아이템 목록을 보여줄 것을 요청
    • 선택 값이 0이면 나가기, 아이템 인덱스이면 Player 객체에게 아이템을 팔 것을 요청한 뒤 해당 아이템 name을 가져와서 store 객체에게 전달
static void SellItem()
{
	bool isExit = false;
    while (!isExit)
    {
    	Console.Clear();
        ("인벤토리 - 아이템 판매").PrintWithColor(ConsoleColor.Yellow, true);
        Console.WriteLine("아이템을 판매할 수 있습니다.");
        Console.WriteLine();
        
        player.DisplayMoney();
        Console.WriteLine();
        
        player.DisplayPurchasedItem();
        Console.WriteLine();
        
        ("0").PrintWithColor(ConsoleColor.Magenta, false); Console.WriteLine(". 나가기");
        
        int select = GetPlayerSelect(0, store.GetStoreItemCount());
        if (select == 0)
        {
        	isExit = true;
        }
        else
        {
        	string itemName = player.SellItem(select - 1);
            store.RecoverItem(itemName);
            ("판매를 완료했습니다.").PrintWithColor(ConsoleColor.Blue, true);
            Thread.Sleep(1000);
        }
    }
}
  • Player.SellItem
    • Player 클래스의 메서드로, itemIdx 값을 받아서 구매한 아이템 목록에서 해당 item을 제거
    • 인벤토리 아이템 리스트에서 itemName으로 해당하는 아이템을 찾아서 장착 해제 및 제거
    • +item.Price * 0.85만큼 플레이어의 money 값 계산
    • 제거한 아이템 객체의 Name을 반환
public string SellItem(int itemIdx)
{
	string itemName = purchasedItemList[itemIdx];
    purchasedItemList.RemoveAt(itemIdx);
    foreach (Item item in itemList)
    {
    	if (itemName == item.Name)
        {
        	item.IsEquipped = false;
            money += (int)(item.Price * 0.85);
            
            itemList.Remove(item);
            
            return itemName;
        }
    }
    return itemName;
}
  • RecoverItem
    • Player 객체가 반환한 itemName을 받아와서 상점의 아이템 판매 현황을 갱신 -> false로 변경
public void RecoverItem(string itemName)
{
	soldState[itemName] = false;
}

던전 입장

  • IDungeon
    • Dungeon 객체의 부모가 될 인터페이스 생성
interface IDungeon
{
	int RecommendedShield { get; }
    
    int DefaultReward { get; }
    
    void FailedDungeon();
    
    void ClearDungeon();
}
  • EasyDungeon, NormalDungeon, HardDungeon
    • IDungeon을 상속받는 Dungeon 객체
    • 던전 난이도에 따라 권장 방어력과 기본 보상을 다르게 설정
    • 던전 클리어 및 실패시 던전에 따라 출력값 다르게 설정
internal class EasyDungeon : IDungeon
{
	private int recommendedShield = 5;
    private int defaultReward = 1000;
    
    public int RecommendedShield 
    { 
    	get { return recommendedShield; }
    }
    
    public int DefaultReward 
    { 
    	get { return defaultReward; } 
    }
    
    public void FailedDungeon()
    {
    	("던전 실패").PrintWithColor(ConsoleColor.Magenta, true);
        Console.WriteLine("쉬운 던전 클리어에 실패했습니다.");
        Console.WriteLine();
    }
    
    public void ClearDungeon()
    {
    	("던전 클리어").PrintWithColor(ConsoleColor.Yellow, true);
        Console.WriteLine("축하합니다!!");
        Console.WriteLine("쉬운 던전을 클리어 하였습니다.");
        Console.WriteLine();
    }
}
  • DisplayDungeonInfo
    • 시작 화면에서 던전 입장 선택시 실행되는 함수
    • 각 던전에 따른 권장 방어력 출력
    • 선택값에 따라 startDungeon 함수를 통해 던전 입장
static void DisplayDungeonInfo()
{
	Console.Clear();
    ("던전 입장").PrintWithColor(ConsoleColor.Yellow, true);
    Console.WriteLine("이곳에서 들어갈 던전을 선택할 수 있습니다.");
    Console.WriteLine();
    
    printDungeonInfo(EASY, EASY_SHIELD);
    printDungeonInfo(NORMAL, NORMAL_SHIELD);
    printDungeonInfo(HARD, HARD_SHIELD);
    ("0").PrintWithColor(ConsoleColor.Magenta, false); Console.WriteLine(". 나가기");
    Console.WriteLine();
    
    int select = GetPlayerSelect(0, 3);
    
    if (select == 0) startState = 0;
    else
    {
    	startDungeon(select);
    }
}
  • startDungeon
    • mode 값으로 받아온 던전 난이도에 따라 알맞는 던전 객체 생성
    • 플레이어의 방어 및 공격 아이템의 효과를 합산한 총 방어력 및 공격력을 계산
    • 던전의 권장 방어력보다 플레이어 방어력이 낮을 때 40% 확률로 실패
    • 던전의 권장 방어력보다 플레이어 방어력이 높을 때
      • 기본 체력 감소량 20~35 + 플레이어의 방어력에 따른 추가 감소량을 계산
      • 만약 플레이어 체력보다 피해량이 많다면 던전 클리어 실패
      • 플레이어 체력이 더 많다면 플레이어의 공격력에 따른 추가 보상을 계산하여 Player 객체와 Dungeon 객체에게 해당 던전을 클리어할 것을 요청
      • 이때 Player 객체에게는 피해량과 보상값을 전달
 static void startDungeon(int mode)
 {
 	Console.Clear();
    Console.WriteLine("던전 진행 중...");
    Thread.Sleep(2000);
    
    Console.Clear();
    
    IDungeon dungeon;
    
    if (mode == EASY) dungeon = new EasyDungeon();
    else if (mode == NORMAL) dungeon = new NormalDungeon();
    else dungeon = new HardDungeon();
    
    int success = new Random().Next(1, 101);
    
    int playerTotalShield = player.Shield + player.GetAdditionalShield();
    int playerTotalPower = player.Power + player.GetAdditionalPower();
    
    // 권장 방어력보다 낮을 때
    if ((dungeon.RecommendedShield > playerTotalShield) && (success > SUCCESS_PROBABLILITY))
    {
    	dungeon.FailedDungeon();
    	player.DungeonFailed(0);
    }
    else
    {
    	// 권장 방어력보다 높을 때 or 낮지만 던전 성공시
        int defaultDecreasedHP = new Random().Next(20, 36);
        int additionalDecreasedHP = dungeon.RecommendedShield - playerTotalShield;
        int decreasedHP = defaultDecreasedHP + additionalDecreasedHP;
        
        if (decreasedHP >= player.HP)
        {
        	dungeon.FailedDungeon();
        	player.DungeonFailed(1);
        }
        else
        {
        	int additionalRewardPercent = new Random().Next(playerTotalPower, (playerTotalPower * 2) + 1);
            int additionalReward = (int)((dungeon.DefaultReward * additionalRewardPercent) / 100);
            int reward = dungeon.DefaultReward + additionalReward;
            
            dungeon.ClearDungeon();
            player.ClearDungeon(decreasedHP, reward);
        }
    }
    
    ("0").PrintWithColor(ConsoleColor.Magenta, false); Console.WriteLine(". 나가기");
    Console.WriteLine();
    
    int select = GetPlayerSelect(0, 0);
    if (select == 0)
    {
    	startState = 0;
    	return;
    }
}
  • Player.DungeonFailed
    • 던전을 실패했을 때의 탐험 결과를 보여줌
    • type이 0이면 권장 방어력보다 플레이어의 방어력이 낮아서 실패한 경우
    • type이 1이면 권장 방어력보다 플레이어의 방어력은 높았지만, 남은 체력이 0 이하라서 실패한 경우
public void DungeonFailed(int type)
{
	Console.WriteLine("[ 탐험 결과 ]");
    Console.Write("체력 "); 
    
    (hp.ToString()).PrintWithColor(ConsoleColor.Magenta, false);
    (" -> ").PrintWithColor(ConsoleColor.Yellow, false);
    
    if (type == 0) hp = (int)(hp / 2);
    else hp = 0;
    
    (hp.ToString()).PrintWithColor(ConsoleColor.Magenta, true);
    Console.WriteLine();
}
  • Player.ClearDungeon
    • 피해량과 보상값을 받아와서 던전을 클리어했을 때의 탐험 결과를 보여줌
public void ClearDungeon(int decreasedHP, int reward)
{
	Console.WriteLine("[ 탐험 결과 ]");
    Console.Write("체력 ");
    
    (hp.ToString()).PrintWithColor(ConsoleColor.Magenta, false);
    (" -> ").PrintWithColor(ConsoleColor.Yellow, false);
    
    hp -= decreasedHP;
    
    (hp.ToString()).PrintWithColor(ConsoleColor.Magenta, true);
    
    Console.Write("Gold ");
    
    (money.ToString()).PrintWithColor(ConsoleColor.Magenta, false);
    (" -> ").PrintWithColor(ConsoleColor.Yellow, false);
    
    money += reward;
    
    (money.ToString()).PrintWithColor(ConsoleColor.Magenta, true);
    
    clearDungeonCount++;
    
    bool isLevelUp = (clearDungeonCount == level);
    
    if (isLevelUp)
    {
    	Console.Write("Level ");
    	("Lv" + level).PrintWithColor(ConsoleColor.Magenta, false);
    	(" -> ").PrintWithColor(ConsoleColor.Yellow, false);
        
        if (level < 5)
        {
        	level++;
            power += 1;
            shield += 2;
            clearDungeonCount = 0;
        }
        
        ("Lv" + level).PrintWithColor(ConsoleColor.Magenta, true);
    }
    
    Console.WriteLine();
}



휴식 기능

  • TakeRest
    • 시작 화면에서 휴식하기 선택시 실행되는 함수
    • 플레이어의 Money가 500 미만이면 Gold가 부족하다는 문구를 출력하고, 500 이상이면 Player 객체에게 휴식할 것을 요청
 static void TakeRest()
 {
 	bool isExit = false;
    while (!isExit)
    {
    	Console.Clear();
        ("휴식하기").PrintWithColor(ConsoleColor.Yellow, true);
        
        ("500").PrintWithColor(ConsoleColor.Magenta, false); Console.Write(" G를 내면 체력을 회복할 수 있습니다. (보유 골드 : ");
        (player.Money.ToString()).PrintWithColor(ConsoleColor.Magenta, false); Console.WriteLine(" G)");
        Console.WriteLine();
        
        ("1").PrintWithColor(ConsoleColor.Magenta, false); Console.WriteLine(". 휴식하기");
        ("0").PrintWithColor(ConsoleColor.Magenta, false); Console.WriteLine(". 나가기");
        Console.WriteLine();
        
        int select = GetPlayerSelect(0, 1);
        if (select == 0)
        {
        	isExit = true;
            startState = 0;
        }
        else
        {
        	if (player.Money < 500)
            {
            	("Gold 가 부족합니다.").PrintWithColor(ConsoleColor.Red, true);
                Thread.Sleep(1000);
            } else
            {
            	("휴식을 완료했습니다.").PrintWithColor(ConsoleColor.Blue, true);
                player.GetRest();
                isExit = true;
                startState = 0;
                Thread.Sleep(1000);
            }
        }
    }
}
  • Player.GetRest
    • Player 클래스의 메서드로, 휴식하는 기능의 함수
    • 플레이어의 체력을 100으로 만들고, money 값에서 500을 뺌
public void GetRest()
{
	hp = 100;
    money -= 500;
}



레벨업 기능

  • 처음에는 Main 함수가 있는 클래스에 전역 변수로 clearDungeonCount를 선언했는데, 추후에 저장 기능에서 클리어한 던전의 수도 저장을 해야 되기 때문에 Player 클래스의 프로퍼티로 추가해줬다.
  • 플레이어가 Lv.1일 때는 1번 던전을 클리어하면 레벨업,
    Lv.2일 때는 2번 클리어하는 방식이므로,
    Lv.N일 때 N번의 던전을 클리어했다면 레벨업을 하고
    클리어한 던전의 수를 초기화해주는 방식으로 구현했다.
  • Player.ClearDungeon
    • Player 클래스의 ClearDungeon 메서드 부분에 아래와 같은 로직을 추가했다.
    • 레벨업 시에 탐험 결과에 레벨의 변화도 함께 출력
    • 레벨업하면 플레이어의 기본 방어력과 공격력도 증가
      • 원래 공격력 0.5, 방어력 1인데 이미 다 int로 선언해서 귀찮아서 그냥 1이랑 2로 했....
clearDungeonCount++;

bool isLevelUp = (clearDungeonCount == level);

if (isLevelUp)
{
	Console.Write("Level ");
    ("Lv" + level).PrintWithColor(ConsoleColor.Magenta, false);
    (" -> ").PrintWithColor(ConsoleColor.Yellow, false);
    
    if (level < 5)
    {
    	level++;
        power += 1;
        shield += 2;
        clearDungeonCount = 0;
    }
    
    ("Lv" + level).PrintWithColor(ConsoleColor.Magenta, true);
}



게임 저장하기

  • 어려웠던 점

    • 앱 개발을 할 때는 JSON 파서에 data class를 넣어서 파싱했는데, C#에서는 객체를 넣으면 어떻게 파싱되는지 몰라서 약간 헤맸다.
      • Player한테 있는 아이템 리스트도 파싱이 되는지, 아니면 따로 해야 되는지를 고민했음
    • 파일 입출력 때문에 코드가 너무! 지저분해져서 이걸 어떻게 분리할까 고민했다.
    • Store의 아이템과 아이템의 판매 현황 리스트를 어떻게 입출력을 하고 연동할 것인가
  • 해결

    • 파일 입출력을 시도해서 클래스의 프로퍼티를 읽고 쓰는 것을 확인
      • 리스트도 같이 파싱되길래 한 번에 Player 객체로 파싱
    • 앱 개발 당시 Firebase나 Retrofit 통신을 했을 때 Repository를 이용했던게 생각나서 찾아보니까 C#도 Repository 패턴을 사용하는 것 같길래 이걸 이용했다~
    • 아이템은 txt 파일의 파일 입출력으로, 판매 현황 리스트는 JSON으로 파싱하고, Store의 InitStore에서 두 리스트의 길이가 다를 때 판매 현황에 없는 아이템 요소를 추가해주는 방식으로 수정
  • IGameDatabaseRepository

internal interface IGameDatabaseRepository
{
	// Player 정보 불러오기(string name, int hp, int shield, int power, int money) 및 업데이트
    // 전체 상점 Item List 불러오기 및 업데이트
    // 상점의 아이템 판매 현황 불러오기 및 업데이트
    
    public Player? GetPlayerInfo();
    
    public List<Item> GetStoreItemList();
    
    public Dictionary<string, bool>? GetStoreItemSoldStateList();
    
    public void UpdatePlayerInfo(Player player);
    
    public void UpdateStoreItemSoldState(Dictionary<string, bool> soldState);
}
  • DefaultGameDatabaseRepository
    • 이 부분은 파일 입출력뿐이라 딱히 설명할게 없..
    • 다 JSON 파싱인데 하나 다른게 있다면 상점의 아이템 목록 리스트 파싱 부분이다.
      • 전체 아이템 DB는 엑셀 파일로 관리하는게 쉬울 것 같아서 엑셀 파일을 txt 파일로 변환한 뒤 그 파일을 읽어오는 로직
      • JSON도 생각을 해봤는데 뭔가 아이템 DB에 아이템 추가/삭제하려면 엑셀 파일 형태로 관리해서 그냥 그대로 읽어오는게 나을 것 같았음
internal class DefaultGameDatabaseRepository : IGameDatabaseRepository
{
	const string DATA_PATH = "D:\\coding\\Game Study\\C_Study_Sparta_2023\\";
    const string PLAYER_DB_PATH = "UserDatabase.txt";
    const string ITEM_DB_PATH = "ItemDatabase.txt";
    const string ITEM_SOLD_STATE_DB_PATH = "ItemSoldStateDatabase.txt";
    
    public Player? GetPlayerInfo()
    {
    	try
        {
        	string jdata = File.ReadAllText(DATA_PATH + PLAYER_DB_PATH);
            return JsonConvert.DeserializeObject<Player>(jdata);
        } catch
        {
        	return null;
        }
    }
    
    public List<Item> GetStoreItemList()
    {
    	List<string[]> itemDB = new List<string[]>();
        List<Item> storeItemList = new List<Item>();
        try
        {
        	StreamReader sr = new StreamReader(DATA_PATH + ITEM_DB_PATH);
            string line = sr.ReadLine();
            
            while (line != null)
            {
            	itemDB.Add(line.Split("\t"));
                line = sr.ReadLine();
            }
            sr.Close();
            
            for (int i = 0; i < itemDB.Count; i++)
            {
            	storeItemList.Add(ParseItemStr(itemDB[i]));
            }
        }
        catch (Exception e)
        {
        	Console.WriteLine("오류가 발생했습니다 : " + e.Message);
        	Environment.Exit(0);
        }
        return storeItemList;
    }
    
    public Dictionary<string, bool>? GetStoreItemSoldStateList()
    {
    	try
        {
        	string jdata = File.ReadAllText(DATA_PATH + ITEM_SOLD_STATE_DB_PATH);
            return JsonConvert.DeserializeObject<Dictionary<string, bool>>(jdata);
        }
        catch
        {
        	return null;
        }
    }
    
    public void UpdatePlayerInfo(Player player)
    {
    	string jdata = JsonConvert.SerializeObject(player);
        File.WriteAllText(DATA_PATH + PLAYER_DB_PATH, jdata);
    }
    
    public void UpdateStoreItemSoldState(Dictionary<string, bool> soldState)
    {
    	string jdata = JsonConvert.SerializeObject(soldState);
        File.WriteAllText(DATA_PATH + ITEM_SOLD_STATE_DB_PATH, jdata);
    }
    
    // string 배열을 읽어와서 Item 객체로 파싱하여 반환함
    private static Item ParseItemStr(string[] itemStr)
    {
    	int idx = 0;
        return new Item(
        	itemStr[idx++],
            int.Parse(itemStr[idx++]),
            int.Parse(itemStr[idx++]),
            int.Parse(itemStr[idx++]),
            itemStr[idx++]
        );
    }
}
  • Store.InitStore
    • 만약에 엑셀 파일로 관리하는 ItemDB에 새로운 아이템을 추가한다면, 이를 어떻게 Store 객체의 판매 현황 리스트에 반영할 것인가가 고민이었다.
    • 그냥 Store 객체 자체를 JSON 형식으로 저장하고 읽어오면 쉽겠지만 위에서 말한 것처럼 전체 아이템 DB도 JSON으로 저장하면 뭔가 아이템 추가/삭제가 귀찮을 것 같았음!!!
    • 그래서 생각한 방법은 Store.initStore에서 아이템 DB와 아이템 판매 현황 리스트 길이가 다를 때, 아이템 DB에는 있는 요소가 판매 현황 리스트에는 없다는 말이기 때문에 없는 요소를 추가해주는 방식으로 수정했다.
    • 근데 지금와서 생각해보니까 나중에 Firebase 같은 서버 통신하려면 그냥 Store 객체 자체를 JSON으로 저장하고 읽어오는게 나을 것 같기도
    • 이 부분은 시간 되면 Firebase 통신 해보면서 수정하는 것으로!!!
 private void InitStore(Dictionary<string, bool> soldStateList)
 {
 	if (soldStateList == null)
    {
    	int length = itemList.Count;
        Console.WriteLine("length = " + length);
        soldState = new Dictionary<string, bool>();
        for (int i = 0; i < length; i++) soldState.Add(itemList[i].Name, false);
    } else
    {
    	soldState = soldStateList;
        if (itemList.Count != soldState.Count)
        {
        	foreach (Item item in itemList)
            {
            	if (soldState[item.Name] == null) 
                {
                	soldState.Add(item.Name, false);
                }
            }
        }
    }
}



TODO

  • 게임 화면 정렬 및 다듬기
  • Firebase 통신 시도해보기
  • 게임 스토리 추가 / 테마 변경



이렇게 추가 기능 구현은 끝!!! 인데 하나 더 수정사항이 있다면
Player 클래스의 purchasedItemList를 List 형식으로 변경했다.
구매한 아이템이 뭔지만 구분하면 되니까 아이템의 name 값만 저장하면 될 것 같았기 때문


오늘은 여기까지~~

~끗~

0개의 댓글