说说游戏服务器

 这是一个纯技术贴,以我做的mansterSLS作为基础,讲述该如何做一个mmorpg游戏服务器。最近心情不好写到重新入职为止。
所有最新的代码可以在https://sourceforge.net/projects/monstersls/ 目录code,svn下载。
说到网游服务器是最近火起来的,但最初的网游服务器几乎和单机游戏一样的古老那就是mudos。mudos是一个通过文字交流的古老的逻辑系统,有点类似聊天机器人,可以根据不同的客户端命令做出相应的回复,使用户维持在一个自我虚幻的环境,跟着故事情节的发展得到许多不同的命运体验。
游戏服务器处理游戏的逻辑部分,为啥不是在客户端呢?原因是游戏是一个完整的世界,所有事件都发生在这个世界里。既然是一个完整的世界那么这个世界发生的事件就要持续完整。这样所有事件都要记录在服务器供理论上所有客户端去查询。另一原因就是防止客户端篡改游戏世界的数据。请注意wow的客户没有加壳和任何通常的防止黑客的手段为什么呢?因为所有游戏世界的数据和行为都是发生在服务器。客户端的指令要经过逻辑判断,不合逻辑的指令会被判失败。一个简单的例子普通攻击指令执行过程大致是,客户端通过服务器查询看到某人,给服务器发送攻击指令,服务器计算攻击的结果,给客户端和他攻击的目标发送结果,并在服务端保存对用的数据。这里无论通过任何客户端都可以给服务器发送指令甚至是模拟器。服务器会先判断一系列的先决条件,例如攻击距离,对方是否在线等等。通过判断后计算攻击结果,这里一系列的过程客户端是无法更改的全部在服务端完成,并把这些数据共享给对应的客户端。这里我们可以看到在客户修改这些数值没有任何意义,服务判断的依据不是客户端上保存的数据。
到这里我们了解了游戏服务器就是一个大的数据库,这个数据库的特征描述如下:
1,高度并发的读取和写入。
2,数据字段不固定,可能在任何时刻增加或减少。
3,不需要模糊查询的操作,所有数据查询都是对象为基础。
等等剩下的还没有想到。
不过单从上面几点商用数据库可以pass了,即使号称对象数据库的PostgreSQL也不行,数据库的一大弊病就是为了模糊查询需要所有对象共享数据字段,这样即使某个数据不使用某个字段也要共享。会浪费大量的数据空间。游戏里面对象之间的字段也完全没有共性可言。
例如npc是一个对象,椅子也是一个对象,如果按hp,mp排序,要买椅子是hp,mp是零,要么是一个非常大的数值。因为椅子就没有hp,mp。如果npc和椅子放在数据库一个表里面椅子就要浪费mp,hp的两个段值。

typedef struct dobject
 {
     map<string, string> data;
     ///for share, only net object using 
     uintptr_t index;
     intptr_t x,y,z; 
     size_t stamp; 
     list<uintptr_t> view;
     void* psp;
 }DOBJECT, *PDOBJECT; 

上面代码是我在游戏里对象的定义,由一个动态增加的map段存储游戏里的逻辑数据。
下面讲到的数据和其他的系统相关会在稍后说到其他系统的时候详细介绍
例如mp,hp,物品,任务等等,
index是socket的索引在讲解网络系统的时候会讲到,
x,y,z是对象在空间的坐标,过段时间会加上方向。
stamp,是时间戳为移动加的。
view,是当前可见对象列表。
psp,是当前对象在空间矩阵的位置。

typedef struct tobject
{
    public:
        uintptr_t id;
        PDOBJECT data;
        pthread_mutex_t hmutex;

}TOBJECT, *PTOBJECT;

这个结构是我称为cell的结构,是一个静态数组结构的一部分。静态数组结构在服务器被普遍使用目的是通过一个全局地址指针可以快速查到相应的数据结构。因为地址指针是全局静态分配的任何情况下都不会访问失败,是对线程下交换数据最快的方式。我们看到这个结构有3部分组成,第一个是id是一个自增加整数值,每次给cell赋值新的对象都会加一,以区分被销毁的旧对象。humtex是互斥锁为了安全的访问cell内的数据对象,data指向的就是对象。
一个对象的创建的过程如下

uintptr_t OBJECT::ObNew(char* obid, uintptr_t index)
{
    PDOBJECT ob = new DOBJECT;
    ob->index = index;
    ob->psp = 0;
    ob->stamp = 0;

    ///add ob to g_obmap

    uintptr_t nob=0, no=0;

    for (size_t cursor = 0; cursor < g_maxobj; cursor++)
    {
        LOCK(g_obmap[cursor].hmutex, OBJECT::ObNew);
        if (NULL == g_obmap[cursor].data)
        {
            g_obmap[cursor].data = ob;
            nob = (uintptr_t)&(g_obmap[cursor]);
            sprintf(obid, "%p#%u", &g_obmap[cursor], ++g_obmap[cursor].id);
            no = 1;
        }
        UNLOCK(g_obmap[cursor].hmutex, OBJECT::ObNew);
        if (no)break;
    }

    return (uintptr_t)nob;
}

第一行new一个新对象,为什么不用malloc呢?因为我要使用map!对不起,我对C的狂热还不深。接下来给这个对象赋初始值,然后循环全局的 cell,找到一个最近为空的cell,把刚刚创造的对象放到cell里面。使用前要锁住cell对象,lock和unlock是两个宏。

///if lock or unlock fail all is unacceptable
#define LOCK(mutex, fun) if(pthread_mutex_lock(&mutex) != 0)\
{\
    Logf(#fun "lock fail\r\n");\
    exit(0);\
}
#define UNLOCK(mutex, fun) if(pthread_mutex_unlock(&mutex) != 0)\
{\
    Logf(#fun "unlock fail\r\n");\
    exit(0);\
}


调用pthread_mutex_lock锁住互斥锁,你也许会问你的工程不是vc的吗?这个函数是linux?这是为了避免使用#define宏定义夸平台,所有的代码尽量使用标准C和C++语法避免,因为线程是平台相关的使用了一个了跨平台的win32版本的pthread库方便代码移植到lnux。如果这两个函数失败会写log后结束程序也就是这两个函数在运行过程中不能失败。

接下来我们看

sprintf(obid, "%p#%u", &g_obmap[cursor], ++g_obmap[cursor].id);


这个是在lua中使用的对象表示,在lua中对象是由地址和id组成的字符串,Lua部分我们稍后在讲。

int OBJECT::ObDelete(const char* obid)
{
    PTOBJECT tob = GET_OBJ(obid);
    int ret = 0;

    LOCK(tob->hmutex, OBJECT::ObDelete);

    if(NULL != tob->data && tob->id == GET_IND(obid))
    {
        if (tob->data->index)
            CloseIndex(tob->data->index);
        delete tob->data;
        tob->data = NULL;
        ret = 1;
    }

    UNLOCK(tob->hmutex, OBJECT::ObDelete);

    return ret;
}


这段代码讲的是对象的销毁,过程和创建相反根据对象字符串得到对象的地址调用cell锁,锁住对象判断cell对象内data是否为空和对象的id是否和当前cell的id是否相同,满足条件的话判断对象是否是一个网络连接对象,如是连接对象调用CloseIndex关闭连接,删除对象并把cell设置成空。

 

昨天迷迷糊糊的居然写了那么,我是做c++起步,c#和java对我来说太慢了。主线就是我开始考虑写服务器时最先想到最难得问题开始说的。对于一个游戏服务器可以多线程并发并快速读写数据是最优先考虑的问题。对于多线程并发最先考虑的是什么?加锁的位置,ok在游戏服务器最小的数据单位是什么呢?对象,ok!当我们一个线程得到一个对象并对这个对象加锁读取对象的数据。虽然游戏服务器可能有几百万个对象但一个行为受影响的对象只会有那么区区几个。这样无论任何时间点上相互等待的对象被限制在一定范围内了。这样多线程服务器就不会出现很多个线程等待一个线程的情况。捎带还要说下多线程的问题,开几个线程比较好呢?我的建议是运行平台有多少个cpu就开多少个线程,开多了,多余的线程要等待,开少了cpu会有富裕。
到目前我们知道了服务器是一个跟我们平时认识的数据库不太一样的数据库,如果有的比较的话客户端对服务器的命令就是数据库的存储过程。运行的过程大致是客户端发出对一些对象的操作指令,服务器锁住对象,操作对象的值,将指令运行的结果返回并分发到客户端。
下面要讲网络了,首先我使用的是select模型,这个模型不是最快的,但对于并发1万个链接来说每次的循环所占的cpu也算很低了。而且这个网络模型可以跨平台使用。使用了一个map用自增加一index做索引查找socket,因为socket反复创建会重复,关掉客户端链接后发送队列可能会有这个 socket的数据还没有发送,清理队列操作太耗时,用一个永远不重复的索引可以辨别sokcet是否已经释放,如果释放就不发送了。下面的代码是用户 socket的建立和删除。


 

BOOL CreateSocketInformation(SOCKET s)
{
    LPSOCKET_INFORMATION SI=&SocketArray[TotalSockets];

    DEBUGOUT("Accepted socket number %d\r\n", s);

    SI->Socket = s;
    SI->index = PashSToS(SI->Socket);
    memset(SI->Obid, 0, 128);
    ObNew(SI->Obid, SI->index);
    if (0 == SI->index || 0 == strlen(SI->Obid))
    {
        Logf("error PashSToS or ObNew\r\n");
        exit(0);
    }
    TotalSockets++;

    ///new client add login event
    PashCmdToR(SI->Obid, "login\r\n");

    return(TRUE);
}
///注意两种情况,来自脚本的关闭,和来自客户端的关闭
void FreeSocketInformation(DWORD Index)
{
    LPSOCKET_INFORMATION SI = &SocketArray[Index];
    DWORD i;

    SOCKET s;
    
    if(PopSToS(SI->index, s) && s == SI->Socket)
    {
        string obid;
        DEBUGOUT("Closing socket number %d\r\n", SI->Socket);
        closesocket(SI->Socket);
        SI->index = 0;
        obid = SI->Obid;

        ///无论来自脚本还是客户端这个时候socket已经关闭
        ///如果来自脚本关闭是先关闭ob再关闭连接
        ///如果来自客户端关闭是先关闭连接再关闭ob
        ///下面的调用是为了释放来自客户端关闭的ob
        ObDelete(SI->Obid);

        for (i = Index; i < TotalSockets; i++)
        {
            SocketArray[i] = SocketArray[i + 1];
        }

        TotalSockets--;

        ///new client add login event
        PashCmdToR(obid.c_str(), "logout\r\n");
    }
}


和客户端通信的方式使用的telnet的命令方式,这个方式解释比较方便组织液比较灵活。完全是文本流的方式也不会有各种太多的限制。名称和参数的定义可以按逻辑设置的要求任意组织给逻辑的编写提供了较大的自由空间。

 

//pash cmd to stack

                char *pbuf = rBuffer;
                for (uintptr_t i = 0; i < RecvBytes; i++)
                {
                    if (rBuffer[i] == '\n')
                    {
                        memset(rBuffer2, 0 , rBuffer + i - pbuf + 2);
                        strncpy(rBuffer2, pbuf, rBuffer + i - pbuf + 1);
                        if (rBuffer[0] != '\r' && rBuffer[0] != '\n')
                        {
                            PashCmdToR(SocketInfo.Obid, rBuffer2);
                        }
                        pbuf = rBuffer + i + 1;
                    }
                }///for end

这段代码把多行命令拆分后压入接受指令栈,接受指令栈会通知处理线程池。如果有空闲的线程就会处理弹出这个指令并处理。

 

因为很多人对游戏服务器还不太了解,这次先把游戏服务服务器的大致框架说下。游戏服务器包含了,游戏逻辑,登录,地图,聊天,即时数据存储(内存数据库),延迟的备份数据存储(游戏存档)。几个大的部分其中游戏逻辑最为复杂,为什么要引入游戏逻辑的概念,是为了把服务器引擎和游戏逻辑分开,引擎负责接受游戏命令把命令用适当的格式调用游戏逻辑处理。因为接收和逻辑分开同一套游戏服务器引擎可以应用到多个不同的游戏。每个游戏只是游戏逻辑不同罢了。所有游戏的活动,任务,战斗,菜单等等这些个个游戏都有差异的部分是通过逻辑部分修改得到。游戏逻辑可以使用c模块的方式和脚本的方式,这两个方式在具体实施过程中有着巨大的差异,一般建议用脚本方式,这个和具体的游戏的开发流程有关,使用脚本的方式有几个好处,
首先脚本的执行是安全的,通过适当的配置脚本运行过程中不会引起服务器宕机,在出现严重问题时可以得到详细的错误报告,并可以在服务器不重新启动的状态下动态修复错误。
第二个好处是因为脚本上手容易,运行安全,这样就可以让更多的人参与到游戏的开发。我的经验是如果产品或策划出文档,程序员再开发c模块在游戏行业是不适用的。在游戏里面改动是非常频繁的,个个游戏产品相识度又不高,产品和策划过去的经验在每个游戏产品当中又不那么通用。让他们更容易的参与到游戏的开发了解游戏引擎能做什么不能做什么,能够让他们有更多的精力投入到游戏内容的创意上。用游戏简单的功能创造出有趣的玩法才是策划要做的事情。他们使用脚本可以所见即所得,创意能否实现就可以不断地尝试验证。如果走下文档,程序开发。策划有创意但看不到结果,程序员有结果但不是自己的想法,做出来的东西难免差强人意。
使用脚本的好处还有后期可以便捷的开发游戏工具,使用工具生成脚本代码,就可以让更多对程序不是很了解的人参与到游戏的开发当中。

游戏开发的正常流程大致是这样的
程序开发基本的功能,产品在基本的功能上划分出,任务系统,pk系统,界面,boss战斗系统等基本的游戏元素。
策划在这些基本的系统基础上完成一个一个的案子。例如,可以先任务,再boss,再pk,再任务,再boss。这样产品就把程序的工作放大了n倍,策划又把产品提供的功能放大n倍。
执行力差是因为现在国内开发流程是策划看到别人有套案子很好。但自己的游戏缺乏某些游戏元素,甚至在底层的程序就不支持。由策划找了很多成功的案子要求底层实现,这个过程就很痛苦了,简单的说技术壁垒指的就是这个状况。

接下来继续说游戏的逻辑部分,逻辑部分在服务器引擎当中分为几个部分,接命令堆栈,处理线程池,发送命令堆栈,发送线程。在命令处理线程中运行游戏脚本处理命令,并把结果通过发送线程发送给多个客户端。
这个流程很简单的例子就是使用mudos,我在前面反复强调mudos因为虽然他性能不强大,但开发的思想是正确的。而且使用简单,文档也较多,对于新手来说是个不错的入门体验。虽然他的脚本没有实现”写不坏”的目标,脚本写的不好很容易让服务器宕机。



 

你可能感兴趣的:(游戏,数据库,socket,object,服务器,脚本)