美的前端
首发于美的前端
H5场景小动画实现之PixiJs实战

H5场景小动画实现之PixiJs实战

实现目标效果

本次我们需要实现的动画效果是横向滚动,切换不同场景幕布,一些场景幕布内不同层需要层次分明的效果,一些场景幕布需要以某个点为中心点放大整个场景的效果;场景内,绘制图片,加装饰,部分装饰需要单独加动画,可能是缓动效果,自动播放;也可能是与用户的触摸操作有关。

在设计师给我们描绘了如此强大的动画效果后,我们考虑用PixiJs来实现整体的动画效果。

实际实现效果:

event.midea.com/act/mys (二维码自动识别)

【注:请在H5微信打开看效果】

1. 绘制场景内容——PixiJs

1.1 为什么选择PixiJs

PixiJS是一个快速的轻量级的2D动画渲染引擎,主要使用了WebGL技术,能帮助展示、驱动和管理富有交互性的图形、制作游戏。使用JavaScript以及其他HTML5技术,结合PixiJS引擎,可以创建出丰富的交互式图形,跨平台的应用程序和游戏。

而对于我们来说,pixiJS每个元素都可以单独操作,单独设置属性来控制动效。在绘制图片时,我们可以通过requestAnimationFrame不断循环,来实现切换同一位置上,不同元素的擦除和绘制效果。

1.2 PixiJs实现思路

我们给每个元素单独设置一些属性来满足我们的动效。主要实现思路如下:

  • 渲染单张图片时,可以使用addChild添加到舞台,然后用render来刷新。
  • 对于图片的位置,可以给每个图片用position属性设置x、y坐标来精准还原设计稿。
  • 实现层次分明的背景动画效果,我们考虑用相对位移实现,原理是给不同的图层设置不同的运动系数,在用户划动幕布时,产生不相等的位移,从而在视觉上产生层次分明的效果。主要计算公式是:
// p.data.position.x为元素初始位置 ,p.data.speed为运动系数(不一致),distance为相对于初始位置的运动位移
   p.x = p.data.position.x + (p.data.speed.x) * distance;
  • 在场景增多,图片元素增加的时候,为了更完整的动画效果和用户体验,需要图片预加载,所以考虑用PIXI.loader来实现图片的预加载,然后在图片预加载回调里执行动画
  • 对于连续播放的动画,考虑使用PIXI.extras.AnimatedSprite来实现,并通过animationSpeed精准控制运动速度、play/stop控制播放/暂停、gotoAndStop精细到第几帧等来满足设计的动效需求。
  • 对于与相对位移相关的动画,使用与位移产生相关联的参数来控制动效
  • 字幕的实现,可以用PIXI.Text来加载和设置字体,然后设置position属性来控制位置,addChild添加到画布和舞台,render来刷新。
  • 多个画布,选择用ScrollerJs来实现画布的横向滚动效果,多个画布的管理,我们选择将数据抽象成对象,尽量使用循环实现来减少代码量。

接下来,我们将按照这个思路来阐述我们的实现过程。

1.3 实现

本次场景都是一整屏,所以先声明宽高:

var  windowHeight = window.innerHeight,
        windowWidth = window.innerWidth;
 

1.3.1 创建舞台(stage)和渲染器 (renderer)

舞台(本例中objPixiContainer ):所有要渲染的对象都必须连接到舞台中才能被显示出来,舞台处于整个树形展示结构的最底层,可以理解为背景。

渲染器(本例中pixiRender ):选用canvas或webGL进行渲染的一个区域

//创建渲染器,宽高为视窗的宽高
 var pixiRender = new PIXI.autoDetectRenderer(windowWidth, windowHeight);

//把渲染器添加到HTML结构里
$(".game_con_wrap")[0].appendChild(pixiRender.view);

//创建一个容器对象:舞台
var objPixiContainer = new PIXI.Container();

//告诉渲染器去渲染舞台
pixiRender.render(objPixiContainer);

其中,autoDetectRenderer方法根据浏览器的支持情况,选择用WebGL还是Canvas去绘制画面,默认是WebGL。但是因为在我司设计师的手机(ip 6sp)上多次打开后出现画面黑屏闪动的副作用,因此选用强制使用CanvasRenderer来进行渲染。

var pixiRender = new PIXI.CanvasRenderer(windowWidth, windowHeight);
 

运行,canvas已经渲染到了全屏幕上:

画布黑乎乎的,于是我们摆点元素上去:

1.3.2 创建精灵(sprite)

特殊的图片对象被叫做精灵图。你可以控制它们的位置,尺寸以及其他许多有用的可以制作交互式动画图形的属性。Pixi有一个 Sprite 类,它是创建游戏精灵的通用方式。

创建精灵:

Pixi强大的loader对象可以用来做图片的预加载。以下代码显示了如何加载图片,并在图片加载完成后执行setup回调。

PIXI.loader
  .add("images/anyImage.png")
  .load(setup);

function setup() {
  //This code will run when the loader has finished loading the image
}

Pixi开发团队推荐,使用了loader,就应该通过引用loader 的 resources 对象来创建精灵。

以下代码就可以通过loader的resources来加载一个图像,调用loadingFinish函数,并且从加载过的图像中创建一个精灵,其中一连串的 add 方法来一次性加载许多图像:

//图片预加载
PIXI.loader
        .add("https://g.mdcdn.cn/h5/img/act/201711/new-1-1.jpg")
        .add("https://g.mdcdn.cn/h5/img/act/201711/new-1-2.png")
        .on("progress", function(){
            //do sth when loading
        })
        .load(loadingFinish);

//加载完成回调
function loadingFinish() {
    //创建一个精灵
    var sprite = new PIXI.Sprite(
        PIXI.loader.resources["https://g.mdcdn.cn/h5/img/act/201711/new-1-1.jpg"].texture
    );
    //添加到舞台
    objPixiContainer.addChild(sprite)

//渲染到渲染器
    pixiRender.render(objPixiContainer);
}

到这里,你会发现,第一个场景的第一张图就渲染完了。

同理,我们再渲染第二张静态背景图:

var sprite2 = new PIXI.Sprite(
    PIXI.loader.resources["https://g.mdcdn.cn/h5/img/act/201711/new-1-2.png"].texture
  );
  objPixiContainer.addChild(sprite2)
  pixiRender.render(objPixiContainer);
 

聪明的你一定看出来了,每次添加精灵或者精灵有动作之后,都需要重新去渲染舞台。这里有两种解决方案:

  1. 每次动作完后,手动执行一遍 pixiRender.render(objPixiContainer);
  2. 利用RAF不断去刷新舞台,只需要调用一次pageUpdate()就可以了。
function pageUpdate() {
    requestAnimationFrame(pageUpdate);
    pixiRender.render(objPixiContainer);
}

完成后,发现图片定位是初始位置跟设计稿不符合,这里可以再去查PixiJs API,给当前精灵设x轴 Y轴的位置:

sprite2.position.set(290, 297)

照这样,再加一个精灵new-1-3.png就可以看见第一个场景完整的框架了:

1.3.3 创建连续播放的动画

在完成上述背景的构建后,设计要求在幕布上云朵要有缓动效果,并给出一系列序列帧。


实现效果如下:

例子可以看:

第一幕:村口demo

这时候,我们可以考虑使用PIXI.extras.AnimatedSprite来实现。

//声明AnimatedSprite的序列帧
var urlPadding = "https://g.mdcdn.cn/h5/img/act/201711/",
  act_1_animate_bg_img_arr = [];
for (let $e = 0; $e < 11; $e++) {
       act_1_animate_bg_img_arr.push(urlPadding + "new-1-sky-" + ($e + 1) + ".jpg");
}

//精灵
var sprite4 = new PIXI.extras.AnimatedSprite.fromImages(act_1_animate_bg_img_arr)
//精灵添加到舞台
  objPixiContainer.addChild(sprite4)

//找到序列帧所在的图层,放到对应位置,并设置移动速度
sprite4.animationSpeed = 0.1; //控制速度
sprite4.play();//自动播放序列帧

如果需要控制跳到第几帧可以使用gotoAndStop/gotoAndPlay API; 这里第一帧,我们基本就完成了。

同理,后文中,奔跑的狼影子动画,也会这样实现。不同的只是图片。

单独查看例子:Jsfiddle: Running wolf

1.3.4 添加字幕

用new PIXI.Text实现:

新建舞台:

var scriptText_con;
    objPixiContainer.removeChild(scriptText_con);
scriptText_con = new pixiContainer;
scriptText_con.position.set(0, 628);

新建文字渲染:

var textSample = new PIXI.Text("老板大人", {fontSize: '22px', fill: 'white', align: 'center'});

文字渲染到舞台:

scriptText_con.addChild(textSample);

渲染到整个舞台:

objPixiContainer.addChild(scriptText_con);

1.3.5 多个场景管理

我们发现有多个场景,可以在objPixiContainer这个舞台下创建多个舞台来管理多个场景:

 //声明一个舞台
    var stageContainer = new pixiContainer();
    
    //多个场景(画布)
    let scenes = [Act_1, Act_2, Act_3, Act_5, Act_6, Act_7, Act_8_1, Act_8, Act_10, Act_11];
    //每个场景又是一个舞台,依次渲染初始位置和舞台精灵
    for (let j = 0; j < scenes.length; j++) {
        scenes[j] = new pixiContainer();
        scenes[j].pivot.set(act_sprites_list[j][0].position.x, 0);
        scenes[j].position.set(act_sprites_list[j][0].position.x, 0);
    }
     //每个actContainerArr依次赋值初始位移和渲染背景
     actContainerArr = scenes;
    //todo 精灵渲染

      //场景舞台添加到背景 
      objPixiContainer.addChild(stageContainer);
      //每个场景添加到场景舞台
      stageContainer.addChild(Act_1, Act_2, Act_3, Act_5, Act_6, Act_7, Act_8_1, Act_8, Act_10, Act_11);

那么问题又来了,我要怎么横向切换第二个场景呢? 这里我们选择使用ScrollerJS。

2. 场景切换——ScrollerJS

2.1 为什么选择ScrollerJS

ScrollerJs是一个纯净的监听scroll和zoom的组件。

2.2 ScrollerJS实现思路

当用户手指滑动屏幕时,触发滚动,通过监听滚动事件,在滚动一定距离触发事件,事件参数包含位置信息等,根据位置信息决定渲染内容(画布显示隐藏,图片位置等)。

2.3 实现

首先,需要给scroller设置容器宽高限制:

scrollerObj.setDimensions(clientWidth, clientHeight, contentWidth, contentHeight);

所以,这里我们设置:

var scrollX=10205;//视具体视觉效果而定
objScroller.setDimensions(windowWidth, windowHeight, scrollX + windowWidth, windowHeight);

然后实时监听鼠标滚动的距离:

//控制位移
function scrollerCallback(left, top, zoom) {
    console.log(`当前滚动条位置:${left},${top}`)
       var cur_Y, cur_X;
    if (GlobalRotation > 0) {
        cur_X = top;
        cur_Y = left;
    } else {
        cur_X = left;
        cur_Y = top;
    }

    //todo 位移距离对应在哪个具体场景
    if(cur_X>950){
    //第一个场景内 执行动画
    }else if(...){
	...	
   }

	//当前场景相对移动
    stageContainer.position.x = -cur_X;
    stageContainer.position.y = -cur_Y;
}
var objScroller = new Scroller(scrollerCallback, {
    zooming: false,
    animating: true,
    bouncing: false,
    animationDuration: 1000
});
objScroller.__enableScrollY = true;

这个滚动区域创建完成后,我们还需要把滚动区域与渲染器关联起来。

objPixiContainer.on("touchstart", onTouchStart)
    .on("touchmove", onTouchMove)
    .on("touchend", onTouchEnd);

function onTouchStart(e) {
//        start_stage_con.visible = false;
    var i = e.data.originalEvent;
    isTouching = true;
    objScroller.doTouchStart(i.touches, i.timeStamp);
}

function onTouchMove(e) {
    if (isTouching) {
        var i = e.data.originalEvent;
        objScroller.doTouchMove(i.touches, i.timeStamp, i.scale);
    }
}

function onTouchEnd(e) {
    var i = e.data.originalEvent;
    objScroller.doTouchEnd(i.timeStamp);
    isTouching = false;
}

然后,我们只需要在元素发生滚动时,在scrollerCallback回调里去根据滚动条的位置判断当前是在哪个场景里,然后执行对应动画效果就好了。音效播放也是根据滚动条的位置来确认画布位置。

到这里,我们就可以控制画布的移动了。

当场景变多的时候,我们把所有的场景相关的参数都配成对象,然后通过操作对象来设置动效。

act_1_sprites_arr = [
    {
        name: "bg-1",
        url: urlPadding + "new-1-1.jpg",
        position: {x: 0, y: 0},//相对于画布左上角初始位置的坐标
        speed: {x: .02, y: 0},//移动速度
        gif: true,//是否是有序列帧
        fromImages: act_1_animate_bg_img_arr  //序列帧图
    },
    {name: "mid-1", url: urlPadding + "new-1-2.png", position: {x: 290, y: 297}, speed: {x: -.1, y: 0}, gif: false},
    {
        name: "front-1",
        url: urlPadding + "new-1-3.png?t=1",
        position: {x: -200, y: 0},
        speed: {x: -.18, y: 0},
        gif: false
    }
];

如果需要平滑放大场景,且随着位移放大系数放大或缩小,那就需要与位移产生关系。

var distance = cur_X - switchX.to3;//在第三个场景内的相对位移
 let o_point_3_4 = {x:200, y:355}, tmp_x_3_4 = 0, s_3_4 = 0, scale_v_3_4 //3放大

 //800为当进入该场景,并发生800的相对位移 开始放大
tmp_x_3_4 = (distance - 800) / 400;  //放大系数
s_3_4 = tmp_x_3_4 * tmp_x_3_4 / 3;

let tmp_x = back1.data.position.x + 800;
let tmp_y = back1.data.position.y;

// 放大
Act_3.pivot.set(tmp_x - o_point_3_4.x * s_3_4, tmp_y - o_point_3_4.y * s_3_4);
scale_v_3_4 = 1 + s_3_4
Act_3.scale.set(scale_v_3_4,scale_v_3_4);

实现效果为:

坑:场景内的滚动条位置,最好用相对位移来算,不然满屏全是绝对位移,改起来非常头疼。

到这里,基本整个画卷可以基本动起来了,就剩一下小元素了。比如突然移动到某个点,突然蹿出来的狼影。这里,我们用到了TweenJS。

3. 装饰动效——结合TweenJS

动画效果实现思路:

  • 可以回溯的动效使用AnimatedSprite + ScrollerJS结合相对位移逐帧渲染。
  • 不能中断的使用 TweenJs 来填充实现。

3.1 为什么考虑TweenJS

Tween.js,类似于jQuery的animate方法和CSS3动画。以平滑的方式修改元素的属性值。只需要告诉TweenJS你想修改什么值,以及动画结束时它的最终值是什么,动画花费多少时间等信息,tween引擎就可以计算从开始动画点到结束动画点之间值,就可以产生平滑的动画效果。

3.2 TweenJS实现思路

定义起始、结束状态和时间,中间的过渡状态由TweenJS自动填充缓动效果。

3.3 实现

//runningWolf是一个精灵sprite,准确来说是animatedsprite

//开始位置声明
var  startPositionW = runningWolf.data.position

//endX : 结束位置(X轴)
new TWEEN.Tween(startPositionW)
   .to({x: endX}, 1 * 1000)
   .onUpdate(function () {
       runningWolf.x = this.x
   })
   .onComplete(function () {
       runningWolf.visible = false;
       scrollerAnimatingWolfRunning = false;
       runningWolf.x = runningWolf.data.position.x
   })
   .start();

实现效果为:

4. 声音管理

对声音的预加载,我们使用了audio标签的preload来实现:

<audio src="https://g.mdcdn.cn/h5/music/act/sound2017/ms_canival_bgm.mp3" id="ms_canival_bgm" preload="auto" loop="loop" ></audio>

但是我们很快发现,在微信浏览器中,ios11等系统的限制使得音频没办法自动播放,因此,我们使用一下方法来处理:

function playBgmAfterLoading(e) {
    var i = document.getElementById(e);
    i.play();
    var n = function () {
        document.removeEventListener("WeixinJSBridgeReady", n);
        i.play();
    };
    document.addEventListener("WeixinJSBridgeReady", n, false);
}

这个原理其实是利用了微信WeixinJSBridgeReady的接口,这个接口是对手机的音频自动播放限制进行处理过了的。 经过这样特殊处理后,在微信浏览器中就可以自动播放音频了。

到什么位置播放什么音频,就需要结合ScrollerJs来管理了。

5. 自动缩放

我们选择以750px宽度为缩放标准,然后算出一个比例: screenScaleRito = window.innerHeight/ 750;(横屏状态下),然后结合横竖屏缩放。

objPixiContainer.scale.set(screenScaleRito, screenScaleRito);
pixiRender.resize(windowWidth, windowHeight);
objPixiContainer.position.set(0, 0);

6. 横竖屏处理

原理:用浏览器窗口文档显示区域的宽度(不包括滚动条)和屏幕的宽高对比来判断横竖屏状态。 横竖屏处理完整代码:

function detectOrient() {
    var storage = localStorage; // 不一定要使用localStorage,其他存储数据的手段都可以
    var data = storage.getItem('J-recordOrientX');
    var cw = document.documentElement.clientWidth;
    var _Width = 0,
        _Height = 0;
    if (!data) {
        var sw = window.screen.width;
        var sh = window.screen.height;
        // 2.在某些机型(如华为P9)下出现 screen.width/height 值交换,所以进行大小值比较判断
        _Width = sw < sh ? sw : sh;
        _Height = sw >= sh ? sw : sh;
        storage.setItem('J-recordOrientX', _Width + ',' + _Height);
    } else {
        var str = data.split(',');
        _Width = str[0];
        _Height = str[1];
    }
    if (cw == _Width) {
        // 竖屏
        return 'portrait';
    }
    if (cw == _Height) {
        // 横屏
        return 'landscape';
    }
}
detectOrient()

然后剩下的就是根据横竖屏来对渲染器和滚动区域进行缩放了。



相关参考:

createjs.cc/src/docs/tw

pixijs.download/release

juejin.im/entry/58f864d

github.com/pbakaus/scro

jianshu.com/p/3836aa0fd

探讨判断横竖屏的最佳实现

逃不掉的四字魔咒

编辑于 2017-11-22

文章被以下专栏收录