Unity 본캠프 13/14일차 - C# 팀 프로젝트

한예준·2025년 4월 24일

어제와 오늘은 C# TRPG 프로젝트를 마무리 하는 데에 집중하였다. 이틀 동안 진행한 내용을 정리하고, 돌아보고자 한다.


C# TRPG 팀프로젝트

몬스터 생성자

  • 기존에 몬스터 생성자는 MaxHP, Atk 이 두가지 값만 할당해줬는데, MonGold, MonLevel을 추가로 할당해주었다.
        public Monster(Monsters type)
        {
            MonsterType = type;
            (monMaxHp, monAtk, monGold, monLevel) = type switch
            {
                Monsters.고블린 => (15, 5, 75, 2),
                Monsters.홉고블린 => (20, 7, 140, 4),
                Monsters.오크 => (20, 8, 160, 6),
.
.
.

switch 생성자
전통적인 switch를 통한 생성자는 다음과 같다.

   public Monster(Monsters type)
    {
        MonsterType = type;
        switch (type)
        {
            case Monsters.고블린:
                monMaxHp = 15;
                monAtk     = 5;
                monGold    = 75;
                monLevel   = 2;
                break;
            case Monsters.홉고블린:
                monMaxHp = 20;
                monAtk     = 7;
                monGold    = 140;
                monLevel   = 4;
                break;
            case Monsters.오크:
                monMaxHp = 20;
                monAtk     = 8;
                monGold    = 160;
                monLevel   = 6;
                break;
                .
                .
                .

현재 switch식 문법은 C# 8.0부터 도입된 것으로, switch를 간결하게 쓸 수 있게 해주었다.
한 줄에 여러 값을 그룹으로 할당할 수 있어서, 필드 초기화 코드를 크게 줄여줄 수 있다.
case마다 break없이 =>로 바로 결과를 반환할 수 있다.


경험치 획득 로직

  • 플레이어에 Mp, exp 필드를 추가하였다.
  • 던전을 클리어할 때 monLevel값의 합만큼 player.exp가 상승하도록 처리하였다.
                int xpGain = monsters.Sum(m => m.monLevel);
                player.exp += xpGain;
                Console.WriteLine($"획득 경험치: {xpGain}");
  • 이후 팀원분이 exp값에 따라 플레이어의 레벨이 올라가는 기능을 구현해주셨다.
    • 플레이어의 레벨에 따라 Atk, Def도 상승하도록 처리해주셨다.
  • 던전 골드 보상도 추가해주었다.

퀘스트 조건 확인

  • 몬스터의 체력이 0이 될 때 quest.CheckCondition와 연결하여 퀘스트 조건을 확인하도록 처리하였다.
            if (monHP <= 0)
            {
                GameManager.quest.CheckCondition(Name);
            }

스킬 기능 구현

  • 기존에 BattleSystems에서 구현하던 스킬 기능을 GameCharacter에서 처리하였다.
  • 또한 "몬스터를 선택하는 기능"을 SelectMonster 메서드를 통해 중복코드 없이 처리하도록 만들었다.
  • 스킬을 포함한 모든 공격 로직을 설명해보겠다.

클래스 구조

  • BattleSystems
    • TakeDamage(int, Job) 데미지 연산
  • GameCharacter
    • Attack(Monster) 기본 공격
    • Skill1(Monster) 단일 대상 스킬
    • Skill2(Monster[]) 범위 스킬
  • BattleSystems
    • Start() 전투 진입점
    • PlayerTurn(...) 플레이어 입력/행동 처리
    • EnemyTurn(...) 몬스터 공격 처리

GameCharacter

  • atk Mp critRate critDamage dodgeRate SkillCost SkillName health 등의 필드를 통해 전투 시스템에서 사용되는 수치를 조정한다.
  • Attack, Skill1, Skill2
    • Attack: TakeDamage에서 받은 데미지를 그대로 반환.
    • Skill1: 단일 대상 스킬. Skill1Cost만큼의 Mp를 소모한다.
    • Skill2: 범위 스킬. 살아있는 몬스터 중 랜덤 타겟팅한다. Skill2Cost만큼의 Mp를 소모한다.

TakeDamage

  • var rnd = GameManager.rd;
  • double variation = rnd.NextDouble() * 0.2 + 0.9; // 0.9 ~ 1.1 사이의 데미지배율 생성
  • int dmg = (int)Math.Round(playerAtk * variation); // 데미지 변동률 적용
  • 기본 데미지. 캐릭터 공격력의 0.9~1.1배 사이의 데미지를 반환한다.
    if (rnd.NextDouble()<job.dodgeRate)
       return 0;
  • 회피 로직. 랜덤값(0~1)이 dodgeRate(0.xx)보다 작으면 0을 반환한다.
    bool isCritical = rnd.NextDouble() < job.critRate;
    else if (isCritical)
        dmg = (int)(dmg * job.critDamage);
  • 크리티컬 확률을 체크한다. 랜덤값(0~1)이 critRate(0.xx)보다 작으면 크리티컬이 발동된다. 크리티컬 데미지는 critDamage만큼의 배율을 가진다.
  • 둘에 해당하지 않을 경우 공격력의 0.9배에서 1.1배에 해당하는 데미지를 반환한다.

    monHP = Math.Max(monHP - dmg, 0);
  • 몬스터 체력 감소 로직. 몬스터의 체력이 0보다 작지 않도록 조정한다.
    if (monHP == 0)
           GameManager.quest.CheckCondition(Name);
  • 만약 몬스터의 체력이 0이 되면 퀘스트 조건을 체크한다.

PlayerTurn

  • 플레이어는 공격, 스킬, 탈출 중 하나를 선택.

    • if (sel == 1) // 공격
    • else if (sel == 2) // 스킬
    • else if (sel == 0) // 탈출
    • 행동은 Attack 또는 Skill로 호출.
  • Attack
    int idx = GameManager.SelectMonster(monsters);// 공격할 몬스터 선택
    var target = monsters[idx]; // 선택한 몬스터
    int dmg = player.Attack(target); // 몬스터에게 공격

  • Skill 예시

else if (skill == 2)
{
	if (player.Mp < player.Skill2Cost || player.health < player.Skill2HpCost) // 스킬 사용 시 마나 또는 체력 확인
	{
	Console.WriteLine("MP 또는 체력이 부족합니다.");
	Thread.Sleep(1000);
	continue;
	}
	player.Skill2(monsters); // 스킬 사용
	return true; // 스킬 사용 후 턴 종료
}

정리

  • 연산은 TakeDamage 내부에서 처리한다.
  • 턴이 종료되면 return true를 통해 EnemyTurn으로 이어진다.
  • 몬스터 전멸 시 전투 루프를 탈출하고 승리 보상을 받는다. 이후 다음 층으로 넘어간다.
  • 이러한 구조를 통해 데미지 로직과 캐릭터별 행동 로직을 각각 관리하여 새로운 직업을 생성하고 스킬의 밸런스를 조정하는 등의 확장 및 유지보수가 용이했다.

직업 추가 및 스킬 추가

  • 위 작업들을 통해 직업 추가를 굉장히 용이하게 했다.
  • 예를 들어, 궁수 직업을 추가하자면,
    • Job 클래스에 Archer 클래스를 상속시킨다.
    • Attack, Skill1, Skill2 메서드는 가상 메서드로 만들어 하위 클래스에서 이 동작을 자유롭게 override할 수 있게 열어놓았다.
    • Archer클래스는 Job이 갖고 있는 모든 필드와 메서드를 물려받는다.
    • 생성자에서 궁수 고유의 스탯을 설정한다.
    • Attack, Skill1, Skill2 메서드를 override 키워드로 재정의해 궁수만의 공격/스킬 로직을 구현한다.
  • 기존에 있던 직업인 전사, 마법사에 궁수, 광전사 직업을 추가하였다.
  • 특히 광전사 직업은 hp를 소모하여 스킬을 사용하고, 던전 클리어 시 mp대신 hp를 얻도록 구현하였다.
//광전사의 스킬 코스트 ex) Skill1
        public override int Skill1(Monster target)
        {
            health -= Skill1HpCost;
            int dmg = target.TakeDamage(atk * 2, this);
            Mathod.FontColorOnce($"{Skill1Name} 사용으로 HP{Skill1HpCost} 소모", ColorCode.Red);
            Thread.Sleep(1000);
            return dmg;
        }
//광전사와 다른 직업의 전투 보상차이
                Console.WriteLine("전투에서 승리했습니다!");
                if (player.chad == "광전사")
                {
                    player.health += 5 * monsters.Length;
                    Mathod.FontColorOnce($"체력이 {5 * monsters.Length}만큼 회복됩니다.\n", ColorCode.Red);
                }
                else
                {
                    player.Mp += 10;
                    Mathod.FontColorOnce("마나가 10만큼 회복됩니다.\n", ColorCode.Blue);
                }

치명타/회피 기능 추가

  • 치명타/회피 기능을 추가하였다. 먼저 캐릭터 직업 클래스에 필드를 추가하였다.
    public class Job
    {        
    .
    .
    .
    	public double critRate;
    	public double critDamage;
        public double dodgeRate;
    .    
    .            
    .
	}
  • 이후 생성자에서 직업별 수치를 설정하였다.
// 전사 클래스 예시
    public class Warrior : Job
	{
    .
    .
    .
            critRate = 0.1; // 치명타 확률
            critDamage = 1.5; // 치명타 배율
            dodgeRate = 0.1; // 회피율
    .
    .
    .
  • 이 수치를 TakeDamage 메서드에서 받아 치명타/회피 로직을 작성하였다.
        public int TakeDamage(int playerAtk, Job job)
        {
            var rnd = GameManager.rd;
            double variation = rnd.NextDouble() * 0.2 + 0.9;  // 0.9 ~ 1.1 사이의 데미지 랜덤 배율 생성
            int dmg = (int)Math.Round(playerAtk * variation);  // 데미지 변동률 적용
            bool isCritical = rnd.NextDouble() < job.critRate; // 크리티컬 확률 체크

            if (rnd.NextDouble()<job.dodgeRate) // 회피일 때
            {
                Mathod.FontColorOnce($"→ {Name}이(가) 공격을 회피했습니다!\n", ColorCode.Cyan);
                Thread.Sleep(1000);
                return 0;
            }
            else if (isCritical) // 크리티컬일 경우
            {
                dmg = (int)(dmg * job.critDamage);
                Mathod.FontColorOnce($"→ {Name}에게 ({dmg}) 크리티컬 데미지!", ColorCode.Yellow);
            }
            else // 일반 데미지
            {
                Mathod.FontColorOnce($"→ {Name}에게 {dmg} 데미지!" , ColorCode.Red);
            }
            monHP = Math.Max(monHP - dmg, 0); // 몬스터 체력 감소
            Mathod.FontColorOnce($"남은 체력: {monHP}/{monMaxHp}\n", ColorCode.Red);
            Thread.Sleep(1000);

            if (monHP == 0)
                GameManager.quest.CheckCondition(Name); // 퀘스트 조건 체크
            return dmg; // 데미지 반환
        }
  • 이후 Attack 메서드는 특별히 수정하지 않아도 기능은 의도대로 구현된다.

버그 수정

  • 방어력이 높은 경우 방어력으로 인해 데미지가 0 미만이 되는 경우가 있었는데, 이를 해결하였다.
                    int dmg = m.monAtk - player.def / 2;
                    if (dmg < 0)
                        dmg = 0;
  • 던전 층수가 저장데이터에 저장되지 않았다. DungeonFloor 변수를 GameManager가 아닌 player에 넣어주었다. 이후 player 저장 데이터에 floor를 추가하였다. 마나, 경험치도 같이 처리해주었다.

  • 광전사는 체력을 코스트로 하는데 Mp가 없어도 스킬을 쓰지 못하는 오류가 있었다. 광전사의 스킬 코스트를 SkillHpCost로 따로 지정해주고 출력로직을 수정해주었다.

//기존
if (player.Mp < player.Skill1Cost)
{
	Console.WriteLine("MP가 부족합니다.");
}
//현재
if (player.Mp < player.Skill1Cost || player.health < player.Skill1HpCost)
{
    Console.WriteLine("MP 또는 체력이 부족합니다.");
}
  • 이후 유저 편의를 위해 출력 텍스트들을 교정해주었다.

회고 및 느낀점

  • 처음에 "이런 기능을 이렇게 구현하면 될 것 같다."는 큰 틀은 머릿속에 있었지만, 실제로 어떤 C# 문법을 사용하여 어떻게 짜야 할지는 감이 잘 잡히지 않았다.
  • 이에 이어 오늘 C# 시험을 보기도 했는데, C# 문법 공부를 더 열심히 해야할 것 같다.
  • 초기에는 모든 공격 로직을 한 곳에 몰아넣었었다. 물론 코드를 작성할 때는 직관적이라 편리했지만, 수정하거나 확장할 때마다 해당 메서드를 들여다보고, 이 메서드를 사용하는 코드도 건드려야 해서 굉장히 번거로웠다.
  • 기능별로 책임을 분리하고, 한 군데에 집중된 코드를 여러 단위로 쪼개니 가독성이 훨씬 좋아졌고, 버그 수정이나 새로운 기능 추가도 훨씬 수월해졌다.
  • 어느 정도 완성한 이후 플레이해 보니, 콘솔에 출력되는 텍스트가 플레이어에게 불친절할 때가 많았다.
  • 플레이어 입장에서 출력 문구를 교정하다 보니, 사용자 시점에서 생각하는 것이 얼마나 중요한지 깨달았다.
  • 팀원분의 조언으로 코딩 컨벤션과 공통 구조를 맞추는 연습을 했다.
  • 클래스/메서드 네이밍과 구조, 흐름 등을 배우니 이전에 잘못 작성한 코드들도 쉽게 교정할 수 있었다.
  • 덕분에 협업 환경에서 내가 짠 코드를 동료가 쉽게 이해할 수 있도록 하는 것이 굉장히 중요한 과정이라는 것을 깨닫게 되었다.

0개의 댓글