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

2,943 阅读25分钟

所有章节

这一章主要实现粒子效果、死亡判定以及跌落,由于时间关系并且threejs不是我主攻的方向(还是要将时间用在正儿八经的事情上),其它的功能实现会在结尾大致分析一下。

源码已放github示例代码

我在调试时内存蹭蹭往上窜,大量对象实例被引用,发现单纯的dispose效果甚微,得做的彻底一点,像这样:

export const destroyMesh = mesh => {
  if (mesh.geometry) {
    mesh.geometry.dispose()
    mesh.geometry = null
  }
  if (mesh.material) {
    mesh.material.dispose()
    mesh.material = null
  }

  mesh.parent.remove(mesh)

  mesh.parent = null
  mesh = null
}

现在就不怕内存暴涨了。同时,我为每个类实现了destroy方法来避免内存问题,

实现小人弹跳

上一章中,我们列出了小人的几个相关注意点:

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

接下来实现起跳和落地时的特效。

粒子特效

继续打开微信跳一跳,撸一把先......

  1. 只要不抬起手指,蓄力的特效是一直在运行的,粒子在小人上方的周围出现(不会从底部出现),此时粒子运动轨迹应该是直线向小人的脚下聚拢
  2. 落地的特效运行短暂时间后就会消失,粒子在小人的下方周围出现(不会从上方出现),运动轨迹应该也是直线,散射的方式。

那么现在粒子类应该具备基本的2个方法: 粒子流粒子喷泉(我随便起的不专业😄),但是呢,要有这2个特效,我们首先得生成粒子。那么

  1. 粒子位置随机,但需要限制范围
  2. 粒子的运动轨迹是有终点的,对于粒子流效果,终点很明确,我们可以将小人的脚下中心点作为终点(需要算上粒子的高度),而对于粒子喷泉,无法确定终点在哪,只能大致确定一个向上的方向,所以也可以认为喷出粒子的终点是随机的,但是统一向外侧方向,同时限制喷出粒子的最大行程就行了
  3. 需要限制一下粒子的数量

然后根据上述分析画了一张大致的垂直截面图:

图一旦画出来,我们又能想到新的问题

  1. 粒子流效果,只要不松开手指是一直运行的,那么这里需要时时刻刻生成新的粒子吗?
  2. 粒子喷泉效果,需要生成新的粒子吗?

然后我想到一个比较类似的场景来解决这个问题:就像一个假山公园,假山上有流水、水池中有喷泉,假山的水是一直在流,是什么让它一直流?肯定是有个水泵在起作用,水泵将流下来的水一直往上抽,同时假山上水也一直往下流,形成一个循环,而喷泉也是利用水池中的水,水最终也落到水池中,不考虑水蒸发,水量是固定的。

不如将这个场景映射到我们的问题中,我们首先在小人脚下准备好定量的粒子(水池中定量的水),再准备一个粒子泵(水泵),粒子泵不断的将小人脚下(水池)的粒子往上抽(设置随机位置,假山),同时让被抽上来的粒子继续前往终点(水往下流),然后粒子喷泉直接复用脚下的粒子(水池中的水),利用粒子喷泉粒子泵将水向上喷,喷完后将它们放进水池(重置到脚下),形成一个循环。同时,这里的粒子系统,应该是跟着小人走的,所以,我们可以将粒子系统作为小人的一部分(添加到一个组中)。

ok,已经有了思路,现在能大致写出Particle类的结构

class Particle {
  constructor ({
    world,
    quantity = 20, // 粒子数量
    triggerObject, // 触发对象
  }) {
    this.world = world
    this.quantity = quantity
    this.triggerObject = triggerObject
    
    this.particleSystem = null
  }
  
  // 生产定量的粒子
  createParticle () {}
  
  // 将粒子放到脚下
  resetParticle () {}
  
  // 粒子流粒子泵
  runParticleFlowPump () {}
  
  // 粒子流
  runParticleFlow () {}
  
  // 粒子喷泉粒子泵
  runParticleFountainPump () {}
  
  // 粒子喷泉
  runParticleFountain () {}
}

首先生成定量的粒子,这里threejs的粒子我研究了半天,还是不得要领,有幸在网上找到了一个demo,然后我直接参考了它。然后根据观摩微信跳一跳的粒子效果,粒子的颜色应该只有2种,白色和绿色,所以这里设置一半的粒子为白色,一半为绿色。new THREE.TextureLoader().load('xxx.png')这种方式出问题毫无征兆,应该使用new THREE.TextureLoader().load(require('./dot.png'), callback)这种形似,或者套一个Promise。

  // 生成粒子
  createParticle () {
    const { quantity, triggerObject } = this
    // 一半白色、一半绿色
    const white = new THREE.Color( 0xffffff )
    const green = new THREE.Color( 0x58D68D )
    const colors = Array.from({ length: quantity }).map((_, i) => i % 2 ? white : green)
    const particleSystem = this.particleSystem = new THREE.Group()

    new THREE.TextureLoader().load(require('./dot.png'), dot => {
      const baseGeometry = new THREE.Geometry()
      baseGeometry.vertices.push(new THREE.Vector3())

      const baseMaterial = new THREE.PointsMaterial({
        size: 0,
        map: dot,
        // depthTest: false, // 开启后可以透视...
        transparent: true
      })

      colors.forEach(color => {
        const geometry = baseGeometry.clone()
        const material = baseMaterial.clone()
        material.setValues({ color })
  
        const particle = new THREE.Points(geometry, material)
        particleSystem.add(particle)
      })
  
      this.resetParticle()
  
      triggerObject.add(particleSystem)
    })
  }

然后将粒子放到小人脚下,需要注意的是,如果这里直接将粒子放到脚下,小人空翻时能被看到,所以需要藏起来。这里约定一个粒子的最大大小值initalY

  // 将粒子放到小人脚下
  resetParticle () {
    const { particleSystem, initalY } = this
    particleSystem.children.forEach(particle => {
      particle.position.y = initalY
      particle.position.x = 0
      particle.position.z = 0
    })
  }

现在,我们已经将定量的粒子生成并放入初始位置了,接下来实现粒子泵,粒子泵的作用就是将脚下的粒子随机放到小人的上方周围(将水往上抽),那么这里的随机值就需要考虑一个范围,并且不能将粒子随机在小人的身体中,这里从分析的第一张图就可以看出来。那现在我们以小人的身高胖瘦为准,约定粒子的随机位置为小人上半身周围,同时以小人的宽度为准限制水平方向的范围。同理,约定喷泉的粒子随机位置为小人的下半身周围,粒子大小为粒子流的一半(观测比粒子流的小),最大喷射距离(行程)为小人身高的一半。

  constructor ({
    world,
    quantity = 20, // 数量
    triggerObject // 触发对象
  }) {
    this.world = world
    this.quantity = quantity
    this.triggerObject = triggerObject
    this.particleSystem = null

    const { x, y } = getPropSize(triggerObject)

    this.triggerObjectWidth = x
    // 限制粒子水平方向的范围
    this.flowRangeX = [-x * 2, x * 2]

    // 粒子流,垂直方向的范围,约定从小人的上半身出现,算上粒子最大大小
    const flowSizeRange = this.flowSizeRange = [x / 6, x / 3]
    this.flowRangeY = [y / 2, y - flowSizeRange[1]]
    // 粒子初始的y值应该是粒子大小的最大值
    this.initalY = flowSizeRange[1]

    // 粒子喷泉,垂直方向的范围,约定从小人的下半身出现,算上粒子最大大小
    const fountainSizeRange = this.fountainSizeRange = this.flowSizeRange.map(s => s / 2)
    this.fountainRangeY = [fountainSizeRange[1], y / 2]
    this.fountainRangeDistance = [y / 4, y / 2]
    // 限制粒子水平方向的范围
    this.fountainRangeX = [-x / 3, x / 3]
  }

既然约定好了安全值,现在就来实现粒子流粒子泵逻辑

  // 粒子流粒子泵
  runParticleFlowPump () {
    const { particleSystem, quantity, initalY } = this
    // 粒子泵只关心脚下的粒子(水池)
    const particles = particleSystem.children.filter(child => child.position.y === initalY)

    // 脚下的粒子量不够,抽不上来
    if (particles.length < quantity / 3) {
      return
    }

    const {
      triggerObjectWidth,
      flowRangeX, flowRangeY, flowSizeRange
    } = this
    // 比如随机 x 值为0,这个值在小人的身体范围内,累加一个1/2身体宽度,这样做可能有部分区域随机不到,不过影响不大
    const halfWidth = triggerObjectWidth / 2

    particles.forEach(particle => {
      const { position, material } = particle
      const randomX = rangeNumberInclusive(...flowRangeX)
      const randomZ = rangeNumberInclusive(...flowRangeX)
      // 小人的身体内,不能成为起点,需要根据正反将身体的宽度加上
      const excludeX = randomX < 0 ? -halfWidth : halfWidth
      const excludeZ = randomZ < 0 ? -halfWidth : halfWidth

      position.x = excludeX + randomX
      position.z = excludeZ + randomZ
      position.y = rangeNumberInclusive(...flowRangeY)

      material.setValues({ size: rangeNumberInclusive(...flowSizeRange) })
    })
  }

现在粒子流的泵已经准备好了,我们进一步实现粒子流的效果,打开微信跳一跳,撸几把......

应该能发现粒子除了是直线运动,也是匀速的(就算不是匀速,也将它处理成匀速吧),也可以先不关心速度,这里还需考虑些东西,那就是粒子流是一直运行的(只有不松开手指),然后到达脚下的粒子也是在不断的被重置位置并开始向脚下移动,所以这里我们没有办法使用Tweenjs来控制动画,因为不晓得粒子流会运行多久,那么这里唯一能参考的就只有时间了,我们可以根据时间流失(时间差)的多少来确定粒子应该走多远,然后约定一个粒子的固定速度,那么配合requestAnimationFrame这个api

// 约定一个固定速度,每毫秒走多远
const speed = triggerObjectWidth * 3 / 1000
const prevTime = 0
const animate = () => {
    if (prevTime) {
        const diffTime = Date.now() - prevTime
        // 粒子的行程
        const trip = diffTime * speed
    }
    prevTime = Date.now()
    requestAnimationFrame(animate)
}

现在我们能算出粒子的行程,那么算出粒子下一次的坐标也就简单了,根据当前的视角画一张图来理解:

在每一个帧时,根据上一次的坐标和终点算出上一次粒子离小人脚下的距离,同时根据时间差和速度能算出粒子本次应该走多远,然后用相似三角形的特性,我们就能算出z'、x'、y',也就是粒子的新位置。同时,粒子流还需要有一个停止的方法,用来在松开手指时终止

  // 粒子流
  runParticleFlow () {
    if (this.runingParticleFlow) {
      return
    }
    this.runingParticleFlow = true

    const { world, triggerObjectWidth, particleSystem, initalY } = this
    let prevTime = 0
    // 约定速度,每毫秒走多远
    const speed = triggerObjectWidth * 3 / 1000
    
    const animate = () => {
      const id = requestAnimationFrame(animate)

      if (this.runingParticleFlow) {
        // 抽粒子
        this.runParticleFlowPump()
        if (prevTime) {
          const actives = particleSystem.children.filter(child => child.position.y !== initalY)
          const diffTime = Date.now() - prevTime
          // 粒子的行程
          const trip = diffTime * speed
  
          actives.forEach(particle => {
            const { position } = particle
            const { x, y, z } = position
            
            if (y < initalY) {
              // 只要粒子的y值超过安全值,就认为它已经到达终点
              position.y = initalY
              position.x = 0
              position.z = 0
            } else {
              const distance = Math.sqrt(Math.pow(x, 2) + Math.pow(z, 2) + Math.pow(y - initalY, 2))
              const ratio = (distance - trip) / distance
              
              position.x = ratio * x
              position.z = ratio * z
              position.y = ratio * y
            }
          })
          world.stage.render()
        }
        prevTime = Date.now()
      } else {
        cancelAnimationFrame(id)
      }
    }
    animate()
  }
  
    // 停止粒子流
  stopRunParticleFlow () {
    this.runingParticleFlow = false
    this.resetParticle()
  }

现在,不出意外,粒子流效果已经实现了,在小人蓄力阶段去触发它,然后松开手指时停止它。接下来我们实现粒子喷泉相关逻辑。首先,粒子喷泉粒子泵也是直接使用小人脚下的粒子,根据我的观摩,喷泉的粒子数量要稍微少一些

  // 粒子喷泉
  runParticleFountain () {
    if (this.runingParticleFountain) {
      return
    }
    this.runingParticleFountain = true

    const { particleSystem, quantity, initalY } = this
    // 粒子泵只关心脚下的粒子(水池)
    const particles = particleSystem.children.filter(child => child.position.y === initalY).slice(0, quantity)

    if (!particles.length) {
      return
    }

    const {
      triggerObjectWidth,
      fountainRangeX, fountainSizeRange, fountainRangeY
    } = this
    const halfWidth = triggerObjectWidth / 2

    particles.forEach(particle => {
      const { position, material } = particle
      const randomX = rangeNumberInclusive(...fountainRangeX)
      const randomZ = rangeNumberInclusive(...fountainRangeX)
      // 小人的身体内,不能成为起点,需要根据正反将身体的宽度加上
      const excludeX = randomX < 0 ? -halfWidth : halfWidth
      const excludeZ = randomZ < 0 ? -halfWidth : halfWidth

      position.x = excludeX + randomX
      position.z = excludeZ + randomZ
      position.y = rangeNumberInclusive(...fountainRangeY)

      material.setValues({ size: rangeNumberInclusive(...fountainSizeRange) })
    })

    // 喷射粒子
    this.runParticleFountainPump(particles, 1000)
  }

现在,实现粒子喷泉粒子泵,它的逻辑和粒子流粒子泵的逻辑类似,坐标计算方法都是一样的,不同的地方是由于粒子喷泉的粒子各有各的终点,需要将终点记录起来(可以用userData属性),而且粒子喷泉不需要终止方法,只需要注意一下,如果当前粒子喷泉还没有结束时触发了粒子流,则立即停止粒子喷泉,让粒子流看起来有一个连贯的效果。然后粒子喷泉应该是在落地时触发

  // 粒子喷泉粒子泵
  runParticleFountainPump (particles, duration) {
    const { fountainRangeDistance, triggerObjectWidth, initalY, world } = this
    // 随机设置粒子的终点
    particles.forEach(particle => {
      const { position: { x, y, z } } = particle

      const userData = particle.userData

      userData.ty = y + rangeNumberInclusive(...fountainRangeDistance)
      // x轴和z轴 向外侧喷出
      const diffX = rangeNumberInclusive(0, triggerObjectWidth / 3)
      userData.tx = (x < 0 ? -diffX : diffX) + x
      const diffZ = rangeNumberInclusive(0, triggerObjectWidth / 3)
      userData.tz = (z < 0 ? -diffZ : diffZ) + z
    })
    
    let prevTime = 0
    const startTime = Date.now()
    const speed = triggerObjectWidth * 3 / 800
    
    const animate = () => {
      const id = requestAnimationFrame(animate)
      // 已经在脚下的粒子不用处理
      const actives = particles.filter(particle => particle.position.y !== initalY)

      if (actives.length && !this.runingParticleFlow && Date.now() - startTime < duration) {
        if (prevTime) {
          const diffTime = Date.now() - prevTime
          // 粒子的行程
          const trip = diffTime * speed

          actives.forEach(particle => {
            const {
              position,
              position: { x, y, z },
              userData: { tx, ty, tz }
            } = particle
            if (y >= ty) {
              // 已经到达终点的粒子,重新放到脚下去
              position.x = 0
              position.y = initalY
              position.z = 0
            } else {
              const diffX = tx - x
              const diffY = ty - y
              const diffZ = tz - z
              const distance = Math.sqrt(Math.pow(diffX, 2) + Math.pow(diffY, 2) + Math.pow(diffZ, 2))
              const ratio = trip / distance

              position.y += ratio * diffY
              position.x += ratio * diffX
              position.z += ratio * diffZ
            }
          })
          world.stage.render()
        }
        prevTime = Date.now()
      } else {
        this.runingParticleFountain = false
        cancelAnimationFrame(id)
      }
    }
    animate()
  }

现在,粒子效果终于完成了。整个功能其实还有很多待考虑的地方,这里主要只是针对小人实现,如果后续需要做的更通用一点,可以优化一下。

残影

我按照这个官方例子,尝试了很久,就是看不到一丢丢残影🤮,估计是哪个不太明显的地方用法不对,残影之后有时间再实现,如果朋友们有此经验,可以在下方留言提示一下,感激不尽。

死亡判定

前面已经实现大部分游戏逻辑,此时的游戏中,小人能随意跳跃,并且不管从什么位置起跳,下一次它总是跃向下一个盒子,同时在小人跳跃之前我们就已经算出落地点,所以,这里的死亡判定只需要判断落地点是否在盒子上就ok了,那么直接使用threejs相关的api为Prop类实现一个containsPoint方法

  // 检测点是否在盒子内
  containsPoint (x, y, z) {
    const { body } = this
    // 更新包围盒
    body.geometry.computeBoundingBox()
    // 更新盒子世界矩阵
    body.updateMatrixWorld()

    // 点的世界坐标,y等于盒子高度,这里需要-1
    const worldPosition = new THREE.Vector3(x, y - 1, z)

    const localPosition = worldPosition.applyMatrix4(new THREE.Matrix4().getInverse(body.matrixWorld))
    return body.geometry.boundingBox.containsPoint(localPosition)
  }

现在小人的jump方法中可以确定落地后的状态

if (nextProp.containsPoint(jumpDownX, propHeight, jumpDownZ)) {
    // 跃向当前盒子
    // 生成新盒子、移动场景......
} else if (!currentProp.containsPoint(jumpDownX, propHeight, jumpDownZ)) {
    // gameOver
}

但是......这个方法只对立方体有效,如果是圆柱体就没法用了,所以这里不能直接使用包围盒来检测。既然不能用包围盒,那就自己算呗,由于死亡是统一在一个高度判定的,所以可以简化为计算一个点是否在平面内,即落地点是否在盒子的顶部平面,也就是说,只需要知道当前盒子是立方体还是圆柱体,然后分别处理一下就能算出来点是否在盒子上了。由于我没有找到判断当前盒子类型的方法,并且BufferGeometry经过clone之后也无法使用instanceof来判断是否是BoxBufferGeometry或者CylinderBufferGeometry,所以,我在通用的立方体中使用了userData属性

// 立方体
export const baseBoxBufferGeometry = new THREE.BoxBufferGeometry(1, 1, 1, 10, 4, 10)
baseBoxBufferGeometry.userData.type = 'box'
// 圆柱体
export const baseCylinderBufferGeometry = new THREE.CylinderBufferGeometry(1, 1, 1, 30, 5)
baseCylinderBufferGeometry.userData.type = 'Cylinder'

现在,将containsPoint方法改造一下

  containsPoint (x, z) {
    const { body } = this
    const { type } = body.geometry.userData
    const { x: sx, z: sz } = this.getSize()
    const { x: px, z: pz } = this.getPosition()

    if (type === 'box') {
      const halfSx = sx / 2
      const halfSz = sz / 2
      const minX = px - halfSx
      const maxX = px + halfSx
      const minZ = pz - halfSz
      const maxZ = pz + halfSz

      return x >= minX && x <= maxX && z >= minZ && z <= maxZ
    } else {
      const radius = sx / 2
      // 小人脚下中心点离圆心的距离
      const distance = Math.sqrt(Math.pow(px - x, 2) + Math.pow(pz - z, 2))

      return distance <= radius
    }
  }

跌落

如果需要实现跌落效果,我们需要将gameOver分支再细分一下,打开微信跳一跳撸一撸......

  1. 小人完全处于半空中
  2. 小人处于盒子边缘
  3. 小人同时跨越2个盒子

对于情况1,处理起来相当简单,而对于情况2,咱们还得再琢磨琢磨

  1. 小人落在当前方向currentProp的远边缘
  2. 小人落在当前方向nextProp的近边缘
  3. 小人落在当前方向nextProp的远边缘
  4. 它也有可能落在当前方向nextProp的两侧边缘,这取决于盒子的大小和距离

现在整理一下

  1. 小人完全出于半空中
  2. 小人落在盒子边缘
    • 小人落在currentProp的远边缘
    • 小人落在nextProp的近边缘
    • 小人落在nextProp的远边缘
    • 小人落在nextProp的两侧边缘
  3. 小人同时跨越2个盒子

小人完处于半空中,直接让小人垂直下落就行

小人落在盒子边缘,这种情况的跌落需要将动作分解为3个,一个是旋转,一个是向下位移,然后是想外位移。对于这个过程,我拿着我那包快抽完的软白沙烟盒在电脑桌上开启了我的小实验,思考了良久之后,我决定将效果实现的比较贴近自然一点,但是在实现过程中,碰到了比较麻烦的东西(数学太弱了),之后仔细体会了微信跳一跳的处理方式,发现他们其实也并没有想将这些细节做得尽善尽美,毕竟这只是整个游戏中的一个不起眼的小插曲。所以我也就退一步用简单的方式实现,或者熟悉物理引擎的朋友们也可以考虑物理引擎。用一张图来描述一下我的简单思路。

首先,确定一下支撑点(图中红点),然后让小人沿着支撑点旋转90度,接着将小人着地。在跌落之前,得先让小人以统一的姿势站好(不然算起来太麻烦),也就是说,假设此游戏中小人的正前方是Z轴方向,不做处理时,若跌落的方向不是Z轴就需要计算出3个方向的角度和位移值,反之如果将小人旋转到当前的跌落方向,我们就能统一以小人的本地坐标系来实现动效。那现在约定小人的正前方是Z轴,通过调整Y轴角度后,统一调整小人X轴值向下旋转,调整Z轴值让小人向下跌落,调整Y轴值让小人在跌落过程中向外侧偏移

  1. 要达到这种目的,首先得算出小人沿Y轴旋转的角度,让小人面朝跌落方向
  2. 需要算出小人脚下中心点到支撑点的距离,用来设置小人的旋转原点

现在将之前的containsPoint方法改造一下:

  /**
   * 计算跌落数据
   * @param {Number} width 小人的宽度
   * @param {Number} x 小人脚下中心点的X值
   * @param {Number} z 小人脚下中心点的Z值
   * @return {
   *   contains, // 小人中心点是否在盒子上
   *   isEdge, // 是否在边缘
   *   translateZ, // 将小人旋转部分移动 -translateZ,将网格移动translateZ
   *   degY, // 调整小人方向,然后使用小人的本地坐标进行平移和旋转
   * }
   */
  computePointInfos (width, x, z) {
    const { body } = this

    if (!body) {
      return {}
    }

    const { type } = body.geometry.userData
    const { x: sx, z: sz } = this.getSize()
    const { x: px, z: pz } = this.getPosition()
    const halfWidth = width / 2

    // 立方体和圆柱体的计算逻辑略有差别
    if (type === 'box') {
      const halfSx = sx / 2
      const halfSz = sz / 2
      const minX = px - halfSx
      const maxX = px + halfSx
      const minZ = pz - halfSz
      const maxZ = pz + halfSz

      const contains = x >= minX && x <= maxX && z >= minZ && z <= maxZ

      if (contains) {
        return { contains }
      }

      const translateZ1 = Math.abs(z - pz) - halfSz
      const translateZ2 = Math.abs(x - px) - halfSx
      // 半空中
      if (translateZ1 >= halfWidth || translateZ2 >= halfWidth) {
        return { contains }
      }

      // 计算是否在盒子的边缘
      let isEdge = false
      let degY = 0
      let translateZ = 0

      // 四个方向上都有可能
      if (x < maxX && x > minX) {
        if (z > maxZ && z < maxZ + halfWidth) {
          degY = 0
        } else if (z < minZ && z > minZ - halfWidth) {
          degY = 180
        }
        isEdge = true
        translateZ = translateZ1
      } else if (z < maxZ && z > minZ) {
        if (x > maxX && x < maxX + halfWidth) {
          degY = 90
        } else if (x < minX && x > minX - halfWidth) {
          degY = 270
        }
        isEdge = true
        translateZ = translateZ2
      }

      return {
        contains,
        translateZ,
        isEdge,
        degY
      }
    } else {
      const radius = sx / 2
      // 小人脚下中心点离圆心的距离
      const distance = Math.sqrt(Math.pow(px - x, 2) + Math.pow(pz - z, 2))

      const contains = distance <= radius

      if (contains) {
        return { contains }
      }

      // 半空中
      if (distance >= radius + halfWidth) {
        return { contains }
      }

      // 在圆柱体的边缘
      const isEdge = true
      const translateZ = distance - radius

      let degY = Math.atan(Math.abs(x - px) / Math.abs(z - pz)) * 180 / Math.PI

      if (x === px) {
        degY = z > pz ? 0 : 180
      } else if (z === pz) {
        degY = x > px ? 90 : 270
      } else if (x > px && z > pz) {
      } else if (x > px && z < pz) {
        degY = 180 - degY
      } else if (z < pz) {
        degY = 180 + degY
      } else {
        degY = 360 - degY
      }

      return {
        contains,
        translateZ,
        isEdge,
        degY
      }
    }
  }

然后,就能根据这个方法实现跌落的效果了,首先改造一下小人的jump方法,增加一个落地后的回调,在回调中判断是否死亡,如果没有死亡,则执行缓存效果并生成新的道具继续游戏,反之,根据计算出的结果让小人跌落。

  // 跳跃
  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) {
      // ...
    } else {
      if (!currentProp) {
        return
      }

      const { bodyScaleSegment, headSegment, G, world, width } = this
      const { v0, theta } = this.computePowerStorageValue()
      const { rangeR, rangeH } = computeObligueThrowValue(v0, theta * (Math.PI / 180), G)
      const { jumpDownX, jumpDownZ } = computePositionByRangeR(rangeR, start, target)

      // 水平匀速
      // ...

      // y轴上升段、下降段
      const rangeHeight = Math.max(world.width / 3, 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)
        },
        () => yDownCallBack()
      )

      yUp.chain(yDown).start()

      // 空翻
      this.flip(duration)
      // 从起跳开始就回弹
      currentProp.springbackTransition(500)

      // 落地后的回调
      const yDownCallBack = () => {
        const currentInfos = currentProp.computePointInfos(width, jumpDownX, jumpDownZ)
        const nextInfos = nextProp.computePointInfos(width, jumpDownX, jumpDownZ)
        
        // 没有落在任何一个盒子上方
        if (!currentInfos.contains && !nextInfos.contains) {
          // gameOver 游戏结束,跌落
          console.log('GameOver')
          this.fall(currentInfos, nextInfos)
        } else {
          bufferUp.onComplete(() => {
            if (nextInfos.contains) {
              // 落在下一个盒子才更新场景
              // 落地后,生成下一个方块 -> 移动镜头 -> 更新关心的盒子 -> 结束
              world.createProp()
              world.moveCamera()
      
              this.currentProp = nextProp
              this.nextProp = nextProp.getNext()
            }

            // 粒子喷泉
            this.particle.runParticleFountain()
            // 跳跃结束了
            this.jumping = false
          }).start()
        }
      }

      // 落地缓冲段
      const bufferUp = animate(
        {
          from: { s: .8 },
          to: { s: 1 },
          duration: 100,
          autoStart: false
        },
        ({ s }) => {
          bodyScaleSegment.scale.setY(s)
        }
      )
    }
  }

接下来根据前面的分析实现跌落方法fall

  // 跌落
  fall (currentInfos, nextInfos) {
    const {
      stage, body,
      world: { propHeight }
    } = this
    let degY, translateZ

    if (currentInfos.isEdge && nextInfos.isEdge) {
      // 同时在2个盒子边缘
      return
    } else if (currentInfos.isEdge) {
      // 当前盒子边缘
      degY = currentInfos.degY
      translateZ = currentInfos.translateZ
    } else if (nextInfos.isEdge) {
      // 目标盒子边缘
      degY = nextInfos.degY
      translateZ = nextInfos.translateZ
    } else {
      // 空中掉落
      return animate(
        {
          from: { y: propHeight },
          to: { y: 0 },
          duration: 400,
          easing: TWEEN.Easing.Bounce.Out
        },
        ({ y }) => {
          body.position.setY(y)
          stage.render()
        }
      )
    }

    // 将粒子销毁掉
    this.particle.destroy()

    const {
      bodyRotateSegment, bodyScaleSegment,
      headSegment, bodyTranslateY,
      width, height
    } = this
    const halfWidth = width / 2

    // 将旋转原点放在脚下,同时让小人面向跌落方向
    headSegment.translateY(bodyTranslateY)
    bodyScaleSegment.translateY(bodyTranslateY)
    bodyRotateSegment.translateY(-bodyTranslateY)
    bodyRotateSegment.rotateY(degY * (Math.PI / 180))

    // 将旋转原点移动到支撑点
    headSegment.translateZ(translateZ)
    bodyScaleSegment.translateZ(translateZ)
    bodyRotateSegment.translateZ(-translateZ)

    let incrementZ = 0
    let incrementDeg = 0
    let incrementY = 0

    // 第一段 先沿着支点旋转
    const rotate = animate(
      {
        from: {
          degY: 0
        },
        to: {
          degY: 90
        },
        duration: 500,
        autoStart: false,
        easing: TWEEN.Easing.Quintic.In
      },
      ({ z, degY }) => {
        bodyRotateSegment.rotateX((degY - incrementDeg) * (Math.PI / 180))
        incrementDeg = degY
        stage.render()
      }
    )
    // 第二段 跌落,沿z轴下落,沿y轴向外侧偏移
    const targZ = propHeight - halfWidth - translateZ
    const fall = animate(
      {
        from: {
          y: 0,
          z: 0
        },
        to: {
          y: halfWidth - translateZ,
          z: targZ,
        },
        duration: 300,
        autoStart: false,
        easing: TWEEN.Easing.Bounce.Out
      },
      ({ z, y }) => {
        headSegment.translateZ(z - incrementZ)
        bodyScaleSegment.translateZ(z - incrementZ)
        bodyRotateSegment.translateY(y - incrementY)
        incrementZ = z
        incrementY = y
        stage.render()
      }
    )
    
    rotate.chain(fall).start()
  }

现在跌落基本已经实现,但此时的跌落时是可以穿过盒子的,这也是比较麻烦的一点,由于算力有限,这里仅做一个简单的碰撞效果

  1. 第一段跌落过程中碰到前方盒子时,立即停止。如果是从立方体跌落到立方体,这里停止没啥大毛病,但是如果有圆柱体参与的话,效果看起来比较尴尬,讲道理应该会向旁边跌落,不过时间有限先就这样了😂
  2. 第二段跌落过程中碰到前方盒子时(也就是头碰到了盒子),让脚着地。圆柱体同样的问题

那么首先得实现一个检测物体碰撞的方法,找来找去还是得用到射线,然后在网上找到了这个粒子。要用这个方式,首先需要注意一下物体的顶点数量,如果太多的话,那性能就没法看,所以

  1. 需要适当的调整一下物体的分段数(包括小人和道具),如图高度分段数不要设置太多,可以从图中理解,绿点代表一个顶点。
  2. 如下图,尽量将不需要比较的顶点过滤掉,判断图中小人是否与红色盒子相撞时,只会涉及到内侧(小人面前的这一侧)的顶点,并且如果要进一步优化,以下图来说,只会涉及到小人跌落路径上的盒子内侧中间区域的一部分顶点,其余的所有顶点都是干扰。
  3. 针对第2点,这里只做了最简单的处理,以上图为例子,仅过滤掉红色盒子所有顶点中Z值大于0的顶点(差不多取一半)。😄其实是可以算出盒子某一侧的顶点的,并且也可以算出小人的路径经过的那部分顶点,如果这样做了,那就是几十倍的优化,因为在动画requestAnimationFrame过程中,大量计算很容易造成卡顿。
  4. 要过滤顶点,需要确定小人的跳跃方向X轴或者Y轴(世界坐标系),还需需要知道小人坠落的方向(基于方向的前后),比如像图中一样倒向红色盒子,需要过滤掉红色盒子距离小人远端的顶点,若倒向的是绿色盒子,则需要过滤掉绿色盒子离小人远端的顶点。

下面,根据上面的分析,将射线检测方法改造一下

/**
 * 获取静止盒子的碰撞检测器
 * @param {Mesh} prop 检测的盒子
 * @param {String} direction 物体过来的方向(世界坐标系)
 * @param {Boolean} isForward 基于方向的前后
 */
export const getHitValidator = (prop, direction, isForward) => {
  const origin = prop.position.clone()
  const vertices = prop.geometry.attributes.position
  const length = vertices.count

  // 盒子是静止的,先将顶点到中心点的向量准备好,避免重复计算
  const directionVectors = Array.from({ length })
    .map((_, i) => new THREE.Vector3().fromBufferAttribute(vertices, i))
    .filter(vector3 => {
      // 过滤掉一部分盒子离小人远端的顶点
      if (direction === 'z' && isForward) {
        // 从当前盒子倒向目标盒子
        return vector3.z < 0
      } else if (direction === 'z') {
        // 从目标盒子倒向当前盒子
        return vector3.z > 0
      } else if (direction === 'x' && isForward) {
        return vector3.x < 0
      } else if (direction === 'x') {
        return vector3.x > 0
      }
    })
    .map(localVertex => {
      const globaVertex = localVertex.applyMatrix4(prop.matrix)
      // 先将向量准备好
      return globaVertex.sub(prop.position)
    })

  return littleMan => {
    for (let i = 0, directionVector; directionVector = directionVectors[i]; i++) {
      const raycaster = new THREE.Raycaster(origin, directionVector.clone().normalize())
      const collisionResults = raycaster.intersectObject(littleMan, true)

      // 发生了碰撞
      if(collisionResults.length > 0 && collisionResults[0].distance < directionVector.length() + 1.2 ){
        return true
      }
    }
    return false
  }
}

接下来,将fall方法完善一下,增加碰撞检测

  // 跌落
  fall (currentInfos, nextInfos) {
    const {
      stage, body, currentProp, nextProp,
      world: { propHeight }
    } = this
    // 跳跃方向
    const direction = currentProp.nextDirection
    let degY, translateZ,
        validateProp, // 需要检测的盒子
        isForward // 相对方向的前、后

    if (currentInfos.isEdge && nextInfos.isEdge) {
      // 同时在2个盒子边缘
      return
    } else if (currentInfos.isEdge) {
      // 当前盒子边缘
      degY = currentInfos.degY
      translateZ = currentInfos.translateZ
      validateProp = nextProp
      isForward = true
    } else if (nextInfos.isEdge) {
      // 目标盒子边缘
      degY = nextInfos.degY
      translateZ = nextInfos.translateZ
      // 目标盒子边缘可能是在盒子前方或盒子后方
      if (direction === 'z') {
        isForward = degY < 90 && degY > 270
      } else {
        isForward = degY < 180
      }
      validateProp = isForward ? null : currentProp
    } else {
      // 空中掉落
      return animate(
        {
          from: { y: propHeight },
          to: { y: 0 },
          duration: 400,
          easing: TWEEN.Easing.Bounce.Out
        },
        ({ y }) => {
          body.position.setY(y)
          stage.render()
        }
      )
    }

    // 将粒子销毁掉
    this.particle.destroy()

    const {
      bodyRotateSegment, bodyScaleSegment,
      headSegment, bodyTranslateY,
      width, height
    } = this
    const halfWidth = width / 2

    // 将旋转原点放在脚下,同时让小人面向跌落方向
    headSegment.translateY(bodyTranslateY)
    bodyScaleSegment.translateY(bodyTranslateY)
    bodyRotateSegment.translateY(-bodyTranslateY)
    bodyRotateSegment.rotateY(degY * (Math.PI / 180))

    // 将旋转原点移动到支撑点
    headSegment.translateZ(translateZ)
    bodyScaleSegment.translateZ(translateZ)
    bodyRotateSegment.translateZ(-translateZ)

    let incrementZ = 0
    let incrementDeg = 0
    let incrementY = 0
    
    let hitValidator = validateProp && getHitValidator(validateProp.body, direction, isForward)

    // 第一段 先沿着支点旋转
    const rotate = animate(
      {
        from: {
          degY: 0
        },
        to: {
          degY: 90
        },
        duration: 500,
        autoStart: false,
        easing: TWEEN.Easing.Quintic.In
      },
      ({ degY }) => {
        if (hitValidator && hitValidator(body.children[0])) {
          rotate.stop()
          hitValidator = null
        } else {
          bodyRotateSegment.rotateX((degY - incrementDeg) * (Math.PI / 180))
          incrementDeg = degY
          stage.render()
        }
      }
    )
    // 第二段 跌落,沿z轴下落,沿y轴向外侧偏移
    const targZ = propHeight - halfWidth - translateZ
    const fall = animate(
      {
        from: {
          y: 0,
          z: 0
        },
        to: {
          y: halfWidth - translateZ,
          z: targZ,
        },
        duration: 300,
        autoStart: false,
        easing: TWEEN.Easing.Bounce.Out
      },
      ({ z, y }) => {
        if (hitValidator && hitValidator(body.children[0])) {
          fall.stop()

          // 稍微处理一下,头撞到盒子的情况
          const radian = Math.atan((targZ - z) / height)
          if (isForward && direction === 'z') {
            bodyRotateSegment.translateY(-height)
            body.position.z += height
            body.rotateX(-radian)
          } else if (direction === 'z') {
            bodyRotateSegment.translateY(-height)
            body.position.z -= height
            body.rotateX(radian)
          } else if (isForward && direction === 'x') {
            bodyRotateSegment.translateY(-height)
            body.position.x += height
            body.rotateZ(radian)
          } else if (direction === 'x') {
            bodyRotateSegment.translateY(-height)
            body.position.x -= height
            body.rotateZ(-radian)
          }
          stage.render()
          hitValidator = null
        } else {
          headSegment.translateZ(z - incrementZ)
          bodyScaleSegment.translateZ(z - incrementZ)
          bodyRotateSegment.translateY(y - incrementY)
          incrementZ = z
          incrementY = y
          stage.render()
        }
      }
    )
    
    rotate.chain(fall).start()
  }

到这里,跌落和碰撞就差不多实现完成了,还有很大的瑕疵,所以,如果朋友你看到这里觉得不太友好的话,暂时很抱歉。若后续我需要更多的涉及到threejs,我再来优化它🙏。

未实现功能分析

加分

这个效果和粒子效果类似,创建后将它们添加到小人的组合中,需要的时候亮出来就行。

中心点提示、落地波纹

这个中心点在全局只需要创建一个,然后在需要时显示它,波纹可能就是中心点扩散的效果。

停留加分

在盒子上停留加分这种功能估计需要支持外部自定义,提供给外部加分的api,但是由于外部不知道停留多久,所以还得通过一种方式告诉外部小人在盒子上的整个生命周期过程,既然这样,那就干脆支持一下外部定义盒子的生命周期(类似vue、react的方式),可能包括盒子创建、小人跳上盒子时、小人蓄力时、小人起跳离开时等等......然后游戏内部在不同时期调用对应的钩子。

大致能想到的就这些了,希望对你有帮助。