threejs games 之升降机

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

这算是我做的第一个3D游戏,虽然完全是照着敲的代码,但也有不少收获。游戏虽然简单,但是一个完整的游戏总有一些亮(麻烦)点。

照着别人的东西做游戏和自己做游戏最大的区别是什么? 个人认为是结果导向。 已经知道这个游戏是什么样的,然后照着做。实际的开发肯定是逐渐摸索的。

image.png

主要玩法

飞机在水平方向上往前飞,在垂直方向上做自由落体。我们可以给飞机加油,使得其在垂直方向上攀升。 目标是躲避障碍物和收集星星。

如此一来,我们要做的事情似乎就非常简单了。 只要加载模型,设置场景 、障碍/收集物。 然后来一个可以控制垂直方向上速度的逻辑即可。

初始化场景

项目是按照esm规范写的,也就是原生js。 主要文件就是一个game.js,里面声明了一个Game class .

外部依赖主要就是threeJS。

image.png

image.png

很普通的初始化,设置好相机 、场景、 灯光 、dpr响应式画布。

其中,使用了半球光作为环境光,同样可以说是无处不在的光,但是还是有方向,这样效果上会真实一点。

镜头跟随

相机没有使用控制器(为了方便自己加了一个),却声明了一个cameraController用作相机的父级和一个cameraTarget作为目标点。

如果,直接把相机放到飞机里,那么在飞机上下移动的时候,相机会完全同步上下移动。这里并不希望这样,期望是虽然在z方向上跟随相机,但是在y轴方向不受其影响。 在更新相机的时候把飞机位置的zx坐标赋值给 cameraController。 这里之所以要给相机额外套一层,是用相机和父级的相对位置来保持相机和飞机的相对位置。 当然,你可以直接在更新相机位置的时候加上这个偏移量 。

 updateCamera(){
                if(this.plane){ 
                    let pos = this.plane.position ;
                    this.cameraController.position.set(pos.x,0,pos.z) ; // 让相机在y分量上固定不跟随目标
                    this.camera.lookAt(pos.x,pos.y,pos.z+6)
                }
    }

事件绑定

我们这个简单游戏用到的交互并不多,就只有给飞机加油,开始游戏这两个。只不过,还考虑了移动端所以加上了touch事件, 而我又考虑到pc上会用键盘操作,所以加了键盘事件。

主要玩法逻辑这里,并不是触发事件直接给飞机提速,而是触发事件,会修改状态,将飞机从自由落体状态转为加速飞升状态。 这样不管事件触发的频率,最终加速度都是一样。

说到这里,凡是涉及物体运动的,最好就是模仿真实物理世界,应用速度,加速度,阻力 等概念,这样写起来会方便很多。

加载模型及其控制

模型就是直接加载,加载完成之后保留一个引用。 但是,这个飞机的逻辑就是一个相对独立模块,所以,单独拆出来一个 plane.js .

飞机负责自身的加载、初始化以及更新。 同样也负责自身的升降控制.

加载完模型后,针对具体情况调整。 我们来看,飞机的控制逻辑,都在update里了。在开始游戏的状态下,z方向的速度不断增加,y方向具有一个加速度。y方向加速度的方向,取决于是否处于加速状态,这个用上面监听的事件交互。 每次更新都是当前位置加上位置的变化量,而不是直接设置,这是交互操作下必然的。

除此之外就是,活跃状态下,螺旋桨要旋转, 机身要摇摆。 这个摇摆是我自己觉得,在初始界面下,飞机静止似乎少了点什么,于是就加上了。 游戏进行中的时候,机身的摇摆仅限于z轴的旋转, 游戏未开始/结束的时候,再加上上下的浮动。

rock中的最后一行代码就是修正飞机当前的方向,又是一个小细节。

这里还用了访问器属性,来简化修改操作和读取飞机位置。

image.png

场景和障碍物

蓝天白云就是一张环境贴图,准确的说是一个立方体纹理贴图(天空盒)。

image.png

障碍物这里可以说终于遇到一点麻烦了。 障碍物是小炸弹,收集物是星星,都是加载的模型,虽然这个⭐️也可以用three的2d形状加上挤压做出来,但是棱边不会有这么圆滑。 这一点在后面的台球游戏中,想必会有更直接的感触。

飞机碰到炸弹,炸了。是障碍物炸还是飞机炸,当然是障碍物炸了,因为飞机只是掉了血条,还能继续飞。

无限障碍

写过无限地图/背景的小伙伴肯定知道,只要用两张一模一样,能无缝衔接的背景图轮换,就可以做出无限的效果。 我们这里也是一样的。

加载完之后,开始排列障碍物。炸弹和⭐️都是object3D,所以具有clone方法.

//加载模型
  this.bomb = gltf.scene.children[0];
  。。。。。。。。
   this.star = gltf.scene.children[0];
  。。。。。。。。
// 添加障碍物
initialize(){
console.log('obstacle init');
this.obstacles =[] ;
        let g1 = new Group() ;
g1.add(this.star) 
this.bomb.rotation.x = PI* .5 ;
let rotate =true;
for( let y =5; y > -8; y-=2.5){ 
rotate =!rotate ;
if(!y) continue;
let b  = this.bomb.clone() ;
b.rotation.x= rotate? -PI* .5:0 ;
b.position.y = y ;
g1.add(b)

};
this.obstacles.push(g1) ;
this.scene.add(g1) ;
//克隆这一组
for(let i= 0;i<3; i++){
let o = g1.clone() ;
this.obstacles.push(o) ;
this.scene.add(o)  ;
}
// 摆放
this.reset() ;
console.log(this.obstacles);
this.ready =true ;
    }

无限障碍就是在飞机飞过一组障碍后, 把看不见的那一组,重新初始化到最前方。 具体做法就是时刻检测飞机和障碍物之间的距离, 一旦某组障碍物落在飞机屁股后面,就把这组重设到前面。

距离检测这个法子也许很笨,但是很实用,暂时找不到什么比较好的法子。重设状态就涉及更细节繁琐但必要的东西了。

碰撞和爆炸

上面的距离检测仅仅是检查, 两个object3D的poisiton的距离,并不精准,毕竟模型并不是一个质点。更精细的碰撞检测,显然会有更大消耗,所以先粗略检测,到了近距离才开始精细检测。 这里没有使用更精细的碰撞检测,一方面两个两个物体相对规则,另一方面,我们的两个物体x分量是完全一致的,这就使得我们可以偷这个懒。

//重设一组障碍物,
    respawnObstacle( obstacle ){
        this.obstacleSpawn.pos += 30 ;
let offset = (random()* 2 -1) * this.obstacleSpawn.offset ;
        this.obstacleSpawn.offset +=.2 ;
obstacle.position.set(0, offset, this.obstacleSpawn.pos) ;
obstacle.children[0].rotation.y = random()* PI *2 ;
// 重置碰撞状态
obstacle.userData.hit = false ;
obstacle.children.forEach((child)=> { child.visible = true})
let n =0;
while(this.explosions.size && n < 300){ 
n++
[...this.explosions][0].onComplete();
}
    }

update(pos, time){
let collisionObstacle, delInd =-1 ;// 可能碰到的 障碍物组 collision n 碰撞
this.obstacles.forEach((obstacle, ind)=> { 
const dZ = obstacle.position.z - pos.z ;
if(abs(dZ) < 2 && !obstacle.userData.hit){
// console.log(dZ);
collisionObstacle = obstacle;
 }
 // 添加新的障碍物,无限障碍, 是不是应该吧这个给删掉 ,不用这个逻辑就是把消失的物体设置到了前方
 if(dZ < -20){ 
this.respawnObstacle(obstacle);
delInd = ind ;
 }
});
if(collisionObstacle){ 
// console.log(collisionObstacle);
collisionObstacle.children.some((child)=> {
child.getWorldPosition(this.tmpPos) ;
let d = this.tmpPos.distanceToSquared(pos)// 距离的平方
if(d < 5){ 
collisionObstacle.userData.hit =true ;
this.hit(child);// 
return true 
}
})
}
[...this.explosions].forEach((e)=> { e.update(time) })

    }

爆炸的逻辑,单独拆分出来了。

关于特效这块,基本原理就是基于一个球模型的顶点,用噪声函数对每个顶点做出一个偏移,因为噪声的连续特性,所以看上去还算是比较平滑的,然后就是随着时间的推移,增加每个顶点偏移的量。 颜色同样是用噪声和随机做的,再加上一个随时间减小的透明度,就完成了。 代码少,就不要太过于纠结其数学公式了。

然后就是,爆炸的状态,是否已经碰了,从爆炸开始到现在过了多久,这些数据的处理。 同样,这个类有一个update方法,负责自身状态的更新,你会发现这个代码模式还是挺不错的。

import { IcosahedronGeometry,Vector2, TextureLoader, ShaderMaterial, Mesh, ShaderChunk } from '../libs/three137/three.module.js';
import { noise } from '../libs/Noise.js';
import { Tween } from '../libs/Toon3D.js';
import { urls } from '../assets/store.js';

class Explosion{
  static vshader =/*glsl*/ `
#include <noise>

uniform float u_time;

varying float noise;

void main() {
  float time = u_time;
  float displacement;
  float b;
  
  // add time to the noise parameters so it's animated
  noise = 10.0 *  -.10 * turbulence( .5 * normal + time );
  b = 5.0 * pnoise( 0.05 * position + vec3( 2.0 * time ), vec3( 100.0 ) );
  displacement = - 10. * noise + b;

  // move the position along the normal and transform it
  vec3 newPosition = position + normal * displacement;
  gl_Position = projectionMatrix * modelViewMatrix * vec4( newPosition, 1.0 );
}
`
  static fshader = /*glsl*/`
#define PI 3.141592653589
#define PI2 6.28318530718

uniform vec2 u_mouse;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_opacity;
uniform sampler2D u_tex;

varying float noise;

//<https://www.shadertoy.com/view/4dS3Wd>
//By Morgan McGuire @morgan3d, http://graphicscodex.com

//https://www.clicktorelease.com/blog/vertex-displacement-noise-3d-webgl-glsl-three-js/

float random( vec3 scale, float seed ){
  return fract( sin( dot( gl_FragCoord.xyz + seed, scale ) ) * 43758.5453 + seed ) ;
}

void main() {

  // get a random offset
  float r = .01 * random( vec3( 12.9898, 78.233, 151.7182 ), 0.0 );
  // lookup vertically in the texture, using noise and offset
  // to get the right RGB colour
  vec2 t_pos = vec2( 0, 1.3 * noise + r );
  vec4 color = texture2D( u_tex, t_pos );

  gl_FragColor = vec4( color.rgb, u_opacity );
}
`


  constructor(parent, obstacles){
    console.log('爆炸开始');
    // this.explosions =new Set()    ;
    this.obstacles = obstacles ;
    // 20 面体,                          半径 额外顶点
    const geo  = new IcosahedronGeometry(20, 4) ; 

    this.uniforms  ={ 
      u_time:{value:0},
      u_mouse:{value:new Vector2()},
      u_opacity:{value:0.6},
      u_resolution: {value: new Vector2()} ,
      u_tex: {value:  new TextureLoader().load('../assets/plane/explosion.png')}
    }

    ShaderChunk.noise = noise ;

    const mat = new ShaderMaterial({ 
      uniforms:this.uniforms ,
      vertexShader: Explosion.vshader ,
      fragmentShader: Explosion.fshader ,
      transparent: true ,
      opacity: .6 
    })

    this.ball = new Mesh( geo, mat) ;
    this.ball.scale.set(.05,.05,.05)  ;
    parent.add(this.ball) ;
    console.log(parent);
    this.tweens = [];
                  //target, channel, endValue, duration, oncomplete, easing="inOutQuad"
    this.tweens.push(new Tween(this.ball.scale, 'x', .2, 1.5 , this.onComplete.bind(this), 'outQuad'))
    this.active =true 
  }
  

  // 爆炸结束
  onComplete(){
    this.ball.parent.remove(this.ball) ;
    this.tweens = [] ;
    this.active = false ;
    this.ball.geometry.dispose() ;
    this.ball.material.dispose() ;
    if(this.obstacles) this.obstacles.removeExplosion(this) ;
    console.log('爆炸结束');
  }

  update(dt) {
    // 这个时间应该是一个相对于初始化的时间
    if(!this.active){ return}
    console.log(dt , '爆炸中',this.ball.scale.x);
    this.uniforms.u_opacity.value  = this.ball.material.opacity ;
    this.uniforms.u_time.value+=dt ;
    if(this.tweens.length < 2){ // tweems 内最多只会有 透明度 和 scale.x 过渡
      // elapse 你& vi 时间过去 流逝
      const elapsedTime  = this.uniforms.u_time.value -1 ;
      if(elapsedTime > 0){  // 延后一点设置透明度渐变
        this.tweens.push( new Tween(this.ball.material, 'opacity', 0, .5))
      }
    }
    // upadate接受的是dt 
    this.tweens.forEach((tween ) => { tween.update(dt)}) // 就是输入时间 得到对应的节点值
    let s = this.ball.scale.x ;
    this.ball.scale.set(s,s,s)
  }

}

export { Explosion };

image.png

结语

上面就是这个游戏的主要的主要逻辑了。但是一个完整的游戏,还有很多细节。

比如音效,一个游戏,没有了音效可以说就失去了三魂,没有特效就失去了七魄。不过还真有这样的游戏,比如之前流行了一段时间的重生文字游戏。

音效这里就是对three的Audio系列进行了一下封装, 需要注意的就是,在没有交互之前是不能主动播放声音的。

涉及的音效有,飞机的螺旋桨(处于飞升状态就开启),爆炸,收集星星,结束游戏。 positionAudio会根据声源到目标位置的距离来决定音量的大小。

还有计分面板,显示还剩多少条命和收集得分。 我始终还不是为了做游戏而做游戏的,所以这些个细节,我知道,但不太愿意去想,去做。
代码片段
码上掘金限制域名了,所以体验请走这里

不好意思,域名限制我又绕开了。

参考链接 B站大学

© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容