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

全部章節vue

這一章主要實現粒子效果、死亡斷定以及跌落,因爲時間關係而且threejs不是我主攻的方向(仍是要將時間用在正兒八經的事情上),其它的功能實現會在結尾大體分析一下。react

源碼已放github示例代碼git

我在調試時內存蹭蹭往上竄,大量對象實例被引用,發現單純的dispose效果甚微,得作的完全一點,像這樣:github

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方法來避免內存問題,web

實現小人彈跳

上一章中,咱們列出了小人的幾個相關注意點:spring

  1. 他有2個部分,頭和身體 √
  2. 起跳前有一個蓄力過程 √
  3. 蓄力時盒子有一個受擠壓過程 √
  4. 蓄力時周圍有特效(叫什麼不清楚)
  5. 蓄力時身體縮放,頭部下移,也就是說身體的部分須要將縮放原點放在小人的腳下 √
  6. 起跳時盒子有一個回彈動畫 √
  7. 空中有翻轉 √
  8. 空中有殘影
  9. 落地時身體有短暫的緩衝過程 √
  10. 落地時地面有特效

接下來實現起跳和落地時的特效。segmentfault

粒子特效

繼續打開微信跳一跳,擼一把先......api

  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的方式),可能包括盒子建立、小人跳上盒子時、小人蓄力時、小人起跳離開時等等......而後遊戲內部在不一樣時期調用對應的鉤子。

大體能想到的就這些了,但願對你有幫助。

相關文章
相關標籤/搜索