阅读 10957

javascript高仿热血传奇游戏

前言

游戏的第一个版本开发于14年,浏览器端使用html+css+js,服务端使用asp+php,通讯采用ajax,数据存储使用access+mySql。不过由于一些问题(当时还不会用node,用asp写复杂的逻辑真的会写吐;当时对canvas写的也少,dom渲染很容易达到性能瓶颈),已经废弃。后来用canvas重制了一版。本文写于18年。

demo

资料汇总

1.开发前的准备

为什么要用Javascript来实现一款比较复杂的PC端游戏

1.js实现PC端网游是可行的。随着PC、手机硬件配置的升级和浏览器的更新换代,以及H5各种库的发展,js实现一款网游的难度越来越低。这里的难度主要是两方面:浏览器的性能;js代码是否足够易于扩展,以满足于一款逻辑极其复杂的游戏的迭代。

2.现阶段的js游戏里,很少有规模较大的可供参考。涉及到多人联机、服务端数据存储、复杂交互的游戏,大多数(几乎全部)都是用flash开发的。但是flash毕竟在衰落,而js发展迅速,并且只要有浏览器就可以运行。

为什么选择了一款2001年的热血传奇游戏

第一个原因是对老游戏的情怀; 当然更重要的另一个原因是,别的游戏要么我不会玩、要么我会玩但没有素材(图片、音效等)。花很大精力去收集一个游戏的地图、人物怪物模型、物品和装备图,然后去处理、解析一遍再用于js开发,我觉得是浪费时间。

由于我以前搜集了一些传奇游戏的素材,并且幸运地找到了提取热血传奇客户端资源文件的方法,所以可以直接开始写码,省去了一些准备时间。

可能的困难

1.浏览器的运行性能:这个应该是最困难的一点。假如游戏要保持40帧,那么每帧只有25ms的时间留给js计算。并且由于渲染通常比计算耗性能,实际上留给js的时间只有10毫秒左右。

2.防作弊:如何避免用户直接调用接口或者篡改网络请求数据?由于目标是用js实现比较复杂的游戏,并且任何网络游戏都需要考虑这一点,一定会有相对成熟的方案。此处不是本文重点。

2.整体设计

浏览器端

  1. 画面渲染使用canvas。

    相比dom(div)+css,canvas可以处理比较复杂的场景渲染和事件管理,例如下面这个场景,涉及了四张图片:玩家、动物、地上的物品、最下层的地图图片。(实际还有地上的影子,鼠标指向人物、动物、物品时出现的相应名称,以及地面上的阴影。为了方便读懂,先不考虑这么多内容。)

    复杂事件demo

    这时,如果希望实现“点击动物、攻击动物;点击物品、捡起物品”的效果,那么需要对动物和物品进行事件监听。如果采用dom的方式,那么会出现几点难于处理的问题:

    • 渲染的顺序和事件处理的顺序不同(有时候z-index小的需要先处理事件),需要额外处理。例如这个上面的例子里:点击怪物、点击物品的时候也容易点到人物,那么需要给人物做“点击事件穿透”的处理。而且事件处理的顺序不固定:假如我有一个技能(例如游戏里的治疗)需要点人物才可以释放,那么这时人物又需要有事件监听。所以一个元素是否需要处理事件、处理事件的先后,是随着游戏状态的不同而变化的,而dom的事件绑定已经不能满足需要

    • 有关联的元素难以放在同一个dom节点中:例如玩家的模型、玩家的名字和玩家身上的技能画效,理想情况下是放在一个<div>或者<section>容器里,便于管理(这样几个元素的定位就可以继承父元素,不用分别处理位置了)。但是这样,z-index会很难处理。例如玩家A在玩家B的上面,那么A会被B遮挡,因此需要A的z-index小一些,但是又需要让玩家A的名字不会被B的名字或者影子遮挡,就无法实现。简单点说,dom结构的可维护性会牺牲画面展示的效果,反之亦然

    • 性能问题。即使牺牲了效果,用dom渲染,势必出现很多嵌套关系,所有元素的style都在频繁变化,连续触发浏览器的repaint甚至reflow。

  2. canvas渲染逻辑与项目逻辑分离

    如果将canvas的各种渲染操作(如drawImagefillText等)与项目代码放在一起,那么势必导致项目后期无法维护。翻了一下几款现有的canvas库,结合vue的数据绑定+调试工具的方式,搞了一个全新的canvas库Easycanvas(github地址),并且像vue一样支持通过一个插件来调试canvas中的元素。

    这样,整个游戏的渲染部分就容易很多,只需要管理游戏当前的状态、并且根据服务端从socket传回来的数据去更新数据就可以。“数据的变化引起视图的变化”这个环节由Easycanvas负责。例如下图的玩家包裹物品的实现,我们只需要给出包裹容器的位置、背包里每个元素的排布规则,然后将每个包裹的物品绑定到一个array上,然后去管理这个array即可(数据映射到画面的过程由Easycanvas负责)。

    包裹demo

    例如,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;
        }
    }
    复制代码
  3. canvas分层渲染

    假设:游戏需要保持40帧,浏览器宽800高600,面积48万(后面称48万为1个屏幕面积)。

    如果用同一个canvas来呈现,那么这个canvas的帧数40,每秒至少需要绘制40个屏幕面积。但是同一个坐标点很可能出现多个元素重叠的情况,例如底部的UI、血条、按钮就是重叠放置,他们又共同遮挡了场景地图。所以这些加在一起,每秒浏览器的绘制量很容易达到100个屏幕面积以上。

    这个绘制是很难优化的,因为整个canvas画布的任何一处都在进行视图的更新:可能是玩家和动物的移动、可能是按钮的特效、可能是某个技能效果的变化。这样的话,即使玩家不动,由于衣服“随风飘飘”的效果(其实是精灵动画播放到下一张图),或者是地面上出现了一瓶药水,都要引起整个canvas的重绘。因为游戏中几乎不可能出现某一帧的画面与上一帧毫无区别的情况,即使是游戏画面的一个局部,也很难保持不变。整个游戏的画面永远在更新。

    因为游戏中几乎不可能出现某一帧的画面与上一帧毫无区别的情况,画面永远在更新。

    因此,这次我采用了3个canvas重叠排布的方式。由于Easycanvas的事件处理支持传递,因此即使点到了最上面的canvas,如果没有任何元素结束了某一次点击,后面的canvas也可以接到这次事件。3个canvas分别负责UI、地面(地图)、精灵(人物、动物、技能特效等):

    layers

    这样分层的好处是,每层最大帧数可以根据需要来调整:

    • 例如UI层,因为很多UI平时是不动的,即使动也不会需要太精密的绘制,所以可以适当降低帧数,例如降低到20。这样假如玩家的体力从100降低到20,那么可以在50ms内更新视图,而50ms的切换是玩家感觉不出来的。因为像体力这种UI层数据的变化很难在很短的时间内连续变化多次,而50ms的延迟是人很难感知的,所以不需要频繁的绘制。假如我们每秒节约了20帧,那么很可能可以节约10个屏幕面积的绘制。

    • 再如地面,只有玩家移动的时候,地图才会变化。这样,如果玩家不动,那么每帧可以省去1个屏幕面积。由于需要保证玩家移动时的流畅感,地面的最大帧数不宜太低。假如地面为30帧,那么玩家不动时,每秒就可以节约30个屏幕面积的绘制(这个项目中,地图是几乎绘满屏幕的)。而且其它玩家、动物的移动不会改变地面,也不需要重绘地面这一层。

    • 精灵层最大帧数不能降低,这层会展示游戏的人物动作等核心部分,所以最大帧数设置为40.

    这样,每秒绘制的面积,玩家移动时可能是80~100个屏幕面积,而玩家不移动时可能只有50个屏幕面积。游戏中,玩家停下来打怪、打字、整理物品、释放技能都是站立不动的,因此大量的时间里都不会触发地面的绘制,对性能的节约很大

服务器端

  1. 由于目标是js实现一款多人网游,所以服务端使用Node,使用socket与浏览器通讯。这样做还有一个好处,就是一些公用的逻辑可以在两端复用,例如判断地图上某个坐标点是否存在障碍物。

  2. Node端的玩家、场景等游戏相关数据全部存储与内存中,定期同步至文件。每次Node服务启动时,将数据从文件读取至内存。这样可以玩家较多时,文件读写的频率成指数级上升,从而引发的性能问题。(后来为了提高稳定,为文件读写增加了一个缓冲,“内存-文件-备份”的方式,以免读写过程中服务器重启导致的文件损坏)。

  3. Node端分接口、数据、实例等多层。“接口”负责和浏览器端交互。“数据”是一些静态数据,例如某个药品的名称和效果、某个怪物的速度和体力,是游戏规则的一部分。“实例”是游戏中的当前状态,例如某个玩家身上的一个药品,就是“药品数据”的一个实例。再举个例子,“鹿的实例”拥有“当前血量”这个属性,鹿A可能是10,鹿B可能是14,而“鹿”本身只有“初始血量”。

3.场景地图的实现

地图场景

下面开始介绍地图场景部分,仍然是依赖Easycanvas进行渲染。

思考

由于玩家是始终固定在屏幕中心的,所以玩家的移动,实际上是地图的移动。例如玩家像左跑,地图就向右平移即可。刚才已经提到,玩家处于3个canvas中的中间一层,而地图属于底层,因此玩家一定遮挡地图。

这样看起来是合理的,但是假如地图中有一棵树,那么“玩家的层次始终高于树”就不对了。这时,有2种大的解决方案:

  • 地图分层,“地面”与“地上”拆开。将玩家处于两层之间,例如下图,左侧是地上、右侧是地面,然后重叠绘制,把人物夹在中间:

    bround

    这样看似解决了问题,其实引入了2个新的问题:第一个是,玩家有时可能会被“地上”的东西遮挡(例如一棵树),有时又需要能够遮挡“地上”的东西(例如站在这棵树的下方,头部会遮挡住树)。另一个问题是渲染的性能消耗会增加。由于玩家是时刻在变的,“地上”这一层需要频繁重绘。这样做也打破了最初的设计——尽量节约地面大地图的渲染,从而导致canvas的分层更加复杂。

  • 地图不分层,“地面”与“地上”在一起绘制。当玩家处于树后的时候,将玩家的透明度设置为0.5,例如下图:

    opacity

    这样做只有一个坏处:玩家的身体要么都不透明、要么都半透明(怪物在地图上行走也会有这个效果),不会完全真实。因为理想的效果是存在玩家的身体被遮挡住一部分的场景的。但是这样做对性能友好,并且代码易于维护,目前我也采用了这个方案。

那么如何判断“地图”这张图片哪些地方是树呢?游戏通常会有一个大的地图描述文件(其实就是一个Array),通过0、1、2这样的数字来标识哪些地方可以通过、哪些地方存在障碍物、哪些地方是传送点等等。热血传奇中的这个“描述文件”就是48x32为最小单位进行描述的,所以玩家在传奇中的行动会有一种“棋盘”的感觉。单位越小越流畅,但是占用的体积越大、生成这个描述的过程也就越耗时。

下面开始正题。

实现

我找了一个朋友帮我导出热血传奇客户端中“比奇省”的地图,宽33600、高22400,是我电脑的几百倍大。为了避免电脑爆炸,需要拆分成多块加载。由于传奇的最小单元是48x32,我们以480x320将地图拆成了4900(70x70)个图片文件。

canvas的尺寸我们设定为800x600,这样玩家只需要加载3x3共计9张图片就可以铺满整个画布。800/480=1.67,那么为什么不是2x2?因为有可能玩家当前的位置正好导致有的图片只展示了一部分。我画了一张美轮美奂的示意图:

tile

所以,至少需要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个区块分别为T15、T16、T17、T18,那么玩家向右跑的时候,4个区块开始向左平移。当玩家跑够了480的距离(其实是容器跑了480的距离),就可以立即将容器放回去(向回移动480,回到原点),然后4个区块变为T16、T17、T18、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),地图容器的位置由玩家坐标决定,也只跟随玩家坐标的变化而重绘,不能由任何其它的数据来干预。这样,一方面数据和视图进行了绑定,另一方面也保证了数据流是单向的,不会受到其它模块的干扰,也不需要其它模块来干扰

4.UI层的实现

接下来开始UI层(由于精灵层比较复杂,放到最后)。

底部UI的实现

热血传奇的底部UI是比较大的图片:

bottom

以下称这张图为“底UI”。底UI的尺寸是800x251,相当于半个游戏屏幕面积。所以一开始设计的时候提到,将UI独立出来放在单独的canvas,然后进行低频绘制。那么按钮、聊天框、血球要不要单独切出来呢?

比如右侧的4个蓝色小按钮,是否应该从底UI抽离出来,单独写渲染逻辑呢?

底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来盖在上面。这样相当于不存在“空状态”对应的图层,只是把它作为背景,在上面根据当前状态来覆盖各种长度的“满状态”图。

下图展示了是怎样通过将满状态的贴图覆盖上去,来实现“血条”的:

ball

可以看到,如果血量是充满的,我们可以将充满状态的图完全覆盖上去;当血量不满时,我们可以从满状态的图片中裁取一部分盖在空球上。我们将他们的裁剪范围(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、31、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的左侧有上下两个箭头,可以切换展示装备、状态、技能等。我们要做的就是,把UI的轮廓图切出来,然后再把每个面板也切出来,进行拼接组合。如下:

role拆分

然后用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的代码量较大,但是都是逻辑代码,没什么亮点,本文省略。只要做好模块的封装,将通用逻辑写到公用模块即可。

5.精灵层的实现

精灵层包括人物、动物(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中)

关注下面的标签,发现更多相似文章
评论

加载更多