專題介紹
該專題將會分析 LOMCN 基於韓版《傳奇2》使用 .NET 重寫的《傳奇》源碼(服務端 + 客戶端),分析數據交互、狀態管理和客戶端渲染等技術,此外筆者還會分享將客戶端部分移植到 Unity 和服務端用現代編程語言重寫的全過程。
相關資料
- 官方論壇
- 服務端 + 客戶端源碼
- 服務端離線數據庫
概覽
在這一篇文章中,我們將從服務端的啟動鏈路入手,分析 TCP 連接監聽、數據包處理和客戶端的狀態維護過程。
啟動鏈路
WinForm 入口 SMain.cs
服務端基於 WinForm 編寫了控制面板,因此將環境啟動放到了 WinForm 的 Load 回調中,在這裡加載 DB 後啟動 Server 環境。這裡的 DB 就是整個遊戲的數據庫了。
注意這裡有兩個完全獨立的 Envir 實例,一個是 EditEnvir,一個是 Main (命名為 Envir),LoadDB 方法會從 Server.MirDB 加載地圖、物品、NPC、任務等數據到 Envir,這裡先檢查能否將 DB 數據加載到 EditEnvir 中,如果成功則啟動 Main Envir(實際上後面還需要將 DB 導入到 Main Envir)。
private void SMain_Load(object sender, EventArgs e){var loaded = EditEnvir.LoadDB();if (loaded){Envir.Start();}AutoResize();}
服務端環境 Envir 啟動
上述代碼調用了 Envir 的 Start 方法,它會通過創建線程開啟整個服務端的 WorkLoop,注意這裡的服務端包含了 UI + 遊戲循環的 WorkLoop 兩部分,其中 UI 部分由 WinForm 負責,遊戲循環通過這裡新開的線程處理。
public void Start(){if (Running || _thread != null) return;Running = true;_thread = new Thread(WorkLoop) {IsBackground = true};_thread.Start();}
開啟 Workloop
整個 Workloop 的大致結構如下,從中我們可以看到首先通過 StartEnvir 將 DB 加載到當前 Envir 中(和上面將數據加載到 EditEnvir 中類似),隨後開啟網絡監聽,然後通過一個 while 開啟真正的遊戲循環:
private void WorkLoop(){//try{// ...StartEnvir();// ...StartNetwork();// ...try{while (Running){// Game Loop}}catch (Exception ex){// ...}// ...StopNetwork();StopEnvir();SaveAccounts();SaveGuilds(true);SaveConquests(true);}_thread = null;}
TCP 狀態管理
有關遊戲循環的細節我們將在接下來的文章中逐步展開,現在我們先重點分析網絡處理相關的部分。
在遊戲循環開始之前,Workloop 方法會通過 StartNetwork 進行服務端網絡的初始化,為了處理客戶端鑑權,需要先通過 LoadAccounts 方法從 Server.MirADB 加載賬戶數據,然後開啟一個 TCP Listener 開始異步接收客戶端連接(注意 Connection Callback 是從一個新線程回調的):
private void StartNetwork(){Connections.Clear();LoadAccounts();LoadGuilds();LoadConquests();_listener = new TcpListener(IPAddress.Parse(Settings.IPAddress), Settings.Port);_listener.Start();_listener.BeginAcceptTcpClient(Connection, null);if (StatusPortEnabled){_StatusPort = new TcpListener(IPAddress.Parse(Settings.IPAddress), 3000);_StatusPort.Start();_StatusPort.BeginAcceptTcpClient(StatusConnection, null);}MessageQueue.Enqueue("Network Started.");}
每個 Connection 都會被包裝成 MirConnection,每個 Connection 都有自己的 ReceiveList 和 SendList 兩個消息隊列,在客戶端連接後會立即 Enqueue 一條 Connected 消息到 SendList:
public MirConnection(int sessionID, TcpClient client){SessionID = sessionID;IPAddress = client.Client.RemoteEndPoint.ToString().Split(':')[0];// ..._client = client;_client.NoDelay = true;TimeConnected = Envir.Time;TimeOutTime = TimeConnected + Settings.TimeOut;_receiveList = new ConcurrentQueue<Packet>();_sendList = new ConcurrentQueue<Packet>();_sendList.Enqueue(new S.Connected());_retryList = new Queue<Packet>();Connected = true;BeginReceive();}
這裡的 S 是 Packet Factory,在服務端和客戶端對應的 namespace 分別是 ServerPackets 和 ClientPackets,每個 packet 都通過 enum 定義了 id,例如這裡的 S.Connected Packet 定義如下:
namespace ServerPackets{public sealed class Connected : Packet{public override short Index{get { return (short)ServerPacketIds.Connected; }}protected override void ReadPacket(BinaryReader reader){}protected override void WritePacket(BinaryWriter writer){}}}
MirConnection 的 BeginReceive 通過 TcpClient 的 BeginReceive 方法異步監聽 Socket 緩衝區:
private void BeginReceive(){if (!Connected) return;try{_client.Client.BeginReceive(_rawBytes, 0, _rawBytes.Length, SocketFlags.None, ReceiveData, _rawBytes);}catch{Disconnecting = true;}}
這裡的 rawBytes 默認為 8 * 1024 = 8KB,作為 Socket 的讀緩衝區,在接收到數據會,會通過 ReceiveData 進行處理:
private void ReceiveData(IAsyncResult result){if (!Connected) return;int dataRead;try{dataRead = _client.Client.EndReceive(result);}catch{Disconnecting = true;return;}if (dataRead == 0){Disconnecting = true;return;}byte[] rawBytes = result.AsyncState as byte[];// 這裡的 rawData 用於 TCP 粘包,可能多個 packet 會被合併成一個 data 到達// 這種情況下 Packet.ReceivePacket 只會處理部分數據,而剩下的數據就會被// 存儲在 rawData 中,下次處理 data 時,需要將 rawData 拼接在 rawBytes// 前面進行處理byte[] temp = _rawData;_rawData = new byte[dataRead + temp.Length];Buffer.BlockCopy(temp, 0, _rawData, 0, temp.Length);Buffer.BlockCopy(rawBytes, 0, _rawData, temp.Length, dataRead);Packet p;while ((p = Packet.ReceivePacket(_rawData, out _rawData)) != null)_receiveList.Enqueue(p);BeginReceive();}
這裡的 Packet 類是服務端和客戶端共享的,位於 Shared/Packet.cs,定義了 Packet 的數據結構,ReceivePacket 會先讀取 Packet 公共頭部 length + id,然後通過 Server 和 Client 各自的 Packet Factory 去初始化一個 Packet 實例並完成讀取:
public static Packet ReceivePacket(byte[] rawBytes, out byte[] extra){extra = rawBytes;Packet p;if (rawBytes.Length < 4) return null; //| 2Bytes: Packet Size | 2Bytes: Packet ID |int length = (rawBytes[1] << 8) + rawBytes[0];if (length > rawBytes.Length || length < 2) return null;using (MemoryStream stream = new MemoryStream(rawBytes, 2, length - 2))using (BinaryReader reader = new BinaryReader(stream)){try{short id = reader.ReadInt16();p = IsServer ? GetClientPacket(id) : GetServerPacket(id);if (p == null) return null;p.ReadPacket(reader);}catch{return null;//return new C.Disconnect();}}extra = new byte[rawBytes.Length - length];Buffer.BlockCopy(rawBytes, length, extra, 0, rawBytes.Length - length);return p;}
處理完成的數據包會被 Enqueue 到 ReceiveList 等待後續處理。
客戶端數據包處理
經過上述分析我們知道,客戶端的數據包會被 Enqueue 到 ReceiveList 中等待處理。自然地,Server 會在遊戲循環中處理它們,這裡我們以處理客戶端的人物行走為例做簡要分析。在遊戲循環中,服務端會遍歷所有已連接的客戶端調用 Process 方法進行數據處理。
lock (Connections){for (var i = Connections.Count - 1; i >= 0; i--){Connections[i].Process();}}
在 Process 方法中,服務端先從 ReceiveList 取出上面已經入隊的客戶端 Packet,根據 id 進行服務端邏輯,然後生成服務端回覆的 Packet 並開始異步發送:
while (!_receiveList.IsEmpty && !Disconnecting){Packet p;if (!_receiveList.TryDequeue(out p)) continue;TimeOutTime = Envir.Time + Settings.TimeOut;ProcessPacket(p);if (_receiveList == null)return;}private void ProcessPacket(Packet p){if (p == null || Disconnecting) return;switch (p.Index){// ...case (short)ClientPacketIds.Walk:Walk((C.Walk) p);break;// ...};// ...};private void Walk(C.Walk p){if (Stage != GameStage.Game) return;if (Player.ActionTime > Envir.Time)_retryList.Enqueue(p); // 異常邏輯下的重放邏輯elsePlayer.Walk(p.Direction); // 處理遊戲邏輯,並生成返回 Packet}
總結
本文重點介紹了服務端的啟動鏈路、網絡初始化和連接處理這三個過程,並簡要分析的遊戲循環的實現與數據包處理,在下一篇文章中,我們會從客戶端角度分析從遊戲啟動、登錄、遊戲開始和基礎遊戲交互的全流程。