用python实现一个回合制游戏后端

发表于2017-09-21
评论3 1.05w浏览

最近忙着校招,虽然复习了很多遍,但心里还是不够踏实,不敢新开一些“大项目”,恰好看到了GAD上有个征文活动,我就却之不恭了。

 

一.项目介绍及相关背景

用专业的术语说,这游戏是多人在线即时策略对战网游,然而这样描述是不可能有人知道我这游戏到底是什么,那就换句话,这游戏是瘟疫公司简化简化再简化的网络对战版,简单明了!

这个项目是我在今年暑假抽了一个月时间和另外两人一起进行的(一策划,一程序,没有美术!!!),本想参加下半年的游戏创新大赛,做出demo后,却发现并不好玩,于是项目就不了了之了。但在此期间确实收获颇多,趁着有时间,将其记下,既是项目总结,也是技术分享。这不是一篇教程,没有太多就技术创新,我只是将以前看到的一些技术文章用我的代码写出来而已,当然中间会结合实际情况有所改动,有兴趣的朋友们可以接着往下看。

游戏前端用的unity,后端使用python,数据库使用轻量级的mysql,辅助工具是svnTeamViewer。为了练手,所以没有使用别的插件,像并发处理,消息传递,错误记录什么的都是手动写的,这部分应该是整个项目最大的亮点了,当然,如果你会这方面的东西,大可不必继续往下看,因为真的真的真的没有太大创新,仅是落地实现而已。

 

二.项目准备

说到做项目,第一步就是找人,做demo人不需要太多,于是我拉了两基友上阵。其中一个想做策划,那就填表吧,另一个想在毕业前做点项目,于是被我安排开发数据库和写后端逻辑,人员问题勉强算是解决了。下一步就是详细的项目规划了,策划想做签到,想做商城,想做任务系统,想做教学关卡,想做。。。。。。md,我们只有2程序,一个暑假时间,所以这些都不做,我们只实现核心玩法。所以核心玩法是什么?只有一个——>释放技能。于是策划回去设计游戏规则,技能树,我和另一个程序开始设计数据库。然而还没设计完就已经迎来暑假,所以大部分开发都是我们通过TeamViewersvn合作的。

先上两张图吧,不然估计没人看下去了,尴尬。

下图是策划写的部分游戏相关设计

然后是技能树界面

没错,这界面是我做的,囧


三.数据库设计

数据库设计是项目前期开发非常重要的部分,能使我们进一步细化整个项目内容,当然,如果设计出了问题,也将严重地拖累整个项目的进程推进。

整个数据库相对比较简单,user表负责存储用户信息;userMap表用来链接本次登录的随机码和user(使用随机码在一次登录中可唯一标识一个用户);hero表用来存储user拥有哪些hero,通过外键与user关联;heroConfig用来配置每个hero的技能信息及基本属性,顾名思义,他就是一张配置信息表;skillConfig表存储所有的技能信息配置,如需要的技能点数,技能id,技能描述等。

所以他们之间的关系大概是这样的

 

原谅我画了个简陋的思维导图,但毕竟你们不需要知道的那么详细,而且他比用例图好看多了,不是吗?

设计部分就不详细展开了,接下来说一说数据库连接的代码部分

为了满足更大的数据量访问要求,我们肯定不能有数据访问就建立连接,不然同时上万个用户,几百个土豆也扛不住啊。因为客户端并不会访问数据库,只有服务器会进行数据库的访问,所以我们只需通过服务器建立少量的连接,然后一直使用这个连接就可以了,在这种情况下单例模式就是最好的选择,但先别激动,现在进行的是服务器开发,普通的单例可不行,我们需要能在多进程下使用的单例

代码如下

mutex=multiprocessing.Lock()
dbaInstance=None
def __new__(cls):
    cls.mutex.acquire()
    if cls.dbaInstance is None:
        cls.dbaInstance = super(dba, cls).__new__(cls)
    cls.mutex.release()
    return cls.dbaInstance

enoughNo!我们还需要对外提供一些函数方便复用这个链接(在python中所有以‘_’开头的均为私有,对外我们只提供excute函数)

def _getConn(self):
    if self._connection=='':
            self._connection=pymysql.connect(host='127.0.0.1',
                                   user='user',
                                   password='password',
                                   db='virus',
                                   port=3306)
    return self._connection
def _getCursor(self):
    if self._cursor=='':
        self._cursor=self._getConn().cursor()
    return self._cursor
def excute(self,sql):
    self._getCursor().execute(sql)
    self._getConn().commit()
    return self._getCursor()

 

四.多进程与消息传递

服务器开发一定会用到多进程,这是毋庸置疑的,然而使用多进程会带来一些很严重的问题比如进程同步和消息传递。为了解决这些问题,我们尽量使代码松耦合,将不同的模块分开,并使用消息队列传递数据,整个过程像极了工厂里的流水线,分工明确且互不干扰。当然好处不止这一点,将代码拆散后我们可以更高效的复用对象,并极大的减少进程切换次数,如果将消息传递换成socket,我们甚至可以将这些代码放到不同的服务器上运行。

下面是部分技术细节

首先,我们需要一个消息接收进程,对于每一个socket连接,都将在该进程中开启一个新线程用来接收数据,并将conn对象保存在dictionary中,dictionary的key就是上文提到的随机码,通过这种方式,我们可以较好的防止外挂恶意伪造信息(然而外挂还可以截包啊)

下面是具体代码

#初始化服务器,开启socket,等待连接
def Reciver(self):
    self._soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        self._soc.bind((self._HOST, self._PORT))
        self._soc.listen(100)#最大连接数,由数据库的setting表控制
        print('socket开启成功!')
        #开启新线程
        tt=threading.Thread(target=self.ReturnMessage)
        tt.start()
        while 1:
            if(self._isConn==False):
                t=threading.Thread(target=self.GetMessage)
                t.start()
    except socket.error as e:
        print(e)
        VirusManager.VirusManager().addError(e)

处理好连接后,我们需要将消息保存并传递到能处理该消息的进程,这个时候我们就需要通过消息队列传递数据。代码仅供参考,具体怎么写请参照对应语言api

def GetMessage(self):
    self._isConn=True
    conn, addr = self._soc.accept()
    #生成随机码,用来标识用户,并将其返回至玩家
    randomID=''
    while(1):       randomID=VirusManager.VirusManager().GetRandomCode(16)
        if(randomID in self._connDict):
            pass
        else:
            break;
    self._connDict[randomID]=conn
    self.rr=randomID
    self._rmessage.put(randomID '@' randomID)
    while(1):
        data=conn.recv(1024).decode("utf-8")
        data=data.split('~')[0]
        if(len(data)<=0):#长度小于等于0表示远程连接断开
            conn.close()
            self._connDict.pop(randomID)
            break;
        self._message.put(data)

当服务器将数据处理完之后,需要将向客户端发送消息,当然,数据仍然是通过消息队列传递的。通过存储conndictionary获取对应连接对象,就可以很方便的传递数据了

代码如下

#向相应的socket对象发送消息
def SendMessage(self,rID,message):
    if(message and rID):
        message=message '~'
        conn=self._connDict.get(rID[0:16])#通过rID获取conn
        if(conn):
            conn.sendall(message.encode(encoding="utf-8"))
#将队列中的消息返回前端
def ReturnMessage(self):
    while(1):
        if(self._rmessage.empty()):
            time.sleep(1)
        else:
            mes=self._rmessage.get()
            mess=mes.split('@')
            if(len(mess)==2):
                self.SendMessage(mess[0],mess[1])

五.消息的处理

下图是前后端传递消息的具体格式

可以看到,每一个由客户端发来的消息,都对应着1~n个服务器可能发出的消息,所以对于每一类消息,我们都由一个单独的函数进行处理,不同的运行结果返回不同的消息(客户端同样如此)。为了使不同的消息对应不同的函数,我们仍然通过dictionary实现,用协议码取得对应的函数(C#中可用委托或switch)并执行。

下面是具体代码

def _deCode(self,message):#通过DeCode对code和方法进行映射调用不同方法
    if(message):
        mes=message.split('@')
        if(len(mes)==2):
            randomID=mes[0]
            mess=mes[1].split('|')
            if(len(mess)==2):
                func=DeCode.code.get(mess[0])
                rep=func(mes[0][0:16],mess[1],self._dbi)
                if(rep):
                    self.reply(randomID '@' rep)

这样就完美了吗?并不。

阅读整个协议表是很困难的,写起来也并不容易,而且他的可扩展性和鲁棒性并不算太高,很多情况他难以处理。

比如将多个消息合并

比如消息中少了一个参数

解决这些问题需对程序员提出了更高的要求,毕竟每一个函数都得进行完美的错误处理,鲁棒性真的很低。

所以有没有更好的解决办法?当然有啊,用json

然而对于服务器来说,json的效率相对较低,而且并不能完美解决以上问题,但仍不失为一种不错的选择。

更好的解决办法是使用第三方的编码插件,当然我这里为了练手就自己写了。。。。。。

 

六.错误信息处理

在服务器运行过程中,我们很可能需要记录一些信息,而这些信息是突发的,长度不定的,为了减少打开关闭文件的次数,我们仍使用一个单独的进程来记录所有需要保存的信息。当然,单例模式和消息队列同样是必不可少的。

代码如下

def __init__(self,file='error.txt'):
    try:
        self._file=file
        self._fileObject=open(self._file,'a')
        print('错误信息文件打开成功')
    except :
        VirusManager.VirusManager().addError('错误信息文件打开失败')
        print('错误信息文件打开失败')
def __del__(self):
    for err in self._errorDict.values():
        self._fileObject.writelines(str(err))
        self._fileObject.writelines('\n')
    self._fileObject.flush()
    self._fileObject.close()
    print('关闭错误信息文本')
def addError(self,error):
    self._errorDict[time.time]=error
    if(len(self._errorDict)>0):
        self._fileObject.writelines(str(time.time()))
        self._fileObject.writelines('\n')
        for err in self._errorDict.values():
            self._fileObject.writelines(str(err))
            self._fileObject.writelines('\n')
        self._fileObject.flush()
        self._errorDict.clear()

 

七.其他关键代码

随机码的生成函数

def GetRandomCode(self,length):
    #随机出数字的个数
    numOfNum = random.randint(1,length-1)
    numOfLetter = length - numOfNum
    #选中numOfNum个数字
    slcNum = [random.choice(string.digits) for i in range(numOfNum)]
    #选中numOfLetter个字母
    slcLetter = [random.choice(string.ascii_letters) for i in range(numOfLetter)]
    #打乱这个组合
    slcChar = slcNum   slcLetter
    random.shuffle(slcChar)
    #生成密码
    genPwd = ''.join([i for i in slcChar])
    return genPwd

主函数

    def main(self):
        message=multiprocessing.Queue()
        rmessage=multiprocessing.Queue()
        self._message=message
        self._rmessage=rmessage
        VirusManager()._conn=Connecter.Connecter('',8889,message,rmessage)
        com=Command.Command()
        p1=multiprocessing.Process(target=VirusManager()._conn.Reciver)
        p2=multiprocessing.Process(target=com.getInput)
        p3=multiprocessing.Process(target=VirusManager().AddBattle)
        p1.start()
        #p2.start()
        p3.start()
        i=1
        while(i<5):
            print('计算进程[' str(i) ']开启')
            i=i 1
            ca=Calculater.Calculater(message,rmessage)
            p=multiprocessing.Process(target=ca.calculate)
            p.start()
        DBI.DBI().usermap.deleteAllUsermap()
        DBI.DBI().combatmatch.deleteAllMatch()
        DBI.DBI().combatnews.deleteAllNews()

八.项目中遇到的坑及吐槽

Python多线程是假的,多线程是假的,是假的!

因为python有个东西叫全局线程锁,同一时间只有一个线程在运行,并不能实现线程及的并发

要小心socket接收数据的长度

很多人接收数据时都喜欢直接生成一个长度固定的char数组用来接收,然后不做处理就转成字符串,就像这样

data=conn.recv(1024).decode("utf-8")

然而这种方式会出现一个严重的问题,那就是你字符串长度和接收数据的长度无关,他的长度是数组的长度,噩梦就因此而来,你会发现这个字符串和正常字符串长得完全一样,甚至他都不占用多余空格

bug调的想哭.jpg

正确的解决方法是在每次传递数据前先协商好数据的长度,直接生成对应长度的数组,当然你也可以通过正则表达式来对字符串进行裁剪,但这样效率就很低了,不是吗?

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

标签: