【可视化学习】95-跳一跳小游戏(一)
发表于:2024-11-26 |

前言

今天和大家一起分享一下一个小案例,如何利用 3D 知识模拟一个跳一跳小游戏,话不多说,这就开始吧。

实现玩家和盒子生成

初始化 Three 项目

这个我介绍很多次了,这里不再阐述。

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
<template>
<div></div>
</template>

<script setup>
import * as THREE from "three";
// 1.创建场景
const scene = new THREE.Scene();

// 2.创建相机
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);

// 设置相机位置
camera.position.z = 3;

// 添加相机
scene.add(camera);

// 初始化渲染器
const renderer = new THREE.WebGLRenderer({
antialias: true, // 开启抗锯齿
});

// 设置渲染器的背景颜色
renderer.setClearColor("#d3d3d3");

// 开启阴影效果
renderer.shadowMap.enabled = true;

//设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);

// 将webgl渲染的canvas内容添加到body上
document.body.appendChild(renderer.domElement);

// 添加世界坐标辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

// 持续渲染,让相机场景动起来
function render() {
// 使用渲染器通过相机将场景渲染进来
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

// 监听画面的变化,更新渲染的画面
window.addEventListener("resize", () => {
// 更新摄像头
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置渲染器的像素比
renderer.setPixelRatio(window.devicePixelRatio);
});
</script>

<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

canvas {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
}
</style>

此时我们就有了一个最简单的背景色的 three 项目
初始化

创建一个基本的盒子

接下来我们创建一个普通的立方体
1
2
3
4
5
6
7
8
9
10
function createCube(x, z) {
const geometry = new THREE.BoxGeometry(30, 20, 30);
const material = new THREE.MeshBasicMaterial({ color: 0x333333 });
const cube = new THREE.Mesh(geometry, material);
cube.position.x = x;
cube.position.z = z;
scene.add(cube);
}

createCube(0, 0);
然后我们调整一下视角
1
2
3
4
5
6
7
8
9
10
// 2.创建相机
const camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
1000
);

// 设置相机位置
camera.position.set(100, 100, 100);

添加一个盒子

添加多个盒子

接下来根据添加盒子的方法,添加多个盒子

1
2
3
4
5
6
7
createCube(0, 0);
createCube(0, -100);
createCube(0, -200);
createCube(0, -300);
createCube(-100, 0);
createCube(-200, 0);
createCube(-300, 0);

添加多个盒子

创建玩家小人

1
2
3
4
5
6
7
8
9
10
11
12
13
let player;
function createPlayer() {
const geometry = new THREE.BoxGeometry(5, 15, 5);
const material = new THREE.MeshBasicMaterial({ color: 0x000000 });
const player = new THREE.Mesh(geometry, material);
player.position.x = 0;
player.position.y = 17.5;
player.position.z = 0;
scene.add(player);
return player;
}

player = createPlayer();

创建小人

解释下为啥 y 方向是 17.5

这里简单讲解一下 y 方向的值为什么设置为17.5,这是因为我们设置了立方体(盒子)的高度为 20,实际上他只有 10 是大于 0 的,而我们给小人设置了 15 的 y 方向的高度,所以我们要让小人站在立方体(盒子)上,就需要 10+7.5 的 y 方向的值,也就是 17.5。

图示:
原先位置

最终位置

实现基本的相机跟随跳跃效果

实现逻辑分析

这里只需要我们将相机和玩家小人一起运动即可

小人移动

暂时先把逻辑写死,就写沿着 z 方向运动的逻辑

我们每次点击的时候,把小人的 z 方向的值-100即可

1
2
3
window.addEventListener("click", () => {
player.position.z -= 100;
});

相机同时移动

1
2
3
4
5
6
7
8
9
10
11
12
13
// 相机目标点
let focusPos = { x: 0, y: 0, z: 0 };

window.addEventListener("click", () => {
// 小人移动
player.position.z -= 100;
// 相机移动
camera.position.z -= 100;
// 目标点移动
focusPos.z -= 100;
// 相机看向目标点
camera.lookAt(focusPos.x, focusPos.y, focusPos.z);
});

动画帧完善跳跃动画

我们在上面已经基本实现了简单的跳跃逻辑,现在我们将这个跳跃完善一下。

调整相机

现在的位置是直接变到下一个方块,太突兀了,得有个动画的过程。

记录起始结束位置

1
2
3
4
const targetCameraPos = { x: 100, y: 100, z: 100 };

const cameraFocus = { x: 0, y: 0, z: 0 };
const targetCameraFocus = { x: 0, y: 0, z: 0 };

从一个位置到另一个位置,显然需要起点和终点坐标。

摄像机的当前位置可以从 camera.position 来取,而目标位置我们通过 targetCameraPos 变量保存。

焦点的起始位置是 cameraFocus,结束位置是 targetCameraFocus

添加移动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function moveCamera() {
const { x, z } = camera.position;
if (x > targetCameraPos.x) {
camera.position.x -= 3;
}
if (z > targetCameraPos.z) {
camera.position.z -= 3;
}

if (cameraFocus.x > targetCameraFocus.x) {
cameraFocus.x -= 3;
}
if (cameraFocus.z > targetCameraFocus.z) {
cameraFocus.z -= 3;
}

camera.lookAt(cameraFocus.x, cameraFocus.y, cameraFocus.z);
}

function render() {
moveCamera();
renderer.render(scene, camera);
requestAnimationFrame(render);
}

如果摄像机没有达到目标位置,就每次渲染移动 3.

焦点位置也是同步移动。

每次 render 的时候调用下,这样每帧都会移动摄像机。

然后当点击的时候,玩家移动,并且设置摄像机的位置和焦点的目标位置

1
2
3
4
5
6
7
window.addEventListener("click", () => {
player.position.z -= 100;

targetCameraPos.z = camera.position.z - 100;

targetCameraFocus.z -= 100;
});

调整小人

现在玩家是直接移动过去的,没有一个跳的过程。我们补充上跳的过程:

记录起始结束位置

要把起始位置和结束位置记录下来:

1
2
3
4
5
const playerPos = { x: 0, y: 17.5, z: 0 };
const targetPlayerPos = { x: 0, y: 17.5, z: 0 };

let player;
let speed = 0;

写小人移动方法

同样的方式写个 movePlayer 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function movePlayer() {
if (player.position.x > targetPlayerPos.x) {
player.position.x -= 3;
}
if (player.position.z > targetPlayerPos.z) {
player.position.z -= 3;
}
player.position.y += speed;
speed -= 0.3;
if (player.position.y < 17.5) {
player.position.y = 17.5;
}
}

function render() {
moveCamera();
movePlayer();
renderer.render(scene, camera);
requestAnimationFrame(render);
}

如果 player 的位置没有到目标位置就移动,并且这里在 y 方向还有个 speed,只不过每次渲染 speed 减 0.3。

然后在点击的时候不再直接改变 player 位置,而是设置 targetPlayerPos 并且设置一个 speed:

1
2
3
4
5
6
7
8
window.addEventListener("click", () => {
targetCameraPos.z = camera.position.z - 100;

targetCameraFocus.z -= 100;

targetPlayerPos.z -= 100;
speed = 5;
});

动态生成盒子

我们现在的盒子是写死的,这样肯定是不对的,这个盒子需要动态生成,并且方向不固定,可能是向左,也可能是向右。

记录参数

首先,我们记录一下当前生成盒子的参数

1
2
3
4
// 当前方块位置
const currentCubePos = { x: 0, y: 0, z: -100 };
// 方块方向
let direction = "right";

添加生成逻辑

点击的时候判断下,如果是向右就改变 z 的位置,否则改变 x 的位置。

然后生成下一个方块,也是随机向左或者向右。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
window.addEventListener("click", () => {
if (direction === "right") {
targetCameraPos.z = camera.position.z - 100;
targetCameraFocus.z -= 100;
targetPlayerPos.z -= 100;
} else {
targetCameraPos.x = camera.position.x - 100;
targetCameraFocus.x -= 100;
targetPlayerPos.x -= 100;
}

speed = 5;

const num = Math.random();
if (num > 0.5) {
currentCubePos.z -= 100;
direction = "right";
} else {
currentCubePos.x -= 100;
direction = "left";
}
createCube(currentCubePos.x, currentCubePos.z);
});

我们这里这保留前俩个盒子,后面的盒子都通过点击来创建。

添加蓄力功能

添加初始速度的添加

每次跳的时候,在随机方向生成一个新方块,我们现在是通过click还进行添加的,可以改成mousedown,mouseup来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
let pressed = false;
let speed = 0;
let speedY = 0;

window.addEventListener("mousedown", () => {
speed = 0;
speedY = 0;
pressed = true;
});
window.addEventListener("mouseup", () => {
pressed = false;
console.log(speed);
});

在 mouseup 的时候标记 pressed 为 false,并且打印速度。

1
2
3
4
5
6
7
8
9
10
11
12
function speedUp() {
if (pressed) {
speed += 0.1;
speedY += 0.1;
}
}

function render() {
speedUp();
renderer.render(scene, camera);
requestAnimationFrame(render);
}

然后按下的时候每帧都增加速度。
速度

按下一段时间再松开,这时会打印现在的速度,这就是蓄力。
为什么有两个速度呢?
因为蓄力之后有两个方向的移动,一个是 x 轴或者 z 轴,一个是 y 轴。

移动小人

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function movePlayer() {
player.position.y += speedY;

if (player.position.y < 17.5) {
player.position.y = 17.5;
} else {
if (direction === "right") {
player.position.z -= speed;
} else {
player.position.x -= speed;
}
}
speedY -= 0.3;
}

现在只有非 pressed 状态才会移动,按下的时候不移动。

移动的截止条件就是 y 轴到了 17.5,也就是平台高度,这个时候就要判断是否跳到了下一个平台。

移动相机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function moveCamera() {
if (player.position.y > 17.5) {
if (direction === "right") {
camera.position.z -= speed;
cameraFocus.z -= speed;
} else {
camera.position.x -= speed;
cameraFocus.x -= speed;
}

camera.lookAt(cameraFocus.x, cameraFocus.y, cameraFocus.z);
}
}

function render() {
if (!pressed) {
moveCamera();
movePlayer();
}
speedUp();

renderer.render(scene, camera);
requestAnimationFrame(render);
}

结束条件同样是玩家跳到了平台高度。

然后相机的位置和焦点的 x 或者 z 轴同步移动。

完善跳跃

修改方块生成距离

当玩家的 y 到了 17.5 的时候,生成下一个方块。

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
let stopped = true;
if (player.position.y < 17.5) {
player.position.y = 17.5;

if (stopped === false) {
// 生成50-150的随机距离
const distance = Math.floor(50 + Math.random() * 100);

const num = Math.random();
if (num > 0.5) {
currentCubePos.z -= distance;
direction = "right";
} else {
currentCubePos.x -= distance;
direction = "left";
}
createCube(currentCubePos.x, currentCubePos.z);
}
stopped = true;
} else {
stopped = false;
if (direction === "right") {
player.position.z -= speed;
} else {
player.position.x -= speed;
}
}
speedY -= 0.3;

让第二个方块也随机

我们调整一下初始位置,这样第二个方块也可以随机

1
2
3
4
// 当前方块位置
const currentCubePos = { x: 0, y: 0, z: 0 };
// 方块方向
let direction = "right";

蓄力缩短小人

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function speedUp() {
if(pressed) {
speed += 0.1;
speedY += 0.1;

if(player.scale.y> 0) {
player.scale.y -= 0.001;
}
player.position.y -= 15 - 15 * player.scale.y

if(player.position.y < 10) {
player.position.y = 10;
}
} else {
player.scale.y = 1;
}
}

判断是否失败

逻辑

逻辑也很简单,就是 x 或者 z 是否是在下个平台的范围内,判断下 player.position.x 是否在方块中心点 currentCubePos.x 加减 15 的范围内。因为我们设置的x,z都是30

逻辑图

分数累加逻辑

跳完之后,如果跳到了下一个方块,就累加分数,否则就提示失败,并打印当前分数:

1
2
3
4
5
6
if(playerInCube()) {
score++;
console.log(score);
} else {
console.log('失败, 当前分数' + score);
}

设置初始分数

分数有点问题,没跳的时候就已经加一了。

这是以为最开始也会触发一次判断,在方块上,所以分数加一了。

我们把初始分数设置为 -1 就好了。

1
let score=-1

实现判断是否失败方法

接下来我们实现一下playerInCube

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function playerInCube() {
if (direction === "right") {
if (
player.position.z < currentCubePos.z + 15 &&
player.position.z > currentCubePos.z - 15
) {
return true;
}
} else if (direction === "left") {
if (
player.position.x < currentCubePos.x + 15 &&
player.position.x > currentCubePos.x - 15
) {
return true;
}
}
return 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
import { ref } from "vue";

// 当前分数
const curScore = ref(0);
// 是否显示失败
const showFail = ref(false);

// 移动玩家
function movePlayer() {
player.position.y += speedY;
if (player.position.y < 17.5) {
player.position.y = 17.5;
if (stopped === false) {
if (playerInCube()) {
score++;
// 赋值分数
curScore.value = score;

const distance = Math.floor(50 + Math.random() * 100);

const num = Math.random();
if (num > 0.5) {
currentCubePos.z -= distance;
direction = "right";
} else {
currentCubePos.x -= distance;
direction = "left";
}
createCube(currentCubePos.x, currentCubePos.z);
} else {
// 失败了显示遮罩层
showFail.value = true;
}
}

stopped = true;
} else {
stopped = false;
if (direction === "right") {
player.position.z -= speed;
} else {
player.position.x -= speed;
}
}
speedY -= 0.3;
}

完整代码

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
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
<template>
<div id="score">{{ curScore }}</div>
<div id="fail" v-if="showFail">
您的分数为 <span id="score2">{{ curScore }}</span>
</div>
</template>

<script setup>
import * as THREE from "three";
import { ref } from "vue";

// 当前分数
const curScore = ref(0);
// 是否显示失败
const showFail = ref(false);

// 1.创建场景
const scene = new THREE.Scene();

const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;

// 2.创建相机
const camera = new THREE.PerspectiveCamera(
45,
windowWidth / windowHeight,
0.1,
1000
);

// 设置相机位置
camera.position.set(100, 100, 100);

// 相机看向场景当前位置
camera.lookAt(scene.position);

// 添加相机
scene.add(camera);

// 初始化渲染器
const renderer = new THREE.WebGLRenderer({
antialias: true, // 开启抗锯齿
});

// 设置渲染器的背景颜色
renderer.setClearColor("#d3d3d3");

//设置渲染的尺寸大小
renderer.setSize(windowWidth, windowHeight);

// 将webgl渲染的canvas内容添加到body上
document.body.appendChild(renderer.domElement);

// 添加世界坐标辅助器
// const axesHelper = new THREE.AxesHelper(1000);
// axesHelper.position.set(0, 0, 0);
// scene.add(axesHelper);

// 平行光
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.position.set(40, 100, 60);
scene.add(directionalLight);

// 相机的焦点
const cameraFocus = { x: 0, y: 0, z: 0 };
// 玩家
let player;
// x或者z轴速度,向左向右的速度
let speed = 0;
// y轴速度,向上的速度
let speedY = 0;

// 当前方块的位置
const currentCubePos = { x: 0, y: 0, z: 0 };

// 当前方向
let direction = "right";

// 是否按下鼠标
let pressed = false;

// 创建方块
function createCube(x, z) {
const geometry = new THREE.BoxGeometry(30, 20, 30);
const material = new THREE.MeshPhongMaterial({ color: 0xffffff });
const cube = new THREE.Mesh(geometry, material);
cube.position.x = x;
cube.position.z = z;
scene.add(cube);
}

// 创建初始化
function create() {
// 创建玩家
function createPlayer() {
const geometry = new THREE.BoxGeometry(5, 15, 5);
const material = new THREE.MeshPhongMaterial({ color: 0xffff00 });
const player = new THREE.Mesh(geometry, material);
player.position.x = 0;
player.position.y = 17.5;
player.position.z = 0;
scene.add(player);
return player;
}

player = createPlayer();

// 创建第一个方块
createCube(0, 0);

// 监听鼠标按下事件
window.addEventListener("mousedown", () => {
speed = 0;
speedY = 0;
pressed = true;
});

// 监听鼠标抬起事件
window.addEventListener("mouseup", () => {
pressed = false;
});
}

// 移动摄像头
function moveCamera() {
if (player.position.y > 17.5) {
if (direction === "right") {
camera.position.z -= speed;
cameraFocus.z -= speed;
} else {
camera.position.x -= speed;
cameraFocus.x -= speed;
}

camera.lookAt(cameraFocus.x, cameraFocus.y, cameraFocus.z);
}
}

// 判断玩家是否在方块上
function playerInCube() {
if (direction === "right") {
if (
player.position.z < currentCubePos.z + 15 &&
player.position.z > currentCubePos.z - 15
) {
return true;
}
} else if (direction === "left") {
if (
player.position.x < currentCubePos.x + 15 &&
player.position.x > currentCubePos.x - 15
) {
return true;
}
}
return false;
}

// 分数
let score = -1;
// 是否停止
let stopped = true;
// 移动玩家
function movePlayer() {
player.position.y += speedY;
if (player.position.y < 17.5) {
player.position.y = 17.5;
if (stopped === false) {
if (playerInCube()) {
score++;
curScore.value = score;

const distance = Math.floor(50 + Math.random() * 100);

const num = Math.random();
if (num > 0.5) {
currentCubePos.z -= distance;
direction = "right";
} else {
currentCubePos.x -= distance;
direction = "left";
}
createCube(currentCubePos.x, currentCubePos.z);
} else {
showFail.value = true;
}
}

stopped = true;
} else {
stopped = false;
if (direction === "right") {
player.position.z -= speed;
} else {
player.position.x -= speed;
}
}
speedY -= 0.3;
}

function speedUp() {
// 如果按下鼠标,加速
if (pressed) {
speed += 0.1;
speedY += 0.1;
// 缩放
if (player.scale.y > 0) {
player.scale.y -= 0.001;
}
// 设置玩家的位置
player.position.y -= 15 - 15 * player.scale.y;
// 设置玩家的最小位置
if (player.position.y < 10) {
player.position.y = 10;
}
} else {
player.scale.y = 1;
}
}

// 渲染
function render() {
// 如果不是按下状态,移动摄像头和玩家
if (!pressed) {
// 移动摄像头
moveCamera();
// 移动玩家
movePlayer();
}
// 加速
speedUp();

renderer.render(scene, camera);
requestAnimationFrame(render);
}

create();
render();

// 监听画面的变化,更新渲染的画面
window.addEventListener("resize", () => {
// 更新摄像头
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置渲染器的像素比
renderer.setPixelRatio(window.devicePixelRatio);
});
</script>

<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

canvas {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
}

#score {
font-size: 50px;
color: #fff;
position: fixed;
top: 10%;
left: 10%;
z-index: 999;
}
#fail {
position: fixed;
background: rgba(0, 0, 0, 0.5);
left: 0;
top: 0;
width: 100vw;
height: 100vh;
font-size: 70px;
color: #fff;
padding-left: 40%;
padding-top: 200px;
z-index:1000
}
</style>

最终效果

结语

本篇文章先到这里,本文实现了跳一跳小游戏的基本逻辑,下一篇跳一跳文章将基于此完善,敬请期待。

上一篇:
微信小程序版本更新提示
下一篇:
前端更新提示