本文作者:sodme
本文出处:http://blog.csdn.net/sodme
声明:本文可以不经作者同意任意转载、复制、传播,但任何对本文的引用都请保留作者、出处及本声明信息。谢谢!
常见的网络服务器,基本上是7*24小时运转的,对于网游来说,至少要求服务器要能连续工作一周以上的时间并保证不出现服务器崩溃这样的灾难性事件。事 实上,要求一个服务器在连续的满负荷运转下不出任何异常,要求它设计的近乎完美,这几乎是不太现实的。服务器本身可以出异常(但要尽可能少得出),但是, 服务器本身应该被设计得足以健壮,“小病小灾”打不垮它,这就要求服务器在异常处理方面要下很多功夫。
服务器的异常处理包括的内容非常广泛,本文仅就在网络封包方面出现的异常作一讨论,希望能对正从事相关工作的朋友有所帮助。
关于网络封包方面的异常,总体来说,可以分为两大类:一是封包格式出现异常;二是封包内容(即封包数据)出现异常。在封包格式的异常处理方面, 我们在最底端的网络数据包接收模块便可以加以处理。而对于封包数据内容出现的异常,只有依靠游戏本身的逻辑去加以判定和检验。游戏逻辑方面的异常处理,是 随每个游戏的不同而不同的,所以,本文随后的内容将重点阐述在网络数据包接收模块中的异常处理。
为方便以下的讨论,先明确两个概念(这两个概念是为了叙述方面,笔者自行取的,并无标准可言):
1、逻辑包:指的是在应用层提交的数据包,一个完整的逻辑包可以表示一个确切的逻辑意义。比如登录包,它里面就可以含有用户名字段和密码字段。尽管它看上去也是一段缓冲区数据,但这个缓冲区里的各个区间是代表一定的逻辑意义的。
2、物理包:指的是使用recv(recvfrom)或wsarecv(wsarecvfrom)从网络底层接收到的数据包,这样收到的一个数据包,能不能表示一个完整的逻辑意义,要取决于它是通过UDP类的“数据报协议”发的包还是通过TCP类的“流协议”发的包。
我们知道,TCP是流协议,“流协议”与“数据报协议”的不同点在于:“数据报协议”中的一个网络包本身就是一个完整的逻辑包,也就是说,在应 用层使用sendto发送了一个逻辑包之后,在接收端通过recvfrom接收到的就是刚才使用sendto发送的那个逻辑包,这个包不会被分开发送,也 不会与其它的包放在一起发送。但对于TCP而言,TCP会根据网络状况和neagle算法,或者将一个逻辑包单独发送,或者将一个逻辑包分成若干次发送, 或者会将若干个逻辑包合在一起发送出去。正因为TCP在逻辑包处理方面的这种粘合性,要求我们在作基于TCP的应用时,一般都要编写相应的拼包、解包代 码。
因此,基于TCP的上层应用,一般都要定义自己的包格式。TCP的封包定义中,除了具体的数据内容所代表的逻辑意义之外,第一步就是要确定以何种方式表示当前包的开始和结束。通常情况下,表示一个TCP逻辑包的开始和结束有两种方式:
1、以特殊的开始和结束标志表示,比如FF00表示开始,00FF表示结束。
2、直接以包长度来表示。比如可以用第一个字节表示包总长度,如果觉得这样的话包比较小,也可以用两个字节表示包长度。
下面将要给出的代码是以第2种方式定义的数据包,包长度以每个封包的前两个字节表示。我将结合着代码给出相关的解释和说明。
函数中用到的变量说明:
CLIENT_BUFFER_SIZE:缓冲区的长度,定义为:Const int CLIENT_BUFFER_SIZE=4096。
m_ClientDataBuf:数据整理缓冲区,每次收到的数据,都会先被复制到这个缓冲区的末尾,然后由下面的整理函数对这个缓冲区进行整理。它的定义是:char m_ClientDataBuf[2* CLIENT_BUFFER_SIZE]。
m_DataBufByteCount:数据整理缓冲区中当前剩余的未整理字节数。
GetPacketLen(const char*):函数,可以根据传入的缓冲区首址按照应用层协议取出当前逻辑包的长度。
GetGamePacket(const char*, int):函数,可以根据传入的缓冲区生成相应的游戏逻辑数据包。
AddToExeList(PBaseGamePacket):函数,将指定的游戏逻辑数据包加入待处理的游戏逻辑数据包队列中,等待逻辑处理线程对其进行处理。
DATA_POS:指的是除了包长度、包类型等这些标志型字段之外,真正的数据包内容的起始位置。
Bool SplitFun(const char* pData,const int &len)
{
PBaseGamePacket pGamePacket=NULL;
__int64 startPos=0, prePos=0, i=0;
int packetLen=0;
//先将本次收到的数据复制到整理缓冲区尾部
startPos = m_DataBufByteCount;
memcpy( m_ClientDataBuf+startPos, pData, len );
m_DataBufByteCount += len;
//当整理缓冲区内的字节数少于DATA_POS字节时,取不到长度信息则退出
//注意:退出时并不置m_DataBufByteCount为0
if (m_DataBufByteCount < DATA_POS+1)
return false;
//根据正常逻辑,下面的情况不可能出现,为稳妥起见,还是加上
if (m_DataBufByteCount > 2*CLIENT_BUFFER_SIZE)
{
//设置m_DataBufByteCount为0,意味着丢弃缓冲区中的现有数据
m_DataBufByteCount = 0;
//可以考虑开放错误格式数据包的处理接口,处理逻辑交给上层
//OnPacketError()
return false;
}
//还原起始指针
startPos = 0;
//只有当m_ClientDataBuf中的字节个数大于最小包长度时才能执行此语句
packetLen = GetPacketLen( pIOCPClient->m_ClientDataBuf );
//当逻辑层的包长度不合法时,则直接丢弃该包
if ((packetLen < DATA_POS+1) || (packetLen > 2*CLIENT_BUFFER_SIZE))
{
m_DataBufByteCount = 0;
//OnPacketError()
return false;
}
//保留整理缓冲区的末尾指针
__int64 oldlen = m_DataBufByteCount;
while ((packetLen <= m_DataBufByteCount) && (m_DataBufByteCount>0))
{
//调用拼包逻辑,获取该缓冲区数据对应的数据包
pGamePacket = GetGamePacket(m_ClientDataBuf+startPos, packetLen);
if (pGamePacket!=NULL)
{
//将数据包加入执行队列
AddToExeList(pGamePacket);
}
pGamePacket = NULL;
//整理缓冲区的剩余字节数和新逻辑包的起始位置进行调整
m_DataBufByteCount -= packetLen;
startPos += packetLen;
//残留缓冲区的字节数少于一个正常包大小时,只向前复制该包随后退出
if (m_DataBufByteCount < DATA_POS+1)
{
for(i=startPos; i
return true;
}
packetLen = GetPacketLen(m_ClientDataBuf + startPos );
//当逻辑层的包长度不合法时,丢弃该包及缓冲区以后的包
if ((packetLen
{
m_DataBufByteCount = 0;
//OnPacketError()
return false;
}
if (startPos+packetLen>=oldlen)
{
for(i=startPos; i
return true;
}
}//取所有完整的包
return true;
}
以上便是数据接收模块的处理函数,下面是几点简要说明:
1、用于拼包整理的缓冲区(m_ClientDataBuf)应该比recv中指定的接收缓冲区(pData)长度(CLIENT_BUFFER_SIZE)要大,通常前者是后者的2倍(2*CLIENT_BUFFER_SIZE)或更大。
2、为避免因为剩余数据前移而导致的额外开销,建议m_ClientDataBuf使用环形缓冲区实现。
3、为了避免出现无法拼装的包,我们约定每次发送的逻辑包,其单个逻辑包最大长度不可以超过CLIENT_BUFFER_SIZE的2倍。因为我们的整 理缓冲区只有2*CLIENT_BUFFER_SIZE这么长,更长的数据,我们将无法整理。这就要求在协议的设计上以及最终的发送函数的处理上要加上这 样的异常处理机制。
4、对于数据包过短或过长的包,我们通常的情况是置m_DataBufByteCount为0,即舍弃当前包的处理。如果此处不设置 m_DataBufByteCount为0也可,但该客户端只要发了一次格式错误的包,则其后继发过来的包则也将连带着产生格式错误,如果设置 m_DataBufByteCount为0,则可以比较好的避免后继的包受此包的格式错误影响。更好的作法是,在此处开放一个封包格式异常的处理接口 (OnPacketError),由上层逻辑决定对这种异常如何处置。比如上层逻辑可以对封包格式方面出现的异常进行计数,如果错误的次数超过一定的值, 则可以断开该客户端的连接。
5、建议不要在recv或wsarecv的函数后,就紧接着作以上的处理。当recv收到一段数据后,生成一个结构体或对象(它主要含有 data和len两个内容,前者是数据缓冲区,后者是数据长度),将这样的一个结构体或对象放到一个队列中由后面的线程对其使用SplitFun函数进行 整理。这样,可以最大限度地提高网络数据的接收速度,不至因为数据整理的原因而在此处浪费时间。
代码中,我已经作了比较详细的注释,可以作为拼包函数的参考,代码是从偶的应用中提取、修改而来,本身只为演示之用,所以未作调试,应用时需要你自己去完善。如有疑问,可以我的blog上留言提出。
提示:阅读本文前,请先读此文了解文章背景:http://data.gameres.com/message.asp?TopicID=27236
让无数中国玩家为之瞩目的“魔兽世界”,随着一系列内测前期工作的逐步展开,正在一步步地走近中国玩家,但是,“魔兽”的服务器,却着实让我们为它捏了一把汗。
造成一个网游服务器当机的原因有很多,但主要有以下两种:一,服务器在线人数达到上限,服务器处理效率严重迟缓,造成当机;二,由于外挂或其它游戏作弊 工具导致的非正常数据包的出错,导致游戏服务器逻辑出现混乱,从而造成当机。在这里,我主要想说说后者如何尽可能地避免。
要避免以上 所说到的第二种情况,我们就应该遵循一个基本原则:在网游服务器的设计中,对于具有较强逻辑关系的处理单元,服务器端和客户端应该采用“互不信任原则”, 即:服务器端即使收到了客户端的数据包,也并不是立刻就认为客户端已经达到了某种功能或者状态,客户端到达是否达到了某种功能或者状态,还必须依靠服务器 端上记载的该客户端“以往状态”来判定,也就是说:服务器端的逻辑执行并不单纯地以“当前”的这一个客户端封包来进行,它还应该广泛参考当前封包的上下文 环境,对执行的逻辑作出更进一步地判定,同时,在单个封包的处理上,服务器端应该广泛考虑当前客户端封包所需要的“前置”封包,如果没有收到该客户端应该 发过来的“前置”封包,则当前的封包应该不进行处理或进行异常处理(如果想要性能高,则可以直接忽略该封包;如果想让服务器稳定,可以进行不同的异常处 理)。
之所以采用“互不信任”原则设计网游服务器,一个很重要的考虑是:防外挂。对于一个网络服务器(不仅仅是游戏服务器,泛指所有 服务器)而言,它所面对的对象既有属于自己系统内的合法的网络客户端,也有不属于自己系统内的非法客户端访问。所以,我们在考虑服务器向外开放的接口时, 就要同时考虑这两种情况:合法客户端访问时的逻辑走向以及非法客户端访问时的逻辑走向。举个简单的例子:一般情况下,玩家登录逻辑中,都是先向服务器发送 用户名和密码,然后再向服务器发送进入某组服务器的数据包;但在非法客户端(如外挂)中,则这些客户端则完全有可能先发进入某组服务器的数据包。当然,这 里仅仅是举个例子,也许并不妥当,但基本的意思我已经表达清楚了,即:你服务器端不要我客户端发什么你就信什么,你还得进行一系列的逻辑验证,以判定我当 前执行的操作是不是合法的。以这个例子中,服务器端可以通过以下逻辑执行验证功能:只有当客户端的用户名和密码通过验证后,该客户端才会进入在线玩家列表 中。而只有在线玩家列表中的成员,才可以在登陆服务器的引导下进入各分组服务器。
总之,在从事网游服务器的设计过程中,要始终不移地 坚持一个信念:我们的服务器,不仅仅有自己的游戏客户端在访问,还有其它很多他人写的游戏客户端在访问,所以,我们应该确保我们的服务器是足够强壮的,任 它风吹雨打也不怕,更不会倒。如果在开发实践中,没有很好地领会这一点或者未能将这一思路贯穿进开发之中,那么,你设计出来的服务器将是无比脆弱的。
当然,安全性和效率总是相互对立的。为了实现我们所说的“互不信任”原则,难免的,就会在游戏逻辑中加入很多的异常检测机制,但异常检测又是比较耗时 的,这就需要我们在效率和安全性方面作个取舍,对于特别重要的逻辑,我们应该全面贯彻“互不信任”原则,一步扣一步,步步为营,不让游戏逻辑出现一点漏 洞。而对于并非十分重要的场合,则完全可以采用“半信任”或者根本“不须信任”的原则进行设计,以尽可能地提高服务器效率。
本文只是对自己长期从事游戏服务器设计以来的感受加以总结,也是对魔兽的服务器有感而发。欢迎有相同感受的朋友或从事相同工作的朋友一起讨论。
本文作者:sodme 本文出处:http://blog.csdn.net/sodme
版权声明:本文可以不经作者同意任意转载,但转载时烦请保留文章开始前两行的版权、作者及出处信息。
QQ游戏于前几日终于突破了百万人同时在线的关口,向着更为远大的目标迈进,这让其它众多传统的棋牌休闲游戏平台黯然失色,相比之下,联众似乎 已经根本不是QQ的对手,因为QQ除了这100万的游戏在线人数外,它还拥有3亿多的注册量(当然很多是重复注册的)以及QQ聊天软件900万的同时在线 率,我们已经可以预见未来由QQ构建起来的强大棋牌休闲游戏帝国。
那么,在技术上,QQ游戏到底是如何实现百万人同时在线并保持游戏高效率的呢?
事实上,针对于任何单一的网络服务器程序,其可承受的同时连接数目是有理论峰值的,通过C++中对TSocket的定义类型:word,我们可以判定 这个连接理论峰值是65535,也就是说,你的单个服务器程序,最多可以承受6万多的用户同时连接。但是,在实际应用中,能达到一万人的同时连接并能保证 正常的数据交换已经是很不容易了,通常这个值都在2000到5000之间,据说QQ的单台服务器同时连接数目也就是在这个值这间。
如果要实现2000到5000用户的单服务器同时在线,是不难的。在windows下,比较成熟的技术是采用IOCP--完成端口。与完成端口相关的 资料在网上和CSDN论坛里有很多,感兴趣的朋友可以自己搜索一下。只要运用得当,一个完成端口服务器是完全可以达到2K到5K的同时在线量的。但,5K 这样的数值离百万这样的数值实在相差太大了,所以,百万人的同时在线是单台服务器肯定无法实现的。
要实现百万人同时在线,首先要实现一个比较完善的完成端口服务器模型,这个模型要求至少可以承载2K到5K的同时在线率(当然,如果你MONEY多, 你也可以只开发出最多允许100人在线的服务器)。在构建好了基本的完成端口服务器之后,就是有关服务器组的架构设计了。之所以说这是一个服务器组,是因 为它绝不仅仅只是一台服务器,也绝不仅仅是只有一种类型的服务器。
简单地说,实现百万人同时在线的服务器模型应该是:登陆服务器+大厅服务器+房间服务器。当然,也可以是其它的模型,但其基本的思想是一样的。下面,我将逐一介绍这三类服务器的各自作用。
登陆服务器:一般情况下,我们会向玩家开放若干个公开的登陆服务器,就如QQ登陆时让你选择的从哪个QQ游戏服务器登陆一样,QQ登陆时让玩家选择的 六个服务器入口实际上就是登陆服务器。登陆服务器主要完成负载平衡的作用。详细点说就是,在登陆服务器的背后,有N个大厅服务器,登陆服务器只是用于为当 前的客户端连接选择其下一步应该连接到哪个大厅服务器,当登陆服务器为当前的客户端连接选择了一个合适的大厅服务器后,客户端开始根据登陆服务器提供的信 息连接到相应的大厅上去,同时客户端断开与登陆服务器的连接,为其他玩家客户端连接登陆服务器腾出套接字资源。在设计登陆服务器时,至少应该有以下功 能:N个大厅服务器的每一个大厅服务器都要与所有的登陆服务器保持连接,并实时地把本大厅服务器当前的同时在线人数通知给各个登陆服务器,这其中包括:用 户进入时的同时在线人数增加信息以及用户退出时的同时在线人数减少信息。这里的各个大厅服务器同时在线人数信息就是登陆服务器为客户端选择某个大厅让其登 陆的依据。举例来说,玩家A通过登陆服务器1连接到登陆服务器,登陆服务器开始为当前玩家在众多的大厅服务器中根据哪一个大厅服务器人数比较少来选择一个 大厅,同时把这个大厅的连接IP和端口发给客户端,客户端收到这个IP和端口信息后,根据这个信息连接到此大厅,同时,客户端断开与登陆服务器之间的连 接,这便是用户登陆过程中,在登陆服务器这一块的处理流程。
大厅服务器:大厅服务器,是普通玩家看不到的服务器,它的连接IP和端口信息是登陆服务器通知给客户端的。也就是说,在QQ游戏的本地文件中,具体的 大厅服务器连接IP和端口信息是没有保存的。大厅服务器的主要作用是向玩家发送游戏房间列表信息,这些信息包括:每个游戏房间的类型,名称,在线人数,连 接地址以及其它如游戏帮助文件URL的信息。从界面上看的话,大厅服务器就是我们输入用户名和密码并校验通过后进入的游戏房间列表界面。大厅服务器,主要 有以下功能:一是向当前玩家广播各个游戏房间在线人数信息;二是提供游戏的版本以及下载地址信息;三是提供各个游戏房间服务器的连接IP和端口信息;四是 提供游戏帮助的URL信息;五是提供其它游戏辅助功能。但在这众多的功能中,有一点是最为核心的,即:为玩家提供进入具体的游戏房间的通道,让玩家顺利进 入其欲进入的游戏房间。玩家根据各个游戏房间在线人数,判定自己进入哪一个房间,然后双击服务器列表中的某个游戏房间后玩家开始进入游戏房间服务器。
游戏房间服务器:游戏房间服务器,具体地说就是如“斗地主1”,“斗地主2”这样的游戏房间。游戏房间服务器才是具体的负责执行游戏相关逻辑的服务 器。这样的游戏逻辑分为两大类:一类是通用的游戏房间逻辑,如:进入房间,离开房间,进入桌子,离开桌子以及在房间内说话等;第二类是游戏桌子逻辑,这个 就是各种不同类型游戏的主要区别之处了,比如斗地主中的叫地主或不叫地主的逻辑等,当然,游戏桌子逻辑里也包括有通用的各个游戏里都存在的游戏逻辑,比如 在桌子内说话等。总之,游戏房间服务器才是真正负责执行游戏具体逻辑的服务器。
这里提到的三类服务器,我均采用的是完成端口模型,每个服务器最多连接数目是5000人,但是,我在游戏房间服务器上作了逻辑层的限定,最多只允许 300人同时在线。其他两个服务器仍然允许最多5000人的同时在线。如果按照这样的结构来设计,那么要实现百万人的同时在线就应该是这样:首先是大 厅,1000000/5000=200。也就是说,至少要200台大厅服务器,但通常情况下,考虑到实际使用时服务器的处理能力和负载情况,应该至少准备 250台左右的大厅服务器程序。另外,具体的各种类型的游戏房间服务器需要多少,就要根据当前玩各种类型游戏的玩家数目分别计算了,比如斗地主最多是十万 人同时在线,每台服务器最多允许300人同时在线,那么需要的斗地主服务器数目就应该不少于:100000/300=333,准备得充分一点,就要准备 350台斗地主服务器。
除正常的玩家连接外,还要考虑到:
对于登陆服务器,会有250台大厅服务器连接到每个登陆服务器上,这是始终都要保持的连接;
而对于大厅服务器而言,如果仅仅有斗地主这一类的服务器,就要有350多个连接与各个大厅服务器始终保持着。所以从这一点看,我的结构在某些方面还存在着需要改进的地方,但核心思想是:尽快地提供用户登陆的速度,尽可能方便地让玩家进入游戏中。
本文作者:sodme
本文出处:http://blog.csdn.net/sodme
声明:本文可以不经作者同意任意转载、复制、引用。但任何对本文的引用,均须注明本文的作者、出处以及本行声明信息。
之前,我分析过QQ游戏(特指QQ休闲平台,并非QQ堂,下同)的通信架构(http://blog.csdn.net/sodme/archive/2005/06/12/393165.aspx),分析过魔兽世界的通信架构(http://blog.csdn.net/sodme/archive/2005/06/18/397371.aspx), 似乎网络游戏的通信架构也就是这些了,其实不然,在网络游戏大家庭中,还有一种类型的游戏我认为有必要把它的通信架构专门作个介绍,这便是如泡泡堂、QQ 堂类的休闲类竞技游戏。曾经很多次,被网友们要求能抽时间看看泡泡堂之类游戏的通信架构,这次由于被逼交作业,所以今晚抽了一点的时间截了一下泡泡堂的 包,正巧昨日与网友就泡泡堂类游戏的通信架构有过一番讨论,于是,将这两天的讨论、截包及思考总结于本文中,希望能对关心或者正在开发此类游戏的朋友有所 帮助,如果要讨论具体的技术细节,请到我的BLOG(http://blog.csdn.net/sodme)加我的MSN讨论..
总体来说,泡泡堂类游戏(此下简称泡泡堂)在大厅到房间这一层的通信架构,其结构与QQ游戏相当,甚至要比QQ游戏来得简单。所以,在房间这一层的通信架构上,我不想过多讨论,不清楚的朋友请参看我对QQ游戏通信架构的分析文章(http://blog.csdn.net/sodme/archive/2005/06/12/393165.aspx)。可以这么说,如果采用与QQ游戏相同的房间和大厅架构,是完全可以组建起一套可扩展的支持百万人在线的游戏系统的。也就是说,通过负载均衡+大厅+游戏房间对游戏逻辑的分摊,完全可以实现一个可扩展的百万人在线泡泡堂。
但是,泡泡堂与斗地主的最大不同点在于:泡泡堂对于实时性要求特别高。那么,泡泡堂是如何解决实时性与网络延迟以及大用户量之间矛盾的呢?
阅读以下文字前,请确认你已经完全理解TCP与UDP之间的不同点。
我们知道,TCP与UDP之间的最大不同点在于:TCP是可靠连接的,而UDP是无连接的。如果通信双方使用TCP协议,那么他们之前必须事先 通过监听+连接的方式将双方的通信管道建立起来;而如果通信双方使用的是UDP通信,则双方不用事先建立连接,发送方只管向目标地址上的目标端口发送 UDP包即可,不用管对方到底收没收到。如果要说形象点,可以用这样一句话概括:TCP是打电话,UDP是发电报。TCP通信,为了保持这样的可靠连接, 在可靠性上下了很多功夫,所以导致了它的通信效率要比UDP差很多,所以,一般地,在地实时性要求非常高的场合,会选择使用UDP协议,比如常见的动作射 击类游戏。
通过载包,我们发现泡泡堂中同时采用了TCP和UDP两种通信协议。并且,具有以下特点:
1.当玩家未进入具体的游戏地图时,仅有TCP通信存在,而没有UDP通信;
2.进入游戏地图后,TCP的通信量远远小于UDP的通信量
3.UDP的通信IP个数,与房间内的玩家成一一对应关系(这一点,应网友疑惑而加,此前已经证实)
以上是几个表面现象,下面我们来分析它的本质和内在。^&^
泡泡堂的游戏逻辑,简单地可以归纳为以下几个方面:
1.玩家移动
2.玩家埋地雷(如果你觉得这种叫法比较土,你也可以叫它:下泡泡,呵呵)
3.地雷爆炸出道具或者地雷爆炸困住另一玩家
4.玩家捡道具或者玩家消灭/解救一被困的玩家
与MMORPG一样,在上面的几个逻辑中,广播量最大的其实是玩家移动。为了保持玩家画面同步,其他玩家的每一步移动消息都要即时地发给其它玩家。
通常,网络游戏的逻辑控制,绝大多数是在服务器端的。有时,为了保证画面的流畅性,我们会有意识地减少服务器端的逻辑判断量和广播量,当然,这 个减少,是以“不危及游戏的安全运行”为前提的。到底如何在效率、流畅性和安全性之间作取舍,很多时候是需要经验积累的,效率提高的过程,就是逻辑不断优 化的过程。不过,有一个原则是可以说的,那就是:“关键逻辑”一定要放在服务器上来判断。那么,什么是“关键逻辑”呢?
拿泡泡堂来说,下面的这个逻辑,我认为就是关键逻辑:玩家在某处埋下一颗地雷,地雷爆炸后到底能不能炸出道具以及炸出了哪些道具,这个信息,需要服务器来给。那么,什么又是“非关键逻辑”呢?
“非关键逻辑”,在不同的游戏中,会有不同的概念。在通常的MMORPG中,玩家移动逻辑的判断,是算作关键逻辑的,否则,如果服务器端不对客 户端发过来的移动包进行判断那就很容易造成玩家的瞬移以及其它毁灭性的灾难。而在泡泡堂中,玩家移动逻辑到底应不应该算作关键逻辑还是值得考虑的。泡泡堂 中的玩家可以取胜的方法,通常是确实因为打得好而赢得胜利,不会因为瞬移而赢得胜利,因为如果外挂要作泡泡堂的瞬移,它需要考虑的因素和判断的逻辑太多 了,由于比赛进程的瞬息万变,外挂的瞬移点判断不一定就比真正的玩家来得准确,所在,在玩家移动这个逻辑上使用外挂,在泡泡堂这样的游戏中通常是得不偿失 的(当然,那种特别变态的高智能的外挂除外)。从目前我查到的消息来看,泡泡堂的外挂多数是一些按键精灵脚本,它的本质还不是完全的游戏机器人,并不是通 过纯粹的协议接管实现的外挂功能。这也从反面验证了我以上的想法。
说到这里,也许你已经明白了。是的!TCP通信负责“关键逻辑”,而UDP通信负责“非关键逻辑”,这里的“非关键逻辑”中就包含了玩家移动。 在泡泡堂中,TCP通信用于本地玩家与服务器之间的通信,而UDP则用于本地玩家与同一地图中的其他各玩家的通信。当本地玩家要移动时,它会同时向同一地 图内的所有玩家广播自己的移动消息,其他玩家收到这个消息后会更新自己的游戏画面以实现画面同步。而当本地玩家要在地图上放置一个炸弹时,本地玩家需要将 此消息同时通知同一地图内的其他玩家以及服务器,甚至这里,可以不把放置炸弹的消息通知给服务器,而仅仅通知其他玩家。当炸弹爆炸后,要拾取物品时才向服 务器提交拾取物品的消息。
那么,你可能会问,“地图上某一点是否存在道具”这个消息,服务器是什么时候通知给客户端的呢?这个问题,可以有两种解决方案:
1.客户端如果在放置炸弹时,将放置炸弹的消息通知给服务器,服务器可以在收到这个消息后,告诉客户端炸弹爆炸后会有哪些道具。但我觉得这种方案不好,因为这样作会增加游戏运行过程中的数据流量。
2.而这第2种方案就是,客户端进入地图后,游戏刚开始时,就由服务器将本地图内的各道具所在点的信息传给各客户端,这样,可以省去两方面的开 销:a.客户端放炸弹时,可以不通知服务器而只通知其它玩家;b.服务器也不用在游戏运行过程中再向客户端传递有关某点有道具的信息。
但是,不管采用哪种方案,服务器上都应该保留一份本地图内道具所在点的信息。因为服务器要用它来验证一个关键逻辑:玩家拾取道具。当玩家要在某点拾取道具时,服务器必须要判定此点是否有道具,否则,外挂可以通过频繁地发拾取道具的包而不断取得道具。
至于泡泡堂其它游戏逻辑的实现方法,我想,还是要依靠这个原则:首先判断这个逻辑是关键逻辑吗?如果不全是,那其中的哪部分是非关键逻辑呢?对 于非关键逻辑,都可以交由客户端之间(UDP)去自行完成。而对于关键逻辑,则必须要有服务器(TCP)的校验和认证。这便是我要说的。
以上仅仅是在理论上探讨关于泡泡堂类游戏在通信架构上的可能作法,这些想法是没有事实依据的,所有结论皆来源于对封包的分析以及个人经验,文章 的内容和观点可能跟真实的泡泡堂通信架构实现有相当大的差异,但我想,这并不是主要的,因为我的目的是向大家介绍这样的TCP和UDP通信并存情况下,如 何对游戏逻辑的进行取舍和划分。无论是“关键逻辑”的定性,还是“玩家移动”的具体实施,都需要开发者在具体的实践中进行总结和优化。此文全当是一个引子 罢,如有疑问,请加Msn讨论。
本文作者:sodme
本文出处:http://blog.csdn.net/sodme
声明:本文可以不经作者同意任意转载,但任何对本文的引用都须注明作者、出处及此声明信息。谢谢!!
要了解此篇文章中引用的本人写的另一篇文章,请到以下地址:
http://blog.csdn.net/sodme/archive/2004/12/12/213995.aspx
以上的这篇文章是早在去年的时候写的了,当时正在作休闲平台,一直在想着如何实现一个可扩充的支持百万人在线的游戏平台,后来思路有了,就写了那篇总结。文章的意思,重点在于阐述一个百万级在线的系统是如何实施的,倒没真正认真地考察过QQ游戏到底是不是那样实现的。
近日在与业内人士讨论时,提到QQ游戏的实现方式并不是我原来所想的那样,于是,今天又认真抓了一下QQ游戏的包,结果确如这位兄弟所言,QQ 游戏的架构与我当初所设想的那个架构相差确实不小。下面,我重新给出QQ百万级在线的技术实现方案,并以此展开,谈谈大型在线系统中的负载均衡机制的设 计。
从QQ游戏的登录及游戏过程来看,QQ游戏中,也至少分为三类服务器。它们是:
第一层:登陆/账号服务器(Login Server),负责验证用户身份、向客户端传送初始信息,从QQ聊天软件的封包常识来看,这些初始信息可能包括“会话密钥”此类的信息,以后客户端与后续服务器的通信就使用此会话密钥进行身份验证和信息加密;
第二层:大厅服务器(估且这么叫吧, Game Hall Server),负责向客户端传递当前游戏中的所有房间信息,这些房间信息包括:各房间的连接IP,PORT,各房间的当前在线人数,房间名称等等。
第三层:游戏逻辑服务器(Game Logic Server),负责处理房间逻辑及房间内的桌子逻辑。
从静态的表述来看,以上的三层结构似乎与我以前写的那篇文章相比并没有太大的区别,事实上,重点是它的工作流程,QQ游戏的通信流程与我以前的设想可谓大相径庭,其设计思想和技术水平确实非常优秀。具体来说,QQ游戏的通信过程是这样的:
1.由Client向Login Server发送账号及密码等登录消息,Login Server根据校验结果返回相应信息。可以设想的是,如果Login Server通过了Client的验证,那么它会通知其它Game Hall Server或将通过验证的消息以及会话密钥放在Game Hall Server也可以取到的地方。总之,Login Server与Game Hall Server之间是可以共享这个校验成功消息的。一旦Client收到了Login Server返回成功校验的消息后,Login Server会主动断开与Client的连接,以腾出socket资源。Login Server的IP信息,是存放在QQGame\config\QQSvrInfo.ini里的。
2.Client收到Login Server的校验成功等消息后,开始根据事先选定的游戏大厅入口登录游戏大厅,各个游戏大厅Game Hall Server的IP及Port信息,是存放在QQGame\Dirconfig.ini里的。Game Hall Server收到客户端Client的登录消息后,会根据一定的策略决定是否接受Client的登录,如果当前的Game Hall Server已经到了上限或暂时不能处理当前玩家登录消息,则由Game Hall Server发消息给Client,以让Client重定向到另外的Game Hall Server登录。重定向的IP及端口信息,本地没有保存,是通过数据包或一定的算法得到的。如果当前的Game Hall Server接受了该玩家的登录消息后,会向该Client发送房间目录信息,这些信息的内容我上面已经提到。目录等消息发送完毕后,Game Hall Server即断开与Client的连接,以腾出socket资源。在此后的时间里,Client每隔30分钟会重新连接Game Hall Server并向其索要最新的房间目录信息及在线人数信息。
3.Client根据列出的房间列表,选择某个房间进入游戏。根据我的抓包结果分析,QQ游戏,并不是给每一个游戏房间都分配了一个单独的端口 进行处理。在QQ游戏里,有很多房间是共用的同一个IP和同一个端口。比如,在斗地主一区,前50个房间,用的都是同一个IP和Port信息。这意味着, 这些房间,在QQ游戏的服务器上,事实上,可能是同一个程序在处理!!!QQ游戏房间的人数上限是400人,不难推算,QQ游戏单个服务器程序的用户承载 量是2万,即QQ的一个游戏逻辑服务器程序最多可同时与2万个玩家保持TCP连接并保证游戏效率和品质,更重要的是,这样可以为腾讯省多少money 呀!!!哇哦!QQ确实很牛。以2万的在线数还能保持这么好的游戏品质,确实不容易!QQ游戏的单个服务器程序,管理的不再只是逻辑意义上的单个房间,而 可能是许多逻辑意义上的房间。其实,对于服务器而言,它就是一个大区服务器或大区服务器的一部分,我们可以把它理解为一个庞大的游戏地图,它实现的也是分 块处理。而对于每一张桌子上的打牌逻辑,则是有一个统一的处理流程,50个房间的50*100张桌子全由这一个服务器程序进行处理(我不知道QQ游戏的具 体打牌逻辑是如何设计的,我想很有可能也是分区域的,分块的)。当然,以上这些只是服务器作的事,针对于客户端而言,客户端只是在表现上,将一个个房间单 独罗列了出来,这样作,是为便于玩家进行游戏以及减少服务器的开销,把这个大区中的每400人放在一个集合内进行处理(比如聊天信息,“向400人广播” 和“向2万人广播”,这是完全不同的两个概念)。
4.需要特别说明的一点。进入QQ游戏房间后,直到点击某个位置坐下打开另一个程序界面,客户端的程序,没有再创建新的socket,而仍然使 用原来大厅房间客户端跟游戏逻辑服务器交互用的socket。也就是说,这是两个进程共用的同一个socket!不要小看这一点。如果你在创建桌子客户端 程序后又新建了一个新的socket与游戏逻辑服务器进行通信,那么由此带来的玩家进入、退出、逃跑等消息会带来非常麻烦的数据同步问题,俺在刚开始的时 候就深受其害。而一旦共用了同一个socket后,你如果退出桌子,服务器不涉及释放socket的问题,所以,这里就少了很多的数据同步问题。关于多个 进程如何共享同一个socket的问题,请去google以下内容:WSADuplicateSocket。
以上便是我根据最新的QQ游戏抓包结果分析得到的QQ游戏的通信流程,当然,这个流程更多的是客户端如何与服务器之间交互的,却没有涉及到服务器彼此之间是如何通信和作数据同步的。关于服务器之间的通信流程,我们只能基于自己的经验和猜想,得出以下想法:
1.Login Server与Game Hall Server之前的通信问题。Login Server是负责用户验证的,一旦验证通过之后,它要设法让Game Hall Server知道这个消息。它们之前实现信息交流的途径,我想可能有这样几条:a. Login Server将通过验证的用户存放到临时数据库中;b. Login Server将验证通过的用户存放在内存中,当然,这个信息,应该是全局可访问的,就是说所有QQ的Game Hall Server都可以通过服务器之间的数据包通信去获得这样的信息。
2.Game Hall Server的最新房间目录信息的取得。这个信息,是全局的,也就是整个游戏中,只保留一个目录。它的信息来源,可以由底层的房间服务器逐级报上来,报给谁?我认为就如保存的全局登录列表一样,它报给保存全局登录列表的那个服务器或数据库。
3.在QQ游戏中,同一类型的游戏,无法打开两上以上的游戏房间。这个信息的判定,可以根据全局信息来判定。
以上关于服务器之间如何通信的内容,均属于个人猜想,QQ到底怎么作的,恐怕只有等大家中的某一位进了腾讯之后才知道了。呵呵。不过,有一点是 可以肯定的,在整个服务器架构中,应该有一个地方是专门保存了全局的登录玩家列表,只有这样才能保证玩家不会重复登录以及进入多个相同类型的房间。
在前面的描述中,我曾经提到过一个问题:当登录当前Game Hall Server不成功时,QQ游戏服务器会选择让客户端重定向到另位的服务器去登录,事实上,QQ聊天服务器和MSN服务器的登录也是类似的,它也存在登录重定向问题。
那么,这就引出了另外的问题,由谁来作这个策略选择?以及由谁来提供这样的选择资源?这样的处理,便是负责负载均衡的服务器的处理范围了。由QQ游戏的通信过程分析派生出来的针对负责均衡及百万级在线系统的更进一步讨论,将在下篇文章中继续。
在此,特别感谢网友tilly及某位不便透露姓名的网友的讨论,是你们让我决定认真再抓一次包探个究竟。
项目开发的基本硬件配置
一台普通的pc就可以了,
安装好windows 2000和vc6就可以了,
然后连上网,局域网和internet都可以,
接下去的东西我都简化,不去用晦涩的术语,
既然是网络,我们就需要网络编程接口,
服务器端我们用的是winsock 1.1,使用tcp连接方式,
[tcp和udp]
tcp可以理解为一条连接两个端子的隧道,提供可靠的数据传输服务,
只要发送信息的一方成功的调用了tcp的发送函数发送一段数据,
我们可以认为接收方在若干时间以后一定会接收到完整正确的数据,
不需要去关心网络传输上的细节,
而udp不保证这一点,
对于网络游戏来说,tcp是普遍的选择。
[阻塞和非阻塞]
在通过socket发送数据时,如果直到数据发送完毕才返回的方式,也就是说如果我们使用send( buffer, 100.....)这样的函数发送100个字节给别人,我们要等待,直到100个自己发送完毕,程序才往下走,这样就是阻塞的,
而非阻塞的方式,当你调用send(buffer,100....)以后,立即返回,此时send函数告诉你发送成功,并不意味着数据已经向目的地发送完 毕,甚至有可能数据还没有开始发送,只被保留在系统的缓冲里面,等待被发送,但是你可以认为数据在若干时间后,一定会被目的地完整正确的收到,我们要充分 的相信tcp。
阻塞的方式会引起系统的停顿,一般网络游戏里面使用的都是非阻塞的方式,
[有状态服务器和无状态服务器]
在c/s体系中,如果server不保存客户端的状态,称之为无状态,反之为有状态,
在这里要强调一点,
我们所说的服务器不是一台具体的机器,
而是指服务器应用程序,
一台具体的机器或者机器群组可以运行一个或者多个服务器应用程序,
我们的网络游戏使用的是有状态服务器,
保存所有玩家的数据和状态,
[开发语言]
vc6
我们首先要熟练的掌握一门开发语言,
学习c++是非常有必要的,
而vc是windows下面的软件开发工具,
为什么选择vc,可能与我本身使用vc有关,
而且网上可以找到许多相关的资源和源代码,
[操作系统]
我们使用windows2000作为服务器的运行环境,
所以我们有必要去了解windows是如何工作的,
同时对它的编程原理应该熟练的掌握
[数据结构和算法]
要写出好的程序要先具有设计出好的数据结构和算法的能力,
好的算法未必是繁琐的公式和复杂的代码,
我们要找到又好写有满足需求的算法,
有时候,最笨的方法同时也是很好的方法,
很多程序员沉迷于追求精妙的算法而忽略了宏观上的工程,
花费了大量的精力未必能够取得好的效果,
举个例子,
我当年进入游戏界工作,学习老师的代码,
发现有个函数,要对画面中的npc位置进行排序,
确定哪个先画,那个后画,
他的方法太“笨”,
任何人都会想到的冒泡,
一个一个去比较,没有任何的优化,
我当时想到的算法就有很多,
而且有一大堆优化策略,
可是,当我花了很长时间去实现我的算法时,
发现提升的那么一点效率对游戏整个运行效率而言几乎是没起到什么作用,
或者说虽然算法本身快了几倍,
可是那是多余的,老师的算法虽然“笨”,
可是他只花了几十行代码就搞定了,
他的时间花在别的更需要的地方,
这就是他可以独自完成一个游戏,
而我可以把一个函数优化100倍也只能打杂的原因
[tcp/ip的理论]
推荐数据用tcp/ip进行网际互连,tcp/ip详解,
这是两套书,共有6卷,
都是国外的大师写的,
可以说是必读的,
[消息]
消息是个很常见的术语,
在windows中,消息机制是个十分重要的概念,
我们在网络游戏中,也使用了消息这样的机制,
一般我们这么做,
一个数据块,头4个字节是消息名,后面接2个字节的数据长度,
再后面就是实际的数据
为什么使用消息??
我们来看看例子,
在游戏世界,
一个玩家想要和别的玩家聊天,
那么,他输入好聊天信息,
客户端生成一条聊天消息,
并把聊天的内容打包到消息中,
然后把聊天消息发送给服务器,
请求服务器把聊天信息发送给另一个玩家,
服务器接收到一条消息,
此刻,服务器并不知道当前的数据是什么东西,
对于服务器来讲,这段数据仅仅来自于网络通讯的底层,
不加以分析的话,没有任何的信息,
因为我们的通讯是基于消息机制的,
我们认为服务器接收到的任何数据都是基于消息的数据方式组织的,
4个字节消息名,2字节长度,这个是不会变的,
通过消息名,服务器发现当前数据是一条聊天数据,
通过长度把需要的数据还原,校验,
然后把这条消息发送给另一个玩家,
大家注意,消息是变长的,
关于消息的解释完全在于服务器和客户端的应用程序,
可以认为与网络传输低层无关,
比如一条私聊消息可能是这样的,
MsgID:4 byte
Length:2 byte
TargetPlayerID:2 byte
String:anybyte < 256
一条移动消息可能是这样的,
MsgID:4 byte
Length:2 byte
TargetPlayerID:2 byte
TargetPosition:4 byte (x,y)
编程者可以自定义消息的内容以满足不同的需求
队列
[队列]
队列是一个很重要的数据结构,
比如说消息队列,
服务器或者客户端,
发送的消息不一定是立即发送的,
而是等待一个适当时间,
或者系统规定的时间间隔以后才发送,
这样就需要创建一个消息队列,以保存发送的消息,
消息队列的大小可以按照实际的需求创建,
队列又可能会满,
当队列满了,可以直接丢弃消息,
如果你觉得这样不妥,
也可以预先划分一个足够大的队列,
可以使用一个系统全局的大的消息队列,
也可以为每个对象创建一个消息队列,
这个我们的一个数据队列的实现,
开发工具vc.net,使用了C++的模板,
关于队列的算法和基础知识,我就不多说了,
DataBuffer.h
#ifndef __DATABUFFER_H__
#define __DATABUFFER_H__
#include
#include
#include "g_assert.h"
#include
#ifndef HAVE_BYTE
typedef unsigned char byte;
#endif // HAVE_BYTE
//数据队列管理类
template
class DataBufferTPL
{
public:
bool Add( byte *data ) // 加入队列数据
{
G_ASSERT_RET( data, false );
m_ControlStatus = false;
if( IsFull() )
{
//assert( false );
return false;
}
memcpy( m_s_ptr, data, _max_size );
NextSptr();
m_NumData++;
m_ControlStatus = true;
return true;
}
bool Get( byte *data ) // 从队列中取出数据
{
G_ASSERT_RET( data, false );
m_ControlStatus = false;
if( IsNull() )
return false;
memcpy( data, m_e_ptr, _max_size );
NextEptr();
m_NumData--;
m_ControlStatus = true;
return true;
}
bool CtrlStatus() // 获取操作成功结果
{
return m_ControlStatus;
}
int GetNumber() // 获得现在的数据大小
{
return m_NumData;
}
public:
DataBufferTPL()
{
m_NumData = 0;
m_start_ptr = m_DataTeam[0];
m_end_ptr = m_DataTeam[_max_line-1];
m_s_ptr = m_start_ptr;
m_e_ptr = m_start_ptr;
}
~DataBufferTPL()
{
m_NumData = 0;
m_s_ptr = m_start_ptr;
m_e_ptr = m_start_ptr;
}
private:
bool IsFull() // 是否队列满
{
G_ASSERT_RET( m_NumData >=0 && m_NumData <= _max_line, false );
if( m_NumData == _max_line )
return true;
else
return false;
}
bool IsNull() // 是否队列空
{
G_ASSERT_RET( m_NumData >=0 && m_NumData <= _max_line, false );
if( m_NumData == 0 )
return true;
else
return false;
}
void NextSptr() // 头位置增加
{
assert(m_start_ptr);
assert(m_end_ptr);
assert(m_s_ptr);
assert(m_e_ptr);
m_s_ptr += _max_size;
if( m_s_ptr > m_end_ptr )
m_s_ptr = m_start_ptr;
}
void NextEptr() // 尾位置增加
{
assert(m_start_ptr);
assert(m_end_ptr);
assert(m_s_ptr);
assert(m_e_ptr);
m_e_ptr += _max_size;
if( m_e_ptr > m_end_ptr )
m_e_ptr = m_start_ptr;
}
private:
byte m_DataTeam[_max_line][_max_size]; //数据缓冲
int m_NumData; //数据个数
bool m_ControlStatus; //操作结果
byte *m_start_ptr; //起始位置
byte *m_end_ptr; //结束位置
byte *m_s_ptr; //排队起始位置
byte *m_e_ptr; //排队结束位置
};
//////////////////////////////////////////////////////////////////////////
// 放到这里了!
//ID自动补位列表模板,用于自动列表,无间空顺序列表。
template
class IDListTPL
{
public:
// 清除重置
void Reset()
{
for(int i=0;i<_max_count;i++)
m_dwList[i] = G_ERROR;
m_counter = 0;
}
int MaxSize() const { return _max_count; }
int Count() const { return m_counter; }
const DWORD operator[]( int iIndex ) {
G_ASSERTN( iIndex >= 0 && iIndex < m_counter );
return m_dwList[ iIndex ];
}
bool New( DWORD dwID )
{
G_ASSERT_RET( m_counter >= 0 && m_counter < _max_count, false );
//ID 唯一性,不能存在相同ID
if ( Find( dwID ) != -1 )
return false;
m_dwList[m_counter] = dwID;
m_counter++;
return true;
}
// 没有Assert的加入ID功能
bool Add( DWORD dwID )
{
if( m_counter <0 || m_counter >= _max_count )
return false;
//ID 唯一性,不能存在相同ID
if ( Find( dwID ) != -1 )
return false;
m_dwList[m_counter] = dwID;
m_counter++;
return true;
}
bool Del( int iIndex )
{
G_ASSERT_RET( iIndex >=0 && iIndex < m_counter, false );
for(int k=iIndex;k
m_dwList[k] = m_dwList[k+1];
}
m_dwList[k] = G_ERROR;
m_counter--;
return true;
}
int Find( DWORD dwID )
{
for(int i=0;i
if( m_dwList[i] == dwID )
return i;
}
return -1;
}
IDListTPL():m_counter(0)
{
for(int i=0;i<_max_count;i++)
m_dwList[i] = G_ERROR;
}
virtual ~IDListTPL()
{}
private:
DWORD m_dwList[_max_count];
int m_counter;
};
//////////////////////////////////////////////////////////////////////////
#endif //__DATABUFFER_H__
我们采用winsock作为网络部分的编程接口,
接下去编程者有必要学习一下socket的基本知识,
不过不懂也没有关系,我提供的代码已经把那些麻烦的细节或者正确的系统设置给弄好了,
编程者只需要按照规则编写游戏系统的处理代码就可以了,
这些代码在vc6下编译通过,
是通用的网络传输底层,
这里是socket部分的代码,
我们需要安装vc6才能够编译以下的代码,
因为接下去我们要接触越来越多的c++,
所以,大家还是去看看c++的书吧,
// socket.h
#ifndef _socket_h
#define _socket_h
#pragma once
//定义最大连接用户数目 ( 最大支持 512 个客户连接 )
#define MAX_CLIENTS 512
//#define FD_SETSIZE MAX_CLIENTS
#pragma comment( lib, "wsock32.lib" )
#include
class CSocketCtrl
{
void SetDefaultOpt();
public:
CSocketCtrl(): m_sockfd(INVALID_SOCKET){}
BOOL StartUp();
BOOL ShutDown();
BOOL IsIPsChange();
BOOL CanWrite();
BOOL HasData();
int Recv( char* pBuffer, int nSize, int nFlag );
int Send( char* pBuffer, int nSize, int nFlag );
BOOL Create( UINT uPort );
BOOL Create(void);
BOOL Connect( LPCTSTR lpszHostAddress, UINT nHostPort );
void Close();
BOOL Listen( int nBackLog );
BOOL Accept( CSocketCtrl& sockCtrl );
BOOL RecvMsg( char *sBuf );
int SendMsg( char *sBuf,unsigned short stSize );
SOCKET GetSockfd(){ return m_sockfd; }
BOOL GetHostName( char szHostName[], int nNameLength );
protected:
SOCKET m_sockfd;
static DWORD m_dwConnectOut;
static DWORD m_dwReadOut;
static DWORD m_dwWriteOut;
static DWORD m_dwAcceptOut;
static DWORD m_dwReadByte;
static DWORD m_dwWriteByte;
};
#endif
// socket.cpp
#include
#include "msgdef.h"
#include "socket.h"
// 吊线时间
#define ALL_TIMEOUT 120000
DWORD CSocketCtrl::m_dwConnectOut = 60000;
DWORD CSocketCtrl::m_dwReadOut = ALL_TIMEOUT;
DWORD CSocketCtrl::m_dwWriteOut = ALL_TIMEOUT;
DWORD CSocketCtrl::m_dwAcceptOut = ALL_TIMEOUT;
DWORD CSocketCtrl::m_dwReadByte = 0;
DWORD CSocketCtrl::m_dwWriteByte = 0;
// 接收数据
BOOL CSocketCtrl::RecvMsg( char *sBuf )
{
if( !HasData() )
return FALSE;
MsgHeader header;
int nbRead = this->Recv( (char*)&header, sizeof( header ), MSG_PEEK );
if( nbRead == SOCKET_ERROR )
return FALSE;
if( nbRead < sizeof( header ) )
{
this->Recv( (char*)&header, nbRead, 0 );
printf( "\ninvalid msg, skip %ld bytes.", nbRead );
return FALSE;
}
if( this->Recv( (char*)sBuf, header.stLength, 0 ) != header.stLength )
return FALSE;
return TRUE;
}
// 发送数据
int CSocketCtrl::SendMsg( char *sBuf,unsigned short stSize )
{
static char sSendBuf[ 4000 ];
memcpy( sSendBuf,&stSize,sizeof(short) );
memcpy( sSendBuf + sizeof(short),sBuf,stSize );
if( (sizeof(short) + stSize) != this->Send( sSendBuf,stSize+sizeof(short),0 ) )
return -1;
return stSize;
}
// 启动winsock
BOOL CSocketCtrl::StartUp()
{
WSADATA wsaData;
WORD wVersionRequested = MAKEWORD( 1, 1 );
int err = WSAStartup( wVersionRequested, &wsaData );
if ( err != 0 )
{
return FALSE;
}
return TRUE;
}
// 关闭winsock
BOOL CSocketCtrl::ShutDown()
{
WSACleanup();
return TRUE;
}
// 得到主机名
BOOL CSocketCtrl::GetHostName( char szHostName[], int nNameLength )
{
if( gethostname( szHostName, nNameLength ) != SOCKET_ERROR )
return TRUE;
return FALSE;
}
BOOL CSocketCtrl::IsIPsChange()
{
return FALSE;
static int iIPNum = 0;
char sHost[300];
hostent *pHost;
if( gethostname(sHost,299) != 0 )
return FALSE;
pHost = gethostbyname(sHost);
int i;
char *psHost;
i = 0;
do
{
psHost = pHost->h_addr_list[i++];
if( psHost == 0 )
break;
}while(1);
if( iIPNum != i )
{
iIPNum = i;
return TRUE;
}
return FALSE;
}
// socket是否可以写
BOOL CSocketCtrl::CanWrite()
{
int e;
fd_set set;
timeval tout;
tout.tv_sec = 0;
tout.tv_usec = 0;
FD_ZERO(&set);
FD_SET(m_sockfd,&set);
e=::select(0,NULL,&set,NULL,&tout);
if(e==SOCKET_ERROR) return FALSE;
if(e>0) return TRUE;
return FALSE;
}
// socket是否有数据
BOOL CSocketCtrl::HasData()
{
int e;
fd_set set;
timeval tout;
tout.tv_sec = 0;
tout.tv_usec = 0;
FD_ZERO(&set);
FD_SET(m_sockfd,&set);
e=::select(0,&set,NULL,NULL,&tout);
if(e==SOCKET_ERROR) return FALSE;
if(e>0) return TRUE;
return FALSE;
}
int CSocketCtrl::Recv( char* pBuffer, int nSize, int nFlag )
{
return recv( m_sockfd, pBuffer, nSize, nFlag );
}
int CSocketCtrl::Send( char* pBuffer, int nSize, int nFlag )
{
return send( m_sockfd, pBuffer, nSize, nFlag );
}
BOOL CSocketCtrl::Create( UINT uPort )
{
m_sockfd=::socket(PF_INET,SOCK_STREAM,0);
if(m_sockfd==INVALID_SOCKET) return FALSE;
SOCKADDR_IN SockAddr;
memset(&SockAddr,0,sizeof(SockAddr));
SockAddr.sin_family = AF_INET;
SockAddr.sin_addr.s_addr = INADDR_ANY;
SockAddr.sin_port = ::htons( uPort );
if(!::bind(m_sockfd,(SOCKADDR*)&SockAddr, sizeof(SockAddr)))
{
SetDefaultOpt();
return TRUE;
}
Close();
return FALSE;
}
void CSocketCtrl::Close()
{
::closesocket( m_sockfd );
m_sockfd = INVALID_SOCKET;
}
BOOL CSocketCtrl::Connect( LPCTSTR lpszHostAddress, UINT nHostPort )
{
if(m_sockfd==INVALID_SOCKET) return FALSE;
SOCKADDR_IN sockAddr;
memset(&sockAddr,0,sizeof(sockAddr));
LPSTR lpszAscii=(LPSTR)lpszHostAddress;
sockAddr.sin_family=AF_INET;
sockAddr.sin_addr.s_addr=inet_addr(lpszAscii);
if(sockAddr.sin_addr.s_addr==INADDR_NONE)
{
HOSTENT * lphost;
lphost = ::gethostbyname(lpszAscii);
if(lphost!=NULL)
sockAddr.sin_addr.s_addr = ((IN_ADDR *)lphost->h_addr)->s_addr;
else return FALSE;
}
sockAddr.sin_port = htons((u_short)nHostPort);
int r=::connect(m_sockfd,(SOCKADDR*)&sockAddr,sizeof(sockAddr));
if(r!=SOCKET_ERROR) return TRUE;
int e;
e=::WSAGetLastError();
if(e!=WSAEWOULDBLOCK) return FALSE;
fd_set set;
timeval tout;
tout.tv_sec = 0;
tout.tv_usec = 100000;
UINT n=0;
while( n< CSocketCtrl::m_dwConnectOut)
{
FD_ZERO(&set);
FD_SET(m_sockfd,&set);
e=::select(0,NULL,&set,NULL, &tout);
if(e==SOCKET_ERROR) return FALSE;
if(e>0) return TRUE;
if( IsIPsChange() )
return FALSE;
n += 100;
}
return FALSE;
}
// 设置监听socket
BOOL CSocketCtrl::Listen( int nBackLog )
{
if( m_sockfd == INVALID_SOCKET ) return FALSE;
if( !listen( m_sockfd, nBackLog) ) return TRUE;
return FALSE;
}
// 接收一个新的客户连接
BOOL CSocketCtrl::Accept( CSocketCtrl& ms )
{
if( m_sockfd == INVALID_SOCKET ) return FALSE;
if( ms.m_sockfd != INVALID_SOCKET ) return FALSE;
int e;
fd_set set;
timeval tout;
tout.tv_sec = 0;
tout.tv_usec = 100000;
UINT n=0;
while(n< CSocketCtrl::m_dwAcceptOut)
{
//if(stop) return FALSE;
FD_ZERO(&set);
FD_SET(m_sockfd,&set);
e=::select(0,&set,NULL,NULL, &tout);
if(e==SOCKET_ERROR) return FALSE;
if(e==1) break;
n += 100;
}
if( n>= CSocketCtrl::m_dwAcceptOut ) return FALSE;
ms.m_sockfd=accept(m_sockfd,NULL,NULL);
if(ms.m_sockfd==INVALID_SOCKET) return FALSE;
ms.SetDefaultOpt();
return TRUE;
}
BOOL CSocketCtrl::Create(void)
{
m_sockfd=::socket(PF_INET,SOCK_STREAM,0);
if(m_sockfd==INVALID_SOCKET) return FALSE;
SOCKADDR_IN SockAddr;
memset(&SockAddr,0,sizeof(SockAddr));
SockAddr.sin_family = AF_INET;
SockAddr.sin_addr.s_addr = INADDR_ANY;
SockAddr.sin_port = ::htons(0);
//if(!::bind(m_sock,(SOCKADDR*)&SockAddr, sizeof(SockAddr)))
{
SetDefaultOpt();
return TRUE;
}
Close();
return FALSE;
}
// 设置正确的socket状态,
// 主要是主要是设置非阻塞异步传输模式
void CSocketCtrl::SetDefaultOpt()
{
struct linger ling;
ling.l_onoff=1;
ling.l_linger=0;
setsockopt( m_sockfd, SOL_SOCKET, SO_LINGER, (char *)&ling, sizeof(ling));
setsockopt( m_sockfd, SOL_SOCKET, SO_REUSEADDR, 0, 0);
int bKeepAlive = 1;
setsockopt( m_sockfd, SOL_SOCKET, SO_KEEPALIVE, (char*)&bKeepAlive, sizeof(int));
BOOL bNoDelay = TRUE;
setsockopt( m_sockfd, IPPROTO_TCP, TCP_NODELAY, (char*)&bNoDelay, sizeof(BOOL));
unsigned long nonblock=1;
::ioctlsocket(m_sockfd,FIONBIO,&nonblock);
}
今天晚上写了一些测试代码,
想看看flash究竟能够承受多大的网络数据传输,
我在flash登陆到服务器以后,
每隔3毫秒就发送100次100个字符的串 "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789" 给flash,
然后在flash里面接收数据的函数里面统计数据,
var g_nTotalRecvByte = 0;
var g_time = new Date();
var g_nStartTime = g_time.getTime();
var g_nCounter = 0;
mySocket.onData=function(xmlDoc)
{
g_nTotalRecvByte += xmlDoc.length;
// 每接收超过1k字节的数据,输出一次信息,
if( g_nTotalRecvByte-g_nCounter > 1024 )
{
g_time = new Date();
var nPassedTime = g_time.getTime()-g_nStartTime;
trace( "花费时间:"+nPassedTime+"毫秒" );
g_nCounter = g_nTotalRecvByte;
trace( "接收总数:"+g_nTotalRecvByte+"字节" );
trace( "接收速率:"+g_nTotalRecvByte*1000/nPassedTime+"字节/秒" );
}
结果十分令我意外,
这是截取的一段调试信息,
//
花费时间:6953毫秒
接收总数:343212字节
接收速率:49361.7143678988字节/秒
花费时间:7109毫秒
接收总数:344323字节
接收速率:48434.800956534字节/秒
花费时间:7109毫秒
接收总数:345434字节
接收速率:48591.0817273878字节/秒
。。。
。。。
。。。
。。。
花费时间:8125毫秒
接收总数:400984字节
接收速率:49351.8769230769字节/秒
花费时间:8125毫秒
接收总数:402095字节
接收速率:49488.6153846154字节/秒
花费时间:8125毫秒
接收总数:403206字节
接收速率:49625.3538461538字节/秒
我检查了几遍源程序,没有发现逻辑错误,
如果程序没有问题的话,
那么我们得出的结论是,flash的xml socket每秒可以接收至少40K的数据,
这还没有计算xmlSocket.onData事件的触发,调试代码、信息输出占用的时间。
比我想象中快了一个数量级,
够用了,
flash网络游戏我们可以继续往下走了,
有朋友问到lag的问题,
问得很好,不过也不要过于担心,
lag的产生有的是因为网络延迟,
有的是因为服务器负载过大,
对于游戏的设计者和开发者来说,
首先要从设计的角度来避免或者减少lag产生的机会,
如果lag产生了,
也不要紧,找到巧妙的办法骗过玩家的眼睛,
这也有很多成熟的方法了,
比如航行预测法,路径插值等等,
都可以产生很好的效果,
还有最后的绝招,就是提高服务器的配置和网络带宽,
从我开发网络游戏这段时间的经验来看,
我们的服务器是vc开发的,
普通pc跑几百个玩家,几百个怪物是没有问题的,
又作了一个flash发送的测试,
网络游戏的特点是,
出去的信息比较少,
进来的信息比较多,
这个很容易理解,
人操作游戏的速度是很有限的,
控制指令的产生也是随机的,
离散的,
但是多人游戏的话,
因为人多,信息的流量也就区域均匀分布了,
在昨天接收数据的基础上,
我略加修改,
这次,
我在_root.enterFrame写了如下代码,
_root.onEnterFrame = function()
{
var i;
for( i = 0; i < 10; i++ )
mySocket.send( ConvertToMsg( "01234567890123456789012345678901234567890123456789" ) );
return;
}
服务器端要做的是,
把所有从flash客户端收到的信息原封不动的返回来,
这样,我又可以通过昨天onData里面的统计算法来从侧面估算出flash发送数据的能力,
这里是输出的数据
//
花费时间:30531毫秒
接收总数:200236字节
接收速率:6558.44878975468字节/秒
花费时间:30937毫秒
接收总数:201290字节
接收速率:6506.44858906811字节/秒
花费时间:31140毫秒
接收总数:202344字节
接收速率:6497.88053949904字节/秒
花费时间:31547毫秒
接收总数:203398字节
接收速率:6447.45934637208字节/秒
可以看出来,发送+接收同时做,
发送速率至少可以达到5k byte/s
有一点要注意,要非常注意,
不能让flash的网络传输满载,
所谓满载就是flash在阻塞运算的时候,
不断的有数据从网络进来,
而flash又无法在预计的时间内处理我这些信息,
或者flash发送数据过于频繁,
导致服务器端缓冲溢出导致错误,
对于5k的传输速率,
已经足够了,
因为我也想不出来有什么产生这么大的数据量,
而且如果产生了这么大的数据量,
也就意味着服务器每时每刻都要处理所有的玩家发出的海量数据,
还要把这些海量数据转发给其他的玩家,
已经引起数据爆炸了,
所以,5k的上传从设计阶段就要避免的,
我想用flash做的网络游戏,
除了动作类游戏可能需要恒定1k以内的上传速率,
其他的200个字节/秒以内就可以了,
我们以前讨论过,
通过消息来传递信息,
消息的结构是
struct msg
{
short nLength; // 2 byte
DWORD dwId; // 4 byte
....
data
}
但是在为flash开发的消息中,
不能采用这种结构,
首先Flash xmlSocket只传输字符串,
从xmlSocket的send,onData函数可以看出来,
发出去的,收进来的都应该是字符串,
而在服务器端是使用vc,java等高级语言编写的,
消息中使用的是二进制数据块,
显然,简单的使用字符串会带来问题,
所以,我们需要制定一套协议,
就是无论在客户端还是服务器端,
都用统一的字符串消息,
通过解析字符串的方式来传递信息,
我想这就是flash采用xml document来传输结构化信息的理由之一,
xml document描述了一个完整的数据结构,
而且全部使用的是字符串,
原来是这样,怪不得叫做xml socket,
本来socket和xml完全是不同的概念,
flash偏偏出了个xml socket,
一开始令我费解,
现在,渐渐理解其中奥妙。
在服务器端,我们为flash定义了一种msg结构,
使用语言,vc6
#define MSGMAXSIZE 512
// 消息头
struct MsgHeader
{
short stLength;
MsgHeader():stLength( 0 ){}
};
// 消息
struct Msg
{
MsgHeader header;
short GetLength(){ return header.stLength; }
};
// flash 消息
struct MsgToFlashublic Msg
{
// 一个足够大的缓冲,但是不会被整个发送,
char szString[MSGMAXSIZE];
// 计算设置好内容后,内部会计算将要发送部分的长度,
// 要发送的长度=消息头大小+字符串长度+1
void SetString( const char* pszChatString )
{
if( strlen( pszChatString ) < MSGMAXSIZE-1 )
{
strcpy( szString, pszChatString );
header.stLength = sizeof( header )+
(short)strlen( pszChatString )+1;
}
}
};
在发往flash的消息中,整个处理过后MsgToFlash结构将被发送,
实践证明,在flash 客户端的xmlSocket onData事件中,
接收到了正确的消息,消息的内容是MasToFlash的szString字段,
是一个字符串,
比如在服务器端,
MsgToFlash msg;
msg.SetString( "move player0 to 100 100" );
SendMsg( msg,............. );
那么,在我们的flash客户端的onData( xmlDoc )中,
我们trace( xmlDoc )
结果是
move player0 to 100 100
然后是flash发送消息到服务器,
我们强调flash只发送字符串,
这个字符串无论是否内部拥有有效数据,
服务器都应该首先把消息收下来,
那就要保证发送给服务器的消息遵循统一的结构,
在flash客户端中,
我们定义一个函数,
这个函数把一个字符串转化为服务器可以识别的消息,
补充:现在我们约定字符串长度都不大于97个字节长度,
var num_table = new array( "0","1","2","3","4","5","6","7","8","9" );
function ConvertToMsg( str )
{
var l = str.length+3;
var t = "";
if( l > 10 )
t = num_table[Math.floor(l/10)]+num_table[Number(l%10)]+str;
else
t = num_table[0]+num_table[l]+str;
return t;
}
比如
var msg = ConvertToMsg( "client login" );
我们trace( msg );
看到的是
15client login
为什么是这个结果呢?
15是消息的长度,
头两个字节是整个消息的长度的asc码,意思是整个消息有15个字节长,
然后是信息client login,
最后是一个0(c语言中的字符串结束符)
当服务器收到15client login,
他首先把15给分析出来,
把"15"字符串转化为15的数字,
然后,根据15这个长度把后面的client login读出来,
这样,网络传输的底层就完成了,
client login的处理就交给逻辑层,
要想在修改游戏中做到百战百胜,是需要相当丰富的计算机知识的。有很多计算机高手就是从玩游戏,修改游戏中,逐步对计算机产生浓厚的兴趣,逐步成长起来 的。不要在羡慕别人能够做到的,因为别人能够做的你也能够!我相信你们看了本教程后,会对游戏有一个全新的认识,呵呵,因为我是个好老师!(别拿鸡蛋砸我 呀,救命啊!#¥%……*)
不过要想从修改游戏中学到知识,增加自己的计算机水平,可不能只是靠修改游戏呀! 要知道,修改游戏只是一个验证你对你所了解的某些计算机知识的理解程度的场所,只能给你一些发现问题、解决问题的机会,只能起到帮助你提高学习计算机的兴 趣的作用,而决不是学习计算机的捷径。
一:什么叫外挂?
现在的网络游戏多是基于Internet上客户/服务器模式,服务端程序运行在游戏服务器上,游戏的设计者在其中创造一个庞大的游戏空间,各地的玩家可以通过运行客户端程序同时登录到游戏中。简单地说,网络游戏实际上就是由游戏开发商 提供一个游戏环境,而玩家们就是在这个环境中相对自由和开放地进行游戏操作。那么既然在网络游戏中有了服务器这个概念,我们以前传统的修改游戏方法就显得 无能为力了。记得我们在单机版的游戏中,随心所欲地通过内存搜索来修改角色的各种属性,这在网络游戏中就没有任何用处了。因为我们在网络游戏中所扮演角色 的各种属性及各种重要资料都存放在服务器上,在我们自己机器上(客户端)只是显示角色的状态,所以通过修改客户端内存里有关角色的各种属性是不切实际的。 那么是否我们就没有办法在网络游戏中达到我们修改的目的?回答是"否".我们知道Internet客户/服务器模式的通讯一般采用TCP/IP通信协议,数据交换是通过IP数据包的传输来实现的,一般来说我们客户端向服务器发出某些请求,比如移动、战斗等指令都是通过封包的形式和服务器交换数 据。那么我们把本地发出消息称为SEND,意思就是发送数据,服务器收到我们SEND的消息后,会按照既定的程序把有关的信息反馈给客户端,比如,移动的 坐标,战斗的类型。那么我们把客户端收到服务器发来的有关消息称为RECV.知道了这个道理,接下来我们要做的工作就是分析客户端和服务器之间往来的数据 (也就是封包),这样我们就可以提取到对我们有用的数据进行修改,然后模拟服务器发给客户端,或者模拟客户端发送给服务器,这样就可以实现我们修改游戏的 目的了。
目前除了修改游戏封包来实现修改游戏的目的,我们也可以修改客户端的有关程序来达到我们的要求。我们知道目前各个服务器的运算能力是有限的,特别在游戏 中,游戏服务器要计算游戏中所有玩家的状况几乎是不可能的,所以有一些运算还是要依靠我们客户端来完成,这样又给了我们修改游戏提供了一些便利。比如我们 可以通过将客户端程序脱壳来发现一些程序的判断分支,通过跟踪调试我们可以把一些对我们不利的判断去掉,以此来满足我们修改游戏的需求。 在下几个章节中,我们将给大家讲述封包的概念,和修改跟踪客户端的有关知识。大家准备好了吗?
游戏数据格式和存储:
在进行我们的工作之前,我们需要掌握一些关于计算机中储存数据方式的知识和游戏中储存数据的特点。本章节是提供给菜鸟级的玩家看的,如果你是高手就可以跳 过了,如果,你想成为无坚不摧的剑客,那么,这些东西就会花掉你一些时间;如果,你只想作个江湖的游客的话,那么这些东西,了解与否无关紧要。是作剑客, 还是作游客,你选择吧!
现在我们开始!首先,你要知道游戏中储存数据的几种格式,这几种格式是:字节(BYTE)、字(WORD)和双字(DOUBLE WORD),或者说是8位、16位和32位储存方式。字节也就是8位方式能储存0~255的数字;字或说是16位储存方式能储存0~65535的数;双字 即32位方式能储存0~4294967295的数。
为何要了解这些知识呢?在游戏中各种参数的最大值是不同的,有些可能100左右就够了,比如,金庸群侠传中的角色的等级、随机遇敌个数等等。而有些却需要 大于255甚至大于65535,象金庸群侠传中角色的金钱值可达到数百万。所以,在游戏中各种不同的数据的类型是不一样的。在我们修改游戏时需要寻找准备 修改的数据的封包,在这种时候,正确判断数据的类型是迅速找到正确地址的重要条件。
在计算机中数据以字节为基本的储存单位,每个字节被赋予一个编号,以确定各自的位置。这个编号我们就称为地址。
在需要用到字或双字时,计算机用连续的两个字节来组成一个字,连续的两个字组成一个双字。而一个字或双字的地址就是它们的低位字节的地址。 现在我们常用的Windows 9x操作系统中,地址是用一个32位的二进制数表示的。而在平时我们用到内存地址时,总是用一个8位的16进制数来表示它。
二进制和十六进制又是怎样一回事呢?
简单说来,二进制数就是一种只有0和1两个数码,每满2则进一位的计数进位法。同样,16进制就是每满十六就进一位的计数进位法。16进制有0——F十六 个数字,它为表示十到十五的数字采用了A、B、C、D、E、F六个数字,它们和十进制的对应关系是:A对应于10,B对应于11,C对应于12,D对应于 13,E对应于14,F对应于15.而且,16进制数和二进制数间有一个简单的对应关系,那就是;四位二进制数相当于一位16进制数。比如,一个四位的二 进制数1111就相当于16进制的F,1010就相当于A.了解这些基础知识对修改游戏有着很大的帮助,下面我就要谈到这个问题。由于在计算机中数据是以 二进制的方式储存的,同时16进制数和二进制间的转换关系十分简单,所以大部分的修改工具在显示计算机中的数据时会显示16进制的代码,而且在你修改时也 需要输入16进制的数字。你清楚了吧?
在游戏中看到的数据可都是十进制的,在要寻找并修改参数的值时,可以使用Windows提供的计算器来进行十进制和16进制的换算,我们可以在开始菜单里的程序组中的附件中找到它。
现在要了解的知识也差不多了!不过,有个问题在游戏修改中是需要注意的。在计算机中数据的储存方式一般是低位数储存在低位字节,高位数储存在高位字节。比如,十进制数41715转换为16进制的数为A2F3,但在计算机中这个数被存为F3A2.
看了以上内容大家对数据的存贮和数据的对应关系都了解了吗? 好了,接下来我们要告诉大家在游戏中,封包到底是怎么一回事了,来!大家把袖口卷起来,让我们来干活吧!
二:什么是封包?
怎么截获一个游戏的封包?怎么去检查游戏服务器的ip地址和端口号? Internet用户使用的各种信息服务,其通讯的信息最终均可以归结为以IP包为单位的信息传送,IP包除了包括要传送的数据信息外,还包含有信息要发 送到的目的IP地址、信息发送的源IP地址、以及一些相关的控制信息。当一台路由器收到一个IP数据包时,它将根据数据包中的目的IP地址项查找路由表,根据查找的结果将此IP数据包送往对应端口。下一台IP路由器收到此数据包后继续转发,直至发到目的地。路由器之间可以通过路由协议来进行路由信息的交换,从而更新路由表。
那么我们所关心的内容只是IP包中的数据信息,我们可以使用许多监听网络的工具来截获客户端与服务器之间的交换数据,下面就向你介绍其中的一种工具:WPE. WPE使用方法:执行WPE会有下列几项功能可选择:
SELECT GAME选择目前在记忆体中您想拦截的程式,您只需双击该程式名称即可。
TRACE追踪功能。用来追踪撷取程式送收的封包。WPE必须先完成点选欲追踪的程式名称,才可以使用此项目。 按下Play键开始撷取程式收送的封包。您可以随时按下 | | 暂停追踪,想继续时请再按下 | | .按下正方形可以停止撷取封包并且显示所有已撷取封包内容。若您没按下正方形停止键,追踪的动作将依照OPTION里的设定值自动停止。如果您没有撷取到 资料,试试将OPTION里调整为Winsock Version 2.WPE 及 Trainers 是设定在显示至少16 bits 颜色下才可执行。
FILTER过滤功能。用来分析所撷取到的封包,并且予以修改。
SEND PACKET送出封包功能。能够让您送出假造的封包。
TRAINER MAKER制作修改器。
OPTIONS设定功能。让您调整WPE的一些设定值。
FILTER的详细教学
- 当FILTER在启动状态时 ,ON的按钮会呈现红色。- 当您启动FILTER时,您随时可以关闭这个视窗。FILTER将会留在原来的状态,直到您再按一次 on / off 钮。- 只有FILTER启用钮在OFF的状态下,才可以勾选Filter前的方框来编辑修改。- 当您想编辑某个Filter,只要双击该Filter的名字即可。
NORMAL MODE:
范例:
当您在 Street Fighter Online ﹝快打旋风线上版﹞游戏中,您使用了两次火球而且击中了对方,这时您会撷取到以下的封包:SEND-> 0000 08 14 21 06 01 04 SEND-> 0000 02 09 87 00 67 FF A4 AA 11 22 00 00 00 00 SEND-> 0000 03 84 11 09 11 09 SEND-> 0000 0A 09 C1 10 00 00 FF 52 44 SEND-> 0000 0A 09 C1 10 00 00 66 52 44您的第一个火球让对方减了16滴﹝16 = 10h﹞的生命值,而您观察到第4跟第5个封包的位置4有10h的值出现,应该就是这里了。
您观察10h前的0A 09 C1在两个封包中都没改变,可见得这3个数值是发出火球的关键。
因此您将0A 09 C1 10填在搜寻列﹝SEARCH﹞,然后在修改列﹝MODIFY﹞的位置4填上FF.如此一来,当您再度发出火球时,FF会取代之前的10,也就是攻击力为255的火球了!
ADVANCED MODE:范例: 当您在一个游戏中,您不想要用真实姓名,您想用修改过的假名传送给对方。在您使用TRACE后,您会发现有些封包里面有您的名字出现。假设您的名字是 Shadow,换算成16进位则是﹝53 68 61 64 6F 77﹞;而您打算用moon﹝6D6F 6F 6E 20 20﹞来取代他。1) SEND-> 0000 08 14 21 06 01 042) SEND-> 0000 01 06 99 53 68 61 64 6F 77 00 01 05 3) SEND-> 0000 03 84 11 09 11 094) SEND-> 0000 0A 09 C1 10 00 53 68 61 64 6F 77 00 11 5) SEND-> 0000 0A 09 C1 10 00 00 66 52 44但是您仔细看,您的名字在每个封包中并不是出现在相同的位置上- 在第2个封包里,名字是出现在第4个位置上- 在第4个封包里,名字是出现在第6个位置上在这种情况下,您就需要使用ADVANCED MODE- 您在搜寻列﹝SEARCH﹞填上:53 68 61 64 6F 77 ﹝请务必从位置1开始填﹞- 您想要从原来名字Shadow的第一个字母开始置换新名字,因此您要选择从数值被发现的位置开始替代连续数值﹝from the position of the chain found﹞.- 现在,在修改列﹝MODIFY﹞000的位置填上:6D 6F 6F 6E 20 20 ﹝此为相对应位置,也就是从原来搜寻栏的+001位置开始递换﹞- 如果您想从封包的第一个位置就修改数值,请选择﹝from the beginning of the packet﹞了解一点TCP/IP协议常识的人都知道,互联网是 将信息数据打包之后再传送出去的。每个数据包分为头部信息和数据信息两部分。头部信息包括数据包的发送地址和到达地址等。数据信息包括我们在游戏中相关操 作的各项信息。那么在做截获封包的过程之前我们先要知道游戏服务器的IP地址和端口号等各种信息,实际上最简单的是看看我们游戏目录下,是否有一个 SERVER.INI的配置文件,这个文件里你可以查看到个游戏服务器的IP地址,比如金庸群侠传就是如此,那么除了这个我们还可以在DOS下使用 NETSTAT这个命令, NETSTAT命令的功能是显示网络连接、路由表和网络接口信息,可以让用户得知目前都有哪些网络连接正在运作。或者你可以使用木马客星等工具来查看网络 连接。工具是很多的,看你喜欢用哪一种了。
NETSTAT命令的一般格式为:NETSTAT [选项]命令中各选项的含义如下:-a 显示所有socket,包括正在监听的。-c 每隔1秒就重新显示一遍,直到用户中断它。
-i 显示所有网络接口的信息。-n 以网络IP地址代替名称,显示出网络连接情形。-r 显示核心路由表,格式同"route -e".-t 显示TCP协议的连接情况。-u 显示UDP协议的连接情况。-v 显示正在进行的工作。
三:怎么来分析我们截获的封包?
首先我们将WPE截获的封包保存为文本文件,然后打开它,这时会看到如下的数据(这里我们以金庸群侠传里PK店小二客户端发送的数据为例来讲解):第一个 文件:SEND-> 0000 E6 56 0D 22 7E 6B E4 17 13 13 12 13 12 13 67 1BSEND-> 0010 17 12 DD 34 12 12 12 12 17 12 0E 12 12 12 9BSEND-> 0000 E6 56 1E F1 29 06 17 12 3B 0E 17 1ASEND-> 0000 E6 56 1B C0 68 12 12 12 5ASEND-> 0000 E6 56 02 C8 13 C9 7E 6B E4 17 10 35 27 13 12 12SEND-> 0000 E6 56 17 C9 12第二个文件:SEND-> 0000 83 33 68 47 1B 0E 81 72 76 76 77 76 77 76 02 7ESEND-> 0010 72 77 07 1C 77 77 77 77 72 77 72 77 77 77 6DSEND-> 0000 83 33 7B 94 4C 63 72 77 5E 6B 72 F3SEND-> 0000 83 33 7E A5 21 77 77 77 3FSEND-> 0000 83 33 67 AD 76 CF 1B 0E 81 72 75 50 42 76 77 77SEND-> 0000 83 33 72 AC 77我们发现两次PK店小二的数据格式一样,但是内容却不相同,我们是PK的同一个NPC,为什么会不同呢? 原来金庸群侠传的封包是经过了加密运算才在网路上传输的,那么我们面临的问题就是如何将密文解密成明文再分析了。
因为一般的数据包加密都是异或运算,所以这里先讲一下什么是异或。 简单的说,异或就是"相同为0,不同为1"(这是针对二进制按位来讲的),举个例子,0001和0010异或,我们按位对比,得到异或结果是0011,计 算的方法是:0001的第4位为0,0010的第4位为0,它们相同,则异或结果的第4位按照"相同为0,不同为1"的原则得到0,0001的第3位为 0,0010的第3位为0,则异或结果的第3位得到0,0001的第2位为0,0010的第2位为1,则异或结果的第2位得到1,0001的第1位为 1,0010的第1位为0,则异或结果的第1位得到1,组合起来就是0011.异或运算今后会遇到很多,大家可以先熟悉熟悉,熟练了对分析很有帮助的。
下面我们继续看看上面的两个文件,按照常理,数据包的数据不会全部都有值的,游戏开发时会预留一些字节空间来便于日后的扩充,也就是说数据包里会存在一些"00"的字节,观察上面的文件,我们会发现文件一里很多"12",文件二里很多"77",那么这是不是代表我们说的"00"呢?推理到这里,我们就开始行动吧!
我们把文件一与"12"异或,文件二与"77"异或,当然用手算很费事,我们使用"M2M 1.0 加密封包分析工具"来计算就方便多了。得到下面的结果:第一个文件:1 SEND-> 0000 F4 44 1F 30 6C 79 F6 05 01 01 00 01 00 01 75 09SEND-> 0010 05 00 CF 26 00 00 00 00 05 00 1C 00 00 00 892 SEND-> 0000 F4 44 0C E3 3B 13 05 00 29 1C 05 083 SEND-> 0000 F4 44 09 D2 7A 00 00 00 484 SEND-> 0000 F4 44 10 DA 01 DB 6C 79 F6 05 02 27 35 01 00 005 SEND-> 0000 F4 44 05 DB 00第二个文件:1 SEND-> 0000 F4 44 1F 30 6C 79 F6 05 01 01 00 01 00 01 75 09SEND-> 0010 05 00 70 6B 00 00 00 00 05 00 05 00 00 00 1A2 SEND-> 0000 F4 44 0C E3 3B 13 05 00 29 1C 05 843 SEND-> 0000 F4 44 09 D2 56 00 00 00 484 SEND-> 0000 F4 44 10 DA 01 B8 6C 79 F6 05 02 27 35 01 00 005 SEND-> 0000 F4 44 05 DB 00哈,这一下两个文件大部分都一样啦,说明我们的推理是正确的,上面就是我们需要的明文!
接下来就是搞清楚一些关键的字节所代表的含义,这就需要截获大量的数据来分析。
首先我们会发现每个数据包都是"F4 44"开头,第3个字节是变化的,但是变化很有规律。我们来看看各个包的长度,发现什么没有?对了,第3个字节就是包的长度! 通过截获大量的数据包,我们判断第4个字节代表指令,也就是说客户端告诉服务器进行的是什么操作。例如向服务器请求战斗指令为"30",战斗中移动指令 为"D4"等。 接下来,我们就需要分析一下上面第一个包"F4 44 1F 30 6C 79 F6 05 01 01 00 01 00 01 75 09 05 00 CF 26 00 00 00 00 05 00 1C 00 00 00 89",在这个包里包含什么信息呢?应该有通知服务器你PK的哪个NPC吧,我们就先来找找这个店小二的代码在什么地方。 我们再PK一个小喽罗(就是大理客栈外的那个咯):SEND-> 0000 F4 44 1F 30 D4 75 F6 05 01 01 00 01 00 01 75 09SEND-> 0010 05 00 8A 19 00 00 00 00 11 00 02 00 00 00 C0 我们根据常理分析,游戏里的NPC种类虽然不会超过65535(FFFF),但开发时不会把自己限制在字的范围,那样不利于游戏的扩充,所以我们在双字里 看看。通过"店小二"和"小喽罗"两个包的对比,我们把目标放在"6C 79 F6 05"和"CF 26 00 00"上。(对比一下很容易的,但你不能太迟钝咯,呵呵)我们再看看后面的包,在后面的包里应该还会出现NPC的代码,比如移动的包,游戏允许观战,服务 器必然需要知道NPC的移动坐标,再广播给观战的其他玩家。在后面第4个包"SEND-> 0000 F4 44 10 DA 01 DB 6C 79 F6 05 02 27 35 01 00 00"里我们又看到了"6C 79 F6 05",初步断定店小二的代码就是它了!(这分析里边包含了很多工作的,大家可以用WPE截下数据来自己分析分析)
第一个包的分析暂时就到这里(里面还有的信息我们暂时不需要完全清楚了)
我们看看第4个包"SEND-> 0000 F4 44 10 DA 01 DB 6C 79 F6 05 02 27 35 01 00 00",再截获PK黄狗的包,(狗会出来2只哦)看看包的格式:SEND-> 0000 F4 44 1A DA 02 0B 4B 7D F6 05 02 27 35 01 00 00SEND-> 0010 EB 03 F8 05 02 27 36 01 00 00根据上面的分析,黄狗的代码为"4B 7D F6 05"(100040011),不过两只黄狗服务器怎样分辨呢?看看"EB 03 F8 05"(100140011),是上一个代码加上100000,呵呵,这样服务器就可以认出两只黄狗了。我们再通过野外遇敌截获的数据包来证实,果然如 此。
那么,这个包的格式应该比较清楚了:第3个字节为包的长度,"DA"为指令,第5个字节为NPC个数,从第7个字节开始的10个字节代表一个NPC的信息,多一个NPC就多10个字节来表示。
大家如果玩过网金,必然知道随机遇敌有时会出现增援,我们就利用游戏这个增援来让每次战斗都会出现增援的NPC吧。
通过在战斗中出现增援截获的数据包,我们会发现服务器端发送了这样一个包:F4 44 12 E9 EB 03 F8 05 02 00 00 03 00 00 00 00 00 00 第5-第8个字节为增援NPC的代码(这里我们就简单的以黄狗的代码来举例)。 那么,我们就利用单机代理技术来同时欺骗客户端和服务器吧!
好了,呼叫NPC的工作到这里算是完成了一小半,接下来的事情,怎样修改封包和发送封包,我们下节继续讲解吧。
四:怎么冒充"客户端"向"服务器"发我们需要的封包?
这里我们需要使用一个工具,它位于客户端和服务器端之间,它的工作就是进行数据包的接收和转发,这个工具我们称为代理。如果代理的工作单纯就是接收和转发 的话,这就毫无意义了,但是请注意:所有的数据包都要通过它来传输,这里的意义就重大了。我们可以分析接收到的数据包,或者直接转发,或者修改后转发,或 者压住不转发,甚至伪造我们需要的封包来发送。
下面我们继续讲怎样来同时欺骗服务器和客户端,也就是修改封包和伪造封包。 通过我们上节的分析,我们已经知道了打多个NPC的封包格式,那么我们就动手吧!
首先我们要查找客户端发送的包,找到战斗的特征,就是请求战斗的第1个包,我们找"F4 44 1F 30"这个特征,这是不会改变的,当然是要解密后来查找哦。 找到后,表示客户端在向服务器请求战斗,我们不动这个包,转发。 继续向下查找,这时需要查找的特征码不太好办,我们先查找"DA",这是客户端发送NPC信息的数据包的指令,那么可能其他包也有"DA",没关系,我们 看前3个字节有没有"F4 44"就行了。找到后,我们的工作就开始了!
我们确定要打的NPC数量。这个数量不能很大,原因在于网金的封包长度用一个字节表示,那么一个包可以有255个字节,我们上面分析过,增加一个NPC要增加10个字节,所以大家算算就知道,打20个NPC比较合适。
然后我们要把客户端原来的NPC代码分析计算出来,因为增加的NPC代码要加上100000哦。再把我们增加的NPC代码计算出来,并且组合成新的封包,注意代表包长度的字节要修改啊,然后转发到服务器,这一步在编写程序的时候要注意算法,不要造成较大延迟。
上面我们欺骗服务器端完成了,欺骗客户端就简单了。
发送了上面的封包后,我们根据新增NPC代码构造封包马上发给客户端,格式就是"F4 44 12 E9 NPC代码 02 00 00 03 00 00 00 00 00 00",把每个新增的NPC都构造这样一个包,按顺序连在一起发送给客户端,客户端也就被我们骗过了,很简单吧。
以后战斗中其他的事我们就不管了,尽情地开打吧。
游戏外挂基本原理及实现
解释游戏外挂的基本原理和实现方法
游戏外挂已经深深地影响着众多网络游戏玩家,今天在网上看到了一些关于游戏外挂编写的技术,于是转载上供大家参考
1、游戏外挂的原理
外挂现在分为好多种,比如模拟键盘的,鼠标的,修改数据包的,还有修改本地内存的,但好像没有修改服务器内存的哦,呵呵。其实修改服务器也是有办法的,只是技术太高一般人没有办法入手而已。(比如请GM去夜总会、送礼、收黑钱等等办法都可以修改服务器数据,哈哈)
修改游戏无非是修改一下本地内存的数据,或者截获API函数等等。这里我把所能想到的方法都作一个介绍,希望大家能做出很好的外挂来使游戏厂商更好的完善 自己的技术。我见到一篇文章是讲魔力宝贝的理论分析,写得不错,大概是那个样子。下来我就讲解一下技术方面的东西,以作引玉之用。
2 技术分析部分
2.1 模拟键盘或鼠标的响应
我们一般使用:
UINT SendInput(
UINT nInputs, // count of input events
PINPUT pInputs, // array of input events
int cbSize // size of structure
);
API函数。第一个参数是说明第二个参数的矩阵的维数的,第二个参数包含了响应事件,这个自己填充就可以,最后是这个结构的大小,非常简单,这是最简单的方法模拟键盘鼠标了,呵呵。注意,这个函数还有个替代函数:
VOID keybd_event(
BYTE bVk, // 虚拟键码
BYTE bScan, // 扫描码
DWORD dwFlags,
ULONG_PTR dwExtraInfo // 附加键状态
);
与
VOID mouse_event(
DWORD dwFlags, // motion and click options
DWORD dx, // horizontal position or change
DWORD dy, // vertical position or change
DWORD dwData, // wheel movement
ULONG_PTR dwExtraInfo // application-defined information
);
这两个函数非常简单了,我想那些按键精灵就是用的这个吧。上面的是模拟键盘,下面的是模拟鼠标的。这个仅仅是模拟部分,要和游戏联系起来我们还需要找到游 戏的窗口才行,或者包含快捷键,就象按键精灵的那个激活键一样,我们可以用GetWindow函数来枚举窗口,也可以用Findwindow函数来查找制 定的窗口(注意,还有一个FindWindowEx),FindwindowEx可以找到窗口的子窗口,比如按钮,等什么东西。当游戏切换场景的时候我们 可以用FindWindowEx来确定一些当前窗口的特征,从而判断是否还在这个场景,方法很多了,比如可以GetWindowInfo来确定一些东西, 比如当查找不到某个按钮的时候就说明游戏场景已经切换了,等等办法。有的游戏没有控件在里面,这是对图像做坐标变换的话,这种方法就要受到限制了。这就需 要我们用别的办法来辅助分析了。
至于快捷键我们要用动态连接库实现了,里面要用到hook技术了,这个也非常简单。大家可能都会了,其实就是一个全局的hook对象然后 SetWindowHook就可以了,回调函数都是现成的,而且现在网上的例子多如牛毛。这个实现在外挂中已经很普遍了。如果还有谁不明白,那就去看看 MSDN查找SetWindowHook就可以了。
不要低估了这个动态连接库的作用,它可以切入所有的进程空间,也就是可以加载到所有的游戏里面哦,只要用对,你会发现很有用途的。这个需要你复习一下Win32编程的基础知识了。呵呵,赶快去看书吧。
2.2 截获消息
有些游戏的响应机制比较简单,是基于消息的,或者用什么定时器的东西。这个时候你就可以用拦截消息来实现一些有趣的功能了。
我们拦截消息使用的也是hook技术,里面包括了键盘消息,鼠标消息,系统消息,日志等,别的对我们没有什么大的用处,我们只用拦截消息的回调函数就可以 了,这个不会让我写例子吧。其实这个和上面的一样,都是用SetWindowHook来写的,看看就明白了很简单的。
至于拦截了以后做什么就是你的事情了,比如在每个定时器消息里面处理一些我们的数据判断,或者在定时器里面在模拟一次定时器,那么有些数据就会处理两次, 呵呵。后果嘛,不一定是好事情哦,呵呵,不过如果数据计算放在客户端的游戏就可以真的改变数据了,呵呵,试试看吧。用途还有很多,自己想也可以想出来的, 呵呵。
2.3 拦截Socket包
这个技术难度要比原来的高很多。
首先我们要替换WinSock.DLL或者WinSock32.DLL,我们写的替换函数要和原来的函数一致才行,就是说它的函数输出什么样的,我们也要 输出什么样子的函数,而且参数,参数顺序都要一样才行,然后在我们的函数里面调用真正的WinSock32.DLL里面的函数就可以了。
首先:我们可以替换动态库到系统路径。
其次:我们应用程序启动的时候可以加载原有的动态库,用这个函数LoadLibary然后定位函数入口用GetProcAddress函数获得每个真正Socket函数的入口地址。
当游戏进行的时候它会调用我们的动态库,然后从我们的动态库中处理完毕后才跳转到真正动态库的函数地址,这样我们就可以在里面处理自己的数据了,应该是一 切数据。呵呵,兴奋吧,拦截了数据包我们还要分析之后才能进行正确的应答,不要以为这样工作就完成了,还早呢。等分析完毕以后我们还要仿真应答机制来和服 务器通信,一个不小心就会被封号。
分析数据才是工作量的来源呢,游戏每次升级有可能加密方式会有所改变,因此我们写外挂的人都是亡命之徒啊,被人愚弄了还不知道。
2.4 截获API
上面的技术如果可以灵活运用的话我们就不用截获API函数了,其实这种技术是一种补充技术。比如我们需要截获Socket以外的函数作为我们的用途,我们就要用这个技术了,其实我们也可以用它直接拦截在Socket中的函数,这样更直接。
现在拦截API的教程到处都是,我就不列举了,我用的比较习惯的方法是根据输入节进行拦截的,这个方法可以用到任何一种操作系统上,比如Windows 98/2000等,有些方法不是跨平台的,我不建议使用。这个技术大家可以参考《Windows核心编程》里面的545页开始的内容来学习,如果是 Win98系统可以用“Windows系统奥秘”那个最后一章来学习。
网络游戏通讯模型初探
[文章导读]本文就将围绕三个主题来给大家讲述一下网络游戏的网络互连实现方法
序言
网络游戏,作为游戏与网络有机结合的产物,把玩家带入了新的娱乐领域。网络游戏在中国开始发展至今也仅有3,4年的历史,跟已经拥有几十年开发历史的单机游戏相比,网络游戏还是非常年轻的。当然,它的形成也是根据历史变化而产生的可以说没有互联网的 兴起,也就没有网络游戏的诞生。作为新兴产物,网络游戏的开发对广大开发者来说更加神秘,对于一个未知领域,开发者可能更需要了解的是网络游戏与普通单机 游戏有何区别,网络游戏如何将玩家们连接起来,以及如何为玩家提供一个互动的娱乐环境。本文就将围绕这三个主题来给大家讲述一下网络游戏的网络互连实现方 法。
网络游戏与单机游戏
说到网络游戏,不得不让人联想到单机游戏,实际上网络游戏的实质脱离不了单机游戏的制作思想,网络游戏和单机游戏的差别大家可以很直接的想到:不就是可以 多人连线吗?没错,但如何实现这些功能,如何把网络连线合理的融合进单机游戏,就是我们下面要讨论的内容。在了解网络互连具体实现之前,我们先来了解一下 单机与网络它们各自的运行流程,只有了解这些,你才能深入网络游戏开发的核心。
现在先让我们来看一下普通单机游戏的简化执行流程:
Initialize() // 初始化模块
{
初始化游戏数据;
}
Game() // 游戏循环部分
{
绘制游戏场景、人物以及其它元素;
获取用户操作输入;
switch( 用户输入数据)
{
case 移动:
{
处理人物移动;
}
break;
case 攻击:
{
处理攻击逻辑:
}
break;
...
其它处理响应;
...
default:
break;
}
游戏的NPC等逻辑AI处理;
}
Exit() // 游戏结束
{
释放游戏数据;
离开游戏;
}
我们来说明一下上面单机游戏的流程。首先,不管是游戏软件还是其他应用软件,初始化部分必不可少,这里需要对游戏的数据进行初始化,包括图像、声音以及一 些必备的数据。接下来,我们的游戏对场景、人物以及其他元素进行循环绘制,把游戏世界展现给玩家,同时接收玩家的输入操作,并根据操作来做出响应,此外, 游戏还需要对NPC以及一些逻辑AI进行处理。最后,游戏数据被释放,游戏结束。
网络游戏与单机游戏有一个很显著的差别,就是网络游戏除了一个供操作游戏的用户界面平台(如单机游戏)外,还需要一个用于连接所有用户,并为所有用户提供数据服务的服务器,从某些角度来看,游戏服务器就像一个大型的数据库,
提供数据以及数据逻辑交互的功能。让我们来看看一个简单的网络游戏模型执行流程:
客户机:
Login()// 登入模块
{
初始化游戏数据;
获取用户输入的用户和密码;
与服务器创建网络连接;
发送至服务器进行用户验证;
...
等待服务器确认消息;
...
获得服务器反馈的登入消息;
if( 成立 )
进入游戏;
else
提示用户登入错误并重新接受用户登入;
}
Game()// 游戏循环部分
{
绘制游戏场景、人物以及其它元素;
获取用户操作输入;
将用户的操作发送至服务器;
...
等待服务器的消息;
...
接收服务器的反馈信息;
switch( 服务器反馈的消息数据 )
{
case 本地玩家移动的消息:
{
if( 允许本地玩家移动 )
客户机处理人物移动;
else
客户机保持原有状态;
}
break;
case 其他玩家/NPC的移动消息:
{
根据服务器的反馈信息进行其他玩家或者NPC的移动处理;
}
break;
case 新玩家加入游戏:
{
在客户机中添加显示此玩家;
}
break;
case 玩家离开游戏:
{
在客户机中销毁此玩家数据;
}
break;
...
其它消息类型处理;
...
default:
break;
}
}
Exit()// 游戏结束
{
发送离开消息给服务器;
...
等待服务器确认;
...
得到服务器确认消息;
与服务器断开连接;
释放游戏数据;
离开游戏;
}
服务器:
Listen() // 游戏服务器等待玩家连接模块
{
...
等待用户的登入信息;
...
接收到用户登入信息;
分析用户名和密码是否符合;
if( 符合 )
{
发送确认允许进入游戏消息给客户机;
把此玩家进入游戏的消息发布给场景中所有玩家;
把此玩家添加到服务器场景中;
}
else
{
断开与客户机的连接;
}
}
Game() // 游戏服务器循环部分
{
...
等待场景中玩家的操作输入;
...
接收到某玩家的移动输入或NPC的移动逻辑输入;
// 此处只以移动为例
进行此玩家/NPC在地图场景是否可移动的逻辑判断;
if( 可移动 )
{
对此玩家/NPC进行服务器移动处理;
发送移动消息给客户机;
发送此玩家的移动消息给场景上所有玩家;
}
else
发送不可移动消息给客户机;
}
Exit() // 游戏服务=器结束
{
接收到玩家离开消息;
将此消息发送给场景中所有玩家;
发送允许离开的信息;
将玩家数据存入数据库;
注销此玩家在服务器内存中的数据;
}
}
让我们来说明一下上面简单网络游戏模型的运行机制。先来讲讲服务器端,这里服务器端分为三个部分(实际上一个完整的网络游戏远不止这些):登入模块、游戏 模块和登出模块。登入模块用于监听网络游戏客户端发送过来的网络连接消息,并且验证其合法性,然后在服务器中创建这个玩家并且把玩家带领到游戏模块中; 游戏模块则提供给玩家用户实际的应用服务,我们在后面会详细介绍这个部分; 在得到玩家要离开游戏的消息后,登出模块则会把玩家从服务器中删除,并且把玩家的属性数据保存到服务器数据库中,如: 经验值、等级、生命值等。
接下来让我们看看网络游戏的客户端。这时候,客户端不再像单机游戏一样,初始化数据后直接进入游戏,而是在与服务器创建连接,并且获得许可的前提下才进入 游戏。除此之外,网络游戏的客户端游戏进程需要不断与服务器进行通讯,通过与服务器交换数据来确定当前游戏的状态,例如其他玩家的位置变化、物品掉落情 况。同样,在离开游戏时,客户端会向服务器告知此玩家用户离开,以便于服务器做出相应处理。
以上用简单的伪代码给大家阐述了单机游戏与网络游戏的执行流程,大家应该可以清楚看出两者的差别,以及两者间相互的关系。我们可以换个角度考虑,网络游戏 就是把单机游戏的逻辑运算部分搬移到游戏服务器中进行处理,然后把处理结果(包括其他玩家数据)通过游戏服务器返回给连接的玩家。
网络互连
在了解了网络游戏基本形态之后,让我们进入真正的实际应用部分。首先,作为网络游戏,除了常规的单机游戏所必需的东西之外,我们还需要增加一个网络通讯模块,当然,这也是网络游戏较为主要的部分,我们来讨论一下如何实现网络的通讯模块。
一个完善的网络通讯模块涉及面相当广,本文仅对较为基本的处理方式进行讨论。网络游戏是由客户端和服务器组成,相应也需要两种不同的网络通讯处理方式,不 过也有相同之处,我们先就它们的共同点来进行介绍。我们这里以Microsoft Windows 2000 [2000 Server]作为开发平台,并且使用Winsock作为网络接口(可能一些朋友会考虑使用DirectPlay来进行网络通讯,不过对于当前在线游 戏,DirectPlay并不适合,具体原因这里就不做讨论了)。
确定好平台与接口后,我们开始进行网络连接创建之前的一些必要的初始化工作,这部分无论是客户端或者服务器都需要进行。让我们看看下面的代码片段:
WORD wVersionRequested;
WSADATAwsaData;
wVersionRequested MAKEWORD(1, 1);
if( WSAStartup( wVersionRequested, &wsaData ) !0 )
{
Failed( WinSock Version Error!" );
}
上面通过调用Windows的socket API函数来初始化网络设备,接下来进行网络Socket的创建,代码片段如下:
SOCKET sSocket socket( AF_INET, m_lProtocol, 0 );
if( sSocket == INVALID_SOCKET )
{
Failed( "WinSocket Create Error!" );
}
这里需要说明,客户端和服务端所需要的Socket连接数量是不同的,客户端只需要一个Socket连接足以满足游戏的需要,而服务端必须为每个玩家用户 创建一个用于通讯的Socket连接。当然,并不是说如果服务器上没有玩家那就不需要创建Socket连接,服务器端在启动之时会生成一个特殊的 Socket用来对玩家创建与服务器连接的请求进行响应,等介绍网络监听部分后会有更详细说明。
有初始化与创建必然就有释放与删除,让我们看看下面的释放部分:
if( sSocket != INVALID_SOCKET )
{
closesocket( sSocket );
}
if( WSACleanup() != 0 )
{
Warning( "Can't release Winsocket" );
}
这里两个步骤分别对前面所作的创建初始化进行了相应释放。
接下来看看服务器端的一个网络执行处理,这里我们假设服务器端已经创建好一个Socket供使用,我们要做的就是让这个Socket变成监听网络连接请求的专用接口,看看下面代码片段:
SOCKADDR_IN addr;
memset( &addr, 0, sizeof(addr) );
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl( INADDR_ANY );
addr.sin_port = htons( Port ); // Port为要监听的端口号
// 绑定socket
if( bind( sSocket, (SOCKADDR*)&addr, sizeof(addr) ) == SOCKET_ERROR )
{
Failed( "WinSocket Bind Error!");
}
// 进行监听
if( listen( sSocket, SOMAXCONN ) == SOCKET_ERROR )
{
Failed( "WinSocket Listen Error!");
}
这里使用的是阻塞式通讯处理,此时程序将处于等待玩家用户连接的状态,倘若这时候有客户端连接进来,则通过accept()来创建针对此玩家用户的Socket连接,代码片段如下:
sockaddraddrServer;
int nLen sizeof( addrServer );
SOCKET sPlayerSocket accept( sSocket, &addrServer, &nLen );
if( sPlayerSocket == INVALID_SOCKET )
{
Failed( WinSocket Accept Error!");
}
这里我们创建了sPlayerSocket连接,此后游戏服务器与这个玩家用户的通讯全部通过此Socket进行,到这里为止,我们服务器已经有了接受玩家用户连接的功能,现在让我们来看看游戏客户端是如何连接到游戏服务器上,代码片段如下:
SOCKADDR_IN addr;
memset( &addr, 0, sizeof(addr) );
addr.sin_family = AF_INET;// 要连接的游戏服务器端口号
addr.sin_addr.s_addr = inet_addr( IP );// 要连接的游戏服务器IP地址,
addr.sin_port = htons( Port );//到此,客户端和服务器已经有了通讯的桥梁,
//接下来就是进行数据的发送和接收:
connect( sSocket, (SOCKADDR*)&addr, sizeof(addr) );
if( send( sSocket, pBuffer, lLength, 0 ) == SOCKET_ERROR )
{
Failed( "WinSocket Send Error!");
}
这里的pBuffer为要发送的数据缓冲指针,lLength为需要发送的数据长度,通过这支Socket API函数,我们无论在客户端或者服务端都可以进行数据的发送工作,同时,我们可以通过recv()这支Socket API函数来进行数据接收:
lLength, 0 ) == SOCKET_ERROR )
{
Failed( "WinSocket Recv Error!");
}
其中pBuffer用来存储获取的网络数据缓冲,lLength则为需要获取的数据长度。
现在,我们已经了解了一些网络互连的基本知识,但作为网络游戏,如此简单的连接方式是无法满足网络游戏中百人千人同时在线的,我们需要更合理容错性更强的网络通讯处理方式,当然,我们需要先了解一下网络游戏对网络通讯的需求是怎样的。
大家知道,游戏需要不断循环处理游戏中的逻辑并进行游戏世界的绘制,上面所介绍的Winsock处理方式均是以阻塞方式进行,这样就违背了游戏的执行本 质,可以想象,在客户端连接到服务器的过程中,你的游戏不能得到控制,这时如果玩家想取消连接或者做其他处理,甚至显示一个最基本的动态连接提示都不行。
所以我们需要用其他方式来处理网络通讯,使其不会与游戏主线相冲突,可能大家都会想到: 创建一个网络线程来处理不就可以了?没错,我们可以创建一个专门用于网络通讯的子线程来解决这个问题。当然,我们游戏中多了一个线程,我们就需要做更多的 考虑,让我们来看看如何创建网络通讯线程。
在Windows系统中,我们可以通过CreateThread()函数来进行线程的创建,看看下面的代码片段:
DWORD dwThreadID;
HANDLE hThread = CreateThread( NULL, 0, NetThread/*网络线程函式*/, sSocket, 0, &dwThreadID );
if( hThread == NULL )
{
Failed( "WinSocket Thread Create Error!");
}
这里我们创建了一个线程,同时将我们的Socket传入线程函数:
DWORD WINAPINetThread(LPVOID lParam)
{
SOCKET sSocket (SOCKET)lParam;
...
return 0;
}
NetThread就是我们将来用于处理网络通讯的网络线程。那么,我们又如何把Socket的处理引入线程中?
看看下面的代码片段:
HANDLE hEvent;
hEvent = CreateEvent(NULL,0,0,0);
// 设置异步通讯
if( WSAEventSelect( sSocket, hEvent,
FD_ACCEPT|FD_CONNECT|FD_READ|FD_WRITE|FD_CLOSE ) ==SOCKET_ERROR )
{
Failed( "WinSocket EventSelect Error!");
}
通过上面的设置之后,WinSock API函数均会以非阻塞方式运行,也就是函数执行后会立即返回,这时网络通讯会以事件方式存储于hEvent,而不会停顿整支程式。
完成了上面的步骤之后,我们需要对事件进行响应与处理,让我们看看如何在网络线程中获得网络通讯所产生的事件消息:
WSAEnumNetworkEvents( sSocket, hEvent, &SocketEvents );
if( SocketEvents.lNetworkEvents != 0 )
{
switch( SocketEvents.lNetworkEvents )
{
case FD_ACCEPT:
WSANETWORKEVENTS SocketEvents;
break;
case FD_CONNECT:
{
if( SocketEvents.iErrorCode[FD_CONNECT_BIT] == 0)
// 连接成功
{
// 连接成功后通知主线程(游戏线程)进行处理
}
}
break;
case FD_READ:
// 获取网络数据
{
if( recv( sSocket, pBuffer, lLength, 0) == SOCKET_ERROR )
{
Failed( "WinSocket Recv Error!");
}
}
break;
case FD_WRITE:
break;
case FD_CLOSE:
// 通知主线程(游戏线程), 网络已经断开
break;
default:
}
}
这里仅对网络连接(FD_CONNECT) 和读取数据(FD_READ) 进行了简单模拟操作,但实际中网络线程接收到事件消息后,会对数据进行组织整理,然后再将数据回传给我们的游戏主线程使用,游戏主线程再将处理过的数据发 送出去,这样一个往返就构成了我们网络游戏中的数据通讯,是让网络游戏动起来的最基本要素。
最后,我们来谈谈关于网络数据包(数据封包)的组织,网络游戏的数据包是游戏数据通讯的最基本单位,网络游戏一般不会用字节流的方式来进行数据传输,一个 数据封包也可以看作是一条消息指令,在游戏进行中,服务器和客户端会不停的发送和接收这些消息包,然后将消息包解析转换为真正所要表达的指令意义并执行。
互动与管理
说到互动,对于玩家来说是与其他玩家的交流,但对于计算机而言,实现互动也就是实现数据消息的相互传递。前面我们已经了解过网络通讯的基本概念,它构成了 互动的最基本条件,接下来我们需要在这个网络层面上进行数据的通讯。遗憾的是,计算机并不懂得如何表达玩家之间的交流,因此我们需要提供一套可让计算机了 解的指令组织和解析机制,也就是对我们上面简单提到的网络数据包(数据封包)的处理机制。
为了能够更简单的给大家阐述网络数据包的组织形式,我们以一个聊天处理模块来进行讨论,看看下面的代码结构:
struct tagMessage{
long lType;
long lPlayerID;
};
// 消息指令
// 指令相关的玩家标识
char strTalk[256]; // 消息内容
上面是抽象出来的一个极为简单的消息包结构,我们先来谈谈其各个数据域的用途:首先,lType 是消息指令的类型,这是最为基本的消息标识,这个标识用来告诉服务器或客户端这条指令的具体用途,以便于服务器或客户端做出相应处理。lPlayerID 被作为玩家的标识。大家知道,一个玩家在机器内部实际上也就是一堆数据,特别是在游戏服务器中,可能有成千上万个玩家,这时候我们需要一个标记来区分玩 家,这样就可以迅速找到特定玩家,并将通讯数据应用于其上。
strTalk 是我们要传递的聊天数据,这部分才是真正的数据实体,前面的参数只是数据实体应用范围的限定。
在组织完数据之后,紧接着就是把这个结构体数据通过Socket 连接发送出去和接收进来。这里我们要了解,网络在进行数据传输过程中,它并不关心数据采用的数据结构,这就需要我们把数据结构转换为二进制数据码进行发 送,在接收方,我们再将这些二进制数据码转换回程序使用的相应数据结构。让我们来看看如何实现:
tagMessageMsg;
Msg.lTypeMSG_CHAT;
Msg.lPlayerID 1000;
strcpy( &Msg.strTalk, "聊天信息" );
首先,我们假设已经组织好一个数据包,这里MSG_CHAT 是我们自行定义的标识符,当然,这个标识符在服务器和客户端要统一。玩家的ID 则根据游戏需要来进行设置,这里1000 只作为假设,现在继续:
char* p = (char*)&Msg;
long lLength = sizeof( tagMessage );
send( sSocket, p, lLength );
// 获取数据结构的长度
我们通过强行转换把结构体转变为char 类型的数据指针,这样就可以通过这个指针来进行流式数据处理,这里通过
sizeof() 获得结构体长度,然后用WinSock 的Send() 函数将数据发送出去。
接下来看看如何接收数据:
long lLength = sizeof( tagMessage );
char* Buffer = new char[lLength];
recv( sSocket, Buffer, lLength );
tagMessage* p = (tagMessage*)Buffer;
// 获取数据
在通过WinSock 的recv() 函数获取网络数据之后,我们同样通过强行转换把获取出来的缓冲数据转换为相应结构体,这样就可以方便地对数据进行访问。(注:强行转换仅仅作为数据转换的 一种手段,实际应用中有更多可选方式,这里只为简洁地说明逻辑)谈到此处,不得不提到服务器/ 客户端如何去筛选处理各种消息以及如何对通讯数据包进行管理。无论是服务器还是客户端,在收到网络消息的时候,通过上面的数据解析之后,还必须对消息类型 进行一次筛选和派分,简单来说就是类似Windows 的消息循环,不同消息进行不同处理。这可以通过一个switch 语句(熟悉Windows 消息循环的朋友相信已经明白此意),基于消
息封包里的lType 信息,对消息进行区分处理,考虑如下代码片段:
switch( p->lType ) // 这里的p->lType为我们解析出来的消息类型标识
{
case MSG_CHAT: // 聊天消息
break;
case MSG_MOVE: // 玩家移动消息
break;
case MSG_EXIT: // 玩家离开消息
break;
default:
break;
}
面片段中的MSG_MOVE 和MSG_EXIT 都是我们虚拟的消息标识(一个真实游戏中的标识可能会有上百个,这就需要考虑优化和优先消息处理问题)。此外,一个网络游戏服务器面对的是成百上千的连接 用户,我们还需要一些合理的数据组织管理方式来进行相关处理。普通的单体游戏服务器,可能会因为当机或者用户过多而导致整个游戏网络瘫痪,而这也就引入分 组服务器机制,我们把服务器分开进行数据的分布式处理。
我们把每个模块提取出来,做成专用的服务器系统,然后建立一个连接所有服务器的数据中心来进行数据交互,这里每个模块均与数据中心创建了连接,保证了每个 模块的相关性,同时玩家转变为与当前提供服务的服务器进行连接通讯,这样就可以缓解单独一台服务器所承受的负担,把压力分散到多台服务器上,同时保证了数 据的统一,而且就算某台服务因为异常而当机也不会影响其他模块的游戏玩家,从而提高了整体稳定性。分组式服务器缓解了服务器的压力,但也带来了服务器调度 问题,分组式服务器需要对服务器跳转进行处理,就以一个玩家进行游戏场景跳转作为讨论基础:假设有一玩家处于游戏场景A,他想从场景A 跳转到场景B,在游戏中,我们称之场景切换,这时玩家就会触发跳转需求,比如走到了场景中的切换点,这样服务器就把玩家数据从"游戏场景A 服务器"删除,同时在"游戏场景B 服务器"中把玩家建立起来。
这里描述了场景切换的简单模型,当中处理还有很多步骤,不过通过这样的思考相信大家可以派生出很多应用技巧。
不过需要注意的是,在场景切换或者说模块间切换的时候,需要切实考虑好数据的传输安全以及逻辑合理性,否则切换很可能会成为将来玩家复制物品的桥梁。
总结
本篇讲述的都是通过一些简单的过程来进行网络游戏通讯,提供了一个制作的思路,虽然具体实现起来还有许多要做 ,但只要顺着这个思路去扩展、去完善,相信大家很快就能够编写出自己的网络通讯模块。由于时间仓促,本文在很多细节方面都有省略,文中若有错误之处也望大 家见谅
先要这样
apt-get install build-essential
apt-get install libncurses5-dev
apt-get install m4
apt-get install libssl-dev
然后要用新立得装如下库:
libc6
unixodbc
unixodbc-dev
gcj
freeglut3-dev libwxgtk2.8-dev g++
然后下载源代码
tar -xvf otp-src-R12B-0.tar.gz
cd otp-src-R12B-0
sudo ./configure --prefix=/otp/erlang
sudo make
sudo make install
安装完毕,可以rm -fr opt-src-R12B-0删除源代码
然后改改/etc/profile
export PATH=/opt/erlang/bin:$PATH
alias ls='ls -color=auto'
alias ll='ll -lht'
可以source /etc/profile一下,及时修改PATH