原文地址
上周我发布了Ash,一个Actionscript游戏开发实体系统框架,许多人问我这个问题"什么是实体系统框架?"本文就是我详尽的答案.
实体系统逐渐流行起来了,比较著名的有像Unity,不太知名的框架像Actionscript框架Ember2,Xember和我自己的Ash.使用这个有一个很好的理由:它们简化了游戏架构,鼓励在你的代码中进行清晰的职责分工而且用起来很有趣.
在本文中我将向你展示一个基于实体的架构从经典而流行的游戏循环演变而来.这可能会花点时间.例子将使用Actionscript因为这正好是我当前使用的语言,但其架构理念可以用于所有的编程语言.
这基于我在2011年在try{harder}上的一次展示
贯穿整篇文章,我将使用一个简单的小行星游戏作为一个例子.我比较喜欢使用小行星游戏是因为涉及到了大游戏中很多系统的简化版本-渲染,物理,ai,用户所控制的角色和npc.
要理解为什么我们使用实体系统,你得了解经典且流行的游戏循环.小行星游戏的游戏循环可能会类似下面展示的:
function update(time:Number):void { game.update(time); spaceship.updateInputs(time); for each (var flyingSaucer:FlyingSaucer in flyingSaucers) { flyingSaucer.updateAI(time); } spaceship.update(time); for each (var flyingSaucer:FlyingSaucer in flyingSaucers) { flyingSaucer.update(time); } for each (var asteroid:Asteroid in asteroids) { asteroid.update(time); } for each (var bullet:Bullet in bullets) { bullet.update(time); } collisionManager.update(time); spaceship.render(); for each (var flyingSaucer:FlyingSaucer in flyingSaucers) { flyingSaucer.render(); } for each (var asteroid:Asteroid in asteroids) { asteroid.render(); } for each (var bullet:Bullet in bullets) { bullet.render(); } }
游戏循环在固定的间隔被调用,通常是每秒60次或者每秒30次来更新游戏.当我们在每帧更新各种各样的游戏对象检查它们之间的碰撞然后将他们全部绘制时循环中的操作顺序是很重要的.
这是一个非常简单的游戏循环.它简单是因为:
实体系统架构衍生于解决游戏循环弊病的一种尝试.它将游戏循环看做游戏的核心,且预先假设在现代游戏架构中简化游戏循环比其他事物重要得多.例如,比视图和控制器分离重要得多.
在这种演变中第一步要考虑的是被称作进程的对象.这些对象可被初始化,定期更新以及销毁.一个进程的接口类似如下所示:
interface IProcess { function start():Boolean; function update(time:Number):void; function end():void; }
如果我们将游戏循环拆分为许多进程来处理我们可以简化游戏循环,例如,渲染,移动,碰撞检测.要管理这些进程我们创建一个进程管理器.
class ProcessManager { private var processes:PriorirtiesdList; public function addProcess(process:IProcess,priority:int):void { if(process.start()) { processes.add(process,priority); return true; } return false; } public function update(time:Number):void { for each(var process:IProcess in processes) { process.update(time); } } public function removeProcess(process:IProcess):void { process.end(); processes.remove(process); } }这是一个稍微简化版的进程管理器.特别是我们要确定我们以正确的顺序更新进程(顺序由add方法中的priority参数指定)并且我们需要处理在更新循环中移除进程的情况.但你已经了解了概念.如果我们的游戏循环被拆分为多个进程,那我们进程管理器的更新方法是我们新的游戏循环且进程变成了游戏的核心.
作为一个例子我们看下渲染进程.我们需要将渲染代码从原来的游戏循环中抽出来并将其放置在一个进程中,代码类似如下:
class RenderProcess implements IProcess { public function start():Boolean { //initialize render system return true; } public function update(time:Number):void { spaceship.render(); for each(var flyingSaucer:FlyingSaucer in flyingSaucers) { flyingSaucer.render(); } for each(var asteriod:FlyingSaucer in asteriods) { asteriod.render(); } for each(var bullet:Bullet in bullets) { bullet.render(); } } public function end():void { //clean-up render system } }
interface IRenderable { function render():void; }
class RenderProcess implements IProcess { private var targets:Vector.<IRenderable>; public function start():Boolean { //initialize render system return true; } public function update(time:Number):void { for each(var target:IRenderable in targets) { target.render(); } } public function end():void { //clean-up render system } }之后我们的飞船类将包含一些类似下面的代码
class Spaceship implements IRenderable { public var view:DisplayObject; public var position:Point; public var rotation:Number; public function render():void { view.x = position.x; view.y = position.y; view.rotation = rotation; } }代码基于flash显示列表.如果我们使用blitting(早期创建像素游戏的一种做法)或者使用stage3d,可能会有些不同,但原理是相同的.我们需要要渲染的图形,渲染图形的位置和渲染值.渲染函数进行渲染.
事实上,这段代码中没有任何东西使其与飞船(spaceship)不同.所有的代码都被可渲染对象共享.它两唯一的不同之处是哪个显示对象被赋值给view属性以及位置和旋转值.因此我们将这代码封装(wrap)到基类并使用继承.
class Renderable implements IRenderable { public var view:DisplayObject; public var position:Point; public var rotation:Number; public function render() { view.x = position.x; view.y = position.y; view.rotation = rotation; } }
class Spaceship extends Renderable { }当然,所有的可渲染物件都会继承自renderable类,所以我们得到如下图所示的一个类层级关系图.
要理解下一步,我们首先需要看下另外一个进程和其影响的类.因此我们尝试一下移动进程,它用于更新物体的位置.
interface IMoveable { function move(time:Number); }
class MoveProcess implements IProcess { private var targets:Vector.<IMoveable>; public function start():Boolean { return true; } public function update(time:Number):void { for each(var target:IMoveable in targets) { target.move(time); } } public function end():void { } }
class Moveable implements IMoveable { public var position:Point; public var rotation:Number; public var velocity:Point; public var angularVelocity:Number; public function move(time:Number):void { position.x += velocity.x; position.y += velocity.y; rotation += angularVelocity; } }
class Spaceship extends Moveable { }
这还算好,但不幸的是我们需要我们的飞船既可以移动也可以被渲染出来,并且许多现代程序设计语言不允许多重继承.
即使是在这些允许多重继承的语言里,我们也有这样的问题,这个问题是Moveable类中的position与rotation应该与Renderable类中的position和rotation相等.
一个常用的解决方案是使用继承链,以便Moveable继承自Renderable
class Moveable extends Renderable implements IMoveable { public var velocity:Point; public var angularVelocity:Number; public function move(time:Number):void { position.x += velocity.x; position.y += velocity.y; rotation += angularVelocity; } }
class Spaceship extends Moveable { }现在飞船既可以移动又可以渲染.我们可将同样的原则应用于其他的游戏对象来得到这样的类层级
我们甚至可以有只继承自Renderable的静态对象.
但如果我们想要一个可移动却不可渲染的对象将会怎样?比如,一个无形的游戏对象?这时我们的类层级失效了,我们需要一种Moveable接口不继承自Renderable的替代方案.
class InvisibleMoveable implements IMoveable { public var position:Point; public var rotation:Number; public var velocity:Point; public var angularVelocity:Number; public function move(time:Number):void { position.x += velocity.x; position.y += velocity.y; rotation += angularVelocity; } }
在一个简单的游戏中,这样做虽然有点笨但还是掌控的,但在一个使用继承来将进程应用于对象的复杂的游戏中就会迅速变得不可控制,因为你很快发现游戏里的对象不适应一个简单的线性继承树,就像上面的force-field一样.
长久以来面向对象编程的一条好原则就是多使用合成.在此处应用该原则可避免我们陷入潜在的继承紊乱.
我们仍旧需要Renderable和Moveable类,我们将创建包含这两个类实例的飞船类而不是继承这些类来创建飞船类.
class Renderable implements IRenderable { public var view:DisplayObject; public var position:Point; public var rotation:Number; public function render(time:Number):void { view.x = position.x; view.y = position.y; view.rotation = rotation; } }
class Moveable implements IMoveable { public var position:Point; public var rotation:Number; public var velocity:Number; public var angularVelocity:Number; public function move(time:Number):void { position.x += velocity.x; position.y += velocity.y; rotation += angularVelocity; } }
class Spaceship { public var renderData:IRenderable; public var moveData:IMoveable; }使用这种方法,我们可以以我们想要的任何方式来合并多种行为(behaviours)而不用遭遇继承问题.
通过这种合成生成的对象,静态对象,太空船,飞碟,小行星,子弹和force field都叫做实体(entities).
我们的线程依旧没有变化
interface IRenderable { function render():void; }
class RenderProcess implements IProcess { private var targets:Vector.<IRenderable>; public function update(time:Number):void { for each(var target:IRenderable in targets) { target.render(); } } }
interface IMoveable { function move():void; }
class MoveProcess implements IProcess { private var targets:Vector.<IMoveable>; public function update(time:Number):void { for each(var target:IMoveable in targets) { target.move(time); } } }但我们不将飞船实体添加到每个线程而是添加飞船实体的成员.因此当我们创建飞船时所做如下:
public function createSpaceship():Spaceship { var spaceship:Spaceship = new Spaceship(); ... renderProcess.addItem(spaceship.renderData); moveProcess.addItem(spaceship.moveData); ... return spaceship; }这种方法看起来很棒.它使我们可以自由打乱和匹配不同游戏对象之间的进程支持而无需陷入继承链或者重复造轮子,但这有一个问题.
由于移动线程将会改变Moveable实例的值且渲染线程要使用Renderable实例的值,Renderable类实例中的position属性和rotation属性需要与Moveable类实例中的position和rotation属性数值相同.
class Renderable implements IRenderable { public var view:DisplayObject; public var position:Point; public var rotation:Number; public function render():void { view.x = position.x; view.y = position.y; view.rotation = rotation; } }
class Moveable implements IMoveable { public var position:Point; public var rotation:Number; public var velocity:Point; public var angularVelocity:Number; public function move(time:Number):void { position.x += velocity.x; position.y += velocity.y; rotation += angularVelocity; } }
class Spaceship { public var renderData:IRenderable; public var moveData:IMoveable; }要解决这个问题,我们需要确保这两个类实例都指向这些属性的相同实例.在Actionscript中这意味着这些属性必须是对象,因为对象可以按引用传递而基础类型是按值传递的.
所以我们引入另一组类,我们称其为组件(components).这些组件是一些值对象,值对象将属性包裹在(wrap)对象中以便在进程间共享.
class PositionComponent { public var x:Number; public var y:Number; public var rotation:Number; }
class VelocityComponent { public var velocityX:Number; public var velocityY:Number; public var angularVelocity:Number; }
class DisplayComponent { public var view:DisplayObject; }
class Renderable implements IRenderable { public var display:DisplayComponent; public var position:PositionComponent; public function render():void { display.view.x = position.x; display.view.y = position.y; display.view.rotation = position.rotation; } }
class Moveable implements IMoveable { public var position:PositionComponent; public var velocity:VelocityComponent; public function move(time:Number):void { position.x += velocity.velocityX * time; position.y += velocity.velocityY * time; position.rotation += velocity.angularVelocity * time; } }当我们创建飞船我们确保Moveable和Renderable实例共享相同的PositionComponent实例.
class Spaceship { public function Spaceship() { moveData = new Moveable(); renderData = new Renderable(); moveData.position = new PositionComponent(); moveData.velocity = new VelocityComponent(); renderData.position = moveData.position; renderData.display = new DisplayComponent(); } }进程仍没有受到该改变的影响.
到目前为止我们有了整齐分开的任务.游戏循环(game loop)循环遍历(cycle through)进程,调用每个进程上的更新方法.每个进程包含了一个对象集合,集合中的对象实现了其操作的接口并将调用这些对象的适当方法.这些对象每个都对其数据做单一而重要的作业(task).通过组件系统,这些对象能够共享数据,因此多个线程的组合能够在游戏实体中产生复杂的更新同时保证每个进程相对简单.
这个架构类似于游戏开发中的许多实体系统.架构遵循了良好的面向对象程序设计原则且能够运转.但在更多事情到来之前,我们先开始疯一把
当前的架构使用了良好的面向对象实践,像封装和单一职责原则(single responsibility)——IRenderable和IMoveable的实现封装了在每帧中游戏实体更新的单一职责的数据和逻辑——而合成——Spaceship实体是通过组合IRenderable和IMoveable接口的实现而创建的.通过组件系统(system of components)我们确保在恰当的地方数据在实体的不同数据类之间共享.
实体系统改革中的下一步或多或少有点不直观,打破了面向对象程序设计的核心教条之一.我们在Renderable和Moveable实现中打破了数据和逻辑的封装.特别是我们将逻辑从这些类中抽出反而将其放置在进程中
所以这个
interface IRenderable { function render(); }
class Renderable implements IRenderable { public var display:DisplayComponent; public var position:PositionComponent; public function render():void { display.view.x = position.x; display.view.y = position.y; display.view.rotation = position.rotation; } }
class RenderProcess implements IProcess { private var targets:Vector.<IRenderable>; public function update(time:Number):void { for each (var target:IRenderable in targets) { target.render(); } } }变成了这个
class RenderData { public var display:DisplayComponent; public var position:PositionComponent; }
class RenderProcess implements IProcess { private var targets:Vector.<RenderData>; public function update(time:Number):void { for each(var target:RenderData in targets) { target.display.view.x = target.position.x; target.display.view.y = target.position.y; target.display.view.rotation = target.position.rotation; } } }而这个
interface IMoveable { function move(time:Number):void; }
class Moveable implements IMoveable { public var position:PositionComponent; public var velocity:VelocityComponent; public function move(time:Number):void { position.x += velocity.velocityX * time; position.y += velocity.velocityY * time; position.rotation += velocity.angularVelocity * time; } }
class MoveProcesss implements IProcess { private var targets:Vector.<IMoveable>; public function move(time:Number):void { for each(var target:Moveable in targets) { target.move(time); } } }变成了这样
class MoveData { public var position:PositionComponent; public var velocity:VelocityComponent; }
class MoveProcess implements IProcess { private var targets:Vector.<MoveData>; public function move(time:Number):void { target.position.x += target.velocity.velocityX * time; target.position.y += target.velocity.velocityY * time; target.position.rotation += target.velocity.angularVelocity * time; } }(可能)不会立即明白为什么我们这么做,但是包涵一下.从表面上看,我们移除了接口的必要(removed the need for the interface),并且我们给线程更重要的事情来做——而不是简单的将其工作委托给IRenderable或者IMoveable的实现,它自己要做事(it does the work itself).
这样第一个明显的结果是所有的实体必须使用相同的渲染方法,因为渲染代码现在在RenderProcess里.但事实上并不这么回事.例如,我们能够有两个进程比如RenderMoveClip和RenderBitmap且它们能够处理不同的实体集.所以我们并没有丧失任何灵活性.
我们所得到的是重构我们实体的重要能力来产生一个架构,该架构有着明确的分工和简单的配置.重构以一个问题开始.
当前,我们的实体
class Spaceship { public var moveData:MoveData; public var renderData:RenderData; }包含两个数据类
class MoveData { public var position:PositionComponent; public var velocity:VelocityComponent; }
class RenderData { public var display:DisplayComponent; public var position:PositionComponent; }这些数据类反过来包含三个组件
class PositionComponent { public var x:Number; public var y:Number; public var rotation:Number; }
class VelocityComponent { public var velocityX:Number; public var velocityY:Number; public var angularVelocity:Number; }
class DisplayComponent { public var view:DisplayObject; }数据类由两个进程使用
class MoveProcess implements IProcess { private var targets:Vector.<MoveData>; public function move(time:Number):void { for each(var target:MoveData in targets) { target.position.x += target.velocity.velocityX * time; target.position.y += target.velocity.velocityY * time; target.position.rotation += target.velocity.angularVelocity * time; } } }
class RenderProcess implements IProcess { private var targets:Vector.<RenderData>; public function update(time:Number):void { for each(var target:RenderData in targets) { target.display.view.x = target.position.x; target.display.view.y = target.position.y; target.display.view.rotation = target.position.rotation; } } }但是实体不应该关心数据类.组件共同(collectively)包含实体的状态.数据类为进程的方便而存在.因此我们重构代码以便spaceship实体包含组件(components)而不是数据类
class Spaceship { public var position:PositionComponent; public var velocity:PositionComponent; public var display:DisplayComponent; }
class PositionComponent { public var x:Number; public var y:Number; public var rotation:Number; }
class VelocityComponent { public var velocityX:Number; public var velocityY:Number; }
class DisplayComponent { public var view:DisplayObject; }通过移除数据类,使用构成组件(constituent components)替代来定义spaceship,我们移除了spaceship实体需要知道那个进程或许会对其起作用的必要(we have removed any need for the spaceship entity to know what process may act on it).spaceship现在包含定义其状态的组件.任何为进程要求合并这些组件到其他数据类都是别的类的事儿.
当进程需要时,实体系统框架(我们很快会接触到)中的一些核心代码会动态的创建这些数据对象.在这个简化的语境(reduced context)中,数据类将只是由进程使用的集合(数组,链表或者别的依据实现而不同)中的节点.所以为弄清楚这我们将它们重命名为节点.
class MoveNode { public var position:PositionComponent; public var velocity:VelocityComponent; }
class RenderNode { public var display:DisplayComponent; public var position:PositionComponent; }进程并没有改变,但是为了保持共同的命名约定我也将它们的名称改变并称其为系统.
class MoveSystem implements ISystem { private var targets:Vector.<MoveNode>; public function update(time:Number):void { for each(var target:MoveNode in targets) { target.position.x += target.velocity.velocityX * time; target.position.y += target.velocity.velocityY * time; target.position.rotation += target.velocity.angularVelocity * time; } } }
class RenderSystem implements ISystem { private var targets:Vector.<RenderNode>; public function update(time:Number):void { target.display.view.x = target.position.x; target.display.view.y = target.position.y; target.display.view.rotation = target.position.rotation; } }
interface ISystem { function update(time:Number):void; }
最后一点改变--Spaceship类没有什么特别的.他只是一个组件容器.所以我们将只称其为实体并给它一个组件集合.我们将根据这些组件的类类型(class type)来访问它们.
class Entity { private var components:Dictionary; public function add(component:Object):void { var componentClass:Class = component.constructor; components[componentClass] = component; } public function remove(componentClass:Class):void { delete components[componentClass]; } public function get(componentClass:Class):void { return components[componentClass]; } }所以我们像这样创建我们的spaceship
public function createSpaceship():void { var spaceship:Entity = new Entity(); var position:PositionComponent = new PositionComponent(); position.x = Stage.stageWidth / 2; position.y = Stage.stageHeight / 2; position.rotation = 0; spaceship.add(position); var display:DisplayComponent = new DisplayComponent(); display.view = new SpaceshipImage(); spaceship.add(display); engine.add(spaceship); }
我们决不能忘记系统管理器,之前叫做进程管理器
class SystemManager { private var systems:PriorirtiesdList; public function addSystem(system:ISystem,priority:int):void { systems.add(system,priority); system.start(); } public function update():void { for each(var system:ISystem in systems) { system.update(time); } } public function removeSystem(system:ISystem):void { system.end(); systems.remove(system); } }
这将会被加强(enhanced)并处于我们实体系统框架的核心.我们将为其添加我们之前提到的功能以便为系统动态创建节点.
实体只关心组件,系统只关心节点.因此,要完成实体系统框架,当实体改变,为系统所用的节点结合添加或者删除组件式,我们需要监视实体的代码.因为这是实体和系统都知道的那一点代码,我们可能考虑将其作为游戏的中心.在Ash中,我称这为Engine类,它是系统管理器的一个增强版.
当你开始使用或停止使用Engine类时,每个实体和系统都被添加到或者从Engine类删除.Engine类跟踪实体上的组件并创建(和在必要时销毁)节点,将这些节点添加到节点集合中.Engine类也为系统提供了一种方法来得到其需要的集合.
public class Engine { private var entities:EntityList; private var systems:EntityList; private var nodeLists:Dictionary; public function addEntity(entity:Entity):void { entities.add(entity); //create nodes from this entity's components and add them to node lists //also watch for later addition and removal of components from the entity so //you can adjust its derived nodes accordingly } public function removeEntity(entity:Entity):void { //destory nodes from this entity's components //and remove them from the node lists entities.remove(entity); } public function addSystem(system:System,priority:int):void { systems.add(system,priority); system.start(); } public function removeSystem(system:System):void { system.end(); systems.remove(system); } public function getNodeList(nodeClass:Class):NodeList { var nodes:NodeList = new NodeList(); nodeLists[nodeClass] = nodes; //create the nodes from the current set of entities //and populate the node list return nodes; } public function update(time:Number):void { for each(var system:ISystem in systems) { system.update(time); } } }
要查看该架构的一个实现,checkout Ash实体系统框架并看看例子小行星游戏的实现.
所以,总结一下,实体系统发源于想要简化游戏循环.从哪里衍生出了实体架构,它代表着游戏的状态和系统,系统作用于游戏状态.系统在每帧中都会更新--这就是游戏循环.实体由组件(components)构成,系统作用于含有它们感兴趣的组件的实体.引擎管理者系统和实体并确保每个系统能够访问到一组有对应组件的实体集合.
然而,系统通常不关心作为整体的实体,只关心它们需要的特定组件.因此,要优化该架构并提供额外的净化度(additional clarity),系统作用于静态类型的包含对应组件的节点对象,在节点对象中这些组件都属于同一个实体.
一个实体系统框架为这种架构提供了基本的架子(scaffolding)和核心管理,没有提供任何实际的实体或者系统类.你通过创建对应的实体和系统来创建游戏.
一个基于实体的游戏引擎将在基本的框架上提供许多标准的系统和实体.
3个Actionscript实体系统框架是我自己的Ash,Tom Davies的Ember2和Alec McEachran的Xember.Artemis是一个java的实体系统框架,且已经移植到了C#.
我的下一篇文章将会涉及到我为什么在我的游戏开发项目中喜欢使用实体系统框架的原因.