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

4,285 阅读8分钟

本文首发于:kapeter.com/post/29

前言

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

企业咚咚20210316150010.gif

基本概念

Pixi.js

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

DragonBones

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

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

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

动画素材准备

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

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

屏幕快照 2021-03-16 下午3.09.55.png

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

屏幕快照 2021-03-03 下午7.44.28.png

创建项目

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

npx create-react-app dragonBones-demo

为什么使用React,而不是直接使用游戏引擎?这主要基于业务考虑:在电商互动游戏中,游戏只占项目本身的一部分,其他还涉及商品、分享、发券等逻辑。如果使用游戏引擎,则无法复用团队内部这些业务组件,导致开发周期大幅提高。如果你是新团队或者专门的游戏团队,没有技术包袱,可以考虑直接使用游戏引擎。

在写代码之前,我们需要引入基本的运行库。通过研究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(加载组件)

WechatIMG197.jpeg

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

const [progress, setProgress] = useState(0);
const [isLoading, setIsLoading] = useState(true);

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

CountDown(倒计时组件)

WechatIMG196.jpeg

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(游戏组件)

WechatIMG195.jpeg

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

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

GameOver(结束弹框)

WechatIMG198.jpeg

我们通过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技术应用于实际业务。欢迎有同样兴趣的同学一起参与讨论。

参考资料