I'm FanJae.

[My Turn Based Console RPG] Day 5. 프로젝트 최종 정리 본문

Projects/My Turn Based Console RPG

[My Turn Based Console RPG] Day 5. 프로젝트 최종 정리

FanJae 2026. 5. 19. 23:55

1. 프로젝트 개요

- 콘솔 환경에서 동작하는 턴제 RPG

- 로컬 환경에서 플레이하는 턴제 RPG 게임을 구현하였다.

- 개발 인원 : 1인

- 개발 기간 : 05.13~05.17

- 실행 환경 : .NET 8.0 (런타임 설치 필요)


2. 프로젝트 정리

- 이번 콘솔 프로젝트에서 내가 가장 목표했던 것은 게임에서 활용되는 여러 시스템을 고려한 구현이었다. 즉, 게임 자체의 볼륨을 높이는 것보다 게임에서 활용되는 여러 시스템을 만들어 보는 것에 초점을 맞췄다.

 

① UI를 표현할 때 어떤 느낌으로 표현할 것인가?

② 아이템 이라는 것은 어떤 정보를 가져야 할까?

캐릭터라는 것에서 Playerable한(즉, 플레이 할 수 있는) 캐릭터와 몬스터를 어떻게 분리할 것인가?

인벤토리에서는 실제 아이템 객체를 가지고 있는 것이 이상적일까? 아니면 이를 분리하는 것이 이상적인가? 또, 많은 게임을 보면 인벤토리에서 개수 정보를 가지는 것과 그렇지 않은 것을 어떻게 분리할 것인가?

저장 / 로드 상황에서 고려해야 할 점은 무엇일까?

각 클래스는 어떤 정보를 알아야 하고, 어떤 정보는 몰라야 하는가? 또, 클래스의 책임 단위는 어디까지 나눠야 할까?

※ 이전에 작업했던 코드와는 다르게 이번에는 최대한 분리하여 작업하기 위해 노력 했고, 꽤 많은 부분에서 책임을 나눴다.


3. 프로젝트 전체 구조 설명

- 각 프로젝트는 기능 단위와 역할에 따라 폴더를 분리하였다.

- 예를 들어, 플레이어와 몬스터와 같은 게임 내 객체는 Character로 분리하였다.

- 실제 게임 진행과 관련된 로직은 System 폴더, 콘솔 출력이나 화면 구성을 담당하는 클래스는 UI와 같은 식으로 분리 작업을 진행하였다.

- 즉, 전체적인 설계의 목적은 게임 로직과 출력 로직이 한 클래스에 섞이지 않도록 하는게 목적이다.


① Character

- Character 폴더는 전투에 참여하는 객체들을 관리한다.

- 공통 부모 클래스인 Character를 기준으로, Player와 Monster가 상속 구조를 이루고, Player는 Warrior와 Mage로 직업을 나누었다.

- 전투에 참여하는 객체(Player, Monster)에 대한 여러 가지 정보(상태이상, 착용 장비, 스킬)등의 정보도 이 폴더에서 관리하고 있다.

 

Character 폴더의 구성 요소

Character
├── Character.cs - 플레이어와 몬스터의 공통 능력치 / 전투 기능 정의
├── Player.cs - 플레이어의 레벨, 경험치, 골드, 인벤토리, 장비, 스킬 관리
│ ├── Warrior.cs - 전사 직업의 초기 능력치, 성장치, 스킬 정의
│ └── Mage.cs - 마법사 직업의 초기 능력치, 성장치, 스킬 정의
├── Monster
│ ├── Monster.cs - 실제 전투에 사용되는 몬스터 객체
│ ├── MonsterData.cs - 몬스터 원본 데이터 구조 정의
│ ├── MonsterDatabase.cs - 몬스터 데이터 목록 관리 및 조회
│ └── MonsterFactory.cs - MonsterData를 Monster 객체로 생성
├── StatusEffects
│ ├── StatusEffect.cs - 상태이상의 공통 구조 정의
│ └── PoisonEffect.cs - 독 상태 이상 효과 구현
├── Equipment.cs - 플레이어의 무기 / 방어구 장착 및 장비 보너스 관리
└── Skill.cs - 스킬 정보, MP 소모, 데미지 처리 관리

② Enums

- Enums 폴더는 게임에서 사용하는 상태값과 타입값을 enum으로 관리한다.

- 문자열이나 숫자를 직접 사용하는 대신 enum를 사용해 코드 의미를 명확하게 했다.

 

Enums 폴더의 구성 요소

Enums
├── BattleResult.cs - 전투 결과
├── CountEffectType.cs - 상태 이상 종류
├── EquipmentType.cs - 장비 종류
├── ItemType.cs - 아이템 종류
├── JobType.cs - 작업 종류
├── MainMenuResult.cs - 메인 메뉴 선택 결과
├── ShopMode.cs - 상점 모드
└── SkillType.cs - 스킬 종류

③ Items

- Items 폴더는 아이템과 인벤토리 관련 기능을 관리한다.

- 아이템의 원본 데이터는 ItemData와 ItemDatabase가 관리한다.

- 플레이어가 보유한 아이템은 Inventory와 InvetorySlot 형태로 나눠 관리한다.

- 소비 아이템 효과는 IItemEffect 인터페이스 형태로 나눠서, 아이템 효과를 확장할 수 있게 분리하였다.

Items 폴더의 구성 요소

Items
├── DropItem.cs - 몬스터가 드랍할 아이템 ID, 수량, 드랍 확률 관리
├── EquipmentStats.cs - 장비 아이템의 장비 타입, 착용 가능 직업, 공격력 / 방어 보너스 관리
├── HealEffect.cs - HP 회복 아이템 효과 구현
├── IItemEffect.cs - 소비 아이템 효과의 공통 인터 페이스
├── Inventory.cs - 플레이어가 가진 아이템 슬롯 목록 관리
├── InventorySlot.cs - 인벤토리 한 칸의 아이템 ID, 수량, 장착 여부 관리
├── ItemData.cs - 아이템의 원본 데이터 정의
├── ItemDatabase.cs - 아이템 원본 데이터를 Dictionary로 관리
└── RestoreMpEffect.cs - MP 회복 아이템 구현

④ Map

- Map 폴더는 게임 내 맵 이동과 포탈 이동 기능을 관리하는 영역이다.

- MapData는 하나의 맵 정보 저장, MapPortal은 연결 정보 저장을 한다.

- Maps 클래스에서는 게임 전체 맵 데이터를 생성하고 관리하도록 구성했다.

Maps 폴더의 구성 요소

Map
├── MapData.cs - 맵 이름, 설명, 포탈 목록 등 맵 정보 관리
├── MapPortal.cs - 연결된 맵과 이동 위치 정보 관리
└── Maps.cs - 전체 맵 데이터 생성 및 관

⑤ Save

- Save 폴더는 게임 저장 및 불러오기에 필요한 데이터 구조를 관리한다.

- SaveData 클래스는 게임 진행에 필요한 주요 데이터를 저장하고, 현재 플레이 상태를 하나의 객체로 정리하여 저장할 수 있게 구성했다.

- InventorySaveData 클래스는 인벤토리 저장 전용 데이터 구조다. 인벤토리 슬롯의 아이템 ID, 수량, 장착 여부 등을 저장하며, 불러오기 시 Inventory 복원에 사용한다.

- Save 폴더의 클래스들은 저장 가능한 데이터 구조를 정의하는 역할만 담당한다.

Save 폴더의 구성 요소

Save
├── InventorySaveData.cs - 인벤토리 저장용 데이터 구조 관리
└── SaveData.cs - 플레이어 상태 및 게임 진행 정보 저장

⑥ Shops

- Shop 폴더는 상점에서 판매되는 상품 데이터를 관리한다.

- 현재는 ShopItem 클래스만 포함되어 있다.

Shops 폴더의 구성 요소

Shops
└── ShopItem.cs - 상점에서 판매하는 아이템 ID, 가격, 재고 등 상점 상품 정보 관리

⑦ Systems

- System 폴더는 게임의 핵심 진행 로직을 담당한다.

- System 폴더는 데이터나 화면 구성을 담당하는 객체를 연결 해 실제 게임 기능을 동작하도록 처리하는 것으로 구성되어 있다.

- GameController가 전체 게임 흐름을 제어하고, BattleSystem, ShopSystem, SaveService, DropService와 같은 클래스 들이 각각 전투, 상점, 저장, 드랍 기능을 담당하여 게임 진행 로직을분리했다.

Systems 폴더의 구성 요소

System
├── BattleSystem.cs - 턴제 전투 흐름과 전투 결과 처리
├── DropService.cs - 몬스터 처치 후 아이템 드랍 처리
├── EncounterService.cs - 랜덤 몬스터 등장 처리
├── GameController.cs - 게임 전체 흐름 제어
├── ItemUseService.cs - 인벤토리 아이템 사용 처리
├── PlayerFactory.cs - 직업 선택에 따른 플레이어 객체 생성
├── SaunaService.cs - 사우나 회복 기능 처리
├── SaveService.cs - 게임 저장 및 불러오기 처리
└── ShopSystem.cs - 상점 구매 / 판매 로직 처리

⑧ UI (Input, Rendering)

 

(1) UI 전체 구조 설명

- UI 폴더는 콘솔 화면 출력과 사용자 입력을 담당한다.

- UI 내부에서도 역할에 따라 Input, Rendering, Screens, Views, Settings 등으로 세분화하였다.

 

(2) Input

- Input 폴더는 사용자 입력을 처리하는 클래스들을 관리한다.

- 현재는 MenuInput 클래스만 존재하며, 선택 가능한 범위 안의 값인지 검사하고 결과를 반환하는 역할만 담당한다.

 

(3) Rendering

- Rendering 폴더는 콘솔 출력에 공통적으로 사용되는 렌더링 기능을 관리한다.

- ConsoleRenderer, AsciiImageRenderer, MapRenderer는 각각 공통 콘솔 출력, ASCII 이미지 출력, 맵 출력 기능을 담당한다. 이를 통해 화면 클래스에서 입력 처리와 출력 세부 구현이 반복되지 않게 하고 있다.

 

(4) Settings

- Settings 폴더는 콘솔 창과 화면 배치에 필요한 설정 값을 관리한다. 콘솔 환경 설정과, 화면 크기 같은 상수 값을 처리한다.

 

(5) Screens

- Screens 폴더는 화면 단위 흐름을 담당한다.

- 각 화면마다 별도의 Screen 클래스를 두어, 사용자 입력을 받고 필요한 기능을 호출하도록 처리했다.

 

(6) Views

- Views 폴더는 각 화면에 실제로 출력되는 내용을 구성한다.

- Screen 폴더가 화면 흐름과 입력 처리를 하면, Views 폴더는 화면에 어떤 목록을 출력할지 처리해준다. 이를 통해 화면 흐름 로직과 출력 코드를 나눠 처리했다.

UI 폴더의 구성 요소

UI
├── Input
│ └── MenuInput.cs - 메뉴 선택 입력 처리
├── Rendering
│ ├── AsciiImageRenderer.cs - ASCII 이미지 출력처리
│ ├── ConsoleRenderer.cs - 콘솔 출력 공통 기능 제공
│ └── MapRenderer.cs - 맵 화면 출력 처리
├── Screens
│ ├── BattleScreen.cs - 전투 화면 흐름과 입력 처리
│ ├── CharacterSelectScreen.cs - 캐릭터 선택 화면 흐름 처리
│ ├── GameScreen.cs - 게임 메인 화면 흐름 처리
│ ├── InventoryScreen.cs - 인벤토리 화면 흐름과 아이템 선택 처리
│ ├── MainMenu.cs - 메인 메뉴 화면 흐름 처리
│ ├── MapScreen.cs - 맵 화면 흐름과 이동 선택 처리
│ ├── SaunaScreen.cs - 사우나 화면 흐름 처리
│ └── ShopScreen.cs - 상점 화면 흐름과 구매/판매 선택 처리
├── Settings
│ ├── ConsoleSetting.cs - 콘솔 창 크기 및 환경 설정
│ └── Layout.cs - 화면별 레이아웃 상수
├── Views
│ ├── BattleUI.cs - 전투 UI 출력
│ ├── CharacterSelectView.cs - 캐릭터 선택 화면 출력
│ ├── GameUI.cs - 게임 메인 UI
│ ├── InventoryView.cs - 인벤토리 목록 및 아이템 정보 관련
│ ├── MainMenuView.cs - 메인 메뉴 화면 출력
│ ├── SaunaView.cs - 사우나 화면 출력
│ └── ShopView.cs - 상점 상품 및 구매/판매 화면
├── LogoArt.cs - 게임 로고 및 ASCII 아트
└── Program.cs - 프로그램 시작 지점

5. 주요 설계

5-1. Screen / View / System 단위 책임 분리 

- 기본적으로 프로그램의 흐름은 위의 4단계를 근거로 이뤄지게 처리했다.

- 특정 Screen에서 사용자 입력에 따른 흐름에 따라서, System을 호출해준다. 이후, System이 주요 로직을 처리합니다. System이 호출되면 System은 필요한 객체를 가져와서 로직을 처리한다.

- 로직 처리가 완료되면, 갱신된 정보를 화면에 새로고침 하여, Views 또는 Renderer가 이 역할을 처리해준다.

 

(1) 실제 예시

① 몬스터 전투 처리

(1) 입력(Screen) : 사용자가 BattleScreen에서 공격을 선택한다.

(2) 처리(System) : BattleSystem이 호출되어, Player의 공격력을 가져오고, Monster 객체의 TakeDamage() 메서드를 실행하여, HP 데이터를 깎는다.

     - 몬스터가 사망시, DropService가 ItemDatabase를 참조하여 드롭 아이템을 계산하고, Inventory에 추가한다.

(3) 출력 (Views or Renderer) : BattleUI가 갱신된 플레이어와 몬스터의 HP 상태를 읽어와 화면을 새로 고침한다.


5-2. 이벤트 기반 화면 전환 구조

public event Action? MapChanged; 
public event Action<Monster>? MonsterEncountered; 

public event Action? ShopEntered; 
public event Action? SaunaEntered;

- MapScreen은 전투나 상점 화면을 직접 실행하지 않고, 이벤트를 통해 외부에 상태를 전달하도록 구성했다.

- 이를 통해 맵 이동 로직과 화면 전환을 분리했고, MapScreen이 특정 화면 구현에 의존하지 않게 했다.

 

public GameScreen(Player player)
{
    this.player = player; 

    mapScreen = new MapScreen(Maps.Town, player);
    gameUI = new GameUI(player);

    mapScreen.MapChanged += DrawAll;
    mapScreen.MonsterEncountered += StartBattle;

    mapScreen.ShopEntered += OpenShop;
    mapScreen.SaunaEntered += OpenSauna;
}

- GameScreen이 객체 생성 시점에 해당 이벤트를 구독하는 것을 확인할 수 있다.

 

public void Dispose() // 이벤트 정리
{
    mapScreen.MapChanged -= DrawAll;
    mapScreen.MonsterEncountered -= StartBattle;
    mapScreen.ShopEntered -= OpenShop;
    mapScreen.SaunaEntered -= OpenSauna;
}

- 게임에서 나갈 때 구독했던 이벤트를 정리하도록 처리했다.


5-3. ID 기반 인벤토리 설계

using MyConsoleMapleRPG.Items;
namespace MyConsoleMapleRPG.Items.Inventory
{
    // 플레이어가 가진 아이템 슬롯 목록을 관리하는 클래스
    // ItemId와 Count만 저장, 실제 데이터는 ItemDatabase에서 조회
    internal class Inventory
    {
        private readonly List<InventorySlot> slots = new();

        public IReadOnlyList<InventorySlot> Slots => slots;

        // 아이템 추가
        public bool AddItem(ItemData itemData, int count = 1) 
        {
            if (itemData == null)
                return false;

            if (count <= 0)
                return false;

            if (itemData.IsStackable) // 쌓이는 아이템 추가
            {
                AddStackableItem(itemData, count);
                return true;
            }

            AddNonStackableItem(itemData.Id, count); // 쌓이지 않는 아이템 추가
            return true;
        }

        public bool TryGetSlot(int slotIndex, out InventorySlot? slot) // 슬롯 정보 획득
        {
            if (slotIndex < 0 || slotIndex >= slots.Count)
            {
                slot = null;
                return false;
            }

            slot = slots[slotIndex];
            return true;
        }

        public bool RemoveItemAt(int slotIndex, int count = 1) // 특정 위치 아이템 제거
        {
            if (count <= 0)
                return false;

            if (!TryGetSlot(slotIndex, out InventorySlot? slot))
                return false;

            if (slot.Count < count) // 제거해야할 개수보다 슬롯 아이템 개수가 적음
                return false;

            slot.RemoveCount(count); // 개수 깎기

            if (slot.Count <= 0) // 슬롯에 아이템이 없으면 해당 슬롯 삭제
                slots.RemoveAt(slotIndex);

            return true;
        }
        public bool RemoveItem(int itemId, int count)
        {
            // 0개 이하일때 차감 불가
            if (count <= 0)
                return false;

            int totalCount = slots.Where(slot => slot.ItemId == itemId).Sum(slot => slot.Count); // Linq
            // 해당 아이템의 보유량 계산

            if (totalCount < count) return false;

            int remaining = count; // 남은 아이템 개수(목표 차감량)

            // 기본적으로 뒷쪽부터 순차제거 하는 방식으로 구현
            for (int i = slots.Count - 1; i >= 0 && remaining > 0; i--)
            {
                InventorySlot slot = slots[i];

                if (slot.ItemId != itemId)
                    continue;

                // 현재 슬롯에서 차감할 수량
                int removeCount = Math.Min(slot.Count, remaining);
                slot.RemoveCount(removeCount);
                remaining -= removeCount; 

                if (slot.Count <= 0)
                    slots.RemoveAt(i);
            }

            return true;
        }


        private void AddStackableItem(ItemData itemData, int count) // 쌓이는 아이템 처리
        {
            int remainingCount = count;

            foreach (InventorySlot slot in slots)
            {
                if (slot.ItemId != itemData.Id) // 아이템이 다르면 다음 슬롯으로
                    continue;

                int availableSpace = itemData.MaxStackCount - slot.Count; // 할당 공간 계산

                if (availableSpace <= 0)
                    continue;

                int addCount = Math.Min(availableSpace, remainingCount); // 추가 가능한 개수 만큼 추가
                slot.AddCount(addCount);
                remainingCount -= addCount;

                if (remainingCount <= 0)
                    return;
            }

            while (remainingCount > 0) // 추가 가능할때까지 계속 넣ㄱ
            {
                int addCount = Math.Min(itemData.MaxStackCount, remainingCount);
                slots.Add(new InventorySlot(itemData.Id, addCount));
                remainingCount -= addCount;
            }
        }

        private void AddNonStackableItem(int itemId, int count) // 쌓기 안되는 아이템 추가
        {
            for (int i = 0; i < count; i++)
            {
                slots.Add(new InventorySlot(itemId, 1));
            }
        }

        public InventorySlot AddSlotForLoad(int itemId, int count, bool isEquipped) // 세이브 데이터 정보를 불러올때 인벤토리 복원
        {
            InventorySlot slot = new InventorySlot(itemId, count); // 인벤토리 슬롯에 아이템 재할당

            slot.SetEquipped(isEquipped); // 장비 착용 처리

            slots.Add(slot); 

            return slot;
        }
    }
}
namespace MyConsoleMapleRPG.Items.Inventory
{
    // 인벤토리의 한 칸을 표현하는 클래스
    // 어떤 아이템인가와 몇 개인가에 대한 보관
    internal class InventorySlot
    {
        public int ItemId { get; }
        public int Count { get; private set; }
        public bool IsEquipped { get; private set; }

        public InventorySlot(int itemId, int count) // 인벤토리 슬롯
        {
            if (count <= 0) throw new ArgumentException("아이템 수량은 1 이상이어야 합니다.");

            ItemId = itemId;
            Count = count;
        }
        public void SetEquipped(bool value) // 장비 착용 설정
        {
            IsEquipped = value;
        }

        public void AddCount(int amount) // 개수 늘리기
        {
            if (amount <= 0) return;

            Count += amount;
        }

        public void RemoveCount(int amount) // 개수 줄이기
        {
            if (amount <= 0) return;

            Count = Math.Max(0, Count - amount);
        }


    }
}

- 인벤토리에는 아이템 객체 자체를 직접 저장하지 않고, ItemID를 통해 관리하고 있다.

- 별도의 InventorySlot을 통해, ItemId, Count, IsEquipped 같은 보유 상태만 저장하도록 설계했다.

 

- 아이템의 이름, 설명, 가격, 타입 같은 아이템의 원본 정보는 ItemData / ItemDataBase에서 관리한다.

- InventorySlot은 플레이어가 해당 아이템을 몇개 가지고 있는지, 장착 중인지와 같은 상태만 처리한다..

 

- 이를 통해, 아이템 원본 데이터와 플레이어가 가진 아이템 데이터를 분리했고, 저장/로드 시에도 ItemId와 수량 중심으로 처리했다.

 

6. 플레이 영상

https://youtu.be/cmpc88eea0M?si=KzTv0UgK27L4_FHr

 

7. Git

https://github.com/fanjae/GM07_KimHanjae_MyConsoleMapleRPG

 

GitHub - fanjae/GM07_KimHanjae_MyConsoleMapleRPG: C# 콘솔 기반 턴제 RPG 구현

C# 콘솔 기반 턴제 RPG 구현. Contribute to fanjae/GM07_KimHanjae_MyConsoleMapleRPG development by creating an account on GitHub.

github.com

 

 

 
 
 
 
Comments