Game Programming in C++ - Day 22

이응민·2025년 2월 17일
0

Game Programming in C++

목록 보기
22/22

Day 22 UI 구현하기

폰트 렌더링

트루타입(True Type) 폰트 포맷에서 직선이나 베지어 곡선은 개별 문자(글리프라고도 한다)의 윤곽을 형성한다. SDL TTF 라이브러리 이러한 트루타입 폰트를 로드하고 렌더링하는 기능을 제공한다. 라이브러리를 초기화한 후 진행해야 할 기본 단계는 특정 크기로 폰트를 로드하는 것이다. 그런 다음 SDL TTF는 문자열을 파라미터로 받아 폰트에 있는 글리프를 사용해서 문자열을 텍스쳐로 렌더링한다. 게임은 이 렌더링된 텍스처를 사용해서 다른 2D 스프라이트처럼 텍스처를 그린다. Game 클래스는 지금까지 구축했던 시스템처럼 Game::Initialize에서 SDL TTF를 초기화한다. TTP_Init 함수는 성공적으로 실행되면 0을 반환하고, 에러가 발생하는 경우에는 -1을 반환한다. Game::ShutDown은 라이브러리를 해제하기 위해 TT_Quit 함수를 호출한다.

Load 함수는 특정한 파일의 폰트를 로드하고 Unload 함수는 모든 폰트 데이터를 해제한다. RenderText 함수는 문잗열과 문자열 색상, 그리고 폰트 크기를 파라미터로 받아 텍스처를 생성한다. TTF_OpenFont 함수는 .ttf 파일로부터 특정한 크기로 폰트를 로드하며 해당 크기와 일치하는 TTF_Font 폰트 데이터의 포인터를 반환한다. 게임에서 다양한 크기의 텍스트를 지원하려면 TTF_OpenFont를 여러 번 호출해야 할 것이다. Font::Load 함수에서는 먼저 생성하고자 하는 폰트 크기의 벡터를 정의한 뒤 이 벡터를 순회하면서 TTF_OpenFont를 호출한다. TTF_OpenFont는 폰트 크기마다 한 번씩 호출되며, 생성한 각각의 TTF_Font는 mFontData 맵에 추가된다.

bool Font::Load(const std::string& fileName) {
	// 해당 폰트 사이즈를 지원
	std::vector<int> fontSizes =
	{
		8, 9,
		10, 11, 12, 14, 16, 18,
		20, 22, 24, 26, 28,
		30, 32, 34, 36, 38,
		40, 42, 44, 46, 48,
		52, 56,
		60, 64, 68,
		72
	};
	// 각 크기의 폰트 사이즈마다 한 번씩 TTF_OpenFont 호출
	for (auto& size : fontSizes) {
		TTF_Font* font = TTF_OpenFont(fileName.c_str(), size);
		if (font == nullptr) {
			SDL_Log("Failed to load font %s in size %d", fileName.c_str(), size);
			return false;
		}
		mFontData.emplace(size, font);
	}

	return true;
}

다른 리소스처럼 로드한 폰트는 한 곳에서 관리한다. 폰트의 경우 Game 클래스에 키가 폰트 파일 이름이고, 값이 Font의 포인터인 맵에 추가한다. 그리고 GetFont 함수를 추가한다. GetTexture 유형의 함수처럼 GetFont는 맵상에서 데이터를 찾는다. 데이터가 없으면 폰트 파일을 로드하고 로드한 데이터를 맵에 추가한다.

Texture* Font::RenderText(const std::string& text, const Vector3& color, int pointSize)
{
	Texture* texture = nullptr;

	// SDL_Color 타입으로 색상 변환
	SDL_Color sdlColor;
	sdlColor.r = static_cast<Uint8>(color.x * 255);
	sdlColor.g = static_cast<Uint8>(color.y * 255);
	sdlColor.b = static_cast<Uint8>(color.z * 255);
	sdlColor.a = 255;

	// 해당 크기의 폰트 데이터 검색
	auto iter = mFontData.find(pointSize);
	if (iter != mFontData.end()) {
		TTF_Font* font = iter->second;
		// 텍스트를 그린다(알파값으로 블렌딩됨)
		SDL_Surface* surf = TTF_RenderText_Blended(font, text.c_str(), sdlColor);
		if (surf != nullptr) {
			// SDL surface 객체를 texture 객체로 변환
			texture = new Texture();
			texture->CreateFromSurface(surf);
			SDL_FreeSurface(surf);
		}

	}
	else {
		SDL_Log("Point size %d is unsupported", pointSize);
	}

	return texture;
}

Font::RenderText 함수는 적당한 펀트 크기를 사용해서 주어진 문자열의 텍스처를 생성한다. 먼저 Vector 3 색상을 각 요소의 범위가 0에서 255까지인 SDL_Color로 변환하고 요청한 폰트 크기와 일치라는 TTF_Font 데이터를 찾기 위해 mFontData 맵을 검색한다. 그런 다음 TTF_RenderText_Blended 함수를 호출하는데 이 함수는 TTF_Font*와 렌더링 할 텍스트 문자열, 색상을 파라미터로 받는다. 이는 Blended 첨자는 폰트가 글리프에 알파 명도를 포함해서 그릴 것이라는 것을 끗한다. 그러나 TTF_RenderText_Blended는 SDL_Surface 포인터를 만환하는데 OpenGL은 SDL_Surface를 직접 사용해서 화면에 그릴 수 없다. 그래서 Texture 클래스에 SDL_Surface를 Texture로 변환하는 Texture::CreateFromSurface 함수를 추가한다.

void Texture::CreateFromSurface(SDL_Surface* surface) {
	mWidth = surface->w;
	mHeight = surface->h;

	// GL Texture 생성
	glGenTextures(1, &mTextureID);
	glBindTexture(GL_TEXTURE_2D, mTextureID);
	glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, mWidth, mHeight, 0, GL_BGRA,
		GL_UNSIGNED_BYTE, surface->pixels);

	// linear filtering 사용
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
}

텍스처 생성은 비용이 크므로 UI 코드는 프레임마다 RenderText를 호출하지 않고 텍스트 문자열이 변경돼서 그 결과를 텍스처로 저장하는 경우에만 RenderText를 호출한다. 그런 다음 UI 코드는 프레임마다 렌더링된 텍스트를 포함한 텍스처를 그린다.

UI 스크린

UI 시스템은 HUD나 메뉴 등 여러 경우에 사용될 수 있기 때문에 유연성이 중요하다. UI는 여러 레이어를 포함한 것이다. 예를 들어 게임을 플레이 할 동안 HUD(Head-Up Display)는 체력이나 점수같은 플레이어와 관련된 정보를 보여준다. 플레이어가 게임을 정지하면 게임은 여러 옵션을 가진 메뉴를 보여준다. 정지화면에서 메뉴를 보여줄 때도 HUD가 보이길 원할 수도 있다. 그리고 정지 메뉴 중 하나로 게임 끝내기가 있다고 할 때 플레이어가 이것을 선택하면 진짜로 게임을 끝낼 것인지를 묻는 확인용 다이얼로그 박스가 뜨지만 여전히 그 아래 정지 메뉴나 HUD를 볼 수 있다. 이 과정에서 플레이어는 최상단 레이어하고만 상호작용을 할 수 있다. 그래서 UI는 여러 레이어로 구성된 스택으로 구현하면 좋다. 단일 UI 레이어는 UIScreen 클래스로 구현한다. 정지 메뉴나 HUD는 UIScreen의 서브클래스가 된다. 게임 세계를 그린 후 게임은 상향 순으로 스택상의 모든 UI 스크린을 그린다. 오직 UI 스택 상단의 UIScreen만이 입력 이벤트를 받을 수 있다.

class UIScreen
{
public:
	UIScreen(class Game* game);
	virtual ~UIScreen();
	// UIScreen의 서브클래스는 아래의 함수들을 재정의할 수 있다
	virtual void Update(float deltaTime);
	virtual void Draw(class Shader* shader);
	virtual void ProcessInput(const uint8_t* keys);
	virtual void HandleKeyPress(int key);
    // UI의 활성화 여부를 기록
    enum UIState
	{
		EActive,
		EClosing
	};
    // 상태를 closing으로 변경
	void Close();
	// UIScreen의 상태를 얻는다
	UIState GetState() const { return mState; }
	// 제목 텍스트를 변경
	void SetTitle(const std::string& text, 
    	const Vector3& color = Color::White, int pointSize = 40);
    protected:
	// 텍스처를 그리기 위한 헬퍼 함수
	void DrawTexture(class Shader* shader, class Texture* texture,
		const Vector2& offset = Vector2::Zero,
		float scale = 1.0f)
	class Game* mGame;
	// UI 스크린의 제목 텍스트 렌더링을 위한 정보
	class Font* mFont;
	class Texture* mTitle;
	Vector2 mTitlePos;
	// UI 상태
	UIState mState;
};

위는 기본 UIScreen의 클래스이다. 이 클래스에서는 서브클래스가 재정의할 수 있는 몇가지 가상함수가 있다.

  • Update: UI 스크린의 상태를 갱신한다.
  • Draw: 화면에 그린다.
  • ProcessInput, HandleKeyPress: 여러 타입의 입력을 다루는 입력 처리 함수

또한 UI 스크린의 특정 상태도 추적할 수 있는 UIScreen의 경우에는 오직 스크린이 활성화되었거나 닫쳤거나 2가지 상태만이 필요하다. UI 스크린은 제목도 있으므로 멤버 데이터에는 Font에 대한 포인터, 렌더링된 제목을 포함하는 텍스처, 화면상에서의 제목의 위치 정보를 가진다. 그리고 서브클래스에서는 SetTitle 함수를 호출할 수 있는데 SetTitle 함수는 Font::RenderText 함수를 사용해서 mTitle 멤버에 값을 설정한다. UIScreen은 액터가 아니라서 컴포넌트를 UIScreen에 붙일 수 없다. 그래서 UIScreen은 SpriteComponent의 그리기 기능을 사용할 수 없다. 그래서 대신에 화면상의 특정 위치에 텍스처를 그리는 DrawTexture라는 헬퍼 함수를 사용한다. 모든 UI 스크린은 상황에 따라 이 헬퍼 함수를 호출한다.

UI 스크린 스택

게임에 UI 스크린 스택을 추가하려면 몇몇 위치에서 연결이 필요하다. 먼저 UI 스택을 구성하기위해 UIScreen 포인터 벡터를 Game 클래스에 추가한다. 여기서는 std::stack을 사용하지 않는데 전체 UI 스택을 반복하면서 조회할 필요가 있는데 std::stack은 이 기능을 지원하지 않기 때문이다. 그리고 새로운 UIScreen을 스택에 추가하는 PushUI 함수를 구현한다. 그리고 참조로 스택을 얻어내는 함수도 추가한다.

// 게임을 위한 UI 스택
std::vector<class UIScreen*> mUIStack;
// 참조로 스택을 반환한다
const std::vector<class UIScreen*>& GetUIStack() { return mUIStack; }
// 특정 UIScreen을 스택에 추가한다
void PushUI(class UIScreen* screen);

UIScreen 생성자는 PushUI를 호출하고 this 포인터를 전달한다. 즉 UIScreen(또는 UIScreen의 서브클래스)을 동적으로 할당하면 자동으로 스택에 UIScreen에 추가된다. UI 스크린 갱신은 Update 함수에서 게임 세계 상의 모든 액터가 갱신된 후에 일어난다. 코드는 UI 스크린 스택을 반복하면서 활성화된 UI 스크린의 Update 함수를 호출해서 UI 스크린을 갱신한다.

for (auto ui : mUIStack) {
	if (ui->GetStack() == UIScreen::EActive) {
    ui->Update(deltaTime);
    }
}

모든 UI 스크린을 갱신한 후 상태가 EClosing인 UI스크린이 있다면 모두 삭제한다. UI 스크린을 그리는 작업은 렌더러에서 수행해야한다. Renderer::Draw 함수는 메시 셰이더를 사용해서 3D 메시 컴포넌트를 그린 뒤 스프라이트 셰이더를 사용해서 모든 스프라이트 컴포넌트를 그렸는데 UI는 텍스처로 구성되므로 스프라이트가 사용하는 같은 셰이더를 사용해서 UI를 그려야한다. 그래서 렌더러는 모든 스프라이트 컴포넌트를 그린 후에 Game 객체에서 UI 스택을 얻은 뒤 각각의 UIScreen을 얻는다.

for (auto ui : mGame->GetUIStack()) {
	ui->Draw(mSpriteShader);
}

테스트를 위해 HUD라는 UIScreen의 서브클래스를 생성한다. Game::LoadData에서 HUD의 인스턴스를 저장한다.

mHUD = new HUD(this);

HUD의 생성자는 UIScreen의 생성자를 호출하기 때문에 HUD 객체는 자동으로 UI 스택에 추가된다. UI 스택에서 입력을 다루는 것은 조금 까다롭다. 대부분의 경우 마우스를 클릭하는 특정한 입력 액션은 게임이나 UI에 영향을 주지만 동시에 두 가지에 영향을 미치지는 않는다. 그러므로 입력이 게임에 전달되는지 UI에 전달되는지를 결정해야한다. 이를 구현하기 위해 먼저 3가지의 다른 상태를 갖는 mGameState 변수를 Game에 추가한다.

  • 게임플레이
  • 정지
  • 나가기

게임 플레이 상태에서의 입력 액션은 게임 세계로 전달되며 이는 각 액터로 입력이 전달됨을 뜻한다. 정지 상태에서의 모든 입력 액션은 UI 스택의 상단에 있는 UI 스크린에 전달된다. 따라서 Game::ProcessInput은 각 액터나 또는 UI 스크린의 ProcessInput 함수를 호출해야한다.

if (mGameState == EGameplay)
{
	HandleKeyPress(event.button.button);
}
else if (!mUIStack.empty())
{
	mUIStack.back()->
		HandleKeyPress(event.button.button);
}

이 과정을 확장해서 UI 스택 상단에 있는 UI 스크린이 입력의 처리 유무를 결정하게 만들 수 있다. UI 스크린이 입력의 처리르 원하지 않으면 해당 입력을 스택상에 있는 다음 최상단의 UI로 포워딩한다. 비슷한 맥락으로 SDL_KEYDOWN이나 SDL_MOUSEBUTTON 이벤트가 발생하면 이벤트를 게임세계나 스택 상단의 UI 스크린으로 보낸다(HandleKeyPress 함수를 경유한다). 게임의 상태를 추적하기 위해 mGameState 변수를 추가했으므로 게임 루프 또한 변경이 필요하다. 게임 루프 상태는 게임이 EQuit상태가 아닌 한 루프 상태를 유지한다. 게임 상태가 오직 EGamePlay인 경우에만 게임 세계의 모든 액터에 Update를 호출하도록 게임 루프를 호출한다. 게임은 정지 상태에 있는 동안에는 게임 세계의 오브젝트를 갱신하지 않는다. EGamePlay 상태일때만 Game::UpdateGame에서 액터들을 갱신한다.

정지 메뉴

이제 게임이 정지 상태를 지원하기 때문에 정지 메뉴를 추가한다. 먼저 UIScreen의 서브 클래스인 PauseMenu를 선언한다. PauseMenu의 생성자는 게임 상태를 정지로 설정하고 UI 스크린의 제목 텍스트를 설정한다.

PauseMenu::PauseMenu(Game* game)
	:UIScreen(game)
{
	mGame->SetState(Game::EPaused);
    SetTitle("PAUSED");
}

소멸자에서는 게임플레이로 되돌아가도록 게임 상태를 EGamePlay로 지정한다.

PauseMenu::~PauseMenu() 
{
	~mGame->SetState(Game::EGamePlay);
}

마지막으로 HandleKeyPress 함수에서는 플레이어가 이스케이프 키를 누르면 정지 메뉴를 닫고록 구현한다.

void PauseMenu::HandleKeyPress(int key) {
	UIScreen::HandleKeyPress(key);

	if (key == SDLK_ESCAPE) {
		Close();
	}
}

이 코드는 게임이 PauseMenu 인스턴스를 삭제하게 만든다. 그리고 인스턴스가 삭제되면 PauseMenu의 소멸자가 호출되며 게임 상태를 게임플레이 상대로 바꾼다. PauseMenu 또한 UIScreen의 서브 클래스이므로 생성되면 자동으로 UI 스택에 저장되므로 Game::HandleKeyPress에서 플레이어가 escape 키를 누르면 PauseMenu를 생성한다. 그래서 이제 플레이어가 esc 버튼을 누르면 게임이 PauseMenu 객체를 생성해서 게임이 정지 상태가 되고 액터가 갱신되지 않는다. 그리고 다시 esc를 누르면 객체가 삭제되고 게임 플레이 상태로 복귀하게 된다.

버튼

게임에서 대부분의 메뉴는 플레이어와 상호 작용을 할 수 있는 버튼이 존재한다. 예를 들어 정지 메뉴는 게임을 재개하거나 게임 종료, 옵션 설정, 그리고 기타 작업을 위한 버튼을 갖는다. 여러 UI 스크린은 버튼이 필요하므로 기본 UIScreen 클래스로 버튼을 추가한다. 버튼 캡슐화를 위해 Button 클래스를 선언한다.

class Button
{
public:
	// 생성자는 이름과 폰트, 콜백 함수 그리고 위치와 너비/높이를 파라미터로 받는다
	Button(const std::string& name, class Font* font,
		std::function<void()> onClick,
		const Vector2& pos, const Vector2& dims);
	~Button();
	// 버튼의 이름을 설정하고 텍스처를 생성한다.
    
	void SetName(const std::string& name);
	// 점이 버튼 경계 안에 있다면 true를 반환한다
	bool ContainsPoint(const Vector2& pt) const;
	// 버튼을 클릭했을 경우 호출된다.
	void OnClick();
    
    // getter/setter
    // ...
private:
	std::function<void()> mOnClick;
	std::string mName;
	class Texture* mNameTex;
	class Font* mFont;
	Vector2 mPosition;
	Vector2 mDimensions;
	bool mHighlighted;
};

Button 클래스는 주어진 점이 버튼의 2D 경계 내부에 있으면 true를 반환하는 ContainsPoint 함수를 구현한다. 이 함수는 이전에 충돌 감지에서 점이 경계 내에 있지 않은 4가지 경우에 대한 테스트를 한 것과 같은 접근법을 사용한다. 4가지 경우중 하나라도 true가 아니면 버튼은 점을 포함해야한다.

bool Button::ContainsPoint(const Vector2& pt) const {
	bool no = pt.x < (mPosition.x - mDimensions.x / 2.0f) ||
		pt.x >(mPosition.x + mDimensions.x / 2.0f) ||
		pt.y < (mPosition.y - mDimensions.y / 2.0f) ||
		pt.y >(mPosition.y + mDimensions.y / 2.0f);
	return !no;
}

Button::SetName 함수는 mNameTex에 저장할 버튼 이름 텍스처를 생성하기 위해 RenderText 함수를 사용한다. OnClick 함수는 mOnClick 핸들러가 존재하면 이 핸들러를 호출한다.

void Button::OnClick() {
	if (mOnClick) {
		mOnClick();
	}
}

그리고 버튼을 지원하도록 UIScreen에 멤버 변수를 추가한다.

  • Button 포인터 맵
  • 버튼을 위한 2개의 텍스처

텍스처 중 하나는 버튼이 선택되지 않았을 때의 텍스처이고 다른 하나는 선택되었을때의 버튼이다. 다른 텍스처를 사용하면 플레이어가 버튼이 선택됐는지 아닌지를 구분하는 것이 쉬워진다. 다음으로 새 버튼을 생성하기 쉽도록 헬퍼 함수를 추가한다.

void UIScreen::AddButton(const std::string& name, std::function<void()> onClick) 
{
	Vector2 dims(static_cast<float>(mButtonOn->GetWidth()),
		static_cast<float>(mButtonOn->GetHeight()));
	Button* b = new Button(name, mFont, onClick, mNextButtonPos, dims);
	mButtons.emplace_back(b);
	// 다음 버튼의 위치를 갱신한다
	// 버튼의 높이 값으로 위치 값을 감소시킨 후 패딩 값을 더한다.
	mNextButtonPos.y -= mButtonOff->GetHeight() + 20.0f;
}


mNextButtonPos 변수는 UIScreen이 버튼을 어디에 그릴지를 결정한다. 다음으로 버튼을 그리기 위해 UIScreen::Draw에 코드를 추가한다. 먼저, 각 버튼에서는 버튼 텍스처(버튼이 선택되었는지에 따라 mButtonOn 또는 mButtonOff가 사용된다)를 그린다. 그런 다음 버튼의 텍스트를 그린다.

for (auto b : mButtons) 
{
	// 버튼의 배경 텍스처를 그린다
    Texture* tex = b->GetHighlighted() ? mButtonOn : mButtonOff;
    DrawTexture(shader, tex, b_>GetPosition());
    // 버튼의 텍스트를 그린다
    DrawTexture(shader, b->GetNameTex(), b->GetPosition());
}

플레이어는 버튼을 선택하고 클릭하는데 마우스를 사용한다. 게임은 상대 마우스 모드를 사용하고 있으므로 마우스가 이동하면 카메라가 회던한다. 그래서 플레이어가 마우스를 이동시켜서 버튼을 클릭하려면 상대 마우스 모드를 비활성화해야한다. 비활성화는 PauseMenu 클래스에서 한다. PauseMenu의 생성자에서 상대 마우스 모드를 비활성화화고 소멸자에서 다시 활성화한다.

void UIScreen::ProcessInput(const uint8_t* keys) {
	// UI에 버튼이 있는가?
	if (!mButtons.empty()) {
		// 마우스의 위치를 얻는다
		int x, y;
		SDL_GetMouseState(&x, &y);
		// 화면 중심이 (0, 0)인 좌표로 변환
		Vector2 mousePos(static_cast<float>(x), static_cast<float>(y));
		mousePos.x -= mGame->GetRenderer()->GetScreenWidth() * 0.5f;
		mousePos.y = mGame->GetRenderer()->GetScreenHeight() * 0.5f - mousePos.y;
		// 마우스와 겹치는 버튼을 하이라이트 처리
		for (auto b : mButtons) {
			if (b->ContainsPoint(mousePos)) {
				b->SetHighlighted(true);
			}
			else {
				b->SetHighlighted(false);
			}
		}
	}
}

마우스를 클릭하면 UIScreen::HandleKeyPress가 호출된다. ProcessInput 함수가 이미 마우스를 클릭한 버튼을 찾아냈기에 HandleKeyPress 함수는 간단히 클릭된 버튼의 OnClick 함수를 호출한다. 이제 PauseMenu 클래스에 버튼을 추가한다. 게임을 재개하는 버튼과 게임을 종료하는 버튼을 추가한다.

AddButton("ResumeButton", [this]() {
	Close();
});
AddButton("QuitButton", [this]() {
	mGame->SetState(Game::EQuit);
});

AddButton을 전달하는 람다 표현식은 플레이어가 마우스를 클릭할 때 어떤일이 일어날지 정의한다. 플레이어가 재개 버튼을 클릭하면 정지 메뉴는 닫히며 종료 버튼을 클릭하면 게임은 종료된다. 람다 표현식은 PauseMenu의 멤버에 접근할 수 있도록 this 포인터를 캡처한다.

다이얼로그 박스

게임 종료와 같은 특정 메뉴 액션에는 플레이어에게 확인 다일러로그 박스를 보여주는 것이 좋다. 이렇게 하면 플레이어가 실수로 버튼을 클릭해도 실수를 바로 잡을 여지가 있다. UI 스크린 스택을 이용하면 하나의 UI 스크린에서 다이얼로그 박스를 제어하는 것이 더 쉽다. 그래서 DialogBOx라는 UIScreen의 새 서브 클래스를 작성한다.
DialogBox 생성자는 유저가 OK를 클릭했을때 실행될 함수와 텍스트 문자열을 인자로 받는다.

DialogBox::DialogBox(Game* game, const std::string& text,
	std::function<void()> onOK)
	:UIScreen(game)
{
	// 다이얼로그 박스 위치를 조정
	mBGPos = Vector2(0.0f, 0.0f);
	mTitlePos = Vector2(0.0f, 100.0f);
	mNextButtonPos = Vector2(0.0f, 0.0f);
	// 배경 텍스처 설정
	mBackground = mGame->GetRenderer()->GetTexture("Assets/DialogBG.png");
	SetTitle(text, Vector3::Zero, 30);
	// 버튼 추가
    AddButton("OKButton", [onOK]() {
		onOK();
	});
	AddButton("CancelButton", [this]() {
		Close();
	});
}

먼저 생성자는 제목과 버튼 위치 멤버 변수를 초기화한다. UIScreen 화면 배경 텍스처인 UIScreen의 새 멤버 변수 mBackground를 사용해서 UIScreen::Draw에서 다른 것을 그리기에 앞서 배경을 그린다(배경이 존재한다면). 마지막으로 DialogBox에 OK 버튼과 Cancel 버튼을 추가한다. DialogBox도 UIScreen이므로 DialogBox의 인스턴스를 동적으로 할당해서 UI 스택에 추가한다. 정지 메뉴의 경우 플레이어가 게임 종료를 원하는지 확인하는 다이얼로그 박스를 생성하도록 나가기 버튼을 변경한다.

AddButton("QuitButton", [this]() {
	new DialogBox(mGame, "QuitText",
		[this]() {
			mGame->SetState(Game::EQuit);
	});
});

HUD 요소

HUD의 요소 유형은 게임에 따라 다양하다. HUD 요소에는 히트 포인트를 보여주거나 탄약 수, 점수, 다음 목표를 가리키는 화살표 등이 있다.

조준망(Crosshair) 추가

대부분의 1인칭 게임은 화면 중간에 조준망을 가지고 있다 플레이어가 다른 물체를 겨냥하면 조준망은 다른 텍스처로 외형을 바꾸기도한다. 예를 들어 플레이어가 물체를 집을 수 있다면 조준망은 손으로 바뀐다. 플레이어가 총을 쏘는 게임에서는 조준망의 색깔이 바뀔 수 있다. 여기서는 플레이어가 게임 상의 목표물 중 하나를 겨냥할 때 빨간색으로 변하는 조준망을 구현한다. 이 작업을 위해 HUD에 플레이어가 적을 겨냥하고 있는지를 체크하기 위한 이진값과 여러 텍스처를 가리키는 멤버 변수를 추가한다.

// 십자선 텍스처
class Texture* mCrosshair;
class Texture* mCrosshairEnemy;
// 십자선이 적을 조준하고 있는지
bool mTargetEnemy;

대상이 되는 목표물을 구별할 수 있도록 TargetComponent라는 새 컴포넌트를 생성한다. 그런 다음 HUD의 멤버 변수로 TargetComponent 포인터에 대한 벡터를 선언한다.

std::vector<class TargetComponent*> TargetComps;

그리고 mTargetComps에 타겟을 추가하거나 제거할 수 있는 AddTarget과 RemoveTarget을 추가한다. 이 함수들은 TargetCompnent의 생성자와 소멸자에서 호출된다.

TargetComponent::TargetComponent(Actor* owner)
	:Component(owner)
{
	mOwner->GetGame()->GetHUD()->AddTargetComponent(this);
}

TargetComponent::~TargetComponent() {
	mOwner->GetGame()->GetHUD()->RemoveTargetComponent(this);
}

그리고 아래와 같이 UpdateCrosshair를 구현한다.

void HUD::UpdateCrosshair(float deltaTime) {
	// 일반 커서로 리셋
	mTargetEnemy = false;
	// 선분을 만든다
	const float cAimDist = 5000.0f;
	Vector3 start, dir;
	mGame->GetRenderer()->GetScreenDirection(start, dir);
	LineSegment l(start, start + dir * cAimDist);
	// 선분 캐스트
	PhysWorld::CollisionInfo info; 
	if (mGame->GetPhysWorld()->SegmentCast(l, info)) {
		// 선분 캐스트를 통해 얻은 액터가 타겟 컴포넌트에 소유자와 일치하는지 체크
        for (auto tc : mTargetComps) {
			if (tc->GetOwner() == info.mActor) {
				mTargetEnemy = true;
				break;
			}
		}
	}
}

이 함수는 HUD::Update에서 호출한다. 먼저 mTargetEnemy 값을 false로 초기화한후 GetScreenDirection 함수를 사용해서 세계 상에서 카메라가 현제 바라보는 곳의 정규화된 벡터를 반환한다. 이 벡터와 상수를 사용해서 선분을 만든후 이 선분과 교차하는 최초의 액터를 찾기위해 SegmentCast 함수를 사용한다. 그런 다음 이 액터가 TargetComponent인지 판단한다. 현재로서는 액터가 TargetComponent인지 확인하는 방법은 mTargetComps에 있는 TargetComponent의 소유자가 선분 캐스트를 통해 알아낸 액터와 동일한지 파악하는 것이다. 액터가 어떤 컴포넌트를 갖고 있는지 찾는 방법을 구현하고 나면 이 부분은 좀 더 최적화가 가능하다.
십자선 텍스처를 그리는 법은 간단하다. HUD::Draw에서 mTargetEnemy를 확인한 뒤 화면 중앙에 해당 텍스처를 그린다. 텍스처 크기 조정 값으로 2.0f를 전달한다.

Texture* cross = mTargetEnemy ? mCrosshairEnemy : mCrosshair;
DrawTexture(shader, cross, Vector2::Zero, 2.0f);

레이더 추가

게임은 플레이어 기준 특정 반경 이내의 근처에 있는 적들을 보여주기 위한 레이더가 있다. 레이더 상에 있는 이 적들은 점이나 원처럼 보이는 신호(bilps)로 표현할 수 있다. 레이더를 통해 플레이어는 주변에 적이 있는지 판단한다. 일부 게임에서는 항상 레이더 상에 적들을 보여주는 반면 일부 다른 게임에서는 오직 특정 상태에 있는 적만 보여준다. 하지만 이러한 상태는 적을 보여주는 기본적 접근법의 확장일 뿐이다.
레이더 작동을 구현하는 것은 두파트로 나뉜다. 먼저 레이더 상에 나타나는 액터의 추적이 필요하다. 그리고 프레임마다 플레이어에 상대적인 액터의 위치를 기반으로 레이더 상의 신호를 갱신해야한다. 가장 기초적인 접근법은 레이더 중심으로부터 Vector2 오프셋 형식으로 신호를 나내는 것이다. TargetComponent를 가진 액터는 레이터 범위에 있다면 레이더에 포착되도록 한다. HUD에 몇 가지 멤버 변수를 추가한다.

// 레이더의 상대적인 신호의 2D 오프셋
std::<vector<Vector2> mBlips;
// 레이더의 범위, 반지름 값
float mRadarRange;
float mRadarRadius;

mBlips 벡터는 레이더 중심에서 상대적인 신호들의 2D 오프셋을 기록한다. 레이더를 갱신할때 mBlips 벡터도 갱신된다. 그리고 레이더를 그릴 때는 배경을 먼저 그린 뒤 신호들의 2D 오프셋을 사용해서 신호 텍스처를 그린다. mRadarRange 변수는 세계 공간에서 레이더가 인식할 범위를 나타내고 mRadarRadius 변수는 그려질 2D 레이더의 지름이다. 게임에 50 단위의 범위를 가진 레이더가 있다고 가정하고 플레이어 바로 앞 25 단위에 물체가 있다고 하면 오브젝트의 위치는 3D 상에 있지만 플레이어의 위치와 물체 위치를 모두 화면 레이더 상의 2D 좌표로 변환해야한다. z축이 상향 벡터인 세계 에서는 xy 평면 상으로 플레이어와 게임 오브젝트가 투영된다는 것이다. 즉 레이더는 레이더가 추적하는 플레이어와 오브젝트의 z 요소를 무시한다. 레이더는 세계 공간에서 항상 전방을 가리키며 게임 세계 에서는 +x가 전방이므로 z 요소만으 ㄹ무시하는 것만으로는 충분하지 않다. 레이더에 플레이어와 액터를 제대로 나타내려면 (x, y, z)좌표를 레이더 오프셋 표현인 2D 벡터(y, x)로 나타내야한다.
플레이어와 오브젝트의 위치로부터 각각 2D 레이더 좌표를 얻으면 플레이어에서 오브젝트로의 벡터 a\overrightarrow{a}를 구할 수 있다. a\overrightarrow{a}의 길이는 물체가 레이더 범위 안에 있는 지를 정한다. 레이더가 50 단위의 범위를 가지며 오브젝트가 25단위 앞에 있는 이전 예제의 경우는 a\overrightarrow{a}의 길이가 최대 범위보다 작아. 이는 오브젝트가 레이더의 중심과 가장 자리 사이에 있다는 걸 뜻한다. a\overrightarrow{a}를 레이더의 최대 범위 값으로 나누면 레이더의 반지름에 대한 오브젝트 오프셋의 비율을 구할 수 있다. 그리고 이 값에 레이더의 반지름을 곱해서 새로운 벡터 r\overrightarrow{r}을 구한다.

r\overrightarrow{r} == RadarRaduis(a/RaderRange)RadarRaduis(\overrightarrow{a}/RaderRange)

그런데 레이더는 플레이어가 회전할 때 같이 회전하므로 레이더 상의 위쪽은 계 임세계에서 항상 전방과 일치한다. 이는 레이더 신호의 오프셋으로 r\overrightarrow{r}을 바로 사용할 수 없다는 것을 뜻한다. 그레서 플레이어 전방벡터의 xy 평면 투영과 세계의 전방(x 방향)사이의 각도를 알아야한다. atan2 함수로 각도 θ\theta를 계산한다. 그리고 θ\theta를 이용해서 2D 회전 행렬을 구축한다.

Rotation2D(θ)Rotation2D(\theta) == [cosθsinθsinθcosθ]\begin{bmatrix}cos\theta&sin\theta\\-sin\theta&cos\theta\\ \end{bmatrix}

액터의 최종적인 신호 오프셋은 방금 얻은 행렬로 회전시킨 벡터 r\overrightarrow{r}이다.

BlipOffset=rRotation2D(θ)BlipOffset = \overrightarrow{r}Rotation2D(\theta)

void HUD::UpdateRadar(float deltaTime) {
	// 신호 오프셋 벡터 클리어
	mBlips.clear();

	// 플레이어 위치를 레이더 좌표로 변환 (x축은 전방 벡터, z축은 상향 벡터)
	Vector3 playerPos = mGame->GetPlayer()->GetPosition();
	Vector2 playerPos2D(playerPos.y, playerPos.x);
	// 레이더의 전방은 플레이어의 전방과 같음
	Vector3 playerForward = mGame->GetPlayer()->GetForward();
	Vector2 playerForward2D(playerForward.x, playerForward.y);

	// 레이더를 회전시키기 위해 atan2 함수 사용
	float angle = Math::Atan2(playerForward2D.y, playerForward2D.x);
	// 2D 회전 행렬을 만든다
	Matrix3 rotMat = Matrix3::CreateRotation(angle);

	// 오브젝트 신호(blips)의 위치를 얻는다
	for (auto tc : mTargetComps) {
		Vector3 targetPos = tc->GetOwner()->GetPosition();
		Vector2 actorPos2D(targetPos.y, targetPos.x);

		// 플레이어와 타겟 사이의 벡터를 계산
		Vector2 playerToTarget = actorPos2D - playerPos2D;

		// 타겟이 범위 안에 있는지 확인
		if (playerToTarget.LengthSq() <= (mRadarRange * mRadarRange)) {
			// playerToTarget 좌표를 레이더 화면 중심으로부터의 오프셋으로 변환
			Vector2 blipPos = playerToTarget;
			blipPos *= mRadarRadius / mRadarRange;
			// 신호를 회전시켜 레이더 공간의 최종 좌표로 변환
			blipPos = Vector2::Transform(blipPos, rotMat);
			mBlips.emplace_back(blipPos);
		}
	}
}

레이더를 그리기 위해 먼저 배경을 그리고 각 신호를 순회하면서 신호를 레이더 중심 + 신호의 오프셋 위치에 그린다.

const Vector2 cRadarPos(-390.0f, 275.0f);
DrawTexture(shader, mRadar, cRadarPos, 1.0f);
// 신호들
for (const Vector2& blip : mBlips)
{
	DrawTexture(shader, mBlipTex, cRadarPos + blip, 1.0f);
}

현지화

현지화(localization)은 게임을 한 지역에서 다른 지역으로 상황에 맞게 변환하는 과정을 뜻한다. 현지화 요소중 가장 일반적인 항목은 화면상에 보여주는 텍스트와 텍스트에 맞는 음성이다. 현지화의 책임은 프로그래머에도 할당된다. UI의 경우 게임은 화면상에 여러 로케일(locale)로 텍스트를 쉽게 보여주는 시스템이 필요하다. 그래서 텍스트 간의 변환을 위한 맵이 필요하다.

유니 코드로 작업하기

텍스트를 현지화하는 경우 한가지 문제점은 아스키 문자 각각은 내부적으로는 1바이트로 저장되지만 실제로는 7비트 정보만 가지는데 있다. 문자가 전체 128개라는 것을 의미하고 그 중 52개는 영어 대소문자 나머지는 숫자나 기호이다. 아스키는 다른 언어의 글리프(glyph)를 포함하지 않는다. 이 문제를 해결하기 위해 표준화된 유니코드(Unicode)가 나왔다. 유니코드는 이모지 뿐만 아니라 다양한 언어의 글리프를 포함해서 10만개 이상의 다양한 글리프를 지원한다. 단일 바이트는 256개의 구별된 값 이상을 표현할 수 없기 때문에 유니코드는 여러 바이트를 사용한 인코딩 방법을 고려해야한다. 하나의 문자가 2바이트 또는 4바이트를 포함하는 몇가지 바이트 인코딩 방식이 존재한다. 하지만 가장 인기있는 인코딩 방법은 UTF-8로 문자열의 각 문자를 1에서 4바이트 사이의 다양한 길이를 갖는 문자로 인코딩하는 것이다. UTF-8은 아스키와 완전히 하위 호환된다는 데 있다. 아스키의 바이트 시퀀스가 UTF-8의 바이트 시퀀스와 동일하다. UTF-8의 1바이트인 특수한 UTF-8인 경우는 아스키로 간주한다. 하위 호환성은 UTF-8이 JSON과 같은 파일 포맷 뿐아니라 월드 와이드 웹의 기본 인코딩 방법인 이유다. 하지만 C++에는 유니코드에 대한 내장 기능이 없다. 예를 들어 std::string에 경우 어직 아스키 문자만을 대상으로 한다. 하지만 RapidJSON라이브러리와 SDL TTF는 UTF-8인코딩은 지원한다. std::string에 UTF-8 문자열을 저장한뒤 RapidJSON 라이브러리, SDL TTF와 연동해서 사용하면 추가 코드 필요없이 UTF-8 문자열의 지원이 가능하다.

텍스트 맵 추가하기

Game에 키와 같이 std::string인 std::unordered_map 타입의 mTextMap 멤버 변수를 추가한다. 이 맵은 "QuitText"와 같은 키를 '게임을 종료하겠습니까?' 같은 화면에 표시되는 텍스트와 매핑된다. 이 맵은 JSON 파일로 정의할 수 있다.

{
	"TextMap":{
		"PauseTitle": "PAUSED",
		"ResumeButton": "Resume",
		"QuitButton": "Quit",
		"QuitText": "Do you want to quit?",
		"OKButton": "OK",
		"CancelButton": "Cancel"
	}
}

모든 언어는 그 언어만의 JSON 파일을 가지며 이를 통해 다른 언어간 전환이 쉬워진다. 그리고 gptext 파일을 파싱하고 mTextMap에 내용을 채우는 LoadText 함수를 Game에 추가한다. 비슷하게 게임에서 키가 주어졌을 때 관련 텍스트를 반환하는 GetText 함수도 구현한다. 이 함수는 단지 mTextMap에서 찾기 명령만을 수행한다. 다음으로 Font::RenderText에서 2가지를 수정한다. 첫째, 파라미터로 얻은 텍스트 문자열을 직접 렌더링하지않고 텍스트 맵에서 텍스트 문자열을 찾도록 수정한다.

const std::string& actualText = mGame->GetText(textKey);

그리고 TTF_RenderText_Blended를 호출하는 대신 TTF_RenderUTF-8_Blended를 호출한다.

SDL_Surface* surf = TTF_RenderUTF8_Blended(font, actualText.c_str(), sldColor);

마지막으로 이전에 하드 코딩된 텍스트 문자열을 사용한 일부 코드를 텍스트 키를 사용하는 형태로 변경한다. 예를 들어 정지 메뉴의 제목 키는 이제 "PAUSED"가 아니라 "PauseTitle"이 된다. 이렇게 구축하면 RenderText를 호출할 시 키와 연관된 올바른 텍스트가 맵에서 로드될 것이다.

기타 현지화 이슈

이번 절에서 설명한 코드틑 트루타입 폰트가 모든 글리프를 지원하는 경우에만 정산 작동한다. 그런데 현실적으로 폰트 타일은 글리프의 일부분만을 포함하는 것이 일반적이다. 중국어 같은 일부 언어는 일반적으로 해당 언어 전용의 폰트 파일을 갖는다. 이 문제를 해결하려면 gptext 파일에 폰트 엔트리를 추가해야한다. 또한 mTextMap을 구축할 때 올바른 폰트를 로드해야한다.

다양한 해상도 지원

플레이어는 다양한 해상도에서 게임을 즐기는 것이 일반적이다. 여러 해상도를 지원하는 한 가지 방법은 UI 요소의 경우 특정 픽셀 좌표의 사용이나 절대 좌표의 사용을 피하는 것이다. 예를 들어 절대 좌표를 사용한다면 UI요소를 정확히 좌표 (1900, 1000)에 놓고 이 좌표가 오른쪽 하단 구석과 일치한다고 판단하는 것이다. 이렇게 하는 대신에 좌표가 앵커(anchor)라는 화면의 측정한 부분에 상대적인 상대 좌표(relative coordinate)를 사용하면 좋다. 예를 들어 오른쪽 하단 구석을 기준으로 (-100, -100)에 요소를 놓을 수 있다. 이는 1080p 화면에서 (1820, 980)의 위치에 UI 요소를 표현할 수 있음을 뜻한다. 반면 1650×10501650\times 1050화면에서는 (1580, 950)에 나타난다. 개발자는 화면상의 키 포인트에 상대적인 좌표를 구현할 수 있고 또는 다른 UI 요소에 상대적인 좌표로도 표현할 수 있다. 이 상대 좌표를 구현하려면 앵커 포인트와 UI 요소의 상대 좌쵸가 필요하며 런타임에는 이 상대 좌표를 절대 좌표로 계산하는 동적인 계산이 필요하다. 개선해야할 또 다른 사항은 해상도에 따라 UI 요소의 크기를 조정하는 것이다. UI 요소 크기 조정은 고해상도 화면에서 매우 유용한데 왜냐하면 고해상도 화면에서는 UI는 너무 작아져서 사용하기 힘들기 때문이다. 높은 해상도에서는 UI 크기를 확다해거나 플레이어에게 UI 크기 조정이 가능하도록 옵션을 제공해주면 좋다.

0개의 댓글