Skip to content

Latest commit

 

History

History
688 lines (547 loc) · 32.7 KB

File metadata and controls

688 lines (547 loc) · 32.7 KB

三、夯实基础

虽然从头开始构建自己的库可能是一个有益的过程,但它也可能很快变成一个耗时的过程。这就是为什么大多数专业游戏开发人员依赖一些公共库来加快开发速度,更重要的是,提供一个专门的、高性能的实现。通过连接这些公共库并构建抽象这些库的助手和管理器类,您实际上是在构建最终将为您的工具和游戏引擎提供动力的结构。

在接下来的几节中,我们将介绍这些库如何协同工作,并构建完善结构所需的一些库,为我们在本书剩余部分扩展演示打下坚实的基础。

首先,我们将关注渲染系统,这可以说是任何游戏项目最重要的方面之一。适当的、高性能的实现不仅需要大量的时间,还需要视频驱动程序实现和计算机图形数学方面的专业知识。话虽如此,事实上,自己创建一个定制的低级图形库并不是不可能的,只是如果你的最终目标只是制作视频游戏,不建议过度使用。因此,大多数开发人员不会自己创建一个低级实现,而是转向几个不同的库,为他们提供对图形设备裸机的抽象访问。

对于贯穿本书的例子,我们将使用一些不同的图形 API 来帮助加速这个过程,并帮助提供跨平台的一致性。这些应用编程接口包括:

  • OpenGL(https://www.opengl.org/):开放图形库 ( OGL )是一个开放的跨语言、跨平台的应用编程接口,即 API,用于渲染 2D 和 3D 图形。该应用编程接口提供对图形处理单元 ( 图形处理器)的低级访问。
  • SDL(https://www.libsdl.org/):简易直播媒体层 ( SDL )是一个跨平台的软件开发库,旨在为多媒体硬件组件提供一个低级别的硬件抽象层。虽然它确实提供了自己的渲染机制,但 SDL 可以使用 OGL 来提供完整的 3D 渲染支持。

虽然这些 API 在使用图形硬件时为我们提供了一些抽象,从而节省了我们的时间和精力,但很快就会发现抽象级别不够高。

您将需要另一个抽象层来创建在多个项目中重用这些 API 的有效方式。这就是助手类和管理类的作用。这些类将为我们和其他程序员提供所需的结构和抽象。它们将包装设置和初始化库和硬件所需的所有公共代码。任何项目所需要的代码,无论是游戏性还是类型,都可以封装在这些类中,并将成为引擎的一部分。

在本章中,我们将涵盖以下主题:

  • 构建助手类
  • 用管理器封装
  • 创建接口

构建助手类

在面向对象编程中,助手类用于帮助提供一些功能,而这些功能并不是使用它的应用的主要目标。助手类有多种形式,通常是提供方法或类当前范围之外的功能的类的总称。许多不同的编程模式都使用助手类。在我们的例子中,我们也将大量使用助手类。这里只是一个例子。

让我们看一下创建窗口的一组非常常见的步骤。可以肯定地说,您将创建的大多数游戏都将有某种显示,并且通常在不同的目标上是典型的,在我们的例子中是 Windows 和 macOS。不得不为每个新项目不断地反复输入相同的说明似乎是一种浪费。这种情况非常适合在助手类中抽象出来,最终成为引擎本身的一部分。以下代码是演示代码示例中包含的Window类的标题,您可以在 GitHub 存储库的Chapter03文件夹下找到完整的源代码。

首先,我们有几个必要的包含,SDLglew是一个窗口创建辅助库,最后,包含标准的string类:

#pragma once 
#include <SDL/SDL.h> 
#include <GL/glew.h> 
#include <string> 

接下来,我们有一个enum WindowFlags。我们用它来设置一些位操作,以改变窗口的显示方式;不可见、全屏或无边界。您会注意到,我已经将代码包装在命名空间BookEngine中,正如我在上一章中提到的,这对于防止命名冲突的发生至关重要,一旦我们开始将引擎导入项目,这将非常有帮助:

namespace BookEngine
{ 
  enum WindowFlags //Used for bitwise passing  
  { 
    INVISIBLE = 0x1, 
    FULLSCREEN = 0x2, 
    BORDERLESS = 0x4 
  }; 

现在我们有了Window类本身。我们这个班有几个public方法。首先是默认构造函数和析构函数。即使缺省构造函数和析构函数是空的,也要包含它们,这是一个好主意,如这里所示,尽管有编译器,包括它自己的编译器,但是如果您计划创建类的智能或托管指针,如unique_ptr,这些指定的是需要的:

class Window 
  { 
  public: 
    Window(); 
    ~Window(); 

接下来我们有Create函数,这个函数将是构建或创建窗口的函数。创建窗口需要一些参数,如窗口名称、屏幕宽度和高度,以及我们想要设置的任何标志,参见前面提到的enum:

int Create(std::string windowName, int screenWidth, int 
screenHeight, unsigned int currentFlags);

那么我们有两个Get功能。这些函数将分别返回宽度和高度:

int GetScreenWidth() { return m_screenWidth; } 
int GetScreenHeight() { return m_screenHeight; } 

最后一个公共功能是SwapBuffer功能;这是一个重要的功能,我们将很快深入了解。

void SwapBuffer(); 

为了结束类定义,我们有几个私有变量。第一个是指向一个SDL_Window*类型的指针,命名为足够合适的m_SDL_Window。然后我们有两个 holder 变量来存储屏幕的宽度和高度。这照顾到了新的Window类的定义,正如你所看到的,它在面值上非常简单。它提供了对窗口创建的简单访问,而无需开发人员调用它来了解实现的确切细节,这是面向对象编程的一个方面,并且这个方法非常强大:

private: 
    SDL_Window* m_SDL_Window; 
    int m_screenWidth; 
    int m_screenHeight; 
  }; 
} 

为了获得真正的抽象感,让我们遍历Window类的实现,并真正看到创建窗口本身所需的所有部分:

#include ""Window.h"" 
#include ""Exception.h"" 
#include ""Logger.h"" 
namespace BookEngine 
{ 
  Window::Window() 
  { 
  } 
  Window::~Window() 
  { 
  } 

Window.cpp文件从需要开始包括,当然我们需要包括Window.h,但是你也会注意到我们也需要包括Exception.hLogger.h头文件。这是另外两个帮助文件,创建它们是为了抽象它们自己的进程。Exception.h文件是一个助手类,提供了一个易于使用的异常处理系统。Logger.h文件是一个助手类,顾名思义,它提供了一个易于使用的日志记录系统。随意翻看每一个;代码位于 GitHub 代码库的Chapter03文件夹中。

在 includes 之后,我们再次将代码包装在BookEngine命名空间中,并为类提供空的构造函数和析构函数。

Create功能首先实现。在这个函数中有创建实际窗口所需的步骤。它开始设置窗口显示flags,使用一系列if语句为窗口创建选项的按位表示。我们使用之前创建的enum来让我们人类更容易阅读。

  int Window::Create(std::string windowName, int screenWidth, int 
 screenHeight, unsigned int currentFlags) 
  { 
    Uint32 flags = SDL_WINDOW_OPENGL; 
    if (currentFlags & INVISIBLE) 
    { 
      flags |= SDL_WINDOW_HIDDEN; 
    } 
    if (currentFlags & FULLSCREEN) 
    { 
      flags |= SDL_WINDOW_FULLSCREEN_DESKTOP; 
    } 
    if (currentFlags & BORDERLESS) 
    { 
      flags |= SDL_WINDOW_BORDERLESS; 
    } 

在我们设置了窗口的显示选项之后,我们继续使用 SDL 库来创建窗口。正如我之前提到的,我们使用像 SDL 这样的库来帮助我们简化这种结构的创建。我们开始用try语句包装这些函数调用;这将允许我们捕捉任何问题,并将其传递给我们的Exception类,我们很快就会看到:

try { 
      //Open an SDL window 
      m_SDL_Window = SDL_CreateWindow(windowName.c_str(), 
              SDL_WINDOWPOS_CENTERED, 
              SDL_WINDOWPOS_CENTERED, 
              screenWidth, 
              screenHeight, 
              flags); 

第一行使用传入的变量将私有成员变量m_SDL_Window设置为新创建的窗口,用于名称、宽度、高度和任何标志。我们还通过将SDL_WINDOWPOS_CENTERED定义传递给函数,将默认窗口的种子点设置为屏幕中心:

if (m_SDL_Window == nullptr) 
    throw Exception(""SDL Window could not be created!""); 

在我们尝试创建窗口之后,最好检查一下这个过程是否成功。我们用一个简单的 if 语句来完成,并检查变量m_SDL_Window是否设置为nullptr;如果是,我们扔一个Exception。我们经过Exception那根绳子""SDL Window could not be created!""。这是我们可以在 catch 语句中打印出来的错误消息。稍后,我们将看到一个这样的例子。使用这种方法,我们为自己提供了一些简单的错误检查。

一旦我们创建了窗口并完成了一些错误检查,我们就可以继续设置一些其他组件了。其中一个组件是 OGL 库,它需要设置所谓的上下文。OGL 上下文可以被认为是描述与应用呈现相关的所有细节的一组状态。在绘制任何图形之前,必须设置 OGL 上下文。

一个问题是,创建一个窗口和一个 OGL 上下文并不是 OGL 规范本身的一部分。这意味着每个平台可以以不同的方式处理这个问题。对我们来说幸运的是,SDL API 再次为我们抽象了繁重的工作,并允许我们在一行代码中完成这一切。我们创建了一个名为glContextSDL_GLContext变量。然后我们将glContext赋给SDL_GL_CreateContext函数的返回值,该函数接受一个参数,也就是我们之前创建的SDL_Window。在此之后,我们当然会做一个简单的检查,以确保一切都按计划进行,就像我们之前创建窗口时所做的那样:

//Set up our OpenGL context 
SDL_GLContext glContext = SDL_GL_CreateContext(m_SDL_Window); 
   if (glContext == nullptr) 
     throw Exception(""SDL_GL context could not be created!""); 

我们需要初始化的下一个组件是GLEW。这又一次被我们抽象为一个简单的命令,glewInit()。该函数不接受参数,但返回一个错误状态代码。我们可以使用这个状态代码来执行类似的错误检查,就像我们对窗口和 OGL 所做的那样。这次改为对照定义的GLEW_OK进行检查。如果评估结果不是GLEW_OK,我们扔出一个Exception稍后被抓。

//Set up GLEW (optional) 
GLenum error = glewInit(); 
  if (error != GLEW_OK) 
    throw Exception(""Could not initialize glew!""); 

现在,所需的组件已经初始化,现在是记录运行应用的设备的一些信息的好时机。您可以记录关于设备的各种数据,这些数据可以在试图跟踪模糊问题时提供有价值的见解。在这种情况下,我正在系统中轮询运行应用的 OGL 版本,然后使用Logger助手类将其打印到运行时文本文件中:

//print some log info 
std::string versionNumber = (const 
char*)glGetString(GL_VERSION);      
WriteLog(LogType::RUN, ""*** OpenGL Version: "" + 
versionNumber + ""***"");

现在我们设置清晰的颜色或用于刷新显卡的颜色。在这种情况下,它将是我们应用的背景色。glClearColor函数取四个浮点值,代表从0.01.0范围内的红、绿、蓝和阿尔法值。Alpha 是透明值,其中1.0f不透明,0.0f完全透明:

//Set the background color to blue 
glClearColor(0.0f, 0.0f, 1.0f, 1.0f); 

下一行设置VSYNC值,这是一种尝试将应用的帧率与物理显示器的帧率相匹配的机制。SDL_GL_SetSwapInterval函数接受一个参数,一个可以是开的1或关的0的整数:

//Enable VSYNC 
SDL_GL_SetSwapInterval(1);

组成try语句块的最后两行,启用混合并设置执行 alpha 混合时使用的方法。有关这些特定功能的更多信息,请查看 OGL 发展文档:

 //Enable alpha blend 
 glEnable(GL_BLEND); 
 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); 
} 

在我们的try区块之后,我们现在必须包含一个或多个catch区块。这是我们将捕获任何已经发生的抛出错误的地方。在我们的例子中,我们只需要抓住所有的异常。我们使用Logger助手类中的WriteLog函数将异常消息e.reason添加到错误日志文本文件中。这是一个非常基本的情况,但是当然,我们可以在这里做得更多,如果可能的话,甚至可能从错误中恢复过来:

catch (Exception e) 
 { 
    //Write Log 
    WriteLog(LogType::ERROR, e.reason); 
  } 
  } 

最后,Window.cpp文件中的最后一个函数是SwapBuffer函数。在不太深入实现的情况下,交换缓冲区所做的是交换 GPU 的前后缓冲区。简而言之,这使得屏幕绘制更加流畅。这是一个复杂的过程,再次被 SDL 库抽象化。我们的SwapBuffer函数再次抽象了这个过程,因此当我们想要交换缓冲区时,我们只需调用SwapBuffer,而不必调用 SDL 函数并指定窗口,这正是函数中所做的:

void Window::SwapBuffer() 
 { 
   SDL_GL_SwapWindow(m_SDL_Window); 
 } 
} 

如您所见,构建这些助手函数可以让开发和迭代过程变得更快、更简单。接下来,我们将看看另一种编程方法,它再次从开发人员手中抽象出繁重的工作,并提供一种对流程的控制形式,即管理系统。

用管理器封装

在处理输入和音频系统等复杂系统时,直接控制和检查系统的每个状态和其他内部组件很容易变得繁琐和笨拙。这就是经理编程模式的想法。使用抽象和多态性,我们可以创建允许我们模块化和简化与这些系统交互的类。管理器类可以在许多不同的用例中找到。本质上,如果您看到需要对某个系统进行结构化控制,这可能是经理类的候选人。接下来是我为本书中的示例代码创建的一个管理器类的示例。随着我们的继续,你会看到更多。

离开渲染系统一秒钟,让我们看看任何游戏都需要执行的一个非常常见的任务,处理输入。因为每一个游戏都需要某种形式的输入,所以只有将处理这种输入的代码转移到一个我们可以反复使用的类中才有意义。让我们看看InputManager类,从头文件开始:

#pragma once 
#include <unordered_map> 
#include <glm/glm.hpp> 
namespace BookEngine { 
  class InputManager 
  { 
  public: 
    InputManager(); 
    ~InputManager(); 

InputManager类和其他类一样开始,我们有需要的包含,并且我们再次将该类包装在BookEngine名称空间中,以确保说服力和安全性。还定义了标准构造函数和析构函数。

接下来,我们还有一些公共功能。首先是Update功能,不出意外会更新输入系统。然后我们有KeyPressKeyReleased函数,这些函数都取一个对应于键盘按键的整数值。当key分别被按下或释放时,下列功能将会启动:

void Update(); 
void KeyPress(unsigned int keyID);  
void KeyRelease(unsigned int keyID);

KeyPressKeyRelease功能之后,我们还有两个关键的相关功能isKeyDownisKeyPressed。像KeyPressKeyRelease功能一样,isKeyDownisKeyPressed功能采用对应于键盘按键的整数值。值得注意的区别是,这些函数根据键的状态返回一个布尔值。我们将在接下来的实现文件中看到更多相关信息:

 bool isKeyDown(unsigned int keyID); //Returns true if key is 
 held    bool isKeyPressed(unsigned int keyID); //Returns true if key 
 was pressed this update

InputManager类中的最后两个公共函数是SetMouseCoordsGetMouseCoords,它们完全按照名称建议的那样工作,分别设置或获取鼠标坐标。

void SetMouseCoords(float x, float y); 
glm::vec2 GetMouseCoords() const { return m_mouseCoords; }; 

接下来是私有成员和函数,我们声明了一些变量来存储一些关于键和鼠标的信息。首先,我们有一个存储按键被按下与否状态的布尔值。接下来,我们有两个无序地图,将存储当前keymap和以前的关键地图。我们存储的最后一个值是鼠标坐标。我们从另一个辅助库中得到一个vec2构造,OpenGL 数学 ( GLM )。我们使用这个vec2,它只是一个二维向量,来存储鼠标光标的 xy 坐标值,因为它在 2D 平面上,即屏幕上。如果你正在寻找向量和笛卡尔坐标系的复习资料,我强烈推荐约翰·弗林特博士的游戏开发人员数学概念入门书:

private: 
   bool WasKeyDown(unsigned int keyID); 
std::unordered_map<unsigned int, bool> m_keyMap; 
   std::unordered_map<unsigned int, bool> m_previousKeyMap; 
   glm::vec2 m_mouseCoords;
}; 

现在我们来看看实现,InputManager.cpp文件。

我们再次从 includes 和名称空间包装器开始。然后我们有了构造函数和析构函数。这里需要注意的重点是构造函数中m_mouseCoords0.0f的设置:

namespace BookEngine 
{ 
  InputManager::InputManager() : m_mouseCoords(0.0f) 
  { 
  } 
  InputManager::~InputManager() 
  { 
  } 

接下来是Update功能。这是一个简单的更新,我们逐步通过keyMap中的每个键,并复制到以前的keyMap持有人

m_previousKeyMap:

void InputManager::Update() 
 { 
   for (auto& iter : m_keyMap) 
   { 
     m_previousKeyMap[iter.first] = iter.second;  
   } 
 } 

下一个功能是KeyPress功能。在这个函数中,我们使用关联数组的技巧来测试并插入与传入的 ID 相匹配的按键。诀窍在于,如果位于keyID索引的索引处的项目不存在,它将被自动创建:

void InputManager::KeyPress(unsigned int keyID) 
 { 
   m_keyMap[keyID] = true; 
 } 
. We do the same for the KeyRelease function below. 
 void InputManager::KeyRelease(unsigned int keyID) 
 { 
   m_keyMap[keyID] = false; 
  } 

KeyRelease功能与KeyPressed功能的设置相同,只是我们将keyID索引处的keyMap项目设置为假:

bool InputManager::isKeyDown(unsigned int keyID) 
 { 
   auto key = m_keyMap.find(keyID); 
   if (key != m_keyMap.end()) 
     return key->second;   // Found the key 
   return false; 
 }

KeyPressKeyRelease功能之后,我们实现isKeyDownisKeyPressed功能。首先是isKeydown功能;这里我们要测试一个键是否已经被按下。在这种情况下,我们采用不同于KeyPressKeyRelease函数的方法来测试密钥,并避免关联数组技巧。这是因为如果密钥尚不存在,我们不想创建它,所以我们手动创建它:

bool InputManager::isKeyPressed(unsigned int keyID) 
 { 
   if(isKeyDown(keyID) && !m_wasKeyDown(keyID)) 
   { 
     return true; 
   } 
   return false; 
 } 

isKeyPressed功能相当简单。在这里,我们通过使用isKeyDown功能来测试是否按下了与传入的标识匹配的键,并且还通过将该标识传递给m_wasKeyDown来测试该键是否没有被按下。如果这两个条件都满足,我们返回真,否则返回假。接下来,我们有WasKeyDown函数,很像isKeyDown函数,我们进行手动查找以避免使用关联数组技巧意外创建对象:

bool InputManager::WasKeyDown(unsigned int keyID) 
 { 
   auto key = m_previousKeyMap.find(keyID); 
   if (key != m_previousKeyMap.end()) 
     return key->second;   // Found the key 
   return false; 
} 

InputManager中的最后一个功能是SetMouseCoords。这是一个非常简单的Set函数,它接受传入的浮点数,并将它们分配给二维向量m_mouseCoordsxy成员:

void InputManager::SetMouseCoords(float x, float y) 
 { 
   m_mouseCoords.x = x; 
   m_mouseCoords.y = y; 
 } 
}

创建接口

有时,您会面临这样一种情况,即您需要描述功能并提供对类的一般行为的访问,而不需要提交特定的实现。这就是接口或抽象类的想法开始发挥作用的地方。使用接口提供了一个简单的基类,其他类可以从中继承,而不必担心内在的细节。构建强接口可以通过提供一个标准类来进行交互,从而实现快速开发。虽然从理论上讲,接口可以由任何类创建,但是更常见的是在代码被重用的情况下使用它们。以下是为该书的示例代码创建的示例接口,该代码创建了一个游戏主类的接口。

让我们看一下存储库中示例代码的界面。这个界面将提供对游戏核心组件的访问。我已经将这个类命名为IGame,使用前缀I将这个类标识为一个接口。以下是从定义文件IGame.h开始的实现。

首先,我们拥有所需的 includes 和名称空间包装器。您会注意到,我们包含的文件是我们刚刚创建的一些文件。这是抽象延续的一个主要例子。我们使用这些构建块继续构建允许这种无缝抽象的结构:

#pragma once 
#include <memory> 
#include ""BookEngine.h"" 
#include ""Window.h"" 
#include ""InputManager.h"" 
#include ""ScreenList.h"" 
namespace BookEngine 
{ 

接下来,我们有一个前进宣言。这个声明是为屏幕创建的另一个接口。此接口及其支持的帮助器类的完整源代码可在代码存储库中找到。类IScreen;像这样使用正向声明在 C++ 中是一种常见的做法。

If the definition file only requires the simple definition of a class, not adding the header for that class will speed up compile times.

继续讨论公共成员和函数,我们从构造函数和析构函数开始。你会注意到这个析构函数在这种情况下是虚拟的。我们将析构函数设置为虚拟的,以允许我们通过指针在派生类的实例上调用 delete。当我们希望我们的界面也能直接处理一些清理工作时,这就很方便了:

class IGame 
  { 
  public: 
    IGame(); 
    virtual ~IGame(); 

接下来我们有Run函数和ExitGame函数的声明。

    void Run(); 
    void ExitGame(); 

然后我们有一些纯虚函数,OnInitOnExitAddScreens。纯虚函数是必须被继承类重写的函数。通过在定义的末尾添加=0;,我们告诉编译器这些函数纯粹是虚拟的。

在设计接口时,在定义哪些函数必须被覆盖时,保持谨慎是很重要的。同样非常重要的是要注意,拥有纯虚函数会隐式地使为其定义的类变得抽象。因此,抽象类不能直接实例化,任何派生类都需要实现所有继承的纯虚函数。如果没有,它们也会变得抽象:

    virtual void OnInit() = 0; 
    virtual void OnExit() = 0; 
    virtual void AddScreens() = 0; 

在我们的纯虚函数声明之后,我们有一个函数OnSDLEvent,我们用它来连接到 SDL 事件系统。这为我们的输入和其他事件驱动系统提供了支持:

void OnSDLEvent(SDL_Event& event);

IGame接口类中的公共函数是一个简单的辅助函数GetFPS,返回当前的fps。注意const修饰符,它们很快识别出该函数不会以任何方式修改变量值:

const float GetFPS() const { return m_fps; } 

在我们受保护的空间中,我们从一些函数声明开始。首先是Init或初始化功能。这将是处理大部分设置的功能。然后我们有两个虚函数UpdateDraw

像纯虚函数一样,虚函数是可以被派生类的实现重写的函数。与纯虚函数不同,虚函数在默认情况下不会使类抽象,也不必被重写。虚拟和纯虚拟功能是多态设计的基石。随着您继续发展旅程,您将很快看到它们的好处:

protected: 
   bool Init(); 
   virtual void Update(); 
   virtual void Draw(); 

为了关闭IGame定义文件,我们有几个成员来存放不同的对象和值。我不打算一行一行地讨论这些问题,因为我觉得它们不言自明:

    std::unique_ptr<ScreenList> m_screenList = nullptr; 
    IGameScreen* m_currentScreen = nullptr; 
    Window m_window; 
    InputManager m_inputManager; 
    bool m_isRunning = false; 
    float m_fps = 0.0f; 
  }; 
} 

现在我们已经了解了接口类的定义,让我们快速浏览一下实现。以下是IGame.cpp文件。为了节省时间和空间,我将突出重点。在大多数情况下,代码是不言自明的,并且为了更清楚起见,位于存储库中的源代码得到了很好的注释:

#include ""IGame.h"" 
#include ""IScreen.h"" 
#include ""ScreenList.h"" 
#include ""Timing.h"" 
namespace BookEngine 
{ 
  IGame::IGame() 
  { 
    m_screenList = std::make_unique<ScreenList>(this); 
  } 

  IGame::~IGame() 
  { 
  } 

我们的实现从构造函数和析构函数开始。构造器很简单,它唯一的工作就是使用这个IGame对象作为传入的参数来添加一个新屏幕的唯一指针。有关屏幕创建的更多信息,请参见IScreen类。接下来,我们来实现Run功能。这个函数在被调用时会启动发动机。在函数内部,我们做了一个快速检查,以确保我们已经初始化了我们的对象。然后,我们使用另一个助手类fpsLimiter,来SetMaxFPS我们的游戏可以运行。之后,我们将isRunning布尔值设置为true,然后用它来控制游戏循环:

void IGame::Run() 
  { 
    if (!Init()) 
      return; 
    FPSLimiter fpsLimiter; 
    fpsLimiter.SetMaxFPS(60.0f); 
    m_isRunning = true; 

接下来是游戏循环。在游戏循环中,我们做一些简单的调用。首先,我们启动fpsLimiter。然后我们调用InputManager上的更新函数。

It is a good idea always to check input before doing other updates or drawing since their calculations are sure to use the new input values.

在我们更新InputManager之后,我们递归调用我们的UpdateDraw类,我们很快就会看到。我们通过结束fpsLimiter函数并在Window对象上调用SwapBuffer来结束循环:

///Game Loop 
    while (m_isRunning) 
    { 
      fpsLimiter.Begin(); 
      m_inputManager.Update(); 
      Update(); 
      Draw(); 
      m_fps = fpsLimiter.End(); 
      m_window.SwapBuffer(); 
    } 
  } 

我们实现的下一个功能是ExitGame功能。最终,这将是游戏最终退出时调用的函数。我们关闭、销毁并释放屏幕列表创建的任何内存,并将isRunning布尔设置为false,这将结束循环:

void IGame::ExitGame() 
 { 
   m_currentScreen->OnExit(); 
   if (m_screenList) 
   { 
     m_screenList->Destroy(); 
     m_screenList.reset(); //Free memory 
   } 
   m_isRunning = false; 
 } 

接下来是Init功能。该函数将初始化所有内部对象设置,并在连接的系统上调用初始化。同样,这是面向对象编程和多态的一个很好的例子。以这种方式处理初始化允许级联效应,保持代码模块化,并且更容易修改:

  bool IGame::Init() 
  { 
    BookEngine::Init(); 
    SDL_GL_SetAttribute(SDL_GL_ACCELERATED_VISUAL, 1); 
    m_window.Create(""BookEngine"", 1024, 780, 0); 
    OnInit(); 
    AddScreens(); 
    m_currentScreen = m_screenList->GetCurrentScreen(); 
    m_currentScreen->OnEntry();     
    m_currentScreen->Run(); 
    return true; 
}

接下来,我们有Update功能。在这个Update函数中,我们创建了一个结构,允许我们基于当前屏幕所处的状态执行某些代码。我们使用一个简单的开关案例方法来实现这一点,以ScreenState类型的枚举元素作为案例。这个设置被认为是一个简单的有限状态机,是一个非常强大的设计方法,在整个游戏开发中使用。在本书的示例中,您肯定会再次看到这个弹出窗口:

void IGame::Update() 
  { 
    if (m_currentScreen) 
    { 
      switch (m_currentScreen->GetScreenState()) 
      { 
      case ScreenState::RUNNING: 
        m_currentScreen->Update(); 
        break; 
      case ScreenState::CHANGE_NEXT: 
        m_currentScreen->OnExit(); 
        m_currentScreen = m_screenList->MoveToNextScreen(); 
        if (m_currentScreen) 
        { 
          m_currentScreen->Run(); 
          m_currentScreen->OnEntry(); 
        } 
        break; 
      case ScreenState::CHANGE_PREVIOUS: 
        m_currentScreen->OnExit(); 
        m_currentScreen = m_screenList->MoveToPreviousScreen(); 
        if (m_currentScreen) 
        { 
          m_currentScreen->Run(); 
          m_currentScreen->OnEntry(); 
        } 
        break; 
      case ScreenState::EXIT_APP: 
          ExitGame(); 
          break; 
      default: 
          break; 
      } 
    } 
    else 
    { 
      //we have no screen so exit 
      ExitGame(); 
    } 
  }

在我们的Update之后,我们实现Draw功能。在我们的功能中,我们只做几件事。首先,我们重置Viewport作为一个简单的安全检查,然后如果当前屏幕的状态与枚举值RUNNING匹配,我们再次使用多态性传递Draw调用对象行:

void IGame::Draw() 
  { 
    //For safety 
    glViewport(0, 0, m_window.GetScreenWidth(), m_window.GetScreenHeight()); 

    //Check if we have a screen and that the screen is running 
    if (m_currentScreen && 
      m_currentScreen->GetScreenState() == ScreenState::RUNNING) 
    { 
      m_currentScreen->Draw(); 
    } 
  } 

我们需要实现的最后一个功能是OnSDLEvent功能。就像我在这个类的定义部分提到的,我们将使用这个函数把我们的InputManager系统连接到 SDL 内置事件系统。

每次按键或鼠标移动都被当作一个事件来处理。基于已经发生的事件类型,我们再次使用 switch case 语句来创建一个简单的有限状态机。关于每个功能是如何实现的,请参考前面的经理模式讨论部分。

  void IGame::OnSDLEvent(SDL_Event & event) 
  { 
    switch (event.type) { 
    case SDL_QUIT: 
      m_isRunning = false; 
      break; 
    case SDL_MOUSEMOTION: 
      m_inputManager.SetMouseCoords((float)event.motion.x, 
(float)event.motion.y); 
      break; 
    case SDL_KEYDOWN: 
      m_inputManager.KeyPress(event.key.keysym.sym); 
      break; 
    case SDL_KEYUP: 
      m_inputManager.KeyRelease(event.key.keysym.sym); 
      break; 
    case SDL_MOUSEBUTTONDOWN: 
      m_inputManager.KeyPress(event.button.button); 
      break; 
    case SDL_MOUSEBUTTONUP: 
      m_inputManager.KeyRelease(event.button.button); 
      break; 
    } 
  } 
}

嗯,这就解决了IGame界面的问题。有了这个,我们现在可以创建一个新的项目,它可以利用示例引擎中的这个和其他接口来创建一个游戏,并且只用几行代码就可以初始化它。这是位于代码库的Chapter03文件夹中的示例项目的App类:

#pragma once 
#include <BookEngine/IGame.h> 
#include ""GamePlayScreen.h"" 
class App : public BookEngine::IGame 
{ 
public: 
  App(); 
  ~App(); 
  virtual void OnInit() override; 
  virtual void OnExit() override; 
  virtual void AddScreens() override; 
private: 
  std::unique_ptr<GameplayScreen> m_gameplayScreen = nullptr; 
}; 

这里需要注意的重点是,第一,App类继承自BookEngine::IGame接口,第二,我们拥有继承类所需的所有必要覆盖。接下来,如果我们看一下应用的入口点main.cpp文件,您将会看到简单的命令来设置和启动我们的界面、管理器和助手为我们抽象的所有令人惊奇的事情:

#include <BookEngine/IGame.h> 
#include ""App.h"" 
int main(int argc, char** argv) 
{ 
  App app; 
  app.Run(); 
  return 0; 
} 

正如您所看到的,每次我们想要创建一个新项目时,这比不得不从头开始不断地重新创建框架要简单得多。

要查看本章中描述的框架的输出,构建BookEngine项目,然后构建并运行示例项目。XCode 和 Visual Studio 项目可以在 GitHub 代码存储库的Chapter03文件夹中找到。

在 Windows 上,运行时的示例项目如下所示:

在 macOS 上,运行时的示例项目如下所示:

摘要

在这一章中,我们讲述了很多。我们看了使用面向对象编程和多态性为所有游戏项目创建可重用结构的不同方法。我们用真实代码中的例子来演示助手类、管理器类和接口类的区别。

在接下来的章节中,我们将看到这个结构被重用,并在此基础上创建演示。事实上,在下一章中,我们将构建更多的管理器和助手类来创建素材管理管道。