如何使用Entitas的逻辑编辑一个游戏 (FNGGames) - OneYoungMean/Entitas-CSharp-OYM GitHub Wiki
参考译文:https://blog.csdn.net/u012662020/article/details/80574960
点击这里获取项目源码
这次我们以上面的游戏为案例,来说明如何使用Entitas来构建我的游戏工程。我会假定你对Entitas如何工作已经有了相当好的理解。这并不是一篇详细介绍Entitas特性的教程。相反,它旨在解释如何使用Entitas构建代码,并且最大程度地提高清晰度、稳定性和灵活性,尤其是在大型的工程中。我当然不会声称这是权威的,这只是我学会使用Entitas开发某些项目后的一些方法。
我将尝试涵盖以下主题。我会使用Unity作为示例来说明views和views controllers的概念,但是这种思维方式可以应用到各种引擎。
- 定义Data, Logic, View layers
- 维护他们之间的分离
- 使用接口进行抽象
- 控制反转
- 抽象的视图层 (views and view controllers)
Data: 游戏的 状态。比如血量、物品栏、经验、敌人类型、AI状态、移动速度等等。在Entitas中这些数据都被存储到Components中。
Logic: 游戏数据遵循改变的规则比如 PlaceInInventory(), BuildItem(), FireWeapon() 等等。在Entitas中我们称呼它为Systems.
View: 代码负责展示给玩家游戏的状态,比如渲染,动画,音频,UI等等。在我的例子当中,他们都挂载在MonoBehaviours的GameObjects上。 Services: 外部的信息来源或者信息池。比如:寻路,排行榜,反作弊,社交,物理,甚至是引擎本身。
Input: 外部的模拟输入,通常通过有限的入口进入部分游戏逻辑。比如:控制器、键盘、鼠标、网络输入等。
任何游戏的核心都只是CPU上的数值模拟. 每一款游戏都不过是一组数据(游戏的状态)的集合,这些数据会经历周期性的数值变换(游戏的逻辑). 游戏的状态得分、血量、经验值、NPC对话、物品栏等等等等。 游戏的 逻辑 规定了这些数据可以经历的转换规则 (调用PlaceItemInInventory()
用已定义的方式改变游戏状态).整个模拟系统可以不依赖于任何额外的层而存在。
通常,一个游戏和纯粹的数值模拟器之间的区别在于循环中有外部的使用者,使用者有能力在外部使用模拟器已定义的逻辑改变游戏状态。. 随着用户的加入,就有了连接模拟器与用户的需求。这就是"View Layer".这一层代码库的一部分负责展现游戏状态,通过将角色渲染到屏幕上、播放音频、更新UI控件等方式。没有这一层用户就没有办法理解模拟器或者与模拟器进行有效的交互。
大多数游戏架构的目标在于维护 logic and data layers and the view 的层.而我们的核心想法就是模拟层不应该关心或者知道他们是如何被渲染到屏幕上的。比如当玩家受到攻击后改变血量的方法,就不应该包含渲染特效或者播放音频的代码。
我维护这种分离的方式是依照下图来构建我的工程。我将试着叙述图中的每一部分,然后使用接口或helps来拓展类似于抽象这样的概念。
抽象(在当前特定的语境中) is the process of removing strong coupling between what you want to do and how you want to do it(大致意思是指,在你想做的和你需要的之间进行强解耦)。举个很简单的例子,你需要准备一个写日志的功能,日志需要提供给用户查看。
Naive approach(你不应该使用的方法) 关于这个方法,我确定大多数读者已经知道如何避免了,就是在每一个需要写日志的system中调用Debug.Log。这将立刻给代码库带来很高的耦合度,以后维护起来将是一场噩梦。
上面的方法带来的问题
我们假设如果你想要用更合适的方法,比如将日志写入文件的方法来代替Debug.Log,会发生什么?如果是添加一个游戏中的调试控制台,并将信息记录下来呢?你将不得不浏览整个代码库,然后添加或者替换这些方法调用。这是一场真正的噩梦,即使是一个中等大小的项目。
这些代码可读性差,易混淆,没有进行职责的分离。果你已经完成过一个Unity项目,你可能觉得很OK因为这些事Unity都已经做了打日志这件事。在这里我告诉你,这一点都不OK。你的CharacterController不应该直接去解析用户输入,不应该跟踪物品栏,不应该播放脚步声音频,也不应该发布facebook消息,而应该控制角色移动,并且只用于控制角色移动。你应该尝试分离你代码所关注的地方。
代码解耦-Entitas方法
假设你对这些问题太熟悉了,所以希望寻求用Entitas来解决。那现在就来谈一谈Entitas方式的好处:
在Entitas中我们通过创建LogMessageComponent(string message)的方法,然后设计一个ReactiveSystem来实现这个功能,这个system用来收集信息并且做具体的代码实现。有了这个设置,我们可以很容易地创建一个Entity,给它挂上组件以使它能够向控制台打印信息。
using UnityEngine;
using Entitias;
using System.Collections.Generic;
// debug message component
[Debug]
public sealed class DebugLogComponent : IComponent {
public string message;
}
// reactive system to handle messages
public class HandleDebugLogMessageSystem : ReactiveSystem<DebugEntity> {
// collector: Debug.Matcher.DebugLog
// filter: entity.hasDebugLog
public void Execute (List<DebugEntity> entities) {
foreach (var e in entities) {
Debug.Log(e.debugLog.message);
e.isDestroyed = true;
}
}
}
现在,不论什么时候想要创建日志信息,创建一个Entity然后交给System处理。具体的实现我们可以想改多少次就改多少次,并且只在代码的一个地方改(System中)。
只有Entitas时的问题
这种方法对某些用户来说足够了,尤其是类似于MatchOne这样的小项目。但是它本身并不是没有问题。我们添加了对UnityEngine的一个很强的依赖,因为我们在System中使用了它的API。我们也在System中直接写了功能的实现。
使用Debug.Log()在这种情况下似乎不是问题,毕竟只是一行代码。但是如果里面包含解析json文件的操作或者需要发送网络消息怎么办?现在你的系统里有很多具体的代码实现。也有很多依赖和using 声明(UnityEngine/JSON library/Networking Library等等)。代码的可读性很差,如果考虑到工具库变化的话还很容易出错。如果有一天改变了引擎,所有游戏代码需要完全重写。
使用Debug.Log()在这种情况下似乎不是问题,毕竟只有一行代码。但是,如果里面包含解析json文件的操作或者需要发送网络消息怎么办?现在你的系统里有很多具体的代码实现。也有很多依赖和using 声明(UnityEngine/JSON/.NET等等)。不仅代码的可读性会很差,同时如果工具库发生变化还会非常容易出错,如果有一天改变了引擎,你的游戏代码就全部需要重写。
在c#中,解决依赖性和提高代码清晰度的方法是使用接口。一个接口就类似于一个约定。告诉编译器你的类实现了一个接口,就好比你说“这个类作为这个接口出现时具有相同的公共的API”。
当我声明接口时我会想“我的游戏需要从这个接口中获得什么样的信息或功能”,然后我会试着为它提供一个描述性的、简单的API。对于日志功能来说我们只需要一个方法,一个简单的·LogMessage(string message) 。用接口实现如下:
// 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
}
}
通过继承ILogService
,你可以保证编译器中会有void LogMessage(string message)
的方法。这意味着您可以像以前一样在反应系统中使用它。在该系统只能关注ILogService
。如果我们给系统传递一个JsonLogService
,我们将获得一个带有日志消息的json文件,但是我们将无法访问JsonLogService
类的公共字符串字段,因为在接口中天门没有被定义。值得注意的是,现在我们已经将ILogService
的实例传递给系统的构造方法。我会在下文中稍后给出解释:
// 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;
}
}
}
在我的工程中一个更复杂的例子是IIputService 。我又开始想了:我需要知道用户的什么输入呢?我是否可以定义一组简单的属性或方法来获取到我想得到的信息?以下是我的接口的一部分:
// 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
}
// (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
// ...
}
现在我可以写一个EmitInputSystem 来向Entitas中发送输入数据。现在这些数据成为了游戏数据的一部分,并且可以驱使其他模块做其他的事。这种方法的好处是我可以将使用Unity的实现替换为InControl,而不用修改任何游戏代码。注意以下的系统中,我们的代码只实现这个特定的接口。
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
}
}
现在我希望你已经理解了我说的"抽象",我刚从一个任务当中抽象 了一个需要执行的逻辑,也就是我从how中抽象了what。在Input示例中我说了,我关心的只是我可以查询用户是否按下了Button(A),我不关心这个(A)来自于键盘还是鼠标还是网络连接。这无关于我的游戏在哪获取到了它。
对于“获取time delta”功能来说,我不需要知道是来自Unity还是XNA还是Unreal,我只需要知道它是多少,这关系到我要将角色在屏幕上移动多少距离。
现在我们要向代码中引入一种之前没有遇到过的复杂情况:现在我们的system需要一个继承自某个接口的实例的引用。在上面的例子中我是通过构造方法传入的,但是这将导致许多具有不同构造方法的system。我们想要的是这些Service实例是全局可访问的。
我们也希望在代码库中只有这么一个地方,这个地方靠近应用的初始化点,在这个地方我们可以决定使用接口的哪些实现。也是在这里,我们创建实例,并使这些实例全局可访问,以便可以在system中查询到,而不必把它们传入每一个单独的构造方法中。
幸运的是这使用Enitas实现起来超级简单。我的方法是首先创建一个Helper类,其中包含每一个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;
}
}
现在可以在GameController里面很轻易的初始化它:
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 里面有一组unique components持有这些接口的实例。比如:
[Meta, Unique]
public sealed class TimeServiceComponent : IComponent {
public ITimeService instance;
}
最后有一个继承自功能的类 ,它在系统层面上第一个运行,叫做ServiceRegistrationSystems 。它的构造方法里有一个额外的Services 参数,然后把这个services向下传入到initialize systems。这些system简单地将Servives 中的实例分配给MetaContext中的unique components。
ServiceRegistrationSystems.cs
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));
}
}
一个RegistrationSystems示例
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实例(_context.meta.timeService.instance)全局访问这些service实例。而且我们只在一个地方创建它们,所以回滚、修改实现或者模拟现实用于测试都变得轻而易举。你也可以轻松使用编译指令获得指定平台的实现或者只在调试状态下有效的实现。我们使用了“控制反转”的依赖性解决方式,(依赖性)从system类的深处转到了应用的顶部(初始化处)。
目前为止,我们看到了上图中左侧的service接口,现在来看一看右侧的View接口。工作方式很相似。就像之前说的,View层关心的是将游戏状态展现给玩家,包括动画、声音、图片、网格、渲染等等。目标同样是消除对游戏引擎或第三方库的依赖性,得到纯粹的、描述性的system代码,避免任何具体的实现。
一个naive的方法是用一个ViewComponent 里面引用一个GameObject。然后可能需要一个简单的标记componentAssignViewComponent 来说明我们需要一个新的GameObject作为Entity的view。要使用的话需要写一个reactive systerm作用于AssignView 和过滤器!entity.hasView 来确保只在需要的地方添加view。在这个system,甚至是component中,你可能会直接使用Unity的API。这当然不能实现我们设置的目标。
在这里可以使用上文中提到的service形式,连同更深一层次的对view的抽象。同样,思考一下在view中需要什么数据或功能,然后为它写一个接口。这将决定system代码如何从view中get或set数据。不妨把它叫做“ViewController”——这是直接控制View对象的代码块。典型的,里面可能包含transform信息(位置/旋转/缩放),也可能有标签、层、名称、enable状态。
很自然的,view同样应该绑定到Entity,并且它可能需要处理这个Entity和其他游戏状态的信息。为此,需要在设置view的时候,传入entity引用和Contexts实例。也要能够在entity代码内部销毁view。示例如下:
public interface IViewController {
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, IViewController {
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用于创建这些view并与entity绑定。这是我的IViewService 接口和在它Unity中的实现。 一个component持有这个view controller
A component to hold the view controller
[Game]
public sealed class ViewComponent : IComponent {
public IViewController instance;
}
一个接口来定义我需要能够访问view service的两件事情
public interface IViewService {
// create a view from a premade asset (e.g. a prefab)
IViewController LoadAsset(Contexts contexts, IEntity entity, string assetName);
}
view service在Unity中的实现:
public class UnityViewService : IViewService {
public IViewController 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<IViewController>();
if (viewController != null) viewController.InitializeView(contexts, entity);
return viewController;
}
}
一个LoadAssetSystem用于加载资源并且绑定view
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);
}
}
}
以下是一个position system示例,使用了抽象的view而不直接与Unity交互。
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;
}
}
}
代码中没有对Unity引擎的依赖,component和system只引用了接口。代码中也没有具体的实现(不用担心关于访问GameObject和Transform的问题,只需要简单地去设置接口里的属性)
这种方法的问题
有一个很明显的瑕疵——我们在代码中写了一个system用来与view层交互——这破坏了我们之前的原则,也就是模拟器不应该知道自己是否被渲染。在Entitas中有另一种强制完全与view解耦的方法——就是Entitas的“事件”功能。
在游戏Match-One中,Simon并没有ViewComponent。事实上,没有任何游戏代码知道他正在被渲染。代替MonoBehaviour的是事件监听器。我将使用事件来重构上面的示例,以此展示如何简化游戏逻辑,甚至是将模拟器层与view层完全解耦。
首先,需要一个使用[Event]属性标记的component,用来生成我们需要的监听器和事件系统。这里再次以Position功能为例:
// [Game, Event(true)] (Event(true) DEPRECATED as of Entitas 1.6.0)
[Game, Event(EventTarget.Self)] // generates events that are bound to the entities that raise them
public sealed class PositionComponent : IComponent {
public Vector2D value;
}
这个新的属性(Event(true))会生成一个PositionListenerComponent 和一个IPositionListener 接口。现在写另外一个接口来作用于全部的事件监听器,所以就可以在它们创建的时候安全地进行初始化。
public interface IEventListener {
void RegisterListeners(IEntity entity);
}
现在不在需要view component或view service中的LoadAsset方法的返回值了,所以我们可以移除它们。现在需要在view service中添加代码来识别并且初始化asset中的事件监听器:
First update our IViewService:
using Entitas;
public interface IViewService {
// create a view from a premade asset (e.g. a prefab)
void LoadAsset(
Contexts contexts,
IEntity entity,
string assetName);
}
Then update our Unity implementation, UnityViewService.
using UnityEngine;
using Entitas;
public class UnityViewService : IViewService {
// now returns void instead of IViewController
public void LoadAsset(Contexts contexts, IEntity entity, string assetName) {
//Similar to before, but now we don't return anything.
var viewGo = GameObject.Instantiate(Resources.Load<GameObject>("Prefabs/" + assetName));
if (viewGo != 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脚本来监听位置的改变,比如:
public class PositionListener : MonoBehaviour, IEventListener, IPositionListener {
GameEntity _entity;
public void RegisterEventListeners(IEntity entity) {
_entity = (GameEntity)entity;
_entity.AddPositionListener(this);
}
public void OnPosition(GameEntity e, Vector2D newPosition) {
transform.position = newPosition.ToVector2();
}
}
如果把这个脚本添加到一个预制体上然后在game controller中生成EventSystems ,那这个GameObject的位置就会与entity中的PositionComponent完美同步,而不需要systems。那view层物体的位置就完全与模拟器层中entity中的位置完全解耦了。可以轻松地向component中添加事件。重构在之前IViewContoller接口中所有的功能,使用事件监听可以完全摆脱它。
使用service来加载资源的模式,有控制view层中信息流初始化的能力。可以随意添加(IAudioPlayer,UAnimator,ICollider等等)然后将它们的引用传递给contexts或者相关的entity。你可以控制初始化的顺序和时间(不再需要关心Unity中Start()和Update()的调用时间,Start()执行过早时检查是否为空等问题)。
现在能够做到使view层控制自身——view controller变成了简单的事件监听器,在关注的组件改变时触发,而完全不需要向模拟器层返回信息(除了在初始化时向entity上挂一个xxxListenerComponent的情况)。可以在Unity中实现一整个动画系统,通过monobehaviour事件监听器,而不用在模拟器层中引用它。同样适用于音频、粒子、着色器等等。
很完美,我们实现了开始时设置的所有目标。
我们将entitas代码与游戏引擎和第三方库完全解耦了。
我们有一个模拟器层(数据在component中,逻辑在system中),这对引擎来说是完全不可知的。而在工程中也只有一个文件夹包含各种接口针对Unity的实现。这也是唯一一个,当我们想将引擎从Unity改为XNA时,需要改变的文件夹。
在应用的顶部,有一个地方会决定使用哪个实现。可以在这里进行测试模拟、尝试新的第三方解决方案、或者轻松的改变游戏内事物如何运行的想法,而不以任何方法改变游戏逻辑。
模拟器层与view层是完全解耦的,一旦事件系统运行起来,我们的游戏逻辑甚至不知道正在被渲染。整个模拟器可以在服务器端运行,而视图层在客户端运行。
最后,再回头看一下游戏逻辑,会发现它清晰易读。复杂的实现被隐藏,而只有一些描述性的方法和属性的调用。设计只包含关注字段的接口,再也不用看到巨大的包含无用信息的intelli-sense 下拉框。我们将只能访问我们真正需要的东西。