用python实现一个回合制游戏后端
最近忙着校招,虽然复习了很多遍,但心里还是不够踏实,不敢新开一些“大项目”,恰好看到了GAD上有个征文活动,我就却之不恭了。
一.项目介绍及相关背景
用专业的术语说,这游戏是多人在线即时策略对战网游,然而这样描述是不可能有人知道我这游戏到底是什么,那就换句话,这游戏是瘟疫公司简化简化再简化的网络对战版,简单明了!
这个项目是我在今年暑假抽了一个月时间和另外两人一起进行的(一策划,一程序,没有美术!!!),本想参加下半年的游戏创新大赛,做出demo后,却发现并不好玩,于是项目就不了了之了。但在此期间确实收获颇多,趁着有时间,将其记下,既是项目总结,也是技术分享。这不是一篇教程,没有太多就技术创新,我只是将以前看到的一些技术文章用我的代码写出来而已,当然中间会结合实际情况有所改动,有兴趣的朋友们可以接着往下看。
游戏前端用的unity,后端使用python,数据库使用轻量级的mysql,辅助工具是svn和TeamViewer。为了练手,所以没有使用别的插件,像并发处理,消息传递,错误记录什么的都是手动写的,这部分应该是整个项目最大的亮点了,当然,如果你会这方面的东西,大可不必继续往下看,因为真的真的真的没有太大创新,仅是落地实现而已。
二.项目准备
说到做项目,第一步就是找人,做demo人不需要太多,于是我拉了两基友上阵。其中一个想做策划,那就填表吧,另一个想在毕业前做点项目,于是被我安排开发数据库和写后端逻辑,人员问题勉强算是解决了。下一步就是详细的项目规划了,策划想做签到,想做商城,想做任务系统,想做教学关卡,想做。。。。。。md,我们只有2程序,一个暑假时间,所以这些都不做,我们只实现核心玩法。所以核心玩法是什么?只有一个——>释放技能。于是策划回去设计游戏规则,技能树,我和另一个程序开始设计数据库。然而还没设计完就已经迎来暑假,所以大部分开发都是我们通过TeamViewer和svn合作的。
先上两张图吧,不然估计没人看下去了,尴尬。
下图是策划写的部分游戏相关设计
然后是技能树界面
没错,这界面是我做的,囧
三.数据库设计
数据库设计是项目前期开发非常重要的部分,能使我们进一步细化整个项目内容,当然,如果设计出了问题,也将严重地拖累整个项目的进程推进。
整个数据库相对比较简单,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
enough?No!我们还需要对外提供一些函数方便复用这个链接(在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)
当服务器将数据处理完之后,需要将向客户端发送消息,当然,数据仍然是通过消息队列传递的。通过存储conn的dictionary获取对应连接对象,就可以很方便的传递数据了
代码如下
#向相应的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
正确的解决方法是在每次传递数据前先协商好数据的长度,直接生成对应长度的数组,当然你也可以通过正则表达式来对字符串进行裁剪,但这样效率就很低了,不是吗?