开源游戏学习- 模拟系统的架构设计

来源:互联网 发布:多益网络账号格式错误 编辑:程序博客网 时间:2024/06/05 22:56

花了两天把OAD的模拟系统架构翻译了一遍,如下

模拟系统的架构:

概念

实体系统是运行这个游戏模拟器和游戏玩法代码的基础架构,(游戏玩法不是这个系统里面的东西,但是建立在系统的上层,影响系统的设计)。

实体系统最重要的概念就是实体,这个代表了很多类型的东西在模拟世界 - 一个人,一棵树,一个岩石,一把弓箭,或者更加抽象的东西类似于事件触发器,玩家,玩家输入控制。

实体由很多个组件组成,一个组件是一大块自包含的数据和代码,负责实体的一部分行为。一个组件可能负责渲染实体,另一个负责跟踪他在世界中的方位;另一个负责跟踪他的血量并且dang有动机和死亡的时候减少它或者杀死它。

每个组件是一个C++代码的对象实例。但是实体不是由任何C++对象来表示的 - 每个组件被绑定了一个实体ID,实体存在只是一个概念:通过一系列有同样的实体ID的组件定义的。

组件之间有两种通讯机制:1对1通信,通过QueryInterface调用来检索组件然后调用组件的方法;另一种1对多的同学通过发送广播消息,订阅了此消息类型的主角将会接收到。

脚本

为了简化开发,提高迭代时间,避免崩溃,大多数游戏玩法应该用js写。每个组件可以完全用C++或者JS编写。本地和脚本组件的通讯和本地-本地以及脚本-脚本的通讯是几乎一样的,除了本地的组件可能只对脚本暴露一部分方法。


组件只有在要求高运行时间、底内存或者与游戏引擎交互的时候应该用C++编写(例如 渲染)。只有在代码需要每帧执行一次的时候才可以关心高运行效率问题(例如:位置插值函数),或者执行每个模拟轮需要计算大量的实体(例如:检测单元是否到达范围以内)。组件开始的时候都应用js编写,但发现性能不行的时候,再去坐js优化,如果还是有问题才考虑用C++重写。


组件脚本支持热加载:当这个游戏运行时,你可以编辑并保存脚本文件,它将立刻重载并且在游戏中体现出来,不需要停止或重启(数据不会变,只有代码会变)。

接口

大多数实体表现得和其他很相似,但是我们不希望每个组件对应一个不同的实现。

例如:大多数的实体有Position组件,  在Updata回应MoveTo调用并且可以通过渲染器索引到当前帧数的位置。(帧数比模拟更新调用的更加频繁)。对于移动平滑的实体,组件应该插值的形式从上一个模拟状态到当前状态,如此的话它就需要在每次状态转变时记录下上一次的位置。对于不是直线移动的实体(弓箭),线性插值将不准确,对于不会移动的实体,记录上一次的记录会浪费资源。


当然设定一个标志用switch来转换线性和抛物线插值是可行的,但是代码将随着特殊情况的增加变得日趋复杂,优化存储变得很难实现,静止物体的计算也是个问题。


因此,我们将position定义为接口,一个接口没有定义任何代码和数据,但是定义了一系列的方法(例如MoveTo和GetInterpolatedPosition)。我们接下来定义一系列的组件类型来实现这些借口,提供代码和数据-  例如PositionInterpolated 和PositionStatic 组件类型都实现了Position接口。一个组件是一个组件类型的实例。

一个实体每个接口至多对应一个组件(就是说PositionInterpolated and PositionStatic的实例不能同时存在于实体当中)。任何与实体交互的代码不能有组件类型的信息耦合 - 代码只能通过QueryInterface 来获得指向实现了Position接口的组件的指针,并且调用他们实现的方法。


消息传递

组件直接的方法调用有时是需要的,但是他们迫使组件实现时需要知道其他组件的详细信息,例如,一个Position组件可能希望通知很多组件当实体移动的时候(例如:任何需要检测实体是否进入范围组件,或者走到熔岩上设置它着火),如果Position逐渐暗类型的代码需要知道每个需要通知的组件类型信息的话,那么代码将变得复杂和呆板。

消息传递系统解决了这个问题,组件类型可以订阅一个特殊的消息类型,组件可以发送或者广播一条有类型和相关数据的消息,将被订阅了此消息的组件接收(Post发送一个消息到有特殊实体ID的组件上,广播发送到所有持有此组件的额实体上)。例如:“(burn-on-lava)岩浆上着火”组件可以订阅一条PositionChanged 类型的消息,Position组件接着发送一条PositionChanged 消息到自己的实体ID上,然后着火组件的消息处理函数将会被调用。Position组件只需要知道消息类型,不需要任何组件信息。

1对多的消息传递(任意个数的组件可以接收当个的消息),他们是单向的(不能对消息进行恢复)。组件会以任何而一致的顺序被通知。

序列化

模拟系统需要支持一下三个相关的功能:

1、序列化模拟状态为字节流,方便保存游戏和重新加入游戏。

2、计算模拟状态的校验和,在游戏有显著差异前检测出不同步的错误。

3、将仿真或者具体实体或者组件的状态转变为人类可读的格式,方便调试。


因此每个组件都需要实现序列化技术,传递它们内部数据到序列化API(不仅用更高效率的格式保持,并且将它进行校验和计算,或转换为人类可读的文本)。


对于脚本组件这个工作是自动的,但是C++组件必须手动实现它。总的规则就是一系列的方法调用一定会产生有一样校验和并且表现相同:

  • constructor() -> Init(context, paramNode) -> ...simulation turns... -> Serialize(stream)
  • constructor() -> Deserialize(context, paramNode, stream)
序列化数据必须忽略编译器,操作系统,CPU等等,则表示组件

相应的内部状态必须不包含诸如size_t值(32位和64位平台不一样)或者是floats(我们不相信编译器会平等对待)

对于经常使用的组件,保存游戏的序列化的输出应该尽可能的高效。任何在反序列化之后可以安全的重构的内部缓存应该忽略。任何从paramNode初始化的数据不应该被序列化除非它改变了 - 保存一些占位符来替代并且在Deserialize中重构它。

实体模版


实体是从实体模版构建的,用XML定义(典型事例:typically in the binaries/data/mods/public/simulation/templates/ directory)。XML文件的根元素一个文件包含一个,给出组件类型的名字,每个组件包含这个组件的初始化数据,要么是空的,要么是一系列的元素有多种多样的数据。这些数据将传递给组件的Init方法。

系统实体

有些玩法逻辑代码是全局的,只有拷贝它才是有用的;但是写进组件的基础架构里是有好处的。这些代码可以作为一个系统的组件去实现,虽然像正常的组件,但是被赋予实体ID : SYSTEM_ENTITY(作为一个公约,非强制性的),他可以同样被任何模拟代码用相同的方式调用。

用C++定义接口

Create the file simulation2/components/ICmpExample.h:

/* Copyright (C) 2013 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D.  If not, see <http://www.gnu.org/licenses/>. */#ifndef INCLUDED_ICMPEXAMPLE#define INCLUDED_ICMPEXAMPLE#include "simulation2/system/Interface.h"// ...any other forward declarations and includes you might need.../** * Documentation to describe what this interface and its associated component types are * for, and roughly how they should be used. */class ICmpExample : public IComponent{public:    /**     * Documentation for each method.     */    virtual int DoWhatever(int x, int y) = 0;    // ...    DECLARE_INTERFACE_TYPE(Example)};#endif // INCLUDED_ICMPEXAMPLE
这样就定义了C++代码用来获得组件的接口。

Create the file simulation2/components/ICmpExample.cpp:

/* Copyright (C) 2013 Wildfire Games. * ...the usual copyright header... */#include "precompiled.h"#include "ICmpExample.h"#include "simulation2/system/InterfaceScripted.h"BEGIN_INTERFACE_WRAPPER(Example)DEFINE_INTERFACE_METHOD_2("DoWhatever", int, ICmpExample, DoWhatever, int, int)// DEFINE_INTERFACE_METHOD for all the other methods tooEND_INTERFACE_WRAPPER(Example)
这个定义了JS的封装,这样脚本可以获取实现这个接口的组件方法。

这个封装应该只包含安全访问模拟脚本的方法:他们必须不能崩溃(甚至当输入不正确),他们必须返回确定的值,只被C++使用的方法不需要列在这里(这里是js能访问C++方法的关键)

每个接口必须定义脚本封装BEGIN_INTERFACE_WRAPPER,虽然他们可能会没有任何方法。

更新simulation2/TypeList.h,添加 

INTERFACE(Example)

TypeList.h用于很多方面 - 它将定义接口ID IID_Example(C++和js),并且将新的借口挂接到接口

记住运行update-workspaces脚本在添加或者移除了代码之后,这样他们将会添加进接口注册系统。

接口方法脚本封装

接口方法用以下宏定义:

DEFINE_INTERFACE_METHOD_NumberOfArguments("MethodName"ReturnTypeICmpExampleMethodName,ArgType0ArgType1, ...)

对应C++方法:ReturnType ICmpExample::MethodName(ArgType0ArgType1, ...)

对于这种暴露给脚本的方法,参数应该是简单类型并且用值传递,例如:std::wstring.

参数和返回值将在两种语言之间自动的转换。为了达到这个目的,ToJSVal<ReturnType> and FromJSVal<ArgTypeN> 必须定义。

两个MethodName不需要相同。

参数个数有限制。


脚本的类型转换

用C++定义组件类型

现在我们希望实现Example接口。我们需要一个名字,

Create simulation2/components/CCmpExample.cpp:

/* Copyright (C) 2013 Wildfire Games. * ...the usual copyright header... */#include "precompiled.h"#include "simulation2/system/Component.h"#include "ICmpExample.h"// ... any other includes needed ...class CCmpExample : public ICmpExample{public:    static void ClassInit(CComponentManager& componentManager)    {        // ...    }    DEFAULT_COMPONENT_ALLOCATOR(Example)    // ... member variables ...    static std::string GetSchema()    {        return "<ref name='anything'/>";    }    virtual void Init(const CParamNode& paramNode)    {        // ...    }    virtual void Deinit()    {        // ...    }    virtual void Serialize(ISerializer& serialize)    {        // ...    }    virtual void Deserialize(const CParamNode& paramNode, IDeserializer& deserialize)    {        // ...    }    virtual void HandleMessage(const CMessage& msg, bool UNUSED(global))    {        // ...    }    // ... Implementation of interface functions: ...    virtual int DoWhatever(int x, int y)    {        return x+y;    }};REGISTER_COMPONENT_TYPE(Example)
唯一可选的方法是 HandleMessage and GetSchema - 其他都需要定义。

更新文件simulation2/TypeList.h 并添加

COMPONENT(Example)

消息处理

首先你需要注册所有你希望接收的消息类型,在ClassInit中:

static void ClassInit(CComponentManager& componentManager){    componentManager.SubscribeToMessageType(CID_Example, MT_Update);    ...}
你可以使用SubscribeGloballyToMessageType,来用PostMessage发送消息给几个不同的实体。(通常用于喜欢听到所有的MT_Destroy 消息的组件


然后我们需要在HandleMessage中应答消息:

virtual void HandleMessage(const CMessage& msg, bool UNUSED(global)){    switch (msg.GetType())    {    case MT_Update:    {        const CMessageUpdate& msgData = static_cast<const CMessageUpdate&> (msg);        Update(msgData.turnLength); // or whatever processing you want to do        break;    }    }}

CMessage结构在simulation2/MessageTypes.h.中定义的。但是要注意的是你要将msg进行类型转换。

创建组件

 组件类型实例有两个生命周期

CCmpExample();Init(paramNode);// any sequence of HandleMessage and Serialize and interface methodsDeinit();~CCmpExample();
CCmpExample();Deserialize(paramNode, deserialize);// any sequence of HandleMessage and Serialize and interface methodsDeinit();~CCmpExample();

Init/Deserialize/Deinit之间的顺序在实体中大多数是没有定义的,所以他们必须不依靠其他已经存在的实体或者组件;除了像 SYSTEM_ENTITY 这种在最开始创建的可以用,单个实体的组件将按照TypeList.h.里的顺序处理。

在一个典型的组件当中:

1、构造函数应该非常的小,此外初始化一些成员变量 - 一般来说默认的构造函数够了所以不需要再写一个。

2、Init应该传递paramNode参数(实体模版的数据)并且在成员变量中储存任何需要的数据。

3、Deserialize 应该经常明确的首先调用Init(来加载原有的模版数据),并且从deserializer读取一些具体实例的数据。

4、Deinit 应该清除Init / Deserialize分配的资源。

5、析构函数应该清除所有构造函数分配的资源。

组件XML格式

传递给Init的 paramNode 是由实体模版定义文件构造的。

组件应该定义一个架构,用于几个目的:

1、文档的XML结构通过组件来预估。

2、自动查错,可以看出XML结构是否是匹配预期的,因此组件不需要再做错误检测。

3、(在未来实现)用可视化工具自动的生成。

GetSchema一定要返回一个Relax NG的框架,它将用来构造一个单独的全局的文件。(你可以用-dumpSchema命令行参数运行游戏来查看架构)。http://relaxng.org/tutorial-20011203.html描述了很多RNG语言的细节。

在简单情况中,你可以写一些类似于:

static std::string GetSchema(){    return        "<element name='Name'><text/></element>"        "<element name='Height'><data type='nonNegativeInteger'/></element>"        "<optional>"            "<element name='Eyes'><empty/></element>"        "</optional>";    }}
例如:一个单个字符串(C++自动串联引用行)定义了一系列的元素,相应的实体模版XML代码如下:

<Entity>  <Example>    <Name>Barney</Name>    <Height>235</Height>    <Eyes/>  </Example>  <!-- ... other components ... --></Entity>
在架构中,每个<element>都有一个名字和一些内容

内容的具体类型应该是下面的一种:

  • <empty/>
  • <text/>
  • <data type='boolean'/>
  • <data type='decimal'/>
  • <data type='nonNegativeInteger'/>
  • <data type='positiveInteger'/>
  • <ref name='nonNegativeDecimal'/>
  • <ref name='positiveDecimal'/>
(最后两个会有一点不一样因为他们不是数据类型)

元素可以被绑定在<optional>中。很多组的元素可以被绑定在<choice>来限制只可以选一个。

一个<element>中的内容可以被进一步嵌套,但是注意dang加载一个实体模版时元素可能重新排列:如果你指定一个组件序列那就需要和<interleave>绑定,架构的检查器将忽略重排这个队列。

早些时候的组件开发,你可以设置一个 <ref name='anything'/>允许任何内桶,如果你不定义GetSchema,那么默认是<empty/>(i.e.这里必须没有元素)。


允许用js实现接口注册系统。

如果你喜欢允许C++和js都实现ICmpExample,我们需要定义一个特殊的组件类型来用C++方法代理脚本,将下面的代码加入ICmpExample.cpp:

#include "simulation2/scripting/ScriptComponent.h"// ...class CCmpExampleScripted : public ICmpExample{public:    DEFAULT_SCRIPT_WRAPPER(ExampleScripted)    virtual int DoWhatever(int x, int y)    {        return m_Script.Call<int> ("DoWhatever", x, y);    }};REGISTER_COMPONENT_SCRIPT_WRAPPER(ExampleScripted)
然后加入TypeList.h

COMPONENT(ExampleScripted)
m_Script.Call 拿一个返回值作为模版参数,然后是JS函数的名字以及后面一列的参数。如果需要,你可以在调用脚本之前做一些类型转换。你需要保证类型是由ToJSVal 和 FromJSVal处理的(正如前面讲到的)。

用JS定义组件类型
现在我们希望有一个js实现的ICMpExample,想一个 新名字,类似:ExampleTwo (跟富有想象力的)。然后编写binaries/data/mods/public/simulation/components/ExampleTwo.js:
function ExampleTwo() {}ExampleTwo.prototype.Schema = "<ref name='anything'/>";ExampleTwo.prototype.Init = function() {    ...};ExampleTwo.prototype.Deinit = function() {    ...};ExampleTwo.prototype.OnUpdate = function(msg) {    ...};Engine.RegisterComponentType(IID_Example, "ExampleTwo", ExampleTwo);
这里用到了JS的prototype系统来创建实际上类似类的,叫做ExampleTwo。(如果你敲 new ExampleTwo(),那么JS将会构造一个新的对象继承于 ExampleTwo.prototype,然后将调用ExampleTwo 函数设置新对象。“继承与”在这里表示如果你读取了这个对象的一个属性(或者方法),但没有在对象中定义,那么它将会从原型中读取)。

Engine.RegisterComponentType 告诉引擎开始使用JS类ExampleTwo,暴露名字(在模版文件中)“ExampleTwo”,实现接口ID IID_Example(例如IXCmpExample 接口)。

Init和Deinit函数是可选的,不像C++,这里没有Serialize/Deserialize方法 - 每个JS组件实例是自动序列化和恢复的。(自动序列化严格限制你可以在对象中存储的属性 - 例如:你不能存储函数闭包,因为他们太难被序列化。它能序列化 string、数字、bool、null、未定义、数组等属性名是纯数字,属性是可序列化得对象。环形结构是允许的)。

除了ClassInit 和 HandleMessage, 你可以简单的添加函数形式如OnMessageType。(如果你喜欢等同于SubscribeGloballyToMessageType的类型,那么使用OnGlobalMessageType。)当你调用RegisterComponentType,它将查找所有这样的函数并且自动的订阅消息。msg参数通常是一个直接的JS对象上相应CMessgae类的map索引(例如OnUpdate可以读msg.turnLength)。

用JS定义接口类型

如果接口只被JS组件调用过,从未被C++组件直接实现或调用,那么你们不需要做定义IXmpExample的任何工作,只需创建一个文件binaries/data/mods/public/simulation/components/interfaces/Example.js:
Engine.RegisterInterface("Example");
你可以在JS组件中使用IID_Example(不是每个接口定义都需要一个js文件,这样做只是方便以后扩展新的接口)。

用C++定义一个新的消息类型

想一个名字,我们再次用Example。
在TypeList.h:中添加:

MESSAGE(Example)
在Messagetypes.h中添加

class CMessageExample : public CMessage{public:    DEFAULT_MESSAGE_IMPL(Example)    CMessageExample(int x, int y) :        x(x), y(y)    {    }    int x;    int y;};
包括和消息相关的数据域(某些情况下可能没有数据域)。
(如果太多的消息类型的话,MessgaeType.h可能会拆分成几个文件更好的组织他们,但是现在所有东西都是放在里面的)。

现在你需要在MessageTypeConversions.cpp文件中添加C++/JS转换,这样脚本就可以发送和接受消息了:

jsval CMessageExample::ToJSVal(ScriptInterface& scriptInterface) const{    TOJSVAL_SETUP();    SET_MSG_PROPERTY(x);    SET_MSG_PROPERTY(y);    return OBJECT_TO_JSVAL(obj);}CMessage* CMessageExample::FromJSVal(ScriptInterface& scriptInterface, jsval val){    FROMJSVAL_SETUP();    GET_MSG_PROPERTY(int, x);    GET_MSG_PROPERTY(int, y);    return new CMessageExample(x, y);}
(你可以直接用JSAPI,但是这些宏定义简化了很多等级的难度)。

如果你不想让脚本支持发送接收消息,你可以实现下面的函数:

jsval CMessageExample::ToJSVal(ScriptInterface& UNUSED(scriptInterface)) const{    return JSVAL_VOID;}CMessage* CMessageExample::FromJSVal(ScriptInterface& UNUSED(scriptInterface), jsval UNUSED(val)){    return NULL;}

用JS定义一个新的消息

如果一个消息只会被JS组件发送和接收,那么他可以单纯的用js定义,例如:
// Message of the form { "foo": 1, "bar": "baz" }// sent whenever the example component wants to demonstrate the message feature.Engine.RegisterMessageType("Example");

注意,注释中德消息结构是唯一的规格 - 无需告诉引擎它需要什么属性。
这个消息类型可以洗那个C++定义的CMessageExample一样被JS使用。

组件通讯
消息传输
对于一对多的通讯,你可以发送简介的消息给其他的组件。

在C++中,使用CComponentManager::PostMessage来向一个特定的实体发送一个消息,CComponentManager::BroadcastMessage是发送给所有实体。(所有情况下,消息应该只能被订阅了相应的消息类型的组件接收)。
CMessageExample msg(10, 20);GetSimContext().GetComponentManager().PostMessage(ent, msg);GetSimContext().GetComponentManager().BroadcastMessage(msg);
对于js,使用Engine.PostMessage and Engine.BroadcastMessage,使用MT_*常量来定义消息类型:
Engine.PostMessage(ent, MT_Example, { x: 10, y: 20 });Engine.BroadcastMessage(MT_Example, { x: 10, y: 20 });
在PostMessage/BroadcastMessage调用返回之前消息将被接收并且同步处理。

检索接口
您也可以直接获取给定实体组件的实现一个给定的接口,用来直接在上面调用方法。
C++中,使用CmpPtr
#include "simulation2/components/ICmpPosition.h"...CmpPtr<ICmpPosition> cmpPosition(context, ent);if (!cmpPosition)    // do something to avoid dereferencing null pointerscmpPosition->MoveTo(x, y);

JS中,使用Engine.QueryInterface:

var cmpPosition = Engine.QueryInterface(ent, IID_Position);cmpPosition.MoveTo(x, y);
(cmpPosition如果是null会抛出一个异常,所以不需要明确的检查除非组件不存在是很合理的并且你希望优雅的处理它)。


测试组件

测试对于确保和维护代码质量很重要,所以所有的不是很小的组教案都需要测试用例。首要任务就是隔离测试每个组件,检查以下方面:

1、从模板数据初始化组件状态。

2、回应方法调用来定义和检索状态。

3、回应广播/发送消息。

4、序列化和反序列化,针对游戏保存和网络。

为了集中精力做这些,与其他组件的通讯和相互作用明确不会在这里测试(虽然在其他地方还是会测试)。测试组件加载,但是所有其他的组件都被mock替代(用虚假的实现方式实现借口(忽略调用,返回常量,等等))。具体的不同取决与用什么语言写组件。


测试C++组件

创建文件simulation2/components/tests/test_Example.h,并且从test_CommandQueue.h拷贝。特别的,你需要setUp和tearDown函数来初始化CXeromyces,并且你应该使用ComponentTestHelperCxxTest's TS_* 来建立测试环境并且构造自己的组件,然后使用CxxTest's TS_*宏来检查,然后使用ComponentTestHelper::Roundtrip去测试序列化往返。


定义mock组件对象例如MockTerrain。如果他不可用将它放在ComponentTest.h中