春节用JS在系统里放烟花

1,061 阅读4分钟

PK创意闹新春,我正在参加「春节创意投稿大赛」,详情请看:春节创意投稿大赛

前言

没有烟花的春节是不完整的,产品经理还想在系统里放烟花。。。

还好有牛逼的canvas可以用。

效果

先来看实现效果吧。

1111.gif

主要API

实现过程中主要使用到以下API

canvas相关

  • fillRect 在画布上绘制填充,用于实现夜幕。
  • createRadialGradient 实现放射状/圆形渐变,用于实现烟花爆炸效果。
  • addColorStop 规定 gradient 对象中的颜色和位置。
  • arc 创建弧/曲线(用于创建圆或部分圆)用于实现烟花掉落轨迹。
  • globalCompositeOperation globalCompositeOperation 属性设置或返回如何将一个源(新的)图像绘制到目标(已有)的图像上。 源图像 = 您打算放置到画布上的绘图。 目标图像 = 您已经放置在画布上的绘图。

Math相关

  • random 制造随机数,实现随机性。
  • cos 获得一个数值的余弦值。
  • sin 获得一个数值的正弦值 使用余弦模拟三维效果,并在中间放置更多的粒子。
  • sqrt 返回一个数的平方根,用于计算距离。

定时器

setInterval/clearInterval

  • setInterval 重复调用函数或执行代码片段,实现不间断的烟花燃放效果。
  • clearInterval 清除定时器,烟花毕竟是明火,消防安全人人有责,放烟花也要有始有终。开玩笑,主要还是避免浏览器崩溃。

使用

看下如何使用 下面都是在React项目中的用法。

  • 组件中引入
import fireworks from './fireworks';
  • 使用
const fireworksBox = useRef();
useEffect(() => {
  // 初始化
  fireworks.init({
    // 是否开启音效
    sound: false, 
    // 透明度
    opacity: 1,
    // 宽度
    width: '100%',
    // 高度
    height: '100%',
    // 渲染烟花的盒子
    box: fireworksBox.current,
  });
  return () => {
    // 销毁
    fireworks.destroy();
  };
}, []);

使用包含初始化和销毁,因为烟花特效使用了定时器,所以建议在组件销毁时调用销毁烟花方法清除定时器。

配置项

  • 烟花爆炸音效 支持开启烟花爆炸音效,声音效果可以替换,需要自行准备声音资源,转换成data:audio/wav;base64后使用,或者不开启音效,自行在系统中增加音效。

  • 透明度 支持透明度设置,方便与其他模块融合。不建议降低烟花透明度,因为透明后烟花颜色会变淡,我们使用时是在烟花上层增加的其他模块透明,效果还不错。

  • 宽度和高度 支持自定义宽度和高度,可以选择全屏放烟花,或者弹窗放烟花。

  • 盒子容器 烟花渲染需要的盒子容器。

  • 盒子容器的背景色 bgColor

  • Z轴层级 zIndex

  • globalCompositeOperation globalCompositeOperation 属性设置或返回如何将一个源(新的)图像绘制到目标(已有)的图像上。

源图像 = 您打算放置到画布上的绘图。

目标图像 = 您已经放置在画布上的绘图。

options.globalCompositeOperation = options.globalCompositeOperation || 'lighter';

  • 烟花速度参数

    烟花的两个速度参数均为毫秒值。

    • launchTime 底部烟花放出时间间隔,越小,烟花越多
    • loopTime 烟花从底部升空的速度,越小,烟花升空越快
  • 内部配置项 除了前面的外部配置项,内部还有一些配置项,可以通过修改源代码进行修改。

MAX_PARTICLES最大粒子数。

春节还搞了个灯笼,参见# 春节用Div给系统挂上大红灯笼

烟花方法代码如下

let launchInterval = null;
let loopInterval = null;
const init = (opt) => {
  const options = opt || {};
  options.box = options.box || document.body;
  options.bgColor = 'rgba(28, 32, 51, 0.2)' || 'rgba(0, 0, 0, 0.05)';
  options.zIndex = options.zIndex || 1;
  options.globalCompositeOperation = options.globalCompositeOperation || 'lighter';
  options.sound = options.sound || false;
  options.opacity = options.opacity || 1;
  options.width = options.width || '100%';
  options.height = options.height || '100%';
  options.launchTime = options.launchTime || 1000;
  options.loopTime = options.loopTime || 15;
  const fireworksField = options.box;
  let particles = [];
  let rockets = [];
  const MAX_PARTICLES = 400;
  let SCREEN_WIDTH = options.width;
  let SCREEN_HEIGHT = options.height;
  const sounds = [];
  let audio;
  if (options.sound) {
    sounds.push({
      prefix: 'data:audio/wav;base64,',
      data:'爆炸音效转换后编码,太长了不放了',
    });
    audio = document.createElement('audio');
  }
  const canvas = document.createElement('canvas');
  canvas.className = 'fireworksField';
  canvas.width = SCREEN_WIDTH;
  canvas.height = SCREEN_HEIGHT;
  canvas.style.width = SCREEN_WIDTH;
  canvas.style.height = SCREEN_HEIGHT;
  canvas.style.position = 'absolute';
  canvas.style.top = '0px';
  canvas.style.left = '0px';
  canvas.style.zIndex = options.zIndex;
  canvas.style.opacity = options.opacity;
  const context = canvas.getContext('2d');
  function Particle(pos) {
    this.pos = {
      x: pos ? pos.x : 0,
      y: pos ? pos.y : 0,
    };
    this.vel = {
      x: 0,
      y: 0,
    };
    this.shrink = 0.97;
    this.size = 2;

    this.resistance = 1;
    this.gravity = 0;

    this.flick = false;

    this.alpha = 1;
    this.fade = 0;
    this.color = 0;
  }
  // eslint-disable-next-line func-names
  Particle.prototype.update = function () {
    this.vel.x *= this.resistance;
    this.vel.y *= this.resistance;
    this.vel.y += this.gravity;
    this.pos.x += this.vel.x;
    this.pos.y += this.vel.y;
    this.size *= this.shrink;
    this.alpha -= this.fade;
  };
  // eslint-disable-next-line func-names
  Particle.prototype.render = function (c) {
    if (!this.exists()) {
      return;
    }
    c.save();
    // eslint-disable-next-line no-param-reassign
    c.globalCompositeOperation = options.globalCompositeOperation;
    const { x } = this.pos;
    const { y } = this.pos;
    const r = this.size / 2;
    const gradient = c.createRadialGradient(x, y, 0.1, x, y, r);
    gradient.addColorStop(0.1, `rgba(255,255,255,${this.alpha})`);
    gradient.addColorStop(0.8, `hsla(${this.color}, 100%, 50%, ${this.alpha})`);
    gradient.addColorStop(1, `hsla(${this.color}, 100%, 50%, 0.1)`);
    // eslint-disable-next-line no-param-reassign
    c.fillStyle = gradient;
    c.beginPath();
    c.arc(
      this.pos.x,
      this.pos.y,
      this.flick ? Math.random() * this.size : this.size,
      0,
      Math.PI * 2,
      true
    );
    c.closePath();
    c.fill();
    c.restore();
  };
  // eslint-disable-next-line func-names
  Particle.prototype.exists = function () {
    return this.alpha >= 0.1 && this.size >= 1;
  };
  function Rocket(x) {
    Particle.apply(this, [
      {
        x,
        y: SCREEN_HEIGHT,
      },
    ]);

    this.explosionColor = 0;
  }
  Rocket.prototype = new Particle();
  Rocket.prototype.constructor = Rocket;
  // eslint-disable-next-line func-names
  Rocket.prototype.explode = function () {
    if (options.sound) {
      const randomNumber = ((min, max) => {
        const newMin = Math.ceil(min);
        const newMax = Math.floor(max);
        return Math.floor(Math.random() * (newMax - newMin + 1)) + newMin;
      })(0, 2);
      audio.src = sounds[randomNumber].prefix + sounds[randomNumber].data;
      audio.play();
    }
    const count = Math.random() * 10 + 80;
    for (let i = 0; i < count; i += 1) {
      const particle = new Particle(this.pos);
      const angle = Math.random() * Math.PI * 2;
      const speed = Math.cos((Math.random() * Math.PI) / 2) * 15;
      particle.vel.x = Math.cos(angle) * speed;
      particle.vel.y = Math.sin(angle) * speed;
      particle.size = 10;
      particle.gravity = 0.2;
      particle.resistance = 0.92;
      particle.shrink = Math.random() * 0.05 + 0.93;
      particle.flick = true;
      particle.color = this.explosionColor;
      particles.push(particle);
    }
  };
  // eslint-disable-next-line func-names
  Rocket.prototype.render = function (c) {
    if (!this.exists()) {
      return;
    }
    c.save();
    // eslint-disable-next-line no-param-reassign
    c.globalCompositeOperation = options.globalCompositeOperation;
    const { x } = this.pos;
    const { y } = this.pos;
    const r = this.size / 2;
    const gradient = c.createRadialGradient(x, y, 0.1, x, y, r);
    gradient.addColorStop(0.1, `rgba(255, 255, 255 ,${this.alpha})`);
    gradient.addColorStop(1, `rgba(28, 32, 51, ${this.alpha})`);
    gradient.addColorStop(1, options.bgColor);
    // eslint-disable-next-line no-param-reassign
    c.fillStyle = gradient;
    c.beginPath();
    c.arc(
      this.pos.x,
      this.pos.y,
      this.flick ? (Math.random() * this.size) / 2 + this.size / 2 : this.size,
      0,
      Math.PI * 2,
      true
    );
    c.closePath();
    c.fill();
    c.restore();
  };
  const loop = () => {
    if (SCREEN_WIDTH !== window.innerWidth) {
      SCREEN_WIDTH = window.innerWidth;
      canvas.width = window.innerWidth;
    }
    if (SCREEN_HEIGHT !== window.innerHeight) {
      SCREEN_HEIGHT = window.innerHeight;
      canvas.height = window.innerHeight;
    }
    context.fillStyle = options.bgColor;
    context.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
    const existingRockets = [];
    for (let i = 0; i < rockets.length; i += 1) {
      rockets[i].update();
      rockets[i].render(context);
      const a = SCREEN_WIDTH - rockets[i].pos.x;
      const a2 = a * a;
      const b = SCREEN_HEIGHT - rockets[i].pos.y;
      const b2 = b * b;
      const distance = Math.sqrt(a2, b2);
      const randomChance =
        rockets[i].pos.y < (SCREEN_HEIGHT * 2) / 3 ? Math.random() * 100 <= 1 : false;
      if (
        rockets[i].pos.y < SCREEN_HEIGHT / 5 ||
        rockets[i].vel.y >= 0 ||
        distance < 50 ||
        randomChance
      ) {
        rockets[i].explode();
      } else {
        existingRockets.push(rockets[i]);
      }
    }
    rockets = existingRockets;
    const existingParticles = [];
    for (let i = 0; i < particles.length; i += 1) {
      particles[i].update();
      if (particles[i].exists()) {
        particles[i].render(context);
        existingParticles.push(particles[i]);
      }
    }
    particles = existingParticles;
    while (particles.length > MAX_PARTICLES) {
      particles.shift();
    }
  };

  const launchFrom = (x) => {
    if (rockets.length < 10) {
      const rocket = new Rocket(x);
      rocket.explosionColor = Math.floor((Math.random() * 360) / 10) * 10;
      rocket.vel.y = Math.random() * -3 - 4;
      rocket.vel.x = Math.random() * 6 - 3;
      rocket.size = 8;
      rocket.shrink = 0.999;
      rocket.gravity = 0.01;
      rockets.push(rocket);
    }
  };

  const launch = () => {
    launchFrom(SCREEN_WIDTH / 2);
  };

  fireworksField.append(canvas);
  launchInterval = setInterval(launch, options.launchTime);
  loopInterval = setInterval(loop, options.loopTime);
  return fireworksField;
};

const destroy = () => {
  clearInterval(launchInterval);
  clearInterval(loopInterval);
  launchInterval = null;
  loopInterval = null;
  const elems = document.querySelectorAll('.fireworksField');
  elems.forEach((elem) => elem.parentNode.removeChild(elem));
};

export default {
  init,
  destroy,
};