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

全部章節前端

前言

搞了三年前端,呆過幾個不大的公司,作過幾個不大的項目。三年來心態略顯浮躁,昔日的朋友早已練就大佬之身,而我卻在原地停留了好久好久。因爲在前段時間離職,因此近期正在備試惡補,弄得日夜顛倒,已分不清白天黑夜。強行灌輸老是那麼枯燥,而且我那該死的記憶力太不爭氣,左腦進右腦出,因此找點有意思的事情(臨摹個小遊戲)給本身找找刺激!git

表達能力有限,文筆又差,若是有不少病句還請海量......github

因爲本文只是嘗試對微信跳一跳進行一次深刻的臨摹,和原遊戲確定還存在很大的差距,而且首次使用threejs,因此本解析僅做爲一個簡單的嚮導,但願能對你有些做用,若是哪裏有不妥的地方讀者能夠自由發揮。算法


萬字多圖長文預警!!!spring

本章源碼已放github這是示例這是一個半成品,到此時尚未寫完,過幾天再發完整版編程

前置知識

微信跳一跳,這個遊戲剛出的時候,本身在閒暇時間寫過一個很是簡單的版本,自覺得接下來就很簡單了,但毫無疑問那只是沒有絲毫起伏的波瀾,這一次重寫讓我踩了好幾個坑plus。看似風平浪靜的水面,你要是不下水,就不知道水下有多少暗流涌動。canvas

考慮具體實現以前,咱們首先得了解一部分與本遊戲相關的threejs的知識api

1、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)
    複製代碼

2、建立物體

首先是你須要什麼形狀的物體?幾何形狀(Geometry),物體的外觀是什麼樣的?材質(Material),而後建立它網格(Mesh)。你須要看到物體嗎?燈光(Light)

3、物體陰影

  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
    複製代碼

4、threejs的變換原點

旋轉(rotation)、縮放(scale)的原點是網格(Mesh)中心點,畫來一張圖來描述:

也就是說,能夠經過位移幾何形狀(Geometry)達到控制縮放原點的目的,除此以外,threejs中還有組(Group),那麼若是對一個組內物體進行縮放操做,對應的就是經過控制組內物體的位置來控制物體的縮放原點

5、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函數,這個函數生成大小不1、顏色隨機的立方體盒子。想到這裏,咱們彷佛能夠經過維護一個集合,這個集合專門存放各類不一樣風格的盒子的生成器(即函數),來達到可定製化的需求,好比後期產品須要加一個貼了xxx廣告的xxx形狀的盒子,咱們能夠往這個集合中添加一個新的道具生成器就好了,而這個盒子的樣式由外部來定。

既然盒子的樣式能夠由外部來定,那就須要有一個統一的規範,好比盒子的寬度、深度、高度範圍,再好比考慮性能上的優化,咱們最好提供一個可copy的幾何對象和材質。

// 維護一個道具生成器集合
const boxCreators = []
// 共享立方體
const baseBoxBufferGeometry = new THREE.BoxBufferGeometry()
// 共享材質
const baseMeshLambertMaterial = new THREE.MeshLambertMaterial()
// 隨機顏色
const colors = [0x67C23A, 0xE6A23C, 0xF56C6C]
// 盒子大小限制範圍
const boxSizeRange = [30, 60]

// 實現一個默認的生成大小不1、顏色隨機的立方體盒子的生成器
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
    }
  }
複製代碼

本覺得幾天能寫的差很少,沒想到估算偏差太大,待我幾天後繼續更新......

若是對你有幫助,請給個贊,謝了老鐵!

相關文章
相關標籤/搜索