Coding Feature.

SFML 게임 제작 공부 #5. Game 클래스, 시간 처리. 본문

Game Development/SFML 게임 제작 공부

SFML 게임 제작 공부 #5. Game 클래스, 시간 처리.

codingfeature 2023. 9. 7. 14:26

Huge Reference

 

SFML Game Development By Example | Packt

Create and develop exciting games from start to finish using SFML

www.packtpub.com

 

이전까지 Game Loop을 살펴보고, 게임 창을 관리하는 Window 클래스를 만들어봤다.

이제 Game Loop을 관리하는 Game 클래스를 만들어보고자 한다.

Game Loop은 다음과 같다.

출처 - SFML Game Development By Example By Raimondas Pupius

위의 Loop에서 1. 사용자의 Input에 대해서 처리하고, 2. 게임 요소를 업데이트하며, 3. 게임 요소를 윈도우에 렌더링 하는 과정을 게임이 끝날 때까지 반복하게 된다.

 

위 내용을 담은 Game 클래스를 만들어보자.

우선 헤더를 다음과 같이 작성해보았다.

//Game.hpp
#pragma once
#include <SFML/Graphics.hpp>
#include "Window.hpp"

class Game
{
	Window _window;

public:
	Game();
	~Game();

	void HandleInput();
	void Update();
	void Render();

	Window* GetWindow();
};

아래 public 메서드 HandleInput과 Update, Render는 위의 Game Loop의 내용을 처리하는 메서드이다.

 

클래스 메서드를 우선 다음과 같이 작성해보았다.

//Game.cpp
#include "Game.hpp"

Game::Game() : _window("Game", sf::Vector2u(800, 600)) {
	// 게임 요소 세팅
}

Game::~Game() {
	// 게임 요소 정리
}

void Game::HandleInput() {
	// Input 관리.
}

void Game::Update() {
	_window.Update(); 
	// 게임 내 요소 업데이트 및 이벤트 관리.
}

void Game::Render() {
	// Clear -> Draw -> Display Cycle.
	_window.BeginDraw();	// Clear
	
	// _window.Draw(something);

	_window.EndDraw();		// Display
}

Window* Game::GetWindow() {
	return &_window;
}

우선 Game 클래스의 생성자에서 스프라이트 등 게임 요소들을 초기화하게 된다.

그리고 소멸자에서는 사용된 게임 요소를 정리하게 된다.

 

HandleInput 메서드에서는 사용자의 키 입력(마우스 클릭, 키보드, 게임패드 버튼 등)에 대해서 처리를 하고 Update는 윈도우를 포함한 게임 요소들을 업데이트하게 된다. 그리고 Render 매서드에서는 Window 클래스에서 보았던 Clear -> Draw -> Display 사이클을 실행하게 된다.

 

GetWindow 메서드는 private으로 선언된 윈도우 클래스 객체의 주소값을 리턴하게 된다. 이는 나중에 main.cpp에서 while loop 조건문(isDone)에서 사용하게 된다.

 

이제 Window 클래스와 Game 클래스를 모두 만들어보았으니 클래스를 만들기 이전 main.cpp 코드와 한번 비교해보고 싶어졌다.

// Window, Game 클래스 작성 이전 코드.
#include <SFML/Graphics.hpp>

int main(int argc ,void** argv[]) {
	sf::RenderWindow window(sf::VideoMode(800, 600), "Window"); // 윈도우 초기화.

	while (window.isOpen()) { // 창이 열려 있을 동안..
		sf::Event event;
		while (window.pollEvent(event)) {			// 큐에 저장된 이벤트를 하나씩 꺼냄.
			if (event.type == sf::Event::Closed)	// 이벤트가 창 종료일때
				window.close();						// 창 끄기.
		}
		window.clear(sf::Color::Black); // 화면을 특정 색으로 모두 채워 초기화.

		//window.draw(something)		// 특정 오브젝트를 그리기 위해 버퍼로 옮김.

		window.display();				// Draw 된 모든 오브젝트를 화면에 표시.
	}
	return 0;
}

 

// Window, Game 클래스 작성 이후 코드.
#include <SFML/Graphics.hpp>
#include "Game.hpp"

int main(int argc ,void** argv[]) {
	Game game;

	while (!game.GetWindow()->IsDone()) {
		game.HandleInput();
		game.Update();
		game.Render();
	}

	return 0;
}

확실히 작업하기 편해졌다.

 

 

이전에 delta_time에 대해서 언급을 했는데 이제 이를 짚고 넘어가보기로 했다.

 

앞서 Game Loop을 while 문으로 반복하면서 게임 요소를 업데이트하고 화면에 그린다고 했다. 하지만 게임을 플레이하는 사용자들의 하드웨어 사양은 모두 제각각이기 때문에 같은 시간 내 loop 내 연산을 처리하는 양이 하드웨어 마다 다를 것이다 . 프로그램의 Instruction을 처리하는 CPU의 Clock Cycle 속도가 빠를수록 같은 시간동안 loop 내 내용을 더 많이 처리하기 때문에 게임의 속도도 더 빨라질 것이다.

 

예를 들어 게임 내 세모 모양이 한 업데이트마다 오른쪽으로 10m 움직인다고 하고, A 컴퓨터는 1초당 10번 업데이트하고, B 컴퓨터는 20번 업데이트를 한다고 하자. 게임 내 세모는 A 컴퓨터 화면에는 1초당 10m * 10 Iterations = 100m 로 움직일 것이고, B에서는 1초당 10m * 20 Iterations = 200m 움직일 것이다.

 

위 불일치 문제를 해결하기 위해서 Frame-rate의 최대치를 설정할 수도 있다. SFML에서는 다음과 같은 함수를 제공한다.

sf::RenderWindow window;
window.setFramerateLimit(60); // 1초당 최대 60 프레임 설정.

따라서 1초당 60번 Iteration 이상으로 연산을 하는 빠른 컴퓨터에 대해서는 속도를 제한하므로 모두 일정한 속도로 게임이 동작할 것이다.

 

다만 초당 프레임(FPS)의 최대치만 정했기 때문에 30FPS와 같이 위보다 적은 프레임으로 동작하는 컴퓨터의 경우에는 게임은 더 느리게 동작할 것이다. 따라서 좋은 방법은 아니다.

 

이를 위해 delta_time을 사용하게 된다.

delta_time이란, 이전 Game Loop과 현재 Game Loop 사이의 걸린 시간을 의미한다.

 

위에서 본 예시인 A와 B 컴퓨터에 대해서 살펴보자.

A 컴퓨터는 1초당 10번 Iterate 하므로 delta_time은 1s / 10 Iterations 이다.

B 컴퓨터는 1초당 20번 Iterate 하므로 delta_time은 1s / 20 Iterations 이다.

 

이제 세모가 움직이는 속도 10m/s에다 각 delta_time을 곱한 뒤에 다시 움직이는 거리를 구해본다.

A 컴퓨터 (속도 * delta_time * Iterations)

: ( 10m/s ) * ( 1s/10 ) * ( 10 ) = 10m

B 컴퓨터 (속도 * delta_time * Iterations)

: ( 10m/s ) * ( 1s/20 ) * ( 20 ) = 10m

 

A와 B 컴퓨터 모두 1초당 세모가 움직이는 거리가 같게끔 화면에 렌더링될 것이다.

 

delta_time은 주로 중력이나 물체가 움직이는 속도 등 물리적인 요소를 다룰 때 사용된다.

delta_time을 구하기 위해 SFML에서는 Time과 Clock 클래스를 사용할 수 있다.

// Time
sf::Time t;

// 아래 세 함수는 모두 0.01초를 나타냄.
t = sf::microseconds(10000);
t = sf::milliseconds(10);
t = sf::seconds(0.01f);

// time 에 담긴 시간을 마이크로, 밀리, 또는 그냥 1초 단위로 나타낼 수 있음.
sf::Int64 usec = t.asMicroseconds();
sf::Int32 msec = t.asMilliseconds();
float     sec  = t.asSeconds();


// Clock
sf::Clock clock; // 객체 생성 시 타이머가 시작.

//Clock 객체 생성 때부터, 또는 restart() 이후 부터 걸린 시간을 반환.
t = clock.getElapsedTime();  

// 타이머를 0초부터 재시작함. 단 이때에도 타이머가 시작된 때부터의 걸린 시간을 반환함.
t = clock.restart();

 

이제 앞서 작성한 Game 클래스에 delta_time을 사용할 수 있도록 수정해보고자 한다.

// Game.hpp
#pragma once
#include <SFML/Graphics.hpp>
#include "Window.hpp"

class Game
{
	Window _window;
	sf::Time _elapsed; 		// 추가
	sf::Clock _clock;		// 추가

public:
	Game();
	~Game();

	void HandleInput();
	void Update();
	void Render();

	Window* GetWindow();

	sf::Time GetElapsed();	// 추가
	void RestartTime();	// 추가
};

private 변수에 _elapsed, _clock을 추가하였다.

여기서 _elapsed는 각 Game Loop 당 걸린 시간, 즉 delta_time을 저장하게 된다.

 

// Game.cpp 중 일부.
sf::Time Game::GetElapsed() {
	return _elapsed;
}

void Game::RestartTime() {
	_elapsed = _clock.restart();
}

그리고 Game.cpp 에 위 두 메서드를 정의한다.

GetElapsed는 delta_time을 반환하고, RestartTime은 clock을 리셋시킨다. 앞에서 clock.restart 함수는 타이머를 리셋시키기도 하지만 clock에 저장된 time 값을 반환하기도 한다는 것을 잠깐 언급했었다.

 

그리고 main.cpp의 Game Loop 내용 아래에 다음과 같이 추가한다.

// main.cpp 중 일부.
while (!game.GetWindow()->IsDone()) {
    game.HandleInput();
    game.Update();
    game.Render();
    game.RestartTime(); // 추가
}

따라서 위 Game Loop 마다 생성된 delta_time을 GetElapsed 메서드로 접근할 수 있을 것이다.

위와 같이 변동적인 프레임 간의 시간 간격에 대한 처리를 Variable time-step 이라고 한다.

 

어떤 게임에서는 1초당 코드가 수행되는 횟수를 제한하고 싶을 때가 있을 것이다. 예를 들면 테트리스같이 그리드(Grid) 단위로 딱딱 일정 시간이 지날 때마다 움직이게 하는 시스템(Tick)을 하고 싶을 때처럼 말이다. 

그러면 다음과 같이 코드를 짜면 된다. 

 

예를 들어 1초당 120번 수행 하고 싶다고 하자.

우선 _elapsed에 RestartTime메서드가 호출될때마다 시간을 누적시키도록 RestartTime 메서드를 수정한다.

void Game::RestartTime() {
	_elapsed += _clock.restart();
}

 

그다음 수행 당 시간을 1 / 120으로 놓는다.

float frametime = 1.f / 120.f;

if(_elapsed.asSeconds() >= frametime){
	// 1 초당 120번 수행될 코드 작성.
    
    _elapsed -= sf::seconds(frametime);
}

 

위 코드를 Game 클래스의 Update 메서드 내에 써주면 된다.

_elapsed에 담긴 시간이 frametime을 넘기면 업데이트를 하고 다시 _elapsed를 0으로 만들어버리니 수행되는 간격은 frametime이 될 것이다.

이렇게 일정한 프레임 간의 시간 간격에 대한 처리를 Fixed time-step 이라고 한다.

 

Variable time-step과 Fixed time-step 의 장단점을 찾아보았다.

 

Variable time-step

장점 - 현실과 비슷하게 매끄러운 효과, 코드를 쉽게 짤 수 있음.

단점 - 결과물에 대한 예측이 어려움. 

 

Fixed time-step

장점 - 결과물에 대한 예측이 쉬움.

단점 - v-sync 와 싱크되지 않음. 최대 프레임을 강제함. variable time-step을 전제로 하는 프레임워크와 혼용하기 어려움.

 

(출처 - https://gamedev.stackexchange.com/questions/1589/when-should-i-use-a-fixed-or-variable-time-step)

 

게임 디자인에 맞게 사용하면 좋을 듯 하다. 테트리스나 뱀 게임 같은 시스템이라면 Fixed가 좋을 것이고, 퐁이나 매끄러운 플레이 시스템이 필요한 액션 2D 게임이면 Variable 이 좋을 것이다.

 

 

지금까지 Window, Game, 그리고 게임 내 시간을 다루는 법을 알아보았으니 이를 응용해서 화면에 동그라미가 움직이는 코드를 간단하게 짜보기로 했다.

 

먼저 Circle 이라는 클래스를 만들었다.

// Circle.hpp
#pragma once
#include <SFML/Graphics.hpp>

class Circle
{
	sf::CircleShape _circle;

	float _radius;
	sf::Vector2f _position;
	sf::Vector2f _velocity;
public:
	Circle();
	Circle(float radius, sf::Vector2f position, sf::Vector2f velocity);
	~Circle();
	void InitCircle();
	void UpdateCircle(float delta_time);

	sf::CircleShape& GetCircle();
	float GetRadius();
	sf::Vector2f GetPosition();
	sf::Vector2f GetVelocity();

	void SetVelocity(sf::Vector2f velocity);
	void SetPosition(sf::Vector2f position);
};
// Circle.cpp
#include "Circle.hpp"

Circle::Circle(){
	_radius = 60.f;
	_position = sf::Vector2f(100.f, 500.f);
	_velocity = sf::Vector2f(100.f, 100.f);
}

Circle::Circle(float radius, sf::Vector2f position, sf::Vector2f velocity) {
	_radius = radius;
	_position = position;
	_velocity = velocity;
}

Circle::~Circle() {}

void Circle::InitCircle() {
	_circle.setRadius(_radius);
	_circle.setFillColor(sf::Color::Red);
	_circle.setOrigin(sf::Vector2f(_radius, _radius));
	_circle.setPosition(_position);
}

void Circle::UpdateCircle(float delta_time) {
	_position += _velocity * delta_time;
	_circle.setPosition(_position);
}

sf::CircleShape& Circle::GetCircle() {
	return _circle;
}

float Circle::GetRadius() {
	return _radius;
}

sf::Vector2f Circle::GetPosition() {
	return _position;
}

sf::Vector2f Circle::GetVelocity() {
	return _velocity;
}

void Circle::SetVelocity(sf::Vector2f velocity) {
	_velocity = velocity;
}

void Circle::SetPosition(sf::Vector2f position) {
	_position = position;
}

먼저 생성자에서는 원의 반지름, 위치, 속도를 설정하고, InitCircle 에서는 Circle 클래스의 객체 특성(반지름, 색상, 위치)을 설정한다. 그리고 UpdateCircle 매서드는 인자로 delta_time을 받는데 이는 앞서 구현한 _elapsed 시간을 받아와서 속도에 곱함으로써 하드웨어와 관계 없이 화면에 동일한 속도로 움직이게 하도록 해준다.

 

그리고 Game 클래스를 다음과 같이 수정해보았다.

// Game.hpp
#pragma once
#include <SFML/Graphics.hpp>
#include "Window.hpp"
#include "Circle.hpp"

class Game
{
	Window _window;
	sf::Time _elapsed;
	sf::Clock _clock;

	Circle _circle;

public:
	Game();
	~Game();

	void HandleInput();
	void Update();
	void Render();

	Window* GetWindow();

	sf::Time GetElapsed();
	void RestartTime();
};
// Game.cpp
#include "Game.hpp"
#include "Config.hpp"

Game::Game() : _window("Game", sf::Vector2u(WINDOW_WIDTH, WINDOW_HEIGHT)) {
	// 게임 요소 세팅
	_circle.InitCircle();
	_circle.SetVelocity(sf::Vector2f(300.f, 300.f));
}

Game::~Game() {
	// 게임 요소 정리
}

void Game::HandleInput() {
	// Input 관리.
}

void Game::Update() {
	_window.Update();
	float radius = _circle.GetRadius();
	sf::Vector2f position = _circle.GetPosition();
	sf::Vector2f velocity = _circle.GetVelocity();

	if (position.x - radius < 0) { // 원이 화면 왼쪽에 닿았을 때
    	// position을 화면 왼쪽으로부터 조금 떨어진 곳으로 설정.
		_circle.SetPosition(sf::Vector2f(radius + 0.1f, _circle.GetPosition().y));
		
        // x 좌표계에 대해서 방향 전환.
		velocity.x *= -1;
		_circle.SetVelocity(velocity);
	}

	if (position.x + radius > WINDOW_WIDTH) { // 원이 화면 오른쪽에 닿았을 때
    	// position을 화면 오른쪽으로부터 조금 떨어진 곳으로 설정.
		_circle.SetPosition(sf::Vector2f(WINDOW_WIDTH - radius - 0.1f, _circle.GetPosition().y));
		
        // x 좌표계에 대해서 방향 전환.
		velocity.x *= -1;
		_circle.SetVelocity(velocity);
	}

	if (position.y - radius < 0) { // 원이 화면 위쪽에 닿았을 때
    	// position을 화면 위쪽으로부터 조금 떨어진 곳으로 설정.
		_circle.SetPosition(sf::Vector2f(_circle.GetPosition().x, radius + 0.1f));

		// y 좌표계에 대해서 방향 전환.
		velocity.y *= -1;
		_circle.SetVelocity(velocity);
	}

	if (position.y + radius > WINDOW_HEIGHT) { // 원이 화면 아래쪽에 닿았을 때
    	// position을 화면 아래쪽으로부터 조금 떨어진 곳으로 설정.
		_circle.SetPosition(sf::Vector2f(_circle.GetPosition().x, WINDOW_HEIGHT - radius - 0.1f));

		// y 좌표계에 대해서 방향 전환.
		velocity.y *= -1;
		_circle.SetVelocity(velocity);
	}

	_circle.UpdateCircle(_elapsed.asSeconds());
	
	// 게임 내 요소 업데이트 및 이벤트 관리.
}

void Game::Render() {
	// Clear -> Draw -> DIsplay Cycle.
	_window.BeginDraw();	// Clear
	
	_window.Draw(_circle.GetCircle());

	_window.EndDraw();		// DIsplay
}

Window* Game::GetWindow() {
	return &_window;
}

sf::Time Game::GetElapsed() {
	return _elapsed;
}

void Game::RestartTime() {
	_elapsed = _clock.restart();
}

우선 Circle 클래스 객체 _circle를 하나 private으로 선언한다. 이후 Game 클래스의 생성자에서 InitCircle 매서드를 호출해서 _circle 객체의 특성을 초기화한다. 그 다음 Update 매세드에서 _circle 객체의 움직임을 프로그래밍 하였다. 그리고 _window의 Draw 매서드에서 _circle 객체를 집어넣음으로서 화면에 렌더링하도록 한다.

 

Update 매서드에서는 Circle이 화면 위, 아래, 좌, 우 에 닿았을 때 속도의 방향을 변경하도록 하였다.

결과는 다음과 같았다.

 

 

 

다음에는 Event Manager를 구현해보고자 한다.