HTML 动效效果

7 阅读11分钟
  • 纯css版
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>光标绕屏旋转 · 纯CSS3</title>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      :root {
        --pad: 32px;
        --dur: 6s;
        --size: 16px;
      }

      body {
        width: 100vw;
        height: 100vh;
        background: #05050f;
        overflow: hidden;
        cursor: none;
        display: flex;
        align-items: center;
        justify-content: center;
      }

      /* ───── 背景星点 ───── */
      .stars {
        position: fixed;
        inset: 0;
        pointer-events: none;
      }
      .stars::before,
      .stars::after {
        content: "";
        position: absolute;
        inset: 0;
        background-image:
          radial-gradient(
            1px 1px at 10% 15%,
            rgba(255, 255, 255, 0.6) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 25% 60%,
            rgba(200, 180, 255, 0.5) 0%,
            transparent 100%
          ),
          radial-gradient(
            1.5px 1.5px at 40% 30%,
            rgba(255, 255, 255, 0.4) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 55% 80%,
            rgba(180, 200, 255, 0.6) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 70% 20%,
            rgba(255, 255, 255, 0.5) 0%,
            transparent 100%
          ),
          radial-gradient(
            1.5px 1.5px at 80% 55%,
            rgba(200, 180, 255, 0.4) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 90% 75%,
            rgba(255, 255, 255, 0.6) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 15% 85%,
            rgba(180, 220, 255, 0.5) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 35% 45%,
            rgba(255, 255, 255, 0.3) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 65% 10%,
            rgba(200, 180, 255, 0.5) 0%,
            transparent 100%
          ),
          radial-gradient(
            1.5px 1.5px at 50% 50%,
            rgba(255, 255, 255, 0.3) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 5% 40%,
            rgba(180, 200, 255, 0.4) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 95% 35%,
            rgba(255, 255, 255, 0.5) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 45% 95%,
            rgba(200, 180, 255, 0.4) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 75% 90%,
            rgba(180, 220, 255, 0.5) 0%,
            transparent 100%
          );
        animation: twinkle 4s ease-in-out infinite alternate;
      }
      .stars::after {
        background-image:
          radial-gradient(
            1px 1px at 20% 25%,
            rgba(255, 255, 255, 0.4) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 60% 40%,
            rgba(200, 180, 255, 0.5) 0%,
            transparent 100%
          ),
          radial-gradient(
            1.5px 1.5px at 85% 15%,
            rgba(255, 255, 255, 0.6) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 30% 70%,
            rgba(180, 200, 255, 0.4) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 72% 65%,
            rgba(255, 255, 255, 0.5) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 8% 55%,
            rgba(200, 180, 255, 0.4) 0%,
            transparent 100%
          ),
          radial-gradient(
            1px 1px at 48% 8%,
            rgba(180, 220, 255, 0.5) 0%,
            transparent 100%
          ),
          radial-gradient(
            1.5px 1.5px at 92% 88%,
            rgba(255, 255, 255, 0.4) 0%,
            transparent 100%
          );
        animation-delay: 2s;
        animation-duration: 5s;
      }
      @keyframes twinkle {
        0% {
          opacity: 0.6;
        }
        100% {
          opacity: 1;
        }
      }

      /* ───── 轨道虚线边框 ───── */
      .track {
        position: fixed;
        inset: var(--pad);
        border-radius: 20px;
        border: 1px dashed rgba(167, 139, 250, 0.15);
        box-shadow:
          0 0 40px rgba(167, 139, 250, 0.04) inset,
          0 0 40px rgba(167, 139, 250, 0.04);
        pointer-events: none;
      }

      /* ───── 中央文字 ───── */
      .center {
        text-align: center;
        user-select: none;
        pointer-events: none;
        position: relative;
        z-index: 1;
      }
      .center h1 {
        font-family: "Segoe UI", system-ui, sans-serif;
        font-size: clamp(2.5rem, 6vw, 5rem);
        font-weight: 800;
        letter-spacing: 0.12em;
        background: linear-gradient(135deg, #c084fc, #818cf8, #34d399);
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
        background-clip: text;
        animation: title-pulse 3s ease-in-out infinite alternate;
      }
      .center p {
        margin-top: 0.6rem;
        font-family: "Segoe UI", system-ui, sans-serif;
        font-size: 0.85rem;
        letter-spacing: 0.35em;
        text-transform: uppercase;
        color: rgba(255, 255, 255, 0.2);
      }
      @keyframes title-pulse {
        from {
          filter: brightness(1);
        }
        to {
          filter: brightness(1.3);
        }
      }

      /* ══════════════════════════════════════════
       纯 CSS3 矩形路径动画核心
       原理:
         将光标放在屏幕中心,
         用 translate + offset-path(CSS Motion Path)
         让它沿矩形边缘运动;
         降级方案:用四段 translate 分段 animation
       ══════════════════════════════════════════ */

      /* ── 方案:offset-path 矩形路径(现代浏览器支持) ── */
      .cursor-wrap {
        position: fixed;
        /* 从左上角 padding 处开始 */
        top: 0;
        left: 0;
        width: 0;
        height: 0;
        pointer-events: none;
        z-index: 100;
      }

      /* 光标本体 */
      .cursor {
        position: fixed;
        width: var(--size);
        height: var(--size);
        border-radius: 50%;
        pointer-events: none;
        /* offset-path 在动画中设置 */
        offset-rotate: auto;
        transform-origin: center center;
        animation: orbit var(--dur) linear infinite;
      }

      /* ── offset-path 矩形 ──
       calc() 不能直接用 vw/vh 在 offset-path 里,
       所以用 CSS @property + 百分比技巧,
       或者直接给一个足够大的矩形并用 scale 适配。
       最兼容的方式:用四段 keyframe 分段 translate。
    */

      /* ───── 四段 translate 方案(100% 纯CSS,无JS,兼容性好) ───── */
      /*
      思路:让光标 fixed 定位,用 4 段动画模拟矩形四条边:
      top: pad → right: pad → bottom: pad → left: pad
      为避免 left/top 百分比问题,改用 transform: translate
      以屏幕左上角为基点。
    */

      .cursor {
        /* 重置 offset-path,改用 transform */
        offset-path: none;
        top: var(--pad);
        left: var(--pad);
        background: radial-gradient(circle at 35% 35%, #e0c8ff, #818cf8);
        box-shadow:
          0 0 12px 4px rgba(192, 132, 252, 0.8),
          0 0 30px 10px rgba(129, 140, 248, 0.4);
        animation:
          orbit-pos var(--dur) linear infinite,
          cursor-glow var(--dur) linear infinite,
          cursor-scale 1.5s ease-in-out infinite alternate;
      }

      /* 位置动画:沿矩形四边运动
       利用 left/top/right/bottom 切换模拟矩形路径
       但 CSS 不能直接 animate left+top 同时变化来走对角,
       所以用 transform translate(x, y) 四段绝对坐标。

       关键帧百分比对应四边:
         0%  → 25%  : 顶边  left→right  (top 固定 = pad)
         25% → 50%  : 右边  top→bottom  (right 固定 = pad)
         50% → 75%  : 底边  right→left  (bottom 固定 = pad)
         75% → 100% : 左边  bottom→top  (left 固定 = pad)

       由于 fixed + left/top 无法同时动画两个方向,
       使用单一 translate(x, y) 从原点(pad, pad)出发。
    */
      @keyframes orbit-pos {
        /*  顶边:从左上 → 右上  */
        0% {
          transform: translate(0px, 0px) rotate(0deg) scaleX(1.6);
        }
        24.9% {
          transform: translate(calc(100vw - 2 * var(--pad) - var(--size)), 0px)
            rotate(0deg) scaleX(1.6);
        }
        /*  右边:从右上 → 右下  */
        25% {
          transform: translate(calc(100vw - 2 * var(--pad) - var(--size)), 0px)
            rotate(90deg) scaleX(1.6);
        }
        49.9% {
          transform: translate(
              calc(100vw - 2 * var(--pad) - var(--size)),
              calc(100vh - 2 * var(--pad) - var(--size))
            )
            rotate(90deg) scaleX(1.6);
        }
        /*  底边:从右下 → 左下  */
        50% {
          transform: translate(
              calc(100vw - 2 * var(--pad) - var(--size)),
              calc(100vh - 2 * var(--pad) - var(--size))
            )
            rotate(180deg) scaleX(1.6);
        }
        74.9% {
          transform: translate(0px, calc(100vh - 2 * var(--pad) - var(--size)))
            rotate(180deg) scaleX(1.6);
        }
        /*  左边:从左下 → 左上  */
        75% {
          transform: translate(0px, calc(100vh - 2 * var(--pad) - var(--size)))
            rotate(270deg) scaleX(1.6);
        }
        100% {
          transform: translate(0px, 0px) rotate(360deg) scaleX(1.6);
        }
      }

      /* 光标颜色随时间变化 */
      @keyframes cursor-glow {
        0% {
          background: radial-gradient(circle at 35% 35%, #f0e0ff, #a78bfa);
          box-shadow:
            0 0 14px 5px rgba(167, 139, 250, 0.9),
            0 0 35px 12px rgba(139, 92, 246, 0.4);
        }
        25% {
          background: radial-gradient(circle at 35% 35%, #bfdbfe, #60a5fa);
          box-shadow:
            0 0 14px 5px rgba(96, 165, 250, 0.9),
            0 0 35px 12px rgba(59, 130, 246, 0.4);
        }
        50% {
          background: radial-gradient(circle at 35% 35%, #a7f3d0, #34d399);
          box-shadow:
            0 0 14px 5px rgba(52, 211, 153, 0.9),
            0 0 35px 12px rgba(16, 185, 129, 0.4);
        }
        75% {
          background: radial-gradient(circle at 35% 35%, #fde68a, #f59e0b);
          box-shadow:
            0 0 14px 5px rgba(245, 158, 11, 0.9),
            0 0 35px 12px rgba(217, 119, 6, 0.4);
        }
        100% {
          background: radial-gradient(circle at 35% 35%, #f0e0ff, #a78bfa);
          box-shadow:
            0 0 14px 5px rgba(167, 139, 250, 0.9),
            0 0 35px 12px rgba(139, 92, 246, 0.4);
        }
      }

      @keyframes cursor-scale {
        from {
          transform-origin: center;
        }
        /* scale 由 orbit-pos 中的 scaleX 控制 */
      }

      /* ───── 拖尾:用多个延迟副本叠加 ───── */
      .trail {
        position: fixed;
        top: var(--pad);
        left: var(--pad);
        border-radius: 50%;
        pointer-events: none;
        z-index: 99;
      }

      /* 生成 8 条拖尾,每条都是相同路径但有延迟,尺寸/透明度递减 */
      .trail-1 {
        width: 14px;
        height: 14px;
        opacity: 0.55;
        animation: orbit-pos var(--dur) linear infinite;
        animation-delay: calc(-1 * var(--dur) / 60);
        background: rgba(167, 139, 250, 0.6);
        box-shadow: 0 0 10px 3px rgba(167, 139, 250, 0.4);
      }
      .trail-2 {
        width: 12px;
        height: 12px;
        opacity: 0.45;
        animation: orbit-pos var(--dur) linear infinite;
        animation-delay: calc(-2 * var(--dur) / 60);
        background: rgba(139, 114, 240, 0.5);
        box-shadow: 0 0 8px 3px rgba(139, 114, 240, 0.3);
      }
      .trail-3 {
        width: 10px;
        height: 10px;
        opacity: 0.35;
        animation: orbit-pos var(--dur) linear infinite;
        animation-delay: calc(-3 * var(--dur) / 60);
        background: rgba(120, 100, 220, 0.4);
      }
      .trail-4 {
        width: 9px;
        height: 9px;
        opacity: 0.27;
        animation: orbit-pos var(--dur) linear infinite;
        animation-delay: calc(-4 * var(--dur) / 60);
        background: rgba(100, 130, 240, 0.4);
      }
      .trail-5 {
        width: 7px;
        height: 7px;
        opacity: 0.2;
        animation: orbit-pos var(--dur) linear infinite;
        animation-delay: calc(-5 * var(--dur) / 60);
        background: rgba(80, 160, 220, 0.35);
      }
      .trail-6 {
        width: 6px;
        height: 6px;
        opacity: 0.14;
        animation: orbit-pos var(--dur) linear infinite;
        animation-delay: calc(-7 * var(--dur) / 60);
        background: rgba(60, 180, 200, 0.3);
      }
      .trail-7 {
        width: 4px;
        height: 4px;
        opacity: 0.1;
        animation: orbit-pos var(--dur) linear infinite;
        animation-delay: calc(-9 * var(--dur) / 60);
        background: rgba(50, 200, 180, 0.25);
      }
      .trail-8 {
        width: 3px;
        height: 3px;
        opacity: 0.07;
        animation: orbit-pos var(--dur) linear infinite;
        animation-delay: calc(-12 * var(--dur) / 60);
        background: rgba(40, 210, 160, 0.2);
      }

      /* ───── 运动轨迹残影光晕(四角固定装饰) ───── */
      .corner-glow {
        position: fixed;
        width: 120px;
        height: 120px;
        border-radius: 50%;
        pointer-events: none;
        z-index: 0;
        filter: blur(40px);
        animation: corner-pulse 3s ease-in-out infinite alternate;
      }
      .corner-glow.tl {
        top: 0;
        left: 0;
        background: rgba(167, 139, 250, 0.12);
        animation-delay: 0s;
      }
      .corner-glow.tr {
        top: 0;
        right: 0;
        background: rgba(96, 165, 250, 0.1);
        animation-delay: 0.75s;
      }
      .corner-glow.br {
        bottom: 0;
        right: 0;
        background: rgba(52, 211, 153, 0.1);
        animation-delay: 1.5s;
      }
      .corner-glow.bl {
        bottom: 0;
        left: 0;
        background: rgba(245, 158, 11, 0.1);
        animation-delay: 2.25s;
      }

      @keyframes corner-pulse {
        from {
          opacity: 0.4;
          transform: scale(0.8);
        }
        to {
          opacity: 1;
          transform: scale(1.2);
        }
      }

      /* ───── 底部提示文字 ───── */
      .hint {
        position: fixed;
        bottom: 18px;
        left: 50%;
        transform: translateX(-50%);
        color: rgba(255, 255, 255, 0.12);
        font-family: "Segoe UI", system-ui, sans-serif;
        font-size: 0.65rem;
        letter-spacing: 0.2em;
        text-transform: uppercase;
        pointer-events: none;
        white-space: nowrap;
        animation: hint-fade 2s ease-in-out infinite alternate;
      }
      @keyframes hint-fade {
        from {
          opacity: 0.4;
        }
        to {
          opacity: 0.9;
        }
      }
    </style>
  </head>
  <body>
    <!-- 背景星点 -->
    <div class="stars"></div>

    <!-- 四角光晕 -->
    <div class="corner-glow tl"></div>
    <div class="corner-glow tr"></div>
    <div class="corner-glow br"></div>
    <div class="corner-glow bl"></div>

    <!-- 轨道边框 -->
    <div class="track"></div>

    <!-- 中央文字 -->
    <div class="center">
      <h1>ORBIT</h1>
      <p>Pure CSS3 · No JavaScript</p>
    </div>

    <!-- 拖尾(越靠后延迟越大,越小越透明) -->
    <div class="trail trail-8"></div>
    <div class="trail trail-7"></div>
    <div class="trail trail-6"></div>
    <div class="trail trail-5"></div>
    <div class="trail trail-4"></div>
    <div class="trail trail-3"></div>
    <div class="trail trail-2"></div>
    <div class="trail trail-1"></div>

    <!-- 主光标(最后渲染,在最上层) -->
    <div class="cursor"></div>

    <!-- 底部提示 -->
    <div class="hint">Pure CSS3 Animation · cursor orbiting the screen</div>
  </body>
</html>

  • JS 实现版
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>光标围绕屏幕旋转动效</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      width: 100vw;
      height: 100vh;
      background: #0a0a0f;
      overflow: hidden;
      cursor: none;
      font-family: 'Segoe UI', sans-serif;
      display: flex;
      align-items: center;
      justify-content: center;
    }

    /* 中央文字 */
    .center-text {
      text-align: center;
      user-select: none;
      pointer-events: none;
      z-index: 1;
    }

    .center-text h1 {
      font-size: clamp(2rem, 5vw, 4rem);
      font-weight: 700;
      background: linear-gradient(135deg, #a78bfa, #60a5fa, #34d399);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
      background-clip: text;
      letter-spacing: 0.05em;
      margin-bottom: 0.5rem;
    }

    .center-text p {
      color: rgba(255,255,255,0.3);
      font-size: 1rem;
      letter-spacing: 0.2em;
      text-transform: uppercase;
    }

    /* 边框轨道发光 */
    .border-glow {
      position: fixed;
      inset: 0;
      pointer-events: none;
      z-index: 0;
    }

    .border-glow::before {
      content: '';
      position: absolute;
      inset: 12px;
      border-radius: 16px;
      border: 1px solid rgba(167, 139, 250, 0.08);
      box-shadow:
        inset 0 0 60px rgba(167,139,250,0.03),
        0 0 60px rgba(167,139,250,0.03);
    }

    /* 主光标 */
    #cursor {
      position: fixed;
      width: 18px;
      height: 18px;
      border-radius: 50%;
      pointer-events: none;
      z-index: 1000;
      transform: translate(-50%, -50%);
      mix-blend-mode: screen;
    }

    /* 拖尾粒子容器 */
    #trail-canvas {
      position: fixed;
      inset: 0;
      pointer-events: none;
      z-index: 999;
    }

    /* 轨道路径指示(可选装饰) */
    #path-canvas {
      position: fixed;
      inset: 0;
      pointer-events: none;
      z-index: 2;
    }

    /* 背景粒子 */
    #bg-canvas {
      position: fixed;
      inset: 0;
      pointer-events: none;
      z-index: 0;
    }

    /* 计数/角度显示 */
    .info {
      position: fixed;
      bottom: 20px;
      left: 50%;
      transform: translateX(-50%);
      color: rgba(255,255,255,0.15);
      font-size: 0.7rem;
      letter-spacing: 0.15em;
      text-transform: uppercase;
      pointer-events: none;
      z-index: 10;
    }
  </style>
</head>
<body>

  <canvas id="bg-canvas"></canvas>
  <canvas id="path-canvas"></canvas>
  <canvas id="trail-canvas"></canvas>

  <div class="border-glow"></div>

  <div class="center-text">
    <h1>ORBIT</h1>
    <p>Cursor Animation</p>
  </div>

  <div id="cursor"></div>
  <div class="info" id="info">angle: 0°</div>

<script>
(() => {
  /* ─── 配置 ─── */
  const CFG = {
    padding: 48,          // 离屏幕边缘的距离
    speed: 0.8,           // 每帧角度增量(度)
    trailLength: 60,      // 拖尾点数
    trailFade: 0.92,      // 拖尾衰减
    bgParticles: 80,      // 背景星点数量
  };

  /* ─── 工具 ─── */
  const $ = id => document.getElementById(id);

  /* ─── Canvas 初始化 ─── */
  const trailCanvas  = $('trail-canvas');
  const pathCanvas   = $('path-canvas');
  const bgCanvas     = $('bg-canvas');
  const trailCtx     = trailCanvas.getContext('2d');
  const pathCtx      = pathCanvas.getContext('2d');
  const bgCtx        = bgCanvas.getContext('2d');

  function resize() {
    [trailCanvas, pathCanvas, bgCanvas].forEach(c => {
      c.width  = window.innerWidth;
      c.height = window.innerHeight;
    });
    initBgParticles();
    drawPath();
  }
  window.addEventListener('resize', resize);

  /* ─── 路径计算:矩形跑道(圆角) ─── */
  // 返回给定 t∈[0,1) 时沿矩形路径的 {x, y, angle(tangent)}
  function getRectPoint(t) {
    const pad = CFG.padding;
    const W   = window.innerWidth;
    const H   = window.innerHeight;
    const x0  = pad, y0 = pad;
    const x1  = W - pad, y1 = H - pad;
    const w   = x1 - x0, h = y1 - y0;
    const perimeter = 2 * (w + h);
    const dist = t * perimeter;

    // 四段:top, right, bottom, left
    if (dist <= w) {
      return { x: x0 + dist, y: y0, tx: 1, ty: 0 };
    } else if (dist <= w + h) {
      const d = dist - w;
      return { x: x1, y: y0 + d, tx: 0, ty: 1 };
    } else if (dist <= 2*w + h) {
      const d = dist - w - h;
      return { x: x1 - d, y: y1, tx: -1, ty: 0 };
    } else {
      const d = dist - 2*w - h;
      return { x: x0, y: y1 - d, tx: 0, ty: -1 };
    }
  }

  /* ─── 绘制路径(淡虚线) ─── */
  function drawPath() {
    const ctx = pathCtx;
    ctx.clearRect(0, 0, pathCanvas.width, pathCanvas.height);
    ctx.save();
    ctx.setLineDash([6, 14]);
    ctx.lineWidth = 1;
    ctx.strokeStyle = 'rgba(167,139,250,0.10)';
    ctx.beginPath();
    const steps = 300;
    for (let i = 0; i <= steps; i++) {
      const pt = getRectPoint(i / steps);
      i === 0 ? ctx.moveTo(pt.x, pt.y) : ctx.lineTo(pt.x, pt.y);
    }
    ctx.closePath();
    ctx.stroke();
    ctx.restore();
  }

  /* ─── 背景粒子 ─── */
  let bgParticles = [];
  function initBgParticles() {
    bgParticles = Array.from({ length: CFG.bgParticles }, () => ({
      x: Math.random() * window.innerWidth,
      y: Math.random() * window.innerHeight,
      r: Math.random() * 1.5 + 0.3,
      a: Math.random(),
      speed: Math.random() * 0.003 + 0.001,
      phase: Math.random() * Math.PI * 2,
    }));
  }

  function drawBg(time) {
    const ctx = bgCtx;
    ctx.clearRect(0, 0, bgCanvas.width, bgCanvas.height);
    bgParticles.forEach(p => {
      const alpha = p.a * (0.4 + 0.6 * Math.sin(time * p.speed + p.phase));
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(167,139,250,${alpha})`;
      ctx.fill();
    });
  }

  /* ─── 光标拖尾 ─── */
  let trail = [];

  function addTrailPoint(x, y, t) {
    trail.push({ x, y, t });
    if (trail.length > CFG.trailLength) trail.shift();
  }

  function drawTrail() {
    const ctx = trailCtx;
    ctx.clearRect(0, 0, trailCanvas.width, trailCanvas.height);

    for (let i = 1; i < trail.length; i++) {
      const ratio   = i / trail.length;
      const alpha   = ratio * ratio;
      const width   = ratio * 6 + 0.5;

      // 渐变色:紫 → 蓝 → 青
      const hue = 260 + ratio * 80;
      ctx.save();
      ctx.beginPath();
      ctx.moveTo(trail[i - 1].x, trail[i - 1].y);
      ctx.lineTo(trail[i].x, trail[i].y);
      ctx.strokeStyle = `hsla(${hue}, 90%, 70%, ${alpha})`;
      ctx.lineWidth   = width;
      ctx.lineCap     = 'round';
      ctx.shadowColor = `hsla(${hue}, 100%, 70%, ${alpha * 0.6})`;
      ctx.shadowBlur  = 12;
      ctx.stroke();
      ctx.restore();
    }

    // 发光头部
    if (trail.length > 0) {
      const head = trail[trail.length - 1];
      ctx.save();
      const grad = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 18);
      grad.addColorStop(0, 'rgba(200,180,255,0.9)');
      grad.addColorStop(0.4, 'rgba(120,160,255,0.5)');
      grad.addColorStop(1, 'rgba(100,220,200,0)');
      ctx.beginPath();
      ctx.arc(head.x, head.y, 18, 0, Math.PI * 2);
      ctx.fillStyle = grad;
      ctx.fill();
      ctx.restore();
    }
  }

  /* ─── 光标 DOM 元素 ─── */
  const cursorEl = $('cursor');
  const infoEl   = $('info');

  function updateCursorEl(x, y, tx, ty) {
    // 旋转方向
    const angle = Math.atan2(ty, tx) * 180 / Math.PI;
    cursorEl.style.left = x + 'px';
    cursorEl.style.top  = y + 'px';

    // 动态渐变跟随方向
    const hue = (360 * progress + performance.now() * 0.05) % 360;
    cursorEl.style.background =
      `radial-gradient(circle at 40% 40%, hsl(${hue},100%,85%), hsl(${(hue+60)%360},90%,55%))`;
    cursorEl.style.boxShadow =
      `0 0 20px 6px hsl(${hue},100%,65%), 0 0 40px 12px hsl(${(hue+60)%360},80%,40%)`;
    cursorEl.style.transform =
      `translate(-50%, -50%) rotate(${angle}deg) scaleX(1.5)`;
  }

  /* ─── 主动画循环 ─── */
  let progress   = 0; // [0, 1)
  let lastTime   = null;

  function tick(time) {
    if (!lastTime) lastTime = time;
    const dt = Math.min(time - lastTime, 50); // 最大 50ms 防跳帧
    lastTime = time;

    // 推进进度
    const W = window.innerWidth, H = window.innerHeight;
    const perimeter = 2 * (W - 2*CFG.padding + H - 2*CFG.padding);
    const pixelsPerFrame = (CFG.speed / 100) * perimeter * (dt / 16.67);
    progress += pixelsPerFrame / perimeter;
    if (progress >= 1) progress -= 1;

    const pt = getRectPoint(progress);

    // 更新光标
    updateCursorEl(pt.x, pt.y, pt.tx, pt.ty);
    addTrailPoint(pt.x, pt.y, time);

    // 绘制
    drawBg(time);
    drawTrail();

    // 角度信息
    const deg = Math.round(progress * 360);
    infoEl.textContent = `progress: ${Math.round(progress * 100)}%`;

    requestAnimationFrame(tick);
  }

  /* ─── 启动 ─── */
  resize();
  requestAnimationFrame(tick);

  /* ─── 滚轮调速 ─── */
  window.addEventListener('wheel', e => {
    CFG.speed = Math.max(0.2, Math.min(5, CFG.speed + (e.deltaY > 0 ? 0.1 : -0.1)));
  });

  /* ─── 点击加速burst ─── */
  window.addEventListener('click', () => {
    const orig = CFG.speed;
    CFG.speed = Math.min(8, orig * 3);
    setTimeout(() => { CFG.speed = orig; }, 600);
  });

})();
</script>
</body>
</html>