用粒子动画来忆起你的春节时光 | 支持表情文字

8,243 阅读8分钟

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

前言

俗话说:过了腊八就是年。腊八节过完,新春已经踩着风火轮追来了!你期待今年的新春吗?

除夕、春节,是一年间最重要的几个节日,也是难得阖家大团圆的美妙之日,回忆起过往的新春佳节,小包就不由思绪万千,有快乐,有开心,有向往,有苦涩,春节的时光短暂却又永恒,深深篆刻在小包的记忆之海。

不知道你记忆中的新春会是什么样子那?接下来首先以几段关键词动画,来领略一下小包的新春回忆吧。

欢迎大佬们在评论区中评论,分享你的新春记忆关键词或关键词动画,我们一起来体悟或者猜测你的新春回忆,点赞+评论最多的掘友小包会送他掘金周边一份

工具

在分享案例玩法和实现之前,先提供几个工具,方便大家定制自己的新春回忆。

关于录屏这里,小包其实本来预想是实现生成动图功能,找了好多方案,实现效果都比较差,后面小包会继续推进这方面的研究,如果有进展会继续更新后续研究。

玩法

体验地址: 定制你的专属新春回忆

玩法非常简单,大致就这几点注意事项:

  • 关键词输入在下划线位置
  • 多个关键词可通过空格或者中英文逗号分隔
  • 支持中英文、表情文字
  • 文字尽量不要太长,五字以内
  • 输入关键词序列后,回车键开启动画

下面先来看看小包的春节回忆吧。

童年

童年时光已经离小包有些遥远了,那个年代物质生活不算丰富,精神生活比较匮乏,但架不住小包当时单纯啊,翻山越岭,爬山跨海,上天入地,给小包留下了无数刻骨铭心的回忆,青山、近邻、小河、伙伴,简单的生活创造无尽的美好。

除夕夜

那时代的也是年味十足,除夕夜小包一家三口,吃着水饺,看着春晚,热情似火,初一零时,月夜小桥,爆竹声声,红包鼓鼓。关键词: 👨‍👩‍👦,🥟,📺,👏,🕛,🌉,🧨,🧧

childEnd.gif

春节

春节的日子就更充实了,一家三口,六点起床,大红灯笼,七点拜年,走家串巷,些许人群,糖果花生,快乐逍遥。演化成关键词:👨‍👩‍👦,🕕,🏮,🕖,🤝,👨‍👨‍👦‍👦,🍬,🥜,🥰

childNew.gif

青春

后续部分的表情关键词就不做解读了,大家可以猜一下,发在评论里面,最贴近的会有掘金周边送出

关键词: 👨‍👨‍👧‍👦,🥟,📺,🕛,广场,🧨,🎇,🙏

adlEnd.gif

疫情

说实话,随着年龄的增长,物质生活和精神生活都迅速发展,社会的浮躁气息愈来愈浓,年味也越来越淡,但疫情那年,会成为小包永恒的记忆,那年年味有可能更淡了,但情味却浓上加浓,没有什么困难可以战胜中华民族。

关键词: 👨‍👨‍👧‍👦,📺,🦠,🛌,🥼,🦸🏻,🏆,致敬

convEnd.gif

代码实现

粒子效果其实小包已经实现过多次,所以简单部分小包就不做详细讲解,核心讲解动画切换部分。

创建粒子类

粒子主要包含粒子随机位置、粒子目标位置、粒子颜色和粒子半径。本项目中粒子半径统一设置为2。

class Particle {
  constructor({ x = 0, y = 0, tx = 0, ty = 0, radius = 2, color = "#F00000" }) {
    // 当前坐标为随机生成坐标
    this.x = particle.x;
    this.y = y;
    // 目标点坐标(副画布原有粒子坐标)
    this.tx = tx;
    this.ty = ty;
    this.radius = 2;
    this.color = color;
  }
 // 粒子绘制   
  draw(ctx) {
    ctx.save();
    ctx.translate(this.x, this.y);
    ctx.fillStyle = this.color;
    ctx.beginPath();
    // 绘制圆形
    ctx.arc(0, 0, this.radius, 0, Math.PI * 2, true);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
    return this;
  }
}

提取像素信息

创建副画布及绘制文字

将要生成粒子的文字渲染到副画布上,副画布是虚拟画布,只做提取文字像素的载体使用,不会渲染到页面中。

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const viewWidth = window.innerWidth * 0.5;
const viewHeight = window.innerHeight * 0.5;

canvas.width = viewWidth;
canvas.height = viewHeight;
// 预留处理图片的接口
if (typeof target === "string") {
    // 绘制文字
    // 保证长文字的在PC端及移动端的完整展示
    // 处理的有几分粗糙
    ctx.font = `${
          target.length < 3 ? textWidth : (textWidth * 3) / (1 + target.length)
        }px bold`;
    // 设置文字的基本样式
    ctx.fillStyle = colorList[rand(0, colorList.length)];
    ctx.textBaseline = "middle";
    ctx.textAlign = "center";
    ctx.fillText(target, viewWidth / 2, viewHeight / 2);
}

获取像素点数据

重点介绍一下getImageData方法

  • 语法 ctx.getImageData(sx, sy, sw, sh);

  • 参数

    • sx, sy: 要提取图像的左上角 x y 坐标
    • sw, sh: 要提取图像的宽高
  • 返回值

    返回 ImageData 对象,该对象拷贝了指定图像区域的像素。对于图像中每个像素点,都分别存放 RGBA 四方面的信息,所有的像素数据以一维数组形式存放在 data 属性中(每个像素点由 rgba 四个值组成,因此每次需要乘以 4 才能跳到下一个像素点)

const { data, width, height } = ctx.getImageData(0, 0, viewWidth, viewHeight);

image.png

由上图可见,imageData 中包含四个属性,上面我们使用了 width height datadata 中存放了巨量的像素点数据,以我们这个案例为例子,第一个关键词就包含了 737280 个数字,因此我们需要根据一定的算法来进行像素筛选。

image.png

提取绘制粒子的像素点

这里解释一下 interval 的作用以及为什么可以实现对像素点数量的控制?

getImageData 提取画布的全部像素点,如上图 data 所示,一共提取了 737280 / 4 = 184320 个像素点,如果不经筛选全部绘制成半径为 2 的粒子,一方面粒子会发生重叠;另一方面如果生成 18万 个粒子,为了保证粒子运动的流畅性,使用 requestAnimationFrame 更新粒子位置,如此巨大数量的粒子频繁渲染对电脑的渲染性能要求极高,就比如小包的电脑,就完全无法运行,卡顿感爆棚。

因此我们需要寻找一个办法,即能适配各种各样的显示屏,又能要渲染恰到好处的粒子数,那我们应该怎么处理 getImageData 返回数据那?

大佬们想出一个很好的办法,画布的 width, height 通过 getImageData 同步获得,因此我们可以把画布看作 width * height 个宽高都为 1 的网格构成。我们在这个网格中取数据,interval 为选取间隔,每隔 interval 选取一个像素点, interval 值越大,间隔越大,所以每一行中选取的像素点就越少。(如果 interval 选取太大,选区的像素点就越少,文字有可能会发生缺失,因此我们要选取合适的 interval)

接下来就是如何定位到这个像素点?getImageData 返回的数据是按行来读取像素点,每个像素点使用四个数组位来存放。如果我们以宽高为基准,那么纵坐标 y * 画布宽度 + 横坐标 x得出像素点位置,然后乘以4计算出在 ImageData 数组的索引位置。横坐标 x 和纵坐标 y 即是粒子的目标位置

// 获取目标对象的像素点,interval 控制像素点数量,值越大返回的像素点越少
  const pixeles = [];
  // 遍历像素数据,用interval减少取到的像素数据
  for (let x = 0; x < width; x += interval) {
    for (let y = 0; y < height; y += interval) {
      const pos = (y * width + x) * 4; 
      // 只提取 rgba 中透明度大于0.5的像素,即aplha > 128
      if (data[pos + 3] > 128) {
        pixeles.push({
          x,
          y,
          rgba: [data[pos], data[pos + 1], data[pos + 2], data[pos + 3]],
        });
      }
    }
  }
  return pixeles;
}

绘制粒子

根据合适 interval 选取的像素点对应创建粒子,粒子的目标位置和颜色由像素点提供,当前位置通过随机数生成。

function createParticles({ text, radius, interval }) {
  const pixeles = getWordPxInfo(text, interval);
  return pixeles.map((particle) => {
    return new Particle({
      x: Math.random() * (50 + window.innerWidth * 0.5) - 50,
      y: Math.random() * (50 + window.innerHeight * 0.5) - 50,
      tx: particle.x,
      ty: particle.y,
      radius: particle.raduis,
      color: particle.rgba,
    });
  });
}

生成的粒子如下图分布:

particlesFirst.png

文字切换

每次更新粒子位置后需要去判断是否所有的粒子到达目标,如果所有粒子都完成运动,则进入下一个文字动画。因此文字切换的难点在于如何检测所有的粒子是否完成运动?

每个粒子上有 finished 属性,默认值为 false。规定粒子当前位置距离目标位置小于 0.1 代表当前粒子运动结束,当前粒子运动结束后修改其 finished 值为 true;如果大于 0.1 ,粒子继续运动。(粒子运动采取的缓动系数为0.09)

由于每个粒子上都具备 finished 属性,我们可以通过 filter 过滤出所有 finished 属性为 true 的粒子,如果过滤出粒子数量等于总粒子数量,当前文字动画结束,切换下一个文字。

// 参数分别是粒子数组及下一个文字动画回调
function drawFrame(particles, finished) {
  // 开启粒子渲染动画
  const timer = window.requestAnimationFrame(() => {
    drawFrame(particles, finished);
  });
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // 缓动系数设置为0.09
  const easing = 0.09;
  const finishedParticles = particles.filter((particle) => {
    // 当前坐标和目标点之间的距离
    const dx = particle.tx - particle.x;
    const dy = particle.ty - particle.y;
    // 粒子移动速度
    let vx = dx * easing;
    let vy = dy * easing;

    // 判断当前粒子是否完成动画
    if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) {
      // 完成动画位置不会在改变,并修改 finished 为 true  
      particle.finished = true;
      particle.x = particle.tx;
      particle.y = particle.ty;
    } else {
      // 未完成动画继续更新粒子位置   
      particle.x += vx;
      particle.y += vy;
    }
    // 绘制粒子新位置   
    particle.draw(ctx);
    // 判断返回完成运动的的粒子数
    return particle.finished;
  });

  if (finishedParticles.length === particles.length) {
    // 全部粒子运动结束,结束当前动画
    window.cancelAnimationFrame(timer);
    // 开启下一轮文字回调
    finished && finished();
  }
  return particles;
}

代码实现到这里,就可以实现当前文字的动画效果及动画效果结束判断,我们只需要在当前动画效果结束后,执行 finished 回调即可实现切换。

动画切换函数 loop

loop 函数设计为了给 drawFrame 提供下一个文字的渲染。代码逻辑比较简单,不在多言。

function loop(words, i = 0) {
  return drawFrame(
    // 生成粒子
    createParticles({ text: words[i], radius: 2, interval: 5 }),
    // finish函数部分=》下一个文字
    () => {
      i++;
      // hack一下空文字
      if (i < words.length && words[i].length > 0) {
        loop(words, i);
      }
    }
  );
}

虎年送大礼

说句掏心窝子的话,自从十月份在掘金开始写文,刚迈入 2022 年,小包就成功登临 LV4 ,小包对自己的认知还是特别清晰的,小包需要快速进步才能配称得上优秀作者。但真的十分感谢大佬们的支持,在掘金这边相识了很多新的朋友,希望朋友的友谊能地久天长。另外还要感谢掘金社区的可爱运营们,负责,接地气,让小包这个大龄写手重新拥有热情,重拾初心,希望新的一年可以和掘金一起成长,一起进步。

这半年来,零零散散薅了社区不少羊毛,有些送给了朋友,送完朋友还剩一些,就送给掘金的最熟悉的陌生人吧,希望未来的日子能越来越熟悉。

  1. 小包预留俩组关键词,猜测最贴切的掘友,小包会送上掘金周边一份
  2. 分享你的新春回忆(录制动图或者关键词都可),收获到点赞和评论之后最多的掘友,小包同样也会送上掘金周边一份
  3. 别的奖项暂时还没想出,后面如果想出在动态添加上

源码仓库

源码地址: 定制你的专属新春回忆

体验地址:定制你的专属新春回忆

如果感觉有帮助的话,别忘了给小包点个 ⭐ 。

近期精彩

JavaScript进阶系列

面试部分

快乐编程

后语

伙伴们,如果大家感觉本文对你有一些帮助,给阿包点一个赞👍或者关注➕都是对我最大的支持。

另外如果本文章有问题,或者对文章其中一部分不理解,都可以评论区回复我,我们来一起讨论,共同学习,一起进步!

疫情早日结束 人间恢复太平