一颗会动的爱心

Published on
/
/趣玩前端
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>一颗会动的爱心</title>
    <style>
      body {
        margin: 0;
        padding: 0;
        overflow: hidden;
        background-color: #000;
        font-family: "Archivo Black", sans-serif;
        color: white;
      }

      #c {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        z-index: 1;
      }

      #controls-container {
        position: fixed;
        top: 20px;
        left: 20px;
        z-index: 100;
      }

      #toggle-controls {
        background: rgba(0, 0, 0, 0.7);
        color: white;
        border: none;
        padding: 10px;
        border-radius: 5px;
        cursor: pointer;
        margin-bottom: 5px;
        backdrop-filter: blur(5px);
      }

      #controls {
        background: rgba(100, 100, 100, 0.2);
        padding: 15px;
        border-radius: 10px;
        max-width: 300px;
        backdrop-filter: blur(5px);
        display: block;
      }

      .control-group {
        margin: 10px 0;
        display: flex;
        align-items: center;
      }

      .control-group label {
        width: 100px;
        font-size: 14px;
        margin-right: 10px;
      }

      .control-group input[type="range"] {
        flex-grow: 1;
        margin-right: 10px;
      }

      .control-group span {
        width: 30px;
        text-align: right;
        font-size: 14px;
      }

      .control-group select {
        flex-grow: 1;
        padding: 5px;
        border-radius: 4px;
        background: #333;
        color: white;
        border: none;
      }

      .heart-outline {
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        width: 360px;
        height: 320px;
        fill: none;
        stroke: rgba(255, 255, 255, 0.3);
        stroke-width: 2;
        pointer-events: none;
        z-index: 10;
      }

      #intro-controls {
        margin-top: 20px;
        padding: 20px;
        border-radius: 10px;
        background-color: rgba(0, 0, 0, 0.5);
      }

      #intro-controls h2 {
        font-size: 1.5em;
        margin-bottom: 10px;
      }

      #intro-controls ul {
        list-style-type: none;
        padding: 0;
      }

      #intro-controls li {
        margin-bottom: 5px;
        font-size: 1em;
      }

      #toggle-fullscreen {
        position: fixed;
        top: 20px;
        right: 20px;
        background: rgba(0, 0, 0, 0.7);
        color: white;
        border: none;
        padding: 10px;
        border-radius: 5px;
        cursor: pointer;
        z-index: 100;
        backdrop-filter: blur(5px);
      }

      @media (max-width: 600px) {
        #controls-container {
          top: 10px;
          left: 10px;
          right: 10px;
          max-width: none;
        }

        .control-group label {
          width: 80px;
          font-size: 12px;
        }
      }
    </style>
  </head>
  <body>
    <div id="controls-container">
      <button id="toggle-controls">控制面板</button>
      <button id="toggle-fullscreen">全屏</button>
      <div id="controls">
        <div class="control-group">
          <label for="colorScheme">颜色:</label>
          <select id="colorScheme">
            <option value="rainbow">彩虹</option>
            <option value="red">红色</option>
            <option value="green">绿色</option>
            <option value="blue">蓝色</option>
            <option value="monochrome">单色</option>
          </select>
        </div>
        <div class="control-group">
          <label for="particleSize">尺寸:</label>
          <input type="range" id="particleSize" min="1" max="20" value="10" />
          <span id="particleSizeValue">10</span>
        </div>
        <div class="control-group">
          <label for="particleCount">粒子数:</label>
          <input
            type="range"
            id="particleCount"
            min="10"
            max="100"
            value="32"
          />
          <span id="particleCountValue">32</span>
        </div>
        <div class="control-group">
          <label for="speed">速度:</label>
          <input
            type="range"
            id="speed"
            min="0.1"
            max="2"
            step="0.1"
            value="1"
          />
          <span id="speedValue">1</span>
        </div>
        <div class="control-group">
          <label for="mouseInfluence">鼠标跟随:</label>
          <input
            type="range"
            id="mouseInfluence"
            min="0"
            max="100"
            value="50"
          />
          <span id="mouseInfluenceValue">50</span>
        </div>
      </div>
    </div>

    <canvas id="c"></canvas>
    <script>
      // Configuration
      const config = {
        particleCount: 32,
        speed: 1,
        colorScheme: "rainbow",
        mouseInfluence: 50,
        showHeartOutline: true,
        particleSize: 10,
      };

      // Canvas setup
      const canvas = document.getElementById("c");
      const ctx = canvas.getContext("2d");
      let canvasWidth = (canvas.width = window.innerWidth);
      let canvasHeight = (canvas.height = window.innerHeight);

      // Animation state
      let trails = [];
      let heartPath = [];
      let mouseX = canvasWidth / 2;
      let mouseY = canvasHeight / 2;
      let mouseActive = false;
      let animationRunning = false;

      // Initialize heart path points (fixed formula)
      function initHeartPath() {
        heartPath = [];
        const PI2 = 6.28318; // 2*PI approximation
        const steps = Math.max(32, config.particleCount);

        for (let i = 0; i < steps; i++) {
          const t = (i / steps) * PI2;
          heartPath.push([
            canvasWidth / 2 + 180 * Math.pow(Math.sin(t), 3),
            canvasHeight / 2 +
              10 *
                -(
                  15 * Math.cos(t) -
                  5 * Math.cos(2 * t) -
                  2 * Math.cos(3 * t) -
                  Math.cos(4 * t)
                ),
          ]);
        }
      }

      // Initialize particles with proper bounds checking
      function initParticles() {
        trails = [];
        if (heartPath.length === 0) initHeartPath();

        for (let i = 0; i < config.particleCount; i++) {
          const particles = [];
          const x = Math.random() * canvasWidth;
          const y = Math.random() * canvasHeight;

          for (let k = 0; k < config.particleCount; k++) {
            // Color generation
            let hue,
              saturation = Math.random() * 40 + 60;
            let brightness = Math.random() * 60 + 20;

            switch (config.colorScheme) {
              case "red":
                hue = Math.random() * 20 + 350;
                break;
              case "blue":
                hue = Math.random() * 20 + 200;
                break;
              case "green":
                hue = Math.random() * 20 + 100;
                break;
              case "monochrome":
                hue = 0;
                saturation = 0;
                break;
              default:
                hue = (i / config.particleCount) * 360; // rainbow
            }

            particles.push({
              x,
              y,
              velX: 0,
              velY: 0,
              radius:
                ((1 - k / config.particleCount + 1) * config.particleSize) / 2,
              speed: Math.random() + 1,
              targetIndex: Math.floor(Math.random() * heartPath.length),
              direction: (i % 2) * 2 - 1,
              friction: Math.random() * 0.2 + 0.7,
              color: `hsla(${hue},${saturation}%,${brightness}%,0.1)`,
            });
          }
          trails.push(particles);
        }
      }

      // Render a single particle
      function renderParticle(particle) {
        ctx.fillStyle = particle.color;
        ctx.beginPath();
        ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
        ctx.fill();
      }

      // Main animation loop with robust error handling
      function animationLoop() {
        if (!animationRunning) return; // Stop the animation if not running

        try {
          // Clear with trail effect
          ctx.fillStyle = "rgba(0, 0, 0, 0.2)";
          ctx.fillRect(0, 0, canvasWidth, canvasHeight);

          trails.forEach((trail) => {
            if (!trail || !trail.length) return;

            const leader = trail[0];
            const target = heartPath[leader.targetIndex % heartPath.length];
            if (!target) return;

            // Mouse influence
            if (mouseActive && config.mouseInfluence > 0) {
              const dx = mouseX - leader.x;
              const dy = mouseY - leader.y;
              const dist = Math.sqrt(dx * dx + dy * dy);

              if (dist < 300) {
                const force = (1 - dist / 300) * (config.mouseInfluence / 20);
                leader.velX += (dx / dist) * force;
                leader.velY += (dy / dist) * force;
              }
            }

            // Move toward target
            const dx = leader.x - target[0];
            const dy = leader.y - target[1];
            const dist = Math.sqrt(dx * dx + dy * dy);

            if (dist < 10) {
              if (Math.random() > 0.95) {
                leader.targetIndex = Math.floor(
                  Math.random() * heartPath.length
                );
              } else {
                if (Math.random() > 0.99) leader.direction *= -1;
                leader.targetIndex += leader.direction;
                leader.targetIndex =
                  (leader.targetIndex + heartPath.length) % heartPath.length;
              }
            }

            // Update physics
            leader.velX += (-dx / dist) * leader.speed * config.speed;
            leader.velY += (-dy / dist) * leader.speed * config.speed;
            leader.x += leader.velX;
            leader.y += leader.velY;
            leader.velX *= leader.friction;
            leader.velY *= leader.friction;

            // Render trail
            renderParticle(leader);
            for (let k = 1; k < trail.length; k++) {
              trail[k].x -= (trail[k].x - trail[k - 1].x) * 0.7;
              trail[k].y -= (trail[k].y - trail[k - 1].y) * 0.7;
              renderParticle(trail[k]);
            }
          });
        } catch (error) {
          console.error("Animation error:", error);
        }

        requestAnimationFrame(animationLoop);
      }

      // Control handlers
      function setupControls() {
        const controls = document.getElementById("controls");
        let controlsVisible = true;

        document
          .getElementById("toggle-controls")
          .addEventListener("click", () => {
            controlsVisible = !controlsVisible;
            controls.style.display = controlsVisible ? "block" : "none";
          });

        document
          .getElementById("toggle-fullscreen")
          .addEventListener("click", () => {
            if (!document.fullscreenElement) {
              document.documentElement.requestFullscreen();
            } else {
              if (document.exitFullscreen) {
                document.exitFullscreen();
              }
            }
          });

        const updateParticles = () => {
          config.particleCount = Math.min(
            100,
            Math.max(
              10,
              parseInt(document.getElementById("particleCount").value)
            )
          );
          document.getElementById("particleCountValue").textContent =
            config.particleCount;
          initHeartPath();
          initParticles();
        };

        document
          .getElementById("particleCount")
          .addEventListener("input", updateParticles);
        document.getElementById("speed").addEventListener("input", (e) => {
          config.speed = parseFloat(e.target.value);
          document.getElementById("speedValue").textContent = config.speed;
        });
        document
          .getElementById("colorScheme")
          .addEventListener("change", (e) => {
            config.colorScheme = e.target.value;
            initParticles();
          });
        document
          .getElementById("mouseInfluence")
          .addEventListener("input", (e) => {
            config.mouseInfluence = parseInt(e.target.value);
            document.getElementById("mouseInfluenceValue").textContent =
              config.mouseInfluence;
          });
        document
          .getElementById("particleSize")
          .addEventListener("input", (e) => {
            config.particleSize = parseInt(e.target.value);
            document.getElementById("particleSizeValue").textContent =
              config.particleSize;
            initParticles();
          });

        // Mouse tracking
        document.addEventListener("mousemove", (e) => {
          mouseX = e.clientX;
          mouseY = e.clientY;
          mouseActive = true;
        });
        document.addEventListener("mouseleave", () => (mouseActive = false));

        // Window resize
        window.addEventListener("resize", () => {
          canvasWidth = canvas.width = window.innerWidth;
          canvasHeight = canvas.height = window.innerHeight;
          initHeartPath();
        });
      }

      // Initialize everything
      function init() {
        const controls = document.getElementById("controls");
        const canvas = document.getElementById("c");

        controls.style.display = "block";
        canvas.style.display = "block";
        animationRunning = true;
        setupControls();
        animationLoop(); // Start the animation loop

        // Initialize everything
        initHeartPath();
        initParticles();
      }

      // Start the animation
      init();
    </script>
  </body>
</html>