24.01.08 TIL - [C#] Text RPG 만들기 (4) : 최종 완성 및 회고

JJwoo·2024년 1월 8일

오늘 하루도 아침부터 개인 프로젝트 완성을 위해 불태웠다.
그래서 굉장히 긴 글이 될 듯 하다.
(사실은 코드와 주석이 대부분 이다(❁´◡`❁)

이것 저것 많이 만들고 여기 저기 많이 돌아다녀서 머리가 아프다.

# 제작 과정 순서

// 데이터 초기화
// 스타팅 로고를 보여줌 (게임 처음 킬 때만)
// 선택 화면을 보여줌 (기본 구현사항 - 상태 / 인벤토리)
// 상태 화면을 구현함 ( 필요 구현 요소 : 캐릭터, 아이템)
// 인벤토리 화면 구현함
// 장비장착 화면 구현

<여기부터는 해설 없이>
// 상점 화면 구현
// 휴식하기 화면 구현
// 장착 관리 (타입 별로 중복 장착 안되게)
// 게임 끝내기
// 게임 저장/로드
// 던전 입장, 사냥 기능
// 기타 세부적인 디테일 개선
  • 숫자. 은 코드 작업 순서를 표시 한 것.
  • 주석으로 시행 착오 해결 방법 및 개념, 설명 적어 둠.
  • 추가로 기억나는 오류와, 해결 과정 기재
  • 코드 변경점 위주로 서술

1. 캐릭터, 아이템 클래스

   public class Character // 3-1. 캐릭터 클래스 정의, 캐릭터의 구성도
   {
       public string Name { get; } // 한 번 정의되면 바꿀 수 없도록 함
       public string Job { get; } // Enum은 아직 배우지 않았으므로 pass
       public int Level { get; }
       public int Atk { get; }
       public int Def { get; }
       public int Hp { get; set; } // 사냥이나 휴식하기 등의 기능 때문에 set; 추가
       public int Gold { get; set; } // 아이템 구매 판매 등 유동적이므로 set; 추가

       public Character(string name, string job, int level, int atk, int def, int hp, int gold)
       // 3-2. 캐릭터 클래스의 생성자, 기본적으로 클래스의 이름과 같은 함수, 캐릭터를 실제로 생성하는 과정 = 인스턴스를 만든다
       {
           Name = name;
           Job = job;
           Level = level;
           Atk = atk;
           Def = def;
           Hp = hp;
           Gold = gold;
           // 조금 이따가 GameDataSetting()에 new 캐릭터를 생성 할 때, (인스턴스를 만들 때)
           // 생성자에서 설정한 기본 세팅 조건들이 new player에게 자동으로 대입 됨.
       }
   }

   public class Item // 4. 아이템 클래스 정의
   {
       public string Name { get; }
       // get 접근자 : 속성 값을 읽는 데 사용, 클래스의 외부에서 속성의 값을 요청할 때 get 접근자의 코드가 실행
       // set 접근자 : 속성 값을 할당 하는데 사용, 클래스의 외부에서 속성에 특정 값을 할당. (골드, 체력, 공격력 방어력 등 변하는 값에 사용 할 예정)
       // get; 만 있다 = 읽기 전용, 객체가 생성될 때 설정 되고 이후에는 변경 할 수 없음. 
       // 둘 다 있다   = 읽기/쓰기 전용, 클래스 외부에서 값을 변경할 수 있음, 속성이 동적으로 변경될 필요가 있는 경우 사용.
       // ∴ 플레이어의 레벨이나 체력이 게임 도중 변경될 수 있다면(사냥을 한다거나) set 접근자가 필요할 수 있다.


       public string Description { get; } // 템 설명

       public int Type { get; } // 무기 or 방어구 타입

       public int Atk { get; } 

       public int Def { get; }

       public int Gold { get; set; } // set을 추가 하지않아(읽기 전용이었어서) 골드가 변하지 않는 문제가 있었음, 그래서 추가

       public bool IsEquipped { get; set; } // 4-1. 장착 유무, 장착이 실제로 되었는지 확인하기 위함

       public bool IsPurchased { get; set; } // 8. 상점 관련, 구매 여부를 나타내는 속성

       public static int ItemCnt = 0;
       // 5-2.  클래스에 공유가 되는 int형 변수
       // 각각의 인스턴스가 아닌, 아이템 클래스에 귀속 되어 게임에 전반적으로 공유 되는 변수, 아이템 숫자 관련 함수의 for 조건문 등에 활용 될 예정
       // 아이템이 만들어 질 때마다 변수를 1만큼 올리고 싶을 때, 일일히 인스턴스에서 만들기 보단 전체에 공유 되는 값을 위함.

       public Item(string name, string description, int type, int atk, int def, int gold, bool isEquipped = false)
       // 4-2. 아이템 클래스의 생성자, 장착 유무는 false로 설정(처음에 안 끼고 있으니)
       {
           Name = name;
           Description = description;
           Type = type;
           Atk = atk;
           Def = def;
           Gold = gold; // 이 부분 작성을 빼먹어 골드값이 0으로 나오는 문제가 있었음.
           IsEquipped = isEquipped;
           IsPurchased = false; // 8. 상점 관련, 기본적으로 아이템은 구매되지 않은 상태
       }

캐릭터와 아이템 관련 된 클래스와 생성자

자꾸 아이템 값이 0골드로 나와서 뭔가 했더니 Gold = gold;로 생성자도 만들어 주지 않았고, gest;만 하고 set;을 하질 않았었다.
체력, 레벨, 공격력, 방어력 등 변하는 것들은 다 set;을 설정해주었다.
이 변하는 변수들에 대한 설정, 선언 및 초기화와, 조건문이나 문법들을 활용하는 방법에 익숙해지는 것이 중요 할 듯 하다.

IsPurchased = false; // 8. 상점 관련, 기본적으로 아이템은 구매되지 않은 상태를 상점 기능 만들 때 추가 해주었음.


2. 아이템 설명 출력 함수 + 텍스트 정렬 함수 2종

public void PrintItemStatDescription(bool withNumber = false, int idx = 0, bool showPrice = true) 
    // 7-1. 아이템 설명 출력,  아이템 관련 출력은 다 여기에 작성해서 연동
{
    Console.Write("- ");

    if (withNumber) // 7-4 아이템 장착관리 번호 컬러
    {
        Console.ForegroundColor = ConsoleColor.DarkMagenta;
        Console.Write("{0} ", idx);
        Console.ResetColor();
    }
    if (IsEquipped) // 아이템 착용 시
    {
        Console.Write("[");
        Console.ForegroundColor = ConsoleColor.Cyan; //   [ 의 뒤의 색 변경
        Console.Write("E"); // cyan 컬러
        Console.ResetColor(); // 색 리셋
        Console.Write("]");
        Console.Write(PadRightForMixedText(Name, 9)); // 장착 중인 경우는 9글자 출력
    }
        Console.Write(PadRightForMixedText(Name, 12)); // 장착 중이 아닌 경우 12글자 출력
        Console.Write(" | ");
    
    // 수치가 0이 아니라면 작동,  [ 삼항 연산자 활용 => 조건 ? 조건이 참이라면 : 조것이 거짓이라면 ]
    if (Atk != 0) Console.Write($"Atk {(Atk >= 0 ? " + " : "")}{Atk}");  // 공격력이 0보다 크거나 같으면 "+" 를 붙이고 아니면 " "해라(붙이지 마라).
    if (Def != 0) Console.Write($"Def {(Def >= 0 ? " + " : "")}{Def}");

    Console.Write(" | " + Description); // 설명 출력
    if (showPrice) // 8. 상점관련, 가격 및 구매 완료 표시 bool 코드
    {
        // IsPurchased가 true이면 "구매완료", 아니면 가격을 출력
        string priceOrStatus = IsPurchased ? "구매완료" : Gold + " G";
        Console.WriteLine(" | " + priceOrStatus);
    }
    Console.WriteLine();
}

public static int GetPrintableLength(string str) // 아이템 텍스트 정렬을 위해 Length의 길이를 구함
{
    int length = 0;
    foreach (char c in str)
    {
        if (char.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.OtherLetter)
        {
            length += 2; // 한글과 같은 넓은 문자는 길이를 2로 취급
        }
        else
        {
            length += 1; // 나머지 문자는 길이를 1로 취급
        }
    }
    return length;
}
public static string PadRightForMixedText(string str, int totalLength)
{
    int currentLength = GetPrintableLength(str); // 텍스트의 실제 길이
    int padding = totalLength - currentLength; //  총길이 - 실제길이 = int padding 추가해야 할 길이
    return str.PadRight(str.Length + padding); // padding 만큼 PadRight(문자열의 오른쪽)에 공백을 추가
}

public void PrintItemStatDescription()

아이템 텍스트 출력에 관련된 함수는 다 여기에 들어갔는데

if (showPrice) // 8. 상점관련, 가격 및 구매 완료 표시 bool 코드
{
    // IsPurchased가 true이면 "구매완료", 아니면 가격을 출력
    string priceOrStatus = IsPurchased ? "구매완료" : Gold + " G";
    Console.WriteLine(" | " + priceOrStatus);
}
Console.WriteLine();

상점 관련 bool 값은 직접 제작,
구매여부에 따라 구매완료 텍스트를 띄우거나 가격을 출력 해주는 구조.
PrintItemStatDescription 메서드의 매개변수 3종인

bool withNumber = false, int idx = 0, bool showPrice = true

을 설정해주고 다른 곳에서 PrintItemStatDescription()을 활용 할 때, 필요에 따라 true와 false를 뒤바꿔주거나, showPrice( 구매완료/가격 텍스트 출력 우무)가 필요 없다면 아예 생략 할 수 있다는 부분에서 어썸한 기분을 느꼈다.

크게 막힌 부분은 없었고, 마젠타가 무슨 색인지 오늘 알았다..

나머지 함수 2개는 아이템 텍스트 정렬을 위한 함수라고 해설에서 알려주신 부분인데 자세한 건 코드 주석에 처리 함.


여기부터는 본격적인 프로그램 실행 부분.

바로 전역적으로 사용될 플레이어 캐릭터와 아이템 목록을 선언

static Character _player;
static Item[] _items;
// 실제 플레이에서 쓸 캐릭터와 아이템 추가,  class Program 내에서 플레이어 관련에 주구장창 사용 예정
// 아이템은 여러 개 이므로 배열[] 사용, class Program내에서 아이템 관련 사용 예정
        static void Main(string[] args) // 메인 함수
        {

            GameDataSetting(); // 1. 게임 데이터 세팅
                               // new 라는 키워드를 통해 새로운 캐릭터를 메모리에 할당 지시, Character 생성자 발동

            PrintStartLogo(); // 5-4. 게임 스타트 화면 함수

            StartMenu(); // 5-5. 스타트 메뉴
        }

메인 함수,
게임 데이터 세팅
프린트 스타팅 로고 (시작화면)
스타트 메뉴(메뉴 선택)
메서드 3종으로 구성

 static void Main(string[] args) // 메인 메서드
 {

     GameDataSetting(); // 1. 게임 데이터 세팅
                        // new 라는 키워드를 통해 새로운 캐릭터를 메모리에 할당 지시, Character 생성자 발동

     PrintStartLogo(); // 5-4. 게임 스타트 화면 함수

     StartMenu(); // 5-5. 스타트 메뉴
 }


 private static void GameDataSetting() // 2. 게임 데이터 메서드 생성, Private(비공개)
 {
     _player = new Character("정진", "백수", 1, 10, 5, 100, 5000); // 5-1. new 플레이어 변수 선언(실제로 사용할 캐릭터의 데이터)
                                                               // 아까 3-2 에서 만든 캐릭터 클래스 생성자의 세팅 값들을 받아 옴.
                                                               // () 괄호 안에 각각 순서대로 Name, Job, Level, Atk, Def, HP, Gold 순서이며, 입력 값이 세팅 됨.
                                                               // _Player에  _ (언더스코어) 사용 이유? = Private(비공개) 필드 구분을 위한 것.
                                                               // 클래스 내부에서만 사용되는 변수임을 쉽게 식별할 수 있고, 외부에서 접근되는 공개 속성이나 메서드와의 혼동을 방지.

     // 복습 : 클래스의 필드(field)란 클래스에 포함된 변수(Variable)를 말한다. ( 결국 같은 뜻? )
     // 변수에는 특정 값을 할당할 수 있고, 이를 통해 객체의 특성을 만들어줄 수 있다.

     _items = new Item[8]; // 이번에는 List 대신 배열을 사용, 추후 5-3. additem 메서드를 정의 할 예정. (5-2 로 이동)

     // 5-4. 아이템 추가
     AddItem(new Item("무쇠 갑옷", "무쇠로 만들어진 튼튼한 갑옷입니다.", 0, 0, 5, 1000)); // 맨 앞의 숫자가 0이면 방어구, 1이면 무기
     AddItem(new Item("수련자 갑옷", "수련에 도움을 주는 갑옷입니다.", 0, 0, 9, 2000));
     AddItem(new Item("스파르타의 갑옷", "스파르타의 전사들이 사용했다는 전설의 갑옷입니다.", 0, 0, 15, 3500));
     AddItem(new Item("전신 방탄복", "총탄 및 파편 등으로부터 보호하기 위해 특수제작된 보호구", 0, 0, 30, 5500)); // 추가 아이템 1
     AddItem(new Item("낡은 검", "쉽게 볼 수 있는 낡은 검입니다.", 1, 2, 0, 600));
     AddItem(new Item("청동 도끼", "쉽게 볼 수 있는 낡은 검입니다.", 1, 5, 0, 1500));
     AddItem(new Item("스파르타의 창", "스파르타의 전사들이 사용했다는 전설의 창입니다.", 1, 7, 0, 3000));
     AddItem(new Item("AK - 47", "왜 갑자기 총기가 튀어나왔는지는 모르겠지만, 너도 나도 사이 좋게 한 방입니다.", 1, 15, 0, 6000)); // 추가 아이템 2
 }

 static void StartMenu() // 5-5. 스타트 메뉴
 {
     Console.Clear(); // 게임 스타트 화면 정리
     Console.WriteLine("■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■");
     Console.WriteLine("스파르타 마을에 오신 여러분 환영합니다.");
     Console.WriteLine("이곳에서 던전으로 들어가기전 활동을 할 수 있습니다.");
     Console.WriteLine("■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■");
     Console.WriteLine();  // Console.WriteLine(); 단축키 -> C + W + Tab
     Console.WriteLine("");
     Console.WriteLine("1. 상태 보기");
     Console.WriteLine("2. 인벤토리");
     Console.WriteLine("3. 상점");
     Console.WriteLine("4. 던전 입장");
     Console.WriteLine("5. 휴식");
     Console.WriteLine("6. 게임 저장 및 불러오기");
     Console.WriteLine("7. 게임 종료");
     Console.WriteLine("");

     // int keyInput = int.TryParse(Console.ReadLine(), out keyInput);  는 안 쓰고 아래 따로 함수 생성

     // CheckValidInput(1, 7);   , 5-6. 유효성 확인을 위한건데 일단 관련 함수들을 만들고, switch 문의 매개변수로 사용될 예정(바로 아래)

     switch (CheckValidInput(1, 7))
     // 5-8. switch문, CheckValidInput함수로 유효 값(1~7)을 입력 받으면 그에 맞는 함수 호출 후 break;로 벗어남.

     {
         case 1:
             StatusMenu(); // 플레이어 상태
             break;
         case 2:
             InventoryMenu(); // 인벤토리
             break;
         case 3:
             StoreMenu(); // 상점
             break;
         case 4:
             DungeonMenu(); // 던전 입장
             break;
         case 5:
             RestMenu(); // 휴식
             break;
         case 6:
             SaveGameMenu(); // 게임 저장 및 불러오기
             break;
         case 7:
             GameOverMenu(); // 게임 종료
             break;
     }
 }

 private static void StatusMenu() // 6. 플레이어 상태
 {
     // 6-3. 상태창 꾸미기 작업 시작
     Console.Clear();
     ShowHighlightText("■ 상태 보기 ■"); // 제목에 첫 줄 색 변경 6-2. 함수 활용
     Console.WriteLine("캐릭터의 정보가 표기됩니다.");

     PrintTextWithHighlights("Lv ", _player.Level.ToString("00")); // 문자열 하이라이트 6-2. 함수 활용, "00" 은 01,02,03 이런 식으로 두 자릿수로 표현
     Console.WriteLine("");
     Console.WriteLine("{0} ({1})", _player.Name, _player.Job);

     // 7-8. 합산 공격력, 방어력 구현 추가
     int bonusAtk = GetSumBonusAtk(); // 밑에 넣어줄 예정
     int bonusDef = GetSumBonusDef();
     PrintTextWithHighlights("공격력 : ", (_player.Atk + bonusAtk).ToString(), bonusAtk > 0 ? string.Format(" (+{0})", bonusAtk) : "");
     // 플레이어 공격력 + 보너스 공격력을 문자열로, (삼항연산자) 보너스 공격력이 0보다 크면 +(보너스 어택)을 출력해주고, 아니면은 빈칸을 추가
     PrintTextWithHighlights("방어력 : ", (_player.Def + bonusDef).ToString(), bonusDef > 0 ? string.Format(" (+{0})", bonusDef) : "");

     // 각각 PrintTextWithHighlights 함수의 s1 , s2, s3인데, Atk(공격력) 의 자료형은 Int이므로, s2의 노란색 컬러를 적용 시키기 위해 Tostring 해줌
     PrintTextWithHighlights("체력 : ", _player.Hp.ToString());
     PrintTextWithHighlights("골드 : ", _player.Gold.ToString());
     Console.WriteLine("");
     Console.WriteLine("0. 뒤로가기");
     Console.WriteLine("");

     switch (CheckValidInput(0, 0)) // 0번 나가기
     {
         case 0:
             StartMenu();
             break;
     }
 }
 private static int GetSumBonusAtk() // 7-7. 공격력 합산 표시
 {
     int sum = 0; // 능력치를 다 더 할 것 
     for (int i = 0; i < Item.ItemCnt; i++)   // 아이템을 전부 확인
     {
         if (_items[i].IsEquipped) sum += _items[i].Atk;
         // 아이템 목록의 아이템이 장착되어 있다면, 장착 아이템의 Atk를 다 더해라.
     }
     return sum; // 그 후 sum에게 값을 반환
 }
 private static int GetSumBonusDef()  // 방어력 합산
 {
     int sum = 0;
     for (int i = 0; i < Item.ItemCnt; i++)   // 아이템을 전부 확인
     {
         if (_items[i].IsEquipped) sum += _items[i].Def;
     }
     return sum;
 }

 private static void InventoryMenu() // 7. 인벤토리 
 {
     Console.Clear();
     ShowHighlightText("■ 인벤토리 ■");
     Console.WriteLine("보유중인 아이템을 관리 할 수 있습니다.");
     Console.WriteLine("");
     Console.WriteLine("[아이템 목록]");
     Console.WriteLine("");

     // 위쪽 Item 클래스로 이동해서 작업 7-1로.

     // 7-2 아이템 설명 출력 반복문
     for (int i = 0; i < Item.ItemCnt; i++) // 아이템의 Itemcnt 반복문, 아이템의 가짓 수 만큼 출력되어 보임
     {
         if (_items[i].IsPurchased) // 구매한 아이템 이라면 표시
         {
             _items[i].PrintItemStatDescription(true, i + 1, false, true);
         }
     }
     Console.WriteLine("");
     Console.WriteLine("0. 나가기");
     Console.WriteLine("1. 장착관리");
     Console.WriteLine("");

     switch (CheckValidInput(0, 1))
     {
         case 0:
             StartMenu();
             break;
         case 1:
             EquipMenu(); // 장착 관리
             break;
     }
 }

 private static void EquipMenu() // 7-3. 장착관리 메뉴
 {
     Console.Clear();
     ShowHighlightText("■ 인벤토리 - 장착 관리 ■");
     Console.WriteLine("보유중인 아이템을 장착/해제 할 수 있습니다.");
     Console.WriteLine("");
     Console.WriteLine("[아이템 목록]");
     Console.WriteLine("");

     // 잠시 PrintItemStatDescription함수로 이동, withNumber 변수를 활용 예정
     for (int i = 0; i < Item.ItemCnt; i++)
     {
         if (_items[i].IsPurchased) // 구매한 아이템 이라면 표시
         {
             _items[i].PrintItemStatDescription(true, i + 1, false);
             Console.WriteLine("아이템을 장착했습니다.");
         }
     }

     Console.WriteLine("");
     Console.WriteLine("0. 나가기");

     // 7-5.  default를 활용한 switch문 ->  모든 케이스가 아니면, 마지막에 케이스 default가 실행
     int keyInput = CheckValidInput(0, Item.ItemCnt); // 아까 만든 입력 유효성 확인 함수(0에서 아이템 숫자만큼) 인풋값에 활용
     switch (keyInput)
     {
         case 0:
             InventoryMenu();
             break;
         default:
             ToggleEquipStatus(keyInput - 1); // ToggleEquipStatus는 이 아래에 만들 예정(아이템 장착 상태 변경),
                                              // 유저 입력값은 123이며 실제 배열에는 012 이므로 -1해서 맞춰줌
             EquipMenu();
             break;
     }
 }

 private static void ToggleEquipStatus(int idx) // 7-6. 아이템 장착 상태 변경  
 {
     // 타입 별로 하나의 아이템만 장착
     // 같은 타입의 다른 아이템이 이미 장착되어 있다면 해제
     for (int i = 0; i < Item.ItemCnt; i++)
     {
         if (i != idx && _items[i].IsEquipped && _items[i].Type == _items[idx].Type)
         {
             _items[i].IsEquipped = false; // 기존 아이템 해제   // IsEquipped;가 true면 [E]가 나온다.
         }
     }
     // 새 아이템 장착 / 해제
     _items[idx].IsEquipped = !_items[idx].IsEquipped; // _items의 목록[idx]에 들어가서 IsEquipped이면, !(bool값을 반대로)로 장착 상태 변경

 }

 private static void StoreMenu() // 8. 상점
 {
     Console.Clear();
     ShowHighlightText("■ 상 점 ■");
     Console.WriteLine("필요한 아이템을 얻을 수 있는 상점입니다.");
     Console.WriteLine("");
     Console.WriteLine("[보유 골드]");
     Console.WriteLine("");
     PrintTextWithHighlights("", _player.Gold.ToString(), " G"); // 문자열 s2만 노란 색이기 때문에 앞에 "" 로 s1 추가해줬음, 관련 메서드 새로 만드는 것보단 편법 사용. 그래서 골드 값은 s2(노랑)으로 출력
     Console.WriteLine("");
     Console.WriteLine("[아이템 목록]");
     Console.WriteLine("");

     for (int i = 0; i < Item.ItemCnt; i++)
     {
         _items[i].PrintItemStatDescription(false, i + 1, true); // 아이템 설명 출력 메서드를 사용합니다.
     }
     // withNumber 파라미터를 false로 설정하여 상점에서만 아이템 번호 없이 출력
     // true값 하나 더 추가, 상점에서 아이템을 출력할 때 showPrice(가격 보여주기)를 true로 설정하여 가격을 출력 (기본이 true여서 넣을 필요는 없었던듯)
     // 인벤토리에선, 가격 표시를 사용 안하므로 2개의 매개변수만 가짐. (true, i + 1)


     Console.WriteLine("");
     Console.WriteLine("0. 나가기");
     Console.WriteLine("1. 아이템 구매");
     Console.WriteLine("2. 아이템 판매");
     Console.WriteLine("");

     switch (CheckValidInput(0, 2))
     {
         case 0:
             StartMenu();
             break;
         case 1:
             BuyItemMenu();
             break;
         case 2:
             SellItemMenu();
             break;
     }
 }


 private static void BuyItemMenu() // 8-1. 구매
 {
     Console.Clear();
     ShowHighlightText("■ 상 점 ■");
     Console.WriteLine("필요한 아이템을 구매 할 수 있습니다.\n");
     Console.WriteLine("\n구매하고 싶은 아이템 번호를 입력 해주세요.");
     Console.WriteLine("0을 입력하면 상점으로 돌아갑니다.");
     Console.WriteLine("");
     Console.WriteLine("[아이템 목록]");
     Console.WriteLine("");
     for (int i = 0; i < Item.ItemCnt; i++)
     {
         _items[i].PrintItemStatDescription(true, i + 1);
     }

     int choice = CheckValidInput(0, _items.Length); // 0 입력 가능
     if (choice == 0) // 0 입력 시 상점 메뉴로 복귀
     {
         StoreMenu();
         return;
     }

     choice -= 1; // 사용자 입력에서 1을 빼서 실제 인덱스로
     Item selectedItem = _items[choice];
     if (selectedItem.IsPurchased)   // 이미 구매한 아이템인지 확인
     {
         Console.WriteLine("이미 구매한 아이템입니다.");
     }
     else if (_player.Gold >= selectedItem.Gold) // 플레이어 골드가 선택 아이템 골드보다 크면 = 구매 가능한 경우
     {
         selectedItem.IsPurchased = true; // 구매 표시를 true로
         _player.Gold -= selectedItem.Gold; // 플레이어 골드 감소
         Console.WriteLine($"\n{selectedItem.Name} 구매를 완료했습니다.");
     }
     else // 골드가 부족한 경우
     {
         Console.WriteLine("Gold가 부족합니다.");
     }
     Console.WriteLine("아무 키나 누르면, 상점으로 돌아갑니다.");
     Console.ReadKey();
     StoreMenu();
 }

 private static void SellItemMenu() // 8-2. 판매
 {
     Console.Clear();
     ShowHighlightText("■ 상 점 ■");
     Console.WriteLine("필요한 아이템을 판매 할 수 있습니다.\n");
     Console.WriteLine("판매하고 싶은 아이템 번호를 입력 해주세요.");
     Console.WriteLine("\n0을 입력하면 상점으로 돌아갑니다.");
     Console.WriteLine("");
     Console.WriteLine("[아이템 목록]");
     Console.WriteLine("");
     for (int i = 0; i < _items.Length; i++)
     {
         if (_items[i].IsPurchased) // 구매한 아이템만 표시
         {
             _items[i].PrintItemStatDescription(true, i + 1, false);
             // PrintItemStatDescription 메서드를 호출할 때 showPrice를 false로 설정하여 가격 대신 판매가를

             int sellPrice = (int)(_items[i].Gold * 0.85); // 실제 판매가를 계산.
             Console.WriteLine($" - 판매가: {sellPrice} G");
         }
     }

     int choice = CheckValidInput(0, _items.Length); // 0 입력 가능
     if (choice == 0) // 0 입력 시 상점 메뉴로 복귀
     {
         StoreMenu();
         return;
     }

     choice -= 1; // 사용자 입력에서 1을 빼서 실제 인덱스로 변환
     Item selectedItem = _items[choice];
     if (selectedItem.IsPurchased)
     {
         int sellPrice = (int)(selectedItem.Gold * 0.85); // 실제 판매가를 계산
         _player.Gold += sellPrice; // 플레이어의 골드를 판매가만큼 증가
         selectedItem.IsPurchased = false; // 아이템의 구매 상태를 false로 설정
         Console.WriteLine($"{selectedItem.Name}을(를) {sellPrice}G에 판매했습니다.");
     }
     else
     {
         Console.WriteLine("판매할 수 없는 아이템입니다.");
     }

     Console.WriteLine("아무 키나 누르면, 상점으로 돌아갑니다.");
     Console.ReadKey();
     StoreMenu();
 }

 private static void ShowHighlightText(string text) // 6-1. 첫 줄 색 변경 함수, 마젠타 색
 {
     Console.ForegroundColor = ConsoleColor.Magenta;
     Console.WriteLine(text);
     Console.ResetColor();
 }

 private static void PrintTextWithHighlights(string s1, string s2, string s3 = "") // 6-2. 문자열 하이라이트 효과 함수 
 {
     Console.Write(s1);
     Console.ForegroundColor = ConsoleColor.Yellow; // 노란색 발동 -> s2에 적용
     Console.Write(s2);
     Console.ResetColor(); // 색 리셋
     Console.WriteLine(s3);
 }

 private static int CheckValidInput(int min, int max) // 5-6. 입력 값 유효성 확인 함수 (int 반환)
 {
     // 아래 두 가지 상황은 비정상, 재입력 수행
     // 1. 숫자가 아닌 입력을 받은 경우,    2. 숫자가 최솟값 에서 최댓값의 범위를 벗어난 경우
     int keyInput; // tryParse(정수화)에 필요
     bool result;  // while 반복문에 필요
     do // 일단 한 번 실행
     {
         Console.WriteLine("\n원하시는 행동을 입력 해주세요.");
         result = int.TryParse(Console.ReadLine(), out keyInput);
         // 입력을 정수로 변환하여 int KeyInput에 저장하고, 결과 값을 result로 설정.
         // 결과 값이 숫자(정수)면 가져오고, 그 외면 안 가져옴(실행 안한다는 뜻)
     }
     while (result == false || CheckIfVaild(keyInput, min, max) == false); // result가 false거나 CheckIfVaild함수가 false면 반복 

     //여기에 도착했다는 것은 (아래 유효성 확인 bool 함수를 통해) 제대로 입력을 받았다는 것.
     return keyInput;
 }

 private static bool CheckIfVaild(int keyInput, int min, int max) // 5-7. 유효성 확인 함수2 (bool 반환)
 {
     if (min <= keyInput && keyInput <= max) return true; // 키 입력값이 min ~ mix 사이면 return이 참 = 실행
     return false; // 그 외면 false
 }

 static void AddItem(Item item) // 5-3. 아이템 추가 함수
 {                               // ( ) 안에 Item item ? -> AddItem 메서드의 매개 변수 = 외부에서 Item 객체의 item 매개 변수를 받아 온 것.
                                 // Item 객체의 속성과 메서드에 접근 할 수 있음(데이터 읽기, 수정, 필요 기능 수행)
                                 // 타입 유연성, 재사용성, 안정성을 보장하고 잘못된 타입의 객체 전달 방지.

     if (Item.ItemCnt == 8) return; // Item클래스 객체의 ItemCnt 변수가 8이면 아무 것도 안 만든다.
     _items[Item.ItemCnt] = item;  // 0개 -> 0번 인덱스, 1개 -> 1번 인덱스
     Item.ItemCnt++;
 }

 private static void PrintStartLogo() // 5-4. 게임 스타트 화면
 {
     Console.WriteLine("==================================================================");
     Console.WriteLine("     ___________________   _____  __________ ___________ _____");
     Console.WriteLine("     \\_____  \\  |     ___//  /_\\  \\ |       _/  |    |  /  /_\\  \\ ");
     Console.WriteLine("     /        \\ |    |   /    |    \\|    |   \\  |    | /    |    \\");
     Console.WriteLine("    /_______  / |____|   \\____|__  /|____|_  /  |____| \\____|__  /");
     Console.WriteLine("            \\/                   \\/        \\/                  \\/");
     Console.WriteLine("________    ____ ___ _______     ________ ___________________    _______ ");
     Console.WriteLine("\\______ \\  |    |   \\\\      \\   /  _____/ \\_   _____/\\_____  \\   \\      \\");
     Console.WriteLine(" |    |  \\ |    |   //   |   \\ /   \\  ___  |    __)_  /   |   \\  /   |   \\");
     Console.WriteLine(" |    `   \\|    |  //    |    \\\\    \\_\\  \\ |        \\/    |    \\/    |    \\");
     Console.WriteLine("/_______  /|______/ \\____|__  / \\______  //_______  /\\_______  /\\____|__  /");
     Console.WriteLine("        \\/                  \\/         \\/         \\/         \\/         \\/");
     Console.WriteLine("==============================================================================");
     Console.WriteLine("                            PRESS ANYWAY TO START                             ");
     Console.WriteLine("==============================================================================");
     Console.ReadKey(); // 아무 키나 입력 받음
 }

 private static void RestMenu() // 9. 휴식하기
 {
     Console.Clear();
     ShowHighlightText("■ 휴 식 하 기 ■");
     Console.WriteLine("돈을 내고 여관에서 휴식을 하여 체력을 회복 시킬 수 있습니다.");
     Console.WriteLine("");
     Console.WriteLine($" 500 G 를 내면 체력을 회복할 수 있습니다. (보유 골드 : {_player.Gold} G)\n");
     Console.WriteLine("1. 휴식하기");
     Console.WriteLine("0. 나가기");

     string input = Console.ReadLine(); // 입력한 input은 문자열이다.
     if (input == "0") // 0번 나가기 입력 시 실행
     {
         StartMenu();
         return;
     }

     if (input == "1") // 휴식을 선택한 경우
     {
         if (_player.Hp == 100) // 이미 최대 체력인 경우
         {
             Console.WriteLine("이미 최대 체력입니다.");
         }
         else if (_player.Gold >= 500) // 골드가 500이상이며, 최대 체력이 아닌 경우(생략)
         {
             _player.Gold -= 500; // 골드 차감
             _player.Hp = 100; // 체력을 최대(100)로 회복
             Console.WriteLine($"휴식을 완료했습니다. (보유 골드 : {_player.Gold} G)");
         }
         else // 골드가 부족한 경우
         {
             Console.WriteLine("Gold가 부족합니다.");
         }
     }
     else
     {
         Console.WriteLine("잘못된 입력입니다.");
     }

     Console.WriteLine("\n아무 키나 누르면 돌아갑니다.");
     Console.ReadKey();
     StartMenu();
 }

 private static void DungeonMenu() // 12. 던전 입장
 {
     Console.Clear();
     ShowHighlightText("■ 던전 입장 ■");
     Console.WriteLine("이곳에서 던전으로 들어가기 전 다음 활동을 할 수 있습니다.");
     Console.WriteLine("");
     Console.WriteLine("1. 상태 보기");
     Console.WriteLine("2. 인벤토리");
     Console.WriteLine("3. 상점");
     Console.WriteLine("4. 던전 입장");
     Console.WriteLine("0. 메인 화면으로 나가기");
     Console.WriteLine("");

     switch (CheckValidInput(0, 4))
     {
         case 0:
             StartMenu(); // 메인 메뉴로 나가기
             break;
         case 1:
             StatusMenu(); // 상태 보기
             break;
         case 2:
             InventoryMenu(); // 인벤토리
             break;
         case 3:
             StoreMenu(); // 상점
             break;
         case 4:
             DungeonChoiceMenu(); // 던전 난이도 선택
             break;
     }
 }

 private static void DungeonChoiceMenu() // 12-1. 던전 난이도 선택
 {
     Console.Clear();
     ShowHighlightText("■ 던전 난이도 선택 ■");
     Console.WriteLine("다양한 난이도의 던전을 선택할 수 있습니다.");
     Console.WriteLine("");
     Console.WriteLine("1. 쉬운 던전     | 방어력 5 이상 권장");
     Console.WriteLine("2. 일반 던전     | 방어력 11 이상 권장");
     Console.WriteLine("3. 어려운 던전   | 방어력 17 이상 권장");
     Console.WriteLine("0. 던전 입구로 돌아가기");
     Console.WriteLine("");

     if (_player.Hp <= 0) // 체력이 0이하인 경우 입장컷.
         if (_player.Hp <= 0) // 체력이 0이하인 경우 입장컷.
         {
             Console.Clear();
             Console.WriteLine("!! 체력이 부족합니다. 회복 후에 도전해주세요 !!");
             Console.WriteLine("");
             Console.WriteLine("0. 던전 입구로 돌아가기");
             CheckValidInput(0, 0);
             DungeonMenu();
             return;
         }

     switch (CheckValidInput(0, 3))
     {
         case 0:
             DungeonMenu(); // 던전 메뉴
             break;
         case 1:
             ExecuteDungeon("쉬운 던전", 5, 1000); // ExecuteDungeon의 매개변수 값 입력 ( 던전 이름, 요구 방어력, 보상 골드 ) 
             break;
         case 2:
             ExecuteDungeon("일반 던전", 11, 1700);
             break;
         case 3:
             ExecuteDungeon("어려운 던전", 17, 2500);
             break;

             // 처음에는 EasyDungeon(); NormalDungeon(); HardDungeon(); 으로 구성했는데
             // 메서드가 하나여도, 로직에 따라 난이도 차이를 줄 수 있어서 ExecuteDungeon() 하나로 합침. 
     }
 }

 private static void ExecuteDungeon(string dungeonName, int requiredDef, int reward)
 {
     Console.WriteLine($"{dungeonName}에 입장했습니다. 각오 단단히 하세요.");
     Console.WriteLine("");

     int totalDef = _player.Def + GetSumBonusDef(); // 총 방어력 = 플레이어 방어력 + 아이템 추가 방어력
     int hpLoss = new Random().Next(20, 36); // 무작위 체력 감소 20 ~ 35
     int bonusReward = reward; // 기본 보상 설정 + 공격력에 따른 추가 보상이 더 해질 수 있게 정의

     double perBonusReward = new Random().Next(_player.Atk, _player.Atk * 2) / 100.0;
     // 플레이어의 공격력에 따른 추가 보상의 비율을 결정.
     // 공격력의 1 ~ 2배 사이의 값을 백분율 % 로 계산
     // double 사용 이유 -> float보다 정밀함
     // new Random().Next() 는 난수(무작위 수)를 생성하는 데 사용, Next의 매개변수에 지정 해준 값 2개가 랜덤 범위임.

     if (totalDef < requiredDef) // 총 방어력이 권장 방어력보다 낮은 경우
     {
         if (new Random().NextDouble() < 0.4) // 랜덤 조건문, 40% 확률로 실패..    = 40% 확률로 작동
         {
             Console.WriteLine("던전을 클리어하지 못했습니다.. 체력이 반 감소합니다.");
             _player.Hp = Math.Max(_player.Hp - hpLoss / 2, 0); // 실패시 체력이 절반으로 감소, 0보다는 낮아지지 않음.
             _player.Hp = Math.Min(_player.Hp, 100);
             // Math.Max(a,b) -> a,b를 비교해서 더 큰 값을 반환, 
             // 체력이 0 이하로 떨어지지 않도록 하려고 사용. ( 0이 음수보다 더 크므로 0이 반환 )
         }
         else // 권장 방어력 이상이거나, 권장 방어력 이하이지만 던전 실패가 아닌 경우
         {
             Console.WriteLine("던전을 클리어했습니다! 축하 드립니다.");

             bonusReward += (int)(reward * perBonusReward);
             // bonusReward 보너스 골드는  기본 골드 x 추가 공격력 비율 보상 곱해서 더 함
             // (결과값 int형으로)

             _player.Hp = Math.Max(_player.Hp - hpLoss, 0); // 플레이어 체력 - 손실 체력,  0보다는 낮아지지 않음.
             _player.Hp = Math.Min(_player.Hp, 100);
             _player.Gold += bonusReward; // 플레이어 골드 + 보너스 골드
         }
     }
     else // 권장 방어력 이상인 경우
     {
         Console.WriteLine("던전을 클리어했습니다!");
         hpLoss -= totalDef - requiredDef; // 체력손실량 = 방어력에 따라 체력 감소량 조정  

         bonusReward += (int)(reward * perBonusReward); // 위와 동일

         _player.Hp = Math.Max(_player.Hp - (hpLoss > 0 ? hpLoss : 0), 0);
         // 삼항 연산자, hpLoss가 0보다 크면 hpLoss를 쓰고, 아니면 0을 사용
         // 즉 체력 손실이 0보다 크면 그 값을 체력에서 차감하고, 그렇지 않으면 차감 X
         // Math.Max로, 체력 계산값이 0보다 크면 그 값을 _player.Hp로 반환 = 음수 방지

         _player.Hp = Math.Min(_player.Hp, 100);
         // 그런데, 이번엔 체력이 100을 초과하는 경우가 발생해서 Math.Min(더 작은 녀석 반환)를 만들어 줬음. 다른 조건문에도 만들어야 함.

         _player.Gold += bonusReward; // 플레이어의 골드에 최종 보상 더하기
     }

     Console.WriteLine("\n[탐험 결과]");

     Console.WriteLine($"체력 {_player.Hp + hpLoss} -> {_player.Hp}");
     // {_player.Hp + hpLoss} = 던전 전 체력

     Console.WriteLine($"Gold + {reward} G -> {_player.Gold} G\n");
     // 얼마가 추가되었는지 알 수 있 도록 {reward} 표시

     Console.WriteLine("0. 나가기");

     CheckValidInput(0, 0);
     StartMenu();
 }

 private static void GameOverMenu() // 11. 게임 종료
 {
     Console.Clear();
     ShowHighlightText("■ 정말 종료하시겠습니까? 진짜로? ■");
     Console.WriteLine();
     Console.WriteLine();
     Console.WriteLine("1. 네, 종료합니다.\n");
     Console.WriteLine("0. 아니요? 제가요? 왜요?");

     switch (CheckValidInput(0, 1))
     {
         case 1: // 게임 종료
             Console.WriteLine("아무 키나 누르면, 게임을 종료합니다.");
             Console.ReadKey();
             Environment.Exit(0); // 애플리케이션 종료
             break;
         case 0: // 메인 메뉴로 돌아가기
             StartMenu();
             break;
     }
 }

 private static void SaveGameMenu() // 10. 게임 저장
 {
     Console.Clear();
     ShowHighlightText("■ 저장 및 불러오기 ■");
     Console.WriteLine("게임을 저장하거나, 불러올 수 있습니다.");
     Console.WriteLine("");
     Console.WriteLine("1. 게임 저장하기");
     Console.WriteLine("2. 게임 불러오기");
     Console.WriteLine("0. 메인 화면으로 나가기");
     Console.WriteLine("");
     var input = Console.ReadLine();
     switch (input)
     {
         case "0":
             StartMenu();
             break;
         case "1":
             SaveGame("gameData.json");
             Console.WriteLine("게임이 저장되었습니다! 아무 키나 눌러주시면 메인 메뉴로 돌아갑니다.");
             Console.ReadKey();
             StartMenu(); // 메인 메뉴로
             break;
         case "2":
             LoadGame("gameData.json");
             Console.WriteLine("게임을 불러왔습니다! 아무 키나 눌러주시면 메인 메뉴로 돌아갑니다.");
             Console.ReadKey();
             StartMenu();
             break;

         default:
             Console.WriteLine("잘못된 입력입니다.");
             break;
     }
 }
 // 저장하기 전에, 패키지 관리자 콘솔에서 Install-Package Newtonsoft.Json 설치
 static void SaveGame(string filePath) // 10-1. 게임 저장,  다른 것도 모르겠는데 이건 진짜 모르겠음.
 {
     var gameData = new // 플레이어와 아이템 저장용 객체 생성
     {
         Player = _player,
         Items = _items
     };

     // JsonConvert.SerializeObject = 지정된 객체를 JSON 형식의 문자열로 변환
     string json = JsonConvert.SerializeObject(gameData, Formatting.Indented);
     // Formatting = 데이터를 일정한 형식에 맞추어 구성, 가독성
     // Indented = 들여쓰기, 이거 안하면 json 데이터가 일렬로 표현되서 어떤 객체가 뭐가뭔지 파악하기가 좀 어려워진다고 함.
     // 그래서 Indented 쓰면 객체마다 한 줄씩 출력 되서 데이터를 읽기 편해짐.

     File.WriteAllText(filePath, json); // filePath에 json 문자열을 파일로 저장, 덮어쓰기 가능.
                                        // 
                                        //File.WriteAllText = 특정 파일에 문자열을 쓰는 기능을 수행(저장), 덮어쓰기 가능
                                        //여기서는 데이터를 파일 형태로 저장할 때 사용
 }

 static void LoadGame(string filePath) // 10-2. 게임 불러오기
 {

     string json = File.ReadAllText(filePath); // filePath 로부터 저장했던 json 문자열을 읽어(Read)옴.

     var gameData = JsonConvert.DeserializeObject<dynamic>(json); // 위에서 읽어온 JSON 문자열로부터 게임 데이터를 역직렬화

     // DeserializeObject => Json 문자열을 다시 .NET객체(C#에서 코드로 보이게)로 역직렬화
     // dynamic, 역직렬화된 객체의 타입이 런타임에 결정.
     // 구체적인 클래스 타입을 명시하지 않아도 다양한 형태의 JSON 데이터를 처리 가능

     // 요약 : JSON 형태의 문자열 json을 .NET의 dynamic 타입 객체로 변환
     // 이렇게 하면  JSON에 있는 데이터의 구조가 미리 정해지지 않아도 되며
     // JSON 데이터가 어떤 형태든 상관없이 그때그때 필요한 데이터 형태로 변환해서 사용할 수 있다고 함.

     // 게임 데이터를 현재 게임 상태에 할당
     _player = gameData.Player.ToObject<Character>();
     _items = gameData.Items.ToObject<Item[]>();

     // ( 위 SaveGame()에서 저장용으로 생성하고, 저장했던 ) gameData 객체의 플레이어와 아이템 속성을 
     // 각각 Character(클래스), Item[](클래스) 타입의 객체로 변환하는 기능 = 게임 데이터가 현재 게임 데이터에 할당 된다는 뜻.

     // ToObject<지정하고픈 객체>() = 위에서 설정한 dynamic 타입의 객체를, 우리가 지정한 타입의 객체로 변환해줌
     

오늘도 무한 주석으로 공부.

던전 - 전투 구현 - ui 구현이 가장 힘들었다.


Q. filePath 를 SaveGame()과 LoadGame() 매개변수로 한 이유?

// 저장 or 읽어올 파일의 위치와 이름을 지정하기 위한 것

filePath 매개변수가 없다면, 게임을 다시 켰을 때 데이터를 불러올 정확한 위치를 알 수 없음.
filePath는 데이터를 저장하거나 불러올 파일의 경로와 이름을 지정.
이를 통해 프로그램이 어떤 파일에서 데이터를 저장하거나 읽어와야 하는지 알 수 있다.
-> '열쇠'라고 생각하면 된다고 함.

Q. 그런데 filePath는 프로그램이 종료되면 메모리에서 사라진다는데, 어떻게 불러온다는거지?
A. 아, filePath는 저장/로드 함수에 코드로 정의되어 있으니, 프로그램을 실행하면 다시 생성되는 구나.

그래서 filePath는 프로그램을 다시 시작할 때마다 정의 되고,
우리가 만든 코드를 사용하여 JSON 데이터를 불러 옴 (게임을 저장했다면, 로드를 했을 때.)

그럼 gameData.json은 윈도우 어딘가에 저장되어 있다? -> true
일반적으로 파일 경로에 디렉토리를 지정하지 않았다면, 실행 파일이 있는 곳에 저장

나의 경우는 gameData.json이
D:\WJJ_Project\source\repos\TextRPG2\TextRPG2\bin\Debug\net8.0 안에 있었음.


  • 튜텨님의 피드백
  1. 프로그램 클래스를 헤딴 곳에 넣고 나머지 클래스들을 밖에서 사용하고 있었다.
    = 근본부터 잘못 되었다.

  2. 상점 아이템 리스트의 초기화 및 원소 삽입 로직을 하나의 메서드에 합치고, 시작 시 한 번만 호출되도록 구성해야겠다.

  3. 대부분 동기들 물어보니 공통적으로 ReadMe가 중요하다고 말하셨다.. ReadMe 작성법을 찾아봐야 하겠다.

profile
개발 모코코

0개의 댓글