This article was originally published at: kapeter.com/post/29

preface

In the past two years, the network platform set off an interactive game upsurge. The three major e-commerce platforms have launched a number of interactive mini-games (plant trees, raise pets, Monopoly, etc.) at the same time. The purpose is to improve APP daily activity through fun games, enhance user engagement, and then convert into orders. In this trend, the segment of the front-end – WebGL has become a new technology hot spot. The Technology department of Amoy has launched EVAJS, an interactive Engine, and Ant Financial has launched Oasis Engine, a Web 3D Engine. It is believed that other frameworks will be launched in the future. In order not to be eliminated by The Times, the author also began to study this part of knowledge. This article will use The Pixi as the rendering engine to create a simple game Demo using DragonBones animation.

The basic concept

Pixi.js

Pixi this need not be more introduction, the famous HTML5 2D rendering engine, perfect technical documentation, rich API, numerous plug-ins, suitable for WebGL beginners to learn.

DragonBones

DragonBones is a 2D bone animation solution from Egret. The biggest advantage of DragonBones over rival Spine (the animation scheme used by EVAJS) is that it is free and suitable for individuals to develop and use.

With a brief overview of the basic concepts, you should be able to understand DragonBones’ data structure.

  • Skeleton (armature) : A skeleton is a collection of bones that contain at least one skeleton. A project can contain an overpaid skeleton.
  • Bones: Bones are the basic components of bone animation. Bones can rotate, scale, and pan.
  • Slot: A slot is a container for pictures, a bridge between bones and pictures. In the main scene, the hierarchical relationship of images is reflected by the hierarchical relationship of slots in the hierarchy panel.
  • Texture: The texture is the basic design material. The texture needs a slot to bind to the skeleton, also called texture in webGL.

Animation material preparation

The first thing we need to do is to download and install the Editor for DragonBones.

After installation, open the software, there are some learning resources for us to use in the welcome screen. We randomly select a material to open, into the edit interface.

As you can see, the official has done all our work, we do not need to edit the material, directly choose the menu bar “File” -> “Export”, will pop up an export box. Select JSON as the data type, check “Package ZIP”, and click “Finish” to get a ZIP package. After decompression, there are three files, one PNG file and two JSON files, which are the materials needed in the subsequent project.

Create a project

Since demo does not need to introduce business components, create-react-app is used to quickly create a project.

npx create-react-app dragonBones-demo
Copy the code

Why React instead of using the game engine directly? This is mainly based on business considerations: in e-commerce interactive games, the game is only a part of the project itself, and the other logic involves goods, sharing, vouchers and so on. If a game engine is used, these business components cannot be reused within the team, resulting in significantly higher development cycles. If you are a new team or a dedicated game team with no technical baggage, consider using a game engine directly.

Before writing the code, we need to introduce the basic runtime. Looking at the DragonBones runtime code, I unfortunately found that the runtime does not support NPM import and requires CLI generation of the corresponding version of the runtime. I use Pixi, so all I need to generate is the runtime for Pixi.

According to the official documentation, we installed DragonBones-Runtime globally. Then run DBR

@

to generate the runtime libraries that the engine depends on in the dragonBones -out directory in the directory where you executed the command:

NPM install -g dragonBones -Runtime DBR [email protected]Copy the code

There is a problem here, the Pixi stable version is currently 5.0, and we wish the DragonBones runtime would support 5.0 as well, but the DBR tells us that 5.0 is not currently supported. Is it really so?

In the DragonBonesJS repository, we can see that there is a 5.0 version, I guess the CLI is not updated to cause the information inconsistency. So instead of going through the CLI, we can download the code and package it ourselves to get the latest runtime code. Then, package as described in the project readme.md.

Once packaged, we place the runtime and PIXI files in the project’s public folder and import them through the tag.

<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>
Copy the code

Finally, put the visual, audio, bone animation and other materials we have prepared into the public folder of the project, and the preliminary preparation work will be completed.

Overall project construction

The page flow I envisioned is divided into four parts: load resources -> game countdown -> game on -> game over.

Based on these four steps, I have preliminarily divided four components (or pages) :

  • Loading
  • CountDown
  • Game
  • GameOver

Loading (Loading components)

We need a progress variable to know the current resource loading progress. When progress is loaded to 100, the resource is loaded. We can close the loading page and proceed to the next step.

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

useEffect(() = > {
  if (progress >= 100) {
    // Delay can make the progress bar animation disappear after 100%, and also provide some time for rendering
    setTimeout(() = > {
      setIsLoading(false);
    }, 200);
  }
}, [progress]);
Copy the code

CountDown (CountDown component)

Loading will start the game, and users will not be able to react. Here, a three-second countdown is added to cover the game screen. When the countdown ends, the game begins.

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]);
Copy the code

Game (Game Component)

Resource loading progress and game progression are controlled by game components. Thanks to Hooks, we can manage these states simply by passing functions in. Of course, you can also use unified data management, such as Redux. For our demo, that’s enough.

<Game
  setProgress={setProgress}
  isPlaying={isPlaying}
  setIsPlaying={setIsPlaying}
  setHeadCount={setHeadCount}
/>
Copy the code

GameOver (End popup)

We use isPlaying to determine whether to show. Then bind a click event (replay) to the button of the pop-up box to complete the process loop.

useEffect(() = > {
  // isPlaying is false at the beginning, but we don't want this popbox to appear before the game starts, so make an accumulator here
  if (isPlaying) {
    gameCount++;
  } else {
    if (gameCount > 0) {
      setShowGameOver(true);
    }
  }
}, [isPlaying]);
Copy the code

The game module

Next, let’s implement the game module, which is the focus of this article.

Initialize the

On the Html side, we just need to create a div container and give it an ID.

<div id="my-canvas" className="my-canvas"></div>
Copy the code

After the page loads, perform the game initialization.

useEffect(() = > {
  conststate = { setHeadCount, headCount }; init(props, state); } []);/ * * *@description Game initialization *@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);
  }
  // Screen adaptor
  detectOrient();
  // Mount props to 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` },
    / /... Omit a list of resources
  ]);
  app.loader.on("progress".({ progress }) = > {
    app.reactProps.setProgress(progress.toFixed(2));
  });
  app.loader.once("complete", setup, this);
  app.loader.load();
}
Copy the code

In the init function, we do the following:

  • throughPIXI.ApplicationCreate a canvas and mount it into the div container we just set up.
  • Screen adaptation, more on this later;
  • Put the reactpropsandstateMount to theappObject, the subsequent operation is more convenient;
  • usePIXI.LoaderLoad game resources inprogressEvent to pass the current progress to the Loading component in thecompleteEvent, triggersetupFunction.

Material loading

The setup function is used to add the loaded material to the canvas and start the game.

We have three elements in our game, and we load them one by one.

/ * * *@description Start the game@param {*} target
 * @param {*} Resource Resource list */
function setup(target, resource) {
  addBg(resource);
  addMonster(resource);
  addMaster(resource);
  play();
}
Copy the code

Game Background (Tile Sprite)

A tile Sprite is a special Sprite that can repeat a texture within a certain range. We can use them to create background effects with infinite scrolling.

/ * * *@description Add background@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);
}
Copy the code

Everything that moves has a reference. So we want to create the effect of the character moving forward, and we can do it in two ways: first, the character moves to the right without moving the background; In the second, the character does not move and the background moves to the left. Based on this principle, we can achieve progress without changing the position of the character.

Game protagonist (Skeletal animation)

Now let’s load our first skeleton animation.

const dragonbonesFactory = dragonBones.PixiFactory.factory; // Create a new skeleton animation factory
let swordsManDisplay = null;
/ * * *@description Setting roles@param {*} Resource Resource list */
function addMaster(resource) {
  let textureImg = resource["swordsManTex"].texture;
  let textureData = resource["swordsManTexData"].data;
  let skeletonData = resource["swordsManBonesData"].data;
  // Skeleton animation is implemented
  dragonbonesFactory.parseDragonBonesData(skeletonData); // Parse the bone data
  dragonbonesFactory.parseTextureAtlasData(textureData, textureImg); // Parse texture data
  swordsManDisplay = dragonbonesFactory.buildArmatureDisplay(skeletonData.armature[0].name); // Build a skeleton animation
  swordsManDisplay.x = 200;
  swordsManDisplay.y = getY(350);
  swordsManDisplay.scale.set(0.25.0.25);
  swordsManDisplay.animation.play('steady'.0); // Perform the animation
  app.stage.addChild(swordsManDisplay);
}
Copy the code

In dragonBones, the Factory class manages bone animation. Two points to note:

  • When using a Factory, care should be taken to avoid having the same name for keel data or skeleton data.
  • It is not recommended to use multiple Factory instances without special requirements

So, let’s copy a PixiFactory object. Then use the factory class parseDragonBonesData and parseTextureAtlasData to parse the loaded resource file and build a DisplayObject. This object inherits both PIXI’s DisplayObject and dragonBones’ BaseObject, and can use both methods. This is also our main operation object. Because there are so many classes included, I will not introduce them here. If you are interested, you can check the official API documentation.

Once loaded, we reposition and size the figure to fit the background.

We then give the character a default action, perform play on the animation property of the display object, and set the number of times to 0(loop play).

Finally, the display object is added to the canvas, and a robot in standby action appears on the screen.

Game Monster (Skeletal Animation)

The monster’s loading is basically the same as the main character’s.

It is worth mentioning that the monster is to the right of the main character, so we want him to put skills towards the main character, which is more logical. We need to do a horizontal flip on the skeleton: Set the flipX property of ArMature to True to complete. Similarly, set the flipY property of ArMature to True to complete the vertical flip.

/ * * *@description Loading monsters@param {*} Resource Resource list */
function addMonster(resource) {
  / /... Omit duplicate code
  demonDisplay.armature.flipX = true;
  / /... Omit duplicate code
  app.stage.addChild(demonDisplay);
}
Copy the code

The game process

Play is the core function that controls how the game is played, and is looped through requestAnimationFrame.

/ * * *@description The game * /
function play() {
  if (app.reactProps.isPlaying) {
    // Start the game, change the initial action
    if (swordsManDisplay.animation.lastAnimationName === 'steady') {
      swordsManDisplay.animation.play('walk'.0);
    }
    if(! attackState.isPlaying && ! jumpState.isPlaying) {// Background scroll
      tilingSprite.tilePosition.x -= 5;
      // Reset the monster
      if (demonDisplay.x < -150) {
        demonDisplay.x = getX(parseInt(Math.random() * 400));
        demonDisplay.animation.play('uniqueAttack'.0); // Perform the animation
      } else {
        demonDisplay.x -= 5; }}// End the game
    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);
}
Copy the code

This function does the following:

  • Determine whether the robot’s previous action is standby or not. If so, set the robot’s action to walk, indicating the start of the game. This action is performed only once in the game cycle;
  • Control background scrolling, through the parallax to produce the effect of moving forward;
  • When a monster is out of screen range, it resets its state and position, making it reusable with less overhead. This can be interpreted as a simple application of object pooling;
  • The end of the game condition is determined, when the robot colliders with the monster, and the robot does not make an attack, the game ends, and the end of the game pop-up box.

Collision detection

We originally planned to use containsPoint method and intersectsSegment method provided by dragonBones for collision detection, but the official Demo was not clear about the conversion between the local coordinate system and the world coordinate system. We tried many times but failed to collide.

I use a more tricky method for detection. Since each slot is also a displayObject, I can call a method on PIXI to get its world coordinates and compare its X value.

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

  return bounds.x < x;
}
Copy the code

Motion interaction

I set up two actions in the game: Jump and Attack. Developing a HUD using PIXI without the help of a framework is cumbersome, so I wrote two buttons directly in the DOM.

Focus on the implementation of attack function.

/ * * *@description Attack action */
function attack() {
  if(! attackState.isPlaying) { playSound('attackSound');
    attackState = swordsManDisplay.animation.gotoAndPlayByFrame('attack1'.20.1); // Perform the animation
    if (isHit(500) && demonDisplay.animation.lastAnimationName ! = ='dead') {
      demonDisplay.animation.play('dead'.1); app.reactState.setHeadCount(++app.reactState.headCount); }}}Copy the code

AttackState records the state of the current animation. I did an anti-frequency to skip the click event while the attack was still in progress.

Then perform the following three operations:

  • Play effect sound;
  • Executes the attack action and assigns the animation state toattackState. A new player function is used:gotoAndPlayByFrame, it controls which frame the animation starts from, so that the two actions connect more naturally;
  • Collision detection, when the collision to the monster and the monster is still active, it is considered to kill the monster, monster executiondeadAction, head count plus one.

After an attack has been executed, we need to reset. You can add a COMPLETE event to the character when the character is loaded. This way, we don’t have to manually reset the initial action every time.

Here, I noticed a small problem that dragonBones seemed to be reusing the memory space for animation state, causing the attackState value to keep changing. To prevent confusion, free up this space after each action.

swordsManDisplay.on(dragonBones.EventObject.COMPLETE, () = > {
  swordsManDisplay.animation.play('walk'.0);
  // It seems that this space is common
  attackState = {};
  jumpState = {};
});
Copy the code

Music module

Then encapsulate two methods: playSound and stopSound, to control all sound playback, I have two: background sound and hero attack sound.

/ * * *@description Play sound@param {*} Name Resource name *@param {boolean} [loop=false] Whether to loop */
function playSound(name, loop = false) {
  const sound = app.loader.resources[name].sound;
  sound.play({
    loop
  });
}
/ * * *@description Pause sound@param {*} Name Resource name */
function stopSound(name) {
  const sound = app.loader.resources[name].sound;
  sound.stop();
}
Copy the code

Note that Chrome does not allow the sound to play automatically until the user interacts with it, so we made a switch in the upper right corner to control the background sound. Attack sounds are interactive, so you don’t need to worry about that.

Landscape adaptation

Since we’re playing landscape, we need to force landscape for portrait.

Here, the practice of concave laboratory is used for reference, and the resize event is monitored. When the screen is vertical, the whole picture is rotated 90 degrees.

const detectOrient = function () {
  let width = document.documentElement.clientWidth,
    height = document.documentElement.clientHeight,
    $wrapper = document.getElementById("app"),
    style = "";

  if (getOrientation() === 'landscape') { / / 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 { / / vertical screen
    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);
Copy the code

After investigating several functions to determine screen orientation, other apis are more or less incompatibable, so I chose to use mediaQuery.

export function getOrientation() {
  const mql = window.matchMedia("(orientation: portrait)")

  return mql.matches ? 'portrait' : 'landscape';
}
Copy the code

Although the picture rotated 90 degrees, our game canvas didn’t rotate with it, and we had to adjust it separately.

function detectOrient() {
  clientWidth = document.documentElement.clientWidth;
  clientHeight = document.documentElement.clientHeight;

  if (getOrientation() == 'portrait') {
    app.renderer.resize(clientHeight, clientWidth);
  } else{ app.renderer.resize(clientWidth, clientHeight); }}Copy the code

The entire canvas is repainted using the Renderer object of PIXI. At this point, all the elements on the canvas are out of place, and we need to adjust the position according to the screen orientation.

/ * * *@description Gets the relative position *@param {*} y
 * @returns {*}  * /
function getY(y) {
  return getOrientation() === 'landscape' ? clientHeight - 375 + y : clientWidth - 375 + y;
}
/ * * *@description Gets the relative position *@param {*} x
 * @returns {*}  * /
function getX(x) {
  return getOrientation() === 'landscape' ? clientWidth + x : clientHeight + x;
}
Copy the code

At this point, the whole game is over.

The deployment of online

If you are Posting to the root directory, you can skip this section.

Everything was fine locally, but when I packed up and uploaded to the server, I had a problem. Released due to my address with path (such as xxx.com/xxx/index.html), the first step in the introduction of js path becomes: xxx.com/libs/pixi.min.js, but the real address is: xxx.com/xxx/libs/pixi.min.js. The change method is also very simple, we just need to change the PUBLIC_URL.

Env. Production file in the project root directory and add two lines:

PUBLIC_URL=https://xxx.com/xxx
REACT_APP_RES_PATH=/xxx
Copy the code

The first line is to change the reference to the PUBLIC_URL in index.html, and the second line is to change the static resource prefix in the project.

Of course, this is only a simple process. In actual projects, we can solve these problems through engineering means, such as deployment of CDN.

conclusion

This article based on React + Pixi + DragonBones to do a simple game demo, basic through the 2D game development process, can provide some lessons for the subsequent project development.

During the development process, I also encountered some problems, such as:

  • The runtime does not support itNPM, need to be introduced through the label;
  • DragonBonesThe official documents are not perfect and have not been updated for a long time, which makes the process of stepping pits very difficult.
  • WebGL is still a segmented field in China, with few related articles, so you need to explore by yourself or read English forums.

In the future, I will continue to explore and learn, such as introducing front-end engineering and trying other bone animation schemes (such as Live2D and Spine) to solve the pain points in development and really apply WebGL technology to actual business. Students with similar interests are welcome to join the discussion.

The resources

  • Official documentation for DragonBones
  • DragonBonesJS code repository
  • PixiJS API Documentation
  • H5 game development: landscape adaptation
  • Learn PixiJS – Visual effects