Text Rpg 필수 기능들을 모두 구현했다.
-> 화면에 출력되는 줄 간격 이런거 신경쓰느라고 시간이 좀 많이 썼다.
(멍도 조금씩 때리며)
//Main에서 Game 클래스 객체 생성 후 메서드 실행
public void StartGame()
{
if (!isGameOver)
{
CreatePlayer();
}
}
// 플레이어 생성
private void CreatePlayer()
{
while (true)
{
bool isNameSet = false;
bool isJobSet = false;
// 이름과 직업 설정
// 콜백을 받아 bool 타입 변수 값 대입
string name = SetPlayerName(success => isNameSet = success);
int jobType = SetPlayerJob(success => isJobSet = success);
// 이름과 직업이 잘 설정되었으면 MainGame으로 이동 아니면 다시 생성
if (isNameSet && isJobSet)
{
player = new Player(name, jobType);
MainGame();
break;
}
}
}
// 플레이어 이름 설정
private string SetPlayerName(Action<bool> onSet)
{
while (true)
{
Console.Clear();
Console.WriteLine("스파르타 던전에 오신 여러분 환영합니다!!");
// 이름 입력 유효성 검사
string playerName = InputValidator(2, 10);
Console.WriteLine($"\n입력하신 이름은 \"{playerName}\"입니다.\n");
Console.WriteLine("정말로 이 이름을 사용하시겠습니까?");
// 선택 입력 유효성 검사
int choice = InputValidator("1. 네\n2. 아니오", CHOICE_ACTION, 1, 2);
switch (choice)
{
case 1:
onSet?.Invoke(true); // 설정에 성공, 콜백 호출
return playerName;
case 2:
onSet?.Invoke(false); // 설정에 실패, 콜백 호출
break;
}
}
}
// 플레이어 직업 설정
private int SetPlayerJob(Action<bool> onSet)
{
while (true)
{
Console.Clear();
Console.WriteLine("스파르타 던전에 오신 여러분 환영합니다!!");
Console.WriteLine("\n원하시는 직업을 고르세요.");
// 직업 입력 유효성 검사
int jobType = InputValidator("1. 전사\n2. 마법사\n3. 궁수", "원하시는 직업의 번호를 선택하세요.", 1, 3);
Console.WriteLine($"\n선택하신 직업은 \"{(JobType)jobType}\"입니다.\n");
Console.WriteLine("이 직업을 선택하시겠습니까?");\
// 선택 입력 유효성 검사
int choice = InputValidator("1. 네\n2. 아니오", CHOICE_ACTION, 1, 2);
switch (choice)
{
case 1:
onSet?.Invoke(true); // 설정에 성공, 콜백 호출
return jobType;
case 2:
onSet?.Invoke(false); // 설정에 실패, 콜백 호출
break;
}
}
}
Main에서 Game 클래스 객체를 생성 후 StartGame() 메서드 호출 하며 시작
최대한 C# 문법으로 배웠던 내용들을 사용해보고자 하였다.
InputValidator 의 메서드 오버로딩
Action< bool > 타입의 델리게이트를 사용하여 설정완료 콜백을 받아 코드 진행처음 사용하려고 할 때는 조금 어색한 느낌이었지만 계속 사용하면서 점점 익숙해졌다.
// 게임의 메인 루프
void MainGame()
{
// 아이템 초기화 (초기에 한 번만 생성됨)
items = ItemManager.Instance.CreateItem();
while (!isGameOver)
{
Console.Clear();
Console.WriteLine("당신은 현재 마을에 있습니다.");
// 행동 동작 입력 대기
int choice = InputValidator("1. 상태보기\n2. 인벤토리\n3. 상점", CHOICE_ACTION, 1, 3);
// 각 선택에 따른 페이지로 이동
switch (choice)
{
case 1:
player.ShowStatus(); // 상태창으로 이동
break;
case 2:
inven.InventoryPage(player, items); // 인벤토리 페이지 이동
break;
case 3:
shopPages.ShopMainPage(player, items); // 상점 페이지 이동
break;
}
}
}
플레이어의 설정이 완료되면 게임의 메인루프로 넘어오게된다.
상점(ShopPages), 인벤토리(Inventory), 상태창(Player)을 각각 클래스로 만들고 해당 클래스에서 필요한 동작을 하고, 게임은 메인 루프에서 사용자의 선택에 따라 각각의 정의된 메서드가 호출되고, 해당 메서드가 종료되면 다시 메인 루프로 돌아오게되는 방식으로 설계하였다.
// 플레이어 응답 유효성 검사 메서드 (행동 선택)
public static int InputValidator(string options, string msg, int min, int max)
{
while (true)
{
Console.Write($"\n{options}\n\n{msg}\n>> ");
if (int.TryParse(Console.ReadLine(), out int value) && value >= min && value <= max)
return value;
Console.WriteLine($"{WRONG_CHOICE}\n");
}
}
// 플레이어 응답 유효성 검사 메서드 (이름 입력)
public static string InputValidator(int minLength, int maxLength)
{
while (true)
{
Console.Write($"\n원하시는 이름을 입력해주세요.(길이{minLength} ~ {maxLength})\n>> ");
string inputName = Console.ReadLine();
// 이름 길이가 조건에 맞다면 return
if (inputName.Length >= minLength && inputName.Length <= maxLength) return inputName;
Console.WriteLine($"{WRONG_CHOICE}");
}
}
자주 사용되는 플레이어의 응답 유효성을 검사하는 메서드를 정의하였다.
오버로딩으로 반환 타입과 전달 받는 매개변수를 다르게 하여,
string을 반환하는 경우 - 최소, 최대 길이를 지정하여 유효성 검사
int 값을 반환하는 경우 - 선택지, 문구, 최소, 최대 값을 받아서 선택지들과 문구를 출력하고,
해당 범위 안에서의 유효성을 검사한다.
public enum JobType { Warrior = 1, Mage, Archer };
public struct Stats
{
// 각 필드마다 get/set 정의 → 값이 0 이하가 되지 않도록 처리
private int level;
public int Level
{
get => level;
set => level = Math.Max(0, value);
}
private int hp;
public int Hp
{
get => hp;
set => hp = Math.Max(0, value);
}
... 생략
}
enum 타입으로 직업을 구분하고, 구조체에 플레이어의 능력치를 담아 저장
public class Player
{
private JobType jobType { get; } // 플레이어 직업
public string Name { get; } // 플레이어 이름
public Stats stats; // 플레이어 능력치
// 생성자
public Player(string Name, int jobType)
{
// 이름과 직업 타입만 받아오고 나머지는 기본값
this.Name = Name;
this.jobType = (JobType)jobType;
stats.Level = 1;
stats.Attack = 10;
stats.ItemAttack = 0;
stats.Defense = 5;
stats.ItemDefense = 0;
stats.Hp = 100;
stats.Gold = 1500;
}
// 상태창 페이지
public void StatusPage()
{
while (true)
{
// 콘솔 창 클리어 및 캐릭터 정보 표시
Console.Clear();
Console.WriteLine("상태 보기");
Console.WriteLine("캐릭터의 정보가 표시 됩니다.\n");
Console.WriteLine($"닉네임: {Name}");
Console.WriteLine($"Lv. {stats.Level:00}");
Console.WriteLine($"Chad ({GetJobType(jobType)})");
Console.WriteLine($"공격력 : {stats.Attack} " + GetItemStat(stats.ItemAttack));
Console.WriteLine($"방어력 : {stats.Defense} " + GetItemStat(stats.ItemDefense));
Console.WriteLine($"체력 : {stats.Hp}");
Console.WriteLine($"Gold : {stats.Gold} G");
Console.WriteLine("\n0. 나가기");
Console.Write("\n원하시는 행동을 입력해주세요.\n>> ");
// 입력 값 체크
if (!int.TryParse(Console.ReadLine(), out int input) || input != 0)
{
Console.WriteLine("\n잘못된 입력입니다. 다시 입력해주세요.");
Thread.Sleep(1000);
}
else return;
}
}
// 나중에 던전 입장 시에 사용
virtual public void EnterDungeon()
{
Console.WriteLine($"\n{Name}이(가) 던전에 입장합니다.");
}
// enum 타입의 변수를 받아 string 으로 변환
private string GetJobType(JobType type)
{
return type switch
{
JobType.Warrior => "전사",
JobType.Mage => "마법사",
JobType.Archer => "궁수",
_ => "백수"
};
}
// 아이템을 장착해 얻은 능력치를 string으로 변환
private string GetItemStat(int itemStat)
{
if (itemStat == 0) return "";
else return $"(+{itemStat})";
}
}
- StatusPage(): 메서드를 통해 플레이어의 정보들을 포맷에 맞춰 화면에 출력
- EnterDungeon(): 가상메서드로 자식 클래스에서 override 가능
- GetJobType(): enum 타입 변수 jobType 간단하게 string 리턴
- GetItemStat(): 아이템을 통해 얻은 능력치를 포맷에 맞춰 string 리턴
public class Warrior : Player
{
public Warrior(string Name, int jobType) : base(Name, jobType) { }
public override void EnterDungeon()
{
Console.WriteLine($"{Name}이(가) 칼과 방패를 힘차게 두드리며 던전에 입장합니다.");
}
}
public class Mage : Player
{
public Mage(string Name, int jobType) : base(Name, jobType) { }
public override void EnterDungeon()
{
Console.WriteLine($"{Name}이(가) 주문을 중얼중얼...외우며 던전에 입장합니다.");
}
}
public class Archer : Player
{
public Archer(string Name, int jobType) : base(Name, jobType) { }
public override void EnterDungeon()
{
Console.WriteLine($"{Name}이(가) 괜히 활시위를 한번 당겨보며 던전에 입장합니다.");
}
}
아직은 부모클래스의 EnterDungeon을 override하여 각 자식마다 다르게 구현만 해놓은 상태이다.
기능을 더 만들어보면서 각 직업간의 특색을 살리는 방향을 생각해봐야겠다.
public enum ItemType { Weapon = 1, Armor };
public struct ItemStats
{
public string Name { get; } // 아이템 이름
public int Value { get; } // 아이템 능력치 상승값
public string Info { get; } // 아이템 설명
public int Cost { get; } // 아이템 가격
// 구조체 생성자
public ItemStats(string name, int value, string info, int cost)
{
Name = name;
Value = value;
Info = info;
Cost = cost;
}
}
Player 클래스와 동일하게 타입은 enum으로 관리, 관련 스탯들은 구조체로 관리하고 있다.
public class Item
{
public ItemType Type { get; }
public ItemStats itemStat;
public bool IsOwn { get; private set; }
public bool IsEquip { get; private set; }
// 아이템 생성자
public Item(string name, ItemType type, int value, string info, int cost)
{
Type = type;
itemStat = new ItemStats(name, value, info, cost);
IsOwn = false;
IsEquip = false;
}
public void BuyItem(int gold, Action<bool> onBought)
{
// 이미 소유중이라면 리턴
if (IsOwn)
{
Console.WriteLine("\n이미 구매한 아이템입니다.");
onBought?.Invoke(false);
return;
}
// 골드가 충분하면 구매
else if (gold >= itemStat.Cost)
{
IsOwn = true;
Console.WriteLine("\n구매를 완료했습니다.");
onBought?.Invoke(true);
}
// 골드가 부족하면 구매 실패
else if (gold < itemStat.Cost)
{
Console.WriteLine("\nGold가 부족합니다.");
onBought?.Invoke(false);
}
}
// 아이템 장착 및 해제
public void SetEquipment(Player player)
{
// 장착 여부 변경
IsEquip = !IsEquip;
// 장착 여부에 따른 스탯 +, -
switch (Type)
{
case ItemType.Weapon:
player.stats.ItemAttack += IsEquip ? itemStat.Value : -itemStat.Value;
break;
case ItemType.Armor:
player.stats.ItemDefense += IsEquip ? itemStat.Value : -itemStat.Value;
break;
}
}
// 아이템 정보 출력 (상점)
public void PrintItemInfoShop()
{
// 아이템 이름, 아이템 타입과 능력치, 아이템 설명, 아이템 구매여부에 따라 골드 or "구매완료" 출력
Console.WriteLine($"{itemStat.Name} | {GetItemType(Type)} +{itemStat.Value} | {itemStat.Info} | {(IsOwn ? "구매완료" : itemStat.Cost + " G")}");
}
// 아이템 정보 출력 (인벤토리)
public void PrintItemInfoInventory()
{
// 장착여부, 아이템 이름, 아이템 타입과 능력치, 아이템 설명 출력
Console.WriteLine($"{(IsEquip ? "[E]" : "")}{itemStat.Name} | {GetItemType(Type)} +{itemStat.Value} | {itemStat.Info}");
}
// 아이템 타입에 따른 string 리턴
private string GetItemType(ItemType type)
{
return type switch
{
ItemType.Weapon => "공격력",
ItemType.Armor => "방어력",
_ => "???"
};
}
}
아이템의 관한 처리를 모두 Item 클래스에서 동작하게 만들었다. 사용을 위해선 생성한 Item 배열을 가지고 각 아이템에 접근하여 메서드를 호출
코드의 가독성과 유지보수를 위해 각 Class를 나누어 작성 중인데 뭔가 만들다 보면 기능이 점점 추가되어서 지저분해지는거 같은 느낌... 🤣
public Dictionary<Item, int> invenItem = new Dictionary<Item, int>();
소유한 아이템과 그 아이템의 items[] 배열에서의 인덱스를 저장하는 딕셔너리
아이템을 소유한 것들을 그 인덱스 위치와 함께 저장하여 아이템을 변경하려고 했다.하지만? 생각해보니 너무 어렵게 생각을 해버린 것이었다.
그냥 List에 Item을 담아서 사용하면 되는걸 왜 그랬을까..?
public List<Item> invenItem = new List<Item>();
허허허 이제라도 알아챈 것이 어디인가.
// 기본 인벤토리 화면 (보유 중인 아이템 목록을 출력함)
public void InventoryPage(Player player, Item[] items)
{
while (true)
{
// 화면 출력
Console.Clear();
Console.WriteLine("인벤토리\n보유 중인 아이템을 관리할 수 있습니다.\n");
Console.WriteLine("[아이템 목록]\n");
for (int i = 0; i < items.Length; i++)
{
// 소유중인 아이템만 출력
if (items[i].IsOwn)
{
Console.Write("- ");
items[i].PrintItemInfoInventory();
// 내 아이템에 존재하지 않는다면 인덱스와 함께 추가
if (!invenItem.Contains(items[i]))
{
invenItem.Add(items[i]);
}
}
}
// 선택 입력 유효성 검사
int choice = Game.InputValidator("\n1. 장착 관리\n0. 나가기", Game.CHOICE_ACTION, 0, 1);
switch (choice)
{
case 0: // 나가기
return;
case 1:
ItemEquipPage(player, items); // 아이템 장착 관리로 이동
break;
}
}
}
// 아이템 장착 페이지, 아이템을 장착 및 해제할 수 있음
private void ItemEquipPage(Player player, Item[] items)
{
while (true)
{
Console.Clear();
Console.WriteLine("인벤토리\n보유 중인 아이템을 관리할 수 있습니다.\n");
Console.WriteLine("[아이템 목록]\n");
for(int i = 0; i < invenItem.Count; i++)
{
Console.Write($"{i + 1} ");
invenItem[i].PrintItemInfoInventory();
}
// 선택 입력 유효성 검사 (0 ~ 소유한 아이템의 길이)
int choice = Game.InputValidator("0. 나가기", Game.CHOICE_ACTION, 0, invenItem.Count);
if (choice == 0) return; // 나가기
else // 소유중인 아이템 선택 시
{
// 소유중인 아이템 리스트에서 인덱스로 접근 후 장착
Item item = invenItem[choice - 1];
item.SetEquipment(player);
}
}
}
'소유중인 아이템에 접근을 어떻게 해야되지?' 라는 고민을 잠시 했지만 어차피 우리의 아이템 목록의 선택지는 순서대로 1, 2, 3 처럼 표시되는 정수여서 그 [입력값 - 1] 을 해주면 List의 인덱스로 쉽게 접근하여 사용이 가능했다.
// 상점의 메인 탭
public void ShopMainPage(Player player, Item[] items)
{
while (true)
{
// 화면 출력
Console.Clear();
Console.WriteLine("상점");
Console.WriteLine("필요한 아이템을 구매할 수 있는 상점입니다.\n");
Console.WriteLine($"[보유골드]\n{player.stats.Gold} G\n");
Console.WriteLine("[아이템 목록]");
foreach (Item item in items)
{
Console.Write("- ");
item.PrintItemInfoShop();
}
// 선택 입력 유효성 검사
int choice = Game.InputValidator("1. 아이템 구매\n0. 나가기", Game.CHOICE_ACTION, 0, 1);
switch (choice)
{
case 1: // 아이템 구매
ShopPurchasePage(player, items);
break;
case 0: // 나가기
return;
}
}
}
// 상점의 구매 탭
void ShopPurchasePage(Player player, Item[] items)
{
while (true)
{
// 화면 출력
Console.Clear();
Console.WriteLine("상점 - 아이템 구매");
Console.WriteLine("필요한 아이템을 구매할 수 있는 상점입니다.\n");
Console.WriteLine($"[보유골드]\n{player.stats.Gold} G\n");
Console.WriteLine("[아이템 목록]");
for (int i = 0; i < items.Length; i++)
{
Console.Write($"- {i + 1} ");
items[i].PrintItemInfoShop();
}
// 선택 입력 유효성 검사
int choice = Game.InputValidator("0. 나가기", Game.CHOICE_ACTION, 0, items.Length);
if (choice == 0) return; // 돌아가기
else
{
Item item = items[choice - 1]; // 선택한 아이템
// 아이템 구매
item.BuyItem(player.stats.Gold, (onBought) =>
{
// 구매 성공 시
if (onBought)
{
player.stats.Gold -= item.itemStat.Cost;
Thread.Sleep(1000);
}
// 실패 시
else
{
Thread.Sleep(1000); // 1초 뒤 다시 처음으로
}
});
}
}
}
화면에 아이템들을 출력하고 그 아이템들을 이용해서 구매 (판매도 만들 예정)등의 기능을 하는 것이 Inventory와 비슷하여 금방 만들어낼 수 있었다.
TextRpg를 처음 만들려고할 때 이쁘게 만들고 싶어서 구조에 대해 고민하다보니 처음엔 개발 속도가 정말 더뎠었는데, 구조를 점차 잡아가면서 다른 기능들을 추가하는 것은 크게 어렵지 않았다.
확실히 TextRpg를 만들면서 기존에 Unity를 사용해서 작업할 때 보다는 언어를 좀 더 공부하게 되는 느낌이었다.
솔직히 오늘은 좀 놀면서 해서 많이 만들지는 못했다..
필수 기능은 이제 모두 구현하였으니 내일은 남은 도전 기능을 만들어야겠다. 최대한 빠르게 마무리를 하고 계속 복습하면서 알고리즘 강의로 넘어가야지 화이팅 내 인생 😁