HexMap无限地图已经悄无声息的完成了,之所以悄无声息的完成,是因为弃用了ECS框架,弃用的原因在于不够成熟,而我要开发的游戏需要成熟的解决方案。既然不成熟,那么当初为啥要入坑呢?岂不是浪费了太多时间,在Unite 哥本哈根2019的大会上,官方演示了ECS的多人在线游戏的案例,这让ECS或DOTS着实让人期待,这也是明知不成熟,还是要入坑的原因:一项新技术的吸引力对于一个好奇心强的程序猿来说,几乎是不可抗拒的,所以说优化是程序猿的致命毒药,而新技术从某种层面上讲,就是优化。
试问:一个武林高手在拿到绝世武功秘籍的时候,如何能够放手?谁不想炼就绝世神功?
我们程序猿时时刻刻都在面临优化的诱惑,这种歇斯底里的渴望促使我们不断学习,不断进步。
我们在灵魂深处知道:我们离Matrix还有非常非常遥远的距离,程序猿想要成为新世界的神!
所以这是一场造神革命,我们可以看到未来,因此我们才知道差距有多大,因此才肝着努力。
这样写也许太夸张了,不过内心深处就是有这样夸张的渴望,实际上已经饥渴难耐到饮鸩止渴了。
不得已,个人技术水平太Low,不得不沿用原作者的OOP架构,貌似回到了原点。于是耐着性子把原作者的教程拜读了一遍,写得太好了,所以根本没有补充的地方。
站在巨人的肩膀上,无限地图顺利完成,我做的无非是导入了一些Polygon风格的资源,使地图看起来更漂亮一些。这些都不值得大书特书,有兴趣的朋友看看原教程都可以轻易完成。
无限地图完成后,终于又要开新坑了,我的独立游戏是末世生存游戏,我希望末世有着无限的挑战,因而做了无限地图。接下来我需要这款游戏可以像饥荒那样联网,可以使用多人模式,也可以单机模式。
因为我需要一个全面的解决方案,之前研究了太多框架GameFramework、Skynet、YouyouFramework、xluaFramework等等,研究的这些东西都是非常优秀的,但是好的不一定是适合的。每个框架在设计的时候,或多或少都有取舍,毕竟这是一个百家争鸣的时代,非常多杰出的作者开源了自己的杰作,他们都有自己的优势。
我最终选择了ET,它的优势作者熊猫国宝已经表述得非常明了,这些优势使我最终下定决心要使用ET,如果DOTS革新成熟了,也许会融入进来,在那之前我会安安心心地夯实ET开发之路。
在开始学习笔记之前,我已经学习了两天ET了,以下是我的学习路径:
两天时间做了以上研究,即便是这样,我觉得离我的独立游戏还有距离。我需要研究一个更加完整的案例,于是我克隆了五星麻将,当然也可以选择斗地主案例,选择五星麻将的原因是我以前开发过一款麻将游戏,所以对麻将比较熟悉。那个时候一下班就被老板叫去机麻,为了熟悉麻将逻辑。后来中途又被调到老虎机项目,委以重任。扯远了,总之选择了五星麻将来作为自己的入门案例。
其实上面例举的学习路径也算是准备工作了,毕竟五星麻将还是有一定难度的。
如果上面的案例大家都掌握了,那么就开始着手准备五星麻将案例吧:
0下载Unity编辑器(2018.4.5f1 or 更新的版本),if(已经下载了)continue;
1克隆:git clone https://github.com/wufanjoin/fivestar.git --recurse
或下载Zip压缩包
2如果下载的是压缩包,解压。将$GitProject\fivestar文件夹下的Unity添加到Unity Hub项目中;
3用Unity Hub打开项目:Unity,等待Unity进行编译工作;
4打开项目后,启动场景在Scenes目录下,打开Init场景。
按照作者要求进行本地配置时报错了,操作参考运行指南,错误如下图所示:
点击去发现了MongoHelper类冲突了,如下图所示:
解决方法很简单,既然冲突了,改个名字就好了。利用VS的重命名快捷键对MongoHelper重命名为MongoHelpero。于是可以顺利加载配置文件了,如下图所示:
配置好了,顺利启动游戏,如下图所示:
如果阁下走过前言列出的学习路径的话,一些基础知识我就不详细讲解了,毕竟在下也是一知半解的状态。不过,接下来的热更新就是难点了,所以还是稍微解释一下下吧。
ET的热更新方案正如作者所说:
因为ios的限制,之前unity热更新一般使用lua,导致unity3d开发人员要写两种代码,麻烦的要死。之后幸好出了ILRuntime库,利用ILRuntime库,unity3d可以利用C#语言加载热更新dll进行热更新。ILRuntime一个缺陷就是开发时候不支持VS debug,这有点不爽。ET框架使用了一个预编译指令ILRuntime,可以无缝切换。平常开发的时候不使用ILRuntime,而是使用Assembly.Load加载热更新动态库,这样可以方便用VS单步调试。在发布的时候,定义预编译指令ILRuntime就可以无缝切换成使用ILRuntime加载热更新动态库。
是利用ILRuntime库来进行热更的,原理作者解释了,就是把需要热更的代码打成dll(Dynamic Link Library的英文缩写,意思是动态链接库),dll的方便之处在于动态加载,从而使代码得到更新,而不必重新安装整个程序,这就是所谓的热更新了。那么具体如何操作呢?
其实之前的教程已经解释过了,我就再演示一下吧,如下图所示:
开发的时候把需要热更新的代码放到Hotfix解决方案下面,就可以热更新了,当前添加宏之类的操作就不必我啰嗦了。实际上ET支持整个项目都热更新,为了省事,也为了备不时之需,我觉得完全可以把所有开发的代码都放在Hotfix下面,全部热更就好了。当然,如果确定是万年不变的代码,其实也没必要放进来,这样可以减少热更新的开销,热更新必然是有代价的,具体这里就不解释了(其实我也不甚明白Orz)。
五星麻将作者提到过字段isNetworkBundle,使用该字段就可以控制是否使用网络资源了,如果你想使用就勾选true,这样的话需要部署一个文件服务器,这个肉饼老师的视频中演示了,我就不多说了。
我们这里不勾选,按照本地流程走,按照ET的流程,初始化需要添加一大堆需要用的组件,以备不时之需。这些属于基本操作了,因此也不做详细解释了,下面是热更新的入口:
//加载热更项目
Game.Hotfix.LoadHotfixAssembly();
通过这一个方法,我们就加载了热更新的代码了,流程如下:
///
/// 加载热更新程序集
///
public void LoadHotfixAssembly()
{
//0.加载打包的代码资源包,内含热更新代码程序集dll动态链接库,对应的路径:Assets\Res\Code
Game.Scene.GetComponent<ResourcesComponent>().LoadBundle($"code.unity3d");
//1.从加载的AssetBundle资源中获取代码资源并转化成游戏对象
GameObject code = (GameObject)Game.Scene.GetComponent<ResourcesComponent>().GetAsset("code.unity3d", "Code");
//2.从游戏对象上获取对应的动态链接库和程序数据库资源转化成字节
byte[] assBytes = code.Get<TextAsset>("Hotfix.dll").bytes;
byte[] pdbBytes = code.Get<TextAsset>("Hotfix.pdb").bytes;
#if ILRuntime
//因为设置了ILRuntime的宏,所以会进入到这里,这意味着热更新模式运行游戏
Log.Debug($"当前使用的是ILRuntime模式");
//3.获取热更库的环境域,这个属于ILRuntime的知识了
this.appDomain = new ILRuntime.Runtime.Enviorment.AppDomain();
//4.把动态链接库库和PDB(Program Database File,程序数据库文件)加入内存
this.dllStream = new MemoryStream(assBytes);
this.pdbStream = new MemoryStream(pdbBytes);
//5.通过内存加载上面的资源
this.appDomain.LoadAssembly(this.dllStream, this.pdbStream, new Mono.Cecil.Pdb.PdbReaderProvider());
//6.热更代码的启动方法,直接定位到ETHotfix.Init类下的启动方法
this.start = new ILStaticMethod(this.appDomain, "ETHotfix.Init", "Start", 0);
//7.热更类型通过反射
this.hotfixTypes = this.appDomain.LoadedTypes.Values.Select(x => x.ReflectionType).ToList();
#else
Log.Debug($"当前使用的是Mono模式");
this.assembly = Assembly.Load(assBytes, pdbBytes);
Type hotfixInit = this.assembly.GetType("ETHotfix.Init");
this.start = new MonoStaticMethod(hotfixInit, "Start");
this.hotfixTypes = this.assembly.GetTypes().ToList();
#endif
//8.秉承过河拆桥的原则,呸,优化内存的原则,卸载AssetBundle资源
Game.Scene.GetComponent<ResourcesComponent>().UnloadBundle($"code.unity3d");
}
这个流程是定死的,也不用去改动,照着用即可。
上面为热更新的使用铺平了道路,只需一声令下即可开始运行热更新里面的代码,命令如下:
//执行热更项目
Game.Hotfix.GotoHotfix();
这行代码最终执行的是ETHotfix.Init.Start方法,只是过程比较委婉曲折而已,下面正式启动:
// 注册热更层回调
ETModel.Game.Hotfix.Update = () => { Update(); };
ETModel.Game.Hotfix.LateUpdate = () => { LateUpdate(); };
ETModel.Game.Hotfix.OnApplicationQuit = () => { OnApplicationQuit(); };
//添加UI组件
Game.Scene.AddComponent<UIComponent>();
Game.Scene.AddComponent<OpcodeTypeComponent>();
Game.Scene.AddComponent<MessageDispatcherComponent>();
// 加载热更配置
ETModel.Game.Scene.GetComponent<ResourcesComponent>().LoadBundle("config.unity3d");
Game.Scene.AddComponent<ConfigComponent>();
ETModel.Game.Scene.GetComponent<ResourcesComponent>().UnloadBundle("config.unity3d");
//房间配置
AnnouncementConfig cardFiveStarRoom = (AnnouncementConfig)Game.Scene.GetComponent<ConfigComponent>().Get(typeof(AnnouncementConfig), 1);
Log.Debug($"config {JsonHelper.ToJson(cardFiveStarRoom)}");
//直接添加Session组件
Game.Scene.AddComponent<SessionComponent>();
//GameGather新加的组件
Game.Scene.AddComponent<VersionsShowComponent>();//版本号显示组件
Game.Scene.AddComponent<KCPUseManage>();//KCP使用组件
Game.Scene.AddComponent<UserComponent>();//用户信息管理组件
Game.Scene.AddComponent<ToyGameComponent>();//游戏场景 管理组件
Game.Scene.AddComponent<MusicSoundComponent>();//音乐 音效组件
Game.Scene.AddComponent<FrienCircleComponet>();//亲友圈组件
Game.Scene.GetComponent<ToyGameComponent>().StartGame(ToyGameId.Login);
GameObject.Find("Reporter").SetActive(ETModel.Init.IsAdministrator);//打印日志
这里打印的配置文件如下图所示:
由此我们就知道了配置文件的使用方式了,类名和配置都是AnnouncementConfig,所以就能在加载的资源中进行刷选。底层的实现大家可以自行阅读源码,这里我只需要知道如何使用即可。
当然,我选择与底层分离的原因主要是水平有限,不想消耗精力去研究底层,而我要开发的游戏仅仅关心如何实现高级的功能,而没有太多富余的时间去研究底层(富余的时间都去打牌了Orz)。
所以,原本要研究代码的时间用来打了三圈牌,输了120大洋(输的是从老爹那里借来的钱,借200,最终还80,想想自己真是坑爹!),于是干脆跳过一些底层实现。
Anyway,热更新跑起来了,上面的代码StartGame(ToyGameId.Login);
直接跳转到登录界面。
基于ET的事件机制,其实在ComponentFactory.Create
的时候就触发了下面的事件,当然组件工厂(ComponentFactory)的Create方法是由AddComponent方法间接触发的。
[ObjectSystem]
public class ToyGameComponentAwakeSystem : ETHotfix.AwakeSystem<ToyGameComponent>
{
public override void Awake(ToyGameComponent self)
{
self.Awake();
}
}
所以AddComponent是触发上面事件的始作俑者,当然底层做了非常多的工作,详见肉饼老师的解说视频。我们这里只需知道会触发该事件,还是那句老话,水平有限,停留于使用层面。
该事件的使用方式其实作者写得非常清楚了,整个ET的运行都是基于这样的事件机制的,因而事件机制是必修课,基于事件机制我们可以做很多工作,就像这里的Awake方法:
///
/// 由事件机制触发的唤醒方法,在每次添加组件的时候执行一次
///
public void Awake()
{
mGameAisleBaseDic.Clear();
List<Type> types = Game.EventSystem.GetTypes();
foreach (Type type in types)
{
object[] attrs = type.GetCustomAttributes(typeof(ToyGameAttribute), false);
if (attrs.Length == 0)
{
continue;
}
ToyGameAttribute toyGameAttribute= attrs[0] as ToyGameAttribute;
ToyGameAisleBase toyGameAisleBase = Activator.CreateInstance(type) as ToyGameAisleBase;
toyGameAisleBase.Awake(toyGameAttribute.Type);
mGameAisleBaseDic.Add(toyGameAttribute.Type, toyGameAisleBase);
}
}
这里的Awake干了啥?其实我也是一知半解,只能靠着程序猿的本能猜测一下,大概是维护一个字典,这个字典的用途大概是根据游戏类型(ToyGameAttribute)来获取不同的通道(ToyGameAisleBase)。
ToyGameAisleBase负责对应游戏类型的进出,进进出出的地方就是通道了Orz,它有很多子类,后面会讲。
有了Awake所做的工作,我们可以过渡到Start方法了,也就是上面调用的StartGame(ToyGameId.Login);
public void StartGame(long gameType,params object[] objs)
{
if (mGameAisleBaseDic.ContainsKey(gameType))
{
if (CurrToyGame != ToyGameId.None)
{
mGameAisleBaseDic[CurrToyGame].EndAndStartOtherGame();
}
mGameAisleBaseDic[gameType].StartGame(objs);
}
else
{
Log.Error("想要进入的游戏不存在:"+ gameType);
}
}
因为有Awake所做的工作,所以mGameAisleBaseDic字典里面才有对应的Key,否则就会报错。
这些Key实际上就是ToyGameId,这些是属于自定义的字段,应该是通过发射机制注册的,如果没有明白我在说什么,这里是入门指南传送门,走你。当然这纯属我的猜测,总之不必在意这些细节,我们只需知道字典里有什么,我们该如何使用,我的格言大概就是:站在巨人的肩膀上致敬巨人!
namespace ETHotfix
{
public class ToyGameId
{
public const long None = 0;
public const long Login = 1;
public const long Lobby = 2;
public const long Common = 1000;
public const long JoyLandlords = 1001;
public const long CardFiveStar =1002;
public const long CardFiveStarVideo = 2002;
}
}
字典里面其实就是上面这些Key了,我们可以在这里自定义自己想做的游戏类型,上面已经定义了登录、大厅等字段,我们也完全可以加上public const long CSDN = 3;
这样的字段。
Anyway,我们现在已经推测出字典里面有什么了,如果大家怀疑我的推测,完全可以循环遍历打印出来了。我丝毫不怀疑自己的推理,所以就不打印了。
这里的public long CurrToyGame = ToyGameId.None;
字段意思是当前的游戏模式,所以最终又调用了mGameAisleBaseDic[gameType].StartGame(objs);
正式开始登录。
负责登录的是登录通道(LoginAisle),所有的游戏通道都是继承ToyGameAisleBase,这样才能通过上面的字典统一调用。如果阁下不知道这是什么设计模式的话,即使在评论区留言我也不会告诉阁下的。
public override void StartGame(params object[] objs)
{
base.StartGame();
Log.Debug("进入登陆界面");
Game.Scene.GetComponent<KCPUseManage>().InitiativeDisconnect();
Game.Scene.GetComponent<UIComponent>().Show(UIType.LoginPanel);
}
于是,通过登录通道,我们终于进入了登录流程。
在展示UI界面前,进行了KCP断开连接的操作,之所以这么做其实是为了干掉Session,我想这是为了应对多账号用户的注销操作。这也纯属个人推理,如果阁下不明白,我也不解释。
KCP是啥?阁下可以把它当作是TCP的兄弟了,都是CP嘛。那么TCP是啥?这里是入门指南传送门,走你!
送走了一波王莽(网盲,源于文盲、法盲、色盲,王莽就是不懂网络的莽夫!)
在开始展示登录界面之前,有必要先看看UIComponent都做了些什么工作:
首先触发的是事件系统:
[ObjectSystem]
public class UiComponentAwakeSystem : AwakeSystem<UIComponent>
{
public override void Awake(UIComponent self)
{
self.Awake();
}
}
[ObjectSystem]
public class UiComponentLoadSystem : LoadSystem<UIComponent>
{
public override void Load(UIComponent self)
{
self.Load();
}
}
通过ET的事件机制进而触发Awake:
public void Awake()
{
this.Root = GameObject.Find("Global/UI/");
this.Load();
Ins = this;
}
在Awake中设置了UI的根节点,然后进行加载以及单例模式(Unity必修课)。
public void Load()
{
uiMvcVessel.Clear();
List<Type> types = Game.EventSystem.GetTypes();
foreach (Type type in types)
{
object[] attrs = type.GetCustomAttributes(typeof(UIFactoryAttribute), true);
if (attrs.Length == 0)
{
attrs = type.GetCustomAttributes(typeof(UIComponentAttribute), true);
if (attrs.Length == 0)
{
continue;
}
}
Type attrType = attrs[0].GetType();
if (typeof(UIFactoryAttribute) == attrType)
{
UIFactoryAttribute factoryAttribute = attrs[0] as UIFactoryAttribute;
uiMvcVessel.AddUIMvcVessel(UIMvcVesselType.Factory, factoryAttribute.Type, type);
}
else if (typeof(UIComponentAttribute) == attrType)
{
UIComponentAttribute componentAttribute = attrs[0] as UIComponentAttribute;
uiMvcVessel.AddUIMvcVessel(UIMvcVesselType.Componet, componentAttribute.Type, type);
}
}
}
这段代码大家眼熟吗?几乎同样的设计模式,啥?没看出来!
Ok,只能帮到这里了,因此我们同样可以通过UIType来自定义自己需要的UI字段。
原理是一样的,这里使用uiMvcVessel来统一管理UI,如果阁下不知道MVC,这里是入门指南传送门,走你!
恐怕新手都送走了,所以ET的门槛其实很高,所以才叫外星人嘛。
咱其实也吃不消,不然为啥要写学习笔记呢?一起学习,共同进步嘛。
Whatever,已经撑到这里了,我们还是继续吧。UIType中定义了所有用到的UI面板,这里就不复制代码了。
这里使用MVC模式来管理UI,把对应的UI类型加入UIMvcVessel中,便于后面的方法调用。
public void Show(string type)
{
UI ui;
if (uis.TryGetValue(type, out ui))
{
UIView uiView = ui.GetComponent<UIView>();
uiView.Show();
}
else
{
Create(type);
}
}
It’s Showtime!终于轮到展示UI界面了,那么LoginPanel一开始进入Show方法时,是没有创建的。如果已经创建了,就直接展示。我们还是从创建开始:
public UI Create(string type)
{
try
{
UI ui;
IUIFactory uiFactory = uiMvcVessel.GetUIMvcVessel(UIMvcVesselType.Factory, type) as IUIFactory;
if (uiFactory != null)
{
ui = uiFactory.Create(this.GetParent<Scene>(), type, Root);
}
else
{
UIView uiCommpoentView = uiMvcVessel.GetUIMvcVessel(UIMvcVesselType.Componet, type) as UIView;
ui = DefaultUIFactory.Create(this.GetParent<Scene>(), type, Root, uiCommpoentView);
}
UIView uiView = ui.GetComponent<UIView>();
uiView.pViewState = ViewState.CreateIn;//状态改为正在创建中
Type t = uiView.GetType();
ui.GameObject.transform.SetParent(this.Root.Get<GameObject>(uiView.pCavasName).transform, false);
uiView.OnCrete(ui.GameObject);
uis.Add(type, ui);
uiViews.Add(uiView);
return ui;
}
catch (Exception e)
{
throw new Exception($"{type} UI 错误: {e}");
}
}
这里使用的是工厂模式,而不是组件模式,两者有啥优劣呢?阁下已经知道在哪里寻找答案。
当然,我们也可以不知道工厂模式和组件模式的区别,这里走了一个创建流程,下面正式Showtime。
至此,这一篇流水账总算记完了,下一篇将完成登录流程,从客户端登录,进入游戏大厅!
如果喜欢可以点赞支持一下,谢谢鼓励!如果有什么疑问可以给我留言,有错漏的地方请批评指证!
技术难题?加入开发者联盟:566189328(QQ付费群)提供有限技术探讨,以及,心灵鸡汤Orz!
当然,不需要技术探讨也欢迎加入进来,在这里劈柴、遛狗、聊天、撸猫!( ̄┰ ̄*)