本文借鉴了The Aviator的玩法和部分代码,最终效果如下。

game1
game2
game3

1 加载模型和动画

参考学习笔记1中的方法,将海豚和鲨鱼模型及动画加载到three.js中。

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
// 对象构造器  
let playerObj;
const mixers = [];
// 游戏人物
const Player = function () {
const isExist = playerObj ? true : false;
const loader = new THREE.FBXLoader();
loader.load('assets/models/Player' + OPTIONS.player + '.fbx', function (object) {
object.mixer = new THREE.AnimationMixer(object);
mixers.push(object.mixer);
const action = object.mixer.clipAction(object.animations[0]);
action.play();
// 侧面面对镜头
object.rotation.y = 1.57;
object.position.y = 50;
object.position.z = 0;
const scale = OPTIONS.objScale[OPTIONS.player];
object.scale.set(scale, scale, scale);
// 加入场景
if (isExist) {
scene.remove(playerObj)
playerObj = object;
scene.add(object);
} else {
playerObj = object;
scene.add(object);
// 载入obj后开始第一次动画循环
loop();
}
});
};

游戏开始时玩家需要选择两个游戏人物中的一个,变量isExist用于判断之前是否已载入某个模型,变量scale根据不同的角色调节模型的大小。载入完毕后开始动画循环。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 海底  
const Sea = function () {
// 锥:上底半径,下底半径,高度,圆面划分,高度划分
const geom = new THREE.CylinderGeometry(OPTIONS.seaRadius, OPTIONS.seaRadius, 200, 40, 10);
// 滚动
geom.applyMatrix(new THREE.Matrix4().makeRotationX(-Math.PI/2));
geom.mergeVertices();
// 增加波动
this.waves = [];
for (let i=0; i < geom.vertices.length; i++) {
this.waves.push({
y: geom.vertices[i].y,
x: geom.vertices[i].x,
z: geom.vertices[i].z,
ang: Math.random() * Math.PI * 2,
amp: 5 + Math.random() * 15,
speed: 0.016 + Math.random() * 0.032
});
};
// 材质
const mat = new THREE.MeshPhongMaterial({
color: COLORS.sand
});
mat.flatShading = true;
this.mesh = new THREE.Mesh(geom, mat);
this.mesh.position.y = -600;
this.mesh.position.z = -100;
scene.add(this.mesh);
}

Sea.prototype.wave = function () {
const verts = this.mesh.geometry.vertices;
for (let i=0; i < verts.length; i++) {
const vprops = this.waves[i];
verts[i].x = vprops.x + Math.cos(vprops.ang) * vprops.amp;
verts[i].y = vprops.y + Math.sin(vprops.ang) * vprops.amp;
vprops.ang += vprops.speed;
}
this.mesh.geometry.verticesNeedUpdate = true;
sea.mesh.rotation.z += 0.005;
}

3 引入绿色小球

绿色小球触碰后消失,并增加得分。绿球起始在Math.PI / 3位置处生成,当全部被主角吃掉或运动至-Math.PI / 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
// 绿色小球  
const GreenBalls = function () {
this.generate();
}

GreenBalls.prototype.generate = function () {
// 删除已有mesh
if (this.meshArr) {
for (let i = 0; i < this.meshArr.length; i++) {
this.mesh.remove(this.meshArr[i]);
}
}
// 生成一排绿色球
this.mesh = new THREE.Object3D();
this.meshArr = [];
const geom = new THREE.TetrahedronGeometry(10, 0);
const mat = new THREE.MeshPhongMaterial({
color: 0x00CD66,
shininess: 0,
specular: 0xFFFFFF
});
mat.flatShading = true;
// 每次几个球
const count = Math.floor(2 + Math.random() * 6);
const ANGLE = Math.PI / 50;
// 高度范围620-800
const h = 620 + Math.random() * 180;
for (let i = 0; i < count; i++) {
const greenBallMesh = new THREE.Mesh(geom.clone(), mat);
greenBallMesh.position.x = Math.sin(ANGLE * i + Math.PI / 3) * h;
greenBallMesh.position.y = Math.cos(ANGLE * i + Math.PI / 3) * h;
greenBallMesh.position.z = 0;
greenBallMesh.rotation.y = Math.random() * Math.PI;
greenBallMesh.rotation.z = Math.random() * Math.PI;
greenBallMesh.angle = ANGLE * i + Math.PI / 3;
greenBallMesh.h = h;
this.mesh.add(greenBallMesh);
this.meshArr.push(greenBallMesh);
}
this.mesh.position.y = -600;
scene.add(this.mesh);
};

GreenBalls.prototype.update = function (deltaTime) {
const count = this.meshArr.length;
for (let i = 0; i < count; i++) {
// 自转
this.meshArr[i].rotation.x += 3 * deltaTime * OPTIONS.angleSpeed;
this.meshArr[i].rotation.z += 2 * deltaTime * OPTIONS.angleSpeed;
// 公转,逆时针旋转
this.meshArr[i].angle -= deltaTime * OPTIONS.angleSpeed;
const angle = this.meshArr[i].angle;
const h = this.meshArr[i].h;
this.meshArr[i].position.x = Math.sin(angle) * h;
this.meshArr[i].position.y = Math.cos(angle) * h;
// 判断是否碰撞
let diffX = this.meshArr[i].position.x - playerObj.position.x;
diffX = diffX > 0 ? diffX : -diffX;
let diffY = this.meshArr[i].position.y - (playerObj.position.y + 600);
diffY = diffY > 0 ? diffY : -diffY;
if (diffX < OPTIONS.objBoundary[OPTIONS.player][0] &&
diffY < OPTIONS.objBoundary[OPTIONS.player][1]) {
particles.generate(15, playerObj.position.clone(), 0x009999, 3);
this.mesh.remove(this.meshArr[i]);
this.meshArr.splice(i, 1);
// this.meshArr.shift();
// 计分
OPTIONS.score += OPTIONS.greenBallScore;
// 玩家选择鲨鱼,单独加一定分数
if (OPTIONS.player === 1) {
OPTIONS.score += 5;
}
break;
}
}
// 绿球都被吃或超过左边界
if (this.meshArr.length === 0 || this.meshArr[0].angle < -Math.PI / 3) {
this.generate();
}
};

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
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
// 红色小球  
const RedBall = function () {
this.generate();
}

RedBall.prototype.generate = function () {
// 删除已有mesh
if (this.meshTemp) {
this.mesh.remove(this.meshTemp);
}
this.mesh = new THREE.Object3D();
// 生成一排绿色球
const geom = new THREE.TetrahedronGeometry(8, 2);
const mat = new THREE.MeshPhongMaterial({
color: COLORS.red,
shininess: 0,
specular: 0xFFFFFF
});
mat.flatShading = true;
const h = 620 + Math.random() * 180;
const mesh = new THREE.Mesh(geom, mat);
mesh.position.x = Math.sin(Math.PI / 3) * h;
mesh.position.y = -600 + Math.cos(Math.PI / 3) * h;
mesh.position.z = 0;
mesh.rotation.y = Math.random() * Math.PI;
mesh.rotation.z = Math.random() * Math.PI;
mesh.angle = Math.PI / 3;
mesh.h = h;
this.meshTemp = mesh;
this.mesh.add(mesh);
scene.add(this.mesh);
};

RedBall.prototype.update = function (deltaTime) {
// 自转
this.meshTemp.rotation.x -= 2 * deltaTime * OPTIONS.angleSpeed;
this.meshTemp.rotation.z -= 3 * deltaTime * OPTIONS.angleSpeed;
// 公转,逆时针旋转,绿色小球的1.8倍
this.meshTemp.angle -= 1.8 * deltaTime * OPTIONS.angleSpeed;
const angle = this.meshTemp.angle;
const h = this.meshTemp.h;
this.meshTemp.position.x = Math.sin(angle) * h;
this.meshTemp.position.y = -600 + Math.cos(angle) * h;
// 判断是否碰撞
let diffX = this.meshTemp.position.x - playerObj.position.x;
diffX = diffX > 0 ? diffX : -diffX;
let diffY = this.meshTemp.position.y - (playerObj.position.y);
diffY = diffY > 0 ? diffY : -diffY;
// console.log(diffX + " " + diffY)
if (diffX < OPTIONS.objBoundary[OPTIONS.player][0] &&
diffY < OPTIONS.objBoundary[OPTIONS.player][1]) {
// 发生碰撞
this.mesh.remove(this.meshTemp);
this.meshTemp = undefined;
playerObj.position.x -= 20;
playerObj.position.y -= 5;
particles.generate(15, playerObj.position.clone(), COLORS.red, 3);
// 计分
OPTIONS.score -= OPTIONS.redBallScore;
}
// 红球被吃或超过左边界
if (this.meshTemp === undefined || this.meshTemp.angle < -Math.PI / 3) {
this.generate();
}
};

5 粒子效果

主角触碰绿色小球或红色小球时,小球消失并产生爆炸的粒子效果,如下。
game4

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
const Particle = function() {  
const geom = new THREE.TetrahedronGeometry(3, 0);
const mat = new THREE.MeshPhongMaterial({
color: 0x009999,
shininess: 0,
specular: 0xFFFFFF
});
mat.flatShading = true;
this.mesh = new THREE.Mesh(geom,mat);
}

Particle.prototype.animate = function(position, color, scale) {
this.mesh.material.color = new THREE.Color(color);
this.mesh.material.needsUpdate = true;
this.mesh.scale.set(scale, scale, scale);
// 向周围四散
const speed = 0.6 + Math.random() * 0.2;
TweenMax.to(this.mesh.rotation, speed, {
x: Math.random() * 12,
y: Math.random() * 12
});
TweenMax.to(this.mesh.scale, speed, {
x: 0.1,
y: 0.1,
z: 0.1
});
TweenMax.to(this.mesh.position, speed, {
x: position.x + (Math.random() * 2 - 1) * 50,
y: position.y + (Math.random() * 2 - 1) * 50,
delay: Math.random() * 0.1,
ease: Power2.easeOut
});
}

Particles = function () {
this.mesh = new THREE.Object3D();
scene.add(this.mesh)
}

Particles.prototype.generate = function(count, position, color, scale) {
for (var i = 0; i < count; i++) {
const particle = new Particle();
particle.mesh.visible = true;
particle.mesh.position.x = position.x + OPTIONS.objBoundary[OPTIONS.player][0];
particle.mesh.position.y = position.y;
this.mesh.add(particle.mesh);
particle.animate(position, color, scale);
}
}

首先定义单个粒子的形状和发生爆炸时的动画效果animate。每个粒子的爆炸方向、大小、速度均随机。之后定义粒子效果和生成方法,每次需要爆炸效果时,调用粒子效果的生成方法即可。生成方法的四个参数count, position, color, scale分别代表粒子个数,爆炸时主角位置,爆炸粒子颜色及大小。

6 动画循环

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
// 动画循环  
const clock = new THREE.Clock();
let lastClock = 1;
function loop() {
//status: "start",
if (OPTIONS.status === 'start') {
requestAnimationFrame(loop);
const deltaTime = clock.getDelta();
// player动画
for (let i = 0; i < mixers.length; i ++) {
mixers[i].update(deltaTime);
}
// 海洋动画
sea.mesh.rotation.z += 0.005;
sea.wave();
scoreDOM.innerText = '鼠标点击切换人物\n按任意键开始';
if (OPTIONS.player === 0) {
describeDOM.innerText = '海豚体型小巧,速度较快,不易触碰水雷\n但每次获取的绿色能量较少';
} else if (OPTIONS.player === 1) {
describeDOM.innerText = '鲨鱼体型巨大,速度较慢,较易触碰水雷\n但每次获取的绿色能量会有一定加成';
}
renderer.render(scene, camera);
} else if (OPTIONS.status === 'play') {
requestAnimationFrame(loop);
const deltaTime = clock.getDelta();
// player移动
if (OPTIONS.player === 0) {
playerObj.position.x += 2 * mousePos.x;
playerObj.position.y -= 2 * mousePos.y;
} else if (OPTIONS.player === 1) {
playerObj.position.x += 1.5 * mousePos.x;
playerObj.position.y -= 1.5 * mousePos.y;
}
// player动画
for (let i = 0; i < mixers.length; i ++) {
mixers[i].update(deltaTime);
}
// 海洋动画
sea.mesh.rotation.z += 0.005;
sea.wave();
// 绿色小球旋转
greenBalls.update(deltaTime);
// 红色小球旋转
redBall.update(deltaTime);
if (OPTIONS.score < 0) {
OPTIONS.status = 'start';
OPTIONS.score = 0;
// 归位
playerObj.position.x = 0;
playerObj.position.y = 50;
}
scoreDOM.innerText = '分数 ' + OPTIONS.score;
renderer.render(scene, camera);
}
}

每次循环先判断当前游戏状态,start为开始界面,play为游戏界面。开始游戏时根据鼠标位置改变主角运动方向。播放主角动画,海洋波动动画。调用绿色小球、红色小球的update方法,判断分数是否大于0并更新分数。

7 交互

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
// 交互  
function handleMouseClick(event) {
if (OPTIONS.status === 'start') {
// 选择玩家
if (OPTIONS.player === 0) {
OPTIONS.player = 1;
player = new Player();
} else if (OPTIONS.player === 1) {
OPTIONS.player = 0;
player = new Player();
}
}
}

function handleKeyDown(event) {
if (OPTIONS.status === 'start') {
describeDOM.innerText = '';
OPTIONS.status = 'play';
}
}

function handleMouseMove(event) {
if (OPTIONS.status === 'play') {
// -1到1之间
const tx = -1 + (event.clientX / WIDTH) * 2;
const ty = -1 + (event.clientY / HEIGHT) * 2;
mousePos.x = tx;
mousePos.y = ty;
}
}

主要有三种交互,一是游戏开始界面鼠标点击切换主角,二是开始界面按任意键开始游戏,三是游戏界面定位鼠标位置。

8 其他

在three.js中创建场景、光照、各种object并初始化。

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
// 创建场景  
function createScene() {
HEIGHT = window.innerHeight;
WIDTH = window.innerWidth;
const fieldOfView = 60;
const aspectRatio = WIDTH / HEIGHT;
const nearPlane = 1;
const farPlane = 10000;
// 相机
camera = new THREE.PerspectiveCamera(
fieldOfView,
aspectRatio,
nearPlane,
farPlane
);
camera.position.x = 0;
camera.position.z = 220;
// camera.position.z = 2000;
camera.position.y = 100;
// 场景
scene = new THREE.Scene();
// 雾气效果:颜色,近点,远点
// scene.fog = new THREE.Fog(COLORS.blue, 100, 950);
// 渲染
renderer = new THREE.WebGLRenderer({alpha: true, antialias: true});
renderer.setSize(WIDTH, HEIGHT);
// renderer.shadowMap.enabled = true;
// 加入DOM
container = document.getElementById('webgl');
container.appendChild(renderer.domElement);

window.addEventListener('resize', handleWindowResize, false);
}

// 屏幕自适应
function handleWindowResize() {
HEIGHT = window.innerHeight;
WIDTH = window.innerWidth;
renderer.setSize(WIDTH, HEIGHT);
camera.aspect = WIDTH / HEIGHT;
camera.updateProjectionMatrix();
}

// 光照
function createLights() {
// 直射光
const shadowLight = new THREE.DirectionalLight(0xffffff, 0.9);
shadowLight.position.set(150, 350, 350);
scene.add(shadowLight);
}

// 创建对象
function createObject() {
// 玩家
player = new Player();
// 海洋滚筒
sea = new Sea();
// 绿色球
greenBalls = new GreenBalls();
// 红色球
redBall = new RedBall();
// 粒子效果
particles = new Particles();
}

function init() {
document.addEventListener('mousedown', handleMouseClick, false);
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('mousemove', handleMouseMove, false);
scoreDOM = document.getElementById('score');
describeDOM = document.getElementById('describe');
createScene();
createLights();
createObject();
}

window.addEventListener('load', init, false);

index.html文件如下,使用CSS设置渐变背景色。

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
<!DOCTYPE html>  
<html>
<head>
<meta charset="UTF-8">
<title>Game</title>
<style>
body {
margin: 0;
}
#score {
position: absolute;
top: 5%;
width: 100%;
z-index: 10;
text-align: center;
font-size: 3rem;
color: #eeeeee;
}
#describe {
position: absolute;
top: 40%;
width: 100%;
z-index: 10;
text-align: center;
font-size: 2rem;
color: #eeeeee;
}
#webgl {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
background: -webkit-linear-gradient(#55f0f6, #48a0c9);
background: linear-gradient(#55f0f6, #48a0c9);
}
</style>
</head>
<body>
<div id="score"></div>
<div id="describe"></div>
<div id="webgl"></div>
<script src="js/lib/three.min.js"></script>
<script src="js/lib/FBXLoader.js"></script>
<script src="js/lib/inflate.min.js"></script>
<script src="js/lib/TweenMax.min.js"></script>
<script src="js/game.js" /></script>
</body>
</html>

完整程序见我的github