最新公告
  • 欢迎您光临网站无忧模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • React + Pixi + DragonBones 打造H5横屏互动游戏

    正文概述 掘金(Ka_Peter)   2021-03-24   2424

    前言

    近两年,网络平台掀起了一股互动游戏热潮。三大电商平台都不约而同地推出来一些互动小游戏(种树、养宠物、大富翁等),其目的在于通过趣味游戏提高APP日活,增强用户粘性,进而转化成订单。在这种趋势下,前端中的细分领域——WebGL成了新的技术热点。淘系技术部推出了互动引擎EVAJS,蚂蚁金服推出了Web 3D 引擎Oasis Engine,相信后续还会有其他的框架推出。为了不被时代淘汰,笔者也开始研究这部分知识。本文将以Pixi作为渲染引擎,使用DragonBones骨骼动画,打造一个简易的游戏Demo。

    React + Pixi + DragonBones 打造H5横屏互动游戏

    基本概念

    Pixi.js

    Pixi这个不用多做介绍,大名鼎鼎的HTML5 2D渲染引擎,完善的技术文档,丰富的API,众多的插件,适合WebGL初学者入门学习。

    DragonBones

    DragonBones是由白鹭时代(Egret)推出的2D 骨骼动画解决方案。相较于竞品Spine(EVAJS使用的动画方案),DragonBones最大的优势在于它是免费的,适合个人开发这学习使用。

    简单介绍一下基本概念,理解了这些概念,也就能理解DragonBones的数据结构。

    • 骨架(armature):骨架是骨骼的集合,骨架中至少包含一个骨骼。一个项目中可以包含多付骨架。
    • 骨骼(bone):骨骼是骨骼动画的基本组成部分。骨骼可以旋转,缩放,平移。
    • 插槽(slot):插槽是图片的容器,是骨骼和图片的桥梁。主场景中,图片的层次关系由插槽在层级面板的层次关系体现。
    • 图片(texture):图片是最基本的设计素材,图片需要以插槽为中介来和骨骼绑定,在webGL中也叫纹理。

    动画素材准备

    开发首先需要一些视觉素材,我们首先去官网下载并安装DragonBones的编辑器。

    安装好后,打开软件,在欢迎界面有一些学习资源供我们使用。我们随便选择一个素材打开,就进入了编辑界面。

    React + Pixi + DragonBones 打造H5横屏互动游戏

    可以看到,官方已经把我们做好了全部工作,我们不需要再对素材进行编辑,直接选择菜单栏中的“文件”->“导出”,会弹出一个导出弹框。数据类型选择JSON,勾选“打包zip”,点击“完成”,我们就能得到一个zip包。解压之后,里面有三个文件,一个png文件,两个JSON文件,这就是后续项目中需要用到的素材。

    React + Pixi + DragonBones 打造H5横屏互动游戏

    创建项目

    因为demo不需要引入业务组件,所以就用create-react-app快速创建一个项目。

    npx create-react-app dragonBones-demo
    

    在写代码之前,我们需要引入基本的运行库。通过研究DragonBones运行库代码,我遗憾地发现运行库并不支持NPM引入,需要通过CLI的方式生成对应版本的运行库。我使用Pixi,因此需要生成的是Pixi对应的运行库。

    根据官方的文档,我们全局安装dragonbones-runtime。然后执行dbr <engine-name>@<version>即可在执行命令的目录下的dragonbones-out目录下生成该引擎依赖的 运行库:

    npm install -g dragonbones-runtime
    dbr pixijs@4.6.2
    

    这里遇到一个问题,目前Pixi稳定版本是5.0,我们希望dragonbones运行库也支持5.0,但dbr的提示是目前不支持5.0版本。真的是这样吗?

    通过对DragonBonesJS代码仓库的分析,我们可以看到有5.0的版本,我这边猜测是CLI未更新导致的信息不一致。所以我们不通过CLI,而且直接下载代码自行打包,就能获得最新的运行库代码。然后,按照项目readme.md的介绍进行打包。

    打包完之后,我们把运行库和PIXI文件放在项目的public文件夹下,通过<script></script>标签引入。

    <script src="%PUBLIC_URL%/libs/pixi.min.js"></script>
    <script src="%PUBLIC_URL%/libs/pixi-sound.js"></script>
    <script src="%PUBLIC_URL%/libs/dragonBones.min.js"></script>
    

    最后,把我们准备好的视觉、音频、骨骼动画等素材也放到项目的public文件夹下,前期准备工作就完成了。

    整体项目构建

    我构想出的页面流程分为四部分:加载资源->游戏倒计时->游戏进行->游戏结束。

    根据这四个步骤,我初步划分出四个组件(或页面):

    • Loading
    • CountDown
    • Game
    • GameOver

    Loading(加载组件)

    React + Pixi + DragonBones 打造H5横屏互动游戏

    我们需要一个progress变量来知道当前资源加载进度,当progress加载到100,就说明资源加载完成,我们可以关闭加载页面,进入下一步。

    const [progress, setProgress] = useState(0);
    const [isLoading, setIsLoading] = useState(true);
    
    useEffect(() => {
      if (progress >= 100) {
        // 延迟可以让进度条动画到了100%之后才消失,也可以给渲染提供一点时间
        setTimeout(() => {
          setIsLoading(false);
        }, 200);
      }
    }, [progress]);
    

    CountDown(倒计时组件)

    React + Pixi + DragonBones 打造H5横屏互动游戏

    loading就开始游戏,用户会反应不过来,这里加一个三秒倒计时蒙层盖在游戏画面上。当倒计时结束,游戏开始。

    const [countDown, setCountDown] = useState(3);
    const [isPlaying, setIsPlaying] = useState(false);
    
    useInterval(() => {
      setCountDown(countDown - 1);
    }, (countDown > 0 && !isLoading) ? 1000 : null);
    
    useEffect(() => {
      if (countDown <= 0) {
        setIsPlaying(true);
      }
    }, [countDown]);
    

    Game(游戏组件)

    React + Pixi + DragonBones 打造H5横屏互动游戏

    资源加载进度、游戏进程其实都需要游戏组件来控制。得益于Hooks,我们只要把函数传进去就能管理这些状态。当然你也可以使用统一的数据管理,如redux等。对于我们这个demo来说,这种方式足够了。

    <Game
      setProgress={setProgress}
      isPlaying={isPlaying}
      setIsPlaying={setIsPlaying}
      setHeadCount={setHeadCount}
    />
    

    GameOver(结束弹框)

    React + Pixi + DragonBones 打造H5横屏互动游戏

    我们通过isPlaying来判断是否展示。然后在弹框的按钮绑定一个点击事件(replay),完成流程循环。

    useEffect(() => {
      // isPlaying在一开始是false,但我们不希望游戏还没开始,就出现这弹框,这里做个累加器
      if (isPlaying) {
        gameCount++;
      } else {
        if (gameCount > 0) {
          setShowGameOver(true);
        }
      }
    }, [isPlaying]);
    

    游戏模块

    接下来,我们来实现游戏模块,这也是本文的重点。

    初始化

    Html方面,我们只需要创建一个div容器,给一个id就行。

    <div id="my-canvas" className="my-canvas"></div>
    

    在页面加载好后,执行游戏初始化。

    useEffect(() => {
      const state = { setHeadCount, headCount };
      init(props, state);
    }, []);
    
    /**
     * @description 游戏初始化
     * @param {*} props
     * @param {*} state
     */
    function init(props, state) {
      app = new PIXI.Application({
        backgroundColor: 0x7976b6
      });
      if (document.getElementById("my-canvas") && app) {
        document.getElementById("my-canvas").appendChild(app.view);
      }
      // 屏幕适配
      detectOrient();
      // 挂载props到app上
      app.reactProps = props;
      app.reactState = state;
      app.loader.add([
        { name: 'bg', url: `${process.env.REACT_APP_RES_PATH}resources/bg.png` },
        { name: 'swordsManBonesData', url: `${process.env.REACT_APP_RES_PATH}resources/SwordsMan/SwordsMan_ske.json` },
        { name: 'swordsManTexData', url: `${process.env.REACT_APP_RES_PATH}resources/SwordsMan/SwordsMan_tex.json` },
        { name: 'swordsManTex', url: `${process.env.REACT_APP_RES_PATH}resources/SwordsMan/SwordsMan_tex.png` },
        { name: 'bgSound', url: `${process.env.REACT_APP_RES_PATH}resources/bg.mp3` },
        //…… 省略一堆资源列表
      ]);
      app.loader.on("progress", ({ progress }) => {
        app.reactProps.setProgress(progress.toFixed(2));
      });
      app.loader.once("complete", setup, this);
      app.loader.load();
    }
    

    init函数中,我们做了以下几件事:

    • 通过PIXI.Application创建一个画布,并挂载到我们刚才设置的div容器中;
    • 屏幕适配,这个下文详细讲述;
    • 把react的propsstate挂载到app对象上,后续操作起来比较方便;
    • 使用PIXI.Loader加载游戏资源,在progress事件中,把当前进度传递给Loading 组件,在complete事件中,触发setup函数。

    素材装载

    setup函数用来把加载好的素材加入到画布上,然后启动游戏。

    我们游戏一共三个元素,我们一个个来加载。

    /**
     * @description 启动游戏
     * @param {*} target
     * @param {*} resource 资源列表
     */
    function setup(target, resource) {
      addBg(resource);
      addMonster(resource);
      addMaster(resource);
      play();
    }
    

    游戏背景(平铺精灵)

    平铺精灵(TilingSprite)是一种特殊的精灵,可以在一定的范围内重复一个纹理。我们可以使用它们创建无限滚动的背景效果。

    /**
     * @description 加入背景
     * @param {*} resource
     */
    function addBg(resource) {
      const textureImg = resource["bg"].texture;
      tilingSprite = new PIXI.TilingSprite(textureImg, 960, 375);
      tilingSprite.position.y = getY(0);
      tilingSprite.position.x = 0;
      app.stage.addChild(tilingSprite);
    }
    

    事物运动都是有参照物的。因此我们想制造一个人物前进的效果,我们有两种做法,第一种,人物向右运动,背景不动;第二种,人物不动,背景向左运动。根据这个原理,我们就可以在不改变人物位置的情况下,实现前进效果。

    游戏主角(骨骼动画)

    现在,我们来装载第一个骨骼动画。

    const dragonbonesFactory = dragonBones.PixiFactory.factory; //新建骨骼动画制作工厂
    let swordsManDisplay = null;
    /**
     * @description 设置角色
     * @param {*} resource 资源列表
     */
    function addMaster(resource) {
      let textureImg = resource["swordsManTex"].texture;
      let textureData = resource["swordsManTexData"].data;
      let skeletonData = resource["swordsManBonesData"].data;
      //骨骼动画实现
      dragonbonesFactory.parseDragonBonesData(skeletonData); //解析骨骼数据
      dragonbonesFactory.parseTextureAtlasData(textureData, textureImg); //解析纹理数据
      swordsManDisplay = dragonbonesFactory.buildArmatureDisplay(skeletonData.armature[0].name); //构建骨骼动画
      swordsManDisplay.x = 200;
      swordsManDisplay.y = getY(350);
      swordsManDisplay.scale.set(0.25, 0.25);
      swordsManDisplay.animation.play('steady', 0); //执行动画
      app.stage.addChild(swordsManDisplay);
    }
    

    dragonBones中,由工厂类(Factory)管理骨骼动画。需要注意两点:

    • 当使用一个 Factory 时,需要注意避免龙骨数据或骨架数据重名。
    • 如果没有特殊需求,建议不要使用多个 Factory 实例

    所以,我们这边先复制一个PixiFactory对象。然后使用工厂类的parseDragonBonesDataparseTextureAtlasData解析已经加载好的资源文件,然后构建出一个显示对象(DisplayObject),这个对象同时继承了PIXIDisplayObject对象和dragonBonesBaseObject对象,可以使用两者的方法。这也是我们主要的操作对象。由于包含的类实在太多,这里就不一一介绍,有兴趣的可以查看官方API文档。

    装载好后,我们调整这个人物的位置和大小,使之贴合背景。

    然后,我们给这个人物一个默认动作,执行显示对象的animation属性上的play,并把执行次数设置成0(循环播放)。

    最后,把这个显示对象加入画布,一个做着待机动作的机器人就出现在画面上。

    游戏怪物(骨骼动画)

    怪兽的装载和主角基本一致。

    值得一提的是,怪兽在主角右边,我们希望他面向主角放技能,这样更符合逻辑。我们需要对骨骼进行一个水平翻转:设置armatureflipX属性为true,即可完成。同理,设置armatureflipY属性为true,即可完成垂直翻转。

    /**
     * @description 加载怪兽
     * @param {*} resource 资源列表
     */
    function addMonster(resource) {
      // ...省略重复代码
      demonDisplay.armature.flipX = true;
      // ...省略重复代码
      app.stage.addChild(demonDisplay);
    }
    

    游戏流程

    play是控制游戏进行的核心函数,通过requestAnimationFrame进行循环调用。

    /**
     * @description 游戏
     */
    function play() {
      if (app.reactProps.isPlaying) {
        // 游戏开始,变动初始动作
        if (swordsManDisplay.animation.lastAnimationName === 'steady') {
          swordsManDisplay.animation.play('walk', 0);
        }
        if (!attackState.isPlaying && !jumpState.isPlaying) {
          // 背景滚动
          tilingSprite.tilePosition.x -= 5;
          // 重置怪物
          if (demonDisplay.x < -150) {
            demonDisplay.x = getX(parseInt(Math.random() * 400));
            demonDisplay.animation.play('uniqueAttack', 0); //执行动画
          } else {
            demonDisplay.x -= 5;
          }
        }
        // 判定结束游戏
        if (isHit(250) && !attackState.isPlaying && demonDisplay.animation.lastAnimationName !== 'dead') {
          app.reactProps.setIsPlaying(false);
          app.reactProps.setHeadCount(app.reactState.headCount);
          app.reactState.setHeadCount(0);
          swordsManDisplay.animation.play('steady', 0);
          demonDisplay.x = clientWidth + parseInt(Math.random() * 400);
        }
      }
    
      requestAnimationFrame(play);
    }
    

    该函数主要做了以下几件事:

    • 判断机器人的前一个动作是不是待机动作,如果是,则要把机器人的动作设置成走路,表明游戏开始。该操作在游戏周期中只执行一次;
    • 控制背景滚动,通过视差产生人物往前走的效果;
    • 当怪物超出屏幕范围时,重置它的状态和位置,使之可以重复利用,较少开销。这里可以理解为对象池的简单应用;
    • 游戏结束条件判定,当机器人与怪物产生碰撞时,且机器人未做出攻击动作,则游戏结束,弹出游戏结束弹框。

    碰撞检测

    原本打算使用dragonBones提供的containsPoint方法和intersectsSegment方法进行碰撞检测,但涉及到本地坐标系和世界坐标系的转换,官方Demo也不是很清楚,尝试了很多次,都没碰撞成功。

    我这边使用一个比较取巧的方法进行检测。因为每一个插值(slot)也是一个displayObject,我就可以调用PIXI上的方法,获取它的世界坐标,然后比较它的X值。

    function isHit(x) {
      const target = demonDisplay.armature.getSlot('body').display;
      const bounds = target.getBounds();
    
      return bounds.x < x;
    }
    

    动作交互

    我在游戏中设置了两个动作:jumpattack。在没框架帮助的情况下,使用PIXI开发HUD比较麻烦,所以,我这边用DOM直接写了两个按钮。

    重点来看,attack函数的实现。

    /**
     * @description 攻击动作
     */
    function attack() {
      if (!attackState.isPlaying) {
        playSound('attackSound');
        attackState = swordsManDisplay.animation.gotoAndPlayByFrame('attack1', 20, 1); //执行动画
        if (isHit(500) && demonDisplay.animation.lastAnimationName !== 'dead') {
          demonDisplay.animation.play('dead', 1);
          app.reactState.setHeadCount(++app.reactState.headCount);
        }
      }
    }
    

    attackState记录了当前动画的状态。我做了一个防频,当攻击动作未结束的时候,跳过本次点击事件。

    然后执行以下三个操作:

    • 播放效果音;
    • 执行攻击动作,并把动画状态赋值给attackState。这里使用了一个新的播放函数:gotoAndPlayByFrame,它控制动画从哪一帧开始播放,使得两个动作衔接更自然;
    • 碰撞检测,当碰撞到怪物且怪物还处于活跃状态,则算击杀怪物,怪物执行dead动作,人头数加一。

    一个攻击动作执行完后,我们需要进行复位。可以在装载人物的时候,给人物添加一个动作执行完成(COMPLETE)事件。这样,我们就不需要每次都手动复位初始动作了。

    这里,我发现一个小问题,dragonBones好像在重复使用动画状态的内存空间,导致attackState值一直在变。为了防止出现混淆的情况,每次执行完动作后,就把这块空间释放掉。

    swordsManDisplay.on(dragonBones.EventObject.COMPLETE, () => {
      swordsManDisplay.animation.play('walk', 0);
      // 似乎这块空间是公用的
      attackState = {};
      jumpState = {};
    });
    

    音乐模块

    音乐部分,我们借助pixi-sound这个官方插件来完成,也是通过<script></script>引入,注意它需要在PIXI之后。

    然后封装两个方法:playSoundstopSound,就能控制所有声音的播放,我这边就两个:背景音和主角的攻击声。

    /**
     * @description 播放声音
     * @param {*} name 资源名
     * @param {boolean} [loop=false] 是否循环
     */
    function playSound(name, loop = false) {
      const sound = app.loader.resources[name].sound;
      sound.play({
        loop
      });
    }
    /**
     * @description 暂停声音
     * @param {*} name 资源名
     */
    function stopSound(name) {
      const sound = app.loader.resources[name].sound;
      sound.stop();
    }
    

    需要注意的是,chrome禁止声音自动播放,需要用户出现交互时,才能播放,所以我们在右上角做了一个开关控制背景音。攻击声音本来就是需要交互触发,所以不需要考虑这个。

    横屏适配

    因为我们是横屏游戏,所以需要对竖屏的情况进行强制横屏。

    这里借鉴凹凸实验室的实践,对resize事件进行监听,当屏幕是竖屏的时候,整个画面进行90度旋转。

    const detectOrient = function () {
      let width = document.documentElement.clientWidth,
        height = document.documentElement.clientHeight,
        $wrapper = document.getElementById("app"),
        style = "";
    
      if (getOrientation() === 'landscape') { // 横屏
        style = `
          width: ${width}px;
          height: ${height}px;
          -webkit-transform: rotate(0); transform: rotate(0);
          -webkit-transform-origin: 0 0;
          transform-origin: 0 0;
        `;
      } else { // 竖屏
        style = `
          width: ${height}px;
          height: ${width}px;
          -webkit-transform: rotate(90deg); 
          transform: rotate(90deg);
          -webkit-transform-origin: ${width / 2}px ${width / 2}px;
          transform-origin: ${width / 2}px ${width / 2}px;
        `
      }
      $wrapper.style.cssText = style;
    }
    
    useEffect(() => {
      detectOrient();
    }, []);
    
    useEventListener('resize', detectOrient);
    

    调研了几个判断屏幕方向的函数,其他API或多或少有点兼容性问题,我这边选择使用mediaQuery进行判断。

    export function getOrientation() {
      const mql = window.matchMedia("(orientation: portrait)")
    
      return mql.matches ? 'portrait' : 'landscape';
    }
    

    虽然,画面旋转了90度,但我们的游戏画布并不是随之旋转的,我们需要单独调整。

    function detectOrient() {
      clientWidth = document.documentElement.clientWidth;
      clientHeight = document.documentElement.clientHeight;
    
      if (getOrientation() == 'portrait') {
        app.renderer.resize(clientHeight, clientWidth);
      } else {
        app.renderer.resize(clientWidth, clientHeight);
      }
    }
    

    通过PIXIrenderer对象对整个画布重绘。此时,发现画布上的元素都发生了错位,我们需要根据屏幕方向调整位置。

    /**
     * @description 获取相对位置
     * @param {*} y
     * @returns {*}  
     */
    function getY(y) {
      return getOrientation() === 'landscape' ? clientHeight - 375 + y : clientWidth - 375 + y;
    }
    /**
     * @description 获取相对位置
     * @param {*} x
     * @returns {*}  
     */
    function getX(x) {
      return getOrientation() === 'landscape' ? clientWidth + x : clientHeight + x;
    }
    

    至此,整个游戏就完成了。

    部署上线

    本地一切正常,但当我打包上传到服务器上时,问题出现了。由于我发布的地址带路径(比如xxx.com/xxx/index.html),在第一步中引入的js路径就变成了:xxx.com/libs/pixi.min.js,但实际地址是:xxx.com/xxx/libs/pixi.min.js。修改方法也很简单,我们只要修改PUBLIC_URL即可。

    根据create-react-app文档,我们在项目根目录创建.env.production文件,里面添加两行:

    PUBLIC_URL=https://xxx.com/xxx
    REACT_APP_RES_PATH=/xxx
    

    第一行是来修改index.htmlPUBLIC_URL的指向,第二行来修改项目中的静态资源前缀。

    当然这只是一个简单的处理,在实际项目中,我们可以通过工程化的手段来解决这些问题,比如部署CDN。

    总结

    本文基于React + Pixi + DragonBones做了一个简单的游戏demo,基本走通了2D游戏开发流程,可以为后续的项目开发提供一些经验教训。

    在开发过程中,我也遇到一些问题,比如:

    • 运行库不支持NPM,需要通过标签引入;
    • DragonBones官方文档不够完善,且长时间未更新,导致踩坑过程十分艰难;
    • WebGL国内还算一个细分领域,相关的文章较少,需要自行摸索,或者看英文论坛。

    后续,我也将继续探索学习,比如引入前端工程化、尝试其他骨骼动画方案(比如Live2D、Spine)等,解决开发中的痛点,真正将WebGL技术应用于实际业务。欢迎有同样兴趣的同学一起参与讨论。

    参考资料

    • DragonBones官方文档
    • DragonBonesJS代码仓库
    • PixiJS API Documentation
    • H5游戏开发:横屏适配
    • 学习 PixiJS — 视觉效果

    下载网 » React + Pixi + DragonBones 打造H5横屏互动游戏

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元