前言
今天和大家一起分享一下一个小案例,如何利用 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
| 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) { 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 就好了。
实现判断是否失败方法
接下来我们实现一下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>
|
最终效果
结语
本篇文章先到这里,本文实现了跳一跳小游戏的基本逻辑,下一篇跳一跳文章将基于此完善,敬请期待。