用依赖注射模式实现快速安全的游戏对象原型
用依赖注射模式实现快速安全的游戏对象原型
Erick B. Passos
Media Lab - UFF
Jonhnny Weslley S. Sousa
LCD - UFCG
Giancarlo Nascimento
Media Lab - UFF
Esteban Walter Gonzales Clua
Media Lab - UFF
Lauro Kozovits
UERJ
">[摘要]:大多数游戏引擎是基于游戏对象的继承和/或组件化的行为。虽然这种方法使得系统框架有一个清晰视图,良好的代码重用,快速原型化,它带来了一些问题,主要是游戏对象/组件的实例的高度依赖。这种依赖性往往导致静态转换和很难调试的空指针引用。本文应用依赖注入模式以安全地初始化游戏对象和减轻在游戏开发原型和产品发布阶段程序员角色职责。游戏对象的属性初始化依存关系注入只发生在初始化阶段,而在游戏循环中,没有任何性能损失。
[关键字] 游戏引擎架构;依赖注射,对象组合
作者联系方式:
{epassos,esteban}@ic.uff.br jonhnny@lsd.ufcg.edu.br giancarlotaveira@gmail.com lauro@jogos.etc.br
1. 引言
在计算机科学领域,很少有像电脑游戏和面向对象那样直接的编程映射关系。一个游戏类匹配几个不同的游戏类的实例存在的虚拟世界概念,游戏对象可以是任何实物例如一个人物,房屋或者是一个不可见的触发对象。游戏执行通常由游戏类内部具有以下三个目的的循环组成:
1. 获取用户或网络输入,
2. 根据用户输入或物理模拟更新游戏对象,播放动画和执行AI
3. 在输出设备上绘制可见的游戏对象
虽然以上的循环形式可以更好地利用并行[邬斯迪摩赖斯扎米特等。 2007],但我们认为,此基本模式能很好地反映我们工作的目的。为表模拟不同类型的对象,程序员通常创建的游戏对象上的子类,每一个新的游戏对象指定更专门的内容和行为。该继承方法存在的问题是众所周知的。这方面的一个很好的例子是,当两个不同层次的对象,有时有共同的功能和特点,导致代码冗余。这是在面向对象的软件设计一个共同的问题因此更换由游戏引擎继承为组合已经为一个很好的做法[福尔默2007年;斯托伊2006; Ponder2004; Billas 2002]。采用组合替代继承之后,游戏对象仅仅只需要拥有共同的属性如姓名,位置和方向。但最重要的是,它可以作为一种可重复使用的组件的容器。每一类扩展一个抽象组件描述一个游戏对象不同的的方面或行为如物理,AI,或生命值,根据需要来组成游戏对象。这些组件应该高度灵活,易于维护,同时也最大限度地提高代码重用,同时这些组件也可以用于不同的游戏类型。
这两种方法都有一个共同的问题,组件(或游戏对象)之间的高耦合性。举个例子来说,一个AI组件通常不但依赖于生命(Health)组件来做出决策同时也依赖于物理组件来运行。一般情况下,这些依赖通常由程序员直接解决。正如以下的java所示(部分AIComponent类,从最初的C++例子[stoy2006]改编)
1 public void update(float interpolation) {
2 final GameObject o = getOwner();
3 Health h = (Health) o.getComponent("health");
4 if (h != null) {
5 // take AI actions based on health
6 }
7 }
Code 1: Traditional dependency handling
很容易看出此实现(AIComponent)显示的依赖于生命组件(第3行),该行代码假定相同的组件对象已经用标识“Health”在游戏对象中注册过。如果每个游戏对象都包含一个AI组件实例并且已经用Health组件初始化过,一切都会像预期的一样。显然,这段代码并没有什么不对,但进一步的审视将在下面给出:
当有必要清除隐式依赖时,代码2-4行只是空余的代码,与游戏逻辑的AIupdate无关
代码段第3行的显式转换,或者说在某些脚本语言中滥用强制转换,是在依赖对象或组件中一个常见的运行时问题
如果一个特定的游戏对象没有用Health组件初始化,那么AI决策将不会被正确执行,使得很难调试。
当设计关卡时,除非除非组件记录了这些讨人厌的依赖,不然程序员还得亲自写一些代码
一段等效UnrealScript代码[EpicGames 1998]将更加难以调试,因为该语言通过将指针转化为void*隐藏了所有的空指针和引用。Tim Sweeney最近说在Unreal引擎中大约百分之五十的Bug是因为缺乏强类型检查[Sweeney 2006].他也指出一个典型的游戏对象更新通常要涉及到五到十个其它对象,这也显示了依赖这个关系是多么的常见和明显。
本文应用依赖注射(Dependency Injection)模式来解决依赖的部分问题,通过本文,可以减轻程序员手工检查这些依赖的职责。正如以下章节将给出的一样,我们的框架解决安全初始化游戏对象或组件,使得编码以更加清晰和可维护的方式进行。本文剩下的章节由以下几个部分组成:第二部分讨论相关工作,第三部分描述概念和GCore框架实现的模式;而第四个部分将讲述依赖注射模式的使用和我们框架相对于前面研究的优点;最后,第五部分总结本文并概述今后的工作。
2. 相关工作
成功的商业游戏引擎对游戏对象继承有很强的依赖,例如CryEngine[Cry-Tek 2008]使用了类似于游戏对象和组件相似的实体和实体项。同样的架构也可以在其它商业引擎中找到,如Unreal Engine[EpicGames 1998]和Torque[GarageGames]。同时也有很多基于组件架构的引擎[UnityTechnologies 2008;Spinor;3DVia;Billias2002]。这些工具的一个共同问题是对象之间的依赖太高,这也是我们研究的潜在目标。在Tim Sweeney[Sweeney 2006]的一次谈话中,他暴露了当前编程语言和工具中中的两个问题细节:较差的并行处理和弱类型检查。他提出了许多新编程语言应该拥有的特征,这特征应该能解决程序员在实现游戏对象脚本时所遇到的大多数运行时错误。他的观点和我们的多少是有点相似的,但他的目标是创建一门拥有以上特征的新语言,这并不是一个简单的任务。在本文中,我们将使用当前流行的技术,这些技术可以应用到大多数工具中。我们的依赖注射实现是基于java的反射机制之上的,这种反射机制已经被作移植到基于如XNA[Microsoft]平台的C#上。C++和一些脚本 语言是没有反射机制的,但是花一些时间来设计这样一系统是可能的[Pocomatic 2007]。Dungeon Siege 是第一批包含完全游戏对象组件系统之一。在两界游戏开发者大会上[Billas2002;Billas2003],Scott Billas 展示组件架构和一些帮助游戏开发的一些特征。在最近的交谈中[Bilas 2007],他展示了怎样提高游戏产品线的想法,它们其中的一些和游戏对象初始化的健全检查有关,例如属性需求和依赖。他提出,这些断言和和错误消息应该直接由组件程序员代替关卡设计师来实现。我们不但认识到这些想法确实非常重要,而且也提出用工具来做健全检查和依赖注射,代替程序员从而提高整个产品流水线。Haller et al[Haller et al.200]提出了使用通信槽和消息管理来消除强关联一种新的游戏对象和组件构架。该解决方案使得组合对象更容易和组件之间联系列容易,但是需要一个很复杂的架构。使用我们的方法,程序员根本不用学习新的语言和通信架构,因此这种方法更适合原型开发。Unity3D游戏引擎[UnityTechnologies 2008] 是一个与其相关的最近产品,它获得了许多开发者的关注,因为它有设计得很好的游戏对象组件系统和场景编辑器,该编辑器还使用了一种可视化的方法来组合对象。在其最新版本中(2.1,在2008,7月下旬发布),一个简单的依赖健全检查形式已经提供给了脚本程序员,脚本程序员可以使用它来说明一个组件依赖于存在的另一个组件,这些都是在运行时在场景编辑器中检查。然而,尽管是自动的被场景编辑器初始化,这些实体并不是自动的注入依赖组件,而是由脚本程序员来完成,而且脚本程序员还要显示的调用getComponet(Type)方法来获取引用。我们的系统既做了健全检查也做到了自动注入依赖的组件。
据我们所知,所以之前的游戏对象组件系统研究只走了这么远,我们建议全面采用依赖注入来处理游戏引擎中组件依赖关系。在下面是的章节中,我们的的框架,GCore,将解释其架构和游戏对象组合的依赖注射。
3. GCore 框架
图表 1 GCore组件架构 |
GCore,即Game Core的缩写,是一个数据驱动的游戏框架其目标是使用JMonkeyEngine [JMonkeyEngnie]生产高效游戏产品,JMonkeEngine是一个用OpenGL实现渲染OpenAL实现音频的场景图引擎,包括了一些例如物理引擎[JMEPhysic]和网络引擎子系统[Imagination]来实现一个可扩展和易用的工具。因为他的核心概念非常适合用其它平台和语言如C++,C#一实现,我们把关注点主要放在它的数据驱动和依赖注射功能上,这些功能使得游戏设计者,关卡设计者程序员和美术师在游戏产品线上合作更密切。程序员的角色是实现可复用的游戏组件而关卡设计师则整合组件和美术师作品。在本文中,我们将展示怎样用GCore工具和技术去帮助程序员和关卡设计师。
3.1 主要概念
从软件工程的观点来看,GCore定义了四个主要的概念/类来描述一个游戏:GameManager,
GameState,GameObject, 和AbstractComponent,GameManager是一个整个系统的外观模式[Gamma et al,1995]而GameState类似于Use-case图,每一个State都是一个独立的游戏场景(或者其他用户交互概念)例如3D游戏场景,菜单或者是HUD,在执行期间,玩家选择这些场景而游戏在这些不同的GameState实例之间切换。一个可运行的GameState由一个唯一的名字和一序列游戏对象(GameObject)组成,每个游戏对象又由一序列AbstractComponet实现组成。在图表一中,可以看到GCore的一个简单的类图架构,而图表2则用顺序图通过每一个不同的步骤方法调用用的顺序来解释了GCore中用到的游戏循环概念。
图表 2 GCore 游戏循环 |
从图表2可以看出,每一个组件在每一帧频时都会被更新。在抽象类AbstractComponent中并没有渲染成员函数,因为它们中的大多数并不是一个具有图形属性的对象。相反,类GameObject维护了一个场景图结点,在需要时,该结点上的图形组件可以随时附加一个可以绘制的几何图元。在每一帧结束时,该结点都会被渲染,这样也使得该附加到该结点上的图形组件对象渲染到设备上。
3.2 以数据驱的游戏对象组合
GCore的高效生产率的主要原因是因为可以完全以一种声明的形式来创建游戏。图表3给出了一个组合游戏对象的例子,这是一个由两个GameObject 实例组成的GameState,每一个GameObject又由代表人物我行为不同组成构成。
在上面的例子中,游戏状态中有两个活动的游戏对象名字分别为“npc”和“tree”.Npc对象由一个具有图形描述的VisualComponent和在游戏执行中负责控制行为AIComponent组成。注意到由于Update方法每一帧时都会调用,因此两个组件都会被更新。在AIComponent中该更新方法包含了实际的AI步骤实现,而VisualComponent的更新方法确为空。另外一个命名为“Tree”的对象是静态的,它仅仅只由单一的含有由几何图形的VisualComponent组成。
游戏状态和对应的游戏对象都保存于XML文件之中,这样不但非常容易维护也适合用于集成例如关卡编辑器这样的集成开发工具。从代码段2,可以看到一个简单游戏的主要xml配置文件。配置文件的根结点元素是”Game”,该元素有两个属性,名字和第一个加载的游戏状态。”Game”元素必须由声明的类型和游戏状态组成,为了使它们能像期望的保持在不同的文件中,通过使用关键字“include”提供一个组合征用类型的方法,而这样类型又可以在游戏状态中作进一步的详细说明和初始化到游戏对象。
图表 3 组合实例 |
<game name="example" init="menu">
<!-- type definitions -->
<include file="types.xml" />
<!-- game states -->
<include file="menu.xml" />
<include file="farm.xml" />
</game>
Code 2: Main configuration file (game.xml)
类型由许多组件组成,这些组成可以拥有自己的可以在xml文件中说明的属性值。在代码段3中,我们可以看出以组合,继承和属性说明构成的类型声明。第一个类型,其名字为“Basic”,定义了所有的派生类和对象必须有一个VisualComponent附加于其上。第二个类型,其名字为”NpC”,描述了其继承于”basic”,因此它不但拥有来自父类的VisualComponent也附加了一个AIComponent。而”tree”类型通过继承”basic”和修改VisualComponet中的属性展示了GCore的能力,在这个例子中,我们定义了一个为任意”tree”类型游戏对象从外部加载3D模型的作为几何数据的描述。
<type name="basic">
<component class="VisualComponent" />
</type>
<type name="npc-type" extends="basic">
<component class="AIComponent" />
</type>
<type name="tree-type" extends="basic">
<component class="VisualComponent">
<model value="tree.3ds" />
</component>
</type>
Code 3: Sample type declarations (types.xml)
游戏状态由一些游戏对象组成,该对象可以从预先定义好的类型和说明派生而来,也可以插入它想要的任意组件。甚至可以定义一个没基类的对象,但我们并不推荐该方法因为它不利于代码重用。在代码段4展示了图表3游戏状态的xml文件。”npc”对象继承于预先定义好的”npc-type”并描述了用于VisualComponent的一个3D模型。”tree”对象描述了ViusalComponent一个新的位置向量。我们可以很容易的看出用数据驱动来组合游戏对象的灵活性,甚至可以让一个相同的类型对象拥有不同的名字的组件。类GameObject和AbstractComponent是用组合设计模式[Gamma et al.1995]来实现的,在需要的情况下,可以递归嵌套组件。
<gamestate name="farm">
<!-- game object1: npc -->
<object name="npc" type="npc-type">
<component class="VisualComponent">
<model value="zombie.3ds" />
</component>
</object>
<!-- game object2: tree -->
<object name="tree" type="tree-type">
<component class="VisualComponent">
<position x="10" z="15" />
</component>
</object>
</gamestate>
Code 4: Game state and objects composition (farm.xml)
3.3 xml解析和游戏执行
从上一节可以得出每个游戏完全由保存在xml文件中的原子数据来说明。该xml配置文件在初始化时解析并加载到一些被配置的对象中。在需要时,轻量级的原子数据结构可以在运行时用于初始化游戏状态和游戏对象。GCore具有解析java所有基本数据和三个值的向量,四元数和例如纹理、模型和声音文件的能力。当然你也可以通过继承PropertyParse类解析任意用户自定义的数据类型。
在游戏中,必须至少具有一个默认的游戏状态,该状态将在最初时被加载。由于游戏是由一系列这样的游戏状态组成,在运行时,必须用一种方法来初始化,析构和切换它们。为了提供一个这些特点犀利实现,GameManager类实现了一个中介模式[Gamma et al.1995].该模式使用了公有方法通过状态名来激活(重新激活),暂停或者销毁任意声明的游戏状态。销毁之前启用的游戏状态是可以选择的(在激活另一个状态之前),因为同上时间在内存中可以存在多个状态。
GameManager类也负责游戏对象和组件的正确初始化。在初始化游戏状态阶段,我们使用了生成器设计模式(Builder)[Gamma et al.1995]来初始化游戏状态,游戏对象,组件和属性。当然也解决了依赖注射。然而,为了可读性,我们将在下一节展示一个简化的版本而不是整个过过程。因为我们的关注点主要是依赖注射,以及它的优点和实现细节。
4 GCore中的依赖注射
在GCore中,所有组件之间的直接依赖都可以用该框架来解决。首先,我们来考虑一下,一个简单的游戏对象怎样能被一些可重用的组件来实现,该游戏对象由一个外部3D模型,玩家输入,和跟踪相机组成。理想情况下,为了代码复用性,这些不同的部分将用不同的组件来实现,例如VisualComponent,PlayerInput和ChaseCamera.VisualComponent可以独立于其它两个组件用于例如房屋和树之类的需要一个图形描述的静态对象。这些都不需要被一个相机跟踪或者受用户输入控制,因此没有必要在VisualComponent中包含其它两个组件。然而,当用于组合一个玩家对象,ChaseCamera和PlayerInput将被使用,并且它们都依赖于几何数据的方向。它们分别使用该几何数据作为观察目标或者根据用户命令更新。VisualComponent已经定义了满足该要求一个几何属性(它加载的模型)。图表4展示了这些对象的关系图。玩家游戏对象有一系列的组件,这些组件都是AbstractComponent的子类。
图表 4 依赖组件实例 |
图表4中的组件c2和c3都依赖于组件c1。很明示ChaseCamera和PlayerComponent都有一个VisualComponent类型的属性,因此它们可以在各自的更新中使用该组件,而不是在更新函数中手动查找该组件,组件程序员只需要包含如代码段5所示的在运行时可以获取自定义标记 @Inject到属性声明中即可。当初始化每个组件时,正如我们将在下一节解释的,如果符号@Inject在任意声明之前找到,我们的框架将在同一个游戏对象中查找一个该组件类型的实例并设置为其属性。
class ChaseCamera extends AbstractComponent {
@Inject
VisualComponent vc;
public void update(float interpolation){
camera.lookAt(vc.getWorldTranslation());
}
}
Code 5: @Inject in ChaseCamera source code
从上面的代码可以看出,没有一行代码是去查找一个VisualComponent并且检查它是否为空的。也不难发现,和代码段1相比,上面的代码更短,更简洁和安全因为在初始化时如果没有VisualComponent声明该框架将停止初始化并给出一个错误日志。代码段6是一个正确的玩家对象组成Xml文件。
<object name="player">
<component class="VisualComponent">
<model value="knight.md5" />
</component>
<component class="PlayerInput" />
<component class="ChaseCamera" />
</object>
Code 6: Correct player object composition
图表 5 游戏对象初始化 |
由于声明中包含了一个VisualComponent,和其它两个依赖该组件的组件,初始化可以正常进行,游戏对象的初始化顺序如图表5所示
从图表5中可以看出,当创建游戏状态时,所有的游戏对象和组件及它们的声明属性将首先被初始化。经过第一个步骤(图表中消息2,3和4),游戏对象被正确的初始化,组件的依赖也被注入(消息5和6)。最后,通过依赖注入,所有声明的组件都被附加到游戏对象上。也使得所有的依赖得到解决。
在代码段7中,描述了一个不正确的玩家对象组成。让我们想象一下,例如一个头上设计师犯了一个错误,他认为“character”的父类已经拥有了一个VisualComponent,仅仅只包含了另外两个所需的组件。因为这样的描述是不合法的,我们的框架将在初始化时给出一个如代码段8所示的错误而不会导致一个未知的运行时错误。
<object name="player" type="character">
<component class="PlayerInput" />
<component class="ChaseCamera" />
</object>
Code 7: Incorrect player object composition
"Incomplete composition of object: ’player’.
Missing required ’VisualComponent’
needed by included ’ChaseCamera’."
Code 8: Unsolved dependency initialization error
通过自动处理组件之间的耦合和安全的初始化游戏对象,程序员将不再需要手动来检查显示的依赖和空引用。从上面的例子可以得出:由于更少代码调试的依赖,游戏产品线中的关卡设计得到了提高。
4.2 实例2:游戏机制的快速原型
在上面的介绍中,我们指出由对象和组件之间依赖所造成问题之一是许多代码被用来处理与游戏逻辑无关问题。像这样的样板代码会耗费程序员大量的时间,通常,调试这些代码也会耗费很多时间。在该实例中,我将展示如果用依赖注射通过使程序员只集中核心机制的实现来提高组件快速原型技术。
假设我们将要实现一个“月球货船”游戏,该游戏的核心机制由控制一个火箭助推的重型运输工具组成。实现的目标是尽快的暴露核心机制给早期的的测试人员。以下几点是这个原型的重要特点:
1. 有一个类似于月球的地形
2. 重力和物理碰撞系统
3. 3D模型加载
4. 推动登月仓的玩家控制(这是最重要的一点)
很容易看出特点1-3在其它游戏系统中也会用到,而且在GCore中这些特点已经被开发为可复用的组件了,因此只需要实现最后一个玩家控制推动器即可。在代码段9中,我们可以看到用于该原型的xml文件,该xml文件声明了一个由地形和登月仓游戏对象组成的游戏状态,推动器类的实现将在下一部分给出。
<game name="lunarCargo" init="moon">
<gamestate name="moon">
<!-- prototype object1: terrain -->
<object name="terrain">
<component class="TerrainComponent">
<heightmap value="moon.png" />
</component>
<component class="TerrainPhysics" />
</object>
<!-- prototype object2: lunar module -->
<object name="module">
<component class="VisualComponent">
<model value="cargo-ship.3ds" />
</component>
<component class="DynamicPhysics" />
<component class="Thrust" />
</object>
<gamestate>
<game>
Code 9: Lunar Cargo prototype XML
GCore的DynamicPhysics组件依赖于VisualComponent,因为前者不但要使用它作为碰撞的几何数据还要在物理模拟时移动它。很明显推动器的实现也依赖于DynamicPhysics,因为当用户输入“推动”时,DynamicPhysics必须应用力到推动器上。代码段10展示了推动器类的实现,推动器对DynamicPhysics的依赖通过符号说明@Inject暴露给框架,更新(Update)方法在行动时默认该属性值是非空的。
很明显程序员可以更集中精力于核心机制的实现上:检查用户的输入并将其实施于登月仓推动器的物理系统上。通过这种方法,我们相信人们可以在游戏开发流程中写出更好的代码和获得更快的原型又不用花很多时间在设计上。
5小结
数据驱动被证实为在游戏开发流程中管理风险的好方法。正如许多成功的游戏引擎和架构展示的一样,将这些与一个良好设计的游戏引擎结合起来,将会得到一个强大的框架。然而,面向对象,特别是组件组合,有许多维护性和高度依赖的问题。在本文中,我们描述了一个基于依赖注射的方法安全的将这些职责从程序员中移除。
众所周知,在游戏开发周期中,基本上不可能同时写个良好设计代码并且实现快速原型。我们坚信通过使用GCore可以实现快速原型并且不用放弃良好的编程实践。通过使用依赖注射,我们完全移除了在游戏对象脚本中是十分常见的隐式依赖硬编码。GCore组件库,提供了一个强大的,可扩展的和安全的工具。
GCore是一个正在进行的长期工作,我们现在正研究怎样将这些技术用到其它场合例如组件之间依赖和从其它游戏对象和组件获取属性的依赖的游戏设计。我们计划扩展这些符号的使用以至于程序员可以应用如not-aull,mix/max values/length这样的约束到任意基础类型(附加文件,字符串,向量和四元数)。我们短期计划也包括了一个提供可视化组合的关卡编辑,这样关卡设计师就不用亲自写xml说明文件。
致谢
我们要感谢Scott Bilas对游戏的对象组件的依赖和其他相关的软件工程和目前在游戏引擎的发展趋势有关问题的有意义的讨论。
我们也非常感谢在JMonkeyEngine讨论论坛的每个人,特别是发开发者,总是愿意帮助解决渲染,音频和物理等问题,你们的帮助才使得GCore框架成为可能。