韓版《傳奇2》源碼分析與 Unity 重製(二):客戶端啟動與交互流程


3樓貓 發佈時間:2023-03-20 08:43:44 作者:Sou1gh0st Language

專題介紹

該專題將會分析 LOMCN 基於韓版《傳奇2》,使用 .NET 重寫的傳奇源碼(服務端 + 客戶端),分析數據交互、狀態管理和客戶端渲染等技術,此外筆者還會分享將客戶端部分移植到 Unity 和服務端用現代編程語言重寫的全過程。

系列文章

韓版傳奇 2 源碼分析與 Unity 重製(一)服務端 TCP 狀態管理

概覽

在這一篇文章中,我們將從客戶端入手,分析從 TCP 連接建立、登錄鑑權、角色選擇、開始遊戲到遊戲內交互的全過程。

客戶端啟動

WinForm 入口 Program.cs
與服務端類似,客戶端也是一個 WinForm 應用程序,在 Application 啟動後,會先跳轉到 AMain 檢查是否有熱更新,隨後再跳轉到 CMain 開啟客戶端主邏輯:
// Program.cs [STAThread] private static void Main(string[] args) { // ... Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); if (Settings.P_Patcher) Application.Run(PForm = new Launcher.AMain()); else Application.Run(Form = new CMain()); // ... }
監聽事件循環
在 CMain 的構造函數中,我們監聽了 Application Idle 事件作為事件循環:
// CMain.cs public CMain() { InitializeComponent(); ​ Application.Idle += Application_Idle; ​ // ... }
在 Application_Idle 中,我們通過 UpdateTime 更新客戶端全局的時間戳,通過 UpdateEnviroment 處理網絡數據,通過 RenderEnvironment 處理客戶端渲染:
private static void Application_Idle(object sender, EventArgs e) { try { while (AppStillIdle) { UpdateTime(); UpdateEnviroment(); RenderEnvironment(); } ​ } catch (Exception ex) { SaveError(ex.ToString()); } }
客戶端場景劃分
在用戶登錄之前,UpdateEnviroment 發現連接實例為空不會做任何操作,因此我們先跳過這個函數來看 RenderEnvironment 的處理過程,這裡實際上就是基於 Direct 3D 的客戶端的渲染循環,請大家注意 MirScene.ActiveScene.Draw 這個調用,傳奇通過 Scene 去區分不同的場景,例如登錄頁面、角色選擇頁面和遊戲頁面,每個頁面都是一個獨立的 Scene:
private static void RenderEnvironment() { try { if (DXManager.DeviceLost) { DXManager.AttemptReset(); Thread.Sleep(1); return; } ​ DXManager.Device.Clear(ClearFlags.Target, Color.CornflowerBlue, 0, 0); DXManager.Device.BeginScene(); DXManager.Sprite.Begin(SpriteFlags.AlphaBlend); DXManager.SetSurface(DXManager.MainSurface); ​ // Note here if (MirScene.ActiveScene != null) MirScene.ActiveScene.Draw(); ​ DXManager.Sprite.End(); DXManager.Device.EndScene(); DXManager.Device.Present(); } catch (Direct3D9Exception ex) { DXManager.DeviceLost = true; } catch (Exception ex) { SaveError(ex.ToString()); ​ DXManager.AttemptRecovery(); } }
那麼當前的 ActiveScene 是在哪裡設置的呢?實際上在 MirScene 初始化時它會被指定為 LoginScene:
public abstract class MirScene : MirControl { public static MirScene ActiveScene = new LoginScene(); // ... }
因此上面的 Draw 方法其實會將登錄頁面繪製出來,我們這裡先跳過 GUI 相關的部分,直接來看一下當用戶輸入完賬號密碼後是如何建立連接和發起登錄的。

TCP 連接建立

傳奇中的每個 Scene 都是繼承自 MirControl 的 UI 對象,MirControl 提供了 Shown 回調用於監聽 UI 的展示,在 LoginScene 展示時我們會開啟 TCP 連接:
public LoginScene() { // ... Shown += (sender, args) => { Network.Connect(); _connectBox.Show(); }; }
Network 是客戶端的網絡管理類,在 Connect 方法中我們會創建一個 TcpClient 對象併發起連接,服務端的信息通過配置獲取:
public static void Connect() { if (_client != null) Disconnect(); ​ ConnectAttempt++; ​ _client = new TcpClient {NoDelay = true}; _client.BeginConnect(Settings.IPAddress, Settings.Port, Connection, null); }
與服務端的處理方式類似,在 BeginConnect 的異步回調中,我們會開啟 receiveList 和 sendList 兩個隊列,然後通過 BeginReceive 接收服務端數據、處理成 Packet 並加入 receiveList 等待處理。在客戶端每幀 Process 的過程中,我們會處理 receiveList 更改客戶端狀態,同時根據用戶輸入產生數據包加入到 sendList 發送到服務端。

第一個數據包

服務端發送 S.Connected
通過上面的分析我們知道客戶端啟動的第一步是發起 TCP 連接請求,服務端在對 Client 進行 Accept 時會創建 MirConnection 對象(如果對此沒有印象可以參考第一篇文章),在 MirConnection 的構造方法中我們會向客戶端發送 Connected 數據包,這便是客戶端與服務端交流的第一個數據包啦:
public MirConnection(int sessionID, TcpClient client) { // ... _receiveList = new ConcurrentQueue<Packet>(); _sendList = new ConcurrentQueue<Packet>(); _sendList.Enqueue(new S.Connected()); _retryList = new Queue<Packet>(); ​ Connected = true; BeginReceive(); }
客戶端處理 S.Connected
前面我們提到在 TCP 連接建立之前基於 Application Idle 的事件循環對 UpdateEnviroment 的調用會被忽略,而在連接建立之後這裡會通過 Network.Process 處理服務端數據包和發送這一幀產生的數據包,數據包會被路由到 ActiveScene 進行處理,因此這裡的 ProcessPacket 會調用到 LoginScene:
public static void Process() { // ... while (_receiveList != null && !_receiveList.IsEmpty) { if (!_receiveList.TryDequeue(out Packet p) || p == null) continue; MirScene.ActiveScene.ProcessPacket(p); } if (CMain.Time > TimeOutTime && _sendList != null && _sendList.IsEmpty) _sendList.Enqueue(new C.KeepAlive()); if (_sendList == null || _sendList.IsEmpty) return; TimeOutTime = CMain.Time + Settings.TimeOut; // 5000ms List<byte> data = new List<byte>(); while (!_sendList.IsEmpty) { if (!_sendList.TryDequeue(out Packet p)) continue; data.AddRange(p.GetPacketBytes()); } CMain.BytesSent += data.Count; BeginSend(data); }
在 LoginScene 的 ProcessPacket 中包含了對客戶端初始化和賬戶相關的數據處理,由於當前數據包是 S.Connected 自然會進入到 ServerPacketIds.Connected 這個 case,隨後客戶端通過 SendVersion 發送數據完整性檢查請求(這裡會對 Executable 進行 hash):
public override void ProcessPacket(Packet p) { switch (p.Index) { case (short)ServerPacketIds.Connected: Network.Connected = true; SendVersion(); break; case (short)ServerPacketIds.ClientVersion: ClientVersion((S.ClientVersion) p); break; // ... default: base.ProcessPacket(p); break; } }
數據完整性檢查與 Connected 數據包類似,首先客戶端發送 hash 到服務端,服務端校驗後將結果返回到客戶端,這是一個初級的逆向對抗策略,可通過修改發送的 hash 或忽略返回的錯誤跳過。

客戶端登錄過程

在上述檢查通過以後,客戶端會展示賬號密碼輸入頁面,用戶輸入賬號密碼後點擊登錄會調用 Login 方法發起登錄請求:
// LoginScene.cs private void Login() { OKButton.Enabled = false; Network.Enqueue(new C.Login {AccountID = AccountIDTextBox.Text, Password = PasswordTextBox.Text}); }
作為一款早期的遊戲,傳奇的密碼採用了明文傳輸(囧),服務端收到 C.Login 數據包後,會嘗試從 Account Database 中查詢與之匹配的賬戶,如果校驗失敗會發送 S.Login 返回登錄失敗的原因,成功則發送 S.LoginSuccess:
// Envir.cs public void Login(ClientPackets.Login p, MirConnection c) { // ... if (!AccountIDReg.IsMatch(p.AccountID)) { c.Enqueue(new ServerPackets.Login { Result = 1 }); return; } if (!PasswordReg.IsMatch(p.Password)) { c.Enqueue(new ServerPackets.Login { Result = 2 }); return; } var account = GetAccount(p.AccountID); if (account == null) { c.Enqueue(new ServerPackets.Login { Result = 3 }); return; } // ... if (string.CompareOrdinal(account.Password, p.Password) != 0) { if (account.WrongPasswordCount++ >= 5) { account.Banned = true; account.BanReason = "Too many Wrong Login Attempts."; account.ExpiryDate = DateTime.Now.AddMinutes(2); c.Enqueue(new ServerPackets.LoginBanned { Reason = account.BanReason, ExpiryDate = account.ExpiryDate }); return; } c.Enqueue(new ServerPackets.Login { Result = 4 }); return; } account.WrongPasswordCount = 0; lock (AccountLock) { account.Connection?.SendDisconnect(1); account.Connection = c; } c.Account = account; c.Stage = GameStage.Select; account.LastDate = Now; account.LastIP = c.IPAddress; MessageQueue.Enqueue(account.Connection.SessionID + ", " + account.Connection.IPAddress + ", User logged in."); c.Enqueue(new ServerPackets.LoginSuccess { Characters = account.GetSelectInfo() }); }
相應地,在客戶端側也包含了對 Login 和 LoginSuccess 的處理:
// LoginScene.cs public override void ProcessPacket(Packet p) { switch (p.Index) { // ... case (short)ServerPacketIds.Login: Login((S.Login) p); break; case (short)ServerPacketIds.LoginSuccess: Login((S.LoginSuccess) p); break; default: base.ProcessPacket(p); break; } }
在登錄失敗時會調用到 private void Login(S.Login p) 這個重載方法展示登錄失敗原因(事實上出於安全考慮,登錄失敗的原因應當儘可能模糊):
// LoginScene.cs private void Login(S.Login p) { _login.OKButton.Enabled = true; switch (p.Result) { case 0: MirMessageBox.Show("Logging in is currently disabled."); _login.Clear(); break; case 1: MirMessageBox.Show("Your AccountID is not acceptable."); _login.AccountIDTextBox.SetFocus(); break; case 2: MirMessageBox.Show("Your Password is not acceptable."); _login.PasswordTextBox.SetFocus(); break; case 3: MirMessageBox.Show(GameLanguage.NoAccountID); _login.PasswordTextBox.SetFocus(); break; case 4: MirMessageBox.Show(GameLanguage.IncorrectPasswordAccountID); _login.PasswordTextBox.Text = string.Empty; _login.PasswordTextBox.SetFocus(); break; } }
在登錄成功時會調用到 private void Login(S.LoginSuccess p) 這個重載方法切換到角色選擇 Scene 等待用戶的下一步操作,為了避免額外的數據交互,服務端在登錄成功後會返回角色列表:
// LoginScene.cs private void Login(S.LoginSuccess p) { Enabled = false; _login.Dispose(); if(_ViewKey != null && !_ViewKey.IsDisposed) _ViewKey.Dispose(); SoundManager.PlaySound(SoundList.LoginEffect); _background.Animated = true; _background.AfterAnimation += (o, e) => { Dispose(); ActiveScene = new SelectScene(p.Characters); }; }

開始遊戲

服務端同步角色數據
在用戶選擇完角色點擊開始遊戲後,客戶端會發送包含角色選擇信息的 C.StartGame 數據包到服務端:
// SelectScene.cs public void StartGame() { // ... Network.Enqueue(new C.StartGame { CharacterIndex = Characters[_selected].Index }); }
服務端在接收到 C.StartGame 後會讀從數據庫讀取角色數據,隨後新建一個 PlayerObject 調用 StartGame 方法:
// MirConnection.cs private void StartGame(C.StartGame p) { // ... CharacterInfo info = null; for (int i = 0; i < Account.Characters.Count; i++) { if (Account.Characters[i].Index != p.CharacterIndex) continue; info = Account.Characters[i]; break; } if (info == null) { Enqueue(new S.StartGame { Result = 2 }); return; } // ... Player = new PlayerObject(info, this); Player.StartGame(); }
在 PlayerObject 的 StartGame 方法中,服務端將角色添加到地圖中,隨後發送遊戲開始和玩家數據到客戶端:
// PlayerObject.cs public void StartGame() { Map temp = Envir.GetMap(CurrentMapIndex); if (temp != null && temp.Info.NoReconnect) { Map temp1 = Envir.GetMapByNameAndInstance(temp.Info.NoReconnectMap); if (temp1 != null) { temp = temp1; CurrentLocation = GetRandomPoint(40, 0, temp); } } if (temp == null || !temp.ValidPoint(CurrentLocation)) { temp = Envir.GetMap(BindMapIndex); if (temp == null || !temp.ValidPoint(BindLocation)) { SetBind(); temp = Envir.GetMap(BindMapIndex); if (temp == null || !temp.ValidPoint(BindLocation)) { StartGameFailed(); return; } } CurrentMapIndex = BindMapIndex; CurrentLocation = BindLocation; } temp.AddObject(this); CurrentMap = temp; Envir.Players.Add(this); StartGameSuccess(); //Call Login NPC CallDefaultNPC(DefaultNPCType.Login); //Call Daily NPC if (Info.NewDay) { CallDefaultNPC(DefaultNPCType.Daily); } }
隨後在 StartGameSuccess 的調用中向客戶端發送遊戲開發和角色數據,這裡的每個 Get 方法的作用都是將地圖和角色數據同步到客戶端:
// PlayerObject.cs private void StartGameSuccess() { Connection.Stage = GameStage.Game; // ... Enqueue(new S.StartGame { Result = 4, Resolution = Settings.AllowedResolution }); ReceiveChat(string.Format(GameLanguage.Welcome, GameLanguage.GameName), ChatType.Hint); // ... Spawned(); SetLevelEffects(); GetItemInfo(); GetMapInfo(); GetUserInfo(); GetQuestInfo(); GetRecipeInfo(); GetCompletedQuests(); GetMail(); GetFriends(); GetRelationship(); if ((Info.Mentor != 0) && (Info.MentorDate.AddDays(Settings.MentorLength) < DateTime.Now)) MentorBreak(); else GetMentor(); CheckConquest(); GetGameShop(); // ... } private void GetUserInfo() { string guildname = MyGuild != null ? MyGuild.Name : ""; string guildrank = MyGuild != null ? MyGuildRank.Name : ""; S.UserInformation packet = new S.UserInformation { ObjectID = ObjectID, RealId = (uint)Info.Index, Name = Name, GuildName = guildname, GuildRank = guildrank, NameColour = GetNameColour(this), Class = Class, Gender = Gender, Level = Level, Location = CurrentLocation, Direction = Direction, Hair = Hair, HP = HP, MP = MP, Experience = Experience, MaxExperience = MaxExperience, LevelEffects = LevelEffects, Inventory = new UserItem[Info.Inventory.Length], Equipment = new UserItem[Info.Equipment.Length], QuestInventory = new UserItem[Info.QuestInventory.Length], Gold = Account.Gold, Credit = Account.Credit, HasExpandedStorage = Account.ExpandedStorageExpiryDate > Envir.Now ? true : false, ExpandedStorageExpiryTime = Account.ExpandedStorageExpiryDate }; Info.Inventory.CopyTo(packet.Inventory, 0); Info.Equipment.CopyTo(packet.Equipment, 0); Info.QuestInventory.CopyTo(packet.QuestInventory, 0); //IntelligentCreature for (int i = 0; i < Info.IntelligentCreatures.Count; i++) packet.IntelligentCreatures.Add(Info.IntelligentCreatures[i].CreateClientIntelligentCreature()); packet.SummonedCreatureType = SummonedCreatureType; packet.CreatureSummoned = CreatureSummoned; Enqueue(packet); }

客戶端開始遊戲

客戶端目前處於 SelectScene,在收到遊戲啟動成功的數據包 S.StartGame 後會根據返回數據調整分辨率並切換到 GameScene:
public void StartGame(S.StartGame p) { StartGameButton.Enabled = true; ​ switch (p.Result) { case 0: MirMessageBox.Show("Starting the game is currently disabled."); break; case 1: MirMessageBox.Show("You are not logged in."); break; case 2: MirMessageBox.Show("Your character could not be found."); break; case 3: MirMessageBox.Show("No active map and/or start point found."); break; case 4: ​ if (p.Resolution < Settings.Resolution || Settings.Resolution == 0) Settings.Resolution = p.Resolution; ​ switch (Settings.Resolution) { default: case 1024: Settings.Resolution = 1024; CMain.SetResolution(1024, 768); break; case 1280: CMain.SetResolution(1280, 800); break; case 1366: CMain.SetResolution(1366, 768); break; case 1920: CMain.SetResolution(1920, 1080); break; } ​ ActiveScene = new GameScene(); Dispose(); break; } }
在 GameScene 中客戶端會處理來自服務端的角色信息、地圖數據以及 NPC 和其他玩家數據等,例如在收到遊戲開始時服務端發送的 S.UserInformation 後會創建當前玩家的角色:
// GameScene.cs public override void ProcessPacket(Packet p) { switch (p.Index) { // ... case (short)ServerPacketIds.UserInformation: UserInformation((S.UserInformation)p); break; // ... } } ​ private void UserInformation(S.UserInformation p) { User = new UserObject(p.ObjectID); User.Load(p); MainDialog.PModeLabel.Visible = User.Class == MirClass.Wizard || User.Class == MirClass.Taoist; Gold = p.Gold; Credit = p.Credit; ​ InventoryDialog.RefreshInventory(); foreach (SkillBarDialog Bar in SkillBarDialogs) Bar.Update(); }

下一步

到這裡整個客戶端的啟動流程就分析完了,接下來的邏輯主要集中在服務端向客戶端同步狀態和客戶端發送角色行為,在接下來的文章中我們將深入分析這些交互的處理過程。

© 2022 3樓貓 下載APP 站點地圖 廣告合作:asmrly666@gmail.com