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


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

前言

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

准备

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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);

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

1
2
3
4
5
6
7
8
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);
})

添加力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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(为了看的效果明显点)

添加倾斜边界

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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'
}
}),
]
}

添加兜底边界

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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变量用来存储兜底边界,便于实现碰撞检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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]);
}
})
});

位置设定

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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]
    ];
  2. 遍历每一个键,设定好每个键的位置。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    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来设置图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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}{还需要修改一下创建渲染器时的设置}

1
2
3
4
5
6
7
8
9
10
const render = Render.create({
element: document.querySelector('.content'),
engine: engine,
options: {
width: WIDTH,
height: HEIGHT,
background: '#222',
wireframes: false, // 关闭线框
}
});

变化边界长度,并隐藏

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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
}
})
]
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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);

添加音效

1
2
3
4
5
<body>
<div class="content"></div>
<audio id="type" src="./type.mp3"></audio>
<script src="./bundle.js"></script>
</body>
1
2
3
4
5
6
7
8
9
function playSound(name) {
const sound = document.getElementById(name);
sound.play();
}

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

预加载图片

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

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

1
2
3
4
function preloadImg (url) {
const img = new window.Image()
img.src = url
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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,和球的坐标即可。

1
2
3
4
5
6
7
8
9
10
11
function secretWords(key) {
lastKeys = lastKeys.slice(-3) + key;

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

if (rainMode) {
playSound('rain');
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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]);
}

完整代码

1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
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');
}
}
}

文章作者: 赤蓝紫
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 赤蓝紫 !
评论
  目录