我用PIXI + GSAP导了一出好戏《刹车》 | 猿创营

3,646 阅读9分钟

在写这篇文章之前,先感谢大帅老师@大帅老猿,是他让我重新拿起了笔在掘金上输出文章。本人17年就注册了掘金账号,潜水到21年初,才在掘金写了第一篇文章,之后又陆续写了3篇,都没有啥点赞和阅读量,兴致就低了,息笔一年。直到今年4月份,在大帅老师的带领下,参加了游戏创意大赛,并输出了自己的文章,虽然在创意大赛上没有得奖,只拿了一个阳光普照,但是在码上掘金活动中,运气好获得了14名的成绩,拿到了我在掘金社区的第一个奖:小爱音箱。原来我并不是不能写,我只是缺少机会,我只是缺少锻炼!于是在之后的掘金活动中,我几乎都参与了,大奖不容易拿,拿拿小奖,撸撸羊毛还是很开心的(6月更文挑战不小心写成了满勤,羊毛大大滴多)~

这次大帅带着猿创营的小伙伴搞了个全员实训 - PIXI + GSAP实现刹车动效的 掘金更文活动,输出自己在看完大帅在哔哩哔哩的直播后自己的动效实现的文章。个人认为是很有意义的,所以还没在掘金写过文章的同学,不要怂,上来就直接干,相信大家也能收获自己的成长,爱上写作。逼逼太多了,赶紧上点最近很多人都上过的干货:

工程准备

fork 大帅老师的github仓库到自己账号,并克隆到本地,工程目录如下,极其简单啊,就5张资源图片,一个js文件一个html文件,咱不整花里胡哨的,直接html+js开干:

image.png

入口页

<html>
<head>
    <title>猿创营</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,minimum-scale=1.0,user-scalable=no">
    <style type="text/css">
        html,
        body {
            margin: 0;
            padding: 0;
        }

        div {
            width: 100%;
        }
    </style>
    <script src="https://pixijs.download/release/pixi.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.10.4/gsap.min.js"></script>
    <script src="./js/brakebanner.js"></script>
    <script>
        window.onload = init;

        function init() {
            let banner = new BrakeBanner("#brakebanner");
        }
    </script>
</head>

<body>
    <div id="brakebanner"></div>
</body>

</html>

在我们的入口页引入了两个js:pixi.min.jsgsap.min.jsPIXIGSAP正是打造咱们今天的主角的称手工具。先附上两个官网链接供大家直达,证明他俩不是并夕夕买的,是从海外淘来的呢:

PixiJS官网 github star: 36.8k

Gsap官网 github star: 14.4k

另外我们还引用了一个brakebanner.js。在后面调用了一个init函数,用以实例化一个brakeBanner类,这个才是我们今天的主角。

brakeBanner类实现

镜头转向我们的主角:

image.png

是的,这是一个小白,需要大家慢慢调教~ 小白说,脸都不要了吗?行,给他来个门面:

创建PIXI应用

class BrakeBanner {
    constructor(selector) {
        this.app = new PIXI.Application({
            width: 100,
            height: 100,
            backgroundColor: 'black'
        });
        document.querySelector(selector).appendChild(this.app.view);
    }
}

效果如下:

image.png

小白说: PIXI.Application是个啥呀? this.app.view又是个啥呀?为啥我那么黑呀?给我洗白呀!

导演: 知道那么多干嘛?会演戏就行,只会背台词没演技是走不远的,演就完事了。

小白:行吧,那我要一黑到底!

导演:你特么演我?

《刹车》第一幕:彻底黑化的小白

小白延伸了他的边界至窗口大小,并将自己抹黑:

this.app = new PIXI.Application({
    width: window.innerWidth,
    height: window.innerHeight,
    backgroundColor: 'black'
});

image.png

这下好了,小白彻底黑化了!

导入资源

《刹车》第二幕:小白的好帮手PIXI

在一个风和日丽的早晨,小白骑上他心爱的小摩托自行车(贫穷买不起摩托)去郊游。出门前,小白打算掏出了他刚收到的淘宝拼装自行车,于是喊来了PIXIPIXI在拼装图片方面很有优势:

this.loader = new PIXI.Loader();
this.loader.add('bike', 'images/brake_bike.png');
this.loader.add('handlerbar', 'images/brake_handlerbar.png');
this.loader.add('lever', 'images/brake_lever.png');
this.loader.load();
this.loader.onComplete.add(() => {
    let bike = new PIXI.Sprite(this.loader.resources['bike'].texture);
    let handler = new PIXI.Sprite(this.loader.resources['handlerbar'].texture);
    let lever = new PIXI.Sprite(this.loader.resources['lever'].texture);
    this.app.stage.addChild(bike);
    this.app.stage.addChild(handler);
    this.app.stage.addChild(lever);
})

不一会儿,PIXI就把车把,车架,和车把手搬到了观众面前:

image.png

吃瓜群众:这个是自行车?

什么是容器

小白:别急,车子太大了,我让PIXI调整下,PIXI在吗?帮我把车子车架和车把手给我缩小到原来的1/4

PIXI:好的,巴啦啦能量,scale变身:

this.loader.onComplete.add(() => {
+   const bikeContainer = new PIXI.Container();
    let bike = new PIXI.Sprite(this.loader.resources['bike'].texture);
    let handler = new PIXI.Sprite(this.loader.resources['handlerbar'].texture);
    let lever = new PIXI.Sprite(this.loader.resources['lever'].texture);
+   bikeContainer.addChild(bike);
+   bikeContainer.addChild(handler);
+   bikeContainer.addChild(lever);
+   bikeContainer.scale.x = bikeContainer.scale.y = .25;
    this.app.stage.addChild(bikeContainer);
})

image.png

着色器的使用

PIXI很老道啊,这边车架,车把,车把手始终是一起的,他把他们都装在一个容器Container里,干完这些后,PIXI以为小白会夸他两句,没想到小白眉头微皱道:“这个颜色骑出去,满大街都是,能不能换个颜色呢?可以稍微夸张点也是OK的”。PIXI摸了摸自己的下巴,眼珠子转了转:“有了”,然后从口袋里掏出了一个工具:无敌调色板,还有一个好听的英文名字tint

this.loader.onComplete.add(() => {
    const bikeContainer = new PIXI.Container();
    let bike = new PIXI.Sprite(this.loader.resources['bike'].texture);
    let handler = new PIXI.Sprite(this.loader.resources['handlerbar'].texture);
    let lever = new PIXI.Sprite(this.loader.resources['lever'].texture);
+   bike.tint = 0xff0000; // 给自行车着色
+   handler.tint = 0xff00ff;
+   lever.tint = 0x00ffff;
    bikeContainer.addChild(bike);
    bikeContainer.addChild(handler);
    bikeContainer.addChild(lever);
    bikeContainer.scale.x = bikeContainer.scale.y = .25;
    this.app.stage.addChild(bikeContainer);
})

image.png

小白: 够骚气!奖励你一个棒棒糖🍭。

PIXI实例的位置调整

PIXI接过棒棒糖,继续手头上的工作,很明显,他在调车把手,这不是他在行的,不过做事得做全套嘛!心疼PIXI两秒,一顿操作之后:

this.loader.onComplete.add(() => {
    const bikeContainer = new PIXI.Container();
    let bike = new PIXI.Sprite(this.loader.resources['bike'].texture);
    let handler = new PIXI.Sprite(this.loader.resources['handlerbar'].texture);
    let lever = new PIXI.Sprite(this.loader.resources['lever'].texture);
    bike.tint = 0xff0000;
    handler.tint = 0xff00ff;
    lever.tint = 0x00ffff;
+   lever.x = 365; // 调整车把手的x坐标
+   lever.y = 420; // 调整车把手的y坐标
    bikeContainer.addChild(bike);
    bikeContainer.addChild(handler);
    bikeContainer.addChild(lever);
    bikeContainer.scale.x = bikeContainer.scale.y = .25;
    this.app.stage.addChild(bikeContainer);
})

image.png

小白眉头正准备皱的时候,PIXI迅速调整了一下顺序:

this.loader.onComplete.add(() => {
    const bikeContainer = new PIXI.Container();
    let bike = new PIXI.Sprite(this.loader.resources['bike'].texture);
    let handler = new PIXI.Sprite(this.loader.resources['handlerbar'].texture);
    let lever = new PIXI.Sprite(this.loader.resources['lever'].texture);
    bike.tint = 0xff0000;
    handler.tint = 0xff00ff;
    lever.tint = 0x00ffff;
    lever.x = 365;
    lever.y = 420;
    bikeContainer.addChild(bike);
↑   bikeContainer.addChild(lever); // 先加到容器里的会被放在最底下
↓   bikeContainer.addChild(handler);
    bikeContainer.scale.x = bikeContainer.scale.y = .25;
    this.app.stage.addChild(bikeContainer);
})

image.png

嗯,车子装好了,要出发咯!

PIXI: 等等!这车子没有刹车,我再给它装个刹车按钮。

this.loader.onComplete.add(() => {
    // 自行车容器
    // ...

    // 刹车按钮容器
    const btnContainer = new PIXI.Container();
    let btn = new PIXI.Sprite(this.loader.resources['btn'].texture);
    let btn_circle = new PIXI.Sprite(this.loader.resources['btn_circle'].texture);
    btnContainer.addChild(btn);
    btnContainer.addChild(btn_circle);
    btnContainer.scale.x = btnContainer.scale.y = .5;
    this.app.stage.addChild(btnContainer);
})

image.png

PIXI: 别急,调整下位置:

this.loader.onComplete.add(() => {
    // 自行车容器
    // ...

    // 刹车按钮容器
    const btnContainer = new PIXI.Container();
    let btn = new PIXI.Sprite(this.loader.resources['btn'].texture);
    let btn_circle = new PIXI.Sprite(this.loader.resources['btn_circle'].texture);
+   btn.pivot.x = btn.width / 2; // 将按钮的圆形置于图片中心
+   btn.pivot.y = btn.height / 2;
+   btn_circle.pivot.x = btn_circle.width / 2;
+   btn_circle.pivot.y = btn_circle.height / 2;
    btnContainer.addChild(btn);
    btnContainer.addChild(btn_circle);
    btnContainer.scale.x = btnContainer.scale.y = .5;
+   btnContainer.x = 350;
+   btnContainer.y = 350;
    this.app.stage.addChild(btnContainer);
})

image.png

《刹车》第三幕:PIXI的好基友GSAP

GSAP:简化你的动效逻辑

PIXI:再赠送你一个按钮特效吧,这样好在别人面前装逼,不过这个还是我的好兄弟GSAP比较在行。

GSAP: 这回 终于轮到我了吧,弟弟们,看好了:

this.loader.onComplete.add(() => {
    // 自行车容器
    // ...

    // 刹车按钮容器
    const btnContainer = new PIXI.Container();
    let btn = new PIXI.Sprite(this.loader.resources['btn'].texture);
    let btn_circle = new PIXI.Sprite(this.loader.resources['btn_circle'].texture);
    btn.pivot.x = btn.width / 2; // 将按钮的圆形置于图片中心
    btn.pivot.y = btn.height / 2;
    btn_circle.pivot.x = btn_circle.width / 2;
    btn_circle.pivot.y = btn_circle.height / 2;
+   gsap.to(btn_circle.scale, { duration: 1, x: 1.3,y:1.3, repeat: -1 }); // 通过gsap控制圆圈的缩放。
    btnContainer.addChild(btn);
    btnContainer.addChild(btn_circle);
    btnContainer.scale.x = btnContainer.scale.y = .5;
    btnContainer.x = 350;
    btnContainer.y = 350;
    this.app.stage.addChild(btnContainer);
})

是的,你没看错,就是一行代码!

demo.gif

不过还是差点意思,作为动效界的扛把子,怎么允许不完美出现,再酷炫点:

this.loader.onComplete.add(() => {
    // 自行车容器
    // ...

    // 刹车按钮容器
    const btnContainer = new PIXI.Container();
    let btn = new PIXI.Sprite(this.loader.resources['btn'].texture);
    let btn_circle = new PIXI.Sprite(this.loader.resources['btn_circle'].texture);
+   let btn_circle2 = new PIXI.Sprite(this.loader.resources['btn_circle'].texture); // 加一个固定的圈
    btn.pivot.x = btn.width / 2;
    btn.pivot.y = btn.height / 2;
    btn_circle.pivot.x = btn_circle.width / 2;
    btn_circle.pivot.y = btn_circle.height / 2;
+   btn_circle2.pivot.x = btn_circle2.width / 2;
+   btn_circle2.pivot.y = btn_circle2.height / 2;

+   btn_circle.scale.x = btn_circle.scale.y = .8; // 起始从0.8倍开始,更具动感
    gsap.to(btn_circle.scale, { duration: 1, x: 1.3, y: 1.3, repeat: -1 });
+   gsap.to(btn_circle, { duration: 1, alpha: 0, repeat: -1 }); // 让圆圈变到透明
    btnContainer.addChild(btn);
    btnContainer.addChild(btn_circle);
+   btnContainer.addChild(btn_circle2);
    btnContainer.scale.x = btnContainer.scale.y = .5;
    btnContainer.x = 350;
    btnContainer.y = 350;
    this.app.stage.addChild(btnContainer);
})

demo1.gif

吃瓜群众:卧槽!牛逼。

GSAP: 洒洒水啦~ 加上按钮事件,控制你的刹车把手:

this.loader.onComplete.add(() => {
    // 自行车容器
    // ...

    // 刹车按钮容器
    const btnContainer = new PIXI.Container();
    let btn = new PIXI.Sprite(this.loader.resources['btn'].texture);
    let btn_circle = new PIXI.Sprite(this.loader.resources['btn_circle'].texture);
    let btn_circle2 = new PIXI.Sprite(this.loader.resources['btn_circle'].texture); // 加一个固定的圈
    btn.pivot.x = btn.width / 2;
    btn.pivot.y = btn.height / 2;
    btn_circle.pivot.x = btn_circle.width / 2;
    btn_circle.pivot.y = btn_circle.height / 2;
    btn_circle2.pivot.x = btn_circle2.width / 2;
    btn_circle2.pivot.y = btn_circle2.height / 2;

    btn_circle.scale.x = btn_circle.scale.y = .8; // 起始从0.8倍开始,更具动感
    gsap.to(btn_circle.scale, { duration: 1, x: 1.3, y: 1.3, repeat: -1 });
    gsap.to(btn_circle, { duration: 1, alpha: 0, repeat: -1 }); // 让圆圈变到透明
    btnContainer.addChild(btn);
    btnContainer.addChild(btn_circle);
    btnContainer.addChild(btn_circle2);
    btnContainer.scale.x = btnContainer.scale.y = .5;
    btnContainer.x = 350;
    btnContainer.y = 350;
    
+   btnContainer.interactive = true;
+   btnContainer.buttonMode = true;
+   btnContainer.on('mousedown', () => {
+           gsap.to(lever, { duration: .6, rotation: Math.PI / 180 * -30 });
+   })
+   btnContainer.on('mouseup', () => {
+           gsap.to(lever, { duration: .6, rotation: 0 });
+   })
    
    this.app.stage.addChild(btnContainer);
})

实际上这边我省略了一步,就是需要设置车把手的旋转圆心为右下角:

this.loader.onComplete.add(() => {
    // ...
+   lever.x = 750;
+   lever.y = 930;
+   lever.pivot.x = 483;
+   lever.pivot.y = 500;
    // ...
})

ok!效果不错!可以出发!

demo3.gif

PIXI: 给你整点东西,让你看着像在前进,毕竟咱们是在拍戏,不能真的上路。来,给你整点参照物,参照物在动,假装是你在动~

PIXI绘制图形

在画面中随机添加粒子:

this.loader.onComplete.add(() => {
    // ...
    // 粒子容器
    const particleContaner = new PIXI.Container();
    this.app.stage.addChild(particleContaner);
    // 预设一些颜色
    const colors = [0xf1cf54, 0xb5cea8, 0xf1cf54, 0x818181, 0x000000];
    for (let i = 0; i < 10; i++) {
        let particle = new PIXI.Graphics();
        // 随机取一种颜色
        let color = colors[Math.floor(Math.random() * colors.length)];
        particle.beginFill(color);
        // 在窗口随机位置画半径为3-6的圆
        let x = Math.random() * window.innerWidth;
        let y = Math.random() * window.innerHeight;
        let r = 3 + Math.ceil(Math.random() * 3)
        particle.drawCircle(x, y, r);
        particle.endFill();
        particleContaner.addChild(particle);
    }
})

image.png

导演:PIXI,麻烦把车子挪到角落去!

PIXI:好的,导演

this.loader.onComplete.add(() => {
    // 自行车容器
    const bikeContainer = new PIXI.Container();
    let bike = new PIXI.Sprite(this.loader.resources['bike'].texture);
    let lever = new PIXI.Sprite(this.loader.resources['lever'].texture);
    let handler = new PIXI.Sprite(this.loader.resources['handlerbar'].texture);
    bike.tint = 0xff0000;
    handler.tint = 0xff00ff;
    lever.tint = 0x00ffff;
    lever.x = 750;
    lever.y = 930;
    lever.pivot.x = 483;
    lever.pivot.y = 500;
    bikeContainer.addChild(bike);
    bikeContainer.addChild(lever);
    bikeContainer.addChild(handler);
    bikeContainer.scale.x = bikeContainer.scale.y = .25;
+   bikeContainer.x = window.innerWidth - bikeContainer.width;
+   bikeContainer.y = window.innerHeight - bikeContainer.height;
    this.app.stage.addChild(bikeContainer);
})

image.png

按钮也一起调整下吧,让它和车子始终保持位置不变:

    // 刹车按钮容器
    // ...
    btnContainer.x = window.innerWidth - 350;
    btnContainer.y = window.innerHeight - 350;
    // ...

image.png

一切准备就绪,现在要让我们的粒子动起来:

this.loader.onComplete.add(() => {
    // ...
    // 粒子容器
    const particleContaner = new PIXI.Container();
    this.app.stage.addChild(particleContaner);
    // 预设一些颜色
    const colors = [0xf1cf54, 0xb5cea8, 0xcf09d3, 0xf8b62a, 0x3b8f09];
+   const particles = []; // 保存粒子
    for (let i = 0; i < 10; i++) {
        let particle = new PIXI.Graphics();
        // 随机取一种颜色
        let color = colors[Math.floor(Math.random() * colors.length)];
        particle.beginFill(color);
        // 在窗口随机位置画半径为3-6的圆
        let x = Math.random() * window.innerWidth;
        let y = Math.random() * window.innerHeight;
        let r = 3 + Math.ceil(Math.random() * 3)
        particle.drawCircle(x, y, r);
        particle.endFill();
        particleContaner.addChild(particle);
+       particles.push(particle);
    }
+   const move = () => {
+       particles.forEach(item => {
+               item.y += 1;
+       })
+   }
+   gsap.ticker.add(move)
})

是的,他神奇地动起来了!

吃瓜群众:牛逼

demo2.gif

但是我们的车子和粒子的运动方向不一致,怎么办呢?将每个粒子都旋转某个角度?no!no!no!,只需旋转粒子容器即可:

    // 粒子容器
    const particleContaner = new PIXI.Container();
+   particleContaner.rotation = Math.PI / 180 * 30;
    this.app.stage.addChild(particleContaner);

demo4.gif

导演:让粒子铺满整个画面,并且无限循环 GSAP:这个嘛。简单

复习三角函数

image.png 啊!初中数学三角函数不记得了...

先求个斜边吧:

// 斜边
let hyp = Math.floor(Math.pow((Math.pow(window.innerHeight, 2) + Math.pow(window.innerWidth, 2)), 0.5));

再计算下屏幕的tan角:

let angle = 180 / (Math.PI / Math.atan(window.innerHeight / window.innerWidth));

最终计算出z的高度:

let z = Math.abs(Math.cos(90 - 30 - angle) * hyp);
// 粒子容器
    const particleContaner = new PIXI.Container();
+   particleContaner.x =  window.innerWidth / 2;
+   particleContaner.y =  window.innerHeight / 2;
+   particleContaner.pivot.x = window.innerWidth / 2;
+   particleContaner.pivot.y = window.innerHeight / 2;
    particleContaner.rotation = Math.PI / 180 * 30;
    this.app.stage.addChild(particleContaner);
    // 预设一些颜色
    const colors = [0xf1cf54, 0xb5cea8, 0xcf09d3, 0xf8b62a, 0x3b8f09];
    const particles = [];
    for (let i = 0; i < 100; i++) {
            let particle = new PIXI.Graphics();
            // 随机取一种颜色
            let color = colors[Math.floor(Math.random() * colors.length)];
            particle.beginFill(color);
            // 在窗口随机位置画半径为3-6的圆
            let x = Math.random() * window.innerWidth;
            let y = Math.random() * window.innerHeight;
            let r = 3 + Math.ceil(Math.random() * 3)
            particle.drawCircle(x, y, r);
            particle.endFill();
            particleContaner.addChild(particle);
            // 将粒子信息保存
            particles.push(particle);
    }
+   let hyp = Math.floor(Math.pow((Math.pow(window.innerHeight, 2) + Math.pow(window.innerWidth, 2)), 0.5));
+   let angle = 180 / (Math.PI / Math.atan(window.innerHeight / window.innerWidth));
+   let z = Math.abs(Math.cos(90 - 30 - angle) * hyp);
+   let ext = (z - window.innerHeight) / 2; // 向下和向上扩展的长度
    const move = () => {
        particles.forEach(item => {
+           item.y += 10;
+           if(item.y > z - ext) {
+               item.y = -ext;
+           }
        })
    }
    gsap.ticker.add(move)

demo6.gif

导演:可以的,GSAP,给你加个鸡腿!咱们给这个自行车搞个百公里加速的效果?

GSAP:交给我吧,为了看着舒服,我把粒子减少到20个。

+   let speed = 0;
    const move = () => {
+       speed += .1;
+       speed = Math.min(20, speed);
        let ext = (z - window.innerHeight) / 2;
        particles.forEach(item => {
+           item.y += speed;
            if(item.y > z - ext) {
                item.y = -ext;
            }
        })
    }

demo7.gif

粒子现在越来越快了。但是还是差点意思,你们有没有发现一个现象,就是当速度快到一定程度的时候,只能看到残影。那么如何做到有残影的效果呢?GSAP带你实现:

    let speed = 0;
    const move = () => {
        speed += .1;
        speed = Math.min(20, speed);
        let ext = (z - window.innerHeight) / 2;
        particles.forEach(item => {
            item.y += speed;
+           item.scale.x -= .0003 * speed;
+           item.scale.y += .003 * speed;
+           item.scale.x = Math.max(.01 , item.scale.x);
+           item.scale.y = Math.min(40, item.scale.y);
            if(item.y > z - ext) {
                item.y = -ext;
            }
        })
    }

只要把粒子的宽度随着速度的增加逐渐减小,高度随着速度的增加逐渐增大,就有那种快到变形的感觉了

demo8.gif

最后加上按钮控制,不能刹车的话,这就不算什么好戏了。

定义开始和暂停事件,并在按钮事件中调用:

    const start = () => {
        speed = 0;
        gsap.ticker.add(move)
    }
    const pause = () => {
        gsap.ticker.remove(move)
        particles.forEach(x => {
            x.scale.x = x.scale.y = 1;
            gsap.to(x, { duration: .6, x: x.sx, y: x.sy, ease: 'elastic.out' });
        })
    }
    start();
    btnContainer.on('mousedown', () => {
        gsap.to(lever, { duration: .6, rotation: Math.PI / 180 * -30 });
+       pause();
    })
    btnContainer.on('mouseup', () => {    
        gsap.to(lever, { duration: .6, rotation: 0 });
+       start();
    })

demo9.gif

ok,这回小白可以骑上这个自行车出门了!

杀青

咔!收摊,领盒饭!

公众号里搜 大帅老猿,在他这里可以学到很多东西。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿