《滑稽大作战》是我在大二开发的一款Websocket双人实时游戏。
如果服务器内存还足够,还没宕机,那么这个地址应该能够访问。如果不能访问也没关系,我的Github上有我所有独立项目的代码,包括它。同时这个Github仓库也是我一直更新的仓库,觉得有趣或者期待下个项目的同学可以在Github上关注该项目,欢迎start,欢迎fork。
这款游戏源于大二一期企业老师布置的课外作业。算法不太出色的我最后没能很好的完成,但我总是喜欢把事情变得更加有趣或者有意义,加上对自己服务端能力的自信,我打算把它做成在线积分制游戏。我约了同学,他帮我设计了界面草图、奖杯图片和碰撞的核心代码,我则包揽了后端与Ajax,以及部分前端。就这样边写游戏边应付其他课内作业,前前后后大概忙了一个多月后终于上线。当时的游戏界面用DOM实现,作为一名物理未曾挂科的理科生,即使我把JS定时器设置为16毫秒遵守60HZ定律也无法解决偶尔卡顿的情况。
这是第一版的样子:
这学期的课程比上学期少许多,自己也变得不爱上课,泡图书馆的时间就多了起来,自打重构demo0后开始学习Websocket,本想做聊天室但前期的UI设计十分繁重,索性开始重构demo2,利用Websocket加上好玩的双人对战。
这次的技术栈是Spring Boot全家桶、Websocket、Canvas、新浪OAuth2以及前端的Vue2。
- Websocket:前辈们有珠玉在前,晚辈就不献丑。
- Vue:在前辈们晒Vue作品时总会让初学者以为它是个动画库,起初我也是这么认为。顺着官方文档写了几个Hello Word后才大致明白它的作用,传统的前端常常需要做很多DOM数据与事件的绑定操作,document.querySelecotr()在vue之前是我们离不开的代码。所以它并不是什么动画库,它能让你少些非常多数据与事件绑定的代码,把多出来的时间能花在设计与动画上,这也就是Vue作品一般都比较好看的原因。这次重构我用Vue来做DOM数据与事件的绑定,减少模板样式的JS代码,熟悉Spring的人写Vue时应该能感觉出有注解式编程的味道。
- OAuth2:第三方登录是我一直想尝试的东西。
- Canvas:动画或游戏的标配。浏览器为你提供的动画API,浏览器会根据硬件的负载程度使用自有的逻辑去调整动画频率,以达到性能与流畅的完美平衡,比起自己写定时器要有效与可靠得多。
初学Vue的Javaer应该使用webpack吗?如果已经掌握webpack自然不用说,对于像我没用过webpack的Backend开发者来说,其实不是必须的,我也不信webpack能够实现传统HTML+CSS+JS实现不了的东西,一味追求webpack显得有点舍本逐末。
几乎所有的后端语言都能实现Websocket,所以这篇小结不会计较某种语言的具体实现。
Websocket如今也不是什么新奇东西,浏览器在很多年前已经支持Websocket,其应用也非常广泛,除了老生常谈的股票报价与Web聊天以外,还有在线编辑文档,比如石墨文档,甚至是网页用户行为分析(作为典型的非社交型程序员,我对不用沟通的即可了解用户的行为与爱好非常感兴趣,更重要的是用户的数据比用户的嘴巴要更诚些)。Websocket游戏的发展则慢一些,一方面是Websocket游戏大量的逻辑运算需要消耗CPU,其搭档Canvas则需要GPU渲染,另一方面是Websocket游戏的一致性与同步性比起端游更难实现。Websocket开始进入公众视野应该是从15年中旬上线的细胞吞噬游戏(Agar)开始的。
接下来进入正题。
我是如何开发出滑稽大作战的?
我觉得可以分成三个部分:数据传输,一致性与性能优化。
数据传输:
首先需要解决两个页面之间数据的传输。
每个与服务器建立了Websocket长连接的浏览器(或页面)称为客户端。一个更加简单的场景是服务端向所有客户端推送消息,像股票报价一样,其实现原理是服务端用一个数组保存所有与之建立长连接的客户端,当服务器需要推送消息时,只要遍历数组下发消息即可,当用户关闭页面时服务器响应并从数组上删除节点即可。但用户A要向用户B发送数据,就像游戏里玩家A要向玩家B发送数据一样,而实际上玩家A与玩家B不能建立连接,只能向服务器发送消息,利用服务器再向玩家B间接发送数据,此时服务器仅充当消息中转站的角色。到这里我跨出了非常成功的第一步。
另一个问题是消息的定向推送。
在滑稽大作战中与之对应的场景是确保两名搭档玩家只能向对方发送自己的游戏数据,同时防止其他玩家有意或无意干扰游戏,设想一下当两名正在游戏的搭档玩家中的一方突然收到其玩家故意发送的游戏数据时渲染错位的尴尬场景。滑稽大作战中前端Websocket的建立条件是LocalStorage存有相关数据,该数据是在用户同意新浪授权后服务器发放的。数据里有一份Token数据,该Token是用户的唯一凭证和其他HTTP请求的权限凭证。
// JavaScript构造URL并建立长连接:
var socket = new WebSocket("ws://demo.leeys.top/demo2/socket?token=" + token);
服务器在Websocket握手时会判断Token的有效性, 校验成功后服务器能确认这条长连接属于数据库某个用户,此时存储Websocket连接的数据结构要改成Map,把用户的UUID当做key。当两名搭档玩家正在游戏时,JS只是负责把数据上传给服务器,并不需要在上传的数据中指定发送给谁,只有服务器知道应该把数据发给谁,通过Map结构非常容易实现。这点与细胞吞噬不同,后者更类似广播机制,任何由玩家上传到服务器的游戏数据都会被广播给其他玩家。
紧接着制定消息收发的数据格式。
双人对战时双方需要交换数据,但仅有一份JS文件,这需要你在一份JS文件中先想象自己是消息发送方编写上传数据的代码,然后又意淫自己忽然变成消息接收方编写接收数据的代码,这个过程十分有趣。但总之收发消息之间需要固定的消息格式以及相同的逻辑。
性能优化:
按照开发的顺序,这次我先完成了性能优化部分,这个过程很有趣,也是我个人比较比较感兴趣的部分。
消息格式:众所周知,网络消息长度越短越好。消息越短意味着更小的数据包更低的延迟更节省带宽,在相同带宽下能支持更高并发量。在制定消息对象时,只发送必要的数据,数据的键值一两个简写字母即可。
消息传送:也许你会监听浏览器keydown事件,打算在事件触发时发送坐标给另一玩家,你在监听事件里加上了打印滑稽勇士X坐标的代码,你打开控制台验证你的想法,发现长按方向键后会源源不断触发事件,X坐标也不停变化,看起来还不错。很可惜,方向是对的,但方式却错了。网络传输需要消耗时间,最后你能从两个浏览器里观察到出现瞬移的现象,更糟糕的是在改变方向时卡顿了将近一秒,后者怪不不得你,是浏览器略微怪异按键事件。总之结果非常糟糕,不说体验,这种上传频率,即使是百兆带宽的服务器也经不起这么扛。小结一下,这种方式的原理是利用按键事件的触发来发送滑稽勇士X坐标,对方根据接收的X轴坐标进行Canvas渲染,缺陷很致命,一是卡顿,二是暴殄带宽。前者的更本原因是渲染X坐标次数不足,以至于1/24秒的人类视觉停留现象也填补不了两次位移的巨大空缺。
网络通信时间就像物理定律不可违背,但程序员发明了魔法。
最终的目的是要使用户「觉得」流畅,魔法就是「欺骗」,欺骗用户使用户觉得很流畅,在业界有个术语叫「插值」,原理很简单,就是一方玩家虚拟渲染另一方玩家两次坐标的差值,当两方都进行虚拟渲染时就会有一种彼此都很流畅的极乐幻觉。我的做法是发送方发送数据时带上运动的方向,接收方接收到数据时假定对方接下来都往这个方向运动,同时根据数据包数据更新X坐标,防止出现过大偏差。如此一来整个运动过程非常顺畅平滑。但这样还是不能避免键盘长按触发的大量事件导致大量数据包发送,设想当其中一玩家按D往右运动时,对方接受到第一个数据 包后已经假设你接下来是往右运动,所以接下来的按键数据完全没有必要发送给对方,只需要你抬起按键时再告知对方——我已经停下来,不用继续假装了。优化过后游戏过程的数据包发送次数已经减少非常多,正常情况下一秒最多只有几个数据包,但体验非常流畅平滑,我想不出更完美的方案了。
缺陷还是有的,如果我们利用触发的按键事件来同步球的位置,那么当玩家都不动或者按键频率低时,数据交换频率非常低,小球会因为碰撞或其他原因导致位置的大幅度不一致,我采用了一个折中的办法——创建一个一致性定时器(consistencyTimer)每隔150毫秒向对方发送数据包来校正球和砖块的数据。
一致性:
最后是游戏一致性的问题。
什么是一致性?网络传输消耗的时间无法避免,那么游戏双方在同一时刻必定存在位置的不一致性,射击类的高敏性游戏更加明显,也许在你这边显示击中对方,在另一方则显示成功躲避,但这种情况应该如何评判? 处理的方式是两端允许出现结果的不一致性,比如你看到对方出血画面,但对方则看到成功躲避了,这样两方都玩得很开心,但最后的结果还得由服务器做最终评判。在滑稽大作战中对应的场景是,玩家A与玩家B互为搭档,玩家A看到玩家B接不到球,玩家B则看到球已经被自己接住了,发生这种情况的原因是球与玩家位置在双方出现了不一致情况,这是任何人都避免不了的,所以此时球应该是落地还是反弹?我的处理方式就略显粗糙,考虑到做服务器评判会把开发难度带上平方级别,我干脆把游戏的控制权完全转移给主邀方(先邀请的一方),主邀方控制球的启动、球的XY坐标与运动方向、球的落地与碰撞检测,几乎所有的代码都跑在了主邀方,所以受邀方的作用是?受邀方的唯一作用就是一个陪衬!在代码上受邀方的工作是上发自己的位置与运动方向,还有渲染主邀方下发的数据。在不做服务器评判下,取消其中一方的控制权是唯一的办法。
以上就是这篇小结的全部内容。
最后聊点轻松的话题,其中包括我对游戏的一点理解。
在重构期间没找到搭档,我一个人能想到的有趣的东西十分有限,如果删去滑稽元素我觉得整个游戏就什么亮点了。使用滑稽元素并不意味我喜欢贴吧,实际上我有很多年没上过贴吧。至于意图,大概是迎合目前互联网上自嘲、表情包的文化习俗,不过能有很多人玩我也很开心,至少不必写测试就能知道服务器的耐受量。
从小到大我对游戏毫无天赋,也跟人民币玩家不沾边,一直是游戏里那种备受煎熬每天活在水深火热中的玩家,小时候沉迷过几次游戏,多数在寒暑假,但一到开学我就得以一种壮士断腕的气概卸载整个游戏重新投入到伟大的社会主义学习道路中去。因为家里网速很慢一个游戏要下十几个小时,加上小时候我性子很急容不得等待,游戏卸载起初的几个星期里,每当夜深人静无比孤独的时候心魔总会来袭,但每次我都很清醒是否值得为一次手痒而付出饱受摧残十几小时的代价,所以卸载游戏的办法对我来说十分有效。高中起我就很少玩游戏,除了乌拉圭铁皮公司(IRONHIDE)开发的《Kingdom Rush》之外,我没再玩过其他的游戏,只不过铁皮公司几年才会出一款游戏,那时候我会找个段空闲时间痛快玩上一个星期然后投入全身心到社会主义学习道路中去。相比高中那些中午泡网吧打英雄联盟的同学来说,这可能是我比较怪异的地方。
虽然我没有游戏天赋,也不怎么爱玩游戏,但我一直认为游戏是人类文明的至高产物。从春秋的围棋再到象棋和后来的蹴鞠,不得不说每一种流传至今的游戏的诞生都是人类智慧火山的一次喷发。当电子游戏时代的到来,游戏的创造变得更加多元化,所有程序员都能参与其中。这会使游戏的创造变得十分廉价吗?一点都不会,你所看到的每个精致小巧的游戏里其实都隐藏着无比复杂的逻辑。要我说,Game才是Code Word里最难开发的产品,Game Developer永远代表着Geek。正因如此,哪怕铁皮公司的开发周期从两年变三年甚至到五年,我都会等下去。
7月11号就快到了,借此致敬世界上最伟大的Game Developer —— 岩田聪。