| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- git
- Network Programming
- System Programming
- Toy Project
- Online Judge
- C++
- BOJ
- c#
- Data Structure
- 독서
- multi-thread
- Unity
- PS
- Today
- Total
I'm FanJae.
[My Turn Based Console RPG] Day 4. 아이템, 상점, 인벤토리 기능 구현 본문
[My Turn Based Console RPG] Day 4. 아이템, 상점, 인벤토리 기능 구현
FanJae 2026. 5. 18. 22:541. 시작에 앞서
- 평일에는 부트캠프에서 배우는 내용을 정리하고 저녁에 주로 구현했기 때문에 시간이 부족했기에 수업이 없는 토요일 / 일요일에 집중적으로 구현을 진행하였다.
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;
}
}
}
- 사우나 서비스에서 실제 체력 회복 등의 작업을 처리한다. 골드가 부족하면 회복할 수 없지만, 골드가 있으면 체력 회복과 같은 작업을 하는 것을 확인할 수 있다.
'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 3. 배틀 시스템 구현 및 기능 단위 클래스 분리 (0) | 2026.05.17 |
| [My Turn Based Console RPG] Day 2. 직업 선택 페이지 & 기본 UI & 맵 관련 기능 구현 (0) | 2026.05.16 |
| [My Turn Based Console RPG] Day 1. 기본 구상 및 메인 화면 구현 (0) | 2026.05.14 |