惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Jina AI
Jina AI
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
有赞技术团队
有赞技术团队
罗磊的独立博客
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
U
Unit 42
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Recent Announcements
Recent Announcements
Y
Y Combinator Blog
Vercel News
Vercel News
Martin Fowler
Martin Fowler
V
V2EX
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
L
LangChain Blog
云风的 BLOG
云风的 BLOG
H
Hackread – Cybersecurity News, Data Breaches, AI and More
aimingoo的专栏
aimingoo的专栏
G
Google Developers Blog
The GitHub Blog
The GitHub Blog
N
Netflix TechBlog - Medium
Google DeepMind News
Google DeepMind News
雷峰网
雷峰网
阮一峰的网络日志
阮一峰的网络日志
F
Fortinet All Blogs

机核

游戏性能旗舰最强之选,一加 Ace 6 至尊版国补到手价2999元起 6元钱自己更换电动车刹车线 《生化危机9:安魂曲》编剧Haris Orkin专访 摸金游戏?音乐游戏!暗区新赛季这把能弹的琴有何来历? 好评国产武侠SRPG《息风谷战略》免费DLC现已推出 | 机核 GCORES 碎片 《生化危机:安魂曲》将于5月实装首个免费更新内容 | 机核 GCORES 新锐东方游戏,谱写世界新章! 沉浸式恋爱视觉小说游戏《心象演算》免费试玩版现已正式上线 | 机核 GCORES 互动影游《代号三国:龙起》上线!穿越三国与曹操并肩、与佳人同行、与权谋博弈! 《老头收集梦想生活》,游戏酒桌会6,录音笔VOL.689 | 机核 GCORES 破界·共生——《白日梦:无限世界》五大核心游戏特点解析 价格已到史低,锐龙5 9600X/锐龙7 9700X正适合抄底 时间循环之旅即刻启程!《归环》一周目测试今日开启 热门在线韩游变魂游,洛奇衍生作能否打破“花瓶”魔咒? LG UltraGear evo 全新高端显示器系列:当“5K”遇见“AI”,不止强大,更懂热爱 反套路三国互动影游《代号三国:龙起》今日上线! 愿望单登记人数突破10万!备受瞩目的“女儿养成游戏” 《梦幻魔法公主》今日于Steam平台上线!限时八折优惠中 《黑神话:悟空》全球音乐会2026巡演将于4月29日12时开票 | 机核 GCORES “Snowguelike”生存肉鸽挖矿新作《蛙穿雪境》公布发售日期,5月7日正式上线 | 机核 GCORES 重塑移动办公、AI创作新境!全新华硕灵耀Air系列、ProArt 骁龙版震撼首发,创芯未来 均分88:《Saros》媒体评分汇总 | 机核 GCORES 上海烛龙公布合作遗迹探险游戏《吉时已到》首支预告片 | 机核 GCORES 首个独立游戏《萝薇日记》已上线Steam! 烛龙新IP《吉时已到》首曝,打造国内首款中式合作遗迹探险游戏 喜加一:《暗黑破坏神Ⅳ》国服现已开启限时免费领取本体活动 | 机核 GCORES 新版《生化危机》电影定于9月18日上映,官方网站现已上线 | 机核 GCORES 《冲就完事模拟器2》“星球大战”联动DLC正式宣布 | 机核 GCORES SteamController将于5月4日发售,售价99美元 | 机核 GCORES 事已至此,内存这么用也算省钱了,“2+1”非对称双通道应用实测
韩版《传奇2》源码分析与 Unity 重制(一):服务端 TCP 状态...
Sou1gh0st · 2023-03-13 · via 机核

专题介绍

该专题将会分析 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); // 异常逻辑下的重放逻辑

else

Player.Walk(p.Direction); // 处理游戏逻辑,并生成返回 Packet

}

总结

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