Post

C++ - 팀프로젝트 텍스트기반 RPG 후기

C++

C++ - 팀프로젝트 텍스트기반 RPG 후기

팀프로젝트 후기

지난주 팀 프로젝트로 텍스트 기반 RPG 게임을 제작했다. RPG 게임에 로그라이크 장르를 추가하여 각 스테이지에서 몬스터와 싸우고, 보상으로 랜덤 스킬을 얻어 자신만의 빌드를 방식으로 게임을 기획하였다.

내가 맡은 역할은 시스템 플로우와 상점 시스템을 담당하는 것이었는데, 게임에 필요한 주요 기능들을 각각 담당하는 GameSystem 클래스를 만들었다. 그 후 GameSystem을 상속받아 LobbySystem, BattleSystem, ShopSystem이 각기 다른 기능을 담당하게 했다. GameSystem 클래스에는 상태 패턴을 적용하여 현재 시스템 상태에 맞게 기능을 실행하도록 했다.

세 가지 시스템을 관리하는 SystemContext 클래스도 설계했으며, 이 클래스는 시스템 간 이동을 처리하고 플레이어를 관리한다. 다른 시스템에서 플레이어 정보를 필요로 할 때 SystemContext를 통해 쉽게 접근할 수 있도록 했다.

시스템 간 이동은 Exit SystemEnter System을 통해 처리된다. Exit System에서는 이동 전 작업을 마무리하고, Enter System에서는 새로운 시스템에 들어가기 전 전처리를 한다. 현재 시스템에서 다시 같은 시스템으로 이동하는 경우는 예외 처리하여 ExitEnter가 실행되지 않도록 했다.

상점 시스템은 간단하게 설계했다. EnterSystem을 호출하면 상점에서 판매할 아이템 리스트가 생성된다. 아이템은 ItemManager에서 랜덤으로 생성된 후, 플레이어의 입력을 검증하는 InputManager가 플레이어의 입력을 받는다. 이 입력을 통해 System이 상태를 바꾸도록 구현했다.

기능적으로 배운 것들

이번 프로젝트에선 가변인자 템플릿과 폴드에 대해 배운 것이 가장 기억에 남는다.

위에서 설명한 GetInput 함수는 입력 조건을 검사하는 Validator 객체들을 넣어주는데, 입력 조건이 여러개인 입력은 여러개의 Validator를 입력받기 위해 가변 인자 템플릿을 이용해주었다.

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
31
32
33
34
35
36
37
38
	template<typename InputType, typename... Validators> // InputValidatorTypes.h 에 정의
	static InputType GetInput(const string& title, const vector<string>& options, Validators... validators) //메뉴를 출력하고, 입력을 받은 다음, validator들도 검증 후 command 실행
	{
		auto displayEvent = make_shared<IDisplayMenuEvent>(title, options);
		auto wrongInputEvent = make_shared<IWrongInputEvent>();
		InputType input;
			
		ClearInput();

		while (true)
		{
			GlobalEventManager::Get().Notify(displayEvent);

			cin >> input;
			
			if (cin.fail())
			{
				ClearInput();
				GlobalEventManager::Get().Notify(wrongInputEvent);
				continue;
			}

			bool isValidate = true;

			for (const auto& validator : {validators...}) // 입력 조건들을 테스트
			{
				if (!validator.IsValidate(input))
				{
					isValidate = false;
					break;
				}
			}

			if (isValidate) break;
		}
		
		return input;
	}

하지만 이 함수는 문제가 있었는데 바로 이 부분이다.

1
2
3
4
5
6
7
8
for (const auto& validator : {validators...})
{
	if (!validator.IsValidate(input))
	{
		isValidate = false;
		break;
	}
}

GetInput을 테스트 할 때, 검사할 조건이 하나인 선택지는 validator가 하나만 들어갔고 이 때는 문제없이 잘 동작했다.

1
2
3
4
5
6
7
string userName = InputManagerSystem::GetInput(
	"캐릭터의 이름을 입력해주세요.(중간 공백 허용, 최대12자)",
	{},
	NameRangeValidator(1, 12),
	NameSpaceValidator(),
	NoSpecialCharValidator()
);

문제는 validator를 여러개 사용할 때 문제가 발생한다.

이는 가변 인자 템플릿의 동작 방식과 std::initializer_list 때문인데, 만약 validator를 하나만 넣으면 {validators...}{validator}로 변경되며, 단일 요소로 초기화되며 std::initializer_list<ValidatorType>으로 생성되어 정상 동작한다.

반면에, 여러 validator들을 넣은 경우 std::initializer_list안에 여러 타입의 validator가 들어가야하는데, 컴파일러가 여러 타입의 validator들을 하나의 initializer_list안에 넣을 수 없기에 오류가 발생된 것이다.

요약하면, initializer_list에 들어갈 타입을 추론하지 못해서 발생한 문제다.

1
2
3
4
5
6
7
([&] {
	if (!_validators.IsValidate(input))
	{
		isValidate = false;
		Delay(0, 500);
	}
}(), ...);

이 방법은 initializer_list를 사용하지 않고 C++17에서 도입된 fold expression 방식으로 해결한 것이다.

주요 포인트는 람다와 ... 키워드다.

... 키워드는 가변인자인 모든 validator들을 순회한다는 것을 의미한다.

여기서 람다 함수는 & 캡쳐로 함수 내부에서 IsValidate 를 사용할 수 있게 해준다.

이건 하나의 validator에 대한 검사이기에 모든 validator들이 검사를 진행하기 위해 ... 을 사용해 순회하며 검사를 진행하게 만든 것이다.

방금 설명했듯이 C++17에서부터 지원하기 시작한 기능이기에 만약 17 이상 버전이 아니면 예전처럼 재귀함수의 형태로 구현해야한다.

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
31
32
33
34
35
36
37
38
39
40
41
template<typename Validator, typename... Rest>
static bool ValidateInput(const Validator& validator, const InputType& input, bool& isValidate, Rest... rest) {
    if (!validator.IsValidate(input)) {
        isValidate = false;
        Delay(0, 500); // 필요시 딜레이
        return false;  // 유효하지 않으면 바로 종료
    }
    return ValidateInput(rest..., input, isValidate); // 나머지 validator 검사
}

// 재귀 종료 조건
static bool ValidateInput(bool& isValidate) {
    return true; // 모든 검증 통과
}

template<typename InputType, typename... Validators>
static InputType GetInput(const string& title, const vector<string>& options, Validators... validators) {
    auto displayEvent = make_shared<IDisplayMenuEvent>(title, options);
    auto wrongInputEvent = make_shared<IWrongInputEvent>();
    InputType input;

    ClearInput();

    while (true) {
        GlobalEventManager::Get().Notify(displayEvent);
        cin >> input;

        if (cin.fail()) {
            ClearInput();
            GlobalEventManager::Get().Notify(wrongInputEvent);
            continue;
        }

        bool isValidate = true;
        if (ValidateInput(validators..., input, isValidate) && isValidate) {
            break; // 모든 검증 통과 시 반복 종료
        }
    }

    return input;
}

이게 재귀를 이용한 검증 방식이다. 재귀를 사용하면 C++17 보다 낮아도 사용 가능하다.

하지만 재귀를 이용한 방식은 순회를 끝낼 수 있는 함수를 따로 구현해야하는 단점이 존재하기에 fold 보다 이쁘지 않다.

팀업 활동에 대해 배운 점들

  1. 명확하고 구체적인 요청의 중요성 다른 팀원에게 필요한 기능을 요청할 때, 추상적인 설명보다 자세하고 구체적인 요구사항을 전달하는 것이 서로의 시간을 절약할 수 있다는 점을 배웠다.
  • 사례 1: 아이템 정보 출력 함수 요청 아이템 정보를 출력하는 함수를 요청했을 때, Item을 담당하는 팀원분께 Item 클래스의 고유 정보를 출력하는 기능을 원한다고 간단하게 구두로 설명해 전달했다.

하지만 결과물은 Inventory 클래스에 속한 아이템 정보를 출력하는 함수로 구현되었다.

상점 시스템이 vector<shared_ptr<Item>>을 기준으로 설계된 상태였기에 Inventory를 사용하는 방식으로 변경하기 어려웠고, 결국 Item 클래스에 별도로 함수를 추가하며 추가 작업이 발생하게 되었다.

  • 사례 2: 스킬 관리 기능 요청 이후 같은 실수를 반복하지 않기 위해 스킬 담당 팀원에게 좀 더 구체적으로 요청한 결과, 바로 원하는 기능을 받을 수 있었다.

하지만 스킬 매니저의 스킬 데이터를 저장하는 컨테이너가 unordered_map 구조로 설계되어 있어, 내가 필요로 했던 인덱스 기반 접근 방식과 호환되지 않는 문제가 발생했다.
때문에 인덱스를 기준으로 데이터를 받아올 수 있는 기능을 추가로 요청해야만 했다.

위 사례를 통해, 필요한 기능의 세부 사항과 데이터 구조를 포함한 전반적인 요구사항을 명확히 전달하지 않으면 작업 효율성이 떨어지고 수정 작업으로 인해 시간이 지체될 수 있음을 깨달았다.

  1. 모든 기능을 완성한 후 추가 기능과 개선점을 고려하자

위에서 간단하게 설명한 InputManagerSystemInputManagerSystemstatic 함수 GetInput으로 선택지를 출력, 올바른 선택지를 입력할 때 까지 계속 입력을 받는다.

예시를 들어보자면

1
2
3
4
5
6
7
string userName = InputManagerSystem::GetInput(
	"캐릭터의 이름을 입력해주세요.(중간 공백 허용, 최대12자)",
	{},
	NameRangeValidator(1, 12),
	NameSpaceValidator(),
	NoSpecialCharValidator()
);

위 코드는 이름의 길이를 1~12로 제한하고, 공백만 들어가면 안되며, 특수문자를 사용하지 않은 입력을 받을 때 userName에 입력값을 반환 해준다.

이렇게 받은 결과값을 기준으로 분기별 state 변화나 기능을 실행하도록 설계했다.

계속 이렇게 진행하다 보니

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int input = InputManagerSystem::GetInput<int>
(
	"==== 게임 로비 ====",
	{ "1. 게임 시작" , "2. 게임 종료" },
	RangeValidator<int>(1, 2)
);

if (input == 1)
{
	state = make_shared<LobbyCreateState>();
	auto startEvent = make_shared<IGameStartEvent>();
	GlobalEventManager::Get().Notify(startEvent);
}
else
{
	exit(1);
}

이렇게 input을 받아 밑에서 처리하는 부분이 너무 마음에 들지 않았고, 이 부분을 커멘드 패턴으로 만들어 GetInput에서 입력 검증뿐만 아니라 기능 실행도 처리하면 좋겠다고 생각했다.

1
2
3
4
5
6
7
8
int input = InputManagerSystem::GetInput<int>(
    "==== 게임 로비 ====",
    {
        { "1. 게임 시작", make_shared<StateChangeCommand>(LobbyCreateState) },
        { "2. 게임 종료", make_shared<GameExitCommand>() }
    },
    RangeValidator<int>(1, 2)
);

이렇게 각 분기별로 실행할 명령어를 넣어주면, 입력값에 맞는 command를 실행할 것이고, Invoker가 커멘드들을 처리, Redo, Undo, ‘로그에 저장’ 까지 확장할 예정이였다.

하지만 기능마다 Command를 만드면서 많은 헤더 파일을 include 해야했고, 이 Command들이 저장된 헤더 파일을 InputManagerSysteminclude하니 헤더 순환 문제가 발생하게 되었다.

기능을 전부 커맨드로 변경하고 주요 로직을 수정한 뒤였기에, 다시 설계를 되돌리기도 어려운 상황이 되었다.

결국 커맨드 적용을 포기하고 기존 방식으로 복구하려 했지만, 이미 많은 부분에서 수정된 코드로 인해 많은 잔버그가 발생했고, 예정된 QA 일정이 뒤로 밀리는 최악의 상황이 발생했다.

버그는 고쳤지만 코드는 엉망이 되었고, 아직 전투 버그들도 고쳐지지 않은 상황이여서 밤새 작업을 진행해야했다.

결론

이렇게 일주일간의 팀 프로젝트는 어찌저찌 잘 넘어갔지만, 아직 내가 많이 부족하다는 걸 느낀 프로젝트였다.

솔직히 GameSystem의 상태 패턴도 완벽하게 잘 이용하지 못했다고 생각하고, 괜히 욕심만 부려 작업에 차질이 생겼으며, 팀원간의 소통도 부족했다고 생각한다.

첫 팀 프로젝트라 많이 서툴었던 것 같다. 다음엔 팀 프로젝트에선 이 부분들을 개선시켜 좋은 결과를 TIL에 작성할 수 있도록 노력해야겠다.

플레이 영상

썸네일

This post is licensed under CC BY 4.0 by the author.