这一节我们将会学习到游戏的基本结构,其中的内容包括了:

  • Game类的创建
  • 什么是帧数
  • Player类的创建
  • 事件管理器

Game类

在上一节中,我们用尽可能少的代码创建了一个基本游戏,它包括了:

  • 窗口的创建
  • 图形的绘制
  • 处理用户的输入
  • 将游戏元素绘制到屏幕上

上一节中的实例代码全部写在了 main 函数了,并没有使用到 C++ 的面向对象特性。为了提高我们代码的可复用性,从本节开始,我们将会一步步的使用 OOP 设计思想封装我们的游戏基本元素,搭建出一个游戏框架的雏形。首先是Game类的实现,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Game {
public:
Game(const Game&) = delete;
Game& operator=(const Game&) = delete;
Game();
void run();
private:
void processEvents();
void update();
void render();
sf::RenderWindow _window;
sf::CircleShape _player;
};
int main(int argc,char* argv[]) {
Game game;
game.run();
return 0;
}

Game类中的 =delete 是为了删除C++类默认的拷贝构造函数以及拷贝赋值运算符,有关这方面的介绍可以去了解一下,这里就不做赘述。

可以看到,我们的 main 函数里面不在包含任何循环,游戏的运行只需要调用 Game 类中的 run 方法。对于类中的其它方法 processEvents(),update(),render() ,下面一一介绍:

  • processEvents(): 这里处理用户的输入
  • update(): 更新游戏的状态,计算出下一步
  • render(): 绘制游戏的画面(渲染)

下面,先做简单的实现,为了简单,我们的游戏角色先用一个圆形表示:

1、构造方法的实现

1
2
3
4
5
Game::Game() : _window(sf::VideoMode(800, 600),"02_Game_Archi"),
_player(150) {
_player.setFillColor(sf::Color::Blue);
_player.setPosition(10, 20);
}

2、Game.run() 隐藏了 main 里面的循环体

1
2
3
4
5
6
7
void Game::run() {
while (_window.isOpen()) {
processEvents();
update();
render();
}
}

3、processEvents() 用于处理用户的输入,这里它只是简单地通过轮询从上一帧到现在的事件。例如点击按钮或者按下键盘按键,我们这里就只检查用户按下窗口的关闭按钮以及 ESC 键,然后窗口就会关闭。

1
2
3
4
5
6
7
8
9
void Game::processEvents() {
sf::Event event;
while (_window.pollEvent(event)) {
if ((event.type == sf::Event::Closed)
|| ((event.type == sf::Event::KeyPressed) && (event.key.code == sf::Keyboard::Escape))) {
_window.close();
}
}
}

4、update() 方法更新了我们的游戏逻辑。但是现在我们还没有具体的逻辑实现,后面将会加入。

1
void Game::update() {}

5、render() 负责将游戏画面渲染到屏幕上。首先默认用 sf::Color::Black 清除窗口,然后将我们的游戏对象渲染到窗口,最后在屏幕上显示出来。

1
2
3
4
5
void Game::render() {
_window.clear();
_window.draw(_player);
_window.display();
}

运行效果跟前一节是一样的

http://sshpark.github.io/images/20190404201354.png

FPS

FPS(frames per second): 每秒的帧数,一帧就是通常指一个画面。

由于电脑硬件的不同,同一个游戏在不同电脑的运行速度很可能是不一样的。如果开发者没有注意到这个问题,可能会出现角色穿墙的情况,如 图 1 所示。

http://sshpark.github.io/images/20190528162017.png

为了解决这个问题,通常有三种方案,一是动态时间步长,二是固定时间步长,三是两者一起使用。

1、动态时间步长

由于每台计算机的性能可能不一样,因此处理一帧所花费的时间也是不同的,但是现实世界中时间的流逝是一样的。 因此这种方法更新的主要原理就是计算出上帧到现在所花费的时间,然后将这个时间传入到 update() 函数。

最后的绘制效果如 图 2 所示,可以看到在运行快的计算机上面用户的帧数更高,而运行较慢的计算机上帧数较低,但是完成这个过程所花费的时间是一样的。

http://sshpark.github.io/images/20190529152709.png

这个过程的代码大概长这个样子:

1
2
3
4
5
6
7
8
void Game::run() {
sf::Clock clock;
while (_window.isOpen()) {
processEvents();
update(clock.restart());
render();
}
}

这时候,我们的 update 方法也需要做些改变:

1
void update(sf::Time deltaTime);

deltaTime 参数代表的是上次调用 update 到现在经过了多少时间


2、固定时间步长

自上一次游戏循环过去了一定量的真实时间。 需要为游戏的“当前时间”模拟推进相同长度的时间,以追上玩家的时间。 我们使用一系列的固定时间步长。 代码大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Game::run(int frame_per_seconds) {
sf::Clock clock;
sf::Time timeSinceLastUpdate = sf::Time::Zero;
sf::Time TimePerFrame = sf::seconds(1.f/frame_per_seconds);
while (_window.isOpen()) {
processEvents();
bool repaint = false;
timeSinceLastUpdate += clock.restart();
while (timeSinceLastUpdate > TimePerFrame) {
timeSinceLastUpdate -= TimePerFrame;
repaint = true;
update(TimePerFrame);
}
if(repaint)
render();
}
}

在每帧的开始,根据过去了多少真实的时间,更新timeSinceLastUpdate。 这个变量表明了游戏世界时钟比真实世界落后了多少,然后我们使用一个固定时间步长(fix time step)的内部循环进行追赶。 一旦我们追上真实时间,我们就渲染然后开始新一轮循环。

3、最小时间步长

这个方法把前面两个方法结合。通过确保传入 update() 方法的时间参数不那么高使得游戏需要运行的足够快,也就是我们通过这个方法设置了最小的帧数,但是没有最大的。

就是将传入 update() 方法的时间参数不大于一个值,具体过程如图所示:

http://sshpark.github.io/images/20190605120644.png

具体代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Game::run(int minimum_frame_per_seconds)) {
sf::Clock clock;
sf::Time timeSinceLastUpdate;
sf::Time TimePerFrame = sf::seconds(1.f/minimum_frame_per_seconds);
while (_window.isOpen()) {
processEvents();
timeSinceLastUpdate = clock.restart();
while (timeSinceLastUpdate > TimePerFrame) {
timeSinceLastUpdate -= TimePerFrame;
update(TimePerFrame);
}
update(timeSinceLastUpdate);
render();
}
}

在每一帧中,update() 方法都被调用,但是我们确保了传入参数不会太大。


下一节将会学习如何移动我们的角色😃。