StarryBackground.vue 7.14 KB
<template>
  <div class="canvas-box" :style="containerStyle">
    <canvas ref="canvasRef">你的浏览器不支持canvas</canvas>
  </div>
</template>

<script>
import { wxInfo } from '@/utils/tools';

const { isMobile, isWeiXin } = wxInfo();
const isWxMobile = isMobile && isWeiXin;
</script>

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

const props = defineProps({
  // 背景颜色
  bgColor: {
    type: String,
    default: 'white'
  },
  // 背景图片 URL
  bgImage: {
    type: String,
    default: ''
  },
  // 星星数量
  starCount: {
    type: Number,
    default: isWxMobile ? 500 : 500
  },
  // 星星颜色 (RGBA 前缀,例如 '255,255,255')
  starColor: {
    type: String,
    default: '255,255,255'
  },
  // 星星移动速度系数 (值越大移动越快)
  starSpeed: {
    type: Number,
    default: isWxMobile ? 1.5 : 0.1
  },
  // 流星速度 - 水平分量 (负值向左,正值向右)
  meteorVx: {
    type: Number,
    default: isWxMobile ? -10 : -2
  },
  // 流星速度 - 垂直分量 (正值向下)
  meteorVy: {
    type: Number,
    default: isWxMobile ? 10 : 2
  },
  // 流星拖尾长度系数 (值越大尾巴越长)
  meteorTrail: {
    type: Number,
    default: isWxMobile ? 4 : 10
  }
});

const containerStyle = computed(() => {
  const style = {
    backgroundColor: props.bgColor
  };
  if (props.bgImage) {
    style.backgroundImage = `url(${props.bgImage})`;
    style.backgroundSize = 'cover';
    style.backgroundPosition = 'center';
    style.backgroundRepeat = 'no-repeat';
  }
  return style;
});

const canvasRef = ref(null);
let context = null;
let width = 0;
let height = 0;
let stars = [];
let animationFrameId = null;
let meteorTimeoutId = null;
let meteorIndex = -1; // 当前流星的索引

// 初始化星星
const initStars = () => {
  stars = [];
  for (let i = 0; i < props.starCount; i++) {
    stars.push({
      x: Math.round(Math.random() * width),
      y: Math.round(Math.random() * height),
      r: Math.random() * 3,
      ra: Math.random() * 0.01,
      alpha: Math.random(),
      vx: Math.random() * props.starSpeed - props.starSpeed / 2,
      vy: Math.random() * props.starSpeed - props.starSpeed / 2
    });
  }
};

// 渲染函数
const render = () => {
  if (!context) return;

  // 使用 clearRect 代替 fillRect 覆盖,如果需要透明背景或自定义背景色由 CSS 控制
  // 但为了实现星星的拖尾效果或者覆盖上一帧,这里使用 clearRect 清除整个画布
  context.clearRect(0, 0, width, height);

  for (let i = 0; i < props.starCount; i++) {
    const star = stars[i];

    // 流星逻辑
    if (i === meteorIndex) {
      // 检查流星是否超出边界,如果是则重置
      if (star.x < -100 || star.y > height + 100) {
        meteorIndex = -1; // 结束当前流星
      } else {
        star.vx = props.meteorVx;
        star.vy = props.meteorVy;
        context.beginPath();

        // 创建线性渐变来实现淡入淡出和拖尾效果
        const meteorHeadX = star.x + star.vx;
        const meteorHeadY = star.y + star.vy;
        const meteorTailX = star.x + star.vx * props.meteorTrail;
        const meteorTailY = star.y + star.vy * props.meteorTrail;

        const gradient = context.createLinearGradient(meteorHeadX, meteorHeadY, meteorTailX, meteorTailY);
        gradient.addColorStop(0, `rgba(${props.starColor}, 1)`); // 头部最亮
        gradient.addColorStop(0.4, `rgba(${props.starColor}, 0.5)`); // 中间半透明
        gradient.addColorStop(1, `rgba(${props.starColor}, 0)`); // 尾部完全透明

        context.strokeStyle = gradient;
        context.lineWidth = 1; // 细一点
        context.moveTo(meteorHeadX, meteorHeadY);
        context.lineTo(meteorTailX, meteorTailY);
        context.stroke();
        context.closePath();
      }
    }

    // 星星闪烁与移动逻辑
    star.alpha += star.ra;
    if (star.alpha <= 0) {
      star.alpha = 0;
      star.ra = -star.ra;
      star.vx = Math.random() * props.starSpeed - props.starSpeed / 2;
      star.vy = Math.random() * props.starSpeed - props.starSpeed / 2;
    } else if (star.alpha > 1) {
      star.alpha = 1;
      star.ra = -star.ra;
    }

    star.x += star.vx;
    // 边界检查
    if (star.x >= width) {
      star.x = 0;
    } else if (star.x < 0) {
      star.x = width;
      star.vx = Math.random() * props.starSpeed - props.starSpeed / 2;
      star.vy = Math.random() * props.starSpeed - props.starSpeed / 2;
    }

    star.y += star.vy;
    if (star.y >= height) {
      star.y = 0;
      star.vy = Math.random() * props.starSpeed - props.starSpeed / 2;
      star.vx = Math.random() * props.starSpeed - props.starSpeed / 2;
    } else if (star.y < 0) {
      star.y = height;
    }

    // 绘制星星
    context.beginPath();

    // 叠加闪烁效果:使用正弦波在基础 alpha 上叠加快速变化
    // Date.now() 产生时间变化,i 作为相位偏移,* 0.005 控制闪烁频率
    const twinkle = Math.abs(Math.sin(Date.now() * 0.006 + i));
    const finalAlpha = Math.min(1, Math.max(0, star.alpha * (0.5 + 0.5 * twinkle)));

    const bg = context.createRadialGradient(star.x, star.y, 0, star.x, star.y, star.r);
    bg.addColorStop(0, `rgba(${props.starColor}, ${finalAlpha})`);
    bg.addColorStop(1, `rgba(${props.starColor}, 0)`);
    context.fillStyle = bg;
    context.arc(star.x, star.y, star.r, 0, Math.PI * 2, true);
    context.fill();
    context.closePath();
  }

  animationFrameId = requestAnimationFrame(render);
};

// 流星触发逻辑
const triggerMeteor = () => {
  const time = Math.round(Math.random() * 3000 + 33);
  meteorTimeoutId = setTimeout(() => {
    // 随机选择一个星星作为流星的起点,或者随机生成一个位置
    // 为了保证流星从右往左出现,我们从右侧区域随机选点
    meteorIndex = Math.ceil(Math.random() * stars.length);

    // 如果选中了星星,重置它的位置到右上方,以便从右往左飞
    if (stars[meteorIndex]) {
        stars[meteorIndex].x = width + Math.random() * 200; // 屏幕右侧外
        stars[meteorIndex].y = Math.random() * height * 0.5; // 屏幕上半部分
    }

    triggerMeteor();
  }, time);
};

// 窗口大小调整处理
const handleResize = () => {
  if (canvasRef.value) {
    width = window.innerWidth;
    height = window.innerHeight;
    canvasRef.value.width = width;
    canvasRef.value.height = height;
    // 大小改变时可能需要重置星星位置,或者保留。这里选择不重置,只更新边界。
  }
};

onMounted(() => {
  if (canvasRef.value) {
    width = window.innerWidth;
    height = window.innerHeight;
    canvasRef.value.width = width;
    canvasRef.value.height = height;
    context = canvasRef.value.getContext('2d');

    initStars();
    render();
    triggerMeteor();

    window.addEventListener('resize', handleResize);
  }
});

onUnmounted(() => {
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
  }
  if (meteorTimeoutId) {
    clearTimeout(meteorTimeoutId);
  }
  window.removeEventListener('resize', handleResize);
});
</script>

<style scoped>
.canvas-box {
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
  z-index: -1;
  overflow: hidden;
}
</style>