总体思路确定了,我们进入编码。首先搭个架子。(演示命令行为Linux。Windows大部分类似,小部分命令名字不同请自行置换)
确认我们目前所处的位置:
[tim@iZ625ivhudwZ GameEngineFromScratch]$ git branch
* article_1
master
新建一个branch用于保存本篇文章开发的内容:
[tim@iZ625ivhudwZ GameEngineFromScratch]$ git checkout -b article_5
Switched to a new branch 'article_5'
[tim@iZ625ivhudwZ GameEngineFromScratch]$ git branch
article_1
* article_5
master
新建一个目录,用于存放框架。其中再建立一个Common子目录,用于存放各平台通用代码;建立一个Interface子目录,用于存放模块间接口代码:
[tim@iZ625ivhudwZ GameEngineFromScratch]$ mkdir Framework
[tim@iZ625ivhudwZ GameEngineFromScratch]$ cd Framework
[tim@iZ625ivhudwZ Framework]$ mkdir Common
[tim@iZ625ivhudwZ Framework]$ mkdir Interface
[tim@iZ625ivhudwZ Framework]$ dir
Common Interface
现在整个项目目录的结构如下:
[tim@iZ625ivhudwZ GameEngineFromScratch]$ tree .
.
├── Framework
│ ├── Common
│ └── Interface
├── LICENSE
├── main.c
└── README.md
将main.c移动到Framework/Common之下。也就是说,我们准备在不同平台直接共用同一个程序入口:
[tim@iZ625ivhudwZ GameEngineFromScratch]$ mv main.c Framework/Common/
[tim@iZ625ivhudwZ GameEngineFromScratch]$ tree .
.
├── Framework
│ ├── Common
│ │ └── main.c
│ └── Interface
├── LICENSE
└── README.md
3 directories, 3 files
在Framework/Interface之下新建Interface.hpp
[tim@iZ625ivhudwZ GameEngineFromScratch]$ vi Framework/Interface/Interface.hpp
通过宏定义定义几个alias,用以提高代码的可读性:
#pragma once
#define Interface class
#define implements public
在Framework/Interface执行新建IRuntimeModule.hpp
vi Framework/Interface/IRuntimeModule.hpp
在其中定义每个Runtime Module都应该支持的一些方法:
#pragma once
#include "Interface.hpp"
namespace My {
Interface IRuntimeModule{
public:
virtual ~IRuntimeModule() {};
virtual int Initialize() = 0;
virtual void Finalize() = 0;
virtual void Tick() = 0;
};
}
说明一下:
#pragma once
这是是声明这个头文件在编译的时候只需要处理一次。项目大了,同一个头文件可能会被多次包含(比如在包含的其它头文件里面又包含了同样的头文件)。由于编译器处理包含文件的方式是将其展开在源文件当中,所以如果不加这个条件编译指令,头文件的内容会在同一个源文件里面多次展开,那么编译器就会报错,说同一个东西被多次定义。
这个条件编译指令对于很老的C/C++编译器来说是不认识的。如果遇到这种情况,就需要将其改成下面这种形式:
#ifndef __INTERFACE_H__
<代码>
效果是一样的。就是啰嗦一些。
virtual ~IRuntimeModule() {};
虚函数的析构函数。因为是空函数,这个在Visual Studio里面不定义也是可以的。但是按照严格的C++标准,包括《C++ Primer》这本书里面的推荐做法,对于有其他虚函数的类,建议把析构函数也声明为virtual。这是因为如果不这么做,那么当使用基类指针释放派生类的实例的时候,可能导致只调用了基类的析构函数,从而产生memory leak的情况。在某些平台上,比如PSV,如果不定义这个虚析构函数,编译器会报Warning。
virtual int Initialize() = 0;
virtual void Finalize() = 0;
virtual void Tick() = 0;
纯虚成员函数。定义为纯虚函数的目的是强制派生类实现这些方法。可以有效避免遗漏。
然后再说一下这3个函数(接口)的作用:
之所以要单独定义模块的初始化/反初始化函数,而不是在类的构造函数/析构函数里面完成这些工作,主要是有以下一些考虑:
接下来定义Application接口。这个接口用于抽象化不同平台的Application(并将其模块化),使得我们可以用同一个主入口(main.c)启动程序(也意味着我们可以使用同一套启动参数)
[tim@iZ625ivhudwZ GameEngineFromScratch]$ vi Framework/Interface/IApplication.hpp
内容如下:
#pragma once
#include "Interface.hpp"
#include "IRuntimeModule.hpp"
namespace My {
Interface IApplication : implements IRuntimeModule
{
public:
virtual int Initialize() = 0;
virtual void Finalize() = 0;
// One cycle of the main loop
virtual void Tick() = 0;
virtual bool IsQuit() = 0;
};
}
可以看到它继承了我们刚才定义的IRuntimeModule,重载了IRuntimeModule的3个接口,另外增加了一个公共接口:IsQuit(),用于查询应用程序是否需要退出。这是因为,在很多平台上用户关闭应用程序都是通过系统通知过来的。我们的程序自身并不会直接进行这方面的判断。所以当我们收到这样的关闭通知的时候,我们就通过这个接口告诉主循环,我们该结束了。
可以看到这仍然是一个纯虚类。接下来我们可以直接从这个类派生出各个平台的Application类。但是实际上,各个平台的Application虽然有很多不同,共通点也是很多的。提高代码可维护性的一个重要做法,就是要避免同样的代码分散在不同的文件当中。否则很容易出现只改了一处而没有改其他的情况。
因此,我们在Framework/Common下面,新建两个文件,用来提供各平台共通的Application实现:
[tim@iZ625ivhudwZ GameEngineFromScratch]$ touch Framework/Common/BaseApplication.{hpp,cpp}
大括号是Linux Bash情况下的一种Hack,就是小技巧,可以一次生成两个文件。如果是Windows,请分别生成这两个文件。
现在我们编辑这两个文件:
BaseApplication.hpp
#pragma once
#include "IApplication.hpp"
namespace My {
class BaseApplication : implements IApplication
{
public:
virtual int Initialize();
virtual void Finalize();
// One cycle of the main loop
virtual void Tick();
virtual bool IsQuit();
protected:
// Flag if need quit the main loop of the application
bool m_bQuit;
};
}
BaseApplication.cpp
#include "BaseApplication.hpp"
// Parse command line, read configuration, initialize all sub modules
int My::BaseApplication::Initialize()
{
m_bQuit = false;
return 0;
}
// Finalize all sub modules and clean up all runtime temporary files.
void My::BaseApplication::Finalize()
{
}
// One cycle of the main loop
void My::BaseApplication::Tick()
{
}
bool My::BaseApplication::IsQuit()
{
return m_bQuit;
}
好了。这个类里面有一个受保护的变量m_bQuit,用于记录应用程序是否被通知退出。
最后让我们来修改我们的main.c。首先把它重新命名为main.cpp,因为我们用到了C++的特性:类。然后改写成下面这个样子:
#include
#include "IApplication.hpp"
using namespace My;
namespace My {
extern IApplication* g_pApp;
}
int main(int argc, char** argv) {
int ret;
if ((ret = g_pApp->Initialize()) != 0) {
printf("App Initialize failed, will exit now.");
return ret;
}
while (!g_pApp->IsQuit()) {
g_pApp->Tick();
}
g_pApp->Finalize();
return 0;
}
因为我们将不同平台的应用程序进行了抽象,所以我们的main函数不需要关心我们目前到底是工作在哪个平台。我们只需要通过IApplication接口提供的方法进行调用就可以了。
好了,一个基本的架子我们已经搭建好了。但是要让它跑起来之前,我们还需要做一些事情。什么事情?注意这一行:
namespace My {
extern IApplication* g_pApp;
}
我们需要定义一个具体的Application实例。让我们新建一个Empty目录,代表一个特殊的平台(无平台),然后在里面写一个EmptyApplication.cpp, 来创建这个实例:
#include "BaseApplication.hpp"
namespace My {
BaseApplication g_App;
IApplication* g_pApp = &g_App;
}
好了,现在我们的项目差不多是这个样子:
[tim@iZ625ivhudwZ GameEngineFromScratch]$ tree
.
├── Empty
│ └── EmptyApplication.cpp
├── Framework
│ ├── Common
│ │ ├── BaseApplication.cpp
│ │ ├── BaseApplication.hpp
│ │ └── main.cpp
│ └── Interface
│ ├── IApplication.hpp
│ ├── Interface.hpp
│ └── IRuntimeModule.hpp
├── LICENSE
└── README.md
4 directories, 9 files
为了编译它,我们需要创建CMakeLists.txt。首先我们需要在项目根目录创建一个如下:
cmake_minimum_required (VERSION 3.1)
set (CMAKE_C_STANDARD 11)
set (CMAKE_CXX_STANDARD 11)
project (GameEngineFromScrath)
include_directories("${PROJECT_SOURCE_DIR}/Framework/Common")
include_directories("${PROJECT_SOURCE_DIR}/Framework/Interface")
add_subdirectory(Framework)
add_subdirectory(Empty)
注意前三行是因为我们的引擎后面会用到一些C/C++ 11的特性。现在删掉这三行也可以。
然后是Framework目录里面需要一个:
add_subdirectory(Common)
这个只是用来完成一个CMake的递归搜索
Framework/Common下面需要一个,用来建立Framework库:
add_library(Common
BaseApplication.cpp
main.cpp
)
最后就是Empty目录下面一个,用来建立Empty平台的最后的可执行文件:
add_executable(Empty EmptyApplication.cpp)
target_link_libraries(Empty Common)
好了,可以编译了。仍然是采用out of source tree的方式,退回到项目根目录,创建一个build目录,进入build目录,执行
cmake ..
make
就可以了。生成文件为build/Empty/Empty(.exe)
如果是Linux,也可以采用docker的方式进行编译。这也是我推荐的方式,可以有效避免在系统里安装一大堆开发用的包和工具。同样回到根目录,执行
[tim@iZ625ivhudwZ GameEngineFromScratch]$ docker run -it --rm -v $(pwd):/usr/src tim03/clang
bash-4.4# cd build
bash-4.4# cmake ../
-- The C compiler identification is GNU 6.3.0
-- The CXX compiler identification is GNU 6.3.0
-- Check for working C compiler: /usr/bin/gcc
-- Check for working C compiler: /usr/bin/gcc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /usr/src/build
bash-4.4# make
Scanning dependencies of target Common
[ 20%] Building CXX object Framework/Common/CMakeFiles/Common.dir/BaseApplication.cpp.o
[ 40%] Linking CXX static library libCommon.a
[ 60%] Built target Common
Scanning dependencies of target Empty
[ 80%] Building CXX object Empty/CMakeFiles/Empty.dir/EmptyApplication.cpp.o
[100%] Linking CXX executable Empty
[100%] Built target Empty
bash-4.4# Empty/Empty
^C
注意我们这个引擎目前正常情况下不会有任何输出,而且会死循环。按Ctrl+C退出。
(– EOF –)
本作品采用知识共享署名 4.0 国际许可协议进行许可。
上一节(引擎概观) 下一节(图形API介绍)