专题介绍

该专题将会剖析 LOMCN 根据韩版传奇 2,使用 .NET 重写的传奇源码(服务端 + 客户端),剖析数据交互、状况办理和客户端烘托等技能,此外笔者还会同享将客户端部分移植到 Unity 和服务端用现代编程语言重写的全进程。

相关资料

  • 官方论坛: www.lomcn.org/forum/
  • 服务端 + 客户端源码: github.com/Suprcode/mi…
  • 服务端离线数据库: github.com/Suprcode/mi…

概览

在这一篇文章中,咱们将从服务端的发动链路下手,剖析服务端的 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); // 反常逻辑下的重放逻辑
    else
        Player.Walk(p.Direction); // 处理游戏逻辑,并生成返回 Packet
}

总结

本文要点介绍了服务端的发动链路、网络初始化和衔接处理这三个进程,并扼要剖析的游戏循环的实现与数据包处理,鄙人一篇文章中,咱们会从客户端角度剖析从游戏发动、登录、游戏开端和基础游戏交互的全流程。