본문 바로가기

개발/내일배움캠프

C++ 인벤토리, 제작시스템 구현해보기 4 / 실제 기능 구현

https://zerosik00.tistory.com/58

 

C++ 인벤토리, 제작시스템 구현해보기 3

https://zerosik00.tistory.com/57 C++ 인벤토리, 제작시스템 구현해보기 2https://zerosik00.tistory.com/56 C++ 인벤토리, 제작시스템 구현해보기 1목표객체 지향 설계방식과 SOLID원칙을 기반으로 하여.C++ CLI환경에

zerosik00.tistory.com

전에 이어서 각 함수들의 구현부에 대해 설명한다. 다만 헤더도 변경점이 있으나 구현부를 통해 확인바란다.

 

우선 개인적으로 C++ 사용 미숙으로 가장 문제가 많았던 CraftingManager.cpp

전체 코드는 접은글

더보기
더보기
#include "CraftingManager.h"

void CraftingManager::InitializeRecipes()
{
	// 여기에 초기 데이터 예시
	//craftRecipes.push_back(CraftRecipe{ 1, {{1,1},{2,1}} });
}

CraftResult CraftingManager::customCraft(const std::map<int, int>& items) const
{
	for (const auto& rcp : craftRecipes) {
		if (rcp.matchesIngredient(items)) {
			return { CraftResultType::Success, rcp.getResultItemId() };
		}
	}
	return { CraftResultType::RecipeNotFound, -1 };
}

CraftResult CraftingManager::craft(int itemId, Inventory& inventory)
{
	//CraftResult craftResult{ CraftResultType::RecipeNotFound, -1 };
	if (itemDB.isItemExistById(itemId) == false) {
		return { CraftResultType::InvalidItem, -1 };
	}

	const CraftRecipe* recipe = findRecipeByResultItemId(itemId);
	if (recipe == nullptr) {
		return { CraftResultType::RecipeNotFound, -1 };
	}

	//재고 충분한지 확인
	const std::map<int,int>& requiredIngredients = recipe->getIngredients();
	for (const auto& [id, count] : requiredIngredients) {
		if (inventory.hasEnoughItem(id, count) == false) {
			return { CraftResultType::NotEnoughIngredient, -1 };
		}
	}

	for (const auto& [id, count] : requiredIngredients) {
		bool consumed =inventory.consumeItem(id, count);
		if (consumed == false) {
			//앞에서 체크했는데 소모가 안되면???
			//원자성을 유지할 방법 생각
		}
	}
	//아이템 소모후 인벤토리에 추가, 성공 반환.
	inventory.addItem(recipe->getResultItemId(), 1);
	return { CraftResultType::Success, recipe->getResultItemId() };
}

bool CraftingManager::addCraftRecipe(const std::map<int, int>& items, const int resultItemId)
{
	if (findRecipeByResultItemId(resultItemId) == nullptr) {
		CraftRecipe newRecipe{ resultItemId, items };
		craftRecipes.push_back(newRecipe);
		return true;
	}
	return false;
}

const CraftRecipe* CraftingManager::findRecipeByResultItemId(const int id) const
{
	for (const auto& rcp : craftRecipes) {
		if (rcp.getResultItemId() == id)
			return &rcp;
	}
	return nullptr;
}

//아래처럼하면 댕글링포인터 발생함 
//const CraftRecipe& CraftingManager::getRecipeById(int id)
//{
//	for (CraftRecipe rcp : craftRecipes) {<- rcp는 복사본임
//		if (rcp.getItemId() == id)
//			return rcp;<-즉 이건 지역변수의 반환임.
//	}
//	return CraftRecipe();<-마찬가지로 지역변수 임시객체
//}
// 지역변수를 값참조로 반환을 하면 함수를 벗어나면서 그즉시 파괴됨.
// 이거 보일때마다 복기하기

std::vector<int> CraftingManager::findRecipesByIngredientId(int ingredientId) const
{
	std::vector<int> resultItems;
	for (const auto& rcp : craftRecipes) {
		const auto& recipeIngredients = rcp.getIngredients();
		if (recipeIngredients.contains(ingredientId)) {
			resultItems.push_back(rcp.getResultItemId());
		}
	}
	return resultItems;
}

std::vector<int> CraftingManager::findRecipesByInventory(const Inventory& inventory) const
{
	std::vector<int> craftables;
	for (const auto& recipe : craftRecipes) {
		bool isCraftable = true;
		const auto& ingredient = recipe.getIngredients();
		for (const auto& [k, v] : ingredient) {
			if (inventory.hasEnoughItem(k, v) == false) {
				isCraftable = false;
				break;
			}
		}

		if(isCraftable)
			craftables.push_back(recipe.getResultItemId());
	}
	return craftables;
}

const std::vector<CraftRecipe>& CraftingManager::getAllRecipes() const
{
	return craftRecipes;
}

우선 사용부분에서 결과값을 명확하게 하기 위해 결과값 객체를 추가로 정의하였다.

enum class CraftResultType {
	Success,//성공
	RecipeNotFound,//레시피없음
	NotEnoughIngredient,//재료부족
	InvalidItem,//아이템 정보 없음
};

struct CraftResult {
	CraftResultType resultType;
	int resultItemId;
};

실패경로가 여러개인 함수에 대해서 좀더 명확한 결과를 반환할 수 있도록 한다.

enum을 보면 성공 외에 레시피가 없다, 재료가 부족하다, 아이템이 잘못됫다 등 더 명확한 정보를 제공하도록 한다.

 

 

그리고 early-return 코드작성기법을 적용해보았다. CraftManger의 craft함수로 보면 아래와 같다.

CraftResult CraftingManager::craft(int itemId, Inventory& inventory)
{
	//CraftResult craftResult{ CraftResultType::RecipeNotFound, -1 };
	if (itemDB.isItemExistById(itemId) == false) {
		return { CraftResultType::InvalidItem, -1 };
	}

	const CraftRecipe* recipe = findRecipeByResultItemId(itemId);
	if (recipe == nullptr) {
		return { CraftResultType::RecipeNotFound, -1 };
	}

	//재고 충분한지 확인
	const std::map<int,int>& requiredIngredients = recipe->getIngredients();
	for (const auto& [id, count] : requiredIngredients) {
		if (inventory.hasEnoughItem(id, count) == false) {
			return { CraftResultType::NotEnoughIngredient, -1 };
		}
	}

	for (const auto& [id, count] : requiredIngredients) {
		bool consumed =inventory.consumeItem(id, count);
		if (consumed == false) {
			//앞에서 체크했는데 소모가 안되면???
			//원자성을 유지할 방법 생각
		}
	}
	//아이템 소모후 인벤토리에 추가, 성공 반환.
	inventory.addItem(recipe->getResultItemId(), 1);
	return { CraftResultType::Success, recipe->getResultItemId() };
}

위에서부터 함수의 작동 조건들을 체크하고, 조건을 (불)만족하면 그 라인에서 return을 사용하는 방법이다.

 

내 craft함수의 경우 위에서부터순서대로

  1. 아이템 Id가 유효한지
  2. 제작법이 존재하는지
  3. 존재한다면 제작법에서 요구하는 재료를 만족하는지

3가지 조건을 체크해서 불만족시 이유와 함께 return해버리고,

마지막에 필요한 재료를 인벤토리에서 소모하고 결과물을 인벤토리에 추가 한 후 성공값을 반환한다.

 

만약 이 기법을 적용하지않고 극단적으로 조건문마저 반대로 변경해버리면  아래 코드처럼 되니 확인해보길 바란다

더보기
더보기
CraftResult CraftingManager::craft(int itemId, Inventory& inventory)
{
    CraftResult result = { CraftResultType::InvalidItem, -1 };

    // 1. 아이템 존재 여부 확인
    if (itemDB.isItemExistById(itemId) == true) {
        const CraftRecipe* recipe = findRecipeByResultItemId(itemId);

        // 2. 레시피 존재 여부 확인
        if (recipe != nullptr) {
            const std::map<int, int>& requiredIngredients = recipe->getIngredients();
            bool hasAllIngredients = true;

            // 3. 재료 보유 여부 루프 검사
            for (const auto& [id, count] : requiredIngredients) {
                if (inventory.hasEnoughItem(id, count) == false) {
                    hasAllIngredients = false;
                    result = { CraftResultType::NotEnoughIngredient, -1 };
                    break;
                }
            }

            // 4. 재료가 충분할 경우 최종 실행
            if (hasAllIngredients == true) {
                for (const auto& [id, count] : requiredIngredients) {
                    inventory.consumeItem(id, count);
                }
                inventory.addItem(recipe->getResultItemId(), 1);
                result = { CraftResultType::Success, recipe->getResultItemId() };
            }
        }
        else {
            result = { CraftResultType::RecipeNotFound, -1 };
        }
    }
    else {
        result = { CraftResultType::InvalidItem, -1 };
    }

    return result;
}

코드의 깊이(Depth)가 4단계까지 늘어나고,

실패처리에 대한 분기else가 함수 밑바닥으로 가 보기 힘들고

분기의 조건을 확인하기위한 플래그 지역변수까지 생성해야한다!

그리고 저번에도 정리했지만 하나 잘못만들고 댕글링포인터로 한시간 고생한것 복기하려고 따로 주석남긴 코드

const CraftRecipe& CraftingManager::getRecipeById(int id)
{
	for (CraftRecipe rcp : craftRecipes) {<- rcp는 복사본임
		if (rcp.getItemId() == id)
			return rcp;<-즉 이건 지역변수의 반환임.
	}
	return CraftRecipe();<-마찬가지로 지역변수 임시객체
}

값참조를 반환하는 함수가 임시객체나 지역변수를 반환하면 바로 값이 사라져 메모리 에러가 발생한다.

값참조 반환은 클래스의 맴버변수등 계속 살아있는 값에만 사용하도록 해야 한다.

포인터를 반환하는경우에도 동일하니 주의할것


다음은 ItemDatabase.cpp.

여기도 결과값을 명확히 하기위한 객체를 추가하였다.

//ItemDatabase.h
enum class QueryResultType {
	Success,//성공
	InvalidItemType,//아이템 정보 없음
	AlreadyExist,
	ItemNotFound,
	NoAvailableId
};

struct QueryResult {
	QueryResultType resultType;
	int resultItemId;
};

DB의 역활을 하는 클래스이므로 QueryResult라고 이름짓고  Type을 통해 실패사유를 명확히.

 

 

전체코드는 아래.

더보기
더보기
#include "ItemDatabase.h"

void ItemDatabase::initializeItemDatabase()
{
	//초기 아이템 추가 예시
	//itemDatabase.insert({ 100, Item{100, "초급 회복 물약","기초적인 회복 물약."} });
}

const std::map<int, Item>& ItemDatabase::getAllItems() const
{
	return itemDatabase;
}

QueryResult ItemDatabase::tryAddCustomItem(const std::string& name, const std::string& desc, ItemType type) {
	//아이템 존재하는지 먼저 체크
	for (const auto& [k, v] : itemDatabase) {
		if (v.getName() == name) {
			return { QueryResultType::AlreadyExist , k };
		}
	}

	int startIdx, endIdx;
	switch (type) {
	case(ItemType::Material):
		startIdx = 0, endIdx = 100;
		break;
	case(ItemType::Potion):
		startIdx = 100, endIdx = 200;
		break;
	default:
		return { QueryResultType::InvalidItemType, -1 };
	}

	//신규 아이템 추가
	for(int idx = startIdx ; idx < endIdx ; idx++){
		//아이템 ID가 사용중이지 않으면 해당 아이디 사용.
		if (itemDatabase.contains(idx) == false) {
			itemDatabase.emplace(idx, Item{ idx, name, desc, type });
			return { QueryResultType::Success, idx };
		}
	}

	return { QueryResultType::NoAvailableId , -1 };
}

bool ItemDatabase::isItemExistById(int id) const
{
	return itemDatabase.contains(id);
}

const Item* ItemDatabase::getItemById(int id) const
{
	auto it = itemDatabase.find(id);
	if (it != itemDatabase.end()) {
		return &it->second;
	}
	return nullptr;
}

ItemType ItemDatabase::getItemTypeById(int id) const
{
	auto it = itemDatabase.find(id);
	if (it != itemDatabase.end()) {
		return it->second.getItemType();
	}
	else
		return ItemType::None;
}

QueryResult ItemDatabase::getItemIdByName(const std::string& name) const
{
	for (const auto& [k, v] : itemDatabase) {
		if (v.getName() == name) {
			return { QueryResultType::Success , k };
		}
	}
	return { QueryResultType::ItemNotFound , -1 };
}

 

이 부분을 작성하면서 배운것중 하나는 STL 컨테이너 접근시 아무렇게나 인덱스[]를 통해 접근하면 안된다는것인데,

 

읽기 전용인 const함수에서는 인덱스접근을 하려고하면 오류가 발생한다.

읽기인데 대체 왜그런가 했는데 원인은 컨테이너의 특성에 있었다.

map컨테이너에 없는 key값으로 인덱스 접근을 하려고 하면 컨테이너는 즉시 해당 key의 value로 기본값 ( int 라면 0 )을 생성한다.

즉 쓰기 기능이 포함되어있는것이다.

 

이를 해결하기 위해서는 여러 읽기기능만 하는 함수들을 이용해야하는데

여기서 사용한 함수들은 다음과 같다.

  • .at() : 파라미터로 key값을 주면 해당 key값에 해당하는 value를 반환.
  • .find() : 파라미터로 key를 주면 해당 위치의 iterator(pair)를 반환한다. 없으면 end()에 해당하는 iter 반환.
  • .contains() : 파라미터에 key값을 주면 컨테이너에 해당 key가 존재하는지 bool반환

이중 find를 사용하면 원하는 키값이 있는지부터, 값을 받아오는것까지 가능하니 매우 유용하다.

const Item* ItemDatabase::getItemById(int id) const
{
	auto it = itemDatabase.find(id);
	if (it != itemDatabase.end()) {
		return &it->second;
	}
	return nullptr;
}

사용 예시.

const 반환값 설정하여 포인터 변경의 위험을 막고, 함수뒤 const추가로 맴버변수 변경의 위험을 미리 차단한다.

 

추가로 STL 컨테이너에 값을 넣는 방식이 push와 emplace 두가지가있는데

push는 객체를 파라미터로 받아서 컨테이너에 담는 방식

emplace는 컨테이너가 담을 객체의 생성자에 해당하는 값들을 받아 컨테이너에서 생성되는 방식으로

두가지 방식의 "기능"은 동일하지만

push의 객체 복사, 이동등의 과정이 없으므로 사용이 가능하다면 emplace가 더 경제적이다.

 


마지막은 인벤토리

더보기
더보기
#include "Inventory.h"

const std::map<int, int>& Inventory::getStock() const
{
	return stock;
}

int Inventory::getItemCount(int itemId) const
{
	auto it = stock.find(itemId);
	if (it != stock.end()) {
		return it->second;
	}
	return 0;
}

bool Inventory::addItem(int itemId, int count)
{
	if (count <= 0)
		return false;

	auto it = stock.find(itemId);
	if (it == stock.end()) {
		if (count > maxStack)
			return false;
		
		stock[itemId] = count;
	}
	else {
		if ((it->second + count) > maxStack)
			return false;
		else {
			it->second += count;
		}
	}
	return true;
}

bool Inventory::consumeItem(int itemId, int count)
{
	if (count <= 0)
		return false;

	if (!stock.contains(itemId)) {
		return false;
	}

	if (stock.at(itemId) < count) {
		return false;
	}

	stock[itemId] -= count;

	if (stock[itemId] == 0) {
		stock.erase(itemId);
	}

	return true;
}

bool Inventory::hasEnoughItem(int itemId, int count) const
{
	if (count <= 0)
		return false;

	auto it = stock.find(itemId);
	if (it == stock.end()){
		return false;
	}

	return it->second >= count;
}

실제 데이터를 담는 stock 배열에 값을 변경하는 기능에 충실하다.

add, consume, enough~ 함수에는 모두 count 파라미터가 0또는 음수인지 확인하는 조건문을 추가하였는데,

이는 add를 consume처럼, consume을 add처럼 쓰는 이상한 일을 막기 위해서이다.

함수의 이름과 행동이 일치하도록

 

인벤토리에는 아이템별 최대 개수(maxstack)가 정해져있어 이를 넘지 못하도록 하였다.

 

오늘 구현은 여기까지,


사족.

역시 뭘 만들면서 사고쳐봐야 체득이 빠르다...