使用物理引擎matterjs实现键盘特效动画

2023-10-23 15:57:41 浏览数 (1)

使用物理引擎matterjs实现键盘特效动画

前言

偶然间看到一个网站,觉得这个动画很炫酷。就收藏了一下,在稍微学习了一下matterjs后,打算跟着源码学习,弄懂并且自己实现一个。

准备

先安装matter-jswebpack,并且稍微配置一下webpack

代码语言:javascript复制
const path = require("path");

module.exports = {
  entry: path.join(__dirname, "src", "index.js"),
  mode: "development",
  output: {
    filename: "bundle.js",
    path: path.join(__dirname),
  },
  devServer: {
    hot: true,
    static: {
      directory: path.join(__dirname),
    },
  }
}

Matterjs初体验

根据Matterjs官网的demo,可以知道Matterjs的使用主要分为几个步骤:

  1. 创建引擎
  2. 创建渲染器
  3. 创建物体
  4. 将物体添加到引擎的world
  5. 执行渲染器
  6. 创建执行器runner,并运行引擎(如果没有这一步,则没有办法触发物理动画)

index.html

代码语言:javascript复制
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    html, body {
      padding: 0;
      margin: 0;
      overflow: hidden;
    }
  </style>
</head>
<body>
  <div class="content"></div>
  <script src="./bundle.js"></script>
</body>
</html>

代码语言:javascript复制
import Matter from 'matter-js';

// module aliases
const Engine = Matter.Engine;
const Render = Matter.Render;
const Runner = Matter.Runner;
const Bodies = Matter.Bodies;
const Composite = Matter.Composite;

const WIDTH = window.innerWidth;
const HEIGHT = window.innerHeight;

// 1. 创建引擎
const engine = Engine.create();

// 2. 创建渲染器
const render = Render.create({
  element: document.querySelector('.content'),
  engine: engine,
  options: {
    width: WIDTH,
    height: HEIGHT,
    background: '#222'
  }
});

// 3. 创建物体
const boxA = Bodies.rectangle(600, 200, 200, 200);
const boxB = Bodies.rectangle(800, 300, 80, 80);
const ground = Bodies.rectangle(700, 400, 800, 40, { isStatic: true });

// 4. 将物体添加到引擎的`world`中
Composite.add(engine.world, [boxA, boxB, ground]);

// 5. 执行渲染器
Render.run(render);

// 6. 创建执行器runner,并运行引擎
const runner = Runner.create();
Runner.run(runner, engine);

添加键盘事件,每次点击生成一个物体

代码语言:javascript复制
document.body.addEventListener('keydown', (e) => {
  const ball = Bodies.circle(400, 200, 50, {
    restitution: 0.9,  // 设置弹力
    friction: 0.001    // 设置摩檫力,默认为0.5
  });

  Composite.add(engine.world, ball);
})

添加力

代码语言:javascript复制
document.body.addEventListener('keydown', (e) => {
  const ball = Bodies.circle(400, 200, 50, {
    restitution: 0.9,
    friction: 0.001    // 设置摩檫力,默认为0.5
  });

  const vector = {
    x: (Date.now() % 10) * 0.004  - 0.02,
    y: (-1 * HEIGHT / 3200)
  };

  Matter.Body.applyForce(ball, ball.position, vector);

  Composite.add(engine.world, ball);
})

上面的vector就是力。y轴方向上,力会根据窗口高度变化的一个,而x轴方向上,力会随着时间的变化而发生变化,力的区间为[-0.02, 0.02],将它分成10部分,根据时间戳来获取x轴方向上的力。(Date.now() % 10) * 0.004 - 0.02

下面的演示用的里的区间是[-0.2, 0.2],(Date.now() % 10) * 0.04  - 0.2(为了看的效果明显点)

添加倾斜边界

添加倾斜边界,让弹出来的球能够慢慢消失。

代码语言:javascript复制
const OFFSET = 1;  // 使用的是Matterjs 0.10.0。如果是最新的,可能要设置成10
const boundaries = generateBoundaries();
Composite.add(engine.world, boundaries);

function generateBoundaries() {
  return [
    Bodies.rectangle(WIDTH / 4, HEIGHT - 400, WIDTH / 2, OFFSET, {
      angle: -0.1,
      isStatic: true,
      friction: 0.001    // 设置摩檫力,默认为0.5
      render: {
        fillStyle: '#fff'
      }
    }),

    Bodies.rectangle((WIDTH / 4) * 3, HEIGHT - 400, WIDTH / 2, OFFSET, {
      angle: 0.1,
      isStatic: true,
      friction: 0.001    // 设置摩檫力,默认为0.5
      render: {
        fillStyle: '#fff'
      }
    }),
  ]
}

添加兜底边界

当我们添加倾斜边界时,球会越变越多,包括消失的球也没有进行处理。所以可以添加一个兜底边界,后面再添加碰撞事件,清除接触到兜底边界的小球。

下面的例子会先将倾斜边界变短,查看实际效果

代码语言:javascript复制
function generateBoundaries() {
  return [
    Bodies.rectangle(WIDTH / 4, HEIGHT - 400, WIDTH / 4, OFFSET, {
      angle: -0.1,
      isStatic: true,
      friction: 0.001    // 设置摩檫力,默认为0.5
      render: {
        fillStyle: '#fff'
      }
    }),

    Bodies.rectangle((WIDTH / 4) * 3, HEIGHT - 400, WIDTH / 4, OFFSET, {
      angle: 0.1,
      isStatic: true,
      friction: 0.001    // 设置摩檫力,默认为0.5
      render: {
        fillStyle: '#fff'
      }
    }),

    Bodies.rectangle(WIDTH / 2, HEIGHT - 200, WIDTH, OFFSET, {
      isStatic: true,
      friction: 0.001    // 设置摩檫力,默认为0.5
      render: {
        fillStyle: '#fff'
      }
    })
  ]
}

添加一个platform变量用来存储兜底边界,便于实现碰撞检测。

代码语言:javascript复制
const OFFSET = 1;  // 使用的是Matterjs 0.10.0。如果是最新的,可能要设置成10
const boundaries = generateBoundaries();
Composite.add(engine.world, boundaries);

let platform = boundaries[2];
Matter.Events.on(engine, 'collisionStart', (e) => {
  e.pairs.forEach(function (pair) {  // e.pairs表示发生碰撞的两个物体
    const bodyA = pair.bodyA;
    const bodyB = pair.bodyB;

    if (bodyA === platform) {   // bodyA是兜底边界,则将bodyB从engine的世界中移除。
      Composite.remove(engine.world, [bodyB]);
    }

    if (bodyB === platform) {
      Composite.remove(engine.world, [bodyA]);
    }
  })
})

位置设定

上面的实现会导致不论点击什么键,小球出现的位置都是固定的,所以进行一个位置设定的操作,来实现点击键盘后,位置和键盘上的位置差不多一样。

首先,需要构建一个二维数组,元素的值则是每一行键盘对应的顺序(功能键为null

代码语言:javascript复制
const KEYS = [
// 字母键和符号键
['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', null],
[null, 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '[', ']', '\'],
[null, 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ';', ''', null],
[null, null, 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '.', '/', null, null],

// 数字键
[null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-/', 'num-*', 'num--'],
[null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-7', 'num-8', 'num-9', 'num- '],
[null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-4', 'num-5', 'num-6', null],
[null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-1', 'num-2', 'num-3', null],
[null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-0', null, 'num-.', null]
]; 

遍历每一个键,设定好每个键的位置。

代码语言:javascript复制
const positions = {};

// 生成键盘字母对应的位置
generatePositions();

function generatePositions() {
KEYS.forEach((row) => {
  row.forEach((letter, i) => {
    if (!letter) {
      return; // 功能键,忽略
    }

    positions[letter] = ((i / row.length)   (0.5 / row.length)) * WIDTH;
  })
})
}

((i / row.length) (0.5 / row.length)) * WIDTHi是点在行中的索引,row.length是该行中点的总数,(i / row.length) * WIDTH的值就是该点应该在的位置,但是这样子得到的将不会是对应单元格的中心位置,而是左侧边缘的位置,所以还应该加半个单元格的宽度,即(0.5 / row.length) * WIDTH

使用vkey库,将数字键变为<num-0>的形式,用来与一般的区分,并且会将所有的英文变成大写。

代码语言:javascript复制
document.body.addEventListener('keydown', (e) => {
let key = vkey[e.keyCode];

if (key == null) {
  return;
}

key = key.replace(/</g, '').replace(/>/g, '');  // 将`<num-0>`形式变为`num-0`

if (key in positions) {
  addLetter(key, positions[key], HEIGHT - 50);
}
})

function addLetter(key, x, y) {
const ball = Bodies.circle(x, y, 30, {
  restitution: 0.9
});

const vector = {
  x: (Date.now() % 10) * 0.004 - 0.02,
  y: (-1 * HEIGHT / 3600)
};

Matter.Body.applyForce(ball, ball.position, vector);

Composite.add(engine.world, [ball]);
}

小球换成图片

通过render.sprite.texture来设置图片。

代码语言:javascript复制
const ball = Bodies.circle(x, y, 30, {
  restitution: 0.9,
  friction: 0.001    // 设置摩檫力,默认为0.5
  render: {
    sprite: {
      texture: getImagePath(key)
    }
  }
}); 

function getImagePath(key) {
  if (key.indexOf('num-') === 0) {
    key = key.substring(4);
  }

  if (key === '*') key = 'star';
  if (key === ' ') key = 'plus';
  if (key === '.') key = 'dot';
  if (key === '/') key = 'slash';
  if (key === '\') key = 'backslash';

  return './img/'   key   '.png';
}

color{red}{还需要修改一下创建渲染器时的设置}

代码语言:javascript复制
const render = Render.create({
  element: document.querySelector('.content'),
  engine: engine,
  options: {
    width: WIDTH,
    height: HEIGHT,
    background: '#222',
    wireframes: false,  // 关闭线框
  }
})

变化边界长度,并隐藏

最下面的边界长度为WIDTH * 4,这样子为了避免内存泄漏,把一些球给移除。

代码语言:javascript复制
function generateBoundaries() {
  return [
    Bodies.rectangle(WIDTH / 4, HEIGHT   50, WIDTH / 2, OFFSET, {
      angle: -0.1,
      isStatic: true,
      friction: 0.001    // 设置摩檫力,默认为0.5
      render: {
        fillStyle: '#fff',
        visible: false
      }
    }),

    Bodies.rectangle((WIDTH / 4) * 3, HEIGHT   50, WIDTH / 2, OFFSET, {
      angle: 0.1,
      isStatic: true,
      friction: 0.001    // 设置摩檫力,默认为0.5
      render: {
        fillStyle: '#fff',
        visible: false
      }
    }),

    Bodies.rectangle(WIDTH / 2, HEIGHT   400, WIDTH * 4, OFFSET, {
      isStatic: true,
      friction: 0.001    // 设置摩檫力,默认为0.5
      render: {
        fillStyle: '#fff',
        visible: false
      }
    })
  ]
}

抽离初始化步骤,并在窗口大小变化时重新初始化

如果在串口大小变化时,不重新初始化,就会导致一些字符并不会在窗口中看得见。

代码语言:javascript复制
let WIDTH;
let HEIGHT;

let engine = null;
let render = null;

let boundaries = null;
let platform = null;

const KEYS = [
  // 字母键、符号键
  ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', null],
  [null, 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '[', ']', '\'],
  [null, 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ';', ''', null],
  [null, null, 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '.', '/', null, null],

  // 数字键
  [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-/', 'num-*', 'num--'],
  [null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-7', 'num-8', 'num-9', 'num- '],
  [null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-4', 'num-5', 'num-6', null],
  [null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-1', 'num-2', 'num-3', null],
  [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-0', null, 'num-.', null]
];
const positions = {};

function init() {
  WIDTH = window.innerWidth;
  HEIGHT = window.innerHeight;

  if (!engine) {
    engine = Engine.create();
  }

  // 生成键盘字母对应的位置
  generatePositions();

  // 如果渲染器不存在,则创建
  if (!render) {
    render = Render.create({
      element: document.querySelector('.content'),
      engine: engine,
      options: {
        width: WIDTH,
        height: HEIGHT,
        background: '#222',
        wireframes: false
      }
    });
  } else {
    // 如果渲染器已存在,则获取渲染器的画布对象,并重新设置宽度和高度
    const canvas = render.canvas;

    // 设置画布的宽度和高度
    canvas.width = WIDTH;
    canvas.height = HEIGHT;
  }

  render.options.width = WIDTH;
  render.options.height = HEIGHT;

  // 如果有边界,那就移除
  if (boundaries) {
    Composite.remove(engine.world, boundaries)
  }

  boundaries = generateBoundaries();
  Composite.add(engine.world, boundaries);

  platform = boundaries[2];
}

init();
window.addEventListener('resize', init);

添加音效

代码语言:javascript复制
<body>
  <div class="content"></div>
  <audio id="type" src="./type.mp3"></audio>
  <script src="./bundle.js"></script>
</body>

代码语言:javascript复制
function playSound(name) {
  const sound = document.getElementById(name);
  sound.play();
}

function addLetter(key, x, y) {
  playSound('type'); 
  ...
}

预加载图片

当实际部署上线后,会发现,一开始点击键盘时,是没有反应的。因为当我们点击时,才去请求对应图片。所以可以做一个预加载的操作,优化一下。

预加载就可以通过新建Image,并设置src值来实现

代码语言:javascript复制
function preloadImg (url) {
  const img = new window.Image()
  img.src = url
}

当遍历设置字符位置时,就能够进行一个预加载操作。

代码语言:javascript复制
function generatePositions() {
  KEYS.forEach((row) => {
    row.forEach((letter, i) => {
      if (!letter) {
        return; // 功能键,忽略
      }

      positions[letter] = ((i / row.length)   (0.5 / row.length)) * WIDTH;

       // 预加载图片
       preloadImg(getImagePath(letter));
    })
  })
}

添加下雨模式

通过使用lastKeys来记录点击过的字符,当最后点击的字符为rain时(不区分大小写),开启一个下雨模式。而下雨模式也很简单,只需要重新设置力vector,和球的坐标即可。

代码语言:javascript复制
function secretWords(key) {
  lastKeys = lastKeys.slice(-3)   key;

  if (lastKeys === 'RAIN') {
    rainMode = !rainMode;

    if (rainMode) {
      playSound('rain');
    }
  }
}

代码语言:javascript复制
function addLetter(key, x, y) {
  playSound('type');

  const ball = Bodies.circle(x, y, 30, {
    restitution: 0.9,
    friction: 0.001,
    render: {
      sprite: {
        texture: getImagePath(key)
      }
    }
  });

  let vector = {
    x: (Date.now() % 10) * 0.004 - 0.02,
    y: (-1 * HEIGHT / 3600)
  }; 

  // 新增。下雨模式重新设置坐标,以及力vector
  if (rainMode) {
    vector = {
      x: 0,
      y: 0
    };

    Matter.Body.setPosition(ball, {
      x: ball.position.x,
      y: -30
    })
  }

  secretWords(key);
  // 新增。

  Matter.Body.applyForce(ball, ball.position, vector);

  Composite.add(engine.world, [ball]);
}

完整代码

代码语言:javascript复制
import Matter from 'matter-js';
import vkey from 'vkey';

// module aliases
const Engine = Matter.Engine;
const Render = Matter.Render;
const Runner = Matter.Runner;
const Bodies = Matter.Bodies;
const Composite = Matter.Composite;

const OFFSET = 1;  // 使用的是Matterjs 0.10.0。如果是最新的,可能要设置成10

let WIDTH;
let HEIGHT;

let engine = null;
let render = null;

let boundaries = null;
let platform = null;

const KEYS = [
  // 字母键、符号键
  ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', null],
  [null, 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '[', ']', '\'],
  [null, 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ';', ''', null],
  [null, null, 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '.', '/', null, null],

  // 数字键
  [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-/', 'num-*', 'num--'],
  [null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-7', 'num-8', 'num-9', 'num- '],
  [null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-4', 'num-5', 'num-6', null],
  [null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-1', 'num-2', 'num-3', null],
  [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 'num-0', null, 'num-.', null]
];
const positions = {};

function init() {
  WIDTH = window.innerWidth;
  HEIGHT = window.innerHeight;

  if (!engine) {
    engine = Engine.create();
  }

  // 生成键盘字母对应的位置
  generatePositions();

  // 如果渲染器不存在,则创建
  if (!render) {
    render = Render.create({
      element: document.querySelector('.content'),
      engine: engine,
      options: {
        width: WIDTH,
        height: HEIGHT,
        background: '#222',
        wireframes: false
      }
    });
  } else {
    // 如果渲染器已存在,则获取渲染器的画布对象,并重新设置宽度和高度
    const canvas = render.canvas;

    // 设置画布的宽度和高度
    canvas.width = WIDTH;
    canvas.height = HEIGHT;
  }

  render.options.width = WIDTH;
  render.options.height = HEIGHT;

  // 如果有边界,那就移除
  if (boundaries) {
    Composite.remove(engine.world, boundaries)
  }

  boundaries = generateBoundaries();
  Composite.add(engine.world, boundaries);

  platform = boundaries[2];
}

init();
window.addEventListener('resize', init);

Composite.add(engine.world, boundaries);

// 5. 执行渲染器
Render.run(render);

// 6. 创建执行器runner,并运行引擎
const runner = Runner.create();
Runner.run(runner, engine);


Matter.Events.on(engine, 'collisionStart', (e) => {
  e.pairs.forEach(function (pair) {  // e.pairs表示发生碰撞的两个物体
    const bodyA = pair.bodyA;
    const bodyB = pair.bodyB;

    if (bodyA === platform) {   // bodyA是兜底边界,则将bodyB从engine的世界中移除。
      Composite.remove(engine.world, [bodyB]);
    }

    if (bodyB === platform) {
      Composite.remove(engine.world, [bodyA]);
    }
  })
});

document.body.addEventListener('keydown', (e) => {
  let key = vkey[e.keyCode];

  if (key == null) {
    return;
  }

  key = key.replace(/</g, '').replace(/>/g, '');

  if (key in positions) {
    addLetter(key, positions[key], HEIGHT - 50);
  }
})

function generatePositions() {
  KEYS.forEach((row) => {
    row.forEach((letter, i) => {
      if (!letter) {
        return; // 功能键,忽略
      }

      positions[letter] = ((i / row.length)   (0.5 / row.length)) * WIDTH;
    
       // 预加载图片
       preloadImg(getImagePath(letter));
    })
  })
}

let rainMode = false;
function addLetter(key, x, y) {
  playSound('type');

  const ball = Bodies.circle(x, y, 30, {
    restitution: 0.9,
    friction: 0.001,
    render: {
      sprite: {
        texture: getImagePath(key)
      }
    }
  });

  let vector = {
    x: (Date.now() % 10) * 0.004 - 0.02,
    y: (-1 * HEIGHT / 3600)
  }; 

  // 新增。下雨模式重新设置坐标,以及力vector
  if (rainMode) {
    vector = {
      x: 0,
      y: 0
    };

    Matter.Body.setPosition(ball, {
      x: ball.position.x,
      y: -30
    })
  }

  secretWords(key);
  // 新增。

  Matter.Body.applyForce(ball, ball.position, vector);

  Composite.add(engine.world, [ball]);
}

function getImagePath(key) {
  if (key.indexOf('num-') === 0) {
    key = key.substring(4);
  }

  if (key === '*') key = 'star';
  if (key === ' ') key = 'plus';
  if (key === '.') key = 'dot';
  if (key === '/') key = 'slash';
  if (key === '\') key = 'backslash';

  return './img/'   key   '.png';
}

function generateBoundaries() {
  return [
    Bodies.rectangle(WIDTH / 4, HEIGHT   50, WIDTH / 2, OFFSET, {
      angle: -0.1,
      isStatic: true,
      render: {
        fillStyle: '#fff',
        visible: false
      }
    }),

    Bodies.rectangle((WIDTH / 4) * 3, HEIGHT   50, WIDTH / 2, OFFSET, {
      angle: 0.1,
      isStatic: true,
      render: {
        fillStyle: '#fff',
        visible: false
      }
    }),

    Bodies.rectangle(WIDTH / 2, HEIGHT   400, WIDTH * 4, OFFSET, {
      isStatic: true,
      render: {
        fillStyle: '#fff',
        visible: false
      }
    })
  ]
}

function playSound(name) {
  const sound = document.getElementById(name);
  sound.play();
}

function preloadImg (url) {
  const img = new window.Image()
  img.src = url
}

let lastKeys = '';
function secretWords(key) {
  lastKeys = lastKeys.slice(-3)   key;

  if (lastKeys === 'RAIN') {
    rainMode = !rainMode;

    if (rainMode) {
      playSound('rain');
    }
  }
}

0 人点赞