ET6.0框架介绍

项目的初步运行

1.运行Unity客户端项目,并且打开编程软件进行初步编译;
2.打开服务端项目,第一次打开编译Client下的Mono文件夹;
3.重新编译整个解决方案;
4.客户端F5生成代码;

ET前后端通讯机制

登录实例:
1.通过NetKcpComponent组件创建Session会话连接;
2.Session(由ET框架管理)会话的Id和TChannel进行绑定;
3.而TChannel实际创建Socket连接,所以有了SessionId就有了Socket;
4.Session向Socket写入客户端登录数据,传输到网关负载均衡服务器;

ET的多线程和分布式

第一代服务器架构:使用单台物理机,单个服务器进程,单线程无阻塞Socket服务器所有玩家(无数据库软件);
第二代服务器架构:采用分区分服模式,引用数据库软件存储数据,多个服务器进程同时运行,每个服务器单独一个游戏世界,互不牵扯,可以使用多台计算机部署架构,但是游戏服务器进程直接和玩家连接,同时也要和数据库连接;
第三代服务器架构:发展除让玩家统一连接的网关服务器,游戏服务器进程只和网关服务器进行通讯,而数据库的任务则交给数据库代理服务器进程进行处理(读取),读取的数据会放在缓存中,提升性能;
第三代服务器架构(无缝地图-魔兽世界):MMORPG游戏的标准配置,引用Login服务器、游戏服务器分化的概念;
ET框架服务器架构-Ecs组件式架构:一个游戏可以由多台机器一起运行,一台机器可以运行多个服务器进程Process,一个进程下可以有多个Scene(Realm、Gate、Map游戏服务器、Location定位服务器),可以通过需求变成一二三甚至更加复杂的的服务器架构

登录实例(接上):
1.网关负载均衡服务器随机分配网关配置;
2.通过配置向这个随机分配网关服务器请求一个Key,返回给客户端,客户端通过这个连接网关;
3.await等待服务端消息返回,该返回消息获取到的只是网关IP地址,断开旧连接;
4.游戏客户端和游戏网关建立正式连接;
5.登录进Map服务器
注意:C就是客户端,R是网关负载均衡服务器,G是网关;
ET分布式说明

ET的ECS组件编程

ECS:Entity实体——Component组件——System系统(在ET6.0中实体基本即组件);

遵守的编程原则:
1.实体即组件、组件即实体;
2.编写一个实体或者组件,绝不继承除Entity外的任何父类;
3.不使用任何虚函数,使用逻辑分发替代;
4.Model和ModelView只存放实体和组件的数据字段声明,绝不存放任何逻辑函数;
5.Hotfix和HotfixView值保留纯逻辑函数,也就是静态类和扩展方法编写的System,不能存在任何数据字段;
6.Model和Hotfix中不能出现跟Unity引擎相关的对象类和调用相关API函数;
7.实体组件中声明数据字段必须编写生命周期函数,防止实体对象池回收再利用的逻辑错误;

其他原则:
1.系统命名必须是实体名+System;
2.系统必须是静态类;
3.AddChild和AddComponent的实体需要添加IAwake接口,而且如果实现了生命周期函数,实体或组件也需要继承相应的接口;
4.显示层可以调用逻辑层,逻辑层不能直接调用显示层,只能用事件;

注意事项:

1.id是身份证,在Actor消息模型中,及其换区也不会变,instanceId是居住证,每次换区都会变;

//1.实体或者组件
    public class Computer: Entity,IAwake,IUpdate,IDestroy
    public class MouseComponent : Entity, IAwake
    
//2.系统
    public static class ComputerSystem
    {
       //需要使用拓展方法
        public static void Start(this Computer self)
        {
            Log.Debug("计算机启动");
			//这里可以调用身上的组件
            self.GetComponent<PCCaseComponent>().StartPower();
            self.GetComponent<MonitorsComponent>().DisPlay();
        }
    }

//3.实体实例化和组件添加,这里的实体实例化其实也只是添加到ZoneSence身上
    Computer computer = args.ZoneScene.AddChild<Computer>();
	computer.AddComponent<PCCaseComponent>();
	computer.AddComponent<MonitorsComponent>();
	computer.AddComponent<KeyBoardComponent>();
	computer.AddComponent<MouseComponent>();
	computer.Start();

ET中ECS编程的生命周期

ET的生命周期和Unity中的类似,在系统中创建对应的类,然后继承自对应的生命周期系统,实现对应的方法就ok了,这里注意的是实现什么生命周期函数,实体或组件也需要继承对应的接口,而且经过测试,貌似Awake在AddChild之前调用。

//1.生命周期函数类
    public class ComputerAwakeSystem : AwakeSystem<Computer>

    public class ComputerUpdateSystem : UpdateSystem<Computer>

    public class ComputerDestroySystem : DestroySystem<Computer>

ET的逻辑分发

OOP面向对象的缺点:继承层次过深时,牵一发而动全身;

ET的逻辑分发:ET实现实体多样性只需要用一个枚举去区分就可以了,其中实体组成多样性就是组件的不同,不再需要多重的层次继承;

ET的Scene层级树

Scene的概念:Scence相对于树的根节点,它本质也是一个实体,实体可以挂载在下面,其他实体也可以挂载在其他实体下,进行对层次的嵌套挂载;但是不管嵌套多少层的实体,它的Domain字段指代的就是实体的根节点Scene;而Zone字段代表的是Scene的逻辑索引Id,在服务器一般当作区服的索引Id;

客户端Scene的层级关系(客户端可以通过ZoneScene字段获取固定Sence根节点):
ET框架客户端Scene的层级关系

服务端Scene的层级关系:
ET框架服务端Scene的层级关系

服务端机器人Scene的层级关系
ET框架服务端机器人Scene的层级关系

ET的Excel配置工具

Excel配置工具注意事项:
1.配置文件从第三行第三列开始写,跟VBA宏有关;
2.第一个类型字段名必须是Id;
3.第一行是给策划看的,第二行是字段的字段名,第三行是字段类型;
4.加#是注释,仅给策划使用,不会添加到游戏数据中;
5.配置数据经过了Probuf序列化,服务端导出后配置文件在Config文件夹下,客户端在Unity\Assets\Bundles\Config下;可以查看文本形式在Bin\Json目录下,c是客户端,s是服务端;生成的数据类在Model下的Generate文件夹下;
6.如果客户端专用字段在行或者列加c,而服务端加s即可,如果是整个配置文件都分端考虑,则在第一行第一列添加c或s标识;
7.行是可以空行的,可以用来分类注释,目测加#;
8.一个配置文件下可以有多个配置表,最后进行合并导出,如果有一个表你不想合并出在表名前加#;
9.配置工具支持的导表类型可以在Tools\Apps\EXcelExporter文件下的Convert静态类中定义或查看;
10.数组类型默认在配置文件中加英文,配置;

//1.真正获取时用到的是表名+Category这个分部类的单例实例,一般和配置类一起自动生成,是分部类可以例如写一些获取配置信息的方法,配置类也是分部类,可以扩展一些复杂类型
//获取单个
UnitConfig config = UnitConfigCategory.Instance.Get(1001)
//获取所有
var configs = UnitConfigCategory.Instance.GetAll()

ET中的事件系统

1.注意事项:

  • 事件的事件定义是一个结构体,参数传递在其中声明,在EventType命名空间下进行定义,写在Model层下;
  • 事件逻辑类需要实现AEvent这个类,基类放行填的就是上面的结构体;
  • 如果视图层也要使用事件在Modelview下定义即可,但是订阅类因为继承了AEvent类,客户端部分就不能使用Monobehavior类型接入这样的订阅方式了,需要特别注意;
  • 事件结构体的定义必须在Model或ModelView下,事件的订阅和发布必须在Hotfix或HotfixView下 (是否为View层根据是否需要UnityAPI决定)
  • 在ET框架中View层可以直接调用逻辑层的代码,而逻辑层不允许直接调用View层的代码,所以逻辑层想要和View层交互只能使用抛出事件的方式,让View层进行订阅进行相应的处理。
//1.定义事件,添加事件,必须EventType命名空间下进行定义
        public struct InstallComputer
        {
            //定义参数
            public Computer computer;
        }
        
//2.调用事件,触发事件
		//同步Publish
		Game.EventSystem.Publish(new EventType.InstallComputer() { computer = computer });
		//同步PublishAnycs
		await Game.EventSystem.PublishAnycs(new EventType.InstallComputer() { computer = computer });
		//异步PublishAnycs
		Game.EventSystem.PublishAnycs(new EventType.InstallComputer() { computer = computer }).Coroutine();
		
//3.编写事件触发逻辑
    public class InstallComputer_AddComponent : AEvent<InstallComputer>
    {
        //必须添加async
        protected async override ETTask Run(InstallComputer arg)
        {
            Computer computer = arg.computer;
            computer.AddComponent<PCCaseComponent>();
            computer.AddComponent<MonitorsComponent>();
            computer.AddComponent<KeyBoardComponent>();
            computer.AddComponent<MouseComponent>();
            await ETTask.CompletedTask;
        }
    }

ET中的ETTask异步编程

同步操作:先完成其全部工作在返回调用者;
异步操作:先返回给调用者再完成全部工作,异步编程一般是以异步操作编写出运行时间可能持续很长一段时间的函数,常用于IO密集型和计算密集型逻辑;
ETTask:ETTask是C#种的Task的精简版,只支持单线程的功能,基本可以做到无GC,在ET中编写异步函数,必须返回ETTask类型,如果有返回值则是ETTask的泛形中的放行,可以在ThirdParty拜读ETTask的源码;

1.ETTask函数编写
		//无返回值,参数类型用来控制取消异步函数
		public async ETTask TestAsync(ETCancellationToken cancellationToken)
        {
			//表明该函数可能是同步函数
			await ETTask.CompletedTask;
			//第二参数同上
			bool rt = await TimerComponent.Instance.WaitAsync(1000, cancellationToken);
			//根据返回值控制
			if (rt) Log.Debug("函数取消了");
			else Log.Debug("继续执行下面逻辑");
		}

		//有返回值
        public async ETTask<int> TestResultAsync()
        {
            await TimerComponent.Instance.WaitAsync(1000);
			return 10;
        }
        
2.ETTask函数的调用
		//定义取消异步函数实例
		ETCancellationToken cancellationToken=new ETCancellationToken();
		//等待无返回值函数调用完成
		await TestAsync(cancellationToken);
		//不等待无返回值函数完成,执行下面逻辑
		TestAsync(cancellationToken).Coroutine();
		//等待函数返回值,一般有返回值的逻辑上必须等待,否则代码可能有问题
		int value = await TestResultAsync();
		//取消函数
		cancellationToken.Cancel();

ET中的Protobuf通讯消息

Protobuf:Protobuf就是一个用于生成通讯消息类的代码生成器,是Google公司提出的一种开源的轻便高效的结构化数据存储格式,常用于结构化数据的序列化,具有语言无关、平台无关、可扩展性特性,常用于通讯协议、服务端数据交换等应用场景;Protobuf 拥有类型安全,易用性好,自动化程度高,兼容性强等优势。相对于其他常见的列入XML、JSON,描述同样的数据信息,ProtoBuf序列化后数据量更小、序列化和反序列化速度更快、操作更为简单。
Proto数据结构描叙文件——Protobuf Compiler——各种编程语言的消息定义文件;
Protobuf的Github开源地址 :GitHub - protocolbuffers/protobuf: Protocol Buffers - Google 的数据交换格式

ET中的Protobuf:版本是proto3,且不是谷歌C++版本,采用是C#版的protobuf-net,代码生成程序在Tools\Apps\Proto2CS下的Proto2CS类,是ET框架提供的,不是谷歌原版;

.proto描叙文件编写的的注意事项:
1.Proto数据结构描叙文件存放在Proto文件夹下;
2.OuterMessage是定义客户端和服务端通信的消息;
3.InnerMessage是服务端内部通信的消息;
4.MongoMessage也是是服务端内部通信的消息,但是内部可以定义实体类型;
5.C就是客户端,R是网关负载均衡服务器,G是网关,M是Map服务器;
6.消息定义体中的Id不能重复;
7.生成的转换类型列表可以在Tools\Apps\Proto2CS的Proto2CS类中查看,消息体生成一般在Model\Generate\Message下;
8.加上repeated转换后是List类型,不支持字典,可以用俩个列表表示键值对;
9.字符之间必须要留空格;
10.proto文件中的注释意义重大;

网络Handler消息编写注意事项
1.类名定义务必以消息类名+Handler作为声明
2.ActorLoaction消息使用ActorMessageHandler标签,Actor和普通消息使用MessageHandler标签,新版本可以不用了;
3.普通消息一般用于客户端和服务端网关或者网关负载进行通信使用
4.Actor和ActorLoaction消息一般用于Unit之间的通信,如服务端内部的Unit通信和客户端和Map服务器进程通信,不同的是ActorLoaction消息需要Location定位的参与;

ET普通网络消息编写

普通消息.proto文件编写:

// ResponseType R2C_LoginTest(普通请求消息一定要注释返回类)
message C2R_LoginTest  // IRequest(普通请求消息必须注释)
{
    int32 RpcId = 90;(普通请求消息必须声明该字段)
	string Account = 1;
	string Password = 2;
}

message R2C_LoginTest  // IResponse(普通响应消息必须注释)
{
    int32 RpcId = 90;(普通响应消息必须声明该字段)
	int32 Error = 91;
	string Message = 92;
	string GateAddress = 1;
	string Key = 2;
}

message C2R_SayHello // IMessage(一般消息必须注释)
{
	string Hello = 1;
}

普通消息C#代码:

//1.客户端编写登录逻辑
        public static async ETTask LoginTest(Scene zoneScene, string address)
        {
            try
            {
                Session session = null;
                R2C_LoginTest r2C_LoginTest = null;
                try
                {
                    session = zoneScene.GetComponent<NetKcpComponent>().Create(NetworkHelper.ToIPEndPoint(address));
                    {
                        r2C_LoginTest =(R2C_LoginTest)await session.Call(new C2R_LoginTest() { Account = "123", Password = "456" });
                        Log.Debug(r2C_LoginTest.Key);
                        session.Send(new C2R_SayHello() { Hello = "你好" });
                    }
                }
                finally
                {
                    session?.Dispose();
                }
            }
            catch(Exception e)
            {
                Log.Error(e.ToString());
            }
        }

//2.普通请求消息逻辑
    [MessageHandler]
    public class C2R_LoginTestHandler : AMRpcHandler<C2R_LoginTest, R2C_LoginTest>
    {
        protected override async ETTask Run(Session session, C2R_LoginTest request, R2C_LoginTest response, Action reply)
        {
            response.Key = "110";
            reply();
            await ETTask.CompletedTask;
        }
    }
    
//3.一般消息(不用响应)
    [MessageHandler]
    public class C2R_SayHelloHandler : AMHandler<C2R_SayHello>
    {
        protected override async ETTask Run(Session session, C2R_SayHello message)
        {
            Log.Debug(message.Hello);
            await ETTask.CompletedTask;
        }
    }

ET中的Actor通信模型

Actor模型:Actor模型

Actor消息:Actor模型中一个很重要的概念就是 Actor地址,因为当一个Actor需要与另外Actor进行通信,必须通过这个地址。ET框架考虑到分布式的网络环境,通过Entity的InstanceId 对Actor地址进行了抽象,屏蔽了不同进程之间的差异。

ActorLocation消息:虽然在Actor模型中只需要知道对方的InstanceId就能发送消息,但是Actor可能在不同的进程之间进行转移,所以一个Actor的Actor地址(InstanceId)会发生
动态变化。为此ET框架提供了一种ActorLocation机制,这个机制通过增加Location定位服务器进程, Entity通过注册自身的ID和InstanceId, 使得通信消息被发送到目标Entity所在的实际进程处进行消息的处理;

ActorLocation消息Proto文件编写:

//ResponseType M2C_TestActorLocationResponse
message C2M_TestActorLocationReqeust // IActorLocationRequest
{
	int32 RpcId = 90;
	string Content = 1;
}

message M2C_TestActorLocationResponse // IActorLocationResponse
{
	int32 RpcId = 90;
	int32 Error = 91;
	string Message = 92;
	string Content = 1;
}

message C2M_TestActorLocationMessage // IActorLocationMessage
{
	int32 RpcId = 90;
	string Content = 1;
}

ActorLocation消息C#代码编写:

1.客户端场景切换逻辑
            try
            {
                Session session = zoneScene.GetComponent<Session>();
                var message = (M2C_TestActorLocationResponse)await session.Call(new C2M_TestActorLocationReqeust() { Content = "111" });
                Log.Debug(message.Content);
                session.Send(new C2M_TestActorLocationMessage() { Content = "22222" });
            }
            catch (Exception e)
            {
                Log.Error(e);
            }
            
2.ActorLocation请求消息逻辑
    [ActorMessageHandler]
    public class C2M_TestActorLocationReqeustHandler : AMActorLocationRpcHandler<Unit, C2M_TestActorLocationReqeust, M2C_TestActorLocationResponse>
    {
        protected override async ETTask Run(Unit unit, C2M_TestActorLocationReqeust request, M2C_TestActorLocationResponse response, Action reply)
        {
            Log.Debug(request.Content);
            response.Content = "333333";
            reply();
            await ETTask.CompletedTask;
        }
    }
3.ActorLocation一般消息逻辑
    [ActorMessageHandler]
    public class C2M_TestActorLocationMessageHandler : AMActorLocationHandler<Unit, C2M_TestActorLocationMessage>
    {
        protected override async ETTask Run(Unit entity, C2M_TestActorLocationMessage message)
        {
            Log.Debug(message.Content);
            //MessageHelper.SendToClient(entity, 发送给客户端的消息);
            await ETTask.CompletedTask;
        }
    }

ET教程登录实例

实例步骤:
1.编写账号登录请求和响应俩条协议Proto;
2.服务端创建账户实体以及对应的账户枚举类型,同时SceneType枚举添加登录服务器和ErrorType枚举添加对应错误码,并且在场景工厂类添加登录服务器枚举的组件添加逻辑;
3.客户端在视图层对应的逻辑层接口重新编写登录请求的逻辑,并且编写账号信息组件和账号系统以及其生命周期,并且在场景工厂zoneScene添加该组件(创建Session——发送请求——根据返回错误码进行判断——成功则添加Session组件并且添加心跳组件——获取账户信息组件并赋值——返回错误码给视图层做处理);
4.服务端编写登录请求处理逻辑,同时编写Token组件和对应的Token系统,在场景工厂的登录服务器组件添加该组件(判断Scene是否为登录Type——移除Session监听组件——判断账户密码为空——正则判断账户密码长度——数据库读取账户集合——没有账号则添加新账号并且保存——有则判断是否黑名单、密码是否正确——顶号操作并且发送消息——添加会话定时断开组件——根据服务器时间和随机数创建Token令牌——根据session的根节点获取Token组件,并且添加该令牌到Token字典中——令牌和账户Id添加回复内容并回复);

事后心得:
1.数据库组件是单例,数据库组件挂载在账号服务器上就ok了,如果,游戏服务端是分布式的,多进程,可以尝试挂载在Map服务器上,或者直接挂载在GameSence上,全局都可以访问;
2.出现问题回复消息后不能马上断开Session,不然一般消息没发出去连接就断开了,可以为Session扩展一个断开连接的方法,在一秒后再断开连接,同时要记得记录一下Session的Id,如果一秒后Id变化,则说明该秒内的逻辑已经重新申请了Session,则不需要再断开了;
3.游戏中玩家可能会出现点击过快而导致发送多条数据的情况,除了客户端作一些处理外(参考下面代码案例一),服务端也需要作一些处理,遵循Ecs的编程方式,可以为Session添加一个SessionLock的无逻辑组件,当session有这个组件的时候直接返回,之后使用using包裹住异步处理逻辑和关键逻辑,逻辑处理完释放该组件;
4.游戏中很低的概率会出现俩位玩家请求同样的账户密码创建不同的Sessin进行处理,且该账户都是新账号,都进行到了账户创建的逻辑处理,这时候数据库就可能会有俩个一模一样的账号,打破的账号唯一性,所以这里要使用协程锁锁住异步逻辑,也是使用using关键字,同时使用ET自带的CoroutineLockComponent组件,添加一个协程锁类型,锁住账户的哈希码(using (await CoroutineLockComponent.Instance.Wait(CoroutineLockType.Account,request.AccountName.Trim().GetHashCode())));
5.游戏中可能会有玩家已经上线的情况,这个时候就需要顶号操作,可以定义一个账号Id和会话Id映射字典的组件,挂载在账号服务器上,相应的逻辑处理完后,对玩家其他会话上线进行判断,这里只存取了sessionId,可以通过Game.EventSystem.Get(sessionId) as Session获取Session,进行判断;
6.玩家手机没电或者直接杀进程可能session会话不会正常断开连接,这里则需要添加一个账号在线时间检测组件去检测,组件中应该设有一个定时器Id,并且要新定义一个定时器常量类型,并且为该常量类型写逻辑函数(定时器逻辑类标签[Timer(TimerType.AccountCheckOutTimer)]),TimerComponent.Instance.NewOnceTimer(TimeHelper.ServerNow() + 600000, TimerType.AccountCheckOutTimer, self);