天天看點

如何使用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來拓展類似于抽象這樣的概念。

如何使用Entitas開發遊戲(FNGGames)

功能的抽象

抽象(在目前特定的語境中)是解除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<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等等)。代碼的可讀性很差,如果考慮到工具庫變化的話還很容易出錯。如果有一天改變了引擎,所有遊戲代碼需要完全重寫。

使用接口的示例

在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<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的實作替換為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<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;
}
           

最後有一個

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。然後可能需要一個簡單的标記component

AssignViewComponent

來說明我們需要一個新的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<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)] // 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<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腳本來監聽位置的改變,比如:

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 下拉框。我們将隻能通路我們真正需要的東西。