如何使用Entitas构建游戏
一、优势与目的
在大型项目中,使用Entitas可以使结构更清晰、稳定、灵活,如果对Entitas不是足够了解,可以看看前面几篇教程,本文目的是基于Entitas设计一个合理的框架使得逻辑层与表现层分离,也可以叫视图层这样就可以使得核心代码不依赖于具体哪个游戏引擎,本文以Unity为例进行介绍,分为一下几点:
- 定义数据层、逻辑层、表现层,彼此独立
- 抽象接口
- 视图与视图控制
二、明确概念
数据:Data,表示游戏状态,体力、经验、敌人类型、AI状态、移动速度等,在Entitas中,这些数据保存在Components中,就是ECS中的C.
逻辑:Logic,不用说了吧,在Entitas中叫System,就是ECS中的S.
表现:View,就是将游戏状态进行显示,比如引擎渲染、播放动画和音乐、UI显示,在这个例子中,将由GameObjects上的MonoBehaviours负责完成.
服务:Services,主要是第三方工具或插件,比如游戏引擎本身或物理、寻路模块.
输入:Input,例如控制器/键盘/鼠标输入,网络输入.
三、结构
整个结构为了保持模块之间的解耦、独立,比如说打日志,很多项目的做法是在各个系统里调用Debug.Log等,这就会导致强耦合,如果依赖了平台的API,想换平台,可能需要到处寻找对该方法的引用来重新使用新平台的API,时间成本是很大的,那么Entitas可以很好解决这个问题。
Service
先看结构图左部分,Service与Entity的交互是通过接口来进行的,接口就是告诉编译器,这个类(实现接口)有了这个API,可以调用接口里的方法,这样核心代码就脱离了具体的执行环境或平台,可以方便进行转移。下面举几个典型的例子:
LogService
// the previous reactive system becomes
public class HandleDebugLogMessageSystem : ReactiveSystem<DebugEntity> {
ILogService _logService;
// contructor needs a new argument to get a reference to the log service
public HandleDebugLogMessageSystem(Contexts contexts, ILogService logService) {
// could be a UnityDebugLogService or a JsonLogService
_logService = logService;
}
// collector: Debug.Matcher.DebugLog
// filter: entity.hasDebugLog
public void Execute (List<DebugEntity> entities) {
foreach (var e in entities) {
_logService.LogMessage(e.DebugLog.message); // using the interface to call the method
e.isDestroyed = true;
}
}
}
如果想打日志,就调用DebugContext来创建DebugEntity,并添加一个DebugLogComponent就可以了,至于Service如下:
// the interface
public interface ILogService {
void LogMessage(string message);
}
// a class that implements the interface
using UnityEngine;
public class UnityDebugLogService : ILogService {
public void LogMessage(string message) {
Debug.Log(message);
}
}
// another class that does things differently but still implements the interface
using SomeJsonLib;
public class JsonLogService : ILogService {
string filepath;
string filename;
bool prettyPrint;
// etc...
public void LogMessage(string message) {
// open file
// parse contents
// write new contents
// close file
}
}
我不用关心LogService是如何LogMessage的,无论写文件还是通过网络发送,甚至依赖了哪个平台的API,比如Unity的Debug.Log。这样通过接口将Entitas与服务联系起来又做到了解耦,Service只是帮System对数据进行处理。
InputService
这个复杂一些,首先我要知道我需要哪些输入信息,可以定义一个接口包含一些属性,C#中的属性既可以当成字段也可以理解成方法:
// interface
public interface IInputService {
Vector2D leftStick {get;}
Vector2D rightStick {get;}
bool action1WasPressed {get;}
bool action1IsPressed {get;}
bool action1WasReleased {get;}
float action1PressedTime {get;}
// ... and a bunch more
}
如果从Unity里获取输入信息,就可以写一个这样的Service:
// (partial) unity implentation
using UnityEngine;
public class UnityInputService : IInputService {
// thank god we can hide this ugly unity api in here
Vector2D leftStick {get {return new Vector2D(Input.GetAxis('horizontal'), Input.GetAxis('Vertical'));} }
// you must implement ALL properties from the interface
// ...
}
现在我们创建一个System,EmitInputSystem将 输入数据拉取进来给Entitas:
public class EmitInputSystem : IInitalizeSystem, IExecuteSystem {
Contexts _contexts;
IInputService _inputService;
InputEntity _inputEntity;
// contructor needs a new argument to get a reference to the log service
public EmitInputSystem (Contexts contexts, IInputService inputService) {
_contexts = contexts;
_inputService= inputService;
}
public void Initialize() {
// use unique flag component to create an entity to store input components
_contexts.input.isInputManger = true;
_inputEntity = _contexts.input.inputEntity;
}
// clean simple api,
// descriptive,
// obvious what it does
// resistant to change
// no using statements
public void Execute () {
inputEntity.isButtonAInput = _inputService.button1Pressed;
inputEntity.ReplaceLeftStickInput(_inputService.leftStick);
// ... lots more queries
}
}
其中的InputEntity的InputComponent使用了[Unique]属性,对这段代码不明白的同学建议学习一下Entitas的前几节,现在这些就是我所说的抽象,通过InputService我并不关心数据是从键盘还是鼠标或者手柄传进来的,那么对于TimeSerivice,我只需要它给我delta time,不管它是Unity、XNA还是Unreal,我只需要拿到timeDelta然后移动相应的物体就可以了。
Service接入优化
对Service的获取可能在System的各个地方,那么按照上面的方法会需要写很多不同的构造函数,所以最好的办法就是让这些Service可以全局获取,那么上文提到的[Unique]可以轻松的做到这一点。
-
先构造一个帮助类持有所有Service的引用Services.cs:
public class Services { public readonly IViewService View; public readonly IApplicationService Application; public readonly ITimeService Time; public readonly IInputService Input; public readonly IAiService Ai; public readonly IConfigurationService Config; public readonly ICameraService Camera; public readonly IPhysicsService Physics; public Services(IViewService view, IApplicationService application, ITimeService time, IInputService input, IAiService ai, IConfigurationService config, ICameraService camera, IPhysicsService physics) { View = view; Application = application; Time = time; Input = input; Ai = ai; Config = config; Camera = camera; Physics = physics; } } var _services = new Services( new UnityViewService(), // responsible for creating gameobjects for views new UnityApplicationService(), // gives app functionality like .Quit() new UnityTimeService(), // gives .deltaTime, .fixedDeltaTime etc new InControlInputService(), // provides user input // next two are monobehaviours attached to gamecontroller GetComponent<UnityAiService>(), // async steering calculations on MB GetComponent<UnityConfigurationService>(), // editor accessable global config new UnityCameraService(), // camera bounds, zoom, fov, orthsize etc new UnityPhysicsService() // raycast, checkcircle, checksphere etc. );
-
在MetaContext中 ,我们有一套唯一组件持有这些Service实例:
[Meta, Unique] public sealed class TimeServiceComponent : IComponent { public ITimeService instance; }
-
最后,我们创建一个ServiceRegistrationSystems,继承Feature,然后将Services 传给它的构造器,这样每个子系统负责将每个Service实例传给MetaContext每个独一无二的组件:
public class ServiceRegistrationSystems : Feature { public ServiceRegistrationSystems(Contexts contexts, Services services) { Add(new RegisterViewServiceSystem(contexts, services.View)); Add(new RegisterTimeServiceSystem(contexts, services.Time)); Add(new RegisterApplicationServiceSystem(contexts, services.Application)); Add(new RegisterInputServiceSystem(contexts, services.Input)); Add(new RegisterAiServiceSystem(contexts, services.Ai)); Add(new RegisterConfigurationServiceSystem(contexts, services.Config)); Add(new RegisterCameraServiceSystem(contexts, services.Camera)); Add(new RegisterPhysicsServiceSystem(contexts, services.Physics)); Add(new ServiceRegistrationCompleteSystem(contexts)); } } //其中一个例子如下: public class RegisterTimeServiceSystem : IInitializeSystem { private readonly MetaContext _metaContext; private readonly ITimeService _timeService; public RegisterTimeServiceSystem(Contexts contexts, ITimeService timeService) { _metaContext = contexts.meta; _timeService = timeService; } public void Initialize() { _metaContext.ReplaceTimeService(_timeService); } }
通过_contexts.meta.timeService.instance就可以在全局方便地获取每一个Service的实例。
View
现在来看看结构图右部分,和上面原理基本一致,目标是为了脱离具体的游戏引擎或运行环境,使得渲染显示模块与Entitas分离。我们再次使用接口,只需要考虑视图与Entitas之间需要的数据和方法,这是一个接口:
public interface IGameObjectView {
Vector2D Position {get; set;}
Vector2D Scale {get; set;}
bool Active {get; set;}
void InitializeView(Contexts contexts, IEntity Entity);
void DestroyView();
}
这是该接口在Unity中的实现:
public class UnityGameView : Monobehaviour, IGameObjectView{
protected Contexts _contexts;
protected GameEntity _entity;
public Vector2D Position {
get {return transform.position.ToVector2D();}
set {transform.position = value.ToVector2();}
}
public Vector2D Scale // as above but with tranform.localScale
public bool Active {get {return gameObject.activeSelf;} set {gameObject.SetActive(value);} }
public void InitializeView(Contexts contexts, IEntity Entity) {
_contexts = contexts;
_entity = (GameEntity)entity;
}
public void DestroyView() {
Object.Destroy(this);
}
}
接下来,我们需要一个Service(IViewService)去创建这些View,然后将它们和Entity关联起来:
public interface IViewService {
// create a view from a premade asset (e.g. a prefab)
IGameObjectView LoadAsset(Contexts contexts, IEntity entity, string assetName);
}
持有View的Entity组件:
[Game]
public sealed class ViewComponent : IComponent {
public IGameObjectView instance;
}
Unity实现ViewService:
public class UnityViewService : IViewService {
public IGameObjectView LoadAsset(Contexts contexts, IEntity entity, string assetName) {
var viewGo = GameObject.Instantiate(Resources.Load<GameObject>("Prefabs/" + assetName));
if (viewGo == null) return null;
var viewController = viewGo.GetComponent<IGameObjectView>();
if (viewController != null) viewController.InitializeView(contexts, entity);
return viewController;
}
}
然后通过LoadAssetSystem 将View和Entity进行绑定:
public class LoadAssetSystem : ReactiveSystem<GameEntity>, IInitializeSystem {
readonly Contexts _contexts;
readonly IViewService _viewService;
// collector: GameMatcher.Asset
// filter: entity.hasAsset && !entity.hasView
public void Initialize() {
// grab the view service instance from the meta context
_viewService = _contexts.meta.viewService.instance;
}
public void Execute(List<GameEntity> entities) {
foreach (var e in entities) {
// call the view service to make a new view
var view = _viewService.LoadAsset(_contexts, e, e.asset.name);
if (view != null) e.ReplaceView(view);
}
}
}
PositionSystem代码如下,使用了IGameObjectView 更新位置信息,而不是直接使用Unity的API:
public class SetViewPositionSystem : ReactiveSystem<GameEntity> {
// collector: GameMatcher.Position;
// filter: entity.hasPosition && entity.hasView
public void Execute(List<GameEntity> entities) {
foreach (var e in entities) {
e.view.instance.Position = e.position.value;
}
}
}
以上是View部分的解耦过程,但是有个明显的缺陷,就是我们在Entitas中,不需要知道引擎的渲染过程,有一种更好的解耦方法就是Event功能。
Event
使用[Event]属性给需要的组件:
[Game, Event(true)] // generates events that are bound to the entities that raise them
public sealed class PositionComponent : IComponent {
public Vector2D value;
}
将会生成PositionListenerComponent和IPositionListener,IPositionListener包含一个方法void OnPosition,再写一个接口给所有的View监听器,在Unity或其他环境下实现:
public interface IEventListener {
void RegisterListeners(IEntity entity);
}
重新实现View Service:
public UnityViewService : IViewService {
// now returns void instead of IViewController
public void LoadAsset(Contexts contexts, IEntity entity, string assetName) {
// as before
var viewGo = GameObject.Instantiate(Resources.Load<GameObject>("Prefabs/" + name));
if (viewGo == null) return null;
var viewController = viewGo.GetComponent<IViewController>();
if (viewController != null) viewController.InitializeView(contexts, entity);
// except we add some lines to find and initialize any event listeners
var eventListeners = viewGo.GetComponents<IEventListener>();
foreach (var listener in eventListeners) listener.RegisterListeners(entity);
}
}
现在我们可以去除SetViewXXXSystem 类了,因为我们不需要告诉View去做什么,但要实现如下MonoBehaviour并挂在相应的View上:
public class PositionListener : Monobehaviour, IEventListener, IPositionListener {
GameEntity _entity;
public void RegisterListeners(IEntity entity) {
_entity = (GameEntity)entity;
//view监听entity的position变化事件
_entity.AddPositionListener(this);
}
public void OnPosition(GameEntity e, Vector2D newPosition) {
//通过EventSystem获取所有position变化的entity集合,然后依次通过其PositionListener获取所有handler,并触发
transform.position = newPosition.ToVector2();
}
}
每个View监听Entity的数据变化事件,而且每个Entity可以被多个View监听,但不建议这样做,一旦PositionComponent改变会导致EventSystem触发相应Entity的PositionHandler集合,个人觉得Event并不太好用,会生成大量的Listener文件,造成系统庞杂,通过接口已经可以做到解耦。
四、总结
并不是所有项目都适合用Entitas,大型项目比较适合,而且Entitas是通过数据驱动的,只有数据变化了才会触发相应的逻辑,内部很多的Cache对性能做了很多的优化,具体源码可以在GitHub上查看。