专题介绍
该专题将会分析 LOMCN 基于韩版传奇 2,使用 .NET 重写的传奇源码(服务端 + 客户端),分析数据交互、状态管理和客户端渲染等技术,此外笔者还会分享将客户端部分移植到 Unity 和服务端用现代编程语言重写的全过程。
概览
在这一篇文章中,我们将从客户端入手,分析从 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 hereif (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; // 5000msList<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 NPCCallDefaultNPC(DefaultNPCType.Login);//Call Daily NPCif (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();elseGetMentor();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);//IntelligentCreaturefor (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();
}
下一步
到这里整个客户端的启动流程就分析完了,接下来的逻辑主要集中在服务端向客户端同步状态和客户端发送角色行为,在接下来的文章中我们将深入分析这些交互的处理过程。