2D网络游戏开发

njustbxh 贡献于2012-07-16

作者 zhouhj  创建于2010-12-17 05:02:00   修改者zhouhj  修改于2010-12-17 05:03:00字数47523

文档摘要:“2D网游开发”,我有时都觉得这个标题包含的内容太多,要实现起来也太难。于是,我决定将它分门别类,我按照我要实现的一个网络游戏将它分成下面几个部分:
关键词:

 2D网络游戏开发 第一讲 “2D网游开发”,我有时都觉得这个标题包含的内容太多,要实现起来也太难。于是,我决定将它分门别类,我按照我要实现的一个网络游戏将它分成下面几个部分: ? 客户端 ? 网络端 ? 服务器端 ? 网页端 我们来讲解一下,我们分别要在每个端口完成什么内容: (1) 客户端 劈开接受网络数据不谈,我们主要在客户端实现游戏界面的显示,游戏界面又包括那些呢?比如,游戏地图的显示,精灵的显示,UI(用户界面)的显示,还有就是一些游戏规则的制定等等。 (2) 网络端 网络端我们要做些什么呢?我想主要包括两个方面:在服务器端,从数据库中取出数据,然后将数据发送给客户端,从客户端得到数据,然后更新数据库;而在客户端,我们要干些什么呢?从网络中取出数据,然后更新游戏变量,得到游戏变量,然后将它发送给服务器。这里,我只是谈了一下网络端的大概内容,实际中可能还要修改。 (3) 服务器端 服务器端的主要内容,我想就是和数据库打交道。因为我们是通过网站来注册用户,所以,服务器端的主要内容就是取数据库内容,更新数据库内容。可能会涉及到删数据库内容,但这不常见。 (4) 网页端 网页端实现的主要内容,我想大概就是用户的注册,修改,信息的发布,玩家的交流和互动。 我在这里大概讲解了2D网游开发开发所涉及的内容,可能这些内容会随着实际开发修改,再修改。 我想我制作的这个游戏客户端用SDL,网络端用Raknet,数据库用mySQL,网站制作用JSP。 这些内容可能不能算作一篇文章,我想叫心得还可以。当然,我也希望我的这些心得不会让你误入歧途。 既然这篇心得叫“2D网络游戏开发(网络篇)”,我就不会写上其它的一些内容,我会在这以下的文章中写我在使用Raknet的一些感受,同样,我希望它对你有用。 反正,我觉得它很有用。 下面,我们就开始吧! 大概在这半年时间里,我接触了3款网络引擎,它们分别是: ? openTNL (http://www.opentnl.org ) ? SDL_net (http://www.libsdl.org/projects/SDL_net/ ) ? Radnet (http://www.rakkarsoft.com ) 三款网络引擎都是为游戏设计的,下面我来谈一谈我对三款引擎的看法。 OpenTNL 来自于Torque 3D游戏引擎,关于Torque 的信息,请访问 http://www.garagegames.com/ 。应该说openTNL是Torque 的一部分。可以跨平台运行,也有许多丰富的文档和教程,但它却有一个致命的弱点――使用过于复杂。因为Torque属于那种早期的游戏引擎,所以在OpenTNL中,很多的编码方式都与你所学的不同,你需要花费很多的时间去学习它。我想这也是为什么OpenTNL没有做过许多项目的原因。 SDL_net 来源于SDL,也是一个跨平台的网络引擎。SDL_net使用C语言写成,学习起来也简单明了,但SDL_net太年轻了,只发展了短短几年时间。因此,SDL_net还存在太多的BUG(错误),另外缺少足够的支持文件也是它发展缓慢的原因,毕竟,它太年轻了。 Radnet 可以说是基于上述两款引擎的优点为一体。它既有OpenTNL的文档丰富,又有SDL_net的使用简单。 在接下来的日子里,我们将讲解如何使用Radnet,让你充分享受网络给你的快感。 Radnet是一个基于UDP网络传输协议的C++网络库,允许程序员在他们自己的程序中实现高效的网络传输服务。通常情况下用于游戏,但也可以用于其它项目。 Radnet有以下好处: ? 高性能 在同一台计算机上,Radnet可以实现在两个程序之间每秒传输25,000条信息; ? 容易使用 Radnet有在线用户手册,视频教程。每一个函数和类都有详细的讲解,每一个功能都有自己的例程; ? 跨平台,当前Radnet支持Windows, Linux, Macs,可以建立在Visual Studio, GCC, Code: Blocks, DevCPP 和其它平台上; ? 在线技术支持 RakNet有一个活跃的论坛,邮件列表,你只要给他们发信,他们可以在几小时之内回复你。 ? 安全的传输 RakNet在你的代码中自动使用SHA1, AES128, SYN,用RSA避免传输受到攻击 ? 音频传输 用Speex编码解码,8位的音频只需要每秒500字节传输。 ? 远程终端 用RakNet,你能远程管理你的程序,包括程序的设置,密码的管理和日志的管理。 ? 目录服务器 目录服务器允许服务器列举他们自己需要的客户端,并与他们连接。 ? Autopatcher Autopatcher系统将限制客户端传输到服务端的文件,这样是为了避免一些不合法的用户将一些不合法的文件传输到服务端。 ? 对象重载系统 ? 网络数据压缩 BitStream类允许压缩矢量,矩阵,四元数和在-1到1之间的实数。 ? 远程功能调用 ? 强健的通信层 可以保障信息按照不同的信道传输 RakNet支持两种版权,如果你是做免费游戏,RakNet将是免费的。相反,你必须支付一定的费用。 从这里你可以下载到最新的RakNet: http://www.rakkarsoft.com/raknet/downloads/RakNet.zip 关于RakNet的设置方式,我们将在下一篇讲解。 2D网络游戏开发 第二讲 在上一章中,我简单的讲解了什么是Raknet,它有那些好处。在这一章中,我们将讲解如何在IDE中配置Raknet,并将测试一个程序。 由于Raknet的作者使用的是VC++.NET,所以在作者的主页上,他详细地讲解了如何在VC++.NET中配置Raknet,如果你使用的VC++.NET,可以参看上面的文章。 而我使用的是DC++,因此,我将讲解如何在DC++中配置Raknet。如果你使用的是VC6,我建议你立即升级到.NET。因为在VC6中配置很麻烦,Raknet中的许多函数库,它都没有,需要重新安装,其中就包括STL。 将Raknet.zip解压后,并不能直接使用,因为它没有LIB文件,这需要你重新编译。在Raknet文件下,有多个项目文件,有VC的和DC的。我们需要的就是DC的。如果你先安装了DC++,那马上你就能看见DC的项目文件图标。 打开它,然后按“F9”,编译文件。如果成功的话,你将在Raknet文件中发现以RakNet.a为名称的文件,这就是DC++使用的库文件。 在VC中,库文件扩展名是以LIB结尾,而在DC中是以A结尾。 将你的RakNet.a拷贝到你的DC++文件中LIB目录下,地址参考:c:dev-cpplib. 然后将解压后的RakNet文件中的include目录下的所有文件拷贝到DC++文件中include目录下,地址参考:c:dev-cppinclude. 到现在为止,我们的文件拷贝算是完成,然后打开DC,新建一个项目。 然后建立一个空的项目: 在“Project”中选择”Project Option”. 在“Parameters”表中”Linker”选项中添加下面的语句: lib/RakNet.a lib/libws2_32.a 然后选择OK。 项目配置完成。 然后新建一个源代码文件,改名为main.cpp. 打开一个Raknet的例子,路径参考为: E:RakNetSamplesCode SamplesChat Example 打开一个C++文件,如:Chat Example Server.cpp. 将Chat Example Server.cpp中的内容全部拷贝到main.cpp文件中。 这是一个服务器文件,按“F9“,编译后,显示结果如下: 如果你的程序执行结果如上图,说明你已经配置好了;如果没有,可能你在某个地方出错了,请认真检查一下。 下一节中,我们将讲解RakNet中的函数。 2D网络游戏开发 第三讲 在前面的章节中,我们已经讲解了Raknet是什么,如何在DC中配置Raknet,并测试了我们的第一个程序。 在这一篇中,我们将讲解Raknet的函数,并将写出我们的第一程序。 因为Raknet是基于Berkeley Sockets和Winsock开发的,所以它支持WINDOWS系统和LINUX系统。可以在局域网,因特网上运行。 当今的游戏大多支持两种模式的网络连接:对等模式和客服端/服务器模式。其实,在现今的在线休闲游戏中,这两种模式都支持。 Raknet支持上述的两种模式。 在网络上,我们传输信息一般都是依靠TCP/IP协议的,而TCP/IP协议中传输信息的协议又包括TCP和UDP。 TCP是指的什么呢?它是指的面向连接的虚电路协议。也就是说,它在发送数据之前,要和用户建立连接,并一直保持和用户连接,然后发送数据,并不断询问用户是否收到正确的数据,如果不正确,就重发,直到正确为止。 UDP又是指的什么呢?它是指用户数据报协议,它在发送数据之前,先和用户建立连接,连接建立好以后,并不一直保持和用户的连接,然后发送数据,也不管对方是否收到数据,然后关闭连接。 从上面的描述可以看出TCP是相当可靠的一种连接方式,但它并不适合于游戏中。你可以想一下,如果一个玩家和服务器建立TCP连接,那么要等到这个用户断开和服务器的连接以后,其它用户才能使用服务器。这显然是不行的。 而UDP能做到和多个用户同时通信。例如,一个玩家要取得他的个人资料,然后他向服务器发出一个请求,服务器用UDP回答他,并关闭和他的连接;服务器然后就可以处理其他玩家的信息了。 我们在本例中要使用三个头文件: RakClientInterface.h RakNetworkFactory.h RakServerInterface.h 第一个头文件包含了建立客服端所需要的信息,其中包括客服端的建立,连接和数据的发送和接收。 第二个头文件用于管理我们在程序中使用的类,包括类内存分配和类内存的释放。 第三个头文件用于建立服务器所需用的信息,包括服务器的建立,连接和数据的发送和接收。 char str[512]; RakClientInterface *rakClientInterface; RakServerInterface *rakServerInterface; str[512]是用来判断我们是要建立服务器还是客户端。接下来就声明一个客户端实例和一个服务器实例。 printf("(C)客服端 (S)服务器?n"); gets(str); if (str[0]=='c') { rakClientInterface=RakNetworkFactory::GetRakClientInterface(); rakServerInterface=0; printf("客服端已经建立。"); } else { rakClientInterface=0; rakServerInterface=RakNetworkFactory::GetRakServerInterface(); printf("服务器已经建立。"); } 得到一个输入值,如果输入值为c,就建立客户端,然后将服务器实例设置为空,如果输入值为其它,就建立服务器,然后就将客户端实例设置为空。 RakNetworkFactory::GetRakClientInterface(); 初始化一个客户端实例,为它分配内存; RakNetworkFactory::GetRakServerInterface(); 初始化一个服务器实例,为它分配内存; 最后,程序执行完成,我们就需要释放掉我们刚才分配的内存。 if (rakClientInterface) RakNetworkFactory::DestroyRakClientInterface(rakClientInterface); else if (rakServerInterface) RakNetworkFactory::DestroyRakServerInterface(rakServerInterface); 完整的程序代码如下: #include "stdio.h" #include "conio.h" #include "raknet/RakClientInterface.h" #include "raknet/RakNetworkFactory.h" #include "raknet/RakServerInterface.h" int main(void) { char str[512]; RakClientInterface *rakClientInterface; RakServerInterface *rakServerInterface; printf("(C)客服端 (S)服务器?n"); gets(str); if (str[0]=='c') { rakClientInterface=RakNetworkFactory::GetRakClientInterface(); rakServerInterface=0; printf("客服端已经建立。"); } else { rakClientInterface=0; rakServerInterface=RakNetworkFactory::GetRakServerInterface(); printf("服务器已经建立。"); } // TODO - Add code body here getch(); if (rakClientInterface) RakNetworkFactory::DestroyRakClientInterface(rakClientInterface); else if (rakServerInterface) RakNetworkFactory::DestroyRakServerInterface(rakServerInterface); return 0; } 程序执行结果如图,你可以建立客户端或服务器: 2D网络游戏开发 第四讲 在上一篇中,我们只是讲解了如何建立一个服务器或客户端。这一篇中,我们将讲解客户端如何和服务器进行连接。 #include "stdio.h" // Printf and gets #include "string.h" // strcpy #include "RakClientInterface.h" #include "RakNetworkFactory.h" #include "RakServerInterface.h" #include "PacketEnumerations.h" 这是我们程序中包括的头文件,其中最主要的就是多了一个PacketEnumerations.h。这个头文件是干什么的呢? 打开它的文件可以看到就是一些宏变量,用于处理网络引擎在运行过程中得到的信息。我在这里就不一一进行翻译,在以后的应用中,我们将进行详细得讲解。 首先,在变量的声明中,多了一个包的声明: Packet *packet; Packet是网络传输中用于存储数据的一个数据结构,它的结构如下: Struct Packet { PlayerID playerId; Unsigned long length; Unsigned long bitsize; Char *data; } PlayerID表明了包的出处。每一个连接服务器的客户端都将被分配一个唯一的ID号,用于标识自己。 Length和bitsize告诉你这个结构中的数据长度和比特大小。 *data 就是这个包中的数据。 然后,我们就建立客户端或服务器,代码和前面的一样。 客户端或服务器建立好以后,我们就判断建立的是客户端还是服务器: if (rakServerInterface) { // 服务器运行在端口60000处 rakServerInterface->Start(32, 0, 0, 60000); } else { // 运行客户端 printf("输入服务器IP地址:n"); gets(str); // 127.0.0.1 designates the feedback loop so we can test on one computer if (str[0]==0) strcpy(str, "127.0.0.1"); rakClientInterface->Connect(str, 60000, 0, 0, 0); } 在rakServerInterface->Start(32, 0, 0, 60000);这个语句中,第一个参数表明你的服务器允许同时连接多少个客户端,在这里,我们设置的是32。就表示同时可连接32个客户端。这个参数最大可以设置成65535;第二个参数做保留之用,设置成0;第三个参数用于设置多久进行服务器更新,参数要大于等于0,表示用每隔当前设置的毫秒数进行更新,这里设置的是0;最后一个参数用于设置服务器的端口 (值得注意的是,客户端的端口应和服务器的端口一样),另外,设置的端口号应该在32000之上,因为,在32000之下的端口都被保留了,用于其它,诸如WWW,FTP,POP3等服务了。 我们接下来看一下rakClientInterface->Connect(str, 60000, 0, 0, 0),这个方法用于客户端连接服务器。第一个参数表示你要连接的服务器的IP地址,如果是在自己这台计算机调试程序,直接输入”127.0.0.1”或“localhost”;第二个参数表示要连接的服务器的端口;第三个参数表示要连接的客户端端口,主要就是用于客户端之间交换数据;第四个参数不要;第五个参数和服务器start函数中的第三个参数一样. 然后,我们就在循环中处理数据: while (1) { if (rakServerInterface) packet=rakServerInterface->Receive(); else packet=rakClientInterface->Receive(); if (packet) { switch (packet->data[0]) { case ID_REMOTE_DISCONNECTION_NOTIFICATION: printf("另一个连接已经断开.n"); break; case ID_REMOTE_CONNECTION_LOST: printf("一个客户端丢失连接.n"); break; case ID_REMOTE_NEW_INCOMING_CONNECTION: printf("一个客户端已上线.n"); break; case ID_CONNECTION_REQUEST_ACCEPTED: printf("我们的连接要求已经接受.n"); break; case ID_NEW_INCOMING_CONNECTION: printf("有新连接.n"); break; case ID_NO_FREE_INCOMING_CONNECTIONS: printf("服务器已满.n"); break; case ID_DISCONNECTION_NOTIFICATION: if (rakServerInterface) printf("客户端丢失.n"); else printf("连接中断.n"); break; case ID_CONNECTION_LOST: if (rakServerInterface) printf("客户端丢失连接.n"); else printf("连接丢失.n"); break; default: printf("ID信息 %i 已经到达.n", packet->data[0]); break; } if (rakServerInterface) rakServerInterface->DeallocatePacket(packet); else rakClientInterface->DeallocatePacket(packet); } } 下面,我们详细地讲解它们的作用。 if (rakServerInterface) packet=rakServerInterface->Receive(); else packet=rakClientInterface->Receive(); 从服务器或客户端接受数据,将它保存在packet中。 接下来进行处理。 因为网络引擎在运行过程中要返回一些信息,这些信息有的是给客户端的,有的是给服务器的,而有的是两个都给的。 Packet中返回的第一个data[0]表明了这些类型,这些类型的解释在PacketEnumerations.h中。 由于篇幅的关系,我在这里就不一一进行解释,大家还是自己去看吧。 if (rakServerInterface) rakServerInterface->DeallocatePacket(packet); else rakClientInterface->DeallocatePacket(packet); 是指接收处理好了的包,让它生效。 最后,和前面一篇文章一样,释放掉我们所占有的内存。 if (rakClientInterface) RakNetworkFactory::DestroyRakClientInterface(rakClientInterface); else if (rakServerInterface) RakNetworkFactory::DestroyRakServerInterface(rakServerInterface); return 0; } 服务器截图: 图注1 客户端截图: 图注2 2D网络游戏开发 第五讲 在第四篇中,我们学习了如何使用raknet进行服务器和客户端的连接,在这一篇中,我们将讲解如何让客户端和服务器进行通信,比如说聊天。 好吧,其实我已经知道你等不及了,那就让我们开始吧。 程序代码和“2D网络游戏开发(网络篇)(四)”中的一样,只是我们需要在其中添加一些内容。 在进入循环之前,我们需要定义一个信息变量,用于存储我们将要发送的信息。 char message[2048]; 进入循环,在循环开始处,也就是接收信息之前,添加下面代码: if(kbhit()) { gets(message); if(rakServerInterface) { rakServerInterface->Send(message, (const int) strlen(message)+1, HIGH_PRIORITY, RELIABLE_ORDERED, 0, UNASSIGNED_PLAYER_ID, true); } else { rakClientInterface->Send(message, (int) strlen(message)+1, HIGH_PRIORITY, RELIABLE_ORDERED, 0); } } 好吧,让我们来分析一下上面这段代码。 kbhit()用于监测是否有按键,如果没有,先得到信息,保存在message中,然后监测你建立的是服务器还是客户端。 如果是服务器,就用服务器发送数据,相反就用客户端发送数据。 下面我们来具体分析一下服务器的send()函数: 第一个参数,指向你要发送的数据; 第二个参数,你发送的数据的大小,也许你就会问,为什么要加一个1呢?那是因为我们的数据都是按照数据流发送的,如果你不在数据之间留下空格,网络引擎就无法分辨出数据,所以我们要在每个数据之后加上一个空格。也就相当于把一根线切成一截一截的。 第三个参数,用于设置你发送数据的重要性,一共有三个参数: HIGH_PRIORITY MEDIUM_PRIORITY LOW_PRIORITY 分别是重要性高,中,底。网络引擎在发送数据时,首先要将数据排队,按照他的重要性来发送,重要性高的先发送,否则就后发。 第四个参数,可靠性,一个有五个参数: UNRELIABLE - 5, 1, 6 UNRELIABLE_SEQUENCED - 5 RELIABLE - 5, 1, 4, 6, 2, 3 RELIABLE_ORDERED - 1, 2, 3, 4, 5, 6 RELIABLE_SEQUENCED - 5, 6 可靠性又表示什么呢?也就是在网络引擎发送数据时,如果你选择可靠的发送数据,那么数据就会按照正确的循序到达,而如果选择不可靠,那么数据可能就是无续的到达了。 通常使用的有RELIABLE - 5, 1, 4, 6, 2, 3和RELIABLE_ORDERED - 1, 2, 3, 4, 5, 6。 第五个参数,通常选择为0,不是很重要。 第六个参数,接收者的ID,直接用UNASSIGNED_PLAYER_ID 进行设置。 第七个参数,是否广播,有两个参数0和1,0表示不广播,1表示将这条信息发送到和服务器连接的所有客户端。 rakClientInterface的send函数和服务端的参数是一样的,只是少了最后的两个。 最后,我们还得修改下面的内容: 在 default: 之下,将程序修改成如下: printf("%sn", packet->data); if(rakServerInterface) { sprintf(message,"%s",packet->data); rakServerInterface->Send(message, (const int) strlen(message)+1, HIGH_PRIORITY, RELIABLE_ORDERED, 0, UNASSIGNED_PLAYER_ID, true); } 第一条语句是打印我们收到的数据; 如果我们建立的是服务器,那么就将我们接收到的数据转换成char格式,(因为packet->data的数据格式是unsigned char),然后将这条数据广播给服务器中所有的客户端。 到这里,我们的程序完成,运行效果如下: 服务器: 图注1 客户端1: 图注2 客户端2 图注3 OK,今天的内容就到这里了,祝你们周末玩得高兴,玩得愉快! 2D网络游戏开发 第六讲 在前面的章节中,我们实现了一个简单的聊天室。今天,我们仍然要围绕这个主题,但采取别的方法,这个方法很有用,应该说是整个网络引擎的关键,它就是DDRPC(Remote Procedure Calls),翻译成中文就可以理解成”远程功能调用”。 通常情况下,你发送一个信息,你必须实现下面的四个步骤: 1. 建一个数据包来存储你的数据; 2. 必须建立一个函数来实现数据包的编码和发送; 3. 建立一个数据包识别函数,用来识别你的数据包,以便于你调用哪个函数来处理它; 4. 最后建立一个函数来解码你的数据包并且处理它。    以上就是我们在编写网络程序要做的四个步骤。 但raknet已经为你做好了这一切,那就是RPC,有了它,你只用实现两个步骤,这样你就有更多的时间集中到游戏上。   1. 将你的数据编码; 2. 然后调用远程系统的一个相应函数来处理它. RPC在你的游戏中实现的过程如下: A. 告诉网络系统允许使用RPC调用函数 当然,你不需要RPC调用系统中的任何一个函数,这样势必为你带来很多的麻烦。你只需要几个用RPC参数定义的函数就行了,你可以照着下面的例子来定义你的RPC函数。 C函数 void MyFunction(RPCParameters *rpcParameters) {} // 客户端指针 RakClient *rakClient; //注册成为RPC REGISTER_AS_REMOTE_PROCEDURE_CALL(rakClient, MyFunction); C++静态函数 static void MyClass::MyFunction(RPCParameters *rpcParameters) {} // 客户端指针 RakClient *rakClient; //注册成为RPC REGISTER_AS_REMOTE_PROCEDURE_CALL(rakClient, MyClass::MyFunction); C++ 成员函数 class MyClass : public NetworkIDGenerator { void __cdecl func1(RPCParameters *rpcParms); }; //客户端指针 RakClient *rakClient; //注册成为RPC REGISTER_CLASS_MEMBER_RPC(rakClient, MyClass, func1) 服务器的注册同上一样。 B.给你的数据编码 你的RPC方法能够处理一个有长度的字符串或比特流,这就等同于将数据打包。 C.调用RPC函数进行处理 D.在远程系统上相应的函数将对你的数据进行处理 以上就是RPC的处理过程。 下面,我们来看一下RPC的参数和结构: char * input; 来自于远程系统的数据; unsigned int numberOfBitsOfData; 我们接收的数据的大小; PlayerID sender; 哪一个系统调用这个RPC; RakPeerInterface * recipient; rakpeer中的哪一个实例将得到这次调用; Bool hasTimestamp; 如果为真,那么输入的开始4个字节表示时间; RakNet::BitStream * replyToSender 用相应的数据流回应发送者. 下面,我们来具体地看一下代码: char message1[300]; void PrintMessage(RPCParameters *rpcParameters) { printf("%sn",rpcParameters->input); sprintf(message1,"%s",rpcParameters->input); if(rakServerInterface) { rakServerInterface->RPC("PrintMessage", message1, (strlen(message1)+1)*8, HIGH_PRIORITY, RELIABLE_ORDERED, 0,rpcParameters->sender, true, false, UNASSIGNED_NETWORK_ID,0); } } 下面,我们来具体地讲解这个函数。 Message1用于存储得到的信息。首先,我们打印我们得到的信息,然后判断我们是否是服务器,如果是,那么就调用客户端的RPC. 这里这个RPC函数原型如下: bool RakServer::RPC ( char * uniqueID, const char * data, unsigned int bitLength, PacketPriority priority, PacketReliability reliability, char orderingChannel, PlayerID playerId, bool broadcast, bool shiftTimestamp, ObjectID objectID, RakNet::BitStream * replyFromTarget ) [virtual, inherited] 第一个参数,我们注册的RPC函数名; 第二个参数,我们要发送的数据; 第三个参数,发送的数据的大小; 第四个参数,数据包的安全级别,和send函数一样; 第五个参数,数据包的可靠性,和send函数一样; 第六个参数,和send函数一样; 第七个参数,接收者ID; 第八个参数,是否广播; 第九个参数,与时间有关,以后讲解; 第十个参数,如果是静态函数,直接设置成UNASSIGNED_OBJECT_ID 第十一个参数,保留。 if (rakServerInterface) { // 服务器运行在端口60000处 rakServerInterface->Start(32, 0, 0, 60000); REGISTER_STATIC_RPC(rakServerInterface, PrintMessage); } else { // 运行客户端 printf("输入服务器IP地址:n"); gets(str); // 127.0.0.1 designates the feedback loop so we can test on one computer if (str[0]==0) strcpy(str, "127.0.0.1"); rakClientInterface->Connect(str, 60000, 0, 0, 0); REGISTER_STATIC_RPC(rakClientInterface, PrintMessage); } 在服务器或客户端注册RPC。 gets(message); if(rakServerInterface) { rakServerInterface->RPC("PrintMessage", message, (strlen(message)+1)*8, HIGH_PRIORITY, RELIABLE_ORDERED, 0,UNASSIGNED_PLAYER_ID , true, false, UNASSIGNED_NETWORK_ID,0); } else { rakClientInterface->RPC("PrintMessage", message, (strlen(message)+1)*8, HIGH_PRIORITY, RELIABLE_ORDERED, 0, false, UNASSIGNED_NETWORK_ID,0); } 得到信息,然后调用RPC。 其它代码不用改动,程序运行的效果如下: 图注1 图注2 OK,今天的内容就到这里了。 2D网络游戏开发 第七讲 在前面的章节中,我们讲解了如何在服务器和客户端之间如何传输信息,今天,我们将讲解在它们之间如何传输文件。这个功能很重要,比如,在网络游戏中,为了保持游戏的新颖,游戏每次开始时都需要更新数据。 而我们今天的内容就是讲解如何在客户端和服务器之间传输文件。 这个系统的名字叫自动补丁系统(autopatcher),它不包括在raknet库文件中。是一个分开的类,在后面,我们将讲解它的使用。 Autopatcher系统主要的工作就是在两个和两个以上的系统之间传输一些它们之前没有的,或是改变了的文件。它主要的工作就是传输文件,压缩这些传输文件,保证文件传输安全和文件操作。它并不提供基本的连接或用户接口,(这些你可以采用raknet中的功能完成)。Autopatcher通常用于商业游戏。 每个系统都需要建立autopatcher类实例。Autopatcher类包含四个文件Autopatcher.cpp, Autopatcher.h, DownloadableFileDescriptor.cpp, and DownloadableFileDescriptor.h。再次重申,Autopatcher并不包含在raknet库文件中,所以调试的时候有点不同。 这些文件都包含在Source/Autopatcher目录下,在编译时,要包含这个文件夹下的所有文件。因为在实际的游戏编程中可能需要修改这些文件。 接下来,就是要告诉autopatcher哪些文件可以被下载。这件事可以用SetFileDownloadable(char *filename, bool checkFileSignature)函数来设置。这个函数的作用就是将文件读入到内存,如果文件很大,就需要压缩它,最后将这些可下载文件添加到一个中心列表上。函数的第一个参数是文件的路径。第二个参数是一个bool值,这个参数的目的就是用于数字签名,用于检测文件在传输过程中是否被修改。如果设置成真的话,就将有一个和我们的文件名相同的文件XX.sha来描述我们的文件。你可以调用CreateFileSignature函数来建立这个签名文件。如何文件签名检测失败,SetFileDownloadable将返回SET_FILE_DOWNLOADABLE_FILE_NO_SIGNATURE_FILE或者SET_FILE_DOWNLOADABLE_FILE_SIGNATURE_CHECK_FAILED. 假如这些系统都已经连接,如果你想下载的话,就可以调用RequestDownloadableFileList函数来建立下载系统;如果你只想更新的话,就可以调用SendDownloadableFileList来建立你的更新系统。 当你得到你的Packet::data中的第一个字节包含下面的包ID时: ID_AUTOPATCHER_FILE_LIST ID_AUTOPATCHER_REQUEST_FILES ID_AUTOPATCHER_SET_DOWNLOAD_LIST ID_AUTOPATCHER_WRITE_FILE 就将将那整个包发送给相应的函数: void OnAutopatcherFileList(Packet *packet,bool onlyAcceptFilesIfRequested); void OnAutopatcherRequestFiles(Packet *packet); void OnAutopatcherSetDownloadList(Packet *packet); void OnAutopatcherWriteFile(Packet *packet); 下面,我们来具体地看一下,代码是如何实现的。 首先,你需要在你的项目中设置一个参数,因为autopatcher在压缩文件的时候使用了zlib这个压缩包。因此,在编译文件时,你应该加载它。我使用的是dev c++,因此,我需要现下载一个zlib.devpak,然后安装它,在项目设置中,包含它们的库文件: lib/RakNet.a lib/libws2_32.a lib/libz.a 如果你使用VC,也需要同样的操作。 项目设置好了以后,就需要在代码中包含autopatcher的头文件: #include "Autopatcher.h" #include "Autopatcher.cpp" #include "DownloadableFileDescriptor.h" #include "DownloadableFileDescriptor.cpp" 这个例子在raknet的example文件下。 然后定义一个AutoPatcher 实例: AutoPatcher autoPatcher; 你需要写一个函数来分析你所下载文件的信息: unsigned int ShowDownloadStatus(AutoPatcher *autoPatcher) { char fileName[256]; unsigned numberOfDownloadingFiles; unsigned fileLength, compressedFileLength; bool dataIsCompressed; numberOfDownloadingFiles = autoPatcher->GetDownloadStatus(fileName, &fileLength, &dataIsCompressed, &compressedFileLength); if (numberOfDownloadingFiles>0) { printf("n%i 有多少个文件在下载列表中n", numberOfDownloadingFiles); printf("当前文件: %s (%i 字节数)n", fileName, fileLength); if (dataIsCompressed==false) printf("文件无压缩传输。n"); else printf("文件被压缩成了 %i 字节n",compressedFileLength); } else printf("当前没有可下载的文件n"); return numberOfDownloadingFiles; } 这个函数最后返回我们要下载的文件的个数。 其中,GetDownloadStatus函数原型如下: unsigned int AutoPatcher::GetDownloadStatus ( char * filename, unsigned * fileLength, bool * fileDataIsCompressed, unsigned * compressedFileLength ) 这个函数返回下载列表中有多少个文件。 第一个参数filename , 当前已下载的文件; 第二个参数fileLength, 当前已下载文件的长度; 第三个参数fileDatalsCompressed, 文件是否压缩; 第四个参数compressedFileLength, 如果文件压缩,返回它的大小。 这个函数返回下载列表中文件的个数。 我们在这个例子中使用的是peer to peer方式,这样做的好处是为了使其简单,关于c/s的方式,还在进一步研究当中。 autoPatcher.SetNetworkingSystem(rakPeer); SetNetworkingSystem函数的原型如下: void SetNetworkingSystem (RakPeerInterface *localSystem) void SetNetworkingSystem (RakClientInterface *localSystem) void SetNetworkingSystem (RakServerInterface *localSystem) 这个函数的作用就是设置使用哪个系统的实例来传输文件。 这些设置好了以后,下一步我们将在服务器端设置加载文件,在客户端设置下载文件,设置如下: 服务器端: if (AutoPatcher::CreateFileSignature(input)==false) printf("Unable to create file signature %s.shan", input); if (autoPatcher.SetFileDownloadable(input, true)!=SET_FILE_DOWNLOADABLE_SUCCESS) printf("Unable to make %s downloadablen", input); } printf("Enter name of second file to make downloadable or hit enter for nonen"); gets(input); if (input[0] && autoPatcher.SetFileDownloadable(input, false)!=SET_FILE_DOWNLOADABLE_SUCCESS) printf("Unable to make %s downloadablen", input); 前面两个if语句用于设置带签名的文件,最后一个if用于设置不带签名的下载。三个函数原型如下: bool AutoPatcher::CreateFileSignature ( char * filename ) [static] 为filename建立一个以.sha为扩展名的签名文件。 SetFileDownloadableResult AutoPatcher::SetFileDownloadable ( char * filename, bool checkFileSignature ) 这个函数,我们已经在前面讲解了。 然后需要在客户端设置文件的下载目录: autoPatcher.SetDownloadedFileDirectoryPrefix(input); 函数原型如下: void AutoPatcher::SetDownloadedFileDirectoryPrefix ( char * prefix ) 就是设置文件下载到什么地方,用prefix设置,例如 c:. 现在进入循环当中,在消息检测当中,加入下面的代码: case ID_AUTOPATCHER_REQUEST_FILE_LIST: printf("Got a request for the file listn"); autoPatcher.SendDownloadableFileList(packet->playerId); break; case ID_AUTOPATCHER_FILE_LIST: printf("n-------nGot the list of available server files.nRequesting downloads.n-------n"); autoPatcher.OnAutopatcherFileList(packet, true); if (ShowDownloadStatus(&autoPatcher)==0) { rakPeer->DeallocatePacket(packet); goto QUIT; } break; case ID_AUTOPATCHER_REQUEST_FILES: printf("Got a request for filesn"); autoPatcher.OnAutopatcherRequestFiles(packet); break; case ID_AUTOPATCHER_SET_DOWNLOAD_LIST: printf("* Confirmed download listn"); autoPatcher.OnAutopatcherSetDownloadList(packet); break; case ID_AUTOPATCHER_WRITE_FILE: printf("-------nGot a filen-------n"); autoPatcher.OnAutopatcherWriteFile(packet); if (ShowDownloadStatus(&autoPatcher)==0) { rakPeer->DeallocatePacket(packet); goto QUIT; } break; } autoPatcher.SendDownloadableFileList函数的原型如下: void AutoPatcher::SendDownloadableFileList ( const PlayerID remoteSystem ) 这个函数的作用就是将下载列表中的全部文件发送到以PlayerID为标致的客户端去。 .OnAutopatcherFileList的函数如下: void AutoPatcher::OnAutopatcherFileList ( Packet * packet, bool onlyAcceptFilesIfRequested ) 这个函数的作用就是分析服务器端的下载文件列表,看是否有我们没有的文件,或是不匹配的文件。然后以包的形式将分析结果传给服务器。 第二个参数,如果设置成真,那么就调用了这个函数以后就用GetDownloadStatus函数进行分析。如果设置成假,就马上下载文件。 autoPatcher.OnAutopatcherRequestFiles(packet); 这个函数原型如下: void AutoPatcher::OnAutopatcherRequestFiles ( Packet * packet ) 这个函数的作用就是从内存中读取文件,然后将它们发送到客户端。 autoPatcher.OnAutopatcherSetDownloadList(packet); 这个函数原型如下: void AutoPatcher::OnAutopatcherSetDownloadList ( Packet * packet ) 这个函数的作用就是服务器端同意下载下载列表上的文件。 autoPatcher.OnAutopatcherWriteFile(packet); 函数原型如下: bool AutoPatcher::OnAutopatcherWriteFile ( Packet * packet ) 这个函数的作用就是将接受到的文件全部写入到我们设置的硬盘里。 其它就没什么了,大家可以参考其源代码就在raknet的例程当中。关于程序执行的效果,请大家自己去编译。 Over! 2D网络游戏开发 第八讲 前言 已经写到raknet编程的第八篇了,在前面的内容当中,我们讲解了raknet如何传输普通的信息,可光是这些是不够的。因为一个游戏不可能就只传输这些信息,它们可能会传输数据结构。 而我们今天的内容就和数据结构有关。 建立一个数据包 建立什么样的数据包,或是在数据包中要包含什么样的信息完全由你想发送什么样的数据决定。而我们在游戏当中到底要发送什么样的信息呢?比如,我们要发送一个游戏玩家Mine在游戏世界中某一时间的坐标,那么我们就需要下面的数据: ? 玩家Mine在游戏世界中的坐标,包含3个浮点值:x,y,z。你可以用一个矢量值来表示。 ? 用一些方法来确定所有玩家都知道玩家Mine的存在。关于这个问题,可以直接采用Network ID Generator类来完成。我们假设类Mine从NetworkObject继承而来,那么其它所有玩家就不得不得到并存储Mine的ObjectID. ? 谁是玩家Mine,关于这个问题请参考players,PlayerID。如果你是在服务器上玩游戏,我们可以将你的playerID值设置成一些傀儡值,比如255。而如果在客户端,你可以用GetPlayerID得到它。 ? 当一个玩家Mine在某个地方,但可能10秒以后,它就到达另一个地方,因此最重要的是我们得到那相同的时间,这样做的好处就是避免玩家Mine在不同的计算机上拥有不同的时间。幸运的是,Raknet可以使用Timestamping来处理这个问题。 使用结构或比特流 最终,你发送数据将发送用户的角色的流。有两种方法可以编码你要发送的数据。一种方法是建立一个结构,另一个方法是内建一个比特流类。 建立一个结构的优点就是能够非常容易地改变结构并且看到你实际发送了什么数据。这样,发送者和接收者就能够共享结构中相同的源文件,这样做就避免了不必要的错误。建立结构的缺点就是你将不得不经常改变和重新编译许多文件。也将失去在比特流中传输时压缩的机会。 使用比特流的优点就是你不用改变任何其它的外部文件。简单地建立一个比特流,把你想发送的数据写入比特流,然后发送。你也可以通过压缩来读写数据,然后通过bool值来判断数据是否被压缩。比特流的缺点是你现在一旦范了一个错误,那就不容易那么改变。 用结构建立一个包 打开NetworkStructures.h 在文件中间将有一个大的部分,像下面这样: // ----------------------------------------- //你的结构在下面 //------------------------------------------ // ------------------------------------ //你的结构在这里 // --------------------------------- // ----------------------------------------------- // 你的结构在上面 // -------------------------------------- 有两个通用的形式用于结构中,一个是带有timestamping,一个是没有。 不带Timestamping的形式 #pragma pack(1) struct structName { unsigned char typeId; //把你的结构类型放在这里 //你的数据放在这里 }; 带 Timestamping的形式 #pragma pack(1) struct structName { unsigned char useTimeStamp; // 分配它到ID_TIMESTAMP unsigned long timeStamp; // 把通过timeGetTime()返回的系统时间放在这里 unsigned char typeId; // 结构类型放在这里 //你的数据放在这里 }; 对于前面我们的角色,我们在这里想使用timestamping,因此,结构填充的结果如下: #pragma pack(1) struct structName { unsigned char useTimeStamp; //分配这个到ID_TIMESTAMP unsigned long timestamp; //把由getTime()得到的系统时间放在这里 unsigned char typeId; //这将分配一个类型id到PacketEnumerations.h中,这里,我们可以说是ID_SET_TIMED_MINE float x,y,z; //角色Mine的坐标 ObjectID objected; //角色Mine的目标ID,用于在不同计算机上参考Mine的通用方法 PlayerID playerId; //角色Mine拥有的玩家的PlayerId }; 正如上面我们写的结构,我们添加typeId的目的就是当我们得到一个数据流到达时,我们将知道我们看见了什么。因此最后一步就是在PacketEnumerations.h中添加ID_SET_TIMED_MINE. 注意你不能在结构中直接或间接地包含指针。 你将注意到我在结构中调用了ObjectID objected和PlayerID playerId。为什么不使用一些更具有描述性的名字,比如mineId和mineOwerId? 我以我的实际经练告诉你,在实际运用中使用描述性的名字是不明智的。因为虽然你知道这些数据包中的参数意味着什么,但它们自己却并不知道。使用通用名字的优点是你可以通过粘贴,复制来处理你的数据包,而不用重新给这些数据包装填数据。当你有许多的数据包的时候,你将在一个很大的游戏中,这种方法保存了许多分歧。 巢结构  关于巢结构并没有多少问题,值得注意的是第一个字节总是决定了数据包的类型。 #pragma pack(1) struct A { unsigned char typeId; // ID_A }; #pragma pack(1) struct B { unsigned char typeId; //ID_A }; #pragma pack(1) struct C //结构C的类型和结构A的类型一样 { A a; B b; } #pragma pack(1) struct D //结构C的类型和结构A的类型一样 { B b; A al } 使用比特流来建立数据包 我们仍然使用上面的例子,但这次我们决定用比特流来重写他。我们有和前面相同的数据。 Unsigned char useTimeStamp; // 将这个分配给ID_TIMESTAMP Unsigned long timestamp; //把由getTime()返回的系统时间放在这里 Unsigned char typeId; //这里将把一个数据类型添加到PacketEnumerations.h文件中,这里我们设置的是ID_SET_TIMED_MINE UseTimeStamp=ID_TIMESTAMP; TimeStamp=getTime(); TypeId=ID_SET_TIMED_MINE; Bitstream myBitStream; MyBitStream.Write(useTimeStamp); MyBitStream.Write(timestamp); MyBitStream.Write(typeId); //假设我们有一个定义为Mine*的mine对象 myBitStream.Write(mine->GetPosition().x); myBitStream.Write(mine->GetPosition().y); myBitStream.Write(mine->GetPosition().z); myBitStream.Write(mine->GetID()); //在这个结构中,这是ObjectId 对象Id myBitStream.Write(mine->GetOwner()); //在这个结构中,这是PlayerID,玩家Id 如果我们现在直接使用RakClient::Send或RakServer::Send来发送我们的比特流将导致结构的混乱。现在让我们试着做一些改进。现在,假设我们玩家的坐标由于某种原因,在0,0,0,处。那么前面的代码将修改成下面的形式: unsigned char useTimeStamp; //分配这个到ID_TIMESTAMP unsigned long timestamp; //存储由函数getTime()返回的系统时间 unsigned char typeId; //分配一个类型到PacketEnumerations.h中,我们这里设置的是ID_SET_TIMED_MINE useTimeStamp=ID_TIMESTAMP; timestamp=getTime(); typeId=ID_SET_TIMED_MINE; Bitstream myBitStream; MyBitStream.Write(useTimeStamp); MyBitStream.Write(timestamp); MyBitStream.Write(typeId); If(mine->GetPosition().x==0.0f && mine->GetPostion().y==0.0f && mine->GetPosition().z==0.0f) { myBitStream.Write(true); } else { myBitStream.Write(false); myBitStream.Write(mine->GetPosition().x); myBitStream.Write(mine->GetPosition().y); myBitStream.Write(mine->GetPositon().z); } myBitStream.Write(mine->GetID()); //在这个结构中,这是物体 myBitStream.Write(mine->GetOwner()); //在这个结构中,这是玩家 写字符流 通过重载BitStream,可以用数组来写字符流。一个方法是先写数据长度,然后再写数据,内容如下: void WriteStringToBitStream(char *myString,BitStream *output) { output->Write((unsigned short) strlen(myString)); output->Write(myString,strlen(myString)); } 它们的编码是相同的,不管怎样,那不是很高效。Raknet有一个字符压缩类StringCompressor可以进行对字符串的压缩。如果我们想使用这个类来编码数据,可以写成下面的形式: void WriteStringToBitStream(char *myString,BitStream *output) { stringCompressor->EncodeString(myString,256,output); } 你可以通过下面的形式将压缩的字符串解压出来。 Void WriteBitStreamToString(char *myString,BitStream *input) { stringCompressor->DecodeString(myString,256,input); } 在这里,256表示了最大的字节数。在编码过程中,如果你的字符串少于256个字节,那么将写那整个字符串。相反,如果你的字符串大于256个字节,那么就需要将它截断,然后在256个字节的数组中进行编码,当然包括终结符null。 2D网络游戏开发 第九讲 前言 转眼之间,就来到了这一系列教程的第九课来了,事先声明一下,这些文章大多来源于raknet的官方网站或者例程。如果你觉得我的文章有地方看不懂,那也请你原谅我,因为我也和你一样,也是重头学习这个函数库。在这种情况下,我还是请你阅读它官方网站上的。 比特流简介 在上一篇文章中,我们已经讲解了什么是数据包,也涉及到比特流,但一直没有对比特流进行详细地描述。而我们今天在讲解接收数据包之前,先简单地讲解一下比特流。 描述 bitstream 是名字空间Raknet下的一个类,它的作用就是对动态数组进行打包和解包bitsteam主要有三个优点: 1. 动态地建立数据包 2. 压缩 3. 写比特 要使用bitsteam,你必须首先预定义你的结构,并且把它们转换成(char*)形式。依赖与具体内容,你可以选择在运行的时候写数据块。Bitstream也可以根据数据包的类型进行压缩。 压缩采用下面的方法,比较的简单: 1. 上面的一半是否全是0(1表示无符号类型)?(译者注:不知道这里是否翻译正确,我也不明白,对不起哦) TRUE C 写一个1 FALSE - 上面的一半为0 2.重复那下面的一半,直到字节的一半 这意味着如果你的数据在它自己最大范围一半的情况下,你将保存比特流。这样你就可以用WriteCompressed来代替Write和用ReadCompressed 来代替Read。 最后,你可以写比特。但大多数时间,你不会注意到这些。不管怎样,当写bool值时,将自动只写一个比特。这对你的数据的加密也很有用。 写数据 建立一个bitstream,然后为你的每个数据类型建立一个写方法。如果你的数据类型是Raknet中自带的,那么正确的重载函数将被调用。如果你的数据类型是你自己的类型,那么你必须向下面这样做。 BitStream.Write((char*)&myType,sizeof(myType); 这是构造函数中的一个带参数的版本。 读数据 读数据是同样简单。建立一个bitstream,然后在构造函数中将它指派给你的数据。 BitStream.myBitStream(packet->data,packet->length,false); 主要函数 构造函数 BitStream(int initialBytesToAllocate); 这个构造函数的作用是决定预先分配多少内存给这个bitstream。 构造函数 BitStream(const char* _data, unsigned int lengthInBytes,bool _copyData); 这个版本的构造函数是给bitstream一些初始数据。这通常用于将当前数据流解释成bitstream,最后一个参数_copyData如果为false,那么指针将指向当前的数据。如果为true,那么你可能想改变数据,想做一个内部的拷贝。 写数据函数 写数据函数用于在比特流结尾将数据变换成比特流,你将使用相反的方法Read将数读回来。 写压缩数据函数 就是将经过压缩后的数据写入bitstream,你同样可以使用相反的方法ReadCompressed将数据读回来。 读数据函数 read数据函数用于按顺序从比特流中将数据读出来。如果在bitsteam中没有数据,就返回false。 读压缩数据函数 与前面相同,只不过是对压缩数据进行操作。 GetNumberOfBitsUsed GetNumberOfBytesUsed 得到写入比特流的字节数或比特数。 GetData 给你一个指针,指向内部数据。可以让你直接访问数据。 Over! 结尾语 本来还打算讲解一下如何接收数据包,但我好想玩一下3D哦,大家就可怜可怜我,让我玩一下3D嘛…..不关它,先玩了再说。 2D网络游戏开发 第十讲 前言 其实,我也很想将游戏开发简单化,可不管我怎么努力,游戏开发还是那么难,还是涉及那么多知识。同样,在这里,我也希望我的翻译和讲解能够让你明白,为了使你和我更容易地理解游戏开发的内容,因此你的反馈对我很重要,我的邮箱是akinggw@126.com 。欢迎你提出你宝贵的意见。最近天气比较热,因此,我也改编程为翻译,希望没有译错。 发送数据包 决定你的数据 关于这个东西,已经在建立数据包那一篇讲解了,你最好翻看前面的内容。 决定使用那种方式来进行 你通常是想发送一个触发动作的标志,而不是发送动作的结果。通常来说,一个数据包的来源主要集中在下面三个方面: 来自于一个函数来触发动作 来自于一个标志驱使函数去触发动作 来自于一个数据管理器 它们每种方法都有自己的优点和缺点。 来自于一个函数来触发动作 例子: 假如我们有一个函数叫ShootBullet,带有大量的参数,包括射击的方式,在哪里射击和向哪个方向射击。每次ShootBullet函数一旦调用,那么我就将发送一个数据包去告诉网络,已经有事情发生了。 这种方法的优点 很容易维护。ShootBullet函数可能在许多不同的地方被调用(比如,鼠标输出,键盘输出,AI等),因此,我们就不得不保证在每一个接收到的地方的一致性。这种方法在现在的单玩家游戏中很容易实现。 这种方法的缺点 编程实现很难。如果我用ShootBullet去初始那个包,那么当网络想实现这个函数时,它将调用什么?如果ShootBullet初始那数据包,并且网络调用ShootBullet函数,那么另一个数据包将寄出,建立一个反馈循环。因此,我能写另一个函数,比如DoShootBullet,或者传输一个参数到ShootBullet,告诉它是否发送一个数据包。我们也不得不考虑授权,客户端是直接发出子弹,还是在发出子弹之前,要经过服务器授权?如果需要授权,那么ShootBullet将发送一个数据包并且马上返回,只有一种情况不会返回,那就是它被网络调用并且它不能发送数据包。那网络也许还需要一些ShootBullet函数没有的附加信息,比如子弹存在的数量。有时这些可以从上下文中得到,但是有时也不能。 来自于一个标志驱使函数去触发动作 我们还是使用上面的例子,但这次有一点不同,我们这次是在ShootBullet函数内部发送数据包,而何时发送数据包是通过一个标志来确定。例如,当用户点击鼠标,AI决定射击或空格键被按下。 优点 我可以在网络中调用ShootBullet函数,而不用担心返回循环。并且我将得到更多函数以外的信息可以利用。因此,利用网络,可以很容易地发送数据。 缺点 难于维护。如果我稍后添加另一种方法射击子弹,可能我会忘记发送数据包。 来自于一个数据管理器 例子 每次,一个玩家的生命值到达0,发送一个数据包。不管怎样,我不会这样做。我会把它添加到一些运行每一帧的一些函数里,也许在代码中实现玩家的更新。当代码发现玩家的生命值是0时,它马上发送一个数据包。然后注册这个已经发送的数据包以避免又一次重新被发送。 优点 从网络来看,非常的清楚。我不得不担心反馈和在实际动作中改变那函数。不用维护,除非一些人改变了它。还有就是非常的高效。 缺点 但是从设计的角度讲,非常的不整洁并且只能用于数据的一定类型。另外就是不得不反复设置。 决定可靠性的类型和你需要什么样的顺序流 PcaketPriority.h 包含了可靠性的类型。你需要从三中级别中作出选择:HIGH_PRIORITY, MEDIUM_PRIORITY, LOW_PRIORITY。 高可靠性的数据包比中可靠性的数据包先寄出,而中可靠性的数据包比低可靠性的数据包先寄出。在游戏中,你将用RELIABLE_ORDERED来设置数据包。 调用客户端的服务器的发送函数 发送函数并不会改变你的数据,但是会做一次拷贝。 顺序流 对于顺序数据包,有32种顺序流,而对于连续的数据包,同样有32种顺序流。你能把流想象成一种相关的顺序流,它们所有的数据包拥有相同的顺序类型。我们可以举一个例子来描述这个东西。假设你想排序所有的聊天信息,排序所有的玩家移动数据包,排序所有的玩家开火包,连续所有的弹药数据包。你想使聊天信息按顺序到达,又不想聊天信息被挂起,因为你不能很容易的得到玩家移动的数据包。玩家移动数据包并不和聊天信息关联,因此,你不用担心他们是按照什么顺序到达。这样,你就可以为他们使用不同的顺序流,也许,0表示聊天信息,1表示玩家的移动数据包。值得一提的是玩家的开火数据包的顺序应该和玩家移动的数据包相关,因为你不想在错误的位置进行开火。因此,你应该将玩家开火的数据包放在和玩家移动数据包相同的数据流里。这样做的好处就是如果开火数据包比移动数据包后到达,开火数据包不会给你任何东西,它会一直等待移动数据包的到达。 序列将不停地剔除老的数据包,因此,如果你得到包的顺序为2,1,3,那么你将开始收到2,1,然后这些包被剔除,最后你将得到数据包3。这对于弹药来说非常的有用,因为弹药就只是不断地下降。如果你得到一个很老的包,那你就将得到很多的弹药,这明显是个错误。值得注意的是序列数据包不同于顺序数据包,因为顺序数据包是用一组流来表示的。 2D网络游戏开发 第十一讲 前言 在前面的内容中,我们讲解了什么是比特流(bitstream),如何建立数据包,如何发送数据包。今天,我们将讲解如何接收数据包,把这三个内容翻译了,我们将来实际编写一下代码。看看这些东西到底有多重要。 接收数据包 当一个数据包从网络上传输过来。接收函数将返回不为0,在这里我们要分三步来处理这个接收过程。Multiplayer类将为你处理第一步和第三步, 1. 决定数据包的类型,这个数据包的类型可以由下面的代码返回: unsigned char GetPacketIdentifier(Packet *p) { if((unsigned char)p->data[0]==ID_TIMESTAMP) return (unsigned char) p->data[sizeof(unsigned char) + sizeof(unsigned long)]; else return (unsigned char)p->data[0]; } 接收数据结构 如果你原来发送的是一个结构,你就可以像下面这样接收它: //如果你不使用Multiplayer,这里就将交给ProcessUnhandledPacket处理 if(packetIdentifier==/*这里是已经分配给用户的数据包标识*/) DoMyPacketHandler(packet); //把这个函数放在你想放的任何地方,在状态类中处理游戏是一个好的地方 void DoMyPacketHandler(Packet *packet) { //将数据放在结构的适当类型当中 MyStruct *s=(MyStruct *) packet->data; Assert(p->length==sizeof(MyStruct)); If(p->length!=sizeof(MyStruct)) Return; //用NetworkIDGenerator和宏定义位置,然后得到一个指针指向标明结构的对象 MyObject *object =(MyObject *) GET_OBJECT_FORM_ID(s.objected); //对于这个数据包的类型,执行一个功能 object->DoFunction(); } 有用的注释 ? 我们将数据包中的数据指向结构的相应类型是为了避免空拷贝。这样做的话,不管怎样,如果我们改变了结构中任何的数据,那么也将同样改变数据包。这可能或也不可能是你想要的结果。小心保管服务器返回的信息,因为这可能引起一些不必要的麻烦。 ? 那断言(assert),也并不一定是必须的,但是当我们发送数据包时,分配了错误的标识或者错误的长度的时候,它对于找到错误将是非常有用的。 ? 但某些人为了攻击客户端或服务器,而发送一些包含错误长度和类型的数据包,在这种情况下,状态就显得特别的重要了。 2. 通过DeallocatePacket(Packet *packet)=0;函数删除数据包。 接收比特流 如果你寄出一个比特流,我们建立比特流去分析数据应该按照我们写数据相同的顺序。我们建立比特流,使用了数据和数据包的长度。我们然后在使用写(Write)函数之前使用读(Read)函数;在使用写压缩(WriteCompressed)之前使用读压缩(ReadCompressed),无论我们写何种数据都将按照上面给出的相同的逻辑分支。 下面,我们给出一个例子,这个例子使用了在建数据包时使用的结构Mine。这个例子的作用就是如何读出Mine结构中的数据。 Void DoMyPacketHandler(Packet *packet) { Bitstream myBitStream(packet->data,packet->length,false); //第三个参数false的作用是不允许我们拷贝传输过来的数据 MyBitStream.Read(useTimeStamp); MyBitStream.Read(timestamp); MyBitStream.Read(typeId); Bool isAtZero; MyBitStream.Read(isAtZero); If(isAtZero==false) { x=0.0f; y=0.0f; z=0.0f; } else { myBitStream.Read(x); myBitStream.Read(y); myBitStream.Read(z); } myBitStream.Read(objectId); // 在这个结构中,这是对象标识 myBitStream.Read(playerId); //在这个结构中, 这是玩家标识 } 2D网络游戏开发 第十二讲 前言 一个商业网络游戏不可能只有一台服务器,这样的话就没法达到什么万人在线了,它一般都是由很多的服务器组成的,然后又用一台主服务器来管理这些游戏服务器。我们称这台主服务器为Master Server。 以下,我翻译成主服务器。还有就是不得不提醒你的是,我的翻译中可能存在错误,这些文章全部来源于Raknet的官方手册,如果你有什么看不明白的地方,你可以直接去看英文版,也可以来信告诉我,我的邮箱是akinggw@126.com 主服务器 主游戏服务器是一台管理运行游戏服务器的服务器。它的作用就是帮你的玩家引导到一些活动的游戏服务器上。 在RakNet中,我们在主游戏服务器方面只提供了一些简单的应用。因此,在实际开发中,你将不得不自己添加一些其它的功能。 那些代码是基于类编写的,一个是用于管理游戏服务器类,一个是用于管理游戏客户端类,而你的游戏将直接和管理游戏服务器类通信。为了包含这些功能,你将不得不在适当的时候创建管理游戏服务器的实例和管理游戏客户端的实例。 主游戏服务器通常是一个基于控制台的程序或一个简单的窗口程序。运行它,然后它就将初始化,每一帧更新一次。如果你想的话,你也可以取得并执行OnModifiedPacket这个函数。MasterServerMain文件中包含了一个非常好的测试代码,这段代码用于测试主游戏服务器的各种性能。一个非常好的注意就是使用动态域名来代替一个固定的IP地址,这样做的好处就是你改变服务器的时候不用玩家更新。例如,你的游戏的web地址为 molegame.com 你就可以在管理游戏客户端编码连接这个域名,这样就解决了IP问题。 管理游戏客户端(MasterClient)是一个客户端,但这个客户端却包含并管理游戏服务。它最大的一个好处就是连接动态域名而不是一个固定的IP地址。 这就是这篇文章的内容,关于主游戏服务器的例子请参考Raknet中自带的例程。 Over! 2D网络游戏开发 第十三讲 时间邮寄(Timestamping) 也不知道翻译正确没有,如果没有正确,还请你批评指正,我一定虚心接受。 在相同的时间帧下,如何在不同计算机上参照那些事件 时间邮票并不会和你的系统时间有什么区别。不幸的是,每台计算机都有它们各自不同的时间。如果你直接在网络上传输系统时间,那么你将得到其它机器上的时间值,而这些时间值却告诉你那事件已经发生了。这是为什么呢?因为你只知道你自己系统上的时间,而不知道其它人的时间。Raknet中的时间邮寄(Timestamping)功能允许你去读取与自己系统相关的时间,允许你将这些集中在游戏里而不用担心其它系统的本机时间。而要完成这个功能,时间邮寄(Timestamping)它会自动完成,并且达到的时间精确度相当的高。 图注1 假设一个事件发生在客户端,而事件在客户端发生的本机时间为2000,而这时在服务器端的逻辑系统时间为12000,在另一个客户端的逻辑系统时间为8000。如果我们的数据包不用时间邮寄(Timestamping)进行调整,那么服务器将得到时间2000,或者10000ms以下,但这时实际上时间是在ping/2 ms以下,它们中间可能就相差100。 幸运的是,RakNet已经为你处理好了这一切,在两个系统的时间和ping之间进行补偿。使用相关的时间,服务器将粗暴的认为事件发生在ping/2 ms以前,而其它的客户端也将同样地认为事件发生在ping/2 ms以前。 这样的话,你就可以直接正确地的将时间邮寄(Timestamping)写入你的数据包而不用担心其它事情。 关于如何将时间邮寄(Timestamping)写入到你的数据包中,请参考建立数据包那一节。 注意:我们推荐使用类GetTime,因为这是一个高频率的时间发生器。当然你也可以使用windows 函数 timeGetTime(),但是,这个函数不是很好。时间邮寄(Timestamping)依赖于自动的频率,因此你将需要调用StartOccasionalPing去使用它。 2D网络游戏开发 第十四讲 可靠的类型 数据包优先权 enum PacketPriority { HIGH_PRIORITY, MEDIUM_PRIORITY, LOW_PRIORITY, NUMBER_OF_PRIORITIES }; 首先,数据包优先权是简单的。高优先权数据包在中优先权数据包之上,而中优先权数据包又在低优先权数据包之上。在游戏中不断提高数据包的优先等级是不正确的,因为这样会扰乱游戏。 数据包的可靠性 enum PacketReliability { UNRELIABLE, UNRELIABLE_SEQUENCED, RELIABLE, RELIABLE_ORDERED, RELIABLE_SEQUENCED }; Unreliable Unreliable的数据包是直接被UDP寄出,它们可能按照顺序到达,也可能不会。用这种方法寄出的数据最好是不重要的,或者发送很多次,这样做的好处就是,遗失的数据包可以得到补偿。 优点 ― 这种类型的数据包不需要被网络告知是否到达目的地。 缺点 ― 数据包没有顺序,也可能不会到达目的地,如果发送内存是满的,首先抛弃的就是这种数据。 Unreliable sequenced 不可靠的连续数据包和不可靠的数据包大体相同。就只有一点不同,那就是老的数据包被忽视,只有最新的数据包可以被接受。 优点 ― 和不可靠的数据包差不多,因此你不用担心老的数据包会改变你的数据。 缺点 ― 大量的数据包在他们可能从未到达目的地的情况下丢失,也可能在到达目的地以后丢失。 当发送缓冲区满了的时候,这些数据包首先被丢失。或是最近的数据包寄出后却从未到达。 Reliable 可靠的数据包是UDP通过一个可靠的层打包后发送到目的地的。 优点 ― 数据包将完整无缺的到达目的地。 缺点 ― 没有包顺序。 Reliable ordered 可靠性顺序包是UDP通过一个可靠层打包后,按照顺序发送到目的地的。 优点 ― 这种数据包将按照一定顺序被寄出。这样做的好处就是很容易编程,因为你不用担心如何组织它们或者遗失的数据包。 缺点 ― 可能会占用额外的网络宽带。如果网络很繁忙的时候,数据包可能到达的非常慢。如果一个数据包晚到,那么将延迟许多数据包的到达。不管怎样,这个缺点可以使用顺序流来缓解。 Reliable sequenced 可靠性连续数据包是UDP使用可靠层打包的,它的目的就是确保这些数据包可以按照连续的顺序到达目的地。 优点 ― 你可以得到UDP数据包的可靠性,顺序数据包的顺序,不用担心老的数据包。 缺点 ― 还是占用了大量的网络宽带。 2D网络游戏开发 第十五讲 前言 最近这天气是越来越热,心情也就越来越不好,可工作还的继续,但好久没编程了(可能有两三天了)。俺也不准备编程了,还是翻译吧,反正这些俺也没有搞清楚。 网络ID产生器 网络ID产生器允许我们在不同的计算机上按照相同的方式参照对象 网络ID产生器类是一个可选类,主要的功能就是自动分配一个ID值到对象中。这在多人对战游戏中非常的有用,因为如果不这样的话,你就没有办法在远程系统中参照动态的对象。 重点 网络ID产生器(NetworkIDGenerator)有一个派生类叫NetworkObject。一旦你使用了Multiplayer类,它就假设有一个外部的立即服务器和客户端。 如果你想使用你自己的实例,在获取的类中执行纯虚的函数,告诉NetworkIDGenerator你的客户端和服务器的身份。 在那最简单的一种情况下,它们是如何工作的: 用GetNetworkID()函数得到一个对象的ID。如果ID没有分配,那么将返回UNASSIGNED_OBJECT_ID 用SetNetworkID()函数得到一个对象的ID。 如果你一旦操作失误,你将得到麻烦,这就是为什么NetworkIDGenerator类使用printf和puts的原因。如果一旦你遇到错误,你将不会使用上面所说的那些,而是运行你自己的消息处理指针,这样你就能得到一些警告或错误。 1. 不要在一个对象中调用SetNetworkID,因为它已经被分配了,除非你想重新分配他们的ID。 2. 如果你在一个系统中删除了一个对象,那么同样它的ID也将作废,因此你需要在所有系统中删除那个对象。 3. 由于传输的原因,可能存在一个对象存在于一个系统之中,但不在另一个系统之中。当你使用GET_OBJECT_FROM_ID转换一个对象ID到一个指针的时候,你必须检查是否返回值为0。 服务器端 ID号是自动分配的,因此GetNetworkID()函数将总是工作,而你也不用调用SetNetworkID()。当服务器建立了一个新的网络对象,那些客户端必须知道它。要实现这一切,你可以像下面这样做: MyObject* myObject = new MyObject; //MyObject从Network对象继承而来 ObjectID objected= myObject->GetID(); 然后建立一个带ObjectID的数据包,然后将这个数据包发送到客户端。 当那客户端得到那数据包: MyObject* myObject = new MyObject; //myObject从Network对象继承而来 MyObject->SetID(objectId); //objectId是包含在数据包中 客户端 前面已经说过,ID号是自动分配的,并不需要你去分配。你需要做的就是从服务器端得到它们。如果你建立或是想建立一个对象,你必须编写这样一个过程,这个过程做这样一件事,就是服务器将返回一个数据包,而这个数据包将告诉你,你建立的对象的ID是多少。 如果服务器建立了一个对象(或是另一个客户端建立了,然后服务器又告诉你另一个客户端做了什么事,那么你能想平常那样分配ID号。 在客户端,处理对象的建立的一种最容易的方法是询问服务器是否存在该对象,因为只有当服务器存在了该对象,服务器才会返回。 你可以发送像下面这些东西ID_REQUEST_CREATE_OBJECT。 你能先在服务器端建立对象,然后将这个建立的对象的ID号编写到数据包中,然后发送给这个数据包所指的发信人。具体过程如下: 服务器端 //编写代码处理ID_REQUEST_CREATE_OBJECT MyObject* myObject = new MyObject; //MyObject从Network继承而来 ObjectID objected = myObject->GetID(); 建立一个带ObjectID的数据包,然后把它发送给客户端。当有人调用了对象的类型的时候,数据包将返回ID_CREATE_OBJECT。 当客户端得到数据包: MyObject* myObject =new Myobject; //MyObject 从Network继承而来 MyObject->SetID(objected); //objectID是包含在数据包中 另一方面,一个好的主意是把一些欺骗检测放在这里。例如,如果客户端要建立50辆坦克,但在游戏中只有5秒钟时间,这肯定要发生错误。 编程提示 在你的游戏中,并不是每件事都必须通过NetworkIDGenerator继承而来。只有一件事是必须的,那就是你在这许多的系统中将使用相同的方式。如果有一些特殊的方法参考那些对象,比如每个系统只有对象的一个类型,你就可以不按照上面的方法。 从NetworkIDGenerator继承来的例子: 你的游戏中有许多的敌人,但这些敌人不是按照一定顺序编排的。 你的游戏中有很多的敌人,当这些敌人死后,将被删除。 你的游戏中有许多的机关,当一个玩家站在一个油桶边,将会爆炸。你能通过继承NetworkIDGenerator类来建立这些机关。 不从NetworkIDGenerator继承来的例子: 在你的游戏中,所有的敌人是在一个数组中按照一定的顺序建立的。在这种情况下,你只需要发送数组的索引。 在你的游戏中只有一个城堡,一个数据包参考到这个城堡,你可以直接知道它是那一个。 从不被网络参考的对象,例如,从一杆枪中发出的子弹。这个交互,只是开枪射击的玩家和被子弹击中的玩家的交互,你不用去担心枪。 其它函数 NetworkIDGenerator* GET_OBJECT_FROM_ID(ObjectID x); 这个函数的作用就是在一个内部描述中(通常是一棵AVL平衡二叉树)寻找满足的选项,返回一个指针到NetworkIDDGenerator。然后你就能在这个指针中设置你想要的类型,这个依赖于你想寻找的具体的内容是什么。 例子: MyObject* myObject = (MyObject*) GET_OBJECT_FROM_ID(packet->objected); If(myObject==0 Return; //不能找到对象 Static unsigned short GetStaticItemID(void); Static void SetStaticItemID(unsigned short I); 这些高级的函数可能你不会用到,除非你想全面的了解NetworkIDGenerator。你能使用它们去设置一些特别的值去强迫ID的值,服务器将使用这些ID值去得到当前最高的值。这只实用于那些已经使用了ID,而导入服务器到当前游戏的情况。例如,如果我保存游戏到服务器中,使用的ID从0到1000,如果我想继续游戏,我可以通过调用SetStaticItemID(1001);导入游戏。这种方法分配的ID值不会和现在的ID值发生冲突。 2D网络游戏开发 第十六讲 前言 Welcome to my game program world。也不知道这样写可不可以,反正大概意思对了就行了。我们还是对Raknet中的一些重要概念进行阐述,这样可以加深我们对Raknet结构的认识。以便于我们在以后的学习中不至于问这问那。正所谓磨刀不无砍材工吗?(不知道打对了没有(:) 玩家ID 什么是玩家ID? 玩家ID是一个结构,这个结构包含了一个二进制的IP地址和在网络上使用的系统接口。什么时候需要玩家ID呢: ? 服务器从特定的客户端得到信息,并且想把信息转发给其它的客户端。你将在发送函数中指定那发送者的玩家ID(这个通常在Packet::PlayerID中),并且要将广播方式设置成true。 ? 在游戏世界中的一些选项,比如前面介绍的Mine,属于一个特定的玩家,你想点击这个玩家的角色,然后把他杀掉。 ? 每个客户端都应该知道每个玩家在地图中是唯一的。例如,每个在游戏中的玩家一个特定的得分。 ? 你想在点对点网络中给任意一个发送信息。 重点考虑 1. 数据包的接受者自动知道发送数据包给它的任何系统的玩家ID。因为它从发送者的IP/接口得到这些。如果你需要服务器知道玩家ID是多少,发送者不需要在他们自己的数据结构中编写它们自己的玩家ID。通过Receive函数返回,原始发送者的玩家ID是自动通过数据包结构传输到程序员那里。 2. 当在使用客户端-服务器模型时,客户端并不知道最初发送数据包的玩家ID。客户端是考虑来自服务器端所有的数据包。因此,如果你想一个客户端知道另一个客户端的玩家ID,你就不得不在数据结构中添加玩家ID选项。你可以使用一个发送客户端来填充它,也可以在一个服务端得到一个开始发送者发送的数据包以后填充它。 3. 玩家ID并不总是按照一定的顺序分配,也不一定在一定的范围中分配,大家要记住这一点。 2D网络游戏开发 第十七讲 前言 首先,感谢大家关心这个栏目,虽然很难,但我相信只要我们有信心有决心,我们就一定能达成所愿。 网络消息 来自网络引擎的消息 我们在接收数据包的时候,数据包的第一个字节代表了数据包的类型。这个类型你可以自己定义,当然也有一些是网络引擎自身的,需要网络引擎自己来处理。这些信息,网络引擎使用了宏变量保存在Mutiplayer.h/cpp文件里,我们下面来看看这些宏变量代表了什么意思。 ID_PONG 15:测试一个没有连接的系统。第一个字节是ID_PONG,接下来四个字节是一个ping,最后的字节是系统自己所拥有的数据。 ID_RSA_PUBLIC_KEY_MISMATCH [客户端|PEER](注:这个代表了由谁来处理,这里指的是由客户端和点对点通信中的点来处理)16:我们预先设置一个RSA密匙让那些不匹配这个RSA密匙的系统使用。 ID_REMOTE_DISCONNECTION_NOTIFICATION [客户端] 17:在一个客户端/服务器环境下,除了我们自己以外的客户端断开连接。Packet::playerID将被修改来反映客户端的玩家ID。 ID_REMOTE_CONNECTION_LOST [客户端] 18: 在客户端/服务器环境下,除了我们自己以外的客户端掉线,Packet::playerID将被修改来反映客户端的玩家ID。 ID_REMOTE_NEW_INCOMING_CONNECTION [客户端] 19:在客户端/服务器环境下,除了我们自己以外的客户端上线,Packet::playerID将被修改来反映客户端的玩家ID。 ID_REMOTE_EXISTING_CONNECTION [客户端] 20:在我们和服务器进行初始化连接时,我们将告诉在游戏中的其它客户端。Packet::playerID是被修改以反映这个连接服务器的客户端的玩家ID。 ID_REMOTE_STATIC_DATA [客户端] 21:得到另一个客户端的数据。 ID_CONNECTION_BANNED [PEER|客户端] 22:我们被禁止和我们想连接的系统进行连接。 ID_CONNECTION_REQUEST_ACCEPTED [PEER|客户端] 23:在客户端/服务器环境下,我们的连接请求被服务器接受了。 ID_NEW_INCOMING_CONNECTION [PEER|服务器] 24:一个远程系统已经被成功的连接。 ID_NO_FREE_INCOMING_CONNECTIONS [PEER|客户端] 25:我们试图连接的系统不接受新的连接。 ID_DISCONNECTION_NOTIFICATION [PEER|服务器|客户端] 26: 系统在Packet::playerID中标明不和我们进行连接。对于客户端,这意味着服务器已经关闭。 ID_CONNECTION_LOST [PEER|服务器|客户端] 27:可靠的数据包不能投递到Packet::playerID所标注的系统中。可能它和系统的连接已经关闭。 ID_TIMESTAMP [PEER|服务器|客户端] 28:在这个字节后的四个字节表示了一个unsigned int类型,它会因发送者和接受者之间的系统时间的不同而被修改。需要你调用StartOccasionalPing函数。 ID_RECEIVED_STATIC_DATA [PEER|服务器|客户端] 29:我们得到一个包含静态数据的比特流。你现在就可以读取这个数据。这个数据包一旦建立连接就将自动被发送,当然你也可以手动发送。 ID_INVALID_PASSWORD [PEER|客户端] 30: 远程系统使用了密码,当我们没有提供正确的密码的时候将被拒绝连接。 ID_MODIFIED_PACKET [PEER|服务器|客户端] 31:数据包在传输过程中被修改,发送者包含在Packet::playerID。 ID_REMOTE_PORT_REFUSED [PEER|服务器|客户端] 32:远程主机在这个端口不接受数据。 ID_VOICE_PACKET [PEER] 33 : 这个数据包包含了噪音数据。你应该把他们传输到RakVoice系统中进行处理。 ID_UPDATE_DISTRIBUTED_NETWORK_OBJECT [客户端|服务器] 34: 表明了分布式网络对象的建立和更新。这些数据将被传输到DistributedNetworkObjectManager::Instance()->HandleDistributedNetworkObjectPacket ID_DISTRIBUTED_NETWORK_OBJECT_CREATION_ACCEPTED [服务器] 35: 分布式网络对象客户端的建立已经被接受。传输数据到DistributedNetworkObjectManager::Instance()->HandleDistributedNetworkObjectPacketCreationAccepted ID_DISTRIBUTED_NETWORK_OBJECT_CREATION_REJECTED [客户端] 36:分布式网络对象客户端的建立已经被拒绝。传输数据到DistributedNetworkObjectManager::Instance()->HandleDistributedNetworkObjectPacketCreationAccepted ID_AUTOPATCHER_REQUEST_FILE_LIST [PEER|服务器|客户端] 37:请求可下载文件的列表。然后传输到Autopatcher::SendDownloadableFileList ID_AUTOPATCHER_FILE_LIST [PEER|服务器|客户端] 38:得到可下载文件的列表。然后传输到Autopatcher::OnAutopatcherFileList. ID_AUTOPATCHER_REQUEST_FILES [PEER|服务器|客户端] 39:请求可下载文件的特定设置。然后传输到Autopatcher::OnAutopatcherRequestFiles. ID_AUTOPATCHER_SET_DOWNLOAD_LIST [PEER|服务器|客户端] 40 : 同意一列文件被下载,传输数据到Autopatcher::OnAutopatcherSetDownloadList. ID_AUTOPATCHER_WRITE_FILE [PEER|服务器|客户端] 41:得到我们请求下载的文件。然后传输到Autopatcher::OnAutopatcherWriteFile ID_QUERY_MASTER_SERVER [主游戏服务器] 42:请求主游戏服务器中的游戏服务器的至少一个关键码。 ID_MASTER_SERVER_DELIST_SERVER [主游戏服务器] 43: 从主游戏服务器中删除游戏服务器。 ID_MASTER_SERVER_UPDATE_SERVER [主游戏服务器|游戏服务器客户端] 44:添加或更新服务器中的信息。 ID_MASTER_SERVER_SET_SERVER [主游戏服务器|游戏服务器客户端]45:添加或设置服务器中的信息。 ID_RELAYED_CONNECTION_NOTIFICATION [主游戏服务器|游戏服务器客户端] 46: 这条消息是被主游戏服务器返回的,用于标明游戏服务器客户端和主游戏服务器建立了连接。 ID_ADVERTISE_SYSTEM [PEER|服务器|客户端] 47:通知远程系统我们的IP地址和端口。 ID_FULLY_CONNECTED_MESH_JOIN_RESPONSE [通过MessageHandlerInterface设置的PEER] 48 :被FullyConnectedMesh数据包指针用于自动连接其它peers,并且构建一个全面的连接网格。 ID_FULLY_CONNECTED_MESH_JOIN_REQUEST [PEER] 49: 被FullyConnectedMesh数据包指针用于自动连接其它peers,并且构建一个全面的连接网格。 ID_CONNECTION_ATTEMPT_FAILED [FEER|服务器|客户端]50:试图再次和服务器进行连接时被拒绝。 ID_SYNCHRONIZE_MEMORY [PEER|服务器|通过MemorySychronizer建立的客户端] 51:发送内存更新。 ID_SYNCHRONIZE_MEMORY_STR_MAP [PEER|服务器|通过MemorySychronizer建立的客户端]52: 被MemorySychronizer用于将string转换成unsigned short保存在带宽(bandwidth)中。 2D网络游戏开发 第十八讲 前言 这一系列的教程已经写了很多了,喜欢的人也很多,可就是反馈太少了。同志们,如果你喜欢这篇教程或也想研究这个引擎,请来信给我,我愿意和大家一起探讨,我的邮箱是akinggw@126.com 。 数据压缩 Raknet提供了将数据进行压缩传输的方法,下面我们就来详细了解其基本原理。 描述 Raknet能够自动压缩将要发送出去的数据并且在接收到数据后将其自动解压。要实现这些,你需要为你的游戏构建一个简单的频率表,这样做的好处就是能够预先计算编码这些数据需要多少存储空间。下面介绍数据压缩大概的过程: 1. 运行一个简单的‘平均’的游戏。得到服务器和一个客户端的频率表(如果你想的话,也可以是所有客户端的平均。 2. 根据客户端的频率表,为服务器产生一个解压层 3. 根据服务器的频率表,为服务器产生一个压缩层 4. 根据服务器的频率表,为客户端产生一个解压层 5. 根据客户端的频率表,为客户端产生一个压缩层 然后,它们就会被自动进行处理。 下面将对这些函数进行讲解,在Raknet中有详细的例程。 数据压缩函数 GenerateC,ompressionLayer(unsigned long inputFrequencyTable[256],bool inputLayer) 得到一个由函数GetSendFrequencyTabl返回的频率表,这将产生一个内部的压缩层。你需要调用这个函数两次,在一次中设置inputLayer为true,另一次设置inputLayer为false。InputLayer为真的时候意味着输入。当我们是客户端的时候,服务器到客户端的频率表中,当我们是服务器的时候,客户端到服务器的频率表中。(偶也不太明白这句话)我们需要两个层,因为服务器发送的数据通常不同于客户端发送的数据,因此我们不能在客户端和服务器上使用相同的压缩层。在RakServerInterface.h , RakClientInterface.h中有非常全面的头文件描述。 DeleteCompressionLayer(bool inputLayer) 这会释放一个现在存在的压缩层的内存。在开始释放之前,我们应该标明是输入层还是输出层。 GetSendFrequencyTable(unsigned long outputFrequencyTable[256]) 返回一个已经发送的频率表。 Float GetCompressionRatio 这里将返回一个数字N,这个N 应该大于0.0f,这个值越小越好。N等于1.0f意味着你的数据比原来的数据大。这显示了你的压缩频率可能受到了影响。 Float GetDecompressionRatio 这个返回值N大于0.0f,并且也越大越好。N等于1.0f意味着接收到的数据没有经过压缩,还是和原来的一样大,这显示了你的压缩频率可能受到了影响。 2D网络游戏开发 第十九讲 前言 hi,伙计们,是不是感觉前面的内容一点让你百般无聊,不要着急,今天我们就将给出一个别人编的例子。这个例子很简单,主要就是使用了我们上一张所讲解的内容。本来都不愿意拿出来分享的。但我这个人心比较好,分享就分享吧。呵呵! 如果你觉得这个例子比较好,请记得千万要给我来信,我的邮箱是 akinggw@126.com 这个例子最开始是出现在Irrlicht这个3D游戏引擎的官方主页上。文章标题叫“ A Primer For RakNet Using Irrlicht” ,中文意思就是如何在Irrlicht引擎中使用Raknet。文章比较简单,内容主要就是Raknet中的比特流和数据结构。你如果将我前面写的文章全部看懂了,理解这篇文章就相当没问题。 但在我这篇文章中,我只会讲解关于RankNet的部分,如果你对Irrlicht游戏引擎有兴趣,请参考原文:http://www.daveandrews.org/articles/irrlicht_raknet/ 。 OK,废话少说,让我们开始吧! 先看一张游戏执行的界图,你可以在http://www.daveandrews.org/articles/irrlicht_raknet/Chalkboard.zip 下载文件源代码和可执行文件。 客户端 如果你执行了游戏,你就会发现。这根本就不是游戏,它只是显示如何在每个客户端同步画图。 因此,我们先讲解我们要用到的一些参数,如果用一个数据结构来表示可以表示如下: 数据包 { 数据包类型; 鼠标开始位置; 鼠标结束位置; }; 这就是我们要传输的数据。如果想象一下,假如我要传输我自己的游戏中的角色信息。那结构就会是下面这个样子: 数据包 { 数据包类型; //角色信息 角色类型; //比如人,精灵,妖怪等等 操作方式; //比如是重新建立,更改,删除 角色所处地图; //就是它在那张地图上 角色所处地图坐标; //就是它在地图什么位置 角色生命值; . . . }; 好象又扯得太远了, 我们还是来说这里吧。 #include “irrlicht.h“ #include ”PacketEnumerations.h” #include “RakNetworkFactory.h” #include “NetworkTypes.h” #include “RakClientInterface.h” #include “BitStream.h” #include “windows.h” // for Sleep() #include “stdio.h” #include “conio.h” #include “string.h” #include “stdlib.h” 头文件是不能不要的。作者说bitstream好象还存在一些问题,所以作者建议你在项目中包括bitstream.h和bitstream.cpp这两个文件。 另外,作者也讲解了为什么要包含windows.h文件。包含windows.h文件的作用就是使用它的一个函数Sleep()。 通过使用Sleep()函数的目的就是为了让主线程在执行的过程中,能够给Raknet更多的时间去执行。在整个游戏客户端中,我们都将在主循环中使用Sleep()函数。Sleep(1)并不能是程序暂停,但可以让处理器放弃执行另一程序。 “客户端连接“类 作者使用了一个客户端连接类来管理和 服务器的连接,数据包的处理和发送。我们首先来看一下这个类的成员函数和功能: 1. 构造函数 客户端连接类的构造函数有两个参数:用于连接的服务器IP地址(用字符串表示)和用于连接的服务器的端口号(也是用字符串表示)。这个函数的功能就是初始化Raknet网络连接并连接到服务器。 2. 拆构函数 拆构函数的功能就是关闭构造函数建立的连接,并且释放Raknet建立的网络结构。 3. 添加线坐标 这个函数的作用就是将我们为了画线时产生的坐标x,y添加到画线列表中。 4. 发送线到服务器 这个函数的作用就是发送我们画线用的坐标(或者点)到服务器。 5. 画线 这个函数与网络传输无关,主要就是将画线列表中的点连成一条线。 6. 侦听数据包 这个函数的作用就是检测从服务器传输过来的数据包,然后自动用下一个函数来处理它。 7. 处理数据包 它的作用就是从数据包中读出数据,然后显示到屏幕上, 具体类描述如下: class ClientConnection { private: list> lineList; RakClientInterface *client; public: ClientConnection(char * serverIP, char * portString); ~ClientConnection(); void AddLineLocal(s32 x1, s32 y1, s32 x2, s32 y2); void SendLineToServer(s32 x1, s32 y1, s32 x2, s32 y2); void DrawLines(IVideoDriver * irrVideo); void ListenForPackets(); void HandlePacket(Packet * p); }; 还有一个类,这个类来自于Irrlicht。这个类主要就是处理游戏中的一些事件,我在这里不讲解,请大家自己看。 另外,在文件开始处,要加入下面的代码: const unsigned char PACKET_ID_LINE = 100; 这个用于设置我们的数据包类型,关于这个方面,我已经在前面进行了详细的讲解。 接下来,我们具体地看一下函数的实现。 首先,是构造函数: ClientConnection(char * serverIP, char * portString) : client(NULL) { client = RakNetworkFactory::GetRakClientInterface(); client->Connect(serverIP, atoi(portString), 0, 0, 0); } 比较简单,我在这里就不进行讲解。 ~ClientConnection() { client->Disconnect(300); RakNetworkFactory::DestroyRakClientInterface(client); } 拆构函数,也比较简单。 void SendLineToServer(s32 x1, s32 y1, s32 x2, s32 y2) { RakNet::BitStream dataStream; dataStream.Write(PACKET_ID_LINE); dataStream.Write(x1); dataStream.Write(y1); dataStream.Write(x2); dataStream.Write(y2); client->Send(&dataStream, HIGH_PRIORITY, RELIABLE_ORDERED, 0); } 发送数据到服务器端,这个完全按照我们我们前面所讲的内容进行的。先往数据流中写数据包类型,然后才是数据。最后发送,关于Send()函数,我们前面已经讲解了。 我们再来看一下侦听函数: void ListenForPackets() { Packet * p = client->Receive(); if(p != NULL) { HandlePacket(p); client->DeallocatePacket(p); } } 我们接收的时候必须先将数据包中的数据重新写入数据流,然后读取这个数据流的类型。 如果这个数据包满足我们的条件,我们就将数据一一读出,然后画线,最后退出。 这就是客户端的处理,下面我们来看一下服务器的处理过程。 服务器可以用一个控制台程序来写,下面我们来看一下这个程序应该怎样来写。 #include #include #include #include #include // for Sleep() 开始和客户端一样,首先包含Raknet的头文件。 const unsigned char PACKET_ID_LINE = 100; 定义数据包的类型。 服务器端不是使用的类,直接使用了三个函数: 1. SendLineToClients() 这个函数的目的就是将接收的信息广播给所有在线的客户端。 2. HandlePacket() 这个函数的目的就是处理数据包中的数据。 3. Main() 这个函数的目的就是建立服务器,然后在游戏循环中接收数据,处理数据。 下面,我们来具体看一下这些函数的处理过程。 void SendLineToClients(RakServerInterface * server, PlayerID clientToExclude, int x1, int y1, int x2, int y2) { RakNet::BitStream dataStream; dataStream.Write(PACKET_ID_LINE); dataStream.Write(x1); dataStream.Write(y1); dataStream.Write(x2); dataStream.Write(y2); server->Send(&dataStream, HIGH_PRIORITY, RELIABLE_ORDERED, 0, clientToExclude, true); } 这个函数的目的就是将接受到的数据写入数据流,然后广播给在线的所有客户端。 void HandlePacket(RakServerInterface * server, Packet * p) { unsigned char packetID; RakNet::BitStream dataStream((const char*)p->data, p->length, false); dataStream.Read(packetID); switch(packetID) { case PACKET_ID_LINE: int x1, y1, x2, y2; dataStream.Read(x1); dataStream.Read(y1); dataStream.Read(x2); dataStream.Read(y2); SendLineToClients(server, p->playerId, x1, y1, x2, y2); break; default: printf("Unhandled packet (not a problem): %in", int(packetID)); } } 这个函数的作用就是从网络上得到数据,然后判断这个数据是不是我们所需要的,如果是,就将它从数据流中读出,然后用SenLineToClients函数进行处理。 最后我们来看一下main函数。 int main() { RakServerInterface * server = RakNetworkFactory::GetRakServerInterface(); Packet * packet = NULL; int port = 10000; if(server->Start(32, 0, 0, port)) { printf("Server started successfully.n"); printf("Server is now listening on port %i.nn", port); printf("Press a key to close server.n"); } else { printf("There was an error starting the server."); system("pause"); return 0; } while(kbhit() == false) { Sleep(1); packet = server->Receive(); if(packet != NULL) { HandlePacket(server, packet); server->DeallocatePacket(packet); } } server->Disconnect(300); RakNetworkFactory::DestroyRakServerInterface(server); printf("Server closed successfully.n"); system("pause"); } 这个函数的作用很简单,我简单地说一下,先建立服务器, 然后进入游戏循环,接收数据,如果数据不为空,就进行处理。如果你按动任何按键就退出,关闭服务器。 这就是一个游戏的例程,很简单。 2D网络游戏开发 第二十讲 前言 有几天没有发东西了,在我的记忆里,好象有两天。难怪手痒痒的,这两天在做一个网站,刚刚把关键的技术问题解决,所以没有多少时间写文章。在加上最近这几天又比较热,40几度,吓人!还是回到我们的正题上吧,就是关于网络游戏的制作,今天我不想编程,只是想谈谈游戏服务器的结构。 结构 其实,我们的游戏传输的就是结构,精灵结构,NPC结构,物品结构,聊天信息结构等等很多结构。(这只是我的想法,有可能有错,不要太当真) 我们下面来做一个例子,比如我们所操作的角色。它应该包含哪些东西呢,她叫什么名字,她所在的地图号,在地图的某个地方,有多少等级,她的经练值是多少,其它还有她穿什么装备,使用什么功夫等等信息。 当然我们前面所说道的装备,功夫,又是另一个结构。 下面我们就用语言的方式来描述她。 角色她 { 姓名 等级 所在地图号 所在地图坐标 精练值是多少 所穿的装备 { } 所使用的功夫 { } . . . } 这些数据一旦接收,游戏引擎就可以根据这个结构中的信息做相应的动作。比如根据角色所在地图号加载地图,然后根据角色的坐标信息显示精灵。最后显示其它相关的信息。 这些东西是如何在客户端生成的呢? 我们可以跟踪这个过程。 1. 玩家打开游戏,输入名称和密码。(我们假定他们是在网站上注册的); 2. 服务器接受玩家资料,如果是登陆信息,就检测玩家的名称和密码。如果正确,再检测玩家是否第一次登陆,如果是第一次,就为玩家初始化上述结构中的信息,然后发送给客户端,同时写入数据库;如果玩家不是第一次登陆,就从数据库中取出相关信息,发送给客户端。同时服务器还要建立一个玩家链表,用于管理这些玩家信息。关于玩家在服务器上的管理,光一根链表是不行的,如果游戏是一个小游戏,只有一张地图可以但有多张地图时,或人数很多时,一根链表就不能解决问题了。即使能,那速度就会受到影响,因为它是在一个很长的路上来回跑动啊。这显然不行。最好的办法就是有多少张地图就建立多少根链表。这时又有一个问题,就是当玩家从一张地图到了另一张地图,怎么办呢?这个也是有解决办法的,因为我们的玩家在游戏中都拥有唯一的ID,又因为每次客户端都会回传数据。这时我们先根据玩家回传的玩家ID在所有地图中查找它属于哪个链表,找到地图的ID号,然后和玩家回传的地图ID进行比较,如果相等就不改,如果不相等,先删除服务器上的地图ID所指的链表中的玩家,然后将这个回传的数据重新插入它所在的地图ID所指的链表。(希望我的讲解不会让你迷惑) 这样就解决了玩家在服务器上的管理问题。也许你要问,这样做的好处是什么?那我就可以告诉你,这样做的好处有很多:A. 节省了查找玩家的时间,比如你向你所在地图的某个玩家发动进攻,总不能每次都把服务器中的全部玩家都遍历一次。B. 方便地图的管理 比如你在一个游戏地图中奔跑,如何显示你周围的NPC和其它玩家呢? 不可能每次都去服务器得到你周围的情况吧,即使允许,可能数据传输过来时,你已经到了另一个地方。最好的办法就是在游戏开始之前将地图中所有的情况传输到你的客户端,这样才能达到同步显示。而对于地图中情况的改变,那就成了一个一个的事情,可以在这些情况改变时,只对你的情况列表做一些小的动作,这样就不会影响大局了。但这种方法只适合与小的地图,对于想《传奇》那样的超大地图就行不通了。这时,我们就可以用一个四叉树来解决,看下图: 我图画的不好,只为说明一个问题,按照我们前面所说的,把地图分成一根根链表来存储。但当我们使用的是超大地图,那同样将遇到很多速度上的问题。如何来提高这个查找速度呢? 我认为把上面我们所用到的链表换成四叉数,什么是四叉树?看下面就明白了: 图注1 四叉树的作用就是把一个平面一分为四,这样我们就能把《传奇》中的地图分成一些很小的方块。至于这些方块要分到多小,你可以通过设置四叉树的深度来决定,当然不能太小了。 我们还是使用链表来存储玩家的信息,而这些链表应该预先定义,需要多少,就定义多少,然后将这些链表的ID好存储到四叉树的叶节点中。这样,我们每次我们查找玩家信息就可以先在四叉树中找到链表ID,然后在链表中进行操作,速度有提升了不少。当然这种方法对于那种超大的地图才有效果,如果地图不大,还是采用前面的方法吧。 前面这个方法不仅要管理玩家,同时地图中的物品,NPC,也是这样管理。 1. 这一步就是将玩家所在的地图中的物品,NPC,等等信息发送给玩家; 2. 玩家接收到自己的信息和地图中的所有信息,然后显示。 3. 这时,玩家就会响应某些动作,比如移动人物,攻击别人等等动作。这里就有两个方面,玩家自身的改变和玩家对他人的改变。我们先说说玩家自身的改变,对于这个方面,先修改客户端,然后将数据传输到服务器,修改数据库,修改地图链表。经过仔细观察,我发现对于游戏中所有的对象不外乎三种操作:删除,更新,添加。你可以自己去想一下,任何东西都只有这三种操作。这样的话,我们就不得不在将我们的数据包改成下面的样子: 数据包 { 数据包类型; 数据包子类型; 角色她 { 姓名 等级 所在地图号 所在地图坐标 精练值是多少 所穿的装备 { } 所使用的功夫 { } . . . } } 这个数据包中的第一个类型代表数据包的类型,比如它代表了什么对象,是玩家,物品还是NPC。第二个类型代表了对这个数据包中的数据实行何种操作,是删除,修改,还是增加。 以上说的只是玩家自身的改变。而玩家对其它对象的改变呢? 我在这里也不是太清楚,不过有一种方法就是就是发消息到服务器,然后让服务器自己来处理这些事情。 4. 玩家关闭客户端,然后退出。 我想这就是客户端和服务器实现的大概过程。 Over!

下载文档到电脑,查找使用更方便

文档的实际排版效果,会与网站的显示效果略有不同!!

需要 10 金币 [ 分享文档获得金币 ] 0 人已下载

下载文档