小谈Online-game服务器端设计(1)
谈这个话题之前,首先要让大家知道,什么是服务器。在网络游戏中,服务器所扮演的角色是同步,广播和服务器主动的一些行为,比如说天气,NPC AI之类的,之所以现在的很多网络游戏服务器都需要负担一些游戏逻辑上的运算是因为为了防止客户端的作弊行为。了解到这一点,那么本系列的文章将分为两部 分来谈谈网络游戏服务器的设计,一部分是讲如何做好服务器的网络连接,同步,广播以及NPC的设置,另一部分则将着重谈谈哪些逻辑放在服务器比较合适,并 且用什么样的结构来安排这些逻辑。
服务器的网络连接
大多数的网络游戏的服务器都会选择非阻塞select这种结构,为什么呢?因为网络游戏的服务器需要处理的连接非常之多,并且大部分会选择在 Linux/Unix下运行,那么为每个用户开一个线程实际上是很不划算的,一方面因为在Linux/Unix下的线程是用进程这么一个概念模拟出来的, 比较消耗系统资源,另外除了I/O之外,每个线程基本上没有什么多余的需要并行的任务,而且网络游戏是互交性非常强的,所以线程间的同步会成为很麻烦的问 题。由此一来,对于这种含有大量网络连接的单线程服务器,用阻塞显然是不现实的。对于网络连接,需要用一个结构来储存,其中需要包含一个向客户端写消息的 缓冲,还需要一个从客户端读消息的缓冲,具体的大小根据具体的消息结构来定了。另外对于同步,需要一些时间校对的值,还需要一些各种不同的值来记录当前状 态,下面给出一个初步的连接的结构:
typedef connection_s {
user_t *ob; /* 指向处理服务器端逻辑的结构 */
int fd; /* socket连接 */
struct sockaddr_in addr; /* 连接的地址信息 */
char text[MAX_TEXT]; /* 接收的消息缓冲 */
int text_end; /* 接收消息缓冲的尾指针 */
int text_start; /* 接收消息缓冲的头指针 */
int last_time; /* 上一条消息是什么时候接收到的 */
struct timeval latency; /* 客户端本地时间和服务器本地时间的差值 */
struct timeval last_confirm_time; /* 上一次验证的时间 */
short is_confirmed; /* 该连接是否通过验证过 */
int ping_num; /* 该客户端到服务器端的ping值 */
int ping_ticker; /* 多少个IO周期处理更新一次ping值 */
int message_length; /* 发送缓冲消息长度 */
char message_buf[MAX_TEXT]; /* 发送缓冲区 */
int iflags; /* 该连接的状态 */
} connection_t;
服务器循环的处理所有连接,是一个死循环过程,每次循环都用select检查是否有新连接到达,然后循环所有连接,看哪个连接可以写或者可以读,就处理该连接的读写。由于所有的处理都是非阻塞的,所以所有的Socket IO都可以用一个线程来完成。
由于网络传输的关系,每次recv()到的数据可能不止包含一条消息,或者不到一条消息,那么怎么处理呢?所以对于接收消息缓冲用了两个指针,每次接收都 从text_start开始读起,因为里面残留的可能是上次接收到的多余的半条消息,然后text_end指向消息缓冲的结尾。这样用两个指针就可以很方 便的处理这种情况,另外有一点值得注意的是:解析消息的过程是一个循环的过程,可能一次接收到两条以上的消息在消息缓冲里面,这个时候就应该执行到消息缓 冲里面只有一条都不到的消息为止,大体流程如下:
while ( text_end – text_start > 一条完整的消息长度 )
{
从text_start处开始处理;
text_start += 该消息长度;
}
memcpy ( text, text + text_start, text_end – text_start );
对于消息的处理,这里首先就需要知道你的游戏总共有哪些消息,所有的消息都有哪些,才能设计出比较合理的消息头。一般来说,消息大概可分为主角消息,场景 消息,同步消息和界面消息四个部分。其中主角消息包括客户端所控制的角色的所有动作,包括走路,跑步,战斗之类的。场景消息包括天气变化,一定的时间在场 景里出现一些东西等等之类的,这类消息的特点是所有消息的发起者都是服务器,广播对象则是场景里的所有玩家。而同步消息则是针对发起对象是某个玩家,经过 服务器广播给所有看得见他的玩家,该消息也是包括所有的动作,和主角消息不同的是该种消息是服务器广播给客户端的,而主角消息一般是客户端主动发给服务器 的。最后是界面消息,界面消息包括是服务器发给客户端的聊天消息和各种属性及状态信息。
下面来谈谈消息的组成。一般来说,一个消息由消息头和消息体两部分组成,其中消息头的长度是不变的,而消息体的长度是可变的,在消息体中需要保存消息体的 长度。由于要给每条消息一个很明显的区分,所以需要定义一个消息头特有的标志,然后需要消息的类型以及消息ID。消息头大体结构如下:
type struct message_s {
unsigned short message_sign;
unsigned char message_type;
unsigned short message_id
unsigned char message_len
}message_t;
服务器的广播
服务器的广播的重点就在于如何计算出广播的对象。很显然,在一张很大的地图里面,某个玩家在最东边的一个动作,一个在最西边的玩家是应该看不到的,那么怎 么来计算广播的对象呢?最简单的办法,就是把地图分块,分成大小合适的小块,然后每次只象周围几个小块的玩家进行广播。那么究竟切到多大比较合适呢?一般 来说,切得块大了,内存的消耗会增大,切得块小了,CPU的消耗会增大(原因会在后面提到)。个人觉得切成一屏左右的小块比较合适,每次广播广播周围九个 小块的玩家,由于广播的操作非常频繁,那么遍利周围九块的操作就会变得相当的频繁,所以如果块分得小了,那么遍利的范围就会扩大,CPU的资源会很快的被 吃完。
切好块以后,怎么让玩家在各个块之间走来走去呢?让我们来想想在切换一次块的时候要做哪些工作。首先,要算出下个块的周围九块的玩家有哪些是现在当前块没 有的,把自己的信息广播给那些玩家,同时也要算出下个块周围九块里面有哪些物件是现在没有的,把那些物件的信息广播给自己,然后把下个块的周围九快里没有 的,而现在的块周围九块里面有的物件的消失信息广播给自己,同时也把自己消失的消息广播给那些物件。这个操作不仅烦琐而且会吃掉不少CPU资源,那么有什 么办法可以很快的算出这些物件呢?一个个做比较?显然看起来就不是个好办法,这里可以参照二维矩阵碰撞检测的一些思路,以自己周围九块为一个矩阵,目标块 周围九块为另一个矩阵,检测这两个矩阵是否碰撞,如果两个矩阵相交,那么没相交的那些块怎么算。这里可以把相交的块的坐标转换成内部坐标,然后再进行运 算。
对于广播还有另外一种解决方法,实施起来不如切块来的简单,这种方法需要客户端来协助进行运算。首先在服务器端的连接结构里面需要增加一个广播对象的队 列,该队列在客户端登陆服务器的时候由服务器传给客户端,然后客户端自己来维护这个队列,当有人走出客户端视野的时候,由客户端主动要求服务器给那个物件 发送消失的消息。而对于有人总进视野的情况,则比较麻烦了。
首先需要客户端在每次给服务器发送update position的消息的时候,服务器都给该连接算出一个视野范围,然后在需要广播的时候,循环整张地图上的玩家,找到坐标在其视野范围内的玩家。使用这 种方法的好处在于不存在转换块的时候需要一次性广播大量的消息,缺点就是在计算广播对象的时候需要遍历整个地图上的玩家,如果当一个地图上的玩家多得比较 离谱的时候,该操作就会比较的慢。