| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 독서
- c#
- multi-thread
- Data Structure
- System Programming
- Online Judge
- Network Programming
- git
- Unity
- PS
- Toy Project
- BOJ
- C++
- Today
- Total
I'm FanJae.
[My Turn Based Console RPG] Day 3. 배틀 시스템 구현 및 기능 단위 클래스 분리 본문
[My Turn Based Console RPG] Day 3. 배틀 시스템 구현 및 기능 단위 클래스 분리
FanJae 2026. 5. 17. 22:221. 시작에 앞서
- 기본적인 배틀 시스템을 구현하기에 앞서서.. 캐릭터를 어떻게 넣을까 이런 저런 방법을 하다가 선택했던 것은 결국 직접 찍어보자는 생각을 하게 되었다. (당시에 내가 무슨 생각으로 도트를 찍으려고 했는지 전혀 모르겠다. 잠깐 미쳤던것 같다.)


- 근데 기대 이상으로 캐릭터가 잘 뽑혔다. 그래서 한번 몬스터도 직접 그려보자고 생각하게 되었는데.. 그 결과는.....

- 네.. 그렇게 되었습니다. 놀랍게도 같은 사람이 그린거다.
2. 시작에 앞서
- 위와 같이 그려놓고 기본 전투 컨셉을 잡아 나갔다.
- 배틀 화면은 기존에 보이던 창과 별개로 새로운 창을 그려서 처리할 목적으로 만들었다. 배틀 UI 창은 직접 구상한게 아닌, 유튜브에 검색하면 나오는 C# 콘솔 RPG 포트폴리오에 있는 내용을 참고하여 만들었다.
① 배틀 시스템

- 처음에 구현했던 모양새가 생각보다 괜찮게 나와서 이 형태 그대로 가져갔다.
- 대신 COMMAND 창과 로그 창을 분리하도록 처리하여 아래와 같은 형태로 분리했다.

② 초기 배틀 시스템 처리
internal class BattleSystem // 기본 배틀 시스템
{
private Player player;
private Monster monster;
public BattleSystem(Player player, Monster monster) // 기본적으로 배틀은 1vs1. 추후 늘어날수도?
{
this.player = player;
this.monster = monster;
}
public void Start() // 배틀 시작 메서드
{
while(player.Hp > 0 && monster.Hp > 0) // 한명이라도 체력이 0으로 내려가면 종료.
{
player.Action(monster); // 선공은 기본적으로 플레이어 먼저
if (monster.Hp == 0)
{
break;
}
monster.Action(player); // 몬스터 후공
}
if (player.Hp == 0) Console.WriteLine($"{monster.Name}과의 전투에서 패배하였습니다.");
else if (monster.Hp == 0) Console.WriteLine($"{monster.Name}과의 전투에서 승리하였습니다.");
else if (player.Hp == 0 && monster.Hp == 0) Console.WriteLine($"무승부로 싸움이 종료되었습니다.");
}
}
- 초기 BattleSystem에서는 간단한 형태만 갖춰 둔 상태였다. 하지만 이후 BattleSystem, View, Screen 형태로 나누면서 복잡해졌지만, 클래스의 책임 단위를 나누면서 특정 문제가 발생 했을 때 체크하는 것이나 기능 추가는 분명히 간편해졌다.
③ BattleSystem 배틀 진행 흐름 제어 클래스
namespace MyConsoleMapleRPG.UI.Screens
{
// 전투 화면의 입력 루프와 전투 결과 흐름 담당.
// 전투 계산은 BattleSystem, 출력은 BattleUI에 위임
// 키 입력에 따라 공격/스킬/도망을 실행한 뒤 결과에 따라 전투를 종료한다.
internal class BattleScreen
{
private readonly BattleSystem battleSystem;
private readonly BattleUI battleUI;
private readonly Player player;
private string battleLog;
public BattleScreen(Player player, Monster monster) // 배틀 시작
{
battleSystem = new BattleSystem(player, monster);
battleUI = new BattleUI(player, monster);
this.player = player;
battleLog = $"{monster.Name}가/(이) 나타났습니다!";
}
public BattleResult Show()
{
battleUI.DrawBaseScreen(); // 배틀 UI 기본 틀
battleUI.DrawDynamic(battleLog); // 배틀 도중에 그리는 부분
while (true)
{
ConsoleKey key = Console.ReadKey(true).Key;
BattleResult result = BattleResult.None;
switch (key)
{
case ConsoleKey.Z: // 기본 공격
result = battleSystem.PlayerAttack(out battleLog);
break;
case ConsoleKey.X: // 스킬 공격
int? skillIndex = SelectSkill();
if (skillIndex == null)
{
battleLog = "스킬 선택을 취소했습니다.";
result = BattleResult.InvalidAction;
break;
}
result = battleSystem.PlayerSkill(skillIndex.Value, out battleLog);
break;
case ConsoleKey.C: // 도망
result = battleSystem.Escape(out battleLog);
break;
case ConsoleKey.Escape: // 테스트용 코드
return BattleResult.Escape;
default:
continue;
}
battleUI.DrawDynamic(battleLog);
ShowPendingBattleLogs(); // 배틀에서 출력해야할 로그 정보 찍음
if (result == BattleResult.InvalidAction) // 유효하지 않은 동작
continue;
if (result == BattleResult.None) // 계속 진행
continue;
if (result == BattleResult.Victory) // 배틀 승리
{
ShowRewardLogs(); // 보상 로그 출력
return BattleResult.Victory;
}
Console.ReadKey(true);
return result;
}
}
private int? SelectSkill() // 스킬 선택
{
int selectedIndex = 0;
while (true)
{
battleUI.DrawSkillMenu(selectedIndex);
ConsoleKey key = Console.ReadKey(true).Key;
switch (key)
{
case ConsoleKey.UpArrow:
selectedIndex--;
if (selectedIndex < 0)
selectedIndex = player.Skills.Count - 1;
break;
case ConsoleKey.DownArrow:
selectedIndex++;
if (selectedIndex >= player.Skills.Count) selectedIndex = 0;
break;
case ConsoleKey.Z:
case ConsoleKey.Enter:
battleUI.DrawCommandMenu();
battleUI.DrawDynamic(battleLog);
return selectedIndex;
case ConsoleKey.X:
case ConsoleKey.Escape:
battleUI.DrawBaseScreen();
battleUI.DrawDynamic(battleLog);
return null;
}
}
}
private void ShowRewardLogs() // 보상 로그 출력
{
foreach (string rewardLog in battleSystem.RewardLogs)
{
Console.ReadKey(true);
battleLog = rewardLog;
battleUI.DrawDynamic(battleLog);
}
Console.ReadKey(true);
}
private void ShowPendingBattleLogs() // 배틀 관련 남아있는 로그 출력
{
while (battleSystem.TryDequeueLog(out string log))
{
Console.ReadKey(true);
battleLog = log;
battleUI.DrawDynamic(battleLog);
}
}
}
}
- 배틀의 흐름 제어와 관련한 부분은 이곳에서 처리하게 만들었다. 입력과 흐름 제어와 관련한 부분만 남기기 위해 노력하였지만, 마지막에 보상 로그나 배틀 관련 로그를 추가하면서 부득이하게 배틀 시스템에 이를 추가하였는데, 리팩토링 한다면 이는 분리가 필요해보인다.
④ BattleUI (View) 클래스
using MyConsoleMapleRPG.Character;
using MyConsoleMapleRPG.Character.Monster;
using MyConsoleMapleRPG.UI.Rendering;
namespace MyConsoleMapleRPG.UI.Views
{
// 전투 화면의 출력 전용 UI 클래스.
// 기본 레이아웃, 캐릭터 이미지, HP/MP 상태바, 명령어, 전투 로그를 표현
internal class BattleUI
{
private const int WindowX = 2;
private const int WindowY = 1;
private const int WindowWidth = 90;
private const int WindowHeight = 32;
private const int PlayerImageX = 20;
private const int PlayerImageY = 3;
private const int MonsterImageX = 62;
private const int MonsterImageY = 3;
private const int ImageWidth = 16;
private const int ImageHeight = 16;
private const int PlayerStatusX = 8;
private const int PlayerHpY = 17;
private const int PlayerMpY = 18;
private const int MonsterStatusX = 50;
private const int MonsterHpY = 17;
private readonly Player player;
private readonly Monster monster;
public BattleUI(Player player, Monster monster) // 배틀 UI
{
this.player = player;
this.monster = monster;
}
public void DrawBaseScreen()
{
Console.Clear();
DrawBase();
DrawCharacters();
DrawMenu();
}
public void DrawDynamic(string battleLog)
{
DrawStatus();
DrawLog(battleLog);
}
private void DrawBase() // 기본 틀(화면에서 보이는 플레이어, 커맨드, 로그창 그림)
{
ConsoleRenderer.DrawWindow(WindowX, WindowY, WindowWidth, WindowHeight, $"BATTLE - {monster.Name}");
Console.SetCursorPosition(6, 3);
Console.Write("PLAYER");
Console.SetCursorPosition(68, 3);
Console.Write("MONSTER");
ConsoleRenderer.DrawWindow(5, 22, 84, 4, "COMMAND");
ConsoleRenderer.DrawWindow(5, 27, 84, 4, "LOG");
}
private void DrawCharacters() // 캐릭 정보 그림
{
AsciiImageRenderer.Draw(player.ImagePath, PlayerImageX, PlayerImageY, ImageWidth, ImageHeight);
AsciiImageRenderer.Draw(monster.ImagePath, MonsterImageX, MonsterImageY, ImageWidth, ImageHeight);
Console.Write("\x1b[0m");
Console.ResetColor();
}
private void DrawStatus() // 상태 정보 그림
{
ConsoleRenderer.DrawBar(PlayerStatusX, PlayerHpY, "PLAYER HP", player.Hp, player.MaxHp, 10, ConsoleColor.Red);
ConsoleRenderer.DrawBar(PlayerStatusX, PlayerMpY, "PLAYER MP", player.Mp, player.MaxMp, 10, ConsoleColor.Blue);
ConsoleRenderer.DrawBar(MonsterStatusX, MonsterHpY, "ENEMY HP", monster.Hp, monster.MaxHp, 10, ConsoleColor.Red);
Console.SetCursorPosition(8, 20);
Console.Write($"ATK : {player.Attack} DEF : {player.Defense}");
Console.SetCursorPosition(55, 20);
Console.Write($"ATK : {monster.Attack} DEF : {monster.Defense}");
DrawStatusEffects(); // 상태 이상 관련 처리(정상, 중독 여부 등의 현재 상태이상 정보 확인)
}
private void DrawMenu() // 메뉴 정보 그림
{
Console.SetCursorPosition(10, 24);
Console.Write("[Z] 공격");
Console.SetCursorPosition(25, 24);
Console.Write("[X] 스킬");
Console.SetCursorPosition(40, 24);
Console.Write("[C] 도망");
}
private void DrawLog(string battleLog) // 로그 정보
{
int x = 10;
int y = 29;
int width = 75;
Console.SetCursorPosition(x, y);
Console.Write(new string(' ', width));
Console.SetCursorPosition(x, y);
Console.Write(battleLog);
}
public void DrawSkillMenu(int selectedIndex) // 스킬 메뉴 관련 정보
{
ConsoleRenderer.DrawWindow(5, 22, 84, 4, "SKILL");
for (int i = 0; i < player.Skills.Count; i++)
{
Console.SetCursorPosition(10 + i * 25, 24);
string cursor = i == selectedIndex ? "▶ " : " ";
Skill skill = player.Skills[i];
Console.Write($"{cursor}{skill.Name} MP:{skill.MpCost}");
}
}
public void DrawCommandMenu() // 커맨드 메뉴 관련 정보
{
ConsoleRenderer.DrawWindow(5, 22, 84, 4, "COMMAND");
DrawMenu();
}
private void DrawStatusEffects() // 상태이상 정보 관련 그림
{
Console.SetCursorPosition(8, 21);
Console.Write(new string(' ', 30));
Console.SetCursorPosition(8, 21);
if (player.StatusEffects.Count == 0)
{
Console.Write("상태 : 정상");
return;
}
string statusText = string.Join(", ", player.StatusEffects.Select(effect => effect.Name));
Console.Write($"상태 : {statusText}");
}
}
}
- 배틀과 관련한 메뉴 정보를 출력하는 클래스다. 배틀에서 나오는 기본 틀, 기본 공격 관련 메뉴, 스킬 메뉴 관련 정보, 및 상태 이상 관련된 정보를 출력한다.
⑤ BattleSystem
public BattleResult PlayerAttack(out string log) // 플레이어 기본 공격
{
BattleResult result = ProcessPlayerStatusEffects(out log); // 상태이상 처리
if (result != BattleResult.None)
return result;
int damage = player.Action(monster);
log = $"{player.Name}의 공격! {monster.Name}에게 {damage}의 피해를 입혔습니다.";
if (monster.TryApplyCounterEffect(player, out string counterMessage)) // 플레이어가 공격할 때 적용되는 상태이상이 있는 경우
AddLog(counterMessage);
return PlayerAction(log, out log);
}
- BattleSystem은 실질적으로, 각 흐름 제어에 따라서 주요 로직을 실행하도록 바꾸었다.
- 플레이어가 공격 하기에 앞서, 상태 이상을 확인하는데 현재는 중독만 존재한다. 추후 기절과 같은 상태이상이 추가 될 것을 고려하여 플레이어의 공격이 일어나기 전에 처리하는 형태로 구현했다.
3. 몬스터 인카운트 정보

- 화면상 보이는 초록색 부분이 풀숲과 같은 개념으로 처리했다.
- Day 2일차에서 다뤘던 맵 데이터 정보와 더불어 다음과 같은 형태로 처리한다.
① 인카운트 전투 처리
// MapScreen.cs
private void TryHandleEncounter(char target) // 인카운터 배틀 처리
{
if (target != ',')
return;
Monster? monster = encounterService.TryEncounter(currentMap);
if (monster != null)
MonsterEncountered?.Invoke(monster);
}
- 맵에서는 인카운터 배틀이 가능하도록 처리했다. 해당 타일에 닿을때 마다 25% 확률로 몬스터를 만나게 처리했다.
using MyConsoleMapleRPG.Character.Monster;
using MyConsoleMapleRPG.Map;
namespace MyConsoleMapleRPG.Systems
{
// 맵 이동 중 랜덤 몬스터 조우 여부 판단.
// 맵 데이터에 등장 가능한 몬스터 목록에 대한 실제 확률 계산 담당
internal class EncounterService
{
private readonly Random random = new Random();
private const int EncounterRate = 25;
public Monster? TryEncounter(MapData mapData) // 인카운트 배틀 시도
{
if (mapData.EncounterMonsters.Count == 0) return null; // 몬스터 없으면 해당 없음
if (random.Next(100) >= EncounterRate) return null;
int index = random.Next(mapData.EncounterMonsters.Count); // 몬스터를 만날 상황이면 랜덤하게 뽑아서 등장 시킴
MonsterData data = mapData.EncounterMonsters[index];
return MonsterFactory.Create(data); // 싸울 몬스터 생성
}
}
}
- 이와 같이 처리하였다. 맵 이동 중 랜덤 몬스터의 조우 여부를 판단하고, 인카운트 배틀에 대한 시도를 진행한다. 해당 필드(맵)에 몬스터 정보가 추가되어 있지 않은 경우 예외하였다.
// Maps.cs
Field1.EncounterMonsters.Add(MonsterDatabase.Get(1));
Field1.EncounterMonsters.Add(MonsterDatabase.Get(2));
// monsterdatabase.cs 일부
private static readonly Dictionary<int, MonsterData> monsters = new()
{
{
1,
new MonsterData("초록 달팽이", 15, 0, 5, 0, 10, 10, "Assets/image/green_snail.png",
new List<DropItem>
{
new DropItem(1001, 1, 0.1),
new DropItem(2001, 1, 0.01),
new DropItem(3001, 1, 0.3)
}
}
}
- Dictionary 형태로 담겨있는 몬스터 정보를 얻어와서 실제 싸움을 진행할 몬스터를 생성해주는 방식으로 처리한다. 이때, 몬스터 생성의 책임은 MonsterFactory가 진행한다.
4. 클래스 책임 분리 (1차)
- 이전 까지는 하나의 클래스가 모든 기능을 책임지게 처리하였다. 예를들어, BattleSystem이라고 하면, BattleSystem이 배틀에 대한 흐름, 배틀 UI와 같은 화면 상에 보이는 내용까지 모두 한 곳에서 처리했다. 이를 분리할 필요가 있다고 생각했고, 디렉토리 구조를 기능 단위로 분리해보기로 했다.
- Character
- Enums
- Items
- Maps
- Systems
- UI
- Input
- Rendering
- Settings
- Views
- 초기 분리는 대충 위와 같은 형태로 생각하였다.
- 책임 단위에 따라서 클래스를 나눠놓고 그 규칙에 근거해 움직이면, 추후 추가될 기능을 구현할 때 의미가 있는 작업이 될 것이라고 생각했다. 하루 안에 모든 클래스를 분리하지는 못했지만, 다음 날(16일)에 이어서 분리 작업을 진행했다.
'Projects > My Turn Based Console RPG' 카테고리의 다른 글
| [My Turn Based Console RPG] Day 5. 프로젝트 최종 정리 (0) | 2026.05.19 |
|---|---|
| [My Turn Based Console RPG] Day 5. 저장 / 불러오기 기능 추가 (0) | 2026.05.19 |
| [My Turn Based Console RPG] Day 4. 아이템, 상점, 인벤토리 기능 구현 (0) | 2026.05.18 |
| [My Turn Based Console RPG] Day 2. 직업 선택 페이지 & 기본 UI & 맵 관련 기능 구현 (0) | 2026.05.16 |
| [My Turn Based Console RPG] Day 1. 기본 구상 및 메인 화면 구현 (0) | 2026.05.14 |