Canvas动态粒子文字效果

前言

前两天在写了Canvas实现数字雨和放大镜效果Canvas实现苹果充电盒动效后,还想试一下更炫酷的效果,经过挑选感觉动态粒子文字效果不错,马上开始研究。

正文

还是先看看效果,其中有几个要解决的难点,后面有完整代码。

  • 怎么确定文字位置的粒子坐标
  • 怎么让粒子的位置和文字位置的粒子坐标对应
  • 怎么让粒子动起来

事先准备

我们要准备几个方便自己写代码的函数,随机数、随机颜色、和绘制粒子

初始化粒子

首先得有粒子,我们使用随机数和随机颜色生成粒子,并且记录坐标信息。

怎么确定文字位置的粒子坐标

原理:用一种特殊颜色(如红色)在画布的一块区域填充文字,然后使用getImageData方法获取这一块区域每一个单位像素颜色,如果这个单位像素是标记的特殊颜色就记录其坐标。

这里是取5个像素为一个单位,是为了有颗粒感。

怎么让粒子的位置和文字位置的粒子坐标对应

我们现在有所有粒子的位置信息也有文字位置的粒子坐标,那我们怎么对应起来呢。我们可以使用随机数加上map使二者对应。

有了生成的map映射关系,我们就可以确定每一个粒子要到达的位置。遍历所有粒子,存在map映射关系的话我们就使用映射到的坐标,不存在映射关系我们使用随机数生成。同时,确定水平和竖直方向速度。

让粒子动起来

起始坐标和目标坐标有了,速度也有了,那不就剩下使用requestAnimationFrame绘制了吗,再加上边界的判断,最后在所有粒子都到达指定坐标停止动画就行了。

完整代码

tsx 复制代码
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import './Test.scss'

interface DotItem {
  x: number,
  y: number,
  toX: number,
  toY: number,
  speedX: number,
  speedY: number,
  color: string,
  isArrive: boolean,
}
export default function Index() {
  /** 随机文字*/
  const sentenceList = ['Hello World', 'Canvas', '掘金你好', '前端']
  const frameDom = useRef<any>(null);
  /** 粒子总数*/
  const dotTotal = useRef(1200)
  const canvasDom = useRef<any>(null);
  const canvasCtx = useRef<any>(null);
  const [height, setHeight] = useState(0)
  const [width, setWidth] = useState(0)
  /** 粒子信息列表*/
  const allDot = useRef<DotItem[]>([])
  /** 文字粒子信息*/
  const textCoordinateList = useRef<{
    x: number,
    y: number
  }[]>([])
  const moveAnimation = useRef<any>(null)
  /** 文字粒子和粒子全部信息的映射 */
  let map = useRef(new Map());

  /** 生成随机数*/
  const createRandomNum = useCallback((min: number, max: number) => {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }, [])

  /** 生成随机颜色*/
  function getRandomColor() {
    var letters = '0123456789ABCDEF';
    var color = '#';
    for (var i = 0; i < 6; i++) {
      color += letters[Math.floor(Math.random() * 16)];
    }
    return color;
  }

  /** 绘制点*/
  const pointPlot = useCallback((x: number, y: number, color: string) => {
    canvasCtx.current.beginPath()
    canvasCtx.current.strokeStyle = color;
    canvasCtx.current.arc(x, y, 1, 0, 2 * Math.PI);
    canvasCtx.current.stroke();
  }, [])

  /** 是否全部到达 */
  const isAllArrive = useCallback(() => {
    let isTrue = true
    for (let i = 0; i < allDot.current.length; i++) {
      if (!allDot.current[i].isArrive) {
        isTrue = false
      }
    }
    return isTrue
  }, [])

  /** 绘制移动动画*/
  const drawMove = useCallback(() => {
    canvasCtx.current.clearRect(0, 0, width, height)
    for (let i = 0; i < allDot.current.length; i++) {
      let { x: currentX, y: currentY, toX, toY, speedX, speedY } = allDot.current[i]
      let x = 0;
      let y = 0;
      x = currentX + speedX
      y = currentY + speedY

      //边界判断
      if (speedX < 0 && x < toX ||
        speedX > 0 && x > toX
      ) {
        x = toX
        allDot.current[i] = {
          ...allDot.current[i],
          isArrive: true,
        }
      }
      if (speedY < 0 && y < toY ||
        speedY > 0 && y > toY
      ) {
        y = toY;
        allDot.current[i] = {
          ...allDot.current[i],
          isArrive: true,
        }
      }
      
      pointPlot(x, y, allDot.current[i].color)
      allDot.current[i] = {
        ...allDot.current[i],
        x, y,
      }
    }
    moveAnimation.current = requestAnimationFrame(drawMove)

    //全部粒子到达目标位置,停止动画
    if (isAllArrive()) {
      cancelAnimationFrame(moveAnimation.current)
    }
  }, [width, height, isAllArrive])


  /** 设置文字坐标信息*/
  const setLiteralCoordinate = useCallback(() => {
    let index = createRandomNum(0, sentenceList.length - 1);
    let text = sentenceList[index]
    canvasCtx.current.font = "120px Arial"
    canvasCtx.current.fillStyle = "red"
    let textWidth = canvasCtx.current.measureText(text).width;
    canvasCtx.current.fillText(text, width / 2 - textWidth / 2, height / 2)

    let startX = width / 2 - textWidth / 2
    let endX = startX + textWidth;
    let startY = height / 2 - 120;
    let endY = height / 2 + 30;

    //组成记录文字点的信息
    textCoordinateList.current = [];
    for (let i = startX; i <= endX; i += 5) {
      for (let j = startY; j <= endY; j += 5) {
        let imageData = canvasCtx.current.getImageData(i, j, 2, 2);
        let data = imageData.data
        if (data[0] == 255 && data[1] == 0 && data[2] == 0) {
          textCoordinateList.current.push({
            x: i,
            y: j,
          })
        }
      }
    }
  }, [width, height])

  /** 设置点到达坐标*/
  const setArrivalCoordinate = useCallback(() => {
    for (let i = 0; i < allDot.current.length; i++) {
      let x = 0;
      let y = 0;
      if (map.current.has(i)) {
        x = textCoordinateList.current[map.current.get(i)].x;
        y = textCoordinateList.current[map.current.get(i)].y;
      } else {
        x = createRandomNum(0, width)
        y = createRandomNum(0, height)
      }

      allDot.current[i] = {
        ...allDot.current[i],
        toX: x,
        toY: y,
        speedX: ((x - allDot.current[i].x) / 2000 * 17),
        speedY: ((y - allDot.current[i].y) / 2000 * 17),
        isArrive: false,
      }
    }
  }, [width, height])

  /** 动画*/
  const onScatter = useCallback(() => {
    setLiteralCoordinate()
    createMap()
    setArrivalCoordinate()
    drawMove()
  }, [height, width, drawMove, setLiteralCoordinate, setArrivalCoordinate])

  /**  创建映射关系*/
  const createMap = useCallback(() => {
    map.current.clear()
    var numbers = [];
    for (var i = 0; i < allDot.current.length; i++) {
      numbers.push(i);
    }
    var randomNumbers = [];
    for (var j = 0; j < textCoordinateList.current.length; j++) {
      var randomIndex = createRandomNum(0, numbers.length - 1)
      randomNumbers.push(numbers[randomIndex]);
      map.current.set(numbers[randomIndex], j);
      numbers.splice(randomIndex, 1);
    }
  }, [])


  /** 视口大小变化*/
  const onReSize = useCallback(() => {
    let { height, width } = frameDom.current.getBoundingClientRect();
    setHeight(height)
    setWidth(width)
  }, [])

  /** 初始化*/
  useEffect(() => {
    if (canvasDom.current === null) {
      return
    }
    canvasCtx.current = canvasDom.current.getContext('2d')

    /** 初始化*/
    let { height, width } = frameDom.current.getBoundingClientRect();
    setHeight(height)
    setWidth(width)
  }, [])

  useEffect(() => {
    requestAnimationFrame(() => {
      for (let i = 0; i < dotTotal.current; i++) {
        let x = createRandomNum(0, width)
        let y = createRandomNum(0, height)
        let color = getRandomColor()
        pointPlot(x, y, color)
        allDot.current[i] = {
          x, y, color,
          toX: 0,
          toY: 0,
          speedX: 0,
          speedY: 0,
          isArrive: false,
        }
      }
      onScatter()
    })
  }, [onScatter, height, width])

  useEffect(() => {
    window.addEventListener('resize', onReSize)
    return () => {
      window.removeEventListener('resize', onReSize)
    }
  }, [onReSize])

  return (
    <>
      <div
        ref={frameDom}
        onClick={onScatter}

        style={{
          position: 'relative',
          height: '100vh',
          width: '100%',
          backgroundColor: "black"
        }}>
        <canvas
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            zIndex: 2,
          }}
          ref={canvasDom} width={width} height={height}></canvas>
      </div>
    </>
  )
}

结语

感兴趣的可以去试试

相关推荐
qq_390161774 分钟前
防抖函数--应用场景及示例
前端·javascript
3345543232 分钟前
element动态表头合并表格
开发语言·javascript·ecmascript
John.liu_Test34 分钟前
js下载excel示例demo
前端·javascript·excel
Yaml41 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
PleaSure乐事1 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
哟哟耶耶1 小时前
js-将JavaScript对象或值转换为JSON字符串 JSON.stringify(this.SelectDataListCourse)
前端·javascript·json
getaxiosluo1 小时前
react jsx基本语法,脚手架,父子传参,refs等详解
前端·vue.js·react.js·前端框架·hook·jsx
理想不理想v1 小时前
vue种ref跟reactive的区别?
前端·javascript·vue.js·webpack·前端框架·node.js·ecmascript
知孤云出岫1 小时前
web 渗透学习指南——初学者防入狱篇
前端·网络安全·渗透·web
贩卖纯净水.1 小时前
Chrome调试工具(查看CSS属性)
前端·chrome