<!-- TentacleEffect.vue -->
<template>
  <canvas ref="canvas"></canvas>
</template>

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

export default {
  name: 'TentacleEffect',
  props: {
    // 触手数量
    tentacleCount: {
      type: Number,
      default: 500
    },
    // 最大长度
    maxLength: {
      type: Number,
      default: 300
    },
    // 最小长度
    minLength: {
      type: Number,
      default: 50
    },
    // 每个触手的段数
    segmentCount: {
      type: Number,
      default: 30
    },
    // 背景色
    backgroundColor: {
      type: String,
      default: 'rgb(30,30,30)'
    },
    // 触手基础色调
    baseHue: {
      type: Number,
      default: 180 // 青色
    }
  },

  setup(props) {
    const canvas = ref(null);
    let ctx = null;
    let width = 0;
    let height = 0;
    let animationId = null;
    
    const mouse = { x: false, y: false };
    const last_mouse = {};
    const target = { x: 0, y: 0 };
    const last_target = {};
    let t = 0;
    const q = 10;
    let tent = [];

    class Segment {
      constructor(parent, l, a, first) {
        this.first = first;
        if (first) {
          this.pos = {
            x: parent.x,
            y: parent.y
          };
        } else {
          this.pos = {
            x: parent.nextPos.x,
            y: parent.nextPos.y
          };
        }
        this.l = l;
        this.ang = a;
        this.nextPos = {
          x: this.pos.x + this.l * Math.cos(this.ang),
          y: this.pos.y + this.l * Math.sin(this.ang)
        };
      }

      update(t) {
        this.ang = Math.atan2(t.y - this.pos.y, t.x - this.pos.x);
        this.pos.x = t.x + this.l * Math.cos(this.ang - Math.PI);
        this.pos.y = t.y + this.l * Math.sin(this.ang - Math.PI);
        this.nextPos.x = this.pos.x + this.l * Math.cos(this.ang);
        this.nextPos.y = this.pos.y + this.l * Math.sin(this.ang);
      }

      fallback(t) {
        this.pos.x = t.x;
        this.pos.y = t.y;
        this.nextPos.x = this.pos.x + this.l * Math.cos(this.ang);
        this.nextPos.y = this.pos.y + this.l * Math.sin(this.ang);
      }

      show() {
        ctx.lineTo(this.nextPos.x, this.nextPos.y);
      }
    }

    class Tentacle {
      constructor(x, y, l, n, a) {
        this.x = x;
        this.y = y;
        this.l = l;
        this.n = n;
        this.rand = Math.random();
        this.segments = [new Segment(this, this.l / this.n, 0, true)];
        for (let i = 1; i < this.n; i++) {
          this.segments.push(
            new Segment(this.segments[i - 1], this.l / this.n, 0, false)
          );
        }
      }

      move(last_target, target) {
        this.angle = Math.atan2(target.y - this.y, target.x - this.x);
        this.dt = dist(last_target.x, last_target.y, target.x, target.y) + 5;
        this.t = {
          x: target.x - 0.8 * this.dt * Math.cos(this.angle),
          y: target.y - 0.8 * this.dt * Math.sin(this.angle)
        };
        
        if (this.t.x) {
          this.segments[this.n - 1].update(this.t);
        } else {
          this.segments[this.n - 1].update(target);
        }
        
        for (let i = this.n - 2; i >= 0; i--) {
          this.segments[i].update(this.segments[i + 1].pos);
        }
        
        if (dist(this.x, this.y, target.x, target.y) <= this.l + dist(last_target.x, last_target.y, target.x, target.y)) {
          this.segments[0].fallback({ x: this.x, y: this.y });
          for (let i = 1; i < this.n; i++) {
            this.segments[i].fallback(this.segments[i - 1].nextPos);
          }
        }
      }

      show(target) {
        if (dist(this.x, this.y, target.x, target.y) <= this.l) {
          ctx.globalCompositeOperation = "color-dodge";
          ctx.beginPath();
          ctx.lineTo(this.x, this.y);
          for (let i = 0; i < this.n; i++) {
            this.segments[i].show();
          }
          ctx.strokeStyle = `hsl(${this.rand * 60 + props.baseHue},100%,${this.rand * 60 + 25}%)`;
          ctx.lineWidth = this.rand * 2;
          ctx.lineCap = "round";
          ctx.lineJoin = "round";
          ctx.stroke();
          ctx.globalCompositeOperation = "source-over";
        }
      }

      show2(target) {
        ctx.beginPath();
        if (dist(this.x, this.y, target.x, target.y) <= this.l) {
          ctx.arc(this.x, this.y, 2 * this.rand + 1, 0, 2 * Math.PI);
          ctx.fillStyle = "white";
        } else {
          ctx.arc(this.x, this.y, this.rand * 2, 0, 2 * Math.PI);
          ctx.fillStyle = "darkcyan";
        }
        ctx.fill();
      }
    }

    function dist(p1x, p1y, p2x, p2y) {
      return Math.sqrt(Math.pow(p2x - p1x, 2) + Math.pow(p2y - p1y, 2));
    }

    function initTentacles() {
      tent = [];
      for (let i = 0; i < props.tentacleCount; i++) {
        tent.push(
          new Tentacle(
            Math.random() * width,
            Math.random() * height,
            Math.random() * (props.maxLength - props.minLength) + props.minLength,
            props.segmentCount,
            Math.random() * 2 * Math.PI
          )
        );
      }
    }

    function draw() {
      if (mouse.x) {
        target.errx = mouse.x - target.x;
        target.erry = mouse.y - target.y;
      } else {
        target.errx = width/2 + (height/2 - q) * Math.sqrt(2) * Math.cos(t) / (Math.pow(Math.sin(t), 2) + 1) - target.x;
        target.erry = height/2 + (height/2 - q) * Math.sqrt(2) * Math.cos(t) * Math.sin(t) / (Math.pow(Math.sin(t), 2) + 1) - target.y;
      }

      target.x += target.errx / 10;
      target.y += target.erry / 10;
      t += 0.01;

      ctx.beginPath();
      ctx.arc(target.x, target.y, dist(last_target.x, last_target.y, target.x, target.y) + 5, 0, 2 * Math.PI);
      ctx.fillStyle = "hsl(210,100%,80%)";
      ctx.fill();

      tent.forEach(t => {
        t.move(last_target, target);
        t.show2(target);
      });
      
      tent.forEach(t => {
        t.show(target);
      });

      last_target.x = target.x;
      last_target.y = target.y;
    }

    function animate() {
      ctx.clearRect(0, 0, width, height);
      draw();
      animationId = requestAnimationFrame(animate);
    }

    onMounted(() => {
      const canvasEl = canvas.value;
      ctx = canvasEl.getContext('2d');
      width = canvasEl.width = window.innerWidth;
      height = canvasEl.height = window.innerHeight;

      // 设置背景色
      document.body.style.backgroundColor = props.backgroundColor;

      initTentacles();
      animate();

      // 事件监听
      canvasEl.addEventListener('mousemove', (e) => {
        last_mouse.x = mouse.x;
        last_mouse.y = mouse.y;
        mouse.x = e.pageX - canvasEl.offsetLeft;
        mouse.y = e.pageY - canvasEl.offsetTop;
      });

      canvasEl.addEventListener('mouseleave', () => {
        mouse.x = false;
        mouse.y = false;
      });

      window.addEventListener('resize', () => {
        width = canvasEl.width = window.innerWidth;
        height = canvasEl.height = window.innerHeight;
        initTentacles();
      });
    });

    onBeforeUnmount(() => {
      if (animationId) {
        cancelAnimationFrame(animationId);
      }
    });

    return {
      canvas
    };
  }
};
</script>

<style scoped>
canvas {
  display: block;
}
</style>

使用示例:

<template>
  <TentacleEffect
    :tentacleCount="400"
    :maxLength="250"
    :minLength="40"
    :segmentCount="25"
    backgroundColor="rgb(20,20,20)"
    :baseHue="200"
  />
</template>

<script>
import TentacleEffect from './components/TentacleEffect.vue'

export default {
  components: {
    TentacleEffect
  }
}
</script>

参数说明:

  • tentacleCount: 触手的数量

  • maxLength: 触手最大长度

  • minLength: 触手最小长度

  • segmentCount: 每个触手的段数

  • backgroundColor: 背景颜色

  • baseHue: 触手的基础色调值(0-360)

这个组件提供了良好的封装和可配置性,你可以通过调整这些参数来实现不同的视觉效果。触手会对鼠标移动做出反应,当鼠标离开时会自动进行波浪运动。

注意事项:

  1. 性能考虑:触手数量会影响性能,建议根据实际情况调整

  2. 响应式:组件会自动适应窗口大小变化

  3. 清理:组件会在卸载时自动清理动画帧