网游服务端开发入门知识

大多数的网络游戏的服务器都会选择非阻塞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;
2  服务器的广播
  服务器的广播的重点就在于如何计算出广播的对象。很显然,在一张很大的地图里面,某个玩家在最东边的一个动作,一个在最西边的玩家是应该看不到的,那么怎么来计算广播的对象呢?最简单的办法,就是把地图分块,分成大小合适的小块,然后每次只象周围几个小块的玩家进行广播。那么究竟切到多大比较合适呢?一般来说,切得块大了,内存的消耗会增大,切得块小了,CPU的消耗会增大(原因会在后面提到)。个人觉得切成一屏左右的小块比较合适,每次广播广播周围九个小块的玩家,由于广播的操作非常频繁,那么遍利周围九块的操作就会变得相当的频繁,所以如果块分得小了,那么遍利的范围就会扩大,CPU的资源会很快的被吃完。
  切好块以后,怎么让玩家在各个块之间走来走去呢?让我们来想想在切换一次块的时候要做哪些工作。首先,要算出下个块的周围九块的玩家有哪些是现在当前块没有的,把自己的信息广播给那些玩家,同时也要算出下个块周围九块里面有哪些物件是现在没有的,把那些物件的信息广播给自己,然后把下个块的周围九快里没有的,而现在的块周围九块里面有的物件的消失信息广播给自己,同时也把自己消失的消息广播给那些物件。这个操作不仅烦琐而且会吃掉不少CPU资源,那么有什么办法可以很快的算出这些物件呢?一个个做比较?显然看起来就不是个好办法,这里可以参照二维矩阵碰撞检测的一些思路,以自己周围九块为一个矩阵,目标块周围九块为另一个矩阵,检测这两个矩阵是否碰撞,如果两个矩阵相交,那么没相交的那些块怎么算。这里可以把相交的块的坐标转换成内部坐标,然后再进行运算。
  对于广播还有另外一种解决方法,实施起来不如切块来的简单,这种方法需要客户端来协助进行运算。首先在服务器端的连接结构里面需要增加一个广播对象的队列,该队列在客户端登陆服务器的时候由服务器传给客户端,然后客户端自己来维护这个队列,当有人走出客户端视野的时候,由客户端主动要求服务器给那个物件发送消失的消息。而对于有人总进视野的情况,则比较麻烦了。
  首先需要客户端在每次给服务器发送update position的消息的时候,服务器都给该连接算出一个视野范围,然后在需要广播的时候,循环整张地图上的玩家,找到坐标在其视野范围内的玩家。使用这种方法的好处在于不存在转换块的时候需要一次性广播大量的消息,缺点就是在计算广播对象的时候需要遍历整个地图上的玩家,如果当一个地图上的玩家多得比较离谱的时候,该操作就会比较的慢。
3  服务器的同步
  同步在网络游戏中是非常重要的,它保证了每个玩家在屏幕上看到的东西大体是一样的。其实呢,解决同步问题的最简单的方法就是把每个玩家的动作都向其他玩家广播一遍,这里其实就存在两个问题:1,向哪些玩家广播,广播哪些消息。2,如果网络延迟怎么办。事实上呢,第一个问题是个非常简单的问题,不过之所以我提出这个问题来,是提醒大家在设计自己的消息结构的时候,需要把这个因素考虑进去。而对于第二个问题,则是一个挺麻烦的问题,大家可以来看这么个例子:
  比如有一个玩家A向服务器发了条指令,说我现在在P1点,要去P2点。指令发出的时间是T0,服务器收到指令的时间是T1,然后向周围的玩家广播这条消息,消息的内容是“玩家A从P1到P2”有一个在A附近的玩家B,收到服务器的这则广播的消息的时间是T2,然后开始在客户端上画图,A从P1到P2点。这个时候就存在一个不同步的问题,玩家A和玩家B的屏幕上显示的画面相差了T2-T1的时间。这个时候怎么办呢?
  有个解决方案,我给它取名叫 预测拉扯,虽然有些怪异了点,不过基本上大家也能从字面上来理解它的意思。要解决这个问题,首先要定义一个值叫:预测误差。然后需要在服务器端每个玩家连接的类里面加一项属性,叫latency,然后在玩家登陆的时候,对客户端的时间和服务器的时间进行比较,得出来的差值保存在latency里面。还是上面的那个例子,服务器广播消息的时候,就根据要广播对象的latency,计算出一个客户端的CurrentTime,然后在消息头里面包含这个CurrentTime,然后再进行广播。并且同时在玩家A的客户端本地建立一个队列,保存该条消息,只到获得服务器验证就从未被验证的消息队列里面将该消息删除,如果验证失败,则会被拉扯回P1点。然后当玩家B收到了服务器发过来的消息“玩家A从P1到P2”这个时候就检查消息里面服务器发出的时间和本地时间做比较,如果大于定义的预测误差,就算出在T2这个时间,玩家A的屏幕上走到的地点P3,然后把玩家B屏幕上的玩家A直接拉扯到P3,再继续走下去,这样就能保证同步。更进一步,为了保证客户端运行起来更加smooth,我并不推荐直接把玩家拉扯过去,而是算出P3偏后的一点P4,然后用(P4-P1)/T(P4-P3)来算出一个很快的速度S,然后让玩家A用速度S快速移动到P4,这样的处理方法是比较合理的,这种解决方案的原形在国际上被称为(Full plesiochronous),当然,该原形被我篡改了很多来适应网络游戏的同步,所以而变成所谓的:预测拉扯。
    另外一个解决方案,我给它取名叫 验证同步,听名字也知道,大体的意思就是每条指令在经过服务器验证通过了以后再执行动作。具体的思路如下:首先也需要在每个玩家连接类型里面定义一个latency,然后在客户端响应玩家鼠标行走的同时,客户端并不会先行走动,而是发一条走路的指令给服务器,然后等待服务器的验证。服务器接受到这条消息以后,进行逻辑层的验证,然后计算出需要广播的范围,包括玩家A在内,根据各个客户端不同的latency生成不同的消息头,开始广播,这个时候这个玩家的走路信息就是完全同步的了。这个方法的优点是能保证各个客户端之间绝对的同步,缺点是当网络延迟比较大的时候,玩家的客户端的行为会变得比较不流畅,给玩家带来很不爽的感觉。该种解决方案的原形在国际上被称为(Hierarchical master-slave synchronization),80年代以后被广泛应用于网络的各个领域。
  最后一种解决方案是一种理想化的解决方案,在国际上被称为Mutual synchronization,是一种对未来网络的前景的良好预测出来的解决方案。这里之所以要提这个方案,并不是说我们已经完全的实现了这种方案,而只是在网络游戏领域的某些方面应用到这种方案的某些思想。我对该种方案取名为:半服务器同步。大体的设计思路如下:
  首先客户端需要在登陆世界的时候建立很多张广播列表,这些列表在客户端后台和服务器要进行不及时同步,之所以要建立多张列表,是因为要广播的类型是不止一种的,比如说有local message,有remote message,还有global message 等等,这些列表都需要在客户端登陆的时候根据服务器发过来的消息建立好。在建立列表的同时,还需要获得每个列表中广播对象的latency,并且要维护一张完整的用户状态列表在后台,也是不及时的和服务器进行同步,根据本地的用户状态表,可以做到一部分决策由客户端自己来决定,当客户端发送这部分决策的时候,则直接将最终决策发送到各个广播列表里面的客户端,并对其时间进行校对,保证每个客户端在收到的消息的时间是和根据本地时间进行校对过的。那么再采用预测拉扯中提到过的计算提前量,提高速度行走过去的方法,将会使同步变得非常的smooth。该方案的优点是不通过服务器,客户端自己之间进行同步,大大的降低了由于网络延迟而带来的误差,并且由于大部分决策都可以由客户端来做,也大大的降低了服务器的资源。由此带来的弊端就是由于消息和决策权都放在客户端本地,所以给外挂提供了很大的可乘之机。 
4  NPC问题
  下面我想来谈谈关于服务器上NPC的设计以及NPC智能等一些方面涉及到的问题。首先,我们需要知道什么是NPC,NPC需要做什么。NPC的全称是(Non-Player Character),很显然,他是一个character,但不是玩家,那么从这点上可以知道,NPC的某些行为是和玩家类似的,他可以行走,可以战斗,可以呼吸(这点将在后面的NPC智能里面提到),另外一点和玩家物件不同的是,NPC可以复生(即NPC被打死以后在一定时间内可以重新出来)。其实还有最重要的一点,就是玩家物件的所有决策都是玩家做出来的,而NPC的决策则是由计算机做出来的,所以在对NPC做何种决策的时候,需要所谓的NPC智能来进行决策。
  下面我将分两个部分来谈谈NPC,首先是NPC智能,其次是服务器如何对NPC进行组织。之所以要先谈NPC智能是因为只有当我们了解清楚我们需要NPC做什么之后,才好开始设计服务器来对NPC进行组织。
NPC智能
  NPC智能分为两种,一种是被动触发的事件,一种是主动触发的事件。对于被动触发的事件,处理起来相对来说简单一些,可以由事件本身来呼叫NPC身上的函数,比如说NPC的死亡,实际上是在NPC的HP小于一定值的时候,来主动呼叫NPC身上的OnDie() 函数,这种由事件来触发NPC行为的NPC智能,我称为被动触发。这种类型的触发往往分为两种:
一种是由别的物件导致的NPC的属性变化,然后属性变化的同时会导致NPC产生一些行为。由此一来,NPC物件里面至少包含以下几种函数:
class NPC {
public:
    // 是谁在什么地方导致了我哪项属性改变了多少。
    OnChangeAttribute(object_t *who, int which, int how, int where);
Private:
    OnDie();
    OnEscape();
    OnFollow();
    OnSleep();
    // 一系列的事件。
}
  这是一个基本的NPC的结构,这种被动的触发NPC的事件,我称它为NPC的反射。但是,这样的结构只能让NPC被动的接收一些信息来做出决策,这样的NPC是愚蠢的。那么,怎么样让一个NPC能够主动的做出一些决策呢?这里有一种方法:呼吸。那么怎么样让NPC有呼吸呢?
  一种很简单的方法,用一个计时器,定时的触发所有NPC的呼吸,这样就可以让一个NPC有呼吸起来。这样的话会有一个问题,当NPC太多的时候,上一次NPC的呼吸还没有呼吸完,下一次呼吸又来了,那么怎么解决这个问题呢。这里有一种方法,让NPC异步的进行呼吸,即每个NPC的呼吸周期是根据NPC出生的时间来定的,这个时候计时器需要做的就是隔一段时间检查一下,哪些NPC到时间该呼吸了,就来触发这些NPC的呼吸。
  上面提到的是系统如何来触发NPC的呼吸,那么NPC本身的呼吸频率该如何设定呢?这个就好象现实中的人一样,睡觉的时候和进行激烈运动的时候,呼吸频率是不一样的。同样,NPC在战斗的时候,和平常的时候,呼吸频率也不一样。那么就需要一个Breath_Ticker来设置NPC当前的呼吸频率。
  那么在NPC的呼吸事件里面,我们怎么样来设置NPC的智能呢?大体可以概括为检查环境和做出决策两个部分。首先,需要对当前环境进行数字上的统计,比如说是否在战斗中,战斗有几个敌人,自己的HP还剩多少,以及附近有没有敌人等等之类的统计。统计出来的数据传入本身的决策模块,决策模块则根据NPC自身的性格取向来做出一些决策,比如说野蛮型的NPC会在HP比较少的时候仍然猛扑猛打,又比如说智慧型的NPC则会在HP比较少的时候选择逃跑。等等之类的。
  至此,一个可以呼吸,反射的NPC的结构已经基本构成了,那么接下来我们就来谈谈系统如何组织让一个NPC出现在世界里面。
NPC的组织
    这里有两种方案可供选择,其一:NPC的位置信息保存在场景里面,载入场景的时候载入NPC。其二,NPC的位置信息保存在NPC身上,有专门的事件让所有的NPC登陆场景。这两种方法有什么区别呢?又各有什么好坏呢?
    前一种方法好处在于场景载入的时候同时载入了NPC,场景就可以对NPC进行管理,不需要多余的处理,而弊端则在于在刷新的时候是同步刷新的,也就是说一个场景里面的NPC可能会在同一时间内长出来。而对于第二种方法呢,设计起来会稍微麻烦一些,需要一个统一的机制让NPC登陆到场景,还需要一些比较麻烦的设计,但是这种方案可以实现NPC异步的刷新,是目前网络游戏普遍采用的方法,下面我们就来着重谈谈这种方法的实现:
  首先我们要引入一个“灵魂”的概念,即一个NPC在死后,消失的只是他的肉体,他的灵魂仍然在世界中存在着,没有呼吸,在死亡的附近漂浮,等着到时间投胎,投胎的时候把之前的所有属性清零,重新在场景上构建其肉体。那么,我们怎么来设计这样一个结构呢?首先把一个场景里面要出现的NPC制作成图量表,给每个NPC一个独一无二的标识符,在载入场景之后,根据图量表来载入属于该场景的NPC。在NPC的OnDie() 事件里面不直接把该物件destroy 掉,而是关闭NPC的呼吸,然后打开一个重生的计时器,最后把该物件设置为invisable。这样的设计,可以实现NPC的异步刷新,在节省服务器资源的同时也让玩家觉得更加的真实。
补充的谈谈启发式搜索(heuristic searching)在NPC智能中的应用。
    其主要思路是在广度优先搜索的同时,将下一层的所有节点经过一个启发函数进行过滤,一定范围内缩小搜索范围。众所周知的寻路A*算法就是典型的启发式搜索的应用,其原理是一开始设计一个Judge(point_t* point)函数,来获得point这个一点的代价,然后每次搜索的时候把下一步可能到达的所有点都经过Judge()函数评价一下,获取两到三个代价比较小的点,继续搜索,那些没被选上的点就不会在继续搜索下去了,这样带来的后果的是可能求出来的不是最优路径,这也是为什么A*算法在寻路的时候会走到障碍物前面再绕过去,而不是预先就走斜线来绕过该障碍物。如果要寻出最优化的路径的话,是不能用A*算法的,而是要用动态规划的方法,其消耗是远大于A*的。
    那么,除了在寻路之外,还有哪些地方可以应用到启发式搜索呢?其实说得大一点,NPC的任何决策都可以用启发式搜索来做,比如说逃跑吧,如果是一个2D的网络游戏,有八个方向,NPC选择哪个方向逃跑呢?就可以设置一个Judge(int direction)来给定每个点的代价,在Judge里面算上该点的敌人的强弱,或者该敌人的敏捷如何等等,最后选择代价最小的地方逃跑。下面,我们就来谈谈对于几种NPC常见的智能的启发式搜索法的设计:
Target select (选择目标):
    首先获得地图上离该NPC附近的敌人列表。设计Judge() 函数,根据敌人的强弱,敌人的远近,算出代价。然后选择代价最小的敌人进行主动攻击。
Escape(逃跑):
    在呼吸事件里面检查自己的HP,如果HP低于某个值的时候,或者如果你是远程兵种,而敌人近身的话,则触发逃跑函数,在逃跑函数里面也是对周围的所有的敌人组织成列表,然后设计Judge() 函数,先选择出对你构成威胁最大的敌人,该Judge() 函数需要判断敌人的速度,战斗力强弱,最后得出一个主要敌人,然后针对该主要敌人进行路径的Judge() 的函数的设计,搜索的范围只可能是和主要敌人相反的方向,然后再根据该几个方向的敌人的强弱来计算代价,做出最后的选择。
Random walk(随机走路):
    这个我并不推荐用A*算法,因为NPC一旦多起来,那么这个对CPU的消耗是很恐怖的,而且NPC大多不需要长距离的寻路,只需要在附近走走即可,那么,就在附近随机的给几个点,然后让NPC走过去,如果碰到障碍物就停下来,这样几乎无任何负担。
Follow Target(追随目标):
    这里有两种方法,一种方法NPC看上去比较愚蠢,一种方法看上去NPC比较聪明,第一种方法就是让NPC跟着目标的路点走即可,几乎没有资源消耗。而后一种则是让NPC在跟随的时候,在呼吸事件里面判断对方的当前位置,然后走直线,碰上障碍物了用A*绕过去,该种设计会消耗一定量的系统资源,所以不推荐NPC大量的追随目标,如果需要大量的NPC追随目标的话,还有一个比较简单的方法:让NPC和目标同步移动,即让他们的速度统一,移动的时候走同样的路点,当然,这种设计只适合NPC所跟随的目标不是追杀的关系,只是跟随着玩家走而已了。

posted on 2011-05-24 19:22  游戏开发:主席  阅读(15292)  评论(6编辑  收藏  举报

导航