텍스트 Rpg - C#

C#으로 만드는 텍스트 RPG (1)

게임만드는학생 2025. 1. 25. 14:01

 

먼저 첫번째 미션을 해결했다.

우선 플레이가 가능하게 구현하는데에 초점을 맞췄다. 

앞으로 미션을 해나가면서 개선해나갈 것이다. 

 

미션 1: 기본 구조와 클래스 설계

  • 목표: 클래스와 객체를 사용하여 기본 게임 구조를 설계한다.
  • 미션:
    1. Player 클래스에 이름, 레벨, 체력, 공격력을 프로퍼티로 추가하고, Attack 메서드를 만들어라.
    2. Monster 클래스에 이름, 체력, 공격력을 추가하고, TakeDamage 메서드를 만들어라.
    3. 플레이어와 몬스터가 서로 공격하며 체력을 깎는 간단한 로직을 작성해라.

 

첫번째 미션은 게임의 기본로직을 만드는 것이다. 

 

public class Player
{
    int _hp=100;
    int _exp = 0;

    public string Name { get; set; }
    public int Level { get; set; } = 1;
    public int Exp { get { return _exp; } 
        set 
        {
            while (value >= MaxExp)
            {
                Level++;
                Console.WriteLine($"레벨업 했습니다! 현재 레벨 : {Level}");
                AttackPower *= 2;
                Hp = 100;
                MaxExp *= 2;
                value = (value - MaxExp > 0)? value-MaxExp : 0;
            }
             _exp = value; 
        } 
    }
    public int MaxExp { get; set; } = 10;
    public int Hp { get { return _hp; } set { if (value <= 0) { _hp = 0; Dead(); } else { _hp = value; } } } 
    public int AttackPower { get; set; } = 10;
    
    public Player() { }

    public Player(string Name) : this()
    {
        this.Name = Name;
    }

    public void PrintInfo()
    {
        Console.WriteLine($"이름 : {Name}");
        Console.WriteLine($"레벨 : {Level}");
        Console.WriteLine($"경험치 : {Exp}/{MaxExp}");
        Console.WriteLine($"체력 : {Hp}");
        Console.WriteLine($"공격력 : {AttackPower}\n");
    }

    public void Attack(Monster monster)
    {
        monster.TakeDamage(AttackPower);
    }

    public void TakeDamage(int damage)
    {
        Hp -= damage;
        Console.WriteLine($"몬스터에게 데미지{damage}를 입었습니다! 현재체력 : {Hp}");
    }

    public void GetExp(int exp)
    {
        Exp += exp;
    }

    void Dead()
    {
        
    }
}

 

플레이어 클래스는 필요한 정보들을 프로퍼티로 작성했다. 

hp와 exp는 set함수에서 각 조건을 달아서 hp가 0일때 dead함수호출, exp를 획득해서 레벨업을 할 때를 처리했다. 

maxExp는 레벨업에 필요한 경험치를 나타내는데 value값 즉, 현재 경험치 + 획득경험치가 maxExp를 넘으면 레벨업을 하는데 레벨업 후에도, 그 다음 maxExp보다 높으면 바로 레벨업을 진행하게끔 while로 처리했다. 

 

public class Monster
{
    static int CountId = 1;

    int _hp = 30;

    public int Id { get; } = CountId;
    public string Name { get; set; }
    public int Level { get; set; } = 1;
    public int Exp { get; set; } = 10;
    public int Hp { get { return _hp; } set { if (value <= 0) { _hp = 0;Dead(); } else { _hp = value; } } }
    public int AttackPower { get; set; } = 10;

    public Monster()
    {
        Level = 1*CountId;
        Exp = 10*CountId;
        Hp = 30*CountId;
        AttackPower = 10*CountId/2;
        this.Name = $"몬스터{CountId++}";
    }

    public void Attack(Player player)
    {
        player.TakeDamage(AttackPower);
    }

    public void TakeDamage(int damage)
    {
        Hp -= damage;
        Console.WriteLine($"{Name}에게 데미지{damage}를 입혔습니다. {Name}의 체력 : {Hp}");
    }

    void Dead()
    {
        
    }
}

다음은 몬스터 클래스이다. 플레이어랑 거의 동일하게 변수를 프로퍼티로 구성했고 static변수로 몬스터 아이디를 부여했다. 그리고 간단하게 몬스터 세마리를 생성할 때, 생성할수록 강해지도록 생성자에서 세팅한다. 

 

플레이와 몬스터의 전투는 attack함수와 takedamaged함수로 한다. 

 

전투로직은 gamemanager에 함수로 구현했다. 

 

public enum ResultBattle
{
    PlayerDie,
    MonsterDie,
    RetreatPlayer
}


class GameManager
{
    public static GameManager Instance { get; }=new GameManager();

    public static int MonsterCount { get; } = 3;

    public Player Player { get; private set; }
    public  Monster[] monsters;

    Boss _boss;
    public Boss Boss { get { return _boss; } }

    public void Init()
    {
        Player = new Player();
        monsters = new Monster[MonsterCount];
        for(int i = 0; i < MonsterCount; i++) 
        {
            monsters[i] = new Monster();
        }

        _boss = new Boss();
    }

    public void ResetMonter()
    {
        foreach(Monster monster in monsters)
        {
            monster.Hp = 30 * monster.Id;
        }
    }

    public void PlayStartScene(out Player player)
    {
        /*PrintStringByTick("어둠의 그림자가 세상을 덮쳤다.\n" +
        "당신은 이 세계를 구할 유일한 용사로 선택받았다.\n" +
        "지금부터의 여정은 쉽지 않을 것이다.\n" +
        "당신의 선택과 용기가 모든 것을 바꿀 것이다.\n\n",30);*/

        Console.WriteLine("조작 방법을 알려드리겠습니다.\n\n");

        Console.WriteLine("조작 방법:\n1. 숫자를 입력하여 선택지를 고릅니다.\n" +
            "2. 전투 중에는 '1'을 입력해 공격\n '2'를 입력해 아이템 사용.\n3. 게임 종료는 '0'을 입력하세요.\n");

        Console.WriteLine("용사의 이름은 무엇인가?\n");
        player = new Player(Console.ReadLine());
    }

    public void PrintPlayerInfo(ref Player player)
    {
        Console.WriteLine("캐릭터 상태창입니다.");
        player.PrintInfo();
    }

    public void ExitGame()
    {
        Console.WriteLine("게임을 종료합니다.");
        Environment.Exit(0);
    }

    public void PrintMainMenu()
    {
        Console.WriteLine(UtilTextManager.MainMenuChoice);
    }

    public ResultBattle StartBattle(Player player, Monster monster)
    {
        Console.WriteLine($"{monster.Name}이(가) 당신을 공격합니다!\n");
        int choice;
        while (player.Hp > 0 && monster.Hp > 0)
        {
            Console.WriteLine(UtilTextManager.ChoiceMenuInBattle);
            choice = int.Parse(Console.ReadLine());
            if (choice == 3)
            {
                if (monster is Boss)
                    Console.WriteLine(UtilTextManager.RetreatBoss);
                else
                {
                    Console.WriteLine(UtilTextManager.ExitDungeon);
                    return ResultBattle.RetreatPlayer;
                }
            }
            else if (choice == 2)
            {
                // 인벤토리 보여주기
            }
            else
            {
                Console.WriteLine($"용사{player.Name}가 {monster.Name}을 공격!");
                player.Attack(monster);
                if (monster.Hp <= 0) return ResultBattle.MonsterDie;
                monster.Attack(player);
            }
        }
        return ResultBattle.PlayerDie;
    }

    public void MoveTown()
    {
        Console.WriteLine(UtilTextManager.EnterTown);
    }

    public void MoveDungeon(ref Player player)
    {
        Console.WriteLine(UtilTextManager.EnterDungeon);

        int choice = int.Parse(Console.ReadLine());

        if (choice == 2)// 입구에서 마을로 되돌아가기
        {
            Console.WriteLine(UtilTextManager.ExitDungeonEntrance);
            return;
        }
        PlayDungeon(player);
        
    }

    void PlayDungeon(Player player)
    {
        // 던전 입장
        int count = 0;// 몬스터 등장 횟수
        int choice;
        for (int i = 0; i < GameManager.MonsterCount; i++)
        {
            Console.WriteLine(UtilTextManager.DungeonAppearedMonster[count]);

            ResultBattle result = StartBattle(player, monsters[count]);

            if (result == ResultBattle.RetreatPlayer) return;
            else if (result == ResultBattle.PlayerDie)
            {
                Console.WriteLine(UtilTextManager.PlayerDead); return;
            }
            else
            {
                Console.WriteLine($"{GameManager.Instance.monsters[count].Name}을 물리쳤습니다! " +
                    $"경험치 {GameManager.Instance.monsters[count].Exp}를 획득했습니다.\r\n");
                player.GetExp(GameManager.Instance.monsters[count].Exp);
            }

            Console.WriteLine(UtilTextManager.NextStepChoice);

            choice = int.Parse(Console.ReadLine());

            if (choice == 1)
            {
                Console.WriteLine(UtilTextManager.DungeonContinue[count]);
            }
            else if (choice == 2)
            {
                Console.WriteLine(UtilTextManager.MoveTownAfterBattle);
                return;
            }
            else
            {
                Random random = new Random();

                // 0~99 범위의 난수 생성
                int randomValue = random.Next(0, 100);

                if (randomValue < 45)
                {
                    Console.WriteLine("당신은 주변을 탐색하던 중 희미하게 빛나는 물체를 발견했습니다.\r\n" +
                        "가까이 다가가 확인하니, {아이템 이름}을(를) 발견했습니다!\r\n" +
                        "이 아이템은 당신의 여정에 큰 도움이 될 것입니다.");
                }
                else
                {
                    Console.WriteLine("당신은 주변을 탐색했지만, 특별한 것을 발견하지 못했습니다.\r\n" +
                        "어둠 속에서는 아무것도 보이지 않으며, 조용히 다시 길을 준비합니다.");
                }

                Console.WriteLine(UtilTextManager.DungeonContinue[count]);
            }
            count++;
        }

        // 보스등장
        Console.WriteLine(UtilTextManager.AppearedBoss);

        ResultBattle resultBattle = GameManager.Instance.StartBattle(player, GameManager.Instance.Boss);

        if (resultBattle == ResultBattle.PlayerDie)
        {
            Console.WriteLine(UtilTextManager.PlayerDead); return;
        }
        else
        {
            Console.WriteLine($"{GameManager.Instance.Boss.Name}을 물리쳤습니다! " +
                $"경험치 {GameManager.Instance.Boss.Exp}를 획득했습니다.\r\n");
            player.GetExp(GameManager.Instance.Boss.Exp);

            Console.WriteLine(UtilTextManager.ClearBoss);
        }
    }
}

싱글톤으로 구현했고, 플레이어와 몬스터의 정보를 여기에 넣었다. 또한 각종 화면출력에 관한 함수도 여기에 넣었다. 

 

전투와 던전플레이에 관한 함수를 살펴본다. 

public ResultBattle StartBattle(Player player, Monster monster)
{
    Console.WriteLine($"{monster.Name}이(가) 당신을 공격합니다!\n");
    int choice;
    while (player.Hp > 0 && monster.Hp > 0)
    {
        Console.WriteLine(UtilTextManager.ChoiceMenuInBattle);
        choice = int.Parse(Console.ReadLine());
        if (choice == 3)
        {
            if (monster is Boss)
                Console.WriteLine(UtilTextManager.RetreatBoss);
            else
            {
                Console.WriteLine(UtilTextManager.ExitDungeon);
                return ResultBattle.RetreatPlayer;
            }
        }
        else if (choice == 2)
        {
            // 인벤토리 보여주기
        }
        else
        {
            Console.WriteLine($"용사{player.Name}가 {monster.Name}을 공격!");
            player.Attack(monster);
            if (monster.Hp <= 0) return ResultBattle.MonsterDie;
            monster.Attack(player);
        }
    }
    return ResultBattle.PlayerDie;
}

여기서 enum 은

public enum ResultBattle
{
    PlayerDie,
    MonsterDie,
    RetreatPlayer
}

이렇게 타입을 만들었다. 전투 후 상태를 리턴할 때, 여러가지의 상태를 표현하려면 enum이 필요하다고 판단해 구현했다. 

몬스터나 플레이어가 죽을 때까지 while이 반복되며 선택에 따라서 진행된다. 

전투를 선택하면 서로 한번씩 공격하게 된다. 이 때, attack함수에 상대 오브젝트를 넣어주면 attack에서 상대의 takedamaged함수를 호출하여 데미지를 준다. 

상대에게 데미지를 주고 hp를 줄이고 콘솔로 출력하는 이 과정을 delegate로 개선할 여지가 있어보인다. 

 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace TextRpgCS
{
    internal static class UtilTextManager
    {
        public static string MainMenuChoice { get; } = 
            "무엇을 하시겠습니까?\n1. 마을로 이동\n2. 던전 탐험\n3. 캐릭터 상태 확인\n4. 게임 종료\n";

        public static string EnterTown { get; } = "마을에 입장하셨습니다.";

        public static string EnterDungeon { get; } = "당신은 던전의 입구에 서 있습니다.\n" +
        "차가운 바람이 얼굴을 스치고, 어두운 안개가 던전 깊숙이 흘러갑니다.\n" +
        "입구 근처에는 오래된 경고문이 적혀 있습니다:\n" +
        "모든 준비가 갖춰졌는가? 용기를 낼 때, 비로소 길이 열릴 것이다.\n\n1. 던전에 들어간다.\n2. 마을로 돌아간다.";

        public static string ExitDungeonEntrance { get; } =
            "당신은 잠시 던전 입구에서 머뭇거리다 발길을 돌립니다. " +
            "마을로 돌아가 추가 준비를 하기로 결심합니다.\r\n" +
            "모험은 서두르지 않는 것이 좋다. 당신은 다시 한 번 마음을 가다듬습니다.\n";

        public static string ExitDungeon { get; } =
            "당신은 던전에서 벗어나 마을로 돌아옵니다. 상처를 회복하고, 다음 여정을 준비하기로 합니다.\n";

        public static string[] DungeonAppearedMonster { get; } =
        {
            "당신은 깊은 숨을 들이쉬고 던전으로 발을 내딛습니다. " +
            "어둠 속에서 당신의 발소리가 메아리칩니다.\r\n" +
            "희미한 빛줄기가 벽에 반사되며, 어디선가 들려오는 이상한 소리가 당신을 긴장하게 만듭니다.\r\n" +
            "누군가 있나... 당신의 목소리는 어둠 속으로 사라집니다.\r\n" +
            "잠시 후, 던전 입구 근처에서 들려오는 발소리에 몸을 숨기던 중, 작은 그림자가 빠르게 움직이는 것이 보입니다.\r\n" +
            "갑자기 나타난 몬스터! 덩치가 작지만 움직임이 날렵합니다.\r\n" +
            "이 몬스터를 쓰러뜨려야 더 안으로 들어갈 수 있습니다.\r\n",

            "조금 더 깊은 곳으로 들어가자, 공간이 더 좁아지고 공기가 무거워지는 것을 느낍니다.  \r\n" +
            "어둠 속에서 갑자기 거대한 소리가 울려 퍼지고, 커다란 몬스터가 당신 앞을 막아섭니다!  \r\n" +
            "이 몬스터는 이전보다 더 강력해 보입니다. 조심하세요!\r\n",

            "던전 중간에 도달했을 때, 분위기는 더욱 음산해지고 당신의 손에 땀이 흐릅니다.  \r\n" +
            "갑자기 벽이 움직이기 시작하더니, 엄청난 크기의 몬스터가 모습을 드러냅니다.  \r\n" +
            "던전의 마지막 수호자처럼 보이는 이 몬스터를 쓰러뜨리지 못하면 앞으로 나아갈 수 없습니다.\r\n"
        };

        public static string[] DungeonContinue { get; } =
        {
            "더 어두운 곳으로 발걸음을 옮겼습니다. 벽에 걸린 오래된 횃불이 바람에 흔들리고, 발밑에서 먼지가 날립니다.\r\n" +
            "또 다른 몬스터가 당신 앞에 나타납니다!",

            "던전의 중간 지점에 도달했습니다. 이상한 기운이 감돌며, 멀리서 무거운 발소리가 들립니다.\r\n" +
            "앞에 더 강한 몬스터가 기다리고 있을 것 같습니다!",

            "마침내 던전 깊숙한 곳에 도달했습니다. 커다란 문이 앞을 가로막고 있으며, 문 너머로 강력한 에너지가 느껴집니다.\r\n" +
            "최종 보스와의 전투를 준비하세요!"
        };

        public static string ChoiceMenuInBattle { get; } =
            "무엇을 하시겠습니까?\n1. 공격\n2. 아이템 사용\n3. 도망";

        public static string PlayerDead { get; } = 
            "몬스터가 당신을 공격했습니다. 치명적인 공격을 받았습니다!\r\n" +
                "당신은 쓰러졌습니다...\r\n" +
                "어두운 시야 속에서, 당신은 마지막으로 희미한 빛을 떠올립니다.\r\n" +
                "게임 오버. 마을로 돌아갑니다...\n";

        public static string NextStepChoice { get; } = 
            "다음 행동을 선택하세요:\r\n" +
            "1. 던전 안으로 더 깊이 들어간다.\r\n" +
            "2. 마을로 돌아간다.\r\n" +
            "3. 주변을 탐색한다.";

        public static string MoveTownAfterBattle { get; } =
            "당신은 던전에서 벗어나 마을로 돌아옵니다. 상처를 회복하고, 다음 여정을 준비하기로 합니다.";

        public static string AppearedBoss { get; } =
            "문이 천천히 열리며, 엄청난 크기의 그림자가 드러납니다. " +
            "몬스터가 당신을 주시하며 낮게 으르렁거립니다.\r\n" +
            "이제 최후의 결전을 시작합니다!\r\n";

        public static string ChoiceMenuBoss { get; } =
            "무엇을 하시겠습니까?\r\n" +
            "1. 공격\r\n2. 아이템 사용\r\n3. 전략적으로 후퇴";

        public static string RetreatBoss { get; }=
            "몬스터의 압도적인 위용에 당신은 잠시 망설이다가 뒤로 물러섭니다.\r\n" +
            "그러나 보스는 쉽게 놓아주지 않습니다! 문이 닫히며 당신의 도망길을 막습니다.\r\n" +
            "다시 마음을 가다듬고 싸울 준비를 해야 합니다.";

        public static string AttackBoss { get; } =
            "당신은 칼을 단단히 쥐고 몬스터에게 달려듭니다.\r\n" +
            "보스는 거대한 팔을 휘둘러 공격하지만, 당신은 이를 간신히 피합니다.\r\n" +
            "전투가 치열하게 펼쳐집니다!";

        public static string ClearBoss { get; } =
            "당신은 최종 보스를 물리치고 세계를 구했습니다. 마을 사람들은 당신을 영웅으로 칭송합니다.\r\n" +
            "새로운 모험이 기다리고 있을지도 모릅니다. 다시 도전하시겠습니까?";

        // 문자열 받아서 한글자씩 띄우는 함수 만들기
        static void PrintStringByTick(string s, int interval)
        {
            foreach (char c in s)
            {
                Console.Write(c);
                Thread.Sleep(interval);
            }
        }
    }
}

 

이 코드는 UtilTextmanager인데, 텍스트rpg인 만큼 여러개의 텍스트가 존재할텐데 이것을 여러군데 함수에서 다 적어놓으면 가독성도 떨어지고, 같은 텍스트를 사용한 곳을 전부 유지보수해야한다는 점을 매니저를 구현해 해결하였다. 

마찬가지로 싱글톤으로 구현했고 여러 텍스트를 프로퍼티로 변수화하고 한글자씩 텀을가지고 출력해주는PrintStringByTick 같은 함수를 지원하는 매니저클래스이다. 

 

이를 이용해 

main에서 스위치를 이용해 구현한다.

GameManager.Instance.Init();
GameManager.Instance.PlayStartScene(out player);
while (true)
{
    GameManager.Instance.PrintMainMenu();
    int choiceMenu = int.Parse(Console.ReadLine());
    Console.WriteLine("\n");
    switch (choiceMenu)
    {
        case 1:
            GameManager.Instance.MoveTown();
            break;
        case 2:
            GameManager.Instance.MoveDungeon(ref player);
            GameManager.Instance.ResetMonter();
            break;
        case 3:
            GameManager.Instance.PrintPlayerInfo(ref player);
            break;
        case 4:
            GameManager.Instance.ExitGame();
            break;
    }

}

 

 

미션을 해결해나가면서 델리게이트 등을 이용해 개선해나가야겠다. 

 

그리고 gpt가 알려준 개선점으로 테스트를 진행했다. 

visual studio에서 지원하는 xUnitTest 라는 프로젝트이다. 

 

이것을 같은 솔루션 내에 생성하고 메인 프로젝트를 참조하게 되면 유닛테스트를 진행할 수 있다. 

이것으로 몬스터와 플레이어간의 전투를 테스트했다. 

using Xunit;
using TextRpgCS; // 게임 프로젝트의 네임스페이스

namespace TestProject1
{
    public class PlayerTests
    {
        [Fact]
        public void Attack_ShouldReduceMonsterHealth()
        {
            // Arrange
            var player = new Player { Name = "Hero", Hp = 100, AttackPower = 20 };
            var monster = new Monster { Name = "Goblin", Hp = 50 };

            // Act
            player.Attack(monster);

            // Assert
            Assert.Equal(30, monster.Hp); // 몬스터 체력이 50 - 20 = 30이어야 함.
        }

        [Fact]
        public void TakeDamage_ShouldReducePlayerHealth()
        {
            // Arrange
            var player = new Player { Name = "Hero", Hp = 100, AttackPower = 20 };

            // Act
            player.TakeDamage(30);

            // Assert
            Assert.Equal(70, player.Hp); // 플레이어 체력이 100 - 30 = 70이어야 함.
        }
    }

    public class MonsterTests
    {
        [Fact]
        public void TakeDamage_ShouldReduceMonsterHealth()
        {
            // Arrange
            var monster = new Monster { Name = "Goblin", Hp = 50 };

            // Act
            monster.TakeDamage(20);

            // Assert
            Assert.Equal(30, monster.Hp); // 몬스터 체력이 50 - 20 = 30이어야 함.
        }
    }
}

이 코드는 내가 구현한 것은 아니고 gpt가 구현한 것이다. 

시작프로젝트로 테스트프로젝트를 설정하고 test->run 을 실행하면 테스트 결과를 알 수 있다. 

간단하게 구현해서인지 바로 통과했다. 

 

다음은 아이템과 인벤토리를 구현해야한다.