[C++] 콘솔 TextRPG

김영웅·2025년 1월 20일

정리

  • TextRPG를 구현했다.
  • 콘솔에 대해 공부했다.
  • 이미지 파일(BMP)을 해체해봤다.


팀프로젝트에서 화면을 맡는 LogManager를 맡았다
원래는 조금의 기능만 만들려고 했지만 다른 분들을 보다 보니 불이 붙어 열심히 하게 됐다.

Git

https://github.com/SeonBab/Sparta_Text_RPG

Layout

화면의 구역을 Layout으로 나누었다.
Layout을 부모 클래스로 만들어 각각 DrawLayout, PlayerStatLayout, LogLayout으로 나누어 화면에 보여지게 만들었다.

구역을 구분하는 데에는 SetConsoleCursorPosition() 함수를 사용해 원하는 구역에 출력할 수 있게 만들었다.

DrawLayout

왼쪽 부분에 있는 그림을 담당하는 부분이다.
.bmp 파일을 읽어 해당 픽셀의 색을 ■에 입혀 출력한다.
.bmp 파일은 투명도를 가질 수 없어, 하얀색(255,255,255)를 출력하지 않게 만들어 다른 이미지 위에도 이미지를 띄울 수 있다.

최초에는 색을 못 입힌다 생각해 흑백으로 만들려고 했지만 요즘은 TrueColor라는 기능으로 색을 입혀 문자를 출력할 수 있다고 해서 색을 입혔다.

for (int y = 0; y < infoHeader.height; y++)
{
	for (int x = 0; x < infoHeader.width; x++)
	{
		LogManager::Get().MoveCursor(prePos.first + x * 2, prePos.second + y);

		auto& pixel = pixelVec[x + y * infoHeader.width];
		if (!(pixel.red == 255 && pixel.green == 255 && pixel.blue == 255))
			//색 입혀서 출력, 하얀색(=투명)은 무시
			cout << "\033[38;2;" << (int)pixel.red << ";" << (int)pixel.green << ";" << (int)pixel.blue << "m" << "■" << "\033[0m";
	}
}

PlayerStatLayout

오른쪽 위 부분에 있는 플레이어의 정보를 담당하는 부분이다.
목표는 실시간 갱신이었지만, 콘솔은 cin 같은 입력을 받을 때 프로세스가 멈춰 Log가 갱신될 때 마다 플레이어의 스탯이 갱신되게 만들었다.

이름, 레벨, 데미지, 돈, 체력, 경험치를 띄워주며, 체력과 경험치는 최대치에 비례해서 ■로 출력하고 일정량 적다면 □를 띄워 Slider처럼 보이게 만들었다.

LogLayout

게임이 진행될 때 나오는 모든 Text는 이 곳에 들어가게 된다.
'\n'도 인식되고 글이 길어지면 자동으로 줄바꿈이 된다.
만약 Log를 모두 출력할 수 없다면 넘어간 만큼 윗부분을 잘라 아랫부분을 출력하게 만들었다.

outputTextVec.clear();
if (outputTextVec.size() == 0)
	outputTextVec.push_back(string());

int lineNumber = 0;
int charNum = 0;

for (int i = 0; i < text.size(); i++)
{
	outputTextVec[lineNumber].push_back(text[i]);

	if (text[i] == '\n' || charNum >= rect.width - 1)
	{
		lineNumber++;
		charNum = 0;

		if (outputTextVec.size() <= lineNumber)
			outputTextVec.push_back(string());
	}
	else
		charNum++;
}

이미지

포토샵에서 비트 이미지를 찍어 JSON으로 포지션 값을 저장해 흑백으로 출력을 할까 했지만
알아보기 힘들기도 하고 수정이 쉽지 않을 것 같아, 다른 라이브러리를 사용하지 않고 기본 기능들로 이미지를 띄어주게 구현하기로 결정했다.

처음엔 png 파일을 해체해 띄어줄려고 했지만, 압축 파일이라 그냥 라이브러리를 쓰는게 차라리 더 간단해 보여서 그나마 제일 쉬울 비압축 파일인 bmp를 사용하기로 결정했다.


void DrawLayout::DrawBMP(const std::string& Filename)
{
	//BMP 읽어오기

	ifstream file(Filename, ios::binary);
	if (!file)
	{
		LogManager::Get() << "파일을 열지 못했습니다.\n경로 : " << Filename << "\n";
		return;
	}

	FBMPHeader header;
	FBMPInfoHeader infoHeader;

	// 헤더 읽기
	file.read(reinterpret_cast<char*>(&header), sizeof(header));
	file.read(reinterpret_cast<char*>(&infoHeader), sizeof(infoHeader));

	if (header.fileType != 0x4D42)
	{
		LogManager::Get() << "BMP 파일이 아닙니다.\n";
		return;
	}

	int rowSize = (infoHeader.bitCount * infoHeader.width + 31) / 32 * 4;
	vector<UCHAR> row(rowSize);

	file.seekg(header.offsetData, ios::beg);

	vector<FRgb> pixelVec(infoHeader.width * infoHeader.height);
	for (int y = infoHeader.height - 1; y >= 0; y--)
	{
		file.read(reinterpret_cast<char*>(row.data()), rowSize);
		for (int x = 0; x < infoHeader.width; ++x)
		{
			UCHAR blue = row[x * 3];
			UCHAR green = row[x * 3 + 1];
			UCHAR red = row[x * 3 + 2];

			//BMP는 그림이 아래부터 기록되있기 때문에 뒤집어 주기 위해서 배열에 넣어줌
			pixelVec[x + y * infoHeader.width] = { red, green, blue };
		}
	}

	//출력
	auto prePos = LogManager::Get().GetCursorPosition();

	for (int y = 0; y < infoHeader.height; y++)
	{
		for (int x = 0; x < infoHeader.width; x++)
		{
			LogManager::Get().MoveCursor(prePos.first + x * 2, prePos.second + y);

			auto& pixel = pixelVec[x + y * infoHeader.width];
			if (!(pixel.red == 255 && pixel.green == 255 && pixel.blue == 255))
				//색 입혀서 출력, 하얀색(=투명)은 무시
				cout << "\033[38;2;" << (int)pixel.red << ";" << (int)pixel.green << ";" << (int)pixel.blue << "m" << "■" << "\033[0m";
		}
	}

	file.close();
}

우선 파일에 첫 부분에는 이미지의 정보가 저장되어 있기 때문에, 이걸 분리해야 한다.
FBMPHeader와 FBMPInfoHeader를 선언해 파일의 정보들을 이곳에 담아야 한다.
여기서 주의할 점은 #pragma pack(push, 1) 이게 없으면 C++은 struct를 생성할 때 안에 있는 변수들 최대 바이트 단위를 기준으로 struct를 생성하기 때문에 byte가 안 맞아 정보를 잘못 받아온다.

#pragma pack(push, 1) 
struct FBMPHeader
{
	USHORT fileType;    // 파일 타입 ("BM")
	UINT fileSize;      // 전체 파일 크기
	USHORT reserved1;   // 예약
	USHORT reserved2;   // 예약
	UINT offsetData;	// 픽셀 데이터의 시작 위치
};

struct FBMPInfoHeader
{
	UINT size;			//헤더 크기
	int width;          // 이미지의 가로 픽셀 수
	int height;         // 이미지의 세로 픽셀 수
	USHORT planes;      // 컬러 플레인 수 (항상 1)
	USHORT bitCount;    // 비트 수 (24비트 BMP는 24)
};
#pragma pack(pop)

정보를 받아왔다면 이제 이 정보로 한 줄씩 이미지를 읽는다.
한 줄을 읽는 데 필요한 byte 수를 rowSize로 설정하고 height 만큼 반복해 이미지의 정보를 받아와 rgb를 저장해둔다.
바로 출력하지 않는 이유는 .bmp 파일이 픽셀 저장을 반대로 해서 그걸 배열에 넣어서 정상적으로 배치한 다음에 출력을 해준다.

출력 때 TrueColor를 이용해 색을 입혀주면 이쁘게 출력된다.

참고로 이미지는 인터넷에서 구한 것과 가지고 있던 에셋을 활용해서 직접 만들었다.



LogManager

  • static LogManager Get()
    클래스가 싱글톤으로 동작하게 하므로 필요하다.
    LogManager의 instance를 반환한다.

  • void Draw(EDraw, int x, int y)
    미리 지정된 이미지를 그린다.
    EDraw로 원하는 이미지를 그릴 수 있으며, x와 y로 위치를 설정할 수 있다.

  • void Append(string, float)
    LogLayout에 원하는 text를 출력한다.
    사용 시 편리하게 만들기 위해 cout처럼 operator<<를 오버라이딩해 LogManager::Get() << 으로 간단하게 호출하게 만들었다.

  • void Pause()
    입력 및 출력을 잠시 멈춘다.
    내부적으론 Sleep()를 이용했다.

  • void Delay(float)
    똑같이 입력 및 출력을 잠시 멈춘다.
    원래는 Append(string, float) 함수의 두번째 매개변수인 float로 조절할 수 있었는데, << 방식을 추가하며 오히려 딜레이를 넣기 어려워져 급하게 추가했다.
    프로젝트가 끝나고나서 생각해보니 cout을 쓸 때 endl 마냥 인스턴스로 둬서
    LogManager::Get() << delay << "Text" 이런식으로 쓸 수 있게 만드는게 더 좋았을 것 같다.
    이 역시 내부적으론 Sleep()를 이용했다.

  • void Clear()
    모든 Layout의 Clear()를 호출해 buffer까지 비운다.

  • void MoveCursor(int, int)
    콘솔창의 커서의 위치를 변경한다.

  • std::pair<int, int> GetCursorPosition()
    현재 콘솔창의 커서의 위치를 반환한다.

  • Layout& GetLayout(ELayout)
    배치되어 있는 Layout을 받아온다.




지금까지 이번 프로젝트에서 내가 맡은 기능들을 정리했다.
개인적으로 콘솔을 이만큼 세세하게 공부한게 오랜만이라 재밌었다.

아쉬운 점은 윈도우 11인 사람은 정각, 반각 문제 때문에 Draw가 제대로 되지 않는 문제가 있었다.
그 문제를 시간이 없어 제대로 해결하지 못한게 아쉽다.

profile
게임 프로그래머

0개의 댓글