五星麻將0客戶端登錄界面
前言
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)。
熱更新程序集HotfixAssembly
五星麻將作者提到過字段isNetworkBundle,使用該字段就可以控制是否使用網絡資源了,如果你想使用就勾選true,這樣的話需要部署一個文件服務器,這個肉餅老師的視頻中演示了,我就不多說了。
我們這裏不勾選,按照本地流程走,按照ET的流程,初始化需要添加一大堆需要用的組件,以備不時之需。這些屬於基本操作了,因此也不做詳細解釋了,下面是熱更新的入口:
//加載熱更項目
Game.Hotfix.LoadHotfixAssembly();
通過這一個方法,我們就加載了熱更新的代碼了,流程如下:
/// <summary>
/// 加載熱更新程序集
/// </summary>
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");
}
這個流程是定死的,也不用去改動,照着用即可。
熱更新啓動GotoHotfix
上面爲熱更新的使用鋪平了道路,只需一聲令下即可開始運行熱更新裏面的代碼,命令如下:
//執行熱更項目
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,想想自己真是坑爹!),於是乾脆跳過一些底層實現。
登錄Login
Anyway,熱更新跑起來了,上面的代碼StartGame(ToyGameId.Login);
直接跳轉到登錄界面。
基於ET的事件機制,其實在ComponentFactory.Create<ToyGameComponent>();
的時候就觸發了下面的事件,當然組件工廠(ComponentFactory)的Create方法是由AddComponent方法間接觸發的。
[ObjectSystem]
public class ToyGameComponentAwakeSystem : ETHotfix.AwakeSystem<ToyGameComponent>
{
public override void Awake(ToyGameComponent self)
{
self.Awake();
}
}
所以AddComponent是觸發上面事件的始作俑者,當然底層做了非常多的工作,詳見肉餅老師的解說視頻。我們這裏只需知道會觸發該事件,還是那句老話,水平有限,停留於使用層面。
該事件的使用方式其實作者寫得非常清楚了,整個ET的運行都是基於這樣的事件機制的,因而事件機制是必修課,基於事件機制我們可以做很多工作,就像這裏的Awake方法:
/// <summary>
/// 由事件機制觸發的喚醒方法,在每次添加組件的時候執行一次
/// </summary>
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
負責登錄的是登錄通道(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);
}
於是,通過登錄通道,我們終於進入了登錄流程。
登錄界面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!
當然,不需要技術探討也歡迎加入進來,在這裏劈柴、遛狗、聊天、擼貓!( ̄┰ ̄*)