간단하게 상호작용 가능한 TextRPG를 만들어 보자


main이 길어지면 전체 로직을 살펴보기 어려워 지므로 최대한 짧게 짜기 위하여 다음과 같이 구성하였다.
#include "App.h"
int main()
{
srand(unsigned int(time(NULL)));
CApp* App = CApp::GetInst();
int RetValue = 0;
if (App->Init())
{
RetValue = App->Run();
}
App->DestroyInst();
return RetValue;
}
FlowChart 대로 흘러갈 핵심 구성은 App이라는 객체에서 구동이 되도록 작성하였다.
Main에서 호출할 예정이므로 싱글톤 패턴으로 구성하였고 앱의 기능은 단순하다.
처음에는 모든 부분을 생각하지 못해서 초기화 함수와, 핵심 로직을 실행할 함수 하나만 구현하였다.
이 객체 안에서 게임 실행과 종료 그리고 게임 규칙을 정의 하므로 많은 기능을 의도적으로 만들지 않았다.
#pragma once
#include "Global.h"
class CApp
{
public:
CApp();
~CApp();
public:
bool Init();
int Run();
private:
class CEnemy* CreateEnemy();
class CEnemy* m_Enemy;
void PlayAction();
private:
class CCharactor* m_Player;
private:
static CApp* m_Inst;
public:
static CApp* GetInst()
{
if (m_Inst == nullptr)
m_Inst = new CApp;
return m_Inst;
}
static void DestroyInst()
{
if (m_Inst)
{
delete m_Inst;
m_Inst = nullptr;
}
}
};
맴버변수인 Enemy 변수와, 맴버함수인 CreateEnemy와 PlayAction은 로직 안에서 이름 그대로의 기능을 수행한다.
다음은 구현부를 살표보도록 하자
#include "App.h"
#include "Charactor.h"
#include "Grunt.h"
#include "Unique.h"
#include "Boss.h"
CApp* CApp::m_Inst = nullptr;
CApp::CApp() : m_Enemy(nullptr)
{
m_Player = new CCharactor("용사", 10, 10);
std::cout << "일어나세요. 용사여" << std::endl;
m_Player->SetPosition(0);
}
CApp::~CApp()
{
if (m_Enemy)
{
delete m_Enemy;
m_Enemy = nullptr;
}
if (m_Player)
{
delete m_Player;
m_Player = nullptr;
}
}
bool CApp::Init()
{
return true;
}
int CApp::Run()
{
// 게임
bool GameEnd = false;
while (!GameEnd)
{
if(m_Enemy == nullptr)
CreateEnemy();
std::cout << "내 위치 : " << m_Player->GetPosition() << " 적 위치 : " << m_Enemy->GetPosition() << std::endl;;
char Action = NULL;
std::cout << "행동을 선택해 주세요(f: 전진, b: 뒤로가기, a: 공격, h: 힐) : ";
std::cin >> Action;
std::cout << std::endl;
switch (Action)
{
case 'f':
m_Player->SetAction(ACTION_TYPE::Move);
break;
case 'b':
m_Player->SetAction(ACTION_TYPE::Back);
break;
case 'a':
m_Player->SetAction(ACTION_TYPE::Atk);
break;
case 'h':
m_Player->SetAction(ACTION_TYPE::Heal);
break;
default:
GameEnd = true;
break;
}
PlayAction();
if (m_Enemy->isDead())
{
delete m_Enemy;
m_Enemy = nullptr;
m_Player->SetPosition(0);
}
if (m_Player->isDead())
{
GameEnd = true;
}
}
return 0;
}
CEnemy* CApp::CreateEnemy()
{
if (m_Enemy != nullptr)
{
return nullptr;
}
ENEMY_TYPE type = (ENEMY_TYPE)(rand() % (int)(ENEMY_TYPE::Max));
switch (type)
{
case ENEMY_TYPE::Grunt:
m_Enemy = new CGrunt;
break;
case ENEMY_TYPE::Unique:
m_Enemy = new CUnique;
break;
case ENEMY_TYPE::Boss:
m_Enemy = new CBoss;
break;
case ENEMY_TYPE::Max:
return nullptr;
default:
break;
}
return m_Enemy;
}
void CApp::PlayAction()
{
if (m_Player == nullptr)
{
std::cout << "플레이어 없음 오류!" << std::endl;
return;
}
switch (m_Player->GetAction())
{
case ACTION_TYPE::Move:
m_Player->Move(true,m_Enemy);
break;
case ACTION_TYPE::Back:
m_Player->Move(false);
break;
case ACTION_TYPE::Atk:
m_Player->Attack(m_Enemy);
break;
case ACTION_TYPE::Heal:
m_Player->Heal();
break;
case ACTION_TYPE::Max:
break;
default:
break;
}
}
생성자에 보면 Player를 생성하게 되어있다 플레이어 이름은 용사이기에 게임 시작시 "일어나세요, 용사님" 하는건 왕도적인 클리셰가 아닌가 그런 상상을 하며 제일 재밌게 만들었던 부분이다.
소멸자 에서는 동적할당 받을 플레이어와 적의 객체의 메모리 누수를 방지하기 위해 게임을 종료시 무조건 해제하게 만들어 두었다.
플레이어와 적의 거리는 무조건 5이다 따라서 매크로를 이용해 값을 넣어서 관리를 했거나, 맴버변수로 적과 플레이어의 거리를 관리를 했으면 더 단순하게 만들 수 있었을거 같다. 그러나 작업 중에는 미리 생각지 못했고 생각이 난 후에는 전역변수로서 관리하는게 메모리 낭비라 생각해서 굳이 하지 않았다.
이 게임은 사실상 플레이어의 입력값에 따라서 적의 반응이 달라지는 턴제 게임이므로 로직은 플레이어의 반응에 따라서 적의 반응이 결정된다
따라서 플레이어의 위치와 적의 위치를 표시해주는 기능과 플레이어의 행동을 입력하는 기능은 전체적인 루프의 근간이 된다.
상남자 용사는 마왕을 향해 달려가는 여정은 고독하고 처절해야 하기 때문에 플레이어가 생성될 시점에는 적은 생성되지 않는다. 플레이어가 적을 죽이면 바로 다음 적이 기다리고 있어야 한다 그렇기 때문에 루프 시작점에서 적을 생성하고 적이 죽으면 다음번 루프에서 다시 적이 생성된다 적은 플레이어가 죽을 때까지 생성되고 플레이어가 죽으면 게임이 종료된다 그야말로 세상의 종말 낭만 용사 아닌가!
CEnemy* CApp::CreateEnemy()
{
if (m_Enemy != nullptr)
{
return nullptr;
}
ENEMY_TYPE type = (ENEMY_TYPE)(rand() % (int)(ENEMY_TYPE::Max));
switch (type)
{
case ENEMY_TYPE::Grunt:
m_Enemy = new CGrunt;
break;
case ENEMY_TYPE::Unique:
m_Enemy = new CUnique;
break;
case ENEMY_TYPE::Boss:
m_Enemy = new CBoss;
break;
case ENEMY_TYPE::Max:
return nullptr;
default:
break;
}
return m_Enemy;
}
우리의 상남자 용사를 괴롭히는 적들은 랜덤으로 생성되기를 원했다 순차적으로 강한 적이 등장하는건 너무 왕도적인 클리셰이므로 확률에 따라서 처음부터 가장 강한 적이 등장할 수 있는것이다. 그래서 메인에서 난수를 생성할 수 있는 준비를 했던 것이다 rand()함수도 사실 random은 아니라서 다른 난수로직을 사용할까 고민했는데 어차피 정밀한 난수가 중요하지는 않기 때문에 친숙한 rand()함수를 사용하였다.
적의 타입은 enum class를 사용하여 구현한 ENEMY_TYPE이다 혹시라도 다른 적을 더 만들경우 효율적으로 만들기 위해 enum class를 사용하였고 타입에 따라서 모든 몬스터의 부모 객체인 enemy 객체의 포인터 타입에 값을 담을 수 있게 구현하였다.
void CApp::PlayAction()
{
if (m_Player == nullptr)
{
std::cout << "플레이어 없음 오류!" << std::endl;
return;
}
switch (m_Player->GetAction())
{
case ACTION_TYPE::Move:
m_Player->Move(true,m_Enemy);
break;
case ACTION_TYPE::Back:
m_Player->Move(false);
break;
case ACTION_TYPE::Atk:
m_Player->Attack(m_Enemy);
break;
case ACTION_TYPE::Heal:
m_Player->Heal();
break;
case ACTION_TYPE::Max:
break;
default:
break;
}
}
플레이어가 죽었는데 행동을 입력할 경우를 방지하기 위해 최소한의 안전장치는 마련해 놓았다
플레이어의 행동은 어차피 4가지 밖에 되지 않는다 따라서 행동에 대한 함수만 동작시키는 방식으로 구현하였다.
이 객체는 모든 객체의 부모 객체가 되어야한다. 플레이어와 적의 공통 기능을 모아놓았다
맴버변수로는 체력과 데미지 그리고 구분하기 위한 이름과 위치를 가진다.
맴버함수로는 움직이고 치고 받는 기능과 해당 객체가 살아있는지 여부를 판단하는 기능을 가진다.
#pragma once
#include "Global.h"
class CActor
{
public:
CActor();
CActor(std::string Name,int HP, int AD);
~CActor();
public:
bool Init();
void SetPosition(const int Position);
int GetPosition() const;
virtual void Damaged(const int Damage);
virtual void Attack(CActor* Chr);
virtual void Move(bool Front);
virtual void Move(const bool Front, CActor* Actor);
bool isDead();
protected:
int m_HP;
int m_AD;
std::string m_Name;
int m_Position;
};
GetterSetter를 제외하면 치고받는 기능과 살아있는지 확인하는 기능만 있다
void CActor::Damaged(const int Damage)
{
m_HP -= Damage;
m_HP = m_HP > 0 ? m_HP : 0;
std::cout << m_Name << "이가 " << Damage << "피해를 입습니다. 남은 체력 : " << m_HP << std::endl;
std::cout << std::endl;
if (m_HP == 0)
{
std::cout << m_Name << "는 사망하였습니다.\n" << std::endl;
}
}
void CActor::Attack(CActor* Chr)
{
Chr->Damaged(m_AD);
std::cout << Chr->m_Name << "의 남은 체력 : " << Chr->m_HP << std::endl;
}
bool CActor::isDead()
{
if (m_HP == 0)
return true;
else
return false;
}
방어력따윈 없는 상남자들의 전투이기 때문에 데미지가 들어오면 그대로 입고 공격하면 방어무시 데미지를 우겨넣을 수 있다.
CEnemy::CEnemy()
{
SetPosition(5);
}
CBoss::CBoss()
{
m_Name = "강한개체";
m_HP = 20;
m_AD = 4;
MeetMessage();
}
CGrunt::CGrunt()
{
m_Name = "잡몹";
m_HP = 10;
m_AD = 2;
MeetMessage();
}
CUnique::CUnique()
{
m_Name = "조금쌔보이는개체";
m_HP = 15;
m_AD = 3;
MeetMessage();
}
모든 몬스터의 기본적인 기능은 Enemy객체를 상속받고 Enemy객체에서 수행하기에 체력과 이름만 가지게 된다. Boss몹이 너무 강해버리면 용사가 잡몹수준이 되어버리기 떄문에 데미지는 높지 않게 설정되었다.
#pragma once
#include "Actor.h"
class CCharactor :
public CActor
{
public:
CCharactor();
CCharactor(std::string Name, int HP, int AD);
~CCharactor();
public:
void Heal();
virtual void Move(const bool Front) override;
virtual void Move(const bool Front, CActor* Actor) override;
virtual void Attack(CActor* Actor) override;
void SetAction(const ACTION_TYPE Action) { m_Action = Action; }
ACTION_TYPE GetAction() const { return m_Action; }
private:
int m_HealPosion[HEAL_COUNT] = { 10,20,30 };
int m_HealIdx = 0;
ACTION_TYPE m_Action;
};
플레이어의 상태를 enum으로 저장하고 가져오는 기능이 추가되었다
그리고 Heal을 할 수 있는 내부 변수가 추가 되어있다.
void CCharactor::Heal()
{
if (m_HealIdx == HEAL_COUNT)
{
std::cout << "모든 포션을 사용 하였습니다.";
return;
}
m_HP += m_HealPosion[m_HealIdx];
std::cout << m_Name << "는 체력을 " << m_HealPosion[m_HealIdx] << "만큼 회복하였습니다.\n" << std::endl;
m_HealIdx++;
}
void CCharactor::Move(const bool Front)
{
if (!Front)
{
if (m_Position != 0)
m_Position -= 1;
else
std::cout << "더 이상 뒤로 갈 수 없습니다." << std::endl;
}
m_Position += 1;
}
void CCharactor::Move(const bool Front, CActor* Actor)
{
if (!Front)
{
return;
}
int Position = Actor->GetPosition() - m_Position;
// 2칸 정도의 차이
if (Position > 1)
{
m_Position += 1;
Actor->Move(true);
}
// 바로 앞의 몬스터
else if (Position <= 1)
{
std::cout << "감히 나를 쳐.. ? ㅠ" << std::endl;
Actor->Attack(this);
}
}
void CCharactor::Attack(CActor* Actor)
{
int Position = Actor->GetPosition() - m_Position;
// 2칸 정도의 차이
if (Position > 1)
{
std::cout << "아 짧았다.. ? ㅠ" << std::endl;
}
// 바로 앞의 몬스터
else if (Position <= 1)
{
std::cout << "울부짖으며, \n 드루와 !! 드루와 !!" << std::endl;
CActor::Attack(Actor);
Actor->Attack(this);
}
}
계층 구조에서 모든 객체가 Actor 클래스를 상속받기 때문에 다음과 같이 짤 수 있었다
내부적으로 Position을 계산하는 함수를 따로 두었으면 조금더 코드가 간결할 수 있었을거 같다.

구현된 게임 실행화면이다.