前言

​ 因为学校毕设选题选择了Unity游戏开发相关的内容,并且ECS一直是我比较想去了解的方面,所以选择了ET作为毕设游戏所采用的框架。

1
2
参考:1.<<ET6.0使用手册>> https://www.lfzxb.top/et6-manual/
2.<<ET6.0学习笔记>> https://www.lfzxb.top/et6.0-study/

ECS相对于传统面向对象模式OOP

​ 以前曾开发过ARPG的demo和2D横版过关的demo,所采用的都是传统的面向对象的思路和方法。采用类继承的方式,在前期的开发过程中还是比较轻松,但是在后期的基类逐渐冗余后,也能感觉到面向对象的一些的缺点。

1
参考:https://www.cnblogs.com/egametang/p/7511589.html

​ 在看了上面这篇文章以后,更是对一些面向对象的优缺点有了更深的体会,浓缩为以下两点。

OOP数据结构耦合性强

1
一旦父类中增加或删除某个字段,可能要影响到所有子类,影响到所有子类相关的逻辑。

OOP难以热插拔

1
继承结构无法在运行时动态增加或删除字段,比如有时会需要取消player的某个功能,但这时只能进行禁用,而不能把功能完完全全的剥离出去。

​ 这点在lua中其实得到了很好的解决,在实习阶段我也是基本用的lua来进行minigame的开发,相对于C#来说,Lua语言其实可以选择抛弃传统的纯粹继承的方式,采用类似于单例类+require的方式来进行逻辑开发,而实际上在除游戏实际角色、技能、状态机之外的情况下我也确实采用了这种方法来编写(登入,选服,匹配等等)。可以说这段经历对我来说是一段新的体验,也更加深了我想要去了解ECS组件式开发的兴趣。

ET框架作为一个Unity的双端框架,采用了ECS的组件式概念来进行设计,其ECS的概念也曾经应用于热门的商业游戏守望先锋等,即实体(Entity)-组件(Component)-系统(system),其将Entity中所具有的Component抽象出来,遵循组合大于继承的原则,每位实体都通过一个或多个组件ID连接多个功能组件,避免了一些不必要的开销。

这篇文章仅用于记录下ECS学习过程中的一些心得体会,主要还是对烟雨大佬视频的一些自身收获。

环境配置和demo运行

在ET/Book中已经有各种ET框架指南,本篇主要是记录一些具体过程。

先放一下工程中整体的目录截图:

分为两个主要的sln,Unity和Server:

Unity:

image-20211017140008420

Server:

image-20211017140040074

△Unity和Server中所有的project都要进行build的操作

服务器:

​ 需要安装MongoDB数据库(对于ET的初始demo来说不需要,因为其将数据库交互部分注释了)

​ 需要安装Net Core 或Net Framework(我用的是Net5.0)

​ 在build后启动Server.App即可开启服务器

客户端:

​ 在UnityHub中打开对应项目工程后,首先在Assets/Scenes中找到启动的Init场景

image-20211017143237959

运行后即可输入账号密码进入游戏场景

image-20211017143405871

image-20211017143434676

ET框架结构总览:

Unity.sln项目结构:

image-20211107102409848

image-20211108135131078

  • Hotfix对应Model中Component的System实现,如demo中的XunLuoPathComponentSystem,为逻辑层方法
  • HotfixView对应ModelView中Component的具体实现,如demo中的各种UIComponentSystem,为表现层方法
  • Model为游戏中一些数据Component的存储目录,如demo中的MoveComponent,为逻辑层数据
  • ModelView为游戏中一些外部View展示Component的存储目录,如demo中的各种UI界面Component,也包括GameObject、Animator等,为表现层数据

img

导表部分

先上一张导表方法图:

image-20211110105429222

与烟雨大佬视频中的不同,现在的ET版本中导表工具已经由之前的Assets/Editor/ExcelExporterEditor/ExcelExporterEditor.cs迁移到Server/Model/ExcelExporter/ExcelExporter.cs,而在Unity中的Editor拓展也已经移除了。

过程理解:

导出JSON(暂存信息):

让我们先打开这个ExcelExporter.cs,其在调用时已经被包含在ET/Bin/Server.dll的动态链接库中,因此我们当前的目录其实就是Bin,接下来看下图中的Excel表,Json表等存放的路径就非常清晰了。

image-20211017153220996

对于Excel的读取采用了OfficeOpenXml中的ExcelPackage类来进行处理

1
C#excelpackage读写Excel文件:https://www.cnblogs.com/sange0708/p/15005370.html

先来看一下在demo中给出的AIConfig.xlsx的表格例子:

image-20211017162205295

对应在ExcelPackage读取后的JSON导出代码:

image-20211017162408170

可以得知对应Excel中第二行为是否不导出为JSON,第三行的内容为变量描述、第四行为变量名、第五行为导出类型

#作为中断读取的标志,有#的变量都不会导出为Json

之后是对具体变量内容的读取和写入:

image-20211017163109993

根据之前读取到的头文件信息,从Excel表中的第六行开始进行读取,而后在StringBuffer中写入对应的json信息。

导出类(用于反序列化):

其对headInfo的读取与导出JSON是相同的,关键点在于对于class读取类的导出,用到了在Export中读取到的template.txt,它其实是一个类class的代码模板(继承自ProtoObject),导出类做的主要工作就是将类名、域名等的信息套到这个模板代码中,并生成对应的cs文件。

image-20211017164315689

根据生成的类,动态编译把JSON转译成protobuf:

比起之前版本的ET,新版本的ET是通过protobuf来作为序列化数据的。因此最关键的一步就是转译,具体的代码过程就不贴出来了。

导表所支持的类型:

image-20211110124718506

使用例子:

导表:

​ 在cmd中cd到对应的根目录/Bin文件夹中,而后执行dotnet Server.dll –AppType=ExcelExporter即可,导出的表格会分别存放在./Client/Json文件夹和./Server/Json文件夹中。

image-20211017164636195

脚本使用:

​ 在具体CS脚本中的通过表格名+Category的单例类就可以获取对应的表格信息(key值对应ID)。

image-20211017170048269

Proto生成CS与拓展

ET框架采用了分布式服务器的方式。

同样列出上方的一张图:

image-20211018155809927

我们先来找到ET/Model/Proto2CS/Program.cs文件,与导表的cs文件相同,其在具体使用的时候同样是被打成了dll放在了ET/Bin文件夹下,因此文件的根目录同样是Bin文件夹。

image-20211018160111894

​ 对于客户端和服务器的生成路径如上图

1
2
之前在实习阶段也对proto的定义有了一些理解,但是在实际看到ET框架内的proto的时候还是和之前所认知的有一些差别,
之前的框架是基于lua脚本因此对proto来说无需转换到CS,而如今ET框架的热更等是面向ILRuntime的。

image-20211018163131915

看到具体的Proto2CS的生成函数:

1.InnerMessage是服务器内部的协议生成,可能是用于分布式的多服务器沟通

2.OuterMessage则是针对于C2S和S2C之间的交互了

ET客户端与服务端的交互总览:(取自烟雨大佬的视频)

image-20211020153521006

网关服:用于做消息转发,与Actor信息配套

验证服:账号的登入,注册

战斗服:寻路、战斗信息

1.Domain指这个entity属于哪个scene,一个进程可以容纳多个scene

2.对于客户端和服务端来说,其最大的scene都为Game.scene,在客户端中,目前通过Game.scene来作为全局通用的Component的存储(比如UIComponent、UIEventComponent等),而对于服务器来说一个功能对应ET中的一个scene,具体的配置方法在Excel/StartSceneConfig中。

3.每个客户端的Game.scene在服务端会在随机分配的gate中生成一个key,后续客户端可以通过这个key连接到服务端。

使用例子:

脚本使用:

客户端调用:

image-20211018171843943

服务器处理:

image-20211019135845225

image-20211019141256527

△可以看到在服务器的Handler中有分布式服务器之间的交互处理

实际使用过程:

  • 定义proto文件
  • 生成C#代码
  • 编写双端收发逻辑代码

在OuterMessage.proto中添加新的协议

这里没有采用actor的方式,添加了三个协议

  • C2R_TestSend
  • C2R_TestCall
  • R2C_TestResponse

image-20211020234524827

在客户端的LoginHelper的异步Login中添加相应session.Call和Send:

image-20211020154921586

在服务器的Hotfix/Demo中加入对应的CallHandler和SendHandler

  • C2R_TestCallHandler

image-20211020234051973

  • C2R_TestSendHandler

image-20211020234029225

  • 注意点
    • Request和Response的应实现AMRpcHandler接口,而用于处理Request的则实现AMHandler接口。
    • 在写入新的协议handler以后要对重新build Hotfix
    • 要在对应的Handler类上打上[MessageHandler]的标签
    • Log.Info进行了进一步封装,其结果存放在ET/Logs目录的日志文件中

现在服务器和客户端都能输出正确回应:

server:

image-20211020234220037

client:

image-20211020232241622

Actor机制

同样先放上在烟雨大佬视频的思维导图:

image-20211021101415991

  • 关键点
    • Entity InstanceID是唯一的,即对应的每个居住证是唯一的,仅代表了当前所在进程以及自身Entity
    • Entity上挂载的MailboxComponent组件就是一种Actor,只需要知道Entity的InstanceId就可以给这个Entity发送消息
    • Actor模型固然方便,但是在有时我们无法知道对方的InstanceId或者对方的InstanceId进行了改变,这时候就需要ActorLocation发挥作用,Actor对象在一个进程创建时或者迁移到一个新的进程时,都需要把自己的Id跟新的InstanceId注册到Location Server上去,ActorLocationSender提供两种方法,Send跟Call,Send一个信息也需要接受者返回一个消息,只有收到返回的消息才会发送下一个消息。
    • Actor模型是纯粹的服务器消息通信机制,ET客户端使用这个Actor完全是因为Gate需要对客户端消息进行转发,我们可以正好利用服务端actor模型来进行转发,所以客户端有些消息也是继承了actor的接口。
    • Actor和ActorLocation的一个最大的区别在于ActorLocation需要先在Location服务器中获取到Entity的真实的InstanceId,其在LocationProxyComponentSystem.cs中体现,通过key:UnitId对应value:InstanceId

具体例子:

下面是一个在进入map服后创建战斗Unit的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[ActorMessageHandler]
public class G2M_CreateUnitHandler : AMActorRpcHandler<Scene, G2M_CreateUnit, M2G_CreateUnit>
{
protected override async ETTask Run(Scene scene, G2M_CreateUnit request, M2G_CreateUnit response, Action reply)
{
UnitComponent unitComponent = scene.GetComponent<UnitComponent>();
Unit unit = Entity.CreateWithId<Unit, int>(unitComponent, IdGenerater.Instance.GenerateId(), 1001);
unit.AddComponent<MoveComponent>();
unit.Position = new Vector3(-10, 0, -10);

NumericComponent numericComponent = unit.AddComponent<NumericComponent>();
numericComponent.Set(NumericType.Speed, 6f); // 速度是6米每秒

unit.AddComponent<MailBoxComponent>();
await unit.AddLocation();
unit.AddComponent<UnitGateComponent, long>(request.GateSessionId);
unitComponent.Add(unit);
response.UnitId = unit.Id;

// 把自己广播给周围的人
M2C_CreateUnits createUnits = new M2C_CreateUnits();
createUnits.Units.Add(UnitHelper.CreateUnitInfo(unit));
MessageHelper.Broadcast(unit, createUnits);

// 把周围的人通知给自己
createUnits.Units.Clear();
Unit[] units = scene.GetComponent<UnitComponent>().GetAll();
foreach (Unit u in units)
{
createUnits.Units.Add(UnitHelper.CreateUnitInfo(u));
}
//△通过GateSessionActorId向对应的unit发送消息
MessageHelper.SendActor(unit.GetComponent<UnitGateComponent>().GateSessionActorId, createUnits);

reply();
}
}

其中的MessageHelper.SendActor已经很明了了,其通过对应Unit的网关InstanceId来向对应Unit发送信息,那么对于MessageHelper呢?

​ 查看MessageHelper源码中的Broadcast发现,其同样使用了SendActor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void Broadcast(Unit unit, IActorMessage message)
{
//获取在相同Scene中的所有Units(也就是同处于战斗服)
var units = unit.Domain.GetComponent<UnitComponent>().GetAll();

if (units == null)
{
return;
}

foreach (Unit u in units)
{
UnitGateComponent unitGateComponent = u.GetComponent<UnitGateComponent>();
SendActor(unitGateComponent.GateSessionActorId, message);
}
}

ActorLocation机制:

  • 其相对于actor消息加入了Location服务器用于查询当前对象所处的进程(如不同地图进程map),防止玩家Player在不同地图中传送后的消息丢失image-20220116202349042
  • 于Actor消息的区分:Actor消息主要用于外围系统,如聊天系统,而ActorLocation消息主要用于地图传送等系统

ET 6.0 demo中的网络交互流程:

  • 交互流程
    • 客户端发送C2R_Login协议(账号密码)到服务端,服务端在大区中随机分配一个gate,并且在服务器内部向gate请求一个key,客户端可以拿着这个key连接gate,服务器将Address,网关key,网关Id回传给客户端
    • 客户端发送G2C_LoginGate(网关key和网关Id)到服务端,服务端通过网关key获取对应的账号,同时获取Game中的PlayerComponent,创建Player的Entity并加入其中,同时返回playerId
    • 客户端发送G2C_EnterMap到服务器,服务器在zone中找到对应的map scene后在map上创建战斗Unit(同时会向客户端发送M2C_CreateUnits令其在Game中创建Unit),并且将UnitId赋值给player.UnitId,同时将其作为返回值回传给客户端,客户端收到后在Game的unitComponent获取对应Unit并赋值给MyUnit

异步处理Async和Await

这部分的内容比较多,放在了一篇新的文章中。

事件系统(EventSystem)

使用过程:

ET中的事件系统相对比较简单,其定义方式分为三个步骤:

  • 在EventType中定义事件结构体

    1
    2
    3
    4
    5
    6
    7
    namespace EventType
    {
    public struct TestEvent
    {
    public string message;
    }
    }
  • 定义一个类用于处理指定时间,泛型类型订阅的事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class TestEvent_Action: AEvent<EventType.TestEvent>
    {
    protected override async ETTask Run(EventType.TestEvent args)
    {
    Log.Info($"TestEvent事件触发了 : {args.Type}");
    //虽然这个函数是async的,但是我们内部没有异步操作,就可以这样调用,相当于直接return
    await ETTask.CompletedTask;
    }
    }
  • 抛出事件

    1
    Game.EventSystem.Publish(new EventType.TestEvent(){Message="测试"}})

代码解析:

​ ![ET EventSystem](ET6.0框架初步/ET EventSystem.png)

  • 在EventSystem的Add(Assembly assembly)中在程序集内获取到所有打上了Attribute的class,并在从中挑选出打了[Event]标签的class(即实现了IEvent接口的AEvent<A>抽象类),并将其订阅事件结构体类型Type对应List<obj>()存储在AllEvents字典中
    image-20211022105434483
  • 在Publish时将所有ETTask的Run()函数加入到ListComponent.Create()创建的list中,而后调用ETTaskHelper.WaitAll(list.List);执行
    image-20211022105925399
  • 注意在WaitAll中所有的事件是异步执行的,内部通过CoroutineBlocker来阻塞未执行完的所有Task,在count==0时继续之前所有阻塞的tcs
    image-20211022110531535

关键点:

  • ETTask重写了传统Task,将自身作为Awaiter,并通过枚举的AwaiterStatus判断是否结束。dd0f615f0d5b6fef48bbbac88276c8d

    上图是可等待模式的处理,对应ETTask分别为:

    GetAwaiter():

    image-20211022141746640

    IsCompleted():

    image-20211022141852539

    其中的AwaiterStatus可以在SetResult()中进行更改。

    image-20211022142017462

UI系统

还是很想吐槽一下ET的UI系统实在是有点过于简洁了,其UI生命周期中的事件只有OnCreate和OnRemove两种,后续还需要继续拓展。

  • 通过UIComponent组件类管理Scene上的UI
    • 通过.Create(this UIComponent self,string uiType)函数进行创建,同时在创建时会执行UIEvents字典中通过自定义标签导入进来的onCreate()函数,其外部通过UIHelper.Create(Scene scene,string uiType)在指定scene中创建
    • 通过.Remove(this UIComponent self,string uiType)函数进行移除,同时在创建时会执行UIEvents字典中通过自定义标签导入进来的onRemove()函数,其外部通过UIHelper.Create(Scene scene,string uiType)在指定scene中移除
  • 通过ReferenceCollector可以获取UIPrefab上面的引用,可以直接在prefab里加入RC方便后续获取子物体
    image-20211022153257071

对ET自带的UI部分进行了拓展。

  • 将原先的mid、hidden、low等变为普通GO,用代码设定sortingOrder
  • 加入UICamera方便管理,代码设定UICamera
  • 新增onShow(),onHide()

10/24更新:

  • 新增UI栈
  • UIComponent、UIComponentSystem、UIHelper代码拓展

10/25更新:

  • 引入新的UIStatesComponent组件,记录UIForm(UI类型),isShow(是否展示中)等信息
  • 处理UI栈与UI字典的关系

10/26更新:

  • 加入UI栈中UI对应次数的字典,处理同一UI多次入栈的情况

场景管理

ET场景切换整体思路和代码也不多,后续可能要结合Loading进度条拓展

先放上流程图:

ET:Scene.drawio

针对其加载回调做了一些拓展处理:

  • 在ChangeSceneAsync完成时会进行EventType.ChangeSceneFinish事件广播处理,广播内容为sceneName和zoneScene
  • 在ChangeSceneFinish_SceneCallBack的事件处理函数中根据场景名进行组件添加处理
    image-20211028001327798

AB包配置

  • 对于ET来说有自带的AB包配置打包工具,其在项目的Tools/打包工具中
    image-20211113151829342
  • 打包前需要配置完成对应的AssetBundle名,若不太了解可以先去学习一下Unity的AB包打包和加载部分。

使用例子:

以Demo中UI打包与加载为例子。

  • 打包
    • 在Bundle/UI/UILogin.prefab中配置好对应的AB包名,demo这里配置为了uilogin.unity3d。image-20211113152457652
    • 在打包工具中点击开始打包(以打包的BuildType选择为Release为例子)
    • 打出的AB包会根据打包时选择的BuildType存放在对应的路径中,因此这里对应存储在ET/Release/PC/StreamingAssets
      image-20211113162252071
  • 加载
    • 在Editor状态下代码中默认采用的是真实路径来进行加载,因此如果要测试打出的AB包,需要注释ResourceComponent中LoadOneBundle(string assetBundleName)中相应的代码。

      ResourcesComponent.cs:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
         private void LoadOneBundle(string assetBundleName)
      {
      assetBundleName = assetBundleName.BundleNameToLower();
      ABInfo abInfo;
      if (this.bundles.TryGetValue(assetBundleName, out abInfo))
      {
      ++abInfo.RefCount;
      //Log.Debug($"---------------load one bundle {assetBundleName} refcount: {abInfo.RefCount}");
      return;
      }

      if (!Define.IsAsync)
      {
      //----------------------注释部分-----------------------------
      // #if UNITY_EDITOR
      // string[] realPath = null;
      // realPath = AssetDatabase.GetAssetPathsFromAssetBundle(assetBundleName);
      //
      // foreach (string s in realPath)
      // {
      // string assetName = Path.GetFileNameWithoutExtension(s);
      // UnityEngine.Object resource = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(s);
      // AddResource(assetBundleName, assetName, resource);
      // }
      //
      // if (realPath.Length > 0)
      // {
      // abInfo = Entity.Create<ABInfo, string, AssetBundle>(this, assetBundleName, null);
      // this.bundles[assetBundleName] = abInfo;
      // //Log.Debug($"---------------load one bundle {assetBundleName} refcount: {abInfo.RefCount}");
      // }
      // else
      // {
      // Log.Error($"assets bundle not found: {assetBundleName}");
      // }
      // #endif
      // return;
      //----------------------注释部分-----------------------------
      }

      string p = Path.Combine(PathHelper.AppHotfixResPath, assetBundleName);
      AssetBundle assetBundle = null;
      if (File.Exists(p))
      {
      assetBundle = AssetBundle.LoadFromFile(p);
      }
      else
      {
      p = Path.Combine(PathHelper.AppResPath, assetBundleName);
      assetBundle = AssetBundle.LoadFromFile(p);
      }

      if (assetBundle == null)
      {
      // 获取资源的时候会抛异常,这个地方不直接抛异常,因为有些地方需要Load之后判断是否Load成功
      Log.Warning($"assets bundle not found: {assetBundleName}");
      return;
      }

      if (!assetBundle.isStreamedSceneAssetBundle)
      {
      // 异步load资源到内存cache住
      var assets = assetBundle.LoadAllAssets();
      foreach (UnityEngine.Object asset in assets)
      {
      AddResource(assetBundleName, asset.name, asset);
      }
      }

      abInfo = Entity.Create<ABInfo, string, AssetBundle>(this, assetBundleName, assetBundle);
      this.bundles[assetBundleName] = abInfo;

      Log.Debug($"---------------load one bundle {assetBundleName} refcount: {abInfo.RefCount}");
      }
    • 通过以下代码进行AB包的读取,这里以UILogin.prefab所在的uilogin.unity3d的ab包为例子

      在其OnCreate()的声明周期中进行AB包加载和对应的GO生成。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      public override async ETTask<UI> OnCreate(UIComponent uiComponent)
      {
      //异步加载过程(ETTask仍为单线程)
      await ResourcesComponent.Instance.LoadBundleAsync(UIType.UILogin.StringToAB());
      GameObject bundleGameObject = (GameObject) ResourcesComponent.Instance.GetAsset(UIType.UILogin.StringToAB(), UIType.UILogin);
      GameObject gameObject = UnityEngine.Object.Instantiate(bundleGameObject);

      UI ui = Entity.Create<UI, string, GameObject>(uiComponent, UIType.UILogin, gameObject);

      ui.AddComponent<UILoginComponent>();
      return ui;
      }