前言php
游戏的第一个版本开发于14年,浏览器端使用html+css+js,服务端使用asp+php,通信采用ajax,数据存储使用access+mySql。不过因为一些问题(当时还不会用node,用asp写复杂的逻辑真的会写吐;当时对canvas写的也少,dom渲染很容易达到性能瓶颈),已经废弃。后来用canvas重制了一版。本文写于18年。css
资料汇总html
项目地址及Demo请关注:github.com/chenzhuo199…vue
项目使用数据驱动的轻量canvas渲染库Easycanvas:github.com/chenzhuo199…node
Easycanvas的Chrome调试插件:github.com/HuJiaoHJ/ec…git
热血传奇客户端文件解析:github.com/jootnet/mir…)github
本文在掘金持续更新,转载请注明:juejin.im/post/5a8d85…ajax
1.js实现PC端网游是可行的。随着PC、手机硬件配置的升级和浏览器的更新换代,以及H5各类库的发展,js实现一款网游的难度愈来愈低。这里的难度主要是两方面:浏览器的性能;js代码是否足够易于扩展,以知足于一款逻辑极其复杂的游戏的迭代。算法
2.现阶段的js游戏里,不多有规模较大的可供参考。涉及到多人联机、服务端数据存储、复杂交互的游戏,大多数(几乎所有)都是用flash开发的。可是flash毕竟在衰落,而js发展迅速,而且只要有浏览器就能够运行。canvas
第一个缘由是对老游戏的情怀; 固然更重要的另外一个缘由是,别的游戏要么我不会玩、要么我会玩但没有素材(图片、音效等)。花很大精力去收集一个游戏的地图、人物怪物模型、物品和装备图,而后去处理、解析一遍再用于js开发,我以为是浪费时间。
因为我之前搜集了一些传奇游戏的素材,而且幸运地找到了提取热血传奇客户端资源文件的方法,因此能够直接开始写码,省去了一些准备时间。
1.浏览器的运行性能:这个应该是最困难的一点。假如游戏要保持40帧,那么每帧只有25ms的时间留给js计算。而且因为渲染一般比计算耗性能,实际上留给js的时间只有10毫秒左右。
2.防做弊:如何避免用户直接调用接口或者篡改网络请求数据?因为目标是用js实现比较复杂的游戏,而且任何网络游戏都须要考虑这一点,必定会有相对成熟的方案。此处不是本文重点。
画面渲染使用canvas。
相比dom(div)+css,canvas能够处理比较复杂的场景渲染和事件管理,例以下面这个场景,涉及了四张图片:玩家、动物、地上的物品、最下层的地图图片。(实际还有地上的影子,鼠标指向人物、动物、物品时出现的相应名称,以及地面上的阴影。为了方便读懂,先不考虑这么多内容。)
这时,若是但愿实现“点击动物、攻击动物;点击物品、捡起物品”的效果,那么须要对动物和物品进行事件监听。若是采用dom的方式,那么会出现几点难于处理的问题:
渲染的顺序和事件处理的顺序不一样(有时候z-index小的须要先处理事件),须要额外处理。例如这个上面的例子里:点击怪物、点击物品的时候也容易点到人物,那么须要给人物作“点击事件穿透”的处理。并且事件处理的顺序不固定:假如我有一个技能(例如游戏里的治疗)须要点人物才能够释放,那么这时人物又须要有事件监听。因此一个元素是否须要处理事件、处理事件的前后,是随着游戏状态的不一样而变化的,而dom的事件绑定已经不能知足须要。
有关联的元素难以放在同一个dom节点中:例如玩家的模型、玩家的名字和玩家身上的技能画效,理想状况下是放在一个<div>
或者<section>
容器里,便于管理(这样几个元素的定位就能够继承父元素,不用分别处理位置了)。可是这样,z-index会很难处理。例如玩家A在玩家B的上面,那么A会被B遮挡,所以须要A的z-index小一些,可是又须要让玩家A的名字不会被B的名字或者影子遮挡,就没法实现。简单点说,dom结构的可维护性会牺牲画面展现的效果,反之亦然。
性能问题。即便牺牲了效果,用dom渲染,势必出现不少嵌套关系,全部元素的style都在频繁变化,连续触发浏览器的repaint甚至reflow。
canvas渲染逻辑与项目逻辑分离
若是将canvas的各类渲染操做(如drawImage
、fillText
等)与项目代码放在一块儿,那么势必致使项目后期没法维护。翻了一下几款现有的canvas库,结合vue的数据绑定+调试工具的方式,搞了一个全新的canvas库Easycanvas(github地址),而且像vue同样支持经过一个插件来调试canvas中的元素。
这样,整个游戏的渲染部分就容易不少,只须要管理游戏当前的状态、而且根据服务端从socket传回来的数据去更新数据就能够。“数据的变化引发视图的变化”这个环节由Easycanvas负责。例以下图的玩家包裹物品的实现,咱们只须要给出包裹容器的位置、背包里每一个元素的排布规则,而后将每一个包裹的物品绑定到一个array上,而后去管理这个array便可(数据映射到画面的过程由Easycanvas负责)。
例如,5行8列共计40个物品的样式能够经过以下的形式传递给Easycanvas(index为物品索引,物品x方向间距36,y方向间距32)。而这个逻辑是一成不变的,不管物品的数组怎样变化、包裹被拖拽到什么位置,每一个物品的相对位置都是固定的。至于canvas上的渲染则彻底不须要项目自己来考虑,因此可维护性较好。
style: {
tw: 30, th: 30,
tx: function () {
return 40 + index % 8 * 36;
},
ty: function () {
return 31 + Math.floor(index / 8) * 32;
}
}
复制代码
canvas分层渲染
假设:游戏须要保持40帧,浏览器宽800高600,面积48万(后面称48万为1个屏幕面积)。
若是用同一个canvas来呈现,那么这个canvas的帧数40,每秒至少须要绘制40个屏幕面积。可是同一个坐标点极可能出现多个元素重叠的状况,例如底部的UI、血条、按钮就是重叠放置,他们又共同遮挡了场景地图。因此这些加在一块儿,每秒浏览器的绘制量很容易达到100个屏幕面积以上。
这个绘制是很难优化的,由于整个canvas画布的任何一处都在进行视图的更新:多是玩家和动物的移动、多是按钮的特效、多是某个技能效果的变化。这样的话,即便玩家不动,因为衣服“随风飘飘”的效果(实际上是精灵动画播放到下一张图),或者是地面上出现了一瓶药水,都要引发整个canvas的重绘。由于游戏中几乎不可能出现某一帧的画面与上一帧毫无区别的状况,即便是游戏画面的一个局部,也很难保持不变。整个游戏的画面永远在更新。
由于游戏中几乎不可能出现某一帧的画面与上一帧毫无区别的状况,画面永远在更新。
所以,此次我采用了3个canvas重叠排布的方式。因为Easycanvas的事件处理支持传递,所以即便点到了最上面的canvas,若是没有任何元素结束了某一次点击,后面的canvas也能够接到此次事件。3个canvas分别负责UI、地面(地图)、精灵(人物、动物、技能特效等):
这样分层的好处是,每层最大帧数能够根据须要来调整:
例如UI层,由于不少UI平时是不动的,即便动也不会须要太精密的绘制,因此能够适当下降帧数,例如下降到20。这样假如玩家的体力从100下降到20,那么能够在50ms内更新视图,而50ms的切换是玩家感受不出来的。由于像体力这种UI层数据的变化很难在很短的时间内连续变化屡次,而50ms的延迟是人很难感知的,因此不须要频繁的绘制。假如咱们每秒节约了20帧,那么极可能能够节约10个屏幕面积的绘制。
再如地面,只有玩家移动的时候,地图才会变化。这样,若是玩家不动,那么每帧能够省去1个屏幕面积。因为须要保证玩家移动时的流畅感,地面的最大帧数不宜过低。假如地面为30帧,那么玩家不动时,每秒就能够节约30个屏幕面积的绘制(这个项目中,地图是几乎绘满屏幕的)。并且其它玩家、动物的移动不会改变地面,也不须要重绘地面这一层。
精灵层最大帧数不能下降,这层会展现游戏的人物动做等核心部分,因此最大帧数设置为40.
这样,每秒绘制的面积,玩家移动时多是80~100个屏幕面积,而玩家不移动时可能只有50个屏幕面积。游戏中,玩家停下来打怪、打字、整理物品、释放技能都是站立不动的,所以大量的时间里都不会触发地面的绘制,对性能的节约很大。
因为目标是js实现一款多人网游,因此服务端使用Node,使用socket与浏览器通信。这样作还有一个好处,就是一些公用的逻辑能够在两端复用,例如判断地图上某个坐标点是否存在障碍物。
Node端的玩家、场景等游戏相关数据所有存储与内存中,按期同步至文件。每次Node服务启动时,将数据从文件读取至内存。这样能够玩家较多时,文件读写的频率成指数级上升,从而引起的性能问题。(后来为了提升稳定,为文件读写增长了一个缓冲,“内存-文件-备份”的方式,以避免读写过程当中服务器重启致使的文件损坏)。
Node端分接口、数据、实例等多层。“接口”负责和浏览器端交互。“数据”是一些静态数据,例如某个药品的名称和效果、某个怪物的速度和体力,是游戏规则的一部分。“实例”是游戏中的当前状态,例如某个玩家身上的一个药品,就是“药品数据”的一个实例。再举个例子,“鹿的实例”拥有“当前血量”这个属性,鹿A多是10,鹿B多是14,而“鹿”自己只有“初始血量”。
下面开始介绍地图场景部分,仍然是依赖Easycanvas进行渲染。
因为玩家是始终固定在屏幕中心的,因此玩家的移动,其实是地图的移动。例如玩家像左跑,地图就向右平移便可。刚才已经提到,玩家处于3个canvas中的中间一层,而地图属于底层,所以玩家必定遮挡地图。
这样看起来是合理的,可是假如地图中有一棵树,那么“玩家的层次始终高于树”就不对了。这时,有2种大的解决方案:
地图分层,“地面”与“地上”拆开。将玩家处于两层之间,例以下图,左侧是地上、右侧是地面,而后重叠绘制,把人物夹在中间:
这样看似解决了问题,其实引入了2个新的问题:第一个是,玩家有时可能会被“地上”的东西遮挡(例如一棵树),有时又须要可以遮挡“地上”的东西(例如站在这棵树的下方,头部会遮挡住树)。另外一个问题是渲染的性能消耗会增长。因为玩家是时刻在变的,“地上”这一层须要频繁重绘。这样作也打破了最初的设计——尽可能节约地面大地图的渲染,从而致使canvas的分层更加复杂。
地图不分层,“地面”与“地上”在一块儿绘制。当玩家处于树后的时候,将玩家的透明度设置为0.5,例以下图:
这样作只有一个坏处:玩家的身体要么都不透明、要么都半透明(怪物在地图上行走也会有这个效果),不会彻底真实。由于理想的效果是存在玩家的身体被遮挡住一部分的场景的。可是这样作对性能友好,而且代码易于维护,目前我也采用了这个方案。
那么如何判断“地图”这张图片哪些地方是树呢?游戏一般会有一个大的地图描述文件(其实就是一个Array),经过0、一、2这样的数字来标识哪些地方能够经过、哪些地方存在障碍物、哪些地方是传送点等等。热血传奇中的这个“描述文件”就是48x32为最小单位进行描述的,因此玩家在传奇中的行动会有一种“棋盘”的感受。单位越小越流畅,可是占用的体积越大、生成这个描述的过程也就越耗时。
下面开始正题。
我找了一个朋友帮我导出热血传奇客户端中“比奇省”的地图,宽33600、高22400,是我电脑的几百倍大。为了不电脑爆炸,须要拆分红多块加载。因为传奇的最小单元是48x32,咱们以480x320将地图拆成了4900(70x70)个图片文件。
canvas的尺寸咱们设定为800x600,这样玩家只须要加载3x3共计9张图片就能够铺满整个画布。800/480=1.67,那么为何不是2x2?由于有可能玩家当前的位置正好致使有的图片只展现了一部分。我画了一张美轮美奂的示意图:
因此,至少须要3x3排列9张图片就能够“铺满”画布。可是这样作有一个隐患,那就是每一个480x320的地图碎片文件的体积至少要几十KB以上,若是须要的时候才拿来绘制,那么将致使人物跑动的时候能够看到区块是一个一个加载出来的,影响体验。因此我采用了4x4共计16个区块来填充画布。这样为地图平移的效果预留一些冗余的面积,将图片文件的加载时机提早,起到了预加载的效果。这里不须要考虑是否浪费了渲染的性能,由于canvas的大小是800x600,当咱们向外部(例如某个区块的横坐标为900~1380)绘制的时候,不会真的“绘制”,也就不会有性能浪费。(这里啰嗦一下,使用canvas原生的drawImage方法向canvas的外部绘制的时候,我测试的结果是耗费的性能极低。而我在Easycanvas库里封装了canvas的原生方法:当判断绘制区域部分超过canvas的时候,会对绘制进行裁剪;当绘制区域彻底超过canvas的时候,就再也不执行drawImage方法。)
咱们经过Easycanvas向画布添加一个地图容器(用来装载这16张区块)。容器的左上角顶点位于浏览器(0,0)点的左上方,以保证容器彻底覆盖画布。须要注意的一点是:地图容器只会在1个区块内小幅移动,横、纵向的最大移动距离为480和320。以水平方向为例,假如容器里第一行的4个区块分别为T1五、T1六、T1七、T18,那么玩家向右跑的时候,4个区块开始向左平移。当玩家跑够了480的距离(实际上是容器跑了480的距离),就能够当即将容器放回去(向回移动480,回到原点),而后4个区块变为T1六、T1七、T1八、T19.这样,容器的样式就是对480和320进行取余,而后再加上适当的修正:
var $bgBox = PaintBG.add({
name: 'backgroundBox',
style: {
tx: function () {
return - ((global.pX - 240) % 480) - 320; // 这里的算法不惟一,对480取余才是重点
},
ty: function () {
return - ((global.pY - 160) % 320) - 175;
},
tw: 480 * 4, // 做为容器,宽高能够省略不写,这里写出是便于理解
th: 320 * 4,
locate: 'lt', // tx、ty做为左上角顶点传给Easycanvas
}
});
复制代码
而后向容器增长16个区块便可,增长区块的代码比较简单,这里列出每一个区块的号码算法(假设每一个区块对应的图片的文件名为15x16.jpg这种格式):
content: {
img: function () {
var layer1 = Math.floor((global.pX - 240) / 480) + i - 1;
var layer2 = Math.floor((global.pY - 160) / 320) + j - 1;
var block = `${layer1}x${layer2}`;
return Easycanvas.imgLoader(`${block}.jpg`);
}
}
复制代码
其中,i和j表明区块的序号(0-4)。layer的计算方法也不是惟一的,根据容器的算法进行调整便可。
这样,当玩家的坐标pX和pY变化的时候,地图就会进行平移。玩家向右跑、地图向左平移(因此上面的tx须要加负号,这里的tx相似vue语法中的computed),地图容器的位置由玩家坐标决定,也只跟随玩家坐标的变化而重绘,不能由任何其它的数据来干预。这样,一方面数据和视图进行了绑定,另外一方面也保证了数据流是单向的,不会受到其它模块的干扰,也不须要其它模块来干扰。
接下来开始UI层(因为精灵层比较复杂,放到最后)。
热血传奇的底部UI是比较大的图片:
如下称这张图为“底UI”。底UI的尺寸是800x251,至关于半个游戏屏幕面积。因此一开始设计的时候提到,将UI独立出来放在单独的canvas,而后进行低频绘制。那么按钮、聊天框、血球要不要单独切出来呢?
好比右侧的4个蓝色小按钮,是否应该从底UI抽离出来,单独写渲染逻辑呢?
咱们判断一个局部是否须要从总体抽离出来的关键是,看它存不存在“总体和局部不一样时渲染”的状况。例如某一个时刻底UI存在,而按钮不见了,那么按钮必定须要切出来。也许会问:这个局部是须要变化的,例如鼠标按下按钮时,按钮发光,那么是否是应该切出来?答案是不该该。咱们彻底能够把一个“发光按钮”放在按钮所在的位置,而后让它的透明度为0,而且当鼠标按下时,透明度改成1:
UI.add({
name: 'buttomUI_button1',
content: {
img: './button1_hover.png'
},
style: {
opacity: 0 // 宽高、位置不是重点,此处省略
},
events: {
mousedown () {
this.style.opacity = 1;
},
mouseup () {
this.style.opacity = 0;
},
click () {
// ...
}
}
});
复制代码
并且,因为大部分状况下按钮是正常状态,因此这样作也是对性能最友好的方式。同时,这种设计也可让底UI只负责渲染,而底UI的一个个子元素去对应各自的点击事件,也便于代码的维护。
热血传奇中的球形血条看起来是个立体的东西,其实只是图片的切换。假设空状态的球对应的图片为empty.png、满状态对应full.png。
例如玩家拥有100的最大法力值,当前还剩30,那么能够理解为底部30%绘制full.png这张图片、而顶部70%绘制empty.png.不过,出于逻辑简化和性能的考虑,能够将empty.png放到底UI上(参考上一张底UI的图),而后根据当前血量去用full.png来盖在上面。这样至关于不存在“空状态”对应的图层,只是把它做为背景,在上面根据当前状态来覆盖各类长度的“满状态”图。
下图展现了是怎样经过将满状态的贴图覆盖上去,来实现“血条”的:
能够看到,若是血量是充满的,咱们能够将充满状态的图彻底覆盖上去;当血量不满时,咱们能够从满状态的图片中裁取一部分盖在空球上。咱们将他们的裁剪范围(Easycanvas里的sx、sy、sw、sh参数,其中s表明source,指源图片)与数据层绑定在一块儿,传递给Easycanvas(满状态的半球的尺寸为46x90)。涉及的变量计算较多,下面一一阐述。
var $redBall = UI.add({
content: {
img: 'full_red.png'
},
style: {
sx: 0,
sw: 46,
sy: function () {
return (1 - hpRatio) * 90;
},
sh: function () {
return hpRatio * 90;
},
tx: CONSTANTS.ballStartX,
ty: function () {
return CONSTANTS.ballStartY + (1 - hpRatio) * 90;
},
tw: 46,
th: function () {
return 90 * hpRatio;
},
opacity: Easycanvas.transition.pendulum(0.8, 1, 1000).loop(),
locate: 'lt',
},
});
复制代码
因为无论血量如何变化,球距离左侧的位置是固定的,因此tx、sx是定值。tx的值是根据底UI测量出来的常量,sx是0是为了从源图片的最左侧开始绘制。
咱们让当前血量与最大血量的比值为hpRatio,那么hpRatio为1的时候,血量充满。这时,不须要对源图片进行裁剪,咱们绘制完整高度的血球。所以绘制的高度与hpRatio成正比。
而血量少的时候,咱们应该从源图片的中间开始,将中部至底部的部分绘制上去。因此hpRatio越小,裁剪起点sy越大。而且y方向裁剪的起点sy与裁剪的高度sh存在关系:sy+sh=90。一样,hpRatio越小表明血量越少,这时绘制起点越向下。
至于opacity,咱们让他从0.8到1进行缓慢的循环好了。这样能够给玩家一种血球“流淌”的感受。(假如咱们有多张图片组成的动画,让他们轮播会更加逼真。)
至此,完成了球形血条的开发。视图彻底由数据驱动,每当血量更改时,咱们算出新的hpRatio,血球就会随之更新。仍然是从数据到视图的单向数据流,这样能够保证视图展现效果只由数值驱动,便于后续的扩展。例如“玩家喝药补充血量”就不须要关心这个球形血条应该如何变化,只须要和数据进行关联便可。
背包涉及了极其复杂的交互,主要的几点:
视图与物品Array的绑定。物品数据更新时,视图须要更新。这是最基础的功能。
每个物品有很是复杂的事件。双击物品可使用。单击物品后,物品跟随鼠标移动,此时:
若是点击地面,须要将物品丢弃到地上(实际上是向服务端发送丢弃物品请求);若是点击人物装备栏的一个槽,那么能够穿戴或者替换装备;若是点击的是仓库里的一个槽,事件又变成了存储物品;若是点击背包,那么多是放回物品,也多是交换两个物品的位置……还有不少不少状况。
背包是能够拖动到任何地方的、能够和其它相似背包同样的“对话框UI”共存的。那么势必出现多个相似背包这样的对话框之间的层级计算的关系。我把背包对话框拖拽到人物对话框上,那么背包的z-index大一些。若是这时点了一下人物对话框,那么确定人物对话框的z-index要更高一些。假如这时又弹出了一个NPC对话框呢?
在热血传奇游戏中,我把背包拖到任何地方,这时打开仓库,那么系统会自动进行排列:仓库在左出现,背包马上移动到右侧,方便玩家操做。涉及到一些算法,让玩家感到这些对话框是“智能”的。
Warning,前方高能预警。
玩家可能还会这么操做:
打开背包,而后左键点击地面,人物开始奔跑。玩家的鼠标动来动去,控制人物在地图上奔跑。而后鼠标就动到背包里了,停留在某一个物品上,这时抬起左键,(@*(#)¥……@(#@#!
假如数字1对应了一个技能,玩家拖拽背包的时候,忽然对着背包里的某瓶无辜的药水按了一下技能(就算玩家傻,至少要保证咱们的js不报错)。
某个几百字也没法描述清楚的case,此处省略。
开始写码。首先确定要有一个背包容器:
var $dialogPack = UI.add({
name: 'pack',
content: {
img: pack,
},
style: {
tw: pack.width, th: pack.height,
locate: 'lt',
zIndex: 1,
},
drag: {
dragable: true,
},
events: {
eIndex: function () {
return this.style.zIndex;
},
mousedown: function () {
$this.style.zIndex = ++dialogs.currentMaxZIndex;
return true;
},
}
});
复制代码
style没什么好多说的,zIndex咱们先随便写个1上去.后面的drag是Easycanvas提供的拖拽API,也没什么好多说的。事件的eIndex(Easycanvas用来管理事件触发顺序的索引,event-zIndex)须要和zIndex同步,毕竟玩家看到哪一个对话框在上面,哪一个对话框确定先捕获到事件。
可是,咱们须要给mousedown绑定一个事件:当玩家点击了这个对话框时,把它的zIndex提到当前全部对话框中的最高。咱们让全部对话框都从一个公共的dialogs模块里获取“当前最大zIndex”。每次设置以后,最大zIndex自增1,以供下一个对话框使用。
容器先这样,下面开始填充内容。咱们让背包的Array为global.pack,用一个for循环来为40个格子填充物品,索引为i:
$dialogPack.add({
name: 'pack_' + i,
content: {
img: function () {
if (!global.pack[i]) {
return; // 第i个格子没有物品,就不渲染
}
return Easycanvas.imgLoader(global.pack[i].image);
},
},
style: {
tw: 30, th: 30,
tx: function () {
return 40 + i % 8 * 36;
},
ty: function () {
return 31 + Math.floor(i / 8) * 32;
},
locate: 'center',
},
events: {
mousemove: function (e) {
if (global.pack[i]) {
// equipDetail模块负责展现鼠标指向物品的浮层
equipDetail.show(global.pack[i], e);
return !global.hanging.active;
}
return false;
},
mouseout: function () {
// 关闭浮层
equipDetail.hide();
return true;
},
click: function () {
// 把点了什么物品告诉hang模块
hang.start({
$sprite: this,
type: 'pack',
index: i,
});
return true;
},
dblclick: function (e) {
bottomHang.cancel();
equipDetail.hide();
useItem(i);
return true;
}
}
});
复制代码
因为每时每刻背包均可能发生变化,这里的img是一个function,动态return出结果。注:我写demo测试了一下,执行1
和(function () {return 1;})()
消耗性能的差别很小,能够忽略。
style里对40个物品进行8x5的排列,40、3一、32这些数字是从背包的素材图里量出来的。每一个格子的大小为30x30,热血传奇还有6个快捷物品栏(挂在底UI上),也用相似的方法添加,此处省略。可是须要注意:不能省去每一个格子的style里的宽高,由于当img为空时,也须要有一个对象存在面积,这样才能捕捉到事件。若是不写明宽高,那么点击没有物品的格子将不触发任何事件。咱们把一个物品放到空格子上,是须要这个空格子来捕获事件的。
对每一个格子,当鼠标移入的时候,若是这个格子存在物品,那么须要展现物品的信息浮层。若是点击了物品,须要让物品的图片跟随鼠标移动(玩家拿起了物品)。这两块逻辑比较复杂,咱们写单独的模块来负责。
双击一个格子,那么要作3件事:隐藏信息浮层、取消拿起物品、使用物品(发送请求给服务端)。在热血传奇游戏中,是容许玩家手里拿着物品A,而后双击物品B的(可是不能拿着A使用A,由于拿起A以后就点不到A了)。若是要作到彻底一致的话,能够去掉bottomHang.cancel
这一句,同时增长“点击格子时,若是格子里的物品已经拿在手上,那么没法使用这个物品”的逻辑。
这块没有太多的技术含量,只要模块抽离干净,就只剩下码代码写逻辑,再也不赘述。
接下来咱们开始hang模块,实现“玩家单击拿起背包里的物品A、单击另外一个物品B,交换两个物品的位置”。首先要明确一点,从代码的角度说,“把一个物品放到一个空格子”和“交换两个物品的位置”没有任何区别,由于前者能够当作物品和空格子的交换。咱们只须要把两个物品格子的索引i和j传递给服务端就好。
大概的逻辑以下:
// hang.js
const hang = {};
hang.isHanging = false;
hang.index = -1;
hang.lastType = '';
hang.$view = UI.add({
name: 'hangView',
style: {},
zIndex: Number.MAX_SAFE_INTEGER // 多写几个9也行
});
hang.start = function ({$sprite, type, index}) {
if (!this.isHanging) {
this.isHanging = true;
this.index = index;
this.lastType = type;
this.$view.content.img = $sprite.content.img;
this.$view.style = {
tx: () => global.mouse.x, // 把鼠标坐标记录到这里的逻辑不赘述
ty: () => global.mouse.y,
};
} else {
// 这里只列出上一次点击和本次点击都来自背包的场景
if (type === 'pack' && this.lastType === 'pack') {
this.isHanging = false;
// 假设toServer是发送socket消息给服务端的一个方法
toServer('PACK_CHANGE', hang.index, index);
}
}
};
hang.cancel = function () {
this.isHanging = false;
delete this.$view.content.img;
};
export default hang;
复制代码
首先,hang模块拥有一个挂在UI层的对象$view。当点击了背包中的一个物品时,把这个物品的img传递过来展现,同时让这个$view跟随鼠标指针。(固然,这时还须要隐藏背包中的那个物品,此处不赘述。)
当调用了cancel后,干掉这个$view里面的img便可(同时也干掉刚才说的“隐藏背包中的那个物品”的没有赘述的逻辑)。这样就实现了点击左键,“拾起物品”的功能。若是已经拾起了一个物品,就会调用toServer方法,向服务端发送2个物品的索引。
而服务端要作的是,校验玩家登陆态,而后对背包的array作一下array[i]=[array[j], array[j]=array[i]][0]
(其实就是第i和第j的元素交换,以前看到别人的写法比较巧妙,拿来用了)。(固然,若是是对快捷栏进行操做,还要判断一下物品类型,由于只有药品和卷轴能够放到这几个位置。此处再也不赘述。)
最后,服务端将新的array推送给客户端,客户端更新一下便可。看起来大功告成了?
并无!若是存在网络延迟,那么极可能出现这样的状况:玩家想要交换物品A和B的位置,而后丢弃物品B。可是因为网络问题,交换还没完成,丢弃指令已经发出了。因而玩家把物品A扔了出去。也许物品A是一个价值连城的宝物。
如何避免这样的case呢?首先,玩家要丢什么东西,是根据“背包中物品的图片”来进行识别的。玩家必定不能接受的是,选择一个物品B,丢出去以后,就变成物品A了。哪怕丢弃失败,从新丢一次,也比错误的执行要好。
因此,咱们须要经过物品的ID来解决这个问题。玩家丢弃物品的时候,咱们记录下“跟随鼠标运动的那个物品的ID”并发给服务端,这样才能够确保即便客户端渲染物品列表的时候,即便因为延迟致使了索引顺序错误,玩家也不会误操做到另外一个物品。固然,更保险的作法是带着索引和物品ID,服务端再作一次校验。
这样,咱们能够在玩家操做了以后,马上更新客户端的array,当服务端响应成功以后,再返回新的array给客户端(固然也能够只返回变化的部分或者操做的结果,来节约传输数据的大小)。固然理想状况下这2个array就是相同的,若是不一样的话,咱们用服务端的array去替换客户端的array。一些游戏中因为网络较差,致使用户的行为被撤销,也是一样的缘由。
这样,hang模块就实现了背包中2个物品的交换。至于背包和其它对话框的联动,例如把背包中的图频放到人物的装备槽,能够经过对hang进行逻辑的补充实现。
至于展现物品信息的那个浮层,逻辑和上面相似,此处也再也不赘述。而刚才提到的一些问题,例如对着背包放技能,将在后续专门的部分介绍。
弄懂了背包以后,人物的实现就比较简单。
人物UI的左侧有上下两个箭头,能够切换展现装备、状态、技能等。咱们要作的就是,把UI的轮廓图切出来,而后再把每一个面板也切出来,进行拼接组合。以下:
而后用Easycanvas库来add一个父元素做为框架,再向父元素填充几个children就能够了。咱们经过一个变量来控制当前展现到了第几个面板:
var $role = UI.add({
name: 'role', // role是角色的意思
content: {
img: 'role.png'
},
// 事件后面再提
});
$role.add({
name: 'role-equip', // 第一页是人物装备
content: {
img: 'roleEquip.jpg'
},
style: {
// 箭头函数看不习惯的话,也能够写function,当role.index为0是可见
visible: () => role.index === 0
}
});
$role.add({
name: 'role-state', // 第二页是人物状态
……
复制代码
而后,相似咱们向背包中增长格子的方式那样,把人物装备的几个格子绑定到一个array或者object类型的数据上就能够了。第二页的属性能够采用在图片上写字符串的形式。干货很少,此处也再也不赘述了。
那么,如何监听“玩家把背包UI中的一个装备,拿到人物UI的装备槽”呢?
在游戏的第一个版本,我只给背包物品绑定了“双击时,发送使用物品的请求到服务端”的事件,而玩家佩戴装备也使用双击背包中装备的方式来进行(是的,官方也能够这样作)。我原本打算偷个懒,不作两个UI对话框的联动逻辑,可是后来发现这个躲不开,由于后面还会有仓库UI,玩家确定会手动来移动物品的。若是让玩家双击物品来进行存取操做,我想确定会被扣上“反人类”的帽子。
因此,我给人物装备的每个格子也绑定了一个点击事件。还记得背包UI中的hang模块吗?点击人物装备的格子,一样调用hang模块。当咱们发现hang模块中有一个来自于背包的物品了,那么点击人物装备就直接调用“使用装备”指令。
So,人物装备里每个格子须要绑定的单击事件的处理逻辑就是:
若是此时hang模块已经有一个活跃的“来自背包UI的物品”,尝试佩戴此物品。(服务端发现这个位置已经有一个装备了,那么会先执行“卸下装备”。)
若是此时hang模块是闲置的,而这个格子已经穿戴了装备,那么把它丢进hang(用户拿起了身上穿着的装备)。而且,为点击背包格子补充一个事件:若是发现hang里有一个来自于人物UI的物品,那么执行“卸下装备”。
若是此时hang模块已经有一个活跃的“来自人物UI物品”,那么告诉服务端,我要交换2个身上的装备(例如左、右两个手套)。固然服务端会check一下是否能够交换,好比不能把鞋子套在头上。
一样,每次服务端处理完毕后,将角色UI用到的数据以及背包UI里更新的数据推到客户端浏览器,进行更新。固然,人物UI的装备格子也须要绑定鼠标的移入,唤起浮层,展现装备信息。整我的物UI的代码量较大,可是都是逻辑代码,没什么亮点,本文省略。只要作好模块的封装,将通用逻辑写到公用模块便可。
精灵层包括人物、动物(NPC、怪物、场景装饰)、技能等核心要素。开篇提到,这层的FPS至少须要40.下面开始逐一介绍:
首先,人物的跑动会和地面联动。人物跑动修改global数据中的x和y坐标,触发地面的平移效果。这里涉及到如下两个点:
玩家操做人物移动时,是正常通行仍是被障碍物挡住,这个判断要在客户端作。若是在服务端作,那么每跑一步就要发送请求给服务端,而后服务端返回是否成功,先不说网络延迟会不会致使用户感受操做不流畅,单单是这个触发的频率就足以挤爆服务器。客户端游戏一般的作法是,将地图中哪些地方能够通行储存在文件中,玩家安装游戏时下载到本地解析。而网页游戏的话,用户每次进入一个地图或者区块,服务端发送当前地图或者区块的数据(大数组)。固然,这个数据最好作一下浏览器缓存(localStorage),毕竟一个游戏不可能常常改地图。
客户端连续上报坐标给服务端,服务端进行处理,再连续分发给其它玩家。这个上报的时间间隔不宜太长。假如1秒上报一次,那么玩家A看到的玩家B,将永远是1秒钟以前的玩家B。通常来讲,间隔0.5秒已经不太能被接受了。我十几年前和朋友去网吧联机玩,我俩一块儿跑步,在他的屏幕中他跑在我前面一点,在个人屏幕中我跑在前面一点,就是客户端上报间隔和服务器下发间隔一块儿形成的。固然,只要差的很少,就不会有问题。(多少能够称为“很少”呢?这个取决于这段距离的偏差,是否影响了释放技能的结果断定。后面会提到。)
那么如何防止玩家篡改数据,从而实现“水中漂”的做弊手法呢?好比(200, 300)是一个水池,谁也跑不到这里。可是我在网络请求中告诉服务端:“我如今就位于(200, 300),你来咬我啊~”。
比较简单的作法是,咱们在服务端判断一下这个坐标点是否能够抵达,若是不是,推一个消息给客户端,让客户端刷新一下位置(玩家会感到卡了一下而后人物弹了回去)。同时,咱们不把这个无效的数据存下来,其它用户也就不会看到(其它玩家不必看到我跑到水池中,而后再弹回去的过程)。服务端要作的就是记录事实、陈述事实,而不是接受玩家上报的全部信息。假设有人对岸边的我发起攻击,那么在服务端的眼中,攻击有效。至于做弊的人看到“本身在水池里,竟然还能被砍到”,无、所、谓!没有必要为一个做弊的用户写太多兼容逻辑,由于不须要为这样的用户提供良好的游戏体验。
更高级一点的作法是,咱们在服务端先判断这个点是否能够经过,而后判断玩家在时间内是否有可能到达这个点。好比有人上一秒上报本身在(100,100),下一秒上报本身在(900,900),那么必定是有问题的。咱们用距离除以上报时间间隔,和玩家的速度比对一下便可。固然,要留有必定的冗余,由于玩家可能网络不稳定,上报的频率有些抖动,这样计算下来个别时间段的速度偏快一些,是正常的。由此,咱们也知道了,在某款网络游戏的外挂中,为何开1.1倍速通常没问题,开1.5倍速就会频繁掉线。由于服务端设置了10%的冗余。固然,能够经过判断连续N秒内玩家一共走的距离,来识别这些“每秒钟都悄悄多走了一小段距离”的玩家。
或者,咱们能够把上报的坐标加密,或者上报时额外上报用户的鼠标移动轨迹等信息,来识别操做是否合法。不过这样作只是提升了做弊的门槛,没法防住全部状况,即便咱们动态地生成密钥。毕竟不少网络游戏都有自动跑步的挂,只要不损害其余玩家的利益就好。
(因为内容过长,其它内容暂时放在github的wiki中)