【canvas】实现多种形状的烟花

匆匆过客 提交于 2020-01-12 17:30:31

canvas做烟花效果,已经烂大街了。可搬别人的代码,不如自己动手更有意思。

虽然自己做的效果并不是很出众,但完成这一特效整个过程,也算是一种收获。
在这里插入图片描述

在做烟花特效之前,我还做了
【canvas】网易云音乐鲸云特效『水晶音波』的简单实现
【canvas】网易云音乐鲸云动效『孤独星球』的简单实现
相比之下,烟花特效要复杂一点。




爆炸

刚开始,从简单的来,先做爆炸的单个碎片。
同样,在这过程中,我会先把重复用到的常量定义好,减少重复计算。

const PI2 = Math.PI * 2;
const PI7_8 = Math.PI * 7 / 8;
const PI15_32 = Math.PI * 15 / 32;
const PI_16 = Math.PI / 16;

先定义“碎片”
为了构造一个碎片。需要知道

参数 含义
size 碎片的大小
lightness 碎片的亮度
color 碎片的颜色
angle 碎片移动的方向(角度)
speed 碎片移动的速度
shape 碎片所"遵循"的形状,用于修正碎片的速度
frame 帧数

帧数,没错。你没有看错,这里我用了帧数,目的是为了告诉一个碎片最多只能渲染多少次(帧)。可以理解为碎片Shard的生存周期。

同时,烟花爆炸的位置,烟花一爆炸,碎片Shard开始执行render()函数绘制。所以我另外写了一个函数bomb(x, y)。以下是全部代码。

class Shard {
  constructor(context, size, lightness, color, angle, speed, shape, frame) {
    this.ctx = context;
    this.maxSize = size;
    this.maxLightness = lightness;
    this.color = color;
    this.angle = angle;
    this.shape = shape;
    this.speed = speed;
    this.frame = frame;
    this.speedX = speed * shape(angle) * Math.cos(angle); // 极坐标转直角坐标的公式 x = r * cosθ
    this.speedY = speed * shape(angle) * Math.sin(angle); // 极坐标转直角坐标的公式 y = r * sinθ
  }

  __linear(src, dst, coeff) {
    return src + (dst - src) * coeff;
  }

  __update() {
    this.index++;
    if (this.index >= this.frame) {
      this.feedback && this.feedback('finish');
    } else {
      const coeff = this.index / this.frame;
      const param1 = coeff * coeff;
      const param2 = Math.sin(this.__linear(0, PI7_8, coeff));
      this.x += this.__linear(this.speedX, 0, param1);
      this.y += this.__linear(this.speedY, 0, param1);
      this.lightness = Math.floor(this.__linear(0, this.maxLightness, param2));
      this.size = Math.floor(this.__linear(0, this.maxSize, param2));
    }
  }

  bomb(x, y) {
    this.feedback && this.feedback('exploding');
    this.x = x;
    this.y = y;
    this.lightness = 0;
    this.size = 0;
    this.index = 0;
  }

  render() {
    this.ctx.fillStyle = `hsl(${this.color}, 100%, ${this.lightness}%)`;
    this.ctx.beginPath();
    this.ctx.arc(this.x, this.y, this.size, 0, PI2);
    this.ctx.fill();
    this.__update();
  }
}

代码解读

为了让烟花爆炸看起来不那么单调,碎片亮度、大小,移动速度,理应做成非线性的
所以首先,我先是撸了一个线性函数__linear(src, dst, coeff)。中学数学,不难理解。

__linear(src, dst, coeff) {
  return src + (dst - src) * coeff;
}

然后,让碎片的移动由快到慢,最后为零,当然,是非线性的,所以用初中就学过的二元一次方程y = x^2
让大小和亮度由0变大,然后变小,所以用三角函数,大概是这样的一个过程。
在这里插入图片描述

      const coeff = this.index / this.frame;
      const param1 = coeff * coeff;
      const param2 = Math.sin(this.__linear(0, PI7_8, coeff));
      this.x += this.__linear(this.speedX, 0, param1);
      this.y += this.__linear(this.speedY, 0, param1);
      this.lightness = Math.floor(this.__linear(0, this.maxLightness, param2));
      this.size = Math.floor(this.__linear(0, this.maxSize, param2));


升空

升空就简单多了。

class Rocket {
  constructor(context, range, altitude, shardNum) {
    this.ctx = context;
    this.range = range;
    this.altitude = altitude;
    this.shardNum = shardNum;
    this.restart();

    this.shards = [];
    const maxSize = 2.5;
    const maxLightness = 40;
    const speed = 2;
    const { func, scope } = shapeSet.random();
    const frame = 50;
    for (let i = 0; i < this.shardNum; ++i) {
      const angle = scope[0] + scope[1] * i / this.shardNum;
      this.shards.push(new Shard(this.ctx, maxSize, maxLightness, this.color, angle, speed, func, frame));
    }
    this.shards[this.shards.length - 1].feedback = this.__guard.bind(this);
  }


  restart() {
    const speed = 10 + Math.random() * 6;
    const angle = PI15_32 + Math.random() * PI_16;
    this.xSpeed = Math.cos(angle) * speed;
    this.ySpeed = -Math.sin(angle) * speed;
    this.color = Math.floor(Math.random() * 360);
    this.size = 2;
    this.x = this.range[0] + Math.floor(Math.random() * (this.range[1] - this.range[0]));
    this.y = this.altitude;
    this.state = 'waiting';
  }

  __guard(state) {
    this.state = state;
  }

  __update() {
    if (this.ySpeed < 0) {
      this.x += this.xSpeed;
      this.y += this.ySpeed;
      this.ySpeed += 0.15;
      this.size -= 0.01;
    }
    else if (this.state == 'finish')
      this.restart();
    else if (this.state == 'waiting')
      this.shards.forEach(shard => shard.bomb(this.x, this.y));
  }

  render = () => {
    if (this.state == 'exploding') {
      this.shards.forEach(Shard => Shard.render());
    }
    else {
      this.ctx.fillStyle = `hsl(${this.color}, 100%, 40%)`;
      this.ctx.beginPath();
      this.ctx.arc(this.x, this.y, this.size, 0, PI2);
      this.ctx.fill();
    }
    this.__update();
  }
}

代码解读

升空的Rocket类中,我定义了一个状态state,有三种情况

state 含义
exploding 爆炸中,此时Rocketrender()函数只绘制碎片Shard
waiting 升空中,此时Rocketrender()函数只绘制自身`
finish 爆炸完了,执行restart()从头开始。

值得注意的是,一个火箭会生成多个碎片,但我们只需要关注其中一个碎片,这个碎片反馈是否完成爆炸即可。
this.shards[this.shards.length - 1].feedback = this.__guard.bind(this);
Shard中也可以看到
if (this.index >= this.frame) { this.feedback && this.feedback('finish'); }



烟花爆炸形状

在上述Rocket中,可以看到一段const { func, scope } = shapeSet.random();,这是我把所有形状的函数放到了shapeSet类中管理,不复杂。

class Shape {
  constructor() { this.Set = []; }
  push(func) { this.Set.push(func); }
  random() { return this.Set[Math.floor(Math.random() * this.Set.length)]; }
};

然后,延续我之前的风格,定义个“场景”,然后跑场景就行。

class Scene {
  constructor(canvas) {
    this.cvs = canvas;
    this.ctx = this.cvs.getContext('2d');
    const range = [this.cvs.width / 2, this.cvs.width / 2];
    const altitude = this.cvs.height;
    const rocketNum = 1;
    const shardNum = 50;
    this.firework = [];
    for (let i = 0; i < rocketNum; ++i) {
      this.firework.push(new Rocket(this.ctx, range, altitude, shardNum));
    }
  }

  render() {
    this.ctx.fillStyle = "rgba(0, 0, 0, .4)";
    this.ctx.fillRect(0, 0, this.cvs.width, this.cvs.height);
    this.firework.forEach(rocket => rocket.render());
  }

  run() {
    if (this.ctx) {
      this.timer = setInterval(this.render.bind(this), 10);
    }
  }

  stop() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = 0;
    }
  }
};

接下来,我就只需解决形状就行了。

简单形状

  1. 圆形
    ρ= ρ = 常量
function circle(θ) {
  return 1; // 半径为1
}

在这里插入图片描述

  1. 心形曲线
    ρ=a×(1+sinθ) ρ = a \times (1+sinθ)
function cardioid(θ) {
  return 1 + Math.sin(θ);
}

在这里插入图片描述

  1. 玫瑰曲线
    ρ=a×sin(nθ) ρ = a \times sin(nθ)
    或者
    ρ=a×cos(nθ) ρ = a \times cos(nθ)
const rose = [];
function roseCurve(θ, n = 3) {
  rose[n] || (rose[n] = {});
  rose[n][θ] || (rose[n][θ] = Math.sin(n * θ));
  return rose[n][θ];
};

在这里插入图片描述

玫瑰曲线有个重要特性:闭合曲线
这里就不全部论述,仅说用到的。

  • n为整数时,
  1. n为奇数,玫瑰线的叶子数为n闭合周期为 π
  2. n为偶数,玫瑰线的叶子数为2n闭合周期为 2π
  • n不是整数,而是n = L/W的有理数时,
  1. LW中有一个为偶数时,玫瑰线的叶子数为2L,闭合周期为2Wπ

比如:六叶玫瑰线
ρ=a×sin(32θ) ρ = a \times sin\left( \frac{3}{2} {θ} \right)
闭合曲线为
在这里插入图片描述

  1. 伯努利双扭线
    ρ2=a2×cos(2θ) ρ^{2} = a^{2} \times cos(2θ)
const lemn = {};
function lemniscate(θ) {
  lemn[θ] || (lemn[θ] = Math.sqrt(Math.cos(2 * θ)));
  return lemn[θ];
}

在这里插入图片描述

复杂形状

  1. 蝴蝶曲线
    ρ=ecosθ2×cos(4θ)+sin5θ12 ρ = e^{cosθ}-2 \times cos{(4θ)}+sin^5\frac{θ}{12}
const butterfly = {};
function butterflyCurve(θ) {
  butterfly[θ] || (butterfly[θ] = Math.exp(Math.cos(θ)) - 2 * Math.cos(4 * θ) + Math.pow(Math.sin(θ / 12), 5));
  return butterfly[θ];
}

在这里插入图片描述

其他形状

  1. 六叶玫瑰线的闭合曲线为 ,但如果我们只绘制[0, π/2],就是开头的6
    在这里插入图片描述

完成,下面是最终效果。
在线演示:codepen
在这里插入图片描述

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!