4000字带你实现一个有趣刹车动效 | 猿创营

3,401 阅读5分钟

前言

非常感谢你能够看到这篇文章,事情是这样的,前一阵子我认识了大帅老师,并且他在现在在带领学员一起写一些项目,由于项目中使用到了相关的技术,那么他就出了这样一个教程,我现在学会了然后再分享给你们。

“授人以鱼不如授人以渔”,本篇文章不是只贴代码那么简单,我将交给大家一些使用到的api和代码里面的核心思想给大家。

在线预览 源码链接

技术介绍

这个动画采用的技术是 PixiJSGSAP 这两个库。

PIXIJS : 一个基于 WebGL 2D 的一个渲染引擎,它能够使用一些很方便的 api 实现一些游戏和一些特效的开发。

GSAP : 一个制作 JavaScript 的动画库,相信我真的很好用。

PIXI入门

初始化

首先你需要使用 Application 这个类来创建一个应用实例

 const app = new PIXI.Application({
    width: window.innerWidth, // 宽度
    height: window.innerHeight, // 高度
    resizeTo: window, // 当 window 触发 resize 事件的时候将会重新计算宽高
    backgroundColor: 0xffffff, // 背景颜色
});

当你运行了这些代码你会发现页面什么都没有那就对了,因为你只是创建了实例但是还没有挂载到页面上

document.body.appendChild(app.view);

我们可以通过 app 实例的 view 属性拿到他的 canvas 节点,然后添加到 body 中

此时你可以发现整个页面被你设置的画布颜色填充满了,那么就有效果了

导入图片的两种方式

  1. 直接导入
const sprite = PIXI.Sprite.from('assets/images/sample.png');
app.stage.addChild(sprite);

我们导入一张图片,这种导入方式有弊端的,我们设想一个场景中如果需要导入大量的图片,返回的时间又是不同的,这对用户体验不好,那有没有一种方式等我们图片全部加载好之后页面在进行渲染,在图片还没有加载好的时候我们可以加一些 loding 动画或是一些小游戏来优化用户体验 🤔

答案是有的,那就是我们接下来要介绍的 loader 的概念,loader 是一个加载器,它能够帮你加载你需要的资源,当加载完成后会触发一个回调来通知我们,来咱们看看怎么使用,上代码!!!

  1. loader 加载器
// 创建一个加载器
const loader = new Loader()
// 添加任务
loader.add(key,url)
loader.add(key,url)
loader.add(key,url)
loader.add(key,url)

//加载资源
loader.load(()=>{
    //当资源全部加载完毕后,我们可以开始干我们的事了
    const sprite = PIXI.Sprite(loader.resources[key].texture);
    app.stage.addChild(sprite);
})

使用 loader 这种方式可以保证我们的资源加载是万无一失的

操作图片

图片的属性

  • sprite.width 宽
  • sprite.width 高
  • sprite.x 横坐标
  • sprite.y 纵坐标
  • sprite.scale.x x轴缩放比例 0~1
  • sprite.scale.y y轴缩放比例 0~1
  • sprite.alpha 透明度

这是一些基本属性,当然其中的大部分属性其他元素有些也有

锚点

这里详细讲一下 anchor 这个属性,字面量意思可以知道它是锚点的意思,那么什么是锚点呢?

默认锚点在左上角

image.png

当我们旋转的时候他是基于这个点来进行旋转的

image.png

如果我们想要基于中心的旋转,那么我们只需要把锚点位置调到中心点即可,他的范围是 0~1

设置的时候类似于我们小学的坐标

image.png

一半一半就是中心点了,上代码 !!!

sprite.anchor.x = sprite.anchor.y = 0.5

旋转

有了锚点接下来就是旋转了

sprite.anchor.rotation = 弧度

这里告诉大家一个弧度公式 ( 角度 * Math.PI ) / 180

Container 技巧

设想一个场景

image.png

image.png

当窗口变小之后我们需要调整两个圆的位置,如果按照之前的方法,我们需要分别对每一个元素调整 X 和 Y ,你可能会说两个不打紧,那要是10个,20个呢?所以就有 Container 的场景了,我们可以把两个元素丢进一个容器中,我们只需要操作容器的 X 和 Y 就可以了

image.png

image.png

完成,上代码 !!!

// 创建容器
const container = new Container();
// 将图片加入到容器中
container.addChild(sprite1);
container.addChild(sprite2);
// 将容器加入到app中
app.stage.addChild(container);

现在你只需要操作容器的坐标就好了

实战演练

image.png

主体内容分为三块:

  • 自行车
  • 按钮
  • 雨滴

初始化

// pixi 实例
  private app: Application
  // 加载器
  private loader: Loader
  // banner 容器
  private breakBannerContainer: Container = new Container()
  // 所有的雨滴
  private raindrops: any[] = []
  //雨滴数量
  private raindropCount: number = 20
  // 车容器
  private bycyleContainer: Container = new Container()
  // 刹车
  private brake_lever: Sprite | null = null
  // 车身
  private brake_bike: Sprite | null = null
  // 按钮容器
  private btnContainer: Container = new Container()
  // 车速
  private speed: number = 0
  // 缩放大小
  private scale: number = 0.6
  //循环方法
  private loop = this._loop.bind(this)

  constructor() {
    this.app = new Application({
      width: window.innerWidth,
      height: window.innerHeight,
      resizeTo: window,
      backgroundColor: 0xffffff,
    })

    this.loader = new Loader()

    this._init()
  }

  private async _init() {
    // 加载所有资源
    await this._loadResources()
    // 初始化车
    this._initBycycle()
    // 初始化雨滴
    this._initRaindrops()
    // 让雨滴动起来
    this._raindropsRun()
    // 初始化按钮
    this._initBtn()
    // 添加事件
    this._addEvents()
    
    this.breakBannerContainer.scale.x = this.breakBannerContainer.scale.y = this.scale
    this.app.stage.addChild(this.breakBannerContainer)

    // resize
    this._resize()
  }

加载资源

通过Promise 封装了一个加载资源的函数

  enum Images {
    BRAKE_BIKE = "brake_bike.png",
    BRAKE_HANDLERBAR = "brake_handlerbar.png",
    BRAKE_LEVER = "brake_lever.png",
    BTN_CIRCLE = "btn_circle.png",
    BTN = "btn.png",
  }
  
  private async _loadResources() {
    const getLoaderArgs = (name: string): [string, string] => [
      name,
      `./images/${name}`,
    ]

    this.loader.add(...getLoaderArgs(Images.BRAKE_BIKE))
    this.loader.add(...getLoaderArgs(Images.BRAKE_HANDLERBAR))
    this.loader.add(...getLoaderArgs(Images.BRAKE_LEVER))
    this.loader.add(...getLoaderArgs(Images.BTN))
    this.loader.add(...getLoaderArgs(Images.BTN_CIRCLE))

    return new Promise((resolve) => this.loader.load(resolve))
  }

自行车的实现

  private _initBycycle() {
    const bycyleContainer = this.bycyleContainer
    // 获取资源
    const brake_bike = this.brake_bike = new Sprite(this.loader.resources["brake_bike.png"].texture)
    const brake_lever = this.brake_lever = new Sprite(this.loader.resources["brake_lever.png"].texture)
    const brake_handlerbar = new Sprite(this.loader.resources["brake_handlerbar.png"].texture)
    // 降低透明度
    brake_bike.alpha = 0.3
    //刹车位置
    brake_lever.y = 950
    brake_lever.x = 800
    // 设置锚点
    brake_lever.anchor.x = 1
    brake_lever.anchor.y = 1
    // 调整角度
    brake_lever.rotation = (-20 * Math.PI) / 180
    // 将资源都添加到容器中
    bycyleContainer.addChild(brake_bike)
    bycyleContainer.addChild(brake_lever)
    bycyleContainer.addChild(brake_handlerbar)
    // 将车容器添加到画布中
    this.breakBannerContainer.addChild(bycyleContainer)
  }

自行车实现还是蛮简单的,获取图片元素然后添加到容器中

生成雨滴

  private _initRaindrops() {
    const raindropContainer = new Container()

    const colors = [0x34495e, 0xffe720]

    for(let i = 0; i < this.raindropCount; i++) {
      const gr = new Graphics()

      const grItem = {
        x: Math.random() * window.innerWidth + this.bycyleContainer!.x,
        y: Math.random() * window.innerHeight + this.bycyleContainer!.y,
        gr,
      }

      gr.x = grItem.x
      gr.y = grItem.y

      gr.beginFill(colors[Math.floor(Math.random() * colors.length)])
      gr.drawCircle(0, 0, 6).endFill()

      raindropContainer.addChild(gr)

      raindropContainer.pivot.x = window.innerWidth / 2 - 1000
      raindropContainer.pivot.y = window.innerHeight / 2
      raindropContainer.rotation = (35 * Math.PI) / 180

      this.raindrops.push(grItem)
    }

    this.app.stage.addChild(raindropContainer)
  }

image.png

生成雨滴之后是这样的,那我们如何实现变成一条线条呢?

咱们作为一个前端工程师可以知道咱们让一个图片的宽定的足够跨宽,高设置的很短他就可以实现一个竖线的形式了,那么这里他是一个圆形我们应该怎么让他变成一条竖线,其实只要设置他的 X 轴和 Y 轴缩放比例就可以了,原理相同

image.png

但是相比于原来的图还是有区别,需求是斜着的并且还是虚线,这里实现比较巧妙,我们将图片加入到一个容器中,然后旋转这个容器就可以了

image.png

你看,我们就实现了,那么这里会有一个疑问,为什么斜着变成虚线,我也不知道答案,但是我大概猜到了

image.png

你看由于我们显示器都是像素为单位的,说白了就是一个一个的小格子,如果是一根竖线他是没有缝隙的,可以看到一条标准的线,但是右边这种将一根线倾斜之后由于像素之间它是有缝隙的所以肉眼看上去就像一条虚线了,这是我的理解,但是我这么想到的时候突然感觉自己太棒了,哈哈哈😃,当然如果你们也有想法欢迎评论咱们一起讨论

那么接下来再让他动起来就好了

  private _raindropsRun() {
    gsap.ticker.add(this.loop)
  }
  private _loop() {
    this.speed += 0.5
    this.speed = Math.min(40, this.speed)

    this.raindrops.forEach((v) => {
      v.gr.scale.y = 20
      v.gr.scale.x = 0.05
      //不断调整纵坐标的位置
      v.gr.y += this.speed
      if (v.gr.y > window.innerHeight) {
        v.gr.y = 0
      }
    })
  }

实现按钮动效

  private _initBtn() {
    const btnContainer = this.btnContainer

    const btn = new Sprite(this.loader.resources[Images.BTN].texture)
    const btnCircle = new Sprite(this.loader.resources[Images.BTN_CIRCLE].texture)

    btnCircle.pivot.x = btnCircle.pivot.y = btnCircle.width / 2
    btnCircle.scale.x = btnCircle.scale.y = 0.8
    
    btn.pivot.x = btn.pivot.y = btn.width / 2
    btnContainer.x = 600
    btnContainer.y = 700

    // 鼠标放在上面的时候,箭头变小手
    btnContainer.buttonMode = true
    btnContainer.interactive = true

    gsap.to(btnCircle.scale, {
      x: 1.1,
      y: 1.1,
      duration: 1,
      repeat: -1,
      ease: "easeInOut",
    })

    btnContainer.addChild(btn)
    btnContainer.addChild(btnCircle)
    this.breakBannerContainer.addChild(btnContainer)
  }

这里有两个点需要注意的 :

  • 两个图片如何居中

先看一下素材

image.png

这里用到了一个新的属性 pivot 支点,类似于我定一个图片的中心点位置,然后图片基于中心点进行确定位置,我们将两个图片都基于自己中心位置进行偏移,那么他们就对齐了

image.png

  • GSAP 怎么实现动画效果的

动画我们就是用了 GSAP 的 to 方法

    gsap.to(btnCircle.scale, {
      x: 1.1,
      y: 1.1,
      duration: 1,
      repeat: -1,
      ease: "easeInOut",
    })

我们将外圈从 0.8 倍在 1s 内缩放到1倍然后重复这个过程就实现了

接下来给按钮添加事件

  private _addEvents() {
    // 按下按钮时
    this.btnContainer.on("pointerdown", () => {
      // 降低车身透明度
      gsap.to(this.brake_bike, { alpha: 1, duration: 0.3 })

      // 改变刹车角度,形成刹车动画
      gsap.to(this.brake_lever, {
        rotation: (-35 * Math.PI) / 180,
        duration: 0.1,
        ease: "easeInOut",
      })
      // 将车容器偏移
      gsap.to(this.bycyleContainer, {
        y: this.bycyleContainer.height * 0.1,
        duration: 0.1,
        ease: "easeInOut",
      })

      // 隐藏按钮
      gsap.to(this.btnContainer, { alpha: 0, duration: 0.5 })

      // 停止粒子动画
      this._raindropsPause()
    })

自动调整位置

整个效果我们是一直都在右边的,所以每当 window 触发 resize 的时候我们都需要重新设置他的 X 和 Y

  // 窗口大小发生变化时
  private _resize() {
    // 让车一直处于画面右下角
    const setBycyclePosition = () => {
      this.breakBannerContainer.x = window.innerWidth - this.breakBannerContainer.width
      this.breakBannerContainer.y = window.innerHeight - this.breakBannerContainer.height
    }
    
    setBycyclePosition()
    window.addEventListener("resize", setBycyclePosition)
  }

这里也是用到了我们前面讲到的,将所有的元素都加入到一个容器中,最后你只需要调整最外层容器的位置就好了,这个技巧真的很实用,不然我们还得一次操作那么多的元素,就很棒 👍

项目源码

import gsap from "gsap"
import { Application, Container, Graphics, Loader, Sprite } from "pixi.js"

  enum Images {
    BRAKE_BIKE = "brake_bike.png",
    BRAKE_HANDLERBAR = "brake_handlerbar.png",
    BRAKE_LEVER = "brake_lever.png",
    BTN_CIRCLE = "btn_circle.png",
    BTN = "btn.png",
  }

export class BrakeBanner {
  // pixi 实例
  private app: Application
  // 加载器
  private loader: Loader
  // banner 容器
  private breakBannerContainer: Container = new Container()
  // 所有的雨滴
  private raindrops: any[] = []
  //雨滴数量
  private raindropCount: number = 100
  // 车容器
  private bycyleContainer: Container = new Container()
  // 刹车
  private brake_lever: Sprite | null = null
  // 车身
  private brake_bike: Sprite | null = null
  // 按钮容器
  private btnContainer: Container = new Container()
  // 车速
  private speed: number = 0
  // 缩放大小
  private scale: number = 0.6
  //循环方法
  private loop = this._loop.bind(this)

  constructor() {
    this.app = new Application({
      width: window.innerWidth,
      height: window.innerHeight,
      resizeTo: window,
      backgroundColor: 0xffffff,
    })

    this.loader = new Loader()

    this._init()
  }

  private async _init() {
    // 加载所有资源
    await this._loadResources()
    // 初始化车
    this._initBycycle()
    // 初始化雨滴
    this._initRaindrops()
    // 让雨滴动起来
    this._raindropsRun()
    // 初始化按钮
    this._initBtn()
    // 添加事件
    this._addEvents()
    
    this.breakBannerContainer.scale.x = this.breakBannerContainer.scale.y = this.scale
    this.app.stage.addChild(this.breakBannerContainer)

    // resize
    this._resize()
  }

  private _addEvents() {
    // 按下按钮时
    this.btnContainer.on("pointerdown", () => {
      // 降低车身透明度
      gsap.to(this.brake_bike, { alpha: 1, duration: 0.3 })

      // 改变刹车角度,形成刹车动画
      gsap.to(this.brake_lever, {
        rotation: (-35 * Math.PI) / 180,
        duration: 0.1,
        ease: "easeInOut",
      })
      // 将车容器偏移
      gsap.to(this.bycyleContainer, {
        y: this.bycyleContainer.height * 0.1,
        duration: 0.1,
        ease: "easeInOut",
      })

      // 隐藏按钮
      gsap.to(this.btnContainer, { alpha: 0, duration: 0.5 })

      // 停止粒子动画
      this._raindropsPause()
    })

    // 松开按钮时
    this.btnContainer.on("pointerup", () => {
      // 恢复刹车角度
      gsap.to(this.brake_lever, {
        rotation: (-20 * Math.PI) / 180,
        duration: 0.8,
        ease: "easeInOut",
      })
      // 恢复车身透明度
      gsap.to(this.brake_bike, { alpha: 0.3, duration: 0.2 })

      // 恢复车容器偏移
      gsap.to(this.bycyleContainer, {
        x: 0,
        y: 0,
        duration: 0.8,
        ease: "easeInOut",
      })

      // 显示按钮
      gsap.to(this.btnContainer, { alpha: 1, duration: 1 })

      this._raindropsRun()
    })
  }

  private _loop() {
    this.speed += 0.5
    this.speed = Math.min(40, this.speed)

    this.raindrops.forEach((v) => {
      v.gr.scale.y = 20
      v.gr.scale.x = 0.05

      v.gr.y += this.speed
      if (v.gr.y > window.innerHeight) {
        v.gr.y = 0
      }
    })
  }

  private _raindropsRun() {
    gsap.ticker.add(this.loop)
  }

  private _raindropsPause() {
    gsap.ticker.remove(this.loop)

    this.speed = 0

    this.raindrops.forEach((v) => {
      v.gr.scale.y = 1
      v.gr.scale.x = 1
      gsap.to(v.gr, { x: v.x, y: v.y, duration: 0.3, ease: "elastic" })
    })
  }

  private _initRaindrops() {
    const raindropContainer = new Container()

    const colors = [0x34495e, 0xffe720]

    for(let i = 0; i < this.raindropCount; i++) {
      const gr = new Graphics()

      const grItem = {
        x: Math.random() * window.innerWidth + this.bycyleContainer!.x,
        y: Math.random() * window.innerHeight + this.bycyleContainer!.y,
        gr,
      }

      gr.x = grItem.x
      gr.y = grItem.y

      gr.beginFill(colors[Math.floor(Math.random() * colors.length)])
      gr.drawCircle(0, 0, 6).endFill()

      raindropContainer.addChild(gr)

      raindropContainer.pivot.x = window.innerWidth / 2 - 1000
      raindropContainer.pivot.y = window.innerHeight / 2
      raindropContainer.rotation = (35 * Math.PI) / 180

      this.raindrops.push(grItem)
    }

    this.app.stage.addChild(raindropContainer)
  }

  private _initBtn() {
    const btnContainer = this.btnContainer

    const btn = new Sprite(this.loader.resources[Images.BTN].texture)
    const btnCircle = new Sprite(this.loader.resources[Images.BTN_CIRCLE].texture)

    btnCircle.pivot.x = btnCircle.pivot.y = btnCircle.width / 2
    btnCircle.scale.x = btnCircle.scale.y = 0.8
    
    btn.pivot.x = btn.pivot.y = btn.width / 2
    btnContainer.x = 600
    btnContainer.y = 700

    btnContainer.buttonMode = true
    btnContainer.interactive = true

    gsap.to(btnCircle.scale, {
      x: 1.1,
      y: 1.1,
      duration: 1,
      repeat: -1,
      ease: "easeInOut",
    })

    btnContainer.addChild(btn)
    btnContainer.addChild(btnCircle)
    this.breakBannerContainer.addChild(btnContainer)
  }
 
  private async _loadResources() {
    const getLoaderArgs = (name: string): [string, string] => [
      name,
      `./images/${name}`,
    ]

    this.loader.add(...getLoaderArgs(Images.BRAKE_BIKE))
    this.loader.add(...getLoaderArgs(Images.BRAKE_HANDLERBAR))
    this.loader.add(...getLoaderArgs(Images.BRAKE_LEVER))
    this.loader.add(...getLoaderArgs(Images.BTN))
    this.loader.add(...getLoaderArgs(Images.BTN_CIRCLE))

    return new Promise((resolve) => this.loader.load(resolve))
  }

  private _initBycycle() {
    const bycyleContainer = this.bycyleContainer

    // 获取资源
    const brake_bike = this.brake_bike = new Sprite(this.loader.resources["brake_bike.png"].texture)
    const brake_lever = this.brake_lever = new Sprite(this.loader.resources["brake_lever.png"].texture)
    const brake_handlerbar = new Sprite(this.loader.resources["brake_handlerbar.png"].texture)
    // 降低透明度
    brake_bike.alpha = 0.3
    //刹车位置
    brake_lever.y = 950
    brake_lever.x = 800
    // 设置锚点
    brake_lever.anchor.x = 1
    brake_lever.anchor.y = 1
    // 调整角度
    brake_lever.rotation = (-20 * Math.PI) / 180
    // 将资源都添加到容器中
    bycyleContainer.addChild(brake_bike)
    bycyleContainer.addChild(brake_lever)
    bycyleContainer.addChild(brake_handlerbar)

    // 将车容器添加到画布中
    this.breakBannerContainer.addChild(bycyleContainer)
  }

  // 窗口大小发生变化时
  private _resize() {
    // 让车一直处于画面右下角
    const setBycyclePosition = () => {
      this.breakBannerContainer.x = window.innerWidth - this.breakBannerContainer.width
      this.breakBannerContainer.y = window.innerHeight - this.breakBannerContainer.height
    }
    
    setBycyclePosition()
    window.addEventListener("resize", setBycyclePosition)
  }

  mount(selector: string) {
    document.querySelector(selector)?.appendChild(this.app.view)
  }
}

源码链接

当然你也可以直接去我的仓库下 clone 下来研究研究,欢迎哦🙂

总结

  1. 过去我对canvas或这类技术都是恐惧的,现在我觉得也就那么回事,也蛮有意思的,希望大家也去试试
  2. 在我们实现某个需求的时候可以采用任务拆分的思想,将每个模块拆分出来逐个击破,最后需求就被实现了
  3. 当我们写完一段代码之后可以试着去优化一下,看看有没有更巧妙的方式实现,这也有利于后期的维护

感谢你能够看完这篇文章,我也是总结了很久才写出来的,如果对你有帮助的话麻烦你点个赞👍

这对我真的很重要,同时也是对我文章的一种肯定,促使我能够继续输出内容

最后推荐下大帅老师

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