接上文http://wchrt.blog.51cto.com/8472636/1661524
4、游戏通信协议设计
因为是PC、手机都能玩的游戏,考虑到糟糕的手机网络环境,通信采用客户端单方发起请求,服务器回复的方式,使服务器不用考虑确保手机信号不好或IP变更的情况,类似于web方式。
游戏没有设计固定的用户,采用的是游戏每次向服务器申请一个游戏ID,使用这个游戏ID在互联网上和其他用户对战。于是协议报文设计了两种:普通请求/回复报文gamequest、游戏数据报文nextquest。
#include <iostream> #include <string> #include <cstring> #define NEWID (char)1 #define NEWGAME (char)3 #define NEXTSTEP (char)5 #define GETNEXTSTEP (char)6 #define GAMEEND (char)10 #define NEWID_FAIL 0 #define NEWID_SECC 1 #define NEWGAME_FAIL 0 #define NEWGAME_ISFIRST 1 #define NEWGAME_ISSEC 2 #define NEXTSTEP_FAIL 1 #define NEXTSTEP_SEC 1 struct gamequest { unsigned int id; char type; unsigned int data; }; struct nextstephead { unsigned int id; char type; char x; char y; char mac;//游戏数据校验 short stepno; };
NEWID:申请一个新的游戏ID的请求与回复
NEWGAME:申请开始游戏的请求与回复
NEXTSTEP:更新游戏对局数据的请求与回复
GETNEXSTEP:获取游戏对局数据的请求与回复
GAMEEND:终止或结束游戏的请求
关于游戏请求与游戏对局时的通信,因为采用的是请求加回复的方式,服务器不能主动通知客户端有新的游戏开始或是对手已经喜下了下一步棋,因此需要客户端主动向服务器获取相应的信息。于是这部分被设计为客户端定时向服务器发送更新数据的请求,服务器一旦接收到请求,就把通过该请求的TCP连接发回去。这样虽然增加了网络的流量,但为了数据的稳定性必须做出牺牲。好的是该协议报文很小,而且因为是对局游戏,就算有几万人同时在玩,实际单位时间的数据量也不会太多,最重要的是在处理并发数据的情况。
5、服务器实现:
这是最重要最核心的部分。一个高效、稳定的游戏服务器程序直接决定了游戏的体验。在实际的游戏服务器开发中,游戏逻辑与网络通信逻辑可能分工由不同的人员开发。因此,游戏逻辑与网络通信逻辑应在保证效率的情况下尽可能地实现低耦合。我这里虽然是独立开发的,是因为游戏的逻辑很简单,但如果比如去开发一个像GTAOL这样的游戏服务器,本来做网络通信的人想要做出GTA的游戏逻辑那就相当地困难,需要写处理世界、物体、角色,还要和游戏端的逻辑一致,累成狗狗。
所以说游戏的逻辑与网络的通信需要尽可能地独立,就这个五子棋服务器而言,网络通信端使用PPC、select、epoll都和游戏逻辑无关,只要能接收分类并交给游戏逻辑处理,并将游戏逻辑处理好的数据发出即可。该服务器选用的epoll实现的,因篇幅原因,网络通信部分已经在这篇文章中说明清楚:epoll模型的理解封装与应用。
关于服务器的游戏逻辑,首先看看我们的服务器要做哪些事情:
1、用户游戏ID的申请与管理
2、对局数据的处理与管理
大致就以上这两种事情。但是因为游戏的客户端数量很多,不同的客户端之间进行对局,必须要清晰地处理与管理这些数据。我这里建立了一个idpool,用于id的储存于申请,以防发生错误给用户分配无效或是重复的id。
对局数据的处理与管理:
在两个用户都有id的情况下,双方都能申请进行游戏。这是服务端要做的就是匹配好这些用户并通知这些用户开始游戏。为方便说明,我先把代码粘上来:
#ifndef _GAME_H_ #define _GAME_H_ #include<iostream> #include<cstdio> #include<cstring> #include<string> #include<stdlib.h> #include<list> #include "ssock.h" #include "gameprotocol.h" using namespace std; #define idpoollength 1000 #define datapoollength 50 //链式IDpool class idpool { list<unsigned int> ids; public: idpool() { for(int i=1;i<idpoollength;i++) { ids.push_back(i); } } unsigned getid() { if(ids.empty()) { return 0; } unsigned re=ids.front(); ids.pop_front(); return re; } void freeid(unsigned int x) { ids.push_front(x); } }; //对局匹配类 class p2p { unsigned int with[idpoollength]; unsigned int info[idpoollength]; public: p2p() { for(int i=0;i<idpoollength;i++) { with[i]=i; } } bool ispair(unsigned int x1) { return with[x1]!=x1&&with[x1]!=0; } //设置为该id等待匹配 void setwait(unsigned int x1) { with[x1]=0; } //自动匹配函数 bool makepair(unsigned int x1) { for(int i=1;i<idpoollength;i++) { if(with[i]==0&&x1!=i) { setp2p(x1,i); return true; } } return false; } //设置两id匹配 void setp2p(unsigned int x1,unsigned x2) { with[x1]=x2; with[x2]=x1; info[x1]=1; info[x2]=2; } //释放匹配(单方向) void freep2p(unsigned int x1) { //with[with[x1]]=with[x1]; with[x1]=x1; } unsigned int getotherid(unsigned int x1) { return with[x1]; } unsigned int getp2pinfo(unsigned int x1) { return info[x1]; } }; struct step { unsigned short x; unsigned short y; short stepno; }; //对于下棋状态类 class stepstatus { step idstep[idpoollength]; public: stepstatus() { for(int i=0;i<idpoollength;i++) { idstep[i].stepno=-1; } } bool setstep(unsigned int i,unsigned short xx,unsigned short yy,short sn) { idstep[i].x=xx; idstep[i].y=yy; idstep[i].stepno=sn; return true; } step *getstep(unsigned int i) { return idstep+i; } }; //服务器游戏主逻辑类 class gamemain:public idpool,public p2p,public stepstatus { public: //报文缓冲数据池,用于自动分配可用的mdata用以存储待发送的数据 mdata datapool[datapoollength]; gamemain(); mdata *getdatainpool(); //api函数,释放用过的mdata到pool中 void freedatainpool(mdata *data); //数据处理api函数,用于处理网络通信部分传入的数据,这个函数是线程安全的 mdata *dealdata(mdata *data); //以下为游戏数据分类处理的函数 mdata *newid(mdata *data); mdata *newgame(mdata *data); bool checkmac(nextstephead *nsh); mdata *nextstep(mdata *data); mdata *getnextstep(mdata *data); mdata *gameend(mdata *data); }; #endif //_GAME_H_
p2p类:它的作用是用来匹配玩家的。当有客户端申请进行游戏时,服务器会先调用makepair函数来寻找可以进行匹配的另一个玩家,如果找到了合适的玩家,接下来就会调用setp2p简历这两个玩家有对局关系。如果没有匹配到,则会调用setwait等待其他的用户进行匹配。该类使用的数据结构为简单的hash映射。
setpstatus类:用于存放对局数据的类,使用的pool方式,客户端下棋的信息将会储存在这里,用以客户端获取对方下棋的信息。p2p类的info会直接映射到pool的对应下标。不同id的客户端查找数据会相当地迅速。
gamemain类:游戏的主类。给出api函数dealdata用以接收客户端的数据并将处理后的数据返回。
#include "game.h" gamemain::gamemain() { //:idpool(),p2p(),stepstatus() { for(int i=0;i<datapoollength;i++) { datapool[i].len=1; } } } mdata *gamemain::getdatainpool() { for(int i=0;i<datapoollength;i++) { if(datapool[i].len==1) { return datapool+i; } } return NULL; } void gamemain::freedatainpool(mdata *data) { data->len=1; } mdata *gamemain::dealdata(mdata *data) { gamequest *gqh=(gamequest *)data->buf; printf("this data:type:%d,id:%d\n",gqh->type,gqh->id); if(gqh->type==NEWID) { return newid(data); } else if(gqh->type==NEWGAME) { return newgame(data); } else if(gqh->type==NEXTSTEP) { return nextstep(data); } else if(gqh->type==GETNEXTSTEP) { return getnextstep(data); } else if(gqh->type==GAMEEND) { return gameend(data); } } mdata *gamemain::newid(mdata *data) { mdata *newdata=getdatainpool(); gamequest *rgqh=(gamequest *)newdata->buf; newdata->len=sizeof(gamequest); rgqh->type=NEWID; rgqh->id=0; rgqh->data=getid(); printf("a new id:%u send,len:%u\n",rgqh->data,newdata->len); return newdata; } mdata *gamemain::newgame(mdata *data) { gamequest *gqh=(gamequest *)data->buf; mdata *newdata=getdatainpool(); gamequest *rgqh=(gamequest *)newdata->buf; newdata->len=sizeof(gamequest); rgqh->type=NEWGAME; if(ispair(gqh->id)||makepair(gqh->id)) { rgqh->id=getotherid(gqh->id); rgqh->data=getp2pinfo(gqh->id); printf("a new game start:%d and %d\n",gqh->id,rgqh->id); return newdata; } setwait(gqh->id); rgqh->data=NEWGAME_FAIL; return newdata; } bool gamemain::checkmac(nextstephead *nsh) { return nsh->mac==(nsh->type^nsh->x^nsh->y^nsh->stepno); } mdata *gamemain::nextstep(mdata *data) { nextstephead *nsh=(nextstephead *)data->buf; mdata *newdata=getdatainpool(); newdata->len=0; printf("nextstep: %d %d %d %d\n",nsh->id,nsh->x,nsh->y,nsh->stepno); if(checkmac(nsh)) { if(setstep(nsh->id,nsh->x,nsh->y,nsh->stepno)) { gamequest *rgqh=(gamequest *)newdata->buf; newdata->len=sizeof(gamequest); rgqh->type=NEXTSTEP; rgqh->data=NEXTSTEP_SEC; return newdata; } } return newdata; } mdata *gamemain::getnextstep(mdata *data) { gamequest *gqh=(gamequest *)data->buf; step *sh=getstep(getotherid(gqh->id)); mdata *newdata=getdatainpool(); if(sh->stepno!=-1) { nextstephead *rnsh=(nextstephead *)newdata->buf; newdata->len=sizeof(nextstephead); rnsh->type=GETNEXTSTEP; rnsh->id=getotherid(gqh->id); rnsh->x=sh->x; rnsh->y=sh->y; rnsh->stepno=sh->stepno; rnsh->mac=rnsh->type^rnsh->x^rnsh->y^rnsh->stepno; printf("gnextstep: %d %d %d %d\n",rnsh->id,rnsh->x,rnsh->y,rnsh->stepno); sh->stepno=-1; return newdata; } newdata->len=0; return newdata; } mdata *gamemain::gameend(mdata *data) { gamequest *gqh=(gamequest *)data->buf; mdata *newdata=getdatainpool(); freep2p(gqh->id); newdata->len=0; return newdata; }
这里的dealdata是线程安全的,方便网络通信部分用的各种方式调用。因为这该五子棋服务器的游戏逻辑的主要功能就是数据的存储转发,没有什么需要在后台一直运行的要求。因此该程序耦合很低,使用很简答,只需要创建、调用处理函数、获取处理结果即可。
6、网络游戏功能实现
现在回到游戏客户端,前面已经实现的单机游戏的功能。现在要做的就是加入网络功能,其实就是把单机的ai部分接到服务器上。
首先是游戏id的获取。通过向服务器发送NEWID请求。会受到服务器分配的id。将这个id作为自己的游戏id,在告知服务器退出游戏或是服务器在长时间未受到该id的情况下自动释放前都有效。
当客户端分配到id后,就可以向服务器发起游戏匹配请求NEWGAME。为了防止匹配不到玩家,设置发送匹配请求最多只维持一分钟,在一分钟结束后,客户端向服务器发出停止匹配的请求。
当有两个客户端在这交叉的时段进行进行匹配,便可能匹配在一起开始游戏。
游戏匹配成功后,客户端将收到服务器发过来的对局基础信息,包括了对手id、先手还是后手。当游戏开始后,先手的下棋然后将数据提交到服务器,又后手的更新数据,然后照这样依次循环下去直到游戏结束。
id2与id3匹配到了一起。
在游戏结束时,赢的一方会显示胜利,输的显示失败,双方都不再更新数据。退出对局后便能开始下继续匹配游戏。
游戏客户端需要注意的是对局数据的校验还有sock链接的问题。当在糟糕的网络环境下,客户端不应定能获取到正确的数据,因此要根据数据包总的mac进行校验。而tcp链接再侧重状态下将时断时续。因此要注意当连接中断后及时与服务器进行重连。
还有关于跨平台的问题。我将socket封装成类,不管是win还是linux都是同样的调用方式。在sock类中用ifdef区分开两个系统的不同api调用。
以下是客户端跨平台sock的封装:
#ifndef _MSOCK_H_ #define _MSOCK_H_ #include<iostream> #include<cstdio> #include<cstring> #include<string> #ifdef WIN32 #include<winsock2.h> #else #include<fcntl.h> #include<sys/ioctl.h> #include<sys/socket.h> #include<unistd.h> #include<netdb.h> #include<arpa/inet.h> #include<netinet/in.h> #include<sys/types.h> #define SOCKET int #define SOCKET_ERROR -1 #define INVALID_SOCKET -1 #endif using namespace std; static int networkinit() { #ifdef WIN32 WSADATA wsadata={0}; return WSAStartup(MAKEWORD(1,0),&wsadata); #else return 0; #endif } static int networkclose() { #ifdef WIN32 return WSACleanup(); #endif return 0; } class msock_tcp { public: SOCKET sock; int info; sockaddr_in addr; msock_tcp() { newsocket(); addr.sin_family=AF_INET; } void newsocket() { sock=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP); if(sock==INVALID_SOCKET) { puts("socket build error"); exit(-1); } } void setioctl(bool x) { #ifdef WIN32 if(!x) { return; } unsigned long ul = 1; ioctlsocket(sock, FIONBIO, (unsigned long*)&ul); #else fcntl(sock, F_SETFL, O_NONBLOCK); #endif } bool setip(string ip) { //解析域名IP hostent *hname=gethostbyname(ip.c_str()); if(!hname) { puts("can‘t find address"); return false; }//puts(inet_ntoa(addr.sin_addr)); #ifdef WIN32 addr.sin_addr.S_un.S_addr=*(u_long *)hname->h_addr_list[0]; #else addr.sin_addr.s_addr=*(u_long *)hname->h_addr_list[0]; #endif return true; } void setport(int port) { addr.sin_port=htons(port); } int mconnect() { return connect(sock,(sockaddr *)&addr,sizeof(addr)); } int msend(const char *data,const int len) { info=send(sock,data,len,0); if(info==SOCKET_ERROR) { mclose(); newsocket(); mconnect(); info=send(sock,data,len,0); } return info; } int msend(const string data) { return msend(data.c_str(),data.length()); } int mrecv(char *data,int len) { return recv(sock,data,len,0); } int mrecv(char *data) { return recv(sock,data,2047,0); } int mclose() { #ifdef WIN32 return closesocket(sock); #else return close(sock); #endif } }; #endif
网络匹配类:
#ifndef _NETWORKSCENE_H_ #define _NETWORKSCENE_H_ #include "cocos2d.h" #include "NetGameMain.h" USING_NS_CC; class NETWorkScene:public Layer { public: msock_tcp *sock; char rdata[2048]; int rlen; unsigned int gameid; unsigned int gameid2; CCLabelTTF* gameinfo; virtual bool init(); //从服务器中获取id bool getidonserver(); void showgameid(); //发起匹配游戏请求 bool findplayer(); void findbutton(Ref* pSender); //开始新游戏,进入对局场景 bool newgamestart(bool ismyround); NETGameMain *gamemain; //数据以及ui更新 updatequest upq; void update_quest(); void update(float delta); CREATE_FUNC(NETWorkScene); }; #endif // _NETWORKSCENE_H_
#include "NetWorkScene.h" bool NETWorkScene::init() { if(networkinit()) { CCLOG("network init fail"); return false; } sock=new msock_tcp; sock->setioctl(true); //我用于测试的centos服务器 sock->setip("wchrter.oicp.net");//127.0.0.1 sock->setport(5940); //sock->setip("127.0.0.1"); //sock->setport(5000); if(sock->mconnect()>=0) { CCLOG("sock connect error"); //this->removeFromParentAndCleanup(true); } else { CCLOG("sock connect secc"); } gameid=0; auto fdItem = MenuItemImage::create( "net_find1.png", "net_find2.png", CC_CALLBACK_1(NETWorkScene::findbutton, this)); fdItem->setScale(2.0); // create menu, it‘s an autorelease object auto menu = Menu::create(fdItem, NULL); winsize=Director::sharedDirector()->getWinSize(); menu->setPosition(ccp(winsize.x/2,winsize.y/2)); this->addChild(menu, 1); gameinfo = CCLabelTTF::create("", "Arial", 30); gameinfo->setPosition(ccp(winsize.x/4, winsize.y/2)); this->addChild(gameinfo); scheduleUpdate(); return true; } bool NETWorkScene::getidonserver() { gamequest quest; quest.id=0; quest.type=NEWID; if(SOCKET_ERROR==sock->msend((char *)&quest,sizeof(quest))) { CCLOG("getidonserver error"); return false; } return true; } void NETWorkScene::showgameid() { gameinfo->setString("your\ngame id:\n"+inttostring(gameid)); } bool NETWorkScene::findplayer() { if(gameid==0) { if(!getidonserver()) { return false; } return false; } gamequest quest; quest.id=gameid; quest.type=NEWGAME; upq.set(quest,30); return true; } void NETWorkScene::findbutton(Ref* pSender) { findplayer(); } bool NETWorkScene::newgamestart(bool ismyround) { upq.settle(0); NETGameMain *newgame=NETGameMain::create(); newgame->setgameid(gameid,gameid2); newgame->setsock(sock); newgame->setismyround(ismyround); Point winsize=Director::sharedDirector()->getWinSize(); newgame->setScale(winsize.y/defaultwinsize); auto director = Director::getInstance(); auto scene = Scene::create(); scene->addChild(newgame); director->pushScene(scene); return true; } void NETWorkScene::update_quest() { if(upq.end()) { return ; } if(!upq.push()) { return; } if(SOCKET_ERROR==sock->msend((char *)&upq.quest,sizeof(upq.quest))) { CCLOG("socket error"); } return; } void NETWorkScene::update(float delta) { //CCLOG("JB"); update_quest(); rlen=sock->mrecv(rdata); if(rlen>0) { gamequest *gqh=(gamequest *)rdata; CCLOG("%d: %d %02x %d\n",rlen,gqh->id,gqh->type,gqh->data); if(gqh->type==NEWID) { gameid=gqh->data; showgameid(); } else if(gqh->type==NEWGAME) { gameid2=gqh->id; if(gqh->data==NEWGAME_ISFIRST) { newgamestart(true); } else if(gqh->data==NEWGAME_ISSEC) { newgamestart(false); } else { CCLOG("findplayer fail"); } } } else { //CCLOG("no message"); } }
网络游戏对局类:
#ifndef _NETGAMEMAIN_H_ #define _NETGAMEMAIN_H_ #include "cocos2d.h" #include "ChessMain.h" #include "msock.h" #include "gameprotocol.h" USING_NS_CC; #define defaulttoolwidth 200.0 #define defaulttoolheight 100.0 #define updatetime 20 //更新类 class updatequest { int timecnt; int timelimit; public: gamequest quest; updatequest() { timecnt=0; timelimit=0; } void set(gamequest q,int tle=5) { quest=q; timelimit=tle*updatetime; timecnt=0; } void settle(int tle) { timelimit=tle; } bool end() { if(timelimit<0) { return false; } if(timecnt<timelimit) { return false; } return true; } bool push(int pt=1) { timecnt+=pt; if(timecnt%updatetime==0) { return true; } return false; } }; //游戏菜单类 class NETGameEndTool:public Layer { public: NETGameEndTool(int type); bool init(int type); void gameEnd(Ref* pSender); }; class NETGameMain:public ChessMain { public: virtual bool init(); virtual void onEnter(); msock_tcp *sock; char rdata[2048]; int rlen; //自己id与对局者id unsigned int gameid; unsigned int gameid2; CCLabelTTF* idinfo; CCLabelTTF* roundinfo; void setgameid(unsigned int x,unsigned int y); void setsock(msock_tcp *s); void setismyround(bool x); //当前是否为自己回合 bool ismyround; virtual bool onTouchBegan(Touch *touch, Event *unused_event); bool isnetsetp; void nextnetstep(int x,int y); //胜利检测 void checkwin(); //数据与ui更新 updatequest upq; void update_quest(); void update(float delta); CREATE_FUNC(NETGameMain); }; string inttostring(int num); #endif //_AIGAMEMAIN_H_
实现代码:
#include "NetWorkScene.h" bool NETWorkScene::init() { if(networkinit()) { CCLOG("network init fail"); return false; } sock=new msock_tcp; sock->setioctl(true); //我用于测试的centos服务器 sock->setip("wchrter.oicp.net");//127.0.0.1 sock->setport(5940); //sock->setip("127.0.0.1"); //sock->setport(5000); if(sock->mconnect()>=0) { CCLOG("sock connect error"); //this->removeFromParentAndCleanup(true); } else { CCLOG("sock connect secc"); } gameid=0; auto fdItem = MenuItemImage::create( "net_find1.png", "net_find2.png", CC_CALLBACK_1(NETWorkScene::findbutton, this)); fdItem->setScale(2.0); // create menu, it‘s an autorelease object auto menu = Menu::create(fdItem, NULL); winsize=Director::sharedDirector()->getWinSize(); menu->setPosition(ccp(winsize.x/2,winsize.y/2)); this->addChild(menu, 1); gameinfo = CCLabelTTF::create("", "Arial", 30); gameinfo->setPosition(ccp(winsize.x/4, winsize.y/2)); this->addChild(gameinfo); scheduleUpdate(); return true; } bool NETWorkScene::getidonserver() { gamequest quest; quest.id=0; quest.type=NEWID; if(SOCKET_ERROR==sock->msend((char *)&quest,sizeof(quest))) { CCLOG("getidonserver error"); return false; } return true; } void NETWorkScene::showgameid() { gameinfo->setString("your\ngame id:\n"+inttostring(gameid)); } bool NETWorkScene::findplayer() { if(gameid==0) { if(!getidonserver()) { return false; } return false; } gamequest quest; quest.id=gameid; quest.type=NEWGAME; upq.set(quest,30); return true; } void NETWorkScene::findbutton(Ref* pSender) { findplayer(); } bool NETWorkScene::newgamestart(bool ismyround) { upq.settle(0); NETGameMain *newgame=NETGameMain::create(); newgame->setgameid(gameid,gameid2); newgame->setsock(sock); newgame->setismyround(ismyround); Point winsize=Director::sharedDirector()->getWinSize(); newgame->setScale(winsize.y/defaultwinsize); auto director = Director::getInstance(); auto scene = Scene::create(); scene->addChild(newgame); director->pushScene(scene); return true; } void NETWorkScene::update_quest() { if(upq.end()) { return ; } if(!upq.push()) { return; } if(SOCKET_ERROR==sock->msend((char *)&upq.quest,sizeof(upq.quest))) { CCLOG("socket error"); } return; } void NETWorkScene::update(float delta) { //CCLOG("JB"); update_quest(); rlen=sock->mrecv(rdata); if(rlen>0) { gamequest *gqh=(gamequest *)rdata; CCLOG("%d: %d %02x %d\n",rlen,gqh->id,gqh->type,gqh->data); if(gqh->type==NEWID) { gameid=gqh->data; showgameid(); } else if(gqh->type==NEWGAME) { gameid2=gqh->id; if(gqh->data==NEWGAME_ISFIRST) { newgamestart(true); } else if(gqh->data==NEWGAME_ISSEC) { newgamestart(false); } else { CCLOG("findplayer fail"); } } } else { //CCLOG("no message"); } }
游戏客户端就ok了。
7、平台移植:
整个项目搞定了就是爽哈,平台移植便是非常轻松的事情,只要自己写的代码没作死,用特定系统或编译器的api或是语法与库,平台移植就相当得快速。尤其是cocos2dx引擎,早已把移植的工作全都准备好了,只需要自己调调错即可(回想起了以前自己一个人把c++往android上交叉编译,叫那个苦啊)。
控制台傻瓜编译:
编译成功。
用手机打开游戏客户端,获取到的id为5。(联想P780,你值得信赖的充电宝手机)
手机与客户端实现网络游戏对局。
哈哈,手机也能和电脑一起联网玩游戏了。
这次做的这套五子棋网络游戏还有很多欠缺的东西,客户端还缺乏一定的容错能力,用户体验也不够人性化。在网络方面,通信的方式并不适合时效性要求较高的游戏,像一些及时对战游戏,请求/回复的方式需要很频繁的请求才能保证时效。这样也没错,糟糕的网络环境也不能用来玩这些游戏。自己对自己的美工挺满意的,嘿(哪里有美工啊?这个图片都算不上好不好)。
总的来说,这是一次很棒的开发经历,希望毕业以后也能有这样的闲功夫,去做自己真正想做的。