游戏开发中的实体系统框架是什么?
上周我发布了Ashley(以下简称Ash),一个用于Actionscript游戏开发的实体系统框架,一些人问我“什么是实体系统框架?这是我相当长的答案。
实体系统越来越受欢迎,有着名的例子,如Unity,以及不太知名的框架,如Actionscript框架Ember2,Xember和我自己的Ash。这有一个很好的理由;他们简化了游戏架构,鼓励在代码中分离责任,并且使用起来很有趣。
在这篇文章中,我将向您介绍基于实体的架构是如何从老式的游戏循环演变而来的。这可能需要一些时间。示例将使用Actionscript(flash脚本语言)语言,因为恰好是我正在使用的,但是该架构适用于所有编程语言。
这是基于2011年我在{harder}尝试的演示。
在这篇文章中,我将使用一个简单的小行星游戏作为例子。我喜欢使用小行星作为例子,因为它涉及到了:渲染,物理,ai,用户控制字符,非玩家角色。
游戏循环
要理解为什么我们使用实体系统,你真的需要了解老式的游戏循环。小行星的游戏循环可能看起来像这样
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();
}
}
这个游戏循环是在规则的间隔,通常每秒调用30次或60次,以更新游戏。循环中的操作顺序很重要,因为我们更新各种游戏对象,检查它们之间的碰撞,然后绘制它们。
这是一个非常简单的游戏循环。这很简单,因为
1. 游戏很简单
2. 游戏只有一个状态
在过去,我曾经为控制游戏循环做了很多工作,一个单一的功能,超过3,000行代码。它不漂亮,且不聪明。这就是游戏的建立方式,我们不得不忍受它。
实体系统架构来源于尝试解决游戏循环的问题。它将游戏循环视为游戏的核心,并且预先假设简化游戏循环比现代游戏架构中的任何其他任务更重要。比如,比从控制器分离视图更重要。
进程
第一步是考虑被称为进程的对象。这些是可以初始化,定期更新和销毁的对象。进程的接口看起来像这样:
interface IProcess
{
function start():Boolean;
function update( time:Number ):void;
function end():void;
}
我们可以简化你的游戏循环,如果我们将它分成若干进程来处理,例如渲染,移动,冲突解决。 为了管理这些进程,我们创建了一个进程管理器:
class ProcessManager
{
private var processes:PrioritisedList;
public function addProcess( process:IProcess, priority:int ):Boolean
{
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
{
// initialise render system
return true;
}
public function update( time:Number ):void
{
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();
}
}
public function end() : void
{
// clean-up render system
}
}
使用接口
但这不是很有效率。 我们仍然需要手动渲染所有不同类型的游戏对象。 但如果对所有可渲染对象有一个通用接口,我们可以简化很多事情。
interface IRenderable
{
function render();
}
class RenderProcess implements IProcess
{
private var targets:Vector.<IRenderable>;
public function start() : Boolean
{
// initialise 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
}
}
然后我们的spaceship类(太空船)可能包含这样的代码
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,它会是不同的,但原则是一样的。 我们需要渲染的图像,以及渲染它的位置和旋转。 render函数进行渲染。
使用基类和继承
事实上,在这段代码中没有什么使它成为一个独特的太空船。 所有的代码可以被所有可渲染对象共享。 唯一让它们不同的是哪个显示对象被分配给view属性,以及什么是位置和旋转。 所以让我们在一个基类中使用继承。
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 Spaceship extends 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 * time;
position.y += velocity.y * time;
rotation += angularVelocity * time;
}
}
class Spaceship extends Moveable
{
}
多重继承
这几乎是好的,我们希望我们的宇宙飞船既可移动又可渲染,但不幸的是,许多编程语言不允许多重继承。
即使在那些允许多重继承的语言中,我们也有一个问题,Moveable类中的位置和旋转应该与Renderable类中的位置和旋转相同。
一个常见的解决方案是使用继承链,因此让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 * time;
position.y += velocity.y * time;
rotation += angularVelocity * time;
}
}
class Spaceship extends Moveable
{
}
现在宇宙飞船是可移动的和可渲染的。 我们可以对其他游戏对象应用相同的原则来获得这个类层次结构。
我们甚至可以有静态对象,只是扩继承Renderable。
可移动但不可渲染
但是如果我们想要一个不可渲染的可移动对象呢? 例如,一个看不见的游戏对象? 现在我们的类层次结构分解了,我们需要实现可移动接口(IMoveable)但不继承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 * time;
position.y += velocity.y * time;
rotation += angularVelocity * time;
}
}
在一个简单的游戏中,这是笨拙但可管理的,但在一个复杂的游戏使用继承来应用进程到对象很快就变得无法管理,因为你很快会发现在游戏中的项目,不适合一个简单的线性继承树。
喜欢组合继承(Favour composition over inheritance)
长期以来一直是面向对象编程的有利于合成而不是继承的合理原则。 在这里应用这个原则可以拯救我们这种潜在的继承混乱。
我们仍然需要Renderable和Moveable类,但是不是扩展这些类来创建太空船类,我们将在太空船类里创建一个包含每个类的实例。
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 * time;
position.y += velocity.y * time;
rotation += angularVelocity * time;
}
}
class Spaceship
{
public var renderData:IRenderable;
public var moveData:IMoveable;
}
这样,我们可以以任何我们喜欢的方式组合各种行为,而不会遇到继承问题。
这个对象是由组合物,静态对象,宇宙飞船,飞碟,小行星,子弹和力场组成的,这个对象被统称为实体。
我们的流程保持不变。
interface IRenderable
{
function render();
}
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();
}
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;
}
这种方法看起来不错。 它给我们自由地混合和匹配不同游戏对象之间的过程支持,而不会进入spagetti继承链或重复我们自己。 但有一个问题。
共享数据怎么办?
Renderable类实例中的位置和旋转属性需要与Moveable类实例中的位置和旋转属性具有相同的值,因为Move过程将更改Moveable实例中的值,并且Render进程将使用 可渲染的实例。
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 * time;
position.y += velocity.y * time;
rotation += angularVelocity * time;
}
}
class Spaceship
{
public var renderData:IRenderable;
public var moveData:IMoveable;
}
为了解决这个问题,我们需要确保两个类的实例中引用这些属性必须是同一个实例。 在Actionscript中,这意味着这些属性必须是对象,因为对象可以通过引用传递。
所以我们引入另一组类,我们称为组件。 这些组件仅仅是将属性包装到对象中用于在进程之间共享的值对象。
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();
}
}
原来的流程并没有因为这个改变而受到影响。
一个好地方停下来(A good place to pause)
在这一点上,我们有一个整洁的任务分离。游戏循环遍历过程,调用每个进程的更新方法。每个进程包含实现其操作的接口的对象集合,并将调用这些对象的适当方法。这些对象每个对其数据执行单个重要任务。通过组件系统,这些对象能够共享数据,因此多个进程的组合可以在游戏实体中产生复杂的更新,同时保持每个进程相对简单。
这个架构类似于游戏开发中的一些实体系统。该架构遵循良好的面向对象原则,它的工作原理。但是还有更多的事情,从疯狂的时刻开始。
放弃良好的面向对象实践(Abandoning good object-oriented practice)
当前的架构使用良好的面向对象的实践,如封装和单一责任--IRenderable和IMoveable实现封装数据和逻辑单个责任在每个框架和组成的游戏实体的更新 - 通过组合实现的宇宙飞船实体IRenderable和IMoveable接口。通过组件系统,我们确保在适当的情况下,实体的不同数据类之间共享数据。
实体系统的这种演化的下一步有点违反直觉,打破了面向对象编程的核心原则之一。我们在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 );
}
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 MoveProcess 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
{
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;
}
}
}
从表面上看,我们已经删除了对接口的需求,并且我们已经给这个过程更重要的事情 - 而不是简单地将其工作委派给IRenderable或IMoveable实现,它自己完成了工作。
这第一个明显的后果是所有实体必须使用相同的渲染方法,因为渲染代码现在在RenderProcess中。 但事实并非如此。 例如,我们可以有两个进程,例如RenderMovieClip和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;
}
}
}
但实体不应该关心数据类。 组件共同包含实体的状态。 数据类存在是为了方便过程。 所以我们重构代码,所以太空船实体包含组件,而不是数据类。
class Spaceship
{
public var position:PositionComponent;
public var velocity:VelocityComponent;
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;
public var angularVelocity:Number;
}
class DisplayComponent
{
public var view:DisplayObject;
}
通过删除数据类,并使用构成组件来定义太空船,我们已经移除了太空船实体知道什么进程可能对其起作用的任何需要。 太空船现在包含定义其状态的组件。 任何要求将这些组件合并到其他数据类的过程是一些其他类的责任。
系统和节点
实体系统框架中的一些核心代码(我们将在稍后介绍)将动态创建这些数据对象,因为它们是进程所需要的。 在这种简化的上下文中,数据类将仅仅是进程使用的集合(数组,链表或其他,取决于实现)中的节点。 为了澄清这一点,我们将它们重命名为节点(nodes)。
class MoveNode
{
public var position:PositionComponent;
public var velocity:VelocityComponent;
}
class RenderNode
{
public var display:DisplayComponent;
public var position:PositionComponent;
}
这些过程没有改变,但是为了保持更常见的命名,我也将改变他们的名字并称之为系统(systems)。
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
{
for each( var target:RenderNode in targets )
{
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;
}
什么是实体(Entity)?
最后一个改变 - 太空船类没有什么特别的。 它只是一个组件的容器。 因此,我们将其称为Entity并给它一个组件集合。 我们将根据它们的类类型访问这些组件。并给它一个组件集合。 我们将根据它们的类类型访问这些组件。
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 ):Object
{
return components[ componentClass ];
}
}
所以我们这样创建飞船类
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:PrioritisedList;
public function addSystem( system:ISystem, priority:int ):void
{
systems.add( system, priority );
system.start();
}
public function update( time:Number ):void
{
for each( var system:ISystem in systemes )
{
system.update( time );
}
}
public function removeSystem( system:ISystem ):void
{
system.end();
systems.remove( system );
}
}
这将得到加强,并将成为我们实体系统框架的核心。我们将添加上面提到的功能来动态创建系统的节点。
实体只关心组件,而系统只关心节点。因此,为了完成实体系统框架,我们需要代码来监视实体,并且当它们改变时,向系统使用的节点集合添加和删除它们的组件。因为这是一个知道实体和系统的代码,我们可能认为它是游戏的核心。在Ash,我称之为Engine类,它是系统管理器的增强版本。
当您开始使用它并停止使用它时,每个实体和每个系统都将添加到Engine类并从中删除。 Engine类跟踪实体上的组件,并根据需要创建和销毁节点,将这些节点添加到节点集合。 Engine类还为系统提供了一种获取所需集合的方法。
public class Engine
{
private var entities:EntityList;
private var systems:SystemList;
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
{
// destroy nodes containing 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 systemes )
{
system.update( time );
}
}
}
要看到这个架构的一个实现,下载Ash实体系统框架,并查看示例Asteroids。
结论
因此,总而言之,实体系统源于简化游戏循环的愿望。从中可以看出代表游戏状态的实体结构,以及根据游戏状态操作的系统。系统每帧更新 - 这是游戏循环。实体由组件组成,并且系统对具有它们感兴趣的组件的实体进行操作。引擎监视系统和实体,并确保每个系统可以访问具有适当组件的所有实体的集合。
然而,系统通常不关心实体作为一个整体,只是它们需要的特定组件。因此,为了优化架构并提供额外的清晰度,系统对包含适当组件的静态类型节点对象进行操作,其中这些组件都属于同一实体。
实体系统框架为此体系结构提供基本的框架和核心管理,而不提供任何实际的实体或系统类。您可以通过创建适当的实体和系统来创建游戏。
基于实体的游戏引擎将在基本框架之上提供许多标准系统和实体。
Actioncript语言的三个实体系统框架分别是我自己的Ash, Tom Davies 编写的Ember2 和Alec McEachran编写的Xember 。Artemis是一个Java的实体系统框架,现已移植到C#。
原文地址:http://www.richardlord.net/blog/what-is-an-entity-framework
Ash API:http://libgdx.badlogicgames.com/ashley/docs/com/badlogic/ashley/core/EntitySystem.html