Cocos2d-x(1) 网络传输架构
网络
经过如此长时间的孤单捕鱼之后,现在我们的游戏终于要引入网络和多人联机的功能了。在移动互联网时代,移动设备与过去最大的不同就在于网络传输速度的大幅提升。此外,硬件的提升也给了游戏更大的想象空间。借助网络,我们可以为游戏添加更多有趣的特性。
网络传输架构
在游戏中选择网络传输方式时需要慎重。通常,有两种可能的方式供我们选择,具体如下所示。
- 直接使用socket传输。通信的两端都使用一个特定的端口传输数据,传输面向的是字节流或数据包,需要处理的细节比较多,包括建立与关闭连接、设计与实现网络协议、维护传输通道的稳定性、监控数据传输速率等。因此,很多情况下,我们需要在socket传输之上再根据游戏需求包装一层操作协议,以降低使用复杂度。
- 使用HTTP传输。直接用数据包的形式将数据提交到服务端的特定 URL 中,服务器同样用数据包的形式返回响应数据,其中大量的底层细节隐藏在了HTTP中。HTTP 作为非对等、主从式的传输,需要建立对应的服务器,幸运的是,Web大潮催生了一批稳定、成熟的HTTP服务器框架,让我们可以方便地建立一个 HTTP服务器。
还有一个值得考虑的因素是,移动设备的网络位置通常是不固定的,哪怕是在一个比较短的时间内,网络环境也有可能发生变动。如果直接在两台移动设备间建立连接进行通信,一方面难以确定一个固定的用于连接的地址,另一方面网络传输速率可能存在跳变。
对于非对战类游戏来说,在两个移动设备之间需要传输数据的情景并不多,少量的情景(如聊天等)建议通过服务器转发来完成。
综合考虑以上的因素,游戏中涉及网络部分的传输一般采用中心服务器的架构,服务器以HTTP服务形式建立API 服务,各移动终端向中心服务器请求所需的API 获得服务。
CURL
CURL是Cocos2d-x 推荐使用的网络传输库,随引擎代码一起分发了CURL的一份 CPP 实现。它是免费开源的,而且支持FTP、HTTP、LDAP 等多种传输方式,同时横跨了 Windows、UNIX 、Linux 平台,可以在各种主流的移动设备上良好工作。
它存在两套核心的接口,分别对应两种不同的使用方式,具体如下所示。
单线程传输的阻塞方式:每次处理一个传输请求,会一直阻塞当前线程直到传输完成。
非阻塞方式:允许同时提交一批传输请求,CURL会开启后台线程处理这些请求,传输结果的返回也是异步的。
在传输开始之前,这两种方式的CURL都要求我们先做全局的初始化,以及与之匹配的使用完毕之后的全局清理,这通常对应了程序的开始和结束。为保证正常的全局初始化和清理,我们使用单例模式对其简单封装如下:
class CURL_GLOBAL_INITIATOR { CURL_GLOBAL_INITIATOR() { curl_global_init(CURL_GLOBAL_ALL); } static CURL_GLOBAL_INITIATOR curl_global_initiator; public: ~CURL_GLOBAL_INITIATOR() { curl_global_cleanup(); } }; CURL_GLOBAL_INITIATOR CURL_GLOBAL_INITIATOR::curl_global_initiator;
CURL是纯C 写成的网络库,其API 全是函数形式,不涉及类,在实际使用中,我们不妨根据情况作适当的二次封装。
简单传输
简单传输就是阻塞的单线程传输,使用方式相对简单,其接口也都是前缀为curl_easy_的形式,涉及 4 个常用API,如下所示:
CURL *curl_easy_init(void); //初始化一个传输 CURLcode curl_easy_setopt(CURL *curl, CURLoption option, ...); //设置传输参数 CURLcode curl_easy_perform(CURL *curl); //执行当前传输 void curl_easy_cleanup(CURL *curl); //清理
其中curl_easy_setopt函数的使用形式和一般的函数不太一样的是,没有对每一个参数配备独立的设置函数,而是通过定义不同的枚举常量传递参数设置,这和OpenGL中的 glEnable 系列函数非常类似,极大地简化了 API 接口。
我们沿用键值对的形式传输数据,这样的好处依然是简单灵活,只需通过POST 形式向服务器提交数据即可。因此,根据实际使用,我们将对其作适当的二次封装,相关代码如下:
class NetworkAdaptor{ string m_sBaseUrl; public: NetworkAdaptor(const string& baseUrl); NetworkAdaptor(const char* baseUrl); bool sendValueForKey(const char* key, const char* _value, string& writeBackBuff); bool sendValuesForKey(const map <string, string>& values, string& writeBackBuff); };
其中核心部分的sendValuesForKey函数向一个预设的URL 传输一个字典中的所有键值对。只传输一对键值对的sendValueForKey 可以在此基础上实现。sendValuesForKey函数的实现代码如下所示:
bool NetworkAdaptor::sendValuesForKey(const map <string,string>& values, string& writeBackBuff) { CURL *curl = curl_easy_init(); string sendout; translate(values, sendout); curl_easy_setopt(curl, CURLOPT_URL, m_sBaseUrl.c_str()); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_POST, 1); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, sendout.c_str()); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writer); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &writeBackBuff); int res = curl_easy_perform(curl); curl_easy_cleanup(curl); if (res == 0) { CCLOG("get data from server : %s", writeBackBuff.c_str()); return true; } else { CCLOG("curl post error!"); return false; } }
在这个函数中,我们用curl_easy_setopt 设置了CURL 的几个关键参数:设置发送 POST 请求、设置上传的数据、设置回调函数,以及设置缓冲对象。最后便是执行和清理了。
translate 函数负责将传入的字典值按照 POST的参数传递标准序列化为字符串,其实现代码如下:
void translate(const map <string, string>& values, string& sendoutMsg) { sendoutMsg = ""; for(map<string, string>::const_iterator it = values.begin(); it != values.end(); ++it) { sendoutMsg += (it ->first + " = " + it->second); } }
而回调写函数则是根据CURL的回调标准编写,负责将 data指向的 nmemb 个数据(每个数据的大小为 size字节)写入writeData 缓冲区内,并返回读取的总字节数。这里我们直接将传回的数据连接到一个缓冲字符串之后。而在实际开发中,这也是一个非常适合用于解码的地方,我们可以将传回的数据直接反序列化为需要操作的数据对象,相关代码如下:
size_t writer(char* data, size_t size, size_t nmemb, string* writerData) { LOG_FUNCTION_LIFE; if( writerData == NULL) return 0; writerData->append(data, size * nmemb); return size * nmemb; }
非阻塞传输
前面看到的整个网络传输过程是阻塞串行执行的,尽管设置了回调函数,但也只是为了应对间断到达的数据流,代码之间实际上不存在乱序执行的可能。在传输量稍大的情况下,例如初始化一些场景实时请求资源时,或是对游戏进行大规模升级时,阻塞主线程会导致画面停滞,这在实际开发中是绝对不允许的。另外,网络传输速度毕竟是有限的。即使网络繁忙时,系统内的大部分CPU 和内存资源也都是空闲的。因此,我们需要引入非阻塞的网络传输。
CURL是支持非阻塞传输的,而且还允许并行地进行多个网络请求,其接口主要是以 curl_multi为前缀的系列的函数:
CURLM *curl_multi_init(void); //初始化 CURLMcode curl_multi_add_handle(CURLM *multi_handle, CURL *curl_handle); //添加一个传输请求 CURLMcode curl_multi_perform(CURLM *multi_handle, int *running_handles); //执行传输 CURLMcode curl_multi_cleanup(CURLM *multi_handle); //清理
其巧妙之处就在于,将非阻塞传输搭建在了阻塞传输的基础之上,使得接口并不比阻塞传输复杂。我们再次将其封装为适合传输键值对的形式:
typedef map <string, string> StringMap; class AsynchronousNetworkAdaptor { protected: struct RequestInfo { RequestInfo(const StringMap& _v, const string& _u, string& _b) : values(_v), url(_u), buffer(_b) { } StringMap values; string url; string& buffer; }; vector<RequestInfo> requests; public: void sendValueForKeyToURL(const char* key, const char* _value, const string& url, string& writeBackBuff); void sendValuesForKeyToURL(const StringMap& values, const string& url, string& writeBackBuff); void flushSendRequest(); CC_SYNTHESIZE_READONLY(int, m_iUnfinishedRequest, UnfinishedRequest); };
每个请求到达后,我们仅仅将其缓冲到一个数组中:
void AsynchronousNetworkAdaptor::sendValuesForKeyToURL( const StringMap& values, const string& url, string& writeBackBuff){ RequestInfo info(values, url, writeBackBuff); requests.push_back(info); }
随后,在flushSendRequest函数内一次性地将所有的请求发出,相关代码如下:
void AsynchronousNetworkAdaptor::flushSendRequest() { CURLM* backUrl = curl_multi_init(); for(int i = 0; i < requests.size (); i++) { CURL *curl = curl_easy_init(); curl_easy_setopt(curl, CURLOPT_URL, requests[i].url); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_POST,1); string sendout; translate(requests[i].values, sendout); curl_easy_setopt(curl, CURLOPT_POSTFIELDS,sendout.c_str()); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writer); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &requests[i].buffer); curl_multi_add_handle(backUrl, curl); curl_easy_cleanup(curl); } curl_multi_perform(backUrl, &m_iUnfinishedRequest); }
注意,这里只是用一个for 循环将若干个独立的阻塞式传输请求包装在了一起,并添加到之前创建的非阻塞传输处理器中,回写函数还是沿用阻塞方式下的函数。最后发出请求时,传入了一个整型变量,这个变量表示还在传输中的请求的数目,可以实时地反映当前的传输情况。
这样,我们就可以在游戏中保持绘图的流畅,同时在每帧更新中检查传输是否完成,完成后再触发相应的操作。
用户记录
引入网络传输后可以完成的第一件事便是上传用户记录,这里的用户记录包括两方面的信息:一方面是用户对游戏的设置,诸如音效开关等;另一方面是用户的游戏进程信息,如用户的等级、金币数和成就等。这两方面信息本来是保存在本地设备的,我们将其上传到网络的服务器端,这带来的好处是用户可以随时在几台不同的设备上共用同一个捕鱼账户,自由地切换捕鱼的环境。
为了简化,我们依然沿用上一章的UserRecord作为例子:
void UserRecord::readFromServer(const char* url) { NetworkAdaptor adaptor(url); string val; adaptor.sendValueForKey(this ->makeKeyForWrite().c_str(), "", val); this->readFromString(val); } void UserRecord::saveToServer(const char* url) { NetworkAdaptor adaptor(url); string val; this->writeToString(val); adaptor.sendValueForKey(this ->makeKeyForWrite().c_str(), "", val); }
这里我们可以很好地享用了上一章的成果:只需要封装字符串的传输,非字符串类型可以通过序列化、反序列化与字符串相互转换。
多人对战与同步问题
仅仅上传用户记录还是没有充分利用联网带来的好处,我们还需要引入联网的双人对战模式。
一旦涉及多人对战,就不得不考虑设备间的同步问题。多人对战时存在两台以上的可计算设备,考虑到线程等潜在的不确定性因素,这些设备关于游戏状态的计算结果很可能是不一致的,这时就需要互相之间进行同步。尤其是一些涉及胜负的因素,例如对鱼的击中判断,必须放在专用的判断服务器上进行,以保证游戏的公平性。
然而,将哪些计算交由服务器进行就需要抉择了,一是服务器的性能再强大,能支撑的计算也是有限的;二是必须尽量压缩数据的传输量,因为玩家的流量和服务器的带宽都是有限的。
在所有的计算中,涉及胜负的游戏逻辑判断是难以避免的,只能尽量压缩传输的数据来减少带宽消耗。而下面我们将看到的,则是网络模块中常见的两个小技巧。
时间同步
首先必须同步的是时间,这里的时间并不等同于设备的时间,更确切地说是游戏时间,即游戏已经进行了多长时间。时间的重要性在于,游戏内发生的事件和服务器送达的同步信息,都需要依赖时间来最后同步到本地。这个时间通常在游戏一开始的时候由服务器发送,设备端从接收到服务器的指令开始计算,总的来说,服务器时间总要比设备端时间稍快一些。一个比较好的解决方案是,在游戏一开始的时候,服务器和设备端都向同一个时间服务器获取时间戳,同步双方的时间。这样在双方传输指令或事件时,可以根据标准时间给出时间戳,保证动作按序执行。
通用的时间同步协议是NTP,全球有若干开放的服务器提供时间同步服务,可以向服务器获取当前的标准时间。协议相对复杂,但是在使用上是比较简单的,读者可以在各大代码仓库(例如GitHub)中找到开源的实现,感兴趣的读者可以进一步研究。
鱼群同步
在同一个游戏里,两个玩家所面对的鱼群必须在包括动作、位置等全部状态上都是一致的,于是我们就面临着鱼群的同步问题。对于捕鱼游戏而言,只要稳定鱼群的生成即可。由于每一条鱼的动作都是事先规划好的,所以同步问题就相当于解决了。但如果鱼群交由服务器负责生成,生成结果的传输对带宽压力还是不小的,尤其是服务器还需要同时向两台以上的设备分发生成的结果;如果遇到网络的短暂阻塞,屏幕将出现短时间的无鱼状态,稍后到达的生成信息也很可能造成本地游戏进程延迟的混乱局面。
回顾游戏中的鱼群生成,实际上是由随机数控制的,我们只需要一个可复用的伪随机数序列生成器,在不同的设备间同步这个生成器的随机数种子,就可以保证两位玩家看到同样的鱼群了,相关代码如下:
class Random { int m_iSeed; public: Random(const int& seed = 0) : m_iSeed(seed) { } int nextInt(int low = 0, int high = 65535) { m_iSeed = (m_iSeed * 7 + 11); return m_iSeed % (high - low + 1) + low; } float nextFloat(float low = 0.0, float high = 1.0) { int tmp = nextInt(); return ((float)tmp/(float)65535); } };
这其实是一个有代表性的例子。很多游戏都存在着这样的场景,无论是Doodle Jump 的木板、《水果忍者》中的水果,还是模拟养成类游戏中的事件,都可以处理为由一个随机数发生器控制的随机事件,进而由一个随机数种子控制两台设备间的同步。
校验
尽管我们通过随机数种子控制了鱼群的同步,但还是不可能将鱼群的计算完全交由两台不同的设备自主进行,因为长时间运行之后同样可能出现一些误差。这时需要以服务器的计算结果为准,重新校准两台设备的鱼群状态。
频繁地校准将导致带宽消耗上升,就失去了使用随机数控制的意义了,所以我们需要一个校验手段,以比较出两台设备的状态是否一致。
通常,我们会取一些重要的游戏数据来计算一个校验值,一起传输到服务器后进行校验即可。一定要保证选择的数据两边是同等的,尽量避开剧烈变动的临时数据。在《捕鱼达人》中,鱼群的数量和每个鱼群的位置就是很好的校验数据。对于这个校验值的计算,我们可以采用一些轻量级的算法,确保其独特性就足够了,不必采用MD5 等重型的校验值计算方法。这里我们只对鱼群使用异或校验,生成一个32位的校验值,相关代码如下:
int hashForFishes(CCArray* fishes) { int ret = fishes->count(); for(int i = 0; i < fishes->count(); i++) { CCSprite* sp = dynamic_cast<CCSprite *>(fishes->objectAtIndex(i)); CCPoint pos = sp->getPosition(); ret ^= (int)pos.x; ret ^= (int)pos.y; } return ret; }
小结
在这一章中,我们探讨了网络相关的几个话题,从直接的数据传输到多人游戏中的多种同步问题。可以看到,涉及网络的游戏设计更追求异步、并发的编程思想。下面总结本章的重要知识点。
socket、HTTP:socket (套接字)是操作系统提供的网络基础设施,用于计算机在网络间建立数据连接;HTTP是运行于 TCP/IP应用层的一套文本传输协议,常用于网页传输。在游戏开发中,通常利用socket 产生长连接来维持游戏的同步,然而现在也有许多游戏采用HTTP。
CURL:CURL 是一套URL 通信库,提供了 HTTP、FTP 等协议的支持,因此可以利用 CURL方便地建立 HTTP连接。
阻塞、非阻塞:网络传输是一个耗时的任务,因此运行时会阻碍其他代码的执行,直接在主线程中执行网络传输任务称作阻塞式传输,而在新线程中异步地进行网络传输任务则称为非阻塞式传输。
同步:网络游戏需要保持用户终端与服务器的数据一致,这个过程称为同步。有许多方案可以解决同步问题。
本章我们选择了一种分布式的游戏逻辑计算框架,配合校验机制,可以有效降低带宽消耗。更深入的话题,如游戏大厅的设计和服务器端的负载均衡等,已经超出了本书的讨论范围,感兴趣的读者不妨查阅相关资料进一步学习。
End, thank you!!
浙公网安备 33010602011771号