如何使用Entitas开发游戏(FNGGames)

原文链接:How I build games with Entitas (FNGGames)

这个帖子用来说明如何使用Entitas来构建我的游戏工程。我会假定你对Entitas如何工作已经有了相当好的理解。这并不是一篇详细介绍Entitas特性的教程。相反,它旨在解释如何使用Entitas构建代码,并且最大程度地提高清晰度、稳定性和灵活性,尤其是在大型的工程中。我当然不会声称这是权威的,这只是我学会使用Entitas开发某些项目后的一些方法。As always, YMMV.

我将尝试涵盖以下主题。我会使用Unity作为示例来说明views和views controllers的概念,但是这种思维方式可以应用到各种引擎。

  • 定义数据、逻辑和视图层
  • 维护层与层之间的分离
  • 使用接口进行抽象
  • 控制反转
  • 视图层抽象(Views与View controllers)

定义

Data: 游戏状态。数据比如血量、物品栏、经验、敌人类型、AI状态、移动速度等等。在Entitas中这些数据在Compenents中。

Logic: 数据改变的规则。PlaceInventory()、BuildItem()、FireWeapon()等等。在Etitas中叫做Systems。

View: 负责向玩家展现游戏状态、渲染、动画、音频、UI等的代码。在示例中使用依赖于GameObject的MonoBehaviour。

Service: 外部的信息来源或者信息池。比如:寻路,排行榜,反作弊,社交,物理,甚至是引擎本身。

Input: 外部的模拟输入,通常通过有限的入口进入部分游戏逻辑。比如:控制器、键盘、鼠标、网络输入等。

架构

任何游戏的核心都只是CPU上的数值模拟。每一款游戏都不过是一组数据(游戏状态)的集合,这些数据会经历周期性的数值变换(游戏逻辑)。这些数据包括得分、血量、经验值、NPC对话、物品栏等等等等。游戏逻辑规定了这些数据可以经历的转换规则(调用PlaceItemInInventory()用已定义的方式改变游戏状态)。整个模拟系统可以不依赖于任何额外的层而存在。

通常,一个游戏和纯粹的数值模拟器之间的区别在于循环中有外部的用户,用户有能力在外部使用模拟器已定义的逻辑改变游戏状态。随着用户的加入,就有了连接模拟器与用户的需求。这就是“视图层”。这一层代码库的一部分负责展现游戏状态,通过将角色渲染到屏幕上、播放音频、更新UI控件等方式。没有这一层用户就没有办法理解模拟器或者与模拟器进行有效的交互。

大多数游戏架构的目标在于维护logic、data和view的分层。想法就是模拟器层不应该关心或者知道他们是如果渲染到屏幕上的。当玩家受到攻击后改变血量的方法,不应该包含渲染特效或者播放音频的代码。

我维护这种分离的方式是依照下图来构建我的工程。我将试着叙述图中的每一部分,然后使用接口或helps来拓展类似于抽象这样的概念。

功能的抽象

抽象(在当前特定的语境中)是解除what与how之间强耦合关系的过程。一个非常简单的例子,想象有一个写日志的功能,日志可供用户查看。

Naive的方法

最naive的方法,我确定大多数读者已经知道如何避免了,就是在每一个需要写日志的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 {

    // collector: Debug.Matcher.DebugLog

    // filter: entity.hasDebugLog

    public void Execute (List 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等等)。代码的可读性很差,如果考虑到工具库变化的话还很容易出错。如果有一天改变了引擎,所有游戏代码需要完全重写。

使用接口的示例

在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) 方法。这意味着你可以在上文提到的ReactiveSystem中调用它。这个系统只关注接口ILogService 。如果我们向系统中传入一个JsonLogService ,我们会得到一个包含日志信息的json文件。我们不能访问JsonLogMessage 类中的公共字符串字段,因为接口中并没有定义。需要注意的是,我们向这个system的构造方法中传入了一个`ILogMessage 的实例,我会在下文中解释。

// the previous reactive system becomes 
public class HandleDebugLogMessageSystem : ReactiveSystem {

    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 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的实现替换为AssetStore中的一个解决方案,比如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示例中我说了,我关心的只是我可以查询用户是否按下了ButtonA,我不关系这来自于键盘还是鼠标还是网络连接。在游戏中实际读取输入的地方,这些都不重要。

对于“获取time delta”功能来说,我不需要知道是来自Unity还是XNA还是Unreal,我只需要知道它是多少,这关系到我要将角色在屏幕上移动多少距离。

控制反转

现在我们要向代码中引入一种之前没有遇到过的复杂情况:现在我们的system需要一个继承自某个接口的实例的引用。在上面的例子中我是通过构造方法传入的,但是这将导致许多具有不同构造方法的system。我们想要的是这些Service实例是全局可访问的。我们也希望在代码库中只有这么一个地方,这个地方靠近应用的初始化点,在这个地方我们可以决定使用接口的哪些实现。也是在这里,我们创建实例,并使这些实例全局可访问,以便可以在system中查询到,而不必把它们传入每一个单独的构造方法中。幸运的是这使用Enitas实现起来超级简单。
我的方法是首先创建一个Helper类,其中包含每一个Service的引用。
Service.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(), // async steering calculations on MB
    GetComponent(), // 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;
}

最后有一个Feature ,它在系统层面上第一个运行,叫做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);
    }
}

最后的结果是我们可以全局访问这些service实例,通过Contexts实例(_context.meta.timeService.instance)。而且我们只在一个地方创建它们,所以回滚、修改实现或者模拟现实用于测试都变得轻而易举。你也可以轻松使用编译指令获得指定平台的实现或者只在调试状态下有效的实现。我们使用了“控制反转”的依赖性解决方式,(依赖性)从system类的深处转到了应用的顶部(初始化处)。

View层抽象

目前为止,我们看到了上图中左侧的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

[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("Prefabs/" + assetName));
        if (viewGo == null) return null;
        var viewController = viewGo.GetComponent();
        if (viewController != null) viewController.InitializeView(contexts, entity);
        return viewController;
    }
}

一个LoadAssetSystem用于加载资源并且绑定view

public class LoadAssetSystem : ReactiveSystem, 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 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 {
    // collector: GameMatcher.Position;
    // filter: entity.hasPosition && entity.hasView
    public void Execute(List 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)] // 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中的事件监听器:

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("Prefabs/" + name));
        if (viewGo == null) return null;
        var viewController = viewGo.GetComponent();
        if (viewController != null) viewController.InitializeView(contexts, entity);      

        // except we add some lines to find and initialize any event listeners
        var eventListeners = viewGo.GetComponents();
        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 下拉框。我们将只能访问我们真正需要的东西。

你可能感兴趣的:(如何使用Entitas开发游戏(FNGGames))