Răsfoiți Sursa

Book增加组件跟EventSystem两篇文章

tanghai 7 ani în urmă
părinte
comite
f393b8c232
2 a modificat fișierele cu 214 adăugiri și 0 ștergeri
  1. 80 0
      Book/3.3一切皆组件.md
  2. 134 0
      Book/3.4事件机制EventSystem.md

+ 80 - 0
Book/3.3一切皆组件.md

@@ -0,0 +1,80 @@
+# 一切皆组件
+目前十分流行ECS设计,主要是守望先锋的成功,引爆了这种技术。守望先锋采用了状态帧这种网络技术,客户端会进行预测,预测不准需要进行回滚,由于组件式的设计,回滚可以只回滚某些组件即可。ECS最重要的设计是逻辑跟数据的完全分离。即EC是纯数据,System实际上就是逻辑,由数据驱动逻辑。数据驱动逻辑是什么意思呢?很简单通过Update检测数据变化,通过事件机制来订阅数据变化,这就是所谓的数据驱动了。其它的特点例如缓存命中,在编写逻辑上来说并不太重要,现代游戏都用脚本,连脚本的性能都能容忍怎么会在乎缓存命中那点性能提升?ET在设计的时候吸收了这些想法,但是并不完全照搬,目前的设计是我经过长期的思考跟重构得来的,还是有些自己特色。  
+
+传统的ECS写逻辑作者看来存在不少缺陷,比如为了复用,数据必然要拆成非常小的颗粒,会导致组件非常非常多。但是游戏是多人合作开发的,每个人基本上只熟悉自己的模块,最后可能造成组件大量冗余。还有个问题,常见的ECS是扁平式的,Entity跟Component只有一层。组件一多,开发功能可能不知道该使用哪些Component。好比一家公司,最大的是老板,老板手下带几百个人,老板不可能认识所有的人,完成一项任务,老板没法挑出自己需要的人。合理的做法是老板手下应该有几个经理,每个经理手下应该有几个主管,每个主管管理几个工人,这样形成树状的管理结构才会容易管理。这类似ET的做法,Entity可以管理Component,Component管理Entity,甚至Component还可以挂载Component。例如:人由头,身体,手,脚组成,而头又由眼睛,耳朵,鼻子,嘴巴组成。
+```csharp
+    Head head = human.AddComponent<Head>();
+    head.AddComponent<Eye>();
+    head.AddComponent<Mouse>();
+    head.AddComponent<Nose>();
+    head.AddComponent<Ear>();
+    human.AddComponent<Body>();
+    human.AddComponent<Hand>();
+    human.AddComponent<Leg>();
+```
+ET中,所有数据都是Component,包括Entity,Entity继承于ComponentWithId,ComponentWithId继承于Component,所以Entity本质上也是一个Component,只不过它可以挂载其它的Component。实际使用中你可以继承Component,ComponentWithId,Entity三者之一,区别是如果该类需要挂载组件则继承Entity,如果不需要挂载组件但是需要带个逻辑Id则继承ComponentWithId,剩下的继承Component。ET的Entity是可以有数据成员的,通用的数据放在Entity身上作为成员,不太通用的数据可以作为组件挂在Entity身上。比如物品的设计,所有物品都有配置id,数量,等级的字段,这些字段没有必要做成组件,放在Entity身上使用会更加方便。
+```csharp
+    class Item: Entity
+    {
+        // 道具的配置Id
+        public int ConfigId { get; set; }
+        // 道具的数量
+        public int Count { get; set; }
+        // 道具的等级
+        public int Level { get; set; }
+    }
+```
+ET的这种设计数据是一种树状的结构,非常有层次,能够非常轻松的理解整个游戏的架构。顶层Game.Scene,不同模块的数据都挂载在Game.Scene上面,每个模块自身下面又可以挂载很多数据。每开发一个新功能不用思考太多,类该怎么设计,数据放在什么地方,挂载这里会不会导致冗余等等。比如我玩家需要做一个道具系统,设计一个ItemsComponent挂在Player身上即可,需要技能开发一个SpellComponent挂在Player身上。全服需要做一个活动,搞个活动组件挂在Game.Scene上面。这种设计任务分派会很简单,十分的模块化。  
+
+# 组件的一些细节
+### 1.组件的创建
+组件的创建不要自己去new,应该统一使用ComponentFactory创建。ComponentFactory提供了三组方法用来创建组件Create,CreateWithParent,CreateWithId。Create是最简单的创建方式,它做了几个处理
+a. 根据组件类型构造一个组件
+b. 将组件加入事件系统,并且抛出一个AwakeSystem
+c. 是否启用对象池
+CreateWithParent在Create的基础上提供了一个Parent对象,设置到Component.Parent字段上。CreateWithId是用来创建ComponentWithId或者其子类的,在Create的基础上可以自己设置一个Id
+Component在创建的时候可以选择是否使用对象池。三类工厂方法都带有一个fromPool的参数,默认是true。
+### 2.组件的释放
+Component都继承了一个IDisposable接口,需要注意,Component有非托管资源,删除一个Component必须调用该接口。该接口做了如下的操作
+a. 抛出Destroy System
+b. 如果组件是使用对象池创建的,那么在这里会放回对象池
+c. 从全局事件系统(EventSystem)中删除该组件,并且将InstanceId设为0
+如果组件挂载Entity身上,那么Entity调用Dispose的时候会自动调用身上所有Component的Dispose方法。
+
+### 3.InstanceId的作用
+任何Component都带有一个InstanceId字段,这个字段会在组件构造,或者组件从对象池取出的时候重新设置,这个InstanceId标识这个组件的身份。为什么需要这么一个字段呢?有以下几个原因
+1.对象池的存在,组件未必会释放,而是回到对象池中。在异步调用中,很可能这个组件已经被释放了,然后又被重新利用了起来,这样我们需要一种方式能区分之前的组件对象是否已经被释放,例如下面这段代码:
+```csharp
+		public static async ETVoid UpdateAsync(this ActorLocationSender self)
+		{
+			try
+			{
+				long instanceId = self.InstanceId;
+				while (true)
+				{
+					if (self.InstanceId != instanceId)
+					{
+						return;
+					}
+					ActorTask actorTask = await self.GetAsync();
+					
+					if (self.InstanceId != instanceId)
+					{
+						return;
+					}
+					if (actorTask.ActorRequest == null)
+					{
+						return;
+					}
+
+					await self.RunTask(actorTask);
+				}
+			}
+			catch (Exception e)
+			{
+				Log.Error(e);
+			}
+		}
+```
+while (true)中是段异步方法,await self.GetAsync()之后很可能ActorLocationSender对象已经被释放了,甚至有可能这个对象又被其它逻辑从对象池中再次利用了起来。我们这时候可以通过InstanceId的变化来判断这个对象是否已经被释放掉。
+2.InstanceId是全局唯一的,并且带有位置信息,可以通过InstanceId来找到对象的位置,将消息发给对象。这个设计将会Actor消息中利用到。这里暂时就不讲了。

+ 134 - 0
Book/3.4事件机制EventSystem.md

@@ -0,0 +1,134 @@
+# 事件机制EventSystem  
+ECS最重要的特性一是数据跟逻辑分离,二是数据驱动逻辑。什么是数据驱动逻辑呢?不太好理解,我们举个例子
+一个moba游戏,英雄都有血条,血条会在人物头上显示,也会在左上方头像UI上显示。这时候服务端发来一个扣血消息。我们怎么处理这个消息?第一种方法,在消息处理函数中修改英雄的血数值,修改头像上血条显示,同时修改头像UI的血条。这种方式很明显造成了模块间的耦合。第二种方法,扣血消息处理函数中只是改变血值,血值的改变抛出一个hpchange的事件,人物头像模块跟UI模块都订阅血值改变事件,在订阅的方法中分别处理自己的逻辑,这样各个模块负责自己的逻辑,没有耦合。
+ET提供了多种事件,事件都是可以多次订阅的:  
+1. AwakeSystem,组件工厂创建组件后抛出,只抛出一次,可以带参数
+```csharp
+    Player player = ComponentFactory.Create<Player>();
+
+    // 订阅Player的Awake事件
+    public class PlayerAwakeSystem: AwakeSystem<Player>
+    {
+        public override void Awake(Player self)
+        {
+        }
+    }
+```
+2. StartSystem,组件UpdateSystem调用前抛出
+```csharp
+    // 订阅Player的Start事件
+    public class PlayerStartSystem: StartSystem<Player>
+    {
+        public override void Start(Player self)
+        {
+        }
+    }
+```
+3. UpdateSystem,组件每帧抛出
+```csharp
+    // 订阅Player的Update事件
+    public class PlayerUpdateSystem: UpdateSystem<Player>
+    {
+        public override void Update(Player self)
+        {
+        }
+    }
+```
+4. DestroySystem,组件删除时抛出
+```csharp
+    // 订阅Player的Destroy事件
+    public class PlayerDestroySystem: DestroySystem<Player>
+    {
+        public override void Destroy(Player self)
+        {
+        }
+    }
+
+    Player player = ComponentFactory.Create<Player>();
+    // 这里会触发Destroy事件
+    player.Dispose();
+```
+5. ChangeSystem,组件内容改变时抛出,需要开发者手动触发
+```csharp
+    // 订阅Player的Destroy事件
+    public class PlayerDestroySystem: DestroySystem<Player>
+    {
+        public override void Destroy(Player self)
+        {
+        }
+    }
+
+    Player player = ComponentFactory.Create<Player>();
+    // 需要手动触发ChangeSystem
+    Game.EventSystem.Change(player);
+```
+6. DeserializeSystem,组件反序列化之后抛出
+```csharp
+    // 订阅Player的Deserialize事件
+    public class PlayerDeserializeSystem: DeserializeSystem<Player>
+    {
+        public override void Deserialize(Player self)
+        {
+        }
+    }
+
+    // 这里player2会触发Deserialize事件
+    Player player2 = MongoHelper.FromBson<Player>(player.ToBson());
+```
+7. LoadSystem,EventSystem加载dll时抛出,用于服务端热更新,重新加载dll做一些处理,比如重新注册handler
+```csharp
+    // 订阅Player的Load事件
+    public class PlayerLoadSystem: LoadSystem<Player>
+    {
+        public override void Load(Player self)
+        {
+        }
+    }
+```
+8. 普通的Event,由开发者自己抛出,可以最多带三个参数。另外客户端热更层也可以订阅mono层的Event事件
+```csharp
+    int oldhp = 10;
+    int newhp = 5;
+    // 抛出hp改变事件
+    Game.EventSystem.Run("HpChange", oldhp, newhp);
+
+    // UI订阅hp改变事件
+    [Event("HpChange")]
+    public class HpChange_ShowUI: AEvent<int, int>
+    {
+        public override void Run(int a, int b)
+        {
+            throw new NotImplementedException();
+        }
+    }
+
+    // 模型头顶血条模块也订阅hp改变事件
+    [Event("HpChange")]
+    public class HpChange_ModelHeadChange: AEvent<int, int>
+    {
+        public override void Run(int a, int b)
+        {
+            throw new NotImplementedException();
+        }
+    }
+```
+
+9. 除此之外还有很多事件,例如消息事件。消息事件使用MessageHandler来声明,可以带参数指定哪种服务器需要订阅。
+```csharp
+	[MessageHandler(AppType.Gate)]
+	public class C2G_LoginGateHandler : AMRpcHandler<C2G_LoginGate, G2C_LoginGate>
+	{
+		protected override void Run(Session session, C2G_LoginGate message, Action<G2C_LoginGate> reply)
+		{
+			G2C_LoginGate response = new G2C_LoginGate();
+			reply(response);
+		}
+	}
+```
+更具体的消息事件等到讲消息的时候再细细讲解了  
+10. 数值事件,数值模块再讲解  
+......, 更多的事件由自己去开发。
+
+ET框架的逻辑就是由以上各种事件来驱动的。
+
+