Skip to content

粒子时钟实现

Triabin

1294字约4分钟

2024-07-26

粒子时钟实现方案

1、预期效果

  • 网页刷新后,首先生成一定数量的粒子,随机分布在一个环形上
  • 粒子迅速按照当前时间的数字为轨迹移动到指定位置上排列
  • 粒子的排布随着时间的变化而变化

2、实现思路

  • 创建一个画布,在画布上按照需求画出粒子,起始粒子所处的环形直径为画布高度

  • 将每一个粒子当做一个对象,为确定其位置,设计属性

    ① 粒子本身属性:x坐标、y坐标、粒子半径(粒子大小随机)

    ② 粒子(圆心)所处位置

    ③ 起始位置形成的环的半径(画布高度的一半),粒子圆心坐标到环圆心坐标直线形成的夹角角度(随机,同时也为了确定起始位置)

    ④ 为类本身设计draw和moveTo两个方法,分别用于在画布上绘画和移动粒子

  • 获取当前时间字符串,在画布上按照所需字体、大小使用黑色画出当前时间,然后在画布上找出颜色为黑色的像素点坐标,记录这些坐标,然后清空画布

    Tips:不一定要获取所有坐标,因为粒子密度可以根据需求减小,因此可以设置一个步长来跳过一些坐标

  • 在环形上动态调整所需(粒子)坐标数量,然后调用粒子本身的draw函数绘制出粒子,然后调用其moveTo函数移动到上一步获取到的坐标上去,不但重复这一过程。

3、代码实现(vue3环境,封装了组件)

<!-- 动态时钟组件 -->
<template>
  <canvas ref="canvasRef"></canvas>
</template>

<script setup>
import { ref, onMounted, defineProps } from 'vue';

const canvasRef = ref(null);
const ctx = ref(null);

// 父组件数据
const props = defineProps({
  // 粒子颜色
  color: {
    type: String,
    default: "#5445544D"
  },

  // 字体
  font: {
    type: String,
    default: 'sans-serif'
  },

  // 字体大小
  fontSize: {
    type: Number,
    default: 240
  },

  // 粒子密度
  density: {
    type: Number,
    default: 6
  }
});

onMounted(async () => {
  canvasRef.value.width = window.innerWidth * devicePixelRatio;
  canvasRef.value.height = window.innerHeight * devicePixelRatio;
  draw();
});

// 获取[min,max]之间的随机整数
const getRandom = (min, max) => Math.floor(Math.random() * (max - min + 1) + min);

// 时钟数字由一个个粒子构成,将每一个粒子当做一个对象,创建粒子对象
class Particle {
  constructor() {
    // 粒子半径
    this.r = getRandom(2 * devicePixelRatio, 7 * devicePixelRatio);
    // 环半径
    const ar = Math.min(canvasRef.value.width, canvasRef.value.height) / 2;
    // 粒子初始位置圆心与环圆心形成的夹角(随机)
    const rad = getRandom(0, 360) * Math.PI / 180; // 角度转换为弧度
    // 环圆心横坐标
    const ax = canvasRef.value.width / 2;
    // 环圆心纵坐标
    const ay = canvasRef.value.height / 2;
    // 粒子本身横坐标
    this.x = ax + ar * Math.cos(rad);
    // 粒子本身纵坐标
    this.y = ay + ar * Math.sin(rad);
  }

  // 绘制粒子
  draw() {
    ctx.value.beginPath();
    ctx.value.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
    ctx.value.fillStyle = props.color || '#5445544D';
    ctx.value.fill();
  }

  // 移动粒子
  moveTo(tx, ty) {
    const duration = 500; // 500ms运动时间
    // 设置起始坐标
    const sx = this.x, sy = this.y;
    // 计算横纵向速度
    const xSpeed = (tx - sx) / duration;
    const ySpeed = (ty - sy) / duration;
    // 记录开始运动时间
    const startTime = new Date();
    const _move = () => {
      const t = Date.now() - startTime; // 当前已运动时间
      const currX = sx + xSpeed * t; // 当前x坐标 = 起始x坐标 + x坐标速度 * 当前已运动时间
      const currY = sy + ySpeed * t; // 当前y坐标 = 起始y坐标 + y坐标速度 * 当前已运动时间
      this.x = currX;
      this.y = currY;
      // 停止条件
      if (t >= duration) {
        this.x = tx;
        this.y = ty;
        return;
      }
      requestAnimationFrame(_move);
    }
    _move();
  }
}

// 用于存放粒子的数组
const particles = [];

// 清空画布函数
const clear = () => ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);

// 重画粒子函数(每次移动都清空画布再重画,消除轨迹)
const draw = () => {
  if (!canvasRef.value) return;
  ctx.value = canvasRef.value.getContext('2d', { willReadFrequently: true });
  // 1. 清空画布
  clear();
  // 2. 更新粒子数量和坐标
  update();
  // 3. 画出每一个粒子
  for (let particle of particles) {
    particle.draw();
  }
  requestAnimationFrame(draw);
}

// 获取当前时间文字
const getTimeText = () => new Date().toTimeString().substring(0, 8);

// 更新画布粒子
let curTimeText = undefined;
const update = () => {
  // 判断差异,避免无意义更新
  const text = getTimeText();
  if (text === curTimeText) {
    return;
  }
  curTimeText = text;

  // 画文字
  const { width, height } = canvasRef.value;
  ctx.value.fillStyle = '#000';
  ctx.value.textBaseline = 'middle';
  ctx.value.font = `${props.fontSize * devicePixelRatio}px 'DS-Digital', ${props.font}`;
  ctx.value.textAlign = 'center';
  ctx.value.fillText(text, width / 2, height / 2);

  // 获取像素信息
  const points = getPoints();
  // 获取像素点后清空画布,重新通过粒子画出时间文字
  clear();
  for (let i = 0; i < points.length; i++) {
    const [x, y] = points[i];
    let p = particles[i];
    if (!p) {
      p = new Particle();
      particles.push(p);
    }
    p.moveTo(x, y);
  }
  if (points.length < particles.length) {
    particles.splice(points.length);
  }
};

// 从画布获取将要画的文字坐标
const getPoints = () => {
  const points = [];
  const { data } = ctx.value.getImageData(0, 0, canvasRef.value.width, canvasRef.value.height);
  const gap = props.density; // 设置步长,不需要获取所有像素点(步长越大,生成的粒子数量越小)
  for (let i = 0; i < canvasRef.value.width; i += gap) {
    for (let j = 0; j < canvasRef.value.height; j += gap) {
      const index = (i + j * canvasRef.value.width) * 4; // 每连续的四位为一个像素点的信息
      const r = data[index];
      const g = data[index + 1];
      const b = data[index + 2];
      const a = data[index + 3];
      // 判断是否为黑色像素,为黑色像素则为所需坐标
      if (r === 0 && g === 0 && b === 0 && a === 255) {
        points.push([i, j]);
      }
    }
  }
  return points;
}

</script>

<style lang="scss" scoped>
canvas {
  /*background: radial-gradient(#FFF, #8C738C);*/
  background: transparent;
  display: block;
  width: 35vw;
  height: 35vh;
}
</style>

4、实现效果

<Clock color="#283747AF" font="sans-serif" :fontSize="320" :density="6"/>

dynamicClock