Text Rpg 도전 기능들을 데이터 저장빼고 모두 구현했다.
기존에 있던 코드들도 열심히 리팩토링해서 최대한 깔끔하게 정리하려고 노력했다.
간단하게 아스키 아트(텍스트) 넣을거 없나 찾아보다가 좋은 사이트를 발견했다.
snskeyboard
기존에 Game 클래스 안에 존재하던 InputValidator()를 따로 InputHandler static 클래스를 만들어 관리하게 하였다.
public static class InputHandler
{
// 자주쓰이는 string 상수로 저장
public const string CHOICE_ACTION = "원하시는 행동을 선택하세요.";
public const string WRONG_INPUT = "잘못된 입력입니다. 다시 입력해주세요.";
// 플레이어 응답 대기 메서드 (행동 선택)
public static int InputValidator(string options, int min, int max)
{
while (true)
{
Console.Write($"\n{options}\n\n{CHOICE_ACTION}\n>> ");
if (int.TryParse(Console.ReadLine(), out int value) && value >= min && value <= max)
return value;
Console.WriteLine($"{WRONG_INPUT}\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_INPUT}");
}
}
}
Item 클래스 내부에서 동작하던 구매, 장착/해제 관련 메서드들도 ItemManager로 옮겨서 Item 클래스에서는 정보 저장과 출력도 해주도록 수정하였다.
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 ChangeIsOwn()
{
IsOwn = !IsOwn;
}
public void ChangeIsEquip()
{
IsEquip = !IsEquip;
}
// 아이템 정보 출력 (상점)
public void PrintItemInfoShop()
{
Console.WriteLine($"{itemStat.Name, -10} | {itemStat.GetItemType(Type), -3} +{itemStat.Value, -3} | {itemStat.Info, -35} | {(IsOwn ? "구매완료" : itemStat.Cost + " G"), -10}");
}
// 아이템 정보 출력 (상점 - 아이템 판매)
public void PrintItemInfoSellItem()
{
Console.WriteLine($"{itemStat.Name, -10} | {itemStat.GetItemType(Type), -3} +{itemStat.Value, -3} | {itemStat.Info, -35} | {itemStat.Cost * 0.85 + " G", -10}");
}
// 아이템 정보 출력 (인벤토리)
public void PrintItemInfoInventory()
{
Console.WriteLine($"{(IsEquip ? "[E]" : "")} {itemStat.Name, -10} | {itemStat.GetItemType(Type), -3} +{itemStat.Value, -3} | {itemStat.Info}");
}
}
목록 출력했을 때 조금이라도 이쁘게 나오게 하려고 조금이라도 정렬을 해봤지만 아직은 그렇게 이쁘게 나오지 않는다. 다른 방법을 좀 찾아봐야할 거 같다.
플레이어가 이동하는 페이지들 상점, 인벤토리, 휴식, 던전 등의 콘솔 출력을 담당하는 코드들은 한 곳으로 모았다.
그리고 아이템을 출력하는 코드는 상점과 인벤토리 유사해서 메서드로 만들어 각 페이지 타입에 맞게 사용하여 아이템 목록을 출력하게 하엿다.
enum CatalogueType
{
Shop,
SellItem,
Inventory
}
public class Pages
{
#region 목록 출력 메서드
/// <summary>
/// 목록을 출력하는 메서드
/// </summary>
/// <param name="items"></param>
/// <param name="type">0 = Shop, 1 = SellItem, 2 = Inventory</param>
private void PrintCatalogue(IReadOnlyCollection<Item> items, CatalogueType type)
{
foreach (Item item in items)
{
Console.Write("- ");
switch (type)
{
case CatalogueType.Shop: // 상점
item.PrintItemInfoShop();
break;
case CatalogueType.SellItem: // 상점 - 아이템판매
item.PrintItemInfoSellItem();
break;
case CatalogueType.Inventory: // 인벤토리
item.PrintItemInfoInventory();
break;
}
}
}
private void PrintCatalogueWithNumber(IReadOnlyCollection<Item> items, CatalogueType type)
{
int idx = 1;
foreach (Item item in items)
{
Console.Write($"{idx} ");
switch (type)
{
case CatalogueType.Shop: // 상점
item.PrintItemInfoShop();
break;
case CatalogueType.SellItem: // 상점 - 아이템판매
item.PrintItemInfoSellItem();
break;
case CatalogueType.Inventory: // 인벤토리
item.PrintItemInfoInventory();
break;
}
idx++;
}
}
}
각 출력하는 포맷이 좀 다르다 보니까 Item내에서 아이템 정보를 출력해주는 메서드가 많아 좀 지저분해졌다. 어떻게 잘 정리하면 더 깔끔해질 수 있을거 같은데 오늘은 아무리 머리를 굴려봐도 생각이 나지 않았다.
아이템 정보를 클래스 / 구조체 활용하고 배열로 관리하는건 만들다보니 이미 되어있던 것들이고,
나만의 새로운 아이템 추가는 밑에 두 개 추가해주었다.
new Item("최강의 검", ItemType.Weapon, 99, "그냥 얘가 최고다.", 9999),
new Item("최강의 갑옷", ItemType.Armor, 99, "하지만 얘도 최고다", 99999)
500G를 주고 체력을 회복하는 시스템을 만드는 것이었다. (체력은 100까지 회복)
체력은 프로퍼티로 0 ~ 100사이 값을 고정해주었다.
... Player 클래스의 Hp 프로퍼티
public int Hp
{
get => hp;
// 체력은 0 ~ 100 사이 값 고정
set => hp = Math.Min(Math.Max(0, value), 100);
}
public void RestPage(Player player)
{
// 휴식 가격
int cost = 500;
while (true)
{
Console.Clear();
BannerManager.Show("REST");
Console.WriteLine("휴식하기");
Console.WriteLine($"{cost} G를 내면 체력을 회복할 수 있습니다. (보유 골드 : {player.stats.Gold} G)");
int choice = InputHandler.InputValidator("1. 휴식하기\n0. 나가기", 0, 1);
switch (choice)
{
case 1:
player.Rest(cost);
break;
case 0:
return;
}
}
}
Pages 클래스 내부에 RestPage()를 추가 해주었다 기본 구조는 다른 페이지들과 비슷하고 어떻게 골드와 체력을 처리할까 하다가 변수를 소지하고있는 Player 클래스 내부에서 처리하는 편이 안전하겠다 생각이 들어 cost값만 넘겨주어서 Player 내부에 메서드를 구현하였다.
public abstract class Player
{
abstract public void Rest(int cost);
}
자식 클래스로 3가지 직업들을 만들어놓았는데 그 직업들의 특성을 살려 직업마다 휴식을 할 때 출력되는 내용이 다르게 출력되도록 추상 메서드로 선언해주고 자식 클래스에서 구현하였다.
// 법사의 Rest()
public override void Rest(int cost)
{
// 체력이 최대라면 휴식X
if (stats.Hp == 100)
{
Console.WriteLine("\n지금은 어떤 마법이던 쓸 수 있을 거 같습니다.");
Thread.Sleep(1000);
return;
}
else if (stats.Gold >= cost)
{
// 골드 차감
stats.Gold -= cost;
Console.WriteLine("\n주문서와 지팡이를 내려놓고 잠에 듭니다.");
Thread.Sleep(1000);
stats.Hp += 30; // 체력 100으로 회복
Console.WriteLine($"\n체력이 회복되었습니다!\n현재 체력 -> {stats.Hp}");
Thread.Sleep(1000);
return;
}
else
{
Console.WriteLine("\nGold가 부족합니다.");
Thread.Sleep(1000);
return;
}
}
이번 기능은 사실 뭐 어려울게 없어서 출력되는 메세지 뭐로할지 고민하는 시간이 더 길었던거 같다.
아이템은 Weapon과 Armor 두 개의 타입이 존재하는데, 한가지 타입에 하나씩 착용을 하도록 기능을 개선하는 것이었다.
private Item? weaponEquipment = null; // 장착 중인 Weapon 타입 아이템
private Item? armorEquipment = null; // 장착 중인 Armor 타입 아이템
우선 각 타입마다 장착중인 아이템을 저장하기 위한 Nullable 타입 변수를 2개 만들어줬다.
그러고 처음엔 그냥 간단하게
public void SetEquipment(Player player, Item item)
{
// 장착 여부 변경
item.ChangeIsEquip();
// 장착 여부에 따른 스탯 +, -
switch (item.Type)
{
case ItemType.Weapon:
player.stats.ItemAttack += item.IsEquip ? item.itemStat.Value : -item.itemStat.Value;
weaponEquipment = weaponEquipment ?? item;
if (weaponEquipment != null)
{
weaponEquipment.ChangeIsEquip();
player.stats.Attack -= weaponEquipment.itemStat.Value;
weaponEquipment = item;
}
break;
case ItemType.Armor:
... 생략
}
}
기존에 있던 코드에 장착중인 장비가 있는지 확인하고 해제하고 장착하도록 구현했는데
그것은 나만의 생각이었다.
우선 weaponEquipment = weaponEquipment ?? item; 여기서 이미 변수에 값을 할당해놓고 밑에서 if (weaponEquipment != null) 항상 true인걸 검사하고있는 바보가 있었다.
그래서 일단 두개의 순서만 바꿔보았는데 잘 생각해보니
weaponEquipment 가 매개변수 item과 같을 때 문제가 생기는 것이다.장착 여부를 변경하는 ChangeIsEquip() 메서드를 2번 호출하는 것이 문제였다.
덕분에 위와 같은 상황일때 스탯만 깎이는 사태가 벌어졌기 때문이다.그래서 내린 결론은 나중에 아이템을 팔았을 때 장비도 해제되어야하고 하니 그냥 새로 만들자였다.
// 아이템 장착 및 해제 설정
public void SetItemEquip(Player player, Item item)
{
if (!item.IsEquip)
ItemEquip(player, item);
else
ItemUnEquip(player, item);
}
// 아이템 장착
private void ItemEquip(Player player, Item item)
{
switch (item.Type)
{
case ItemType.Weapon:
// 장착 아이템이 있으면 해당 아이템 장착 해제
if (weaponEquipment != null)
ItemUnEquip(player, weaponEquipment);
// 장착 아이템 변경
weaponEquipment = item;
// 아이템 장착 및 스탯 적용
item.ChangeIsEquip();
player.stats.ItemAttack += item.itemStat.Value;
break;
case ItemType.Armor:
... 생략
}
}
// 아이템 해제
private void ItemUnEquip(Player player, Item item)
{
switch (item.Type)
{
case ItemType.Weapon:
// 장착 아이템 해제 및 스탯 제거
weaponEquipment = null;
item.ChangeIsEquip();
player.stats.ItemAttack -= item.itemStat.Value;
break;
case ItemType.Armor:
... 생략
}
}
이렇게 기능을 나누어서 작성하니 보기 너무 편안했다. 동작을 생각하기도 편안하고,
원래 '짧은게 보기 좋지' 라는 생각으로 삼항 연산자를 사용해서 처리를 했었는데
차라리 메서드를 나누어 구현하는 편이 훨씬 보기에도 좋고 유지보수에도 쉽다는걸 배우게 됐다.읽기 쉽고 명확한 코드 = 좋은 코드
판매는 구매가격의 85% 가격으로 판매하고, 장착하고 있는 아이템이었다면 해제되는 기능
public void ShopSellPage(Player player)
{
while (true)
{
// 화면 출력
Console.Clear();
BannerManager.Show("SHOP");
Console.WriteLine("상점 - 아이템 판매");
Console.WriteLine("필요한 아이템을 구매할 수 있는 상점입니다.\n");
Console.WriteLine($"[보유골드]\n{player.stats.Gold} G\n");
Console.WriteLine("[아이템 목록]");
// 아이템 목록 출력
PrintCatalogueWithNumber(player.inven.Items, CatalogueType.SellItem);
// 선택 입력 유효성 검사
int choice = InputHandler.InputValidator("0. 나가기", 0, player.inven.Items.Count);
if (choice == 0) return; // 돌아가기
else
{
Item item = player.inven.GetItemUseIndex(choice - 1);
//아이템 판매
ItemManager.Instance.SellItem(player, item);
}
}
}
(ItemManager 클래스)
public void SellItem(Player player, Item item)
{
if (item.IsEquip)
SetItemEquip(player, item);
Console.WriteLine($"\n아이템을 판매하여 {item.itemStat.Cost * 0.85:F0} G를 얻었습니다.");
// 아이템 인벤에서 제거 및 돈 지급
item.ChangeIsOwn();
player.inven.RemoveItemInventory(item);
player.stats.Gold += (int)(item.itemStat.Cost * 0.85);
Thread.Sleep(1000);
}
위에서 아이템 장착 해제 관련 부분을 잘 나눠서 작성해놓으니까 다른 작업들을 하기에 수월했다.
저 0.85 곱하는게 조금 짜치긴한다..
던전입장과 그 던전에서 수행되는 기능. 레벨업도 던전과 연결되어있기 때문에 같이 진행했다.
주어진 내용대로 정확하게 수행하는게 프로그래머의 능력이라 생각하여 최대한 정확하게 구현하려고 노력했다.
public enum DungeonType
{
EASY = 5,
NORMAL = 11,
HARD = 17
};
// 각 난이도의 보상 설정
private Dictionary<DungeonType, int> rewardDic = new()
{
{ DungeonType.EASY, 1000 },
{ DungeonType.NORMAL, 1700 },
{ DungeonType.HARD, 2500 },
};
먼저 던전의 난이도를 enum 타입으로 만들어 정수값을 권장 방어력과 같게하여
나중에 난이도의 값을 변경 시에도 용이하게 하고, 타입과 보상을 묶어 딕셔너리로 관리했다.
public void EnterDungeon(Player player, DungeonType type, Action<bool> onCleared)
{
// 클리어 여부 변수
bool isClear = false;
// 권장 방어력 보다 높을 때
if (player.stats.Defense >= (int)type)
isClear = DungeonClear(player, type, true);
// 권장 방어력 보다 낮을 때
else
{
// 40% 확률로 실패
if (rand.Next(1, 10) <= 4)
isClear = DungeonClear(player, type, false);
// 60% 확률로 성공
else
isClear = DungeonClear(player, type, true);
}
// 클리어 콜백 호출
onCleared?.Invoke(isClear);
}
먼저 권장 방어력을 기준으로 던전의 클리어 여부를 나누고 클리어 보상을 계산하고 지급하는 메서드를 호출한다.
private bool DungeonClear(Player player, DungeonType type, bool isClear)
{
// 추가 체력 감소에 쓰일 방어력 차이
int defenseDiff = player.stats.Defense - (int)type;
// 추가 보상 % 계산
float bonusReward = 1f + (rand.Next((int)player.stats.Attack, (int)(player.stats.Attack * 2)) / 100f);
if (isClear)
{
// 체력 감소량
int hpDecrease = rand.Next(20 + defenseDiff, 35 + defenseDiff);
// 보상 골드 계산
int rewardGold = (int)(rewardDic[type] * bonusReward);
// 클리어 보상 적용
player.ClearDungeon(hpDecrease, rewardGold);
}
else
{
// 체력 절반 감소, 보상 X
player.ClearDungeon(player.stats.Hp / 2, 0);
}
return isClear;
}
그러고 나서 체력 감소량과 보너스 보상을 계산하고 플레이어에게 지급한다.
지금 보니 저 보상들 계산하는건 따로 메서드로 나누는 편이 더 좋을 거 같다.
public void ClearDungeon(int hpDecrease, int rewardGold)
{
// 체력 감소 및 골드 추가
stats.Hp -= hpDecrease;
stats.Gold += rewardGold;
}
Player 클래스 내부에서 보상을 처리해준다.
// 던전 결과 페이지
private void DungeonResultPage(Player player, DungeonType type)
{
if (player.stats.Hp == 0)
{
Console.WriteLine("\n지금은 숟가락들 힘도 없습니다...");
Thread.Sleep(1000);
return;
}
// 플레이어 던전 입장
player.EnterDungeon();
// 사용할 클래스 객체 생성
Random rand = new Random();
Dungeon dungeon = new Dungeon();
// 던전 탐험 전 Hp, Gold 저장
int pre_Hp = player.stats.Hp;
int pre_Gold = player.stats.Gold;
// 던전 탐험 시작
dungeon.EnterDungeon(player, type, onCleared =>
{
// 클리어 성공
if (onCleared)
{
Console.Clear();
BannerManager.Show("DUNGEON");
Console.WriteLine("던전 클리어");
Console.WriteLine("축하합니다!!!");
Console.WriteLine($"{dungeon.GetTypeString(type)}을 클리어 하였습니다.\n");
Console.WriteLine("[탐험 결과]");
Console.WriteLine($"체력 {pre_Hp} -> {player.stats.Hp}");
Console.WriteLine($"Gold {pre_Gold} G -> {player.stats.Gold} G");
player.SetClearTime(); // 클리어 타임 추가
int choice = InputHandler.InputValidator("0. 나가기", 0, 0);
if (choice == 0) return;
}
// 클리어 실패
else
{
... 생략
}
});
}
체력이 0이면 던전에 입장하지 못하도록 하고, Dugeon 클래스의 EnterDugeon() 메서드를 호출해 완료 콜백이 호출되면 성공/실패 화면을 출력하도록 하였다.
Player 클래스의 SetClearTime() 호출하여 클리어 타임을 증가 -> 일정 횟수가 되면 레벨업!
// 클리어 타임으로 레벨 관리
public void SetClearTime()
{
// 클리어 횟수 증가
clearTime++;
// 던전 클리어 횟수와 레벨이 같으면 레벨업
if (stats.Level == clearTime)
LevelUp();
}
// 레벨업
private void LevelUp()
{
// 레벨 증가 및 클리어 횟수 초기화
stats.Level++;
clearTime = 0;
Console.WriteLine($"\n레벨업 하셨습니다!! 현재 레벨 -> Lv. {stats.Level}\n");
Console.WriteLine($"공격력: {stats.Attack:F1} (+0.5)");
Console.WriteLine($"방어력: {stats.Defense} (+ 1)");
stats.Attack += 0.5f;
stats.Defense += 1;
}
레벨과 클리어 횟수가 같으면 레벨업을 하도록 구현하라 해서 간단하게 구현했다.
이렇게 데이터 저장을 뺀 나머지 도전 기능까지 모두 구현을 완료했다~! 😁
코드 리팩토링을 진행하면서 쏟은 시간이 생각보다 많아서 처음부터 코드를 완변하게 짜기란 쉽지는 않겠지만, 그 동안 내가 얼마나 코드를 거지같이 안좋게 짰는지 더더욱 느껴지는 시간이 되었다.
전에 Unity로 작업할 때는 C#과 친하다는 느낌보다는 Unity랑 친한 느낌이었는데 이번에 TextRpg를 작업하면서 C#이랑 좀 친해졌다는 생각이 든다.
지금이 진짜 C# 기초를 잘 다질 수 있는 시간인 거 같아서 너무 좋다. 이런 시간이 필요했었는데 이번 기회에 제대로 개념을 잘 잡아놓고 넘어가야겠다.