课程简介 本书主要讲述大型多人在线游戏开发的框架与编程实践,以实际例子来介绍从无到有地制作网络游戏框架的完整过程,让读者了解网络游戏制作中的所有细节。全书共12章,从网络游戏的底层网络编程开始,逐步引导读者学习网络游戏开发的各个步骤。 本书通过近50个真实示例、近80个流程图,以直观的方式阐述和还原游戏制作的全过程,涵盖了网络游戏设计的核心概念和实现,包括游戏主循环、线程、Actor模式、定时器、对象池、组件编码、架构层的解耦等。 本书既可以作为网络游戏行业从业人员的编程指南,也可以作为大学计算机相关专业网络游戏开发课程的参考书。 关键词:网络游戏,游戏程序,程序设计 1。1节介绍了单机游戏与网络游戏的区别,1。2和1。3小节带你理解IP地址和TCPIP。 课程完整版可前往UWA学堂观看《多人在线游戏架构实战:基于C的分布式游戏编程》 https:edu。uwa4d。comcourseintro03821。4阻塞式网络编程 下面就从简单的网络模型入手来实现一个简单的网络程序。要达到的目的如下: (1)客户端与服务端建立网络通信。 (2)完成通信之后,客户端向服务端发起一条协议。 (3)服务端收到协议,并转发给客户端。 (4)客户端收到协议,打印出来。 在这个例子中,将展示网络编程的基本功能接收和发送,处理服务端与客户端对于Socket的不同表现,实现服务端与客户端的收发协议流程。1。4。1工程源代码 该工程的源代码在本书源代码库的0101networkfirst目录下。先来执行工程,看看结果如何(注:在不少中文版的集成开发环境中把英文版中的Project翻译成项目,因此工程和项目在这种语境下指同一个概念,例如工程文件就是指项目文件)。工程中提供了两种打开方式,一种是在Windows系统下的VisualStudio工程文件,另一种是在Linux系统下的CMake文件。如果读者还不了解CMakeLists。txt文件的定义,那么建议先阅读附录中的CMake部分。本书提供的所有源代码均有这两种打开方式。 如果在使用Windows编译源代码时出现SDK版本不一致的问题,那么右击解决方案,选择重定解决方案目标。产生这个问题的原因是工程原来指定的SDK与本地环境中的SDK版本不一致。再次提醒,编译目标为debug,x86。 现在看看在Linux上如何执行本例。进入工程目录,执行脚本makeall。sh,编译的步骤已经在这个脚本中写好了,本书所有的工程都采用该方式编译。〔rootlocalhost0101networkfirst〕。makeall。sh 执行makeall脚本时,它会将该工程上的所有可执行程序都进行编译,本例中生成了两个文件:clientd和serverd。为了便于调试,所有的库文件源代码都是直接编译到执行文件中的,不再生成中间的静态库文件。 这是我们第一次使用makeall。sh脚本,每个工程都会有该脚本,用于批量编译。读者可使用vim。makeall。sh命令查看该脚本。 这里做一个简短的说明,在makeall。sh脚本中提供了两个参数,默认情况下,采用Debug模式编译代码,如果执行命令。makeall。shrelease,就编译Release版本。除此之外,还可给定clean参数,即执行。makeall。shclean,目的是清除CMake生成的临时文件,重新生成Makefile文件。 在脚本中提供了一个build函数,该函数的目的是对给定目录下的所有工程进行编译。以srclibs目录为例,函数build对libs目录下的srclibsnetwork目录进行了编译。这个目录是网络库工程,其下有一个已经写好的CMakeLists。txt文件。该文件与附录中讨论的文件格式大同小异,有3个地方值得注意。 (1)编译文件名set(MyProjectNamenetworkd) 指定一个编译文件名。当属性CMAKEBUILDTYPE为Debug时,输出文件加了d字符,以方便区分Debug和Release版本。CMake提供的STREQUAL函数用于字符串比较。 (2)输出目录set(CMAKEARCHIVEOUTPUTDIRECTORY。。。。。。libs) 设置属性CMAKEARCHIVEOUTPUTDIRECTORY。该属性指定了静态库生成的目录。 工程生成的可执行文件放在工程目录下的bin目录中,库文件放在工程目录下的lib目录中。不论是Windows系统还是Linux系统都遵从该规则。 (3)生成文件 在CMakeLists。txt文件的最后使用了addlibrary指令,而不是addexecutable指令。addlibrary({MyProjectName}STATIC{SRCS}) addexecutable生成可执行文件,addlibrary则生成一个库。关键字STATIC表示要生成一个静态库,需要生成动态库时,关键字改为SHARED即可。 下面看看第一个例子的执行结果,执行makeall。sh编译完成之后,进入bin目录。〔rootlocalhost0101networkfirst〕cdbin〔rootlocalhostbin〕lsclientdserverd 在bin目录下生成了serverd和clientd两个可执行文件。先运行serverd,再运行clientd。 在表12中展示了服务端和客户端的打印数据。进程serverd开始运行之后,就会进入网络监听状态,当clientd启动后,serverd收到一个连接请求,打印acceptoneconnection,双方连接成功之后,clientd首先发出数据ping,serverd收到之后返回一条相同的数据,最后clientd收到serverd发出的数据。这个简单的例子完成了一个来回的数据发送与处理。看看流程图可能会更容易厘清思路,如图18所示,图中标注了数据流转的4个步骤。 表12阻塞式网络通信运行结果 客户端发送ping数据。 服务端收到ping数据。 服务端收到ping数据的同时返回一个ping给客户端。 客户端收到ping数据。 图18阻塞式网络通信流程 网络监听和连接是如何实现的呢?无论是服务端还是客户端,首先要做的事情都是创建一个Socket(套接字)。那么Socket是什么呢?下面通过代码分析来简要说明。1。4。2服务端代码分析 服务端的主要代码在server。cpp文件中。对比源代码进行查看,需要掌握以下几个关键点。 关键点1:Socket初始化 代码的第一行为创建Socket做初始化准备:sockinit(); 首先要了解什么是Socket。简单来说,Socket定义了IP地址上的一个通信连接。例如,同一台计算机向同一个服务端发起两个网络连接,这就是两个通信连接,不论在客户端还是服务端都会产生两个不同的Socket。Socket实际上就是一个ID,也可以用通道来理解这种通信。打个比方,你有一个手机号码,当你打电话给别人的时候,你与对方建立了一个通信通道。Socket也是类似的,每次通信开始,通信双方建立了Socket,这个Socket被分配了一个ID,一个固定的ID固定了通道,避免收到错误消息。 sockinit()定义在network工程的network。h文件中。在Windows系统下的宏定义为:definesockinit(){WSADATAwsaData;WSAStartup(MAKEWORD(2,2),wsaData);} 在Windows系统下,需要初始化执行WSAStartup函数。在Linux系统下,调用::socket函数之前不需要执行任何操作,所以定义了一个空宏。 关键点2:创建Socket 初始化操作完成之后需要创建一个Socket。创建失败会调用宏sockerr来显示其错误。创建代码如下:SOCKETsocket::socket(AFINET,SOCKSTREAM,IPPROTOTCP);if(socketINVALIDSOCKET){std::cout::socketfailed。err:sockerr()std::endl;return1;} 函数::socket是底层函数,这个函数调用的细节放在后面来讲解。它在两个系统下略有不同。在Linux系统下,位于usrincludesyssocket。h文件中,其定义如下:externintsocket(intdomain,inttype,intprotocol)THROW; 从函数返回值可以看出,生成的Socket为int类型。 而在Windows系统下,返回值为SOCKET类型。socket()函数定义如下:SOCKETWSAAPIsocket(Inintaf,Ininttype,Inintprotocol);typedefUINTPTRSOCKET; 在Windows系统下,SOCKET类型被重定义为一个UINT类型,也就是说,如果编译的版本为32位,SOCKET类型就为unsignedint,64位则为unsignedint64,总之,SOCKET本身也是一个数值类型。 为了兼容Linux和Windows这两个系统,工程中在Linux下定义了两个宏:SOCKET和INVALIDSOCKET。defineSOCKETintdefineINVALIDSOCKET1 虽然两个系统中的定义略有不同,但::socket函数的表现却是相同的。如果调用::socket函数失败,在Linux系统下的返回值为1,在Windows系统下返回一个宏定义INVALIDSOCKET。 除了定义不同之外,两个系统显示出错信息的函数也有差别,工程中定义了sockerr宏来处理。关于这个宏,在Windows系统和Linux系统下的定义不同,其定义如下:ifndefWIN32definesockerr()WSAGetLastError()elsedefinesockerr()errnoendif 关键点3:绑定IP与端口 创建Socket之后需要指定IP地址和端口实现绑定操作,本例中指向了本机127。0。0。1的2233端口。sockaddrinaddr;memset(addr,0,sizeof(sockaddrin));addr。sinfamilyAFINET;addr。sinporthtons(2233);::inetpton(AFINET,127。0。0。1,addr。sinaddr。saddr);if(::bind(socket,reinterpretcastsockaddr(addr),sizeof(addr))0){std::cout::bindfailed。err:sockerr()std::endl;return1;} 在绑定过程中使用了sockaddrin结构,该结构中指定了协议族、IP地址和端口。 之前讨论过IP地址可以唯一标识一台计算机,但一台计算机可能有多个IP地址,至少可以有一个对外的地址和一个对内的地址。日常中,设置IP地址为192。168。0。120,这是一个内网地址,127。0。0。1是一个特定的描述,指向本机,也是一个内网地址。在本例中,打开127。0。0。1的2233端口,开放的范围只是本机,也就是说这个服务端只能由本机上执行的clientd对它进行连接。如果这里填写的IP地址是192。168。0。120,那么开放的范围是整个局域网,离开了局域网就不能访问了。如果计算机还有一个公网IP地址,调用::bind函数绑定的是一个公网IP地址,那么在任何地方、任何计算机上都可以访问这个IP地址开放的端口。 特别说明:如果在Linux下反复测试时遇到了错误::bindfailed。Err:98,则是因为之前绑定的端口没有被释放,系统有一定的回收时间。为了快速释放,我们可以输入sslnpt命令找到端口对应的PID,使用kill指令杀掉进程。〔rootlocalhostbin〕sslnptStateRecvQSendQLocalAddress:PortPeerAddress:PortLISTEN010127。0。0。1:2233:users:((logind,pid6367,fd3))〔rootlocalhostbin〕kill96367 关键点4:监听网络 绑定好IP地址和端口之后,还需要打开对Socket的监听,服务端的工作才算完成。其代码如下:intbacklogGetListenBacklog();if(::listen(socket,backlog)0){std::cout::listenfailed。sockerr()std::endl;return1;} 对于服务器来说,这是必不可少的一步,调用了底层函数::listen。只有打开了监听,我们才可以敏锐地察觉是否有客户端对该端口发起了通信请求。参数backlog指定了请求缓存列表可以有多长。 关键点5:等待连接 有了监听就可以接收连接了,接收连接的代码如下:structsockaddrsocketClient;socklentsocketLengthsizeof(socketClient);intnewSocket::accept(socket,socketClient,socketLength); 在本例中,程序会在::accept函数阻塞住。如果在::accept后面一行打一个断点,会发现断点不会被触发。::accept函数的功能是接收一个请求,如果没有就会一直等待。所以,在运行了serverd还没有运行clientd之前,服务端处于阻塞状态,它在等待一个连接请求。 在一些网络编程图书中会讨论TCP的3次握手,在本程序中,我们并没有看到3次握手,而是通过::accept函数就收到了连接。这并不是说3次握手不存在或者没有完成,而是这3次握手过程已经在底层完成了。 关键点6:接收数据 当::accept函数接收到有新的连接到来时,双方通信建立完成,可以开始发送数据。代码中调用了底层::recv函数接收数据,再调用::send函数发送接收到的数据。这部分的代码如下:charbuf〔1024〕;memset(buf,0,sizeof(buf));autosize::recv(newSocket,buf,sizeof(buf),0);if(size0){std::cout::recv。bufstd::endl;::send(newSocket,buf,size,0);std::cout::send。bufstd::endl;} 在上面的代码中,接收数据放在一个长度为1024字节的缓存中,接收到什么数据就发送什么数据回去。 关键点7:关闭Socket 关闭Socket调用了两个宏:sockclose(socket);sockexit(); 在Windows和Linux系统下做了不同的处理。在Windows系统下初始化时调用了WSAStartup函数,结束时则需要调用WSACleanup函数。宏定义如下:ifndefWIN32definesockexit()definesockclose(sockfd)::close(sockfd)elsedefinesockexit(){WSACleanup();}definesockclose(sockfd)::closesocket(sockfd)endif 特别说明:在Linux下关闭Socket时,有时使用::close函数,有时使用::shutdown函数。这两者有什么区别呢? 可以做一个实验,将::close函数替换为::shutdown函数。在生成和关闭Socket处进行打印,从打印信息中可以看出,使用::shutdown函数,关闭过的Socket即使关闭了,也不会重用。每次有新的连接到来时,就需要用到新的Socket,其值会在之前的值上加1。这是因为::shutdown函数会关闭TCP连接,但不释放Socket。而::close函数会将套接字计数减1,当计数0时,会自动调用::shutdown函数。看上去在关闭连接时使用::close才是正确的,但为什么还是有人直接使用::shutdown呢?因为使用::close并不能真正断开连接,它只是计数减1,在某些情况下,可能需要直接断开连接,所以调用::shutdown函数关闭网络。 而使用::close函数,在某些情况下,Socket的状态会变为CLOSEWAIT状态,CLOSEWAIT实际上就是等待关闭状态。 出现这种情况的原因比较复杂,有一种情况是客户端想关闭,但服务端可能还在读或写,就产生了等待。在网络编程中,这是我们需要特别注意的一个问题。1。4。3客户端代码分析 客户端的源代码与服务端略有不同,相对来说步骤简单一些。客户端的主要逻辑在client。cpp文件中,需要掌握以下几个关键点。 关键点1:Socket初始化 在客户端开始时,同样初始化和创建了Socket,与服务端原理一致。sockinit();SOCKETsocket::socket(AFINET,SOCKSTREAM,IPPROTOTCP);if(socketINVALIDSOCKET){std::cout::socketfailed。err:sockerr()std::endl;return1;} 关键点2:网络连接 对于客户端来说,并不需要执行::bind绑定函数,也不需要监听端口。客户端需要执行的是调用::connect函数,它向一个指定的IP和端口发起连接操作。函数::connect会用到sockaddrin结构。调用代码如下:sockaddrinaddr;memset(addr,0,sizeof(sockaddrin));addr。sinfamilyAFINET;addr。sinporthtons(2233);::inetpton(AFINET,127。0。0。1,addr。sinaddr。saddr);if(::connect(socket,(structsockaddr)addr,sizeof(sockaddr))0){std::cout::connectfailed。err:sockerr()std::endl;return1;} 在客户端,使用sockaddrin结构的初始化工作与服务端一样。客户端调用了服务端没有涉及的底层函数::connect,向一个指定的地址(也就是在服务端开放的地址)发起了一个连接。 关键点3:发送数据 客户端与服务端一致,都是调用底层函数::send发送数据。使用下面的代码发送一个ping字符串:std::stringmsgping;::send(socket,msg。cstr(),msg。length(),0); 关键点4:接收数据 客户端发送数据之后,陷入等待操作中,函数::recv等待接收数据。charbuffer〔1024〕;memset(buffer,0,sizeof(buffer));::recv(socket,buffer,sizeof(buffer),0);std::cout::recv。bufferstd::endl;1。4。4系统差异 在前两小节中,对Linux和Windows两个系统进行了有区别的编码。除了Socket的定义之外,还有两个大的区别: (1)虽然网络API是底层函数,但在Windows系统下创建Socket之前需要调用WSAStartup函数,而退出的时候需要调用WSACleanup函数。两个函数的定义如下:intWSAStartup(WORDwVersionRequested,LPWSADATAlpWSAData);intWSACleanup(); (2)获取错误的方式也略有不同。在Linux系统下使用errno变量,在Windows下使用WSAGetLastError函数,如果执行网络API时出错,该函数就会返回一个错误码,定义如下:intWSAGetLastError();1。4。5网络底层函数说明 前面举了一个简单的例子,用到的底层函数是网络编程的基础,本书之后的所有示例都是基于这些网络底层函数来完成的。为了加深理解,下面对这些底层函数逐一进行详细的说明。 1。函数::socket 客户端与服务端在初始化时,均使用了::socket函数。每个网络通信必有一个Socket。函数的参数说明见表13,原型如下:intsocket(intfamily,inttype,intprotocol); 表13socket函数参数 在本节的例子中,不论是在服务端还是客户端,生成Socket时均采用了AFINET、SOCKSTREAM、IPPROTOTCP这3个参数,即采用IPv4协议,以SOCKSTREAM数据流发送时采用可靠的TCP。 正常情况下,调用该函数会返回一个大于零的正数,即Socket值。这个值是唯一的,由系统分配。如果A与B建立了连接,那么对于A来说,在连接没有中断的情况下,它一定是一个定值,且不与其他Socket值相同。Socket这个单词在英文里的翻译是插槽、插座,在网络术语中一般翻译成套接字,可以将其理解为如果占用了一个插槽,其他人是不可能再使用的。 在Windows下,生成的Socket值是随机大于1000的值,在Linux下,它是从个位数开始累计的。在Linux下,Socket值也被称为描述符。这和Linux的内核结构有关联,这里不需要深究,只需要知道Socket也称为描述符即可。一旦Socket创建成功,返回的Socket值就会成为网络通信的重要依据。需要注意的是,Socket值是可以被复用的,也就是说,如果最开始分配了123给A,随后A断线,C上线,C也有可能分配到123。 2。函数::bind ::bind是服务端必不可少的一个函数,其作用是指定IP和端口开放给客户端连接,客户端则没必要调用该函数。函数的参数说明见表14,函数的原型如下:intbind(intsockfd,constsockaddraddress,intaddresslen); 表14bind函数参数 参数sockfd即调用::socket函数之后得到的Socket值。 sockaddr是通用的套接字地址结构,在代码中还使用了sockaddrin结构,这两者在这里没有什么差别,长度一样,可以相互转换,sockaddrin是Internet环境下套接字的地址形式。参数address指定了Socket需要绑定的地址信息,这些信息中包括IP和端口。 该函数返回负数就表示出错了。如果试图绑定一个已经在使用的端口,调用::bind就会失败。 3。函数::listen ::listen是服务端调用的函数,用于对IP地址和端口的监听。函数的参数说明见表15,函数的原型如下:intlisten(intsockfd,intbacklog); 表15listen函数的参数说明 关于连接队列的最大长度,可以使用系统的宏SOMAXCONN。在Linux下,它的定义在usrincludebitssocket。h文件中,默认为128。值backlog的意义在于,当一个连接请求到来时,另一个连接请求可能同时到来,系统需要缓存其中之一,backlog是系统处理连接的缓冲队列的长度。虽然TCP有3次握手,但是目前来看这个过程还是相当快的,所以510个缓存已经足够使用。 该函数返回负数就表示出错了。 4。函数::connect ::connect是对一个已知地址进行网络连接的函数。一旦客户端调用::connect函数,就会触发TCP的3次握手协议,3次握手完成之后,在服务端会调用::accept函数。 在调用::connect函数时,如果失败,就不能对当前Socket再次调用::connect函数,正确的做法是关闭Socket再次调用::connect函数。该函数的参数与::bind函数是一致的。函数的原型如下:intconnect(intsockfd,constsockaddraddress,intaddresslen); 该函数返回负数就表示出错了。 5。函数::accept 该函数用于监听端口,若::accept收到数据,则一定有一个新的连接被发起。函数的参数与::bind函数是一致的。函数的原型如下:intaccept(intsockfd,sockaddraddress,intaddresslen); 函数::accept返回的值是一个新的Socket值。调用::accept时,我们传入了一个Socket值,这个值可以称为监听Socket,而返回的这个新值就是客户端连接服务端的连接Socket。这两个值是有区别的。服务端有且仅有一个监听Socket,却可以有无数个连接Socket。 6。函数::send和::recv 函数::send和::recv是一对用于发送和接收数据的函数,除了这两个函数之外,网络底层还提供了其他发送和接收数据的函数,适用于不同的场合,这里只介绍我们使用的这一对函数。函数的参数说明见表16,函数的原型如下:intsend(intsockfd,constcharbuf,intlen,intflags)intrecv(intsockfd,charbuf,intlen,intflags) 表16发送、接收函数参数 在4个参数中,需要着重说明的是buf参数。对于::send函数而言,发送的缓冲buf是const指针,而len则是发送数据的长度。 对于::recv函数而言,buf是接收数据的缓存,len是该缓存的长度。假设服务端向客户端发送了2024字节的数据,但客户端接收buf的长度只有1024,len的长度也只能为1024,即::recv函数一次只会读取系统底层网络缓冲中的1024字节,放入buf缓冲中。这个概念非常关键,会引发粘包、拆包的问题。网络数据并不是我们想象中一条一条规整地发送过来的,有可能接收的1024字节里面有3个协议数据,也有可能接收的1024字节只是某个协议的一部分,需要多次读取。在后面的例子中,我们会详细讲解这些情况该如何处理。 发送和接收函数调用失败返回非正数,若成功,则返回发送、接收的字节长度。对于::recv函数来说,若返回0,则表示在另一端发送了一个FIN结束包,网络已中断。但::recv函数在返回负数时,也并不都意味着网络出错而需要断开网络,这在后面用到的时候再讲解。1。4。6小结 我们已经对网络通信有了一定的了解,客户端和服务端在初始化时略有不同,但收发数据的流程是相同的,本例采用了阻塞式的收发数据方式,所有的代码都是在阻塞模式下进行的。函数::accept、::send和::recv都处于阻塞模式下。 所谓阻塞,就是一定要收到数据之后,后面的操作才会继续。客户端调用::connect函数连接到服务端,发送数据之后一直阻塞在::recv函数上,直到收到服务端传来的数据才退出。服务端同样是阻塞的,在::accept函数处等待连接进来,如果没有就一直等待,接收到一个连接之后,再次阻塞,等待::recv函数返回数据。 作为服务端,采用阻塞模式显然不够高效。一般来说,服务端需要同时处理成千上万个通信,不能因为一个连接而阻塞另一个连接的收发数据进程。在实际情况下,更常用的是非阻塞模式。接下来以一个实例来说明非阻塞模式是如何工作的。 课程完整版可前往UWA学堂观看《多人在线游戏架构实战:基于C的分布式游戏编程》 https:edu。uwa4d。comcourseintro0382本书特色 1、从网络游戏的底层编码开始,深入讲解游戏开发的详细步骤、游戏主循环、线程的使用、Actor模式的应用等。 2、以直观的方式阐述和还原游戏制作的全过程,全面介绍游戏编码过程中众多的核心概念和具体实现,如定时器、对象池、组件编码、架构层的解耦等。 3、使用C来实现游戏的架构,读者也可以举一反三,使用其他的编程语言轻松实现游戏开发目标。你将获得 1、充分了解业务逻辑和底层框架的设计意图 2、立足实践的服务端学习思路,深入浅出 3、用实际案例贯穿各知识点,在实践中学习 4、了解商业游戏的设计思路和实现方法