ECS框架的初步探究

ECS框架的初步探究

來自專欄 MusouCrow BLOG4 人贊了文章

歡迎參與討論,轉載請註明出處。

前言

  在閱讀這篇文章之前,你需要了解一下何為ECS框架。關於ECS框架,其實近年來一直想去嘗試,終於在近日有所體悟,遂有此文。

詳解

  ECS框架的存在實際上很早就出現了(我記得最初在2003年),近年隨著《守望先鋒》架構設計與網路同步一文出現後瞬間成了炙手可熱的新星。

  ECS框架與幀同步鎖定類似,皆只是擁有一個概念,但無確切的實現標準。但事實上已經不少現成的實現(如Entitas),不過我覺得Entitas在與Unity的結合上不符合我的審美,於是自己動手造了個輪子。

  ECS框架的概念其實相當直觀:Entity-Component-System三件套。

* Entity即實體,作為Component的經紀人,可擁有多個Component。

* Component即組件,作為數據存儲的容器,原則上只包含內部數據自處理的函數。Component以Entity作為標識,以此判斷所屬。

* System即系統,作為業務函數的集合,會與Component對接實現業務運行(System處理Component)。

  以上三點可謂看過相關文章的都懂,只是落實到具體實現上仍會有不少不明不白之處(Entity是作為容器還是標識符?Component可否嵌套Component?System之間可否相互調用?)。以上問題並沒有確切的答案,只能是落實實現時根據需求而定。

實現

  所謂實踐出真知,在此之前我寫了個貪吃蛇,這是個不錯的素材,於是便將其ECS化。這下也可將兩者進行對比,品味其中區別。

Entity

  由於這款遊戲是使用Unity製作的,那麼自然最好與Unity本身相結合。我首先考慮到的便是與Unity本身的GameObject-Behavior(其實是Component,為防誤解,特此改稱)框架結合(業務環境下有調用它們的需求),於是選擇將Entity做成一個Behavior:

using System;using UnityEngine;namespace Game.Core { public class Entity : MonoBehaviour { public static event Action<Entity> NewTickEvent; public static event Action<Entity> DestroyTickEvent; protected void Start() { if (Entity.NewTickEvent != null) { Entity.NewTickEvent(this); } } protected void OnDestroy() { if (Entity.DestroyTickEvent != null) { Entity.DestroyTickEvent(this); } } }}

  可以看出,Entity的生命周期也與GameObject進行了捆綁,並且設置了兩個event令System可以進行監控。

  再來看看Entity的具體實例:

using UnityEngine;namespace Game.Entitys { using Core; using Components; public class Food : Entity { public Position position = new Position(); protected void Awake() { this.position.Init(this); } protected new void OnDestroy() { base.OnDestroy(); this.position.Destroy(); } }}

  可以看出Food實體創建了一個Position組件,托Unity編輯器的服,我們可以清晰地看到Position的數據構成,並可方便地進行編輯(包括運行時)。當然可以看得出這裡Component的創建方式相當彆扭(實例化後仍需Init),這是為了對接Unity的序列化功能,若不這麼做的話,某些數據將會序列化失敗(如Collision Slot)。

Component

  Component的初始實現便很簡單了,只需要對接Entity以及預留Init與Destroy介面即可:

using System;namespace Game.Core { [Serializable] public class Component { [NonSerialized] public Entity entity; public virtual void Init(Entity entity) { this.entity = entity; } public virtual void Destroy() {} }}

  這裡令Component擁有entity是為了便於識別身份,[Serializable]標識表示該對象可序列化(與編輯器交互),[NonSerialized]標識表示不讓該變數序列化(沒有顯示在編輯器的需求)。接下來看看Position組件的具體實現:

using System;using System.Collections.Generic;using UnityEngine;namespace Game.Components { using Core; using Solts; public class Position : Component { public static Dictionary<Entity, Position> Map = new Dictionary<Entity, Position>(); public static List<Position> List = new List<Position>(); public Vector2Int value; public Collision collsionSlot; public override void Init(Entity entity) { base.Init(entity); Position.Map.Add(entity, this); Position.List.Add(this); } public override void Destroy() { Position.Map.Remove(this.entity); Position.List.Remove(this); } }}

  關於ECS框架有一個很普遍的問題:在System要如何獲取到Component?我的解決方法便是為有獲取需求的Component設立存儲容器,當然這種寫法有點死板,應該專門設立容器管理類進行自動化處理,這是個可改善的方向。

System

  System純粹來看便是個函數集,在Entitas的實現是專門設立Behavior裝載System以運行。而我選擇分離:System即Behavior,兩者倒沒什麼根本上的區別,全憑個人喜好罷了。在以Behavior的實現下並不需要System基類,以下以涉及到坐標與碰撞的Field系統為例:

using System.Collections.Generic;using UnityEngine;namespace Game.Systems { using Core; using Components; using Entitys; public class Field : MonoBehaviour { public const float SIZE = 0.32f; private static List<Entity> SyncList = new List<Entity>(); public static Vector2 ToPosition(int x, int y) { return new Vector2(x * SIZE + SIZE * 0.5f, y * SIZE + SIZE * 0.5f); } public static void AdjustPosition(Position position, Transform transform=null) { transform = transform == null ? position.entity.transform : transform; transform.position = Field.ToPosition(position.value.x, position.value.y); } private static void Collide(Position a, Position b) { if (a.value == b.value) { if (a.collsionSlot != null) { a.collsionSlot.Run(a.entity, b.entity); } if (b.collsionSlot != null) { b.collsionSlot.Run(b.entity, a.entity); } } } private static void Sync(Position position, Joint joint) { joint.laterPos = position.value; } protected void Awake() { Entity.NewTickEvent += this.NewTick; Entity.DestroyTickEvent += this.DestroyTick; Director.UpdateTickEvent += this.UpdateTick; } private void NewTick(Entity entity) { bool hasPos = Position.Map.ContainsKey(entity); bool hasJoi = Joint.Map.ContainsKey(entity); if (hasPos) { Field.AdjustPosition(Position.Map[entity]); } if (hasPos && hasJoi) { Field.SyncList.Add(entity); } } private void UpdateTick() { for (int i = 0; i < Position.List.Count; i++) { for (int j = i + 1; j < Position.List.Count; j++) { Field.Collide(Position.List[i], Position.List[j]); } } foreach (var entity in Field.SyncList) { Field.Sync(Position.Map[entity], Joint.Map[entity]); } } private void DestroyTick(Entity entity) { if (Field.SyncList.Contains(entity)) { Field.SyncList.Remove(entity); } } }}

  可以看出,繼承Behavior的System可以很方便地使用自帶的各種回調函數(如Awake),業務函數也變得清晰無比,只需要提供相應Component即可(如AdjustPosition)。對於一些需要複合組件的業務(如Sync),則會專門設立容器(SyncList)進行存儲,對Entity的NewTickEvent與DestroyTickEvent進行監控便可篩選出合適的對象,且所有組件可通過Entity從組件容器進行獲取,十分方便。

  當然也不要忘記與編輯器結合的優勢,System也可以將變數序列化與編輯器交互:

  當然Unity可進行序列化的部分只有實例變數,所以需要作此處理:

public class Test : MonoBehaviour { private static Test Instance; public static int Get() { return Instance.value; } public int value; protected void Awake() { Food.Instance = this; }}

  因為System是單例Behavior,所以這麼做是安全的。如此便可操作實例對象了。

後記

  總的而言,ECS框架主要是一種對OOP思想的反思,甚至可以說是一種復古(函數式編程風格)。也是一種徹底的組件模式實現,徹底地奉行數據-邏輯分離。它使得我們更容易地去抽象、描述遊戲事物。當然我認為它在某種程度上是反直覺的、抽象的(某些只會屬於某個對象所屬的業務卻要分開寫,並且用組件去涵蓋)。所以我認為它更適用於某些場景下,如動作遊戲里的地圖單位,分為多種樣式(物件、道具、戰鬥單位、NPC、飛行道具等),這種時候使用傳統的繼承+子對象寫法確實不如ECS來得好了。再比如UI方面,我認為還是MVC框架更為王道。所以切忌教條主義,一切跟著實際需求走。


推薦閱讀:

游門弄斧41——LudumDare41總結
Unity2017中的Timeline工作流
控制台遊戲專題又來了,這次是你們沒有見過的船新版本
關於Unity2018的新版ECS框架
網路遊戲成癮(障礙)系列——3: 網路遊戲障礙的危害?

TAG:遊戲開發 | Unity遊戲引擎 |