왜.... 오셨나요?

부끄러워요

유니티개발 TIL

3주차 2일 TIL_TextRPG심화(전투시스템&몬스터)

와피했는데 2025. 7. 15. 09:38

들어가기 앞서

바로 이전글에서 RPG의 탐험 요소인 던전을 제작했었다 그래서 이번엔 던전의 다른 요소인 전투 요소를 제작하고자 한다


몬스터 구상 단계


우선 몬스터에 요구하는 기능은 다음과 같다

  • 요소 : 이름, 체력, 공격력, 방어력, 골드, 레벨 (+추가할경우 : 경험치)
  • 이미지
  • 데미지를 입는 기능과 데미지를 입히는 기능

몬스터 구현 단계


<몬스터 부모클래스>

  우선 보스가 아닌이상 여러종류의 몬스터라도 하나의 공통적인 변수를 공유하기 때문에 부모클래스를 만들어서 여러 종류의 몬스터를 양산하도록 한다

 public abstract class Monster : Character
 {
     // 몬스터 목록을 관리하는 정적 리스트
     public static List<Monster> monsterlist { get; private set; } = null!;
     public static void Init()
     {
         // 몬스터 목록 초기화
         monsterlist.Clear();
         monsterlist = new List<Monster>();
     }

     // 몬스터 생성자
     protected Monster monster;
     protected Monster(Monster ms) => monster = ms;

     public string Name;
     public string Image;
     public int CurHp;
     public int MaxHp;
     public int ATK;
     public int DFP;
     public int Level;
     public int Gold;
     public bool IsDead =false;
 }

 

  Monster라는 부모클래스를 생성해준다

 

>Character클래스는 플레이어도 같이 쓰는 공통기능인 데미지 관련 기능이 있어서 상속받는다


 

<몬스터 자식클래스>

  총 3가지 자식클래스를 생성하게 되었고 아래는 그 3종류중 하나인 "Minion"클래스다

  public class Minion : Monster
  {
      public Minion(Monster ms) : base(ms)
      {
          // 몬스터 정보 설정
          Name = "미니언";
          MaxHp = 15;
          Level = 2;
          ATK = 5;
          DFP = 1;
          Gold = 15;

          // 이미지 설정
          StringBuilder sb = new StringBuilder();
          sb.AppendLine("####################");
          sb.AppendLine("#                  #");
          sb.AppendLine("#   (작고 소듕한)   #");
          sb.AppendLine("#  (미니언)  #");
          sb.AppendLine("#                  #");
          sb.AppendLine("####################");
          Image = sb.ToString();
      }
  }

 

  Image는 앞으로 전투가 일어날때 배치될 이미지다

 

변경

=>

 public class Minion : Monster
 {
     public Minion()
     {
         // 몬스터 정보 설정
         Name = "미니언";
         MaxHP = 15;
         Level = 2;
         ATK = 5;
         DFP = 1;
         Gold = 15;


         // 초기화
         CurHP = MaxHP;
     }
     public override void PrintMonster(int no, ConsoleColor c)
     {
         Console.ResetColor();// 기본 색 복원
         Console.ForegroundColor = c;   // 번호 색
         Console.Write($"# {no}. ");
         Console.ResetColor();// 기본 색 복원
         Console.WriteLine($"{Name} | HP : {CurHP}/{MaxHP}");
     }
 }

 

변경 사유: 아스키아트, 이전 텍스트가 다 보기애 다른 정보가 눈에 들어오지 않는 문제점과 한번 수정할때 손이 많이 가는 유지보수의 문제점이 있어서 BaseScene에서 사용하는 Print함수처럼 구성함

 

 

- 고민 -

1. 몬스터 이미지를 stringbuilder로 한이유 : 기존 C#의 string은 참조 타입이며 수정을 하게 되면 새로운 string 객체가 생기고 새 객체로 대체되는 형식임. 이와 동시에 이전 객체는 남아있게 되어 GC 대상이 되어서 자주 변경될 문자열이라면 stringbuilder를 사용하는게 GC의 발생빈도를 줄일 수 있다.

 


전투 구상 단계


우선 전투에 요구하는 기능은 다음과 같다

  • 몬스터 출현 마리수 확률 난수 연산
  • 던전과 전투를 한 클래스에서 제작(화면나누기)
  • 전투시스템 FSM(유한상태머신) 패턴 사용<= 수정
  • 플레이어와 몬스터 HP체크
  • 데미지 기능 구현
  • 방어 기능 구현

드래곤퀘스트 전투장면

 

<전투시스템FSM(TurnBased)>

전투는 고전의 느낌이 물씬 나는 턴제방식을 채택하게 되었다 텍스트로 이뤄진 게임이다 보니 실시간 전투는 아무래도 타자연습에서 하는 비내리기 같은 낱만 빠르게 없애기가 될것 같다 

 

우선적으로 구상해야될 것은 상태이다 유한상태머신과 상태패턴이 유사한점이 있다보니 상태를 먼저 정해주고 하는것이 좋다

전투상태 다이어그램


전투 구현 단계


<전체적인 변경점>

결론적으로 FSM는 버렸습니다

몬스터의 종류와 수가 다수일경우 상태들 단위가 정하기 어렵고 구조적으로 복잡해질것 같다는 생각으로 조언을 듣고 생각을 바꾸어 하나의 스크립트안에 턴제구조를 설계하고자 한다

구상단계의 다이어그램 구조와 구현방식도 바꾸어 설계하도록한다

 

조언 =>

더보기

 

FSM의 경우 단점중 클래스가 상태별로 많아지는 경우가 있어서 상당히 복잡해질수있고 재상용에 어려움이 있음
예를들어 보스몹 같은 하나의 유니크한 오브젝트의 경우 다양한 전투패턴을 구현하는데 사용하기 적합할 수 있지만
다수의 몬스터가 존재할때는 하나의 클래스에 단순화된 움직임기능만 구현하는게 개발과정에도 도움이 될듯합니다


그리고 FSM도 있지만 BT(Behavior Tree)같은 FSM의 단점을 보완하기 위한 기법도 있습니다
BT는 노드로 관리 되고 한 스트립트안에서 구현되기애 개발자는 물론 기획자도 보기애 가독성면도 좋고 재사용도 용이합니다

 

결론적으로 클래스가 많아지거나 상태가 많아지는 경우 그거애 따른 개발복잡성이 증가하고 개발시간이 늦어질 수 있다는것입니다

 

  지난날의 과오 : https://github.com/Pass1948/My-AP-Project

    - 해당 링크는 이전 개인 프로젝에서 '마리오RPG'의 타이밍액션 턴제시스템에 감명받아 구현하고자 했던 흔적이다

(같은 실수를 할뻔)

 

GitHub - Pass1948/My-AP-Project

Contribute to Pass1948/My-AP-Project development by creating an account on GitHub.

github.com

 

변경된 다이어그램은 다음과 같다

전투상태 다이어그램

전투시스템 구조적 변경을 통해 던전클래스의 전체적인 구조변경이 이뤄젔다

 

변경점

  - 상태패턴 적용을 통해 상태별 화면 랜더&업데이트 나누기(열거형 목록을 Switch문으로 화면&조작 변경)

// 화면랜더
public override void RenderMenu()
 {
     Console.Clear(); // 콘솔 화면 초기화
     switch(GameManager.Instance.currentState)
     {
         case DungeonState.Idle:
             DungeonRender(); // 던전 씬 랜더링
             break;
         case DungeonState.PlayerTrun:
             PlayerTurnRender(); // 배틀씬 랜더링
             break;
         case DungeonState.EnemyTurn:
             break;
         case DungeonState.EndBattle:
             break;
     }
 }
 
 // 업데이트
   public override void UpdateInput()
  {
      string input = Console.ReadLine();
      int index;
      if (!int.TryParse(input, out index))
      {
          Info("잘못된 입력입니다.");
          Thread.Sleep(800);
          return;
      }
      switch (GameManager.Instance.currentState)
      {
          case DungeonState.Idle:
              DungeonMove(index); // 던전 행동 선택
              break;
          case DungeonState.PlayerTrun:
              PlayerTrunMove(index); // 플레이어 턴 행동 선택
              break;
          case DungeonState.EnemyTurn:
              break;
          case DungeonState.EndBattle:
              break;
      }                
  }

 

 

  - 몬스터 스폰기능 수정(마릿수&종류 올랜덤)

 if (walkCount < dungeonClearCount)
 {
     walkCount++; // 이동 횟수 증가
     if (new Random().NextDouble() < monsValue)  // 몬스터 등장 확률 체크
     {
         GameManager.Instance.currentState = DungeonState.PlayerTrun; // 전투진입 상태변경
         SwapnMonster();
     }
 }
 
 //몬스터 스폰함수
  void SwapnMonster()
 {
     if (battleInitialized) return; // 이미 배틀이 초기화된 경우, 중복 초기화를 방지
     battleInitialized = true;
     currentMonsters.Clear(); // 몬스터 목록 초기화

     var rnd = new Random();
     var types = GameManager.Instance.monsType.Keys.ToList(); // 몬스터 타입 목록 가져오기
     int MonsterCount = new Random().Next(1, 4); // 최소 1, 최대 3 마리 까지 생성하도록 설정

     for (int i = 0; i < MonsterCount; i++)
     {
         var mType = types[rnd.Next(types.Count)];

         switch (mType)
         {
             case MonsterType.Minion:
                 currentMonsters.Add(new Minion());
                 break;
             case MonsterType.SigeMinion:
                 currentMonsters.Add(new SiegeMinion());
                 break;
             case MonsterType.Voidgrub:
                 currentMonsters.Add(new Voidgrub());
                 break;
         }
     }
 }

 

= 몬스터 클래스 자체를 리스트에 넣어서 클래스안에 있는 특정 인수들을 골라서 사용할 수 있게 활용

 


<플레이어턴 기능>

● 화면 출력

● 공격, 방어, 도망 기능 구현

 

    ◆  PlayerTurnRender() : 화면 출력 함수

void PlayerTurnRender()
{
    Print("◎Battle!!◎", ConsoleColor.DarkYellow);
    Print($"\n몬스터가 {currentMonsters.Count}마리가 나타났습니다!\n");
    Print("\n============[몬스터]============");
    for (int i = 0; i < currentMonsters.Count; i++)
    {
        currentMonsters[i].PrintMonster();
    }

    Print("===========[전투선택지]===========");
    Print(1, "공격", ConsoleColor.DarkCyan);
    Print(2, "방어", ConsoleColor.DarkCyan);
    Print(3, "도망", ConsoleColor.DarkCyan);

    Print("\n원하시는 행동을 입력해주세요");
    Console.Write(">>");
}

 

 

    ◆ 방어, 도망 함수

 
void PlayerDefend(int i)
{
    isDF= true; // 방어 상태로 변경
    GameManager.Instance.currentState = DungeonState.EnemyTurn;
    MonstersDeadCheck();
}

void PlayerRun()
{
    if(currentMonsters.Count > 1)
    {
        if (new Random().NextDouble() < 0.3f) // 30% 확률로 도망 성공
        {
            GameManager.Instance.currentState = DungeonState.Idle;
            Info("도망쳤습니다");
            Thread.Sleep(500);
            return;
        }
        else
        {
            Info("도망치지 못했습니다.");
            GameManager.Instance.currentState = DungeonState.EnemyTurn;
            MonstersDeadCheck();
            return;
        }
    }
    else
    {
        Info("도망쳤습니다.");
        GameManager.Instance.currentState = DungeonState.Idle;
        Thread.Sleep(200);
    }
}

 

- 방어 메커니즘 : 자신의 방어력x2 - 타켓데미지 => 양수일경우 : 0데미지, 음수일경우 : 값만큼 데미지

- 도망 메커니즘 : 몬스터의 수가 2마리 이상일경우 : 30% 확률로 도망성공, 1마리 이하일경우 : 100%도망