I'm FanJae.

[My Turn Based Console RPG] Day 4. 아이템, 상점, 인벤토리 기능 구현 본문

Projects/My Turn Based Console RPG

[My Turn Based Console RPG] Day 4. 아이템, 상점, 인벤토리 기능 구현

FanJae 2026. 5. 18. 22:54

1. 시작에 앞서

- 평일에는 부트캠프에서 배우는 내용을 정리하고 저녁에 주로 구현했기 때문에 시간이 부족했기에 수업이 없는 토요일 / 일요일에 집중적으로 구현을 진행하였다.


2. 아이템 적용

 

- 아이템은 기본적으로 이런 정보를 가진다.

- ID, 이름, 아이템 설명, 가격, 타입 外 여러 가지. 이러한 정보를 ItemDatabase라는 아이템의 원본 정보를 가지고 있는 Dictionary에 담아 처리한다.

// ItemDatabase.cs
private static readonly Dictionary<int, ItemData> items = new()
{
    {
        1001,
        new ItemData(
            id: 1001,
            name: "빨간 포션",
            description: "HP를 30 회복합니다.",
            price: 50,
            type: ItemType.Consumable,
            isStackable: true,
            maxStackCount: 99,
            effect: new HealEffect(30),
            iconPath: "Assets/red_potion.png"
        )
    },

- 시간 상의 이유로 하드 코딩하였지만, 리팩토링 한다면 JSON 형태로 처리하면 좋을 것 같다.

- 포션의 경우는 IItemEffect를 구현한 객체를 할당하도록 처리했다.

using MyConsoleMapleRPG.Character;

namespace MyConsoleMapleRPG.Items.Effects
{
    // 소비 아이템 효과의 공통 인터페이스
    // 효과 종류가 달라도 Apply(Player)하나로 실행할 수 있게 함.
    internal interface IItemEffect
    {
        void Apply(Player player);
    }
}

- IItemEffect 를 구현하면 효과 종류가 달라도  Apply(Player) 하나로 실행할 수 있다.

- 예를들어 체력 회복 효과의 경우 다음과 같은 형태로 처리한다.

internal class HealEffect : IItemEffect
{
	  // HP를 회복 시키는 아이템 효과
    private readonly int amount;

    public HealEffect(int amount)
    {
        this.amount = amount;
    }

    public void Apply(Player player) // 플레이어 회복
    {
        player.Heal(amount);
    }
}

- 이렇게 하면, 체력 회복과 관련한 효과를 값만 바꿔주는 형태로 모든 포션이 사용할 수 있다.

- 실제 아이템의 '사용' 자체를 처리하는 작업은 ItemUseService가 담당한다.

 

internal class ItemUseService
{
    public bool UseItem(Inventory inventory, int slotIndex, Player player, out string message) // 아이템 사용
    {
        if (!inventory.TryGetSlot(slotIndex, out InventorySlot? slot) || slot == null)
        {
            message = "잘못된 슬롯입니다.";
            return false;
        }

        if (!ItemDatabase.TryGet(slot.ItemId, out ItemData? item) || item == null)
        {
            message = "존재하지 않는 아이템입니다.";
            return false;
        }

        if (item.Type == ItemType.Consumable) // 먹을 수 있는 아이템의 경우
        {
            if (item.Effect == null) // 이펙트가 없으면 사용 효과 없음
            {
                message = "사용 효과가 없습니다.";
                return false;
            }

            item.Effect.Apply(player);
            inventory.RemoveItemAt(slotIndex, 1);
            message = $"{item.Name}을(를) 사용했습니다.";
            return true;
        }

        if (item.Type == ItemType.Equipment) // 장비인 경우
        {
            return player.Equipment.ToggleEquip(slot, item, player.JobType, out message); // 장착 적용과 해제 사이에 토글
        }

        message = "사용할 수 없는 아이템입니다.";
        return false;
    }
}

- 장비의 존재 유무를 먼저 체크하고, 먹을 수 있는 아이템(포션과 같은 아이템) 여부를 체크하여, 소비가 가능한 아이템은 소비하도록 처리한다. 장비의 경우는 장착과 착용 해제를 토글 형태로 처리했다.


3. 장비 관련

- 플레이어는 자신에게 맞는 무기와 방어구를 착용해 보너스 스탯을 얻을 수 있고, 장비 착용시 [E] 라고 표현되게 처리했다.

- 또, 자신에게 맞지 않는 장비를 착용 시도하는 경우 착용하지 못하게 처리했다.

{
    2001,
    new ItemData(
        id: 2001,
        name: "검",
        description: "전사가 쓰는 검. 공격력 + 5",
        price: 300,
        type: ItemType.Equipment,
        isStackable: false,
        maxStackCount: 1,
        iconPath: "Assets/sword_shop.png",
        equipmentStats: new EquipmentStats(equipmentType: EquipmentType.Weapon,requiredJobs: JobType.Warrior,attackBonus: 5)
    )
},

- 장비의 아이템 정보에는 포션과 다르게 Ieffect를 구현하는 대신, equipmentstats 라는 것을 할당하도록 처리했다. equipmentStats 정보를 보고, 해당 아이템의 타입과 요구하는 직업, 스탯 보너스 등을 계산한다.

 

using MyConsoleMapleRPG.Enums;

namespace MyConsoleMapleRPG.Items
{
    // 장비 아이템만 필요한 스탯 정보를 담는 데이터 클래스
    internal class EquipmentStats
    {
        public EquipmentType EquipmentType { get; }
        public JobType RequiredJobs { get; }
        public int AttackBonus { get; }
        public int DefenseBonus { get; }

        public EquipmentStats(EquipmentType equipmentType,JobType requiredJobs,int attackBonus = 0,int defenseBonus = 0)
        {
            EquipmentType = equipmentType;
            RequiredJobs = requiredJobs;
            AttackBonus = attackBonus;
            DefenseBonus = defenseBonus;
        }
    }
}

- 장비 아이템만 필요한 스탯 정보를 다음과 같은 형태로 담고 있다.


4. 인벤토리 관련

- 이전 부트캠프 과제 중에 객체 지향 5대 설계 원칙(SOLID)를 지키도록 리팩토링 하는 과제가 나올 때 구상했던 'MyImprovedInventory' 라는 것을 적용해보기로 했다.

① 고민의 출발점

// Invetory.cs
class Inventory // 과제 코드의 Inventory 中 일부
    {
        private Item[] items = new Item[10];
        private int count = 0;
    }

 

- 과제에서 구현한 형태의 인벤토리는 다형성 처리 (여러 장비가 늘어날 때 마다 if-else 형태로 장비 사용을 처리하는 문제)를 해결하는 데 초점을 맞춘 과제였다.

- 과제를 해결한 뒤, Inventory가 아이템 객체를 가지고 있을 때 생길 수 있는 문제점에 대해 고려해 보았고, 이렇게 짤 때 어떤 문제가 일어날 지 확인했다.

- 슬롯이 80칸. 이 게임에서 한 칸에 들어갈 수 있는 count 개수는 약 9999개다. 모두 합치면, 대략 80만 개 정도다. 

- 이 모든 객체에 대한 생명 주기를 관리한다는 것은 까다로운 문제다. (멀티 플레이 게임의 경우는 고려할 문제가 더 생기게 된다.)

 

② 설계 방향

 // Inventory.cs 일부 정보
 private readonly List<InventorySlot> slots = new();

 public IReadOnlyList<InventorySlot> Slots => slots;

- 인벤토리 내부에서 각 슬롯 정보를 가지고 있고, 이 슬롯 정보는 각 아이템의 실제 객체가 아닌 아이템 ID 값만 가지고 있게 처리하여 인벤토리(또는 슬롯)이 실제 아이템 객체를 할당 받지 않도록 처리했다.

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

- 인벤토리는 InventoryView가 인벤토리와 관련한 출력을 담당하고, InventoryScreen은 키보드 입력 처리와 같은 흐름 제어를 담당한다. 인벤토리에서는 핵심 로직을 다른 클래스에 위임하지 않고, 본인이 직접 처리한다.

 

- 단, 아이템 사용과 관련된 부분만 별도로 ItemUserService로 나눴다.

// InventoryScreen.cs 일부
public void Show() // 인벤토리 정보 보여줌
{
    while (true)
    {
        view.Draw(selectedIndex, player.Gold, message);

        ConsoleKey key = Console.ReadKey(true).Key;

        switch (key)
        {
            case ConsoleKey.UpArrow:
                MoveUp();
                break;

            case ConsoleKey.DownArrow:
                MoveDown();
                break;

            case ConsoleKey.Z:
                UseSelectedItem();
                break;

            case ConsoleKey.Escape:
                return;
        }
    }
}

private void MoveUp() // 위로 이동
{
    if (player.Inventory.Slots.Count == 0)
        return;

    selectedIndex--;

    if (selectedIndex < 0)
        selectedIndex = player.Inventory.Slots.Count - 1;
}

private void MoveDown() // 아래로 이동
{
    if (player.Inventory.Slots.Count == 0)
        return;

    selectedIndex++;

    if (selectedIndex >= player.Inventory.Slots.Count)
        selectedIndex = 0;
}

private void UseSelectedItem() // 사용 버튼 누름
{
    if (player.Inventory.Slots.Count == 0) // 해당 슬롯에 아무것도 없는 경우
        return;

    itemUseService.UseItem(player.Inventory, selectedIndex, player, out message); // 아이템 사용

    if (selectedIndex >= player.Inventory.Slots.Count) 
        selectedIndex = Math.Max(0, player.Inventory.Slots.Count - 1);
}
// Inventory.cs 中 일부

// 아이템 추가
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;
}
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));
    }
}

- 이와 같은 형태로 Stackable 한 아이템과 NonStackable한 아이템을 분리하여 처리하고 있다.


5. 상점 관련

- 상점에서는 원하는 아이템을 구매 및 판매가 가능하다.

- UI를 고려하여 4칸이 넘어가면 다음 목록이 보이게 처리했다.

 

// ShopScreen.cs
private int CurrentItemCount // 현재 아이템 개수 (구매일땐 상점의 아이템이 판매일때는 내 인벤의 아이템이 보인다.)
{
    get
    {
        return mode == ShopMode.Buy
            ? shopSystem.Items.Count
            : player.Inventory.Slots.Count;
    }
}
 

- 상점은 모드에 따라서 구매할 때(재고)에 상점의 아이템의 개수, 판매할 때(수량)에 인벤토리에 있는 개수를 계산했다.

- 상점의 메뉴 입력 및 흐름 처리는 ShopScreen이 담당하고, 화면에 출력하는 역할은 ShopView가 처리한다. 이외의 실제 물건을 사고 파는(구매, 판매와 같은) 역할은 ShopSystem에 분리하여 구현하였다.

 

// ShopScreen.cs
public void Show()
{
    while(true)
    {
        view.Draw(selectedIndex, player.Gold, message, mode, player); // 상점 그리기

        ConsoleKey key = Console.ReadKey(true).Key;

        switch(key)
        {
            case ConsoleKey.UpArrow:
            {
                int prevIndex = selectedIndex;
                MoveUp();
                break;
            }

            case ConsoleKey.DownArrow:
            { 
                int prevIndex = selectedIndex;
                MoveDown();
                break;
            }

            case ConsoleKey.LeftArrow:
            case ConsoleKey.RightArrow:
                ToggleMode();
                break;

            case ConsoleKey.Z:
                if (mode == ShopMode.Buy)
                    BuySelectedItem();
                else
                    SellSelectedItem();
                break;

            case ConsoleKey.Escape:
                return;
            
        }
    }
}

- ShopScreen의 흐름에 따라서 ShopView와 ShopSystem이 각자의 할 일을 처리한다.

// ShopSystem.cs
public bool Buy(int selectedIndex, Player player, out string message) // 구매
{
    message = "";

    if (selectedIndex < 0 || selectedIndex >= items.Count) // 선택된 것이 없는 경우
    {
        message = "잘못된 상품입니다.";
        return false;
    }

    ShopItem shopItem = items[selectedIndex];

    if (!shopItem.HasStock()) // 제고가 없는 경우
    {
        message = "재고가 없습니다.";
        return false;
    }

    if (!ItemDatabase.TryGet(shopItem.ItemId, out ItemData? itemData) || itemData == null) // 아이템이 존재하지 않는 경우
    {
        message = "존재하지 않는 아이템입니다.";
        return false;
    }

    if (!player.SpendGold(itemData.Price)) // 돈이 모자른 경우
    {
        message = "골드가 부족합니다.";
        return false;
    }

    player.Inventory.AddItem(itemData, 1); // 인벤토리에 아이템을 추가(실제로는 아이템의 번호만 가져간다.)
    shopItem.DecreaseStock(); // 재고가 있던 경우 재고 감소

    message = $"{itemData.Name}을(를) 구매했습니다.";
    return true;
}

- 구매가 가능한지 확인하여, 구매가 가능하면 인벤토리에 아이템을 추가한다. 이때, 실제로는 ItemData를 가져가서 아이템의 번호와 같은 정보만 가져간다. (즉, 실제 객체에 대한 복사가 이뤄지지 않게 처리했다. → 복사를 안해도 되게)

 

// ShopView.cs
private void DrawItems(int selectedIndex) // 아이템 리스트 정보 그려주는 메서드
{
    int windowX = 6;
    int windowY = 3;
    int windowWidth = 76;
    int windowHeight = 8;

    int startX = 8;
    int startY = 5;
    int startIndex = 0;

    ConsoleRenderer.DrawWindow(windowX, windowY, windowWidth, windowHeight, " 아이템 LIST");

    Console.SetCursorPosition(startX + 2, startY - 1);
    Console.Write("이름");

    Console.SetCursorPosition(startX + 28, startY - 1);
    Console.Write("가격");

    Console.SetCursorPosition(startX + 40, startY - 1);
    Console.Write("재고");

    if (selectedIndex >= ListVisibleCount)
        startIndex = selectedIndex - ListVisibleCount + 1;

    for (int i = 0; i < ListVisibleCount; i++) // 최대 볼 수 있는 개수 만큼만 출력한다.
    {
        int itemIndex = startIndex + i;

        Console.SetCursorPosition(startX, startY + i);
        Console.Write(new string(' ', 65));

        if (itemIndex >= shopSystem.Items.Count)
            continue;

        ShopItem shopItem = shopSystem.Items[itemIndex];

        if (!TryGetItem(shopItem.ItemId, out ItemData? itemData) || itemData == null)
            continue;

        string cursor = itemIndex == selectedIndex ? "▶" : " ";
        string stockText = GetStockText(shopItem);

        Console.SetCursorPosition(startX, startY + i);
        Console.Write($"{cursor} {itemData.Name.PadRight(14)}");

        Console.SetCursorPosition(startX + 28, startY + i);
        Console.Write($"{itemData.Price}G");

        Console.SetCursorPosition(startX + 40, startY + i);
        Console.Write(stockText);
    }
}

6. 사우나 구현(RPG의 여관 시스템)

- 메이플 스토리에는 사우나가 있고, 사우나에 있으면 체력이 빠르게 오른다. 여기서는 일반적인 RPG 게임의 여관을 대신하여 사우나로 표현하였다.

    private void DrawSaunaArt()
    {
        string[] saunaArt =
        {
    "┌──────────────────────────────────────────────┐",
    "│        ~        ~        ~        ~          │",
    "│    ~        ♨   ♨   ♨        ~~~~~        │",
    "│                                              │",
    "│        ██████████████████████                │",
    "│        █                    █                │",
    "│        █       SAUNA        █                │",
    "│        █                    █                │",
    "│        ██████████████████████                │",
    "│                                              │",
    "│        ┌────────────────────┐                │",
    "│        │====================│                │",
    "│        │====================│                │",
    "│        │====================│                │",
    "│        └────────────────────┘                │",
    "│              ( -_- )                         │",
    "│               /| |\\          ~               │",
    "│               / \\                            │",
    "│      ~~~~~~~~ steam ~~~~~~~~~~~~~            │",
    "│                                              │",
    "└──────────────────────────────────────────────┘"
};

- 사우나 아트는 이렇게 생겼다.

- 이전에 보았던 인벤토리, 상점과 동일하게 입력과 흐름 처리는 SaunaScreen이 담당하고, 사우나 체력 회복 관련 로직은 SanuaService가 담당한다. 또, 출력과 관련한 부분은 SaunaView가 처리하도록 분리하였다.

 

// SaunaView 中 일부

namespace MyConsoleMapleRPG.UI.Views
{
    // 사우나 화면의 출력 전용 View
    // 플레이어 상태, 메뉴, 메시지 ASCII 아트를 그림

    internal class SaunaView
    {
        public void DrawBase()
        {
            Console.Clear();
            ConsoleRenderer.DrawWindow(2, 1, 90, 32, "SAUNA");
            DrawSaunaArt();
        }

        public void DrawMenu()
        {
            ConsoleRenderer.DrawWindow(58, 13, 30, 18, "MENU");

            Console.SetCursorPosition(61, 16);
            Console.Write("따뜻한 사우나");

            Console.SetCursorPosition(61, 18);
            Console.Write("HP / MP 전체 회복");

            Console.SetCursorPosition(61, 20);
            Console.Write($"비용 : {SaunaService.RecoverCost}G");

            Console.SetCursorPosition(61, 24);
            Console.Write("[Z] 회복하기");

            Console.SetCursorPosition(61, 28);
            Console.Write("[ESC] 나가기");
        }

}

- 사우나와 관련된 UI를 출력하는 것을 확인할 수 있다. (사우나 아트도 이 안에 있다.)

 

// SaunaScreen.cs

namespace MyConsoleMapleRPG.UI.Screens
{
    // 사우나 화면의 입력 루프를 담당
    // 회복에 대한 실제 로직은 SanuaService가 담당
    // 결과 메시지 및 출력은 SanuaView에 위임
    internal class SaunaScreen
    {
        private readonly Player player;
        private readonly SaunaService saunaService = new SaunaService();
        private readonly SaunaView view = new SaunaView();

        private string message = "";

        public SaunaScreen(Player player)
        {
            this.player = player;
        }

        public void Show() // 사우나 관련 흐름 처리 시작 부분
        {
            view.DrawBase();
            view.DrawPlayerStatus(player);
            view.DrawMenu();
            view.DrawMessage(message);

            while (true)
            {
                ConsoleKey key = Console.ReadKey(true).Key;

                switch (key)
                {
                    case ConsoleKey.Z:
                        bool success = saunaService.Recover(player, out message);

                        view.DrawPlayerStatus(player);
                        view.DrawMenu();
                        view.DrawMessage(message);

                        if (success)
                        {
                            Console.ReadKey(true);
                            return;
                        }

                        break;

                    case ConsoleKey.Escape:
                        return;
                }
            }
        }
    }
}

- 사우나와 관련된 입력 및 흐름 제어를 처리하는 클래스다. 흐름에 따라 회복과 같은 작업을 할 Service를 호출해준다.

 

using MyConsoleMapleRPG.Character;

namespace MyConsoleMapleRPG.Systems
{
    // 사우나/여관 같은 회복 시설의 실제 회복 규칙 담당
    // 비용 지불과 HP/MP 전체 회복 처리
    internal class SaunaService
    {
        public const int RecoverCost = 50;

        public bool Recover(Player player, out string message) // 체력 회복
        {
            if (player == null)
            {
                message = "플레이어 정보가 없습니다.";
                return false;
            }

            if (!player.SpendGold(RecoverCost))
            {
                message = "골드가 부족합니다.";
                return false;
            }

            player.FullRecover();

            message = "회복되었습니다.";
            return true;
        }
    }
}

- 사우나 서비스에서 실제 체력 회복 등의 작업을 처리한다. 골드가 부족하면 회복할 수 없지만, 골드가 있으면 체력 회복과 같은 작업을 하는 것을 확인할 수 있다.

Comments