前言

本文章不包含代码分析,仅仅为使用手册。

ET本身就是一个很优秀的框架,对于ET的设计理念和核心思想,可以去阅读下原作者写的一系列文章:ETBook

这里也列举出一些学习资料,供大家参考

环境

服务器 .Net版本:.Net Core 5.0

云端服务器:CentOS 7.6

公共部分

基础架构

ET是一个单线程,多进程的框架,好处是单线程的开发效率更高,写出的代码更安全,Bug也更好查,多进程可以做分布式架构,均衡负载等。

ET框架采用了ECS架构(Entity-Component-System),而非传统的OOP架构,好处是开发效率非常高,并且借助于ECS的解耦功能,重构起来也很快。

但是ET的ECS也并非纯正的ECS,而是在架构学术派与实用派做了折衷,严格来说是E + S(Entity是纯数据,System是纯逻辑),那么把Component放哪了呢?在ET里Component就是Entity,Entity就是Component,但这个概念是代码层面的设计,当我们进行开发的时候,依旧要时刻谨记,Entity是一个对象,而Component是一个组件,只能依托于对象存在

基础结构如下

image-20210907004244017

Game:整个世界,可以理解为造物主

Scene:世界中的各个大陆

EventSystem:事件系统,整个世界的神谕者,他来决定哪个实体应该干什么

如果我们要获取图中Component1组件,代码可以这样写

1
Component1 component1 = Game.Scene.GetComponent<Component1>();

逻辑开发

如果我们想创建一个人类,那么就需要定义一个HumanEntity(人的定义),BodyComponent(一个人的身体),HeadComponent(一个人的头)

1
2
3
4
5
// 对你没看错,就这么一个光秃秃的类,它什么都没有
public class HumanEntity: Entity
{

}

然后是身体和头部,可以看到,虽然依旧是继承的Entity,但是他们的名称后缀是Component,而不是Entity,这是需要我们时刻谨记的

1
2
3
public class BodyComponent: Entity
{
}
1
2
3
public class HeadComponent : Entity
{
}

现在我们就开始创造一个人类吧

1
2
3
4
5
6
7
// 由EntityFactory这个工厂来提供类型的实例化,因为自带对象池机制
// 注意第一个参数我们传递的是Game.Scene,因为在创建Entity时必须要指定其降生在哪个大陆
HumanEntity human1 = EntityFactory.Create<HumanEntity>(Game.Scene, true);

// 为这个人类加上头和身体
human1.AddComponent<HeadComponent>();
human1.AddComponent<BodyComponent>();

我们知道,一个人终有生老病死,所以一个Entity会有自己的生命周期

image-20210907004311876

其中Awake是Entity首次被创建时自动调用,Update为每帧调用,LateUpdate为每帧结尾调用,Destroy为Entity死亡的时候调用,Dispose为Entity灵魂湮灭时调用,其中Dispose标红,是因为,我们永远不应该拓展Entity的Dispose方法,因为这是由框架接管的,我们最好只拓展Destroy方法

尤其需要注意的是,如果没有将一个Entity直接或间接的装载到一个Scene上,那么它的Update,LateUpdate都将不会执行,所谓直接或间接的意思就是这个Entity一定是一个Scene的子节点,例如

1
2
3
//         A
// B C
// D

A为Scene,B为挂载到A上的一个Entity,C为挂载到A上的一个Entity,D为挂载到B上的一个Entity,那么B,D的Update,LateUpdate都可以正常执行

但是如果

1
2
3
4
// 只是指明B会降生在Game.Scene上,但是并没有指明B的Parent就是Game.Scene(不限定Game.Scene,任何一个Scene对象即可)
B b = EntityFactory.Create<B>(Game.Scene, true);
// AddComponent的时候指明了d的父亲是b,但是b的父亲和Game.Scene(不限定Game.Scene,任何一个Scene对象即可)没关系,所以d的Update,LateUpdate也是无效的
D d = b.AddComponent<D>();

那么不论是B还是D的Update,LateUpdate都不会执行

正确的方式应该是

1
2
3
4
// 创建的时候指明B的父亲是Game.Scene(不限定Game.Scene,任何一个Scene对象即可)
B b = EntityFactory.CreateWithParent<B>(Game.Scene, true);
// AddComponent的时候指明了d的父亲是b,并且b的父亲直接就是Game.Scene(不限定Game.Scene,任何一个Scene对象即可),所以b,d的Update,LateUpdate都正常
b.AddComponent<D>();

这样B和D的Update,LateUpdate都可以正常执行了

那么我们如何给我们的人类加上生命周期,让他变成活人呢?

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
public class HumanEntityAwakeSystem : AwakeSystem<HumanEntity>
{
public override void Awake(HumanEntity self)
{

}
}
public class HumanEntityUpdateSystem : UpdateSystem<HumanEntity>
{
public override void Update(HumanEntity self)
{

}
}
public class HumanEntityLateUpdateSystem : LateUpdateSystem<HumanEntity>
{
public override void LateUpdate(HumanEntity self)
{

}
}
public class HumanEntityDestroySystem: DestroySystem<HumanEntity>
{
public override void Destroy(HumanEntity self)
{

}
}

没错,只需要定义这样几个类,就行了!框架会帮我们在合适的生命周期调用他们,还是我们上面的例子:

1
2
3
4
5
6
7
// 会调用human1的Awake函数(需要注意的是,根据我们上面提到的,human1只会执行Awake函数,Update,LateUpdate都是失效的)
HumanEntity human1 = EntityFactory.Create<HumanEntity>(Game.Scene, true);

// 会调用HeadComponent的Awake函数
human1.AddComponent<HeadComponent>();
// 会调用BodyComponent的Awake函数
human1.AddComponent<BodyComponent>();

我们可以在Entity生命周期函数中执行想要执行的操作,比如我们想让每个人类在出生的时候都大哭一下,哭是头部的行为,所以我们要把哭这个行为放在HeadComponent的Awake函数中,这样我们在给人类加上头部的时候,他就会哭出来啦

1
2
3
4
5
6
7
public class HeadComponentAwakeSystem : AwakeSystem<HeadComponent>
{
public override void Awake(HeadComponent self)
{
Log.Info("哇~");
}
}

嗯,第一个人造好啦,当你开始造第二个人的时候,才一拍脑袋,坏了忘了给第一个人取名了,但是木已成舟,只能先这样了,索性就叫第一个人 无名氏吧,在造第二个人的时候可不能再忘了,所以给HumanEntity加了一个string字段

1
2
3
4
5
6
7
public class HumanEntity: Entity
{
/// <summary>
/// 名字
/// </summary>
public string Name;
}

如果我们想要在创建一个人的时候就给他取个名字,该怎么做呢?

首先我们要拓展一下HumanEntity的生命周期函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class HumanEntityAwakeSystem : AwakeSystem<HumanEntity>
{
public override void Awake(HumanEntity self)
{
}
}

//新增的生命周期函数,可以注意到它相对于第一个Awake,可以多接受一个string参数
public class HumanEntityAwakeSystem2 : AwakeSystem<HumanEntity, string>
{
public override void Awake(HumanEntity self, string name)
{
// 给自己一个名字
self.Name = name;
}
}

然后就可以开始造第二个人啦

1
2
3
4
5
6
7
8
9
10
HumanEntity human1 = EntityFactory.Create<HumanEntity>(Game.Scene, true);
human1.AddComponent<HeadComponent>();
human1.AddComponent<BodyComponent>();

// 我们给第二个人取名 李逍遥,可以注意到它相对于第一个Awake,可以多接受一个string参数
// 其实对于下面的AddComponent,如果想要传递参数,也是一样的做法
// 例如 human2.AddComponent<HeadComponent, string>("Da Tou");
HumanEntity human2 = EntityFactory.Create<HumanEntity, string>(Game.Scene, "Li XiaoYao", true);
human2.AddComponent<HeadComponent>();
human2.AddComponent<BodyComponent>();

两个人出生在同一片大陆的不同地方,但是他们都在寻找着人类的踪迹,终于有一天,他们两个相遇了,并且当无名氏得知李逍遥有名字这件事后大吃一惊,当即想要签订契约成为李逍遥的小弟,所以有了如下操作

1
2
// 设置父级,设置好之后,一旦父级死亡,自己也将死亡,各自执行自己的Destroy函数
human1.Parent = human2;

await/async

await/async是ET中非常强大的一个功能,可以非常方便的实现协程,以同步的方式写异步代码

现在我们把最开始的例子变成异步的,可以注意到多了async ETVoid两个关键字,这就实现了异步

1
2
3
4
public static async ETVoid StartAsync()
{
...
}

但是这有什么用呢?我们开始重新创造这个世界

由于我们第一次造人,工艺不是很成熟,所以一个人造完后,需要一段时间适应身体才能自由活动

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
public static async ETVoid StartAsync()
{
HumanEntity human1 = EntityFactory.Create<HumanEntity>(Game.Scene, true);
human1.AddComponent<HeadComponent>();
human1.AddComponent<BodyComponent>();
//等待1000ms,但是并不会阻塞在这里,而是立即执行外面将要执行的函数,也就是StartAsync2
await TimerComponent.Instance.WaitAsync(1000);
Log.Info("无名氏可以自由活动啦");
}
public static void Start()
{
HumanEntity human2 = EntityFactory.Create<HumanEntity, string>(Game.Scene, "Li XiaoYao", true);
human2.AddComponent<HeadComponent>();
human2.AddComponent<BodyComponent>();
Log.Info("李逍遥可以自由活动啦");
}

public static void WorldStart()
{
StartAsync().Coroutine();
Start();
}

调用堆栈为
-----------------------------------------------
WorldStart->StartAsync->await TimerComponent.Instance.WaitAsync->Start --(等待1000ms)--> Log.Info("无名氏可以自由活动啦");
--------------------------------------------------
李逍遥可以自由活动啦
无名氏可以自由活动啦

从最后的Log可以看出,虽然无名氏是先被制造的,但是是李逍遥先可以自由活动的!

换一种思路,有没有可能是我们第一次造人太累了,需要自己休息1000ms呢?有一说一,雀食是有这个可能的

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
public static async ETTask StartAsync()
{
HumanEntity human1 = EntityFactory.Create<HumanEntity>(Game.Scene, true);
human1.AddComponent<HeadComponent>();
human1.AddComponent<BodyComponent>();
await TimerComponent.Instance.WaitAsync(1000);
Log.Info("无名氏可以自由活动啦");
}
public static void Start()
{
HumanEntity human2 = EntityFactory.Create<HumanEntity, string>(Game.Scene, "Li XiaoYao", true);
human2.AddComponent<HeadComponent>();
human2.AddComponent<BodyComponent>();
Log.Info("李逍遥可以自由活动啦");
}

public static async ETVoid WorldStart()
{
await StartAsync();
Start();
}
调用堆栈为
-----------------------------------------------
WorldStart->StartAsync->await TimerComponent.Instance.WaitAsync--(等待1000ms)-->Log.Info("无名氏可以自由活动啦")->Start->Log.Info("无名氏可以自由活动啦");
--------------------------------------------------
无名氏可以自由活动啦
李逍遥可以自由活动啦

到这里就可以总结一下了,如果一个异步方法是async ETVoid的,那么他就是不可被等待的,会直接执行外部后面的方法,如果一个异步方法是async ETTask的,那么他就是可以被等待的,只有执行完它才会执行外部后面的方法

但是同样需要记住,ET是单线程的,这里的等待其实就是一个计时器。

在第一个例子里,我们把这个时间戳加入计时器组件后,才跳出的StartAsync堆栈,并没有新开辟线程,也没用什么魔法

在第二个例子里,我们把这个时间戳加入计时器组件后,由于是async ETTask,所以跳不出去,只能老实等待1000ms

事件系统

在ET里事件系统也非常的简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 定义一个事件结构体
public struct AppStart
{
public string Type;
}

// 定义一个类,用于处理指定事件,泛型类型为我们想要订阅的事件
public class AppStart_Init: AEvent<EventType.AppStart>
{
protected override async ETTask Run(EventType.AppStart args)
{
Log.Info($"AppStart事件触发了 : {args.Type}");
//这个操作也很常用,比如虽然这个函数是async的,但是我们内部没有异步操作,就可以这样调用,相当于直接return
await ETTask.CompletedTask;
}
}

// 抛出事件
Game.EventSystem.Publish(new EventType.AppStart(){Type = "游戏开始"});

------------------------------
AppStart事件触发了

可以看到我们甚至都不需要主动注册事件,因为注册事件这个操作,在初始化的时候框架就帮我们做好了,我们只管抛出事件就行了!

配置表读取

ET自带了Excel表的数据导出和读取操作,项目的Excel表格存放位置为 /项目根目录/Excel/

策划同学配表时必须严格按照这个格式进行配置,即前两行两列为空,例如这个 UnitConfig.xlsx 表

image-20210907004444990

配置好之后,在Unity菜单栏点击Tools-Excel配置表导出即可

随后即可进行配置表的读取了,以我们上面的 UnitConfig.xlsx 表为例

1
UnitConfig unitConfig = UnitConfigCategory.Instance.Get(1001);

但请注意,Excel表中读出的数据是只读的,一定不能对其进行修改!

网络通信

这部分可能有些定义比较陌生,建议先看完这几个学习资料再进行学习:

首先说明下ET中各个服务器缩写代表的含义

image-20210907004836553

协议定义与导出

网络通信使用了ProtoBuf协议,PB定义文件位于 /项目根目录/Proto/中,有两个proto文件

  • InnerMessage.proto是服务端内网消息定义
  • OutMessage是客户端和服务端交互的外网消息定义

定义的内容有一定的格式要求,这里以OutMessage中的C2M_TestRequest为例

1
2
3
4
5
6
7
//ResponseType M2C_TestResponse
message C2M_TestRequest // IActorLocationRequest
{
int32 RpcId = 90;
int64 ActorId = 93;
string request = 1;
}

消息的头部注释部分(//ResponseType M2C_TestResponse)是声明其RPC调用返回的消息类型,消息的尾部注释部分(// IActorLocationRequest)是声明这个消息所继承的类型,下面是消息类型的汇总

  • 不需要返回结果的消息 IMessage
  • 需要返回结果的消息 IRequest
  • 用于回复的消息 IResponse
  • 不需要返回结果的Actor消息 IActorMessage,IActorLocationMessage
  • 需要返回结果的Actor消息 IActorRequest IActorLocationRequest
  • 用于回复的Actor消息 IActorResponse IActorLocationResponse

尤其需要注意的是,RPC消息中,Request一定要有RpcId字段,Response必须要有Error字段,否则会报错!

PB文件定义完成后,可以来到Unity菜单Tools-PB协议导出,即可一键导出

导出目录:

客户端:Assets/Model/Generate/Message/

服务端:Server/Model/Generate/Message/

通信代码

首先以登陆部分的代码为例(经典RPC调用)

客户端发起登录请求

1
2
3
4
5
// 一个Session可以理解为一个Socket链接,Create的参数是要连接的远程地址
Session session = zoneScene.GetComponent<NetKcpComponent>().Create(NetworkHelper.ToIPEndPoint(address));
// 直接通过await发送RPC消息,只有服务器回包后,才会执行最后的Log,运行堆栈可以翻看上面的await/async部分
r2CLogin = (R2C_Login) await session.Call(new C2R_Login() {Account = account, Password = password});
Log.Info("登陆完成");

服务端这边相应的要写一个消息处理函数,其与事件系统和生命周期系统书写方式十分类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//注意这里的两个泛型参数类型要写对,由于是RPC调用,所以是一个Request和一个Response
[MessageHandler]
public class C2R_LoginHandler : AMRpcHandler<C2R_Login, R2C_Login>
{
//注意这里的request参数和response参数类型要写对
//这里的Session就是服务器向客户端发送消息的一个Socket连接
protected override async ETTask Run(Session session, C2R_Login request, R2C_Login response, Action reply)
{
// 随机分配一个Gate
StartSceneConfig config = RealmGateAddressHelper.GetGate(session.DomainZone());
//Log.Debug($"gate address: {MongoHelper.ToJson(config)}");

// 向gate请求一个key,客户端可以拿着这个key连接gate
// 内网消息一律使用Actor消息
G2R_GetLoginKey g2RGetLoginKey = (G2R_GetLoginKey) await ActorMessageSenderComponent.Instance.Call(
config.InstanceId, new R2G_GetLoginKey() {Account = request.Account});

// gate回包后response填充数据
response.Address = config.OuterIPPort.ToString();
response.Key = g2RGetLoginKey.Key;
response.GateId = g2RGetLoginKey.GateId;
// reply会自动将response消息发回客户端
reply();
}

最后客户端这边收到服务端回包进行处理,也就是await调用完成,执行 Log.Info("登陆完成");

如果不是RPC调用就更简单了(纯Message协议发送),这里以客户端往服务端发送寻路请求为例子

客户端发送寻路请求

1
self.DomainScene().GetComponent<SessionComponent>().Session.Send(self.frameClickMap);

服务端处理寻路请求,并且把最终的寻路路径点发回客户端

1
2
3
4
5
6
7
8
9
10
11
[ActorMessageHandler]
public class C2M_PathfindingResultHandler : AMActorLocationHandler<Unit, C2M_PathfindingResult>
{
protected override async ETTask Run(Unit unit, C2M_PathfindingResult message)
{
Vector3 target = new Vector3(message.X, message.Y, message.Z);
unit.FindPathMoveToAsync(target).Coroutine();

await ETTask.CompletedTask;
}
}

客户端处理服务端发回的路径点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[MessageHandler]
public class M2C_PathfindingResultHandler : AMHandler<M2C_PathfindingResult>
{
protected override async ETVoid Run(Session session, M2C_PathfindingResult message)
{
Unit unit = session.Domain.GetComponent<UnitComponent>().Get(message.Id);
float speed = unit.GetComponent<NumericComponent>().GetAsFloat(NumericType.Speed);
using MonoListComponent<Vector3> list = MonoListComponent<Vector3>.Create();

for (int i = 0; i < message.Xs.Count; ++i)
{
list.List.Add(new Vector3(message.Xs[i], message.Ys[i], message.Zs[i]));
}
await unit.GetComponent<MoveComponent>().MoveToAsync(list.List, speed);
}
}

客户端部分

客户端支持了资源热更新(xAsset)和代码热更新(ILRuntime),在支持热更新的前提下,我们基本上只需要出一次安装包,然后后续的资源更新和代码更新都通过在线下载的方式进行更新即可,非常的高效。

客户端分为两大部分:Mono层和热更层

Mono层

也就是非热更层,一般不需要往这里写代码

热更层

Model,ModelView,Hotfix,HotfixView,其中Model(XXX)层为纯数据,Hotfix(XXX)为纯逻辑

那么对于有或者没有View的区别就是,View表示会和Unity产生耦合的代码,例如GameObject,UI等,而不带View的就表示不会和Unity产生耦合

服务端部分

服务端主要为两大部分,Model层和Hotfix层,Model层是纯数据,Hotfix层是纯逻辑,设计的初衷是为了不停服的情况下进行热更新(即只构建Hotfix层代码,然后直接Reload实现不停服热更)

对于服务端的Linux云端部署,之前有写过一篇文章,可以只看云服务器部署部分 构建recastnavigation并部署服务端到Linux

ET6.0的部署配置抛弃了老版本的Json配置,而使用Excel进行配置,更加的灵活和方便,可以结合我的 ET6.0学习笔记 对配置表里的内容进行理解。

MongoDB数据库

环境配置

请先按照此篇文章进行MongoDB数据库环境的安装与配置:包含MongoDB环境安装与配置的教程

增删改查

ET中所有保存到MongoDB的对象都要继承自Entity,例如我们有一个账号类型

1
2
3
4
5
6
7
public class AccountInfo: Entity
{
//用户名
public string Account { get; set; }
//密码
public string Password { get; set; }
}

想要将其保存到数据库中

1
2
3
4
5
6
7
//新建账号
AccountInfo newAccount = EntityFactory.CreateWithId<AccountInfo>(session.DomainScene(), IdGenerater.Instance.GenerateId());
newAccount.Account = request.Account;
newAccount.Password = request.Password;

// 保存账号数据到数据库
await DBComponent.Instance.Save(newAccount);

如果想要查询某一类对象

1
2
3
4
// 参数是一个条件表达式,意思是要查询的AccountInfo对象需要满足Account为request.Account,并且Password为request.Password
// 之所以返回的是一个数组是因为我们写不同的条件表达式会有不同的查询结果,有可能会有多个对象单个键相同的情况
List<AccountInfo> accountInfos = await DBComponent.Instance.Query<AccountInfo>(acco
account.Account == request.Account && account.Password == request.Password);

如果想要修改已有的数据对象,就要先获取它,然后修改它,然后再保存回数据库

1
2
3
4
// 这里以另一种查询方式为例,假设我们已知想要查询数据对象的Id(这个Id在单个表中绝对唯一,所以获取的是单个对象)
AccountInfo accountInfo = await DBComponent.Instance.Query<AccountInfo>(1000000000);
accountInfo.Account = "NewName";
await DBComponent.Instance.Save(accountInfo);

如果想要删除一个已有对象

1
2
// 其实这里也可以写条件表达式,这里为了简单就直接用Id了
await DBComponent.Instance.Remove<AccountInfo>(1000000000);

强悍的自动序列化功能

还记得我们最开始举的那个无名氏和李逍遥例子吗?如果我们将HumanEntity保存到数据库,那么他们身上的Component都会自动序列化,然后添加到数据库中!