深度解析,用Threejs临摹微信跳一跳 (1)

9,567 阅读45分钟

所有章节

前言

搞了三年前端,呆过几个不大的公司,做过几个不大的项目。三年来心态略显浮躁,昔日的朋友早已练就大佬之身,而我却在原地停留了很久很久。由于在前段时间离职,所以近期正在备试恶补,弄得日夜颠倒,已分不清白天黑夜。强行灌输总是那么枯燥,并且我那该死的记忆力太不争气,左脑进右脑出,所以找点有意思的事情(临摹个小游戏)给自己找找刺激!

表达能力有限,文笔又差,如果有很多病句还请海量......

由于本文只是尝试对微信跳一跳进行一次深入的临摹,和原游戏肯定还存在很大的差距,并且首次使用threejs,所以本解析仅作为一个简单的向导,希望能对你有些作用,如果哪里有不妥的地方读者可以自由发挥。


万字多图长文预警!!!

本章源码已放github这是示例这是一个半成品,到此时还没有写完,过几天再发完整版

前置知识

微信跳一跳,这个游戏刚出的时候,自己在闲暇时间写过一个非常简单的版本,自以为接下来就很简单了,但毫无疑问那只是没有丝毫起伏的波澜,这一次重写让我踩了好几个坑plus。看似风平浪静的水面,你要是不下水,就不知道水下有多少暗流涌动。

考虑具体实现之前,我们首先得了解一部分与本游戏相关的threejs的知识

一、threejs三大组件

  1. 场景(Scene)

    const scene = new THREE.Scene()
    // 坐标辅助线,在调试阶段非常好用
    scene.add(new THREE.AxesHelper(10e3))
    
  2. 相机(Camera),这里重点关注正交相机,游戏实现将使用它。

    • 正交相机

      const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far)
      // 将正交相机放入场景中
      scene.add(camera)
      

      正交相机看物体的大小与它和物体之间的距离没有关系,远近皆一样大。比如你固定视野的范围为宽高比:200x320,最远能看到1000米内的物体,最近能看到1米以外的物体,那么:

      const camera = new THREE.OrthographicCamera(-200 / 2, 200 / 2, 320 / 2, -320 / 2, 1, 1000)
      
    • 透视相机我们用不到

  3. 渲染器(Renderer)

    const renderer = new THREE.WebGLRenderer({
        antialias: true // 抗锯齿
    })
    
    // 具体渲染
    renderer.render(scene, camera)
    

二、创建物体

首先是你需要什么形状的物体?几何形状(Geometry),物体的外观是什么样的?材质(Material),然后创建它网格(Mesh)。你需要看到物体吗?灯光(Light)

三、物体阴影

  1. 接收阴影的物体,比如创建一个地面来接收阴影

    const geometry = new THREE.PlaneBufferGeometry(10e2, 10e2, 1, 1)
    const meterial = new THREE.MeshLambertMaterial()
    
    const plane = new THREE.Mesh(geometry, meterial)
    // 接收阴影
    plane.receiveShadow = true
    
  2. 物体开启投影

    // 创建一个立方体
    const geometry = new THREE.BoxBufferGeometry()
    const meterial = new THREE.MeshLambertMaterial()
    
    const box = new THREE.Mesh(geometry, meterial)
    // 投射我的影子
    box.castShadow = true
    // 别人的影子也可以落在我身上
    box.receiveShadow = true
    
  3. 光源开启阴影

    // 平行光
    const lightght = new THREE.DirectionalLight(0xffffff, .8)
    // 投射阴影
    light.castShadow = true
    // 定义可见域的投射阴影
    light.shadow.camera.left = -400
    light.shadow.camera.right = 400
    light.shadow.camera.top = 400
    light.shadow.camera.bottom = -400
    light.shadow.camera.near = 0
    light.shadow.camera.far = 1000
    
  4. 场景也需要开启阴影

    const const renderer = new THREE.WebGLRenderer({ ... })
    renderer.shadowMap.enabled = true
    

四、threejs的变换原点

旋转(rotation)、缩放(scale)的原点是网格(Mesh)中心点,画来一张图来描述:

也就是说,可以通过位移几何形状(Geometry)达到控制缩放原点的目的,除此之外,threejs中还有组(Group),那么如果对一个组内物体进行缩放操作,对应的就是通过控制组内物体的位置来控制物体的缩放原点

五、threejs的优化

  • 用BufferGeometry代替Geometry,BufferGeometry 会缓存网格模型,性能更高效。
  • 使用clone()方法
    // 创建一个立方体,大小默认为 1,1,1
    const baseBoxBufferGeometry = new THREE.BoxBufferGeometry()
    // 克隆几何体
    const geometry = baseBoxBufferGeometry.clone()
    // 通过缩放设置几何体的大小
    geometry.scale(20, 20, 20)
    
  • 不再需要的物体应该进行销毁操作dispose

开始分析第一步

既然要分析如何开始,那么就需要先将手机拿出来,把微信跳一跳多撸几把先熟悉下地形

对于这种不知道从何开始的情况,我们首先必须要找到一个切入点(比如必须要先做什么),然后根据这个切入点层层展开,直至揭开这个游戏的面纱,这有点类似编程界经常冒出的一个词面向过程式,不怎么高大上,但很实用。

  • 首先我们必须要创建一个场景,然后在场景中创建一个盒子,并且我要用像微信跳一跳一样的视角看向这个盒子
  • 待发现......

场景创建

场景的创建很简单,也就是threejs的三大组件。需要注意的是,场景有多大?其实我不知道...

打开微信跳一跳撸几把......,别忘了仔细观察!!!

确定场景大小

其实是无法肉眼确定场景是多大的,但是能确定在场景中应该使用什么相机。没错,正交相机,这个从微信跳一跳的界面应能很清晰的感觉到,物体大小和远近没有关系,这里2张图片直观的展示了正交相机和透视相机的区别。

那解决方法就显而易见了,我们只需要自己定义一个场景大小,然后将里面的物体大小相对场景大小取一个合适的范围就行了,canvas的宽高有点像视觉视口,场景大小有点像布局视口,然后将布局视口缩放至视觉视口大小。假设图中物体宽度是场景宽度的一半,如果我设置场景宽度为1000,那么我绘制物体时将宽度设置为500就好了,或者也可以定义其它尺寸,考虑微信跳一跳是全屏并适应不同手机的,我们使用innerWidth、innerHeight设置场景大小。

创建相机

既然要用正交相机,也确定了场景大小,那也就是确定了正交相机的视锥体的宽高,然后近端面和远端面合理就行,这取决于相机角度。我们创建相机,并将相机位置设置为-100,100,-100,让X轴Z轴在我们前方,原因就是之后移动的时候可以不必用负数坐标(辅助线的方向是正向)

  const { innerWidth, innerHeight } = window
  /**
   * 场景
   */
  const scene = new THREE.Scene()
  // 场景背景,用于调试
  scene.background = new THREE.Color( 0xf5f5f5 )
  // 坐标辅助线,在调试阶段非常好用
  scene.add(new THREE.AxesHelper(10e3))

  /**
   * 相机
   */
  const camera = new THREE.OrthographicCamera(-innerWidth / 2, innerWidth / 2, innerHeight / 2, -innerHeight / 2, 0.1, 1000)
  camera.position.set(-100, 100, -100)
  // 看向场景中心点
  camera.lookAt(scene.position)
  scene.add(camera)

  /**
   * 盒子
   */
  const boxGeometry = new THREE.BoxBufferGeometry(100, 50, 100)
  const boxMaterial = new THREE.MeshLambertMaterial({ color: 0x67C23A })
  const box = new THREE.Mesh(boxGeometry, boxMaterial)
  scene.add(box)

  /**
   * 渲染器
   */
  const canvas = document.querySelector('#canvas')
  const renderer = new THREE.WebGLRenderer({
      canvas,
      alpha: true, // 透明场景
      antialias:true // 抗锯齿
  })
  renderer.setSize(innerWidth, innerHeight)

  // 渲染
  renderer.render(scene, camera)

这些过程都比较简单,没什么难的,但是发现盒子是个纯黑的,只能看到一点点轮廓,这是因为没有光线照射,现在给它一丢丢光线...

/**
 * 平行光
 */
const light = new THREE.DirectionalLight(0xffffff, .8)
light.position.set(-200, 600, 300)
// 环境光
scene.add(new THREE.AmbientLight(0xffffff, .4))
scene.add(light)

现在我们看到了这个盒子的颜色,也有了相应的轮廓,我们的第一步完成了,嘿嘿嘿。但是少了点什么?

打开微信跳一跳一顿琢磨......

看完发现盒子的影子在哪呢?

显示阴影

根据阴影的必要条件,我们首先需要创建一个地面,用来接收盒子等物品的阴影,这个地面作为整个游戏所有物体的阴影接收者。

const planeGeometry = new THREE.PlaneBufferGeometry(10e2, 10e2, 1, 1)
const planeMeterial = new THREE.MeshLambertMaterial({ color: 0xffffff })

const plane = new THREE.Mesh(planeGeometry, planeMeterial)
plane.rotation.x = -.5 * Math.PI
plane.position.y = -.1
// 接收阴影
plane.receiveShadow = true
scene.add(plane)

与此同时

// 让物体投射阴影
box.castShadow = true

// 让平行光投射阴影
light.castShadow = true
// 定义可见域的投射阴影
light.shadow.camera.left = -400
light.shadow.camera.right = 400
light.shadow.camera.top = 400
light.shadow.camera.bottom = -400
light.shadow.camera.near = 0
light.shadow.camera.far = 1000
// 定义阴影的分辨率
light.shadow.mapSize.width = 1600
light.shadow.mapSize.height = 1600

// 场景开启阴影
renderer.shadowMap.enabled = true

ok,阴影出现了。但是,可以发现白色的地面没有完全撑满相机的可视区,露出了地面以外的场景,这肯定是不能接受的。我们期望的效果应该是地面铺满整个可视区,为什么发生这种情况?(即使此时将地面设置的非常大)


写到后面时,无意中在threejs文档看到阴影材质(ShadowMaterial),可以将地面的材质更换为这个,然后为场景设置一个背景色。


确定相机位置

一张相机右侧的垂直截面,可以发现,当我们以场景中心点固定一个垂直方向角度∠a的时候,相机和地面的距离y是有范围限制的,当y小于minY时,将出现可视区下边的空白区,大于maxY的时候会出现上边的空白区。此时我们通过调整相机远近就可以解决这种空白问题。

同时,很容易看出minY可以通过∠a和正交相机的下侧面高度算出来

const computeCameraMinY = (radian, bottom) => Math.cos(radian) * bottom

对于maxY,可以先算出场景中心点到视锥体远截面的垂直距离,然后就能得到近截面到场景中心点的距离,就能算出最大的maxY

const computeCameraMaxY = (radian, top, near, far) => {
    const farDistance = top / Math.tan(radian)
    const nearDistance = far - near - farDistance
    return Math.sin(radian) * nearDistance
}

固定垂直方向角度的情况下,相机的y值范围确定好了,那么水平方向有范围限制吗?根据上图可以发现,只要y值正常,水平的坐标xz应该是由y和水平方向的夹角决定的。

所以我们还需要确定一个水平方向的角度,不妨以X轴来确定它,固定水平方向角度为∠b

为了方便理解,上图是以225度画出来的。现在:

  • 已知∠a,计算出y(可以取一个区间内的值)
  • 已知∠b,计算出xz
/**
 * 根据角度计算相机初始位置
 * @param {Number} verticalDeg 相机和场景中心点的垂直角度
 * @param {Number} horizontalDeg 相机和x轴的水平角度
 * @param {Number} top 相机上侧面
 * @param {Number} bottom 相机下侧面
 * @param {Number} near 摄像机视锥体近端面
 * @param {Number} far 摄像机视锥体远端面
 */
export function computeCameraInitalPosition (verticalDeg, horizontalDeg, top, bottom, near, far) {
  const verticalRadian = verticalDeg * (Math.PI / 180)
  const horizontalRadian = horizontalDeg * (Math.PI / 180)
  const minY = Math.cos(verticalRadian) * bottom
  const maxY = Math.sin(verticalRadian) * (far - near - top / Math.tan(verticalRadian))
  
  if (minY > maxY) {
    console.warn('警告: 垂直角度太小了!')
  }
  // 取一个中间值靠谱
  const y = minY + (maxY - minY) / 2
  const longEdge = y / Math.tan(verticalRadian)
  const x = Math.sin(horizontalRadian) * longEdge
  const z = Math.cos(horizontalRadian) * longEdge

  return { x, y, z }
}

感谢兴趣的朋友可以自己尝试一下,将函数中的y设置成minY,maxY区间之外的值,就会出现前面讨论的问题。

地面的大小范围就不用纠结了,我们知道相机视锥体的范围是多大,所以尽可能将地面的大小设置的稍微大一点就行了

现在地面应该能完全展示在可视区了,然后撸一把微信跳一跳,大致确定一下游戏的摄像机位置

const { x, y, z } = computeCameraInitalPosition(35, 225, offsetHeight / 2, offsetHeight / 2, 0.1, 1000)
camera.position.set(x, y, z)

按照现在的设置,会警告垂直角度太小了,这时可以根据刚刚的分析将相机的远截面调大一些

const camera = new THREE.OrthographicCamera(-innerWidth / 2, innerWidth / 2, offsetHeight / 2, -offsetHeight / 2, 0.1, 2000)
const { x, y, z } = computeCameraInitalPosition(35, 225, offsetHeight / 2, offsetHeight / 2, 0.1, 2000)
camera.position.set(x, y, z)

将盒子放到地面

可以发现此时的盒子只有一半露出了地面,我们需要将它放到地平面上,因为在微信跳一跳中生成新的盒子的时候,是由上方掉落的,有一个物体弹球下落的动画过程,那么我们这样做:

box.translateY(15)

有问题吗?这需要在玩游戏的时候观察的仔细一点,奉劝你打开微信跳一跳撸一把先......

仔细研究会发现盒子除了出场时候的动画,在小人蓄力的时候同样是有动画过程的,那是一个缩放操作。然后根据前置知识中的第四点,我们需要将盒子的缩放原点放在底部中心,于是就有:

box.geometry.translate(0, 15, 0)

现在盒子被放置在地面上,在之后我们写盒子落地动画和缩放时,就方便很多了。

确定盒子样式

前面约定了场景大小为innerWidth、innerHeight,那么对于盒子的大小,为了让不同的手机看到的盒子大小比例是一致的,可以先根据场景大小酌情而定,毕竟这是个临摹项目,也没有什么设计规范,所以盒子的宽度、深度、高度我们酌情处理。

同时,通过体验和观摩微信跳一跳,里面的盒子应该是有一部分定制的,有一部分是随机的,有不同大小和不同形状。那么我们可以优先考虑实现随机的那一部分,然后试试通过类似可配置的方式支持一下定制的盒子,毕竟也就是外观上的不同,游戏逻辑是不变的。

既然需要随机生成盒子,考虑到不同盒子之间有太多可能的差异,我们只能从众多盒子中找出一部分有相似性的盒子抽象出来,然后用一个专门的函数来生成它,比如实现一个boxCreator函数,这个函数生成大小不一、颜色随机的立方体盒子。想到这里,我们似乎可以通过维护一个集合,这个集合专门存放各种不同风格的盒子的生成器(即函数),来达到可定制化的需求,比如后期产品需要加一个贴了xxx广告的xxx形状的盒子,我们可以往这个集合中添加一个新的道具生成器就行了,而这个盒子的样式由外部来定。

既然盒子的样式可以由外部来定,那就需要有一个统一的规范,比如盒子的宽度、深度、高度范围,再比如考虑性能上的优化,我们最好提供一个可copy的几何对象和材质。

// 维护一个道具生成器集合
const boxCreators = []
// 共享立方体
const baseBoxBufferGeometry = new THREE.BoxBufferGeometry()
// 共享材质
const baseMeshLambertMaterial = new THREE.MeshLambertMaterial()
// 随机颜色
const colors = [0x67C23A, 0xE6A23C, 0xF56C6C]
// 盒子大小限制范围
const boxSizeRange = [30, 60]

// 实现一个默认的生成大小不一、颜色随机的立方体盒子的生成器
const defaultBoxCreator = () => {
    const [minSize, maxSize] = boxSizeRange
    const randomSize = ~~(random() * (maxSize - minSize + 1)) + minSize
    const geometry = baseBoxBufferGeometry.clone()
    geometry.scale(randomSize, 30, randomSize)
    
    const randomColor = colors[~~(Math.random() * colors.length)]
    const material = baseMeshLambertMaterial.clone()
    material.setValues({ randomColor })
    
    return new THREE.Mesh(geometry, material)
}

// 将盒子创造起存入管理集合中
boxCreators.push(defaultBoxCreator)

到现在为止,我们应该已经有了一个实现该游戏的思路雏型,在开始大刀阔斧的之前,我认为应该先做点什么。回看前面的代码,完全是过程式,没有抽象也没有模块化概念,初期这可能对我们非常有帮助,但在后期这种思维可能对我们产生很多负面影响,没有一个清晰的架构,实现过程可能是拆东墙补西墙似的痛苦。所以接下来,思考一下针对这个游戏框架我们需要做什么样的优化。

面向对象的开始

人生本就是一场游戏,游戏中有你我他,游戏有游戏的规则,还有什么比现实世界更具有参考性的?

我们创建一个跳一跳的游戏世界,它应该维持整个游戏的运转:

// index.js
class JumpGameWorld {
    constructor () {
        // ...
    }
}

就像我们人类生活在地球上,地球作为我们放飞自我的大舞台,那跳一跳怎么能没有一个类似地球的载体?创建一个跳一跳的游戏舞台:

// State.js
class Stage {
    constructor () {}
}

舞台中有一个小人:

// LittleMan.js
class LittleMan {
    constructor () {}
}

舞台中还有道具(盒子)

// Prop.js
class Prop {
    constructor () {}
}

道具各有各的特色,并且不是凭空产生,所以实现一个道具生成器(就像工厂):

// PropCreator.js
class PropCreator () {
    constructor () {}
}

此外,还有通用的几何体和材质、工具方法管理

// utils.js

// 材质
export const baseMeshLambertMaterial = new THREE.MeshLambertMaterial()
// 立方体
export const baseBoxBufferGeometry = new THREE.BoxBufferGeometry()

// ...

完善舞台 Stage

确定好了游戏的结构,那之后就跟着这个骨架来完善它,接下来将舞台逻辑完善下:

class Stage {
  constructor ({
    width,
    height,
    canvas,
    axesHelper = false, // 辅助线
    cameraNear, // 相机近截面
    cameraFar, // 相机远截面
    cameraInitalPosition, // 相机初始位置
    lightInitalPosition // 光源初始位置
  }) {
    this.width = width
    this.height = height
    this.canvas = canvas
    this.axesHelper = axesHelper
    // 正交相机配置
    this.cameraNear = cameraNear
    this.cameraFar = cameraFar
    this.cameraInitalPosition = cameraInitalPosition
    this.lightInitalPosition = lightInitalPosition
    
    this.scene = null
    this.plane = null
    this.light = null
    this.camera = null
    this.renderer = null

    this.init()
  }

  init () {
    this.createScene()
    this.createPlane()
    this.createLight()
    this.createCamera()
    this.createRenterer()
    this.render()
    this.bindResizeEvent()
  }

  bindResizeEvent () {
    const { container, renderer } = this
    window.addEventListener('resize', () => {
      const { offsetWidth, offsetHeight } = container

      this.width = offsetWidth
      this.height = offsetHeight

      renderer.setSize(offsetWidth, offsetHeight)
      renderer.setPixelRatio(window.devicePixelRatio)
      this.render()
    }, false)
  }

  // 场景
  createScene () {
    const scene = this.scene = new THREE.Scene()

    if (this.axesHelper) {
      scene.add(new THREE.AxesHelper(10e3))
    }
  }

  // 地面
  createPlane () {
    const { scene } = this
    const geometry = new THREE.PlaneBufferGeometry(10e2, 10e2, 1, 1)
    const meterial = new THREE.ShadowMaterial()
    meterial.opacity = 0.5

    const plane = this.plane = new THREE.Mesh(geometry, meterial)

    plane.rotation.x = -.5 * Math.PI
    plane.position.y = -.1
    // 接收阴影
    plane.receiveShadow = true
    scene.add(plane)
  }

  // 光
  createLight () {
    const { scene, lightInitalPosition: { x, y, z }, height } = this
    const light = this.light = new THREE.DirectionalLight(0xffffff, .8)

    light.position.set(x, y, z)
    // 开启阴影投射
    light.castShadow = true
    // // 定义可见域的投射阴影
    light.shadow.camera.left = -height
    light.shadow.camera.right = height
    light.shadow.camera.top = height
    light.shadow.camera.bottom = -height
    light.shadow.camera.near = 0
    light.shadow.camera.far = 2000
    // 定义阴影的分辨率
    light.shadow.mapSize.width = 1600
    light.shadow.mapSize.height = 1600

    // 环境光
    scene.add(new THREE.AmbientLight(0xffffff, .4))
    scene.add(light)
  }

  // 相机
  createCamera () {
    const {
      scene,
      width, height,
      cameraInitalPosition: { x, y, z },
      cameraNear, cameraFar
    } = this
    const camera = this.camera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, cameraNear, cameraFar)

    camera.position.set(x, y, z)
    camera.lookAt(scene.position)
    scene.add(camera)
  }

  // 渲染器
  createRenterer () {
    const { canvas, width, height } = this
    const renderer = this.renderer = new THREE.WebGLRenderer({
      canvas,
      alpha: true, // 透明场景
      antialias:true // 抗锯齿
    })

    renderer.setSize(width, height)
    // 开启阴影
    renderer.shadowMap.enabled = true
    // 设置设备像素比
    renderer.setPixelRatio(window.devicePixelRatio)
  }

  // 执行渲染
  render () {
    const { scene, camera } = this
    this.renderer.render(scene, camera)
  }

  add (...args) {
    return this.scene.add(...args)
  }
  
  remove (...args) {
    return this.scene.remove(...args)
  }
}

完善生成器 PropCreator

前面我们已经大致确定了需要维护一个道具生成器集合,集合中有默认的道具生成器,也支持后期添加定制化的生成器。基于这个逻辑PropCreator应该对外提供一个api比如createPropCreator来新增生成器,这个api中还需要提供对应的辅助属性,比如道具的大小范围、通用材质等等。

那这个对外api需要考虑些什么呢?

  • 需要告诉外部,道具大小的限制,如果定制化的道具很大或者很小,那游戏就没法完了
  • 考虑一下性能,将一些通用的材质、几何体提供给外部
  • ......
  /**
   * 新增定制化的生成器
   * @param {Function} creator 生成器函数
   * @param {Boolean} isStatic 是否是动态创建
   */
  createPropCreator (creator, isStatic) {
    if (Array.isArray(creator)) {
      creator.forEach(crt => this.createPropCreator(crt, isStatic))
    }

    const { propCreators, propSizeRange, propHeight } = this

    if (propCreators.indexOf(creator) > -1) {
      return
    }

    const wrappedCreator = function () {
      if (isStatic && wrappedCreator.box) {
        // 静态盒子,下次直接clone
        return wrappedCreator.box.clone()
      } else {
        const box = creator(THREE, {
          propSizeRange,
          propHeight,
          baseMeshLambertMaterial,
          baseBoxBufferGeometry
        })

        if (isStatic) {
          // 被告知是静态盒子,缓存起来
          wrappedCreator.box = box
        }
        return box
      }
    }

    propCreators.push(wrappedCreator)
  }

假如有一个生成器只有一种样式,那将么有必要每次都重新生成,支持传入一个isStatic来告诉生成器是否可以缓存,这样后续重复生成时就不必重新创建。

接下来实现内置的生成器,为了方便扩展,这里新建一个文件来维护defaultProp.js

const colors = [0x67C23A, 0xE6A23C, 0xF56C6C, 0x909399, 0x409EFF, 0xffffff]

// 静态
export const statics = [
  // ...
]

// 非静态
export const actives = [
  // 默认纯色立方体创造器
  function defaultCreator (THREE, helpers) {
    const {
      propSizeRange: [min, max],
      propHeight,
      baseMeshLambertMaterial,
      baseBoxBufferGeometry
    } = helpers

    // 随机颜色
    const color = randomArrayElm(colors)
    // 随机大小
    const size = rangeNumberInclusive(min, max)

    const geometry = baseBoxBufferGeometry.clone()
    geometry.scale(size, propHeight, size)

    const material = baseMeshLambertMaterial.clone()
    material.setValues({ color })

    return new THREE.Mesh(geometry, material)
  },
]

默认的道具生成器实现了,可能我不需要默认的,可以实现一下可配置:

  constructor ({
    propHeight,
    propSizeRange,
    needDefaultCreator
  }) {
    this.propHeight = propHeight
    this.propSizeRange = propSizeRange

    // 维护的生成器
    this.propCreators = []

    if (needDefaultCreator) {
      this.createPropCreator(actives, false)
      this.createPropCreator(statics, true)
    }
  }

然后对于游戏内部,需要提供一个api来随机执行生成器生成道具,这里注意到微信跳一跳每次开局的头2个盒子都是一种风格(立方体),所以可以做一下控制,支持传入一个索引来生成指定的盒子。

  createProp (index) {
    const { propCreators } = this
    return index > -1
      ? propCreators[index] && propCreators[index]() || randomArrayElm(propCreators)()
      : randomArrayElm(propCreators)()
  }

到这里,道具生成器就差不多了,但是不要掉以轻心,做正儿八经的产品时我估计少不了一顿琢磨。比如:

  • 控制道具的出现频率以及次数
  • 不同道具是否不一样的入场动画
  • ......

完善道具类 Prop

道具类后期需要不断的扩充,除了几个基本的属性外,后续还有其它的东西需要扩展,比如对道具某些属性的访问和计算,以及道具的动画,此时我也确定写到后面需要什么。

class Prop {
  constructor ({
    world, // 所处世界
    stage, // 所处舞台
    body, // 主体
    height
  }) {
    this.world = world
    this.stage = stage
    this.body = body
    this.height = height
  }
  
  getPosition () {
    return this.body.position
  }

  setPosition (x, y, z) {
    return this.body.position.set(x, y, z)
  }
}

初始化舞台和道具生成器

接下在游戏世界中将舞台和道具生成器进行初始化,同时需要注意,道具生成器只负责生成道具,它并不知道生成的道具应该出现在什么位置,所以在JumpGameWorld我们需要实现一个内部的createProp方法来告诉道具生成器给我生成一个盒子,然后由我决定将它放在那里。

  constructor ({
    container,
    canvas,
    needDefaultCreator = true,
    axesHelper = false
  }) {
    const { offsetWidth, offsetHeight } = container
    this.container = container
    this.canvas = canvas
    this.width = offsetWidth
    this.height = offsetHeight
    this.needDefaultCreator = needDefaultCreator
    this.axesHelper = axesHelper
    
    // 经过多次尝试
    const [min, max] = [~~(offsetWidth / 6), ~~(offsetWidth / 3.5)]
    this.propSizeRange = [min, max]
    this.propHeight = ~~(max / 2)

    this.stage = null
    this.propCreator = null

    this.init()
  }
  
  // 初始化舞台
  initStage () {
    const { container, canvas } = this
    const { offsetHeight } = container
    const axesHelper = true
    const cameraNear = 0.1
    const cameraFar = 2000
    // 计算相机应该放在哪里
    const cameraInitalPosition = this.cameraInitalPosition = computeCameraInitalPosition(35, 225, offsetHeight / 2, offsetHeight / 2, cameraNear, cameraFar)
    const lightInitalPosition = this.lightInitalPosition = { x: -300, y: 600, z: 200 }
    
    this.stage = new Stage({
      container,
      canvas,
      axesHelper,
      cameraNear,
      cameraFar,
      cameraInitalPosition,
      lightInitalPosition
    })
  }

  // 初始化道具生成器
  initPropCreator () {
    const { needDefaultCreator, propSizeRange, propHeight } = this

    this.propCreator = new PropCreator({
      propHeight,
      propSizeRange,
      needDefaultCreator
    })
  }
  
  // 对外的新增生成器的接口
  createPropCreator (...args) {
    this.propCreator.createPropCreator(...args)
  }

那么接下来我需要将盒子放到哪里呢?打开微信跳一跳,撸一把......

撸完回来会发现,新出的盒子可能会在2个方向上生成,X轴X轴,并且生成2个盒子之间的距离应该是随机的,但是距离肯定得有一个范围限制,不能出现盒子挨着盒子或者盒子出现在可视区之外的情况。所以,这里先根据盒子大小范围和场景大小约定一下盒子之间的间距范围propDistanceRange = [~~(min / 2), max * 2],先酌情而定,不行再调一下。

那么想到这里,我们似乎需要先实现一个计算盒子入场位置的方法computeMyPosition。要计算下一个盒子的距离就得拿到上一个盒子,同时游戏过程中会生成一大堆盒子,不可能将它们丢弃不管,为了考虑性能,我们还需要定时对盒子进行清理和销毁操作,所以还需要有一个集合props来管理已经创建的盒子,这样的话,每次创建盒子时拿到最近创建的一个盒子就行了。这里需要注意一下:

  1. 通过多次观摩微信跳一跳的第二个盒子,发现第二个盒子和第一个盒子的距离总是一样,所以对于第二个盒子的距离我们单独处理下。
  2. 除了头2个盒子距离一样,之前忽略了一点,头2个盒子的大小也是一样的,所以需要回头把PropCreator的默认道具生成器处理一下,判断如果是前面2个盒子就设置固定尺寸
  3. 盒子的入场动画从第三个盒子才开始,前2个盒子游戏开始就直接出现,所以,前2个盒子入场的高度肯定是0了,之后的盒子入场高度是多少我也不知道,酌情而定
  4. 计算盒子的距离时,需要算上盒子自身的尺寸,所以需要获取到盒子的尺寸
// utils.js
export const getPropSize = box => {
  const box3 = getPropSize.box3 || (getPropSize.box3 = new THREE.Box3())
  box3.setFromObject(box)
  return box3.getSize(new THREE.Vector3())
}

// Prop.js
  getSize () {
    return getPropSize(this.body)
  }

然后Prop

class Prop {
  constructor ({
    // ...
    enterHeight,
    distanceRange,
    prev
  }) {
    // ...
    this.enterHeight = enterHeight
    this.distanceRange = distanceRange
    this.prev = prev
  }

  // 计算位置
  computeMyPosition () {
    const {
      world,
      prev,
      distanceRange,
      enterHeight
    } = this
    const position = {
      x: 0,
      // 头2个盒子y值为0
      y: enterHeight,
      z: 0
    }

    if (!prev) {
      // 第1个盒子
      return position
    }

    if (enterHeight === 0) {
      // 第2个盒子,固定一个距离
      position.z = world.width / 2
      return position
    }

    const { x, z } = prev.getPosition()
    // 随机2个方向 x or z
    const direction = Math.round(Math.random()) === 0
    const { x: prevWidth, z: prevDepth } = prev.getSize()
    const { x: currentWidth, z: currentDepth } = this.getSize()
    // 根据区间随机一个距离
    const randomDistance = rangeNumberInclusive(...distanceRange)

    if (direction) {
      position.x = x + prevWidth / 2 + randomDistance + currentWidth / 2
      position.z = z
    } else {
      position.x = x
      position.z = z + prevDepth / 2 + randomDistance + currentDepth / 2
    }

    return position
  }

  // 将道具放入舞台
  enterStage () {
    const { stage, body, height } = this
    const { x, y, z } = this.computeMyPosition()

    body.castShadow = true
    body.receiveShadow = true
    body.position.set(x, y, z)
    // 需要将盒子放到地面
    body.geometry.translate(0, height / 2, 0)
    
    stage.add(body)
    stage.render()
  }

  // 获取道具大小
  getSize () {
    return getPropSize(this.body)
  }
  
  // ...
}

现在可以实现盒子生成的逻辑了

  // JumpGameWorld.js
  // 创建盒子
  createProp (enterHeight = 100) {
    const {
      height,
      propCreator,
      propHeight,
      propSizeRange: [min, max],
      propDistanceRange,
      stage, props,
      props: { length }
    } = this
    const currentProp = props[length - 1]
    const prop = new Prop({
      world: this,
      stage,
      // 头2个盒子用第一个创造器生成
      body: propCreator.createProp(length < 3 ? 0 : -1),
      height: propHeight,
      prev: currentProp,
      enterHeight,
      distanceRange: propDistanceRange
    })
    const size = prop.getSize()

    if (size.y !== propHeight) {
      console.warn(`高度: ${size.y},盒子高度必须为 ${propHeight}`)
    }
    if (size.x < min || size.x > max) {
      console.warn(`宽度: ${size.x}, 盒子宽度必须为 ${min} - ${max}`)
    }
    if (size.z < min || size.z > max) {
      console.warn(`深度: ${size.z}, 盒子深度度必须为 ${min} - ${max}`)
    }

    prop.enterStage()
    props.push(prop)
  }

然后初始化一下

  init () {
    this.initStage()
    this.initPropCreator()
    // 第一个道具
    this.createProp()
    // 第二个道具
    this.createProp()
  }

到这里,已经实现了随机生成道具的功能,但现在场景是静止的,没法去验证生成更多道具的逻辑,所以下一步,我们先实现场景移动。

场景移动

拿起手机打开微信跳一跳继续琢磨......

我们先不管小人是否存在,可以发现每一次生成盒子的同时,场景就开始移动了。那么如何移动呢?可以通过移动相机达到场景移动的效果,没啥好纠结的,这就是规律,就像拍电影一样,人动了,你的摄像机能不跟着动吗?

那么问题来了,我们要把相机移动到哪个位置?

  • 设置相机位置的同时,如何保证最新的2个盒子在可视区中有合适的位置
  • 盒子的大小不一,会不会出现有一半出现在场景外的情况

没关系,先拿起手机打开微信跳一跳撸一撸......

你会发现场景每次移动后,中心点差不多是最新的2个盒子的中间,但是感觉略有向下偏移,我们不妨把它分解一下

这就好办了,我们算出最新的2个盒子中间的点,将这个点向下偏移一个值,然后将结果加上相机的初始位置,不就得到相机的位置了吗?这里约定偏移值为视锥体高度的1/10,然后在JumpGameWorld中:

  // 计算最新的2个盒子的中心点
  getLastTwoCenterPosition () {
    const { props, props: { length } } = this
    const { x: x1, z: z1 } = props[length - 2].getPosition()
    const { x: x2, z: z2 } = props[length - 1].getPosition()

    return {
      x: x1 + (x2 - x1) / 2,
      z: z1 + (z2 - z1) / 2
    }
  }
  
  // 移动相机,总是看向最后2个小球的中间位置
  moveCamera () {
    const {
      stage,
      height
      cameraInitalPosition: { x: initX, y: initY, z: initZ }
    } = this
    // 将可视区向上偏移一点,这样看起来道具的位置更合理
    const cameraOffsetY = height / 10

    const { x, y, z } = this.getLastTwoCenterPosition()
    const to = {
      x: x + initX + cameraOffsetY,
      y: initY, // 高度是不变的
      z: z + initZ + cameraOffsetY
    }

    // 移动舞台相机
    stage.moveCamera(to)
  }

得到了相机的位置后,我们需要在舞台类中提供对应的方法,Stage

  // 移动相机
  moveCamera ({ x, z }) {
    const { camera } = this
    camera.position.x = x
    camera.position.z = z
    this.render()
  }

现在相机已经可以移动了,我们设置一个定时器来测试一下,可以先将盒子的y值统一设置为0

  init () {
    this.initStage()
    this.initPropCreator()
    // 第一个道具
    this.createProp()
    // 第二个道具
    this.createProp()
    // 首次调整相机
    this.moveCamera()

    // 测试
    const autoMove = () => {
      setTimeout(() => {
        autoMove()
        // 每次有新的道具时,需要移动相机
        this.createProp()
        this.moveCamera()
      }, 2000)
    }
    autoMove()    
  }

ok,非常nice,但是测试时问题来了

  1. 相机移动到一定距离后,发现看不到道具的影子了
  2. 阴影的位置每次都变化,这不是期望的效果
  3. 相机需要平滑的过渡动画
  4. 相机移动了,必定有一部分盒子移出可视区外,它们还有用吗?没有用的话如何销毁呢?

暂时发现这么几个问题,我们一个个解决它。

影子的问题,这是因为地面不够大,那能将地面设置的足够大吗?根据我们前面对相机的分析可以知道,是可以的,因为我们没有改变相机的任何角度,只是进行了平移,但是这样做也太low了,并且最大值是有限的,所以,我们可以在每次移动相机的同时移动地面,造成地面没有移动的假象。那么地面的位置也就呼之而出了,就是那个中心点的位置。

阴影的问题,这和地面类似,我们也可以让光源跟着相机移动,但是光线需要注意一点

平行光的方向是从它的位置到目标位置。默认的目标位置为原点 (0,0,0)。 注意: 对于目标的位置,要将其更改为除缺省值之外的任何位置,它必须被添加到 scene 场景中去。

意思就是光线的目标位置如果改变了,必须要创建一个目标对象并添加到场景中去,也就是说,除了更新光源的位置,还需要对光照的目标位置进行更新

var targetObject = new THREE.Object3D();
scene.add(targetObject);

light.target = targetObject;

场景过渡,这个就没什么复杂的了,直接使用Tween.js插件,由于后续还有很多地方要用到过渡效果,我们可以先将它简单封装一下

export const animate = (configs, onUpdate, onComplete) => {
  const {
    from, to, duration,
    easing = k => k,
    autoStart = true // 为了使用tween的chain
  } = configs

  const tween = new TWEEN.Tween(from)
    .to(to, duration)
    .easing(easing)
    .onUpdate(onUpdate)
    .onComplete(() => {
      onComplete && onComplete()
    })

  if (autoStart) {
    tween.start()
  }

  animateFrame()
  return tween
}

const animateFrame = function () {
  if (animateFrame.openin) {
    return
  }
  animateFrame.openin = true

  const animate = () => {
    const id = requestAnimationFrame(animate)
    if (!TWEEN.update()) {
      animateFrame.openin = false
      cancelAnimationFrame(id)
    }
  }
  animate()
}

盒子的销毁,对于不在可视区的盒子,确实是有必要进行销毁的,毕竟当数量非常庞大的时候,会带来显著的性能问题。我们可以选择一个恰当的时机做这件事情,比如每次相机移动完成后执行盒子的清理操作。那该如何判断盒子是否在可视区?先搁着,待解决前面几个问题在考虑。

然后根据上面总结的问题改造一下moveCamera,不要忘记加一个光源目标对象lightTarget,然后还需要提供一个相机移动完成的回调(等下用来执行盒子销毁)

  // Stage.js
  // center为2个盒子的中心点
  moveCamera ({ cameraTo, center, lightTo }, onComplete, duration) {
    const {
      camera, plane,
      light, lightTarget,
      lightInitalPosition
    } = this

    // 移动相机
    animate(
      {
        from: { ...camera.position },
        to: cameraTo,
        duration
      },
      ({ x, y, z }) => {
        camera.position.x = x
        camera.position.z = z
        this.render()
      },
      onComplete
    )

    // 灯光和目标也需要动起来,为了保证阴影位置不变
    const { x: lightInitalX, z: lightInitalZ } = lightInitalPosition
    animate(
      {
        from: { ...light.position },
        to: lightTo,
        duration
      },
      ({ x, y, z }) => {
        lightTarget.position.x = x - lightInitalX
        lightTarget.position.z = z - lightInitalZ
        light.position.set(x, y, z)
      }
    )

    // 保证不会跑出有限大小的地面
    plane.position.x = center.x
    plane.position.z = center.z
  }

对应的,JumpGameWorld中也改造下

  // 移动相机,总是看向最后2个小球的中间位置
  moveCamera (duration = 500) {
    const {
      stage,
      cameraInitalPosition: { x: cameraX, y: cameraY, z: cameraZ },
      lightInitalPosition: { x: lightX, y: lightY, z: lightZ }
    } = this
    // 向下偏移值,取舞台高度的1/10
    const cameraOffsetY = stage.frustumHeight / 10

    const { x, y, z } = this.getLastTwoCenterPosition()
    const cameraTo = {
      x: x + cameraX + cameraOffsetY,
      y: cameraY, // 高度是不变的
      z: z + cameraZ + cameraOffsetY
    }
    const lightTo = {
      x: x + lightX,
      y: lightY,
      z: z + lightZ
    }

    // 移动舞台相机
    const options = {
      cameraTo,
      lightTo,
      center: { x, y, z }
    }
    stage.moveCamera(
      options,
      () => {
        // 执行盒子销毁操作
      },
      duration
    )
  }

盒子的销毁

何时进行销毁我们已经有思路了,那么销毁的依据是什么?很显然只要盒子不在可视区了就可以销毁了,因为场景是前进的,可视区的中心不断的往X轴或者Z轴方向前移。那么首先想到的是实现一个检测盒子是否在可视区的方法,threejs也有提供相应api可操作,感兴趣的朋友可以去了解下相关的算法,我就看不下去了,数学太弱。另外,threejs中的算法似乎是跟顶点和射线相关,物体(顶点越多)越复杂计算量越大。我们不妨尝试换一种方式看这个问题,那就是一定需要计算盒子是否在可视区吗?

凑合着看吧,不太好画出来。假设我们的场景大小是200*320,盒子大小范围是[30,60],另外还有盒子之间的间距限制[20,100],那么我们以最小的安全值来大致估算一下,放2个盒子30+20+30,已经有80宽了,也就是说200宽横放不超过4个。另外我们的可视区的中心点是处于最近的2个盒子的中心(不考虑相机的下偏移量),那么竖着放时,160高的范围最多竖着放3个盒子,再加上中心点上边的一个,也是4个盒子。也就是说,按照估算,可视区可能最多同时存在8个盒子(如果要抠字眼,可以实际测试一下,这里仅估算,误差应该还和相机角度有关)。

现在,逻辑已经很明确了,根据假设,当我们管理的盒子集合props的长度大于8时,就可以执行盒子销毁操作了,并且没有必要每次相机移动后都清理,可以固定一下每次清理几个,比如我们约定每次清理4个,那么每次有12个盒子时销毁4个,以此类推......

  // JumpGameWorld.js
  // 销毁道具
  clearProps () {
    const {
      width,
      height,
      safeClearLength,
      props, stage,
      props: { length }
    } = this
    const point = 4

    if (length > safeClearLength) {
      props.slice(0, point).forEach(prop => prop.dispose())
      this.props = props.slice(point)
    }
  }
  
  // 估算销毁安全值
  computeSafeClearLength () {
    const { width, height, propSizeRange } = this
    const minS = propSizeRange[0]
    const hypotenuse = Math.sqrt(minS * minS + minS * minS)
    this.safeClearLength = Math.ceil(width / minS) + Math.ceil(height / hypotenuse / 2) + 1
  }
  
  // Prop.js
  // 销毁
  dispose () {
    const { body, stage } = this

    body.geometry.dispose()
    body.material.dispose()
    stage.remove(body)
    // 解除对前一个的引用
    this.prev = null
  }

回想一下,如果用算法去处理盒子的销毁,可能也是得有一个安全值的,为什么呢?

如果出现图中的情况,并且没有设定一个安全值的话,算法会告诉你,图中倒数第4个盒子已经出了可视区了,那我们应该清理吗?按照下一个盒子可能的方向,如果和图中一致,场景会右移,这时候这个盒子应该出现在可视区,而不是销毁掉。

问题一个接着一个的来,下一步,我们实现盒子的入场弹球下落

盒子弹球下落

加入一个动画其实很简单,可以在创建盒子进入舞台时处理它,现在实现一个entranceTransition方法

  // 放入舞台
  enterStage () {
    // ...

    this.entranceTransition()
  }
  // 盒子的入场动画
  entranceTransition (duration = 400) {
    const { body, enterHeight, stage } = this

    if (enterHeight === 0) {
      return
    }

    animate(
      {
        to: { y: 0 },
        from: { y: enterHeight },
        duration,
        easing: TWEEN.Easing.Bounce.Out
      },
      ({ y }) => {
        body.position.setY(y)
        stage.render()
      }
    )
  }

到此,我们已经实现了场景、道具的主要逻辑,已经初具规模嘿嘿嘿。

小人实现 LittleMan

现在来实现小人的逻辑,打开微信跳一跳多撸几把......

然后分析一下和小人相关点都有哪些?

  1. 他有2个部分,头和身体
  2. 起跳前有一个蓄力过程
  3. 蓄力时盒子有一个受挤压过程
  4. 蓄力时周围有特效(叫什么不清楚)
  5. 蓄力时身体缩放,头部下移,也就是说身体的部分需要将缩放原点放在小人的脚下
  6. 起跳时盒子有一个回弹动画
  7. 空中有翻转
  8. 空中有残影
  9. 落地时身体有短暂的缓冲过程
  10. 落地时地面有特效

下面,将它们一一解开......

绘制小人

首先头部很简单,就是一个圆。身体部分是一个不规则的圆柱,由于刚接触threejs,不知道有什么捷径去画这个身体部分,所以这里我用三个几何体将身体组合起来,画之前我们得回看分析的那些点,看看画的时候是不是需要注意什么。首先有影响的肯定是缩放功能(注意头部不会缩放),这要求画的时候将身体的缩放原点放在他脚下,然后还有空中翻转,这部分暂时不太清楚翻转的原点在哪里(太快),可能是身体和头部的整体的中心点,也可能不是,但这不影响我们能确定身体和头是一个整体(threejs的组),至于翻转的原点在哪,等我们做出来之后调试效果时再做处理,那么稳妥起见,用一张图来描述应该怎么画

每个虚线框都代表一层包装(网格或者组),对于小人,如果要修改旋转原点只需要调整头和身体组的上下偏移位置即可做到。

我琢磨了一下微信跳一跳的开场画面(就是还没有点开始游戏时),小人是从空白的地方跳上盒子的,开始游戏后是从空中落到盒子上,那么小人应该有一个入场的方法enterStage,然后身体创建的方法createBody,还应该有一个跳跃方法jump。so:

class LittleMan {
  constructor ({
    world,
    color
  }) {
    this.world = world
    this.color = color

    this.stage = null
  }

  // 创建身体
  createBody () {}

  // 进入舞台
  enterStage () {}

  // 跳跃
  jump () {}
}

我们先将身体画出来,由于场景宽度是根据视口宽度设置的,所以小人的尺寸动态需要算一下。

  // 创建身体
  createBody () {
    const { color, world: { width } } = this
    const material = baseMeshLambertMaterial.clone()
    material.setValues({ color })

    // 头部
    const headSize = this.headSize = width * .03
    const headTranslateY = this.headTranslateY = headSize * 4.5
    const headGeometry = new THREE.SphereGeometry(headSize, 40, 40)
    const headSegment = this.headSegment = new THREE.Mesh(headGeometry, material)
    headSegment.castShadow = true
    headSegment.translateY(headTranslateY)

    // 身体
    this.width = headSize * 1.2 * 2
    this.bodySize = headSize * 4
    const bodyBottomGeometry = new THREE.CylinderBufferGeometry(headSize * .9, this.width / 2, headSize * 2.5, 40)
    bodyBottomGeometry.translate(0, headSize * 1.25, 0)
    const bodyCenterGeometry = new THREE.CylinderBufferGeometry(headSize, headSize * .9, headSize, 40)
    bodyCenterGeometry.translate(0, headSize * 3, 0)
    const bodyTopGeometry = new THREE.SphereGeometry(headSize, 40, 40)
    bodyTopGeometry.translate(0, headSize * 3.5, 0)

    const bodyGeometry = new THREE.Geometry()
    bodyGeometry.merge(bodyTopGeometry)
    bodyGeometry.merge(new THREE.Geometry().fromBufferGeometry(bodyCenterGeometry))
    bodyGeometry.merge(new THREE.Geometry().fromBufferGeometry(bodyBottomGeometry))

    // 缩放控制
    const translateY = this.bodyTranslateY = headSize * 1.5
    const bodyScaleSegment = this.bodyScaleSegment = new THREE.Mesh(bodyGeometry, material)
    bodyScaleSegment.castShadow = true
    bodyScaleSegment.translateY(-translateY)

    // 旋转控制
    const bodyRotateSegment = this.bodyRotateSegment = new THREE.Group()
    bodyRotateSegment.add(headSegment)
    bodyRotateSegment.add(bodyScaleSegment)
    bodyRotateSegment.translateY(translateY)

    // 整体身高 = 头部位移 + 头部高度 / 2 = headSize * 5
    const body = this.body = new THREE.Group()
    body.add(bodyRotateSegment)
  }

然后我们需要让小人走到舞台中的指定位置

  // 进入舞台
  enterStage (stage, { x, y, z }) {
    const { body } = this
    
    body.position.set(x, y, z)

    this.stage = stage
    stage.add(body)
    stage.render()
  }

在游戏中初始化,并让小人进入场景

  // JumpGameWorld.js
  // 初始化小人
  initLittleMan () {
    const { stage, propHeight } = this
    const littleMan = this.littleMan = new LittleMan({
      world: this,
      color: 0x386899
    })
    littleMan.enterStage(stage, { x: 0, y: propHeight, z: 0 })
  }

第一步已经完成,接下来,我们需要让小人动起来,实现他的弹跳功能。

实现小人弹跳

打开微信跳一跳,这个需要仔细琢磨琢磨......

我们可以将整个弹跳过程分解一下,蓄力 -> 起跳 -> 抛物线运动 -> 着地 -> 缓冲,这里 的蓄力就是鼠标按下(touchstart或者mousedown)时发生,起跳是松开时(touchend或者mouseup)发生。需要注意的是,如果连续按下和松开,在小人没有落地前是不能做任何操作的,还有一种情况就是:如果小人在空中时鼠标按下,落地一段时间后鼠标松开,这时也是不能做任何操作的,所以我们可以在按下之后绑定松开事件,然后松开事件发生后立即移除它。

  bindEvent () {
    const { container } = this.world
    const isMobile = 'ontouchstart' in document
    const mousedownName = isMobile ? 'touchstart' : 'mousedown'
    const mouseupName = isMobile ? 'touchend' : 'mouseup'
    
    // 该起跳了
    const mouseup = () => {
      if (this.jumping) {
        return
      }
      this.jumping = true
      // 蓄力动作应该停止
      this.poweringUp = false

      this.jump()
      container.removeEventListener(mouseupName, mouseup)
    }

    // 蓄力的时候
    const mousedown = event => {
      event.preventDefault()
      // 跳跃没有完成不能操作
      if (this.poweringUp || this.jumping) {
        return
      }
      this.poweringUp = true
      
      this.powerStorage()
      container.addEventListener(mouseupName, mouseup, false)
    }

    container.addEventListener(mousedownName, mousedown, false)
  }
  // 进入舞台
  enterStage (stage, { x, y, z }) {
    // ...
    this.bindEvent()
  }

蓄力的目的是为了跳的更远,也就是说,力度决定了远近,我们可以根据力度大小 * 系数去模拟计算一个射程,说到这里,脑海里蹦出一个词斜抛运动,似乎n年没有接触过了,然后默默的打开百度:斜抛运动

斜抛运动: 物体以一定的初速度斜向射出去,在空气阻力可以忽略的情况下,物体所做的这类运动叫做斜抛运动。物体作匀变速曲线运动,它的运动轨迹是抛物线。

微信跳一跳中是斜抛运动吗?打开它去琢磨一下......

上上下下观察了许久之后,以我的空间感几乎能断定它"应该"不是一个匀变速曲线的斜抛运动,毕竟斜抛运动公式在空气阻力可以忽略的情况下才有效,而微信跳一跳的轨迹完全就不像一个对称的抛物线嘛,它看起来像这样:

这应该比较像一个有阻力的斜抛运动,但我在网上没有找到考虑阻力的斜抛公式,所以,咱们在利用斜抛运动的时候可能得稍稍做一点改变。在不做修改的情况下,y值是需要通过x的值计算出来的,这样我们就没法比较直接的控制y的曲线。现在绕个弯,不如将y的运动分离出来,并且保留x轴的匀速,创建一个x轴的过渡,同时创建两个y轴的过渡,上升段减速,下降段加速,这里约定下上升时间为总时间的60%。然后,根据斜抛运动的相关公式,我们可以计算出水平射程射高运动时间我感觉微信跳一跳中是一个固定值,这里就不算了。

既然需要利用斜抛公式,那就需要创建2个变量,速度v0theta,在蓄力的同时通过递增v0和递减theta来模拟轨迹。先将公式准备好,同时JumpGameWorld中新增一个重力参数重力G,默认先用9.8

// 斜抛计算
export const computeObligueThrowValue = function (v0, theta, G) {
  const sin2θ = sin(2 * theta)
  const sinθ = sin(theta)

  const rangeR = pow(v0, 2) * sin2θ / G
  const rangeH = pow(v0 * sinθ, 2) / (2 * G)

  return {
    rangeR,
    rangeH
  }
}

蓄力

然后,我们现在实现蓄力的基本逻辑,要做的事情就是递增斜抛参数以及缩放小人,这里先不关注斜抛参数值,待我们让小人动起来之后再去调整它。还有一点需要注意一下,蓄力结束后,需要将小人复原,但不能直接复原,需要将效蓄力结束时的值保存起来,然后在抛物线运动阶段将小人复原,这样效果就比较平滑了。

  resetPowerStorageParameter () {
    this.v0 = 20
    this.theta = 90

    // 由于蓄力导致的变形,需要记录后,在空中将小人复原
    this.toValues = {
      headTranslateY: 0,
      bodyScaleXZ: 0,
      bodyScaleY: 0
    }
    this.fromValues = this.fromValues || {
      headTranslateY: this.headTranslateY,
      bodyScaleXZ: 1,
      bodyScaleY: 1
    }
  }

  // 蓄力
  powerStorage () {
    const { stage, bodyScaleSegment, headSegment, fromValues, bodySize } = this

    this.resetPowerStorageParameter()

    const tween = animate(
      {
        from: { ...fromValues },
        to: {
          headTranslateY: bodySize - bodySize * .6,
          bodyScaleXZ: 1.3,
          bodyScaleY: .6
        },
        duration: 1500
      },
      ({ headTranslateY, bodyScaleXZ, bodyScaleY  }) => {
        if (!this.poweringUp) {
          // 抬起时停止蓄力
          tween.stop()
        } else {
          this.v0 *= 1.008
          this.theta *= .99

          headSegment.position.setY(headTranslateY)
          bodyScaleSegment.scale.set(bodyScaleXZ, bodyScaleY, bodyScaleXZ)
          
          // 保存此时的位置用于复原
          this.toValues = {
            headTranslateY,
            bodyScaleXZ,
            bodyScaleY
          }

          stage.render()
        }
      }
    )
  }

现在按下鼠标,应该能看到小人蓄力的效果了,接下来我们还需要实现小人对盒子的挤压效果。

盒子挤压效果

有几个我之前根本没有想到的问题就是,我(比如我是舞台中的小人)进入了舞台,我站在哪里?我接收到下一步指令后,下一步要往哪里走?

  1. 我可能站在某一个盒子上,也可能在地面(比如点击开始游戏之前,小人从地面跳上盒子)
  2. 我的目标应该是下一个盒子

根据上面的分析,小人应该知道他当前所在的盒子currentProp是哪个,值可能是null,当然还知道下一个目标盒子nextProp是哪个。

首先确定何时设置当前所在盒子currentProp,我们之前实现了进入舞台的enterStage方法,那此时应该就明确了这个方法仅仅是进入舞台,和盒子没有关系,所以现在我们需要在小人进入舞台后,跳向第一个盒子,根据观摩微信跳一跳:

  1. 在没有点击开始游戏时,进入舞台的边缘位置(除了在道具上面),接着斜抛运动跳向第一个盒子
  2. 点击开始游戏后,小人出现在第一个盒子的正上方,然后弹球下落运动跳向第一个盒子,针对这个动作我们需要单独实现

以上分析如果还不是很清楚,建议你拿上你的手机打开微信跳一跳多撸几把......

那么要怎么做就很明确了,在小人进入舞台时设置下一个跳跃的目标盒子,然后执行跳跃操作,跳上去之后将其设置成当前盒子,同时将此盒子的下一个设置为下次的跳跃目标,这里可以回头在盒子生成的地方将它与下一个关联一下,方便处理

// JumpGameWorld.js
  // 创建盒子
  createProp (enterHeight = 100) {
    // ...
    
    // 关联下一个用于小人寻找目标
    if (currentProp) {
      currentProp.setNext(prop)
    }

    prop.enterStage()
    props.push(prop)
  }
  
// Prop.js
  setNext (next) {
    this.next = next
  }

  getNext (next) {
    return this.next
  }
  // 销毁
  dispose () {
    const { body, stage, prev, next } = this
    // 解除关联的引用
    this.prev = null
    this.next = null
    if (prev) {
      prev.next = null
    }
    if (next) {
      next.prev = null
    }

    body.geometry.dispose()
    body.material.dispose()
    stage.remove(body)
  }
  // LittleMan.js
  enterStage (stage, { x, y, z }, nextProp) {
    const { body } = this

    body.position.set(x, y, z)
    this.stage = stage
    // 进入舞台时告诉小人目标
    this.nextProp = nextProp

    stage.add(body)
    stage.render()
    this.bindEvent()
  }
  
  // 跳跃
  jump () {
    const {
        stage, body,
        currentProp, nextProp,
        world: { propHeight }
    } = this
    const { x, z } = body.position
    const { x: nextX, z: nextZ } = nextProp.position

    // 开始游戏时,小人从第一个盒子正上方入场做弹球下落
    if (!currentProp && x === nextX && z === nextZ) {
      body.position.setY(propHeight)
      this.currentProp = nextProp
      this.nextProp = nextProp.getNext()
    } else {
      // ...
    }
    
    stage.render()
  }

具体的跳跃动画之后再解决。现在已经知道了当前站在哪个盒子,可以愉快的实现挤压效果了。那么具体的挤压效果我们应该如何实现呢?前面已经实现了小人的蓄力,根据微信跳一跳的效果,挤压效果也是在蓄力期间过渡,盒子被挤压的同时,小人也需要更新它的整体y轴位置,所以,现在对蓄力动画进行改造

  // 初始化斜抛相关参数
  resetPowerStorageParameter () {
    // ...
    
    this.toValues = {
      // ...
      propScaleY: 0
    }
    this.fromValues = this.fromValues || {
      // ...
      propScaleY: 1
    }
  }

  // 蓄力
  powerStorage () {
    const {
      stage,
      body, bodyScaleSegment, headSegment,
      fromValues,
      currentProp,
      world: { propHeight }
    } = this

    // ...

    const tween = animate(
      {
        from: { ...fromValues },
        to: {
          // ...
          propScaleY: .8
        },
        duration: 1500
      },
      ({ headTranslateY, bodyScaleY, bodyScaleXZ, propScaleY }) => {
        if (!this.poweringUp) {
          // 抬起时停止蓄力
          tween.stop()
        } else {
          // ...
          
          currentProp.scale.setY(propScaleY)
          body.position.setY(propHeight * propScaleY)

          // ...

          stage.render()
        }
      }
    )
  }

现在,挤压效果已经实现,接下来分析起跳的过程,请打开微信跳一跳......

起跳

那速度超级的快,看不清,还是自己分析一下吧。首先,按照生活常识,小人跳起来的初始速度应该是大于盒子的回弹速度的,在盒子回弹到顶点之前应该是不会相撞的,那么我们可以同时开启2个动画,一个是盒子的回弹,一个是小人的斜抛运动。

第一个动画,先为盒子实现一个回弹功能springbackTransition:

  // 回弹动画
  springbackTransition (duration) {
    const { body, stage } = this
    const y = body.scale.y
    
    animate(
      {
        from: { y },
        to: { y: 1 },
        duration,
        easing: TWEEN.Easing.Bounce.Out
      },
      ({ y }) => {
        body.scale.setY(y)
        stage.render()
      }
    )
  }

第二个动画,小人的抛物线运动,这个已经分析过了,X轴做匀速运动,Y轴分2段,上升段是减速,下降段是加速。整个跳跃过程,除了抛物线运动,还包括一个落地缓冲,缓冲理论上也是2段变化,但这里由于变化非常快,我觉得肉眼是很难识别出来的,所以先只设置后半段看看效果,同时,缓冲结束时间点应该是整个跳跃过程的结束时间点。

另外,小人运动的方向可能是X轴Z轴,所以需要先确定小人的方向,我们可以通过比较2个盒子的x值和z值来判定方向,x相等则是Z轴方向,否则是X轴

现在,确定了方向、运动曲线、射程,那就可以开始动手了吗?too young too simple,这个射程我们能直接用于小人在X轴或者Z轴的偏移吗?

如上图,先假设小人不会跳出盒子,很明确的,小人每次跳跃都需要瞄准下一个盒子的中心点,至于能不能准确落到中心点,那是不确定的,由射程决定,但一定不会跳出从起跳点到下一个盒子中心点相连的这一条线,现在,再进一步分解下:

从图中规律可以看出,已知c1p2的直线距离和坐标差,然后根据相似三角行特性就能算出X轴Z轴方向的偏移量。接下来就是套公式了,求出真正的xz,我们实现一个computePositionByRangeR方法。

/**
 * 根据射程算出落地点
 * @param {Number} range 射程
 * @param {Object} c1 起跳点
 * @param {Object} p2 目标盒子中心点
 */
export const computePositionByRange = function (range, c1, p2) {
  const { x: c1x, z: c1z } = c1
  const { x: p2x, z: p2z } = p2

  const p2cx = p2x - c1x
  const p2cz = p2z - c1z
  const p2c = sqrt(pow(p2cz, 2) + pow(p2cx, 2))

  const jumpDownX = p2cx * range / p2c
  const jumpDownZ = p2cz * range / p2c

  return {
    jumpDownX: c1x + jumpDownX,
    jumpDownZ: c1z + jumpDownZ
  }
}

然后我们将之前总结的起跳逻辑都实现一下,包括小人首次的弹球下落,由于我实现完之后体验发现如果蓄力时间很短,计算得到的射高值有点低(和微信体验差别有点大),所以我直接将射高写死了一个最小值😄,看起来和微信跳一跳体验更接近些。

  // 跳跃
  jump () {
    const {
      stage, body,
      currentProp, nextProp,
      world: { propHeight }
    } = this
    const duration = 400
    const start = body.position
    const target = nextProp.getPosition()
    const { x: startX, y: startY, z: startZ } = start

    // 开始游戏时,小人从第一个盒子正上方入场做弹球下落
    if (!currentProp && startX === target.x && startZ === target.z) {
      animate(
        {
          from: { y: startY },
          to: { y: propHeight },
          duration,
          easing: TWEEN.Easing.Bounce.Out
        },
        ({ y }) => {
          body.position.setY(y)
          stage.render()
        },
        () => {
          this.currentProp = nextProp
          this.nextProp = nextProp.getNext()
          this.jumping = false
        }
      )
    } else {
      if (!currentProp) {
        return
      }

      const { bodyScaleSegment, headSegment, G } = this
      const { v0, theta } = this.computePowerStorageValue()
      const { rangeR, rangeH } = computeObligueThrowValue(v0, theta * (Math.PI / 180), G)

      // 水平匀速
      const { jumpDownX, jumpDownZ } = computePositionByRangeR(rangeR, start, target)
      animate(
        {
          from: {
            x: startX,
            z: startZ,
            ...this.toValues
          },
          to: {
            x: jumpDownX,
            z: jumpDownZ,
            ...this.fromValues
          },
          duration
        },
        ({ x, z, headTranslateY, bodyScaleXZ, bodyScaleY }) => {
          body.position.setX(x)
          body.position.setZ(z)
          headSegment.position.setY(headTranslateY)
          bodyScaleSegment.scale.set(bodyScaleXZ, bodyScaleY, bodyScaleXZ)
        }
      )

      // y轴上升段、下降段
      const rangeHeight = Math.max(60, rangeH) + propHeight
      const yUp = animate(
        {
          from: { y: startY },
          to: { y: rangeHeight },
          duration: duration * .65,
          easing: TWEEN.Easing.Cubic.Out,
          autoStart: false
        },
        ({ y }) => {
          body.position.setY(y)
        }
      )
      const yDown = animate(
        {
          from: { y: rangeHeight },
          to: { y: propHeight },
          duration: duration * .35,
          easing: TWEEN.Easing.Cubic.In,
          autoStart: false
        },
        ({ y }) => {
          body.position.setY(y)
        }
      )

      // 落地后,生成下一个方块 -> 移动镜头 -> 更新关心的盒子 -> 结束
      const ended = () => {
        const { world } = this
        world.createProp()
        world.moveCamera()

        this.currentProp = nextProp
        this.nextProp = nextProp.getNext()
        // 跳跃结束了
        this.jumping = false
      }
      // 落地缓冲段
      const bufferUp = animate(
        {
          from: { s: .8 },
          to: { s: 1 },
          duration: 100,
          autoStart: false
        },
        ({ s }) => {
          bodyScaleSegment.scale.setY(s)
        },
        () => {
          // 以落地缓冲结束作为跳跃结束时间点
          ended()
        }
      )

      // 上升 -> 下降 -> 落地缓冲
      yDown.chain(bufferUp)
      yUp.chain(yDown).start()

      // 需要处理不同方向空翻
      const direction = currentProp.getPosition().z === nextProp.getPosition().z
      this.flip(duration, direction)

      // 从起跳开始就回弹
      currentProp.springbackTransition(500)
    }

    stage.render()
  }

  

  // 空翻
  flip (duration, direction) {
    const { bodyRotateSegment } = this
    let increment = 0

    animate(
      {
        from: { deg: 0 },
        to: { deg: 360 },
        duration,
        easing: TWEEN.Easing.Sinusoidal.InOut
      },
      ({ deg }) => {
        if (direction) {
          bodyRotateSegment.rotateZ(-(deg - increment) * (Math.PI/180))
        } else {
          bodyRotateSegment.rotateX((deg - increment) * (Math.PI/180))
        }
        increment = deg
      }
    )
  }

ok,现在小人可以起跳了,并且总是朝向下一个盒子的中心点方向,游戏已经处具规模。不过现在有一个比较明显的问题,就是蓄力值的变化,接下来调整蓄力值。

蓄力值优化

当前的蓄力值变化逻辑是放在动画中,其实就是在requestAnimationFrame中,requestAnimationFrame的执行时间是不稳定的,所以得换一种方式来处理,那如果用定时器呢?其实定时器也不一定是定时(准时),最可靠的方法就是记录一个鼠标按下的时间,然后根据鼠标松开时的时间差来算蓄力值,但这个时间差有一个最大值,就是蓄力的最大时间。现在实现一个computePowerStorageValue方法通过时间计算蓄力值,然后将jump方法中的参数替换一下(系数试了很多遍确定这样算比较像微信跳一跳的感觉)

  computePowerStorageValue () {
    const { powerStorageDuration, powerStorageTime, v0, theta } = this
    const diffTime = Date.now() - powerStorageTime
    const time = Math.min(diffTime, powerStorageDuration)
    const percentage = time / powerStorageDuration

    return {
      v0: v0 + 30 * percentage,
      theta: theta - 50 * percentage
    }
  }

本以为几天能写的差不多,没想到估算误差太大,待我几天后继续更新......

如果对你有帮助,请给个赞,谢了老铁!