上一篇知道如何製做threejs地球以後,就正式coding了,固然仍是使用最心愛的Vue。本篇會有一些代碼,可是都是十幾行的獨立片斷,相信你不用擔憂。html
在進入本篇主題前,要簡單看一下xplan中的自適應解決方案,即如何在不一樣尺寸設備中,都保證地球最合適的大小和位置,而且與其配套的一些圖片(虛線的橢圓軌道、正中心白色的圓環等)都不會顯示的錯位。vue
xplan用的方式簡單直接,固定大小內做佈局,而後針對不一樣的設備尺寸進行縮放。git
固定畫布大小(375 * 600),全部和地球相關的元素均可以在這個範圍內絕對定位,以後scale一下,保證在設備實際尺寸中是被包含(contain)的。這種方式比REM等其餘的自適應方式更適合這個項目,畢竟threejs中不能使用REM單位。github
感謝Vue,我得以將上面這個自行縮放的邏輯寫成一個Page組件,以後不再用操心佈局問題了。web
xplan中的動畫是最吸引個人地方,特別是地球放大,穿越雲層的那一刻,想一想還有點小激動。npm
其實以前看到過一些項目有作從外太空俯衝進地球表面的動畫,可是那些基本都是純圖片製做的SpriteSheet Animation,動畫的前進後退控制都很容易。但xplan項目中則不一樣,動畫過程當中須要控制多個動畫對象,還要配合其餘資源(音頻和視頻)。canvas
xplan中動畫的邏輯是,在地球自轉過程當中,長按按鈕,會依次發生:segmentfault
地球旋轉到目的座標設計模式
地球放大(相機推動)到該座標框架
到足夠近的時候,播放雲層穿越動畫
雲層穿越結束後,展現對應座標的視頻內容
任什麼時候刻鬆開長按按鈕,動畫都會回退到地球自轉的狀態
爲了方便討論,將上面分析到的動畫階段命名一下:
地球自轉過程:idle階段
地球轉動到指定座標的過程:rotating階段
地球距離被拉近拉遠的過程:zooming階段
穿越雲層的過程:diving階段
雲層事後的視頻展現:presenting階段
具體分析幾個過程:
在idle階段,只要touchstart,就算你只長按了0.1s,那麼rotating的動畫就會完整的觸發,而後狀態跳回idle(rotating沒有反向旋轉)。如上示意圖。
若是長按至了zooming階段,鬆開手指以後,zooming動畫會馬上反向播放,直至回到idle階段。如上示意圖。
若是zooming過程鬆開手指後,可是在離開zooming階段前再次按下去,那麼zooming動畫會再一次正向播放。如上示意圖。
diving階段貌似又回到了和rotating相似的行爲,就算中途結束,也會完成當前階段的動畫。可是和rotating不同的是,diving階段是有反向動畫的。所以能夠看到上面的示意圖。
我在考慮的過程當中,陰差陽錯的誤覺得還有一個條件:即除了rotating階段外,其餘動畫過程均可以隨時進和退(上面的GIF就是我最終完成的動畫控制)。這個給本身添加額外的難度,困擾了我好久。
我建立了一個Earth類,負責3D地球(包括光線,光暈,地表的雲,浮動座標點等)的建立和渲染,同時向外提供幾個public方法:
setCameraPosition()
getCameraPosition()
startAutoRotation()
stopAutoRotation()
地球旋轉到指定座標點,其實就是設置camera的position來完成了。要有流暢動畫的感受,就使用tween去作position的更新。
new TWEEN.Tween( earth.getCameraPosition() ).to( targetCameraPosition, 1000 ).onUpdate(function () { earth.setCameraPosition(this.x, this.y, this.z) })
關於tween和threejs動畫,這裏有教程。
其實最開始,這個Earth類沒有這麼純粹,我在裏面加了targetLocation
表明當前要轉到的目標地點;還將tween的邏輯寫在了這個類裏面,讓earth知道本身的目的地,控制本身的旋轉動畫。但後面發現對於這個項目中動畫可控制的靈活性,這樣封裝在內部的動畫邏輯,將很難寫成清晰的代碼,讓其能和後面的雲層動畫統一來控制起來。
決定使用SpriteSheet Animation相似的方法作雲層動畫。其實有這樣的庫,好比Film(這個好像也是qq下面的團隊作的),可是我仍是更想從npm中install一個,因爲沒有找到合適的,就索性本身寫一個好了,因而就發佈了一個小工具——image-sprite。
操做由ImageSprite類建立雲層對象,只用到了兩個public方法,主要控制播放前一幀和後一幀:
imageSprite.next()
imageSprite.prev()
其實應該使用自動播放(play)和暫停(pause)應該也能完成,anyway
雲層動畫功能單一,想把它寫的不純粹也難。我的以爲coding的藝術就在於如何去劃分這個純粹。
上面兩個關鍵動畫對象都實現了,用戶的行爲也很簡單,只有touchstart和touchend,那麼用一個touchDown
標誌位記錄一下就能夠了。因此能夠有一箇中控器(controller),根據用戶產生的狀態,來調用不一樣的動畫對象播放動畫。
最早開始,腦子裏面第一印象是下面這樣的解決方案:
function handleTouchDown () { touchDown = true if (currentState is idle) { playRotatingForwardAnimation(handleAnimationComplete) } else if (currentState is rotating) { playZoomingForwardAnimation(handleAnimationComplete) } else if (currentState is zooming) { playDivingForwardAnimation(handleAnimationComplete) } else if (currentState is diving) { playPresentingForwardAnimation(handleAnimationComplete) } else if (currentState is presenting) { // nothing to do } } function handleTouchEnd () { touchDown = false } function handleAnimationComplete () { if (touchDown) { // 找到下一個階段,正向播放動畫 findNextState() play<nextstate>ForwardAnimation(handleAnimationComplete) } else { // 找到上一個階段,反向播放動畫 findPrevState() play<prevstate>BackwardAnimation(handleAnimationComplete) } }
這樣的方案能解決動畫的大方向,即動畫階段之間的前進和後退,沒法控制階段內的每一幀的方向。並且也能看到,上面有太多的if判斷,handleTouchDown
函數中的那種if狀況,必定要避免,不然大項目中代碼很難維護。這樣的狀況使用有限狀態機模式或者策略模式都是很容易解決的。
第一印象告訴我:
要使用狀態機設計模式
要從幀級別去作控制
寫代碼過程當中確定會遇到狀態,最多見的狀態會被記錄成布爾值或者字符串常量,而後在作某個行爲的時候對狀態變量進行if-else判斷。若是隻有2個狀態,還行,可是狀態若是會變多,那麼這樣的代碼就很難維護,將在主體中引入愈來愈多的if-else,愈來愈多的與特定狀態相關的變量和邏輯。
我的很是喜歡狀態機模式或者策略模式,它們本質都同樣,都是使用組合代替繼承,完成統一接口下的行爲的多樣性。最開心的是,這個模式將混雜在主體中的狀態量和行爲抽離出來,單獨封裝,讓主體變的清清爽爽;還有,在JS中,你甚至鏈接口類都不用寫!
舉個簡單的例子,上一篇中談到的ImageSprite,用來將一系列圖片進行播放,本質上就是繪製圖片而已。可是我這裏提供兩種模式,一種繪製在canvas裏,一種繪製在dom裏(即image展現)。
不使用模式,能夠簡單的寫成這樣:
class ImageSprite { constructor () { this.renderMode = 'canvas' this.context = null this.imageElement = null this.images = [] } drawImage () { if (this.renderMode === 'canvas') { this.context.drawImage() } else if (this.rendererMode === 'dom') { this.imageElement.src = '...' } } }
使用了狀態機模式(這裏的場景來看,叫策略模式更貼切,渲染策略不一樣):
class ImageSprite { constructor () { this.renderer = new CanvasRenderer(this) this.images = [] } drawImage () { this.renderer.drawImage() } } class CanvasRenderer { constructor (imageSprite) { this.imageSprite = imageSprite this.context = null } drawImage () { this.context.drawImage() } } class DomRenderer { constructor (imageSprite) { this.imageSprite = imageSprite this.imageElement = null } drawImage () { this.imageElement.src = '...' } }
能夠看到使用了模式以後,context
和imageElement
這樣的和狀態相關的變量,還有繪製canvas圖片和繪製dom圖片的不一樣代碼,都從主體ImageSprite中抽離出去,單獨的封裝到了不一樣的狀態對象中去了。
想一想一下若是有第三種渲染模式,好比渲染在webgl中去,在不使用模式的代碼中,要添加變量,要修改drawImage
函數;可是在使用了模式的代碼中,現有代碼都不用改變,只須要添加一個新類WebglRenderer
就能夠了。這就是代碼的可擴展性和可維護性的體現。(在Java中,還能省去代碼的從新編譯的過程)
回到xplan的動畫中去。在前面分析動畫階段的時候,其實就獲得了每一個狀態,這些狀態的統一接口就是向前幀動畫(forward)和向後幀動畫(backward)。
先無論每一個state中邏輯該怎樣,有了約定的接口,就能夠把咱們的中控器(Controller)寫個基本框架了:
class Controller { constructor (earth, cloud) { this.earth = earth this.cloud = cloud this.touchDown = false this.state = new IdleState(this) // 初始狀態爲IdleState this._init() } _loop () { requestAnimationFrame(this._loop.bind(this)) if (this.touchDown) { // 若是touchDown,則向前一幀 this.state.forward() } else { // 不然,向後一幀 this.state.backward() } handleTouchStart () { this.touchDown = true } handleTouchEnd () { this.touchDown = false } // ... }
由於要作到幀級別的控制,所以這裏用到requestAnimationFrame來製做渲染循環。代碼是否是很清晰簡單!在渲染循環中,根本不在意動畫邏輯怎麼執行,只知道touchDown了,就作向前動畫,不然作向後動畫,其餘的都在各自的狀態類裏去實現。
下面拿兩個狀態類舉例,其餘的請移步這裏。
IdleState
class IdleState { constructor (controller) { this.controller = controller } forward () { this.controller.state = new RotatingState(this.controller) } backward () { // do nothing } }
這裏IdleState沒有向後的動畫,所以backward()
裏面是空的;而該狀態下的touchDown都會讓earth開始旋轉到指定座標,而這個過程咱們知道是RotatingState該作的,因此在RotatingState的‘forward()`裏會去實現旋轉控制。
DivingState
class DivingState { constructor (controller) { this.controller = controller } forward () { let cloud = this.controller.cloud if (cloud.currentFrame is last frame) { // 最後一幀時,進入下一個狀態 this.controller.state = new PresentingState(this.controller) } else { cloud.next() // 播放下一幀 } } backward () { let cloud = this.controller.cloud if (cloud.currentFrame is first frame) { // 回退到第一幀時,進入上一個狀態 this.controller.state = new ZoomingState(this.controller) } else { cloud.prev() // 播放前一幀 } } }
還記得麼,diving是指穿越雲層的那個過程。所以它往前(forward)是presenting,日後(backward)是zooming。而何時切換到下一個或者前一個狀態,和往前或者日後的每一幀動畫該如何執行,都只有這個DivingState知道,完美的邏輯封裝。
完整的動畫邏輯裏,還包含着一些音頻和視頻的控制邏輯。好比地球自轉時播放背景音樂,動畫一旦開始則中止;穿越雲層後播放視頻,其餘時候視頻是中止的。這些邏輯,可以很容易的添加到上面的狀態中去。好比在IdleState的contructor中播放音樂,在RotatingState的contructor中中止播放音樂;在PresentingState的constructor中播放視頻,在DivingState的contructor中中止視頻。
因此,一旦邏輯清晰了,代碼清晰了,添加功能時顯得很容易。
完成上面的全部動畫狀態以後,我發現地球其實還有一個動畫,那就是開場的逆向旋轉並放大的入場動畫。在上面作動畫分析的時候,是把這個開場動畫分開來設想的,可是上面的controller用上狀態機以後,意外的發現這個入場動畫能夠以另一個state放進來。
入場動畫狀態類:
class EnteringState { constructor (controller) { this.controller = controller this.tween = new TWEEN.Tween({ // 起點位置 }).to({ // 終點位置 }, 1600).onUpdate(function () { // 設置earth的縮放和旋轉 }).onComplete(function () { this.controller.state = new IdleState(this.controller) // 完成後進入IdleState }).easing(TWEEN.Easing.Cubic.Out).start() } forward () { TWEEN.update() } backward () { // do nothing } }
最後將Controller初始化時的第一個state賦值改成EnteringState
便可。這真算是一個意外的收穫,原本是打算單獨(在controller以外)去實現的。
到這裏就差很少了,xplan主要的東西都講到了,高(shan)仿(zhai)的過程還不錯,瞭解了three,順便還publish了幾個小的工具庫;有不足、也有超越。這個h5看似複雜,可是技術也沒有多高深,主要仍是創意,仍是要給xplan點個贊!
最後,我的接h5,有沒有我的或者公司啊,不要很差意思聯繫我~