上一篇知道如何制做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,有没有我的或者公司啊,不要很差意思联系我~