专题介绍
该专题将会分析 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}
总结
本文重点介绍了服务端的启动链路、网络初始化和连接处理这三个过程,并简要分析的游戏循环的实现与数据包处理,在下一篇文章中,我们会从客户端角度分析从游戏启动、登录、游戏开始和基础游戏交互的全流程。