발전하는 춘배

[원고, C++/OOP] 로그라이크 게임 만들어보기 9 - 아이템과 인벤토리 2 : 엔티티 스탯 시스템, 아이템 데이터화, 데이터 로드, 테스트 본문

.원고

[원고, C++/OOP] 로그라이크 게임 만들어보기 9 - 아이템과 인벤토리 2 : 엔티티 스탯 시스템, 아이템 데이터화, 데이터 로드, 테스트

춘배0 2026. 2. 21. 12:29

이어서 실제 아이템 데이터들을 만들어보자.

귀찮아서 gpt한테 만들어달라 했다.

[
  {
    "name": "Small Potion",
    "type": "consumable",
    "description": "Heals +20 health to the player",
    "effects": [
      { "type": "heal", "value": 20 }
    ]
  },
  {
    "name": "Large Potion",
    "type": "consumable",
    "description": "Heals +50 health to the player",
    "effects": [
      { "type": "heal", "value": 50 }
    ]
  },
  {
    "name": "Iron Sword",
    "type": "weapon",
    "description": "A basic iron sword that increases attack power",
    "stats": {
      "attack": 10
    }
  },
  {
    "name": "Steel Sword",
    "type": "weapon",
    "description": "A stronger sword forged from steel",
    "stats": {
      "attack": 18
    }
  },
  {
    "name": "Leather Armor",
    "type": "armor",
    "description": "Light armor that provides minimal protection",
    "stats": {
      "defense": 5
    }
  },
  {
    "name": "Iron Armor",
    "type": "armor",
    "description": "Heavy armor that greatly increases defense",
    "stats": {
      "defense": 12
    }
  }
]

오 잘 보니 소비템은 내가 애초에 effects로 설계해서 나중에 추가를 고려했었는데,

장비템은 무기는 attack, 갑옷은 defense만 고려했지 다른 스탯을 추가할 수 있단 생각을 못 했었다.

못 한건지 안 한건지.

아무튼 이왕 하는 거 확장성을 또 고려해보자 해서 EquipmentItem 멤버도 Stat 구조체로 수정해준다.

struct Stat {
  std::string name;
  int value;
};

class EquipmentItem : public Item {
private:
  std::vector<Stat> stats;
...
}

 

지금은 무기는 공격력만, 갑옷은 방어력만 올려주도록 짜 놨지만

스탯을 전반적으로 적용해주도록 수정해주어야 한다.

 

1. 엔티티 스탯 시스템

지금 엔티티는

  int damage;
  int defense = 0;

이런 식으로 구현되어 있는데 스탯 시스템을 도입해보자.

enum class StatType {
  MAXHP,
  DAMAGE,
  DEFENSE
};

이렇게 스탯타입을 만들어주고 엔티티에

std::unordered_map<StatType, int> stats;

로 저장한다.

 

근데 생각해보면 플레이어는 '기본 스탯'을 가지고 태어나고, 장비 착용에 따른 '추가 스탯'을 얻어 합산 적용받게 된다.

이걸 코드로 표현할 순 없을까?

gpt형님의 아이디어를 참고해봤다.

class StatBlock {
private:
  std::unordered_map<StatType, int> baseStats;
  std::unordered_map<StatType, int> bonusStats;

public:
  StatBlock();
  StatBlock(int maxHp, int damage, int defense);

  int get(StatType type) const;
  void addBonus(StatType type, int value);
  void removeBonus(StatType type, int value);
};

이렇게 만들어주고

Entity에서는

StatBlock stats; 로  사용해준다.

 

그리고 WeaponItem과 ArmorItem의 equip()과 unequip()을 수정해주어야하는데,

원래 각각 공격력과 방어력을 따로따로 올려주어야 해서 분리해놓았지만

이제 스탯 시스템으로 합쳐졌으므로 얘네 로직도 합칠 수가 있다.

이전:

bool WeaponItem::equip(Entity& target) {
  target.setDamage(target.getDamage() + damage);
  return true;
}
bool WeaponItem::unequip(Entity& target) {
  target.setDamage(target.getDamage() - damage);
  return true;
}

bool ArmorItem::equip(Entity& target) {
  target.setDefense(target.getDefense() + defense);
  return true;
}
bool ArmorItem::unequip(Entity& target) {
  target.setDefense(target.getDefense() - defense);
  return true;
}

이후:

bool EquipmentItem::equip(Entity &target) {
  for (const auto &stat : stats) {
    target.addBonusStat(stat.type, stat.value);
  }
  return true;
}
bool EquipmentItem::unequip(Entity &target) {
  for (const auto &stat : stats) {
    target.removeBonusStat(stat.type, stat.value);
  }
  return true;
}

사실 bool의 의미가 있나 싶긴 한데 뜯어고치기 귀찮스러워서 그냥 놔둠.

 

시간이 꽤 걸렸는데 이걸 왜 했더라.. 하면

gpt한테 json데이터 좀 만들어줘라

-> 어라 장비 공격력, 방어력을 stats로 묶어놨네

-> 스탯 시스템 만들어야지

-> ???

 

이제 다시 이어서 해보자.

2. ItemDatabase 구현

모든 아이템 정보를 저장해둘 ItemDatabase 클래스를 만든다.

내부 구현은 일단 vector<Item>으로 하고,

JSON 파싱을 해서 파일로부터 데이터를 로드하는 static void loadFromFile(const std::string &path);

item의 find를 도와주는 findItembyItemName(); 를 일단 구현해본다. 왜냐하면 아이템 이름은 고유하게 만들려고 하기 때문.

 

윽 문제가 있다.

vector<Item>은 불가능하다!

당장 바로 저번에 Item은 추상 클래스이므로 값(객체)을 저장하는 pair 생성이 불가능하다는 걸 배웠다.

벡터도 마찬가지로 값을 저장하므로, Item은 저장할 수 없고 포인터를 저장해주어야 한다.

그렇게 되면 이 items는 아이템의 프로토타입들을 저장하는 역할을 하게 된다.

나중에 아이템이 필요하면, ItemDB에서 그 아이템을 복제해와서 사용하는 식으로 쓰면 됨.

class ItemDatabase {
private:
    static std::vector<std::unique_ptr<Item>> items;

public:
    static void loadFromFile(const std::string& path);
    static const Item* findItemByName(const std::string& name);
};

또 지난번에 알아봤던 전방 선언으로 #include <Item>을 대신해준다.

 

실제 구현은 타일 데이터 저장했던 것처럼 nlohmann/json 사용했는데

라이브러리 사용법은 따로 알아보기도 귀찮고 굳이 알아야 하나 싶어서 gpt한테 백프로 맡겼다.

 

일단 지금은 벡터라서 탐색이 덜 효율적이긴 한데

아이템 개수도 많지 않고 테스트용이므로 일단 놔둔다.

나중에 탐색 시간이 너무 길다 싶으면 map으로 딸깍 바꿔주면 됨

 

gpt가 잘 써줬겠지 가정하고 테스트 코드를 작성해본다.

 

3. 에러 해결하기

하나. 컴파일

일단 컴파일 에러가 뜬다

컨수머블아이템의 생성자는

ConsumableItem(const std::string& name, const std::string& description, const std::vector<Effect>& effects);

이건데

gpt 코드는

이렇게 생겼다.

힐벨류를 effects에 넣어서 전달해줘야 할듯.

if (type == "consumable") {
    int healValue = entry["effects"][0]["value"];
    Effect effect;
    effect.type = EffectType::HEAL;
    effect.value = healValue;
    std::vector<Effect> effects;
    effects.push_back(effect);
    items.push_back(
        std::make_unique<ConsumableItem>(name, description, effects)
    );
}

이렇게 수정해준다.

딱 봤을 때 문제가 없는 코드는 아니다. healValue를 effects의 고정된 순서에서 뽑아오고 ([0]), 다른 효과들은 고려하지 않았기 때문에 확장성이 없다.

근데 일단 지금 소비템은 체력회복포션밖에 없으므로 놔둔다.

언젠간 반드시 문제가 되겠지.

 

마찬가지로 밑에 분기들도 수정해준다.

else if (type == "weapon") {
    int attack = entry["stats"]["attack"];
    std::vector<Stat> stats;
    stats.push_back(Stat{StatType::DAMAGE, attack});
    items.push_back(
        std::make_unique<WeaponItem>(name, description, stats)
    );
}
else if (type == "armor") {
    int defense = entry["stats"]["defense"];
    std::vector<Stat> stats;
    stats.push_back(Stat{StatType::DEFENSE, defense});
    items.push_back(
        std::make_unique<ArmorItem>(name, description, stats)
    );
}

소비템이랑 같은 로직인데 struct 초기화 문법만 도입해봤다.

마찬가지로 확장성이 없다는 문제는 있으나 굳이 해결해야하나 싶어서(확장을 할까?) 넘어간다.

 

둘. 아이템 데이터 로드

테스트:

int main() {
  TileDatabase::loadFromFile("data/tile_def.json");
  std::cout << "Tile definitions loaded successfully.\n";
  ItemDatabase::loadFromFile("data/item_data.json");
  std::cout << "Item definitions loaded successfully.\n";
  ItemDatabase::printAllItems();

결과:

Tile definitions loaded successfully.
Item definitions loaded successfully.
Small Potion
Large Potion
Iron Sword
Steel Sword
Leather Armor
Iron Armor

로딩이 잘 됐다.

 

셋: 아이템 획득, 장착, 사용

플레이어에게 아이템을 줘 보자. 테스트 코드를 짜다 보니 뭔가 찝찝스러운 부분이 있었다.

if (command == "/getTestItem") {
  for (int i = 0; i < 3; i++) {
    player->getInventory().addItem(*ItemDatabase::findItemByName("Small Potion"), 1);
  }
  player->getInventory().addItem(*ItemDatabase::findItemByName("Iron Sword"), 1);
  player->getInventory().addItem(*ItemDatabase::findItemByName("Iron Armor"), 1);
  return;
}

addItem()안에 들어가는 게 너무 복잡스럽다.

이게 왜 찝찝한지 대신 좀 깨달아줘요 GPT형님

지금 내 데이터베이스는 그냥 프로토타입 저장소로 사용되고 있다.

즉 플레이어한테 아이템을 주려면 위에서처럼 이 프로토타입을 복사해서 인벤토리에 넣어주는 식으로 되는데

이것 보다는

데이터베이스의 내부에는 프로토타입을 저장하는 게 맞지만,

실제 아이템도 데이터베이스가 생성해서 건내주는 게 더 좋다.

std::unique_ptr<Item> ItemDatabase::createItem(const std::string& name) {
    const Item* item = findItemByName(name);
    if (item) {
        return item->clone();
    }
    return nullptr;
}

이렇게 생성시켜주면 된다.

그러면 실제로 아이템을 인벤토리에 넣어주는 코드는

if (command == "/getTestItem") {
  for (int i = 0; i < 3; i++) {
    player->getInventory().addItem(ItemDatabase::createItem("Small Potion"), 1);
  }
  player->getInventory().addItem(ItemDatabase::createItem("Iron Sword"), 1);
  player->getInventory().addItem(ItemDatabase::createItem("Iron Armor"), 1);
  return;
}

이렇게 좀 더 예뻐짐

 

결과:

Enter command: /getTestItem
Enter command: /inv
-----------Inventory-----------
Inventory (3/32):
1. Small Potion x3
   Heals +20 health to the player
2. Iron Sword x1
   A basic iron sword that increases attack power
3. Iron Armor x1
   Heavy armor that greatly increases defense
-------------------------------
-----------Equipment-----------
-------------------------------

아이템 지급이 잘 됐다.

장착해보자. 플레이어 기본 스탯은 이렇다.

Enter command: /stat
-----------Player Info-----------
Max HP: 30
Damage: 10
Defense: 0
HP: 30
---------------------------------

장비 장착

Enter command: /equip
Enter item slot: 2
Enter command: /inv
-----------Inventory-----------
Inventory (3/32):
1. Small Potion x3
   Heals +20 health to the player
2. Iron Sword x1
   A basic iron sword that increases attack power
3. Iron Armor x1
   Heavy armor that greatly increases defense
-------------------------------
-----------Equipment-----------
Weapon: Iron Sword
-------------------------------
Enter command: /stat
-----------Player Info-----------
Max HP: 30
Damage: 20
Defense: 0
HP: 30
---------------------------------

대미지가 잘 올랐다.

갑옷도 입어보자.

Enter command: /equip
Enter item slot: 3
Enter command: /inv
-----------Inventory-----------
Inventory (3/32):
1. Small Potion x3
   Heals +20 health to the player
2. Iron Sword x1
   A basic iron sword that increases attack power
3. Iron Armor x1
   Heavy armor that greatly increases defense
-------------------------------
-----------Equipment-----------
Weapon: Iron Sword
Armor: Iron Armor
-------------------------------
Enter command: /stat
-----------Player Info-----------
Max HP: 30
Damage: 20
Defense: 12
HP: 30
---------------------------------

방어력도 잘 올랐다.

 

적한테 피해를 입고 포션을 마셔보자.

아 공격력이 올랐더니 체력이 15인 적들이 원콤이 나버린다. (피해를 못 입는다)

장비 해제 커맨드는 만들질 않아서 게임 다시 시작해서 포션 테스트만 다시 해본다.

Enter command: /stat
-----------Player Info-----------
Max HP: 30
Damage: 10
Defense: 0
HP: 10
---------------------------------
Enter command: /use
Enter item slot: 1
Consumed Small Potion | HP +20.
Enter command: /inv
-----------Inventory-----------
Inventory (3/32):
1. Small Potion x2
   Heals +20 health to the player
2. Iron Sword x1
   A basic iron sword that increases attack power
3. Iron Armor x1
   Heavy armor that greatly increases defense
-------------------------------
-----------Equipment-----------
-------------------------------
Enter command: /stat
-----------Player Info-----------
Max HP: 30
Damage: 10
Defense: 0
HP: 30
---------------------------------

체력이 10이었는데 포션을 사용했더니 인벤토리에서 하나 개수가 줄고 체력이 찼다.

성공!

반응형