“This article has participated in the good article call order activity, click to see: back end, big front end double track submission, 20,000 yuan prize pool for you to challenge!” Technology stack: Canvas, matter-JS, TS

This post was originally shared internally, using the names of colleagues.


Wang Mengjia: “Xiong Dongqi GG, I want to eat fruit ~” Xiong Dongqi: “this which can bear, cut for you”

Let’s see what it looks like

Nonsense words don’t say, direct open whole!

The index

Complete code: github.com/superBlithe…

plan

  • Step1: initialize & simple layout
  • Step2: Knife light realization
  • Step3: knife collision fruit, matterjs enter
    • Matterjs combining FYGE
    • The force of gravity
    • The collision
  • Step4: Physics engine & collision detection
  • Step5: the game page

plan

Another way to think about it, let’s disassemble it and then implement it. It’s like building blocks. Step by step.

  • twoA scene:The start page,The game page
  • twoKey classes:fruit,Knife light
  • Physics engine: gravity, collision detection

Can skip the step1

Step1: initialize & simple layout

Wang mengjia said: “I don’t know anything, so what can I do ~” Xiong Dongqi: Let’s start from 0 ~

The initial project has been prepared for you in advance

You can clone the basic project: github.com/superBlithe…

What did the basic projects do?

  • All kinds of pictures
  • Create a directory
  • The entry file main.ts loads the images, and then you just need to cache them
  • Encapsulate some methods

SRC Project directory:

  • Components:Store various components, such as background, knife light, fruit
    • GameBg.ts
  • config
    • GameCfg.ts The game is configured
    • GUtils.ts Utility methods
  • GameScene
    • GameScene.ts The game page
    • StartScene.ts The start page
  • main.ts Entry, file

Start game button

The start button is actually divided into rings and watermelon 🍉

Declare the type

export class StartScene extends FYGE.Container {
  /* start button */
  private startBtnGroup: FYGE.Container;
  private btnOut: FYGE.Sprite;
  /** Fruit: Watermelon */
  private xigua: Fruit;
  /** Watermelon animation */
  private TweenXigu: FYGE.Tween;
Copy the code

The real button watermelon needs to be a fruit

So let’s animate the rings and the rings

Let’s turn it counterclockwise

  private initBtn() {
    this.startBtnGroup = this.addChild(new FYGE.Container());
    this.startBtnGroup.position.x = 200;
    this.startBtnGroup.position.y = 350;
    let btnOut = this.startBtnGroup.addChild(new FYGE.Sprite(getRes(RES_MAP.newGameBtnOut)));
    btnOut.anchorX = btnOut.anchorY = btnOut.width / 2;
    FYGE.Tween.get(btnOut, { loop: true }).to({ rotation: -360 }, 8000);
    this.btnOut = btnOut;
  }
Copy the code

Simple fruits

Why call simple fruit kind? Because here is just a display of nodes in the beginning page of the watermelon, and the game page of various fruits. Will be extended from this class. Let’s start by creating a simple fruit class. New components/Fruit. Ts

import { FRUIT_NAME, RES_MAP, SS } from ".. /config/GameCfg";
import { ADD_SPRITE, getRes, GUtils } from ".. /config/GUtils";

export default class Fruit extends FYGE.Sprite {
  constructor() {
    super(a);/** ** ** ** ** ** * /
    this.texture = getRes(RES_MAP["fruitXigua"]).clone();
    this.anchorX = this.width / 2;
    this.anchorY = this.height / 2; }}Copy the code

Then, on the start page, introduce it and declare it

 /** Fruit: Watermelon */
  private xigua: Fruit;
Copy the code

Then initialize it in the initBtn method and add the action of clockwise rotation

  let xigua = this.addChild(new Fruit());
  xigua.x = 250;
  xigua.y = 400;
  this.TweenXigu = FYGE.Tween.get(xigua, { loop: true }).to({ rotation: 360 }, 4000);
  this.xigua = xigua;
Copy the code

With the foundation set up, we will draw the knife light next

Step2: Knife light realization

Wang Mengjia: “good scary ~, still have knife light! “Xiong Dongqi:” must ah, not only the beginning page, the game page should also have, but also participate in the collision detection behind it ~”

The knife light is actually a polygon drawn. DrawPolygon can take a set of coordinate Points.

When the mouse moves, capture the coordinates. When the mouse is raised, the drawing is cleared.

Create components/ blades.ts

import { RES_MAP, SS } from ".. /config/GameCfg";
import Tpoint from "./Tpoint";

// Survival time of each point
const POINTLIFETIME = 100;
export default class Blade extends FYGE.Graphics {
  private points: Tpoint[] = [];

  public drawBlade(e: FYGE.MouseEvent) {
    this.clear();
    let point = new Tpoint(e.localX, e.localY);
    point.time = new Date().getTime();
    this.points.push(point);
    if (new Date().getTime() - this.points[0].time > POINTLIFETIME) {
      this.points.shift();
    }
    // If there are too few points, it will be touched by mistake
    if (this.points.length < 2) return;
    this.beginFill(0xffffff);
    this.drawPolygon(this.points);
    this.endFill();
  }

  public reset() {
    this.points = [];
    this.clear(); }}Copy the code

Wang Mengjia: “what is Tpoint? “Bear from the East:” is just a normal Point that extends a time field. Build your own. “Xiong Dongqi:” we want to add the event. “

StartScene.ts

  / * * * / knife light
  private blade: Blade;
  // In init()
  this.blade = this.addChild(new Blade());

/** Listen for events */
  private addEvents() {
    this.stage.addEventListener(FYGE.MouseEvent.MOUSE_MOVE, this.onMouseMove, this);
    this.stage.addEventListener(FYGE.MouseEvent.MOUSE_UP, this.onMouseUp, this);
  }

  Remove event */
  private removeEvents() {
    this.stage.removeEventListener(FYGE.MouseEvent.MOUSE_MOVE, this.onMouseMove, this);
    this.stage.removeEventListener(FYGE.MouseEvent.MOUSE_UP, this.onMouseUp, this);
  }

  private onMouseUp() {
    this.blade.reset();
  }

  /** Mouse move */
  private onMouseMove(e) {
    _throttle(this.blade.drawBlade(e), 50);
    // this.blade.checkCollide(this.xigua, this.doStart.bind(this));
  }
Copy the code

Step3: knife collision fruit, matterjs enter

If only start button watermelon collision detection, we can directly use FYGE’s point and object sprite.hitTestPoint method. In fact, that’s what I did in the beginning. But then you have to simulate gravity, you have to simulate objects colliding with objects. So matterJS is introduced. Install matterjs

  yarn add matter-js @types/matter-js -S
Copy the code

How do you use it?

The brick breaking article has talked about some usage. Can be combined with reference. What Matterjs creates is called the physical world, and what FYGE creates is called the view world.

  • First we have to create a physical world
  • And then what? Draw some rigid bodies in the physical world and bind them to the nodes in the current FYGE
  • Listen to the physical world and change the view world

Creating the physical world

Hide the background (for debugging purposes) and then execute the create physical World method with Render turned on while debugging

init()

// this.addChild(new GameBg());
  this.createPhyWorld()
Copy the code

createPhyWorld()

  /** Create the physical world */
   private createPhyWorld() {
    const { Engine, Render, Runner, Composite, Bodies, World, Composites } = Matter;
    this.engine = Engine.create();
    this.world = this.engine.world;
     this.engine.gravity.y = 0;
     
    /** Really run */
    this.runner = Runner.create();
    Runner.run(this.runner, this.engine);
    // @ts-ignore
    this.composites = Composite;
     
    // Temporary rendering engine for debugging
    var render = Render.create({
      element: document.body,
      engine: this.engine,
      options: {
        width: 750.height: 600,}}); Render.run(render); }Copy the code

Increased physical world knife light

We just need to extend the Blade class and add a PhyBody attribute. While drawing the knife light, draw a copy in the physical world. Every time we draw the drawBlade method, we need to clear the previous drawing.

SS is SS = document. Body. ClientWidth / 750; In order to synchronize the scaling of the two world coordinates.

Complete knife light code blade.ts

import * as Matter from "matter-js";
import { RES_MAP, SS } from ".. /config/GameCfg";
import Tpoint from "./Tpoint";

// Survival time of each point
const POINTLIFETIME = 100;
export default class Blade extends FYGE.Graphics {
  private points: Tpoint[] = [];
  private _body: Matter.Body;
  get phyBody() :Matter.Body {
    return this._body;
  }
  set phyBody(v: Matter.Body) {
    this._body = v;
  }

  public drawBlade(e: FYGE.MouseEvent) {
    this.clear();
    let point = new Tpoint(e.localX, e.localY);
    point.time = new Date().getTime();
    this.points.push(point);
    if (new Date().getTime() - this.points[0].time > POINTLIFETIME) {
      this.points.shift();
    }
    // If there are too few points, it will be touched by mistake
    if (this.points.length < 2) return;
    this.beginFill(0xffffff);
    this.drawPolygon(this.points);
    this.endFill();
    // @ts-ignore
    this.phyBody && this.parent.composites.remove(this.parent.world, [this.phyBody]);
    // The physical world is also drawn
    this.phyBody = Matter.Bodies.fromVertices(
      e.localX * SS,
      e.localY * SS,
      [
        this.points.map((p) = > {
          let { x, y } = p;
          return { x: x * SS, y: y * SS }; }),] and {isStatic: true});// @ts-ignore
    this.parent.composites.add(this.parent.world, [this.phyBody]);
  }

  public reset() {
    this.points = [];
    this.clear();
    // @ts-ignore
    this.phyBody && this.parent.composites.remove(this.parent.world, [this.phyBody]); }}Copy the code

The knife light became ~

Also added a physical rigid body to fruit – watermelon

The method is the same as the knife light, here directly paste the code

export default class Fruit extends FYGE.Sprite {
  public phyBody: Matter.Body;
  constructor() {
    super(a);/** ** ** ** ** ** * /
    this.texture = getRes(RES_MAP["fruitXigua"]).clone();
    this.anchorX = this.width / 2;
    this.anchorY = this.height / 2; 
    this.phyBody = Matter.Bodies.circle(this.x * SS, this.y * SS, (this.width / 2) * SS, {
      isStatic: true.isSensor: true.// Sensors that can detect collisions, but do not participate in collisions
      render: { fillStyle: "#060a19" },
      collisionFilter: { group: -1 }, // See reademe for collision rules
    });
    this.setPhyPos();
  }
  set fx(value: number) {
    this.position.x = value;
    this.setPhyPos();
  }

  set fy(value: number) {
    this.position.y = value;
    this.setPhyPos();
  }

  setPhyPos() {
    Matter.Body.setPosition(this.phyBody, {
      x: (this.x + this.width / 2) * SS,
      y: (this.y + this.height / 2) * SS, }); }}Copy the code

And then what? In the initBtn method on the home page, add the watermelon rigidbody, then change x and y to fx and fy. This will set the coordinates of both the view world and the physical rigidbody.

xigua.fx = 250;
xigua.fy = 400;
// @ts-ignore
this.composites.add(this.world, [xigua.phyBody]);
Copy the code

Now it’s time to start the physics engine and crash

Step4: Physics engine & collision detection

Wang Mengjia: “Is this about to start cutting fruit? “Xiong Dongqi:” Yes, MM”

Not surprisingly, the watermelon and the knife light have a white frame on the screen. Yeah, that’s the rigid body of the physical world.

The watermelon and the knife light are both physically rigid bodies that have been drawn, and now they have to collide.

InitBtn (), start the game page, we’ve already set gravity to zero, so we don’t need static

  Matter.Body.setStatic(this.xigua.phyBody, false);
Copy the code

Then add an event listener to createPhyWorld()

  Matter.Events.on(this.engine, "collisionStart".this.onCollisionStart.bind(this));
Copy the code

Add the corresponding method

 /** Across the start button */
  doStart() {
    alert("Start the game.");
  }
  / * * *@description: Collision detection */
  onCollisionStart(e) {
    let pairs = e.pairs;
    if (this.xigua.phyBody.id === pairs[0].bodyA.id) {
      this.doStart(); }}Copy the code

The steel collision will trigger, onecollisionStartCallback, and then we judge the id of the steel is not the ID of the watermelon. Because the watermelonCan't be knocked offSo the watermelon rigidbody has an attributeisSensor

isSensor: true.// Sensors that can detect collisions, but do not participate in collisions
Copy the code

As you can see, the rigid body of the watermelon has fallen off.

And then we’re ready to alert the game and to start the game we need to do the following

  • Watermelon cut,
  • Watermelon fall
  • Clear the current page and enter the new page
  /** Across the start button */
  doStart() {
    this.engine.gravity.y = 1.2;
    /** Stop the watermelon rotation */
    FYGE.Tween.removeTweenSelf(this.TweenXigu);
    this.btnOut.visible = false;
    // this.xigua.doHalf();
    this.removeEvents();
    setTimeout(() = > {
      // @ts-ignore
      this.composites.clear(this.world, true);
      Matter.Runner.stop(this.runner);
      Matter.Engine.clear(this.engine);
      this.parent.addChild(new GameScene());
      this.parent.removeChild(this);
    }, 1000);
  }
Copy the code

If you look at the code above, you see that there’s a doHalf. Just cut the watermelon, so for now, let’s start with comments.

View the world of watermelon drops

You’ve just seen the physical rigid body fall, while the watermelons in the view world remain in place. In fact, it is simple to take the coordinates of the physical rigid body per frame and change the coordinates of the view world watermelon. Fruit.ts constructor()

   this.addEventListener(
      FYGE.Event.ADDED_TO_STAGE,
      () = > {
        this.addEventListener(FYGE.Event.ENTER_FRAME, this.onFarm, this);
      },
      this
    );
Copy the code

Fruit.ts onFarm()

  /** update the current coordinates according to the physical rigid body. * /
  private onFarm() {
    this.x = this.phyBody.position.x / SS - this.width / 2;
    this.y = this.phyBody.position.y / SS - this.height / 2;
  }
Copy the code

Cut the watermelon

This one is even simpler, just hide the original image and add two child nodes to the watermelon. The picture was prepared in advance.

Fruit.ts

  /* the two halves of the fruit are two different pictures. * /
  private half_pre: FYGE.Sprite;
  private half_next: FYGE.Sprite;
 /** Cut in half */
   doHalf() {
    if (this.half_next || this.half_pre) return;
    this.half_pre = this.addChild(ADD_SPRITE(getRes(RES_MAP["fruitXigua" + "1"]), -5.0));
    this.half_next = this.addChild(ADD_SPRITE(getRes(RES_MAP["fruitXigua" + "2"]), 5.0));
    this.texture.valid = false;
    FYGE.Tween.get(this.half_pre).to({ x: GUtils.getRandom(-120, -80), rotation: GUtils.getRandom(-50, -30) }, GUtils.getRandom(2000.4000));
    FYGE.Tween.get(this.half_next).to({ x: GUtils.getRandom(80.120), rotation: GUtils.getRandom(30.50) }, GUtils.getRandom(2000.4000));
    Matter.Body.setStatic(this.phyBody, false);
  }
Copy the code

And let’s see if we have a cut animation. RES_MAP[“fruitXigua” + “1”] fruitXigua [“fruitXigua” + “1”]

Now it’s only watermelon, and there are other fruits behind it, and when each fruit is cut, it’s the name of the fruit + “1”.

———— At this point, the start page is complete. Now let’s move on to the game page.

Step5: the game page

Wang Mengjia: “Bear GG, finally want to start the game, good look forward to ah” Xiong Dongqi: “think much, the front of the basic have been realized, the game page is just a simple random fruit, remember what points”

As they mentioned above, there is nothing new on the game page.

Expand the fruit

  • The name of the fruit is dynamically selected
  • Add a state of death to the fruit

Fruit.ts

  public die: boolean = false;
  /** Name of fruit */
  private fName: FRUIT_NAME;

   constructor(fName: FRUIT_NAME = "fruitXigua") {
    super(a);this.fName = fName;
    /** ** ** ** ** ** * /
    this.texture = getRes(RES_MAP[this.fName]).clone();
   }
Copy the code

The cut event is also replaced with a picture of the real Fruit and the die state Fruit. Ts doHalf

this.half_pre = this.addChild(ADD_SPRITE(getRes(RES_MAP[this.fName + "1"]), -5.0));
this.half_next = this.addChild(ADD_SPRITE(getRes(RES_MAP[this.fName + "2"]), 5.0));
this.die = true;
Copy the code

I’ve talked about all of these before. The source code is posted directly here

  • Creating the physical world
  • Produce fruit
  • Randomly initialize the fruit
  • Give the fruit a counterforce

Complete the GameScene. Ts

import Blade from ".. /components/blade";
import Fruit from ".. /components/Fruit";
import GameBg from ".. /components/GameBg";
import { RES_MAP, FRUIT_NAME, FRUIT_ARRAY, OVER_COUNT } from ".. /config/GameCfg";
import { ADD_TEXT, GUtils, _throttle } from ".. /config/GUtils";
import * as Matter from "matter-js";
import { StartScene } from "./StartScene";

export class GameScene extends FYGE.Container {
  private engine: Matter.Engine;
  private world: Matter.World;
  private render: Matter.Render;
  private runner: Matter.Runner;
  private composites: Matter.Composite;
  private gameover: boolean = false;
  /** fruit list */
  private fruits: Fruit[] = [];
  /** The maximum amount of fruit, which increases with the score */
  private fruitMax: number = 4;

  / * * level * /
  private _lv: number = 1;
  /** Level text */
  private lvText: FYGE.TextField;
  private get lv() :number {
    return this._lv;
  }
  private set lv(v: number) {
    this._lv = v;
    this.lvText && (this.lvText.text = "The first" + v + "Closed");
  }
  / * * * / knife light
  private blade: Blade;
  /** Score text */
  private scoreText: FYGE.TextField;
  /** 分数 */
  private _score: number = 0;
  private get score() :number {
    return this._score;
  }
  private set score(v: number) {
    this._score = v;
    this.scoreText && (this.scoreText.text = "Score." + v);
    FYGE.Tween.removeTweens(this.scoreText);
    FYGE.Tween.get(this.scoreText)
      .set({ scaleX: 1.scaleY: 1.anchorX: 30 })
      .to({ scaleX: 1.1.scaleY: 1.1.alpha: 1 }, 100)
      .to({ scaleX: 1.scaleY: 1 }, 50);
    if (v > 10) {
      this.lv = Math.ceil(v / 10); }}/** Quantity lost */
  private _dieCount: number = 0;
  / * * * / text
  private dieCountText: FYGE.TextField;
  private get dieCount() :number {
    return this._dieCount;
  }
  private set dieCount(v: number) {
    this._dieCount = v;
    this.dieCountText && (this.dieCountText.text = "Lost." + v);
    // Too many lost, game over
    v >= OVER_COUNT &&
      (this.gameover = true) &&
      setTimeout(() = > {
        this.gameOver();
      }, 17);
  }
  constructor() {
    super(a);this.addEventListener(
      FYGE.Event.ADDED_TO_STAGE,
      () = > {
        this.addEvents();
        this.initGame();
      },
      this
    );
  }

  /* initialize game */
  initGame() {
    this.addChild(new GameBg());

    this.blade = this.addChild(new Blade());

    this.createPhyWorld();
    /** generate fruit */
    this.genneratorFruit();
    / * * level * /
    this.lvText = this.addChild(ADD_TEXT("Level 1".30."#66ff00".50.50));
    /** 分数 */
    this.scoreText = this.addChild(ADD_TEXT(Score: "0".40."#66ffff".this.stage.width / 2 - 50.50));
    /** Lose the runaway fruit */
    this.dieCountText = this.addChild(ADD_TEXT("Lost: 0".30."#ff3399".this.stage.width - 150.50));
  }
  /** Create the physical world */
  private createPhyWorld() {
    const { Engine, Render, Runner, Composite, Bodies, World, Composites } = Matter;
    this.engine = Engine.create();
    this.world = this.engine.world;
    this.engine.gravity.y = 0.5;
    // Temporary rendering engine for debugging
    var render = Render.create({
      element: document.body,
      engine: this.engine,
      options: {
        width: 750.height: 600,}}); Render.run(render);/** Really run */
    this.runner = Runner.create();
    Runner.run(this.runner, this.engine);
    // @ts-ignore
    this.composites = Composite;
    Matter.Events.on(this.engine, "collisionStart".this.onCollisionStart.bind(this));
  }
  /** Generate fruit */
  genneratorFruit() {
    if (this.gameover) return;
    while (this.fruits.length < this.fruitMax + this.lv) {
      this.randomFruit(); }}/** Random fruit */
  randomFruit() {
    let index = Math.floor(Math.random() * FRUIT_ARRAY.length);
    let fruit = new Fruit(FRUIT_ARRAY[index]);

    fruit.fx = GUtils.getRandom(this.stage.width * 0.25.this.stage.width * 0.75);
    fruit.fy = this.stage.height;
    // @ts-ignore
    this.composites.add(this.world, [fruit.phyBody]);

    FYGE.Tween.get(fruit, { loop: true }).to({ rotation: 360 }, 4000);

    this.addChild(fruit);
    // Timeout is for debugging
    setTimeout(() = > {
      if (!this.stage) return;
      Matter.Body.setStatic(fruit.phyBody, false);
      let sh = this.stage.height / 1334;
      Matter.Body.setVelocity(fruit.phyBody, { x: GUtils.getRandom(-3.3), y: GUtils.getRandom(-15 * sh, -12 * sh) });
    }, GUtils.getRandom(0.2000));
    this.fruits.push(fruit);
  }

  / * * *@description: Collision detection */
  onCollisionStart(e) {
    let pairs = e.pairs;
    pairs.map((p) = > {
      let needHalf = this.fruits.find((fruit) = >fruit.phyBody? .id === p.bodyA? .id); ! needHalf? .die && (this.score += 1) && needHalf? .doHalf(); }); }/** Mouse move */
  private onMouseMove(e) {
    _throttle(this.blade.drawBlade(e), 50);
    /** * use hitTestPoint for collision detection. * /
    // this.blade.checkCollide(this.xigua, this.doStart.bind(this));
  }

  /** Listen for events */
  private addEvents() {
    this.stage.addEventListener(FYGE.MouseEvent.MOUSE_MOVE, this.onMouseMove, this);
    this.stage.addEventListener(FYGE.MouseEvent.MOUSE_UP, this.onMouseUp, this);
    this.addEventListener(FYGE.Event.ENTER_FRAME, this.onFarm, this);
  }

  Remove event */
  private removeEvents() {
    this.stage.removeEventListener(FYGE.MouseEvent.MOUSE_MOVE, this.onMouseMove, this);
    this.stage.removeEventListener(FYGE.MouseEvent.MOUSE_UP, this.onMouseUp, this);
    this.removeEventListener(FYGE.Event.ENTER_FRAME, this.onFarm, this);
  }

  private onMouseUp() {
    this.blade.reset();
  }

  private onFarm() {
    this.fruits.map((f, i) = > {
      if (f.y > this.stage? .height) {console.log("You go", f.die);
        // If the fruit is still alive, it will lose +1
        if(! f.die) {this.dieCount += 1;
        }
        this.removeChild(f);
        this.fruits.splice(i, 1);
        this.genneratorFruit(); }}); }/ * * *@description: Game over */
  private gameOver() {
    this.removeEvents();
    this.removeAllChildren();

    alert("Game over.");
    // @ts-ignore
    this.composites.clear(this.world, true);
    Matter.Runner.stop(this.runner);
    Matter.Engine.clear(this.engine);
    this.parent.addChild(new StartScene());
    this.parent.removeChild(this); }}Copy the code

Here’s an extension of the rules of collision detection like fruit can’t collide with fruit. We use the Group attribute of collisionFilter directly. If more complex can refer to the following

Collision detection rule

Simple collision relationship, directly set group can be complex collision relationship, you can set category and mask value to match, to make a very advanced collision relationship

Matter collision Provides the collisionFilter attribute, which supports three types of attributes: group category mask

If the two groups are equal

If any group is greater than zero, they’re always colliding with each other, so let’s say they’re all 1, they’re always colliding with each other and if any group is less than 0, let’s say they’re all negative 1, they’re never colliding with each other, and that’s what we’re using for our fruit to divide these two cases, we’re going to do it by category and mask

If the two groups are not equal

Category represents a collision classification, and its value can be 1,2,4,8… Up to 2^31, each rigidbody sets a value, mask, which is the collision set (category set), which is the result of the category, for example, if you accept type 2, 4, and the value is 6, if a and B collide, a’s mask must contain b’s category, At the same time, the mask of B must also contain the category of A, that is (A. ategory & B.ask)! == 0 && (b.category & a.mask) ! = = 0

For more rules, go to the API.

conclusion

At this point, the simple game of cutting fruit is over. The logic is crude. For learning communication only.

Xiong Dongqi: “I’m all right, have you learned fei?” Wang Mengjia: “Nothing ~”