View on GitHub

essay

All about my technical essays and practicals

移动端手势动效的实现

为了配合《流星蝴蝶剑》手游开服,我们对移动官网进行了重构,加入跟随手指动作的“剑气”特效,使其更契合游戏主题。本文将从背景,动效原理,实现和页面性能几个维度进行分析。

背景

《流星蝴蝶剑》手游是主打高难度战斗和写实动作的硬核动作武侠手游。为了与游戏风格契合,增加页面的动感,我们最终决定在移动端官网加入手势动效:让用户在滑动浏览时,能看到自己的手指在页面上产生一道道“剑气”。调研发现,目前市面上暂无可供参考的方案,因此只能从一些手游客户端中借鉴移植。有类似效果的《水果忍者》就是比较好的借鉴对象。

动效原理

在《水果忍者》中,用户手指划过屏幕,就会有相应的“刀痕”出现。不难发现,该痕迹主要由两部分组成:

  1. 痕迹主体。由核心的细长线条和周围边缘构成。手指划过屏幕时出现,随着时间不断消逝。
  2. 手指划过水果或者案板后,四周留下的“碎片”。碎片与痕迹同时产生,带有一定初速,在重力作用下四散开,并逐渐消逝。

所以,移动端web页面的“剑气”特效,也需要分解成划痕+粒子碎片两部分:1. 随手指滑动绘制出的“剑气”主体,2. “剑气”划过后,周围空间留下的“碎片”或者说是“火花”。

解决了“剑气”的构成原理后,还需要解决在移动端全屏绘制手势跟随动效的难题。

在pc端,鼠标跟随动画很成熟。只要在页面上加入一个canvas,并监听上面的鼠标事件,就可以根据鼠标位置绘制动效。而移动端受到屏幕滚动功能的影响,不可以直接把touchmove事件绑定到canvas上,需要转换一下思路。

从原理上看,canvas相当于一层画布,用于绘制动效。而绘制的坐标其实是屏幕坐标,无论touch事件绑定在什么元素上,只要能获取该事件的屏幕坐标即可。所以可以把touch事件绑定到body上,再将动效绘制到position属性为fixed,大小占满整个屏幕的canvas上。为了不影响页面正常交互,还需要设置pointer-event属性,让touch事件穿透该canvas元素。

具体实现

有了上述分析,具体实现就简单多了。首先是搭建页面基本结构。我们创建一个canvas元素,铺满屏幕并设置position为fixed。此外,还需要在body上绑定touchmove,touchstart,和touchend事件。在每次touch事件的回调函数中更新手势动效。利用requestAnimationFrame在canvas上重绘动效。为了让canvas不会阻碍用户交互,还需要讲canvas元素的pointer-event设置为none。

“剑气”动效的实现也不难。用原生的canvas API写动画比较繁琐,为了简化代码,这里引入一个简单的canvas库–http://soulwire.github.io/sketch.js/。

“剑气”的痕迹主体由一个“Trace”对象的数组来表示。每一次touchmove都会在该数组中新增一个Trace对象。该对象存储了上一次touchmove事件的坐标,当前事件坐标,透明度,痕迹宽度和颜色。在每个渲染周期里,我们利用ctx.lineTo 连接各个Trace对象的起点和终点,并降低透明度和宽度,制造出逐渐消失的效果。

其中Trace类的实现如下:


Trace.prototype = {
    constructor: Trace,
    init: function(x, y, lastX, lastY){
        this.alive = true
        this.opacity = 1
        this.width = 5
        this.x = x
        this.y = y
        this.lastX = lastX
        this.lastY = lastY
        this.color = '#f0d08f'
    },
    vanish: function(){
        this.opacity -= 0.1 * 1
        this.width -= 0.1 * 5
        this.alive = this.opacity > 0.1
    },
    draw: function( ctx ){
        ctx.beginPath()
        ctx.moveTo(this.lastX, this.lastY)
        ctx.lineTo(this.x, this.y)
        ctx.lineWidth = Math.round(this.width)
        ctx.strokeStyle = 'rgba(' + 
        // 把  #aabbcc 转化为 11,22,33 再 concat上 opacity
        [this.color.substr(1,2), this.color.substr(3,2), this.color.substr(5,2)].map(function(v){return parseInt(v, 16)}).concat(this.opacity).join(',')
        +')'
        ctx.stroke()
    }
}

碎片粒子效果的实现也类似,只需记录当前touch事件的坐标,加入粒子速度,加速度等物理量,以及粒子的颜色,透明度等等。粒子每次在touch事件坐标周围随机出现,向四周散开,并随时间逐渐消逝。官网使用的粒子特效直接参考了sketch.js官网的例子,“碎片”粒子代码如下:

Particle.prototype = {
    constructor: Particle,
    init: function( x, y, radius ) {
        this.alive = true
        this.radius = radius || 10
        this.wander = 0.15
        this.theta = random( TWO_PI )
        this.drag = 0.92
        this.color = '#fff'
        this.x = x || 0.0
        this.y = y || 0.0
        this.vx = 0.0
        this.vy = 0.0
    },
    move: function() {
        this.x += this.vx
        this.y += this.vy
        this.vx *= this.drag
        this.vy *= this.drag
        this.theta += random( -0.5, 0.5 ) * this.wander
        this.vx += sin( this.theta ) * 0.1
        this.vy += cos( this.theta ) * 0.1
        this.radius *= 0.96
        this.alive = this.radius > 0.5
    },
    draw: function( ctx ) {
        ctx.beginPath()
        ctx.arc( this.x, this.y, this.radius, 0, TWO_PI )
        ctx.fillStyle = this.color
        ctx.fill()
    }
}

最后,将touch事件的回调代码补全,创建Trace和Particle的数组,用requestAnimationFrame不断重绘到canvas中,就能得到想要的效果了。

页面性能

手指跟随动效是canvas动画,用requestAnimationFrame不断重绘,本身对资源要求不高。用chrome自带的性能面板监测,帧频稳定。

值得一提的是,随着touch事件不断触发,Trace和Particle数组中的实例越来越多。若不及时清理,势必导致内存暴涨。因此,可以重用实例或者及时清除已经消逝了的实例来降低内存占用。

最后附上一个预览地址: