敲代码小人加载动画

5 阅读8分钟

敲代码小人加载动画敲代码小人加载动画敲代码小人加载动画敲代码小人加载动画

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>敲代码小人加载动画</title>
    <style>
      html,
      body {
        margin: 0;
        height: 100%;
        background: #e9e7e2;
      } /* 页面底色(最外层,画布外的区域) */
      body {
        display: flex;
        align-items: center;
        justify-content: center;
        overflow: hidden;
        position: relative;
      }
      .scene-wrap {
        position: relative;
        line-height: 0;
      }
      /* 画布按整数像素放大,保持像素硬边;竖屏占满高度,横屏占满宽度 */
      canvas {
        image-rendering: pixelated;
        image-rendering: crisp-edges;
        height: 100vh;
        width: auto;
        max-width: 100vw;
        display: block;
        position: relative;
        z-index: 1;
      }
      #svg-kb {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        z-index: 0;
        pointer-events: none;
      }
      @media (max-aspect-ratio: 280/392) {
        canvas {
          width: 100vw;
          height: auto;
        }
      }
    </style>
  </head>
  <body>
    <!-- 逻辑分辨率 280x392,和原视频一致;所有坐标都在这个网格里 -->
    <div class="scene-wrap">
      <svg id="svg-kb" viewBox="0 0 280 392">
        <rect id="kb-bg" rx="1" ry="1" fill="#28343b" />
        <rect id="term-bg" rx="3" ry="3" fill="#1a1e26" />
        <rect id="term-bar" rx="3" ry="3" fill="#262b35" />
        <g id="dot0">
          <circle r="2" fill="#ff5f57" />
          <line
            x1="-1"
            y1="-1"
            x2="1"
            y2="1"
            stroke="#4a0000"
            stroke-width="0.7"
          />
          <line
            x1="1"
            y1="-1"
            x2="-1"
            y2="1"
            stroke="#4a0000"
            stroke-width="0.7"
          />
        </g>
        <g id="dot1">
          <circle r="2" fill="#febc2e" />
          <line
            x1="-1"
            y1="0"
            x2="1"
            y2="0"
            stroke="#7a5600"
            stroke-width="0.7"
          />
        </g>
        <g id="dot2">
          <circle r="2" fill="#28c840" />
          <rect
            x="-0.8"
            y="-0.8"
            width="1.6"
            height="1.6"
            fill="none"
            stroke="#0a4d00"
            stroke-width="0.5"
          />
        </g>
      </svg>
      <canvas id="c" width="280" height="392"></canvas>
    </div>
    <script>
      const cv = document.getElementById("c"),
        x = cv.getContext("2d");
      x.imageSmoothingEnabled = false; // 关闭抗锯齿,保证像素清晰

      /* ============================================================
   调色板:改这里换颜色
   ============================================================ */
      const COL = {
        bg: "#e9e7e2", // 背景
        termBg: "#1a1e26", // 终端窗口底色
        termBar: "#262b35", // 终端标题栏
        dotClose: "#ff5f57", // 标题栏关闭按钮(红色)
        dotMin: "#febc2e", // 标题栏最小化按钮(黄色)
        dotMax: "#28c840", // 标题栏全屏按钮(绿色)
        green: "#5fcd5f", // 代码行:绿色
        blue: "#5b9bd5", // 代码行:蓝色
        yellow: "#e5c07b", // 代码行:黄色
        skin: "#DAA57F", // 脸/身体 主色
        hand: "#DAA57F", // 两侧的手 主色
        foot: "#DAA57F", // 脚 主色
        eye: "#222026", // 眼睛
        kbFrame: "#28343b", // 键盘外框
        key: "#84aab1", // 键帽 亮色
        keyD: "#56808b", // 键帽 暗色
      };

      /* ============================================================
   CFG:所有可调的"动作/节奏/尺寸"参数都在这里
   ============================================================ */
      const CFG = {
        cx: 140, // 角色水平中心(画布宽280,所以140=正中)

        // ---- 终端窗口 ----
        term: { x: 100, y: 70, w: 80, h: 60 }, // 位置和大小
        // 代码行:[颜色, 左缩进, 长度],从上到下依次"打字"出现
        lines: [
          ["green", 0, 46],
          ["yellow", 12, 62],
          ["blue", 6, 40],
          ["green", 0, 30],
        ],
        lineMs: 470, // 每行打字耗时(毫秒),越小打字越快
        pauseMs: 850, // 全部打完后的停顿(毫秒),然后清空重来

        // ---- 头部----
        head: {
          faceW: 56,
          faceH: 30,
          y: 140,
        },

        // ---- 眼睛 ----
        eye: {
          w: 5,
          h: 6, // 瞳孔宽/高
          leftX: -15,
          rightX: 10, // 左右眼相对中心的水平位置
          topY: 153, // 瞳孔上沿高度
          blinkMs: 110, // 单次眨眼时长(毫秒)
        },

        // ---- 两侧的手(紧贴脸宽 faceW=56 的两侧;下端向内倾斜 rot 度)----
        hand: { w: 10, h: 10, y: 155, amp: 3, rot: 20 },
        //  w/h = 手的宽高(正方形);y = 手顶部高度
        //  amp = 打字时上下摆动幅度(像素)
        //  rot = 手底部向内侧旋转的角度(度)
        //  手内侧顶角自动取脸的左右边缘 (±faceW/2)
        //  leftX/rightX = 相对中心的水平位置(贴着脸两侧);y = 高度;amp = 打字时上下摆动幅度(像素)

        // ---- 键盘 ----
        kb: { w: 82, h: 18, y: 166 },
        // 三行键盘,键宽5px,间距1px
        // 第一行:5键 + 空格(16px) + 5键
        // 第二行:12键
        // 第三行:11键
        kbRows: [
          { keysL: [6, 6], spaceW: 32, keysR: [6, 6, 6, 6] },
          [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5],
          [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5],
        ],

        // ---- 脚(键盘下方一排)----
        feet: { dx: [-20, -11, 5, 14], w: 6, h: 7, wiggleMs: 300 },
        //  dx = 4只脚相对中心的水平位置;wiggleMs = 抖动速度(越小越快)

        // ---- 整体律动(低头敲击)----
        bob: { ms: 400, headDown: 3, kbUp: 1, pauseScale: 0.35 },
        //  ms = 一次上下的周期;headDown = 头最多下移;kbUp = 键盘最多上移;
        //  pauseScale = 停顿时律动减弱比例
      };

      /* ============================================================
   下面是绘制逻辑,一般不用改
   ============================================================ */
      const x0 = CFG.cx;
      function r(px, py, w, h, c) {
        if (w > 0 && h > 0) {
          x.fillStyle = c;
          x.fillRect(px, py, w, h);
        }
      } // 画一个像素方块

      // 画一只手:以"手的几何中心"为旋转中心,整体旋转 deg 度
      // cx, cy: 手中心的世界坐标
      // side: "L" 左手(底端向内=右、顶端向外=左 → 逆时针,负角度)
      //       "R" 右手(底端向内=左、顶端向外=右 → 顺时针,正角度)
      function drawHand(cx, cy, w, h, deg, side) {
        x.save();
        x.translate(cx, cy);
        x.rotate(((side === "L" ? -1 : 1) * deg * Math.PI) / 180);
        x.fillStyle = COL.hand;
        x.fillRect(-w / 2, -h / 2, w, h);
        x.restore();
      }

      // 终端:画窗口 + 标题栏 + 已"打"出的代码行
      function drawTerminal(typed) {
        const T = CFG.term;
        // 在canvas上挖洞,让SVG圆角背景透过来
        x.clearRect(T.x, T.y, T.w, T.h);
        // 更新SVG终端圆角矩形位置
        const termBg = document.getElementById("term-bg");
        termBg.setAttribute("x", T.x);
        termBg.setAttribute("y", T.y);
        termBg.setAttribute("width", T.w);
        termBg.setAttribute("height", T.h);
        // 标题栏(SVG圆角,与终端背景对齐)
        const termBar = document.getElementById("term-bar");
        termBar.setAttribute("x", T.x);
        termBar.setAttribute("y", T.y);
        termBar.setAttribute("width", T.w);
        termBar.setAttribute("height", 12);
        // 在canvas上挖洞让SVG标题栏透过来
        x.clearRect(T.x, T.y, T.w, 12);
        // 三个圆形按钮:关闭(红,左移1px)、最小化(黄,左移2px)、全屏(绿,左移3px)
        const dotOffsets = [-1, -2, -3];
        for (let i = 0; i < 3; i++) {
          const dot = document.getElementById("dot" + i);
          dot.setAttribute(
            "transform",
            `translate(${T.x + 8 + i * 7 + dotOffsets[i]},${T.y + 6})`,
          );
        }
        let yy = T.y + 18; // 第一行代码的高度
        const maxW = T.w - 16; // 代码行最大宽度(终端宽减去左右边距8+8)
        const maxY = T.y + T.h - 4; // 代码不超过终端底部(留4px边距)
        for (let i = 0; i < CFG.lines.length; i++) {
          if (yy + 3 > maxY) break; // 超出终端底部,不再绘制
          const [colName, ind, full] = CFG.lines[i];
          let w;
          if (i < typed.line)
            w = full; // 这行已打完
          else if (i === typed.line)
            w = Math.max(1, Math.round(full * typed.frac)); // 这行正在打(按比例长出)
          else break; // 后面的还没轮到
          // 限制代码行宽度不超出终端
          const availW = Math.max(1, maxW - ind);
          w = Math.min(w, availW);
          r(T.x + 8 + ind, yy, w, 4, COL[colName]);
          yy += 8; // 行高4 + 行距4 = 每8像素一行
        }
      }

      // 画整个角色 + 终端
      // 参数:b=律动量(0~1)  gx=眼球左右  gy=眼球上下  blink=是否眨眼
      //       hL/hR=左右手偏移  feet=4只脚的抖动量数组  typed=打字进度
      function drawScene(b, gx, gy, blink, hL, hR, feet, typed) {
        r(0, 0, 280, 392, COL.bg); // 清屏
        drawTerminal(typed);

        const hy = Math.round(b * CFG.bob.headDown); // 头随律动下移
        const ky = -Math.round(b * CFG.bob.kbUp); // 键盘随律动上移(与头相向靠拢)
        const H = CFG.head,
          E = CFG.eye,
          HA = CFG.hand,
          K = CFG.kb;

        // --- 头 ---
        r(x0 - H.faceW / 2, H.y + hy, H.faceW, H.faceH, COL.skin);

        // --- 眼睛:往上看时高度增加;眨眼时压成横线 ---
        const eyeH = E.h + (gy < 0 ? Math.round(-gy * 0.4) : 0); // 往上看时眼睛更高
        if (blink) {
          r(x0 + E.leftX + gx, E.topY + gy + 4 + hy, E.w + 1, 2, COL.eye);
          r(x0 + E.rightX + gx, E.topY + gy + 4 + hy, E.w + 1, 2, COL.eye);
        } else {
          r(x0 + E.leftX + gx, E.topY + gy + hy, E.w, eyeH, COL.eye);
          r(x0 + E.rightX + gx, E.topY + gy + hy, E.w, eyeH, COL.eye);
        }

        // --- 脚:先画背景,再画脚;与头同步上下跳动 ---
        const F = CFG.feet,
          fy = K.y + K.h + hy - 6;
        // 脚区域背景:覆盖所有脚的矩形(两侧各加1像素边距,底部加2px)
        r(
          x0 + F.dx[0] - 1,
          fy,
          F.dx[F.dx.length - 1] + F.w - F.dx[0] + 2,
          F.h + 2,
          "#222026",
        );
        F.dx.forEach((d, i) => {
          const dy = feet[i];
          r(x0 + d, fy - dy, F.w, F.h + dy, COL.foot);
        });

        // --- 键盘(SVG圆角背景 + canvas键帽)---
        const kx = x0 - K.w / 2,
          kby = K.y + ky;
        // 在canvas上挖洞,让下面SVG的圆角背景透过来
        x.clearRect(kx, kby, K.w, K.h);
        // 更新SVG圆角矩形位置
        const kbBg = document.getElementById("kb-bg");
        kbBg.setAttribute("x", kx);
        kbBg.setAttribute("y", kby);
        kbBg.setAttribute("width", K.w);
        kbBg.setAttribute("height", K.h);
        const gap = 1; // 键间距1px
        let ry = kby + 2;
        CFG.kbRows.forEach((row, rowIdx) => {
          let rxp = kx + 3 + (rowIdx === 1 ? 2 : 0); // 所有键右移1px,第二行额外右移2px
          if (Array.isArray(row)) {
            // 普通行(第二行键高3px,第三行3px)
            const kh = rowIdx === 1 ? 3 : 3;
            row.forEach((kw, i) => {
              r(rxp, ry, kw - 1, kh, (i + rowIdx) % 3 ? COL.key : COL.keyD);
              rxp += kw + gap;
            });
          } else {
            // 第一行:左侧键 + 空格 + 右侧键(键高4px,比其他行多1px)
            row.keysL.forEach((kw, i) => {
              r(rxp, ry, kw - 1, 4, (i + rowIdx) % 3 ? COL.key : COL.keyD);
              rxp += kw + gap;
            });
            r(rxp + 1, ry, row.spaceW - 1, 3, COL.key); // 空格键(高度3px,右移1px)
            rxp += row.spaceW + gap + 2; // 右侧键向右偏移2px
            row.keysR.forEach((kw, i) => {
              r(rxp, ry, kw - 1, 4, (i + rowIdx + 1) % 3 ? COL.key : COL.keyD);
              rxp += kw + gap;
            });
          }
          ry += rowIdx === 0 ? 6 : 5; // 第一行后间距6px,其余5px
        });

        // --- 两侧的手(最后画,层级高于键盘;紧贴脸两侧;绕手几何中心旋转;打字时上下交替)---
        const faceEdge = H.faceW / 2; // 脸的左右边缘相对中心的距离
        const handCenterY = HA.y + HA.h / 2; // 手中心的 y
        // 手紧贴脸:左手外侧边贴脸左边缘 → 中心 = -faceEdge - w/2;右手同理
        drawHand(
          x0 - faceEdge - HA.w / 2,
          handCenterY + hy + hL,
          HA.w,
          HA.h,
          HA.rot,
          "L",
        );
        drawHand(
          x0 + faceEdge + HA.w / 2,
          handCenterY + hy + hR,
          HA.w,
          HA.h,
          HA.rot,
          "R",
        );
      }

      /* ============================================================
   动画循环:计算每一帧的各种偏移量,再调用 drawScene
   ============================================================ */
      const TYPE_MS = CFG.lines.length * CFG.lineMs; // 全部打完所需时间
      const LOOP_MS = TYPE_MS + CFG.pauseMs; // 一个完整循环时长
      let start = null;
      let gx = 0,
        gy = 0; // 眼睛当前左右/上下偏移
      const EYE_LOOP = 5000; // 眼睛动画循环时长(毫秒)
      // 眼睛循环阶段定义:[起始ms, 结束ms, gy目标, gx目标]
      const EYE_PHASES = [
        [0, 3000, 2, 0], // 往下看3秒
        [3000, 3400, -8, -6], // 往左上看
        [3400, 3800, -8, 6], // 往右上看
        [3800, 4200, -8, -6], // 往左上看
        [4200, 4600, -8, 6], // 往右上看
        [4600, 5000, 2, 0], // 往下看
      ];
      // 眨眼时刻(循环内):往下看期间1500ms眨一次
      const BLINK_AT = 1500;

      function frame(t) {
        if (start === null) {
          start = t;
        }
        const e = (t - start) % LOOP_MS; // 当前在循环里的位置(毫秒)

        // 打字进度
        let typed,
          typing = e < TYPE_MS;
        if (typing) {
          const line = Math.floor(e / CFG.lineMs);
          typed = { line, frac: (e - line * CFG.lineMs) / CFG.lineMs };
        } else typed = { line: CFG.lines.length, frac: 0 };

        // 律动 b:0→1→0 平滑往复;停顿时减弱
        const phase = (t % CFG.bob.ms) / CFG.bob.ms;
        let b = (1 - Math.cos(phase * 2 * Math.PI)) / 2;
        if (!typing) b *= CFG.bob.pauseScale;

        // 两手:用正弦相位,左右反相 → 交替上下
        const hp = Math.sin((t / CFG.bob.ms) * Math.PI * 2),
          amp = typing ? CFG.hand.amp : 1;
        const hL = Math.round(-hp * amp),
          hR = Math.round(hp * amp);

        // 脚:每只脚用不同相位的正弦,超过阈值就抬1像素
        const feet = CFG.feet.dx.map((_, i) =>
          Math.sin(t / CFG.feet.wiggleMs + i * 1.7) > 0.4 ? 1 : 0,
        );

        // 眼睛:按固定循环阶段移动
        const eyeT = (t - start) % EYE_LOOP;
        let gxT = 0,
          gyT = 2;
        for (const [s, e, gy, gx] of EYE_PHASES) {
          if (eyeT >= s && eyeT < e) {
            gxT = gx;
            gyT = gy;
            break;
          }
        }
        // 平滑过渡
        gx += (gxT - gx) * 0.15;
        gy += (gyT - gy) * 0.15;
        // 眨眼:在往下看阶段的指定时刻眨一次
        const blink = eyeT >= BLINK_AT && eyeT < BLINK_AT + CFG.eye.blinkMs;

        drawScene(
          b,
          Math.round(gx),
          Math.round(gy),
          blink,
          hL,
          hR,
          feet,
          typed,
        );
        requestAnimationFrame(frame);
      }

      // 若系统开启"减弱动态效果",则只画一帧静态画面
      if (
        window.matchMedia &&
        window.matchMedia("(prefers-reduced-motion: reduce)").matches
      ) {
        drawScene(0, 0, 0, false, 0, 0, [0, 0, 0, 0], {
          line: CFG.lines.length,
          frac: 0,
        });
      } else {
        requestAnimationFrame(frame);
      }
    </script>
  </body>
</html>